From 9741b6e08939e6d3fd4fb98ec9ac77701544da1d Mon Sep 17 00:00:00 2001 From: nuu-maan Date: Thu, 3 Sep 2026 00:22:43 +0530 Subject: [PATCH 1/3] Expand the Linux HUD only while a menu is open Linux has no hover-driven mouse passthrough, so the HUD lives in a compact 160px window and its popover menus were clipped. The earlier attempt to grow the window on hover (shipped in 1.3.3) made the bar jump away from the pointer on Wayland, because Hyprland re-centres a floating window that resizes itself and the bar was anchored to the window bottom. Grow the window only while a popover is open, and on Wayland anchor the bar to the window centre so it stays put through the resize. --- electron/electron-env.d.ts | 2 ++ electron/hudOverlayBounds.ts | 15 +++++++++++++++ electron/preload.ts | 3 +++ electron/windows.ts | 10 ++++++++++ src/components/launch/LaunchWindow.tsx | 9 +++++++-- .../launch/hooks/useLaunchHudInteractionState.ts | 1 + .../launch/hooks/useLaunchWindowSystemState.ts | 5 +++++ 7 files changed, 43 insertions(+), 2 deletions(-) diff --git a/electron/electron-env.d.ts b/electron/electron-env.d.ts index c00ef3226..87e708bda 100644 --- a/electron/electron-env.d.ts +++ b/electron/electron-env.d.ts @@ -198,6 +198,7 @@ interface RendererNativeExportCapabilities { interface Window { electronAPI: { hudOverlaySetIgnoreMouse: (ignore: boolean) => void; + hudOverlaySetMenuOpen: (open: boolean) => void; hudOverlaySetSourceSelectionActive: (active: boolean) => void; hudOverlayDrag: (phase: "start" | "move" | "end", screenX: number, screenY: number) => void; hudOverlayHide: () => void; @@ -208,6 +209,7 @@ interface Window { getHudOverlayMousePassthroughSupported: () => Promise<{ success: boolean; supported: boolean; + resizeAnchor?: "bottom" | "center"; }>; setHudOverlayCaptureProtection: ( enabled: boolean, diff --git a/electron/hudOverlayBounds.ts b/electron/hudOverlayBounds.ts index 8c51b88c7..3ff89c8f9 100644 --- a/electron/hudOverlayBounds.ts +++ b/electron/hudOverlayBounds.ts @@ -38,6 +38,21 @@ export function getHudOverlayWindowBounds( }; } +export type HudOverlayResizeAnchor = "bottom" | "center"; + +// Wayland refuses client-side window placement, so the compositor decides where +// a resized HUD lands. Hyprland keeps floating windows centered while X11 honors +// the bottom-anchored bounds this module computes. +export function getHudOverlayResizeAnchor( + platform: NodeJS.Platform, + env: NodeJS.ProcessEnv, +): HudOverlayResizeAnchor { + if (platform !== "linux") { + return "bottom"; + } + return env.XDG_SESSION_TYPE === "wayland" || env.WAYLAND_DISPLAY ? "center" : "bottom"; +} + export function shouldExpandHudOverlayFallback({ fallbackExpanded, recordingActive, diff --git a/electron/preload.ts b/electron/preload.ts index a30372314..fced9567d 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -167,6 +167,9 @@ contextBridge.exposeInMainWorld("electronAPI", { hudOverlaySetIgnoreMouse: (ignore: boolean) => { ipcRenderer.send("hud-overlay-set-ignore-mouse", ignore); }, + hudOverlaySetMenuOpen: (open: boolean) => { + ipcRenderer.send("hud-overlay-set-menu-open", open); + }, hudOverlaySetSourceSelectionActive: (active: boolean) => { ipcRenderer.send("hud-overlay-set-source-selection-active", active); }, diff --git a/electron/windows.ts b/electron/windows.ts index bb3d6d071..4c3029096 100644 --- a/electron/windows.ts +++ b/electron/windows.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; import { app, BrowserWindow, ipcMain } from "electron"; import { USER_DATA_PATH } from "./appPaths"; import { + getHudOverlayResizeAnchor, getHudOverlayWindowBounds, resizeHudOverlayFallbackBounds, shouldExpandHudOverlayFallback, @@ -306,6 +307,14 @@ ipcMain.on("hud-overlay-set-ignore-mouse", (_event, ignore: boolean) => { setHudOverlayMousePassthrough(Boolean(ignore)); }); +// Linux has no hover-driven passthrough, so the compact HUD window only grows +// while a menu is open; growing on hover moves the bar out from under the pointer. +ipcMain.on("hud-overlay-set-menu-open", (_event, open: boolean) => { + if (process.platform === "linux") { + setHudOverlayFallbackExpanded(Boolean(open)); + } +}); + ipcMain.on("hud-overlay-set-source-selection-active", (_event, active: boolean) => { hudOverlaySourceSelectionActive = Boolean(active); if (hudOverlaySourceSelectionActive) { @@ -393,6 +402,7 @@ ipcMain.handle("get-hud-overlay-mouse-passthrough-supported", () => { return { success: true, supported: isHudOverlayMousePassthroughSupported(), + resizeAnchor: getHudOverlayResizeAnchor(process.platform, process.env), }; }); diff --git a/src/components/launch/LaunchWindow.tsx b/src/components/launch/LaunchWindow.tsx index cb43ead1b..b37bc92d4 100644 --- a/src/components/launch/LaunchWindow.tsx +++ b/src/components/launch/LaunchWindow.tsx @@ -108,6 +108,7 @@ function LaunchWindowContent() { const { hudOverlayMousePassthroughSupported, + hudOverlayResizeAnchor, platform, appVersion, hideHudFromCapture, @@ -445,8 +446,12 @@ function LaunchWindowContent() { value={{ onMouseEnter: handleHudMouseEnter, onMouseLeave: handleHudMouseLeave }} >
(null); useEffect(() => { + window.electronAPI?.hudOverlaySetMenuOpen?.(openId !== null); if (openId !== null) { if (timeoutRef.current) clearTimeout(timeoutRef.current); window.electronAPI?.hudOverlaySetIgnoreMouse?.(false); diff --git a/src/components/launch/hooks/useLaunchWindowSystemState.ts b/src/components/launch/hooks/useLaunchWindowSystemState.ts index 56793fa36..f3199967a 100644 --- a/src/components/launch/hooks/useLaunchWindowSystemState.ts +++ b/src/components/launch/hooks/useLaunchWindowSystemState.ts @@ -7,6 +7,9 @@ export function useLaunchWindowSystemState( const [hudOverlayMousePassthroughSupported, setHudOverlayMousePassthroughSupported] = useState< boolean | null >(null); + const [hudOverlayResizeAnchor, setHudOverlayResizeAnchor] = useState<"bottom" | "center">( + "bottom", + ); const [platform, setPlatform] = useState(null); const [appVersion, setAppVersion] = useState(null); const [hideHudFromCapture, setHideHudFromCapture] = useState(true); @@ -54,6 +57,7 @@ export function useLaunchWindowSystemState( const result = await window.electronAPI.getHudOverlayMousePassthroughSupported(); if (!cancelled && result.success) { setHudOverlayMousePassthroughSupported(result.supported); + setHudOverlayResizeAnchor(result.resizeAnchor ?? "bottom"); } } catch (error) { console.error("Failed to load HUD overlay mouse passthrough support:", error); @@ -132,6 +136,7 @@ export function useLaunchWindowSystemState( return { recordingsDirectory, hudOverlayMousePassthroughSupported, + hudOverlayResizeAnchor, platform, appVersion, hideHudFromCapture, From f36efbdf813a9aafa7396d50cc266b43fad83408 Mon Sep 17 00:00:00 2001 From: nuu-maan Date: Thu, 3 Sep 2026 00:22:43 +0530 Subject: [PATCH 2/3] Capture cursor telemetry on Wayland via Hyprland IPC and evdev uiohook only sees XWayland clients, so under a Wayland session it reports no pointer motion or clicks and auto-zoom and click effects have nothing to work with. Poll Hyprland's socket for the pointer position and read mouse buttons from /dev/input devices that advertise BTN_LEFT. --- electron/ipc/cursor/interaction.ts | 8 ++ electron/ipc/cursor/wayland.test.ts | 66 ++++++++++ electron/ipc/cursor/wayland.ts | 179 ++++++++++++++++++++++++++++ 3 files changed, 253 insertions(+) create mode 100644 electron/ipc/cursor/wayland.test.ts create mode 100644 electron/ipc/cursor/wayland.ts diff --git a/electron/ipc/cursor/interaction.ts b/electron/ipc/cursor/interaction.ts index 47c42437f..1765216df 100644 --- a/electron/ipc/cursor/interaction.ts +++ b/electron/ipc/cursor/interaction.ts @@ -24,6 +24,7 @@ import { isCursorCapturePaused, pushCursorSample, } from "./telemetry"; +import { startWaylandInteractionCapture } from "./wayland"; const nodeRequire = createRequire(import.meta.url); @@ -249,6 +250,12 @@ export async function startInteractionCapture() { stopInteractionCapture(); + const stopWaylandCapture = startWaylandInteractionCapture({ + onMouseDown: recordCursorMouseDown, + onMouseUp: recordCursorMouseUp, + }); + setInteractionCaptureCleanup(() => stopWaylandCapture?.()); + try { const hook = loadUiohookModule(); console.log( @@ -296,6 +303,7 @@ export async function startInteractionCapture() { } setInteractionCaptureCleanup(() => { + stopWaylandCapture?.(); try { if (typeof hook.off === "function") { hook.off("mousedown", onMouseDown); diff --git a/electron/ipc/cursor/wayland.test.ts b/electron/ipc/cursor/wayland.test.ts new file mode 100644 index 000000000..5c0676e33 --- /dev/null +++ b/electron/ipc/cursor/wayland.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it, vi } from "vitest"; + +vi.mock("electron", () => ({ + app: { + getPath: vi.fn(() => "/tmp"), + }, +})); +import { + getHyprlandSocketPath, + hasMouseButtonCapability, + isWaylandSession, + parseEvdevButtonEvents, + parseHyprlandCursorPos, +} from "./wayland"; + +function inputEvent(type: number, code: number, value: number): Buffer { + const buffer = Buffer.alloc(24); + buffer.writeUInt16LE(type, 16); + buffer.writeUInt16LE(code, 18); + buffer.writeInt32LE(value, 20); + return buffer; +} + +describe("wayland cursor capture", () => { + it("detects wayland sessions from the environment", () => { + expect(isWaylandSession({ XDG_SESSION_TYPE: "wayland" })).toBe(true); + expect(isWaylandSession({ WAYLAND_DISPLAY: "wayland-1" })).toBe(true); + expect(isWaylandSession({ XDG_SESSION_TYPE: "x11" })).toBe(false); + }); + + it("builds the hyprland socket path", () => { + expect( + getHyprlandSocketPath({ + XDG_RUNTIME_DIR: "/run/user/1000", + HYPRLAND_INSTANCE_SIGNATURE: "abc", + }), + ).toBe("/run/user/1000/hypr/abc/.socket.sock"); + expect(getHyprlandSocketPath({ XDG_RUNTIME_DIR: "/run/user/1000" })).toBeNull(); + }); + + it("parses hyprland cursorpos replies", () => { + expect(parseHyprlandCursorPos('{"x": 960, "y": 553}')).toEqual({ x: 960, y: 553 }); + expect(parseHyprlandCursorPos("unknown request")).toBeNull(); + }); + + it("reads BTN_LEFT from sysfs key capabilities", () => { + expect(hasMouseButtonCapability("1f0000 0 0 0 0")).toBe(true); + expect(hasMouseButtonCapability("ffffffff 0 0 0 0 0 0 0")).toBe(false); + expect(hasMouseButtonCapability("")).toBe(false); + }); + + it("extracts mouse button presses from evdev packets", () => { + const packet = Buffer.concat([ + inputEvent(2, 0, 5), + inputEvent(1, 0x110, 1), + inputEvent(0, 0, 0), + inputEvent(1, 0x111, 0), + inputEvent(1, 0x110, 2), + inputEvent(1, 30, 1), + ]); + expect(parseEvdevButtonEvents(packet)).toEqual([ + { button: 1, pressed: true }, + { button: 2, pressed: false }, + ]); + }); +}); diff --git a/electron/ipc/cursor/wayland.ts b/electron/ipc/cursor/wayland.ts new file mode 100644 index 000000000..440d54da5 --- /dev/null +++ b/electron/ipc/cursor/wayland.ts @@ -0,0 +1,179 @@ +import { createReadStream, readdirSync, readFileSync } from "node:fs"; +import { createConnection } from "node:net"; +import path from "node:path"; +import { CURSOR_SAMPLE_INTERVAL_MS } from "../constants"; +import { setLinuxCursorScreenPoint } from "../state"; +import { getScreen } from "../utils"; + +const EV_KEY = 1; +const BTN_LEFT = 0x110; +const BTN_RIGHT = 0x111; +const BTN_MIDDLE = 0x112; +const INPUT_EVENT_SIZE = 24; + +export function isWaylandSession(env: NodeJS.ProcessEnv = process.env): boolean { + return env.XDG_SESSION_TYPE === "wayland" || Boolean(env.WAYLAND_DISPLAY); +} + +export function getHyprlandSocketPath(env: NodeJS.ProcessEnv = process.env): string | null { + const signature = env.HYPRLAND_INSTANCE_SIGNATURE; + const runtimeDir = env.XDG_RUNTIME_DIR; + if (!signature || !runtimeDir) { + return null; + } + return path.join(runtimeDir, "hypr", signature, ".socket.sock"); +} + +export function parseHyprlandCursorPos(raw: string): { x: number; y: number } | null { + try { + const parsed = JSON.parse(raw) as { x?: unknown; y?: unknown }; + if (typeof parsed.x === "number" && typeof parsed.y === "number") { + return { x: parsed.x, y: parsed.y }; + } + } catch { + // Hyprland replied with an error string instead of JSON. + } + return null; +} + +function requestHyprlandCursorPos(socketPath: string): Promise<{ x: number; y: number } | null> { + return new Promise((resolve) => { + const chunks: Buffer[] = []; + const socket = createConnection(socketPath); + socket.setTimeout(500); + socket.on("connect", () => socket.write("j/cursorpos")); + socket.on("data", (chunk) => chunks.push(Buffer.from(chunk))); + socket.on("close", () => resolve(parseHyprlandCursorPos(Buffer.concat(chunks).toString()))); + socket.on("timeout", () => socket.destroy()); + socket.on("error", () => resolve(null)); + }); +} + +function startHyprlandCursorPolling(): (() => void) | null { + const socketPath = getHyprlandSocketPath(); + if (!socketPath) { + return null; + } + + let inFlight = false; + const timer = setInterval(() => { + if (inFlight) { + return; + } + inFlight = true; + void requestHyprlandCursorPos(socketPath).then((point) => { + inFlight = false; + if (!point) { + return; + } + // Hyprland reports logical layout coordinates; the telemetry cache expects + // physical pixels like the X11 hook provides. + const scale = getScreen().getPrimaryDisplay().scaleFactor || 1; + setLinuxCursorScreenPoint({ + x: point.x * scale, + y: point.y * scale, + updatedAt: Date.now(), + }); + }); + }, CURSOR_SAMPLE_INTERVAL_MS); + + return () => clearInterval(timer); +} + +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 []; + } +} + +function startEvdevButtonCapture(handlers: { + onMouseDown: (button: 1 | 2 | 3) => void; + onMouseUp: () => void; +}): () => void { + const streams = listMouseEventDevices().map((devicePath) => { + let pending = Buffer.alloc(0); + const stream = createReadStream(devicePath); + stream.on("data", (chunk: Buffer) => { + pending = Buffer.concat([pending, chunk]); + const usable = pending.length - (pending.length % INPUT_EVENT_SIZE); + for (const event of parseEvdevButtonEvents(pending.subarray(0, usable))) { + if (event.pressed) { + handlers.onMouseDown(event.button); + } else { + handlers.onMouseUp(); + } + } + pending = pending.subarray(usable); + }); + stream.on("error", () => stream.destroy()); + return stream; + }); + + return () => { + for (const stream of streams) { + stream.destroy(); + } + }; +} + +// ponytail: Hyprland-only pointer position via its IPC socket; other Wayland +// compositors keep falling back to Electron's stale cursor point. +export function startWaylandInteractionCapture(handlers: { + onMouseDown: (button: 1 | 2 | 3) => void; + onMouseUp: () => void; +}): (() => void) | null { + if (process.platform !== "linux" || !isWaylandSession()) { + return null; + } + + const stopPolling = startHyprlandCursorPolling(); + const stopButtons = startEvdevButtonCapture(handlers); + return () => { + stopPolling?.(); + stopButtons(); + }; +} From 21806a6c5c3510c3f9e09880dc9fca16810a0a32 Mon Sep 17 00:00:00 2001 From: nuu-maan Date: Thu, 3 Sep 2026 00:22:43 +0530 Subject: [PATCH 3/3] Document Arch build deps and Hyprland window rules --- README.md | 46 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) diff --git a/README.md b/README.md index 121ef9343..c4086e378 100644 --- a/README.md +++ b/README.md @@ -200,6 +200,15 @@ PKGBUILD, desktop entry, release sync, and optional **local-from-source** packag sudo apt install build-essential cmake libx11-dev libxtst-dev libxrandr-dev libxt-dev ``` +**Linux (Arch / Omarchy):** + +```bash +sudo pacman -S --needed base-devel cmake libx11 libxtst libxrandr libxt +``` + +CMake is only needed for the bundled whisper caption runtime. Without it, install with +`WHISPER_RUNTIME_ALLOW_MISSING=1 npm install`; everything except auto-captions still works. + **Windows:** Visual Studio 2022 (or Build Tools) with the C++ workload and CMake. ### Steps @@ -225,6 +234,43 @@ Target-specific build commands are also available: --- +## Linux: Hyprland / Omarchy + +Recordly's recording HUD, countdown, and source picker are transparent floating windows. +Hyprland decorates them like any other window, so the compositor's blur, shadow, dim, and +opacity rules show up as a grey box around the HUD. Wayland also ignores `alwaysOnTop`, so +the HUD can end up behind other windows or stuck on one workspace. + +Add these rules to `~/.config/hypr/hyprland.lua` (Omarchy) and run `hyprctl reload`: + +```lua +o.window("^[Rr]ecordly$", { tag = "-default-opacity", opacity = "1 1" }) +o.window({ class = "^[Rr]ecordly$", float = true }, { + pin = true, + no_blur = true, + no_shadow = true, + border_size = 0, + no_dim = true, +}) +``` + +Plain `hyprland.conf` equivalent: + +``` +windowrule = opacity 1 1, class:^[Rr]ecordly$ +windowrule = pin, class:^[Rr]ecordly$, floating:1 +windowrule = noblur, class:^[Rr]ecordly$, floating:1 +windowrule = noshadow, class:^[Rr]ecordly$, floating:1 +windowrule = nodim, class:^[Rr]ecordly$, floating:1 +windowrule = bordersize 0, class:^[Rr]ecordly$, floating:1 +``` + +Cursor telemetry (auto-zoom, click effects) on Wayland reads the pointer position from +Hyprland's IPC socket and mouse buttons from `/dev/input`, which requires your user to be in +the `input` group (Omarchy does this by default). + +--- + ## macOS: "App cannot be opened" Locally built apps may be quarantined by macOS.