Rust · 17003 bytes Raw Blame History
1 //! HyprKVM CLI tool
2 //!
3 //! Separate CLI for querying daemon status and managing configuration.
4
5 use std::path::PathBuf;
6
7 use clap::{Parser, Subcommand};
8 use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
9 use tokio::net::UnixStream;
10
11 use hyprkvm_common::protocol::{IpcRequest, IpcResponse, SwitchTarget};
12 use hyprkvm_common::Direction;
13
14 #[derive(Parser)]
15 #[command(name = "hyprkvm-ctl")]
16 #[command(about = "HyprKVM control utility")]
17 #[command(version)]
18 struct Cli {
19 #[command(subcommand)]
20 command: Commands,
21 }
22
23 #[derive(Subcommand)]
24 enum Commands {
25 // ========================================================================
26 // Status & Diagnostics
27 // ========================================================================
28 /// Show daemon status
29 Status {
30 /// Output as JSON
31 #[arg(long)]
32 json: bool,
33 },
34
35 /// List connected peers
36 Peers {
37 /// Output as JSON
38 #[arg(long)]
39 json: bool,
40 },
41
42 /// Ping a peer
43 Ping {
44 /// Peer name to ping
45 peer: String,
46 },
47
48 // ========================================================================
49 // Control Transfer
50 // ========================================================================
51 /// Transfer control to another machine
52 Switch {
53 /// Direction (left/right/up/down) or machine name
54 target: String,
55 },
56
57 /// Return control to this machine
58 Return,
59
60 // ========================================================================
61 // Input Management
62 // ========================================================================
63 /// Force release input capture (recovery)
64 Release,
65
66 /// Enable/disable edge barrier (lock cursor to this machine)
67 Barrier {
68 #[command(subcommand)]
69 action: BarrierAction,
70 },
71
72 // ========================================================================
73 // Connection Management
74 // ========================================================================
75 /// Disconnect from a peer
76 Disconnect {
77 /// Peer name to disconnect
78 peer: String,
79 },
80
81 /// Reconnect to a peer
82 Reconnect {
83 /// Peer name to reconnect
84 peer: String,
85 },
86
87 // ========================================================================
88 // Configuration & Daemon
89 // ========================================================================
90 /// Show current configuration
91 Config {
92 #[command(subcommand)]
93 action: ConfigAction,
94 },
95
96 /// Reload configuration from file
97 Reload,
98
99 /// Shutdown the daemon
100 Shutdown,
101
102 /// Show daemon logs
103 Logs {
104 /// Number of lines to show
105 #[arg(short = 'n', default_value = "50")]
106 lines: u32,
107 },
108 }
109
110 #[derive(Subcommand)]
111 enum BarrierAction {
112 /// Enable barrier (prevent cursor from leaving)
113 On,
114 /// Disable barrier (allow cursor to leave)
115 Off,
116 }
117
118 #[derive(Subcommand)]
119 enum ConfigAction {
120 /// Show current configuration
121 Show,
122 }
123
124 // ============================================================================
125 // IPC Client
126 // ============================================================================
127
128 /// Get the IPC socket path
129 fn socket_path() -> PathBuf {
130 let runtime_dir =
131 std::env::var("XDG_RUNTIME_DIR").unwrap_or_else(|_| "/tmp".to_string());
132 PathBuf::from(runtime_dir).join("hyprkvm.sock")
133 }
134
135 /// IPC client for sending commands to daemon
136 struct IpcClient {
137 stream: UnixStream,
138 }
139
140 impl IpcClient {
141 /// Connect to the daemon
142 async fn connect() -> std::io::Result<Self> {
143 let path = socket_path();
144 let stream = UnixStream::connect(&path).await?;
145 Ok(Self { stream })
146 }
147
148 /// Send a request and get response
149 async fn request(&mut self, req: &IpcRequest) -> std::io::Result<IpcResponse> {
150 // Send request
151 let json = serde_json::to_string(req)?;
152 self.stream.write_all(json.as_bytes()).await?;
153 self.stream.write_all(b"\n").await?;
154 self.stream.flush().await?;
155
156 // Read response
157 let mut reader = BufReader::new(&mut self.stream);
158 let mut line = String::new();
159 reader.read_line(&mut line).await?;
160
161 serde_json::from_str(&line)
162 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
163 }
164 }
165
166 /// Connect to daemon or exit with error
167 async fn connect_or_exit() -> IpcClient {
168 match IpcClient::connect().await {
169 Ok(c) => c,
170 Err(e) => {
171 eprintln!("Error: daemon not running ({})", e);
172 std::process::exit(1);
173 }
174 }
175 }
176
177 // ============================================================================
178 // Helpers
179 // ============================================================================
180
181 /// Format uptime in human-readable form
182 fn format_uptime(secs: u64) -> String {
183 let days = secs / 86400;
184 let hours = (secs % 86400) / 3600;
185 let mins = (secs % 3600) / 60;
186 let secs = secs % 60;
187
188 if days > 0 {
189 format!("{}d {}h {}m {}s", days, hours, mins, secs)
190 } else if hours > 0 {
191 format!("{}h {}m {}s", hours, mins, secs)
192 } else if mins > 0 {
193 format!("{}m {}s", mins, secs)
194 } else {
195 format!("{}s", secs)
196 }
197 }
198
199 /// Get colored status indicator
200 fn status_indicator(status: &str) -> &'static str {
201 match status {
202 "connected" => "\x1b[32m●\x1b[0m", // Green dot
203 "connecting" => "\x1b[33m●\x1b[0m", // Yellow dot
204 "disconnected" => "\x1b[31m●\x1b[0m", // Red dot
205 _ => "○", // Empty dot
206 }
207 }
208
209 /// Parse target as direction or machine name
210 fn parse_switch_target(target: &str) -> SwitchTarget {
211 match target.to_lowercase().as_str() {
212 "left" | "l" => SwitchTarget::Direction(Direction::Left),
213 "right" | "r" => SwitchTarget::Direction(Direction::Right),
214 "up" | "u" => SwitchTarget::Direction(Direction::Up),
215 "down" | "d" => SwitchTarget::Direction(Direction::Down),
216 _ => SwitchTarget::MachineName(target.to_string()),
217 }
218 }
219
220 /// Handle common response types
221 fn handle_ok_or_error(response: IpcResponse) -> anyhow::Result<()> {
222 match response {
223 IpcResponse::Ok { message } => {
224 println!("{}", message);
225 Ok(())
226 }
227 IpcResponse::Transferred { to_machine } => {
228 println!("Control transferred to {}", to_machine);
229 Ok(())
230 }
231 IpcResponse::Error { message } => {
232 eprintln!("Error: {}", message);
233 std::process::exit(1);
234 }
235 _ => {
236 eprintln!("Unexpected response from daemon");
237 std::process::exit(1);
238 }
239 }
240 }
241
242 // ============================================================================
243 // Command Handlers
244 // ============================================================================
245
246 async fn handle_status(json_output: bool) -> anyhow::Result<()> {
247 let mut client = match IpcClient::connect().await {
248 Ok(c) => c,
249 Err(e) => {
250 if json_output {
251 println!(
252 "{}",
253 serde_json::json!({
254 "error": "daemon not running",
255 "details": e.to_string()
256 })
257 );
258 } else {
259 eprintln!("Error: daemon not running ({})", e);
260 }
261 std::process::exit(1);
262 }
263 };
264
265 let response = client.request(&IpcRequest::Status).await?;
266
267 match response {
268 IpcResponse::Status {
269 state,
270 connected_peers,
271 uptime_secs,
272 machine_name,
273 } => {
274 if json_output {
275 println!(
276 "{}",
277 serde_json::json!({
278 "machine_name": machine_name,
279 "state": state,
280 "connected_peers": connected_peers,
281 "uptime_secs": uptime_secs,
282 })
283 );
284 } else {
285 println!("HyprKVM Status");
286 println!("──────────────────────────────");
287 println!("Machine: {}", machine_name);
288 println!("State: {}", state);
289 println!("Uptime: {}", format_uptime(uptime_secs));
290 println!("Peers: {} connected", connected_peers.len());
291 if !connected_peers.is_empty() {
292 println!(" {}", connected_peers.join(", "));
293 }
294 }
295 }
296 IpcResponse::Error { message } => {
297 if json_output {
298 println!("{}", serde_json::json!({ "error": message }));
299 } else {
300 eprintln!("Error: {}", message);
301 }
302 std::process::exit(1);
303 }
304 _ => {
305 eprintln!("Unexpected response from daemon");
306 std::process::exit(1);
307 }
308 }
309
310 Ok(())
311 }
312
313 async fn handle_peers(json_output: bool) -> anyhow::Result<()> {
314 let mut client = match IpcClient::connect().await {
315 Ok(c) => c,
316 Err(e) => {
317 if json_output {
318 println!(
319 "{}",
320 serde_json::json!({
321 "error": "daemon not running",
322 "details": e.to_string()
323 })
324 );
325 } else {
326 eprintln!("Error: daemon not running ({})", e);
327 }
328 std::process::exit(1);
329 }
330 };
331
332 let response = client.request(&IpcRequest::ListPeers).await?;
333
334 match response {
335 IpcResponse::Peers { peers } => {
336 if json_output {
337 println!("{}", serde_json::to_string_pretty(&peers)?);
338 } else {
339 if peers.is_empty() {
340 println!("No peers configured");
341 } else {
342 println!("Configured Peers");
343 println!("──────────────────────────────────────────────────");
344 for peer in &peers {
345 let indicator = status_indicator(&peer.status);
346 println!(
347 "{} {} ({:?}) - {}",
348 indicator, peer.name, peer.direction, peer.address
349 );
350 }
351 println!("──────────────────────────────────────────────────");
352 let connected = peers.iter().filter(|p| p.connected).count();
353 println!("{}/{} peers connected", connected, peers.len());
354 }
355 }
356 }
357 IpcResponse::Error { message } => {
358 if json_output {
359 println!("{}", serde_json::json!({ "error": message }));
360 } else {
361 eprintln!("Error: {}", message);
362 }
363 std::process::exit(1);
364 }
365 _ => {
366 eprintln!("Unexpected response from daemon");
367 std::process::exit(1);
368 }
369 }
370
371 Ok(())
372 }
373
374 async fn handle_ping(peer_name: String) -> anyhow::Result<()> {
375 let mut client = connect_or_exit().await;
376
377 println!("Pinging {}...", peer_name);
378
379 let response = client
380 .request(&IpcRequest::PingPeer {
381 peer_name: peer_name.clone(),
382 })
383 .await?;
384
385 match response {
386 IpcResponse::PingResult {
387 peer_name,
388 latency_ms,
389 error,
390 } => {
391 if let Some(err) = error {
392 eprintln!("Ping failed: {}", err);
393 std::process::exit(1);
394 } else if let Some(ms) = latency_ms {
395 println!("Reply from {}: time={}ms", peer_name, ms);
396 } else {
397 eprintln!("Ping failed: no response");
398 std::process::exit(1);
399 }
400 }
401 IpcResponse::Error { message } => {
402 eprintln!("Error: {}", message);
403 std::process::exit(1);
404 }
405 _ => {
406 eprintln!("Unexpected response from daemon");
407 std::process::exit(1);
408 }
409 }
410
411 Ok(())
412 }
413
414 async fn handle_switch(target: String) -> anyhow::Result<()> {
415 let mut client = connect_or_exit().await;
416 let switch_target = parse_switch_target(&target);
417 let response = client.request(&IpcRequest::Switch { target: switch_target }).await?;
418 handle_ok_or_error(response)
419 }
420
421 async fn handle_return() -> anyhow::Result<()> {
422 let mut client = connect_or_exit().await;
423 let response = client.request(&IpcRequest::Return).await?;
424 handle_ok_or_error(response)
425 }
426
427 async fn handle_release() -> anyhow::Result<()> {
428 let mut client = connect_or_exit().await;
429 let response = client.request(&IpcRequest::Release).await?;
430 handle_ok_or_error(response)
431 }
432
433 async fn handle_barrier(enabled: bool) -> anyhow::Result<()> {
434 let mut client = connect_or_exit().await;
435 let response = client.request(&IpcRequest::SetBarrier { enabled }).await?;
436 handle_ok_or_error(response)
437 }
438
439 async fn handle_disconnect(peer: String) -> anyhow::Result<()> {
440 let mut client = connect_or_exit().await;
441 let response = client.request(&IpcRequest::Disconnect { peer_name: peer }).await?;
442 handle_ok_or_error(response)
443 }
444
445 async fn handle_reconnect(peer: String) -> anyhow::Result<()> {
446 let mut client = connect_or_exit().await;
447 let response = client.request(&IpcRequest::Reconnect { peer_name: peer }).await?;
448 handle_ok_or_error(response)
449 }
450
451 async fn handle_config_show() -> anyhow::Result<()> {
452 let mut client = connect_or_exit().await;
453 let response = client.request(&IpcRequest::GetConfig).await?;
454
455 match response {
456 IpcResponse::Config { toml } => {
457 println!("{}", toml);
458 Ok(())
459 }
460 IpcResponse::Error { message } => {
461 eprintln!("Error: {}", message);
462 std::process::exit(1);
463 }
464 _ => {
465 eprintln!("Unexpected response from daemon");
466 std::process::exit(1);
467 }
468 }
469 }
470
471 async fn handle_reload() -> anyhow::Result<()> {
472 let mut client = connect_or_exit().await;
473 let response = client.request(&IpcRequest::Reload).await?;
474 handle_ok_or_error(response)
475 }
476
477 async fn handle_shutdown() -> anyhow::Result<()> {
478 let mut client = connect_or_exit().await;
479 let response = client.request(&IpcRequest::Shutdown).await?;
480 handle_ok_or_error(response)
481 }
482
483 async fn handle_logs(lines: u32) -> anyhow::Result<()> {
484 let mut client = connect_or_exit().await;
485 let response = client
486 .request(&IpcRequest::GetLogs {
487 lines: Some(lines),
488 follow: false,
489 })
490 .await?;
491
492 match response {
493 IpcResponse::Logs { lines } => {
494 for line in lines {
495 println!("{}", line);
496 }
497 Ok(())
498 }
499 IpcResponse::Error { message } => {
500 eprintln!("Error: {}", message);
501 std::process::exit(1);
502 }
503 _ => {
504 eprintln!("Unexpected response from daemon");
505 std::process::exit(1);
506 }
507 }
508 }
509
510 // ============================================================================
511 // Main
512 // ============================================================================
513
514 #[tokio::main]
515 async fn main() -> anyhow::Result<()> {
516 let cli = Cli::parse();
517
518 match cli.command {
519 // Status & Diagnostics
520 Commands::Status { json } => handle_status(json).await?,
521 Commands::Peers { json } => handle_peers(json).await?,
522 Commands::Ping { peer } => handle_ping(peer).await?,
523
524 // Control Transfer
525 Commands::Switch { target } => handle_switch(target).await?,
526 Commands::Return => handle_return().await?,
527
528 // Input Management
529 Commands::Release => handle_release().await?,
530 Commands::Barrier { action } => {
531 let enabled = matches!(action, BarrierAction::On);
532 handle_barrier(enabled).await?
533 }
534
535 // Connection Management
536 Commands::Disconnect { peer } => handle_disconnect(peer).await?,
537 Commands::Reconnect { peer } => handle_reconnect(peer).await?,
538
539 // Configuration & Daemon
540 Commands::Config { action } => match action {
541 ConfigAction::Show => handle_config_show().await?,
542 },
543 Commands::Reload => handle_reload().await?,
544 Commands::Shutdown => handle_shutdown().await?,
545 Commands::Logs { lines } => handle_logs(lines).await?,
546 }
547
548 Ok(())
549 }
550