tenseleyflow/hyprkvm / cf52745

Browse files

Add networking, state management, and configuration

- Implement TCP transport layer with length-prefixed JSON framing
- Add peer connection manager with handshake protocol
- Create state manager for unified daemon state
- Add TOML configuration with machine topology support
- Include example config, systemd service, and keybinding script
- Add basic README
Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
cf52745b0f04cf1e69c3e9810ca3dcc9cc82d485
Parents
39bce59
Tree
b65fea4

12 changed files

StatusFile+-
A README.md 79 0
A config/hyprkvm.example.toml 85 0
A contrib/hyprkvm.desktop 10 0
A contrib/hyprkvm.service 22 0
A flake.lock 82 0
A hyprkvm-daemon/src/config/mod.rs 343 0
A hyprkvm-daemon/src/network/mod.rs 9 0
A hyprkvm-daemon/src/network/peer.rs 270 0
A hyprkvm-daemon/src/network/transport.rs 232 0
A hyprkvm-daemon/src/state/manager.rs 244 0
A hyprkvm-daemon/src/state/mod.rs 7 0
A scripts/hyprkvm-move.sh 52 0
README.mdadded
@@ -0,0 +1,79 @@
1
+# HyprKVM
2
+
3
+Hyprland-native software KVM switch that integrates with workspace navigation.
4
+
5
+## Features
6
+
7
+- **Workspace-integrated switching**: Move past your last workspace to switch machines
8
+- **Mouse edge switching**: Standard screen-edge triggers like Synergy/Barrier
9
+- **Encrypted connections**: TLS with certificate pinning
10
+- **Clipboard sharing**: Sync clipboard between machines
11
+- **GUI and CLI**: Visual layout editor or config-file driven
12
+
13
+## Status
14
+
15
+🚧 **Early Development** - Sprint 0 in progress
16
+
17
+## Building
18
+
19
+### Prerequisites
20
+
21
+- Rust 1.75+
22
+- GTK4 development libraries (for future GUI)
23
+- Hyprland
24
+
25
+### Arch Linux / CachyOS
26
+
27
+```bash
28
+sudo pacman -S rust gtk4 libadwaita
29
+```
30
+
31
+### NixOS
32
+
33
+```bash
34
+nix develop  # When flake is available
35
+```
36
+
37
+### Build
38
+
39
+```bash
40
+cargo build --release
41
+```
42
+
43
+## Quick Start
44
+
45
+1. Start the daemon:
46
+   ```bash
47
+   ./target/release/hyprkvm daemon
48
+   ```
49
+
50
+2. Configure neighbor machines in `~/.config/hyprkvm/hyprkvm.toml`:
51
+   ```toml
52
+   [machines]
53
+   self_name = "my-laptop"
54
+
55
+   [[machines.neighbors]]
56
+   name = "desktop"
57
+   direction = "right"
58
+   address = "desktop.local:24850"
59
+   ```
60
+
61
+3. Optionally, install keybinding interceptors in your `hyprland.conf`:
62
+   ```ini
63
+   bind = SUPER, Left,  exec, ~/.config/hypr/scripts/hyprkvm-move.sh left
64
+   bind = SUPER, Right, exec, ~/.config/hypr/scripts/hyprkvm-move.sh right
65
+   bind = SUPER, Up,    exec, ~/.config/hypr/scripts/hyprkvm-move.sh up
66
+   bind = SUPER, Down,  exec, ~/.config/hypr/scripts/hyprkvm-move.sh down
67
+   ```
68
+
69
+## Configuration
70
+
71
+See [config/hyprkvm.example.toml](config/hyprkvm.example.toml) for all options.
72
+
73
+## Architecture
74
+
75
+See [docs/ROADMAP.md](docs/ROADMAP.md) for the full architecture and development plan.
76
+
77
+## License
78
+
79
+MIT License
config/hyprkvm.example.tomladded
@@ -0,0 +1,85 @@
1
+# HyprKVM Configuration
2
+# Copy to ~/.config/hyprkvm/hyprkvm.toml
3
+
4
+# =============================================================================
5
+# Machine Identity
6
+# =============================================================================
7
+[machines]
8
+# This machine's name (defaults to hostname)
9
+self_name = "my-machine"
10
+
11
+# Neighbor machines
12
+# Configure machines adjacent to this one
13
+
14
+[[machines.neighbors]]
15
+name = "desktop"
16
+direction = "right"  # This machine is to our right
17
+address = "desktop.local:24850"
18
+# Optional: pre-trust fingerprint (skip verification prompt)
19
+# fingerprint = "SHA256:..."
20
+
21
+# [[machines.neighbors]]
22
+# name = "laptop"
23
+# direction = "left"
24
+# address = "192.168.1.20:24850"
25
+
26
+# =============================================================================
27
+# Network Settings
28
+# =============================================================================
29
+[network]
30
+# Port to listen on for incoming connections
31
+listen_port = 24850
32
+
33
+# Address to bind to (0.0.0.0 for all interfaces)
34
+bind_address = "0.0.0.0"
35
+
36
+# Connection timeout in seconds
37
+connect_timeout_secs = 5
38
+
39
+# Heartbeat settings
40
+ping_interval_secs = 5
41
+ping_timeout_secs = 10
42
+
43
+# TLS certificate paths (auto-generated if missing)
44
+[network.tls]
45
+cert_path = "~/.config/hyprkvm/cert.pem"
46
+key_path = "~/.config/hyprkvm/key.pem"
47
+
48
+# =============================================================================
49
+# Input Settings
50
+# =============================================================================
51
+[input]
52
+# Escape hotkey - press to return control to this machine
53
+[input.escape_hotkey]
54
+key = "scroll_lock"
55
+modifiers = []  # e.g., ["super"] for Super+ScrollLock
56
+
57
+# Triple-tap fallback (three quick taps of this key)
58
+triple_tap_enabled = true
59
+triple_tap_key = "shift"
60
+triple_tap_window_ms = 500
61
+
62
+# =============================================================================
63
+# Clipboard Settings
64
+# =============================================================================
65
+[clipboard]
66
+# Enable clipboard synchronization
67
+enabled = true
68
+
69
+# When to sync
70
+sync_on_enter = true   # Sync when taking control of another machine
71
+sync_on_leave = true   # Sync when returning control
72
+
73
+# What to sync
74
+sync_text = true
75
+sync_images = true
76
+
77
+# Maximum clipboard size to sync (bytes)
78
+max_size = 10485760  # 10MB
79
+
80
+# =============================================================================
81
+# Logging
82
+# =============================================================================
83
+[logging]
84
+# Log level: error, warn, info, debug, trace
85
+level = "info"
contrib/hyprkvm.desktopadded
@@ -0,0 +1,10 @@
1
+[Desktop Entry]
2
+Name=HyprKVM
3
+Comment=Hyprland-native software KVM switch
4
+Exec=hyprkvm-gui
5
+Icon=hyprkvm
6
+Type=Application
7
+Categories=Utility;System;
8
+Keywords=kvm;synergy;barrier;input;keyboard;mouse;hyprland;
9
+StartupNotify=true
10
+StartupWMClass=hyprkvm
contrib/hyprkvm.serviceadded
@@ -0,0 +1,22 @@
1
+[Unit]
2
+Description=HyprKVM - Hyprland KVM Switch Daemon
3
+Documentation=https://github.com/tenseleyFlow/hyprKVM
4
+After=graphical-session.target
5
+PartOf=graphical-session.target
6
+
7
+[Service]
8
+Type=simple
9
+ExecStart=%h/.local/bin/hyprkvm daemon
10
+Restart=on-failure
11
+RestartSec=5
12
+Environment=RUST_LOG=info
13
+
14
+# Security hardening
15
+NoNewPrivileges=true
16
+ProtectSystem=strict
17
+ProtectHome=read-only
18
+ReadWritePaths=%h/.config/hyprkvm
19
+PrivateTmp=true
20
+
21
+[Install]
22
+WantedBy=graphical-session.target
flake.lockadded
@@ -0,0 +1,82 @@
1
+{
2
+  "nodes": {
3
+    "flake-utils": {
4
+      "inputs": {
5
+        "systems": "systems"
6
+      },
7
+      "locked": {
8
+        "lastModified": 1731533236,
9
+        "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
10
+        "owner": "numtide",
11
+        "repo": "flake-utils",
12
+        "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
13
+        "type": "github"
14
+      },
15
+      "original": {
16
+        "owner": "numtide",
17
+        "repo": "flake-utils",
18
+        "type": "github"
19
+      }
20
+    },
21
+    "nixpkgs": {
22
+      "locked": {
23
+        "lastModified": 1767379071,
24
+        "narHash": "sha256-EgE0pxsrW9jp9YFMkHL9JMXxcqi/OoumPJYwf+Okucw=",
25
+        "owner": "NixOS",
26
+        "repo": "nixpkgs",
27
+        "rev": "fb7944c166a3b630f177938e478f0378e64ce108",
28
+        "type": "github"
29
+      },
30
+      "original": {
31
+        "owner": "NixOS",
32
+        "ref": "nixos-unstable",
33
+        "repo": "nixpkgs",
34
+        "type": "github"
35
+      }
36
+    },
37
+    "root": {
38
+      "inputs": {
39
+        "flake-utils": "flake-utils",
40
+        "nixpkgs": "nixpkgs",
41
+        "rust-overlay": "rust-overlay"
42
+      }
43
+    },
44
+    "rust-overlay": {
45
+      "inputs": {
46
+        "nixpkgs": [
47
+          "nixpkgs"
48
+        ]
49
+      },
50
+      "locked": {
51
+        "lastModified": 1767408057,
52
+        "narHash": "sha256-0TD2PNTt6olOonFgcvZJcNGiU3x5cX+RMzrfWfHB9Jw=",
53
+        "owner": "oxalica",
54
+        "repo": "rust-overlay",
55
+        "rev": "294198315a13d6d130565ad08e97685df7b0d458",
56
+        "type": "github"
57
+      },
58
+      "original": {
59
+        "owner": "oxalica",
60
+        "repo": "rust-overlay",
61
+        "type": "github"
62
+      }
63
+    },
64
+    "systems": {
65
+      "locked": {
66
+        "lastModified": 1681028828,
67
+        "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
68
+        "owner": "nix-systems",
69
+        "repo": "default",
70
+        "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
71
+        "type": "github"
72
+      },
73
+      "original": {
74
+        "owner": "nix-systems",
75
+        "repo": "default",
76
+        "type": "github"
77
+      }
78
+    }
79
+  },
80
+  "root": "root",
81
+  "version": 7
82
+}
hyprkvm-daemon/src/config/mod.rsadded
@@ -0,0 +1,343 @@
1
+//! Configuration management for HyprKVM
2
+
3
+use std::collections::HashMap;
4
+use std::net::SocketAddr;
5
+use std::path::Path;
6
+
7
+use hyprkvm_common::Direction;
8
+use serde::{Deserialize, Serialize};
9
+
10
+/// Main configuration structure
11
+#[derive(Debug, Clone, Serialize, Deserialize)]
12
+pub struct Config {
13
+    #[serde(default)]
14
+    pub daemon: DaemonConfig,
15
+
16
+    #[serde(default)]
17
+    pub network: NetworkConfig,
18
+
19
+    #[serde(default)]
20
+    pub machines: MachinesConfig,
21
+
22
+    #[serde(default)]
23
+    pub input: InputConfig,
24
+
25
+    #[serde(default)]
26
+    pub clipboard: ClipboardConfig,
27
+
28
+    #[serde(default)]
29
+    pub logging: LoggingConfig,
30
+}
31
+
32
+impl Default for Config {
33
+    fn default() -> Self {
34
+        Self {
35
+            daemon: DaemonConfig::default(),
36
+            network: NetworkConfig::default(),
37
+            machines: MachinesConfig::default(),
38
+            input: InputConfig::default(),
39
+            clipboard: ClipboardConfig::default(),
40
+            logging: LoggingConfig::default(),
41
+        }
42
+    }
43
+}
44
+
45
+impl Config {
46
+    /// Load configuration from file
47
+    pub fn load(path: &Path) -> Result<Self, ConfigError> {
48
+        let content = std::fs::read_to_string(path)
49
+            .map_err(|e| ConfigError::Io(e.to_string()))?;
50
+
51
+        toml::from_str(&content)
52
+            .map_err(|e| ConfigError::Parse(e.to_string()))
53
+    }
54
+
55
+    /// Save configuration to file
56
+    pub fn save(&self, path: &Path) -> Result<(), ConfigError> {
57
+        let content = toml::to_string_pretty(self)
58
+            .map_err(|e| ConfigError::Serialize(e.to_string()))?;
59
+
60
+        // Ensure parent directory exists
61
+        if let Some(parent) = path.parent() {
62
+            std::fs::create_dir_all(parent)
63
+                .map_err(|e| ConfigError::Io(e.to_string()))?;
64
+        }
65
+
66
+        std::fs::write(path, content)
67
+            .map_err(|e| ConfigError::Io(e.to_string()))
68
+    }
69
+}
70
+
71
+#[derive(Debug, Clone, Serialize, Deserialize)]
72
+pub struct DaemonConfig {
73
+    /// Unix socket path for IPC
74
+    #[serde(default = "default_socket_path")]
75
+    pub socket_path: String,
76
+}
77
+
78
+fn default_socket_path() -> String {
79
+    std::env::var("XDG_RUNTIME_DIR")
80
+        .map(|dir| format!("{}/hyprkvm.sock", dir))
81
+        .unwrap_or_else(|_| "/tmp/hyprkvm.sock".to_string())
82
+}
83
+
84
+impl Default for DaemonConfig {
85
+    fn default() -> Self {
86
+        Self {
87
+            socket_path: default_socket_path(),
88
+        }
89
+    }
90
+}
91
+
92
+#[derive(Debug, Clone, Serialize, Deserialize)]
93
+pub struct NetworkConfig {
94
+    /// Port to listen on
95
+    #[serde(default = "default_port")]
96
+    pub listen_port: u16,
97
+
98
+    /// Address to bind to
99
+    #[serde(default = "default_bind_address")]
100
+    pub bind_address: String,
101
+
102
+    /// Connection timeout in seconds
103
+    #[serde(default = "default_timeout")]
104
+    pub connect_timeout_secs: u64,
105
+
106
+    /// Ping interval in seconds
107
+    #[serde(default = "default_ping_interval")]
108
+    pub ping_interval_secs: u64,
109
+
110
+    /// Ping timeout in seconds
111
+    #[serde(default = "default_ping_timeout")]
112
+    pub ping_timeout_secs: u64,
113
+
114
+    /// TLS configuration
115
+    #[serde(default)]
116
+    pub tls: TlsConfig,
117
+}
118
+
119
+fn default_port() -> u16 { 24850 }
120
+fn default_bind_address() -> String { "0.0.0.0".to_string() }
121
+fn default_timeout() -> u64 { 5 }
122
+fn default_ping_interval() -> u64 { 5 }
123
+fn default_ping_timeout() -> u64 { 10 }
124
+
125
+impl Default for NetworkConfig {
126
+    fn default() -> Self {
127
+        Self {
128
+            listen_port: default_port(),
129
+            bind_address: default_bind_address(),
130
+            connect_timeout_secs: default_timeout(),
131
+            ping_interval_secs: default_ping_interval(),
132
+            ping_timeout_secs: default_ping_timeout(),
133
+            tls: TlsConfig::default(),
134
+        }
135
+    }
136
+}
137
+
138
+#[derive(Debug, Clone, Serialize, Deserialize)]
139
+pub struct TlsConfig {
140
+    /// Path to certificate file
141
+    #[serde(default = "default_cert_path")]
142
+    pub cert_path: String,
143
+
144
+    /// Path to private key file
145
+    #[serde(default = "default_key_path")]
146
+    pub key_path: String,
147
+}
148
+
149
+fn default_cert_path() -> String {
150
+    dirs::config_dir()
151
+        .map(|d| d.join("hyprkvm").join("cert.pem"))
152
+        .unwrap_or_else(|| std::path::PathBuf::from("cert.pem"))
153
+        .to_string_lossy()
154
+        .to_string()
155
+}
156
+
157
+fn default_key_path() -> String {
158
+    dirs::config_dir()
159
+        .map(|d| d.join("hyprkvm").join("key.pem"))
160
+        .unwrap_or_else(|| std::path::PathBuf::from("key.pem"))
161
+        .to_string_lossy()
162
+        .to_string()
163
+}
164
+
165
+impl Default for TlsConfig {
166
+    fn default() -> Self {
167
+        Self {
168
+            cert_path: default_cert_path(),
169
+            key_path: default_key_path(),
170
+        }
171
+    }
172
+}
173
+
174
+#[derive(Debug, Clone, Serialize, Deserialize)]
175
+pub struct MachinesConfig {
176
+    /// This machine's name
177
+    #[serde(default = "default_machine_name")]
178
+    pub self_name: String,
179
+
180
+    /// Neighbor machines
181
+    #[serde(default)]
182
+    pub neighbors: Vec<NeighborConfig>,
183
+}
184
+
185
+fn default_machine_name() -> String {
186
+    hostname::get()
187
+        .map(|h| h.to_string_lossy().to_string())
188
+        .unwrap_or_else(|_| "unknown".to_string())
189
+}
190
+
191
+impl Default for MachinesConfig {
192
+    fn default() -> Self {
193
+        Self {
194
+            self_name: default_machine_name(),
195
+            neighbors: Vec::new(),
196
+        }
197
+    }
198
+}
199
+
200
+#[derive(Debug, Clone, Serialize, Deserialize)]
201
+pub struct NeighborConfig {
202
+    /// Machine name
203
+    pub name: String,
204
+
205
+    /// Direction relative to this machine
206
+    pub direction: Direction,
207
+
208
+    /// Address (hostname:port or ip:port)
209
+    pub address: String,
210
+
211
+    /// Pre-trusted certificate fingerprint (optional)
212
+    pub fingerprint: Option<String>,
213
+}
214
+
215
+#[derive(Debug, Clone, Serialize, Deserialize)]
216
+pub struct InputConfig {
217
+    /// Escape hotkey configuration
218
+    #[serde(default)]
219
+    pub escape_hotkey: EscapeHotkeyConfig,
220
+}
221
+
222
+impl Default for InputConfig {
223
+    fn default() -> Self {
224
+        Self {
225
+            escape_hotkey: EscapeHotkeyConfig::default(),
226
+        }
227
+    }
228
+}
229
+
230
+#[derive(Debug, Clone, Serialize, Deserialize)]
231
+pub struct EscapeHotkeyConfig {
232
+    /// Primary escape key (e.g., "scroll_lock", "pause")
233
+    #[serde(default = "default_escape_key")]
234
+    pub key: String,
235
+
236
+    /// Required modifiers
237
+    #[serde(default)]
238
+    pub modifiers: Vec<String>,
239
+
240
+    /// Enable triple-tap escape
241
+    #[serde(default = "default_true")]
242
+    pub triple_tap_enabled: bool,
243
+
244
+    /// Triple-tap key
245
+    #[serde(default = "default_triple_tap_key")]
246
+    pub triple_tap_key: String,
247
+
248
+    /// Triple-tap window in milliseconds
249
+    #[serde(default = "default_triple_tap_window")]
250
+    pub triple_tap_window_ms: u64,
251
+}
252
+
253
+fn default_escape_key() -> String { "scroll_lock".to_string() }
254
+fn default_true() -> bool { true }
255
+fn default_triple_tap_key() -> String { "shift".to_string() }
256
+fn default_triple_tap_window() -> u64 { 500 }
257
+
258
+impl Default for EscapeHotkeyConfig {
259
+    fn default() -> Self {
260
+        Self {
261
+            key: default_escape_key(),
262
+            modifiers: Vec::new(),
263
+            triple_tap_enabled: true,
264
+            triple_tap_key: default_triple_tap_key(),
265
+            triple_tap_window_ms: default_triple_tap_window(),
266
+        }
267
+    }
268
+}
269
+
270
+#[derive(Debug, Clone, Serialize, Deserialize)]
271
+pub struct ClipboardConfig {
272
+    /// Enable clipboard sync
273
+    #[serde(default = "default_true")]
274
+    pub enabled: bool,
275
+
276
+    /// Sync on control transfer enter
277
+    #[serde(default = "default_true")]
278
+    pub sync_on_enter: bool,
279
+
280
+    /// Sync on control transfer leave
281
+    #[serde(default = "default_true")]
282
+    pub sync_on_leave: bool,
283
+
284
+    /// Sync text content
285
+    #[serde(default = "default_true")]
286
+    pub sync_text: bool,
287
+
288
+    /// Sync image content
289
+    #[serde(default = "default_true")]
290
+    pub sync_images: bool,
291
+
292
+    /// Maximum clipboard size in bytes
293
+    #[serde(default = "default_max_clipboard_size")]
294
+    pub max_size: u64,
295
+}
296
+
297
+fn default_max_clipboard_size() -> u64 { 10 * 1024 * 1024 } // 10MB
298
+
299
+impl Default for ClipboardConfig {
300
+    fn default() -> Self {
301
+        Self {
302
+            enabled: true,
303
+            sync_on_enter: true,
304
+            sync_on_leave: true,
305
+            sync_text: true,
306
+            sync_images: true,
307
+            max_size: default_max_clipboard_size(),
308
+        }
309
+    }
310
+}
311
+
312
+#[derive(Debug, Clone, Serialize, Deserialize)]
313
+pub struct LoggingConfig {
314
+    /// Log level (error, warn, info, debug, trace)
315
+    #[serde(default = "default_log_level")]
316
+    pub level: String,
317
+}
318
+
319
+fn default_log_level() -> String { "info".to_string() }
320
+
321
+impl Default for LoggingConfig {
322
+    fn default() -> Self {
323
+        Self {
324
+            level: default_log_level(),
325
+        }
326
+    }
327
+}
328
+
329
+/// Configuration errors
330
+#[derive(Debug, thiserror::Error)]
331
+pub enum ConfigError {
332
+    #[error("IO error: {0}")]
333
+    Io(String),
334
+
335
+    #[error("Parse error: {0}")]
336
+    Parse(String),
337
+
338
+    #[error("Serialize error: {0}")]
339
+    Serialize(String),
340
+
341
+    #[error("Validation error: {0}")]
342
+    Validation(String),
343
+}
hyprkvm-daemon/src/network/mod.rsadded
@@ -0,0 +1,9 @@
1
+//! Network module
2
+//!
3
+//! Handles peer-to-peer connections between HyprKVM instances.
4
+
5
+pub mod peer;
6
+pub mod transport;
7
+
8
+pub use peer::{Peer, PeerError, PeerManager};
9
+pub use transport::{connect, FramedConnection, Server, TransportError};
hyprkvm-daemon/src/network/peer.rsadded
@@ -0,0 +1,270 @@
1
+//! Peer connection management
2
+//!
3
+//! Handles connections to other HyprKVM instances.
4
+
5
+use std::collections::HashMap;
6
+use std::net::SocketAddr;
7
+use std::sync::Arc;
8
+use std::time::Duration;
9
+
10
+use tokio::sync::{mpsc, RwLock};
11
+use tokio::time::timeout;
12
+
13
+use hyprkvm_common::protocol::{
14
+    HelloAckPayload, HelloPayload, Message, PROTOCOL_VERSION,
15
+};
16
+
17
+use super::transport::{connect, FramedConnection, Server, TransportError};
18
+
19
+/// Default timeout for handshake
20
+const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(5);
21
+
22
+/// A connected peer
23
+pub struct Peer {
24
+    /// Connection to the peer
25
+    pub conn: FramedConnection,
26
+    /// Peer's machine name
27
+    pub name: String,
28
+    /// Peer's protocol version
29
+    pub protocol_version: u32,
30
+    /// Peer's capabilities
31
+    pub capabilities: Vec<String>,
32
+}
33
+
34
+impl Peer {
35
+    /// Perform client-side handshake
36
+    pub async fn handshake_client(
37
+        mut conn: FramedConnection,
38
+        our_name: &str,
39
+        our_capabilities: &[String],
40
+    ) -> Result<Self, PeerError> {
41
+        // Send Hello
42
+        let hello = Message::Hello(HelloPayload {
43
+            protocol_version: PROTOCOL_VERSION,
44
+            machine_name: our_name.to_string(),
45
+            capabilities: our_capabilities.to_vec(),
46
+        });
47
+
48
+        conn.send(&hello).await?;
49
+
50
+        // Wait for HelloAck
51
+        let response = timeout(HANDSHAKE_TIMEOUT, conn.recv())
52
+            .await
53
+            .map_err(|_| PeerError::HandshakeTimeout)?
54
+            .map_err(PeerError::Transport)?
55
+            .ok_or(PeerError::ConnectionClosed)?;
56
+
57
+        match response {
58
+            Message::HelloAck(ack) => {
59
+                if !ack.accepted {
60
+                    return Err(PeerError::HandshakeRejected(
61
+                        ack.error.unwrap_or_else(|| "Unknown reason".to_string()),
62
+                    ));
63
+                }
64
+
65
+                tracing::info!(
66
+                    "Handshake successful with peer '{}' (protocol v{})",
67
+                    ack.machine_name,
68
+                    ack.protocol_version
69
+                );
70
+
71
+                Ok(Self {
72
+                    conn,
73
+                    name: ack.machine_name,
74
+                    protocol_version: ack.protocol_version,
75
+                    capabilities: vec![], // Server doesn't send capabilities in ack
76
+                })
77
+            }
78
+            _ => Err(PeerError::UnexpectedMessage),
79
+        }
80
+    }
81
+
82
+    /// Perform server-side handshake
83
+    pub async fn handshake_server(
84
+        mut conn: FramedConnection,
85
+        our_name: &str,
86
+        our_capabilities: &[String],
87
+    ) -> Result<Self, PeerError> {
88
+        // Wait for Hello
89
+        let request = timeout(HANDSHAKE_TIMEOUT, conn.recv())
90
+            .await
91
+            .map_err(|_| PeerError::HandshakeTimeout)?
92
+            .map_err(PeerError::Transport)?
93
+            .ok_or(PeerError::ConnectionClosed)?;
94
+
95
+        match request {
96
+            Message::Hello(hello) => {
97
+                // Check protocol version compatibility
98
+                let accepted = hello.protocol_version == PROTOCOL_VERSION;
99
+                let error = if !accepted {
100
+                    Some(format!(
101
+                        "Protocol version mismatch: expected {}, got {}",
102
+                        PROTOCOL_VERSION, hello.protocol_version
103
+                    ))
104
+                } else {
105
+                    None
106
+                };
107
+
108
+                // Send HelloAck
109
+                let ack = Message::HelloAck(HelloAckPayload {
110
+                    accepted,
111
+                    protocol_version: PROTOCOL_VERSION,
112
+                    machine_name: our_name.to_string(),
113
+                    error: error.clone(),
114
+                });
115
+
116
+                conn.send(&ack).await?;
117
+
118
+                if !accepted {
119
+                    return Err(PeerError::HandshakeRejected(error.unwrap()));
120
+                }
121
+
122
+                tracing::info!(
123
+                    "Handshake successful with peer '{}' (protocol v{})",
124
+                    hello.machine_name,
125
+                    hello.protocol_version
126
+                );
127
+
128
+                Ok(Self {
129
+                    conn,
130
+                    name: hello.machine_name,
131
+                    protocol_version: hello.protocol_version,
132
+                    capabilities: hello.capabilities,
133
+                })
134
+            }
135
+            _ => Err(PeerError::UnexpectedMessage),
136
+        }
137
+    }
138
+
139
+    /// Send a message to the peer
140
+    pub async fn send(&mut self, msg: &Message) -> Result<(), PeerError> {
141
+        self.conn.send(msg).await.map_err(PeerError::Transport)
142
+    }
143
+
144
+    /// Receive a message from the peer
145
+    pub async fn recv(&mut self) -> Result<Option<Message>, PeerError> {
146
+        self.conn.recv().await.map_err(PeerError::Transport)
147
+    }
148
+
149
+    /// Get the remote address
150
+    pub fn remote_addr(&self) -> SocketAddr {
151
+        self.conn.remote_addr()
152
+    }
153
+}
154
+
155
+/// Manages connections to multiple peers
156
+pub struct PeerManager {
157
+    /// Our machine name
158
+    our_name: String,
159
+    /// Our capabilities
160
+    our_capabilities: Vec<String>,
161
+    /// Connected peers by name
162
+    peers: Arc<RwLock<HashMap<String, Peer>>>,
163
+    /// Server for incoming connections
164
+    server: Option<Server>,
165
+}
166
+
167
+impl PeerManager {
168
+    /// Create a new peer manager
169
+    pub fn new(our_name: String, our_capabilities: Vec<String>) -> Self {
170
+        Self {
171
+            our_name,
172
+            our_capabilities,
173
+            peers: Arc::new(RwLock::new(HashMap::new())),
174
+            server: None,
175
+        }
176
+    }
177
+
178
+    /// Start listening for incoming connections
179
+    pub async fn listen(&mut self, addr: SocketAddr) -> Result<SocketAddr, PeerError> {
180
+        let server = Server::bind(addr).await.map_err(PeerError::Transport)?;
181
+        let local_addr = server.local_addr();
182
+        self.server = Some(server);
183
+        Ok(local_addr)
184
+    }
185
+
186
+    /// Accept a new incoming connection (call in a loop)
187
+    pub async fn accept(&self) -> Result<Option<Peer>, PeerError> {
188
+        let server = match &self.server {
189
+            Some(s) => s,
190
+            None => return Ok(None),
191
+        };
192
+
193
+        let conn = server.accept().await.map_err(PeerError::Transport)?;
194
+        let peer = Peer::handshake_server(conn, &self.our_name, &self.our_capabilities).await?;
195
+
196
+        // Add to peers map
197
+        {
198
+            let mut peers = self.peers.write().await;
199
+            peers.insert(peer.name.clone(), peer);
200
+        }
201
+
202
+        // Return None to indicate we stored the peer internally
203
+        // Callers should use get_peer to access it
204
+        Ok(None)
205
+    }
206
+
207
+    /// Connect to a remote peer
208
+    pub async fn connect(&self, addr: SocketAddr) -> Result<String, PeerError> {
209
+        let conn = connect(addr).await.map_err(PeerError::Transport)?;
210
+        let peer = Peer::handshake_client(conn, &self.our_name, &self.our_capabilities).await?;
211
+        let name = peer.name.clone();
212
+
213
+        // Add to peers map
214
+        {
215
+            let mut peers = self.peers.write().await;
216
+            peers.insert(name.clone(), peer);
217
+        }
218
+
219
+        Ok(name)
220
+    }
221
+
222
+    /// Get a peer by name
223
+    pub async fn get_peer(&self, name: &str) -> Option<tokio::sync::RwLockReadGuard<'_, HashMap<String, Peer>>> {
224
+        let peers = self.peers.read().await;
225
+        if peers.contains_key(name) {
226
+            Some(peers)
227
+        } else {
228
+            None
229
+        }
230
+    }
231
+
232
+    /// Remove a peer
233
+    pub async fn remove_peer(&self, name: &str) -> Option<Peer> {
234
+        let mut peers = self.peers.write().await;
235
+        peers.remove(name)
236
+    }
237
+
238
+    /// Get names of all connected peers
239
+    pub async fn peer_names(&self) -> Vec<String> {
240
+        let peers = self.peers.read().await;
241
+        peers.keys().cloned().collect()
242
+    }
243
+
244
+    /// Check if a peer is connected
245
+    pub async fn is_connected(&self, name: &str) -> bool {
246
+        let peers = self.peers.read().await;
247
+        peers.contains_key(name)
248
+    }
249
+}
250
+
251
+#[derive(Debug, thiserror::Error)]
252
+pub enum PeerError {
253
+    #[error("Transport error: {0}")]
254
+    Transport(#[from] TransportError),
255
+
256
+    #[error("Handshake timeout")]
257
+    HandshakeTimeout,
258
+
259
+    #[error("Handshake rejected: {0}")]
260
+    HandshakeRejected(String),
261
+
262
+    #[error("Connection closed")]
263
+    ConnectionClosed,
264
+
265
+    #[error("Unexpected message during handshake")]
266
+    UnexpectedMessage,
267
+
268
+    #[error("Peer not found: {0}")]
269
+    PeerNotFound(String),
270
+}
hyprkvm-daemon/src/network/transport.rsadded
@@ -0,0 +1,232 @@
1
+//! TCP transport layer for HyprKVM
2
+//!
3
+//! Provides basic message framing and transport over TCP.
4
+
5
+use std::io;
6
+use std::net::SocketAddr;
7
+
8
+use bytes::{Buf, BufMut, BytesMut};
9
+use tokio::io::{AsyncReadExt, AsyncWriteExt, BufReader, BufWriter};
10
+use tokio::net::{TcpListener, TcpStream};
11
+
12
+use hyprkvm_common::protocol::Message;
13
+
14
+/// Maximum message size (1MB)
15
+const MAX_MESSAGE_SIZE: u32 = 1024 * 1024;
16
+
17
+/// Frame header size (4 bytes for length)
18
+const FRAME_HEADER_SIZE: usize = 4;
19
+
20
+/// A framed connection that can send and receive Messages
21
+pub struct FramedConnection {
22
+    stream: TcpStream,
23
+    read_buf: BytesMut,
24
+    write_buf: BytesMut,
25
+    remote_addr: SocketAddr,
26
+}
27
+
28
+impl FramedConnection {
29
+    /// Create a new framed connection from a TcpStream
30
+    pub fn new(stream: TcpStream) -> io::Result<Self> {
31
+        let remote_addr = stream.peer_addr()?;
32
+        Ok(Self {
33
+            stream,
34
+            read_buf: BytesMut::with_capacity(8192),
35
+            write_buf: BytesMut::with_capacity(8192),
36
+            remote_addr,
37
+        })
38
+    }
39
+
40
+    /// Get the remote address
41
+    pub fn remote_addr(&self) -> SocketAddr {
42
+        self.remote_addr
43
+    }
44
+
45
+    /// Send a message
46
+    pub async fn send(&mut self, msg: &Message) -> Result<(), TransportError> {
47
+        // Serialize the message
48
+        let json = serde_json::to_vec(msg)
49
+            .map_err(|e| TransportError::Serialize(e.to_string()))?;
50
+
51
+        if json.len() > MAX_MESSAGE_SIZE as usize {
52
+            return Err(TransportError::MessageTooLarge(json.len()));
53
+        }
54
+
55
+        // Write length prefix + data
56
+        self.write_buf.clear();
57
+        self.write_buf.put_u32(json.len() as u32);
58
+        self.write_buf.extend_from_slice(&json);
59
+
60
+        self.stream
61
+            .write_all(&self.write_buf)
62
+            .await
63
+            .map_err(TransportError::Io)?;
64
+
65
+        self.stream.flush().await.map_err(TransportError::Io)?;
66
+
67
+        tracing::trace!("Sent message: {:?}", msg);
68
+        Ok(())
69
+    }
70
+
71
+    /// Receive a message (blocking until one is available or connection closes)
72
+    pub async fn recv(&mut self) -> Result<Option<Message>, TransportError> {
73
+        loop {
74
+            // Try to parse a complete frame from the buffer
75
+            if let Some(msg) = self.try_parse_frame()? {
76
+                return Ok(Some(msg));
77
+            }
78
+
79
+            // Read more data
80
+            let n = self
81
+                .stream
82
+                .read_buf(&mut self.read_buf)
83
+                .await
84
+                .map_err(TransportError::Io)?;
85
+
86
+            if n == 0 {
87
+                // Connection closed
88
+                if self.read_buf.is_empty() {
89
+                    return Ok(None);
90
+                } else {
91
+                    return Err(TransportError::ConnectionReset);
92
+                }
93
+            }
94
+        }
95
+    }
96
+
97
+    /// Try to parse a complete frame from the read buffer
98
+    fn try_parse_frame(&mut self) -> Result<Option<Message>, TransportError> {
99
+        if self.read_buf.len() < FRAME_HEADER_SIZE {
100
+            return Ok(None);
101
+        }
102
+
103
+        // Peek at the length
104
+        let len = u32::from_be_bytes([
105
+            self.read_buf[0],
106
+            self.read_buf[1],
107
+            self.read_buf[2],
108
+            self.read_buf[3],
109
+        ]) as usize;
110
+
111
+        if len > MAX_MESSAGE_SIZE as usize {
112
+            return Err(TransportError::MessageTooLarge(len));
113
+        }
114
+
115
+        let total_frame_size = FRAME_HEADER_SIZE + len;
116
+        if self.read_buf.len() < total_frame_size {
117
+            return Ok(None);
118
+        }
119
+
120
+        // Consume the frame
121
+        self.read_buf.advance(FRAME_HEADER_SIZE);
122
+        let json_data = self.read_buf.split_to(len);
123
+
124
+        // Deserialize
125
+        let msg: Message = serde_json::from_slice(&json_data)
126
+            .map_err(|e| TransportError::Deserialize(e.to_string()))?;
127
+
128
+        tracing::trace!("Received message: {:?}", msg);
129
+        Ok(Some(msg))
130
+    }
131
+
132
+    /// Shutdown the connection gracefully
133
+    pub async fn shutdown(&mut self) -> io::Result<()> {
134
+        self.stream.shutdown().await
135
+    }
136
+}
137
+
138
+/// TCP server that accepts connections
139
+pub struct Server {
140
+    listener: TcpListener,
141
+    local_addr: SocketAddr,
142
+}
143
+
144
+impl Server {
145
+    /// Bind to an address and start listening
146
+    pub async fn bind(addr: SocketAddr) -> Result<Self, TransportError> {
147
+        let listener = TcpListener::bind(addr).await.map_err(TransportError::Io)?;
148
+        let local_addr = listener.local_addr().map_err(TransportError::Io)?;
149
+
150
+        tracing::info!("Server listening on {}", local_addr);
151
+        Ok(Self {
152
+            listener,
153
+            local_addr,
154
+        })
155
+    }
156
+
157
+    /// Get the local address
158
+    pub fn local_addr(&self) -> SocketAddr {
159
+        self.local_addr
160
+    }
161
+
162
+    /// Accept a new connection
163
+    pub async fn accept(&self) -> Result<FramedConnection, TransportError> {
164
+        let (stream, addr) = self.listener.accept().await.map_err(TransportError::Io)?;
165
+        tracing::info!("Accepted connection from {}", addr);
166
+        FramedConnection::new(stream).map_err(TransportError::Io)
167
+    }
168
+}
169
+
170
+/// Connect to a remote server
171
+pub async fn connect(addr: SocketAddr) -> Result<FramedConnection, TransportError> {
172
+    tracing::info!("Connecting to {}", addr);
173
+    let stream = TcpStream::connect(addr).await.map_err(TransportError::Io)?;
174
+    tracing::info!("Connected to {}", addr);
175
+    FramedConnection::new(stream).map_err(TransportError::Io)
176
+}
177
+
178
+#[derive(Debug, thiserror::Error)]
179
+pub enum TransportError {
180
+    #[error("IO error: {0}")]
181
+    Io(#[from] io::Error),
182
+
183
+    #[error("Failed to serialize message: {0}")]
184
+    Serialize(String),
185
+
186
+    #[error("Failed to deserialize message: {0}")]
187
+    Deserialize(String),
188
+
189
+    #[error("Message too large: {0} bytes")]
190
+    MessageTooLarge(usize),
191
+
192
+    #[error("Connection reset while reading")]
193
+    ConnectionReset,
194
+}
195
+
196
+#[cfg(test)]
197
+mod tests {
198
+    use super::*;
199
+    use hyprkvm_common::protocol::{HelloPayload, PROTOCOL_VERSION};
200
+
201
+    #[tokio::test]
202
+    async fn test_roundtrip() {
203
+        let server = Server::bind("127.0.0.1:0".parse().unwrap()).await.unwrap();
204
+        let addr = server.local_addr();
205
+
206
+        let server_handle = tokio::spawn(async move {
207
+            let mut conn = server.accept().await.unwrap();
208
+            let msg = conn.recv().await.unwrap().unwrap();
209
+            conn.send(&msg).await.unwrap();
210
+            conn.shutdown().await.unwrap();
211
+        });
212
+
213
+        let mut client = connect(addr).await.unwrap();
214
+        let msg = Message::Hello(HelloPayload {
215
+            protocol_version: PROTOCOL_VERSION,
216
+            machine_name: "test".to_string(),
217
+            capabilities: vec![],
218
+        });
219
+
220
+        client.send(&msg).await.unwrap();
221
+        let echo = client.recv().await.unwrap().unwrap();
222
+
223
+        if let (Message::Hello(sent), Message::Hello(received)) = (&msg, &echo) {
224
+            assert_eq!(sent.protocol_version, received.protocol_version);
225
+            assert_eq!(sent.machine_name, received.machine_name);
226
+        } else {
227
+            panic!("Wrong message type");
228
+        }
229
+
230
+        server_handle.await.unwrap();
231
+    }
232
+}
hyprkvm-daemon/src/state/manager.rsadded
@@ -0,0 +1,244 @@
1
+//! State manager module
2
+//!
3
+//! Single source of truth for all runtime state.
4
+
5
+use std::net::SocketAddr;
6
+use std::sync::atomic::{AtomicBool, Ordering};
7
+use std::sync::Arc;
8
+
9
+use tokio::sync::RwLock;
10
+
11
+use hyprkvm_common::Direction;
12
+
13
+use crate::config::Config;
14
+use crate::hyprland::ipc::HyprlandClient;
15
+use crate::hyprland::layout::MonitorLayout;
16
+use crate::input::EdgeEvent;
17
+
18
+/// The current control state
19
+#[derive(Debug, Clone, PartialEq, Eq)]
20
+pub enum ControlState {
21
+    /// This machine has keyboard/mouse control
22
+    Local,
23
+    /// Control transferred to another machine
24
+    Remote { machine: String },
25
+}
26
+
27
+/// What triggered an edge detection
28
+#[derive(Debug, Clone)]
29
+pub enum EdgeTrigger {
30
+    /// Mouse moved to screen edge
31
+    Mouse {
32
+        direction: Direction,
33
+        position: (i32, i32),
34
+    },
35
+    /// Keyboard navigation at workspace boundary
36
+    Keyboard { direction: Direction },
37
+}
38
+
39
+/// Information about a neighbor machine
40
+#[derive(Debug, Clone)]
41
+pub struct NeighborInfo {
42
+    pub name: String,
43
+    pub address: SocketAddr,
44
+    pub direction: Direction,
45
+}
46
+
47
+/// Unified state manager for the daemon
48
+pub struct StateManager {
49
+    /// Hyprland client for queries
50
+    hyprland: HyprlandClient,
51
+    /// Current monitor layout
52
+    layout: RwLock<MonitorLayout>,
53
+    /// Daemon configuration
54
+    config: Config,
55
+    /// Whether we have local control
56
+    has_local_control: AtomicBool,
57
+    /// Current remote machine (if control is remote)
58
+    current_remote: RwLock<Option<String>>,
59
+}
60
+
61
+impl StateManager {
62
+    /// Create a new state manager
63
+    pub async fn new(config: Config, hyprland: HyprlandClient) -> Result<Self, StateError> {
64
+        // Get initial monitor layout
65
+        let monitors = hyprland
66
+            .monitors()
67
+            .await
68
+            .map_err(|e| StateError::Hyprland(e.to_string()))?;
69
+
70
+        let layout = MonitorLayout::from_monitors(&monitors);
71
+
72
+        tracing::info!(
73
+            "Initialized state manager with {} monitors",
74
+            monitors.len()
75
+        );
76
+
77
+        Ok(Self {
78
+            hyprland,
79
+            layout: RwLock::new(layout),
80
+            config,
81
+            has_local_control: AtomicBool::new(true),
82
+            current_remote: RwLock::new(None),
83
+        })
84
+    }
85
+
86
+    /// Get current control state
87
+    pub async fn control_state(&self) -> ControlState {
88
+        if self.has_local_control.load(Ordering::Relaxed) {
89
+            ControlState::Local
90
+        } else {
91
+            let remote = self.current_remote.read().await;
92
+            match remote.as_ref() {
93
+                Some(name) => ControlState::Remote {
94
+                    machine: name.clone(),
95
+                },
96
+                None => ControlState::Local,
97
+            }
98
+        }
99
+    }
100
+
101
+    /// Check if an edge trigger should cause a network switch
102
+    pub async fn should_network_switch(&self, trigger: &EdgeTrigger) -> Option<NeighborInfo> {
103
+        let direction = match trigger {
104
+            EdgeTrigger::Mouse { direction, .. } => direction,
105
+            EdgeTrigger::Keyboard { direction } => direction,
106
+        };
107
+
108
+        // Check if we have a neighbor in this direction
109
+        self.get_neighbor(*direction).await
110
+    }
111
+
112
+    /// Get neighbor machine in the given direction
113
+    pub async fn get_neighbor(&self, direction: Direction) -> Option<NeighborInfo> {
114
+        let layout = self.layout.read().await;
115
+
116
+        // First check if we're at the edge of our local monitors
117
+        if !layout.is_at_edge(direction) {
118
+            tracing::debug!(
119
+                "Not at local edge for direction {:?}, no network switch",
120
+                direction
121
+            );
122
+            return None;
123
+        }
124
+
125
+        // Check if we have a configured neighbor in this direction
126
+        self.config
127
+            .machines
128
+            .neighbors
129
+            .iter()
130
+            .find(|n| n.direction == direction)
131
+            .map(|n| NeighborInfo {
132
+                name: n.name.clone(),
133
+                address: n.address,
134
+                direction: n.direction,
135
+            })
136
+    }
137
+
138
+    /// Set control as transferred to a remote machine
139
+    pub async fn set_control_remote(&self, machine: &str) {
140
+        tracing::info!("Control transferred to: {}", machine);
141
+        self.has_local_control.store(false, Ordering::Relaxed);
142
+        let mut remote = self.current_remote.write().await;
143
+        *remote = Some(machine.to_string());
144
+    }
145
+
146
+    /// Set control as returned to local
147
+    pub async fn set_control_local(&self) {
148
+        tracing::info!("Control returned to local");
149
+        self.has_local_control.store(true, Ordering::Relaxed);
150
+        let mut remote = self.current_remote.write().await;
151
+        *remote = None;
152
+    }
153
+
154
+    /// Refresh monitor layout from Hyprland
155
+    pub async fn refresh_layout(&self) -> Result<(), StateError> {
156
+        let monitors = self
157
+            .hyprland
158
+            .monitors()
159
+            .await
160
+            .map_err(|e| StateError::Hyprland(e.to_string()))?;
161
+
162
+        let new_layout = MonitorLayout::from_monitors(&monitors);
163
+
164
+        let mut layout = self.layout.write().await;
165
+        *layout = new_layout;
166
+
167
+        tracing::debug!("Refreshed monitor layout: {} monitors", monitors.len());
168
+        Ok(())
169
+    }
170
+
171
+    /// Get a read lock on the current layout
172
+    pub async fn layout(&self) -> tokio::sync::RwLockReadGuard<'_, MonitorLayout> {
173
+        self.layout.read().await
174
+    }
175
+
176
+    /// Get the configured edges that have network neighbors
177
+    pub fn network_edge_directions(&self) -> Vec<Direction> {
178
+        self.config
179
+            .machines
180
+            .neighbors
181
+            .iter()
182
+            .map(|n| n.direction)
183
+            .collect()
184
+    }
185
+
186
+    /// Process an edge event and determine if action needed
187
+    pub async fn process_edge_event(&self, event: EdgeEvent) -> Option<NeighborInfo> {
188
+        let trigger = EdgeTrigger::Mouse {
189
+            direction: event.direction,
190
+            position: event.position,
191
+        };
192
+
193
+        if let Some(neighbor) = self.should_network_switch(&trigger).await {
194
+            tracing::info!(
195
+                "Edge event {:?} should switch to {}",
196
+                event.direction,
197
+                neighbor.name
198
+            );
199
+            Some(neighbor)
200
+        } else {
201
+            tracing::debug!(
202
+                "Edge event {:?} has no network neighbor",
203
+                event.direction
204
+            );
205
+            None
206
+        }
207
+    }
208
+
209
+    /// Process a keyboard move and determine if action needed
210
+    pub async fn process_keyboard_move(&self, direction: Direction) -> Option<NeighborInfo> {
211
+        let trigger = EdgeTrigger::Keyboard { direction };
212
+
213
+        if let Some(neighbor) = self.should_network_switch(&trigger).await {
214
+            tracing::info!(
215
+                "Keyboard move {:?} should switch to {}",
216
+                direction,
217
+                neighbor.name
218
+            );
219
+            Some(neighbor)
220
+        } else {
221
+            tracing::debug!("Keyboard move {:?} has no network neighbor", direction);
222
+            None
223
+        }
224
+    }
225
+
226
+    /// Get machine name
227
+    pub fn machine_name(&self) -> &str {
228
+        &self.config.machines.self_name
229
+    }
230
+
231
+    /// Get the config reference
232
+    pub fn config(&self) -> &Config {
233
+        &self.config
234
+    }
235
+}
236
+
237
+#[derive(Debug, thiserror::Error)]
238
+pub enum StateError {
239
+    #[error("Hyprland error: {0}")]
240
+    Hyprland(String),
241
+
242
+    #[error("Configuration error: {0}")]
243
+    Config(String),
244
+}
hyprkvm-daemon/src/state/mod.rsadded
@@ -0,0 +1,7 @@
1
+//! State management module
2
+//!
3
+//! Maintains the unified state of the daemon.
4
+
5
+pub mod manager;
6
+
7
+pub use manager::{ControlState, EdgeTrigger, NeighborInfo, StateError, StateManager};
scripts/hyprkvm-move.shadded
@@ -0,0 +1,52 @@
1
+#!/bin/bash
2
+# HyprKVM Move Handler
3
+#
4
+# This script intercepts workspace movement keybindings and routes them
5
+# through HyprKVM to enable cross-machine navigation.
6
+#
7
+# Usage: hyprkvm-move.sh <direction>
8
+# Directions: left, right, up, down
9
+#
10
+# Install in hyprland.conf:
11
+#   bind = SUPER, Left,  exec, ~/.config/hypr/scripts/hyprkvm-move.sh left
12
+#   bind = SUPER, Right, exec, ~/.config/hypr/scripts/hyprkvm-move.sh right
13
+#   bind = SUPER, Up,    exec, ~/.config/hypr/scripts/hyprkvm-move.sh up
14
+#   bind = SUPER, Down,  exec, ~/.config/hypr/scripts/hyprkvm-move.sh down
15
+
16
+set -e
17
+
18
+DIRECTION="${1:-}"
19
+SOCKET_PATH="${XDG_RUNTIME_DIR:-/tmp}/hyprkvm.sock"
20
+
21
+if [[ -z "$DIRECTION" ]]; then
22
+    echo "Usage: $0 <left|right|up|down>" >&2
23
+    exit 1
24
+fi
25
+
26
+# Validate direction
27
+case "$DIRECTION" in
28
+    left|right|up|down|l|r|u|d)
29
+        ;;
30
+    *)
31
+        echo "Invalid direction: $DIRECTION" >&2
32
+        echo "Valid: left, right, up, down" >&2
33
+        exit 1
34
+        ;;
35
+esac
36
+
37
+# Check if HyprKVM daemon is running
38
+if [[ -S "$SOCKET_PATH" ]]; then
39
+    # Daemon is running - send move request
40
+    # The daemon will decide whether to do a local move or network switch
41
+    if command -v hyprkvm &>/dev/null; then
42
+        hyprkvm move "$DIRECTION"
43
+    else
44
+        # Fallback: try to send via socket directly
45
+        echo "{\"command\":\"move\",\"direction\":\"$DIRECTION\"}" | \
46
+            nc -U -N "$SOCKET_PATH" 2>/dev/null || \
47
+            hyprctl dispatch movefocus "$DIRECTION"
48
+    fi
49
+else
50
+    # Daemon not running - fall back to native hyprctl
51
+    hyprctl dispatch movefocus "$DIRECTION"
52
+fi