diff --git a/README.md b/README.md index 9e04398..3ff658d 100644 --- a/README.md +++ b/README.md @@ -76,6 +76,8 @@ To keep dependency bloat and compile time down, everything is contained in featu | `tunneld` | Interface with [pymobiledevice3](https://github.com/doronz88/pymobiledevice3)'s tunneld. | | `usbmuxd` | Connect using the usbmuxd daemon.| | `xpc` | Access protected services via XPC over RSD. | +| `xctest` | Launch XCTest runners and coordinate modern testmanagerd/XCTest sessions. | +| `wda` | Minimal WebDriverAgent bootstrap helpers and localhost bridge support. | | `notification_proxy` | Post and observe iOS notifications. | ### Planned/TODO @@ -146,6 +148,28 @@ async fn main() { More examples are in the [`tools`](tools/) crate and in the crate documentation. +### XCTest / WDA + +`idevice` also includes support for launching XCTest runners through the +library's modern DVT/RSD path. This can be used to start +WebDriverAgent-style runners on recent iOS versions. + +The bundled CLI exposes this through the `idevice-tools xctest` command: + +```bash +idevice-tools --udid xctest io.github.kor1k1.WebDriverAgentRunner.xctrunner +``` + +To wait for WDA and expose localhost bridge URLs for HTTP and MJPEG: + +```bash +idevice-tools --udid xctest --bridge io.github.kor1k1.WebDriverAgentRunner.xctrunner +``` + +The current `wda` support is intentionally a bootstrap layer for readiness +checks and session startup, rather than a complete long-lived WebDriver +client. + ## FFI For use in other languages, a small FFI crate has been created to start exposing diff --git a/ffi/src/dvt/remote_server.rs b/ffi/src/dvt/remote_server.rs index ddab14a..e138d11 100644 --- a/ffi/src/dvt/remote_server.rs +++ b/ffi/src/dvt/remote_server.rs @@ -91,9 +91,7 @@ pub unsafe extern "C" fn remote_server_connect_rsd( match res { Ok(d) => { - let boxed = Box::new(RemoteServerHandle(RemoteServerClient::new(Box::new( - d.into_inner(), - )))); + let boxed = Box::new(RemoteServerHandle(d)); unsafe { *handle = Box::into_raw(boxed) }; null_mut() } diff --git a/idevice/Cargo.toml b/idevice/Cargo.toml index eef7ab7..ec8287c 100644 --- a/idevice/Cargo.toml +++ b/idevice/Cargo.toml @@ -108,11 +108,13 @@ installation_proxy = [ "tokio/fs", ] installcoordination_proxy = [] +xctest = ["dvt", "installation_proxy", "afc", "dep:uuid", "dep:ns-keyed-archive", "tunnel_tcp_stack", "rsd", "core_device_proxy"] springboardservices = [] misagent = [] mobile_image_mounter = ["dep:sha2"] mobileactivationd = ["dep:reqwest"] mobilebackup2 = [] +wda = ["dep:serde_json", "tokio/time", "tokio/net"] notification_proxy = [ "tokio/macros", "tokio/time", @@ -170,11 +172,13 @@ full = [ "house_arrest", "installation_proxy", "installcoordination_proxy", + "xctest", "location_simulation", "misagent", "mobile_image_mounter", "mobileactivationd", "mobilebackup2", + "wda", "notification_proxy", "pair", "pcapd", diff --git a/idevice/src/lib.rs b/idevice/src/lib.rs index a63cb89..ec214f4 100644 --- a/idevice/src/lib.rs +++ b/idevice/src/lib.rs @@ -851,6 +851,22 @@ pub enum IdeviceError { #[cfg(feature = "installation_proxy")] #[error("Application verification failed: {0}")] ApplicationVerificationFailed(String), + + #[cfg(feature = "xctest")] + #[error("application is not installed on the device")] + AppNotInstalled, + + #[cfg(feature = "xctest")] + #[error("test runner did not connect within the timeout")] + TestRunnerTimeout, + + #[cfg(feature = "xctest")] + #[error("test runner disconnected before the test plan completed")] + TestRunnerDisconnected, + + #[cfg(feature = "xctest")] + #[error("xctest session timed out after {0:.1}s")] + XcTestTimeout(f64), } impl IdeviceError { @@ -995,6 +1011,14 @@ impl IdeviceError { IdeviceError::NotificationProxyDeath => 202, #[cfg(feature = "installation_proxy")] IdeviceError::ApplicationVerificationFailed(_) => 203, + #[cfg(feature = "xctest")] + IdeviceError::AppNotInstalled => 204, + #[cfg(feature = "xctest")] + IdeviceError::TestRunnerTimeout => 205, + #[cfg(feature = "xctest")] + IdeviceError::TestRunnerDisconnected => 206, + #[cfg(feature = "xctest")] + IdeviceError::XcTestTimeout(_) => 207, } } diff --git a/idevice/src/provider.rs b/idevice/src/provider.rs index 91d96c6..09c1273 100644 --- a/idevice/src/provider.rs +++ b/idevice/src/provider.rs @@ -99,7 +99,7 @@ impl IdeviceProvider for TcpProvider { /// USB-based device connection provider using usbmuxd #[cfg(feature = "usbmuxd")] -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct UsbmuxdProvider { /// USB connection address pub addr: UsbmuxdAddr, diff --git a/idevice/src/services/dvt/message.rs b/idevice/src/services/dvt/message.rs index d6b33e9..f2e07b9 100644 --- a/idevice/src/services/dvt/message.rs +++ b/idevice/src/services/dvt/message.rs @@ -59,6 +59,7 @@ //! # } use plist::Value; +use std::io::{Cursor, Read}; use tokio::io::{AsyncRead, AsyncReadExt}; use super::errors::DvtError; @@ -84,7 +85,7 @@ pub struct MessageHeader { /// Conversation tracking index conversation_index: u32, /// Channel number this message belongs to - pub channel: u32, + pub channel: i32, /// Whether a reply is expected expects_reply: bool, } @@ -94,12 +95,20 @@ pub struct MessageHeader { /// 16-byte structure following the message header #[derive(Debug, Default, Clone, Copy, PartialEq)] pub struct PayloadHeader { - /// Flags controlling message processing - flags: u32, + /// DTX message type (DISPATCH/OBJECT/OK/ERROR/DATA) + msg_type: u8, + /// Reserved bytes in the wire format + flags_a: u8, + /// Reserved bytes in the wire format + flags_b: u8, + /// Reserved byte in the wire format + reserved: u8, /// Length of auxiliary data section aux_length: u32, /// Total length of payload (aux + data) - total_length: u64, + total_length: u32, + /// Additional payload flags + flags: u32, } /// Header for auxiliary data section @@ -120,7 +129,7 @@ pub struct AuxHeader { /// Auxiliary data container /// /// Contains a header and a collection of typed values -#[derive(Debug, PartialEq)] +#[derive(Debug, Clone, PartialEq)] pub struct Aux { /// Auxiliary data header pub header: AuxHeader, @@ -129,7 +138,7 @@ pub struct Aux { } /// Typed auxiliary value that can be included in messages -#[derive(PartialEq)] +#[derive(Clone, PartialEq)] pub enum AuxValue { /// NULL value (type 0x0a) - no payload bytes Null, @@ -148,7 +157,7 @@ pub enum AuxValue { } /// Complete protocol message -#[derive(PartialEq)] +#[derive(Clone, PartialEq)] pub struct Message { /// Message metadata header pub message_header: MessageHeader, @@ -163,111 +172,155 @@ pub struct Message { } impl Aux { - /// Parses auxiliary data from bytes + /// Parses the legacy aux wire format used on iOS 16 and earlier /// - /// # Arguments - /// * `bytes` - Raw byte slice containing auxiliary data + /// Layout: `[AuxHeader (16 B)][type (4 B)][data...][type (4 B)][data...]...` /// - /// # Returns - /// * `Ok(Aux)` - Parsed auxiliary data - /// * `Err(IdeviceError)` - If parsing fails - /// - /// # Errors - /// * `IdeviceError::NotEnoughBytes` if input is too short - /// * `IdeviceError::UnknownAuxValueType` for unsupported types - /// * `IdeviceError` for other parsing failures - pub fn from_bytes(bytes: Vec) -> Result { + /// Type 0xF0 entries are `PrimitiveDictionary` blocks embedded inside + /// the legacy envelope; their bodies are skipped since the useful values + /// are the surrounding flat entries. + fn parse_legacy_bytes(bytes: Vec) -> Result { if bytes.len() < 16 { - return Err(IdeviceError::NotEnoughBytes(bytes.len(), 24)); + return Err(IdeviceError::NotEnoughBytes(bytes.len(), 16)); } + let mut cursor = Cursor::new(bytes.as_slice()); let header = AuxHeader { - buffer_size: u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]), - unknown: u32::from_le_bytes([bytes[4], bytes[5], bytes[6], bytes[7]]), - aux_size: u32::from_le_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]), - unknown2: u32::from_le_bytes([bytes[12], bytes[13], bytes[14], bytes[15]]), + buffer_size: Self::read_u32(&mut cursor)?, + unknown: Self::read_u32(&mut cursor)?, + aux_size: Self::read_u32(&mut cursor)?, + unknown2: Self::read_u32(&mut cursor)?, }; - let mut bytes = &bytes[16..]; let mut values = Vec::new(); - loop { - if bytes.len() < 8 { - break; - } - let aux_type = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]); - bytes = &bytes[4..]; + while cursor.position() + 4 <= bytes.len() as u64 { + let aux_type = Self::read_u32(&mut cursor)?; match aux_type { 0x0a => { - // null / PNULL - no payload bytes - // used as dictionary keys and separators - } - 0x01 => { - let len = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize; - bytes = &bytes[4..]; - if bytes.len() < len { - return Err(IdeviceError::NotEnoughBytes(bytes.len(), len)); - } - values.push(AuxValue::String(String::from_utf8(bytes[..len].to_vec())?)); - bytes = &bytes[len..]; + // PNULL separator — used as dictionary keys; not a user value. } - 0x02 => { - let len = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]) as usize; - bytes = &bytes[4..]; - if bytes.len() < len { - return Err(IdeviceError::NotEnoughBytes(bytes.len(), len)); + 0x0f0 => { + // PrimitiveDictionary block embedded in a legacy envelope. + // Layout after the type: u32 flags, u64 body_length, [body]. + // Skip the entire block; positional args appear as flat entries. + let _flags = Self::read_u32(&mut cursor)?; + let body_len = Self::read_u64(&mut cursor)?; + let pos = cursor.position() as usize; + let end = pos + body_len as usize; + if end > bytes.len() { + return Err(IdeviceError::NotEnoughBytes(bytes.len(), end)); } - values.push(AuxValue::Array(bytes[..len].to_vec())); - bytes = &bytes[len..]; + cursor.set_position(end as u64); } - 0x03 => { - values.push(AuxValue::U32(u32::from_le_bytes([ - bytes[0], bytes[1], bytes[2], bytes[3], - ]))); - bytes = &bytes[4..]; + _ => { + // All other types share the same encoding as parse_primitive, + // but the type word is already consumed above so we reconstruct + // a cursor over [type || remaining] to reuse parse_primitive. + let pos = cursor.position() as usize - 4; + let mut sub = Cursor::new(&bytes[pos..]); + values.push(Self::parse_primitive(&mut sub)?); + cursor.set_position(pos as u64 + sub.position()); } - 0x06 => { - if bytes.len() < 8 { - return Err(IdeviceError::NotEnoughBytes(8, bytes.len())); - } - values.push(AuxValue::I64(i64::from_le_bytes([ - bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], - bytes[7], - ]))); - bytes = &bytes[8..]; - } - 0x09 => { - // Double (f64) - if bytes.len() < 8 { - return Err(IdeviceError::NotEnoughBytes(8, bytes.len())); - } - let bits = u64::from_le_bytes([ - bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], - bytes[7], - ]); - values.push(AuxValue::Double(f64::from_bits(bits))); - bytes = &bytes[8..]; - } - 0xf0 => { - // PrimitiveDictionary - // Layout: u32 magic, u32 unknown, u64 body_length, then [key,value] pairs - if bytes.len() < 16 { - return Err(IdeviceError::NotEnoughBytes(16, bytes.len())); - } - // Skip magic (4), unknown (4), body_length (8) - let body_length = u64::from_le_bytes([ - bytes[4], bytes[5], bytes[6], bytes[7], bytes[8], bytes[9], bytes[10], - bytes[11], - ]) as usize; - // Skip the dictionary body content - bytes = &bytes[16 + body_length..]; - } - _ => return Err(DvtError::UnknownAuxValueType(aux_type).into()), } } Ok(Self { header, values }) } + fn read_u32(cursor: &mut Cursor<&[u8]>) -> Result { + let mut buf = [0u8; 4]; + Read::read_exact(cursor, &mut buf)?; + Ok(u32::from_le_bytes(buf)) + } + + fn read_u64(cursor: &mut Cursor<&[u8]>) -> Result { + let mut buf = [0u8; 8]; + Read::read_exact(cursor, &mut buf)?; + Ok(u64::from_le_bytes(buf)) + } + + fn read_f64(cursor: &mut Cursor<&[u8]>) -> Result { + let mut buf = [0u8; 8]; + Read::read_exact(cursor, &mut buf)?; + Ok(f64::from_le_bytes(buf)) + } + + fn read_exact_vec(cursor: &mut Cursor<&[u8]>, len: usize) -> Result, IdeviceError> { + let mut buf = vec![0u8; len]; + Read::read_exact(cursor, &mut buf)?; + Ok(buf) + } + + fn parse_primitive(cursor: &mut Cursor<&[u8]>) -> Result { + let raw_type = Self::read_u32(cursor)?; + let type_code = raw_type & 0xFF; + match type_code { + 0x01 => { + let len = Self::read_u32(cursor)? as usize; + Ok(AuxValue::String(String::from_utf8(Self::read_exact_vec( + cursor, len, + )?)?)) + } + 0x02 => { + let len = Self::read_u32(cursor)? as usize; + Ok(AuxValue::Array(Self::read_exact_vec(cursor, len)?)) + } + 0x03 => Ok(AuxValue::U32(Self::read_u32(cursor)?)), + 0x06 => Ok(AuxValue::I64(Self::read_u64(cursor)? as i64)), + 0x09 => Ok(AuxValue::Double(Self::read_f64(cursor)?)), + 0x0A => Ok(AuxValue::Null), + _ => Err(DvtError::UnknownAuxValueType(raw_type).into()), + } + } + + /// Parses auxiliary data from bytes, selecting the correct wire format + /// based on the leading magic byte. + /// + /// # Wire formats + /// + /// **Legacy**: the first byte is NOT `0xF0`. + /// The buffer begins with a 16-byte `AuxHeader` followed by flat + /// type-tagged value entries. + /// + /// **Modern** (iOS 17+, RSD/testmanagerd path): the first byte IS `0xF0`, + /// indicating the entire buffer is a single `PrimitiveDictionary` block + /// (`[flags(4B)][unknown(4B)][body_len(8B)][key-value pairs...]`). + /// Keys are positional-null sentinels; only the values are collected. + pub fn from_bytes(bytes: Vec) -> Result { + if bytes.is_empty() { + return Ok(Self::from_values(Vec::new())); + } + + if (bytes[0] as u32) != 0xF0 { + return Self::parse_legacy_bytes(bytes); + } + + if bytes.len() < 16 { + return Err(IdeviceError::NotEnoughBytes(bytes.len(), 16)); + } + + let mut cursor = Cursor::new(bytes.as_slice()); + let _type_and_flags = Self::read_u32(&mut cursor)?; + let _unknown_flags = Self::read_u32(&mut cursor)?; + let body_len = Self::read_u64(&mut cursor)?; + let body_end = 16u64 + body_len; + if body_end > bytes.len() as u64 { + return Err(IdeviceError::NotEnoughBytes(bytes.len(), body_end as usize)); + } + + let mut values = Vec::new(); + while cursor.position() < body_end { + let _key = Self::parse_primitive(&mut cursor)?; + let value = Self::parse_primitive(&mut cursor)?; + values.push(value); + } + + Ok(Self { + header: AuxHeader::default(), + values, + }) + } + /// Creates new auxiliary data from values /// /// Note: Header fields are populated during serialization @@ -444,7 +497,7 @@ impl MessageHeader { fragment_count: u16, identifier: u32, conversation_index: u32, - channel: u32, + channel: i32, expects_reply: bool, ) -> Self { Self { @@ -460,6 +513,21 @@ impl MessageHeader { } } + /// Returns the unique message identifier. + pub(crate) fn identifier(&self) -> u32 { + self.identifier + } + + /// Returns the conversation index for this message. + pub(crate) fn conversation_index(&self) -> u32 { + self.conversation_index + } + + /// Returns whether this message expects a reply. + pub(crate) fn expects_reply(&self) -> bool { + self.expects_reply + } + /// Serializes header to bytes pub fn serialize(&self) -> Vec { let mut res = Vec::new(); @@ -485,10 +553,10 @@ impl PayloadHeader { /// Serializes header to bytes pub fn serialize(&self) -> Vec { - let mut res = Vec::new(); - res.extend_from_slice(&self.flags.to_le_bytes()); + let mut res = vec![self.msg_type, self.flags_a, self.flags_b, self.reserved]; res.extend_from_slice(&self.aux_length.to_le_bytes()); res.extend_from_slice(&self.total_length.to_le_bytes()); + res.extend_from_slice(&self.flags.to_le_bytes()); res } @@ -496,15 +564,10 @@ impl PayloadHeader { /// Creates header for method invocation messages pub fn method_invocation() -> Self { Self { - flags: 2, + msg_type: 2, ..Default::default() } } - - /// Updates flags to indicate reply expectation - pub fn apply_expects_reply_map(&mut self) { - self.flags |= 0x1000 - } } impl Message { @@ -533,9 +596,16 @@ impl Message { length: u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]), identifier: u32::from_le_bytes([buf[16], buf[17], buf[18], buf[19]]), conversation_index: u32::from_le_bytes([buf[20], buf[21], buf[22], buf[23]]), - //treat both as the negative and positive representation of the channel code in the response - // the same when performing fragmentation - channel: i32::abs(i32::from_le_bytes([buf[24], buf[25], buf[26], buf[27]])) as u32, + channel: { + let wire_channel = i32::from_le_bytes([buf[24], buf[25], buf[26], buf[27]]); + let conversation_index = + u32::from_le_bytes([buf[20], buf[21], buf[22], buf[23]]); + if conversation_index.is_multiple_of(2) { + -wire_channel + } else { + wire_channel + } + }, expects_reply: u32::from_le_bytes([buf[28], buf[29], buf[30], buf[31]]) == 1, }; if header.fragment_count > 1 && header.fragment_id == 0 { @@ -552,11 +622,13 @@ impl Message { // read the payload header let buf = &packet_data[0..16]; let pheader = PayloadHeader { - flags: u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]), + msg_type: buf[0], + flags_a: buf[1], + flags_b: buf[2], + reserved: buf[3], aux_length: u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]), - total_length: u64::from_le_bytes([ - buf[8], buf[9], buf[10], buf[11], buf[12], buf[13], buf[14], buf[15], - ]), + total_length: u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]), + flags: u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]), }; let aux = if pheader.aux_length > 0 { let buf = packet_data[16..(16 + pheader.aux_length as usize)].to_vec(); @@ -565,7 +637,7 @@ impl Message { None }; // read the data - let need_len = (pheader.total_length - pheader.aux_length as u64) as usize; + let need_len = (pheader.total_length - pheader.aux_length) as usize; let buf = packet_data [(pheader.aux_length + 16) as usize..pheader.aux_length as usize + 16 + need_len] .to_vec(); @@ -631,7 +703,7 @@ impl Message { // Update the payload header let mut payload_header = self.payload_header.to_owned(); payload_header.aux_length = aux.len() as u32; - payload_header.total_length = (aux.len() + data.len()) as u64; + payload_header.total_length = (aux.len() + data.len()) as u32; let payload_header = payload_header.serialize(); // Update the message header @@ -646,6 +718,65 @@ impl Message { res } + + /// Builds a raw reply frame for an incoming message, sending `data_bytes` + /// verbatim as the payload without additional NSKeyedArchive encoding. + /// + /// This is used for replies where the payload is already a serialised + /// NSKeyedArchive (e.g. `XCTestConfiguration`). Pass an empty slice to + /// send an acknowledgement with no payload. + pub(crate) fn build_raw_reply( + channel: i32, + incoming_msg_id: u32, + incoming_conversation_index: u32, + data_bytes: &[u8], + ) -> Vec { + // Payload header (16 bytes): flags=0, aux_len=0, total_len + let msg_type: u8 = if data_bytes.is_empty() { 0 } else { 3 }; + let flags_a: u8 = 0; + let flags_b: u8 = 0; + let reserved: u8 = 0; + let aux_len: u32 = 0; + let total_len: u32 = data_bytes.len() as u32; + + let payload_total = 16usize + data_bytes.len(); // payload_hdr + data + + // Message header (32 bytes) + let magic: u32 = 0x1F3D5B79; + let header_len: u32 = 32; + let fragment_id: u16 = 0; + let fragment_count: u16 = 1; + let length: u32 = payload_total as u32; + let conversation_index = incoming_conversation_index + 1; + let expects_reply: u32 = 0; + let wire_channel = if conversation_index.is_multiple_of(2) { + channel + } else { + -channel + }; + + let mut buf = Vec::with_capacity(32 + 16 + data_bytes.len()); + buf.extend_from_slice(&magic.to_le_bytes()); + buf.extend_from_slice(&header_len.to_le_bytes()); + buf.extend_from_slice(&fragment_id.to_le_bytes()); + buf.extend_from_slice(&fragment_count.to_le_bytes()); + buf.extend_from_slice(&length.to_le_bytes()); + buf.extend_from_slice(&incoming_msg_id.to_le_bytes()); + buf.extend_from_slice(&conversation_index.to_le_bytes()); + buf.extend_from_slice(&wire_channel.to_le_bytes()); + buf.extend_from_slice(&expects_reply.to_le_bytes()); + // Payload header + buf.push(msg_type); + buf.push(flags_a); + buf.push(flags_b); + buf.push(reserved); + buf.extend_from_slice(&aux_len.to_le_bytes()); + buf.extend_from_slice(&total_len.to_le_bytes()); + buf.extend_from_slice(&0_u32.to_le_bytes()); + // Data + buf.extend_from_slice(data_bytes); + buf + } } impl std::fmt::Debug for AuxValue { diff --git a/idevice/src/services/dvt/mod.rs b/idevice/src/services/dvt/mod.rs index e96d96e..95b79b6 100644 --- a/idevice/src/services/dvt/mod.rs +++ b/idevice/src/services/dvt/mod.rs @@ -34,6 +34,8 @@ pub mod remote_server; pub mod screenshot; #[cfg(feature = "sysmontap")] pub mod sysmontap; +#[cfg(feature = "xctest")] +pub mod xctest; impl RsdService for remote_server::RemoteServerClient> { fn rsd_service_name() -> std::borrow::Cow<'static, str> { diff --git a/idevice/src/services/dvt/process_control.rs b/idevice/src/services/dvt/process_control.rs index 2f67506..b910e17 100644 --- a/idevice/src/services/dvt/process_control.rs +++ b/idevice/src/services/dvt/process_control.rs @@ -35,7 +35,7 @@ //! ``` use plist::{Dictionary, Value}; -use tracing::{debug, warn}; +use tracing::warn; use super::errors::DvtError; use crate::{IdeviceError, ReadWrite, dvt::message::AuxValue, obf}; @@ -52,6 +52,71 @@ pub struct ProcessControlClient<'a, R: ReadWrite> { channel: Channel<'a, R>, } +fn parse_u64_value(value: &Value) -> Option { + match value { + Value::Integer(v) => v.as_unsigned(), + Value::String(s) => s.parse().ok(), + _ => None, + } +} + +fn extract_ns_error_message(value: &Value) -> Option { + let dict = match value { + Value::Dictionary(dict) => dict, + _ => return None, + }; + + let user_info = match dict.get("NSUserInfo") { + Some(Value::Array(items)) => items, + _ => { + return dict + .get("NSLocalizedDescription") + .and_then(Value::as_string) + .map(ToOwned::to_owned); + } + }; + + let mut description = dict + .get("NSLocalizedDescription") + .and_then(Value::as_string) + .map(ToOwned::to_owned); + let mut reason = dict + .get("NSLocalizedFailureReason") + .and_then(Value::as_string) + .map(ToOwned::to_owned); + + for entry in user_info { + let Value::Dictionary(item) = entry else { + continue; + }; + + let key = item.get("key").and_then(Value::as_string); + let value = item.get("value"); + + match (key, value) { + (Some("NSLocalizedDescription"), Some(Value::String(s))) if description.is_none() => { + description = Some(s.clone()); + } + (Some("NSLocalizedFailureReason"), Some(Value::String(s))) if reason.is_none() => { + reason = Some(s.clone()); + } + (Some("NSUnderlyingError"), Some(v)) => { + if let Some(message) = extract_ns_error_message(v) { + return Some(message); + } + } + _ => {} + } + } + + match (description, reason) { + (Some(description), Some(reason)) => Some(format!("{description}: {reason}")), + (Some(description), None) => Some(description), + (None, Some(reason)) => Some(reason), + (None, None) => None, + } +} + impl<'a, R: ReadWrite> ProcessControlClient<'a, R> { /// Creates a new ProcessControlClient /// @@ -86,7 +151,7 @@ impl<'a, R: ReadWrite> ProcessControlClient<'a, R> { /// * `Err(IdeviceError)` - If launch fails /// /// # Errors - /// * `IdeviceError::UnexpectedResponse` if server response is invalid + /// * `IdeviceError::UnexpectedResponse("unexpected response".into())` if server response is invalid /// * Other communication or serialization errors pub async fn launch_app( &mut self, @@ -114,24 +179,25 @@ impl<'a, R: ReadWrite> ProcessControlClient<'a, R> { None => Dictionary::new(), }; - self.channel - .call_method( + let res = self + .channel + .call_method_with_reply( Some(method), Some(vec![ AuxValue::archived_value(""), AuxValue::archived_value(bundle_id.into()), AuxValue::archived_value(env_vars), - AuxValue::archived_value(arguments), + AuxValue::archived_value(Value::Array( + arguments + .into_iter() + .map(|(_, value)| value) + .collect::>(), + )), AuxValue::archived_value(options), ]), - true, ) .await?; - let res = self.channel.read_message().await?; - debug!("Launch response message: {res:#?}"); - debug!("Launch response data: {:?}", res.data); - match res.data { Some(Value::Integer(p)) => match p.as_unsigned() { Some(p) => Ok(p), @@ -151,6 +217,61 @@ impl<'a, R: ReadWrite> ProcessControlClient<'a, R> { } } + /// Launches a process with fully customisable options. + /// + /// This is a lower-level variant of [`launch_app`](Self::launch_app) that + /// accepts a pre-built `args` array (`Vec`) and a custom + /// `options` dictionary (e.g. `{"StartSuspendedKey": false, "ActivateSuspended": true}`). + /// + /// # Arguments + /// * `bundle_id` - Bundle identifier of the app to launch + /// * `env` - Environment variables + /// * `args` - Launch arguments (each element already a `plist::Value::String`) + /// * `options` - Launch options dictionary + /// + /// # Returns + /// * `Ok(u64)` - PID of the launched process + pub(crate) async fn launch_with_options( + &mut self, + bundle_id: impl Into, + env: Dictionary, + args: Vec, + options: Dictionary, + ) -> Result { + let res = self + .channel + .call_method_with_reply( + Some(Value::String( + "launchSuspendedProcessWithDevicePath:bundleIdentifier:environment:arguments:options:" + .into(), + )), + Some(vec![ + AuxValue::archived_value(Value::String(String::new())), // device_path = "" + AuxValue::archived_value(bundle_id.into()), + AuxValue::archived_value(Value::Dictionary(env)), + AuxValue::archived_value(Value::Array(args)), + AuxValue::archived_value(Value::Dictionary(options)), + ]), + ) + .await?; + match res.data { + Some(v) => parse_u64_value(&v).ok_or_else(|| { + if let Some(message) = extract_ns_error_message(&v) { + warn!("Launch failed: {message}"); + return IdeviceError::InternalError(message); + } + warn!("PID wasn't parseable: {v:?}"); + IdeviceError::UnexpectedResponse("unexpected response".into()) + }), + _ => { + warn!("Did not get integer response from launchSuspendedProcess"); + Err(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )) + } + } + } + /// Kills a running process /// /// # Arguments @@ -185,18 +306,16 @@ impl<'a, R: ReadWrite> ProcessControlClient<'a, R> { /// /// # Errors /// * `IdeviceError::DisableMemoryLimitFailed` if device reports failure - /// * `IdeviceError::UnexpectedResponse` for invalid responses + /// * `IdeviceError::UnexpectedResponse("unexpected response".into())` for invalid responses /// * Other communication errors pub async fn disable_memory_limit(&mut self, pid: u64) -> Result<(), IdeviceError> { - self.channel - .call_method( + let res = self + .channel + .call_method_with_reply( "requestDisableMemoryLimitsForPid:".into(), Some(vec![AuxValue::U32(pid as u32)]), - true, ) .await?; - - let res = self.channel.read_message().await?; match res.data { Some(Value::Boolean(b)) => { if b { diff --git a/idevice/src/services/dvt/remote_server.rs b/idevice/src/services/dvt/remote_server.rs index 7556cbc..bd7e5d8 100644 --- a/idevice/src/services/dvt/remote_server.rs +++ b/idevice/src/services/dvt/remote_server.rs @@ -49,12 +49,45 @@ //! } //! ``` -use std::collections::{HashMap, VecDeque}; +use std::{ + collections::{HashMap, VecDeque}, + future::Future, + pin::Pin, + sync::{ + Arc, + atomic::{AtomicBool, AtomicU32, Ordering}, + }, +}; + +#[cfg(not(feature = "xctest"))] +use std::io; -use tokio::io::AsyncWriteExt; +use plist::Dictionary; +use tokio::{ + io::{AsyncWriteExt, ReadHalf, WriteHalf}, + sync::{Mutex, Notify, oneshot}, + task::JoinHandle, +}; use tracing::{debug, warn}; use super::errors::DvtError; + +#[cfg(feature = "xctest")] +fn remote_timeout_error(timeout: std::time::Duration) -> IdeviceError { + IdeviceError::XcTestTimeout(timeout.as_secs_f64()) +} + +#[cfg(not(feature = "xctest"))] +fn remote_timeout_error(timeout: std::time::Duration) -> IdeviceError { + IdeviceError::Socket(io::Error::new( + io::ErrorKind::TimedOut, + format!( + "remote server operation timed out after {:.1}s", + timeout.as_secs_f64() + ), + )) +} + use crate::{ IdeviceError, ReadWrite, dvt::message::{Aux, AuxValue, Message, MessageHeader, PayloadHeader}, @@ -67,16 +100,10 @@ pub const INSTRUMENTS_MESSAGE_TYPE: u32 = 2; /// /// Manages multiple communication channels and handles message serialization/deserialization. /// Each channel operates independently and maintains its own message queue. -#[derive(Debug)] pub struct RemoteServerClient { - /// The underlying device connection - idevice: R, - /// Counter for message identifiers - pub(crate) current_message: u32, - /// Next available channel number - pub(crate) new_channel: u32, - /// Map of channel numbers to their message queues - channels: HashMap>, + label: Arc, + shared: Arc>>, + reader_task: JoinHandle<()>, } /// Handle to a specific communication channel @@ -87,31 +114,168 @@ pub struct Channel<'a, R: ReadWrite> { /// Reference to parent client client: &'a mut RemoteServerClient, /// Channel number this handle operates on - channel: u32, + channel: i32, } -impl RemoteServerClient { - /// Creates a new RemoteServerClient with the given transport - /// - /// # Arguments - /// * `idevice` - The underlying transport implementing ReadWrite - /// - /// # Returns - /// A new client instance with root channel (0) initialized - pub fn new(idevice: R) -> Self { +/// Owned handle to a specific communication channel. +/// +/// This mirrors pymobiledevice3's `DTXChannel` lifetime model more closely +/// than the borrowed [`Channel`]: it keeps only the shared transport state and +/// the channel code, so service/proxy wrappers can outlive a temporary +/// `&mut RemoteServerClient` borrow. +#[derive(Debug)] +pub struct OwnedChannel { + label: Arc, + shared: Arc>>, + channel: i32, +} + +impl Clone for OwnedChannel { + fn clone(&self) -> Self { + Self { + label: self.label.clone(), + shared: self.shared.clone(), + channel: self.channel, + } + } +} + +type IncomingMessageHandler = Arc< + dyn Fn( + Message, + ) + -> Pin> + Send>> + + Send + + Sync, +>; + +type IncomingChannelInitializer = Arc< + dyn Fn( + Arc, + Arc>, + i32, + String, + ) -> Pin> + Send>> + + Send + + Sync, +>; + +pub(crate) enum IncomingHandlerOutcome { + Unhandled, + HandledNoReply, + Reply(Vec), +} + +#[derive(Debug, Default)] +struct ChannelQueue { + messages: Mutex>, + notify: Notify, +} + +#[derive(Debug, Clone)] +struct ChannelMetadata { + code: i32, + identifier: String, + remote: bool, +} + +struct IncomingChannelRegistration { + identifiers: Vec, + initializer: IncomingChannelInitializer, +} + +#[derive(Debug, Clone)] +enum CapabilityHandshakeState { + Pending, + Skipped, + Received(Dictionary), +} + +struct RemoteServerShared { + label: Arc, + writer: Mutex, + current_message: AtomicU32, + new_channel: AtomicU32, + channels: Mutex>>, + channel_metadata: Mutex>, + pending_replies: Mutex>>, + handlers: Mutex>, + incoming_channel_registrations: Mutex>>, + registry_notify: Notify, + supported_identifiers: Mutex, + handshake_notify: Notify, + closed: AtomicBool, + closed_notify: Notify, +} + +impl std::fmt::Debug for RemoteServerShared { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RemoteServerShared") + .field( + "current_message", + &self.current_message.load(Ordering::Relaxed), + ) + .field("new_channel", &self.new_channel.load(Ordering::Relaxed)) + .field("closed", &self.closed.load(Ordering::Relaxed)) + .finish_non_exhaustive() + } +} + +impl RemoteServerShared { + fn new(label: Arc, writer: W) -> Self { let mut channels = HashMap::new(); - channels.insert(0, VecDeque::new()); + channels.insert(0, Arc::new(ChannelQueue::default())); + let mut channel_metadata = HashMap::new(); + channel_metadata.insert( + 0, + ChannelMetadata { + code: 0, + identifier: "ctrl".into(), + remote: false, + }, + ); Self { - idevice, - current_message: 0, - new_channel: 1, - channels, + label, + writer: Mutex::new(writer), + current_message: AtomicU32::new(0), + new_channel: AtomicU32::new(1), + channels: Mutex::new(channels), + channel_metadata: Mutex::new(channel_metadata), + pending_replies: Mutex::new(HashMap::new()), + handlers: Mutex::new(HashMap::new()), + incoming_channel_registrations: Mutex::new(Vec::new()), + registry_notify: Notify::new(), + supported_identifiers: Mutex::new(CapabilityHandshakeState::Pending), + handshake_notify: Notify::new(), + closed: AtomicBool::new(false), + closed_notify: Notify::new(), } } +} + +impl std::fmt::Debug for RemoteServerClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RemoteServerClient") + .field("shared", &"") + .finish() + } +} - /// Consumes the client and returns the underlying transport - pub fn into_inner(self) -> R { - self.idevice +impl RemoteServerClient { + /// Creates a new client with a debug label used in tracing output. + fn with_label_typed(idevice: R, label: impl Into) -> Self + where + R: 'static, + { + let (reader, writer) = tokio::io::split(idevice); + let label: Arc = label.into().into(); + let shared = Arc::new(RemoteServerShared::new(label.clone(), writer)); + let reader_task = Self::spawn_reader(label.clone(), shared.clone(), reader); + Self { + label, + shared, + reader_task, + } } /// Returns a handle to the root channel (channel 0) @@ -122,6 +286,120 @@ impl RemoteServerClient { } } + /// Returns a future that resolves when this DTX connection disconnects. + /// + /// This captures the shared state by clone so callers can await it + /// alongside operations that hold a mutable borrow of the client. + pub(crate) fn disconnect_waiter(&self) -> impl Future + Send + 'static + where + R: 'static, + { + let shared = self.shared.clone(); + async move { + if shared.closed.load(Ordering::Relaxed) { + return; + } + shared.closed_notify.notified().await; + } + } + + /// Returns the peer capabilities received during `_notifyOfPublishedCapabilities:`. + pub(crate) async fn supported_identifiers(&self) -> Option { + match &*self.shared.supported_identifiers.lock().await { + CapabilityHandshakeState::Received(dict) => Some(dict.clone()), + CapabilityHandshakeState::Pending | CapabilityHandshakeState::Skipped => None, + } + } + + /// Waits for `_notifyOfPublishedCapabilities:` from the remote side. + pub(crate) async fn wait_for_capabilities( + &self, + timeout: std::time::Duration, + ) -> Result { + tokio::time::timeout(timeout, async { + loop { + match &*self.shared.supported_identifiers.lock().await { + CapabilityHandshakeState::Received(dict) => return Ok(dict.clone()), + CapabilityHandshakeState::Skipped => { + return Err(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )); + } + CapabilityHandshakeState::Pending => {} + } + + if self.shared.closed.load(Ordering::Relaxed) { + return Err(Self::closed_error()); + } + + tokio::select! { + _ = self.shared.handshake_notify.notified() => {} + _ = self.shared.closed_notify.notified() => return Err(Self::closed_error()), + } + } + }) + .await + .map_err(|_| remote_timeout_error(timeout))? + } + + /// Performs the DTX capability handshake, mirroring pymobiledevice3's + /// `DTXConnection._perform_handshake()`. + pub(crate) async fn perform_handshake( + &mut self, + capabilities: Option, + timeout: std::time::Duration, + ) -> Result, IdeviceError> { + let already_received = self.supported_identifiers().await; + + { + let mut state = self.shared.supported_identifiers.lock().await; + *state = match (capabilities.is_some(), already_received.as_ref()) { + (false, _) => CapabilityHandshakeState::Skipped, + (true, Some(dict)) => CapabilityHandshakeState::Received(dict.clone()), + (true, None) => CapabilityHandshakeState::Pending, + }; + } + + if let Some(capabilities) = capabilities { + self.root_channel() + .call_method( + Some("_notifyOfPublishedCapabilities:"), + Some(vec![AuxValue::archived_value(plist::Value::Dictionary( + capabilities, + ))]), + false, + ) + .await?; + } else { + return Ok(None); + } + + if let Some(capabilities) = already_received { + return Ok(Some(capabilities)); + } + + tokio::time::timeout(timeout, async { + loop { + match &*self.shared.supported_identifiers.lock().await { + CapabilityHandshakeState::Received(dict) => return Ok(Some(dict.clone())), + CapabilityHandshakeState::Skipped => return Ok(None), + CapabilityHandshakeState::Pending => {} + } + + if self.shared.closed.load(Ordering::Relaxed) { + return Err(Self::closed_error()); + } + + tokio::select! { + _ = self.shared.handshake_notify.notified() => {} + _ = self.shared.closed_notify.notified() => return Err(Self::closed_error()), + } + } + }) + .await + .map_err(|_| remote_timeout_error(timeout))? + } + /// Creates a new channel with the given identifier /// /// # Arguments @@ -132,53 +410,354 @@ impl RemoteServerClient { /// * `Err(IdeviceError)` - If channel creation fails /// /// # Errors - /// * `IdeviceError::UnexpectedResponse` if server responds with unexpected data + /// * `IdeviceError::UnexpectedResponse("unexpected response".into()) if server responds with unexpected data /// * Other IO or serialization errors + #[allow(unreachable_code)] pub async fn make_channel<'c>( &'c mut self, identifier: impl Into, ) -> Result, IdeviceError> { - let code = self.new_channel; - self.new_channel += 1; + let code = self.shared.new_channel.fetch_add(1, Ordering::Relaxed) as i32; + let identifier = identifier.into(); + self.register_channel_metadata(code, identifier.clone(), false) + .await; + self.ensure_channel_registered(code).await; let args = vec![ - AuxValue::U32(code), + AuxValue::U32( + code.try_into() + .expect("locally opened channels are positive"), + ), AuxValue::Array( - ns_keyed_archive::encode::encode_to_bytes(plist::Value::String(identifier.into())) + ns_keyed_archive::encode::encode_to_bytes(plist::Value::String(identifier)) .expect("Failed to encode"), ), ]; - // Insert channel BEFORE sending request, so responses on this channel - // are cached and not discarded when read_message loops - self.channels.insert(code, VecDeque::new()); - - let mut root = self.root_channel(); - // Don't expect a reply - channel creation is fire-and-forget - // Responses come on the new channel, not the root channel - root.call_method( - Some("_requestChannelWithCode:identifier:"), - Some(args), - false, - ) - .await?; + let reply = self + .call_method_with_reply(0, Some("_requestChannelWithCode:identifier:"), Some(args)) + .await?; + + if reply.data.is_some() { + warn!("make_channel: unexpected reply payload: {:?}", reply.data); + return Err(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )); + } self.build_channel(code) } - /// Builds a channel with the given code + /// Opens a named service channel. /// - /// Public for use by energy_monitor and other services - pub(crate) fn build_channel<'c>( + /// This is a service-level alias for `make_channel()` that mirrors the + /// terminology used by pymobiledevice3's `DTXConnection.open_channel()`. + pub(crate) async fn open_service_channel<'c>( &'c mut self, - code: u32, + identifier: &str, ) -> Result, IdeviceError> { + self.make_channel(identifier).await + } + + /// Opens a `dtxproxy:` channel assembled from local/remote service names. + /// + /// Mirrors pymobiledevice3's proxy-channel naming model, where the caller + /// reasons about the two sub-services and the transport constructs the + /// wire identifier. + pub(crate) async fn make_proxy_channel<'c>( + &'c mut self, + local_service: &str, + remote_service: &str, + ) -> Result, IdeviceError> { + self.make_channel(format!("dtxproxy:{local_service}:{remote_service}")) + .await + } + + /// Opens a proxied service channel assembled from local/remote service names. + /// + /// This is a service-level alias for `make_proxy_channel()` that matches + /// the "proxy service" terminology used in pymobiledevice3. + pub(crate) async fn open_proxied_service_channel<'c>( + &'c mut self, + local_service: &str, + remote_service: &str, + ) -> Result, IdeviceError> { + self.make_proxy_channel(local_service, remote_service).await + } + + fn build_channel<'c>(&'c mut self, code: i32) -> Result, IdeviceError> { Ok(Channel { client: self, channel: code, }) } + /// Returns an owned handle for an existing registered channel. + pub(crate) fn accept_owned_channel(&self, code: i32) -> OwnedChannel { + OwnedChannel { + label: self.label.clone(), + shared: self.shared.clone(), + channel: code, + } + } + + /// Registers an initializer that runs as soon as the remote opens a + /// matching incoming channel via `_requestChannelWithCode:identifier:`. + /// + /// This mirrors pymobiledevice3's service instantiation timing more + /// closely: the handler is installed before we acknowledge the channel + /// request, so the channel can start handling inbound invokes + /// immediately after the peer receives the OK reply. + pub(crate) async fn register_incoming_channel_initializer( + &mut self, + identifiers: &[&str], + initializer: F, + ) where + F: Fn(OwnedChannel, String) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + { + let identifiers = identifiers + .iter() + .map(|identifier| (*identifier).to_owned()) + .collect(); + let initializer: IncomingChannelInitializer> = + Arc::new(move |label, shared, channel, identifier| { + let owned = OwnedChannel { + label, + shared, + channel, + }; + Box::pin(initializer(owned, identifier)) + }); + self.shared + .incoming_channel_registrations + .lock() + .await + .push(IncomingChannelRegistration { + identifiers, + initializer, + }); + } + + async fn register_channel_metadata(&self, code: i32, identifier: String, remote: bool) { + self.shared.channel_metadata.lock().await.insert( + code, + ChannelMetadata { + code, + identifier, + remote, + }, + ); + self.shared.registry_notify.notify_waiters(); + } + + pub(crate) async fn wait_for_registered_channel_code( + &self, + identifiers: &[&str], + remote: Option, + timeout: Option, + ) -> Result { + let wait_future = async { + loop { + if let Some(code) = self.find_registered_channel_code(identifiers, remote).await { + return Ok(code); + } + + if self.shared.closed.load(Ordering::Relaxed) { + return Err(Self::closed_error()); + } + + tokio::select! { + _ = self.shared.registry_notify.notified() => {} + _ = self.shared.closed_notify.notified() => return Err(Self::closed_error()), + } + } + }; + + match timeout { + Some(timeout) => tokio::time::timeout(timeout, wait_future) + .await + .map_err(|_| remote_timeout_error(timeout))?, + None => wait_future.await, + } + } + + /// Waits for the code of a service channel matching one of the given identifiers. + pub(crate) async fn wait_for_service_channel_code( + &self, + identifiers: &[&str], + remote: Option, + timeout: Option, + ) -> Result { + self.wait_for_registered_channel_code(identifiers, remote, timeout) + .await + } + + pub(crate) async fn wait_for_proxied_channel_code( + &self, + identifiers: &[&str], + remote_service: bool, + remote_channel: Option, + timeout: Option, + ) -> Result { + let wait_future = async { + loop { + if let Some(code) = self + .find_registered_proxied_channel_code( + identifiers, + remote_service, + remote_channel, + ) + .await + { + return Ok(code); + } + + if self.shared.closed.load(Ordering::Relaxed) { + return Err(Self::closed_error()); + } + + tokio::select! { + _ = self.shared.registry_notify.notified() => {} + _ = self.shared.closed_notify.notified() => return Err(Self::closed_error()), + } + } + }; + + match timeout { + Some(timeout) => tokio::time::timeout(timeout, wait_future) + .await + .map_err(|_| remote_timeout_error(timeout))?, + None => wait_future.await, + } + } + + /// Waits for the code of a proxied service channel whose local or remote + /// sub-service matches one of `identifiers`. + pub(crate) async fn wait_for_proxied_service_channel_code( + &self, + identifiers: &[&str], + remote_service: bool, + remote_channel: Option, + timeout: Option, + ) -> Result { + self.wait_for_proxied_channel_code(identifiers, remote_service, remote_channel, timeout) + .await + } + + async fn find_registered_channel_code( + &self, + identifiers: &[&str], + remote: Option, + ) -> Option { + let metadata = self.shared.channel_metadata.lock().await; + metadata.values().find_map(|entry| { + let matches_identifier = identifiers.contains(&entry.identifier.as_str()); + let matches_remote = remote.is_none_or(|remote_flag| remote_flag == entry.remote); + (matches_identifier && matches_remote).then_some(entry.code) + }) + } + + async fn find_registered_proxied_channel_code( + &self, + identifiers: &[&str], + remote_service: bool, + remote_channel: Option, + ) -> Option { + let metadata = self.shared.channel_metadata.lock().await; + metadata.values().find_map(|entry| { + let matches_remote_channel = + remote_channel.is_none_or(|remote_flag| remote_flag == entry.remote); + if !matches_remote_channel { + return None; + } + + let (local_service, remote_service_name) = + Self::parse_dtxproxy_identifier(&entry.identifier, entry.remote)?; + let candidate = if remote_service { + remote_service_name + } else { + local_service + }; + + identifiers.contains(&candidate).then_some(entry.code) + }) + } + + fn parse_dtxproxy_identifier(identifier: &str, remote_channel: bool) -> Option<(&str, &str)> { + let mut parts = identifier.split(':'); + let prefix = parts.next()?; + let first = parts.next()?; + let second = parts.next()?; + if prefix != "dtxproxy" || parts.next().is_some() { + return None; + } + + if remote_channel { + Some((second, first)) + } else { + Some((first, second)) + } + } + + async fn send_method( + &self, + channel: i32, + identifier: u32, + data: Option>, + args: Option>, + expect_reply: bool, + correlate_reply: bool, + ) -> Result>, IdeviceError> { + let mheader = MessageHeader::new(0, 1, identifier, 0, channel, expect_reply); + let pheader = PayloadHeader::method_invocation(); + let aux = args.map(Aux::from_values); + let data: Option = data.map(Into::into); + + let message = Message::new(mheader, pheader, aux, data); + debug!("[{}] Sending message: {message:#?}", self.label); + + let receiver = if correlate_reply { + let (sender, receiver) = oneshot::channel(); + self.shared + .pending_replies + .lock() + .await + .insert(identifier, sender); + Some(receiver) + } else { + None + }; + + let write_result = self.shared.write_all(&message.serialize()).await; + if write_result.is_err() { + self.shared.pending_replies.lock().await.remove(&identifier); + } + write_result?; + + Ok(receiver) + } + + async fn wait_for_reply( + &self, + identifier: u32, + receiver: oneshot::Receiver, + ) -> Result { + match receiver.await { + Ok(message) => Ok(message), + Err(_) => { + self.shared.pending_replies.lock().await.remove(&identifier); + if self.shared.closed.load(Ordering::Relaxed) { + Err(Self::closed_error()) + } else { + Err(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )) + } + } + } + } + /// Calls a method on the specified channel /// /// # Arguments @@ -195,27 +774,34 @@ impl RemoteServerClient { /// IO or serialization errors pub async fn call_method( &mut self, - channel: u32, + channel: i32, data: Option>, args: Option>, expect_reply: bool, ) -> Result<(), IdeviceError> { - self.current_message += 1; - - let mheader = MessageHeader::new(0, 1, self.current_message, 0, channel, expect_reply); - let pheader = PayloadHeader::method_invocation(); - let aux = args.map(Aux::from_values); - let data: Option = data.map(Into::into); - - let message = Message::new(mheader, pheader, aux, data); - debug!("Sending message: {message:#?}"); - - self.idevice.write_all(&message.serialize()).await?; - self.idevice.flush().await?; - + let identifier = self.shared.current_message.fetch_add(1, Ordering::Relaxed) + 1; + self.send_method(channel, identifier, data, args, expect_reply, false) + .await?; Ok(()) } + /// Calls a method and waits for the reply correlated by message identifier. + pub(crate) async fn call_method_with_reply( + &mut self, + channel: i32, + data: Option>, + args: Option>, + ) -> Result { + let identifier = self.shared.current_message.fetch_add(1, Ordering::Relaxed) + 1; + let receiver = self + .send_method(channel, identifier, data, args, true, true) + .await? + .ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + ))?; + self.wait_for_reply(identifier, receiver).await + } + /// Reads the next message from the specified channel /// /// Checks cached messages first, then reads from transport if needed. @@ -230,36 +816,475 @@ impl RemoteServerClient { /// # Errors /// * `IdeviceError::UnknownChannel` if channel doesn't exist /// * Other IO or deserialization errors - pub async fn read_message(&mut self, channel: u32) -> Result { - // Determine if we already have a message cached - let cache = match self.channels.get_mut(&channel) { - Some(c) => c, - None => return Err(DvtError::UnknownChannel(channel).into()), + pub async fn read_message(&mut self, channel: i32) -> Result { + loop { + let queue = self + .get_channel_queue(channel) + .await + .ok_or_else(|| DvtError::UnknownChannel(channel.unsigned_abs()))?; + + { + let mut messages = queue.messages.lock().await; + if let Some(msg) = messages.pop_front() { + return Ok(msg); + } + } + + if self.shared.closed.load(Ordering::Relaxed) { + return Err(Self::closed_error()); + } + + tokio::select! { + _ = queue.notify.notified() => {} + _ = self.shared.closed_notify.notified() => return Err(Self::closed_error()), + } + } + } + + fn spawn_reader( + label: Arc, + shared: Arc>>, + mut reader: ReadHalf, + ) -> JoinHandle<()> + where + R: 'static, + { + tokio::spawn(async move { + loop { + match Message::from_reader(&mut reader).await { + Ok(msg) => { + debug!("[{}] Read message: {msg:#?}", label); + if Self::dispatch_pending_reply(&shared, msg.clone()).await { + continue; + } + if Self::handle_control_message(&shared, &msg).await { + continue; + } + if Self::dispatch_to_handler(&shared, msg.clone()).await { + continue; + } + Self::enqueue_message(&shared, msg).await; + } + Err(e) => { + warn!("[{}] RemoteServer reader exiting: {} ({:?})", label, e, e); + Self::fail_pending_replies(&shared).await; + shared.closed.store(true, Ordering::Relaxed); + shared.closed_notify.notify_waiters(); + break; + } + } + } + }) + } + + async fn handle_control_message( + shared: &Arc>>, + msg: &Message, + ) -> bool { + if msg.message_header.channel != 0 { + return false; + } + + match msg.data.as_ref() { + Some(plist::Value::String(selector)) + if selector == "_notifyOfPublishedCapabilities:" => + { + let aux = match msg.aux.as_ref() { + Some(aux) => aux.values.as_slice(), + None => { + warn!("Capabilities notification without aux payload"); + return true; + } + }; + + let Some(first) = aux.first() else { + warn!("Capabilities notification missing payload"); + return true; + }; + + match Self::decode_capabilities(first) { + Ok(capabilities) => { + debug!("Received remote capabilities: {:?}", capabilities); + *shared.supported_identifiers.lock().await = + CapabilityHandshakeState::Received(capabilities); + shared.handshake_notify.notify_waiters(); + // Preserve pre-XCTest behavior: older DVT callers expect the + // initial capabilities hello to remain observable via + // `read_message(0)` on the root channel. + Self::enqueue_message(shared, msg.clone()).await; + } + Err(e) => warn!("Failed to decode remote capabilities: {}", e), + } + return true; + } + Some(plist::Value::String(selector)) if selector == "_channelCanceled:" => { + let aux = match msg.aux.as_ref() { + Some(aux) => aux.values.as_slice(), + None => { + warn!("Incoming channel cancellation without aux payload"); + return true; + } + }; + + let Some(first) = aux.first() else { + warn!("Incoming channel cancellation missing channel code"); + return true; + }; + + match Self::decode_channel_code(first) { + Ok(channel_code) => { + debug!("Remote cancelled channel {}", channel_code); + Self::remove_channel(shared, channel_code).await; + } + Err(e) => warn!("Failed to decode incoming channel cancellation: {}", e), + } + return true; + } + Some(plist::Value::String(selector)) + if selector == "_requestChannelWithCode:identifier:" => {} + _ => return false, + } + + let aux = match msg.aux.as_ref() { + Some(aux) => aux.values.as_slice(), + None => { + warn!("Incoming channel request without aux payload"); + return false; + } }; - if let Some(msg) = cache.pop_front() { - return Ok(msg); + if aux.len() < 2 { + warn!("Incoming channel request missing aux values"); + return false; } - loop { - let msg = Message::from_reader(&mut self.idevice).await?; - debug!("Read message: {msg:#?}"); + let code = match aux[0] { + AuxValue::U32(code) => -(code as i32), + _ => { + warn!("Incoming channel request aux[0] is not U32"); + return false; + } + }; - if msg.message_header.channel == channel { - return Ok(msg); - } else if let Some(cache) = self.channels.get_mut(&msg.message_header.channel) { - cache.push_back(msg); - } else { - warn!( - "Received message for unknown channel: {}", - msg.message_header.channel - ); + let identifier = match Self::decode_identifier(&aux[1]) { + Ok(identifier) => identifier, + Err(e) => { + warn!("Failed to decode incoming channel identifier: {}", e); + return false; + } + }; + + debug!( + "Remote requested channel {} with identifier '{}'", + code, identifier + ); + + shared.channel_metadata.lock().await.insert( + code, + ChannelMetadata { + code, + identifier: identifier.clone(), + remote: true, + }, + ); + shared.registry_notify.notify_waiters(); + Self::ensure_channel_registered_shared(shared, code).await; + + if let Err(error) = + Self::run_incoming_channel_initializers(shared, code, identifier.clone()).await + { + warn!( + "Failed to initialize incoming channel {} ('{}'): {}", + code, identifier, error + ); + } + + if let Err(e) = shared + .send_raw_reply( + 0, + msg.message_header.identifier(), + msg.message_header.conversation_index(), + &[], + ) + .await + { + warn!("Failed to acknowledge incoming channel request: {}", e); + shared.closed.store(true, Ordering::Relaxed); + shared.closed_notify.notify_waiters(); + } + + true + } + + async fn run_incoming_channel_initializers( + shared: &Arc>>, + channel: i32, + identifier: String, + ) -> Result<(), IdeviceError> { + let initializer = { + let registrations = shared.incoming_channel_registrations.lock().await; + registrations + .iter() + .find(|registration| { + registration + .identifiers + .iter() + .any(|candidate| candidate == &identifier) + }) + .map(|registration| registration.initializer.clone()) + }; + + let Some(initializer) = initializer else { + return Ok(()); + }; + + initializer(shared.label.clone(), shared.clone(), channel, identifier).await + } + + async fn enqueue_message(shared: &Arc>>, msg: Message) { + if msg.message_header.conversation_index() == 0 { + debug!( + "Queueing unhandled incoming message on channel {} expects_reply={} data={:?}", + msg.message_header.channel, + msg.message_header.expects_reply(), + msg.data + ); + } + if let Some(queue) = + Self::get_channel_queue_shared(shared, msg.message_header.channel).await + { + let notify = &queue.notify; + { + let mut messages = queue.messages.lock().await; + messages.push_back(msg); + } + notify.notify_waiters(); + } else { + warn!( + "Received message for unknown channel: {}", + msg.message_header.channel + ); + } + } + + async fn dispatch_to_handler( + shared: &Arc>>, + msg: Message, + ) -> bool { + if msg.message_header.conversation_index() != 0 { + return false; + } + + let handler = { + let handlers = shared.handlers.lock().await; + handlers.get(&msg.message_header.channel).cloned() + }; + + let Some(handler) = handler else { + return false; + }; + + let expects_reply = msg.message_header.expects_reply(); + let msg_id = msg.message_header.identifier(); + let conversation_index = msg.message_header.conversation_index(); + let channel = msg.message_header.channel; + + match handler(msg).await { + Ok(IncomingHandlerOutcome::Unhandled) => false, + Ok(IncomingHandlerOutcome::HandledNoReply) => { + if expects_reply + && let Err(e) = shared + .send_raw_reply(channel, msg_id, conversation_index, &[]) + .await + { + warn!("Failed to auto-ack handled incoming message: {}", e); + } + true + } + Ok(IncomingHandlerOutcome::Reply(reply_bytes)) => { + if let Err(e) = shared + .send_raw_reply(channel, msg_id, conversation_index, &reply_bytes) + .await + { + warn!("Failed to reply from incoming handler: {}", e); + } + true + } + Err(e) => { + warn!("Incoming message handler failed: {}", e); + false + } + } + } + + async fn dispatch_pending_reply( + shared: &Arc>>, + msg: Message, + ) -> bool { + if msg.message_header.conversation_index() == 0 { + return false; + } + + let pending = shared + .pending_replies + .lock() + .await + .remove(&msg.message_header.identifier()); + + let Some(sender) = pending else { + return false; + }; + + if sender.send(msg).is_err() { + warn!("Reply waiter dropped before correlated reply was delivered"); + } + + true + } + + async fn ensure_channel_registered(&self, code: i32) { + Self::ensure_channel_registered_shared(&self.shared, code).await; + } + + async fn ensure_channel_registered_shared( + shared: &Arc>>, + code: i32, + ) { + let mut channels = shared.channels.lock().await; + channels + .entry(code) + .or_insert_with(|| Arc::new(ChannelQueue::default())); + } + + async fn get_channel_queue(&self, code: i32) -> Option> { + Self::get_channel_queue_shared(&self.shared, code).await + } + + async fn get_channel_queue_shared( + shared: &Arc>>, + code: i32, + ) -> Option> { + let channels = shared.channels.lock().await; + channels.get(&code).cloned() + } + + fn decode_identifier(aux: &AuxValue) -> Result { + match aux { + AuxValue::String(s) => Ok(s.clone()), + AuxValue::Array(bytes) => { + match ns_keyed_archive::decode::from_bytes(bytes).map_err(DvtError::from)? { + plist::Value::String(s) => Ok(s), + _ => Err(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )), + } } + _ => Err(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )), } } + + fn decode_capabilities(aux: &AuxValue) -> Result { + match aux { + AuxValue::Array(bytes) => { + match ns_keyed_archive::decode::from_bytes(bytes).map_err(DvtError::from)? { + plist::Value::Dictionary(dict) => Ok(dict), + _ => Err(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )), + } + } + _ => Err(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )), + } + } + + fn decode_channel_code(aux: &AuxValue) -> Result { + match aux { + AuxValue::U32(code) => i32::try_from(*code) + .map_err(|_| IdeviceError::UnexpectedResponse("unexpected response".into())), + AuxValue::I64(code) => i32::try_from(*code) + .map_err(|_| IdeviceError::UnexpectedResponse("unexpected response".into())), + _ => Err(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )), + } + } + + async fn remove_channel(shared: &Arc>>, channel_code: i32) { + shared.handlers.lock().await.remove(&channel_code); + shared.channels.lock().await.remove(&channel_code); + shared.channel_metadata.lock().await.remove(&channel_code); + shared.registry_notify.notify_waiters(); + } + + async fn fail_pending_replies(shared: &Arc>>) { + shared.pending_replies.lock().await.clear(); + } + + fn closed_error() -> IdeviceError { + IdeviceError::Socket(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "remote server connection closed", + )) + } +} + +impl RemoteServerClient> { + /// Creates a new RemoteServerClient with the given transport. + pub fn new(idevice: impl ReadWrite + 'static) -> Self { + Self::with_label(idevice, "remote-server") + } + + /// Creates a new client with a debug label used in tracing output. + pub fn with_label(idevice: impl ReadWrite + 'static, label: impl Into) -> Self { + Self::with_label_typed(Box::new(idevice), label) + } +} + +impl Drop for RemoteServerClient { + fn drop(&mut self) { + self.reader_task.abort(); + } +} + +impl RemoteServerShared { + async fn write_all(&self, bytes: &[u8]) -> Result<(), IdeviceError> { + let mut writer = self.writer.lock().await; + writer.write_all(bytes).await?; + writer.flush().await?; + Ok(()) + } + + async fn send_raw_reply( + &self, + channel: i32, + incoming_msg_id: u32, + incoming_conversation_index: u32, + data_bytes: &[u8], + ) -> Result<(), IdeviceError> { + let buf = Message::build_raw_reply( + channel, + incoming_msg_id, + incoming_conversation_index, + data_bytes, + ); + self.write_all(&buf).await + } } impl Channel<'_, R> { + /// Converts this borrowed channel handle into an owned/shared one. + pub(crate) fn detach(&self) -> OwnedChannel { + OwnedChannel { + label: self.client.label.clone(), + shared: self.client.shared.clone(), + channel: self.channel, + } + } + /// Reads the next message from the remote server on this channel /// /// # Returns @@ -296,4 +1321,153 @@ impl Channel<'_, R> { .call_method(self.channel, method, args, expect_reply) .await } + + /// Calls a method on this channel and waits for the correlated reply. + pub(crate) async fn call_method_with_reply( + &mut self, + method: Option>, + args: Option>, + ) -> Result { + self.client + .call_method_with_reply(self.channel, method, args) + .await + } +} + +impl OwnedChannel { + /// Reads the next queued message from this channel. + pub async fn read_message(&mut self) -> Result { + loop { + let queue = + RemoteServerClient::::get_channel_queue_shared(&self.shared, self.channel) + .await + .ok_or_else(|| DvtError::UnknownChannel(self.channel.unsigned_abs()))?; + + { + let mut messages = queue.messages.lock().await; + if let Some(msg) = messages.pop_front() { + return Ok(msg); + } + } + + if self.shared.closed.load(Ordering::Relaxed) { + return Err(RemoteServerClient::::closed_error()); + } + + tokio::select! { + _ = queue.notify.notified() => {} + _ = self.shared.closed_notify.notified() => { + return Err(RemoteServerClient::::closed_error()) + } + } + } + } + + /// Reads the next queued message with a timeout. + pub(crate) async fn read_message_timeout( + &mut self, + timeout: std::time::Duration, + ) -> Result { + tokio::time::timeout(timeout, self.read_message()) + .await + .map_err(|_| remote_timeout_error(timeout))? + } + + /// Calls a method on this channel. + pub async fn call_method( + &mut self, + method: Option>, + args: Option>, + expect_reply: bool, + ) -> Result<(), IdeviceError> { + let identifier = self.shared.current_message.fetch_add(1, Ordering::Relaxed) + 1; + let mheader = MessageHeader::new(0, 1, identifier, 0, self.channel, expect_reply); + let pheader = PayloadHeader::method_invocation(); + let aux = args.map(Aux::from_values); + let data: Option = method.map(Into::into); + let message = Message::new(mheader, pheader, aux, data); + debug!("[{}] Sending message: {message:#?}", self.label); + + self.shared.write_all(&message.serialize()).await?; + + Ok(()) + } + + /// Calls a method on this channel and waits for the correlated reply. + pub(crate) async fn call_method_with_reply( + &mut self, + method: Option>, + args: Option>, + ) -> Result { + let identifier = self.shared.current_message.fetch_add(1, Ordering::Relaxed) + 1; + let mheader = MessageHeader::new(0, 1, identifier, 0, self.channel, true); + let pheader = PayloadHeader::method_invocation(); + let aux = args.map(Aux::from_values); + let data: Option = method.map(Into::into); + let message = Message::new(mheader, pheader, aux, data); + debug!("[{}] Sending message: {message:#?}", self.label); + + let (sender, receiver) = oneshot::channel::(); + self.shared + .pending_replies + .lock() + .await + .insert(identifier, sender); + + let write_result = self.shared.write_all(&message.serialize()).await; + if write_result.is_err() { + self.shared.pending_replies.lock().await.remove(&identifier); + } + write_result?; + + match receiver.await { + Ok(message) => Ok(message), + Err(_) => { + self.shared.pending_replies.lock().await.remove(&identifier); + if self.shared.closed.load(Ordering::Relaxed) { + Err(RemoteServerClient::::closed_error()) + } else { + Err(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )) + } + } + } + } + + /// Registers an incoming handler for this channel. + pub(crate) async fn set_incoming_handler(&mut self, handler: F) + where + F: Fn(Message) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + { + let handler: IncomingMessageHandler = Arc::new(move |msg| Box::pin(handler(msg))); + self.shared + .handlers + .lock() + .await + .insert(self.channel, handler); + } + + /// Removes the incoming handler for this channel. + pub(crate) async fn clear_incoming_handler(&mut self) { + self.shared.handlers.lock().await.remove(&self.channel); + } + + /// Sends a raw reply for an incoming message on this channel. + pub(crate) async fn send_raw_reply_for( + &mut self, + incoming_msg_id: u32, + incoming_conversation_index: u32, + data_bytes: &[u8], + ) -> Result<(), IdeviceError> { + self.shared + .send_raw_reply( + self.channel, + incoming_msg_id, + incoming_conversation_index, + data_bytes, + ) + .await + } } diff --git a/idevice/src/services/dvt/xctest/dtx_services.rs b/idevice/src/services/dvt/xctest/dtx_services.rs new file mode 100644 index 0000000..8e4fffb --- /dev/null +++ b/idevice/src/services/dvt/xctest/dtx_services.rs @@ -0,0 +1,205 @@ +//! DTX channel names and method selectors for the XCTest / testmanagerd protocol. +//! +//! These constants correspond 1-to-1 with the Objective-C selector strings used by +//! Xcode's IDE interface and the on-device testmanagerd daemon. They are kept in one +//! place so every other module can import them without magic strings. +// Jackson Coxson + +// --------------------------------------------------------------------------- +// testmanagerd service names +// --------------------------------------------------------------------------- + +/// iOS < 14 — lockdown, no SSL. +pub const TESTMANAGERD_SERVICE: &str = "com.apple.testmanagerd.lockdown"; + +/// iOS 14–16 — lockdown with SSL. +pub const TESTMANAGERD_SECURE_SERVICE: &str = "com.apple.testmanagerd.lockdown.secure"; + +/// iOS 17+ — accessed over the RSD tunnel. +pub const TESTMANAGERD_RSD_SERVICE: &str = "com.apple.dt.testmanagerd.remote"; + +// --------------------------------------------------------------------------- +// DVT (instruments) service names +// --------------------------------------------------------------------------- + +/// iOS < 14 — legacy instruments remote server, lockdown, no SSL. +pub const DVT_LEGACY_SERVICE: &str = "com.apple.instruments.remoteserver"; + +/// iOS 14+ — instruments remote server with DVT secure socket proxy. +pub const DVT_SERVICE: &str = "com.apple.instruments.remoteserver.DVTSecureSocketProxy"; + +// --------------------------------------------------------------------------- +// DTX channel identifiers +// --------------------------------------------------------------------------- + +/// Channel identifier for the XCTest IDE ↔ daemon interface. +/// Used on iOS < 17 (lockdown path). +pub const XCTEST_MANAGER_IDE_INTERFACE: &str = "XCTestManager_IDEInterface"; + +/// Service identifier for the daemon-facing side of the XCTest proxy channel. +pub const XCTEST_MANAGER_DAEMON_CONNECTION_INTERFACE: &str = + "XCTestManager_DaemonConnectionInterface"; + +/// Service identifier for the runner-facing side of the XCTest proxy channel. +pub const XCTEST_DRIVER_INTERFACE: &str = "XCTestDriverInterface"; + +/// iOS 17+ proxy channel: IDE ↔ DaemonConnectionInterface. +/// Format used by pymobiledevice3's DtxProxyService over RSD. +pub const XCTEST_PROXY_IDE_TO_DAEMON: &str = + "dtxproxy:XCTestManager_IDEInterface:XCTestManager_DaemonConnectionInterface"; + +/// iOS 17+ proxy channel: IDE ↔ XCTestDriverInterface (reverse channel from runner). +pub const XCTEST_PROXY_IDE_TO_DRIVER: &str = + "dtxproxy:XCTestManager_IDEInterface:XCTestDriverInterface"; + +// --------------------------------------------------------------------------- +// Xcode version reported to testmanagerd +// --------------------------------------------------------------------------- + +/// Protocol version number reported to testmanagerd as the IDE's Xcode version. +/// The exact value is not significant; 36 matches a recent Xcode release. +pub const XCODE_VERSION: u64 = 36; + +// --------------------------------------------------------------------------- +// Outgoing IDE → daemon selectors +// --------------------------------------------------------------------------- + +/// iOS 17+: initiate the control channel, passing IDE capabilities. +pub const IDE_INITIATE_CTRL_SESSION_WITH_CAPABILITIES: &str = + "_IDE_initiateControlSessionWithCapabilities:"; + +/// iOS 11–16: initiate the control channel with a protocol version number. +pub const IDE_INITIATE_CTRL_SESSION_WITH_PROTOCOL_VERSION: &str = + "_IDE_initiateControlSessionWithProtocolVersion:"; + +/// iOS 17+: initiate the main session with a UUID and IDE capabilities. +pub const IDE_INITIATE_SESSION_WITH_IDENTIFIER_CAPABILITIES: &str = + "_IDE_initiateSessionWithIdentifier:capabilities:"; + +/// iOS 11–16: initiate the main session with a UUID, client string, path, and version. +pub const IDE_INITIATE_SESSION_WITH_IDENTIFIER_FOR_CLIENT_AT_PATH_PROTOCOL_VERSION: &str = + "_IDE_initiateSessionWithIdentifier:forClient:atPath:protocolVersion:"; + +/// iOS 12+: authorise the test session for a launched process ID. +pub const IDE_AUTHORIZE_TEST_SESSION: &str = "_IDE_authorizeTestSessionWithProcessID:"; + +/// iOS 10–11: authorise by PID with a protocol version. +pub const IDE_INITIATE_CTRL_SESSION_FOR_PID_PROTOCOL_VERSION: &str = + "_IDE_initiateControlSessionForTestProcessID:protocolVersion:"; + +/// iOS < 10: authorise by PID only. +pub const IDE_INITIATE_CTRL_SESSION_FOR_PID: &str = "_IDE_initiateControlSessionForTestProcessID:"; + +// --------------------------------------------------------------------------- +// Outgoing IDE → driver selectors +// --------------------------------------------------------------------------- + +/// Signal the test runner to begin executing its test plan. +pub const IDE_START_EXECUTING_TEST_PLAN: &str = "_IDE_startExecutingTestPlanWithProtocolVersion:"; + +// --------------------------------------------------------------------------- +// Incoming runner → IDE callbacks (_XCT_*) +// --------------------------------------------------------------------------- + +/// Test plan has started executing. +pub const XCT_DID_BEGIN_TEST_PLAN: &str = "_XCT_didBeginExecutingTestPlan"; + +/// Test plan has finished executing (terminal event). +pub const XCT_DID_FINISH_TEST_PLAN: &str = "_XCT_didFinishExecutingTestPlan"; + +/// Runner signals readiness and negotiates capabilities (iOS 17+ DDI variant). +pub const XCT_RUNNER_READY_WITH_CAPABILITIES: &str = "_XCT_testRunnerReadyWithCapabilities:"; + +/// Informational log message from the runner. +pub const XCT_LOG_MESSAGE: &str = "_XCT_logMessage:"; + +/// Debug log message from the runner. +pub const XCT_LOG_DEBUG_MESSAGE: &str = "_XCT_logDebugMessage:"; + +/// Protocol version negotiation. +pub const XCT_EXCHANGE_PROTOCOL_VERSION: &str = + "_XCT_exchangeCurrentProtocolVersion_minimumVersion_"; + +/// Test bundle is ready (legacy, no capabilities). +pub const XCT_BUNDLE_READY: &str = "_XCT_testBundleReady"; + +/// Test bundle is ready with a protocol version. +pub const XCT_BUNDLE_READY_WITH_PROTOCOL_VERSION: &str = + "_XCT_testBundleReadyWithProtocolVersion_minimumVersion_"; + +/// UI testing initialization began. +pub const XCT_DID_BEGIN_UI_INIT: &str = "_XCT_didBeginInitializingForUITesting"; + +/// Test runner formed the test plan payload. +pub const XCT_DID_FORM_PLAN: &str = "_XCT_didFormPlanWithData:"; + +/// Runner requested launch progress for a token. +pub const XCT_GET_PROGRESS_FOR_LAUNCH: &str = "_XCT_getProgressForLaunch:"; + +/// UI testing initialization failed. +pub const XCT_UI_INIT_DID_FAIL: &str = "_XCT_initializationForUITestingDidFailWithError:"; + +/// Test runner failed to bootstrap. +pub const XCT_DID_FAIL_BOOTSTRAP: &str = "_XCT_didFailToBootstrapWithError:"; + +// --- suite lifecycle (legacy string-based, pre-iOS 14) -------------------- + +/// Test suite started (legacy). +pub const XCT_SUITE_DID_START: &str = "_XCT_testSuite_didStartAt_"; + +/// Test suite finished (legacy). +pub const XCT_SUITE_DID_FINISH: &str = + "_XCT_testSuite_didFinishAt_runCount_withFailures_unexpected_testDuration_totalDuration_"; + +// --- case lifecycle (legacy string-based, pre-iOS 14) --------------------- + +/// Test case started (legacy). +pub const XCT_CASE_DID_START: &str = "_XCT_testCaseDidStartForTestClass_method_"; + +/// Test case finished (legacy). +pub const XCT_CASE_DID_FINISH: &str = + "_XCT_testCaseDidFinishForTestClass_method_withStatus_duration_"; + +/// Test case recorded a failure (legacy). +pub const XCT_CASE_DID_FAIL: &str = + "_XCT_testCaseDidFailForTestClass_method_withMessage_file_line_"; + +/// Test case stalled on the main thread (legacy). +pub const XCT_CASE_DID_STALL: &str = "_XCT_testCase_method_didStallOnMainThreadInFile_line_"; + +/// Test case will start an activity (legacy). +pub const XCT_CASE_WILL_START_ACTIVITY: &str = "_XCT_testCase_method_willStartActivity_"; + +/// Test case finished an activity (legacy). +pub const XCT_CASE_DID_FINISH_ACTIVITY: &str = "_XCT_testCase_method_didFinishActivity_"; + +// --- suite lifecycle (identifier-based, iOS 14+) -------------------------- + +/// Test suite started, identified by XCTTestIdentifier. +pub const XCT_SUITE_DID_START_ID: &str = "_XCT_testSuiteWithIdentifier:didStartAt:"; + +/// Test suite finished, identified by XCTTestIdentifier. +pub const XCT_SUITE_DID_FINISH_ID: &str = "_XCT_testSuiteWithIdentifier:didFinishAt:runCount:skipCount:failureCount:expectedFailureCount:uncaughtExceptionCount:testDuration:totalDuration:"; + +// --- case lifecycle (identifier-based, iOS 14+) --------------------------- + +/// Test case started, identified by XCTTestIdentifier. +pub const XCT_CASE_DID_START_ID: &str = + "_XCT_testCaseDidStartWithIdentifier:testCaseRunConfiguration:"; + +/// Test case finished, identified by XCTTestIdentifier. +pub const XCT_CASE_DID_FINISH_ID: &str = + "_XCT_testCaseWithIdentifier:didFinishWithStatus:duration:"; + +/// Test case recorded an XCTIssue, identified by XCTTestIdentifier. +pub const XCT_CASE_DID_RECORD_ISSUE: &str = "_XCT_testCaseWithIdentifier:didRecordIssue:"; + +/// Test case will start an activity, identified by XCTTestIdentifier. +pub const XCT_CASE_WILL_START_ACTIVITY_ID: &str = "_XCT_testCaseWithIdentifier:willStartActivity:"; + +/// Test case finished an activity, identified by XCTTestIdentifier. +pub const XCT_CASE_DID_FINISH_ACTIVITY_ID: &str = "_XCT_testCaseWithIdentifier:didFinishActivity:"; + +/// Performance metric measured during a test method. +pub const XCT_METHOD_DID_MEASURE_METRIC: &str = + "_XCT_testMethod_ofClass_didMeasureMetric_file_line_"; diff --git a/idevice/src/services/dvt/xctest/listener.rs b/idevice/src/services/dvt/xctest/listener.rs new file mode 100644 index 0000000..b4868c1 --- /dev/null +++ b/idevice/src/services/dvt/xctest/listener.rs @@ -0,0 +1,234 @@ +//! XCUITest lifecycle callback trait. +//! +//! Implement [`XCUITestListener`] and pass it to [`super::XCUITestService::run`] to +//! receive per-test-case and per-suite events as they arrive from the runner. +//! All methods have default no-op implementations; override only what you need. +// Jackson Coxson + +use crate::IdeviceError; + +// --------------------------------------------------------------------------- +// Supporting types +// --------------------------------------------------------------------------- + +/// Result record for a single finished test case. +#[derive(Debug, Clone)] +pub struct XCTestCaseResult { + /// Test class name (e.g. `"UITests"`). + pub test_class: String, + /// Test method name (e.g. `"testLogin"`). + pub method: String, + /// Outcome string: `"passed"`, `"failed"`, or `"skipped"`. + pub status: String, + /// Wall-clock duration of the test case in seconds. + pub duration: f64, +} + +// --------------------------------------------------------------------------- +// Listener trait +// --------------------------------------------------------------------------- + +/// Callback interface for XCUITest lifecycle events. +/// +/// All methods receive `&mut self` so implementors can accumulate state (e.g. +/// counters, log buffers). Every method returns `Result<(), IdeviceError>` so +/// that the orchestrator can propagate fatal listener errors back to the caller. +/// +/// The default implementation is a no-op for every method. +#[allow(async_fn_in_trait)] +pub trait XCUITestListener: Send { + // --- test plan ---------------------------------------------------------- + + /// Invoked when the runner begins executing the test plan. + async fn did_begin_executing_test_plan(&mut self) -> Result<(), IdeviceError> { + Ok(()) + } + + /// Invoked when the runner has finished executing the entire test plan. + async fn did_finish_executing_test_plan(&mut self) -> Result<(), IdeviceError> { + Ok(()) + } + + // --- bundle ready ------------------------------------------------------- + + /// Invoked when the test bundle signals readiness (legacy protocol, no capabilities). + async fn test_bundle_ready(&mut self) -> Result<(), IdeviceError> { + Ok(()) + } + + /// Invoked when the test bundle reports its protocol version. + async fn test_bundle_ready_with_protocol_version( + &mut self, + _protocol_version: u64, + _minimum_version: u64, + ) -> Result<(), IdeviceError> { + Ok(()) + } + + /// Invoked when the runner announces readiness together with its capability set. + async fn test_runner_ready_with_capabilities(&mut self) -> Result<(), IdeviceError> { + Ok(()) + } + + // --- suite lifecycle ---------------------------------------------------- + + /// Invoked when a test suite starts. + async fn test_suite_did_start( + &mut self, + _suite: &str, + _started_at: &str, + ) -> Result<(), IdeviceError> { + Ok(()) + } + + /// Invoked when a test suite finishes. + #[allow(clippy::too_many_arguments)] + async fn test_suite_did_finish( + &mut self, + _suite: &str, + _finished_at: &str, + _run_count: u64, + _failures: u64, + _unexpected: u64, + _test_duration: f64, + _total_duration: f64, + _skipped: u64, + _expected_failures: u64, + _uncaught_exceptions: u64, + ) -> Result<(), IdeviceError> { + Ok(()) + } + + // --- case lifecycle ----------------------------------------------------- + + /// Invoked when a single test case starts. + async fn test_case_did_start( + &mut self, + _test_class: &str, + _method: &str, + ) -> Result<(), IdeviceError> { + Ok(()) + } + + /// Invoked when a single test case finishes. + async fn test_case_did_finish( + &mut self, + _result: XCTestCaseResult, + ) -> Result<(), IdeviceError> { + Ok(()) + } + + /// Invoked when a test case records a failure. + async fn test_case_did_fail( + &mut self, + _test_class: &str, + _method: &str, + _message: &str, + _file: &str, + _line: u64, + ) -> Result<(), IdeviceError> { + Ok(()) + } + + /// Invoked when a test case stalls on the main thread. + async fn test_case_did_stall( + &mut self, + _test_class: &str, + _method: &str, + _file: &str, + _line: u64, + ) -> Result<(), IdeviceError> { + Ok(()) + } + + // --- activities --------------------------------------------------------- + + /// Invoked when a test case is about to start an activity step. + async fn test_case_will_start_activity( + &mut self, + _test_class: &str, + _method: &str, + _activity_title: &str, + ) -> Result<(), IdeviceError> { + Ok(()) + } + + /// Invoked when a test case finishes an activity step. + async fn test_case_did_finish_activity( + &mut self, + _test_class: &str, + _method: &str, + _activity_title: &str, + ) -> Result<(), IdeviceError> { + Ok(()) + } + + // --- metrics ------------------------------------------------------------ + + /// Invoked when a test method measures a performance metric. + async fn test_method_did_measure_metric( + &mut self, + _test_class: &str, + _method: &str, + _metric: &str, + _file: &str, + _line: u64, + ) -> Result<(), IdeviceError> { + Ok(()) + } + + // --- logging ------------------------------------------------------------ + + /// Invoked for informational log messages from the runner. + async fn log_message(&mut self, _message: &str) -> Result<(), IdeviceError> { + Ok(()) + } + + /// Invoked for debug log messages from the runner. + async fn log_debug_message(&mut self, _message: &str) -> Result<(), IdeviceError> { + Ok(()) + } + + // --- protocol negotiation ----------------------------------------------- + + /// Invoked when the runner negotiates protocol versions. + async fn exchange_protocol_version( + &mut self, + _current: u64, + _minimum: u64, + ) -> Result<(), IdeviceError> { + Ok(()) + } + + // --- iOS 14+ UI testing init -------------------------------------------- + + /// Invoked when initialization for UI testing begins. + async fn did_begin_initializing_for_ui_testing(&mut self) -> Result<(), IdeviceError> { + Ok(()) + } + + /// Invoked when the runner forms a test plan payload. + async fn did_form_plan(&mut self, _data: &str) -> Result<(), IdeviceError> { + Ok(()) + } + + /// Invoked when the runner asks for launch progress. + async fn get_progress_for_launch(&mut self, _token: &str) -> Result<(), IdeviceError> { + Ok(()) + } + + /// Invoked when UI testing initialization fails. + async fn initialization_for_ui_testing_did_fail( + &mut self, + _description: &str, + ) -> Result<(), IdeviceError> { + Ok(()) + } + + /// Invoked when the test runner fails to bootstrap. + async fn did_fail_to_bootstrap(&mut self, description: &str) -> Result<(), IdeviceError> { + Err(IdeviceError::UnexpectedResponse(format!( + "test runner failed to bootstrap: {description}" + ))) + } +} diff --git a/idevice/src/services/dvt/xctest/mod.rs b/idevice/src/services/dvt/xctest/mod.rs new file mode 100644 index 0000000..917411d --- /dev/null +++ b/idevice/src/services/dvt/xctest/mod.rs @@ -0,0 +1,2099 @@ +//! XCTest service client for iOS instruments protocol. +//! +//! This module provides orchestration for running XCTest bundles (including +//! WebDriverAgent) on iOS devices through the instruments and testmanagerd +//! protocols. It handles session setup, test runner launch, and lifecycle +//! event dispatch. +//! +//! Supports iOS 11+ via lockdown and iOS 17+ via RSD tunnel. +//! +//! # Example +//! ```rust,no_run +//! # #[cfg(feature = "xctest")] +//! # { +//! use idevice::services::dvt::xctest::{TestConfig, XCUITestService}; +//! +//! #[tokio::main] +//! async fn main() -> Result<(), idevice::IdeviceError> { +//! // provider setup omitted +//! Ok(()) +//! } +//! # } +//! ``` + +pub mod dtx_services; +pub mod listener; +pub mod types; + +use std::sync::Arc; + +use plist::{Dictionary, Value}; +#[cfg(feature = "wda")] +use serde_json::Value as JsonValue; +use tracing::{debug, warn}; + +#[cfg(feature = "wda")] +use crate::services::wda::{WdaClient, WdaPorts}; +#[cfg(feature = "wda")] +use crate::services::wda_bridge::WdaBridge; +use crate::{ + IdeviceError, IdeviceService, ReadWrite, + dvt::message::{AuxValue, Message}, + provider::{IdeviceProvider, RsdProvider}, + services::{ + core_device_proxy::CoreDeviceProxy, + dvt::{ + process_control::ProcessControlClient, + remote_server::{IncomingHandlerOutcome, OwnedChannel, RemoteServerClient}, + }, + installation_proxy::InstallationProxyClient, + lockdown::LockdownClient, + rsd::RsdHandshake, + }, +}; +use dtx_services::{ + DVT_LEGACY_SERVICE, DVT_SERVICE, IDE_AUTHORIZE_TEST_SESSION, IDE_INITIATE_CTRL_SESSION_FOR_PID, + IDE_INITIATE_CTRL_SESSION_FOR_PID_PROTOCOL_VERSION, + IDE_INITIATE_CTRL_SESSION_WITH_CAPABILITIES, IDE_INITIATE_CTRL_SESSION_WITH_PROTOCOL_VERSION, + IDE_INITIATE_SESSION_WITH_IDENTIFIER_CAPABILITIES, + IDE_INITIATE_SESSION_WITH_IDENTIFIER_FOR_CLIENT_AT_PATH_PROTOCOL_VERSION, + IDE_START_EXECUTING_TEST_PLAN, TESTMANAGERD_RSD_SERVICE, TESTMANAGERD_SECURE_SERVICE, + TESTMANAGERD_SERVICE, XCODE_VERSION, XCT_BUNDLE_READY, XCT_BUNDLE_READY_WITH_PROTOCOL_VERSION, + XCT_CASE_DID_FAIL, XCT_CASE_DID_FINISH, XCT_CASE_DID_FINISH_ACTIVITY, + XCT_CASE_DID_FINISH_ACTIVITY_ID, XCT_CASE_DID_FINISH_ID, XCT_CASE_DID_RECORD_ISSUE, + XCT_CASE_DID_STALL, XCT_CASE_DID_START, XCT_CASE_DID_START_ID, XCT_CASE_WILL_START_ACTIVITY, + XCT_CASE_WILL_START_ACTIVITY_ID, XCT_DID_BEGIN_TEST_PLAN, XCT_DID_BEGIN_UI_INIT, + XCT_DID_FAIL_BOOTSTRAP, XCT_DID_FINISH_TEST_PLAN, XCT_DID_FORM_PLAN, + XCT_EXCHANGE_PROTOCOL_VERSION, XCT_GET_PROGRESS_FOR_LAUNCH, XCT_LOG_DEBUG_MESSAGE, + XCT_LOG_MESSAGE, XCT_METHOD_DID_MEASURE_METRIC, XCT_RUNNER_READY_WITH_CAPABILITIES, + XCT_SUITE_DID_FINISH, XCT_SUITE_DID_FINISH_ID, XCT_SUITE_DID_START, XCT_SUITE_DID_START_ID, + XCT_UI_INIT_DID_FAIL, XCTEST_DRIVER_INTERFACE, XCTEST_MANAGER_DAEMON_CONNECTION_INTERFACE, + XCTEST_MANAGER_IDE_INTERFACE, XCTEST_PROXY_IDE_TO_DRIVER, +}; +use listener::{XCTestCaseResult, XCUITestListener}; +use types::{ + XCActivityRecord, XCTCapabilities, XCTIssue, XCTTestIdentifier, XCTestConfiguration, + archive_nsuuid_to_bytes, archive_xct_capabilities_to_bytes, +}; + +#[cfg(feature = "wda")] +use tokio::task::JoinHandle; + +// --------------------------------------------------------------------------- +// TestConfig +// --------------------------------------------------------------------------- + +/// Launch configuration for the XCTest runner and optional target application. +/// +/// Built from `InstallationProxyClient` and used to generate both the +/// on-device `XCTestConfiguration` file and the process-launch environment. +/// +/// # Example +/// ```rust,no_run +/// # #[cfg(feature = "xctest")] +/// # async fn example() -> Result<(), idevice::IdeviceError> { +/// // let cfg = TestConfig::from_installation_proxy(&mut proxy, "com.example.App.xctrunner", None).await?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Debug, Clone)] +pub struct TestConfig { + // --- Runner app info (from installation_proxy) ------------------------- + /// Bundle identifier of the runner app (e.g. `"com.example.App.xctrunner"`). + pub runner_bundle_id: String, + /// On-device path of the runner app bundle (`"Path"` key). + pub runner_app_path: String, + /// On-device container path of the runner app (`"Container"` key). + pub runner_app_container: String, + /// Executable name inside the runner bundle (`"CFBundleExecutable"` key). + /// Must end with `"-Runner"`. + pub runner_bundle_executable: String, + + // --- Target app (optional) -------------------------------------------- + /// Bundle identifier of the app under test, if any. + pub target_bundle_id: Option, + /// On-device path of the target app bundle, if any. + pub target_app_path: Option, + /// Extra environment variables forwarded to the target app. + pub target_app_env: Option, + /// Extra launch arguments forwarded to the target app. + pub target_app_args: Option>, + + // --- Test filters ------------------------------------------------------ + /// If set, only these test identifiers are run. + pub tests_to_run: Option>, + /// If set, these test identifiers are skipped. + pub tests_to_skip: Option>, + + // --- Runner overrides ------------------------------------------------- + /// Additional environment variables merged into the runner launch env. + pub runner_env: Option, + /// Additional arguments appended to the runner launch args. + pub runner_args: Option>, +} + +impl TestConfig { + /// Constructs a `TestConfig` by querying `InstallationProxyClient` for + /// the runner (and optionally target) application information. + /// + /// # Arguments + /// * `install_proxy` - Connected `InstallationProxyClient` + /// * `runner_bundle_id` - Bundle identifier of the `.xctrunner` app + /// * `target_bundle_id` - Optional bundle identifier of the app under test + /// + /// # Errors + /// * `IdeviceError::AppNotInstalled` if runner or target is not found + /// * `IdeviceError::UnexpectedResponse("unexpected response".into())` if `CFBundleExecutable` does not + /// end with `"-Runner"` or required keys are missing + pub async fn from_installation_proxy( + install_proxy: &mut InstallationProxyClient, + runner_bundle_id: &str, + target_bundle_id: Option<&str>, + ) -> Result { + let app_string = |dict: &Dictionary, key: &str| -> Result { + dict.get(key) + .and_then(|value| value.as_string()) + .map(ToOwned::to_owned) + .ok_or_else(|| { + warn!("Missing or non-string key '{}' in app info dict", key); + IdeviceError::UnexpectedResponse("unexpected response".into()) + }) + }; + + // Build the bundle ID list to look up in one request + let mut ids = vec![runner_bundle_id.to_owned()]; + if let Some(t) = target_bundle_id { + ids.push(t.to_owned()); + } + + let apps = install_proxy.get_apps(None, Some(ids)).await?; + + // --- Runner --- + let runner_info = apps.get(runner_bundle_id).ok_or_else(|| { + warn!("Runner app not installed: {}", runner_bundle_id); + IdeviceError::AppNotInstalled + })?; + + let runner_dict = runner_info.as_dictionary().ok_or_else(|| { + warn!("Runner info is not a dictionary"); + IdeviceError::UnexpectedResponse("unexpected response".into()) + })?; + + let runner_app_path = app_string(runner_dict, "Path")?; + let runner_app_container = app_string(runner_dict, "Container")?; + let runner_bundle_executable = app_string(runner_dict, "CFBundleExecutable")?; + + if !runner_bundle_executable.ends_with("-Runner") { + warn!( + "CFBundleExecutable '{}' does not end with '-Runner'; this is not a valid xctest runner bundle", + runner_bundle_executable + ); + return Err(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )); + } + + // --- Target (optional) --- + let (target_bundle_id_out, target_app_path) = if let Some(t) = target_bundle_id { + let target_info = apps.get(t).ok_or_else(|| { + warn!("Target app not installed: {}", t); + IdeviceError::AppNotInstalled + })?; + let target_dict = target_info.as_dictionary().ok_or_else(|| { + warn!("Target info is not a dictionary"); + IdeviceError::UnexpectedResponse("unexpected response".into()) + })?; + let path = app_string(target_dict, "Path")?; + (Some(t.to_owned()), Some(path)) + } else { + (None, None) + }; + + Ok(Self { + runner_bundle_id: runner_bundle_id.to_owned(), + runner_app_path, + runner_app_container, + runner_bundle_executable, + target_bundle_id: target_bundle_id_out, + target_app_path, + target_app_env: None, + target_app_args: None, + tests_to_run: None, + tests_to_skip: None, + runner_env: None, + runner_args: None, + }) + } + + /// Returns the config name — the executable name with `"-Runner"` stripped. + /// + /// For example, `"WebDriverAgentRunner-Runner"` → `"WebDriverAgentRunner"`. + pub fn config_name(&self) -> &str { + self.runner_bundle_executable + .strip_suffix("-Runner") + .unwrap_or(&self.runner_bundle_executable) + } + + /// Builds an [`XCTestConfiguration`] for this test run. + /// + /// # Arguments + /// * `session_id` - Unique UUID for this test session + /// * `ios_major_version` - iOS major version number (e.g. `17`) + /// + /// # Errors + /// Propagates serialisation errors from nested types. + pub fn build_xctest_configuration( + &self, + session_id: uuid::Uuid, + ios_major_version: u8, + ) -> Result { + let config_name = self.config_name(); + + let test_bundle_url = format!( + "file://{}/PlugIns/{}.xctest", + self.runner_app_path, config_name + ); + + let automation_framework_path = if ios_major_version >= 17 { + "/System/Developer/Library/PrivateFrameworks/XCTAutomationSupport.framework".to_owned() + } else { + "/Developer/Library/PrivateFrameworks/XCTAutomationSupport.framework".to_owned() + }; + + // productModuleName: config_name when a target app is set, else default WDA name + let product_module_name = if self.target_bundle_id.is_some() { + config_name.to_owned() + } else { + "WebDriverAgentRunner".to_owned() + }; + + // When a target app is specified, targetApplicationEnvironment must be at + // least an empty dict (not null) — mirrors Python's `self.target_app_env or {}` + let target_application_environment = if self.target_bundle_id.is_some() { + Some(self.target_app_env.clone().unwrap_or_default()) + } else { + None + }; + + Ok(XCTestConfiguration { + test_bundle_url, + session_identifier: session_id, + product_module_name, + automation_framework_path, + target_application_bundle_id: self.target_bundle_id.clone(), + target_application_path: self.target_app_path.clone(), + target_application_environment, + target_application_arguments: self.target_app_args.clone().unwrap_or_default(), + tests_to_run: self.tests_to_run.clone(), + tests_to_skip: self.tests_to_skip.clone(), + ide_capabilities: XCTCapabilities::ide_defaults(), + }) + } +} + +// --------------------------------------------------------------------------- +// build_launch_env +// --------------------------------------------------------------------------- + +/// Builds the process-launch arguments, environment, and options for the +/// XCTest runner process. +/// +/// # Arguments +/// * `ios_major_version` - iOS major version number +/// * `session_id` - Test session UUID +/// * `runner_app_path` - On-device path of the runner app bundle +/// * `runner_app_container` - On-device container path of the runner app +/// * `target_name` - Config name (executable without `"-Runner"` suffix) +/// * `xctest_config_path` - Device path to the `.xctestconfiguration` file +/// (e.g. `"/tmp/{UUID}.xctestconfiguration"`) +/// * `extra_env` - Additional env vars merged on top of the base set +/// * `extra_args` - Additional args appended after the base set +/// +/// # Returns +/// `(launch_args, launch_env, launch_options)` as `(Vec, Dictionary, Dictionary)` +#[allow(clippy::too_many_arguments)] +pub(crate) fn build_launch_env( + ios_major_version: u8, + session_id: &uuid::Uuid, + runner_app_path: &str, + runner_app_container: &str, + target_name: &str, + xctest_config_path: &str, + extra_env: Option<&Dictionary>, + extra_args: Option<&[String]>, +) -> (Vec, Dictionary, Dictionary) { + let session_upper = session_id.to_string().to_uppercase(); + + // Base environment + let mut env = crate::plist!(dict { + "CA_ASSERT_MAIN_THREAD_TRANSACTIONS": "0", + "CA_DEBUG_TRANSACTIONS": "0", + "DYLD_FRAMEWORK_PATH": format!("{}/Frameworks:", runner_app_path), + "DYLD_LIBRARY_PATH": format!("{}/Frameworks", runner_app_path), + "MTC_CRASH_ON_REPORT": "1", + "NSUnbufferedIO": "YES", + "SQLITE_ENABLE_THREAD_ASSERTIONS": "1", + "WDA_PRODUCT_BUNDLE_IDENTIFIER": "", + "XCTestBundlePath": format!("{}/PlugIns/{}.xctest", runner_app_path, target_name), + "XCTestConfigurationFilePath": format!("{}{}", runner_app_container, xctest_config_path), + "XCODE_DBG_XPC_EXCLUSIONS": "com.apple.dt.xctestSymbolicator", + "XCTestSessionIdentifier": session_upper.clone(), + }); + + // iOS >= 11 + if ios_major_version >= 11 { + let ios11_env = crate::plist!(dict { + "DYLD_INSERT_LIBRARIES": "/Developer/usr/lib/libMainThreadChecker.dylib", + "OS_ACTIVITY_DT_MODE": "YES", + }); + for (key, value) in ios11_env { + env.insert(key, value); + } + } + + // iOS >= 17 — extend DYLD paths and clear config path (sent via capabilities) + if ios_major_version >= 17 { + let existing_fw = env + .get("DYLD_FRAMEWORK_PATH") + .and_then(|v| v.as_string()) + .unwrap_or("") + .to_owned(); + let existing_lib = env + .get("DYLD_LIBRARY_PATH") + .and_then(|v| v.as_string()) + .unwrap_or("") + .to_owned(); + // Prepend '$' so dyld expands the existing path value at launch time, + // matching Python: f"${app_env['DYLD_FRAMEWORK_PATH']}/System/..." + let ios17_env = crate::plist!(dict { + "DYLD_FRAMEWORK_PATH": format!( + "${}/System/Developer/Library/Frameworks:", + existing_fw + ), + "DYLD_LIBRARY_PATH": format!("${}:/System/Developer/usr/lib", existing_lib), + // Config path is sent as return value of _XCT_testRunnerReadyWithCapabilities_ + "XCTestConfigurationFilePath": "", + "XCTestManagerVariant": "DDI", + }); + for (key, value) in ios17_env { + env.insert(key, value); + } + } + + // Merge caller-provided overrides + if let Some(extra) = extra_env { + for (k, v) in extra.iter() { + env.insert(k.clone(), v.clone()); + } + } + + // Launch arguments + let mut args = vec![ + "-NSTreatUnknownArgumentsAsOpen".to_owned(), + "NO".to_owned(), + "-ApplePersistenceIgnoreState".to_owned(), + "YES".to_owned(), + ]; + if let Some(extra) = extra_args { + args.extend_from_slice(extra); + } + + // Launch options + let opts = if ios_major_version >= 12 { + crate::plist!(dict { + "StartSuspendedKey": false, + "ActivateSuspended": true, + }) + } else { + crate::plist!(dict { + "StartSuspendedKey": false, + }) + }; + + (args, env, opts) +} + +// --------------------------------------------------------------------------- +// testmanagerd connections +// --------------------------------------------------------------------------- + +/// Active DTX connections for running XCTest. +/// +/// Holds three `RemoteServerClient` instances: +/// - `ctrl` — testmanagerd control channel connection +/// - `main` — testmanagerd main channel connection +/// - `dvt` — DVT instruments connection (for `ProcessControl`) +pub(super) struct TestManagerConnections { + pub ctrl: RemoteServerClient>, + pub main: RemoteServerClient>, + pub dvt: RemoteServerClient>, + /// Keeps the software tunnel/adapter handles alive for the duration of the session. + #[allow(dead_code)] + rsd_handles: Vec, +} + +/// Connects to a lockdown-based DTX service, trying each name in order. +/// +/// Returns the first successful `RemoteServerClient`. +async fn connect_dtx_service( + provider: &dyn IdeviceProvider, + service_names: &[&str], + read_greeting: bool, +) -> Result>, IdeviceError> { + let mut lockdown = LockdownClient::connect(provider).await?; + lockdown + .start_session(&provider.get_pairing_file().await?) + .await?; + + let mut last_err: Option = None; + for &name in service_names { + match lockdown.start_service(name).await { + Ok((port, ssl)) => { + let mut idevice = provider.connect(port).await?; + if ssl { + idevice + .start_session(&provider.get_pairing_file().await?, false) + .await?; + } + let socket = idevice + .get_socket() + .ok_or(IdeviceError::NoEstablishedConnection)?; + let label = format!("lockdown:{name}"); + let client = RemoteServerClient::with_label(socket, label); + if read_greeting { + // testmanagerd sends a capabilities hello on connect. + let _ = client + .wait_for_capabilities(std::time::Duration::from_secs(10)) + .await; + } + return Ok(client); + } + Err(e) => { + last_err = Some(e); + } + } + } + Err(last_err.unwrap_or(IdeviceError::ServiceNotFound)) +} + +const RSD_GREETING_TIMEOUT_SECS: u64 = 30; + +/// DTX capabilities dict announced to the daemon on each connection. +/// +/// Mirrors `DTXConnection.DEFAULT_CAPABILITIES` from the Instruments protocol. +fn dtx_capabilities_dict(include_process_control_callback: bool) -> plist::Dictionary { + let mut caps = crate::plist!(dict { + "com.apple.private.DTXBlockCompression": 0i64, + "com.apple.private.DTXConnection": 1i64, + }); + if include_process_control_callback { + caps.insert( + "com.apple.instruments.client.processcontrol.capability.terminationCallback".into(), + plist::Value::Integer(1i64.into()), + ); + } + caps +} + +/// Opens a single RSD service port and performs the DTX capability handshake, +/// retrying up to `MAX_ATTEMPTS` times. +async fn rsd_connect( + handle: &mut crate::tcp::handle::AdapterHandle, + handshake: &RsdHandshake, + service_name: &str, + label: &str, + include_process_control_callback: bool, +) -> Result>, IdeviceError> { + const MAX_ATTEMPTS: usize = 5; + let service = handshake + .services + .get(service_name) + .ok_or_else(|| { + warn!("RSD service not found: {}", service_name); + IdeviceError::ServiceNotFound + })? + .clone(); + let port = service.port; + + let mut last_err = None; + for attempt in 1..=MAX_ATTEMPTS { + debug!( + "[{}] opening service '{}' on remote port {} (attempt {}/{})", + label, service_name, port, attempt, MAX_ATTEMPTS + ); + let stream = handle.connect_to_service_port(port).await?; + debug!("[{}] service port {} connected", label, port); + let mut client = RemoteServerClient::with_label(stream, label); + match client + .perform_handshake( + Some(dtx_capabilities_dict(include_process_control_callback)), + std::time::Duration::from_secs(RSD_GREETING_TIMEOUT_SECS), + ) + .await + { + Ok(remote_capabilities) => { + debug!( + "[{}] RSD DTX capabilities exchange complete: {:?}", + label, remote_capabilities + ); + return Ok(client); + } + Err(error) => { + warn!( + "[{}] RSD DTX handshake failed on attempt {}/{}: {}", + label, attempt, MAX_ATTEMPTS, error + ); + last_err = Some(error); + if attempt < MAX_ATTEMPTS { + tokio::time::sleep(std::time::Duration::from_millis(750)).await; + } + } + } + } + + Err(last_err.unwrap_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + ))) +} + +/// Attempts a single CoreDeviceProxy + RSD stack setup, returning all three +/// DTX connections on success. +async fn connect_rsd_stack_once( + provider: &dyn IdeviceProvider, +) -> Result { + let proxy = CoreDeviceProxy::connect(provider).await?; + let rsd_port = proxy.tunnel_info().server_rsd_port; + let adapter = proxy.create_software_tunnel()?; + let mut handle = adapter.to_async_handle(); + + debug!("[rsd] connecting to shared RSD port {}", rsd_port); + let rsd_stream = handle.connect_to_service_port(rsd_port).await?; + let handshake = RsdHandshake::new(rsd_stream).await?; + debug!( + "[rsd] shared RSD handshake OK — {} services advertised", + handshake.services.len() + ); + + let dvt = match rsd_connect( + &mut handle, + &handshake, + "com.apple.instruments.dtservicehub", + "dtservicehub", + true, + ) + .await + { + Ok(client) => client, + Err(e) => { + warn!( + "RSD dtservicehub connect failed ({}), falling back to lockdown DVT", + e + ); + connect_dtx_service(provider, &[DVT_SERVICE, DVT_LEGACY_SERVICE], false).await? + } + }; + let ctrl = rsd_connect( + &mut handle, + &handshake, + TESTMANAGERD_RSD_SERVICE, + "testmanagerd-ctrl", + false, + ) + .await?; + let main = rsd_connect( + &mut handle, + &handshake, + TESTMANAGERD_RSD_SERVICE, + "testmanagerd-main", + false, + ) + .await?; + + Ok(TestManagerConnections { + ctrl, + main, + dvt, + rsd_handles: vec![handle], + }) +} + +/// Establishes the three DTX connections for iOS 17+ via CoreDeviceProxy + RSD. +/// +/// Opens a software TCP tunnel through CoreDeviceProxy, does the RSD handshake +/// to discover service ports, then connects to testmanagerd (×2) and +/// `dtservicehub` on their advertised ports. +async fn connect_testmanagerd_rsd( + provider: &dyn IdeviceProvider, +) -> Result { + const RSD_STACK_ATTEMPTS: usize = 3; + + let mut last_err = None; + for attempt in 1..=RSD_STACK_ATTEMPTS { + debug!( + "[rsd] establishing CoreDeviceProxy/software tunnel stack (attempt {}/{})", + attempt, RSD_STACK_ATTEMPTS + ); + match connect_rsd_stack_once(provider).await { + Ok(connections) => return Ok(connections), + Err(error) => { + warn!( + "[rsd] CoreDeviceProxy/software tunnel stack attempt {}/{} failed: {}", + attempt, RSD_STACK_ATTEMPTS, error + ); + last_err = Some(error); + if attempt < RSD_STACK_ATTEMPTS { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + } + } + } + + Err(last_err.unwrap_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + ))) +} + +/// Establishes the three DTX connections required for an XCTest run. +/// +/// For iOS 17+ tries `CoreDeviceProxy` + RSD first. Falls back to lockdown +/// for iOS < 17 or if CoreDeviceProxy is unavailable. +/// +/// # Arguments +/// * `provider` - Device connection provider +/// * `ios_major_version` - iOS major version (used to select service names) +pub(super) async fn connect_testmanagerd( + provider: &dyn IdeviceProvider, + ios_major_version: u8, +) -> Result { + // iOS 17+ must use RSD tunnel path + if ios_major_version >= 17 { + return connect_testmanagerd_rsd(provider).await; + } + + // iOS < 17 (or fallback): lockdown path + let tm_service = if ios_major_version >= 14 { + TESTMANAGERD_SECURE_SERVICE + } else { + TESTMANAGERD_SERVICE + }; + + let ctrl = connect_dtx_service(provider, &[tm_service], true).await?; + let main = connect_dtx_service(provider, &[tm_service], true).await?; + let dvt = connect_dtx_service(provider, &[DVT_SERVICE, DVT_LEGACY_SERVICE], false).await?; + + Ok(TestManagerConnections { + ctrl, + main, + dvt, + rsd_handles: Vec::new(), + }) +} + +// --------------------------------------------------------------------------- +// session init + process launch +// --------------------------------------------------------------------------- + +/// Initialises the control session on the ctrl DTX channel. +/// +/// Sends the appropriate IDE-initiation method based on `ios_major_version`. +pub(super) async fn init_ctrl_session( + ctrl_channel: &mut OwnedChannel, + ios_major_version: u8, +) -> Result<(), IdeviceError> { + if ios_major_version >= 17 { + let caps_bytes = + AuxValue::Array(archive_xct_capabilities_to_bytes(&XCTCapabilities::empty())?); + let reply = ctrl_channel + .call_method_with_reply( + Some(IDE_INITIATE_CTRL_SESSION_WITH_CAPABILITIES), + Some(vec![caps_bytes]), + ) + .await?; + debug!("init_ctrl_session (iOS 17+) reply: {:?}", reply.data); + } else if ios_major_version >= 11 { + let version_bytes = AuxValue::archived_value(Value::Integer((XCODE_VERSION as i64).into())); + let reply = ctrl_channel + .call_method_with_reply( + Some(IDE_INITIATE_CTRL_SESSION_WITH_PROTOCOL_VERSION), + Some(vec![version_bytes]), + ) + .await?; + debug!("init_ctrl_session (iOS 11-16) reply: {:?}", reply.data); + } + // iOS < 11: nothing to do + Ok(()) +} + +/// Initialises the main test session on the main DTX channel. +pub(super) async fn init_session( + main_channel: &mut OwnedChannel, + ios_major_version: u8, + session_id: &uuid::Uuid, + xctest_config: &XCTestConfiguration, +) -> Result<(), IdeviceError> { + let uuid_bytes = AuxValue::Array(archive_nsuuid_to_bytes(session_id)?); + + if ios_major_version >= 17 { + let caps_bytes = AuxValue::Array(archive_xct_capabilities_to_bytes( + &XCTCapabilities::ide_defaults(), + )?); + let reply = main_channel + .call_method_with_reply( + Some(IDE_INITIATE_SESSION_WITH_IDENTIFIER_CAPABILITIES), + Some(vec![uuid_bytes, caps_bytes]), + ) + .await?; + debug!("init_session (iOS 17+) reply: {:?}", reply.data); + } else if ios_major_version >= 11 { + let client_bytes = AuxValue::archived_value(Value::String("not-very-important".into())); + let path_bytes = AuxValue::archived_value(Value::String( + "/Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild".into(), + )); + let version_bytes = AuxValue::archived_value(Value::Integer((XCODE_VERSION as i64).into())); + let reply = main_channel + .call_method_with_reply( + Some(IDE_INITIATE_SESSION_WITH_IDENTIFIER_FOR_CLIENT_AT_PATH_PROTOCOL_VERSION), + Some(vec![uuid_bytes, client_bytes, path_bytes, version_bytes]), + ) + .await?; + debug!("init_session (iOS 11-16) reply: {:?}", reply.data); + } else { + return Ok(()); + } + + let _ = xctest_config; // used by caller for bootstrap reply handling + Ok(()) +} + +/// Launches the XCTest runner process via ProcessControl. +/// +/// Uses `launchSuspendedProcessWithDevicePath:bundleIdentifier:environment:arguments:options:` +/// with the provided arguments and options dictionaries. +/// +/// # Returns +/// PID of the launched process. +pub(super) async fn launch_runner( + process_control: &mut ProcessControlClient<'_, R>, + bundle_id: &str, + launch_args: Vec, + launch_env: Dictionary, + launch_options: Dictionary, +) -> Result { + let args_array: Vec = launch_args.into_iter().map(Value::String).collect(); + + process_control + .launch_with_options(bundle_id, launch_env, args_array, launch_options) + .await +} + +// --------------------------------------------------------------------------- +// authorize + driver channel + start plan +// --------------------------------------------------------------------------- + +/// Authorises the test session for the launched runner process. +pub(super) async fn authorize_test( + ctrl_channel: &mut OwnedChannel, + ios_major_version: u8, + pid: u64, +) -> Result<(), IdeviceError> { + let pid_bytes = AuxValue::archived_value(Value::Integer((pid as i64).into())); + + if ios_major_version >= 12 { + let reply = ctrl_channel + .call_method_with_reply(Some(IDE_AUTHORIZE_TEST_SESSION), Some(vec![pid_bytes])) + .await?; + match reply.data { + Some(Value::Boolean(true)) | None => { + debug!("authorize_test: OK"); + } + Some(Value::Boolean(false)) => { + warn!("authorize_test returned false"); + return Err(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )); + } + other => { + debug!("authorize_test reply: {:?}", other); + } + } + } else if ios_major_version >= 10 { + let version_bytes = AuxValue::archived_value(Value::Integer((XCODE_VERSION as i64).into())); + let reply = ctrl_channel + .call_method_with_reply( + Some(IDE_INITIATE_CTRL_SESSION_FOR_PID_PROTOCOL_VERSION), + Some(vec![pid_bytes, version_bytes]), + ) + .await?; + debug!("authorize_test (<12, >=10) reply: {:?}", reply.data); + } else { + let reply = ctrl_channel + .call_method_with_reply( + Some(IDE_INITIATE_CTRL_SESSION_FOR_PID), + Some(vec![pid_bytes]), + ) + .await?; + debug!("authorize_test (<10) reply: {:?}", reply.data); + } + Ok(()) +} + +struct TestManagerProxy { + channel: OwnedChannel, +} + +impl TestManagerProxy { + async fn open( + client: &mut RemoteServerClient, + ios_major_version: u8, + ) -> Result { + let channel = if testmanager_uses_proxy(ios_major_version) { + client + .open_proxied_service_channel( + XCTEST_MANAGER_IDE_INTERFACE, + XCTEST_MANAGER_DAEMON_CONNECTION_INTERFACE, + ) + .await? + } else { + client + .open_service_channel(XCTEST_MANAGER_IDE_INTERFACE) + .await? + }; + + Ok(Self { + channel: channel.detach(), + }) + } + + async fn install_bootstrap_handler(&mut self, xctest_config: XCTestConfiguration) { + install_early_xctest_handler(&mut self.channel, xctest_config).await; + } + + async fn init_ctrl_session(&mut self, ios_major_version: u8) -> Result<(), IdeviceError> { + init_ctrl_session(&mut self.channel, ios_major_version).await + } + + async fn init_session( + &mut self, + ios_major_version: u8, + session_id: &uuid::Uuid, + xctest_config: &XCTestConfiguration, + ) -> Result<(), IdeviceError> { + init_session( + &mut self.channel, + ios_major_version, + session_id, + xctest_config, + ) + .await + } + + async fn authorize_test( + &mut self, + ios_major_version: u8, + pid: u64, + ) -> Result<(), IdeviceError> { + authorize_test(&mut self.channel, ios_major_version, pid).await + } +} + +struct DriverProxy { + channel: OwnedChannel>, +} + +impl DriverProxy { + async fn wait( + client: &mut RemoteServerClient>, + timeout_secs: f64, + ) -> Result { + Ok(Self { + channel: wait_for_driver_channel(client, timeout_secs).await?, + }) + } + + async fn start_executing_test_plan(&mut self) -> Result<(), IdeviceError> { + start_executing_test_plan(&mut self.channel).await + } +} + +struct XCTestProcessControlChannel<'a, R: ReadWrite> { + service: ProcessControlClient<'a, R>, +} + +impl<'a, R: ReadWrite + 'static> XCTestProcessControlChannel<'a, R> { + async fn open(client: &'a mut RemoteServerClient) -> Result { + Ok(Self { + service: ProcessControlClient::new(client).await?, + }) + } + + async fn launch_suspended_process( + &mut self, + bundle_id: &str, + launch_args: Vec, + launch_env: Dictionary, + launch_options: Dictionary, + ) -> Result { + launch_runner( + &mut self.service, + bundle_id, + launch_args, + launch_env, + launch_options, + ) + .await + } +} + +/// Waits for the test runner to open the reverse `XCTestDriverInterface` channel. +/// +/// After launching, the runner sends `_requestChannelWithCode:identifier:` on root +/// channel 0. This function reads root-channel messages until that request arrives, +/// replies with an empty acknowledgement, registers the channel, and returns a +/// `Channel` handle to it. +fn testmanager_uses_proxy(ios_major_version: u8) -> bool { + ios_major_version >= 17 +} + +async fn wait_for_xctest_service_channel( + main_client: &mut RemoteServerClient>, + plain_identifiers: &[&str], + proxy_remote_identifiers: &[&str], + timeout_secs: f64, +) -> Result>, IdeviceError> { + let timeout = Some(std::time::Duration::from_secs_f64(timeout_secs)); + + let code = match main_client + .wait_for_proxied_service_channel_code(proxy_remote_identifiers, true, Some(true), timeout) + .await + { + Ok(code) => code, + Err(IdeviceError::XcTestTimeout(_)) => match main_client + .wait_for_service_channel_code(plain_identifiers, Some(true), timeout) + .await + { + Ok(code) => code, + Err(IdeviceError::XcTestTimeout(_)) => return Err(IdeviceError::TestRunnerTimeout), + Err(error) => return Err(error), + }, + Err(error) => return Err(error), + }; + + Ok(main_client.accept_owned_channel(code)) +} + +async fn register_early_driver_channel_handler( + main_client: &mut RemoteServerClient>, + xctest_config: &XCTestConfiguration, +) { + let xctest_config = xctest_config.clone(); + main_client + .register_incoming_channel_initializer( + &[XCTEST_DRIVER_INTERFACE, XCTEST_PROXY_IDE_TO_DRIVER], + move |mut channel, _identifier| { + let xctest_config = xctest_config.clone(); + Box::pin(async move { + install_early_xctest_handler(&mut channel, xctest_config).await; + Ok(()) + }) + }, + ) + .await; +} + +async fn initialize_testmanager_sessions( + ctrl_proxy: &mut TestManagerProxy>, + main_proxy: &mut TestManagerProxy>, + xctest_config: &XCTestConfiguration, +) -> Result<(), IdeviceError> { + ctrl_proxy + .install_bootstrap_handler(xctest_config.clone()) + .await; + main_proxy + .install_bootstrap_handler(xctest_config.clone()) + .await; + Ok(()) +} + +async fn initialize_testmanager_daemon_sessions( + ctrl_proxy: &mut TestManagerProxy>, + main_proxy: &mut TestManagerProxy>, + ios_major_version: u8, + session_id: &uuid::Uuid, + xctest_config: &XCTestConfiguration, +) -> Result<(), IdeviceError> { + ctrl_proxy.init_ctrl_session(ios_major_version).await?; + main_proxy + .init_session(ios_major_version, session_id, xctest_config) + .await?; + + Ok(()) +} + +async fn launch_and_authorize_test_runner( + ctrl_proxy: &mut TestManagerProxy>, + process_control: &mut XCTestProcessControlChannel<'_, Box>, + ios_major_version: u8, + runner_bundle_id: &str, + launch_args: Vec, + launch_env: Dictionary, + launch_options: Dictionary, +) -> Result { + let pid = process_control + .launch_suspended_process(runner_bundle_id, launch_args, launch_env, launch_options) + .await?; + debug!("Launched test runner pid={}", pid); + + if ios_major_version < 17 { + tokio::time::sleep(std::time::Duration::from_secs(1)).await; + } + + ctrl_proxy.authorize_test(ios_major_version, pid).await?; + Ok(pid) +} + +async fn start_test_plan_session( + main_client: &mut RemoteServerClient>, + _main_proxy: &mut TestManagerProxy>, +) -> Result>, IdeviceError> { + let mut driver_proxy = DriverProxy::wait(main_client, 30.0).await?; + driver_proxy.start_executing_test_plan().await?; + driver_proxy.channel.clear_incoming_handler().await; + Ok(driver_proxy.channel) +} + +pub(super) async fn wait_for_driver_channel( + main_client: &mut RemoteServerClient>, + timeout_secs: f64, +) -> Result>, IdeviceError> { + const DRIVER_SERVICE_IDENTIFIERS: &[&str] = &[XCTEST_DRIVER_INTERFACE]; + wait_for_xctest_service_channel( + main_client, + DRIVER_SERVICE_IDENTIFIERS, + DRIVER_SERVICE_IDENTIFIERS, + timeout_secs, + ) + .await +} + +/// Signals the test runner to begin executing the test plan. +pub(super) async fn start_executing_test_plan( + driver_channel: &mut OwnedChannel, +) -> Result<(), IdeviceError> { + let version_bytes = AuxValue::archived_value(Value::Integer((XCODE_VERSION as i64).into())); + let reply = driver_channel + .call_method_with_reply( + Some(IDE_START_EXECUTING_TEST_PLAN), + Some(vec![version_bytes]), + ) + .await?; + debug!("start_executing_test_plan reply: {:?}", reply.data); + Ok(()) +} + +// --------------------------------------------------------------------------- +// _XCT_* dispatch + run_dispatch_loop + XCUITestService +// --------------------------------------------------------------------------- + +// --- Aux-value helpers ------------------------------------------------------ + +fn decode_aux_archive(aux: &AuxValue) -> Result { + match aux { + AuxValue::Array(bytes) => ns_keyed_archive::decode::from_bytes(bytes) + .map_err(|_| IdeviceError::UnexpectedResponse("unexpected response".into())), + _ => Err(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )), + } +} + +fn aux_as_string(aux: &AuxValue) -> Result { + if let AuxValue::String(s) = aux { + return Ok(s.clone()); + } + match decode_aux_archive(aux)? { + Value::String(s) => Ok(s), + _ => Err(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )), + } +} + +fn aux_as_u64(aux: &AuxValue) -> Result { + match aux { + AuxValue::U32(v) => return Ok(*v as u64), + AuxValue::I64(v) => return Ok(*v as u64), + _ => {} + } + match decode_aux_archive(aux)? { + Value::Integer(i) => i.as_unsigned().ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )), + _ => Err(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )), + } +} + +fn aux_as_f64(aux: &AuxValue) -> Result { + match decode_aux_archive(aux) { + Ok(Value::Real(f)) => return Ok(f), + Ok(Value::Integer(i)) => return Ok(i.as_unsigned().unwrap_or(0) as f64), + _ => {} + } + match aux { + AuxValue::U32(v) => Ok(*v as f64), + AuxValue::I64(v) => Ok(*v as f64), + AuxValue::Double(v) => Ok(*v), + _ => Err(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )), + } +} + +// --- Dispatch --------------------------------------------------------------- + +/// Dispatches a single incoming `_XCT_*` message to the appropriate listener +/// method. +/// +/// Returns `Some(reply_bytes)` if the caller must send a reply (only for +/// `_XCT_testRunnerReadyWithCapabilities_`); `None` otherwise. +pub(super) async fn dispatch_xct_message( + method: &str, + aux: &[AuxValue], + xctest_config: &XCTestConfiguration, + listener: &mut L, + done_flag: &mut bool, +) -> Result>, IdeviceError> { + match method { + // --- logging --- + m if m == XCT_LOG_DEBUG_MESSAGE => { + if let Some(msg) = aux.first().map(aux_as_string).transpose()? { + listener.log_debug_message(&msg).await?; + } + } + m if m == XCT_LOG_MESSAGE => { + if let Some(msg) = aux.first().map(aux_as_string).transpose()? { + listener.log_message(&msg).await?; + } + } + + // --- protocol negotiation --- + m if m == XCT_EXCHANGE_PROTOCOL_VERSION => { + let current = aux.first().map(aux_as_u64).transpose()?.unwrap_or(0); + let minimum = aux.get(1).map(aux_as_u64).transpose()?.unwrap_or(0); + listener.exchange_protocol_version(current, minimum).await?; + } + + // --- bundle ready --- + m if m == XCT_BUNDLE_READY => { + listener.test_bundle_ready().await?; + } + m if m == XCT_BUNDLE_READY_WITH_PROTOCOL_VERSION => { + let proto = aux.first().map(aux_as_u64).transpose()?.unwrap_or(0); + let min = aux.get(1).map(aux_as_u64).transpose()?.unwrap_or(0); + listener + .test_bundle_ready_with_protocol_version(proto, min) + .await?; + } + m if m == XCT_RUNNER_READY_WITH_CAPABILITIES => { + if let Some(raw) = aux.first() + && let Ok(decoded) = decode_aux_archive(raw) + && let Some(caps) = XCTCapabilities::from_plist(&decoded) + { + debug!("testRunnerReadyWithCapabilities: {:?}", caps.capabilities); + } + listener.test_runner_ready_with_capabilities().await?; + let reply = xctest_config.to_archive_bytes()?; + return Ok(Some(reply)); + } + + // --- test plan lifecycle --- + m if m == XCT_DID_BEGIN_TEST_PLAN => { + listener.did_begin_executing_test_plan().await?; + } + m if m == XCT_DID_FINISH_TEST_PLAN => { + *done_flag = true; + listener.did_finish_executing_test_plan().await?; + } + + // --- suite lifecycle (legacy string-based) --- + m if m == XCT_SUITE_DID_START => { + let suite = aux + .first() + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let started_at = aux + .get(1) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + listener.test_suite_did_start(&suite, &started_at).await?; + } + m if m == XCT_SUITE_DID_FINISH => { + let suite = aux + .first() + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let finished_at = aux + .get(1) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let run_count = aux.get(2).map(aux_as_u64).transpose()?.unwrap_or(0); + let failures = aux.get(3).map(aux_as_u64).transpose()?.unwrap_or(0); + let unexpected = aux.get(4).map(aux_as_u64).transpose()?.unwrap_or(0); + let test_dur = aux.get(5).map(aux_as_f64).transpose()?.unwrap_or(0.0); + let total_dur = aux.get(6).map(aux_as_f64).transpose()?.unwrap_or(0.0); + listener + .test_suite_did_finish( + &suite, + &finished_at, + run_count, + failures, + unexpected, + test_dur, + total_dur, + 0, + 0, + 0, + ) + .await?; + } + + // --- suite lifecycle (identifier-based, iOS 14+) --- + m if m == XCT_SUITE_DID_START_ID => { + if let Some(raw) = aux.first() + && let Ok(decoded) = decode_aux_archive(raw) + && let Some(id) = XCTTestIdentifier::from_plist(&decoded) + { + let tc = id.test_class(); + if !tc.is_empty() && tc != "All tests" { + let started_at = aux + .get(1) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + listener.test_suite_did_start(tc, &started_at).await?; + } + } + } + m if m == XCT_SUITE_DID_FINISH_ID => { + if let Some(raw) = aux.first() + && let Ok(decoded) = decode_aux_archive(raw) + && let Some(id) = XCTTestIdentifier::from_plist(&decoded) + { + let tc = id.test_class().to_owned(); + if !tc.is_empty() && tc != "All tests" { + let finished_at = aux + .get(1) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let run_count = aux.get(2).map(aux_as_u64).transpose()?.unwrap_or(0); + let skip_count = aux.get(3).map(aux_as_u64).transpose()?.unwrap_or(0); + let fail_count = aux.get(4).map(aux_as_u64).transpose()?.unwrap_or(0); + let expected_fail = aux.get(5).map(aux_as_u64).transpose()?.unwrap_or(0); + let uncaught = aux.get(6).map(aux_as_u64).transpose()?.unwrap_or(0); + let test_dur = aux.get(7).map(aux_as_f64).transpose()?.unwrap_or(0.0); + let total_dur = aux.get(8).map(aux_as_f64).transpose()?.unwrap_or(0.0); + listener + .test_suite_did_finish( + &tc, + &finished_at, + run_count, + fail_count, + uncaught, + test_dur, + total_dur, + skip_count, + expected_fail, + 0, + ) + .await?; + } + } + } + + // --- case lifecycle (legacy) --- + m if m == XCT_CASE_DID_START => { + let test_class = aux + .first() + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let method_name = aux + .get(1) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + listener + .test_case_did_start(&test_class, &method_name) + .await?; + } + m if m == XCT_CASE_DID_FINISH => { + let test_class = aux + .first() + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let method_name = aux + .get(1) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let status = aux + .get(2) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let duration = aux.get(3).map(aux_as_f64).transpose()?.unwrap_or(0.0); + listener + .test_case_did_finish(XCTestCaseResult { + test_class, + method: method_name, + status, + duration, + }) + .await?; + } + m if m == XCT_CASE_DID_FAIL => { + let test_class = aux + .first() + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let method_name = aux + .get(1) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let message = aux + .get(2) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let file = aux + .get(3) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let line = aux.get(4).map(aux_as_u64).transpose()?.unwrap_or(0); + listener + .test_case_did_fail(&test_class, &method_name, &message, &file, line) + .await?; + } + m if m == XCT_CASE_DID_STALL => { + let test_class = aux + .first() + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let method_name = aux + .get(1) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let file = aux + .get(2) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let line = aux.get(3).map(aux_as_u64).transpose()?.unwrap_or(0); + listener + .test_case_did_stall(&test_class, &method_name, &file, line) + .await?; + } + + // --- case lifecycle (identifier-based, iOS 14+) --- + m if m == XCT_CASE_DID_START_ID => { + if let Some(raw) = aux.first() + && let Ok(decoded) = decode_aux_archive(raw) + && let Some(id) = XCTTestIdentifier::from_plist(&decoded) + { + let method_name = id.test_method().unwrap_or("").to_owned(); + listener + .test_case_did_start(id.test_class(), &method_name) + .await?; + } + } + m if m == XCT_CASE_DID_FINISH_ID => { + if let Some(raw) = aux.first() + && let Ok(decoded) = decode_aux_archive(raw) + && let Some(id) = XCTTestIdentifier::from_plist(&decoded) + { + let test_class = id.test_class().to_owned(); + let method_name = id.test_method().unwrap_or("").to_owned(); + let status = aux + .get(1) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let duration = aux.get(2).map(aux_as_f64).transpose()?.unwrap_or(0.0); + listener + .test_case_did_finish(XCTestCaseResult { + test_class, + method: method_name, + status, + duration, + }) + .await?; + } + } + m if m == XCT_CASE_DID_RECORD_ISSUE => { + if let (Some(id_raw), Some(issue_raw)) = (aux.first(), aux.get(1)) + && let (Ok(id_val), Ok(issue_val)) = + (decode_aux_archive(id_raw), decode_aux_archive(issue_raw)) + && let (Some(id), Some(issue)) = ( + XCTTestIdentifier::from_plist(&id_val), + XCTIssue::from_plist(&issue_val), + ) + { + let test_class = id.test_class().to_owned(); + let method_name = id.test_method().unwrap_or("").to_owned(); + let file = issue + .source_code_context + .as_ref() + .and_then(|c| c.location.as_ref()) + .and_then(|l| l.file_path()) + .unwrap_or("") + .to_owned(); + let line = issue + .source_code_context + .as_ref() + .and_then(|c| c.location.as_ref()) + .map(|l| l.line_number) + .unwrap_or(0); + listener + .test_case_did_fail( + &test_class, + &method_name, + &issue.compact_description, + &file, + line, + ) + .await?; + } + } + + // --- activities (legacy) --- + m if m == XCT_CASE_WILL_START_ACTIVITY => { + let test_class = aux + .first() + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let method_name = aux + .get(1) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + // aux[2] is an XCActivityRecord NSKeyedArchive blob, not a plain string + let title = aux + .get(2) + .and_then(|a| decode_aux_archive(a).ok()) + .and_then(|v| XCActivityRecord::from_plist(&v)) + .map(|r| r.title) + .unwrap_or_default(); + listener + .test_case_will_start_activity(&test_class, &method_name, &title) + .await?; + } + m if m == XCT_CASE_DID_FINISH_ACTIVITY => { + let test_class = aux + .first() + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let method_name = aux + .get(1) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + // aux[2] is an XCActivityRecord NSKeyedArchive blob, not a plain string + let title = aux + .get(2) + .and_then(|a| decode_aux_archive(a).ok()) + .and_then(|v| XCActivityRecord::from_plist(&v)) + .map(|r| r.title) + .unwrap_or_default(); + listener + .test_case_did_finish_activity(&test_class, &method_name, &title) + .await?; + } + + // --- activities (identifier-based) --- + m if m == XCT_CASE_WILL_START_ACTIVITY_ID => { + if let Some(id_raw) = aux.first() + && let Ok(id_val) = decode_aux_archive(id_raw) + && let Some(id) = XCTTestIdentifier::from_plist(&id_val) + { + let method_name = id.test_method().unwrap_or("").to_owned(); + // aux[1] is an XCActivityRecord NSKeyedArchive blob + let title = aux + .get(1) + .and_then(|a| decode_aux_archive(a).ok()) + .and_then(|v| XCActivityRecord::from_plist(&v)) + .map(|r| r.title) + .unwrap_or_default(); + listener + .test_case_will_start_activity(id.test_class(), &method_name, &title) + .await?; + } + } + m if m == XCT_CASE_DID_FINISH_ACTIVITY_ID => { + if let Some(id_raw) = aux.first() + && let Ok(id_val) = decode_aux_archive(id_raw) + && let Some(id) = XCTTestIdentifier::from_plist(&id_val) + { + let method_name = id.test_method().unwrap_or("").to_owned(); + // aux[1] is an XCActivityRecord NSKeyedArchive blob + let title = aux + .get(1) + .and_then(|a| decode_aux_archive(a).ok()) + .and_then(|v| XCActivityRecord::from_plist(&v)) + .map(|r| r.title) + .unwrap_or_default(); + listener + .test_case_did_finish_activity(id.test_class(), &method_name, &title) + .await?; + } + } + + // --- metrics --- + // Python selector: _XCT_testMethod:ofClass:didMeasureMetric:file:line: + // → aux[0]=method, aux[1]=test_class, aux[2]=metric, aux[3]=file, aux[4]=line + m if m == XCT_METHOD_DID_MEASURE_METRIC => { + let method_name = aux + .first() + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let test_class = aux + .get(1) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let metric = aux + .get(2) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let file = aux + .get(3) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let line = aux.get(4).map(aux_as_u64).transpose()?.unwrap_or(0); + listener + .test_method_did_measure_metric(&test_class, &method_name, &metric, &file, line) + .await?; + } + + // --- iOS 14+ UI testing --- + m if m == XCT_DID_BEGIN_UI_INIT => { + listener.did_begin_initializing_for_ui_testing().await?; + } + m if m == XCT_DID_FORM_PLAN => { + let data = aux + .first() + .and_then(|value| aux_as_string(value).ok()) + .unwrap_or_default(); + listener.did_form_plan(&data).await?; + } + m if m == XCT_GET_PROGRESS_FOR_LAUNCH => { + let token = aux + .first() + .and_then(|value| aux_as_string(value).ok()) + .unwrap_or_default(); + listener.get_progress_for_launch(&token).await?; + } + m if m == XCT_UI_INIT_DID_FAIL => { + let desc = aux + .first() + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + listener + .initialization_for_ui_testing_did_fail(&desc) + .await?; + } + m if m == XCT_DID_FAIL_BOOTSTRAP => { + // The aux is an NSKeyedArchived NSError. Try plain string first, + // then decode the archive and pull NSLocalizedDescription out of + // the error dictionary, falling back to a generic message. + let desc = aux + .first() + .and_then(|v| { + // plain string (unlikely but handle it) + if let AuxValue::String(s) = v { + return Some(s.clone()); + } + // NSKeyedArchive -> plist Value + let decoded = decode_aux_archive(v).ok()?; + // NSError serialises as a Dictionary. String fields come + // through as plain values; other fields (domain, userInfo) + // are Uid references into the archive's $objects table + // which the decoder doesn't follow. + if let Value::Dictionary(d) = &decoded { + // Try inline string fields first + if let Some(s) = d + .get("NSLocalizedDescription") + .or_else(|| d.get("NSLocalizedFailureReason")) + .and_then(|v| v.as_string()) + { + return Some(s.to_owned()); + } + // Fall back to the numeric code with a hint for + // the most common values seen from testmanagerd + if let Some(code) = d.get("NSCode").and_then(|v| v.as_signed_integer()) { + let hint = match code { + 103 => " (untrusted developer certificate — go to Settings → General → VPN & Device Management and trust your developer app)", + _ => "", + }; + return Some(format!("NSError code {code}{hint}")); + } + } + None + }) + .unwrap_or_else(|| "unknown error".to_owned()); + listener.did_fail_to_bootstrap(&desc).await?; + } + + other => { + warn!("Unknown _XCT_ method: {}", other); + } + } + + Ok(None) +} + +struct EarlyXCTestBootstrapListener; + +impl XCUITestListener for EarlyXCTestBootstrapListener {} + +fn should_handle_in_bootstrap(method: &str) -> bool { + matches!( + method, + XCT_EXCHANGE_PROTOCOL_VERSION + | XCT_RUNNER_READY_WITH_CAPABILITIES + | XCT_BUNDLE_READY + | XCT_BUNDLE_READY_WITH_PROTOCOL_VERSION + | XCT_LOG_MESSAGE + | XCT_LOG_DEBUG_MESSAGE + ) +} + +async fn install_early_xctest_handler( + main_channel: &mut OwnedChannel, + xctest_config: XCTestConfiguration, +) { + main_channel + .set_incoming_handler(move |msg: Message| { + let xctest_config = xctest_config.clone(); + Box::pin(async move { + let method = match msg.data.as_ref() { + Some(Value::String(method)) => method.as_str(), + _ => return Ok(IncomingHandlerOutcome::Unhandled), + }; + + if !should_handle_in_bootstrap(method) { + return Ok(IncomingHandlerOutcome::Unhandled); + } + + let aux = msg.aux.as_ref().map(|a| a.values.as_slice()).unwrap_or(&[]); + + let mut listener = EarlyXCTestBootstrapListener; + let mut done = false; + let reply = + dispatch_xct_message(method, aux, &xctest_config, &mut listener, &mut done) + .await?; + + Ok(match reply { + Some(reply_bytes) => IncomingHandlerOutcome::Reply(reply_bytes), + None => IncomingHandlerOutcome::HandledNoReply, + }) + }) + }) + .await; +} + +/// Main event loop: reads incoming `_XCT_*` messages and dispatches them until +/// `_XCT_didFinishExecutingTestPlan` or `timeout` elapses. +pub(super) async fn run_dispatch_loop( + driver_channel: &mut OwnedChannel>, + xctest_config: &XCTestConfiguration, + listener: &mut L, + timeout: Option, +) -> Result<(), IdeviceError> { + let deadline = timeout.map(|t| std::time::Instant::now() + t); + let mut done = false; + + loop { + let remaining = if let Some(dl) = deadline { + let r = dl + .checked_duration_since(std::time::Instant::now()) + .ok_or_else(|| IdeviceError::XcTestTimeout(timeout.unwrap().as_secs_f64()))?; + Some(r) + } else { + None + }; + + let msg = match remaining { + Some(r) => driver_channel.read_message_timeout(r).await?, + None => driver_channel.read_message().await?, + }; + + let method = match &msg.data { + Some(Value::String(s)) => s.clone(), + None => continue, // heartbeat / empty + _ => { + warn!("Non-string message data on XCTest channel"); + continue; + } + }; + + let aux = msg.aux.as_ref().map(|a| a.values.as_slice()).unwrap_or(&[]); + + let msg_id = msg.message_header.identifier(); + let conversation_index = msg.message_header.conversation_index(); + let reply_opt = + dispatch_xct_message(&method, aux, xctest_config, listener, &mut done).await?; + + if msg.message_header.expects_reply() { + match reply_opt { + Some(reply_bytes) => { + driver_channel + .send_raw_reply_for(msg_id, conversation_index, &reply_bytes) + .await?; + } + None => { + driver_channel + .send_raw_reply_for(msg_id, conversation_index, &[]) + .await?; + } + } + } + + if done { + return Ok(()); + } + } +} + +/// Mirrors pymobiledevice3's "test done vs disconnect" race. +/// +/// Once the test plan has started, the runner may terminate its own DTX +/// connection before `_XCT_didFinishExecutingTestPlan` is delivered. In that +/// case we surface `TestRunnerDisconnected` rather than hanging until timeout. +async fn run_dispatch_loop_until_done_or_disconnect( + main_client: &mut RemoteServerClient>, + mut driver_channel: OwnedChannel>, + xctest_config: &XCTestConfiguration, + listener: &mut L, + timeout: Option, +) -> Result<(), IdeviceError> { + let disconnected = main_client.disconnect_waiter(); + tokio::pin!(disconnected); + + tokio::select! { + result = run_dispatch_loop(&mut driver_channel, xctest_config, listener, timeout) => result, + _ = &mut disconnected => Err(IdeviceError::TestRunnerDisconnected), + } +} + +// --------------------------------------------------------------------------- +// XCUITestService +// --------------------------------------------------------------------------- + +/// High-level service that orchestrates an XCTest (or WDA) run end-to-end. +/// +/// # Example +/// ```rust,no_run +/// # #[cfg(feature = "xctest")] +/// # async fn example() -> Result<(), idevice::IdeviceError> { +/// // let svc = XCUITestService::new(provider); +/// // svc.run(cfg, &mut listener, None).await?; +/// # Ok(()) +/// # } +/// ``` +pub struct XCUITestService { + provider: Arc, +} + +#[cfg(feature = "wda")] +#[derive(Debug)] +pub struct WdaRunHandle { + task: JoinHandle>, + ports: WdaPorts, + status: JsonValue, +} + +#[cfg(feature = "wda")] +#[derive(Debug)] +pub struct WdaBridgedRunHandle { + runner: WdaRunHandle, + bridge: WdaBridge, +} + +#[cfg(feature = "wda")] +impl WdaRunHandle { + /// Returns the device-side ports used by the running WDA instance. + pub fn ports(&self) -> WdaPorts { + self.ports + } + + /// Returns the `/status` payload observed when WDA became reachable. + pub fn status(&self) -> &JsonValue { + &self.status + } + + /// Waits for the underlying xctrunner task to complete. + pub async fn wait(self) -> Result<(), IdeviceError> { + match self.task.await { + Ok(result) => result, + Err(error) => Err(IdeviceError::UnknownErrorType(format!( + "wda runner task join failed: {error}" + ))), + } + } + + /// Aborts the underlying xctrunner task. + pub fn abort(&self) { + self.task.abort(); + } +} + +#[cfg(feature = "wda")] +impl WdaBridgedRunHandle { + /// Returns the localhost bridge for this WDA runner. + pub fn bridge(&self) -> &WdaBridge { + &self.bridge + } + + /// Returns the device-side ports used by the running WDA instance. + pub fn ports(&self) -> WdaPorts { + self.runner.ports() + } + + /// Returns the `/status` payload observed when WDA became reachable. + pub fn status(&self) -> &JsonValue { + self.runner.status() + } + + /// Returns the localhost WDA HTTP URL. + pub fn wda_url(&self) -> &str { + self.bridge.wda_url() + } + + /// Returns the localhost MJPEG URL. + pub fn mjpeg_url(&self) -> &str { + self.bridge.mjpeg_url() + } + + /// Waits for the underlying xctrunner task to complete. + pub async fn wait(self) -> Result<(), IdeviceError> { + self.runner.wait().await + } + + /// Aborts the underlying xctrunner task. + pub fn abort(&self) { + self.runner.abort(); + } +} + +#[cfg(feature = "wda")] +struct NoopXCTestListener; + +#[cfg(feature = "wda")] +impl XCUITestListener for NoopXCTestListener {} + +impl std::fmt::Debug for XCUITestService { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("XCUITestService") + .field("provider", &"") + .finish() + } +} + +impl XCUITestService { + /// Creates a new `XCUITestService` backed by the given provider. + pub fn new(provider: Arc) -> Self { + Self { provider } + } + + /// Runs the XCTest bundle described by `cfg` to completion. + /// + /// # Arguments + /// * `cfg` - Test configuration (runner/target app info, filters, …) + /// * `listener` - Receives lifecycle events as the test runs + /// * `timeout` - Optional wall-clock timeout from when the test plan starts + /// + /// # Errors + /// * `IdeviceError::XcTestTimeout` if `timeout` elapses + /// * `IdeviceError::TestRunnerTimeout` if the runner does not open + /// `XCTestDriverInterface` within 30 seconds + pub async fn run( + &self, + cfg: TestConfig, + listener: &mut L, + timeout: Option, + ) -> Result<(), IdeviceError> { + // 1. Session UUID + config path + let session_id = uuid::Uuid::new_v4(); + let xctest_path = format!( + "/tmp/{}.xctestconfiguration", + session_id.to_string().to_uppercase() + ); + + // 2. iOS major version (needed for service selection and launch env) + let ios_major_version: u8 = { + let mut lockdown = LockdownClient::connect(&*self.provider).await?; + lockdown + .start_session(&self.provider.get_pairing_file().await?) + .await?; + let ver = lockdown.get_value(Some("ProductVersion"), None).await?; + ver.as_string() + .and_then(|s| s.split('.').next()) + .and_then(|s| s.parse().ok()) + .unwrap_or(16u8) + }; + + // 3. Build XCTestConfiguration + let xctest_config = cfg.build_xctest_configuration(session_id, ios_major_version)?; + + // 4. Connect to testmanagerd (ctrl + main) and DVT + let mut conns = connect_testmanagerd(&*self.provider, ios_major_version).await?; + let mut ctrl_proxy = TestManagerProxy::open(&mut conns.ctrl, ios_major_version).await?; + let mut main_proxy = TestManagerProxy::open(&mut conns.main, ios_major_version).await?; + let mut process_control = XCTestProcessControlChannel::open(&mut conns.dvt).await?; + + let config_name = cfg.config_name().to_owned(); + initialize_testmanager_sessions(&mut ctrl_proxy, &mut main_proxy, &xctest_config).await?; + register_early_driver_channel_handler(&mut conns.main, &xctest_config).await; + initialize_testmanager_daemon_sessions( + &mut ctrl_proxy, + &mut main_proxy, + ios_major_version, + &session_id, + &xctest_config, + ) + .await?; + + // Build launch environment from the config + let (launch_args, launch_env, launch_options) = build_launch_env( + ios_major_version, + &session_id, + &cfg.runner_app_path, + &cfg.runner_app_container, + &config_name, + &xctest_path, + cfg.runner_env.as_ref(), + cfg.runner_args.as_deref(), + ); + + let _pid = launch_and_authorize_test_runner( + &mut ctrl_proxy, + &mut process_control, + ios_major_version, + &cfg.runner_bundle_id, + launch_args, + launch_env, + launch_options, + ) + .await?; + + // 6-7. Wait for driver channel and start the test plan. + let driver_channel = start_test_plan_session(&mut conns.main, &mut main_proxy).await?; + + // 8. Dispatch loop, raced against the runner connection dropping. + run_dispatch_loop_until_done_or_disconnect( + &mut conns.main, + driver_channel, + &xctest_config, + listener, + timeout, + ) + .await?; + + Ok(()) + } + + /// Starts an XCTest runner intended to host WebDriverAgent and waits + /// until WDA responds on its device-side HTTP port. + /// + /// The xctrunner orchestration continues on a background task. This is + /// designed for automation use cases where callers want a durable WDA + /// session instead of waiting for the XCTest plan to terminate. + /// + /// Readiness detection currently uses a simple polling loop against + /// `WdaClient::status()`. This is intentionally conservative bootstrap + /// behavior for now; large-scale orchestration should still stagger or + /// back off parallel startup attempts at a higher layer. + #[cfg(feature = "wda")] + pub async fn run_until_wda_ready( + &self, + cfg: TestConfig, + readiness_timeout: std::time::Duration, + ) -> Result { + let provider = self.provider.clone(); + let runner_cfg = cfg.clone(); + let task = tokio::spawn(async move { + let service = XCUITestService::new(provider); + let mut listener = NoopXCTestListener; + service.run(runner_cfg, &mut listener, None).await + }); + + let wda = WdaClient::new(&*self.provider); + let deadline = std::time::Instant::now() + readiness_timeout; + let poll_interval = std::time::Duration::from_millis(250); + + let status = loop { + if task.is_finished() { + let result = match task.await { + Ok(result) => result, + Err(error) => { + return Err(IdeviceError::UnknownErrorType(format!( + "wda runner task join failed: {error}" + ))); + } + }; + result?; + return Err(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )); + } + + match wda.status().await { + Ok(status) => break status, + Err(_) if std::time::Instant::now() < deadline => { + tokio::time::sleep(poll_interval).await; + } + Err(error) => { + task.abort(); + return Err(error); + } + } + }; + + Ok(WdaRunHandle { + task, + ports: wda.ports(), + status, + }) + } + + /// Starts an XCTest-hosted WDA runner, waits until WDA is reachable, and + /// exposes localhost URLs suitable for GUI/web consumers. + #[cfg(feature = "wda")] + pub async fn run_until_wda_ready_with_bridge( + &self, + cfg: TestConfig, + readiness_timeout: std::time::Duration, + ) -> Result { + let runner = self.run_until_wda_ready(cfg, readiness_timeout).await?; + let bridge = WdaBridge::start_with_ports(self.provider.clone(), runner.ports()).await?; + Ok(WdaBridgedRunHandle { runner, bridge }) + } +} diff --git a/idevice/src/services/dvt/xctest/types.rs b/idevice/src/services/dvt/xctest/types.rs new file mode 100644 index 0000000..2dd2a2c --- /dev/null +++ b/idevice/src/services/dvt/xctest/types.rs @@ -0,0 +1,811 @@ +//! NSKeyedArchive type proxies for the XCTest protocol. +//! +//! These types are exchanged as NSKeyedArchive-encoded plists between the IDE +//! and the on-device testmanagerd / test runner. They are distinct from the +//! DTX protocol itself and live here because they are XCTest-specific payloads. +//! +//! Types that are only ever *received* from the runner implement only decode +//! logic. [`XCTestConfiguration`] and [`XCTCapabilities`] must also be +//! encoded because the IDE sends them to the runner. +// Jackson Coxson + +use plist::{Dictionary, Uid, Value}; +use uuid::Uuid; + +use crate::IdeviceError; + +// --------------------------------------------------------------------------- +// Internal NSKeyedArchive encoder +// --------------------------------------------------------------------------- + +/// Builds an NSKeyedArchive `$objects` array incrementally. +/// +/// Each `encode_*` method appends one or more objects and returns the `Uid` +/// (index into `$objects`) of the newly added top-level entry. Call +/// [`ArchiveBuilder::finish`] to wrap the objects array into the complete +/// NSKeyedArchive plist dict. +struct ArchiveBuilder { + objects: Vec, +} + +impl ArchiveBuilder { + fn new() -> Self { + // $objects[0] is always the special "$null" sentinel + Self { + objects: vec![Value::String("$null".into())], + } + } + + /// Returns the UID that represents a null / missing value. + fn null_uid() -> Uid { + Uid::new(0) + } + + /// Appends `v` to `$objects` and returns its index as a `Uid`. + fn push(&mut self, v: Value) -> Uid { + self.objects.push(v); + Uid::new(self.objects.len() as u64 - 1) + } + + /// Returns the UID of the class-info dict for `class_name`, creating it if + /// it does not already exist in `$objects`. + fn get_or_create_class(&mut self, class_name: &str, superclasses: &[&str]) -> Uid { + // Reuse an existing class dict if present + for (i, obj) in self.objects.iter().enumerate() { + if let Some(d) = obj.as_dictionary() + && d.get("$classname").and_then(|v| v.as_string()) == Some(class_name) + { + return Uid::new(i as u64); + } + } + let mut classes: Vec = std::iter::once(class_name) + .chain(superclasses.iter().copied()) + .map(|s| Value::String(s.into())) + .collect(); + // Ensure NSObject is always the last entry + if classes.last().and_then(|v| v.as_string()) != Some("NSObject") { + classes.push(Value::String("NSObject".into())); + } + let mut d = Dictionary::new(); + d.insert("$classname".into(), Value::String(class_name.into())); + d.insert("$classes".into(), Value::Array(classes)); + self.push(Value::Dictionary(d)) + } + + /// Encodes a `&str` as a plain NSString entry. + fn encode_str(&mut self, s: &str) -> Uid { + self.push(Value::String(s.into())) + } + + /// Encodes an `Option<&str>`: `None` maps to `UID(0)` (null). + fn encode_opt_str(&mut self, s: Option<&str>) -> Uid { + match s { + Some(s) => self.encode_str(s), + None => Self::null_uid(), + } + } + + /// Encodes a boolean inline (not a UID reference). + /// + /// In NSKeyedArchive, scalar booleans are stored directly in the object + /// dict, not as a UID reference into `$objects`. + fn bool_value(b: bool) -> Value { + Value::Boolean(b) + } + + /// Encodes an integer inline. + fn int_value(i: u64) -> Value { + Value::Integer(i.into()) + } + + /// Encodes a `plist::Dictionary` as an `NSDictionary` object. + fn encode_nsdict(&mut self, dict: &Dictionary) -> Uid { + // Collect pairs first to avoid simultaneous borrow of self + let pairs: Vec<(String, Value)> = + dict.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); + + let mut key_uids = Vec::with_capacity(pairs.len()); + let mut val_uids = Vec::with_capacity(pairs.len()); + for (k, v) in pairs { + let k_uid = self.encode_str(&k); + let v_uid = self.encode_value(v); + key_uids.push(Value::Uid(k_uid)); + val_uids.push(Value::Uid(v_uid)); + } + + let class_uid = self.get_or_create_class("NSDictionary", &[]); + let mut d = Dictionary::new(); + d.insert("$class".into(), Value::Uid(class_uid)); + d.insert("NS.keys".into(), Value::Array(key_uids)); + d.insert("NS.objects".into(), Value::Array(val_uids)); + self.push(Value::Dictionary(d)) + } + + /// Encodes a slice of `Value` as an `NSArray` object. + fn encode_nsarray(&mut self, items: &[Value]) -> Uid { + let items = items.to_vec(); + let mut obj_uids = Vec::with_capacity(items.len()); + for v in items { + let uid = self.encode_value(v); + obj_uids.push(Value::Uid(uid)); + } + let class_uid = self.get_or_create_class("NSArray", &[]); + let mut d = Dictionary::new(); + d.insert("$class".into(), Value::Uid(class_uid)); + d.insert("NS.objects".into(), Value::Array(obj_uids)); + self.push(Value::Dictionary(d)) + } + + /// Encodes an `NSURL` with a single relative string component. + fn encode_nsurl(&mut self, url: &str) -> Uid { + let str_uid = self.encode_str(url); + let class_uid = self.get_or_create_class("NSURL", &[]); + let mut d = Dictionary::new(); + d.insert("$class".into(), Value::Uid(class_uid)); + d.insert("NS.relative".into(), Value::Uid(str_uid)); + d.insert("NS.base".into(), Value::Uid(Self::null_uid())); + self.push(Value::Dictionary(d)) + } + + /// Encodes an `NSUUID` from its raw 16-byte representation. + fn encode_nsuuid(&mut self, uuid: &Uuid) -> Uid { + let bytes = uuid.as_bytes().to_vec(); + let class_uid = self.get_or_create_class("NSUUID", &[]); + let mut d = Dictionary::new(); + d.insert("$class".into(), Value::Uid(class_uid)); + d.insert("NS.uuidbytes".into(), Value::Data(bytes)); + self.push(Value::Dictionary(d)) + } + + /// Dispatches a generic `plist::Value` to the appropriate encoder. + fn encode_value(&mut self, v: Value) -> Uid { + match v { + Value::Boolean(b) => self.push(Value::Boolean(b)), + Value::Integer(i) => self.push(Value::Integer(i)), + Value::Real(f) => self.push(Value::Real(f)), + Value::String(s) => self.encode_str(&s), + Value::Data(d) => self.push(Value::Data(d)), + Value::Array(arr) => { + let items = arr.clone(); + self.encode_nsarray(&items) + } + Value::Dictionary(d) => { + let dict = d.clone(); + self.encode_nsdict(&dict) + } + // Unknown types map to $null + _ => Self::null_uid(), + } + } + + /// Wraps `$objects` into a complete NSKeyedArchive plist dict. + fn finish(self, root_uid: Uid) -> Value { + let mut top = Dictionary::new(); + top.insert("root".into(), Value::Uid(root_uid)); + + let mut root = Dictionary::new(); + root.insert("$archiver".into(), Value::String("NSKeyedArchiver".into())); + root.insert("$version".into(), Value::Integer(100000u64.into())); + root.insert("$top".into(), Value::Dictionary(top)); + root.insert("$objects".into(), Value::Array(self.objects)); + Value::Dictionary(root) + } +} + +/// Serialises an NSKeyedArchive `Value` to binary plist bytes. +fn archive_to_bytes(archive: Value) -> Result, IdeviceError> { + let buf = Vec::new(); + let mut writer = std::io::BufWriter::new(buf); + plist::to_writer_binary(&mut writer, &archive).map_err(|e| { + tracing::warn!("Failed to serialise NSKeyedArchive: {e}"); + IdeviceError::UnexpectedResponse("failed to serialize NSKeyedArchive".into()) + })?; + Ok(writer.into_inner().unwrap()) +} + +/// Serialises an `NSUUID` object to NSKeyedArchive bytes. +pub(crate) fn archive_nsuuid_to_bytes(uuid: &Uuid) -> Result, IdeviceError> { + let mut class = Dictionary::new(); + class.insert( + "$classes".into(), + Value::Array(vec![Value::String("NSUUID".into())]), + ); + class.insert("$classname".into(), Value::String("NSUUID".into())); + + let mut obj = Dictionary::new(); + obj.insert("$class".into(), Value::Uid(Uid::new(2))); + obj.insert("NS.uuidbytes".into(), Value::Data(uuid.as_bytes().to_vec())); + + let mut top = Dictionary::new(); + top.insert("root".into(), Value::Uid(Uid::new(1))); + + let mut archive = Dictionary::new(); + archive.insert("$archiver".into(), Value::String("NSKeyedArchiver".into())); + archive.insert( + "$objects".into(), + Value::Array(vec![ + Value::String("$null".into()), + Value::Dictionary(obj), + Value::Dictionary(class), + ]), + ); + archive.insert("$top".into(), Value::Dictionary(top)); + archive.insert("$version".into(), Value::Integer(100000u64.into())); + + archive_to_bytes(Value::Dictionary(archive)) +} + +/// Serialises an `XCTCapabilities` object to NSKeyedArchive bytes matching +/// pymobiledevice3's simple `encode_archive` layout. +pub(crate) fn archive_xct_capabilities_to_bytes( + capabilities: &XCTCapabilities, +) -> Result, IdeviceError> { + let mut objects = vec![Value::String("$null".into())]; + + let root_uid = Uid::new(1); + let xct_caps_class_uid = Uid::new(2); + let dict_uid = Uid::new(3); + let nsdict_class_uid = Uid::new(4); + + let mut root = Dictionary::new(); + root.insert("$class".into(), Value::Uid(xct_caps_class_uid)); + root.insert("capabilities-dictionary".into(), Value::Uid(dict_uid)); + objects.push(Value::Dictionary(root)); + + let mut xct_caps_class = Dictionary::new(); + xct_caps_class.insert( + "$classes".into(), + Value::Array(vec![Value::String("XCTCapabilities".into())]), + ); + xct_caps_class.insert("$classname".into(), Value::String("XCTCapabilities".into())); + objects.push(Value::Dictionary(xct_caps_class)); + + let key_base = 5u64; + let mut key_uids = Vec::with_capacity(capabilities.capabilities.len()); + let mut value_uids = Vec::with_capacity(capabilities.capabilities.len()); + + for (idx, (key, value)) in capabilities.capabilities.iter().enumerate() { + let key_uid = Uid::new(key_base + (idx as u64 * 2)); + let value_uid = Uid::new(key_base + (idx as u64 * 2) + 1); + key_uids.push(Value::Uid(key_uid)); + value_uids.push(Value::Uid(value_uid)); + objects.push(Value::String(key.clone())); + objects.push(value.clone()); + } + + let mut dict = Dictionary::new(); + dict.insert("$class".into(), Value::Uid(nsdict_class_uid)); + dict.insert("NS.keys".into(), Value::Array(key_uids)); + dict.insert("NS.objects".into(), Value::Array(value_uids)); + objects.insert(dict_uid.get() as usize, Value::Dictionary(dict)); + + let mut nsdict_class = Dictionary::new(); + nsdict_class.insert( + "$classes".into(), + Value::Array(vec![Value::String("NSDictionary".into())]), + ); + nsdict_class.insert("$classname".into(), Value::String("NSDictionary".into())); + objects.insert( + nsdict_class_uid.get() as usize, + Value::Dictionary(nsdict_class), + ); + + let mut top = Dictionary::new(); + top.insert("root".into(), Value::Uid(root_uid)); + + let mut archive = Dictionary::new(); + archive.insert("$archiver".into(), Value::String("NSKeyedArchiver".into())); + archive.insert("$objects".into(), Value::Array(objects)); + archive.insert("$top".into(), Value::Dictionary(top)); + archive.insert("$version".into(), Value::Integer(100000u64.into())); + + archive_to_bytes(Value::Dictionary(archive)) +} + +// --------------------------------------------------------------------------- +// XCTCapabilities +// --------------------------------------------------------------------------- + +/// Proxy for `XCTCapabilities` — a dictionary wrapper negotiated between the +/// IDE and testmanagerd during session initialisation. +/// +/// The default instance carries the set of capabilities that a modern Xcode +/// IDE advertises. +#[derive(Debug, Clone)] +pub struct XCTCapabilities { + /// The inner `capabilities-dictionary` exchanged with the daemon. + pub capabilities: Dictionary, +} + +impl XCTCapabilities { + /// Creates an empty `XCTCapabilities`. + pub fn empty() -> Self { + Self { + capabilities: Dictionary::new(), + } + } + + /// Returns the default IDE capabilities advertised to testmanagerd. + /// + /// These match the values sent by a recent Xcode release and must be + /// present for the modern DDI protocol variant to work correctly. + pub fn ide_defaults() -> Self { + let caps = crate::plist!(dict { + "expected failure test capability": true, + "test case run configurations": true, + "test timeout capability": true, + "test iterations": true, + "request diagnostics for specific devices": true, + "delayed attachment transfer": true, + "skipped test capability": true, + "daemon container sandbox extension": true, + "ubiquitous test identifiers": true, + "XCTIssue capability": true, + }); + Self { capabilities: caps } + } + + /// Decodes an `XCTCapabilities` from the `plist::Value` received in a DTX + /// message payload (already decoded from NSKeyedArchive by the message + /// layer). + /// + /// # Errors + /// Returns `None` if the value does not contain a + /// `"capabilities-dictionary"` key. + pub fn from_plist(v: &Value) -> Option { + let dict = v.as_dictionary()?; + let caps = dict + .get("capabilities-dictionary") + .and_then(|v| v.as_dictionary()) + .cloned() + .unwrap_or_default(); + Some(Self { capabilities: caps }) + } + + /// Converts to a `plist::Value` dict with the `capabilities-dictionary` wrapper. + /// + /// This is the form expected by `_IDE_initiateControlSessionWithCapabilities:` and + /// `_IDE_initiateSessionWithIdentifier:capabilities:`. + pub fn to_plist_value(&self) -> Value { + let mut d = Dictionary::new(); + d.insert( + "capabilities-dictionary".into(), + Value::Dictionary(self.capabilities.clone()), + ); + Value::Dictionary(d) + } + + /// Encodes this `XCTCapabilities` into the provided [`ArchiveBuilder`] and + /// returns the `Uid` of the resulting object entry. + fn encode_with_builder(&self, builder: &mut ArchiveBuilder) -> Uid { + let dict_uid = builder.encode_nsdict(&self.capabilities); + let class_uid = builder.get_or_create_class("XCTCapabilities", &[]); + let mut obj = Dictionary::new(); + obj.insert("$class".into(), Value::Uid(class_uid)); + obj.insert("capabilities-dictionary".into(), Value::Uid(dict_uid)); + builder.push(Value::Dictionary(obj)) + } +} + +// --------------------------------------------------------------------------- +// XCTestConfiguration +// --------------------------------------------------------------------------- + +/// Launch configuration for an XCTest runner bundle. +/// +/// Built from [`TestConfig`](super::TestConfig) and serialised as an +/// NSKeyedArchive plist that is written to the device before the runner +/// process is launched. +/// +/// All fields mirror the Objective-C `XCTestConfiguration` class. +#[derive(Debug, Clone)] +pub struct XCTestConfiguration { + // --- required fields (no defaults) ------------------------------------ + /// `file://` URL pointing to the `.xctest` bundle inside the app container. + pub test_bundle_url: String, + /// UUID that uniquely identifies this test session. + pub session_identifier: Uuid, + + // --- fields with per-run overrides ------------------------------------ + /// Module name used when `productModuleName` differs from the default. + pub product_module_name: String, + /// Path to the `XCTAutomationSupport.framework`. + pub automation_framework_path: String, + + /// Bundle ID of the target application under test (optional). + pub target_application_bundle_id: Option, + /// On-device path of the target application bundle (optional). + pub target_application_path: Option, + /// Environment variables forwarded to the target app (optional). + pub target_application_environment: Option, + /// Launch arguments forwarded to the target app. + pub target_application_arguments: Vec, + + /// Test identifiers to run; `None` means run all. + pub tests_to_run: Option>, + /// Test identifiers to skip; `None` means skip none. + pub tests_to_skip: Option>, + + // --- fixed defaults --------------------------------------------------- + /// IDE capabilities sent along with the configuration. + pub ide_capabilities: XCTCapabilities, +} + +impl XCTestConfiguration { + /// Serialises this configuration as binary NSKeyedArchive bytes. + /// + /// The resulting bytes are written to `/tmp/.xctestconfiguration` + /// on the device via AFC before the runner process is launched. + /// + /// # Errors + /// Returns [`IdeviceError::UnexpectedResponse`] if plist serialisation + /// fails (should not happen under normal circumstances). + pub fn to_archive_bytes(&self) -> Result, IdeviceError> { + let mut b = ArchiveBuilder::new(); + + // --- nested objects ----------------------------------------------- + + let caps_uid = self.ide_capabilities.encode_with_builder(&mut b); + let bundle_url_uid = b.encode_nsurl(&self.test_bundle_url); + let session_uid = b.encode_nsuuid(&self.session_identifier); + let automation_path_uid = b.encode_str(&self.automation_framework_path); + let product_module_uid = b.encode_str(&self.product_module_name); + + // aggregateStatisticsBeforeCrash: {"XCSuiteRecordsKey": {}} + let mut agg_stats = Dictionary::new(); + agg_stats.insert( + "XCSuiteRecordsKey".into(), + Value::Dictionary(Dictionary::new()), + ); + let agg_stats_uid = b.encode_nsdict(&agg_stats); + + // targetApplicationPath — default placeholder keeps field non-empty + let target_app_path_uid = b.encode_opt_str( + self.target_application_path + .as_deref() + .or(Some("/whatever-it-does-not-matter/but-should-not-be-empty")), + ); + + let target_bundle_uid = b.encode_opt_str(self.target_application_bundle_id.as_deref()); + + let target_env_uid = match &self.target_application_environment { + Some(env) => b.encode_nsdict(env), + None => ArchiveBuilder::null_uid(), + }; + + let target_args_uid = b.encode_nsarray( + &self + .target_application_arguments + .iter() + .map(|s| Value::String(s.clone())) + .collect::>(), + ); + + let tests_to_run_uid = match &self.tests_to_run { + Some(t) => { + let items: Vec = t.iter().map(|s| Value::String(s.clone())).collect(); + b.encode_nsarray(&items) + } + None => ArchiveBuilder::null_uid(), + }; + + let tests_to_skip_uid = match &self.tests_to_skip { + Some(t) => { + let items: Vec = t.iter().map(|s| Value::String(s.clone())).collect(); + b.encode_nsarray(&items) + } + None => ArchiveBuilder::null_uid(), + }; + + let format_version_uid = b.push(Value::Integer(2u64.into())); + + // --- XCTestConfiguration object dict ------------------------------ + + let class_uid = b.get_or_create_class("XCTestConfiguration", &[]); + + let mut obj = Dictionary::new(); + + // Nested-object fields (stored as UID references) + obj.insert("$class".into(), Value::Uid(class_uid)); + obj.insert( + "aggregateStatisticsBeforeCrash".into(), + Value::Uid(agg_stats_uid), + ); + obj.insert( + "automationFrameworkPath".into(), + Value::Uid(automation_path_uid), + ); + obj.insert("IDECapabilities".into(), Value::Uid(caps_uid)); + obj.insert("productModuleName".into(), Value::Uid(product_module_uid)); + obj.insert( + "targetApplicationArguments".into(), + Value::Uid(target_args_uid), + ); + obj.insert( + "targetApplicationBundleID".into(), + Value::Uid(target_bundle_uid), + ); + obj.insert( + "targetApplicationEnvironment".into(), + Value::Uid(target_env_uid), + ); + obj.insert( + "targetApplicationPath".into(), + Value::Uid(target_app_path_uid), + ); + obj.insert("testBundleURL".into(), Value::Uid(bundle_url_uid)); + obj.insert("sessionIdentifier".into(), Value::Uid(session_uid)); + obj.insert("testsToRun".into(), Value::Uid(tests_to_run_uid)); + obj.insert("testsToSkip".into(), Value::Uid(tests_to_skip_uid)); + obj.insert("formatVersion".into(), Value::Uid(format_version_uid)); + + // testApplicationDependencies: {} (empty NSDictionary, not null) + let test_app_deps_uid = b.encode_nsdict(&Dictionary::new()); + obj.insert( + "testApplicationDependencies".into(), + Value::Uid(test_app_deps_uid), + ); + + // Null-valued optional fields + for key in &[ + "baselineFileRelativePath", + "baselineFileURL", + "defaultTestExecutionTimeAllowance", + "maximumTestExecutionTimeAllowance", + "randomExecutionOrderingSeed", + "testApplicationUserOverrides", + "testBundleRelativePath", + ] { + obj.insert((*key).into(), Value::Uid(ArchiveBuilder::null_uid())); + } + + // Inline boolean fields + obj.insert( + "disablePerformanceMetrics".into(), + ArchiveBuilder::bool_value(false), + ); + obj.insert("emitOSLogs".into(), ArchiveBuilder::bool_value(false)); + obj.insert( + "gatherLocalizableStringsData".into(), + ArchiveBuilder::bool_value(false), + ); + obj.insert( + "initializeForUITesting".into(), + ArchiveBuilder::bool_value(true), + ); + obj.insert("reportActivities".into(), ArchiveBuilder::bool_value(true)); + obj.insert( + "reportResultsToIDE".into(), + ArchiveBuilder::bool_value(true), + ); + obj.insert( + "testTimeoutsEnabled".into(), + ArchiveBuilder::bool_value(false), + ); + obj.insert("testsDrivenByIDE".into(), ArchiveBuilder::bool_value(false)); + obj.insert( + "testsMustRunOnMainThread".into(), + ArchiveBuilder::bool_value(true), + ); + obj.insert( + "treatMissingBaselinesAsFailures".into(), + ArchiveBuilder::bool_value(false), + ); + + // Inline integer fields + obj.insert( + "systemAttachmentLifetime".into(), + ArchiveBuilder::int_value(2), + ); + obj.insert("testExecutionOrdering".into(), ArchiveBuilder::int_value(0)); + obj.insert( + "userAttachmentLifetime".into(), + ArchiveBuilder::int_value(0), + ); + obj.insert( + "preferredScreenCaptureFormat".into(), + ArchiveBuilder::int_value(2), + ); + + let config_uid = b.push(Value::Dictionary(obj)); + let archive = b.finish(config_uid); + archive_to_bytes(archive) + } +} + +// --------------------------------------------------------------------------- +// Runtime decode types (received from the runner, decode only) +// --------------------------------------------------------------------------- + +/// Decoded proxy for `XCTTestIdentifier`. +/// +/// `components` is the ordered list of name parts, e.g. +/// `["UITests", "testLogin"]`. Use [`test_class`](Self::test_class) and +/// [`test_method`](Self::test_method) as named accessors. +#[derive(Debug, Clone)] +pub struct XCTTestIdentifier { + /// Ordered name components. + pub components: Vec, +} + +impl XCTTestIdentifier { + /// Returns the test class name (first component). + pub fn test_class(&self) -> &str { + self.components.first().map(|s| s.as_str()).unwrap_or("") + } + + /// Returns the test method name (second component), if present. + pub fn test_method(&self) -> Option<&str> { + self.components.get(1).map(|s| s.as_str()) + } + + /// Decodes from a `plist::Value` received in a DTX payload. + /// + /// The value is expected to be a dictionary with a `"c"` key containing + /// an array of component strings. + pub fn from_plist(v: &Value) -> Option { + let dict = v.as_dictionary()?; + let components = dict + .get("c") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_string().map(|s| s.to_owned())) + .collect() + }) + .unwrap_or_default(); + Some(Self { components }) + } +} + +/// Decoded proxy for `XCTSourceCodeLocation`. +#[derive(Debug, Clone)] +pub struct XCTSourceCodeLocation { + /// `file://` URL string of the source file, or `None` if absent. + pub file_url: Option, + /// Line number within the source file. + pub line_number: u64, +} + +impl XCTSourceCodeLocation { + /// Returns the local file path, stripping the `file://` prefix if present. + pub fn file_path(&self) -> Option<&str> { + self.file_url + .as_deref() + .map(|u| u.strip_prefix("file://").unwrap_or(u)) + } + + /// Decodes from a `plist::Value` dict. + pub fn from_plist(v: &Value) -> Option { + let dict = v.as_dictionary()?; + // `file-url` arrives as an NSURL object: {"NS.relative": "file://...", ...}. + // Handle both the nested dict form and a plain string as a fallback. + let file_url = dict.get("file-url").and_then(|v| { + v.as_dictionary() + .and_then(|d| d.get("NS.relative")) + .and_then(|v| v.as_string()) + .map(|s| s.to_owned()) + .or_else(|| v.as_string().map(|s| s.to_owned())) + }); + let line_number = dict + .get("line-number") + .and_then(|v| v.as_unsigned_integer()) + .unwrap_or(0); + Some(Self { + file_url, + line_number, + }) + } +} + +/// Decoded proxy for `XCTSourceCodeContext`. +#[derive(Debug, Clone)] +pub struct XCTSourceCodeContext { + /// Source location, if available. + pub location: Option, +} + +impl XCTSourceCodeContext { + /// Decodes from a `plist::Value` dict. + pub fn from_plist(v: &Value) -> Option { + let dict = v.as_dictionary()?; + let location = dict + .get("location") + .and_then(XCTSourceCodeLocation::from_plist); + Some(Self { location }) + } +} + +/// Decoded proxy for `XCTIssue` / `XCTMutableIssue`. +/// +/// `compact_description` is the short human-readable failure message +/// (e.g. `"((false) is true) failed"`). +#[derive(Debug, Clone)] +pub struct XCTIssue { + /// Short failure description. + pub compact_description: String, + /// Detailed description, if available. + pub detailed_description: Option, + /// Source location context, if available. + pub source_code_context: Option, + /// Issue type code. + pub issue_type: i64, +} + +impl XCTIssue { + /// Decodes from a `plist::Value` dict. + pub fn from_plist(v: &Value) -> Option { + let dict = v.as_dictionary()?; + let compact = dict + .get("compact-description") + .and_then(|v| v.as_string()) + .unwrap_or("") + .to_owned(); + let detailed = dict + .get("detailed-description") + .and_then(|v| v.as_string()) + .map(|s| s.to_owned()); + let ctx = dict + .get("source-code-context") + .and_then(XCTSourceCodeContext::from_plist); + let issue_type = dict + .get("type") + .and_then(|v| v.as_signed_integer()) + .unwrap_or(0); + Some(Self { + compact_description: compact, + detailed_description: detailed, + source_code_context: ctx, + issue_type, + }) + } +} + +/// Decoded proxy for `XCActivityRecord` — a single activity step in a test. +#[derive(Debug, Clone)] +pub struct XCActivityRecord { + /// Human-readable title of the activity. + pub title: String, + /// Activity type string. + pub activity_type: String, +} + +impl XCActivityRecord { + /// Decodes from a `plist::Value` dict. + pub fn from_plist(v: &Value) -> Option { + let dict = v.as_dictionary()?; + let title = dict + .get("title") + .and_then(|v| v.as_string()) + .unwrap_or("") + .to_owned(); + let activity_type = dict + .get("activityType") + .and_then(|v| v.as_string()) + .unwrap_or("") + .to_owned(); + Some(Self { + title, + activity_type, + }) + } +} + +/// Decoded proxy for `XCTestCaseRunConfiguration`. +#[derive(Debug, Clone, Copy)] +pub struct XCTestCaseRunConfiguration { + /// Iteration index (1-based) when tests are repeated. + pub iteration: u64, +} + +impl XCTestCaseRunConfiguration { + /// Decodes from a `plist::Value` dict. + pub fn from_plist(v: &Value) -> Option { + let dict = v.as_dictionary()?; + let iteration = dict + .get("iteration") + .and_then(|v| v.as_unsigned_integer()) + .unwrap_or(1); + Some(Self { iteration }) + } +} diff --git a/idevice/src/services/mod.rs b/idevice/src/services/mod.rs index 802b837..30aef16 100644 --- a/idevice/src/services/mod.rs +++ b/idevice/src/services/mod.rs @@ -55,3 +55,7 @@ pub mod simulate_location; pub mod springboardservices; #[cfg(feature = "syslog_relay")] pub mod syslog_relay; +#[cfg(feature = "wda")] +pub mod wda; +#[cfg(feature = "wda")] +pub mod wda_bridge; diff --git a/idevice/src/services/wda.rs b/idevice/src/services/wda.rs new file mode 100644 index 0000000..7429609 --- /dev/null +++ b/idevice/src/services/wda.rs @@ -0,0 +1,1249 @@ +//! Minimal WebDriverAgent bootstrap client over direct device connections. +//! +//! This client talks to WDA on the device port directly through +//! [`crate::provider::IdeviceProvider`], so parallel automation across many +//! devices does not require binding unique localhost ports per device. +//! +//! The API intentionally remains library-first and currently covers session +//! bootstrap plus the most common WDA interactions. It is not yet a full +//! long-lived WebDriver transport and currently assumes simple HTTP JSON +//! request/response flows. + +use std::time::Duration; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use serde_json::{Value, json}; +use tokio::time::{Instant, sleep, timeout}; + +use crate::{Idevice, IdeviceError, provider::IdeviceProvider}; + +/// Default WDA HTTP port on the device. +pub const DEFAULT_WDA_PORT: u16 = 8100; + +/// Default MJPEG streaming port used by many WDA builds. +pub const DEFAULT_WDA_MJPEG_PORT: u16 = 9100; + +/// Poll interval used while waiting for WDA to begin responding. +const WDA_READY_POLL_INTERVAL: Duration = Duration::from_millis(250); + +/// Device-side ports exposed by a WDA runner. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WdaPorts { + /// Device port for the HTTP WebDriver endpoint. + pub http: u16, + /// Device port for the MJPEG stream endpoint. + pub mjpeg: u16, +} + +impl Default for WdaPorts { + fn default() -> Self { + Self { + http: DEFAULT_WDA_PORT, + mjpeg: DEFAULT_WDA_MJPEG_PORT, + } + } +} + +/// Minimal WDA bootstrap client bound to a specific device provider. +/// +/// This type intentionally opens a fresh direct device connection per request +/// to keep the transport simple and independent per device. +#[derive(Debug)] +pub struct WdaClient<'a> { + provider: &'a dyn IdeviceProvider, + ports: WdaPorts, + timeout: Duration, + session_id: Option, +} + +impl<'a> WdaClient<'a> { + /// Creates a WDA client using the default device-side ports. + pub fn new(provider: &'a dyn IdeviceProvider) -> Self { + Self { + provider, + ports: WdaPorts::default(), + timeout: Duration::from_secs(10), + session_id: None, + } + } + + /// Overrides the device-side WDA ports. + pub fn with_ports(mut self, ports: WdaPorts) -> Self { + self.ports = ports; + self + } + + /// Overrides the per-request timeout. + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + /// Returns the configured device-side ports. + pub fn ports(&self) -> WdaPorts { + self.ports + } + + /// Returns the currently tracked WDA session id, if one exists. + pub fn session_id(&self) -> Option<&str> { + self.session_id.as_deref() + } + + /// Fetches `/status` from the WDA HTTP endpoint. + pub async fn status(&self) -> Result { + self.request_json("GET", "/status", None).await + } + + /// Waits until WDA begins responding on its HTTP endpoint. + /// + /// This uses a modest polling interval to avoid hammering usbmux/device + /// connects when many devices are starting up in parallel. + pub async fn wait_until_ready( + &self, + timeout_duration: Duration, + ) -> Result { + let deadline = Instant::now() + timeout_duration; + loop { + match self.status().await { + Ok(status) => return Ok(status), + Err(_) if Instant::now() < deadline => { + sleep(WDA_READY_POLL_INTERVAL).await; + } + Err(error) => return Err(error), + } + } + } + + /// Starts a WDA session and returns the session id. + pub async fn start_session(&mut self, bundle_id: Option<&str>) -> Result { + let mut caps = serde_json::Map::new(); + if let Some(bundle_id) = bundle_id { + caps.insert("bundleId".into(), Value::String(bundle_id.to_owned())); + } + + let mut capabilities = serde_json::Map::new(); + capabilities.insert("alwaysMatch".into(), Value::Object(caps.clone())); + + let payload = Value::Object(serde_json::Map::from_iter([ + ("capabilities".into(), Value::Object(capabilities)), + ("desiredCapabilities".into(), Value::Object(caps)), + ])); + + let response = self + .request_json("POST", "/session", Some(&payload)) + .await?; + let session_id = Self::extract_session_id(&response)?; + self.session_id = Some(session_id.clone()); + Ok(session_id) + } + + /// Finds a single element and returns its WDA element id. + pub async fn find_element( + &self, + using: &str, + value: &str, + session_id: Option<&str>, + ) -> Result { + let session_id = self.require_session_id(session_id)?; + let response = self + .request_json( + "POST", + &format!("/session/{session_id}/element"), + Some(&json!({ "using": using, "value": value })), + ) + .await?; + Self::extract_element_id(Self::value_field(&response)?) + } + + /// Finds multiple elements and returns their WDA element ids. + pub async fn find_elements( + &self, + using: &str, + value: &str, + session_id: Option<&str>, + ) -> Result, IdeviceError> { + let session_id = self.require_session_id(session_id)?; + let response = self + .request_json( + "POST", + &format!("/session/{session_id}/elements"), + Some(&json!({ "using": using, "value": value })), + ) + .await?; + let values = + Self::value_field(&response)? + .as_array() + .ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + ))?; + values.iter().map(Self::extract_element_id).collect() + } + + /// Clicks an element by its WDA element id. + pub async fn click( + &self, + element_id: &str, + session_id: Option<&str>, + ) -> Result<(), IdeviceError> { + let session_id = self.require_session_id(session_id)?; + self.request_json( + "POST", + &format!("/session/{session_id}/element/{element_id}/click"), + Some(&json!({})), + ) + .await?; + Ok(()) + } + + /// Returns a raw attribute value for an element. + pub async fn element_attribute( + &self, + element_id: &str, + name: &str, + session_id: Option<&str>, + ) -> Result { + let session_id = self.require_session_id(session_id)?; + let response = self + .request_json( + "GET", + &format!("/session/{session_id}/element/{element_id}/attribute/{name}"), + None, + ) + .await?; + Ok(Self::value_field(&response)?.clone()) + } + + /// Returns the element text-like value as a string when WDA provides it. + pub async fn element_text( + &self, + element_id: &str, + session_id: Option<&str>, + ) -> Result { + self.element_attribute(element_id, "value", session_id) + .await? + .as_str() + .map(ToOwned::to_owned) + .ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )) + } + + /// Returns the element bounds rectangle. + pub async fn element_rect( + &self, + element_id: &str, + session_id: Option<&str>, + ) -> Result { + let session_id = self.require_session_id(session_id)?; + let response = self + .request_json( + "GET", + &format!("/session/{session_id}/element/{element_id}/rect"), + None, + ) + .await?; + Ok(Self::value_field(&response)?.clone()) + } + + /// Returns whether an element is displayed. + pub async fn element_displayed( + &self, + element_id: &str, + session_id: Option<&str>, + ) -> Result { + self.element_bool_state(element_id, "displayed", session_id) + .await + } + + /// Returns whether an element is enabled. + pub async fn element_enabled( + &self, + element_id: &str, + session_id: Option<&str>, + ) -> Result { + self.element_bool_state(element_id, "enabled", session_id) + .await + } + + /// Returns whether an element is selected. + pub async fn element_selected( + &self, + element_id: &str, + session_id: Option<&str>, + ) -> Result { + self.element_bool_state(element_id, "selected", session_id) + .await + } + + /// Presses a hardware button through WDA if the current server supports it. + pub async fn press_button( + &self, + name: &str, + session_id: Option<&str>, + ) -> Result<(), IdeviceError> { + let normalized = normalize_wda_button_name(name); + let payload = json!({ "name": normalized }); + + if let Some(session_id) = session_id.or(self.session_id()) { + match self + .request_json( + "POST", + &format!("/session/{session_id}/wda/pressButton"), + Some(&payload), + ) + .await + { + Ok(_) => return Ok(()), + Err(IdeviceError::UnknownErrorType(message)) if message.contains("404") => {} + Err(error) => return Err(error), + } + + if self.try_keys_endpoint(session_id, &normalized).await? { + return Ok(()); + } + } + + if normalized == "home" { + self.request_json("POST", "/wda/homescreen", Some(&json!({}))) + .await?; + return Ok(()); + } + + Err(IdeviceError::UnknownErrorType( + "WDA does not support pressButton or keys endpoints".into(), + )) + } + + /// Unlocks the device via WDA. + pub async fn unlock(&self, session_id: Option<&str>) -> Result<(), IdeviceError> { + if let Some(session_id) = session_id.or(self.session_id()) { + match self + .request_json( + "POST", + &format!("/session/{session_id}/wda/unlock"), + Some(&json!({})), + ) + .await + { + Ok(_) => return Ok(()), + Err(IdeviceError::UnknownErrorType(message)) if message.contains("404") => {} + Err(error) => return Err(error), + } + } + + self.request_json("POST", "/wda/unlock", Some(&json!({}))) + .await?; + Ok(()) + } + + /// Returns the current UI source tree as XML. + pub async fn source(&self, session_id: Option<&str>) -> Result { + let path = match session_id.or(self.session_id()) { + Some(session_id) => format!("/session/{session_id}/source"), + None => "/source".to_owned(), + }; + let response = self.request_json("GET", &path, None).await?; + Self::value_field(&response)? + .as_str() + .map(ToOwned::to_owned) + .ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )) + } + + /// Returns a PNG screenshot as raw bytes. + pub async fn screenshot(&self, session_id: Option<&str>) -> Result, IdeviceError> { + let path = match session_id.or(self.session_id()) { + Some(session_id) => format!("/session/{session_id}/screenshot"), + None => "/screenshot".to_owned(), + }; + let response = self.request_json("GET", &path, None).await?; + let value = + Self::value_field(&response)? + .as_str() + .ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + ))?; + STANDARD + .decode(value) + .map_err(|_| IdeviceError::UnexpectedResponse("unexpected response".into())) + } + + /// Returns the current window size payload from WDA. + pub async fn window_size(&self, session_id: Option<&str>) -> Result { + let session_id = self.require_session_id(session_id)?; + let response = self + .request_json("GET", &format!("/session/{session_id}/window/size"), None) + .await?; + Ok(Self::value_field(&response)?.clone()) + } + + /// Sends text input to the currently focused element. + pub async fn send_keys( + &self, + text: &str, + session_id: Option<&str>, + ) -> Result<(), IdeviceError> { + let session_id = self.require_session_id(session_id)?; + let payload = json!({ + "value": text.chars().map(|ch| ch.to_string()).collect::>() + }); + + match self + .request_json( + "POST", + &format!("/session/{session_id}/wda/keys"), + Some(&payload), + ) + .await + { + Ok(_) => Ok(()), + Err(IdeviceError::UnknownErrorType(message)) if message.contains("404") => { + self.request_json( + "POST", + &format!("/session/{session_id}/keys"), + Some(&payload), + ) + .await?; + Ok(()) + } + Err(error) => Err(error), + } + } + + /// Swipes from one coordinate to another. + pub async fn swipe( + &self, + start_x: i64, + start_y: i64, + end_x: i64, + end_y: i64, + duration: f64, + session_id: Option<&str>, + ) -> Result<(), IdeviceError> { + let session_id = self.require_session_id(session_id)?; + self.request_json( + "POST", + &format!("/session/{session_id}/wda/dragfromtoforduration"), + Some(&json!({ + "fromX": start_x, + "fromY": start_y, + "toX": end_x, + "toY": end_y, + "duration": duration, + })), + ) + .await?; + Ok(()) + } + + /// Performs a tap gesture on the screen or relative to an element. + pub async fn tap( + &self, + x: Option, + y: Option, + element_id: Option<&str>, + session_id: Option<&str>, + ) -> Result<(), IdeviceError> { + let session_id = self.require_session_id(session_id)?; + match self + .execute_gesture("tap", x, y, element_id, None, Some(session_id)) + .await + { + Ok(()) => Ok(()), + Err(IdeviceError::UnknownErrorType(message)) if message.contains("status=404") => { + let (tap_x, tap_y) = self + .resolve_gesture_coordinates(x, y, element_id, session_id) + .await?; + self.perform_tap_actions(session_id, tap_x, tap_y, 1).await + } + Err(error) => Err(error), + } + } + + /// Performs a double-tap gesture on the screen or relative to an element. + pub async fn double_tap( + &self, + x: Option, + y: Option, + element_id: Option<&str>, + session_id: Option<&str>, + ) -> Result<(), IdeviceError> { + let session_id = self.require_session_id(session_id)?; + match self + .execute_gesture("doubleTap", x, y, element_id, None, Some(session_id)) + .await + { + Ok(()) => Ok(()), + Err(IdeviceError::UnknownErrorType(message)) if message.contains("status=404") => { + let (tap_x, tap_y) = self + .resolve_gesture_coordinates(x, y, element_id, session_id) + .await?; + self.perform_tap_actions(session_id, tap_x, tap_y, 2).await + } + Err(error) => Err(error), + } + } + + /// Performs a long-press gesture on the screen or relative to an element. + pub async fn touch_and_hold( + &self, + duration: f64, + x: Option, + y: Option, + element_id: Option<&str>, + session_id: Option<&str>, + ) -> Result<(), IdeviceError> { + let session_id = self.require_session_id(session_id)?; + match self + .execute_gesture( + "touchAndHold", + x, + y, + element_id, + Some(duration), + Some(session_id), + ) + .await + { + Ok(()) => Ok(()), + Err(IdeviceError::UnknownErrorType(message)) if message.contains("status=404") => { + let (hold_x, hold_y) = self + .resolve_gesture_coordinates(x, y, element_id, session_id) + .await?; + self.perform_touch_and_hold_actions(session_id, hold_x, hold_y, duration) + .await + } + Err(error) => Err(error), + } + } + + /// Scrolls the current view or an element using a WDA mobile command. + /// + /// Typical directions are `up`, `down`, `left`, and `right`. + pub async fn scroll( + &self, + direction: Option<&str>, + name: Option<&str>, + predicate_string: Option<&str>, + to_visible: Option, + element_id: Option<&str>, + session_id: Option<&str>, + ) -> Result<(), IdeviceError> { + let session_id = self.require_session_id(session_id)?; + let mut payload = serde_json::Map::new(); + + if let Some(direction) = direction { + payload.insert("direction".into(), Value::String(direction.to_owned())); + } + if let Some(name) = name { + payload.insert("name".into(), Value::String(name.to_owned())); + } + if let Some(predicate_string) = predicate_string { + payload.insert( + "predicateString".into(), + Value::String(predicate_string.to_owned()), + ); + } + if let Some(to_visible) = to_visible { + payload.insert("toVisible".into(), Value::Bool(to_visible)); + } + if let Some(element_id) = element_id { + payload.insert("elementId".into(), Value::String(element_id.to_owned())); + } + + self.execute_mobile_method(session_id, "scroll", Value::Object(payload)) + .await?; + Ok(()) + } + + /// Returns the current viewport rectangle if the server exposes it. + pub async fn viewport_rect(&self, session_id: Option<&str>) -> Result { + let session_id = self.require_session_id(session_id)?; + let response = self + .execute_mobile_method( + session_id, + "viewportRect", + Value::Object(Default::default()), + ) + .await?; + Ok(Self::value_field(&response)?.clone()) + } + + /// Returns the current orientation if the server exposes it. + pub async fn orientation(&self, session_id: Option<&str>) -> Result { + let session_id = self.require_session_id(session_id)?; + let response = self + .request_json("GET", &format!("/session/{session_id}/orientation"), None) + .await?; + Self::value_field(&response)? + .as_str() + .map(ToOwned::to_owned) + .ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )) + } + + /// Launches or activates an application via WDA. + pub async fn launch_app( + &self, + bundle_id: &str, + arguments: Option<&[String]>, + environment: Option<&serde_json::Map>, + session_id: Option<&str>, + ) -> Result { + let session_id = self.require_session_id(session_id)?; + let mut payload = serde_json::Map::new(); + payload.insert("bundleId".into(), Value::String(bundle_id.to_owned())); + if let Some(arguments) = arguments { + payload.insert( + "arguments".into(), + Value::Array(arguments.iter().cloned().map(Value::String).collect()), + ); + } + if let Some(environment) = environment { + payload.insert("environment".into(), Value::Object(environment.clone())); + } + let response = self + .execute_mobile_method(session_id, "launchApp", Value::Object(payload)) + .await?; + Ok(Self::value_field(&response)?.clone()) + } + + /// Activates an already running application. + pub async fn activate_app( + &self, + bundle_id: &str, + session_id: Option<&str>, + ) -> Result { + let session_id = self.require_session_id(session_id)?; + let response = self + .execute_mobile_method(session_id, "activateApp", json!({ "bundleId": bundle_id })) + .await?; + Ok(Self::value_field(&response)?.clone()) + } + + /// Terminates an application and returns the WDA result. + pub async fn terminate_app( + &self, + bundle_id: &str, + session_id: Option<&str>, + ) -> Result { + let session_id = self.require_session_id(session_id)?; + let response = self + .execute_mobile_method(session_id, "terminateApp", json!({ "bundleId": bundle_id })) + .await?; + Self::value_field(&response)? + .as_bool() + .ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )) + } + + /// Queries the XCTest application state for the given bundle id. + pub async fn query_app_state( + &self, + bundle_id: &str, + session_id: Option<&str>, + ) -> Result { + let session_id = self.require_session_id(session_id)?; + let response = self + .execute_mobile_method( + session_id, + "queryAppState", + json!({ "bundleId": bundle_id }), + ) + .await?; + Self::value_field(&response)? + .as_i64() + .ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )) + } + + /// Backgrounds the current app for the given number of seconds. + /// + /// A negative value means background without restoring. + pub async fn background_app( + &self, + seconds: Option, + session_id: Option<&str>, + ) -> Result { + let session_id = self.require_session_id(session_id)?; + let payload = match seconds { + Some(seconds) => json!({ "seconds": seconds }), + None => json!({}), + }; + let response = self + .execute_mobile_method(session_id, "backgroundApp", payload) + .await?; + Ok(Self::value_field(&response)?.clone()) + } + + /// Returns whether the device is currently locked. + pub async fn is_locked(&self, session_id: Option<&str>) -> Result { + let session_id = self.require_session_id(session_id)?; + let response = self + .execute_mobile_method(session_id, "isLocked", Value::Object(Default::default())) + .await?; + Self::value_field(&response)? + .as_bool() + .ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )) + } + + /// Deletes a session, terminating the app under test. + /// + /// This is the standard W3C WebDriver `DELETE /session/{id}` endpoint and + /// is supported by all WDA builds, unlike the Appium `mobile:` execute routes. + pub async fn delete_session(&self, session_id: &str) -> Result<(), IdeviceError> { + self.request_json("DELETE", &format!("/session/{session_id}"), None) + .await + .map(|_| ()) + } + + /// Sends a single HTTP request over a direct device connection and parses + /// the JSON response body. + /// + /// This intentionally uses `Connection: close` and per-request sockets to + /// keep the transport simple and independent per device. + async fn request_json( + &self, + method: &str, + path: &str, + payload: Option<&Value>, + ) -> Result { + let body = match payload { + Some(payload) => serde_json::to_vec(payload) + .map_err(|_| IdeviceError::UnexpectedResponse("unexpected response".into()))?, + None => Vec::new(), + }; + + let mut request = format!( + "{method} {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Length: {}\r\n", + body.len() + ); + if payload.is_some() { + request.push_str("Content-Type: application/json\r\n"); + } + request.push_str("\r\n"); + + let mut idevice = self.provider.connect(self.ports.http).await?; + timeout(self.timeout, async { + idevice.send_raw(request.as_bytes()).await?; + if !body.is_empty() { + idevice.send_raw(&body).await?; + } + Self::read_json_response(&mut idevice).await + }) + .await + .map_err(|_| timeout_error("wda request"))? + } + + /// Reads a non-streaming JSON HTTP response. + /// + /// The current bootstrap client expects either a `Content-Length` body or + /// connection-close semantics and does not yet implement chunked transfer + /// decoding. + async fn read_json_response(idevice: &mut Idevice) -> Result { + let mut response = Vec::new(); + let mut header_end = None; + let mut content_length = None; + + loop { + let chunk = idevice.read_any(8192).await?; + if chunk.is_empty() { + break; + } + + response.extend_from_slice(&chunk); + + if header_end.is_none() + && let Some(offset) = find_bytes(&response, b"\r\n\r\n") + { + let header_len = offset + 4; + header_end = Some(header_len); + let header_text = String::from_utf8_lossy(&response[..offset]); + content_length = parse_content_length(&header_text); + } + + if let (Some(header_len), Some(content_length)) = (header_end, content_length) + && response.len() >= header_len + content_length + { + break; + } + } + + let header_end = header_end.ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + ))?; + let header_text = String::from_utf8_lossy(&response[..header_end - 4]); + let mut lines = header_text.lines(); + let status_line = lines.next().ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + ))?; + let status_code = status_line + .split_whitespace() + .nth(1) + .and_then(|value| value.parse::().ok()) + .ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + ))?; + + let body = &response[header_end..]; + let json: Value = serde_json::from_slice(body) + .map_err(|_| IdeviceError::UnexpectedResponse("unexpected response".into()))?; + + if !(200..300).contains(&status_code) { + return Err(IdeviceError::UnknownErrorType(Self::format_error( + &json, + status_code, + ))); + } + + match json.get("status") { + None | Some(Value::Null) => {} + Some(Value::Number(number)) if number.as_i64() == Some(0) => {} + Some(Value::String(value)) if value == "0" => {} + Some(_) => { + return Err(IdeviceError::UnknownErrorType(Self::format_error( + &json, + status_code, + ))); + } + } + + Ok(json) + } + + fn require_session_id<'b>( + &'b self, + session_id: Option<&'b str>, + ) -> Result<&'b str, IdeviceError> { + session_id + .or(self.session_id()) + .ok_or_else(|| IdeviceError::UnknownErrorType("session_id is required".into())) + } + + fn value_field(response: &Value) -> Result<&Value, IdeviceError> { + response + .get("value") + .ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )) + } + + fn extract_session_id(response: &Value) -> Result { + response + .get("sessionId") + .and_then(Value::as_str) + .or_else(|| { + response + .get("value") + .and_then(Value::as_object) + .and_then(|value| value.get("sessionId")) + .and_then(Value::as_str) + }) + .map(ToOwned::to_owned) + .ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )) + } + + fn extract_element_id(value: &Value) -> Result { + let element = value.as_object().ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + ))?; + element + .get("ELEMENT") + .or_else(|| element.get("element-6066-11e4-a52e-4f735466cecf")) + .or_else(|| element.get("element")) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )) + } + + fn format_error(data: &Value, status_code: u16) -> String { + let message = data + .get("value") + .map(|value| match value { + Value::Object(object) => object + .get("message") + .or_else(|| object.get("error")) + .cloned() + .unwrap_or_else(|| Value::Object(object.clone())), + other => other.clone(), + }) + .unwrap_or(Value::Null); + format!("WDA error (status={status_code}): {message}") + } + + async fn try_keys_endpoint( + &self, + session_id: &str, + normalized: &str, + ) -> Result { + let key = normalize_wda_key_name(normalized); + let payload = json!({ "keys": [key] }); + match self + .request_json( + "POST", + &format!("/session/{session_id}/wda/keys"), + Some(&payload), + ) + .await + { + Ok(_) => Ok(true), + Err(IdeviceError::UnknownErrorType(message)) if message.contains("404") => Ok(false), + Err(error) => Err(error), + } + } + + async fn execute_mobile_method( + &self, + session_id: &str, + method: &str, + args: Value, + ) -> Result { + let payload = json!({ + "script": format!("mobile: {method}"), + "args": [args], + }); + + match self + .request_json( + "POST", + &format!("/session/{session_id}/execute"), + Some(&payload), + ) + .await + { + Ok(response) => Ok(response), + Err(IdeviceError::UnknownErrorType(message)) if message.contains("status=404") => { + self.request_json( + "POST", + &format!("/session/{session_id}/execute/sync"), + Some(&payload), + ) + .await + } + Err(error) => Err(error), + } + } + + async fn perform_actions(&self, session_id: &str, actions: Value) -> Result<(), IdeviceError> { + self.request_json( + "POST", + &format!("/session/{session_id}/actions"), + Some(&json!({ "actions": actions })), + ) + .await?; + Ok(()) + } + + async fn perform_tap_actions( + &self, + session_id: &str, + x: f64, + y: f64, + tap_count: usize, + ) -> Result<(), IdeviceError> { + let mut gesture_actions = vec![pointer_move_action(0, x, y)]; + for index in 0..tap_count { + gesture_actions.push(pointer_down_action()); + gesture_actions.push(pointer_up_action()); + if index + 1 != tap_count { + gesture_actions.push(pointer_pause_action(100)); + } + } + + self.perform_actions( + session_id, + json!([{ + "type": "pointer", + "id": "finger1", + "parameters": { "pointerType": "touch" }, + "actions": gesture_actions, + }]), + ) + .await + } + + async fn perform_touch_and_hold_actions( + &self, + session_id: &str, + x: f64, + y: f64, + duration: f64, + ) -> Result<(), IdeviceError> { + let hold_duration_ms = duration_to_millis(duration)?; + self.perform_actions( + session_id, + json!([{ + "type": "pointer", + "id": "finger1", + "parameters": { "pointerType": "touch" }, + "actions": [ + pointer_move_action(0, x, y), + pointer_down_action(), + pointer_pause_action(hold_duration_ms), + pointer_up_action(), + ], + }]), + ) + .await + } + + async fn resolve_gesture_coordinates( + &self, + x: Option, + y: Option, + element_id: Option<&str>, + session_id: &str, + ) -> Result<(f64, f64), IdeviceError> { + match (x, y) { + (Some(x), Some(y)) => Ok((x, y)), + (None, None) => { + let element_id = element_id.ok_or_else(|| { + IdeviceError::UnknownErrorType( + "gesture fallback requires coordinates or an element id".into(), + ) + })?; + let rect = self.element_rect(element_id, Some(session_id)).await?; + let center_x = + json_number_field(&rect, "x")? + json_number_field(&rect, "width")? / 2.0; + let center_y = + json_number_field(&rect, "y")? + json_number_field(&rect, "height")? / 2.0; + Ok((center_x, center_y)) + } + _ => Err(IdeviceError::UnknownErrorType( + "gesture fallback requires both x and y coordinates".into(), + )), + } + } + + async fn execute_gesture( + &self, + method: &str, + x: Option, + y: Option, + element_id: Option<&str>, + duration: Option, + session_id: Option<&str>, + ) -> Result<(), IdeviceError> { + let session_id = self.require_session_id(session_id)?; + let mut payload = serde_json::Map::new(); + + if let Some(x) = x { + payload.insert("x".into(), Value::from(x)); + } + if let Some(y) = y { + payload.insert("y".into(), Value::from(y)); + } + if let Some(element_id) = element_id { + payload.insert("elementId".into(), Value::String(element_id.to_owned())); + } + if let Some(duration) = duration { + payload.insert("duration".into(), Value::from(duration)); + } + + self.execute_mobile_method(session_id, method, Value::Object(payload)) + .await?; + Ok(()) + } + + async fn element_bool_state( + &self, + element_id: &str, + state: &str, + session_id: Option<&str>, + ) -> Result { + let session_id = self.require_session_id(session_id)?; + let response = self + .request_json( + "GET", + &format!("/session/{session_id}/element/{element_id}/{state}"), + None, + ) + .await?; + Self::value_field(&response)? + .as_bool() + .ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )) + } +} + +fn parse_content_length(headers: &str) -> Option { + headers.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + if !name.eq_ignore_ascii_case("content-length") { + return None; + } + value.trim().parse::().ok() + }) +} + +fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +fn normalize_wda_button_name(name: &str) -> String { + match name + .trim() + .to_ascii_lowercase() + .replace(['-', '_'], "") + .as_str() + { + "home" => "home".into(), + "volumeup" | "volup" | "volumeupbutton" => "volumeUp".into(), + "volumedown" | "voldown" | "volumedownbutton" => "volumeDown".into(), + "lock" | "lockscreen" | "sleep" | "power" => "lock".into(), + _ => name.to_owned(), + } +} + +fn normalize_wda_key_name(name: &str) -> String { + match name + .trim() + .to_ascii_lowercase() + .replace(['-', '_'], "") + .as_str() + { + "home" => "HOME".into(), + "volumeup" | "volup" => "VOLUME_UP".into(), + "volumedown" | "voldown" => "VOLUME_DOWN".into(), + "lock" | "lockscreen" | "sleep" | "power" => "LOCK".into(), + _ => name.to_owned(), + } +} + +fn timeout_error(context: &str) -> IdeviceError { + std::io::Error::new(std::io::ErrorKind::TimedOut, format!("{context} timed out")).into() +} + +fn json_number_field(value: &Value, field: &str) -> Result { + value + .get(field) + .and_then(Value::as_f64) + .ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )) +} + +fn pointer_move_action(duration_ms: u64, x: f64, y: f64) -> Value { + json!({ + "type": "pointerMove", + "duration": duration_ms, + "x": x, + "y": y, + "origin": "viewport", + }) +} + +fn pointer_down_action() -> Value { + json!({ + "type": "pointerDown", + "button": 0, + }) +} + +fn pointer_up_action() -> Value { + json!({ + "type": "pointerUp", + "button": 0, + }) +} + +fn pointer_pause_action(duration_ms: u64) -> Value { + json!({ + "type": "pause", + "duration": duration_ms, + }) +} + +fn duration_to_millis(duration: f64) -> Result { + if !duration.is_finite() || duration < 0.0 { + return Err(IdeviceError::UnknownErrorType( + "gesture duration must be a non-negative finite number".into(), + )); + } + Ok((duration * 1000.0).round() as u64) +} + +#[cfg(test)] +mod tests { + use super::{ + DEFAULT_WDA_MJPEG_PORT, DEFAULT_WDA_PORT, WDA_READY_POLL_INTERVAL, WdaPorts, + duration_to_millis, find_bytes, normalize_wda_button_name, normalize_wda_key_name, + parse_content_length, + }; + + #[test] + fn default_ports_match_expected_wda_values() { + let ports = WdaPorts::default(); + assert_eq!(ports.http, DEFAULT_WDA_PORT); + assert_eq!(ports.mjpeg, DEFAULT_WDA_MJPEG_PORT); + } + + #[test] + fn ready_poll_interval_is_conservative() { + assert_eq!( + WDA_READY_POLL_INTERVAL, + std::time::Duration::from_millis(250) + ); + } + + #[test] + fn parse_content_length_is_case_insensitive() { + let headers = "HTTP/1.1 200 OK\r\ncontent-length: 123\r\nConnection: close\r\n"; + assert_eq!(parse_content_length(headers), Some(123)); + } + + #[test] + fn parse_content_length_ignores_missing_header() { + let headers = "HTTP/1.1 200 OK\r\nConnection: close\r\n"; + assert_eq!(parse_content_length(headers), None); + } + + #[test] + fn find_bytes_locates_header_separator() { + let response = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}"; + assert_eq!(find_bytes(response, b"\r\n\r\n"), Some(34)); + } + + #[test] + fn find_bytes_returns_none_when_missing() { + assert_eq!(find_bytes(b"abcdef", b"xyz"), None); + } + + #[test] + fn normalize_button_aliases() { + assert_eq!(normalize_wda_button_name("home"), "home"); + assert_eq!(normalize_wda_button_name("volume_up"), "volumeUp"); + assert_eq!(normalize_wda_button_name("sleep"), "lock"); + } + + #[test] + fn normalize_key_aliases() { + assert_eq!(normalize_wda_key_name("home"), "HOME"); + assert_eq!(normalize_wda_key_name("vol-down"), "VOLUME_DOWN"); + assert_eq!(normalize_wda_key_name("power"), "LOCK"); + } + + #[test] + fn duration_to_millis_rounds_seconds() { + assert_eq!(duration_to_millis(0.18).unwrap(), 180); + assert_eq!(duration_to_millis(1.25).unwrap(), 1250); + } + + #[test] + fn duration_to_millis_rejects_negative_values() { + assert!(duration_to_millis(-0.1).is_err()); + } +} diff --git a/idevice/src/services/wda_bridge.rs b/idevice/src/services/wda_bridge.rs new file mode 100644 index 0000000..311d685 --- /dev/null +++ b/idevice/src/services/wda_bridge.rs @@ -0,0 +1,245 @@ +//! Localhost bridge for WebDriverAgent HTTP and MJPEG endpoints. +//! +//! This module exposes device-side WDA ports as dynamic localhost URLs so GUI +//! clients (for example Tauri/React) can consume them as ordinary HTTP +//! endpoints without hard-coding host ports. + +use std::{net::SocketAddr, sync::Arc}; + +use tokio::{ + io::copy_bidirectional, + net::{TcpListener, TcpStream}, + task::JoinHandle, +}; +use tracing::{debug, warn}; + +use crate::{IdeviceError, provider::IdeviceProvider}; + +use super::wda::{DEFAULT_WDA_MJPEG_PORT, DEFAULT_WDA_PORT, WdaPorts}; + +/// Localhost URLs assigned to a running WDA bridge. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WdaBridgeEndpoints { + /// Device UDID when it can be resolved from the pairing file. + pub udid: Option, + /// Local URL forwarding to the device-side WDA HTTP endpoint. + pub wda_url: String, + /// Local URL forwarding to the device-side MJPEG endpoint. + pub mjpeg_url: String, + /// Local ports bound on the host. + pub local_ports: WdaPorts, + /// Original device-side ports. + pub device_ports: WdaPorts, +} + +#[derive(Debug)] +struct TcpPortForward { + local_addr: SocketAddr, + task: JoinHandle<()>, +} + +impl TcpPortForward { + async fn start( + provider: Arc, + device_port: u16, + label: &'static str, + ) -> Result { + let listener = TcpListener::bind(("127.0.0.1", 0)).await?; + let local_addr = listener.local_addr()?; + let provider_label = provider.label().to_string(); + + let task = tokio::spawn(async move { + loop { + let (mut client, client_addr) = match listener.accept().await { + Ok(connection) => connection, + Err(error) => { + warn!("[{}] localhost bridge accept failed: {}", label, error); + break; + } + }; + + let provider = provider.clone(); + let provider_label = provider_label.clone(); + tokio::spawn(async move { + debug!( + "[{}] bridging {} -> {}:{}", + label, client_addr, provider_label, device_port + ); + + let device = match provider.connect(device_port).await { + Ok(device) => device, + Err(error) => { + warn!( + "[{}] failed to connect to device port {}: {}", + label, device_port, error + ); + return; + } + }; + + let mut device_socket = match device.get_socket() { + Some(socket) => socket, + None => { + warn!( + "[{}] failed to extract device socket for port {}", + label, device_port + ); + return; + } + }; + + if let Err(error) = proxy_connection(&mut client, device_socket.as_mut()).await + { + debug!( + "[{}] bridge connection {} -> {} closed with error: {}", + label, client_addr, device_port, error + ); + } + }); + } + }); + + Ok(Self { local_addr, task }) + } + + fn local_port(&self) -> u16 { + self.local_addr.port() + } +} + +impl Drop for TcpPortForward { + fn drop(&mut self) { + self.task.abort(); + } +} + +/// Dynamic localhost bridge for a single device's WDA endpoints. +#[derive(Debug)] +pub struct WdaBridge { + endpoints: WdaBridgeEndpoints, + wda_forward: TcpPortForward, + mjpeg_forward: TcpPortForward, +} + +impl WdaBridge { + /// Starts localhost forwarding for the default WDA HTTP and MJPEG ports. + pub async fn start(provider: Arc) -> Result { + Self::start_with_ports( + provider, + WdaPorts { + http: DEFAULT_WDA_PORT, + mjpeg: DEFAULT_WDA_MJPEG_PORT, + }, + ) + .await + } + + /// Starts localhost forwarding for custom device-side WDA ports. + pub async fn start_with_ports( + provider: Arc, + device_ports: WdaPorts, + ) -> Result { + let udid = provider + .get_pairing_file() + .await + .ok() + .and_then(|pairing| pairing.udid); + let wda_forward = + TcpPortForward::start(provider.clone(), device_ports.http, "wda-http").await?; + let mjpeg_forward = + TcpPortForward::start(provider, device_ports.mjpeg, "wda-mjpeg").await?; + + let local_ports = WdaPorts { + http: wda_forward.local_port(), + mjpeg: mjpeg_forward.local_port(), + }; + + let endpoints = bridge_endpoints(udid, local_ports, device_ports); + + Ok(Self { + endpoints, + wda_forward, + mjpeg_forward, + }) + } + + /// Returns the resolved localhost endpoints. + pub fn endpoints(&self) -> &WdaBridgeEndpoints { + &self.endpoints + } + + /// Returns the localhost WDA HTTP URL. + pub fn wda_url(&self) -> &str { + &self.endpoints.wda_url + } + + /// Returns the localhost MJPEG URL. + pub fn mjpeg_url(&self) -> &str { + &self.endpoints.mjpeg_url + } + + /// Stops the localhost bridge by consuming the handle. + /// + /// Dropping the bridge aborts the underlying accept loops, so an explicit + /// shutdown method is only a convenience wrapper over normal drop + /// semantics. + pub fn shutdown(self) { + let WdaBridge { + endpoints: _, + wda_forward, + mjpeg_forward, + } = self; + drop(wda_forward); + drop(mjpeg_forward); + } +} + +fn bridge_endpoints( + udid: Option, + local_ports: WdaPorts, + device_ports: WdaPorts, +) -> WdaBridgeEndpoints { + WdaBridgeEndpoints { + udid, + wda_url: format!("http://127.0.0.1:{}", local_ports.http), + mjpeg_url: format!("http://127.0.0.1:{}", local_ports.mjpeg), + local_ports, + device_ports, + } +} + +async fn proxy_connection( + client: &mut TcpStream, + device: &mut dyn crate::ReadWrite, +) -> Result<(), IdeviceError> { + let _ = copy_bidirectional(client, device).await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{WdaPorts, bridge_endpoints}; + + #[test] + fn bridge_endpoints_use_local_ports_in_urls() { + let endpoints = bridge_endpoints( + Some("test-udid".into()), + WdaPorts { + http: 38100, + mjpeg: 39100, + }, + WdaPorts { + http: 8100, + mjpeg: 9100, + }, + ); + + assert_eq!(endpoints.udid.as_deref(), Some("test-udid")); + assert_eq!(endpoints.wda_url, "http://127.0.0.1:38100"); + assert_eq!(endpoints.mjpeg_url, "http://127.0.0.1:39100"); + assert_eq!(endpoints.local_ports.http, 38100); + assert_eq!(endpoints.local_ports.mjpeg, 39100); + assert_eq!(endpoints.device_ports.http, 8100); + assert_eq!(endpoints.device_ports.mjpeg, 9100); + } +} diff --git a/idevice/src/usbmuxd/mod.rs b/idevice/src/usbmuxd/mod.rs index ef38e19..6acdf82 100644 --- a/idevice/src/usbmuxd/mod.rs +++ b/idevice/src/usbmuxd/mod.rs @@ -65,6 +65,7 @@ pub struct UsbmuxdConnection { } /// Address of the usbmuxd service +#[allow(missing_copy_implementations)] #[derive(Clone, Debug)] pub enum UsbmuxdAddr { /// Unix domain socket path (Unix systems only) diff --git a/tests/src/main.rs b/tests/src/main.rs index 32f8064..3fb7d8c 100644 --- a/tests/src/main.rs +++ b/tests/src/main.rs @@ -34,6 +34,7 @@ mod rsd_services; mod screenshotr; mod springboard; mod syslog_relay; +mod xctest; /// Runs an async test case, printing PASS/FAIL and updating the counters. /// @@ -257,6 +258,16 @@ async fn main() -> ExitCode { println!("\n── DVT / Instruments ────────────────────────────────────────────"); dvt::run_tests(&usbmuxd_device, &mut success, &mut failure).await; + // ── XCTest / WDA (requires WDA_BUNDLE_ID env var) ───────────────────────── + println!("\n── XCTest / WDA ─────────────────────────────────────────────────"); + xctest::run_tests( + std::sync::Arc::new(usbmuxd_device.clone()) + as std::sync::Arc, + &mut success, + &mut failure, + ) + .await; + println!("\n═══════════════════════════════════════════════════════════════"); println!("All tests finished!"); println!(" Success: {success}"); diff --git a/tests/src/xctest.rs b/tests/src/xctest.rs new file mode 100644 index 0000000..b199f2f --- /dev/null +++ b/tests/src/xctest.rs @@ -0,0 +1,245 @@ +// Jackson Coxson +// XCTest / WDA integration tests. +// +// Automatically discovers the WDA/IntegrationApp runner by searching installed +// apps for a name containing "WebDriverAgent" or "IntegrationApp". Override +// the discovered bundle ID by setting WDA_BUNDLE_ID. +// +// If no runner is found the entire module is skipped. +// +// Once WDA is up, the tests drive com.apple.Preferences (Settings) which is +// always present on every device. +// +// To install the runner, +// 1. git clone https://github.com/appium/appium-xcuitest-driver.git +// 2. Open that john in Xcode +// 3. Select WebDriverAgentRunner in the schemes (it doesn't show up as an app, it's fine) +// 4. Product -> Test +// 5. Trust in Settings -> General -> VPN and profile management + +use std::{sync::Arc, time::Duration}; + +use idevice::{ + IdeviceService, + dvt::xctest::{TestConfig, XCUITestService}, + provider::IdeviceProvider, + services::{installation_proxy::InstallationProxyClient, wda::WdaClient}, +}; + +use crate::run_test; + +const WDA_READINESS_TIMEOUT: Duration = Duration::from_secs(120); +const SETTINGS_BUNDLE: &str = "com.apple.Preferences"; +const RUNNER_NAME_KEYWORDS: &[&str] = &["webdriveragent", "integrationapp", "xctrunner"]; + +/// Search installed apps for a bundle whose display name or bundle name +/// contains one of the runner keywords (case-insensitive). Returns the +/// bundle ID of the first match. +async fn find_runner_bundle(provider: &dyn IdeviceProvider) -> Option { + // Honour explicit override first + if let Ok(id) = std::env::var("WDA_BUNDLE_ID") { + return Some(id); + } + + let mut iproxy = InstallationProxyClient::connect(provider).await.ok()?; + let apps = iproxy.get_apps(None, None).await.ok()?; + + for (bundle_id, info) in &apps { + let dict = info.as_dictionary()?; + + let display_name = dict + .get("CFBundleDisplayName") + .or_else(|| dict.get("CFBundleName")) + .and_then(|v| v.as_string()) + .unwrap_or("") + .to_lowercase(); + + if RUNNER_NAME_KEYWORDS + .iter() + .any(|kw| display_name.contains(kw) || bundle_id.to_lowercase().contains(kw)) + { + return Some(bundle_id.clone()); + } + } + + None +} + +pub async fn run_tests(provider: Arc, success: &mut u32, failure: &mut u32) { + let bundle_id = match find_runner_bundle(&*provider).await { + Some(id) => id, + None => { + println!( + " xctest: no WDA/IntegrationApp runner found (set WDA_BUNDLE_ID to override), skipping" + ); + return; + } + }; + + println!(" xctest: runner: {bundle_id}"); + + // Build TestConfig from the installed runner + let cfg = { + let mut iproxy = match InstallationProxyClient::connect(&*provider).await { + Ok(c) => c, + Err(e) => { + println!(" xctest: InstallationProxy unavailable ({e}), skipping"); + *failure += 1; + return; + } + }; + + match TestConfig::from_installation_proxy(&mut iproxy, &bundle_id, None).await { + Ok(c) => c, + Err(e) => { + println!(" xctest: TestConfig::from_installation_proxy failed ({e})"); + *failure += 1; + return; + } + } + }; + + let service = XCUITestService::new(provider.clone()); + + // Launch WDA and wait until it's reachable + let handle = match service + .run_until_wda_ready(cfg, WDA_READINESS_TIMEOUT) + .await + { + Ok(h) => { + println!( + " {:<60}\x1b[32m[ PASS ]\x1b[0m", + "xctest: runner started + WDA ready" + ); + *success += 1; + h + } + Err(e) => { + println!( + " {:<60}\x1b[31m[ FAIL ]\x1b[0m {e}", + "xctest: runner started + WDA ready" + ); + *failure += 1; + return; + } + }; + + let mut wda = WdaClient::new(&*provider).with_ports(handle.ports()); + + run_test!("xctest: WDA status", success, failure, async { + wda.status().await.map(|_| ()) + }); + + // Open a session targeting Settings — always present, no extra install needed + let session_id = match wda.start_session(Some(SETTINGS_BUNDLE)).await { + Ok(id) => { + println!( + " {:<60}\x1b[32m[ PASS ]\x1b[0m (session={id})", + "xctest: WDA start_session Settings" + ); + *success += 1; + id + } + Err(e) => { + println!( + " {:<60}\x1b[31m[ FAIL ]\x1b[0m {e}", + "xctest: WDA start_session Settings" + ); + *failure += 1; + handle.abort(); + return; + } + }; + + let sid = Some(session_id.as_str()); + + run_test!( + "xctest: WDA screenshot (Settings)", + success, + failure, + async { + let bytes = wda.screenshot(sid).await?; + if bytes.is_empty() { + Err(idevice::IdeviceError::UnexpectedResponse( + "screenshot returned empty bytes".into(), + )) + } else { + println!("({} bytes)", bytes.len()); + Ok(()) + } + } + ); + + run_test!( + "xctest: WDA orientation (Settings)", + success, + failure, + async { + let o = wda.orientation(sid).await?; + println!("({o})"); + Ok::<(), idevice::IdeviceError>(()) + } + ); + + run_test!( + "xctest: WDA window_size (Settings)", + success, + failure, + async { + let size = wda.window_size(sid).await?; + println!( + "(w={}, h={})", + size.get("width").and_then(|v| v.as_f64()).unwrap_or(0.0), + size.get("height").and_then(|v| v.as_f64()).unwrap_or(0.0), + ); + Ok::<(), idevice::IdeviceError>(()) + } + ); + + run_test!("xctest: WDA source (Settings)", success, failure, async { + let src = wda.source(sid).await?; + if src.is_empty() { + Err(idevice::IdeviceError::UnexpectedResponse( + "page source was empty".into(), + )) + } else { + println!("({} bytes)", src.len()); + Ok(()) + } + }); + + run_test!( + "xctest: WDA find + click Bluetooth", + success, + failure, + async { + let element_id = wda + .find_element("predicate string", "label BEGINSWITH 'Bluetooth'", sid) + .await?; + wda.click(&element_id, sid).await + } + ); + + // After tapping Bluetooth the nav bar back-button label becomes "Settings", + // confirming we navigated to a sub-page. + run_test!( + "xctest: WDA verify navigation to Bluetooth", + success, + failure, + async { + wda.find_element( + "predicate string", + "label == 'Settings' AND type == 'XCUIElementTypeButton'", + sid, + ) + .await + .map(|_| ()) + } + ); + + run_test!("xctest: WDA delete session", success, failure, async { + wda.delete_session(&session_id).await + }); + + handle.abort(); +} diff --git a/tools/src/main.rs b/tools/src/main.rs index c2c6332..4693dce 100644 --- a/tools/src/main.rs +++ b/tools/src/main.rs @@ -53,6 +53,7 @@ mod screenshot; mod springboardservices; mod syslog_relay; mod sysmontap; +mod xctest; mod pcap; @@ -141,9 +142,13 @@ async fn main() { .with_subcommand("condition_inducer", condition_inducer::register()) .with_subcommand("network_monitor", network_monitor::register()) .with_subcommand("sysmontap", sysmontap::register()) + .with_subcommand("xctest", xctest::register()) .subcommand_required(true) - .collect() - .expect("Failed to collect CLI args"); + .collect(); + + let Some(arguments) = arguments else { + return; + }; let udid = arguments.get_flag::("udid"); let host = arguments.get_flag::("host"); @@ -289,6 +294,9 @@ async fn main() { "sysmontap" => { sysmontap::main(sub_args, provider).await; } + "xctest" => { + xctest::main(sub_args, provider).await; + } _ => unreachable!(), } } diff --git a/tools/src/xctest.rs b/tools/src/xctest.rs new file mode 100644 index 0000000..13d9906 --- /dev/null +++ b/tools/src/xctest.rs @@ -0,0 +1,222 @@ +// XCTest runner tool - launches an XCTest bundle (e.g. WebDriverAgent) on device. +// Usage: +// idevice-tools xctest [target_bundle_id] +// +// Example (WDA): +// idevice-tools xctest io.github.kor1k1.WebDriverAgentRunner.xctrunner +// idevice-tools xctest --bridge io.github.kor1k1.WebDriverAgentRunner.xctrunner + +use std::{sync::Arc, time::Duration}; + +use idevice::{ + IdeviceError, IdeviceService, + provider::IdeviceProvider, + services::dvt::xctest::{TestConfig, XCUITestService, listener::XCUITestListener}, + services::installation_proxy::InstallationProxyClient, +}; +use jkcli::{CollectedArguments, JkArgument, JkCommand, JkFlag}; + +pub fn register() -> JkCommand { + JkCommand::new() + .help("Launch an XCTest runner bundle (e.g. WebDriverAgent) on a connected device") + .with_flag( + JkFlag::new("wda-debug-log") + .with_help("Print verbose WebDriverAgent/XCTest debug log lines from the runner"), + ) + .with_flag( + JkFlag::new("bridge") + .with_help("Wait for WDA and expose localhost bridge URLs for HTTP and MJPEG"), + ) + .with_flag( + JkFlag::new("wda-timeout") + .with_argument(JkArgument::new().required(true)) + .with_help("WDA readiness timeout in seconds when --bridge is used (default: 30)"), + ) + .with_argument( + JkArgument::new() + .required(true) + .with_help( + "Bundle ID of the .xctrunner app (e.g. io.github.kor1k1.WebDriverAgentRunner.xctrunner)", + ), + ) + .with_argument( + JkArgument::new() + .required(false) + .with_help("Optional target app bundle ID under test"), + ) +} + +/// Minimal listener that prints test lifecycle events to stdout. +struct PrintListener { + show_wda_debug_logs: bool, +} + +impl XCUITestListener for PrintListener { + async fn did_begin_executing_test_plan(&mut self) -> Result<(), IdeviceError> { + println!("[XCTest] Test plan started"); + Ok(()) + } + + async fn did_finish_executing_test_plan(&mut self) -> Result<(), IdeviceError> { + println!("[XCTest] Test plan finished"); + Ok(()) + } + + async fn test_runner_ready_with_capabilities(&mut self) -> Result<(), IdeviceError> { + println!("[XCTest] Runner ready. Automation session is live."); + println!( + "[XCTest] If this is WDA, device-side endpoints are usually HTTP :8100 and MJPEG :9100." + ); + Ok(()) + } + + async fn test_suite_did_start( + &mut self, + suite: &str, + started_at: &str, + ) -> Result<(), IdeviceError> { + println!("[XCTest] Suite start: {} @ {}", suite, started_at); + Ok(()) + } + + async fn test_case_did_start( + &mut self, + test_class: &str, + method: &str, + ) -> Result<(), IdeviceError> { + println!("[XCTest] CASE START: {}/{}", test_class, method); + Ok(()) + } + + async fn test_case_did_finish( + &mut self, + result: idevice::services::dvt::xctest::listener::XCTestCaseResult, + ) -> Result<(), IdeviceError> { + println!( + "[XCTest] CASE END: {}/{} -> {} ({:.3}s)", + result.test_class, result.method, result.status, result.duration + ); + Ok(()) + } + + async fn test_case_did_fail( + &mut self, + test_class: &str, + method: &str, + message: &str, + file: &str, + line: u64, + ) -> Result<(), IdeviceError> { + eprintln!( + "[XCTest] FAIL: {}/{} - {} ({}:{})", + test_class, method, message, file, line + ); + Ok(()) + } + + async fn log_message(&mut self, message: &str) -> Result<(), IdeviceError> { + println!("[WDA] {}", message); + Ok(()) + } + + async fn log_debug_message(&mut self, message: &str) -> Result<(), IdeviceError> { + if self.show_wda_debug_logs { + println!("[WDA DBG] {}", message); + } + Ok(()) + } + + async fn initialization_for_ui_testing_did_fail( + &mut self, + description: &str, + ) -> Result<(), IdeviceError> { + eprintln!("[XCTest] UI testing init FAILED: {}", description); + Ok(()) + } + + async fn did_fail_to_bootstrap(&mut self, description: &str) -> Result<(), IdeviceError> { + eprintln!("[XCTest] Bootstrap FAILED: {}", description); + Ok(()) + } +} + +pub async fn main(arguments: &CollectedArguments, provider: Box) { + if let Err(e) = run(arguments, provider).await { + eprintln!("[XCTest] Error: {}", e); + std::process::exit(1); + } +} + +async fn run( + arguments: &CollectedArguments, + provider: Box, +) -> Result<(), IdeviceError> { + let mut arguments = arguments.clone(); + let use_bridge = arguments.has_flag("bridge"); + let show_wda_debug_logs = arguments.has_flag("wda-debug-log"); + let wda_timeout = arguments.get_flag::("wda-timeout").unwrap_or(30.0); + let runner_bundle_id: String = arguments + .next_argument() + .expect("runner bundle ID is required"); + let target_bundle_id: Option = arguments.next_argument(); + + println!("[XCTest] Runner: {}", runner_bundle_id); + if let Some(ref t) = target_bundle_id { + println!("[XCTest] Target: {}", t); + } + + let cfg = build_test_config( + provider.as_ref(), + &runner_bundle_id, + target_bundle_id.as_deref(), + ) + .await?; + + println!("[XCTest] App path: {}", cfg.runner_app_path); + println!("[XCTest] Container: {}", cfg.runner_app_container); + println!("[XCTest] Executable: {}", cfg.runner_bundle_executable); + + let provider: Arc = Arc::from(provider); + let svc = XCUITestService::new(provider); + let mut listener = PrintListener { + show_wda_debug_logs, + }; + + println!("[XCTest] Launching runner - this may take 15-30s ..."); + + if use_bridge { + let handle = svc + .run_until_wda_ready_with_bridge(cfg, Duration::from_secs_f64(wda_timeout)) + .await?; + let endpoints = handle.bridge().endpoints(); + println!("[XCTest] WDA is ready and bridged to localhost."); + if let Some(udid) = endpoints.udid.as_deref() { + println!("[XCTest] Device UDID: {}", udid); + } + println!("[XCTest] WDA URL: {}", endpoints.wda_url); + println!("[XCTest] MJPEG URL: {}", endpoints.mjpeg_url); + println!( + "[XCTest] Local ports: HTTP {} -> device {}, MJPEG {} -> device {}", + endpoints.local_ports.http, + endpoints.device_ports.http, + endpoints.local_ports.mjpeg, + endpoints.device_ports.mjpeg + ); + println!("[XCTest] Bridge is live. Press Ctrl+C to stop."); + handle.wait().await?; + } else { + svc.run(cfg, &mut listener, None).await?; + println!("[XCTest] Done."); + } + + Ok(()) +} +async fn build_test_config( + provider: &dyn IdeviceProvider, + runner_bundle_id: &str, + target_bundle_id: Option<&str>, +) -> Result { + let mut install_proxy = InstallationProxyClient::connect(provider).await?; + TestConfig::from_installation_proxy(&mut install_proxy, runner_bundle_id, target_bundle_id) + .await +}