-
-
Notifications
You must be signed in to change notification settings - Fork 1.9k
Fix HUD menus and cursor telemetry on Linux Wayland (Hyprland/Omarchy) #863
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
sed -n '245,275p' README.mdRepository: webadderallorg/Recordly Length of output: 1023 🌐 Web query:
💡 Result: In Hyprland 0.54, the window rule syntax underwent significant changes [1][2]. The old Citations:
🏁 Script executed: #!/bin/bash
curl -L --fail --silent --show-error \
https://wiki.hypr.land/0.54.0/Configuring/Window-Rules/ |
grep -n -E 'match:class|match:float|no_blur|no_shadow|no_dim|border_size|noblur|noshadow|nodim|bordersize|windowrule' |
head -80Repository: webadderallorg/Recordly Length of output: 8750 Update the plain Hyprland example for Hyprland 0.54. Use 🤖 Prompt for AI Agents |
||
|
|
||
| ``` | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win Add a language identifier to this fence.
🧰 Tools🪛 markdownlint-cli2 (0.23.2)[warning] 259-259: Fenced code blocks should have a language specified (MD040, fenced-code-language) 🤖 Prompt for AI AgentsSource: Linters/SAST tools |
||
| 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). | ||
|
Comment on lines
+269
to
+270
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔒 Security & Privacy | 🟠 Major | ⚡ Quick win 🧩 Analysis chain🏁 Script executed: rg -n -C 8 'startEvdevButtonCapture|/dev/input|input.*group|evdev' electron README.mdRepository: webadderallorg/Recordly Length of output: 4474 Security Misconfiguration (CWE-732): Incorrect Permission Assignment for Critical Resource Reachability: Internal · Exploitability: Moderate Document the broad privilege granted by Recordly opens mouse-capable 🤖 Prompt for AI Agents |
||
|
|
||
| --- | ||
|
|
||
| ## macOS: "App cannot be opened" | ||
|
|
||
| Locally built apps may be quarantined by macOS. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 }, | ||
| ]); | ||
| }); | ||
| }); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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(), | ||
|
Comment on lines
+71
to
+75
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
set -euo pipefail
# Confirm the declared Electron version and inspect every cursor-coordinate conversion.
rg -n -C 2 '"electron"\s*:' --glob 'package.json' --glob 'pnpm-lock.yaml' --glob 'yarn.lock' .
rg -n -C 6 'setLinuxCursorScreenPoint|getNormalizedCursorPoint|getHookCursorScreenPoint|getPrimaryDisplay|getAllDisplays|scaleFactor' electron
# Expected: a mixed-DPI test or mapping path chooses the output for each Hyprland point,
# rather than applying getPrimaryDisplay().scaleFactor to the full virtual layout.Repository: webadderallorg/Recordly Length of output: 30351 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- electron/ipc/cursor/wayland.ts ---'
cat -n electron/ipc/cursor/wayland.ts | sed -n '1,110p'
printf '%s\n' '--- electron/ipc/cursor/telemetry.ts ---'
cat -n electron/ipc/cursor/telemetry.ts | sed -n '160,215p'
printf '%s\n' '--- screen wrapper ---'
rg -n -C 3 'function getScreen|const getScreen|export .*getScreen' electron/ipc electronRepository: webadderallorg/Recordly Length of output: 8843 🌐 Web query:
💡 Result: In Hyprland, the Citations:
🌐 Web query:
💡 Result: In Electron 43, the screen and display information, including bounds and scale factor, are managed through the screen module API [1][2]. Key concepts regarding screen coordinates and scaling include: 1. Coordinate System: Electron uses two types of coordinates [1]. - Device-independent pixel (DIP) points: Virtualized screen points that are scaled based on the display's DPI [1]. The bounds and workArea properties of a Display object are provided in DIPs [3][4]. - Physical screen points: Raw hardware pixels on the display [1]. 2. Scaling: The scaleFactor property of a Display object represents the output device's pixel scale factor [3]. This value is essential for translating between DIPs and physical pixels [1]. 3. Conversion Utilities: Electron provides utility methods to convert between these coordinate systems [1]. On Linux (and Windows), you can use: - screen.dipToScreenPoint(point): Converts a DIP point to a physical point [1]. - screen.screenToDipPoint(point): Converts a physical point to a DIP point [1]. - Similar methods exist for rectangles, such as screen.dipToScreenRect and screen.screenToDipRect [1]. 4. Linux-Specific Considerations: While these APIs are available, note that screen.screenToDipPoint and screen.dipToScreenPoint are not currently supported on Wayland and will return the input point without modifications [1]. Developers should ensure they are targeting the correct display when querying these properties, often by using screen.getDisplayNearestPoint(point) or screen.getDisplayMatching(rect) rather than relying solely on screen.getPrimaryDisplay [1][5]. As of Electron 43, there are no specific documented changes to the core logic of the screen module's coordinate system, though the release included various fixes for window positioning and scaling behavior [6][7]. Citations:
Map Hyprland points in Electron’s per-display coordinate space. When the cursor is on an output whose scale differs from the primary display, the primary-scale multiplication in 🤖 Prompt for AI Agents |
||
| }); | ||
| }); | ||
| }, 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(); | ||
| }; | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
Limit the CMake fallback claim to development installs.
WHISPER_RUNTIME_ALLOW_MISSING=1 npm installonly scopes the variable to the install command. The build script still requires CMake or a staged runtime for directnpm run buildand target build commands unless the variable is set again. This conflicts with the packaged-build steps below. State that the fallback supports development only, or document the required variable and missing auto-caption behavior for packaged builds.🤖 Prompt for AI Agents