From 54edfd382466016333223132db9559efbefba52b Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:35:24 +0200 Subject: [PATCH 1/8] feat(device): add Apple TV remote pairing transport --- Cargo.lock | 141 +- Cargo.toml | 4 +- apps/plumesign/src/commands/device.rs | 332 +++- apps/plumesign/src/commands/mod.rs | 1 + apps/plumesign/src/main.rs | 1 + crates/plume_core/src/developer/mod.rs | 2 + crates/plume_core/src/developer/platform.rs | 148 ++ crates/plume_core/src/lib.rs | 10 +- crates/plume_core/src/utils/mod.rs | 2 +- crates/plume_core/src/utils/provision.rs | 548 ++++++- crates/plume_utils/Cargo.toml | 5 + crates/plume_utils/src/device.rs | 1622 ++++++++++++++++++- crates/plume_utils/src/discovery/mdns.rs | 161 ++ crates/plume_utils/src/discovery/mod.rs | 1053 ++++++++++++ crates/plume_utils/src/lib.rs | 179 +- crates/plume_utils/src/pairing.rs | 186 +++ 16 files changed, 4167 insertions(+), 228 deletions(-) create mode 100644 crates/plume_core/src/developer/platform.rs create mode 100644 crates/plume_utils/src/discovery/mdns.rs create mode 100644 crates/plume_utils/src/discovery/mod.rs create mode 100644 crates/plume_utils/src/pairing.rs diff --git a/Cargo.lock b/Cargo.lock index 27895035..8b5379e7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -198,7 +198,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -209,7 +209,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -563,7 +563,7 @@ dependencies = [ "futures-io", "futures-lite", "parking", - "polling", + "polling 3.11.0", "rustix 1.1.4", "slab", "windows-sys 0.61.2", @@ -1064,7 +1064,7 @@ checksum = "b99da2f8558ca23c71f4fd15dc57c906239752dd27ff3c00a1d56b685b7cbfec" dependencies = [ "bitflags 2.11.1", "log", - "polling", + "polling 3.11.0", "rustix 0.38.44", "slab", "thiserror 1.0.69", @@ -1077,7 +1077,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4dbf9978365bac10f54d1d4b04f7ce4427e51f71d61f2fe15e3fed5166474df7" dependencies = [ "bitflags 2.11.1", - "polling", + "polling 3.11.0", "rustix 1.1.4", "slab", "tracing", @@ -1642,34 +1642,12 @@ dependencies = [ "crossbeam-utils", ] -[[package]] -name = "crossbeam-queue" -version = "0.3.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" -dependencies = [ - "crossbeam-utils", -] - [[package]] name = "crossbeam-utils" version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" -[[package]] -name = "crossfire" -version = "2.1.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fd901251b9b46c1752c85edfee0aee718c03a85a065f4126d32e5d6d419edf48" -dependencies = [ - "crossbeam-queue", - "crossbeam-utils", - "enum_dispatch", - "futures-core", - "parking_lot", -] - [[package]] name = "crunchy" version = "0.2.4" @@ -2086,7 +2064,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -2267,18 +2245,6 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" -[[package]] -name = "enum_dispatch" -version = "0.3.13" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "aa18ce2bc66555b3218614519ac839ddb759a7d6720732f979ef8d13be147ecd" -dependencies = [ - "once_cell", - "proc-macro2", - "quote", - "syn 2.0.117", -] - [[package]] name = "enumflags2" version = "0.7.12" @@ -2336,7 +2302,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -2474,6 +2440,17 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "flume" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -3374,7 +3351,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2", + "socket2 0.5.10", "system-configuration", "tokio", "tower-service", @@ -3766,6 +3743,16 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "if-addrs" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69b2eeee38fef3aa9b4cc5f1beea8a2444fc00e7377cafae396de3f5c2065e24" +dependencies = [ + "libc", + "windows-sys 0.59.0", +] + [[package]] name = "ignore" version = "0.4.25" @@ -3944,15 +3931,17 @@ dependencies = [ [[package]] name = "jktcp" -version = "0.1.2" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65b9d88c89c8fe802c7e7c2bf32b0fb85ef53187c30a8f130d41578b4362baa7" +checksum = "54408d8a86952b9f1cc009782ad7bbb4007a7caaba0599db441e0eae8785288c" dependencies = [ - "crossfire", "futures", + "getrandom 0.3.4", "rand 0.9.4", "tokio", "tracing", + "wasm-bindgen-futures", + "wasmtimer", ] [[package]] @@ -4333,6 +4322,19 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "mdns-sd" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fe7c11a1eb3cfbfcf702d1601c1f5f4c102cdc8665b8a557783ef634741676e" +dependencies = [ + "flume", + "if-addrs", + "log", + "polling 2.8.0", + "socket2 0.5.10", +] + [[package]] name = "memchr" version = "2.8.0" @@ -5669,12 +5671,14 @@ name = "plume_utils" version = "2.6.3" dependencies = [ "decompress", + "env_logger", "flate2", "futures", "goblin", "idevice", "image", "log", + "mdns-sd", "plist", "plume_core", "plume_store", @@ -5767,6 +5771,22 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "polling" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "concurrent-queue", + "libc", + "log", + "pin-project-lite", + "windows-sys 0.48.0", +] + [[package]] name = "polling" version = "3.11.0" @@ -5996,7 +6016,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.2", "rustls", - "socket2", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -6034,7 +6054,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.5.10", "tracing", "windows-sys 0.60.2", ] @@ -6623,7 +6643,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6703,7 +6723,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7277,6 +7297,16 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "socket2" version = "0.6.3" @@ -7284,7 +7314,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -7335,6 +7365,9 @@ name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] [[package]] name = "spirv" @@ -7549,7 +7582,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix 1.1.4", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -7730,7 +7763,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", - "socket2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] @@ -8124,7 +8157,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset 0.9.1", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -8792,7 +8825,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 6e77b6ff..3a115022 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,7 +62,7 @@ rand = "0.8" base64 = "0.22" sha2 = "0.11" regex = "1.11" -uuid = { version = "1.3", features = ["v4", "fast-rng", "macro-diagnostics"] } +uuid = { version = "1.3", features = ["v3", "v4", "fast-rng", "macro-diagnostics"] } # Requests reqwest = { version = "0.13", default-features = false, features = [ "blocking", @@ -73,4 +73,4 @@ reqwest = { version = "0.13", default-features = false, features = [ ] } chrono = { version = "0.4", default-features = false, features = ["std", "serde"] } serde = { version = "1", features = ["derive"] } -serde_json = { version = "1" } \ No newline at end of file +serde_json = { version = "1" } diff --git a/apps/plumesign/src/commands/device.rs b/apps/plumesign/src/commands/device.rs index feb12262..66e08c7f 100644 --- a/apps/plumesign/src/commands/device.rs +++ b/apps/plumesign/src/commands/device.rs @@ -1,75 +1,115 @@ +use std::net::{IpAddr, Ipv4Addr}; use std::path::PathBuf; +use std::time::Duration; -use anyhow::{Error, Ok, Result}; -use clap::Args; -use dialoguer::Select; -use idevice::{ - IdeviceService, - installation_proxy::InstallationProxyClient, - usbmuxd::{UsbmuxdAddr, UsbmuxdConnection}, -}; +use anyhow::{Result, anyhow}; +use clap::{Args, Subcommand}; +use dialoguer::{Input, Select}; +use plume_utils::discovery::{DeviceDiscovery, PlatformDiscovery}; use plume_utils::{Device, Package, get_device_for_id}; +use crate::get_data_path; + #[derive(Debug, Args)] #[command(arg_required_else_help = true)] pub struct DeviceArgs { - /// Device UDID to target (optional, will prompt if not provided) - #[arg( - short = 'u', - long = "udid", - value_name = "UDID", - conflicts_with = "mac" - )] + #[arg(short = 'u', long = "udid", value_name = "UDID", conflicts_with = "mac")] pub udid: Option, - /// Install app at specified path to device (.ipa, .app) #[arg(short = 'i', long = "install", value_name = "PATH")] pub install: Option, - /// Install pairing record from specified path to device #[arg( short = 'p', long = "pairing", - value_name = "MAC", conflicts_with = "mac", requires = "pairing_path" )] pub pairing: bool, - /// Path to pairing record to install (i.e. /Documents/pairingFile.plist) #[arg(long = "pairing-path", value_name = "PATH", requires = "pairing")] pub pairing_path: Option, - /// App identifier for the app to use for pairing record installation (optional, will prompt if not provided) #[arg(long = "pairing-app-identifier", value_name = "IDENTIFIER")] pub pairing_app_identifier: Option, - /// Install to connected Mac (arm64 only) #[cfg(all(target_os = "macos", target_arch = "aarch64"))] - #[arg(short = 'm', long = "mac", value_name = "MAC", conflicts_with = "udid")] + #[arg(short = 'm', long = "mac", conflicts_with = "udid")] pub mac: bool, } +#[derive(Debug, Args)] +pub struct PairArgs { + #[command(subcommand)] + pub command: PairCommand, +} + +#[derive(Debug, Subcommand)] +pub enum PairCommand { + Scan { + #[arg(long, default_value_t = 5)] + timeout: u64, + }, + Connect(PairConnectArgs), + Reconnect { + #[arg(long, default_value_t = 5)] + timeout: u64, + }, + Forget(PairForgetArgs), +} + +#[derive(Debug, Args)] +pub struct PairConnectArgs { + #[arg(long)] + pub name: Option, + #[arg(long)] + pub ip: Option, + #[arg(long)] + pub port: Option, + #[arg(long)] + pub pin: Option, + #[arg(long, default_value_t = 5)] + pub timeout: u64, +} + +#[derive(Debug, Args)] +pub struct PairForgetArgs { + #[arg(long, conflicts_with = "identity")] + pub name: Option, + #[arg(long)] + pub identity: Option, +} + pub async fn execute(args: DeviceArgs) -> Result<()> { let device = { #[cfg(all(target_os = "macos", target_arch = "aarch64"))] { if args.mac { - Device { + Some(Device { name: "My Mac".to_string(), udid: String::new(), + product_type: None, + device_class: Some("Mac".to_string()), + os_version: None, + serial_number: None, device_id: 0, usbmuxd_device: None, is_mac: true, - } + pairing_address: None, + reconnect_address: None, + pairing_identity: None, + pairing_cache_dir: None, + core_device_authenticated: false, + }) } else { - select_device(args.udid).await? + Some(select_device(args.udid).await?) } } #[cfg(not(all(target_os = "macos", target_arch = "aarch64")))] { - select_device(args.udid).await? + Some(select_device(args.udid).await?) } }; + let device = device.ok_or_else(|| anyhow!("No device selected"))?; + if let Some(app_path) = args.install { let mut app_path = app_path; - if !app_path.is_dir() { app_path = Package::new(app_path)? .get_package_bundle()? @@ -79,33 +119,30 @@ pub async fn execute(args: DeviceArgs) -> Result<()> { #[cfg(all(target_os = "macos", target_arch = "aarch64"))] if args.mac { - log::info!("Installing app at {:?} to connected Mac", app_path); plume_utils::install_app_mac(&app_path).await?; return Ok(()); } - log::info!("Installing app at {:?} to device {}", app_path, device.name); device .install_app(&app_path, |progress| async move { - log::info!("{}", progress); + log::info!("Installation progress: {progress}%"); }) .await?; } if args.pairing { if let Some(pairing_path) = args.pairing_path { - log::info!( - "Installing pairing record from {:?} to device {}", - pairing_path, - device.name - ); - let app_identifier = if let Some(identifier) = args.pairing_app_identifier { - identifier - } else { - apps(&device).await? + let app_identifier = match args.pairing_app_identifier { + Some(identifier) => identifier, + None => apps(&device).await?, }; device - .install_pairing_record(&app_identifier, pairing_path.to_str().unwrap()) + .install_pairing_record( + &app_identifier, + pairing_path + .to_str() + .ok_or_else(|| anyhow!("Pairing path is not valid UTF-8"))?, + ) .await?; } } @@ -113,58 +150,203 @@ pub async fn execute(args: DeviceArgs) -> Result<()> { Ok(()) } -pub async fn select_device(device_udid: Option) -> Result { - if let Some(udid) = device_udid { - return Ok(get_device_for_id(&udid).await?); +pub async fn execute_pair(args: PairArgs) -> Result<()> { + match args.command { + PairCommand::Scan { timeout } => { + let devices = discover_network_devices(Duration::from_secs(timeout)).await?; + if devices.is_empty() { + println!("No Apple TVs found."); + } else { + for device in devices { + println!("{device}"); + } + } + } + PairCommand::Connect(args) => pair_connect(args).await?, + PairCommand::Reconnect { timeout } => { + let devices = discover_network_devices(Duration::from_secs(timeout)).await?; + let mut found = false; + for device in devices { + found = true; + if plume_utils::is_valid_device_udid(&device.udid) { + println!("Reconnected {device}"); + } else { + println!("{} is paired but its authenticated UDID is unavailable", device.name); + } + } + if !found { + println!("No Apple TVs found."); + } + } + PairCommand::Forget(args) => { + let PairForgetArgs { name, identity } = args; + let identity = identity + .or_else(|| name.as_deref().map(|name| name.replace(' ', "-"))) + .ok_or_else(|| anyhow!("--name or --identity is required"))?; + let name = name.unwrap_or_else(|| identity.replace('-', " ")); + let device = Device::new_tvos( + name, + identity, + IpAddr::V4(Ipv4Addr::UNSPECIFIED), + None, + None, + get_data_path(), + ); + device.forget_tvos_pairing(get_data_path()).await?; + println!("Forgot the saved Apple TV pairing record."); + } } - let mut muxer = UsbmuxdConnection::default().await?; - let devices = muxer.get_devices().await?; + Ok(()) +} - if devices.is_empty() { - return Err(anyhow::anyhow!( - "No devices connected. Please connect a device or specify a UDID with --device-udid" - )); +pub async fn select_device(device_udid: Option) -> Result { + let devices = discover_devices().await?; + if let Some(udid) = device_udid { + if let Some(device) = devices + .iter() + .find(|device| device.udid.eq_ignore_ascii_case(&udid)) + { + return Ok(device.clone()); + } + if let Ok(device) = get_device_for_id(&udid).await { + return Ok(device); + } + return Err(anyhow!("No connected device has UDID {udid}")); } - let device_futures: Vec<_> = devices.into_iter().map(|d| Device::new(d)).collect(); - - let devices = futures::future::join_all(device_futures).await; - - let device_names: Vec = devices.iter().map(|d| d.to_string()).collect(); - + let names = devices.iter().map(ToString::to_string).collect::>(); let selection = Select::new() .with_prompt("Select a device to register and install to") - .items(&device_names) + .items(&names) .default(0) .interact()?; - Ok(devices[selection].clone()) } -async fn apps(device: &Device) -> Result { - const INSTALLATION_LABEL: &str = "App Installation"; - let p = device.usbmuxd_device.clone().unwrap().to_provider( - UsbmuxdAddr::from_env_var().unwrap_or_default(), - INSTALLATION_LABEL, - ); +async fn discover_devices() -> Result> { + let mut devices = Vec::new(); + if let Ok(mut muxer) = idevice::usbmuxd::UsbmuxdConnection::default().await { + if let Ok(usb_devices) = muxer.get_devices().await { + devices.extend( + futures::future::join_all(usb_devices.into_iter().map(Device::new)).await, + ); + } + } + devices.extend(discover_network_devices(Duration::from_secs(5)).await?); + let devices = plume_utils::deduplicate_devices(devices); + if devices.is_empty() { + return Err(anyhow!( + "No devices connected. Connect a device or pair an Apple TV with `plumesign pair connect`." + )); + } + Ok(devices) +} - let mut lpc = InstallationProxyClient::connect(&p) - .await - .map_err(|e| anyhow::anyhow!("Failed to create installation proxy client: {}", e))?; +async fn discover_network_devices(timeout: Duration) -> Result> { + let discovered = PlatformDiscovery::new().discover(timeout).await?; + let cache_dir = get_data_path(); + let mut devices = plume_utils::discovery::group_network_devices(&discovered, &cache_dir); + for device in &mut devices { + if device.has_pairing_source(&cache_dir) { + if let Ok(info) = device.fetch_tvos_info(cache_dir.clone()).await { + device.apply_tvos_info(&info); + } + } + } + Ok(plume_utils::deduplicate_devices(devices)) +} - let ia = lpc - .get_apps(Some("User"), None) - .await - .map_err(|e| anyhow::anyhow!("Failed to get installed apps: {}", e))?; +async fn pair_connect(args: PairConnectArgs) -> Result<()> { + let cache_dir = get_data_path(); + let mut device = if let Some(ip) = args.ip { + let name = args.name.unwrap_or_else(|| "Apple TV".to_string()); + let port = args + .port + .ok_or_else(|| anyhow!("--port is required with --ip"))?; + let identity = name.replace(' ', "-"); + Device::new_tvos(name, identity, ip, Some(port), None, cache_dir.clone()) + } else { + let devices = discover_network_devices(Duration::from_secs(args.timeout)).await?; + let tv_names = devices + .iter() + .map(|device| device.name.clone()) + .collect::>(); + if tv_names.is_empty() { + return Err(anyhow!("No Apple TVs found on the network")); + } + let index = if let Some(name) = args.name { + devices + .iter() + .position(|device| device.name.eq_ignore_ascii_case(&name)) + .ok_or_else(|| anyhow!("Apple TV {name:?} was not found"))? + } else { + Select::new() + .with_prompt("Select an Apple TV to pair") + .items(&tv_names) + .default(0) + .interact()? + }; + devices[index].clone() + }; - let app_names: Vec = ia.keys().cloned().collect(); + let pin = args.pin; + device + .pair_tvos( + move || { + let pin = pin.clone(); + async move { + pin.unwrap_or_else(|| { + Input::::new() + .with_prompt("Enter the PIN shown on the Apple TV") + .interact_text() + .unwrap_or_default() + }) + } + }, + cache_dir.clone(), + ) + .await?; + let info = device.fetch_tvos_info(cache_dir).await?; + device.apply_tvos_info(&info); + if !plume_utils::is_valid_device_udid(&device.udid) { + return Err(anyhow!( + "Pairing succeeded but the authenticated Apple TV UDID was not returned" + )); + } + println!( + "Paired {} ({}, {}, UDID {})", + device.name, + device.product_type.as_deref().unwrap_or("Apple TV"), + device.os_version.as_deref().unwrap_or("tvOS"), + device.udid + ); + Ok(()) +} + +async fn apps(device: &Device) -> Result { + let apps = device.installed_apps().await?; + if apps.is_empty() { + return Err(anyhow!("No supported installed apps found")); + } + let names = apps + .iter() + .map(|app| { + format!( + "{} ({})", + app.app, + app.bundle_id.as_deref().unwrap_or("unknown bundle") + ) + }) + .collect::>(); let selection = Select::new() - .items(&app_names) - .default(0) .with_prompt("Select an installed app") + .items(&names) + .default(0) .interact()?; - - Ok(app_names[selection].clone()) + apps[selection] + .bundle_id + .clone() + .ok_or_else(|| anyhow!("Selected app has no bundle identifier")) } diff --git a/apps/plumesign/src/commands/mod.rs b/apps/plumesign/src/commands/mod.rs index b418c48c..cfca12a9 100644 --- a/apps/plumesign/src/commands/mod.rs +++ b/apps/plumesign/src/commands/mod.rs @@ -29,4 +29,5 @@ pub enum Commands { Account(account::AccountArgs), /// Device management commands Device(device::DeviceArgs), + Pair(device::PairArgs), } diff --git a/apps/plumesign/src/main.rs b/apps/plumesign/src/main.rs index b0f933d7..56bf28d1 100644 --- a/apps/plumesign/src/main.rs +++ b/apps/plumesign/src/main.rs @@ -19,6 +19,7 @@ async fn main() -> anyhow::Result<()> { Commands::MachO(args) => commands::macho::execute(args).await?, Commands::Account(args) => commands::account::execute(args).await?, Commands::Device(args) => commands::device::execute(args).await?, + Commands::Pair(args) => commands::device::execute_pair(args).await?, } Ok(()) diff --git a/crates/plume_core/src/developer/mod.rs b/crates/plume_core/src/developer/mod.rs index b68d1d95..a1bea0e0 100644 --- a/crates/plume_core/src/developer/mod.rs +++ b/crates/plume_core/src/developer/mod.rs @@ -1,7 +1,9 @@ +mod platform; pub mod qh; mod session; pub mod v1; +pub use platform::DeveloperPlatform; pub use session::{DeveloperSession, RequestType}; #[macro_export] diff --git a/crates/plume_core/src/developer/platform.rs b/crates/plume_core/src/developer/platform.rs new file mode 100644 index 00000000..ef0a604d --- /dev/null +++ b/crates/plume_core/src/developer/platform.rs @@ -0,0 +1,148 @@ +use plist::{Dictionary, Value}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DeveloperPlatform { + #[default] + Ios, + Tvos, +} + +impl DeveloperPlatform { + pub fn from_device_metadata( + product_type: Option<&str>, + device_class: Option<&str>, + network_transport: bool, + ) -> Self { + if network_transport + || product_type.is_some_and(|value| value.starts_with("AppleTV")) + || device_class.is_some_and(|value| value.eq_ignore_ascii_case("AppleTV")) + { + Self::Tvos + } else { + Self::Ios + } + } + + pub fn request_fields(self) -> &'static [(&'static str, &'static str)] { + match self { + DeveloperPlatform::Ios => &[], + DeveloperPlatform::Tvos => &[("DTDK_Platform", "tvos"), ("subPlatform", "tvOS")], + } + } + + pub fn apply_to(self, body: &mut Dictionary) { + let fields = self.request_fields(); + if fields.is_empty() { + return; + } + for (key, value) in fields { + body.insert((*key).to_string(), Value::String((*value).to_string())); + } + } + + pub fn profile_platforms(self) -> &'static [&'static str] { + match self { + Self::Ios => &["ios", "iphoneos"], + Self::Tvos => &["tvos"], + } + } + + pub fn matches_profile_platform(self, value: &str) -> bool { + self.profile_platforms() + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(value)) + } +} + +impl std::fmt::Display for DeveloperPlatform { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DeveloperPlatform::Ios => formatter.write_str("iOS"), + DeveloperPlatform::Tvos => formatter.write_str("tvOS"), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_ios() { + assert_eq!(DeveloperPlatform::default(), DeveloperPlatform::Ios); + } + + #[test] + fn ios_request_fields_are_empty() { + assert!(DeveloperPlatform::Ios.request_fields().is_empty()); + } + + #[test] + fn tvos_request_fields_are_exact() { + assert_eq!( + DeveloperPlatform::Tvos.request_fields(), + &[("DTDK_Platform", "tvos"), ("subPlatform", "tvOS")] + ); + } + + #[test] + fn ios_apply_to_leaves_dictionary_unchanged() { + let mut body = Dictionary::new(); + body.insert("teamId".to_string(), Value::String("T123".to_string())); + body.insert("appIdId".to_string(), Value::String("A456".to_string())); + let original = body.clone(); + + DeveloperPlatform::Ios.apply_to(&mut body); + + assert_eq!(body, original); + assert_eq!(body.keys().count(), 2); + } + + #[test] + fn tvos_apply_to_adds_exactly_the_two_platform_fields() { + let mut body = Dictionary::new(); + body.insert("teamId".to_string(), Value::String("T123".to_string())); + body.insert("appIdId".to_string(), Value::String("A456".to_string())); + + DeveloperPlatform::Tvos.apply_to(&mut body); + + assert_eq!(body.keys().count(), 4); + assert_eq!(body.get("teamId").and_then(Value::as_string), Some("T123")); + assert_eq!(body.get("appIdId").and_then(Value::as_string), Some("A456")); + assert_eq!( + body.get("DTDK_Platform").and_then(Value::as_string), + Some("tvos") + ); + assert_eq!( + body.get("subPlatform").and_then(Value::as_string), + Some("tvOS") + ); + } + + #[test] + fn metadata_classifies_tvos_without_trusting_network_identifiers() { + assert_eq!( + DeveloperPlatform::from_device_metadata( + Some("AppleTV14,1"), + Some("AppleTV"), + false + ), + DeveloperPlatform::Tvos + ); + assert_eq!( + DeveloperPlatform::from_device_metadata(None, None, true), + DeveloperPlatform::Tvos + ); + assert_eq!( + DeveloperPlatform::from_device_metadata(Some("iPhone15,2"), Some("iPhone"), false), + DeveloperPlatform::Ios + ); + } + + #[test] + fn profile_platform_matching_is_case_insensitive() { + assert!(DeveloperPlatform::Tvos.matches_profile_platform("tvOS")); + assert!(!DeveloperPlatform::Tvos.matches_profile_platform("iOS")); + assert!(DeveloperPlatform::Ios.matches_profile_platform("iPhoneOS")); + } +} diff --git a/crates/plume_core/src/lib.rs b/crates/plume_core/src/lib.rs index 3d438f9a..1ac3897d 100644 --- a/crates/plume_core/src/lib.rs +++ b/crates/plume_core/src/lib.rs @@ -2,11 +2,15 @@ pub mod auth; pub mod developer; mod utils; -pub use apple_codesign::{AppleCodesignError, SettingsScope, SigningSettings, UnifiedSigner}; +pub use apple_codesign::{ + AppleCodesignError, SettingsScope, SigningSettings, UnifiedSigner, verify_macho_data, +}; pub use omnisette::AnisetteConfiguration; -pub use utils::{CertificateIdentity, MachO, MachOExt, MobileProvision}; +pub use utils::{ + CertificateIdentity, MachO, MachOExt, MobileProvision, is_valid_device_udid, +}; use thiserror::Error as ThisError; #[derive(Debug, ThisError)] @@ -68,6 +72,8 @@ pub enum Error { Slice(#[from] std::array::TryFromSliceError), #[error("Invalid key length for AES-GCM: {0}")] SHA2(#[from] sha2::digest::InvalidLength), + #[error("Provisioning profile invalid: {0}")] + ProvisioningProfileInvalid(String), } pub fn client() -> Result { diff --git a/crates/plume_core/src/utils/mod.rs b/crates/plume_core/src/utils/mod.rs index 7427aed1..cb95c652 100644 --- a/crates/plume_core/src/utils/mod.rs +++ b/crates/plume_core/src/utils/mod.rs @@ -8,7 +8,7 @@ mod provision; pub use certificate::CertificateIdentity; #[cfg(feature = "tweaks")] pub use macho::{MachO, MachOExt}; -pub use provision::MobileProvision; +pub use provision::{MobileProvision, is_valid_device_udid}; pub const TEAM_ID_REGEX: &str = r"^[A-Z0-9]{10}\."; diff --git a/crates/plume_core/src/utils/provision.rs b/crates/plume_core/src/utils/provision.rs index b9b2bc4f..8e124bf0 100644 --- a/crates/plume_core/src/utils/provision.rs +++ b/crates/plume_core/src/utils/provision.rs @@ -1,34 +1,44 @@ use std::fs; use std::path::{Path, PathBuf}; +use std::time::SystemTime; -use crate::Error; +use crate::developer::DeveloperPlatform; use crate::utils::TEAM_ID_REGEX; +use crate::{Error, MachO}; use plist::{Date, Dictionary, Value}; - -use super::MachO; +use x509_certificate::CapturedX509Certificate; #[derive(Clone)] pub struct MobileProvision { pub data: Vec, entitlements: Dictionary, expiration_date: Date, + platforms: Vec, + provisioned_devices: Vec, + developer_certificates: Vec>, } impl MobileProvision { pub fn load_with_path>(path: P) -> Result { - let path = path.as_ref(); - let data = fs::read(path)?; - - Self::load_with_bytes(data) + Self::load_with_bytes(fs::read(path)?) } pub fn load_with_bytes(data: Vec) -> Result { - let (entitlements, expiration_date) = Self::extract_entitlements_from_prov(&data)?; + let ( + entitlements, + expiration_date, + platforms, + provisioned_devices, + developer_certificates, + ) = Self::extract_profile_data(&data)?; Ok(Self { data, entitlements, expiration_date, + platforms, + provisioned_devices, + developer_certificates, }) } @@ -47,7 +57,7 @@ impl MobileProvision { .entitlements .get("com.apple.developer.team-identifier") .and_then(Value::as_string) - .map(|s| s.to_owned()); + .map(str::to_owned); crate::utils::merge_entitlements( &mut self.entitlements, @@ -80,39 +90,523 @@ impl MobileProvision { .as_string()?; let re = regex::Regex::new(TEAM_ID_REGEX).ok()?; - let bundle_id = re.replace(app_id, "").to_string(); + Some(re.replace(app_id, "").to_string()) + } + + pub fn validate_for( + &self, + platform: DeveloperPlatform, + bundle_id: &str, + device_udid: Option<&str>, + certificate_der: Option<&[u8]>, + requested_entitlements: Option<&Dictionary>, + ) -> Result<(), Error> { + if self.platforms.is_empty() + || !self + .platforms + .iter() + .any(|value| platform.matches_profile_platform(value)) + { + return Err(Error::ProvisioningProfileInvalid(format!( + "profile does not target {}", + platform + ))); + } + + if SystemTime::now() >= SystemTime::from(self.expiration_date) { + return Err(Error::ProvisioningProfileInvalid( + "profile is expired".to_string(), + )); + } + + let application_identifier = self + .entitlements + .get("application-identifier") + .and_then(Value::as_string) + .ok_or_else(|| { + Error::ProvisioningProfileInvalid( + "profile has no application identifier".to_string(), + ) + })?; + if !application_identifier_grants(application_identifier, bundle_id) { + return Err(Error::ProvisioningProfileInvalid(format!( + "application identifier {application_identifier:?} does not match {bundle_id:?}" + ))); + } + + if let Some(udid) = device_udid { + if !is_valid_device_udid(udid) + || !self + .provisioned_devices + .iter() + .any(|value| value.eq_ignore_ascii_case(udid)) + { + return Err(Error::ProvisioningProfileInvalid(format!( + "profile does not contain selected Apple TV UDID {udid}" + ))); + } + } - Some(bundle_id) + let Some(certificate_der) = certificate_der else { + return Err(Error::ProvisioningProfileInvalid( + "active signing certificate is unavailable".to_string(), + )); + }; + if !self + .developer_certificates + .iter() + .any(|certificate| certificate.as_slice() == certificate_der) + { + return Err(Error::ProvisioningProfileInvalid( + "profile does not contain the active signing certificate".to_string(), + )); + } + let certificate = CapturedX509Certificate::from_der(certificate_der)?; + if !certificate.time_constraints_valid(None) { + return Err(Error::ProvisioningProfileInvalid( + "active signing certificate is expired or not yet valid".to_string(), + )); + } + + if let Some(requested_entitlements) = requested_entitlements { + for (key, requested) in requested_entitlements { + if matches!( + key.as_str(), + "application-identifier" | "com.apple.developer.team-identifier" + ) { + continue; + } + + let Some(granted) = self.entitlements.get(key) else { + return Err(Error::ProvisioningProfileInvalid(format!( + "profile does not grant entitlement {key:?}" + ))); + }; + if !value_grants(granted, requested) { + return Err(Error::ProvisioningProfileInvalid(format!( + "profile does not grant entitlement {key:?}" + ))); + } + } + } + + Ok(()) } - fn extract_entitlements_from_prov(data: &[u8]) -> Result<(Dictionary, Date), Error> { + fn extract_profile_data( + data: &[u8], + ) -> Result<(Dictionary, Date, Vec, Vec, Vec>), Error> { let start = data .windows(6) - .position(|w| w == b"") + .rposition(|window| window == b"") .ok_or(Error::ProvisioningEntitlementsUnknown)? + 8; - let plist_data = &data[start..end]; - let plist = plist::Value::from_reader_xml(plist_data)?; - - let expiration_date = plist + let plist = Value::from_reader_xml(&data[start..end])?; + let dictionary = plist .as_dictionary() - .and_then(|d| d.get("ExpirationDate")) - .and_then(|v| v.as_date()); + .ok_or(Error::ProvisioningEntitlementsUnknown)?; - let entitlements = plist - .as_dictionary() - .and_then(|d| d.get("Entitlements")) - .and_then(|v| v.as_dictionary()) + let entitlements = dictionary + .get("Entitlements") + .and_then(Value::as_dictionary) .cloned() - .ok_or(Error::ProvisioningEntitlementsUnknown); + .ok_or(Error::ProvisioningEntitlementsUnknown)?; + let expiration_date = dictionary + .get("ExpirationDate") + .and_then(Value::as_date) + .ok_or(Error::ProvisioningEntitlementsUnknown)?; + let platforms = string_values(dictionary.get("Platform")); + let provisioned_devices = string_values(dictionary.get("ProvisionedDevices")); + let developer_certificates = dictionary + .get("DeveloperCertificates") + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_data) + .map(ToOwned::to_owned) + .collect() + }) + .unwrap_or_default(); Ok(( - entitlements?, - expiration_date.ok_or(Error::ProvisioningEntitlementsUnknown)?, + entitlements, + expiration_date, + platforms, + provisioned_devices, + developer_certificates, )) } } + +fn string_values(value: Option<&Value>) -> Vec { + match value { + Some(Value::String(value)) => vec![value.clone()], + Some(Value::Array(values)) => values + .iter() + .filter_map(Value::as_string) + .map(ToOwned::to_owned) + .collect(), + _ => Vec::new(), + } +} + +fn application_identifier_grants(granted: &str, requested: &str) -> bool { + let granted_bundle_id = match (granted.get(..10), granted.as_bytes().get(10), granted.get(11..)) { + (Some(team), Some(b'.'), Some(bundle_id)) + if team + .bytes() + .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit()) => + { + bundle_id + } + _ => granted, + }; + + if granted_bundle_id == requested { + return true; + } + + if granted_bundle_id == "*" { + return true; + } + + granted_bundle_id + .strip_suffix(".*") + .is_some_and(|prefix| requested.starts_with(prefix) && requested.len() > prefix.len()) +} + +fn value_grants(granted: &Value, requested: &Value) -> bool { + match (granted, requested) { + (Value::String(granted), Value::String(requested)) => wildcard_matches(granted, requested), + (Value::Array(granted), Value::Array(requested)) => requested + .iter() + .all(|requested| granted.iter().any(|granted| value_grants(granted, requested))), + (Value::Dictionary(granted), Value::Dictionary(requested)) => requested.iter().all( + |(key, requested)| { + granted + .get(key) + .is_some_and(|granted| value_grants(granted, requested)) + }, + ), + _ => granted == requested, + } +} + +fn wildcard_matches(granted: &str, requested: &str) -> bool { + if !granted.contains('*') { + return granted == requested; + } + + let mut remainder = requested; + let mut parts = granted.split('*'); + let Some(first) = parts.next() else { + return false; + }; + if !remainder.starts_with(first) { + return false; + } + remainder = &remainder[first.len()..]; + + let suffixes = parts.collect::>(); + for (index, part) in suffixes.iter().enumerate() { + if index == suffixes.len() - 1 { + return remainder.ends_with(part); + } + let Some(position) = remainder.find(part) else { + return false; + }; + remainder = &remainder[position + part.len()..]; + } + + true +} + +pub fn is_valid_device_udid(value: &str) -> bool { + let bytes = value.as_bytes(); + let is_hex = |part: &[u8]| part.iter().all(|byte| byte.is_ascii_hexdigit()); + + (bytes.len() == 40 && is_hex(bytes)) + || (bytes.len() == 25 + && bytes[8] == b'-' + && is_hex(&bytes[..8]) + && is_hex(&bytes[9..])) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::OnceLock; + use std::time::Duration; + + fn certificate_der() -> &'static [u8] { + static CERTIFICATE: OnceLock> = OnceLock::new(); + CERTIFICATE + .get_or_init(|| { + rcgen::generate_simple_self_signed(vec!["example.com".to_string()]) + .unwrap() + .serialize_der() + .unwrap() + }) + .as_slice() + } + + fn expired_certificate_der() -> Vec { + let mut params = rcgen::CertificateParams::new(vec!["example.com".to_string()]); + params.not_after = rcgen::date_time_ymd(2000, 1, 1); + rcgen::Certificate::from_params(params) + .unwrap() + .serialize_der() + .unwrap() + } + + fn profile( + platform: &str, + bundle_id: &str, + expiration: SystemTime, + devices: &[&str], + certificates: &[&[u8]], + extra_entitlements: &[(&str, Value)], + ) -> MobileProvision { + let mut entitlements = Dictionary::new(); + entitlements.insert( + "application-identifier".to_string(), + Value::String(format!("L988J7YMK5.{bundle_id}")), + ); + entitlements.insert( + "com.apple.developer.team-identifier".to_string(), + Value::String("L988J7YMK5".to_string()), + ); + for (key, value) in extra_entitlements { + entitlements.insert((*key).to_string(), value.clone()); + } + + let mut root = Dictionary::new(); + root.insert( + "Entitlements".to_string(), + Value::Dictionary(entitlements), + ); + root.insert( + "ExpirationDate".to_string(), + Value::Date(Date::from(expiration)), + ); + root.insert( + "Platform".to_string(), + Value::Array(vec![Value::String(platform.to_string())]), + ); + root.insert( + "ProvisionedDevices".to_string(), + Value::Array( + devices + .iter() + .map(|device| Value::String((*device).to_string())) + .collect(), + ), + ); + root.insert( + "DeveloperCertificates".to_string(), + Value::Array( + certificates + .iter() + .map(|certificate| Value::Data((*certificate).to_vec())) + .collect(), + ), + ); + + let mut data = Vec::new(); + Value::Dictionary(root).to_writer_xml(&mut data).unwrap(); + MobileProvision::load_with_bytes(data).unwrap() + } + + fn valid_profile() -> MobileProvision { + profile( + "tvOS", + "com.example.tv", + SystemTime::now() + Duration::from_secs(3600), + &["00008110-000C25540CD1801E"], + &[certificate_der()], + &[("get-task-allow", Value::Boolean(true))], + ) + } + + #[test] + fn accepts_matching_profile() { + let requested = Dictionary::from_iter([( + "get-task-allow".to_string(), + Value::Boolean(true), + )]); + valid_profile() + .validate_for( + DeveloperPlatform::Tvos, + "com.example.tv", + Some("00008110-000C25540CD1801E"), + Some(certificate_der()), + Some(&requested), + ) + .unwrap(); + } + + #[test] + fn rejects_ios_profile_for_tvos() { + let profile = profile( + "iOS", + "com.example.tv", + SystemTime::now() + Duration::from_secs(3600), + &["00008110-000C25540CD1801E"], + &[certificate_der()], + &[], + ); + let error = profile + .validate_for( + DeveloperPlatform::Tvos, + "com.example.tv", + Some("00008110-000C25540CD1801E"), + Some(certificate_der()), + None, + ) + .unwrap_err(); + assert!(error.to_string().contains("tvOS")); + } + + #[test] + fn rejects_missing_device() { + let error = valid_profile() + .validate_for( + DeveloperPlatform::Tvos, + "com.example.tv", + Some("00008110-000C25540CD1801F"), + Some(certificate_der()), + None, + ) + .unwrap_err(); + assert!(error.to_string().contains("UDID")); + } + + #[test] + fn rejects_expired_profile() { + let profile = profile( + "tvOS", + "com.example.tv", + SystemTime::now() - Duration::from_secs(1), + &["00008110-000C25540CD1801E"], + &[certificate_der()], + &[], + ); + let error = profile + .validate_for( + DeveloperPlatform::Tvos, + "com.example.tv", + Some("00008110-000C25540CD1801E"), + Some(certificate_der()), + None, + ) + .unwrap_err(); + assert!(error.to_string().contains("expired")); + } + + #[test] + fn rejects_expired_certificate() { + let certificate = expired_certificate_der(); + let profile = profile( + "tvOS", + "com.example.tv", + SystemTime::now() + Duration::from_secs(3600), + &["00008110-000C25540CD1801E"], + &[certificate.as_slice()], + &[], + ); + let error = profile + .validate_for( + DeveloperPlatform::Tvos, + "com.example.tv", + Some("00008110-000C25540CD1801E"), + Some(certificate.as_slice()), + None, + ) + .unwrap_err(); + assert!(error.to_string().contains("expired")); + } + + #[test] + fn rejects_missing_certificate() { + let error = valid_profile() + .validate_for( + DeveloperPlatform::Tvos, + "com.example.tv", + Some("00008110-000C25540CD1801E"), + Some(b"other"), + None, + ) + .unwrap_err(); + assert!(error.to_string().contains("certificate")); + } + + #[test] + fn rejects_mismatched_bundle_id() { + let error = valid_profile() + .validate_for( + DeveloperPlatform::Tvos, + "com.example.other", + Some("00008110-000C25540CD1801E"), + Some(certificate_der()), + None, + ) + .unwrap_err(); + assert!(error.to_string().contains("application identifier")); + } + + #[test] + fn rejects_ungranted_entitlement() { + let requested = Dictionary::from_iter([( + "com.apple.developer.networking.wifi-info".to_string(), + Value::Boolean(true), + )]); + let error = valid_profile() + .validate_for( + DeveloperPlatform::Tvos, + "com.example.tv", + Some("00008110-000C25540CD1801E"), + Some(certificate_der()), + Some(&requested), + ) + .unwrap_err(); + assert!(error.to_string().contains("entitlement")); + } + + #[test] + fn wildcard_application_identifier_grants_final_bundle_id() { + let profile = profile( + "tvOS", + "*", + SystemTime::now() + Duration::from_secs(3600), + &[], + &[certificate_der()], + &[], + ); + assert!(application_identifier_grants( + "L988J7YMK5.*", + "com.example.tv" + )); + assert!(profile + .validate_for( + DeveloperPlatform::Tvos, + "com.example.tv", + None, + Some(certificate_der()), + None, + ) + .is_ok()); + } + + #[test] + fn bare_application_identifier_is_not_split_as_a_team_prefix() { + assert!(application_identifier_grants( + "com.example.tv", + "com.example.tv" + )); + } +} diff --git a/crates/plume_utils/Cargo.toml b/crates/plume_utils/Cargo.toml index 60ab60ee..33342dd2 100644 --- a/crates/plume_utils/Cargo.toml +++ b/crates/plume_utils/Cargo.toml @@ -22,3 +22,8 @@ flate2.workspace = true plume_core = { path = "../plume_core", features = ["tweaks"] } plume_store = { path = "../plume_store" } decompress = { path = "../../3rdparty/decompress" } + +mdns-sd = "0.11" + +[dev-dependencies] +env_logger.workspace = true diff --git a/crates/plume_utils/src/device.rs b/crates/plume_utils/src/device.rs index 793afbdf..b1176b39 100644 --- a/crates/plume_utils/src/device.rs +++ b/crates/plume_utils/src/device.rs @@ -1,20 +1,28 @@ use std::fmt; use std::path::{Component, Path, PathBuf}; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; use idevice::core_device_proxy::CoreDeviceProxy; use idevice::installation_proxy::InstallationProxyClient; use idevice::lockdown::LockdownClient; use idevice::misagent::MisagentClient; use idevice::provider::UsbmuxdProvider; -use idevice::remote_pairing::{RemotePairingClient, RpPairingFile}; +use idevice::remote_pairing::{ + RemotePairingClient, RpPairingFile, RpPairingSocket, connect_tls_psk_tunnel_native, +}; +use idevice::remote_pairing::errors::RemotePairingError; use idevice::rsd::RsdHandshake; +use idevice::tcp::adapter::Adapter; +use idevice::tcp::handle::AdapterHandle; use idevice::usbmuxd::{Connection, UsbmuxdAddr, UsbmuxdDevice}; use idevice::utils::installation; -use idevice::{IdeviceService, RemoteXpcClient}; -use plume_core::MobileProvision; +use idevice::{IdeviceService, RemoteXpcClient, RsdService}; +use plume_core::{MobileProvision, developer::DeveloperPlatform}; use crate::Error; use crate::options::SignerAppReal; +use crate::pairing::{PairingBackend, PairingFailure, PairingStage, ensure_pairing}; use idevice::afc::opcode::AfcFopenMode; use idevice::house_arrest::HouseArrestClient; use idevice::usbmuxd::UsbmuxdConnection; @@ -24,6 +32,47 @@ pub const CONNECTION_LABEL: &str = "plume_info"; pub const INSTALLATION_LABEL: &str = "plume_install"; pub const HOUSE_ARREST_LABEL: &str = "plume_house_arrest"; +impl<'a, R: idevice::remote_pairing::RpPairingSocketProvider> PairingBackend + for RemotePairingClient<'a, R> +{ + async fn verify(&mut self) -> Result<(), PairingFailure> { + self.attempt_pair_verify() + .await + .map_err(|error| PairingFailure::Protocol(error.to_string()))?; + self.validate_pairing() + .await + .map_err(|error| PairingFailure::Protocol(error.to_string())) + } + + async fn pair(&mut self, pin: &str) -> Result<(), PairingFailure> { + let pin = pin.to_string(); + RemotePairingClient::connect( + self, + |_| { + let pin = pin.clone(); + async move { pin } + }, + (), + ) + .await + .map_err(|error| match error { + idevice::IdeviceError::RemotePairing(RemotePairingError::SrpAuthFailed) => { + PairingFailure::WrongPin + } + error => PairingFailure::Protocol(error.to_string()), + }) + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeviceTransport { + Usbmuxd, + RemotePairing, + CoreDevice, + LocalMac, + Unavailable, +} + macro_rules! get_dict_string { ($dict:expr, $key:expr) => { $dict @@ -39,49 +88,225 @@ macro_rules! get_dict_string { pub struct Device { pub name: String, pub udid: String, + pub product_type: Option, + pub device_class: Option, + pub os_version: Option, + pub serial_number: Option, pub device_id: u32, pub usbmuxd_device: Option, // On x86_64 macs, `is_mac` variable should never be true // since its only true if the device is added manually. pub is_mac: bool, + pub pairing_address: Option<(std::net::IpAddr, u16)>, + pub reconnect_address: Option<(std::net::IpAddr, u16)>, + pub pairing_identity: Option, + pub pairing_cache_dir: Option, + pub core_device_authenticated: bool, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct TvosDeviceInfo { + pub name: Option, + pub udid: Option, + pub product_type: Option, + pub device_class: Option, + pub os_version: Option, + pub serial_number: Option, +} + +impl TvosDeviceInfo { + pub fn from_rsd_properties(props: &std::collections::HashMap) -> Self { + let as_string = |key: &str| -> Option { + props + .get(key) + .and_then(|v| v.as_string()) + .map(str::to_string) + }; + + TvosDeviceInfo { + name: as_string("DeviceName").or_else(|| as_string("Name")), + udid: as_string("UniqueDeviceID"), + product_type: as_string("ProductType"), + device_class: as_string("DeviceClass"), + os_version: as_string("OSVersion") + .or_else(|| as_string("HumanReadableProductVersionString")), + serial_number: as_string("SerialNumber"), + } + } +} + +pub fn synthetic_device_id(pairing_identity: &str) -> u32 { + const FNV_OFFSET_BASIS: u32 = 0x811c_9dc5; + const FNV_PRIME: u32 = 0x0100_0193; + + let mut hash = FNV_OFFSET_BASIS; + for byte in pairing_identity.as_bytes() { + hash ^= *byte as u32; + hash = hash.wrapping_mul(FNV_PRIME); + } + + hash |= 0x8000_0000; + + if hash == u32::MAX { + hash = 0x8000_0000; + } + + hash } impl Device { pub async fn new(usbmuxd_device: UsbmuxdDevice) -> Self { - let name = Self::get_name_from_usbmuxd_device(&usbmuxd_device) + let values = Self::get_values_from_usbmuxd_device(&usbmuxd_device) .await + .ok(); + let name = values + .as_ref() + .map(|values| get_dict_string!(values, "DeviceName")) .unwrap_or_default(); + let product_type = values + .as_ref() + .map(|values| get_dict_string!(values, "ProductType")) + .filter(|value| !value.is_empty()); + let device_class = values + .as_ref() + .map(|values| get_dict_string!(values, "DeviceClass")) + .filter(|value| !value.is_empty()); + let os_version = values + .as_ref() + .map(|values| get_dict_string!(values, "ProductVersion")) + .filter(|value| !value.is_empty()); + let serial_number = values + .as_ref() + .map(|values| get_dict_string!(values, "SerialNumber")) + .filter(|value| !value.is_empty()); Device { name, udid: usbmuxd_device.udid.clone(), + product_type, + device_class, + os_version, + serial_number, device_id: usbmuxd_device.device_id.clone(), usbmuxd_device: Some(usbmuxd_device), is_mac: false, + pairing_address: None, + reconnect_address: None, + pairing_identity: None, + pairing_cache_dir: None, + core_device_authenticated: false, } } - async fn get_name_from_usbmuxd_device(device: &UsbmuxdDevice) -> Result { + async fn get_values_from_usbmuxd_device( + device: &UsbmuxdDevice, + ) -> Result { let mut lockdown = LockdownClient::connect(&device.to_provider(UsbmuxdAddr::default(), CONNECTION_LABEL)) .await?; - let values = lockdown.get_value(None, None).await?; - Ok(get_dict_string!(values, "DeviceName")) + Ok(lockdown.get_value(None, None).await?) } - pub async fn installed_apps(&self) -> Result, Error> { - let device = match &self.usbmuxd_device { - Some(dev) => dev, - None => return Err(Error::Other("Device is not connected via USB".to_string())), - }; + pub fn new_tvos( + name: String, + pairing_identity: String, + ip: std::net::IpAddr, + pairing_port: Option, + reconnect_port: Option, + cache_dir: PathBuf, + ) -> Self { + Device { + name, + udid: String::new(), + product_type: None, + device_class: Some("AppleTV".to_string()), + os_version: None, + serial_number: None, + device_id: 0, + usbmuxd_device: None, + is_mac: false, + pairing_address: pairing_port.map(|port| (ip, port)), + reconnect_address: reconnect_port.map(|port| (ip, port)), + pairing_identity: Some(pairing_identity), + pairing_cache_dir: Some(cache_dir), + core_device_authenticated: false, + } + } - let provider = device.to_provider( - UsbmuxdAddr::from_env_var().unwrap_or_default(), - INSTALLATION_LABEL, - ); + pub(crate) fn pairing_cache_path(&self, cache_dir: &Path) -> Result { + let key = self.pairing_identity.as_deref().unwrap_or(&self.udid); - let mut ic = InstallationProxyClient::connect(&provider).await?; - let apps = ic.get_apps(Some("User"), None).await?; + if key.is_empty() { + return Err(Error::Other( + "Device has neither a pairing identity nor a UDID; cannot locate its pairing \ + file cache" + .to_string(), + )); + } + if key.contains('/') + || key.contains('\\') + || key.contains(':') + || key.chars().all(|c| c == '.') + { + return Err(Error::Other(format!( + "Pairing identity {key:?} is not a valid cache key" + ))); + } + + Ok(cache_dir.join(format!("plume_{key}.plist"))) + } + + pub fn is_tvos(&self) -> bool { + self.developer_platform() == DeveloperPlatform::Tvos + } + + pub fn developer_platform(&self) -> DeveloperPlatform { + DeveloperPlatform::from_device_metadata( + self.product_type.as_deref(), + self.device_class.as_deref(), + self.is_network() || self.pairing_identity.is_some(), + ) + } + + pub fn is_network(&self) -> bool { + matches!( + self.transport(), + DeviceTransport::RemotePairing | DeviceTransport::CoreDevice + ) + } + + pub fn transport(&self) -> DeviceTransport { + if self.usbmuxd_device.is_some() { + DeviceTransport::Usbmuxd + } else if self.core_device_authenticated { + DeviceTransport::CoreDevice + } else if self.pairing_address.is_some() || self.reconnect_address.is_some() { + DeviceTransport::RemotePairing + } else if self.is_mac { + DeviceTransport::LocalMac + } else { + DeviceTransport::Unavailable + } + } + + pub async fn installed_apps(&self) -> Result, Error> { + let apps = if let Some(device) = &self.usbmuxd_device { + let provider = device.to_provider( + UsbmuxdAddr::from_env_var().unwrap_or_default(), + INSTALLATION_LABEL, + ); + let mut ic = InstallationProxyClient::connect(&provider).await?; + ic.get_apps(Some("User"), None).await? + } else if self.is_network() { + let cache_dir = self.pairing_cache_dir.clone().ok_or_else(|| { + Error::Other("Network Apple TV has no pairing cache directory".to_string()) + })?; + let (mut adapter, mut handshake) = self.establish_tvos_tunnel(cache_dir).await?; + let mut ic = InstallationProxyClient::connect_rsd(&mut adapter, &mut handshake).await?; + ic.get_apps(Some("User"), None).await? + } else { + return Err(Error::Other("Device has no installation transport".to_string())); + }; let mut found_apps = Vec::new(); @@ -105,35 +330,46 @@ impl Device { } pub async fn is_app_installed(&self, bundle_id: &str) -> Result { - let device = match &self.usbmuxd_device { - Some(dev) => dev, - None => return Err(Error::Other("Device is not connected via USB".to_string())), + let apps = if let Some(device) = &self.usbmuxd_device { + let provider = device.to_provider( + UsbmuxdAddr::from_env_var().unwrap_or_default(), + INSTALLATION_LABEL, + ); + let mut ic = InstallationProxyClient::connect(&provider).await?; + ic.get_apps(Some("User"), None).await? + } else if self.is_network() { + let cache_dir = self.pairing_cache_dir.clone().ok_or_else(|| { + Error::Other("Network Apple TV has no pairing cache directory".to_string()) + })?; + let (mut adapter, mut handshake) = self.establish_tvos_tunnel(cache_dir).await?; + let mut ic = InstallationProxyClient::connect_rsd(&mut adapter, &mut handshake).await?; + ic.get_apps(Some("User"), None).await? + } else { + return Err(Error::Other("Device has no installation transport".to_string())); }; - let provider = device.to_provider( - UsbmuxdAddr::from_env_var().unwrap_or_default(), - INSTALLATION_LABEL, - ); - - let mut ic = InstallationProxyClient::connect(&provider).await?; - let apps = ic.get_apps(Some("User"), None).await?; - Ok(apps.contains_key(bundle_id)) } pub async fn install_profile(&self, profile: &MobileProvision) -> Result<(), Error> { - if self.usbmuxd_device.is_none() { - return Err(Error::Other("Device is not connected via USB".to_string())); + if let Some(device) = &self.usbmuxd_device { + let provider = device.to_provider( + UsbmuxdAddr::from_env_var().unwrap_or_default(), + INSTALLATION_LABEL, + ); + let mut mc = MisagentClient::connect(&provider).await?; + mc.install(profile.data.clone()).await?; + } else if self.is_network() { + let cache_dir = self.pairing_cache_dir.clone().ok_or_else(|| { + Error::Other("Network Apple TV has no pairing cache directory".to_string()) + })?; + let (mut adapter, mut handshake) = self.establish_tvos_tunnel(cache_dir).await?; + let mut mc = MisagentClient::connect_rsd(&mut adapter, &mut handshake).await?; + mc.install(profile.data.clone()).await?; + } else { + return Err(Error::Other("Device has no installation transport".to_string())); } - let provider = self.usbmuxd_device.clone().unwrap().to_provider( - UsbmuxdAddr::from_env_var().unwrap_or_default(), - INSTALLATION_LABEL, - ); - - let mut mc = MisagentClient::connect(&provider).await?; - mc.install(profile.data.clone()).await?; - Ok(()) } @@ -316,14 +552,350 @@ impl Device { .connect(async |_| "000000".to_string(), ()) .await?; - let pairing_file_bytes = pairing_file.to_bytes(); - - tokio::fs::write(&pairing_file_path, &pairing_file_bytes).await?; + write_pairing_file(&pairing_file, &path, &pairing_file_path).await?; Ok(pairing_file) } } + async fn try_import_external_pairing( + &self, + cache_dir: &Path, + cache_path: &Path, + ) -> Result, Error> { + let Some((ip, port)) = self.reconnect_address.or(self.pairing_address) else { + return Ok(None); + }; + let address = std::net::SocketAddr::new(ip, port); + + for (source, mut pairing_file) in external_pairing_candidates() { + let stream = match tokio::net::TcpStream::connect(address).await { + Ok(stream) => stream, + Err(error) => { + log::debug!( + "Could not connect to Apple TV while trying external pairing record {}: {error}", + source + ); + continue; + } + }; + let sending_host = pairing_file.identifier.clone(); + let mut client = RemotePairingClient::new( + RpPairingSocket::new(stream), + &sending_host, + &mut pairing_file, + ); + let valid = client.attempt_pair_verify().await.is_ok() + && client.validate_pairing().await.is_ok(); + drop(client); + + if valid { + write_pairing_file(&pairing_file, cache_dir, cache_path).await?; + log::info!( + "Imported an existing Apple TV pairing record from {}", + source + ); + return Ok(Some(pairing_file)); + } + } + + Ok(None) + } + + pub async fn pair_tvos( + &self, + pin_provider: F, + cache_dir: PathBuf, + ) -> Result + where + F: Fn() -> Fut, + Fut: std::future::Future, + { + let cache_path = self.pairing_cache_path(&cache_dir)?; + + let cached_pairing_file = if cache_path.exists() { + match RpPairingFile::read_from_file(&cache_path).await { + Ok(pairing_file) => Some(pairing_file), + Err(error) => { + log::warn!( + "Removing unreadable Apple TV pairing record at {}: {error}", + cache_path.display() + ); + let _ = tokio::fs::remove_file(&cache_path).await; + None + } + } + } else { + None + }; + + if cached_pairing_file.is_none() { + if let Some(pairing_file) = + self.try_import_external_pairing(&cache_dir, &cache_path).await? + { + return Ok(pairing_file); + } + } + + let action = pairing_action( + cached_pairing_file.is_some(), + self.pairing_address.is_some(), + self.reconnect_address.is_some(), + ) + .map_err(Error::Other)?; + + if action == PairingAction::Reconnect { + let (ip, port) = self + .reconnect_address + .or(self.pairing_address) + .expect("pairing_action guarantees an address"); + let addr = std::net::SocketAddr::new(ip, port); + log::info!("tvOS pairing: reconnecting to {addr}"); + let stream = tokio::net::TcpStream::connect(addr).await.map_err(|e| { + Error::Other(format!( + "Failed to reconnect to Apple TV at {addr}: {e}; scan again if the device changed its advertised address" + )) + })?; + let mut pairing_file = cached_pairing_file + .expect("pairing_action selected reconnect with a cached pairing file"); + let sending_host = pairing_file.identifier.clone(); + let mut pairing_client = + RemotePairingClient::new(RpPairingSocket::new(stream), &sending_host, &mut pairing_file); + + let reconnect_result = match pairing_client.attempt_pair_verify().await { + Ok(_) => pairing_client.validate_pairing().await, + Err(error) => Err(error), + }; + drop(pairing_client); + + if reconnect_result.is_ok() { + write_pairing_file(&pairing_file, &cache_dir, &cache_path).await?; + log::info!("tvOS pairing: cached record reconnected without a PIN"); + return Ok(pairing_file); + } + + if cache_path.exists() { + log::warn!( + "Cached Apple TV pairing record at {} is stale; removing it", + cache_path.display() + ); + let _ = tokio::fs::remove_file(&cache_path).await; + } + } + + let (ip, port) = self.pairing_address.ok_or_else(|| { + Error::Other( + "Apple TV pairing requires its manual-pairing service. On the Apple TV, open Settings \ + > Remotes and Devices > Remote App and Devices and wait for \"Waiting to Pair...\", \ + then scan again." + .to_string(), + ) + })?; + + let addr = std::net::SocketAddr::new(ip, port); + log::info!("tvOS pairing: connecting to {addr}"); + let stream = tokio::net::TcpStream::connect(addr).await.map_err(|e| { + Error::Other(format!( + "Failed to connect to Apple TV at {addr}: {e}. The manual-pairing port changes \ + each time the Apple TV re-advertises, so a stale scan result will not connect - \ + scan again immediately before pairing." + )) + })?; + log::info!("tvOS pairing: TCP connected to {addr}, starting RPPairing handshake"); + + let conn = RpPairingSocket::new(stream); + let (mut pairing_file, sending_host) = { + let suffix: String = uuid::Uuid::new_v4() + .simple() + .to_string() + .chars() + .take(6) + .collect(); + let host = format!("plume-{suffix}"); + (RpPairingFile::generate(&host), host) + }; + + let mut pairing_client = RemotePairingClient::new(conn, &sending_host, &mut pairing_file); + let stage = ensure_pairing( + &mut pairing_client, + false, + true, + false, + pin_provider, + ) + .await + .map_err(pairing_failure_to_error)?; + if stage != PairingStage::Paired { + return Err(Error::Other( + "Apple TV pairing did not complete a new pairing".to_string(), + )); + } + log::info!("tvOS pairing: handshake succeeded, caching pairing file"); + + write_pairing_file(&pairing_file, &cache_dir, &cache_path).await?; + + Ok(pairing_file) + } + + pub async fn establish_tvos_tunnel( + &self, + cache_dir: PathBuf, + ) -> Result<(AdapterHandle, RsdHandshake), Error> { + let (ip, port) = self + .reconnect_address + .or(self.pairing_address) + .ok_or_else(|| Error::Other("Device has no network address".to_string()))?; + + let connect_addr = std::net::SocketAddr::new(ip, port); + + let cache_path = self.pairing_cache_path(&cache_dir)?; + let mut pairing_file = if cache_path.exists() { + RpPairingFile::read_from_file(&cache_path).await? + } else { + self.try_import_external_pairing(&cache_dir, &cache_path) + .await? + .ok_or_else(|| { + Error::Other( + "No pairing record is cached for this Apple TV; pair before reconnecting" + .to_string(), + ) + })? + }; + + let stream = tokio::net::TcpStream::connect(connect_addr) + .await + .map_err(|e| { + Error::Other(format!( + "Could not connect to Apple TV at {connect_addr}: {e}" + )) + })?; + let conn = RpPairingSocket::new(stream); + + let hostname = pairing_file.identifier.clone(); + let tunnel = { + let mut rpc = RemotePairingClient::new(conn, &hostname, &mut pairing_file); + + rpc.attempt_pair_verify() + .await + .map_err(|e| Error::Other(format!("Pair-verify failed: {e}")))?; + + if let Err(e) = rpc.validate_pairing().await { + if cache_path.exists() { + log::warn!( + "tvOS tunnel: cached pairing file at {} no longer verifies ({e}); \ + removing it", + cache_path.display() + ); + let _ = tokio::fs::remove_file(&cache_path).await; + } + return Err(Error::Other(format!( + "This Apple TV no longer recognizes this pairing (it may have been reset, \ + forgotten, or lost pairing after a system update); pair with it again: {e}" + ))); + } + + let tunnel_port = rpc + .create_tcp_listener() + .await + .map_err(|e| Error::Other(format!("Failed to create tunnel listener: {e}")))?; + + let tunnel_addr = std::net::SocketAddr::new(connect_addr.ip(), tunnel_port); + let tunnel_stream = tokio::net::TcpStream::connect(tunnel_addr) + .await + .map_err(|e| Error::Other(format!("TLS tunnel connect failed: {e}")))?; + + connect_tls_psk_tunnel_native(Box::new(tunnel_stream), rpc.encryption_key()) + .await + .map_err(|e| Error::Other(format!("TLS-PSK tunnel handshake failed: {e}")))? + }; + + let client_ip: std::net::IpAddr = tunnel + .info + .client_address + .parse() + .map_err(|e| Error::Other(format!("Invalid tunnel client address: {e}")))?; + let server_ip: std::net::IpAddr = tunnel + .info + .server_address + .parse() + .map_err(|e| Error::Other(format!("Invalid tunnel server address: {e}")))?; + let rsd_port = tunnel.info.server_rsd_port; + let mtu = tunnel.info.mtu as usize; + let mss = mtu.saturating_sub(60); + log::info!("tvOS tunnel: negotiated MTU {mtu}, using MSS {mss}"); + + let raw = tunnel.into_inner(); + let mut adapter = Adapter::new(Box::new(raw), client_ip, server_ip); + adapter.set_mss(mss); + let mut adapter_handle = adapter.to_async_handle(); + + let rsd_stream = adapter_handle + .connect(rsd_port) + .await + .map_err(|e| Error::Other(format!("RSD connection failed: {e}")))?; + let handshake = RsdHandshake::new(rsd_stream) + .await + .map_err(|e| Error::Other(format!("RSD handshake failed: {e}")))?; + + Ok((adapter_handle, handshake)) + } + + pub fn has_cached_pairing_file(&self, cache_dir: &Path) -> bool { + self.pairing_cache_path(cache_dir) + .map(|path| path.exists()) + .unwrap_or(false) + } + + pub fn has_pairing_source(&self, cache_dir: &Path) -> bool { + self.has_cached_pairing_file(cache_dir) + || external_pairing_paths() + .into_iter() + .any(|path| path.exists()) + } + + pub async fn forget_tvos_pairing(&self, cache_dir: PathBuf) -> Result<(), Error> { + let path = self.pairing_cache_path(&cache_dir)?; + match tokio::fs::remove_file(&path).await { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } + } + + pub async fn fetch_tvos_info(&self, cache_dir: PathBuf) -> Result { + let (_adapter, handshake) = self.establish_tvos_tunnel(cache_dir).await?; + Ok(TvosDeviceInfo::from_rsd_properties(&handshake.properties)) + } + + pub fn apply_tvos_info(&mut self, info: &TvosDeviceInfo) { + if self.pairing_identity.is_none() { + return; + } + if let Some(name) = info.name.as_deref().filter(|value| !value.is_empty()) { + self.name = name.to_string(); + } + if let Some(udid) = info.udid.as_deref() { + if crate::is_valid_device_udid(udid) { + self.udid = udid.to_string(); + } + } + if info.product_type.is_some() { + self.product_type = info.product_type.clone(); + } + if info.device_class.is_some() { + self.device_class = info.device_class.clone(); + } + if info.os_version.is_some() { + self.os_version = info.os_version.clone(); + } + if info.serial_number.is_some() { + self.serial_number = info.serial_number.clone(); + } + if info.udid.as_deref().is_some_and(crate::is_valid_device_udid) { + self.core_device_authenticated = true; + } + } + pub async fn install_app( &self, app_path: &PathBuf, @@ -333,31 +905,311 @@ impl Device { F: FnMut(i32) -> Fut + Send + Clone + 'static, Fut: std::future::Future + Send, { - if self.usbmuxd_device.is_none() { - return Err(Error::Other("Device is not connected via USB".to_string())); - } - - let provider = self.usbmuxd_device.clone().unwrap().to_provider( - UsbmuxdAddr::from_env_var().unwrap_or_default(), - INSTALLATION_LABEL, - ); - let callback = move |(progress, _): (u64, ())| { let mut cb = progress_callback.clone(); async move { cb(progress as i32).await; } }; - let state = (); - installation::install_package_with_callback(&provider, app_path, None, callback, state) + if self.usbmuxd_device.is_some() { + let provider = self.usbmuxd_device.clone().unwrap().to_provider( + UsbmuxdAddr::from_env_var().unwrap_or_default(), + INSTALLATION_LABEL, + ); + + installation::install_package_with_callback(&provider, app_path, None, callback, state) + .await?; + } else if self.is_network() { + let cache_dir = self.pairing_cache_dir.clone().ok_or_else(|| { + Error::Other( + "Network Apple TV has no pairing_cache_dir configured on this Device; \ + install_app has nowhere to look for its pairing file" + .to_string(), + ) + })?; + + let (mut adapter, mut handshake) = self.establish_tvos_tunnel(cache_dir).await?; + + installation::install_package_with_callback_rsd( + &mut adapter, + &mut handshake, + app_path, + None, + callback, + state, + ) .await?; + } else { + return Err(Error::Other( + "Device has no USB connection and no network address; cannot install".to_string(), + )); + } Ok(()) } } +async fn write_pairing_file( + pairing_file: &RpPairingFile, + cache_dir: &Path, + cache_path: &Path, +) -> Result<(), Error> { + tokio::fs::create_dir_all(cache_dir).await?; + + #[cfg(unix)] + tokio::fs::set_permissions( + cache_dir, + std::fs::Permissions::from_mode(0o700), + ) + .await?; + + let temporary_path = cache_path.with_extension("plist.tmp"); + tokio::fs::write(&temporary_path, pairing_file.to_bytes()).await?; + + #[cfg(unix)] + tokio::fs::set_permissions( + &temporary_path, + std::fs::Permissions::from_mode(0o600), + ) + .await?; + + tokio::fs::rename(&temporary_path, cache_path).await?; + + #[cfg(unix)] + tokio::fs::set_permissions(cache_path, std::fs::Permissions::from_mode(0o600)).await?; + + Ok(()) +} + +fn external_pairing_paths() -> Vec { + #[cfg(target_os = "macos")] + { + let mut paths = Vec::new(); + + if let Some(home) = std::env::var_os("HOME") { + let pymobiledevice_dir = PathBuf::from(home).join(".pymobiledevice3"); + if let Ok(entries) = std::fs::read_dir(pymobiledevice_dir) { + for entry in entries.flatten() { + let path = entry.path(); + if path + .file_name() + .and_then(|name| name.to_str()) + .is_some_and(|name| name.starts_with("remote_") && name.ends_with(".plist")) + { + paths.push(path); + } + } + } + } + + let native_dir = Path::new("/var/db/lockdown/RemotePairing"); + if let Ok(entries) = std::fs::read_dir(native_dir) { + for entry in entries.flatten() { + let path = entry.path().join("selfIdentity.plist"); + if path.is_file() { + paths.push(path); + } + } + } + + paths.sort(); + paths.dedup(); + paths + } + + #[cfg(not(target_os = "macos"))] + { + Vec::new() + } +} + +fn external_pairing_candidates() -> Vec<(String, RpPairingFile)> { + let mut candidates = Vec::new(); + + for path in external_pairing_paths() { + let Ok(bytes) = std::fs::read(&path) else { + continue; + }; + + if path.file_name().and_then(|name| name.to_str()) == Some("selfIdentity.plist") { + let peer_paths = path + .parent() + .map(|parent| parent.join("peers")) + .and_then(|directory| std::fs::read_dir(directory).ok()) + .into_iter() + .flatten() + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("plist")) + .collect::>(); + let peer_bytes = peer_paths + .iter() + .filter_map(|peer_path| std::fs::read(peer_path).ok()) + .collect::>(); + let peer_refs = peer_bytes.iter().map(Vec::as_slice).collect::>(); + + if let Ok(native_candidates) = native_pairing_candidates_from_bytes(&bytes, &peer_refs) + { + for (index, candidate) in native_candidates.into_iter().enumerate() { + let source = if index == 0 { + path.display().to_string() + } else { + peer_paths + .get(index - 1) + .map(|peer_path| peer_path.display().to_string()) + .unwrap_or_else(|| path.display().to_string()) + }; + candidates.push((source, candidate)); + } + } + } else if let Ok(candidate) = external_pairing_file_from_bytes(&bytes) { + candidates.push((path.display().to_string(), candidate)); + } + } + + candidates +} + +fn native_pairing_candidates_from_bytes( + host_bytes: &[u8], + peer_bytes: &[&[u8]], +) -> Result, Error> { + let mut host = external_pairing_file_from_bytes(host_bytes)?; + host.alt_irk = None; + + let mut candidates = vec![host.clone()]; + for bytes in peer_bytes { + let Ok(peer) = plist::from_bytes::(bytes) else { + continue; + }; + let Some(irk) = ["irk", "altIRK", "alt_irk"].iter().find_map(|key| { + peer.get(*key) + .and_then(plist::Value::as_data) + .filter(|data| data.len() == 16) + .map(|data| data.to_vec()) + }) else { + continue; + }; + + let mut candidate = host.clone(); + candidate.alt_irk = Some(irk); + candidates.push(candidate); + } + + Ok(candidates) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PairingAction { + FirstPairing, + Reconnect, +} + +fn pairing_action( + has_cached_pairing: bool, + has_pairing_service: bool, + has_reconnect_service: bool, +) -> Result { + if has_cached_pairing && (has_reconnect_service || has_pairing_service) { + Ok(PairingAction::Reconnect) + } else if !has_cached_pairing && has_pairing_service { + Ok(PairingAction::FirstPairing) + } else if has_cached_pairing { + Err("Apple TV pairing record is stale and no pairing service is currently advertised".to_string()) + } else { + Err( + "Apple TV is not advertising its manual-pairing service; open Settings > Remotes and Devices > Remote App and Devices and wait for \"Waiting to Pair...\", then scan again".to_string(), + ) + } +} + +fn pairing_failure_to_error(failure: PairingFailure) -> Error { + match failure { + PairingFailure::Cancelled => Error::Other("Apple TV pairing was cancelled".to_string()), + PairingFailure::InvalidPin => { + Error::Other("Apple TV pairing PIN must contain exactly six digits".to_string()) + } + PairingFailure::WrongPin => Error::Other("Apple TV rejected the pairing PIN".to_string()), + PairingFailure::StaleRecord => Error::Other( + "This Apple TV pairing record is stale; open the Apple TV pairing screen and try again" + .to_string(), + ), + PairingFailure::ServiceDisappeared => Error::Other( + "Apple TV pairing service disappeared; scan again while the pairing screen is open" + .to_string(), + ), + PairingFailure::Protocol(message) => { + Error::Other(format!("RPPairing handshake failed: {message}")) + } + } +} + +fn local_remote_pairing_identifier() -> Option { + let hostname = std::env::var("HOSTNAME") + .ok() + .filter(|hostname| !hostname.trim().is_empty()) + .or_else(|| { + std::process::Command::new("hostname") + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8(output.stdout).ok()) + .map(|hostname| hostname.trim().to_string()) + .filter(|hostname| !hostname.is_empty()) + })?; + + Some( + uuid::Uuid::new_v3(&uuid::Uuid::NAMESPACE_DNS, hostname.as_bytes()) + .to_string() + .to_uppercase(), + ) +} + +fn external_pairing_file_from_bytes(bytes: &[u8]) -> Result { + let source: plist::Dictionary = plist::from_bytes(bytes)?; + let data_field = |names: &[&str]| { + names + .iter() + .find_map(|name| source.get(*name).and_then(plist::Value::as_data)) + .map(|data| data.to_vec()) + }; + let string_field = |names: &[&str]| { + names + .iter() + .find_map(|name| source.get(*name).and_then(plist::Value::as_string)) + .map(str::to_string) + }; + + let public_key = data_field(&["public_key", "publicKey"]) + .filter(|key| key.len() == 32) + .ok_or_else(|| Error::Other("External pairing record has no valid public key".to_string()))?; + let private_key = data_field(&["private_key", "privateKey"]) + .filter(|key| key.len() == 32) + .ok_or_else(|| Error::Other("External pairing record has no valid private key".to_string()))?; + let identifier = string_field(&["identifier", "host_identifier"]) + .or_else(local_remote_pairing_identifier) + .ok_or_else(|| Error::Other("External pairing record has no host identifier".to_string()))?; + + let mut normalized = plist::Dictionary::new(); + normalized.insert("public_key".to_string(), plist::Value::Data(public_key)); + normalized.insert("private_key".to_string(), plist::Value::Data(private_key)); + normalized.insert( + "identifier".to_string(), + plist::Value::String(identifier), + ); + if let Some(irk) = data_field(&["alt_irk", "irk", "host_alt_irk"]) { + if irk.len() == 16 { + normalized.insert("alt_irk".to_string(), plist::Value::Data(irk)); + } + } + + let mut normalized_bytes = Vec::new(); + plist::to_writer_xml(&mut normalized_bytes, &normalized)?; + Ok(RpPairingFile::from_bytes(&normalized_bytes)?) +} + fn get_app_name_from_info(info: &Value) -> Option { let dict = info.as_dictionary()?; dict.get("CFBundleDisplayName") @@ -372,9 +1224,9 @@ fn get_app_name_from_info(info: &Value) -> Option { impl fmt::Display for Device { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "[{}] {}", + let conn = if self.pairing_address.is_some() || self.reconnect_address.is_some() { + "WiFi (tvOS)" + } else { match &self.usbmuxd_device { Some(device) => match &device.connection_type { Connection::Usb => "USB", @@ -382,9 +1234,16 @@ impl fmt::Display for Device { Connection::Unknown(_) => "Unknown", }, None => "LOCAL", - }, - self.name - ) + } + }; + let identity = if crate::is_valid_device_udid(&self.udid) { + format!(" [{}…{}]", &self.udid[..8], &self.udid[self.udid.len() - 4..]) + } else if self.is_network() { + " [unpaired]".to_string() + } else { + String::new() + }; + write!(f, "[{conn}] {}{identity}", self.name) } } @@ -394,7 +1253,9 @@ pub async fn get_device_for_id(device_id: &str) -> Result { .get_devices() .await? .into_iter() - .find(|d| d.device_id.to_string() == device_id) + .find(|d| { + d.device_id.to_string() == device_id || d.udid.eq_ignore_ascii_case(device_id) + }) .ok_or_else(|| Error::Other(format!("Device ID {device_id} not found")))?; Ok(Device::new(usbmuxd_device).await) @@ -456,3 +1317,644 @@ pub async fn install_app_mac(app_path: &PathBuf) -> Result<(), Error> { pub async fn install_app_mac(_app_path: &PathBuf) -> Result<(), Error> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn real_rsd_properties() -> HashMap { + let mut props = HashMap::new(); + props.insert( + "UniqueDeviceID".to_string(), + plist::Value::String("00008110-001E60481AD9401E".to_string()), + ); + props.insert( + "ProductType".to_string(), + plist::Value::String("AppleTV14,1".to_string()), + ); + props.insert( + "DeviceClass".to_string(), + plist::Value::String("AppleTV".to_string()), + ); + props.insert( + "OSVersion".to_string(), + plist::Value::String("26.5".to_string()), + ); + props.insert( + "HumanReadableProductVersionString".to_string(), + plist::Value::String("26.5".to_string()), + ); + props.insert( + "SerialNumber".to_string(), + plist::Value::String("C6FCY44V73".to_string()), + ); + props.insert( + "HWModel".to_string(), + plist::Value::String("J255AP".to_string()), + ); + props.insert( + "ProductName".to_string(), + plist::Value::String("Apple TVOS".to_string()), + ); + props.insert( + "BuildVersion".to_string(), + plist::Value::String("23L471".to_string()), + ); + props + } + + #[test] + fn from_rsd_properties_reads_real_device_fields() { + let info = TvosDeviceInfo::from_rsd_properties(&real_rsd_properties()); + assert_eq!(info.udid.as_deref(), Some("00008110-001E60481AD9401E")); + assert_eq!(info.product_type.as_deref(), Some("AppleTV14,1")); + assert_eq!(info.device_class.as_deref(), Some("AppleTV")); + assert_eq!(info.os_version.as_deref(), Some("26.5")); + assert_eq!(info.serial_number.as_deref(), Some("C6FCY44V73")); + } + + #[test] + fn from_rsd_properties_empty_map_yields_default() { + let info = TvosDeviceInfo::from_rsd_properties(&HashMap::new()); + assert_eq!(info, TvosDeviceInfo::default()); + } + + #[test] + fn from_rsd_properties_non_string_value_yields_none() { + let mut props = HashMap::new(); + props.insert( + "UniqueDeviceID".to_string(), + plist::Value::Integer(12345.into()), + ); + + let info = TvosDeviceInfo::from_rsd_properties(&props); + assert_eq!(info.udid, None); + } + + #[test] + fn from_rsd_properties_falls_back_to_human_readable_version() { + let mut props = HashMap::new(); + props.insert( + "HumanReadableProductVersionString".to_string(), + plist::Value::String("17.1".to_string()), + ); + + let info = TvosDeviceInfo::from_rsd_properties(&props); + assert_eq!(info.os_version.as_deref(), Some("17.1")); + } + + #[test] + fn from_rsd_properties_prefers_os_version_over_human_readable_when_both_present() { + let mut props = HashMap::new(); + props.insert( + "OSVersion".to_string(), + plist::Value::String("26.5".to_string()), + ); + props.insert( + "HumanReadableProductVersionString".to_string(), + plist::Value::String("26.5 (23L471)".to_string()), + ); + + let info = TvosDeviceInfo::from_rsd_properties(&props); + assert_eq!(info.os_version.as_deref(), Some("26.5")); + } + + #[test] + fn new_tvos_leaves_udid_empty_and_sets_pairing_identity() { + let d = Device::new_tvos( + "Apple TV".to_string(), + "Apple-TV".to_string(), + std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), + Some(1234), + None, + std::env::temp_dir(), + ); + assert!(d.udid.is_empty()); + assert_eq!(d.pairing_identity.as_deref(), Some("Apple-TV")); + } + + #[test] + fn new_tvos_stores_pairing_cache_dir() { + let cache_dir = std::env::temp_dir().join("plume_test_new_tvos_cache_dir"); + let d = Device::new_tvos( + "Apple TV".to_string(), + "Apple-TV".to_string(), + std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), + Some(1234), + None, + cache_dir.clone(), + ); + assert_eq!(d.pairing_cache_dir, Some(cache_dir)); + } + + fn stub_device() -> Device { + Device { + name: "Test Device".to_string(), + udid: "00008110-000C25540CD1801E".to_string(), + product_type: None, + device_class: None, + os_version: None, + serial_number: None, + device_id: 0, + usbmuxd_device: None, + is_mac: false, + pairing_address: None, + reconnect_address: None, + pairing_identity: None, + pairing_cache_dir: None, + core_device_authenticated: false, + } + } + + fn stub_tvos_device() -> Device { + let mut d = stub_device(); + d.pairing_identity = Some("stable-key".to_string()); + d + } + + #[test] + fn apply_tvos_info_none_udid_leaves_existing_udid_unchanged() { + let mut device = stub_tvos_device(); + let info = TvosDeviceInfo { + udid: None, + ..Default::default() + }; + device.apply_tvos_info(&info); + assert_eq!(device.udid, "00008110-000C25540CD1801E"); + } + + #[test] + fn apply_tvos_info_some_udid_overwrites_existing_udid() { + let mut device = stub_tvos_device(); + let info = TvosDeviceInfo { + udid: Some("00008110-000C25540CD1801F".to_string()), + ..Default::default() + }; + device.apply_tvos_info(&info); + assert_eq!(device.udid, "00008110-000C25540CD1801F"); + } + + #[test] + fn apply_tvos_info_empty_udid_leaves_existing_udid_unchanged() { + let mut device = stub_tvos_device(); + let info = TvosDeviceInfo { + udid: Some(String::new()), + ..Default::default() + }; + device.apply_tvos_info(&info); + assert_eq!(device.udid, "00008110-000C25540CD1801E"); + } + + #[test] + fn apply_tvos_info_no_op_when_device_has_no_pairing_identity() { + let mut device = stub_device(); + let info = TvosDeviceInfo { + udid: Some("00008110-000C25540CD1801F".to_string()), + ..Default::default() + }; + device.apply_tvos_info(&info); + assert_eq!(device.udid, "00008110-000C25540CD1801E"); + } + + #[test] + fn is_tvos_true_for_network_paired_device() { + let device = stub_tvos_device(); + assert!(device.is_tvos()); + } + + #[test] + fn is_tvos_false_for_usb_device() { + let device = stub_device(); + assert!(!device.is_tvos()); + } + + #[test] + fn new_tvos_device_reports_is_tvos() { + let d = Device::new_tvos( + "Apple TV".to_string(), + "Apple-TV".to_string(), + std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), + Some(1234), + None, + std::env::temp_dir(), + ); + assert!(d.is_tvos()); + assert_eq!(d.transport(), DeviceTransport::RemotePairing); + } + + #[test] + fn authenticated_rsd_metadata_promotes_remote_pairing_to_core_device() { + let mut device = Device::new_tvos( + "Apple TV".to_string(), + "Apple-TV".to_string(), + std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), + Some(1234), + Some(1235), + std::env::temp_dir(), + ); + + device.apply_tvos_info(&TvosDeviceInfo { + udid: Some("00008110-000C25540CD1801E".to_string()), + ..Default::default() + }); + + assert_eq!(device.transport(), DeviceTransport::CoreDevice); + assert!(device.is_network()); + } + + #[test] + fn transport_identifies_usb_and_unavailable_devices() { + let mut usb = stub_device(); + usb.usbmuxd_device = Some(UsbmuxdDevice { + connection_type: Connection::Usb, + udid: usb.udid.clone(), + device_id: usb.device_id, + }); + assert_eq!(usb.transport(), DeviceTransport::Usbmuxd); + + let mut unavailable = usb; + unavailable.usbmuxd_device = None; + assert_eq!(unavailable.transport(), DeviceTransport::Unavailable); + } + + #[test] + fn pairing_cache_path_prefers_pairing_identity_over_udid() { + let device = stub_tvos_device(); + let cache_dir = Path::new("/cache"); + assert_eq!( + device.pairing_cache_path(cache_dir).unwrap(), + cache_dir.join("plume_stable-key.plist") + ); + } + + #[test] + fn pairing_cache_path_falls_back_to_udid_when_no_pairing_identity() { + let device = stub_device(); + let cache_dir = Path::new("/cache"); + assert_eq!( + device.pairing_cache_path(cache_dir).unwrap(), + cache_dir.join("plume_00008110-000C25540CD1801E.plist") + ); + } + + #[test] + fn pairing_cache_path_rejects_empty_key() { + let mut device = stub_device(); + device.udid = String::new(); + let cache_dir = Path::new("/cache"); + assert!(device.pairing_cache_path(cache_dir).is_err()); + } + + #[test] + fn pairing_cache_path_rejects_dots_only_key() { + let mut device = stub_device(); + device.pairing_identity = Some("..".to_string()); + let cache_dir = Path::new("/cache"); + assert!(device.pairing_cache_path(cache_dir).is_err()); + } + + #[test] + fn pairing_cache_path_rejects_key_with_path_separator() { + let mut device = stub_device(); + device.pairing_identity = Some("../evil".to_string()); + let cache_dir = Path::new("/cache"); + assert!(device.pairing_cache_path(cache_dir).is_err()); + } + + fn unique_temp_dir(tag: &str) -> PathBuf { + std::env::temp_dir().join(format!( + "plume_test_{tag}_{}", + uuid::Uuid::new_v4().simple() + )) + } + + #[test] + fn has_cached_pairing_file_reports_presence_and_absence() { + let cache_dir = unique_temp_dir("has_cached_pairing_file"); + std::fs::create_dir_all(&cache_dir).expect("create scratch cache dir"); + + let mut device = stub_tvos_device(); + device.pairing_identity = Some("has-cache-test".to_string()); + + assert!(!device.has_cached_pairing_file(&cache_dir)); + + let cache_path = device.pairing_cache_path(&cache_dir).unwrap(); + std::fs::write(&cache_path, b"stub").unwrap(); + + assert!(device.has_cached_pairing_file(&cache_dir)); + + std::fs::remove_dir_all(&cache_dir).ok(); + } + + #[test] + fn external_pairing_record_import_supports_native_and_pymobiledevice_shapes() { + let source = RpPairingFile::generate("external-record-test"); + let mut native = plist::Dictionary::new(); + native.insert( + "publicKey".to_string(), + plist::Value::Data(source.public_key_bytes()), + ); + native.insert( + "privateKey".to_string(), + plist::Value::Data(source.private_key_bytes()), + ); + native.insert( + "identifier".to_string(), + plist::Value::String(source.identifier.clone()), + ); + native.insert("irk".to_string(), plist::Value::Data(vec![7; 16])); + + let mut native_bytes = Vec::new(); + plist::to_writer_xml(&mut native_bytes, &native).unwrap(); + let imported_native = external_pairing_file_from_bytes(&native_bytes).unwrap(); + assert_eq!(imported_native.identifier, source.identifier); + assert_eq!(imported_native.public_key_bytes(), source.public_key_bytes()); + assert_eq!(imported_native.alt_irk(), Some(&[7; 16][..])); + + let mut pymobiledevice = plist::Dictionary::new(); + pymobiledevice.insert( + "public_key".to_string(), + plist::Value::Data(source.public_key_bytes()), + ); + pymobiledevice.insert( + "private_key".to_string(), + plist::Value::Data(source.private_key_bytes()), + ); + pymobiledevice.insert( + "remote_unlock_host_key".to_string(), + plist::Value::String("host-key".to_string()), + ); + + let mut pymobiledevice_bytes = Vec::new(); + plist::to_writer_xml(&mut pymobiledevice_bytes, &pymobiledevice).unwrap(); + let imported_pymobiledevice = external_pairing_file_from_bytes(&pymobiledevice_bytes).unwrap(); + assert_eq!( + imported_pymobiledevice.public_key_bytes(), + source.public_key_bytes() + ); + assert_eq!( + imported_pymobiledevice.private_key_bytes(), + source.private_key_bytes() + ); + } + + #[test] + fn native_pairing_candidates_combine_xcode_host_identity_with_each_peer() { + let source = RpPairingFile::generate("native-record-test"); + let mut host = plist::Dictionary::new(); + host.insert( + "publicKey".to_string(), + plist::Value::Data(source.public_key_bytes()), + ); + host.insert( + "privateKey".to_string(), + plist::Value::Data(source.private_key_bytes()), + ); + host.insert( + "identifier".to_string(), + plist::Value::String(source.identifier.clone()), + ); + host.insert("irk".to_string(), plist::Value::Data(vec![1; 16])); + + let mut host_bytes = Vec::new(); + plist::to_writer_xml(&mut host_bytes, &host).unwrap(); + + let mut peer_a = plist::Dictionary::new(); + peer_a.insert("irk".to_string(), plist::Value::Data(vec![2; 16])); + let mut peer_b = plist::Dictionary::new(); + peer_b.insert("irk".to_string(), plist::Value::Data(vec![3; 16])); + let mut peer_a_bytes = Vec::new(); + let mut peer_b_bytes = Vec::new(); + plist::to_writer_xml(&mut peer_a_bytes, &peer_a).unwrap(); + plist::to_writer_xml(&mut peer_b_bytes, &peer_b).unwrap(); + + let candidates = native_pairing_candidates_from_bytes( + &host_bytes, + &[peer_a_bytes.as_slice(), peer_b_bytes.as_slice()], + ) + .unwrap(); + + assert_eq!(candidates.len(), 3); + assert_eq!(candidates[0].alt_irk(), None); + assert_eq!(candidates[1].alt_irk(), Some(&[2; 16][..])); + assert_eq!(candidates[2].alt_irk(), Some(&[3; 16][..])); + assert!(candidates + .iter() + .all(|candidate| candidate.identifier == source.identifier)); + } + + #[test] + fn pairing_action_covers_first_pairing_saved_reconnect_stale_and_disappeared_services() { + assert_eq!( + pairing_action(false, true, false), + Ok(PairingAction::FirstPairing) + ); + assert_eq!( + pairing_action(true, false, true), + Ok(PairingAction::Reconnect) + ); + assert!(pairing_action(true, false, false) + .unwrap_err() + .contains("stale")); + assert!(pairing_action(false, false, true) + .unwrap_err() + .contains("manual-pairing")); + } + + #[cfg(unix)] + #[tokio::test] + async fn pairing_cache_uses_restrictive_permissions() { + let cache_dir = unique_temp_dir("pairing_permissions"); + let cache_path = cache_dir.join("plume_permissions.plist"); + let pairing_file = RpPairingFile::generate("permissions-test"); + + write_pairing_file(&pairing_file, &cache_dir, &cache_path) + .await + .unwrap(); + + assert_eq!( + std::fs::metadata(&cache_dir) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o700 + ); + assert_eq!( + std::fs::metadata(&cache_path) + .unwrap() + .permissions() + .mode() + & 0o777, + 0o600 + ); + + std::fs::remove_dir_all(&cache_dir).unwrap(); + } + + #[test] + fn is_network_follows_the_transport_install_app_picks() { + let mut device = stub_device(); + assert!( + !device.is_network(), + "a device with no transport at all is not a network device" + ); + + device.reconnect_address = + Some((std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), 49151)); + assert!(device.is_network(), "a reconnect address makes it network"); + + device.reconnect_address = None; + device.pairing_address = Some((std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), 49152)); + assert!(device.is_network(), "a pairing address makes it network"); + + let mut mac = stub_device(); + mac.is_mac = true; + assert!( + !mac.is_network(), + "the local Mac is not reached over a tunnel" + ); + } + + async fn noop_callback(_progress: i32) {} + + #[tokio::test] + async fn install_app_with_no_transport_names_the_missing_transport() { + let device = stub_device(); + + let err = device + .install_app(&PathBuf::from("nonexistent.ipa"), noop_callback) + .await + .unwrap_err(); + + let msg = err.to_string(); + assert!( + msg.contains("no USB connection") && msg.contains("no network address"), + "expected a message naming both missing transports, got: {msg}" + ); + } + + #[tokio::test] + async fn install_app_network_device_without_cache_dir_returns_distinct_error() { + let mut device = stub_device(); + device.reconnect_address = + Some((std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), 49151)); + assert!(device.pairing_cache_dir.is_none()); + + let err = device + .install_app(&PathBuf::from("nonexistent.ipa"), noop_callback) + .await + .unwrap_err(); + + let msg = err.to_string(); + assert!( + msg.contains("pairing_cache_dir"), + "expected the missing-cache-dir error, got: {msg}" + ); + assert!(!msg.contains("no USB connection")); + assert!(!msg.contains("No pairing file is cached")); + } + + #[tokio::test] + async fn install_app_network_device_with_no_pairing_file_errors_before_tunnel() { + let cache_dir = unique_temp_dir("no_pairing_file"); + std::fs::create_dir_all(&cache_dir).expect("create scratch cache dir"); + + let mut device = stub_device(); + device.reconnect_address = + Some((std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), 49151)); + device.pairing_cache_dir = Some(cache_dir.clone()); + + let err = device + .install_app(&PathBuf::from("nonexistent.ipa"), noop_callback) + .await + .unwrap_err(); + + let msg = err.to_string(); + assert!( + msg.contains("No pairing record is cached"), + "expected the missing-pairing-file error, got: {msg}" + ); + assert!(!msg.contains("pairing_cache_dir")); + + std::fs::remove_dir_all(&cache_dir).ok(); + } + + fn generated_identities(count: usize) -> Vec { + (0..count).map(|i| format!("dev-{i}")).collect() + } + + #[test] + fn synthetic_device_id_is_deterministic() { + for name in generated_identities(100_000) { + assert_eq!(synthetic_device_id(&name), synthetic_device_id(&name)); + } + } + + #[test] + fn synthetic_device_id_never_zero_or_u32_max() { + for name in generated_identities(100_000) { + let id = synthetic_device_id(&name); + assert_ne!(id, 0, "input {name:?} produced 0"); + assert_ne!(id, u32::MAX, "input {name:?} produced u32::MAX"); + } + + for input in ["", &"x".repeat(500)] { + let id = synthetic_device_id(input); + assert_ne!(id, 0, "input {input:?} produced 0"); + assert_ne!(id, u32::MAX, "input {input:?} produced u32::MAX"); + } + } + + #[test] + fn synthetic_device_id_top_bit_always_set() { + let inputs = [ + "", + "a", + "Living-Room", + "Bedroom", + "Apple-TV", + "Office", + &"z".repeat(200), + ]; + for input in inputs { + let id = synthetic_device_id(input); + assert_eq!( + id & 0x8000_0000, + 0x8000_0000, + "input {input:?} did not have the top bit set" + ); + } + } + + #[test] + fn synthetic_device_id_distinct_for_realistic_names() { + let names = ["Living-Room", "Bedroom", "Apple-TV", "Office"]; + let ids: Vec = names.iter().map(|n| synthetic_device_id(n)).collect(); + for i in 0..ids.len() { + for j in (i + 1)..ids.len() { + assert_ne!( + ids[i], ids[j], + "{:?} and {:?} produced the same id", + names[i], names[j] + ); + } + } + } + + #[test] + fn synthetic_device_id_known_value_regression() { + assert_eq!(synthetic_device_id("Living-Room"), 0xe3eb1b88); + } + + #[tokio::test] + async fn establish_tvos_tunnel_takes_no_pin_argument() { + let device = stub_device(); + let err = device + .establish_tvos_tunnel(std::env::temp_dir()) + .await + .unwrap_err(); + assert!(err.to_string().contains("no network address")); + } +} diff --git a/crates/plume_utils/src/discovery/mdns.rs b/crates/plume_utils/src/discovery/mdns.rs new file mode 100644 index 00000000..02e19a48 --- /dev/null +++ b/crates/plume_utils/src/discovery/mdns.rs @@ -0,0 +1,161 @@ +use super::{ + ALL_SCANNED_SERVICE_TYPES, DeviceDiscovery, DiscoveredDevice, build_device, enrich_and_filter, + dedup_key, parse_instance_name, +}; +use mdns_sd::{ServiceDaemon, ServiceEvent}; +use std::collections::HashMap; +use std::time::Duration; + +pub use super::{ + APPLE_MOBDEV2_SERVICE, APPLE_PAIRABLE_SERVICE, REMOTEPAIRING_MANUAL_PAIRING_SERVICE, + REMOTEPAIRING_SERVICE, +}; + +pub struct MdnsDiscovery { + service_types: Vec, +} + +impl MdnsDiscovery { + pub fn new() -> Self { + Self { + service_types: ALL_SCANNED_SERVICE_TYPES + .iter() + .map(|s| s.to_string()) + .collect(), + } + } +} + +impl Default for MdnsDiscovery { + fn default() -> Self { + Self::new() + } +} + +impl DeviceDiscovery for MdnsDiscovery { + async fn discover(&self, timeout: Duration) -> crate::Result> { + let mdns = ServiceDaemon::new() + .map_err(|e| crate::Error::Other(format!("Failed to create mDNS daemon: {e}")))?; + + let mut receivers = Vec::new(); + for service_type in &self.service_types { + match mdns.browse(service_type) { + Ok(receiver) => receivers.push((service_type.clone(), receiver)), + Err(e) => { + log::warn!("Failed to browse {service_type}: {e}"); + } + } + } + + let service_types = self.service_types.clone(); + let discovered = tokio::task::spawn_blocking(move || { + let mut discovered_devices: HashMap<(String, String), DiscoveredDevice> = + HashMap::new(); + let deadline = std::time::Instant::now() + timeout; + + while std::time::Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + let poll_time = remaining.min(Duration::from_millis(200)); + let mut got_event = false; + + for (service_type, receiver) in &receivers { + match receiver.recv_timeout(poll_time) { + Ok(ServiceEvent::ServiceResolved(info)) => { + got_event = true; + let properties: HashMap = info + .get_properties() + .iter() + .map(|p| (p.key().to_string(), p.val_str().to_string())) + .collect(); + + let hostname = info.get_hostname(); + let instance_name = + parse_instance_name(info.get_fullname(), service_type); + let addresses: Vec = + info.get_addresses().iter().copied().collect(); + let port = Some(info.get_port()); + + let device = build_device( + &instance_name, + hostname, + service_type, + port, + &addresses, + &properties, + ); + + log::debug!( + "mDNS resolved: hostname={} service={} ip={:?} port={:?}", + hostname, + service_type, + device.ip_address, + port + ); + + let key = dedup_key(hostname, &instance_name, service_type); + + discovered_devices.insert(key, device); + } + Ok(_) => { + got_event = true; + } + Err(_) => {} + } + } + + if !got_event && poll_time == remaining { + break; + } + } + + for stype in &service_types { + let _ = mdns.stop_browse(stype); + } + let _ = mdns.shutdown(); + + discovered_devices + }) + .await + .map_err(|e| crate::Error::Other(format!("mDNS scan task failed: {e}")))?; + + Ok(enrich_and_filter(discovered.into_values().collect())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::discovery::DeviceType; + + #[test] + fn test_device_type_from_class() { + assert_eq!( + DeviceType::from_device_class("AppleTV"), + DeviceType::AppleTV + ); + assert_eq!(DeviceType::from_device_class("iPhone"), DeviceType::IPhone); + } + + #[test] + fn test_device_type_from_product() { + assert_eq!( + DeviceType::from_product_type("AppleTV11,1"), + DeviceType::AppleTV + ); + assert_eq!( + DeviceType::from_product_type("iPhone15,2"), + DeviceType::IPhone + ); + } + + #[tokio::test] + #[ignore] + async fn test_mdns_discovery() { + let discovery = MdnsDiscovery::new(); + let devices = discovery.discover(Duration::from_secs(5)).await.unwrap(); + println!("Discovered {} devices:", devices.len()); + for device in &devices { + println!(" - {} ({:?})", device.name, device.device_type); + } + } +} diff --git a/crates/plume_utils/src/discovery/mod.rs b/crates/plume_utils/src/discovery/mod.rs new file mode 100644 index 00000000..7009265b --- /dev/null +++ b/crates/plume_utils/src/discovery/mod.rs @@ -0,0 +1,1053 @@ +pub mod mdns; + +use std::collections::{HashMap, HashSet}; +use std::net::IpAddr; +use std::path::Path; +use std::time::Duration; + +use crate::{Device, synthetic_device_id}; + +pub const REMOTEPAIRING_MANUAL_PAIRING_SERVICE: &str = "_remotepairing-manual-pairing._tcp.local."; +pub const REMOTEPAIRING_SERVICE: &str = "_remotepairing._tcp.local."; +pub const APPLE_MOBDEV2_SERVICE: &str = "_apple-mobdev2._tcp.local."; +pub const APPLE_PAIRABLE_SERVICE: &str = "_apple-pairable._tcp.local."; +pub const COMPANION_LINK_SERVICE: &str = "_companion-link._tcp.local."; + +pub const SERVICE_TYPES: [&str; 4] = [ + APPLE_MOBDEV2_SERVICE, + APPLE_PAIRABLE_SERVICE, + REMOTEPAIRING_SERVICE, + REMOTEPAIRING_MANUAL_PAIRING_SERVICE, +]; + +pub const METADATA_SERVICE_TYPES: [&str; 1] = [COMPANION_LINK_SERVICE]; + +pub const ALL_SCANNED_SERVICE_TYPES: [&str; 5] = [ + APPLE_MOBDEV2_SERVICE, + APPLE_PAIRABLE_SERVICE, + REMOTEPAIRING_SERVICE, + REMOTEPAIRING_MANUAL_PAIRING_SERVICE, + COMPANION_LINK_SERVICE, +]; + +#[derive(Debug, Clone, PartialEq)] +pub enum DeviceType { + IPhone, + IPad, + AppleTV, + AppleMac, + Unknown, +} + +impl DeviceType { + pub fn from_device_class(device_class: &str) -> Self { + match device_class { + "iPhone" => DeviceType::IPhone, + "iPad" => DeviceType::IPad, + "AppleTV" => DeviceType::AppleTV, + "Mac" => DeviceType::AppleMac, + _ => DeviceType::Unknown, + } + } + + pub fn from_product_type(product_type: &str) -> Self { + if product_type.starts_with("iPhone") { + DeviceType::IPhone + } else if product_type.starts_with("iPad") { + DeviceType::IPad + } else if product_type.starts_with("AppleTV") { + DeviceType::AppleTV + } else if product_type.starts_with("Mac") { + DeviceType::AppleMac + } else { + DeviceType::Unknown + } + } +} + +impl std::fmt::Display for DeviceType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DeviceType::IPhone => write!(f, "iPhone"), + DeviceType::IPad => write!(f, "iPad"), + DeviceType::AppleTV => write!(f, "Apple TV"), + DeviceType::AppleMac => write!(f, "Mac"), + DeviceType::Unknown => write!(f, "Unknown"), + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ConnectionType { + USB, + WiFi, +} + +impl std::fmt::Display for ConnectionType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ConnectionType::USB => write!(f, "USB"), + ConnectionType::WiFi => write!(f, "WiFi"), + } + } +} + +#[derive(Debug, Clone)] +pub struct DiscoveredDevice { + pub name: String, + pub hostname: String, + pub udid: Option, + pub ip_address: Option, + pub port: Option, + pub device_type: DeviceType, + pub connection_type: ConnectionType, + pub is_paired: bool, + pub product_type: Option, + pub os_version: Option, + pub service_type: String, +} + + +pub(crate) fn ends_with_ignore_case(s: &str, suffix: &str) -> bool { + let (haystack, needle) = (s.as_bytes(), suffix.as_bytes()); + needle.len() <= haystack.len() + && haystack[haystack.len() - needle.len()..].eq_ignore_ascii_case(needle) +} + +pub(crate) fn parse_instance_name(full_name: &str, service_type: &str) -> String { + let full = full_name.trim_end_matches('.'); + let service = service_type.trim_end_matches('.'); + + if !service.is_empty() { + let suffix_len = service.len() + 1; + if full.len() > suffix_len && ends_with_ignore_case(full, service) { + let cut = full.len() - suffix_len; + if full.as_bytes()[cut] == b'.' { + return full[..cut].to_string(); + } + } + } + full.to_string() +} + +pub(crate) fn short_hostname(hostname: &str) -> &str { + let hostname = hostname.trim_end_matches('.'); + if ends_with_ignore_case(hostname, ".local") { + &hostname[..hostname.len() - ".local".len()] + } else { + hostname + } +} + +pub(crate) fn dedup_key( + hostname: &str, + instance_name: &str, + service_type: &str, +) -> (String, String) { + let host = short_hostname(hostname); + let base = if host.is_empty() { instance_name } else { host }; + (base.to_ascii_lowercase(), service_type.to_string()) +} + +pub(crate) fn first_non_empty<'a>( + props: &'a HashMap, + candidates: &[&str], +) -> Option<&'a str> { + candidates + .iter() + .filter_map(|k| props.get(*k)) + .map(|v| v.as_str()) + .find(|v| !v.is_empty()) +} + +pub(crate) fn build_device( + instance_name: &str, + hostname: &str, + service_type: &str, + port: Option, + addresses: &[IpAddr], + props: &HashMap, +) -> DiscoveredDevice { + let device_type = if let Some(class) = first_non_empty(props, &["DeviceClass", "deviceClass"]) { + DeviceType::from_device_class(class) + } else if let Some(model) = first_non_empty(props, &["ProductType", "model", "rpMd"]) { + DeviceType::from_product_type(model) + } else { + DeviceType::Unknown + }; + + let product_type = + first_non_empty(props, &["ProductType", "model", "rpMd"]).map(str::to_string); + let os_version = first_non_empty(props, &["OSVersion", "osVersion"]).map(str::to_string); + let udid = None; + + let name = { + let from_host = short_hostname(hostname).replace('-', " "); + if !from_host.is_empty() { + from_host + } else { + first_non_empty(props, &["name", "Name"]) + .map(str::to_string) + .unwrap_or_else(|| instance_name.to_string()) + } + }; + + DiscoveredDevice { + name, + hostname: short_hostname(hostname).to_string(), + udid, + ip_address: addresses.first().map(|a| a.to_string()), + port, + device_type, + connection_type: ConnectionType::WiFi, + is_paired: service_type.contains("mobdev2"), + product_type, + os_version, + service_type: service_type.to_string(), + } +} + +pub(crate) fn is_metadata_service(service_type: &str) -> bool { + METADATA_SERVICE_TYPES.contains(&service_type) +} + +fn device_correlation_key(device: &DiscoveredDevice) -> String { + if device.hostname.is_empty() { + device.name.to_ascii_lowercase() + } else { + device.hostname.to_ascii_lowercase() + } +} + +pub(crate) fn enrich_and_filter(devices: Vec) -> Vec { + let mut metadata: HashMap = HashMap::new(); + for d in &devices { + if !is_metadata_service(&d.service_type) { + continue; + } + let key = device_correlation_key(d); + let should_replace = match metadata.get(&key) { + Some(existing) => existing.device_type == DeviceType::Unknown, + None => true, + }; + if should_replace { + metadata.insert(key, d.clone()); + } + } + + devices + .into_iter() + .filter(|d| !is_metadata_service(&d.service_type)) + .map(|mut d| { + if let Some(meta) = metadata.get(&device_correlation_key(&d)) { + let agrees = d.device_type == DeviceType::Unknown + || meta.device_type == DeviceType::Unknown + || d.device_type == meta.device_type; + if agrees { + if d.device_type == DeviceType::Unknown { + d.device_type = meta.device_type.clone(); + } + if d.product_type.is_none() { + d.product_type = meta.product_type.clone(); + } + if d.os_version.is_none() { + d.os_version = meta.os_version.clone(); + } + } + } + d + }) + .collect() +} + +struct NetworkDeviceGroup { + name: String, + hostname: String, + ip: Option, + pairing_port: Option, + reconnect_port: Option, +} + +pub fn group_network_devices(discovered: &[DiscoveredDevice], cache_dir: &Path) -> Vec { + let mut groups: HashMap = HashMap::new(); + + for d in discovered { + if d.device_type != DeviceType::AppleTV { + continue; + } + let is_remote_pairing = d.service_type == REMOTEPAIRING_SERVICE + || d.service_type == REMOTEPAIRING_MANUAL_PAIRING_SERVICE; + let is_core_device = d.service_type == APPLE_MOBDEV2_SERVICE; + if !is_remote_pairing && !is_core_device { + continue; + } + if d.name.is_empty() { + continue; + } + + let key = if d.hostname.is_empty() { + d.name.to_ascii_lowercase() + } else { + d.hostname.to_ascii_lowercase() + }; + let entry = groups.entry(key).or_insert_with(|| NetworkDeviceGroup { + name: d.name.clone(), + hostname: d.hostname.clone(), + ip: None, + pairing_port: None, + reconnect_port: None, + }); + + if entry.ip.is_none() { + if let Some(ip_str) = &d.ip_address { + if let Ok(ip) = ip_str.parse::() { + entry.ip = Some(ip); + } + } + } + + if d.service_type == REMOTEPAIRING_MANUAL_PAIRING_SERVICE { + entry.pairing_port = d.port; + } else if d.service_type == REMOTEPAIRING_SERVICE { + entry.reconnect_port = d.port; + } + } + + let mut devices = Vec::with_capacity(groups.len()); + for group in groups.into_values() { + let Some(ip) = group.ip else { + continue; + }; + if group.pairing_port.is_none() && group.reconnect_port.is_none() { + continue; + } + + let pairing_identity = if group.hostname.is_empty() { + group.name.replace(' ', "-") + } else { + group.hostname + }; + let id = synthetic_device_id(&pairing_identity); + + let mut device = Device::new_tvos( + group.name, + pairing_identity, + ip, + group.pairing_port, + group.reconnect_port, + cache_dir.to_path_buf(), + ); + device.device_id = id; + + devices.push(device); + } + + devices +} + +pub fn disconnected_after_missed_scans( + present_ids: &mut HashSet, + current_ids: &HashSet, + miss_counts: &mut HashMap, + required_misses: u32, +) -> Vec { + for id in current_ids { + miss_counts.remove(id); + } + + let missing = present_ids + .iter() + .copied() + .filter(|id| !current_ids.contains(id)) + .collect::>(); + let threshold = required_misses.max(1); + let mut disconnected = Vec::new(); + + for id in missing { + let misses = miss_counts.entry(id).or_insert(0); + *misses += 1; + if *misses >= threshold { + present_ids.remove(&id); + miss_counts.remove(&id); + disconnected.push(id); + } + } + + disconnected +} + +#[allow(async_fn_in_trait)] +pub trait DeviceDiscovery { + async fn discover(&self, timeout: Duration) -> crate::Result>; +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct PlatformDiscovery; + +impl PlatformDiscovery { + pub fn new() -> Self { + Self + } +} + +impl DeviceDiscovery for PlatformDiscovery { + async fn discover(&self, timeout: Duration) -> crate::Result> { + mdns::MdnsDiscovery::new().discover(timeout).await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn props(pairs: &[(&str, &str)]) -> HashMap { + pairs + .iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect() + } + + #[test] + fn instance_name_strips_service_suffix() { + assert_eq!( + parse_instance_name( + "Living Room._remotepairing-manual-pairing._tcp.local", + REMOTEPAIRING_MANUAL_PAIRING_SERVICE + ), + "Living Room" + ); + } + + #[test] + fn instance_name_handles_trailing_dot_on_both_sides() { + assert_eq!( + parse_instance_name("Apple TV._remotepairing._tcp.local.", REMOTEPAIRING_SERVICE), + "Apple TV" + ); + assert_eq!( + parse_instance_name( + "Apple TV._remotepairing._tcp.local", + "_remotepairing._tcp.local" + ), + "Apple TV" + ); + } + + #[test] + fn instance_name_keeps_literal_non_ascii() { + let full = "Frankie\u{2019}s MacBook Pro._companion-link._tcp.local"; + assert_eq!( + parse_instance_name(full, "_companion-link._tcp.local."), + "Frankie\u{2019}s MacBook Pro" + ); + } + + #[test] + fn instance_name_left_alone_when_suffix_absent() { + assert_eq!( + parse_instance_name("Living Room._other._tcp.local", REMOTEPAIRING_SERVICE), + "Living Room._other._tcp.local" + ); + } + + #[test] + fn instance_name_does_not_split_a_multibyte_character() { + let name = "\u{2019}".to_string() + &"X".repeat(24); + assert_eq!(parse_instance_name(&name, REMOTEPAIRING_SERVICE), name); + + for pad in 0..8 { + let name = "A".repeat(pad) + "\u{2019}\u{2019}\u{2019}"; + assert_eq!(parse_instance_name(&name, "_x._tcp.local"), name); + } + } + + #[test] + fn suffix_match_is_case_insensitive() { + assert!(ends_with_ignore_case( + "Living Room._TCP.LOCAL", + "_tcp.local" + )); + assert!(ends_with_ignore_case("abc", "ABC")); + assert!(!ends_with_ignore_case("abc", "abd")); + assert!(!ends_with_ignore_case("ab", "abc")); + assert_eq!( + parse_instance_name( + "Living Room._RemotePairing._TCP.local", + REMOTEPAIRING_SERVICE + ), + "Living Room" + ); + } + + #[test] + fn short_hostname_strips_local_suffix_without_case_sensitivity() { + assert_eq!(short_hostname("Apple-TV.LOCAL."), "Apple-TV"); + assert_eq!(short_hostname("Apple-TV.example"), "Apple-TV.example"); + } + + #[test] + fn first_non_empty_skips_present_but_empty_values() { + let p = props(&[("ProductType", ""), ("model", "AppleTV14,1")]); + assert_eq!( + first_non_empty(&p, &["ProductType", "model"]), + Some("AppleTV14,1") + ); + assert_eq!(first_non_empty(&p, &["ProductType"]), None); + assert_eq!(first_non_empty(&p, &["absent"]), None); + } + + #[test] + fn real_apple_tv_txt_maps_to_apple_tv() { + let manual = props(&[("model", "AppleTV14,1")]); + let d = build_device( + "Living Room", + "Living-Room.local.", + REMOTEPAIRING_MANUAL_PAIRING_SERVICE, + Some(49153), + &[], + &manual, + ); + assert_eq!(d.device_type, DeviceType::AppleTV); + assert_eq!(d.product_type.as_deref(), Some("AppleTV14,1")); + + let companion = props(&[("rpMd", "AppleTV14,1"), ("udid", "deadbeef")]); + let d = build_device( + "Living Room", + "Living-Room.local.", + REMOTEPAIRING_SERVICE, + Some(49152), + &[], + &companion, + ); + assert_eq!(d.device_type, DeviceType::AppleTV); + assert_eq!(d.product_type.as_deref(), Some("AppleTV14,1")); + assert_eq!(d.udid, None); + } + + #[test] + fn mapping_prefers_device_class() { + let p = props(&[ + ("DeviceClass", "AppleTV"), + ("ProductType", "AppleTV11,1"), + ("UniqueDeviceID", "abc123"), + ("OSVersion", "17.4"), + ("name", "Ignored"), + ]); + let d = build_device( + "Living Room", + "Living-Room.local.", + REMOTEPAIRING_SERVICE, + Some(49152), + &["10.0.0.5".parse::().unwrap()], + &p, + ); + assert_eq!(d.device_type, DeviceType::AppleTV); + assert_eq!(d.product_type.as_deref(), Some("AppleTV11,1")); + assert_eq!(d.os_version.as_deref(), Some("17.4")); + assert_eq!(d.udid, None); + assert_eq!(d.ip_address.as_deref(), Some("10.0.0.5")); + assert_eq!(d.port, Some(49152)); + assert_eq!(d.connection_type, ConnectionType::WiFi); + assert!(!d.is_paired); + assert_eq!(d.service_type, REMOTEPAIRING_SERVICE); + } + + #[test] + fn mapping_name_prefers_hostname_over_txt_and_instance() { + let d = build_device( + "A827F07B-2D1D-4D09-8E1E-5E37EE47A96C", + "Living-Room.local.", + REMOTEPAIRING_SERVICE, + Some(1), + &[], + &props(&[("name", "Some Other Name")]), + ); + assert_eq!(d.name, "Living Room"); + } + + #[test] + fn mapping_name_falls_back_when_hostname_missing() { + let d = build_device( + "instance-label", + "", + REMOTEPAIRING_SERVICE, + Some(1), + &[], + &props(&[("name", "Txt Name")]), + ); + assert_eq!(d.name, "Txt Name"); + + let d = build_device( + "instance-label", + "", + REMOTEPAIRING_SERVICE, + Some(1), + &[], + &props(&[]), + ); + assert_eq!(d.name, "instance-label"); + } + + #[test] + fn same_device_yields_identical_name_across_service_types() { + let manual = build_device( + "Living Room", + "Living-Room.local.", + REMOTEPAIRING_MANUAL_PAIRING_SERVICE, + Some(62782), + &[], + &props(&[("name", "Living Room"), ("model", "AppleTV14,1")]), + ); + let reconnect = build_device( + "A827F07B-2D1D-4D09-8E1E-5E37EE47A96C", + "Living-Room.local.", + REMOTEPAIRING_SERVICE, + Some(49152), + &[], + &props(&[("identifier", "73B8BE56-3881-4145-BF61-EFB7BBAEC98F")]), + ); + + assert_eq!(manual.name, "Living Room"); + assert_eq!(manual.name, reconnect.name); + assert_ne!(manual.service_type, reconnect.service_type); + assert_eq!(manual.port, Some(62782)); + assert_eq!(reconnect.port, Some(49152)); + } + + #[test] + fn mapping_marks_mobdev2_as_paired() { + let d = build_device( + "x", + "", + APPLE_MOBDEV2_SERVICE, + Some(62078), + &[], + &props(&[]), + ); + assert!(d.is_paired); + assert_eq!(d.device_type, DeviceType::Unknown); + assert_eq!(d.product_type, None); + } + + #[test] + fn mapping_does_not_trust_advertised_udid() { + let p = props(&[("udid", "second"), ("identifier", "third")]); + assert_eq!( + build_device("x", "", REMOTEPAIRING_SERVICE, Some(1), &[], &p).udid, + None + ); + let p = props(&[("identifier", "third")]); + assert_eq!( + build_device("x", "", REMOTEPAIRING_SERVICE, Some(1), &[], &p).udid, + None + ); + } + + #[test] + fn dedup_key_normalizes_case_and_falls_back_to_instance() { + assert_eq!( + dedup_key("Living-Room.local.", "Living Room", REMOTEPAIRING_SERVICE), + dedup_key("living-room.local", "Living Room", REMOTEPAIRING_SERVICE) + ); + assert_eq!( + dedup_key("", "Living Room", REMOTEPAIRING_SERVICE), + ("living room".to_string(), REMOTEPAIRING_SERVICE.to_string()) + ); + } + + #[test] + fn same_device_under_two_service_types_is_not_collapsed() { + let p = props(&[("model", "AppleTV14,1")]); + let mut devices: HashMap<(String, String), DiscoveredDevice> = HashMap::new(); + + for (service, port) in [ + (REMOTEPAIRING_SERVICE, 49152u16), + (REMOTEPAIRING_MANUAL_PAIRING_SERVICE, 49153u16), + ] { + let device = build_device( + "Living Room", + "Living-Room.local.", + service, + Some(port), + &[], + &p, + ); + devices.insert( + dedup_key("Living-Room.local.", "Living Room", service), + device, + ); + } + + assert_eq!(devices.len(), 2); + let mut ports: Vec = devices.values().filter_map(|d| d.port).collect(); + ports.sort_unstable(); + assert_eq!(ports, vec![49152, 49153]); + assert!(devices.values().all(|d| d.name == "Living Room")); + } + + fn unknown_device(name: &str, service_type: &str, port: u16) -> DiscoveredDevice { + DiscoveredDevice { + name: name.to_string(), + hostname: String::new(), + udid: None, + ip_address: None, + port: Some(port), + device_type: DeviceType::Unknown, + connection_type: ConnectionType::WiFi, + is_paired: false, + product_type: None, + os_version: None, + service_type: service_type.to_string(), + } + } + + fn companion_link_device(name: &str, product_type: &str) -> DiscoveredDevice { + DiscoveredDevice { + name: name.to_string(), + hostname: String::new(), + udid: None, + ip_address: None, + port: Some(49155), + device_type: DeviceType::from_product_type(product_type), + connection_type: ConnectionType::WiFi, + is_paired: false, + product_type: Some(product_type.to_string()), + os_version: None, + service_type: COMPANION_LINK_SERVICE.to_string(), + } + } + + #[test] + fn enrich_and_filter_fills_model_from_companion_link() { + let remotepairing = unknown_device("Living Room", REMOTEPAIRING_SERVICE, 49152); + let companion = companion_link_device("Living Room", "AppleTV14,1"); + + let result = enrich_and_filter(vec![remotepairing, companion]); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].service_type, REMOTEPAIRING_SERVICE); + assert_eq!(result[0].port, Some(49152)); + assert_eq!(result[0].device_type, DeviceType::AppleTV); + assert_eq!(result[0].product_type.as_deref(), Some("AppleTV14,1")); + } + + #[test] + fn enrich_and_filter_name_correlation_is_case_insensitive() { + let remotepairing = unknown_device("Living Room", REMOTEPAIRING_SERVICE, 49152); + let companion = companion_link_device("living room", "AppleTV14,1"); + + let result = enrich_and_filter(vec![remotepairing, companion]); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].device_type, DeviceType::AppleTV); + assert_eq!(result[0].product_type.as_deref(), Some("AppleTV14,1")); + } + + #[test] + fn enrich_and_filter_does_not_overwrite_known_device_type() { + let mut manual = unknown_device("Living Room", REMOTEPAIRING_MANUAL_PAIRING_SERVICE, 49153); + manual.device_type = DeviceType::AppleTV; + let mut companion = companion_link_device("Living Room", "iPhone15,2"); + companion.device_type = DeviceType::IPhone; + + let result = enrich_and_filter(vec![manual, companion]); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].device_type, DeviceType::AppleTV); + assert_eq!(result[0].product_type, None); + } + + #[test] + fn enrich_and_filter_prefers_a_typed_metadata_entry_regardless_of_order() { + for reversed in [false, true] { + let target = unknown_device("Living Room", REMOTEPAIRING_SERVICE, 49152); + let untyped = companion_link_device("Living Room", ""); + let mut untyped = untyped; + untyped.device_type = DeviceType::Unknown; + untyped.product_type = None; + let typed = companion_link_device("Living Room", "AppleTV14,1"); + + let input = if reversed { + vec![target, typed, untyped] + } else { + vec![target, untyped, typed] + }; + let result = enrich_and_filter(input); + + assert_eq!(result.len(), 1); + assert_eq!( + result[0].device_type, + DeviceType::AppleTV, + "reversed={reversed}" + ); + assert_eq!( + result[0].product_type.as_deref(), + Some("AppleTV14,1"), + "reversed={reversed}" + ); + } + } + + #[test] + fn enrich_and_filter_does_not_overwrite_known_product_type() { + let mut remotepairing = unknown_device("Living Room", REMOTEPAIRING_SERVICE, 49152); + remotepairing.product_type = Some("x".to_string()); + let companion = companion_link_device("Living Room", "AppleTV14,1"); + + let result = enrich_and_filter(vec![remotepairing, companion]); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].product_type.as_deref(), Some("x")); + } + + #[test] + fn enrich_and_filter_drops_unmatched_metadata_entries() { + let companion = companion_link_device("Living Room", "AppleTV14,1"); + + let result = enrich_and_filter(vec![companion]); + + assert!(result.is_empty()); + } + + #[test] + fn enrich_and_filter_does_not_cross_contaminate_hosts() { + let bedroom = unknown_device("Bedroom", REMOTEPAIRING_SERVICE, 49152); + let living_room_companion = companion_link_device("Living Room", "AppleTV14,1"); + + let result = enrich_and_filter(vec![bedroom, living_room_companion]); + + assert_eq!(result.len(), 1); + assert_eq!(result[0].name, "Bedroom"); + assert_eq!(result[0].device_type, DeviceType::Unknown); + assert_eq!(result[0].product_type, None); + } + + #[test] + fn enrich_and_filter_preserves_order_of_non_metadata_entries() { + let bedroom = unknown_device("Bedroom", REMOTEPAIRING_SERVICE, 1); + let companion = companion_link_device("Living Room", "AppleTV14,1"); + let living_room = unknown_device("Living Room", REMOTEPAIRING_SERVICE, 2); + let kitchen = unknown_device("Kitchen", REMOTEPAIRING_SERVICE, 3); + + let result = enrich_and_filter(vec![bedroom, companion, living_room, kitchen]); + + assert_eq!( + result.iter().map(|d| d.name.as_str()).collect::>(), + vec!["Bedroom", "Living Room", "Kitchen"] + ); + } + + fn network_apple_tv(name: &str, service_type: &str, port: u16, ip: &str) -> DiscoveredDevice { + DiscoveredDevice { + name: name.to_string(), + hostname: name.replace(' ', "-").to_ascii_lowercase(), + udid: None, + ip_address: Some(ip.to_string()), + port: Some(port), + device_type: DeviceType::AppleTV, + connection_type: ConnectionType::WiFi, + is_paired: false, + product_type: Some("AppleTV14,1".to_string()), + os_version: None, + service_type: service_type.to_string(), + } + } + + #[test] + fn group_network_devices_only_manual_sets_pairing_port_only() { + let discovered = [network_apple_tv( + "Living Room", + REMOTEPAIRING_MANUAL_PAIRING_SERVICE, + 49153, + "10.0.0.5", + )]; + + let devices = group_network_devices(&discovered, Path::new("/cache")); + + assert_eq!(devices.len(), 1); + assert_eq!( + devices[0].pairing_address, + Some(("10.0.0.5".parse().unwrap(), 49153)) + ); + assert_eq!(devices[0].reconnect_address, None); + } + + #[test] + fn group_network_devices_only_reconnect_sets_reconnect_port_only() { + let discovered = [network_apple_tv( + "Living Room", + REMOTEPAIRING_SERVICE, + 49152, + "10.0.0.5", + )]; + + let devices = group_network_devices(&discovered, Path::new("/cache")); + + assert_eq!(devices.len(), 1); + assert_eq!( + devices[0].reconnect_address, + Some(("10.0.0.5".parse().unwrap(), 49152)) + ); + assert_eq!(devices[0].pairing_address, None); + } + + #[test] + fn group_network_devices_merges_both_service_types_into_one_device() { + let discovered = [ + network_apple_tv( + "Living Room", + REMOTEPAIRING_MANUAL_PAIRING_SERVICE, + 49153, + "10.0.0.5", + ), + network_apple_tv("living room", REMOTEPAIRING_SERVICE, 49152, "10.0.0.5"), + ]; + + let devices = group_network_devices(&discovered, Path::new("/cache")); + + assert_eq!(devices.len(), 1); + assert_eq!(devices[0].pairing_address.map(|(_, p)| p), Some(49153)); + assert_eq!(devices[0].reconnect_address.map(|(_, p)| p), Some(49152)); + } + + #[test] + fn group_network_devices_deduplicates_legacy_core_device_with_remote_pairing() { + let discovered = [ + network_apple_tv("Living Room", REMOTEPAIRING_SERVICE, 49152, "10.0.0.5"), + network_apple_tv("Living Room", APPLE_MOBDEV2_SERVICE, 62078, "10.0.0.5"), + ]; + + let devices = group_network_devices(&discovered, Path::new("/cache")); + + assert_eq!(devices.len(), 1); + assert_eq!(devices[0].reconnect_address.unwrap().1, 49152); + assert_eq!(devices[0].pairing_identity.as_deref(), Some("living-room")); + } + + #[test] + fn group_network_devices_keeps_same_named_hosts_separate() { + let mut first = network_apple_tv("Living Room", REMOTEPAIRING_SERVICE, 49152, "10.0.0.5"); + let mut second = network_apple_tv("Living Room", REMOTEPAIRING_SERVICE, 49152, "10.0.0.6"); + first.hostname = "living-room-a".to_string(); + second.hostname = "living-room-b".to_string(); + + let devices = group_network_devices(&[first, second], Path::new("/cache")); + + assert_eq!(devices.len(), 2); + assert_ne!(devices[0].pairing_identity, devices[1].pairing_identity); + } + + #[test] + fn disappearing_mdns_service_requires_two_missed_scans() { + let mut present = [7u32].into_iter().collect::>(); + let empty = HashSet::new(); + let mut misses = HashMap::new(); + + assert!(disconnected_after_missed_scans(&mut present, &empty, &mut misses, 2).is_empty()); + assert_eq!(disconnected_after_missed_scans(&mut present, &empty, &mut misses, 2), vec![7]); + assert!(present.is_empty()); + assert!(misses.is_empty()); + } + + #[test] + fn rediscovered_mdns_service_clears_missed_scan_count() { + let mut present = [7u32].into_iter().collect::>(); + let empty = HashSet::new(); + let current = [7u32].into_iter().collect::>(); + let mut misses = HashMap::new(); + + assert!(disconnected_after_missed_scans(&mut present, &empty, &mut misses, 2).is_empty()); + assert!(disconnected_after_missed_scans(&mut present, ¤t, &mut misses, 2).is_empty()); + assert!(present.contains(&7)); + } + + #[test] + fn group_network_devices_excludes_non_appletv() { + let mut d = network_apple_tv("Some iPhone", REMOTEPAIRING_SERVICE, 1, "10.0.0.5"); + d.device_type = DeviceType::IPhone; + + let devices = group_network_devices(&[d], Path::new("/cache")); + + assert!(devices.is_empty()); + } + + #[test] + fn group_network_devices_excludes_unsupported_service() { + let d = network_apple_tv("Living Room", APPLE_MOBDEV2_SERVICE, 62078, "10.0.0.5"); + + let devices = group_network_devices(&[d], Path::new("/cache")); + + assert!(devices.is_empty()); + } + + #[test] + fn group_network_devices_keeps_two_different_apple_tvs_separate() { + let discovered = [ + network_apple_tv("Living Room", REMOTEPAIRING_SERVICE, 1, "10.0.0.5"), + network_apple_tv("Bedroom", REMOTEPAIRING_SERVICE, 2, "10.0.0.6"), + ]; + + let devices = group_network_devices(&discovered, Path::new("/cache")); + + assert_eq!(devices.len(), 2); + let mut names: Vec<&str> = devices.iter().map(|d| d.name.as_str()).collect(); + names.sort(); + assert_eq!(names, vec!["Bedroom", "Living Room"]); + } + + #[test] + fn group_network_devices_skips_empty_name() { + let d = network_apple_tv("", REMOTEPAIRING_SERVICE, 1, "10.0.0.5"); + + let devices = group_network_devices(&[d], Path::new("/cache")); + + assert!(devices.is_empty()); + } + + #[test] + fn group_network_devices_skips_unresolved_ip() { + let mut d = network_apple_tv("Living Room", REMOTEPAIRING_SERVICE, 1, "10.0.0.5"); + d.ip_address = None; + + let devices = group_network_devices(&[d], Path::new("/cache")); + + assert!(devices.is_empty()); + } + + #[test] + fn group_network_devices_sets_synthetic_device_id_and_pairing_identity() { + let d = network_apple_tv("Living Room", REMOTEPAIRING_SERVICE, 1, "10.0.0.5"); + + let devices = group_network_devices(&[d], Path::new("/cache")); + + assert_eq!(devices.len(), 1); + assert_eq!(devices[0].pairing_identity.as_deref(), Some("living-room")); + assert_eq!(devices[0].device_id, synthetic_device_id("living-room")); + assert_ne!(devices[0].device_id, 0); + } + + #[test] + fn group_network_devices_keeps_first_resolved_address_when_entries_share_a_name() { + let discovered = [ + network_apple_tv( + "Living Room", + REMOTEPAIRING_MANUAL_PAIRING_SERVICE, + 49153, + "10.0.0.5", + ), + network_apple_tv("Living Room", REMOTEPAIRING_SERVICE, 49152, "10.0.0.9"), + ]; + + let devices = group_network_devices(&discovered, Path::new("/cache")); + + assert_eq!(devices.len(), 1); + assert_eq!( + devices[0].pairing_address.unwrap().0.to_string(), + "10.0.0.5" + ); + assert_eq!( + devices[0].reconnect_address.unwrap().0.to_string(), + "10.0.0.5" + ); + } +} diff --git a/crates/plume_utils/src/lib.rs b/crates/plume_utils/src/lib.rs index 14fa36db..dfda03a6 100644 --- a/crates/plume_utils/src/lib.rs +++ b/crates/plume_utils/src/lib.rs @@ -1,15 +1,20 @@ mod bundle; mod cgbi; mod device; +pub mod discovery; mod options; mod package; +pub mod pairing; mod signer; mod tweak; +use std::collections::HashMap; use std::path::Path; - -pub use bundle::{Bundle, BundleType}; // Bundle helper -pub use device::{Device, get_device_for_id, install_app_mac}; // Device helper +pub use bundle::{Bundle, BundleType}; +pub use device::{ + Device, DeviceTransport, TvosDeviceInfo, get_device_for_id, install_app_mac, + synthetic_device_id, +}; pub use options::{ SignerApp, // Supported app types SignerAppReal, @@ -19,9 +24,12 @@ pub use options::{ SignerMode, // Signing mode SignerOptions, // Main }; -pub use package::Package; // Package helper -pub use signer::Signer; // Signer -pub use tweak::Tweak; // Tweak helper +pub use package::Package; +pub use pairing::{PairingBackend, PairingFailure, PairingStage, ensure_pairing}; +pub use signer::Signer; +pub use tweak::Tweak; + +pub type Result = std::result::Result; use thiserror::Error as ThisError; #[derive(Debug, ThisError)] @@ -68,7 +76,7 @@ pub trait PlistInfoTrait { fn get_build_version(&self) -> Option; } -pub async fn copy_dir_recursively(src: &Path, dst: &Path) -> Result<(), Error> { +pub async fn copy_dir_recursively(src: &Path, dst: &Path) -> Result<()> { use tokio::fs; fs::create_dir_all(dst).await?; @@ -95,3 +103,160 @@ pub async fn copy_dir_recursively(src: &Path, dst: &Path) -> Result<(), Error> { Ok(()) } + +pub use plume_core::is_valid_device_udid; + +fn dedup_key_for_device(device: &Device) -> Option { + if is_valid_device_udid(&device.udid) { + return Some(format!("udid:{}", device.udid.to_ascii_lowercase())); + } + + if device.is_network() { + return device + .pairing_identity + .as_deref() + .filter(|identity| !identity.is_empty()) + .map(|identity| format!("pairing:{}", identity.to_ascii_lowercase())); + } + + None +} + +fn device_quality(device: &Device) -> usize { + let mut score = 0; + if is_valid_device_udid(&device.udid) { + score += 100; + } + if device.is_network() && device.pairing_identity.is_some() { + score += 40; + } + if device.usbmuxd_device.is_some() { + score += 20; + } + score += device.product_type.is_some() as usize * 10; + score += device.device_class.is_some() as usize * 8; + score += device.os_version.is_some() as usize * 4; + score += device.serial_number.is_some() as usize * 4; + score += device.reconnect_address.is_some() as usize * 3; + score += device.pairing_address.is_some() as usize * 2; + score +} + +pub fn deduplicate_devices(devices: impl IntoIterator) -> Vec { + let mut result = Vec::new(); + let mut indexes = HashMap::new(); + + for device in devices { + let Some(key) = dedup_key_for_device(&device) else { + result.push(device); + continue; + }; + + if let Some(index) = indexes.get(&key).copied() { + if device_quality(&device) > device_quality(&result[index]) { + result[index] = device; + } + } else { + indexes.insert(key, result.len()); + result.push(device); + } + } + + result +} + +pub fn format_bytes(bytes: u64) -> String { + const KB: u64 = 1_000; + const MB: u64 = 1_000 * KB; + const GB: u64 = 1_000 * MB; + + if bytes >= GB { + format!("{:.1} GB", bytes as f64 / GB as f64) + } else if bytes >= MB { + format!("{:.1} MB", bytes as f64 / MB as f64) + } else if bytes >= KB { + format!("{} KB", bytes / KB) + } else { + format!("{} B", bytes) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn format_bytes_picks_a_unit_per_magnitude() { + assert_eq!(format_bytes(0), "0 B"); + assert_eq!(format_bytes(999), "999 B"); + assert_eq!(format_bytes(1_000), "1 KB"); + assert_eq!(format_bytes(999_999), "999 KB"); + assert_eq!(format_bytes(1_000_000), "1.0 MB"); + assert_eq!(format_bytes(1_000_000_000), "1.0 GB"); + } + + #[test] + fn format_bytes_rounds_to_one_decimal_at_megabytes() { + assert_eq!(format_bytes(54_741_568), "54.7 MB"); + } + + #[test] + fn validates_legacy_and_modern_udids() { + assert!(is_valid_device_udid("00008110-000C25540CD1801E")); + assert!(is_valid_device_udid("0123456789abcdef0123456789abcdef01234567")); + assert!(!is_valid_device_udid("00:11:22:33:44:55")); + assert!(!is_valid_device_udid("Apple-TV.local")); + } + + #[test] + fn deduplicates_authenticated_network_and_legacy_entries() { + let cache_dir = std::env::temp_dir(); + let mut legacy = Device::new_tvos( + "Living Room".to_string(), + "Living-Room".to_string(), + "192.0.2.10".parse().unwrap(), + None, + Some(49152), + cache_dir.clone(), + ); + legacy.udid = "00008110-000C25540CD1801E".to_string(); + legacy.product_type = Some("AppleTV14,1".to_string()); + let mut authenticated = legacy.clone(); + authenticated.reconnect_address = Some(("192.0.2.11".parse().unwrap(), 49152)); + authenticated.os_version = Some("26.6".to_string()); + + let devices = deduplicate_devices([legacy, authenticated.clone()]); + + assert_eq!(devices.len(), 1); + assert_eq!( + devices[0].reconnect_address, + authenticated.reconnect_address + ); + assert_eq!(devices[0].os_version, authenticated.os_version); + } + + #[test] + fn deduplicates_unenriched_network_advertisements_by_pairing_identity() { + let cache_dir = std::env::temp_dir(); + let first = Device::new_tvos( + "Living Room".to_string(), + "Living-Room".to_string(), + "192.0.2.10".parse().unwrap(), + Some(49153), + None, + cache_dir.clone(), + ); + let second = Device::new_tvos( + "living room".to_string(), + "living-room".to_string(), + "192.0.2.11".parse().unwrap(), + None, + Some(49152), + cache_dir, + ); + + let devices = deduplicate_devices([first, second]); + + assert_eq!(devices.len(), 1); + } +} diff --git a/crates/plume_utils/src/pairing.rs b/crates/plume_utils/src/pairing.rs new file mode 100644 index 00000000..8d4f8927 --- /dev/null +++ b/crates/plume_utils/src/pairing.rs @@ -0,0 +1,186 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PairingFailure { + Cancelled, + InvalidPin, + WrongPin, + StaleRecord, + ServiceDisappeared, + Protocol(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PairingStage { + Reconnected, + Paired, +} + +#[allow(async_fn_in_trait)] +pub trait PairingBackend { + async fn verify(&mut self) -> Result<(), PairingFailure>; + async fn pair(&mut self, pin: &str) -> Result<(), PairingFailure>; +} + +pub async fn ensure_pairing( + backend: &mut B, + has_cached_record: bool, + has_pairing_service: bool, + has_reconnect_service: bool, + pin_provider: F, +) -> Result +where + B: PairingBackend, + F: FnOnce() -> Fut, + Fut: std::future::Future, +{ + if has_cached_record && (has_reconnect_service || has_pairing_service) { + if backend.verify().await.is_ok() { + return Ok(PairingStage::Reconnected); + } + if !has_pairing_service { + return Err(PairingFailure::StaleRecord); + } + } + + if !has_pairing_service { + return Err(PairingFailure::ServiceDisappeared); + } + + let pin = pin_provider().await; + if pin.is_empty() { + return Err(PairingFailure::Cancelled); + } + if pin.len() != 6 || !pin.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(PairingFailure::InvalidPin); + } + + backend.pair(&pin).await?; + Ok(PairingStage::Paired) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::VecDeque; + + struct MockPairingBackend { + verify_results: VecDeque>, + pair_results: VecDeque>, + verify_calls: usize, + pair_calls: Vec, + } + + impl MockPairingBackend { + fn new( + verify_results: impl IntoIterator>, + pair_results: impl IntoIterator>, + ) -> Self { + Self { + verify_results: verify_results.into_iter().collect(), + pair_results: pair_results.into_iter().collect(), + verify_calls: 0, + pair_calls: Vec::new(), + } + } + } + + impl PairingBackend for MockPairingBackend { + async fn verify(&mut self) -> Result<(), PairingFailure> { + self.verify_calls += 1; + self.verify_results.pop_front().unwrap_or(Ok(())) + } + + async fn pair(&mut self, pin: &str) -> Result<(), PairingFailure> { + self.pair_calls.push(pin.to_string()); + self.pair_results.pop_front().unwrap_or(Ok(())) + } + } + + #[tokio::test] + async fn first_pairing_uses_manual_service_and_pin_once() { + let mut backend = MockPairingBackend::new([], [Ok(())]); + + let stage = ensure_pairing(&mut backend, false, true, false, || async { + "123456".to_string() + }) + .await + .unwrap(); + + assert_eq!(stage, PairingStage::Paired); + assert_eq!(backend.verify_calls, 0); + assert_eq!(backend.pair_calls, vec!["123456"]); + } + + #[tokio::test] + async fn saved_record_reconnects_without_requesting_pin() { + let mut backend = MockPairingBackend::new([Ok(())], []); + let mut requested_pin = false; + + let stage = ensure_pairing(&mut backend, true, false, true, || async { + requested_pin = true; + "123456".to_string() + }) + .await + .unwrap(); + + assert_eq!(stage, PairingStage::Reconnected); + assert_eq!(backend.verify_calls, 1); + assert!(backend.pair_calls.is_empty()); + assert!(!requested_pin); + } + + #[tokio::test] + async fn wrong_pin_is_returned_before_any_installation_step() { + let mut backend = MockPairingBackend::new([], [Err(PairingFailure::WrongPin)]); + + let error = ensure_pairing(&mut backend, false, true, false, || async { + "654321".to_string() + }) + .await + .unwrap_err(); + + assert_eq!(error, PairingFailure::WrongPin); + assert_eq!(backend.pair_calls, vec!["654321"]); + } + + #[tokio::test] + async fn cancelled_pin_does_not_call_pairing_backend() { + let mut backend = MockPairingBackend::new([], []); + + let error = ensure_pairing(&mut backend, false, true, false, || async { + String::new() + }) + .await + .unwrap_err(); + + assert_eq!(error, PairingFailure::Cancelled); + assert!(backend.pair_calls.is_empty()); + } + + #[tokio::test] + async fn stale_record_needs_manual_service_before_retrying_pairing() { + let mut backend = MockPairingBackend::new([Err(PairingFailure::Protocol("stale".into()))], []); + + let error = ensure_pairing(&mut backend, true, false, true, || async { + "123456".to_string() + }) + .await + .unwrap_err(); + + assert_eq!(error, PairingFailure::StaleRecord); + assert!(backend.pair_calls.is_empty()); + } + + #[tokio::test] + async fn disappearing_service_is_not_treated_as_a_pairing_failure() { + let mut backend = MockPairingBackend::new([], []); + + let error = ensure_pairing(&mut backend, false, false, true, || async { + "123456".to_string() + }) + .await + .unwrap_err(); + + assert_eq!(error, PairingFailure::ServiceDisappeared); + assert!(backend.pair_calls.is_empty()); + } +} From 1c9be238d956fe546e35e369bb7e6e568277752a Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:35:35 +0200 Subject: [PATCH 2/8] fix(signing): request and validate tvOS profiles --- apps/plumeimpactor/src/refresh.rs | 74 ++++- apps/plumeimpactor/src/screen/progress.rs | 48 ++- apps/plumeimpactor/src/subscriptions.rs | 259 ++++++++++++++-- apps/plumesign/src/commands/account.rs | 13 +- apps/plumesign/src/commands/sign.rs | 71 ++++- crates/plume_core/src/developer/qh/app_ids.rs | 18 +- crates/plume_core/src/developer/qh/devices.rs | 18 +- crates/plume_core/src/developer/qh/profile.rs | 3 + crates/plume_core/src/utils/certificate.rs | 6 + crates/plume_utils/src/signer.rs | 288 ++++++++++++++++-- 10 files changed, 709 insertions(+), 89 deletions(-) diff --git a/apps/plumeimpactor/src/refresh.rs b/apps/plumeimpactor/src/refresh.rs index 8ea1b42a..b26e49a1 100644 --- a/apps/plumeimpactor/src/refresh.rs +++ b/apps/plumeimpactor/src/refresh.rs @@ -5,7 +5,8 @@ use std::time::Duration; use chrono::Utc; use plume_core::{ - AnisetteConfiguration, CertificateIdentity, MobileProvision, developer::DeveloperSession, + AnisetteConfiguration, CertificateIdentity, MobileProvision, + developer::{DeveloperPlatform, DeveloperSession}, }; use plume_store::{AccountStore, RefreshDevice}; use plume_utils::{Bundle, Device, Signer, SignerMode, SignerOptions}; @@ -261,11 +262,23 @@ impl RefreshDaemon { session: &DeveloperSession, team_id: &str, ) -> Result<(), String> { + if !device.is_mac && !plume_utils::is_valid_device_udid(&device.udid) { + return Err("Device UDID is unknown; cannot register it with Apple".to_string()); + } + + let platform = if device.is_tvos() { + DeveloperPlatform::Tvos + } else { + DeveloperPlatform::Ios + }; + let team_id_string = team_id.to_string(); - session - .qh_ensure_device(&team_id_string, &device.name, &device.udid) - .await - .map_err(|e| format!("Failed to ensure device: {}", e))?; + if !device.is_mac { + session + .qh_ensure_device(&team_id_string, &device.name, &device.udid, platform) + .await + .map_err(|e| format!("Failed to ensure device: {}", e))?; + } let bundle = Bundle::new(app.path.clone()).map_err(|e| format!("Failed to create bundle: {}", e))?; @@ -291,15 +304,34 @@ impl RefreshDaemon { let mut signer = Signer::new(Some(signing_identity), options); signer - .register_bundle(&bundle, session, &team_id.to_string(), true) + .register_bundle_for_device( + &bundle, + session, + &team_id.to_string(), + true, + platform, + (!device.is_mac).then_some(device.udid.as_str()), + ) .await .map_err(|e| format!("Failed to register bundle: {}", e))?; signer - .sign_bundle(&bundle) + .sign_bundle_for_device( + &bundle, + platform, + (!device.is_mac).then_some(device.udid.as_str()), + ) .await .map_err(|e| format!("Failed to sign bundle: {}", e))?; + signer + .validate_signed_bundle( + &bundle, + platform, + (!device.is_mac).then_some(device.udid.as_str()), + ) + .map_err(|e| format!("Failed to validate signed bundle: {}", e))?; + if !device.is_mac { device .install_app(&app.path, |_| async {}) @@ -329,10 +361,34 @@ impl RefreshDaemon { ..Default::default() }; - let mut signer = Signer::new(None, options); + let mut on_certificate_reset = crate::certificate_reset::confirm; + let signing_identity = CertificateIdentity::new_with_session( + session, + get_data_path(), + None, + &team_id.to_string(), + false, + Some(&mut on_certificate_reset), + ) + .await + .map_err(|e| format!("Failed to create signing identity: {}", e))?; + let mut signer = Signer::new(Some(signing_identity), options); + + let platform = if device.is_tvos() { + DeveloperPlatform::Tvos + } else { + DeveloperPlatform::Ios + }; signer - .register_bundle(&bundle, session, &team_id.to_string(), true) + .register_bundle_for_device( + &bundle, + session, + &team_id.to_string(), + true, + platform, + Some(&device.udid), + ) .await .map_err(|e| format!("Failed to register bundle: {}", e))?; diff --git a/apps/plumeimpactor/src/screen/progress.rs b/apps/plumeimpactor/src/screen/progress.rs index d79ca5f8..8de9aa38 100644 --- a/apps/plumeimpactor/src/screen/progress.rs +++ b/apps/plumeimpactor/src/screen/progress.rs @@ -8,12 +8,37 @@ use rust_i18n::t; use crate::appearance; -type ProgressReceiver = Arc>>; +type ProgressReceiver = Arc>>; + +#[derive(Debug, Clone)] +pub struct ProgressUpdate { + pub status: String, + pub progress: i32, + pub determinate: bool, +} + +impl ProgressUpdate { + pub fn new(status: String, progress: i32) -> Self { + Self { + status, + progress, + determinate: true, + } + } + + pub fn indeterminate(status: String, progress: i32) -> Self { + Self { + status, + progress, + determinate: false, + } + } +} #[derive(Debug, Clone)] #[allow(dead_code)] pub enum Message { - InstallationProgress(String, i32), + InstallationProgress(ProgressUpdate), InstallationError(String), InstallationFinished, Back, @@ -23,6 +48,7 @@ pub enum Message { pub struct ProgressScreen { pub status: String, pub progress: i32, + pub determinate: bool, pub is_installing: bool, pub progress_rx: Option, } @@ -32,6 +58,7 @@ impl ProgressScreen { Self { status: "Idle.".to_string(), progress: 0, + determinate: true, is_installing: false, progress_rx: None, } @@ -40,15 +67,22 @@ impl ProgressScreen { pub fn start_installation(&mut self, rx: ProgressReceiver) { self.is_installing = true; self.progress = 0; + self.determinate = true; self.status = "Idle.".to_string(); self.progress_rx = Some(rx); } pub fn update(&mut self, message: Message) -> Task { match message { - Message::InstallationProgress(status, progress) => { + Message::InstallationProgress(update) => { + let ProgressUpdate { + status, + progress, + determinate, + } = update; self.status = status.clone(); self.progress = progress; + self.determinate = determinate; if progress == -1 { self.progress_rx = None; @@ -102,9 +136,15 @@ impl ProgressScreen { pub fn view(&self) -> Element<'_, Message> { let progress_bar = iced::widget::progress_bar(0.0..=100.0, self.progress as f32); + let status_text = if self.determinate { + format!("{}% - {}", self.progress, self.status) + } else { + self.status.clone() + }; + let screen_content = column![ text(t!("progress_installing_application")).size(14), - text(format!("{}% – {}", self.progress, self.status)).size(14), + text(status_text).size(14), progress_bar, container(text("")).height(Fill), ] diff --git a/apps/plumeimpactor/src/subscriptions.rs b/apps/plumeimpactor/src/subscriptions.rs index 89451e1e..3ae617f2 100644 --- a/apps/plumeimpactor/src/subscriptions.rs +++ b/apps/plumeimpactor/src/subscriptions.rs @@ -5,8 +5,9 @@ use tray_icon::{TrayIconEvent, menu::MenuEvent}; use crate::{ defaults::get_data_path, - screen::{Message, general}, + screen::{Message, general, progress::ProgressUpdate}, }; +use plume_utils::discovery::{DeviceDiscovery, PlatformDiscovery}; use plume_utils::{Bundle, Device, PlistInfoTrait}; pub(crate) fn device_listener() -> Subscription { @@ -30,9 +31,18 @@ pub(crate) fn device_listener() -> Subscription { let _ = tx.unbounded_send(Message::DeviceConnected(Device { name: "This Mac".into(), udid: mac_udid, + product_type: None, + device_class: Some("Mac".to_string()), + os_version: None, + serial_number: None, device_id: u32::MAX, usbmuxd_device: None, is_mac: true, + pairing_address: None, + reconnect_address: None, + pairing_identity: None, + pairing_cache_dir: None, + core_device_authenticated: false, })); } } @@ -42,8 +52,10 @@ pub(crate) fn device_listener() -> Subscription { }; if let Ok(devices) = muxer.get_devices().await { - for dev in devices { - let device = Device::new(dev).await; + let device_futures = devices.into_iter().map(Device::new); + for device in plume_utils::deduplicate_devices( + futures::future::join_all(device_futures).await, + ) { let _ = tx.unbounded_send(Message::DeviceConnected(device)); } } @@ -75,6 +87,119 @@ pub(crate) fn device_listener() -> Subscription { }) } +pub(crate) fn network_device_listener() -> Subscription { + Subscription::run(|| { + iced::stream::channel( + 100, + |mut output: iced::futures::channel::mpsc::Sender| async move { + use iced::futures::{SinkExt, StreamExt}; + use std::collections::{HashMap, HashSet}; + + let (tx, mut rx) = iced::futures::channel::mpsc::unbounded::(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + rt.block_on(async move { + type EmittedState = ( + Option<(std::net::IpAddr, u16)>, + Option<(std::net::IpAddr, u16)>, + String, + ); + + let mut present_ids: HashSet = HashSet::new(); + let mut last_emitted: HashMap = HashMap::new(); + let mut miss_counts: HashMap = HashMap::new(); + let mut enriched: HashMap = + HashMap::new(); + + loop { + let scan_started = std::time::Instant::now(); + let scan_result = PlatformDiscovery::new() + .discover(std::time::Duration::from_secs(5)) + .await; + + match scan_result { + Ok(discovered) => { + let cache_dir = get_data_path(); + let devices = plume_utils::discovery::group_network_devices( + &discovered, + &cache_dir, + ); + + let mut current_ids: HashSet = HashSet::new(); + + for mut device in devices { + let id = device.device_id; + current_ids.insert(id); + miss_counts.remove(&id); + + if let Some(info) = enriched.get(&id) { + device.apply_tvos_info(info); + } else if device.has_pairing_source(&cache_dir) { + match device.fetch_tvos_info(cache_dir.clone()).await { + Ok(info) => { + device.apply_tvos_info(&info); + enriched.insert(id, info); + } + Err(e) => { + log::warn!( + "Could not fetch tvOS identity for {}: {e}", + device.name + ); + } + } + } + + let state: EmittedState = ( + device.pairing_address, + device.reconnect_address, + device.udid.clone(), + ); + let changed = last_emitted.get(&id) != Some(&state); + + if !present_ids.contains(&id) || changed { + present_ids.insert(id); + last_emitted.insert(id, state); + let _ = + tx.unbounded_send(Message::DeviceConnected(device)); + } + } + + for id in plume_utils::discovery::disconnected_after_missed_scans( + &mut present_ids, + ¤t_ids, + &mut miss_counts, + 2, + ) { + let _ = tx.unbounded_send(Message::DeviceDisconnected(id)); + last_emitted.remove(&id); + } + } + Err(e) => { + log::warn!("Network device scan failed: {e}"); + } + } + + let elapsed = scan_started.elapsed(); + let sleep_for = + std::time::Duration::from_secs(30).saturating_sub(elapsed); + tokio::time::sleep(sleep_for).await; + } + }); + }); + + while let Some(message) = rx.next().await { + let _ = output.send(message).await; + } + }, + ) + }) +} + pub(crate) fn tray_subscription() -> Subscription { Subscription::run(|| { iced::stream::channel( @@ -219,12 +344,12 @@ pub(crate) fn file_hover_subscription() -> Subscription { } pub(crate) fn installation_progress_listener( - progress_rx: Option>>>, -) -> Subscription<(String, i32)> { + progress_rx: Option>>>, +) -> Subscription { match progress_rx { Some(rx) => { struct State { - rx: Arc>>, + rx: Arc>>, } impl std::hash::Hash for State { @@ -238,25 +363,19 @@ pub(crate) fn installation_progress_listener( let rx = state.rx.clone(); iced::stream::channel( 100, - move |mut output: iced::futures::channel::mpsc::Sender<(String, i32)>| async move { + move |mut output: iced::futures::channel::mpsc::Sender| async move { use iced::futures::{SinkExt, StreamExt}; let (tx, mut rx_stream) = - iced::futures::channel::mpsc::unbounded::<(String, i32)>(); + iced::futures::channel::mpsc::unbounded::(); let rx_thread = rx.clone(); std::thread::spawn(move || { loop { - let message = { - if let Ok(guard) = rx_thread.lock() { - guard.try_recv().ok() - } else { - None + if let Ok(guard) = rx_thread.lock() { + while let Ok(update) = guard.try_recv() { + let _ = tx.unbounded_send(update); } - }; - - if let Some((status, progress)) = message { - let _ = tx.unbounded_send((status, progress)); } std::thread::sleep(std::time::Duration::from_millis(100)); @@ -280,16 +399,35 @@ pub(crate) async fn run_installation( options: &plume_utils::SignerOptions, account: Option<&plume_store::GsaAccount>, mut store: Option<&mut plume_store::AccountStore>, - tx: &std::sync::mpsc::Sender<(String, i32)>, + tx: &std::sync::mpsc::Sender, ) -> Result<(), String> { - use plume_core::{AnisetteConfiguration, CertificateIdentity, developer::DeveloperSession}; + use plume_core::{ + AnisetteConfiguration, CertificateIdentity, + developer::{DeveloperPlatform, DeveloperSession}, + }; use plume_utils::{Signer, SignerInstallMode, SignerMode}; let package_file: Bundle; let mut options = options.clone(); let send = |msg: String, progress: i32| { - let _ = tx.send((msg, progress)); + let _ = tx.send(ProgressUpdate::new(msg, progress)); }; + let platform = match device { + Some(dev) if dev.is_tvos() => DeveloperPlatform::Tvos, + _ => DeveloperPlatform::Ios, + }; + let device_udid = device + .filter(|dev| !dev.is_mac) + .map(|dev| dev.udid.as_str()); + + if let Some(dev) = device.filter(|dev| !dev.is_mac) + && !plume_utils::is_valid_device_udid(&dev.udid) + { + return Err(format!( + "Authenticated {} UDID is unavailable; pair or reconnect before installing", + dev.name + )); + } send("Preparing package...".to_string(), 10); @@ -348,9 +486,9 @@ pub(crate) async fn run_installation( send("Ensuring device is registered...".to_string(), 30); - if let Some(dev) = &device { + if let Some(dev) = device.filter(|dev| !dev.is_mac) { session - .qh_ensure_device(team_id, &dev.name, &dev.udid) + .qh_ensure_device(team_id, &dev.name, &dev.udid, platform) .await .map_err(|e| e.to_string())?; } @@ -361,20 +499,32 @@ pub(crate) async fn run_installation( let bundle = package.get_package_bundle().map_err(|e| e.to_string())?; - send("Signing package...".to_string(), 70); + send("Preparing provisioning profiles...".to_string(), 60); signer .modify_bundle(&bundle, &Some(team_id.clone())) .await .map_err(|e| e.to_string())?; signer - .register_bundle(&bundle, &session, team_id, false) + .register_bundle_for_device( + &bundle, + &session, + team_id, + false, + platform, + device_udid, + ) .await .map_err(|e| e.to_string())?; + send("Signing package...".to_string(), 70); signer - .sign_bundle(&bundle) + .sign_bundle_for_device(&bundle, platform, device_udid) .await .map_err(|e| e.to_string())?; + send("Verifying signatures and provisioning profiles...".to_string(), 82); + signer + .validate_signed_bundle(&bundle, platform, device_udid) + .map_err(|e| e.to_string())?; options = signer.options.clone(); package_file = bundle; @@ -396,6 +546,9 @@ pub(crate) async fn run_installation( .sign_bundle(&bundle) .await .map_err(|e| e.to_string())?; + signer + .validate_signed_bundle(&bundle, platform, None) + .map_err(|e| e.to_string())?; options = signer.options.clone(); package_file = bundle; @@ -413,15 +566,58 @@ pub(crate) async fn run_installation( SignerInstallMode::Install => { if let Some(dev) = &device { if !dev.is_mac { - send("Sending to device...".to_string(), 70); + let upload_path = if dev.is_network() { + let _ = tx.send(ProgressUpdate::indeterminate( + "Packaging for transfer...".to_string(), + 70, + )); + + let archive_package = package.clone(); + let bundle_dir = package_file.bundle_dir().clone(); + tokio::task::spawn_blocking(move || { + archive_package.get_archive_based_on_path(&bundle_dir) + }) + .await + .map_err(|e| format!("Packaging task failed: {e}"))? + .map_err(|e| format!("Failed to package for transfer: {e}"))? + } else { + package_file.bundle_dir().clone() + }; + + if upload_path.is_file() { + plume_utils::Package::validate_archive( + &upload_path, + options.mode == SignerMode::Pem, + ) + .map_err(|e| format!("Produced package failed validation: {e}"))?; + } + + if dev.is_network() { + let _ = tx.send(ProgressUpdate::indeterminate( + "Pairing/reconnecting to Apple TV...".to_string(), + 72, + )); + } + + let upload_status = match tokio::fs::metadata(&upload_path).await { + Ok(meta) if meta.is_file() => format!( + "Sending to device ({})...", + plume_utils::format_bytes(meta.len()) + ), + _ => "Sending to device...".to_string(), + }; + let _ = tx.send(ProgressUpdate::indeterminate(upload_status, 70)); let tx_clone = tx.clone(); - dev.install_app(&package_file.bundle_dir(), move |progress: i32| { + dev.install_app(&upload_path, move |progress: i32| { let tx = tx_clone.clone(); // Some libraries expect this future to be processed. // We ensure it sends and resolves immediately. Box::pin(async move { - let _ = tx.send(("Installing...".to_string(), 70 + (progress / 5))); + let _ = tx.send(ProgressUpdate::new( + "Installing...".to_string(), + 70 + (progress / 5), + )); }) }) .await @@ -457,6 +653,11 @@ pub(crate) async fn run_installation( let archive_path = package .get_archive_based_on_path(&package_file.bundle_dir()) .map_err(|e| e.to_string())?; + plume_utils::Package::validate_archive( + &archive_path, + options.mode == SignerMode::Pem, + ) + .map_err(|e| e.to_string())?; let file = rfd::AsyncFileDialog::new() .set_title("Save Package As") @@ -478,7 +679,7 @@ pub(crate) async fn run_installation( } if options.refresh && options.mode == SignerMode::Pem { - send("Saving for refresh...".to_string(), 75); + send("Saving for refresh...".to_string(), 99); let path = get_data_path().join("refresh_store"); tokio::fs::create_dir_all(&path) .await diff --git a/apps/plumesign/src/commands/account.rs b/apps/plumesign/src/commands/account.rs index f58c09a5..013d4a3a 100644 --- a/apps/plumesign/src/commands/account.rs +++ b/apps/plumesign/src/commands/account.rs @@ -5,7 +5,11 @@ use anyhow::{Ok, Result}; use clap::{Args, Subcommand}; use dialoguer::Select; -use plume_core::{AnisetteConfiguration, auth::Account, developer::DeveloperSession}; +use plume_core::{ + AnisetteConfiguration, + auth::Account, + developer::{DeveloperPlatform, DeveloperSession}, +}; use plume_store::AccountStore; use crate::get_data_path; @@ -267,7 +271,10 @@ async fn devices(args: DevicesArgs) -> Result<()> { args.team_id.unwrap() }; - let p = session.qh_list_devices(&team_id).await?.devices; + let p = session + .qh_list_devices(&team_id, DeveloperPlatform::Ios) + .await? + .devices; log::info!("{:#?}", p); @@ -284,7 +291,7 @@ async fn register_device(args: RegisterDeviceArgs) -> Result<()> { }; let p = session - .qh_add_device(&team_id, &args.name, &args.udid) + .qh_add_device(&team_id, &args.name, &args.udid, DeveloperPlatform::Ios) .await? .device; diff --git a/apps/plumesign/src/commands/sign.rs b/apps/plumesign/src/commands/sign.rs index 86a63f7a..7757c564 100644 --- a/apps/plumesign/src/commands/sign.rs +++ b/apps/plumesign/src/commands/sign.rs @@ -127,9 +127,18 @@ pub async fn execute(args: SignArgs) -> Result<()> { Some(Device { name: "My Mac".to_string(), udid: String::new(), + product_type: None, + device_class: Some("Mac".to_string()), + os_version: None, + serial_number: None, device_id: 0, usbmuxd_device: None, is_mac: true, + pairing_address: None, + reconnect_address: None, + pairing_identity: None, + pairing_cache_dir: None, + core_device_authenticated: false, }) } else { Some(select_device(args.udid).await?) @@ -143,30 +152,64 @@ pub async fn execute(args: SignArgs) -> Result<()> { None }; + let platform = device + .as_ref() + .map(plume_utils::Device::developer_platform) + .unwrap_or_default(); + let device_udid = device + .as_ref() + .filter(|device| !device.is_mac) + .map(|device| device.udid.as_str()); + if let Some(device) = device.as_ref().filter(|device| !device.is_mac) + && !plume_utils::is_valid_device_udid(&device.udid) + { + return Err(anyhow::anyhow!( + "Authenticated Apple TV UDID is unavailable; pair or reconnect before installing" + )); + } + if let Some((session, team_id)) = team_id_opt { signer .modify_bundle(&bundle, &Some(team_id.clone())) .await?; - if let Some(ref dev) = device { + if let Some(dev) = device.as_ref().filter(|device| !device.is_mac) { log::info!("Registering device: {} ({})", dev.name, dev.udid); session - .qh_ensure_device(&team_id, &dev.name, &dev.udid) + .qh_ensure_device(&team_id, &dev.name, &dev.udid, platform) .await?; } signer - .register_bundle(&bundle, &session, &team_id, false) + .register_bundle_for_device( + &bundle, + &session, + &team_id, + false, + platform, + device_udid, + ) + .await?; + signer + .sign_bundle_for_device(&bundle, platform, device_udid) .await?; - signer.sign_bundle(&bundle).await?; + signer.validate_signed_bundle(&bundle, platform, device_udid)?; if let Some(dev) = device { log::info!("Installing to device: {}", dev.name); + let install_path = if dev.is_network() { + let package = package + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Network installation requires an IPA input"))?; + package.get_archive_based_on_path(&bundle.bundle_dir())? + } else { + bundle.bundle_dir().clone() + }; #[cfg(all(target_os = "macos", target_arch = "aarch64"))] if args.mac { plume_utils::install_app_mac(&bundle.bundle_dir()).await?; } else { - dev.install_app(bundle.bundle_dir(), |progress| async move { + dev.install_app(&install_path, |progress| async move { log::info!("Installation progress: {}%", progress); }) .await?; @@ -174,7 +217,7 @@ pub async fn execute(args: SignArgs) -> Result<()> { #[cfg(not(all(target_os = "macos", target_arch = "aarch64")))] { - dev.install_app(bundle.bundle_dir(), |progress| async move { + dev.install_app(&install_path, |progress| async move { log::info!("Installation progress: {}%", progress); }) .await?; @@ -184,11 +227,22 @@ pub async fn execute(args: SignArgs) -> Result<()> { } } else { signer.modify_bundle(&bundle, &None).await?; - signer.sign_bundle(&bundle).await?; + signer + .sign_bundle_for_device(&bundle, platform, device_udid) + .await?; + signer.validate_signed_bundle(&bundle, platform, device_udid)?; if let Some(dev) = device { log::info!("Installing to device: {}", dev.name); - dev.install_app(bundle.bundle_dir(), |progress| async move { + let install_path = if dev.is_network() { + let package = package + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Network installation requires an IPA input"))?; + package.get_archive_based_on_path(&bundle.bundle_dir())? + } else { + bundle.bundle_dir().clone() + }; + dev.install_app(&install_path, |progress| async move { log::info!("Installation progress: {}%", progress); }) .await?; @@ -200,6 +254,7 @@ pub async fn execute(args: SignArgs) -> Result<()> { if let Some(pkg) = package { if let Some(output_path) = args.output { let archived_path = pkg.get_archive_based_on_path(&args.package.clone())?; + Package::validate_archive(&archived_path, signer.options.mode == SignerMode::Pem)?; tokio::fs::copy(&archived_path, &output_path).await?; log::info!("Saved signed package to: {}", output_path.display()); if std::env::var("PLUME_DELETE_AFTER_FINISHED").is_err() { diff --git a/crates/plume_core/src/developer/qh/app_ids.rs b/crates/plume_core/src/developer/qh/app_ids.rs index 229273cb..3b50527f 100644 --- a/crates/plume_core/src/developer/qh/app_ids.rs +++ b/crates/plume_core/src/developer/qh/app_ids.rs @@ -4,15 +4,21 @@ use serde::Deserialize; use crate::Error; use super::{DeveloperSession, QHResponseMeta}; +use crate::developer::DeveloperPlatform; use crate::developer::strip_invalid_chars; use crate::developer_endpoint; impl DeveloperSession { - pub async fn qh_list_app_ids(&self, team_id: &String) -> Result { + pub async fn qh_list_app_ids( + &self, + team_id: &String, + platform: DeveloperPlatform, + ) -> Result { let endpoint = developer_endpoint!("/QH65B2/ios/listAppIds.action"); let mut body = Dictionary::new(); body.insert("teamId".to_string(), Value::String(team_id.clone())); + platform.apply_to(&mut body); let response = self.qh_send_request(&endpoint, Some(body)).await?; let response_data: AppIDsResponse = plist::from_value(&Value::Dictionary(response))?; @@ -25,6 +31,7 @@ impl DeveloperSession { team_id: &String, name: &String, identifier: &String, + platform: DeveloperPlatform, ) -> Result { let endpoint = developer_endpoint!("/QH65B2/ios/addAppId.action"); @@ -32,6 +39,7 @@ impl DeveloperSession { body.insert("teamId".to_string(), Value::String(team_id.clone())); body.insert("name".to_string(), Value::String(strip_invalid_chars(name))); body.insert("identifier".to_string(), Value::String(identifier.clone())); + platform.apply_to(&mut body); let response = self.qh_send_request(&endpoint, Some(body)).await?; let response_data: AppIDResponse = plist::from_value(&Value::Dictionary(response))?; @@ -82,8 +90,9 @@ impl DeveloperSession { &self, team_id: &String, identifier: &String, + platform: DeveloperPlatform, ) -> Result, Error> { - let response_data = self.qh_list_app_ids(team_id).await?; + let response_data = self.qh_list_app_ids(team_id, platform).await?; let app_id = response_data .app_ids @@ -98,11 +107,12 @@ impl DeveloperSession { team_id: &String, name: &String, identifier: &String, + platform: DeveloperPlatform, ) -> Result { - if let Some(app_id) = self.qh_get_app_id(team_id, identifier).await? { + if let Some(app_id) = self.qh_get_app_id(team_id, identifier, platform).await? { Ok(app_id) } else { - let response = self.qh_add_app_id(team_id, name, identifier).await?; + let response = self.qh_add_app_id(team_id, name, identifier, platform).await?; Ok(response.app_id) } } diff --git a/crates/plume_core/src/developer/qh/devices.rs b/crates/plume_core/src/developer/qh/devices.rs index 43a32041..268fed9d 100644 --- a/crates/plume_core/src/developer/qh/devices.rs +++ b/crates/plume_core/src/developer/qh/devices.rs @@ -4,14 +4,20 @@ use serde::Deserialize; use crate::Error; use super::{DeveloperSession, QHResponseMeta}; +use crate::developer::DeveloperPlatform; use crate::developer_endpoint; impl DeveloperSession { - pub async fn qh_list_devices(&self, team_id: &String) -> Result { + pub async fn qh_list_devices( + &self, + team_id: &String, + platform: DeveloperPlatform, + ) -> Result { let endpoint = developer_endpoint!("/QH65B2/ios/listDevices.action"); let mut body = Dictionary::new(); body.insert("teamId".to_string(), Value::String(team_id.clone())); + platform.apply_to(&mut body); let response = self.qh_send_request(&endpoint, Some(body)).await?; let response_data: DevicesResponse = plist::from_value(&Value::Dictionary(response))?; @@ -24,6 +30,7 @@ impl DeveloperSession { team_id: &String, device_name: &String, device_udid: &String, + platform: DeveloperPlatform, ) -> Result { let endpoint = developer_endpoint!("/QH65B2/ios/addDevice.action"); @@ -34,6 +41,7 @@ impl DeveloperSession { "deviceNumber".to_string(), Value::String(device_udid.clone()), ); + platform.apply_to(&mut body); let response = self.qh_send_request(&endpoint, Some(body)).await?; let response_data: DeviceResponse = plist::from_value(&Value::Dictionary(response))?; @@ -45,8 +53,9 @@ impl DeveloperSession { &self, team_id: &String, device_udid: &String, + platform: DeveloperPlatform, ) -> Result, Error> { - let response_data = self.qh_list_devices(team_id).await?; + let response_data = self.qh_list_devices(team_id, platform).await?; let device = response_data .devices @@ -61,12 +70,13 @@ impl DeveloperSession { team_id: &String, device_name: &String, device_udid: &String, + platform: DeveloperPlatform, ) -> Result { - if let Some(device) = self.qh_get_device(team_id, device_udid).await? { + if let Some(device) = self.qh_get_device(team_id, device_udid, platform).await? { Ok(device) } else { let response = self - .qh_add_device(team_id, device_name, device_udid) + .qh_add_device(team_id, device_name, device_udid, platform) .await?; Ok(response.device) } diff --git a/crates/plume_core/src/developer/qh/profile.rs b/crates/plume_core/src/developer/qh/profile.rs index 92cf54c3..a33dcd55 100644 --- a/crates/plume_core/src/developer/qh/profile.rs +++ b/crates/plume_core/src/developer/qh/profile.rs @@ -4,6 +4,7 @@ use serde::Deserialize; use crate::Error; use super::{DeveloperSession, QHResponseMeta}; +use crate::developer::DeveloperPlatform; use crate::developer_endpoint; impl DeveloperSession { @@ -11,12 +12,14 @@ impl DeveloperSession { &self, team_id: &String, app_id_id: &String, + platform: DeveloperPlatform, ) -> Result { let endpoint = developer_endpoint!("/QH65B2/ios/downloadTeamProvisioningProfile.action"); let mut body = Dictionary::new(); body.insert("teamId".to_string(), Value::String(team_id.clone())); body.insert("appIdId".to_string(), Value::String(app_id_id.clone())); + platform.apply_to(&mut body); let response = self.qh_send_request(&endpoint, Some(body)).await?; let response_data: ProfilesResponse = plist::from_value(&Value::Dictionary(response))?; diff --git a/crates/plume_core/src/utils/certificate.rs b/crates/plume_core/src/utils/certificate.rs index 1e673e71..84d1473d 100644 --- a/crates/plume_core/src/utils/certificate.rs +++ b/crates/plume_core/src/utils/certificate.rs @@ -32,6 +32,12 @@ pub struct CertificateIdentity { } impl CertificateIdentity { + pub fn certificate_der(&self) -> Option<&[u8]> { + self.cert + .as_ref() + .map(CapturedX509Certificate::constructed_data) + } + // Use for cli context or if you actually store pems? why would you do that though pub async fn new_with_paths(paths: Option>) -> Result { let mut cert = Self { diff --git a/crates/plume_utils/src/signer.rs b/crates/plume_utils/src/signer.rs index 5278d786..e2c9d453 100644 --- a/crates/plume_utils/src/signer.rs +++ b/crates/plume_utils/src/signer.rs @@ -6,7 +6,7 @@ use tokio::fs; use plume_core::{ CertificateIdentity, MobileProvision, SettingsScope, SigningSettings, UnifiedSigner, - developer::DeveloperSession, + developer::{DeveloperPlatform, DeveloperSession}, }; use crate::{Bundle, BundleType, Error, PlistInfoTrait, SignerApp, SignerMode, SignerOptions}; @@ -228,6 +228,20 @@ impl Signer { session: &DeveloperSession, team_id: &String, is_refresh: bool, + platform: DeveloperPlatform, + ) -> Result<(), Error> { + self.register_bundle_for_device(bundle, session, team_id, is_refresh, platform, None) + .await + } + + pub async fn register_bundle_for_device( + &mut self, + bundle: &Bundle, + session: &DeveloperSession, + team_id: &String, + is_refresh: bool, + platform: DeveloperPlatform, + device_udid: Option<&str>, ) -> Result<(), Error> { if self.options.mode != SignerMode::Pem { return Ok(()); @@ -243,6 +257,12 @@ impl Signer { let bundle_arc = Arc::new(bundle.clone()); let session_arc = Arc::new(session); let team_id_arc = Arc::new(team_id.clone()); + let device_udid = device_udid.map(str::to_owned); + let certificate_der = self + .certificate + .as_ref() + .and_then(CertificateIdentity::certificate_der) + .map(ToOwned::to_owned); let futures = bundles.iter().filter_map(|sub_bundle| { let sub_bundle = sub_bundle.clone(); @@ -250,8 +270,11 @@ impl Signer { let session = session_arc.clone(); let team_id = team_id_arc.clone(); let signer_settings = signer_settings.clone(); + let device_udid = device_udid.clone(); + let certificate_der = certificate_der.clone(); if signer_settings.embedding.single_profile + && platform != DeveloperPlatform::Tvos && sub_bundle.bundle_dir() != bundle.bundle_dir() { return None; @@ -276,10 +299,12 @@ impl Signer { let name = sub_bundle.get_bundle_name().unwrap_or_else(|| id.clone()); - session.qh_ensure_app_id(&team_id, &name, &id).await?; + session + .qh_ensure_app_id(&team_id, &name, &id, platform) + .await?; let app_id_id = session - .qh_get_app_id(&team_id, &id) + .qh_get_app_id(&team_id, &id, platform) .await? .ok_or_else(|| Error::Other("Failed to get ensured app ID.".into()))?; @@ -337,17 +362,45 @@ impl Signer { } let profiles = session - .qh_get_profile(&team_id, &app_id_id.app_id_id) + .qh_get_profile(&team_id, &app_id_id.app_id_id, platform) .await?; - let profile_data = profiles.provisioning_profile.encoded_profile; + let mut mobile_provision = MobileProvision::load_with_bytes( + profiles.provisioning_profile.encoded_profile.as_ref().to_vec(), + )?; + let requested_entitlements = macho.entitlements().as_ref(); + if let Err(error) = mobile_provision.validate_for( + platform, + &id, + device_udid.as_deref(), + certificate_der.as_deref(), + requested_entitlements, + ) { + log::warn!( + "Cached or newly returned profile for {id} failed validation: {error}; requesting a replacement" + ); + let refreshed = session + .qh_get_profile(&team_id, &app_id_id.app_id_id, platform) + .await?; + mobile_provision = MobileProvision::load_with_bytes( + refreshed.provisioning_profile.encoded_profile.as_ref().to_vec(), + )?; + mobile_provision.validate_for( + platform, + &id, + device_udid.as_deref(), + certificate_der.as_deref(), + requested_entitlements, + ) + .map_err(|replacement_error| { + Error::Core(replacement_error) + })?; + } tokio::fs::write( sub_bundle.bundle_dir().join("embedded.mobileprovision"), - &profile_data, + &mobile_provision.data, ) .await?; - let mobile_provision = - MobileProvision::load_with_bytes(profile_data.as_ref().to_vec())?; Ok::<_, Error>(mobile_provision) }) }); @@ -358,7 +411,175 @@ impl Signer { Ok(()) } + pub fn validate_signed_bundle( + &self, + bundle: &Bundle, + platform: DeveloperPlatform, + device_udid: Option<&str>, + ) -> Result<(), Error> { + if self.options.mode == SignerMode::None { + return Ok(()); + } + + let certificate_der = self + .certificate + .as_ref() + .and_then(CertificateIdentity::certificate_der); + + for signed_bundle in bundle.collect_bundles_sorted()? { + if *signed_bundle.bundle_type() == BundleType::Unknown { + continue; + } + + let executable = if *signed_bundle.bundle_type() == BundleType::Dylib { + signed_bundle.bundle_dir().clone() + } else { + let executable_name = signed_bundle + .get_executable() + .ok_or_else(|| Error::Other("Signed bundle has no executable".to_string()))?; + signed_bundle.bundle_dir().join(executable_name) + }; + let macho = plume_core::MachO::new(&executable)?; + let has_code_signature = macho + .macho_file() + .nth_macho(0)? + .code_signature()? + .is_some(); + if !has_code_signature { + return Err(Error::Other(format!( + "Signed bundle {} has no code signature", + signed_bundle.bundle_dir().display() + ))); + } + + if self.options.mode != SignerMode::Adhoc { + let verification_problems = + plume_core::verify_macho_data(std::fs::read(&executable)?); + if let Some(problem) = verification_problems.first() { + return Err(Error::Other(format!( + "Signature verification failed for {}: {problem}", + signed_bundle.bundle_dir().display() + ))); + } + } + + if !signed_bundle.bundle_type().should_have_entitlements() { + continue; + } + + let bundle_id = signed_bundle + .get_bundle_identifier() + .ok_or_else(|| Error::Other("Signed bundle has no bundle identifier".to_string()))?; + let profile_path = signed_bundle.bundle_dir().join("embedded.mobileprovision"); + let profile = MobileProvision::load_with_path(profile_path)?; + profile.validate_for( + platform, + &bundle_id, + device_udid, + certificate_der, + macho.entitlements().as_ref(), + )?; + } + + Ok(()) + } + pub async fn sign_bundle(&self, bundle: &Bundle) -> Result<(), Error> { + self.sign_bundle_for_device(bundle, DeveloperPlatform::Ios, None) + .await + } + + pub async fn sign_bundle_for_device( + &self, + bundle: &Bundle, + platform: DeveloperPlatform, + device_udid: Option<&str>, + ) -> Result<(), Error> { + self.validate_provisioning_files(bundle, platform, device_udid)?; + self.sign_bundle_unchecked(bundle, platform, device_udid).await + } + + pub fn validate_provisioning_files( + &self, + bundle: &Bundle, + platform: DeveloperPlatform, + device_udid: Option<&str>, + ) -> Result<(), Error> { + if self.options.mode != SignerMode::Pem { + return Ok(()); + } + + let certificate_der = self + .certificate + .as_ref() + .and_then(CertificateIdentity::certificate_der); + let bundles = bundle + .collect_bundles_sorted()? + .into_iter() + .filter(|candidate| candidate.bundle_type().should_have_entitlements()) + .collect::>(); + + if bundles.is_empty() { + return Err(Error::Core( + plume_core::Error::ProvisioningProfileInvalid( + "no signable app or extension bundles were found".to_string(), + ), + )); + } + if self.provisioning_files.is_empty() { + return Err(Error::Core( + plume_core::Error::ProvisioningProfileInvalid( + "no provisioning profiles are available".to_string(), + ), + )); + } + + for signed_bundle in bundles { + let bundle_id = signed_bundle + .get_bundle_identifier() + .ok_or_else(|| Error::Other("Signable bundle has no bundle identifier".into()))?; + let executable_name = signed_bundle + .get_executable() + .ok_or_else(|| Error::Other("Signable bundle has no executable".into()))?; + let macho = plume_core::MachO::new(&signed_bundle.bundle_dir().join(executable_name))?; + let mut last_error = None; + + let valid = self.provisioning_files.iter().any(|profile| { + match profile.validate_for( + platform, + &bundle_id, + device_udid, + certificate_der, + macho.entitlements().as_ref(), + ) { + Ok(()) => true, + Err(error) => { + last_error = Some(error); + false + } + } + }); + + if !valid { + let error = last_error.unwrap_or_else(|| { + plume_core::Error::ProvisioningProfileInvalid(format!( + "no profile grants {bundle_id}" + )) + }); + return Err(Error::Core(error)); + } + log::info!("ProfileValidated: true for {bundle_id} on {platform}"); + } + + Ok(()) + } + + async fn sign_bundle_unchecked( + &self, + bundle: &Bundle, + platform: DeveloperPlatform, + device_udid: Option<&str>, + ) -> Result<(), Error> { if self.options.mode == SignerMode::None { return Ok(()); } @@ -381,6 +602,8 @@ impl Signer { &self.provisioning_files, settings.clone(), &entitlements_xml, + platform, + device_udid, )?; } @@ -399,12 +622,23 @@ impl Signer { provisioning_files: &[MobileProvision], mut settings: SigningSettings<'_>, entitlements_xml: &String, + platform: DeveloperPlatform, + device_udid: Option<&str>, ) -> Result<(), Error> { if *bundle.bundle_type() == BundleType::Unknown { return Ok(()); } let mut entitlements_xml = entitlements_xml.clone(); + let bundle_id = bundle.get_bundle_identifier(); + let binary_path = bundle + .get_executable() + .map(|executable| bundle.bundle_dir().join(executable)); + let requested_entitlements = binary_path + .as_ref() + .map(plume_core::MachO::new) + .transpose()? + .and_then(|macho| macho.entitlements().clone()); // Only Apps and AppExtensions should have entitlements from provisioning profiles // Dylibs, frameworks, and other components should be signed without entitlements @@ -413,27 +647,26 @@ impl Signer { && bundle.bundle_type().should_have_entitlements() && !provisioning_files.is_empty() { - let mut matched_prov = None; - - for prov in provisioning_files { - if let (Some(bundle_id), Some(team_id)) = - (bundle.get_bundle_identifier(), prov.bundle_id()) - { - if team_id == bundle_id { - matched_prov = Some(prov); - break; - } - } - } + let matched_prov = bundle_id.as_deref().and_then(|bundle_id| { + provisioning_files.iter().find(|prov| { + prov.validate_for( + platform, + bundle_id, + device_udid, + self.certificate + .as_ref() + .and_then(CertificateIdentity::certificate_der), + requested_entitlements.as_ref(), + ) + .is_ok() + }) + }); if let Some(prov) = matched_prov.or_else(|| provisioning_files.first()) { let mut prov = prov.clone(); - if let Some(bundle_executable) = bundle.get_executable() { - if let Some(bundle_id) = bundle.get_bundle_identifier() { - let binary_path = bundle.bundle_dir().join(bundle_executable); - prov.merge_entitlements(binary_path, &bundle_id).ok(); - } + if let (Some(binary_path), Some(bundle_id)) = (&binary_path, &bundle_id) { + prov.merge_entitlements(binary_path.clone(), bundle_id)?; } std::fs::write( @@ -441,9 +674,8 @@ impl Signer { &prov.data, )?; - if let Ok(ent_xml) = prov.entitlements_as_bytes() { - entitlements_xml = String::from_utf8_lossy(&ent_xml).to_string(); - } + let ent_xml = prov.entitlements_as_bytes()?; + entitlements_xml = String::from_utf8_lossy(&ent_xml).to_string(); } } From 0ce81927dfc87009676cdd90eb57e7e46415ca0a Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:35:45 +0200 Subject: [PATCH 3/8] feat(ui): add Apple TV pairing workflow --- apps/plumeimpactor/src/screen/general.rs | 36 +- apps/plumeimpactor/src/screen/mod.rs | 116 ++- apps/plumeimpactor/src/screen/tvos_pairing.rs | 689 ++++++++++++++++++ locales/en.toml | 1 + 4 files changed, 804 insertions(+), 38 deletions(-) create mode 100644 apps/plumeimpactor/src/screen/tvos_pairing.rs diff --git a/apps/plumeimpactor/src/screen/general.rs b/apps/plumeimpactor/src/screen/general.rs index 0c041301..961f0e90 100644 --- a/apps/plumeimpactor/src/screen/general.rs +++ b/apps/plumeimpactor/src/screen/general.rs @@ -20,6 +20,7 @@ pub enum Message { FileSelected(Option), NavigateToInstaller(plume_utils::Package), NavigateToUtilities, + NavigateTvOsPairing, OpenGitHub, OpenDonate, } @@ -121,23 +122,34 @@ impl GeneralScreen { fn view_buttons(&self) -> Element<'_, Message> { container( - row![ + column![ + row![ + button(appearance::icon_text( + appearance::WRENCH, + t!("utilities"), + None + )) + .on_press(Message::NavigateToUtilities) + .width(Fill) + .style(appearance::s_button), + button(appearance::icon_text( + appearance::DOWNLOAD, + t!("import_ipa"), + None + )) + .on_press(Message::OpenFileDialog) + .width(Fill) + .style(appearance::s_button) + ] + .spacing(appearance::THEME_PADDING), button(appearance::icon_text( - appearance::WRENCH, - t!("utilities"), + appearance::PLUS, + t!("pair_apple_tv"), None )) - .on_press(Message::NavigateToUtilities) + .on_press(Message::NavigateTvOsPairing) .width(Fill) .style(appearance::s_button), - button(appearance::icon_text( - appearance::DOWNLOAD, - t!("import_ipa"), - None - )) - .on_press(Message::OpenFileDialog) - .width(Fill) - .style(appearance::s_button) ] .spacing(appearance::THEME_PADDING), ) diff --git a/apps/plumeimpactor/src/screen/mod.rs b/apps/plumeimpactor/src/screen/mod.rs index b9819ced..de06191f 100644 --- a/apps/plumeimpactor/src/screen/mod.rs +++ b/apps/plumeimpactor/src/screen/mod.rs @@ -1,7 +1,8 @@ pub(crate) mod general; mod package; -mod progress; +pub(crate) mod progress; pub(crate) mod settings; +mod tvos_pairing; mod utilties; mod windows; @@ -74,6 +75,7 @@ pub enum Message { SettingsScreen(settings::Message), InstallerScreen(package::Message), ProgressScreen(progress::Message), + TvOsPairingScreen(tvos_pairing::Message), CertificateResetRequested(crate::certificate_reset::ConfirmationRequest), ConfirmCertificateReset, CancelCertificateReset, @@ -97,12 +99,14 @@ pub struct Impactor { } #[derive(Debug, Clone, PartialEq)] +#[allow(dead_code)] pub enum ImpactorScreenType { Main, Utilities, Settings, Installer, Progress, + TvOsPairing, } enum ImpactorScreen { @@ -111,6 +115,7 @@ enum ImpactorScreen { Settings(settings::SettingsScreen), Installer(package::PackageScreen), Progress(progress::ProgressScreen), + TvOsPairing(tvos_pairing::TvOsPairingScreen), } impl Impactor { @@ -192,7 +197,25 @@ impl Impactor { Task::none() } Message::DeviceConnected(device) => { - if !self.devices.iter().any(|d| d.device_id == device.device_id) { + let existing_index = self.devices.iter().position(|existing| { + existing.device_id == device.device_id + || (plume_utils::is_valid_device_udid(&existing.udid) + && plume_utils::is_valid_device_udid(&device.udid) + && existing.udid.eq_ignore_ascii_case(&device.udid)) + }); + if let Some(existing_index) = existing_index { + let existing_id = self.devices[existing_index].device_id; + let selected_existing = self.selected_device.as_ref().map(|d| d.device_id) + == Some(existing_id); + self.devices[existing_index] = device.clone(); + + if selected_existing + || self.selected_device.as_ref().map(|d| d.device_id) + == Some(device.device_id) + { + self.selected_device = Some(device.clone()); + } + } else { self.devices.push(device.clone()); if self.selected_device.is_none() && device.device_id != u32::MAX { @@ -200,9 +223,11 @@ impl Impactor { } } - if let Some(daemon_devices) = REFRESH_DAEMON_DEVICES.get() { - if let Ok(mut devices) = daemon_devices.lock() { - devices.insert(device.udid.clone(), device.clone()); + if !device.udid.is_empty() { + if let Some(daemon_devices) = REFRESH_DAEMON_DEVICES.get() { + if let Ok(mut devices) = daemon_devices.lock() { + devices.insert(device.udid.clone(), device.clone()); + } } } @@ -286,6 +311,7 @@ impl Impactor { ImpactorScreen::Installer(_) => ImpactorScreenType::Progress, ImpactorScreen::Settings(_) => return Task::none(), ImpactorScreen::Progress(_) => return Task::none(), + ImpactorScreen::TvOsPairing(_) => return Task::none(), }; self.navigate_to_screen(next_screen); @@ -305,6 +331,10 @@ impl Impactor { self.navigate_to_screen(ImpactorScreenType::Main); Task::none() } + ImpactorScreen::TvOsPairing(_) => { + self.navigate_to_screen(ImpactorScreenType::Main); + Task::none() + } ImpactorScreen::Settings(_) => { if let Some(prev_screen) = self.previous_screen.take() { self.current_screen = *prev_screen; @@ -460,6 +490,10 @@ impl Impactor { return Task::done(Message::UtilitiesScreen( utilties::Message::RefreshApps(rppairing_enabled), )); + } else if let general::Message::NavigateTvOsPairing = msg { + self.current_screen = + ImpactorScreen::TvOsPairing(tvos_pairing::TvOsPairingScreen::new()); + return Task::none(); } task @@ -646,6 +680,25 @@ impl Impactor { Task::none() } } + Message::TvOsPairingScreen(msg) => { + if let ImpactorScreen::TvOsPairing(ref mut screen) = self.current_screen { + let paired_device = match &msg { + tvos_pairing::Message::PairComplete(Ok(device)) + | tvos_pairing::Message::ReconnectComplete(Ok(device)) => { + Some(device.clone()) + } + _ => None, + }; + let update = screen.update(msg).map(Message::TvOsPairingScreen); + if let Some(device) = paired_device { + Task::batch([update, Task::done(Message::DeviceConnected(device))]) + } else { + update + } + } else { + Task::none() + } + } Message::RefreshAppNow { udid, app_path } => { if let Some(daemon_devices) = REFRESH_DAEMON_DEVICES.get() { let daemon_devices = daemon_devices.clone(); @@ -782,6 +835,7 @@ impl Impactor { pub fn subscription(&self) -> Subscription { let device_subscription = subscriptions::device_listener(); + let network_device_subscription = subscriptions::network_device_listener(); let tray_subscription = subscriptions::tray_subscription(); @@ -791,19 +845,15 @@ impl Impactor { Subscription::none() }; - let progress_subscription = - if let ImpactorScreen::Progress(ref progress) = self.current_screen { - subscriptions::installation_progress_listener(progress.progress_rx.clone()).map( - |(status, progress_val)| { - Message::ProgressScreen(progress::Message::InstallationProgress( - status, - progress_val, - )) - }, - ) - } else { - Subscription::none() - }; + let progress_subscription = if let ImpactorScreen::Progress(ref progress) = + self.current_screen + { + subscriptions::installation_progress_listener(progress.progress_rx.clone()).map( + |update| Message::ProgressScreen(progress::Message::InstallationProgress(update)), + ) + } else { + Subscription::none() + }; let tray_menu_refresh_subscription = subscriptions::tray_menu_refresh_subscription(); let certificate_reset_subscription = subscriptions::certificate_reset_subscription(); @@ -818,6 +868,7 @@ impl Impactor { Subscription::batch(vec![ device_subscription, + network_device_subscription, tray_subscription, hover_subscription, progress_subscription, @@ -861,6 +912,9 @@ impl Impactor { screen.view(has_device).map(Message::InstallerScreen) } ImpactorScreen::Progress(screen) => screen.view().map(Message::ProgressScreen), + ImpactorScreen::TvOsPairing(screen) => { + screen.view().map(Message::TvOsPairingScreen) + } } } @@ -872,11 +926,12 @@ impl Impactor { .map(String::as_str) .unwrap_or("No Device"); - let right_button = if matches!(self.current_screen, ImpactorScreen::Settings(_)) { - button(appearance::icon(appearance::CHEVRON_BACK)) - .on_press(Message::PreviousScreen) - .style(appearance::s_button) - } else if matches!(self.current_screen, ImpactorScreen::Utilities(_)) { + let right_button = if matches!( + self.current_screen, + ImpactorScreen::Settings(_) + | ImpactorScreen::Utilities(_) + | ImpactorScreen::TvOsPairing(_) + ) { button(appearance::icon(appearance::CHEVRON_BACK)) .on_press(Message::PreviousScreen) .style(appearance::s_button) @@ -978,7 +1033,12 @@ impl Impactor { ImpactorScreenType::Progress => { self.current_screen = ImpactorScreen::Progress(progress::ProgressScreen::new()); } - _ => {} + ImpactorScreenType::TvOsPairing => { + self.current_screen = + ImpactorScreen::TvOsPairing(tvos_pairing::TvOsPairingScreen::new()); + } + ImpactorScreenType::Installer => { + } } } @@ -1018,14 +1078,18 @@ impl Impactor { .await { Ok(_) => { - let _ = tx.send(("Installation complete!".to_string(), 100)); + let _ = tx.send(progress::ProgressUpdate::new( + "Installation complete!".to_string(), + 100, + )); if std::env::var("PLUME_DELETE_AFTER_FINISHED").is_err() { package.remove_package_stage(); } } Err(e) => { - let _ = tx_error.send((format!("Error: {}", e), -1)); + let _ = tx_error + .send(progress::ProgressUpdate::new(format!("Error: {}", e), -1)); if std::env::var("PLUME_DELETE_AFTER_FINISHED").is_err() { package.remove_package_stage(); diff --git a/apps/plumeimpactor/src/screen/tvos_pairing.rs b/apps/plumeimpactor/src/screen/tvos_pairing.rs new file mode 100644 index 00000000..e01d8647 --- /dev/null +++ b/apps/plumeimpactor/src/screen/tvos_pairing.rs @@ -0,0 +1,689 @@ +use iced::futures::StreamExt; +use iced::widget::{button, column, container, pick_list, row, rule, scrollable, text, text_input}; +use iced::{Center, Color, Element, Fill, Task}; +use plume_utils::Device; +use plume_utils::discovery::{ + DeviceDiscovery, DeviceType, DiscoveredDevice, PlatformDiscovery, + REMOTEPAIRING_MANUAL_PAIRING_SERVICE, REMOTEPAIRING_SERVICE, +}; +use std::time::Duration; + +use crate::appearance; +use crate::defaults::get_data_path; + +#[derive(Debug, Clone)] +struct StatusMessage { + content: String, + is_error: bool, +} + +impl StatusMessage { + fn success(s: impl Into) -> Self { + Self { + content: s.into(), + is_error: false, + } + } + fn error(s: impl Into) -> Self { + Self { + content: s.into(), + is_error: true, + } + } + fn info(s: impl Into) -> Self { + Self { + content: s.into(), + is_error: false, + } + } + fn color(&self) -> Color { + if self.is_error { + Color::from_rgb(0.9, 0.2, 0.2) + } else { + Color::from_rgb(0.2, 0.8, 0.4) + } + } +} + +#[derive(Debug, Clone)] +pub enum Message { + Scan, + ScanComplete(Result, String>), + SelectDevice(String), + PinChanged(String), + Pair, + Reconnect, + PinRequested(bool), + SubmitPin, + CancelPin, + PairComplete(Result), + ReconnectComplete(Result), + Forget, + ForgetComplete(Result<(), String>), + StartOver, +} + +#[derive(Debug, Clone)] +pub struct TvOsPairingScreen { + discovered: Vec, + selected_label: Option, + pin: String, + scanning: bool, + pairing: bool, + reconnecting: bool, + awaiting_pin: bool, + pin_sender: Option>, + status: Option, + paired_device: Option, +} + +impl TvOsPairingScreen { + pub fn new() -> Self { + Self { + discovered: Vec::new(), + selected_label: None, + pin: String::new(), + scanning: false, + pairing: false, + reconnecting: false, + awaiting_pin: false, + pin_sender: None, + status: None, + paired_device: None, + } + } + + fn device_label(device: &DiscoveredDevice) -> String { + let host = if device.hostname.is_empty() { + device.name.replace(' ', "-") + } else { + device.hostname.clone() + }; + format!("[WiFi (tvOS)] {} ({host})", device.name) + } + + fn selected_label(&self) -> Option<&str> { + self.selected_label.as_deref() + } + + fn selected_device(&self) -> Option<&DiscoveredDevice> { + let label = self.selected_label()?; + self.discovered + .iter() + .find(|device| Self::device_label(device) == label) + } + + fn manual_pairing_entry(&self) -> Option<&DiscoveredDevice> { + let label = self.selected_label()?; + self.discovered.iter().find(|d| { + Self::device_label(d) == label + && d.service_type == REMOTEPAIRING_MANUAL_PAIRING_SERVICE + }) + } + + fn pairing_identity(device: &DiscoveredDevice) -> String { + if device.hostname.is_empty() { + device.name.replace(' ', "-") + } else { + device.hostname.clone() + } + } + + fn reconnect_entry(&self) -> Option<&DiscoveredDevice> { + let label = self.selected_label()?; + self.discovered + .iter() + .find(|d| Self::device_label(d) == label && d.service_type == REMOTEPAIRING_SERVICE) + } + + pub fn update(&mut self, message: Message) -> Task { + match message { + Message::Scan => { + self.scanning = true; + self.status = Some(StatusMessage::info("Scanning for Apple TVs...")); + self.discovered.clear(); + self.selected_label = None; + self.pin.clear(); + + let (tx, rx) = std::sync::mpsc::sync_channel(1); + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + let result: Result, String> = rt.block_on(async { + PlatformDiscovery::new() + .discover(Duration::from_secs(5)) + .await + .map_err(|e| format!("Scan failed: {e}")) + }); + let _ = tx.send(result); + }); + + Task::perform( + async move { + std::thread::spawn(move || { + rx.recv().unwrap_or_else(|_| Err("Scan error".to_string())) + }) + .join() + .unwrap() + }, + Message::ScanComplete, + ) + } + + Message::ScanComplete(result) => { + self.scanning = false; + match result { + Ok(devices) => { + self.discovered = devices + .into_iter() + .filter(|d| d.device_type == DeviceType::AppleTV) + .collect(); + let tv_count = self + .discovered + .iter() + .map(Self::device_label) + .collect::>() + .len(); + if tv_count == 0 { + self.status = + Some(StatusMessage::info("No Apple TVs found on this network.")); + } else { + self.status = Some(StatusMessage::info(format!( + "Found {} Apple TV(s). Select one to pair.", + tv_count + ))); + } + } + Err(e) => { + self.status = Some(StatusMessage::error(e)); + } + } + Task::none() + } + + Message::SelectDevice(label) => { + self.selected_label = Some(label); + self.pin.clear(); + self.status = None; + Task::none() + } + + Message::PinChanged(s) => { + self.pin = s.chars().filter(|c| c.is_ascii_digit()).take(6).collect(); + Task::none() + } + + Message::Pair => { + let Some(dev) = self.manual_pairing_entry() else { + self.status = Some(StatusMessage::error( + "This Apple TV isn't showing a pairing PIN. On the Apple TV, open \ + Settings > Remotes and Devices > Remote App and Devices, wait for \ + \"Waiting to Pair...\", then Scan again.", + )); + return Task::none(); + }; + + let ip_str = match &dev.ip_address { + Some(s) => s.clone(), + None => { + self.status = + Some(StatusMessage::error("Selected device has no IP address.")); + return Task::none(); + } + }; + let pairing_port = match dev.port { + Some(p) => p, + None => { + self.status = Some(StatusMessage::error("Selected device has no port.")); + return Task::none(); + } + }; + let reconnect_port = self.reconnect_entry().and_then(|d| d.port); + + let name = dev.name.clone(); + let hostname = Self::pairing_identity(dev); + let cache_dir = get_data_path(); + + self.pin.clear(); + self.awaiting_pin = false; + self.pairing = true; + self.status = Some(StatusMessage::info("Connecting to Apple TV...")); + + let (pin_req_tx, mut pin_req_rx) = iced::futures::channel::mpsc::unbounded::<()>(); + let (result_tx, mut result_rx) = + iced::futures::channel::mpsc::unbounded::>(); + let (pin_resp_tx, pin_resp_rx) = std::sync::mpsc::sync_channel::(1); + self.pin_sender = Some(pin_resp_tx); + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + let pin_resp_rx = std::sync::Arc::new(std::sync::Mutex::new(pin_resp_rx)); + let result = rt.block_on(async move { + let ip: std::net::IpAddr = + ip_str.parse().map_err(|e| format!("Invalid IP: {e}"))?; + let mut device = Device::new_tvos( + name, + hostname, + ip, + Some(pairing_port), + reconnect_port, + cache_dir.clone(), + ); + device + .pair_tvos( + move || { + let pin_req_tx = pin_req_tx.clone(); + let pin_resp_rx = pin_resp_rx.clone(); + async move { + let _ = pin_req_tx.unbounded_send(()); + let Ok(rx) = pin_resp_rx.lock() else { + return String::new(); + }; + rx.recv_timeout(Duration::from_secs(180)) + .unwrap_or_default() + } + }, + cache_dir.clone(), + ) + .await + .map_err(|e| format!("{e}"))?; + let info = device + .fetch_tvos_info(cache_dir) + .await + .map_err(|e| format!("{e}"))?; + device.apply_tvos_info(&info); + if !plume_utils::is_valid_device_udid(&device.udid) { + return Err( + "Apple TV pairing succeeded but its authenticated UDID was not returned" + .to_string(), + ); + } + Ok(device) + }); + let _ = result_tx.unbounded_send(result); + }); + + Task::batch([ + Task::perform( + async move { + result_rx + .next() + .await + .unwrap_or_else(|| Err("Pairing thread error".to_string())) + }, + Message::PairComplete, + ), + Task::perform( + async move { pin_req_rx.next().await.is_some() }, + Message::PinRequested, + ), + ]) + } + + Message::Reconnect => { + let Some(dev) = self.reconnect_entry() else { + self.status = Some(StatusMessage::error( + "This Apple TV is not advertising its reconnect service.", + )); + return Task::none(); + }; + let Some(ip_str) = dev.ip_address.clone() else { + self.status = Some(StatusMessage::error( + "Selected device has no IP address.", + )); + return Task::none(); + }; + let Some(reconnect_port) = dev.port else { + self.status = Some(StatusMessage::error("Selected device has no port.")); + return Task::none(); + }; + let name = dev.name.clone(); + let identity = Self::pairing_identity(dev); + let cache_dir = get_data_path(); + self.reconnecting = true; + self.status = Some(StatusMessage::info("Reconnecting to Apple TV...")); + + let (tx, rx) = std::sync::mpsc::sync_channel(1); + std::thread::spawn(move || { + let result = tokio::runtime::Runtime::new() + .unwrap() + .block_on(async move { + let ip = ip_str + .parse() + .map_err(|error| format!("Invalid IP address: {error}"))?; + let mut device = Device::new_tvos( + name, + identity, + ip, + None, + Some(reconnect_port), + cache_dir.clone(), + ); + if !device.has_pairing_source(&cache_dir) { + return Err( + "No saved pairing record exists for this Apple TV. Pair it first." + .to_string(), + ); + } + let info = device + .fetch_tvos_info(cache_dir) + .await + .map_err(|error| format!("{error}"))?; + device.apply_tvos_info(&info); + if !plume_utils::is_valid_device_udid(&device.udid) { + return Err( + "Apple TV reconnected but its authenticated UDID was not returned" + .to_string(), + ); + } + Ok(device) + }); + let _ = tx.send(result); + }); + + Task::perform( + async move { + std::thread::spawn(move || { + rx.recv() + .unwrap_or_else(|_| Err("Reconnect thread error".to_string())) + }) + .join() + .unwrap() + }, + Message::ReconnectComplete, + ) + } + + Message::PinRequested(requested) => { + if requested { + self.awaiting_pin = true; + self.status = + Some(StatusMessage::info("Enter the code shown on your Apple TV")); + } + Task::none() + } + + Message::SubmitPin => { + if self.pin.len() != 6 { + return Task::none(); + } + if let Some(tx) = self.pin_sender.as_ref() { + let _ = tx.try_send(self.pin.clone()); + } + self.awaiting_pin = false; + self.status = Some(StatusMessage::info("Verifying...")); + Task::none() + } + + Message::CancelPin => { + if let Some(tx) = self.pin_sender.as_ref() { + let _ = tx.try_send(String::new()); + } + self.awaiting_pin = false; + self.status = Some(StatusMessage::info("Cancelling pairing...")); + Task::none() + } + + Message::PairComplete(result) => { + self.pairing = false; + self.awaiting_pin = false; + self.pin_sender = None; + match result { + Ok(device) => { + self.paired_device = Some(device); + self.status = Some(StatusMessage::success("Paired successfully.")); + self.pin.clear(); + } + Err(e) => { + self.status = Some(StatusMessage::error(e)); + } + } + Task::none() + } + + Message::ReconnectComplete(result) => { + self.reconnecting = false; + match result { + Ok(device) => { + self.paired_device = Some(device); + self.status = Some(StatusMessage::success("Reconnected successfully.")); + } + Err(error) => self.status = Some(StatusMessage::error(error)), + } + Task::none() + } + + Message::Forget => { + let Some(device) = self.selected_device().cloned() else { + self.status = Some(StatusMessage::error("Select an Apple TV first.")); + return Task::none(); + }; + let identity = Self::pairing_identity(&device); + let name = device.name; + let cache_dir = get_data_path(); + let device = Device::new_tvos( + name, + identity, + "0.0.0.0".parse().unwrap(), + None, + None, + cache_dir.clone(), + ); + let (tx, rx) = std::sync::mpsc::sync_channel(1); + std::thread::spawn(move || { + let result = tokio::runtime::Runtime::new() + .unwrap() + .block_on(device.forget_tvos_pairing(cache_dir)) + .map_err(|e| format!("{e}")); + let _ = tx.send(result); + }); + self.status = Some(StatusMessage::info("Forgetting saved pairing...")); + Task::perform( + async move { + std::thread::spawn(move || { + rx.recv() + .unwrap_or_else(|_| Err("Forget thread error".to_string())) + }) + .join() + .unwrap() + }, + Message::ForgetComplete, + ) + } + + Message::ForgetComplete(result) => { + match result { + Ok(()) => { + self.paired_device = None; + self.status = + Some(StatusMessage::success("Saved pairing record forgotten.")); + } + Err(error) => self.status = Some(StatusMessage::error(error)), + } + Task::none() + } + + Message::StartOver => { + self.paired_device = None; + self.discovered.clear(); + self.selected_label = None; + self.pin.clear(); + self.awaiting_pin = false; + self.pin_sender = None; + self.reconnecting = false; + self.status = None; + Task::none() + } + } + } + + pub fn view(&self) -> Element<'_, Message> { + let content = match &self.paired_device { + Some(device) => self.view_paired(device), + None => self.view_pairing(), + }; + + container(scrollable(content.spacing(appearance::THEME_PADDING))).into() + } + + fn view_pairing(&self) -> iced::widget::Column<'_, Message> { + let mut content = column![]; + + let scan_label = if self.scanning { + "Scanning..." + } else { + "Scan for Apple TVs" + }; + content = content.push( + button(text(scan_label).align_x(Center)) + .on_press_maybe(if self.scanning { + None + } else { + Some(Message::Scan) + }) + .style(appearance::s_button) + .width(Fill), + ); + + if let Some(ref s) = self.status { + content = content.push(text(&s.content).size(13).color(s.color())); + } + + if !self.discovered.is_empty() { + content = content + .push(container(rule::horizontal(1)).padding([appearance::THEME_PADDING, 0.0])); + + let mut device_labels: Vec = self + .discovered + .iter() + .filter(|d| d.device_type == DeviceType::AppleTV) + .map(Self::device_label) + .collect(); + device_labels.sort(); + device_labels.dedup(); + + content = content.push( + pick_list( + device_labels, + self.selected_label.clone(), + Message::SelectDevice, + ) + .placeholder("Select an Apple TV") + .width(Fill), + ); + } + + if self.selected_label.is_some() && !self.awaiting_pin { + let pair_label = if self.pairing { "Pairing..." } else { "Pair" }; + if self.manual_pairing_entry().is_some() { + content = content.push( + button(text(pair_label).align_x(Center)) + .on_press_maybe(if self.pairing || self.reconnecting { + None + } else { + Some(Message::Pair) + }) + .style(appearance::p_button) + .width(Fill), + ); + } + if self.reconnect_entry().is_some() { + let reconnect_label = if self.reconnecting { + "Reconnecting..." + } else { + "Reconnect" + }; + content = content.push( + button(text(reconnect_label).align_x(Center)) + .on_press_maybe(if self.pairing || self.reconnecting { + None + } else { + Some(Message::Reconnect) + }) + .style(appearance::s_button) + .width(Fill), + ); + } + } + + if self.awaiting_pin { + content = content + .push(container(rule::horizontal(1)).padding([appearance::THEME_PADDING, 0.0])); + content = content.push(text("Enter the 6-digit code shown on your Apple TV:").size(13)); + content = content.push( + row![ + text_input("123456", &self.pin) + .on_input(Message::PinChanged) + .on_submit_maybe(if self.pin.len() == 6 { + Some(Message::SubmitPin) + } else { + None + }) + .width(iced::Length::Fixed(120.0)), + button(text("Submit").align_x(Center)) + .on_press_maybe(if self.pin.len() == 6 { + Some(Message::SubmitPin) + } else { + None + }) + .style(appearance::p_button) + ] + .spacing(appearance::THEME_PADDING) + .align_y(Center), + ); + content = content.push( + button(text("Cancel").align_x(Center)) + .on_press(Message::CancelPin) + .style(appearance::s_button) + .width(Fill), + ); + } + + content + } + + fn view_paired(&self, device: &Device) -> iced::widget::Column<'_, Message> { + let mut content = column![]; + + content = content + .push(text(format!("Paired with {}", device.name)).size(appearance::THEME_FONT_SIZE + 2.0)); + + content = content.push( + text(format!( + "{} · {} · UDID {}", + device.product_type.as_deref().unwrap_or("Apple TV"), + device.os_version.as_deref().unwrap_or("tvOS"), + device.udid + )) + .size(13), + ); + + content = content.push( + text( + "This Apple TV is now selectable in the device list at the top of the window. \ + To install to it, import an IPA from the main screen the same way you would \ + for any other device.", + ) + .size(13), + ); + + if let Some(ref s) = self.status { + content = content.push(text(&s.content).size(13).color(s.color())); + } + + content = + content.push(container(rule::horizontal(1)).padding([appearance::THEME_PADDING, 0.0])); + content = content.push( + button(text("Pair a Different Apple TV").align_x(Center)) + .on_press(Message::StartOver) + .style(appearance::s_button) + .width(Fill), + ); + content = content.push( + button(text("Forget Saved Pairing").align_x(Center)) + .on_press(Message::Forget) + .style(appearance::s_button) + .width(Fill), + ); + + content + } +} diff --git a/locales/en.toml b/locales/en.toml index 662da138..d6661de2 100644 --- a/locales/en.toml +++ b/locales/en.toml @@ -7,6 +7,7 @@ select_ipa = "Select IPA/TIPA file" ipa = "iOS App Package" utilities = "Utilities" import_ipa = "Import .ipa / .tipa" +pair_apple_tv = "Pair Apple TV" donate = "Donate!" star_us = "Star us on GitHub!" back = "Back" From 5e411719f1e7d93958d4231e1f8653a365c8f0df Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Sun, 13 Sep 2026 00:35:53 +0200 Subject: [PATCH 4/8] fix(package): archive the resigned IPA payload --- crates/plume_utils/src/package.rs | 215 ++++++++++++++++++++++++++++-- 1 file changed, 207 insertions(+), 8 deletions(-) diff --git a/crates/plume_utils/src/package.rs b/crates/plume_utils/src/package.rs index 37f61459..245d11e8 100644 --- a/crates/plume_utils/src/package.rs +++ b/crates/plume_utils/src/package.rs @@ -1,7 +1,8 @@ use super::{Bundle, PlistInfoTrait}; use crate::{Error, SignerApp, SignerOptions, cgbi}; +use plume_core::MobileProvision; use plist::Dictionary; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::{env, fs, io::Read}; use uuid::Uuid; use zip::ZipArchive; @@ -176,12 +177,67 @@ impl Package { Ok(Bundle::new(app_dir)?) } - pub fn get_archive_based_on_path(&self, path: &PathBuf) -> Result { - if path.is_dir() { - self.clone().archive_package_bundle() - } else { - Ok(self.package_file.clone()) + pub fn get_archive_based_on_path(&self, _path: &PathBuf) -> Result { + self.clone().archive_package_bundle() + } + + pub fn validate_archive(path: &Path, require_profile: bool) -> Result<(), Error> { + let mut archive = ZipArchive::new(fs::File::open(path)?)?; + let app_prefix = (0..archive.len()) + .filter_map(|index| archive.by_index(index).ok().map(|entry| entry.name().to_string())) + .find(|entry| { + entry.starts_with("Payload/") + && entry.ends_with("/Info.plist") + && entry.matches('/').count() == 2 + }) + .map(|entry| entry.trim_end_matches("/Info.plist").to_string()) + .ok_or_else(|| Error::Other("Produced IPA has no application bundle".to_string()))?; + + let signature = format!("{app_prefix}/_CodeSignature/CodeResources"); + let mut signature_data = Vec::new(); + let signature_valid = archive + .by_name(&signature) + .map_err(|error| Error::Other(format!("Unable to read produced IPA signature: {error}"))) + .and_then(|mut entry| { + entry + .read_to_end(&mut signature_data) + .map_err(|error| Error::Other(format!("Unable to read produced IPA signature: {error}"))) + }) + .is_ok() + && !signature_data.is_empty(); + if !signature_valid { + return Err(Error::Other( + "Produced IPA has no application code signature".to_string(), + )); } + + if require_profile { + let profile = format!("{app_prefix}/embedded.mobileprovision"); + let mut profile_data = Vec::new(); + let profile_valid = archive + .by_name(&profile) + .map_err(|error| Error::Other(format!("Unable to read produced IPA profile: {error}"))) + .and_then(|mut entry| { + entry + .read_to_end(&mut profile_data) + .map_err(|error| Error::Other(format!("Unable to read produced IPA profile: {error}"))) + }) + .is_ok() + && !profile_data.is_empty(); + if !profile_valid { + return Err(Error::Other( + "Produced IPA has no embedded provisioning profile".to_string(), + )); + } + MobileProvision::load_with_bytes(profile_data) + .map_err(|error| { + Error::Other(format!( + "Produced IPA has an invalid provisioning profile: {error}" + )) + })?; + } + + Ok(()) } fn archive_package_bundle(self) -> Result { @@ -204,8 +260,10 @@ impl Package { let name = entry_path .strip_prefix(prefix) .map_err(|_| Error::PackageInfoPlistMissing)? - .to_string_lossy() - .to_string(); + .components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>() + .join("/"); if entry_path.is_file() { zip.start_file(&name, options.clone())?; @@ -288,3 +346,144 @@ impl Package { *settings = new_settings; } } + +#[cfg(test)] +mod tests { + use super::*; + use std::time::{Duration, SystemTime}; + + fn profile_bytes(marker: &str) -> Vec { + let mut entitlements = Dictionary::new(); + entitlements.insert( + "application-identifier".to_string(), + plist::Value::String("L988J7YMK5.com.example.test".to_string()), + ); + + let mut profile = Dictionary::new(); + profile.insert( + "Entitlements".to_string(), + plist::Value::Dictionary(entitlements), + ); + profile.insert( + "ExpirationDate".to_string(), + plist::Value::Date(plist::Date::from( + SystemTime::now() + Duration::from_secs(3600), + )), + ); + profile.insert( + "Platform".to_string(), + plist::Value::Array(vec![plist::Value::String("iOS".to_string())]), + ); + profile.insert( + "ProvisionedDevices".to_string(), + plist::Value::Array(vec![plist::Value::String( + "00008110-000C25540CD1801E".to_string(), + )]), + ); + profile.insert( + "DeveloperCertificates".to_string(), + plist::Value::Array(vec![plist::Value::Data(vec![0; 4])]), + ); + profile.insert( + "TestMarker".to_string(), + plist::Value::String(marker.to_string()), + ); + + let mut plist_data = Vec::new(); + plist::to_writer_xml(&mut plist_data, &profile).unwrap(); + let mut data = b"CMS".to_vec(); + data.extend(plist_data); + data + } + + fn staged_package(tag: &str) -> Package { + let stage_dir = env::temp_dir().join(format!("plume_pkg_test_{tag}_{}", Uuid::new_v4())); + let app_dir = stage_dir.join("Payload").join("Test.app"); + fs::create_dir_all(app_dir.join("Frameworks")).unwrap(); + fs::write(app_dir.join("Info.plist"), b"plist").unwrap(); + fs::create_dir_all(app_dir.join("_CodeSignature")).unwrap(); + fs::write( + app_dir.join("_CodeSignature").join("CodeResources"), + b"signature", + ) + .unwrap(); + fs::write( + app_dir.join("embedded.mobileprovision"), + profile_bytes("old"), + ) + .unwrap(); + fs::write(app_dir.join("Frameworks").join("lib.dylib"), b"macho").unwrap(); + + Package { + package_file: stage_dir.join("stage.ipa"), + stage_payload_dir: stage_dir.join("Payload"), + stage_dir, + info_plist_dictionary: Dictionary::new(), + archive_entries: Vec::new(), + app_icon_data: None, + } + } + + fn entry_names(archive: &PathBuf) -> Vec { + let mut zip = ZipArchive::new(fs::File::open(archive).unwrap()).unwrap(); + (0..zip.len()) + .map(|i| zip.by_index(i).unwrap().name().to_string()) + .collect() + } + + #[test] + fn archive_entries_are_separated_by_forward_slashes() { + let package = staged_package("separators"); + let stage_dir = package.stage_dir.clone(); + + let archive = package.archive_package_bundle().unwrap(); + let names = entry_names(&archive); + + for name in &names { + assert!( + !name.contains('\\'), + "entry {name:?} uses a backslash separator" + ); + } + assert!( + names.iter().any(|n| n == "Payload/Test.app/Info.plist"), + "no Info.plist at the depth a bundle id is read from, got {names:?}" + ); + + let second = names.get(1).expect("archive has more than one entry"); + assert_eq!( + second.split('/').nth(1), + Some("Test.app"), + "second entry {second:?} does not name the app bundle" + ); + + fs::remove_dir_all(&stage_dir).ok(); + } + + #[test] + fn archiving_a_file_path_uses_the_modified_staged_payload() { + let package = staged_package("modified"); + let stage_dir = package.stage_dir.clone(); + let expected_profile = profile_bytes("new"); + fs::write( + stage_dir.join("Payload/Test.app/embedded.mobileprovision"), + &expected_profile, + ) + .unwrap(); + + let archive = package + .get_archive_based_on_path(&PathBuf::from("input.ipa")) + .unwrap(); + let mut zip = ZipArchive::new(fs::File::open(&archive).unwrap()).unwrap(); + let mut profile = Vec::new(); + zip.by_name("Payload/Test.app/embedded.mobileprovision") + .unwrap() + .read_to_end(&mut profile) + .unwrap(); + + assert_eq!(profile, expected_profile); + assert_ne!(profile, profile_bytes("old")); + Package::validate_archive(&archive, true).unwrap(); + fs::remove_dir_all(&stage_dir).ok(); + } +} From 7bf09e3b6ab95cfb1658e0f4c99b25d3aa674839 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:27:46 +0200 Subject: [PATCH 5/8] fix(tvos): tighten native pairing and validation --- apps/plumeimpactor/src/screen/mod.rs | 60 +- crates/plume_core/src/developer/platform.rs | 13 + .../src/developer/v1/capabilities.rs | 33 +- crates/plume_core/src/utils/provision.rs | 213 ++++++- crates/plume_utils/src/device.rs | 581 +++++++++++------- crates/plume_utils/src/discovery/mdns.rs | 73 ++- crates/plume_utils/src/discovery/mod.rs | 23 +- crates/plume_utils/src/lib.rs | 131 +++- crates/plume_utils/src/signer.rs | 50 +- 9 files changed, 861 insertions(+), 316 deletions(-) diff --git a/apps/plumeimpactor/src/screen/mod.rs b/apps/plumeimpactor/src/screen/mod.rs index de06191f..f576a8aa 100644 --- a/apps/plumeimpactor/src/screen/mod.rs +++ b/apps/plumeimpactor/src/screen/mod.rs @@ -98,6 +98,25 @@ pub struct Impactor { selected_locale: Option, } +fn same_device_identity(first: &Device, second: &Device) -> bool { + if first.device_id != 0 && second.device_id != 0 && first.device_id == second.device_id { + return true; + } + + if plume_utils::is_valid_device_udid(&first.udid) + && plume_utils::is_valid_device_udid(&second.udid) + && first.udid.eq_ignore_ascii_case(&second.udid) + { + return true; + } + + first + .pairing_identity + .as_ref() + .zip(second.pairing_identity.as_ref()) + .is_some_and(|(first, second)| first.eq_ignore_ascii_case(second)) +} + #[derive(Debug, Clone, PartialEq)] #[allow(dead_code)] pub enum ImpactorScreenType { @@ -197,30 +216,23 @@ impl Impactor { Task::none() } Message::DeviceConnected(device) => { - let existing_index = self.devices.iter().position(|existing| { - existing.device_id == device.device_id - || (plume_utils::is_valid_device_udid(&existing.udid) - && plume_utils::is_valid_device_udid(&device.udid) - && existing.udid.eq_ignore_ascii_case(&device.udid)) - }); - if let Some(existing_index) = existing_index { - let existing_id = self.devices[existing_index].device_id; - let selected_existing = self.selected_device.as_ref().map(|d| d.device_id) - == Some(existing_id); - self.devices[existing_index] = device.clone(); - - if selected_existing - || self.selected_device.as_ref().map(|d| d.device_id) - == Some(device.device_id) - { - self.selected_device = Some(device.clone()); - } - } else { - self.devices.push(device.clone()); - - if self.selected_device.is_none() && device.device_id != u32::MAX { - self.selected_device = Some(device.clone()); - } + let selected_before = self.selected_device.clone(); + let mut devices = std::mem::take(&mut self.devices); + devices.push(device.clone()); + self.devices = plume_utils::deduplicate_devices(devices); + + if let Some(selected_before) = selected_before { + self.selected_device = self + .devices + .iter() + .find(|candidate| same_device_identity(candidate, &selected_before)) + .cloned(); + } else if device.device_id != u32::MAX { + self.selected_device = self + .devices + .iter() + .find(|candidate| same_device_identity(candidate, &device)) + .cloned(); } if !device.udid.is_empty() { diff --git a/crates/plume_core/src/developer/platform.rs b/crates/plume_core/src/developer/platform.rs index ef0a604d..45abf649 100644 --- a/crates/plume_core/src/developer/platform.rs +++ b/crates/plume_core/src/developer/platform.rs @@ -30,6 +30,13 @@ impl DeveloperPlatform { } } + pub fn capabilities_filter(self) -> &'static str { + match self { + DeveloperPlatform::Ios => "IOS", + DeveloperPlatform::Tvos => "TVOS", + } + } + pub fn apply_to(self, body: &mut Dictionary) { let fields = self.request_fields(); if fields.is_empty() { @@ -85,6 +92,12 @@ mod tests { ); } + #[test] + fn capabilities_filter_matches_platform() { + assert_eq!(DeveloperPlatform::Ios.capabilities_filter(), "IOS"); + assert_eq!(DeveloperPlatform::Tvos.capabilities_filter(), "TVOS"); + } + #[test] fn ios_apply_to_leaves_dictionary_unchanged() { let mut body = Dictionary::new(); diff --git a/crates/plume_core/src/developer/v1/capabilities.rs b/crates/plume_core/src/developer/v1/capabilities.rs index 0b2c4351..48c6e49e 100644 --- a/crates/plume_core/src/developer/v1/capabilities.rs +++ b/crates/plume_core/src/developer/v1/capabilities.rs @@ -4,6 +4,7 @@ use serde_json::json; use super::{DeveloperSession, RequestType}; use crate::developer_endpoint; +use crate::developer::DeveloperPlatform; use crate::Error; use std::collections::HashSet; @@ -22,11 +23,20 @@ const FREE_DEVELOPER_ACCOUNT_UNALLOWED_CAPABILITIES: &[&str] = &[ impl DeveloperSession { pub async fn v1_list_capabilities(&self, team: &String) -> Result { + self.v1_list_capabilities_for_platform(team, DeveloperPlatform::Ios) + .await + } + + pub async fn v1_list_capabilities_for_platform( + &self, + team: &String, + platform: DeveloperPlatform, + ) -> Result { let endpoint = developer_endpoint!("/v1/capabilities"); let body = json!({ "teamId": team, - "urlEncodedQueryParams": "filter[platform]=IOS" + "urlEncodedQueryParams": format!("filter[platform]={}", platform.capabilities_filter()) }); let response = self @@ -43,7 +53,26 @@ impl DeveloperSession { id: &String, entitlements: &Dictionary, ) -> Result<(), Error> { - let capabilities = self.v1_list_capabilities(team).await?.data; + self.v1_request_capabilities_for_entitlements_on_platform( + team, + id, + entitlements, + DeveloperPlatform::Ios, + ) + .await + } + + pub async fn v1_request_capabilities_for_entitlements_on_platform( + &self, + team: &String, + id: &String, + entitlements: &Dictionary, + platform: DeveloperPlatform, + ) -> Result<(), Error> { + let capabilities = self + .v1_list_capabilities_for_platform(team, platform) + .await? + .data; let entitlement_keys: HashSet<&str> = entitlements.keys().map(|k| k.as_str()).collect(); // Collect capability IDs that match entitlement keys and are allowed for free accounts diff --git a/crates/plume_core/src/utils/provision.rs b/crates/plume_core/src/utils/provision.rs index 8e124bf0..ec1bda01 100644 --- a/crates/plume_core/src/utils/provision.rs +++ b/crates/plume_core/src/utils/provision.rs @@ -193,6 +193,104 @@ impl MobileProvision { Ok(()) } + pub fn validate_final_entitlements( + &self, + platform: DeveloperPlatform, + bundle_id: &str, + device_udid: Option<&str>, + certificate_der: Option<&[u8]>, + entitlements: &Dictionary, + ) -> Result<(), Error> { + self.validate_for( + platform, + bundle_id, + device_udid, + certificate_der, + None, + )?; + + let profile_application_identifier = self + .entitlements + .get("application-identifier") + .and_then(Value::as_string) + .ok_or_else(|| { + Error::ProvisioningProfileInvalid( + "profile has no application identifier".to_string(), + ) + })?; + let application_identifier = entitlements + .get("application-identifier") + .and_then(Value::as_string) + .ok_or_else(|| { + Error::ProvisioningProfileInvalid( + "signed executable has no application identifier".to_string(), + ) + })?; + let final_bundle_id = application_identifier_bundle_id(application_identifier) + .ok_or_else(|| { + Error::ProvisioningProfileInvalid( + "signed executable has an invalid application identifier".to_string(), + ) + })?; + if final_bundle_id != bundle_id + || !application_identifier_grants(profile_application_identifier, final_bundle_id) + { + return Err(Error::ProvisioningProfileInvalid(format!( + "signed executable application identifier {application_identifier:?} does not match {bundle_id:?}" + ))); + } + + let profile_team_identifier = self + .entitlements + .get("com.apple.developer.team-identifier") + .and_then(Value::as_string) + .or_else(|| application_identifier_team(profile_application_identifier)) + .ok_or_else(|| { + Error::ProvisioningProfileInvalid( + "profile has no team identifier".to_string(), + ) + })?; + let final_team_identifier = entitlements + .get("com.apple.developer.team-identifier") + .and_then(Value::as_string) + .or_else(|| application_identifier_team(application_identifier)) + .ok_or_else(|| { + Error::ProvisioningProfileInvalid( + "signed executable has no team identifier".to_string(), + ) + })?; + if final_team_identifier != profile_team_identifier + || application_identifier_team(profile_application_identifier) + .is_some_and(|team| team != profile_team_identifier) + || application_identifier_team(application_identifier) + .is_some_and(|team| team != profile_team_identifier) + { + return Err(Error::ProvisioningProfileInvalid( + "signed executable team identifier does not match the provisioning profile" + .to_string(), + )); + } + + for (key, requested) in entitlements { + if key == "application-identifier" || key == "com.apple.developer.team-identifier" { + continue; + } + + let Some(granted) = self.entitlements.get(key) else { + return Err(Error::ProvisioningProfileInvalid(format!( + "profile does not grant entitlement {key:?}" + ))); + }; + if !value_grants(granted, requested) { + return Err(Error::ProvisioningProfileInvalid(format!( + "profile does not grant entitlement {key:?}" + ))); + } + } + + Ok(()) + } + fn extract_profile_data( data: &[u8], ) -> Result<(Dictionary, Date, Vec, Vec, Vec>), Error> { @@ -256,16 +354,7 @@ fn string_values(value: Option<&Value>) -> Vec { } fn application_identifier_grants(granted: &str, requested: &str) -> bool { - let granted_bundle_id = match (granted.get(..10), granted.as_bytes().get(10), granted.get(11..)) { - (Some(team), Some(b'.'), Some(bundle_id)) - if team - .bytes() - .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit()) => - { - bundle_id - } - _ => granted, - }; + let granted_bundle_id = application_identifier_bundle_id(granted).unwrap_or(granted); if granted_bundle_id == requested { return true; @@ -280,6 +369,33 @@ fn application_identifier_grants(granted: &str, requested: &str) -> bool { .is_some_and(|prefix| requested.starts_with(prefix) && requested.len() > prefix.len()) } +fn application_identifier_bundle_id(value: &str) -> Option<&str> { + let (team, bundle_id) = value.split_once('.')?; + if team.len() == 10 + && team + .bytes() + .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit()) + && !bundle_id.is_empty() + { + Some(bundle_id) + } else { + None + } +} + +fn application_identifier_team(value: &str) -> Option<&str> { + let (team, _) = value.split_once('.')?; + if team.len() == 10 + && team + .bytes() + .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit()) + { + Some(team) + } else { + None + } +} + fn value_grants(granted: &Value, requested: &Value) -> bool { match (granted, requested) { (Value::String(granted), Value::String(requested)) => wildcard_matches(granted, requested), @@ -577,6 +693,83 @@ mod tests { assert!(error.to_string().contains("entitlement")); } + #[test] + fn accepts_profile_entitlements_as_final_entitlements() { + let profile = valid_profile(); + profile + .validate_final_entitlements( + DeveloperPlatform::Tvos, + "com.example.tv", + Some("00008110-000C25540CD1801E"), + Some(certificate_der()), + profile.entitlements(), + ) + .unwrap(); + } + + #[test] + fn rejects_final_application_identifier_mismatch() { + let profile = valid_profile(); + let mut entitlements = profile.entitlements().clone(); + entitlements.insert( + "application-identifier".to_string(), + Value::String("L988J7YMK5.com.example.other".to_string()), + ); + + let error = profile + .validate_final_entitlements( + DeveloperPlatform::Tvos, + "com.example.tv", + Some("00008110-000C25540CD1801E"), + Some(certificate_der()), + &entitlements, + ) + .unwrap_err(); + assert!(error.to_string().contains("application identifier")); + } + + #[test] + fn rejects_final_team_identifier_mismatch() { + let profile = valid_profile(); + let mut entitlements = profile.entitlements().clone(); + entitlements.insert( + "com.apple.developer.team-identifier".to_string(), + Value::String("OTHERTEAM1".to_string()), + ); + + let error = profile + .validate_final_entitlements( + DeveloperPlatform::Tvos, + "com.example.tv", + Some("00008110-000C25540CD1801E"), + Some(certificate_der()), + &entitlements, + ) + .unwrap_err(); + assert!(error.to_string().contains("team identifier")); + } + + #[test] + fn rejects_final_entitlement_not_granted_by_profile() { + let profile = valid_profile(); + let mut entitlements = profile.entitlements().clone(); + entitlements.insert( + "com.apple.developer.networking.wifi-info".to_string(), + Value::Boolean(true), + ); + + let error = profile + .validate_final_entitlements( + DeveloperPlatform::Tvos, + "com.example.tv", + Some("00008110-000C25540CD1801E"), + Some(certificate_der()), + &entitlements, + ) + .unwrap_err(); + assert!(error.to_string().contains("entitlement")); + } + #[test] fn wildcard_application_identifier_grants_final_bundle_id() { let profile = profile( diff --git a/crates/plume_utils/src/device.rs b/crates/plume_utils/src/device.rs index b1176b39..df12a2e8 100644 --- a/crates/plume_utils/src/device.rs +++ b/crates/plume_utils/src/device.rs @@ -135,6 +135,55 @@ impl TvosDeviceInfo { } } +#[derive(Debug, Clone)] +pub struct CoreDeviceTransport { + pairing_address: Option<(std::net::IpAddr, u16)>, + reconnect_address: Option<(std::net::IpAddr, u16)>, + pairing_identity: Option, + udid: String, + cache_dir: PathBuf, + authenticated: bool, +} + +impl CoreDeviceTransport { + fn new(device: &Device, cache_dir: PathBuf) -> Result { + if device.usbmuxd_device.is_some() { + return Err(Error::Other( + "CoreDevice transport cannot wrap a usbmuxd device".to_string(), + )); + } + Ok(Self { + pairing_address: device.pairing_address, + reconnect_address: device.reconnect_address, + pairing_identity: device.pairing_identity.clone(), + udid: device.udid.clone(), + cache_dir, + authenticated: device.core_device_authenticated, + }) + } + + pub fn kind(&self) -> DeviceTransport { + if self.authenticated { + DeviceTransport::CoreDevice + } else if self.pairing_address.is_some() || self.reconnect_address.is_some() { + DeviceTransport::RemotePairing + } else { + DeviceTransport::Unavailable + } + } + + pub async fn connect(&self) -> Result<(AdapterHandle, RsdHandshake), Error> { + establish_core_device_tunnel( + self.pairing_address, + self.reconnect_address, + self.pairing_identity.as_deref(), + &self.udid, + &self.cache_dir, + ) + .await + } +} + pub fn synthetic_device_id(pairing_identity: &str) -> u32 { const FNV_OFFSET_BASIS: u32 = 0x811c_9dc5; const FNV_PRIME: u32 = 0x0100_0193; @@ -234,26 +283,7 @@ impl Device { } pub(crate) fn pairing_cache_path(&self, cache_dir: &Path) -> Result { - let key = self.pairing_identity.as_deref().unwrap_or(&self.udid); - - if key.is_empty() { - return Err(Error::Other( - "Device has neither a pairing identity nor a UDID; cannot locate its pairing \ - file cache" - .to_string(), - )); - } - if key.contains('/') - || key.contains('\\') - || key.contains(':') - || key.chars().all(|c| c == '.') - { - return Err(Error::Other(format!( - "Pairing identity {key:?} is not a valid cache key" - ))); - } - - Ok(cache_dir.join(format!("plume_{key}.plist"))) + pairing_cache_path_for(self.pairing_identity.as_deref(), &self.udid, cache_dir) } pub fn is_tvos(&self) -> bool { @@ -289,6 +319,13 @@ impl Device { } } + pub fn core_device_transport( + &self, + cache_dir: PathBuf, + ) -> Result { + CoreDeviceTransport::new(self, cache_dir) + } + pub async fn installed_apps(&self) -> Result, Error> { let apps = if let Some(device) = &self.usbmuxd_device { let provider = device.to_provider( @@ -301,7 +338,8 @@ impl Device { let cache_dir = self.pairing_cache_dir.clone().ok_or_else(|| { Error::Other("Network Apple TV has no pairing cache directory".to_string()) })?; - let (mut adapter, mut handshake) = self.establish_tvos_tunnel(cache_dir).await?; + let transport = self.core_device_transport(cache_dir)?; + let (mut adapter, mut handshake) = transport.connect().await?; let mut ic = InstallationProxyClient::connect_rsd(&mut adapter, &mut handshake).await?; ic.get_apps(Some("User"), None).await? } else { @@ -341,7 +379,8 @@ impl Device { let cache_dir = self.pairing_cache_dir.clone().ok_or_else(|| { Error::Other("Network Apple TV has no pairing cache directory".to_string()) })?; - let (mut adapter, mut handshake) = self.establish_tvos_tunnel(cache_dir).await?; + let transport = self.core_device_transport(cache_dir)?; + let (mut adapter, mut handshake) = transport.connect().await?; let mut ic = InstallationProxyClient::connect_rsd(&mut adapter, &mut handshake).await?; ic.get_apps(Some("User"), None).await? } else { @@ -363,7 +402,8 @@ impl Device { let cache_dir = self.pairing_cache_dir.clone().ok_or_else(|| { Error::Other("Network Apple TV has no pairing cache directory".to_string()) })?; - let (mut adapter, mut handshake) = self.establish_tvos_tunnel(cache_dir).await?; + let transport = self.core_device_transport(cache_dir)?; + let (mut adapter, mut handshake) = transport.connect().await?; let mut mc = MisagentClient::connect_rsd(&mut adapter, &mut handshake).await?; mc.install(profile.data.clone()).await?; } else { @@ -563,43 +603,12 @@ impl Device { cache_dir: &Path, cache_path: &Path, ) -> Result, Error> { - let Some((ip, port)) = self.reconnect_address.or(self.pairing_address) else { - return Ok(None); - }; - let address = std::net::SocketAddr::new(ip, port); - - for (source, mut pairing_file) in external_pairing_candidates() { - let stream = match tokio::net::TcpStream::connect(address).await { - Ok(stream) => stream, - Err(error) => { - log::debug!( - "Could not connect to Apple TV while trying external pairing record {}: {error}", - source - ); - continue; - } - }; - let sending_host = pairing_file.identifier.clone(); - let mut client = RemotePairingClient::new( - RpPairingSocket::new(stream), - &sending_host, - &mut pairing_file, - ); - let valid = client.attempt_pair_verify().await.is_ok() - && client.validate_pairing().await.is_ok(); - drop(client); - - if valid { - write_pairing_file(&pairing_file, cache_dir, cache_path).await?; - log::info!( - "Imported an existing Apple TV pairing record from {}", - source - ); - return Ok(Some(pairing_file)); - } - } - - Ok(None) + try_import_external_pairing_at( + self.reconnect_address.or(self.pairing_address), + cache_dir, + cache_path, + ) + .await } pub async fn pair_tvos( @@ -681,6 +690,12 @@ impl Device { ); let _ = tokio::fs::remove_file(&cache_path).await; } + if let Some(pairing_file) = + self.try_import_external_pairing(&cache_dir, &cache_path).await? + { + log::info!("tvOS pairing: recovered an existing native pairing record"); + return Ok(pairing_file); + } } let (ip, port) = self.pairing_address.ok_or_else(|| { @@ -741,103 +756,7 @@ impl Device { &self, cache_dir: PathBuf, ) -> Result<(AdapterHandle, RsdHandshake), Error> { - let (ip, port) = self - .reconnect_address - .or(self.pairing_address) - .ok_or_else(|| Error::Other("Device has no network address".to_string()))?; - - let connect_addr = std::net::SocketAddr::new(ip, port); - - let cache_path = self.pairing_cache_path(&cache_dir)?; - let mut pairing_file = if cache_path.exists() { - RpPairingFile::read_from_file(&cache_path).await? - } else { - self.try_import_external_pairing(&cache_dir, &cache_path) - .await? - .ok_or_else(|| { - Error::Other( - "No pairing record is cached for this Apple TV; pair before reconnecting" - .to_string(), - ) - })? - }; - - let stream = tokio::net::TcpStream::connect(connect_addr) - .await - .map_err(|e| { - Error::Other(format!( - "Could not connect to Apple TV at {connect_addr}: {e}" - )) - })?; - let conn = RpPairingSocket::new(stream); - - let hostname = pairing_file.identifier.clone(); - let tunnel = { - let mut rpc = RemotePairingClient::new(conn, &hostname, &mut pairing_file); - - rpc.attempt_pair_verify() - .await - .map_err(|e| Error::Other(format!("Pair-verify failed: {e}")))?; - - if let Err(e) = rpc.validate_pairing().await { - if cache_path.exists() { - log::warn!( - "tvOS tunnel: cached pairing file at {} no longer verifies ({e}); \ - removing it", - cache_path.display() - ); - let _ = tokio::fs::remove_file(&cache_path).await; - } - return Err(Error::Other(format!( - "This Apple TV no longer recognizes this pairing (it may have been reset, \ - forgotten, or lost pairing after a system update); pair with it again: {e}" - ))); - } - - let tunnel_port = rpc - .create_tcp_listener() - .await - .map_err(|e| Error::Other(format!("Failed to create tunnel listener: {e}")))?; - - let tunnel_addr = std::net::SocketAddr::new(connect_addr.ip(), tunnel_port); - let tunnel_stream = tokio::net::TcpStream::connect(tunnel_addr) - .await - .map_err(|e| Error::Other(format!("TLS tunnel connect failed: {e}")))?; - - connect_tls_psk_tunnel_native(Box::new(tunnel_stream), rpc.encryption_key()) - .await - .map_err(|e| Error::Other(format!("TLS-PSK tunnel handshake failed: {e}")))? - }; - - let client_ip: std::net::IpAddr = tunnel - .info - .client_address - .parse() - .map_err(|e| Error::Other(format!("Invalid tunnel client address: {e}")))?; - let server_ip: std::net::IpAddr = tunnel - .info - .server_address - .parse() - .map_err(|e| Error::Other(format!("Invalid tunnel server address: {e}")))?; - let rsd_port = tunnel.info.server_rsd_port; - let mtu = tunnel.info.mtu as usize; - let mss = mtu.saturating_sub(60); - log::info!("tvOS tunnel: negotiated MTU {mtu}, using MSS {mss}"); - - let raw = tunnel.into_inner(); - let mut adapter = Adapter::new(Box::new(raw), client_ip, server_ip); - adapter.set_mss(mss); - let mut adapter_handle = adapter.to_async_handle(); - - let rsd_stream = adapter_handle - .connect(rsd_port) - .await - .map_err(|e| Error::Other(format!("RSD connection failed: {e}")))?; - let handshake = RsdHandshake::new(rsd_stream) - .await - .map_err(|e| Error::Other(format!("RSD handshake failed: {e}")))?; - - Ok((adapter_handle, handshake)) + self.core_device_transport(cache_dir)?.connect().await } pub fn has_cached_pairing_file(&self, cache_dir: &Path) -> bool { @@ -930,7 +849,8 @@ impl Device { ) })?; - let (mut adapter, mut handshake) = self.establish_tvos_tunnel(cache_dir).await?; + let transport = self.core_device_transport(cache_dir)?; + let (mut adapter, mut handshake) = transport.connect().await?; installation::install_package_with_callback_rsd( &mut adapter, @@ -951,6 +871,217 @@ impl Device { } } +fn pairing_cache_path_for( + pairing_identity: Option<&str>, + udid: &str, + cache_dir: &Path, +) -> Result { + let key = pairing_identity.unwrap_or(udid); + + if key.is_empty() { + return Err(Error::Other( + "Device has neither a pairing identity nor a UDID; cannot locate its pairing \ + file cache" + .to_string(), + )); + } + if key.contains('/') + || key.contains('\\') + || key.contains(':') + || key.chars().all(|c| c == '.') + { + return Err(Error::Other(format!( + "Pairing identity {key:?} is not a valid cache key" + ))); + } + + Ok(cache_dir.join(format!("plume_{key}.plist"))) +} + +async fn try_import_external_pairing_at( + address: Option<(std::net::IpAddr, u16)>, + cache_dir: &Path, + cache_path: &Path, +) -> Result, Error> { + let Some((ip, port)) = address else { + return Ok(None); + }; + let address = std::net::SocketAddr::new(ip, port); + + for (source, mut pairing_file) in external_pairing_candidates() { + let stream = match tokio::net::TcpStream::connect(address).await { + Ok(stream) => stream, + Err(error) => { + log::debug!( + "Could not connect to Apple TV while trying external pairing record {}: {error}", + source + ); + continue; + } + }; + let sending_host = pairing_file.identifier.clone(); + let mut client = RemotePairingClient::new( + RpPairingSocket::new(stream), + &sending_host, + &mut pairing_file, + ); + let valid = client.attempt_pair_verify().await.is_ok() + && client.validate_pairing().await.is_ok(); + drop(client); + + if valid { + write_pairing_file(&pairing_file, cache_dir, cache_path).await?; + log::info!( + "Imported an existing Apple TV pairing record from {}", + source + ); + return Ok(Some(pairing_file)); + } + } + + Ok(None) +} + +enum CoreDeviceTunnelAttemptError { + Pairing(Error), + Tunnel(Error), +} + +async fn establish_core_device_tunnel( + pairing_address: Option<(std::net::IpAddr, u16)>, + reconnect_address: Option<(std::net::IpAddr, u16)>, + pairing_identity: Option<&str>, + udid: &str, + cache_dir: &Path, +) -> Result<(AdapterHandle, RsdHandshake), Error> { + let (ip, port) = reconnect_address + .or(pairing_address) + .ok_or_else(|| Error::Other("Device has no network address".to_string()))?; + + let connect_addr = std::net::SocketAddr::new(ip, port); + let cache_path = pairing_cache_path_for(pairing_identity, udid, cache_dir)?; + let (mut pairing_file, mut can_try_external) = if cache_path.exists() { + (RpPairingFile::read_from_file(&cache_path).await?, true) + } else { + ( + try_import_external_pairing_at(Some((ip, port)), cache_dir, &cache_path) + .await? + .ok_or_else(|| { + Error::Other( + "No pairing record is cached for this Apple TV; pair before reconnecting" + .to_string(), + ) + })?, + false, + ) + }; + + let tunnel = loop { + let stream = tokio::net::TcpStream::connect(connect_addr).await.map_err(|e| { + Error::Other(format!("Could not connect to Apple TV at {connect_addr}: {e}")) + })?; + let conn = RpPairingSocket::new(stream); + let hostname = pairing_file.identifier.clone(); + + let attempt: Result<_, CoreDeviceTunnelAttemptError> = async { + let mut rpc = RemotePairingClient::new(conn, &hostname, &mut pairing_file); + + if let Err(e) = rpc.attempt_pair_verify().await { + return Err(CoreDeviceTunnelAttemptError::Pairing(Error::Other( + format!("Pair-verify failed: {e}"), + ))); + } + + if let Err(e) = rpc.validate_pairing().await { + return Err(CoreDeviceTunnelAttemptError::Pairing(Error::Other( + format!( + "This Apple TV no longer recognizes this pairing (it may have been reset, \ + forgotten, or lost pairing after a system update); pair with it again: {e}" + ), + ))); + } + + let tunnel_port = rpc.create_tcp_listener().await.map_err(|e| { + CoreDeviceTunnelAttemptError::Tunnel(Error::Other(format!( + "Failed to create tunnel listener: {e}" + ))) + })?; + + let tunnel_addr = std::net::SocketAddr::new(connect_addr.ip(), tunnel_port); + let tunnel_stream = tokio::net::TcpStream::connect(tunnel_addr) + .await + .map_err(|e| { + CoreDeviceTunnelAttemptError::Tunnel(Error::Other(format!( + "TLS tunnel connect failed: {e}" + ))) + })?; + + connect_tls_psk_tunnel_native(Box::new(tunnel_stream), rpc.encryption_key()) + .await + .map_err(|e| { + CoreDeviceTunnelAttemptError::Tunnel(Error::Other(format!( + "TLS-PSK tunnel handshake failed: {e}" + ))) + }) + } + .await; + + match attempt { + Ok(tunnel) => break tunnel, + Err(CoreDeviceTunnelAttemptError::Pairing(error)) if can_try_external => { + can_try_external = false; + log::warn!( + "tvOS tunnel: cached pairing file at {} is stale ({error}); removing it", + cache_path.display() + ); + let _ = tokio::fs::remove_file(&cache_path).await; + + if let Some(imported) = + try_import_external_pairing_at(Some((ip, port)), cache_dir, &cache_path) + .await? + { + pairing_file = imported; + continue; + } + + return Err(error); + } + Err(CoreDeviceTunnelAttemptError::Pairing(error)) + | Err(CoreDeviceTunnelAttemptError::Tunnel(error)) => return Err(error), + } + }; + + let client_ip: std::net::IpAddr = tunnel + .info + .client_address + .parse() + .map_err(|e| Error::Other(format!("Invalid tunnel client address: {e}")))?; + let server_ip: std::net::IpAddr = tunnel + .info + .server_address + .parse() + .map_err(|e| Error::Other(format!("Invalid tunnel server address: {e}")))?; + let rsd_port = tunnel.info.server_rsd_port; + let mtu = tunnel.info.mtu as usize; + let mss = mtu.saturating_sub(60); + log::info!("tvOS tunnel: negotiated MTU {mtu}, using MSS {mss}"); + + let raw = tunnel.into_inner(); + let mut adapter = Adapter::new(Box::new(raw), client_ip, server_ip); + adapter.set_mss(mss); + let mut adapter_handle = adapter.to_async_handle(); + + let rsd_stream = adapter_handle + .connect(rsd_port) + .await + .map_err(|e| Error::Other(format!("RSD connection failed: {e}")))?; + let handshake = RsdHandshake::new(rsd_stream) + .await + .map_err(|e| Error::Other(format!("RSD handshake failed: {e}")))?; + + Ok((adapter_handle, handshake)) +} + async fn write_pairing_file( pairing_file: &RpPairingFile, cache_dir: &Path, @@ -988,22 +1119,6 @@ fn external_pairing_paths() -> Vec { { let mut paths = Vec::new(); - if let Some(home) = std::env::var_os("HOME") { - let pymobiledevice_dir = PathBuf::from(home).join(".pymobiledevice3"); - if let Ok(entries) = std::fs::read_dir(pymobiledevice_dir) { - for entry in entries.flatten() { - let path = entry.path(); - if path - .file_name() - .and_then(|name| name.to_str()) - .is_some_and(|name| name.starts_with("remote_") && name.ends_with(".plist")) - { - paths.push(path); - } - } - } - } - let native_dir = Path::new("/var/db/lockdown/RemotePairing"); if let Ok(entries) = std::fs::read_dir(native_dir) { for entry in entries.flatten() { @@ -1033,39 +1148,34 @@ fn external_pairing_candidates() -> Vec<(String, RpPairingFile)> { continue; }; - if path.file_name().and_then(|name| name.to_str()) == Some("selfIdentity.plist") { - let peer_paths = path - .parent() - .map(|parent| parent.join("peers")) - .and_then(|directory| std::fs::read_dir(directory).ok()) - .into_iter() - .flatten() - .flatten() - .map(|entry| entry.path()) - .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("plist")) - .collect::>(); - let peer_bytes = peer_paths - .iter() - .filter_map(|peer_path| std::fs::read(peer_path).ok()) - .collect::>(); - let peer_refs = peer_bytes.iter().map(Vec::as_slice).collect::>(); - - if let Ok(native_candidates) = native_pairing_candidates_from_bytes(&bytes, &peer_refs) - { - for (index, candidate) in native_candidates.into_iter().enumerate() { - let source = if index == 0 { - path.display().to_string() - } else { - peer_paths - .get(index - 1) - .map(|peer_path| peer_path.display().to_string()) - .unwrap_or_else(|| path.display().to_string()) - }; - candidates.push((source, candidate)); - } + let peer_paths = path + .parent() + .map(|parent| parent.join("peers")) + .and_then(|directory| std::fs::read_dir(directory).ok()) + .into_iter() + .flatten() + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("plist")) + .collect::>(); + let peer_bytes = peer_paths + .iter() + .filter_map(|peer_path| std::fs::read(peer_path).ok()) + .collect::>(); + let peer_refs = peer_bytes.iter().map(Vec::as_slice).collect::>(); + + if let Ok(native_candidates) = native_pairing_candidates_from_bytes(&bytes, &peer_refs) { + for (index, candidate) in native_candidates.into_iter().enumerate() { + let source = if index == 0 { + path.display().to_string() + } else { + peer_paths + .get(index - 1) + .map(|peer_path| peer_path.display().to_string()) + .unwrap_or_else(|| path.display().to_string()) + }; + candidates.push((source, candidate)); } - } else if let Ok(candidate) = external_pairing_file_from_bytes(&bytes) { - candidates.push((path.display().to_string(), candidate)); } } @@ -1170,10 +1280,12 @@ fn local_remote_pairing_identifier() -> Option { fn external_pairing_file_from_bytes(bytes: &[u8]) -> Result { let source: plist::Dictionary = plist::from_bytes(bytes)?; let data_field = |names: &[&str]| { - names - .iter() - .find_map(|name| source.get(*name).and_then(plist::Value::as_data)) - .map(|data| data.to_vec()) + names.iter().find_map(|name| { + source + .get(*name) + .and_then(plist::Value::as_data) + .map(|data| data.to_vec()) + }) }; let string_field = |names: &[&str]| { names @@ -1563,6 +1675,28 @@ mod tests { assert!(device.is_network()); } + #[test] + fn core_device_transport_exposes_authenticated_device_kind() { + let mut device = Device::new_tvos( + "Apple TV".to_string(), + "Apple-TV".to_string(), + std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), + Some(1234), + Some(1235), + std::env::temp_dir(), + ); + device.apply_tvos_info(&TvosDeviceInfo { + udid: Some("00008110-000C25540CD1801E".to_string()), + ..Default::default() + }); + + let transport = device + .core_device_transport(std::env::temp_dir()) + .unwrap(); + + assert_eq!(transport.kind(), DeviceTransport::CoreDevice); + } + #[test] fn transport_identifies_usb_and_unavailable_devices() { let mut usb = stub_device(); @@ -1648,7 +1782,7 @@ mod tests { } #[test] - fn external_pairing_record_import_supports_native_and_pymobiledevice_shapes() { + fn external_pairing_record_import_supports_native_shape() { let source = RpPairingFile::generate("external-record-test"); let mut native = plist::Dictionary::new(); native.insert( @@ -1672,31 +1806,6 @@ mod tests { assert_eq!(imported_native.public_key_bytes(), source.public_key_bytes()); assert_eq!(imported_native.alt_irk(), Some(&[7; 16][..])); - let mut pymobiledevice = plist::Dictionary::new(); - pymobiledevice.insert( - "public_key".to_string(), - plist::Value::Data(source.public_key_bytes()), - ); - pymobiledevice.insert( - "private_key".to_string(), - plist::Value::Data(source.private_key_bytes()), - ); - pymobiledevice.insert( - "remote_unlock_host_key".to_string(), - plist::Value::String("host-key".to_string()), - ); - - let mut pymobiledevice_bytes = Vec::new(); - plist::to_writer_xml(&mut pymobiledevice_bytes, &pymobiledevice).unwrap(); - let imported_pymobiledevice = external_pairing_file_from_bytes(&pymobiledevice_bytes).unwrap(); - assert_eq!( - imported_pymobiledevice.public_key_bytes(), - source.public_key_bytes() - ); - assert_eq!( - imported_pymobiledevice.private_key_bytes(), - source.private_key_bytes() - ); } #[test] diff --git a/crates/plume_utils/src/discovery/mdns.rs b/crates/plume_utils/src/discovery/mdns.rs index 02e19a48..3c2c533c 100644 --- a/crates/plume_utils/src/discovery/mdns.rs +++ b/crates/plume_utils/src/discovery/mdns.rs @@ -15,6 +15,30 @@ pub struct MdnsDiscovery { service_types: Vec, } +#[derive(Default)] +pub(crate) struct MdnsAccumulator { + devices: HashMap<(String, String), DiscoveredDevice>, +} + +impl MdnsAccumulator { + pub(crate) fn insert( + &mut self, + instance_name: &str, + hostname: &str, + service_type: &str, + device: DiscoveredDevice, + ) { + self.devices.insert( + dedup_key(hostname, instance_name, service_type), + device, + ); + } + + pub(crate) fn into_devices(self) -> Vec { + self.devices.into_values().collect() + } +} + impl MdnsDiscovery { pub fn new() -> Self { Self { @@ -49,8 +73,7 @@ impl DeviceDiscovery for MdnsDiscovery { let service_types = self.service_types.clone(); let discovered = tokio::task::spawn_blocking(move || { - let mut discovered_devices: HashMap<(String, String), DiscoveredDevice> = - HashMap::new(); + let mut discovered_devices = MdnsAccumulator::default(); let deadline = std::time::Instant::now() + timeout; while std::time::Instant::now() < deadline { @@ -92,9 +115,12 @@ impl DeviceDiscovery for MdnsDiscovery { port ); - let key = dedup_key(hostname, &instance_name, service_type); - - discovered_devices.insert(key, device); + discovered_devices.insert( + &instance_name, + hostname, + service_type, + device, + ); } Ok(_) => { got_event = true; @@ -113,19 +139,21 @@ impl DeviceDiscovery for MdnsDiscovery { } let _ = mdns.shutdown(); - discovered_devices + discovered_devices.into_devices() }) .await .map_err(|e| crate::Error::Other(format!("mDNS scan task failed: {e}")))?; - Ok(enrich_and_filter(discovered.into_values().collect())) + Ok(enrich_and_filter(discovered)) } } #[cfg(test)] mod tests { use super::*; - use crate::discovery::DeviceType; + use crate::discovery::{DeviceType, REMOTEPAIRING_SERVICE, build_device}; + use std::net::IpAddr; + use std::collections::HashMap; #[test] fn test_device_type_from_class() { @@ -148,6 +176,35 @@ mod tests { ); } + #[test] + fn accumulator_replaces_duplicate_resolved_advertisements() { + let mut accumulator = MdnsAccumulator::default(); + let first = build_device( + "Living Room", + "Living-Room.local.", + REMOTEPAIRING_SERVICE, + Some(49152), + &["192.0.2.10".parse::().unwrap()], + &HashMap::from([(String::from("model"), String::from("AppleTV14,1"))]), + ); + let second = build_device( + "Living Room", + "Living-Room.local.", + REMOTEPAIRING_SERVICE, + Some(49153), + &["192.0.2.10".parse::().unwrap()], + &HashMap::from([(String::from("model"), String::from("AppleTV14,1"))]), + ); + + accumulator.insert("Living Room", "Living-Room.local.", REMOTEPAIRING_SERVICE, first); + accumulator.insert("Living Room", "Living-Room.local.", REMOTEPAIRING_SERVICE, second); + + let devices = accumulator.into_devices(); + + assert_eq!(devices.len(), 1); + assert_eq!(devices[0].port, Some(49153)); + } + #[tokio::test] #[ignore] async fn test_mdns_discovery() { diff --git a/crates/plume_utils/src/discovery/mod.rs b/crates/plume_utils/src/discovery/mod.rs index 7009265b..7cac5725 100644 --- a/crates/plume_utils/src/discovery/mod.rs +++ b/crates/plume_utils/src/discovery/mod.rs @@ -318,10 +318,6 @@ pub fn group_network_devices(discovered: &[DiscoveredDevice], cache_dir: &Path) let Some(ip) = group.ip else { continue; }; - if group.pairing_port.is_none() && group.reconnect_port.is_none() { - continue; - } - let pairing_identity = if group.hostname.is_empty() { group.name.replace(' ', "-") } else { @@ -924,6 +920,23 @@ mod tests { assert_eq!(devices[0].pairing_identity.as_deref(), Some("living-room")); } + #[test] + fn group_network_devices_retains_legacy_core_device_until_authenticated_data_arrives() { + let discovered = [network_apple_tv( + "Living Room", + APPLE_MOBDEV2_SERVICE, + 62078, + "10.0.0.5", + )]; + + let devices = group_network_devices(&discovered, Path::new("/cache")); + + assert_eq!(devices.len(), 1); + assert!(devices[0].pairing_address.is_none()); + assert!(devices[0].reconnect_address.is_none()); + assert!(devices[0].udid.is_empty()); + } + #[test] fn group_network_devices_keeps_same_named_hosts_separate() { let mut first = network_apple_tv("Living Room", REMOTEPAIRING_SERVICE, 49152, "10.0.0.5"); @@ -973,7 +986,7 @@ mod tests { #[test] fn group_network_devices_excludes_unsupported_service() { - let d = network_apple_tv("Living Room", APPLE_MOBDEV2_SERVICE, 62078, "10.0.0.5"); + let d = network_apple_tv("Living Room", APPLE_PAIRABLE_SERVICE, 62078, "10.0.0.5"); let devices = group_network_devices(&[d], Path::new("/cache")); diff --git a/crates/plume_utils/src/lib.rs b/crates/plume_utils/src/lib.rs index dfda03a6..95209e67 100644 --- a/crates/plume_utils/src/lib.rs +++ b/crates/plume_utils/src/lib.rs @@ -8,11 +8,10 @@ pub mod pairing; mod signer; mod tweak; -use std::collections::HashMap; use std::path::Path; pub use bundle::{Bundle, BundleType}; pub use device::{ - Device, DeviceTransport, TvosDeviceInfo, get_device_for_id, install_app_mac, + CoreDeviceTransport, Device, DeviceTransport, TvosDeviceInfo, get_device_for_id, install_app_mac, synthetic_device_id, }; pub use options::{ @@ -106,20 +105,24 @@ pub async fn copy_dir_recursively(src: &Path, dst: &Path) -> Result<()> { pub use plume_core::is_valid_device_udid; -fn dedup_key_for_device(device: &Device) -> Option { +fn dedup_keys_for_device(device: &Device) -> Vec { + let mut keys = Vec::new(); if is_valid_device_udid(&device.udid) { - return Some(format!("udid:{}", device.udid.to_ascii_lowercase())); + keys.push(format!("udid:{}", device.udid.to_ascii_lowercase())); } - if device.is_network() { - return device - .pairing_identity - .as_deref() - .filter(|identity| !identity.is_empty()) - .map(|identity| format!("pairing:{}", identity.to_ascii_lowercase())); + if let Some(identity) = device + .pairing_identity + .as_deref() + .filter(|identity| !identity.is_empty()) + { + keys.push(format!("pairing:{}", identity.to_ascii_lowercase())); + } + if let Some(usbmuxd) = &device.usbmuxd_device { + keys.push(format!("mux:{}", usbmuxd.device_id)); } - None + keys } fn device_quality(device: &Device) -> usize { @@ -127,12 +130,15 @@ fn device_quality(device: &Device) -> usize { if is_valid_device_udid(&device.udid) { score += 100; } - if device.is_network() && device.pairing_identity.is_some() { + if device.pairing_identity.is_some() { score += 40; } if device.usbmuxd_device.is_some() { score += 20; } + if device.core_device_authenticated { + score += 25; + } score += device.product_type.is_some() as usize * 10; score += device.device_class.is_some() as usize * 8; score += device.os_version.is_some() as usize * 4; @@ -143,26 +149,78 @@ fn device_quality(device: &Device) -> usize { } pub fn deduplicate_devices(devices: impl IntoIterator) -> Vec { - let mut result = Vec::new(); - let mut indexes = HashMap::new(); + let mut groups: Vec<(Vec, Device)> = Vec::new(); for device in devices { - let Some(key) = dedup_key_for_device(&device) else { - result.push(device); + let keys = dedup_keys_for_device(&device); + let matching_indexes = groups + .iter() + .enumerate() + .filter(|(_, (group_keys, _))| keys.iter().any(|key| group_keys.contains(key))) + .map(|(index, _)| index) + .collect::>(); + + let Some(first_index) = matching_indexes.first().copied() else { + groups.push((keys, device)); continue; }; - if let Some(index) = indexes.get(&key).copied() { - if device_quality(&device) > device_quality(&result[index]) { - result[index] = device; - } - } else { - indexes.insert(key, result.len()); - result.push(device); + merge_devices(&mut groups[first_index].1, device); + groups[first_index].0 = dedup_keys_for_device(&groups[first_index].1); + + for index in matching_indexes.into_iter().skip(1).rev() { + let (_, other) = groups.remove(index); + merge_devices(&mut groups[first_index].1, other); + groups[first_index].0 = dedup_keys_for_device(&groups[first_index].1); } } - result + groups.into_iter().map(|(_, device)| device).collect() +} + +fn merge_devices(existing: &mut Device, mut incoming: Device) { + if device_quality(&incoming) > device_quality(existing) { + std::mem::swap(existing, &mut incoming); + } + + if existing.name.is_empty() { + existing.name = incoming.name; + } + if !is_valid_device_udid(&existing.udid) && is_valid_device_udid(&incoming.udid) { + existing.udid = incoming.udid; + } + if existing.product_type.is_none() { + existing.product_type = incoming.product_type; + } + if existing.device_class.is_none() { + existing.device_class = incoming.device_class; + } + if existing.os_version.is_none() { + existing.os_version = incoming.os_version; + } + if existing.serial_number.is_none() { + existing.serial_number = incoming.serial_number; + } + if existing.usbmuxd_device.is_none() { + existing.usbmuxd_device = incoming.usbmuxd_device; + } + if existing.pairing_address.is_none() { + existing.pairing_address = incoming.pairing_address; + } + if existing.reconnect_address.is_none() { + existing.reconnect_address = incoming.reconnect_address; + } + if existing.pairing_identity.is_none() { + existing.pairing_identity = incoming.pairing_identity; + } + if existing.pairing_cache_dir.is_none() { + existing.pairing_cache_dir = incoming.pairing_cache_dir; + } + if existing.device_id == 0 { + existing.device_id = incoming.device_id; + } + existing.is_mac |= incoming.is_mac; + existing.core_device_authenticated |= incoming.core_device_authenticated; } pub fn format_bytes(bytes: u64) -> String { @@ -235,6 +293,31 @@ mod tests { assert_eq!(devices[0].os_version, authenticated.os_version); } + #[test] + fn bridges_legacy_pairing_identity_to_authenticated_udid() { + let cache_dir = std::env::temp_dir(); + let legacy = Device::new_tvos( + "Living Room".to_string(), + "Living-Room".to_string(), + "192.0.2.10".parse().unwrap(), + None, + Some(49152), + cache_dir.clone(), + ); + let mut authenticated = legacy.clone(); + authenticated.udid = "00008110-000C25540CD1801E".to_string(); + authenticated.core_device_authenticated = true; + + let devices = deduplicate_devices([legacy, authenticated]); + + assert_eq!(devices.len(), 1); + assert_eq!( + devices[0].udid, + "00008110-000C25540CD1801E".to_string() + ); + assert!(devices[0].core_device_authenticated); + } + #[test] fn deduplicates_unenriched_network_advertisements_by_pairing_identity() { let cache_dir = std::env::temp_dir(); diff --git a/crates/plume_utils/src/signer.rs b/crates/plume_utils/src/signer.rs index e2c9d453..af0e43f5 100644 --- a/crates/plume_utils/src/signer.rs +++ b/crates/plume_utils/src/signer.rs @@ -310,7 +310,12 @@ impl Signer { if let Some(e) = macho.entitlements().as_ref() { session - .v1_request_capabilities_for_entitlements(&team_id, &id, e) + .v1_request_capabilities_for_entitlements_on_platform( + &team_id, + &id, + e, + platform, + ) .await?; } @@ -472,12 +477,17 @@ impl Signer { .ok_or_else(|| Error::Other("Signed bundle has no bundle identifier".to_string()))?; let profile_path = signed_bundle.bundle_dir().join("embedded.mobileprovision"); let profile = MobileProvision::load_with_path(profile_path)?; - profile.validate_for( + let final_entitlements = macho.entitlements().clone().ok_or_else(|| { + Error::Core(plume_core::Error::ProvisioningProfileInvalid( + "signed executable has no entitlements".to_string(), + )) + })?; + profile.validate_final_entitlements( platform, &bundle_id, device_udid, certificate_der, - macho.entitlements().as_ref(), + &final_entitlements, )?; } @@ -541,10 +551,11 @@ impl Signer { let executable_name = signed_bundle .get_executable() .ok_or_else(|| Error::Other("Signable bundle has no executable".into()))?; - let macho = plume_core::MachO::new(&signed_bundle.bundle_dir().join(executable_name))?; + let binary_path = signed_bundle.bundle_dir().join(executable_name); + let macho = plume_core::MachO::new(&binary_path)?; let mut last_error = None; - let valid = self.provisioning_files.iter().any(|profile| { + let matching_profile = self.provisioning_files.iter().find(|profile| { match profile.validate_for( platform, &bundle_id, @@ -560,14 +571,39 @@ impl Signer { } }); - if !valid { + let Some(matching_profile) = matching_profile else { let error = last_error.unwrap_or_else(|| { plume_core::Error::ProvisioningProfileInvalid(format!( "no profile grants {bundle_id}" )) }); return Err(Error::Core(error)); - } + }; + + let mut effective_profile = matching_profile.clone(); + effective_profile.merge_entitlements(binary_path.clone(), &bundle_id)?; + let final_entitlements = if self.options.embedding.single_profile { + self.options + .custom_entitlements + .as_ref() + .map(|path| { + let value = Value::from_file(path)?; + value.as_dictionary().cloned().ok_or_else(|| { + Error::Other("Custom entitlements file is not a dictionary".to_string()) + }) + }) + .transpose()? + .unwrap_or_else(|| effective_profile.entitlements().clone()) + } else { + effective_profile.entitlements().clone() + }; + effective_profile.validate_final_entitlements( + platform, + &bundle_id, + device_udid, + certificate_der, + &final_entitlements, + )?; log::info!("ProfileValidated: true for {bundle_id} on {platform}"); } From 5ac9a1b13b277e11d62db93578d0287cc08aed17 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Sun, 13 Sep 2026 01:46:04 +0200 Subject: [PATCH 6/8] fix(discovery): prefer reachable Apple TV addresses --- crates/plume_utils/src/discovery/mod.rs | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/crates/plume_utils/src/discovery/mod.rs b/crates/plume_utils/src/discovery/mod.rs index 7cac5725..6cef45e5 100644 --- a/crates/plume_utils/src/discovery/mod.rs +++ b/crates/plume_utils/src/discovery/mod.rs @@ -196,7 +196,11 @@ pub(crate) fn build_device( name, hostname: short_hostname(hostname).to_string(), udid, - ip_address: addresses.first().map(|a| a.to_string()), + ip_address: addresses + .iter() + .find(|address| address.is_ipv4()) + .or_else(|| addresses.first()) + .map(|address| address.to_string()), port, device_type, connection_type: ConnectionType::WiFi, @@ -548,6 +552,23 @@ mod tests { assert_eq!(d.service_type, REMOTEPAIRING_SERVICE); } + #[test] + fn mapping_prefers_ipv4_over_unscoped_link_local_ipv6() { + let d = build_device( + "TV", + "TV.local.", + REMOTEPAIRING_MANUAL_PAIRING_SERVICE, + Some(63295), + &[ + "fe80::1020:429f:1e8:d85d".parse::().unwrap(), + "192.168.2.150".parse::().unwrap(), + ], + &props(&[("model", "AppleTV14,1")]), + ); + + assert_eq!(d.ip_address.as_deref(), Some("192.168.2.150")); + } + #[test] fn mapping_name_prefers_hostname_over_txt_and_instance() { let d = build_device( From 6aceb36245ba0496247ad94be692fbbf99cc5641 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Sun, 13 Sep 2026 12:58:14 +0200 Subject: [PATCH 7/8] fix(apple-tv): finalize pairing and device handling --- Cargo.lock | 2 + apps/plumeimpactor/src/screen/mod.rs | 29 + apps/plumeimpactor/src/screen/tvos_pairing.rs | 62 +- apps/plumesign/src/commands/account.rs | 31 +- apps/plumesign/src/commands/device.rs | 6 + crates/plume_core/src/utils/provision.rs | 18 +- crates/plume_utils/Cargo.toml | 2 + crates/plume_utils/src/device.rs | 698 +++++++++++++++++- crates/plume_utils/src/discovery/mod.rs | 67 +- crates/plume_utils/src/lib.rs | 70 +- 10 files changed, 892 insertions(+), 93 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8b5379e7..c0191750 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5670,6 +5670,7 @@ dependencies = [ name = "plume_utils" version = "2.6.3" dependencies = [ + "base64 0.22.1", "decompress", "env_logger", "flate2", @@ -5682,6 +5683,7 @@ dependencies = [ "plist", "plume_core", "plume_store", + "serde", "thiserror 2.0.18", "tokio", "uuid", diff --git a/apps/plumeimpactor/src/screen/mod.rs b/apps/plumeimpactor/src/screen/mod.rs index f576a8aa..95ae5ac6 100644 --- a/apps/plumeimpactor/src/screen/mod.rs +++ b/apps/plumeimpactor/src/screen/mod.rs @@ -40,6 +40,7 @@ pub enum Message { ComboBoxSelected(String), DeviceConnected(Device), DeviceDisconnected(u32), + DeviceForgotten(Device), // Tray TrayMenuClicked(tray_icon::menu::MenuId), @@ -292,6 +293,28 @@ impl Impactor { Task::none() } + Message::DeviceForgotten(device) => { + self.devices + .retain(|candidate| !same_device_identity(candidate, &device)); + + if self + .selected_device + .as_ref() + .is_some_and(|selected| same_device_identity(selected, &device)) + { + self.selected_device = self.devices.first().cloned(); + } + + if plume_utils::is_valid_device_udid(&device.udid) { + if let Some(daemon_devices) = REFRESH_DAEMON_DEVICES.get() { + if let Ok(mut devices) = daemon_devices.lock() { + devices.remove(&device.udid); + } + } + } + + Task::none() + } Message::NavigateToScreen(screen_type) => { if screen_type == ImpactorScreenType::Settings { if !matches!(self.current_screen, ImpactorScreen::Progress(_)) { @@ -701,9 +724,15 @@ impl Impactor { } _ => None, }; + let forgotten_device = match &msg { + tvos_pairing::Message::ForgetComplete(Ok(device)) => Some(device.clone()), + _ => None, + }; let update = screen.update(msg).map(Message::TvOsPairingScreen); if let Some(device) = paired_device { Task::batch([update, Task::done(Message::DeviceConnected(device))]) + } else if let Some(device) = forgotten_device { + Task::batch([update, Task::done(Message::DeviceForgotten(device))]) } else { update } diff --git a/apps/plumeimpactor/src/screen/tvos_pairing.rs b/apps/plumeimpactor/src/screen/tvos_pairing.rs index e01d8647..b20cef48 100644 --- a/apps/plumeimpactor/src/screen/tvos_pairing.rs +++ b/apps/plumeimpactor/src/screen/tvos_pairing.rs @@ -59,7 +59,7 @@ pub enum Message { PairComplete(Result), ReconnectComplete(Result), Forget, - ForgetComplete(Result<(), String>), + ForgetComplete(Result), StartOver, } @@ -99,7 +99,7 @@ impl TvOsPairingScreen { } else { device.hostname.clone() }; - format!("[WiFi (tvOS)] {} ({host})", device.name) + format!("[WiFi] {} (tvOS) ({host})", device.name) } fn selected_label(&self) -> Option<&str> { @@ -237,7 +237,10 @@ impl TvOsPairingScreen { return Task::none(); } }; - let reconnect_port = self.reconnect_entry().and_then(|d| d.port); + let reconnect_address = self.reconnect_entry().and_then(|device| { + let ip = device.ip_address.as_deref()?.parse().ok()?; + Some((ip, device.port?)) + }); let name = dev.name.clone(); let hostname = Self::pairing_identity(dev); @@ -259,12 +262,11 @@ impl TvOsPairingScreen { let result = rt.block_on(async move { let ip: std::net::IpAddr = ip_str.parse().map_err(|e| format!("Invalid IP: {e}"))?; - let mut device = Device::new_tvos( + let mut device = Device::new_tvos_with_addresses( name, hostname, - ip, - Some(pairing_port), - reconnect_port, + Some((ip, pairing_port)), + reconnect_address, cache_dir.clone(), ); device @@ -452,30 +454,34 @@ impl TvOsPairingScreen { } Message::Forget => { - let Some(device) = self.selected_device().cloned() else { - self.status = Some(StatusMessage::error("Select an Apple TV first.")); - return Task::none(); - }; - let identity = Self::pairing_identity(&device); - let name = device.name; let cache_dir = get_data_path(); - let device = Device::new_tvos( - name, - identity, - "0.0.0.0".parse().unwrap(), - None, - None, - cache_dir.clone(), - ); + let device = if let Some(device) = self.paired_device.clone() { + device + } else { + let Some(discovered) = self.selected_device().cloned() else { + self.status = Some(StatusMessage::error("Select an Apple TV first.")); + return Task::none(); + }; + let identity = Self::pairing_identity(&discovered); + Device::new_tvos( + discovered.name, + identity, + "0.0.0.0".parse().unwrap(), + None, + None, + cache_dir.clone(), + ) + }; let (tx, rx) = std::sync::mpsc::sync_channel(1); std::thread::spawn(move || { let result = tokio::runtime::Runtime::new() .unwrap() .block_on(device.forget_tvos_pairing(cache_dir)) + .map(|_| device) .map_err(|e| format!("{e}")); let _ = tx.send(result); }); - self.status = Some(StatusMessage::info("Forgetting saved pairing...")); + self.status = Some(StatusMessage::info("Removing Apple TV pairing...")); Task::perform( async move { std::thread::spawn(move || { @@ -491,10 +497,11 @@ impl TvOsPairingScreen { Message::ForgetComplete(result) => { match result { - Ok(()) => { + Ok(_) => { self.paired_device = None; - self.status = - Some(StatusMessage::success("Saved pairing record forgotten.")); + self.status = Some(StatusMessage::success( + "Host pairing removed. The Apple TV may reconnect without a PIN until its remote devices are forgotten.", + )); } Err(error) => self.status = Some(StatusMessage::error(error)), } @@ -660,7 +667,8 @@ impl TvOsPairingScreen { text( "This Apple TV is now selectable in the device list at the top of the window. \ To install to it, import an IPA from the main screen the same way you would \ - for any other device.", + for any other device. Forgetting here removes Impactor's local pairing record; \ + use the Apple TV's Forget All Remote Devices option to require a new PIN.", ) .size(13), ); @@ -678,7 +686,7 @@ impl TvOsPairingScreen { .width(Fill), ); content = content.push( - button(text("Forget Saved Pairing").align_x(Center)) + button(text("Forget Host Pairing").align_x(Center)) .on_press(Message::Forget) .style(appearance::s_button) .width(Fill), diff --git a/apps/plumesign/src/commands/account.rs b/apps/plumesign/src/commands/account.rs index 013d4a3a..9e5a4bfc 100644 --- a/apps/plumesign/src/commands/account.rs +++ b/apps/plumesign/src/commands/account.rs @@ -69,8 +69,12 @@ pub struct DevicesArgs { #[arg(short = 't', long = "team", value_name = "TEAM_ID")] pub team_id: Option, /// Filter by device platform (ios, tvos, watchos) - #[arg(long = "platform", value_name = "PLATFORM")] - pub platform: Option, + #[arg( + long = "platform", + value_name = "PLATFORM", + value_parser = parse_platform + )] + pub platform: Option, } #[derive(Debug, Args)] @@ -84,6 +88,25 @@ pub struct RegisterDeviceArgs { /// Device name #[arg(short = 'n', long = "name", value_name = "NAME", required = true)] pub name: String, + #[arg( + long = "platform", + value_name = "PLATFORM", + value_parser = parse_platform, + default_value_t = DeveloperPlatform::Ios + )] + pub platform: DeveloperPlatform, +} + +fn parse_platform(value: &str) -> std::result::Result { + if value.eq_ignore_ascii_case("ios") || value.eq_ignore_ascii_case("iphoneos") { + std::result::Result::Ok(DeveloperPlatform::Ios) + } else if value.eq_ignore_ascii_case("tvos") || value.eq_ignore_ascii_case("appletvos") { + std::result::Result::Ok(DeveloperPlatform::Tvos) + } else { + Err(format!( + "unsupported platform {value:?}; expected ios or tvos" + )) + } } #[derive(Debug, Args)] @@ -272,7 +295,7 @@ async fn devices(args: DevicesArgs) -> Result<()> { }; let p = session - .qh_list_devices(&team_id, DeveloperPlatform::Ios) + .qh_list_devices(&team_id, args.platform.unwrap_or_default()) .await? .devices; @@ -291,7 +314,7 @@ async fn register_device(args: RegisterDeviceArgs) -> Result<()> { }; let p = session - .qh_add_device(&team_id, &args.name, &args.udid, DeveloperPlatform::Ios) + .qh_add_device(&team_id, &args.name, &args.udid, args.platform) .await? .device; diff --git a/apps/plumesign/src/commands/device.rs b/apps/plumesign/src/commands/device.rs index 66e08c7f..3b580b31 100644 --- a/apps/plumesign/src/commands/device.rs +++ b/apps/plumesign/src/commands/device.rs @@ -260,6 +260,12 @@ async fn discover_network_devices(timeout: Duration) -> Result> { async fn pair_connect(args: PairConnectArgs) -> Result<()> { let cache_dir = get_data_path(); let mut device = if let Some(ip) = args.ip { + #[cfg(target_os = "macos")] + if args.name.is_none() { + return Err(anyhow!( + "--name is required with --ip on macOS so devicectl can select the Apple TV" + )); + } let name = args.name.unwrap_or_else(|| "Apple TV".to_string()); let port = args .port diff --git a/crates/plume_core/src/utils/provision.rs b/crates/plume_core/src/utils/provision.rs index ec1bda01..15ef9dc6 100644 --- a/crates/plume_core/src/utils/provision.rs +++ b/crates/plume_core/src/utils/provision.rs @@ -366,7 +366,11 @@ fn application_identifier_grants(granted: &str, requested: &str) -> bool { granted_bundle_id .strip_suffix(".*") - .is_some_and(|prefix| requested.starts_with(prefix) && requested.len() > prefix.len()) + .is_some_and(|prefix| { + requested + .strip_prefix(prefix) + .is_some_and(|remainder| remainder.starts_with('.')) + }) } fn application_identifier_bundle_id(value: &str) -> Option<&str> { @@ -802,4 +806,16 @@ mod tests { "com.example.tv" )); } + + #[test] + fn wildcard_application_identifier_requires_a_bundle_component_boundary() { + assert!(application_identifier_grants( + "L988J7YMK5.com.example.*", + "com.example.tv" + )); + assert!(!application_identifier_grants( + "L988J7YMK5.com.example.*", + "com.examples.tv" + )); + } } diff --git a/crates/plume_utils/Cargo.toml b/crates/plume_utils/Cargo.toml index 33342dd2..224531a6 100644 --- a/crates/plume_utils/Cargo.toml +++ b/crates/plume_utils/Cargo.toml @@ -9,9 +9,11 @@ repository.workspace = true [dependencies] idevice.workspace = true +base64.workspace = true thiserror.workspace = true uuid.workspace = true plist.workspace = true +serde.workspace = true tokio.workspace = true futures.workspace = true log.workspace = true diff --git a/crates/plume_utils/src/device.rs b/crates/plume_utils/src/device.rs index df12a2e8..fcd4ef8b 100644 --- a/crates/plume_utils/src/device.rs +++ b/crates/plume_utils/src/device.rs @@ -1,16 +1,25 @@ use std::fmt; +use std::future::Future; +#[cfg(target_os = "macos")] +use std::io::Write; use std::path::{Component, Path, PathBuf}; +#[cfg(not(target_os = "macos"))] +use std::pin::Pin; +use std::time::Duration; #[cfg(unix)] use std::os::unix::fs::PermissionsExt; +use base64::{Engine as _, engine::general_purpose::STANDARD}; use idevice::core_device_proxy::CoreDeviceProxy; use idevice::installation_proxy::InstallationProxyClient; use idevice::lockdown::LockdownClient; use idevice::misagent::MisagentClient; use idevice::provider::UsbmuxdProvider; use idevice::remote_pairing::{ - RemotePairingClient, RpPairingFile, RpPairingSocket, connect_tls_psk_tunnel_native, + connect_tls_psk_tunnel_native, RemotePairingClient, RpPairingFile, RpPairingSocket, + RpPairingSocketProvider, }; +#[cfg(not(target_os = "macos"))] use idevice::remote_pairing::errors::RemotePairingError; use idevice::rsd::RsdHandshake; use idevice::tcp::adapter::Adapter; @@ -21,17 +30,22 @@ use idevice::{IdeviceService, RemoteXpcClient, RsdService}; use plume_core::{MobileProvision, developer::DeveloperPlatform}; use crate::Error; +use crate::discovery::{DeviceDiscovery, DeviceType, PlatformDiscovery, REMOTEPAIRING_SERVICE}; use crate::options::SignerAppReal; +#[cfg(not(target_os = "macos"))] use crate::pairing::{PairingBackend, PairingFailure, PairingStage, ensure_pairing}; use idevice::afc::opcode::AfcFopenMode; use idevice::house_arrest::HouseArrestClient; use idevice::usbmuxd::UsbmuxdConnection; use plist::Value; +#[cfg(not(target_os = "macos"))] +use serde::Serialize; pub const CONNECTION_LABEL: &str = "plume_info"; pub const INSTALLATION_LABEL: &str = "plume_install"; pub const HOUSE_ARREST_LABEL: &str = "plume_house_arrest"; +#[cfg(not(target_os = "macos"))] impl<'a, R: idevice::remote_pairing::RpPairingSocketProvider> PairingBackend for RemotePairingClient<'a, R> { @@ -64,6 +78,286 @@ impl<'a, R: idevice::remote_pairing::RpPairingSocketProvider> PairingBackend } } +#[cfg(not(target_os = "macos"))] +#[derive(Debug)] +struct SequencedTvosPairingSocket { + inner: RpPairingSocket, + sequence_offset: usize, +} + +#[cfg(not(target_os = "macos"))] +impl SequencedTvosPairingSocket { + fn new(inner: RpPairingSocket, sequence_offset: usize) -> Self { + Self { + inner, + sequence_offset, + } + } +} + +#[cfg(not(target_os = "macos"))] +impl RpPairingSocketProvider for SequencedTvosPairingSocket { + fn send_plain( + &mut self, + value: impl Serialize, + seq: usize, + ) -> Pin> + Send + '_>> { + self.inner.send_plain(value, seq + self.sequence_offset) + } + + fn send_encrypted( + &mut self, + ciphertext: Vec, + seq: usize, + ) -> Pin> + Send + '_>> { + self.inner + .send_encrypted(ciphertext, seq + self.sequence_offset) + } + + fn recv_plain<'a>( + &'a mut self, + ) -> Pin> + Send + 'a>> { + self.inner.recv_plain() + } + + fn serialize_bytes(b: &[u8]) -> plist::Value { + RpPairingSocket::::serialize_bytes(b) + } + + fn deserialize_bytes(v: plist::Value) -> Option> { + RpPairingSocket::::deserialize_bytes(v) + } +} + +#[cfg(not(target_os = "macos"))] +struct TvosPairingBackend<'a> { + client: RemotePairingClient<'a, SequencedTvosPairingSocket>, +} + +#[cfg(not(target_os = "macos"))] +impl<'a> PairingBackend for TvosPairingBackend<'a> { + async fn verify(&mut self) -> Result<(), PairingFailure> { + self.client + .attempt_pair_verify() + .await + .map_err(|error| PairingFailure::Protocol(error.to_string()))?; + self.client + .validate_pairing() + .await + .map_err(|error| PairingFailure::Protocol(error.to_string())) + } + + async fn pair(&mut self, pin: &str) -> Result<(), PairingFailure> { + let pin = pin.to_string(); + RemotePairingClient::pair( + &mut self.client, + |_| { + let pin = pin.clone(); + async move { pin } + }, + (), + ) + .await + .map_err(|error| match error { + idevice::IdeviceError::RemotePairing(RemotePairingError::SrpAuthFailed) => { + PairingFailure::WrongPin + } + error => PairingFailure::Protocol(error.to_string()), + }) + } +} + +#[cfg(not(target_os = "macos"))] +const TVOS_RP_PAIRING_WIRE_PROTOCOL_VERSION: i64 = 26; + +#[cfg(not(target_os = "macos"))] +async fn begin_tvos_pairing( + stream: tokio::net::TcpStream, +) -> Result { + let correlation_identifier: String = uuid::Uuid::new_v4() + .simple() + .to_string() + .chars() + .take(6) + .collect(); + let mut socket = RpPairingSocket::new(stream); + socket + .send_plain(tvos_pairing_handshake_request(&correlation_identifier), 0) + .await?; + let response = socket.recv_plain().await?; + if !tvos_pairing_handshake_allows_pair_setup(&response) { + return Err(Error::Other( + "Apple TV did not advertise support for manual pairing".to_string(), + )); + } + Ok(SequencedTvosPairingSocket::new(socket, 1)) +} + +#[cfg(target_os = "macos")] +async fn pair_tvos_with_devicectl( + device_name: &str, + pin_provider: F, + address: Option<(std::net::IpAddr, u16)>, + cache_dir: &Path, + cache_path: &Path, +) -> Result +where + F: Fn() -> Fut, + Fut: Future, +{ + let (pin_sender, pin_receiver) = std::sync::mpsc::sync_channel::(1); + let device_name = device_name.to_string(); + let mut pairing_task = tokio::task::spawn_blocking(move || -> Result<(), String> { + let mut child = std::process::Command::new("xcrun") + .args([ + "devicectl", + "manage", + "pair", + "--device", + device_name.as_str(), + "--timeout", + "180", + ]) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + .map_err(|error| format!("Could not start xcrun devicectl: {error}"))?; + + let pin = loop { + match pin_receiver.recv_timeout(Duration::from_millis(100)) { + Ok(pin) => break pin, + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + if let Some(status) = child + .try_wait() + .map_err(|error| format!("Could not check xcrun devicectl: {error}"))? + { + if status.success() { + return Ok(()); + } + return Err(format!( + "xcrun devicectl pairing failed with status {status}" + )); + } + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + return Err("Apple TV pairing PIN was not provided".to_string()); + } + } + }; + if pin.is_empty() { + let _ = child.kill(); + return Err("Apple TV pairing was cancelled".to_string()); + } + if pin.len() != 6 || !pin.bytes().all(|byte| byte.is_ascii_digit()) { + let _ = child.kill(); + return Err("Apple TV pairing PIN must contain exactly six digits".to_string()); + } + + let mut stdin = child + .stdin + .take() + .ok_or_else(|| "xcrun devicectl did not expose standard input".to_string())?; + stdin + .write_all(format!("{pin}\n").as_bytes()) + .map_err(|error| format!("Could not provide the Apple TV pairing PIN: {error}"))?; + drop(stdin); + + let status = child + .wait() + .map_err(|error| format!("Could not wait for xcrun devicectl: {error}"))?; + if status.success() { + Ok(()) + } else { + Err(format!("xcrun devicectl pairing failed with status {status}")) + } + }); + + let pin_future = pin_provider(); + tokio::pin!(pin_future); + let pairing_result = tokio::select! { + result = &mut pairing_task => result, + pin = &mut pin_future => { + if pin_sender.send(pin).is_err() { + return Err(Error::Other( + "xcrun devicectl exited before the Apple TV pairing PIN was provided".to_string(), + )); + } + pairing_task.await + } + }; + pairing_result + .map_err(|error| Error::Other(format!("Apple TV pairing task failed: {error}")))? + .map_err(Error::Other)?; + + try_import_external_pairing_at(address, cache_dir, cache_path) + .await? + .ok_or_else(|| { + Error::Other( + "Apple TV pairing completed in Xcode but its pairing record could not be imported" + .to_string(), + ) + }) +} + +#[cfg(not(target_os = "macos"))] +fn tvos_pairing_handshake_request(correlation_identifier: &str) -> Value { + let mut host_options = plist::Dictionary::new(); + host_options.insert("attemptPairVerify".to_string(), Value::Boolean(false)); + + let mut correlation = plist::Dictionary::new(); + correlation.insert( + "value".to_string(), + Value::String(correlation_identifier.to_string()), + ); + + let mut handshake = plist::Dictionary::new(); + handshake.insert("hostOptions".to_string(), Value::Dictionary(host_options)); + handshake.insert( + "correlationIdentifier".to_string(), + Value::Dictionary(correlation), + ); + handshake.insert( + "wireProtocolVersion".to_string(), + Value::Integer(TVOS_RP_PAIRING_WIRE_PROTOCOL_VERSION.into()), + ); + + let mut handshake_container = plist::Dictionary::new(); + handshake_container.insert("_0".to_string(), Value::Dictionary(handshake)); + + let mut request = plist::Dictionary::new(); + request.insert( + "handshake".to_string(), + Value::Dictionary(handshake_container), + ); + + let mut request_container = plist::Dictionary::new(); + request_container.insert("_0".to_string(), Value::Dictionary(request)); + + let mut root = plist::Dictionary::new(); + root.insert("request".to_string(), Value::Dictionary(request_container)); + Value::Dictionary(root) +} + +#[cfg(not(target_os = "macos"))] +fn tvos_pairing_handshake_allows_pair_setup(response: &Value) -> bool { + response + .as_dictionary() + .and_then(|value| value.get("response")) + .and_then(Value::as_dictionary) + .and_then(|value| value.get("_1")) + .and_then(Value::as_dictionary) + .and_then(|value| value.get("handshake")) + .and_then(Value::as_dictionary) + .and_then(|value| value.get("_0")) + .and_then(Value::as_dictionary) + .and_then(|value| value.get("deviceOptions")) + .and_then(Value::as_dictionary) + .and_then(|value| value.get("allowsPairSetup")) + .and_then(Value::as_boolean) + .unwrap_or(false) +} + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum DeviceTransport { Usbmuxd, @@ -263,6 +557,22 @@ impl Device { pairing_port: Option, reconnect_port: Option, cache_dir: PathBuf, + ) -> Self { + Self::new_tvos_with_addresses( + name, + pairing_identity, + pairing_port.map(|port| (ip, port)), + reconnect_port.map(|port| (ip, port)), + cache_dir, + ) + } + + pub fn new_tvos_with_addresses( + name: String, + pairing_identity: String, + pairing_address: Option<(std::net::IpAddr, u16)>, + reconnect_address: Option<(std::net::IpAddr, u16)>, + cache_dir: PathBuf, ) -> Self { Device { name, @@ -274,8 +584,8 @@ impl Device { device_id: 0, usbmuxd_device: None, is_mac: false, - pairing_address: pairing_port.map(|port| (ip, port)), - reconnect_address: reconnect_port.map(|port| (ip, port)), + pairing_address, + reconnect_address, pairing_identity: Some(pairing_identity), pairing_cache_dir: Some(cache_dir), core_device_authenticated: false, @@ -612,7 +922,7 @@ impl Device { } pub async fn pair_tvos( - &self, + &mut self, pin_provider: F, cache_dir: PathBuf, ) -> Result @@ -642,7 +952,7 @@ impl Device { if let Some(pairing_file) = self.try_import_external_pairing(&cache_dir, &cache_path).await? { - return Ok(pairing_file); + return self.finish_tvos_pairing(pairing_file).await; } } @@ -680,7 +990,7 @@ impl Device { if reconnect_result.is_ok() { write_pairing_file(&pairing_file, &cache_dir, &cache_path).await?; log::info!("tvOS pairing: cached record reconnected without a PIN"); - return Ok(pairing_file); + return self.finish_tvos_pairing(pairing_file).await; } if cache_path.exists() { @@ -694,10 +1004,26 @@ impl Device { self.try_import_external_pairing(&cache_dir, &cache_path).await? { log::info!("tvOS pairing: recovered an existing native pairing record"); - return Ok(pairing_file); + return self.finish_tvos_pairing(pairing_file).await; } } + #[cfg(target_os = "macos")] + { + let pairing_file = pair_tvos_with_devicectl( + &self.name, + pin_provider, + self.reconnect_address.or(self.pairing_address), + &cache_dir, + &cache_path, + ) + .await?; + log::info!("tvOS pairing: imported the native Xcode pairing record"); + return self.finish_tvos_pairing(pairing_file).await; + } + + #[cfg(not(target_os = "macos"))] + { let (ip, port) = self.pairing_address.ok_or_else(|| { Error::Other( "Apple TV pairing requires its manual-pairing service. On the Apple TV, open Settings \ @@ -718,21 +1044,18 @@ impl Device { })?; log::info!("tvOS pairing: TCP connected to {addr}, starting RPPairing handshake"); - let conn = RpPairingSocket::new(stream); - let (mut pairing_file, sending_host) = { - let suffix: String = uuid::Uuid::new_v4() - .simple() - .to_string() - .chars() - .take(6) - .collect(); - let host = format!("plume-{suffix}"); - (RpPairingFile::generate(&host), host) + let local_hostname = local_remote_pairing_hostname().ok_or_else(|| { + Error::Other("Could not determine the Mac hostname for Apple TV pairing".to_string()) + })?; + let sending_host = local_remote_pairing_host().unwrap_or_else(|| local_hostname.clone()); + let mut pairing_file = RpPairingFile::generate(&local_hostname); + let conn = begin_tvos_pairing(stream).await?; + let pairing_client = RemotePairingClient::new(conn, &sending_host, &mut pairing_file); + let mut pairing_backend = TvosPairingBackend { + client: pairing_client, }; - - let mut pairing_client = RemotePairingClient::new(conn, &sending_host, &mut pairing_file); let stage = ensure_pairing( - &mut pairing_client, + &mut pairing_backend, false, true, false, @@ -749,9 +1072,74 @@ impl Device { write_pairing_file(&pairing_file, &cache_dir, &cache_path).await?; + self.finish_tvos_pairing(pairing_file).await + } + } + + async fn finish_tvos_pairing( + &mut self, + pairing_file: RpPairingFile, + ) -> Result { + self.refresh_tvos_reconnect_address(Duration::from_secs(5)) + .await?; Ok(pairing_file) } + pub async fn refresh_tvos_reconnect_address( + &mut self, + timeout: Duration, + ) -> Result<(), Error> { + let pairing_identity = self + .pairing_identity + .as_deref() + .filter(|identity| !identity.is_empty()) + .map(str::to_ascii_lowercase); + let known_ip = self + .pairing_address + .or(self.reconnect_address) + .map(|(ip, _)| ip); + + let discovered = PlatformDiscovery::new().discover(timeout).await?; + let candidate = discovered + .into_iter() + .filter(|device| { + device.device_type == DeviceType::AppleTV + && device.service_type == REMOTEPAIRING_SERVICE + }) + .filter_map(|device| { + let ip = device.ip_address.as_deref()?.parse().ok()?; + let port = device.port?; + let same_ip = known_ip == Some(ip); + let same_identity = pairing_identity.as_deref().is_some_and(|identity| { + device.hostname.eq_ignore_ascii_case(identity) + || device.name.eq_ignore_ascii_case(identity) + }); + let same_name = !self.name.is_empty() + && device.name.eq_ignore_ascii_case(&self.name); + if !(same_ip || same_identity || same_name) { + return None; + } + Some(((same_ip, same_identity, same_name), (ip, port))) + }) + .max_by_key(|(matches, _)| *matches) + .map(|(_, address)| address); + + if let Some(address) = candidate { + self.reconnect_address = Some(address); + log::info!( + "tvOS pairing: using verified reconnect service at {}:{}", + address.0, + address.1 + ); + return Ok(()); + } + + Err(Error::Other( + "Apple TV pairing succeeded, but its verified _remotepairing._tcp service was not found; scan again and retry" + .to_string(), + )) + } + pub async fn establish_tvos_tunnel( &self, cache_dir: PathBuf, @@ -766,14 +1154,69 @@ impl Device { } pub fn has_pairing_source(&self, cache_dir: &Path) -> bool { - self.has_cached_pairing_file(cache_dir) - || external_pairing_paths() - .into_iter() - .any(|path| path.exists()) + if self.has_cached_pairing_file(cache_dir) { + return true; + } + + #[cfg(target_os = "macos")] + { + return self.has_native_tvos_pairing(); + } + + #[cfg(not(target_os = "macos"))] + false } pub async fn forget_tvos_pairing(&self, cache_dir: PathBuf) -> Result<(), Error> { let path = self.pairing_cache_path(&cache_dir)?; + + #[cfg(target_os = "macos")] + if self.has_native_tvos_pairing() { + let selector = if crate::is_valid_device_udid(&self.udid) { + self.udid.clone() + } else if let Some(identity) = self + .pairing_identity + .as_deref() + .filter(|identity| !identity.is_empty()) + { + identity.to_string() + } else { + self.name.clone() + }; + let output = std::process::Command::new("xcrun") + .args([ + "devicectl", + "manage", + "unpair", + "--device", + selector.as_str(), + "--timeout", + "45", + ]) + .output() + .map_err(|error| { + Error::Other(format!("Could not run xcrun devicectl unpair: {error}")) + })?; + if !output.status.success() { + let details = String::from_utf8_lossy(&output.stderr) + .trim() + .to_string(); + let details = if details.is_empty() { + String::from_utf8_lossy(&output.stdout).trim().to_string() + } else { + details + }; + return Err(Error::Other(format!( + "Could not remove the native Apple TV pairing: {}", + if details.is_empty() { + output.status.to_string() + } else { + details + } + ))); + } + } + match tokio::fs::remove_file(&path).await { Ok(()) => Ok(()), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), @@ -786,6 +1229,56 @@ impl Device { Ok(TvosDeviceInfo::from_rsd_properties(&handshake.properties)) } + #[cfg(target_os = "macos")] + fn has_native_tvos_pairing(&self) -> bool { + let expected_name = self.name.to_ascii_lowercase(); + let expected_product_type = self + .product_type + .as_deref() + .map(str::to_ascii_lowercase); + + for host_path in external_pairing_paths() { + let Some(peers_path) = host_path.parent().map(|path| path.join("peers")) else { + continue; + }; + let Ok(entries) = std::fs::read_dir(peers_path) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("plist") { + continue; + } + let Ok(bytes) = std::fs::read(path) else { + continue; + }; + let Ok(peer) = plist::from_bytes::(&bytes) else { + continue; + }; + let model = peer + .get("model") + .and_then(Value::as_string) + .map(str::to_ascii_lowercase); + let name = peer + .get("name") + .and_then(Value::as_string) + .map(str::to_ascii_lowercase); + let model_matches = model.as_deref().is_some_and(|model| { + model.starts_with("appletv") + && expected_product_type + .as_deref() + .is_none_or(|expected| expected == model) + }); + let name_matches = name.as_deref() == Some(expected_name.as_str()); + if model_matches && name_matches { + return true; + } + } + } + + false + } + pub fn apply_tvos_info(&mut self, info: &TvosDeviceInfo) { if self.pairing_identity.is_none() { return; @@ -947,6 +1440,61 @@ enum CoreDeviceTunnelAttemptError { Tunnel(Error), } +fn tvos_create_listener_request(encryption_key: &[u8]) -> Value { + let mut listener = plist::Dictionary::new(); + listener.insert( + "key".to_string(), + Value::String(STANDARD.encode(encryption_key)), + ); + + let mut peer_connection = plist::Dictionary::new(); + peer_connection.insert( + "owningPID".to_string(), + Value::Integer((std::process::id() as i64).into()), + ); + peer_connection.insert( + "owningProcessName".to_string(), + Value::String("CoreDeviceService".to_string()), + ); + listener.insert( + "peerConnectionsInfo".to_string(), + Value::Array(vec![Value::Dictionary(peer_connection)]), + ); + listener.insert( + "transportProtocolType".to_string(), + Value::String("tcp".to_string()), + ); + + let mut operation = plist::Dictionary::new(); + operation.insert("createListener".to_string(), Value::Dictionary(listener)); + + let mut request_body = plist::Dictionary::new(); + request_body.insert("_0".to_string(), Value::Dictionary(operation)); + + let mut request = plist::Dictionary::new(); + request.insert("request".to_string(), Value::Dictionary(request_body)); + Value::Dictionary(request) +} + +async fn create_tvos_tcp_listener( + rpc: &mut RemotePairingClient<'_, R>, +) -> Result { + let request = tvos_create_listener_request(rpc.encryption_key()); + let response = rpc.send_receive_encrypted_request(request).await?; + log::debug!("tvOS createListener response: {response:#?}"); + + let port = response + .as_dictionary() + .and_then(|value| value.get("createListener")) + .and_then(Value::as_dictionary) + .and_then(|value| value.get("port")) + .and_then(Value::as_unsigned_integer) + .filter(|port| *port <= u16::MAX as u64) + .ok_or_else(|| Error::Other("missing port in createListener response".to_string()))?; + + Ok(port as u16) +} + async fn establish_core_device_tunnel( pairing_address: Option<(std::net::IpAddr, u16)>, reconnect_address: Option<(std::net::IpAddr, u16)>, @@ -954,9 +1502,16 @@ async fn establish_core_device_tunnel( udid: &str, cache_dir: &Path, ) -> Result<(AdapterHandle, RsdHandshake), Error> { - let (ip, port) = reconnect_address - .or(pairing_address) - .ok_or_else(|| Error::Other("Device has no network address".to_string()))?; + let (ip, port) = reconnect_address.ok_or_else(|| { + if pairing_address.is_some() { + Error::Other( + "Apple TV has only its manual-pairing service; discover its verified _remotepairing._tcp service before opening a tunnel" + .to_string(), + ) + } else { + Error::Other("Device has no network address".to_string()) + } + })?; let connect_addr = std::net::SocketAddr::new(ip, port); let cache_path = pairing_cache_path_for(pairing_identity, udid, cache_dir)?; @@ -1001,7 +1556,7 @@ async fn establish_core_device_tunnel( ))); } - let tunnel_port = rpc.create_tcp_listener().await.map_err(|e| { + let tunnel_port = create_tvos_tcp_listener(&mut rpc).await.map_err(|e| { CoreDeviceTunnelAttemptError::Tunnel(Error::Other(format!( "Failed to create tunnel listener: {e}" ))) @@ -1235,6 +1790,7 @@ fn pairing_action( } } +#[cfg(not(target_os = "macos"))] fn pairing_failure_to_error(failure: PairingFailure) -> Error { match failure { PairingFailure::Cancelled => Error::Other("Apple TV pairing was cancelled".to_string()), @@ -1256,7 +1812,7 @@ fn pairing_failure_to_error(failure: PairingFailure) -> Error { } } -fn local_remote_pairing_identifier() -> Option { +fn local_remote_pairing_hostname() -> Option { let hostname = std::env::var("HOSTNAME") .ok() .filter(|hostname| !hostname.trim().is_empty()) @@ -1270,6 +1826,24 @@ fn local_remote_pairing_identifier() -> Option { .filter(|hostname| !hostname.is_empty()) })?; + Some(hostname) +} + +#[cfg(not(target_os = "macos"))] +fn local_remote_pairing_host() -> Option { + std::process::Command::new("scutil") + .args(["--get", "ComputerName"]) + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8(output.stdout).ok()) + .map(|hostname| hostname.trim().to_string()) + .filter(|hostname| !hostname.is_empty()) + .or_else(local_remote_pairing_hostname) +} + +fn local_remote_pairing_identifier() -> Option { + let hostname = local_remote_pairing_hostname()?; Some( uuid::Uuid::new_v3(&uuid::Uuid::NAMESPACE_DNS, hostname.as_bytes()) .to_string() @@ -1337,7 +1911,7 @@ fn get_app_name_from_info(info: &Value) -> Option { impl fmt::Display for Device { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let conn = if self.pairing_address.is_some() || self.reconnect_address.is_some() { - "WiFi (tvOS)" + "WiFi" } else { match &self.usbmuxd_device { Some(device) => match &device.connection_type { @@ -1355,7 +1929,8 @@ impl fmt::Display for Device { } else { String::new() }; - write!(f, "[{conn}] {}{identity}", self.name) + let platform = if self.is_tvos() { " (tvOS)" } else { "" }; + write!(f, "[{conn}] {}{platform}{identity}", self.name) } } @@ -1532,6 +2107,24 @@ mod tests { assert_eq!(info.os_version.as_deref(), Some("26.5")); } + #[test] + fn display_keeps_tvos_platform_out_of_connection_brackets() { + let mut device = Device::new_tvos( + "Apple TV".to_string(), + "Apple-TV".to_string(), + "192.0.2.10".parse().unwrap(), + None, + Some(49152), + std::env::temp_dir(), + ); + device.udid = "00008110-000C25540CD1801E".to_string(); + + assert_eq!( + device.to_string(), + "[WiFi] Apple TV (tvOS) [00008110…801E]" + ); + } + #[test] fn new_tvos_leaves_udid_empty_and_sets_pairing_identity() { let d = Device::new_tvos( @@ -1871,6 +2464,49 @@ mod tests { .contains("manual-pairing")); } + #[test] + fn tvos_listener_request_contains_coredevice_connection_metadata() { + let request = tvos_create_listener_request(&[0, 1, 2, 255]); + let listener = request + .as_dictionary() + .and_then(|value| value.get("request")) + .and_then(Value::as_dictionary) + .and_then(|value| value.get("_0")) + .and_then(Value::as_dictionary) + .and_then(|value| value.get("createListener")) + .and_then(Value::as_dictionary) + .expect("createListener request"); + + assert_eq!( + listener + .get("key") + .and_then(Value::as_string) + .unwrap(), + "AAEC/w==" + ); + assert_eq!( + listener + .get("transportProtocolType") + .and_then(Value::as_string), + Some("tcp") + ); + + let peers = listener + .get("peerConnectionsInfo") + .and_then(Value::as_array) + .expect("peer connection metadata"); + assert_eq!(peers.len(), 1); + let peer = peers[0].as_dictionary().expect("peer connection"); + assert_eq!( + peer.get("owningProcessName").and_then(Value::as_string), + Some("CoreDeviceService") + ); + assert_eq!( + peer.get("owningPID").and_then(Value::as_unsigned_integer), + Some(std::process::id() as u64) + ); + } + #[cfg(unix)] #[tokio::test] async fn pairing_cache_uses_restrictive_permissions() { diff --git a/crates/plume_utils/src/discovery/mod.rs b/crates/plume_utils/src/discovery/mod.rs index 6cef45e5..db437a53 100644 --- a/crates/plume_utils/src/discovery/mod.rs +++ b/crates/plume_utils/src/discovery/mod.rs @@ -196,11 +196,7 @@ pub(crate) fn build_device( name, hostname: short_hostname(hostname).to_string(), udid, - ip_address: addresses - .iter() - .find(|address| address.is_ipv4()) - .or_else(|| addresses.first()) - .map(|address| address.to_string()), + ip_address: preferred_ip_address(addresses), port, device_type, connection_type: ConnectionType::WiFi, @@ -211,6 +207,15 @@ pub(crate) fn build_device( } } +fn preferred_ip_address(addresses: &[IpAddr]) -> Option { + addresses + .iter() + .filter(|address| address.is_ipv4()) + .min_by_key(|address| address.to_string()) + .or_else(|| addresses.iter().min_by_key(|address| address.to_string())) + .map(ToString::to_string) +} + pub(crate) fn is_metadata_service(service_type: &str) -> bool { METADATA_SERVICE_TYPES.contains(&service_type) } @@ -267,9 +272,9 @@ pub(crate) fn enrich_and_filter(devices: Vec) -> Vec, - pairing_port: Option, - reconnect_port: Option, + pairing_address: Option<(IpAddr, u16)>, + reconnect_address: Option<(IpAddr, u16)>, + legacy_core_device: bool, } pub fn group_network_devices(discovered: &[DiscoveredDevice], cache_dir: &Path) -> Vec { @@ -297,31 +302,36 @@ pub fn group_network_devices(discovered: &[DiscoveredDevice], cache_dir: &Path) let entry = groups.entry(key).or_insert_with(|| NetworkDeviceGroup { name: d.name.clone(), hostname: d.hostname.clone(), - ip: None, - pairing_port: None, - reconnect_port: None, + pairing_address: None, + reconnect_address: None, + legacy_core_device: false, }); + entry.legacy_core_device |= is_core_device; - if entry.ip.is_none() { - if let Some(ip_str) = &d.ip_address { - if let Ok(ip) = ip_str.parse::() { - entry.ip = Some(ip); - } - } - } - + let address = d + .ip_address + .as_deref() + .and_then(|ip| ip.parse::().ok()) + .zip(d.port); if d.service_type == REMOTEPAIRING_MANUAL_PAIRING_SERVICE { - entry.pairing_port = d.port; + if entry.pairing_address.is_none() { + entry.pairing_address = address; + } } else if d.service_type == REMOTEPAIRING_SERVICE { - entry.reconnect_port = d.port; + if entry.reconnect_address.is_none() { + entry.reconnect_address = address; + } } } let mut devices = Vec::with_capacity(groups.len()); for group in groups.into_values() { - let Some(ip) = group.ip else { + if group.pairing_address.is_none() + && group.reconnect_address.is_none() + && !group.legacy_core_device + { continue; - }; + } let pairing_identity = if group.hostname.is_empty() { group.name.replace(' ', "-") } else { @@ -329,12 +339,11 @@ pub fn group_network_devices(discovered: &[DiscoveredDevice], cache_dir: &Path) }; let id = synthetic_device_id(&pairing_identity); - let mut device = Device::new_tvos( + let mut device = Device::new_tvos_with_addresses( group.name, pairing_identity, - ip, - group.pairing_port, - group.reconnect_port, + group.pairing_address, + group.reconnect_address, cache_dir.to_path_buf(), ); device.device_id = id; @@ -1061,7 +1070,7 @@ mod tests { } #[test] - fn group_network_devices_keeps_first_resolved_address_when_entries_share_a_name() { + fn group_network_devices_keeps_each_service_address_when_entries_share_a_name() { let discovered = [ network_apple_tv( "Living Room", @@ -1081,7 +1090,7 @@ mod tests { ); assert_eq!( devices[0].reconnect_address.unwrap().0.to_string(), - "10.0.0.5" + "10.0.0.9" ); } } diff --git a/crates/plume_utils/src/lib.rs b/crates/plume_utils/src/lib.rs index 95209e67..b9ecf4ff 100644 --- a/crates/plume_utils/src/lib.rs +++ b/crates/plume_utils/src/lib.rs @@ -9,6 +9,7 @@ mod signer; mod tweak; use std::path::Path; +use idevice::usbmuxd::Connection; pub use bundle::{Bundle, BundleType}; pub use device::{ CoreDeviceTransport, Device, DeviceTransport, TvosDeviceInfo, get_device_for_id, install_app_mac, @@ -120,6 +121,15 @@ fn dedup_keys_for_device(device: &Device) -> Vec { } if let Some(usbmuxd) = &device.usbmuxd_device { keys.push(format!("mux:{}", usbmuxd.device_id)); + if let Connection::Network(ip) = &usbmuxd.connection_type { + keys.push(format!("network-ip:{ip}")); + } + } + for address in [device.pairing_address, device.reconnect_address] + .into_iter() + .flatten() + { + keys.push(format!("network-ip:{}", address.0)); } keys @@ -201,7 +211,16 @@ fn merge_devices(existing: &mut Device, mut incoming: Device) { if existing.serial_number.is_none() { existing.serial_number = incoming.serial_number; } - if existing.usbmuxd_device.is_none() { + let is_remote_tvos = existing.pairing_identity.is_some() + && (existing + .product_type + .as_deref() + .is_some_and(|value| value.starts_with("AppleTV")) + || existing + .device_class + .as_deref() + .is_some_and(|value| value.eq_ignore_ascii_case("AppleTV"))); + if existing.usbmuxd_device.is_none() && !is_remote_tvos { existing.usbmuxd_device = incoming.usbmuxd_device; } if existing.pairing_address.is_none() { @@ -221,6 +240,9 @@ fn merge_devices(existing: &mut Device, mut incoming: Device) { } existing.is_mac |= incoming.is_mac; existing.core_device_authenticated |= incoming.core_device_authenticated; + if is_remote_tvos { + existing.usbmuxd_device = None; + } } pub fn format_bytes(bytes: u64) -> String { @@ -342,4 +364,50 @@ mod tests { assert_eq!(devices.len(), 1); } + + #[test] + fn deduplicates_usbmuxd_apple_tv_with_authenticated_network_entry() { + let cache_dir = std::env::temp_dir(); + let legacy = Device { + name: "TV".to_string(), + udid: "fff48:e1:5c:79:40:83fff".to_string(), + product_type: Some("AppleTV14,1".to_string()), + device_class: Some("AppleTV".to_string()), + os_version: Some("26.6".to_string()), + serial_number: None, + device_id: 237, + usbmuxd_device: Some(idevice::usbmuxd::UsbmuxdDevice { + connection_type: Connection::Network("192.0.2.10".parse().unwrap()), + udid: "fff48:e1:5c:79:40:83fff".to_string(), + device_id: 237, + }), + is_mac: false, + pairing_address: None, + reconnect_address: None, + pairing_identity: None, + pairing_cache_dir: None, + core_device_authenticated: false, + }; + let mut authenticated = Device::new_tvos( + "TV".to_string(), + "tv".to_string(), + "192.0.2.10".parse().unwrap(), + None, + Some(49152), + cache_dir, + ); + authenticated.udid = "00008110-000C25540CD1801E".to_string(); + authenticated.product_type = Some("AppleTV14,1".to_string()); + authenticated.os_version = Some("26.6".to_string()); + authenticated.core_device_authenticated = true; + + let devices = deduplicate_devices([legacy, authenticated]); + + assert_eq!(devices.len(), 1); + assert_eq!(devices[0].name, "TV"); + assert_eq!(devices[0].udid, "00008110-000C25540CD1801E"); + assert!(devices[0].core_device_authenticated); + assert!(devices[0].usbmuxd_device.is_none()); + assert_eq!(devices[0].transport(), DeviceTransport::CoreDevice); + } } From ada33a9c18fcb9ae0874f708087ad65c75d1b2c6 Mon Sep 17 00:00:00 2001 From: Adrian Castro <22133246+castdrian@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:55:41 +0200 Subject: [PATCH 8/8] refactor(pairing): remove xcrun dependency and redundant tests --- Cargo.lock | 1 - apps/plumesign/src/commands/device.rs | 6 - crates/plume_core/src/developer/platform.rs | 90 -- crates/plume_core/src/utils/provision.rs | 363 ------- crates/plume_utils/Cargo.toml | 3 - crates/plume_utils/src/device.rs | 988 +------------------- crates/plume_utils/src/discovery/mdns.rs | 69 -- crates/plume_utils/src/discovery/mod.rs | 690 -------------- crates/plume_utils/src/lib.rs | 151 --- crates/plume_utils/src/package.rs | 141 --- crates/plume_utils/src/pairing.rs | 128 --- 11 files changed, 45 insertions(+), 2585 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c0191750..9a19729f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5672,7 +5672,6 @@ version = "2.6.3" dependencies = [ "base64 0.22.1", "decompress", - "env_logger", "flate2", "futures", "goblin", diff --git a/apps/plumesign/src/commands/device.rs b/apps/plumesign/src/commands/device.rs index 3b580b31..66e08c7f 100644 --- a/apps/plumesign/src/commands/device.rs +++ b/apps/plumesign/src/commands/device.rs @@ -260,12 +260,6 @@ async fn discover_network_devices(timeout: Duration) -> Result> { async fn pair_connect(args: PairConnectArgs) -> Result<()> { let cache_dir = get_data_path(); let mut device = if let Some(ip) = args.ip { - #[cfg(target_os = "macos")] - if args.name.is_none() { - return Err(anyhow!( - "--name is required with --ip on macOS so devicectl can select the Apple TV" - )); - } let name = args.name.unwrap_or_else(|| "Apple TV".to_string()); let port = args .port diff --git a/crates/plume_core/src/developer/platform.rs b/crates/plume_core/src/developer/platform.rs index 45abf649..25e8dcc0 100644 --- a/crates/plume_core/src/developer/platform.rs +++ b/crates/plume_core/src/developer/platform.rs @@ -69,93 +69,3 @@ impl std::fmt::Display for DeveloperPlatform { } } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn default_is_ios() { - assert_eq!(DeveloperPlatform::default(), DeveloperPlatform::Ios); - } - - #[test] - fn ios_request_fields_are_empty() { - assert!(DeveloperPlatform::Ios.request_fields().is_empty()); - } - - #[test] - fn tvos_request_fields_are_exact() { - assert_eq!( - DeveloperPlatform::Tvos.request_fields(), - &[("DTDK_Platform", "tvos"), ("subPlatform", "tvOS")] - ); - } - - #[test] - fn capabilities_filter_matches_platform() { - assert_eq!(DeveloperPlatform::Ios.capabilities_filter(), "IOS"); - assert_eq!(DeveloperPlatform::Tvos.capabilities_filter(), "TVOS"); - } - - #[test] - fn ios_apply_to_leaves_dictionary_unchanged() { - let mut body = Dictionary::new(); - body.insert("teamId".to_string(), Value::String("T123".to_string())); - body.insert("appIdId".to_string(), Value::String("A456".to_string())); - let original = body.clone(); - - DeveloperPlatform::Ios.apply_to(&mut body); - - assert_eq!(body, original); - assert_eq!(body.keys().count(), 2); - } - - #[test] - fn tvos_apply_to_adds_exactly_the_two_platform_fields() { - let mut body = Dictionary::new(); - body.insert("teamId".to_string(), Value::String("T123".to_string())); - body.insert("appIdId".to_string(), Value::String("A456".to_string())); - - DeveloperPlatform::Tvos.apply_to(&mut body); - - assert_eq!(body.keys().count(), 4); - assert_eq!(body.get("teamId").and_then(Value::as_string), Some("T123")); - assert_eq!(body.get("appIdId").and_then(Value::as_string), Some("A456")); - assert_eq!( - body.get("DTDK_Platform").and_then(Value::as_string), - Some("tvos") - ); - assert_eq!( - body.get("subPlatform").and_then(Value::as_string), - Some("tvOS") - ); - } - - #[test] - fn metadata_classifies_tvos_without_trusting_network_identifiers() { - assert_eq!( - DeveloperPlatform::from_device_metadata( - Some("AppleTV14,1"), - Some("AppleTV"), - false - ), - DeveloperPlatform::Tvos - ); - assert_eq!( - DeveloperPlatform::from_device_metadata(None, None, true), - DeveloperPlatform::Tvos - ); - assert_eq!( - DeveloperPlatform::from_device_metadata(Some("iPhone15,2"), Some("iPhone"), false), - DeveloperPlatform::Ios - ); - } - - #[test] - fn profile_platform_matching_is_case_insensitive() { - assert!(DeveloperPlatform::Tvos.matches_profile_platform("tvOS")); - assert!(!DeveloperPlatform::Tvos.matches_profile_platform("iOS")); - assert!(DeveloperPlatform::Ios.matches_profile_platform("iPhoneOS")); - } -} diff --git a/crates/plume_core/src/utils/provision.rs b/crates/plume_core/src/utils/provision.rs index 15ef9dc6..ecbd72a7 100644 --- a/crates/plume_core/src/utils/provision.rs +++ b/crates/plume_core/src/utils/provision.rs @@ -456,366 +456,3 @@ pub fn is_valid_device_udid(value: &str) -> bool { && is_hex(&bytes[..8]) && is_hex(&bytes[9..])) } - -#[cfg(test)] -mod tests { - use super::*; - use std::sync::OnceLock; - use std::time::Duration; - - fn certificate_der() -> &'static [u8] { - static CERTIFICATE: OnceLock> = OnceLock::new(); - CERTIFICATE - .get_or_init(|| { - rcgen::generate_simple_self_signed(vec!["example.com".to_string()]) - .unwrap() - .serialize_der() - .unwrap() - }) - .as_slice() - } - - fn expired_certificate_der() -> Vec { - let mut params = rcgen::CertificateParams::new(vec!["example.com".to_string()]); - params.not_after = rcgen::date_time_ymd(2000, 1, 1); - rcgen::Certificate::from_params(params) - .unwrap() - .serialize_der() - .unwrap() - } - - fn profile( - platform: &str, - bundle_id: &str, - expiration: SystemTime, - devices: &[&str], - certificates: &[&[u8]], - extra_entitlements: &[(&str, Value)], - ) -> MobileProvision { - let mut entitlements = Dictionary::new(); - entitlements.insert( - "application-identifier".to_string(), - Value::String(format!("L988J7YMK5.{bundle_id}")), - ); - entitlements.insert( - "com.apple.developer.team-identifier".to_string(), - Value::String("L988J7YMK5".to_string()), - ); - for (key, value) in extra_entitlements { - entitlements.insert((*key).to_string(), value.clone()); - } - - let mut root = Dictionary::new(); - root.insert( - "Entitlements".to_string(), - Value::Dictionary(entitlements), - ); - root.insert( - "ExpirationDate".to_string(), - Value::Date(Date::from(expiration)), - ); - root.insert( - "Platform".to_string(), - Value::Array(vec![Value::String(platform.to_string())]), - ); - root.insert( - "ProvisionedDevices".to_string(), - Value::Array( - devices - .iter() - .map(|device| Value::String((*device).to_string())) - .collect(), - ), - ); - root.insert( - "DeveloperCertificates".to_string(), - Value::Array( - certificates - .iter() - .map(|certificate| Value::Data((*certificate).to_vec())) - .collect(), - ), - ); - - let mut data = Vec::new(); - Value::Dictionary(root).to_writer_xml(&mut data).unwrap(); - MobileProvision::load_with_bytes(data).unwrap() - } - - fn valid_profile() -> MobileProvision { - profile( - "tvOS", - "com.example.tv", - SystemTime::now() + Duration::from_secs(3600), - &["00008110-000C25540CD1801E"], - &[certificate_der()], - &[("get-task-allow", Value::Boolean(true))], - ) - } - - #[test] - fn accepts_matching_profile() { - let requested = Dictionary::from_iter([( - "get-task-allow".to_string(), - Value::Boolean(true), - )]); - valid_profile() - .validate_for( - DeveloperPlatform::Tvos, - "com.example.tv", - Some("00008110-000C25540CD1801E"), - Some(certificate_der()), - Some(&requested), - ) - .unwrap(); - } - - #[test] - fn rejects_ios_profile_for_tvos() { - let profile = profile( - "iOS", - "com.example.tv", - SystemTime::now() + Duration::from_secs(3600), - &["00008110-000C25540CD1801E"], - &[certificate_der()], - &[], - ); - let error = profile - .validate_for( - DeveloperPlatform::Tvos, - "com.example.tv", - Some("00008110-000C25540CD1801E"), - Some(certificate_der()), - None, - ) - .unwrap_err(); - assert!(error.to_string().contains("tvOS")); - } - - #[test] - fn rejects_missing_device() { - let error = valid_profile() - .validate_for( - DeveloperPlatform::Tvos, - "com.example.tv", - Some("00008110-000C25540CD1801F"), - Some(certificate_der()), - None, - ) - .unwrap_err(); - assert!(error.to_string().contains("UDID")); - } - - #[test] - fn rejects_expired_profile() { - let profile = profile( - "tvOS", - "com.example.tv", - SystemTime::now() - Duration::from_secs(1), - &["00008110-000C25540CD1801E"], - &[certificate_der()], - &[], - ); - let error = profile - .validate_for( - DeveloperPlatform::Tvos, - "com.example.tv", - Some("00008110-000C25540CD1801E"), - Some(certificate_der()), - None, - ) - .unwrap_err(); - assert!(error.to_string().contains("expired")); - } - - #[test] - fn rejects_expired_certificate() { - let certificate = expired_certificate_der(); - let profile = profile( - "tvOS", - "com.example.tv", - SystemTime::now() + Duration::from_secs(3600), - &["00008110-000C25540CD1801E"], - &[certificate.as_slice()], - &[], - ); - let error = profile - .validate_for( - DeveloperPlatform::Tvos, - "com.example.tv", - Some("00008110-000C25540CD1801E"), - Some(certificate.as_slice()), - None, - ) - .unwrap_err(); - assert!(error.to_string().contains("expired")); - } - - #[test] - fn rejects_missing_certificate() { - let error = valid_profile() - .validate_for( - DeveloperPlatform::Tvos, - "com.example.tv", - Some("00008110-000C25540CD1801E"), - Some(b"other"), - None, - ) - .unwrap_err(); - assert!(error.to_string().contains("certificate")); - } - - #[test] - fn rejects_mismatched_bundle_id() { - let error = valid_profile() - .validate_for( - DeveloperPlatform::Tvos, - "com.example.other", - Some("00008110-000C25540CD1801E"), - Some(certificate_der()), - None, - ) - .unwrap_err(); - assert!(error.to_string().contains("application identifier")); - } - - #[test] - fn rejects_ungranted_entitlement() { - let requested = Dictionary::from_iter([( - "com.apple.developer.networking.wifi-info".to_string(), - Value::Boolean(true), - )]); - let error = valid_profile() - .validate_for( - DeveloperPlatform::Tvos, - "com.example.tv", - Some("00008110-000C25540CD1801E"), - Some(certificate_der()), - Some(&requested), - ) - .unwrap_err(); - assert!(error.to_string().contains("entitlement")); - } - - #[test] - fn accepts_profile_entitlements_as_final_entitlements() { - let profile = valid_profile(); - profile - .validate_final_entitlements( - DeveloperPlatform::Tvos, - "com.example.tv", - Some("00008110-000C25540CD1801E"), - Some(certificate_der()), - profile.entitlements(), - ) - .unwrap(); - } - - #[test] - fn rejects_final_application_identifier_mismatch() { - let profile = valid_profile(); - let mut entitlements = profile.entitlements().clone(); - entitlements.insert( - "application-identifier".to_string(), - Value::String("L988J7YMK5.com.example.other".to_string()), - ); - - let error = profile - .validate_final_entitlements( - DeveloperPlatform::Tvos, - "com.example.tv", - Some("00008110-000C25540CD1801E"), - Some(certificate_der()), - &entitlements, - ) - .unwrap_err(); - assert!(error.to_string().contains("application identifier")); - } - - #[test] - fn rejects_final_team_identifier_mismatch() { - let profile = valid_profile(); - let mut entitlements = profile.entitlements().clone(); - entitlements.insert( - "com.apple.developer.team-identifier".to_string(), - Value::String("OTHERTEAM1".to_string()), - ); - - let error = profile - .validate_final_entitlements( - DeveloperPlatform::Tvos, - "com.example.tv", - Some("00008110-000C25540CD1801E"), - Some(certificate_der()), - &entitlements, - ) - .unwrap_err(); - assert!(error.to_string().contains("team identifier")); - } - - #[test] - fn rejects_final_entitlement_not_granted_by_profile() { - let profile = valid_profile(); - let mut entitlements = profile.entitlements().clone(); - entitlements.insert( - "com.apple.developer.networking.wifi-info".to_string(), - Value::Boolean(true), - ); - - let error = profile - .validate_final_entitlements( - DeveloperPlatform::Tvos, - "com.example.tv", - Some("00008110-000C25540CD1801E"), - Some(certificate_der()), - &entitlements, - ) - .unwrap_err(); - assert!(error.to_string().contains("entitlement")); - } - - #[test] - fn wildcard_application_identifier_grants_final_bundle_id() { - let profile = profile( - "tvOS", - "*", - SystemTime::now() + Duration::from_secs(3600), - &[], - &[certificate_der()], - &[], - ); - assert!(application_identifier_grants( - "L988J7YMK5.*", - "com.example.tv" - )); - assert!(profile - .validate_for( - DeveloperPlatform::Tvos, - "com.example.tv", - None, - Some(certificate_der()), - None, - ) - .is_ok()); - } - - #[test] - fn bare_application_identifier_is_not_split_as_a_team_prefix() { - assert!(application_identifier_grants( - "com.example.tv", - "com.example.tv" - )); - } - - #[test] - fn wildcard_application_identifier_requires_a_bundle_component_boundary() { - assert!(application_identifier_grants( - "L988J7YMK5.com.example.*", - "com.example.tv" - )); - assert!(!application_identifier_grants( - "L988J7YMK5.com.example.*", - "com.examples.tv" - )); - } -} diff --git a/crates/plume_utils/Cargo.toml b/crates/plume_utils/Cargo.toml index 224531a6..2641a79e 100644 --- a/crates/plume_utils/Cargo.toml +++ b/crates/plume_utils/Cargo.toml @@ -26,6 +26,3 @@ plume_store = { path = "../plume_store" } decompress = { path = "../../3rdparty/decompress" } mdns-sd = "0.11" - -[dev-dependencies] -env_logger.workspace = true diff --git a/crates/plume_utils/src/device.rs b/crates/plume_utils/src/device.rs index fcd4ef8b..7e8e03d3 100644 --- a/crates/plume_utils/src/device.rs +++ b/crates/plume_utils/src/device.rs @@ -1,9 +1,6 @@ use std::fmt; use std::future::Future; -#[cfg(target_os = "macos")] -use std::io::Write; use std::path::{Component, Path, PathBuf}; -#[cfg(not(target_os = "macos"))] use std::pin::Pin; use std::time::Duration; #[cfg(unix)] @@ -19,7 +16,6 @@ use idevice::remote_pairing::{ connect_tls_psk_tunnel_native, RemotePairingClient, RpPairingFile, RpPairingSocket, RpPairingSocketProvider, }; -#[cfg(not(target_os = "macos"))] use idevice::remote_pairing::errors::RemotePairingError; use idevice::rsd::RsdHandshake; use idevice::tcp::adapter::Adapter; @@ -32,20 +28,17 @@ use plume_core::{MobileProvision, developer::DeveloperPlatform}; use crate::Error; use crate::discovery::{DeviceDiscovery, DeviceType, PlatformDiscovery, REMOTEPAIRING_SERVICE}; use crate::options::SignerAppReal; -#[cfg(not(target_os = "macos"))] use crate::pairing::{PairingBackend, PairingFailure, PairingStage, ensure_pairing}; use idevice::afc::opcode::AfcFopenMode; use idevice::house_arrest::HouseArrestClient; use idevice::usbmuxd::UsbmuxdConnection; use plist::Value; -#[cfg(not(target_os = "macos"))] use serde::Serialize; pub const CONNECTION_LABEL: &str = "plume_info"; pub const INSTALLATION_LABEL: &str = "plume_install"; pub const HOUSE_ARREST_LABEL: &str = "plume_house_arrest"; -#[cfg(not(target_os = "macos"))] impl<'a, R: idevice::remote_pairing::RpPairingSocketProvider> PairingBackend for RemotePairingClient<'a, R> { @@ -78,14 +71,12 @@ impl<'a, R: idevice::remote_pairing::RpPairingSocketProvider> PairingBackend } } -#[cfg(not(target_os = "macos"))] #[derive(Debug)] struct SequencedTvosPairingSocket { inner: RpPairingSocket, sequence_offset: usize, } -#[cfg(not(target_os = "macos"))] impl SequencedTvosPairingSocket { fn new(inner: RpPairingSocket, sequence_offset: usize) -> Self { Self { @@ -95,7 +86,6 @@ impl SequencedTvosPairingSocket { } } -#[cfg(not(target_os = "macos"))] impl RpPairingSocketProvider for SequencedTvosPairingSocket { fn send_plain( &mut self, @@ -129,12 +119,10 @@ impl RpPairingSocketProvider for SequencedTvosPairingSocket { } } -#[cfg(not(target_os = "macos"))] struct TvosPairingBackend<'a> { client: RemotePairingClient<'a, SequencedTvosPairingSocket>, } -#[cfg(not(target_os = "macos"))] impl<'a> PairingBackend for TvosPairingBackend<'a> { async fn verify(&mut self) -> Result<(), PairingFailure> { self.client @@ -167,10 +155,8 @@ impl<'a> PairingBackend for TvosPairingBackend<'a> { } } -#[cfg(not(target_os = "macos"))] const TVOS_RP_PAIRING_WIRE_PROTOCOL_VERSION: i64 = 26; -#[cfg(not(target_os = "macos"))] async fn begin_tvos_pairing( stream: tokio::net::TcpStream, ) -> Result { @@ -193,114 +179,6 @@ async fn begin_tvos_pairing( Ok(SequencedTvosPairingSocket::new(socket, 1)) } -#[cfg(target_os = "macos")] -async fn pair_tvos_with_devicectl( - device_name: &str, - pin_provider: F, - address: Option<(std::net::IpAddr, u16)>, - cache_dir: &Path, - cache_path: &Path, -) -> Result -where - F: Fn() -> Fut, - Fut: Future, -{ - let (pin_sender, pin_receiver) = std::sync::mpsc::sync_channel::(1); - let device_name = device_name.to_string(); - let mut pairing_task = tokio::task::spawn_blocking(move || -> Result<(), String> { - let mut child = std::process::Command::new("xcrun") - .args([ - "devicectl", - "manage", - "pair", - "--device", - device_name.as_str(), - "--timeout", - "180", - ]) - .stdin(std::process::Stdio::piped()) - .stdout(std::process::Stdio::null()) - .stderr(std::process::Stdio::null()) - .spawn() - .map_err(|error| format!("Could not start xcrun devicectl: {error}"))?; - - let pin = loop { - match pin_receiver.recv_timeout(Duration::from_millis(100)) { - Ok(pin) => break pin, - Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { - if let Some(status) = child - .try_wait() - .map_err(|error| format!("Could not check xcrun devicectl: {error}"))? - { - if status.success() { - return Ok(()); - } - return Err(format!( - "xcrun devicectl pairing failed with status {status}" - )); - } - } - Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { - return Err("Apple TV pairing PIN was not provided".to_string()); - } - } - }; - if pin.is_empty() { - let _ = child.kill(); - return Err("Apple TV pairing was cancelled".to_string()); - } - if pin.len() != 6 || !pin.bytes().all(|byte| byte.is_ascii_digit()) { - let _ = child.kill(); - return Err("Apple TV pairing PIN must contain exactly six digits".to_string()); - } - - let mut stdin = child - .stdin - .take() - .ok_or_else(|| "xcrun devicectl did not expose standard input".to_string())?; - stdin - .write_all(format!("{pin}\n").as_bytes()) - .map_err(|error| format!("Could not provide the Apple TV pairing PIN: {error}"))?; - drop(stdin); - - let status = child - .wait() - .map_err(|error| format!("Could not wait for xcrun devicectl: {error}"))?; - if status.success() { - Ok(()) - } else { - Err(format!("xcrun devicectl pairing failed with status {status}")) - } - }); - - let pin_future = pin_provider(); - tokio::pin!(pin_future); - let pairing_result = tokio::select! { - result = &mut pairing_task => result, - pin = &mut pin_future => { - if pin_sender.send(pin).is_err() { - return Err(Error::Other( - "xcrun devicectl exited before the Apple TV pairing PIN was provided".to_string(), - )); - } - pairing_task.await - } - }; - pairing_result - .map_err(|error| Error::Other(format!("Apple TV pairing task failed: {error}")))? - .map_err(Error::Other)?; - - try_import_external_pairing_at(address, cache_dir, cache_path) - .await? - .ok_or_else(|| { - Error::Other( - "Apple TV pairing completed in Xcode but its pairing record could not be imported" - .to_string(), - ) - }) -} - -#[cfg(not(target_os = "macos"))] fn tvos_pairing_handshake_request(correlation_identifier: &str) -> Value { let mut host_options = plist::Dictionary::new(); host_options.insert("attemptPairVerify".to_string(), Value::Boolean(false)); @@ -339,7 +217,6 @@ fn tvos_pairing_handshake_request(correlation_identifier: &str) -> Value { Value::Dictionary(root) } -#[cfg(not(target_os = "macos"))] fn tvos_pairing_handshake_allows_pair_setup(response: &Value) -> bool { response .as_dictionary() @@ -1008,71 +885,56 @@ impl Device { } } - #[cfg(target_os = "macos")] - { - let pairing_file = pair_tvos_with_devicectl( - &self.name, - pin_provider, - self.reconnect_address.or(self.pairing_address), - &cache_dir, - &cache_path, - ) - .await?; - log::info!("tvOS pairing: imported the native Xcode pairing record"); - return self.finish_tvos_pairing(pairing_file).await; - } - - #[cfg(not(target_os = "macos"))] { - let (ip, port) = self.pairing_address.ok_or_else(|| { - Error::Other( - "Apple TV pairing requires its manual-pairing service. On the Apple TV, open Settings \ - > Remotes and Devices > Remote App and Devices and wait for \"Waiting to Pair...\", \ - then scan again." - .to_string(), - ) - })?; + let (ip, port) = self.pairing_address.ok_or_else(|| { + Error::Other( + "Apple TV pairing requires its manual-pairing service. On the Apple TV, open Settings \ + > Remotes and Devices > Remote App and Devices and wait for \"Waiting to Pair...\", \ + then scan again." + .to_string(), + ) + })?; - let addr = std::net::SocketAddr::new(ip, port); - log::info!("tvOS pairing: connecting to {addr}"); - let stream = tokio::net::TcpStream::connect(addr).await.map_err(|e| { - Error::Other(format!( - "Failed to connect to Apple TV at {addr}: {e}. The manual-pairing port changes \ - each time the Apple TV re-advertises, so a stale scan result will not connect - \ - scan again immediately before pairing." - )) - })?; - log::info!("tvOS pairing: TCP connected to {addr}, starting RPPairing handshake"); + let addr = std::net::SocketAddr::new(ip, port); + log::info!("tvOS pairing: connecting to {addr}"); + let stream = tokio::net::TcpStream::connect(addr).await.map_err(|e| { + Error::Other(format!( + "Failed to connect to Apple TV at {addr}: {e}. The manual-pairing port changes \ + each time the Apple TV re-advertises, so a stale scan result will not connect - \ + scan again immediately before pairing." + )) + })?; + log::info!("tvOS pairing: TCP connected to {addr}, starting RPPairing handshake"); - let local_hostname = local_remote_pairing_hostname().ok_or_else(|| { - Error::Other("Could not determine the Mac hostname for Apple TV pairing".to_string()) - })?; - let sending_host = local_remote_pairing_host().unwrap_or_else(|| local_hostname.clone()); - let mut pairing_file = RpPairingFile::generate(&local_hostname); - let conn = begin_tvos_pairing(stream).await?; - let pairing_client = RemotePairingClient::new(conn, &sending_host, &mut pairing_file); - let mut pairing_backend = TvosPairingBackend { - client: pairing_client, - }; - let stage = ensure_pairing( - &mut pairing_backend, - false, - true, - false, - pin_provider, - ) - .await - .map_err(pairing_failure_to_error)?; - if stage != PairingStage::Paired { - return Err(Error::Other( - "Apple TV pairing did not complete a new pairing".to_string(), - )); - } - log::info!("tvOS pairing: handshake succeeded, caching pairing file"); + let local_hostname = local_remote_pairing_hostname().ok_or_else(|| { + Error::Other("Could not determine the host name for Apple TV pairing".to_string()) + })?; + let mut pairing_file = RpPairingFile::generate(&local_hostname); + let conn = begin_tvos_pairing(stream).await?; + let pairing_client = + RemotePairingClient::new(conn, &local_hostname, &mut pairing_file); + let mut pairing_backend = TvosPairingBackend { + client: pairing_client, + }; + let stage = ensure_pairing( + &mut pairing_backend, + false, + true, + false, + pin_provider, + ) + .await + .map_err(pairing_failure_to_error)?; + if stage != PairingStage::Paired { + return Err(Error::Other( + "Apple TV pairing did not complete a new pairing".to_string(), + )); + } + log::info!("tvOS pairing: handshake succeeded, caching pairing file"); - write_pairing_file(&pairing_file, &cache_dir, &cache_path).await?; + write_pairing_file(&pairing_file, &cache_dir, &cache_path).await?; - self.finish_tvos_pairing(pairing_file).await + self.finish_tvos_pairing(pairing_file).await } } @@ -1170,53 +1032,6 @@ impl Device { pub async fn forget_tvos_pairing(&self, cache_dir: PathBuf) -> Result<(), Error> { let path = self.pairing_cache_path(&cache_dir)?; - #[cfg(target_os = "macos")] - if self.has_native_tvos_pairing() { - let selector = if crate::is_valid_device_udid(&self.udid) { - self.udid.clone() - } else if let Some(identity) = self - .pairing_identity - .as_deref() - .filter(|identity| !identity.is_empty()) - { - identity.to_string() - } else { - self.name.clone() - }; - let output = std::process::Command::new("xcrun") - .args([ - "devicectl", - "manage", - "unpair", - "--device", - selector.as_str(), - "--timeout", - "45", - ]) - .output() - .map_err(|error| { - Error::Other(format!("Could not run xcrun devicectl unpair: {error}")) - })?; - if !output.status.success() { - let details = String::from_utf8_lossy(&output.stderr) - .trim() - .to_string(); - let details = if details.is_empty() { - String::from_utf8_lossy(&output.stdout).trim().to_string() - } else { - details - }; - return Err(Error::Other(format!( - "Could not remove the native Apple TV pairing: {}", - if details.is_empty() { - output.status.to_string() - } else { - details - } - ))); - } - } - match tokio::fs::remove_file(&path).await { Ok(()) => Ok(()), Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), @@ -1790,7 +1605,6 @@ fn pairing_action( } } -#[cfg(not(target_os = "macos"))] fn pairing_failure_to_error(failure: PairingFailure) -> Error { match failure { PairingFailure::Cancelled => Error::Other("Apple TV pairing was cancelled".to_string()), @@ -1829,19 +1643,6 @@ fn local_remote_pairing_hostname() -> Option { Some(hostname) } -#[cfg(not(target_os = "macos"))] -fn local_remote_pairing_host() -> Option { - std::process::Command::new("scutil") - .args(["--get", "ComputerName"]) - .output() - .ok() - .filter(|output| output.status.success()) - .and_then(|output| String::from_utf8(output.stdout).ok()) - .map(|hostname| hostname.trim().to_string()) - .filter(|hostname| !hostname.is_empty()) - .or_else(local_remote_pairing_hostname) -} - fn local_remote_pairing_identifier() -> Option { let hostname = local_remote_pairing_hostname()?; Some( @@ -2004,702 +1805,3 @@ pub async fn install_app_mac(app_path: &PathBuf) -> Result<(), Error> { pub async fn install_app_mac(_app_path: &PathBuf) -> Result<(), Error> { Ok(()) } - -#[cfg(test)] -mod tests { - use super::*; - use std::collections::HashMap; - - fn real_rsd_properties() -> HashMap { - let mut props = HashMap::new(); - props.insert( - "UniqueDeviceID".to_string(), - plist::Value::String("00008110-001E60481AD9401E".to_string()), - ); - props.insert( - "ProductType".to_string(), - plist::Value::String("AppleTV14,1".to_string()), - ); - props.insert( - "DeviceClass".to_string(), - plist::Value::String("AppleTV".to_string()), - ); - props.insert( - "OSVersion".to_string(), - plist::Value::String("26.5".to_string()), - ); - props.insert( - "HumanReadableProductVersionString".to_string(), - plist::Value::String("26.5".to_string()), - ); - props.insert( - "SerialNumber".to_string(), - plist::Value::String("C6FCY44V73".to_string()), - ); - props.insert( - "HWModel".to_string(), - plist::Value::String("J255AP".to_string()), - ); - props.insert( - "ProductName".to_string(), - plist::Value::String("Apple TVOS".to_string()), - ); - props.insert( - "BuildVersion".to_string(), - plist::Value::String("23L471".to_string()), - ); - props - } - - #[test] - fn from_rsd_properties_reads_real_device_fields() { - let info = TvosDeviceInfo::from_rsd_properties(&real_rsd_properties()); - assert_eq!(info.udid.as_deref(), Some("00008110-001E60481AD9401E")); - assert_eq!(info.product_type.as_deref(), Some("AppleTV14,1")); - assert_eq!(info.device_class.as_deref(), Some("AppleTV")); - assert_eq!(info.os_version.as_deref(), Some("26.5")); - assert_eq!(info.serial_number.as_deref(), Some("C6FCY44V73")); - } - - #[test] - fn from_rsd_properties_empty_map_yields_default() { - let info = TvosDeviceInfo::from_rsd_properties(&HashMap::new()); - assert_eq!(info, TvosDeviceInfo::default()); - } - - #[test] - fn from_rsd_properties_non_string_value_yields_none() { - let mut props = HashMap::new(); - props.insert( - "UniqueDeviceID".to_string(), - plist::Value::Integer(12345.into()), - ); - - let info = TvosDeviceInfo::from_rsd_properties(&props); - assert_eq!(info.udid, None); - } - - #[test] - fn from_rsd_properties_falls_back_to_human_readable_version() { - let mut props = HashMap::new(); - props.insert( - "HumanReadableProductVersionString".to_string(), - plist::Value::String("17.1".to_string()), - ); - - let info = TvosDeviceInfo::from_rsd_properties(&props); - assert_eq!(info.os_version.as_deref(), Some("17.1")); - } - - #[test] - fn from_rsd_properties_prefers_os_version_over_human_readable_when_both_present() { - let mut props = HashMap::new(); - props.insert( - "OSVersion".to_string(), - plist::Value::String("26.5".to_string()), - ); - props.insert( - "HumanReadableProductVersionString".to_string(), - plist::Value::String("26.5 (23L471)".to_string()), - ); - - let info = TvosDeviceInfo::from_rsd_properties(&props); - assert_eq!(info.os_version.as_deref(), Some("26.5")); - } - - #[test] - fn display_keeps_tvos_platform_out_of_connection_brackets() { - let mut device = Device::new_tvos( - "Apple TV".to_string(), - "Apple-TV".to_string(), - "192.0.2.10".parse().unwrap(), - None, - Some(49152), - std::env::temp_dir(), - ); - device.udid = "00008110-000C25540CD1801E".to_string(); - - assert_eq!( - device.to_string(), - "[WiFi] Apple TV (tvOS) [00008110…801E]" - ); - } - - #[test] - fn new_tvos_leaves_udid_empty_and_sets_pairing_identity() { - let d = Device::new_tvos( - "Apple TV".to_string(), - "Apple-TV".to_string(), - std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), - Some(1234), - None, - std::env::temp_dir(), - ); - assert!(d.udid.is_empty()); - assert_eq!(d.pairing_identity.as_deref(), Some("Apple-TV")); - } - - #[test] - fn new_tvos_stores_pairing_cache_dir() { - let cache_dir = std::env::temp_dir().join("plume_test_new_tvos_cache_dir"); - let d = Device::new_tvos( - "Apple TV".to_string(), - "Apple-TV".to_string(), - std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), - Some(1234), - None, - cache_dir.clone(), - ); - assert_eq!(d.pairing_cache_dir, Some(cache_dir)); - } - - fn stub_device() -> Device { - Device { - name: "Test Device".to_string(), - udid: "00008110-000C25540CD1801E".to_string(), - product_type: None, - device_class: None, - os_version: None, - serial_number: None, - device_id: 0, - usbmuxd_device: None, - is_mac: false, - pairing_address: None, - reconnect_address: None, - pairing_identity: None, - pairing_cache_dir: None, - core_device_authenticated: false, - } - } - - fn stub_tvos_device() -> Device { - let mut d = stub_device(); - d.pairing_identity = Some("stable-key".to_string()); - d - } - - #[test] - fn apply_tvos_info_none_udid_leaves_existing_udid_unchanged() { - let mut device = stub_tvos_device(); - let info = TvosDeviceInfo { - udid: None, - ..Default::default() - }; - device.apply_tvos_info(&info); - assert_eq!(device.udid, "00008110-000C25540CD1801E"); - } - - #[test] - fn apply_tvos_info_some_udid_overwrites_existing_udid() { - let mut device = stub_tvos_device(); - let info = TvosDeviceInfo { - udid: Some("00008110-000C25540CD1801F".to_string()), - ..Default::default() - }; - device.apply_tvos_info(&info); - assert_eq!(device.udid, "00008110-000C25540CD1801F"); - } - - #[test] - fn apply_tvos_info_empty_udid_leaves_existing_udid_unchanged() { - let mut device = stub_tvos_device(); - let info = TvosDeviceInfo { - udid: Some(String::new()), - ..Default::default() - }; - device.apply_tvos_info(&info); - assert_eq!(device.udid, "00008110-000C25540CD1801E"); - } - - #[test] - fn apply_tvos_info_no_op_when_device_has_no_pairing_identity() { - let mut device = stub_device(); - let info = TvosDeviceInfo { - udid: Some("00008110-000C25540CD1801F".to_string()), - ..Default::default() - }; - device.apply_tvos_info(&info); - assert_eq!(device.udid, "00008110-000C25540CD1801E"); - } - - #[test] - fn is_tvos_true_for_network_paired_device() { - let device = stub_tvos_device(); - assert!(device.is_tvos()); - } - - #[test] - fn is_tvos_false_for_usb_device() { - let device = stub_device(); - assert!(!device.is_tvos()); - } - - #[test] - fn new_tvos_device_reports_is_tvos() { - let d = Device::new_tvos( - "Apple TV".to_string(), - "Apple-TV".to_string(), - std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), - Some(1234), - None, - std::env::temp_dir(), - ); - assert!(d.is_tvos()); - assert_eq!(d.transport(), DeviceTransport::RemotePairing); - } - - #[test] - fn authenticated_rsd_metadata_promotes_remote_pairing_to_core_device() { - let mut device = Device::new_tvos( - "Apple TV".to_string(), - "Apple-TV".to_string(), - std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), - Some(1234), - Some(1235), - std::env::temp_dir(), - ); - - device.apply_tvos_info(&TvosDeviceInfo { - udid: Some("00008110-000C25540CD1801E".to_string()), - ..Default::default() - }); - - assert_eq!(device.transport(), DeviceTransport::CoreDevice); - assert!(device.is_network()); - } - - #[test] - fn core_device_transport_exposes_authenticated_device_kind() { - let mut device = Device::new_tvos( - "Apple TV".to_string(), - "Apple-TV".to_string(), - std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), - Some(1234), - Some(1235), - std::env::temp_dir(), - ); - device.apply_tvos_info(&TvosDeviceInfo { - udid: Some("00008110-000C25540CD1801E".to_string()), - ..Default::default() - }); - - let transport = device - .core_device_transport(std::env::temp_dir()) - .unwrap(); - - assert_eq!(transport.kind(), DeviceTransport::CoreDevice); - } - - #[test] - fn transport_identifies_usb_and_unavailable_devices() { - let mut usb = stub_device(); - usb.usbmuxd_device = Some(UsbmuxdDevice { - connection_type: Connection::Usb, - udid: usb.udid.clone(), - device_id: usb.device_id, - }); - assert_eq!(usb.transport(), DeviceTransport::Usbmuxd); - - let mut unavailable = usb; - unavailable.usbmuxd_device = None; - assert_eq!(unavailable.transport(), DeviceTransport::Unavailable); - } - - #[test] - fn pairing_cache_path_prefers_pairing_identity_over_udid() { - let device = stub_tvos_device(); - let cache_dir = Path::new("/cache"); - assert_eq!( - device.pairing_cache_path(cache_dir).unwrap(), - cache_dir.join("plume_stable-key.plist") - ); - } - - #[test] - fn pairing_cache_path_falls_back_to_udid_when_no_pairing_identity() { - let device = stub_device(); - let cache_dir = Path::new("/cache"); - assert_eq!( - device.pairing_cache_path(cache_dir).unwrap(), - cache_dir.join("plume_00008110-000C25540CD1801E.plist") - ); - } - - #[test] - fn pairing_cache_path_rejects_empty_key() { - let mut device = stub_device(); - device.udid = String::new(); - let cache_dir = Path::new("/cache"); - assert!(device.pairing_cache_path(cache_dir).is_err()); - } - - #[test] - fn pairing_cache_path_rejects_dots_only_key() { - let mut device = stub_device(); - device.pairing_identity = Some("..".to_string()); - let cache_dir = Path::new("/cache"); - assert!(device.pairing_cache_path(cache_dir).is_err()); - } - - #[test] - fn pairing_cache_path_rejects_key_with_path_separator() { - let mut device = stub_device(); - device.pairing_identity = Some("../evil".to_string()); - let cache_dir = Path::new("/cache"); - assert!(device.pairing_cache_path(cache_dir).is_err()); - } - - fn unique_temp_dir(tag: &str) -> PathBuf { - std::env::temp_dir().join(format!( - "plume_test_{tag}_{}", - uuid::Uuid::new_v4().simple() - )) - } - - #[test] - fn has_cached_pairing_file_reports_presence_and_absence() { - let cache_dir = unique_temp_dir("has_cached_pairing_file"); - std::fs::create_dir_all(&cache_dir).expect("create scratch cache dir"); - - let mut device = stub_tvos_device(); - device.pairing_identity = Some("has-cache-test".to_string()); - - assert!(!device.has_cached_pairing_file(&cache_dir)); - - let cache_path = device.pairing_cache_path(&cache_dir).unwrap(); - std::fs::write(&cache_path, b"stub").unwrap(); - - assert!(device.has_cached_pairing_file(&cache_dir)); - - std::fs::remove_dir_all(&cache_dir).ok(); - } - - #[test] - fn external_pairing_record_import_supports_native_shape() { - let source = RpPairingFile::generate("external-record-test"); - let mut native = plist::Dictionary::new(); - native.insert( - "publicKey".to_string(), - plist::Value::Data(source.public_key_bytes()), - ); - native.insert( - "privateKey".to_string(), - plist::Value::Data(source.private_key_bytes()), - ); - native.insert( - "identifier".to_string(), - plist::Value::String(source.identifier.clone()), - ); - native.insert("irk".to_string(), plist::Value::Data(vec![7; 16])); - - let mut native_bytes = Vec::new(); - plist::to_writer_xml(&mut native_bytes, &native).unwrap(); - let imported_native = external_pairing_file_from_bytes(&native_bytes).unwrap(); - assert_eq!(imported_native.identifier, source.identifier); - assert_eq!(imported_native.public_key_bytes(), source.public_key_bytes()); - assert_eq!(imported_native.alt_irk(), Some(&[7; 16][..])); - - } - - #[test] - fn native_pairing_candidates_combine_xcode_host_identity_with_each_peer() { - let source = RpPairingFile::generate("native-record-test"); - let mut host = plist::Dictionary::new(); - host.insert( - "publicKey".to_string(), - plist::Value::Data(source.public_key_bytes()), - ); - host.insert( - "privateKey".to_string(), - plist::Value::Data(source.private_key_bytes()), - ); - host.insert( - "identifier".to_string(), - plist::Value::String(source.identifier.clone()), - ); - host.insert("irk".to_string(), plist::Value::Data(vec![1; 16])); - - let mut host_bytes = Vec::new(); - plist::to_writer_xml(&mut host_bytes, &host).unwrap(); - - let mut peer_a = plist::Dictionary::new(); - peer_a.insert("irk".to_string(), plist::Value::Data(vec![2; 16])); - let mut peer_b = plist::Dictionary::new(); - peer_b.insert("irk".to_string(), plist::Value::Data(vec![3; 16])); - let mut peer_a_bytes = Vec::new(); - let mut peer_b_bytes = Vec::new(); - plist::to_writer_xml(&mut peer_a_bytes, &peer_a).unwrap(); - plist::to_writer_xml(&mut peer_b_bytes, &peer_b).unwrap(); - - let candidates = native_pairing_candidates_from_bytes( - &host_bytes, - &[peer_a_bytes.as_slice(), peer_b_bytes.as_slice()], - ) - .unwrap(); - - assert_eq!(candidates.len(), 3); - assert_eq!(candidates[0].alt_irk(), None); - assert_eq!(candidates[1].alt_irk(), Some(&[2; 16][..])); - assert_eq!(candidates[2].alt_irk(), Some(&[3; 16][..])); - assert!(candidates - .iter() - .all(|candidate| candidate.identifier == source.identifier)); - } - - #[test] - fn pairing_action_covers_first_pairing_saved_reconnect_stale_and_disappeared_services() { - assert_eq!( - pairing_action(false, true, false), - Ok(PairingAction::FirstPairing) - ); - assert_eq!( - pairing_action(true, false, true), - Ok(PairingAction::Reconnect) - ); - assert!(pairing_action(true, false, false) - .unwrap_err() - .contains("stale")); - assert!(pairing_action(false, false, true) - .unwrap_err() - .contains("manual-pairing")); - } - - #[test] - fn tvos_listener_request_contains_coredevice_connection_metadata() { - let request = tvos_create_listener_request(&[0, 1, 2, 255]); - let listener = request - .as_dictionary() - .and_then(|value| value.get("request")) - .and_then(Value::as_dictionary) - .and_then(|value| value.get("_0")) - .and_then(Value::as_dictionary) - .and_then(|value| value.get("createListener")) - .and_then(Value::as_dictionary) - .expect("createListener request"); - - assert_eq!( - listener - .get("key") - .and_then(Value::as_string) - .unwrap(), - "AAEC/w==" - ); - assert_eq!( - listener - .get("transportProtocolType") - .and_then(Value::as_string), - Some("tcp") - ); - - let peers = listener - .get("peerConnectionsInfo") - .and_then(Value::as_array) - .expect("peer connection metadata"); - assert_eq!(peers.len(), 1); - let peer = peers[0].as_dictionary().expect("peer connection"); - assert_eq!( - peer.get("owningProcessName").and_then(Value::as_string), - Some("CoreDeviceService") - ); - assert_eq!( - peer.get("owningPID").and_then(Value::as_unsigned_integer), - Some(std::process::id() as u64) - ); - } - - #[cfg(unix)] - #[tokio::test] - async fn pairing_cache_uses_restrictive_permissions() { - let cache_dir = unique_temp_dir("pairing_permissions"); - let cache_path = cache_dir.join("plume_permissions.plist"); - let pairing_file = RpPairingFile::generate("permissions-test"); - - write_pairing_file(&pairing_file, &cache_dir, &cache_path) - .await - .unwrap(); - - assert_eq!( - std::fs::metadata(&cache_dir) - .unwrap() - .permissions() - .mode() - & 0o777, - 0o700 - ); - assert_eq!( - std::fs::metadata(&cache_path) - .unwrap() - .permissions() - .mode() - & 0o777, - 0o600 - ); - - std::fs::remove_dir_all(&cache_dir).unwrap(); - } - - #[test] - fn is_network_follows_the_transport_install_app_picks() { - let mut device = stub_device(); - assert!( - !device.is_network(), - "a device with no transport at all is not a network device" - ); - - device.reconnect_address = - Some((std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), 49151)); - assert!(device.is_network(), "a reconnect address makes it network"); - - device.reconnect_address = None; - device.pairing_address = Some((std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), 49152)); - assert!(device.is_network(), "a pairing address makes it network"); - - let mut mac = stub_device(); - mac.is_mac = true; - assert!( - !mac.is_network(), - "the local Mac is not reached over a tunnel" - ); - } - - async fn noop_callback(_progress: i32) {} - - #[tokio::test] - async fn install_app_with_no_transport_names_the_missing_transport() { - let device = stub_device(); - - let err = device - .install_app(&PathBuf::from("nonexistent.ipa"), noop_callback) - .await - .unwrap_err(); - - let msg = err.to_string(); - assert!( - msg.contains("no USB connection") && msg.contains("no network address"), - "expected a message naming both missing transports, got: {msg}" - ); - } - - #[tokio::test] - async fn install_app_network_device_without_cache_dir_returns_distinct_error() { - let mut device = stub_device(); - device.reconnect_address = - Some((std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), 49151)); - assert!(device.pairing_cache_dir.is_none()); - - let err = device - .install_app(&PathBuf::from("nonexistent.ipa"), noop_callback) - .await - .unwrap_err(); - - let msg = err.to_string(); - assert!( - msg.contains("pairing_cache_dir"), - "expected the missing-cache-dir error, got: {msg}" - ); - assert!(!msg.contains("no USB connection")); - assert!(!msg.contains("No pairing file is cached")); - } - - #[tokio::test] - async fn install_app_network_device_with_no_pairing_file_errors_before_tunnel() { - let cache_dir = unique_temp_dir("no_pairing_file"); - std::fs::create_dir_all(&cache_dir).expect("create scratch cache dir"); - - let mut device = stub_device(); - device.reconnect_address = - Some((std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST), 49151)); - device.pairing_cache_dir = Some(cache_dir.clone()); - - let err = device - .install_app(&PathBuf::from("nonexistent.ipa"), noop_callback) - .await - .unwrap_err(); - - let msg = err.to_string(); - assert!( - msg.contains("No pairing record is cached"), - "expected the missing-pairing-file error, got: {msg}" - ); - assert!(!msg.contains("pairing_cache_dir")); - - std::fs::remove_dir_all(&cache_dir).ok(); - } - - fn generated_identities(count: usize) -> Vec { - (0..count).map(|i| format!("dev-{i}")).collect() - } - - #[test] - fn synthetic_device_id_is_deterministic() { - for name in generated_identities(100_000) { - assert_eq!(synthetic_device_id(&name), synthetic_device_id(&name)); - } - } - - #[test] - fn synthetic_device_id_never_zero_or_u32_max() { - for name in generated_identities(100_000) { - let id = synthetic_device_id(&name); - assert_ne!(id, 0, "input {name:?} produced 0"); - assert_ne!(id, u32::MAX, "input {name:?} produced u32::MAX"); - } - - for input in ["", &"x".repeat(500)] { - let id = synthetic_device_id(input); - assert_ne!(id, 0, "input {input:?} produced 0"); - assert_ne!(id, u32::MAX, "input {input:?} produced u32::MAX"); - } - } - - #[test] - fn synthetic_device_id_top_bit_always_set() { - let inputs = [ - "", - "a", - "Living-Room", - "Bedroom", - "Apple-TV", - "Office", - &"z".repeat(200), - ]; - for input in inputs { - let id = synthetic_device_id(input); - assert_eq!( - id & 0x8000_0000, - 0x8000_0000, - "input {input:?} did not have the top bit set" - ); - } - } - - #[test] - fn synthetic_device_id_distinct_for_realistic_names() { - let names = ["Living-Room", "Bedroom", "Apple-TV", "Office"]; - let ids: Vec = names.iter().map(|n| synthetic_device_id(n)).collect(); - for i in 0..ids.len() { - for j in (i + 1)..ids.len() { - assert_ne!( - ids[i], ids[j], - "{:?} and {:?} produced the same id", - names[i], names[j] - ); - } - } - } - - #[test] - fn synthetic_device_id_known_value_regression() { - assert_eq!(synthetic_device_id("Living-Room"), 0xe3eb1b88); - } - - #[tokio::test] - async fn establish_tvos_tunnel_takes_no_pin_argument() { - let device = stub_device(); - let err = device - .establish_tvos_tunnel(std::env::temp_dir()) - .await - .unwrap_err(); - assert!(err.to_string().contains("no network address")); - } -} diff --git a/crates/plume_utils/src/discovery/mdns.rs b/crates/plume_utils/src/discovery/mdns.rs index 3c2c533c..fe60b131 100644 --- a/crates/plume_utils/src/discovery/mdns.rs +++ b/crates/plume_utils/src/discovery/mdns.rs @@ -147,72 +147,3 @@ impl DeviceDiscovery for MdnsDiscovery { Ok(enrich_and_filter(discovered)) } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::discovery::{DeviceType, REMOTEPAIRING_SERVICE, build_device}; - use std::net::IpAddr; - use std::collections::HashMap; - - #[test] - fn test_device_type_from_class() { - assert_eq!( - DeviceType::from_device_class("AppleTV"), - DeviceType::AppleTV - ); - assert_eq!(DeviceType::from_device_class("iPhone"), DeviceType::IPhone); - } - - #[test] - fn test_device_type_from_product() { - assert_eq!( - DeviceType::from_product_type("AppleTV11,1"), - DeviceType::AppleTV - ); - assert_eq!( - DeviceType::from_product_type("iPhone15,2"), - DeviceType::IPhone - ); - } - - #[test] - fn accumulator_replaces_duplicate_resolved_advertisements() { - let mut accumulator = MdnsAccumulator::default(); - let first = build_device( - "Living Room", - "Living-Room.local.", - REMOTEPAIRING_SERVICE, - Some(49152), - &["192.0.2.10".parse::().unwrap()], - &HashMap::from([(String::from("model"), String::from("AppleTV14,1"))]), - ); - let second = build_device( - "Living Room", - "Living-Room.local.", - REMOTEPAIRING_SERVICE, - Some(49153), - &["192.0.2.10".parse::().unwrap()], - &HashMap::from([(String::from("model"), String::from("AppleTV14,1"))]), - ); - - accumulator.insert("Living Room", "Living-Room.local.", REMOTEPAIRING_SERVICE, first); - accumulator.insert("Living Room", "Living-Room.local.", REMOTEPAIRING_SERVICE, second); - - let devices = accumulator.into_devices(); - - assert_eq!(devices.len(), 1); - assert_eq!(devices[0].port, Some(49153)); - } - - #[tokio::test] - #[ignore] - async fn test_mdns_discovery() { - let discovery = MdnsDiscovery::new(); - let devices = discovery.discover(Duration::from_secs(5)).await.unwrap(); - println!("Discovered {} devices:", devices.len()); - for device in &devices { - println!(" - {} ({:?})", device.name, device.device_type); - } - } -} diff --git a/crates/plume_utils/src/discovery/mod.rs b/crates/plume_utils/src/discovery/mod.rs index db437a53..8fa45311 100644 --- a/crates/plume_utils/src/discovery/mod.rs +++ b/crates/plume_utils/src/discovery/mod.rs @@ -404,693 +404,3 @@ impl DeviceDiscovery for PlatformDiscovery { mdns::MdnsDiscovery::new().discover(timeout).await } } - -#[cfg(test)] -mod tests { - use super::*; - - fn props(pairs: &[(&str, &str)]) -> HashMap { - pairs - .iter() - .map(|(k, v)| (k.to_string(), v.to_string())) - .collect() - } - - #[test] - fn instance_name_strips_service_suffix() { - assert_eq!( - parse_instance_name( - "Living Room._remotepairing-manual-pairing._tcp.local", - REMOTEPAIRING_MANUAL_PAIRING_SERVICE - ), - "Living Room" - ); - } - - #[test] - fn instance_name_handles_trailing_dot_on_both_sides() { - assert_eq!( - parse_instance_name("Apple TV._remotepairing._tcp.local.", REMOTEPAIRING_SERVICE), - "Apple TV" - ); - assert_eq!( - parse_instance_name( - "Apple TV._remotepairing._tcp.local", - "_remotepairing._tcp.local" - ), - "Apple TV" - ); - } - - #[test] - fn instance_name_keeps_literal_non_ascii() { - let full = "Frankie\u{2019}s MacBook Pro._companion-link._tcp.local"; - assert_eq!( - parse_instance_name(full, "_companion-link._tcp.local."), - "Frankie\u{2019}s MacBook Pro" - ); - } - - #[test] - fn instance_name_left_alone_when_suffix_absent() { - assert_eq!( - parse_instance_name("Living Room._other._tcp.local", REMOTEPAIRING_SERVICE), - "Living Room._other._tcp.local" - ); - } - - #[test] - fn instance_name_does_not_split_a_multibyte_character() { - let name = "\u{2019}".to_string() + &"X".repeat(24); - assert_eq!(parse_instance_name(&name, REMOTEPAIRING_SERVICE), name); - - for pad in 0..8 { - let name = "A".repeat(pad) + "\u{2019}\u{2019}\u{2019}"; - assert_eq!(parse_instance_name(&name, "_x._tcp.local"), name); - } - } - - #[test] - fn suffix_match_is_case_insensitive() { - assert!(ends_with_ignore_case( - "Living Room._TCP.LOCAL", - "_tcp.local" - )); - assert!(ends_with_ignore_case("abc", "ABC")); - assert!(!ends_with_ignore_case("abc", "abd")); - assert!(!ends_with_ignore_case("ab", "abc")); - assert_eq!( - parse_instance_name( - "Living Room._RemotePairing._TCP.local", - REMOTEPAIRING_SERVICE - ), - "Living Room" - ); - } - - #[test] - fn short_hostname_strips_local_suffix_without_case_sensitivity() { - assert_eq!(short_hostname("Apple-TV.LOCAL."), "Apple-TV"); - assert_eq!(short_hostname("Apple-TV.example"), "Apple-TV.example"); - } - - #[test] - fn first_non_empty_skips_present_but_empty_values() { - let p = props(&[("ProductType", ""), ("model", "AppleTV14,1")]); - assert_eq!( - first_non_empty(&p, &["ProductType", "model"]), - Some("AppleTV14,1") - ); - assert_eq!(first_non_empty(&p, &["ProductType"]), None); - assert_eq!(first_non_empty(&p, &["absent"]), None); - } - - #[test] - fn real_apple_tv_txt_maps_to_apple_tv() { - let manual = props(&[("model", "AppleTV14,1")]); - let d = build_device( - "Living Room", - "Living-Room.local.", - REMOTEPAIRING_MANUAL_PAIRING_SERVICE, - Some(49153), - &[], - &manual, - ); - assert_eq!(d.device_type, DeviceType::AppleTV); - assert_eq!(d.product_type.as_deref(), Some("AppleTV14,1")); - - let companion = props(&[("rpMd", "AppleTV14,1"), ("udid", "deadbeef")]); - let d = build_device( - "Living Room", - "Living-Room.local.", - REMOTEPAIRING_SERVICE, - Some(49152), - &[], - &companion, - ); - assert_eq!(d.device_type, DeviceType::AppleTV); - assert_eq!(d.product_type.as_deref(), Some("AppleTV14,1")); - assert_eq!(d.udid, None); - } - - #[test] - fn mapping_prefers_device_class() { - let p = props(&[ - ("DeviceClass", "AppleTV"), - ("ProductType", "AppleTV11,1"), - ("UniqueDeviceID", "abc123"), - ("OSVersion", "17.4"), - ("name", "Ignored"), - ]); - let d = build_device( - "Living Room", - "Living-Room.local.", - REMOTEPAIRING_SERVICE, - Some(49152), - &["10.0.0.5".parse::().unwrap()], - &p, - ); - assert_eq!(d.device_type, DeviceType::AppleTV); - assert_eq!(d.product_type.as_deref(), Some("AppleTV11,1")); - assert_eq!(d.os_version.as_deref(), Some("17.4")); - assert_eq!(d.udid, None); - assert_eq!(d.ip_address.as_deref(), Some("10.0.0.5")); - assert_eq!(d.port, Some(49152)); - assert_eq!(d.connection_type, ConnectionType::WiFi); - assert!(!d.is_paired); - assert_eq!(d.service_type, REMOTEPAIRING_SERVICE); - } - - #[test] - fn mapping_prefers_ipv4_over_unscoped_link_local_ipv6() { - let d = build_device( - "TV", - "TV.local.", - REMOTEPAIRING_MANUAL_PAIRING_SERVICE, - Some(63295), - &[ - "fe80::1020:429f:1e8:d85d".parse::().unwrap(), - "192.168.2.150".parse::().unwrap(), - ], - &props(&[("model", "AppleTV14,1")]), - ); - - assert_eq!(d.ip_address.as_deref(), Some("192.168.2.150")); - } - - #[test] - fn mapping_name_prefers_hostname_over_txt_and_instance() { - let d = build_device( - "A827F07B-2D1D-4D09-8E1E-5E37EE47A96C", - "Living-Room.local.", - REMOTEPAIRING_SERVICE, - Some(1), - &[], - &props(&[("name", "Some Other Name")]), - ); - assert_eq!(d.name, "Living Room"); - } - - #[test] - fn mapping_name_falls_back_when_hostname_missing() { - let d = build_device( - "instance-label", - "", - REMOTEPAIRING_SERVICE, - Some(1), - &[], - &props(&[("name", "Txt Name")]), - ); - assert_eq!(d.name, "Txt Name"); - - let d = build_device( - "instance-label", - "", - REMOTEPAIRING_SERVICE, - Some(1), - &[], - &props(&[]), - ); - assert_eq!(d.name, "instance-label"); - } - - #[test] - fn same_device_yields_identical_name_across_service_types() { - let manual = build_device( - "Living Room", - "Living-Room.local.", - REMOTEPAIRING_MANUAL_PAIRING_SERVICE, - Some(62782), - &[], - &props(&[("name", "Living Room"), ("model", "AppleTV14,1")]), - ); - let reconnect = build_device( - "A827F07B-2D1D-4D09-8E1E-5E37EE47A96C", - "Living-Room.local.", - REMOTEPAIRING_SERVICE, - Some(49152), - &[], - &props(&[("identifier", "73B8BE56-3881-4145-BF61-EFB7BBAEC98F")]), - ); - - assert_eq!(manual.name, "Living Room"); - assert_eq!(manual.name, reconnect.name); - assert_ne!(manual.service_type, reconnect.service_type); - assert_eq!(manual.port, Some(62782)); - assert_eq!(reconnect.port, Some(49152)); - } - - #[test] - fn mapping_marks_mobdev2_as_paired() { - let d = build_device( - "x", - "", - APPLE_MOBDEV2_SERVICE, - Some(62078), - &[], - &props(&[]), - ); - assert!(d.is_paired); - assert_eq!(d.device_type, DeviceType::Unknown); - assert_eq!(d.product_type, None); - } - - #[test] - fn mapping_does_not_trust_advertised_udid() { - let p = props(&[("udid", "second"), ("identifier", "third")]); - assert_eq!( - build_device("x", "", REMOTEPAIRING_SERVICE, Some(1), &[], &p).udid, - None - ); - let p = props(&[("identifier", "third")]); - assert_eq!( - build_device("x", "", REMOTEPAIRING_SERVICE, Some(1), &[], &p).udid, - None - ); - } - - #[test] - fn dedup_key_normalizes_case_and_falls_back_to_instance() { - assert_eq!( - dedup_key("Living-Room.local.", "Living Room", REMOTEPAIRING_SERVICE), - dedup_key("living-room.local", "Living Room", REMOTEPAIRING_SERVICE) - ); - assert_eq!( - dedup_key("", "Living Room", REMOTEPAIRING_SERVICE), - ("living room".to_string(), REMOTEPAIRING_SERVICE.to_string()) - ); - } - - #[test] - fn same_device_under_two_service_types_is_not_collapsed() { - let p = props(&[("model", "AppleTV14,1")]); - let mut devices: HashMap<(String, String), DiscoveredDevice> = HashMap::new(); - - for (service, port) in [ - (REMOTEPAIRING_SERVICE, 49152u16), - (REMOTEPAIRING_MANUAL_PAIRING_SERVICE, 49153u16), - ] { - let device = build_device( - "Living Room", - "Living-Room.local.", - service, - Some(port), - &[], - &p, - ); - devices.insert( - dedup_key("Living-Room.local.", "Living Room", service), - device, - ); - } - - assert_eq!(devices.len(), 2); - let mut ports: Vec = devices.values().filter_map(|d| d.port).collect(); - ports.sort_unstable(); - assert_eq!(ports, vec![49152, 49153]); - assert!(devices.values().all(|d| d.name == "Living Room")); - } - - fn unknown_device(name: &str, service_type: &str, port: u16) -> DiscoveredDevice { - DiscoveredDevice { - name: name.to_string(), - hostname: String::new(), - udid: None, - ip_address: None, - port: Some(port), - device_type: DeviceType::Unknown, - connection_type: ConnectionType::WiFi, - is_paired: false, - product_type: None, - os_version: None, - service_type: service_type.to_string(), - } - } - - fn companion_link_device(name: &str, product_type: &str) -> DiscoveredDevice { - DiscoveredDevice { - name: name.to_string(), - hostname: String::new(), - udid: None, - ip_address: None, - port: Some(49155), - device_type: DeviceType::from_product_type(product_type), - connection_type: ConnectionType::WiFi, - is_paired: false, - product_type: Some(product_type.to_string()), - os_version: None, - service_type: COMPANION_LINK_SERVICE.to_string(), - } - } - - #[test] - fn enrich_and_filter_fills_model_from_companion_link() { - let remotepairing = unknown_device("Living Room", REMOTEPAIRING_SERVICE, 49152); - let companion = companion_link_device("Living Room", "AppleTV14,1"); - - let result = enrich_and_filter(vec![remotepairing, companion]); - - assert_eq!(result.len(), 1); - assert_eq!(result[0].service_type, REMOTEPAIRING_SERVICE); - assert_eq!(result[0].port, Some(49152)); - assert_eq!(result[0].device_type, DeviceType::AppleTV); - assert_eq!(result[0].product_type.as_deref(), Some("AppleTV14,1")); - } - - #[test] - fn enrich_and_filter_name_correlation_is_case_insensitive() { - let remotepairing = unknown_device("Living Room", REMOTEPAIRING_SERVICE, 49152); - let companion = companion_link_device("living room", "AppleTV14,1"); - - let result = enrich_and_filter(vec![remotepairing, companion]); - - assert_eq!(result.len(), 1); - assert_eq!(result[0].device_type, DeviceType::AppleTV); - assert_eq!(result[0].product_type.as_deref(), Some("AppleTV14,1")); - } - - #[test] - fn enrich_and_filter_does_not_overwrite_known_device_type() { - let mut manual = unknown_device("Living Room", REMOTEPAIRING_MANUAL_PAIRING_SERVICE, 49153); - manual.device_type = DeviceType::AppleTV; - let mut companion = companion_link_device("Living Room", "iPhone15,2"); - companion.device_type = DeviceType::IPhone; - - let result = enrich_and_filter(vec![manual, companion]); - - assert_eq!(result.len(), 1); - assert_eq!(result[0].device_type, DeviceType::AppleTV); - assert_eq!(result[0].product_type, None); - } - - #[test] - fn enrich_and_filter_prefers_a_typed_metadata_entry_regardless_of_order() { - for reversed in [false, true] { - let target = unknown_device("Living Room", REMOTEPAIRING_SERVICE, 49152); - let untyped = companion_link_device("Living Room", ""); - let mut untyped = untyped; - untyped.device_type = DeviceType::Unknown; - untyped.product_type = None; - let typed = companion_link_device("Living Room", "AppleTV14,1"); - - let input = if reversed { - vec![target, typed, untyped] - } else { - vec![target, untyped, typed] - }; - let result = enrich_and_filter(input); - - assert_eq!(result.len(), 1); - assert_eq!( - result[0].device_type, - DeviceType::AppleTV, - "reversed={reversed}" - ); - assert_eq!( - result[0].product_type.as_deref(), - Some("AppleTV14,1"), - "reversed={reversed}" - ); - } - } - - #[test] - fn enrich_and_filter_does_not_overwrite_known_product_type() { - let mut remotepairing = unknown_device("Living Room", REMOTEPAIRING_SERVICE, 49152); - remotepairing.product_type = Some("x".to_string()); - let companion = companion_link_device("Living Room", "AppleTV14,1"); - - let result = enrich_and_filter(vec![remotepairing, companion]); - - assert_eq!(result.len(), 1); - assert_eq!(result[0].product_type.as_deref(), Some("x")); - } - - #[test] - fn enrich_and_filter_drops_unmatched_metadata_entries() { - let companion = companion_link_device("Living Room", "AppleTV14,1"); - - let result = enrich_and_filter(vec![companion]); - - assert!(result.is_empty()); - } - - #[test] - fn enrich_and_filter_does_not_cross_contaminate_hosts() { - let bedroom = unknown_device("Bedroom", REMOTEPAIRING_SERVICE, 49152); - let living_room_companion = companion_link_device("Living Room", "AppleTV14,1"); - - let result = enrich_and_filter(vec![bedroom, living_room_companion]); - - assert_eq!(result.len(), 1); - assert_eq!(result[0].name, "Bedroom"); - assert_eq!(result[0].device_type, DeviceType::Unknown); - assert_eq!(result[0].product_type, None); - } - - #[test] - fn enrich_and_filter_preserves_order_of_non_metadata_entries() { - let bedroom = unknown_device("Bedroom", REMOTEPAIRING_SERVICE, 1); - let companion = companion_link_device("Living Room", "AppleTV14,1"); - let living_room = unknown_device("Living Room", REMOTEPAIRING_SERVICE, 2); - let kitchen = unknown_device("Kitchen", REMOTEPAIRING_SERVICE, 3); - - let result = enrich_and_filter(vec![bedroom, companion, living_room, kitchen]); - - assert_eq!( - result.iter().map(|d| d.name.as_str()).collect::>(), - vec!["Bedroom", "Living Room", "Kitchen"] - ); - } - - fn network_apple_tv(name: &str, service_type: &str, port: u16, ip: &str) -> DiscoveredDevice { - DiscoveredDevice { - name: name.to_string(), - hostname: name.replace(' ', "-").to_ascii_lowercase(), - udid: None, - ip_address: Some(ip.to_string()), - port: Some(port), - device_type: DeviceType::AppleTV, - connection_type: ConnectionType::WiFi, - is_paired: false, - product_type: Some("AppleTV14,1".to_string()), - os_version: None, - service_type: service_type.to_string(), - } - } - - #[test] - fn group_network_devices_only_manual_sets_pairing_port_only() { - let discovered = [network_apple_tv( - "Living Room", - REMOTEPAIRING_MANUAL_PAIRING_SERVICE, - 49153, - "10.0.0.5", - )]; - - let devices = group_network_devices(&discovered, Path::new("/cache")); - - assert_eq!(devices.len(), 1); - assert_eq!( - devices[0].pairing_address, - Some(("10.0.0.5".parse().unwrap(), 49153)) - ); - assert_eq!(devices[0].reconnect_address, None); - } - - #[test] - fn group_network_devices_only_reconnect_sets_reconnect_port_only() { - let discovered = [network_apple_tv( - "Living Room", - REMOTEPAIRING_SERVICE, - 49152, - "10.0.0.5", - )]; - - let devices = group_network_devices(&discovered, Path::new("/cache")); - - assert_eq!(devices.len(), 1); - assert_eq!( - devices[0].reconnect_address, - Some(("10.0.0.5".parse().unwrap(), 49152)) - ); - assert_eq!(devices[0].pairing_address, None); - } - - #[test] - fn group_network_devices_merges_both_service_types_into_one_device() { - let discovered = [ - network_apple_tv( - "Living Room", - REMOTEPAIRING_MANUAL_PAIRING_SERVICE, - 49153, - "10.0.0.5", - ), - network_apple_tv("living room", REMOTEPAIRING_SERVICE, 49152, "10.0.0.5"), - ]; - - let devices = group_network_devices(&discovered, Path::new("/cache")); - - assert_eq!(devices.len(), 1); - assert_eq!(devices[0].pairing_address.map(|(_, p)| p), Some(49153)); - assert_eq!(devices[0].reconnect_address.map(|(_, p)| p), Some(49152)); - } - - #[test] - fn group_network_devices_deduplicates_legacy_core_device_with_remote_pairing() { - let discovered = [ - network_apple_tv("Living Room", REMOTEPAIRING_SERVICE, 49152, "10.0.0.5"), - network_apple_tv("Living Room", APPLE_MOBDEV2_SERVICE, 62078, "10.0.0.5"), - ]; - - let devices = group_network_devices(&discovered, Path::new("/cache")); - - assert_eq!(devices.len(), 1); - assert_eq!(devices[0].reconnect_address.unwrap().1, 49152); - assert_eq!(devices[0].pairing_identity.as_deref(), Some("living-room")); - } - - #[test] - fn group_network_devices_retains_legacy_core_device_until_authenticated_data_arrives() { - let discovered = [network_apple_tv( - "Living Room", - APPLE_MOBDEV2_SERVICE, - 62078, - "10.0.0.5", - )]; - - let devices = group_network_devices(&discovered, Path::new("/cache")); - - assert_eq!(devices.len(), 1); - assert!(devices[0].pairing_address.is_none()); - assert!(devices[0].reconnect_address.is_none()); - assert!(devices[0].udid.is_empty()); - } - - #[test] - fn group_network_devices_keeps_same_named_hosts_separate() { - let mut first = network_apple_tv("Living Room", REMOTEPAIRING_SERVICE, 49152, "10.0.0.5"); - let mut second = network_apple_tv("Living Room", REMOTEPAIRING_SERVICE, 49152, "10.0.0.6"); - first.hostname = "living-room-a".to_string(); - second.hostname = "living-room-b".to_string(); - - let devices = group_network_devices(&[first, second], Path::new("/cache")); - - assert_eq!(devices.len(), 2); - assert_ne!(devices[0].pairing_identity, devices[1].pairing_identity); - } - - #[test] - fn disappearing_mdns_service_requires_two_missed_scans() { - let mut present = [7u32].into_iter().collect::>(); - let empty = HashSet::new(); - let mut misses = HashMap::new(); - - assert!(disconnected_after_missed_scans(&mut present, &empty, &mut misses, 2).is_empty()); - assert_eq!(disconnected_after_missed_scans(&mut present, &empty, &mut misses, 2), vec![7]); - assert!(present.is_empty()); - assert!(misses.is_empty()); - } - - #[test] - fn rediscovered_mdns_service_clears_missed_scan_count() { - let mut present = [7u32].into_iter().collect::>(); - let empty = HashSet::new(); - let current = [7u32].into_iter().collect::>(); - let mut misses = HashMap::new(); - - assert!(disconnected_after_missed_scans(&mut present, &empty, &mut misses, 2).is_empty()); - assert!(disconnected_after_missed_scans(&mut present, ¤t, &mut misses, 2).is_empty()); - assert!(present.contains(&7)); - } - - #[test] - fn group_network_devices_excludes_non_appletv() { - let mut d = network_apple_tv("Some iPhone", REMOTEPAIRING_SERVICE, 1, "10.0.0.5"); - d.device_type = DeviceType::IPhone; - - let devices = group_network_devices(&[d], Path::new("/cache")); - - assert!(devices.is_empty()); - } - - #[test] - fn group_network_devices_excludes_unsupported_service() { - let d = network_apple_tv("Living Room", APPLE_PAIRABLE_SERVICE, 62078, "10.0.0.5"); - - let devices = group_network_devices(&[d], Path::new("/cache")); - - assert!(devices.is_empty()); - } - - #[test] - fn group_network_devices_keeps_two_different_apple_tvs_separate() { - let discovered = [ - network_apple_tv("Living Room", REMOTEPAIRING_SERVICE, 1, "10.0.0.5"), - network_apple_tv("Bedroom", REMOTEPAIRING_SERVICE, 2, "10.0.0.6"), - ]; - - let devices = group_network_devices(&discovered, Path::new("/cache")); - - assert_eq!(devices.len(), 2); - let mut names: Vec<&str> = devices.iter().map(|d| d.name.as_str()).collect(); - names.sort(); - assert_eq!(names, vec!["Bedroom", "Living Room"]); - } - - #[test] - fn group_network_devices_skips_empty_name() { - let d = network_apple_tv("", REMOTEPAIRING_SERVICE, 1, "10.0.0.5"); - - let devices = group_network_devices(&[d], Path::new("/cache")); - - assert!(devices.is_empty()); - } - - #[test] - fn group_network_devices_skips_unresolved_ip() { - let mut d = network_apple_tv("Living Room", REMOTEPAIRING_SERVICE, 1, "10.0.0.5"); - d.ip_address = None; - - let devices = group_network_devices(&[d], Path::new("/cache")); - - assert!(devices.is_empty()); - } - - #[test] - fn group_network_devices_sets_synthetic_device_id_and_pairing_identity() { - let d = network_apple_tv("Living Room", REMOTEPAIRING_SERVICE, 1, "10.0.0.5"); - - let devices = group_network_devices(&[d], Path::new("/cache")); - - assert_eq!(devices.len(), 1); - assert_eq!(devices[0].pairing_identity.as_deref(), Some("living-room")); - assert_eq!(devices[0].device_id, synthetic_device_id("living-room")); - assert_ne!(devices[0].device_id, 0); - } - - #[test] - fn group_network_devices_keeps_each_service_address_when_entries_share_a_name() { - let discovered = [ - network_apple_tv( - "Living Room", - REMOTEPAIRING_MANUAL_PAIRING_SERVICE, - 49153, - "10.0.0.5", - ), - network_apple_tv("Living Room", REMOTEPAIRING_SERVICE, 49152, "10.0.0.9"), - ]; - - let devices = group_network_devices(&discovered, Path::new("/cache")); - - assert_eq!(devices.len(), 1); - assert_eq!( - devices[0].pairing_address.unwrap().0.to_string(), - "10.0.0.5" - ); - assert_eq!( - devices[0].reconnect_address.unwrap().0.to_string(), - "10.0.0.9" - ); - } -} diff --git a/crates/plume_utils/src/lib.rs b/crates/plume_utils/src/lib.rs index b9ecf4ff..57f29687 100644 --- a/crates/plume_utils/src/lib.rs +++ b/crates/plume_utils/src/lib.rs @@ -260,154 +260,3 @@ pub fn format_bytes(bytes: u64) -> String { format!("{} B", bytes) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn format_bytes_picks_a_unit_per_magnitude() { - assert_eq!(format_bytes(0), "0 B"); - assert_eq!(format_bytes(999), "999 B"); - assert_eq!(format_bytes(1_000), "1 KB"); - assert_eq!(format_bytes(999_999), "999 KB"); - assert_eq!(format_bytes(1_000_000), "1.0 MB"); - assert_eq!(format_bytes(1_000_000_000), "1.0 GB"); - } - - #[test] - fn format_bytes_rounds_to_one_decimal_at_megabytes() { - assert_eq!(format_bytes(54_741_568), "54.7 MB"); - } - - #[test] - fn validates_legacy_and_modern_udids() { - assert!(is_valid_device_udid("00008110-000C25540CD1801E")); - assert!(is_valid_device_udid("0123456789abcdef0123456789abcdef01234567")); - assert!(!is_valid_device_udid("00:11:22:33:44:55")); - assert!(!is_valid_device_udid("Apple-TV.local")); - } - - #[test] - fn deduplicates_authenticated_network_and_legacy_entries() { - let cache_dir = std::env::temp_dir(); - let mut legacy = Device::new_tvos( - "Living Room".to_string(), - "Living-Room".to_string(), - "192.0.2.10".parse().unwrap(), - None, - Some(49152), - cache_dir.clone(), - ); - legacy.udid = "00008110-000C25540CD1801E".to_string(); - legacy.product_type = Some("AppleTV14,1".to_string()); - let mut authenticated = legacy.clone(); - authenticated.reconnect_address = Some(("192.0.2.11".parse().unwrap(), 49152)); - authenticated.os_version = Some("26.6".to_string()); - - let devices = deduplicate_devices([legacy, authenticated.clone()]); - - assert_eq!(devices.len(), 1); - assert_eq!( - devices[0].reconnect_address, - authenticated.reconnect_address - ); - assert_eq!(devices[0].os_version, authenticated.os_version); - } - - #[test] - fn bridges_legacy_pairing_identity_to_authenticated_udid() { - let cache_dir = std::env::temp_dir(); - let legacy = Device::new_tvos( - "Living Room".to_string(), - "Living-Room".to_string(), - "192.0.2.10".parse().unwrap(), - None, - Some(49152), - cache_dir.clone(), - ); - let mut authenticated = legacy.clone(); - authenticated.udid = "00008110-000C25540CD1801E".to_string(); - authenticated.core_device_authenticated = true; - - let devices = deduplicate_devices([legacy, authenticated]); - - assert_eq!(devices.len(), 1); - assert_eq!( - devices[0].udid, - "00008110-000C25540CD1801E".to_string() - ); - assert!(devices[0].core_device_authenticated); - } - - #[test] - fn deduplicates_unenriched_network_advertisements_by_pairing_identity() { - let cache_dir = std::env::temp_dir(); - let first = Device::new_tvos( - "Living Room".to_string(), - "Living-Room".to_string(), - "192.0.2.10".parse().unwrap(), - Some(49153), - None, - cache_dir.clone(), - ); - let second = Device::new_tvos( - "living room".to_string(), - "living-room".to_string(), - "192.0.2.11".parse().unwrap(), - None, - Some(49152), - cache_dir, - ); - - let devices = deduplicate_devices([first, second]); - - assert_eq!(devices.len(), 1); - } - - #[test] - fn deduplicates_usbmuxd_apple_tv_with_authenticated_network_entry() { - let cache_dir = std::env::temp_dir(); - let legacy = Device { - name: "TV".to_string(), - udid: "fff48:e1:5c:79:40:83fff".to_string(), - product_type: Some("AppleTV14,1".to_string()), - device_class: Some("AppleTV".to_string()), - os_version: Some("26.6".to_string()), - serial_number: None, - device_id: 237, - usbmuxd_device: Some(idevice::usbmuxd::UsbmuxdDevice { - connection_type: Connection::Network("192.0.2.10".parse().unwrap()), - udid: "fff48:e1:5c:79:40:83fff".to_string(), - device_id: 237, - }), - is_mac: false, - pairing_address: None, - reconnect_address: None, - pairing_identity: None, - pairing_cache_dir: None, - core_device_authenticated: false, - }; - let mut authenticated = Device::new_tvos( - "TV".to_string(), - "tv".to_string(), - "192.0.2.10".parse().unwrap(), - None, - Some(49152), - cache_dir, - ); - authenticated.udid = "00008110-000C25540CD1801E".to_string(); - authenticated.product_type = Some("AppleTV14,1".to_string()); - authenticated.os_version = Some("26.6".to_string()); - authenticated.core_device_authenticated = true; - - let devices = deduplicate_devices([legacy, authenticated]); - - assert_eq!(devices.len(), 1); - assert_eq!(devices[0].name, "TV"); - assert_eq!(devices[0].udid, "00008110-000C25540CD1801E"); - assert!(devices[0].core_device_authenticated); - assert!(devices[0].usbmuxd_device.is_none()); - assert_eq!(devices[0].transport(), DeviceTransport::CoreDevice); - } -} diff --git a/crates/plume_utils/src/package.rs b/crates/plume_utils/src/package.rs index 245d11e8..3fd9a3e4 100644 --- a/crates/plume_utils/src/package.rs +++ b/crates/plume_utils/src/package.rs @@ -346,144 +346,3 @@ impl Package { *settings = new_settings; } } - -#[cfg(test)] -mod tests { - use super::*; - use std::time::{Duration, SystemTime}; - - fn profile_bytes(marker: &str) -> Vec { - let mut entitlements = Dictionary::new(); - entitlements.insert( - "application-identifier".to_string(), - plist::Value::String("L988J7YMK5.com.example.test".to_string()), - ); - - let mut profile = Dictionary::new(); - profile.insert( - "Entitlements".to_string(), - plist::Value::Dictionary(entitlements), - ); - profile.insert( - "ExpirationDate".to_string(), - plist::Value::Date(plist::Date::from( - SystemTime::now() + Duration::from_secs(3600), - )), - ); - profile.insert( - "Platform".to_string(), - plist::Value::Array(vec![plist::Value::String("iOS".to_string())]), - ); - profile.insert( - "ProvisionedDevices".to_string(), - plist::Value::Array(vec![plist::Value::String( - "00008110-000C25540CD1801E".to_string(), - )]), - ); - profile.insert( - "DeveloperCertificates".to_string(), - plist::Value::Array(vec![plist::Value::Data(vec![0; 4])]), - ); - profile.insert( - "TestMarker".to_string(), - plist::Value::String(marker.to_string()), - ); - - let mut plist_data = Vec::new(); - plist::to_writer_xml(&mut plist_data, &profile).unwrap(); - let mut data = b"CMS".to_vec(); - data.extend(plist_data); - data - } - - fn staged_package(tag: &str) -> Package { - let stage_dir = env::temp_dir().join(format!("plume_pkg_test_{tag}_{}", Uuid::new_v4())); - let app_dir = stage_dir.join("Payload").join("Test.app"); - fs::create_dir_all(app_dir.join("Frameworks")).unwrap(); - fs::write(app_dir.join("Info.plist"), b"plist").unwrap(); - fs::create_dir_all(app_dir.join("_CodeSignature")).unwrap(); - fs::write( - app_dir.join("_CodeSignature").join("CodeResources"), - b"signature", - ) - .unwrap(); - fs::write( - app_dir.join("embedded.mobileprovision"), - profile_bytes("old"), - ) - .unwrap(); - fs::write(app_dir.join("Frameworks").join("lib.dylib"), b"macho").unwrap(); - - Package { - package_file: stage_dir.join("stage.ipa"), - stage_payload_dir: stage_dir.join("Payload"), - stage_dir, - info_plist_dictionary: Dictionary::new(), - archive_entries: Vec::new(), - app_icon_data: None, - } - } - - fn entry_names(archive: &PathBuf) -> Vec { - let mut zip = ZipArchive::new(fs::File::open(archive).unwrap()).unwrap(); - (0..zip.len()) - .map(|i| zip.by_index(i).unwrap().name().to_string()) - .collect() - } - - #[test] - fn archive_entries_are_separated_by_forward_slashes() { - let package = staged_package("separators"); - let stage_dir = package.stage_dir.clone(); - - let archive = package.archive_package_bundle().unwrap(); - let names = entry_names(&archive); - - for name in &names { - assert!( - !name.contains('\\'), - "entry {name:?} uses a backslash separator" - ); - } - assert!( - names.iter().any(|n| n == "Payload/Test.app/Info.plist"), - "no Info.plist at the depth a bundle id is read from, got {names:?}" - ); - - let second = names.get(1).expect("archive has more than one entry"); - assert_eq!( - second.split('/').nth(1), - Some("Test.app"), - "second entry {second:?} does not name the app bundle" - ); - - fs::remove_dir_all(&stage_dir).ok(); - } - - #[test] - fn archiving_a_file_path_uses_the_modified_staged_payload() { - let package = staged_package("modified"); - let stage_dir = package.stage_dir.clone(); - let expected_profile = profile_bytes("new"); - fs::write( - stage_dir.join("Payload/Test.app/embedded.mobileprovision"), - &expected_profile, - ) - .unwrap(); - - let archive = package - .get_archive_based_on_path(&PathBuf::from("input.ipa")) - .unwrap(); - let mut zip = ZipArchive::new(fs::File::open(&archive).unwrap()).unwrap(); - let mut profile = Vec::new(); - zip.by_name("Payload/Test.app/embedded.mobileprovision") - .unwrap() - .read_to_end(&mut profile) - .unwrap(); - - assert_eq!(profile, expected_profile); - assert_ne!(profile, profile_bytes("old")); - Package::validate_archive(&archive, true).unwrap(); - fs::remove_dir_all(&stage_dir).ok(); - } -} diff --git a/crates/plume_utils/src/pairing.rs b/crates/plume_utils/src/pairing.rs index 8d4f8927..7a3d06c4 100644 --- a/crates/plume_utils/src/pairing.rs +++ b/crates/plume_utils/src/pairing.rs @@ -56,131 +56,3 @@ where backend.pair(&pin).await?; Ok(PairingStage::Paired) } - -#[cfg(test)] -mod tests { - use super::*; - use std::collections::VecDeque; - - struct MockPairingBackend { - verify_results: VecDeque>, - pair_results: VecDeque>, - verify_calls: usize, - pair_calls: Vec, - } - - impl MockPairingBackend { - fn new( - verify_results: impl IntoIterator>, - pair_results: impl IntoIterator>, - ) -> Self { - Self { - verify_results: verify_results.into_iter().collect(), - pair_results: pair_results.into_iter().collect(), - verify_calls: 0, - pair_calls: Vec::new(), - } - } - } - - impl PairingBackend for MockPairingBackend { - async fn verify(&mut self) -> Result<(), PairingFailure> { - self.verify_calls += 1; - self.verify_results.pop_front().unwrap_or(Ok(())) - } - - async fn pair(&mut self, pin: &str) -> Result<(), PairingFailure> { - self.pair_calls.push(pin.to_string()); - self.pair_results.pop_front().unwrap_or(Ok(())) - } - } - - #[tokio::test] - async fn first_pairing_uses_manual_service_and_pin_once() { - let mut backend = MockPairingBackend::new([], [Ok(())]); - - let stage = ensure_pairing(&mut backend, false, true, false, || async { - "123456".to_string() - }) - .await - .unwrap(); - - assert_eq!(stage, PairingStage::Paired); - assert_eq!(backend.verify_calls, 0); - assert_eq!(backend.pair_calls, vec!["123456"]); - } - - #[tokio::test] - async fn saved_record_reconnects_without_requesting_pin() { - let mut backend = MockPairingBackend::new([Ok(())], []); - let mut requested_pin = false; - - let stage = ensure_pairing(&mut backend, true, false, true, || async { - requested_pin = true; - "123456".to_string() - }) - .await - .unwrap(); - - assert_eq!(stage, PairingStage::Reconnected); - assert_eq!(backend.verify_calls, 1); - assert!(backend.pair_calls.is_empty()); - assert!(!requested_pin); - } - - #[tokio::test] - async fn wrong_pin_is_returned_before_any_installation_step() { - let mut backend = MockPairingBackend::new([], [Err(PairingFailure::WrongPin)]); - - let error = ensure_pairing(&mut backend, false, true, false, || async { - "654321".to_string() - }) - .await - .unwrap_err(); - - assert_eq!(error, PairingFailure::WrongPin); - assert_eq!(backend.pair_calls, vec!["654321"]); - } - - #[tokio::test] - async fn cancelled_pin_does_not_call_pairing_backend() { - let mut backend = MockPairingBackend::new([], []); - - let error = ensure_pairing(&mut backend, false, true, false, || async { - String::new() - }) - .await - .unwrap_err(); - - assert_eq!(error, PairingFailure::Cancelled); - assert!(backend.pair_calls.is_empty()); - } - - #[tokio::test] - async fn stale_record_needs_manual_service_before_retrying_pairing() { - let mut backend = MockPairingBackend::new([Err(PairingFailure::Protocol("stale".into()))], []); - - let error = ensure_pairing(&mut backend, true, false, true, || async { - "123456".to_string() - }) - .await - .unwrap_err(); - - assert_eq!(error, PairingFailure::StaleRecord); - assert!(backend.pair_calls.is_empty()); - } - - #[tokio::test] - async fn disappearing_service_is_not_treated_as_a_pairing_failure() { - let mut backend = MockPairingBackend::new([], []); - - let error = ensure_pairing(&mut backend, false, false, true, || async { - "123456".to_string() - }) - .await - .unwrap_err(); - - assert_eq!(error, PairingFailure::ServiceDisappeared); - assert!(backend.pair_calls.is_empty()); - } -}