Git · dadoum.zone
Repositories
Help
Report an Issue
apple-crates
Code
Commits
Branches
Tags
Search
Tree:
53bd1a4
Branches
Tags
main
apple-crates
super-sideloader
src
ipa.rs
Implement super-sideloader ADI backends and IPA loading
Dadoum
commited
53bd1a4
at 2026-07-08 09:55:10
ipa.rs
Blame
History
Raw
use crate::models::{ AppEntitlement, AppMetadata, AppOption, EntitlementValue, PatchOption, SupportedDeviceFamily, }; use crate::paths::app_data_dir; use async_zip::tokio::read::fs::ZipFileReader; use futures_lite::io::AsyncReadExt; use plist::{Dictionary, Value}; use std::fs; use std::io::Cursor; use std::path::{Path, PathBuf}; pub(crate) fn read_ipa(path: &Path, patches: Vec<PatchOption>) -> Result<AppOption, String> { tokio::runtime::Builder::new_current_thread() .enable_all() .build() .map_err(|error| format!("Failed to start IPA reader runtime: {error}"))? .block_on(read_ipa_async(path, patches)) } async fn read_ipa_async(path: &Path, patches: Vec<PatchOption>) -> Result<AppOption, String> { let reader = ZipFileReader::new(path) .await .map_err(|error| format!("Failed to read IPA archive at {}: {error}", path.display()))?; let info_index = reader .file() .entries() .iter() .position(|entry| entry_name(entry).is_some_and(is_app_info_plist)) .ok_or_else(|| "IPA does not contain Payload/*.app/Info.plist.".to_string())?; let info_path = entry_name(&reader.file().entries()[info_index]) .ok_or_else(|| "IPA Info.plist entry name is invalid.".to_string())?; let app_prefix = info_path .strip_suffix("Info.plist") .ok_or_else(|| "IPA Info.plist entry path is invalid.".to_string())? .to_string(); let info_bytes = read_entry(&reader, info_index, "Info.plist").await?; let info = plist::from_bytes::<Dictionary>(&info_bytes) .map_err(|error| format!("Failed to parse IPA Info.plist: {error}"))?; let bundle_id = required_plist_string(&info, "CFBundleIdentifier")?; let executable = required_plist_string(&info, "CFBundleExecutable")?; let version = required_plist_string(&info, "CFBundleShortVersionString")?; let build = required_plist_string(&info, "CFBundleVersion")?; let minimum_os = required_plist_string(&info, "MinimumOSVersion")?; let name = plist_string(&info, "CFBundleDisplayName") .or_else(|| plist_string(&info, "CFBundleName")) .unwrap_or(executable); let supported_devices = supported_devices(&info); let icon_path = extract_icon(&reader, &app_prefix, &info, bundle_id).await; let entitlements = extract_entitlements(&reader, &app_prefix) .await .unwrap_or_else(|| fallback_entitlements(bundle_id)); Ok(AppOption { metadata: AppMetadata::sample( name, bundle_id, version, build, executable, minimum_os, supported_devices, ), path: path.to_string_lossy().to_string().into(), icon_path: icon_path.map(|path| path.to_string_lossy().to_string().into()), icon_override_path: None, entitlements, entitlement_overrides: None, patches, }) } fn is_app_info_plist(path: &str) -> bool { path.starts_with("Payload/") && path.ends_with(".app/Info.plist") && path["Payload/".len()..].contains(".app/") } fn entry_name(entry: &async_zip::ZipEntry) -> Option<&str> { entry.filename().as_str().ok() } fn required_plist_string<'a>(info: &'a Dictionary, key: &str) -> Result<&'a str, String> { plist_string(info, key).ok_or_else(|| format!("IPA Info.plist is missing string key {key}.")) } async fn read_entry(reader: &ZipFileReader, index: usize, label: &str) -> Result<Vec<u8>, String> { let mut entry_reader = reader .reader_without_entry(index) .await .map_err(|error| format!("Failed to read {label} from IPA: {error}"))?; let mut data = Vec::new(); entry_reader .read_to_end(&mut data) .await .map_err(|error| format!("Failed to extract {label} from IPA: {error}"))?; Ok(data) } fn plist_string<'a>(plist: &'a Dictionary, key: &str) -> Option<&'a str> { plist.get(key).and_then(Value::as_string) } fn supported_devices(info: &Dictionary) -> Vec<SupportedDeviceFamily> { let Some(Value::Array(families)) = info.get("UIDeviceFamily") else { return Vec::new(); }; let mut devices = families .iter() .filter_map(|value| value.as_unsigned_integer()) .filter_map(SupportedDeviceFamily::from_device_family_id) .collect::<Vec<_>>(); devices.sort(); devices.dedup(); devices } async fn extract_icon( reader: &ZipFileReader, app_prefix: &str, info: &Dictionary, bundle_id: &str, ) -> Option<PathBuf> { let mut candidates = icon_candidates(info); candidates.reverse(); for candidate in candidates { if let Some((index, name)) = find_icon_entry(reader, app_prefix, &candidate) { if let Ok(bytes) = read_entry(reader, index, name).await { if let Some(path) = cache_icon(bundle_id, &bytes) { return Some(path); } } } } let fallback = reader .file() .entries() .iter() .enumerate() .filter_map(|(index, entry)| { let name = entry_name(entry)?; let lower = name.to_ascii_lowercase(); (name.starts_with(app_prefix) && lower.ends_with(".png") && (lower.contains("appicon") || lower.contains("icon"))) .then_some((index, name, entry.compressed_size())) }) .max_by_key(|(_, _, size)| *size) .map(|(index, name, _)| (index, name)); let Some((index, name)) = fallback else { return None; }; let bytes = read_entry(reader, index, name).await.ok()?; cache_icon(bundle_id, &bytes) } fn icon_candidates(info: &Dictionary) -> Vec<String> { let mut candidates = Vec::new(); append_icon_candidates(info.get("CFBundleIconFiles"), &mut candidates); append_icon_candidates_from_icons(info.get("CFBundleIcons"), &mut candidates); append_icon_candidates_from_icons(info.get("CFBundleIcons~ipad"), &mut candidates); candidates } fn append_icon_candidates(value: Option<&Value>, candidates: &mut Vec<String>) { match value { Some(Value::Array(values)) => { candidates.extend( values .iter() .filter_map(Value::as_string) .map(str::to_string), ); } Some(Value::String(value)) => candidates.push(value.clone()), _ => {} } } fn append_icon_candidates_from_icons(value: Option<&Value>, candidates: &mut Vec<String>) { let Some(Value::Dictionary(icons)) = value else { return; }; let Some(Value::Dictionary(primary_icon)) = icons.get("CFBundlePrimaryIcon") else { return; }; append_icon_candidates(primary_icon.get("CFBundleIconFiles"), candidates); } fn find_icon_entry<'a>( reader: &'a ZipFileReader, app_prefix: &str, candidate: &str, ) -> Option<(usize, &'a str)> { let candidate = candidate.trim_start_matches('/'); let forms = if candidate.ends_with(".png") { vec![candidate.to_string()] } else { vec![ format!("{candidate}@3x.png"), format!("{candidate}@2x.png"), format!("{candidate}.png"), candidate.to_string(), ] }; reader .file() .entries() .iter() .enumerate() .find_map(|(index, entry)| { let name = entry_name(entry)?; let relative = name.strip_prefix(app_prefix)?; forms .iter() .any(|form| relative == form || relative.ends_with(&format!("/{form}"))) .then_some((index, name)) }) } fn cache_icon(bundle_id: &str, bytes: &[u8]) -> Option<PathBuf> { let mut path = app_data_dir()?.join("icons"); fs::create_dir_all(&path).ok()?; path.push(format!("{}.png", cache_safe_name(bundle_id))); fs::write(&path, bytes).ok()?; Some(path) } fn cache_safe_name(value: &str) -> String { value .chars() .map(|character| { if character.is_ascii_alphanumeric() || matches!(character, '.' | '-' | '_') { character } else { '_' } }) .collect() } async fn extract_entitlements( reader: &ZipFileReader, app_prefix: &str, ) -> Option<Vec<AppEntitlement>> { let entry_index = reader.file().entries().iter().position(|entry| { entry_name(entry) == Some(&format!("{app_prefix}embedded.mobileprovision")) })?; let bytes = read_entry(reader, entry_index, "embedded.mobileprovision") .await .ok()?; let plist = embedded_plist(&bytes)?; let entitlements = plist.get("Entitlements")?.as_dictionary()?; Some(entitlements_from_dictionary(entitlements)) } fn embedded_plist(bytes: &[u8]) -> Option<Dictionary> { let start = find_bytes(bytes, b"<?xml")?; let end = find_bytes(&bytes[start..], b"</plist>")? + start + b"</plist>".len(); plist::from_reader(Cursor::new(&bytes[start..end])).ok() } fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option<usize> { haystack .windows(needle.len()) .position(|window| window == needle) } fn entitlements_from_dictionary(entitlements: &Dictionary) -> Vec<AppEntitlement> { entitlements .iter() .map(|(key, value)| AppEntitlement { key: key.clone().into(), value: entitlement_value(value), }) .collect() } fn entitlement_value(value: &Value) -> EntitlementValue { match value { Value::String(value) => EntitlementValue::String(value.clone().into()), Value::Boolean(value) => EntitlementValue::Boolean(*value), Value::Integer(value) => value .as_signed() .map(EntitlementValue::Integer) .unwrap_or_else(|| EntitlementValue::String(value.to_string().into())), Value::Real(value) => EntitlementValue::Number(*value), Value::Array(values) => { EntitlementValue::Array(values.iter().map(entitlement_value).collect()) } Value::Dictionary(values) => EntitlementValue::Dictionary( values .iter() .map(|(key, value)| (key.clone().into(), entitlement_value(value))) .collect(), ), Value::Data(value) => EntitlementValue::Data(value.clone()), Value::Date(value) => EntitlementValue::Date(value.to_xml_format().into()), Value::Uid(value) => EntitlementValue::Uid(value.get()), _ => EntitlementValue::Unknown("Value".into()), } } fn fallback_entitlements(bundle_id: &str) -> Vec<AppEntitlement> { vec![ AppEntitlement { key: "application-identifier".into(), value: EntitlementValue::String(format!("TEAMID.{bundle_id}").into()), }, AppEntitlement { key: "get-task-allow".into(), value: EntitlementValue::Boolean(true), }, ] }