diff --git a/bun.lock b/bun.lock index 0492ab0..8748b11 100644 --- a/bun.lock +++ b/bun.lock @@ -7,6 +7,7 @@ "dependencies": { "posthog-node": "^4.0.0", "ws": "^8.18.0", + "x11": "^3.9.1", }, "devDependencies": { "@electron/notarize": "^2.5.0", @@ -1019,6 +1020,8 @@ "ws": ["ws@8.20.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-sAt8BhgNbzCtgGbt2OxmpuryO63ZoDk/sqaB/znQm94T4fCEsy/yV+7CdC1kJhOU9lboAEU7R3kquuycDoibVA=="], + "x11": ["x11@3.9.1", "", {}, "sha512-FRsfhutjoBi93FNsjkoRV/kAmQS/fknL54QN872k8b7RbNiLefbsbPvaYPGq1+201xEt9VRR5+Lm+pmJn/dsTQ=="], + "xmlbuilder": ["xmlbuilder@15.1.1", "", {}, "sha512-yMqGBqtXyeN1e3TGYvgNgDVZ3j84W4cwkOXQswghol6APgZWaff9lnbvN7MHYJOiXsvGPXtjTYJEiC9J2wv9Eg=="], "y18n": ["y18n@5.0.8", "", {}, "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA=="], diff --git a/package.json b/package.json index 42b1004..1731e2e 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ }, "dependencies": { "posthog-node": "^4.0.0", - "ws": "^8.18.0" + "ws": "^8.18.0", + "x11": "^3.9.1" }, "devDependencies": { "@types/ws": "^8.5.0", diff --git a/src/main/index.ts b/src/main/index.ts index 6516d57..aa998ad 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -2,6 +2,7 @@ import { app, BrowserWindow, Tray, Menu, globalShortcut, screen, ipcMain, shell, import path from 'path'; import { CompanionManager } from './companion-manager'; import { createPanelWindow, createOverlayWindow, createStreamWindow } from './windows'; +import { X11CursorSource } from './services/cursor-source'; import { IPC, type StreamVisibility, type StreamWindowBounds, type LocalConnection } from '../shared/types'; import { AUDIO_IPC } from './services/audio-capture'; import * as chatHistory from './services/chat-history-store'; @@ -394,10 +395,31 @@ app.whenReady().then(() => { companion.handleAudioChunk(buffer); }); - // Track cursor position for overlay rendering + // Track cursor position for overlay rendering. + // + // Electron's screen.getCursorScreenPoint() is broken on Linux X11 since + // v29 (electron/electron#42519): it returns one stale point forever, so + // the companion cursor would pin in place. On X11 we read the pointer + // directly from the X server (pure-JS x11 client) and convert physical + // pixels to DIPs; elsewhere the Electron API still works. + const isX11 = process.platform === 'linux' && !!process.env.DISPLAY; + const cursorSource = isX11 + ? new X11CursorSource(process.env.DISPLAY!) + : null; setInterval(() => { - const pos = screen.getCursorScreenPoint(); - sendToOverlays(IPC.CURSOR_POSITION, pos); + let pos: { x: number; y: number } | null = null; + if (cursorSource) { + const raw = cursorSource.poll(); + // X11 QueryPointer returns physical pixels; convert to DIPs manually + // because Electron's screenToDipPoint/screenToDipRect are win32-only. + if (raw) { + const display = screen.getDisplayMatching({ x: raw.x, y: raw.y, width: 1, height: 1 }); + pos = { x: raw.x / display.scaleFactor, y: raw.y / display.scaleFactor }; + } + } else { + pos = screen.getCursorScreenPoint(); + } + if (pos) sendToOverlays(IPC.CURSOR_POSITION, pos); }, 16); // ~60fps // Poll permissions diff --git a/src/main/services/cursor-source.ts b/src/main/services/cursor-source.ts new file mode 100644 index 0000000..8637f92 --- /dev/null +++ b/src/main/services/cursor-source.ts @@ -0,0 +1,105 @@ +/** + * Linux X11 cursor position source. + * + * Electron's screen.getCursorScreenPoint() has been broken on Linux X11 + * since v29 (electron/electron#42519) — it returns a single stale point + * forever. Flicky's overlay follows the cursor at ~60fps, so a frozen + * coordinate pins the companion cursor in place. + * + * This module reads the pointer directly from the X server via the pure-JS + * `x11` client (no native compilation, no subprocess). QueryPointer is + * async — the reply arrives on the next event-loop turn — so poll() issues + * the query and returns the last-known position, yielding a one-tick lag + * that is imperceptible at 60fps. + */ + +/** Minimal typing for the `x11` package (has no bundled type declarations). */ +interface X11ClientHandle { + client: { + QueryPointer( + root: number, + callback: ( + err: Error | null, + reply?: { rootX: number; rootY: number; sameScreen: boolean }, + ) => void, + ): void; + }; + screen: Array<{ root: number }>; +} + +interface X11Module { + createClient(options: { display: string }): { + on(event: 'connect', handler: (client: X11ClientHandle) => void): void; + on(event: 'error', handler: (err: Error) => void): void; + }; +} + +/** + * Polls the real pointer position directly from the X server. + * + * `display` is the X11 display string (e.g. ":0"). The connection is + * established lazily on first poll and held open; if the display is + * unreachable, polls keep returning the fallback position instead of + * throwing. + */ +export class X11CursorSource { + private client: X11ClientHandle | null = null; + private connectionError: Error | null = null; + private lastPoint: { x: number; y: number } | null = null; + private readonly display: string; + + constructor(display: string) { + this.display = display; + } + + /** + * Issues a fresh pointer query and returns the most recent known + * position. Returns null only when the X connection has not yet produced + * a reply (first call) or is unreachable. + */ + poll(): { x: number; y: number } | null { + if (!this.client && !this.connectionError) { + this.connect(); + } + if (!this.client || this.connectionError) return this.lastPoint; + + const root = this.client.screen[0]?.root; + if (root === undefined) return this.lastPoint; + + try { + this.client.client.QueryPointer(root, (err, reply) => { + if (err) { + this.connectionError = err; + return; + } + if (reply) { + this.lastPoint = { x: reply.rootX, y: reply.rootY }; + } + }); + } catch (err) { + this.connectionError = err as Error; + } + return this.lastPoint; + } + + private connect(): void { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + const x11 = require('x11') as X11Module; + const client = x11.createClient({ display: this.display }); + client.on('connect', (handle) => { + this.client = handle; + this.connectionError = null; + }); + client.on('error', (err) => { + this.connectionError = err; + }); + } catch (err) { + this.connectionError = err as Error; + } + } + + get isConnected(): boolean { + return !!this.client && !this.connectionError; + } +}