diff --git a/Cargo.lock b/Cargo.lock index 27895035..9a19729f 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" @@ -5668,6 +5670,7 @@ dependencies = [ name = "plume_utils" version = "2.6.3" dependencies = [ + "base64 0.22.1", "decompress", "flate2", "futures", @@ -5675,9 +5678,11 @@ dependencies = [ "idevice", "image", "log", + "mdns-sd", "plist", "plume_core", "plume_store", + "serde", "thiserror 2.0.18", "tokio", "uuid", @@ -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/Cargo.toml b/Cargo.toml index 6e77b6ff..3a115022 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -62,7 +62,7 @@ rand = "0.8" base64 = "0.22" sha2 = "0.11" regex = "1.11" -uuid = { version = "1.3", features = ["v4", "fast-rng", "macro-diagnostics"] } +uuid = { version = "1.3", features = ["v3", "v4", "fast-rng", "macro-diagnostics"] } # Requests reqwest = { version = "0.13", default-features = false, features = [ "blocking", @@ -73,4 +73,4 @@ reqwest = { version = "0.13", default-features = false, features = [ ] } chrono = { version = "0.4", default-features = false, features = ["std", "serde"] } serde = { version = "1", features = ["derive"] } -serde_json = { version = "1" } \ No newline at end of file +serde_json = { version = "1" } diff --git a/apps/plumeimpactor/src/refresh.rs b/apps/plumeimpactor/src/refresh.rs index 8ea1b42a..b26e49a1 100644 --- a/apps/plumeimpactor/src/refresh.rs +++ b/apps/plumeimpactor/src/refresh.rs @@ -5,7 +5,8 @@ use std::time::Duration; use chrono::Utc; use plume_core::{ - AnisetteConfiguration, CertificateIdentity, MobileProvision, developer::DeveloperSession, + AnisetteConfiguration, CertificateIdentity, MobileProvision, + developer::{DeveloperPlatform, DeveloperSession}, }; use plume_store::{AccountStore, RefreshDevice}; use plume_utils::{Bundle, Device, Signer, SignerMode, SignerOptions}; @@ -261,11 +262,23 @@ impl RefreshDaemon { session: &DeveloperSession, team_id: &str, ) -> Result<(), String> { + if !device.is_mac && !plume_utils::is_valid_device_udid(&device.udid) { + return Err("Device UDID is unknown; cannot register it with Apple".to_string()); + } + + let platform = if device.is_tvos() { + DeveloperPlatform::Tvos + } else { + DeveloperPlatform::Ios + }; + let team_id_string = team_id.to_string(); - session - .qh_ensure_device(&team_id_string, &device.name, &device.udid) - .await - .map_err(|e| format!("Failed to ensure device: {}", e))?; + if !device.is_mac { + session + .qh_ensure_device(&team_id_string, &device.name, &device.udid, platform) + .await + .map_err(|e| format!("Failed to ensure device: {}", e))?; + } let bundle = Bundle::new(app.path.clone()).map_err(|e| format!("Failed to create bundle: {}", e))?; @@ -291,15 +304,34 @@ impl RefreshDaemon { let mut signer = Signer::new(Some(signing_identity), options); signer - .register_bundle(&bundle, session, &team_id.to_string(), true) + .register_bundle_for_device( + &bundle, + session, + &team_id.to_string(), + true, + platform, + (!device.is_mac).then_some(device.udid.as_str()), + ) .await .map_err(|e| format!("Failed to register bundle: {}", e))?; signer - .sign_bundle(&bundle) + .sign_bundle_for_device( + &bundle, + platform, + (!device.is_mac).then_some(device.udid.as_str()), + ) .await .map_err(|e| format!("Failed to sign bundle: {}", e))?; + signer + .validate_signed_bundle( + &bundle, + platform, + (!device.is_mac).then_some(device.udid.as_str()), + ) + .map_err(|e| format!("Failed to validate signed bundle: {}", e))?; + if !device.is_mac { device .install_app(&app.path, |_| async {}) @@ -329,10 +361,34 @@ impl RefreshDaemon { ..Default::default() }; - let mut signer = Signer::new(None, options); + let mut on_certificate_reset = crate::certificate_reset::confirm; + let signing_identity = CertificateIdentity::new_with_session( + session, + get_data_path(), + None, + &team_id.to_string(), + false, + Some(&mut on_certificate_reset), + ) + .await + .map_err(|e| format!("Failed to create signing identity: {}", e))?; + let mut signer = Signer::new(Some(signing_identity), options); + + let platform = if device.is_tvos() { + DeveloperPlatform::Tvos + } else { + DeveloperPlatform::Ios + }; signer - .register_bundle(&bundle, session, &team_id.to_string(), true) + .register_bundle_for_device( + &bundle, + session, + &team_id.to_string(), + true, + platform, + Some(&device.udid), + ) .await .map_err(|e| format!("Failed to register bundle: {}", e))?; diff --git a/apps/plumeimpactor/src/screen/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..95ae5ac6 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; @@ -39,6 +40,7 @@ pub enum Message { ComboBoxSelected(String), DeviceConnected(Device), DeviceDisconnected(u32), + DeviceForgotten(Device), // Tray TrayMenuClicked(tray_icon::menu::MenuId), @@ -74,6 +76,7 @@ pub enum Message { SettingsScreen(settings::Message), InstallerScreen(package::Message), ProgressScreen(progress::Message), + TvOsPairingScreen(tvos_pairing::Message), CertificateResetRequested(crate::certificate_reset::ConfirmationRequest), ConfirmCertificateReset, CancelCertificateReset, @@ -96,13 +99,34 @@ pub struct Impactor { selected_locale: Option, } +fn same_device_identity(first: &Device, second: &Device) -> bool { + if first.device_id != 0 && second.device_id != 0 && first.device_id == second.device_id { + return true; + } + + if plume_utils::is_valid_device_udid(&first.udid) + && plume_utils::is_valid_device_udid(&second.udid) + && first.udid.eq_ignore_ascii_case(&second.udid) + { + return true; + } + + first + .pairing_identity + .as_ref() + .zip(second.pairing_identity.as_ref()) + .is_some_and(|(first, second)| first.eq_ignore_ascii_case(second)) +} + #[derive(Debug, Clone, PartialEq)] +#[allow(dead_code)] pub enum ImpactorScreenType { Main, Utilities, Settings, Installer, Progress, + TvOsPairing, } enum ImpactorScreen { @@ -111,6 +135,7 @@ enum ImpactorScreen { Settings(settings::SettingsScreen), Installer(package::PackageScreen), Progress(progress::ProgressScreen), + TvOsPairing(tvos_pairing::TvOsPairingScreen), } impl Impactor { @@ -192,17 +217,30 @@ impl Impactor { Task::none() } Message::DeviceConnected(device) => { - if !self.devices.iter().any(|d| d.device_id == device.device_id) { - self.devices.push(device.clone()); - - if self.selected_device.is_none() && device.device_id != u32::MAX { - self.selected_device = Some(device.clone()); - } + let selected_before = self.selected_device.clone(); + let mut devices = std::mem::take(&mut self.devices); + devices.push(device.clone()); + self.devices = plume_utils::deduplicate_devices(devices); + + if let Some(selected_before) = selected_before { + self.selected_device = self + .devices + .iter() + .find(|candidate| same_device_identity(candidate, &selected_before)) + .cloned(); + } else if device.device_id != u32::MAX { + self.selected_device = self + .devices + .iter() + .find(|candidate| same_device_identity(candidate, &device)) + .cloned(); } - if let Some(daemon_devices) = REFRESH_DAEMON_DEVICES.get() { - if let Ok(mut devices) = daemon_devices.lock() { - devices.insert(device.udid.clone(), device.clone()); + if !device.udid.is_empty() { + if let Some(daemon_devices) = REFRESH_DAEMON_DEVICES.get() { + if let Ok(mut devices) = daemon_devices.lock() { + devices.insert(device.udid.clone(), device.clone()); + } } } @@ -255,6 +293,28 @@ impl Impactor { Task::none() } + Message::DeviceForgotten(device) => { + self.devices + .retain(|candidate| !same_device_identity(candidate, &device)); + + if self + .selected_device + .as_ref() + .is_some_and(|selected| same_device_identity(selected, &device)) + { + self.selected_device = self.devices.first().cloned(); + } + + if plume_utils::is_valid_device_udid(&device.udid) { + if let Some(daemon_devices) = REFRESH_DAEMON_DEVICES.get() { + if let Ok(mut devices) = daemon_devices.lock() { + devices.remove(&device.udid); + } + } + } + + Task::none() + } Message::NavigateToScreen(screen_type) => { if screen_type == ImpactorScreenType::Settings { if !matches!(self.current_screen, ImpactorScreen::Progress(_)) { @@ -286,6 +346,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 +366,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 +525,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 +715,31 @@ impl Impactor { Task::none() } } + Message::TvOsPairingScreen(msg) => { + if let ImpactorScreen::TvOsPairing(ref mut screen) = self.current_screen { + let paired_device = match &msg { + tvos_pairing::Message::PairComplete(Ok(device)) + | tvos_pairing::Message::ReconnectComplete(Ok(device)) => { + Some(device.clone()) + } + _ => None, + }; + let forgotten_device = match &msg { + tvos_pairing::Message::ForgetComplete(Ok(device)) => Some(device.clone()), + _ => None, + }; + let update = screen.update(msg).map(Message::TvOsPairingScreen); + if let Some(device) = paired_device { + Task::batch([update, Task::done(Message::DeviceConnected(device))]) + } else if let Some(device) = forgotten_device { + Task::batch([update, Task::done(Message::DeviceForgotten(device))]) + } else { + update + } + } 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 +876,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 +886,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 +909,7 @@ impl Impactor { Subscription::batch(vec![ device_subscription, + network_device_subscription, tray_subscription, hover_subscription, progress_subscription, @@ -861,6 +953,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 +967,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 +1074,12 @@ impl Impactor { ImpactorScreenType::Progress => { self.current_screen = ImpactorScreen::Progress(progress::ProgressScreen::new()); } - _ => {} + ImpactorScreenType::TvOsPairing => { + self.current_screen = + ImpactorScreen::TvOsPairing(tvos_pairing::TvOsPairingScreen::new()); + } + ImpactorScreenType::Installer => { + } } } @@ -1018,14 +1119,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..8de9aa38 100644 --- a/apps/plumeimpactor/src/screen/progress.rs +++ b/apps/plumeimpactor/src/screen/progress.rs @@ -8,12 +8,37 @@ use rust_i18n::t; use crate::appearance; -type ProgressReceiver = Arc>>; +type ProgressReceiver = Arc>>; + +#[derive(Debug, Clone)] +pub struct ProgressUpdate { + pub status: String, + pub progress: i32, + pub determinate: bool, +} + +impl ProgressUpdate { + pub fn new(status: String, progress: i32) -> Self { + Self { + status, + progress, + determinate: true, + } + } + + pub fn indeterminate(status: String, progress: i32) -> Self { + Self { + status, + progress, + determinate: false, + } + } +} #[derive(Debug, Clone)] #[allow(dead_code)] pub enum Message { - InstallationProgress(String, i32), + InstallationProgress(ProgressUpdate), InstallationError(String), InstallationFinished, Back, @@ -23,6 +48,7 @@ pub enum Message { pub struct ProgressScreen { pub status: String, pub progress: i32, + pub determinate: bool, pub is_installing: bool, pub progress_rx: Option, } @@ -32,6 +58,7 @@ impl ProgressScreen { Self { status: "Idle.".to_string(), progress: 0, + determinate: true, is_installing: false, progress_rx: None, } @@ -40,15 +67,22 @@ impl ProgressScreen { pub fn start_installation(&mut self, rx: ProgressReceiver) { self.is_installing = true; self.progress = 0; + self.determinate = true; self.status = "Idle.".to_string(); self.progress_rx = Some(rx); } pub fn update(&mut self, message: Message) -> Task { match message { - Message::InstallationProgress(status, progress) => { + Message::InstallationProgress(update) => { + let ProgressUpdate { + status, + progress, + determinate, + } = update; self.status = status.clone(); self.progress = progress; + self.determinate = determinate; if progress == -1 { self.progress_rx = None; @@ -102,9 +136,15 @@ impl ProgressScreen { pub fn view(&self) -> Element<'_, Message> { let progress_bar = iced::widget::progress_bar(0.0..=100.0, self.progress as f32); + let status_text = if self.determinate { + format!("{}% - {}", self.progress, self.status) + } else { + self.status.clone() + }; + let screen_content = column![ text(t!("progress_installing_application")).size(14), - text(format!("{}% – {}", self.progress, self.status)).size(14), + text(status_text).size(14), progress_bar, container(text("")).height(Fill), ] diff --git a/apps/plumeimpactor/src/screen/tvos_pairing.rs b/apps/plumeimpactor/src/screen/tvos_pairing.rs new file mode 100644 index 00000000..b20cef48 --- /dev/null +++ b/apps/plumeimpactor/src/screen/tvos_pairing.rs @@ -0,0 +1,697 @@ +use iced::futures::StreamExt; +use iced::widget::{button, column, container, pick_list, row, rule, scrollable, text, text_input}; +use iced::{Center, Color, Element, Fill, Task}; +use plume_utils::Device; +use plume_utils::discovery::{ + DeviceDiscovery, DeviceType, DiscoveredDevice, PlatformDiscovery, + REMOTEPAIRING_MANUAL_PAIRING_SERVICE, REMOTEPAIRING_SERVICE, +}; +use std::time::Duration; + +use crate::appearance; +use crate::defaults::get_data_path; + +#[derive(Debug, Clone)] +struct StatusMessage { + content: String, + is_error: bool, +} + +impl StatusMessage { + fn success(s: impl Into) -> Self { + Self { + content: s.into(), + is_error: false, + } + } + fn error(s: impl Into) -> Self { + Self { + content: s.into(), + is_error: true, + } + } + fn info(s: impl Into) -> Self { + Self { + content: s.into(), + is_error: false, + } + } + fn color(&self) -> Color { + if self.is_error { + Color::from_rgb(0.9, 0.2, 0.2) + } else { + Color::from_rgb(0.2, 0.8, 0.4) + } + } +} + +#[derive(Debug, Clone)] +pub enum Message { + Scan, + ScanComplete(Result, String>), + SelectDevice(String), + PinChanged(String), + Pair, + Reconnect, + PinRequested(bool), + SubmitPin, + CancelPin, + PairComplete(Result), + ReconnectComplete(Result), + Forget, + ForgetComplete(Result), + StartOver, +} + +#[derive(Debug, Clone)] +pub struct TvOsPairingScreen { + discovered: Vec, + selected_label: Option, + pin: String, + scanning: bool, + pairing: bool, + reconnecting: bool, + awaiting_pin: bool, + pin_sender: Option>, + status: Option, + paired_device: Option, +} + +impl TvOsPairingScreen { + pub fn new() -> Self { + Self { + discovered: Vec::new(), + selected_label: None, + pin: String::new(), + scanning: false, + pairing: false, + reconnecting: false, + awaiting_pin: false, + pin_sender: None, + status: None, + paired_device: None, + } + } + + fn device_label(device: &DiscoveredDevice) -> String { + let host = if device.hostname.is_empty() { + device.name.replace(' ', "-") + } else { + device.hostname.clone() + }; + format!("[WiFi] {} (tvOS) ({host})", device.name) + } + + fn selected_label(&self) -> Option<&str> { + self.selected_label.as_deref() + } + + fn selected_device(&self) -> Option<&DiscoveredDevice> { + let label = self.selected_label()?; + self.discovered + .iter() + .find(|device| Self::device_label(device) == label) + } + + fn manual_pairing_entry(&self) -> Option<&DiscoveredDevice> { + let label = self.selected_label()?; + self.discovered.iter().find(|d| { + Self::device_label(d) == label + && d.service_type == REMOTEPAIRING_MANUAL_PAIRING_SERVICE + }) + } + + fn pairing_identity(device: &DiscoveredDevice) -> String { + if device.hostname.is_empty() { + device.name.replace(' ', "-") + } else { + device.hostname.clone() + } + } + + fn reconnect_entry(&self) -> Option<&DiscoveredDevice> { + let label = self.selected_label()?; + self.discovered + .iter() + .find(|d| Self::device_label(d) == label && d.service_type == REMOTEPAIRING_SERVICE) + } + + pub fn update(&mut self, message: Message) -> Task { + match message { + Message::Scan => { + self.scanning = true; + self.status = Some(StatusMessage::info("Scanning for Apple TVs...")); + self.discovered.clear(); + self.selected_label = None; + self.pin.clear(); + + let (tx, rx) = std::sync::mpsc::sync_channel(1); + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + let result: Result, String> = rt.block_on(async { + PlatformDiscovery::new() + .discover(Duration::from_secs(5)) + .await + .map_err(|e| format!("Scan failed: {e}")) + }); + let _ = tx.send(result); + }); + + Task::perform( + async move { + std::thread::spawn(move || { + rx.recv().unwrap_or_else(|_| Err("Scan error".to_string())) + }) + .join() + .unwrap() + }, + Message::ScanComplete, + ) + } + + Message::ScanComplete(result) => { + self.scanning = false; + match result { + Ok(devices) => { + self.discovered = devices + .into_iter() + .filter(|d| d.device_type == DeviceType::AppleTV) + .collect(); + let tv_count = self + .discovered + .iter() + .map(Self::device_label) + .collect::>() + .len(); + if tv_count == 0 { + self.status = + Some(StatusMessage::info("No Apple TVs found on this network.")); + } else { + self.status = Some(StatusMessage::info(format!( + "Found {} Apple TV(s). Select one to pair.", + tv_count + ))); + } + } + Err(e) => { + self.status = Some(StatusMessage::error(e)); + } + } + Task::none() + } + + Message::SelectDevice(label) => { + self.selected_label = Some(label); + self.pin.clear(); + self.status = None; + Task::none() + } + + Message::PinChanged(s) => { + self.pin = s.chars().filter(|c| c.is_ascii_digit()).take(6).collect(); + Task::none() + } + + Message::Pair => { + let Some(dev) = self.manual_pairing_entry() else { + self.status = Some(StatusMessage::error( + "This Apple TV isn't showing a pairing PIN. On the Apple TV, open \ + Settings > Remotes and Devices > Remote App and Devices, wait for \ + \"Waiting to Pair...\", then Scan again.", + )); + return Task::none(); + }; + + let ip_str = match &dev.ip_address { + Some(s) => s.clone(), + None => { + self.status = + Some(StatusMessage::error("Selected device has no IP address.")); + return Task::none(); + } + }; + let pairing_port = match dev.port { + Some(p) => p, + None => { + self.status = Some(StatusMessage::error("Selected device has no port.")); + return Task::none(); + } + }; + let reconnect_address = self.reconnect_entry().and_then(|device| { + let ip = device.ip_address.as_deref()?.parse().ok()?; + Some((ip, device.port?)) + }); + + let name = dev.name.clone(); + let hostname = Self::pairing_identity(dev); + let cache_dir = get_data_path(); + + self.pin.clear(); + self.awaiting_pin = false; + self.pairing = true; + self.status = Some(StatusMessage::info("Connecting to Apple TV...")); + + let (pin_req_tx, mut pin_req_rx) = iced::futures::channel::mpsc::unbounded::<()>(); + let (result_tx, mut result_rx) = + iced::futures::channel::mpsc::unbounded::>(); + let (pin_resp_tx, pin_resp_rx) = std::sync::mpsc::sync_channel::(1); + self.pin_sender = Some(pin_resp_tx); + std::thread::spawn(move || { + let rt = tokio::runtime::Runtime::new().unwrap(); + let pin_resp_rx = std::sync::Arc::new(std::sync::Mutex::new(pin_resp_rx)); + let result = rt.block_on(async move { + let ip: std::net::IpAddr = + ip_str.parse().map_err(|e| format!("Invalid IP: {e}"))?; + let mut device = Device::new_tvos_with_addresses( + name, + hostname, + Some((ip, pairing_port)), + reconnect_address, + cache_dir.clone(), + ); + device + .pair_tvos( + move || { + let pin_req_tx = pin_req_tx.clone(); + let pin_resp_rx = pin_resp_rx.clone(); + async move { + let _ = pin_req_tx.unbounded_send(()); + let Ok(rx) = pin_resp_rx.lock() else { + return String::new(); + }; + rx.recv_timeout(Duration::from_secs(180)) + .unwrap_or_default() + } + }, + cache_dir.clone(), + ) + .await + .map_err(|e| format!("{e}"))?; + let info = device + .fetch_tvos_info(cache_dir) + .await + .map_err(|e| format!("{e}"))?; + device.apply_tvos_info(&info); + if !plume_utils::is_valid_device_udid(&device.udid) { + return Err( + "Apple TV pairing succeeded but its authenticated UDID was not returned" + .to_string(), + ); + } + Ok(device) + }); + let _ = result_tx.unbounded_send(result); + }); + + Task::batch([ + Task::perform( + async move { + result_rx + .next() + .await + .unwrap_or_else(|| Err("Pairing thread error".to_string())) + }, + Message::PairComplete, + ), + Task::perform( + async move { pin_req_rx.next().await.is_some() }, + Message::PinRequested, + ), + ]) + } + + Message::Reconnect => { + let Some(dev) = self.reconnect_entry() else { + self.status = Some(StatusMessage::error( + "This Apple TV is not advertising its reconnect service.", + )); + return Task::none(); + }; + let Some(ip_str) = dev.ip_address.clone() else { + self.status = Some(StatusMessage::error( + "Selected device has no IP address.", + )); + return Task::none(); + }; + let Some(reconnect_port) = dev.port else { + self.status = Some(StatusMessage::error("Selected device has no port.")); + return Task::none(); + }; + let name = dev.name.clone(); + let identity = Self::pairing_identity(dev); + let cache_dir = get_data_path(); + self.reconnecting = true; + self.status = Some(StatusMessage::info("Reconnecting to Apple TV...")); + + let (tx, rx) = std::sync::mpsc::sync_channel(1); + std::thread::spawn(move || { + let result = tokio::runtime::Runtime::new() + .unwrap() + .block_on(async move { + let ip = ip_str + .parse() + .map_err(|error| format!("Invalid IP address: {error}"))?; + let mut device = Device::new_tvos( + name, + identity, + ip, + None, + Some(reconnect_port), + cache_dir.clone(), + ); + if !device.has_pairing_source(&cache_dir) { + return Err( + "No saved pairing record exists for this Apple TV. Pair it first." + .to_string(), + ); + } + let info = device + .fetch_tvos_info(cache_dir) + .await + .map_err(|error| format!("{error}"))?; + device.apply_tvos_info(&info); + if !plume_utils::is_valid_device_udid(&device.udid) { + return Err( + "Apple TV reconnected but its authenticated UDID was not returned" + .to_string(), + ); + } + Ok(device) + }); + let _ = tx.send(result); + }); + + Task::perform( + async move { + std::thread::spawn(move || { + rx.recv() + .unwrap_or_else(|_| Err("Reconnect thread error".to_string())) + }) + .join() + .unwrap() + }, + Message::ReconnectComplete, + ) + } + + Message::PinRequested(requested) => { + if requested { + self.awaiting_pin = true; + self.status = + Some(StatusMessage::info("Enter the code shown on your Apple TV")); + } + Task::none() + } + + Message::SubmitPin => { + if self.pin.len() != 6 { + return Task::none(); + } + if let Some(tx) = self.pin_sender.as_ref() { + let _ = tx.try_send(self.pin.clone()); + } + self.awaiting_pin = false; + self.status = Some(StatusMessage::info("Verifying...")); + Task::none() + } + + Message::CancelPin => { + if let Some(tx) = self.pin_sender.as_ref() { + let _ = tx.try_send(String::new()); + } + self.awaiting_pin = false; + self.status = Some(StatusMessage::info("Cancelling pairing...")); + Task::none() + } + + Message::PairComplete(result) => { + self.pairing = false; + self.awaiting_pin = false; + self.pin_sender = None; + match result { + Ok(device) => { + self.paired_device = Some(device); + self.status = Some(StatusMessage::success("Paired successfully.")); + self.pin.clear(); + } + Err(e) => { + self.status = Some(StatusMessage::error(e)); + } + } + Task::none() + } + + Message::ReconnectComplete(result) => { + self.reconnecting = false; + match result { + Ok(device) => { + self.paired_device = Some(device); + self.status = Some(StatusMessage::success("Reconnected successfully.")); + } + Err(error) => self.status = Some(StatusMessage::error(error)), + } + Task::none() + } + + Message::Forget => { + let cache_dir = get_data_path(); + let device = if let Some(device) = self.paired_device.clone() { + device + } else { + let Some(discovered) = self.selected_device().cloned() else { + self.status = Some(StatusMessage::error("Select an Apple TV first.")); + return Task::none(); + }; + let identity = Self::pairing_identity(&discovered); + Device::new_tvos( + discovered.name, + identity, + "0.0.0.0".parse().unwrap(), + None, + None, + cache_dir.clone(), + ) + }; + let (tx, rx) = std::sync::mpsc::sync_channel(1); + std::thread::spawn(move || { + let result = tokio::runtime::Runtime::new() + .unwrap() + .block_on(device.forget_tvos_pairing(cache_dir)) + .map(|_| device) + .map_err(|e| format!("{e}")); + let _ = tx.send(result); + }); + self.status = Some(StatusMessage::info("Removing Apple TV pairing...")); + Task::perform( + async move { + std::thread::spawn(move || { + rx.recv() + .unwrap_or_else(|_| Err("Forget thread error".to_string())) + }) + .join() + .unwrap() + }, + Message::ForgetComplete, + ) + } + + Message::ForgetComplete(result) => { + match result { + Ok(_) => { + self.paired_device = None; + self.status = Some(StatusMessage::success( + "Host pairing removed. The Apple TV may reconnect without a PIN until its remote devices are forgotten.", + )); + } + Err(error) => self.status = Some(StatusMessage::error(error)), + } + Task::none() + } + + Message::StartOver => { + self.paired_device = None; + self.discovered.clear(); + self.selected_label = None; + self.pin.clear(); + self.awaiting_pin = false; + self.pin_sender = None; + self.reconnecting = false; + self.status = None; + Task::none() + } + } + } + + pub fn view(&self) -> Element<'_, Message> { + let content = match &self.paired_device { + Some(device) => self.view_paired(device), + None => self.view_pairing(), + }; + + container(scrollable(content.spacing(appearance::THEME_PADDING))).into() + } + + fn view_pairing(&self) -> iced::widget::Column<'_, Message> { + let mut content = column![]; + + let scan_label = if self.scanning { + "Scanning..." + } else { + "Scan for Apple TVs" + }; + content = content.push( + button(text(scan_label).align_x(Center)) + .on_press_maybe(if self.scanning { + None + } else { + Some(Message::Scan) + }) + .style(appearance::s_button) + .width(Fill), + ); + + if let Some(ref s) = self.status { + content = content.push(text(&s.content).size(13).color(s.color())); + } + + if !self.discovered.is_empty() { + content = content + .push(container(rule::horizontal(1)).padding([appearance::THEME_PADDING, 0.0])); + + let mut device_labels: Vec = self + .discovered + .iter() + .filter(|d| d.device_type == DeviceType::AppleTV) + .map(Self::device_label) + .collect(); + device_labels.sort(); + device_labels.dedup(); + + content = content.push( + pick_list( + device_labels, + self.selected_label.clone(), + Message::SelectDevice, + ) + .placeholder("Select an Apple TV") + .width(Fill), + ); + } + + if self.selected_label.is_some() && !self.awaiting_pin { + let pair_label = if self.pairing { "Pairing..." } else { "Pair" }; + if self.manual_pairing_entry().is_some() { + content = content.push( + button(text(pair_label).align_x(Center)) + .on_press_maybe(if self.pairing || self.reconnecting { + None + } else { + Some(Message::Pair) + }) + .style(appearance::p_button) + .width(Fill), + ); + } + if self.reconnect_entry().is_some() { + let reconnect_label = if self.reconnecting { + "Reconnecting..." + } else { + "Reconnect" + }; + content = content.push( + button(text(reconnect_label).align_x(Center)) + .on_press_maybe(if self.pairing || self.reconnecting { + None + } else { + Some(Message::Reconnect) + }) + .style(appearance::s_button) + .width(Fill), + ); + } + } + + if self.awaiting_pin { + content = content + .push(container(rule::horizontal(1)).padding([appearance::THEME_PADDING, 0.0])); + content = content.push(text("Enter the 6-digit code shown on your Apple TV:").size(13)); + content = content.push( + row![ + text_input("123456", &self.pin) + .on_input(Message::PinChanged) + .on_submit_maybe(if self.pin.len() == 6 { + Some(Message::SubmitPin) + } else { + None + }) + .width(iced::Length::Fixed(120.0)), + button(text("Submit").align_x(Center)) + .on_press_maybe(if self.pin.len() == 6 { + Some(Message::SubmitPin) + } else { + None + }) + .style(appearance::p_button) + ] + .spacing(appearance::THEME_PADDING) + .align_y(Center), + ); + content = content.push( + button(text("Cancel").align_x(Center)) + .on_press(Message::CancelPin) + .style(appearance::s_button) + .width(Fill), + ); + } + + content + } + + fn view_paired(&self, device: &Device) -> iced::widget::Column<'_, Message> { + let mut content = column![]; + + content = content + .push(text(format!("Paired with {}", device.name)).size(appearance::THEME_FONT_SIZE + 2.0)); + + content = content.push( + text(format!( + "{} · {} · UDID {}", + device.product_type.as_deref().unwrap_or("Apple TV"), + device.os_version.as_deref().unwrap_or("tvOS"), + device.udid + )) + .size(13), + ); + + content = content.push( + text( + "This Apple TV is now selectable in the device list at the top of the window. \ + To install to it, import an IPA from the main screen the same way you would \ + for any other device. Forgetting here removes Impactor's local pairing record; \ + use the Apple TV's Forget All Remote Devices option to require a new PIN.", + ) + .size(13), + ); + + if let Some(ref s) = self.status { + content = content.push(text(&s.content).size(13).color(s.color())); + } + + content = + content.push(container(rule::horizontal(1)).padding([appearance::THEME_PADDING, 0.0])); + content = content.push( + button(text("Pair a Different Apple TV").align_x(Center)) + .on_press(Message::StartOver) + .style(appearance::s_button) + .width(Fill), + ); + content = content.push( + button(text("Forget Host Pairing").align_x(Center)) + .on_press(Message::Forget) + .style(appearance::s_button) + .width(Fill), + ); + + content + } +} diff --git a/apps/plumeimpactor/src/subscriptions.rs b/apps/plumeimpactor/src/subscriptions.rs index 89451e1e..3ae617f2 100644 --- a/apps/plumeimpactor/src/subscriptions.rs +++ b/apps/plumeimpactor/src/subscriptions.rs @@ -5,8 +5,9 @@ use tray_icon::{TrayIconEvent, menu::MenuEvent}; use crate::{ defaults::get_data_path, - screen::{Message, general}, + screen::{Message, general, progress::ProgressUpdate}, }; +use plume_utils::discovery::{DeviceDiscovery, PlatformDiscovery}; use plume_utils::{Bundle, Device, PlistInfoTrait}; pub(crate) fn device_listener() -> Subscription { @@ -30,9 +31,18 @@ pub(crate) fn device_listener() -> Subscription { let _ = tx.unbounded_send(Message::DeviceConnected(Device { name: "This Mac".into(), udid: mac_udid, + product_type: None, + device_class: Some("Mac".to_string()), + os_version: None, + serial_number: None, device_id: u32::MAX, usbmuxd_device: None, is_mac: true, + pairing_address: None, + reconnect_address: None, + pairing_identity: None, + pairing_cache_dir: None, + core_device_authenticated: false, })); } } @@ -42,8 +52,10 @@ pub(crate) fn device_listener() -> Subscription { }; if let Ok(devices) = muxer.get_devices().await { - for dev in devices { - let device = Device::new(dev).await; + let device_futures = devices.into_iter().map(Device::new); + for device in plume_utils::deduplicate_devices( + futures::future::join_all(device_futures).await, + ) { let _ = tx.unbounded_send(Message::DeviceConnected(device)); } } @@ -75,6 +87,119 @@ pub(crate) fn device_listener() -> Subscription { }) } +pub(crate) fn network_device_listener() -> Subscription { + Subscription::run(|| { + iced::stream::channel( + 100, + |mut output: iced::futures::channel::mpsc::Sender| async move { + use iced::futures::{SinkExt, StreamExt}; + use std::collections::{HashMap, HashSet}; + + let (tx, mut rx) = iced::futures::channel::mpsc::unbounded::(); + + std::thread::spawn(move || { + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + + rt.block_on(async move { + type EmittedState = ( + Option<(std::net::IpAddr, u16)>, + Option<(std::net::IpAddr, u16)>, + String, + ); + + let mut present_ids: HashSet = HashSet::new(); + let mut last_emitted: HashMap = HashMap::new(); + let mut miss_counts: HashMap = HashMap::new(); + let mut enriched: HashMap = + HashMap::new(); + + loop { + let scan_started = std::time::Instant::now(); + let scan_result = PlatformDiscovery::new() + .discover(std::time::Duration::from_secs(5)) + .await; + + match scan_result { + Ok(discovered) => { + let cache_dir = get_data_path(); + let devices = plume_utils::discovery::group_network_devices( + &discovered, + &cache_dir, + ); + + let mut current_ids: HashSet = HashSet::new(); + + for mut device in devices { + let id = device.device_id; + current_ids.insert(id); + miss_counts.remove(&id); + + if let Some(info) = enriched.get(&id) { + device.apply_tvos_info(info); + } else if device.has_pairing_source(&cache_dir) { + match device.fetch_tvos_info(cache_dir.clone()).await { + Ok(info) => { + device.apply_tvos_info(&info); + enriched.insert(id, info); + } + Err(e) => { + log::warn!( + "Could not fetch tvOS identity for {}: {e}", + device.name + ); + } + } + } + + let state: EmittedState = ( + device.pairing_address, + device.reconnect_address, + device.udid.clone(), + ); + let changed = last_emitted.get(&id) != Some(&state); + + if !present_ids.contains(&id) || changed { + present_ids.insert(id); + last_emitted.insert(id, state); + let _ = + tx.unbounded_send(Message::DeviceConnected(device)); + } + } + + for id in plume_utils::discovery::disconnected_after_missed_scans( + &mut present_ids, + ¤t_ids, + &mut miss_counts, + 2, + ) { + let _ = tx.unbounded_send(Message::DeviceDisconnected(id)); + last_emitted.remove(&id); + } + } + Err(e) => { + log::warn!("Network device scan failed: {e}"); + } + } + + let elapsed = scan_started.elapsed(); + let sleep_for = + std::time::Duration::from_secs(30).saturating_sub(elapsed); + tokio::time::sleep(sleep_for).await; + } + }); + }); + + while let Some(message) = rx.next().await { + let _ = output.send(message).await; + } + }, + ) + }) +} + pub(crate) fn tray_subscription() -> Subscription { Subscription::run(|| { iced::stream::channel( @@ -219,12 +344,12 @@ pub(crate) fn file_hover_subscription() -> Subscription { } pub(crate) fn installation_progress_listener( - progress_rx: Option>>>, -) -> Subscription<(String, i32)> { + progress_rx: Option>>>, +) -> Subscription { match progress_rx { Some(rx) => { struct State { - rx: Arc>>, + rx: Arc>>, } impl std::hash::Hash for State { @@ -238,25 +363,19 @@ pub(crate) fn installation_progress_listener( let rx = state.rx.clone(); iced::stream::channel( 100, - move |mut output: iced::futures::channel::mpsc::Sender<(String, i32)>| async move { + move |mut output: iced::futures::channel::mpsc::Sender| async move { use iced::futures::{SinkExt, StreamExt}; let (tx, mut rx_stream) = - iced::futures::channel::mpsc::unbounded::<(String, i32)>(); + iced::futures::channel::mpsc::unbounded::(); let rx_thread = rx.clone(); std::thread::spawn(move || { loop { - let message = { - if let Ok(guard) = rx_thread.lock() { - guard.try_recv().ok() - } else { - None + if let Ok(guard) = rx_thread.lock() { + while let Ok(update) = guard.try_recv() { + let _ = tx.unbounded_send(update); } - }; - - if let Some((status, progress)) = message { - let _ = tx.unbounded_send((status, progress)); } std::thread::sleep(std::time::Duration::from_millis(100)); @@ -280,16 +399,35 @@ pub(crate) async fn run_installation( options: &plume_utils::SignerOptions, account: Option<&plume_store::GsaAccount>, mut store: Option<&mut plume_store::AccountStore>, - tx: &std::sync::mpsc::Sender<(String, i32)>, + tx: &std::sync::mpsc::Sender, ) -> Result<(), String> { - use plume_core::{AnisetteConfiguration, CertificateIdentity, developer::DeveloperSession}; + use plume_core::{ + AnisetteConfiguration, CertificateIdentity, + developer::{DeveloperPlatform, DeveloperSession}, + }; use plume_utils::{Signer, SignerInstallMode, SignerMode}; let package_file: Bundle; let mut options = options.clone(); let send = |msg: String, progress: i32| { - let _ = tx.send((msg, progress)); + let _ = tx.send(ProgressUpdate::new(msg, progress)); }; + let platform = match device { + Some(dev) if dev.is_tvos() => DeveloperPlatform::Tvos, + _ => DeveloperPlatform::Ios, + }; + let device_udid = device + .filter(|dev| !dev.is_mac) + .map(|dev| dev.udid.as_str()); + + if let Some(dev) = device.filter(|dev| !dev.is_mac) + && !plume_utils::is_valid_device_udid(&dev.udid) + { + return Err(format!( + "Authenticated {} UDID is unavailable; pair or reconnect before installing", + dev.name + )); + } send("Preparing package...".to_string(), 10); @@ -348,9 +486,9 @@ pub(crate) async fn run_installation( send("Ensuring device is registered...".to_string(), 30); - if let Some(dev) = &device { + if let Some(dev) = device.filter(|dev| !dev.is_mac) { session - .qh_ensure_device(team_id, &dev.name, &dev.udid) + .qh_ensure_device(team_id, &dev.name, &dev.udid, platform) .await .map_err(|e| e.to_string())?; } @@ -361,20 +499,32 @@ pub(crate) async fn run_installation( let bundle = package.get_package_bundle().map_err(|e| e.to_string())?; - send("Signing package...".to_string(), 70); + send("Preparing provisioning profiles...".to_string(), 60); signer .modify_bundle(&bundle, &Some(team_id.clone())) .await .map_err(|e| e.to_string())?; signer - .register_bundle(&bundle, &session, team_id, false) + .register_bundle_for_device( + &bundle, + &session, + team_id, + false, + platform, + device_udid, + ) .await .map_err(|e| e.to_string())?; + send("Signing package...".to_string(), 70); signer - .sign_bundle(&bundle) + .sign_bundle_for_device(&bundle, platform, device_udid) .await .map_err(|e| e.to_string())?; + send("Verifying signatures and provisioning profiles...".to_string(), 82); + signer + .validate_signed_bundle(&bundle, platform, device_udid) + .map_err(|e| e.to_string())?; options = signer.options.clone(); package_file = bundle; @@ -396,6 +546,9 @@ pub(crate) async fn run_installation( .sign_bundle(&bundle) .await .map_err(|e| e.to_string())?; + signer + .validate_signed_bundle(&bundle, platform, None) + .map_err(|e| e.to_string())?; options = signer.options.clone(); package_file = bundle; @@ -413,15 +566,58 @@ pub(crate) async fn run_installation( SignerInstallMode::Install => { if let Some(dev) = &device { if !dev.is_mac { - send("Sending to device...".to_string(), 70); + let upload_path = if dev.is_network() { + let _ = tx.send(ProgressUpdate::indeterminate( + "Packaging for transfer...".to_string(), + 70, + )); + + let archive_package = package.clone(); + let bundle_dir = package_file.bundle_dir().clone(); + tokio::task::spawn_blocking(move || { + archive_package.get_archive_based_on_path(&bundle_dir) + }) + .await + .map_err(|e| format!("Packaging task failed: {e}"))? + .map_err(|e| format!("Failed to package for transfer: {e}"))? + } else { + package_file.bundle_dir().clone() + }; + + if upload_path.is_file() { + plume_utils::Package::validate_archive( + &upload_path, + options.mode == SignerMode::Pem, + ) + .map_err(|e| format!("Produced package failed validation: {e}"))?; + } + + if dev.is_network() { + let _ = tx.send(ProgressUpdate::indeterminate( + "Pairing/reconnecting to Apple TV...".to_string(), + 72, + )); + } + + let upload_status = match tokio::fs::metadata(&upload_path).await { + Ok(meta) if meta.is_file() => format!( + "Sending to device ({})...", + plume_utils::format_bytes(meta.len()) + ), + _ => "Sending to device...".to_string(), + }; + let _ = tx.send(ProgressUpdate::indeterminate(upload_status, 70)); let tx_clone = tx.clone(); - dev.install_app(&package_file.bundle_dir(), move |progress: i32| { + dev.install_app(&upload_path, move |progress: i32| { let tx = tx_clone.clone(); // Some libraries expect this future to be processed. // We ensure it sends and resolves immediately. Box::pin(async move { - let _ = tx.send(("Installing...".to_string(), 70 + (progress / 5))); + let _ = tx.send(ProgressUpdate::new( + "Installing...".to_string(), + 70 + (progress / 5), + )); }) }) .await @@ -457,6 +653,11 @@ pub(crate) async fn run_installation( let archive_path = package .get_archive_based_on_path(&package_file.bundle_dir()) .map_err(|e| e.to_string())?; + plume_utils::Package::validate_archive( + &archive_path, + options.mode == SignerMode::Pem, + ) + .map_err(|e| e.to_string())?; let file = rfd::AsyncFileDialog::new() .set_title("Save Package As") @@ -478,7 +679,7 @@ pub(crate) async fn run_installation( } if options.refresh && options.mode == SignerMode::Pem { - send("Saving for refresh...".to_string(), 75); + send("Saving for refresh...".to_string(), 99); let path = get_data_path().join("refresh_store"); tokio::fs::create_dir_all(&path) .await diff --git a/apps/plumesign/src/commands/account.rs b/apps/plumesign/src/commands/account.rs index f58c09a5..9e5a4bfc 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; @@ -65,8 +69,12 @@ pub struct DevicesArgs { #[arg(short = 't', long = "team", value_name = "TEAM_ID")] pub team_id: Option, /// Filter by device platform (ios, tvos, watchos) - #[arg(long = "platform", value_name = "PLATFORM")] - pub platform: Option, + #[arg( + long = "platform", + value_name = "PLATFORM", + value_parser = parse_platform + )] + pub platform: Option, } #[derive(Debug, Args)] @@ -80,6 +88,25 @@ pub struct RegisterDeviceArgs { /// Device name #[arg(short = 'n', long = "name", value_name = "NAME", required = true)] pub name: String, + #[arg( + long = "platform", + value_name = "PLATFORM", + value_parser = parse_platform, + default_value_t = DeveloperPlatform::Ios + )] + pub platform: DeveloperPlatform, +} + +fn parse_platform(value: &str) -> std::result::Result { + if value.eq_ignore_ascii_case("ios") || value.eq_ignore_ascii_case("iphoneos") { + std::result::Result::Ok(DeveloperPlatform::Ios) + } else if value.eq_ignore_ascii_case("tvos") || value.eq_ignore_ascii_case("appletvos") { + std::result::Result::Ok(DeveloperPlatform::Tvos) + } else { + Err(format!( + "unsupported platform {value:?}; expected ios or tvos" + )) + } } #[derive(Debug, Args)] @@ -267,7 +294,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, args.platform.unwrap_or_default()) + .await? + .devices; log::info!("{:#?}", p); @@ -284,7 +314,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, args.platform) .await? .device; diff --git a/apps/plumesign/src/commands/device.rs b/apps/plumesign/src/commands/device.rs index feb12262..66e08c7f 100644 --- a/apps/plumesign/src/commands/device.rs +++ b/apps/plumesign/src/commands/device.rs @@ -1,75 +1,115 @@ +use std::net::{IpAddr, Ipv4Addr}; use std::path::PathBuf; +use std::time::Duration; -use anyhow::{Error, Ok, Result}; -use clap::Args; -use dialoguer::Select; -use idevice::{ - IdeviceService, - installation_proxy::InstallationProxyClient, - usbmuxd::{UsbmuxdAddr, UsbmuxdConnection}, -}; +use anyhow::{Result, anyhow}; +use clap::{Args, Subcommand}; +use dialoguer::{Input, Select}; +use plume_utils::discovery::{DeviceDiscovery, PlatformDiscovery}; use plume_utils::{Device, Package, get_device_for_id}; +use crate::get_data_path; + #[derive(Debug, Args)] #[command(arg_required_else_help = true)] pub struct DeviceArgs { - /// Device UDID to target (optional, will prompt if not provided) - #[arg( - short = 'u', - long = "udid", - value_name = "UDID", - conflicts_with = "mac" - )] + #[arg(short = 'u', long = "udid", value_name = "UDID", conflicts_with = "mac")] pub udid: Option, - /// Install app at specified path to device (.ipa, .app) #[arg(short = 'i', long = "install", value_name = "PATH")] pub install: Option, - /// Install pairing record from specified path to device #[arg( short = 'p', long = "pairing", - value_name = "MAC", conflicts_with = "mac", requires = "pairing_path" )] pub pairing: bool, - /// Path to pairing record to install (i.e. /Documents/pairingFile.plist) #[arg(long = "pairing-path", value_name = "PATH", requires = "pairing")] pub pairing_path: Option, - /// App identifier for the app to use for pairing record installation (optional, will prompt if not provided) #[arg(long = "pairing-app-identifier", value_name = "IDENTIFIER")] pub pairing_app_identifier: Option, - /// Install to connected Mac (arm64 only) #[cfg(all(target_os = "macos", target_arch = "aarch64"))] - #[arg(short = 'm', long = "mac", value_name = "MAC", conflicts_with = "udid")] + #[arg(short = 'm', long = "mac", conflicts_with = "udid")] pub mac: bool, } +#[derive(Debug, Args)] +pub struct PairArgs { + #[command(subcommand)] + pub command: PairCommand, +} + +#[derive(Debug, Subcommand)] +pub enum PairCommand { + Scan { + #[arg(long, default_value_t = 5)] + timeout: u64, + }, + Connect(PairConnectArgs), + Reconnect { + #[arg(long, default_value_t = 5)] + timeout: u64, + }, + Forget(PairForgetArgs), +} + +#[derive(Debug, Args)] +pub struct PairConnectArgs { + #[arg(long)] + pub name: Option, + #[arg(long)] + pub ip: Option, + #[arg(long)] + pub port: Option, + #[arg(long)] + pub pin: Option, + #[arg(long, default_value_t = 5)] + pub timeout: u64, +} + +#[derive(Debug, Args)] +pub struct PairForgetArgs { + #[arg(long, conflicts_with = "identity")] + pub name: Option, + #[arg(long)] + pub identity: Option, +} + pub async fn execute(args: DeviceArgs) -> Result<()> { let device = { #[cfg(all(target_os = "macos", target_arch = "aarch64"))] { if args.mac { - Device { + Some(Device { name: "My Mac".to_string(), udid: String::new(), + product_type: None, + device_class: Some("Mac".to_string()), + os_version: None, + serial_number: None, device_id: 0, usbmuxd_device: None, is_mac: true, - } + pairing_address: None, + reconnect_address: None, + pairing_identity: None, + pairing_cache_dir: None, + core_device_authenticated: false, + }) } else { - select_device(args.udid).await? + Some(select_device(args.udid).await?) } } #[cfg(not(all(target_os = "macos", target_arch = "aarch64")))] { - select_device(args.udid).await? + Some(select_device(args.udid).await?) } }; + let device = device.ok_or_else(|| anyhow!("No device selected"))?; + if let Some(app_path) = args.install { let mut app_path = app_path; - if !app_path.is_dir() { app_path = Package::new(app_path)? .get_package_bundle()? @@ -79,33 +119,30 @@ pub async fn execute(args: DeviceArgs) -> Result<()> { #[cfg(all(target_os = "macos", target_arch = "aarch64"))] if args.mac { - log::info!("Installing app at {:?} to connected Mac", app_path); plume_utils::install_app_mac(&app_path).await?; return Ok(()); } - log::info!("Installing app at {:?} to device {}", app_path, device.name); device .install_app(&app_path, |progress| async move { - log::info!("{}", progress); + log::info!("Installation progress: {progress}%"); }) .await?; } if args.pairing { if let Some(pairing_path) = args.pairing_path { - log::info!( - "Installing pairing record from {:?} to device {}", - pairing_path, - device.name - ); - let app_identifier = if let Some(identifier) = args.pairing_app_identifier { - identifier - } else { - apps(&device).await? + let app_identifier = match args.pairing_app_identifier { + Some(identifier) => identifier, + None => apps(&device).await?, }; device - .install_pairing_record(&app_identifier, pairing_path.to_str().unwrap()) + .install_pairing_record( + &app_identifier, + pairing_path + .to_str() + .ok_or_else(|| anyhow!("Pairing path is not valid UTF-8"))?, + ) .await?; } } @@ -113,58 +150,203 @@ pub async fn execute(args: DeviceArgs) -> Result<()> { Ok(()) } -pub async fn select_device(device_udid: Option) -> Result { - if let Some(udid) = device_udid { - return Ok(get_device_for_id(&udid).await?); +pub async fn execute_pair(args: PairArgs) -> Result<()> { + match args.command { + PairCommand::Scan { timeout } => { + let devices = discover_network_devices(Duration::from_secs(timeout)).await?; + if devices.is_empty() { + println!("No Apple TVs found."); + } else { + for device in devices { + println!("{device}"); + } + } + } + PairCommand::Connect(args) => pair_connect(args).await?, + PairCommand::Reconnect { timeout } => { + let devices = discover_network_devices(Duration::from_secs(timeout)).await?; + let mut found = false; + for device in devices { + found = true; + if plume_utils::is_valid_device_udid(&device.udid) { + println!("Reconnected {device}"); + } else { + println!("{} is paired but its authenticated UDID is unavailable", device.name); + } + } + if !found { + println!("No Apple TVs found."); + } + } + PairCommand::Forget(args) => { + let PairForgetArgs { name, identity } = args; + let identity = identity + .or_else(|| name.as_deref().map(|name| name.replace(' ', "-"))) + .ok_or_else(|| anyhow!("--name or --identity is required"))?; + let name = name.unwrap_or_else(|| identity.replace('-', " ")); + let device = Device::new_tvos( + name, + identity, + IpAddr::V4(Ipv4Addr::UNSPECIFIED), + None, + None, + get_data_path(), + ); + device.forget_tvos_pairing(get_data_path()).await?; + println!("Forgot the saved Apple TV pairing record."); + } } - let mut muxer = UsbmuxdConnection::default().await?; - let devices = muxer.get_devices().await?; + Ok(()) +} - if devices.is_empty() { - return Err(anyhow::anyhow!( - "No devices connected. Please connect a device or specify a UDID with --device-udid" - )); +pub async fn select_device(device_udid: Option) -> Result { + let devices = discover_devices().await?; + if let Some(udid) = device_udid { + if let Some(device) = devices + .iter() + .find(|device| device.udid.eq_ignore_ascii_case(&udid)) + { + return Ok(device.clone()); + } + if let Ok(device) = get_device_for_id(&udid).await { + return Ok(device); + } + return Err(anyhow!("No connected device has UDID {udid}")); } - let device_futures: Vec<_> = devices.into_iter().map(|d| Device::new(d)).collect(); - - let devices = futures::future::join_all(device_futures).await; - - let device_names: Vec = devices.iter().map(|d| d.to_string()).collect(); - + let names = devices.iter().map(ToString::to_string).collect::>(); let selection = Select::new() .with_prompt("Select a device to register and install to") - .items(&device_names) + .items(&names) .default(0) .interact()?; - Ok(devices[selection].clone()) } -async fn apps(device: &Device) -> Result { - const INSTALLATION_LABEL: &str = "App Installation"; - let p = device.usbmuxd_device.clone().unwrap().to_provider( - UsbmuxdAddr::from_env_var().unwrap_or_default(), - INSTALLATION_LABEL, - ); +async fn discover_devices() -> Result> { + let mut devices = Vec::new(); + if let Ok(mut muxer) = idevice::usbmuxd::UsbmuxdConnection::default().await { + if let Ok(usb_devices) = muxer.get_devices().await { + devices.extend( + futures::future::join_all(usb_devices.into_iter().map(Device::new)).await, + ); + } + } + devices.extend(discover_network_devices(Duration::from_secs(5)).await?); + let devices = plume_utils::deduplicate_devices(devices); + if devices.is_empty() { + return Err(anyhow!( + "No devices connected. Connect a device or pair an Apple TV with `plumesign pair connect`." + )); + } + Ok(devices) +} - let mut lpc = InstallationProxyClient::connect(&p) - .await - .map_err(|e| anyhow::anyhow!("Failed to create installation proxy client: {}", e))?; +async fn discover_network_devices(timeout: Duration) -> Result> { + let discovered = PlatformDiscovery::new().discover(timeout).await?; + let cache_dir = get_data_path(); + let mut devices = plume_utils::discovery::group_network_devices(&discovered, &cache_dir); + for device in &mut devices { + if device.has_pairing_source(&cache_dir) { + if let Ok(info) = device.fetch_tvos_info(cache_dir.clone()).await { + device.apply_tvos_info(&info); + } + } + } + Ok(plume_utils::deduplicate_devices(devices)) +} - let ia = lpc - .get_apps(Some("User"), None) - .await - .map_err(|e| anyhow::anyhow!("Failed to get installed apps: {}", e))?; +async fn pair_connect(args: PairConnectArgs) -> Result<()> { + let cache_dir = get_data_path(); + let mut device = if let Some(ip) = args.ip { + let name = args.name.unwrap_or_else(|| "Apple TV".to_string()); + let port = args + .port + .ok_or_else(|| anyhow!("--port is required with --ip"))?; + let identity = name.replace(' ', "-"); + Device::new_tvos(name, identity, ip, Some(port), None, cache_dir.clone()) + } else { + let devices = discover_network_devices(Duration::from_secs(args.timeout)).await?; + let tv_names = devices + .iter() + .map(|device| device.name.clone()) + .collect::>(); + if tv_names.is_empty() { + return Err(anyhow!("No Apple TVs found on the network")); + } + let index = if let Some(name) = args.name { + devices + .iter() + .position(|device| device.name.eq_ignore_ascii_case(&name)) + .ok_or_else(|| anyhow!("Apple TV {name:?} was not found"))? + } else { + Select::new() + .with_prompt("Select an Apple TV to pair") + .items(&tv_names) + .default(0) + .interact()? + }; + devices[index].clone() + }; - let app_names: Vec = ia.keys().cloned().collect(); + let pin = args.pin; + device + .pair_tvos( + move || { + let pin = pin.clone(); + async move { + pin.unwrap_or_else(|| { + Input::::new() + .with_prompt("Enter the PIN shown on the Apple TV") + .interact_text() + .unwrap_or_default() + }) + } + }, + cache_dir.clone(), + ) + .await?; + let info = device.fetch_tvos_info(cache_dir).await?; + device.apply_tvos_info(&info); + if !plume_utils::is_valid_device_udid(&device.udid) { + return Err(anyhow!( + "Pairing succeeded but the authenticated Apple TV UDID was not returned" + )); + } + println!( + "Paired {} ({}, {}, UDID {})", + device.name, + device.product_type.as_deref().unwrap_or("Apple TV"), + device.os_version.as_deref().unwrap_or("tvOS"), + device.udid + ); + Ok(()) +} + +async fn apps(device: &Device) -> Result { + let apps = device.installed_apps().await?; + if apps.is_empty() { + return Err(anyhow!("No supported installed apps found")); + } + let names = apps + .iter() + .map(|app| { + format!( + "{} ({})", + app.app, + app.bundle_id.as_deref().unwrap_or("unknown bundle") + ) + }) + .collect::>(); let selection = Select::new() - .items(&app_names) - .default(0) .with_prompt("Select an installed app") + .items(&names) + .default(0) .interact()?; - - Ok(app_names[selection].clone()) + apps[selection] + .bundle_id + .clone() + .ok_or_else(|| anyhow!("Selected app has no bundle identifier")) } diff --git a/apps/plumesign/src/commands/mod.rs b/apps/plumesign/src/commands/mod.rs index b418c48c..cfca12a9 100644 --- a/apps/plumesign/src/commands/mod.rs +++ b/apps/plumesign/src/commands/mod.rs @@ -29,4 +29,5 @@ pub enum Commands { Account(account::AccountArgs), /// Device management commands Device(device::DeviceArgs), + Pair(device::PairArgs), } diff --git a/apps/plumesign/src/commands/sign.rs b/apps/plumesign/src/commands/sign.rs index 86a63f7a..7757c564 100644 --- a/apps/plumesign/src/commands/sign.rs +++ b/apps/plumesign/src/commands/sign.rs @@ -127,9 +127,18 @@ pub async fn execute(args: SignArgs) -> Result<()> { Some(Device { name: "My Mac".to_string(), udid: String::new(), + product_type: None, + device_class: Some("Mac".to_string()), + os_version: None, + serial_number: None, device_id: 0, usbmuxd_device: None, is_mac: true, + pairing_address: None, + reconnect_address: None, + pairing_identity: None, + pairing_cache_dir: None, + core_device_authenticated: false, }) } else { Some(select_device(args.udid).await?) @@ -143,30 +152,64 @@ pub async fn execute(args: SignArgs) -> Result<()> { None }; + let platform = device + .as_ref() + .map(plume_utils::Device::developer_platform) + .unwrap_or_default(); + let device_udid = device + .as_ref() + .filter(|device| !device.is_mac) + .map(|device| device.udid.as_str()); + if let Some(device) = device.as_ref().filter(|device| !device.is_mac) + && !plume_utils::is_valid_device_udid(&device.udid) + { + return Err(anyhow::anyhow!( + "Authenticated Apple TV UDID is unavailable; pair or reconnect before installing" + )); + } + if let Some((session, team_id)) = team_id_opt { signer .modify_bundle(&bundle, &Some(team_id.clone())) .await?; - if let Some(ref dev) = device { + if let Some(dev) = device.as_ref().filter(|device| !device.is_mac) { log::info!("Registering device: {} ({})", dev.name, dev.udid); session - .qh_ensure_device(&team_id, &dev.name, &dev.udid) + .qh_ensure_device(&team_id, &dev.name, &dev.udid, platform) .await?; } signer - .register_bundle(&bundle, &session, &team_id, false) + .register_bundle_for_device( + &bundle, + &session, + &team_id, + false, + platform, + device_udid, + ) + .await?; + signer + .sign_bundle_for_device(&bundle, platform, device_udid) .await?; - signer.sign_bundle(&bundle).await?; + signer.validate_signed_bundle(&bundle, platform, device_udid)?; if let Some(dev) = device { log::info!("Installing to device: {}", dev.name); + let install_path = if dev.is_network() { + let package = package + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Network installation requires an IPA input"))?; + package.get_archive_based_on_path(&bundle.bundle_dir())? + } else { + bundle.bundle_dir().clone() + }; #[cfg(all(target_os = "macos", target_arch = "aarch64"))] if args.mac { plume_utils::install_app_mac(&bundle.bundle_dir()).await?; } else { - dev.install_app(bundle.bundle_dir(), |progress| async move { + dev.install_app(&install_path, |progress| async move { log::info!("Installation progress: {}%", progress); }) .await?; @@ -174,7 +217,7 @@ pub async fn execute(args: SignArgs) -> Result<()> { #[cfg(not(all(target_os = "macos", target_arch = "aarch64")))] { - dev.install_app(bundle.bundle_dir(), |progress| async move { + dev.install_app(&install_path, |progress| async move { log::info!("Installation progress: {}%", progress); }) .await?; @@ -184,11 +227,22 @@ pub async fn execute(args: SignArgs) -> Result<()> { } } else { signer.modify_bundle(&bundle, &None).await?; - signer.sign_bundle(&bundle).await?; + signer + .sign_bundle_for_device(&bundle, platform, device_udid) + .await?; + signer.validate_signed_bundle(&bundle, platform, device_udid)?; if let Some(dev) = device { log::info!("Installing to device: {}", dev.name); - dev.install_app(bundle.bundle_dir(), |progress| async move { + let install_path = if dev.is_network() { + let package = package + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Network installation requires an IPA input"))?; + package.get_archive_based_on_path(&bundle.bundle_dir())? + } else { + bundle.bundle_dir().clone() + }; + dev.install_app(&install_path, |progress| async move { log::info!("Installation progress: {}%", progress); }) .await?; @@ -200,6 +254,7 @@ pub async fn execute(args: SignArgs) -> Result<()> { if let Some(pkg) = package { if let Some(output_path) = args.output { let archived_path = pkg.get_archive_based_on_path(&args.package.clone())?; + Package::validate_archive(&archived_path, signer.options.mode == SignerMode::Pem)?; tokio::fs::copy(&archived_path, &output_path).await?; log::info!("Saved signed package to: {}", output_path.display()); if std::env::var("PLUME_DELETE_AFTER_FINISHED").is_err() { diff --git a/apps/plumesign/src/main.rs b/apps/plumesign/src/main.rs index b0f933d7..56bf28d1 100644 --- a/apps/plumesign/src/main.rs +++ b/apps/plumesign/src/main.rs @@ -19,6 +19,7 @@ async fn main() -> anyhow::Result<()> { Commands::MachO(args) => commands::macho::execute(args).await?, Commands::Account(args) => commands::account::execute(args).await?, Commands::Device(args) => commands::device::execute(args).await?, + Commands::Pair(args) => commands::device::execute_pair(args).await?, } Ok(()) diff --git a/crates/plume_core/src/developer/mod.rs b/crates/plume_core/src/developer/mod.rs index b68d1d95..a1bea0e0 100644 --- a/crates/plume_core/src/developer/mod.rs +++ b/crates/plume_core/src/developer/mod.rs @@ -1,7 +1,9 @@ +mod platform; pub mod qh; mod session; pub mod v1; +pub use platform::DeveloperPlatform; pub use session::{DeveloperSession, RequestType}; #[macro_export] diff --git a/crates/plume_core/src/developer/platform.rs b/crates/plume_core/src/developer/platform.rs new file mode 100644 index 00000000..25e8dcc0 --- /dev/null +++ b/crates/plume_core/src/developer/platform.rs @@ -0,0 +1,71 @@ +use plist::{Dictionary, Value}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DeveloperPlatform { + #[default] + Ios, + Tvos, +} + +impl DeveloperPlatform { + pub fn from_device_metadata( + product_type: Option<&str>, + device_class: Option<&str>, + network_transport: bool, + ) -> Self { + if network_transport + || product_type.is_some_and(|value| value.starts_with("AppleTV")) + || device_class.is_some_and(|value| value.eq_ignore_ascii_case("AppleTV")) + { + Self::Tvos + } else { + Self::Ios + } + } + + pub fn request_fields(self) -> &'static [(&'static str, &'static str)] { + match self { + DeveloperPlatform::Ios => &[], + DeveloperPlatform::Tvos => &[("DTDK_Platform", "tvos"), ("subPlatform", "tvOS")], + } + } + + pub fn capabilities_filter(self) -> &'static str { + match self { + DeveloperPlatform::Ios => "IOS", + DeveloperPlatform::Tvos => "TVOS", + } + } + + pub fn apply_to(self, body: &mut Dictionary) { + let fields = self.request_fields(); + if fields.is_empty() { + return; + } + for (key, value) in fields { + body.insert((*key).to_string(), Value::String((*value).to_string())); + } + } + + pub fn profile_platforms(self) -> &'static [&'static str] { + match self { + Self::Ios => &["ios", "iphoneos"], + Self::Tvos => &["tvos"], + } + } + + pub fn matches_profile_platform(self, value: &str) -> bool { + self.profile_platforms() + .iter() + .any(|candidate| candidate.eq_ignore_ascii_case(value)) + } +} + +impl std::fmt::Display for DeveloperPlatform { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DeveloperPlatform::Ios => formatter.write_str("iOS"), + DeveloperPlatform::Tvos => formatter.write_str("tvOS"), + } + } +} diff --git a/crates/plume_core/src/developer/qh/app_ids.rs b/crates/plume_core/src/developer/qh/app_ids.rs index 229273cb..3b50527f 100644 --- a/crates/plume_core/src/developer/qh/app_ids.rs +++ b/crates/plume_core/src/developer/qh/app_ids.rs @@ -4,15 +4,21 @@ use serde::Deserialize; use crate::Error; use super::{DeveloperSession, QHResponseMeta}; +use crate::developer::DeveloperPlatform; use crate::developer::strip_invalid_chars; use crate::developer_endpoint; impl DeveloperSession { - pub async fn qh_list_app_ids(&self, team_id: &String) -> Result { + pub async fn qh_list_app_ids( + &self, + team_id: &String, + platform: DeveloperPlatform, + ) -> Result { let endpoint = developer_endpoint!("/QH65B2/ios/listAppIds.action"); let mut body = Dictionary::new(); body.insert("teamId".to_string(), Value::String(team_id.clone())); + platform.apply_to(&mut body); let response = self.qh_send_request(&endpoint, Some(body)).await?; let response_data: AppIDsResponse = plist::from_value(&Value::Dictionary(response))?; @@ -25,6 +31,7 @@ impl DeveloperSession { team_id: &String, name: &String, identifier: &String, + platform: DeveloperPlatform, ) -> Result { let endpoint = developer_endpoint!("/QH65B2/ios/addAppId.action"); @@ -32,6 +39,7 @@ impl DeveloperSession { body.insert("teamId".to_string(), Value::String(team_id.clone())); body.insert("name".to_string(), Value::String(strip_invalid_chars(name))); body.insert("identifier".to_string(), Value::String(identifier.clone())); + platform.apply_to(&mut body); let response = self.qh_send_request(&endpoint, Some(body)).await?; let response_data: AppIDResponse = plist::from_value(&Value::Dictionary(response))?; @@ -82,8 +90,9 @@ impl DeveloperSession { &self, team_id: &String, identifier: &String, + platform: DeveloperPlatform, ) -> Result, Error> { - let response_data = self.qh_list_app_ids(team_id).await?; + let response_data = self.qh_list_app_ids(team_id, platform).await?; let app_id = response_data .app_ids @@ -98,11 +107,12 @@ impl DeveloperSession { team_id: &String, name: &String, identifier: &String, + platform: DeveloperPlatform, ) -> Result { - if let Some(app_id) = self.qh_get_app_id(team_id, identifier).await? { + if let Some(app_id) = self.qh_get_app_id(team_id, identifier, platform).await? { Ok(app_id) } else { - let response = self.qh_add_app_id(team_id, name, identifier).await?; + let response = self.qh_add_app_id(team_id, name, identifier, platform).await?; Ok(response.app_id) } } diff --git a/crates/plume_core/src/developer/qh/devices.rs b/crates/plume_core/src/developer/qh/devices.rs index 43a32041..268fed9d 100644 --- a/crates/plume_core/src/developer/qh/devices.rs +++ b/crates/plume_core/src/developer/qh/devices.rs @@ -4,14 +4,20 @@ use serde::Deserialize; use crate::Error; use super::{DeveloperSession, QHResponseMeta}; +use crate::developer::DeveloperPlatform; use crate::developer_endpoint; impl DeveloperSession { - pub async fn qh_list_devices(&self, team_id: &String) -> Result { + pub async fn qh_list_devices( + &self, + team_id: &String, + platform: DeveloperPlatform, + ) -> Result { let endpoint = developer_endpoint!("/QH65B2/ios/listDevices.action"); let mut body = Dictionary::new(); body.insert("teamId".to_string(), Value::String(team_id.clone())); + platform.apply_to(&mut body); let response = self.qh_send_request(&endpoint, Some(body)).await?; let response_data: DevicesResponse = plist::from_value(&Value::Dictionary(response))?; @@ -24,6 +30,7 @@ impl DeveloperSession { team_id: &String, device_name: &String, device_udid: &String, + platform: DeveloperPlatform, ) -> Result { let endpoint = developer_endpoint!("/QH65B2/ios/addDevice.action"); @@ -34,6 +41,7 @@ impl DeveloperSession { "deviceNumber".to_string(), Value::String(device_udid.clone()), ); + platform.apply_to(&mut body); let response = self.qh_send_request(&endpoint, Some(body)).await?; let response_data: DeviceResponse = plist::from_value(&Value::Dictionary(response))?; @@ -45,8 +53,9 @@ impl DeveloperSession { &self, team_id: &String, device_udid: &String, + platform: DeveloperPlatform, ) -> Result, Error> { - let response_data = self.qh_list_devices(team_id).await?; + let response_data = self.qh_list_devices(team_id, platform).await?; let device = response_data .devices @@ -61,12 +70,13 @@ impl DeveloperSession { team_id: &String, device_name: &String, device_udid: &String, + platform: DeveloperPlatform, ) -> Result { - if let Some(device) = self.qh_get_device(team_id, device_udid).await? { + if let Some(device) = self.qh_get_device(team_id, device_udid, platform).await? { Ok(device) } else { let response = self - .qh_add_device(team_id, device_name, device_udid) + .qh_add_device(team_id, device_name, device_udid, platform) .await?; Ok(response.device) } diff --git a/crates/plume_core/src/developer/qh/profile.rs b/crates/plume_core/src/developer/qh/profile.rs index 92cf54c3..a33dcd55 100644 --- a/crates/plume_core/src/developer/qh/profile.rs +++ b/crates/plume_core/src/developer/qh/profile.rs @@ -4,6 +4,7 @@ use serde::Deserialize; use crate::Error; use super::{DeveloperSession, QHResponseMeta}; +use crate::developer::DeveloperPlatform; use crate::developer_endpoint; impl DeveloperSession { @@ -11,12 +12,14 @@ impl DeveloperSession { &self, team_id: &String, app_id_id: &String, + platform: DeveloperPlatform, ) -> Result { let endpoint = developer_endpoint!("/QH65B2/ios/downloadTeamProvisioningProfile.action"); let mut body = Dictionary::new(); body.insert("teamId".to_string(), Value::String(team_id.clone())); body.insert("appIdId".to_string(), Value::String(app_id_id.clone())); + platform.apply_to(&mut body); let response = self.qh_send_request(&endpoint, Some(body)).await?; let response_data: ProfilesResponse = plist::from_value(&Value::Dictionary(response))?; diff --git a/crates/plume_core/src/developer/v1/capabilities.rs b/crates/plume_core/src/developer/v1/capabilities.rs index 0b2c4351..48c6e49e 100644 --- a/crates/plume_core/src/developer/v1/capabilities.rs +++ b/crates/plume_core/src/developer/v1/capabilities.rs @@ -4,6 +4,7 @@ use serde_json::json; use super::{DeveloperSession, RequestType}; use crate::developer_endpoint; +use crate::developer::DeveloperPlatform; use crate::Error; use std::collections::HashSet; @@ -22,11 +23,20 @@ const FREE_DEVELOPER_ACCOUNT_UNALLOWED_CAPABILITIES: &[&str] = &[ impl DeveloperSession { pub async fn v1_list_capabilities(&self, team: &String) -> Result { + self.v1_list_capabilities_for_platform(team, DeveloperPlatform::Ios) + .await + } + + pub async fn v1_list_capabilities_for_platform( + &self, + team: &String, + platform: DeveloperPlatform, + ) -> Result { let endpoint = developer_endpoint!("/v1/capabilities"); let body = json!({ "teamId": team, - "urlEncodedQueryParams": "filter[platform]=IOS" + "urlEncodedQueryParams": format!("filter[platform]={}", platform.capabilities_filter()) }); let response = self @@ -43,7 +53,26 @@ impl DeveloperSession { id: &String, entitlements: &Dictionary, ) -> Result<(), Error> { - let capabilities = self.v1_list_capabilities(team).await?.data; + self.v1_request_capabilities_for_entitlements_on_platform( + team, + id, + entitlements, + DeveloperPlatform::Ios, + ) + .await + } + + pub async fn v1_request_capabilities_for_entitlements_on_platform( + &self, + team: &String, + id: &String, + entitlements: &Dictionary, + platform: DeveloperPlatform, + ) -> Result<(), Error> { + let capabilities = self + .v1_list_capabilities_for_platform(team, platform) + .await? + .data; let entitlement_keys: HashSet<&str> = entitlements.keys().map(|k| k.as_str()).collect(); // Collect capability IDs that match entitlement keys and are allowed for free accounts diff --git a/crates/plume_core/src/lib.rs b/crates/plume_core/src/lib.rs index 3d438f9a..1ac3897d 100644 --- a/crates/plume_core/src/lib.rs +++ b/crates/plume_core/src/lib.rs @@ -2,11 +2,15 @@ pub mod auth; pub mod developer; mod utils; -pub use apple_codesign::{AppleCodesignError, SettingsScope, SigningSettings, UnifiedSigner}; +pub use apple_codesign::{ + AppleCodesignError, SettingsScope, SigningSettings, UnifiedSigner, verify_macho_data, +}; pub use omnisette::AnisetteConfiguration; -pub use utils::{CertificateIdentity, MachO, MachOExt, MobileProvision}; +pub use utils::{ + CertificateIdentity, MachO, MachOExt, MobileProvision, is_valid_device_udid, +}; use thiserror::Error as ThisError; #[derive(Debug, ThisError)] @@ -68,6 +72,8 @@ pub enum Error { Slice(#[from] std::array::TryFromSliceError), #[error("Invalid key length for AES-GCM: {0}")] SHA2(#[from] sha2::digest::InvalidLength), + #[error("Provisioning profile invalid: {0}")] + ProvisioningProfileInvalid(String), } pub fn client() -> Result { diff --git a/crates/plume_core/src/utils/certificate.rs b/crates/plume_core/src/utils/certificate.rs index 1e673e71..84d1473d 100644 --- a/crates/plume_core/src/utils/certificate.rs +++ b/crates/plume_core/src/utils/certificate.rs @@ -32,6 +32,12 @@ pub struct CertificateIdentity { } impl CertificateIdentity { + pub fn certificate_der(&self) -> Option<&[u8]> { + self.cert + .as_ref() + .map(CapturedX509Certificate::constructed_data) + } + // Use for cli context or if you actually store pems? why would you do that though pub async fn new_with_paths(paths: Option>) -> Result { let mut cert = Self { diff --git a/crates/plume_core/src/utils/mod.rs b/crates/plume_core/src/utils/mod.rs index 7427aed1..cb95c652 100644 --- a/crates/plume_core/src/utils/mod.rs +++ b/crates/plume_core/src/utils/mod.rs @@ -8,7 +8,7 @@ mod provision; pub use certificate::CertificateIdentity; #[cfg(feature = "tweaks")] pub use macho::{MachO, MachOExt}; -pub use provision::MobileProvision; +pub use provision::{MobileProvision, is_valid_device_udid}; pub const TEAM_ID_REGEX: &str = r"^[A-Z0-9]{10}\."; diff --git a/crates/plume_core/src/utils/provision.rs b/crates/plume_core/src/utils/provision.rs index b9b2bc4f..ecbd72a7 100644 --- a/crates/plume_core/src/utils/provision.rs +++ b/crates/plume_core/src/utils/provision.rs @@ -1,34 +1,44 @@ use std::fs; use std::path::{Path, PathBuf}; +use std::time::SystemTime; -use crate::Error; +use crate::developer::DeveloperPlatform; use crate::utils::TEAM_ID_REGEX; +use crate::{Error, MachO}; use plist::{Date, Dictionary, Value}; - -use super::MachO; +use x509_certificate::CapturedX509Certificate; #[derive(Clone)] pub struct MobileProvision { pub data: Vec, entitlements: Dictionary, expiration_date: Date, + platforms: Vec, + provisioned_devices: Vec, + developer_certificates: Vec>, } impl MobileProvision { pub fn load_with_path>(path: P) -> Result { - let path = path.as_ref(); - let data = fs::read(path)?; - - Self::load_with_bytes(data) + Self::load_with_bytes(fs::read(path)?) } pub fn load_with_bytes(data: Vec) -> Result { - let (entitlements, expiration_date) = Self::extract_entitlements_from_prov(&data)?; + let ( + entitlements, + expiration_date, + platforms, + provisioned_devices, + developer_certificates, + ) = Self::extract_profile_data(&data)?; Ok(Self { data, entitlements, expiration_date, + platforms, + provisioned_devices, + developer_certificates, }) } @@ -47,7 +57,7 @@ impl MobileProvision { .entitlements .get("com.apple.developer.team-identifier") .and_then(Value::as_string) - .map(|s| s.to_owned()); + .map(str::to_owned); crate::utils::merge_entitlements( &mut self.entitlements, @@ -80,39 +90,369 @@ impl MobileProvision { .as_string()?; let re = regex::Regex::new(TEAM_ID_REGEX).ok()?; - let bundle_id = re.replace(app_id, "").to_string(); + Some(re.replace(app_id, "").to_string()) + } - Some(bundle_id) + pub fn validate_for( + &self, + platform: DeveloperPlatform, + bundle_id: &str, + device_udid: Option<&str>, + certificate_der: Option<&[u8]>, + requested_entitlements: Option<&Dictionary>, + ) -> Result<(), Error> { + if self.platforms.is_empty() + || !self + .platforms + .iter() + .any(|value| platform.matches_profile_platform(value)) + { + return Err(Error::ProvisioningProfileInvalid(format!( + "profile does not target {}", + platform + ))); + } + + if SystemTime::now() >= SystemTime::from(self.expiration_date) { + return Err(Error::ProvisioningProfileInvalid( + "profile is expired".to_string(), + )); + } + + let application_identifier = self + .entitlements + .get("application-identifier") + .and_then(Value::as_string) + .ok_or_else(|| { + Error::ProvisioningProfileInvalid( + "profile has no application identifier".to_string(), + ) + })?; + if !application_identifier_grants(application_identifier, bundle_id) { + return Err(Error::ProvisioningProfileInvalid(format!( + "application identifier {application_identifier:?} does not match {bundle_id:?}" + ))); + } + + if let Some(udid) = device_udid { + if !is_valid_device_udid(udid) + || !self + .provisioned_devices + .iter() + .any(|value| value.eq_ignore_ascii_case(udid)) + { + return Err(Error::ProvisioningProfileInvalid(format!( + "profile does not contain selected Apple TV UDID {udid}" + ))); + } + } + + let Some(certificate_der) = certificate_der else { + return Err(Error::ProvisioningProfileInvalid( + "active signing certificate is unavailable".to_string(), + )); + }; + if !self + .developer_certificates + .iter() + .any(|certificate| certificate.as_slice() == certificate_der) + { + return Err(Error::ProvisioningProfileInvalid( + "profile does not contain the active signing certificate".to_string(), + )); + } + let certificate = CapturedX509Certificate::from_der(certificate_der)?; + if !certificate.time_constraints_valid(None) { + return Err(Error::ProvisioningProfileInvalid( + "active signing certificate is expired or not yet valid".to_string(), + )); + } + + if let Some(requested_entitlements) = requested_entitlements { + for (key, requested) in requested_entitlements { + if matches!( + key.as_str(), + "application-identifier" | "com.apple.developer.team-identifier" + ) { + continue; + } + + let Some(granted) = self.entitlements.get(key) else { + return Err(Error::ProvisioningProfileInvalid(format!( + "profile does not grant entitlement {key:?}" + ))); + }; + if !value_grants(granted, requested) { + return Err(Error::ProvisioningProfileInvalid(format!( + "profile does not grant entitlement {key:?}" + ))); + } + } + } + + Ok(()) } - fn extract_entitlements_from_prov(data: &[u8]) -> Result<(Dictionary, Date), Error> { + pub fn validate_final_entitlements( + &self, + platform: DeveloperPlatform, + bundle_id: &str, + device_udid: Option<&str>, + certificate_der: Option<&[u8]>, + entitlements: &Dictionary, + ) -> Result<(), Error> { + self.validate_for( + platform, + bundle_id, + device_udid, + certificate_der, + None, + )?; + + let profile_application_identifier = self + .entitlements + .get("application-identifier") + .and_then(Value::as_string) + .ok_or_else(|| { + Error::ProvisioningProfileInvalid( + "profile has no application identifier".to_string(), + ) + })?; + let application_identifier = entitlements + .get("application-identifier") + .and_then(Value::as_string) + .ok_or_else(|| { + Error::ProvisioningProfileInvalid( + "signed executable has no application identifier".to_string(), + ) + })?; + let final_bundle_id = application_identifier_bundle_id(application_identifier) + .ok_or_else(|| { + Error::ProvisioningProfileInvalid( + "signed executable has an invalid application identifier".to_string(), + ) + })?; + if final_bundle_id != bundle_id + || !application_identifier_grants(profile_application_identifier, final_bundle_id) + { + return Err(Error::ProvisioningProfileInvalid(format!( + "signed executable application identifier {application_identifier:?} does not match {bundle_id:?}" + ))); + } + + let profile_team_identifier = self + .entitlements + .get("com.apple.developer.team-identifier") + .and_then(Value::as_string) + .or_else(|| application_identifier_team(profile_application_identifier)) + .ok_or_else(|| { + Error::ProvisioningProfileInvalid( + "profile has no team identifier".to_string(), + ) + })?; + let final_team_identifier = entitlements + .get("com.apple.developer.team-identifier") + .and_then(Value::as_string) + .or_else(|| application_identifier_team(application_identifier)) + .ok_or_else(|| { + Error::ProvisioningProfileInvalid( + "signed executable has no team identifier".to_string(), + ) + })?; + if final_team_identifier != profile_team_identifier + || application_identifier_team(profile_application_identifier) + .is_some_and(|team| team != profile_team_identifier) + || application_identifier_team(application_identifier) + .is_some_and(|team| team != profile_team_identifier) + { + return Err(Error::ProvisioningProfileInvalid( + "signed executable team identifier does not match the provisioning profile" + .to_string(), + )); + } + + for (key, requested) in entitlements { + if key == "application-identifier" || key == "com.apple.developer.team-identifier" { + continue; + } + + let Some(granted) = self.entitlements.get(key) else { + return Err(Error::ProvisioningProfileInvalid(format!( + "profile does not grant entitlement {key:?}" + ))); + }; + if !value_grants(granted, requested) { + return Err(Error::ProvisioningProfileInvalid(format!( + "profile does not grant entitlement {key:?}" + ))); + } + } + + Ok(()) + } + + fn extract_profile_data( + data: &[u8], + ) -> Result<(Dictionary, Date, Vec, Vec, Vec>), Error> { let start = data .windows(6) - .position(|w| w == b"") + .rposition(|window| window == b"") .ok_or(Error::ProvisioningEntitlementsUnknown)? + 8; - let plist_data = &data[start..end]; - let plist = plist::Value::from_reader_xml(plist_data)?; - - let expiration_date = plist + let plist = Value::from_reader_xml(&data[start..end])?; + let dictionary = plist .as_dictionary() - .and_then(|d| d.get("ExpirationDate")) - .and_then(|v| v.as_date()); + .ok_or(Error::ProvisioningEntitlementsUnknown)?; - let entitlements = plist - .as_dictionary() - .and_then(|d| d.get("Entitlements")) - .and_then(|v| v.as_dictionary()) + let entitlements = dictionary + .get("Entitlements") + .and_then(Value::as_dictionary) .cloned() - .ok_or(Error::ProvisioningEntitlementsUnknown); + .ok_or(Error::ProvisioningEntitlementsUnknown)?; + let expiration_date = dictionary + .get("ExpirationDate") + .and_then(Value::as_date) + .ok_or(Error::ProvisioningEntitlementsUnknown)?; + let platforms = string_values(dictionary.get("Platform")); + let provisioned_devices = string_values(dictionary.get("ProvisionedDevices")); + let developer_certificates = dictionary + .get("DeveloperCertificates") + .and_then(Value::as_array) + .map(|values| { + values + .iter() + .filter_map(Value::as_data) + .map(ToOwned::to_owned) + .collect() + }) + .unwrap_or_default(); Ok(( - entitlements?, - expiration_date.ok_or(Error::ProvisioningEntitlementsUnknown)?, + entitlements, + expiration_date, + platforms, + provisioned_devices, + developer_certificates, )) } } + +fn string_values(value: Option<&Value>) -> Vec { + match value { + Some(Value::String(value)) => vec![value.clone()], + Some(Value::Array(values)) => values + .iter() + .filter_map(Value::as_string) + .map(ToOwned::to_owned) + .collect(), + _ => Vec::new(), + } +} + +fn application_identifier_grants(granted: &str, requested: &str) -> bool { + let granted_bundle_id = application_identifier_bundle_id(granted).unwrap_or(granted); + + if granted_bundle_id == requested { + return true; + } + + if granted_bundle_id == "*" { + return true; + } + + granted_bundle_id + .strip_suffix(".*") + .is_some_and(|prefix| { + requested + .strip_prefix(prefix) + .is_some_and(|remainder| remainder.starts_with('.')) + }) +} + +fn application_identifier_bundle_id(value: &str) -> Option<&str> { + let (team, bundle_id) = value.split_once('.')?; + if team.len() == 10 + && team + .bytes() + .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit()) + && !bundle_id.is_empty() + { + Some(bundle_id) + } else { + None + } +} + +fn application_identifier_team(value: &str) -> Option<&str> { + let (team, _) = value.split_once('.')?; + if team.len() == 10 + && team + .bytes() + .all(|byte| byte.is_ascii_uppercase() || byte.is_ascii_digit()) + { + Some(team) + } else { + None + } +} + +fn value_grants(granted: &Value, requested: &Value) -> bool { + match (granted, requested) { + (Value::String(granted), Value::String(requested)) => wildcard_matches(granted, requested), + (Value::Array(granted), Value::Array(requested)) => requested + .iter() + .all(|requested| granted.iter().any(|granted| value_grants(granted, requested))), + (Value::Dictionary(granted), Value::Dictionary(requested)) => requested.iter().all( + |(key, requested)| { + granted + .get(key) + .is_some_and(|granted| value_grants(granted, requested)) + }, + ), + _ => granted == requested, + } +} + +fn wildcard_matches(granted: &str, requested: &str) -> bool { + if !granted.contains('*') { + return granted == requested; + } + + let mut remainder = requested; + let mut parts = granted.split('*'); + let Some(first) = parts.next() else { + return false; + }; + if !remainder.starts_with(first) { + return false; + } + remainder = &remainder[first.len()..]; + + let suffixes = parts.collect::>(); + for (index, part) in suffixes.iter().enumerate() { + if index == suffixes.len() - 1 { + return remainder.ends_with(part); + } + let Some(position) = remainder.find(part) else { + return false; + }; + remainder = &remainder[position + part.len()..]; + } + + true +} + +pub fn is_valid_device_udid(value: &str) -> bool { + let bytes = value.as_bytes(); + let is_hex = |part: &[u8]| part.iter().all(|byte| byte.is_ascii_hexdigit()); + + (bytes.len() == 40 && is_hex(bytes)) + || (bytes.len() == 25 + && bytes[8] == b'-' + && is_hex(&bytes[..8]) + && is_hex(&bytes[9..])) +} diff --git a/crates/plume_utils/Cargo.toml b/crates/plume_utils/Cargo.toml index 60ab60ee..2641a79e 100644 --- a/crates/plume_utils/Cargo.toml +++ b/crates/plume_utils/Cargo.toml @@ -9,9 +9,11 @@ repository.workspace = true [dependencies] idevice.workspace = true +base64.workspace = true thiserror.workspace = true uuid.workspace = true plist.workspace = true +serde.workspace = true tokio.workspace = true futures.workspace = true log.workspace = true @@ -22,3 +24,5 @@ flate2.workspace = true plume_core = { path = "../plume_core", features = ["tweaks"] } plume_store = { path = "../plume_store" } decompress = { path = "../../3rdparty/decompress" } + +mdns-sd = "0.11" diff --git a/crates/plume_utils/src/device.rs b/crates/plume_utils/src/device.rs index 793afbdf..7e8e03d3 100644 --- a/crates/plume_utils/src/device.rs +++ b/crates/plume_utils/src/device.rs @@ -1,29 +1,249 @@ use std::fmt; +use std::future::Future; use std::path::{Component, Path, PathBuf}; +use std::pin::Pin; +use std::time::Duration; +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; +use base64::{Engine as _, engine::general_purpose::STANDARD}; use idevice::core_device_proxy::CoreDeviceProxy; use idevice::installation_proxy::InstallationProxyClient; use idevice::lockdown::LockdownClient; use idevice::misagent::MisagentClient; use idevice::provider::UsbmuxdProvider; -use idevice::remote_pairing::{RemotePairingClient, RpPairingFile}; +use idevice::remote_pairing::{ + connect_tls_psk_tunnel_native, RemotePairingClient, RpPairingFile, RpPairingSocket, + RpPairingSocketProvider, +}; +use idevice::remote_pairing::errors::RemotePairingError; use idevice::rsd::RsdHandshake; +use idevice::tcp::adapter::Adapter; +use idevice::tcp::handle::AdapterHandle; use idevice::usbmuxd::{Connection, UsbmuxdAddr, UsbmuxdDevice}; use idevice::utils::installation; -use idevice::{IdeviceService, RemoteXpcClient}; -use plume_core::MobileProvision; +use idevice::{IdeviceService, RemoteXpcClient, RsdService}; +use plume_core::{MobileProvision, developer::DeveloperPlatform}; use crate::Error; +use crate::discovery::{DeviceDiscovery, DeviceType, PlatformDiscovery, REMOTEPAIRING_SERVICE}; use crate::options::SignerAppReal; +use crate::pairing::{PairingBackend, PairingFailure, PairingStage, ensure_pairing}; use idevice::afc::opcode::AfcFopenMode; use idevice::house_arrest::HouseArrestClient; use idevice::usbmuxd::UsbmuxdConnection; use plist::Value; +use serde::Serialize; pub const CONNECTION_LABEL: &str = "plume_info"; pub const INSTALLATION_LABEL: &str = "plume_install"; pub const HOUSE_ARREST_LABEL: &str = "plume_house_arrest"; +impl<'a, R: idevice::remote_pairing::RpPairingSocketProvider> PairingBackend + for RemotePairingClient<'a, R> +{ + async fn verify(&mut self) -> Result<(), PairingFailure> { + self.attempt_pair_verify() + .await + .map_err(|error| PairingFailure::Protocol(error.to_string()))?; + self.validate_pairing() + .await + .map_err(|error| PairingFailure::Protocol(error.to_string())) + } + + async fn pair(&mut self, pin: &str) -> Result<(), PairingFailure> { + let pin = pin.to_string(); + RemotePairingClient::connect( + self, + |_| { + let pin = pin.clone(); + async move { pin } + }, + (), + ) + .await + .map_err(|error| match error { + idevice::IdeviceError::RemotePairing(RemotePairingError::SrpAuthFailed) => { + PairingFailure::WrongPin + } + error => PairingFailure::Protocol(error.to_string()), + }) + } +} + +#[derive(Debug)] +struct SequencedTvosPairingSocket { + inner: RpPairingSocket, + sequence_offset: usize, +} + +impl SequencedTvosPairingSocket { + fn new(inner: RpPairingSocket, sequence_offset: usize) -> Self { + Self { + inner, + sequence_offset, + } + } +} + +impl RpPairingSocketProvider for SequencedTvosPairingSocket { + fn send_plain( + &mut self, + value: impl Serialize, + seq: usize, + ) -> Pin> + Send + '_>> { + self.inner.send_plain(value, seq + self.sequence_offset) + } + + fn send_encrypted( + &mut self, + ciphertext: Vec, + seq: usize, + ) -> Pin> + Send + '_>> { + self.inner + .send_encrypted(ciphertext, seq + self.sequence_offset) + } + + fn recv_plain<'a>( + &'a mut self, + ) -> Pin> + Send + 'a>> { + self.inner.recv_plain() + } + + fn serialize_bytes(b: &[u8]) -> plist::Value { + RpPairingSocket::::serialize_bytes(b) + } + + fn deserialize_bytes(v: plist::Value) -> Option> { + RpPairingSocket::::deserialize_bytes(v) + } +} + +struct TvosPairingBackend<'a> { + client: RemotePairingClient<'a, SequencedTvosPairingSocket>, +} + +impl<'a> PairingBackend for TvosPairingBackend<'a> { + async fn verify(&mut self) -> Result<(), PairingFailure> { + self.client + .attempt_pair_verify() + .await + .map_err(|error| PairingFailure::Protocol(error.to_string()))?; + self.client + .validate_pairing() + .await + .map_err(|error| PairingFailure::Protocol(error.to_string())) + } + + async fn pair(&mut self, pin: &str) -> Result<(), PairingFailure> { + let pin = pin.to_string(); + RemotePairingClient::pair( + &mut self.client, + |_| { + let pin = pin.clone(); + async move { pin } + }, + (), + ) + .await + .map_err(|error| match error { + idevice::IdeviceError::RemotePairing(RemotePairingError::SrpAuthFailed) => { + PairingFailure::WrongPin + } + error => PairingFailure::Protocol(error.to_string()), + }) + } +} + +const TVOS_RP_PAIRING_WIRE_PROTOCOL_VERSION: i64 = 26; + +async fn begin_tvos_pairing( + stream: tokio::net::TcpStream, +) -> Result { + let correlation_identifier: String = uuid::Uuid::new_v4() + .simple() + .to_string() + .chars() + .take(6) + .collect(); + let mut socket = RpPairingSocket::new(stream); + socket + .send_plain(tvos_pairing_handshake_request(&correlation_identifier), 0) + .await?; + let response = socket.recv_plain().await?; + if !tvos_pairing_handshake_allows_pair_setup(&response) { + return Err(Error::Other( + "Apple TV did not advertise support for manual pairing".to_string(), + )); + } + Ok(SequencedTvosPairingSocket::new(socket, 1)) +} + +fn tvos_pairing_handshake_request(correlation_identifier: &str) -> Value { + let mut host_options = plist::Dictionary::new(); + host_options.insert("attemptPairVerify".to_string(), Value::Boolean(false)); + + let mut correlation = plist::Dictionary::new(); + correlation.insert( + "value".to_string(), + Value::String(correlation_identifier.to_string()), + ); + + let mut handshake = plist::Dictionary::new(); + handshake.insert("hostOptions".to_string(), Value::Dictionary(host_options)); + handshake.insert( + "correlationIdentifier".to_string(), + Value::Dictionary(correlation), + ); + handshake.insert( + "wireProtocolVersion".to_string(), + Value::Integer(TVOS_RP_PAIRING_WIRE_PROTOCOL_VERSION.into()), + ); + + let mut handshake_container = plist::Dictionary::new(); + handshake_container.insert("_0".to_string(), Value::Dictionary(handshake)); + + let mut request = plist::Dictionary::new(); + request.insert( + "handshake".to_string(), + Value::Dictionary(handshake_container), + ); + + let mut request_container = plist::Dictionary::new(); + request_container.insert("_0".to_string(), Value::Dictionary(request)); + + let mut root = plist::Dictionary::new(); + root.insert("request".to_string(), Value::Dictionary(request_container)); + Value::Dictionary(root) +} + +fn tvos_pairing_handshake_allows_pair_setup(response: &Value) -> bool { + response + .as_dictionary() + .and_then(|value| value.get("response")) + .and_then(Value::as_dictionary) + .and_then(|value| value.get("_1")) + .and_then(Value::as_dictionary) + .and_then(|value| value.get("handshake")) + .and_then(Value::as_dictionary) + .and_then(|value| value.get("_0")) + .and_then(Value::as_dictionary) + .and_then(|value| value.get("deviceOptions")) + .and_then(Value::as_dictionary) + .and_then(|value| value.get("allowsPairSetup")) + .and_then(Value::as_boolean) + .unwrap_or(false) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeviceTransport { + Usbmuxd, + RemotePairing, + CoreDevice, + LocalMac, + Unavailable, +} + macro_rules! get_dict_string { ($dict:expr, $key:expr) => { $dict @@ -39,49 +259,279 @@ macro_rules! get_dict_string { pub struct Device { pub name: String, pub udid: String, + pub product_type: Option, + pub device_class: Option, + pub os_version: Option, + pub serial_number: Option, pub device_id: u32, pub usbmuxd_device: Option, // On x86_64 macs, `is_mac` variable should never be true // since its only true if the device is added manually. pub is_mac: bool, + pub pairing_address: Option<(std::net::IpAddr, u16)>, + pub reconnect_address: Option<(std::net::IpAddr, u16)>, + pub pairing_identity: Option, + pub pairing_cache_dir: Option, + pub core_device_authenticated: bool, +} + +#[derive(Debug, Clone, Default, PartialEq)] +pub struct TvosDeviceInfo { + pub name: Option, + pub udid: Option, + pub product_type: Option, + pub device_class: Option, + pub os_version: Option, + pub serial_number: Option, +} + +impl TvosDeviceInfo { + pub fn from_rsd_properties(props: &std::collections::HashMap) -> Self { + let as_string = |key: &str| -> Option { + props + .get(key) + .and_then(|v| v.as_string()) + .map(str::to_string) + }; + + TvosDeviceInfo { + name: as_string("DeviceName").or_else(|| as_string("Name")), + udid: as_string("UniqueDeviceID"), + product_type: as_string("ProductType"), + device_class: as_string("DeviceClass"), + os_version: as_string("OSVersion") + .or_else(|| as_string("HumanReadableProductVersionString")), + serial_number: as_string("SerialNumber"), + } + } +} + +#[derive(Debug, Clone)] +pub struct CoreDeviceTransport { + pairing_address: Option<(std::net::IpAddr, u16)>, + reconnect_address: Option<(std::net::IpAddr, u16)>, + pairing_identity: Option, + udid: String, + cache_dir: PathBuf, + authenticated: bool, +} + +impl CoreDeviceTransport { + fn new(device: &Device, cache_dir: PathBuf) -> Result { + if device.usbmuxd_device.is_some() { + return Err(Error::Other( + "CoreDevice transport cannot wrap a usbmuxd device".to_string(), + )); + } + Ok(Self { + pairing_address: device.pairing_address, + reconnect_address: device.reconnect_address, + pairing_identity: device.pairing_identity.clone(), + udid: device.udid.clone(), + cache_dir, + authenticated: device.core_device_authenticated, + }) + } + + pub fn kind(&self) -> DeviceTransport { + if self.authenticated { + DeviceTransport::CoreDevice + } else if self.pairing_address.is_some() || self.reconnect_address.is_some() { + DeviceTransport::RemotePairing + } else { + DeviceTransport::Unavailable + } + } + + pub async fn connect(&self) -> Result<(AdapterHandle, RsdHandshake), Error> { + establish_core_device_tunnel( + self.pairing_address, + self.reconnect_address, + self.pairing_identity.as_deref(), + &self.udid, + &self.cache_dir, + ) + .await + } +} + +pub fn synthetic_device_id(pairing_identity: &str) -> u32 { + const FNV_OFFSET_BASIS: u32 = 0x811c_9dc5; + const FNV_PRIME: u32 = 0x0100_0193; + + let mut hash = FNV_OFFSET_BASIS; + for byte in pairing_identity.as_bytes() { + hash ^= *byte as u32; + hash = hash.wrapping_mul(FNV_PRIME); + } + + hash |= 0x8000_0000; + + if hash == u32::MAX { + hash = 0x8000_0000; + } + + hash } impl Device { pub async fn new(usbmuxd_device: UsbmuxdDevice) -> Self { - let name = Self::get_name_from_usbmuxd_device(&usbmuxd_device) + let values = Self::get_values_from_usbmuxd_device(&usbmuxd_device) .await + .ok(); + let name = values + .as_ref() + .map(|values| get_dict_string!(values, "DeviceName")) .unwrap_or_default(); + let product_type = values + .as_ref() + .map(|values| get_dict_string!(values, "ProductType")) + .filter(|value| !value.is_empty()); + let device_class = values + .as_ref() + .map(|values| get_dict_string!(values, "DeviceClass")) + .filter(|value| !value.is_empty()); + let os_version = values + .as_ref() + .map(|values| get_dict_string!(values, "ProductVersion")) + .filter(|value| !value.is_empty()); + let serial_number = values + .as_ref() + .map(|values| get_dict_string!(values, "SerialNumber")) + .filter(|value| !value.is_empty()); Device { name, udid: usbmuxd_device.udid.clone(), + product_type, + device_class, + os_version, + serial_number, device_id: usbmuxd_device.device_id.clone(), usbmuxd_device: Some(usbmuxd_device), is_mac: false, + pairing_address: None, + reconnect_address: None, + pairing_identity: None, + pairing_cache_dir: None, + core_device_authenticated: false, } } - async fn get_name_from_usbmuxd_device(device: &UsbmuxdDevice) -> Result { + async fn get_values_from_usbmuxd_device( + device: &UsbmuxdDevice, + ) -> Result { let mut lockdown = LockdownClient::connect(&device.to_provider(UsbmuxdAddr::default(), CONNECTION_LABEL)) .await?; - let values = lockdown.get_value(None, None).await?; - Ok(get_dict_string!(values, "DeviceName")) + Ok(lockdown.get_value(None, None).await?) } - pub async fn installed_apps(&self) -> Result, Error> { - let device = match &self.usbmuxd_device { - Some(dev) => dev, - None => return Err(Error::Other("Device is not connected via USB".to_string())), - }; + pub fn new_tvos( + name: String, + pairing_identity: String, + ip: std::net::IpAddr, + pairing_port: Option, + reconnect_port: Option, + cache_dir: PathBuf, + ) -> Self { + Self::new_tvos_with_addresses( + name, + pairing_identity, + pairing_port.map(|port| (ip, port)), + reconnect_port.map(|port| (ip, port)), + cache_dir, + ) + } - let provider = device.to_provider( - UsbmuxdAddr::from_env_var().unwrap_or_default(), - INSTALLATION_LABEL, - ); + pub fn new_tvos_with_addresses( + name: String, + pairing_identity: String, + pairing_address: Option<(std::net::IpAddr, u16)>, + reconnect_address: Option<(std::net::IpAddr, u16)>, + cache_dir: PathBuf, + ) -> Self { + Device { + name, + udid: String::new(), + product_type: None, + device_class: Some("AppleTV".to_string()), + os_version: None, + serial_number: None, + device_id: 0, + usbmuxd_device: None, + is_mac: false, + pairing_address, + reconnect_address, + pairing_identity: Some(pairing_identity), + pairing_cache_dir: Some(cache_dir), + core_device_authenticated: false, + } + } + + pub(crate) fn pairing_cache_path(&self, cache_dir: &Path) -> Result { + pairing_cache_path_for(self.pairing_identity.as_deref(), &self.udid, cache_dir) + } + + pub fn is_tvos(&self) -> bool { + self.developer_platform() == DeveloperPlatform::Tvos + } + + pub fn developer_platform(&self) -> DeveloperPlatform { + DeveloperPlatform::from_device_metadata( + self.product_type.as_deref(), + self.device_class.as_deref(), + self.is_network() || self.pairing_identity.is_some(), + ) + } + + pub fn is_network(&self) -> bool { + matches!( + self.transport(), + DeviceTransport::RemotePairing | DeviceTransport::CoreDevice + ) + } + + pub fn transport(&self) -> DeviceTransport { + if self.usbmuxd_device.is_some() { + DeviceTransport::Usbmuxd + } else if self.core_device_authenticated { + DeviceTransport::CoreDevice + } else if self.pairing_address.is_some() || self.reconnect_address.is_some() { + DeviceTransport::RemotePairing + } else if self.is_mac { + DeviceTransport::LocalMac + } else { + DeviceTransport::Unavailable + } + } - let mut ic = InstallationProxyClient::connect(&provider).await?; - let apps = ic.get_apps(Some("User"), None).await?; + pub fn core_device_transport( + &self, + cache_dir: PathBuf, + ) -> Result { + CoreDeviceTransport::new(self, cache_dir) + } + + pub async fn installed_apps(&self) -> Result, Error> { + let apps = if let Some(device) = &self.usbmuxd_device { + let provider = device.to_provider( + UsbmuxdAddr::from_env_var().unwrap_or_default(), + INSTALLATION_LABEL, + ); + let mut ic = InstallationProxyClient::connect(&provider).await?; + ic.get_apps(Some("User"), None).await? + } else if self.is_network() { + let cache_dir = self.pairing_cache_dir.clone().ok_or_else(|| { + Error::Other("Network Apple TV has no pairing cache directory".to_string()) + })?; + let transport = self.core_device_transport(cache_dir)?; + let (mut adapter, mut handshake) = transport.connect().await?; + let mut ic = InstallationProxyClient::connect_rsd(&mut adapter, &mut handshake).await?; + ic.get_apps(Some("User"), None).await? + } else { + return Err(Error::Other("Device has no installation transport".to_string())); + }; let mut found_apps = Vec::new(); @@ -105,35 +555,48 @@ impl Device { } pub async fn is_app_installed(&self, bundle_id: &str) -> Result { - let device = match &self.usbmuxd_device { - Some(dev) => dev, - None => return Err(Error::Other("Device is not connected via USB".to_string())), + let apps = if let Some(device) = &self.usbmuxd_device { + let provider = device.to_provider( + UsbmuxdAddr::from_env_var().unwrap_or_default(), + INSTALLATION_LABEL, + ); + let mut ic = InstallationProxyClient::connect(&provider).await?; + ic.get_apps(Some("User"), None).await? + } else if self.is_network() { + let cache_dir = self.pairing_cache_dir.clone().ok_or_else(|| { + Error::Other("Network Apple TV has no pairing cache directory".to_string()) + })?; + let transport = self.core_device_transport(cache_dir)?; + let (mut adapter, mut handshake) = transport.connect().await?; + let mut ic = InstallationProxyClient::connect_rsd(&mut adapter, &mut handshake).await?; + ic.get_apps(Some("User"), None).await? + } else { + return Err(Error::Other("Device has no installation transport".to_string())); }; - let provider = device.to_provider( - UsbmuxdAddr::from_env_var().unwrap_or_default(), - INSTALLATION_LABEL, - ); - - let mut ic = InstallationProxyClient::connect(&provider).await?; - let apps = ic.get_apps(Some("User"), None).await?; - Ok(apps.contains_key(bundle_id)) } pub async fn install_profile(&self, profile: &MobileProvision) -> Result<(), Error> { - if self.usbmuxd_device.is_none() { - return Err(Error::Other("Device is not connected via USB".to_string())); + if let Some(device) = &self.usbmuxd_device { + let provider = device.to_provider( + UsbmuxdAddr::from_env_var().unwrap_or_default(), + INSTALLATION_LABEL, + ); + let mut mc = MisagentClient::connect(&provider).await?; + mc.install(profile.data.clone()).await?; + } else if self.is_network() { + let cache_dir = self.pairing_cache_dir.clone().ok_or_else(|| { + Error::Other("Network Apple TV has no pairing cache directory".to_string()) + })?; + let transport = self.core_device_transport(cache_dir)?; + let (mut adapter, mut handshake) = transport.connect().await?; + let mut mc = MisagentClient::connect_rsd(&mut adapter, &mut handshake).await?; + mc.install(profile.data.clone()).await?; + } else { + return Err(Error::Other("Device has no installation transport".to_string())); } - let provider = self.usbmuxd_device.clone().unwrap().to_provider( - UsbmuxdAddr::from_env_var().unwrap_or_default(), - INSTALLATION_LABEL, - ); - - let mut mc = MisagentClient::connect(&provider).await?; - mc.install(profile.data.clone()).await?; - Ok(()) } @@ -316,14 +779,350 @@ impl Device { .connect(async |_| "000000".to_string(), ()) .await?; - let pairing_file_bytes = pairing_file.to_bytes(); - - tokio::fs::write(&pairing_file_path, &pairing_file_bytes).await?; + write_pairing_file(&pairing_file, &path, &pairing_file_path).await?; Ok(pairing_file) } } + async fn try_import_external_pairing( + &self, + cache_dir: &Path, + cache_path: &Path, + ) -> Result, Error> { + try_import_external_pairing_at( + self.reconnect_address.or(self.pairing_address), + cache_dir, + cache_path, + ) + .await + } + + pub async fn pair_tvos( + &mut self, + pin_provider: F, + cache_dir: PathBuf, + ) -> Result + where + F: Fn() -> Fut, + Fut: std::future::Future, + { + let cache_path = self.pairing_cache_path(&cache_dir)?; + + let cached_pairing_file = if cache_path.exists() { + match RpPairingFile::read_from_file(&cache_path).await { + Ok(pairing_file) => Some(pairing_file), + Err(error) => { + log::warn!( + "Removing unreadable Apple TV pairing record at {}: {error}", + cache_path.display() + ); + let _ = tokio::fs::remove_file(&cache_path).await; + None + } + } + } else { + None + }; + + if cached_pairing_file.is_none() { + if let Some(pairing_file) = + self.try_import_external_pairing(&cache_dir, &cache_path).await? + { + return self.finish_tvos_pairing(pairing_file).await; + } + } + + let action = pairing_action( + cached_pairing_file.is_some(), + self.pairing_address.is_some(), + self.reconnect_address.is_some(), + ) + .map_err(Error::Other)?; + + if action == PairingAction::Reconnect { + let (ip, port) = self + .reconnect_address + .or(self.pairing_address) + .expect("pairing_action guarantees an address"); + let addr = std::net::SocketAddr::new(ip, port); + log::info!("tvOS pairing: reconnecting to {addr}"); + let stream = tokio::net::TcpStream::connect(addr).await.map_err(|e| { + Error::Other(format!( + "Failed to reconnect to Apple TV at {addr}: {e}; scan again if the device changed its advertised address" + )) + })?; + let mut pairing_file = cached_pairing_file + .expect("pairing_action selected reconnect with a cached pairing file"); + let sending_host = pairing_file.identifier.clone(); + let mut pairing_client = + RemotePairingClient::new(RpPairingSocket::new(stream), &sending_host, &mut pairing_file); + + let reconnect_result = match pairing_client.attempt_pair_verify().await { + Ok(_) => pairing_client.validate_pairing().await, + Err(error) => Err(error), + }; + drop(pairing_client); + + if reconnect_result.is_ok() { + write_pairing_file(&pairing_file, &cache_dir, &cache_path).await?; + log::info!("tvOS pairing: cached record reconnected without a PIN"); + return self.finish_tvos_pairing(pairing_file).await; + } + + if cache_path.exists() { + log::warn!( + "Cached Apple TV pairing record at {} is stale; removing it", + cache_path.display() + ); + let _ = tokio::fs::remove_file(&cache_path).await; + } + if let Some(pairing_file) = + self.try_import_external_pairing(&cache_dir, &cache_path).await? + { + log::info!("tvOS pairing: recovered an existing native pairing record"); + return self.finish_tvos_pairing(pairing_file).await; + } + } + + { + let (ip, port) = self.pairing_address.ok_or_else(|| { + Error::Other( + "Apple TV pairing requires its manual-pairing service. On the Apple TV, open Settings \ + > Remotes and Devices > Remote App and Devices and wait for \"Waiting to Pair...\", \ + then scan again." + .to_string(), + ) + })?; + + let addr = std::net::SocketAddr::new(ip, port); + log::info!("tvOS pairing: connecting to {addr}"); + let stream = tokio::net::TcpStream::connect(addr).await.map_err(|e| { + Error::Other(format!( + "Failed to connect to Apple TV at {addr}: {e}. The manual-pairing port changes \ + each time the Apple TV re-advertises, so a stale scan result will not connect - \ + scan again immediately before pairing." + )) + })?; + log::info!("tvOS pairing: TCP connected to {addr}, starting RPPairing handshake"); + + let local_hostname = local_remote_pairing_hostname().ok_or_else(|| { + Error::Other("Could not determine the host name for Apple TV pairing".to_string()) + })?; + let mut pairing_file = RpPairingFile::generate(&local_hostname); + let conn = begin_tvos_pairing(stream).await?; + let pairing_client = + RemotePairingClient::new(conn, &local_hostname, &mut pairing_file); + let mut pairing_backend = TvosPairingBackend { + client: pairing_client, + }; + let stage = ensure_pairing( + &mut pairing_backend, + false, + true, + false, + pin_provider, + ) + .await + .map_err(pairing_failure_to_error)?; + if stage != PairingStage::Paired { + return Err(Error::Other( + "Apple TV pairing did not complete a new pairing".to_string(), + )); + } + log::info!("tvOS pairing: handshake succeeded, caching pairing file"); + + write_pairing_file(&pairing_file, &cache_dir, &cache_path).await?; + + self.finish_tvos_pairing(pairing_file).await + } + } + + async fn finish_tvos_pairing( + &mut self, + pairing_file: RpPairingFile, + ) -> Result { + self.refresh_tvos_reconnect_address(Duration::from_secs(5)) + .await?; + Ok(pairing_file) + } + + pub async fn refresh_tvos_reconnect_address( + &mut self, + timeout: Duration, + ) -> Result<(), Error> { + let pairing_identity = self + .pairing_identity + .as_deref() + .filter(|identity| !identity.is_empty()) + .map(str::to_ascii_lowercase); + let known_ip = self + .pairing_address + .or(self.reconnect_address) + .map(|(ip, _)| ip); + + let discovered = PlatformDiscovery::new().discover(timeout).await?; + let candidate = discovered + .into_iter() + .filter(|device| { + device.device_type == DeviceType::AppleTV + && device.service_type == REMOTEPAIRING_SERVICE + }) + .filter_map(|device| { + let ip = device.ip_address.as_deref()?.parse().ok()?; + let port = device.port?; + let same_ip = known_ip == Some(ip); + let same_identity = pairing_identity.as_deref().is_some_and(|identity| { + device.hostname.eq_ignore_ascii_case(identity) + || device.name.eq_ignore_ascii_case(identity) + }); + let same_name = !self.name.is_empty() + && device.name.eq_ignore_ascii_case(&self.name); + if !(same_ip || same_identity || same_name) { + return None; + } + Some(((same_ip, same_identity, same_name), (ip, port))) + }) + .max_by_key(|(matches, _)| *matches) + .map(|(_, address)| address); + + if let Some(address) = candidate { + self.reconnect_address = Some(address); + log::info!( + "tvOS pairing: using verified reconnect service at {}:{}", + address.0, + address.1 + ); + return Ok(()); + } + + Err(Error::Other( + "Apple TV pairing succeeded, but its verified _remotepairing._tcp service was not found; scan again and retry" + .to_string(), + )) + } + + pub async fn establish_tvos_tunnel( + &self, + cache_dir: PathBuf, + ) -> Result<(AdapterHandle, RsdHandshake), Error> { + self.core_device_transport(cache_dir)?.connect().await + } + + pub fn has_cached_pairing_file(&self, cache_dir: &Path) -> bool { + self.pairing_cache_path(cache_dir) + .map(|path| path.exists()) + .unwrap_or(false) + } + + pub fn has_pairing_source(&self, cache_dir: &Path) -> bool { + if self.has_cached_pairing_file(cache_dir) { + return true; + } + + #[cfg(target_os = "macos")] + { + return self.has_native_tvos_pairing(); + } + + #[cfg(not(target_os = "macos"))] + false + } + + pub async fn forget_tvos_pairing(&self, cache_dir: PathBuf) -> Result<(), Error> { + let path = self.pairing_cache_path(&cache_dir)?; + + match tokio::fs::remove_file(&path).await { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } + } + + pub async fn fetch_tvos_info(&self, cache_dir: PathBuf) -> Result { + let (_adapter, handshake) = self.establish_tvos_tunnel(cache_dir).await?; + Ok(TvosDeviceInfo::from_rsd_properties(&handshake.properties)) + } + + #[cfg(target_os = "macos")] + fn has_native_tvos_pairing(&self) -> bool { + let expected_name = self.name.to_ascii_lowercase(); + let expected_product_type = self + .product_type + .as_deref() + .map(str::to_ascii_lowercase); + + for host_path in external_pairing_paths() { + let Some(peers_path) = host_path.parent().map(|path| path.join("peers")) else { + continue; + }; + let Ok(entries) = std::fs::read_dir(peers_path) else { + continue; + }; + for entry in entries.flatten() { + let path = entry.path(); + if path.extension().and_then(|extension| extension.to_str()) != Some("plist") { + continue; + } + let Ok(bytes) = std::fs::read(path) else { + continue; + }; + let Ok(peer) = plist::from_bytes::(&bytes) else { + continue; + }; + let model = peer + .get("model") + .and_then(Value::as_string) + .map(str::to_ascii_lowercase); + let name = peer + .get("name") + .and_then(Value::as_string) + .map(str::to_ascii_lowercase); + let model_matches = model.as_deref().is_some_and(|model| { + model.starts_with("appletv") + && expected_product_type + .as_deref() + .is_none_or(|expected| expected == model) + }); + let name_matches = name.as_deref() == Some(expected_name.as_str()); + if model_matches && name_matches { + return true; + } + } + } + + false + } + + pub fn apply_tvos_info(&mut self, info: &TvosDeviceInfo) { + if self.pairing_identity.is_none() { + return; + } + if let Some(name) = info.name.as_deref().filter(|value| !value.is_empty()) { + self.name = name.to_string(); + } + if let Some(udid) = info.udid.as_deref() { + if crate::is_valid_device_udid(udid) { + self.udid = udid.to_string(); + } + } + if info.product_type.is_some() { + self.product_type = info.product_type.clone(); + } + if info.device_class.is_some() { + self.device_class = info.device_class.clone(); + } + if info.os_version.is_some() { + self.os_version = info.os_version.clone(); + } + if info.serial_number.is_some() { + self.serial_number = info.serial_number.clone(); + } + if info.udid.as_deref().is_some_and(crate::is_valid_device_udid) { + self.core_device_authenticated = true; + } + } + pub async fn install_app( &self, app_path: &PathBuf, @@ -333,31 +1132,571 @@ impl Device { F: FnMut(i32) -> Fut + Send + Clone + 'static, Fut: std::future::Future + Send, { - if self.usbmuxd_device.is_none() { - return Err(Error::Other("Device is not connected via USB".to_string())); - } - - let provider = self.usbmuxd_device.clone().unwrap().to_provider( - UsbmuxdAddr::from_env_var().unwrap_or_default(), - INSTALLATION_LABEL, - ); - let callback = move |(progress, _): (u64, ())| { let mut cb = progress_callback.clone(); async move { cb(progress as i32).await; } }; - let state = (); - installation::install_package_with_callback(&provider, app_path, None, callback, state) + if self.usbmuxd_device.is_some() { + let provider = self.usbmuxd_device.clone().unwrap().to_provider( + UsbmuxdAddr::from_env_var().unwrap_or_default(), + INSTALLATION_LABEL, + ); + + installation::install_package_with_callback(&provider, app_path, None, callback, state) + .await?; + } else if self.is_network() { + let cache_dir = self.pairing_cache_dir.clone().ok_or_else(|| { + Error::Other( + "Network Apple TV has no pairing_cache_dir configured on this Device; \ + install_app has nowhere to look for its pairing file" + .to_string(), + ) + })?; + + let transport = self.core_device_transport(cache_dir)?; + let (mut adapter, mut handshake) = transport.connect().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(()) } } +fn pairing_cache_path_for( + pairing_identity: Option<&str>, + udid: &str, + cache_dir: &Path, +) -> Result { + let key = pairing_identity.unwrap_or(udid); + + if key.is_empty() { + return Err(Error::Other( + "Device has neither a pairing identity nor a UDID; cannot locate its pairing \ + file cache" + .to_string(), + )); + } + if key.contains('/') + || key.contains('\\') + || key.contains(':') + || key.chars().all(|c| c == '.') + { + return Err(Error::Other(format!( + "Pairing identity {key:?} is not a valid cache key" + ))); + } + + Ok(cache_dir.join(format!("plume_{key}.plist"))) +} + +async fn try_import_external_pairing_at( + address: Option<(std::net::IpAddr, u16)>, + cache_dir: &Path, + cache_path: &Path, +) -> Result, Error> { + let Some((ip, port)) = address else { + return Ok(None); + }; + let address = std::net::SocketAddr::new(ip, port); + + for (source, mut pairing_file) in external_pairing_candidates() { + let stream = match tokio::net::TcpStream::connect(address).await { + Ok(stream) => stream, + Err(error) => { + log::debug!( + "Could not connect to Apple TV while trying external pairing record {}: {error}", + source + ); + continue; + } + }; + let sending_host = pairing_file.identifier.clone(); + let mut client = RemotePairingClient::new( + RpPairingSocket::new(stream), + &sending_host, + &mut pairing_file, + ); + let valid = client.attempt_pair_verify().await.is_ok() + && client.validate_pairing().await.is_ok(); + drop(client); + + if valid { + write_pairing_file(&pairing_file, cache_dir, cache_path).await?; + log::info!( + "Imported an existing Apple TV pairing record from {}", + source + ); + return Ok(Some(pairing_file)); + } + } + + Ok(None) +} + +enum CoreDeviceTunnelAttemptError { + Pairing(Error), + Tunnel(Error), +} + +fn tvos_create_listener_request(encryption_key: &[u8]) -> Value { + let mut listener = plist::Dictionary::new(); + listener.insert( + "key".to_string(), + Value::String(STANDARD.encode(encryption_key)), + ); + + let mut peer_connection = plist::Dictionary::new(); + peer_connection.insert( + "owningPID".to_string(), + Value::Integer((std::process::id() as i64).into()), + ); + peer_connection.insert( + "owningProcessName".to_string(), + Value::String("CoreDeviceService".to_string()), + ); + listener.insert( + "peerConnectionsInfo".to_string(), + Value::Array(vec![Value::Dictionary(peer_connection)]), + ); + listener.insert( + "transportProtocolType".to_string(), + Value::String("tcp".to_string()), + ); + + let mut operation = plist::Dictionary::new(); + operation.insert("createListener".to_string(), Value::Dictionary(listener)); + + let mut request_body = plist::Dictionary::new(); + request_body.insert("_0".to_string(), Value::Dictionary(operation)); + + let mut request = plist::Dictionary::new(); + request.insert("request".to_string(), Value::Dictionary(request_body)); + Value::Dictionary(request) +} + +async fn create_tvos_tcp_listener( + rpc: &mut RemotePairingClient<'_, R>, +) -> Result { + let request = tvos_create_listener_request(rpc.encryption_key()); + let response = rpc.send_receive_encrypted_request(request).await?; + log::debug!("tvOS createListener response: {response:#?}"); + + let port = response + .as_dictionary() + .and_then(|value| value.get("createListener")) + .and_then(Value::as_dictionary) + .and_then(|value| value.get("port")) + .and_then(Value::as_unsigned_integer) + .filter(|port| *port <= u16::MAX as u64) + .ok_or_else(|| Error::Other("missing port in createListener response".to_string()))?; + + Ok(port as u16) +} + +async fn establish_core_device_tunnel( + pairing_address: Option<(std::net::IpAddr, u16)>, + reconnect_address: Option<(std::net::IpAddr, u16)>, + pairing_identity: Option<&str>, + udid: &str, + cache_dir: &Path, +) -> Result<(AdapterHandle, RsdHandshake), Error> { + let (ip, port) = reconnect_address.ok_or_else(|| { + if pairing_address.is_some() { + Error::Other( + "Apple TV has only its manual-pairing service; discover its verified _remotepairing._tcp service before opening a tunnel" + .to_string(), + ) + } else { + Error::Other("Device has no network address".to_string()) + } + })?; + + let connect_addr = std::net::SocketAddr::new(ip, port); + let cache_path = pairing_cache_path_for(pairing_identity, udid, cache_dir)?; + let (mut pairing_file, mut can_try_external) = if cache_path.exists() { + (RpPairingFile::read_from_file(&cache_path).await?, true) + } else { + ( + try_import_external_pairing_at(Some((ip, port)), cache_dir, &cache_path) + .await? + .ok_or_else(|| { + Error::Other( + "No pairing record is cached for this Apple TV; pair before reconnecting" + .to_string(), + ) + })?, + false, + ) + }; + + let tunnel = loop { + let stream = tokio::net::TcpStream::connect(connect_addr).await.map_err(|e| { + Error::Other(format!("Could not connect to Apple TV at {connect_addr}: {e}")) + })?; + let conn = RpPairingSocket::new(stream); + let hostname = pairing_file.identifier.clone(); + + let attempt: Result<_, CoreDeviceTunnelAttemptError> = async { + let mut rpc = RemotePairingClient::new(conn, &hostname, &mut pairing_file); + + if let Err(e) = rpc.attempt_pair_verify().await { + return Err(CoreDeviceTunnelAttemptError::Pairing(Error::Other( + format!("Pair-verify failed: {e}"), + ))); + } + + if let Err(e) = rpc.validate_pairing().await { + return Err(CoreDeviceTunnelAttemptError::Pairing(Error::Other( + format!( + "This Apple TV no longer recognizes this pairing (it may have been reset, \ + forgotten, or lost pairing after a system update); pair with it again: {e}" + ), + ))); + } + + let tunnel_port = create_tvos_tcp_listener(&mut rpc).await.map_err(|e| { + CoreDeviceTunnelAttemptError::Tunnel(Error::Other(format!( + "Failed to create tunnel listener: {e}" + ))) + })?; + + let tunnel_addr = std::net::SocketAddr::new(connect_addr.ip(), tunnel_port); + let tunnel_stream = tokio::net::TcpStream::connect(tunnel_addr) + .await + .map_err(|e| { + CoreDeviceTunnelAttemptError::Tunnel(Error::Other(format!( + "TLS tunnel connect failed: {e}" + ))) + })?; + + connect_tls_psk_tunnel_native(Box::new(tunnel_stream), rpc.encryption_key()) + .await + .map_err(|e| { + CoreDeviceTunnelAttemptError::Tunnel(Error::Other(format!( + "TLS-PSK tunnel handshake failed: {e}" + ))) + }) + } + .await; + + match attempt { + Ok(tunnel) => break tunnel, + Err(CoreDeviceTunnelAttemptError::Pairing(error)) if can_try_external => { + can_try_external = false; + log::warn!( + "tvOS tunnel: cached pairing file at {} is stale ({error}); removing it", + cache_path.display() + ); + let _ = tokio::fs::remove_file(&cache_path).await; + + if let Some(imported) = + try_import_external_pairing_at(Some((ip, port)), cache_dir, &cache_path) + .await? + { + pairing_file = imported; + continue; + } + + return Err(error); + } + Err(CoreDeviceTunnelAttemptError::Pairing(error)) + | Err(CoreDeviceTunnelAttemptError::Tunnel(error)) => return Err(error), + } + }; + + let client_ip: std::net::IpAddr = tunnel + .info + .client_address + .parse() + .map_err(|e| Error::Other(format!("Invalid tunnel client address: {e}")))?; + let server_ip: std::net::IpAddr = tunnel + .info + .server_address + .parse() + .map_err(|e| Error::Other(format!("Invalid tunnel server address: {e}")))?; + let rsd_port = tunnel.info.server_rsd_port; + let mtu = tunnel.info.mtu as usize; + let mss = mtu.saturating_sub(60); + log::info!("tvOS tunnel: negotiated MTU {mtu}, using MSS {mss}"); + + let raw = tunnel.into_inner(); + let mut adapter = Adapter::new(Box::new(raw), client_ip, server_ip); + adapter.set_mss(mss); + let mut adapter_handle = adapter.to_async_handle(); + + let rsd_stream = adapter_handle + .connect(rsd_port) + .await + .map_err(|e| Error::Other(format!("RSD connection failed: {e}")))?; + let handshake = RsdHandshake::new(rsd_stream) + .await + .map_err(|e| Error::Other(format!("RSD handshake failed: {e}")))?; + + Ok((adapter_handle, handshake)) +} + +async fn write_pairing_file( + pairing_file: &RpPairingFile, + cache_dir: &Path, + cache_path: &Path, +) -> Result<(), Error> { + tokio::fs::create_dir_all(cache_dir).await?; + + #[cfg(unix)] + tokio::fs::set_permissions( + cache_dir, + std::fs::Permissions::from_mode(0o700), + ) + .await?; + + let temporary_path = cache_path.with_extension("plist.tmp"); + tokio::fs::write(&temporary_path, pairing_file.to_bytes()).await?; + + #[cfg(unix)] + tokio::fs::set_permissions( + &temporary_path, + std::fs::Permissions::from_mode(0o600), + ) + .await?; + + tokio::fs::rename(&temporary_path, cache_path).await?; + + #[cfg(unix)] + tokio::fs::set_permissions(cache_path, std::fs::Permissions::from_mode(0o600)).await?; + + Ok(()) +} + +fn external_pairing_paths() -> Vec { + #[cfg(target_os = "macos")] + { + let mut paths = Vec::new(); + + let native_dir = Path::new("/var/db/lockdown/RemotePairing"); + if let Ok(entries) = std::fs::read_dir(native_dir) { + for entry in entries.flatten() { + let path = entry.path().join("selfIdentity.plist"); + if path.is_file() { + paths.push(path); + } + } + } + + paths.sort(); + paths.dedup(); + paths + } + + #[cfg(not(target_os = "macos"))] + { + Vec::new() + } +} + +fn external_pairing_candidates() -> Vec<(String, RpPairingFile)> { + let mut candidates = Vec::new(); + + for path in external_pairing_paths() { + let Ok(bytes) = std::fs::read(&path) else { + continue; + }; + + let peer_paths = path + .parent() + .map(|parent| parent.join("peers")) + .and_then(|directory| std::fs::read_dir(directory).ok()) + .into_iter() + .flatten() + .flatten() + .map(|entry| entry.path()) + .filter(|path| path.extension().and_then(|ext| ext.to_str()) == Some("plist")) + .collect::>(); + let peer_bytes = peer_paths + .iter() + .filter_map(|peer_path| std::fs::read(peer_path).ok()) + .collect::>(); + let peer_refs = peer_bytes.iter().map(Vec::as_slice).collect::>(); + + if let Ok(native_candidates) = native_pairing_candidates_from_bytes(&bytes, &peer_refs) { + for (index, candidate) in native_candidates.into_iter().enumerate() { + let source = if index == 0 { + path.display().to_string() + } else { + peer_paths + .get(index - 1) + .map(|peer_path| peer_path.display().to_string()) + .unwrap_or_else(|| path.display().to_string()) + }; + candidates.push((source, candidate)); + } + } + } + + candidates +} + +fn native_pairing_candidates_from_bytes( + host_bytes: &[u8], + peer_bytes: &[&[u8]], +) -> Result, Error> { + let mut host = external_pairing_file_from_bytes(host_bytes)?; + host.alt_irk = None; + + let mut candidates = vec![host.clone()]; + for bytes in peer_bytes { + let Ok(peer) = plist::from_bytes::(bytes) else { + continue; + }; + let Some(irk) = ["irk", "altIRK", "alt_irk"].iter().find_map(|key| { + peer.get(*key) + .and_then(plist::Value::as_data) + .filter(|data| data.len() == 16) + .map(|data| data.to_vec()) + }) else { + continue; + }; + + let mut candidate = host.clone(); + candidate.alt_irk = Some(irk); + candidates.push(candidate); + } + + Ok(candidates) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum PairingAction { + FirstPairing, + Reconnect, +} + +fn pairing_action( + has_cached_pairing: bool, + has_pairing_service: bool, + has_reconnect_service: bool, +) -> Result { + if has_cached_pairing && (has_reconnect_service || has_pairing_service) { + Ok(PairingAction::Reconnect) + } else if !has_cached_pairing && has_pairing_service { + Ok(PairingAction::FirstPairing) + } else if has_cached_pairing { + Err("Apple TV pairing record is stale and no pairing service is currently advertised".to_string()) + } else { + Err( + "Apple TV is not advertising its manual-pairing service; open Settings > Remotes and Devices > Remote App and Devices and wait for \"Waiting to Pair...\", then scan again".to_string(), + ) + } +} + +fn pairing_failure_to_error(failure: PairingFailure) -> Error { + match failure { + PairingFailure::Cancelled => Error::Other("Apple TV pairing was cancelled".to_string()), + PairingFailure::InvalidPin => { + Error::Other("Apple TV pairing PIN must contain exactly six digits".to_string()) + } + PairingFailure::WrongPin => Error::Other("Apple TV rejected the pairing PIN".to_string()), + PairingFailure::StaleRecord => Error::Other( + "This Apple TV pairing record is stale; open the Apple TV pairing screen and try again" + .to_string(), + ), + PairingFailure::ServiceDisappeared => Error::Other( + "Apple TV pairing service disappeared; scan again while the pairing screen is open" + .to_string(), + ), + PairingFailure::Protocol(message) => { + Error::Other(format!("RPPairing handshake failed: {message}")) + } + } +} + +fn local_remote_pairing_hostname() -> Option { + let hostname = std::env::var("HOSTNAME") + .ok() + .filter(|hostname| !hostname.trim().is_empty()) + .or_else(|| { + std::process::Command::new("hostname") + .output() + .ok() + .filter(|output| output.status.success()) + .and_then(|output| String::from_utf8(output.stdout).ok()) + .map(|hostname| hostname.trim().to_string()) + .filter(|hostname| !hostname.is_empty()) + })?; + + Some(hostname) +} + +fn local_remote_pairing_identifier() -> Option { + let hostname = local_remote_pairing_hostname()?; + Some( + uuid::Uuid::new_v3(&uuid::Uuid::NAMESPACE_DNS, hostname.as_bytes()) + .to_string() + .to_uppercase(), + ) +} + +fn external_pairing_file_from_bytes(bytes: &[u8]) -> Result { + let source: plist::Dictionary = plist::from_bytes(bytes)?; + let data_field = |names: &[&str]| { + names.iter().find_map(|name| { + source + .get(*name) + .and_then(plist::Value::as_data) + .map(|data| data.to_vec()) + }) + }; + let string_field = |names: &[&str]| { + names + .iter() + .find_map(|name| source.get(*name).and_then(plist::Value::as_string)) + .map(str::to_string) + }; + + let public_key = data_field(&["public_key", "publicKey"]) + .filter(|key| key.len() == 32) + .ok_or_else(|| Error::Other("External pairing record has no valid public key".to_string()))?; + let private_key = data_field(&["private_key", "privateKey"]) + .filter(|key| key.len() == 32) + .ok_or_else(|| Error::Other("External pairing record has no valid private key".to_string()))?; + let identifier = string_field(&["identifier", "host_identifier"]) + .or_else(local_remote_pairing_identifier) + .ok_or_else(|| Error::Other("External pairing record has no host identifier".to_string()))?; + + let mut normalized = plist::Dictionary::new(); + normalized.insert("public_key".to_string(), plist::Value::Data(public_key)); + normalized.insert("private_key".to_string(), plist::Value::Data(private_key)); + normalized.insert( + "identifier".to_string(), + plist::Value::String(identifier), + ); + if let Some(irk) = data_field(&["alt_irk", "irk", "host_alt_irk"]) { + if irk.len() == 16 { + normalized.insert("alt_irk".to_string(), plist::Value::Data(irk)); + } + } + + let mut normalized_bytes = Vec::new(); + plist::to_writer_xml(&mut normalized_bytes, &normalized)?; + Ok(RpPairingFile::from_bytes(&normalized_bytes)?) +} + fn get_app_name_from_info(info: &Value) -> Option { let dict = info.as_dictionary()?; dict.get("CFBundleDisplayName") @@ -372,9 +1711,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" + } else { match &self.usbmuxd_device { Some(device) => match &device.connection_type { Connection::Usb => "USB", @@ -382,9 +1721,17 @@ impl fmt::Display for Device { Connection::Unknown(_) => "Unknown", }, None => "LOCAL", - }, - self.name - ) + } + }; + let identity = if crate::is_valid_device_udid(&self.udid) { + format!(" [{}…{}]", &self.udid[..8], &self.udid[self.udid.len() - 4..]) + } else if self.is_network() { + " [unpaired]".to_string() + } else { + String::new() + }; + let platform = if self.is_tvos() { " (tvOS)" } else { "" }; + write!(f, "[{conn}] {}{platform}{identity}", self.name) } } @@ -394,7 +1741,9 @@ pub async fn get_device_for_id(device_id: &str) -> Result { .get_devices() .await? .into_iter() - .find(|d| d.device_id.to_string() == device_id) + .find(|d| { + d.device_id.to_string() == device_id || d.udid.eq_ignore_ascii_case(device_id) + }) .ok_or_else(|| Error::Other(format!("Device ID {device_id} not found")))?; Ok(Device::new(usbmuxd_device).await) diff --git a/crates/plume_utils/src/discovery/mdns.rs b/crates/plume_utils/src/discovery/mdns.rs new file mode 100644 index 00000000..fe60b131 --- /dev/null +++ b/crates/plume_utils/src/discovery/mdns.rs @@ -0,0 +1,149 @@ +use super::{ + ALL_SCANNED_SERVICE_TYPES, DeviceDiscovery, DiscoveredDevice, build_device, enrich_and_filter, + dedup_key, parse_instance_name, +}; +use mdns_sd::{ServiceDaemon, ServiceEvent}; +use std::collections::HashMap; +use std::time::Duration; + +pub use super::{ + APPLE_MOBDEV2_SERVICE, APPLE_PAIRABLE_SERVICE, REMOTEPAIRING_MANUAL_PAIRING_SERVICE, + REMOTEPAIRING_SERVICE, +}; + +pub struct MdnsDiscovery { + service_types: Vec, +} + +#[derive(Default)] +pub(crate) struct MdnsAccumulator { + devices: HashMap<(String, String), DiscoveredDevice>, +} + +impl MdnsAccumulator { + pub(crate) fn insert( + &mut self, + instance_name: &str, + hostname: &str, + service_type: &str, + device: DiscoveredDevice, + ) { + self.devices.insert( + dedup_key(hostname, instance_name, service_type), + device, + ); + } + + pub(crate) fn into_devices(self) -> Vec { + self.devices.into_values().collect() + } +} + +impl MdnsDiscovery { + pub fn new() -> Self { + Self { + service_types: ALL_SCANNED_SERVICE_TYPES + .iter() + .map(|s| s.to_string()) + .collect(), + } + } +} + +impl Default for MdnsDiscovery { + fn default() -> Self { + Self::new() + } +} + +impl DeviceDiscovery for MdnsDiscovery { + async fn discover(&self, timeout: Duration) -> crate::Result> { + let mdns = ServiceDaemon::new() + .map_err(|e| crate::Error::Other(format!("Failed to create mDNS daemon: {e}")))?; + + let mut receivers = Vec::new(); + for service_type in &self.service_types { + match mdns.browse(service_type) { + Ok(receiver) => receivers.push((service_type.clone(), receiver)), + Err(e) => { + log::warn!("Failed to browse {service_type}: {e}"); + } + } + } + + let service_types = self.service_types.clone(); + let discovered = tokio::task::spawn_blocking(move || { + let mut discovered_devices = MdnsAccumulator::default(); + let deadline = std::time::Instant::now() + timeout; + + while std::time::Instant::now() < deadline { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + let poll_time = remaining.min(Duration::from_millis(200)); + let mut got_event = false; + + for (service_type, receiver) in &receivers { + match receiver.recv_timeout(poll_time) { + Ok(ServiceEvent::ServiceResolved(info)) => { + got_event = true; + let properties: HashMap = info + .get_properties() + .iter() + .map(|p| (p.key().to_string(), p.val_str().to_string())) + .collect(); + + let hostname = info.get_hostname(); + let instance_name = + parse_instance_name(info.get_fullname(), service_type); + let addresses: Vec = + info.get_addresses().iter().copied().collect(); + let port = Some(info.get_port()); + + let device = build_device( + &instance_name, + hostname, + service_type, + port, + &addresses, + &properties, + ); + + log::debug!( + "mDNS resolved: hostname={} service={} ip={:?} port={:?}", + hostname, + service_type, + device.ip_address, + port + ); + + discovered_devices.insert( + &instance_name, + hostname, + service_type, + device, + ); + } + Ok(_) => { + got_event = true; + } + Err(_) => {} + } + } + + if !got_event && poll_time == remaining { + break; + } + } + + for stype in &service_types { + let _ = mdns.stop_browse(stype); + } + let _ = mdns.shutdown(); + + discovered_devices.into_devices() + }) + .await + .map_err(|e| crate::Error::Other(format!("mDNS scan task failed: {e}")))?; + + Ok(enrich_and_filter(discovered)) + } +} diff --git a/crates/plume_utils/src/discovery/mod.rs b/crates/plume_utils/src/discovery/mod.rs new file mode 100644 index 00000000..8fa45311 --- /dev/null +++ b/crates/plume_utils/src/discovery/mod.rs @@ -0,0 +1,406 @@ +pub mod mdns; + +use std::collections::{HashMap, HashSet}; +use std::net::IpAddr; +use std::path::Path; +use std::time::Duration; + +use crate::{Device, synthetic_device_id}; + +pub const REMOTEPAIRING_MANUAL_PAIRING_SERVICE: &str = "_remotepairing-manual-pairing._tcp.local."; +pub const REMOTEPAIRING_SERVICE: &str = "_remotepairing._tcp.local."; +pub const APPLE_MOBDEV2_SERVICE: &str = "_apple-mobdev2._tcp.local."; +pub const APPLE_PAIRABLE_SERVICE: &str = "_apple-pairable._tcp.local."; +pub const COMPANION_LINK_SERVICE: &str = "_companion-link._tcp.local."; + +pub const SERVICE_TYPES: [&str; 4] = [ + APPLE_MOBDEV2_SERVICE, + APPLE_PAIRABLE_SERVICE, + REMOTEPAIRING_SERVICE, + REMOTEPAIRING_MANUAL_PAIRING_SERVICE, +]; + +pub const METADATA_SERVICE_TYPES: [&str; 1] = [COMPANION_LINK_SERVICE]; + +pub const ALL_SCANNED_SERVICE_TYPES: [&str; 5] = [ + APPLE_MOBDEV2_SERVICE, + APPLE_PAIRABLE_SERVICE, + REMOTEPAIRING_SERVICE, + REMOTEPAIRING_MANUAL_PAIRING_SERVICE, + COMPANION_LINK_SERVICE, +]; + +#[derive(Debug, Clone, PartialEq)] +pub enum DeviceType { + IPhone, + IPad, + AppleTV, + AppleMac, + Unknown, +} + +impl DeviceType { + pub fn from_device_class(device_class: &str) -> Self { + match device_class { + "iPhone" => DeviceType::IPhone, + "iPad" => DeviceType::IPad, + "AppleTV" => DeviceType::AppleTV, + "Mac" => DeviceType::AppleMac, + _ => DeviceType::Unknown, + } + } + + pub fn from_product_type(product_type: &str) -> Self { + if product_type.starts_with("iPhone") { + DeviceType::IPhone + } else if product_type.starts_with("iPad") { + DeviceType::IPad + } else if product_type.starts_with("AppleTV") { + DeviceType::AppleTV + } else if product_type.starts_with("Mac") { + DeviceType::AppleMac + } else { + DeviceType::Unknown + } + } +} + +impl std::fmt::Display for DeviceType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + DeviceType::IPhone => write!(f, "iPhone"), + DeviceType::IPad => write!(f, "iPad"), + DeviceType::AppleTV => write!(f, "Apple TV"), + DeviceType::AppleMac => write!(f, "Mac"), + DeviceType::Unknown => write!(f, "Unknown"), + } + } +} + +#[derive(Debug, Clone, PartialEq)] +pub enum ConnectionType { + USB, + WiFi, +} + +impl std::fmt::Display for ConnectionType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + ConnectionType::USB => write!(f, "USB"), + ConnectionType::WiFi => write!(f, "WiFi"), + } + } +} + +#[derive(Debug, Clone)] +pub struct DiscoveredDevice { + pub name: String, + pub hostname: String, + pub udid: Option, + pub ip_address: Option, + pub port: Option, + pub device_type: DeviceType, + pub connection_type: ConnectionType, + pub is_paired: bool, + pub product_type: Option, + pub os_version: Option, + pub service_type: String, +} + + +pub(crate) fn ends_with_ignore_case(s: &str, suffix: &str) -> bool { + let (haystack, needle) = (s.as_bytes(), suffix.as_bytes()); + needle.len() <= haystack.len() + && haystack[haystack.len() - needle.len()..].eq_ignore_ascii_case(needle) +} + +pub(crate) fn parse_instance_name(full_name: &str, service_type: &str) -> String { + let full = full_name.trim_end_matches('.'); + let service = service_type.trim_end_matches('.'); + + if !service.is_empty() { + let suffix_len = service.len() + 1; + if full.len() > suffix_len && ends_with_ignore_case(full, service) { + let cut = full.len() - suffix_len; + if full.as_bytes()[cut] == b'.' { + return full[..cut].to_string(); + } + } + } + full.to_string() +} + +pub(crate) fn short_hostname(hostname: &str) -> &str { + let hostname = hostname.trim_end_matches('.'); + if ends_with_ignore_case(hostname, ".local") { + &hostname[..hostname.len() - ".local".len()] + } else { + hostname + } +} + +pub(crate) fn dedup_key( + hostname: &str, + instance_name: &str, + service_type: &str, +) -> (String, String) { + let host = short_hostname(hostname); + let base = if host.is_empty() { instance_name } else { host }; + (base.to_ascii_lowercase(), service_type.to_string()) +} + +pub(crate) fn first_non_empty<'a>( + props: &'a HashMap, + candidates: &[&str], +) -> Option<&'a str> { + candidates + .iter() + .filter_map(|k| props.get(*k)) + .map(|v| v.as_str()) + .find(|v| !v.is_empty()) +} + +pub(crate) fn build_device( + instance_name: &str, + hostname: &str, + service_type: &str, + port: Option, + addresses: &[IpAddr], + props: &HashMap, +) -> DiscoveredDevice { + let device_type = if let Some(class) = first_non_empty(props, &["DeviceClass", "deviceClass"]) { + DeviceType::from_device_class(class) + } else if let Some(model) = first_non_empty(props, &["ProductType", "model", "rpMd"]) { + DeviceType::from_product_type(model) + } else { + DeviceType::Unknown + }; + + let product_type = + first_non_empty(props, &["ProductType", "model", "rpMd"]).map(str::to_string); + let os_version = first_non_empty(props, &["OSVersion", "osVersion"]).map(str::to_string); + let udid = None; + + let name = { + let from_host = short_hostname(hostname).replace('-', " "); + if !from_host.is_empty() { + from_host + } else { + first_non_empty(props, &["name", "Name"]) + .map(str::to_string) + .unwrap_or_else(|| instance_name.to_string()) + } + }; + + DiscoveredDevice { + name, + hostname: short_hostname(hostname).to_string(), + udid, + ip_address: preferred_ip_address(addresses), + port, + device_type, + connection_type: ConnectionType::WiFi, + is_paired: service_type.contains("mobdev2"), + product_type, + os_version, + service_type: service_type.to_string(), + } +} + +fn preferred_ip_address(addresses: &[IpAddr]) -> Option { + addresses + .iter() + .filter(|address| address.is_ipv4()) + .min_by_key(|address| address.to_string()) + .or_else(|| addresses.iter().min_by_key(|address| address.to_string())) + .map(ToString::to_string) +} + +pub(crate) fn is_metadata_service(service_type: &str) -> bool { + METADATA_SERVICE_TYPES.contains(&service_type) +} + +fn device_correlation_key(device: &DiscoveredDevice) -> String { + if device.hostname.is_empty() { + device.name.to_ascii_lowercase() + } else { + device.hostname.to_ascii_lowercase() + } +} + +pub(crate) fn enrich_and_filter(devices: Vec) -> Vec { + let mut metadata: HashMap = HashMap::new(); + for d in &devices { + if !is_metadata_service(&d.service_type) { + continue; + } + let key = device_correlation_key(d); + let should_replace = match metadata.get(&key) { + Some(existing) => existing.device_type == DeviceType::Unknown, + None => true, + }; + if should_replace { + metadata.insert(key, d.clone()); + } + } + + devices + .into_iter() + .filter(|d| !is_metadata_service(&d.service_type)) + .map(|mut d| { + if let Some(meta) = metadata.get(&device_correlation_key(&d)) { + let agrees = d.device_type == DeviceType::Unknown + || meta.device_type == DeviceType::Unknown + || d.device_type == meta.device_type; + if agrees { + if d.device_type == DeviceType::Unknown { + d.device_type = meta.device_type.clone(); + } + if d.product_type.is_none() { + d.product_type = meta.product_type.clone(); + } + if d.os_version.is_none() { + d.os_version = meta.os_version.clone(); + } + } + } + d + }) + .collect() +} + +struct NetworkDeviceGroup { + name: String, + hostname: String, + pairing_address: Option<(IpAddr, u16)>, + reconnect_address: Option<(IpAddr, u16)>, + legacy_core_device: bool, +} + +pub fn group_network_devices(discovered: &[DiscoveredDevice], cache_dir: &Path) -> Vec { + let mut groups: HashMap = HashMap::new(); + + for d in discovered { + if d.device_type != DeviceType::AppleTV { + continue; + } + let is_remote_pairing = d.service_type == REMOTEPAIRING_SERVICE + || d.service_type == REMOTEPAIRING_MANUAL_PAIRING_SERVICE; + let is_core_device = d.service_type == APPLE_MOBDEV2_SERVICE; + if !is_remote_pairing && !is_core_device { + continue; + } + if d.name.is_empty() { + continue; + } + + let key = if d.hostname.is_empty() { + d.name.to_ascii_lowercase() + } else { + d.hostname.to_ascii_lowercase() + }; + let entry = groups.entry(key).or_insert_with(|| NetworkDeviceGroup { + name: d.name.clone(), + hostname: d.hostname.clone(), + pairing_address: None, + reconnect_address: None, + legacy_core_device: false, + }); + entry.legacy_core_device |= is_core_device; + + let address = d + .ip_address + .as_deref() + .and_then(|ip| ip.parse::().ok()) + .zip(d.port); + if d.service_type == REMOTEPAIRING_MANUAL_PAIRING_SERVICE { + if entry.pairing_address.is_none() { + entry.pairing_address = address; + } + } else if d.service_type == REMOTEPAIRING_SERVICE { + if entry.reconnect_address.is_none() { + entry.reconnect_address = address; + } + } + } + + let mut devices = Vec::with_capacity(groups.len()); + for group in groups.into_values() { + if group.pairing_address.is_none() + && group.reconnect_address.is_none() + && !group.legacy_core_device + { + continue; + } + let pairing_identity = if group.hostname.is_empty() { + group.name.replace(' ', "-") + } else { + group.hostname + }; + let id = synthetic_device_id(&pairing_identity); + + let mut device = Device::new_tvos_with_addresses( + group.name, + pairing_identity, + group.pairing_address, + group.reconnect_address, + cache_dir.to_path_buf(), + ); + device.device_id = id; + + devices.push(device); + } + + devices +} + +pub fn disconnected_after_missed_scans( + present_ids: &mut HashSet, + current_ids: &HashSet, + miss_counts: &mut HashMap, + required_misses: u32, +) -> Vec { + for id in current_ids { + miss_counts.remove(id); + } + + let missing = present_ids + .iter() + .copied() + .filter(|id| !current_ids.contains(id)) + .collect::>(); + let threshold = required_misses.max(1); + let mut disconnected = Vec::new(); + + for id in missing { + let misses = miss_counts.entry(id).or_insert(0); + *misses += 1; + if *misses >= threshold { + present_ids.remove(&id); + miss_counts.remove(&id); + disconnected.push(id); + } + } + + disconnected +} + +#[allow(async_fn_in_trait)] +pub trait DeviceDiscovery { + async fn discover(&self, timeout: Duration) -> crate::Result>; +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct PlatformDiscovery; + +impl PlatformDiscovery { + pub fn new() -> Self { + Self + } +} + +impl DeviceDiscovery for PlatformDiscovery { + async fn discover(&self, timeout: Duration) -> crate::Result> { + mdns::MdnsDiscovery::new().discover(timeout).await + } +} diff --git a/crates/plume_utils/src/lib.rs b/crates/plume_utils/src/lib.rs index 14fa36db..57f29687 100644 --- a/crates/plume_utils/src/lib.rs +++ b/crates/plume_utils/src/lib.rs @@ -1,15 +1,20 @@ mod bundle; mod cgbi; mod device; +pub mod discovery; mod options; mod package; +pub mod pairing; mod signer; mod tweak; use std::path::Path; - -pub use bundle::{Bundle, BundleType}; // Bundle helper -pub use device::{Device, get_device_for_id, install_app_mac}; // Device helper +use idevice::usbmuxd::Connection; +pub use bundle::{Bundle, BundleType}; +pub use device::{ + CoreDeviceTransport, Device, DeviceTransport, TvosDeviceInfo, get_device_for_id, install_app_mac, + synthetic_device_id, +}; pub use options::{ SignerApp, // Supported app types SignerAppReal, @@ -19,9 +24,12 @@ pub use options::{ SignerMode, // Signing mode SignerOptions, // Main }; -pub use package::Package; // Package helper -pub use signer::Signer; // Signer -pub use tweak::Tweak; // Tweak helper +pub use package::Package; +pub use pairing::{PairingBackend, PairingFailure, PairingStage, ensure_pairing}; +pub use signer::Signer; +pub use tweak::Tweak; + +pub type Result = std::result::Result; use thiserror::Error as ThisError; #[derive(Debug, ThisError)] @@ -68,7 +76,7 @@ pub trait PlistInfoTrait { fn get_build_version(&self) -> Option; } -pub async fn copy_dir_recursively(src: &Path, dst: &Path) -> Result<(), Error> { +pub async fn copy_dir_recursively(src: &Path, dst: &Path) -> Result<()> { use tokio::fs; fs::create_dir_all(dst).await?; @@ -95,3 +103,160 @@ pub async fn copy_dir_recursively(src: &Path, dst: &Path) -> Result<(), Error> { Ok(()) } + +pub use plume_core::is_valid_device_udid; + +fn dedup_keys_for_device(device: &Device) -> Vec { + let mut keys = Vec::new(); + if is_valid_device_udid(&device.udid) { + keys.push(format!("udid:{}", device.udid.to_ascii_lowercase())); + } + + if let Some(identity) = device + .pairing_identity + .as_deref() + .filter(|identity| !identity.is_empty()) + { + keys.push(format!("pairing:{}", identity.to_ascii_lowercase())); + } + if let Some(usbmuxd) = &device.usbmuxd_device { + keys.push(format!("mux:{}", usbmuxd.device_id)); + if let Connection::Network(ip) = &usbmuxd.connection_type { + keys.push(format!("network-ip:{ip}")); + } + } + for address in [device.pairing_address, device.reconnect_address] + .into_iter() + .flatten() + { + keys.push(format!("network-ip:{}", address.0)); + } + + keys +} + +fn device_quality(device: &Device) -> usize { + let mut score = 0; + if is_valid_device_udid(&device.udid) { + score += 100; + } + if device.pairing_identity.is_some() { + score += 40; + } + if device.usbmuxd_device.is_some() { + score += 20; + } + if device.core_device_authenticated { + score += 25; + } + score += device.product_type.is_some() as usize * 10; + score += device.device_class.is_some() as usize * 8; + score += device.os_version.is_some() as usize * 4; + score += device.serial_number.is_some() as usize * 4; + score += device.reconnect_address.is_some() as usize * 3; + score += device.pairing_address.is_some() as usize * 2; + score +} + +pub fn deduplicate_devices(devices: impl IntoIterator) -> Vec { + let mut groups: Vec<(Vec, Device)> = Vec::new(); + + for device in devices { + let keys = dedup_keys_for_device(&device); + let matching_indexes = groups + .iter() + .enumerate() + .filter(|(_, (group_keys, _))| keys.iter().any(|key| group_keys.contains(key))) + .map(|(index, _)| index) + .collect::>(); + + let Some(first_index) = matching_indexes.first().copied() else { + groups.push((keys, device)); + continue; + }; + + merge_devices(&mut groups[first_index].1, device); + groups[first_index].0 = dedup_keys_for_device(&groups[first_index].1); + + for index in matching_indexes.into_iter().skip(1).rev() { + let (_, other) = groups.remove(index); + merge_devices(&mut groups[first_index].1, other); + groups[first_index].0 = dedup_keys_for_device(&groups[first_index].1); + } + } + + groups.into_iter().map(|(_, device)| device).collect() +} + +fn merge_devices(existing: &mut Device, mut incoming: Device) { + if device_quality(&incoming) > device_quality(existing) { + std::mem::swap(existing, &mut incoming); + } + + if existing.name.is_empty() { + existing.name = incoming.name; + } + if !is_valid_device_udid(&existing.udid) && is_valid_device_udid(&incoming.udid) { + existing.udid = incoming.udid; + } + if existing.product_type.is_none() { + existing.product_type = incoming.product_type; + } + if existing.device_class.is_none() { + existing.device_class = incoming.device_class; + } + if existing.os_version.is_none() { + existing.os_version = incoming.os_version; + } + if existing.serial_number.is_none() { + existing.serial_number = incoming.serial_number; + } + let is_remote_tvos = existing.pairing_identity.is_some() + && (existing + .product_type + .as_deref() + .is_some_and(|value| value.starts_with("AppleTV")) + || existing + .device_class + .as_deref() + .is_some_and(|value| value.eq_ignore_ascii_case("AppleTV"))); + if existing.usbmuxd_device.is_none() && !is_remote_tvos { + existing.usbmuxd_device = incoming.usbmuxd_device; + } + if existing.pairing_address.is_none() { + existing.pairing_address = incoming.pairing_address; + } + if existing.reconnect_address.is_none() { + existing.reconnect_address = incoming.reconnect_address; + } + if existing.pairing_identity.is_none() { + existing.pairing_identity = incoming.pairing_identity; + } + if existing.pairing_cache_dir.is_none() { + existing.pairing_cache_dir = incoming.pairing_cache_dir; + } + if existing.device_id == 0 { + existing.device_id = incoming.device_id; + } + existing.is_mac |= incoming.is_mac; + existing.core_device_authenticated |= incoming.core_device_authenticated; + if is_remote_tvos { + existing.usbmuxd_device = None; + } +} + +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) + } +} diff --git a/crates/plume_utils/src/package.rs b/crates/plume_utils/src/package.rs index 37f61459..3fd9a3e4 100644 --- a/crates/plume_utils/src/package.rs +++ b/crates/plume_utils/src/package.rs @@ -1,7 +1,8 @@ use super::{Bundle, PlistInfoTrait}; use crate::{Error, SignerApp, SignerOptions, cgbi}; +use plume_core::MobileProvision; use plist::Dictionary; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::{env, fs, io::Read}; use uuid::Uuid; use zip::ZipArchive; @@ -176,12 +177,67 @@ impl Package { Ok(Bundle::new(app_dir)?) } - pub fn get_archive_based_on_path(&self, path: &PathBuf) -> Result { - if path.is_dir() { - self.clone().archive_package_bundle() - } else { - Ok(self.package_file.clone()) + pub fn get_archive_based_on_path(&self, _path: &PathBuf) -> Result { + self.clone().archive_package_bundle() + } + + pub fn validate_archive(path: &Path, require_profile: bool) -> Result<(), Error> { + let mut archive = ZipArchive::new(fs::File::open(path)?)?; + let app_prefix = (0..archive.len()) + .filter_map(|index| archive.by_index(index).ok().map(|entry| entry.name().to_string())) + .find(|entry| { + entry.starts_with("Payload/") + && entry.ends_with("/Info.plist") + && entry.matches('/').count() == 2 + }) + .map(|entry| entry.trim_end_matches("/Info.plist").to_string()) + .ok_or_else(|| Error::Other("Produced IPA has no application bundle".to_string()))?; + + let signature = format!("{app_prefix}/_CodeSignature/CodeResources"); + let mut signature_data = Vec::new(); + let signature_valid = archive + .by_name(&signature) + .map_err(|error| Error::Other(format!("Unable to read produced IPA signature: {error}"))) + .and_then(|mut entry| { + entry + .read_to_end(&mut signature_data) + .map_err(|error| Error::Other(format!("Unable to read produced IPA signature: {error}"))) + }) + .is_ok() + && !signature_data.is_empty(); + if !signature_valid { + return Err(Error::Other( + "Produced IPA has no application code signature".to_string(), + )); } + + if require_profile { + let profile = format!("{app_prefix}/embedded.mobileprovision"); + let mut profile_data = Vec::new(); + let profile_valid = archive + .by_name(&profile) + .map_err(|error| Error::Other(format!("Unable to read produced IPA profile: {error}"))) + .and_then(|mut entry| { + entry + .read_to_end(&mut profile_data) + .map_err(|error| Error::Other(format!("Unable to read produced IPA profile: {error}"))) + }) + .is_ok() + && !profile_data.is_empty(); + if !profile_valid { + return Err(Error::Other( + "Produced IPA has no embedded provisioning profile".to_string(), + )); + } + MobileProvision::load_with_bytes(profile_data) + .map_err(|error| { + Error::Other(format!( + "Produced IPA has an invalid provisioning profile: {error}" + )) + })?; + } + + Ok(()) } fn archive_package_bundle(self) -> Result { @@ -204,8 +260,10 @@ impl Package { let name = entry_path .strip_prefix(prefix) .map_err(|_| Error::PackageInfoPlistMissing)? - .to_string_lossy() - .to_string(); + .components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>() + .join("/"); if entry_path.is_file() { zip.start_file(&name, options.clone())?; diff --git a/crates/plume_utils/src/pairing.rs b/crates/plume_utils/src/pairing.rs new file mode 100644 index 00000000..7a3d06c4 --- /dev/null +++ b/crates/plume_utils/src/pairing.rs @@ -0,0 +1,58 @@ +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PairingFailure { + Cancelled, + InvalidPin, + WrongPin, + StaleRecord, + ServiceDisappeared, + Protocol(String), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PairingStage { + Reconnected, + Paired, +} + +#[allow(async_fn_in_trait)] +pub trait PairingBackend { + async fn verify(&mut self) -> Result<(), PairingFailure>; + async fn pair(&mut self, pin: &str) -> Result<(), PairingFailure>; +} + +pub async fn ensure_pairing( + backend: &mut B, + has_cached_record: bool, + has_pairing_service: bool, + has_reconnect_service: bool, + pin_provider: F, +) -> Result +where + B: PairingBackend, + F: FnOnce() -> Fut, + Fut: std::future::Future, +{ + if has_cached_record && (has_reconnect_service || has_pairing_service) { + if backend.verify().await.is_ok() { + return Ok(PairingStage::Reconnected); + } + if !has_pairing_service { + return Err(PairingFailure::StaleRecord); + } + } + + if !has_pairing_service { + return Err(PairingFailure::ServiceDisappeared); + } + + let pin = pin_provider().await; + if pin.is_empty() { + return Err(PairingFailure::Cancelled); + } + if pin.len() != 6 || !pin.bytes().all(|byte| byte.is_ascii_digit()) { + return Err(PairingFailure::InvalidPin); + } + + backend.pair(&pin).await?; + Ok(PairingStage::Paired) +} diff --git a/crates/plume_utils/src/signer.rs b/crates/plume_utils/src/signer.rs index 5278d786..af0e43f5 100644 --- a/crates/plume_utils/src/signer.rs +++ b/crates/plume_utils/src/signer.rs @@ -6,7 +6,7 @@ use tokio::fs; use plume_core::{ CertificateIdentity, MobileProvision, SettingsScope, SigningSettings, UnifiedSigner, - developer::DeveloperSession, + developer::{DeveloperPlatform, DeveloperSession}, }; use crate::{Bundle, BundleType, Error, PlistInfoTrait, SignerApp, SignerMode, SignerOptions}; @@ -228,6 +228,20 @@ impl Signer { session: &DeveloperSession, team_id: &String, is_refresh: bool, + platform: DeveloperPlatform, + ) -> Result<(), Error> { + self.register_bundle_for_device(bundle, session, team_id, is_refresh, platform, None) + .await + } + + pub async fn register_bundle_for_device( + &mut self, + bundle: &Bundle, + session: &DeveloperSession, + team_id: &String, + is_refresh: bool, + platform: DeveloperPlatform, + device_udid: Option<&str>, ) -> Result<(), Error> { if self.options.mode != SignerMode::Pem { return Ok(()); @@ -243,6 +257,12 @@ impl Signer { let bundle_arc = Arc::new(bundle.clone()); let session_arc = Arc::new(session); let team_id_arc = Arc::new(team_id.clone()); + let device_udid = device_udid.map(str::to_owned); + let certificate_der = self + .certificate + .as_ref() + .and_then(CertificateIdentity::certificate_der) + .map(ToOwned::to_owned); let futures = bundles.iter().filter_map(|sub_bundle| { let sub_bundle = sub_bundle.clone(); @@ -250,8 +270,11 @@ impl Signer { let session = session_arc.clone(); let team_id = team_id_arc.clone(); let signer_settings = signer_settings.clone(); + let device_udid = device_udid.clone(); + let certificate_der = certificate_der.clone(); if signer_settings.embedding.single_profile + && platform != DeveloperPlatform::Tvos && sub_bundle.bundle_dir() != bundle.bundle_dir() { return None; @@ -276,16 +299,23 @@ 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()))?; if let Some(e) = macho.entitlements().as_ref() { session - .v1_request_capabilities_for_entitlements(&team_id, &id, e) + .v1_request_capabilities_for_entitlements_on_platform( + &team_id, + &id, + e, + platform, + ) .await?; } @@ -337,17 +367,45 @@ impl Signer { } let profiles = session - .qh_get_profile(&team_id, &app_id_id.app_id_id) + .qh_get_profile(&team_id, &app_id_id.app_id_id, platform) .await?; - let profile_data = profiles.provisioning_profile.encoded_profile; + let mut mobile_provision = MobileProvision::load_with_bytes( + profiles.provisioning_profile.encoded_profile.as_ref().to_vec(), + )?; + let requested_entitlements = macho.entitlements().as_ref(); + if let Err(error) = mobile_provision.validate_for( + platform, + &id, + device_udid.as_deref(), + certificate_der.as_deref(), + requested_entitlements, + ) { + log::warn!( + "Cached or newly returned profile for {id} failed validation: {error}; requesting a replacement" + ); + let refreshed = session + .qh_get_profile(&team_id, &app_id_id.app_id_id, platform) + .await?; + mobile_provision = MobileProvision::load_with_bytes( + refreshed.provisioning_profile.encoded_profile.as_ref().to_vec(), + )?; + mobile_provision.validate_for( + platform, + &id, + device_udid.as_deref(), + certificate_der.as_deref(), + requested_entitlements, + ) + .map_err(|replacement_error| { + Error::Core(replacement_error) + })?; + } tokio::fs::write( sub_bundle.bundle_dir().join("embedded.mobileprovision"), - &profile_data, + &mobile_provision.data, ) .await?; - let mobile_provision = - MobileProvision::load_with_bytes(profile_data.as_ref().to_vec())?; Ok::<_, Error>(mobile_provision) }) }); @@ -358,7 +416,206 @@ impl Signer { Ok(()) } + pub fn validate_signed_bundle( + &self, + bundle: &Bundle, + platform: DeveloperPlatform, + device_udid: Option<&str>, + ) -> Result<(), Error> { + if self.options.mode == SignerMode::None { + return Ok(()); + } + + let certificate_der = self + .certificate + .as_ref() + .and_then(CertificateIdentity::certificate_der); + + for signed_bundle in bundle.collect_bundles_sorted()? { + if *signed_bundle.bundle_type() == BundleType::Unknown { + continue; + } + + let executable = if *signed_bundle.bundle_type() == BundleType::Dylib { + signed_bundle.bundle_dir().clone() + } else { + let executable_name = signed_bundle + .get_executable() + .ok_or_else(|| Error::Other("Signed bundle has no executable".to_string()))?; + signed_bundle.bundle_dir().join(executable_name) + }; + let macho = plume_core::MachO::new(&executable)?; + let has_code_signature = macho + .macho_file() + .nth_macho(0)? + .code_signature()? + .is_some(); + if !has_code_signature { + return Err(Error::Other(format!( + "Signed bundle {} has no code signature", + signed_bundle.bundle_dir().display() + ))); + } + + if self.options.mode != SignerMode::Adhoc { + let verification_problems = + plume_core::verify_macho_data(std::fs::read(&executable)?); + if let Some(problem) = verification_problems.first() { + return Err(Error::Other(format!( + "Signature verification failed for {}: {problem}", + signed_bundle.bundle_dir().display() + ))); + } + } + + if !signed_bundle.bundle_type().should_have_entitlements() { + continue; + } + + let bundle_id = signed_bundle + .get_bundle_identifier() + .ok_or_else(|| Error::Other("Signed bundle has no bundle identifier".to_string()))?; + let profile_path = signed_bundle.bundle_dir().join("embedded.mobileprovision"); + let profile = MobileProvision::load_with_path(profile_path)?; + let final_entitlements = macho.entitlements().clone().ok_or_else(|| { + Error::Core(plume_core::Error::ProvisioningProfileInvalid( + "signed executable has no entitlements".to_string(), + )) + })?; + profile.validate_final_entitlements( + platform, + &bundle_id, + device_udid, + certificate_der, + &final_entitlements, + )?; + } + + Ok(()) + } + pub async fn sign_bundle(&self, bundle: &Bundle) -> Result<(), Error> { + self.sign_bundle_for_device(bundle, DeveloperPlatform::Ios, None) + .await + } + + pub async fn sign_bundle_for_device( + &self, + bundle: &Bundle, + platform: DeveloperPlatform, + device_udid: Option<&str>, + ) -> Result<(), Error> { + self.validate_provisioning_files(bundle, platform, device_udid)?; + self.sign_bundle_unchecked(bundle, platform, device_udid).await + } + + pub fn validate_provisioning_files( + &self, + bundle: &Bundle, + platform: DeveloperPlatform, + device_udid: Option<&str>, + ) -> Result<(), Error> { + if self.options.mode != SignerMode::Pem { + return Ok(()); + } + + let certificate_der = self + .certificate + .as_ref() + .and_then(CertificateIdentity::certificate_der); + let bundles = bundle + .collect_bundles_sorted()? + .into_iter() + .filter(|candidate| candidate.bundle_type().should_have_entitlements()) + .collect::>(); + + if bundles.is_empty() { + return Err(Error::Core( + plume_core::Error::ProvisioningProfileInvalid( + "no signable app or extension bundles were found".to_string(), + ), + )); + } + if self.provisioning_files.is_empty() { + return Err(Error::Core( + plume_core::Error::ProvisioningProfileInvalid( + "no provisioning profiles are available".to_string(), + ), + )); + } + + for signed_bundle in bundles { + let bundle_id = signed_bundle + .get_bundle_identifier() + .ok_or_else(|| Error::Other("Signable bundle has no bundle identifier".into()))?; + let executable_name = signed_bundle + .get_executable() + .ok_or_else(|| Error::Other("Signable bundle has no executable".into()))?; + let binary_path = signed_bundle.bundle_dir().join(executable_name); + let macho = plume_core::MachO::new(&binary_path)?; + let mut last_error = None; + + let matching_profile = self.provisioning_files.iter().find(|profile| { + match profile.validate_for( + platform, + &bundle_id, + device_udid, + certificate_der, + macho.entitlements().as_ref(), + ) { + Ok(()) => true, + Err(error) => { + last_error = Some(error); + false + } + } + }); + + let Some(matching_profile) = matching_profile else { + let error = last_error.unwrap_or_else(|| { + plume_core::Error::ProvisioningProfileInvalid(format!( + "no profile grants {bundle_id}" + )) + }); + return Err(Error::Core(error)); + }; + + let mut effective_profile = matching_profile.clone(); + effective_profile.merge_entitlements(binary_path.clone(), &bundle_id)?; + let final_entitlements = if self.options.embedding.single_profile { + self.options + .custom_entitlements + .as_ref() + .map(|path| { + let value = Value::from_file(path)?; + value.as_dictionary().cloned().ok_or_else(|| { + Error::Other("Custom entitlements file is not a dictionary".to_string()) + }) + }) + .transpose()? + .unwrap_or_else(|| effective_profile.entitlements().clone()) + } else { + effective_profile.entitlements().clone() + }; + effective_profile.validate_final_entitlements( + platform, + &bundle_id, + device_udid, + certificate_der, + &final_entitlements, + )?; + log::info!("ProfileValidated: true for {bundle_id} on {platform}"); + } + + Ok(()) + } + + async fn sign_bundle_unchecked( + &self, + bundle: &Bundle, + platform: DeveloperPlatform, + device_udid: Option<&str>, + ) -> Result<(), Error> { if self.options.mode == SignerMode::None { return Ok(()); } @@ -381,6 +638,8 @@ impl Signer { &self.provisioning_files, settings.clone(), &entitlements_xml, + platform, + device_udid, )?; } @@ -399,12 +658,23 @@ impl Signer { provisioning_files: &[MobileProvision], mut settings: SigningSettings<'_>, entitlements_xml: &String, + platform: DeveloperPlatform, + device_udid: Option<&str>, ) -> Result<(), Error> { if *bundle.bundle_type() == BundleType::Unknown { return Ok(()); } let mut entitlements_xml = entitlements_xml.clone(); + let bundle_id = bundle.get_bundle_identifier(); + let binary_path = bundle + .get_executable() + .map(|executable| bundle.bundle_dir().join(executable)); + let requested_entitlements = binary_path + .as_ref() + .map(plume_core::MachO::new) + .transpose()? + .and_then(|macho| macho.entitlements().clone()); // Only Apps and AppExtensions should have entitlements from provisioning profiles // Dylibs, frameworks, and other components should be signed without entitlements @@ -413,27 +683,26 @@ impl Signer { && bundle.bundle_type().should_have_entitlements() && !provisioning_files.is_empty() { - let mut matched_prov = None; - - for prov in provisioning_files { - if let (Some(bundle_id), Some(team_id)) = - (bundle.get_bundle_identifier(), prov.bundle_id()) - { - if team_id == bundle_id { - matched_prov = Some(prov); - break; - } - } - } + let matched_prov = bundle_id.as_deref().and_then(|bundle_id| { + provisioning_files.iter().find(|prov| { + prov.validate_for( + platform, + bundle_id, + device_udid, + self.certificate + .as_ref() + .and_then(CertificateIdentity::certificate_der), + requested_entitlements.as_ref(), + ) + .is_ok() + }) + }); if let Some(prov) = matched_prov.or_else(|| provisioning_files.first()) { let mut prov = prov.clone(); - if let Some(bundle_executable) = bundle.get_executable() { - if let Some(bundle_id) = bundle.get_bundle_identifier() { - let binary_path = bundle.bundle_dir().join(bundle_executable); - prov.merge_entitlements(binary_path, &bundle_id).ok(); - } + if let (Some(binary_path), Some(bundle_id)) = (&binary_path, &bundle_id) { + prov.merge_entitlements(binary_path.clone(), bundle_id)?; } std::fs::write( @@ -441,9 +710,8 @@ impl Signer { &prov.data, )?; - if let Ok(ent_xml) = prov.entitlements_as_bytes() { - entitlements_xml = String::from_utf8_lossy(&ent_xml).to_string(); - } + let ent_xml = prov.entitlements_as_bytes()?; + entitlements_xml = String::from_utf8_lossy(&ent_xml).to_string(); } } 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"