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. 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/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(); + }; +} 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 }} >