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
36 changes: 36 additions & 0 deletions packages/remote-input/src/backends/nutjs.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { resolveModifiers } from '../modifiers.js';
import { isInputDebugEnabled } from '../debug.js';
import type {
InputEvent,
MouseMoveEvent,
Expand All @@ -11,7 +12,7 @@
} from '../types.js';

// Helper that performs the one-time dynamic import and configures nut-js
async function loadNut() {

Check warning on line 15 in packages/remote-input/src/backends/nutjs.ts

View workflow job for this annotation

GitHub Actions / Lint

Missing return type on function
const nut = await import('@nut-tree-fork/nut-js');
nut.mouse.config.autoDelayMs = 0;
nut.mouse.config.mouseSpeed = 10000;
Expand Down Expand Up @@ -39,7 +40,7 @@
return nutPromise;
}

function mapMouseButton(button: MouseButton, Button: NutModule['Button']) {

Check warning on line 43 in packages/remote-input/src/backends/nutjs.ts

View workflow job for this annotation

GitHub Actions / Lint

Missing return type on function
switch (button) {
case 'left':
return Button.LEFT;
Expand Down Expand Up @@ -142,6 +143,19 @@
const { screen } = await getNut();
this.screenWidth = await screen.width();
this.screenHeight = await screen.height();

if (isInputDebugEnabled()) {
// The other half of a mis-placed click: if this disagrees with the
// display's real geometry (Retina reporting physical pixels while
// setPosition expects logical points, say), every coordinate is scaled
// wrong and no settle delay will save it.
console.log('[InputInjector:debug] screen geometry from nut-js', {
width: this.screenWidth,
height: this.screenHeight,
platform: process.platform,
});
}

return { screenWidth: this.screenWidth, screenHeight: this.screenHeight };
}

Expand Down Expand Up @@ -169,6 +183,28 @@
// a delay is a sub-millisecond blip that many controls simply ignore.
await settle();

if (isInputDebugEnabled()) {
// Read the pointer back from the OS. If `actual` does not match
// `requested` here, the settle above is too short and the click is
// landing somewhere other than where the guest aimed.
let actual: { x: number; y: number } | null = null;
try {
actual = await mouse.getPosition();
} catch (error) {
console.warn('[InputInjector:debug] could not read pointer back', { error });
}

console.log('[InputInjector:debug] button', {
action: event.action,
button: event.button,
normalized: { x: event.x, y: event.y },
requested: { x, y },
actual,
drift: actual ? { x: actual.x - x, y: actual.y - y } : null,
screen: { width: this.screenWidth, height: this.screenHeight },
});
}

switch (event.action) {
case 'down':
await mouse.pressButton(button);
Expand Down
33 changes: 33 additions & 0 deletions packages/remote-input/src/debug.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import { afterEach, describe, expect, it } from 'vitest';
import { isInputDebugEnabled } from './debug.js';

describe('isInputDebugEnabled', () => {
const original = process.env.PAIRUX_DEBUG_INPUT;

afterEach(() => {
if (original === undefined) delete process.env.PAIRUX_DEBUG_INPUT;
else process.env.PAIRUX_DEBUG_INPUT = original;
});

// The tracing prints the coordinates of everything the remote peer clicks
// and costs a window-server round trip per button event, so silence has to
// be the default rather than something a build flag happens to give us.
it('is off unless explicitly enabled', () => {
delete process.env.PAIRUX_DEBUG_INPUT;
expect(isInputDebugEnabled()).toBe(false);
});

it('is on for exactly "1"', () => {
process.env.PAIRUX_DEBUG_INPUT = '1';
expect(isInputDebugEnabled()).toBe(true);
});

// Anything truthy-looking but not "1" stays off, so a stray value in a shell
// profile cannot quietly turn tracing on in a packaged build.
it('ignores other values', () => {
for (const value of ['0', 'true', 'yes', '']) {
process.env.PAIRUX_DEBUG_INPUT = value;
expect(isInputDebugEnabled()).toBe(false);
}
});
});
15 changes: 15 additions & 0 deletions packages/remote-input/src/debug.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
/**
* Per-event input tracing, off unless `PAIRUX_DEBUG_INPUT=1`.
*
* Exists to answer questions that cannot be settled by reading the code —
* above all, whether the pointer is actually where we asked it to be by the
* time a remote click's button event goes down. The OS applies synthetic
* moves on its own schedule, so that ordering is only observable at runtime.
*
* Off by default: it reads the cursor position on every button event, which
* is a round trip into the window server, and it prints the coordinates of
* everything the remote peer clicks.
*/
export function isInputDebugEnabled(): boolean {
return process.env.PAIRUX_DEBUG_INPUT === '1';
}
16 changes: 16 additions & 0 deletions packages/remote-input/src/injector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
type InputBackendSelection,
} from './factory.js';
import { InputRateLimiter, validateInputEvent, type RejectionReason } from './safety.js';
import { isInputDebugEnabled } from './debug.js';
import type {
InputBackend,
InputDiagnostics,
Expand Down Expand Up @@ -287,6 +288,21 @@ export class RemoteInputInjector {
const remaining = new Set(this.heldButtons);
if (event.action === 'down') remaining.add(event.button);
else if (event.action === 'up') remaining.delete(event.button);

if (isInputDebugEnabled()) {
// The two-cursor bookkeeping around a click. A restore firing between a
// down and its up would yank the pointer mid-click and is invisible from
// the backend's own trace, so it is recorded here.
this.logger.log('[RemoteInput:debug] click dispatch', {
action: event.action,
dragging,
heldBefore: [...this.heldButtons],
remainingAfter: [...remaining],
borrowedFrom: this.borrowedFrom,
willRestore: remaining.size === 0,
});
}

if (remaining.size > 0) return;

await this.restoreLocalPointer();
Expand Down
Loading