From 88fb6009ffe1d3c73c79d764c1930010d5cb8c55 Mon Sep 17 00:00:00 2001 From: Andrew Harness Date: Fri, 31 Jul 2026 13:20:50 -0400 Subject: [PATCH 1/6] fix: write zip entry names with forward slashes Entry names were built with to_string_lossy on a relative path, so on Windows they carried backslashes. Zip requires '/', and readers split on it to locate a bundle, so an archive exported on Windows could not be read back by tooling that looks for Payload/.app/Info.plist. Join the path components rather than replacing separators, so a name containing a literal backslash on a platform that permits one survives. --- crates/plume_utils/src/package.rs | 73 ++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 2 deletions(-) diff --git a/crates/plume_utils/src/package.rs b/crates/plume_utils/src/package.rs index 37f61459..eb14c908 100644 --- a/crates/plume_utils/src/package.rs +++ b/crates/plume_utils/src/package.rs @@ -201,11 +201,16 @@ impl Package { for entry in fs::read_dir(path)? { let entry = entry?; let entry_path = entry.path(); + // Zip entry names are separated by '/' on every platform, and readers split on + // it to find a bundle: `to_string_lossy` on a relative path would hand them + // Windows separators and leave the archive unreadable. 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 +293,67 @@ impl Package { *settings = new_settings; } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A stage directory laid out the way `get_package_bundle` leaves one. + 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::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() + } + + /// InstallationProxy locates the bundle by splitting entry names on '/' and counting + /// segments, so a separator that is not '/' makes the archive unreadable to it even though + /// the zip itself is well formed. + #[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" + ); + } + // A bundle id is read from the Info.plist exactly three segments deep. + 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:?}" + ); + + // The package type is read from the second entry, taking the segment after "Payload". + 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(); + } +} From b9a9bc93f1c9524f4e605cef63f13554170ef931 Mon Sep 17 00:00:00 2001 From: Andrew Harness Date: Fri, 31 Jul 2026 13:20:51 -0400 Subject: [PATCH 2/6] chore(deps): update jktcp for windowed TCP sends The pinned release sent one segment and waited for its acknowledgement before sending the next, so any transfer over the userspace TCP stack ran at one segment per round trip regardless of the link. 0.1.6 keeps a send window bounded by the peer's advertised window, with window scaling. Measured over a CoreDevice tunnel: 2.3 MB/s to 6.35 MB/s, no retransmissions. --- Cargo.lock | 142 +++++++++++++++++++++++++++++++++-------------------- 1 file changed, 88 insertions(+), 54 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ebaaafaf..d9c5b77f 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,18 +5671,21 @@ name = "plume_utils" version = "2.6.0" dependencies = [ "decompress", + "env_logger", "flate2", "futures", "goblin", "idevice", "image", "log", + "mdns-sd", "plist", "plume_core", "plume_store", "thiserror 2.0.18", "tokio", "uuid", + "windows-sys 0.60.2", "zip 8.6.0", ] @@ -5767,6 +5772,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 +6017,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.2", "rustls", - "socket2", + "socket2 0.5.10", "thiserror 2.0.18", "tokio", "tracing", @@ -6034,7 +6055,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2", + "socket2 0.5.10", "tracing", "windows-sys 0.60.2", ] @@ -6623,7 +6644,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys 0.12.1", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6703,7 +6724,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 +7298,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 +7315,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 +7366,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 +7583,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 +7764,7 @@ dependencies = [ "libc", "mio", "pin-project-lite", - "socket2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] @@ -8124,7 +8158,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset 0.9.1", "tempfile", - "windows-sys 0.61.2", + "windows-sys 0.60.2", ] [[package]] @@ -8792,7 +8826,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]] From 7255d48e578179663cd12cd847975bc18a6b3ed9 Mon Sep 17 00:00:00 2001 From: Andrew Harness Date: Fri, 31 Jul 2026 13:21:06 -0400 Subject: [PATCH 3/6] feat: discover Apple TVs over mDNS An Apple TV is reached over the network rather than usbmuxd, so it has to be found before anything else can talk to it. Adds a discovery module with a backend per platform. mdns-sd serves macOS and Linux. It does not work on Windows: it binds its own socket on port 5353 and never receives the device's responses, because the Dnscache service owns multicast DNS there, so Windows uses the OS resolver through dnsapi.dll instead. Browsing alone returns SRV records only intermittently, so each instance is resolved individually to obtain a port. Apple TVs advertise two RPPairing services that are not interchangeable. _remotepairing-manual-pairing._tcp is advertised only while the device is showing a pairing code and is the only one that accepts first-time pairing; _remotepairing._tcp is advertised once a pairing exists and only accepts reconnection. They carry different ports, and the identifier they advertise changes between advertisements, so it cannot be used as an identity. Devices are grouped by hostname instead, and a companion-link advertisement supplies the model a paired device otherwise stops publishing. --- crates/plume_utils/Cargo.toml | 14 + crates/plume_utils/src/discovery/mdns.rs | 175 +++ crates/plume_utils/src/discovery/mod.rs | 1092 +++++++++++++++++ .../src/discovery/windows_dnssd.rs | 1062 ++++++++++++++++ crates/plume_utils/src/lib.rs | 45 +- 5 files changed, 2386 insertions(+), 2 deletions(-) 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/discovery/windows_dnssd.rs diff --git a/crates/plume_utils/Cargo.toml b/crates/plume_utils/Cargo.toml index 60ab60ee..1fe715e3 100644 --- a/crates/plume_utils/Cargo.toml +++ b/crates/plume_utils/Cargo.toml @@ -22,3 +22,17 @@ flate2.workspace = true plume_core = { path = "../plume_core", features = ["tweaks"] } plume_store = { path = "../plume_store" } decompress = { path = "../../3rdparty/decompress" } + +# mDNS discovery for Apple TV network scanning +mdns-sd = "0.11" + +[dev-dependencies] +env_logger.workspace = true + +# Native DNS-SD via the Windows resolver (dnsapi.dll). The raw-socket mdns-sd backend does +# not receive multicast responses on Windows; the OS resolver does. +[target.'cfg(windows)'.dependencies] +windows-sys = { version = "0.60", features = [ + "Win32_NetworkManagement_Dns", + "Win32_Foundation", +] } diff --git a/crates/plume_utils/src/discovery/mdns.rs b/crates/plume_utils/src/discovery/mdns.rs new file mode 100644 index 00000000..70be3796 --- /dev/null +++ b/crates/plume_utils/src/discovery/mdns.rs @@ -0,0 +1,175 @@ +use super::{ + ALL_SCANNED_SERVICE_TYPES, DeviceDiscovery, DiscoveredDevice, build_device, enrich_and_filter, + parse_instance_name, short_hostname, +}; +use mdns_sd::{ServiceDaemon, ServiceEvent}; +use std::collections::HashMap; +use std::time::Duration; + +/// The service-type constants live in the parent module so every backend shares them; they are +/// re-exported here because callers import them from this path. +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}")))?; + + // Browse all service types simultaneously + 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 || { + // Keyed by (hostname, service_type): the same physical device can advertise + // multiple RPPairing service types at once with different ports (e.g. manual + // pairing vs. an already-established pairing), and those are not interchangeable. + let mut discovered_devices: HashMap<(String, String), DiscoveredDevice> = + HashMap::new(); + let deadline = std::time::Instant::now() + timeout; + + // Poll all receivers until timeout + while std::time::Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + // Short poll interval so we check all receivers fairly + 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()); + + // Shared with the native Windows backend so both produce identical + // `DiscoveredDevice` values. + 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 = ( + short_hostname(hostname).to_ascii_lowercase(), + service_type.clone(), + ); + + discovered_devices.insert(key, device); + } + Ok(_) => { + got_event = true; + } + Err(_) => {} // timeout on this receiver, try next + } + } + + if !got_event && poll_time == remaining { + break; // final poll expired with no events + } + } + + // Stop all browses before dropping to avoid "closed channel" errors + 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..d516c245 --- /dev/null +++ b/crates/plume_utils/src/discovery/mod.rs @@ -0,0 +1,1092 @@ +pub mod mdns; +#[cfg(windows)] +pub mod windows_dnssd; + +use std::collections::HashMap; +use std::net::IpAddr; +use std::path::Path; +use std::time::Duration; + +use crate::{Device, synthetic_device_id}; + +/// Service for devices with no existing pairing, actively showing a pairing PIN on screen. +/// This is the only service that supports first-time SRP pair-setup. +pub const REMOTEPAIRING_MANUAL_PAIRING_SERVICE: &str = "_remotepairing-manual-pairing._tcp.local."; +/// Service for devices with an existing pairing. Reconnect (pair-verify) only - does not +/// support first-time pairing. +pub const REMOTEPAIRING_SERVICE: &str = "_remotepairing._tcp.local."; +/// Legacy lockdown-over-network service, advertised by devices already paired with this host. +pub const APPLE_MOBDEV2_SERVICE: &str = "_apple-mobdev2._tcp.local."; +/// Legacy service advertised by devices willing to accept a new lockdown pairing. +pub const APPLE_PAIRABLE_SERVICE: &str = "_apple-pairable._tcp.local."; +/// Advertised persistently by Macs, iPhones and Apple TVs alike, so unlike the four services +/// above this is not a service anything here connects to. It exists only so entries built from +/// it can supply the model of a same-host entry whose own advertisement carries none - a paired +/// Apple TV that is not on its pairing screen advertises only `_remotepairing._tcp.local.`, +/// whose TXT record has no model at all. See `enrich_and_filter`. +pub const COMPANION_LINK_SERVICE: &str = "_companion-link._tcp.local."; + +/// The service types scanned by every discovery backend, in a fixed order. Every entry built +/// from one of these is returned to callers as a device in its own right. +pub const SERVICE_TYPES: [&str; 4] = [ + APPLE_MOBDEV2_SERVICE, + APPLE_PAIRABLE_SERVICE, + REMOTEPAIRING_SERVICE, + REMOTEPAIRING_MANUAL_PAIRING_SERVICE, +]; + +/// Service types browsed only to enrich `SERVICE_TYPES` entries that lack a model. Entries built +/// from these are never themselves returned as devices - see `enrich_and_filter`. +pub const METADATA_SERVICE_TYPES: [&str; 1] = [COMPANION_LINK_SERVICE]; + +/// Every service type a discovery backend browses in one scan: the four device-producing +/// services followed by the metadata-only 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 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, + /// The mDNS service type this entry was resolved from (e.g. + /// `_remotepairing-manual-pairing._tcp.local.`). Devices advertise different RPPairing + /// services depending on whether they're actively showing a pairing PIN or already paired, + /// and those services listen on different ports - callers must not treat entries from + /// different service types as interchangeable. + pub service_type: String, +} + +// --------------------------------------------------------------------------------------------- +// Shared advertisement mapping +// +// Every backend funnels its raw advertisement facts through these helpers, so the two backends +// produce identical `DiscoveredDevice` values and remain interchangeable. +// --------------------------------------------------------------------------------------------- + +/// Case-insensitive check that `s` ends with `suffix`. +/// +/// Compares bytes rather than slicing the `&str`: instance names carry literal non-ASCII (a real +/// device on this network is named `Frankies MacBook Pro`), and slicing at +/// `len - suffix.len()` panics whenever that offset lands inside a multi-byte character. +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) +} + +/// Extracts the instance label from a full DNS-SD instance name. +/// +/// Instance names arrive with literal spaces and literal non-ASCII - there is no `\032` escaping +/// and no punycode - so the label is recovered by stripping the known `.` suffix, +/// never by splitting on `.`. +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; + // A `.` at `cut` proves `cut` is a character boundary, so the slice below is safe. + if full.as_bytes()[cut] == b'.' { + return full[..cut].to_string(); + } + } + } + full.to_string() +} + +/// Strips the trailing dot and the `.local` label from a host name. +pub(crate) fn short_hostname(hostname: &str) -> &str { + hostname.trim_end_matches('.').trim_end_matches(".local") +} + +/// Key under which a discovered entry is stored. +/// +/// The same physical device legitimately advertises several service types at once on *different* +/// ports (manual pairing versus an established pairing), so the service type is part of the key +/// and those entries must not collapse into one. The host part is lower-cased because DNS names +/// are case-insensitive and the same device may be advertised with differing case. +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()) +} + +/// Looks up the first present key from `candidates`, ignoring empty values. +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()) +} + +/// Builds a [`DiscoveredDevice`] from the facts one backend gathered about a single service +/// instance. Free of any backend-specific or FFI type, so both backends share it verbatim. +pub(crate) fn build_device( + instance_name: &str, + hostname: &str, + service_type: &str, + port: Option, + addresses: &[IpAddr], + props: &HashMap, +) -> DiscoveredDevice { + // Real Apple TV advertisements carry no `DeviceClass` and no `ProductType`: manual pairing + // reports `model=AppleTV14,1` and companion-link reports `rpMd=AppleTV14,1`. Both must map to + // `AppleTV`, because the pairing UI hard-filters on that. + 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 = + first_non_empty(props, &["UniqueDeviceID", "udid", "identifier"]).map(str::to_string); + + // Derived from the hostname first. The name is what the UI uses to correlate a device's + // several service-type entries with each other, so it must be identical across them: one + // device advertises manual pairing under a friendly instance name ("Living Room") but an + // established pairing under a bare UUID, and only the shared hostname yields the same value + // for both. TXT and instance name are fallbacks for advertisements carrying no hostname. + 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, + 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(), + } +} + +/// True when `service_type` is browsed only to enrich other entries and must never itself be +/// surfaced as a device. +pub(crate) fn is_metadata_service(service_type: &str) -> bool { + METADATA_SERVICE_TYPES.contains(&service_type) +} + +/// Fills in missing model information on entries whose own advertisement carries none, using +/// metadata-only advertisements from the same host, then drops those metadata entries. +/// +/// Correlation is by lower-cased `name`: both device-producing and metadata-only entries derive +/// `name` from the advertised host name (see `build_device`), so entries for one physical device +/// share it regardless of which service they came from. +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 = d.name.to_ascii_lowercase(); + 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(&d.name.to_ascii_lowercase()) { + // Values are adopted only from a metadata advertisement that agrees about what + // the device is. Filling individual fields from one that disagrees would produce + // an incoherent entry, such as an Apple TV carrying an iPhone model. + 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() +} + +/// One physical network Apple TV's manual-pairing and reconnect advertisements, correlated by +/// name (see `screen/tvos_pairing.rs`'s `manual_pairing_entry`/`reconnect_entry`, which this +/// mirrors), merged into the single address and port pair `Device::new_tvos` needs. +struct NetworkDeviceGroup { + name: String, + ip: Option, + pairing_port: Option, + reconnect_port: Option, +} + +/// Turns a raw scan result into the `Device` values a global device picker can list and select, +/// one per physical Apple TV. Pure and I/O-free: callers that need a device's real UDID still +/// have to enrich it themselves (see `Device::fetch_tvos_info`), since that requires a network +/// round trip this function deliberately does not perform. +/// +/// Only entries typed `AppleTV` and advertising `REMOTEPAIRING_SERVICE` or +/// `REMOTEPAIRING_MANUAL_PAIRING_SERVICE` are considered; everything else (other device types, +/// the legacy lockdown services, the metadata-only companion-link service) is ignored. Entries +/// are grouped by case-insensitive name, exactly as `screen/tvos_pairing.rs` correlates them, so +/// a device advertising both services yields one `Device` carrying both ports rather than two. +/// +/// A group with no resolved IP address, or an empty name, is dropped rather than turned into a +/// `Device`: an empty name would produce a `pairing_identity` of `""`, which +/// `Device::pairing_cache_path` rejects outright, and would collide every such device onto the +/// same synthetic id. +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; + } + if d.service_type != REMOTEPAIRING_SERVICE + && d.service_type != REMOTEPAIRING_MANUAL_PAIRING_SERVICE + { + continue; + } + if d.name.is_empty() { + continue; + } + + let key = d.name.to_ascii_lowercase(); + let entry = groups.entry(key).or_insert_with(|| NetworkDeviceGroup { + name: d.name.clone(), + ip: None, + pairing_port: None, + reconnect_port: None, + }); + + // The first entry to resolve an address for this device wins; a later entry for the + // same physical device never overrides it. Scan order between the manual-pairing and + // reconnect entries is not guaranteed stable across scans (the Windows backend walks a + // HashMap internally), so preferring "first seen" over "last seen" keeps the chosen + // address from flapping between otherwise-identical scans. + 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() { + // No resolved address means no connection info exists for this Apple TV in this scan; + // it cannot be turned into a usable Device, so it is treated as absent. + let Some(ip) = group.ip else { + continue; + }; + + let pairing_identity = group.name.replace(' ', "-"); + 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(), + ); + // Every network device would otherwise share new_tvos()'s default device_id of 0 and + // collide with each other under the app's id-based device-list dedupe, leaving only one + // Apple TV ever listed no matter how many are actually on the network. + device.device_id = id; + + devices.push(device); + } + + devices +} + +#[allow(async_fn_in_trait)] +pub trait DeviceDiscovery { + async fn discover(&self, timeout: Duration) -> crate::Result>; +} + +/// Discovery backend appropriate for the host platform. +/// +/// On Windows the native DNS-SD resolver (`dnsapi.dll`) is tried first, because the +/// raw-multicast-socket backend does not reliably receive responses there; if it errors or +/// finds nothing, the `mdns-sd` backend runs as a fallback. Every other platform uses +/// `mdns-sd` directly. +#[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> { + // The fallback runs inside the caller's budget, not in addition to it: the two backends + // together never exceed `timeout`. A native scan that finds nothing returns after its + // browse budget, which is what leaves the fallback a usable slice. + #[cfg(windows)] + let timeout = { + let started = std::time::Instant::now(); + + match windows_dnssd::WindowsDnsSdDiscovery::new() + .discover(timeout) + .await + { + Ok(devices) if !devices.is_empty() => return Ok(devices), + Ok(_) => log::warn!( + "Windows DNS-SD discovery returned no devices, falling back to mdns-sd" + ), + Err(e) => { + log::warn!("Windows DNS-SD discovery failed ({e}), falling back to mdns-sd") + } + } + + let remaining = timeout.saturating_sub(started.elapsed()); + if remaining.is_zero() { + log::warn!("No time left for the mdns-sd fallback within the requested timeout"); + return Ok(Vec::new()); + } + remaining + }; + + 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() { + // The suffix comparison must not slice the string at `len - suffix.len()`: here that + // offset lands inside the leading U+2019, which panics on a `&str` slice. + let name = "\u{2019}".to_string() + &"X".repeat(24); + assert_eq!(parse_instance_name(&name, REMOTEPAIRING_SERVICE), name); + + // Same hazard with the multi-byte character straddling the boundary from the other side. + 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")); + // DNS names are case-insensitive, so a differently-cased service label still strips. + assert_eq!( + parse_instance_name( + "Living Room._RemotePairing._TCP.local", + REMOTEPAIRING_SERVICE + ), + "Living Room" + ); + } + + #[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() { + // Manual pairing: no DeviceClass, no ProductType, model only. The pairing UI filters on + // `device_type == AppleTV`, so anything else drops the device before the user sees it. + 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")); + + // Companion-link style: rpMd only. + 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.as_deref(), Some("deadbeef")); + } + + #[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.as_deref(), Some("abc123")); + 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() { + // Instance label and TXT name both differ from the host name; the host name still wins. + 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() { + // No host name: the TXT name is next. + let d = build_device( + "instance-label", + "", + REMOTEPAIRING_SERVICE, + Some(1), + &[], + &props(&[("name", "Txt Name")]), + ); + assert_eq!(d.name, "Txt Name"); + + // Neither host name nor TXT name: the parsed instance label is last. + 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() { + // An Apple TV advertises manual pairing under a friendly instance name carrying a TXT + // `name`, and an established pairing under a bare UUID with no TXT `name` at all (both + // shapes taken from a packet capture of a real pairing session). The UI correlates a + // device's service-type entries by name, so both must resolve to the same name while + // remaining separate entries with their own ports. + 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_udid_precedence() { + let p = props(&[("udid", "second"), ("identifier", "third")]); + assert_eq!( + build_device("x", "", REMOTEPAIRING_SERVICE, Some(1), &[], &p) + .udid + .as_deref(), + Some("second") + ); + let p = props(&[("identifier", "third")]); + assert_eq!( + build_device("x", "", REMOTEPAIRING_SERVICE, Some(1), &[], &p) + .udid + .as_deref(), + Some("third") + ); + } + + #[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(), + 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(), + 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; + // A companion-link entry disagreeing with an already-known type must not win. + 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); + // Nor may its other fields be filled from that disagreeing entry: an Apple TV carrying + // an iPhone model is worse than an Apple TV carrying no model at all. + assert_eq!(result[0].product_type, None); + } + + #[test] + fn enrich_and_filter_prefers_a_typed_metadata_entry_regardless_of_order() { + // Several metadata entries can share a name; the one that actually identifies the + // device must win whichever order they arrive in. + 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(), + 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_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_non_rppairing_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); + // The first entry encountered (the manual-pairing one, listed first above) sets the + // address; the later reconnect entry for the same device does not override it. + 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/discovery/windows_dnssd.rs b/crates/plume_utils/src/discovery/windows_dnssd.rs new file mode 100644 index 00000000..70c831b4 --- /dev/null +++ b/crates/plume_utils/src/discovery/windows_dnssd.rs @@ -0,0 +1,1062 @@ +//! DNS-SD discovery through the native Windows resolver (`dnsapi.dll`). +//! +//! The `mdns-sd` backend binds its own UDP socket on port 5353 and, on Windows, does not +//! receive multicast responses even when the OS resolver (Dnscache) sees the same devices. +//! This backend hands the query to the OS instead, via `DnsServiceBrowse` / `DnsServiceResolve`. +//! +//! Discovery runs in two stages because the browse callback delivers records +//! non-deterministically: some runs yield PTR records only, with no SRV in an eight-second +//! window. Stage A browses each service type and collects deduplicated instance names (using +//! any SRV/TXT/A records that happen to arrive as an opportunistic fast path). Stage B +//! resolves every instance that still lacks a port. A service type with no advertiser never +//! invokes the callback at all - no error and no negative result - so both stages are bounded +//! purely by the caller's timeout. + +use super::{ + ALL_SCANNED_SERVICE_TYPES, COMPANION_LINK_SERVICE, DeviceDiscovery, DiscoveredDevice, + REMOTEPAIRING_MANUAL_PAIRING_SERVICE, REMOTEPAIRING_SERVICE, build_device, dedup_key, + enrich_and_filter, parse_instance_name, +}; +use std::collections::HashMap; +use std::ffi::c_void; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::panic::{AssertUnwindSafe, catch_unwind}; +use std::ptr; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::mpsc::{RecvTimeoutError, SyncSender, sync_channel}; +use std::time::{Duration, Instant}; + +use windows_sys::Win32::NetworkManagement::Dns::{ + DNS_QUERY_REQUEST_VERSION1, DNS_RECORDW, DNS_SERVICE_BROWSE_REQUEST, + DNS_SERVICE_BROWSE_REQUEST_0, DNS_SERVICE_CANCEL, DNS_SERVICE_INSTANCE, + DNS_SERVICE_RESOLVE_REQUEST, DNS_TXT_DATAW, DNS_TYPE_A, DNS_TYPE_AAAA, DNS_TYPE_PTR, + DNS_TYPE_SRV, DNS_TYPE_TEXT, DnsServiceBrowse, DnsServiceBrowseCancel, DnsServiceFreeInstance, + DnsServiceResolve, DnsServiceResolveCancel, +}; +use windows_sys::core::PWSTR; + +/// `DnsServiceBrowse` / `DnsServiceResolve` return this when the operation was accepted and +/// its callback will run later on a threadpool thread. +const DNS_REQUEST_PENDING: i32 = 9506; +const ERROR_SUCCESS: i32 = 0; + +/// Query all interfaces. Verified to cover both WiFi and Ethernet on a dual-homed host. +const ALL_INTERFACES: u32 = 0; + +/// Upper bound on a single UTF-16 string read out of an OS-owned buffer. DNS names cap at 255 +/// bytes and TXT strings at 255 bytes per string, so this only exists to stop a runaway scan if +/// a buffer is not terminated. +const MAX_WIDE_CHARS: usize = 8192; +/// Upper bound on records walked in one browse callback chain. +const MAX_RECORD_CHAIN: usize = 512; +/// Upper bound on TXT strings / instance properties read from one record. +const MAX_PROPERTIES: usize = 256; +/// Upper bound on concurrent `DnsServiceResolve` operations. +const MAX_RESOLVES: usize = 32; + +/// Browse events are produced on OS threadpool threads and drained by the scanning thread. +/// The OS re-delivers the same instance a dozen or more times per scan, so this is sized to +/// absorb bursts; a full channel drops the event rather than blocking a threadpool thread. +const BROWSE_CHANNEL_CAPACITY: usize = 512; + +/// Stop browsing once no new instance has appeared for this long, so the remaining budget can +/// go to stage B. +const BROWSE_QUIET_PERIOD: Duration = Duration::from_millis(1200); + +/// Fraction of the caller's timeout spent in stage A; the rest is stage B. +const BROWSE_BUDGET_FRACTION: f64 = 0.6; + +// --------------------------------------------------------------------------------------------- +// Pure helpers (unit-tested without FFI) +// --------------------------------------------------------------------------------------------- + +/// The name passed to the Win32 DNS-SD API. The shared service-type constants carry a trailing +/// dot for mDNS presentation; `dnsapi` wants the name without one. +fn query_name(service_type: &str) -> &str { + service_type.trim_end_matches('.') +} + +/// Resolve ordering for one service type. The RPPairing services are what this application +/// exists to find, so they are resolved first and are never the entries dropped when the +/// concurrency cap truncates the candidate list. Companion-link is metadata that only enriches +/// an RPPairing entry, so it ranks below RPPairing but still above the legacy lockdown services. +fn resolve_priority(service_type: &str) -> u8 { + if service_type == REMOTEPAIRING_MANUAL_PAIRING_SERVICE || service_type == REMOTEPAIRING_SERVICE + { + 0 + } else if service_type == COMPANION_LINK_SERVICE { + 1 + } else { + 2 + } +} + +/// Orders stage-B candidates deterministically: RPPairing services first, then by service index +/// and instance key. `HashMap` iteration order is arbitrary, so without this the concurrency cap +/// would drop a different, arbitrary subset on every scan. +fn order_resolve_candidates( + mut candidates: Vec<(usize, String)>, + service_types: &[String], +) -> Vec<(usize, String)> { + candidates.sort_by(|a, b| { + let pa = service_types + .get(a.0) + .map_or(u8::MAX, |s| resolve_priority(s)); + let pb = service_types + .get(b.0) + .map_or(u8::MAX, |s| resolve_priority(s)); + pa.cmp(&pb).then(a.0.cmp(&b.0)).then_with(|| a.1.cmp(&b.1)) + }); + candidates +} + +/// Splits a `key=value` TXT string. A string with no `=` is a valueless key. +fn split_txt(entry: &str) -> (String, String) { + match entry.split_once('=') { + Some((k, v)) => (k.to_string(), v.to_string()), + None => (entry.to_string(), String::new()), + } +} + +// --------------------------------------------------------------------------------------------- +// UTF-16 helpers +// --------------------------------------------------------------------------------------------- + +/// NUL-terminated UTF-16 buffer for passing a Rust string to the Win32 API. +fn to_wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +/// Decodes an OS-owned NUL-terminated UTF-16 string. +/// +/// # Safety +/// `p` must be null or point to a NUL-terminated UTF-16 buffer that stays valid for the call. +unsafe fn wide_to_string(p: *const u16) -> String { + if p.is_null() { + return String::new(); + } + let mut len = 0usize; + while len < MAX_WIDE_CHARS { + if unsafe { *p.add(len) } == 0 { + break; + } + len += 1; + } + let slice = unsafe { std::slice::from_raw_parts(p, len) }; + String::from_utf16_lossy(slice) +} + +// --------------------------------------------------------------------------------------------- +// Stage A: DnsServiceBrowse +// --------------------------------------------------------------------------------------------- + +enum BrowseEvent { + /// A PTR record: the full instance name for the browsed service type. + Instance { + service_idx: usize, + full_name: String, + }, + /// An SRV record: owner name is the full instance name. + Srv { + service_idx: usize, + full_name: String, + target: String, + port: u16, + }, + /// A TXT record: owner name is the full instance name. + Txt { + service_idx: usize, + full_name: String, + strings: Vec, + }, + /// An A/AAAA record: owner name is a host name, not an instance name. + Address { host: String, addr: IpAddr }, +} + +struct BrowseContext { + service_idx: usize, + tx: SyncSender, +} + +/// Keeps every allocation a single in-flight browse points at alive for the browse's lifetime. +struct BrowseHandle { + /// `DNS_SERVICE_BROWSE_REQUEST::QueryName` points into this buffer. + _query: Vec, + /// The API writes its internal handle into `reserved`, so this must not move; it is also + /// what `DnsServiceBrowseCancel` is given. + cancel: Box, + /// The only strong reference to the context while the browse runs, owned by the OS as + /// `pQueryContext`. Reclaimed once the cancel confirms no callback can still run; + /// deliberately never reclaimed if the cancel fails. + ctx_raw: *const BrowseContext, +} + +/// Browse callback, invoked on a Windows threadpool thread. +/// +/// `DnsServiceBrowseCancel` re-enters this function synchronously on the *cancelling* thread +/// with `status = ERROR_CANCELLED` and a NULL record. The NULL check below is therefore +/// mandatory, and the body must never take a lock the cancelling thread might already hold - +/// it only performs a non-blocking channel send. +unsafe extern "system" fn browse_callback( + _status: u32, + pquerycontext: *const c_void, + pdnsrecord: *const DNS_RECORDW, +) { + if pquerycontext.is_null() || pdnsrecord.is_null() { + return; + } + // A panic must not unwind into the OS threadpool. + let _ = catch_unwind(AssertUnwindSafe(|| { + let ctx = unsafe { &*(pquerycontext as *const BrowseContext) }; + unsafe { walk_browse_records(pdnsrecord, ctx) }; + })); +} + +/// Walks the linked record chain delivered to a browse callback. +/// +/// The API retains ownership of this chain; it must not be passed to `DnsFree`. +/// +/// # Safety +/// `head` must be a valid `DNS_RECORDW` chain owned by the caller of the browse callback. +unsafe fn walk_browse_records(head: *const DNS_RECORDW, ctx: &BrowseContext) { + let mut cur = head; + let mut visited = 0usize; + + while !cur.is_null() && visited < MAX_RECORD_CHAIN { + visited += 1; + let rec = unsafe { &*cur }; + let owner = unsafe { wide_to_string(rec.pName) }; + let idx = ctx.service_idx; + + let event = match rec.wType { + DNS_TYPE_PTR => { + let target = unsafe { wide_to_string(rec.Data.Ptr.pNameHost) }; + if target.is_empty() { + None + } else { + Some(BrowseEvent::Instance { + service_idx: idx, + full_name: target, + }) + } + } + DNS_TYPE_SRV => { + let srv = unsafe { rec.Data.Srv }; + if owner.is_empty() { + None + } else { + Some(BrowseEvent::Srv { + service_idx: idx, + full_name: owner, + target: unsafe { wide_to_string(srv.pNameTarget) }, + port: srv.wPort, + }) + } + } + DNS_TYPE_TEXT => { + // `pStringArray` is a flexible array member, so the strings must be read + // through the record the OS owns - never through a copy of the union, which + // only has room for one element. + let txt: *const DNS_TXT_DATAW = unsafe { ptr::addr_of!((*cur).Data.Txt) }; + let count = (unsafe { (*txt).dwStringCount } as usize).min(MAX_PROPERTIES); + let base: *const PWSTR = unsafe { ptr::addr_of!((*txt).pStringArray) }.cast(); + let mut strings = Vec::with_capacity(count); + for i in 0..count { + let s = unsafe { wide_to_string(*base.add(i)) }; + if !s.is_empty() { + strings.push(s); + } + } + if owner.is_empty() || strings.is_empty() { + None + } else { + Some(BrowseEvent::Txt { + service_idx: idx, + full_name: owner, + strings, + }) + } + } + DNS_TYPE_A => { + // IP4_ADDRESS holds the four octets in network order inside a u32. + let raw = unsafe { rec.Data.A.IpAddress }; + if owner.is_empty() { + None + } else { + Some(BrowseEvent::Address { + host: owner, + addr: IpAddr::V4(Ipv4Addr::from(raw.to_ne_bytes())), + }) + } + } + DNS_TYPE_AAAA => { + let bytes = unsafe { rec.Data.AAAA.Ip6Address.IP6Byte }; + if owner.is_empty() { + None + } else { + Some(BrowseEvent::Address { + host: owner, + addr: IpAddr::V6(Ipv6Addr::from(bytes)), + }) + } + } + _ => None, + }; + + if let Some(event) = event { + // Non-blocking: a full channel drops the event rather than stalling a threadpool + // thread. The OS re-delivers every instance many times per scan. + let _ = ctx.tx.try_send(event); + } + + cur = rec.pNext as *const DNS_RECORDW; + } +} + +/// What the two stages learned about one service instance. +#[derive(Default)] +struct InstanceState { + /// Instance name as delivered, with original casing. + full_name: String, + hostname: Option, + port: Option, + props: HashMap, + /// Addresses reported directly by `DnsServiceResolve`, which are authoritative for this + /// instance and take precedence over A/AAAA records seen during the browse. + resolved_addresses: Vec, +} + +// --------------------------------------------------------------------------------------------- +// Stage B: DnsServiceResolve +// --------------------------------------------------------------------------------------------- + +struct ResolveOutcome { + hostname: String, + port: u16, + props: HashMap, + addresses: Vec, +} + +struct ResolveContext { + idx: usize, + /// Set by the completion callback before it publishes its result. Read on the scanning + /// thread to decide whether a cancel is still needed; a completion that has already run + /// has released its cancel handle, so cancelling it again would touch freed state. + completed: AtomicBool, + tx: SyncSender<(usize, Option)>, +} + +struct ResolveHandle { + /// `DNS_SERVICE_RESOLVE_REQUEST::QueryName` is a mutable `PWSTR` into this buffer. + _query: Vec, + cancel: Box, + ctx_raw: *const ResolveContext, + ctx: Arc, +} + +/// Resolve callback, invoked on a Windows threadpool thread. +/// +/// Unlike the browse chain, the `DNS_SERVICE_INSTANCE` handed here is caller-owned: every +/// needed field is copied into owned Rust types and the instance is then released with +/// `DnsServiceFreeInstance`. The free is deliberately outside the `catch_unwind` that guards +/// the copy, so a panic during copying still releases the instance. +unsafe extern "system" fn resolve_callback( + status: u32, + pquerycontext: *const c_void, + pinstance: *const DNS_SERVICE_INSTANCE, +) { + let outcome = catch_unwind(AssertUnwindSafe(|| { + if status == 0 && !pinstance.is_null() { + Some(unsafe { copy_instance(pinstance) }) + } else { + None + } + })) + .unwrap_or(None); + + if !pinstance.is_null() { + unsafe { DnsServiceFreeInstance(pinstance) }; + } + + if pquerycontext.is_null() { + return; + } + let _ = catch_unwind(AssertUnwindSafe(move || { + let ctx = unsafe { &*(pquerycontext as *const ResolveContext) }; + // Published before the result, so the scanning thread never observes a delivered result + // without also observing that this operation completed. + ctx.completed.store(true, Ordering::Release); + let _ = ctx.tx.try_send((ctx.idx, outcome)); + })); +} + +/// # Safety +/// `p` must point to a valid `DNS_SERVICE_INSTANCE` that stays valid for the call. +unsafe fn copy_instance(p: *const DNS_SERVICE_INSTANCE) -> ResolveOutcome { + let inst = unsafe { &*p }; + + let mut props = HashMap::new(); + if !inst.keys.is_null() && !inst.values.is_null() { + let count = (inst.dwPropertyCount as usize).min(MAX_PROPERTIES); + for i in 0..count { + let key = unsafe { wide_to_string(*inst.keys.add(i)) }; + if key.is_empty() { + continue; + } + let value = unsafe { wide_to_string(*inst.values.add(i)) }; + props.insert(key, value); + } + } + + let mut addresses = Vec::new(); + if !inst.ip4Address.is_null() { + let raw = unsafe { *inst.ip4Address }; + addresses.push(IpAddr::V4(Ipv4Addr::from(raw.to_ne_bytes()))); + } + if !inst.ip6Address.is_null() { + let bytes = unsafe { (*inst.ip6Address).IP6Byte }; + addresses.push(IpAddr::V6(Ipv6Addr::from(bytes))); + } + + ResolveOutcome { + hostname: unsafe { wide_to_string(inst.pszHostName) }, + port: inst.wPort, + props, + addresses, + } +} + +// --------------------------------------------------------------------------------------------- +// Backend +// --------------------------------------------------------------------------------------------- + +pub struct WindowsDnsSdDiscovery { + service_types: Vec, +} + +impl WindowsDnsSdDiscovery { + pub fn new() -> Self { + Self { + service_types: ALL_SCANNED_SERVICE_TYPES + .iter() + .map(|s| s.to_string()) + .collect(), + } + } +} + +impl Default for WindowsDnsSdDiscovery { + fn default() -> Self { + Self::new() + } +} + +impl DeviceDiscovery for WindowsDnsSdDiscovery { + async fn discover(&self, timeout: Duration) -> crate::Result> { + let service_types = self.service_types.clone(); + + tokio::task::spawn_blocking(move || scan(&service_types, timeout)) + .await + .map_err(|e| crate::Error::Other(format!("Windows DNS-SD scan task failed: {e}")))? + } +} + +fn scan(service_types: &[String], timeout: Duration) -> crate::Result> { + let start = Instant::now(); + let total_deadline = start + timeout; + let browse_deadline = start + timeout.mul_f64(BROWSE_BUDGET_FRACTION); + + let (instances, host_addresses) = browse_stage(service_types, browse_deadline)?; + let instances = resolve_stage(service_types, instances, total_deadline); + + let mut devices: HashMap<(String, String), DiscoveredDevice> = HashMap::new(); + for ((service_idx, _), state) in instances { + let Some(service_type) = service_types.get(service_idx) else { + continue; + }; + let Some(port) = state.port else { + log::debug!( + "Windows DNS-SD: dropping {} ({}) - no port after resolve", + state.full_name, + service_type + ); + continue; + }; + + let hostname = state.hostname.clone().unwrap_or_default(); + let instance_name = parse_instance_name(&state.full_name, service_type); + + let mut addresses = state.resolved_addresses.clone(); + for addr in host_addresses + .get(&hostname.trim_end_matches('.').to_ascii_lowercase()) + .into_iter() + .flatten() + { + if !addresses.contains(addr) { + addresses.push(*addr); + } + } + + log::debug!( + "Windows DNS-SD resolved: instance={instance_name} host={hostname} service={service_type} port={port} addrs={addresses:?}" + ); + + let device = build_device( + &instance_name, + &hostname, + service_type, + Some(port), + &addresses, + &state.props, + ); + devices.insert(dedup_key(&hostname, &instance_name, service_type), device); + } + + Ok(enrich_and_filter(devices.into_values().collect())) +} + +fn browse_stage( + service_types: &[String], + deadline: Instant, +) -> crate::Result<( + HashMap<(usize, String), InstanceState>, + HashMap>, +)> { + let (tx, rx) = sync_channel::(BROWSE_CHANNEL_CAPACITY); + let mut handles: Vec = Vec::with_capacity(service_types.len()); + + for (idx, service_type) in service_types.iter().enumerate() { + let query = to_wide(query_name(service_type)); + let ctx = Arc::new(BrowseContext { + service_idx: idx, + tx: tx.clone(), + }); + // The OS holds this strong reference for as long as a callback can be in flight, so an + // in-flight threadpool callback can never see a freed context. + let ctx_raw = Arc::into_raw(ctx); + let mut cancel = Box::new(DNS_SERVICE_CANCEL { + reserved: ptr::null_mut(), + }); + + let request = DNS_SERVICE_BROWSE_REQUEST { + Version: DNS_QUERY_REQUEST_VERSION1, + InterfaceIndex: ALL_INTERFACES, + QueryName: query.as_ptr(), + Anonymous: DNS_SERVICE_BROWSE_REQUEST_0 { + pBrowseCallback: Some(browse_callback), + }, + pQueryContext: ctx_raw as *mut c_void, + }; + + let status = unsafe { DnsServiceBrowse(&request, &mut *cancel) }; + if status == ERROR_SUCCESS || status == DNS_REQUEST_PENDING { + handles.push(BrowseHandle { + _query: query, + cancel, + ctx_raw, + }); + } else { + log::warn!("DnsServiceBrowse({service_type}) failed with status {status}"); + // The browse never started, so no callback can be pending. + drop(unsafe { Arc::from_raw(ctx_raw) }); + } + } + + if handles.is_empty() { + return Err(crate::Error::Other( + "DnsServiceBrowse failed for every service type".to_string(), + )); + } + drop(tx); + + let mut instances: HashMap<(usize, String), InstanceState> = HashMap::new(); + let mut host_addresses: HashMap> = HashMap::new(); + let mut last_progress = Instant::now(); + + loop { + let now = Instant::now(); + if now >= deadline { + break; + } + // A service type with no advertiser never fires its callback, so an expired poll is a + // normal outcome and never an error. + let wait = deadline + .saturating_duration_since(now) + .min(Duration::from_millis(200)); + match rx.recv_timeout(wait) { + Ok(event) => { + if apply_browse_event(event, &mut instances, &mut host_addresses) { + last_progress = Instant::now(); + } + } + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => break, + } + if !instances.is_empty() && last_progress.elapsed() >= BROWSE_QUIET_PERIOD { + break; + } + } + + // `DnsServiceBrowseCancel` re-enters `browse_callback` synchronously on this thread with a + // NULL record before it returns, so callback dispatch for a browse is serialized against + // this thread: once a cancel reports success, no callback is running for that browse and + // none can be dispatched afterwards. That is what makes reclaiming the OS's reference safe. + for handle in handles { + let status = unsafe { DnsServiceBrowseCancel(&*handle.cancel) }; + if status == ERROR_SUCCESS { + drop(unsafe { Arc::from_raw(handle.ctx_raw) }); + } else { + // The browse is still live and its callback may still fire. Leak the context, the + // cancel block and the query buffer rather than free memory the OS still points at: + // a bounded leak on a path that should never happen beats a use-after-free. + log::warn!( + "DnsServiceBrowseCancel returned status {status}; leaking the browse context" + ); + std::mem::forget(handle); + } + } + + // Events queued before the cancels completed are still worth keeping. + while let Ok(event) = rx.try_recv() { + apply_browse_event(event, &mut instances, &mut host_addresses); + } + + Ok((instances, host_addresses)) +} + +/// Folds one browse event into the accumulators. Returns true when it added new information. +fn apply_browse_event( + event: BrowseEvent, + instances: &mut HashMap<(usize, String), InstanceState>, + host_addresses: &mut HashMap>, +) -> bool { + match event { + BrowseEvent::Instance { + service_idx, + full_name, + } => { + let key = ( + service_idx, + full_name.trim_end_matches('.').to_ascii_lowercase(), + ); + match instances.entry(key) { + std::collections::hash_map::Entry::Occupied(_) => false, + std::collections::hash_map::Entry::Vacant(slot) => { + slot.insert(InstanceState { + full_name, + ..Default::default() + }); + true + } + } + } + BrowseEvent::Srv { + service_idx, + full_name, + target, + port, + } => { + let key = ( + service_idx, + full_name.trim_end_matches('.').to_ascii_lowercase(), + ); + let state = instances.entry(key).or_insert_with(|| InstanceState { + full_name, + ..Default::default() + }); + let changed = state.port != Some(port); + state.port = Some(port); + if !target.is_empty() { + state.hostname = Some(target); + } + changed + } + BrowseEvent::Txt { + service_idx, + full_name, + strings, + } => { + let key = ( + service_idx, + full_name.trim_end_matches('.').to_ascii_lowercase(), + ); + let state = instances.entry(key).or_insert_with(|| InstanceState { + full_name, + ..Default::default() + }); + let mut changed = false; + for entry in strings { + let (k, v) = split_txt(&entry); + if state.props.insert(k, v).is_none() { + changed = true; + } + } + changed + } + BrowseEvent::Address { host, addr } => { + let list = host_addresses + .entry(host.trim_end_matches('.').to_ascii_lowercase()) + .or_default(); + if list.contains(&addr) { + false + } else { + list.push(addr); + true + } + } + } +} + +/// Resolves every instance that still lacks a port or arrived with no TXT properties. All +/// resolves are issued up front and awaited together so one slow instance does not consume the +/// whole budget. +/// +/// An instance that got an SRV record but no TXT still needs resolving: without properties it +/// maps to `DeviceType::Unknown` and the pairing UI filters it away. +fn resolve_stage( + service_types: &[String], + mut instances: HashMap<(usize, String), InstanceState>, + deadline: Instant, +) -> HashMap<(usize, String), InstanceState> { + let candidates: Vec<(usize, String)> = instances + .iter() + .filter(|(_, state)| state.port.is_none() || state.props.is_empty()) + .map(|(key, _)| key.clone()) + .collect(); + + let mut pending = order_resolve_candidates(candidates, service_types); + if pending.len() > MAX_RESOLVES { + log::warn!( + "{} instances need resolving but only {MAX_RESOLVES} run concurrently; \ + dropping {} lower-priority candidates this scan", + pending.len(), + pending.len() - MAX_RESOLVES + ); + pending.truncate(MAX_RESOLVES); + } + + if pending.is_empty() || Instant::now() >= deadline { + return instances; + } + + // Sized so a completion never has to drop its result. + let (tx, rx) = sync_channel::<(usize, Option)>(pending.len() + 8); + let mut handles: Vec = Vec::with_capacity(pending.len()); + + for (i, key) in pending.iter().enumerate() { + let Some(state) = instances.get(key) else { + continue; + }; + // QueryName is a mutable PWSTR; the buffer must outlive the operation, so ownership + // moves into the handle rather than ending with this loop iteration. + let mut query = to_wide(state.full_name.trim_end_matches('.')); + let ctx = Arc::new(ResolveContext { + idx: i, + completed: AtomicBool::new(false), + tx: tx.clone(), + }); + let ctx_raw = Arc::into_raw(ctx.clone()); + let mut cancel = Box::new(DNS_SERVICE_CANCEL { + reserved: ptr::null_mut(), + }); + + let request = DNS_SERVICE_RESOLVE_REQUEST { + Version: DNS_QUERY_REQUEST_VERSION1, + InterfaceIndex: ALL_INTERFACES, + QueryName: query.as_mut_ptr(), + pResolveCompletionCallback: Some(resolve_callback), + pQueryContext: ctx_raw as *mut c_void, + }; + + let status = unsafe { DnsServiceResolve(&request, &mut *cancel) }; + if status == ERROR_SUCCESS || status == DNS_REQUEST_PENDING { + handles.push(ResolveHandle { + _query: query, + cancel, + ctx_raw, + ctx, + }); + } else { + log::debug!( + "DnsServiceResolve({}) failed with status {status}", + state.full_name + ); + drop(unsafe { Arc::from_raw(ctx_raw) }); + } + } + + if handles.is_empty() { + return instances; + } + drop(tx); + + let mut outcomes: HashMap = HashMap::new(); + let mut done = 0usize; + while done < handles.len() { + let now = Instant::now(); + if now >= deadline { + break; + } + let wait = deadline + .saturating_duration_since(now) + .min(Duration::from_millis(200)); + match rx.recv_timeout(wait) { + Ok((idx, outcome)) => { + done += 1; + if let Some(outcome) = outcome { + outcomes.insert(idx, outcome); + } + } + Err(RecvTimeoutError::Timeout) => {} + Err(RecvTimeoutError::Disconnected) => break, + } + } + + // Late completions whose results arrived after the deadline are still usable. + while let Ok((idx, outcome)) = rx.try_recv() { + if let Some(outcome) = outcome { + outcomes.insert(idx, outcome); + } + } + + for handle in handles { + // Read from the context rather than from the channel drain: the callback sets this + // before publishing its result, so a completion that raced the drain is still seen here + // and its cancel handle - already released by the API - is left alone. + if handle.ctx.completed.load(Ordering::Acquire) { + // The completion callback runs at most once and has already run. + drop(unsafe { Arc::from_raw(handle.ctx_raw) }); + continue; + } + + let status = unsafe { DnsServiceResolveCancel(&*handle.cancel) }; + if status == ERROR_SUCCESS { + drop(unsafe { Arc::from_raw(handle.ctx_raw) }); + } else { + // The resolve is still live and its callback may still fire. Leak rather than free + // memory the OS still points at. + log::warn!( + "DnsServiceResolveCancel returned status {status}; leaking the resolve context" + ); + std::mem::forget(handle); + } + } + + for (idx, outcome) in outcomes { + let Some(key) = pending.get(idx) else { + continue; + }; + let Some(state) = instances.get_mut(key) else { + continue; + }; + if !outcome.hostname.is_empty() { + state.hostname = Some(outcome.hostname); + } + // Instances are also resolved for their TXT properties alone, so a resolve that reports + // no port must not clobber a port already learned from an SRV record. + if outcome.port != 0 { + state.port = Some(outcome.port); + } + for (k, v) in outcome.props { + state.props.entry(k).or_insert(v); + } + state.resolved_addresses = outcome.addresses; + log::debug!( + "Windows DNS-SD resolve: {} -> port {} ({})", + state.full_name, + outcome.port, + service_types + .get(key.0) + .map(String::as_str) + .unwrap_or("unknown service") + ); + } + + instances +} + +// --------------------------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use crate::discovery::APPLE_MOBDEV2_SERVICE; + + /// The instance-name, TXT-property and dedup-key mapping is shared with the `mdns-sd` + /// backend and is tested in `super::super` so it runs on every platform. What follows is + /// specific to this backend. + #[test] + fn query_name_strips_trailing_dot() { + assert_eq!( + query_name(REMOTEPAIRING_SERVICE), + "_remotepairing._tcp.local" + ); + assert_eq!( + query_name(REMOTEPAIRING_MANUAL_PAIRING_SERVICE), + "_remotepairing-manual-pairing._tcp.local" + ); + assert_eq!(query_name("_x._tcp.local"), "_x._tcp.local"); + } + + #[test] + fn to_wide_is_nul_terminated() { + let w = to_wide("ab"); + assert_eq!(w, vec![b'a' as u16, b'b' as u16, 0]); + } + + #[test] + fn txt_split_handles_valueless_keys() { + assert_eq!( + split_txt("model=AppleTV14,1"), + ("model".into(), "AppleTV14,1".into()) + ); + assert_eq!(split_txt("flag"), ("flag".into(), String::new())); + assert_eq!(split_txt("k="), ("k".into(), String::new())); + } + + #[test] + fn resolve_candidates_put_rppairing_services_first() { + let service_types: Vec = ALL_SCANNED_SERVICE_TYPES + .iter() + .map(|s| s.to_string()) + .collect(); + // Indices follow ALL_SCANNED_SERVICE_TYPES: 0 mobdev2, 1 pairable, 2 remotepairing, + // 3 manual pairing, 4 companion-link. + let candidates = vec![ + (0usize, "b-mobdev2".to_string()), + (4usize, "e-companion".to_string()), + (3usize, "d-manual".to_string()), + (1usize, "a-pairable".to_string()), + (2usize, "c-remotepairing".to_string()), + ]; + + let ordered = order_resolve_candidates(candidates.clone(), &service_types); + assert_eq!( + ordered.iter().map(|c| c.0).collect::>(), + vec![2, 3, 4, 0, 1], + "RPPairing services resolve first, companion-link next, legacy lockdown services last" + ); + + // Ordering is a pure function of the input set, not of HashMap iteration order. + let mut shuffled = candidates; + shuffled.reverse(); + assert_eq!( + order_resolve_candidates(shuffled, &service_types), + ordered, + "ordering must be independent of the order candidates were collected in" + ); + } + + #[test] + fn resolve_priority_ranks_pairing_above_companion_link_above_legacy() { + assert_eq!(resolve_priority(REMOTEPAIRING_MANUAL_PAIRING_SERVICE), 0); + assert_eq!(resolve_priority(REMOTEPAIRING_SERVICE), 0); + assert_eq!(resolve_priority(COMPANION_LINK_SERVICE), 1); + assert_eq!(resolve_priority(APPLE_MOBDEV2_SERVICE), 2); + } + + #[test] + fn browse_events_accumulate_into_instance_state() { + let mut instances = HashMap::new(); + let mut addrs = HashMap::new(); + let full = "Living Room._remotepairing._tcp.local".to_string(); + + assert!(apply_browse_event( + BrowseEvent::Instance { + service_idx: 2, + full_name: full.clone(), + }, + &mut instances, + &mut addrs, + )); + // The OS re-delivers the same instance many times; the repeat adds nothing. + assert!(!apply_browse_event( + BrowseEvent::Instance { + service_idx: 2, + full_name: full.clone(), + }, + &mut instances, + &mut addrs, + )); + assert!(apply_browse_event( + BrowseEvent::Srv { + service_idx: 2, + full_name: full.clone(), + target: "Living-Room.local".to_string(), + port: 49152, + }, + &mut instances, + &mut addrs, + )); + assert!(apply_browse_event( + BrowseEvent::Txt { + service_idx: 2, + full_name: full.clone(), + strings: vec!["model=AppleTV14,1".to_string()], + }, + &mut instances, + &mut addrs, + )); + assert!(apply_browse_event( + BrowseEvent::Address { + host: "Living-Room.local".to_string(), + addr: "10.0.0.5".parse().unwrap(), + }, + &mut instances, + &mut addrs, + )); + + assert_eq!(instances.len(), 1); + let state = instances.values().next().unwrap(); + assert_eq!(state.port, Some(49152)); + assert_eq!(state.hostname.as_deref(), Some("Living-Room.local")); + assert_eq!( + state.props.get("model").map(String::as_str), + Some("AppleTV14,1") + ); + assert_eq!(addrs.get("living-room.local").unwrap().len(), 1); + } + + #[test] + fn instances_from_different_service_types_stay_separate() { + let mut instances = HashMap::new(); + let mut addrs = HashMap::new(); + for idx in [2usize, 3usize] { + apply_browse_event( + BrowseEvent::Instance { + service_idx: idx, + full_name: "Living Room._x._tcp.local".to_string(), + }, + &mut instances, + &mut addrs, + ); + } + assert_eq!(instances.len(), 2); + } + + #[test] + fn wide_round_trip() { + let w = to_wide("Frankie\u{2019}s MacBook Pro"); + let s = unsafe { wide_to_string(w.as_ptr()) }; + assert_eq!(s, "Frankie\u{2019}s MacBook Pro"); + assert_eq!(unsafe { wide_to_string(std::ptr::null()) }, ""); + } + + #[tokio::test] + #[ignore = "requires a device advertising DNS-SD on the local network"] + async fn live_scan() { + let devices = WindowsDnsSdDiscovery::new() + .discover(Duration::from_secs(8)) + .await + .unwrap(); + for d in &devices { + println!( + "{} type={:?} ip={:?} port={:?} service={}", + d.name, d.device_type, d.ip_address, d.port, d.service_type + ); + } + } +} diff --git a/crates/plume_utils/src/lib.rs b/crates/plume_utils/src/lib.rs index 14fa36db..01f830f4 100644 --- a/crates/plume_utils/src/lib.rs +++ b/crates/plume_utils/src/lib.rs @@ -1,6 +1,7 @@ mod bundle; mod cgbi; mod device; +pub mod discovery; mod options; mod package; mod signer; @@ -9,7 +10,7 @@ mod tweak; 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 device::{Device, TvosDeviceInfo, get_device_for_id, install_app_mac, synthetic_device_id}; // Device helper pub use options::{ SignerApp, // Supported app types SignerAppReal, @@ -23,6 +24,8 @@ pub use package::Package; // Package helper pub use signer::Signer; // Signer pub use tweak::Tweak; // Tweak helper +pub type Result = std::result::Result; + use thiserror::Error as ThisError; #[derive(Debug, ThisError)] pub enum Error { @@ -68,7 +71,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 +98,41 @@ pub async fn copy_dir_recursively(src: &Path, dst: &Path) -> Result<(), Error> { Ok(()) } + +/// Renders a byte count for display, in decimal units to match how Apple's own tools report +/// file sizes. Sub-megabyte values keep whole units because a decimal place there is noise. +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"); + } +} From f394cc59f265d993f19e3fc6ae4d08b0aa3a38a0 Mon Sep 17 00:00:00 2001 From: Andrew Harness Date: Fri, 31 Jul 2026 13:21:06 -0400 Subject: [PATCH 4/6] feat: pair with an Apple TV and install over a network tunnel Pairing runs the SRP exchange over RPPairing. The device displays its code only after accepting the request and stops displaying it when the session closes, so pairing takes a provider rather than a code and invokes it while the session is held open. Installing establishes a TLS-PSK tunnel, runs the CoreDevice and RSD handshakes, and then uses the same installation entry point USB does. Reconnection is verify-only and never falls back to pair-setup, so reaching a tunnel cannot make a device display a code to someone who did not ask to pair. The MSS is taken from the MTU the tunnel just negotiated. The stack behind the tunnel sends one segment per round trip, so the segment size sets the transfer rate outright; left unset it falls back to a default an order of magnitude below what the tunnel carries. mDNS never advertises a UDID, so the real one is read from the RSD handshake. It is kept separate from the key the pairing file is stored under, which leaves udid empty until a tunnel supplies it rather than letting a fabricated value reach Apple. --- crates/plume_utils/src/device.rs | 1011 +++++++++++++++++++++++++++++- 1 file changed, 993 insertions(+), 18 deletions(-) diff --git a/crates/plume_utils/src/device.rs b/crates/plume_utils/src/device.rs index 793afbdf..c6e32c51 100644 --- a/crates/plume_utils/src/device.rs +++ b/crates/plume_utils/src/device.rs @@ -6,8 +6,12 @@ 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::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}; @@ -44,6 +48,90 @@ pub struct Device { // 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, + /// Address of the `_remotepairing-manual-pairing._tcp.local.` service, advertised only + /// while the Apple TV is actively showing a pairing PIN. Required for first-time pairing; + /// not valid once the pairing UI is dismissed. + pub pairing_address: Option<(std::net::IpAddr, u16)>, + /// Address of the `_remotepairing._tcp.local.` service, advertised whenever the Apple TV + /// has an established pairing. Used to reconnect (pair-verify) after the initial pairing. + pub reconnect_address: Option<(std::net::IpAddr, u16)>, + /// Stable key for this device's cached pairing file, for devices paired over the network. + /// `None` for USB devices, which are keyed by `udid`. Deliberately not the mDNS-advertised + /// identifier, which changes between advertisements of the same device. + pub pairing_identity: Option, + /// Directory holding this device's cached pairing file. Set for network devices, whose + /// install path must re-establish a tunnel; `None` for USB devices, which need no pairing file. + pub pairing_cache_dir: Option, +} + +/// A network Apple TV's real identity, as reported by the device itself over an RSD handshake +/// rather than derived from mDNS advertisements. +#[derive(Debug, Clone, Default, PartialEq)] +pub struct TvosDeviceInfo { + pub udid: Option, + pub product_type: Option, + pub device_class: Option, + pub os_version: Option, + pub serial_number: Option, +} + +impl TvosDeviceInfo { + /// Extracts the fields the signing pipeline and device list need from an RSD handshake's + /// `properties` map. Pure and I/O-free: every field is read as a plist string, and a key + /// that is missing or holds a non-string value yields `None` rather than a stringified + /// fallback or a panic. `OSVersion` is preferred over `HumanReadableProductVersionString` + /// for `os_version`, falling back to the latter when the former is absent. + 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 { + 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"), + } + } +} + +/// Stable, collision-avoiding device id for a device discovered over the network. +/// +/// `Device::new_tvos` leaves `device_id` at 0 (the same default a real usbmuxd device would +/// never have), and `u32::MAX` is reserved in `screen/mod.rs` as the sentinel for the "This Mac" +/// gestalt device; a caller building a `Device` for a network Apple TV must replace `device_id` +/// with this function's result to avoid colliding with either. FNV-1a is used rather than +/// `std::collections::hash_map::DefaultHasher` because the latter's output is not guaranteed +/// stable across Rust versions, and this id must stay the same across app restarts (it is how +/// the device list dedupes and how disconnect events are matched to the device that connected). +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); + } + + // Setting the top bit both keeps the result out of usbmuxd's small sequential id space and + // guarantees it is never 0, since the FNV-1a output above is then unconditionally nonzero + // in its high half. + hash |= 0x8000_0000; + + // The only value the top-bit setting above cannot rule out is u32::MAX itself, which is a + // reserved sentinel (see the doc comment); remapped to a different fixed value that is + // still nonzero and still has the top bit set. + if hash == u32::MAX { + hash = 0x8000_0000; + } + + hash } impl Device { @@ -58,9 +146,104 @@ impl Device { 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, } } + /// Create a Device representing a network Apple TV (no USB connection). + /// `pairing_identity` is used as the stable key for this device's cached pairing file + /// (e.g. derived from its advertised name). It is not the device's real UDID: that value is + /// not available over mDNS at all, so `udid` is left empty here. A network device's real + /// UDID, once known (see `fetch_tvos_info`/`apply_tvos_info`), must never be fabricated from + /// mDNS data, because `udid` is what gets registered with Apple's developer portal and doing + /// so would consume a real device slot on a value that does not correspond to a real device. + /// + /// `pairing_address` should come from `_remotepairing-manual-pairing._tcp.local.` and is + /// required for `pair_tvos()`. `reconnect_address` should come from + /// `_remotepairing._tcp.local.` and is preferred by `establish_tvos_tunnel()` once a device + /// is already paired; when absent, `pairing_address` is used as a fallback. + /// + /// `cache_dir` is the directory this device's pairing file lives (or will be cached) under; + /// it is stored on the device so `install_app()` can re-establish a tunnel without the + /// transport-agnostic caller having to know a cache directory even exists. + 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(), + 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), + } + } + + /// Path of this device's cached pairing file under `cache_dir`. + /// Network devices are keyed by `pairing_identity` (a stable name-derived key); USB devices, + /// which have no `pairing_identity`, fall back to `udid`. + /// + /// The key can originate from mDNS-advertised data on the local network, so it is validated + /// rather than trusted: an empty key would collapse every un-enriched network device onto + /// the same cache file, and a key containing a path separator or drive letter could write + /// outside `cache_dir` entirely. Both are rejected outright rather than sanitized, since a + /// rewritten key could just as easily collide two distinct devices onto one cache file. + 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"))) + } + + /// True when this device is an Apple TV, which the developer portal must be told about + /// explicitly because its requests default to iOS. + /// + /// A network-paired device (one with a `pairing_identity`) is the only case this recognizes; + /// a USB-attached Apple TV is not currently distinguished from a USB-attached iOS device and + /// is treated as iOS. That gap is out of scope here: USB Apple TV support does not exist yet + /// in this codebase, so there is no USB device to misclassify in practice. + pub fn is_tvos(&self) -> bool { + self.pairing_identity.is_some() + } + + /// Whether `install_app` will reach this device over a network tunnel rather than usbmuxd. + /// + /// Mirrors the transport selection in `install_app` itself, so a caller that needs to know + /// which transport an install will take cannot disagree with the one it actually picks. The + /// distinction matters because a round trip over the tunnel costs orders of magnitude more + /// than one over usbmuxd. + pub fn is_network(&self) -> bool { + self.usbmuxd_device.is_none() + && (self.pairing_address.is_some() || self.reconnect_address.is_some()) + } + async fn get_name_from_usbmuxd_device(device: &UsbmuxdDevice) -> Result { let mut lockdown = LockdownClient::connect(&device.to_provider(UsbmuxdAddr::default(), CONNECTION_LABEL)) @@ -324,6 +507,288 @@ impl Device { } } + /// Pair with a network Apple TV (tvOS 17.4+) using the RPPairing binary protocol. + /// Connects to the mDNS `_remotepairing-manual-pairing._tcp.local.` service - the only + /// service that accepts a first-time SRP pair-setup - which is advertised only while the + /// Apple TV is actively showing a pairing PIN (Settings > Remotes and Devices > Remote App + /// and Devices). `pin` is the 6-digit code shown on the Apple TV screen. + /// The resulting pairing file is cached at `cache_dir/plume_.plist`. + /// + /// Always contacts the device, even when a cached pairing file already exists: a cached + /// file that still verifies produces a silent pair-verify with `pin_provider` never called, + /// while a stale one (device reset, "Forget This Device", tvOS update) is transparently + /// replaced by a real pair-setup. This is why calling this again is the correct way to + /// recover from a pairing that `establish_tvos_tunnel` found to be stale. + /// + /// `pin_provider` is invoked only once the device has accepted the pair-setup request, which + /// is the moment its PIN appears on screen. Taking a provider rather than a string is what + /// lets a caller prompt for the code at that point: the code does not exist before then, and + /// the device stops displaying it as soon as the session closes. + pub async fn pair_tvos( + &self, + pin_provider: F, + cache_dir: PathBuf, + ) -> Result + where + F: Fn() -> Fut, + Fut: std::future::Future, + { + let (ip, port) = self.pairing_address.ok_or_else(|| { + Error::Other( + "Device is not advertising the 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 cache_path = self.pairing_cache_path(&cache_dir)?; + + 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); + + // A cached pairing file is loaded (rather than always generating fresh) so that + // `connect()` below can pair-verify against it: an already-valid pairing succeeds at + // verification and never invokes the PIN provider at all, while a stale one (device + // reset, "Forget This Device", tvOS update) falls through to a real pair-setup that + // does invoke it. This is what makes pressing Pair on a healthy pairing silent. + // `sending_host` is only a label: it travels as the `sendingHost`/`name` field and is what + // the Apple TV lists this host as, while the pairing identity is the file's `identifier` + // and its Ed25519 keys. A fresh pairing therefore uses a readable name, and + // `RpPairingFile::generate` derives the identifier from it. + let (mut pairing_file, sending_host) = if cache_path.exists() { + let file = RpPairingFile::read_from_file(&cache_path).await?; + let host = file.identifier.clone(); + (file, host) + } else { + 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); + pairing_client + .connect( + move |_: u8| { + log::info!("tvOS pairing: device requested the PIN"); + pin_provider() + }, + 0u8, + ) + .await + .map_err(|e| Error::Other(format!("RPPairing handshake failed: {e}")))?; + log::info!("tvOS pairing: handshake succeeded, caching pairing file"); + + tokio::fs::create_dir_all(&cache_dir).await?; + pairing_file.write_to_file(&cache_path).await?; + + Ok(pairing_file) + } + + /// Establish a TLS-PSK tunnel to an already-paired Apple TV and discover RSD services. + /// This is the post-pairing step that gives access to InstallationProxy, AFC, etc. + /// + /// Verify-only: this pair-verifies against the cached pairing file and never performs a + /// fresh SRP pair-setup, so it can never make the Apple TV display a pairing code - unlike + /// `pair_tvos()`, which is the only place that may do that. If the cached pairing file is + /// no longer valid (device factory reset, "Forget This Device", or a tvOS update that broke + /// the pairing - the last of which the Apple TV routinely does after a system update), this + /// call fails and deletes the stale cache file so the next `pair_tvos()` call re-pairs + /// instead of hitting the same failure again. + /// + /// Prefers `reconnect_address` (`_remotepairing._tcp.local.`), which is what a paired + /// device keeps advertising. Falls back to `pairing_address` if no reconnect address is + /// known (e.g. installing immediately after pairing, in the same scan, before a reconnect + /// address has been observed). + 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); + + // Load or generate pairing file + 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 { + let suffix: String = uuid::Uuid::new_v4() + .simple() + .to_string() + .chars() + .take(6) + .collect(); + RpPairingFile::generate(&format!("plume-{suffix}")) + }; + + // Connect via RPPairing binary protocol + 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}")))? + }; + + // Cache pairing file now that rpc is dropped + if !cache_path.exists() { + tokio::fs::create_dir_all(&cache_dir).await?; + pairing_file.write_to_file(&cache_path).await?; + } + + 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); + // The tunnel's own handshake settles the MTU, and the software TCP stack sends one + // segment per round trip, so the segment size sets the transfer rate outright. Left + // unset it falls back to a 1280-byte-MTU default an order of magnitude below what the + // tunnel carries. + 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)) + } + + /// Whether a pairing file is already cached for this device under `cache_dir`. Lets a + /// caller decide whether attempting `fetch_tvos_info` is worthwhile before doing so: a + /// device that has never been paired has no pairing file to verify against, so a tunnel + /// attempt would only fail, and callers that poll periodically (e.g. network device + /// discovery) should treat that as "nothing to enrich yet" rather than a failure worth + /// logging on every poll. + pub fn has_cached_pairing_file(&self, cache_dir: &Path) -> bool { + self.pairing_cache_path(cache_dir) + .map(|path| path.exists()) + .unwrap_or(false) + } + + /// Read a network Apple TV's real identity (UDID, product type, OS version, etc.) over its + /// tunnel's RSD handshake. mDNS never advertises these values, so this is the only way to + /// learn a network device's actual UDID before registering it with Apple. + /// The tunnel is torn down before returning; this call exists purely to enrich a `Device`, + /// not to keep a connection open. + /// + /// Requires an existing cached pairing file and fails before touching the network if there + /// is none. `establish_tvos_tunnel` is verify-only and would fail on a missing file anyway, + /// but checking here first gives a specific, actionable error instead of a generic + /// verification failure for what is meant to be a read-only identity probe. + pub async fn fetch_tvos_info(&self, cache_dir: PathBuf) -> Result { + let cache_path = self.pairing_cache_path(&cache_dir)?; + if !cache_path.exists() { + return Err(Error::Other( + "Device has no cached pairing file; fetch_tvos_info only reads an already \ + paired device and will not initiate a new pairing" + .to_string(), + )); + } + + let (_adapter, handshake) = self.establish_tvos_tunnel(cache_dir).await?; + Ok(TvosDeviceInfo::from_rsd_properties(&handshake.properties)) + } + + /// Copy the real UDID from a previously fetched `TvosDeviceInfo` into this device. + /// A no-op unless `self.pairing_identity` is set, i.e. unless this is a network device: + /// applying tvOS-sourced info to a USB device would silently overwrite its real usbmuxd + /// UDID with an unrelated one. Leaves `self.udid` untouched when `info.udid` is `None` or + /// empty, which keeps a device that has not yet been enriched (or whose RSD properties did + /// not carry a UDID) from ever acquiring a fabricated identity. Does not touch + /// `pairing_identity`, which stays keyed to the pairing file regardless of what the device + /// turns out to be. + pub fn apply_tvos_info(&mut self, info: &TvosDeviceInfo) { + if self.pairing_identity.is_none() { + return; + } + if let Some(udid) = info.udid.as_deref() { + if !udid.is_empty() { + self.udid = udid.to_string(); + } + } + } + + /// Install an app on this device, whether it is reachable over USB or over a network + /// TLS-PSK tunnel. `app_path` may be a single file (e.g. a signed `.ipa`) or a directory + /// (e.g. `package_file.bundle_dir()` from the standard signing pipeline) - both transports + /// upload it via `idevice`'s installation helpers, which branch on `app_path` being a + /// directory the same way for USB and network devices. pub async fn install_app( &self, app_path: &PathBuf, @@ -333,26 +798,60 @@ 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.pairing_address.is_some() || self.reconnect_address.is_some() { + 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(), + ) + })?; + + // Checked up front rather than left to establish_tvos_tunnel: that call is + // verify-only and would fail on a missing file regardless, but checking here first + // gives a specific "pair before installing" error instead of a generic verification + // failure surfacing from deep inside the tunnel handshake. + let cache_path = self.pairing_cache_path(&cache_dir)?; + if !cache_path.exists() { + return Err(Error::Other( + "No pairing file is cached yet for this Apple TV; pair with it before \ + installing" + .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(()) } @@ -372,9 +871,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 +881,9 @@ impl fmt::Display for Device { Connection::Unknown(_) => "Unknown", }, None => "LOCAL", - }, - self.name - ) + } + }; + write!(f, "[{conn}] {}", self.name) } } @@ -456,3 +955,479 @@ 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; + + /// Property map captured from a live Apple TV 4K (AppleTV14,1) RSD handshake, trimmed to + /// the keys `TvosDeviceInfo` reads. The handshake carries 46 keys in total; only the ones + /// relevant here are reproduced. + 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)); + } + + /// Represents a USB device: keyed by `udid`, with no `pairing_identity`. + fn stub_device() -> Device { + Device { + name: "Test Device".to_string(), + udid: "EXISTING-UDID".to_string(), + device_id: 0, + usbmuxd_device: None, + is_mac: false, + pairing_address: None, + reconnect_address: None, + pairing_identity: None, + pairing_cache_dir: None, + } + } + + /// Represents a network (tvOS) device: keyed by `pairing_identity`, same as what + /// `Device::new_tvos` produces once an install has left a UDID on it via `apply_tvos_info`. + 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, "EXISTING-UDID"); + } + + #[test] + fn apply_tvos_info_some_udid_overwrites_existing_udid() { + let mut device = stub_tvos_device(); + let info = TvosDeviceInfo { + udid: Some("REAL".to_string()), + ..Default::default() + }; + device.apply_tvos_info(&info); + assert_eq!(device.udid, "REAL"); + } + + #[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, "EXISTING-UDID"); + } + + #[test] + fn apply_tvos_info_no_op_when_device_has_no_pairing_identity() { + let mut device = stub_device(); + let info = TvosDeviceInfo { + udid: Some("SHOULD-NOT-APPLY".to_string()), + ..Default::default() + }; + device.apply_tvos_info(&info); + assert_eq!(device.udid, "EXISTING-UDID"); + } + + #[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()); + } + + #[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_EXISTING-UDID.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()); + } + + /// Unique scratch directory under the system temp dir, not created on disk by this helper. + 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(); + } + + /// `is_network` decides whether an install pays to be archived first, so it has to agree with + /// the transport `install_app` actually selects rather than merely with "is this an Apple TV". + #[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() { + // No usbmuxd device, no pairing_address, no reconnect_address. + 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}" + ); + // Distinct from both the no-transport error and the no-pairing-file error below. + 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()); + // stub_device()'s udid is non-empty, so pairing_cache_path resolves under cache_dir, + // which is empty here - no pairing file exists there. + + let err = device + .install_app(&PathBuf::from("nonexistent.ipa"), noop_callback) + .await + .unwrap_err(); + + let msg = err.to_string(); + // This is what actually catches the guard being deleted: install_app's own + // "no cached pairing file" check must fire before establish_tvos_tunnel runs at all. + assert!( + msg.contains("No pairing file is cached"), + "expected the missing-pairing-file error, got: {msg}" + ); + assert!(!msg.contains("pairing_cache_dir")); + + std::fs::remove_dir_all(&cache_dir).ok(); + } + + /// A large, cheap-to-generate corpus of distinct identities, so the invariants below are + /// checked across a broad slice of the hash's output space rather than a handful of + /// hand-picked strings. + 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"); + } + + // Edge cases the generated sweep above does not naturally produce. + 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() { + // Compile-level check that the tunnel path is verify-only and takes no PIN: a device + // with neither pairing_address nor reconnect_address fails on the address lookup before + // any I/O, which both proves the one-argument signature and keeps this test hardware-free. + 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")); + } +} From 5d4581d595a43eaaa8c94d766ef86532bcf8e631 Mon Sep 17 00:00:00 2001 From: Andrew Harness Date: Fri, 31 Jul 2026 13:21:26 -0400 Subject: [PATCH 5/6] feat: request tvOS provisioning from the developer portal Signing for an Apple TV failed with error 8220, "your team has no devices from which to generate a provisioning profile", even with the device registered and listDevices returning it as tvOS. The portal defaults every request to iOS, so it looked for an iOS device and found none. Carry the platform in the request body as DTDK_Platform and subPlatform. The URL is not the mechanism: the /tvos/ path segment does not exist on this API version and probing it returns a non-plist body. A/B tested against the live API with one variable changed, downloadTeamProvisioningProfile returns 8220 without these fields and a profile with them. iOS sends no extra fields, so its requests stay byte-identical. Certificates and app groups are deliberately left unparameterised; a certificate issues correctly for a tvOS profile without them. Refreshing also refuses to register a device whose UDID is still unknown, which a network device's is until a tunnel supplies it. --- apps/plumeimpactor/src/refresh.rs | 25 ++++- apps/plumesign/src/commands/account.rs | 13 ++- crates/plume_core/src/developer/mod.rs | 2 + crates/plume_core/src/developer/platform.rs | 99 +++++++++++++++++++ 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_utils/src/signer.rs | 11 ++- 8 files changed, 170 insertions(+), 19 deletions(-) create mode 100644 crates/plume_core/src/developer/platform.rs diff --git a/apps/plumeimpactor/src/refresh.rs b/apps/plumeimpactor/src/refresh.rs index 8ea1b42a..1abfa58a 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,9 +262,19 @@ impl RefreshDaemon { session: &DeveloperSession, team_id: &str, ) -> Result<(), String> { + if device.udid.is_empty() { + 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) + .qh_ensure_device(&team_id_string, &device.name, &device.udid, platform) .await .map_err(|e| format!("Failed to ensure device: {}", e))?; @@ -291,7 +302,7 @@ impl RefreshDaemon { let mut signer = Signer::new(Some(signing_identity), options); signer - .register_bundle(&bundle, session, &team_id.to_string(), true) + .register_bundle(&bundle, session, &team_id.to_string(), true, platform) .await .map_err(|e| format!("Failed to register bundle: {}", e))?; @@ -331,8 +342,14 @@ impl RefreshDaemon { let mut signer = Signer::new(None, options); + let platform = if device.is_tvos() { + DeveloperPlatform::TvOs + } else { + DeveloperPlatform::IOs + }; + signer - .register_bundle(&bundle, session, &team_id.to_string(), true) + .register_bundle(&bundle, session, &team_id.to_string(), true, platform) .await .map_err(|e| format!("Failed to register bundle: {}", e))?; diff --git a/apps/plumesign/src/commands/account.rs b/apps/plumesign/src/commands/account.rs index f58c09a5..a9343193 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/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..7431f94b --- /dev/null +++ b/crates/plume_core/src/developer/platform.rs @@ -0,0 +1,99 @@ +use plist::{Dictionary, Value}; + +/// Platform an Apple developer-portal request applies to. The portal's URL path is `ios` for +/// every platform; a non-iOS target is selected with request fields instead. +/// +/// Threaded through `qh_download_team_prov_profile` (`qh/profile.rs`), the device methods in +/// `qh/devices.rs`, and the App ID methods in `qh/app_ids.rs`. `qh/teams.rs`, `qh/certs.rs` and +/// `qh/app_groups.rs` are deliberately left untouched: certificates and app groups were never +/// proven to need these fields against Apple's live API, and every additional call site that +/// starts sending them is additional risk to the iOS path that every existing user depends on. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DeveloperPlatform { + #[default] + IOs, + TvOs, +} + +impl DeveloperPlatform { + /// Fields identifying this platform to the portal. Empty for iOS, which is the portal's + /// default and must keep sending byte-identical requests to what it sent before. + pub fn request_fields(self) -> &'static [(&'static str, &'static str)] { + match self { + DeveloperPlatform::IOs => &[], + DeveloperPlatform::TvOs => &[("DTDK_Platform", "tvos"), ("subPlatform", "tvOS")], + } + } + + /// Inserts this platform's request fields into `body`. + /// + /// For `IOs`, `request_fields()` is empty, so this returns without touching `body` at all - + /// an iOS request body must stay byte-identical to what it was before tvOS support existed, + /// and that invariant is what this early return enforces. + 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())); + } + } +} + +#[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") + ); + } +} 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_utils/src/signer.rs b/crates/plume_utils/src/signer.rs index 5278d786..5bab778b 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,7 @@ impl Signer { session: &DeveloperSession, team_id: &String, is_refresh: bool, + platform: DeveloperPlatform, ) -> Result<(), Error> { if self.options.mode != SignerMode::Pem { return Ok(()); @@ -276,10 +277,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,7 +340,7 @@ 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; From 0d5d291dfe6ac131be1d3325d8c39f2e7cccb54e Mon Sep 17 00:00:00 2001 From: Andrew Harness Date: Fri, 31 Jul 2026 13:21:26 -0400 Subject: [PATCH 6/6] feat: pair, select and install to Apple TVs from the GUI Adds a pairing screen reached from the main screen: scan, pick a device, enter the code it displays, pair. Once paired, an Apple TV appears in the same device picker USB devices use, so installing to one goes through the normal flow rather than a separate path. Network devices are rescanned on a timer and only reported gone after two consecutive misses, because a single missed response is common and would otherwise move the user's device selection without them noticing. Over a tunnel the bundle is uploaded as one archive rather than mirrored file by file. AFC costs a round trip per file open, write and close, and a tunnelled round trip is dear enough that hundreds of small files cost far more than the bytes in them; measured on a 66 MB bundle of 444 files, that overhead was most of the transfer. usbmuxd round trips are cheap by comparison, so it keeps mirroring there, where compressing would cost more than it saves. The bar no longer claims a percentage for the upload, which reports none. It names the size being sent instead, so a transfer that takes a while does not read as a hang. --- apps/plumeimpactor/src/screen/general.rs | 36 +- apps/plumeimpactor/src/screen/mod.rs | 107 +++- apps/plumeimpactor/src/screen/progress.rs | 53 +- apps/plumeimpactor/src/screen/tvos_pairing.rs | 523 ++++++++++++++++++ apps/plumeimpactor/src/subscriptions.rs | 259 ++++++++- apps/plumesign/src/commands/device.rs | 7 +- apps/plumesign/src/commands/sign.rs | 10 +- locales/en.toml | 1 + 8 files changed, 926 insertions(+), 70 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..8eae7610 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,24 @@ impl Impactor { Task::none() } Message::DeviceConnected(device) => { - if !self.devices.iter().any(|d| d.device_id == device.device_id) { + // A device already in the list is replaced in place rather than skipped: a + // network Apple TV can be re-announced with refreshed addresses (e.g. it starts + // advertising a pairing address once the user opens its pairing screen) or a + // newly-adopted udid (see subscriptions::network_device_listener), and the + // stored copy - including the current selection, if this is it - must pick that + // up instead of freezing at whatever was first seen. + if let Some(existing) = self + .devices + .iter_mut() + .find(|d| d.device_id == device.device_id) + { + *existing = device.clone(); + + if 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 +222,14 @@ 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()); + // A network device's udid is empty until enrichment adopts a real one; keying + // the refresh-daemon map on an empty string would collide every such device + // onto one entry and evict the others. + 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 +313,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 +333,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 +492,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 +682,13 @@ impl Impactor { Task::none() } } + Message::TvOsPairingScreen(msg) => { + if let ImpactorScreen::TvOsPairing(ref mut screen) = self.current_screen { + screen.update(msg).map(Message::TvOsPairingScreen) + } 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 +825,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 +835,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 +858,7 @@ impl Impactor { Subscription::batch(vec![ device_subscription, + network_device_subscription, tray_subscription, hover_subscription, progress_subscription, @@ -861,6 +902,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 +916,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 +1023,13 @@ 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 => { + // Installer screen is set directly via NavigateToInstaller in MainScreen handler. + } } } @@ -1018,14 +1069,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/progress.rs b/apps/plumeimpactor/src/screen/progress.rs index d79ca5f8..b0d22f5a 100644 --- a/apps/plumeimpactor/src/screen/progress.rs +++ b/apps/plumeimpactor/src/screen/progress.rs @@ -8,12 +8,42 @@ use rust_i18n::t; use crate::appearance; -type ProgressReceiver = Arc>>; +type ProgressReceiver = Arc>>; + +/// A single update on the install progress channel. +/// +/// `progress` keeps the existing `-1` (error) / `>= 100` (finished) sentinels; `determinate` +/// is a separate flag so a phase that reports no percentage of its own does not need a third +/// sentinel value layered onto `progress`. +#[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 +53,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 +63,7 @@ impl ProgressScreen { Self { status: "Idle.".to_string(), progress: 0, + determinate: true, is_installing: false, progress_rx: None, } @@ -40,15 +72,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 +141,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/screen/tvos_pairing.rs b/apps/plumeimpactor/src/screen/tvos_pairing.rs new file mode 100644 index 00000000..3d5b9466 --- /dev/null +++ b/apps/plumeimpactor/src/screen/tvos_pairing.rs @@ -0,0 +1,523 @@ +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, + /// The pairing session reached the point where the Apple TV displays its code. `false` + /// means the session ended without ever asking, so there is nothing to prompt for. + PinRequested(bool), + SubmitPin, + PairComplete(Result<(), String>), + /// Discard the current pairing result and return to the scan/pair flow. + StartOver, +} + +#[derive(Debug, Clone)] +pub struct TvOsPairingScreen { + discovered: Vec, + selected_name: Option, + pin: String, + scanning: bool, + pairing: bool, + /// True while the pairing session is held open waiting for the code the Apple TV is + /// currently showing. The PIN entry is visible only in this state. + awaiting_pin: bool, + /// Hands the typed code to the pairing thread's PIN provider. Present only for the + /// duration of a pairing attempt. + pin_sender: Option>, + status: Option, + /// Name of the Apple TV that was just paired, retained only to confirm which device it was. + paired_name: Option, +} + +impl TvOsPairingScreen { + pub fn new() -> Self { + Self { + discovered: Vec::new(), + selected_name: None, + pin: String::new(), + scanning: false, + pairing: false, + awaiting_pin: false, + pin_sender: None, + status: None, + paired_name: None, + } + } + + /// The manual-pairing service entry for the selected device, if the Apple TV is currently + /// advertising it (i.e. actively showing a pairing PIN). Required to start a new pairing. + fn manual_pairing_entry(&self) -> Option<&DiscoveredDevice> { + let name = self.selected_name.as_deref()?; + // Names are compared case-insensitively: they derive from the advertised host name, and + // DNS names are case-insensitive, so a device that varied its own capitalization between + // service types would otherwise fail to correlate with itself. + self.discovered.iter().find(|d| { + d.name.eq_ignore_ascii_case(name) + && d.service_type == REMOTEPAIRING_MANUAL_PAIRING_SERVICE + }) + } + + /// Identity a device's cached pairing file is stored under. + /// + /// It cannot be the advertised `identifier`: the two RPPairing service types report + /// different identifiers for the same Apple TV, and a paired device stops advertising + /// manual pairing altogether, so a file written under the manual-pairing identifier could + /// never be found again. The name derives from the host name and is the same under every + /// service type, which is what makes a pairing survive to the next connection. + fn pairing_identity(name: &str) -> String { + name.replace(' ', "-") + } + + /// The standard (already-paired) service entry for the selected device, if known. + fn reconnect_entry(&self) -> Option<&DiscoveredDevice> { + let name = self.selected_name.as_deref()?; + self.discovered + .iter() + .find(|d| d.name.eq_ignore_ascii_case(name) && 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_name = 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) => { + // A device's service-type entries do not all carry model information: + // the manual-pairing advertisement identifies itself as an Apple TV, + // but the established-pairing one advertises no model at all. Keeping + // only entries that are themselves typed AppleTV would discard the + // reconnect entry and with it the port needed to reach a paired device, + // so every entry belonging to a name that identified as an Apple TV + // under any service type is kept. + let tv_names: std::collections::HashSet = devices + .iter() + .filter(|d| d.device_type == DeviceType::AppleTV) + .map(|d| d.name.to_ascii_lowercase()) + .collect(); + self.discovered = devices + .into_iter() + .filter(|d| tv_names.contains(&d.name.to_ascii_lowercase())) + .collect(); + if tv_names.is_empty() { + 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_names.len() + ))); + } + } + Err(e) => { + self.status = Some(StatusMessage::error(e)); + } + } + Task::none() + } + + Message::SelectDevice(name) => { + self.selected_name = Some(name); + 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.name); + let cache_dir = get_data_path(); + + // The Apple TV shows no code until it has accepted the pair-setup request, so + // any digits left in the field predate this attempt and must not be reused. + self.pin.clear(); + self.awaiting_pin = false; + self.pairing = true; + self.status = Some(StatusMessage::info("Connecting to Apple TV...")); + + // pin_req carries the moment the device asks for its code from the pairing + // thread to the UI; pin_resp carries the typed code back the other way. + // + // Both UI-facing channels are async and must stay that way: these two tasks run + // in one batch, and a task that blocks its executor thread waiting for a result + // prevents every other task in the batch from being polled. Blocking here would + // leave the PIN prompt undelivered while the pairing thread waits for the code + // that only that prompt can produce. + 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(); + // The provider is `Fn` and so cannot move the receiver out of itself; the + // Arc> lets every invocation borrow the one receiver. + 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 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(()); + // Blocking here is load-bearing and must stay: the + // Apple TV displays its code only while the pair-setup + // session is open, so the session has to be held for as + // long as the user takes to read and type it. This runs + // on this thread's own runtime with nothing else on it. + // An empty code on timeout or on a closed channel fails + // the handshake cleanly instead of waiting forever. + let Ok(rx) = pin_resp_rx.lock() else { + return String::new(); + }; + rx.recv_timeout(Duration::from_secs(180)) + .unwrap_or_default() + } + }, + cache_dir, + ) + .await + .map(|_| ()) + .map_err(|e| format!("{e}")) + }); + 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, + ), + // Resolves to false when the pairing thread drops its sender without ever + // asking - the attempt ended before the device got as far as showing a code. + Task::perform( + async move { pin_req_rx.next().await.is_some() }, + Message::PinRequested, + ), + ]) + } + + 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() { + // try_send so a submit can never block the UI thread; the pairing thread is + // already parked on this channel, so the single slot is free. + let _ = tx.try_send(self.pin.clone()); + } + self.awaiting_pin = false; + self.status = Some(StatusMessage::info("Verifying...")); + Task::none() + } + + Message::PairComplete(result) => { + self.pairing = false; + self.awaiting_pin = false; + self.pin_sender = None; + match result { + Ok(_) => { + // The name must come from the discovered entries rather than the + // advertised identifier, which differs per service type; either entry + // carries the same device name. + let name = self + .reconnect_entry() + .or_else(|| self.manual_pairing_entry()) + .map(|d| d.name.clone()); + self.paired_name = name; + self.status = Some(StatusMessage::success("Paired successfully.")); + self.pin.clear(); + } + Err(e) => { + self.status = Some(StatusMessage::error(e)); + } + } + Task::none() + } + + Message::StartOver => { + self.paired_name = None; + self.discovered.clear(); + self.selected_name = None; + self.pin.clear(); + self.awaiting_pin = false; + self.pin_sender = None; + self.status = None; + Task::none() + } + } + } + + pub fn view(&self) -> Element<'_, Message> { + // The screen either pairs a device or confirms one was just paired. Showing the scan + // button and device picker alongside the confirmation would invite pairing the same + // device again before the user has gone to the main screen to install to it. + let content = match &self.paired_name { + Some(name) => self.view_paired(name), + None => self.view_pairing(), + }; + + container(scrollable(content.spacing(appearance::THEME_PADDING))).into() + } + + /// Scan, pick a device, pair, and enter the code the device shows. + 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])); + + // A device appears once per RPPairing service type it advertises, so the picker + // lists distinct names; the individual service entries are looked up by name. + let mut device_names: Vec = + self.discovered.iter().map(|d| d.name.clone()).collect(); + device_names.sort(); + device_names.dedup(); + + content = content.push( + pick_list( + device_names, + self.selected_name.clone(), + Message::SelectDevice, + ) + .placeholder("Select an Apple TV") + .width(Fill), + ); + } + + // No code is collected before pairing starts: the Apple TV does not display one until + // it has accepted a pair-setup request. + if self.selected_name.is_some() && !self.awaiting_pin { + let pair_label = if self.pairing { "Pairing..." } else { "Pair" }; + content = content.push( + button(text(pair_label).align_x(Center)) + .on_press_maybe(if self.pairing { + None + } else { + Some(Message::Pair) + }) + .style(appearance::p_button) + .width(Fill), + ); + } + + // Shown only while the device is displaying its code and the session is held open. + 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 + } + + /// Confirm which Apple TV was just paired and hand off to the main screen for installation. + fn view_paired(&self, name: &str) -> iced::widget::Column<'_, Message> { + let mut content = column![]; + + content = content + .push(text(format!("Paired with {name}")).size(appearance::THEME_FONT_SIZE + 2.0)); + + 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 + } +} diff --git a/apps/plumeimpactor/src/subscriptions.rs b/apps/plumeimpactor/src/subscriptions.rs index 89451e1e..346dc92e 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 { @@ -33,6 +34,10 @@ pub(crate) fn device_listener() -> Subscription { device_id: u32::MAX, usbmuxd_device: None, is_mac: true, + pairing_address: None, + reconnect_address: None, + pairing_identity: None, + pairing_cache_dir: None, })); } } @@ -75,6 +80,164 @@ pub(crate) fn device_listener() -> Subscription { }) } +/// Discovers network Apple TVs over mDNS and feeds them into the same `DeviceConnected`/ +/// `DeviceDisconnected` stream USB devices arrive on, so `screen/mod.rs` can list and select +/// them the same way. Never fabricates a UDID (`Device::new_tvos` leaves it empty; a real one is +/// adopted only via `Device::fetch_tvos_info`/`apply_tvos_info` once a pairing file exists); the +/// udid checks in `run_installation` and `RefreshDaemon::resign_and_reinstall` are what stop a +/// still-unenriched network device from being registered with Apple. +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 { + // Addresses and udid last sent to the UI for each id, so a device can be + // re-emitted (screen/mod.rs replaces its stored copy in place) when + // either changes - e.g. a device first seen with only a reconnect + // address later gains a pairing address once the user opens its pairing + // screen, or a device adopts its real udid via enrichment below. udid is + // part of this key because otherwise a device already present here would + // never be re-emitted purely for having gone from an empty udid to a + // real one, and the enrichment below would have no way to reach the UI. + type EmittedState = ( + Option<(std::net::IpAddr, u16)>, + Option<(std::net::IpAddr, u16)>, + String, + ); + + // Ids currently believed present: emitted as DeviceConnected and not yet + // followed by a DeviceDisconnected. + let mut present_ids: HashSet = HashSet::new(); + let mut last_emitted: HashMap = HashMap::new(); + // Consecutive scans in a row a present id was missing from the results. + // Reaching 2 is what actually emits DeviceDisconnected, so a single + // missed scan (a common mDNS hiccup, e.g. a dropped companion-link + // response) does not silently move the user's device selection - + // screen/mod.rs falls back to devices.first() on disconnect, which the + // user might not notice before installing to the wrong device. + let mut miss_counts: HashMap = HashMap::new(); + // Successfully fetched real identities, cached so a tunnel is attempted + // at most once per id rather than on every 30s scan. + 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); + // Seen this round: any earlier miss streak is over. + miss_counts.remove(&id); + + // A tunnel is attempted at most once per id, and only + // once a pairing file exists - a device that has never + // been paired has nothing to verify against, so a tunnel + // attempt would fail on every single scan for no benefit. + if let Some(info) = enriched.get(&id) { + device.apply_tvos_info(info); + } else if device.has_cached_pairing_file(&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)); + } + } + + let missing: Vec = present_ids + .iter() + .copied() + .filter(|id| !current_ids.contains(id)) + .collect(); + + for id in missing { + let misses = miss_counts.entry(id).or_insert(0); + *misses += 1; + if *misses >= 2 { + let _ = + tx.unbounded_send(Message::DeviceDisconnected(id)); + present_ids.remove(&id); + last_emitted.remove(&id); + miss_counts.remove(&id); + } + } + } + Err(e) => { + // Left entirely untouched: a scan failure carries no + // information about which devices are still there, so it + // must not count as a miss for anything, let alone an + // immediate disconnect. + log::warn!("Network device scan failed: {e}"); + } + } + + // A scan itself takes up to 5s, so sleeping a fixed 30s on top of + // it would drift the real cadence to ~35s; subtracting the elapsed + // scan time keeps each iteration starting roughly 30s after the + // previous one started. + 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 +382,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 +401,21 @@ 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 + // Drained to empty rather than one per tick: the terminal -1 and + // 100 updates must not sit behind a queue of earlier ones. + 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,15 +439,22 @@ 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, }; send("Preparing package...".to_string(), 10); @@ -299,6 +465,18 @@ pub(crate) async fn run_installation( return Err("GSA account is required for PEM signing".to_string()); }; + // Checked before any portal call, not just before qh_ensure_device: on a + // free-tier account at its certificate limit, CertificateIdentity::new_with_session + // below can revoke an existing certificate to make room for a new one, which + // invalidates apps already sideloaded on the user's other devices. That cost must + // not be paid for an install that is going to fail this same check a few lines + // later anyway. + if let Some(dev) = &device { + if dev.udid.is_empty() { + return Err("Device UDID is unknown; cannot register it with Apple".to_string()); + } + } + send("Ensuring account is valid...".to_string(), 20); let session = DeveloperSession::new( @@ -350,7 +528,7 @@ pub(crate) async fn run_installation( if let Some(dev) = &device { 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())?; } @@ -368,7 +546,7 @@ pub(crate) async fn run_installation( .await .map_err(|e| e.to_string())?; signer - .register_bundle(&bundle, &session, team_id, false) + .register_bundle(&bundle, &session, team_id, false, platform) .await .map_err(|e| e.to_string())?; signer @@ -413,15 +591,48 @@ pub(crate) async fn run_installation( SignerInstallMode::Install => { if let Some(dev) = &device { if !dev.is_mac { - send("Sending to device...".to_string(), 70); + // Over a network tunnel, send one archive rather than mirroring the bundle + // directory: AFC costs a round trip per file open, write and close, and a + // tunnelled round trip is expensive enough that hundreds of small files cost + // far more than the bytes in them. usbmuxd round trips are cheap by + // comparison, so there the compression would cost more time than it saves. + 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() + }; + + 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 @@ -478,7 +689,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/device.rs b/apps/plumesign/src/commands/device.rs index feb12262..d528aba0 100644 --- a/apps/plumesign/src/commands/device.rs +++ b/apps/plumesign/src/commands/device.rs @@ -10,6 +10,7 @@ use idevice::{ }; use plume_utils::{Device, Package, get_device_for_id}; + #[derive(Debug, Args)] #[command(arg_required_else_help = true)] pub struct DeviceArgs { @@ -56,6 +57,10 @@ pub async fn execute(args: DeviceArgs) -> Result<()> { device_id: 0, usbmuxd_device: None, is_mac: true, + pairing_address: None, + reconnect_address: None, + pairing_identity: None, + pairing_cache_dir: None, } } else { select_device(args.udid).await? @@ -87,7 +92,7 @@ pub async fn execute(args: DeviceArgs) -> Result<()> { 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?; } diff --git a/apps/plumesign/src/commands/sign.rs b/apps/plumesign/src/commands/sign.rs index 86a63f7a..0fe79131 100644 --- a/apps/plumesign/src/commands/sign.rs +++ b/apps/plumesign/src/commands/sign.rs @@ -3,7 +3,7 @@ use std::path::PathBuf; use anyhow::Result; use clap::Args; -use plume_core::{CertificateIdentity, MobileProvision}; +use plume_core::{CertificateIdentity, MobileProvision, developer::DeveloperPlatform}; use plume_utils::{Bundle, Package, Signer, SignerMode, SignerOptions}; use crate::{ @@ -130,6 +130,10 @@ pub async fn execute(args: SignArgs) -> Result<()> { device_id: 0, usbmuxd_device: None, is_mac: true, + pairing_address: None, + reconnect_address: None, + pairing_identity: None, + pairing_cache_dir: None, }) } else { Some(select_device(args.udid).await?) @@ -151,12 +155,12 @@ pub async fn execute(args: SignArgs) -> Result<()> { if let Some(ref dev) = device { 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, DeveloperPlatform::IOs) .await?; } signer - .register_bundle(&bundle, &session, &team_id, false) + .register_bundle(&bundle, &session, &team_id, false, DeveloperPlatform::IOs) .await?; signer.sign_bundle(&bundle).await?; 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"