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
2 changes: 1 addition & 1 deletion apps/desktop/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@pairux/desktop",
"version": "0.9.75",
"version": "0.9.76",
"private": true,
"description": "PairUX Desktop - Screen sharing with remote control",
"author": "PairUX Team <hello@pairux.com>",
Expand Down
9 changes: 9 additions & 0 deletions apps/desktop/src/main/input/injector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import { resolveCaptureBoundsForSource } from '../capture/captureDisplay';
export type InputInjectionDiagnostics = InputDiagnostics;

let injector: RemoteInputInjector | null = null;
const REJECTION_LOG_INTERVAL_MS = 5_000;
let lastRejectionLogAt = 0;

function getInjector(): RemoteInputInjector {
const selection = getInputBackendSelection();
Expand All @@ -29,7 +31,13 @@ function getInjector(): RemoteInputInjector {
// barrier while leaving corner UI (Start button, menu bar) clickable.
// Other platforms get the guest's exact coordinates.
edgeMarginPx: selection.platform === 'linux' ? 1 : 0,
// This is a second, process-side guard after renderer coalescing. It also
// protects IPC callers that bypass the normal host hook.
maxEventsPerSecond: 120,
onRejected: (reason, event, detail) => {
const now = Date.now();
if (now - lastRejectionLogAt < REJECTION_LOG_INTERVAL_MS) return;
lastRejectionLogAt = now;
console.warn('[InputInjector] Rejected input event', {
reason,
detail,
Expand All @@ -44,6 +52,7 @@ function getInjector(): RemoteInputInjector {
/** Test seam: drop the singleton so the next call re-selects a backend. */
export function resetInputInjector(): void {
injector = null;
lastRejectionLogAt = 0;
backendPrimary = null;
captureSourceId = null;
}
Expand Down
10 changes: 10 additions & 0 deletions apps/desktop/src/main/ipc/input.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,16 @@ describe('IPC Input Handlers', () => {
expect(injectInput).toHaveBeenCalledTimes(2);
expect(result).toEqual({ success: true, count: 2 });
});

it('caps oversized batches before they reach the main process injector', async () => {
const handler = mockIpcMainHandlers.get('input:injectBatch')!;
const event: InputEvent = { type: 'mouse', action: 'move', x: 0.5, y: 0.5 };

const result = await handler({}, { events: Array.from({ length: 100 }, () => event) });

expect(injectInput).toHaveBeenCalledTimes(64);
expect(result).toEqual({ success: true, count: 64 });
});
});

describe('input:emergencyStop handler', () => {
Expand Down
9 changes: 6 additions & 3 deletions apps/desktop/src/main/ipc/input.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,12 +155,15 @@ export function registerInputHandlers(): void {
return { success: true };
});

// Batch inject multiple events (for better performance)
// Batch inject multiple events (for better performance). Keep this bounded:
// the renderer is not a trust boundary and a huge array would otherwise
// monopolize the main process before the injector's per-event limiter runs.
ipcMain.handle('input:injectBatch', async (_event, args: { events: InputEvent[] }) => {
for (const event of args.events) {
const events = Array.isArray(args.events) ? args.events.slice(0, 64) : [];
for (const event of events) {
await injectInput(event);
}
return { success: true, count: args.events.length };
return { success: true, count: events.length };
});

// Emergency stop - release all keys/buttons and disable injection
Expand Down
87 changes: 85 additions & 2 deletions apps/desktop/src/renderer/hooks/useInputInjection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,7 @@ describe('useInputInjection', () => {
expect(mockElectronAPI.invoke).toHaveBeenCalledWith('input:inject', { event });
});

it('should batch mouse move events', async () => {
it('should coalesce mouse moves to the most recent position each frame', async () => {
const { result } = renderHook(() => useInputInjection({ enabled: true }));

await act(async () => {
Expand All @@ -266,10 +266,93 @@ describe('useInputInjection', () => {
});

expect(mockElectronAPI.invoke).toHaveBeenCalledWith('input:injectBatch', {
events: expect.arrayContaining([moveEvent1, moveEvent2]),
events: [moveEvent2],
});
});

it('should coalesce wheel events without losing their total distance', async () => {
const { result } = renderHook(() => useInputInjection({ enabled: true }));

await act(async () => {
await vi.runAllTimersAsync();
});

await act(async () => {
await result.current.injectEvent({
type: 'mouse',
action: 'scroll',
deltaX: 2,
deltaY: 3,
deltaMode: 0,
x: 0.1,
y: 0.1,
});
await result.current.injectEvent({
type: 'mouse',
action: 'scroll',
deltaX: 4,
deltaY: 5,
deltaMode: 0,
x: 0.2,
y: 0.2,
});
await vi.advanceTimersByTimeAsync(20);
});

expect(mockElectronAPI.invoke).toHaveBeenCalledWith('input:injectBatch', {
events: [
{
type: 'mouse',
action: 'scroll',
deltaX: 6,
deltaY: 8,
deltaMode: 0,
x: 0.2,
y: 0.2,
},
],
});
});

it('should rate-limit click floods but always release an accepted button', async () => {
const { result } = renderHook(() => useInputInjection({ enabled: true }));

await act(async () => {
await vi.runAllTimersAsync();
});

const downEvent: InputEvent = {
type: 'mouse',
action: 'down',
button: 'left',
x: 0.5,
y: 0.5,
};
const upEvent: InputEvent = { ...downEvent, action: 'up' };

await act(async () => {
await result.current.injectEvent(downEvent);
await Promise.all(
Array.from({ length: 40 }, () =>
result.current.injectEvent({
type: 'mouse',
action: 'click',
button: 'left',
x: 0.5,
y: 0.5,
})
)
);
await result.current.injectEvent(upEvent);
});

const injected = mockElectronAPI.invoke.mock.calls.filter(
([channel]) => channel === 'input:inject'
);
expect(injected.length).toBeLessThan(20);
expect(injected.at(-1)).toEqual(['input:inject', { event: upEvent }]);
});

it('should not inject when disabled', async () => {
const { result } = renderHook(() => useInputInjection({ enabled: false }));

Expand Down
126 changes: 112 additions & 14 deletions apps/desktop/src/renderer/hooks/useInputInjection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ import { useEffect, useCallback, useRef, useState } from 'react';
import type { InputEvent } from '@pairux/shared-types';
import type { InputInjectionDiagnostics } from '../../preload/api';

// Network-originated input must not be able to grow the host's IPC queue
// without bound. Continuous gestures are sampled once per frame, while button
// and keyboard events are rate-limited below.
const INPUT_FRAME_MS = 16;
const MAX_DISCRETE_EVENTS_PER_SECOND = 30;
const DISCRETE_EVENT_BURST = 15;
const MAX_QUEUED_INJECTIONS = 32;

interface UseInputInjectionOptions {
/**
* Optional declarative enablement. Omit it when the host needs to await
Expand Down Expand Up @@ -52,13 +60,20 @@ export function useInputInjection({
const isEnabledRef = useRef(false);
const [isInitialized, setIsInitialized] = useState(false);
const [diagnostics, setDiagnostics] = useState<InputInjectionDiagnostics | null>(null);
const pendingEvents = useRef<InputEvent[]>([]);
const pendingMove = useRef<InputEvent | null>(null);
const pendingScroll = useRef<Extract<InputEvent, { type: 'mouse'; action: 'scroll' }> | null>(
null
);
const flushTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
// IPC handlers may run concurrently. Keep every OS injection in the order
// it arrived, especially move -> down -> up. Without this queue, a button
// down that is waiting for a pending move batch can be overtaken by its up,
// leaving a mouse button held on the host desktop.
const injectionQueue = useRef<Promise<void>>(Promise.resolve());
const queuedInjectionCount = useRef(0);
const discreteRateLimit = useRef({ tokens: DISCRETE_EVENT_BURST, updatedAt: Date.now() });
const pressedMouseButtons = useRef(new Set<string>());
const pressedKeys = useRef(new Set<string>());

const activate = useCallback(async (): Promise<boolean> => {
try {
Expand Down Expand Up @@ -91,7 +106,19 @@ export function useInputInjection({
}, []);

const enqueueInjection = useCallback(
(operation: () => Promise<unknown>, errorMessage: string): Promise<void> => {
(
operation: () => Promise<unknown>,
errorMessage: string,
isRequiredRelease = false
): Promise<void> => {
// A release for an accepted press gets a bounded exception: it
// prevents stuck input while the pressed-key/button sets limit how many
// such exceptions a sender can create.
if (queuedInjectionCount.current >= MAX_QUEUED_INJECTIONS && !isRequiredRelease) {
return Promise.resolve();
}

queuedInjectionCount.current += 1;
const queued = injectionQueue.current.then(async () => {
try {
await operation();
Expand All @@ -102,12 +129,46 @@ export function useInputInjection({

// The operation catches its own error, so later input is never blocked
// behind a rejected promise.
injectionQueue.current = queued;
return queued;
injectionQueue.current = queued.finally(() => {
queuedInjectionCount.current -= 1;
});
return injectionQueue.current;
},
[]
);

const consumeDiscreteEventToken = useCallback((event: InputEvent): boolean => {
const isMouseRelease =
event.type === 'mouse' &&
event.action === 'up' &&
pressedMouseButtons.current.delete(event.button);
const isKeyRelease =
event.type === 'keyboard' && event.action === 'up' && pressedKeys.current.delete(event.code);

// A release for an accepted press must always get through. Otherwise rate
// limiting could leave the host with a key or button stuck down.
if (isMouseRelease || isKeyRelease) return true;

const now = Date.now();
const elapsedSeconds = Math.max(0, now - discreteRateLimit.current.updatedAt) / 1000;
discreteRateLimit.current.tokens = Math.min(
DISCRETE_EVENT_BURST,
discreteRateLimit.current.tokens + elapsedSeconds * MAX_DISCRETE_EVENTS_PER_SECOND
);
discreteRateLimit.current.updatedAt = now;

if (discreteRateLimit.current.tokens < 1) return false;
discreteRateLimit.current.tokens -= 1;

if (event.type === 'mouse' && event.action === 'down') {
pressedMouseButtons.current.add(event.button);
} else if (event.type === 'keyboard' && event.action === 'down') {
pressedKeys.current.add(event.code);
}

return true;
}, []);

// Initialize input injection system on mount
useEffect(() => {
const init = async () => {
Expand Down Expand Up @@ -178,10 +239,12 @@ export function useInputInjection({

// Flush pending events in batch
const flushEvents = useCallback(async () => {
if (pendingEvents.current.length === 0) return;

const events = [...pendingEvents.current];
pendingEvents.current = [];
const events = [pendingMove.current, pendingScroll.current].filter(
(event): event is InputEvent => event !== null
);
pendingMove.current = null;
pendingScroll.current = null;
if (events.length === 0) return;

await enqueueInjection(
() => window.electronAPI.invoke('input:injectBatch', { events }),
Expand All @@ -194,16 +257,43 @@ export function useInputInjection({
async (event: InputEvent) => {
if (!isEnabledRef.current) return;

// For mouse moves, batch them up
// Intermediate mouse positions are not useful to the host. Keep only
// the latest one and sample it once per frame.
if (event.type === 'mouse' && event.action === 'move') {
pendingEvents.current.push(event);
pendingMove.current = event;

// Flush after a short delay to batch moves
// Flush after a short delay to sample at most once per frame.
flushTimeout.current ??= setTimeout(() => {
flushTimeout.current = null;
void flushEvents();
}, 16); // ~60fps
}, INPUT_FRAME_MS); // ~60fps
} else if (event.type === 'mouse' && event.action === 'scroll') {
// Trackpads emit scrolls at a very high rate. Preserve the cumulative
// distance while turning the whole frame into one OS injection.
const existing = pendingScroll.current;
pendingScroll.current =
existing && existing.deltaMode === event.deltaMode
? {
...event,
deltaX: existing.deltaX + event.deltaX,
deltaY: existing.deltaY + event.deltaY,
}
: event;

flushTimeout.current ??= setTimeout(() => {
flushTimeout.current = null;
void flushEvents();
}, INPUT_FRAME_MS);
} else {
const isRequiredRelease =
(event.type === 'mouse' &&
event.action === 'up' &&
pressedMouseButtons.current.has(event.button)) ||
(event.type === 'keyboard' &&
event.action === 'up' &&
pressedKeys.current.has(event.code));
if (!consumeDiscreteEventToken(event)) return;

// For clicks and keyboard, inject immediately
// But first flush any pending moves
if (flushTimeout.current) {
Expand All @@ -217,13 +307,14 @@ export function useInputInjection({
const moveFlush = flushEvents();
const injection = enqueueInjection(
() => window.electronAPI.invoke('input:inject', { event }),
'[useInputInjection] Failed to inject:'
'[useInputInjection] Failed to inject:',
isRequiredRelease
);
await moveFlush;
await injection;
}
},
[flushEvents, enqueueInjection]
[flushEvents, enqueueInjection, consumeDiscreteEventToken]
);

// Inject multiple events in batch
Expand Down Expand Up @@ -254,10 +345,17 @@ export function useInputInjection({

// Cleanup on unmount
useEffect(() => {
const mouseButtons = pressedMouseButtons.current;
const keys = pressedKeys.current;

return () => {
if (flushTimeout.current) {
clearTimeout(flushTimeout.current);
}
pendingMove.current = null;
pendingScroll.current = null;
mouseButtons.clear();
keys.clear();
// Disable injection when component unmounts
if (isEnabled) {
isEnabledRef.current = false;
Expand Down
Loading
Loading