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
83 changes: 83 additions & 0 deletions apps/desktop/src/renderer/hooks/useInputInjection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -311,6 +311,89 @@ describe('useInputInjection', () => {
});
expect(mockElectronAPI.invoke).toHaveBeenCalledWith('input:inject', { event: clickEvent });
});

it('keeps mouse down before mouse up while a pending move batch is slow', async () => {
let finishMoveBatch: (() => void) | undefined;
const moveBatchPending = new Promise<void>((resolve) => {
finishMoveBatch = resolve;
});

mockElectronAPI.invoke.mockImplementation((channel: string) => {
switch (channel) {
case 'input:init':
return Promise.resolve({ success: true });
case 'input:status':
return Promise.resolve({
enabled: false,
backend: 'nut-js',
backendSupported: true,
stats: { received: 0, injected: 0, errors: 0 },
});
case 'input:enable':
return Promise.resolve({
success: true,
enabled: true,
backend: 'nut-js',
backendSupported: true,
stats: { received: 0, injected: 0, errors: 0 },
});
case 'input:injectBatch':
return moveBatchPending;
default:
return Promise.resolve({ success: true });
}
});

const { result } = renderHook(() => useInputInjection({ enabled: true }));

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

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

let downPromise: Promise<void>;
let upPromise: Promise<void>;
await act(async () => {
await result.current.injectEvent(moveEvent);
downPromise = result.current.injectEvent(downEvent);
upPromise = result.current.injectEvent(upEvent);
await Promise.resolve();
});

const whileMoveIsPending = mockElectronAPI.invoke.mock.calls.filter(
([channel]) => channel === 'input:injectBatch' || channel === 'input:inject'
);
expect(whileMoveIsPending).toEqual([['input:injectBatch', { events: [moveEvent] }]]);

finishMoveBatch?.();
await act(async () => {
await Promise.all([downPromise!, upPromise!]);
});

const injectionCalls = mockElectronAPI.invoke.mock.calls.filter(
([channel]) => channel === 'input:injectBatch' || channel === 'input:inject'
);
expect(injectionCalls).toEqual([
['input:injectBatch', { events: [moveEvent] }],
['input:inject', { event: downEvent }],
['input:inject', { event: upEvent }],
]);
});
});

describe('injectBatch', () => {
Expand Down
63 changes: 44 additions & 19 deletions apps/desktop/src/renderer/hooks/useInputInjection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,29 @@ export function useInputInjection({
const [diagnostics, setDiagnostics] = useState<InputInjectionDiagnostics | null>(null);
const pendingEvents = useRef<InputEvent[]>([]);
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 the virtual mouse button held on the host desktop.
const injectionQueue = useRef<Promise<void>>(Promise.resolve());

const enqueueInjection = useCallback(
(operation: () => Promise<unknown>, errorMessage: string): Promise<void> => {
const queued = injectionQueue.current.then(async () => {
try {
await operation();
} catch (error) {
console.error(errorMessage, error);
}
});

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

// Initialize input injection system on mount
useEffect(() => {
Expand Down Expand Up @@ -132,12 +155,11 @@ export function useInputInjection({
const events = [...pendingEvents.current];
pendingEvents.current = [];

try {
await window.electronAPI.invoke('input:injectBatch', { events });
} catch (error) {
console.error('[useInputInjection] Failed to inject batch:', error);
}
}, []);
await enqueueInjection(
() => window.electronAPI.invoke('input:injectBatch', { events }),
'[useInputInjection] Failed to inject batch:'
);
}, [enqueueInjection]);

// Inject a single event (batched for performance)
const injectEvent = useCallback(
Expand All @@ -160,30 +182,33 @@ export function useInputInjection({
clearTimeout(flushTimeout.current);
flushTimeout.current = null;
}
await flushEvents();

try {
await window.electronAPI.invoke('input:inject', { event });
} catch (error) {
console.error('[useInputInjection] Failed to inject:', error);
}
// Enqueue both operations synchronously. Awaiting the move flush here
// before reserving the button event's place lets a subsequent mouseup
// jump ahead of its mousedown.
const moveFlush = flushEvents();
const injection = enqueueInjection(
() => window.electronAPI.invoke('input:inject', { event }),
'[useInputInjection] Failed to inject:'
);
await moveFlush;
await injection;
}
},
[isEnabled, flushEvents]
[isEnabled, flushEvents, enqueueInjection]
);

// Inject multiple events in batch
const injectBatch = useCallback(
async (events: InputEvent[]) => {
if (!isEnabled || events.length === 0) return;

try {
await window.electronAPI.invoke('input:injectBatch', { events });
} catch (error) {
console.error('[useInputInjection] Failed to inject batch:', error);
}
await enqueueInjection(
() => window.electronAPI.invoke('input:injectBatch', { events }),
'[useInputInjection] Failed to inject batch:'
);
},
[isEnabled]
[isEnabled, enqueueInjection]
);

// Emergency stop
Expand Down
Loading