From 8f56fdf10eeaad8e40cf8a862bb1a8035d4b6653 Mon Sep 17 00:00:00 2001 From: Mr P-Tech Date: Mon, 10 Aug 2026 13:20:00 +0100 Subject: [PATCH] fix(control): serialize inject/disable to prevent permanently stuck clicks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When inject('down') was in-flight (mid-await inside dispatch), disable() released nothing because trackHeldState hadn't added the button to heldButtons yet. Nut.js had already pressed it at the OS level, so the release never came. Every subsequent click was then treated as a continuation of that drag — hence "neither side can click anything." Inject now chains onto a promise so disable() and emergencyStop() wait for any in-progress injection to finish before reading heldButtons — releaseAll always sees the post-trackHeldState state. Also adds pointer event listeners so trackpads that fire pointer events without mouse events still produce clicks on the host. A dedup ref prevents the mouse-event double-fire Chromium also delivers. --- .../src/renderer/hooks/useRemoteControl.ts | 46 ++++++++++++++ apps/web/src/hooks/useRemoteControl.ts | 46 ++++++++++++++ packages/remote-input/src/injector.ts | 61 +++++++++++++------ 3 files changed, 135 insertions(+), 18 deletions(-) diff --git a/apps/desktop/src/renderer/hooks/useRemoteControl.ts b/apps/desktop/src/renderer/hooks/useRemoteControl.ts index e8477e6..e098c92 100644 --- a/apps/desktop/src/renderer/hooks/useRemoteControl.ts +++ b/apps/desktop/src/renderer/hooks/useRemoteControl.ts @@ -55,6 +55,9 @@ export function useRemoteControl({ const heldButtonsRef = useRef>(new Set()); const heldKeysRef = useRef>(new Set()); const lastCursorUpdateRef = useRef(0); + // Pointer events fire before mouse events in Chromium. Storing the last + // pointer event's timestamp lets the mouse handler skip a double-fire. + const lastPointerEventRef = useRef(0); const cursorThrottleMs = 16; // ~60fps throttle for cursor updates // Check if we can send input (enabled, granted control, and capturing) @@ -112,6 +115,9 @@ export function useRemoteControl({ (event: MouseEvent) => { if (!canSendInput) return; + // Skip if a pointer event just fired (Chromium fires both). + if (Date.now() - lastPointerEventRef.current < 100) return; + const coords = getRelativeCoords(event); if (!coords) return; @@ -149,6 +155,9 @@ export function useRemoteControl({ (event: MouseEvent) => { if (!canSendInput) return; + // Skip if a pointer event just fired. + if (Date.now() - lastPointerEventRef.current < 100) return; + const coords = getRelativeCoords(event); if (!coords) return; @@ -271,6 +280,33 @@ export function useRemoteControl({ [canSendInput] ); + // Pointer events fire before mouse events in Chromium, and on some trackpads + // only pointer events fire at all. Delegate to the mouse handlers, then mark + // the timestamp so the mouse-handler dedup skips the follow-up mouse event. + const handlePointerDown = useCallback( + (event: PointerEvent) => { + lastPointerEventRef.current = Date.now(); + handleMouseDown(event as unknown as MouseEvent); + }, + [handleMouseDown] + ); + + const handlePointerUp = useCallback( + (event: PointerEvent) => { + lastPointerEventRef.current = Date.now(); + handleMouseUp(event as unknown as MouseEvent); + }, + [handleMouseUp] + ); + + const handlePointerMove = useCallback( + (event: PointerEvent) => { + lastPointerEventRef.current = Date.now(); + handleMouseMove(event as unknown as MouseEvent); + }, + [handleMouseMove] + ); + // Start capturing input const startCapture = useCallback(() => { setIsCapturing(true); @@ -294,6 +330,10 @@ export function useRemoteControl({ container.addEventListener('mousemove', handleMouseMove); container.addEventListener('mousedown', handleMouseDown); container.addEventListener('mouseup', handleMouseUp); + // Pointer events cover trackpads that don't fire mouse events. + container.addEventListener('pointermove', handlePointerMove); + container.addEventListener('pointerdown', handlePointerDown); + container.addEventListener('pointerup', handlePointerUp); container.addEventListener('wheel', handleWheel, { passive: false }); container.addEventListener('mouseleave', handleMouseLeave); container.addEventListener('contextmenu', handleContextMenu); @@ -310,6 +350,9 @@ export function useRemoteControl({ container.removeEventListener('mousemove', handleMouseMove); container.removeEventListener('mousedown', handleMouseDown); container.removeEventListener('mouseup', handleMouseUp); + container.removeEventListener('pointermove', handlePointerMove); + container.removeEventListener('pointerdown', handlePointerDown); + container.removeEventListener('pointerup', handlePointerUp); container.removeEventListener('wheel', handleWheel); container.removeEventListener('mouseleave', handleMouseLeave); container.removeEventListener('contextmenu', handleContextMenu); @@ -329,6 +372,9 @@ export function useRemoteControl({ handleWheel, handleMouseLeave, handleContextMenu, + handlePointerDown, + handlePointerUp, + handlePointerMove, handleKeyDown, handleKeyUp, releaseHeldInput, diff --git a/apps/web/src/hooks/useRemoteControl.ts b/apps/web/src/hooks/useRemoteControl.ts index e8477e6..e098c92 100644 --- a/apps/web/src/hooks/useRemoteControl.ts +++ b/apps/web/src/hooks/useRemoteControl.ts @@ -55,6 +55,9 @@ export function useRemoteControl({ const heldButtonsRef = useRef>(new Set()); const heldKeysRef = useRef>(new Set()); const lastCursorUpdateRef = useRef(0); + // Pointer events fire before mouse events in Chromium. Storing the last + // pointer event's timestamp lets the mouse handler skip a double-fire. + const lastPointerEventRef = useRef(0); const cursorThrottleMs = 16; // ~60fps throttle for cursor updates // Check if we can send input (enabled, granted control, and capturing) @@ -112,6 +115,9 @@ export function useRemoteControl({ (event: MouseEvent) => { if (!canSendInput) return; + // Skip if a pointer event just fired (Chromium fires both). + if (Date.now() - lastPointerEventRef.current < 100) return; + const coords = getRelativeCoords(event); if (!coords) return; @@ -149,6 +155,9 @@ export function useRemoteControl({ (event: MouseEvent) => { if (!canSendInput) return; + // Skip if a pointer event just fired. + if (Date.now() - lastPointerEventRef.current < 100) return; + const coords = getRelativeCoords(event); if (!coords) return; @@ -271,6 +280,33 @@ export function useRemoteControl({ [canSendInput] ); + // Pointer events fire before mouse events in Chromium, and on some trackpads + // only pointer events fire at all. Delegate to the mouse handlers, then mark + // the timestamp so the mouse-handler dedup skips the follow-up mouse event. + const handlePointerDown = useCallback( + (event: PointerEvent) => { + lastPointerEventRef.current = Date.now(); + handleMouseDown(event as unknown as MouseEvent); + }, + [handleMouseDown] + ); + + const handlePointerUp = useCallback( + (event: PointerEvent) => { + lastPointerEventRef.current = Date.now(); + handleMouseUp(event as unknown as MouseEvent); + }, + [handleMouseUp] + ); + + const handlePointerMove = useCallback( + (event: PointerEvent) => { + lastPointerEventRef.current = Date.now(); + handleMouseMove(event as unknown as MouseEvent); + }, + [handleMouseMove] + ); + // Start capturing input const startCapture = useCallback(() => { setIsCapturing(true); @@ -294,6 +330,10 @@ export function useRemoteControl({ container.addEventListener('mousemove', handleMouseMove); container.addEventListener('mousedown', handleMouseDown); container.addEventListener('mouseup', handleMouseUp); + // Pointer events cover trackpads that don't fire mouse events. + container.addEventListener('pointermove', handlePointerMove); + container.addEventListener('pointerdown', handlePointerDown); + container.addEventListener('pointerup', handlePointerUp); container.addEventListener('wheel', handleWheel, { passive: false }); container.addEventListener('mouseleave', handleMouseLeave); container.addEventListener('contextmenu', handleContextMenu); @@ -310,6 +350,9 @@ export function useRemoteControl({ container.removeEventListener('mousemove', handleMouseMove); container.removeEventListener('mousedown', handleMouseDown); container.removeEventListener('mouseup', handleMouseUp); + container.removeEventListener('pointermove', handlePointerMove); + container.removeEventListener('pointerdown', handlePointerDown); + container.removeEventListener('pointerup', handlePointerUp); container.removeEventListener('wheel', handleWheel); container.removeEventListener('mouseleave', handleMouseLeave); container.removeEventListener('contextmenu', handleContextMenu); @@ -329,6 +372,9 @@ export function useRemoteControl({ handleWheel, handleMouseLeave, handleContextMenu, + handlePointerDown, + handlePointerUp, + handlePointerMove, handleKeyDown, handleKeyUp, releaseHeldInput, diff --git a/packages/remote-input/src/injector.ts b/packages/remote-input/src/injector.ts index 6582475..19a41f1 100644 --- a/packages/remote-input/src/injector.ts +++ b/packages/remote-input/src/injector.ts @@ -88,6 +88,11 @@ export class RemoteInputInjector { /** Where the local pointer was before a remote click borrowed it. */ private borrowedFrom: { x: number; y: number } | null = null; + // Every async operation that results in an OS-level button press or release + // chains through this promise. disable() and emergencyStop() wait on it so + // releaseAll always runs *after* trackHeldState has recorded the press. + private pendingInject: Promise | null = null; + constructor(options: RemoteInputInjectorOptions = {}) { this.selection = options.selection ?? getInputBackendSelection(); this.makeBackend = options.createBackend ?? createInputBackend; @@ -163,9 +168,12 @@ export class RemoteInputInjector { disable(): void { this.enabled = false; - // Anything still held must come back up, or the host is left mid-drag with - // a physically stuck button and no way to recover short of a reboot. - void this.releaseAll('injection disabled'); + // Wait for any in-flight inject to finish (so trackHeldState has run), + // then let go of everything. releaseAll() is deliberately not gated on + // `enabled` — releasing is always safe and must never be skipped. + void (this.pendingInject ?? Promise.resolve()).then(() => + this.releaseAll('injection disabled') + ); this.logger.log('[RemoteInput] Injection disabled'); } @@ -377,23 +385,35 @@ export class RemoteInputInjector { return; } - const validation = validateInputEvent(event); - if (!validation.ok) { - // reason is always set when ok is false - this.reject(validation.reason ?? 'invalid-key', event, validation.detail); - this.logger.warn('[RemoteInput] Refused event', { - reason: validation.reason, - detail: validation.detail, - }); - return; - } - - if (!this.rateLimiter.shouldAllow()) { - this.reject('rate-limited', event, 'event rate ceiling exceeded'); - return; - } + // Serialize every injection so disable() and emergencyStop() can wait for + // us to finish (including trackHeldState) before they release everything. + // Without this, releaseAll can check heldButtons while dispatch is + // mid-await — nut-js has already pressed the button at the OS level, but + // trackHeldState hasn't recorded it yet — and the button stays stuck. + const prev = this.pendingInject; + let resolve: (() => void) | undefined; + this.pendingInject = new Promise((r) => { + resolve = r; + }); try { + await prev; + + const validation = validateInputEvent(event); + if (!validation.ok) { + this.reject(validation.reason ?? 'invalid-key', event, validation.detail); + this.logger.warn('[RemoteInput] Refused event', { + reason: validation.reason, + detail: validation.detail, + }); + return; + } + + if (!this.rateLimiter.shouldAllow()) { + this.reject('rate-limited', event, 'event rate ceiling exceeded'); + return; + } + await this.dispatch(event); this.stats.injected += 1; this.trackHeldState(event); @@ -405,6 +425,8 @@ export class RemoteInputInjector { action: 'action' in event ? event.action : undefined, error: error instanceof Error ? error.message : String(error), }); + } finally { + resolve?.(); } } @@ -416,6 +438,9 @@ export class RemoteInputInjector { async emergencyStop(): Promise { this.logger.log('[RemoteInput] Emergency stop'); this.enabled = false; + // Must wait for any in-flight inject to finish before releasing, for the + // same reason disable() does: trackHeldState might not have run yet. + if (this.pendingInject) await this.pendingInject; await this.releaseAll('emergency stop'); try {