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]] 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/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/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/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/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/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/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")); + } +} 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"); + } +} 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(); + } +} 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; 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"