From 6bb6722ee06e5e219fc1b49db8aa6ad2100173b3 Mon Sep 17 00:00:00 2001 From: Dave Nicolson Date: Sat, 26 Sep 2026 18:45:41 +0200 Subject: [PATCH 1/2] Add tvOS support --- isideload/src/dev/developer_session.rs | 10 ++++++++- isideload/src/dev/device_type.rs | 28 ++++++++++++++++++++++++-- isideload/src/sideload/sideloader.rs | 3 +++ isideload/src/util/device.rs | 19 +++++++++++++++-- 4 files changed, 55 insertions(+), 5 deletions(-) diff --git a/isideload/src/dev/developer_session.rs b/isideload/src/dev/developer_session.rs index d53bb24..b33a300 100644 --- a/isideload/src/dev/developer_session.rs +++ b/isideload/src/dev/developer_session.rs @@ -31,6 +31,7 @@ pub struct DeveloperSession { adsid: String, client: Arc, anisette_generator: AnisetteDataGenerator, + device_type: DeveloperDeviceType, } impl DeveloperSession { @@ -45,6 +46,7 @@ impl DeveloperSession { adsid, client, anisette_generator, + device_type: DeveloperDeviceType::Ios, } } @@ -67,6 +69,11 @@ impl DeveloperSession { )) } + /// Set the platform selected for subsequent developer service requests. + pub fn set_device_type(&mut self, device_type: DeveloperDeviceType) { + self.device_type = device_type; + } + pub async fn get_headers(&mut self) -> Result { let mut headers = self .anisette_generator @@ -101,7 +108,8 @@ impl DeveloperSession { "userLocale": ["en_US"], }); - let body = base.into_iter().chain(body.into_iter()).collect(); + let mut body = base.into_iter().chain(body.into_iter()).collect(); + self.device_type.apply_platform_fields(&mut body); let text = self .client diff --git a/isideload/src/dev/device_type.rs b/isideload/src/dev/device_type.rs index d2a1001..61dc44d 100644 --- a/isideload/src/dev/device_type.rs +++ b/isideload/src/dev/device_type.rs @@ -1,4 +1,6 @@ -#[derive(Debug, Clone)] +use plist::{Dictionary, Value}; + +#[derive(Debug, Clone, PartialEq, Eq)] pub enum DeveloperDeviceType { Any, Ios, @@ -11,10 +13,32 @@ impl DeveloperDeviceType { match self { DeveloperDeviceType::Any => "", DeveloperDeviceType::Ios => "ios/", - DeveloperDeviceType::Tvos => "tvos/", + // tvOS is served by the iOS service path and selects itself with the + // `subPlatform` request field instead. + DeveloperDeviceType::Tvos => "ios/", DeveloperDeviceType::Watchos => "watchos/", } } + + /// Add the request fields that select this platform. + pub fn apply_platform_fields(&self, body: &mut Dictionary) { + if let DeveloperDeviceType::Tvos = self { + body.insert("subPlatform".into(), Value::String("tvOS".into())); + } + } +} + +impl From> for DeveloperDeviceType { + /// Map a lockdown `ProductType` (e.g. `AppleTV6,2`) to a developer device type. + /// + /// A missing `ProductType` is treated as iOS. + fn from(product_type: Option<&str>) -> Self { + match product_type { + Some(product_type) if product_type.starts_with("AppleTV") => Self::Tvos, + Some(product_type) if product_type.starts_with("Watch") => Self::Watchos, + _ => Self::Ios, + } + } } pub fn dev_url(endpoint: &str, device_type: impl Into>) -> String { diff --git a/isideload/src/sideload/sideloader.rs b/isideload/src/sideload/sideloader.rs index 37c4edc..9e3a1fc 100644 --- a/isideload/src/sideload/sideloader.rs +++ b/isideload/src/sideload/sideloader.rs @@ -247,6 +247,9 @@ impl Sideloader { { let device_info = IdeviceInfo::from_device(device_provider).await?; + self.dev_session + .set_device_type(device_info.product_type.as_deref().into()); + let team = self.get_team().await?; self.dev_session .ensure_device_registered(&team, &device_info.name, &device_info.udid, None) diff --git a/isideload/src/util/device.rs b/isideload/src/util/device.rs index 2fbcce9..a61d689 100644 --- a/isideload/src/util/device.rs +++ b/isideload/src/util/device.rs @@ -4,11 +4,16 @@ use rootcause::prelude::*; pub struct IdeviceInfo { pub name: String, pub udid: String, + pub product_type: Option, } impl IdeviceInfo { pub fn new(name: String, udid: String) -> Self { - Self { name, udid } + Self { + name, + udid, + product_type: None, + } } pub async fn from_device(device: &impl IdeviceProvider) -> Result { @@ -39,6 +44,16 @@ impl IdeviceInfo { .ok_or_else(|| report!("Device UDID is not a string"))? .to_string(); - Ok(Self::new(device_name, device_udid)) + let product_type = lockdown + .get_value(Some("ProductType"), None) + .await + .ok() + .and_then(|value| value.as_string().map(str::to_string)); + + Ok(Self { + name: device_name, + udid: device_udid, + product_type, + }) } } From c0a7c8f27701dc3feef4e2ee25a9b63b6465b5cd Mon Sep 17 00:00:00 2001 From: Dave Nicolson Date: Sat, 17 Oct 2026 17:46:33 +0200 Subject: [PATCH 2/2] Sanitize bundle names for tvOS signing --- isideload/src/dev/developer_session.rs | 6 ++- isideload/src/sideload/application.rs | 67 ++++++++++++++++++++++++++ isideload/src/sideload/bundle.rs | 15 ++++++ isideload/src/sideload/sideloader.rs | 3 ++ 4 files changed, 90 insertions(+), 1 deletion(-) diff --git a/isideload/src/dev/developer_session.rs b/isideload/src/dev/developer_session.rs index b33a300..290f54e 100644 --- a/isideload/src/dev/developer_session.rs +++ b/isideload/src/dev/developer_session.rs @@ -71,7 +71,11 @@ impl DeveloperSession { /// Set the platform selected for subsequent developer service requests. pub fn set_device_type(&mut self, device_type: DeveloperDeviceType) { - self.device_type = device_type; + self.device_type = device_type.clone(); + } + + pub fn device_type(&self) -> DeveloperDeviceType { + self.device_type.clone() } pub async fn get_headers(&mut self) -> Result { diff --git a/isideload/src/sideload/application.rs b/isideload/src/sideload/application.rs index 4e0ccd0..98ddd6e 100644 --- a/isideload/src/sideload/application.rs +++ b/isideload/src/sideload/application.rs @@ -100,6 +100,18 @@ impl Application { }) } + // tvOS rejects bundles whose directory or executable path contains a space + // during signature verification (0xe8008015), while iOS does not. Rename the + // bundle directory and its executables to strip spaces for signing, keeping + // the plist bundle name (CFBundleName/CFBundleDisplayName) untouched. + pub fn sanitize_signing_names(&mut self) -> Result<(), Report> { + sanitize_bundle_paths(&mut self.bundle)?; + for bundle in self.bundle.app_extensions_mut() { + sanitize_bundle_paths(bundle)?; + } + Ok(()) + } + pub fn get_special_app(&self) -> Option { let bundle_id = self.bundle.bundle_identifier().unwrap_or(""); let special_app = match bundle_id { @@ -332,3 +344,58 @@ impl std::fmt::Display for SpecialApp { } } } + +fn sanitize_bundle_paths(bundle: &mut Bundle) -> Result<(), Report> { + let parent = bundle.bundle_dir.parent().ok_or_report()?.to_path_buf(); + let folder_name = bundle + .bundle_dir + .file_stem() + .ok_or_report()? + .to_string_lossy() + .to_string(); + let clean_folder = sanitize_name(&folder_name); + if clean_folder != folder_name { + let new_dir = parent.join(format!("{}.app", clean_folder)); + if new_dir != bundle.bundle_dir { + let old_dir = bundle.bundle_dir.clone(); + isideload_vfs::fs::rename(&old_dir, &new_dir) + .context(format!("Failed to rename bundle directory {:?}", old_dir))?; + bundle.rebase_bundle_dir(&old_dir, &new_dir); + } + } + + let executable = bundle + .app_info + .get("CFBundleExecutable") + .and_then(|v| v.as_string()) + .map(str::to_string) + .unwrap_or_else(|| folder_name.clone()); + let clean_exec = sanitize_name(&executable); + if clean_exec != executable { + let new_executable = bundle.bundle_dir.join(&clean_exec); + let old_executable = bundle.bundle_dir.join(&executable); + if isideload_vfs::fs::metadata(&old_executable).is_ok() && old_executable != new_executable + { + isideload_vfs::fs::rename(&old_executable, &new_executable) + .context(format!("Failed to rename executable {:?}", old_executable))?; + } + bundle.app_info.insert( + "CFBundleExecutable".to_string(), + plist::Value::String(clean_exec), + ); + } + + Ok(()) +} + +fn sanitize_name(name: &str) -> String { + let clean: String = name + .chars() + .filter(|c| c.is_ascii_alphanumeric() || *c == '.' || *c == '-') + .collect(); + if clean.is_empty() { + "App".to_string() + } else { + clean + } +} diff --git a/isideload/src/sideload/bundle.rs b/isideload/src/sideload/bundle.rs index 67a947d..b581d55 100644 --- a/isideload/src/sideload/bundle.rs +++ b/isideload/src/sideload/bundle.rs @@ -124,6 +124,21 @@ impl Bundle { &self.frameworks } + // Rewrites the stored path of this bundle and its sub-bundles when a parent + // directory has been renamed on disk, so that they keep pointing at valid + // locations. + pub fn rebase_bundle_dir(&mut self, old_dir: &std::path::Path, new_dir: &std::path::Path) { + if let Ok(rel) = self.bundle_dir.strip_prefix(old_dir) { + self.bundle_dir = new_dir.join(rel); + } + for ext in &mut self.app_extensions { + ext.rebase_bundle_dir(old_dir, new_dir); + } + for fw in &mut self.frameworks { + fw.rebase_bundle_dir(old_dir, new_dir); + } + } + pub fn frameworks_mut(&mut self) -> &mut [Bundle] { &mut self.frameworks } diff --git a/isideload/src/sideload/sideloader.rs b/isideload/src/sideload/sideloader.rs index 9e3a1fc..50379ee 100644 --- a/isideload/src/sideload/sideloader.rs +++ b/isideload/src/sideload/sideloader.rs @@ -96,6 +96,9 @@ impl Sideloader { } let mut app = Application::new(app_path)?; + if self.dev_session.device_type() == crate::dev::device_type::DeveloperDeviceType::Tvos { + app.sanitize_signing_names()?; + } let special = app.get_special_app(); let main_bundle_id = app.main_bundle_id()?;