tenseleyflow/hyprkvm / 7683c88

Browse files

Implement keyboard-based workspace navigation switching

Add IPC mechanism between CLI and daemon so `hyprkvm move <direction>`
can trigger machine transfers when at screen edges.

- Add Unix socket IPC server at $XDG_RUNTIME_DIR/hyprkvm.sock
- Daemon handles Move requests by checking cursor position
- If cursor at edge with connected peer, initiates transfer
- Otherwise returns DoLocalMove for normal hyprctl movefocus
- CLI gracefully falls back to local move if daemon not running

This enables the key HyprKVM feature: using SUPER+Arrow keys to
seamlessly switch between machines when at workspace boundaries.
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
7683c88c9f2ebff77e7e9bf66aa13e781f7c768b
Parents
237c8cf
Tree
8feddc7

3 changed files

StatusFile+-
M hyprkvm-common/src/protocol/mod.rs 43 0
A hyprkvm-daemon/src/ipc.rs 104 0
M hyprkvm-daemon/src/main.rs 182 3
hyprkvm-common/src/protocol/mod.rsmodified
@@ -202,3 +202,46 @@ pub struct ClipboardDataPayload {
202202
     /// Total chunks (if chunked)
203203
     pub total_chunks: Option<u32>,
204204
 }
205
+
206
+// ============================================================================
207
+// IPC Messages (CLI <-> Daemon)
208
+// ============================================================================
209
+
210
+/// IPC request from CLI to daemon
211
+#[derive(Debug, Clone, Serialize, Deserialize)]
212
+#[serde(tag = "type", rename_all = "snake_case")]
213
+pub enum IpcRequest {
214
+    /// Request to move focus in a direction (keyboard navigation)
215
+    Move { direction: Direction },
216
+    /// Get daemon status
217
+    Status,
218
+    /// List connected peers
219
+    ListPeers,
220
+}
221
+
222
+/// IPC response from daemon to CLI
223
+#[derive(Debug, Clone, Serialize, Deserialize)]
224
+#[serde(tag = "type", rename_all = "snake_case")]
225
+pub enum IpcResponse {
226
+    /// Move was handled - transferred to another machine
227
+    Transferred { to_machine: String },
228
+    /// Move should be handled locally (no edge crossing)
229
+    DoLocalMove,
230
+    /// Status response
231
+    Status {
232
+        state: String,
233
+        connected_peers: Vec<String>,
234
+    },
235
+    /// Peer list
236
+    Peers { peers: Vec<PeerInfo> },
237
+    /// Error occurred
238
+    Error { message: String },
239
+}
240
+
241
+/// Info about a connected peer
242
+#[derive(Debug, Clone, Serialize, Deserialize)]
243
+pub struct PeerInfo {
244
+    pub name: String,
245
+    pub direction: Direction,
246
+    pub connected: bool,
247
+}
hyprkvm-daemon/src/ipc.rsadded
@@ -0,0 +1,104 @@
1
+//! IPC module for CLI <-> Daemon communication
2
+//!
3
+//! Uses a Unix socket at $XDG_RUNTIME_DIR/hyprkvm.sock
4
+
5
+use std::path::PathBuf;
6
+use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
7
+use tokio::net::{UnixListener, UnixStream};
8
+
9
+use hyprkvm_common::protocol::{IpcRequest, IpcResponse};
10
+
11
+/// Get the IPC socket path
12
+pub fn socket_path() -> PathBuf {
13
+    let runtime_dir = std::env::var("XDG_RUNTIME_DIR")
14
+        .unwrap_or_else(|_| "/tmp".to_string());
15
+    PathBuf::from(runtime_dir).join("hyprkvm.sock")
16
+}
17
+
18
+/// IPC Server for receiving CLI commands
19
+pub struct IpcServer {
20
+    listener: UnixListener,
21
+}
22
+
23
+impl IpcServer {
24
+    /// Create and bind the IPC server
25
+    pub async fn bind() -> std::io::Result<Self> {
26
+        let path = socket_path();
27
+
28
+        // Remove old socket if it exists
29
+        let _ = std::fs::remove_file(&path);
30
+
31
+        let listener = UnixListener::bind(&path)?;
32
+        tracing::info!("IPC server listening on {}", path.display());
33
+
34
+        Ok(Self { listener })
35
+    }
36
+
37
+    /// Accept a new connection
38
+    pub async fn accept(&self) -> std::io::Result<IpcConnection> {
39
+        let (stream, _) = self.listener.accept().await?;
40
+        Ok(IpcConnection { stream })
41
+    }
42
+}
43
+
44
+/// A single IPC connection
45
+pub struct IpcConnection {
46
+    stream: UnixStream,
47
+}
48
+
49
+impl IpcConnection {
50
+    /// Receive a request
51
+    pub async fn recv(&mut self) -> std::io::Result<Option<IpcRequest>> {
52
+        let mut reader = BufReader::new(&mut self.stream);
53
+        let mut line = String::new();
54
+
55
+        let n = reader.read_line(&mut line).await?;
56
+        if n == 0 {
57
+            return Ok(None);
58
+        }
59
+
60
+        serde_json::from_str(&line)
61
+            .map(Some)
62
+            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
63
+    }
64
+
65
+    /// Send a response
66
+    pub async fn send(&mut self, response: &IpcResponse) -> std::io::Result<()> {
67
+        let json = serde_json::to_string(response)?;
68
+        self.stream.write_all(json.as_bytes()).await?;
69
+        self.stream.write_all(b"\n").await?;
70
+        self.stream.flush().await?;
71
+        Ok(())
72
+    }
73
+}
74
+
75
+/// IPC client for sending commands to daemon
76
+pub struct IpcClient {
77
+    stream: UnixStream,
78
+}
79
+
80
+impl IpcClient {
81
+    /// Connect to the daemon
82
+    pub async fn connect() -> std::io::Result<Self> {
83
+        let path = socket_path();
84
+        let stream = UnixStream::connect(&path).await?;
85
+        Ok(Self { stream })
86
+    }
87
+
88
+    /// Send a request and get response
89
+    pub async fn request(&mut self, req: &IpcRequest) -> std::io::Result<IpcResponse> {
90
+        // Send request
91
+        let json = serde_json::to_string(req)?;
92
+        self.stream.write_all(json.as_bytes()).await?;
93
+        self.stream.write_all(b"\n").await?;
94
+        self.stream.flush().await?;
95
+
96
+        // Read response
97
+        let mut reader = BufReader::new(&mut self.stream);
98
+        let mut line = String::new();
99
+        reader.read_line(&mut line).await?;
100
+
101
+        serde_json::from_str(&line)
102
+            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
103
+    }
104
+}
hyprkvm-daemon/src/main.rsmodified
@@ -13,6 +13,7 @@ use tracing_subscriber::FmtSubscriber;
1313
 mod config;
1414
 mod hyprland;
1515
 mod input;
16
+mod ipc;
1617
 mod network;
1718
 mod state;
1819
 mod transfer;
@@ -356,6 +357,49 @@ async fn run_daemon(config_path: &std::path::Path) -> anyhow::Result<()> {
356357
     // Listen for Hyprland events
357358
     let mut event_stream = hyprland::events::HyprlandEventStream::connect().await?;
358359
 
360
+    // Start IPC server for CLI commands
361
+    let (ipc_tx, mut ipc_rx) = tokio::sync::mpsc::channel::<(
362
+        hyprkvm_common::protocol::IpcRequest,
363
+        tokio::sync::oneshot::Sender<hyprkvm_common::protocol::IpcResponse>,
364
+    )>(16);
365
+
366
+    tokio::spawn(async move {
367
+        let server = match ipc::IpcServer::bind().await {
368
+            Ok(s) => s,
369
+            Err(e) => {
370
+                tracing::error!("Failed to start IPC server: {}", e);
371
+                return;
372
+            }
373
+        };
374
+
375
+        loop {
376
+            match server.accept().await {
377
+                Ok(mut conn) => {
378
+                    let ipc_tx = ipc_tx.clone();
379
+                    tokio::spawn(async move {
380
+                        match conn.recv().await {
381
+                            Ok(Some(request)) => {
382
+                                let (resp_tx, resp_rx) = tokio::sync::oneshot::channel();
383
+                                if ipc_tx.send((request, resp_tx)).await.is_ok() {
384
+                                    if let Ok(response) = resp_rx.await {
385
+                                        let _ = conn.send(&response).await;
386
+                                    }
387
+                                }
388
+                            }
389
+                            Ok(None) => {}
390
+                            Err(e) => {
391
+                                tracing::debug!("IPC recv error: {}", e);
392
+                            }
393
+                        }
394
+                    });
395
+                }
396
+                Err(e) => {
397
+                    tracing::error!("IPC accept error: {}", e);
398
+                }
399
+            }
400
+        }
401
+    });
402
+
359403
     info!("Daemon running. Move mouse to screen edges to trigger transfer. Press Ctrl+C to stop.");
360404
 
361405
     loop {
@@ -836,6 +880,108 @@ async fn run_daemon(config_path: &std::path::Path) -> anyhow::Result<()> {
836880
                 }
837881
             }
838882
 
883
+            // Handle IPC requests from CLI
884
+            Some((request, response_tx)) = ipc_rx.recv() => {
885
+                use hyprkvm_common::protocol::{IpcRequest, IpcResponse};
886
+
887
+                let response = match request {
888
+                    IpcRequest::Move { direction } => {
889
+                        // Check if we're at the edge of our screen in this direction
890
+                        // and have a peer connected in that direction
891
+                        let at_edge = match direction {
892
+                            Direction::Left => {
893
+                                // Check if cursor is at left edge
894
+                                if let Ok(cursor) = hypr_client.cursor_pos().await {
895
+                                    cursor.x <= EDGE_THRESHOLD
896
+                                } else {
897
+                                    false
898
+                                }
899
+                            }
900
+                            Direction::Right => {
901
+                                if let Ok(cursor) = hypr_client.cursor_pos().await {
902
+                                    cursor.x >= screen_width as i32 - EDGE_THRESHOLD
903
+                                } else {
904
+                                    false
905
+                                }
906
+                            }
907
+                            Direction::Up => {
908
+                                if let Ok(cursor) = hypr_client.cursor_pos().await {
909
+                                    cursor.y <= EDGE_THRESHOLD
910
+                                } else {
911
+                                    false
912
+                                }
913
+                            }
914
+                            Direction::Down => {
915
+                                if let Ok(cursor) = hypr_client.cursor_pos().await {
916
+                                    cursor.y >= screen_height as i32 - EDGE_THRESHOLD
917
+                                } else {
918
+                                    false
919
+                                }
920
+                            }
921
+                        };
922
+
923
+                        // Check if we have a peer in this direction
924
+                        let has_peer = {
925
+                            let peers = peers.read().await;
926
+                            peers.contains_key(&direction)
927
+                        };
928
+
929
+                        // Get neighbor name if configured
930
+                        let neighbor_name = config.machines.neighbors
931
+                            .iter()
932
+                            .find(|n| n.direction == direction)
933
+                            .map(|n| n.name.clone());
934
+
935
+                        if at_edge && has_peer && neighbor_name.is_some() {
936
+                            // Initiate transfer
937
+                            let cursor_pos = hypr_client.cursor_pos().await
938
+                                .map(|c| (c.x, c.y))
939
+                                .unwrap_or((0, 0));
940
+
941
+                            if let Err(e) = transfer_manager.initiate_transfer(
942
+                                direction,
943
+                                cursor_pos,
944
+                                screen_height,
945
+                                screen_width,
946
+                            ).await {
947
+                                IpcResponse::Error { message: format!("Transfer failed: {}", e) }
948
+                            } else {
949
+                                IpcResponse::Transferred { to_machine: neighbor_name.unwrap() }
950
+                            }
951
+                        } else {
952
+                            // Let the CLI handle it locally
953
+                            IpcResponse::DoLocalMove
954
+                        }
955
+                    }
956
+                    IpcRequest::Status => {
957
+                        let state = format!("{:?}", transfer_manager.state().await);
958
+                        let connected_peers: Vec<String> = {
959
+                            let peers = peers.read().await;
960
+                            config.machines.neighbors
961
+                                .iter()
962
+                                .filter(|n| peers.contains_key(&n.direction))
963
+                                .map(|n| n.name.clone())
964
+                                .collect()
965
+                        };
966
+                        IpcResponse::Status { state, connected_peers }
967
+                    }
968
+                    IpcRequest::ListPeers => {
969
+                        let peers_guard = peers.read().await;
970
+                        let peer_list: Vec<hyprkvm_common::protocol::PeerInfo> = config.machines.neighbors
971
+                            .iter()
972
+                            .map(|n| hyprkvm_common::protocol::PeerInfo {
973
+                                name: n.name.clone(),
974
+                                direction: n.direction,
975
+                                connected: peers_guard.contains_key(&n.direction),
976
+                            })
977
+                            .collect();
978
+                        IpcResponse::Peers { peers: peer_list }
979
+                    }
980
+                };
981
+
982
+                let _ = response_tx.send(response);
983
+            }
984
+
839985
             // Shutdown
840986
             _ = tokio::signal::ctrl_c() => {
841987
                 info!("Shutting down...");
@@ -858,12 +1004,45 @@ async fn show_status() -> anyhow::Result<()> {
8581004
 
8591005
 async fn handle_move(direction: &str) -> anyhow::Result<()> {
8601006
     use hyprkvm_common::Direction;
1007
+    use hyprkvm_common::protocol::{IpcRequest, IpcResponse};
8611008
 
8621009
     let dir: Direction = direction.parse()?;
863
-    tracing::debug!("Move request: {}", dir);
8641010
 
865
-    // TODO: Connect to daemon, check if network switch needed
866
-    // For now, just execute local hyprctl move
1011
+    // Try to connect to daemon
1012
+    match ipc::IpcClient::connect().await {
1013
+        Ok(mut client) => {
1014
+            // Ask daemon if we should transfer or move locally
1015
+            let request = IpcRequest::Move { direction: dir };
1016
+            match client.request(&request).await {
1017
+                Ok(IpcResponse::Transferred { to_machine }) => {
1018
+                    // Transfer was initiated by daemon
1019
+                    tracing::info!("Transferred control to {}", to_machine);
1020
+                    return Ok(());
1021
+                }
1022
+                Ok(IpcResponse::DoLocalMove) => {
1023
+                    // Fall through to local move
1024
+                }
1025
+                Ok(IpcResponse::Error { message }) => {
1026
+                    tracing::warn!("Daemon error: {}", message);
1027
+                    // Fall through to local move
1028
+                }
1029
+                Ok(_) => {
1030
+                    tracing::warn!("Unexpected response from daemon");
1031
+                    // Fall through to local move
1032
+                }
1033
+                Err(e) => {
1034
+                    tracing::debug!("IPC request failed: {}, doing local move", e);
1035
+                    // Fall through to local move
1036
+                }
1037
+            }
1038
+        }
1039
+        Err(e) => {
1040
+            tracing::debug!("Daemon not running ({}), doing local move", e);
1041
+            // Fall through to local move
1042
+        }
1043
+    }
1044
+
1045
+    // Execute local hyprctl move
8671046
     let output = tokio::process::Command::new("hyprctl")
8681047
         .args(["dispatch", "movefocus", &dir.to_string()])
8691048
         .output()