From 373e38215281692f15f8d3fc36a0adccff80df62 Mon Sep 17 00:00:00 2001 From: Jon Kinney Date: Mon, 27 Apr 2026 12:59:33 -0500 Subject: [PATCH] fix(capture): release peer keys on release-bind MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When the user pressed the release-bind chord (typically all four modifiers) the down events for the chord were forwarded to the peer while capture was active, but the matching up events arrived after the local tap flipped to passthrough and were never forwarded. The peer was left with phantom held modifiers until either its watchdog ran (1+ s) or the Leave message was processed — and Leave is sent over UDP/DTLS and can be lost. Drain the capture's pressed_keys set in release_capture and emit a KeyboardEvent::Key{state: 0} for every still-held key, plus a zeroed KeyboardEvent::Modifiers, before sending Leave. The receiver already maintains pressed_keys per handle and processes these key-up events through its normal path, so no protocol change is required and an unmodified peer picks up the fix automatically. The receiver-side release_keys safety net stays in place for the genuine packet-loss / disconnect-without-Leave cases. Co-Authored-By: Claude Opus 4.7 (1M context) --- input-capture/src/lib.rs | 13 +++++++++++++ src/capture.rs | 37 ++++++++++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) diff --git a/input-capture/src/lib.rs b/input-capture/src/lib.rs index 4767503..b1ef6c0 100644 --- a/input-capture/src/lib.rs +++ b/input-capture/src/lib.rs @@ -171,6 +171,19 @@ impl InputCapture { self.capture.release().await } + /// Drain and return every key the capture has forwarded as + /// down-but-not-up. The caller is expected to synthesize key-up + /// events to the remote peer for each — otherwise the peer + /// retains phantom-held keys after capture is released. The + /// canonical case is the release-bind chord + /// (Ctrl+Shift+Alt+Meta): the down events were sent while + /// capture was active, but the matching up events arrive after + /// the local tap has flipped to passthrough and never reach + /// the peer. + pub fn take_pressed_keys(&mut self) -> HashSet { + std::mem::take(&mut self.pressed_keys) + } + /// destroy the input capture pub async fn terminate(&mut self) -> Result<(), CaptureError> { self.capture.terminate().await diff --git a/src/capture.rs b/src/capture.rs index 4300dea..8f739bd 100644 --- a/src/capture.rs +++ b/src/capture.rs @@ -8,7 +8,7 @@ use futures::StreamExt; use input_capture::{ CaptureError, CaptureEvent, CaptureHandle, InputCapture, InputCaptureError, Position, }; -use input_event::scancode; +use input_event::{Event, KeyboardEvent, scancode}; use lan_mouse_proto::ProtoEvent; use local_channel::mpsc::{Receiver, Sender, channel}; use tokio::task::{JoinHandle, spawn_local}; @@ -376,6 +376,41 @@ impl CaptureTask { async fn release_capture(&mut self, capture: &mut InputCapture) -> Result<(), CaptureError> { // If we have an active client, notify them we're leaving if let Some(handle) = self.active_client.take() { + // Synthesize key-up events for every key still held in the + // capture's pressed_keys set BEFORE sending Leave. Without + // this, pressing the release-bind chord (typically all four + // modifiers) leaves the peer with phantom held modifiers: + // the down events were forwarded while capture was active, + // but the matching up events arrive after the local tap + // flips to passthrough and never reach the peer. The peer + // then runs every subsequent keystroke through those held + // mods until its watchdog times out (1+ s) or our Leave + // arrives — and Leave can be lost over UDP/DTLS. + for key in capture.take_pressed_keys() { + let key_up = ProtoEvent::Input(Event::Keyboard(KeyboardEvent::Key { + time: 0, + key: key as u32, + state: 0, + })); + if let Err(e) = self.conn.send(key_up, handle).await { + log::warn!("failed to send key-up to client {handle}: {e}"); + } + } + // Reset the modifier mask too. The peer's input-emulation + // layer keeps a separate XKB-style modifier state that's + // updated by KeyboardEvent::Modifiers, distinct from the + // pressed_keys set drained above. Without this, an + // already-locked CapsLock would survive the release. + let mods_zero = ProtoEvent::Input(Event::Keyboard(KeyboardEvent::Modifiers { + depressed: 0, + latched: 0, + locked: 0, + group: 0, + })); + if let Err(e) = self.conn.send(mods_zero, handle).await { + log::warn!("failed to reset modifiers on client {handle}: {e}"); + } + log::info!("sending Leave event to client {handle}"); if let Err(e) = self.conn.send(ProtoEvent::Leave(0), handle).await { log::warn!("failed to send Leave to client {handle}: {e}");