diff --git a/apps/desktop/src/renderer/hooks/useInputInjection.test.ts b/apps/desktop/src/renderer/hooks/useInputInjection.test.ts index f5cc6b4..66a4e99 100644 --- a/apps/desktop/src/renderer/hooks/useInputInjection.test.ts +++ b/apps/desktop/src/renderer/hooks/useInputInjection.test.ts @@ -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((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; + let upPromise: Promise; + 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', () => { diff --git a/apps/desktop/src/renderer/hooks/useInputInjection.ts b/apps/desktop/src/renderer/hooks/useInputInjection.ts index 8a2c9c7..8f8ba58 100644 --- a/apps/desktop/src/renderer/hooks/useInputInjection.ts +++ b/apps/desktop/src/renderer/hooks/useInputInjection.ts @@ -44,6 +44,29 @@ export function useInputInjection({ const [diagnostics, setDiagnostics] = useState(null); const pendingEvents = useRef([]); const flushTimeout = useRef | 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.resolve()); + + const enqueueInjection = useCallback( + (operation: () => Promise, errorMessage: string): Promise => { + 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(() => { @@ -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( @@ -160,16 +182,20 @@ 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 @@ -177,13 +203,12 @@ export function useInputInjection({ 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