@@ -1,3 +1,4 @@ |
| 1 | +use std::collections::HashMap; |
| 1 | 2 | use std::env; |
| 2 | 3 | use std::io::{self, BufRead, BufReader, Write}; |
| 3 | 4 | use std::os::unix::net::UnixStream; |
@@ -7,6 +8,14 @@ use garwarp_ipc::{ |
| 7 | 8 | ControlRequest, ControlResponse, DEFAULT_CONTROL_SOCKET, DEFAULT_RUNTIME_SUBDIR, |
| 8 | 9 | PROTOCOL_VERSION, RequestTransitionTarget, |
| 9 | 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; |
| 10 | 19 | |
| 11 | 20 | fn main() { |
| 12 | 21 | let args: Vec<String> = env::args().collect(); |
@@ -30,6 +39,7 @@ enum Command { |
| 30 | 39 | Status, |
| 31 | 40 | Stop, |
| 32 | 41 | List, |
| 42 | + PortalSmoke, |
| 33 | 43 | Inspect { |
| 34 | 44 | id: String, |
| 35 | 45 | }, |
@@ -55,6 +65,7 @@ fn parse_command(args: &[String]) -> Result<Command, String> { |
| 55 | 65 | [command] if command == "status" => Ok(Command::Status), |
| 56 | 66 | [command] if command == "stop" => Ok(Command::Stop), |
| 57 | 67 | [command] if command == "list" => Ok(Command::List), |
| 68 | + [command] if command == "portal-smoke" => Ok(Command::PortalSmoke), |
| 58 | 69 | [command, id] if command == "inspect" => Ok(Command::Inspect { id: id.clone() }), |
| 59 | 70 | [command] if command == "version" || command == "--version" || command == "-V" => { |
| 60 | 71 | Ok(Command::Version) |
@@ -224,6 +235,7 @@ fn run(command: Command) -> io::Result<()> { |
| 224 | 235 | )), |
| 225 | 236 | } |
| 226 | 237 | } |
| 238 | + Command::PortalSmoke => run_portal_smoke(), |
| 227 | 239 | Command::Inspect { id } => { |
| 228 | 240 | let response = send_request(ControlRequest::InspectRequest { id })?; |
| 229 | 241 | match response { |
@@ -318,6 +330,169 @@ fn run(command: Command) -> io::Result<()> { |
| 318 | 330 | } |
| 319 | 331 | } |
| 320 | 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 | + |
| 321 | 496 | fn send_request(request: ControlRequest) -> io::Result<ControlResponse> { |
| 322 | 497 | let socket_path = control_socket_path(); |
| 323 | 498 | let mut stream = UnixStream::connect(&socket_path)?; |
@@ -350,6 +525,7 @@ fn print_help() { |
| 350 | 525 | println!(" status (default)"); |
| 351 | 526 | println!(" stop"); |
| 352 | 527 | println!(" list"); |
| 528 | + println!(" portal-smoke"); |
| 353 | 529 | println!(" inspect <id>"); |
| 354 | 530 | println!(" begin <id> <sender> [app_id|-] [parent_window|-]"); |
| 355 | 531 | println!(" transition <id> <sender> <awaiting_user|fulfilled|cancelled|failed> [app_id|-]"); |
@@ -360,7 +536,9 @@ fn print_help() { |
| 360 | 536 | |
| 361 | 537 | #[cfg(test)] |
| 362 | 538 | 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 | + }; |
| 364 | 542 | use garwarp_ipc::RequestTransitionTarget; |
| 365 | 543 | |
| 366 | 544 | #[test] |
@@ -431,6 +609,13 @@ mod tests { |
| 431 | 609 | assert_eq!(command, Command::List); |
| 432 | 610 | } |
| 433 | 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 | + |
| 434 | 619 | #[test] |
| 435 | 620 | fn parse_transition_target_rejects_unknown_state() { |
| 436 | 621 | let parsed = parse_transition_target("bogus"); |
@@ -445,4 +630,10 @@ mod tests { |
| 445 | 630 | Some("org.test.App".to_string()) |
| 446 | 631 | ); |
| 447 | 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 | + } |
| 448 | 639 | } |