Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
28 changes: 25 additions & 3 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
105 changes: 105 additions & 0 deletions src/main/services/cursor-source.ts
Original file line number Diff line number Diff line change
@@ -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;
}
}