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