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
18 changes: 18 additions & 0 deletions apps/desktop/src/main/input/injector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ vi.mock('../platform', () => ({
detectDisplayServer: vi.fn().mockReturnValue('x11'),
}));

vi.mock('../capture/captureDisplay', () => ({
resolveCaptureBoundsForSource: vi.fn().mockResolvedValue(null),
}));

// Mock @nut-tree-fork/nut-js before imports
// Note: All values must be defined inside the factory since vi.mock is hoisted
vi.mock('@nut-tree-fork/nut-js', () => {
Expand Down Expand Up @@ -93,6 +97,7 @@ vi.mock('@nut-tree-fork/nut-js', () => {
});

import { mouse, keyboard, Button, Key, screen } from '@nut-tree-fork/nut-js';
import { resolveCaptureBoundsForSource } from '../capture/captureDisplay';
import {
initInputInjector,
enableInjection,
Expand All @@ -101,11 +106,14 @@ import {
updateScreenSize,
injectInput,
emergencyStop,
resetInputInjector,
setCaptureSource,
} from './injector';

describe('Input Injector', () => {
beforeEach(async () => {
vi.clearAllMocks();
resetInputInjector();
// Reset injection state and screen size
disableInjection();
// Reset to default screen size for consistent tests
Expand All @@ -130,6 +138,16 @@ describe('Input Injector', () => {
// Should not throw
await expect(initInputInjector()).resolves.not.toThrow();
});

it('re-resolves a source selected before backend geometry was initialized', async () => {
await setCaptureSource('screen:second');
await initInputInjector();

expect(resolveCaptureBoundsForSource).toHaveBeenLastCalledWith('screen:second', {
width: 1920,
height: 1080,
});
});
});

describe('enableInjection / disableInjection', () => {
Expand Down
12 changes: 11 additions & 1 deletion apps/desktop/src/main/input/injector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ function getInjector(): RemoteInputInjector {
export function resetInputInjector(): void {
injector = null;
backendPrimary = null;
captureSourceId = null;
}

/**
Expand All @@ -54,11 +55,14 @@ export function resetInputInjector(): void {
* and scale every subsequent resolution against the wrong reference.
*/
let backendPrimary: { width: number; height: number } | null = null;
/** The latest source requested, retained until backend dimensions are known. */
let captureSourceId: string | null = null;

export async function initInputInjector(): Promise<void> {
const injector = getInjector();
await injector.init();
backendPrimary = injector.getScreenSize();
await updateCaptureBounds();
}

/**
Expand All @@ -69,7 +73,13 @@ export async function initInputInjector(): Promise<void> {
* single-monitor host has always had.
*/
export async function setCaptureSource(sourceId: string | null): Promise<void> {
const bounds = await resolveCaptureBoundsForSource(sourceId, backendPrimary);
captureSourceId = sourceId;
await updateCaptureBounds();
}

/** Re-resolve once both the selected source and backend geometry are available. */
async function updateCaptureBounds(): Promise<void> {
const bounds = await resolveCaptureBoundsForSource(captureSourceId, backendPrimary);
getInjector().updateCaptureBounds(bounds);
}

Expand Down
19 changes: 19 additions & 0 deletions apps/desktop/src/main/window.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,25 @@ beforeEach(() => {
});

describe('createMainWindow', () => {
it('allows pointer lock through both Electron permission paths', async () => {
const { createMainWindow } = await import('./window');
const { session } = await import('electron');
await createMainWindow(false);

const requestHandler = vi
.mocked(session.defaultSession.setPermissionRequestHandler)
.mock.calls.at(-1)?.[0];
const checkHandler = vi
.mocked(session.defaultSession.setPermissionCheckHandler)
.mock.calls.at(-1)?.[0];
const callback = vi.fn();

requestHandler?.({} as Electron.WebContents, 'pointerLock', callback, {} as never);

expect(callback).toHaveBeenCalledWith(true);
expect(checkHandler?.({} as Electron.WebContents, 'pointerLock', '', {} as never)).toBe(true);
});

it('keeps the renderer unthrottled in the background', async () => {
const { createMainWindow } = await import('./window');
await createMainWindow(false);
Expand Down
36 changes: 23 additions & 13 deletions apps/desktop/src/main/window.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,20 +119,24 @@ export async function createMainWindow(isWayland: boolean): Promise<BrowserWindo
{ useSystemPicker: true }
);

// Handle permission requests
// Keep this list shared by both Electron permission hooks. Chromium may use
// the synchronous check path for Pointer Lock, so approving only the request
// path would still leave the renderer unable to capture its cursor.
const allowedPermissions = new Set([
'media',
'display-capture',
'mediaKeySystem',
'geolocation',
'notifications',
'fullscreen',
'pointerLock',
'clipboard-sanitized-write',
'clipboard-read',
]);

// Handle permission requests.
session.defaultSession.setPermissionRequestHandler((_webContents, permission, callback) => {
const allowedPermissions = [
'media',
'display-capture',
'mediaKeySystem',
'geolocation',
'notifications',
'fullscreen',
'clipboard-sanitized-write',
'clipboard-read',
];

if (allowedPermissions.includes(permission)) {
if (allowedPermissions.has(permission)) {
console.log('[Main] Permission granted:', permission);
callback(true);
} else {
Expand All @@ -141,6 +145,12 @@ export async function createMainWindow(isWayland: boolean): Promise<BrowserWindo
}
});

session.defaultSession.setPermissionCheckHandler((_webContents, permission) => {
const allowed = allowedPermissions.has(permission);
console.log(`[Main] Permission ${allowed ? 'granted' : 'denied'}:`, permission);
return allowed;
});

// Mirror renderer console output into the main process stdout.
//
// Session logic (WebRTC, control state, audio routing) all lives in the
Expand Down
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` — falls back to leaving the pointer where the click 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` | 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 |

> 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
35 changes: 15 additions & 20 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 @@ -640,28 +640,20 @@
expect(moves(backend)).toHaveLength(0);
});

// Regression. Two cursors rest on one absolute positioning call per click
// with nothing in between to correct it, which is only trustworthy where the
// pointer can be read back. On a host that cannot report it — Wayland with
// no compositor helper — that made every click a single unverified "move
// there, now press": the guest saw their cursor move and nothing respond.
// Where the pointer cannot be read, movement must drive the real cursor.
it('drives the cursor directly when the host cannot report the pointer', async () => {
it('keeps movement virtual when the host cannot report the pointer', async () => {
const backend = fakeBackend();
delete (backend as { getCursorPosition?: unknown }).getCursorPosition;
const injector = twoCursorInjector(backend);
injector.enable();

await injector.inject({ type: 'mouse', action: 'move', x: 0.3, y: 0.4 });

expect(backend.inject).toHaveBeenCalledWith(
expect.objectContaining({ action: 'move', x: 0.3, y: 0.4 })
);
expect(moves(backend)).toHaveLength(0);
});

// The method can exist and still refuse to answer (KWin not reporting), so
// the first null has to switch the strategy rather than being shrugged off.
it('switches to driving the cursor once a position read comes back empty', async () => {
// The method can exist and still refuse to answer (KWin not reporting). A
// click then cannot be restored, but remote movement must remain virtual.
it('does not hijack movement once a position read comes back empty', async () => {
const backend = fakeBackend({ getCursorPosition: vi.fn().mockResolvedValue(null) });
const injector = twoCursorInjector(backend);
injector.enable();
Expand All @@ -674,9 +666,10 @@
await injector.inject({ type: 'mouse', action: 'down', button: 'left', x: 0.2, y: 0.2 });
await injector.inject({ type: 'mouse', action: 'up', button: 'left', x: 0.2, y: 0.2 });

// From here movement drives the real cursor, so clicks land where aimed.
// From here a later click cannot restore the local pointer, but movement
// must not continuously warp it away from the host.
await injector.inject({ type: 'mouse', action: 'move', x: 0.7, y: 0.8 });
expect(moves(backend).at(-1)?.[0]).toMatchObject({ x: 0.7, y: 0.8 });
expect(moves(backend)).toHaveLength(0);
});

// A host that *can* report the pointer must keep both cursors, or the fix
Expand Down Expand Up @@ -878,7 +871,7 @@
await Promise.all(promises);
}

it('drops moves that a newer position has already replaced', async () => {
it('keeps the newest superseding move when the queue drains', async () => {
const { backend, release, injected } = blockingBackend();
const injector = makeInjector(backend);
injector.enable();
Expand All @@ -890,10 +883,11 @@

await drain(release, [inFlight, ...queued]);

// The middle position is gone: it was obsolete before it ever ran.
// The middle position is gone, but the final position is injected rather
// than silently lost with it.
const xs = injected.filter((e) => 'x' in e).map((e) => (e as { x: number }).x);
expect(xs).not.toContain(0.2);
expect(injected.length).toBeLessThan(3);
expect(xs).toContain(0.3);
});

it('never drops a click, however far behind the queue is', async () => {
Expand Down Expand Up @@ -932,8 +926,8 @@
expect(xs).toContain(0.3);
});

it('reports where the viewer stopped even when the last move was dropped', async () => {
const { backend, release } = blockingBackend();
it('reports and injects where the viewer stopped after coalescing', async () => {
const { backend, release, injected } = blockingBackend();
const injector = makeInjector(backend);
injector.enable();

Expand All @@ -945,6 +939,7 @@

// The overlay still has to draw the guest's cursor where they left it.
expect(injector.getRemoteCursorPosition().x).toBe(0.9);
expect(injected.some((event) => 'x' in event && event.x === 0.9)).toBe(true);
});

it('counts what it dropped, so a laggy host can be told apart from a busy one', async () => {
Expand Down
Loading
Loading