From 27a845fbadf8a536f6443551b00c1950375ceed4 Mon Sep 17 00:00:00 2001 From: Mr P-Tech Date: Tue, 11 Aug 2026 13:05:09 +0100 Subject: [PATCH] fix(control): protect Wayland host cursor --- packages/remote-input/README.md | 16 ++++----- .../src/backends/waylandYdotool.test.ts | 20 +++++++++++ .../src/backends/waylandYdotool.ts | 8 +++++ packages/remote-input/src/injector.test.ts | 14 ++++++++ packages/remote-input/src/injector.ts | 10 +++++- packages/remote-input/src/types.ts | 7 ++++ .../src/wayland/kwinCursorProvider.test.ts | 20 ++++++++--- .../src/wayland/kwinCursorProvider.ts | 34 +++++++++++++------ 8 files changed, 106 insertions(+), 23 deletions(-) diff --git a/packages/remote-input/README.md b/packages/remote-input/README.md index ec4cc3a..92e5b56 100644 --- a/packages/remote-input/README.md +++ b/packages/remote-input/README.md @@ -124,14 +124,14 @@ system cursor directly. ## Platform support -| Platform | Backend | Two cursors | Requirements | -| ----------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | -| macOS | `nut-js` | Full — local pointer restored | Accessibility permission (see below) | -| Windows | `nut-js` | Full — local pointer restored | None. Admin only to drive elevated windows. | -| Linux / X11 | `nut-js` | Full — local pointer restored | None | -| Linux / Wayland (KDE) | `wayland-ydotool` | Full via `KWinCursorProvider` — without it, movement remains virtual and a click leaves the pointer where it landed | `ydotool` + running `ydotoold` with `/dev/uinput`; `gdbus` for cursor reporting | -| Linux / Wayland (other) | `wayland-ydotool` | Partial — movement never hijacked, but a click leaves the pointer where it landed | `ydotool` + a running `ydotoold` with `/dev/uinput` | -| Linux / Wayland | `wayland-portal` | n/a | Diagnostic only — reports why control is unavailable | +| Platform | Backend | Two cursors | Requirements | +| ----------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------ | ---------------------------------------------------- | +| macOS | `nut-js` | Full — local pointer restored | Accessibility permission (see below) | +| Windows | `nut-js` | Full — local pointer restored | None. Admin only to drive elevated windows. | +| Linux / X11 | `nut-js` | Full — local pointer restored | None | +| Linux / Wayland (KDE) | `wayland-ydotool` | Movement remains virtual; a click leaves the pointer where it landed. Experimental restoration requires `PAIRUX_WAYLAND_CURSOR_RESTORE=1`. | `ydotool` + running `ydotoold` with `/dev/uinput` | +| Linux / Wayland (other) | `wayland-ydotool` | Partial — movement never hijacked, but a click leaves the pointer where it landed | `ydotool` + a running `ydotoold` with `/dev/uinput` | +| Linux / Wayland | `wayland-portal` | n/a | Diagnostic only — reports why control is unavailable | > This package injects into a real OS, so it runs only where one exists. A > browser cannot be the _controlled_ machine; a browser-based client can only diff --git a/packages/remote-input/src/backends/waylandYdotool.test.ts b/packages/remote-input/src/backends/waylandYdotool.test.ts index 615b31e..4689c41 100644 --- a/packages/remote-input/src/backends/waylandYdotool.test.ts +++ b/packages/remote-input/src/backends/waylandYdotool.test.ts @@ -73,6 +73,26 @@ describe('WaylandYdotoolInputBackend', () => { await expect(backend.init()).resolves.toEqual({ screenWidth: 1920, screenHeight: 1080 }); }); + it('passes pointer-borrow reporter suppression through to KWin', () => { + const backend = new WaylandYdotoolInputBackend(vi.fn(), { + hasBinary: true, + hasSocket: true, + socketPath: '/run/ydotoold/socket', + }); + const cursorProvider = { + suspendUpdates: vi.fn(), + resumeUpdates: vi.fn(), + }; + (backend as unknown as { cursorProvider: typeof cursorProvider }).cursorProvider = + cursorProvider; + + backend.suspendCursorReporting(); + backend.resumeCursorReporting(); + + expect(cursorProvider.suspendUpdates).toHaveBeenCalledOnce(); + expect(cursorProvider.resumeUpdates).toHaveBeenCalledOnce(); + }); + it('emits mouse move command with absolute coordinates', async () => { const run = vi.fn(async (_command: string, _args: string[]) => undefined); const backend = new WaylandYdotoolInputBackend(run, { diff --git a/packages/remote-input/src/backends/waylandYdotool.ts b/packages/remote-input/src/backends/waylandYdotool.ts index efd6e26..564856a 100644 --- a/packages/remote-input/src/backends/waylandYdotool.ts +++ b/packages/remote-input/src/backends/waylandYdotool.ts @@ -550,6 +550,14 @@ export class WaylandYdotoolInputBackend implements InputBackend { await this.cursorProvider.start(); } + suspendCursorReporting(): void { + this.cursorProvider.suspendUpdates(); + } + + resumeCursorReporting(): void { + this.cursorProvider.resumeUpdates(); + } + getCursorPosition(): Promise<{ x: number; y: number } | null> { const point = this.cursorProvider.getPosition(); if (!point) return Promise.resolve(null); diff --git a/packages/remote-input/src/injector.test.ts b/packages/remote-input/src/injector.test.ts index 67d27a7..6aeaf44 100644 --- a/packages/remote-input/src/injector.test.ts +++ b/packages/remote-input/src/injector.test.ts @@ -594,6 +594,20 @@ describe('RemoteInputInjector two-cursor mode', () => { expect(restore?.[0]).toMatchObject({ x: 0.9, y: 0.9 }); }); + it('pauses a compositor cursor reporter until the borrowed pointer is restored', async () => { + const backend = reportingBackend({ + suspendCursorReporting: vi.fn(), + resumeCursorReporting: vi.fn(), + }); + const injector = twoCursorInjector(backend); + injector.enable(); + + await injector.inject({ type: 'mouse', action: 'click', button: 'left', x: 0.2, y: 0.2 }); + + expect(backend.suspendCursorReporting).toHaveBeenCalledOnce(); + expect(backend.resumeCursorReporting).toHaveBeenCalledOnce(); + }); + // Restoring between down and up would tear the drag apart, so the borrowed // pointer is only handed back once every button is released. it('holds the borrowed pointer until the drag ends', async () => { diff --git a/packages/remote-input/src/injector.ts b/packages/remote-input/src/injector.ts index a937e85..49ee10e 100644 --- a/packages/remote-input/src/injector.ts +++ b/packages/remote-input/src/injector.ts @@ -325,7 +325,13 @@ export class RemoteInputInjector { const reported = (await backend.getCursorPosition?.()) ?? null; // A null here means the compositor will not report the pointer, so this // click cannot later restore it. Movement nevertheless remains virtual. - this.borrowedFrom ??= reported; + if (reported) { + // A compositor reporter sees the ydotool motion required to land this + // click. Freeze it before injecting so the synthetic position cannot + // replace the host's origin before we restore it. + backend.suspendCursorReporting?.(); + this.borrowedFrom ??= reported; + } } await backend.inject(this.withEdgeMargin(event)); @@ -390,6 +396,8 @@ export class RemoteInputInjector { }); } catch (error) { this.logger.warn('[RemoteInput] Could not restore local pointer', { error }); + } finally { + this.getBackend().resumeCursorReporting?.(); } } diff --git a/packages/remote-input/src/types.ts b/packages/remote-input/src/types.ts index 30c65ac..efe2904 100644 --- a/packages/remote-input/src/types.ts +++ b/packages/remote-input/src/types.ts @@ -151,6 +151,13 @@ export interface InputBackend { * participant actually holds control. */ startCursorReporting?: () => Promise; + /** + * Stop an optional cursor reporter from mistaking PairUX's own synthetic + * pointer motion for the host user's position while a click is borrowed. + */ + suspendCursorReporting?: () => void; + /** Resume optional cursor reporting after a borrowed click has been restored. */ + resumeCursorReporting?: () => void; } export interface InputStats { diff --git a/packages/remote-input/src/wayland/kwinCursorProvider.test.ts b/packages/remote-input/src/wayland/kwinCursorProvider.test.ts index 31623d3..15416a6 100644 --- a/packages/remote-input/src/wayland/kwinCursorProvider.test.ts +++ b/packages/remote-input/src/wayland/kwinCursorProvider.test.ts @@ -79,6 +79,20 @@ describe('KWinCursorProvider', () => { withPosition.position = { x: 100, y: 200, at: Date.now() - 10_000 }; expect(provider.getPosition()).toBeNull(); }); + + it('ignores synthetic movement while a pointer borrow is active', () => { + const provider = new KWinCursorProvider({ logger: silent }); + const state = provider as unknown as { + position: { x: number; y: number; at: number } | null; + ignoreUpdatesUntil: number; + }; + + provider.suspendUpdates(); + expect(state.ignoreUpdatesUntil).toBe(Number.POSITIVE_INFINITY); + + provider.resumeUpdates(); + expect(state.ignoreUpdatesUntil).toBeGreaterThan(Date.now()); + }); }); describe('isKWinCursorRestoreEnabled', () => { @@ -98,11 +112,9 @@ describe('isKWinCursorRestoreEnabled', () => { } } - // The helper only targets KDE on Wayland, and a user should not have to - // discover an env var to get working pointer restore there. - it('is on for a KDE Wayland session', () => { + it('is off by default, including on KDE Wayland', () => { env({ XDG_SESSION_TYPE: 'wayland', XDG_CURRENT_DESKTOP: 'KDE' }); - expect(isKWinCursorRestoreEnabled()).toBe(true); + expect(isKWinCursorRestoreEnabled()).toBe(false); }); it('is off where the helper does not apply', () => { diff --git a/packages/remote-input/src/wayland/kwinCursorProvider.ts b/packages/remote-input/src/wayland/kwinCursorProvider.ts index cca0b1e..e4a02a6 100644 --- a/packages/remote-input/src/wayland/kwinCursorProvider.ts +++ b/packages/remote-input/src/wayland/kwinCursorProvider.ts @@ -48,19 +48,15 @@ const REPORT_INTERVAL_MS = 100; * the report rate is capped, it exists only while a guest holds control, and it * gives up after repeated failures. * - * PAIRUX_WAYLAND_CURSOR_RESTORE=0 forces it off (if a compositor misbehaves), - * =1 forces it on (e.g. a KDE session that does not advertise itself). + * It is deliberately opt-in: KWin cannot identify whether a position change + * came from the host's mouse or from PairUX's synthetic ydotool event. A wrong + * restore is worse than no restore because it can repeatedly steal the host's + * pointer. Set PAIRUX_WAYLAND_CURSOR_RESTORE=1 only to try the experimental + * restore path; =0 is accepted for explicitness. */ export function isKWinCursorRestoreEnabled(): boolean { const override = process.env.PAIRUX_WAYLAND_CURSOR_RESTORE; - if (override === '0') return false; - if (override === '1') return true; - - const wayland = - process.env.XDG_SESSION_TYPE === 'wayland' || process.env.WAYLAND_DISPLAY !== undefined; - const kde = (process.env.XDG_CURRENT_DESKTOP ?? '').toUpperCase().includes('KDE'); - - return wayland && kde; + return override === '1'; } /** @@ -151,6 +147,8 @@ export interface KWinCursorProviderOptions { export class KWinCursorProvider { private position: { x: number; y: number; at: number } | null = null; + /** Ignore KWin notifications caused by our own move/click/restore cycle. */ + private ignoreUpdatesUntil = 0; private started = false; private available = false; private bus: { disconnect: () => void } | null = null; @@ -214,6 +212,21 @@ export class KWinCursorProvider { return { x: this.position.x, y: this.position.y }; } + /** + * A borrowed click produces compositor cursor notifications of its own. + * Ignore them until after its restore has settled, or the next click would + * "restore" the host pointer to PairUX's previous synthetic position. + */ + suspendUpdates(): void { + this.ignoreUpdatesUntil = Number.POSITIVE_INFINITY; + } + + resumeUpdates(): void { + // ydotool and KWin are asynchronous. Keep ignoring the synthetic restore + // notification long enough for it to arrive before accepting host motion. + this.ignoreUpdatesUntil = Date.now() + 150; + } + async stop(): Promise { for (const iface of SCRIPTING_INTERFACES) { try { @@ -245,6 +258,7 @@ export class KWinCursorProvider { const { Interface } = dbus.interface; const record = (x: number, y: number): void => { + if (Date.now() < this.ignoreUpdatesUntil) return; this.position = { x, y, at: Date.now() }; };