tenseleyflow/hyprkvm / df4590f

Browse files

Fix recovery mode: remove timeout, add edge check, do movefocus

Recovery mode improvements:
- Remove arbitrary timeout, exit when Super key is released instead
- Add same at_edge check as IPC Move before initiating transfer
- When not at edge, do movefocus ourselves (libinput dropped the keypress)

The key insight is that libinput's stale state causes it to DROP the
keypress entirely, so we must handle it ourselves in all cases - either
initiating transfer (at edge) or doing movefocus (not at edge).
Authored by espadonne
SHA
df4590fffad49d036e5cb08d157d13b31b9a17cd
Parents
77ea3e1
Tree
7ee7095

3 changed files

StatusFile+-
M hyprkvm-daemon/src/input/evdev_grab.rs 268 130
M hyprkvm-daemon/src/input/grabber.rs 6 0
M hyprkvm-daemon/src/main.rs 136 3
hyprkvm-daemon/src/input/evdev_grab.rsmodified
@@ -7,19 +7,29 @@ use std::collections::HashMap;
77
 use std::fs;
88
 use std::os::unix::io::{AsRawFd, BorrowedFd};
99
 use std::path::PathBuf;
10
-use std::sync::atomic::{AtomicBool, Ordering};
10
+use std::sync::atomic::{AtomicBool, AtomicU64, Ordering};
1111
 use std::sync::mpsc;
1212
 use std::sync::Arc;
1313
 use std::thread;
1414
 
1515
 use evdev::{Device, InputEventKind};
16
+use hyprkvm_common::Direction;
1617
 use rustix::fs::{fcntl_setfl, OFlags};
1718
 
1819
 use super::grabber::GrabEvent;
1920
 
21
+// Recovery mode ends when:
22
+// 1. Super key is released (user stopped trying keybinds or will retry with fresh state)
23
+// 2. The target key combo is detected
24
+// 3. A new grab starts
25
+
2026
 /// Evdev-based input grabber
2127
 pub struct EvdevGrabber {
2228
     active: Arc<AtomicBool>,
29
+    /// Flag indicating recovery mode is active (1 = active, 0 = inactive)
30
+    recovery_active: Arc<AtomicU64>,
31
+    /// The direction to watch for in recovery mode (encoded as u8: 1=Up, 2=Down, 3=Left, 4=Right, 0=none)
32
+    recovery_direction: Arc<AtomicU64>,
2333
     event_rx: mpsc::Receiver<GrabEvent>,
2434
     _thread: thread::JoinHandle<()>,
2535
 }
@@ -29,13 +39,17 @@ impl EvdevGrabber {
2939
     pub fn new() -> Result<Self, EvdevGrabError> {
3040
         let active = Arc::new(AtomicBool::new(false));
3141
         let active_clone = active.clone();
42
+        let recovery_active = Arc::new(AtomicU64::new(0));
43
+        let recovery_clone = recovery_active.clone();
44
+        let recovery_direction = Arc::new(AtomicU64::new(0));
45
+        let recovery_dir_clone = recovery_direction.clone();
3246
 
3347
         let (event_tx, event_rx) = mpsc::channel();
3448
 
3549
         let thread = thread::Builder::new()
3650
             .name("evdev-grabber".to_string())
3751
             .spawn(move || {
38
-                if let Err(e) = run_evdev_grabber(active_clone, event_tx) {
52
+                if let Err(e) = run_evdev_grabber(active_clone, recovery_clone, recovery_dir_clone, event_tx) {
3953
                     tracing::error!("Evdev grabber error: {}", e);
4054
                 }
4155
             })
@@ -43,6 +57,8 @@ impl EvdevGrabber {
4357
 
4458
         Ok(Self {
4559
             active,
60
+            recovery_active,
61
+            recovery_direction,
4662
             event_rx,
4763
             _thread: thread,
4864
         })
@@ -51,12 +67,32 @@ impl EvdevGrabber {
5167
     /// Start grabbing input
5268
     pub fn start(&self) {
5369
         tracing::info!("Starting evdev input grab");
70
+        // Cancel any recovery mode when starting a new grab
71
+        self.recovery_active.store(0, Ordering::SeqCst);
5472
         self.active.store(true, Ordering::SeqCst);
5573
     }
5674
 
57
-    /// Stop grabbing input
58
-    pub fn stop(&self) {
59
-        tracing::info!("Stopping evdev input grab");
75
+    /// Stop grabbing input and enter recovery monitoring mode
76
+    /// `stale_direction` is the direction of the outgoing transfer - the key that's stale in libinput
77
+    pub fn stop(&self, stale_direction: Option<Direction>) {
78
+        // Encode direction: 1=Up, 2=Down, 3=Left, 4=Right, 0=none
79
+        let dir_code = match stale_direction {
80
+            Some(Direction::Up) => 1,
81
+            Some(Direction::Down) => 2,
82
+            Some(Direction::Left) => 3,
83
+            Some(Direction::Right) => 4,
84
+            None => 0,
85
+        };
86
+        self.recovery_direction.store(dir_code, Ordering::SeqCst);
87
+
88
+        tracing::info!("Stopping evdev input grab, entering recovery mode, watching for {:?}",
89
+            stale_direction);
90
+
91
+        // Activate recovery mode (will stay active until Super is released or hotkey detected)
92
+        if stale_direction.is_some() {
93
+            self.recovery_active.store(1, Ordering::SeqCst);
94
+        }
95
+        // Deactivate grab
6096
         self.active.store(false, Ordering::SeqCst);
6197
     }
6298
 
@@ -124,6 +160,8 @@ fn find_input_devices() -> Vec<PathBuf> {
124160
 
125161
 fn run_evdev_grabber(
126162
     active: Arc<AtomicBool>,
163
+    recovery_active: Arc<AtomicU64>,
164
+    recovery_direction: Arc<AtomicU64>,
127165
     event_tx: mpsc::Sender<GrabEvent>,
128166
 ) -> Result<(), EvdevGrabError> {
129167
     let device_paths = find_input_devices();
@@ -137,161 +175,261 @@ fn run_evdev_grabber(
137175
         tracing::debug!("  {}", path.display());
138176
     }
139177
 
140
-    // We'll open and grab devices fresh each time we activate
178
+    // State machine states
179
+    #[derive(Debug, Clone, Copy, PartialEq)]
180
+    enum State {
181
+        Idle,
182
+        Grabbed,
183
+        Recovery,
184
+    }
185
+
141186
     let mut devices: HashMap<PathBuf, Device> = HashMap::new();
142
-    let mut grabbed = false;
143
-    let mut last_active = false;
187
+    let mut state = State::Idle;
188
+
189
+    // Key state tracking for recovery mode (Super + Arrow detection)
190
+    let mut super_held = false;
191
+    let mut super_was_held_at_start = false; // Track if Super was held when recovery started
192
+    let mut recovery_hotkey_sent = false;
193
+
194
+    // Key codes
195
+    const KEY_LEFTMETA: u16 = 125;
196
+    const KEY_RIGHTMETA: u16 = 126;
197
+    const KEY_UP: u16 = 103;
198
+    const KEY_DOWN: u16 = 108;
199
+    const KEY_LEFT: u16 = 105;
200
+    const KEY_RIGHT: u16 = 106;
144201
 
145202
     loop {
146203
         let is_active = active.load(Ordering::SeqCst);
147
-
148
-        // Handle grab/ungrab transitions
149
-        if is_active != last_active {
150
-            last_active = is_active;
151
-
152
-            if is_active {
153
-                // Open and grab all devices fresh
154
-                devices.clear();
155
-                tracing::info!("Opening and grabbing input devices...");
156
-
157
-                for path in &device_paths {
158
-                    match Device::open(path) {
159
-                        Ok(mut dev) => {
160
-                            let name = dev.name().unwrap_or("unknown").to_string();
161
-
162
-                            // Set non-blocking mode
163
-                            // SAFETY: dev owns the fd and will outlive this borrow
164
-                            let fd = unsafe { BorrowedFd::borrow_raw(dev.as_raw_fd()) };
165
-                            if let Err(e) = fcntl_setfl(fd, OFlags::NONBLOCK) {
166
-                                tracing::warn!("Failed to set non-blocking on {}: {}", name, e);
204
+        let in_recovery = recovery_active.load(Ordering::SeqCst) == 1;
205
+
206
+        // State transitions
207
+        match state {
208
+            State::Idle => {
209
+                if is_active {
210
+                    // Transition to Grabbed
211
+                    devices.clear();
212
+                    tracing::info!("Opening and grabbing input devices...");
213
+
214
+                    for path in &device_paths {
215
+                        match Device::open(path) {
216
+                            Ok(mut dev) => {
217
+                                let name = dev.name().unwrap_or("unknown").to_string();
218
+                                let fd = unsafe { BorrowedFd::borrow_raw(dev.as_raw_fd()) };
219
+                                let _ = fcntl_setfl(fd, OFlags::NONBLOCK);
220
+                                let _ = dev.fetch_events(); // Drain pending
221
+
222
+                                match dev.grab() {
223
+                                    Ok(()) => {
224
+                                        tracing::info!("Grabbed: {} ({})", name, path.display());
225
+                                        devices.insert(path.clone(), dev);
226
+                                    }
227
+                                    Err(e) => {
228
+                                        tracing::warn!("Cannot grab {}: {}", name, e);
229
+                                    }
230
+                                }
231
+                            }
232
+                            Err(e) => {
233
+                                tracing::warn!("Failed to open {}: {}", path.display(), e);
167234
                             }
235
+                        }
236
+                    }
168237
 
169
-                            // Drain any pending events before grabbing to start fresh
170
-                            let _ = dev.fetch_events();
238
+                    if devices.is_empty() {
239
+                        tracing::error!("Failed to grab any input devices!");
240
+                    } else {
241
+                        tracing::info!("Successfully grabbed {} devices", devices.len());
242
+                        state = State::Grabbed;
243
+                    }
244
+                }
245
+            }
246
+
247
+            State::Grabbed => {
248
+                if !is_active {
249
+                    // Transition to Recovery (ungrab but keep devices open)
250
+                    tracing::info!("Releasing grab, entering recovery mode");
251
+
252
+                    for (_path, dev) in &mut devices {
253
+                        if let Err(e) = dev.ungrab() {
254
+                            tracing::warn!("Failed to ungrab: {}", e);
255
+                        }
256
+                    }
171257
 
172
-                            // Try to grab immediately after opening
173
-                            match dev.grab() {
174
-                                Ok(()) => {
175
-                                    tracing::info!("Grabbed: {} ({})", name, path.display());
176
-                                    devices.insert(path.clone(), dev);
258
+                    // Reset recovery state
259
+                    super_held = false;
260
+                    super_was_held_at_start = false;
261
+                    recovery_hotkey_sent = false;
262
+
263
+                    // Query current Super key state from physical keyboard
264
+                    for dev in devices.values() {
265
+                        if let Ok(key_state) = dev.get_key_state() {
266
+                            if key_state.contains(evdev::Key::new(KEY_LEFTMETA))
267
+                                || key_state.contains(evdev::Key::new(KEY_RIGHTMETA))
268
+                            {
269
+                                super_held = true;
270
+                                super_was_held_at_start = true;
271
+                                tracing::debug!("Super key is physically held at recovery start");
272
+                            }
273
+                        }
274
+                    }
275
+
276
+                    state = State::Recovery;
277
+                    tracing::info!("Now in recovery mode, super_held={}, super_was_held_at_start={}",
278
+                        super_held, super_was_held_at_start);
279
+                } else {
280
+                    // Still grabbed - forward events
281
+                    let mut motion_dx: f64 = 0.0;
282
+                    let mut motion_dy: f64 = 0.0;
283
+                    let mut scroll_h: f64 = 0.0;
284
+                    let mut scroll_v: f64 = 0.0;
285
+
286
+                    for dev in devices.values_mut() {
287
+                        if let Ok(events) = dev.fetch_events() {
288
+                            for ev in events {
289
+                                if let InputEventKind::Key(key) = ev.kind() {
290
+                                    tracing::debug!("RAW EVDEV: key={} value={}",
291
+                                        key.code(), ev.value());
177292
                                 }
178
-                                Err(e) => {
179
-                                    tracing::warn!("Cannot grab {} ({}): {}", name, path.display(), e);
180
-                                    // Don't add to devices if we can't grab
293
+                                match convert_event(&ev) {
294
+                                    Some(GrabEvent::PointerMotion { dx, dy }) => {
295
+                                        motion_dx += dx;
296
+                                        motion_dy += dy;
297
+                                    }
298
+                                    Some(GrabEvent::Scroll { horizontal, vertical }) => {
299
+                                        scroll_h += horizontal;
300
+                                        scroll_v += vertical;
301
+                                    }
302
+                                    Some(other) => {
303
+                                        if event_tx.send(other).is_err() {
304
+                                            return Ok(());
305
+                                        }
306
+                                    }
307
+                                    None => {}
181308
                                 }
182309
                             }
183310
                         }
184
-                        Err(e) => {
185
-                            tracing::warn!("Failed to open {}: {}", path.display(), e);
311
+                    }
312
+
313
+                    if motion_dx != 0.0 || motion_dy != 0.0 {
314
+                        if event_tx.send(GrabEvent::PointerMotion { dx: motion_dx, dy: motion_dy }).is_err() {
315
+                            return Ok(());
316
+                        }
317
+                    }
318
+                    if scroll_h != 0.0 || scroll_v != 0.0 {
319
+                        if event_tx.send(GrabEvent::Scroll { horizontal: scroll_h, vertical: scroll_v }).is_err() {
320
+                            return Ok(());
186321
                         }
187322
                     }
188323
                 }
324
+            }
189325
 
190
-                if devices.is_empty() {
191
-                    tracing::error!("Failed to grab any input devices!");
192
-                } else {
193
-                    tracing::info!("Successfully grabbed {} devices", devices.len());
326
+            State::Recovery => {
327
+                if is_active {
328
+                    // New grab starting, go back to grabbed state
329
+                    // First close current devices, they'll be reopened fresh
330
+                    devices.clear();
331
+                    recovery_active.store(0, Ordering::SeqCst);
332
+                    state = State::Idle;
333
+                    continue;
194334
                 }
195
-                grabbed = true;
196
-            } else {
197
-                // Ungrab and close all devices
198
-                tracing::info!("Releasing {} input devices", devices.len());
199
-
200
-                for (path, mut dev) in devices.drain() {
201
-                    // Query physical key state while still grabbed
202
-                    let held_keys: Vec<u16> = if let Ok(state) = dev.get_key_state() {
203
-                        // Check which modifier/arrow keys are physically held
204
-                        let keys_to_check: &[u16] = &[
205
-                            125, 126,  // Super
206
-                            42, 54,    // Shift
207
-                            29, 97,    // Ctrl
208
-                            56, 100,   // Alt
209
-                            103, 108, 105, 106, // Arrows
210
-                        ];
211
-                        keys_to_check.iter()
212
-                            .filter(|&&k| state.contains(evdev::Key::new(k)))
213
-                            .copied()
214
-                            .collect()
215
-                    } else {
216
-                        Vec::new()
217
-                    };
218
-
219
-                    if !held_keys.is_empty() {
220
-                        tracing::debug!("Keys physically held during ungrab: {:?}", held_keys);
221
-                    }
222
-
223
-                    // Ungrab the device
224
-                    if let Err(e) = dev.ungrab() {
225
-                        tracing::warn!("Failed to ungrab {}: {}", path.display(), e);
226
-                        continue;
227
-                    }
228
-                    tracing::debug!("Released {}", path.display());
229335
 
230
-                    // Device is dropped here, closing the fd
336
+                if !in_recovery {
337
+                    // Recovery mode was disabled externally
338
+                    tracing::info!("Recovery mode ended (disabled)");
339
+                    devices.clear();
340
+                    state = State::Idle;
341
+                    continue;
231342
                 }
232
-                grabbed = false;
233343
 
234
-                // Brief pause to let libinput process the ungrab
235
-                thread::sleep(std::time::Duration::from_millis(5));
236
-            }
237
-        }
344
+                // In recovery mode - monitor for Super+Arrow
345
+                // Read events WITHOUT grab (we're just observing)
346
+                let mut should_end_recovery = false;
347
+                let mut end_reason = "";
348
+
349
+                // Decode the direction we're watching for
350
+                let watch_dir_code = recovery_direction.load(Ordering::SeqCst);
351
+                let watch_direction: Option<Direction> = match watch_dir_code {
352
+                    1 => Some(Direction::Up),
353
+                    2 => Some(Direction::Down),
354
+                    3 => Some(Direction::Left),
355
+                    4 => Some(Direction::Right),
356
+                    _ => None,
357
+                };
358
+
359
+                for dev in devices.values_mut() {
360
+                    if let Ok(events) = dev.fetch_events() {
361
+                        for ev in events {
362
+                            if let InputEventKind::Key(key) = ev.kind() {
363
+                                let keycode = key.code();
364
+                                let pressed = ev.value() == 1;
365
+                                let released = ev.value() == 0;
366
+
367
+                                // Track Super key state
368
+                                if keycode == KEY_LEFTMETA || keycode == KEY_RIGHTMETA {
369
+                                    if pressed {
370
+                                        super_held = true;
371
+                                        tracing::debug!("RECOVERY: Super pressed");
372
+                                    } else if released {
373
+                                        super_held = false;
374
+                                        tracing::debug!("RECOVERY: Super released");
375
+
376
+                                        // End recovery when Super is released
377
+                                        // If Super was held at start, user has finished their keybind attempt
378
+                                        // If Super wasn't held at start, they did a fresh Super press+release
379
+                                        if super_was_held_at_start {
380
+                                            tracing::info!("RECOVERY: Super released (was held at start), ending recovery");
381
+                                            should_end_recovery = true;
382
+                                            end_reason = "Super released";
383
+                                            break;
384
+                                        }
385
+                                    }
386
+                                }
238387
 
239
-        // Read events if grabbed
240
-        if grabbed && !devices.is_empty() {
241
-            // Accumulate motion deltas across all devices and events
242
-            let mut motion_dx: f64 = 0.0;
243
-            let mut motion_dy: f64 = 0.0;
244
-            let mut scroll_h: f64 = 0.0;
245
-            let mut scroll_v: f64 = 0.0;
246
-
247
-            for (_path, dev) in &mut devices {
248
-                // Non-blocking read
249
-                if let Ok(events) = dev.fetch_events() {
250
-                    for ev in events {
251
-                        // Log raw key events from kernel for debugging
252
-                        if let InputEventKind::Key(key) = ev.kind() {
253
-                            tracing::debug!("RAW EVDEV: key={} value={} (1=press, 0=release, 2=repeat)",
254
-                                key.code(), ev.value());
255
-                        }
256
-                        match convert_event(&ev) {
257
-                            Some(GrabEvent::PointerMotion { dx, dy }) => {
258
-                                // Accumulate motion instead of sending immediately
259
-                                motion_dx += dx;
260
-                                motion_dy += dy;
261
-                            }
262
-                            Some(GrabEvent::Scroll { horizontal, vertical }) => {
263
-                                // Accumulate scroll
264
-                                scroll_h += horizontal;
265
-                                scroll_v += vertical;
266
-                            }
267
-                            Some(other) => {
268
-                                // Key events etc. - send immediately
269
-                                if event_tx.send(other).is_err() {
270
-                                    return Ok(());
388
+                                // Check for Super+Arrow, but ONLY for the direction we're watching
389
+                                if pressed && super_held && !recovery_hotkey_sent {
390
+                                    let key_direction = match keycode {
391
+                                        KEY_UP => Some(Direction::Up),
392
+                                        KEY_DOWN => Some(Direction::Down),
393
+                                        KEY_LEFT => Some(Direction::Left),
394
+                                        KEY_RIGHT => Some(Direction::Right),
395
+                                        _ => None,
396
+                                    };
397
+
398
+                                    if let Some(dir) = key_direction {
399
+                                        // Only trigger if this matches the direction we're watching for
400
+                                        if watch_direction == Some(dir) {
401
+                                            tracing::info!("RECOVERY: Detected Super+{:?} (matches watch direction), sending hotkey event", dir);
402
+                                            if event_tx.send(GrabEvent::RecoveryHotkey { direction: dir }).is_err() {
403
+                                                return Ok(());
404
+                                            }
405
+                                            recovery_hotkey_sent = true;
406
+                                            should_end_recovery = true;
407
+                                            end_reason = "hotkey detected";
408
+                                            break;
409
+                                        } else {
410
+                                            tracing::debug!("RECOVERY: Ignoring Super+{:?} (watching for {:?})", dir, watch_direction);
411
+                                        }
412
+                                    }
271413
                                 }
272414
                             }
273
-                            None => {}
274415
                         }
275416
                     }
417
+                    if should_end_recovery {
418
+                        break;
419
+                    }
276420
                 }
277
-            }
278
-
279
-            // Send accumulated motion as single event (if any)
280
-            if motion_dx != 0.0 || motion_dy != 0.0 {
281
-                if event_tx.send(GrabEvent::PointerMotion { dx: motion_dx, dy: motion_dy }).is_err() {
282
-                    return Ok(());
283
-                }
284
-            }
285421
 
286
-            // Send accumulated scroll as single event (if any)
287
-            if scroll_h != 0.0 || scroll_v != 0.0 {
288
-                if event_tx.send(GrabEvent::Scroll { horizontal: scroll_h, vertical: scroll_v }).is_err() {
289
-                    return Ok(());
422
+                // End recovery mode if hotkey was detected or Super was released
423
+                if should_end_recovery {
424
+                    tracing::info!("Recovery mode ended ({})", end_reason);
425
+                    devices.clear();
426
+                    recovery_active.store(0, Ordering::SeqCst);
427
+                    state = State::Idle;
290428
                 }
291429
             }
292430
         }
293431
 
294
-        // Minimal sleep to avoid busy-looping while keeping latency low
432
+        // Minimal sleep
295433
         thread::sleep(std::time::Duration::from_micros(100));
296434
     }
297435
 }
hyprkvm-daemon/src/input/grabber.rsmodified
@@ -44,6 +44,8 @@ pub enum GrabEvent {
4444
     PointerButton { button: u32, pressed: bool },
4545
     Scroll { horizontal: f64, vertical: f64 },
4646
     ModifiersChanged { mods: Modifiers },
47
+    /// Hotkey detected during recovery monitoring (bypasses libinput stale state)
48
+    RecoveryHotkey { direction: hyprkvm_common::Direction },
4749
 }
4850
 
4951
 impl GrabEvent {
@@ -77,6 +79,10 @@ impl GrabEvent {
7779
                     super_key: mods.logo,
7880
                 }
7981
             }
82
+            GrabEvent::RecoveryHotkey { .. } => {
83
+                // This is a local-only event, should never be sent over network
84
+                panic!("RecoveryHotkey cannot be converted to protocol");
85
+            }
8086
         };
8187
 
8288
         InputEventPayload {
hyprkvm-daemon/src/main.rsmodified
@@ -520,6 +520,10 @@ async fn run_daemon(config_path: &std::path::Path) -> anyhow::Result<()> {
520520
                             input::GrabEvent::ModifiersChanged { .. } => {
521521
                                 other_events.push(grab_event);
522522
                             }
523
+                            input::GrabEvent::RecoveryHotkey { .. } => {
524
+                                // Should not happen during capture, ignore
525
+                                tracing::warn!("RecoveryHotkey received during capture, ignoring");
526
+                            }
523527
                         }
524528
                     }
525529
 
@@ -561,7 +565,7 @@ async fn run_daemon(config_path: &std::path::Path) -> anyhow::Result<()> {
561565
                     if should_escape {
562566
                         info!("Escape triggered - stopping capture");
563567
                         capture_direction = None;
564
-                        input_grabber.stop();
568
+                        input_grabber.stop(None); // No recovery needed for escape
565569
 
566570
                         // Send Leave message - we're leaving in the opposite direction (returning to us)
567571
                         let leave = Message::Leave(hyprkvm_common::protocol::LeavePayload {
@@ -577,6 +581,134 @@ async fn run_daemon(config_path: &std::path::Path) -> anyhow::Result<()> {
577581
                             }
578582
                         }
579583
                     }
584
+                } else {
585
+                    // Not capturing - check for RecoveryHotkey events from recovery mode
586
+                    // These bypass libinput's stale state by detecting keypresses directly at evdev level
587
+                    while let Some(grab_event) = input_grabber.try_recv() {
588
+                        if let input::GrabEvent::RecoveryHotkey { direction } = grab_event {
589
+                            info!("RECOVERY HOTKEY: Super+{:?} detected via evdev", direction);
590
+
591
+                            // Same at_edge check as IPC Move - only transfer if at edge monitor+window
592
+                            let at_edge = 'edge_check: {
593
+                                // Get monitors and find focused one
594
+                                let monitors = match hypr_client.monitors().await {
595
+                                    Ok(m) => m,
596
+                                    Err(e) => {
597
+                                        info!("  RECOVERY edge_check: monitors query failed: {}", e);
598
+                                        break 'edge_check false;
599
+                                    }
600
+                                };
601
+                                let focused_monitor = match monitors.iter().find(|m| m.focused) {
602
+                                    Some(m) => m,
603
+                                    None => {
604
+                                        info!("  RECOVERY edge_check: no focused monitor found");
605
+                                        break 'edge_check false;
606
+                                    }
607
+                                };
608
+
609
+                                // Check if there's another monitor in the requested direction
610
+                                let has_monitor_in_direction = monitors.iter().any(|m| {
611
+                                    if m.id == focused_monitor.id { return false; }
612
+                                    match direction {
613
+                                        Direction::Left => m.x + m.width as i32 <= focused_monitor.x,
614
+                                        Direction::Right => m.x >= focused_monitor.x + focused_monitor.width as i32,
615
+                                        Direction::Up => m.y + m.height as i32 <= focused_monitor.y,
616
+                                        Direction::Down => m.y >= focused_monitor.y + focused_monitor.height as i32,
617
+                                    }
618
+                                });
619
+
620
+                                if has_monitor_in_direction {
621
+                                    info!("  RECOVERY edge_check: has monitor in direction {:?}", direction);
622
+                                    break 'edge_check false;
623
+                                }
624
+
625
+                                // On edge monitor. Check if at edge window.
626
+                                let active_window: serde_json::Value = match hypr_client.query("activewindow").await {
627
+                                    Ok(w) => w,
628
+                                    Err(e) => {
629
+                                        info!("  RECOVERY edge_check: activewindow query failed: {}", e);
630
+                                        break 'edge_check false;
631
+                                    }
632
+                                };
633
+
634
+                                let win_x = active_window.get("at").and_then(|a| a.get(0)).and_then(|x| x.as_i64()).unwrap_or(0) as i32;
635
+                                let win_y = active_window.get("at").and_then(|a| a.get(1)).and_then(|y| y.as_i64()).unwrap_or(0) as i32;
636
+                                let win_w = active_window.get("size").and_then(|s| s.get(0)).and_then(|w| w.as_i64()).unwrap_or(100) as i32;
637
+                                let win_h = active_window.get("size").and_then(|s| s.get(1)).and_then(|h| h.as_i64()).unwrap_or(100) as i32;
638
+
639
+                                // Get all clients (windows)
640
+                                let clients: Vec<serde_json::Value> = match hypr_client.query("clients").await {
641
+                                    Ok(c) => c,
642
+                                    Err(e) => {
643
+                                        info!("  RECOVERY edge_check: clients query failed: {}", e);
644
+                                        break 'edge_check false;
645
+                                    }
646
+                                };
647
+
648
+                                // Check if any window is further in the requested direction on same monitor
649
+                                let has_window_in_direction = clients.iter().any(|client| {
650
+                                    let mon = client.get("monitor").and_then(|m| m.as_i64()).unwrap_or(-1) as i32;
651
+                                    if mon != focused_monitor.id { return false; }
652
+
653
+                                    let cx = client.get("at").and_then(|a| a.get(0)).and_then(|x| x.as_i64()).unwrap_or(0) as i32;
654
+                                    let cy = client.get("at").and_then(|a| a.get(1)).and_then(|y| y.as_i64()).unwrap_or(0) as i32;
655
+                                    let cw = client.get("size").and_then(|s| s.get(0)).and_then(|w| w.as_i64()).unwrap_or(0) as i32;
656
+                                    let ch = client.get("size").and_then(|s| s.get(1)).and_then(|h| h.as_i64()).unwrap_or(0) as i32;
657
+
658
+                                    match direction {
659
+                                        Direction::Left => cx + cw < win_x + 10,
660
+                                        Direction::Right => cx > win_x + win_w - 10,
661
+                                        Direction::Up => cy + ch < win_y + 10,
662
+                                        Direction::Down => cy > win_y + win_h - 10,
663
+                                    }
664
+                                });
665
+
666
+                                info!("  RECOVERY edge_check: has_window_in_direction={} -> at_edge={}", has_window_in_direction, !has_window_in_direction);
667
+                                !has_window_in_direction
668
+                            };
669
+
670
+                            // Check if we have a peer in this direction
671
+                            let has_peer = {
672
+                                let peers = peers.read().await;
673
+                                peers.contains_key(&direction)
674
+                            };
675
+
676
+                            if at_edge && has_peer {
677
+                                // Get cursor position for transfer
678
+                                let cursor_pos = hypr_client.cursor_pos().await
679
+                                    .map(|c| (c.x, c.y))
680
+                                    .unwrap_or((0, 0));
681
+
682
+                                info!("RECOVERY HOTKEY: At edge with peer, initiating transfer to {:?}", direction);
683
+                                if let Err(e) = transfer_manager.initiate_transfer(
684
+                                    direction,
685
+                                    cursor_pos,
686
+                                    screen_height,
687
+                                    screen_width,
688
+                                ).await {
689
+                                    tracing::error!("Failed to initiate transfer from recovery hotkey: {}", e);
690
+                                }
691
+                            } else if !at_edge {
692
+                                // Not at edge - need to do movefocus ourselves because libinput
693
+                                // DROPPED the keypress due to stale state (it thinks the arrow key
694
+                                // is still pressed from before the grab). This is the whole reason
695
+                                // recovery mode exists.
696
+                                let hypr_dir = match direction {
697
+                                    Direction::Left => "l",
698
+                                    Direction::Right => "r",
699
+                                    Direction::Up => "u",
700
+                                    Direction::Down => "d",
701
+                                };
702
+                                info!("RECOVERY HOTKEY: Not at edge, doing movefocus {} (libinput dropped the keypress)", hypr_dir);
703
+                                match hypr_client.dispatch("movefocus", hypr_dir).await {
704
+                                    Ok(()) => info!("  RECOVERY movefocus succeeded"),
705
+                                    Err(e) => tracing::error!("  RECOVERY movefocus failed: {}", e),
706
+                                }
707
+                            } else {
708
+                                info!("RECOVERY HOTKEY: No peer in direction {:?}", direction);
709
+                            }
710
+                        }
711
+                    }
580712
                 }
581713
 
582714
                 // Handle edge events from layer-shell barriers (for inter-monitor edges)
@@ -946,8 +1078,9 @@ async fn run_daemon(config_path: &std::path::Path) -> anyhow::Result<()> {
9461078
                         let was_capturing_direction = capture_direction;
9471079
                         capture_direction = None;
9481080
 
949
-                        // Release the evdev grab
950
-                        input_grabber.stop();
1081
+                        // Release the evdev grab and enter recovery mode for the stale direction
1082
+                        // The stale key is the arrow key used to initiate the original outgoing transfer
1083
+                        input_grabber.stop(was_capturing_direction);
9511084
 
9521085
                         // Drain any remaining events
9531086
                         while input_grabber.try_recv().is_some() {}