Rust · 22028 bytes Raw Blame History
1 use std::collections::HashMap;
2 use std::env;
3 use std::io::{self, BufRead, BufReader, Write};
4 use std::os::unix::net::UnixStream;
5 use std::path::PathBuf;
6
7 use garwarp_ipc::{
8 ControlRequest, ControlResponse, DEFAULT_CONTROL_SOCKET, DEFAULT_RUNTIME_SUBDIR,
9 PROTOCOL_VERSION, RequestTransitionTarget,
10 };
11 use zbus::{
12 blocking::Connection,
13 zvariant::{OwnedObjectPath, OwnedValue},
14 };
15
16 const BACKEND_DBUS_NAME: &str = "org.freedesktop.impl.portal.desktop.garwarp";
17 const BACKEND_OBJECT_PATH: &str = "/org/freedesktop/portal/desktop";
18 const PORTAL_RESPONSE_FAILED: u32 = 2;
19
20 fn main() {
21 let args: Vec<String> = env::args().collect();
22 let command = match parse_command(&args[1..]) {
23 Ok(command) => command,
24 Err(error) => {
25 eprintln!("garwarpctl error: {error}");
26 print_help();
27 std::process::exit(1);
28 }
29 };
30
31 if let Err(error) = run(command) {
32 eprintln!("garwarpctl error: {error}");
33 std::process::exit(1);
34 }
35 }
36
37 #[derive(Debug, Clone, PartialEq, Eq)]
38 enum Command {
39 Status,
40 Stop,
41 List,
42 PortalSmoke,
43 Inspect {
44 id: String,
45 },
46 Version,
47 Help,
48 Begin {
49 id: String,
50 sender: String,
51 app_id: Option<String>,
52 parent_window: Option<String>,
53 },
54 Transition {
55 id: String,
56 sender: String,
57 app_id: Option<String>,
58 target: RequestTransitionTarget,
59 },
60 }
61
62 fn parse_command(args: &[String]) -> Result<Command, String> {
63 match args {
64 [] => Ok(Command::Status),
65 [command] if command == "status" => Ok(Command::Status),
66 [command] if command == "stop" => Ok(Command::Stop),
67 [command] if command == "list" => Ok(Command::List),
68 [command] if command == "portal-smoke" => Ok(Command::PortalSmoke),
69 [command, id] if command == "inspect" => Ok(Command::Inspect { id: id.clone() }),
70 [command] if command == "version" || command == "--version" || command == "-V" => {
71 Ok(Command::Version)
72 }
73 [command] if command == "help" || command == "--help" || command == "-h" => {
74 Ok(Command::Help)
75 }
76 [command, id, sender] if command == "begin" => Ok(Command::Begin {
77 id: id.clone(),
78 sender: sender.clone(),
79 app_id: None,
80 parent_window: None,
81 }),
82 [command, id, sender, app_id] if command == "begin" => Ok(Command::Begin {
83 id: id.clone(),
84 sender: sender.clone(),
85 app_id: optional_value(app_id),
86 parent_window: None,
87 }),
88 [command, id, sender, app_id, parent_window] if command == "begin" => Ok(Command::Begin {
89 id: id.clone(),
90 sender: sender.clone(),
91 app_id: optional_value(app_id),
92 parent_window: optional_value(parent_window),
93 }),
94 [command, id, sender, state] if command == "transition" => Ok(Command::Transition {
95 id: id.clone(),
96 sender: sender.clone(),
97 app_id: None,
98 target: parse_transition_target(state)?,
99 }),
100 [command, id, sender, state, app_id] if command == "transition" => {
101 Ok(Command::Transition {
102 id: id.clone(),
103 sender: sender.clone(),
104 app_id: optional_value(app_id),
105 target: parse_transition_target(state)?,
106 })
107 }
108 [command, id, sender] if command == "await" => Ok(Command::Transition {
109 id: id.clone(),
110 sender: sender.clone(),
111 app_id: None,
112 target: RequestTransitionTarget::AwaitingUser,
113 }),
114 [command, id, sender, app_id] if command == "await" => Ok(Command::Transition {
115 id: id.clone(),
116 sender: sender.clone(),
117 app_id: optional_value(app_id),
118 target: RequestTransitionTarget::AwaitingUser,
119 }),
120 [command, id, sender] if command == "fulfill" => Ok(Command::Transition {
121 id: id.clone(),
122 sender: sender.clone(),
123 app_id: None,
124 target: RequestTransitionTarget::Fulfilled,
125 }),
126 [command, id, sender, app_id] if command == "fulfill" => Ok(Command::Transition {
127 id: id.clone(),
128 sender: sender.clone(),
129 app_id: optional_value(app_id),
130 target: RequestTransitionTarget::Fulfilled,
131 }),
132 [command, id, sender] if command == "cancel" => Ok(Command::Transition {
133 id: id.clone(),
134 sender: sender.clone(),
135 app_id: None,
136 target: RequestTransitionTarget::Cancelled,
137 }),
138 [command, id, sender, app_id] if command == "cancel" => Ok(Command::Transition {
139 id: id.clone(),
140 sender: sender.clone(),
141 app_id: optional_value(app_id),
142 target: RequestTransitionTarget::Cancelled,
143 }),
144 [command, id, sender] if command == "fail" => Ok(Command::Transition {
145 id: id.clone(),
146 sender: sender.clone(),
147 app_id: None,
148 target: RequestTransitionTarget::Failed,
149 }),
150 [command, id, sender, app_id] if command == "fail" => Ok(Command::Transition {
151 id: id.clone(),
152 sender: sender.clone(),
153 app_id: optional_value(app_id),
154 target: RequestTransitionTarget::Failed,
155 }),
156 _ => Err("unknown command or invalid arguments".to_string()),
157 }
158 }
159
160 fn parse_transition_target(value: &str) -> Result<RequestTransitionTarget, String> {
161 match value {
162 "awaiting_user" => Ok(RequestTransitionTarget::AwaitingUser),
163 "fulfilled" => Ok(RequestTransitionTarget::Fulfilled),
164 "cancelled" => Ok(RequestTransitionTarget::Cancelled),
165 "failed" => Ok(RequestTransitionTarget::Failed),
166 _ => Err(format!("unsupported transition state: {value}")),
167 }
168 }
169
170 fn optional_value(value: &str) -> Option<String> {
171 if value == "-" || value.is_empty() {
172 None
173 } else {
174 Some(value.to_string())
175 }
176 }
177
178 fn run(command: Command) -> io::Result<()> {
179 match command {
180 Command::Status => {
181 let response = send_request(ControlRequest::Status)?;
182 match response {
183 ControlResponse::Status(status) => {
184 println!("protocol={}", status.protocol_version);
185 println!("health={}", status.health.as_str());
186 println!("in_flight={}", status.in_flight_requests);
187 println!("total={}", status.total_requests);
188 println!("terminal={}", status.terminal_requests);
189 Ok(())
190 }
191 ControlResponse::Error { code, reason } => Err(io::Error::other(format!(
192 "daemon error: code={code} reason={reason}"
193 ))),
194 other => Err(io::Error::new(
195 io::ErrorKind::InvalidData,
196 format!("unexpected response: {other:?}"),
197 )),
198 }
199 }
200 Command::Stop => {
201 let response = send_request(ControlRequest::Stop)?;
202 match response {
203 ControlResponse::AckStopping => {
204 println!("stopping");
205 Ok(())
206 }
207 ControlResponse::Error { code, reason } => Err(io::Error::other(format!(
208 "daemon error: code={code} reason={reason}"
209 ))),
210 other => Err(io::Error::new(
211 io::ErrorKind::InvalidData,
212 format!("unexpected response: {other:?}"),
213 )),
214 }
215 }
216 Command::List => {
217 let response = send_request(ControlRequest::ListRequests)?;
218 match response {
219 ControlResponse::RequestList { ids } => {
220 if ids.is_empty() {
221 println!("ids=-");
222 } else {
223 for id in ids {
224 println!("id={id}");
225 }
226 }
227 Ok(())
228 }
229 ControlResponse::Error { code, reason } => Err(io::Error::other(format!(
230 "daemon error: code={code} reason={reason}"
231 ))),
232 other => Err(io::Error::new(
233 io::ErrorKind::InvalidData,
234 format!("unexpected response: {other:?}"),
235 )),
236 }
237 }
238 Command::PortalSmoke => run_portal_smoke(),
239 Command::Inspect { id } => {
240 let response = send_request(ControlRequest::InspectRequest { id })?;
241 match response {
242 ControlResponse::RequestSnapshot {
243 id,
244 state,
245 sender,
246 app_id,
247 parent_window,
248 } => {
249 println!("id={id}");
250 println!("state={state}");
251 println!("sender={sender}");
252 println!("app_id={}", app_id.unwrap_or_else(|| "-".to_string()));
253 println!(
254 "parent_window={}",
255 parent_window.unwrap_or_else(|| "-".to_string())
256 );
257 Ok(())
258 }
259 ControlResponse::Error { code, reason } => Err(io::Error::other(format!(
260 "daemon error: code={code} reason={reason}"
261 ))),
262 other => Err(io::Error::new(
263 io::ErrorKind::InvalidData,
264 format!("unexpected response: {other:?}"),
265 )),
266 }
267 }
268 Command::Begin {
269 id,
270 sender,
271 app_id,
272 parent_window,
273 } => {
274 let response = send_request(ControlRequest::BeginRequest {
275 id,
276 sender,
277 app_id,
278 parent_window,
279 })?;
280 match response {
281 ControlResponse::AckRequest { id, state } => {
282 println!("id={id}");
283 println!("state={state}");
284 Ok(())
285 }
286 ControlResponse::Error { code, reason } => Err(io::Error::other(format!(
287 "daemon error: code={code} reason={reason}"
288 ))),
289 other => Err(io::Error::new(
290 io::ErrorKind::InvalidData,
291 format!("unexpected response: {other:?}"),
292 )),
293 }
294 }
295 Command::Transition {
296 id,
297 sender,
298 app_id,
299 target,
300 } => {
301 let response = send_request(ControlRequest::TransitionRequest {
302 id,
303 sender,
304 app_id,
305 target,
306 })?;
307 match response {
308 ControlResponse::AckRequest { id, state } => {
309 println!("id={id}");
310 println!("state={state}");
311 Ok(())
312 }
313 ControlResponse::Error { code, reason } => Err(io::Error::other(format!(
314 "daemon error: code={code} reason={reason}"
315 ))),
316 other => Err(io::Error::new(
317 io::ErrorKind::InvalidData,
318 format!("unexpected response: {other:?}"),
319 )),
320 }
321 }
322 Command::Version => {
323 println!("garwarpctl protocol v{PROTOCOL_VERSION}");
324 Ok(())
325 }
326 Command::Help => {
327 print_help();
328 Ok(())
329 }
330 }
331 }
332
333 fn run_portal_smoke() -> io::Result<()> {
334 let connection = Connection::session()
335 .map_err(|error| io::Error::other(format!("failed to connect to session bus: {error}")))?;
336 let sender = connection
337 .unique_name()
338 .ok_or_else(|| io::Error::other("connection missing unique bus name"))?
339 .as_str()
340 .to_string();
341 let sender_segment = sender_to_handle_segment(&sender)?;
342
343 let screenshot_handle = request_handle(&sender_segment, "smoke_screenshot")?;
344 let screenshot_response = call_portal_screenshot(&connection, screenshot_handle)?;
345 println!("screenshot_response={screenshot_response}");
346
347 let open_file_handle = request_handle(&sender_segment, "smoke_open_file")?;
348 let open_file_response = call_portal_open_file(&connection, open_file_handle)?;
349 println!("open_file_response={open_file_response}");
350
351 let choose_handle = request_handle(&sender_segment, "smoke_choose_app")?;
352 let choose_response = call_portal_choose_application(&connection, choose_handle.clone())?;
353 println!("choose_application_response={choose_response}");
354
355 call_portal_update_choices_expect_invalid_transition(&connection, choose_handle)?;
356 println!("update_choices=invalid_transition");
357 println!("portal_smoke=ok");
358 Ok(())
359 }
360
361 fn sender_to_handle_segment(sender: &str) -> io::Result<String> {
362 let sender = sender.strip_prefix(':').ok_or_else(|| {
363 io::Error::new(
364 io::ErrorKind::InvalidInput,
365 format!("invalid sender unique name: {sender}"),
366 )
367 })?;
368 if sender.is_empty() {
369 return Err(io::Error::new(
370 io::ErrorKind::InvalidInput,
371 "invalid sender unique name: empty".to_string(),
372 ));
373 }
374
375 let mut segment = String::with_capacity(sender.len());
376 for ch in sender.chars() {
377 if ch.is_ascii_alphanumeric() {
378 segment.push(ch);
379 continue;
380 }
381 if matches!(ch, '.' | '_' | '-') {
382 segment.push('_');
383 continue;
384 }
385 return Err(io::Error::new(
386 io::ErrorKind::InvalidInput,
387 format!("unsupported sender character: {ch}"),
388 ));
389 }
390
391 Ok(segment)
392 }
393
394 fn request_handle(sender_segment: &str, token: &str) -> io::Result<OwnedObjectPath> {
395 let path = format!("{BACKEND_OBJECT_PATH}/request/{sender_segment}/{token}");
396 OwnedObjectPath::try_from(path.clone()).map_err(|error| {
397 io::Error::new(
398 io::ErrorKind::InvalidInput,
399 format!("invalid request-handle path {path}: {error}"),
400 )
401 })
402 }
403
404 fn empty_options() -> HashMap<String, OwnedValue> {
405 HashMap::new()
406 }
407
408 fn call_portal_screenshot(connection: &Connection, handle: OwnedObjectPath) -> io::Result<u32> {
409 let message = connection
410 .call_method(
411 Some(BACKEND_DBUS_NAME),
412 BACKEND_OBJECT_PATH,
413 Some("org.freedesktop.impl.portal.Screenshot"),
414 "Screenshot",
415 &(handle, "org.test.App", "", empty_options()),
416 )
417 .map_err(|error| io::Error::other(format!("screenshot call failed: {error}")))?;
418 expect_failed_response(message)
419 }
420
421 fn call_portal_open_file(connection: &Connection, handle: OwnedObjectPath) -> io::Result<u32> {
422 let message = connection
423 .call_method(
424 Some(BACKEND_DBUS_NAME),
425 BACKEND_OBJECT_PATH,
426 Some("org.freedesktop.impl.portal.FileChooser"),
427 "OpenFile",
428 &(handle, "org.test.App", "", "Open", empty_options()),
429 )
430 .map_err(|error| io::Error::other(format!("open file call failed: {error}")))?;
431 expect_failed_response(message)
432 }
433
434 fn call_portal_choose_application(
435 connection: &Connection,
436 handle: OwnedObjectPath,
437 ) -> io::Result<u32> {
438 let choices = vec!["org.test.Viewer".to_string()];
439 let message = connection
440 .call_method(
441 Some(BACKEND_DBUS_NAME),
442 BACKEND_OBJECT_PATH,
443 Some("org.freedesktop.impl.portal.AppChooser"),
444 "ChooseApplication",
445 &(handle, "org.test.App", "", choices, empty_options()),
446 )
447 .map_err(|error| io::Error::other(format!("choose application call failed: {error}")))?;
448 expect_failed_response(message)
449 }
450
451 fn call_portal_update_choices_expect_invalid_transition(
452 connection: &Connection,
453 handle: OwnedObjectPath,
454 ) -> io::Result<()> {
455 let choices = vec!["org.test.Viewer".to_string()];
456 match connection.call_method(
457 Some(BACKEND_DBUS_NAME),
458 BACKEND_OBJECT_PATH,
459 Some("org.freedesktop.impl.portal.AppChooser"),
460 "UpdateChoices",
461 &(handle, choices),
462 ) {
463 Ok(_) => Err(io::Error::other(
464 "expected UpdateChoices to fail with invalid_transition".to_string(),
465 )),
466 Err(error) => {
467 let text = error.to_string();
468 if !text.contains("invalid_transition") {
469 return Err(io::Error::other(format!(
470 "unexpected UpdateChoices error: {text}"
471 )));
472 }
473 Ok(())
474 }
475 }
476 }
477
478 fn expect_failed_response(message: zbus::message::Message) -> io::Result<u32> {
479 let (response, results): (u32, HashMap<String, OwnedValue>) = message
480 .body()
481 .deserialize()
482 .map_err(|error| io::Error::other(format!("failed to decode method reply: {error}")))?;
483 if response != PORTAL_RESPONSE_FAILED {
484 return Err(io::Error::other(format!(
485 "unexpected portal response code: {response}"
486 )));
487 }
488 if !results.is_empty() {
489 return Err(io::Error::other(
490 "expected empty results map for placeholder implementation".to_string(),
491 ));
492 }
493 Ok(response)
494 }
495
496 fn send_request(request: ControlRequest) -> io::Result<ControlResponse> {
497 let socket_path = control_socket_path();
498 let mut stream = UnixStream::connect(&socket_path)?;
499 let line = request.as_line();
500 stream.write_all(line.as_bytes())?;
501 stream.write_all(b"\n")?;
502 stream.flush()?;
503
504 let mut reader = BufReader::new(stream);
505 let mut line = String::new();
506 reader.read_line(&mut line)?;
507 ControlResponse::parse_line(&line)
508 .map_err(|error| io::Error::new(io::ErrorKind::InvalidData, error))
509 }
510
511 fn control_socket_path() -> PathBuf {
512 runtime_dir().join(DEFAULT_CONTROL_SOCKET)
513 }
514
515 fn runtime_dir() -> PathBuf {
516 let base = env::var_os("XDG_RUNTIME_DIR")
517 .map(PathBuf::from)
518 .unwrap_or_else(env::temp_dir);
519 base.join(DEFAULT_RUNTIME_SUBDIR)
520 }
521
522 fn print_help() {
523 println!("garwarpctl <command>");
524 println!("commands:");
525 println!(" status (default)");
526 println!(" stop");
527 println!(" list");
528 println!(" portal-smoke");
529 println!(" inspect <id>");
530 println!(" begin <id> <sender> [app_id|-] [parent_window|-]");
531 println!(" transition <id> <sender> <awaiting_user|fulfilled|cancelled|failed> [app_id|-]");
532 println!(" await|fulfill|cancel|fail <id> <sender> [app_id|-]");
533 println!(" version");
534 println!(" help");
535 }
536
537 #[cfg(test)]
538 mod tests {
539 use super::{
540 Command, optional_value, parse_command, parse_transition_target, sender_to_handle_segment,
541 };
542 use garwarp_ipc::RequestTransitionTarget;
543
544 #[test]
545 fn status_is_default_command() {
546 assert_eq!(
547 parse_command(&[]).expect("status should be default"),
548 Command::Status
549 );
550 }
551
552 #[test]
553 fn parse_begin_command_with_parent_window() {
554 let args = vec![
555 "begin".to_string(),
556 "req-1".to_string(),
557 ":1.2".to_string(),
558 "org.test.App".to_string(),
559 "x11:0x2a".to_string(),
560 ];
561 let command = parse_command(&args).expect("begin command should parse");
562 assert_eq!(
563 command,
564 Command::Begin {
565 id: "req-1".to_string(),
566 sender: ":1.2".to_string(),
567 app_id: Some("org.test.App".to_string()),
568 parent_window: Some("x11:0x2a".to_string()),
569 }
570 );
571 }
572
573 #[test]
574 fn parse_transition_command() {
575 let args = vec![
576 "transition".to_string(),
577 "req-1".to_string(),
578 ":1.2".to_string(),
579 "cancelled".to_string(),
580 ];
581 let command = parse_command(&args).expect("transition command should parse");
582 assert_eq!(
583 command,
584 Command::Transition {
585 id: "req-1".to_string(),
586 sender: ":1.2".to_string(),
587 app_id: None,
588 target: RequestTransitionTarget::Cancelled,
589 }
590 );
591 }
592
593 #[test]
594 fn parse_inspect_command() {
595 let args = vec!["inspect".to_string(), "req-1".to_string()];
596 let command = parse_command(&args).expect("inspect command should parse");
597 assert_eq!(
598 command,
599 Command::Inspect {
600 id: "req-1".to_string()
601 }
602 );
603 }
604
605 #[test]
606 fn parse_list_command() {
607 let args = vec!["list".to_string()];
608 let command = parse_command(&args).expect("list command should parse");
609 assert_eq!(command, Command::List);
610 }
611
612 #[test]
613 fn parse_portal_smoke_command() {
614 let args = vec!["portal-smoke".to_string()];
615 let command = parse_command(&args).expect("portal-smoke command should parse");
616 assert_eq!(command, Command::PortalSmoke);
617 }
618
619 #[test]
620 fn parse_transition_target_rejects_unknown_state() {
621 let parsed = parse_transition_target("bogus");
622 assert!(parsed.is_err());
623 }
624
625 #[test]
626 fn optional_value_uses_dash_as_none() {
627 assert_eq!(optional_value("-"), None);
628 assert_eq!(
629 optional_value("org.test.App"),
630 Some("org.test.App".to_string())
631 );
632 }
633
634 #[test]
635 fn sender_segment_is_derived_from_unique_name() {
636 let segment = sender_to_handle_segment(":1.42").expect("segment should parse");
637 assert_eq!(segment, "1_42");
638 }
639 }
640