gardesk/garshot / a4a4364

Browse files

add garshotctl CLI control tool

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
a4a4364d97e66a29a573d0fe60fea02356514f9f
Parents
7075af2
Tree
aa484a4

2 changed files

StatusFile+-
A garshotctl/Cargo.toml 31 0
A garshotctl/src/main.rs 226 0
garshotctl/Cargo.tomladded
@@ -0,0 +1,31 @@
1
+[package]
2
+name = "garshotctl"
3
+version.workspace = true
4
+edition.workspace = true
5
+license.workspace = true
6
+description = "CLI control tool for garshot daemon"
7
+
8
+[[bin]]
9
+name = "garshotctl"
10
+path = "src/main.rs"
11
+
12
+[dependencies]
13
+# Async runtime
14
+tokio = { workspace = true }
15
+
16
+# Serialization
17
+serde = { workspace = true }
18
+serde_json = { workspace = true }
19
+
20
+# CLI
21
+clap = { workspace = true }
22
+
23
+# Error handling
24
+anyhow = { workspace = true }
25
+
26
+# Logging
27
+tracing = { workspace = true }
28
+tracing-subscriber = { workspace = true }
29
+
30
+# IPC
31
+garshot-ipc = { workspace = true }
garshotctl/src/main.rsadded
@@ -0,0 +1,226 @@
1
+//! garshotctl - CLI control tool for the garshot daemon.
2
+//!
3
+//! This tool sends commands to the garshot daemon via IPC (Unix domain sockets).
4
+//! For one-shot captures without the daemon, use `garshot screen` directly.
5
+
6
+use anyhow::Context;
7
+use clap::{Parser, Subcommand};
8
+use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
9
+use tokio::net::UnixStream;
10
+use tracing_subscriber::EnvFilter;
11
+
12
+use garshot_ipc::{OutputMode, Request, Response};
13
+
14
+#[derive(Parser)]
15
+#[command(name = "garshotctl", about = "Control tool for garshot daemon")]
16
+#[command(version, author)]
17
+struct Args {
18
+    #[command(subcommand)]
19
+    command: Command,
20
+}
21
+
22
+#[derive(Subcommand)]
23
+enum Command {
24
+    /// Capture full screen.
25
+    Screen {
26
+        /// Specific monitor name.
27
+        #[arg(short, long)]
28
+        monitor: Option<String>,
29
+
30
+        /// Include cursor in screenshot.
31
+        #[arg(short, long)]
32
+        cursor: bool,
33
+
34
+        /// Copy to clipboard instead of saving to file.
35
+        #[arg(long)]
36
+        clipboard: bool,
37
+    },
38
+
39
+    /// Interactive region selection.
40
+    Region {
41
+        /// Include cursor in screenshot.
42
+        #[arg(short, long)]
43
+        cursor: bool,
44
+
45
+        /// Copy to clipboard instead of saving to file.
46
+        #[arg(long)]
47
+        clipboard: bool,
48
+    },
49
+
50
+    /// Capture specific window.
51
+    Window {
52
+        /// Window ID (hex or decimal). Defaults to active window.
53
+        #[arg(short, long)]
54
+        id: Option<String>,
55
+
56
+        /// Include window decorations.
57
+        #[arg(short, long)]
58
+        decorations: bool,
59
+
60
+        /// Include cursor in screenshot.
61
+        #[arg(short, long)]
62
+        cursor: bool,
63
+
64
+        /// Copy to clipboard instead of saving to file.
65
+        #[arg(long)]
66
+        clipboard: bool,
67
+    },
68
+
69
+    /// Capture currently active window.
70
+    Active {
71
+        /// Include window decorations.
72
+        #[arg(short, long)]
73
+        decorations: bool,
74
+
75
+        /// Include cursor in screenshot.
76
+        #[arg(short, long)]
77
+        cursor: bool,
78
+
79
+        /// Copy to clipboard instead of saving to file.
80
+        #[arg(long)]
81
+        clipboard: bool,
82
+    },
83
+
84
+    /// Show daemon status.
85
+    Status,
86
+
87
+    /// List available monitors.
88
+    Monitors,
89
+
90
+    /// Shutdown the daemon.
91
+    Shutdown,
92
+}
93
+
94
+#[tokio::main]
95
+async fn main() -> anyhow::Result<()> {
96
+    // Initialize logging
97
+    tracing_subscriber::fmt()
98
+        .with_env_filter(
99
+            EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("warn")),
100
+        )
101
+        .init();
102
+
103
+    let args = Args::parse();
104
+
105
+    let request = match args.command {
106
+        Command::Screen {
107
+            monitor,
108
+            cursor,
109
+            clipboard,
110
+        } => Request::Screen {
111
+            monitor,
112
+            cursor,
113
+            output: if clipboard {
114
+                OutputMode::Clipboard
115
+            } else {
116
+                OutputMode::File
117
+            },
118
+        },
119
+
120
+        Command::Region { cursor, clipboard } => Request::Region {
121
+            cursor,
122
+            output: if clipboard {
123
+                OutputMode::Clipboard
124
+            } else {
125
+                OutputMode::File
126
+            },
127
+        },
128
+
129
+        Command::Window {
130
+            id,
131
+            decorations,
132
+            cursor,
133
+            clipboard,
134
+        } => {
135
+            let window_id = id.map(|s| parse_window_id(&s)).transpose()?;
136
+            Request::Window {
137
+                id: window_id,
138
+                decorations,
139
+                cursor,
140
+                output: if clipboard {
141
+                    OutputMode::Clipboard
142
+                } else {
143
+                    OutputMode::File
144
+                },
145
+            }
146
+        }
147
+
148
+        Command::Active {
149
+            decorations,
150
+            cursor,
151
+            clipboard,
152
+        } => Request::ActiveWindow {
153
+            decorations,
154
+            cursor,
155
+            output: if clipboard {
156
+                OutputMode::Clipboard
157
+            } else {
158
+                OutputMode::File
159
+            },
160
+        },
161
+
162
+        Command::Status => Request::Status,
163
+        Command::Monitors => Request::ListMonitors,
164
+        Command::Shutdown => Request::Shutdown,
165
+    };
166
+
167
+    let response = send_request(request).await?;
168
+
169
+    if response.success {
170
+        if let Some(path) = response.path {
171
+            println!("{}", path.display());
172
+        }
173
+        if let Some(info) = response.info {
174
+            println!("{}", serde_json::to_string_pretty(&info)?);
175
+        }
176
+    } else {
177
+        let error_msg = response.error.unwrap_or_else(|| "Unknown error".to_string());
178
+        eprintln!("Error: {}", error_msg);
179
+        std::process::exit(1);
180
+    }
181
+
182
+    Ok(())
183
+}
184
+
185
+/// Send a request to the garshot daemon and return the response.
186
+async fn send_request(request: Request) -> anyhow::Result<Response> {
187
+    let socket_path = garshot_ipc::socket_path();
188
+
189
+    if !socket_path.exists() {
190
+        anyhow::bail!(
191
+            "Daemon not running (socket not found: {})\n\
192
+             Start the daemon with: garshot daemon\n\
193
+             Or use one-shot mode: garshot screen",
194
+            socket_path.display()
195
+        );
196
+    }
197
+
198
+    let mut stream = UnixStream::connect(&socket_path)
199
+        .await
200
+        .context("Failed to connect to daemon")?;
201
+
202
+    // Send request as JSON line
203
+    let json = serde_json::to_string(&request)?;
204
+    stream.write_all(json.as_bytes()).await?;
205
+    stream.write_all(b"\n").await?;
206
+    stream.flush().await?;
207
+
208
+    // Read response
209
+    let mut reader = BufReader::new(stream);
210
+    let mut line = String::new();
211
+    reader.read_line(&mut line).await?;
212
+
213
+    let response: Response = serde_json::from_str(&line).context("Failed to parse response")?;
214
+
215
+    Ok(response)
216
+}
217
+
218
+/// Parse a window ID from string (supports hex 0x... or decimal).
219
+fn parse_window_id(s: &str) -> anyhow::Result<u32> {
220
+    let s = s.trim();
221
+    if s.starts_with("0x") || s.starts_with("0X") {
222
+        u32::from_str_radix(&s[2..], 16).context("Invalid hex window ID")
223
+    } else {
224
+        s.parse().context("Invalid decimal window ID")
225
+    }
226
+}