Skip to content
Merged
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
16 changes: 8 additions & 8 deletions packages/remote-input/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 20 additions & 0 deletions packages/remote-input/src/backends/waylandYdotool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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, {
Expand Down
8 changes: 8 additions & 0 deletions packages/remote-input/src/backends/waylandYdotool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
14 changes: 14 additions & 0 deletions packages/remote-input/src/injector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@
platform: 'linux' as const,
displayServer: 'x11' as const,
},
createBackend: () => backend,

Check warning on line 26 in packages/remote-input/src/injector.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Missing return type on function
logger: silentLogger,
// virtualCursor off by default for these non-cursor tests
virtualCursor: false,
Expand Down Expand Up @@ -93,7 +93,7 @@
const onRejected = vi.fn();
const injector = new RemoteInputInjector({
selection: { kind: 'nut-js', platform: 'linux', displayServer: 'x11' },
createBackend: () => backend,

Check warning on line 96 in packages/remote-input/src/injector.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Missing return type on function
logger: silentLogger,
virtualCursor: false,
onRejected,
Expand Down Expand Up @@ -373,7 +373,7 @@
const backend = fakeBackend();
const injector = new RemoteInputInjector({
selection: { kind: 'nut-js', platform: 'linux', displayServer: 'x11' },
createBackend: () => backend,

Check warning on line 376 in packages/remote-input/src/injector.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Missing return type on function
logger: silentLogger,
virtualCursor: false,
holdTimeoutMs: 1000,
Expand All @@ -397,7 +397,7 @@
const backend = fakeBackend();
const injector = new RemoteInputInjector({
selection: { kind: 'nut-js', platform: 'linux', displayServer: 'x11' },
createBackend: () => backend,

Check warning on line 400 in packages/remote-input/src/injector.test.ts

View workflow job for this annotation

GitHub Actions / Lint

Missing return type on function
logger: silentLogger,
virtualCursor: false,
holdTimeoutMs: 1000,
Expand Down Expand Up @@ -594,6 +594,20 @@
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 () => {
Expand Down
10 changes: 9 additions & 1 deletion packages/remote-input/src/injector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));
Expand Down Expand Up @@ -390,6 +396,8 @@ export class RemoteInputInjector {
});
} catch (error) {
this.logger.warn('[RemoteInput] Could not restore local pointer', { error });
} finally {
this.getBackend().resumeCursorReporting?.();
}
}

Expand Down
7 changes: 7 additions & 0 deletions packages/remote-input/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -151,6 +151,13 @@ export interface InputBackend {
* participant actually holds control.
*/
startCursorReporting?: () => Promise<void>;
/**
* 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 {
Expand Down
20 changes: 16 additions & 4 deletions packages/remote-input/src/wayland/kwinCursorProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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', () => {
Expand Down
34 changes: 24 additions & 10 deletions packages/remote-input/src/wayland/kwinCursorProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}

/**
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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<void> {
for (const iface of SCRIPTING_INTERFACES) {
try {
Expand Down Expand Up @@ -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() };
};

Expand Down
Loading