Rust · 5955 bytes Raw Blame History
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 }
227