gardesk/garwarp / 5c14533

Browse files

add portal smoke command

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
5c14533447cc2ccce99d81d7c9a893a840f05935
Parents
1fd4b17
Tree
b96cd5b

3 changed files

StatusFile+-
M Cargo.lock 1 0
M garwarpctl/Cargo.toml 1 0
M garwarpctl/src/main.rs 192 1
Cargo.lockmodified
@@ -316,6 +316,7 @@ name = "garwarpctl"
316316
 version = "0.1.0"
317317
 dependencies = [
318318
  "garwarp-ipc",
319
+ "zbus",
319320
 ]
320321
 
321322
 [[package]]
garwarpctl/Cargo.tomlmodified
@@ -5,3 +5,4 @@ edition = "2024"
55
 
66
 [dependencies]
77
 garwarp-ipc = { path = "../garwarp-ipc" }
8
+zbus = "5.14.0"
garwarpctl/src/main.rsmodified
@@ -1,3 +1,4 @@
1
+use std::collections::HashMap;
12
 use std::env;
23
 use std::io::{self, BufRead, BufReader, Write};
34
 use std::os::unix::net::UnixStream;
@@ -7,6 +8,14 @@ use garwarp_ipc::{
78
     ControlRequest, ControlResponse, DEFAULT_CONTROL_SOCKET, DEFAULT_RUNTIME_SUBDIR,
89
     PROTOCOL_VERSION, RequestTransitionTarget,
910
 };
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;
1019
 
1120
 fn main() {
1221
     let args: Vec<String> = env::args().collect();
@@ -30,6 +39,7 @@ enum Command {
3039
     Status,
3140
     Stop,
3241
     List,
42
+    PortalSmoke,
3343
     Inspect {
3444
         id: String,
3545
     },
@@ -55,6 +65,7 @@ fn parse_command(args: &[String]) -> Result<Command, String> {
5565
         [command] if command == "status" => Ok(Command::Status),
5666
         [command] if command == "stop" => Ok(Command::Stop),
5767
         [command] if command == "list" => Ok(Command::List),
68
+        [command] if command == "portal-smoke" => Ok(Command::PortalSmoke),
5869
         [command, id] if command == "inspect" => Ok(Command::Inspect { id: id.clone() }),
5970
         [command] if command == "version" || command == "--version" || command == "-V" => {
6071
             Ok(Command::Version)
@@ -224,6 +235,7 @@ fn run(command: Command) -> io::Result<()> {
224235
                 )),
225236
             }
226237
         }
238
+        Command::PortalSmoke => run_portal_smoke(),
227239
         Command::Inspect { id } => {
228240
             let response = send_request(ControlRequest::InspectRequest { id })?;
229241
             match response {
@@ -318,6 +330,169 @@ fn run(command: Command) -> io::Result<()> {
318330
     }
319331
 }
320332
 
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
+
321496
 fn send_request(request: ControlRequest) -> io::Result<ControlResponse> {
322497
     let socket_path = control_socket_path();
323498
     let mut stream = UnixStream::connect(&socket_path)?;
@@ -350,6 +525,7 @@ fn print_help() {
350525
     println!("  status (default)");
351526
     println!("  stop");
352527
     println!("  list");
528
+    println!("  portal-smoke");
353529
     println!("  inspect <id>");
354530
     println!("  begin <id> <sender> [app_id|-] [parent_window|-]");
355531
     println!("  transition <id> <sender> <awaiting_user|fulfilled|cancelled|failed> [app_id|-]");
@@ -360,7 +536,9 @@ fn print_help() {
360536
 
361537
 #[cfg(test)]
362538
 mod tests {
363
-    use super::{Command, optional_value, parse_command, parse_transition_target};
539
+    use super::{
540
+        Command, optional_value, parse_command, parse_transition_target, sender_to_handle_segment,
541
+    };
364542
     use garwarp_ipc::RequestTransitionTarget;
365543
 
366544
     #[test]
@@ -431,6 +609,13 @@ mod tests {
431609
         assert_eq!(command, Command::List);
432610
     }
433611
 
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
+
434619
     #[test]
435620
     fn parse_transition_target_rejects_unknown_state() {
436621
         let parsed = parse_transition_target("bogus");
@@ -445,4 +630,10 @@ mod tests {
445630
             Some("org.test.App".to_string())
446631
         );
447632
     }
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
+    }
448639
 }