Rust · 150563 bytes Raw Blame History
1 //! HyprKVM Daemon - Main entry point
2 //!
3 //! The daemon handles:
4 //! - Hyprland IPC communication
5 //! - Edge detection (mouse and keyboard)
6 //! - Network connections to peer machines
7 //! - Input capture and injection
8
9 use clap::{Parser, Subcommand};
10 use tracing::{info, Level};
11 use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
12
13 mod clipboard;
14 mod config;
15 #[cfg(feature = "gui")]
16 mod gui;
17 mod hyprland;
18 mod input;
19 mod ipc;
20 mod network;
21 mod state;
22 mod transfer;
23
24 use config::Config;
25
26 /// Convert keycode to human-readable name for logging
27 fn keycode_to_name(keycode: u32) -> &'static str {
28 match keycode {
29 1 => "ESC",
30 14 => "BACKSPACE",
31 15 => "TAB",
32 28 => "ENTER",
33 29 => "LEFTCTRL",
34 42 => "LEFTSHIFT",
35 54 => "RIGHTSHIFT",
36 56 => "LEFTALT",
37 57 => "SPACE",
38 58 => "CAPSLOCK",
39 97 => "RIGHTCTRL",
40 100 => "RIGHTALT",
41 103 => "UP",
42 105 => "LEFT",
43 106 => "RIGHT",
44 108 => "DOWN",
45 125 => "LEFTMETA",
46 126 => "RIGHTMETA",
47 _ => "OTHER",
48 }
49 }
50
51 #[derive(Parser)]
52 #[command(name = "hyprkvm")]
53 #[command(about = "Hyprland-native software KVM switch")]
54 #[command(version)]
55 struct Cli {
56 /// Config file path
57 #[arg(short, long)]
58 config: Option<std::path::PathBuf>,
59
60 /// Increase log verbosity (-v, -vv, -vvv)
61 #[arg(short, long, action = clap::ArgAction::Count)]
62 verbose: u8,
63
64 #[command(subcommand)]
65 command: Option<Commands>,
66 }
67
68 #[derive(Subcommand)]
69 enum Commands {
70 /// Start the HyprKVM daemon
71 Daemon,
72
73 /// Show daemon status
74 Status,
75
76 /// Handle a move request (called by keybinding script)
77 Move {
78 /// Direction to move
79 direction: String,
80 },
81
82 /// Configuration management
83 Config {
84 #[command(subcommand)]
85 action: ConfigAction,
86 },
87
88 /// Launch the graphical configuration interface
89 #[cfg(feature = "gui")]
90 Gui,
91 }
92
93 #[derive(Subcommand)]
94 enum ConfigAction {
95 /// Show current configuration
96 Show,
97 /// Reload configuration
98 Reload,
99 }
100
101 fn main() -> anyhow::Result<()> {
102 let cli = Cli::parse();
103
104 // Set up logging with dual output (stderr + file)
105 let log_level = match cli.verbose {
106 0 => Level::INFO,
107 1 => Level::DEBUG,
108 _ => Level::TRACE,
109 };
110
111 // Create log directory
112 let log_dir = dirs::data_local_dir()
113 .unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
114 .join("hyprkvm");
115 std::fs::create_dir_all(&log_dir).ok();
116
117 // File appender with daily rotation
118 let file_appender = tracing_appender::rolling::daily(&log_dir, "daemon.log");
119 let (non_blocking, _guard) = tracing_appender::non_blocking(file_appender);
120
121 // Build subscriber with both stderr and file layers
122 let subscriber = tracing_subscriber::registry()
123 .with(
124 tracing_subscriber::fmt::layer()
125 .with_target(false)
126 .with_writer(std::io::stderr)
127 )
128 .with(
129 tracing_subscriber::fmt::layer()
130 .with_target(true)
131 .with_ansi(false)
132 .with_writer(non_blocking)
133 )
134 .with(
135 tracing_subscriber::filter::LevelFilter::from_level(log_level)
136 );
137 subscriber.init();
138
139 // Load configuration path
140 let config_path = cli.config.unwrap_or_else(|| {
141 dirs::config_dir()
142 .unwrap_or_else(|| std::path::PathBuf::from("."))
143 .join("hyprkvm")
144 .join("hyprkvm.toml")
145 });
146
147 // Handle GUI: either explicit `gui` command OR no command (default)
148 #[cfg(feature = "gui")]
149 if cli.command.is_none() || matches!(cli.command, Some(Commands::Gui)) {
150 info!("Starting HyprKVM GUI...");
151 return gui::run_gui(&config_path);
152 }
153
154 // If GUI feature not enabled and no command given, show helpful message
155 #[cfg(not(feature = "gui"))]
156 if cli.command.is_none() {
157 eprintln!("No command specified. Available commands:");
158 eprintln!(" hyprkvm daemon - Start the KVM daemon");
159 eprintln!(" hyprkvm status - Show daemon status");
160 eprintln!(" hyprkvm config - Configuration management");
161 eprintln!();
162 eprintln!("To enable the GUI, rebuild with: cargo build --features gui");
163 std::process::exit(1);
164 }
165
166 // Run async commands in tokio runtime
167 // At this point we know command is Some(...) because None cases are handled above
168 let command = cli.command.expect("command should be Some at this point");
169
170 tokio::runtime::Builder::new_multi_thread()
171 .enable_all()
172 .build()?
173 .block_on(async {
174 match command {
175 Commands::Daemon => {
176 info!("Starting HyprKVM daemon...");
177 run_daemon(&config_path).await
178 }
179 Commands::Status => {
180 show_status().await
181 }
182 Commands::Move { direction } => {
183 handle_move(&direction).await
184 }
185 Commands::Config { action } => {
186 match action {
187 ConfigAction::Show => show_config(&config_path),
188 ConfigAction::Reload => reload_config().await,
189 }
190 }
191 #[cfg(feature = "gui")]
192 Commands::Gui => unreachable!("GUI handled above"),
193 }
194 })
195 }
196
197 async fn run_daemon(config_path: &std::path::Path) -> anyhow::Result<()> {
198 use std::collections::HashMap;
199 use std::net::SocketAddr;
200 use std::sync::Arc;
201 use tokio::sync::RwLock;
202 use hyprkvm_common::Direction;
203 use hyprkvm_common::protocol::{Message, HelloPayload, PROTOCOL_VERSION};
204
205 // Load or create default config
206 let mut config = match Config::load(config_path) {
207 Ok(cfg) => cfg,
208 Err(e) => {
209 tracing::warn!("Failed to load config: {e}, using defaults");
210 Config::default()
211 }
212 };
213
214 info!("Machine name: {}", config.machines.self_name);
215 info!("Listening on port: {}", config.network.listen_port);
216
217 // Track daemon start time for uptime reporting
218 let daemon_start_time = std::time::Instant::now();
219
220 // State flags for CLI control
221 let barrier_enabled = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
222 let shutdown_requested = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
223
224 // Connect to Hyprland
225 info!("Connecting to Hyprland...");
226 let hypr_client = hyprland::ipc::HyprlandClient::new().await?;
227
228 // Query monitors to validate connection
229 let monitors = hypr_client.monitors().await?;
230 info!("Connected to Hyprland. Monitors: {}", monitors.len());
231 for mon in &monitors {
232 info!(" {} at ({}, {}) {}x{}", mon.name, mon.x, mon.y, mon.width, mon.height);
233 }
234
235 // Calculate screen bounds in LOGICAL coordinates (cursor position uses logical coords)
236 // Hyprland reports physical dimensions, but cursor_pos() returns logical coordinates
237 // Logical size = physical size / scale
238 let screen_min_x: i32 = monitors.iter().map(|m| m.x).min().unwrap_or(0);
239 let screen_min_y: i32 = monitors.iter().map(|m| m.y).min().unwrap_or(0);
240 let screen_max_x: i32 = monitors.iter().map(|m| {
241 let logical_width = (m.width as f32 / m.scale).round() as i32;
242 m.x + logical_width
243 }).max().unwrap_or(1920);
244 let screen_max_y: i32 = monitors.iter().map(|m| {
245 let logical_height = (m.height as f32 / m.scale).round() as i32;
246 m.y + logical_height
247 }).max().unwrap_or(1080);
248 let screen_width: u32 = (screen_max_x - screen_min_x) as u32;
249 let screen_height: u32 = (screen_max_y - screen_min_y) as u32;
250 info!("Screen bounds (logical): ({}, {}) to ({}, {}), dimensions: {}x{}",
251 screen_min_x, screen_min_y, screen_max_x, screen_max_y, screen_width, screen_height);
252
253 // Determine which edges have network neighbors
254 let mut enabled_edges = Vec::new();
255 let mut neighbor_map: HashMap<Direction, SocketAddr> = HashMap::new();
256 for neighbor in &config.machines.neighbors {
257 enabled_edges.push(neighbor.direction);
258 neighbor_map.insert(neighbor.direction, neighbor.address);
259 info!(" Network neighbor: {} ({}) at {}", neighbor.name, neighbor.direction, neighbor.address);
260 }
261
262 // If no neighbors configured, just run in demo mode
263 if enabled_edges.is_empty() {
264 info!("No neighbors configured. Add neighbors in config to enable control transfer.");
265 enabled_edges = vec![Direction::Left, Direction::Right];
266 }
267
268 // Start edge capture
269 info!("Starting edge capture for: {:?}", enabled_edges);
270 let monitor_infos: Vec<input::MonitorInfo> = monitors.iter().map(|m| input::MonitorInfo {
271 name: m.name.clone(),
272 x: m.x,
273 y: m.y,
274 width: m.width,
275 height: m.height,
276 scale: m.scale,
277 }).collect();
278
279 // Store per-monitor logical bounds for cursor edge detection
280 // Each tuple: (x, y, logical_width, logical_height)
281 let monitor_logical_bounds: Vec<(i32, i32, i32, i32)> = monitors.iter().map(|m| {
282 let logical_width = (m.width as f32 / m.scale).round() as i32;
283 let logical_height = (m.height as f32 / m.scale).round() as i32;
284 (m.x, m.y, logical_width, logical_height)
285 }).collect();
286
287 let edge_capture = input::EdgeCapture::new(input::EdgeCaptureConfig {
288 barrier_size: 1,
289 enabled_edges: enabled_edges.clone(),
290 monitors: monitor_infos,
291 })?;
292
293 // Create input grabber (for when we send control elsewhere)
294 // Use evdev-based grabber for reliable input capture at kernel level
295 let input_grabber = input::EvdevGrabber::new()?;
296
297 // Create input emulator (for when we receive control from elsewhere)
298 // This is created lazily when we first need to inject
299 let mut input_emulator: Option<input::InputEmulator> = None;
300
301 // Create transfer manager
302 let (transfer_manager, mut transfer_events) = transfer::TransferManager::new(
303 config.machines.self_name.clone(),
304 );
305 let transfer_manager = Arc::new(transfer_manager);
306
307 // Create clipboard manager
308 let clipboard_manager = std::sync::Arc::new(clipboard::ClipboardManager::new(
309 config.clipboard.clone(),
310 ));
311
312 // Track which direction we're capturing for
313 let mut capture_direction: Option<Direction> = None;
314 // Track relay mode: (from, to) when forwarding input from one peer to another
315 let mut relay_mode: Option<(Direction, Direction)> = None;
316 let mut input_sequence: u64 = 0;
317
318 // Escape key detection
319 // KEY_SCROLLLOCK = 70 in Linux evdev keycodes
320 const KEY_SCROLLLOCK: u32 = 70;
321 const KEY_LEFTSHIFT: u32 = 42;
322 const KEY_RIGHTSHIFT: u32 = 54;
323 let mut shift_tap_times: Vec<std::time::Instant> = Vec::new();
324 let triple_tap_window = std::time::Duration::from_millis(
325 config.input.escape_hotkey.triple_tap_window_ms
326 );
327
328 // Cursor-based edge detection state
329 let mut last_cursor_pos: Option<(i32, i32)> = None;
330 let mut edge_dwell_start: Option<(Direction, std::time::Instant)> = None;
331 const EDGE_THRESHOLD: i32 = 2; // Pixels from edge to count as "at edge"
332 const EDGE_DWELL_MS: u64 = 50; // How long cursor must be at edge to trigger
333
334 // Cooldown after control returns to prevent immediate bounce-back
335 // Stores (timestamp, return_direction) - the direction is the arrow key the user pressed to return
336 let mut last_control_return: Option<(std::time::Instant, Direction)> = None;
337 const CONTROL_RETURN_COOLDOWN_MS: u64 = 300; // 300ms cooldown - spurious keypresses happen within ~100-400ms
338
339 // Connection storage: direction -> peer connection
340 let peers: Arc<RwLock<HashMap<Direction, network::FramedConnection>>> =
341 Arc::new(RwLock::new(HashMap::new()));
342
343 // TLS setup (if enabled)
344 let tls_enabled = config.network.tls.enabled;
345 if tls_enabled {
346 info!("TLS is enabled, ensuring certificates exist...");
347 let cert_path = std::path::Path::new(&config.network.tls.cert_path);
348 let key_path = std::path::Path::new(&config.network.tls.key_path);
349
350 if let Err(e) = network::ensure_certificate(cert_path, key_path, &config.machines.self_name) {
351 anyhow::bail!("Failed to setup TLS certificates: {}", e);
352 }
353
354 // Print certificate fingerprint for users to share
355 match network::get_cert_fingerprint(cert_path) {
356 Ok(fp) => {
357 info!("Certificate fingerprint: {}", fp);
358 info!("Share this fingerprint with peers for secure verification");
359 }
360 Err(e) => {
361 tracing::warn!("Could not read certificate fingerprint: {}", e);
362 }
363 }
364 } else {
365 info!("TLS is disabled (plain TCP mode for backwards compatibility)");
366 }
367
368 // Load known hosts for TOFU
369 let known_hosts_path = network::KnownHosts::default_path();
370 let known_hosts = match network::KnownHosts::load(&known_hosts_path) {
371 Ok(kh) => {
372 if !kh.hosts.is_empty() {
373 info!("Loaded {} known host(s) from {:?}", kh.hosts.len(), known_hosts_path);
374 }
375 Arc::new(RwLock::new(kh))
376 }
377 Err(e) => {
378 tracing::warn!("Failed to load known hosts: {}, starting fresh", e);
379 Arc::new(RwLock::new(network::KnownHosts::default()))
380 }
381 };
382 let tofu_enabled = config.network.tls.tofu;
383
384 // Start network server
385 let listen_addr: SocketAddr = format!("0.0.0.0:{}", config.network.listen_port).parse()?;
386 let server = if tls_enabled {
387 let cert_path = std::path::Path::new(&config.network.tls.cert_path);
388 let key_path = std::path::Path::new(&config.network.tls.key_path);
389 network::Server::bind_tls(listen_addr, cert_path, key_path).await?
390 } else {
391 network::Server::bind(listen_addr).await?
392 };
393 info!("Listening for connections on {} (TLS: {})", server.local_addr(), tls_enabled);
394
395 // Channel for signaling config changes that require restart
396 let (restart_tx, mut restart_rx) = tokio::sync::mpsc::channel::<String>(1);
397
398 // Spawn task to accept incoming connections
399 let machine_name = config.machines.self_name.clone();
400 let neighbors_for_accept = config.machines.neighbors.clone();
401 let peers_for_accept = peers.clone();
402 let known_hosts_for_accept = known_hosts.clone();
403 let known_hosts_path_for_accept = known_hosts_path.clone();
404 let config_path_for_accept = config_path.to_path_buf();
405 let restart_tx_for_accept = restart_tx.clone();
406 let accept_handle = tokio::spawn(async move {
407 loop {
408 match server.accept().await {
409 Ok(mut conn) => {
410 let addr = conn.remote_addr();
411 info!("Incoming connection from {}", addr);
412
413 // Receive Hello
414 match conn.recv().await {
415 Ok(Some(Message::Hello(hello))) => {
416 info!("Peer {} connected (protocol v{})", hello.machine_name, hello.protocol_version);
417
418 // Verify TLS fingerprint if this is a TLS connection
419 if let Some(peer_fp) = conn.peer_fingerprint() {
420 let mut kh = known_hosts_for_accept.write().await;
421 match kh.is_trusted(&hello.machine_name, peer_fp) {
422 network::TrustStatus::Trusted => {
423 info!("Peer {} fingerprint verified", hello.machine_name);
424 kh.touch(&hello.machine_name);
425 }
426 network::TrustStatus::Unknown => {
427 if tofu_enabled {
428 info!("TOFU: Trusting new peer {} with fingerprint {}", hello.machine_name, peer_fp);
429 kh.trust_host(&hello.machine_name, peer_fp);
430 if let Err(e) = kh.save(&known_hosts_path_for_accept) {
431 tracing::error!("Failed to save known hosts: {}", e);
432 }
433 } else {
434 tracing::warn!("Unknown peer {} fingerprint and TOFU disabled, rejecting", hello.machine_name);
435 continue;
436 }
437 }
438 network::TrustStatus::Changed { old_fingerprint, new_fingerprint } => {
439 tracing::error!(
440 "SECURITY WARNING: Peer {} fingerprint CHANGED!\n Old: {}\n New: {}\n This could indicate a man-in-the-middle attack!",
441 hello.machine_name, old_fingerprint, new_fingerprint
442 );
443 continue; // Reject the connection
444 }
445 }
446 }
447
448 // Send HelloAck
449 let ack = Message::HelloAck(hyprkvm_common::protocol::HelloAckPayload {
450 accepted: true,
451 protocol_version: PROTOCOL_VERSION,
452 machine_name: machine_name.clone(),
453 error: None,
454 });
455 if let Err(e) = conn.send(&ack).await {
456 tracing::error!("Failed to send HelloAck: {}", e);
457 continue;
458 }
459
460 // Determine direction: use opposite of what peer told us, or fall back to config
461 let direction = if let Some(peer_dir) = hello.my_direction_for_you {
462 // Peer says "I have you as X", so we store them as opposite(X)
463 Some(peer_dir.opposite())
464 } else {
465 // Legacy: look up in our config
466 neighbors_for_accept
467 .iter()
468 .find(|n| n.name == hello.machine_name)
469 .map(|n| n.direction)
470 };
471
472 // NOTE: We no longer auto-correct direction based on peer claims.
473 // Direction changes are now handled explicitly via DirectionChange messages
474 // sent when the user changes direction in the GUI.
475 // This prevents the config from being overwritten on reconnect.
476 if let Some(peer_dir) = hello.my_direction_for_you {
477 let new_dir = peer_dir.opposite();
478 let existing = neighbors_for_accept
479 .iter()
480 .find(|n| n.name == hello.machine_name);
481
482 if let Some(existing_neighbor) = existing {
483 if existing_neighbor.direction != new_dir {
484 // Just log the mismatch, don't auto-correct
485 // The user should update both configs via the GUI
486 tracing::warn!(
487 "Direction mismatch for {}: our config says {:?}, peer claims {:?}. \
488 Use the GUI to update directions on both machines.",
489 hello.machine_name, existing_neighbor.direction, new_dir
490 );
491 }
492 }
493 }
494
495 if let Some(dir) = direction {
496 let mut peers = peers_for_accept.write().await;
497 if peers.contains_key(&dir) {
498 info!("Already have connection for {:?}, dropping incoming from {}", dir, hello.machine_name);
499 // Drop the incoming connection, keep the existing one
500 } else {
501 info!("Storing incoming connection from {} as {:?} (peer claimed {:?})",
502 hello.machine_name, dir, hello.my_direction_for_you);
503 peers.insert(dir, conn);
504 }
505 } else {
506 tracing::warn!(
507 "Unknown peer '{}' connected - not in neighbors list and no direction provided",
508 hello.machine_name
509 );
510 // Connection will be dropped
511 }
512 }
513 Ok(Some(other)) => {
514 tracing::warn!("Expected Hello, got {:?}", other);
515 }
516 Ok(None) => {
517 tracing::debug!("Connection closed during handshake");
518 }
519 Err(e) => {
520 tracing::error!("Handshake error: {}", e);
521 }
522 }
523 }
524 Err(e) => {
525 tracing::error!("Accept error: {}", e);
526 }
527 }
528 }
529 });
530
531 // Connect to configured peers (with retry loop)
532 for neighbor in &config.machines.neighbors {
533 let addr = neighbor.address;
534 let direction = neighbor.direction;
535 let peers_clone = peers.clone();
536 let machine_name = config.machines.self_name.clone();
537 let neighbor_name = neighbor.name.clone();
538
539 // Determine if TLS should be used for this neighbor
540 // Per-neighbor override takes precedence over global setting
541 let use_tls = neighbor.tls.unwrap_or(tls_enabled);
542 let fingerprint = neighbor.fingerprint.clone();
543 let tofu_enabled = config.network.tls.tofu;
544 let known_hosts_clone = known_hosts.clone();
545 let known_hosts_path_clone = known_hosts_path.clone();
546
547 tokio::spawn(async move {
548 loop {
549 // Check if already connected
550 {
551 let peers = peers_clone.read().await;
552 if peers.contains_key(&direction) {
553 // Already connected, wait and check again
554 drop(peers);
555 tokio::time::sleep(std::time::Duration::from_secs(5)).await;
556 continue;
557 }
558 }
559
560 tracing::debug!("Connecting to {} at {} (TLS: {})...", direction, addr, use_tls);
561
562 // Connect with or without TLS
563 let conn_result = if use_tls {
564 network::connect_tls(
565 addr,
566 &neighbor_name,
567 fingerprint.as_deref(),
568 tofu_enabled,
569 ).await
570 } else {
571 network::connect(addr).await
572 };
573
574 match conn_result {
575 Ok(mut conn) => {
576 // Send Hello with our direction for this peer
577 // Peer will use the opposite direction to store us
578 let hello = Message::Hello(HelloPayload {
579 protocol_version: PROTOCOL_VERSION,
580 machine_name: machine_name.clone(),
581 capabilities: vec![],
582 my_direction_for_you: Some(direction),
583 });
584
585 if let Err(e) = conn.send(&hello).await {
586 tracing::error!("Failed to send Hello to {}: {}", direction, e);
587 tokio::time::sleep(std::time::Duration::from_secs(3)).await;
588 continue;
589 }
590
591 // Wait for HelloAck
592 match conn.recv().await {
593 Ok(Some(Message::HelloAck(ack))) => {
594 if ack.accepted {
595 // Verify TLS fingerprint if this is a TLS connection
596 if let Some(peer_fp) = conn.peer_fingerprint() {
597 let mut kh = known_hosts_clone.write().await;
598 match kh.is_trusted(&neighbor_name, peer_fp) {
599 network::TrustStatus::Trusted => {
600 info!("Peer {} fingerprint verified", neighbor_name);
601 kh.touch(&neighbor_name);
602 }
603 network::TrustStatus::Unknown => {
604 if tofu_enabled {
605 info!("TOFU: Trusting new peer {} with fingerprint {}", neighbor_name, peer_fp);
606 kh.trust_host(&neighbor_name, peer_fp);
607 if let Err(e) = kh.save(&known_hosts_path_clone) {
608 tracing::error!("Failed to save known hosts: {}", e);
609 }
610 } else {
611 tracing::warn!("Unknown peer {} fingerprint and TOFU disabled, rejecting", neighbor_name);
612 tokio::time::sleep(std::time::Duration::from_secs(3)).await;
613 continue;
614 }
615 }
616 network::TrustStatus::Changed { old_fingerprint, new_fingerprint } => {
617 tracing::error!(
618 "SECURITY WARNING: Peer {} fingerprint CHANGED!\n Old: {}\n New: {}\n This could indicate a man-in-the-middle attack!",
619 neighbor_name, old_fingerprint, new_fingerprint
620 );
621 tokio::time::sleep(std::time::Duration::from_secs(3)).await;
622 continue; // Reject and retry
623 }
624 }
625 }
626
627 let mut peers = peers_clone.write().await;
628 if peers.contains_key(&direction) {
629 info!("Already have connection for {:?}, dropping outbound to {}", direction, ack.machine_name);
630 // Drop this connection, keep the existing one
631 } else {
632 info!("Connected to {} ({})", ack.machine_name, direction);
633 peers.insert(direction, conn);
634 }
635 // Stay in loop to reconnect if connection drops
636 } else {
637 tracing::error!("Connection rejected: {:?}", ack.error);
638 }
639 }
640 Ok(Some(other)) => {
641 tracing::warn!("Expected HelloAck, got {:?}", other);
642 }
643 Ok(None) => {
644 tracing::warn!("Connection closed during handshake");
645 }
646 Err(e) => {
647 tracing::error!("Handshake error: {}", e);
648 }
649 }
650 }
651 Err(e) => {
652 tracing::debug!("Failed to connect to {} ({}): {}", direction, addr, e);
653 }
654 }
655
656 // Retry after delay
657 tokio::time::sleep(std::time::Duration::from_secs(3)).await;
658 }
659 });
660 }
661
662 // Listen for Hyprland events
663 let mut event_stream = hyprland::events::HyprlandEventStream::connect().await?;
664
665 // Start IPC server for CLI commands
666 let (ipc_tx, mut ipc_rx) = tokio::sync::mpsc::channel::<(
667 hyprkvm_common::protocol::IpcRequest,
668 tokio::sync::oneshot::Sender<hyprkvm_common::protocol::IpcResponse>,
669 )>(16);
670
671 tokio::spawn(async move {
672 let server = match ipc::IpcServer::bind().await {
673 Ok(s) => s,
674 Err(e) => {
675 tracing::error!("Failed to start IPC server: {}", e);
676 return;
677 }
678 };
679
680 loop {
681 match server.accept().await {
682 Ok(mut conn) => {
683 tracing::debug!("IPC: connection accepted");
684 let ipc_tx = ipc_tx.clone();
685 tokio::spawn(async move {
686 match conn.recv().await {
687 Ok(Some(request)) => {
688 tracing::debug!("IPC: received {:?}", request);
689 let (resp_tx, resp_rx) = tokio::sync::oneshot::channel();
690 if ipc_tx.send((request, resp_tx)).await.is_ok() {
691 tracing::debug!("IPC: sent to main loop, awaiting response");
692 match resp_rx.await {
693 Ok(response) => {
694 tracing::debug!("IPC: got response, sending to client");
695 if let Err(e) = conn.send(&response).await {
696 tracing::error!("IPC: failed to send response: {}", e);
697 }
698 }
699 Err(e) => {
700 tracing::error!("IPC: response channel error: {}", e);
701 }
702 }
703 } else {
704 tracing::error!("IPC: failed to send request to main loop");
705 }
706 }
707 Ok(None) => {
708 tracing::debug!("IPC: connection closed by client");
709 }
710 Err(e) => {
711 tracing::debug!("IPC recv error: {}", e);
712 }
713 }
714 });
715 }
716 Err(e) => {
717 tracing::error!("IPC accept error: {}", e);
718 }
719 }
720 }
721 });
722
723 info!("Daemon running. Move mouse to screen edges to trigger transfer. Press Ctrl+C to stop.");
724
725 loop {
726 tokio::select! {
727 // Check for edge events, grabber events, and poll peer messages
728 _ = tokio::time::sleep(std::time::Duration::from_micros(100)) => {
729 // Forward grabbed input to remote peer
730 if let Some(cap_dir) = capture_direction {
731 let mut should_escape = false;
732
733 // Coalesce motion events - drain queue and accumulate
734 let mut motion_dx: f64 = 0.0;
735 let mut motion_dy: f64 = 0.0;
736 let mut scroll_h: f64 = 0.0;
737 let mut scroll_v: f64 = 0.0;
738 let mut other_events: Vec<input::GrabEvent> = Vec::new();
739
740 while let Some(grab_event) = input_grabber.try_recv() {
741 // Check for escape key before forwarding
742 match &grab_event {
743 input::GrabEvent::KeyDown { keycode } => {
744 tracing::debug!("CAPTURE KeyDown: keycode={} ({})",
745 keycode, keycode_to_name(*keycode));
746
747 // Check for scroll_lock
748 if *keycode == KEY_SCROLLLOCK {
749 info!("Scroll Lock pressed - returning control to local");
750 should_escape = true;
751 continue; // Don't forward this key
752 }
753
754 // Check for triple-tap shift
755 if config.input.escape_hotkey.triple_tap_enabled {
756 if *keycode == KEY_LEFTSHIFT || *keycode == KEY_RIGHTSHIFT {
757 let now = std::time::Instant::now();
758 // Remove old taps outside the window
759 shift_tap_times.retain(|t| now.duration_since(*t) < triple_tap_window);
760 shift_tap_times.push(now);
761
762 if shift_tap_times.len() >= 3 {
763 info!("Triple-tap Shift detected - returning control to local");
764 should_escape = true;
765 shift_tap_times.clear();
766 continue;
767 }
768 }
769 }
770 other_events.push(grab_event);
771 }
772 input::GrabEvent::KeyUp { keycode } => {
773 tracing::debug!("CAPTURE KeyUp: keycode={} ({})",
774 keycode, keycode_to_name(*keycode));
775 other_events.push(grab_event);
776 }
777 input::GrabEvent::PointerMotion { dx, dy } => {
778 motion_dx += dx;
779 motion_dy += dy;
780 }
781 input::GrabEvent::PointerButton { .. } => {
782 other_events.push(grab_event);
783 }
784 input::GrabEvent::Scroll { horizontal, vertical } => {
785 scroll_h += horizontal;
786 scroll_v += vertical;
787 }
788 input::GrabEvent::ModifiersChanged { .. } => {
789 other_events.push(grab_event);
790 }
791 input::GrabEvent::RecoveryHotkey { .. } => {
792 // Should not happen during capture, ignore
793 tracing::warn!("RecoveryHotkey received during capture, ignoring");
794 }
795 }
796 }
797
798 // Send non-motion events first (preserve order for key events)
799 {
800 let mut peers_guard = peers.write().await;
801 if let Some(peer) = peers_guard.get_mut(&cap_dir) {
802 for event in other_events {
803 let payload = event.to_protocol(input_sequence);
804 input_sequence += 1;
805 if let Err(e) = peer.send(&Message::InputEvent(payload)).await {
806 tracing::error!("Failed to send input event: {}", e);
807 }
808 }
809
810 // Send coalesced motion as single event
811 if motion_dx != 0.0 || motion_dy != 0.0 {
812 let motion_event = input::GrabEvent::PointerMotion { dx: motion_dx, dy: motion_dy };
813 let payload = motion_event.to_protocol(input_sequence);
814 input_sequence += 1;
815 if let Err(e) = peer.send(&Message::InputEvent(payload)).await {
816 tracing::error!("Failed to send motion event: {}", e);
817 }
818 }
819
820 // Send coalesced scroll as single event
821 if scroll_h != 0.0 || scroll_v != 0.0 {
822 let scroll_event = input::GrabEvent::Scroll { horizontal: scroll_h, vertical: scroll_v };
823 let payload = scroll_event.to_protocol(input_sequence);
824 input_sequence += 1;
825 if let Err(e) = peer.send(&Message::InputEvent(payload)).await {
826 tracing::error!("Failed to send scroll event: {}", e);
827 }
828 }
829 }
830 }
831
832 // If escape was triggered, stop capture and send Leave
833 if should_escape {
834 info!("Escape triggered - stopping capture");
835 capture_direction = None;
836 input_grabber.stop(None); // No recovery needed for escape
837
838 // Send Leave message - we're leaving in the opposite direction (returning to us)
839 let leave = Message::Leave(hyprkvm_common::protocol::LeavePayload {
840 to_direction: cap_dir.opposite(),
841 cursor_pos: hyprkvm_common::protocol::CursorEntryPos::EdgeRelative(0.5),
842 modifiers: hyprkvm_common::ModifierState::default(),
843 transfer_id: input_sequence, // Use as a simple unique ID
844 });
845 let mut peers_guard = peers.write().await;
846 if let Some(peer) = peers_guard.get_mut(&cap_dir) {
847 if let Err(e) = peer.send(&leave).await {
848 tracing::error!("Failed to send Leave: {}", e);
849 }
850 }
851 }
852 } else {
853 // Not capturing - check for RecoveryHotkey events from recovery mode
854 // These bypass libinput's stale state by detecting keypresses directly at evdev level
855 while let Some(grab_event) = input_grabber.try_recv() {
856 if let input::GrabEvent::RecoveryHotkey { direction } = grab_event {
857 info!("RECOVERY HOTKEY: Super+{:?} detected via evdev", direction);
858
859 // Same at_edge check as IPC Move - only transfer if at edge monitor+window
860 let at_edge = 'edge_check: {
861 // Get monitors and find focused one
862 let monitors = match hypr_client.monitors().await {
863 Ok(m) => m,
864 Err(e) => {
865 info!(" RECOVERY edge_check: monitors query failed: {}", e);
866 break 'edge_check false;
867 }
868 };
869 let focused_monitor = match monitors.iter().find(|m| m.focused) {
870 Some(m) => m,
871 None => {
872 info!(" RECOVERY edge_check: no focused monitor found");
873 break 'edge_check false;
874 }
875 };
876
877 // Check if there's another monitor in the requested direction
878 // Use logical dimensions (physical / scale) since positions are logical
879 let has_monitor_in_direction = monitors.iter().any(|m| {
880 if m.id == focused_monitor.id { return false; }
881 let m_logical_w = (m.width as f32 / m.scale).round() as i32;
882 let m_logical_h = (m.height as f32 / m.scale).round() as i32;
883 let focused_logical_w = (focused_monitor.width as f32 / focused_monitor.scale).round() as i32;
884 let focused_logical_h = (focused_monitor.height as f32 / focused_monitor.scale).round() as i32;
885 match direction {
886 Direction::Left => m.x + m_logical_w <= focused_monitor.x,
887 Direction::Right => m.x >= focused_monitor.x + focused_logical_w,
888 Direction::Up => m.y + m_logical_h <= focused_monitor.y,
889 Direction::Down => m.y >= focused_monitor.y + focused_logical_h,
890 }
891 });
892
893 if has_monitor_in_direction {
894 info!(" RECOVERY edge_check: has monitor in direction {:?}", direction);
895 break 'edge_check false;
896 }
897
898 // On edge monitor. Check if at edge window.
899 let active_window: serde_json::Value = match hypr_client.query("activewindow").await {
900 Ok(w) => w,
901 Err(e) => {
902 info!(" RECOVERY edge_check: activewindow query failed: {}", e);
903 break 'edge_check false;
904 }
905 };
906
907 let win_x = active_window.get("at").and_then(|a| a.get(0)).and_then(|x| x.as_i64()).unwrap_or(0) as i32;
908 let win_y = active_window.get("at").and_then(|a| a.get(1)).and_then(|y| y.as_i64()).unwrap_or(0) as i32;
909 let win_w = active_window.get("size").and_then(|s| s.get(0)).and_then(|w| w.as_i64()).unwrap_or(100) as i32;
910 let win_h = active_window.get("size").and_then(|s| s.get(1)).and_then(|h| h.as_i64()).unwrap_or(100) as i32;
911
912 // Get all clients (windows)
913 let clients: Vec<serde_json::Value> = match hypr_client.query("clients").await {
914 Ok(c) => c,
915 Err(e) => {
916 info!(" RECOVERY edge_check: clients query failed: {}", e);
917 break 'edge_check false;
918 }
919 };
920
921 // Check if any OTHER window on this monitor is further in that direction
922 // Use window-based detection for all directions (consistent with IPC Move)
923 let has_window_in_direction = clients.iter().any(|client| {
924 let mon = client.get("monitor").and_then(|m| m.as_i64()).unwrap_or(-1) as i32;
925 if mon != focused_monitor.id { return false; }
926
927 let cx = client.get("at").and_then(|a| a.get(0)).and_then(|x| x.as_i64()).unwrap_or(0) as i32;
928 let cy = client.get("at").and_then(|a| a.get(1)).and_then(|y| y.as_i64()).unwrap_or(0) as i32;
929 let cw = client.get("size").and_then(|s| s.get(0)).and_then(|w| w.as_i64()).unwrap_or(0) as i32;
930 let ch = client.get("size").and_then(|s| s.get(1)).and_then(|h| h.as_i64()).unwrap_or(0) as i32;
931
932 // Skip the active window itself
933 if cx == win_x && cy == win_y && cw == win_w && ch == win_h {
934 return false;
935 }
936
937 match direction {
938 Direction::Left => cx + cw <= win_x + 10,
939 Direction::Right => cx >= win_x + win_w - 10,
940 Direction::Up => cy + ch <= win_y + 10,
941 Direction::Down => cy >= win_y + win_h - 10,
942 }
943 });
944
945 info!(" RECOVERY edge_check: has_window_in_direction={} -> at_edge={}", has_window_in_direction, !has_window_in_direction);
946 !has_window_in_direction
947 };
948
949 // Check if we have a peer in this direction
950 let has_peer = {
951 let peers = peers.read().await;
952 peers.contains_key(&direction)
953 };
954
955 if at_edge && has_peer {
956 // Get cursor position for transfer
957 let cursor_pos = hypr_client.cursor_pos().await
958 .map(|c| (c.x, c.y))
959 .unwrap_or((0, 0));
960
961 if barrier_enabled.load(std::sync::atomic::Ordering::SeqCst) {
962 info!("RECOVERY HOTKEY: Barrier enabled, blocking transfer");
963 } else {
964 info!("RECOVERY HOTKEY: At edge with peer, initiating transfer to {:?}", direction);
965 if let Err(e) = transfer_manager.initiate_transfer(
966 direction,
967 cursor_pos,
968 screen_min_x,
969 screen_min_y,
970 screen_max_x,
971 screen_max_y,
972 true, // keyboard-initiated (recovery hotkey)
973 ).await {
974 tracing::error!("Failed to initiate transfer from recovery hotkey: {}", e);
975 }
976 }
977 } else if !at_edge {
978 // Not at edge - need to do movefocus ourselves because libinput
979 // DROPPED the keypress due to stale state (it thinks the arrow key
980 // is still pressed from before the grab). This is the whole reason
981 // recovery mode exists.
982 let hypr_dir = match direction {
983 Direction::Left => "l",
984 Direction::Right => "r",
985 Direction::Up => "u",
986 Direction::Down => "d",
987 };
988 info!("RECOVERY HOTKEY: Not at edge, doing movefocus {} (libinput dropped the keypress)", hypr_dir);
989 match hypr_client.dispatch("movefocus", hypr_dir).await {
990 Ok(()) => {
991 info!(" RECOVERY movefocus succeeded");
992 // Set cooldown to prevent the IPC Move callback from initiating transfer
993 // (Hyprland fires IPC Move after movefocus, which would see at_edge=true
994 // after we moved to the edge window)
995 last_control_return = Some((std::time::Instant::now(), direction));
996 }
997 Err(e) => tracing::error!(" RECOVERY movefocus failed: {}", e),
998 }
999 } else {
1000 info!("RECOVERY HOTKEY: No peer in direction {:?}", direction);
1001 }
1002 }
1003 }
1004 }
1005
1006 // Handle edge events from layer-shell barriers (for inter-monitor edges)
1007 while let Some(edge_event) = edge_capture.try_recv() {
1008 let direction = edge_event.direction;
1009
1010 // Verify cursor is actually at screen boundary using Hyprland
1011 // (barrier placement can be wrong on multi-monitor setups)
1012 let cursor_pos = match hypr_client.cursor_pos().await {
1013 Ok(pos) => (pos.x, pos.y),
1014 Err(_) => continue, // Can't verify, skip this event
1015 };
1016 let is_at_screen_edge = match direction {
1017 Direction::Left => cursor_pos.0 <= screen_min_x + 5,
1018 Direction::Right => cursor_pos.0 >= screen_max_x - 5,
1019 Direction::Up => cursor_pos.1 <= screen_min_y + 5,
1020 Direction::Down => cursor_pos.1 >= screen_max_y - 5,
1021 };
1022
1023 if !is_at_screen_edge {
1024 tracing::debug!(
1025 "EDGE: {:?} barrier triggered but cursor at ({}, {}) not at screen edge (bounds: {} to {}), ignoring",
1026 direction,
1027 cursor_pos.0,
1028 cursor_pos.1,
1029 screen_min_x,
1030 screen_max_x
1031 );
1032 continue;
1033 }
1034
1035 // Check if we have a peer in this direction
1036 let has_peer = {
1037 let peers = peers.read().await;
1038 peers.contains_key(&direction)
1039 };
1040
1041 if has_peer {
1042 // Check if we're in ReceivedControl state from this direction
1043 // If so, return control instead of initiating a new transfer
1044 let current_state = transfer_manager.state().await;
1045 if let transfer::TransferState::ReceivedControl { from, .. } = current_state {
1046 if from == direction {
1047 info!(
1048 "EDGE: {:?} at ({}, {}) - returning control",
1049 direction,
1050 cursor_pos.0,
1051 cursor_pos.1
1052 );
1053 if let Err(e) = transfer_manager.return_control().await {
1054 tracing::warn!("Failed to return control: {}", e);
1055 } else {
1056 // Set cooldown to prevent immediate re-transfer
1057 last_control_return = Some((std::time::Instant::now(), direction));
1058 }
1059 continue;
1060 }
1061 }
1062
1063 // Check cooldown to prevent bounce-back loops (only for matching direction)
1064 if let Some((last_return, cooldown_dir)) = last_control_return {
1065 if cooldown_dir == direction && last_return.elapsed().as_millis() < CONTROL_RETURN_COOLDOWN_MS as u128 {
1066 tracing::debug!("EDGE: {:?} - in cooldown, ignoring", direction);
1067 continue;
1068 }
1069 }
1070
1071 // Re-check state for the has_devices check
1072 let current_state = transfer_manager.state().await;
1073
1074 if barrier_enabled.load(std::sync::atomic::Ordering::SeqCst) {
1075 info!(
1076 "EDGE: {:?} at ({}, {}) - barrier enabled, blocking",
1077 direction,
1078 cursor_pos.0,
1079 cursor_pos.1
1080 );
1081 } else if !input_grabber.has_devices() && current_state.is_local() {
1082 // No devices and in Local state - can't initiate
1083 tracing::debug!(
1084 "EDGE: {:?} at ({}, {}) - no devices, can't initiate from Local",
1085 direction,
1086 cursor_pos.0,
1087 cursor_pos.1
1088 );
1089 } else {
1090 info!(
1091 "EDGE: {:?} at ({}, {}) - initiating transfer",
1092 direction,
1093 cursor_pos.0,
1094 cursor_pos.1
1095 );
1096
1097 if let Err(e) = transfer_manager.initiate_transfer(
1098 direction,
1099 cursor_pos,
1100 screen_min_x,
1101 screen_min_y,
1102 screen_max_x,
1103 screen_max_y,
1104 false, // not keyboard-initiated (mouse edge)
1105 ).await {
1106 tracing::warn!("Failed to initiate transfer: {}", e);
1107 }
1108 }
1109 } else {
1110 tracing::debug!(
1111 "EDGE: {:?} but no peer connected",
1112 direction
1113 );
1114 }
1115 }
1116
1117 // Cursor-based edge detection (for absolute screen edges)
1118 // This catches the case where cursor is at the edge and can't go further
1119 if capture_direction.is_none() {
1120 if let Ok(cursor) = hypr_client.cursor_pos().await {
1121 let (cx, cy) = (cursor.x, cursor.y);
1122
1123 // Determine if cursor is at a screen edge
1124 // For Left/Right: use global screen bounds
1125 // For Up/Down: check per-monitor bounds (different monitors have different heights)
1126 let at_edge: Option<Direction> = if cx <= EDGE_THRESHOLD {
1127 Some(Direction::Left)
1128 } else if cx >= screen_width as i32 - EDGE_THRESHOLD {
1129 Some(Direction::Right)
1130 } else {
1131 // Check per-monitor Up/Down edges
1132 let mut edge_found = None;
1133 for &(mon_x, mon_y, mon_w, mon_h) in &monitor_logical_bounds {
1134 // Check if cursor is within this monitor's x range
1135 if cx >= mon_x && cx < mon_x + mon_w {
1136 // Check Up edge (top of this monitor)
1137 if cy <= mon_y + EDGE_THRESHOLD && cy >= mon_y {
1138 edge_found = Some(Direction::Up);
1139 break;
1140 }
1141 // Check Down edge (bottom of this monitor)
1142 if cy >= mon_y + mon_h - EDGE_THRESHOLD && cy <= mon_y + mon_h {
1143 edge_found = Some(Direction::Down);
1144 break;
1145 }
1146 }
1147 }
1148 edge_found
1149 };
1150
1151 // Debug: Log when cursor is at Up/Down edge
1152 if matches!(at_edge, Some(Direction::Up) | Some(Direction::Down)) {
1153 let current_state = transfer_manager.state().await;
1154 tracing::debug!(
1155 "CURSOR at {:?} edge: pos=({}, {}), bounds=(0,0)-({}x{}), state={:?}, enabled_edges={:?}",
1156 at_edge, cx, cy, screen_width, screen_height, current_state, enabled_edges
1157 );
1158 }
1159
1160 // Check if we should trigger based on dwell time and movement
1161 if let Some(edge_dir) = at_edge {
1162 // Only care about edges with neighbors
1163 if enabled_edges.contains(&edge_dir) {
1164 let now = std::time::Instant::now();
1165
1166 // Check if cursor is moving toward the edge (or staying at it)
1167 let moving_toward_edge = if let Some((last_x, last_y)) = last_cursor_pos {
1168 match edge_dir {
1169 Direction::Left => cx <= last_x,
1170 Direction::Right => cx >= last_x,
1171 Direction::Up => cy <= last_y,
1172 Direction::Down => cy >= last_y,
1173 }
1174 } else {
1175 true
1176 };
1177
1178 if moving_toward_edge {
1179 match &edge_dwell_start {
1180 Some((dir, start)) if *dir == edge_dir => {
1181 // Already tracking this edge, check if dwell time exceeded
1182 if now.duration_since(*start).as_millis() >= EDGE_DWELL_MS as u128 {
1183 // Trigger!
1184 let has_peer = {
1185 let peers = peers.read().await;
1186 peers.contains_key(&edge_dir)
1187 };
1188
1189 if has_peer {
1190 // Check if we're in ReceivedControl state from this direction
1191 let current_state = transfer_manager.state().await;
1192 if let transfer::TransferState::ReceivedControl { from, entered_at, .. } = &current_state {
1193 // Add cooldown after entering ReceivedControl to prevent immediate return
1194 // (cursor warp may not have taken effect yet, or stale position from polling)
1195 const RECEIVED_CONTROL_COOLDOWN_MS: u128 = 500;
1196 let time_in_state = entered_at.elapsed().as_millis();
1197
1198 if time_in_state < RECEIVED_CONTROL_COOLDOWN_MS {
1199 tracing::debug!(
1200 "CURSOR EDGE: {:?} - just entered ReceivedControl {}ms ago, waiting",
1201 edge_dir, time_in_state
1202 );
1203 edge_dwell_start = None;
1204 continue;
1205 }
1206
1207 if *from == edge_dir {
1208 info!(
1209 "CURSOR EDGE: {:?} at ({}, {}) - returning control",
1210 edge_dir, cx, cy
1211 );
1212 if let Err(e) = transfer_manager.return_control().await {
1213 tracing::warn!("Failed to return control: {}", e);
1214 } else {
1215 // Set cooldown to prevent immediate re-transfer
1216 last_control_return = Some((std::time::Instant::now(), edge_dir));
1217 }
1218 edge_dwell_start = None;
1219 continue;
1220 } else {
1221 tracing::debug!(
1222 "CURSOR EDGE: {:?} - ReceivedControl from {:?}, not matching",
1223 edge_dir, from
1224 );
1225 }
1226 } else {
1227 tracing::debug!(
1228 "CURSOR EDGE: {:?} - state is {:?}, not ReceivedControl",
1229 edge_dir, current_state
1230 );
1231 }
1232
1233 // Check cooldown to prevent bounce-back (only for matching direction)
1234 if let Some((last_return, cooldown_dir)) = last_control_return {
1235 if cooldown_dir == edge_dir && last_return.elapsed().as_millis() < CONTROL_RETURN_COOLDOWN_MS as u128 {
1236 tracing::debug!("CURSOR EDGE: {:?} - in cooldown", edge_dir);
1237 edge_dwell_start = None;
1238 continue;
1239 }
1240 }
1241
1242 if barrier_enabled.load(std::sync::atomic::Ordering::SeqCst) {
1243 info!(
1244 "CURSOR EDGE: {:?} at ({}, {}) - barrier enabled, blocking",
1245 edge_dir, cx, cy
1246 );
1247 } else if !input_grabber.has_devices() && current_state.is_local() {
1248 // No devices and in Local state - can't initiate (no input to grab)
1249 // Note: if in ReceivedControl, we can still relay
1250 tracing::debug!(
1251 "CURSOR EDGE: {:?} at ({}, {}) - no devices, can't initiate from Local",
1252 edge_dir, cx, cy
1253 );
1254 } else {
1255 info!(
1256 "CURSOR EDGE: {:?} at ({}, {}) - initiating transfer",
1257 edge_dir, cx, cy
1258 );
1259
1260 if let Err(e) = transfer_manager.initiate_transfer(
1261 edge_dir,
1262 (cx, cy),
1263 screen_min_x,
1264 screen_min_y,
1265 screen_max_x,
1266 screen_max_y,
1267 false, // not keyboard-initiated (cursor edge)
1268 ).await {
1269 tracing::warn!("Failed to initiate transfer: {}", e);
1270 }
1271 }
1272 } else {
1273 tracing::debug!(
1274 "CURSOR EDGE: {:?} at ({}, {}) but no peer connected",
1275 edge_dir, cx, cy
1276 );
1277 }
1278
1279 // Reset to avoid repeated triggers
1280 edge_dwell_start = None;
1281 }
1282 }
1283 _ => {
1284 // Start tracking this edge
1285 tracing::trace!("Started edge dwell tracking for {:?} at ({}, {})", edge_dir, cx, cy);
1286 edge_dwell_start = Some((edge_dir, now));
1287 }
1288 }
1289 } else {
1290 // Moving away from edge, reset
1291 edge_dwell_start = None;
1292 }
1293 }
1294 } else {
1295 // Not at any edge, reset
1296 edge_dwell_start = None;
1297 }
1298
1299 last_cursor_pos = Some((cx, cy));
1300 }
1301 }
1302
1303 // Check for transfer timeout (stuck in Initiating state)
1304 if let transfer::TransferState::Initiating { started_at, .. } = transfer_manager.state().await {
1305 const TRANSFER_TIMEOUT_MS: u128 = 3000;
1306 if started_at.elapsed().as_millis() > TRANSFER_TIMEOUT_MS {
1307 tracing::warn!("Transfer timed out after {}ms, aborting", TRANSFER_TIMEOUT_MS);
1308 transfer_manager.abort().await;
1309 }
1310 }
1311
1312 // Poll for incoming messages from peers (non-blocking)
1313 let directions: Vec<Direction> = {
1314 let peers = peers.read().await;
1315 peers.keys().cloned().collect()
1316 };
1317
1318 // Debug: log state and peers occasionally (every ~5 seconds at 100μs polling)
1319 static POLL_COUNT: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1320 let count = POLL_COUNT.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1321 if count % 50000 == 0 {
1322 let state = transfer_manager.state().await;
1323 tracing::info!("Poll #{}: state={:?}, peers={:?}", count, state, directions);
1324 }
1325
1326 for direction in directions {
1327 // Clone Arc before shadowing for use in spawned tasks
1328 let peers_arc = peers.clone();
1329 let mut peers = peers.write().await;
1330 if let Some(peer) = peers.get_mut(&direction) {
1331 // Try non-blocking receive using tokio timeout
1332 // Use minimal timeout to avoid blocking the event loop
1333 match tokio::time::timeout(
1334 std::time::Duration::from_micros(50),
1335 peer.recv()
1336 ).await {
1337 Ok(Ok(Some(msg))) => {
1338 tracing::debug!("Received from {:?}: {:?}", direction, msg);
1339 // Handle incoming message
1340 match msg {
1341 Message::Enter(payload) => {
1342 info!("Received Enter from {:?}", direction);
1343 match transfer_manager.handle_enter(
1344 direction,
1345 payload,
1346 screen_min_x,
1347 screen_min_y,
1348 screen_max_x,
1349 screen_max_y,
1350 ).await {
1351 Ok(pos) => {
1352 info!("Positioned cursor at {:?}", pos);
1353 }
1354 Err(e) => {
1355 tracing::error!("Failed to handle Enter: {}", e);
1356 }
1357 }
1358 }
1359 Message::EnterAck(ack) => {
1360 info!("Received EnterAck: success={}", ack.success);
1361 if let Err(e) = transfer_manager.handle_enter_ack(ack).await {
1362 // Usually a benign race condition (collision resolved)
1363 tracing::debug!("Failed to handle EnterAck: {}", e);
1364 }
1365 }
1366 Message::Leave(payload) => {
1367 info!("Received Leave from {:?}", direction);
1368 if let Err(e) = transfer_manager.handle_leave(payload).await {
1369 // Usually a benign race condition
1370 tracing::debug!("Failed to handle Leave: {}", e);
1371 }
1372 // Set cooldown to prevent bounce-back loop
1373 // When we receive Leave, control is returning to us
1374 // The return direction is opposite of peer direction (user pressed Down to return from Up peer)
1375 let return_direction = direction.opposite();
1376 last_control_return = Some((std::time::Instant::now(), return_direction));
1377 tracing::debug!("Set control return cooldown for direction {:?}", return_direction);
1378 }
1379 Message::LeaveAck => {
1380 info!("Received LeaveAck");
1381 // Transfer complete
1382 }
1383 Message::InputEvent(input_payload) => {
1384 // Check if we're in relay mode and this is from the relay source
1385 if let Some((relay_from, relay_to)) = relay_mode {
1386 if direction == relay_from {
1387 // Forward to relay target instead of injecting locally
1388 tracing::trace!("Relaying input from {:?} to {:?}", relay_from, relay_to);
1389 // Need to drop current borrow and get relay target
1390 drop(peers);
1391 let mut peers_guard = peers_arc.write().await;
1392 if let Some(target_peer) = peers_guard.get_mut(&relay_to) {
1393 if let Err(e) = target_peer.send(&Message::InputEvent(input_payload)).await {
1394 tracing::error!("Failed to relay input to {:?}: {}", relay_to, e);
1395 }
1396 }
1397 continue; // Skip local injection
1398 }
1399 }
1400
1401 // Normal case: inject input via emulation module
1402 if let Some(ref mut emu) = input_emulator {
1403 use hyprkvm_common::protocol::InputEventType;
1404 match input_payload.event {
1405 InputEventType::KeyDown { keycode } => {
1406 tracing::debug!("RECV KeyDown: keycode={} ({})",
1407 keycode, keycode_to_name(keycode));
1408 emu.keyboard.key(keycode, hyprkvm_common::KeyState::Pressed);
1409 }
1410 InputEventType::KeyUp { keycode } => {
1411 tracing::debug!("RECV KeyUp: keycode={} ({})",
1412 keycode, keycode_to_name(keycode));
1413 emu.keyboard.key(keycode, hyprkvm_common::KeyState::Released);
1414 }
1415 InputEventType::PointerMotion { dx, dy } => {
1416 emu.pointer.motion(dx, dy);
1417 }
1418 InputEventType::PointerButton { button, pressed } => {
1419 let state = if pressed {
1420 hyprkvm_common::ButtonState::Pressed
1421 } else {
1422 hyprkvm_common::ButtonState::Released
1423 };
1424 emu.pointer.button(button, state);
1425 }
1426 InputEventType::Scroll { horizontal, vertical } => {
1427 emu.pointer.scroll(horizontal, vertical);
1428 }
1429 InputEventType::ModifierState { .. } => {
1430 // Modifier state is informational
1431 }
1432 }
1433 }
1434 }
1435 Message::Ping { timestamp } => {
1436 let _ = peer.send(&Message::Pong { timestamp }).await;
1437 }
1438 Message::Pong { timestamp } => {
1439 tracing::trace!("Pong received, rtt={}ms",
1440 std::time::SystemTime::now()
1441 .duration_since(std::time::UNIX_EPOCH)
1442 .unwrap()
1443 .as_millis() as u64 - timestamp
1444 );
1445 }
1446 Message::ClipboardOffer(offer) => {
1447 // Handle clipboard offer from peer
1448 let cm = clipboard_manager.clone();
1449 let peers_clone = peers_arc.clone();
1450 let dir = direction;
1451 tokio::spawn(async move {
1452 if let Some(request) = cm.handle_offer(offer).await {
1453 let mut peers_guard = peers_clone.write().await;
1454 if let Some(peer) = peers_guard.get_mut(&dir) {
1455 if let Err(e) = peer.send(&Message::ClipboardRequest(request)).await {
1456 tracing::warn!("Failed to send clipboard request: {}", e);
1457 }
1458 }
1459 }
1460 });
1461 }
1462 Message::ClipboardRequest(request) => {
1463 // Handle clipboard request from peer
1464 let cm = clipboard_manager.clone();
1465 let peers_clone = peers_arc.clone();
1466 let dir = direction;
1467 tokio::spawn(async move {
1468 match cm.handle_request(request).await {
1469 Ok(data_chunks) => {
1470 let mut peers_guard = peers_clone.write().await;
1471 if let Some(peer) = peers_guard.get_mut(&dir) {
1472 for chunk in data_chunks {
1473 if let Err(e) = peer.send(&Message::ClipboardData(chunk)).await {
1474 tracing::warn!("Failed to send clipboard data: {}", e);
1475 break;
1476 }
1477 }
1478 }
1479 }
1480 Err(e) => {
1481 tracing::warn!("Clipboard request failed: {}", e);
1482 }
1483 }
1484 });
1485 }
1486 Message::ClipboardData(data) => {
1487 // Handle clipboard data from peer
1488 let cm = clipboard_manager.clone();
1489 tokio::spawn(async move {
1490 if let Err(e) = cm.handle_data(data).await {
1491 tracing::warn!("Clipboard data handling failed: {}", e);
1492 }
1493 });
1494 }
1495 Message::DirectionChange(payload) => {
1496 // Peer is notifying us that they changed our relative direction
1497 // We need to update our config to store them in the opposite direction
1498 let new_dir_for_peer = payload.your_direction_from_me.opposite();
1499 info!("Received DirectionChange: peer says we are {:?} from them, so we store them as {:?}",
1500 payload.your_direction_from_me, new_dir_for_peer);
1501
1502 // Find the peer's name from config based on direction
1503 let peer_name = config.machines.neighbors
1504 .iter()
1505 .find(|n| n.direction == direction)
1506 .map(|n| n.name.clone())
1507 .unwrap_or_else(|| "unknown".to_string());
1508
1509 // Load, update, and save config
1510 let config_path_clone = config_path.clone();
1511 match Config::load(&config_path_clone) {
1512 Ok(mut cfg) => {
1513 let mut found = false;
1514 for neighbor in &mut cfg.machines.neighbors {
1515 if neighbor.name == peer_name {
1516 info!("DirectionChange: updating {} direction {:?} -> {:?}",
1517 neighbor.name, neighbor.direction, new_dir_for_peer);
1518 neighbor.direction = new_dir_for_peer;
1519 found = true;
1520 break;
1521 }
1522 }
1523
1524 if found {
1525 if let Err(e) = cfg.save(&config_path_clone) {
1526 tracing::error!("DirectionChange: failed to save config: {}", e);
1527 let _ = peer.send(&Message::DirectionChangeAck { success: false }).await;
1528 } else {
1529 info!("DirectionChange: config updated, signaling restart");
1530 let _ = peer.send(&Message::DirectionChangeAck { success: true }).await;
1531 // Signal restart to apply new edge barriers
1532 let _ = restart_tx.try_send(format!("Direction sync from {}", peer_name));
1533 }
1534 } else {
1535 tracing::warn!("DirectionChange: peer {} not found in config", peer_name);
1536 let _ = peer.send(&Message::DirectionChangeAck { success: false }).await;
1537 }
1538 }
1539 Err(e) => {
1540 tracing::error!("DirectionChange: failed to load config: {}", e);
1541 let _ = peer.send(&Message::DirectionChangeAck { success: false }).await;
1542 }
1543 }
1544 }
1545 Message::DirectionChangeAck { success } => {
1546 if success {
1547 info!("DirectionChangeAck: peer acknowledged direction update");
1548 } else {
1549 tracing::warn!("DirectionChangeAck: peer failed to update direction");
1550 }
1551 }
1552 _ => {
1553 tracing::debug!("Unhandled message: {:?}", msg);
1554 }
1555 }
1556 }
1557 Ok(Ok(None)) => {
1558 // Connection closed
1559 info!("Peer {:?} disconnected", direction);
1560 peers.remove(&direction);
1561 }
1562 Ok(Err(e)) => {
1563 tracing::error!("Error receiving from {:?}: {}", direction, e);
1564 peers.remove(&direction);
1565 }
1566 Err(_) => {
1567 // Timeout - no message available, that's fine
1568 }
1569 }
1570 }
1571 }
1572 }
1573
1574 // Handle transfer events
1575 Some(event) = transfer_events.recv() => {
1576 match event {
1577 transfer::TransferEvent::SendMessage { direction, message } => {
1578 let mut peers = peers.write().await;
1579 if let Some(peer) = peers.get_mut(&direction) {
1580 info!("Sending {:?} to {:?}", message, direction);
1581 if let Err(e) = peer.send(&message).await {
1582 tracing::error!("Failed to send message to {:?}: {}", direction, e);
1583 // If send fails, abort the transfer
1584 transfer_manager.abort().await;
1585 }
1586 } else {
1587 tracing::warn!("No peer for direction {:?}, aborting transfer", direction);
1588 transfer_manager.abort().await;
1589 }
1590 }
1591 transfer::TransferEvent::StartCapture { direction: cap_dir, keyboard_initiated } => {
1592 info!("StartCapture event received for {:?}, keyboard_initiated={}", cap_dir, keyboard_initiated);
1593 capture_direction = Some(cap_dir);
1594
1595 // Only send synthetic Super key-down if the transfer was keyboard-initiated.
1596 // When triggered via Super+Arrow keybinding, Super was already held when
1597 // the grab started. The evdev grabber won't see the initial Super key-down,
1598 // so we send it explicitly so the destination knows Super is pressed.
1599 // For CLI-initiated switches, the user isn't holding Super, so don't send it.
1600 if keyboard_initiated {
1601 let mut peers_guard = peers.write().await;
1602 if let Some(peer) = peers_guard.get_mut(&cap_dir) {
1603 let super_down = input::GrabEvent::KeyDown { keycode: 125 }; // KEY_LEFTMETA
1604 let payload = super_down.to_protocol(input_sequence);
1605 input_sequence += 1;
1606 tracing::debug!("Sending synthetic Super key-down to destination (keyboard-initiated)");
1607 if let Err(e) = peer.send(&Message::InputEvent(payload)).await {
1608 tracing::error!("Failed to send synthetic Super: {}", e);
1609 }
1610 }
1611 } else {
1612 tracing::debug!("Skipping synthetic Super key-down (CLI-initiated switch)");
1613 // Add delay for CLI-initiated switches to let the terminal settle
1614 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
1615 }
1616
1617 input_grabber.start();
1618 }
1619 transfer::TransferEvent::StopCapture => {
1620 info!("Stopping input capture");
1621 let was_capturing_direction = capture_direction;
1622 capture_direction = None;
1623
1624 // Release the evdev grab and enter recovery mode for the stale direction
1625 // The stale key is the arrow key used to initiate the original outgoing transfer
1626 input_grabber.stop(was_capturing_direction);
1627
1628 // Drain any remaining events
1629 while input_grabber.try_recv().is_some() {}
1630
1631 // CRITICAL FIX: After releasing the evdev grab, libinput has stale state.
1632 // The arrow key that initiated the original transfer (before we went remote)
1633 // is still seen as "pressed" by libinput because it never saw the release.
1634 //
1635 // We use uinput to create a virtual keyboard and send synthetic key-up
1636 // events for ALL arrow keys. This gives libinput fresh key-up events,
1637 // which should clear the stale state.
1638 if let Some(dir) = was_capturing_direction {
1639 // The stale key is the one used to initiate the OUTGOING transfer
1640 let stale_keycode: u16 = match dir {
1641 Direction::Left => 105, // KEY_LEFT
1642 Direction::Right => 106, // KEY_RIGHT
1643 Direction::Up => 103, // KEY_UP
1644 Direction::Down => 108, // KEY_DOWN
1645 };
1646
1647 tracing::info!("Sending synthetic key-ups via uinput to clear stale libinput state");
1648
1649 // Send key-ups for all arrow keys to be safe
1650 let all_arrows: [u16; 4] = [103, 105, 106, 108];
1651 if let Err(e) = input::send_synthetic_key_ups(&all_arrows) {
1652 tracing::warn!("Failed to send synthetic key-ups: {}", e);
1653 }
1654
1655 // Also inject via virtual keyboard for Wayland-level cleanup
1656 if input_emulator.is_none() {
1657 if let Ok(emu) = input::InputEmulator::new() {
1658 input_emulator = Some(emu);
1659 }
1660 }
1661 if let Some(ref mut emu) = input_emulator {
1662 emu.keyboard.key(stale_keycode as u32, hyprkvm_common::KeyState::Released);
1663 emu.keyboard.reset_all_keys();
1664 }
1665 }
1666
1667 // CRITICAL: Set cooldown after receiving Leave from peer
1668 // When peer sends Leave (e.g., user pressed Super+Down on cachyos to return),
1669 // the Down key might still be held when we release the grab. Libinput sees
1670 // Super (still held) + Down = fires IPC Move Down, causing unwanted navigation.
1671 // This cooldown prevents that specific direction from triggering movefocus.
1672 if let Some(capture_dir) = was_capturing_direction {
1673 // Return direction is opposite of capture direction
1674 // e.g., if we were capturing to Up, user pressed Down to return
1675 let return_direction = capture_dir.opposite();
1676 last_control_return = Some((std::time::Instant::now(), return_direction));
1677 tracing::info!("Set cooldown after StopCapture for direction {:?} (peer-initiated return)", return_direction);
1678 }
1679 }
1680 transfer::TransferEvent::StartInjection { from } => {
1681 info!("Starting input injection from {:?}", from);
1682 // Create input emulator if not exists
1683 if input_emulator.is_none() {
1684 match input::InputEmulator::new() {
1685 Ok(emu) => {
1686 info!("Input emulator created");
1687 input_emulator = Some(emu);
1688 }
1689 Err(e) => {
1690 tracing::error!("Failed to create input emulator: {}", e);
1691 }
1692 }
1693 }
1694 }
1695 transfer::TransferEvent::StopInjection => {
1696 info!("Stopping input injection");
1697 // Reset ALL pressed keys so next session starts clean
1698 // This prevents Hyprland from seeing stale key state
1699 // (e.g., arrow key that triggered return was never released)
1700 if let Some(ref mut emu) = input_emulator {
1701 emu.keyboard.reset_all_keys();
1702 }
1703 }
1704 transfer::TransferEvent::StartRelay { from, to } => {
1705 info!("Starting input relay: {:?} -> {:?}", from, to);
1706 relay_mode = Some((from, to));
1707 // No local device grabbing needed - we're forwarding received input
1708 }
1709 transfer::TransferEvent::StopRelay => {
1710 info!("Stopping input relay");
1711 relay_mode = None;
1712 }
1713 transfer::TransferEvent::SyncClipboardOutgoing { direction } => {
1714 // Sync clipboard to the peer in the given direction
1715 // Check if clipboard sync is enabled and appropriate for this event
1716 if config.clipboard.enabled {
1717 let cm = clipboard_manager.clone();
1718 let peers_clone = peers.clone();
1719 tokio::spawn(async move {
1720 match cm.create_offer().await {
1721 Ok(Some(offer)) => {
1722 let mut peers_guard = peers_clone.write().await;
1723 if let Some(peer) = peers_guard.get_mut(&direction) {
1724 info!("Syncing clipboard to {:?}", direction);
1725 if let Err(e) = peer.send(&Message::ClipboardOffer(offer)).await {
1726 tracing::warn!("Failed to send clipboard offer: {}", e);
1727 }
1728 }
1729 }
1730 Ok(None) => {
1731 tracing::debug!("No clipboard content to sync");
1732 }
1733 Err(e) => {
1734 tracing::warn!("Failed to read clipboard for sync: {}", e);
1735 }
1736 }
1737 });
1738 }
1739 }
1740 }
1741 }
1742
1743 // Hyprland events
1744 event = event_stream.next_event() => {
1745 match event {
1746 Ok(evt) => {
1747 tracing::trace!("Hyprland event: {:?}", evt);
1748 }
1749 Err(e) => {
1750 tracing::error!("Event error: {e}");
1751 break;
1752 }
1753 }
1754 }
1755
1756 // Handle IPC requests from CLI
1757 Some((request, response_tx)) = ipc_rx.recv() => {
1758 use hyprkvm_common::protocol::{IpcRequest, IpcResponse};
1759
1760 let response = match request {
1761 IpcRequest::Move { direction } => {
1762 // Log current state for debugging
1763 let current_state = transfer_manager.state().await;
1764 info!("IPC Move {:?}: state={:?}", direction, current_state);
1765
1766 // Early exit: if in ReceivedControl and within cooldown for the ENTRY direction, ignore
1767 // (prevents the Super+Arrow keypress that triggered the transfer from
1768 // causing a double navigation on the receiving machine)
1769 // Only block the direction that would return to source, not all directions
1770 if let transfer::TransferState::ReceivedControl { entered_at, from, .. } = &current_state {
1771 const RECEIVED_CONTROL_IPC_COOLDOWN_MS: u128 = 300;
1772 let time_in_state = entered_at.elapsed().as_millis();
1773 // Only block if this is the same direction we received from (would return to source)
1774 if *from == direction && time_in_state < RECEIVED_CONTROL_IPC_COOLDOWN_MS {
1775 tracing::info!("IPC Move {:?}: in ReceivedControl cooldown ({}ms), ignoring (same as entry direction)", direction, time_in_state);
1776 response_tx.send(IpcResponse::Ok { message: "in cooldown".to_string() }).ok();
1777 continue;
1778 }
1779 }
1780
1781 // If we're already initiating a transfer in this direction, skip entirely
1782 // (prevents double-action when both RECOVERY hotkey and IPC Move fire)
1783 if let transfer::TransferState::Initiating { target, .. } = &current_state {
1784 if *target == direction {
1785 tracing::info!("IPC Move {:?}: already initiating transfer, skipping", direction);
1786 IpcResponse::Ok { message: "transfer already initiating".to_string() }
1787 } else {
1788 // Different direction - this shouldn't happen normally, but do movefocus
1789 let hypr_dir = match direction {
1790 Direction::Left => "l",
1791 Direction::Right => "r",
1792 Direction::Up => "u",
1793 Direction::Down => "d",
1794 };
1795 match hypr_client.dispatch("movefocus", hypr_dir).await {
1796 Ok(_) => IpcResponse::Ok { message: "movefocus".to_string() },
1797 Err(e) => IpcResponse::Error { message: format!("movefocus failed: {}", e) },
1798 }
1799 }
1800 } else {
1801
1802 // For keyboard navigation, check if we're at the absolute edge:
1803 // 1. On edge monitor (no monitor in that direction)
1804 // 2. On edge window of that monitor (no window further in that direction)
1805
1806 let at_edge = 'edge_check: {
1807 // Get monitors and find focused one
1808 let monitors = match hypr_client.monitors().await {
1809 Ok(m) => m,
1810 Err(e) => {
1811 info!(" edge_check: monitors query failed: {}", e);
1812 break 'edge_check false;
1813 }
1814 };
1815 let focused_monitor = match monitors.iter().find(|m| m.focused) {
1816 Some(m) => m,
1817 None => {
1818 info!(" edge_check: no focused monitor found");
1819 break 'edge_check false;
1820 }
1821 };
1822
1823 // Check if there's another monitor in the requested direction
1824 // Use logical dimensions (physical / scale) since positions are logical
1825 let has_monitor_in_direction = monitors.iter().any(|m| {
1826 if m.id == focused_monitor.id { return false; }
1827 let m_logical_w = (m.width as f32 / m.scale).round() as i32;
1828 let m_logical_h = (m.height as f32 / m.scale).round() as i32;
1829 let focused_logical_w = (focused_monitor.width as f32 / focused_monitor.scale).round() as i32;
1830 let focused_logical_h = (focused_monitor.height as f32 / focused_monitor.scale).round() as i32;
1831 match direction {
1832 Direction::Left => m.x + m_logical_w <= focused_monitor.x,
1833 Direction::Right => m.x >= focused_monitor.x + focused_logical_w,
1834 Direction::Up => m.y + m_logical_h <= focused_monitor.y,
1835 Direction::Down => m.y >= focused_monitor.y + focused_logical_h,
1836 }
1837 });
1838
1839 if has_monitor_in_direction {
1840 // There's a monitor in that direction, not at edge
1841 info!(" edge_check: has monitor in direction {:?}", direction);
1842 break 'edge_check false;
1843 }
1844
1845 // We're on the edge monitor. Now check if we're on the edge window.
1846 // Get active window position
1847 let active_window: serde_json::Value = match hypr_client.query("activewindow").await {
1848 Ok(w) => w,
1849 Err(e) => {
1850 info!(" edge_check: activewindow query failed: {}", e);
1851 break 'edge_check false;
1852 }
1853 };
1854
1855 let win_x = active_window.get("at").and_then(|a| a.get(0)).and_then(|x| x.as_i64()).unwrap_or(0) as i32;
1856 let win_y = active_window.get("at").and_then(|a| a.get(1)).and_then(|y| y.as_i64()).unwrap_or(0) as i32;
1857 let win_w = active_window.get("size").and_then(|s| s.get(0)).and_then(|w| w.as_i64()).unwrap_or(100) as i32;
1858 let win_h = active_window.get("size").and_then(|s| s.get(1)).and_then(|h| h.as_i64()).unwrap_or(100) as i32;
1859
1860 // Get all clients (windows)
1861 let clients: Vec<serde_json::Value> = match hypr_client.query("clients").await {
1862 Ok(c) => c,
1863 Err(e) => {
1864 info!(" edge_check: clients query failed: {}", e);
1865 break 'edge_check false;
1866 }
1867 };
1868
1869 // Calculate monitor bounds in logical coordinates
1870 let mon_logical_h = (focused_monitor.height as f32 / focused_monitor.scale).round() as i32;
1871
1872 info!(" edge_check: active window at ({},{}) size {}x{}, {} clients on monitor, mon_y={}, mon_h={}",
1873 win_x, win_y, win_w, win_h,
1874 clients.iter().filter(|c| c.get("monitor").and_then(|m| m.as_i64()).unwrap_or(-1) as i32 == focused_monitor.id).count(),
1875 focused_monitor.y, mon_logical_h);
1876
1877 // Check if any OTHER window on this monitor is further in that direction
1878 let has_window_in_direction = clients.iter().any(|client| {
1879 let mon = client.get("monitor").and_then(|m| m.as_i64()).unwrap_or(-1) as i32;
1880 if mon != focused_monitor.id { return false; }
1881
1882 let cx = client.get("at").and_then(|a| a.get(0)).and_then(|x| x.as_i64()).unwrap_or(0) as i32;
1883 let cy = client.get("at").and_then(|a| a.get(1)).and_then(|y| y.as_i64()).unwrap_or(0) as i32;
1884 let cw = client.get("size").and_then(|s| s.get(0)).and_then(|w| w.as_i64()).unwrap_or(0) as i32;
1885 let ch = client.get("size").and_then(|s| s.get(1)).and_then(|h| h.as_i64()).unwrap_or(0) as i32;
1886
1887 // Skip the active window itself
1888 if cx == win_x && cy == win_y && cw == win_w && ch == win_h {
1889 return false;
1890 }
1891
1892 match direction {
1893 Direction::Left => cx + cw <= win_x + 10,
1894 Direction::Right => cx >= win_x + win_w - 10,
1895 Direction::Up => cy + ch <= win_y + 10,
1896 Direction::Down => cy >= win_y + win_h - 10,
1897 }
1898 });
1899
1900 info!(" edge_check: has_window_in_direction={} -> at_edge={}", has_window_in_direction, !has_window_in_direction);
1901 !has_window_in_direction
1902 };
1903
1904 // Check if we have a peer in this direction
1905 let has_peer = {
1906 let peers = peers.read().await;
1907 peers.contains_key(&direction)
1908 };
1909
1910 // Get neighbor name if configured
1911 let neighbor_name = config.machines.neighbors
1912 .iter()
1913 .find(|n| n.direction == direction)
1914 .map(|n| n.name.clone());
1915
1916 info!("IPC Move {:?}: at_edge={}, has_peer={}, neighbor={:?}", direction, at_edge, has_peer, neighbor_name);
1917
1918 // At edge with peer: either return control or initiate transfer
1919 if at_edge && has_peer && neighbor_name.is_some() {
1920 // Check if we're in ReceivedControl state from this direction
1921 if let transfer::TransferState::ReceivedControl { from, .. } = current_state {
1922 if from == direction {
1923 // Return control to source machine
1924 tracing::info!("Keyboard return: at edge, returning control to {:?}", direction);
1925 if let Err(e) = transfer_manager.return_control().await {
1926 tracing::warn!("Failed to return control: {}", e);
1927 IpcResponse::Error { message: format!("Return failed: {}", e) }
1928 } else {
1929 // Set cooldown to prevent immediate re-transfer (bounce-back)
1930 last_control_return = Some((std::time::Instant::now(), direction));
1931 IpcResponse::Transferred { to_machine: neighbor_name.unwrap() }
1932 }
1933 } else {
1934 // At edge with peer but received control from different direction
1935 if barrier_enabled.load(std::sync::atomic::Ordering::SeqCst) {
1936 IpcResponse::Error { message: "Barrier enabled".to_string() }
1937 } else {
1938 // Initiate new transfer
1939 let cursor_pos = hypr_client.cursor_pos().await
1940 .map(|c| (c.x, c.y))
1941 .unwrap_or((0, 0));
1942
1943 if let Err(e) = transfer_manager.initiate_transfer(
1944 direction,
1945 cursor_pos,
1946 screen_min_x,
1947 screen_min_y,
1948 screen_max_x,
1949 screen_max_y,
1950 true, // keyboard-initiated (IPC Move from keybind)
1951 ).await {
1952 IpcResponse::Error { message: format!("Transfer failed: {}", e) }
1953 } else {
1954 IpcResponse::Transferred { to_machine: neighbor_name.unwrap() }
1955 }
1956 }
1957 }
1958 } else {
1959 // Not in ReceivedControl - check cooldown first (only for matching direction)
1960 let in_cooldown = if let Some((last_return, cooldown_dir)) = last_control_return {
1961 cooldown_dir == direction && last_return.elapsed().as_millis() < CONTROL_RETURN_COOLDOWN_MS as u128
1962 } else {
1963 false
1964 };
1965
1966 if in_cooldown {
1967 // At edge with peer but in cooldown - ignore entirely
1968 // Don't do movefocus because Hyprland may wrap at edge causing bounce-back
1969 tracing::info!("IPC Move {:?}: at edge, in cooldown, ignoring (prevents wrap bounce-back)", direction);
1970 // Clear cooldown after blocking once, so subsequent legitimate presses work
1971 last_control_return = None;
1972 IpcResponse::Ok { message: "in cooldown at edge".to_string() }
1973 } else if barrier_enabled.load(std::sync::atomic::Ordering::SeqCst) {
1974 IpcResponse::Error { message: "Barrier enabled".to_string() }
1975 } else {
1976 // Initiate new transfer
1977 let cursor_pos = hypr_client.cursor_pos().await
1978 .map(|c| (c.x, c.y))
1979 .unwrap_or((0, 0));
1980
1981 if let Err(e) = transfer_manager.initiate_transfer(
1982 direction,
1983 cursor_pos,
1984 screen_min_x,
1985 screen_min_y,
1986 screen_max_x,
1987 screen_max_y,
1988 true, // keyboard-initiated (IPC Move from keybind)
1989 ).await {
1990 IpcResponse::Error { message: format!("Transfer failed: {}", e) }
1991 } else {
1992 IpcResponse::Transferred { to_machine: neighbor_name.unwrap() }
1993 }
1994 }
1995 }
1996 } else {
1997 // Either not at edge, or at edge but no peer
1998 // Check cooldown to prevent spurious navigation after peer return
1999 // (e.g., user pressed Down on cachyos to return, libinput still sees Down held)
2000 let in_cooldown = if let Some((last_return, cooldown_dir)) = last_control_return {
2001 cooldown_dir == direction && last_return.elapsed().as_millis() < CONTROL_RETURN_COOLDOWN_MS as u128
2002 } else {
2003 false
2004 };
2005
2006 if in_cooldown {
2007 tracing::info!("IPC Move {:?}: in cooldown (not at edge), ignoring spurious keypress", direction);
2008 // Clear cooldown after blocking once, so subsequent legitimate presses work
2009 last_control_return = None;
2010 IpcResponse::Ok { message: "in cooldown".to_string() }
2011 } else {
2012 // Do local movefocus
2013 let hypr_dir = match direction {
2014 Direction::Left => "l",
2015 Direction::Right => "r",
2016 Direction::Up => "u",
2017 Direction::Down => "d",
2018 };
2019 info!("IPC Move {:?}: doing local movefocus {}", direction, hypr_dir);
2020 match hypr_client.dispatch("movefocus", hypr_dir).await {
2021 Ok(()) => info!(" movefocus succeeded"),
2022 Err(e) => tracing::error!(" movefocus failed: {}", e),
2023 }
2024 IpcResponse::DoLocalMove
2025 }
2026 }
2027 } // end of else block for Initiating check
2028 }
2029 IpcRequest::Status => {
2030 let state = format!("{:?}", transfer_manager.state().await);
2031 let connected_peers: Vec<String> = {
2032 let peers = peers.read().await;
2033 config.machines.neighbors
2034 .iter()
2035 .filter(|n| peers.contains_key(&n.direction))
2036 .map(|n| n.name.clone())
2037 .collect()
2038 };
2039 let uptime_secs = daemon_start_time.elapsed().as_secs();
2040 IpcResponse::Status {
2041 state,
2042 connected_peers,
2043 uptime_secs,
2044 machine_name: config.machines.self_name.clone(),
2045 }
2046 }
2047 IpcRequest::ListPeers => {
2048 let peers_guard = peers.read().await;
2049 let peer_list: Vec<hyprkvm_common::protocol::PeerInfo> = config.machines.neighbors
2050 .iter()
2051 .map(|n| {
2052 let connected = peers_guard.contains_key(&n.direction);
2053 let status = if connected {
2054 "connected".to_string()
2055 } else {
2056 "disconnected".to_string()
2057 };
2058 hyprkvm_common::protocol::PeerInfo {
2059 name: n.name.clone(),
2060 direction: n.direction,
2061 connected,
2062 address: n.address.to_string(),
2063 status,
2064 }
2065 })
2066 .collect();
2067 IpcResponse::Peers { peers: peer_list }
2068 }
2069 IpcRequest::PingPeer { peer_name } => {
2070 // Find the peer by name
2071 let neighbor = config.machines.neighbors
2072 .iter()
2073 .find(|n| n.name == peer_name);
2074
2075 match neighbor {
2076 Some(n) => {
2077 let direction = n.direction;
2078 let mut peers_guard = peers.write().await;
2079
2080 if let Some(peer_conn) = peers_guard.get_mut(&direction) {
2081 // Send Ping with current timestamp
2082 let timestamp = std::time::SystemTime::now()
2083 .duration_since(std::time::UNIX_EPOCH)
2084 .unwrap()
2085 .as_millis() as u64;
2086
2087 if let Err(e) = peer_conn.send(&Message::Ping { timestamp }).await {
2088 IpcResponse::PingResult {
2089 peer_name,
2090 latency_ms: None,
2091 error: Some(format!("Send failed: {}", e)),
2092 }
2093 } else {
2094 // Wait for Pong with timeout
2095 match tokio::time::timeout(
2096 std::time::Duration::from_secs(5),
2097 peer_conn.recv()
2098 ).await {
2099 Ok(Ok(Some(Message::Pong { timestamp: pong_ts }))) => {
2100 let now = std::time::SystemTime::now()
2101 .duration_since(std::time::UNIX_EPOCH)
2102 .unwrap()
2103 .as_millis() as u64;
2104 let latency = now.saturating_sub(pong_ts);
2105 IpcResponse::PingResult {
2106 peer_name,
2107 latency_ms: Some(latency),
2108 error: None,
2109 }
2110 }
2111 Ok(Ok(Some(_))) => {
2112 IpcResponse::PingResult {
2113 peer_name,
2114 latency_ms: None,
2115 error: Some("Unexpected response".to_string()),
2116 }
2117 }
2118 Ok(Ok(None)) => {
2119 // Connection closed
2120 peers_guard.remove(&direction);
2121 IpcResponse::PingResult {
2122 peer_name,
2123 latency_ms: None,
2124 error: Some("Connection closed".to_string()),
2125 }
2126 }
2127 Ok(Err(e)) => {
2128 IpcResponse::PingResult {
2129 peer_name,
2130 latency_ms: None,
2131 error: Some(format!("Receive error: {}", e)),
2132 }
2133 }
2134 Err(_) => {
2135 IpcResponse::PingResult {
2136 peer_name,
2137 latency_ms: None,
2138 error: Some("Timeout".to_string()),
2139 }
2140 }
2141 }
2142 }
2143 } else {
2144 IpcResponse::PingResult {
2145 peer_name,
2146 latency_ms: None,
2147 error: Some("Peer not connected".to_string()),
2148 }
2149 }
2150 }
2151 None => {
2152 IpcResponse::Error {
2153 message: format!("Unknown peer: {}", peer_name),
2154 }
2155 }
2156 }
2157 }
2158
2159 // ================================================================
2160 // CLI Expansion: Control Transfer
2161 // ================================================================
2162
2163 IpcRequest::Switch { target } => {
2164 use hyprkvm_common::protocol::SwitchTarget;
2165
2166 // Resolve target to a direction
2167 let direction = match &target {
2168 SwitchTarget::Direction(dir) => Some(*dir),
2169 SwitchTarget::MachineName(name) => {
2170 config.machines.neighbors
2171 .iter()
2172 .find(|n| &n.name == name)
2173 .map(|n| n.direction)
2174 }
2175 };
2176
2177 match direction {
2178 Some(dir) => {
2179 let peers_guard = peers.read().await;
2180 if peers_guard.get(&dir).is_some() {
2181 drop(peers_guard);
2182
2183 // Get cursor position (use center of total screen)
2184 let cursor_pos = hypr_client.cursor_pos().await
2185 .map(|c| (c.x, c.y))
2186 .unwrap_or(((screen_min_x + screen_max_x) / 2, (screen_min_y + screen_max_y) / 2));
2187
2188 // Initiate transfer (CLI-initiated, not keyboard)
2189 info!("IPC Switch: calling initiate_transfer");
2190 match transfer_manager.initiate_transfer(dir, cursor_pos, screen_min_x, screen_min_y, screen_max_x, screen_max_y, false).await {
2191 Ok(()) => {
2192 let machine_name = config.machines.neighbors
2193 .iter()
2194 .find(|n| n.direction == dir)
2195 .map(|n| n.name.clone())
2196 .unwrap_or_else(|| format!("{:?}", dir));
2197 info!("IPC Switch: initiate_transfer succeeded, returning response to CLI");
2198 IpcResponse::Transferred { to_machine: machine_name }
2199 }
2200 Err(e) => IpcResponse::Error {
2201 message: format!("Transfer failed: {}", e),
2202 }
2203 }
2204 } else {
2205 IpcResponse::Error {
2206 message: format!("Peer not connected in direction {:?}", dir),
2207 }
2208 }
2209 }
2210 None => {
2211 let name = match target {
2212 SwitchTarget::MachineName(n) => n,
2213 _ => "unknown".to_string(),
2214 };
2215 IpcResponse::Error {
2216 message: format!("Unknown machine: {}", name),
2217 }
2218 }
2219 }
2220 }
2221
2222 IpcRequest::Return => {
2223 match transfer_manager.return_control().await {
2224 Ok(()) => IpcResponse::Ok {
2225 message: "Control returned".to_string(),
2226 },
2227 Err(e) => IpcResponse::Error {
2228 message: format!("Return failed: {}", e),
2229 }
2230 }
2231 }
2232
2233 // ================================================================
2234 // CLI Expansion: Input Management
2235 // ================================================================
2236
2237 IpcRequest::Release => {
2238 // Stop input grabbing
2239 input_grabber.stop(None);
2240 // Abort any pending transfer
2241 transfer_manager.abort().await;
2242 IpcResponse::Ok {
2243 message: "Input released".to_string(),
2244 }
2245 }
2246
2247 IpcRequest::SetBarrier { enabled } => {
2248 barrier_enabled.store(enabled, std::sync::atomic::Ordering::SeqCst);
2249 let status = if enabled { "enabled" } else { "disabled" };
2250 IpcResponse::Ok {
2251 message: format!("Barrier {}", status),
2252 }
2253 }
2254
2255 // ================================================================
2256 // CLI Expansion: Connection Management
2257 // ================================================================
2258
2259 IpcRequest::Disconnect { peer_name } => {
2260 let neighbor = config.machines.neighbors
2261 .iter()
2262 .find(|n| n.name == peer_name);
2263
2264 match neighbor {
2265 Some(n) => {
2266 let direction = n.direction;
2267 let mut peers_guard = peers.write().await;
2268 if let Some(mut peer_conn) = peers_guard.remove(&direction) {
2269 // Send goodbye before disconnecting
2270 let _ = peer_conn.send(&Message::Goodbye).await;
2271 IpcResponse::Ok {
2272 message: format!("Disconnected from {}", peer_name),
2273 }
2274 } else {
2275 IpcResponse::Error {
2276 message: format!("Peer {} not connected", peer_name),
2277 }
2278 }
2279 }
2280 None => IpcResponse::Error {
2281 message: format!("Unknown peer: {}", peer_name),
2282 }
2283 }
2284 }
2285
2286 IpcRequest::Reconnect { peer_name } => {
2287 let neighbor = config.machines.neighbors
2288 .iter()
2289 .find(|n| n.name == peer_name)
2290 .cloned();
2291
2292 match neighbor {
2293 Some(n) => {
2294 let direction = n.direction;
2295 let addr = n.address;
2296 // Remove existing connection if any
2297 {
2298 let mut peers_guard = peers.write().await;
2299 if let Some(mut peer_conn) = peers_guard.remove(&direction) {
2300 let _ = peer_conn.send(&Message::Goodbye).await;
2301 }
2302 }
2303 // Spawn reconnection task (same logic as initial connection)
2304 let peers_clone = peers.clone();
2305 let machine_name = config.machines.self_name.clone();
2306 let neighbor_name = n.name.clone();
2307
2308 // Determine TLS settings for this neighbor
2309 let use_tls = n.tls.unwrap_or(tls_enabled);
2310 let fingerprint = n.fingerprint.clone();
2311 let tofu = config.network.tls.tofu;
2312
2313 tokio::spawn(async move {
2314 // Connect with or without TLS
2315 let conn_result = if use_tls {
2316 network::connect_tls(addr, &neighbor_name, fingerprint.as_deref(), tofu).await
2317 } else {
2318 network::connect(addr).await
2319 };
2320
2321 match conn_result {
2322 Ok(mut conn) => {
2323 // Send Hello with direction for peer sync
2324 let hello = Message::Hello(HelloPayload {
2325 protocol_version: PROTOCOL_VERSION,
2326 machine_name,
2327 capabilities: vec![],
2328 my_direction_for_you: Some(direction),
2329 });
2330 if let Err(e) = conn.send(&hello).await {
2331 tracing::error!("Reconnect: failed to send Hello: {}", e);
2332 return;
2333 }
2334 // Wait for HelloAck
2335 match conn.recv().await {
2336 Ok(Some(Message::HelloAck(ack))) if ack.accepted => {
2337 let mut peers_guard = peers_clone.write().await;
2338 peers_guard.insert(direction, conn);
2339 info!("Reconnected to {}", neighbor_name);
2340 }
2341 Ok(Some(Message::HelloAck(ack))) => {
2342 tracing::error!("Reconnect rejected: {:?}", ack.error);
2343 }
2344 _ => {
2345 tracing::error!("Reconnect: handshake failed");
2346 }
2347 }
2348 }
2349 Err(e) => {
2350 tracing::error!("Reconnect: connection failed: {}", e);
2351 }
2352 }
2353 });
2354 IpcResponse::Ok {
2355 message: format!("Reconnecting to {}", peer_name),
2356 }
2357 }
2358 None => IpcResponse::Error {
2359 message: format!("Unknown peer: {}", peer_name),
2360 }
2361 }
2362 }
2363
2364 // ================================================================
2365 // CLI Expansion: Configuration
2366 // ================================================================
2367
2368 IpcRequest::GetConfig => {
2369 match toml::to_string_pretty(&config) {
2370 Ok(toml_str) => IpcResponse::Config { toml: toml_str },
2371 Err(e) => IpcResponse::Error {
2372 message: format!("Failed to serialize config: {}", e),
2373 }
2374 }
2375 }
2376
2377 IpcRequest::Reload => {
2378 // Re-read and validate config file
2379 match config::Config::load(&config_path) {
2380 Ok(new_config) => {
2381 // Check what changed
2382 let mut changes = Vec::new();
2383 let mut needs_restart = false;
2384
2385 if new_config.machines.self_name != config.machines.self_name {
2386 changes.push(format!(
2387 "machine name: {} -> {} (requires restart)",
2388 config.machines.self_name, new_config.machines.self_name
2389 ));
2390 needs_restart = true;
2391 }
2392
2393 if new_config.network.listen_port != config.network.listen_port {
2394 changes.push(format!(
2395 "listen port: {} -> {} (requires restart)",
2396 config.network.listen_port, new_config.network.listen_port
2397 ));
2398 needs_restart = true;
2399 }
2400
2401 if new_config.machines.neighbors.len() != config.machines.neighbors.len() {
2402 changes.push(format!(
2403 "neighbors: {} -> {} (requires restart)",
2404 config.machines.neighbors.len(), new_config.machines.neighbors.len()
2405 ));
2406 needs_restart = true;
2407 }
2408
2409 // Check for direction changes (requires restart for edge barriers)
2410 // Also send DirectionChange messages to notify peers
2411 let mut direction_changes: Vec<(String, Direction)> = Vec::new();
2412 for new_neighbor in &new_config.machines.neighbors {
2413 if let Some(old_neighbor) = config.machines.neighbors
2414 .iter()
2415 .find(|n| n.name == new_neighbor.name)
2416 {
2417 if old_neighbor.direction != new_neighbor.direction {
2418 changes.push(format!(
2419 "neighbor '{}' direction: {:?} -> {:?} (requires restart)",
2420 new_neighbor.name, old_neighbor.direction, new_neighbor.direction
2421 ));
2422 needs_restart = true;
2423 // Track this change to notify the peer
2424 direction_changes.push((new_neighbor.name.clone(), new_neighbor.direction));
2425 }
2426 }
2427 }
2428
2429 // Send DirectionChange messages to affected peers
2430 // We need to send on the OLD direction since that's where the peer is connected
2431 for (peer_name, new_direction) in &direction_changes {
2432 // Find the OLD direction for this peer from current config
2433 let old_direction = config.machines.neighbors
2434 .iter()
2435 .find(|n| &n.name == peer_name)
2436 .map(|n| n.direction);
2437
2438 if let Some(old_dir) = old_direction {
2439 let mut peers_guard = peers.write().await;
2440 if let Some(peer) = peers_guard.get_mut(&old_dir) {
2441 info!("Sending DirectionChange to {}: you are now {:?} from me",
2442 peer_name, new_direction);
2443 let msg = Message::DirectionChange(
2444 hyprkvm_common::protocol::DirectionChangePayload {
2445 your_direction_from_me: *new_direction,
2446 }
2447 );
2448 if let Err(e) = peer.send(&msg).await {
2449 tracing::warn!("Failed to send DirectionChange to {}: {}", peer_name, e);
2450 }
2451 } else {
2452 tracing::warn!("Peer {} not connected on {:?}", peer_name, old_dir);
2453 }
2454 }
2455 }
2456
2457 // Apply the new config (only if no restart needed)
2458 if !needs_restart {
2459 config = new_config;
2460 }
2461
2462 if changes.is_empty() {
2463 IpcResponse::Ok {
2464 message: "Config unchanged".to_string(),
2465 }
2466 } else if needs_restart {
2467 IpcResponse::Ok {
2468 message: format!(
2469 "Config saved (restart required to apply):\n - {}",
2470 changes.join("\n - ")
2471 ),
2472 }
2473 } else {
2474 IpcResponse::Ok {
2475 message: format!(
2476 "Config reloaded:\n - {}",
2477 changes.join("\n - ")
2478 ),
2479 }
2480 }
2481 }
2482 Err(e) => IpcResponse::Error {
2483 message: format!("Failed to load config: {}", e),
2484 }
2485 }
2486 }
2487
2488 // ================================================================
2489 // CLI Expansion: Daemon Control
2490 // ================================================================
2491
2492 IpcRequest::Shutdown => {
2493 info!("Shutdown requested via IPC");
2494 shutdown_requested.store(true, std::sync::atomic::Ordering::SeqCst);
2495 IpcResponse::Ok {
2496 message: "Shutting down...".to_string(),
2497 }
2498 }
2499
2500 IpcRequest::GetLogs { lines, follow: _ } => {
2501 // Find log files (rolling appender creates daemon.log.YYYY-MM-DD)
2502 let log_dir = dirs::data_local_dir()
2503 .unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
2504 .join("hyprkvm");
2505
2506 // Find the most recent log file
2507 let log_file = std::fs::read_dir(&log_dir)
2508 .ok()
2509 .and_then(|entries| {
2510 entries
2511 .filter_map(|e| e.ok())
2512 .filter(|e| {
2513 e.file_name()
2514 .to_string_lossy()
2515 .starts_with("daemon.log")
2516 })
2517 .max_by_key(|e| e.metadata().ok().and_then(|m| m.modified().ok()))
2518 .map(|e| e.path())
2519 });
2520
2521 match log_file {
2522 Some(path) => {
2523 match std::fs::read_to_string(&path) {
2524 Ok(content) => {
2525 let n = lines.unwrap_or(50) as usize;
2526 let log_lines: Vec<String> = content
2527 .lines()
2528 .rev()
2529 .take(n)
2530 .map(|s| s.to_string())
2531 .collect::<Vec<_>>()
2532 .into_iter()
2533 .rev()
2534 .collect();
2535 IpcResponse::Logs { lines: log_lines }
2536 }
2537 Err(e) => IpcResponse::Error {
2538 message: format!("Failed to read log file: {}", e),
2539 }
2540 }
2541 }
2542 None => {
2543 IpcResponse::Logs {
2544 lines: vec!["No log files found.".to_string()],
2545 }
2546 }
2547 }
2548 }
2549 };
2550
2551 let _ = response_tx.send(response);
2552 }
2553
2554 // Handle restart signal from direction change
2555 Some(reason) = restart_rx.recv() => {
2556 info!("Restart required: {}", reason);
2557 info!("Exiting to allow restart with updated config...");
2558 accept_handle.abort();
2559 // Exit with code 75 (EX_TEMPFAIL) to signal that we need to restart
2560 // This allows systemd or the GUI to restart us
2561 std::process::exit(75);
2562 }
2563
2564 // Shutdown (Ctrl+C or IPC request)
2565 _ = tokio::signal::ctrl_c() => {
2566 info!("Shutting down (Ctrl+C)...");
2567 accept_handle.abort();
2568 break;
2569 }
2570
2571 // Check for IPC shutdown request
2572 _ = async {
2573 while !shutdown_requested.load(std::sync::atomic::Ordering::SeqCst) {
2574 tokio::time::sleep(std::time::Duration::from_millis(100)).await;
2575 }
2576 } => {
2577 info!("Shutting down (IPC request)...");
2578 accept_handle.abort();
2579 break;
2580 }
2581 }
2582 }
2583
2584 Ok(())
2585 }
2586
2587 async fn show_status() -> anyhow::Result<()> {
2588 // TODO: Connect to running daemon via IPC and get status
2589 println!("HyprKVM Status");
2590 println!("==============");
2591 println!("Daemon: not implemented yet");
2592 Ok(())
2593 }
2594
2595 async fn handle_move(direction: &str) -> anyhow::Result<()> {
2596 use hyprkvm_common::Direction;
2597 use hyprkvm_common::protocol::{IpcRequest, IpcResponse};
2598
2599 let dir: Direction = direction.parse()?;
2600
2601 // Try to connect to daemon
2602 match ipc::IpcClient::connect().await {
2603 Ok(mut client) => {
2604 // Ask daemon to handle the move (it does movefocus internally)
2605 let request = IpcRequest::Move { direction: dir };
2606 match client.request(&request).await {
2607 Ok(IpcResponse::Transferred { to_machine }) => {
2608 tracing::info!("Transferred control to {}", to_machine);
2609 }
2610 Ok(IpcResponse::DoLocalMove) => {
2611 // Daemon handled it
2612 }
2613 Ok(IpcResponse::Error { message }) => {
2614 tracing::warn!("Daemon error: {}", message);
2615 }
2616 Ok(_) => {
2617 tracing::warn!("Unexpected response from daemon");
2618 }
2619 Err(e) => {
2620 tracing::debug!("IPC request failed: {}, falling back to local", e);
2621 do_local_move(dir).await?;
2622 }
2623 }
2624 }
2625 Err(e) => {
2626 tracing::debug!("Daemon not running ({}), doing local move", e);
2627 do_local_move(dir).await?;
2628 }
2629 }
2630
2631 Ok(())
2632 }
2633
2634 async fn do_local_move(dir: hyprkvm_common::Direction) -> anyhow::Result<()> {
2635 use hyprkvm_common::Direction;
2636
2637 let hypr_dir = match dir {
2638 Direction::Left => "l",
2639 Direction::Right => "r",
2640 Direction::Up => "u",
2641 Direction::Down => "d",
2642 };
2643
2644 let output = tokio::process::Command::new("hyprctl")
2645 .args(["dispatch", "movefocus", hypr_dir])
2646 .output()
2647 .await?;
2648
2649 if !output.status.success() {
2650 let stderr = String::from_utf8_lossy(&output.stderr);
2651 tracing::error!("hyprctl failed: {}", stderr);
2652 }
2653
2654 Ok(())
2655 }
2656
2657 fn show_config(config_path: &std::path::Path) -> anyhow::Result<()> {
2658 if config_path.exists() {
2659 let content = std::fs::read_to_string(config_path)?;
2660 println!("{}", content);
2661 } else {
2662 println!("No config file at {:?}", config_path);
2663 println!("\nDefault configuration:");
2664 let default = Config::default();
2665 println!("{}", toml::to_string_pretty(&default)?);
2666 }
2667 Ok(())
2668 }
2669
2670 async fn reload_config() -> anyhow::Result<()> {
2671 // TODO: Send reload signal to daemon
2672 println!("Config reload not implemented yet");
2673 Ok(())
2674 }
2675