diff --git a/package.json b/package.json index 35db268..511a0e4 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "iloader", "private": true, - "version": "2.2.6", + "version": "2.2.20", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index e5570c7..e575182 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -268,6 +268,25 @@ dependencies = [ "tokio", ] +[[package]] +name = "async-dnssd" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d49ffe175ab45bbfd74b548313d9d7cdfff27161a94b007b52eeeb5f9aaa15e" +dependencies = [ + "bitflags 1.3.2", + "futures-channel", + "futures-core", + "futures-executor", + "futures-util", + "libc", + "log", + "pin-utils", + "pkg-config", + "tokio", + "winapi", +] + [[package]] name = "async-executor" version = "1.14.0" @@ -1213,34 +1232,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 = "crypto-bigint" version = "0.5.5" @@ -1878,18 +1875,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" @@ -2071,6 +2056,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" @@ -2991,15 +2987,12 @@ checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" [[package]] name = "idevice" -version = "0.1.58" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "066aab61e525fdc6181ae32bf88d535a1eae8462e3e190516ec15a863f5926cd" +version = "0.1.65" dependencies = [ "aes 0.8.4", "async-stream", "async_zip", "base64 0.22.1", - "byteorder", "cbc 0.1.2", "chacha20poly1305", "chrono", @@ -3010,7 +3003,7 @@ dependencies = [ "idevice-srp", "indexmap 2.13.1", "jktcp", - "json", + "libc", "plist", "plist-macro", "rand 0.10.0", @@ -3020,11 +3013,13 @@ dependencies = [ "serde_json", "sha1 0.10.6", "sha2 0.10.9", + "siphasher 1.0.2", "thiserror 2.0.18", "tokio", "tokio-rustls", "tracing", "uuid", + "web-time", "x25519-dalek", "x509-cert 0.2.5", ] @@ -3064,15 +3059,27 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "if-addrs" +version = "0.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0a05c691e1fae256cf7013d99dad472dc52d5543322761f83ec8d47eab40d2b" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "iloader" -version = "2.2.6" +version = "2.2.20" dependencies = [ + "async-dnssd", "chrono", "futures", "idevice", "isideload", "keyring", + "mdns-sd", "once_cell", "plist", "plist-macro", @@ -3208,8 +3215,6 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "isideload" version = "0.2.22" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30bdc9de61cd3845098801de35c642c41f59dd463f8f147c89042b85b803eba2" dependencies = [ "aes 0.9.0", "aes-gcm", @@ -3486,15 +3491,17 @@ dependencies = [ [[package]] name = "jktcp" -version = "0.1.1" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a5e6f49d25ffd9617fde3b1f01b6bc349497affef78d5fc67f5b0f0b4b3924c" +checksum = "54408d8a86952b9f1cc009782ad7bbb4007a7caaba0599db441e0eae8785288c" dependencies = [ - "crossfire", "futures", + "getrandom 0.3.4", "rand 0.9.2", "tokio", "tracing", + "wasm-bindgen-futures", + "wasmtimer", ] [[package]] @@ -3563,12 +3570,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "json" -version = "0.12.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "078e285eafdfb6c4b434e0d31e8cfcb5115b651496faca5749b88fafd4f23bfd" - [[package]] name = "json-patch" version = "3.0.1" @@ -3844,6 +3845,21 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "mdns-sd" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18148fee27e99e76dbf6e137f27727113d31f766e578d1b93a93c3615fca7081" +dependencies = [ + "fastrand", + "flume", + "if-addrs", + "log", + "mio", + "socket-pktinfo", + "socket2", +] + [[package]] name = "memchr" version = "2.8.0" @@ -3894,6 +3910,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi 0.11.1+wasi-snapshot-preview1", "windows-sys 0.61.2", ] @@ -4745,6 +4762,12 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" +[[package]] +name = "pin-utils" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" + [[package]] name = "piper" version = "0.2.5" @@ -6358,6 +6381,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "socket-pktinfo" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "927136cc2ae6a1b0e66ac6b1210902b75c3f726db004a73bc18686dcd0dcd22f" +dependencies = [ + "libc", + "socket2", + "windows-sys 0.60.2", +] + [[package]] name = "socket2" version = "0.6.3" @@ -6421,6 +6455,9 @@ name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] [[package]] name = "spki" @@ -7990,6 +8027,19 @@ dependencies = [ "semver", ] +[[package]] +name = "wasmtimer" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c598d6b99ea013e35844697fc4670d08339d5cda15588f193c6beedd12f644b" +dependencies = [ + "futures", + "js-sys", + "parking_lot", + "pin-utils", + "wasm-bindgen", +] + [[package]] name = "web-sys" version = "0.3.94" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index da28f91..c84706e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "iloader" -version = "2.2.6" +version = "2.2.20" description = "User-friendly sideloader" authors = ["nab138"] edition = "2024" @@ -22,7 +22,7 @@ tauri = { version = "2", features = ["devtools"] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" -idevice = { version = "0.1.57", features = ["usbmuxd", "house_arrest", "afc", "core_device_proxy", "remote_pairing", "tcp", "tunnel_tcp_stack", "xpc", "rsd", "pair"] } +idevice = { version = "0.1.65", features = ["usbmuxd", "house_arrest", "afc", "installation_proxy", "core_device_proxy", "remote_pairing", "tcp", "tunnel_tcp_stack", "xpc", "rsd", "pair"] } isideload = { version = "0.2.22", features = ["fs-storage"] } keyring = { version = "3.6.3", features = ["apple-native", "windows-native", "linux-native-sync-persistent"] } tauri-plugin-store = "2" @@ -44,5 +44,29 @@ rootcause = "0.12.1" thiserror = "2.0.18" strum = {version = "0.28.0", features = ["derive"] } +# Vision Pro discovery. On macOS we go through the system Bonjour daemon +# (dns_sd / mDNSResponder) — raw-multicast mDNS is silently dropped by macOS 15+ +# Local Network privacy when the OS fails to attribute it (no prompt, no Settings +# entry). Elsewhere there is no such gatekeeper and mdns-sd works fine. +[target.'cfg(target_os = "macos")'.dependencies] +async-dnssd = "0.5.1" + +[target.'cfg(not(target_os = "macos"))'.dependencies] +mdns-sd = "0.19" + [target.'cfg(not(any(target_os = "android", target_os = "ios")))'.dependencies] tauri-plugin-updater = "2" + +# Tolerate Apple "device already registered" (developer error 35) during sideload, +# which fires for a Vision Pro registered under visionOS while isideload queries the +# iOS device list. Upstream isideload already carries this fix on git; the published +# 0.2.22 does not, so we vendor a patched copy in-repo (vendor/isideload) with the +# same one-function change. Replace with the upstream release once published. +[patch.crates-io] +isideload = { path = "vendor/isideload" } +# Vision Pro first-time pairing: published idevice hardcodes PIN "000000" when the +# device answers `awaitingUserConsent` (a Trust-dialog assumption). A Vision Pro +# answers that whenever its code screen isn't up yet, so every first pairing attempt +# failed with "missing server proof in SRP response". The vendored copy waits for the +# pairing data and asks the callback for the real on-screen code instead. +idevice = { path = "vendor/idevice" } diff --git a/src-tauri/Info.plist b/src-tauri/Info.plist new file mode 100644 index 0000000..cf0e20d --- /dev/null +++ b/src-tauri/Info.plist @@ -0,0 +1,17 @@ + + + + + + NSLocalNetworkUsageDescription + iloader finds your Apple Vision Pro on your local network to pair with it and install apps over Wi-Fi. + NSBonjourServices + + _remotepairing._tcp + _remotepairing-manual-pairing._tcp + + + diff --git a/src-tauri/src/device.rs b/src-tauri/src/device.rs index c62e6ca..10f841b 100644 --- a/src-tauri/src/device.rs +++ b/src-tauri/src/device.rs @@ -10,7 +10,17 @@ use serde::{Deserialize, Serialize}; use tauri::{AppHandle, State}; use tokio_util::sync::CancellationToken; -use crate::{error::AppError, pairing::pairing_file}; +use crate::{error::AppError, pairing::pairing_file, vision}; + +/// How a device is reached. Usbmux (iPhone/iPad over USB or Wi-Fi via usbmuxd) is the +/// default; Vision is an Apple Vision Pro reached over an RP tunnel (not usbmux). +#[derive(Deserialize, Serialize, Clone, Copy, PartialEq, Eq, Default, Debug)] +#[serde(rename_all = "lowercase")] +pub enum DeviceTransport { + #[default] + Usbmux, + Vision, +} #[derive(Deserialize, Serialize, Clone)] #[serde(rename_all = "camelCase")] @@ -20,6 +30,27 @@ pub struct DeviceInfo { pub udid: String, pub connection_type: String, pub version: String, + /// Lockdown `DeviceClass` (e.g. "iPhone", "iPad", "RealityDevice"). Optional + /// because not every device reports it as a string; a missing value must not + /// drop the device from the list. + #[serde(skip_serializing_if = "Option::is_none")] + pub device_class: Option, + /// Lockdown `ProductType` (e.g. "iPhone14,5", "RealityDevice17,1"). Optional + /// for the same reason as `device_class`. + #[serde(skip_serializing_if = "Option::is_none")] + pub product_type: Option, + /// Transport used to reach the device. Defaults to usbmux for backwards + /// compatibility with any persisted iOS device. + #[serde(default)] + pub transport: DeviceTransport, + /// The Vision Pro's IP address (for the RP tunnel). Only set for `Vision`. + #[serde(skip_serializing_if = "Option::is_none")] + pub ip: Option, + /// For a `Vision` device: whether a reusable pairing file is already stored (so + /// the frontend can select it directly instead of prompting for the headset + /// code). Ignored for usbmux devices. + #[serde(default)] + pub paired: bool, } #[derive(Deserialize, Serialize, Clone)] @@ -33,7 +64,31 @@ pub type DeviceInfoMutex = Mutex>; pub type PairingCancelToken = Mutex>; #[tauri::command] -pub async fn list_devices() -> Result>, AppError> { +pub async fn list_devices(app: AppHandle) -> Result>, AppError> { + // usbmux (iPhone/iPad) and Vision Pro (mDNS) discovery are independent transports, + // so run them concurrently. A usbmux failure must not hide a discovered Vision Pro + // and vice-versa. + let (usbmux_devices, vision_devices) = futures::future::join( + list_usbmux_devices(), + vision::list_vision_devices(&app), + ) + .await; + + let mut results: Vec> = match usbmux_devices { + Ok(v) => v, + // No usbmuxd / no iOS devices shouldn't blank out a Vision Pro on Wi-Fi. + Err(e) => { + if vision_devices.is_empty() { + return Err(e); + } + Vec::new() + } + }; + results.extend(vision_devices.into_iter().map(Ok)); + Ok(results) +} + +async fn list_usbmux_devices() -> Result>, AppError> { let mut usbmuxd = get_usbmuxd().await?; let devs = usbmuxd.get_devices().await.map_err(|e| { @@ -105,12 +160,33 @@ pub async fn list_devices() -> Result>, AppErro AppError::DeviceComs("Product version was not a string".into()) })?; + // DeviceClass / ProductType let us tell an Apple Vision Pro + // ("RealityDevice" / "RealityDevice17,1") apart from an iPhone or + // iPad. Read them best-effort: unlike DeviceName/ProductVersion a + // missing or non-string value must not drop the device. + let device_class = lockdown_client + .get_value(Some("DeviceClass"), None) + .await + .ok() + .and_then(|v| v.as_string().map(str::to_string)); + + let product_type = lockdown_client + .get_value(Some("ProductType"), None) + .await + .ok() + .and_then(|v| v.as_string().map(str::to_string)); + Ok::(DeviceInfo { name: device_name.to_string(), id: device_uid, udid: d.udid.clone(), connection_type, version: version.to_string(), + device_class, + product_type, + transport: DeviceTransport::Usbmux, + ip: None, + paired: false, }) } }) @@ -133,6 +209,21 @@ pub async fn set_selected_device( return Ok(()); } + // Vision Pro: not a usbmux device. Selecting it verifies the stored RP pairing + // (opening a tunnel to confirm it still pairs) rather than doing lockdown pairing. + // An unpaired Vision Pro is paired via the `vision_pair` command instead, so a + // missing pairing here is a real error. + if device.as_ref().unwrap().transport == DeviceTransport::Vision { + let dev = device.unwrap(); + let (pairing, udid) = vision::select_vision_device(&app, &dev).await?; + let mut info = dev; + info.udid = udid; + info.paired = true; + let mut device_state = device_state.lock().unwrap(); + *device_state = Some(DeviceInfoWithPairing { info, pairing }); + return Ok(()); + } + let mut usbmuxd = get_usbmuxd().await?; let token = tokio_util::sync::CancellationToken::new(); diff --git a/src-tauri/src/error.rs b/src-tauri/src/error.rs index 51e7742..0314fa5 100644 --- a/src-tauri/src/error.rs +++ b/src-tauri/src/error.rs @@ -27,6 +27,19 @@ pub enum AppError { HouseArrest(String, String), #[error("{0}")] RemotePairing(String), + /// The Vision Pro couldn't be reached over the network at all (no TCP connection + /// established). Kept distinct from `RemotePairing` so that a dozing headset or a + /// stale address is never mistaken for a pairing gone bad — a stored pairing must + /// survive this. + #[error("{0}")] + VisionUnreachable(String), + /// The Vision Pro was reached and answered, but rejected the stored pairing during + /// pair-verify. This is the ONE error that proves the pairing is stale (e.g. this + /// Mac was removed from the headset's Remote Devices) — only it may trigger + /// discarding the stored pairing. Failures after pair-verify (tunnel setup, RSD) + /// say nothing about the pairing and must not. + #[error("{0}")] + VisionPairingRejected(String), #[error("{0}: {1}")] LockdownPairing(String, String), #[error("{0} canceled")] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index b217fe9..a703af3 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -11,6 +11,7 @@ mod secure_storage; mod error; mod logging; mod operation; +mod vision; use crate::{ account::{ @@ -26,6 +27,7 @@ use crate::{ }, secure_storage::{force_disable_keyring, keyring_available}, sideload::{SideloaderMutex, install_sidestore_operation, sideload_operation}, + vision::{vision_discovery_error, vision_pair}, }; use tauri::Manager; use tracing_subscriber::{Layer, Registry, fmt, layer::SubscriberExt, util::SubscriberInitExt}; @@ -101,6 +103,11 @@ pub fn run() { app.manage(DeviceInfoMutex::new(None)); app.manage(SideloaderMutex::new(None)); app.manage(PairingCancelToken::new(None)); + + // Start browsing for Vision Pros immediately so the mDNS multicast group is + // warm before the first device-list refresh (a cold one-shot browse races + // the network round-trip and can miss a device that's actually present). + vision::start_discovery(); Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -126,6 +133,8 @@ pub fn run() { force_disable_keyring, cancel_pairing, has_stored_rppairing, + vision_pair, + vision_discovery_error, ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); diff --git a/src-tauri/src/pairing.rs b/src-tauri/src/pairing.rs index 03ad874..e2c7428 100644 --- a/src-tauri/src/pairing.rs +++ b/src-tauri/src/pairing.rs @@ -24,9 +24,10 @@ use tokio_util::sync::CancellationToken; use tracing::{error, info, warn}; use crate::{ - device::{DeviceInfo, DeviceInfoMutex, get_provider}, + device::{DeviceInfo, DeviceInfoMutex, DeviceTransport, get_provider}, error::AppError, secure_storage::{create_sideloading_storage, keyring_available}, + vision, }; struct PairingStorageEntry { @@ -145,9 +146,9 @@ async fn generate_rppairing( info!("Starting RPPairing..."); info!("(You may need to tap Trust on the device)"); let mut pairing_file = RpPairingFile::generate(hostname); - let mut pairing_client = RemotePairingClient::new(remote_xpc, hostname, &mut pairing_file); + let mut pairing_client = RemotePairingClient::new(remote_xpc, hostname); pairing_client - .connect(async |_| "000000".to_string(), ()) + .connect(&mut pairing_file, async || "000000".to_string()) .await?; // use it right away to try and convince the device to commmit it to the keychain @@ -156,9 +157,9 @@ async fn generate_rppairing( let mut remote_xpc = RemoteXpcClient::new(tunnel_service_stream).await?; remote_xpc.do_handshake().await?; let _ = remote_xpc.recv_root().await; - let mut pairing_client = RemotePairingClient::new(remote_xpc, hostname, &mut pairing_file); + let mut pairing_client = RemotePairingClient::new(remote_xpc, hostname); pairing_client - .connect(async |_| "000000".to_string(), ()) + .connect(&mut pairing_file, async || "000000".to_string()) .await?; Ok(pairing_file) @@ -223,6 +224,18 @@ pub async fn place_pairing_cmd( } }; + if device.info.transport == DeviceTransport::Vision { + let ip = device + .info + .ip + .clone() + .ok_or_else(|| AppError::RemotePairing("Vision Pro has no IP address".into()))?; + let ips = vision::live_ips(&device.info.name, &ip); + let mut session = vision::VisionSession::connect_any(&ips, &device.pairing).await?; + vision::place_into(&mut session, &bundle_id, &path, &device.pairing).await?; + return Ok(()); + } + let provider = get_provider(&device.info).await?; place_file(device.pairing, &provider, bundle_id, path).await @@ -280,6 +293,34 @@ fn build_pairing_storage_entry(app: &AppHandle, keyring_enabled: bool) -> Pairin } } +/// Store arbitrary keyed bytes in the shared pairing storage (used to cache a Vision +/// Pro's reusable RP pairing file across launches). +pub fn store_pairing_data(app: &AppHandle, key: &str, data: &[u8]) -> Result<(), AppError> { + with_pairing_storage(app, |storage| { + storage + .store_data(key, data) + .map_err(|e| AppError::Storage("Failed to store pairing data".into(), e.to_string())) + }) +} + +/// Retrieve keyed bytes previously stored with [`store_pairing_data`]. +pub fn retrieve_pairing_data(app: &AppHandle, key: &str) -> Result>, AppError> { + with_pairing_storage(app, |storage| { + storage + .retrieve_data(key) + .map_err(|e| AppError::Storage("Failed to retrieve pairing data".into(), e.to_string())) + }) +} + +/// Delete keyed bytes previously stored with [`store_pairing_data`]. +pub fn delete_pairing_data(app: &AppHandle, key: &str) -> Result<(), AppError> { + with_pairing_storage(app, |storage| { + storage + .delete(key) + .map_err(|e| AppError::Storage("Failed to delete pairing data".into(), e.to_string())) + }) +} + fn with_pairing_storage( app: &AppHandle, f: impl FnOnce(&dyn SideloadingStorage) -> Result, @@ -318,8 +359,19 @@ pub async fn pairing_file( res = generate_lockdown_plist(device, &provider, usbmuxd) => res? }; - // rppairing is 17.4+ - if is_ios_version_below(device.version.as_str(), 17, 4) { + // rppairing (RemotePairing) is required on iOS 17.4+. An Apple Vision Pro is + // always iOS-17+ era regardless of its ProductVersion, but visionOS version + // numbers (1.x, 2.x, 26, 27, …) don't line up with iOS's 17.4 threshold — a + // visionOS 1.x/2.x device would report "1.0"/"2.0" and wrongly fall to the + // lockdown-only path, which can't reach the install/JIT services behind the + // RSD tunnel. So force the RemotePairing path for any RealityDevice. + let is_vision = device.device_class.as_deref() == Some("RealityDevice") + || device + .product_type + .as_deref() + .is_some_and(|p| p.starts_with("RealityDevice")); + + if !is_vision && is_ios_version_below(device.version.as_str(), 17, 4) { let lockdown_dict = lockdown_plist.as_dictionary().cloned().ok_or_else(|| { AppError::LockdownPairing( "Lockdown plist was not a dictionary".into(), @@ -412,7 +464,13 @@ pub async fn delete_stored_rppairing( } }; - let cache_key = format!("rppairing_file_{}", device.info.udid); + // A Vision Pro's reusable RP pairing is keyed by name (its UDID isn't known until + // a tunnel is opened); usbmux devices are keyed by UDID. + let cache_key = if device.info.transport == DeviceTransport::Vision { + vision::pairing_storage_key(&device.info.name) + } else { + format!("rppairing_file_{}", device.info.udid) + }; with_pairing_storage(&app, |storage| { storage.delete(&cache_key).map_err(|e| { @@ -450,23 +508,42 @@ pub async fn installed_pairing_apps( None => return Err(AppError::NoDeviceSelected), } }; - let provider = get_provider(&device.info).await?; - let mut installation_proxy = - InstallationProxyClient::connect(&provider) + + // Fetch installed apps over the transport for this device (usbmux vs RP tunnel), + // then apply the same PAIRING_APPS filter to both. + let installed_apps = if device.info.transport == DeviceTransport::Vision { + let ip = device + .info + .ip + .clone() + .ok_or_else(|| AppError::RemotePairing("Vision Pro has no IP address".into()))?; + let ips = vision::live_ips(&device.info.name, &ip); + let mut session = vision::VisionSession::connect_any(&ips, &device.pairing).await?; + let mut inst = session.service::().await?; + inst.get_apps(Some("User"), None).await.map_err(|e| { + AppError::DeviceComsWithMessage("Failed to get installed apps".into(), e.to_string()) + })? + } else { + let provider = get_provider(&device.info).await?; + let mut installation_proxy = + InstallationProxyClient::connect(&provider) + .await + .map_err(|e| { + AppError::DeviceComsWithMessage( + "Failed to connect to installation proxy".into(), + e.to_string(), + ) + })?; + installation_proxy + .get_apps(Some("User"), None) .await .map_err(|e| { AppError::DeviceComsWithMessage( - "Failed to connect to installation proxy".into(), + "Failed to get installed apps".into(), e.to_string(), ) - })?; - - let installed_apps = installation_proxy - .get_apps(Some("User"), None) - .await - .map_err(|e| { - AppError::DeviceComsWithMessage("Failed to get installed apps".into(), e.to_string()) - })?; + })? + }; let mut installed = HashMap::new(); for (bundle_id, app) in installed_apps { diff --git a/src-tauri/src/secure_storage.rs b/src-tauri/src/secure_storage.rs index 7339921..f4361ea 100644 --- a/src-tauri/src/secure_storage.rs +++ b/src-tauri/src/secure_storage.rs @@ -1,3 +1,4 @@ +use std::sync::OnceLock; use std::sync::atomic::{AtomicBool, Ordering}; use isideload::util::{ @@ -29,12 +30,24 @@ pub fn keyring_available() -> bool { !FORCE_DISABLE_KEYRING.load(Ordering::Relaxed) && check_keyring_available() } +/// Probe whether the OS keychain is usable, **once per process**. The probe does a +/// real `get_password`, which on macOS pops the "iloader wants to use your confidential +/// information" prompt. This function is called on nearly every storage access +/// (`create_sideloading_storage`, `with_pairing_storage`, the frontend keyring check), +/// so without caching the prompt appeared 4+ times per launch (and again mid-install). +/// Keychain availability doesn't change during a session, so caching the result is +/// safe and collapses those to a single prompt. (User toggling is handled separately +/// by `FORCE_DISABLE_KEYRING`, which doesn't touch this probe.) fn check_keyring_available() -> bool { - let entry = keyring::Entry::new("iloader", "test"); - if let Ok(entry) = entry { - return entry.set_password("test").is_ok() && entry.get_password().is_ok(); - } - false + static CACHED: OnceLock = OnceLock::new(); + *CACHED.get_or_init(|| { + let entry = keyring::Entry::new("iloader", "test"); + if let Ok(entry) = entry { + entry.set_password("test").is_ok() && entry.get_password().is_ok() + } else { + false + } + }) } pub fn create_sideloading_storage( diff --git a/src-tauri/src/sideload.rs b/src-tauri/src/sideload.rs index 6473ac6..6b7c5e0 100644 --- a/src-tauri/src/sideload.rs +++ b/src-tauri/src/sideload.rs @@ -1,14 +1,50 @@ use std::{path::PathBuf, sync::Mutex}; use crate::{ - device::{DeviceInfoMutex, get_provider, get_provider_from_connection, get_usbmuxd}, + device::{ + DeviceInfoMutex, DeviceInfoWithPairing, DeviceTransport, get_provider, + get_provider_from_connection, get_usbmuxd, + }, error::AppError, operation::Operation, pairing::{get_sidestore_info, place_file}, + vision, }; +use isideload::dev::devices::DevicesApi; use isideload::sideload::{application::SpecialApp, sideloader::Sideloader}; use tauri::{AppHandle, Manager, State, Window}; +/// The RP pairing file name a Vision Pro build of SideStore reads on boot (its +/// AppBootManager prefers this over the classic usbmux pairing). +const VISION_PAIRING_FILENAME: &str = "rp_pairing_file.plist"; + +/// Vision Pro build of SideStore: patched with visionOS support (device-class +/// registration tolerance, RemotePairing boot preference, arm64). Tracks the +/// rebelancap/SideStore fork until the fixes land upstream. There is no nightly or +/// LiveContainer visionOS build, so a Vision Pro always installs this. +const SIDESTORE_VP_URL: &str = + "https://github.com/rebelancap/SideStore/releases/download/visionos-0.6.4/SideStore-visionOS.ipa"; + +/// LiveContainer with the visionOS-patched SideStore embedded (built by +/// rebelancap/LiveContainer's CI from the patched LiveContainer/SideStore). The +/// embedded SideStore reads its pairing from `SideStore/Documents/` inside the +/// LiveContainer container. +const LIVECONTAINER_VP_URL: &str = + "https://github.com/rebelancap/LiveContainer/releases/download/visionos/LiveContainer-SideStore-visionOS.ipa"; + +/// Where the LiveContainer-embedded SideStore looks for its RP pairing file (relative +/// to the LiveContainer container's Documents), mirroring the classic +/// `SideStore/Documents/ALTPairingFile.mobiledevicepairing` iOS path. +const LIVECONTAINER_VISION_PAIRING_PATH: &str = "SideStore/Documents/rp_pairing_file.plist"; + +/// LiveContainer2 — the auto-return relay sibling. On visionOS nothing inside a dying +/// app's own process tree survives it, so after a guest app runs or quits, this second +/// LiveContainer install is what reopens LiveContainer automatically. Installed +/// alongside LiveContainer on the Vision Pro; LiveContainer degrades gracefully +/// (manual reopen) if this install fails, so its failure never fails the operation. +const LIVECONTAINER2_VP_URL: &str = + "https://github.com/rebelancap/LiveContainer/releases/download/visionos/LiveContainer2-visionOS.ipa"; + pub type SideloaderMutex = Mutex>; pub struct SideloaderGuard<'a> { @@ -53,6 +89,13 @@ pub async fn sideload( } }; + // Vision Pro isn't a usbmux device: sign with the Apple account (transport-agnostic), + // then install the signed bundle over the RP tunnel instead of isideload's usbmux + // install path. + if device.info.transport == DeviceTransport::Vision { + return sideload_vision(device, sideloader_state, app_path).await; + } + let provider = get_provider(&device.info).await?; let mut sideloader = SideloaderGuard::take(&sideloader_state)?; @@ -65,6 +108,56 @@ pub async fn sideload( Ok(special) } +/// Sign + install onto a Vision Pro over Wi-Fi. Signing reuses isideload's account +/// flow (it only talks to Apple + the local filesystem); only registration needs the +/// device UDID, and only the install runs over the RP tunnel. +async fn sideload_vision( + device: DeviceInfoWithPairing, + sideloader_state: State<'_, SideloaderMutex>, + app_path: String, +) -> Result, AppError> { + let ip = device + .info + .ip + .clone() + .ok_or_else(|| AppError::RemotePairing("Vision Pro has no IP address".into()))?; + let ips = vision::live_ips(&device.info.name, &ip); + + // The UDID is needed to register the device with the developer account. Prefer the + // one captured at selection; otherwise read it over a short-lived tunnel now. + let udid = if device.info.udid.is_empty() { + vision::read_udid_any(&ips, &device.pairing).await? + } else { + device.info.udid.clone() + }; + + let mut sideloader = SideloaderGuard::take(&sideloader_state)?; + let team = sideloader.get_mut().get_team().await?; + sideloader + .get_mut() + .get_dev_session() + .ensure_device_registered(&team, &device.info.name, &udid, None) + .await?; + + let (signed_path, special) = sideloader + .get_mut() + .sign_app(app_path.into(), Some(team), false) + .await?; + + // Fresh tunnel for the install itself (signing above is network-bound and could + // otherwise idle out an earlier tunnel). Re-read the live addresses too — signing + // can take minutes, plenty of time for the headset's address to move. + let mut session = + vision::VisionSession::connect_any(&vision::live_ips(&device.info.name, &ip), &device.pairing) + .await?; + vision::install_app(&mut session, &signed_path, |pct| { + tracing::info!("Installing to Vision Pro: {pct}%"); + }) + .await?; + + Ok(special) +} + #[tauri::command] pub async fn sideload_operation( window: Window, @@ -93,8 +186,27 @@ pub async fn install_sidestore_operation( ) -> Result<(), AppError> { let op = Operation::new("install_sidestore".to_string(), &window); op.start("download")?; + + // A Vision Pro needs a patched arm64 visionOS build; there's no visionOS *nightly*, + // so nightly is routed to the same patched build, while LiveContainer has its own + // patched visionOS build (LiveContainer + the visionOS-patched SideStore embedded). + // iPhone/iPad keep the upstream SideStore builds. + let is_vision = { + let guard = device_state.lock().unwrap(); + matches!( + guard.as_ref().map(|d| d.info.transport), + Some(DeviceTransport::Vision) + ) + }; + // TODO: Cache & check version to avoid re-downloading - let (filename, url) = if live_container { + let (filename, url) = if is_vision { + if live_container { + ("LiveContainer-SideStore-visionOS.ipa", LIVECONTAINER_VP_URL) + } else { + ("SideStore-visionOS.ipa", SIDESTORE_VP_URL) + } + } else if live_container { if nightly { ( "LiveContainerSideStore-Nightly.ipa", @@ -124,6 +236,23 @@ pub async fn install_sidestore_operation( .map_err(|e| AppError::Filesystem("Failed to get temp dir".into(), e.to_string()))? .join(filename); op.fail_if_err("download", download(url, &dest).await)?; + // The Vision LiveContainer install brings its auto-return relay sibling along. + let lc2_dest = if is_vision && live_container { + let dest2 = handle + .path() + .temp_dir() + .map_err(|e| AppError::Filesystem("Failed to get temp dir".into(), e.to_string()))? + .join("LiveContainer2-visionOS.ipa"); + match download(LIVECONTAINER2_VP_URL, &dest2).await { + Ok(()) => Some(dest2), + Err(e) => { + tracing::warn!("LiveContainer2 download failed (auto-return will need a manual reopen): {e:?}"); + None + } + } + } else { + None + }; op.move_on("download", "install")?; let device = { let device_guard = device_state.lock().unwrap(); @@ -135,37 +264,82 @@ pub async fn install_sidestore_operation( op.fail_if_err( "install", sideload( - device_state, - sideloader_state, + device_state.clone(), + sideloader_state.clone(), dest.to_string_lossy().to_string(), ) .await, )?; + if let Some(dest2) = lc2_dest { + // Best-effort: LiveContainer works without its relay (auto-return just + // becomes a manual reopen), so LC2 failing must not fail the install. + if let Err(e) = sideload( + device_state.clone(), + sideloader_state.clone(), + dest2.to_string_lossy().to_string(), + ) + .await + { + tracing::warn!("LiveContainer2 install failed (auto-return will need a manual reopen): {e:?}"); + } + } op.move_on("install", "pairing")?; - let sidestore_info = op.fail_if_err( - "pairing", - get_sidestore_info(&device.info, live_container).await, - )?; - if let Some(info) = sidestore_info { - let mut usbmuxd = op.fail_if_err("pairing", get_usbmuxd().await)?; - - let provider = op.fail_if_err( + if device.info.transport == DeviceTransport::Vision { + // Place the RP pairing file into SideStore over the tunnel so it can reach the + // device on its own (SideStore-on-visionOS boots from rp_pairing_file.plist). + let ip = match device.info.ip.clone() { + Some(ip) => ip, + None => { + return op.fail( + "pairing", + AppError::RemotePairing("Vision Pro has no IP address".into()), + ); + } + }; + let ips = vision::live_ips(&device.info.name, &ip); + let mut session = op.fail_if_err( "pairing", - get_provider_from_connection(&device.info, &mut usbmuxd).await, + vision::VisionSession::connect_any(&ips, &device.pairing).await, )?; - + // LiveContainer runs SideStore as a guest, so its pairing lives under + // SideStore/Documents/ inside the LiveContainer container; a plain SideStore + // install reads rp_pairing_file.plist straight from its own Documents. + let (needle, path) = if live_container { + ("livecontainer", LIVECONTAINER_VISION_PAIRING_PATH) + } else { + ("sidestore", VISION_PAIRING_FILENAME) + }; + let bundle = op.fail_if_err("pairing", vision::find_app(&mut session, needle).await)?; op.fail_if_err( "pairing", - place_file(device.pairing, &provider, info.bundle_id, info.path).await, + vision::place_into(&mut session, &bundle, path, &device.pairing).await, )?; } else { - return op.fail( + let sidestore_info = op.fail_if_err( "pairing", - AppError::HouseArrest( - "SideStore's not found".into(), - "The device did not report SideStore's bundle ID as installed".into(), - ), - ); + get_sidestore_info(&device.info, live_container).await, + )?; + if let Some(info) = sidestore_info { + let mut usbmuxd = op.fail_if_err("pairing", get_usbmuxd().await)?; + + let provider = op.fail_if_err( + "pairing", + get_provider_from_connection(&device.info, &mut usbmuxd).await, + )?; + + op.fail_if_err( + "pairing", + place_file(device.pairing, &provider, info.bundle_id, info.path).await, + )?; + } else { + return op.fail( + "pairing", + AppError::HouseArrest( + "SideStore's not found".into(), + "The device did not report SideStore's bundle ID as installed".into(), + ), + ); + } } op.complete("pairing")?; diff --git a/src-tauri/src/vision.rs b/src-tauri/src/vision.rs new file mode 100644 index 0000000..e452edf --- /dev/null +++ b/src-tauri/src/vision.rs @@ -0,0 +1,1601 @@ +//! Apple Vision Pro over Wi-Fi — a parallel device path that does NOT go through +//! usbmux. A Wi-Fi Vision Pro is not carried by usbmuxd, so it can't be reached via +//! `UsbmuxdProvider` like an iPhone/iPad. Instead we: +//! +//! 1. discover it over mDNS (`_remotepairing-manual-pairing._tcp`) — via the +//! system Bonjour daemon on macOS (see the `bonjour` module for why), via +//! `mdns_sd`'s own multicast sockets elsewhere, +//! 2. first-time pair with the 6-digit code shown on the headset (`vision_pair`), +//! 3. reach its install services over an RP tunnel: +//! pair-verify on RSD :49152 -> create_tcp_listener -> TLS-PSK CDTunnel +//! -> software TCP stack (Adapter) -> RSD handshake -> RSD services. +//! +//! This is lifted from the standalone `vision-sideload` CLI (its `vision_net.rs`, +//! `install.rs`, `place.rs`, `pair.rs`), which was validated end-to-end against a +//! real Vision Pro. The iOS/iPad path in `device.rs` is unchanged. + +use std::collections::HashMap; +use std::hash::{Hash, Hasher}; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Duration; + +use idevice::RsdService; +use idevice::afc::AfcClient; +use idevice::afc::opcode::AfcFopenMode; +use idevice::house_arrest::HouseArrestClient; +use idevice::installation_proxy::InstallationProxyClient; +use idevice::remote_pairing::{ + RemotePairingClient, RpPairingFile, RpPairingSocket, connect_tls_psk_tunnel_native, +}; +use idevice::rsd::RsdHandshake; +use idevice::tcp::adapter::Adapter; +use idevice::tcp::handle::AdapterHandle; +#[cfg(not(target_os = "macos"))] +use mdns_sd::{ResolvedService, ServiceDaemon, ServiceEvent}; +use tokio::net::TcpStream; + +use tauri::{AppHandle, Emitter, Listener, State, Window}; +use tokio_util::sync::CancellationToken; + +use crate::device::{ + DeviceInfo, DeviceInfoMutex, DeviceInfoWithPairing, DeviceTransport, PairingCancelToken, +}; +use crate::error::AppError; +use crate::pairing::{delete_pairing_data, retrieve_pairing_data, store_pairing_data}; + +/// Manual-pairing service — INITIAL pairing only (pops the headset code). A Vision Pro +/// advertises this ONLY while it is NOT yet paired to us; once paired it stops. +pub const MANUAL_PAIRING_SERVICE: &str = "_remotepairing-manual-pairing._tcp.local."; +/// RemotePairing (RSD) service — a PAIRED Vision Pro advertises this (SRV target is the +/// device hostname on port 49152). Browsing it is how we keep listing a VP after it's +/// paired (so users can reinstall). iPhones advertise it too, so we filter by hostname. +pub const REMOTE_PAIRING_SERVICE: &str = "_remotepairing._tcp.local."; +/// The port pair-verify + tunnel creation live on (the VP's RSD). Stable across the +/// session; NOT the (dynamic) manual-pairing port and NOT 65000. +pub const RSD_PORT: u16 = 49152; + +/// How long `vision_pair` waits for a live manual-pairing announcement (polls ×250ms). +/// Sized for the observed re-announcement cadence (~30-60s between announcements, with +/// the service absent in between), so a tap that lands in a gap still succeeds. +const PAIRABLE_WAIT_POLLS: usize = 160; + +fn ap_err(context: &str, e: impl std::fmt::Debug) -> AppError { + AppError::RemotePairing(format!("{context}: {e:?}")) +} + +/// Whether this running copy of iloader is ad-hoc signed (or unsigned) — the state a +/// from-source build lands in when `APPLE_SIGNING_IDENTITY` isn't set. macOS 15+ +/// cannot reliably attribute Local Network permission to such an app: often no +/// prompt, no entry in the privacy pane, and unicast connects refused instantly with +/// EHOSTUNREACH even while discovery (done by mDNSResponder) works. Worse, the broken +/// permission entry is keyed by BUNDLE ID, so an ad-hoc build can poison +/// `me.nabdev.iloader` for a properly signed copy installed later. Field-confirmed: +/// a user's self-built copy paired the moment it was signed with a (free) Apple +/// Development identity under a fresh bundle id. +#[cfg(target_os = "macos")] +fn is_adhoc_build() -> bool { + static ADHOC: OnceLock = OnceLock::new(); + *ADHOC.get_or_init(|| { + // Only meaningful for a real .app bundle; `cargo run`/`tauri dev` binaries + // live outside one and are exempt from attribution anyway (TN3179). + let Some(bundle) = std::env::current_exe().ok().and_then(|exe| { + exe.ancestors() + .find(|p| p.extension().is_some_and(|e| e == "app")) + .map(Path::to_path_buf) + }) else { + return false; + }; + let Ok(out) = std::process::Command::new("codesign") + .args(["-dv", "--verbose=2"]) + .arg(&bundle) + .output() + else { + return false; + }; + // codesign reports on stderr. + let info = String::from_utf8_lossy(&out.stderr); + let adhoc = info.contains("Signature=adhoc") + || info.contains("code object is not signed at all"); + if adhoc { + tracing::warn!( + target: "vision", + "this iloader build is ad-hoc signed — macOS may silently refuse its \ + local-network traffic (no permission prompt, missing from the Local \ + Network pane, instant EHOSTUNREACH on connects)" + ); + } + adhoc + }) +} + +/// Send one well-formed mDNS PTR query for our pairing service from THIS process's +/// own socket, returning the refusal error if macOS blocked it (`None` = send OK). +/// +/// Two purposes. App-originated local traffic is what makes macOS register the app +/// under Settings ▸ Privacy & Security ▸ Local Network (and show the prompt) — +/// browsing through mDNSResponder does not create that entry on machines where +/// registration is broken, which is why affected users report iloader missing from +/// the pane entirely. And the sendto verdict is an instant, definitive health check +/// of the filter state: two field cases showed iloader's own connects refused in +/// ~300µs while daemon traffic (discovery, usbmuxd-network) sailed through. +#[cfg(target_os = "macos")] +fn local_network_probe() -> Option { + use std::net::UdpSocket; + // Minimal DNS query: header (id 0, flags 0, 1 question), QNAME + // _remotepairing._tcp.local., QTYPE PTR, QCLASS IN. + let mut q: Vec = vec![0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0]; + for label in ["_remotepairing", "_tcp", "local"] { + q.push(label.len() as u8); + q.extend_from_slice(label.as_bytes()); + } + q.extend_from_slice(&[0, 0, 12, 0, 1]); + match UdpSocket::bind(("0.0.0.0", 0)).and_then(|s| s.send_to(&q, ("224.0.0.251", 5353))) { + Ok(_) => None, + Err(e) => Some(e.to_string()), + } +} + +/// How fast an `EHOSTUNREACH` must arrive to count as the kernel REFUSING to send +/// rather than trying and giving up. A genuinely unreachable LAN peer fails only +/// after several ARP probes (seconds); an instant "No route to host" on a +/// directly-attached subnet is a policy verdict — on macOS 15+, the per-app Local +/// Network privacy filter, whose state is known to get stuck out of sync with the +/// Settings switch (notably after app or macOS updates). Field-confirmed: a user's +/// Mac where ping/nc from Terminal reached the headset fine (terminal processes are +/// exempt, TN3179) while iloader's connects failed instantly. +const INSTANT_UNREACHABLE: Duration = Duration::from_millis(300); + +/// A TCP connect to the (already discovered) headset failed — explain the common +/// causes instead of dumping a bare OS error. `HostUnreachable`/`TimedOut` against a +/// LAN peer usually isn't routing: the headset is asleep (ARP goes unanswered), or +/// the Wi-Fi router keeps wireless clients from talking to each other (guest network +/// / AP or client isolation), or a VPN/firewall intercepts local traffic. When the +/// failure was INSTANT, it's macOS itself refusing — see [`INSTANT_UNREACHABLE`]. +fn connect_err(what: &str, ip: &str, port: u16, e: std::io::Error) -> AppError { + AppError::VisionUnreachable(connect_err_message(what, ip, port, e, None)) +} + +fn connect_err_message( + what: &str, + ip: &str, + port: u16, + e: std::io::Error, + elapsed: Option, +) -> String { + use std::io::ErrorKind; + let hint = match e.kind() { + ErrorKind::HostUnreachable | ErrorKind::NetworkUnreachable + if elapsed.is_some_and(|d| d < INSTANT_UNREACHABLE) => + { + "\nmacOS refused this connection instantly instead of trying and timing out — \ + its Local Network privacy filter is blocking iloader, even if the switch \ + looks ON. In System Settings ▸ Privacy & Security ▸ Local Network, switch \ + iloader OFF and back ON, then quit and reopen iloader. If iloader isn't \ + listed there at all, macOS's Local Network database is broken on this Mac: \ + restart the Mac first; if it's still missing after that, delete \ + /Library/Preferences/com.apple.networkextension.plist (admin password \ + needed) and restart — macOS rebuilds it and every app asks for permission \ + again. Known macOS quirk after app or system updates." + } + ErrorKind::HostUnreachable | ErrorKind::NetworkUnreachable | ErrorKind::TimedOut => { + "\nThe Vision Pro was found, but the Mac can't reach it directly. Usually this \ + means the headset is asleep (put it on and keep it awake), the Wi-Fi router \ + blocks devices from talking to each other (guest network or AP/client \ + isolation — try your main network), or a VPN/firewall is in the way." + } + ErrorKind::ConnectionRefused => { + "\nThe Vision Pro refused the connection — its pairing service may have just \ + restarted with a new port. Try again in a few seconds." + } + _ => "", + }; + // On an instant refusal, probe RIGHT NOW (not a cached startup verdict — the + // user may have just toggled the permission) so the message can state whether + // macOS is provably blocking this app's traffic in general. + #[cfg(target_os = "macos")] + let probe_hint = if matches!( + e.kind(), + ErrorKind::HostUnreachable | ErrorKind::NetworkUnreachable + ) && elapsed.is_some_and(|d| d < INSTANT_UNREACHABLE) + && local_network_probe().is_some() + { + "\n(Confirmed: a network probe just now was refused the same way — macOS is \ + blocking iloader itself; the headset and your Wi-Fi are fine.)" + } else { + "" + }; + #[cfg(not(target_os = "macos"))] + let probe_hint = ""; + #[cfg(target_os = "macos")] + let adhoc_hint = if matches!( + e.kind(), + ErrorKind::HostUnreachable | ErrorKind::NetworkUnreachable + ) && elapsed.is_some_and(|d| d < INSTANT_UNREACHABLE) + && is_adhoc_build() + { + "\nIMPORTANT: this copy of iloader is ad-hoc signed (built from source without a \ + signing identity), and macOS blocks such apps' local-network traffic exactly \ + like this. Sign the app with any Apple Development certificate (a free Apple \ + account works), or use the official DMG from the releases page." + } else { + "" + }; + #[cfg(not(target_os = "macos"))] + let adhoc_hint = ""; + format!("{what} ({ip}:{port}): {e}{hint}{probe_hint}{adhoc_hint}") +} + +/// TCP-connect to the first address that answers. +/// +/// A headset advertises several addresses and only some are routable from this Mac, +/// so trying just one turns a perfectly reachable device into "No route to host". +async fn connect_any(ips: &[String], port: u16, what: &str) -> Result<(TcpStream, String), AppError> { + let mut last = None; + for ip in ips { + let started = std::time::Instant::now(); + match TcpStream::connect((ip.as_str(), port)).await { + Ok(s) => { + // Logged BEFORE any protocol byte is exchanged, so a later failure + // can never masquerade as a connect failure in a user log — and the + // local address shows which interface the kernel routed us out of. + tracing::info!( + target: "vision", + "{what}: TCP connected to {ip}:{port} in {:?} (local {})", + started.elapsed(), + s.local_addr().map_or_else(|_| "?".into(), |a| a.to_string()) + ); + return Ok((s, ip.clone())); + } + Err(e) => { + let elapsed = started.elapsed(); + // The timing is diagnostic gold in user logs: instant = macOS policy, + // seconds = nobody answered ARP (asleep / isolated network). + tracing::debug!( + target: "vision", + "{what}: {ip}:{port} unreachable after {elapsed:?} ({e})" + ); + last = Some(connect_err_message(what, ip, port, e, Some(elapsed))); + } + } + } + Err(AppError::VisionUnreachable(last.unwrap_or_else(|| { + format!("{what}: the Vision Pro advertised no usable address") + }))) +} + +/// A Vision Pro discovered over mDNS. +#[derive(Clone, Debug)] +pub struct Discovered { + pub name: String, + /// Preferred address (first entry of `ips`). + pub ip: String, + /// Every advertised IPv4, routable ones first. A headset often advertises more + /// than one and only some are reachable from this Mac, so connections walk this + /// list rather than trusting a single address. + pub ips: Vec, + /// The manual-pairing port, present only when the manual-pairing service is being + /// advertised (i.e. the device is not yet paired to us and CAN be paired now). A + /// paired VP has `None` here — it's reached via the stored pairing over the RSD. + pub manual_pairing_port: Option, +} + +impl Discovered { + /// A stable synthetic device id derived from the canonical name, kept in a high + /// range so it never collides with usbmux's small integer device ids. Using the + /// canonical form keeps the id stable across the manual-pairing (friendly name) + /// and remotepairing (hostname) views of the same device. + pub fn synthetic_id(&self) -> u32 { + let mut h = std::collections::hash_map::DefaultHasher::new(); + canonical(&self.name).hash(&mut h); + 0x5100_0000u32 | (h.finish() as u32 & 0x00FF_FFFF) + } +} + +/// Canonical device identity: lowercase, alphanumerics only. This makes the two mDNS +/// views of the same Vision Pro agree — the manual-pairing service reports the friendly +/// name ("Sam's Apple Vision Pro") while the remotepairing service reports the +/// hostname ("Sams-AppleVisionPro"); both canonicalize to "samsapplevisionpro". +fn canonical(name: &str) -> String { + name.chars() + .filter(|c| c.is_ascii_alphanumeric()) + .flat_map(|c| c.to_lowercase()) + .collect() +} + +/// The pairing-storage key under which a Vision Pro's RP pairing file is cached. Keyed +/// on the canonical name so a paired VP (discovered by hostname) resolves to the same +/// key it was stored under at pair time (discovered by friendly name). +pub fn pairing_storage_key(name: &str) -> String { + format!("vision_pairing_{}", canonical(name)) +} + +/// Friendly instance name from a manual-pairing fullname +/// ("Sam's Apple Vision Pro._remotepairing-manual-pairing._tcp.local." → the name). +/// (mdns_sd only — dns_sd browse callbacks already carry the unescaped instance name.) +#[cfg(not(target_os = "macos"))] +fn instance_name(fullname: &str) -> String { + fullname + .split("._remotepairing") + .next() + .unwrap_or("Vision Pro") + .replace('\u{a0}', " ") + .to_string() +} + +/// The device label from an mDNS hostname ("Sams-AppleVisionPro.local." → +/// "Sams AppleVisionPro"). Used for the remotepairing view, which carries only a +/// UUID instance name, not the friendly name. +fn hostname_label(hostname: &str) -> String { + hostname + .trim_end_matches('.') + .strip_suffix(".local") + .unwrap_or(hostname) + .replace(['-', '_'], " ") +} + +/// True if a hostname looks like a Vision Pro (so we don't list iPhones, which also +/// advertise `_remotepairing._tcp` and are handled over usbmux). Hostname shape +/// depends on the device name — a default-named headset is "Apple-Vision-Pro.local.", +/// a custom-named one e.g. "Sams-AppleVisionPro.local." — so compare on +/// alphanumerics only, or the hyphenated default slips through the filter. +fn is_vision_hostname(hostname: &str) -> bool { + let h = canonical(hostname); + h.contains("visionpro") || h.contains("realitydevice") +} + +/// A persistent mDNS browser. One long-lived `ServiceDaemon` browses BOTH the +/// manual-pairing service (unpaired VPs — pops the code) and the remotepairing service +/// (already-paired VPs), for the app's whole lifetime, keeping the current Vision Pro +/// set up to date. +/// +/// Persistent (not one-shot) browsing matters twice over: a cold one-shot browse races +/// the multicast round-trip and returns nothing on first launch, and — since a VP stops +/// advertising manual-pairing once paired — only continuous browsing of BOTH services +/// keeps a paired VP visible so users can reinstall. +struct VisionBrowser { + devices: Arc>>, + // Kept alive for the process lifetime; `None` if mDNS was unavailable, in which + // case `devices` simply stays empty (no Vision Pros) rather than erroring. On + // macOS the browse tasks are detached onto the async runtime instead. + #[cfg(not(target_os = "macos"))] + _daemon: Option, +} + +impl VisionBrowser { + fn snapshot(&self) -> Vec { + self.devices.lock().unwrap().values().cloned().collect() + } + + /// The named device if known, else any discovered device (single-VP convenience). + fn get(&self, name: &str) -> Option { + let key = canonical(name); + let guard = self.devices.lock().unwrap(); + guard + .get(&key) + .cloned() + .or_else(|| guard.values().next().cloned()) + } +} + +static BROWSER: OnceLock = OnceLock::new(); + +/// Human-readable reason Vision Pro discovery couldn't start — on macOS a rejected +/// Bonjour browse (most notably kDNSServiceErr_PolicyDenied when Local Network +/// permission is off), elsewhere an mDNS stack that failed to bind/join multicast. +/// `None` while discovery is healthy. Surfaced to the UI's empty-list hint via the +/// `vision_discovery_error` command. Previously this failure was swallowed +/// by `ServiceDaemon::new().ok()`, so a blocked mDNS looked identical to "no device". +static DISCOVERY_ERROR: Mutex> = Mutex::new(None); + +fn set_discovery_error(msg: Option) { + if let Ok(mut guard) = DISCOVERY_ERROR.lock() { + *guard = msg; + } +} + +/// The current discovery-startup error, if any. `None` means discovery is running (a +/// device simply may not be present/reachable yet) — callers must not read `None` as +/// "a device exists". +pub fn discovery_error() -> Option { + DISCOVERY_ERROR.lock().ok().and_then(|guard| guard.clone()) +} + +/// Frontend-facing: the Vision Pro discovery-startup error, if discovery couldn't start +/// (mDNS blocked / Local Network permission denied). `None` = discovery is running. +/// The device list's empty state uses this to explain *why* nothing showed up. +#[tauri::command] +pub fn vision_discovery_error() -> Option { + discovery_error() +} + +fn browser() -> &'static VisionBrowser { + BROWSER.get_or_init(new_browser) +} + +/// macOS: browse through the system Bonjour daemon — see the `bonjour` module for why +/// raw multicast is a trap here. +#[cfg(target_os = "macos")] +fn new_browser() -> VisionBrowser { + let devices: Arc>> = Arc::new(Mutex::new(HashMap::new())); + let by_fullname: Arc>> = Arc::new(Mutex::new(HashMap::new())); + set_discovery_error(None); + bonjour::start(devices.clone(), by_fullname); + VisionBrowser { devices } +} + +/// Non-macOS: mdns_sd's own multicast sockets (no local-network gatekeeper there). +#[cfg(not(target_os = "macos"))] +fn new_browser() -> VisionBrowser { + { + let daemon = match ServiceDaemon::new() { + Ok(daemon) => { + set_discovery_error(None); + Some(daemon) + } + Err(e) => { + tracing::error!( + target: "vision", + "Couldn't start network discovery (mDNS): {e}. Vision Pro discovery is \ + disabled. On macOS this usually means Local Network permission was denied \ + (System Settings ▸ Privacy & Security ▸ Local Network) or a VPN/firewall is \ + blocking multicast." + ); + set_discovery_error(Some(format!("Couldn't start network discovery: {e}"))); + None + } + }; + let devices: Arc>> = Arc::new(Mutex::new(HashMap::new())); + let by_fullname: Arc>> = Arc::new(Mutex::new(HashMap::new())); + + if let Some(daemon) = &daemon { + for service in [MANUAL_PAIRING_SERVICE, REMOTE_PAIRING_SERVICE] { + let recv = match daemon.browse(service) { + Ok(recv) => recv, + Err(e) => { + tracing::error!( + target: "vision", + "Couldn't browse mDNS service {service}: {e}" + ); + set_discovery_error(Some(format!("Couldn't start network discovery: {e}"))); + continue; + } + }; + { + let is_manual = service == MANUAL_PAIRING_SERVICE; + let devices_bg = devices.clone(); + let by_fullname_bg = by_fullname.clone(); + // Blocking receive on a dedicated thread per service: no async + // runtime needed, so this works from setup or from a command. + std::thread::spawn(move || { + while let Ok(event) = recv.recv() { + match event { + ServiceEvent::ServiceResolved(info) => { + if let Some((key, dev)) = parse_resolved(&info, is_manual) { + by_fullname_bg + .lock() + .unwrap() + .insert(info.get_fullname().to_string(), key.clone()); + merge_device(&devices_bg, key, dev); + } + } + ServiceEvent::ServiceRemoved(_, fullname) => { + remove_fullname(&devices_bg, &by_fullname_bg, &fullname); + } + _ => {} + } + } + }); + } + } + } + + // `by_fullname` isn't stored on the struct: the browse threads (which run for + // the process lifetime) hold their own `Arc` clones, so the shared map stays + // alive without the struct holding a reference it never reads. + VisionBrowser { + devices, + _daemon: daemon, + } + } +} + +/// Turn a resolved mDNS record into `(device key, Discovered)`, or `None` if it isn't a +/// Vision Pro we can use. +#[cfg(not(target_os = "macos"))] +fn parse_resolved(info: &ResolvedService, is_manual: bool) -> Option<(String, Discovered)> { + let addr = info.get_addresses_v4().into_iter().next()?; + if is_manual { + // Manual-pairing: friendly name from the fullname, dynamic pairing port. + let name = instance_name(info.get_fullname()); + Some(( + canonical(&name), + Discovered { + name, + ip: addr.to_string(), + ips: vec![addr.to_string()], + manual_pairing_port: Some(info.get_port()), + }, + )) + } else { + // RemotePairing: iPhones advertise it too, so filter to Vision Pro hostnames. + let hostname = info.get_hostname(); + if !is_vision_hostname(hostname) { + return None; + } + let name = hostname_label(hostname); + Some(( + canonical(&name), + Discovered { + name, + ip: addr.to_string(), + ips: vec![addr.to_string()], + manual_pairing_port: None, + }, + )) + } +} + +/// Insert/refresh a device. The remotepairing view (no manual port) must not clobber +/// what the manual-pairing view knows better: the pairable port, and the real +/// friendly name — the remotepairing record only carries a hostname-derived label +/// ("Sams AppleVisionPro" for a headset named "Sam's Apple Vision Pro"), so without +/// this the displayed name flip-flops with whichever record resolved last. +/// +/// Addresses are UNIONED, not replaced: each view (and each re-announcement) resolves +/// a partial, point-in-time set, and whichever resolves last would otherwise wipe out +/// the other's — including the one routable address, when a later resolve caught only +/// the 169.254.x link-local. Fresh addresses are kept ahead of remembered ones within +/// the routable/link-local sort, so after a DHCP move the new address is tried first +/// and stale ones drift to the tail (and off the end of the cap). +fn merge_device(devices: &Arc>>, key: String, mut dev: Discovered) { + let mut guard = devices.lock().unwrap(); + if let Some(existing) = guard.get(&key) { + if dev.manual_pairing_port.is_none() { + dev.manual_pairing_port = existing.manual_pairing_port; + dev.name = existing.name.clone(); + } + for ip in &existing.ips { + if !dev.ips.contains(ip) { + dev.ips.push(ip.clone()); + } + } + dev.ips.sort_by_key(|i| i.starts_with("169.254.")); + dev.ips.truncate(8); + if let Some(first) = dev.ips.first() { + dev.ip = first.clone(); + } + } + guard.insert(key, dev); +} + +/// A service instance went away: drop its record, and drop the device itself only if +/// no other record still references it (a VP is often visible via two services, or +/// the same service on several interfaces). +fn remove_fullname( + devices: &Arc>>, + by_fullname: &Arc>>, + fullname: &str, +) { + let key = by_fullname.lock().unwrap().remove(fullname); + if let Some(key) = key { + let still_present = by_fullname.lock().unwrap().values().any(|k| *k == key); + if !still_present { + devices.lock().unwrap().remove(&key); + } else if fullname.contains("_remotepairing-manual-pairing._tcp") { + // The headset withdrew its manual-pairing service but is still visible via + // remotepairing, so the device stays — but its pairing port MUST NOT. A + // headset re-advertises constantly with a NEW port each time (a field log + // showed 64421→64429 within 20 minutes), so a retained port is a port + // nothing is listening on: connecting to it fails, often as "no route to + // host" when the headset is also dozing. Better to report it as not + // currently pairable and wait for the next announcement. + if let Some(dev) = devices.lock().unwrap().get_mut(&key) { + dev.manual_pairing_port = None; + } + } + } +} + +/// System-Bonjour (dns_sd → mDNSResponder) discovery backend, macOS only. +/// +/// On macOS 15+ the Local Network privacy layer decides per-app whether local traffic +/// is allowed — and for an app doing its own multicast (mdns_sd) the OS frequently +/// fails to attribute the traffic to the app. When that happens there is no permission +/// prompt, the app never appears in System Settings ▸ Privacy & Security ▸ Local +/// Network, and the packets are silently dropped: user logs show mdns_sd joining its +/// multicast groups cleanly and then receiving nothing, ever. (Dev runs never hit this +/// because terminal-spawned processes get automatic local-network access — TN3179.) +/// +/// Browsing through mDNSResponder instead is Apple's designed path: the daemon does +/// the multicast, the browse is attributed to us, macOS reliably shows the prompt and +/// lists iloader in the Settings pane, and interface churn / sleep-wake are the +/// daemon's problem, not ours. +#[cfg(target_os = "macos")] +mod bonjour { + use super::*; + use async_dnssd::{BrowseResult, BrowsedFlags, ScopedSocketAddr}; + use futures::StreamExt; + + /// Budget for turning one browse announcement into a connectable (host, port, + /// IPv4). Generous — a re-announcement retriggers resolution anyway. + const RESOLVE_BUDGET: Duration = Duration::from_secs(10); + + /// Once a routable address is in hand, how long to keep waiting for siblings. + /// The address stream never ends on its own, so without a cutoff a headset + /// advertising a single address would stall until the full budget expired. + const ADDR_COLLECT_GRACE: Duration = Duration::from_millis(400); + + /// How long to keep waiting when everything so far is link-local (169.254.x). + /// Those are usually unreachable from the Mac's Wi-Fi, and the resolver often + /// reports them first, so it's worth waiting a bit longer for a routable one. + const ADDR_LINK_LOCAL_WAIT: Duration = Duration::from_millis(2500); + + pub(super) fn start( + devices: Arc>>, + by_fullname: Arc>>, + ) { + for service in [MANUAL_PAIRING_SERVICE, REMOTE_PAIRING_SERVICE] { + // dns_sd takes the bare reg type; the ".local." domain is implied. + let reg_type = service.strip_suffix(".local.").unwrap_or(service); + let is_manual = service == MANUAL_PAIRING_SERVICE; + let devices = devices.clone(); + let by_fullname = by_fullname.clone(); + tauri::async_runtime::spawn(async move { + browse_loop(reg_type, is_manual, devices, by_fullname).await; + }); + } + } + + /// Browse one service type forever. The stream only terminates on error (e.g. + /// mDNSResponder restarted, or the browse was rejected), so on termination we + /// surface the error and retry on a slow cadence — cheap, and it recovers a + /// just-granted Local Network permission without relaunching. + async fn browse_loop( + reg_type: &'static str, + is_manual: bool, + devices: Arc>>, + by_fullname: Arc>>, + ) { + loop { + let mut browse = async_dnssd::browse(reg_type); + // A rejected browse (PolicyDenied etc.) errors within moments; one that + // survives its first seconds was accepted, so a stale startup error (e.g. + // permission was off, user just switched it on) no longer applies — + // clear it even before any device announces itself. + let mut accepted = false; + loop { + let event = if accepted { + browse.next().await + } else { + tokio::select! { + event = browse.next() => event, + _ = tokio::time::sleep(Duration::from_secs(3)) => { + accepted = true; + set_discovery_error(None); + continue; + } + } + }; + let Some(event) = event else { break }; + let event = match event { + Ok(event) => event, + Err(e) => { + tracing::error!( + target: "vision", + "Bonjour browse for {reg_type} failed: {e}" + ); + set_discovery_error(Some(explain_browse_error(&e))); + break; + } + }; + // Events are flowing, so discovery is demonstrably working. + set_discovery_error(None); + let fullname = + format!("{}.{}{}", event.service_name, event.reg_type, event.domain); + if event.flags.contains(BrowsedFlags::ADD) { + let devices = devices.clone(); + let by_fullname = by_fullname.clone(); + // Resolve in its own task so a slow SRV/address lookup doesn't + // stall the browse stream (and other devices' events). + tauri::async_runtime::spawn(async move { + resolve_and_track(&event, is_manual, fullname, devices, by_fullname).await; + }); + } else { + tracing::debug!(target: "vision", "Bonjour: {fullname} went away"); + remove_fullname(&devices, &by_fullname, &fullname); + } + } + tokio::time::sleep(Duration::from_secs(15)).await; + } + } + + /// Resolve one browse Add and keep the device's address list current — the dns_sd + /// analogue of `parse_resolved`, plus late-address tracking. + /// + /// Collect EVERY advertised IPv4, not just the first. A headset commonly + /// advertises several — e.g. its Wi-Fi address plus a 169.254.x link-local from + /// another link — and the first one out of the resolver is often not the one + /// this Mac can route to, which surfaces to the user as "No route to host". + /// + /// The address stream stays open indefinitely (it reports future changes too), + /// so this must never be wrapped in a single timeout around the whole loop: + /// doing that threw away every address collected so far when the budget + /// expired, which silently dropped any headset advertising just one address. + /// + /// The device is published once the list has briefly settled, but the stream is + /// then drained for the REST of the budget, folding late arrivals into the map: + /// the resolver has been seen reporting only the link-local within any reasonable + /// settle window, and a device stuck on 169.254.x until the next re-announcement + /// (30-60s away) is exactly the "No route to host" failure again. + async fn resolve_and_track( + event: &BrowseResult, + is_manual: bool, + fullname: String, + devices: Arc>>, + by_fullname: Arc>>, + ) { + let Ok(Some(Ok(resolved))) = + tokio::time::timeout(RESOLVE_BUDGET, event.resolve().next()).await + else { + tracing::debug!( + target: "vision", + "Bonjour: couldn't resolve {} (device gone or asleep?)", + event.service_name + ); + return; + }; + // remotepairing is advertised by iPhones too (they're handled over usbmux); + // only the hostname says which kind of device this is. + if !is_manual && !is_vision_hostname(&resolved.host_target) { + tracing::debug!( + target: "vision", + "Bonjour: {} is {} — not a Vision Pro, ignoring", + event.service_name, + resolved.host_target + ); + return; + } + let name = if is_manual { + event.service_name.replace('\u{a0}', " ") + } else { + hostname_label(&resolved.host_target) + }; + let key = canonical(&name); + let port = resolved.port; + + let publish = |ips: &[String], first_publish: bool| { + let mut ips = ips.to_vec(); + // Try routable addresses before link-local ones. + ips.sort_by_key(|i| i.starts_with("169.254.")); + let Some(ip) = ips.first().cloned() else { return }; + if first_publish { + tracing::info!( + target: "vision", + "Bonjour: {} at {} (manual pairing port: {:?})", + name, + ip, + is_manual.then_some(port) + ); + } + by_fullname + .lock() + .unwrap() + .insert(fullname.clone(), key.clone()); + merge_device( + &devices, + key.clone(), + Discovered { + name: name.clone(), + ip, + ips, + manual_pairing_port: is_manual.then_some(port), + }, + ); + }; + + let mut addrs = resolved.resolve_socket_address(); + let mut ips: Vec = Vec::new(); + let mut published = false; + let hard_deadline = tokio::time::Instant::now() + RESOLVE_BUDGET; + // First publish waits for the list to settle briefly once something routable + // is in hand, longer while everything so far is link-local. + let mut publish_at: Option = None; + loop { + let now = tokio::time::Instant::now(); + if now >= hard_deadline { + break; + } + if !published && publish_at.is_some_and(|p| now >= p) { + publish(&ips, true); + published = true; + } + let until = if published { + hard_deadline + } else { + publish_at.map_or(hard_deadline, |p| p.min(hard_deadline)) + }; + match tokio::time::timeout(until - now, addrs.next()).await { + Ok(Some(Ok(addr))) => { + if let ScopedSocketAddr::V4 { address, .. } = addr.address { + let s = address.to_string(); + if ips.contains(&s) { + continue; + } + ips.push(s.clone()); + if published { + // A Remove may have raced us; folding an address in then + // would resurrect the withdrawn record — and its dead + // manual-pairing port. Only update while it's still live. + if by_fullname.lock().unwrap().contains_key(&fullname) { + tracing::info!( + target: "vision", + "Bonjour: {name} gained address {s}" + ); + publish(&ips, false); + } + } else { + let have_routable = ips.iter().any(|i| !i.starts_with("169.254.")); + let wait = if have_routable { + ADDR_COLLECT_GRACE + } else { + ADDR_LINK_LOCAL_WAIT + }; + publish_at = Some(tokio::time::Instant::now() + wait); + } + } + } + Ok(Some(Err(_))) => continue, + // Stream ended: use what we have. + Ok(None) => break, + // A deadline passed; the loop top decides whether it was the publish + // point or the end of the budget. + Err(_) => continue, + } + } + if !published { + publish(&ips, true); + } + } + + /// A denied Local Network permission surfaces as dns_sd error -65570 + /// (kDNSServiceErr_PolicyDenied; async-dnssd's older tables call it + /// "ConnectionPending"). Translate that to something a user can act on. + fn explain_browse_error(e: &std::io::Error) -> String { + let raw = e.to_string(); + let lower = raw.to_lowercase(); + if raw.contains("65570") || lower.contains("policy") || lower.contains("connection pending") + { + "Local Network permission is off for iloader — enable it in System Settings ▸ \ + Privacy & Security ▸ Local Network, then try again." + .to_string() + } else { + format!("Couldn't start network discovery: {raw}") + } + } +} + +/// Start the persistent Vision Pro browser at app launch so the multicast group is +/// warm well before the first device-list refresh. Idempotent. +pub fn start_discovery() { + // Logs a warning once if this build is ad-hoc signed (a from-source build that + // macOS will refuse local-network access) so the condition is visible in every + // log, not just after a failed connect. + #[cfg(target_os = "macos")] + let _ = is_adhoc_build(); + // App-originated local traffic registers iloader in the Local Network pane and + // triggers the permission prompt (daemon-mediated browsing doesn't, on machines + // where registration is broken) — and the verdict makes the filter state visible + // in every log from launch. + #[cfg(target_os = "macos")] + match local_network_probe() { + None => tracing::info!(target: "vision", "local-network probe: send OK"), + Some(e) => tracing::warn!( + target: "vision", + "local-network probe: REFUSED ({e}) — macOS's Local Network filter is \ + blocking iloader's own traffic (discovery may still work; it runs via \ + the system's mDNSResponder)" + ), + } + let _ = browser(); +} + +/// Poll the persistent browser's warm set until it's non-empty or the (short) budget +/// runs out. Warm calls return immediately; only a cold first call after launch waits. +async fn snapshot_soon() -> Vec { + let b = browser(); + let mut snap = b.snapshot(); + for _ in 0..16 { + if !snap.is_empty() { + break; + } + tokio::time::sleep(Duration::from_millis(150)).await; + snap = b.snapshot(); + } + snap +} + +/// The addresses to dial for the named headset: everything discovery currently knows +/// (routable first), with `fallback_ip` — whatever the caller captured earlier — +/// appended as a last resort. Connect-time callers must use this rather than a cached +/// single address: a headset advertises several addresses and only some are routable, +/// its address moves with DHCP, and the cached one is often the 169.254.x link-local. +pub fn live_ips(name: &str, fallback_ip: &str) -> Vec { + let mut ips = browser().get(name).map(|d| d.ips).unwrap_or_default(); + if !ips.iter().any(|i| i == fallback_ip) { + ips.push(fallback_ip.to_string()); + } + ips +} + +/// A live tunnel to a paired Vision Pro, over which RSD services can be opened. +pub struct VisionSession { + pub adapter: AdapterHandle, + pub handshake: RsdHandshake, +} + +impl VisionSession { + /// Establish the tunnel using the RP pairing file bytes, trying every address the + /// headset advertises. + pub async fn connect_any(ips: &[String], pairing: &[u8]) -> Result { + let mut pf = RpPairingFile::from_bytes(pairing) + .map_err(|e| AppError::RemotePairing(format!("Invalid pairing file: {e:?}")))?; + + // 1. pair-verify on the RSD to derive the tunnel key. + let (s1, ip) = connect_any(ips, RSD_PORT, "Couldn't reach the Vision Pro (RSD)").await?; + let ip = ip.as_str(); + let mut client = RemotePairingClient::new(RpPairingSocket::new(s1), "iloader"); + client + .connect(&mut pf, || async { "000000".to_string() }) + .await + .map_err(|e| match e { + // The socket died mid-exchange — a dozing headset, not a verdict on + // the pairing. + idevice::IdeviceError::Socket(io) => { + connect_err("Lost the Vision Pro during pair-verify", ip, RSD_PORT, io) + } + e => AppError::VisionPairingRejected(format!( + "pair-verify failed (is the Vision Pro still paired?): {e:?}" + )), + })?; + let key = client.encryption_key().to_vec(); + + // 2. ask the device to open a tunnel listener, connect + TLS-PSK. + let tport = client + .create_tcp_listener() + .await + .map_err(|e| ap_err("create tunnel listener", e))?; + let started = std::time::Instant::now(); + let s2 = TcpStream::connect((ip, tport)).await.map_err(|e| { + AppError::VisionUnreachable(connect_err_message( + "Couldn't open the tunnel to the Vision Pro", + ip, + tport, + e, + Some(started.elapsed()), + )) + })?; + tracing::info!( + target: "vision", + "tunnel: TCP connected to {ip}:{tport} in {:?}", + started.elapsed() + ); + let tunnel = connect_tls_psk_tunnel_native(s2, &key) + .await + .map_err(|e| ap_err("TLS-PSK tunnel", e))?; + let info = tunnel.info.clone(); + + // 3. software TCP stack over the tunnel. + let our_ip = info + .client_address + .parse() + .map_err(|e| ap_err("client ip", e))?; + let their_ip = info + .server_address + .parse() + .map_err(|e| ap_err("server ip", e))?; + let mut adapter = Adapter::new(Box::new(tunnel.into_inner()), our_ip, their_ip); + adapter.set_mss((info.mtu as usize).saturating_sub(60)); + let mut adapter = adapter.to_async_handle(); + + // 4. RSD handshake -> service map. + let rsd_stream = adapter + .connect(info.server_rsd_port) + .await + .map_err(|e| ap_err("connect RSD over tunnel", e))?; + let handshake = RsdHandshake::new(rsd_stream) + .await + .map_err(|e| ap_err("RSD handshake", e))?; + + Ok(Self { adapter, handshake }) + } + + /// Open an RSD service (e.g. `InstallationProxyClient`, `AfcClient`) over the tunnel. + pub async fn service(&mut self) -> Result { + S::connect_rsd(&mut self.adapter, &mut self.handshake) + .await + .map_err(|e| ap_err(&format!("connect RSD service {}", S::rsd_service_name()), e)) + } + + /// The device UDID, read from the RSD handshake properties. + pub fn udid(&self) -> Result { + self.handshake + .properties + .get("UniqueDeviceID") + .and_then(|v| v.as_string()) + .map(str::to_string) + .ok_or_else(|| { + AppError::RemotePairing("could not read the device UDID over RSD".into()) + }) + } +} + +/// Open a short-lived tunnel just to read the device UDID (needed to register the VP +/// with the developer account before signing). Kept separate so it can be dropped +/// before the network-bound signing that follows. +pub async fn read_udid_any(ips: &[String], pairing: &[u8]) -> Result { + VisionSession::connect_any(ips, pairing).await?.udid() +} + +/// Upload a signed `.app` bundle to PublicStaging over AFC, then install it via +/// installation_proxy — all over the RP tunnel. `progress` receives 0..=100. +pub async fn install_app( + session: &mut VisionSession, + app_path: &Path, + progress: impl Fn(u64), +) -> Result<(), AppError> { + let name = app_path + .file_name() + .ok_or_else(|| AppError::RemotePairing("app path has no final component".into()))? + .to_string_lossy() + .to_string(); + let dir = format!("PublicStaging/{name}"); + + // Walk the bundle up front so the upload is a flat list — a mid-bundle AFC + // failure can then retry a single file on a fresh AFC connection without + // re-walking (or re-uploading) anything already transferred. + let mut dirs = Vec::new(); + let mut files = Vec::new(); + collect_upload_entries(app_path, &dir, &mut dirs, &mut files)?; + + let mut afc = session.service::().await?; + for d in &dirs { + afc.mk_dir(d) + .await + .map_err(|e| ap_err(&format!("AFC mkdir {d}"), e))?; + } + + // Multi-MB uploads over the userspace TCP tunnel occasionally drop the AFC + // stream mid-file (field report: `AFC write …: Socket(NotConnected)` installing a + // large app). The tunnel itself usually survives its streams, so retry the file + // on a freshly opened AFC connection before giving up; if the tunnel really died, + // reopening the service fails and that error is surfaced instead. + for (local, remote) in &files { + let mut attempts = 0; + loop { + match afc_upload_file(&mut afc, local, remote).await { + Ok(()) => break, + Err(e) if attempts < 2 => { + attempts += 1; + tracing::warn!( + target: "vision", + "AFC upload of {remote} failed ({e}); reconnecting AFC and retrying \ + ({attempts}/2)" + ); + tokio::time::sleep(Duration::from_millis(750)).await; + afc = session.service::().await?; + } + Err(e) => return Err(e), + } + } + } + + let mut inst = session.service::().await?; + + let mut options = plist::Dictionary::new(); + options.insert("PackageType".into(), plist::Value::String("Developer".into())); + + inst.install_with_callback( + dir, + Some(plist::Value::Dictionary(options)), + async |(percentage, _)| { + progress(percentage); + }, + (), + ) + .await + .map_err(|e| ap_err("installation_proxy install failed", e))?; + + Ok(()) +} + +/// Walk `path` recursively, listing every directory (parents before children, so they +/// can be mk_dir'd in order) and every file with its destination AFC path. +fn collect_upload_entries( + path: &Path, + afc_path: &str, + dirs: &mut Vec, + files: &mut Vec<(PathBuf, String)>, +) -> Result<(), AppError> { + dirs.push(afc_path.to_string()); + for entry in std::fs::read_dir(path) + .map_err(|e| AppError::Filesystem(format!("read_dir {path:?}"), e.to_string()))? + { + let entry = + entry.map_err(|e| AppError::Filesystem("read dir entry".into(), e.to_string()))?; + let p = entry.path(); + let child_name = p + .file_name() + .ok_or_else(|| AppError::Filesystem("dir entry has no name".into(), String::new()))? + .to_string_lossy() + .to_string(); + let child_afc = format!("{afc_path}/{child_name}"); + if p.is_dir() { + collect_upload_entries(&p, &child_afc, dirs, files)?; + } else { + files.push((p, child_afc)); + } + } + Ok(()) +} + +/// Upload one file to `afc_path`. WrOnly truncates, so a retry after a partial write +/// starts the file over cleanly. +async fn afc_upload_file( + afc: &mut AfcClient, + path: &Path, + afc_path: &str, +) -> Result<(), AppError> { + let bytes = std::fs::read(path) + .map_err(|e| AppError::Filesystem(format!("read {path:?}"), e.to_string()))?; + let mut fh = afc + .open(afc_path.to_string(), AfcFopenMode::WrOnly) + .await + .map_err(|e| ap_err(&format!("AFC open {afc_path}"), e))?; + fh.write_entire(&bytes) + .await + .map_err(|e| ap_err(&format!("AFC write {afc_path}"), e))?; + fh.close().await.map_err(|e| ap_err("AFC close", e))?; + Ok(()) +} + +/// Find an installed app whose bundle id contains `needle` (case-insensitive). +pub async fn find_app(session: &mut VisionSession, needle: &str) -> Result { + let mut inst = session.service::().await?; + let apps = inst + .get_apps(None, None) + .await + .map_err(|e| ap_err("get_apps", e))?; + apps.keys() + .find(|b| b.to_lowercase().contains(&needle.to_lowercase())) + .cloned() + .ok_or_else(|| AppError::RemotePairing(format!("no installed app matching '{needle}'"))) +} + +/// Write pairing bytes into `bundle`'s Documents as `path` (over the tunnel). +pub async fn place_into( + session: &mut VisionSession, + bundle: &str, + path: &str, + pairing: &[u8], +) -> Result<(), AppError> { + let ha = session.service::().await?; + let mut afc = ha + .vend_documents(bundle.to_string()) + .await + .map_err(|e| ap_err(&format!("vend documents for {bundle}"), e))?; + + // vend_documents roots AFC at the app CONTAINER, but only /Documents is + // accessible (HouseArrest semantics), so write there. Create any intermediate + // directories one level at a time — AFC mk_dir isn't recursive (LiveContainer's + // pairing path, SideStore/Documents/…, is nested). + if let Some((dir, _)) = path.rsplit_once('/') { + let mut cur = String::from("/Documents"); + for comp in dir.split('/').filter(|c| !c.is_empty()) { + cur.push('/'); + cur.push_str(comp); + let _ = afc.mk_dir(cur.clone()).await; + } + } + let mut fh = afc + .open(format!("/Documents/{path}"), AfcFopenMode::WrOnly) + .await + .map_err(|e| ap_err(&format!("open /Documents/{path}"), e))?; + fh.write_entire(pairing) + .await + .map_err(|e| ap_err("write pairing", e))?; + fh.close().await.map_err(|e| ap_err("close", e))?; + Ok(()) +} + +// --------------------------------------------------------------------------- +// Device-list integration + first-time pairing (the Tauri-facing surface). +// --------------------------------------------------------------------------- + +/// Discover Vision Pro(s) over mDNS and present them as `DeviceInfo` for the unified +/// device list. `paired` reflects whether a reusable RP pairing file is already +/// stored, so the frontend can select the device directly instead of prompting for +/// the headset code. Never errors — a discovery failure just yields an empty list so +/// it can't hide usbmux devices. +pub async fn list_vision_devices(app: &AppHandle) -> Vec { + let discovered = snapshot_soon().await; + discovered + .into_iter() + .map(|d| { + let paired = retrieve_pairing_data(app, &pairing_storage_key(&d.name)) + .ok() + .flatten() + .is_some(); + DeviceInfo { + id: d.synthetic_id(), + name: d.name, + udid: String::new(), + connection_type: "Wireless".to_string(), + version: "visionOS".to_string(), + device_class: Some("RealityDevice".to_string()), + product_type: None, + transport: DeviceTransport::Vision, + ip: Some(d.ip), + paired, + } + }) + .collect() +} + +/// Select an already-paired Vision Pro: load its stored pairing file and verify it by +/// opening a tunnel (which also yields the device UDID). Returns `(pairing, udid)`. +pub async fn select_vision_device( + app: &AppHandle, + device: &DeviceInfo, +) -> Result<(Vec, String), AppError> { + let ip = device + .ip + .clone() + .ok_or_else(|| AppError::RemotePairing("Vision Pro has no IP address".into()))?; + let key = pairing_storage_key(&device.name); + let pairing = retrieve_pairing_data(app, &key)?.ok_or_else(|| { + AppError::RemotePairing( + "This Vision Pro isn't paired yet — enter the code shown on the headset to pair.".into(), + ) + })?; + // Prefer the freshly-advertised addresses over whatever the frontend passed — + // a headset's address changes with DHCP, and the list may hold several. + let ips = live_ips(&device.name, &ip); + + // Verify the stored pairing by opening a tunnel. + match read_udid_any(&ips, &pairing).await { + Ok(udid) => Ok((pairing, udid)), + // The headset answered and explicitly rejected the stored pairing during + // pair-verify (e.g. this Mac was removed from its Remote Devices) — the one + // case that proves the pairing is stale. Drop it so the frontend re-prompts + // for the code. + Err(AppError::VisionPairingRejected(e)) => { + let _ = delete_pairing_data(app, &key); + Err(AppError::RemotePairing(format!( + "This Vision Pro's saved pairing is no longer valid — pair again with the code on \ + the headset. ({e})" + ))) + } + // Anything else — unreachable, a tunnel-setup hiccup, an RSD failure — says + // nothing about the pairing's validity. KEEP it: discarding a good pairing + // here used to force users into a needless re-pair (which then fails too, if + // the headset is simply asleep). + Err(e) => Err(e), + } +} + +/// First-time wireless pairing with a Vision Pro. Connects to its manual-pairing +/// service; the headset shows a 6-digit code which the user types into iloader (sent +/// back via the `vision-pair-code` event). On success the reusable RP pairing file is +/// stored and the device becomes the selected device. +/// +/// Emits `vision-pair-status`: "connecting" | "awaiting-code" | "verifying" | "paired". +#[tauri::command] +pub async fn vision_pair( + app: AppHandle, + window: Window, + device_state: State<'_, DeviceInfoMutex>, + cancel_state: State<'_, PairingCancelToken>, + device: DeviceInfo, +) -> Result<(), AppError> { + let token = CancellationToken::new(); + { + let mut guard = cancel_state.lock().unwrap(); + if let Some(old) = guard.replace(token.clone()) { + old.cancel(); + } + } + + let result = vision_pair_inner(&app, &window, &device, token.clone()).await; + + { + // Single-flight modal flow: clear our cancel token when we're done. + let mut guard = cancel_state.lock().unwrap(); + *guard = None; + } + + let (pairing, udid) = result?; + + let mut info = device; + info.udid = udid; + info.paired = true; + { + let mut ds = device_state.lock().unwrap(); + *ds = Some(DeviceInfoWithPairing { info, pairing }); + } + let _ = window.emit("vision-pair-status", "paired"); + Ok(()) +} + +async fn vision_pair_inner( + app: &AppHandle, + window: &Window, + device: &DeviceInfo, + cancel: CancellationToken, +) -> Result<(Vec, String), AppError> { + let _ = window.emit("vision-pair-status", "connecting"); + + // Resolve the CURRENT (dynamic) manual-pairing port from the persistent browser, + // matching this VP by name. The port is re-announced as the device advertises, so + // the warm set tracks it; we wait briefly only if nothing's cached yet. + // Wait for a CURRENTLY-advertised manual-pairing port, not merely for the device + // to be known. The headset withdraws and re-announces this service every ~30-60s + // with a fresh port, so it is routinely absent for a few seconds at a time; only + // a port from a live announcement is connectable. + // Timestamped so user logs pin down when the user actually clicked — without + // this, dead time between discovery and the first connect is unattributable + // (a field log's "29 missing seconds" turned out to be time-to-click). + tracing::info!(target: "vision", "pairing requested for {}", device.name); + let wait_started = std::time::Instant::now(); + let b = browser(); + let mut dev = None; + let mut seen_without_port = false; + for poll in 0..PAIRABLE_WAIT_POLLS { + if let Some(d) = b.get(&device.name) { + if d.manual_pairing_port.is_some() { + if poll > 0 { + tracing::info!( + target: "vision", + "live manual-pairing announcement (port {:?}) arrived after {:?}", + d.manual_pairing_port, + wait_started.elapsed() + ); + } + dev = Some(d); + break; + } + seen_without_port = true; + } + if poll == 0 { + tracing::info!( + target: "vision", + "no live manual-pairing announcement yet; waiting (the headset re-announces \ + every ~30-60s)" + ); + } + tokio::select! { + _ = cancel.cancelled() => return Err(AppError::Canceled("Wireless pairing".into())), + _ = tokio::time::sleep(Duration::from_millis(250)) => {} + } + } + + let Some(dev) = dev else { + // Distinguish "we can't see it at all" from "we see it but it never offers + // pairing" — an existing host pairing (iloader's, Xcode's, another Mac's) + // suppresses the manual-pairing service entirely. + return Err(AppError::RemotePairing(if seen_without_port { + "This Vision Pro already holds a host pairing (possibly Xcode's), so it isn't \ + accepting new pairing requests. To pair iloader: on the headset, open Settings → \ + General → Remote Devices, remove the existing entry, then try again. An Xcode \ + pairing removed this way can simply be re-paired afterwards — iloader's and \ + Xcode's pairings coexist once both are set up. Tip: if this Mac is already \ + paired via Xcode, the headset may also appear in the device list as a \ + \"Network\" device, which you can sideload to directly without pairing iloader." + .into() + } else { + "Couldn't find the Vision Pro's pairing service over Wi-Fi. Make sure it's on the \ + same network, awake (put the headset on), and that Developer Mode is on." + .into() + })); + }; + let port = dev + .manual_pairing_port + .expect("loop only exits with a device that has a port"); + + // Pair, with one silent retry for failures that happen BEFORE the user was asked + // for a code. If the headset's code screen isn't up when the session starts, the + // first session can be a dud (and was guaranteed to be, before the vendored + // idevice fix for `awaitingUserConsent`); the user can't have mistyped anything + // yet, so retrying transparently beats surfacing an error. + let mut attempt = 0; + let pf = loop { + // Re-resolve address/port from the live browser each attempt — the + // manual-pairing port is dynamic and can rotate when the session cycles. + let (ips, port) = match b.get(&device.name) { + Some(d) if d.manual_pairing_port.is_some() => { + (d.ips.clone(), d.manual_pairing_port.unwrap()) + } + _ => (dev.ips.clone(), port), + }; + tracing::info!( + target: "vision", + "pairing attempt {}: dialing {ips:?} port {port}", + attempt + 1 + ); + + let code_requested = Arc::new(AtomicBool::new(false)); + + // Walk every advertised address — only some are routable from this Mac. + let (stream, _reached_ip) = tokio::select! { + _ = cancel.cancelled() => return Err(AppError::Canceled("Wireless pairing".into())), + res = connect_any(&ips, port, "Couldn't reach the Vision Pro to pair") => res?, + }; + + let mut client = RemotePairingClient::new(RpPairingSocket::new(stream), "iloader"); + let mut pf = RpPairingFile::generate("iloader"); + + // The device shows the code; we prompt the user for it via the frontend. + // idevice calls this once, after the code is on the headset. + let win = window.clone(); + let requested = code_requested.clone(); + let code_cb = move || { + let win = win.clone(); + requested.store(true, Ordering::SeqCst); + async move { + let (tx, rx) = tokio::sync::oneshot::channel::(); + let tx = std::sync::Mutex::new(Some(tx)); + let handler = win.listen("vision-pair-code", move |event| { + if let Ok(mut guard) = tx.lock() + && let Some(sender) = guard.take() + { + let raw = event.payload(); + let code = + serde_json::from_str::(raw).unwrap_or_else(|_| raw.to_string()); + let _ = sender.send(code); + } + }); + let _ = win.emit("vision-pair-status", "awaiting-code"); + let code = rx.await.unwrap_or_default(); + win.unlisten(handler); + code.chars().filter(|c| c.is_ascii_digit()).take(6).collect::() + } + }; + + let result = tokio::select! { + _ = cancel.cancelled() => return Err(AppError::Canceled("Wireless pairing".into())), + res = client.connect(&mut pf, code_cb) => res, + }; + + match result { + Ok(()) => break pf, + Err(e) => { + if attempt == 0 && !code_requested.load(Ordering::SeqCst) { + tracing::warn!( + target: "vision", + "first pairing attempt failed before any code prompt ({e:?}); retrying" + ); + attempt += 1; + tokio::time::sleep(Duration::from_millis(750)).await; + continue; + } + return Err(AppError::RemotePairing(format!( + "Pairing failed: {e:?}\n(If it mentions 'missing server proof', the code was \ + mistyped — try pairing again.)" + ))); + } + } + }; + + let bytes = pf.to_bytes(); + store_pairing_data(app, &pairing_storage_key(&device.name), &bytes)?; + + // Verify the saved pairing by opening a tunnel, which also gives us the UDID. A + // failure here doesn't discard the (successful) pairing — sideload re-reads the + // UDID over a fresh tunnel and will surface any real problem then. + let _ = window.emit("vision-pair-status", "verifying"); + let udid = read_udid_any(&dev.ips, &bytes).await.unwrap_or_default(); + + Ok((bytes, udid)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn vision_hostname_filter_accepts_all_headset_name_shapes() { + // Default device name hyphenates: this exact shape was field-reported as + // wrongly filtered out ("not a Vision Pro, ignoring"). + assert!(is_vision_hostname("Apple-Vision-Pro.local.")); + assert!(is_vision_hostname("Apple-Vision-Pro-2.local.")); + // Custom-named headsets concatenate. + assert!(is_vision_hostname("Sams-AppleVisionPro.local.")); + assert!(is_vision_hostname("RealityDevice.local.")); + // iPhones/AppleTVs advertise _remotepairing._tcp too and must stay hidden. + assert!(!is_vision_hostname("Sams-iPhone.local.")); + assert!(!is_vision_hostname("Living-Room-Apple-TV.local.")); + } + + #[test] + fn canonical_unifies_friendly_name_and_hostname() { + assert_eq!( + canonical("Sam’s Apple Vision Pro"), + canonical("Sams-AppleVisionPro") + ); + } + + fn disc(name: &str, ips: &[&str], port: Option) -> Discovered { + Discovered { + name: name.into(), + ip: ips.first().unwrap().to_string(), + ips: ips.iter().map(|s| s.to_string()).collect(), + manual_pairing_port: port, + } + } + + /// The two service views resolve independently and each sees a partial address + /// set; a later resolve carrying only the link-local must not wipe out the + /// routable address the other view found. + #[test] + fn merge_unions_addresses_across_views() { + let devices = Arc::new(Mutex::new(HashMap::new())); + merge_device( + &devices, + "k".into(), + disc("Sam’s Apple Vision Pro", &["192.168.1.5"], Some(1234)), + ); + // remotepairing view resolves later, catching only the link-local. + merge_device( + &devices, + "k".into(), + disc("Sams AppleVisionPro", &["169.254.7.9"], None), + ); + let d = devices.lock().unwrap().get("k").cloned().unwrap(); + assert_eq!(d.ip, "192.168.1.5"); + assert_eq!(d.ips, vec!["192.168.1.5".to_string(), "169.254.7.9".to_string()]); + assert_eq!(d.manual_pairing_port, Some(1234)); + assert_eq!(d.name, "Sam’s Apple Vision Pro"); + } + + /// An instant EHOSTUNREACH is macOS's Local Network filter refusing to send (the + /// stuck-permission quirk); a slow one is an unanswered ARP (asleep headset). The + /// two need opposite advice. + #[test] + fn unreachable_hint_depends_on_how_fast_it_failed() { + let instant = std::io::Error::from_raw_os_error(65); // EHOSTUNREACH + let m = connect_err_message("x", "192.168.50.93", 53231, instant, Some(Duration::from_millis(5))); + assert!(m.contains("Local Network privacy filter"), "{m}"); + let slow = std::io::Error::from_raw_os_error(65); + let m = connect_err_message("x", "192.168.50.93", 53231, slow, Some(Duration::from_secs(4))); + assert!(m.contains("asleep"), "{m}"); + // No timing info (e.g. mid-exchange socket drop): don't guess at policy. + let unknown = std::io::Error::from_raw_os_error(65); + let m = connect_err_message("x", "192.168.50.93", 53231, unknown, None); + assert!(m.contains("asleep"), "{m}"); + } + + /// After a DHCP move the fresh routable address must be tried before the + /// remembered (stale) one. + #[test] + fn merge_prefers_fresh_routable_over_remembered() { + let devices = Arc::new(Mutex::new(HashMap::new())); + merge_device(&devices, "k".into(), disc("VP", &["192.168.1.5"], Some(1))); + merge_device( + &devices, + "k".into(), + disc("VP", &["192.168.1.42", "169.254.7.9"], Some(2)), + ); + let d = devices.lock().unwrap().get("k").cloned().unwrap(); + assert_eq!( + d.ips, + vec![ + "192.168.1.42".to_string(), + "192.168.1.5".to_string(), + "169.254.7.9".to_string() + ] + ); + assert_eq!(d.ip, "192.168.1.42"); + assert_eq!(d.manual_pairing_port, Some(2)); + } +} diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 15022c1..6b06842 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "iloader", - "version": "2.2.6", + "version": "2.2.20", "identifier": "me.nabdev.iloader", "build": { "beforeDevCommand": "bun run dev", diff --git a/src-tauri/vendor/idevice/Cargo.toml b/src-tauri/vendor/idevice/Cargo.toml new file mode 100644 index 0000000..d4178ef --- /dev/null +++ b/src-tauri/vendor/idevice/Cargo.toml @@ -0,0 +1,530 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2024" +name = "idevice" +version = "0.1.65" +authors = ["Jackson Coxson"] +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "A Rust library to interact with services on iOS devices." +documentation = "https://docs.rs/idevice" +readme = "README.md" +keywords = [ + "lockdownd", + "ios", +] +license = "MIT" +repository = "https://github.com/jkcoxson/idevice" +resolver = "2" + +[package.metadata.docs.rs] +all-features = true +rustdoc-args = [ + "--cfg", + "docsrs", +] + +[features] +_reqwest = ["dep:reqwest"] +_serde_json = ["dep:serde_json"] +afc = [ + "dep:chrono", + "dep:futures", +] +amfi = [] +application_listing = ["dvt"] +aws-lc = [ + "rustls", + "rustls/aws-lc-rs", + "tokio-rustls/aws-lc-rs", +] +bt_packet_logger = [] +companion_proxy = [] +condition_inducer = ["dvt"] +core_device = [ + "xpc", + "dep:uuid", + "dep:ns-keyed-archive", +] +core_device_proxy = ["_serde_json"] +crashreportcopymobile = ["afc"] +debug_proxy = [] +default = ["aws-lc"] +device_info = ["dvt"] +diagnostics_relay = [] +display_stream = [ + "core_device", + "rsd", + "dep:flate2", + "dep:futures", + "dep:web-time", +] +dvt = ["dep:ns-keyed-archive"] +energy_monitor = ["dvt"] +full = [ + "afc", + "amfi", + "bt_packet_logger", + "companion_proxy", + "core_device", + "core_device_proxy", + "display_stream", + "crashreportcopymobile", + "debug_proxy", + "diagnostics_relay", + "dvt", + "energy_monitor", + "graphics", + "device_info", + "application_listing", + "condition_inducer", + "network_monitor", + "sysmontap", + "heartbeat", + "house_arrest", + "installation_proxy", + "installcoordination_proxy", + "xctest", + "location_simulation", + "mdns", + "misagent", + "mobile_image_mounter", + "mobileactivationd", + "mobilebackup2", + "wda", + "notification_proxy", + "pair", + "pcapd", + "preboard_service", + "remote_pairing", + "restore", + "restore_recovery", + "restore_service", + "rsd", + "screenshotr", + "springboardservices", + "syslog_relay", + "tcp", + "tunnel_tcp_stack", + "tss", + "tunneld", + "usbmuxd", + "xpc", +] +graphics = [ + "dvt", + "rsd", +] +heartbeat = [ + "tokio/macros", + "tokio/time", +] +house_arrest = ["afc"] +installation_proxy = [ + "dep:async_zip", + "dep:futures", + "async_zip/tokio", + "async_zip/deflate", +] +installcoordination_proxy = [] +location_simulation = [] +mdns = [ + "dep:hkdf", + "dep:hmac", + "dep:sha2", +] +misagent = [] +mobile_image_mounter = ["dep:sha2"] +mobileactivationd = ["_reqwest"] +mobilebackup2 = [] +network_monitor = ["dvt"] +notification_proxy = [ + "tokio/macros", + "tokio/time", + "dep:async-stream", + "dep:futures", +] +obfuscate = ["dep:obfstr"] +openssl = [ + "dep:openssl", + "dep:tokio-openssl", +] +pair = [ + "chrono/default", + "tokio/time", + "dep:sha2", + "dep:rsa", + "dep:x509-cert", + "dep:web-time", +] +pcapd = [] +preboard_service = [] +remote_pairing = [ + "_serde_json", + "xpc", + "dep:x25519-dalek", + "dep:ed25519-dalek", + "dep:hkdf", + "dep:chacha20poly1305", + "dep:idevice-srp", + "dep:siphasher", + "dep:uuid", + "dep:aes", + "dep:cbc", + "dep:hmac", + "dep:sha1", + "dep:sha2", + "dep:rsa", +] +restore = [ + "tss", + "dep:async_zip", + "async_zip/tokio", + "async_zip/deflate", + "dep:futures", + "dep:der", + "dep:sha2", + "dep:sha1", + "tokio/net", + "tokio/macros", + "tokio/rt", + "tokio/time", + "rustls", + "reqwest/rustls-no-provider", +] +restore_recovery = ["restore"] +restore_service = [] +ring = [ + "rustls", + "rustls/ring", + "tokio-rustls/ring", +] +rsd = ["xpc"] +rustls = [ + "dep:rustls", + "dep:tokio-rustls", +] +screenshotr = [] +springboardservices = [] +syslog_relay = [ + "dep:bytes", + "dep:chrono", +] +sysmontap = [ + "dvt", + "energy_monitor", +] +tcp = ["tokio/net"] +tss = [ + "dep:uuid", + "_reqwest", +] +tunnel_tcp_stack = ["dep:jktcp"] +tunnel_tcp_stack_pcap = [ + "tunnel_tcp_stack", + "jktcp/pcap", +] +tunneld = [ + "_serde_json", + "_reqwest", +] +usbmuxd = [ + "tokio/net", + "dep:futures", +] +wasm = [ + "dep:getrandom_02", + "dep:getrandom_04", + "dep:rustls-pki-types", + "dep:wasmtimer", + "dep:wasm-bindgen-futures", + "wasm-crypto", +] +wasm-crypto = [ + "rustls", + "dep:rustls-rustcrypto", +] +wda = [ + "_serde_json", + "tokio/time", + "tokio/net", +] +xctest = [ + "dvt", + "installation_proxy", + "afc", + "dep:uuid", + "dep:ns-keyed-archive", + "tunnel_tcp_stack", + "rsd", + "core_device_proxy", +] +xpc = [ + "dep:indexmap", + "dep:uuid", + "dep:async-stream", + "dep:futures", +] + +[lib] +name = "idevice" +path = "src/lib.rs" + +[dependencies.aes] +version = "0.8" +optional = true + +[dependencies.async-stream] +version = "0.3.6" +optional = true + +[dependencies.async_zip] +version = "0.0.18" +optional = true + +[dependencies.base64] +version = "0.22" + +[dependencies.bytes] +version = "1.10" +optional = true + +[dependencies.cbc] +version = "0.1" +features = ["alloc"] +optional = true + +[dependencies.chacha20poly1305] +version = "0.10" +optional = true + +[dependencies.chrono] +version = "0.4" +features = ["serde"] +optional = true +default-features = false + +[dependencies.der] +version = "0.7" +features = [ + "alloc", + "derive", +] +optional = true + +[dependencies.ed25519-dalek] +version = "2" +features = ["rand_core"] +optional = true + +[dependencies.flate2] +version = "1" +optional = true + +[dependencies.futures] +version = "0.3" +optional = true + +[dependencies.hkdf] +version = "0.12" +optional = true + +[dependencies.hmac] +version = "0.12" +optional = true + +[dependencies.idevice-srp] +version = "0.6" +optional = true + +[dependencies.indexmap] +version = "2.11" +features = ["serde"] +optional = true + +[dependencies.jktcp] +version = "0.1.6" +optional = true +default-features = false + +[dependencies.ns-keyed-archive] +version = "0.1.5" +optional = true + +[dependencies.obfstr] +version = "0.4" +optional = true + +[dependencies.openssl] +version = "0.10" +optional = true + +[dependencies.plist] +version = "1.8" + +[dependencies.plist-macro] +version = "0.1.6" + +[dependencies.rand] +version = "0.10" + +[dependencies.reqwest] +version = "0.13" +features = ["json"] +optional = true +default-features = false + +[dependencies.rsa] +version = "0.9" +features = ["sha2"] +optional = true + +[dependencies.rustls] +version = "0.23" +features = [ + "std", + "tls12", +] +optional = true +default-features = false + +[dependencies.serde] +version = "1" +features = ["derive"] + +[dependencies.serde_json] +version = "1" +optional = true + +[dependencies.sha1] +version = "0.10" +optional = true + +[dependencies.sha2] +version = "0.10" +features = ["oid"] +optional = true + +[dependencies.siphasher] +version = "1" +optional = true + +[dependencies.thiserror] +version = "2" + +[dependencies.tokio] +version = "1" +features = [ + "io-util", + "time", +] + +[dependencies.tokio-openssl] +version = "0.6" +optional = true + +[dependencies.tokio-rustls] +version = "0.26" +optional = true +default-features = false + +[dependencies.tracing] +version = "0.1.41" + +[dependencies.uuid] +version = "1.18" +features = [ + "serde", + "v3", + "v4", +] +optional = true + +[dependencies.web-time] +version = "1" +optional = true + +[dependencies.x25519-dalek] +version = "2" +optional = true + +[dependencies.x509-cert] +version = "0.2" +features = [ + "builder", + "pem", +] +optional = true +default-features = false + +[dev-dependencies.bytes] +version = "1.10.1" + +[dev-dependencies.tokio] +version = "1.43" +features = ["full"] + +[dev-dependencies.tun-rs] +version = "2.0.8" +features = ["async_tokio"] + +[target.'cfg(not(target_arch = "wasm32"))'.dependencies.tokio] +version = "1" +features = ["fs"] + +[target."cfg(not(windows))".dependencies.libc] +version = "0.2" + +[target.'cfg(target_arch = "wasm32")'.dependencies.getrandom_02] +version = "0.2" +features = ["js"] +optional = true +default-features = false +package = "getrandom" + +[target.'cfg(target_arch = "wasm32")'.dependencies.getrandom_04] +version = "0.4" +features = ["wasm_js"] +optional = true +default-features = false +package = "getrandom" + +[target.'cfg(target_arch = "wasm32")'.dependencies.rustls-pki-types] +version = "1" +features = ["web"] +optional = true +default-features = false + +[target.'cfg(target_arch = "wasm32")'.dependencies.rustls-rustcrypto] +version = "0.0.2-alpha" +optional = true + +[target.'cfg(target_arch = "wasm32")'.dependencies.uuid] +version = "1.23" +features = ["js"] +optional = true +default-features = false + +[target.'cfg(target_arch = "wasm32")'.dependencies.wasm-bindgen-futures] +version = "0.4" +optional = true + +[target.'cfg(target_arch = "wasm32")'.dependencies.wasmtimer] +version = "0.4" +features = ["tokio"] +optional = true +default-features = false diff --git a/src-tauri/vendor/idevice/README.md b/src-tauri/vendor/idevice/README.md new file mode 100644 index 0000000..3ff658d --- /dev/null +++ b/src-tauri/vendor/idevice/README.md @@ -0,0 +1,211 @@ +# idevice + +A pure Rust library for interacting with iOS services. +Inspired by [libimobiledevice](https://github.com/libimobiledevice/libimobiledevice) +[pymobiledevice3](https://github.com/doronz88/pymobiledevice3), +and [go-ios](https://github.com/danielpaulus/go-ios) +this library interfaces with lockdownd, usbmuxd, and RSD to perform actions +on an iOS device that a Mac normally would. + +For help and information, join the [idevice Discord](https://discord.gg/qtgv6QtYbV) + +[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/jkcoxson/idevice) + +## State + +**IMPORTANT**: Breaking changes will happen at each point release until 0.2.0. +Pin your `Cargo.toml` to a specific version to avoid breakage. + +This library is in development and research stage. +Releases are being published to crates.io for use in other projects, +but the API and feature-set are far from final or even planned. + +## Why use this? + +libimobiledevice is a groundbreaking library. Unfortunately, it hasn't +been seriously updated in a long time, and does not support many modern +iOS features. + +Libraries such as pymobiledevice3 and go-ios have popped up to fill that +gap, but both lacked the support I needed for embedding into applications +and server programs. Python requires an interpreter, and Go's current +ability to be embedded in other languages is lacking. + +This library is currently used in popular apps such as +[StikDebug](https://github.com/StephenDev0/StikDebug), +[CrossCode](https://github.com/nab138/CrossCode) +and +[Protokolle](https://github.com/khcrysalis/Protokolle). +``idevice`` has proven there is a need. It's currently deployed on tens of +thousands of devices, all across the world. + +## Features + +To keep dependency bloat and compile time down, everything is contained in features. + +| Feature | Description | +|------------------------|-----------------------------------------------------------------------------| +| `afc` | Apple File Conduit for file system access.| +| `amfi` | Apple mobile file integrity service | +| `bt_packet_logger` | Capture Bluetooth packets. | +| `companion_proxy` | Manage paired Apple Watches. | +| `core_device_proxy` | Start a secure tunnel to access protected services. | +| `crashreportcopymobile`| Copy crash reports.| +| `debug_proxy` | Send GDB commands to the device.| +| `diagnostics_relay` | Access device diagnostics information (IORegistry, MobileGestalt, battery, NAND, device control).| +| `dvt` | Access Apple developer tools (e.g. Instruments).| +| `heartbeat` | Maintain a heartbeat connection.| +| `house_arrest` | Manage files in app containers | +| `installation_proxy` | Manage app installation and uninstallation.| +| `installcoordination_proxy` | Manage app installation coordination.| +| `location_simulation` | Simulate GPS locations on the device.| +| `misagent` | Manage provisioning profiles on the device.| +| `mobile_image_mounter` | Manage DDI images.| +| `mobileactivationd` | Activate/Deactivate device.| +| `mobilebackup2` | Manage backups.| +| `pair` | Pair the device.| +| `pcapd` | Capture network packets.| +| `preboard_service` | Interface with Preboard.| +| `restore_service` | Restore service (recovery/reboot).| +| `screenshotr` | Take screenshots.| +| `springboardservices` | Control SpringBoard (icons, wallpaper, orientation, etc.).| +| `syslog_relay` | Relay system logs and OS trace logs from the device. | +| `tcp` | Connect to devices over TCP.| +| `tunnel_tcp_stack` | Naive in-process TCP stack for `core_device_proxy`.| +| `tss` | Make requests to Apple's TSS servers. Partial support.| +| `tunneld` | Interface with [pymobiledevice3](https://github.com/doronz88/pymobiledevice3)'s tunneld. | +| `usbmuxd` | Connect using the usbmuxd daemon.| +| `xpc` | Access protected services via XPC over RSD. | +| `xctest` | Launch XCTest runners and coordinate modern testmanagerd/XCTest sessions. | +| `wda` | Minimal WebDriverAgent bootstrap helpers and localhost bridge support. | +| `notification_proxy` | Post and observe iOS notifications. | + +### Planned/TODO + +Finish the following: + +- webinspector + +Implement the following: + +- file_relay + +As this project is done in my free time within my busy schedule, there +is no ETA for any of these. Feel free to contribute or donate! + +## Usage + +idevice is purposefully verbose to allow for powerful configurations. +No size fits all, but effort is made to reduce boilerplate via providers. + +```rust +// enable the usbmuxd feature +use idevice::{lockdown::LockdowndClient, IdeviceService}; +use idevice::usbmuxd::{UsbmuxdAddr, UsbmuxdConnection}, + +#[tokio::main] +async fn main() { + // usbmuxd is Apple's daemon for connecting to devices over USB. + // We'll ask usbmuxd for a device + let mut usbmuxd = UsbmuxdConnection::default() + .await + .expect("Unable to connect to usbmuxd"); + let devs = usbmuxd.get_devices().unwrap(); + if devs.is_empty() { + eprintln!("No devices connected!"); + return; + } + + // Create a provider to automatically create connections to the device. + // Many services require opening multiple connections to get where you want. + let provider = devs[0].to_provider(UsbmuxdAddr::from_env_var().unwrap(), 0, "example-program") + + // ``connect`` takes an object with the provider trait + let mut lockdown_client = match LockdowndClient::connect(&provider).await { + Ok(l) => l, + Err(e) => { + eprintln!("Unable to connect to lockdown: {e:?}"); + return; + } + }; + + println!("{:?}", lockdown_client.get_value("ProductVersion").await); + println!( + "{:?}", + lockdown_client + .start_session( + &provider + .get_pairing_file() + .await + .expect("failed to get pairing file") + ) + .await + ); + println!("{:?}", lockdown_client.idevice.get_type().await.unwrap()); + println!("{:#?}", lockdown_client.get_all_values().await); +} +``` + +More examples are in the [`tools`](tools/) crate and in the crate documentation. + +### XCTest / WDA + +`idevice` also includes support for launching XCTest runners through the +library's modern DVT/RSD path. This can be used to start +WebDriverAgent-style runners on recent iOS versions. + +The bundled CLI exposes this through the `idevice-tools xctest` command: + +```bash +idevice-tools --udid xctest io.github.kor1k1.WebDriverAgentRunner.xctrunner +``` + +To wait for WDA and expose localhost bridge URLs for HTTP and MJPEG: + +```bash +idevice-tools --udid xctest --bridge io.github.kor1k1.WebDriverAgentRunner.xctrunner +``` + +The current `wda` support is intentionally a bootstrap layer for readiness +checks and session startup, rather than a complete long-lived WebDriver +client. + +## FFI + +For use in other languages, a small FFI crate has been created to start exposing +idevice. Example C programs can be found in the [`ffi/examples`](ffi/examples/) directory. + +### C++ + +"Hey wait a second, there's a lot of C++ code in this library!!" +C++ bindings have been made for many of idevice's features. This allows smooth +and safer usage in C++ and Swift codebases. + +## Technical Explanation + +There are so many layers and protocols in this library, many stacked on top of +one another. It's difficult to describe the magnitude that is Apple's interfaces. + +I would recommend reading the DeepWiki explanations and overviews to get an idea +of how this library and their associated protocols work. But a general overview is: + +### Lockdown + +1. A lockdown service is accessible via a port given by lockdown +1. Lockdown is accessible by USB or TCP via TLS +1. USB is accessible via usbmuxd +1. usbmuxd is accessed through a unix socket +1. That Unix socket has its own protocol + +### RemoteXPC/RSD + +1. An RSD service is discovered through a RemoteXPC handshake response +1. RemoteXPC is transferred over non-compliant HTTP/2 +1. That HTTP/2 is accessed through an NCM USB interface or CoreDeviceProxy +1. CoreDeviceProxy is a lockdown service, see above + +For more information about rppairing, I've written out the [spec here](https://jkcoxson.com/blog/rppairing-spec) + +## License + +MIT diff --git a/src-tauri/vendor/idevice/src/ca.rs b/src-tauri/vendor/idevice/src/ca.rs new file mode 100644 index 0000000..3b66b64 --- /dev/null +++ b/src-tauri/vendor/idevice/src/ca.rs @@ -0,0 +1,113 @@ +// Jackson Coxson +// Inspired by pymobiledevice3 + +use std::str::FromStr; + +use rsa::{ + RsaPrivateKey, RsaPublicKey, + pkcs1::DecodeRsaPublicKey, + pkcs1v15::SigningKey, + pkcs8::{EncodePrivateKey, LineEnding, SubjectPublicKeyInfo}, +}; +use sha2::Sha256; +use x509_cert::{ + Certificate, + builder::{Builder, CertificateBuilder, Profile}, + der::{EncodePem, asn1::GeneralizedTime}, + name::Name, + serial_number::SerialNumber, + time::{Time, Validity}, +}; + +#[derive(Clone, Debug)] +pub struct CaReturn { + pub host_cert: Vec, + pub dev_cert: Vec, + pub private_key: Vec, +} + +pub fn make_cert( + signing_key: &RsaPrivateKey, + public_key: &RsaPublicKey, + common_name: Option<&str>, +) -> Result> { + // Create subject/issuer name + let name = match common_name { + Some(name) => Name::from_str(&format!("CN={name}"))?, + None => Name::default(), + }; + + // Set validity (10 years). We avoid `Validity::from_now` because it + // calls `std::time::SystemTime::now()`, which panics at runtime on + // wasm32-unknown-unknown. `web-time` is a zero-cost alias on native + // and uses `Date.now()` on wasm. + let lifetime = std::time::Duration::from_secs( + 365 * 9 * 12 * 31 * 24 * 60 * 60, // idk like 9 years + ); + let now_unix = web_time::SystemTime::now() + .duration_since(web_time::UNIX_EPOCH) + .map_err(|e| format!("system time before unix epoch: {e}"))?; + let then_unix = now_unix + lifetime; + let validity = Validity { + not_before: Time::GeneralTime(GeneralizedTime::from_unix_duration(now_unix)?), + not_after: Time::GeneralTime(GeneralizedTime::from_unix_duration(then_unix)?), + }; + + let signing_key = SigningKey::::new(signing_key.clone()); + let public_key = SubjectPublicKeyInfo::from_key(public_key.clone())?; + + // Build certificate + let cert = CertificateBuilder::new( + Profile::Root, + SerialNumber::new(&[1])?, + validity, + name, + public_key, + &signing_key, + )?; + + // Sign the certificate + let tbs_cert = cert.build()?; + + Ok(tbs_cert) +} + +// Equivalent to dump_cert +fn dump_cert(cert: &Certificate) -> Result> { + let b = cert.to_pem(LineEnding::LF)?; + Ok(b) +} + +pub(crate) fn generate_certificates( + device_public_key_pem: &[u8], + private_key: Option, +) -> Result> { + // Load device public key + let device_public_key = + RsaPublicKey::from_pkcs1_pem(std::str::from_utf8(device_public_key_pem)?)?; + + // Generate or use provided private key + let private_key = match private_key { + Some(p) => p, + None => { + let mut rng = rsa::rand_core::OsRng; + RsaPrivateKey::new(&mut rng, 2048)? + } + }; + + // Create CA cert + let ca_public_key = RsaPublicKey::from(&private_key); + let ca_cert = make_cert(&private_key, &ca_public_key, None)?; + + // Create device cert + let dev_cert = make_cert(&private_key, &device_public_key, Some("Device"))?; + + Ok(CaReturn { + host_cert: dump_cert(&ca_cert)?.into_bytes(), + dev_cert: dump_cert(&dev_cert)?.into_bytes(), + private_key: private_key + .to_pkcs8_pem(LineEnding::LF)? + .as_bytes() + .to_vec(), + }) +} diff --git a/src-tauri/vendor/idevice/src/cursor.rs b/src-tauri/vendor/idevice/src/cursor.rs new file mode 100644 index 0000000..115cd5b --- /dev/null +++ b/src-tauri/vendor/idevice/src/cursor.rs @@ -0,0 +1,337 @@ +// Jackson Coxson + +#[derive(Clone, Debug)] +pub struct Cursor<'a> { + inner: &'a [u8], + pos: usize, +} + +impl<'a> Cursor<'a> { + /// Creates a new cursor + pub fn new(inner: &'a [u8]) -> Self { + Self { inner, pos: 0 } + } + + pub fn len(&self) -> usize { + self.inner.len() + } + + pub fn is_empty(&self) -> bool { + self.inner.is_empty() + } + + pub fn at_end(&self) -> bool { + self.pos == self.inner.len() + } + + pub fn read(&mut self, to_read: usize) -> Option<&'a [u8]> { + // Check if the end of the slice (self.pos + to_read) is beyond the buffer length + if self + .pos + .checked_add(to_read) + .is_none_or(|end_pos| end_pos > self.inner.len()) + { + return None; + } + + // The end of the slice is self.pos + to_read + let end_pos = self.pos + to_read; + let res = Some(&self.inner[self.pos..end_pos]); + self.pos = end_pos; + res + } + + pub fn back(&mut self, to_back: usize) { + let to_back = if to_back > self.pos { + self.pos + } else { + to_back + }; + + self.pos -= to_back; + } + + /// True if actually all zeroes + pub fn read_assert_zero(&mut self, to_read: usize) -> Option<()> { + let bytes = self.read(to_read)?; + + #[cfg(not(debug_assertions))] + let _ = bytes; + + #[cfg(debug_assertions)] + for b in bytes.iter() { + if *b > 0 { + eprintln!("Zero read contained non-zero values!"); + eprintln!("{bytes:02X?}"); + return None; + } + } + + Some(()) + } + + pub fn read_to(&mut self, end: usize) -> Option<&'a [u8]> { + if end > self.inner.len() { + return None; + } + let res = Some(&self.inner[self.pos..end]); + self.pos = end; + res + } + + pub fn peek_to(&mut self, end: usize) -> Option<&'a [u8]> { + if end > self.inner.len() { + return None; + } + Some(&self.inner[self.pos..end]) + } + + pub fn peek(&self, to_read: usize) -> Option<&'a [u8]> { + if self + .pos + .checked_add(to_read) + .is_none_or(|end_pos| end_pos > self.inner.len()) + { + return None; + } + + let end_pos = self.pos + to_read; + Some(&self.inner[self.pos..end_pos]) + } + + pub fn reveal(&self, surrounding: usize) { + let len = self.inner.len(); + + if self.pos > len { + println!("Cursor is past end of buffer"); + return; + } + + let start = self.pos.saturating_sub(surrounding); + let end = (self.pos + surrounding + 1).min(len); + + // HEADER + println!("Reveal around pos {} ({} bytes):", self.pos, surrounding); + + // --- HEX LINE --- + print!("Hex: "); + for i in start..end { + if i == self.pos { + print!("[{:02X}] ", self.inner[i]); + } else { + print!("{:02X} ", self.inner[i]); + } + } + println!(); + + // --- ASCII LINE --- + print!("Ascii: "); + for i in start..end { + let b = self.inner[i]; + let c = if b.is_ascii_graphic() || b == b' ' { + b as char + } else { + '.' + }; + + if i == self.pos { + print!("[{}] ", c); + } else { + print!("{} ", c); + } + } + println!(); + + // --- OFFSET LINE --- + print!("Offset: "); + for i in start..end { + let off = i as isize - self.pos as isize; + if i == self.pos { + print!("[{}] ", off); + } else { + print!("{:<3} ", off); + } + } + println!(); + } + + pub fn remaining(&mut self) -> &'a [u8] { + let res = &self.inner[self.pos..]; + self.pos = self.inner.len(); + res + } + + pub fn read_u8(&mut self) -> Option { + if self.pos == self.inner.len() { + return None; + } + let res = Some(self.inner[self.pos]); + self.pos += 1; + res + } + + pub fn read_le_u16(&mut self) -> Option { + const SIZE: usize = 2; + let bytes = self.read(SIZE)?; + let bytes: [u8; SIZE] = bytes.try_into().unwrap(); + Some(u16::from_le_bytes(bytes)) + } + + pub fn read_be_u16(&mut self) -> Option { + const SIZE: usize = 2; + let bytes = self.read(SIZE)?; + let bytes: [u8; SIZE] = bytes.try_into().unwrap(); + Some(u16::from_be_bytes(bytes)) + } + + pub fn read_le_u32(&mut self) -> Option { + const SIZE: usize = 4; + let bytes = self.read(SIZE)?; + let bytes: [u8; SIZE] = bytes.try_into().unwrap(); + Some(u32::from_le_bytes(bytes)) + } + + pub fn read_be_u32(&mut self) -> Option { + const SIZE: usize = 4; + let bytes = self.read(SIZE)?; + let bytes: [u8; SIZE] = bytes.try_into().unwrap(); + Some(u32::from_be_bytes(bytes)) + } + + pub fn read_le_u64(&mut self) -> Option { + const SIZE: usize = 8; + let bytes = self.read(SIZE)?; + let bytes: [u8; SIZE] = bytes.try_into().unwrap(); + Some(u64::from_le_bytes(bytes)) + } + + pub fn read_be_u64(&mut self) -> Option { + const SIZE: usize = 8; + let bytes = self.read(SIZE)?; + let bytes: [u8; SIZE] = bytes.try_into().unwrap(); + Some(u64::from_be_bytes(bytes)) + } + + pub fn read_le_u128(&mut self) -> Option { + const SIZE: usize = 16; + let bytes = self.read(SIZE)?; + let bytes: [u8; SIZE] = bytes.try_into().unwrap(); + Some(u128::from_le_bytes(bytes)) + } + + pub fn read_be_u128(&mut self) -> Option { + const SIZE: usize = 16; + let bytes = self.read(SIZE)?; + let bytes: [u8; SIZE] = bytes.try_into().unwrap(); + Some(u128::from_be_bytes(bytes)) + } + + pub fn read_le_f32(&mut self) -> Option { + const SIZE: usize = 4; + let bytes = self.read(SIZE)?; + let bytes: [u8; SIZE] = bytes.try_into().unwrap(); + Some(f32::from_le_bytes(bytes)) + } + + pub fn read_be_f32(&mut self) -> Option { + const SIZE: usize = 4; + let bytes = self.read(SIZE)?; + let bytes: [u8; SIZE] = bytes.try_into().unwrap(); + Some(f32::from_be_bytes(bytes)) + } + + pub fn read_i8(&mut self) -> Option { + if self.pos == self.inner.len() { + return None; + } + let res = Some(self.inner[self.pos]).map(|x| x as i8); + self.pos += 1; + res + } + + pub fn read_le_i16(&mut self) -> Option { + const SIZE: usize = 2; + let bytes = self.read(SIZE)?; + let bytes: [u8; SIZE] = bytes.try_into().unwrap(); + Some(i16::from_le_bytes(bytes)) + } + + pub fn read_be_i16(&mut self) -> Option { + const SIZE: usize = 2; + let bytes = self.read(SIZE)?; + let bytes: [u8; SIZE] = bytes.try_into().unwrap(); + Some(i16::from_be_bytes(bytes)) + } + + pub fn read_le_i32(&mut self) -> Option { + const SIZE: usize = 4; + let bytes = self.read(SIZE)?; + let bytes: [u8; SIZE] = bytes.try_into().unwrap(); + Some(i32::from_le_bytes(bytes)) + } + + pub fn read_be_i32(&mut self) -> Option { + const SIZE: usize = 4; + let bytes = self.read(SIZE)?; + let bytes: [u8; SIZE] = bytes.try_into().unwrap(); + Some(i32::from_be_bytes(bytes)) + } + + pub fn read_le_i64(&mut self) -> Option { + const SIZE: usize = 8; + let bytes = self.read(SIZE)?; + let bytes: [u8; SIZE] = bytes.try_into().unwrap(); + Some(i64::from_le_bytes(bytes)) + } + + pub fn read_be_i64(&mut self) -> Option { + const SIZE: usize = 8; + let bytes = self.read(SIZE)?; + let bytes: [u8; SIZE] = bytes.try_into().unwrap(); + Some(i64::from_be_bytes(bytes)) + } + + pub fn read_le_i128(&mut self) -> Option { + const SIZE: usize = 16; + let bytes = self.read(SIZE)?; + let bytes: [u8; SIZE] = bytes.try_into().unwrap(); + Some(i128::from_le_bytes(bytes)) + } + + pub fn read_be_i128(&mut self) -> Option { + const SIZE: usize = 16; + let bytes = self.read(SIZE)?; + let bytes: [u8; SIZE] = bytes.try_into().unwrap(); + Some(i128::from_be_bytes(bytes)) + } + + pub fn take_2(&mut self) -> Option<[u8; 2]> { + let bytes = self.read(2)?; + Some(bytes.to_owned().try_into().unwrap()) + } + + pub fn take_3(&mut self) -> Option<[u8; 3]> { + let bytes = self.read(3)?; + Some(bytes.to_owned().try_into().unwrap()) + } + + pub fn take_4(&mut self) -> Option<[u8; 4]> { + let bytes = self.read(4)?; + Some(bytes.to_owned().try_into().unwrap()) + } + + pub fn take_8(&mut self) -> Option<[u8; 8]> { + let bytes = self.read(8)?; + Some(bytes.to_owned().try_into().unwrap()) + } + + pub fn take_20(&mut self) -> Option<[u8; 20]> { + let bytes = self.read(20)?; + Some(bytes.to_owned().try_into().unwrap()) + } + + pub fn take_32(&mut self) -> Option<[u8; 32]> { + let bytes = self.read(32)?; + Some(bytes.to_owned().try_into().unwrap()) + } +} diff --git a/src-tauri/vendor/idevice/src/lib.rs b/src-tauri/vendor/idevice/src/lib.rs new file mode 100644 index 0000000..b208525 --- /dev/null +++ b/src-tauri/vendor/idevice/src/lib.rs @@ -0,0 +1,1107 @@ +#![cfg_attr(docsrs, doc = include_str!("../README.md"))] +#![warn(missing_debug_implementations)] +#![warn(missing_copy_implementations)] +// Jackson Coxson + +#[cfg(feature = "pair")] +mod ca; +pub mod cursor; +#[cfg(feature = "mdns")] +pub mod mdns; +mod obfuscation; +pub mod pairing_file; +pub mod provider; +#[cfg(feature = "remote_pairing")] +pub mod remote_pairing; +#[cfg(feature = "rustls")] +mod sni; +#[cfg(feature = "tunnel_tcp_stack")] +pub mod tcp; +#[cfg(feature = "tss")] +pub mod tss; +#[cfg(feature = "tunneld")] +pub mod tunneld; +#[cfg(feature = "usbmuxd")] +pub mod usbmuxd; +pub mod utils; +#[cfg(feature = "xpc")] +pub mod xpc; + +pub mod services; +pub use services::*; + +/// Time primitives that work across native and wasm32-unknown-unknown. +/// +/// On native targets this is `tokio::time`. On wasm32 we route to `wasmtimer` +/// because `tokio::time` panics at runtime there (no timer backend). +#[allow(unused_imports)] +pub(crate) mod time { + #[cfg(not(target_arch = "wasm32"))] + pub use tokio::time::*; + #[cfg(target_arch = "wasm32")] + pub use wasmtimer::std::Instant; + #[cfg(target_arch = "wasm32")] + pub use wasmtimer::tokio::*; +} + +#[cfg(any(feature = "core_device_proxy", feature = "remote_pairing"))] +pub mod tunnel; + +#[cfg(feature = "xpc")] +pub use xpc::RemoteXpcClient; + +use plist_macro::{plist, pretty_print_dictionary, pretty_print_plist}; +use provider::{IdeviceProvider, RsdProvider}; +#[cfg(feature = "rustls")] +use rustls::{crypto::CryptoProvider, pki_types::ServerName}; +use std::{ + io::{self, BufWriter}, + sync::Arc, +}; +use thiserror::Error; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt}; +use tracing::{debug, trace}; + +use crate::services::lockdown::LockdownClient; + +/// A trait combining all required characteristics for a device communication socket +/// +/// This serves as a convenience trait for any type that can be used as an asynchronous +/// read/write socket for device communication. Combines common async I/O traits with +/// thread safety and debugging requirements. +/// +/// Tokio's TcpStream and UnixStream implement this trait. +pub trait ReadWrite: AsyncRead + AsyncWrite + Unpin + Send + Sync + std::fmt::Debug {} + +// Blanket implementation for any compatible type +impl ReadWrite for T {} + +/// Interface for services that can be connected to on an iOS device +/// +/// Implement this trait to define new services that can be accessed through the +/// device connection protocol. +pub trait IdeviceService: Sized { + /// Returns the service name as advertised by the device + fn service_name() -> std::borrow::Cow<'static, str>; + + /// Establishes a connection to this service + /// + /// # Arguments + /// * `provider` - The device provider that can supply connections + /// + // From the docs + // │ │ ├╴ use of `async fn` in public traits is discouraged as auto trait bounds cannot be specified + // │ │ │ you can suppress this lint if you plan to use the trait only in your own code, or do not care about auto traits like `Send` on the `Future` + // │ │ │ `#[warn(async_fn_in_trait)]` on by default rustc (async_fn_in_trait) [66, 5] + #[allow(async_fn_in_trait)] + async fn connect(provider: &dyn IdeviceProvider) -> Result { + let mut lockdown = LockdownClient::connect(provider).await?; + + let legacy = lockdown + .start_session(&provider.get_pairing_file().await?) + .await?; + // Best-effort fetch UDID for downstream defaults (e.g., MobileBackup2 Target/Source identifiers) + let udid_value = match lockdown.get_value(Some("UniqueDeviceID"), None).await { + Ok(v) => v.as_string().map(|s| s.to_string()), + Err(_) => None, + }; + + let (port, ssl) = lockdown.start_service(Self::service_name()).await?; + + let mut idevice = provider.connect(port).await?; + if ssl { + idevice + .start_session(&provider.get_pairing_file().await?, legacy) + .await?; + } + + if let Some(udid) = udid_value { + idevice.set_udid(udid); + } + + Self::from_stream(idevice).await + } + + #[allow(async_fn_in_trait)] + async fn from_stream(idevice: Idevice) -> Result; +} + +#[cfg(feature = "rsd")] +pub trait RsdService: Sized { + fn rsd_service_name() -> std::borrow::Cow<'static, str>; + fn from_stream( + stream: Box, + ) -> impl std::future::Future> + Send; + fn connect_rsd( + provider: &mut impl RsdProvider, + handshake: &mut rsd::RsdHandshake, + ) -> impl std::future::Future> + where + Self: crate::RsdService, + { + handshake.connect(provider) + } +} + +/// Type alias for boxed device connection sockets +/// +/// Used to enable dynamic dispatch of different connection types while maintaining +/// the required ReadWrite characteristics. +pub type IdeviceSocket = Box; + +/// Installs a process-default rustls [`CryptoProvider`] matching the selected +/// crypto backend (`ring` / `aws-lc` / `wasm-crypto`), unless one is already +/// installed. +pub(crate) fn ensure_default_crypto_provider() { + #[cfg(feature = "rustls")] + { + if CryptoProvider::get_default().is_none() { + // rust-analyzer will choke on this block, don't worry about it + let crypto_provider: CryptoProvider = { + #[cfg(all(feature = "ring", not(feature = "aws-lc")))] + { + debug!("Using ring crypto backend"); + rustls::crypto::ring::default_provider() + } + + #[cfg(all(feature = "aws-lc", not(feature = "ring")))] + { + debug!("Using aws-lc crypto backend"); + rustls::crypto::aws_lc_rs::default_provider() + } + + #[cfg(all( + target_arch = "wasm32", + feature = "wasm-crypto", + not(any(feature = "ring", feature = "aws-lc")) + ))] + { + debug!("Using rustls-rustcrypto (pure Rust) crypto backend"); + rustls_rustcrypto::provider() + } + + #[cfg(all( + not(target_arch = "wasm32"), + not(any(feature = "ring", feature = "aws-lc")) + ))] + { + compile_error!( + "No crypto backend was selected! Specify an idevice feature for a crypto backend" + ); + } + #[cfg(all( + target_arch = "wasm32", + not(any(feature = "ring", feature = "aws-lc", feature = "wasm-crypto")) + ))] + { + compile_error!( + "No crypto backend was selected! On wasm32 enable the `wasm-crypto` (or `wasm`) feature." + ); + } + + #[cfg(all(feature = "ring", feature = "aws-lc"))] + { + // We can't throw a compile error because it breaks rust-analyzer. + // My sanity while debugging the workspace crates are more important. + + debug!("Using ring crypto backend, because both were passed"); + tracing::warn!("Both ring && aws-lc are selected as idevice crypto backends!"); + rustls::crypto::ring::default_provider() + } + }; + + if let Err(e) = CryptoProvider::install_default(crypto_provider) { + // For whatever reason, getting the default provider will return None on iOS at + // random. Installing the default provider a second time will return an error, so + // we will log it but not propogate it. An issue should be opened with rustls. + tracing::error!("Failed to set crypto provider: {e:?}"); + } + } + } +} + +/// Main handle for communicating with an iOS device +/// +/// Manages the connection socket and provides methods for common device operations +/// and message exchange. +#[derive(Debug)] +pub struct Idevice { + /// The underlying connection socket, boxed for dynamic dispatch + socket: Option>, + /// Unique label identifying this connection + label: String, + /// Cached device UDID for convenience in higher-level protocols + udid: Option, +} + +impl Idevice { + /// Creates a new device connection handle + /// + /// # Arguments + /// * `socket` - The established connection socket + /// * `label` - Unique identifier for this connection + pub fn new(socket: Box, label: impl Into) -> Self { + Self { + socket: Some(socket), + label: label.into(), + udid: None, + } + } + + pub fn get_socket(self) -> Option> { + self.socket + } + + /// Sets cached UDID + pub fn set_udid(&mut self, udid: impl Into) { + self.udid = Some(udid.into()); + } + + /// Returns cached UDID if available + pub fn udid(&self) -> Option<&str> { + self.udid.as_deref() + } + + /// Queries the device type + /// + /// Sends a QueryType request and parses the response + /// + /// # Returns + /// The device type string on success + /// + /// # Errors + /// Returns `IdeviceError` if communication fails or response is invalid + pub async fn get_type(&mut self) -> Result { + let req = plist!({ + "Label": self.label.clone(), + "Request": "QueryType", + }); + self.send_plist(req).await?; + + let message: plist::Dictionary = self.read_plist().await?; + match message.get("Type") { + Some(m) => Ok(plist::from_value(m)?), + None => Err(IdeviceError::UnexpectedResponse( + "missing Type in QueryType response".to_string(), + )), + } + } + + /// Performs RSD (Remote Service Discovery) check-in procedure + /// + /// Establishes the basic service connection protocol + /// + /// # Errors + /// Returns `IdeviceError` if the protocol sequence isn't followed correctly + pub async fn rsd_checkin(&mut self) -> Result<(), IdeviceError> { + let req = plist!({ + "Label": self.label.clone(), + "ProtocolVersion": "2", + "Request": "RSDCheckin", + }); + + self.send_plist(req).await?; + let res = self.read_plist().await?; + match res.get("Request").and_then(|x| x.as_string()) { + Some(r) => { + if r != "RSDCheckin" { + return Err(IdeviceError::UnexpectedResponse( + "RSDCheckin request field mismatch".to_string(), + )); + } + } + None => { + return Err(IdeviceError::UnexpectedResponse( + "missing Request field in RSDCheckin response".to_string(), + )); + } + } + + let res = self.read_plist().await?; + match res.get("Request").and_then(|x| x.as_string()) { + Some(r) => { + if r != "StartService" { + return Err(IdeviceError::UnexpectedResponse( + "StartService request field mismatch".to_string(), + )); + } + } + None => { + return Err(IdeviceError::UnexpectedResponse( + "missing Request field in StartService response".to_string(), + )); + } + } + + Ok(()) + } + + /// Sends a plist-formatted message to the device + /// + /// # Arguments + /// * `message` - The plist value to send + /// + /// # Errors + /// Returns `IdeviceError` if serialization or transmission fails + async fn send_plist(&mut self, message: plist::Value) -> Result<(), IdeviceError> { + if let Some(socket) = &mut self.socket { + debug!("Sending plist: {}", pretty_print_plist(&message)); + + let buf = Vec::new(); + let mut writer = BufWriter::new(buf); + message.to_writer_xml(&mut writer)?; + let message = writer.into_inner().unwrap(); + let message = String::from_utf8(message)?; + let len = message.len() as u32; + socket.write_all(&len.to_be_bytes()).await?; + socket.write_all(message.as_bytes()).await?; + socket.flush().await?; + Ok(()) + } else { + Err(IdeviceError::NoEstablishedConnection) + } + } + + /// Sends a binary plist-formatted message to the device + /// + /// # Arguments + /// * `message` - The plist value to send + /// + /// # Errors + /// Returns `IdeviceError` if serialization or transmission fails + #[allow(dead_code)] + async fn send_bplist(&mut self, message: plist::Value) -> Result<(), IdeviceError> { + if let Some(socket) = &mut self.socket { + debug!("Sending plist: {}", pretty_print_plist(&message)); + + let buf = Vec::new(); + let mut writer = BufWriter::new(buf); + message.to_writer_binary(&mut writer)?; + let message = writer.into_inner().unwrap(); + let len = message.len() as u32; + socket.write_all(&len.to_be_bytes()).await?; + socket.write_all(&message).await?; + socket.flush().await?; + Ok(()) + } else { + Err(IdeviceError::NoEstablishedConnection) + } + } + + /// Sends raw binary data to the device + /// + /// # Arguments + /// * `message` - The bytes to send + /// + /// # Errors + /// Returns `IdeviceError` if transmission fails + pub async fn send_raw(&mut self, message: &[u8]) -> Result<(), IdeviceError> { + self.send_raw_with_progress(message, |_| async {}, ()).await + } + + /// Sends raw binary data via vectored I/O + /// + /// # Arguments + /// * `bufs` - The buffers to send + /// + /// # Errors + /// Returns `IdeviceError` if transmission fails + pub async fn send_raw_vectored( + &mut self, + bufs: &[std::io::IoSlice<'_>], + ) -> Result<(), IdeviceError> { + if let Some(socket) = &mut self.socket { + let mut curr_idx = 0; + let mut curr_offset = 0; + + while curr_idx < bufs.len() { + let mut iovec = Vec::new(); + let mut accumulated_len = 0; + let max_chunk = 1024 * 64; + + // Add partial first slice + let first_avail = bufs[curr_idx].len() - curr_offset; + let to_take_first = std::cmp::min(first_avail, max_chunk); + iovec.push(std::io::IoSlice::new( + &bufs[curr_idx][curr_offset..curr_offset + to_take_first], + )); + accumulated_len += to_take_first; + + // Add others up to max_chunk + let mut temp_idx = curr_idx + 1; + while temp_idx < bufs.len() && accumulated_len < max_chunk { + let needed = max_chunk - accumulated_len; + let avail = bufs[temp_idx].len(); + let take = std::cmp::min(avail, needed); + iovec.push(std::io::IoSlice::new(&bufs[temp_idx][..take])); + accumulated_len += take; + temp_idx += 1; + } + + let n = socket.write_vectored(&iovec).await?; + if n == 0 { + return Err(io::Error::new( + io::ErrorKind::WriteZero, + "failed to write whole buffer", + ) + .into()); + } + + // Advance cursor by n + let mut advanced = n; + while advanced > 0 && curr_idx < bufs.len() { + let available = bufs[curr_idx].len() - curr_offset; + if advanced < available { + curr_offset += advanced; + advanced = 0; + } else { + advanced -= available; + curr_idx += 1; + curr_offset = 0; + } + } + } + socket.flush().await?; + Ok(()) + } else { + Err(IdeviceError::NoEstablishedConnection) + } + } + + /// Sends raw binary data with progress callbacks + /// + /// # Arguments + /// * `message` - The bytes to send + /// * `callback` - Progress callback invoked after each chunk + /// * `state` - Arbitrary state passed to callback + /// + /// # Type Parameters + /// * `Fut` - Future type returned by callback + /// * `S` - Type of state passed to callback + /// + /// # Errors + /// Returns `IdeviceError` if transmission fails + pub async fn send_raw_with_progress( + &mut self, + message: &[u8], + callback: impl Fn(((usize, usize), S)) -> Fut, + state: S, + ) -> Result<(), IdeviceError> + where + Fut: std::future::Future, + S: Clone, + { + if let Some(socket) = &mut self.socket { + let message_parts = message.chunks(1024 * 64); + let part_len = message_parts.len() - 1; + + for (i, part) in message_parts.enumerate() { + trace!("Writing {i}/{part_len}"); + socket.write_all(part).await?; + callback(((i, part_len), state.clone())).await; + } + socket.flush().await?; + Ok(()) + } else { + Err(IdeviceError::NoEstablishedConnection) + } + } + + /// Reads exactly `len` bytes from the device + /// + /// # Arguments + /// * `len` - Exact number of bytes to read + /// + /// # Returns + /// The received bytes + /// + /// # Errors + /// Returns `IdeviceError` if reading fails or connection is closed prematurely + pub async fn read_raw(&mut self, len: usize) -> Result, IdeviceError> { + if let Some(socket) = &mut self.socket { + let mut buf = vec![0; len]; + socket.read_exact(&mut buf).await?; + Ok(buf) + } else { + Err(IdeviceError::NoEstablishedConnection) + } + } + + /// Reads up to `max_size` bytes from the device + /// + /// # Arguments + /// * `max_size` - Maximum number of bytes to read + /// + /// # Returns + /// The received bytes (may be shorter than max_size) + /// + /// # Errors + /// Returns `IdeviceError` if reading fails + pub async fn read_any(&mut self, max_size: u32) -> Result, IdeviceError> { + if let Some(socket) = &mut self.socket { + let mut buf = vec![0; max_size as usize]; + let len = socket.read(&mut buf).await?; + Ok(buf[..len].to_vec()) + } else { + Err(IdeviceError::NoEstablishedConnection) + } + } + + /// Reads a plist-formatted message from the device + /// + /// # Returns + /// The parsed plist dictionary + /// + /// # Errors + /// Returns `IdeviceError` if reading, parsing fails, or device reports an error + async fn read_plist(&mut self) -> Result { + let res = self.read_plist_value().await?; + let res: plist::Dictionary = plist::from_value(&res)?; + debug!("Received plist: {}", pretty_print_dictionary(&res)); + + if let Some(e) = res.get("Error") { + let e = match e { + plist::Value::String(e) => e.to_string(), + plist::Value::Integer(e) => { + if let Some(error_string) = res.get("ErrorString").and_then(|x| x.as_string()) { + error_string.to_string() + } else { + e.to_string() + } + } + _ => { + tracing::error!("Error is not a string or integer from read_plist: {e:?}"); + return Err(IdeviceError::UnexpectedResponse( + "error value is not a string or integer".to_string(), + )); + } + }; + if let Some(e) = IdeviceError::from_device_error_type(e.as_str(), &res) { + return Err(e); + } else { + let msg = + if let Some(desc) = res.get("ErrorDescription").and_then(|x| x.as_string()) { + format!("{} ({})", e, desc) + } else { + e + }; + return Err(IdeviceError::UnknownErrorType(msg)); + } + } + Ok(res) + } + + async fn read_plist_value(&mut self) -> Result { + if let Some(socket) = &mut self.socket { + debug!("Reading response size"); + let mut buf = [0u8; 4]; + socket.read_exact(&mut buf).await?; + let len = u32::from_be_bytes(buf); + let mut buf = vec![0; len as usize]; + socket.read_exact(&mut buf).await?; + let res: plist::Value = plist::from_bytes(&buf)?; + Ok(res) + } else { + Err(IdeviceError::NoEstablishedConnection) + } + } + + /// Reads from the socket until `delimiter` is found, returning the data before it. + /// + /// `buffer` is owned by the caller and persists across calls. Any data read past the + /// first delimiter is retained in `buffer`. The caller must reuse the same buffer for a given stream. + #[cfg(feature = "syslog_relay")] + async fn read_until_delim( + &mut self, + buffer: &mut bytes::BytesMut, + delimiter: &[u8], + ) -> Result, IdeviceError> { + if let Some(socket) = &mut self.socket { + let mut temp = [0u8; 1024]; + + loop { + // Check for the delimiter in data already buffered (including any + // remainder carried over from a previous call) before reading more. + if let Some(pos) = buffer.windows(delimiter.len()).position(|w| w == delimiter) { + let mut line = buffer.split_to(pos + delimiter.len()); + line.truncate(line.len() - delimiter.len()); // remove delimiter + return Ok(Some(line)); + } + + let n = socket.read(&mut temp).await?; + if n == 0 { + if buffer.is_empty() { + return Ok(None); // EOF and no data + } else { + // EOF but return partial data, draining the buffer so a + // subsequent call reports EOF. + return Ok(Some(buffer.split())); + } + } + + buffer.extend_from_slice(&temp[..n]); + } + } else { + Err(IdeviceError::NoEstablishedConnection) + } + } + + /// Upgrades the connection to TLS using device pairing credentials + /// + /// # Arguments + /// * `pairing_file` - Contains the device's identity and certificates + /// + /// # Errors + /// Returns `IdeviceError` if TLS handshake fails or credentials are invalid + pub async fn start_session( + &mut self, + pairing_file: &pairing_file::PairingFile, + legacy: bool, + ) -> Result<(), IdeviceError> { + #[cfg(feature = "rustls")] + { + if legacy { + tracing::warn!( + "Compiled with rustls, but connecting to legacy device! rustls does not support old SSL, this will fail." + ); + } + + ensure_default_crypto_provider(); + let config = sni::create_client_config(pairing_file)?; + let connector = tokio_rustls::TlsConnector::from(Arc::new(config)); + + let socket = self.socket.take().unwrap(); + let socket = connector + .connect(ServerName::try_from("Device").unwrap(), socket) + .await?; + + self.socket = Some(Box::new(socket)); + + Ok(()) + } + #[cfg(all(feature = "openssl", not(feature = "rustls")))] + { + let mut connector = + openssl::ssl::SslConnector::builder(openssl::ssl::SslMethod::tls())?; + if legacy { + connector.set_min_proto_version(Some(openssl::ssl::SslVersion::SSL3))?; + connector.set_max_proto_version(Some(openssl::ssl::SslVersion::TLS1))?; + connector.set_cipher_list("ALL:!aNULL:!eNULL:@SECLEVEL=0")?; + connector.set_options(openssl::ssl::SslOptions::ALLOW_UNSAFE_LEGACY_RENEGOTIATION); + } + + let mut connector = connector.build().configure()?.into_ssl("Device")?; + + connector.set_certificate(&pairing_file.host_certificate)?; + connector.set_private_key(&pairing_file.host_private_key)?; + connector.set_verify(openssl::ssl::SslVerifyMode::empty()); + let socket = self.socket.take().unwrap(); + let mut ssl_stream = tokio_openssl::SslStream::new(connector, socket)?; + std::pin::Pin::new(&mut ssl_stream).connect().await?; + self.socket = Some(Box::new(ssl_stream)); + + Ok(()) + } + } +} + +/// Errors specific to the CDTunnel protocol +#[derive(Error, Debug)] +#[non_exhaustive] +pub enum CdTunnelError { + #[error("CDTunnel packet too short")] + PacketTooShort, + #[error("CDTunnel packet invalid magic")] + InvalidMagic, + #[error("proclaimed packet size does not match actual size")] + SizeMismatch, +} + +impl CdTunnelError { + pub fn sub_code(&self) -> i32 { + match self { + Self::PacketTooShort => 1, + Self::InvalidMagic => 2, + Self::SizeMismatch => 3, + } + } +} + +/// Errors specific to the heartbeat service +#[derive(Error, Debug)] +#[non_exhaustive] +pub enum HeartbeatError { + #[error("device went to sleep")] + SleepyTime, + #[error("heartbeat timeout")] + Timeout, +} + +impl HeartbeatError { + pub fn sub_code(&self) -> i32 { + match self { + Self::SleepyTime => 1, + Self::Timeout => 2, + } + } +} + +/// Comprehensive error type for all device communication failures +#[derive(Error, Debug)] +#[non_exhaustive] +pub enum IdeviceError { + // 1: I/O and transport errors + #[error("device socket io failed")] + Socket(#[from] io::Error), + #[cfg(feature = "rustls")] + #[error("PEM parse failed")] + PemParseFailed(#[from] rustls::pki_types::pem::Error), + #[cfg(feature = "rustls")] + #[error("TLS error")] + Rustls(#[from] rustls::Error), + #[cfg(all(feature = "openssl", not(feature = "rustls")))] + #[error("TLS error")] + Rustls(#[from] openssl::ssl::Error), + #[cfg(feature = "rustls")] + #[error("TLS verification build failed")] + TlsBuilderFailed(#[from] rustls::server::VerifierBuilderError), + #[cfg(all(feature = "openssl", not(feature = "rustls")))] + #[error("TLS verification build failed")] + TlsBuilderFailed(#[from] openssl::error::ErrorStack), + #[error("Operation Timeout")] + Timeout, + + // 2: Data format errors + #[error("io on plist")] + Plist(#[from] plist::Error), + #[error("can't convert bytes to utf8")] + Utf8(#[from] std::string::FromUtf8Error), + #[error("failed to parse bytes as valid utf8")] + Utf8Error, + #[cfg(feature = "_serde_json")] + #[error("JSON serialization failed")] + Json(#[from] serde_json::Error), + #[error("cannot parse string as IpAddr")] + AddrParseError(#[from] std::net::AddrParseError), + #[error("not enough bytes, expected {1}, got {0}")] + NotEnoughBytes(usize, usize), + #[error("integer overflow")] + IntegerOverflow, + #[cfg(feature = "_reqwest")] + #[error("http reqwest error")] + Reqwest(#[from] reqwest::Error), + + // 3: Protocol/device response errors + #[error("unexpected response from device: {0}")] + UnexpectedResponse(String), + #[error("this request was prohibited")] + GetProhibited, + #[error("unknown error `{0}` returned from device")] + UnknownErrorType(String), + #[error("internal error")] + InternalError(String), + + // 4: Connection/session state errors + #[error("no SSL session is active")] + SessionInactive, + #[error("device does not have pairing file")] + InvalidHostID, + #[error("no established connection")] + NoEstablishedConnection, + + // 5: Device state errors + #[error("not found")] + NotFound, + #[error("service not found")] + ServiceNotFound, + #[error("device not found")] + DeviceNotFound, + #[error("device locked")] + DeviceLocked, + #[error("Developer mode is not enabled")] + DeveloperModeNotEnabled, + #[error("unsupported watch key")] + UnsupportedWatchKey, + #[error("malformed command")] + MalformedCommand, + #[error("canceled by user")] + CanceledByUser, + #[error("bad build manifest")] + BadBuildManifest, + #[error("image not mounted")] + ImageNotMounted, + #[cfg(any(feature = "restore", feature = "preboard_service"))] + #[error(transparent)] + Restore(#[from] services::restore::RestoreError), + + // 6: Pairing errors (lockdown) + #[cfg(feature = "pair")] + #[error("pairing trust dialog pending")] + PairingDialogResponsePending, + #[cfg(feature = "pair")] + #[error("user denied pairing trust")] + UserDeniedPairing, + #[cfg(feature = "pair")] + #[error("device is locked")] + PasswordProtected, + + // 7: FFI-specific errors + #[error("invalid arguments were passed")] + FfiInvalidArg, + #[error("invalid string was passed")] + FfiInvalidString, + #[error("buffer passed is too small - needs {0}, got {1}")] + FfiBufferTooSmall(usize, usize), + + #[cfg(any( + feature = "debug_proxy", + all(feature = "afc", feature = "installation_proxy") + ))] + #[error("invalid argument passed")] + InvalidArgument, + + // Service-specific sub-error enums + #[error(transparent)] + Heartbeat(#[from] HeartbeatError), + #[error(transparent)] + CdTunnel(#[from] CdTunnelError), + #[cfg(feature = "usbmuxd")] + #[error(transparent)] + Usbmuxd(#[from] usbmuxd::errors::UsbmuxdError), + #[cfg(feature = "remote_pairing")] + #[error(transparent)] + RemotePairing(#[from] remote_pairing::errors::RemotePairingError), + #[cfg(feature = "xpc")] + #[error(transparent)] + Xpc(#[from] xpc::errors::XpcError), + #[cfg(feature = "dvt")] + #[error(transparent)] + Dvt(#[from] services::dvt::errors::DvtError), + #[cfg(feature = "afc")] + #[error("afc error: {0}")] + Afc(#[from] afc::errors::AfcError), + #[cfg(feature = "installation_proxy")] + #[error(transparent)] + InstallationProxy(#[from] services::installation_proxy::InstallationProxyError), + #[cfg(feature = "core_device")] + #[error(transparent)] + CoreDevice(#[from] services::core_device::CoreDeviceError), + + // Feature-gated service errors (single-variant, not worth a sub-enum) + #[cfg(feature = "misagent")] + #[error("misagent operation failed")] + MisagentFailure, + #[cfg(feature = "crashreportcopymobile")] + #[error("crash report mover sent the wrong response")] + CrashReportMoverBadResponse(Vec), + #[cfg(feature = "notification_proxy")] + #[error("notification proxy died")] + NotificationProxyDeath, + #[cfg(feature = "installation_proxy")] + #[error("Application verification failed: {0}")] + ApplicationVerificationFailed(String), + + #[cfg(feature = "xctest")] + #[error("application is not installed on the device")] + AppNotInstalled, + + #[cfg(feature = "xctest")] + #[error("test runner did not connect within the timeout")] + TestRunnerTimeout, + + #[cfg(feature = "xctest")] + #[error("test runner disconnected before the test plan completed")] + TestRunnerDisconnected, + + #[cfg(feature = "xctest")] + #[error("xctest session timed out after {0:.1}s")] + XcTestTimeout(f64), +} + +impl IdeviceError { + /// Converts a device-reported error string to a typed error + /// + /// # Arguments + /// * `e` - The error string from device + /// * `context` - Full plist context containing additional error details + /// + /// # Returns + /// Some(IdeviceError) if the string maps to a known error type, None otherwise + fn from_device_error_type(e: &str, context: &plist::Dictionary) -> Option { + if e.contains("NSDebugDescription=Canceled by user.") { + return Some(Self::CanceledByUser); + } else if e.contains("Developer mode is not enabled.") { + return Some(Self::DeveloperModeNotEnabled); + } + match e { + "GetProhibited" => Some(Self::GetProhibited), + "InvalidHostID" => Some(Self::InvalidHostID), + "SessionInactive" => Some(Self::SessionInactive), + "DeviceLocked" => Some(Self::DeviceLocked), + #[cfg(feature = "pair")] + "PairingDialogResponsePending" => Some(Self::PairingDialogResponsePending), + #[cfg(feature = "pair")] + "UserDeniedPairing" => Some(Self::UserDeniedPairing), + #[cfg(feature = "pair")] + "PasswordProtected" => Some(Self::PasswordProtected), + "UnsupportedWatchKey" => Some(Self::UnsupportedWatchKey), + "MalformedCommand" => Some(Self::MalformedCommand), + "InternalError" => { + let detailed_error = context + .get("DetailedError") + .and_then(|d| d.as_string()) + .unwrap_or("No context") + .to_string(); + + if detailed_error.contains("There is no matching entry in the device map for") { + Some(Self::ImageNotMounted) + } else { + Some(Self::InternalError(detailed_error)) + } + } + #[cfg(feature = "installation_proxy")] + "ApplicationVerificationFailed" => { + let msg = context + .get("ErrorDescription") + .and_then(|x| x.as_string()) + .unwrap_or("No context") + .to_string(); + Some(Self::ApplicationVerificationFailed(msg)) + } + _ => None, + } + } + + /// Returns the top-level error category code for FFI consumers. + pub fn code(&self) -> i32 { + match self { + // 1: I/O and transport + IdeviceError::Socket(_) => 1, + #[cfg(feature = "rustls")] + IdeviceError::PemParseFailed(_) => 2, + #[cfg(any(feature = "rustls", feature = "openssl"))] + IdeviceError::Rustls(_) => 3, + #[cfg(any(feature = "rustls", feature = "openssl"))] + IdeviceError::TlsBuilderFailed(_) => 4, + IdeviceError::Timeout => 109, + + // 5: Data format + IdeviceError::Plist(_) => 5, + IdeviceError::Utf8(_) => 6, + IdeviceError::Utf8Error => 7, + #[cfg(feature = "_serde_json")] + IdeviceError::Json(_) => 8, + IdeviceError::AddrParseError(_) => 9, + IdeviceError::NotEnoughBytes(_, _) => 10, + IdeviceError::IntegerOverflow => 11, + #[cfg(feature = "_reqwest")] + IdeviceError::Reqwest(_) => 12, + + // 13: Protocol/device response + IdeviceError::UnexpectedResponse(_) => 13, + IdeviceError::GetProhibited => 14, + IdeviceError::UnknownErrorType(_) => 15, + IdeviceError::InternalError(_) => 16, + + // 17: Connection/session state + IdeviceError::SessionInactive => 17, + IdeviceError::InvalidHostID => 18, + IdeviceError::NoEstablishedConnection => 19, + + // 20: Device state + IdeviceError::NotFound => 20, + IdeviceError::ServiceNotFound => 21, + IdeviceError::DeviceNotFound => 22, + IdeviceError::DeviceLocked => 23, + IdeviceError::DeveloperModeNotEnabled => 24, + IdeviceError::UnsupportedWatchKey => 25, + IdeviceError::MalformedCommand => 26, + IdeviceError::CanceledByUser => 27, + IdeviceError::BadBuildManifest => 28, + IdeviceError::ImageNotMounted => 29, + + // 30: Pairing (lockdown) + #[cfg(feature = "pair")] + IdeviceError::PairingDialogResponsePending => 30, + #[cfg(feature = "pair")] + IdeviceError::UserDeniedPairing => 31, + #[cfg(feature = "pair")] + IdeviceError::PasswordProtected => 32, + + // 33: FFI + IdeviceError::FfiInvalidArg => 33, + IdeviceError::FfiInvalidString => 34, + IdeviceError::FfiBufferTooSmall(_, _) => 35, + #[cfg(any( + feature = "debug_proxy", + all(feature = "afc", feature = "installation_proxy") + ))] + IdeviceError::InvalidArgument => 36, + + // 100+: Service sub-error enums + IdeviceError::Heartbeat(_) => 100, + IdeviceError::CdTunnel(_) => 101, + #[cfg(feature = "usbmuxd")] + IdeviceError::Usbmuxd(_) => 102, + #[cfg(feature = "remote_pairing")] + IdeviceError::RemotePairing(_) => 103, + #[cfg(feature = "xpc")] + IdeviceError::Xpc(_) => 104, + #[cfg(feature = "dvt")] + IdeviceError::Dvt(_) => 105, + #[cfg(feature = "afc")] + IdeviceError::Afc(_) => 106, + #[cfg(feature = "installation_proxy")] + IdeviceError::InstallationProxy(_) => 107, + #[cfg(feature = "core_device")] + IdeviceError::CoreDevice(_) => 108, + + // 200+: Feature-gated single-variant service errors + #[cfg(feature = "misagent")] + IdeviceError::MisagentFailure => 200, + #[cfg(feature = "crashreportcopymobile")] + IdeviceError::CrashReportMoverBadResponse(_) => 201, + #[cfg(feature = "notification_proxy")] + IdeviceError::NotificationProxyDeath => 202, + #[cfg(feature = "installation_proxy")] + IdeviceError::ApplicationVerificationFailed(_) => 203, + #[cfg(feature = "xctest")] + IdeviceError::AppNotInstalled => 204, + #[cfg(feature = "xctest")] + IdeviceError::TestRunnerTimeout => 205, + #[cfg(feature = "xctest")] + IdeviceError::TestRunnerDisconnected => 206, + #[cfg(feature = "xctest")] + IdeviceError::XcTestTimeout(_) => 207, + + // 208: Restore (see RestoreError::sub_code for the specific failure) + #[cfg(any(feature = "restore", feature = "preboard_service"))] + IdeviceError::Restore(_) => 208, + } + } + + /// Returns the sub-error code within a category, or 0 if there is no sub-error. + pub fn sub_code(&self) -> i32 { + match self { + IdeviceError::Heartbeat(e) => e.sub_code(), + IdeviceError::CdTunnel(e) => e.sub_code(), + #[cfg(feature = "usbmuxd")] + IdeviceError::Usbmuxd(e) => e.sub_code(), + #[cfg(feature = "remote_pairing")] + IdeviceError::RemotePairing(e) => e.sub_code(), + #[cfg(feature = "xpc")] + IdeviceError::Xpc(e) => e.sub_code(), + #[cfg(feature = "dvt")] + IdeviceError::Dvt(e) => e.sub_code(), + #[cfg(feature = "afc")] + IdeviceError::Afc(e) => e.sub_code(), + #[cfg(feature = "installation_proxy")] + IdeviceError::InstallationProxy(e) => e.sub_code(), + #[cfg(feature = "core_device")] + IdeviceError::CoreDevice(e) => e.sub_code(), + #[cfg(any(feature = "restore", feature = "preboard_service"))] + IdeviceError::Restore(e) => e.sub_code(), + _ => 0, + } + } +} diff --git a/src-tauri/vendor/idevice/src/mdns.rs b/src-tauri/vendor/idevice/src/mdns.rs new file mode 100644 index 0000000..f950fd5 --- /dev/null +++ b/src-tauri/vendor/idevice/src/mdns.rs @@ -0,0 +1,79 @@ +//! Bonjour/mDNS device matching. +//! +//! iOS 26.4+ advertises a paired host over Bonjour (`_apple-mobdev2._tcp`) +//! with an `identifier` TXT value and one or more `authTag` TXT values. A +//! muxer matches a service to a known device by recomputing the auth tag from +//! the device's `HostID` (read from its pairing record) and comparing it +//! against the advertised tags. +//! +//! This mirrors MobileDevice's `AMDIsTXTRecordForUDID`: +//! ```text +//! K = HKDF-SHA512(ikm = HostID, salt = "", info = "", L = 32) +//! expected = HMAC-SHA256(K, identifier)[0..8] +//! match = expected == base64_decode(authTag)[0..8] for some authTag +//! ``` + +use base64::{Engine as _, engine::general_purpose::STANDARD as B64}; +use hkdf::Hkdf; +use hmac::{Hmac, Mac}; +use sha2::{Sha256, Sha512}; + +/// Derives the 8-byte auth tag a device with `host_id` publishes for a given +/// Bonjour `identifier`. +/// +/// `host_id` is the device's `HostID` (the UTF-8 bytes of the string stored in +/// its pairing record). `identifier` is the raw `identifier` TXT value. +pub fn derive_auth_tag(host_id: &[u8], identifier: &[u8]) -> [u8; 8] { + let hk = Hkdf::::new(None, host_id); + let mut key = [0u8; 32]; + // `expand` only fails when the output length exceeds 255 * HashLen; 32 + // bytes is always valid for SHA-512. + hk.expand(&[], &mut key) + .expect("32 is a valid HKDF-SHA512 output length"); + + // HMAC accepts a key of any length, so this never errors. + let mut mac = as Mac>::new_from_slice(&key) + .expect("HMAC-SHA256 accepts keys of any length"); + mac.update(identifier); + let tag = mac.finalize().into_bytes(); + + let mut out = [0u8; 8]; + out.copy_from_slice(&tag[..8]); + out +} + +/// Decodes an `authTag` TXT value to its 8-byte form. +/// +/// Bonjour TXT values are raw bytes; the `authTag` entries carry base64-encoded +/// 8-byte HMAC truncations. ASCII whitespace is trimmed before decoding (as +/// MobileDevice does). Anything that doesn't decode to exactly 8 bytes returns +/// `None`. +pub fn decode_auth_tag(raw: &[u8]) -> Option<[u8; 8]> { + let trimmed = raw + .iter() + .position(|b| !b.is_ascii_whitespace()) + .map(|start| { + let end = raw + .iter() + .rposition(|b| !b.is_ascii_whitespace()) + .map(|i| i + 1) + .unwrap_or(raw.len()); + &raw[start..end] + }) + .unwrap_or(&[][..]); + let decoded = B64.decode(trimmed).ok()?; + decoded.as_slice().try_into().ok() +} + +/// Returns `true` if the device with `host_id` published any of `auth_tags` +/// for `identifier`. +/// +/// `auth_tags` are the raw (base64) `authTag` TXT values; each is decoded and +/// compared against the tag derived from `host_id`. +pub fn txt_record_matches(host_id: &[u8], identifier: &[u8], auth_tags: &[&[u8]]) -> bool { + let expected = derive_auth_tag(host_id, identifier); + auth_tags + .iter() + .filter_map(|tag| decode_auth_tag(tag)) + .any(|tag| tag == expected) +} diff --git a/src-tauri/vendor/idevice/src/obfuscation.rs b/src-tauri/vendor/idevice/src/obfuscation.rs new file mode 100644 index 0000000..d101c09 --- /dev/null +++ b/src-tauri/vendor/idevice/src/obfuscation.rs @@ -0,0 +1,15 @@ +// Jackson Coxson + +#[macro_export] +macro_rules! obf { + ($lit:literal) => {{ + #[cfg(feature = "obfuscate")] + { + std::borrow::Cow::Owned(obfstr::obfstr!($lit).to_string()) + } + #[cfg(not(feature = "obfuscate"))] + { + std::borrow::Cow::Borrowed($lit) + } + }}; +} diff --git a/src-tauri/vendor/idevice/src/pairing_file.rs b/src-tauri/vendor/idevice/src/pairing_file.rs new file mode 100644 index 0000000..1e00aaf --- /dev/null +++ b/src-tauri/vendor/idevice/src/pairing_file.rs @@ -0,0 +1,377 @@ +//! iOS Device Pairing File Handling +//! +//! Provides functionality for reading, writing, and manipulating iOS device pairing files +//! which contain the cryptographic materials needed for secure communication with devices. + +use std::path::Path; + +#[cfg(all(feature = "openssl", not(feature = "rustls")))] +use openssl::{ + pkey::{PKey, Private}, + x509::X509, +}; +use plist::Data; +#[cfg(feature = "rustls")] +use rustls::pki_types::{CertificateDer, pem::PemObject}; +use serde::{Deserialize, Serialize}; +use tracing::warn; + +/// Represents a complete iOS device pairing record +/// +/// Contains all cryptographic materials and identifiers needed for secure communication +/// with an iOS device, including certificates, private keys, and device identifiers. +#[cfg(feature = "rustls")] +#[derive(Clone, Debug)] +pub struct PairingFile { + /// Device's certificate in DER format + pub device_certificate: CertificateDer<'static>, + /// Host's private key in DER format + pub host_private_key: Vec, + /// Host's certificate in DER format + pub host_certificate: CertificateDer<'static>, + /// Root CA's private key in DER format + pub root_private_key: Vec, + /// Root CA's certificate in DER format + pub root_certificate: CertificateDer<'static>, + /// System Build Unique Identifier + pub system_buid: String, + /// Host identifier + pub host_id: String, + /// Escrow bag allowing for access while locked + pub escrow_bag: Option>, + /// Device's WiFi MAC address + pub wifi_mac_address: String, + /// Device's Unique Device Identifier (optional) + pub udid: Option, +} + +#[cfg(all(feature = "openssl", not(feature = "rustls")))] +#[derive(Clone, Debug)] +pub struct PairingFile { + pub device_certificate: X509, + pub host_private_key: PKey, + pub host_certificate: X509, + pub root_private_key: PKey, + pub root_certificate: X509, + pub system_buid: String, + pub host_id: String, + pub escrow_bag: Option>, + pub wifi_mac_address: String, + pub udid: Option, +} + +/// Internal representation of a pairing file for serialization/deserialization +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "PascalCase")] +struct RawPairingFile { + device_certificate: Data, + host_private_key: Data, + host_certificate: Data, + root_private_key: Data, + root_certificate: Data, + #[serde(rename = "SystemBUID")] + system_buid: String, + #[serde(rename = "HostID")] + host_id: String, + escrow_bag: Option, // None on Apple Watch + #[serde(rename = "WiFiMACAddress")] + wifi_mac_address: String, + #[serde(rename = "UDID")] + udid: Option, +} + +impl PairingFile { + /// Reads a pairing file from disk + /// + /// # Arguments + /// * `path` - Path to the pairing file (typically a .plist file) + /// + /// # Returns + /// A parsed `PairingFile` on success + /// + /// # Errors + /// Returns `IdeviceError` if: + /// - The file cannot be read + /// - The contents are malformed + /// - Cryptographic materials are invalid + pub fn read_from_file(path: impl AsRef) -> Result { + let f = std::fs::read(path)?; + Self::from_bytes(&f) + } + + /// Parses a pairing file from raw bytes + /// + /// # Arguments + /// * `bytes` - Raw bytes of the pairing file (typically PLIST format) + /// + /// # Returns + /// A parsed `PairingFile` on success + /// + /// # Errors + /// Returns `IdeviceError` if: + /// - The data cannot be parsed as PLIST + /// - Required fields are missing + /// - Cryptographic materials are invalid + pub fn from_bytes(bytes: &[u8]) -> Result { + let r = match ::plist::from_bytes::(bytes) { + Ok(r) => r, + Err(e) => { + warn!("Unable to convert bytes to raw pairing file: {e:?}"); + return Err(crate::IdeviceError::UnexpectedResponse( + "failed to parse raw pairing file from bytes".into(), + )); + } + }; + + match r.try_into() { + Ok(r) => Ok(r), + Err(e) => { + warn!("Unable to convert raw pairing file into pairing file: {e:?}"); + Err(crate::IdeviceError::UnexpectedResponse( + "failed to convert raw pairing file into pairing file".into(), + )) + } + } + } + + /// Creates a pairing file from a plist value + /// + /// # Arguments + /// * `v` - PLIST value containing pairing data + /// + /// # Returns + /// A parsed `PairingFile` on success + /// + /// # Errors + /// Returns `IdeviceError` if: + /// - Required fields are missing + /// - Cryptographic materials are invalid + pub fn from_value(v: &plist::Value) -> Result { + let raw: RawPairingFile = plist::from_value(v)?; + match raw.try_into() { + Ok(p) => Ok(p), + Err(e) => { + warn!("Unable to convert raw pairing file into pairing file: {e:?}"); + Err(crate::IdeviceError::UnexpectedResponse( + "failed to convert raw pairing file into pairing file".into(), + )) + } + } + } + + /// Serializes the pairing file to a PLIST-formatted byte vector + /// + /// # Returns + /// A byte vector containing the serialized pairing file + /// + /// # Errors + /// Returns `IdeviceError` if serialization fails + #[cfg(feature = "rustls")] + pub fn serialize(self) -> Result, crate::IdeviceError> { + let raw = RawPairingFile::from(self); + + let mut buf = Vec::new(); + plist::to_writer_xml(&mut buf, &raw)?; + Ok(buf) + } + + #[cfg(all(feature = "openssl", not(feature = "rustls")))] + pub fn serialize(self) -> Result, crate::IdeviceError> { + let raw = RawPairingFile::try_from(self)?; + + let mut buf = Vec::new(); + plist::to_writer_xml(&mut buf, &raw)?; + Ok(buf) + } +} + +#[cfg(feature = "rustls")] +impl TryFrom for PairingFile { + type Error = rustls::pki_types::pem::Error; + + /// Attempts to convert a raw pairing file into a structured pairing file + /// + /// Performs validation of cryptographic materials during conversion. + fn try_from(value: RawPairingFile) -> Result { + // Convert raw data into certificates and keys with proper PEM format + let device_cert_data = Into::>::into(value.device_certificate); + let host_private_key_data = Into::>::into(value.host_private_key); + let host_cert_data = Into::>::into(value.host_certificate); + let root_private_key_data = Into::>::into(value.root_private_key); + let root_cert_data = Into::>::into(value.root_certificate); + + // Ensure device certificate has proper PEM headers + let device_certificate_pem = ensure_pem_headers(&device_cert_data, "CERTIFICATE"); + + // Ensure host certificate has proper PEM headers + let host_certificate_pem = ensure_pem_headers(&host_cert_data, "CERTIFICATE"); + + // Ensure root certificate has proper PEM headers + let root_certificate_pem = ensure_pem_headers(&root_cert_data, "CERTIFICATE"); + + Ok(Self { + device_certificate: CertificateDer::from_pem_slice(&device_certificate_pem)?, + host_private_key: host_private_key_data, + host_certificate: CertificateDer::from_pem_slice(&host_certificate_pem)?, + root_private_key: root_private_key_data, + root_certificate: CertificateDer::from_pem_slice(&root_certificate_pem)?, + system_buid: value.system_buid, + host_id: value.host_id, + escrow_bag: value.escrow_bag.map(|x| x.into()), + wifi_mac_address: value.wifi_mac_address, + udid: value.udid, + }) + } +} + +#[cfg(all(feature = "openssl", not(feature = "rustls")))] +impl TryFrom for PairingFile { + type Error = openssl::error::ErrorStack; + + fn try_from(value: RawPairingFile) -> Result { + Ok(Self { + device_certificate: X509::from_pem(&Into::>::into(value.device_certificate))?, + host_private_key: PKey::private_key_from_pem(&Into::>::into( + value.host_private_key, + ))?, + host_certificate: X509::from_pem(&Into::>::into(value.host_certificate))?, + root_private_key: PKey::private_key_from_pem(&Into::>::into( + value.root_private_key, + ))?, + root_certificate: X509::from_pem(&Into::>::into(value.root_certificate))?, + system_buid: value.system_buid, + host_id: value.host_id, + escrow_bag: value.escrow_bag.map(|x| x.into()), + wifi_mac_address: value.wifi_mac_address, + udid: value.udid, + }) + } +} + +#[cfg(feature = "rustls")] +impl From for RawPairingFile { + /// Converts a structured pairing file into a raw pairing file for serialization + fn from(value: PairingFile) -> Self { + // Ensure certificates include proper PEM format + let device_cert_data = ensure_pem_headers(&value.device_certificate, "CERTIFICATE"); + let host_cert_data = ensure_pem_headers(&value.host_certificate, "CERTIFICATE"); + let root_cert_data = ensure_pem_headers(&value.root_certificate, "CERTIFICATE"); + + // Ensure private keys include proper PEM format + let host_private_key_data = ensure_pem_headers(&value.host_private_key, "PRIVATE KEY"); + let root_private_key_data = ensure_pem_headers(&value.root_private_key, "PRIVATE KEY"); + + Self { + device_certificate: Data::new(device_cert_data), + host_private_key: Data::new(host_private_key_data), + host_certificate: Data::new(host_cert_data), + root_private_key: Data::new(root_private_key_data), + root_certificate: Data::new(root_cert_data), + system_buid: value.system_buid, + host_id: value.host_id.clone(), + escrow_bag: value.escrow_bag.map(Data::new), + wifi_mac_address: value.wifi_mac_address, + udid: value.udid, + } + } +} + +#[cfg(all(feature = "openssl", not(feature = "rustls")))] +impl TryFrom for RawPairingFile { + type Error = openssl::error::ErrorStack; + + fn try_from(value: PairingFile) -> Result { + Ok(Self { + device_certificate: Data::new(value.device_certificate.to_pem()?), + host_private_key: Data::new(value.host_private_key.private_key_to_pem_pkcs8()?), + host_certificate: Data::new(value.host_certificate.to_pem()?), + root_private_key: Data::new(value.root_private_key.private_key_to_pem_pkcs8()?), + root_certificate: Data::new(value.root_certificate.to_pem()?), + system_buid: value.system_buid, + host_id: value.host_id.clone(), + escrow_bag: value.escrow_bag.map(Data::new), + wifi_mac_address: value.wifi_mac_address, + udid: value.udid, + }) + } +} + +#[cfg(feature = "rustls")] +/// Helper function to ensure data has proper PEM headers +/// If the data already has headers, it returns it as is +/// If not, it adds the appropriate BEGIN and END headers +fn ensure_pem_headers(data: &[u8], pem_type: &str) -> Vec { + if is_pem_formatted(data) { + return data.to_vec(); + } + + // If it's just base64 data, add PEM headers + let mut result = Vec::new(); + + // Add header + let header = format!("-----BEGIN {pem_type}-----\n"); + result.extend_from_slice(header.as_bytes()); + + // Add base64 content with line breaks every 64 characters + let base64_content = if is_base64(data) { + // Clean up any existing whitespace/newlines + let data_str = String::from_utf8_lossy(data); + data_str.replace(['\n', '\r', ' '], "").into_bytes() + } else { + let engine = base64::prelude::BASE64_STANDARD; + base64::Engine::encode(&engine, data).into_bytes() + }; + + // Format base64 content with proper line breaks (64 chars per line) + for (i, chunk) in base64_content.chunks(64).enumerate() { + if i > 0 { + result.push(b'\n'); + } + result.extend_from_slice(chunk); + } + + // Add a final newline before the footer + result.push(b'\n'); + + // Add footer + let footer = format!("-----END {pem_type}-----"); + result.extend_from_slice(footer.as_bytes()); + + result +} + +#[cfg(feature = "rustls")] +/// Check if data is already in PEM format +fn is_pem_formatted(data: &[u8]) -> bool { + if let Ok(data_str) = std::str::from_utf8(data) { + data_str.contains("-----BEGIN") && data_str.contains("-----END") + } else { + false + } +} + +#[cfg(feature = "rustls")] +/// Check if data is already base64 encoded +fn is_base64(data: &[u8]) -> bool { + if let Ok(data_str) = std::str::from_utf8(data) { + // Simple check to see if string contains only valid base64 characters + data_str.chars().all(|c| { + c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '=' || c.is_whitespace() + }) + } else { + false + } +} + +#[test] +fn test_pairing_file_roundtrip() { + let f = std::fs::read("/var/lib/lockdown/test.plist").unwrap(); + + println!("{}", String::from_utf8_lossy(&f)); + + let input = PairingFile::from_bytes(&f).unwrap(); + let output = input.serialize().unwrap(); + println!("{}", String::from_utf8_lossy(&output)); + + assert_eq!(f[..output.len()], output); +} diff --git a/src-tauri/vendor/idevice/src/provider.rs b/src-tauri/vendor/idevice/src/provider.rs new file mode 100644 index 0000000..c2a6241 --- /dev/null +++ b/src-tauri/vendor/idevice/src/provider.rs @@ -0,0 +1,181 @@ +//! iOS Device Connection Providers +//! +//! Provides abstractions for establishing connections to iOS devices through different +//! transport mechanisms (TCP, USB, etc.). + +use std::{future::Future, pin::Pin}; + +#[cfg(feature = "tcp")] +use std::net::IpAddr; +#[cfg(feature = "tcp")] +use std::net::{SocketAddr, SocketAddrV6}; + +#[cfg(feature = "tcp")] +use tokio::net::TcpStream; + +use crate::{Idevice, IdeviceError, ReadWrite, pairing_file::PairingFile}; + +#[cfg(feature = "usbmuxd")] +use crate::usbmuxd::UsbmuxdAddr; + +/// Trait for providers that can establish connections to iOS devices +/// +/// This is an async trait that abstracts over different connection methods +/// (TCP, USB, etc.). +pub trait IdeviceProvider: Unpin + Send + Sync + std::fmt::Debug { + /// Establishes a connection to the specified port on the device + /// + /// # Arguments + /// * `port` - The port number to connect to + /// + /// # Returns + /// A future that resolves to an `Idevice` connection handle + fn connect( + &self, + port: u16, + ) -> Pin> + Send>>; + + /// Returns a label identifying this provider/connection + fn label(&self) -> &str; + + /// Retrieves the pairing file needed for secure communication + /// + /// # Returns + /// A future that resolves to the device's `PairingFile` + fn get_pairing_file( + &self, + ) -> Pin> + Send>>; +} + +pub trait RsdProvider: Unpin + Send + Sync + std::fmt::Debug { + fn connect_to_service_port( + &mut self, + port: u16, + ) -> impl std::future::Future, IdeviceError>> + Send; +} + +/// TCP-based device connection provider +#[cfg(feature = "tcp")] +#[derive(Debug)] +pub struct TcpProvider { + /// IP address of the device + pub addr: std::net::IpAddr, + /// An optional scope ID may be provided for IPv6 addresses. + pub scope_id: Option, + /// Pairing file for secure communication + pub pairing_file: PairingFile, + /// Label identifying this connection + pub label: String, +} + +#[cfg(feature = "tcp")] +impl IdeviceProvider for TcpProvider { + /// Connects to the device over TCP + /// + /// # Arguments + /// * `port` - The TCP port to connect to + /// + /// # Returns + /// An `Idevice` wrapped in a future + fn connect( + &self, + port: u16, + ) -> Pin> + Send>> { + let addr = self.addr; + let label = self.label.clone(); + let scope_id = self.scope_id.unwrap_or(0); + Box::pin(async move { + let socket_addr = match addr { + IpAddr::V4(_) => SocketAddr::new(addr, port), + IpAddr::V6(ipv6) => SocketAddr::V6(SocketAddrV6::new(ipv6, port, 0, scope_id)), + }; + let stream = TcpStream::connect(socket_addr).await?; + Ok(Idevice::new(Box::new(stream), label)) + }) + } + + /// Returns the connection label + fn label(&self) -> &str { + &self.label + } + + /// Returns the pairing file (cloned from the provider) + fn get_pairing_file( + &self, + ) -> Pin> + Send>> { + let pairing_file = self.pairing_file.clone(); + Box::pin(async move { Ok(pairing_file) }) + } +} + +/// USB-based device connection provider using usbmuxd +#[cfg(feature = "usbmuxd")] +#[derive(Debug, Clone)] +pub struct UsbmuxdProvider { + /// USB connection address + pub addr: UsbmuxdAddr, + /// Connection tag/identifier + pub tag: u32, + /// Device UDID + pub udid: String, + /// Device ID + pub device_id: u32, + /// Connection label + pub label: String, +} + +#[cfg(feature = "usbmuxd")] +impl IdeviceProvider for UsbmuxdProvider { + /// Connects to the device over USB via usbmuxd + /// + /// # Arguments + /// * `port` - The port number to connect to on the device + /// + /// # Returns + /// An `Idevice` wrapped in a future + fn connect( + &self, + port: u16, + ) -> Pin> + Send>> { + let addr = self.addr.clone(); + let tag = self.tag; + let device_id = self.device_id; + let label = self.label.clone(); + + Box::pin(async move { + let usbmuxd = addr.connect(tag).await?; + usbmuxd.connect_to_device(device_id, port, &label).await + }) + } + + /// Returns the connection label + fn label(&self) -> &str { + &self.label + } + + /// Retrieves the pairing record from usbmuxd + fn get_pairing_file( + &self, + ) -> Pin> + Send>> { + let addr = self.addr.clone(); + let tag = self.tag; + let udid = self.udid.clone(); + + Box::pin(async move { + let mut usbmuxd = addr.connect(tag).await?; + usbmuxd.get_pair_record(&udid).await + }) + } +} + +#[cfg(feature = "tcp")] +impl RsdProvider for std::net::IpAddr { + async fn connect_to_service_port( + &mut self, + port: u16, + ) -> Result, IdeviceError> { + Ok(Box::new( + tokio::net::TcpStream::connect((*self, port)).await?, + )) + } +} diff --git a/src-tauri/vendor/idevice/src/remote_pairing/errors.rs b/src-tauri/vendor/idevice/src/remote_pairing/errors.rs new file mode 100644 index 0000000..a7dd00b --- /dev/null +++ b/src-tauri/vendor/idevice/src/remote_pairing/errors.rs @@ -0,0 +1,39 @@ +// Jackson Coxson + +/// Errors specific to remote pairing operations +#[derive(thiserror::Error, Debug)] +#[non_exhaustive] +pub enum RemotePairingError { + #[error("Unknown TLV: {0}")] + UnknownTlv(u8), + #[error("Malformed TLV")] + MalformedTlv, + #[error("Pairing rejected: {0}")] + PairingRejected(String), + #[cfg(feature = "remote_pairing")] + #[error("Base64 decode error")] + Base64DecodeError(#[from] base64::DecodeError), + #[error("Pair verify failed")] + PairVerifyFailed, + #[error("SRP auth failed")] + SrpAuthFailed, + #[cfg(feature = "remote_pairing")] + #[error("Chacha encryption error")] + ChachaEncryption(chacha20poly1305::Error), +} + +impl RemotePairingError { + pub fn sub_code(&self) -> i32 { + match self { + Self::UnknownTlv(_) => 1, + Self::MalformedTlv => 2, + Self::PairingRejected(_) => 3, + #[cfg(feature = "remote_pairing")] + Self::Base64DecodeError(_) => 4, + Self::PairVerifyFailed => 5, + Self::SrpAuthFailed => 6, + #[cfg(feature = "remote_pairing")] + Self::ChachaEncryption(_) => 7, + } + } +} diff --git a/src-tauri/vendor/idevice/src/remote_pairing/mod.rs b/src-tauri/vendor/idevice/src/remote_pairing/mod.rs new file mode 100644 index 0000000..b4833f9 --- /dev/null +++ b/src-tauri/vendor/idevice/src/remote_pairing/mod.rs @@ -0,0 +1,920 @@ +//! Remote Pairing + +use crate::IdeviceError; +use base64::Engine as _; +use errors::RemotePairingError; + +use chacha20poly1305::{ + ChaCha20Poly1305, Key, KeyInit, Nonce, + aead::{Aead, Payload}, +}; +use ed25519_dalek::Signature; +use hkdf::Hkdf; +use idevice_srp::{client::SrpClient, groups::G_3072}; +use plist_macro::plist; +use plist_macro::{PlistConvertible, PlistExt}; +use rand::Rng as _; +use rsa::{rand_core::OsRng, signature::SignerMut}; +use serde::Serialize; +use sha2::Sha512; +use tracing::{debug, warn}; +use x25519_dalek::{EphemeralSecret, PublicKey as X25519PublicKey}; + +pub mod errors; +mod opack; +mod peer_device; +mod responder; +mod rp_pairing_file; +mod socket; +pub mod tls_psk; +mod tlv; +pub mod tunnel; + +// export +pub use peer_device::{PeerDevice, compute_auth_tag}; +pub use responder::{PAIRABLE_HOST_SERVICE_TYPE, PairableHost, PairableHostInfo}; +pub use rp_pairing_file::RpPairingFile; +pub use socket::{RpPairingSocket, RpPairingSocketProvider}; +#[cfg(feature = "openssl")] +pub use tunnel::connect_tls_psk_tunnel; +pub use tunnel::{CdTunnel, TunnelInfo, connect_tls_psk_tunnel_native}; + +const RPPAIRING_MAGIC: &[u8] = b"RPPairing"; +const WIRE_PROTOCOL_VERSION: u8 = 19; + +pub struct RemotePairingClient { + inner: R, + sequence_number: usize, + encrypted_sequence_number: u64, + sending_host: String, + + /// The shared secret from X25519 (pair-verify) or SRP (initial pairing). + /// Used as PSK for the TLS tunnel and to derive per-message encryption keys. + encryption_key: Vec, + + client_cipher: ChaCha20Poly1305, + server_cipher: ChaCha20Poly1305, + + paired_peer_device: Option, +} + +impl RemotePairingClient { + pub fn new(inner: R, sending_host: &str) -> Self { + // Initial ciphers are placeholders; they will be re-derived from + // the actual encryption_key once pair-verify or pairing completes. + let initial_key = vec![0u8; 32]; + let (client_cipher, server_cipher) = Self::derive_main_ciphers(&initial_key); + + Self { + inner, + sequence_number: 0, + encrypted_sequence_number: 0, + sending_host: sending_host.to_string(), + encryption_key: initial_key, + client_cipher, + server_cipher, + paired_peer_device: None, + } + } + + fn derive_main_ciphers(key: &[u8]) -> (ChaCha20Poly1305, ChaCha20Poly1305) { + let hk = Hkdf::::new(None, key); + let mut okm = [0u8; 32]; + hk.expand(b"ClientEncrypt-main", &mut okm).unwrap(); + let client_cipher = ChaCha20Poly1305::new(chacha20poly1305::Key::from_slice(&okm)); + + let hk = Hkdf::::new(None, key); + let mut okm = [0u8; 32]; + hk.expand(b"ServerEncrypt-main", &mut okm).unwrap(); + let server_cipher = ChaCha20Poly1305::new(chacha20poly1305::Key::from_slice(&okm)); + + (client_cipher, server_cipher) + } + + /// Returns the encryption key established during pairing. + /// This is used as TLS-PSK for tunnel connections. + pub fn encryption_key(&self) -> &[u8] { + &self.encryption_key + } + + pub async fn connect( + &mut self, + pairing_file: &mut RpPairingFile, + pin_callback: impl Fn() -> Fut, + ) -> Result<(), IdeviceError> + where + Fut: std::future::Future, + { + self.attempt_pair_verify().await?; + + if self.validate_pairing(pairing_file).await.is_err() { + self.pair(pairing_file, pin_callback).await?; + } + Ok(()) + } + + /// Returns peer device info captured during this client's successful `pair()` flow. + pub fn paired_peer_device(&self) -> Result<&PeerDevice, IdeviceError> { + self.paired_peer_device + .as_ref() + .ok_or(IdeviceError::UnexpectedResponse( + "paired peer device info is only available after a successful pair() call".into(), + )) + } + + pub async fn validate_pairing( + &mut self, + pairing_file: &mut RpPairingFile, + ) -> Result<(), IdeviceError> { + let x_private_key = EphemeralSecret::random_from_rng(OsRng); + let x_public_key = X25519PublicKey::from(&x_private_key); + + let pairing_data = tlv::serialize_tlv8(&[ + tlv::TLV8Entry { + tlv_type: tlv::PairingDataComponentType::State, + data: vec![0x01], + }, + tlv::TLV8Entry { + tlv_type: tlv::PairingDataComponentType::PublicKey, + data: x_public_key.to_bytes().to_vec(), + }, + ]); + let pairing_data = R::serialize_bytes(&pairing_data); + self.send_pairing_data(plist!({ + "data": pairing_data, + "kind": "verifyManualPairing", + "startNewSession": true + })) + .await?; + debug!("Waiting for response from verifyManualPairing"); + + let pairing_data = self.receive_pairing_data().await?; + + let data = match R::deserialize_bytes(pairing_data) { + Some(d) => d, + None => { + return Err(IdeviceError::UnexpectedResponse( + "failed to deserialize pair-verify response bytes".into(), + )); + } + }; + + let data = tlv::deserialize_tlv8(&data)?; + + if data + .iter() + .any(|x| x.tlv_type == tlv::PairingDataComponentType::ErrorResponse) + { + self.send_pair_verified_failed().await?; + return Err(RemotePairingError::PairVerifyFailed.into()); + } + + let device_public_key = match data + .iter() + .find(|x| x.tlv_type == tlv::PairingDataComponentType::PublicKey) + { + Some(d) => d, + None => { + warn!("No public key in TLV data"); + return Err(IdeviceError::UnexpectedResponse( + "missing public key in pair-verify TLV data".into(), + )); + } + }; + let peer_pub_bytes: [u8; 32] = match device_public_key.data.as_slice().try_into() { + Ok(d) => d, + Err(_) => { + warn!("Device public key isn't the expected size"); + return Err(IdeviceError::NotEnoughBytes( + 32, + device_public_key.data.len(), + )); + } + }; + let device_public_key = x25519_dalek::PublicKey::from(peer_pub_bytes); + let shared_secret = x_private_key.diffie_hellman(&device_public_key); + + // Save the raw shared secret as the encryption key for tunnel PSK + self.encryption_key = shared_secret.as_bytes().to_vec(); + + // Derive encryption key with HKDF-SHA512 + let hk = + Hkdf::::new(Some(b"Pair-Verify-Encrypt-Salt"), shared_secret.as_bytes()); + + let mut okm = [0u8; 32]; + hk.expand(b"Pair-Verify-Encrypt-Info", &mut okm).unwrap(); + + // ChaCha20Poly1305 AEAD cipher + let cipher = ChaCha20Poly1305::new(chacha20poly1305::Key::from_slice(&okm)); + + let ed25519_signing_key = &mut pairing_file.e_private_key; + + let mut signbuf = Vec::with_capacity(32 + pairing_file.identifier.len() + 32); + signbuf.extend_from_slice(x_public_key.as_bytes()); // 32 bytes + signbuf.extend_from_slice(pairing_file.identifier.as_bytes()); // variable + signbuf.extend_from_slice(device_public_key.as_bytes()); // 32 bytes + + let signature: Signature = ed25519_signing_key.sign(&signbuf); + + let plaintext = vec![ + tlv::TLV8Entry { + tlv_type: tlv::PairingDataComponentType::Identifier, + data: pairing_file.identifier.as_bytes().to_vec(), + }, + tlv::TLV8Entry { + tlv_type: tlv::PairingDataComponentType::Signature, + data: signature.to_vec(), + }, + ]; + let plaintext = tlv::serialize_tlv8(&plaintext); + let nonce = Nonce::from_slice(b"\x00\x00\x00\x00PV-Msg03"); // 12-byte nonce + let ciphertext = cipher + .encrypt( + nonce, + Payload { + msg: &plaintext, + aad: &[], + }, + ) + .expect("encryption should not fail"); + + let msg = vec![ + tlv::TLV8Entry { + tlv_type: tlv::PairingDataComponentType::State, + data: [0x03].to_vec(), + }, + tlv::TLV8Entry { + tlv_type: tlv::PairingDataComponentType::EncryptedData, + data: ciphertext, + }, + ]; + + debug!("Waiting for signbuf response"); + self.send_pairing_data(plist! ({ + "data": R::serialize_bytes(&tlv::serialize_tlv8(&msg)), + "kind": "verifyManualPairing", + "startNewSession": false + })) + .await?; + let res = self.receive_pairing_data().await?; + + let data = match R::deserialize_bytes(res) { + Some(d) => d, + None => { + return Err(IdeviceError::UnexpectedResponse( + "failed to deserialize pair-verify signature response bytes".into(), + )); + } + }; + let data = tlv::deserialize_tlv8(&data)?; + debug!("Verify TLV: {data:#?}"); + + // Check if the device responded with an error (which is expected for a new pairing) + if data + .iter() + .any(|x| x.tlv_type == tlv::PairingDataComponentType::ErrorResponse) + { + debug!( + "Verification failed, device reported an error. This is expected for a new pairing." + ); + self.send_pair_verified_failed().await?; + // Return a specific error to the caller. + return Err(RemotePairingError::PairVerifyFailed.into()); + } + + // Re-derive main encryption ciphers from the X25519 shared secret + let (cc, sc) = Self::derive_main_ciphers(&self.encryption_key); + self.client_cipher = cc; + self.server_cipher = sc; + + Ok(()) + } + + pub async fn send_pair_verified_failed(&mut self) -> Result<(), IdeviceError> { + self.inner + .send_plain( + plist!({ + "event": { + "_0": { + "pairVerifyFailed": {} + } + } + }), + self.sequence_number, + ) + .await?; + self.sequence_number += 1; + Ok(()) + } + + pub async fn attempt_pair_verify(&mut self) -> Result { + debug!("Sending attemptPairVerify"); + self.inner + .send_plain( + plist!({ + "request": { + "_0": { + "handshake": { + "_0": { + "hostOptions": { + "attemptPairVerify": true + }, + "wireProtocolVersion": plist::Value::Integer(WIRE_PROTOCOL_VERSION.into()), + } + } + } + } + }), + self.sequence_number, + ) + .await?; + self.sequence_number += 1; + + debug!("Waiting for attemptPairVerify response"); + let response = self.inner.recv_plain().await?; + + let response = response + .as_dictionary() + .and_then(|x| x.get("response")) + .and_then(|x| x.as_dictionary()) + .and_then(|x| x.get("_1")) + .and_then(|x| x.as_dictionary()) + .and_then(|x| x.get("handshake")) + .and_then(|x| x.as_dictionary()) + .and_then(|x| x.get("_0")); + + match response { + Some(v) => Ok(v.to_owned()), + None => Err(IdeviceError::UnexpectedResponse( + "missing handshake response in attemptPairVerify".into(), + )), + } + } + + pub async fn pair( + &mut self, + pairing_file: &mut RpPairingFile, + pin_callback: impl Fn() -> Fut, + ) -> Result<(), IdeviceError> + where + Fut: std::future::Future, + { + let (salt, public_key, pin) = self.request_pair_consent(pin_callback).await?; + let key = self.init_srp_context(&salt, &public_key, &pin).await?; + let tlv = self.save_pair_record_on_peer(pairing_file, &key).await?; + let peer_device = peer_device::parse_peer_device_from_tlv(&tlv)?; + + pairing_file.alt_irk = Some(peer_device.alt_irk.clone()); + self.paired_peer_device = Some(peer_device); + + Ok(()) + } + + /// Returns salt and public key and pin + async fn request_pair_consent( + &mut self, + pin_callback: impl Fn() -> Fut, + ) -> Result<(Vec, Vec, String), IdeviceError> + where + Fut: std::future::Future, + { + let tlv = tlv::serialize_tlv8(&[ + tlv::TLV8Entry { + tlv_type: tlv::PairingDataComponentType::Method, + data: vec![0x00], + }, + tlv::TLV8Entry { + tlv_type: tlv::PairingDataComponentType::State, + data: vec![0x01], + }, + ]); + let tlv = R::serialize_bytes(&tlv); + self.send_pairing_data(plist!({ + "data": tlv, + "kind": "setupManualPairing", + "sendingHost": &self.sending_host, + "startNewSession": true + })) + .await?; + + let response = self.inner.recv_plain().await?; + let response = match response + .get_by("event") + .and_then(|x| x.get_by("_0")) + .and_then(|x| x.as_dictionary()) + { + Some(r) => r, + None => { + return Err(IdeviceError::UnexpectedResponse( + "missing event._0 in pair consent response".into(), + )); + } + }; + + let pin: Option = None; + + let pairing_data = match if let Some(err) = response.get("pairingRejectedWithError") { + let context = err + .get_by("wrappedError") + .and_then(|x| x.get_by("userInfo")) + .and_then(|x| x.get_by("NSLocalizedDescription")) + .and_then(|x| x.as_string()) + .map(|x| x.to_string()); + return Err(RemotePairingError::PairingRejected(context.unwrap_or_default()).into()); + } else if response.get("awaitingUserConsent").is_some() { + // PATCH (iloader visionOS fork): don't assume consent is a Trust-style + // dialog with the sentinel PIN "000000". An Apple Vision Pro answers + // `awaitingUserConsent` when its pairing-code screen isn't up yet, then + // delivers the pairing data once the code is on screen — SRP must use + // that real code from the callback, or the device rejects our proof + // ("missing server proof in SRP response") on every first attempt. + // Leaving `pin` unset routes this branch to `pin_callback` below. + Some(self.receive_pairing_data().await?) + } else { + // On Apple TV, we can get the pin now + response + .get_by("pairingData") + .and_then(|x| x.get_by("_0")) + .and_then(|x| x.get_by("data")) + .map(|x| x.to_owned()) + } { + Some(p) => p, + None => { + return Err(IdeviceError::UnexpectedResponse( + "missing pairing data in pair consent response".into(), + )); + } + }; + + let tlv = tlv::deserialize_tlv8(&match R::deserialize_bytes(pairing_data) { + Some(t) => t, + None => { + return Err(IdeviceError::UnexpectedResponse( + "failed to deserialize pairing data bytes in pair consent".into(), + )); + } + })?; + debug!("Received pairingData response: {tlv:#?}"); + + let mut salt = Vec::new(); + let mut public_key = Vec::new(); + for t in tlv { + match t.tlv_type { + tlv::PairingDataComponentType::Salt => { + salt = t.data; + } + tlv::PairingDataComponentType::PublicKey => { + public_key.extend(t.data); + } + tlv::PairingDataComponentType::ErrorResponse => { + warn!("Pairing data contained error response"); + return Err(IdeviceError::UnexpectedResponse( + "pairing data contained error response during pair consent".into(), + )); + } + _ => { + continue; + } + } + } + + let pin = match pin { + Some(p) => p, + None => pin_callback().await, + }; + + if salt.is_empty() || public_key.is_empty() { + warn!("Pairing data did not contain salt or public key"); + return Err(IdeviceError::UnexpectedResponse( + "pairing data missing salt or public key".into(), + )); + } + + Ok((salt, public_key, pin)) + } + + /// Returns the encryption key + async fn init_srp_context( + &mut self, + salt: &[u8], + public_key: &[u8], + pin: &str, + ) -> Result, IdeviceError> { + let client = SrpClient::::new( + &G_3072, // PRIME_3072 + generator + ); + + let mut a_private = [0u8; 32]; + rand::rng().fill_bytes(&mut a_private); + + let a_public = client.compute_public_ephemeral(&a_private); + + let verifier = match client.process_reply( + &a_private, + "Pair-Setup".as_bytes(), + &pin.as_bytes()[..6], + salt, + public_key, + false, + ) { + Ok(v) => v, + Err(e) => { + warn!("SRP verifier creation failed: {e:?}"); + return Err(RemotePairingError::SrpAuthFailed.into()); + } + }; + + let client_proof = verifier.proof(); + + let tlv = tlv::serialize_tlv8(&[ + tlv::TLV8Entry { + tlv_type: tlv::PairingDataComponentType::State, + data: vec![0x03], + }, + tlv::TLV8Entry { + tlv_type: tlv::PairingDataComponentType::PublicKey, + data: a_public[..254].to_vec(), + }, + tlv::TLV8Entry { + tlv_type: tlv::PairingDataComponentType::PublicKey, + data: a_public[254..].to_vec(), + }, + tlv::TLV8Entry { + tlv_type: tlv::PairingDataComponentType::Proof, + data: client_proof.to_vec(), + }, + ]); + let tlv = R::serialize_bytes(&tlv); + + self.send_pairing_data(plist!({ + "data": tlv, + "kind": "setupManualPairing", + "sendingHost": &self.sending_host, + "startNewSession": false, + + })) + .await?; + + let response = self.receive_pairing_data().await?; + let response = tlv::deserialize_tlv8(&match R::deserialize_bytes(response.to_owned()) { + Some(r) => r, + None => { + return Err(IdeviceError::UnexpectedResponse( + "failed to deserialize SRP proof response bytes".into(), + )); + } + })?; + + debug!("Proof response: {response:#?}"); + + let proof = match response + .iter() + .find(|x| x.tlv_type == tlv::PairingDataComponentType::Proof) + { + Some(p) => &p.data, + None => { + warn!("Proof response did not contain server proof"); + return Err(IdeviceError::UnexpectedResponse( + "missing server proof in SRP response".into(), + )); + } + }; + + match verifier.verify_server(proof) { + Ok(_) => Ok(verifier.key().to_vec()), + Err(e) => { + warn!("Server auth failed: {e:?}"); + Err(RemotePairingError::SrpAuthFailed.into()) + } + } + } + + async fn save_pair_record_on_peer( + &mut self, + pairing_file: &mut RpPairingFile, + encryption_key: &[u8], + ) -> Result, IdeviceError> { + let salt = b"Pair-Setup-Encrypt-Salt"; + let info = b"Pair-Setup-Encrypt-Info"; + + let hk = Hkdf::::new(Some(salt), encryption_key); + let mut setup_encryption_key = [0u8; 32]; + hk.expand(info, &mut setup_encryption_key) + .expect("HKDF expand failed"); + + // Save the SRP session key as the encryption key + self.encryption_key = encryption_key.to_vec(); + + pairing_file.recreate_signing_keys(); + { + // Re-derive main ciphers from the SRP session key + let (cc, sc) = Self::derive_main_ciphers(encryption_key); + self.client_cipher = cc; + self.server_cipher = sc; + } + + let hk = Hkdf::::new(Some(b"Pair-Setup-Controller-Sign-Salt"), encryption_key); + + let mut signbuf = Vec::with_capacity(32 + pairing_file.identifier.len() + 32); + + let mut hkdf_out = [0u8; 32]; + hk.expand(b"Pair-Setup-Controller-Sign-Info", &mut hkdf_out) + .expect("HKDF expand failed"); + + signbuf.extend_from_slice(&hkdf_out); + + signbuf.extend_from_slice(pairing_file.identifier.as_bytes()); + signbuf.extend_from_slice(pairing_file.e_public_key.as_bytes()); + + let signature = pairing_file.e_private_key.sign(&signbuf); + + let device_info = crate::plist!({ + "altIRK": b"\xe9\xe8-\xc0jIykVoT\x00\x19\xb1\xc7{".to_vec(), + "btAddr": "11:22:33:44:55:66", + "mac": b"\x11\x22\x33\x44\x55\x66".to_vec(), + "remotepairing_serial_number": "AAAAAAAAAAAA", + "accountID": pairing_file.identifier.as_str(), + "model": "computer-model", + "name": self.sending_host.as_str() + }); + let device_info = opack::plist_to_opack(&device_info); + + let tlv = tlv::serialize_tlv8(&[ + tlv::TLV8Entry { + tlv_type: tlv::PairingDataComponentType::Identifier, + data: pairing_file.identifier.as_bytes().to_vec(), + }, + tlv::TLV8Entry { + tlv_type: tlv::PairingDataComponentType::PublicKey, + data: pairing_file.e_public_key.to_bytes().to_vec(), + }, + tlv::TLV8Entry { + tlv_type: tlv::PairingDataComponentType::Signature, + data: signature.to_vec(), + }, + tlv::TLV8Entry { + tlv_type: tlv::PairingDataComponentType::Info, + data: device_info, + }, + ]); + + let key = Key::from_slice(&setup_encryption_key); // 32 bytes + let cipher = ChaCha20Poly1305::new(key); + + let nonce = Nonce::from_slice(b"\x00\x00\x00\x00PS-Msg05"); // 12 bytes + + let plaintext = &tlv; + + let ciphertext = match cipher.encrypt( + nonce, + Payload { + msg: plaintext, + aad: b"", + }, + ) { + Ok(c) => c, + Err(e) => { + warn!("Chacha encryption failed: {e:?}"); + return Err(RemotePairingError::ChachaEncryption(e).into()); + } + }; + debug!("ciphertext len: {}", ciphertext.len()); + + let tlv = tlv::serialize_tlv8(&[ + tlv::TLV8Entry { + tlv_type: tlv::PairingDataComponentType::EncryptedData, + data: ciphertext[..254].to_vec(), + }, + tlv::TLV8Entry { + tlv_type: tlv::PairingDataComponentType::EncryptedData, + data: ciphertext[254..].to_vec(), + }, + tlv::TLV8Entry { + tlv_type: tlv::PairingDataComponentType::State, + data: vec![0x05], + }, + ]); + let tlv = R::serialize_bytes(&tlv); + + debug!("Sending encrypted data"); + self.send_pairing_data(plist!({ + "data": tlv, + "kind": "setupManualPairing", + "sendingHost": &self.sending_host, + "startNewSession": false, + })) + .await?; + + debug!("Waiting for encrypted data"); + let response = match R::deserialize_bytes(self.receive_pairing_data().await?) { + Some(r) => r, + None => { + warn!("Pairing data response was not deserializable"); + return Err(IdeviceError::UnexpectedResponse( + "failed to deserialize pair record response bytes".into(), + )); + } + }; + + let tlv = tlv::deserialize_tlv8(&response)?; + + let mut encrypted_data = Vec::new(); + for t in tlv { + match t.tlv_type { + tlv::PairingDataComponentType::EncryptedData => encrypted_data.extend(t.data), + tlv::PairingDataComponentType::ErrorResponse => { + warn!("TLV contained error response"); + return Err(IdeviceError::UnexpectedResponse( + "TLV error response in pair record save".into(), + )); + } + _ => {} + } + } + + let nonce = Nonce::from_slice(b"\x00\x00\x00\x00PS-Msg06"); + + let plaintext = cipher + .decrypt( + nonce, + Payload { + msg: &encrypted_data, + aad: b"", + }, + ) + .expect("decryption failure!"); + + let tlv = tlv::deserialize_tlv8(&plaintext)?; + + debug!("Decrypted plaintext TLV: {tlv:?}"); + Ok(tlv) + } + + /// Send an encrypted request and receive an encrypted response. + /// Used for post-pairing RPCs like creating tunnel listeners. + pub async fn send_receive_encrypted_request( + &mut self, + request: plist::Value, + ) -> Result { + let plaintext = serde_json::to_vec( + &plist::to_value(&request).map_err(|e| IdeviceError::InternalError(e.to_string()))?, + ) + .map_err(|e| IdeviceError::InternalError(e.to_string()))?; + + // Build nonce: 8-byte LE sequence number + 4 zero bytes + let mut nonce_bytes = [0u8; 12]; + nonce_bytes[..8].copy_from_slice(&self.encrypted_sequence_number.to_le_bytes()); + let nonce = Nonce::from_slice(&nonce_bytes); + + let ciphertext = self + .client_cipher + .encrypt( + nonce, + Payload { + msg: &plaintext, + aad: b"", + }, + ) + .map_err(|e| IdeviceError::RemotePairing(RemotePairingError::ChachaEncryption(e)))?; + + self.inner + .send_encrypted(ciphertext, self.sequence_number) + .await?; + self.sequence_number += 1; + + // Receive encrypted response + let response = self.inner.recv_plain().await?; + let encrypted_data = response + .get_by("message") + .and_then(|m| m.get_by("streamEncrypted")) + .and_then(|s| s.get_by("_0")) + .and_then(|d| { + // Could be bytes directly or base64 + R::deserialize_bytes(d.to_owned()) + }) + .ok_or(IdeviceError::UnexpectedResponse( + "missing encrypted data in streamEncrypted response".into(), + ))?; + + let decrypted = self + .server_cipher + .decrypt( + nonce, + Payload { + msg: &encrypted_data, + aad: b"", + }, + ) + .map_err(|e| IdeviceError::RemotePairing(RemotePairingError::ChachaEncryption(e)))?; + + self.encrypted_sequence_number += 1; + + let value: plist::Value = serde_json::from_slice(&decrypted) + .map_err(|e| IdeviceError::InternalError(e.to_string()))?; + + // Extract response._1 + let result = value + .get_by("response") + .and_then(|r| r.get_by("_1")) + .cloned() + .ok_or(IdeviceError::UnexpectedResponse( + "missing response._1 in encrypted response".into(), + ))?; + + Ok(result) + } + + /// Send a request to create a TCP tunnel listener on the device. + /// Returns the port the device is listening on. + pub async fn create_tcp_listener(&mut self) -> Result { + let request = plist!({ + "request": { + "_0": { + "createListener": { + "key": base64::engine::general_purpose::STANDARD.encode(&self.encryption_key), + "transportProtocolType": "tcp" + } + } + } + }); + + let response = self.send_receive_encrypted_request(request).await?; + debug!("createListener response: {response:#?}"); + + let port = response + .get_by("createListener") + .and_then(|c| c.get_by("port")) + .and_then(|p| p.as_unsigned_integer()) + .ok_or(IdeviceError::UnexpectedResponse( + "missing port in createListener response".into(), + ))?; + + Ok(port as u16) + } + + async fn send_pairing_data( + &mut self, + pairing_data: impl Serialize + PlistConvertible, + ) -> Result<(), IdeviceError> { + self.inner + .send_plain( + plist!({ + "event": { + "_0": { + "pairingData": { + "_0": pairing_data + } + } + } + }), + self.sequence_number, + ) + .await?; + + self.sequence_number += 1; + Ok(()) + } + + async fn receive_pairing_data(&mut self) -> Result { + let response = self.inner.recv_plain().await?; + + let response = match response.get_by("event").and_then(|x| x.get_by("_0")) { + Some(r) => r, + None => { + return Err(IdeviceError::UnexpectedResponse( + "missing event._0 in pairing data response".into(), + )); + } + }; + + if let Some(data) = response + .get_by("pairingData") + .and_then(|x| x.get_by("_0")) + .and_then(|x| x.get_by("data")) + { + Ok(data.to_owned()) + } else if let Some(err) = response.get_by("pairingRejectedWithError") { + let context = err + .get_by("wrappedError") + .and_then(|x| x.get_by("userInfo")) + .and_then(|x| x.get_by("NSLocalizedDescription")) + .and_then(|x| x.as_string()) + .map(|x| x.to_string()); + Err(RemotePairingError::PairingRejected(context.unwrap_or_default()).into()) + } else { + Err(IdeviceError::UnexpectedResponse( + "pairing data response contained neither data nor rejection".into(), + )) + } + } +} + +impl std::fmt::Debug for RemotePairingClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RemotePairingClient") + .field("inner", &self.inner) + .field("sequence_number", &self.sequence_number) + .field("sending_host", &self.sending_host) + .finish() + } +} diff --git a/src-tauri/vendor/idevice/src/remote_pairing/opack.rs b/src-tauri/vendor/idevice/src/remote_pairing/opack.rs new file mode 100644 index 0000000..b1955d5 --- /dev/null +++ b/src-tauri/vendor/idevice/src/remote_pairing/opack.rs @@ -0,0 +1,397 @@ +// Jackson Coxson + +use plist::Value; + +pub fn opack_to_plist(bytes: &[u8]) -> Result { + let mut offset = 0; + let value = opack_to_plist_inner(bytes, &mut offset)?; + if offset != bytes.len() { + return Err(format!( + "unexpected trailing bytes after OPACK payload: {}", + bytes.len() - offset + )); + } + Ok(value) +} + +pub fn plist_to_opack(value: &Value) -> Vec { + let mut buf = Vec::new(); + plist_to_opack_inner(value, &mut buf); + + buf +} + +fn plist_to_opack_inner(node: &Value, buf: &mut Vec) { + match node { + Value::Dictionary(dict) => { + let count = dict.len() as u32; + let blen = if count < 15 { + (count as u8).wrapping_sub(32) + } else { + 0xEF + }; + buf.push(blen); + + for (key, val) in dict { + plist_to_opack_inner(&Value::String(key.clone()), buf); + plist_to_opack_inner(val, buf); + } + + if count > 14 { + buf.push(0x03); + } + } + Value::Array(array) => { + let count = array.len() as u32; + let blen = if count < 15 { + (count as u8).wrapping_sub(48) + } else { + 0xDF + }; + buf.push(blen); + + for val in array { + plist_to_opack_inner(val, buf); + } + + if count > 14 { + buf.push(0x03); // Terminator + } + } + Value::Boolean(b) => { + let bval = if *b { 1u8 } else { 2u8 }; + buf.push(bval); + } + Value::Integer(integer) => { + let u64val = integer.as_unsigned().unwrap_or(0); + + if u64val <= u8::MAX as u64 { + let u8val = u64val as u8; + if u8val > 0x27 { + buf.push(0x30); + buf.push(u8val); + } else { + buf.push(u8val + 8); + } + } else if u64val <= u32::MAX as u64 { + buf.push(0x32); + buf.extend_from_slice(&(u64val as u32).to_le_bytes()); + } else { + buf.push(0x33); + buf.extend_from_slice(&u64val.to_le_bytes()); + } + } + Value::Real(real) => { + let dval = *real; + let fval = dval as f32; + + if fval as f64 == dval { + buf.push(0x35); + buf.extend_from_slice(&fval.to_bits().swap_bytes().to_ne_bytes()); + } else { + buf.push(0x36); + buf.extend_from_slice(&dval.to_bits().swap_bytes().to_ne_bytes()); + } + } + Value::String(s) => { + let bytes = s.as_bytes(); + let len = bytes.len(); + + if len > 0x20 { + if len <= 0xFF { + buf.push(0x61); + buf.push(len as u8); + } else if len <= 0xFFFF { + buf.push(0x62); + buf.extend_from_slice(&(len as u16).to_le_bytes()); + } else if len <= 0xFFFFFFFF { + buf.push(0x63); + buf.extend_from_slice(&(len as u32).to_le_bytes()); + } else { + buf.push(0x64); + buf.extend_from_slice(&(len as u64).to_le_bytes()); + } + } else { + buf.push(0x40 + len as u8); + } + buf.extend_from_slice(bytes); + } + Value::Data(data) => { + let len = data.len(); + if len > 0x20 { + if len <= 0xFF { + buf.push(0x91); + buf.push(len as u8); + } else if len <= 0xFFFF { + buf.push(0x92); + buf.extend_from_slice(&(len as u16).to_le_bytes()); + } else if len <= 0xFFFFFFFF { + buf.push(0x93); + buf.extend_from_slice(&(len as u32).to_le_bytes()); + } else { + buf.push(0x94); + buf.extend_from_slice(&(len as u64).to_le_bytes()); + } + } else { + buf.push(0x70 + len as u8); + } + buf.extend_from_slice(data); + } + _ => {} + } +} + +fn opack_to_plist_inner(bytes: &[u8], offset: &mut usize) -> Result { + let tag = read_u8(bytes, offset)?; + match tag { + 0x01 => Ok(Value::Boolean(true)), + 0x02 => Ok(Value::Boolean(false)), + 0x08..=0x2F => Ok(Value::Integer((tag as u64 - 8).into())), + 0x30 => Ok(Value::Integer((read_u8(bytes, offset)? as u64).into())), + 0x32 => Ok(Value::Integer( + (u32::from_le_bytes(read_exact::<4>(bytes, offset)?) as u64).into(), + )), + 0x33 => Ok(Value::Integer( + u64::from_le_bytes(read_exact::<8>(bytes, offset)?).into(), + )), + 0x35 => { + let n = u32::from_ne_bytes(read_exact::<4>(bytes, offset)?).swap_bytes(); + Ok(Value::Real(f32::from_bits(n) as f64)) + } + 0x36 => { + let n = u64::from_ne_bytes(read_exact::<8>(bytes, offset)?).swap_bytes(); + Ok(Value::Real(f64::from_bits(n))) + } + 0x40..=0x64 => parse_string_value(tag, bytes, offset), + 0x70..=0x94 => parse_data_value(tag, bytes, offset), + 0xD0..=0xDE => parse_array(bytes, offset, Some((tag - 0xD0) as usize)), + 0xDF => parse_array(bytes, offset, None), + 0xE0..=0xEE => parse_dictionary(bytes, offset, Some((tag - 0xE0) as usize)), + 0xEF => parse_dictionary(bytes, offset, None), + 0x03 => Err("unexpected OPACK terminator".into()), + _ => Err(format!("unsupported OPACK tag: 0x{tag:02x}")), + } +} + +fn parse_string_value(bytes_tag: u8, bytes: &[u8], offset: &mut usize) -> Result { + let len = read_sized_len( + bytes_tag, + bytes, + offset, + SizedLenTags { + inline_base: 0x40, + u8_tag: 0x61, + u16_tag: 0x62, + u32_tag: 0x63, + u64_tag: 0x64, + kind: "string", + }, + )?; + Ok(Value::String(read_string(bytes, offset, len)?)) +} + +fn parse_data_value(bytes_tag: u8, bytes: &[u8], offset: &mut usize) -> Result { + let len = read_sized_len( + bytes_tag, + bytes, + offset, + SizedLenTags { + inline_base: 0x70, + u8_tag: 0x91, + u16_tag: 0x92, + u32_tag: 0x93, + u64_tag: 0x94, + kind: "data", + }, + )?; + Ok(Value::Data(read_vec(bytes, offset, len)?)) +} + +#[derive(Copy, Clone)] +struct SizedLenTags { + inline_base: u8, + u8_tag: u8, + u16_tag: u8, + u32_tag: u8, + u64_tag: u8, + kind: &'static str, +} + +fn read_sized_len( + tag: u8, + bytes: &[u8], + offset: &mut usize, + tags: SizedLenTags, +) -> Result { + match tag { + t if (tags.inline_base..tags.u8_tag).contains(&t) => Ok((tag - tags.inline_base) as usize), + t if t == tags.u8_tag => Ok(read_u8(bytes, offset)? as usize), + t if t == tags.u16_tag => Ok(u16::from_le_bytes(read_exact::<2>(bytes, offset)?) as usize), + t if t == tags.u32_tag => Ok(u32::from_le_bytes(read_exact::<4>(bytes, offset)?) as usize), + t if t == tags.u64_tag => { + let len_u64 = u64::from_le_bytes(read_exact::<8>(bytes, offset)?); + usize::try_from(len_u64) + .map_err(|_| format!("{} too large for this platform: {len_u64}", tags.kind)) + } + _ => Err(format!("unsupported OPACK {} tag: 0x{tag:02x}", tags.kind)), + } +} + +fn parse_array(bytes: &[u8], offset: &mut usize, count: Option) -> Result { + let mut items = Vec::with_capacity(count.unwrap_or(0)); + + match count { + Some(count) => { + for _ in 0..count { + items.push(opack_to_plist_inner(bytes, offset)?); + } + } + None => { + while !peek_is_terminator(bytes, *offset) { + items.push(opack_to_plist_inner(bytes, offset)?); + } + *offset += 1; + } + } + + Ok(Value::Array(items)) +} + +fn parse_dictionary( + bytes: &[u8], + offset: &mut usize, + count: Option, +) -> Result { + let mut dict = plist::Dictionary::new(); + + match count { + Some(count) => { + for _ in 0..count { + let key = read_dictionary_key(bytes, offset)?; + let value = opack_to_plist_inner(bytes, offset)?; + dict.insert(key, value); + } + } + None => { + while !peek_is_terminator(bytes, *offset) { + let key = read_dictionary_key(bytes, offset)?; + let value = opack_to_plist_inner(bytes, offset)?; + dict.insert(key, value); + } + *offset += 1; + } + } + + Ok(Value::Dictionary(dict)) +} + +fn read_dictionary_key(bytes: &[u8], offset: &mut usize) -> Result { + opack_to_plist_inner(bytes, offset)? + .into_string() + .ok_or_else(|| "dictionary key is not a string".to_string()) +} + +fn peek_is_terminator(bytes: &[u8], offset: usize) -> bool { + bytes.get(offset).copied() == Some(0x03) +} + +fn read_u8(bytes: &[u8], offset: &mut usize) -> Result { + let b = bytes + .get(*offset) + .copied() + .ok_or_else(|| "unexpected EOF while reading OPACK tag".to_string())?; + *offset += 1; + Ok(b) +} + +fn read_exact(bytes: &[u8], offset: &mut usize) -> Result<[u8; N], String> { + let end = offset.saturating_add(N); + let slice = bytes + .get(*offset..end) + .ok_or_else(|| format!("unexpected EOF while reading {N} bytes"))?; + let mut out = [0u8; N]; + out.copy_from_slice(slice); + *offset = end; + Ok(out) +} + +fn read_vec(bytes: &[u8], offset: &mut usize, len: usize) -> Result, String> { + let end = offset.saturating_add(len); + let slice = bytes + .get(*offset..end) + .ok_or_else(|| format!("unexpected EOF while reading {len} bytes"))?; + *offset = end; + Ok(slice.to_vec()) +} + +fn read_string(bytes: &[u8], offset: &mut usize, len: usize) -> Result { + let data = read_vec(bytes, offset, len)?; + String::from_utf8(data).map_err(|e| format!("invalid UTF-8 string in OPACK payload: {e}")) +} + +#[cfg(test)] +mod tests { + #[test] + fn t1() { + let v = crate::plist!({ + "altIRK": b"\xe9\xe8-\xc0jIykVoT\x00\x19\xb1\xc7{".to_vec(), + "btAddr": "11:22:33:44:55:66", + "mac": b"\x11\x22\x33\x44\x55\x66".to_vec(), + "remotepairing_serial_number": "AAAAAAAAAAAA", + "accountID": "lolsssss", + "model": "computer-model", + "name": "reeeee", + }); + + let res = super::plist_to_opack(&v); + + let expected = [ + 0xe7, 0x46, 0x61, 0x6c, 0x74, 0x49, 0x52, 0x4b, 0x80, 0xe9, 0xe8, 0x2d, 0xc0, 0x6a, + 0x49, 0x79, 0x6b, 0x56, 0x6f, 0x54, 0x00, 0x19, 0xb1, 0xc7, 0x7b, 0x46, 0x62, 0x74, + 0x41, 0x64, 0x64, 0x72, 0x51, 0x31, 0x31, 0x3a, 0x32, 0x32, 0x3a, 0x33, 0x33, 0x3a, + 0x34, 0x34, 0x3a, 0x35, 0x35, 0x3a, 0x36, 0x36, 0x43, 0x6d, 0x61, 0x63, 0x76, 0x11, + 0x22, 0x33, 0x44, 0x55, 0x66, 0x5b, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x70, 0x61, + 0x69, 0x72, 0x69, 0x6e, 0x67, 0x5f, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x5f, 0x6e, + 0x75, 0x6d, 0x62, 0x65, 0x72, 0x4c, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, + 0x41, 0x41, 0x41, 0x41, 0x49, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x44, + 0x48, 0x6c, 0x6f, 0x6c, 0x73, 0x73, 0x73, 0x73, 0x73, 0x45, 0x6d, 0x6f, 0x64, 0x65, + 0x6c, 0x4e, 0x63, 0x6f, 0x6d, 0x70, 0x75, 0x74, 0x65, 0x72, 0x2d, 0x6d, 0x6f, 0x64, + 0x65, 0x6c, 0x44, 0x6e, 0x61, 0x6d, 0x65, 0x46, 0x72, 0x65, 0x65, 0x65, 0x65, 0x65, + ]; + + println!("{res:02X?}"); + assert_eq!(res, expected); + } + + #[test] + fn t2() { + let v = [ + 0xe7, 0x46, 0x61, 0x6c, 0x74, 0x49, 0x52, 0x4b, 0x80, 0xe9, 0xe8, 0x2d, 0xc0, 0x6a, + 0x49, 0x79, 0x6b, 0x56, 0x6f, 0x54, 0x00, 0x19, 0xb1, 0xc7, 0x7b, 0x46, 0x62, 0x74, + 0x41, 0x64, 0x64, 0x72, 0x51, 0x31, 0x31, 0x3a, 0x32, 0x32, 0x3a, 0x33, 0x33, 0x3a, + 0x34, 0x34, 0x3a, 0x35, 0x35, 0x3a, 0x36, 0x36, 0x43, 0x6d, 0x61, 0x63, 0x76, 0x11, + 0x22, 0x33, 0x44, 0x55, 0x66, 0x5b, 0x72, 0x65, 0x6d, 0x6f, 0x74, 0x65, 0x70, 0x61, + 0x69, 0x72, 0x69, 0x6e, 0x67, 0x5f, 0x73, 0x65, 0x72, 0x69, 0x61, 0x6c, 0x5f, 0x6e, + 0x75, 0x6d, 0x62, 0x65, 0x72, 0x4c, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, 0x41, + 0x41, 0x41, 0x41, 0x41, 0x49, 0x61, 0x63, 0x63, 0x6f, 0x75, 0x6e, 0x74, 0x49, 0x44, + 0x48, 0x6c, 0x6f, 0x6c, 0x73, 0x73, 0x73, 0x73, 0x73, 0x45, 0x6d, 0x6f, 0x64, 0x65, + 0x6c, 0x4e, 0x63, 0x6f, 0x6d, 0x70, 0x75, 0x74, 0x65, 0x72, 0x2d, 0x6d, 0x6f, 0x64, + 0x65, 0x6c, 0x44, 0x6e, 0x61, 0x6d, 0x65, 0x46, 0x72, 0x65, 0x65, 0x65, 0x65, 0x65, + ]; + + let expected = crate::plist!({ + "altIRK": b"\xe9\xe8-\xc0jIykVoT\x00\x19\xb1\xc7{".to_vec(), + "btAddr": "11:22:33:44:55:66", + "mac": b"\x11\x22\x33\x44\x55\x66".to_vec(), + "remotepairing_serial_number": "AAAAAAAAAAAA", + "accountID": "lolsssss", + "model": "computer-model", + "name": "reeeee", + }); + + let res = super::opack_to_plist(&v).unwrap(); + + println!("{res:02X?}"); + assert_eq!(res, expected); + } +} diff --git a/src-tauri/vendor/idevice/src/remote_pairing/peer_device.rs b/src-tauri/vendor/idevice/src/remote_pairing/peer_device.rs new file mode 100644 index 0000000..266e12e --- /dev/null +++ b/src-tauri/vendor/idevice/src/remote_pairing/peer_device.rs @@ -0,0 +1,214 @@ +use std::hash::Hasher; + +use base64::Engine; +use siphasher::sip::SipHasher; + +use crate::IdeviceError; + +use super::{opack, tlv}; + +#[derive(Debug, Clone)] +pub struct PeerDevice { + /// peer identifier, same as the identifier returned in the `verifyManualPairing` response + pub account_id: String, + /// altIRK: 16-byte + pub alt_irk: Vec, + /// Device's model identifier, e.g. "iPhone14,4" + pub model: String, + /// Device's name + pub name: String, + /// Device's Unique Device Identifier + pub remotepairing_udid: String, +} + +impl PeerDevice { + /// Validates a `_remotepairing._tcp` mDNS `authTag`. + /// + /// - `alt_irk`: 16-byte mDNS identity key (`alt_irk`) stored in the pairing file + /// - `service_identifier`: the service identifier from the mDNS TXT record + /// - `auth_tag`: 6-byte auth tag from the mDNS TXT record + /// + /// Computes `SipHash-2-4(key=alt_irk, msg=service_identifier)` and compares the + /// first 6 bytes of the 8-byte LE output (reversed) against `auth_tag`. + pub fn validate_auth_tag(alt_irk: &[u8], service_identifier: &str, auth_tag: &str) -> bool { + let bytes = match base64::engine::general_purpose::STANDARD.decode(auth_tag) { + Ok(b) => b, + Err(_) => return false, + }; + if bytes.len() != 6 { + return false; + } + let Ok(alt_irk) = <&[u8; 16]>::try_from(alt_irk) else { + return false; + }; + compute_auth_tag(alt_irk, service_identifier) == bytes.as_slice() + } + + pub fn try_from_info_dictionary(dict: &plist::Dictionary) -> Result { + let alt_irk = required_data_field(dict, "altIRK")?; + if alt_irk.len() != 16 { + return Err(IdeviceError::UnexpectedResponse(format!( + "invalid altIRK length in peer device info: expected 16 bytes, got {}", + alt_irk.len() + ))); + } + + Ok(Self { + account_id: required_string_field(dict, "accountID")?, + alt_irk, + model: required_string_field(dict, "model")?, + name: required_string_field(dict, "name")?, + remotepairing_udid: required_string_field(dict, "remotepairing_udid")?, + }) + } +} + +fn required_string_field(dict: &plist::Dictionary, key: &str) -> Result { + dict.get(key) + .and_then(|value| value.as_string()) + .map(str::to_string) + .ok_or(IdeviceError::UnexpectedResponse(format!( + "missing string field `{key}` in peer device info" + ))) +} + +fn required_data_field(dict: &plist::Dictionary, key: &str) -> Result, IdeviceError> { + dict.get(key) + .and_then(|value| value.as_data()) + .map(|value| value.to_vec()) + .ok_or(IdeviceError::UnexpectedResponse(format!( + "missing data field `{key}` in peer device info" + ))) +} + +fn parse_info_dictionary_from_tlv( + entries: &[tlv::TLV8Entry], +) -> Result { + if tlv::contains_component(entries, tlv::PairingDataComponentType::ErrorResponse) { + return Err(IdeviceError::UnexpectedResponse( + "TLV error response in pair record save".into(), + )); + } + + let info = tlv::collect_component_data(entries, tlv::PairingDataComponentType::Info); + if info.is_empty() { + return Err(IdeviceError::UnexpectedResponse( + "missing info payload in pair record response".into(), + )); + } + + let info_plist = opack::opack_to_plist(&info).map_err(|e| { + IdeviceError::UnexpectedResponse(format!( + "failed to parse OPACK info payload from pair record response: {e}" + )) + })?; + + let info_dict = info_plist + .as_dictionary() + .ok_or(IdeviceError::UnexpectedResponse( + "info OPACK payload is not a dictionary".into(), + ))?; + + Ok(info_dict.to_owned()) +} + +pub(super) fn parse_peer_device_from_tlv( + entries: &[tlv::TLV8Entry], +) -> Result { + let info = parse_info_dictionary_from_tlv(entries)?; + PeerDevice::try_from_info_dictionary(&info) +} + +/// Computes the 6-byte mDNS `authTag` for the given `alt_irk` and `service_identifier`. +/// +/// Algorithm: `SipHash-2-4(key=alt_irk, msg=service_identifier)` → take 8-byte LE output, +/// return the first 6 bytes in **reverse** order. +/// +/// Use this to populate the `authTag` TXT record when advertising a +/// `_remotepairing-pairable-host._tcp` service (base64-encode the result). +pub fn compute_auth_tag(alt_irk: &[u8; 16], service_identifier: &str) -> [u8; 6] { + let k0 = u64::from_le_bytes(alt_irk[..8].try_into().unwrap()); + let k1 = u64::from_le_bytes(alt_irk[8..16].try_into().unwrap()); + let mut sip = SipHasher::new_with_keys(k0, k1); + sip.write(service_identifier.as_bytes()); + let output = sip.finish().to_le_bytes(); + let mut tag = [0u8; 6]; + for i in 0..6 { + tag[i] = output[5 - i]; + } + tag +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample_peer_device_dictionary() -> plist::Dictionary { + let mut dict = plist::Dictionary::new(); + dict.insert( + "accountID".into(), + plist::Value::String("test-account".into()), + ); + dict.insert("altIRK".into(), plist::Value::Data(vec![0xAB; 16])); + dict.insert("model".into(), plist::Value::String("AppleTV11,1".into())); + dict.insert("name".into(), plist::Value::String("Living Room".into())); + dict.insert( + "remotepairing_udid".into(), + plist::Value::String("00008110-001A2B3C00000000".into()), + ); + dict + } + + #[test] + fn peer_device_requires_all_fields() { + let mut dict = sample_peer_device_dictionary(); + dict.remove("model"); + + let err = PeerDevice::try_from_info_dictionary(&dict).unwrap_err(); + + assert!( + matches!(err, IdeviceError::UnexpectedResponse(message) if message.contains("model")) + ); + } + + #[test] + fn peer_device_parses_required_fields() { + let dict = sample_peer_device_dictionary(); + + let peer_device = PeerDevice::try_from_info_dictionary(&dict).unwrap(); + + assert_eq!(peer_device.account_id, "test-account"); + assert_eq!(peer_device.alt_irk, vec![0xAB; 16]); + assert_eq!(peer_device.model, "AppleTV11,1"); + assert_eq!(peer_device.name, "Living Room"); + assert_eq!(peer_device.remotepairing_udid, "00008110-001A2B3C00000000"); + } + + #[test] + fn parse_peer_device_from_tlv_reads_info_payload() { + let info = plist::Value::Dictionary(sample_peer_device_dictionary()); + let entries = vec![tlv::TLV8Entry { + tlv_type: tlv::PairingDataComponentType::Info, + data: opack::plist_to_opack(&info), + }]; + + let peer_device = parse_peer_device_from_tlv(&entries).unwrap(); + + assert_eq!(peer_device.name, "Living Room"); + assert_eq!(peer_device.alt_irk.len(), 16); + } + + #[test] + fn validate_auth_tag_returns_true_for_correct_tag() { + let alt_irk = base64::engine::general_purpose::STANDARD + .decode("Mgp6ZGPzXM2ku9br46vsiw==") + .unwrap(); + let service_identifier = "2BE6E510-0325-4365-923E-B14C6F57DB3A"; + let auth_tag = "kXjlTr2l"; + assert!(PeerDevice::validate_auth_tag( + &alt_irk, + service_identifier, + auth_tag + )); + } +} diff --git a/src-tauri/vendor/idevice/src/remote_pairing/responder.rs b/src-tauri/vendor/idevice/src/remote_pairing/responder.rs new file mode 100644 index 0000000..296ef77 --- /dev/null +++ b/src-tauri/vendor/idevice/src/remote_pairing/responder.rs @@ -0,0 +1,552 @@ +//! Device-initiated remote pairing (the "pairable host" / responder side). +//! +//! Starting with iOS 27, a device can initiate pairing to a +//! computer instead of the computer initiating pairing to the device. The +//! computer advertises an `_remotepairing-pairable-host._tcp` mDNS service; the +//! device connects to the advertised port and drives an rppairing conversation. +//! +//! In that conversation the roles are flipped relative to [`super::RemotePairingClient`]: +//! the device plays the rppairing "host" (it sends `originatedBy: "host"`, +//! initiates the handshake and the SRP pair-setup), while the computer +//! play the rppairing "device"/accessory (`originatedBy: "device"`). We generate +//! and display the setup PIN; the user types it into the iOS device. + +use base64::{Engine as _, engine::general_purpose::STANDARD as B64}; +use chacha20poly1305::{ + ChaCha20Poly1305, Key, KeyInit, Nonce, + aead::{Aead, Payload}, +}; +use hkdf::Hkdf; +use idevice_srp::{client::SrpClient, groups::G_3072, server::SrpServer}; +use plist::Value; +use plist_macro::{PlistExt, plist}; +use rand::{Rng as _, RngExt as _}; +use rsa::signature::SignerMut as _; +use sha2::Sha512; +use tracing::{debug, warn}; + +use crate::IdeviceError; + +use super::{ + PeerDevice, RpPairingFile, errors::RemotePairingError, opack, peer_device, tlv, + tlv::PairingDataComponentType as Tt, +}; + +/// The DNS-SD service type a pairable host advertises over mDNS. A device browses +/// for this and, when the user taps "Pair with ...", connects to the advertised +/// host/port and drives the rppairing conversation handled by [`PairableHost`]. +pub const PAIRABLE_HOST_SERVICE_TYPE: &str = "_remotepairing-pairable-host._tcp.local."; + +/// Static information this host advertises and presents to a connecting device. +/// +/// `name` and `model` are what the device displays to the user (e.g. a Mac shows +/// `model = "Mac17,7"`, `name = "Jackson's MacBook Pro"`). iOS treats the host as +/// a computer, so keep `model` a Mac identifier. The remaining fields can be left +/// at their defaults as pairing succeeds with empty `udid`/`identifier` and without +/// any `deviceKVSData`. +#[derive(Debug, Clone)] +pub struct PairableHostInfo { + /// Human-readable name shown on the device, e.g. `"Jackson's MacBook Pro"`. + pub name: String, + /// Hardware model identifier shown on the device, e.g. `"Mac17,7"`. + pub model: String, + /// UDID reported in `peerDeviceInfo`. May be left empty. + pub udid: String, + /// Identifier reported in `peerDeviceInfo`. May be left empty. + pub identifier: String, + /// Wire protocol version reported during the handshake. Recent iOS uses 26. + pub wire_protocol_version: u8, + /// Our 16-byte mDNS identity key. It is sent to the device during pairing + /// (M6) and must match the `altIRK` used to compute the `authTag` advertised + /// in the `_remotepairing-pairable-host._tcp` record, so an already-paired + /// device can recognize this host. Persist it across runs alongside the + /// pairing file. + /// + /// See [`Self::mdns_txt_records`] and [`crate::remote_pairing::compute_auth_tag`]. + pub alt_irk: [u8; 16], +} + +impl PairableHostInfo { + /// Creates host info for the given name/model with a freshly generated random + /// `alt_irk`. Persist [`Self::alt_irk`] so reconnecting devices keep working. + pub fn generate(name: impl Into, model: impl Into) -> Self { + let mut alt_irk = [0u8; 16]; + rand::rng().fill_bytes(&mut alt_irk); + Self { + name: name.into(), + model: model.into(), + alt_irk, + ..Default::default() + } + } + + /// Builds the TXT records to publish for the [`PAIRABLE_HOST_SERVICE_TYPE`] + /// mDNS service, as `(key, value)` pairs suitable for any mDNS library. + /// + /// `service_identifier` is the host's stable identifier and must be the same + /// value used as the mDNS service instance name and sent to the device during + /// pairing, pass [`RpPairingFile::identifier`]. The `authTag` is derived from + /// [`Self::alt_irk`] and this identifier so an already-paired device can + /// recognize the host. + pub fn mdns_txt_records(&self, service_identifier: &str) -> Vec<(String, String)> { + let auth_tag = B64.encode(peer_device::compute_auth_tag( + &self.alt_irk, + service_identifier, + )); + vec![ + ("name".into(), self.name.clone()), + ("identifier".into(), service_identifier.to_string()), + ("authTag".into(), auth_tag), + ("model".into(), self.model.clone()), + ("flags".into(), "1".into()), + ("ver".into(), self.wire_protocol_version.to_string()), + ("minVer".into(), "17".into()), + ] + } +} + +impl Default for PairableHostInfo { + fn default() -> Self { + Self { + name: "idevice-rs".to_string(), + model: "Mac17,7".to_string(), + udid: String::new(), + identifier: String::new(), + wire_protocol_version: 26, + alt_irk: [0u8; 16], + } + } +} + +/// SRP username used by Apple's pair-setup. Both sides hash with this. +const SRP_USERNAME: &[u8] = b"Pair-Setup"; + +/// The responder side of rppairing: accepts a device-initiated pairing. +/// +/// Construct one around a socket configured for the responder role +/// ([`super::RpPairingSocket::new_device`]) and call [`Self::accept`]. +pub struct PairableHost { + inner: R, + host_info: PairableHostInfo, + /// Our own send-side sequence counter (the device maintains its own). + sequence_number: usize, + /// The SRP session key established during pairing. + encryption_key: Vec, + paired_peer_device: Option, +} + +impl PairableHost { + pub fn new(inner: R, host_info: PairableHostInfo) -> Self { + Self { + inner, + host_info, + sequence_number: 0, + encryption_key: Vec::new(), + paired_peer_device: None, + } + } + + /// The SRP session key established during the most recent successful pairing. + pub fn encryption_key(&self) -> &[u8] { + &self.encryption_key + } + + /// Accept a device-initiated pairing. + /// + /// Performs the handshake and the full SRP pair-setup. `pin_callback` is + /// invoked with the 6-digit setup code that should be displayed to the user + /// to enter on the device. + /// + /// On success the device's identity (including its `altIRK`) is stored in + /// `pairing_file.alt_irk` and the parsed [`PeerDevice`] is returned. The + /// caller is responsible for persisting `pairing_file`. + pub async fn accept( + &mut self, + pairing_file: &mut RpPairingFile, + pin_callback: impl FnOnce(String) -> Fut, + ) -> Result + where + Fut: std::future::Future, + { + self.handshake().await?; + let peer_device = self.pair_setup(pairing_file, pin_callback).await?; + Ok(peer_device) + } + + /// Receive the device's handshake request and reply with our device info. + async fn handshake(&mut self) -> Result<(), IdeviceError> { + debug!("Waiting for device handshake request"); + let request = self.inner.recv_plain().await?; + debug!("Handshake request: {request:#?}"); + + let handshake = request + .get_by("request") + .and_then(|x| x.get_by("_0")) + .and_then(|x| x.get_by("handshake")) + .and_then(|x| x.get_by("_0")) + .ok_or(IdeviceError::UnexpectedResponse( + "missing request._0.handshake._0 in device handshake".into(), + ))?; + + if handshake + .get_by("hostOptions") + .and_then(|x| x.get_by("attemptPairVerify")) + .and_then(|x| x.as_boolean()) + .unwrap_or(false) + { + return Err(IdeviceError::UnexpectedResponse( + "device requested pair-verify; only device-initiated pair-setup is supported" + .into(), + )); + } + + let mut peer_info = plist::Dictionary::new(); + peer_info.insert("udid".into(), Value::String(self.host_info.udid.clone())); + peer_info.insert( + "deviceKVSIncludesSensitiveInfo".into(), + Value::Boolean(false), + ); + peer_info.insert( + "identifier".into(), + Value::String(self.host_info.identifier.clone()), + ); + peer_info.insert("name".into(), Value::String(self.host_info.name.clone())); + peer_info.insert("model".into(), Value::String(self.host_info.model.clone())); + + let response = plist!({ + "response": { + "forRequestIdentifier": 0, + "_1": { + "handshake": { + "_0": { + "wireProtocolVersion": Value::Integer( + (self.host_info.wire_protocol_version as i64).into(), + ), + "minimumSupportedWireProtocolVersion": 8, + "deviceOptions": { + "allowsPairSetup": true, + "allowsPinlessPairing": false, + "allowsIncomingTunnelConnections": false, + "allowsUpgradeOfLockdownPairings": false, + "allowsSharingSensitiveInfo": false + }, + "peerDeviceInfo": Value::Dictionary(peer_info) + } + } + } + } + }); + + debug!("Sending handshake response: {response:#?}"); + self.send_plain(response).await + } + + /// Run the SRP pair-setup as the server/accessory (M1 - M6). + async fn pair_setup( + &mut self, + pairing_file: &mut RpPairingFile, + pin_callback: impl FnOnce(String) -> Fut, + ) -> Result + where + Fut: std::future::Future, + { + debug!("Waiting for pair-setup M1"); + let m1 = self.recv_pairing_tlv().await?; + expect_state(&m1, 1)?; + + // m2 + let srp_client = SrpClient::::new(&G_3072); + let srp_server = SrpServer::::new(&G_3072); + + let mut salt = [0u8; 16]; + rand::rng().fill_bytes(&mut salt); + + let pin = format!("{:06}", rand::rng().random_range(0..1_000_000)); + let verifier = srp_client.compute_verifier(SRP_USERNAME, pin.as_bytes(), &salt); + + let (b_priv, b_pub) = loop { + let mut b = [0u8; 32]; + rand::rng().fill_bytes(&mut b); + let b_pub = srp_server.compute_public_ephemeral(&b, &verifier); + if b_pub.len() == 384 { + break (b, b_pub); + } + }; + + // Display the PIN for the user to enter on the device. + pin_callback(pin).await; + + let mut m2 = vec![ + tlv::TLV8Entry { + tlv_type: Tt::State, + data: vec![0x02], + }, + tlv::TLV8Entry { + tlv_type: Tt::Salt, + data: salt.to_vec(), + }, + ]; + m2.extend(chunk_tlv(Tt::PublicKey, &b_pub)); + debug!("Sending pair-setup M2 (salt + B)"); + self.send_pairing_tlv(&m2).await?; + + // m3 + debug!("Waiting for pair-setup M3"); + let m3 = self.recv_pairing_tlv().await?; + ensure_no_error(&m3)?; + expect_state(&m3, 3)?; + + let a_pub = tlv::collect_component_data(&m3, Tt::PublicKey); + let client_proof = tlv::collect_component_data(&m3, Tt::Proof); + if a_pub.is_empty() || client_proof.is_empty() { + return Err(IdeviceError::UnexpectedResponse( + "pair-setup M3 missing public key or proof".into(), + )); + } + + let verifier = srp_server + .process_reply(&b_priv, &verifier, &a_pub, SRP_USERNAME, &salt) + .map_err(|e| { + warn!("SRP process_reply failed: {e:?}"); + RemotePairingError::SrpAuthFailed + })?; + + if verifier.verify_client(&client_proof).is_err() { + warn!("SRP client proof verification failed (wrong PIN?)"); + // Tell the device authentication failed. + self.send_pairing_tlv(&[ + tlv::TLV8Entry { + tlv_type: Tt::State, + data: vec![0x04], + }, + tlv::TLV8Entry { + tlv_type: Tt::ErrorResponse, + data: vec![0x02], // kTLVError_Authentication + }, + ]) + .await?; + return Err(RemotePairingError::SrpAuthFailed.into()); + } + + let session_key = verifier.key().to_vec(); + + // m4 + debug!("Sending pair-setup M4 (server proof)"); + self.send_pairing_tlv(&[ + tlv::TLV8Entry { + tlv_type: Tt::State, + data: vec![0x04], + }, + tlv::TLV8Entry { + tlv_type: Tt::Proof, + data: verifier.proof().to_vec(), + }, + ]) + .await?; + + // Derive the shared ChaCha20-Poly1305 key used for the M5/M6 exchange. + let setup_cipher = { + let hk = Hkdf::::new(Some(b"Pair-Setup-Encrypt-Salt"), &session_key); + let mut key = [0u8; 32]; + hk.expand(b"Pair-Setup-Encrypt-Info", &mut key) + .expect("HKDF expand failed"); + ChaCha20Poly1305::new(Key::from_slice(&key)) + }; + + // m5 + debug!("Waiting for pair-setup M5 (device identity)"); + let m5 = self.recv_pairing_tlv().await?; + ensure_no_error(&m5)?; + expect_state(&m5, 5)?; + + let ciphertext = tlv::collect_component_data(&m5, Tt::EncryptedData); + let plaintext = setup_cipher + .decrypt( + Nonce::from_slice(b"\x00\x00\x00\x00PS-Msg05"), + Payload { + msg: &ciphertext, + aad: b"", + }, + ) + .map_err(|e| { + warn!("Failed to decrypt pair-setup M5: {e:?}"); + RemotePairingError::ChachaEncryption(e) + })?; + + let device_tlv = tlv::deserialize_tlv8(&plaintext)?; + debug!("Decrypted device identity TLV: {device_tlv:#?}"); + let peer_device = peer_device::parse_peer_device_from_tlv(&device_tlv)?; + + // m6 + debug!("Sending pair-setup M6 (our identity)"); + let m6_plain = self.build_accessory_identity_tlv(pairing_file, &session_key); + let m6_cipher = setup_cipher + .encrypt( + Nonce::from_slice(b"\x00\x00\x00\x00PS-Msg06"), + Payload { + msg: &m6_plain, + aad: b"", + }, + ) + .map_err(RemotePairingError::ChachaEncryption)?; + + let mut m6 = chunk_tlv(Tt::EncryptedData, &m6_cipher); + m6.push(tlv::TLV8Entry { + tlv_type: Tt::State, + data: vec![0x06], + }); + self.send_pairing_tlv(&m6).await?; + + // Store the device's altIRK so we can validate its future + // `_remotepairing._tcp` advertisements. + pairing_file.alt_irk = Some(peer_device.alt_irk.clone()); + self.encryption_key = session_key; + self.paired_peer_device = Some(peer_device.clone()); + + Ok(peer_device) + } + + /// Builds the (plaintext) accessory identity TLV sent in M6: our identifier, + /// long-term Ed25519 public key, a signature over them, and an OPACK info blob. + fn build_accessory_identity_tlv( + &self, + pairing_file: &mut RpPairingFile, + session_key: &[u8], + ) -> Vec { + // Accessory signature: Ed25519 over (AccessoryX || identifier || LTPK), + // where AccessoryX is derived from the SRP session key. + let mut accessory_x = [0u8; 32]; + Hkdf::::new(Some(b"Pair-Setup-Accessory-Sign-Salt"), session_key) + .expand(b"Pair-Setup-Accessory-Sign-Info", &mut accessory_x) + .expect("HKDF expand failed"); + + let ltpk = pairing_file.e_public_key.to_bytes(); + let mut signbuf = Vec::with_capacity(32 + pairing_file.identifier.len() + 32); + signbuf.extend_from_slice(&accessory_x); + signbuf.extend_from_slice(pairing_file.identifier.as_bytes()); + signbuf.extend_from_slice(<pk); + let signature = pairing_file.e_private_key.sign(&signbuf); + + let info = opack::plist_to_opack(&plist!({ + "altIRK": self.host_info.alt_irk.to_vec(), + "btAddr": "11:22:33:44:55:66", + "mac": vec![0x11u8, 0x22, 0x33, 0x44, 0x55, 0x66], + "remotepairing_serial_number": "AAAAAAAAAAAA", + "accountID": pairing_file.identifier.as_str(), + "remotepairing_udid": self.host_info.udid.as_str(), + "model": self.host_info.model.as_str(), + "name": self.host_info.name.as_str() + })); + + tlv::serialize_tlv8(&[ + tlv::TLV8Entry { + tlv_type: Tt::Identifier, + data: pairing_file.identifier.as_bytes().to_vec(), + }, + tlv::TLV8Entry { + tlv_type: Tt::PublicKey, + data: ltpk.to_vec(), + }, + tlv::TLV8Entry { + tlv_type: Tt::Signature, + data: signature.to_vec(), + }, + tlv::TLV8Entry { + tlv_type: Tt::Info, + data: info, + }, + ]) + } + + /// Send a plain (`message.plain`) envelope, bumping our sequence counter. + async fn send_plain(&mut self, value: plist::Value) -> Result<(), IdeviceError> { + self.inner.send_plain(value, self.sequence_number).await?; + self.sequence_number += 1; + Ok(()) + } + + /// Send a `pairingData` event carrying the given TLV entries. + async fn send_pairing_tlv(&mut self, entries: &[tlv::TLV8Entry]) -> Result<(), IdeviceError> { + let data = R::serialize_bytes(&tlv::serialize_tlv8(entries)); + self.send_plain(plist!({ + "event": { + "_0": { + "pairingData": { + "_0": { + "data": data, + "startNewSession": false, + "kind": "setupManualPairing" + } + } + } + } + })) + .await + } + + /// Receive a `pairingData` event and decode its TLV payload. + async fn recv_pairing_tlv(&mut self) -> Result, IdeviceError> { + let response = self.inner.recv_plain().await?; + + let data = response + .get_by("event") + .and_then(|x| x.get_by("_0")) + .and_then(|x| x.get_by("pairingData")) + .and_then(|x| x.get_by("_0")) + .and_then(|x| x.get_by("data")) + .ok_or(IdeviceError::UnexpectedResponse( + "missing event._0.pairingData._0.data in device message".into(), + ))?; + + let bytes = R::deserialize_bytes(data.to_owned()).ok_or( + IdeviceError::UnexpectedResponse("failed to deserialize pairing data bytes".into()), + )?; + + Ok(tlv::deserialize_tlv8(&bytes)?) + } +} + +/// Split `data` into TLV entries of at most 255 bytes each (the TLV length limit). +fn chunk_tlv(tlv_type: Tt, data: &[u8]) -> Vec { + data.chunks(255) + .map(|chunk| tlv::TLV8Entry { + tlv_type, + data: chunk.to_vec(), + }) + .collect() +} + +/// Returns an error if the TLV stream contains an `ErrorResponse` component. +fn ensure_no_error(entries: &[tlv::TLV8Entry]) -> Result<(), IdeviceError> { + if let Some(err) = entries.iter().find(|e| e.tlv_type == Tt::ErrorResponse) { + return Err(IdeviceError::UnexpectedResponse(format!( + "device returned pairing error: {:?}", + err.data + ))); + } + Ok(()) +} + +/// Validates that the TLV stream carries the expected `State` value. +fn expect_state(entries: &[tlv::TLV8Entry], expected: u8) -> Result<(), IdeviceError> { + let state = entries + .iter() + .find(|e| e.tlv_type == Tt::State) + .and_then(|e| e.data.first().copied()); + match state { + Some(s) if s == expected => Ok(()), + other => Err(IdeviceError::UnexpectedResponse(format!( + "unexpected pair-setup state: expected {expected}, got {other:?}" + ))), + } +} + +impl std::fmt::Debug for PairableHost { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("PairableHost") + .field("inner", &self.inner) + .field("sequence_number", &self.sequence_number) + .field("host_info", &self.host_info) + .finish() + } +} diff --git a/src-tauri/vendor/idevice/src/remote_pairing/rp_pairing_file.rs b/src-tauri/vendor/idevice/src/remote_pairing/rp_pairing_file.rs new file mode 100644 index 0000000..1ac87c4 --- /dev/null +++ b/src-tauri/vendor/idevice/src/remote_pairing/rp_pairing_file.rs @@ -0,0 +1,170 @@ +// Jackson Coxson + +#[cfg(not(target_arch = "wasm32"))] +use std::path::Path; + +use ed25519_dalek::{SigningKey, VerifyingKey}; +use plist::Dictionary; +use plist_macro::plist_to_xml_bytes; +use rsa::rand_core::OsRng; +use serde::de::Error; +use tracing::{debug, warn}; + +use crate::IdeviceError; + +#[derive(Clone)] +pub struct RpPairingFile { + pub e_private_key: SigningKey, + pub e_public_key: VerifyingKey, + pub identifier: String, + pub alt_irk: Option>, +} + +impl RpPairingFile { + /// Returns the Ed25519 public key bytes (32 bytes). + pub fn public_key_bytes(&self) -> Vec { + self.e_public_key.to_bytes().to_vec() + } + + /// Returns the Ed25519 private key bytes (32 bytes). + pub fn private_key_bytes(&self) -> Vec { + self.e_private_key.to_bytes().to_vec() + } + + /// Returns the identifier string. + pub fn identifier(&self) -> &str { + &self.identifier + } + + /// Returns the `alt_irk` bytes (16 bytes). + pub fn alt_irk(&self) -> Option<&[u8]> { + self.alt_irk.as_deref() + } + + pub fn generate(sending_host: &str) -> Self { + // Ed25519 private key (persistent signing key) + let ed25519_private_key = SigningKey::generate(&mut OsRng); + let ed25519_public_key = VerifyingKey::from(&ed25519_private_key); + + let identifier = + uuid::Uuid::new_v3(&uuid::Uuid::NAMESPACE_DNS, sending_host.as_bytes()).to_string(); + + Self { + e_private_key: ed25519_private_key, + e_public_key: ed25519_public_key, + identifier, + alt_irk: None, + } + } + + pub(crate) fn recreate_signing_keys(&mut self) { + let ed25519_private_key = SigningKey::generate(&mut OsRng); + let ed25519_public_key = VerifyingKey::from(&ed25519_private_key); + self.e_public_key = ed25519_public_key; + self.e_private_key = ed25519_private_key; + self.alt_irk = None; + } + + /// Serialize to XML plist bytes. + pub fn to_bytes(&self) -> Vec { + let mut dict = plist::Dictionary::new(); + dict.insert( + "public_key".into(), + plist::Value::Data(self.e_public_key.to_bytes().to_vec()), + ); + dict.insert( + "private_key".into(), + plist::Value::Data(self.e_private_key.to_bytes().to_vec()), + ); + dict.insert( + "identifier".into(), + plist::Value::String(self.identifier.clone()), + ); + if let Some(irk) = &self.alt_irk { + dict.insert("alt_irk".into(), plist::Value::Data(irk.clone())); + } + plist_to_xml_bytes(&dict) + } + + /// Parse from plist bytes (XML or binary). + pub fn from_bytes(bytes: &[u8]) -> Result { + let mut p: Dictionary = plist::from_bytes(bytes)?; + debug!("Read dictionary for rppairingfile: {p:#?}"); + + let public_key = match p + .remove("public_key") + .and_then(|x| x.into_data()) + .filter(|x| x.len() == 32) + .and_then(|x| VerifyingKey::from_bytes(&x[..32].try_into().unwrap()).ok()) + { + Some(p) => p, + None => { + warn!("plist did not contain valid public key bytes"); + return Err(IdeviceError::Plist(plist::Error::missing_field( + "public_key", + ))); + } + }; + + let private_key = match p + .remove("private_key") + .and_then(|x| x.into_data()) + .filter(|x| x.len() == 32) + { + Some(p) => SigningKey::from_bytes(&p.try_into().unwrap()), + None => { + warn!("plist did not contain valid private key bytes"); + return Err(IdeviceError::Plist(plist::Error::missing_field( + "private_key", + ))); + } + }; + + let identifier = match p.remove("identifier").and_then(|x| x.into_string()) { + Some(i) => i, + None => { + warn!("plist did not contain identifier"); + return Err(IdeviceError::Plist(plist::Error::missing_field( + "identifier", + ))); + } + }; + + let alt_irk = match p.remove("alt_irk").and_then(|x| x.into_data()) { + Some(irk) => Some(irk), + None => { + warn!("plist did not contain alt_irk"); + None + } + }; + + Ok(Self { + e_private_key: private_key, + e_public_key: public_key, + identifier, + alt_irk, + }) + } + + #[cfg(not(target_arch = "wasm32"))] + pub async fn write_to_file(&self, path: impl AsRef) -> Result<(), IdeviceError> { + tokio::fs::write(path, self.to_bytes()).await?; + Ok(()) + } + + #[cfg(not(target_arch = "wasm32"))] + pub async fn read_from_file(path: impl AsRef) -> Result { + let bytes = tokio::fs::read(path).await?; + Self::from_bytes(&bytes) + } +} + +impl std::fmt::Debug for RpPairingFile { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RpPairingFile") + .field("e_public_key", &self.e_public_key) + .field("identifier", &self.identifier) + .field("alt_irk", &self.alt_irk) + .finish() + } +} diff --git a/src-tauri/vendor/idevice/src/remote_pairing/socket.rs b/src-tauri/vendor/idevice/src/remote_pairing/socket.rs new file mode 100644 index 0000000..473ce93 --- /dev/null +++ b/src-tauri/vendor/idevice/src/remote_pairing/socket.rs @@ -0,0 +1,259 @@ +// Jackson Coxson + +use base64::{Engine as _, engine::general_purpose::STANDARD as B64}; +use plist_macro::{plist, pretty_print_plist}; +use serde::Serialize; +use serde_json::json; +use std::{fmt::Debug, pin::Pin}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tracing::{debug, trace, warn}; + +use crate::{ + IdeviceError, ReadWrite, RemoteXpcClient, remote_pairing::RPPAIRING_MAGIC, xpc::XPCObject, +}; + +pub trait RpPairingSocketProvider: Debug { + fn send_plain( + &mut self, + value: impl Serialize, + seq: usize, + ) -> Pin> + Send + '_>>; + + fn send_encrypted( + &mut self, + ciphertext: Vec, + seq: usize, + ) -> Pin> + Send + '_>>; + + fn recv_plain<'a>( + &'a mut self, + ) -> Pin> + Send + 'a>>; + + /// rppairing uses b64, while RemoteXPC uses raw bytes just fine + fn serialize_bytes(b: &[u8]) -> plist::Value; + fn deserialize_bytes(v: plist::Value) -> Option>; +} + +#[derive(Debug)] +pub struct RpPairingSocket { + pub inner: R, + /// The value placed in the `originatedBy` field of every envelope we send. + /// + /// The host (the side that connects to a device and initiates pairing) sends + /// `"host"`. The responder side - a "pairable host" that a device connects to + /// and pairs *into* (see [`crate::remote_pairing::PairableHost`]) - sends + /// `"device"`, because in rppairing terms it plays the accessory/responder role. + originated_by: &'static str, +} + +impl RpPairingSocket { + /// Creates a socket for the initiating host (sends `originatedBy: "host"`). + pub fn new(socket: R) -> Self { + Self { + inner: socket, + originated_by: "host", + } + } + + /// Creates a socket for the responder side (sends `originatedBy: "device"`). + /// + /// Use this when accepting a device-initiated pairing with + /// [`crate::remote_pairing::PairableHost`]. + pub fn new_device(socket: R) -> Self { + Self { + inner: socket, + originated_by: "device", + } + } + + async fn send_rppairing(&mut self, value: impl Serialize) -> Result<(), IdeviceError> { + let value = serde_json::to_string(&value)?; + let x = value.as_bytes(); + + let mut frame = Vec::with_capacity(RPPAIRING_MAGIC.len() + 2 + x.len()); + frame.extend_from_slice(RPPAIRING_MAGIC); + frame.extend_from_slice(&(x.len() as u16).to_be_bytes()); + frame.extend_from_slice(x); + self.inner.write_all(&frame).await?; + self.inner.flush().await?; + Ok(()) + } +} + +impl RpPairingSocketProvider for RpPairingSocket { + fn send_plain( + &mut self, + value: impl Serialize, + seq: usize, + ) -> Pin> + Send + '_>> { + let v = json!({ + "message": {"plain": {"_0": value}}, + "originatedBy": self.originated_by, + "sequenceNumber": seq + }); + + Box::pin(async move { + self.send_rppairing(v).await?; + Ok(()) + }) + } + + fn send_encrypted( + &mut self, + ciphertext: Vec, + seq: usize, + ) -> Pin> + Send + '_>> { + let v = json!({ + "message": {"streamEncrypted": {"_0": B64.encode(&ciphertext)}}, + "originatedBy": self.originated_by, + "sequenceNumber": seq + }); + + Box::pin(async move { + self.send_rppairing(v).await?; + Ok(()) + }) + } + + fn recv_plain<'a>( + &'a mut self, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + let mut magic = vec![0u8; RPPAIRING_MAGIC.len()]; + self.inner.read_exact(&mut magic).await?; + + let mut packet_len_bytes = [0u8; 2]; + self.inner.read_exact(&mut packet_len_bytes).await?; + let packet_len = u16::from_be_bytes(packet_len_bytes); + + let mut value = vec![0u8; packet_len as usize]; + self.inner.read_exact(&mut value).await?; + + debug!("recv_rppairing: {packet_len} json bytes"); + trace!( + "recv_rppairing payload: {}", + String::from_utf8_lossy(&value) + ); + + let value: serde_json::Value = serde_json::from_slice(&value)?; + + // Try plain first, then return the whole message dict for encrypted + if let Some(v) = value + .get("message") + .and_then(|x| x.get("plain")) + .and_then(|x| x.get("_0")) + { + Ok(plist::to_value(v).unwrap()) + } else { + // Return the full message for encrypted handling + Ok(plist::to_value(&value).unwrap()) + } + }) + } + + fn serialize_bytes(b: &[u8]) -> plist::Value { + plist!(B64.encode(b)) + } + + fn deserialize_bytes(v: plist::Value) -> Option> { + if let plist::Value::String(v) = v { + B64.decode(v).ok() + } else { + None + } + } +} + +impl RpPairingSocketProvider for RemoteXpcClient { + fn send_plain( + &mut self, + value: impl Serialize, + seq: usize, + ) -> Pin> + Send + '_>> { + let value: plist::Value = plist::to_value(&value).expect("plist assert failed"); + let value: XPCObject = value.into(); + + let v = crate::xpc!({ + "mangledTypeName": "RemotePairing.ControlChannelMessageEnvelope", + "value": { + "message": {"plain": {"_0": value}}, + "originatedBy": "host", + "sequenceNumber": seq as u64 + } + }); + debug!("Sending XPC: {v:#?}"); + + Box::pin(async move { + self.send_object(v, true).await?; + Ok(()) + }) + } + + fn send_encrypted( + &mut self, + ciphertext: Vec, + seq: usize, + ) -> Pin> + Send + '_>> { + let v = crate::xpc!({ + "mangledTypeName": "RemotePairing.ControlChannelMessageEnvelope", + "value": { + "message": {"streamEncrypted": {"_0": ciphertext}}, + "originatedBy": "host", + "sequenceNumber": seq as u64 + } + }); + + Box::pin(async move { + self.send_object(v, true).await?; + Ok(()) + }) + } + + fn recv_plain<'a>( + &'a mut self, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + let msg = self.recv_root().await?; + debug!("Received RemoteXPC {}", pretty_print_plist(&msg)); + let msg = msg.into_dictionary().and_then(|mut x| x.remove("value")); + + let msg = match msg { + Some(v) => v, + None => { + return Err(IdeviceError::UnexpectedResponse( + "missing value field in RemoteXPC message".into(), + )); + } + }; + + // Try plain first + if let Some(plain) = msg + .as_dictionary() + .and_then(|x| x.get("message")) + .and_then(|x| x.as_dictionary()) + .and_then(|x| x.get("plain")) + .and_then(|x| x.as_dictionary()) + .and_then(|x| x.get("_0")) + .cloned() + { + return Ok(plain); + } + + // Return the whole value dict for encrypted handling + Ok(msg) + }) + } + + fn serialize_bytes(b: &[u8]) -> plist::Value { + plist::Value::Data(b.to_owned()) + } + + fn deserialize_bytes(v: plist::Value) -> Option> { + if let plist::Value::Data(v) = v { + Some(v) + } else { + warn!("Non-data passed to rppairingsocket::deserialize_bytes for RemoteXPC provider"); + None + } + } +} diff --git a/src-tauri/vendor/idevice/src/remote_pairing/tls_psk.rs b/src-tauri/vendor/idevice/src/remote_pairing/tls_psk.rs new file mode 100644 index 0000000..9b7360c --- /dev/null +++ b/src-tauri/vendor/idevice/src/remote_pairing/tls_psk.rs @@ -0,0 +1,720 @@ +// Jackson Coxson +//! Manual TLS 1.2 PSK-AES128-CBC-SHA implementation. +//! +//! This implements just enough of TLS 1.2 to negotiate +//! `TLS_PSK_WITH_AES_128_CBC_SHA` (0x008C) with no certificates or DH. +//! The result is an encrypted stream suitable for CDTunnel. +//! We did this ourselves because rustls won't :( + +use aes::cipher::{BlockDecryptMut, BlockEncryptMut, KeyIvInit}; +use hmac::{Hmac, Mac}; +use sha1::Sha1; +use sha2::Sha256; +use std::pin::Pin; +use std::task::{Context, Poll}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, ReadBuf}; +use tracing::debug; + +use crate::IdeviceError; + +// TLS 1.2 constants +const TLS_12: [u8; 2] = [0x03, 0x03]; +const CT_HANDSHAKE: u8 = 0x16; +const CT_CHANGE_CIPHER_SPEC: u8 = 0x14; +const CT_APPLICATION_DATA: u8 = 0x17; +/// Maximum plaintext bytes per TLS record (RFC 5246 §6.2.1) +const TLS_MAX_PLAINTEXT: usize = 16384; +const HS_CLIENT_HELLO: u8 = 0x01; +const HS_SERVER_HELLO: u8 = 0x02; +const HS_SERVER_HELLO_DONE: u8 = 0x0E; +const HS_CLIENT_KEY_EXCHANGE: u8 = 0x10; +const HS_FINISHED: u8 = 0x14; +// Offer both; device typically picks AES256-CBC-SHA384 +const PSK_CIPHER_SUITES: &[[u8; 2]] = &[ + [0x00, 0xAF], // TLS_PSK_WITH_AES_256_CBC_SHA384 (preferred by iOS) + [0x00, 0x8C], // TLS_PSK_WITH_AES_128_CBC_SHA (fallback) +]; + +type HmacSha256 = Hmac; +type HmacSha1 = Hmac; +type HmacSha384 = Hmac; +type Aes128CbcEnc = cbc::Encryptor; +type Aes128CbcDec = cbc::Decryptor; +type Aes256CbcEnc = cbc::Encryptor; +type Aes256CbcDec = cbc::Decryptor; + +/// Selected cipher suite parameters +#[derive(Clone, Copy, Debug)] +enum CipherSuite { + Aes128CbcSha, // 0x008C: 16-byte key, 20-byte MAC (SHA1), PRF=SHA256 + Aes256CbcSha384, // 0x00AF: 32-byte key, 48-byte MAC (SHA384), PRF=SHA384 +} + +impl CipherSuite { + fn from_bytes(b: [u8; 2]) -> Option { + match b { + [0x00, 0x8C] => Some(Self::Aes128CbcSha), + [0x00, 0xAF] => Some(Self::Aes256CbcSha384), + _ => None, + } + } + fn enc_key_len(self) -> usize { + match self { + Self::Aes128CbcSha => 16, + Self::Aes256CbcSha384 => 32, + } + } + fn mac_key_len(self) -> usize { + match self { + Self::Aes128CbcSha => 20, + Self::Aes256CbcSha384 => 48, + } + } +} + +struct KeyBlock { + client_mac_key: Vec, + server_mac_key: Vec, + client_write_key: Vec, + server_write_key: Vec, + suite: CipherSuite, +} + +fn hmac_compute(key: &[u8], data: &[u8], suite: CipherSuite) -> Vec { + match suite { + CipherSuite::Aes128CbcSha => { + // PRF uses SHA256 for P_hash, but record MAC uses SHA1 + let mut mac = HmacSha256::new_from_slice(key).unwrap(); + mac.update(data); + mac.finalize().into_bytes().to_vec() + } + CipherSuite::Aes256CbcSha384 => { + let mut mac = HmacSha384::new_from_slice(key).unwrap(); + mac.update(data); + mac.finalize().into_bytes().to_vec() + } + } +} + +/// TLS 1.2 PRF: P_hash with the suite's PRF hash +fn prf(secret: &[u8], label: &[u8], seed: &[u8], len: usize, suite: CipherSuite) -> Vec { + let mut label_seed = label.to_vec(); + label_seed.extend_from_slice(seed); + + let mut a = hmac_compute(secret, &label_seed, suite); + let mut out = Vec::with_capacity(len); + + while out.len() < len { + let mut input = a.clone(); + input.extend_from_slice(&label_seed); + out.extend_from_slice(&hmac_compute(secret, &input, suite)); + a = hmac_compute(secret, &a, suite); + } + out.truncate(len); + out +} + +/// PSK premaster secret (RFC 4279 §2) +fn psk_premaster(psk: &[u8]) -> Vec { + let psk_len = psk.len() as u16; + let mut pm = Vec::with_capacity(4 + psk.len() * 2); + pm.extend_from_slice(&psk_len.to_be_bytes()); + pm.extend(std::iter::repeat_n(0u8, psk.len())); + pm.extend_from_slice(&psk_len.to_be_bytes()); + pm.extend_from_slice(psk); + pm +} + +fn derive_master_secret( + psk: &[u8], + client_random: &[u8; 32], + server_random: &[u8; 32], + suite: CipherSuite, +) -> Vec { + let premaster = psk_premaster(psk); + let mut seed = client_random.to_vec(); + seed.extend_from_slice(server_random); + prf(&premaster, b"master secret", &seed, 48, suite) +} + +fn derive_key_block( + master: &[u8], + client_random: &[u8; 32], + server_random: &[u8; 32], + suite: CipherSuite, +) -> KeyBlock { + let mut seed = server_random.to_vec(); + seed.extend_from_slice(client_random); + let mac_len = suite.mac_key_len(); + let key_len = suite.enc_key_len(); + let total = mac_len * 2 + key_len * 2; + let kb = prf(master, b"key expansion", &seed, total, suite); + let mut pos = 0; + let client_mac_key = kb[pos..pos + mac_len].to_vec(); + pos += mac_len; + let server_mac_key = kb[pos..pos + mac_len].to_vec(); + pos += mac_len; + let client_write_key = kb[pos..pos + key_len].to_vec(); + pos += key_len; + let server_write_key = kb[pos..pos + key_len].to_vec(); + KeyBlock { + client_mac_key, + server_mac_key, + client_write_key, + server_write_key, + suite, + } +} + +fn compute_mac(mac_key: &[u8], seq: u64, ct: u8, data: &[u8], suite: CipherSuite) -> Vec { + let mut buf = Vec::new(); + buf.extend_from_slice(&seq.to_be_bytes()); + buf.extend_from_slice(&[ct, 0x03, 0x03]); + buf.extend_from_slice(&(data.len() as u16).to_be_bytes()); + buf.extend_from_slice(data); + + match suite { + CipherSuite::Aes128CbcSha => { + let mut mac = HmacSha1::new_from_slice(mac_key).unwrap(); + mac.update(&buf); + mac.finalize().into_bytes().to_vec() + } + CipherSuite::Aes256CbcSha384 => { + let mut mac = HmacSha384::new_from_slice(mac_key).unwrap(); + mac.update(&buf); + mac.finalize().into_bytes().to_vec() + } + } +} + +fn encrypt_record(keys: &KeyBlock, seq: u64, ct: u8, plaintext: &[u8]) -> Vec { + let mac = compute_mac(&keys.client_mac_key, seq, ct, plaintext, keys.suite); + + let mut payload = plaintext.to_vec(); + payload.extend_from_slice(&mac); + + // PKCS#7 padding (block size = 16 for both AES-128 and AES-256) + let pad_len = 16 - (payload.len() % 16); + payload.extend(std::iter::repeat_n(pad_len as u8 - 1, pad_len)); + + let mut iv = [0u8; 16]; + rand::fill(&mut iv); + + let ciphertext = match keys.suite { + CipherSuite::Aes128CbcSha => { + let enc = Aes128CbcEnc::new(keys.client_write_key[..16].into(), &iv.into()); + enc.encrypt_padded_vec_mut::(&payload) + } + CipherSuite::Aes256CbcSha384 => { + let enc = Aes256CbcEnc::new(keys.client_write_key[..32].into(), &iv.into()); + enc.encrypt_padded_vec_mut::(&payload) + } + }; + + let mut result = iv.to_vec(); + result.extend_from_slice(&ciphertext); + result +} + +fn decrypt_record( + keys: &KeyBlock, + is_server: bool, + seq: u64, + ct: u8, + encrypted: &[u8], +) -> Result, IdeviceError> { + if encrypted.len() < 16 { + return Err(IdeviceError::InternalError("TLS record too short".into())); + } + + let iv = &encrypted[..16]; + let ciphertext = &encrypted[16..]; + let read_key = if is_server { + &keys.server_write_key + } else { + &keys.client_write_key + }; + let mac_key = if is_server { + &keys.server_mac_key + } else { + &keys.client_mac_key + }; + + let decrypted = match keys.suite { + CipherSuite::Aes128CbcSha => { + let dec = Aes128CbcDec::new(read_key[..16].into(), iv.into()); + dec.decrypt_padded_vec_mut::(ciphertext) + .map_err(|e| IdeviceError::InternalError(format!("CBC decrypt: {e}")))? + } + CipherSuite::Aes256CbcSha384 => { + let dec = Aes256CbcDec::new(read_key[..32].into(), iv.into()); + dec.decrypt_padded_vec_mut::(ciphertext) + .map_err(|e| IdeviceError::InternalError(format!("CBC decrypt: {e}")))? + } + }; + + if decrypted.is_empty() { + return Err(IdeviceError::InternalError("Empty decrypted data".into())); + } + + // Remove PKCS#7 padding: last byte is pad_value, remove (pad_value+1) bytes + let pad_value = *decrypted.last().unwrap() as usize; + let content_len = decrypted.len() - (pad_value + 1); + let mac_len = keys.suite.mac_key_len(); + if content_len < mac_len { + return Err(IdeviceError::InternalError( + "Decrypted content too short for MAC".into(), + )); + } + + let plaintext = &decrypted[..content_len - mac_len]; + let received_mac = &decrypted[content_len - mac_len..content_len]; + + let expected_mac = compute_mac(mac_key, seq, ct, plaintext, keys.suite); + if received_mac != expected_mac.as_slice() { + return Err(IdeviceError::InternalError( + "TLS MAC verification failed".into(), + )); + } + + Ok(plaintext.to_vec()) +} + +fn finished_verify_data( + master: &[u8], + label: &[u8], + transcript: &[u8], + suite: CipherSuite, +) -> [u8; 12] { + use sha2::Digest; + let hash: Vec = match suite { + CipherSuite::Aes128CbcSha => sha2::Sha256::digest(transcript).to_vec(), + CipherSuite::Aes256CbcSha384 => sha2::Sha384::digest(transcript).to_vec(), + }; + prf(master, label, &hash, 12, suite).try_into().unwrap() +} + +fn make_record(ct: u8, payload: &[u8]) -> Vec { + let mut rec = vec![ct, 0x03, 0x03]; + rec.extend_from_slice(&(payload.len() as u16).to_be_bytes()); + rec.extend_from_slice(payload); + rec +} + +fn make_handshake(msg_type: u8, body: &[u8]) -> Vec { + let mut msg = vec![msg_type]; + let len = body.len() as u32; + msg.extend_from_slice(&len.to_be_bytes()[1..]); // 3-byte length + msg.extend_from_slice(body); + msg +} + +async fn read_record(stream: &mut S) -> Result<(u8, Vec), IdeviceError> { + let mut header = [0u8; 5]; + stream.read_exact(&mut header).await?; + let ct = header[0]; + let len = u16::from_be_bytes([header[3], header[4]]) as usize; + let mut payload = vec![0u8; len]; + stream.read_exact(&mut payload).await?; + Ok((ct, payload)) +} + +/// Extract handshake messages from a record payload. +/// A single record can contain multiple handshake messages. +fn parse_handshake_messages(data: &[u8]) -> Vec<(u8, Vec)> { + let mut msgs = Vec::new(); + let mut pos = 0; + while pos + 4 <= data.len() { + let msg_type = data[pos]; + let msg_len = u32::from_be_bytes([0, data[pos + 1], data[pos + 2], data[pos + 3]]) as usize; + if pos + 4 + msg_len > data.len() { + break; + } + msgs.push((msg_type, data[pos..pos + 4 + msg_len].to_vec())); + pos += 4 + msg_len; + } + msgs +} + +/// Perform TLS 1.2 PSK handshake and return an encrypted stream. +pub async fn tls_psk_handshake( + mut stream: S, + psk: &[u8], +) -> Result, IdeviceError> { + let mut client_random = [0u8; 32]; + rand::fill(&mut client_random); + let mut server_random = [0u8; 32]; + let mut selected_cipher = [0u8; 2]; + let mut transcript = Vec::new(); + + // 1. ClientHello + let mut ch_body = Vec::new(); + ch_body.extend_from_slice(&TLS_12); + ch_body.extend_from_slice(&client_random); + ch_body.push(0x00); // session_id len = 0 + let suites_len = (PSK_CIPHER_SUITES.len() * 2) as u16; + ch_body.extend_from_slice(&suites_len.to_be_bytes()); + for suite in PSK_CIPHER_SUITES { + ch_body.extend_from_slice(suite); + } + ch_body.extend_from_slice(&[0x01, 0x00]); // compression: null + let ch = make_handshake(HS_CLIENT_HELLO, &ch_body); + transcript.extend_from_slice(&ch); + stream.write_all(&make_record(CT_HANDSHAKE, &ch)).await?; + debug!("Sent ClientHello"); + + // 2. Read ServerHello, optional ServerKeyExchange (PSK hint), ServerHelloDone + loop { + let (ct, payload) = read_record(&mut stream).await?; + if ct == 21 { + // TLS Alert + let level = payload.first().copied().unwrap_or(0); + let desc = payload.get(1).copied().unwrap_or(0); + return Err(IdeviceError::InternalError(format!( + "TLS Alert: level={level} desc={desc} ({})", + match desc { + 0 => "close_notify", + 10 => "unexpected_message", + 20 => "bad_record_mac", + 40 => "handshake_failure", + 47 => "illegal_parameter", + 70 => "protocol_version", + 71 => "insufficient_security", + 80 => "internal_error", + _ => "unknown", + } + ))); + } + if ct != CT_HANDSHAKE { + return Err(IdeviceError::InternalError(format!( + "Expected handshake, got ct={ct}" + ))); + } + transcript.extend_from_slice(&payload); + + for (msg_type, _msg_bytes) in parse_handshake_messages(&payload) { + match msg_type { + HS_SERVER_HELLO => { + // ServerHello layout (after 4-byte handshake header): + // 2 bytes version, 32 bytes random, 1 byte session_id_len, + // session_id, 2 bytes cipher_suite + if payload.len() >= 4 + 2 + 32 { + server_random.copy_from_slice(&payload[6..38]); + let sid_len = payload[38] as usize; + if payload.len() >= 39 + sid_len + 2 { + selected_cipher + .copy_from_slice(&payload[39 + sid_len..39 + sid_len + 2]); + } + } + debug!("Got ServerHello, cipher={selected_cipher:02x?}"); + } + HS_SERVER_HELLO_DONE => { + debug!("Got ServerHelloDone"); + } + _ => { + debug!("Got handshake msg type {msg_type}"); + } + } + } + + // Check if we've seen ServerHelloDone + if payload.contains(&HS_SERVER_HELLO_DONE) && payload.len() >= 4 { + // Simple check: if ServerHelloDone is in the payload, we're done + // ServerHelloDone is a 0-length message: [0x0E, 0x00, 0x00, 0x00] + if payload + .windows(4) + .any(|w| w == [HS_SERVER_HELLO_DONE, 0x00, 0x00, 0x00]) + { + break; + } + } + } + + // 3. Determine cipher suite and derive keys + let suite = CipherSuite::from_bytes(selected_cipher).ok_or_else(|| { + IdeviceError::InternalError(format!( + "Server selected unsupported cipher: {selected_cipher:02x?}" + )) + })?; + debug!("Using cipher suite: {suite:?}"); + + let master = derive_master_secret(psk, &client_random, &server_random, suite); + let keys = derive_key_block(&master, &client_random, &server_random, suite); + + // 4. ClientKeyExchange (empty PSK identity) + let cke = make_handshake(HS_CLIENT_KEY_EXCHANGE, &[0x00, 0x00]); + transcript.extend_from_slice(&cke); + stream.write_all(&make_record(CT_HANDSHAKE, &cke)).await?; + debug!("Sent ClientKeyExchange"); + + // 5. ChangeCipherSpec + stream + .write_all(&make_record(CT_CHANGE_CIPHER_SPEC, &[0x01])) + .await?; + debug!("Sent ChangeCipherSpec"); + + // 6. Client Finished (encrypted) + let vd = finished_verify_data(&master, b"client finished", &transcript, suite); + let fin = make_handshake(HS_FINISHED, &vd); + transcript.extend_from_slice(&fin); + let enc_fin = encrypt_record(&keys, 0, CT_HANDSHAKE, &fin); + stream + .write_all(&make_record(CT_HANDSHAKE, &enc_fin)) + .await?; + stream.flush().await?; + debug!("Sent encrypted Finished"); + + // 7. Read server ChangeCipherSpec + Finished + let mut server_seq: u64 = 0; + loop { + let (ct, payload) = read_record(&mut stream).await?; + if ct == 21 { + let level = payload.first().copied().unwrap_or(0); + let desc = payload.get(1).copied().unwrap_or(0); + return Err(IdeviceError::InternalError(format!( + "TLS Alert after Finished: level={level} desc={desc}" + ))); + } + match ct { + CT_CHANGE_CIPHER_SPEC => { + debug!("Got server ChangeCipherSpec"); + } + CT_APPLICATION_DATA | CT_HANDSHAKE => { + let plaintext = decrypt_record(&keys, true, server_seq, CT_HANDSHAKE, &payload)?; + server_seq += 1; + + if plaintext.len() >= 4 && plaintext[0] == HS_FINISHED { + let server_vd = + finished_verify_data(&master, b"server finished", &transcript, suite); + if plaintext[4..] == server_vd { + debug!("Server Finished verified!"); + } else { + debug!("Server Finished verify_data mismatch (continuing anyway)"); + } + break; + } + } + _ => { + debug!("Unexpected record type {ct} during handshake"); + } + } + } + + debug!("TLS-PSK handshake complete"); + + Ok(TlsPskStream { + inner: stream, + keys, + write_seq: 1, // seq 0 was used for client Finished + read_seq: server_seq, + read_buf: Vec::new(), + pending_record: Vec::new(), + pending_record_total: 0, + write_buf: Vec::new(), + }) +} + +/// An encrypted TLS-PSK stream implementing AsyncRead + AsyncWrite. +pub struct TlsPskStream { + inner: S, + keys: KeyBlock, + write_seq: u64, + read_seq: u64, + /// Decrypted plaintext buffered for reads + read_buf: Vec, + /// Partial inbound TLS record being assembled + pending_record: Vec, + /// Expected total bytes for the current inbound record (5 + body_len), 0 if unknown + pending_record_total: usize, + /// Partial outbound TLS record waiting to be flushed + write_buf: Vec, +} + +impl std::fmt::Debug for TlsPskStream { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TlsPskStream") + .field("write_seq", &self.write_seq) + .field("read_seq", &self.read_seq) + .finish() + } +} + +impl TlsPskStream { + /// Encrypt and send application data, splitting into multiple TLS records if needed. + pub async fn write_app_data(&mut self, data: &[u8]) -> Result<(), IdeviceError> { + for chunk in data.chunks(TLS_MAX_PLAINTEXT) { + let encrypted = encrypt_record(&self.keys, self.write_seq, CT_APPLICATION_DATA, chunk); + self.write_seq += 1; + self.inner + .write_all(&make_record(CT_APPLICATION_DATA, &encrypted)) + .await?; + } + self.inner.flush().await?; + Ok(()) + } + + /// Read and decrypt application data. + pub async fn read_app_data(&mut self) -> Result, IdeviceError> { + let (ct, payload) = read_record(&mut self.inner).await?; + if ct != CT_APPLICATION_DATA { + return Err(IdeviceError::InternalError(format!( + "Expected application data, got ct={ct}" + ))); + } + let plaintext = decrypt_record( + &self.keys, + true, + self.read_seq, + CT_APPLICATION_DATA, + &payload, + )?; + self.read_seq += 1; + Ok(plaintext) + } +} + +// AsyncRead: assemble complete TLS records across multiple poll_read calls +impl AsyncRead for TlsPskStream { + fn poll_read( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &mut ReadBuf<'_>, + ) -> Poll> { + let this = self.get_mut(); + + // 1. Serve from decrypted buffer first + if !this.read_buf.is_empty() { + let n = buf.remaining().min(this.read_buf.len()); + buf.put_slice(&this.read_buf[..n]); + this.read_buf.drain(..n); + return Poll::Ready(Ok(())); + } + + // 2. Continue assembling a TLS record + loop { + // If we have the header (5 bytes), compute total needed + if this.pending_record.len() >= 5 && this.pending_record_total == 0 { + let body_len = + u16::from_be_bytes([this.pending_record[3], this.pending_record[4]]) as usize; + this.pending_record_total = 5 + body_len; + } + + // If we have a complete record, decrypt it + if this.pending_record_total > 0 + && this.pending_record.len() >= this.pending_record_total + { + let ct = this.pending_record[0]; + let body = this.pending_record[5..this.pending_record_total].to_vec(); + this.pending_record.drain(..this.pending_record_total); + this.pending_record_total = 0; + + match decrypt_record(&this.keys, true, this.read_seq, ct, &body) { + Ok(plaintext) => { + this.read_seq += 1; + let n = buf.remaining().min(plaintext.len()); + buf.put_slice(&plaintext[..n]); + if n < plaintext.len() { + this.read_buf.extend_from_slice(&plaintext[n..]); + } + return Poll::Ready(Ok(())); + } + Err(e) => { + tracing::warn!( + "TLS decrypt failed (ct={ct}, seq={}, body_len={}): {e}", + this.read_seq, + body.len() + ); + return Poll::Ready(Err(std::io::Error::other(format!( + "TLS decrypt: {e}" + )))); + } + } + } + + // Need more data from the underlying stream + let mut tmp = [0u8; 16384]; + let mut tmp_buf = ReadBuf::new(&mut tmp); + match Pin::new(&mut this.inner).poll_read(cx, &mut tmp_buf) { + Poll::Pending => return Poll::Pending, + Poll::Ready(Err(e)) => return Poll::Ready(Err(e)), + Poll::Ready(Ok(())) => { + let n = tmp_buf.filled().len(); + if n == 0 { + return Poll::Ready(Ok(())); // EOF + } + this.pending_record.extend_from_slice(&tmp[..n]); + // Loop back to check if we now have a complete record + } + } + } + } +} + +impl AsyncWrite for TlsPskStream { + fn poll_write( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + data: &[u8], + ) -> Poll> { + let this = self.get_mut(); + + // Flush any pending partial write first + while !this.write_buf.is_empty() { + match Pin::new(&mut this.inner).poll_write(cx, &this.write_buf) { + Poll::Ready(Ok(n)) => { + this.write_buf.drain(..n); + } + Poll::Ready(Err(e)) => return Poll::Ready(Err(e)), + Poll::Pending => return Poll::Pending, + } + } + + // Clamp to TLS max record size to avoid oversized records + let chunk = &data[..data.len().min(TLS_MAX_PLAINTEXT)]; + + // Encrypt the new data into a TLS record + let encrypted = encrypt_record(&this.keys, this.write_seq, CT_APPLICATION_DATA, chunk); + let record = make_record(CT_APPLICATION_DATA, &encrypted); + this.write_seq += 1; + + match Pin::new(&mut this.inner).poll_write(cx, &record) { + Poll::Ready(Ok(written)) => { + if written < record.len() { + // Buffer the unsent remainder + this.write_buf.extend_from_slice(&record[written..]); + } + Poll::Ready(Ok(chunk.len())) + } + Poll::Ready(Err(e)) => { + this.write_seq -= 1; + Poll::Ready(Err(e)) + } + Poll::Pending => { + // Buffer the entire record for later flush + this.write_buf = record; + Poll::Pending + } + } + } + + fn poll_flush(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let this = self.get_mut(); + + // Flush buffered write data first + while !this.write_buf.is_empty() { + match Pin::new(&mut this.inner).poll_write(cx, &this.write_buf) { + Poll::Ready(Ok(n)) => { + this.write_buf.drain(..n); + } + Poll::Ready(Err(e)) => return Poll::Ready(Err(e)), + Poll::Pending => return Poll::Pending, + } + } + + Pin::new(&mut this.inner).poll_flush(cx) + } + + fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.get_mut().inner).poll_shutdown(cx) + } +} diff --git a/src-tauri/vendor/idevice/src/remote_pairing/tlv.rs b/src-tauri/vendor/idevice/src/remote_pairing/tlv.rs new file mode 100644 index 0000000..46412de --- /dev/null +++ b/src-tauri/vendor/idevice/src/remote_pairing/tlv.rs @@ -0,0 +1,140 @@ +// Jackson Coxson + +use super::errors::RemotePairingError; + +// from pym3 +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum PairingDataComponentType { + Method = 0x00, + Identifier = 0x01, + Salt = 0x02, + PublicKey = 0x03, + Proof = 0x04, + EncryptedData = 0x05, + State = 0x06, + ErrorResponse = 0x07, + RetryDelay = 0x08, + Certificate = 0x09, + Signature = 0x0a, + Permissions = 0x0b, + FragmentData = 0x0c, + FragmentLast = 0x0d, + SessionId = 0x0e, + Ttl = 0x0f, + ExtraData = 0x10, + Info = 0x11, + Acl = 0x12, + Flags = 0x13, + ValidationData = 0x14, + MfiAuthToken = 0x15, + MfiProductType = 0x16, + SerialNumber = 0x17, + MfiAuthTokenUuid = 0x18, + AppFlags = 0x19, + OwnershipProof = 0x1a, + SetupCodeType = 0x1b, + ProductionData = 0x1c, + AppInfo = 0x1d, + Separator = 0xff, +} + +#[derive(Debug, Clone)] +pub struct TLV8Entry { + pub tlv_type: PairingDataComponentType, + pub data: Vec, +} + +pub fn collect_component_data( + entries: &[TLV8Entry], + component: PairingDataComponentType, +) -> Vec { + let mut out = Vec::new(); + for entry in entries { + if entry.tlv_type == component { + out.extend_from_slice(&entry.data); + } + } + out +} + +pub fn contains_component(entries: &[TLV8Entry], component: PairingDataComponentType) -> bool { + entries.iter().any(|entry| entry.tlv_type == component) +} + +pub fn serialize_tlv8(entries: &[TLV8Entry]) -> Vec { + let mut out = Vec::new(); + for entry in entries { + out.push(entry.tlv_type as u8); + out.push(entry.data.len() as u8); + out.extend(&entry.data); + } + out +} + +pub fn deserialize_tlv8(input: &[u8]) -> Result, RemotePairingError> { + let mut index = 0; + let mut result = Vec::new(); + + while index + 2 <= input.len() { + let type_byte = input[index]; + let length = input[index + 1] as usize; + index += 2; + + if index + length > input.len() { + return Err(RemotePairingError::MalformedTlv); + } + + let data = input[index..index + length].to_vec(); + index += length; + + let tlv_type = PairingDataComponentType::try_from(type_byte) + .map_err(|_| RemotePairingError::UnknownTlv(type_byte))?; + + result.push(TLV8Entry { tlv_type, data }); + } + + Ok(result) +} + +impl TryFrom for PairingDataComponentType { + type Error = u8; + + fn try_from(value: u8) -> Result { + use PairingDataComponentType::*; + Ok(match value { + 0x00 => Method, + 0x01 => Identifier, + 0x02 => Salt, + 0x03 => PublicKey, + 0x04 => Proof, + 0x05 => EncryptedData, + 0x06 => State, + 0x07 => ErrorResponse, + 0x08 => RetryDelay, + 0x09 => Certificate, + 0x0a => Signature, + 0x0b => Permissions, + 0x0c => FragmentData, + 0x0d => FragmentLast, + 0x0e => SessionId, + 0x0f => Ttl, + 0x10 => ExtraData, + 0x11 => Info, + 0x12 => Acl, + 0x13 => Flags, + 0x14 => ValidationData, + 0x15 => MfiAuthToken, + 0x16 => MfiProductType, + 0x17 => SerialNumber, + 0x18 => MfiAuthTokenUuid, + 0x19 => AppFlags, + 0x1a => OwnershipProof, + 0x1b => SetupCodeType, + 0x1c => ProductionData, + 0x1d => AppInfo, + 0xff => Separator, + other => return Err(other), + }) + } +} diff --git a/src-tauri/vendor/idevice/src/remote_pairing/tunnel.rs b/src-tauri/vendor/idevice/src/remote_pairing/tunnel.rs new file mode 100644 index 0000000..b66be37 --- /dev/null +++ b/src-tauri/vendor/idevice/src/remote_pairing/tunnel.rs @@ -0,0 +1,186 @@ +// Jackson Coxson +//! TLS-PSK tunnel connect helpers for remote pairing. +//! +//! These functions combine TLS-PSK handshake + CDTunnel handshake into a single call. +//! The CDTunnel protocol itself lives in [`crate::tunnel`]. + +use tracing::debug; + +use crate::{IdeviceError, ReadWrite}; + +// Re-export for backwards compatibility +pub use crate::tunnel::{CdTunnel, TunnelInfo}; + +const CDTUNNEL_MAGIC: &[u8] = b"CDTunnel"; +const DEFAULT_MTU: u16 = 16000; + +/// Wraps a `tokio::net::TcpStream` with TLS-PSK using a pure-Rust implementation +/// and performs the CDTunnel handshake, returning a ready-to-use tunnel. +/// +/// `encryption_key` is the key from `RemotePairingClient::encryption_key()`. +/// +/// This uses a built-in TLS 1.2 PSK-AES256-CBC-SHA384 implementation with no +/// external TLS library dependency. +pub async fn connect_tls_psk_tunnel_native( + stream: S, + encryption_key: &[u8], +) -> Result>, IdeviceError> { + let mut tls_stream = super::tls_psk::tls_psk_handshake(stream, encryption_key).await?; + debug!("Native TLS-PSK handshake complete"); + + // CDTunnel handshake over TLS using the record-level API + let request = serde_json::json!({ + "type": "clientHandshakeRequest", + "mtu": DEFAULT_MTU + }); + let body = serde_json::to_vec(&request)?; + + let mut pkt = Vec::new(); + pkt.extend_from_slice(CDTUNNEL_MAGIC); + pkt.extend_from_slice(&(body.len() as u16).to_be_bytes()); + pkt.extend_from_slice(&body); + tls_stream.write_app_data(&pkt).await?; + + debug!("Sent CDTunnel handshake request via TLS"); + + let response_data = tls_stream.read_app_data().await?; + if response_data.len() < CDTUNNEL_MAGIC.len() + 2 { + return Err(IdeviceError::UnexpectedResponse( + "CDTunnel handshake response too short".into(), + )); + } + if &response_data[..CDTUNNEL_MAGIC.len()] != CDTUNNEL_MAGIC { + return Err(IdeviceError::UnexpectedResponse( + "CDTunnel handshake response missing magic header".into(), + )); + } + let body_len = u16::from_be_bytes([ + response_data[CDTUNNEL_MAGIC.len()], + response_data[CDTUNNEL_MAGIC.len() + 1], + ]) as usize; + let body_start = CDTUNNEL_MAGIC.len() + 2; + let response_body = &response_data[body_start..body_start + body_len]; + + let response: serde_json::Value = serde_json::from_slice(response_body) + .map_err(|e| IdeviceError::InternalError(e.to_string()))?; + + debug!("CDTunnel handshake response: {response:#?}"); + + let client_params = + response + .get("clientParameters") + .ok_or(IdeviceError::UnexpectedResponse( + "missing clientParameters in CDTunnel response".into(), + ))?; + + let client_address = client_params + .get("address") + .and_then(|a| a.as_str()) + .ok_or(IdeviceError::UnexpectedResponse( + "missing client address in CDTunnel response".into(), + ))? + .to_string(); + + let mtu = client_params + .get("mtu") + .and_then(|m| m.as_u64()) + .unwrap_or(1500) as u16; + + let server_address = response + .get("serverAddress") + .and_then(|a| a.as_str()) + .ok_or(IdeviceError::UnexpectedResponse( + "missing server address in CDTunnel response".into(), + ))? + .to_string(); + + let server_rsd_port = response + .get("serverRSDPort") + .and_then(|p| p.as_u64()) + .unwrap_or(0) as u16; + + let info = TunnelInfo { + client_address, + netmask: client_params + .get("netmask") + .and_then(|n| n.as_str()) + .unwrap_or("") + .to_string(), + server_address, + mtu, + server_rsd_port, + }; + + debug!("CDTunnel established: {info:?}"); + + Ok(CdTunnel { + inner: tls_stream, + info, + }) +} + +/// Wraps a `tokio::net::TcpStream` with TLS-PSK using OpenSSL and performs +/// the CDTunnel handshake, returning a ready-to-use tunnel. +/// +/// `encryption_key` is the key from `RemotePairingClient::encryption_key()`. +/// +/// Requires the `openssl` feature. Consider using [`connect_tls_psk_tunnel_native`] +/// instead, which has no external dependency. +#[cfg(feature = "openssl")] +pub async fn connect_tls_psk_tunnel( + stream: S, + encryption_key: &[u8], +) -> Result>, IdeviceError> { + use openssl::ssl::{SslConnector, SslMethod, SslVerifyMode}; + + let psk = encryption_key.to_vec(); + + let mut builder = SslConnector::builder(SslMethod::tls_client()) + .map_err(|e| IdeviceError::InternalError(format!("SslConnector::builder: {e}")))?; + + builder.set_verify(SslVerifyMode::NONE); + builder + .set_cipher_list( + "PSK-AES128-CBC-SHA:PSK-AES256-CBC-SHA:PSK-AES128-CBC-SHA256:PSK-AES256-CBC-SHA384", + ) + .map_err(|e| IdeviceError::InternalError(format!("set_cipher_list: {e}")))?; + builder.set_psk_client_callback(move |_ssl, _hint, identity, psk_out| { + if !identity.is_empty() { + identity[0] = 0; + } + let len = psk.len().min(psk_out.len()); + psk_out[..len].copy_from_slice(&psk[..len]); + Ok(len) + }); + + builder + .set_min_proto_version(Some(openssl::ssl::SslVersion::TLS1_2)) + .map_err(|e| IdeviceError::InternalError(e.to_string()))?; + builder + .set_max_proto_version(Some(openssl::ssl::SslVersion::TLS1_2)) + .map_err(|e| IdeviceError::InternalError(e.to_string()))?; + + let ssl_connector = builder.build(); + let mut conf = ssl_connector + .configure() + .map_err(|e| IdeviceError::InternalError(e.to_string()))?; + conf.set_verify_hostname(false); + conf.set_use_server_name_indication(false); + let ssl = conf + .into_ssl("localhost") + .map_err(|e| IdeviceError::InternalError(e.to_string()))?; + + let mut tls_stream = tokio_openssl::SslStream::new(ssl, stream) + .map_err(|e| IdeviceError::InternalError(e.to_string()))?; + + if let Err(e) = std::pin::Pin::new(&mut tls_stream).connect().await { + let ssl_errors = openssl::error::ErrorStack::get(); + let msg = format!("TLS-PSK handshake failed: {e} (SSL errors: {ssl_errors:?})"); + tracing::error!("{msg}"); + return Err(IdeviceError::InternalError(msg)); + } + + debug!("TLS-PSK handshake complete"); + + CdTunnel::handshake(tls_stream).await +} diff --git a/src-tauri/vendor/idevice/src/services/afc/errors.rs b/src-tauri/vendor/idevice/src/services/afc/errors.rs new file mode 100644 index 0000000..9d68106 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/afc/errors.rs @@ -0,0 +1,154 @@ +// Jackson Coxson + +#[derive(thiserror::Error, Debug, PartialEq)] +#[non_exhaustive] +#[repr(C)] +pub enum AfcError { + Success = 0, + UnknownError = 1, + OpHeaderInvalid = 2, + NoResources = 3, + ReadError = 4, + WriteError = 5, + UnknownPacketType = 6, + InvalidArg = 7, + ObjectNotFound = 8, + ObjectIsDir = 9, + PermDenied = 10, + ServiceNotConnected = 11, + OpTimeout = 12, + TooMuchData = 13, + EndOfData = 14, + OpNotSupported = 15, + ObjectExists = 16, + ObjectBusy = 17, + NoSpaceLeft = 18, + OpWouldBlock = 19, + IoError = 20, + OpInterrupted = 21, + OpInProgress = 22, + InternalError = 23, + MuxError = 30, + NoMem = 31, + NotEnoughData = 32, + DirNotEmpty = 33, + UnknownOpcode = 34, + InvalidMagic = 35, + MissingAttribute = 36, +} + +impl std::fmt::Display for AfcError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let description = match self { + AfcError::Success => "Success", + AfcError::UnknownError => "Unknown error", + AfcError::OpHeaderInvalid => "Operation header invalid", + AfcError::NoResources => "No resources available", + AfcError::ReadError => "Read error", + AfcError::WriteError => "Write error", + AfcError::UnknownPacketType => "Unknown packet type", + AfcError::InvalidArg => "Invalid argument", + AfcError::ObjectNotFound => "Object not found", + AfcError::ObjectIsDir => "Object is a directory", + AfcError::PermDenied => "Permission denied", + AfcError::ServiceNotConnected => "Service not connected", + AfcError::OpTimeout => "Operation timed out", + AfcError::TooMuchData => "Too much data", + AfcError::EndOfData => "End of data", + AfcError::OpNotSupported => "Operation not supported", + AfcError::ObjectExists => "Object already exists", + AfcError::ObjectBusy => "Object is busy", + AfcError::NoSpaceLeft => "No space left", + AfcError::OpWouldBlock => "Operation would block", + AfcError::IoError => "I/O error", + AfcError::OpInterrupted => "Operation interrupted", + AfcError::OpInProgress => "Operation in progress", + AfcError::InternalError => "Internal error", + AfcError::MuxError => "Multiplexer error", + AfcError::NoMem => "Out of memory", + AfcError::NotEnoughData => "Not enough data", + AfcError::DirNotEmpty => "Directory not empty", + AfcError::UnknownOpcode => "Unknown AFC opcode", + AfcError::InvalidMagic => "Invalid AFC magic", + AfcError::MissingAttribute => "Missing file attribute", + }; + write!(f, "{description}") + } +} + +impl From for AfcError { + fn from(value: u64) -> Self { + match value { + 0 => Self::Success, + 1 => Self::UnknownError, + 2 => Self::OpHeaderInvalid, + 3 => Self::NoResources, + 4 => Self::ReadError, + 5 => Self::WriteError, + 6 => Self::UnknownPacketType, + 7 => Self::InvalidArg, + 8 => Self::ObjectNotFound, + 9 => Self::ObjectIsDir, + 10 => Self::PermDenied, + 11 => Self::ServiceNotConnected, + 12 => Self::OpTimeout, + 13 => Self::TooMuchData, + 14 => Self::EndOfData, + 15 => Self::OpNotSupported, + 16 => Self::ObjectExists, + 17 => Self::ObjectBusy, + 18 => Self::NoSpaceLeft, + 19 => Self::OpWouldBlock, + 20 => Self::IoError, + 21 => Self::OpInterrupted, + 22 => Self::OpInProgress, + 23 => Self::InternalError, + 30 => Self::MuxError, + 31 => Self::NoMem, + 32 => Self::NotEnoughData, + 33 => Self::DirNotEmpty, + 34 => Self::UnknownOpcode, + 35 => Self::InvalidMagic, + 36 => Self::MissingAttribute, + _ => Self::UnknownError, // fallback for unknown codes + } + } +} + +impl AfcError { + pub fn sub_code(&self) -> i32 { + match self { + Self::Success => 0, + Self::UnknownError => 1, + Self::OpHeaderInvalid => 2, + Self::NoResources => 3, + Self::ReadError => 4, + Self::WriteError => 5, + Self::UnknownPacketType => 6, + Self::InvalidArg => 7, + Self::ObjectNotFound => 8, + Self::ObjectIsDir => 9, + Self::PermDenied => 10, + Self::ServiceNotConnected => 11, + Self::OpTimeout => 12, + Self::TooMuchData => 13, + Self::EndOfData => 14, + Self::OpNotSupported => 15, + Self::ObjectExists => 16, + Self::ObjectBusy => 17, + Self::NoSpaceLeft => 18, + Self::OpWouldBlock => 19, + Self::IoError => 20, + Self::OpInterrupted => 21, + Self::OpInProgress => 22, + Self::InternalError => 23, + Self::MuxError => 24, + Self::NoMem => 25, + Self::NotEnoughData => 26, + Self::DirNotEmpty => 27, + Self::UnknownOpcode => 28, + Self::InvalidMagic => 29, + Self::MissingAttribute => 30, + } + } +} diff --git a/src-tauri/vendor/idevice/src/services/afc/file.rs b/src-tauri/vendor/idevice/src/services/afc/file.rs new file mode 100644 index 0000000..d29b637 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/afc/file.rs @@ -0,0 +1,176 @@ +// Jackson Coxson + +use std::{io::SeekFrom, marker::PhantomPinned, pin::Pin}; + +use tokio::io::{AsyncRead, AsyncSeek, AsyncWrite}; + +use crate::{ + IdeviceError, + afc::{ + AfcClient, + inner_file::{InnerFileDescriptor, OwnedInnerFileDescriptor}, + }, +}; + +/// Handle for an open file on the device. +/// +/// Does **not** close the file descriptor on drop. Callers must invoke +/// [`.close()`](Self::close) to release the device-side FD. +/// The FD is reclaimed automatically when the AFC session ends. +#[derive(Debug)] +pub struct FileDescriptor<'a> { + inner: Pin>>, +} + +/// Owned handle for an open file on the device. +/// +/// Does **not** close the file descriptor on drop. Callers must invoke +/// [`.close()`](Self::close) to release the device-side FD. +/// The FD is reclaimed automatically when the AFC session ends. +#[derive(Debug)] +pub struct OwnedFileDescriptor { + inner: Pin>, +} + +impl<'a> FileDescriptor<'a> { + /// create a new FileDescriptor from a raw fd + /// + /// # Safety + /// make sure the fd is an opened file, and that you got it from a previous + /// FileDescriptor via `as_raw_fd()` method + pub unsafe fn new(client: &'a mut AfcClient, fd: u64, path: String) -> Self { + Self { + inner: Box::pin(InnerFileDescriptor { + client, + fd, + path, + pending_fut: None, + _m: PhantomPinned, + dropped: false, + }), + } + } + + /// Closes the file descriptor + pub async fn close(self) -> Result<(), IdeviceError> { + self.inner.close().await + } +} + +impl OwnedFileDescriptor { + /// create a new OwnedFileDescriptor from a raw fd + /// + /// # Safety + /// make sure the fd is an opened file, and that you got it from a previous + /// OwnedFileDescriptor via `as_raw_fd()` method + pub unsafe fn new(client: AfcClient, fd: u64, path: String) -> Self { + Self { + inner: Box::pin(OwnedInnerFileDescriptor { + client, + fd, + path, + pending_fut: None, + _m: PhantomPinned, + dropped: false, + }), + } + } + + /// Closes the file descriptor + pub async fn close(self) -> Result { + self.inner.close().await + } + + /// gets the owned afc + /// + /// # Safety + /// this get's the afc out without closing, if you want to get the afc and close the file, use + /// `.close()` + pub unsafe fn get_inner_afc(self) -> AfcClient { + self.inner.get_inner_afc() + } +} + +crate::impl_to_structs!(FileDescriptor<'_>, OwnedFileDescriptor; { + pub fn as_raw_fd(&self) -> u64 { + self.inner.fd + } + + /// Returns the current cursor position for the file + pub async fn seek_tell(&mut self) -> Result { + self.inner.as_mut().seek_tell().await + } + + /// Reads the entire contents of the file + /// + /// # Returns + /// A vector containing the file's data + pub async fn read_entire(&mut self) -> Result, IdeviceError> { + self.inner.as_mut().read().await + } + + pub async fn read_n(&mut self, n: usize) -> Result, IdeviceError> { + self.inner.as_mut().read_n(n).await + } + + /// Writes data to the file + /// + /// # Arguments + /// * `bytes` - Data to write to the file + pub async fn write_entire(&mut self, bytes: &[u8]) -> Result<(), IdeviceError> { + self.inner.as_mut().write(bytes).await + } +}); + +crate::impl_trait_to_structs!(AsyncRead for FileDescriptor<'_>, OwnedFileDescriptor; { + fn poll_read( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &mut tokio::io::ReadBuf<'_>, + ) -> std::task::Poll> { + let inner = self.inner.as_mut(); + inner.poll_read(cx, buf) + } +}); + +crate::impl_trait_to_structs!(AsyncWrite for FileDescriptor<'_>, OwnedFileDescriptor; { + fn poll_write( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> std::task::Poll> { + let inner = self.inner.as_mut(); + inner.poll_write(cx, buf) + } + + fn poll_flush( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + let inner = self.inner.as_mut(); + inner.poll_flush(cx) + } + + fn poll_shutdown( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + let inner = self.inner.as_mut(); + inner.poll_shutdown(cx) + } +}); + +crate::impl_trait_to_structs!(AsyncSeek for FileDescriptor<'_>, OwnedFileDescriptor; { + fn start_seek(mut self: Pin<&mut Self>, position: SeekFrom) -> std::io::Result<()> { + let this = self.inner.as_mut(); + this.start_seek(position) + } + + fn poll_complete( + mut self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + let this = self.inner.as_mut(); + this.poll_complete(cx) + } +}); diff --git a/src-tauri/vendor/idevice/src/services/afc/inner_file.rs b/src-tauri/vendor/idevice/src/services/afc/inner_file.rs new file mode 100644 index 0000000..397d21f --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/afc/inner_file.rs @@ -0,0 +1,481 @@ +// Jackson Coxson + +use std::{io::SeekFrom, pin::Pin}; + +use futures::{FutureExt, future::BoxFuture}; +use tokio::io::{AsyncRead, AsyncSeek, AsyncWrite}; + +use crate::{ + IdeviceError, + afc::{ + AfcClient, MAGIC, + opcode::AfcOpcode, + packet::{AfcPacket, AfcPacketHeader}, + }, +}; + +/// Maximum transfer size for file operations (1MB) +const MAX_TRANSFER: u64 = 1024 * 1024; // this is what libimobiledevice uses in afcclient + +fn chunk_number(n: usize, chunk_size: usize) -> impl Iterator { + (0..n) + .step_by(chunk_size) + .map(move |i| (n - i).min(chunk_size)) +} + +/// Descripes what the future returns +#[derive(Debug)] +pub(crate) enum PendingResult { + // writing + Empty, + // seeking + SeekPos(u64), + // reading + Bytes(Vec), +} + +type OwnedBoxFuture = Pin> + Send>>; + +pub(crate) struct InnerFileDescriptor<'a> { + pub(crate) client: &'a mut AfcClient, + pub(crate) fd: u64, + pub(crate) path: String, + + pub(crate) pending_fut: Option>>, + pub(crate) _m: std::marker::PhantomPinned, + + pub(crate) dropped: bool, +} + +pub(crate) struct OwnedInnerFileDescriptor { + pub(crate) client: AfcClient, + pub(crate) fd: u64, + pub(crate) path: String, + + pub(crate) pending_fut: Option, + pub(crate) _m: std::marker::PhantomPinned, + + pub(crate) dropped: bool, +} + +crate::impl_to_structs!(InnerFileDescriptor<'_>, OwnedInnerFileDescriptor; { + /// Generic helper to send an AFC packet and read the response + pub async fn send_packet( + self: Pin<&mut Self>, + opcode: AfcOpcode, + header_payload: Vec, + payload: Vec, + ) -> Result { + // SAFETY: we don't modify pinned fileds, it's ok + let this = unsafe { self.get_unchecked_mut() }; + + let header_len = header_payload.len() as u64 + AfcPacketHeader::LEN; + let header = AfcPacketHeader { + magic: MAGIC, + entire_len: header_len + payload.len() as u64, + header_payload_len: header_len, + packet_num: this.client.package_number, + operation: opcode, + }; + this.client.package_number += 1; + + let packet = AfcPacket { + header, + header_payload, + payload, + }; + + this.client.send(packet).await?; + this.client.read().await + } + + /// Returns the current cursor position for the file + pub async fn seek_tell(self: Pin<&mut Self>) -> Result { + let header_payload = self.fd.to_le_bytes().to_vec(); + let res = self + .send_packet(AfcOpcode::FileTell, header_payload, Vec::new()) + .await?; + + let cur_pos = res + .header_payload + .get(..8) + .ok_or(IdeviceError::UnexpectedResponse("AFC FileTell response missing position bytes".into()))? + .try_into() + .map(u64::from_le_bytes) + .map_err(|_| IdeviceError::UnexpectedResponse("AFC FileTell position bytes invalid length".into()))?; + + Ok(cur_pos) + } + + /// Moves the file cursor + async fn seek(mut self: Pin<&mut Self>, pos: SeekFrom) -> Result { + let (offset, whence) = match pos { + SeekFrom::Start(off) => (off as i64, 0), + SeekFrom::Current(off) => (off, 1), + SeekFrom::End(off) => (off, 2), + }; + + let header_payload = [ + self.fd.to_le_bytes(), + (whence as u64).to_le_bytes(), + offset.to_le_bytes(), + ] + .concat(); + + self.as_mut() + .send_packet(AfcOpcode::FileSeek, header_payload, Vec::new()) + .await?; + + self.as_mut().seek_tell().await + } + + + /// Reads n size of contents from the file + /// + /// # Arguments + /// * `n` - amount of bytes to read + /// # Returns + /// A vector containing the file's data + pub async fn read_n(mut self: Pin<&mut Self>, n: usize) -> Result, IdeviceError> { + let mut collected_bytes = Vec::with_capacity(n); + + for chunk in chunk_number(n, MAX_TRANSFER as usize) { + let header_payload = [self.fd.to_le_bytes(), (chunk as u64).to_le_bytes()].concat(); + let res = self + .as_mut() + .send_packet(AfcOpcode::Read, header_payload, Vec::new()) + .await?; + + if res.payload.is_empty() { + break; + } + collected_bytes.extend(res.payload); + } + Ok(collected_bytes) + } + + /// Reads the entire contents of the file + /// + /// # Returns + /// A vector containing the file's data + pub async fn read(mut self: Pin<&mut Self>) -> Result, IdeviceError> { + let seek_pos = self.as_mut().seek_tell().await? as usize; + + let file_info = unsafe { + let this = self.as_mut().get_unchecked_mut(); + + this.client.get_file_info(&this.path).await? + }; + + let mut bytes_left = file_info.size.saturating_sub(seek_pos); + let mut collected_bytes = Vec::with_capacity(bytes_left); + + while bytes_left > 0 { + let want = bytes_left.min(MAX_TRANSFER as usize); + let bytes = self.as_mut().read_n(want).await?; + + if bytes.is_empty() { + break; + } + + bytes_left = bytes_left.saturating_sub(bytes.len()); + collected_bytes.extend(bytes); + } + + Ok(collected_bytes) + } + + /// Writes data to the file + /// + /// # Arguments + /// * `bytes` - Data to write to the file + pub async fn write(mut self: Pin<&mut Self>, bytes: &[u8]) -> Result<(), IdeviceError> { + for chunk in bytes.chunks(MAX_TRANSFER as usize) { + let header_payload = self.as_ref().fd.to_le_bytes().to_vec(); + self.as_mut() + .send_packet(AfcOpcode::Write, header_payload, chunk.to_vec()) + .await?; + } + Ok(()) + } + + fn store_pending_read(mut self: Pin<&mut Self>, buf_rem: usize) { + unsafe { + let this = self.as_mut().get_unchecked_mut() as *mut Self; + + let fut = Some( + // SAFETY: we already know that self is pinned + Pin::new_unchecked(&mut *this) + .read_n(buf_rem) + .map(|r| r.map(PendingResult::Bytes)) + .boxed(), + ); + + (&mut *this).pending_fut = fut; + } + } + + fn store_pending_seek(mut self: Pin<&mut Self>, position: std::io::SeekFrom) { + unsafe { + let this = self.as_mut().get_unchecked_mut() as *mut Self; + + let fut = Some( + Pin::new_unchecked(&mut *this) + .seek(position) + .map(|r| r.map(PendingResult::SeekPos)) + .boxed(), + ); + + (&mut *this).pending_fut = fut; + } + } + + fn store_pending_write(mut self: Pin<&mut Self>, buf: &'_ [u8]) { + unsafe { + let this = self.as_mut().get_unchecked_mut(); + + let this = this as *mut Self; + + // move the entire buffer into the future so we don't have to store it somewhere + let pined_this = Pin::new_unchecked(&mut *this); + let buf = buf.to_vec(); + let fut = + async move { pined_this.write(&buf).await.map(|_| PendingResult::Empty) }.boxed(); + + (&mut *this).pending_fut = Some(fut); + } + } +}); + +impl<'a> InnerFileDescriptor<'a> { + fn get_or_init_read_fut( + mut self: Pin<&mut Self>, + buf_rem: usize, + ) -> &mut BoxFuture<'a, Result> { + if self.as_ref().pending_fut.is_none() { + self.as_mut().store_pending_read(buf_rem); + } + + unsafe { self.get_unchecked_mut().pending_fut.as_mut().unwrap() } + } + + fn get_or_init_write_fut( + mut self: Pin<&mut Self>, + buf: &'_ [u8], + ) -> &mut BoxFuture<'a, Result> { + if self.as_ref().pending_fut.is_none() { + self.as_mut().store_pending_write(buf); + } + + unsafe { self.get_unchecked_mut().pending_fut.as_mut().unwrap() } + } + + fn get_seek_fut( + self: Pin<&mut Self>, + ) -> Option<&mut BoxFuture<'a, Result>> { + unsafe { self.get_unchecked_mut().pending_fut.as_mut() } + } + + fn remove_pending_fut(mut self: Pin<&mut Self>) { + unsafe { + self.as_mut().get_unchecked_mut().pending_fut.take(); + } + } + + /// Closes the file descriptor + pub async fn close(mut self: Pin>) -> Result<(), IdeviceError> { + self.as_mut().close_inner().await + } + + async fn close_inner(mut self: Pin<&mut Self>) -> Result<(), IdeviceError> { + let header_payload = self.fd.to_le_bytes().to_vec(); + + self.as_mut() + .send_packet(AfcOpcode::FileClose, header_payload, Vec::new()) + .await?; + + unsafe { Pin::into_inner_unchecked(self).dropped = true } + Ok(()) + } +} + +impl OwnedInnerFileDescriptor { + fn get_or_init_read_fut(mut self: Pin<&mut Self>, buf_rem: usize) -> &mut OwnedBoxFuture { + if self.as_ref().pending_fut.is_none() { + self.as_mut().store_pending_read(buf_rem); + } + + unsafe { self.get_unchecked_mut().pending_fut.as_mut().unwrap() } + } + + fn get_or_init_write_fut(mut self: Pin<&mut Self>, buf: &'_ [u8]) -> &mut OwnedBoxFuture { + if self.as_ref().pending_fut.is_none() { + self.as_mut().store_pending_write(buf); + } + + unsafe { self.get_unchecked_mut().pending_fut.as_mut().unwrap() } + } + + fn get_seek_fut(self: Pin<&mut Self>) -> Option<&mut OwnedBoxFuture> { + unsafe { self.get_unchecked_mut().pending_fut.as_mut() } + } + + fn remove_pending_fut(mut self: Pin<&mut Self>) { + unsafe { + self.as_mut().get_unchecked_mut().pending_fut.take(); + } + } + + /// Closes the file descriptor + pub async fn close(mut self: Pin>) -> Result { + self.as_mut().close_inner().await + } + + async fn close_inner(mut self: Pin<&mut Self>) -> Result { + let header_payload = self.fd.to_le_bytes().to_vec(); + + self.as_mut() + .send_packet(AfcOpcode::FileClose, header_payload, Vec::new()) + .await?; + + Ok(self.into_inner_afc()) + } + + fn into_inner_afc(mut self: Pin<&mut Self>) -> AfcClient { + let this = unsafe { Pin::into_inner_unchecked(self.as_mut()) }; + + this.dropped = true; + + let dummy_afc = AfcClient::new(crate::Idevice::new( + Box::new(std::io::Cursor::new(vec![])), + "67", + )); + + // the `.drop()` won't use the `self.client` if we already dropped it (or don't want to + // drop it) + std::mem::replace(&mut this.client, dummy_afc) + } + + pub fn get_inner_afc(mut self: Pin>) -> AfcClient { + self.as_mut().into_inner_afc() + } +} + +crate::impl_trait_to_structs!(AsyncRead for InnerFileDescriptor<'_>, OwnedInnerFileDescriptor; { + fn poll_read( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &mut tokio::io::ReadBuf<'_>, + ) -> std::task::Poll> { + let contents = { + let read_func = self.as_mut().get_or_init_read_fut(buf.remaining()); + match std::task::ready!(read_func.as_mut().poll(cx)) { + Ok(PendingResult::Bytes(c)) => { + self.as_mut().remove_pending_fut(); + c + } + Err(e) => return std::task::Poll::Ready(Err(std::io::Error::other(e.to_string()))), + + _ => unreachable!("a non read future was stored, this shouldn't happen"), + } + }; + + buf.put_slice(&contents); + + std::task::Poll::Ready(Ok(())) + } +}); + +crate::impl_trait_to_structs!(AsyncWrite for InnerFileDescriptor<'_>, OwnedInnerFileDescriptor; { + fn poll_write( + mut self: std::pin::Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> std::task::Poll> { + let write_func = self.as_mut().get_or_init_write_fut(buf); + + match std::task::ready!(write_func.as_mut().poll(cx)) { + Ok(PendingResult::Empty) => self.as_mut().remove_pending_fut(), + Err(e) => { + println!("error: {e}"); + return std::task::Poll::Ready(Err(std::io::Error::other(e.to_string()))); + } + + _ => unreachable!("a non write future was stored, this shouldn't happen"), + } + + std::task::Poll::Ready(Ok(buf.len())) + } + + fn poll_flush( + self: std::pin::Pin<&mut Self>, + _: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + + fn poll_shutdown( + self: std::pin::Pin<&mut Self>, + _: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + +}); + +crate::impl_trait_to_structs!(AsyncSeek for InnerFileDescriptor<'_>, OwnedInnerFileDescriptor; { + fn start_seek(self: Pin<&mut Self>, position: SeekFrom) -> std::io::Result<()> { + self.store_pending_seek(position); + + Ok(()) + } + + fn poll_complete( + mut self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + let Some(fut) = self.as_mut().get_seek_fut() else { + // tokio runs the `poll_complete` before the `start_seek` to ensure no previous seek is in progress + return std::task::Poll::Ready(Ok(0)); + }; + + match std::task::ready!(fut.as_mut().poll(cx)) { + Ok(PendingResult::SeekPos(pos)) => { + self.as_mut().remove_pending_fut(); + std::task::Poll::Ready(Ok(pos)) + } + Err(e) => std::task::Poll::Ready(Err(std::io::Error::other(e.to_string()))), + _ => unreachable!("a non seek future was stored, this shouldn't happen"), + } + } +}); + +crate::impl_trait_to_structs!(Drop for InnerFileDescriptor<'_>, OwnedInnerFileDescriptor; { + fn drop(&mut self) { + self.pending_fut = None; + if !self.dropped { + debug_assert!(false, "AFC file descriptor for {:?} dropped without calling .close().await", self.path); + println!("error: AFC file descriptor dropped without calling .close().await ({})", self.path); + } + } +}); + +impl std::fmt::Debug for InnerFileDescriptor<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("InnerFileDescriptor") + .field("client", &self.client) + .field("fd", &self.fd) + .field("path", &self.path) + .finish() + } +} + +impl std::fmt::Debug for OwnedInnerFileDescriptor { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OwnedInnerFileDescriptor") + .field("client", &self.client) + .field("fd", &self.fd) + .field("path", &self.path) + .finish() + } +} diff --git a/src-tauri/vendor/idevice/src/services/afc/inner_file_impl_macro.rs b/src-tauri/vendor/idevice/src/services/afc/inner_file_impl_macro.rs new file mode 100644 index 0000000..f169ac2 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/afc/inner_file_impl_macro.rs @@ -0,0 +1,23 @@ +#[macro_export] +macro_rules! impl_to_structs { + ( + $( $name:ident $(<$li:lifetime>)? ),+; + $body:tt + ) => { + $( + impl $name $(<$li>)? $body + )+ + }; +} + +#[macro_export] +macro_rules! impl_trait_to_structs { + ( + $trit:ident for $( $name:ident $(<$li:lifetime>)? ),+; + $body:tt + ) => { + $( + impl $trit for $name $(<$li>)? $body + )+ + }; +} diff --git a/src-tauri/vendor/idevice/src/services/afc/mod.rs b/src-tauri/vendor/idevice/src/services/afc/mod.rs new file mode 100644 index 0000000..31672d7 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/afc/mod.rs @@ -0,0 +1,618 @@ +//! AFC (Apple File Conduit) client implementation for interacting with iOS devices. +//! +//! This module provides functionality to interact with the file system of iOS devices +//! through the AFC protocol. + +use std::collections::HashMap; + +use errors::AfcError; +use opcode::{AfcFopenMode, AfcOpcode}; +use packet::{AfcPacket, AfcPacketHeader}; +use tracing::warn; + +use crate::{ + Idevice, IdeviceError, IdeviceService, + afc::file::{FileDescriptor, OwnedFileDescriptor}, + lockdown::LockdownClient, + obf, +}; + +pub mod errors; +pub mod file; +mod inner_file; +mod inner_file_impl_macro; +pub mod opcode; +pub mod packet; + +/// The magic number used in AFC protocol communications +pub const MAGIC: u64 = 0x4141504c36414643; + +/// Client for interacting with the AFC service on iOS devices +#[derive(Debug)] +pub struct AfcClient { + /// The underlying iDevice connection + pub idevice: Idevice, + package_number: u64, +} + +/// Information about a file on the device +#[derive(Clone, Debug)] +pub struct FileInfo { + /// Size of the file in bytes + pub size: usize, + /// Number of blocks allocated for the file + pub blocks: usize, + /// Creation timestamp of the file + pub creation: chrono::NaiveDateTime, + /// Last modification timestamp of the file + pub modified: chrono::NaiveDateTime, + /// Number of hard links to the file + pub st_nlink: String, + /// File type (e.g., "S_IFREG" for regular file) + pub st_ifmt: String, + /// Target path if this is a symbolic link + pub st_link_target: Option, +} + +/// Information about the device's filesystem +#[derive(Clone, Debug)] +pub struct DeviceInfo { + /// Device model identifier + pub model: String, + /// Total storage capacity in bytes + pub total_bytes: usize, + /// Free storage space in bytes + pub free_bytes: usize, + /// Filesystem block size in bytes + pub block_size: usize, +} + +impl IdeviceService for AfcClient { + fn service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.afc") + } + + async fn from_stream(idevice: Idevice) -> Result { + Ok(Self { + idevice, + package_number: 0, + }) + } +} + +impl AfcClient { + /// Creates a new AFC client from an existing iDevice connection + /// + /// # Arguments + /// * `idevice` - An established iDevice connection + pub fn new(idevice: Idevice) -> Self { + Self { + idevice, + package_number: 0, + } + } + + /// Connects to afc2 from a provider + pub async fn new_afc2( + provider: &dyn crate::provider::IdeviceProvider, + ) -> Result { + let mut lockdown = LockdownClient::connect(provider).await?; + + let legacy = lockdown + .start_session(&provider.get_pairing_file().await?) + .await?; + + let (port, ssl) = lockdown.start_service(obf!("com.apple.afc2")).await?; + + let mut idevice = provider.connect(port).await?; + if ssl { + idevice + .start_session(&provider.get_pairing_file().await?, legacy) + .await?; + } + + Self::from_stream(idevice).await + } + + /// Lists the contents of a directory on the device + /// + /// # Arguments + /// * `path` - Path to the directory to list + /// + /// # Returns + /// A vector of file/directory names in the specified directory + pub async fn list_dir(&mut self, path: impl Into) -> Result, IdeviceError> { + let path = path.into(); + let header_payload = path.as_bytes().to_vec(); + let header_len = header_payload.len() as u64 + AfcPacketHeader::LEN; + + let header = AfcPacketHeader { + magic: MAGIC, + entire_len: header_len, // it's the same since the payload is empty for this + header_payload_len: header_len, + packet_num: self.package_number, + operation: AfcOpcode::ReadDir, + }; + self.package_number += 1; + + let packet = AfcPacket { + header, + header_payload, + payload: Vec::new(), + }; + + self.send(packet).await?; + let res = self.read().await?; + + let strings: Vec = res + .payload + .split(|b| *b == 0) + .filter(|s| !s.is_empty()) + .map(|s| String::from_utf8_lossy(s).into_owned()) + .collect(); + Ok(strings) + } + + /// Creates a new directory on the device + /// + /// # Arguments + /// * `path` - Path of the directory to create + pub async fn mk_dir(&mut self, path: impl Into) -> Result<(), IdeviceError> { + let path = path.into(); + let header_payload = path.as_bytes().to_vec(); + let header_len = header_payload.len() as u64 + AfcPacketHeader::LEN; + + let header = AfcPacketHeader { + magic: MAGIC, + entire_len: header_len, // it's the same since the payload is empty for this + header_payload_len: header_len, + packet_num: self.package_number, + operation: AfcOpcode::MakeDir, + }; + self.package_number += 1; + + let packet = AfcPacket { + header, + header_payload, + payload: Vec::new(), + }; + + self.send(packet).await?; + self.read().await?; // read a response to check for errors + + Ok(()) + } + + /// Retrieves information about a file or directory + /// + /// # Arguments + /// * `path` - Path to the file or directory + /// + /// # Returns + /// A `FileInfo` struct containing information about the file + pub async fn get_file_info( + &mut self, + path: impl Into, + ) -> Result { + let path = path.into(); + let header_payload = path.as_bytes().to_vec(); + let header_len = header_payload.len() as u64 + AfcPacketHeader::LEN; + + let header = AfcPacketHeader { + magic: MAGIC, + entire_len: header_len, // it's the same since the payload is empty for this + header_payload_len: header_len, + packet_num: self.package_number, + operation: AfcOpcode::GetFileInfo, + }; + self.package_number += 1; + + let packet = AfcPacket { + header, + header_payload, + payload: Vec::new(), + }; + + self.send(packet).await?; + let res = self.read().await?; + + let strings: Vec = res + .payload + .split(|b| *b == 0) + .filter(|s| !s.is_empty()) + .map(|s| String::from_utf8_lossy(s).into_owned()) + .collect(); + + let mut kvs: HashMap = strings + .chunks_exact(2) + .map(|chunk| (chunk[0].clone(), chunk[1].clone())) + .collect(); + + let size = kvs + .remove("st_size") + .and_then(|x| x.parse::().ok()) + .ok_or(AfcError::MissingAttribute)?; + let blocks = kvs + .remove("st_blocks") + .and_then(|x| x.parse::().ok()) + .ok_or(AfcError::MissingAttribute)?; + + let creation = kvs + .remove("st_birthtime") + .and_then(|x| x.parse::().ok()) + .ok_or(AfcError::MissingAttribute)?; + let creation = chrono::DateTime::from_timestamp_nanos(creation).naive_local(); + + let modified = kvs + .remove("st_mtime") + .and_then(|x| x.parse::().ok()) + .ok_or(AfcError::MissingAttribute)?; + let modified = chrono::DateTime::from_timestamp_nanos(modified).naive_local(); + + let st_nlink = kvs.remove("st_nlink").ok_or(AfcError::MissingAttribute)?; + let st_ifmt = kvs.remove("st_ifmt").ok_or(AfcError::MissingAttribute)?; + let st_link_target = kvs.remove("st_link_target"); + + if !kvs.is_empty() { + warn!("File info kvs not empty: {kvs:?}"); + } + + Ok(FileInfo { + size, + blocks, + creation, + modified, + st_nlink, + st_ifmt, + st_link_target, + }) + } + + /// Retrieves information about the device's filesystem + /// + /// # Returns + /// A `DeviceInfo` struct containing device filesystem information + pub async fn get_device_info(&mut self) -> Result { + let header_len = AfcPacketHeader::LEN; + + let header = AfcPacketHeader { + magic: MAGIC, + entire_len: header_len, // it's the same since the payload is empty for this + header_payload_len: header_len, + packet_num: self.package_number, + operation: AfcOpcode::GetDevInfo, + }; + self.package_number += 1; + + let packet = AfcPacket { + header, + header_payload: Vec::new(), + payload: Vec::new(), + }; + + self.send(packet).await?; + let res = self.read().await?; + + let strings: Vec = res + .payload + .split(|b| *b == 0) + .filter(|s| !s.is_empty()) + .map(|s| String::from_utf8_lossy(s).into_owned()) + .collect(); + + let mut kvs: HashMap = strings + .chunks_exact(2) + .map(|chunk| (chunk[0].clone(), chunk[1].clone())) + .collect(); + + let model = kvs.remove("Model").ok_or(AfcError::MissingAttribute)?; + let total_bytes = kvs + .remove("FSTotalBytes") + .and_then(|x| x.parse::().ok()) + .ok_or(AfcError::MissingAttribute)?; + let free_bytes = kvs + .remove("FSFreeBytes") + .and_then(|x| x.parse::().ok()) + .ok_or(AfcError::MissingAttribute)?; + let block_size = kvs + .remove("FSBlockSize") + .and_then(|x| x.parse::().ok()) + .ok_or(AfcError::MissingAttribute)?; + + if !kvs.is_empty() { + warn!("Device info kvs not empty: {kvs:?}"); + } + + Ok(DeviceInfo { + model, + total_bytes, + free_bytes, + block_size, + }) + } + + /// Removes a file or directory + /// + /// # Arguments + /// * `path` - Path to the file or directory to remove + pub async fn remove(&mut self, path: impl Into) -> Result<(), IdeviceError> { + let path = path.into(); + let header_payload = path.as_bytes().to_vec(); + let header_len = header_payload.len() as u64 + AfcPacketHeader::LEN; + + let header = AfcPacketHeader { + magic: MAGIC, + entire_len: header_len, // it's the same since the payload is empty for this + header_payload_len: header_len, + packet_num: self.package_number, + operation: AfcOpcode::RemovePath, + }; + self.package_number += 1; + + let packet = AfcPacket { + header, + header_payload, + payload: Vec::new(), + }; + + self.send(packet).await?; + self.read().await?; // read a response to check for errors + + Ok(()) + } + + /// Recursively removes a directory and all its contents + /// + /// # Arguments + /// * `path` - Path to the directory to remove + pub async fn remove_all(&mut self, path: impl Into) -> Result<(), IdeviceError> { + let path = path.into(); + let header_payload = path.as_bytes().to_vec(); + let header_len = header_payload.len() as u64 + AfcPacketHeader::LEN; + + let header = AfcPacketHeader { + magic: MAGIC, + entire_len: header_len, // it's the same since the payload is empty for this + header_payload_len: header_len, + packet_num: self.package_number, + operation: AfcOpcode::RemovePathAndContents, + }; + self.package_number += 1; + + let packet = AfcPacket { + header, + header_payload, + payload: Vec::new(), + }; + + self.send(packet).await?; + self.read().await?; // read a response to check for errors + + Ok(()) + } + + /// Opens a file on the device + /// + /// # Arguments + /// * `path` - Path to the file to open + /// * `mode` - Opening mode (read, write, etc.) + /// + /// # Returns + /// A `FileDescriptor` struct for the opened file + pub async fn open<'f>( + &'f mut self, + path: impl Into, + mode: AfcFopenMode, + ) -> Result, IdeviceError> { + let path = path.into(); + let mut header_payload = (mode as u64).to_le_bytes().to_vec(); + header_payload.extend(path.as_bytes()); + let header_len = header_payload.len() as u64 + AfcPacketHeader::LEN; + + let header = AfcPacketHeader { + magic: MAGIC, + entire_len: header_len, // it's the same since the payload is empty for this + header_payload_len: header_len, + packet_num: self.package_number, + operation: AfcOpcode::FileOpen, + }; + self.package_number += 1; + + let packet = AfcPacket { + header, + header_payload, + payload: Vec::new(), + }; + + self.send(packet).await?; + let res = self.read().await?; + if res.header_payload.len() < 8 { + warn!("Header payload fd is less than 8 bytes"); + return Err(IdeviceError::UnexpectedResponse( + "AFC FileOpen response header payload too short for fd".into(), + )); + } + let fd = u64::from_le_bytes(res.header_payload[..8].try_into().unwrap()); + + // we know it's a valid fd + Ok(unsafe { FileDescriptor::new(self, fd, path) }) + } + + /// Opens an owned file on the device + /// + /// # Arguments + /// * `path` - Path to the file to open + /// * `mode` - Opening mode (read, write, etc.) + /// + /// # Returns + /// A `OwnedFileDescriptor` struct for the opened file + pub async fn open_owned( + mut self, + path: impl Into, + mode: AfcFopenMode, + ) -> Result { + let path = path.into(); + let mut header_payload = (mode as u64).to_le_bytes().to_vec(); + header_payload.extend(path.as_bytes()); + let header_len = header_payload.len() as u64 + AfcPacketHeader::LEN; + + let header = AfcPacketHeader { + magic: MAGIC, + entire_len: header_len, // it's the same since the payload is empty for this + header_payload_len: header_len, + packet_num: self.package_number, + operation: AfcOpcode::FileOpen, + }; + self.package_number += 1; + + let packet = AfcPacket { + header, + header_payload, + payload: Vec::new(), + }; + + self.send(packet).await?; + let res = self.read().await?; + if res.header_payload.len() < 8 { + warn!("Header payload fd is less than 8 bytes"); + return Err(IdeviceError::UnexpectedResponse( + "AFC FileOpen response header payload too short for fd".into(), + )); + } + let fd = u64::from_le_bytes(res.header_payload[..8].try_into().unwrap()); + + // we know it's a valid fd + Ok(unsafe { OwnedFileDescriptor::new(self, fd, path) }) + } + + /// Creates a hard or symbolic link + /// + /// # Arguments + /// * `target` - Target path of the link + /// * `source` - Path where the link should be created + /// * `kind` - Type of link to create (hard or symbolic) + pub async fn link( + &mut self, + target: impl Into, + source: impl Into, + kind: opcode::LinkType, + ) -> Result<(), IdeviceError> { + let target = target.into(); + let source = source.into(); + + let mut header_payload = (kind as u64).to_le_bytes().to_vec(); + header_payload.extend(target.as_bytes()); + header_payload.push(0); + header_payload.extend(source.as_bytes()); + header_payload.push(0); + + let header_len = header_payload.len() as u64 + AfcPacketHeader::LEN; + + let header = AfcPacketHeader { + magic: MAGIC, + entire_len: header_len, + header_payload_len: header_len, + packet_num: self.package_number, + operation: AfcOpcode::MakeLink, + }; + self.package_number += 1; + + let packet = AfcPacket { + header, + header_payload, + payload: Vec::new(), + }; + + self.send(packet).await?; + self.read().await?; + + Ok(()) + } + + /// Renames a file or directory + /// + /// # Arguments + /// * `source` - Current path of the file/directory + /// * `target` - New path for the file/directory + pub async fn rename( + &mut self, + source: impl Into, + target: impl Into, + ) -> Result<(), IdeviceError> { + let target = target.into(); + let source = source.into(); + + let mut header_payload = source.as_bytes().to_vec(); + header_payload.push(0); + header_payload.extend(target.as_bytes()); + header_payload.push(0); + + let header_len = header_payload.len() as u64 + AfcPacketHeader::LEN; + + let header = AfcPacketHeader { + magic: MAGIC, + entire_len: header_len, + header_payload_len: header_len, + packet_num: self.package_number, + operation: AfcOpcode::RenamePath, + }; + self.package_number += 1; + + let packet = AfcPacket { + header, + header_payload, + payload: Vec::new(), + }; + + self.send(packet).await?; + self.read().await?; + + Ok(()) + } + + /// Reads a response packet from the device + /// + /// # Returns + /// The received `AfcPacket` + pub async fn read(&mut self) -> Result { + let res = AfcPacket::read(&mut self.idevice).await?; + if res.header.operation == AfcOpcode::Status { + if res.header_payload.len() < 8 { + tracing::error!("AFC returned error opcode, but not a code"); + return Err(IdeviceError::UnexpectedResponse( + "AFC error status response too short for error code".into(), + )); + } + let code = u64::from_le_bytes(res.header_payload[..8].try_into().unwrap()); + let e = AfcError::from(code); + if e == AfcError::Success { + return Ok(res); + } else { + return Err(IdeviceError::Afc(e)); + } + } + Ok(res) + } + + /// Sends a packet to the device + /// + /// # Arguments + /// * `packet` - The packet to send + pub async fn send(&mut self, packet: AfcPacket) -> Result<(), IdeviceError> { + let packet = packet.serialize(); + self.idevice.send_raw(&packet).await?; + Ok(()) + } +} + +#[cfg(feature = "rsd")] +impl crate::RsdService for AfcClient { + fn rsd_service_name() -> std::borrow::Cow<'static, str> { + crate::obf!("com.apple.afc.shim.remote") + } + async fn from_stream(stream: Box) -> Result { + let mut idevice = crate::Idevice::new(stream, ""); + idevice.rsd_checkin().await?; + Ok(Self::new(idevice)) + } +} diff --git a/src-tauri/vendor/idevice/src/services/afc/opcode.rs b/src-tauri/vendor/idevice/src/services/afc/opcode.rs new file mode 100644 index 0000000..22de86e --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/afc/opcode.rs @@ -0,0 +1,93 @@ +// Jackson Coxson + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[repr(u64)] +pub enum AfcOpcode { + Status = 0x00000001, + Data = 0x00000002, // Data + ReadDir = 0x00000003, // ReadDir + ReadFile = 0x00000004, // ReadFile + WriteFile = 0x00000005, // WriteFile + WritePart = 0x00000006, // WritePart + Truncate = 0x00000007, // TruncateFile + RemovePath = 0x00000008, // RemovePath + MakeDir = 0x00000009, // MakeDir + GetFileInfo = 0x0000000a, // GetFileInfo + GetDevInfo = 0x0000000b, // GetDeviceInfo + WriteFileAtom = 0x0000000c, // WriteFileAtomic (tmp file+rename) + FileOpen = 0x0000000d, // FileRefOpen + FileOpenRes = 0x0000000e, // FileRefOpenResult + Read = 0x0000000f, // FileRefRead + Write = 0x00000010, // FileRefWrite + FileSeek = 0x00000011, // FileRefSeek + FileTell = 0x00000012, // FileRefTell + FileTellRes = 0x00000013, // FileRefTellResult + FileClose = 0x00000014, // FileRefClose + FileSetSize = 0x00000015, // FileRefSetFileSize (ftruncate) + GetConInfo = 0x00000016, // GetConnectionInfo + SetConOptions = 0x00000017, // SetConnectionOptions + RenamePath = 0x00000018, // RenamePath + SetFsBs = 0x00000019, // SetFSBlockSize (0x800000) + SetSocketBs = 0x0000001A, // SetSocketBlockSize (0x800000) + FileLock = 0x0000001B, // FileRefLock + MakeLink = 0x0000001C, // MakeLink + SetFileTime = 0x0000001E, // Set st_mtime + RemovePathAndContents = 0x00000022, +} + +#[repr(u64)] +#[derive(Clone, Copy, Debug)] +pub enum AfcFopenMode { + RdOnly = 0x00000001, // r O_RDONLY + Rw = 0x00000002, // r+ O_RDWR | O_CREAT + WrOnly = 0x00000003, // w O_WRONLY | O_CREAT | O_TRUNC + Wr = 0x00000004, // w+ O_RDWR | O_CREAT | O_TRUNC + Append = 0x00000005, // a O_WRONLY | O_APPEND | O_CREAT + RdAppend = 0x00000006, // a+ O_RDWR | O_APPEND | O_CREAT +} + +#[repr(u64)] +#[derive(Clone, Copy, Debug)] +pub enum LinkType { + Hardlink = 0x00000001, + Symlink = 0x00000002, +} + +impl TryFrom for AfcOpcode { + type Error = (); + + fn try_from(value: u64) -> Result { + match value { + 0x00000001 => Ok(Self::Status), + 0x00000002 => Ok(Self::Data), + 0x00000003 => Ok(Self::ReadDir), + 0x00000004 => Ok(Self::ReadFile), + 0x00000005 => Ok(Self::WriteFile), + 0x00000006 => Ok(Self::WritePart), + 0x00000007 => Ok(Self::Truncate), + 0x00000008 => Ok(Self::RemovePath), + 0x00000009 => Ok(Self::MakeDir), + 0x0000000a => Ok(Self::GetFileInfo), + 0x0000000b => Ok(Self::GetDevInfo), + 0x0000000c => Ok(Self::WriteFileAtom), + 0x0000000d => Ok(Self::FileOpen), + 0x0000000e => Ok(Self::FileOpenRes), + 0x0000000f => Ok(Self::Read), + 0x00000010 => Ok(Self::Write), + 0x00000011 => Ok(Self::FileSeek), + 0x00000012 => Ok(Self::FileTell), + 0x00000013 => Ok(Self::FileTellRes), + 0x00000014 => Ok(Self::FileClose), + 0x00000015 => Ok(Self::FileSetSize), + 0x00000016 => Ok(Self::GetConInfo), + 0x00000017 => Ok(Self::SetConOptions), + 0x00000018 => Ok(Self::RenamePath), + 0x00000019 => Ok(Self::SetFsBs), + 0x0000001A => Ok(Self::SetSocketBs), + 0x0000001B => Ok(Self::FileLock), + 0x0000001C => Ok(Self::MakeLink), + 0x0000001E => Ok(Self::SetFileTime), + _ => Err(()), + } + } +} diff --git a/src-tauri/vendor/idevice/src/services/afc/packet.rs b/src-tauri/vendor/idevice/src/services/afc/packet.rs new file mode 100644 index 0000000..189030c --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/afc/packet.rs @@ -0,0 +1,99 @@ +// Jackson Coxson + +use tracing::debug; + +use super::errors::AfcError; +use crate::{Idevice, IdeviceError}; + +use super::opcode::AfcOpcode; + +#[derive(Clone, Copy, Debug)] +pub struct AfcPacketHeader { + pub magic: u64, + pub entire_len: u64, + pub header_payload_len: u64, + pub packet_num: u64, + pub operation: AfcOpcode, +} + +#[derive(Clone, Debug)] +pub struct AfcPacket { + pub header: AfcPacketHeader, + pub header_payload: Vec, + pub payload: Vec, +} + +impl AfcPacketHeader { + pub const LEN: u64 = 40; + + pub fn serialize(&self) -> Vec { + let mut res = Vec::with_capacity(Self::LEN as usize); + + res.extend_from_slice(&self.magic.to_le_bytes()); + res.extend_from_slice(&self.entire_len.to_le_bytes()); + res.extend_from_slice(&self.header_payload_len.to_le_bytes()); + res.extend_from_slice(&self.packet_num.to_le_bytes()); + res.extend_from_slice(&(self.operation as u64).to_le_bytes()); + + res + } + + pub async fn read(reader: &mut Idevice) -> Result { + let header_bytes = reader.read_raw(Self::LEN as usize).await?; + let mut chunks = header_bytes.chunks_exact(8); + let res = Self { + magic: u64::from_le_bytes(chunks.next().unwrap().try_into().unwrap()), + entire_len: u64::from_le_bytes(chunks.next().unwrap().try_into().unwrap()), + header_payload_len: u64::from_le_bytes(chunks.next().unwrap().try_into().unwrap()), + packet_num: u64::from_le_bytes(chunks.next().unwrap().try_into().unwrap()), + operation: match AfcOpcode::try_from(u64::from_le_bytes( + chunks.next().unwrap().try_into().unwrap(), + )) { + Ok(o) => o, + Err(_) => { + return Err(AfcError::UnknownOpcode.into()); + } + }, + }; + if res.magic != super::MAGIC { + return Err(AfcError::InvalidMagic.into()); + } + Ok(res) + } +} + +impl AfcPacket { + pub fn serialize(&self) -> Vec { + let mut res = Vec::new(); + + res.extend_from_slice(&self.header.serialize()); + res.extend_from_slice(&self.header_payload); + res.extend_from_slice(&self.payload); + + res + } + + pub async fn read(reader: &mut Idevice) -> Result { + let header = AfcPacketHeader::read(reader).await?; + debug!("afc header: {header:?}"); + let header_payload = reader + .read_raw((header.header_payload_len - AfcPacketHeader::LEN) as usize) + .await?; + + let payload = if header.header_payload_len == header.entire_len { + Vec::new() // no payload + } else { + reader + .read_raw((header.entire_len - header.header_payload_len) as usize) + .await? + }; + + let res = Self { + header, + header_payload, + payload, + }; + debug!("Recv afc: {res:?}"); + Ok(res) + } +} diff --git a/src-tauri/vendor/idevice/src/services/amfi.rs b/src-tauri/vendor/idevice/src/services/amfi.rs new file mode 100644 index 0000000..4cf657b --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/amfi.rs @@ -0,0 +1,150 @@ +//! Abstraction for Apple Mobile File Integrity + +use crate::{Idevice, IdeviceError, IdeviceService, obf}; + +/// Client for interacting with the AMFI service on the device +#[derive(Debug)] +pub struct AmfiClient { + /// The underlying device connection with established amfi service + pub idevice: Idevice, +} + +impl IdeviceService for AmfiClient { + /// Returns the amfi service name as registered with lockdownd + fn service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.amfi.lockdown") + } + + async fn from_stream(idevice: Idevice) -> Result { + Ok(Self::new(idevice)) + } +} + +impl AmfiClient { + /// Creates a new amfi client from an existing device connection + /// + /// # Arguments + /// * `idevice` - Pre-established device connection + pub fn new(idevice: Idevice) -> Self { + Self { idevice } + } + + /// Shows the developer mode option in settings in iOS 18+ + /// Settings -> Privacy & Security -> Developer Mode + pub async fn reveal_developer_mode_option_in_ui(&mut self) -> Result<(), IdeviceError> { + let request = crate::plist!({ + "action": 0, + }); + self.idevice.send_plist(request).await?; + + let res = self.idevice.read_plist().await?; + if res.get("success").is_some() { + Ok(()) + } else { + Err(IdeviceError::UnexpectedResponse( + "missing success key in reveal developer mode response".into(), + )) + } + } + + /// Enables developer mode, triggering a reboot on iOS 18+ + pub async fn enable_developer_mode(&mut self) -> Result<(), IdeviceError> { + let request = crate::plist!({ + "action": 1, + }); + self.idevice.send_plist(request).await?; + + let res = self.idevice.read_plist().await?; + if res.get("success").is_some() { + Ok(()) + } else { + Err(IdeviceError::UnexpectedResponse( + "missing success key in enable developer mode response".into(), + )) + } + } + + /// Shows the accept dialogue for enabling developer mode + pub async fn accept_developer_mode(&mut self) -> Result<(), IdeviceError> { + let request = crate::plist!({ + "action": 2, + }); + self.idevice.send_plist(request).await?; + + let res = self.idevice.read_plist().await?; + if res.get("success").is_some() { + Ok(()) + } else { + Err(IdeviceError::UnexpectedResponse( + "missing success key in accept developer mode response".into(), + )) + } + } + + /// Gets the developer mode status + pub async fn get_developer_mode_status(&mut self) -> Result { + let request = crate::plist!({ + "action": 3, + }); + self.idevice.send_plist(request).await?; + + let res = self.idevice.read_plist().await?; + match res.get("success").and_then(|x| x.as_boolean()) { + Some(true) => (), + _ => { + return Err(IdeviceError::UnexpectedResponse( + "missing or false success key in developer mode status response".into(), + )); + } + } + + match res.get("status").and_then(|x| x.as_boolean()) { + Some(b) => Ok(b), + _ => Err(IdeviceError::UnexpectedResponse( + "missing status boolean in developer mode status response".into(), + )), + } + } + + /// Trusts an app signer + pub async fn trust_app_signer( + &mut self, + uuid: impl Into, + ) -> Result { + let request = crate::plist!({ + "action": 4, + "input_profile_uuid": uuid.into(), + }); + + self.idevice.send_plist(request).await?; + + let res = self.idevice.read_plist().await?; + match res.get("success").and_then(|x| x.as_boolean()) { + Some(true) => (), + _ => { + return Err(IdeviceError::UnexpectedResponse( + "missing or false success key in trust app signer response".into(), + )); + } + } + + match res.get("status").and_then(|x| x.as_boolean()) { + Some(b) => Ok(b), + _ => Err(IdeviceError::UnexpectedResponse( + "missing status boolean in trust app signer response".into(), + )), + } + } +} + +#[cfg(feature = "rsd")] +impl crate::RsdService for AmfiClient { + fn rsd_service_name() -> std::borrow::Cow<'static, str> { + crate::obf!("com.apple.amfi.lockdown.shim.remote") + } + async fn from_stream(stream: Box) -> Result { + let mut idevice = crate::Idevice::new(stream, ""); + idevice.rsd_checkin().await?; + Ok(Self::new(idevice)) + } +} diff --git a/src-tauri/vendor/idevice/src/services/bt_packet_logger.rs b/src-tauri/vendor/idevice/src/services/bt_packet_logger.rs new file mode 100644 index 0000000..c081df6 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/bt_packet_logger.rs @@ -0,0 +1,203 @@ +//! Abstraction for BTPacketLogger +//! You must have the Bluetooth profile installed, or you'll get no data. +//! https://developer.apple.com/bug-reporting/profiles-and-logs/?name=bluetooth + +use std::pin::Pin; + +use futures::Stream; +use tracing::warn; + +use crate::{Idevice, IdeviceError, IdeviceService, obf}; + +/// Client for interacting with the BTPacketLogger service on the device. +/// You must have the Bluetooth profile installed, or you'll get no data. +/// +/// ``https://developer.apple.com/bug-reporting/profiles-and-logs/?name=bluetooth`` +#[derive(Debug)] +pub struct BtPacketLoggerClient { + /// The underlying device connection with established logger service + pub idevice: Idevice, +} + +#[derive(Debug, Clone)] +pub struct BtFrame { + pub hdr: BtHeader, + pub kind: BtPacketKind, + /// H4-ready payload (first byte is H4 type: 0x01 cmd, 0x02 ACL, 0x03 SCO, 0x04 evt) + pub h4: Vec, +} + +#[derive(Debug, Clone, Copy)] +pub struct BtHeader { + /// Advisory length for [kind + payload]; may not equal actual frame len - 12 + pub length: u32, // BE on the wire + pub ts_secs: u32, // BE + pub ts_usecs: u32, // BE +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BtPacketKind { + HciCmd, // 0x00 + HciEvt, // 0x01 + AclSent, // 0x02 + AclRecv, // 0x03 + ScoSent, // 0x08 + ScoRecv, // 0x09 + Other(u8), +} + +impl BtPacketKind { + fn from_byte(b: u8) -> Self { + match b { + 0x00 => BtPacketKind::HciCmd, + 0x01 => BtPacketKind::HciEvt, + 0x02 => BtPacketKind::AclSent, + 0x03 => BtPacketKind::AclRecv, + 0x08 => BtPacketKind::ScoSent, + 0x09 => BtPacketKind::ScoRecv, + x => BtPacketKind::Other(x), + } + } + fn h4_type(self) -> Option { + match self { + BtPacketKind::HciCmd => Some(0x01), + BtPacketKind::AclSent | BtPacketKind::AclRecv => Some(0x02), + BtPacketKind::ScoSent | BtPacketKind::ScoRecv => Some(0x03), + BtPacketKind::HciEvt => Some(0x04), + BtPacketKind::Other(_) => None, + } + } +} + +impl IdeviceService for BtPacketLoggerClient { + fn service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.bluetooth.BTPacketLogger") + } + + async fn from_stream(idevice: Idevice) -> Result { + Ok(Self::new(idevice)) + } +} + +impl BtPacketLoggerClient { + pub fn new(idevice: Idevice) -> Self { + Self { idevice } + } + + /// Read a single *outer* frame and return one parsed record from it. + /// (This service typically delivers one record per frame.) + pub async fn next_packet( + &mut self, + ) -> Result)>, IdeviceError> { + // 2-byte outer length is **little-endian** + let len = self.idevice.read_raw(2).await?; + if len.len() != 2 { + return Ok(None); // EOF + } + let frame_len = u16::from_le_bytes([len[0], len[1]]) as usize; + + if !(13..=64 * 1024).contains(&frame_len) { + return Err(IdeviceError::UnexpectedResponse( + "BT frame length out of valid range (13..=65536)".into(), + )); + } + + let frame = self.idevice.read_raw(frame_len).await?; + if frame.len() != frame_len { + return Err(IdeviceError::NotEnoughBytes(frame.len(), frame_len)); + } + + // Parse header at fixed offsets (BE u32s) + let (hdr, off) = BtHeader::parse(&frame).ok_or(IdeviceError::UnexpectedResponse( + "failed to parse BT packet header".into(), + ))?; + // packet_type at byte 12, payload starts at 13 + let kind = BtPacketKind::from_byte(frame[off]); + let payload = &frame[off + 1..]; // whatever remains + + // Build H4 buffer (prepend type byte) + let mut h4 = Vec::with_capacity(1 + payload.len()); + if let Some(t) = kind.h4_type() { + h4.push(t); + } else { + return Ok(None); + } + h4.extend_from_slice(payload); + + Ok(Some((hdr, kind, h4))) + } + + /// Continuous stream of parsed frames. + pub fn into_stream( + mut self, + ) -> Pin> + Send>> { + Box::pin(async_stream::try_stream! { + loop { + // outer length (LE) + let len = self.idevice.read_raw(2).await?; + if len.len() != 2 { break; } + let frame_len = u16::from_le_bytes([len[0], len[1]]) as usize; + if !(13..=64 * 1024).contains(&frame_len) { + warn!("invalid frame_len {}", frame_len); + continue; + } + + // frame bytes + let frame = self.idevice.read_raw(frame_len).await?; + if frame.len() != frame_len { + Err(IdeviceError::NotEnoughBytes(frame.len(), frame_len))?; + } + + // header + kind + payload + let (hdr, off) = BtHeader::parse(&frame).ok_or(IdeviceError::UnexpectedResponse("failed to parse BT packet header in stream".into()))?; + let kind = BtPacketKind::from_byte(frame[off]); + let payload = &frame[off + 1..]; + + // make H4 buffer + let mut h4 = Vec::with_capacity(1 + payload.len()); + if let Some(t) = kind.h4_type() { + h4.push(t); + } else { + // unknown kind + continue; + } + h4.extend_from_slice(payload); + + yield BtFrame { hdr, kind, h4 }; + } + }) + } +} + +impl BtHeader { + /// Parse 12-byte header at the start of a frame. + /// Returns (header, next_offset) where next_offset == 12 (start of packet_type). + fn parse(buf: &[u8]) -> Option<(Self, usize)> { + if buf.len() < 12 { + return None; + } + let length = u32::from_be_bytes(buf[0..4].try_into().ok()?); + let ts_secs = u32::from_be_bytes(buf[4..8].try_into().ok()?); + let ts_usecs = u32::from_be_bytes(buf[8..12].try_into().ok()?); + Some(( + BtHeader { + length, + ts_secs, + ts_usecs, + }, + 12, + )) + } +} + +#[cfg(feature = "rsd")] +impl crate::RsdService for BtPacketLoggerClient { + fn rsd_service_name() -> std::borrow::Cow<'static, str> { + crate::obf!("com.apple.bluetooth.BTPacketLogger.shim.remote") + } + async fn from_stream(stream: Box) -> Result { + let mut idevice = crate::Idevice::new(stream, ""); + idevice.rsd_checkin().await?; + Ok(Self::new(idevice)) + } +} diff --git a/src-tauri/vendor/idevice/src/services/companion_proxy.rs b/src-tauri/vendor/idevice/src/services/companion_proxy.rs new file mode 100644 index 0000000..1d977c5 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/companion_proxy.rs @@ -0,0 +1,158 @@ +//! Companion Proxy is Apple's bridge to connect to the Apple Watch + +use tracing::warn; + +use crate::{Idevice, IdeviceError, IdeviceService, RsdService, obf}; + +#[derive(Debug)] +pub struct CompanionProxy { + idevice: Idevice, +} + +#[derive(Debug)] +pub struct CompanionProxyStream { + proxy: CompanionProxy, +} + +impl IdeviceService for CompanionProxy { + fn service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.companion_proxy") + } + + async fn from_stream(idevice: Idevice) -> Result { + Ok(Self::new(idevice)) + } +} + +impl RsdService for CompanionProxy { + fn rsd_service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.companion_proxy.shim.remote") + } + + async fn from_stream(stream: Box) -> Result { + let mut idevice = Idevice::new(stream, ""); + idevice.rsd_checkin().await?; + Ok(Self::new(idevice)) + } +} + +impl CompanionProxy { + pub fn new(idevice: Idevice) -> Self { + Self { idevice } + } + + pub async fn get_device_registry(&mut self) -> Result, IdeviceError> { + let command = crate::plist!({ + "Command": "GetDeviceRegistry" + }); + + self.idevice.send_plist(command).await?; + let res = self.idevice.read_plist().await?; + let list = match res.get("PairedDevicesArray").and_then(|x| x.as_array()) { + Some(l) => l, + None => { + warn!("Didn't get PairedDevicesArray array"); + return Err(IdeviceError::UnexpectedResponse( + "missing PairedDevicesArray in device registry response".into(), + )); + } + }; + + let mut res = Vec::new(); + for l in list { + if let plist::Value::String(l) = l { + res.push(l.to_owned()); + } + } + + Ok(res) + } + + pub async fn listen_for_devices(mut self) -> Result { + let command = crate::plist!({ + "Command": "StartListeningForDevices" + }); + self.idevice.send_plist(command).await?; + + Ok(CompanionProxyStream { proxy: self }) + } + + pub async fn get_value( + &mut self, + udid: impl Into, + key: impl Into, + ) -> Result { + let udid = udid.into(); + let key = key.into(); + let command = crate::plist!({ + "Command": "GetValueFromRegistry", + "GetValueGizmoUDIDKey": udid, + "GetValueKeyKey": key.clone() + }); + self.idevice.send_plist(command).await?; + let mut res = self.idevice.read_plist().await?; + if let Some(v) = res + .remove("RetrievedValueDictionary") + .and_then(|x| x.into_dictionary()) + .and_then(|mut x| x.remove(&key)) + { + Ok(v) + } else { + Err(IdeviceError::NotFound) + } + } + + pub async fn start_forwarding_service_port( + &mut self, + port: u16, + service_name: Option<&str>, + options: Option, + ) -> Result { + let command = crate::plist!({ + "Command": "StartForwardingServicePort", + "GizmoRemotePortNumber": port, + "IsServiceLowPriority": false, + "PreferWifi": false, + "ForwardedServiceName":? service_name, + : Result<(), IdeviceError> { + let command = crate::plist!({ + "Command": "StopForwardingServicePort", + "GizmoRemotePortNumber": port + }); + + self.idevice.send_plist(command).await?; + let res = self.idevice.read_plist().await?; + if let Some(c) = res.get("Command").and_then(|x| x.as_string()) + && (c == "ComandSuccess" || c == "CommandSuccess") + // Apple you spelled this wrong, adding the right spelling just in case you fix it smh + { + Ok(()) + } else { + Err(IdeviceError::UnexpectedResponse( + "missing or invalid Command in stop forwarding response".into(), + )) + } + } +} + +impl CompanionProxyStream { + pub async fn next(&mut self) -> Result { + self.proxy.idevice.read_plist().await + } +} diff --git a/src-tauri/vendor/idevice/src/services/core_device/app_service.rs b/src-tauri/vendor/idevice/src/services/core_device/app_service.rs new file mode 100644 index 0000000..79df672 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/core_device/app_service.rs @@ -0,0 +1,393 @@ +// Jackson Coxson + +use plist_macro::plist_to_xml_bytes; +use serde::Deserialize; +use tracing::warn; + +use crate::{IdeviceError, ReadWrite, RsdService, obf, xpc::XPCObject}; + +use super::CoreDeviceServiceClient; + +impl RsdService for AppServiceClient> { + fn rsd_service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.coredevice.appservice") + } + + async fn from_stream(stream: Box) -> Result { + Ok(Self { + inner: CoreDeviceServiceClient::new(stream).await?, + }) + } +} + +#[derive(Debug)] +pub struct AppServiceClient { + inner: CoreDeviceServiceClient, +} + +#[derive(Deserialize, Clone, Debug)] +pub struct AppListEntry { + #[serde(rename = "isRemovable")] + pub is_removable: bool, + pub name: String, + #[serde(rename = "isFirstParty")] + pub is_first_party: bool, + pub path: String, + #[serde(rename = "bundleIdentifier")] + pub bundle_identifier: String, + #[serde(rename = "isDeveloperApp")] + pub is_developer_app: bool, + #[serde(rename = "bundleVersion")] + pub bundle_version: Option, + #[serde(rename = "isInternal")] + pub is_internal: bool, + #[serde(rename = "isHidden")] + pub is_hidden: bool, + #[serde(rename = "isAppClip")] + pub is_app_clip: bool, + pub version: Option, +} + +#[derive(Deserialize, Clone, Debug)] +pub struct LaunchResponse { + #[serde(rename = "processIdentifierVersion")] + pub process_identifier_version: u32, + #[serde(rename = "processIdentifier")] + pub pid: u32, + #[serde(rename = "executableURL")] + pub executable_url: ExecutableUrl, + #[serde(rename = "auditToken")] + pub audit_token: Vec, +} + +#[derive(Deserialize, Clone, Debug)] +pub struct ExecutableUrl { + pub relative: String, +} + +#[derive(Deserialize, Clone, Debug)] +pub struct ProcessToken { + #[serde(rename = "processIdentifier")] + pub pid: u32, + #[serde(rename = "executableURL")] + pub executable_url: Option, +} + +#[derive(Deserialize, Clone, Debug)] +pub struct SignalResponse { + pub process: ProcessToken, + #[serde(rename = "deviceTimestamp")] + pub device_timestamp: plist::Date, + pub signal: u32, +} + +/// Icon data is in a proprietary format. +/// +/// ``` +/// 0000: 06 00 00 00 40 06 00 00 00 00 00 00 01 00 00 00 - header +/// 0010: 00 00 a0 41 00 00 a0 41 00 00 00 00 00 00 00 00 - width x height as float +/// 0020: 00 00 a0 41 00 00 a0 41 00 00 00 00 00 00 00 00 - wdith x height (again?) +/// 0030: 00 00 00 00 03 08 08 09 2a 68 6f 7d 44 a9 b7 d0 - start of image data +/// +/// ``` +/// +/// The data can be parsed like so in Python +/// +/// ```python +/// from PIL import Image +/// +/// width, height = 20, 20 (from the float sizes) +/// with open("icon.raw", "rb") as f: +/// f.seek(0x30) +/// raw = f.read(width * height * 4) +/// +/// img = Image.frombytes("RGBA", (width, height), raw) +/// img.save("icon.png") +/// ``` +#[derive(Deserialize, Clone, Debug)] +pub struct IconData { + pub data: plist::Data, + #[serde(rename = "iconSize.height")] + pub icon_height: f64, + #[serde(rename = "iconSize.width")] + pub icon_width: f64, + #[serde(rename = "minimumSize.height")] + pub minimum_height: f64, + #[serde(rename = "minimumSize.width")] + pub minimum_width: f64, + #[serde(rename = "$classes")] + pub classes: Vec, + #[serde(rename = "validationToken")] + pub validation_token: plist::Data, + pub uuid: IconUuid, +} + +#[derive(Deserialize, Clone, Debug)] +pub struct IconUuid { + #[serde(rename = "NS.uuidbytes")] + pub bytes: plist::Data, + #[serde(rename = "$classes")] + pub classes: Vec, +} + +impl AppServiceClient { + pub async fn new(stream: R) -> Result { + Ok(Self { + inner: CoreDeviceServiceClient::new(stream).await?, + }) + } + + pub async fn list_apps( + &mut self, + app_clips: bool, + removable_apps: bool, + hidden_apps: bool, + internal_apps: bool, + default_apps: bool, + ) -> Result, IdeviceError> { + let options = crate::plist!(dict { + "includeAppClips": app_clips, + "includeRemovableApps": removable_apps, + "includeHiddenApps": hidden_apps, + "includeInternalApps": internal_apps, + "includeDefaultApps": default_apps, + }); + let res = self + .inner + .invoke_with_plist("com.apple.coredevice.feature.listapps", options) + .await?; + + let res = match res.as_array() { + Some(a) => a, + None => { + warn!("CoreDevice result was not an array"); + return Err(IdeviceError::UnexpectedResponse( + "list apps result was not an array".into(), + )); + } + }; + + let mut desd = Vec::new(); + for r in res { + let r: AppListEntry = match plist::from_value(r) { + Ok(r) => r, + Err(e) => { + warn!("Failed to parse app entry: {e:?}"); + return Err(IdeviceError::UnexpectedResponse( + "failed to parse app list entry".into(), + )); + } + }; + desd.push(r); + } + + Ok(desd) + } + + /// Launches an application by a bundle ID. + /// + /// # Notes + /// * `start_suspended` - If set to true, you will need to attach a debugger using + /// `DebugServer` to continue. + /// + /// * `stdio_uuid` - Create a new ``OpenStdioSocketClient``, read the UUID, and pass it to this + /// function. Note that if the process already has another stdio UUID, this parameter is ignored by + /// iOS. Either make sure the proccess isn't running, or pass ``kill_existing: true`` + #[allow(clippy::too_many_arguments)] // still didn't ask + pub async fn launch_application( + &mut self, + bundle_id: impl Into, + arguments: &[&str], + kill_existing: bool, + start_suspended: bool, + environment: Option, + platform_options: Option, + stdio_uuid: Option, + ) -> Result { + let bundle_id = bundle_id.into(); + + let req = crate::plist!({ + "applicationSpecifier": { + "bundleIdentifier": { + "_0": bundle_id + } + }, + "options": { + "arguments": arguments, + "environmentVariables": environment.unwrap_or_default(), + "standardIOUsesPseudoterminals": true, + "startStopped": start_suspended, + "terminateExisting": kill_existing, + "user": { + "active": true, + }, + "platformSpecificOptions": plist::Value::Data(plist_to_xml_bytes(&platform_options.unwrap_or_default())), + }, + }); + + let req: XPCObject = req.into(); + let mut req = req.to_dictionary().unwrap(); + req.insert( + "standardIOIdentifiers".into(), + match stdio_uuid { + Some(u) => { + let u = XPCObject::Uuid(u); + let mut d = crate::xpc::Dictionary::new(); + d.insert("standardInput".into(), u.clone()); + d.insert("standardOutput".into(), u.clone()); + d.insert("standardError".into(), u.clone()); + d.into() + } + None => crate::xpc::Dictionary::new().into(), + }, + ); + + let res = self + .inner + .invoke("com.apple.coredevice.feature.launchapplication", Some(req)) + .await?; + + let res = match res + .as_dictionary() + .and_then(|r| r.get("processToken")) + .and_then(|x| plist::from_value(x).ok()) + { + Some(r) => r, + None => { + warn!("CoreDevice res did not contain parsable processToken"); + return Err(IdeviceError::UnexpectedResponse( + "missing or unparsable processToken in launch response".into(), + )); + } + }; + + Ok(res) + } + + pub async fn list_processes(&mut self) -> Result, IdeviceError> { + let res = self + .inner + .invoke("com.apple.coredevice.feature.listprocesses", None) + .await?; + + let res = match res + .as_dictionary() + .and_then(|x| x.get("processTokens")) + .and_then(|x| plist::from_value(x).ok()) + { + Some(r) => r, + None => { + warn!("CoreDevice res did not contain parsable processToken"); + return Err(IdeviceError::UnexpectedResponse( + "missing or unparsable processTokens in list processes response".into(), + )); + } + }; + + Ok(res) + } + + /// Gives no response on failure or success + pub async fn uninstall_app( + &mut self, + bundle_id: impl Into, + ) -> Result<(), IdeviceError> { + let bundle_id = bundle_id.into(); + self.inner + .invoke_with_plist( + "com.apple.coredevice.feature.uninstallapp", + crate::plist!({"bundleIdentifier": bundle_id}) + .into_dictionary() + .unwrap(), + ) + .await?; + + Ok(()) + } + + pub async fn send_signal( + &mut self, + pid: u32, + signal: u32, + ) -> Result { + let res = self + .inner + .invoke_with_plist( + "com.apple.coredevice.feature.sendsignaltoprocess", + crate::plist!({ + "process": { "processIdentifier": pid as i64}, + "signal": signal as i64, + }) + .into_dictionary() + .unwrap(), + ) + .await?; + + let res = match plist::from_value(&res) { + Ok(r) => r, + Err(e) => { + warn!("Could not parse signal response: {e:?}"); + return Err(IdeviceError::UnexpectedResponse( + "failed to parse signal response".into(), + )); + } + }; + + Ok(res) + } + + #[cfg(feature = "dvt")] + pub async fn fetch_app_icon( + &mut self, + bundle_id: impl Into, + width: f32, + height: f32, + scale: f32, + allow_placeholder: bool, + ) -> Result { + let bundle_id = bundle_id.into(); + let res = self + .inner + .invoke_with_plist( + "com.apple.coredevice.feature.fetchappicons", + crate::plist!({ + "width": width, + "height": height, + "scale": scale, + "allowPlaceholder": allow_placeholder, + "bundleIdentifier": bundle_id + }) + .into_dictionary() + .unwrap(), + ) + .await?; + + let res = match res + .as_dictionary() + .and_then(|x| x.get("appIconContainer")) + .and_then(|x| x.as_dictionary()) + .and_then(|x| x.get("iconImage")) + .and_then(|x| x.as_data()) + { + Some(r) => r.to_vec(), + None => { + warn!("Did not receive appIconContainer/iconImage data"); + return Err(IdeviceError::UnexpectedResponse( + "missing appIconContainer/iconImage data in fetch icon response".into(), + )); + } + }; + + let res = ns_keyed_archive::decode::from_bytes(&res) + .map_err(crate::services::dvt::errors::DvtError::from)?; + match plist::from_value(&res) { + Ok(r) => Ok(r), + Err(e) => { + warn!("Failed to deserialize ns keyed archive: {e:?}"); + Err(IdeviceError::UnexpectedResponse( + "failed to deserialize icon NSKeyedArchive".into(), + )) + } + } + } +} diff --git a/src-tauri/vendor/idevice/src/services/core_device/diagnosticsservice.rs b/src-tauri/vendor/idevice/src/services/core_device/diagnosticsservice.rs new file mode 100644 index 0000000..5f075cc --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/core_device/diagnosticsservice.rs @@ -0,0 +1,85 @@ +// Jackson Coxson + +use std::pin::Pin; + +use futures::Stream; +use tracing::warn; + +use crate::{IdeviceError, ReadWrite, RsdService, obf}; + +impl RsdService for DiagnostisServiceClient> { + fn rsd_service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.coredevice.diagnosticsservice") + } + + async fn from_stream(stream: Box) -> Result { + Ok(Self { + inner: super::CoreDeviceServiceClient::new(stream).await?, + }) + } +} + +#[derive(Debug)] +pub struct DiagnostisServiceClient { + inner: super::CoreDeviceServiceClient, +} + +pub struct SysdiagnoseResponse<'a> { + pub preferred_filename: String, + pub stream: Pin, IdeviceError>> + 'a>>, + pub expected_length: usize, +} + +impl DiagnostisServiceClient { + pub async fn capture_sysdiagnose<'a>( + &'a mut self, + dry_run: bool, + ) -> Result, IdeviceError> { + let req = crate::plist!({ + "options": { + "collectFullLogs": true + }, + "isDryRun": dry_run + }) + .into_dictionary() + .unwrap(); + + let res = self + .inner + .invoke_with_plist("com.apple.coredevice.feature.capturesysdiagnose", req) + .await?; + + if let Some(len) = res + .as_dictionary() + .and_then(|x| x.get("fileTransfer")) + .and_then(|x| x.as_dictionary()) + .and_then(|x| x.get("expectedLength")) + .and_then(|x| x.as_unsigned_integer()) + && let Some(name) = res + .as_dictionary() + .and_then(|x| x.get("preferredFilename")) + .and_then(|x| x.as_string()) + { + Ok(SysdiagnoseResponse { + stream: Box::pin(self.inner.inner.iter_file_chunks(len as usize, 0)), + preferred_filename: name.to_string(), + expected_length: len as usize, + }) + } else { + warn!("Did not get expected responses from RemoteXPC"); + Err(IdeviceError::UnexpectedResponse( + "missing fileTransfer/expectedLength or preferredFilename in sysdiagnose response" + .into(), + )) + } + } +} + +impl std::fmt::Debug for SysdiagnoseResponse<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SysdiagnoseResponse") + .field("preferred_filename", &self.preferred_filename) + .field("expected_length", &self.expected_length) + .finish() + } +} diff --git a/src-tauri/vendor/idevice/src/services/core_device/display_stream/client.rs b/src-tauri/vendor/idevice/src/services/core_device/display_stream/client.rs new file mode 100644 index 0000000..4eb6f4c --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/core_device/display_stream/client.rs @@ -0,0 +1,236 @@ +//! com.apple.coredevice.displayservice - the service Xcode's DeviceHub uses to +//! stream a device's display. +//! +//! Control plane (support info / server status / start / stop) is plain +//! RemoteXPC. The media is negotiated as an AVConference session: we send a +//! `negotiatorOffer`, the device answers, and then streams +//! plaintext RTP/HEVC to the receiver address we provide. + +use crate::{ + IdeviceError, ReadWrite, RsdService, obf, + xpc::{Dictionary as XpcDictionary, XPCObject}, +}; + +impl RsdService for DisplayServiceClient> { + fn rsd_service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.coredevice.displayservice") + } + + async fn from_stream(stream: Box) -> Result { + Ok(Self { + inner: super::super::CoreDeviceServiceClient::new(stream).await?, + }) + } +} + +#[derive(Debug)] +pub struct DisplayServiceClient { + inner: super::super::CoreDeviceServiceClient, +} + +impl DisplayServiceClient { + pub fn new(inner: super::super::CoreDeviceServiceClient) -> Self { + Self { inner } + } + + /// Query what media-stream features the device supports (codecs, screen + /// sharing, system audio, display info, screenshot capture, etc). + pub async fn get_media_support_info(&mut self) -> Result { + self.inner + .invoke_with_plist( + obf!("com.apple.coredevice.feature.getmediasupportinfo"), + plist::Dictionary::new(), + ) + .await + } + + /// Query the current media-stream server status on the device. When a + /// session is active this returns the full negotiated `streamConfig`. + pub async fn get_media_stream_server_status(&mut self) -> Result { + self.inner + .invoke_with_plist( + obf!("com.apple.coredevice.feature.getmediastreamserverstatus"), + plist::Dictionary::new(), + ) + .await + } + + /// Stop the active media stream. + pub async fn stop_media_stream(&mut self) -> Result { + let mut input = plist::Dictionary::new(); + input.insert("stopAll".into(), plist::Value::Boolean(true)); + self.inner + .invoke_with_plist_action( + obf!("com.apple.coredevice.feature.stopmediastream"), + input, + obf!("com.apple.coredevice.action.mediastreamstop"), + ) + .await + } + + /// Invoke `startvideooutput` with a built `MediaStreamStartParameters` XPC + /// dictionary (see [`build_start_video_parameters`]). + pub async fn start_video_output( + &mut self, + params: XpcDictionary, + ) -> Result { + self.inner + .invoke( + obf!("com.apple.coredevice.feature.startvideooutput"), + Some(params), + ) + .await + } + + /// Invoke `startmediastream` with a built `MediaStreamStartParameters` XPC + /// dictionary. This is the feature DeviceHub actually uses for video. + pub async fn start_media_stream( + &mut self, + params: XpcDictionary, + ) -> Result { + self.inner + .invoke( + obf!("com.apple.coredevice.feature.startmediastream"), + Some(params), + ) + .await + } +} + +fn codable_int(v: i64) -> XPCObject { + let mut d = XpcDictionary::new(); + d.insert("int".into(), XPCObject::Int64(v)); + XPCObject::Dictionary(d) +} + +fn codable_string(v: &str) -> XPCObject { + let mut d = XpcDictionary::new(); + d.insert("string".into(), XPCObject::String(v.into())); + XPCObject::Dictionary(d) +} + +fn codable_uuid(v: uuid::Uuid) -> XPCObject { + let mut d = XpcDictionary::new(); + d.insert("uuid".into(), XPCObject::Uuid(v)); + XPCObject::Dictionary(d) +} + +/// Build the `MediaStreamStartParameters` XPC dictionary for `startmediastream` +/// with `type = video`. +/// +/// `receiver_ip`/`receiver_port` are where the device sends RTP. +/// `negotiator_offer` is the zlib+protobuf blob +/// from [`super::negotiation::MediaNegotiationBlob::to_negotiator_offer`]. +/// `display_id` selects which device display to mirror (1 = primary?). +#[allow(clippy::too_many_arguments)] +pub fn build_start_video_parameters( + receiver_ip: &str, + receiver_port: u16, + sender_ip: &str, + sender_port: u16, + negotiator_offer: Vec, + client_supported_features: u64, + display_id: i64, + client_session_id: uuid::Uuid, +) -> XpcDictionary { + build_start_parameters( + "video", + receiver_ip, + receiver_port, + sender_ip, + sender_port, + negotiator_offer, + client_supported_features, + client_session_id, + Some(display_id), + ) +} + +/// Build the `MediaStreamStartParameters` for the audio stream. Device Hub +/// starts this before the video stream to establish the screen-sharing session +/// (the video stream otherwise fails because the device negotiator finds no local +/// screen video rules). Use the same `client_session_id` as the video stream. +#[allow(clippy::too_many_arguments)] +pub fn build_start_audio_parameters( + receiver_ip: &str, + receiver_port: u16, + sender_ip: &str, + sender_port: u16, + negotiator_offer: Vec, + client_supported_features: u64, + client_session_id: uuid::Uuid, +) -> XpcDictionary { + build_start_parameters( + "audio", + receiver_ip, + receiver_port, + sender_ip, + sender_port, + negotiator_offer, + client_supported_features, + client_session_id, + None, + ) +} + +/// Shared `MediaStreamStartParameters` builder. `display_id` is `Some` only for +/// video streams (adds the `CoreDeviceVideoDisplayMode`/`VideoStreamForDisplayID` +/// options). +/// +/// Option keys/values mirror exactly what Device Hub sends (captured from the +/// device's own logs). The callID is taken from the offer, and the clientName +/// ("CoreDeviceScreenSharing") is supplied by the device's mode-6 settings. +#[allow(clippy::too_many_arguments)] +fn build_start_parameters( + stream_type: &str, + receiver_ip: &str, + receiver_port: u16, + sender_ip: &str, + sender_port: u16, + negotiator_offer: Vec, + client_supported_features: u64, + client_session_id: uuid::Uuid, + display_id: Option, +) -> XpcDictionary { + let mut options = XpcDictionary::new(); + // Required for every CoreDevice media stream: + options.insert( + "AVCMediaStreamNegotiatorTransportProtocolType".into(), + codable_int(2), + ); + options.insert( + "AVCMediaStreamNegotiatorAccessNetworkType".into(), + codable_int(1), + ); + options.insert( + "avcMediaStreamOptionClientSessionID".into(), + codable_uuid(client_session_id), + ); + // Video-specific: which display to mirror. + if let Some(display_id) = display_id { + options.insert( + "CoreDeviceVideoDisplayMode".into(), + codable_string("DisplayByID"), + ); + options.insert("VideoStreamForDisplayID".into(), codable_int(display_id)); + } + + let mut params = XpcDictionary::new(); + params.insert("receiverIP".into(), XPCObject::String(receiver_ip.into())); + params.insert( + "receiverPort".into(), + XPCObject::UInt64(receiver_port as u64), + ); + params.insert("senderIP".into(), XPCObject::String(sender_ip.into())); + params.insert("senderPort".into(), XPCObject::UInt64(sender_port as u64)); + params.insert("timeout".into(), XPCObject::UInt64(3600)); + params.insert("type".into(), XPCObject::String(stream_type.into())); + params.insert("direction".into(), XPCObject::String("output".into())); + params.insert("negotiatorOffer".into(), XPCObject::Data(negotiator_offer)); + params.insert( + "clientSupportedFeatures".into(), + XPCObject::UInt64(client_supported_features), + ); + params.insert("options".into(), XPCObject::Dictionary(options)); + params +} diff --git a/src-tauri/vendor/idevice/src/services/core_device/display_stream/hevc.rs b/src-tauri/vendor/idevice/src/services/core_device/display_stream/hevc.rs new file mode 100644 index 0000000..f0e4dc2 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/core_device/display_stream/hevc.rs @@ -0,0 +1,270 @@ +// Jackson Coxson +// +// HEVC RTP depacketization (RFC 7798) for the CoreDevice display stream. +// +// The device sends HEVC (H.265) over plaintext RTP, dynamic payload type 100. +// +// We reorder packets by RTP sequence number (the transport is UDP, so they can +// arrive out of order), reassemble NAL units, and emit them in Annex-B framing + +const HEVC_NAL_HEADER_LEN: usize = 2; + +const NAL_TYPE_AP: u8 = 48; +const NAL_TYPE_FU: u8 = 49; + +// Parameter-set NAL unit types. +const NAL_TYPE_VPS: u8 = 32; +const NAL_TYPE_SPS: u8 = 33; +const NAL_TYPE_PPS: u8 = 34; + +const NAL_TYPE_IRAP_LOW: u8 = 16; +const NAL_TYPE_IRAP_HIGH: u8 = 23; + +/// Highest VCL NAL unit type (coded slice segments occupy 0..=31). +const NAL_TYPE_VCL_HIGH: u8 = 31; + +const AUD_NAL: [u8; 3] = [0x46, 0x01, 0x50]; +const ANNEXB_START_CODE: [u8; 4] = [0x00, 0x00, 0x00, 0x01]; +const MAX_REORDER_BUFFER: usize = 128; + +#[inline] +fn nal_type(nal_header_byte0: u8) -> u8 { + (nal_header_byte0 >> 1) & 0x3f +} + +#[inline] +fn is_irap(t: u8) -> bool { + (NAL_TYPE_IRAP_LOW..=NAL_TYPE_IRAP_HIGH).contains(&t) +} + +#[inline] +fn is_vcl(t: u8) -> bool { + t <= NAL_TYPE_VCL_HIGH +} + +/// Reassembles HEVC NAL units from RTP payloads and emits an Annex-B stream. +#[derive(Debug, Default)] +pub struct HevcDepacketizer { + reorder: std::collections::BTreeMap)>, + next_seq: Option, + last_ts: Option, + + fu_buffer: Vec, + fu_active: bool, + + vps: Option>, + sps: Option>, + pps: Option>, + params_emitted_since_irap: bool, + + out: Vec, +} + +impl HevcDepacketizer { + pub fn new() -> Self { + Self::default() + } + + pub fn push(&mut self, seq: u16, timestamp: u32, payload: &[u8]) { + if self.next_seq.is_none() { + self.next_seq = Some(seq); + } + + // Ignore packets we've already moved past (stale duplicates / late + // arrivals behind the cursor). + if let Some(next) = self.next_seq + && seq_less_than(seq, next) + { + return; + } + + self.reorder.insert(seq, (timestamp, payload.to_vec())); + self.drain_in_order(); + + // If we're stalled waiting on a lost packet, skip the gap. + if self.reorder.len() > MAX_REORDER_BUFFER + && let Some((&lowest, _)) = self.reorder.iter().next() + { + // A lost packet breaks any in-flight fragment. + self.reset_fu(); + self.next_seq = Some(lowest); + self.drain_in_order(); + } + } + + /// Process buffered packets while they are contiguous from the cursor. + fn drain_in_order(&mut self) { + while let Some(next) = self.next_seq { + let Some((ts, payload)) = self.reorder.remove(&next) else { + break; + }; + // A timestamp change marks a new access unit (frame). Close the + // previous picture by emitting an AUD before the new one's NALs, so + // the decoder doesn't merge slices from different frames together. + if let Some(prev) = self.last_ts + && prev != ts + { + self.write_annexb(&AUD_NAL); + } + self.last_ts = Some(ts); + self.process_payload(&payload); + self.next_seq = Some(next.wrapping_add(1)); + } + } + + /// Handle a single RTP payload according to its NAL structure. + fn process_payload(&mut self, payload: &[u8]) { + if payload.len() < HEVC_NAL_HEADER_LEN { + return; + } + match nal_type(payload[0]) { + NAL_TYPE_AP => self.process_aggregation(payload), + NAL_TYPE_FU => self.process_fragmentation(payload), + _ => { + // A new single NAL implies any in-flight fragment was lost. + self.reset_fu(); + self.emit_nal(payload); + } + } + } + + /// AP (type 48): `[NAL hdr][ (16-bit size)(NAL unit) ]+`. We do not negotiate + /// `sprop-max-don-diff`, so there is no DONL/DOND field to skip. + fn process_aggregation(&mut self, payload: &[u8]) { + self.reset_fu(); + let mut off = HEVC_NAL_HEADER_LEN; // skip the AP's own 2-byte header + while off + 2 <= payload.len() { + let size = u16::from_be_bytes([payload[off], payload[off + 1]]) as usize; + off += 2; + let Some(nal) = payload.get(off..off + size) else { + break; // truncated / malformed + }; + self.emit_nal(nal); + off += size; + } + } + + /// FU (type 49): a 2-byte FU NAL header, then a 1-byte FU header + /// `[S|E|FuType(6)]`, then a fragment of the original NAL's payload. The + /// original NAL header is reconstructed from the FU NAL header (layers/TID) + /// with the type field replaced by `FuType`. + fn process_fragmentation(&mut self, payload: &[u8]) { + if payload.len() < HEVC_NAL_HEADER_LEN + 1 { + return; + } + let fu_header = payload[2]; + let start = fu_header & 0x80 != 0; + let end = fu_header & 0x40 != 0; + let fu_type = fu_header & 0x3f; + let fragment = &payload[3..]; + + if start { + // Reconstruct the original NAL header: take the FU NAL header's two + // bytes and replace the type field (bits 1..=6 of byte 0) with the + // FU type. + let b0 = (payload[0] & 0x81) | (fu_type << 1); + let b1 = payload[1]; + self.fu_buffer.clear(); + self.fu_buffer.push(b0); + self.fu_buffer.push(b1); + self.fu_buffer.extend_from_slice(fragment); + self.fu_active = true; + } else if self.fu_active { + self.fu_buffer.extend_from_slice(fragment); + } else { + // Middle/end fragment with no start — the start packet was lost. + return; + } + + if end && self.fu_active { + let nal = std::mem::take(&mut self.fu_buffer); + self.fu_active = false; + self.emit_nal(&nal); + } + } + + /// Append one complete NAL unit to the output, caching parameter sets and + /// re-injecting them before key frames so a decoder can join mid-stream. + fn emit_nal(&mut self, nal: &[u8]) { + if nal.len() < HEVC_NAL_HEADER_LEN { + return; + } + let t = nal_type(nal[0]); + + match t { + NAL_TYPE_VPS => { + self.vps = Some(nal.to_vec()); + self.params_emitted_since_irap = true; + } + NAL_TYPE_SPS => { + self.sps = Some(nal.to_vec()); + self.params_emitted_since_irap = true; + } + NAL_TYPE_PPS => { + self.pps = Some(nal.to_vec()); + self.params_emitted_since_irap = true; + } + _ => {} + } + + // Before an IRAP (key) frame, make sure parameter sets precede it — but + // only ONCE per key frame. A complex picture is coded as multiple slices, + // i.e. several IRAP NAL units in a row for the *same* picture. Re-injecting + // VPS/SPS/PPS before each slice would plant parameter sets *between* slices + // of one picture, which a decoder reads as an access-unit boundary (H.265 + // AU detection treats a parameter set following a VCL NAL as the start of a + // new AU). That splits one picture into several partial pictures: only the + // first slice's CTUs decode and the rest of the frame is left stale. So we + // inject only when params weren't already emitted for this key frame, then + // mark them emitted so the remaining slices don't repeat it. + if is_irap(t) && !self.params_emitted_since_irap { + let sets: Vec> = [self.vps.clone(), self.sps.clone(), self.pps.clone()] + .into_iter() + .flatten() + .collect(); + for set in sets { + self.write_annexb(&set); + } + self.params_emitted_since_irap = true; + } + + // Re-arm injection only when a *non-IRAP* VCL NAL (a P/B slice) goes by: + // that marks the end of the key frame, so the next IRAP is a fresh key + // frame needing its parameter sets again. Crucially, further slices of the + // *same* IRAP picture (still IRAP NALs) and non-VCL NALs (SEI/AUD) must NOT + // re-arm it, or we'd reintroduce the mid-picture injection above. + if is_vcl(t) && !is_irap(t) { + self.params_emitted_since_irap = false; + } + + self.write_annexb(nal); + } + + fn write_annexb(&mut self, nal: &[u8]) { + self.out.extend_from_slice(&ANNEXB_START_CODE); + self.out.extend_from_slice(nal); + } + + fn reset_fu(&mut self) { + self.fu_buffer.clear(); + self.fu_active = false; + } + + /// Drain and return all Annex-B output accumulated so far. + pub fn take_output(&mut self) -> Vec { + std::mem::take(&mut self.out) + } + + /// True once all three parameter sets (VPS/SPS/PPS) have been observed. + pub fn has_parameter_sets(&self) -> bool { + self.vps.is_some() && self.sps.is_some() && self.pps.is_some() + } +} + +/// RTP sequence-number comparison with 16-bit wraparound (RFC 1982). Returns +/// true if `a` is "before" `b` in sequence order. +#[inline] +fn seq_less_than(a: u16, b: u16) -> bool { + let diff = b.wrapping_sub(a); + diff != 0 && diff < 0x8000 +} diff --git a/src-tauri/vendor/idevice/src/services/core_device/display_stream/media_negotiation.proto b/src-tauri/vendor/idevice/src/services/core_device/display_stream/media_negotiation.proto new file mode 100644 index 0000000..75d7fba --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/core_device/display_stream/media_negotiation.proto @@ -0,0 +1,116 @@ +// VCMediaNegotiationBlobV2 - Apple AVConference media negotiation blob. +// +// The CoreDevice displayservice `startvideooutput`/`startmediastream` feature +// takes a `negotiatorOffer: Data` which is: +// +// negotiatorOffer = zlib( protobuf( VCMediaNegotiationBlobV2 ) ) +// +// The device's answer (in the StartResponse) is the same format. +// +// Schema reverse-engineered from AVConference (Xcode beta, dyld shared cache, +// 2026-06-08) by decompiling each `_ReadFrom` / `writeTo:` function. +// Field numbers are authoritative (taken from PBDataWriterWrite*Field calls). +// Scalar varint fields are written via WriteUint32Field/WriteUint64Field; the +// true proto type may be int32/enum/bool but the wire encoding is identical. +// `bytes` fields use WriteDataField (NSData) and themselves often carry packed +// sub-structures (codec params). TBD by reversing AVCMediaStreamNegotiator. +// +// Messages 4 (microphoneU1), 5 (cameraU1), 6 (momentsSettings) are FaceTime +// camera/mic/moments and are NOT needed for device screen streaming; left out. +// +// We don't actually use this file in idevice, but it's kept for reference. + +syntax = "proto2"; + +package idevice.coredevice.media; + +message VCMediaNegotiationBlobV2 { + optional GeneralInfo general_info = 1; + optional BandwidthSettings bandwidth_settings = 2; + optional CodecFeatures codec_support = 3; + // 4 = microphoneU1 (MicrophoneSettingsU1) - FaceTime, unused + // 5 = cameraU1 (CameraSettingsU1) - FaceTime, unused + // 6 = momentsSettings (MomentsSettings) - FaceTime, unused + repeated StreamGroup stream_groups = 7; +} + +message GeneralInfo { + optional uint64 ntp_time = 1; + optional string cname = 2; + optional uint32 ab_switches = 3; + optional uint32 screen_res = 4; + optional uint32 fec_header_version = 5; + optional uint32 rtx_version = 6; +} + +message BandwidthSettings { + optional uint32 cap_2g = 1; + optional uint32 cap_3g = 2; + optional uint32 cap_lte = 3; + optional uint32 cap_5g = 4; + optional uint32 cap_wifi = 5; +} + +message CodecFeatures { + optional uint32 audio_features = 1; // bitfield; bit0 = allowAudioRecording + optional bytes video_features = 2; +} + +message StreamGroup { + optional uint32 stream_group = 1; + repeated StreamGroupPayload payloads = 2; + repeated StreamGroupStream streams = 3; + optional SettingsU1 settings_u1 = 4; +} + +message StreamGroupPayload { + optional uint32 codec_type = 1; + optional uint32 rtp_payload = 2; + optional uint32 p_time = 3; + optional uint32 rtcp_flags = 4; + optional uint32 media_flags = 5; + optional uint32 profile_level_id = 6; + optional uint32 rtp_sample_rate = 7; + optional uint32 cipher_suite = 8; + optional bytes packed_payload = 9; + optional uint32 encoder_usage = 10; +} + +message StreamGroupStream { + optional uint32 metadata = 1; + optional uint32 payload_spec_or_payloads = 2; + optional uint32 quality_index = 3; + optional uint32 rtp_ssrc = 4; + optional uint32 stream_id = 5; + optional uint32 max_network_bitrate = 6; + optional uint32 repaired_max_network_bitrate = 7; + optional uint32 audio_channel_count = 8; + optional uint32 stream_index = 9; + optional bytes required_packed_payload = 10; + optional bytes optional_packed_payload = 11; + optional uint32 coordinate_system = 12; + optional uint32 payloads_version = 13; + optional uint32 max_network_bitrate_v2 = 14; + optional uint32 repaired_max_network_bitrate_v2 = 15; +} + +message SettingsU1 { + optional uint32 rtp_ssrc = 1; + repeated EncodeDecodeFeatures encode_decode_features = 2; +} + +message EncodeDecodeFeatures { + optional uint32 rtp_payload = 1; + optional bytes encode_decode_features = 2; +} + +// Stands alone in the V2 hierarchy (not referenced by StreamGroup); kept for +// completeness. +message VideoPayload { + optional uint32 video_payload = 1; + optional uint32 parameter_set = 2; + optional uint32 encode_formats = 3; + optional uint32 decode_formats = 4; + optional bytes encode_decode_features = 5; + optional uint32 preferred_decode_format = 6; +} diff --git a/src-tauri/vendor/idevice/src/services/core_device/display_stream/mod.rs b/src-tauri/vendor/idevice/src/services/core_device/display_stream/mod.rs new file mode 100644 index 0000000..ba4dc2f --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/core_device/display_stream/mod.rs @@ -0,0 +1,35 @@ +// Jackson Coxson +// +// Device screen streaming over `com.apple.coredevice.displayservice`. +// +// Pipeline: +// 1. Connect `DisplayServiceClient` over RemoteXPC (RSD). +// 2. Build a `MediaNegotiationBlob` offer, zlib it (`negotiation`), and send +// it via `startvideooutput` with the receiver address of a UDP socket we +// open on the tunnel. +// 3. Parse the device's answer (same blob format) for the negotiated config. +// 4. Receive plaintext RTP/HEVC (`rtp`) and reassemble/decode. +// +// The media transport carries no SRTP (negotiated cipher suite is 0), so the +// whole path is reproducible in userspace. + +mod client; +pub mod hevc; +pub mod negotiation; +mod protobuf; +pub mod rtcp; +pub mod rtp; + +pub use client::{ + DisplayServiceClient, build_start_audio_parameters, build_start_video_parameters, +}; +pub use hevc::HevcDepacketizer; +pub use negotiation::{ + CallInfoBlob, MediaNegotiationBlob, build_screen_audio_offer, build_screen_video_offer, + parse_answer_media_blob, +}; +pub use rtcp::{ + ReportBlock, SenderReport, build_fir, build_frame_ack, build_keyframe_request, build_liveness, + build_pli, build_rctl, build_receiver_report, build_sdes, is_rtcp, +}; +pub use rtp::RtpPacket; diff --git a/src-tauri/vendor/idevice/src/services/core_device/display_stream/negotiation.rs b/src-tauri/vendor/idevice/src/services/core_device/display_stream/negotiation.rs new file mode 100644 index 0000000..a3eeefb --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/core_device/display_stream/negotiation.rs @@ -0,0 +1,711 @@ +// Jackson Coxson +// +// VCMediaNegotiationBlobV2 - the AVConference media-negotiation blob carried as +// the `negotiatorOffer` (and returned as the device's answer) by the CoreDevice +// displayservice. +// +// negotiatorOffer = zlib( protobuf( VCMediaNegotiationBlobV2 ) ) +// +// Schema + field numbers are in `media_negotiation.proto` (reverse-engineered +// from AVConference). We only model the messages needed for a device->host +// video (screen) stream + +use std::io::{Read, Write}; + +use flate2::{Compression, read::ZlibDecoder, write::ZlibEncoder}; + +use super::super::CoreDeviceError; +use super::protobuf::{Decoder, Encoder, Field}; +use crate::IdeviceError; + +/// Top-level negotiation blob. Optional sub-messages are `None` when absent. +#[derive(Debug, Default, Clone)] +pub struct MediaNegotiationBlob { + pub general_info: Option, + pub bandwidth_settings: Option, + pub codec_features: Option, + pub stream_groups: Vec, +} + +#[derive(Debug, Default, Clone)] +pub struct GeneralInfo { + pub ntp_time: u64, + pub cname: String, + pub ab_switches: u32, + pub screen_res: u32, + pub fec_header_version: u32, + pub rtx_version: u32, +} + +#[derive(Debug, Default, Clone, Copy)] +pub struct BandwidthSettings { + pub cap_2g: u32, + pub cap_3g: u32, + pub cap_lte: u32, + pub cap_5g: u32, + pub cap_wifi: u32, +} + +#[derive(Debug, Default, Clone)] +pub struct CodecFeatures { + pub audio_features: u32, + pub video_features: Vec, +} + +#[derive(Debug, Default, Clone)] +pub struct StreamGroup { + pub stream_group: u32, + pub payloads: Vec, + pub streams: Vec, + pub settings_u1: Option, +} + +#[derive(Debug, Default, Clone)] +pub struct StreamGroupPayload { + pub codec_type: u32, + pub rtp_payload: u32, + pub p_time: u32, + pub rtcp_flags: u32, + pub media_flags: u32, + pub profile_level_id: u32, + pub rtp_sample_rate: u32, + pub cipher_suite: u32, + pub packed_payload: Vec, + pub encoder_usage: u32, +} + +#[derive(Debug, Default, Clone)] +pub struct StreamGroupStream { + pub metadata: u32, + pub payload_spec_or_payloads: u32, + pub quality_index: u32, + pub rtp_ssrc: u32, + pub stream_id: u32, + pub max_network_bitrate: u32, + pub repaired_max_network_bitrate: u32, + pub audio_channel_count: u32, + pub stream_index: u32, + pub required_packed_payload: Vec, + pub optional_packed_payload: Vec, + pub coordinate_system: u32, + pub payloads_version: u32, + pub max_network_bitrate_v2: u32, + pub repaired_max_network_bitrate_v2: u32, +} + +#[derive(Debug, Default, Clone)] +pub struct SettingsU1 { + pub rtp_ssrc: u32, + pub encode_decode_features: Vec, +} + +#[derive(Debug, Default, Clone)] +pub struct EncodeDecodeFeatures { + pub rtp_payload: u32, + pub encode_decode_features: Vec, +} + +pub const STREAM_GROUP_SCREEN: u32 = 3; +pub const SCREEN_FLS: &[u8] = b"FLS;VRAE:0;SW:1;"; + +/// So we're using a different on than Device Hub, but this seems to stop screen tearing, +/// so we'll use this one instead. +pub const SCREEN_HEVC_FLS: &str = + "FLS;MS:-1;LF:-1;LTR;CABAC;POS:0;EOD:1;HTS:2;RR:3;AR:16/9,5/8;XR:16/9,5/8;"; + +pub const NEGOTIATOR_MODE_COREDEVICE_SCREEN: i64 = 5; +pub const NEGOTIATOR_MODE_COREDEVICE_AUDIO: i64 = 6; + +const KEY_CALL_ID: &str = "avcMediaStreamOptionCallID"; +const KEY_MEDIA_BLOB: &str = "avcMediaStreamNegotiatorMediaBlob"; +const KEY_MODE: &str = "avcMediaStreamNegotiatorMode"; +const KEY_REMOTE_ENDPOINT_INFO: &str = "avcMediaStreamOptionRemoteEndpointInfo"; + +/// `VCCallInfoBlob` - endpoint metadata embedded (as protobuf) under +/// `avcMediaStreamOptionRemoteEndpointInfo` in the offer. Schema reversed from +/// `-[VCCallInfoBlob writeTo:]`. +#[derive(Debug, Clone, Default)] +pub struct CallInfoBlob { + pub call_id: u32, + pub client_version: u32, + pub device_type: String, + pub framework_version: String, + pub os_version: String, + pub device_name: Option, + pub audio_device_uid: Option, +} + +impl CallInfoBlob { + pub fn encode(&self) -> Vec { + let mut e = Encoder::new(); + e.uint_field(1, self.call_id as u64); + e.uint_field(2, self.client_version as u64); + e.string_field(3, &self.device_type); + e.string_field(4, &self.framework_version); + e.string_field(5, &self.os_version); + if let Some(n) = &self.device_name { + e.string_field(6, n); + } + if let Some(u) = &self.audio_device_uid { + e.string_field(7, u); + } + e.into_bytes() + } +} + +/// Build the CoreDevice screen-sharing media blob +pub fn build_screen_media_blob(session_id: u32, ntp_time: u64) -> Vec { + build_screen_media_blob_with_fls(session_id, ntp_time, SCREEN_HEVC_FLS) +} + +fn build_screen_media_blob_with_fls(session_id: u32, ntp_time: u64, hevc_fls: &str) -> Vec { + fn format_desc(e: &mut Encoder, variant: u64) { + e.message_field(2, |m| { + m.uint_field(1, 1); + m.uint_field(2, variant); + m.uint_field(3, 0xc3c3); + m.uint_field(4, 0); + }); + } + + let mut e = Encoder::new(); + e.uint_field(1, 1); + e.uint_field(2, 1); + e.message_field(5, |m| { + m.uint_field(1, session_id as u64); + m.uint_field(2, 0); + // H.264 (RTP PT 123), four format descriptors. + m.message_field(3, |c| { + c.uint_field(1, 123); + format_desc(c, 1); + format_desc(c, 2); + format_desc(c, 1); + format_desc(c, 2); + c.string_field(3, "FLS;SW:1;"); + c.uint_field(4, 1); + }); + // HEVC (RTP PT 100), two format descriptors. + m.message_field(3, |c| { + c.uint_field(1, 100); + format_desc(c, 1); + format_desc(c, 2); + c.string_field(3, hevc_fls); + c.uint_field(4, 14); + }); + m.uint_field(7, 1); + m.uint_field(8, 63); + m.uint_field(12, 1); + }); + write_blob_tail(&mut e, ntp_time); + e.into_bytes() +} + +/// Build the CoreDevice screen-sharing audio media blob +pub fn build_audio_media_blob(session_id: u32, ntp_time: u64) -> Vec { + let mut e = Encoder::new(); + e.uint_field(1, 1); + e.uint_field(2, 1); + e.message_field(3, |m| { + m.uint_field(1, session_id as u64); + m.uint_field(2, 0); + m.uint_field(3, 0); + m.uint_field(4, 24_191); + m.uint_field(5, 0); + m.uint_field(6, 0); + }); + write_blob_tail(&mut e, ntp_time); + e.into_bytes() +} + +/// Write the fields shared by the audio and video screen blobs: the UserAgent +/// (field 6), field 8, the bandwidth/parameter ladder (field 9, repeated), and +/// the trailing scalars (13 = NTP, 14, 16, 18). +fn write_blob_tail(e: &mut Encoder, ntp_time: u64) { + fn param(e: &mut Encoder, id: u64, value: u64, flags: Option) { + e.message_field(9, |m| { + m.uint_field(1, id); + m.uint_field(2, value); + if let Some(f) = flags { + m.uint_field(3, f); + } + }); + } + e.string_field(6, "Viceroy 1.7.0"); + e.uint_field(8, 0); + param(e, 0, 20_000_000, Some(0x18000)); + param(e, 4074, 0, Some(0x4000)); + param(e, 0, 60_000_000, Some(0x40000)); + param(e, 0, 40_000_000, Some(0x3000)); + param(e, 16, 4100, None); + param(e, 0, 6_000_000, Some(0x20000)); + param(e, 4, 6500, None); + param(e, 0, 100_000_000, Some(0x100000)); + param(e, 1, 299, None); + param(e, 0, 75_000_000, Some(0x80000)); + e.uint_field(13, ntp_time); + e.uint_field(14, 2); + e.uint_field(16, 0); + e.uint_field(18, 1); +} + +/// zlib-compress a media blob into the form embedded in the offer plist. +fn zlib_compress(raw: &[u8]) -> Result, IdeviceError> { + let mut z = ZlibEncoder::new(Vec::new(), Compression::default()); + z.write_all(raw) + .map_err(|e| CoreDeviceError::Negotiation(format!("zlib compress: {e}")))?; + z.finish() + .map_err(|e| CoreDeviceError::Negotiation(format!("zlib finish: {e}")).into()) +} + +/// Build the full `negotiatorOffer` for a device->host screen video stream: +/// a binary plist matching what Device Hub sends. +/// +/// Keys: +/// - `avcMediaStreamOptionCallID` - UUID string identifying the session +/// - `avcMediaStreamNegotiatorMediaBlob` - zlib-compressed screen media blob +/// - `avcMediaStreamNegotiatorMode` - 5 (CoreDeviceScreenSharing; the audio +/// offer uses 6 / CoreDeviceSystemAudio +/// - `avcMediaStreamOptionRemoteEndpointInfo` - `VCCallInfoBlob` protobuf +pub fn build_screen_video_offer( + call_id: &str, + call_info: &CallInfoBlob, + ssrc: u32, +) -> Result, IdeviceError> { + let blob = build_screen_media_blob(ssrc, ntp_now()); + build_offer_plist(call_id, call_info, &blob, NEGOTIATOR_MODE_COREDEVICE_SCREEN) +} + +/// Build the audio screen-sharing `negotiatorOffer`. Sent before the video +/// offer to establish the screen-sharing session. +pub fn build_screen_audio_offer( + call_id: &str, + call_info: &CallInfoBlob, +) -> Result, IdeviceError> { + let session_id = uuid::Uuid::new_v4().as_u128() as u32; + let blob = build_audio_media_blob(session_id, ntp_now()); + build_offer_plist(call_id, call_info, &blob, NEGOTIATOR_MODE_COREDEVICE_AUDIO) +} + +/// Wrap a media blob into the binary-plist `negotiatorOffer`. +fn build_offer_plist( + call_id: &str, + call_info: &CallInfoBlob, + media_blob_raw: &[u8], + negotiator_mode: i64, +) -> Result, IdeviceError> { + let media_blob = zlib_compress(media_blob_raw)?; + + let mut dict = plist::Dictionary::new(); + dict.insert(KEY_CALL_ID.into(), plist::Value::String(call_id.into())); + dict.insert(KEY_MEDIA_BLOB.into(), plist::Value::Data(media_blob)); + dict.insert( + KEY_MODE.into(), + plist::Value::Integer(negotiator_mode.into()), + ); + dict.insert( + KEY_REMOTE_ENDPOINT_INFO.into(), + plist::Value::Data(call_info.encode()), + ); + + let mut out = Vec::new(); + plist::to_writer_binary(&mut out, &plist::Value::Dictionary(dict)) + .map_err(|e| CoreDeviceError::Negotiation(format!("offer plist encode: {e}")))?; + Ok(out) +} + +/// Parse the device's answer (`StartResponse.negotiatorAnswer`): a binary plist +/// with the device's `VCMediaNegotiationBlobV2` under `avcMediaStreamNegotiatorMediaBlob`. +pub fn parse_answer_media_blob(answer: &[u8]) -> Result { + let value: plist::Value = plist::from_bytes(answer) + .map_err(|e| CoreDeviceError::Negotiation(format!("answer plist decode: {e}")))?; + let blob = value + .as_dictionary() + .and_then(|d| d.get(KEY_MEDIA_BLOB)) + .and_then(|v| v.as_data()) + .ok_or(CoreDeviceError::MissingField(KEY_MEDIA_BLOB))?; + Ok(MediaNegotiationBlob::decode(blob)) +} + +fn ntp_now() -> u64 { + const UNIX_TO_NTP: u64 = 2_208_988_800; // seconds between 1900 and 1970 + let now = web_time::SystemTime::now() + .duration_since(web_time::UNIX_EPOCH) + .unwrap_or_default(); + let secs = now.as_secs() + UNIX_TO_NTP; + let frac = ((now.subsec_nanos() as u64) << 32) / 1_000_000_000; + (secs << 32) | frac +} + +impl MediaNegotiationBlob { + /// Build a best-effort offer for a device->host HEVC screen video stream. + pub fn video_offer(ssrc: u32) -> Self { + MediaNegotiationBlob { + general_info: Some(GeneralInfo { + ntp_time: ntp_now(), + ..Default::default() + }), + bandwidth_settings: None, + codec_features: Some(CodecFeatures { + audio_features: 0, + video_features: SCREEN_FLS.to_vec(), + }), + stream_groups: vec![StreamGroup { + stream_group: STREAM_GROUP_SCREEN, + payloads: vec![StreamGroupPayload { + codec_type: 1, + rtp_payload: 100, + cipher_suite: 0, + ..Default::default() + }], + streams: vec![StreamGroupStream { + rtp_ssrc: ssrc, + max_network_bitrate: 6_000_000, + ..Default::default() + }], + settings_u1: Some(SettingsU1 { + rtp_ssrc: ssrc, + encode_decode_features: vec![EncodeDecodeFeatures { + rtp_payload: 100, + encode_decode_features: SCREEN_FLS.to_vec(), + }], + }), + }], + } + } +} + +impl MediaNegotiationBlob { + /// Serialize to protobuf bytes + pub fn encode(&self) -> Vec { + let mut e = Encoder::new(); + if let Some(g) = &self.general_info { + e.message_field(1, |m| g.encode_into(m)); + } + if let Some(b) = &self.bandwidth_settings { + e.message_field(2, |m| b.encode_into(m)); + } + if let Some(c) = &self.codec_features { + e.message_field(3, |m| c.encode_into(m)); + } + for sg in &self.stream_groups { + e.message_field(7, |m| sg.encode_into(m)); + } + e.into_bytes() + } + + /// Serialize and zlib-compress into the wire `negotiatorOffer` form. + pub fn to_negotiator_offer(&self) -> Result, IdeviceError> { + let raw = self.encode(); + let mut z = ZlibEncoder::new(Vec::new(), Compression::default()); + z.write_all(&raw) + .map_err(|e| CoreDeviceError::Negotiation(format!("zlib compress: {e}")))?; + z.finish() + .map_err(|e| CoreDeviceError::Negotiation(format!("zlib finish: {e}")).into()) + } + + /// Decompress + parse a wire `negotiatorOffer`/answer blob. + pub fn from_negotiator_offer(data: &[u8]) -> Result { + let mut z = ZlibDecoder::new(data); + let mut raw = Vec::new(); + z.read_to_end(&mut raw) + .map_err(|e| CoreDeviceError::Negotiation(format!("zlib decompress: {e}")))?; + Ok(Self::decode(&raw)) + } + + /// Parse uncompressed protobuf bytes. + pub fn decode(buf: &[u8]) -> Self { + let mut out = MediaNegotiationBlob::default(); + let mut d = Decoder::new(buf); + while let Some((field, val)) = d.next_field() { + match (field, val) { + (1, Field::Len(b)) => out.general_info = Some(GeneralInfo::decode(b)), + (2, Field::Len(b)) => out.bandwidth_settings = Some(BandwidthSettings::decode(b)), + (3, Field::Len(b)) => out.codec_features = Some(CodecFeatures::decode(b)), + (7, Field::Len(b)) => out.stream_groups.push(StreamGroup::decode(b)), + _ => {} + } + } + out + } +} + +impl GeneralInfo { + fn encode_into(&self, e: &mut Encoder) { + e.uint_field(1, self.ntp_time); + if !self.cname.is_empty() { + e.string_field(2, &self.cname); + } + e.uint_field(3, self.ab_switches as u64); + e.uint_field(4, self.screen_res as u64); + e.uint_field(5, self.fec_header_version as u64); + e.uint_field(6, self.rtx_version as u64); + } + + fn decode(buf: &[u8]) -> Self { + let mut g = GeneralInfo::default(); + let mut d = Decoder::new(buf); + while let Some((f, v)) = d.next_field() { + match (f, v) { + (1, Field::Varint(x)) => g.ntp_time = x, + (2, Field::Len(b)) => g.cname = String::from_utf8_lossy(b).into_owned(), + (3, Field::Varint(x)) => g.ab_switches = x as u32, + (4, Field::Varint(x)) => g.screen_res = x as u32, + (5, Field::Varint(x)) => g.fec_header_version = x as u32, + (6, Field::Varint(x)) => g.rtx_version = x as u32, + _ => {} + } + } + g + } +} + +impl BandwidthSettings { + fn encode_into(&self, e: &mut Encoder) { + e.uint_field(1, self.cap_2g as u64); + e.uint_field(2, self.cap_3g as u64); + e.uint_field(3, self.cap_lte as u64); + e.uint_field(4, self.cap_5g as u64); + e.uint_field(5, self.cap_wifi as u64); + } + + fn decode(buf: &[u8]) -> Self { + let mut s = BandwidthSettings::default(); + let mut d = Decoder::new(buf); + while let Some((f, v)) = d.next_field() { + if let Field::Varint(x) = v { + match f { + 1 => s.cap_2g = x as u32, + 2 => s.cap_3g = x as u32, + 3 => s.cap_lte = x as u32, + 4 => s.cap_5g = x as u32, + 5 => s.cap_wifi = x as u32, + _ => {} + } + } + } + s + } +} + +impl CodecFeatures { + fn encode_into(&self, e: &mut Encoder) { + e.uint_field(1, self.audio_features as u64); + if !self.video_features.is_empty() { + e.bytes_field(2, &self.video_features); + } + } + + fn decode(buf: &[u8]) -> Self { + let mut c = CodecFeatures::default(); + let mut d = Decoder::new(buf); + while let Some((f, v)) = d.next_field() { + match (f, v) { + (1, Field::Varint(x)) => c.audio_features = x as u32, + (2, Field::Len(b)) => c.video_features = b.to_vec(), + _ => {} + } + } + c + } +} + +impl StreamGroup { + fn encode_into(&self, e: &mut Encoder) { + e.uint_field(1, self.stream_group as u64); + for p in &self.payloads { + e.message_field(2, |m| p.encode_into(m)); + } + for s in &self.streams { + e.message_field(3, |m| s.encode_into(m)); + } + if let Some(s) = &self.settings_u1 { + e.message_field(4, |m| s.encode_into(m)); + } + } + + fn decode(buf: &[u8]) -> Self { + let mut g = StreamGroup::default(); + let mut d = Decoder::new(buf); + while let Some((f, v)) = d.next_field() { + match (f, v) { + (1, Field::Varint(x)) => g.stream_group = x as u32, + (2, Field::Len(b)) => g.payloads.push(StreamGroupPayload::decode(b)), + (3, Field::Len(b)) => g.streams.push(StreamGroupStream::decode(b)), + (4, Field::Len(b)) => g.settings_u1 = Some(SettingsU1::decode(b)), + _ => {} + } + } + g + } +} + +impl StreamGroupPayload { + fn encode_into(&self, e: &mut Encoder) { + e.uint_field(1, self.codec_type as u64); + e.uint_field(2, self.rtp_payload as u64); + e.uint_field(3, self.p_time as u64); + e.uint_field(4, self.rtcp_flags as u64); + e.uint_field(5, self.media_flags as u64); + e.uint_field(6, self.profile_level_id as u64); + e.uint_field(7, self.rtp_sample_rate as u64); + e.uint_field(8, self.cipher_suite as u64); + if !self.packed_payload.is_empty() { + e.bytes_field(9, &self.packed_payload); + } + e.uint_field(10, self.encoder_usage as u64); + } + + fn decode(buf: &[u8]) -> Self { + let mut p = StreamGroupPayload::default(); + let mut d = Decoder::new(buf); + while let Some((f, v)) = d.next_field() { + match (f, v) { + (1, Field::Varint(x)) => p.codec_type = x as u32, + (2, Field::Varint(x)) => p.rtp_payload = x as u32, + (3, Field::Varint(x)) => p.p_time = x as u32, + (4, Field::Varint(x)) => p.rtcp_flags = x as u32, + (5, Field::Varint(x)) => p.media_flags = x as u32, + (6, Field::Varint(x)) => p.profile_level_id = x as u32, + (7, Field::Varint(x)) => p.rtp_sample_rate = x as u32, + (8, Field::Varint(x)) => p.cipher_suite = x as u32, + (9, Field::Len(b)) => p.packed_payload = b.to_vec(), + (10, Field::Varint(x)) => p.encoder_usage = x as u32, + _ => {} + } + } + p + } +} + +impl StreamGroupStream { + fn encode_into(&self, e: &mut Encoder) { + e.uint_field(1, self.metadata as u64); + e.uint_field(2, self.payload_spec_or_payloads as u64); + e.uint_field(3, self.quality_index as u64); + e.uint_field(4, self.rtp_ssrc as u64); + e.uint_field(5, self.stream_id as u64); + e.uint_field(6, self.max_network_bitrate as u64); + e.uint_field(7, self.repaired_max_network_bitrate as u64); + e.uint_field(8, self.audio_channel_count as u64); + e.uint_field(9, self.stream_index as u64); + if !self.required_packed_payload.is_empty() { + e.bytes_field(10, &self.required_packed_payload); + } + if !self.optional_packed_payload.is_empty() { + e.bytes_field(11, &self.optional_packed_payload); + } + e.uint_field(12, self.coordinate_system as u64); + e.uint_field(13, self.payloads_version as u64); + e.uint_field(14, self.max_network_bitrate_v2 as u64); + e.uint_field(15, self.repaired_max_network_bitrate_v2 as u64); + } + + fn decode(buf: &[u8]) -> Self { + let mut s = StreamGroupStream::default(); + let mut d = Decoder::new(buf); + while let Some((f, v)) = d.next_field() { + match (f, v) { + (1, Field::Varint(x)) => s.metadata = x as u32, + (2, Field::Varint(x)) => s.payload_spec_or_payloads = x as u32, + (3, Field::Varint(x)) => s.quality_index = x as u32, + (4, Field::Varint(x)) => s.rtp_ssrc = x as u32, + (5, Field::Varint(x)) => s.stream_id = x as u32, + (6, Field::Varint(x)) => s.max_network_bitrate = x as u32, + (7, Field::Varint(x)) => s.repaired_max_network_bitrate = x as u32, + (8, Field::Varint(x)) => s.audio_channel_count = x as u32, + (9, Field::Varint(x)) => s.stream_index = x as u32, + (10, Field::Len(b)) => s.required_packed_payload = b.to_vec(), + (11, Field::Len(b)) => s.optional_packed_payload = b.to_vec(), + (12, Field::Varint(x)) => s.coordinate_system = x as u32, + (13, Field::Varint(x)) => s.payloads_version = x as u32, + (14, Field::Varint(x)) => s.max_network_bitrate_v2 = x as u32, + (15, Field::Varint(x)) => s.repaired_max_network_bitrate_v2 = x as u32, + _ => {} + } + } + s + } +} + +impl SettingsU1 { + fn encode_into(&self, e: &mut Encoder) { + e.uint_field(1, self.rtp_ssrc as u64); + for edf in &self.encode_decode_features { + e.message_field(2, |m| edf.encode_into(m)); + } + } + + fn decode(buf: &[u8]) -> Self { + let mut s = SettingsU1::default(); + let mut d = Decoder::new(buf); + while let Some((f, v)) = d.next_field() { + match (f, v) { + (1, Field::Varint(x)) => s.rtp_ssrc = x as u32, + (2, Field::Len(b)) => s + .encode_decode_features + .push(EncodeDecodeFeatures::decode(b)), + _ => {} + } + } + s + } +} + +impl EncodeDecodeFeatures { + fn encode_into(&self, e: &mut Encoder) { + e.uint_field(1, self.rtp_payload as u64); + if !self.encode_decode_features.is_empty() { + e.bytes_field(2, &self.encode_decode_features); + } + } + + fn decode(buf: &[u8]) -> Self { + let mut s = EncodeDecodeFeatures::default(); + let mut d = Decoder::new(buf); + while let Some((f, v)) = d.next_field() { + match (f, v) { + (1, Field::Varint(x)) => s.rtp_payload = x as u32, + (2, Field::Len(b)) => s.encode_decode_features = b.to_vec(), + _ => {} + } + } + s + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn blob_roundtrips_through_zlib() { + let blob = MediaNegotiationBlob { + general_info: Some(GeneralInfo { + cname: "host".into(), + rtx_version: 1, + ..Default::default() + }), + stream_groups: vec![StreamGroup { + stream_group: 1, + payloads: vec![StreamGroupPayload { + rtp_payload: 100, + rtp_sample_rate: 90000, + ..Default::default() + }], + streams: vec![StreamGroupStream { + rtp_ssrc: 0xdead_beef, + ..Default::default() + }], + ..Default::default() + }], + ..Default::default() + }; + + let wire = blob.to_negotiator_offer().unwrap(); + let back = MediaNegotiationBlob::from_negotiator_offer(&wire).unwrap(); + assert_eq!(back.general_info.as_ref().unwrap().cname, "host"); + assert_eq!(back.stream_groups.len(), 1); + assert_eq!(back.stream_groups[0].payloads[0].rtp_payload, 100); + assert_eq!(back.stream_groups[0].streams[0].rtp_ssrc, 0xdead_beef); + } +} diff --git a/src-tauri/vendor/idevice/src/services/core_device/display_stream/protobuf.rs b/src-tauri/vendor/idevice/src/services/core_device/display_stream/protobuf.rs new file mode 100644 index 0000000..2a8818e --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/core_device/display_stream/protobuf.rs @@ -0,0 +1,201 @@ +// Jackson Coxson +// +// Minimal protobuf wire-format encoder/decoder. +// +// The AVConference media-negotiation blob (`VCMediaNegotiationBlobV2`) is an +// Apple protobuf message. The schema is tiny and fixed (see +// `media_negotiation.proto`), so rather than pull in `prost`/`protoc` we +// hand-roll just the bits of the wire format we need. +// +// Wire types we handle: 0 = varint, 2 = length-delimited (string/bytes/message). + +/// Protobuf wire types. +pub const WIRE_VARINT: u32 = 0; +pub const WIRE_LEN: u32 = 2; + +/// A growable protobuf encoder. +#[derive(Debug, Default)] +pub struct Encoder { + buf: Vec, +} + +impl Encoder { + pub fn new() -> Self { + Self { buf: Vec::new() } + } + + pub fn into_bytes(self) -> Vec { + self.buf + } + + fn write_varint(&mut self, mut v: u64) { + loop { + let mut byte = (v & 0x7f) as u8; + v >>= 7; + if v != 0 { + byte |= 0x80; + } + self.buf.push(byte); + if v == 0 { + break; + } + } + } + + fn write_tag(&mut self, field: u32, wire: u32) { + self.write_varint(((field << 3) | wire) as u64); + } + + /// Write a varint scalar field (uint32/uint64/bool/enum). + pub fn uint_field(&mut self, field: u32, v: u64) { + self.write_tag(field, WIRE_VARINT); + self.write_varint(v); + } + + /// Write a length-delimited field (string/bytes). + pub fn bytes_field(&mut self, field: u32, v: &[u8]) { + self.write_tag(field, WIRE_LEN); + self.write_varint(v.len() as u64); + self.buf.extend_from_slice(v); + } + + pub fn string_field(&mut self, field: u32, v: &str) { + self.bytes_field(field, v.as_bytes()); + } + + /// Write a nested message field by encoding `f` into a sub-encoder. + pub fn message_field(&mut self, field: u32, f: impl FnOnce(&mut Encoder)) { + let mut sub = Encoder::new(); + f(&mut sub); + let bytes = sub.into_bytes(); + self.bytes_field(field, &bytes); + } +} + +/// A protobuf decoder over a borrowed buffer. +#[derive(Debug)] +pub struct Decoder<'a> { + buf: &'a [u8], + pos: usize, +} + +/// One decoded protobuf field. +#[derive(Debug)] +pub enum Field<'a> { + Varint(u64), + Len(&'a [u8]), +} + +impl<'a> Decoder<'a> { + pub fn new(buf: &'a [u8]) -> Self { + Self { buf, pos: 0 } + } + + pub fn is_empty(&self) -> bool { + self.pos >= self.buf.len() + } + + fn read_varint(&mut self) -> Option { + let mut result: u64 = 0; + let mut shift = 0; + loop { + let byte = *self.buf.get(self.pos)?; + self.pos += 1; + result |= ((byte & 0x7f) as u64) << shift; + if byte & 0x80 == 0 { + return Some(result); + } + shift += 7; + if shift >= 64 { + return None; + } + } + } + + /// Read the next `(field_number, value)`. Returns `None` at end of buffer or + /// on malformed input. + pub fn next_field(&mut self) -> Option<(u32, Field<'a>)> { + if self.is_empty() { + return None; + } + let tag = self.read_varint()?; + let field = (tag >> 3) as u32; + let wire = (tag & 7) as u32; + match wire { + WIRE_VARINT => Some((field, Field::Varint(self.read_varint()?))), + WIRE_LEN => { + let len = self.read_varint()? as usize; + let end = self.pos.checked_add(len)?; + let slice = self.buf.get(self.pos..end)?; + self.pos = end; + Some((field, Field::Len(slice))) + } + // 64-bit (1) and 32-bit (5): skip fixed widths so we stay aligned. + 1 => { + self.pos = self.pos.checked_add(8)?; + if self.pos > self.buf.len() { + return None; + } + self.next_field() + } + 5 => { + self.pos = self.pos.checked_add(4)?; + if self.pos > self.buf.len() { + return None; + } + self.next_field() + } + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + impl Field<'_> { + pub fn as_varint(&self) -> Option { + match self { + Field::Varint(v) => Some(*v), + _ => None, + } + } + + pub fn as_bytes(&self) -> Option<&[u8]> { + match self { + Field::Len(b) => Some(b), + _ => None, + } + } + } + + #[test] + fn roundtrip_scalars_and_message() { + let mut e = Encoder::new(); + e.uint_field(1, 100); + e.string_field(2, "abc"); + e.message_field(7, |m| { + m.uint_field(1, 4); + m.bytes_field(2, &[0xde, 0xad]); + }); + let bytes = e.into_bytes(); + + let mut d = Decoder::new(&bytes); + let (f1, v1) = d.next_field().unwrap(); + assert_eq!(f1, 1); + assert_eq!(v1.as_varint(), Some(100)); + let (f2, v2) = d.next_field().unwrap(); + assert_eq!(f2, 2); + assert_eq!(v2.as_bytes(), Some(&b"abc"[..])); + let (f7, v7) = d.next_field().unwrap(); + assert_eq!(f7, 7); + let mut sub = Decoder::new(v7.as_bytes().unwrap()); + assert_eq!(sub.next_field().unwrap().1.as_varint(), Some(4)); + assert_eq!( + sub.next_field().unwrap().1.as_bytes(), + Some(&[0xde, 0xad][..]) + ); + assert!(d.next_field().is_none()); + } +} diff --git a/src-tauri/vendor/idevice/src/services/core_device/display_stream/rtcp.rs b/src-tauri/vendor/idevice/src/services/core_device/display_stream/rtcp.rs new file mode 100644 index 0000000..4d14b5f --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/core_device/display_stream/rtcp.rs @@ -0,0 +1,375 @@ +// Jackson Coxson +// +// RTCP for the CoreDevice display stream (RFC 3550, plus AVPF feedback from +// RFC 4585 / RFC 5104). The media transport is plaintext (the negotiated +// SRTPCipherSuite is 0), so RTCP is plaintext too. + +/// RTCP packet types (RFC 3550 §12.1, RFC 4585 §6). +pub const PT_SENDER_REPORT: u8 = 200; +pub const PT_RECEIVER_REPORT: u8 = 201; +pub const PT_SDES: u8 = 202; +pub const PT_BYE: u8 = 203; +pub const PT_APP: u8 = 204; +pub const PT_RTPFB: u8 = 205; +pub const PT_PSFB: u8 = 206; + +/// PSFB feedback message types (the low 5 bits of the first byte). +const FMT_PLI: u8 = 1; // Picture Loss Indication +const FMT_FIR: u8 = 4; // Full Intra Request + +pub fn is_rtcp(buf: &[u8]) -> bool { + buf.len() >= 2 && (PT_SENDER_REPORT..=PT_PSFB).contains(&buf[1]) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SenderReport { + pub ssrc: u32, + pub ntp_middle: u32, +} + +impl SenderReport { + /// Find and parse the first Sender Report in a (possibly compound) RTCP + /// datagram. Returns `None` if there is no SR. + pub fn parse_first(buf: &[u8]) -> Option { + let mut off = 0; + // Walk the compound packet, each sub-packet's length is in 32-bit words + // minus one. + while off + 4 <= buf.len() { + let pt = buf[off + 1]; + let len_words = u16::from_be_bytes([buf[off + 2], buf[off + 3]]) as usize; + let pkt_len = (len_words + 1) * 4; + if pkt_len == 0 || off + pkt_len > buf.len() { + break; + } + // SR body: sender SSRC (4) | NTP MSW (4) | NTP LSW (4) | ... + if pt == PT_SENDER_REPORT && pkt_len >= 16 { + let ssrc = + u32::from_be_bytes([buf[off + 4], buf[off + 5], buf[off + 6], buf[off + 7]]); + let ntp_msw = + u32::from_be_bytes([buf[off + 8], buf[off + 9], buf[off + 10], buf[off + 11]]); + let ntp_lsw = u32::from_be_bytes([ + buf[off + 12], + buf[off + 13], + buf[off + 14], + buf[off + 15], + ]); + let ntp_middle = (ntp_msw << 16) | (ntp_lsw >> 16); + return Some(SenderReport { ssrc, ntp_middle }); + } + off += pkt_len; + } + None + } +} + +#[derive(Debug, Clone, Copy, Default)] +pub struct ReportBlock { + pub source_ssrc: u32, + pub fraction_lost: u8, + pub cumulative_lost: u32, + pub highest_seq: u32, + pub jitter: u32, + pub lsr: u32, + pub dlsr: u32, +} + +fn patch_length(out: &mut [u8], start: usize) { + let words = ((out.len() - start) / 4).saturating_sub(1) as u16; + out[start + 2..start + 4].copy_from_slice(&words.to_be_bytes()); +} + +pub fn build_receiver_report(our_ssrc: u32, blocks: &[ReportBlock]) -> Vec { + let rc = blocks.len().min(31); + let mut out = Vec::with_capacity(8 + rc * 24); + out.push(0x80 | rc as u8); // V=2, P=0, RC + out.push(PT_RECEIVER_REPORT); + out.extend_from_slice(&[0, 0]); // length, patched below + out.extend_from_slice(&our_ssrc.to_be_bytes()); + for b in blocks.iter().take(31) { + out.extend_from_slice(&b.source_ssrc.to_be_bytes()); + out.push(b.fraction_lost); + // Low 24 bits of the cumulative loss count. + out.extend_from_slice(&b.cumulative_lost.to_be_bytes()[1..]); + out.extend_from_slice(&b.highest_seq.to_be_bytes()); + out.extend_from_slice(&b.jitter.to_be_bytes()); + out.extend_from_slice(&b.lsr.to_be_bytes()); + out.extend_from_slice(&b.dlsr.to_be_bytes()); + } + patch_length(&mut out, 0); + out +} + +pub fn build_sdes(our_ssrc: u32, cname: &str) -> Vec { + let mut out = Vec::new(); + out.push(0x80 | 1); // V=2, SC=1 (one chunk) + out.push(PT_SDES); + out.extend_from_slice(&[0, 0]); // length, patched below + out.extend_from_slice(&our_ssrc.to_be_bytes()); + // CNAME item: type 1, length, text. + let bytes = cname.as_bytes(); + let len = bytes.len().min(255); + out.push(1); + out.push(len as u8); + out.extend_from_slice(&bytes[..len]); + // Items end with a type-0 octet; then pad to a 32-bit boundary. + out.push(0); + while out.len() % 4 != 0 { + out.push(0); + } + patch_length(&mut out, 0); + out +} + +/// Build a Picture Loss Indication asking `media_ssrc` for a fresh +/// keyframe +pub fn build_pli(our_ssrc: u32, media_ssrc: u32) -> Vec { + let mut out = Vec::with_capacity(12); + out.push(0x80 | FMT_PLI); // V=2, FMT=PLI + out.push(PT_PSFB); + out.extend_from_slice(&[0, 0]); // length, patched below + out.extend_from_slice(&our_ssrc.to_be_bytes()); + out.extend_from_slice(&media_ssrc.to_be_bytes()); + patch_length(&mut out, 0); + out +} + +/// Build a Full Intra Request for `media_ssrc` +pub fn build_fir(our_ssrc: u32, media_ssrc: u32, seq_nr: u8) -> Vec { + let mut out = Vec::with_capacity(20); + out.push(0x80 | FMT_FIR); // V=2, FMT=FIR + out.push(PT_PSFB); + out.extend_from_slice(&[0, 0]); // length, patched below + out.extend_from_slice(&our_ssrc.to_be_bytes()); + out.extend_from_slice(&0u32.to_be_bytes()); + out.extend_from_slice(&media_ssrc.to_be_bytes()); + out.push(seq_nr); + out.extend_from_slice(&[0, 0, 0]); + patch_length(&mut out, 0); + out +} + +/// The 4-byte "name" of the per-frame acknowledgment APP packet (PT 204), as +/// captured from Apple's Device Hub. +const APP_NAME_FRAME_ACK: [u8; 4] = [0x00, 0x00, 0x00, 0x05]; + +pub fn build_frame_ack(our_ssrc: u32, rtp_timestamp: u32) -> Vec { + let mut out = Vec::with_capacity(16); + out.push(0x80); // V=2, P=0, subtype=0 + out.push(PT_APP); + out.extend_from_slice(&[0, 0]); // length, patched below + out.extend_from_slice(&our_ssrc.to_be_bytes()); + out.extend_from_slice(&APP_NAME_FRAME_ACK); + out.extend_from_slice(&rtp_timestamp.to_be_bytes()); + patch_length(&mut out, 0); + out +} + +/// Build an `RCTL` receiver-control report (RTCP APP, PT 204), the periodic +/// feedback Apple's Device Hub sends roughly every 50ms (alongside the per-frame ACK). +/// AVConference's encoder relies on this for reference/rate management; without +/// it the encoder drifts out of sync with the receiver during heavy motion and +/// the picture corrupts with no decode errors. +/// +/// 32-byte wire format, fields reverse-engineered from a capture: +/// `80 cc 00 07` | our SSRC (4) | `RCTL` (4) | 20-byte body: +/// [0..4] = `85 00 00 04` (constant tag) +/// [4..6] = frame counter (received frames, big-endian) +/// [6..8] = 0 +/// [8..12] = 0 (per-interval metric; 0 = nominal) +/// [12..14]= millisecond clock (for RTT; only the delta matters) +/// [14..16]= 0 +/// [16..18]= highest RTP sequence number received, relative to the base seq +/// [18..20]= 0 +/// The loss/jitter-ish fields are sent as 0, which is accurate for a lossless +/// link (matching what Apple sent on its lossless session). +pub fn build_rctl(our_ssrc: u32, clock_ms: u16, frames: u16, highest_seq: u16) -> Vec { + let mut out = Vec::with_capacity(32); + out.push(0x80); // V=2, P=0, subtype=0 + out.push(PT_APP); + out.extend_from_slice(&[0, 0]); // length, patched below + out.extend_from_slice(&our_ssrc.to_be_bytes()); + out.extend_from_slice(b"RCTL"); + // 20-byte body. + out.extend_from_slice(&[0x85, 0x00, 0x00, 0x04]); + out.extend_from_slice(&frames.to_be_bytes()); + out.extend_from_slice(&[0, 0, 0, 0, 0, 0]); // [6..12] + out.extend_from_slice(&clock_ms.to_be_bytes()); + out.extend_from_slice(&[0, 0]); // [14..16] + out.extend_from_slice(&highest_seq.to_be_bytes()); + out.extend_from_slice(&[0, 0]); // [18..20] + patch_length(&mut out, 0); + out +} + +/// A compound RTCP report for liveness: Receiver Report + SDES(CNAME). Send this +/// periodically so the device knows the receiver is still alive. +pub fn build_liveness(our_ssrc: u32, cname: &str, blocks: &[ReportBlock]) -> Vec { + let mut p = build_receiver_report(our_ssrc, blocks); + p.extend_from_slice(&build_sdes(our_ssrc, cname)); + p +} + +/// A compound RTCP keyframe request: RR + SDES + PLI + FIR. We send both PLI and +/// FIR because which one the AVConference encoder honors isn't guaranteed; both +/// are cheap and an encoder ignores the form it doesn't implement. `fir_seq` must +/// increment per request (see [`build_fir`]). +pub fn build_keyframe_request( + our_ssrc: u32, + cname: &str, + media_ssrc: u32, + blocks: &[ReportBlock], + fir_seq: u8, +) -> Vec { + let mut p = build_liveness(our_ssrc, cname, blocks); + p.extend_from_slice(&build_pli(our_ssrc, media_ssrc)); + p.extend_from_slice(&build_fir(our_ssrc, media_ssrc, fir_seq)); + p +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Every RTCP sub-packet's length field must equal (bytes/4 - 1), and the + /// total length must be a multiple of 4. + fn assert_well_formed(buf: &[u8]) { + assert_eq!(buf.len() % 4, 0, "RTCP packet not 32-bit aligned"); + let mut off = 0; + while off < buf.len() { + assert!(off + 4 <= buf.len(), "truncated sub-packet header"); + assert_eq!(buf[off] >> 6, 2, "version must be 2"); + let len_words = u16::from_be_bytes([buf[off + 2], buf[off + 3]]) as usize; + let pkt_len = (len_words + 1) * 4; + assert!( + off + pkt_len <= buf.len(), + "sub-packet length overruns buffer" + ); + off += pkt_len; + } + assert_eq!(off, buf.len(), "sub-packet lengths don't tile the buffer"); + } + + #[test] + fn is_rtcp_discriminates_from_rtp() { + // RTP video (PT 100), marker clear and set. + assert!(!is_rtcp(&[0x80, 100, 0, 0])); + assert!(!is_rtcp(&[0x80, 0x80 | 100, 0, 0])); + // RTCP SR / RR / PSFB. + assert!(is_rtcp(&[0x80, PT_SENDER_REPORT, 0, 0])); + assert!(is_rtcp(&[0x81, PT_RECEIVER_REPORT, 0, 0])); + assert!(is_rtcp(&[0x81, PT_PSFB, 0, 0])); + } + + #[test] + fn receiver_report_is_well_formed() { + let blocks = [ReportBlock { + source_ssrc: 0xdead_beef, + fraction_lost: 12, + cumulative_lost: 0x01_2345, + highest_seq: 0x0001_8000, + jitter: 42, + lsr: 0xaabb_ccdd, + dlsr: 0x0000_1000, + }]; + let rr = build_receiver_report(0x1234_5678, &blocks); + assert_well_formed(&rr); + assert_eq!(rr[0] & 0x1f, 1, "RC should be 1"); + assert_eq!(rr[1], PT_RECEIVER_REPORT); + // Layout: header(4) | our_ssrc(4) | block{ source_ssrc(4) @8 | + // fraction_lost(1) @12 | cumulative_lost(3) @13 | ... }. + assert_eq!(&rr[8..12], &0xdead_beefu32.to_be_bytes()); + assert_eq!(rr[12], 12, "fraction lost"); + assert_eq!(&rr[13..16], &[0x01, 0x23, 0x45], "cumulative loss (24-bit)"); + } + + #[test] + fn liveness_and_keyframe_request_are_well_formed() { + let blocks = [ReportBlock { + source_ssrc: 1, + ..Default::default() + }]; + assert_well_formed(&build_liveness(7, "host@1.2.3.4", &blocks)); + assert_well_formed(&build_keyframe_request( + 7, + "host@1.2.3.4", + 0xabcd, + &blocks, + 3, + )); + // No report blocks (before we've seen any RTP) is still valid. + assert_well_formed(&build_liveness(7, "h", &[])); + } + + #[test] + fn pli_and_fir_carry_the_right_ssrcs() { + let pli = build_pli(0x1111_1111, 0x2222_2222); + assert_well_formed(&pli); + assert_eq!(pli[0] & 0x1f, FMT_PLI); + assert_eq!(&pli[4..8], &0x1111_1111u32.to_be_bytes()); + assert_eq!(&pli[8..12], &0x2222_2222u32.to_be_bytes()); + + let fir = build_fir(0x1111_1111, 0x2222_2222, 9); + assert_well_formed(&fir); + assert_eq!(fir[0] & 0x1f, FMT_FIR); + assert_eq!(&fir[4..8], &0x1111_1111u32.to_be_bytes()); + assert_eq!( + &fir[8..12], + &0u32.to_be_bytes(), + "FIR media-source SSRC must be 0" + ); + assert_eq!( + &fir[12..16], + &0x2222_2222u32.to_be_bytes(), + "FCI target SSRC" + ); + assert_eq!(fir[16], 9, "FIR seq nr"); + } + + #[test] + fn rctl_is_well_formed_and_carries_fields() { + let r = build_rctl(0x00db_16eb, 12438, 6, 21); + assert_well_formed(&r); + assert_eq!(r.len(), 32); + assert_eq!(r[1], PT_APP); + assert_eq!(&r[8..12], b"RCTL"); + assert_eq!(&r[12..16], &[0x85, 0x00, 0x00, 0x04]); + assert_eq!(&r[16..18], &6u16.to_be_bytes()); // frame counter + assert_eq!(&r[24..26], &12438u16.to_be_bytes()); // clock at body[12..14] + assert_eq!(&r[28..30], &21u16.to_be_bytes()); // highest seq at body[16..18] + } + + #[test] + fn frame_ack_matches_capture() { + // Captured from Apple's Device Hub: 80cc0003 00000005 . + let ack = build_frame_ack(0x00db_16eb, 5600); + assert_well_formed(&ack); + assert_eq!(ack.len(), 16); + assert_eq!( + &ack, + &[ + 0x80, 0xcc, 0x00, 0x03, 0x00, 0xdb, 0x16, 0xeb, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, + 0x15, 0xe0, // 5600 + ] + ); + } + + #[test] + fn parses_first_sender_report() { + // Build an SR by hand: header + ssrc + ntp(8) + rtp ts(4) + counts(8). + let mut sr = vec![0x80, PT_SENDER_REPORT, 0, 6]; + sr.extend_from_slice(&0x0a0b_0c0du32.to_be_bytes()); // ssrc + sr.extend_from_slice(&0x1111_2222u32.to_be_bytes()); // ntp msw + sr.extend_from_slice(&0x3333_4444u32.to_be_bytes()); // ntp lsw + sr.extend_from_slice(&0u32.to_be_bytes()); // rtp ts + sr.extend_from_slice(&0u32.to_be_bytes()); // packet count + sr.extend_from_slice(&0u32.to_be_bytes()); // octet count + // Prepend an unrelated RR to prove we walk the compound packet. + let mut compound = build_receiver_report(0xfeed, &[]); + compound.extend_from_slice(&sr); + + let parsed = SenderReport::parse_first(&compound).expect("should find the SR"); + assert_eq!(parsed.ssrc, 0x0a0b_0c0d); + // Middle 32 bits = low16(msw) << 16 | high16(lsw). + assert_eq!(parsed.ntp_middle, 0x2222_3333); + } +} diff --git a/src-tauri/vendor/idevice/src/services/core_device/display_stream/rtp.rs b/src-tauri/vendor/idevice/src/services/core_device/display_stream/rtp.rs new file mode 100644 index 0000000..7de0f77 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/core_device/display_stream/rtp.rs @@ -0,0 +1,135 @@ +// Jackson Coxson +// +// RTP parsing for the CoreDevice display stream. +// +// The device sends a plaintext RTP stream (no SRTP, the negotiated +// SRTPCipherSuite is 0) to the receiver address/port we hand it in the +// `startvideooutput` request. Video is HEVC, dynamic payload type 100. +// +// This module only parses the RTP framing; HEVC access-unit reassembly and +// decoding build on top of `RtpPacket`. + +/// A parsed RTP packet (RFC 3550), borrowing its payload from the input buffer. +#[derive(Debug, Clone)] +pub struct RtpPacket<'a> { + pub version: u8, + pub padding: bool, + pub extension: bool, + pub marker: bool, + pub payload_type: u8, + pub sequence_number: u16, + pub timestamp: u32, + pub ssrc: u32, + pub csrc: Vec, + /// Profile-specific extension header (id, data) if `extension` is set. + pub ext_profile: u16, + pub ext_data: &'a [u8], + /// The media payload (after CSRC list, extension, and minus any padding). + pub payload: &'a [u8], +} + +impl<'a> RtpPacket<'a> { + /// Parse an RTP packet from a UDP datagram. Returns `None` if malformed. + pub fn parse(buf: &'a [u8]) -> Option { + if buf.len() < 12 { + return None; + } + let b0 = buf[0]; + let version = b0 >> 6; + if version != 2 { + return None; + } + let padding = b0 & 0x20 != 0; + let extension = b0 & 0x10 != 0; + let csrc_count = (b0 & 0x0f) as usize; + + let b1 = buf[1]; + let marker = b1 & 0x80 != 0; + let payload_type = b1 & 0x7f; + + let sequence_number = u16::from_be_bytes([buf[2], buf[3]]); + let timestamp = u32::from_be_bytes([buf[4], buf[5], buf[6], buf[7]]); + let ssrc = u32::from_be_bytes([buf[8], buf[9], buf[10], buf[11]]); + + let mut off = 12; + let mut csrc = Vec::with_capacity(csrc_count); + for _ in 0..csrc_count { + let end = off + 4; + let w = buf.get(off..end)?; + csrc.push(u32::from_be_bytes([w[0], w[1], w[2], w[3]])); + off = end; + } + + let mut ext_profile = 0u16; + let mut ext_data: &[u8] = &[]; + if extension { + let hdr = buf.get(off..off + 4)?; + ext_profile = u16::from_be_bytes([hdr[0], hdr[1]]); + let ext_words = u16::from_be_bytes([hdr[2], hdr[3]]) as usize; + off += 4; + let ext_len = ext_words * 4; + ext_data = buf.get(off..off + ext_len)?; + off += ext_len; + } + + let mut end = buf.len(); + if padding { + // Last byte is the padding length (including itself). + let pad = *buf.last()? as usize; + if pad == 0 || pad > end.saturating_sub(off) { + return None; + } + end -= pad; + } + let payload = buf.get(off..end)?; + + Some(RtpPacket { + version, + padding, + extension, + marker, + payload_type, + sequence_number, + timestamp, + ssrc, + csrc, + ext_profile, + ext_data, + payload, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parses_basic_rtp() { + // V=2, PT=100, marker set, seq=1, ts=2, ssrc=3, payload "hi" + let mut pkt = vec![0x80, 0x80 | 100, 0x00, 0x01, 0, 0, 0, 2, 0, 0, 0, 3]; + pkt.extend_from_slice(b"hi"); + let p = RtpPacket::parse(&pkt).unwrap(); + assert_eq!(p.payload_type, 100); + assert!(p.marker); + assert_eq!(p.sequence_number, 1); + assert_eq!(p.timestamp, 2); + assert_eq!(p.ssrc, 3); + assert_eq!(p.payload, b"hi"); + } + + #[test] + fn parses_extension_header() { + // extension bit set, 1 ext word + let pkt = vec![ + 0x90, 100, 0, 1, 0, 0, 0, 0, 0, 0, 0, 5, // header + 0xBE, 0xDE, 0, 1, // ext profile 0xBEDE, 1 word + 0xAA, 0xBB, 0xCC, 0xDD, // ext data + 0x01, 0x02, // payload + ]; + let p = RtpPacket::parse(&pkt).unwrap(); + assert_eq!(p.ext_profile, 0xBEDE); + assert_eq!(p.ext_data, &[0xAA, 0xBB, 0xCC, 0xDD]); + assert_eq!(p.payload, &[0x01, 0x02]); + } +} diff --git a/src-tauri/vendor/idevice/src/services/core_device/errors.rs b/src-tauri/vendor/idevice/src/services/core_device/errors.rs new file mode 100644 index 0000000..e9a1b66 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/core_device/errors.rs @@ -0,0 +1,38 @@ +//! Errors specific to the CoreDevice services. + +use thiserror::Error; + +/// Failures specific to talking to the device's CoreDevice services. +#[derive(Error, Debug)] +#[non_exhaustive] +pub enum CoreDeviceError { + /// The device returned an error envelope instead of the expected output + /// (typically a populated `CoreDevice.error` in the response). The string is + /// the device's own error detail. + #[error("device returned an error: {0}")] + DeviceError(String), + + /// A field the response was required to contain was absent. + #[error("device response missing required field `{0}`")] + MissingField(&'static str), + + /// A field was present but had a type or shape we couldn't interpret. + #[error("device response field `{0}` had an unexpected type or shape")] + MalformedField(&'static str), + + /// An AVConference media negotiation blob (offer/answer) failed to encode or + /// decode. + #[error("media negotiation blob error: {0}")] + Negotiation(String), +} + +impl CoreDeviceError { + pub fn sub_code(&self) -> i32 { + match self { + Self::DeviceError(_) => 1, + Self::MissingField(_) => 2, + Self::MalformedField(_) => 3, + Self::Negotiation(_) => 4, + } + } +} diff --git a/src-tauri/vendor/idevice/src/services/core_device/hid.rs b/src-tauri/vendor/idevice/src/services/core_device/hid.rs new file mode 100644 index 0000000..7b8ce97 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/core_device/hid.rs @@ -0,0 +1,584 @@ +//! HID back-channel for CoreDevice remote control. Served by the DDI daemon `dtuhidd`. +//! +//! **Authentication gate (applies to EVERY event kind here):** the device drops +//! the synthetic HID events `dtuhidd` posts unless a displayservice media stream +//! is active. This is true for buttons and keyboard just as much as touch. +//! Without the stream the event decodes and dispatches cleanly (the daemon even +//! logs `received peer event`) but nothing happens. Starting a +//! displayservice media stream (see `display_stream`) authenticates the HID +//! surfaces so the events route through to the system as real input. +//! The stream only needs to exist for the duration of the events; +//! its RTP payload can be discarded. + +use serde::Deserialize; +use std::borrow::Cow; +use web_time::{SystemTime, UNIX_EPOCH}; + +use crate::{ + IdeviceError, ReadWrite, RemoteXpcClient, RsdService, obf, + services::core_device::CoreDeviceError, + xpc::{Dictionary, XPCObject}, +}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ButtonState { + Down, + Up, +} + +impl ButtonState { + pub fn raw(self) -> u64 { + match self { + ButtonState::Down => 1, + ButtonState::Up => 2, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DigitizerEventType { + Start, + Position, + End, +} + +impl DigitizerEventType { + pub fn raw(self) -> u64 { + match self { + DigitizerEventType::Start => 0, + DigitizerEventType::Position => 1, + DigitizerEventType::End => 2, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DigitizerEdge { + None, + Top, + Left, + Bottom, + Right, +} + +impl DigitizerEdge { + pub fn raw(self) -> u64 { + match self { + DigitizerEdge::None => 0, + DigitizerEdge::Top => 1, + DigitizerEdge::Left => 2, + DigitizerEdge::Bottom => 3, + DigitizerEdge::Right => 4, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DigitizerTarget { + MainScreen, + Display(u64), +} + +impl DigitizerTarget { + pub fn raw(self) -> u64 { + match self { + DigitizerTarget::MainScreen => 0, + DigitizerTarget::Display(n) => n, + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScrollTarget { + DigitalCrown, + Dial, +} + +impl ScrollTarget { + pub fn raw(self) -> u64 { + match self { + ScrollTarget::DigitalCrown => 0, + ScrollTarget::Dial => 1, + } + } +} + +pub mod scroll_phase { + pub const UNDEFINED: u64 = 0x0; + pub const BEGAN: u64 = 0x1; + pub const CHANGED: u64 = 0x2; + pub const ENDED: u64 = 0x4; + pub const CANCELLED: u64 = 0x8; + pub const MAY_BEGIN: u64 = 0x80; +} + +pub mod scroll_momentum { + pub const UNDEFINED: u64 = 0x0; + pub const CONTINUE: u64 = 0x1; + pub const START: u64 = 0x2; + pub const END: u64 = 0x4; + pub const WILL_BEGIN: u64 = 0x8; + pub const INTERRUPTED: u64 = 0x10; +} + +pub const DIGITIZER_REPORT_ID: u8 = 0x13; +pub const TOUCHSCREEN_REPORT_ID: u8 = 0x09; +pub const TOUCHSCREEN_STATE_CONTACT: u8 = 0xC2; +pub const TOUCHSCREEN_STATE_RELEASE: u8 = 0x02; + +pub const DIGITIZER_SURFACE_MAIN_TOUCHSCREEN: u64 = 257; +pub const DIGITIZER_SURFACE_TOUCHSCREEN_GESTURE: u64 = 1281; + +/// A 48-bit monotonic timestamp for HID reports. The gesture recognizer only +/// cares about monotonicity and inter-frame deltas, so wall-clock nanoseconds +/// (truncated to 48 bits) are sufficient. +fn default_timestamp() -> u64 { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_nanos() as u64) + .unwrap_or(0); + nanos & ((1u64 << 48) - 1) +} + +/// Build a 19-byte gesture/pointer HID report +/// +/// `x`/`y` are signed 32-bit. `timestamp` is a 48-bit monotonic value; pass +/// `None` to use the current wall clock. +/// +/// Layout: `[0x13][x:i32 LE][y:i32 LE][00 00][ts:6 LE][00 00]`. +pub fn build_digitizer_report(x: i32, y: i32, timestamp: Option) -> Vec { + let ts = timestamp.unwrap_or_else(default_timestamp) & ((1u64 << 48) - 1); + let mut r = Vec::with_capacity(19); + r.push(DIGITIZER_REPORT_ID); + r.extend_from_slice(&x.to_le_bytes()); + r.extend_from_slice(&y.to_le_bytes()); + r.extend_from_slice(&[0, 0]); + r.extend_from_slice(&ts.to_le_bytes()[..6]); + r.extend_from_slice(&[0, 0]); + r +} + +/// Build a 58-byte `mainTouchscreen` HID report (report ID `0x09`). +/// +/// `state` is [`TOUCHSCREEN_STATE_CONTACT`] (a touch sample at `x`/`y`) or +/// [`TOUCHSCREEN_STATE_RELEASE`] (lift). `x`/`y` are unsigned 16-bit. Pass +/// `timestamp = None` to use the current wall clock. +/// +/// Layout: `[0x09 0x01 0x05 state][x:u16 LE][y:u16 LE][32×00][02 00 00 00][ts:6 LE][8×00]`. +pub fn build_touchscreen_report(state: u8, x: u16, y: u16, timestamp: Option) -> Vec { + let ts = timestamp.unwrap_or_else(default_timestamp) & ((1u64 << 48) - 1); + let mut r = Vec::with_capacity(58); + r.extend_from_slice(&[TOUCHSCREEN_REPORT_ID, 0x01, 0x05, state]); + r.extend_from_slice(&x.to_le_bytes()); + r.extend_from_slice(&y.to_le_bytes()); + r.extend_from_slice(&[0u8; 32]); + r.extend_from_slice(&[0x02, 0x00, 0x00, 0x00]); + r.extend_from_slice(&ts.to_le_bytes()[..6]); + r.extend_from_slice(&[0u8; 8]); + r +} + +/// Generic Indigo HID events. +/// +/// `com.apple.coredevice.hid.indigo`. +#[derive(Debug)] +pub struct IndigoHidClient { + inner: RemoteXpcClient, +} + +impl RsdService for IndigoHidClient> { + fn rsd_service_name() -> Cow<'static, str> { + obf!("com.apple.coredevice.hid.indigo") + } + + async fn from_stream(stream: Box) -> Result { + let mut inner = RemoteXpcClient::new(stream).await?; + inner.do_handshake().await?; + Ok(Self { inner }) + } +} + +impl IndigoHidClient { + pub fn new(inner: RemoteXpcClient) -> Self { + Self { inner } + } + + /// Wrap `payload` in the shared `{messageType, payload, featureIdentifier}` + /// envelope and send it one-way (no reply expected). This is the single + /// dispatch path every Indigo event kind shares. + async fn send_event( + &mut self, + message_type: &str, + feature_identifier: Cow<'static, str>, + payload: Dictionary, + ) -> Result<(), IdeviceError> { + let mut msg = Dictionary::new(); + msg.insert( + "messageType".into(), + XPCObject::String(message_type.to_string()), + ); + msg.insert("payload".into(), XPCObject::Dictionary(payload)); + msg.insert( + "featureIdentifier".into(), + XPCObject::String(feature_identifier.into()), + ); + self.inner.send_object(msg, false).await + } + + /// Send an `IndigoButtonEvent`: a single hardware-button state change. + /// + /// * `usage_page` - HID usage page (e.g. `0x0C` Consumer for media keys, + /// `0x01` Generic Desktop for power/sleep). + /// * `usage_code` - HID usage within that page. + /// * `state` - [`ButtonState::Down`] or [`ButtonState::Up`]. + pub async fn send_button( + &mut self, + usage_page: u64, + usage_code: u64, + state: ButtonState, + ) -> Result<(), IdeviceError> { + let mut payload = Dictionary::new(); + payload.insert("state".into(), XPCObject::UInt64(state.raw())); + payload.insert("usagePage".into(), XPCObject::UInt64(usage_page)); + payload.insert("usageCode".into(), XPCObject::UInt64(usage_code)); + self.send_event( + "IndigoButtonEvent", + obf!("com.apple.coredevice.feature.remote.hid.button"), + payload, + ) + .await + } + + /// Send an `IndigoKeyboardButtonEvent`: a single keyboard key state change. + /// + /// * `usage_code` - HID Keyboard/Keypad page (`0x07`) usage, e.g. `0x04`=`a`, + /// `0x28`=Return, `0x2A`=Backspace, `0xE1`=Left Shift. The usage page is + /// implicit (keyboard); the device routes this to its `mainKeyboard` + /// surface. + /// * `state` - [`ButtonState::Down`] or [`ButtonState::Up`]. + /// + /// To type a character that needs a modifier (uppercase, symbols), press the + /// modifier key (e.g. `0xE1`) down, then the key down/up, then the modifier + /// up. + pub async fn send_keyboard( + &mut self, + usage_code: u64, + state: ButtonState, + ) -> Result<(), IdeviceError> { + let mut payload = Dictionary::new(); + payload.insert("usageCode".into(), XPCObject::UInt64(usage_code)); + payload.insert("state".into(), XPCObject::UInt64(state.raw())); + self.send_event( + "IndigoKeyboardButtonEvent", + obf!("com.apple.coredevice.feature.remote.hid.keyboard"), + payload, + ) + .await + } + + /// Send an `IndigoDigitizerEvent`. + /// + /// This is the higher-level digitizer path (distinct from the raw report + /// path on [`UniversalHidServiceClient`]). With `edge` = [`DigitizerEdge::None`] + /// it is a plain touch/drag at `point_one` (and optionally a second contact + /// `point_two`); with a non-`None` edge it becomes an edge-swipe system + /// gesture. Coordinates are `f64` in the display's pixel space. + pub async fn send_digitizer( + &mut self, + point_one: (f64, f64), + point_two: Option<(f64, f64)>, + event_type: DigitizerEventType, + edge: DigitizerEdge, + target: DigitizerTarget, + ) -> Result<(), IdeviceError> { + fn point(x: f64, y: f64) -> XPCObject { + let mut p = Dictionary::new(); + p.insert("x".into(), XPCObject::Double(x)); + p.insert("y".into(), XPCObject::Double(y)); + XPCObject::Dictionary(p) + } + + let mut payload = Dictionary::new(); + payload.insert("pointOne".into(), point(point_one.0, point_one.1)); + // `pointTwo` is an `Optional` decoded with `decodeIfPresent`; omit the + // key entirely when there's no second contact. + if let Some((x, y)) = point_two { + payload.insert("pointTwo".into(), point(x, y)); + } + payload.insert("eventType".into(), XPCObject::UInt64(event_type.raw())); + payload.insert("edge".into(), XPCObject::UInt64(edge.raw())); + payload.insert("target".into(), XPCObject::UInt64(target.raw())); + self.send_event( + "IndigoDigitizerEvent", + obf!("com.apple.coredevice.feature.remote.hid.digitizer"), + payload, + ) + .await + } + + /// Send an `IndigoScrollEvent` (digital crown / dial scrolling). + /// + /// * `point` - scroll delta `(x, y, z)` as `f64`. + /// * `phase` - bitmask from [`scroll_phase`]. + /// * `momentum` - bitmask from [`scroll_momentum`]. + /// * `target` - [`ScrollTarget::DigitalCrown`] or [`ScrollTarget::Dial`]. + pub async fn send_scroll( + &mut self, + point: (f64, f64, f64), + phase: u64, + momentum: u64, + target: ScrollTarget, + ) -> Result<(), IdeviceError> { + let mut p = Dictionary::new(); + p.insert("x".into(), XPCObject::Double(point.0)); + p.insert("y".into(), XPCObject::Double(point.1)); + p.insert("z".into(), XPCObject::Double(point.2)); + + let mut payload = Dictionary::new(); + payload.insert("point".into(), XPCObject::Dictionary(p)); + payload.insert("phase".into(), XPCObject::UInt64(phase)); + payload.insert("momentum".into(), XPCObject::UInt64(momentum)); + payload.insert("target".into(), XPCObject::UInt64(target.raw())); + self.send_event( + "IndigoScrollEvent", + obf!("com.apple.coredevice.feature.remote.hid.scroll"), + payload, + ) + .await + } + + /// Send an `IndigoVendorDefinedEvent`: a raw vendor-defined HID report + /// (routed to the device's `avpCustom` surface). + /// + /// * `usage_page` / `usage` - the vendor usage. + /// * `version` - vendor event version. + /// * `data` - the opaque report bytes. + pub async fn send_vendor_defined( + &mut self, + usage_page: u64, + usage: u64, + version: u64, + data: Vec, + ) -> Result<(), IdeviceError> { + let mut payload = Dictionary::new(); + payload.insert("usagePage".into(), XPCObject::UInt64(usage_page)); + payload.insert("usage".into(), XPCObject::UInt64(usage)); + payload.insert("version".into(), XPCObject::UInt64(version)); + payload.insert("data".into(), XPCObject::Data(data)); + self.send_event( + "IndigoVendorDefinedEvent", + obf!("com.apple.coredevice.feature.remote.hid.vendordefined"), + payload, + ) + .await + } +} + +/// A HID surface the device has registered, as returned by +/// [`UniversalHidServiceClient::list_connected_services`]. The device also +/// reports a verbose `_CoreDevice_codablePropertyStorage` mirror of these +/// fields, which this skips. +#[derive(Debug, Clone, Deserialize)] +pub struct HidSurface { + /// The surface's identifier — the `service_id` to pass to + /// [`UniversalHidServiceClient::send_report`]. + #[serde(rename = "_ServiceID")] + pub service_id: u64, + /// Human-readable product string, e.g. `"CoreDevice touchscreen(nil)"`. + #[serde(rename = "Product")] + pub product: Option, + /// The surface's primary HID usage. + #[serde(rename = "PrimaryUsage")] + pub primary_usage: Option, + /// The surface's primary HID usage page. + #[serde(rename = "PrimaryUsagePage")] + pub primary_usage_page: Option, +} + +/// Inspect and drive the device's registered HID surfaces. +#[derive(Debug)] +pub struct UniversalHidServiceClient { + inner: RemoteXpcClient, +} + +impl RsdService for UniversalHidServiceClient> { + fn rsd_service_name() -> Cow<'static, str> { + obf!("com.apple.coredevice.hid.universalhidservice") + } + + async fn from_stream(stream: Box) -> Result { + let mut inner = RemoteXpcClient::new(stream).await?; + inner.do_handshake().await?; + Ok(Self { inner }) + } +} + +impl UniversalHidServiceClient { + pub fn new(inner: RemoteXpcClient) -> Self { + Self { inner } + } + + /// Build the `{featureIdentifier, messageType: "Request", payload}` envelope + /// these requests share. + fn request(payload: Dictionary) -> Dictionary { + let mut msg = Dictionary::new(); + let universal_hid_feature: Cow<'static, str> = + obf!("com.apple.coredevice.feature.remote.universalhidservice"); + msg.insert( + "featureIdentifier".into(), + XPCObject::String(universal_hid_feature.into()), + ); + msg.insert("messageType".into(), XPCObject::String("Request".into())); + msg.insert("payload".into(), XPCObject::Dictionary(payload)); + msg + } + + /// Enumerate the device's currently-registered HID surfaces. + pub async fn list_connected_services(&mut self) -> Result, IdeviceError> { + let mut payload = Dictionary::new(); + payload.insert( + "connectedServices".into(), + XPCObject::Dictionary(Dictionary::new()), + ); + let msg = Self::request(payload); + self.inner.send_object(msg, true).await?; + let res = self.inner.recv().await?; + + let services = res + .as_dictionary() + .and_then(|d| d.get("connectedServices")) + .ok_or(CoreDeviceError::MissingField("connectedServices"))?; + plist::from_value(services) + .map_err(|_| CoreDeviceError::MalformedField("connectedServices").into()) + } + + /// Deliver a raw HID report to one of the device's HID surfaces. + pub async fn send_report( + &mut self, + service_id: u64, + report: Vec, + ) -> Result<(), IdeviceError> { + // `send` is a Swift tuple `(_0: report, _1: serviceID)`. + let payload = crate::xpc!({ + "send": { + "_0": report, + "_1": service_id + } + }) + .to_dictionary() + .unwrap(); + + let msg = Self::request(payload); + self.inner.send_object(msg, false).await + } + + /// Send a single 19-byte gesture/pointer report at (`x`, `y`). + /// For an actual on-screen touch use + /// [`send_touchscreen`](Self::send_touchscreen). + pub async fn send_digitizer( + &mut self, + x: i32, + y: i32, + service_id: u64, + timestamp: Option, + ) -> Result<(), IdeviceError> { + self.send_report(service_id, build_digitizer_report(x, y, timestamp)) + .await + } + + /// Send a single 58-byte `mainTouchscreen` report. `state` is + /// [`TOUCHSCREEN_STATE_CONTACT`] for an in-progress touch sample or + /// [`TOUCHSCREEN_STATE_RELEASE`] to lift. + pub async fn send_touchscreen( + &mut self, + state: u8, + x: u16, + y: u16, + timestamp: Option, + ) -> Result<(), IdeviceError> { + self.send_report( + DIGITIZER_SURFACE_MAIN_TOUCHSCREEN, + build_touchscreen_report(state, x, y, timestamp), + ) + .await + } + + /// A tap on the touchscreen: one contact sample, a short hold, then a + /// release at the same point. + pub async fn tap(&mut self, x: u16, y: u16) -> Result<(), IdeviceError> { + self.send_touchscreen(TOUCHSCREEN_STATE_CONTACT, x, y, None) + .await?; + crate::time::sleep(std::time::Duration::from_millis(50)).await; + self.send_touchscreen(TOUCHSCREEN_STATE_RELEASE, x, y, None) + .await + } + + /// A drag on the touchscreen from (`x1`, `y1`) to (`x2`, `y2`): a stream of + /// `steps` contact samples advancing linearly, a final contact at the end + /// point, then a release. `delay_ms` is slept between samples so the gesture + /// recognizer sees a velocity (a too-fast drag reads as a tap). This is the + /// real touch-drag used for scrolling/swiping content. `steps` is clamped to + /// at least 1. + pub async fn drag( + &mut self, + x1: u16, + y1: u16, + x2: u16, + y2: u16, + steps: u32, + delay_ms: u64, + ) -> Result<(), IdeviceError> { + let steps = steps.max(1); + for i in 0..steps { + let t = i as f64 / steps as f64; + let x = (x1 as f64 + (x2 as f64 - x1 as f64) * t).round() as u16; + let y = (y1 as f64 + (y2 as f64 - y1 as f64) * t).round() as u16; + self.send_touchscreen(TOUCHSCREEN_STATE_CONTACT, x, y, None) + .await?; + if delay_ms > 0 { + crate::time::sleep(std::time::Duration::from_millis(delay_ms)).await; + } + } + self.send_touchscreen(TOUCHSCREEN_STATE_CONTACT, x2, y2, None) + .await?; + self.send_touchscreen(TOUCHSCREEN_STATE_RELEASE, x2, y2, None) + .await + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn digitizer_report_layout() { + let r = build_digitizer_report(100, -50, Some(0x0102030405)); + assert_eq!(r.len(), 19); + assert_eq!(r[0], DIGITIZER_REPORT_ID); + assert_eq!(&r[1..5], &100i32.to_le_bytes()); + assert_eq!(&r[5..9], &(-50i32).to_le_bytes()); + assert_eq!(&r[9..11], &[0, 0]); + assert_eq!(&r[11..17], &0x0102030405u64.to_le_bytes()[..6]); + assert_eq!(&r[17..19], &[0, 0]); + } + + #[test] + fn touchscreen_report_layout() { + let r = build_touchscreen_report(TOUCHSCREEN_STATE_CONTACT, 375, 812, Some(0xAABBCCDD)); + assert_eq!(r.len(), 58); + assert_eq!(&r[0..4], &[0x09, 0x01, 0x05, 0xC2]); + assert_eq!(&r[4..6], &375u16.to_le_bytes()); + assert_eq!(&r[6..8], &812u16.to_le_bytes()); + assert_eq!(&r[8..40], &[0u8; 32]); + assert_eq!(&r[40..44], &[0x02, 0x00, 0x00, 0x00]); + assert_eq!(&r[44..50], &0xAABBCCDDu64.to_le_bytes()[..6]); + assert_eq!(&r[50..58], &[0u8; 8]); + } + + #[test] + fn timestamp_is_truncated_to_48_bits() { + // A timestamp above 48 bits must be masked, not overflow the 6-byte field. + let r = build_digitizer_report(0, 0, Some(u64::MAX)); + assert_eq!(&r[11..17], &[0xFF; 6]); + } +} diff --git a/src-tauri/vendor/idevice/src/services/core_device/location_service.rs b/src-tauri/vendor/idevice/src/services/core_device/location_service.rs new file mode 100644 index 0000000..c9cb394 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/core_device/location_service.rs @@ -0,0 +1,61 @@ +// Jackson Coxson + +use serde::Deserialize; + +use crate::{IdeviceError, ReadWrite, RsdService, obf}; + +use super::CoreDeviceError; + +/// A built-in location-simulation scenario the device offers. +#[derive(Debug, Clone, Deserialize)] +pub struct LocationScenario { + /// The scenario's identifier, e.g. `"City Run"`. + pub name: String, + /// The display name (often the same as `name`). + #[serde(rename = "localizedName")] + pub localized_name: String, +} + +impl RsdService for LocationServiceClient> { + fn rsd_service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.coredevice.locationservice") + } + + async fn from_stream(stream: Box) -> Result { + Ok(Self { + inner: super::CoreDeviceServiceClient::new(stream).await?, + }) + } +} + +#[derive(Debug)] +pub struct LocationServiceClient { + inner: super::CoreDeviceServiceClient, +} + +impl LocationServiceClient { + pub fn new(inner: super::CoreDeviceServiceClient) -> Self { + Self { inner } + } + + /// List the device's built-in location-simulation scenarios. + pub async fn available_location_scenarios( + &mut self, + ) -> Result, IdeviceError> { + let res = self + .inner + .invoke_with_plist_action( + obf!("com.apple.coredevice.feature.simulatelocation"), + plist::Dictionary::new(), + obf!("com.apple.coredevice.action.availablelocationscenarios"), + ) + .await?; + + let scenarios = res + .as_dictionary() + .and_then(|d| d.get("scenarios")) + .ok_or(CoreDeviceError::MissingField("scenarios"))?; + plist::from_value(scenarios) + .map_err(|_| CoreDeviceError::MalformedField("scenarios").into()) + } +} diff --git a/src-tauri/vendor/idevice/src/services/core_device/mod.rs b/src-tauri/vendor/idevice/src/services/core_device/mod.rs new file mode 100644 index 0000000..d201819 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/core_device/mod.rs @@ -0,0 +1,168 @@ +// Jackson Coxson +// Ported from pymobiledevice3 + +use tracing::warn; + +use crate::{ + IdeviceError, ReadWrite, RemoteXpcClient, + xpc::{self, XPCObject}, +}; + +mod app_service; +mod diagnosticsservice; +#[cfg(feature = "display_stream")] +pub mod display_stream; +mod errors; +#[cfg(feature = "display_stream")] +pub mod hid; +mod location_service; +mod openstdiosocket; +mod orientation_service; +mod pasteboard_service; +mod screencaptureservices; +pub use app_service::*; +pub use diagnosticsservice::*; +#[cfg(feature = "display_stream")] +pub use display_stream::*; +pub use errors::CoreDeviceError; +#[cfg(feature = "display_stream")] +pub use hid::*; +pub use location_service::*; +pub use openstdiosocket::*; +pub use orientation_service::*; +pub use pasteboard_service::*; +pub use screencaptureservices::*; + +const CORE_SERVICE_VERSION: &str = "443.18"; + +#[derive(Debug)] +pub struct CoreDeviceServiceClient { + inner: RemoteXpcClient, +} + +impl CoreDeviceServiceClient { + pub async fn new(inner: R) -> Result { + let mut client = RemoteXpcClient::new(inner).await?; + client.do_handshake().await?; + Ok(Self { inner: client }) + } + + pub async fn invoke_with_plist( + &mut self, + feature: impl Into, + input: plist::Dictionary, + ) -> Result { + let input: XPCObject = plist::Value::Dictionary(input).into(); + let input = input.to_dictionary().unwrap(); + self.invoke(feature, Some(input)).await + } + + pub async fn invoke_with_plist_action( + &mut self, + feature: impl Into, + input: plist::Dictionary, + action_identifier: impl Into, + ) -> Result { + let input: XPCObject = plist::Value::Dictionary(input).into(); + let input = input.to_dictionary().unwrap(); + self.invoke_inner(feature, Some(input), Some(action_identifier.into())) + .await + } + + pub async fn invoke( + &mut self, + feature: impl Into, + input: Option, + ) -> Result { + self.invoke_inner(feature, input, None).await + } + + async fn invoke_inner( + &mut self, + feature: impl Into, + input: Option, + action_identifier: Option, + ) -> Result { + let feature = feature.into(); + let input: crate::xpc::XPCObject = match input { + Some(i) => i.into(), + None => crate::xpc::Dictionary::new().into(), + }; + + let mut req = xpc::Dictionary::new(); + let protocol_version = if action_identifier.is_some() { 2 } else { 0 }; + req.insert( + "CoreDevice.CoreDeviceDDIProtocolVersion".into(), + XPCObject::Int64(protocol_version), + ); + req.insert("CoreDevice.action".into(), xpc::Dictionary::new().into()); + req.insert( + "CoreDevice.coreDeviceVersion".into(), + create_xpc_version_from_string(CORE_SERVICE_VERSION).into(), + ); + req.insert( + "CoreDevice.deviceIdentifier".into(), + XPCObject::String(uuid::Uuid::new_v4().to_string()), + ); + req.insert( + "CoreDevice.featureIdentifier".into(), + XPCObject::String(feature), + ); + req.insert("CoreDevice.input".into(), input); + req.insert( + "CoreDevice.invocationIdentifier".into(), + XPCObject::String(uuid::Uuid::new_v4().to_string()), + ); + if let Some(action_identifier) = action_identifier { + req.insert( + "CoreDevice.actionIdentifier".into(), + XPCObject::String(action_identifier), + ); + } + + self.inner.send_object(req, true).await?; + let res = self.inner.recv().await?; + let mut res = match res { + plist::Value::Dictionary(d) => d, + _ => { + warn!("XPC response was not a dictionary"); + return Err(CoreDeviceError::MalformedField("(root)").into()); + } + }; + + let res = match res.remove("CoreDevice.output") { + Some(r) => r, + None => { + // The device replied with an error rather than an output. Surface + // its contents (commonly under "CoreDevice.error") so callers can + // see why a feature invocation was rejected. + warn!("XPC response did not have an output: {res:?}"); + return match res.get("CoreDevice.error") { + Some(e) => Err(CoreDeviceError::DeviceError(format!("{e:?}")).into()), + None => Err(CoreDeviceError::MissingField("CoreDevice.output").into()), + }; + } + }; + + Ok(res) + } +} + +fn create_xpc_version_from_string(version: impl Into) -> xpc::Dictionary { + let version: String = version.into(); + let mut collected_version = Vec::new(); + version.split('.').for_each(|x| { + if let Ok(x) = x.parse() { + collected_version.push(XPCObject::UInt64(x)); + } + }); + + let mut res = xpc::Dictionary::new(); + res.insert( + "originalComponentsCount".into(), + XPCObject::Int64(collected_version.len() as i64), + ); + res.insert("components".into(), XPCObject::Array(collected_version)); + res.insert("stringValue".into(), XPCObject::String(version)); + res +} diff --git a/src-tauri/vendor/idevice/src/services/core_device/openstdiosocket.rs b/src-tauri/vendor/idevice/src/services/core_device/openstdiosocket.rs new file mode 100644 index 0000000..730f73f --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/core_device/openstdiosocket.rs @@ -0,0 +1,34 @@ +// Jackson Coxson + +use tokio::io::AsyncReadExt; + +use crate::{IdeviceError, ReadWrite, RsdService, obf}; + +impl RsdService for OpenStdioSocketClient { + fn rsd_service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.coredevice.openstdiosocket") + } + + async fn from_stream(stream: Box) -> Result { + Ok(Self { inner: stream }) + } +} + +/// Call ``read_uuid`` to get the UUID. Pass that to app service launch to connect to the stream of +/// the launched app. Inner is exposed to read and write to, using Tokio's AsyncReadExt/AsyncWriteExt +#[derive(Debug)] +pub struct OpenStdioSocketClient { + pub inner: Box, +} + +impl OpenStdioSocketClient { + /// iOS assigns a UUID to a newly opened stream. That UUID is then passed to the launch + /// parameters of app service to start a stream. + pub async fn read_uuid(&mut self) -> Result { + let mut buf = [0u8; 16]; + self.inner.read_exact(&mut buf).await?; + + let res = uuid::Uuid::from_bytes(buf); + Ok(res) + } +} diff --git a/src-tauri/vendor/idevice/src/services/core_device/orientation_service.rs b/src-tauri/vendor/idevice/src/services/core_device/orientation_service.rs new file mode 100644 index 0000000..bc31247 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/core_device/orientation_service.rs @@ -0,0 +1,133 @@ +//! Programmatic device rotation via the `com.apple.coredevice.devicecontrol` +//! RemoteXPC service. + +use std::borrow::Cow; + +use crate::{IdeviceError, ReadWrite, RemoteXpcClient, RsdService, obf, xpc}; + +use super::CoreDeviceError; + +/// Which way to rotate the device by 90 degrees. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum RotationDirection { + /// Counter-clockwise. + Left, + /// Clockwise. + Right, +} + +impl RotationDirection { + /// The `rotate` wire value. + pub fn as_str(self) -> &'static str { + match self { + RotationDirection::Left => "left", + RotationDirection::Right => "right", + } + } +} + +/// A device orientation as reported by `devicecontrol` +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Orientation { + Portrait, + PortraitUpsideDown, + LandscapeLeft, + LandscapeRight, + FaceUp, + FaceDown, + /// A value the device reported that this crate doesn't have a variant for. + Unknown(String), +} + +impl Orientation { + fn from_wire(s: &str) -> Self { + match s { + "portrait" => Orientation::Portrait, + "portraitUpsideDown" => Orientation::PortraitUpsideDown, + "landscapeLeft" => Orientation::LandscapeLeft, + "landscapeRight" => Orientation::LandscapeRight, + "faceUp" => Orientation::FaceUp, + "faceDown" => Orientation::FaceDown, + other => Orientation::Unknown(other.to_string()), + } + } +} + +/// The device's orientation after a rotation request. +#[derive(Debug, Clone)] +pub struct OrientationState { + pub orientation: Orientation, + pub non_flat_orientation: Orientation, + /// whether rotation lock is engaged. + pub locked: bool, +} + +#[derive(Debug)] +pub struct OrientationServiceClient { + inner: RemoteXpcClient, +} + +impl RsdService for OrientationServiceClient> { + fn rsd_service_name() -> Cow<'static, str> { + obf!("com.apple.coredevice.devicecontrol") + } + + async fn from_stream(stream: Box) -> Result { + let mut inner = RemoteXpcClient::new(stream).await?; + inner.do_handshake().await?; + Ok(Self { inner }) + } +} + +impl OrientationServiceClient { + pub fn new(inner: RemoteXpcClient) -> Self { + Self { inner } + } + + /// Rotate the device 90 degrees in `direction`, returning the device's + /// resulting [`OrientationState`]. + pub async fn rotate( + &mut self, + direction: RotationDirection, + ) -> Result { + let id: Cow = obf!("com.apple.coredevice.feature.remote.devicecontrol.orientation"); + let msg = xpc!({ + "featureIdentifier": id.to_string(), + "messageType": "OrientationRequest", + "payload": { + "rotate": { + "_0": direction.as_str() + } + } + }); + + self.inner.send_object(msg, true).await?; + let res = self.inner.recv().await?; + + let dict = res + .as_dictionary() + .ok_or(CoreDeviceError::MalformedField("(root)"))?; + let orientation = dict + .get("currentDeviceOrientation") + .and_then(|v| v.as_string()) + .ok_or(CoreDeviceError::MissingField("currentDeviceOrientation"))?; + let non_flat = dict + .get("currentDeviceNonFlatOrientation") + .and_then(|v| v.as_string()) + .ok_or(CoreDeviceError::MissingField( + "currentDeviceNonFlatOrientation", + ))?; + let locked = dict + .get("currentDeviceOrientationLocked") + .and_then(|v| v.as_boolean()) + .ok_or(CoreDeviceError::MissingField( + "currentDeviceOrientationLocked", + ))?; + + Ok(OrientationState { + orientation: Orientation::from_wire(orientation), + non_flat_orientation: Orientation::from_wire(non_flat), + locked, + }) + } +} diff --git a/src-tauri/vendor/idevice/src/services/core_device/pasteboard_service.rs b/src-tauri/vendor/idevice/src/services/core_device/pasteboard_service.rs new file mode 100644 index 0000000..b80ada7 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/core_device/pasteboard_service.rs @@ -0,0 +1,444 @@ +//! Read/write the device pasteboard via the +//! `com.apple.coredevice.pasteboardservice` RemoteXPC service. +//! +//! Unlike the DDI features, the service speaks XPC dicts directly: the +//! `command` field drives dispatch. Six verbs exist (request -> reply): +//! +//! * `PULL` -> `PULL_REPLY` read the pasteboard +//! * `SET` -> `SET_REPLY` replace the pasteboard +//! * `RESOLVE` -> `DATA` fetch the bytes of a promised item +//! * `AUTONOTIFY` + `PUSH` subscribe to change notifications (clipboard sync) +//! +//! A `PasteboardItem` is `{types: [UTI], data: {UTI: PasteboardItemData}}`. A +//! `PasteboardItemData` encodes as `{data: }` when its bytes are inline, +//! `{size: }` when the item is *promised* (fetch later with `RESOLVE`), +//! or `{error: ...}` when resolution failed. These map to +//! [`PasteboardPayload`]. + +use std::borrow::Cow; + +use crate::{ + IdeviceError, ReadWrite, RemoteXpcClient, RsdService, obf, xpc, + xpc::{Dictionary, XPCObject}, +}; + +use super::CoreDeviceError; + +pub const GENERAL_PASTEBOARD: &str = "general"; + +const UTI_UTF8_PLAIN_TEXT: &str = "public.utf8-plain-text"; +const UTI_PLAIN_TEXT: &str = "public.plain-text"; +const UTI_TEXT: &str = "public.text"; + +const TEXT_UTIS: [&str; 3] = [UTI_UTF8_PLAIN_TEXT, UTI_PLAIN_TEXT, UTI_TEXT]; + +/// Image UTIs we recognise on the pasteboard, in priority order. PNG first +/// (lossless, what we write back), then JPEG and TIFF. +pub const UTI_PNG: &str = "public.png"; +pub const UTI_JPEG: &str = "public.jpeg"; +pub const UTI_TIFF: &str = "public.tiff"; + +const IMAGE_UTIS: [&str; 3] = [UTI_PNG, UTI_JPEG, UTI_TIFF]; + +/// `PasteboardDataInclusionPolicy` tells the device whether to inline item +/// bytes in a `PULL`/`PUSH` snapshot or hand them back as promises to fetch +/// with `RESOLVE`. +#[derive(Debug, Clone, Copy)] +pub enum DataInclusionPolicy { + /// Resolve and inline every item's bytes. Simplest for callers; can be + /// expensive for large items. + AllResolved, + /// Promise every item; fetch bytes on demand with [`resolve`](PasteboardServiceClient::resolve). + AllPromised, + /// Mirror whatever the source set on the device. + MatchSource, + /// Resolve the primary item, promise the rest. + PromiseSecondary, + /// Inline items smaller than `bytes`, promise the rest. + Threshold(i64), +} + +impl DataInclusionPolicy { + fn to_xpc(self) -> XPCObject { + let (key, value) = match self { + DataInclusionPolicy::AllResolved => ("allResolved", Dictionary::new()), + DataInclusionPolicy::AllPromised => ("allPromised", Dictionary::new()), + DataInclusionPolicy::MatchSource => ("matchSource", Dictionary::new()), + DataInclusionPolicy::PromiseSecondary => ("promiseSecondary", Dictionary::new()), + DataInclusionPolicy::Threshold(bytes) => { + let mut inner = Dictionary::new(); + inner.insert("bytes".into(), XPCObject::Int64(bytes)); + ("thresholdData", inner) + } + }; + let mut d = Dictionary::new(); + d.insert(key.into(), XPCObject::Dictionary(value)); + XPCObject::Dictionary(d) + } +} + +/// The bytes (or absence thereof) for one UTI of a [`PasteboardItem`]. +#[derive(Debug, Clone)] +pub enum PasteboardPayload { + /// The bytes were inlined in the snapshot. + Inline(Vec), + /// The item is promised: fetch with [`PasteboardServiceClient::resolve`]. + /// `size` is the device's advertised byte count when known. + Promised { size: Option }, + /// The device reported an error resolving this UTI, rendered for diagnostics. + Error(String), +} + +/// One UTI's payload within a [`PasteboardItem`]. +#[derive(Debug, Clone)] +pub struct PasteboardEntry { + pub uti: String, + pub payload: PasteboardPayload, +} + +/// A single pasteboard item: its declared UTIs (in priority order) and the +/// payload for each. +#[derive(Debug, Clone)] +pub struct PasteboardItem { + /// Position in the snapshot — the `item_index` to pass to + /// [`PasteboardServiceClient::resolve`]. + pub index: usize, + /// Declared UTIs in priority order. + pub types: Vec, + /// Per-UTI payloads. + pub data: Vec, +} + +/// A parsed `PULL_REPLY`/`PUSH` pasteboard snapshot. +#[derive(Debug, Clone)] +pub struct PasteboardSnapshot { + /// The pasteboard the snapshot is for, e.g. [`GENERAL_PASTEBOARD`]. + pub pasteboard_name: Option, + /// Monotonic change counter; bumps on every pasteboard mutation. + pub change_count: Option, + pub items: Vec, +} + +/// A promised (not-inlined) `(item, UTI)` pair to fetch with +/// [`PasteboardServiceClient::resolve`]. +#[derive(Debug, Clone)] +pub struct PromisedItem { + pub item_index: i64, + pub uti: String, + pub size: Option, +} + +impl PasteboardSnapshot { + /// Parse a `PULL_REPLY`/`PUSH` message. Both nest the snapshot under a + /// `pasteboard` key; a bare snapshot dict is also accepted. + fn from_message(msg: &plist::Value) -> Result { + let dict = msg + .as_dictionary() + .ok_or(CoreDeviceError::MalformedField("(root)"))?; + // PULL_REPLY/PUSH nest the snapshot under "pasteboard"; tolerate a bare + // snapshot too. + let snapshot = dict + .get("pasteboard") + .and_then(|p| p.as_dictionary()) + .unwrap_or(dict); + + let metadata = snapshot.get("metadata").and_then(|m| m.as_dictionary()); + let pasteboard_name = metadata + .and_then(|m| m.get("pasteboardName")) + .and_then(|n| n.as_string()) + .map(str::to_string); + let change_count = metadata + .and_then(|m| m.get("changeCount")) + .and_then(|c| c.as_signed_integer()); + + let items = snapshot + .get("items") + .and_then(|i| i.as_array()) + .map(|arr| { + arr.iter() + .enumerate() + .map(|(index, item)| parse_item(index, item)) + .collect() + }) + .unwrap_or_default(); + + Ok(PasteboardSnapshot { + pasteboard_name, + change_count, + items, + }) + } + + /// Best-effort UTF-8 text: the first text UTI carrying inline bytes. + pub fn text(&self) -> Option { + for item in &self.items { + for uti in TEXT_UTIS { + if let Some(entry) = item.data.iter().find(|e| e.uti == uti) + && let PasteboardPayload::Inline(bytes) = &entry.payload + && let Ok(text) = String::from_utf8(bytes.clone()) + { + return Some(text); + } + } + } + None + } + + /// Best-effort image bytes: the first image UTI (PNG/JPEG/TIFF, in priority + /// order) carrying inline bytes, returned with the UTI that produced them so + /// the caller knows the encoding. Promised items are skipped — pull with + /// [`DataInclusionPolicy::AllResolved`] (the default for [`get`](PasteboardServiceClient::get)) + /// to inline them, or fetch with [`resolve`](PasteboardServiceClient::resolve). + pub fn image(&self) -> Option<(&'static str, Vec)> { + for item in &self.items { + for uti in IMAGE_UTIS { + if let Some(entry) = item.data.iter().find(|e| e.uti == uti) + && let PasteboardPayload::Inline(bytes) = &entry.payload + { + return Some((uti, bytes.clone())); + } + } + } + None + } + + /// The promised (not-inlined) entries, each fetchable with + /// [`PasteboardServiceClient::resolve`]. + pub fn promised_items(&self) -> Vec { + let mut out = Vec::new(); + for item in &self.items { + for entry in &item.data { + if let PasteboardPayload::Promised { size } = entry.payload { + out.push(PromisedItem { + item_index: item.index as i64, + uti: entry.uti.clone(), + size, + }); + } + } + } + out + } +} + +fn parse_item(index: usize, item: &plist::Value) -> PasteboardItem { + let dict = item.as_dictionary(); + let types = dict + .and_then(|d| d.get("types")) + .and_then(|t| t.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_string().map(str::to_string)) + .collect() + }) + .unwrap_or_default(); + + let data = dict + .and_then(|d| d.get("data")) + .and_then(|d| d.as_dictionary()) + .map(|map| { + map.iter() + .map(|(uti, datum)| PasteboardEntry { + uti: uti.clone(), + payload: parse_payload(datum), + }) + .collect() + }) + .unwrap_or_default(); + + PasteboardItem { index, types, data } +} + +fn parse_payload(datum: &plist::Value) -> PasteboardPayload { + let Some(d) = datum.as_dictionary() else { + return PasteboardPayload::Promised { size: None }; + }; + if let Some(bytes) = d.get("data").and_then(|v| v.as_data()) { + PasteboardPayload::Inline(bytes.to_vec()) + } else if let Some(err) = d.get("error") { + PasteboardPayload::Error(format!("{err:?}")) + } else { + PasteboardPayload::Promised { + size: d.get("size").and_then(|s| s.as_signed_integer()), + } + } +} + +#[derive(Debug)] +pub struct PasteboardServiceClient { + inner: RemoteXpcClient, +} + +impl RsdService for PasteboardServiceClient> { + fn rsd_service_name() -> Cow<'static, str> { + obf!("com.apple.coredevice.pasteboardservice") + } + + async fn from_stream(stream: Box) -> Result { + let mut inner = RemoteXpcClient::new(stream).await?; + inner.do_handshake().await?; + Ok(Self { inner }) + } +} + +impl PasteboardServiceClient { + pub fn new(inner: RemoteXpcClient) -> Self { + Self { inner } + } + + /// Pull `pasteboard_name` with every item's bytes inlined + /// ([`DataInclusionPolicy::AllResolved`]). + pub async fn get(&mut self, pasteboard_name: &str) -> Result { + self.get_with_policy(pasteboard_name, DataInclusionPolicy::AllResolved) + .await + } + + /// Pull `pasteboard_name` with an explicit data-inclusion `policy`. Use a + /// promising policy plus [`PasteboardSnapshot::promised_items`] + + /// [`resolve`](Self::resolve) to avoid transferring large items you don't + /// need. + pub async fn get_with_policy( + &mut self, + pasteboard_name: &str, + policy: DataInclusionPolicy, + ) -> Result { + let msg = xpc!({ + "command": "PULL", + "pasteboardName": pasteboard_name, + "dataPolicy": policy.to_xpc(), + }); + + self.inner.send_object(msg, true).await?; + let reply = self.inner.recv().await?; + Ok(PasteboardSnapshot::from_message(&reply)?) + } + + /// Replace the contents of `pasteboard_name` with a single UTF-8 `text` value. + pub async fn set_text( + &mut self, + text: &str, + pasteboard_name: &str, + ) -> Result<(), IdeviceError> { + let mut data = Dictionary::new(); + for uti in TEXT_UTIS { + data.insert(uti.into(), xpc!({ "data": text.as_bytes() })); + } + + let msg = xpc!({ + "command": "SET", + "pasteboardName": pasteboard_name, + "items": [{ + "types": TEXT_UTIS.to_vec(), + "data": XPCObject::Dictionary(data), + }], + "sourceMetadata": XPCObject::Null, + }); + + self.inner.send_object(msg, true).await?; + // Drain the SET_REPLY ack; it carries no information we surface. + self.inner.recv().await?; + Ok(()) + } + + /// Replace the contents of `pasteboard_name` with a single image item of the + /// given `uti` (e.g. [`UTI_PNG`]) and its encoded `bytes`. + pub async fn set_image( + &mut self, + bytes: &[u8], + uti: &str, + pasteboard_name: &str, + ) -> Result<(), IdeviceError> { + let mut data = Dictionary::new(); + data.insert(uti.into(), xpc!({ "data": bytes })); + + let msg = xpc!({ + "command": "SET", + "pasteboardName": pasteboard_name, + "items": [{ + "types": vec![uti], + "data": XPCObject::Dictionary(data), + }], + "sourceMetadata": XPCObject::Null, + }); + + self.inner.send_object(msg, true).await?; + // Drain the SET_REPLY ack; it carries no information we surface. + self.inner.recv().await?; + Ok(()) + } + + /// Fetch the bytes of a promised item (`RESOLVE` -> `DATA`). `item_index` is + /// the item's position in the snapshot and `uti` is the type to resolve. + /// + /// Returns `None` when the device can't fulfil the promise — e.g. the + /// snapshot was superseded by a newer pasteboard change, or the data is no + /// longer available (the `DATA` reply carries a null `data`). + pub async fn resolve( + &mut self, + pasteboard_name: &str, + item_index: i64, + uti: &str, + ) -> Result>, IdeviceError> { + let reply = self.resolve_raw(pasteboard_name, item_index, uti).await?; + Ok(reply + .as_dictionary() + .and_then(|d| d.get("data")) + .and_then(|d| d.as_data()) + .map(|d| d.to_vec())) + } + + /// Like [`resolve`](Self::resolve) but returns the raw `DATA` reply for + /// callers that need to inspect it (e.g. an `error` instead of `data`). + pub async fn resolve_raw( + &mut self, + pasteboard_name: &str, + item_index: i64, + uti: &str, + ) -> Result { + let msg = xpc!({ + "command": "RESOLVE", + "pasteboardName": pasteboard_name, + "itemIndex": item_index, + "type": uti, + }); + + self.inner.send_object(msg, true).await?; + self.inner.recv().await + } + + /// Subscribe to (or, with `enable = false`, unsubscribe from) pasteboard + /// change notifications. While subscribed the device pushes `PUSH` snapshots + /// as the pasteboard changes; read them with [`recv_push`](Self::recv_push). + /// + /// `policy` controls whether the pushed snapshots carry inline bytes. + /// + /// The request is sent one-way: the device does not reply on the reply + /// channel, it just begins pushing `PUSH` messages on the root channel. + pub async fn set_change_notifications( + &mut self, + enable: bool, + pasteboard_name: &str, + policy: Option, + ) -> Result<(), IdeviceError> { + let data_policy = policy.map(|p| p.to_xpc()).unwrap_or(XPCObject::Null); + let msg = xpc!({ + "command": "AUTONOTIFY", + "enable": enable, + "pasteboardName": pasteboard_name, + "dataPolicy": data_policy, + }); + + self.inner.send_object(msg, false).await + } + + /// Block for the next `PUSH` change-notification snapshot from the device. + /// Only meaningful after [`set_change_notifications`](Self::set_change_notifications) + /// with `enable = true`. + /// + /// Pushes arrive on the root channel (not the reply channel that + /// request/reply verbs use), so this reads from there. + pub async fn recv_push(&mut self) -> Result { + let push = self.inner.recv_root().await?; + Ok(PasteboardSnapshot::from_message(&push)?) + } +} diff --git a/src-tauri/vendor/idevice/src/services/core_device/screencaptureservices.rs b/src-tauri/vendor/idevice/src/services/core_device/screencaptureservices.rs new file mode 100644 index 0000000..35dc419 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/core_device/screencaptureservices.rs @@ -0,0 +1,72 @@ +// Jackson Coxson + +use crate::{IdeviceError, ReadWrite, RsdService, obf}; + +use super::CoreDeviceError; + +/// Image encoding requested from the device for [`ScreenCaptureServiceClient::take_screenshot`]. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ImageFormat { + /// PNG (lossless). The format Xcode's Devices window uses. + #[default] + Png, + /// JPEG (lossy, smaller). + Jpeg, +} + +impl ImageFormat { + /// The `requestedFormat` wire value. + pub fn as_str(self) -> &'static str { + match self { + ImageFormat::Png => "png", + ImageFormat::Jpeg => "jpeg", + } + } +} + +impl RsdService for ScreenCaptureServiceClient> { + fn rsd_service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.coredevice.screencaptureservice") + } + + async fn from_stream(stream: Box) -> Result { + Ok(Self { + inner: super::CoreDeviceServiceClient::new(stream).await?, + }) + } +} + +#[derive(Debug)] +pub struct ScreenCaptureServiceClient { + inner: super::CoreDeviceServiceClient, +} + +impl ScreenCaptureServiceClient { + /// Capture a screenshot of `display_unique_id` (or the primary display when + /// `None`) encoded as `format`. Returns the raw image bytes. + pub async fn take_screenshot( + &mut self, + display_unique_id: Option<&str>, + format: ImageFormat, + ) -> Result, IdeviceError> { + let mut req = plist::Dictionary::new(); + req.insert("requestedFormat".into(), format.as_str().into()); + if let Some(id) = display_unique_id { + req.insert("displayUniqueID".into(), id.into()); + } + + let res = self + .inner + .invoke_with_plist_action( + obf!("com.apple.coredevice.feature.capturescreenshot"), + req, + obf!("com.apple.coredevice.action.capturescreenshot"), + ) + .await?; + + match res.as_dictionary().and_then(|d| d.get("image")) { + Some(plist::Value::Data(image)) => Ok(image.clone()), + _ => Err(CoreDeviceError::MissingField("image").into()), + } + } +} diff --git a/src-tauri/vendor/idevice/src/services/core_device_proxy.rs b/src-tauri/vendor/idevice/src/services/core_device_proxy.rs new file mode 100644 index 0000000..d2c5a59 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/core_device_proxy.rs @@ -0,0 +1,94 @@ +//! CoreDeviceProxy service for iOS's CoreDeviceProxy daemon. +//! +//! This service starts an L3 (TUN) tunnel for "trusted" services introduced +//! in iOS 17. Over USB it connects to `com.apple.internal.devicecompute.CoreDeviceProxy`; +//! over the network the same CDTunnel protocol runs over TLS-PSK. +//! +//! The CDTunnel framing and handshake are implemented in [`crate::remote_pairing::tunnel`]. +//! +//! # Features +//! - `tunnel_tcp_stack`: Enables software TCP/IP tunnel creation using jktcp. + +use crate::tunnel::{CdTunnel, TunnelInfo}; +use crate::{Idevice, IdeviceError, IdeviceService, ReadWrite, obf}; + +/// A high-level client for the `com.apple.internal.devicecompute.CoreDeviceProxy` service. +/// +/// Wraps a [`CdTunnel`] established over the USB CoreDeviceProxy service connection. +pub struct CoreDeviceProxy { + /// The underlying CDTunnel carrying raw IPv6 packets. + tunnel: CdTunnel>, +} + +impl std::fmt::Debug for CoreDeviceProxy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CoreDeviceProxy") + .field("info", &self.tunnel.info) + .finish() + } +} + +impl IdeviceService for CoreDeviceProxy { + fn service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.internal.devicecompute.CoreDeviceProxy") + } + + async fn from_stream(idevice: Idevice) -> Result { + Self::new(idevice).await + } +} + +impl CoreDeviceProxy { + /// Constructs a new `CoreDeviceProxy` by performing a CDTunnel handshake. + pub async fn new(mut idevice: Idevice) -> Result { + let socket = idevice + .socket + .take() + .ok_or(IdeviceError::NoEstablishedConnection)?; + let tunnel = CdTunnel::handshake(socket).await?; + Ok(Self { tunnel }) + } + + /// Returns the tunnel info (addresses, MTU, RSD port) from the handshake. + pub fn tunnel_info(&self) -> &TunnelInfo { + &self.tunnel.info + } + + /// Sends a raw data packet through the tunnel. + pub async fn send(&mut self, data: &[u8]) -> Result<(), IdeviceError> { + self.tunnel.send_packet(data).await + } + + /// Receives a raw IPv6 packet from the tunnel. + pub async fn recv(&mut self) -> Result, IdeviceError> { + self.tunnel.recv_packet().await + } + + /// Consumes the proxy and returns the inner `CdTunnel`. + pub fn into_tunnel(self) -> CdTunnel> { + self.tunnel + } + + /// Creates a software-based TCP tunnel adapter using jktcp. + #[cfg(feature = "tunnel_tcp_stack")] + pub fn create_software_tunnel(self) -> Result { + let our_ip = self + .tunnel + .info + .client_address + .parse::()?; + let their_ip = self + .tunnel + .info + .server_address + .parse::()?; + // The inner stream is Box but jktcp wants Box. + // Both traits have the same bounds, and IdeviceSocket implements both. + // Re-box through the jktcp trait. + let mtu = self.tunnel.info.mtu as usize; + let stream: Box = self.tunnel.into_inner(); + let mut adapter = crate::tcp::adapter::Adapter::new(Box::new(stream), our_ip, their_ip); + adapter.set_mss(mtu.saturating_sub(60)); + Ok(adapter) + } +} diff --git a/src-tauri/vendor/idevice/src/services/crashreportcopymobile.rs b/src-tauri/vendor/idevice/src/services/crashreportcopymobile.rs new file mode 100644 index 0000000..aa7173a --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/crashreportcopymobile.rs @@ -0,0 +1,169 @@ +//! iOS Crash Logs +//! +//! Provides functionality for managing crash logs on a connected iOS device. +//! +//! This module enables clients to list, pull, and remove crash logs via the +//! `CrashReportCopyMobile` service using the AFC protocol. It also includes a +//! function to trigger a flush of crash logs from system storage into the +//! crash reports directory by connecting to the `com.apple.crashreportmover` service. + +use tracing::{debug, warn}; + +use crate::{Idevice, IdeviceError, IdeviceService, afc::AfcClient, lockdown::LockdownClient, obf}; + +/// Client for managing crash logs on an iOS device. +/// +/// This client wraps access to the `com.apple.crashreportcopymobile` service, +/// which exposes crash logs through the Apple File Conduit (AFC). +#[derive(Debug)] +pub struct CrashReportCopyMobileClient { + /// The underlying AFC client connected to the crash logs directory. + pub afc_client: AfcClient, +} + +impl IdeviceService for CrashReportCopyMobileClient { + /// Returns the name of the CrashReportCopyMobile service. + fn service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.crashreportcopymobile") + } + + async fn from_stream(idevice: Idevice) -> Result { + Ok(Self::new(idevice)) + } +} + +impl CrashReportCopyMobileClient { + /// Creates a new client from an existing AFC-capable device connection. + /// + /// # Arguments + /// * `idevice` - A pre-established connection to the device. + pub fn new(idevice: Idevice) -> Self { + Self { + afc_client: AfcClient::new(idevice), + } + } + + /// Lists crash report files in the root of the crash logs directory. + /// + /// # Arguments + /// * `dir_path` - The directory to pull logs from. Default is / + /// + /// # Returns + /// A list of filenames. + /// + /// # Errors + /// Returns `IdeviceError` if listing the directory fails. + pub async fn ls(&mut self, dir_path: Option<&str>) -> Result, IdeviceError> { + let path = dir_path.unwrap_or("/"); + let mut res = self.afc_client.list_dir(path).await?; + if res.len() > 2 { + if &res[0] == "." { + res.swap_remove(0); + } + if &res[1] == ".." { + res.swap_remove(1); + } + } + + Ok(res) + } + + /// Retrieves the contents of a specified crash log file. + /// + /// # Arguments + /// * `log` - Name of the log file to retrieve. + /// + /// # Returns + /// A byte vector containing the file contents. + /// + /// # Errors + /// Returns `IdeviceError` if the file cannot be opened or read. + pub async fn pull(&mut self, log: impl Into) -> Result, IdeviceError> { + let log = log.into(); + let mut f = self + .afc_client + .open(format!("/{log}"), crate::afc::opcode::AfcFopenMode::RdOnly) + .await?; + + let data = f.read_entire().await?; + f.close().await?; + Ok(data) + } + + /// Removes a specified crash log file from the device. + /// + /// # Arguments + /// * `log` - Name of the log file to remove. + /// + /// # Errors + /// Returns `IdeviceError` if the file could not be deleted. + pub async fn remove(&mut self, log: impl Into) -> Result<(), IdeviceError> { + let log = log.into(); + self.afc_client.remove(format!("/{log}")).await + } + + /// Consumes this client and returns the inner AFC client. + pub fn to_afc_client(self) -> AfcClient { + self.afc_client + } +} + +const EXPECTED_FLUSH: [u8; 4] = [0x70, 0x69, 0x6E, 0x67]; // 'ping' + +/// Triggers a flush of crash logs from system storage. +/// +/// This connects to the `com.apple.crashreportmover` service, +/// which moves crash logs into the AFC-accessible directory. +/// +/// # Arguments +/// * `provider` - The device provider used for connection and pairing info. +/// +/// # Returns +/// `Ok(())` if the service responds with a valid flush indicator. +/// `Err(IdeviceError)` if the service responds with unexpected data +/// or the connection fails. +pub async fn flush_reports( + provider: &dyn crate::provider::IdeviceProvider, +) -> Result<(), IdeviceError> { + let mut lockdown = LockdownClient::connect(provider).await?; + + let legacy = lockdown + .start_session(&provider.get_pairing_file().await?) + .await?; + + let (port, ssl) = lockdown + .start_service(obf!("com.apple.crashreportmover")) + .await?; + + let mut idevice = provider.connect(port).await?; + if ssl { + idevice + .start_session(&provider.get_pairing_file().await?, legacy) + .await?; + } + + let res = idevice.read_raw(4).await?; + debug!( + "Flush reports response: {:?}", + String::from_utf8_lossy(&res) + ); + + if res[..4] == EXPECTED_FLUSH { + Ok(()) + } else { + warn!("crashreportmover sent wrong bytes: {res:02X?}"); + Err(IdeviceError::CrashReportMoverBadResponse(res)) + } +} + +#[cfg(feature = "rsd")] +impl crate::RsdService for CrashReportCopyMobileClient { + fn rsd_service_name() -> std::borrow::Cow<'static, str> { + crate::obf!("com.apple.crashreportcopymobile.shim.remote") + } + async fn from_stream(stream: Box) -> Result { + let mut idevice = crate::Idevice::new(stream, ""); + idevice.rsd_checkin().await?; + Ok(Self::new(idevice)) + } +} diff --git a/src-tauri/vendor/idevice/src/services/debug_proxy.rs b/src-tauri/vendor/idevice/src/services/debug_proxy.rs new file mode 100644 index 0000000..387a01a --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/debug_proxy.rs @@ -0,0 +1,321 @@ +//! GDB Remote Debugging Protocol Implementation for iOS Devices +//! +//! Provides functionality for communicating with the iOS debug server using the +//! GDB Remote Serial Protocol as documented at: +//! https://sourceware.org/gdb/current/onlinedocs/gdb.html/Packets.html#Packets + +use std::fmt::Write; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tracing::debug; + +use crate::{IdeviceError, ReadWrite, RsdService, obf}; + +impl RsdService for DebugProxyClient> { + fn rsd_service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.internal.dt.remote.debugproxy") + } + + async fn from_stream(stream: Box) -> Result { + Ok(Self { + socket: stream, + noack_mode: false, + }) + } +} + +/// Client for interacting with the iOS debug proxy service +/// +/// Implements the GDB Remote Serial Protocol for communicating with debugserver +/// on iOS devices. Handles packet formatting, checksums, and acknowledgments. +#[derive(Debug)] +pub struct DebugProxyClient { + /// The underlying socket connection to debugproxy + pub socket: R, + /// Flag indicating whether ACK mode is disabled + pub noack_mode: bool, +} + +/// Represents a debugserver command with arguments +/// +/// Commands follow the GDB Remote Serial Protocol format: +/// $[]# +#[derive(Debug)] +pub struct DebugserverCommand { + /// The command name (e.g. "qSupported", "vCont") + pub name: String, + /// Command arguments that will be hex-encoded + pub argv: Vec, +} + +impl DebugserverCommand { + /// Creates a new debugserver command + /// + /// # Arguments + /// * `name` - The command name (without leading $) + /// * `argv` - Arguments that will be hex-encoded in the packet + pub fn new(name: String, argv: Vec) -> Self { + Self { name, argv } + } +} + +impl DebugProxyClient { + /// Creates a new debug proxy client with default settings + /// + /// # Arguments + /// * `socket` - Established connection to debugproxy service + pub fn new(socket: R) -> Self { + Self { + socket, + noack_mode: false, + } + } + + /// Consumes the client and returns the underlying socket + pub fn into_inner(self) -> R { + self.socket + } + + /// Sends a command to debugserver and waits for response + /// + /// Formats the command according to GDB Remote Serial Protocol: + /// $[]# + /// + /// # Arguments + /// * `command` - The command and arguments to send + /// + /// # Returns + /// The response string if successful, None if no response received + /// + /// # Errors + /// Returns `IdeviceError` if communication fails + pub async fn send_command( + &mut self, + command: DebugserverCommand, + ) -> Result, IdeviceError> { + // Hex-encode the arguments + let hex_args = command + .argv + .iter() + .map(|arg| hex_encode(arg.as_bytes())) + .collect::>() + .join(""); + + // Construct the packet data (command + hex-encoded arguments) + let packet_data = format!("{}{}", command.name, hex_args); + + // Calculate the checksum + let checksum = calculate_checksum(&packet_data); + + // Construct the full packet + let packet = format!("${packet_data}#{checksum}"); + + // Log the packet for debugging + debug!("Sending packet: {packet}"); + + // Send the packet + self.socket.write_all(packet.as_bytes()).await?; + self.socket.flush().await?; + + // Read the response + let response = self.read_response().await?; + Ok(response) + } + + /// Reads a response packet from debugserver + /// + /// Handles the GDB Remote Serial Protocol response format: + /// $# + /// + /// # Returns + /// The response data without protocol framing if successful + /// + /// # Errors + /// Returns `IdeviceError` if communication fails or protocol is violated + pub async fn read_response(&mut self) -> Result, IdeviceError> { + let mut buffer = Vec::new(); + let mut received_char = [0u8; 1]; + + if !self.noack_mode { + self.socket.read_exact(&mut received_char).await?; + if received_char[0] != b'+' { + debug!("No + ack"); + return Ok(None); + } + } + + self.socket.read_exact(&mut received_char).await?; + if received_char[0] != b'$' { + debug!("No $ response"); + return Ok(None); + } + + loop { + self.socket.read_exact(&mut received_char).await?; + if received_char[0] == b'#' { + break; + } + buffer.push(received_char[0]); + } + // swallow checksum + let mut checksum_chars = [0u8; 2]; + self.socket.read_exact(&mut checksum_chars).await?; + + if !self.noack_mode { + self.send_ack().await?; + } + + let response = String::from_utf8(buffer)?; + Ok(Some(response)) + } + + /// Sends raw bytes directly to the debugproxy connection + /// + /// # Arguments + /// * `bytes` - The raw bytes to send + /// + /// # Errors + /// Returns `IdeviceError` if writing fails + pub async fn send_raw(&mut self, bytes: &[u8]) -> Result<(), IdeviceError> { + self.socket.write_all(bytes).await?; + self.socket.flush().await?; + Ok(()) + } + + /// Reads raw bytes from the debugproxy connection + /// + /// # Arguments + /// * `len` - Maximum number of bytes to read + /// + /// # Returns + /// The received data as a string + /// + /// # Errors + /// Returns `IdeviceError` if reading fails or data isn't valid UTF-8 + pub async fn read(&mut self, len: usize) -> Result { + let mut buf = vec![0; len]; + let r = self.socket.read(&mut buf).await?; + + Ok(String::from_utf8_lossy(&buf[..r]).to_string()) + } + + /// Sets program arguments using the 'A' command + /// + /// Formats arguments according to GDB protocol: + /// A,, + /// + /// # Arguments + /// * `argv` - Program arguments to set + /// + /// # Returns + /// The debugserver response + /// + /// # Errors + /// Returns `IdeviceError` if arguments are empty or communication fails + pub async fn set_argv(&mut self, argv: Vec) -> Result { + if argv.is_empty() { + return Err(IdeviceError::InvalidArgument); + } + + // Calculate the total length of the packet + let mut pkt_len = 0; + for (i, arg) in argv.iter().enumerate() { + let prefix = format!(",{},{},", arg.len() * 2, i); + pkt_len += prefix.len() + arg.len() * 2; + } + + // Allocate and initialize the packet + let mut pkt = vec![0u8; pkt_len + 1]; + let mut pktp = 0; + + for (i, arg) in argv.iter().enumerate() { + let prefix = format!(",{},{},", arg.len() * 2, i); + let prefix_bytes = prefix.as_bytes(); + + // Copy prefix to the packet + pkt[pktp..pktp + prefix_bytes.len()].copy_from_slice(prefix_bytes); + pktp += prefix_bytes.len(); + + // Hex encode the argument + for byte in arg.bytes() { + let hex = format!("{byte:02X}"); + pkt[pktp..pktp + 2].copy_from_slice(hex.as_bytes()); + pktp += 2; + } + } + + // Set the first byte of the packet + pkt[0] = b'A'; + + // Simulate sending the command and receiving a response + self.send_raw(&pkt).await?; + let response = self.read(16).await?; + + Ok(response) + } + + /// Sends an acknowledgment (+) + /// + /// # Errors + /// Returns `IdeviceError` if writing fails + pub async fn send_ack(&mut self) -> Result<(), IdeviceError> { + self.socket.write_all(b"+").await?; + self.socket.flush().await?; + Ok(()) + } + + /// Sends a negative acknowledgment (-) + /// + /// # Errors + /// Returns `IdeviceError` if writing fails + pub async fn send_noack(&mut self) -> Result<(), IdeviceError> { + self.socket.write_all(b"-").await?; + self.socket.flush().await?; + Ok(()) + } + + /// Enables or disables ACK mode + /// + /// When disabled, the client won't expect or send acknowledgments + /// + /// # Arguments + /// * `enabled` - Whether to enable ACK mode + pub fn set_ack_mode(&mut self, enabled: bool) { + self.noack_mode = !enabled; + } +} + +/// Calculates the checksum for a GDB protocol packet +/// +/// The checksum is computed as the modulo 256 sum of all characters +/// between '$' and '#', formatted as two lowercase hex digits. +fn calculate_checksum(data: &str) -> String { + let checksum = data.bytes().fold(0u8, |acc, byte| acc.wrapping_add(byte)); + format!("{checksum:02x}") +} + +/// Hex-encodes bytes as uppercase string +fn hex_encode(bytes: &[u8]) -> String { + bytes.iter().fold(String::new(), |mut output, b| { + let _ = write!(output, "{b:02X}"); + output + }) +} + +impl From for DebugserverCommand { + /// Converts a string into a debugserver command by splitting on whitespace + /// + /// The first token becomes the command name, remaining tokens become arguments + fn from(s: String) -> Self { + let mut split = s.split_whitespace(); + let command = split.next().unwrap_or("").to_string(); + let arguments: Vec = split.map(|s| s.to_string()).collect(); + Self::new(command, arguments) + } +} + +impl From<&str> for DebugserverCommand { + /// Converts a string slice into a debugserver command + fn from(s: &str) -> DebugserverCommand { + s.to_string().into() + } +} diff --git a/src-tauri/vendor/idevice/src/services/diagnostics_relay.rs b/src-tauri/vendor/idevice/src/services/diagnostics_relay.rs new file mode 100644 index 0000000..3aba33d --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/diagnostics_relay.rs @@ -0,0 +1,296 @@ +//! Diagnostics Relay + +use crate::{Idevice, IdeviceError, IdeviceService, obf}; + +/// Client for interacting with the Diagnostics Relay +#[derive(Debug)] +pub struct DiagnosticsRelayClient { + /// The underlying device connection with established service + pub idevice: Idevice, +} + +impl IdeviceService for DiagnosticsRelayClient { + /// Returns the service name as registered with lockdownd + fn service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.mobile.diagnostics_relay") + } + + async fn from_stream(idevice: Idevice) -> Result { + Ok(Self::new(idevice)) + } +} + +impl DiagnosticsRelayClient { + /// Creates a new client from an existing device connection + /// + /// # Arguments + /// * `idevice` - Pre-established device connection + pub fn new(idevice: Idevice) -> Self { + Self { idevice } + } + + /// Requests data from the IO registry + /// + /// # Arguments + /// * `current_plane` - The plane to request the tree as + /// * `entry_name` - The entry to get + /// * `entry_class` - The class to filter by + /// + /// # Returns + /// A plist of the tree on success + pub async fn ioregistry( + &mut self, + current_plane: Option<&str>, + entry_name: Option<&str>, + entry_class: Option<&str>, + ) -> Result, IdeviceError> { + let req = crate::plist!({ + "Request": "IORegistry", + "CurrentPlane":? current_plane, + "EntryName":? entry_name, + "EntryClass":? entry_class, + }); + self.idevice.send_plist(req).await?; + let mut res = self.idevice.read_plist().await?; + + match res.get("Status").and_then(|x| x.as_string()) { + Some("Success") => {} + _ => { + return Err(IdeviceError::UnexpectedResponse( + "missing or non-Success Status in IORegistry response".into(), + )); + } + } + + let res = res + .remove("Diagnostics") + .and_then(|x| x.into_dictionary()) + .and_then(|mut x| x.remove("IORegistry")) + .and_then(|x| x.into_dictionary()); + + Ok(res) + } + + /// Requests MobileGestalt information from the device + /// + /// # Arguments + /// * `keys` - Optional list of specific keys to request. If None, requests all available keys + /// + /// # Returns + /// A dictionary containing the requested MobileGestalt information + pub async fn mobilegestalt( + &mut self, + keys: Option>, + ) -> Result, IdeviceError> { + let req = crate::plist!({ + "Request": "MobileGestalt", + "MobileGestaltKeys":? keys, + }); + self.idevice.send_plist(req).await?; + let mut res = self.idevice.read_plist().await?; + + match res.get("Status").and_then(|x| x.as_string()) { + Some("Success") => {} + _ => { + return Err(IdeviceError::UnexpectedResponse( + "missing or non-Success Status in MobileGestalt response".into(), + )); + } + } + + let res = res.remove("Diagnostics").and_then(|x| x.into_dictionary()); + + Ok(res) + } + + /// Requests gas gauge information from the device + /// + /// # Returns + /// A dictionary containing gas gauge (battery) information + pub async fn gasguage(&mut self) -> Result, IdeviceError> { + let req = crate::plist!({ + "Request": "GasGauge" + }); + self.idevice.send_plist(req).await?; + let mut res = self.idevice.read_plist().await?; + + match res.get("Status").and_then(|x| x.as_string()) { + Some("Success") => {} + _ => { + return Err(IdeviceError::UnexpectedResponse( + "missing or non-Success Status in GasGauge response".into(), + )); + } + } + + let res = res.remove("Diagnostics").and_then(|x| x.into_dictionary()); + + Ok(res) + } + + /// Requests NAND information from the device + /// + /// # Returns + /// A dictionary containing NAND flash information + pub async fn nand(&mut self) -> Result, IdeviceError> { + let req = crate::plist!({ + "Request": "NAND" + }); + + self.idevice.send_plist(req).await?; + let mut res = self.idevice.read_plist().await?; + + match res.get("Status").and_then(|x| x.as_string()) { + Some("Success") => {} + _ => { + return Err(IdeviceError::UnexpectedResponse( + "missing or non-Success Status in NAND response".into(), + )); + } + } + + let res = res.remove("Diagnostics").and_then(|x| x.into_dictionary()); + + Ok(res) + } + + /// Requests all available diagnostics information + /// + /// # Returns + /// A dictionary containing all diagnostics information + pub async fn all(&mut self) -> Result, IdeviceError> { + let req = crate::plist!({ + "Request": "All" + }); + + self.idevice.send_plist(req).await?; + let mut res = self.idevice.read_plist().await?; + + match res.get("Status").and_then(|x| x.as_string()) { + Some("Success") => {} + _ => { + return Err(IdeviceError::UnexpectedResponse( + "missing or non-Success Status in All diagnostics response".into(), + )); + } + } + + let res = res.remove("Diagnostics").and_then(|x| x.into_dictionary()); + + Ok(res) + } + + /// Restarts the device + /// + /// # Returns + /// Result indicating success or failure + pub async fn restart(&mut self) -> Result<(), IdeviceError> { + let req = crate::plist!({ + "Request": "Restart", + }); + + self.idevice.send_plist(req).await?; + let res = self.idevice.read_plist().await?; + + match res.get("Status").and_then(|x| x.as_string()) { + Some("Success") => Ok(()), + _ => Err(IdeviceError::UnexpectedResponse( + "missing or non-Success Status in Restart response".into(), + )), + } + } + + /// Shuts down the device + /// + /// # Returns + /// Result indicating success or failure + pub async fn shutdown(&mut self) -> Result<(), IdeviceError> { + let req = crate::plist!({ + "Request": "Shutdown" + }); + + self.idevice.send_plist(req).await?; + let res = self.idevice.read_plist().await?; + + match res.get("Status").and_then(|x| x.as_string()) { + Some("Success") => Ok(()), + _ => Err(IdeviceError::UnexpectedResponse( + "missing or non-Success Status in Shutdown response".into(), + )), + } + } + + /// Puts the device to sleep + /// + /// # Returns + /// Result indicating success or failure + pub async fn sleep(&mut self) -> Result<(), IdeviceError> { + let req = crate::plist!({ + "Request": "Sleep" + }); + + self.idevice.send_plist(req).await?; + let res = self.idevice.read_plist().await?; + + match res.get("Status").and_then(|x| x.as_string()) { + Some("Success") => Ok(()), + _ => Err(IdeviceError::UnexpectedResponse( + "missing or non-Success Status in Sleep response".into(), + )), + } + } + + /// Requests WiFi diagnostics from the device + pub async fn wifi(&mut self) -> Result, IdeviceError> { + let req = crate::plist!({ + "Request": "WiFi" + }); + + self.idevice.send_plist(req).await?; + let mut res = self.idevice.read_plist().await?; + + match res.get("Status").and_then(|x| x.as_string()) { + Some("Success") => {} + _ => { + return Err(IdeviceError::UnexpectedResponse( + "missing or non-Success Status in WiFi response".into(), + )); + } + } + + let res = res.remove("Diagnostics").and_then(|x| x.into_dictionary()); + + Ok(res) + } + + /// Sends Goodbye request signaling end of communication + pub async fn goodbye(&mut self) -> Result<(), IdeviceError> { + let req = crate::plist!({ + "Request": "Goodbye" + }); + + self.idevice.send_plist(req).await?; + let res = self.idevice.read_plist().await?; + match res.get("Status").and_then(|x| x.as_string()) { + Some("Success") => Ok(()), + Some("UnknownRequest") => Err(IdeviceError::UnexpectedResponse( + "Goodbye request returned UnknownRequest".into(), + )), + _ => Err(IdeviceError::UnexpectedResponse( + "missing or non-Success Status in Goodbye response".into(), + )), + } + } +} + +#[cfg(feature = "rsd")] +impl crate::RsdService for DiagnosticsRelayClient { + fn rsd_service_name() -> std::borrow::Cow<'static, str> { + crate::obf!("com.apple.mobile.diagnostics_relay.shim.remote") + } + async fn from_stream(stream: Box) -> Result { + let mut idevice = crate::Idevice::new(stream, ""); + idevice.rsd_checkin().await?; + Ok(Self::new(idevice)) + } +} diff --git a/src-tauri/vendor/idevice/src/services/dvt/application_listing.rs b/src-tauri/vendor/idevice/src/services/dvt/application_listing.rs new file mode 100644 index 0000000..60d89be --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/dvt/application_listing.rs @@ -0,0 +1,52 @@ +//! Application listing service - List installed applications on the device + +use plist::{Dictionary, Value}; + +use super::message::AuxValue; +use super::remote_server::{Channel, RemoteServerClient}; +use crate::{IdeviceError, ReadWrite, obf}; + +/// Client for listing installed applications +#[derive(Debug)] +pub struct ApplicationListingClient<'a, R: ReadWrite> { + channel: Channel<'a, R>, +} + +impl<'a, R: ReadWrite> ApplicationListingClient<'a, R> { + pub async fn new(client: &'a mut RemoteServerClient) -> Result { + let channel = client + .make_channel(obf!( + "com.apple.instruments.server.services.device.applictionListing" + )) + .await?; + Ok(Self { channel }) + } + + /// Returns the list of installed applications with their attributes + pub async fn installed_applications(&mut self) -> Result, IdeviceError> { + self.channel + .call_method( + Some(Value::String( + "installedApplicationsMatching:registerUpdateToken:".into(), + )), + Some(vec![ + AuxValue::archived_value(Value::Dictionary(Dictionary::new())), + AuxValue::archived_value(Value::String(String::new())), + ]), + true, + ) + .await?; + let msg = self.channel.read_message().await?; + let data = msg + .data + .ok_or_else(|| IdeviceError::UnexpectedResponse("expected application list".into()))?; + + let arr = data + .into_array() + .ok_or_else(|| IdeviceError::UnexpectedResponse("expected array".into()))?; + Ok(arr + .into_iter() + .filter_map(|v| v.into_dictionary()) + .collect()) + } +} diff --git a/src-tauri/vendor/idevice/src/services/dvt/condition_inducer.rs b/src-tauri/vendor/idevice/src/services/dvt/condition_inducer.rs new file mode 100644 index 0000000..eb0b194 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/dvt/condition_inducer.rs @@ -0,0 +1,130 @@ +//! Condition inducer service - Simulate network/thermal conditions on the device + +use plist::Value; + +use super::message::AuxValue; +use super::remote_server::{Channel, RemoteServerClient}; +use crate::{IdeviceError, ReadWrite, obf}; + +/// A condition inducer group with a list of profiles +#[derive(Debug, Clone)] +pub struct ConditionInducerGroup { + pub identifier: String, + pub profiles: Vec, +} + +/// A specific condition profile that can be enabled +#[derive(Debug, Clone)] +pub struct ConditionProfile { + pub identifier: String, + pub description: String, +} + +/// Client for inducing network/thermal conditions +#[derive(Debug)] +pub struct ConditionInducerClient<'a, R: ReadWrite> { + channel: Channel<'a, R>, +} + +impl<'a, R: ReadWrite> ConditionInducerClient<'a, R> { + pub async fn new(client: &'a mut RemoteServerClient) -> Result { + let channel = client + .make_channel(obf!( + "com.apple.instruments.server.services.ConditionInducer" + )) + .await?; + Ok(Self { channel }) + } + + /// Returns available condition inducers grouped by category + pub async fn available_conditions( + &mut self, + ) -> Result, IdeviceError> { + self.channel + .call_method( + Some(Value::String("availableConditionInducers".into())), + None, + true, + ) + .await?; + let msg = self.channel.read_message().await?; + let data = msg + .data + .ok_or_else(|| IdeviceError::UnexpectedResponse("expected array".into()))?; + let arr = data + .into_array() + .ok_or_else(|| IdeviceError::UnexpectedResponse("expected array".into()))?; + + let mut groups = Vec::new(); + for item in arr { + if let Some(dict) = item.into_dictionary() { + let identifier = dict + .get("identifier") + .and_then(|v| v.as_string()) + .unwrap_or("") + .to_string(); + let profiles = dict + .get("profiles") + .and_then(|v| v.as_array()) + .cloned() + .unwrap_or_default() + .into_iter() + .filter_map(|p| { + let pd = p.into_dictionary()?; + Some(ConditionProfile { + identifier: pd + .get("identifier") + .and_then(|v| v.as_string()) + .unwrap_or("") + .to_string(), + description: pd + .get("description") + .and_then(|v| v.as_string()) + .unwrap_or("") + .to_string(), + }) + }) + .collect(); + groups.push(ConditionInducerGroup { + identifier, + profiles, + }); + } + } + Ok(groups) + } + + /// Enables a specific condition profile + pub async fn enable_condition( + &mut self, + condition_identifier: &str, + profile_identifier: &str, + ) -> Result<(), IdeviceError> { + self.channel + .call_method( + Some(Value::String( + "enableConditionWithIdentifier:profileIdentifier:".into(), + )), + Some(vec![ + AuxValue::archived_value(Value::String(condition_identifier.to_string())), + AuxValue::archived_value(Value::String(profile_identifier.to_string())), + ]), + true, + ) + .await?; + // Consume reply + self.channel.read_message().await?; + Ok(()) + } + + /// Disables the currently active condition + pub async fn disable_condition(&mut self) -> Result<(), IdeviceError> { + self.channel + .call_method( + Some(Value::String("disableActiveCondition".into())), + None, + false, + ) + .await + } +} diff --git a/src-tauri/vendor/idevice/src/services/dvt/device_info.rs b/src-tauri/vendor/idevice/src/services/dvt/device_info.rs new file mode 100644 index 0000000..de903bf --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/dvt/device_info.rs @@ -0,0 +1,232 @@ +// Jackson Coxson +//! Device info service - Query device information via DVT + +use plist::Value; + +use super::message::AuxValue; +use super::remote_server::{Channel, RemoteServerClient}; +use crate::{IdeviceError, ReadWrite, obf}; + +/// Client for querying device information +#[derive(Debug)] +pub struct DeviceInfoClient<'a, R: ReadWrite> { + channel: Channel<'a, R>, +} + +/// A running process on the device +#[derive(Debug, Clone)] +pub struct RunningProcess { + pub pid: u32, + pub name: String, + pub real_app_name: String, + pub is_application: bool, + pub start_page_count: u64, +} + +impl<'a, R: ReadWrite> DeviceInfoClient<'a, R> { + pub async fn new(client: &'a mut RemoteServerClient) -> Result { + let channel = client + .make_channel(obf!("com.apple.instruments.server.services.deviceinfo")) + .await?; + Ok(Self { channel }) + } + + /// Read the reply message + async fn read_reply(&mut self) -> Result { + let msg = self.channel.read_message().await?; + msg.data + .ok_or_else(|| IdeviceError::UnexpectedResponse("no data in reply".into())) + } + + /// Returns the list of running processes + pub async fn running_processes(&mut self) -> Result, IdeviceError> { + self.channel + .call_method(Some(Value::String("runningProcesses".into())), None, true) + .await?; + let data = self.read_reply().await?; + let arr = data + .into_array() + .ok_or_else(|| IdeviceError::UnexpectedResponse("expected array".into()))?; + + let mut result = Vec::new(); + for item in arr { + if let Some(dict) = item.into_dictionary() { + let pid = dict + .get("pid") + .and_then(|v| match v { + Value::Integer(i) => i.as_unsigned(), + _ => None, + }) + .unwrap_or(0) as u32; + let name = dict + .get("name") + .and_then(|v| v.as_string()) + .unwrap_or("") + .to_string(); + let real_app_name = dict + .get("realAppName") + .and_then(|v| v.as_string()) + .unwrap_or("") + .to_string(); + let is_application = dict + .get("isApplication") + .and_then(|v| v.as_boolean()) + .unwrap_or(false); + let start_page_count = dict + .get("startPageCount") + .and_then(|v| match v { + Value::Integer(i) => i.as_unsigned(), + _ => None, + }) + .unwrap_or(0); + result.push(RunningProcess { + pid, + name, + real_app_name, + is_application, + start_page_count, + }); + } + } + Ok(result) + } + + /// Returns the executable name for the given PID + pub async fn execname_for_pid(&mut self, pid: u32) -> Result { + self.channel + .call_method( + Some(Value::String("execnameForPid:".into())), + Some(vec![AuxValue::archived_value(Value::Integer( + (pid as i64).into(), + ))]), + true, + ) + .await?; + let data = self.read_reply().await?; + Ok(data.into_string().unwrap_or_default()) + } + + /// Returns whether the given PID is currently running + pub async fn is_running_pid(&mut self, pid: u32) -> Result { + self.channel + .call_method( + Some(Value::String("isRunningPid:".into())), + Some(vec![AuxValue::archived_value(Value::Integer( + (pid as i64).into(), + ))]), + true, + ) + .await?; + let data = self.read_reply().await?; + Ok(data.as_boolean().unwrap_or(false)) + } + + /// Returns hardware information about the device + pub async fn hardware_information(&mut self) -> Result { + self.channel + .call_method( + Some(Value::String("hardwareInformation".into())), + None, + true, + ) + .await?; + let data = self.read_reply().await?; + data.into_dictionary() + .ok_or_else(|| IdeviceError::UnexpectedResponse("expected dictionary".into())) + } + + /// Returns network information about the device + pub async fn network_information(&mut self) -> Result { + self.channel + .call_method(Some(Value::String("networkInformation".into())), None, true) + .await?; + let data = self.read_reply().await?; + data.into_dictionary() + .ok_or_else(|| IdeviceError::UnexpectedResponse("expected dictionary".into())) + } + + /// Returns the mach kernel name + pub async fn mach_kernel_name(&mut self) -> Result { + self.channel + .call_method(Some(Value::String("machKernelName".into())), None, true) + .await?; + let data = self.read_reply().await?; + Ok(data.into_string().unwrap_or_default()) + } + + /// Returns the list of sysmon process attribute names + pub async fn sysmon_process_attributes(&mut self) -> Result, IdeviceError> { + self.channel + .call_method( + Some(Value::String("sysmonProcessAttributes".into())), + None, + true, + ) + .await?; + let data = self.read_reply().await?; + let arr = data + .into_array() + .ok_or_else(|| IdeviceError::UnexpectedResponse("expected array".into()))?; + Ok(arr.into_iter().filter_map(|v| v.into_string()).collect()) + } + + /// Returns the list of sysmon system attribute names + pub async fn sysmon_system_attributes(&mut self) -> Result, IdeviceError> { + self.channel + .call_method( + Some(Value::String("sysmonSystemAttributes".into())), + None, + true, + ) + .await?; + let data = self.read_reply().await?; + let arr = data + .into_array() + .ok_or_else(|| IdeviceError::UnexpectedResponse("expected array".into()))?; + Ok(arr.into_iter().filter_map(|v| v.into_string()).collect()) + } + + /// Lists directory contents at the given path + pub async fn directory_listing(&mut self, path: &str) -> Result, IdeviceError> { + self.channel + .call_method( + Some(Value::String("directoryListingForPath:".into())), + Some(vec![AuxValue::archived_value(Value::String( + path.to_string(), + ))]), + true, + ) + .await?; + let data = self.read_reply().await?; + let arr = data + .into_array() + .ok_or_else(|| IdeviceError::UnexpectedResponse("expected array".into()))?; + Ok(arr.into_iter().filter_map(|v| v.into_string()).collect()) + } + + /// Returns the username for the given UID + pub async fn name_for_uid(&mut self, uid: u32) -> Result { + self.channel + .call_method( + Some(Value::String("nameForUID:".into())), + Some(vec![AuxValue::I64(uid as i64)]), + true, + ) + .await?; + let data = self.read_reply().await?; + Ok(data.into_string().unwrap_or_default()) + } + + /// Returns the group name for the given GID + pub async fn name_for_gid(&mut self, gid: u32) -> Result { + self.channel + .call_method( + Some(Value::String("nameForGID:".into())), + Some(vec![AuxValue::I64(gid as i64)]), + true, + ) + .await?; + let data = self.read_reply().await?; + Ok(data.into_string().unwrap_or_default()) + } +} diff --git a/src-tauri/vendor/idevice/src/services/dvt/energy_monitor.rs b/src-tauri/vendor/idevice/src/services/dvt/energy_monitor.rs new file mode 100644 index 0000000..fafc8d9 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/dvt/energy_monitor.rs @@ -0,0 +1,238 @@ +//! Energy Monitor service client for iOS instruments protocol + +use crate::{ + IdeviceError, ReadWrite, + dvt::message::AuxValue, + dvt::remote_server::{Channel, RemoteServerClient}, + obf, +}; +use plist::{Dictionary, Value}; + +#[derive(Debug)] +pub struct EnergyMonitorClient<'a, R: ReadWrite> { + channel: Channel<'a, R>, +} + +impl<'a, R: ReadWrite> EnergyMonitorClient<'a, R> { + pub async fn new(client: &'a mut RemoteServerClient) -> Result { + let channel = client + .make_channel(obf!("com.apple.xcode.debug-gauge-data-providers.Energy")) + .await?; + Ok(Self { channel }) + } + + pub async fn start_sampling(&mut self, pids: &[u32]) -> Result<(), IdeviceError> { + self.channel + .call_method( + Some(Value::String("startSamplingForPIDs:".into())), + Some(vec![Self::archive_pids(pids)]), + false, + ) + .await + } + + pub async fn stop_sampling(&mut self, pids: &[u32]) -> Result<(), IdeviceError> { + self.channel + .call_method( + Some(Value::String("stopSamplingForPIDs:".into())), + Some(vec![Self::archive_pids(pids)]), + false, + ) + .await + } + + /// Returns raw NSKeyedArchive bytes for the response, since the response uses + /// NSDictionary with NSNumber (integer) keys which plist::Dictionary doesn't support. + pub async fn sample_attributes(&mut self, pids: &[u32]) -> Result, IdeviceError> { + self.channel + .call_method( + Some(Value::String("sampleAttributes:forPIDs:".into())), + Some(vec![ + AuxValue::archived_value(Value::Dictionary(Dictionary::new())), + Self::archive_pids(pids), + ]), + true, + ) + .await?; + let msg = self.channel.read_message().await?; + msg.raw_data + .ok_or_else(|| IdeviceError::UnexpectedResponse("expected energy data bytes".into())) + } + + fn archive_pids(pids: &[u32]) -> AuxValue { + AuxValue::archived_value(Value::Array( + pids.iter() + .map(|p| Value::Integer((*p as i64).into())) + .collect(), + )) + } +} + +/// Parse an NSKeyedArchive binary plist that may contain NSDictionary with integer keys. +/// Returns a plist::Value::Dictionary where integer keys are converted to strings. +pub fn decode_nka_int_keys(bytes: &[u8]) -> Result { + let archive = plist::Value::from_reader(std::io::Cursor::new(bytes)) + .map_err(|e| IdeviceError::UnexpectedResponse(format!("plist parse error: {e}")))?; + + let dict = archive.as_dictionary().ok_or_else(|| { + IdeviceError::UnexpectedResponse("NSKeyedArchive root is not a dict".into()) + })?; + + let objects = dict + .get("$objects") + .and_then(|v| v.as_array()) + .ok_or_else(|| IdeviceError::UnexpectedResponse("missing $objects".into()))?; + + let top = dict + .get("$top") + .and_then(|v| v.as_dictionary()) + .ok_or_else(|| IdeviceError::UnexpectedResponse("missing $top".into()))?; + + let root_uid = top + .get("root") + .and_then(uid_index) + .ok_or_else(|| IdeviceError::UnexpectedResponse("missing $top.root UID".into()))?; + + decode_nka_object(objects, root_uid) +} + +fn uid_index(v: &Value) -> Option { + match v { + Value::Uid(uid) => Some(uid.get() as usize), + _ => None, + } +} + +fn decode_nka_object(objects: &[Value], idx: usize) -> Result { + let obj = objects.get(idx).ok_or_else(|| { + IdeviceError::UnexpectedResponse(format!("UID index {idx} out of bounds")) + })?; + + match obj { + Value::String(s) if s == "$null" => Ok(Value::Dictionary(Dictionary::new())), + Value::Dictionary(d) => { + let class_uid = d.get("$class").and_then(uid_index); + if let Some(cuid) = class_uid { + let class_obj = objects.get(cuid).and_then(|v| v.as_dictionary()); + let class_name = class_obj + .and_then(|d| d.get("$classname")) + .and_then(|v| v.as_string()) + .unwrap_or(""); + + match class_name { + // Match both public and internal iOS class names + s if s.contains("Dictionary") => { + let keys = d.get("NS.keys").and_then(|v| v.as_array()); + let vals = d.get("NS.objects").and_then(|v| v.as_array()); + if let (Some(keys), Some(vals)) = (keys, vals) { + let mut result = Dictionary::new(); + for (k, v) in keys.iter().zip(vals.iter()) { + if let (Some(ki), Some(vi)) = (uid_index(k), uid_index(v)) { + let key_val = decode_nka_object(objects, ki)?; + let val_val = decode_nka_object(objects, vi)?; + let key_str = match &key_val { + Value::String(s) => s.clone(), + Value::Integer(i) => i.to_string(), + Value::Real(f) => f.to_string(), + _ => format!("{key_val:?}"), + }; + result.insert(key_str, val_val); + } + } + return Ok(Value::Dictionary(result)); + } + Ok(Value::Dictionary(Dictionary::new())) + } + // NSSet/NSMutableSet also uses NS.objects (unordered, treat as array) + s if s.contains("Array") || s.contains("Set") => { + let items = d.get("NS.objects").and_then(|v| v.as_array()); + if let Some(items) = items { + let mut result = Vec::new(); + for item in items { + if let Some(i) = uid_index(item) { + result.push(decode_nka_object(objects, i)?); + } + } + return Ok(Value::Array(result)); + } + Ok(Value::Array(Vec::new())) + } + _ => Ok(obj.clone()), + } + } else { + Ok(obj.clone()) + } + } + Value::Uid(uid) => decode_nka_object(objects, uid.get() as usize), + other => Ok(other.clone()), + } +} + +#[derive(Debug, Clone, Copy)] +pub struct EnergySample { + pub pid: u32, + pub timestamp: i64, + pub total_energy: f64, + pub cpu_energy: f64, + pub gpu_energy: f64, + pub networking_energy: f64, + pub display_energy: f64, + pub location_energy: f64, + pub appstate_energy: f64, +} + +impl EnergySample { + /// Parse energy samples from the raw NSKeyedArchive bytes returned by sample_attributes. + /// The response is NSDictionary>. + pub fn from_bytes(bytes: &[u8]) -> Result, IdeviceError> { + let value = decode_nka_int_keys(bytes)?; + let outer = value.as_dictionary().ok_or_else(|| { + IdeviceError::UnexpectedResponse("energy response is not a dict".into()) + })?; + + let mut samples = Vec::new(); + for (pid_str, inner_val) in outer { + let pid: u32 = pid_str.parse().unwrap_or(0); + let inner = match inner_val.as_dictionary() { + Some(d) => d, + None => continue, + }; + + let get_f64 = |key: &str| -> f64 { + inner + .get(key) + .and_then(|v| match v { + Value::Real(f) => Some(*f), + Value::Integer(i) => i.as_signed().map(|i| i as f64), + _ => None, + }) + .unwrap_or(0.0) + }; + + let get_i64 = |key: &str| -> i64 { + inner + .get(key) + .and_then(|v| match v { + Value::Integer(i) => i.as_signed(), + Value::Real(f) => Some(*f as i64), + _ => None, + }) + .unwrap_or(0) + }; + + samples.push(EnergySample { + pid, + timestamp: get_i64("kIDEGaugeSecondsSinceInitialQueryKey"), + total_energy: get_f64("energy.cost"), + cpu_energy: get_f64("energy.cpu.cost"), + gpu_energy: get_f64("energy.gpu.cost"), + networking_energy: get_f64("energy.networking.cost"), + display_energy: get_f64("energy.display.cost"), + location_energy: get_f64("energy.location.cost"), + appstate_energy: get_f64("energy.appstate.cost"), + }); + } + + Ok(samples) + } +} diff --git a/src-tauri/vendor/idevice/src/services/dvt/errors.rs b/src-tauri/vendor/idevice/src/services/dvt/errors.rs new file mode 100644 index 0000000..f905b98 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/dvt/errors.rs @@ -0,0 +1,26 @@ +// Jackson Coxson + +/// Errors specific to the DVT (Developer Tools) protocol +#[derive(thiserror::Error, Debug)] +#[non_exhaustive] +pub enum DvtError { + #[error("NSKeyedArchive error")] + NsKeyedArchiveError(#[from] ns_keyed_archive::ConverterError), + #[error("Unknown aux value type: {0}")] + UnknownAuxValueType(u32), + #[error("unknown channel: {0}")] + UnknownChannel(u32), + #[error("disable memory limit failed")] + DisableMemoryLimitFailed, +} + +impl DvtError { + pub fn sub_code(&self) -> i32 { + match self { + Self::NsKeyedArchiveError(_) => 1, + Self::UnknownAuxValueType(_) => 2, + Self::UnknownChannel(_) => 3, + Self::DisableMemoryLimitFailed => 4, + } + } +} diff --git a/src-tauri/vendor/idevice/src/services/dvt/graphics.rs b/src-tauri/vendor/idevice/src/services/dvt/graphics.rs new file mode 100644 index 0000000..8d21b1e --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/dvt/graphics.rs @@ -0,0 +1,148 @@ +// Jackson Coxson +//! Graphics monitoring service - Monitor GPU/graphics performance + +use std::fmt; + +use plist::{Dictionary, Value}; + +use super::message::AuxValue; +use super::remote_server::{Channel, RemoteServerClient}; +use crate::{IdeviceError, ReadWrite, obf}; + +/// Client for graphics monitoring operations on iOS devices +#[derive(Debug)] +pub struct GraphicsClient<'a, R: ReadWrite> { + channel: Channel<'a, R>, +} + +/// Graphics sample data from `com.apple.instruments.server.services.graphics.opengl` +#[derive(Debug, Clone)] +pub struct GraphicsSample { + /// Timestamp in microseconds (XRVideoCardRunTimeStamp) + pub timestamp: u64, + /// Core Animation frames per second + pub fps: f64, + /// Allocated GPU/system memory in bytes + pub alloc_system_memory: u64, + /// In-use system memory in bytes + pub in_use_system_memory: u64, + /// In-use system memory attributed to the driver + pub in_use_system_memory_driver: u64, + /// GPU bundle name (e.g. "Built-In") + pub gpu_bundle_name: String, + /// GPU recovery count + pub recovery_count: u64, +} + +impl GraphicsSample { + /// Parse a graphics sample from the plist dict pushed by the device. + /// Returns `Err` if the dict doesn't look like a graphics frame (so callers + /// can skip non-data messages such as the startSamplingAtTimeInterval: reply). + pub fn from_plist(data: Value) -> Result { + let dict = data + .into_dictionary() + .ok_or_else(|| IdeviceError::UnexpectedResponse("expected dictionary".into()))?; + + // Require at least one key that's unique to graphics data frames. + if !dict.contains_key("XRVideoCardRunTimeStamp") { + return Err(IdeviceError::UnexpectedResponse( + "not a graphics data frame".into(), + )); + } + + Ok(Self { + timestamp: get_u64(&dict, "XRVideoCardRunTimeStamp"), + fps: get_f64(&dict, "CoreAnimationFramesPerSecond"), + alloc_system_memory: get_u64(&dict, "Alloc system memory"), + in_use_system_memory: get_u64(&dict, "In use system memory"), + in_use_system_memory_driver: get_u64(&dict, "In use system memory (driver)"), + gpu_bundle_name: dict + .get("IOGLBundleName") + .and_then(|v| v.as_string()) + .unwrap_or("") + .to_string(), + recovery_count: get_u64(&dict, "recoveryCount"), + }) + } +} + +fn get_u64(dict: &Dictionary, key: &str) -> u64 { + dict.get(key) + .and_then(|v| match v { + Value::Integer(i) => i.as_unsigned(), + Value::Real(f) => Some(*f as u64), + _ => None, + }) + .unwrap_or(0) +} + +fn get_f64(dict: &Dictionary, key: &str) -> f64 { + dict.get(key) + .and_then(|v| match v { + Value::Real(f) => Some(*f), + Value::Integer(i) => i.as_signed().map(|i| i as f64), + _ => None, + }) + .unwrap_or(0.0) +} + +impl<'a, R: ReadWrite> GraphicsClient<'a, R> { + /// Creates a new GraphicsClient + pub async fn new(client: &'a mut RemoteServerClient) -> Result { + let channel = client + .make_channel(obf!( + "com.apple.instruments.server.services.graphics.opengl" + )) + .await?; + Ok(Self { channel }) + } + + /// Starts graphics sampling at the specified interval. + /// Consumes the device's reply before returning. + pub async fn start_sampling(&mut self, interval: f64) -> Result<(), IdeviceError> { + self.channel + .call_method( + Some(Value::String("startSamplingAtTimeInterval:".into())), + Some(vec![AuxValue::Double(interval)]), + true, + ) + .await?; + // Consume the reply so it doesn't pollute the notification stream. + self.channel.read_message().await?; + Ok(()) + } + + /// Stops graphics sampling + pub async fn stop_sampling(&mut self) -> Result<(), IdeviceError> { + self.channel + .call_method(Some(Value::String("stopSampling".into())), None, false) + .await + } + + /// Reads the next graphics data frame pushed by the device. + /// Skips any non-data messages (e.g. ACKs, capability notices). + pub async fn sample(&mut self) -> Result { + loop { + let msg = self.channel.read_message().await?; + if let Some(data) = msg.data + && let Ok(sample) = GraphicsSample::from_plist(data) + { + return Ok(sample); + } + } + } +} + +impl fmt::Display for GraphicsSample { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "t={:>8}µs | fps={:>5.1} | mem_alloc={:>10} | mem_used={:>10} | gpu={}", + self.timestamp, + self.fps, + self.alloc_system_memory, + self.in_use_system_memory, + self.gpu_bundle_name, + ) + } +} diff --git a/src-tauri/vendor/idevice/src/services/dvt/location_simulation.rs b/src-tauri/vendor/idevice/src/services/dvt/location_simulation.rs new file mode 100644 index 0000000..d10139b --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/dvt/location_simulation.rs @@ -0,0 +1,111 @@ +//! Location Simulation service client for iOS instruments protocol. +//! +//! This module abstracts simulating the device's location over +//! the remote server protocol. Note that a connection must be +//! maintained to keep location simulated. +//! +//! # Example +//! ```rust,no_run +//! #[tokio::main] +//! async fn main() -> Result<(), IdeviceError> { +//! // Create base client (implementation specific) +//! let mut client = RemoteServerClient::new(your_transport); +//! +//! // Create process control client +//! let mut process_control = ProcessControlClient::new(&mut client).await?; +//! +//! // Launch an app +//! let pid = process_control.launch_app( +//! "com.example.app", +//! None, // Environment variables +//! None, // Arguments +//! false, // Start suspended +//! true // Kill existing +//! ).await?; +//! println!("Launched app with PID: {}", pid); +//! +//! // Disable memory limits +//! process_control.disable_memory_limit(pid).await?; +//! +//! // Kill the app +//! process_control.kill_app(pid).await?; +//! +//! Ok(()) +//! } +//! ``` + +use plist::Value; + +use crate::{ + IdeviceError, ReadWrite, + dvt::{ + message::AuxValue, + remote_server::{Channel, RemoteServerClient}, + }, + obf, +}; + +/// A client for the location simulation service +#[derive(Debug)] +pub struct LocationSimulationClient<'a, R: ReadWrite> { + /// The underlying channel used for communication + channel: Channel<'a, R>, +} + +impl<'a, R: ReadWrite> LocationSimulationClient<'a, R> { + /// Opens a new channel on the remote server client for location simulation + /// + /// # Arguments + /// * `client` - The remote server client to connect with + /// + /// # Returns + /// The client on success, IdeviceError on failure + pub async fn new(client: &'a mut RemoteServerClient) -> Result { + let channel = client + .make_channel(obf!( + "com.apple.instruments.server.services.LocationSimulation" + )) + .await?; // Drop `&mut client` before continuing + + Ok(Self { channel }) + } + + /// Clears the set GPS location + pub async fn clear(&mut self) -> Result<(), IdeviceError> { + let method = Value::String("stopLocationSimulation".into()); + + self.channel.call_method(Some(method), None, true).await?; + + let _ = self.channel.read_message().await?; + + Ok(()) + } + + /// Sets the GPS location + /// + /// # Arguments + /// * `latitude` - The f64 latitude value + /// * `longitude` - The f64 longitude value + /// + /// # Errors + /// Returns an IdeviceError on failure + pub async fn set(&mut self, latitude: f64, longitude: f64) -> Result<(), IdeviceError> { + let method = Value::String("simulateLocationWithLatitude:longitude:".into()); + + self.channel + .call_method( + Some(method), + Some(vec![ + AuxValue::archived_value(latitude), + AuxValue::archived_value(longitude), + ]), + true, + ) + .await?; + + // We don't actually care what's in the response, but we need to request one and read it + let _ = self.channel.read_message().await?; + + Ok(()) + } +} diff --git a/src-tauri/vendor/idevice/src/services/dvt/message.rs b/src-tauri/vendor/idevice/src/services/dvt/message.rs new file mode 100644 index 0000000..f2e07b9 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/dvt/message.rs @@ -0,0 +1,810 @@ +//! Instruments protocol message format implementation +//! +//! This module handles the serialization and deserialization of messages used in +//! the iOS instruments protocol. The message format consists of: +//! - 32-byte message header +//! - 16-byte payload header +//! - Optional auxiliary data section +//! - Payload data (typically NSKeyedArchive format) +//! +//! # Message Structure +//! ```text +//! +---------------------+ +//! | MessageHeader | 32 bytes +//! +---------------------+ +//! | PayloadHeader | 16 bytes +//! +---------------------+ +//! | AuxHeader | 16 bytes (if aux present) +//! | Aux data | variable length +//! +---------------------+ +//! | Payload data | variable length (NSKeyedArchive) +//! +---------------------+ +//! ``` +//! +//! # Example +//! ```rust,no_run +//! use plist::Value; +//! use your_crate::IdeviceError; +//! use your_crate::dvt::message::{Message, MessageHeader, PayloadHeader, AuxValue}; +//! +//! # #[tokio::main] +//! # async fn main() -> Result<(), IdeviceError> { +//! // Create a new message +//! let header = MessageHeader::new( +//! 1, // fragment_id +//! 1, // fragment_count +//! 123, // identifier +//! 0, // conversation_index +//! 42, // channel +//! true // expects_reply +//! ); +//! +//! let message = Message::new( +//! header, +//! PayloadHeader::method_invocation(), +//! Some(AuxValue::from_values(vec![ +//! AuxValue::String("param".into()), +//! AuxValue::U32(123), +//! ])), +//! Some(Value::String("data".into())) +//! ); +//! +//! // Serialize message +//! let bytes = message.serialize(); +//! +//! // Deserialize message (from async reader) +//! # let mut reader = &bytes[..]; +//! let deserialized = Message::from_reader(&mut reader).await?; +//! # Ok(()) +//! # } + +use plist::Value; +use std::io::{Cursor, Read}; +use tokio::io::{AsyncRead, AsyncReadExt}; + +use super::errors::DvtError; +use crate::{IdeviceError, pretty_print_plist}; + +/// Message header containing metadata about the message +/// +/// 32-byte structure that appears at the start of every message +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct MessageHeader { + /// Magic number identifying the protocol (0x1F3D5B79) + magic: u32, + /// Length of this header (always 32) + header_len: u32, + /// Fragment identifier for multipart messages + fragment_id: u16, + /// Total number of fragments + fragment_count: u16, + /// Total length of payload (headers + aux + data) + length: u32, + /// Unique message identifier + identifier: u32, + /// Conversation tracking index + conversation_index: u32, + /// Channel number this message belongs to + pub channel: i32, + /// Whether a reply is expected + expects_reply: bool, +} + +/// Payload header containing information about the message contents +/// +/// 16-byte structure following the message header +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub struct PayloadHeader { + /// DTX message type (DISPATCH/OBJECT/OK/ERROR/DATA) + msg_type: u8, + /// Reserved bytes in the wire format + flags_a: u8, + /// Reserved bytes in the wire format + flags_b: u8, + /// Reserved byte in the wire format + reserved: u8, + /// Length of auxiliary data section + aux_length: u32, + /// Total length of payload (aux + data) + total_length: u32, + /// Additional payload flags + flags: u32, +} + +/// Header for auxiliary data section +/// +/// 16-byte structure preceding auxiliary data +#[derive(Debug, Default, Clone, Copy, PartialEq)] +pub struct AuxHeader { + /// Buffer size hint (often 496) + buffer_size: u32, + /// Unknown field (typically 0) + unknown: u32, + /// Actual size of auxiliary data + aux_size: u32, + /// Unknown field (typically 0) + unknown2: u32, +} + +/// Auxiliary data container +/// +/// Contains a header and a collection of typed values +#[derive(Debug, Clone, PartialEq)] +pub struct Aux { + /// Auxiliary data header + pub header: AuxHeader, + /// Collection of auxiliary values + pub values: Vec, +} + +/// Typed auxiliary value that can be included in messages +#[derive(Clone, PartialEq)] +pub enum AuxValue { + /// NULL value (type 0x0a) - no payload bytes + Null, + /// UTF-8 string value (type 0x01) + String(String), + /// Raw byte array (type 0x02) + Array(Vec), + /// 32-bit unsigned integer (type 0x03) + U32(u32), + /// 64-bit signed integer (type 0x06) + I64(i64), + /// 64-bit floating point (double) (type 0x09) + Double(f64), + /// Primitive dictionary (type 0xF0) - value is a list of primitives to match pymobiledevice3 format + PrimitiveDictionary(Vec<(AuxValue, Vec)>), +} + +/// Complete protocol message +#[derive(Clone, PartialEq)] +pub struct Message { + /// Message metadata header + pub message_header: MessageHeader, + /// Payload description header + pub payload_header: PayloadHeader, + /// Optional auxiliary data + pub aux: Option, + /// Optional payload data (typically NSKeyedArchive) + pub data: Option, + /// Raw bytes of the data section before NSKeyedArchive decoding + pub raw_data: Option>, +} + +impl Aux { + /// Parses the legacy aux wire format used on iOS 16 and earlier + /// + /// Layout: `[AuxHeader (16 B)][type (4 B)][data...][type (4 B)][data...]...` + /// + /// Type 0xF0 entries are `PrimitiveDictionary` blocks embedded inside + /// the legacy envelope; their bodies are skipped since the useful values + /// are the surrounding flat entries. + fn parse_legacy_bytes(bytes: Vec) -> Result { + if bytes.len() < 16 { + return Err(IdeviceError::NotEnoughBytes(bytes.len(), 16)); + } + + let mut cursor = Cursor::new(bytes.as_slice()); + let header = AuxHeader { + buffer_size: Self::read_u32(&mut cursor)?, + unknown: Self::read_u32(&mut cursor)?, + aux_size: Self::read_u32(&mut cursor)?, + unknown2: Self::read_u32(&mut cursor)?, + }; + + let mut values = Vec::new(); + while cursor.position() + 4 <= bytes.len() as u64 { + let aux_type = Self::read_u32(&mut cursor)?; + match aux_type { + 0x0a => { + // PNULL separator — used as dictionary keys; not a user value. + } + 0x0f0 => { + // PrimitiveDictionary block embedded in a legacy envelope. + // Layout after the type: u32 flags, u64 body_length, [body]. + // Skip the entire block; positional args appear as flat entries. + let _flags = Self::read_u32(&mut cursor)?; + let body_len = Self::read_u64(&mut cursor)?; + let pos = cursor.position() as usize; + let end = pos + body_len as usize; + if end > bytes.len() { + return Err(IdeviceError::NotEnoughBytes(bytes.len(), end)); + } + cursor.set_position(end as u64); + } + _ => { + // All other types share the same encoding as parse_primitive, + // but the type word is already consumed above so we reconstruct + // a cursor over [type || remaining] to reuse parse_primitive. + let pos = cursor.position() as usize - 4; + let mut sub = Cursor::new(&bytes[pos..]); + values.push(Self::parse_primitive(&mut sub)?); + cursor.set_position(pos as u64 + sub.position()); + } + } + } + + Ok(Self { header, values }) + } + + fn read_u32(cursor: &mut Cursor<&[u8]>) -> Result { + let mut buf = [0u8; 4]; + Read::read_exact(cursor, &mut buf)?; + Ok(u32::from_le_bytes(buf)) + } + + fn read_u64(cursor: &mut Cursor<&[u8]>) -> Result { + let mut buf = [0u8; 8]; + Read::read_exact(cursor, &mut buf)?; + Ok(u64::from_le_bytes(buf)) + } + + fn read_f64(cursor: &mut Cursor<&[u8]>) -> Result { + let mut buf = [0u8; 8]; + Read::read_exact(cursor, &mut buf)?; + Ok(f64::from_le_bytes(buf)) + } + + fn read_exact_vec(cursor: &mut Cursor<&[u8]>, len: usize) -> Result, IdeviceError> { + let mut buf = vec![0u8; len]; + Read::read_exact(cursor, &mut buf)?; + Ok(buf) + } + + fn parse_primitive(cursor: &mut Cursor<&[u8]>) -> Result { + let raw_type = Self::read_u32(cursor)?; + let type_code = raw_type & 0xFF; + match type_code { + 0x01 => { + let len = Self::read_u32(cursor)? as usize; + Ok(AuxValue::String(String::from_utf8(Self::read_exact_vec( + cursor, len, + )?)?)) + } + 0x02 => { + let len = Self::read_u32(cursor)? as usize; + Ok(AuxValue::Array(Self::read_exact_vec(cursor, len)?)) + } + 0x03 => Ok(AuxValue::U32(Self::read_u32(cursor)?)), + 0x06 => Ok(AuxValue::I64(Self::read_u64(cursor)? as i64)), + 0x09 => Ok(AuxValue::Double(Self::read_f64(cursor)?)), + 0x0A => Ok(AuxValue::Null), + _ => Err(DvtError::UnknownAuxValueType(raw_type).into()), + } + } + + /// Parses auxiliary data from bytes, selecting the correct wire format + /// based on the leading magic byte. + /// + /// # Wire formats + /// + /// **Legacy**: the first byte is NOT `0xF0`. + /// The buffer begins with a 16-byte `AuxHeader` followed by flat + /// type-tagged value entries. + /// + /// **Modern** (iOS 17+, RSD/testmanagerd path): the first byte IS `0xF0`, + /// indicating the entire buffer is a single `PrimitiveDictionary` block + /// (`[flags(4B)][unknown(4B)][body_len(8B)][key-value pairs...]`). + /// Keys are positional-null sentinels; only the values are collected. + pub fn from_bytes(bytes: Vec) -> Result { + if bytes.is_empty() { + return Ok(Self::from_values(Vec::new())); + } + + if (bytes[0] as u32) != 0xF0 { + return Self::parse_legacy_bytes(bytes); + } + + if bytes.len() < 16 { + return Err(IdeviceError::NotEnoughBytes(bytes.len(), 16)); + } + + let mut cursor = Cursor::new(bytes.as_slice()); + let _type_and_flags = Self::read_u32(&mut cursor)?; + let _unknown_flags = Self::read_u32(&mut cursor)?; + let body_len = Self::read_u64(&mut cursor)?; + let body_end = 16u64 + body_len; + if body_end > bytes.len() as u64 { + return Err(IdeviceError::NotEnoughBytes(bytes.len(), body_end as usize)); + } + + let mut values = Vec::new(); + while cursor.position() < body_end { + let _key = Self::parse_primitive(&mut cursor)?; + let value = Self::parse_primitive(&mut cursor)?; + values.push(value); + } + + Ok(Self { + header: AuxHeader::default(), + values, + }) + } + + /// Creates new auxiliary data from values + /// + /// Note: Header fields are populated during serialization + /// + /// # Arguments + /// * `values` - Collection of auxiliary values to include + pub fn from_values(values: Vec) -> Self { + Self { + header: AuxHeader::default(), + values, + } + } + + /// Serializes auxiliary data to bytes + /// + /// Includes properly formatted header with updated size fields + pub fn serialize(&self) -> Vec { + let mut values_payload = Vec::new(); + for v in self.values.iter() { + values_payload.extend_from_slice(&0x0a_u32.to_le_bytes()); + match v { + AuxValue::Null => { + // PNULL - type 0x0a with no payload bytes + } + AuxValue::String(s) => { + values_payload.extend_from_slice(&0x01_u32.to_le_bytes()); + values_payload.extend_from_slice(&(s.len() as u32).to_le_bytes()); + values_payload.extend_from_slice(s.as_bytes()); + } + AuxValue::Array(v) => { + values_payload.extend_from_slice(&0x02_u32.to_le_bytes()); + values_payload.extend_from_slice(&(v.len() as u32).to_le_bytes()); + values_payload.extend_from_slice(v); + } + AuxValue::U32(u) => { + values_payload.extend_from_slice(&0x03_u32.to_le_bytes()); + values_payload.extend_from_slice(&u.to_le_bytes()); + } + AuxValue::I64(i) => { + values_payload.extend_from_slice(&0x06_u32.to_le_bytes()); + values_payload.extend_from_slice(&i.to_le_bytes()); + } + AuxValue::Double(d) => { + values_payload.extend_from_slice(&0x09_u32.to_le_bytes()); + values_payload.extend_from_slice(&d.to_le_bytes()); + } + AuxValue::PrimitiveDictionary(entries) => { + // PrimitiveDictionary: type=0xF0, entries are (key, [values]) pairs + // Header: u32 magic (0x1F0), u32 unknown (0), u64 body_length + // pymobiledevice3 format: {PNULL: [arg1, arg2, ...]} + let mut body_payload = Vec::new(); + for (key, values) in entries { + // Write the key primitive once (typically NULL 0x0a) + body_payload.extend_from_slice(&0x0a_u32.to_le_bytes()); + write_primitive_value(key, &mut body_payload); + // Write each value in the list + for value in values { + write_primitive_value(value, &mut body_payload); + } + } + let body_len = body_payload.len() as u64; + values_payload.extend_from_slice(&0xf0_u32.to_le_bytes()); + values_payload.extend_from_slice(&0_u32.to_le_bytes()); // unknown flags + values_payload.extend_from_slice(&body_len.to_le_bytes()); + values_payload.extend_from_slice(&body_payload); + } + } + } + + let mut res = Vec::new(); + let buffer_size = 496_u32; + res.extend_from_slice(&buffer_size.to_le_bytes()); + res.extend_from_slice(&0_u32.to_le_bytes()); + res.extend_from_slice(&(values_payload.len() as u32).to_le_bytes()); + res.extend_from_slice(&0_u32.to_le_bytes()); + res.extend_from_slice(&values_payload); + res + } +} + +/// Helper to write a primitive value to the payload +fn write_primitive_value(v: &AuxValue, payload: &mut Vec) { + match v { + AuxValue::Null => { + // PNULL - type 0x0a with no payload bytes + } + AuxValue::String(s) => { + payload.extend_from_slice(&0x01_u32.to_le_bytes()); + payload.extend_from_slice(&(s.len() as u32).to_le_bytes()); + payload.extend_from_slice(s.as_bytes()); + } + AuxValue::Array(bytes) => { + payload.extend_from_slice(&0x02_u32.to_le_bytes()); + payload.extend_from_slice(&(bytes.len() as u32).to_le_bytes()); + payload.extend_from_slice(bytes); + } + AuxValue::U32(val) => { + payload.extend_from_slice(&0x03_u32.to_le_bytes()); + payload.extend_from_slice(&val.to_le_bytes()); + } + AuxValue::I64(val) => { + payload.extend_from_slice(&0x06_u32.to_le_bytes()); + payload.extend_from_slice(&val.to_le_bytes()); + } + AuxValue::Double(val) => { + payload.extend_from_slice(&0x09_u32.to_le_bytes()); + payload.extend_from_slice(&val.to_le_bytes()); + } + AuxValue::PrimitiveDictionary(_) => { + // Nested dictionaries not typically used as primitive values + // Write as empty dict + payload.extend_from_slice(&0xf0_u32.to_le_bytes()); + payload.extend_from_slice(&0_u32.to_le_bytes()); + payload.extend_from_slice(&0u64.to_le_bytes()); + } + } +} + +impl AuxValue { + /// Creates an auxiliary value containing NSKeyedArchived data + /// + /// # Arguments + /// * `v` - Plist value to archive + pub fn archived_value(v: impl Into) -> Self { + Self::Array(ns_keyed_archive::encode::encode_to_bytes(v.into()).expect("Failed to encode")) + } + + /// Creates a primitive buffer (immutable buffer) containing raw bytes + /// This is used for passing primitive arrays as DTX arguments (wire type 0x02) + /// + /// # Arguments + /// * `bytes` - Raw bytes to include in the buffer + pub fn primitive_buffer(bytes: Vec) -> Self { + Self::Array(bytes) + } + + /// Creates a PrimitiveDictionary auxiliary value (wire type 0xF0) + /// + /// # Arguments + /// * `entries` - List of (key, [values]) pairs + pub fn primitive_dictionary(entries: Vec<(AuxValue, Vec)>) -> Self { + Self::PrimitiveDictionary(entries) + } + + /// Creates a DTX method call auxiliary argument format matching pymobiledevice3 + /// + /// This wraps all arguments in a single PrimitiveDictionary with a NULL key, + /// producing the structure `{PNULL: [arg1, arg2, ...]}`. + /// + /// This matches pymobiledevice3's `MessageAux.build()` which creates `PDict({PNULL: converted_list})`. + /// + /// # Arguments + /// * `args` - List of arguments to wrap + pub fn dtx_method_args(args: Vec) -> Self { + // Create a PrimitiveDictionary with a single entry: (PNULL, [arg1, arg2, ...]) + Self::PrimitiveDictionary(vec![(Self::Null, args)]) + } +} + +impl MessageHeader { + /// Creates a new message header + /// + /// Note: Length field is updated during message serialization + /// + /// # Arguments + /// * `fragment_id` - Identifier for message fragments + /// * `fragment_count` - Total fragments in message + /// * `identifier` - Unique message ID + /// * `conversation_index` - Conversation tracking number + /// * `channel` - Channel number + /// * `expects_reply` - Whether response is expected + pub fn new( + fragment_id: u16, + fragment_count: u16, + identifier: u32, + conversation_index: u32, + channel: i32, + expects_reply: bool, + ) -> Self { + Self { + magic: 0x1F3D5B79, + header_len: 32, + fragment_id, + fragment_count, + length: 0, + identifier, + conversation_index, + channel, + expects_reply, + } + } + + /// Returns the unique message identifier. + pub(crate) fn identifier(&self) -> u32 { + self.identifier + } + + /// Returns the conversation index for this message. + pub(crate) fn conversation_index(&self) -> u32 { + self.conversation_index + } + + /// Returns whether this message expects a reply. + pub(crate) fn expects_reply(&self) -> bool { + self.expects_reply + } + + /// Serializes header to bytes + pub fn serialize(&self) -> Vec { + let mut res = Vec::new(); + res.extend_from_slice(&self.magic.to_le_bytes()); + res.extend_from_slice(&self.header_len.to_le_bytes()); + res.extend_from_slice(&self.fragment_id.to_le_bytes()); + res.extend_from_slice(&self.fragment_count.to_le_bytes()); + res.extend_from_slice(&self.length.to_le_bytes()); + res.extend_from_slice(&self.identifier.to_le_bytes()); + res.extend_from_slice(&self.conversation_index.to_le_bytes()); + res.extend_from_slice(&self.channel.to_le_bytes()); + res.extend_from_slice(&if self.expects_reply { 1_u32 } else { 0 }.to_le_bytes()); + + res + } +} + +impl PayloadHeader { + /// Creates a new payload header + pub fn new() -> Self { + Self::default() + } + + /// Serializes header to bytes + pub fn serialize(&self) -> Vec { + let mut res = vec![self.msg_type, self.flags_a, self.flags_b, self.reserved]; + res.extend_from_slice(&self.aux_length.to_le_bytes()); + res.extend_from_slice(&self.total_length.to_le_bytes()); + res.extend_from_slice(&self.flags.to_le_bytes()); + + res + } + + /// Creates header for method invocation messages + pub fn method_invocation() -> Self { + Self { + msg_type: 2, + ..Default::default() + } + } +} + +impl Message { + /// Reads and parses a message from an async reader + /// + /// # Arguments + /// * `reader` - Async reader to read from + /// + /// # Returns + /// * `Ok(Message)` - Parsed message + /// * `Err(IdeviceError)` - If reading/parsing fails + /// + /// # Errors + /// * Various IdeviceError variants for IO and parsing failures + pub async fn from_reader(reader: &mut R) -> Result { + let mut packet_data: Vec = Vec::new(); + // loop for deal with multiple fragments + let mheader = loop { + let mut buf = [0u8; 32]; + reader.read_exact(&mut buf).await?; + let header = MessageHeader { + magic: u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]), + header_len: u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]), + fragment_id: u16::from_le_bytes([buf[8], buf[9]]), + fragment_count: u16::from_le_bytes([buf[10], buf[11]]), + length: u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]), + identifier: u32::from_le_bytes([buf[16], buf[17], buf[18], buf[19]]), + conversation_index: u32::from_le_bytes([buf[20], buf[21], buf[22], buf[23]]), + channel: { + let wire_channel = i32::from_le_bytes([buf[24], buf[25], buf[26], buf[27]]); + let conversation_index = + u32::from_le_bytes([buf[20], buf[21], buf[22], buf[23]]); + if conversation_index.is_multiple_of(2) { + -wire_channel + } else { + wire_channel + } + }, + expects_reply: u32::from_le_bytes([buf[28], buf[29], buf[30], buf[31]]) == 1, + }; + if header.fragment_count > 1 && header.fragment_id == 0 { + // when reading multiple message fragments, the first fragment contains only a message header. + continue; + } + let mut buf = vec![0u8; header.length as usize]; + reader.read_exact(&mut buf).await?; + packet_data.extend(buf); + if header.fragment_id == header.fragment_count - 1 { + break header; + } + }; + // read the payload header + let buf = &packet_data[0..16]; + let pheader = PayloadHeader { + msg_type: buf[0], + flags_a: buf[1], + flags_b: buf[2], + reserved: buf[3], + aux_length: u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]), + total_length: u32::from_le_bytes([buf[8], buf[9], buf[10], buf[11]]), + flags: u32::from_le_bytes([buf[12], buf[13], buf[14], buf[15]]), + }; + let aux = if pheader.aux_length > 0 { + let buf = packet_data[16..(16 + pheader.aux_length as usize)].to_vec(); + Some(Aux::from_bytes(buf)?) + } else { + None + }; + // read the data + let need_len = (pheader.total_length - pheader.aux_length) as usize; + let buf = packet_data + [(pheader.aux_length + 16) as usize..pheader.aux_length as usize + 16 + need_len] + .to_vec(); + let raw_data = if buf.is_empty() { + None + } else { + Some(buf.clone()) + }; + let data = if buf.is_empty() { + None + } else { + Some( + ns_keyed_archive::decode::from_bytes(&buf) + .map_err(super::errors::DvtError::from)?, + ) + }; + + Ok(Message { + message_header: mheader, + payload_header: pheader, + aux, + data, + raw_data, + }) + } + + /// Creates a new message + /// + /// # Arguments + /// * `message_header` - Message metadata + /// * `payload_header` - Payload description + /// * `aux` - Optional auxiliary data + /// * `data` - Optional payload data + pub fn new( + message_header: MessageHeader, + payload_header: PayloadHeader, + aux: Option, + data: Option, + ) -> Self { + Self { + message_header, + payload_header, + aux, + data, + raw_data: None, + } + } + + /// Serializes message to bytes + /// + /// Updates length fields in headers automatically + pub fn serialize(&self) -> Vec { + let aux = match &self.aux { + Some(a) => a.serialize(), + None => Vec::new(), + }; + let data = match &self.data { + Some(d) => ns_keyed_archive::encode::encode_to_bytes(d.to_owned()) + .expect("Failed to encode value"), + None => Vec::new(), + }; + + // Update the payload header + let mut payload_header = self.payload_header.to_owned(); + payload_header.aux_length = aux.len() as u32; + payload_header.total_length = (aux.len() + data.len()) as u32; + let payload_header = payload_header.serialize(); + + // Update the message header + let mut message_header = self.message_header.to_owned(); + message_header.length = (payload_header.len() + aux.len() + data.len()) as u32; + + let mut res = Vec::new(); + res.extend_from_slice(&message_header.serialize()); + res.extend_from_slice(&payload_header); + res.extend_from_slice(&aux); + res.extend_from_slice(&data); + + res + } + + /// Builds a raw reply frame for an incoming message, sending `data_bytes` + /// verbatim as the payload without additional NSKeyedArchive encoding. + /// + /// This is used for replies where the payload is already a serialised + /// NSKeyedArchive (e.g. `XCTestConfiguration`). Pass an empty slice to + /// send an acknowledgement with no payload. + pub(crate) fn build_raw_reply( + channel: i32, + incoming_msg_id: u32, + incoming_conversation_index: u32, + data_bytes: &[u8], + ) -> Vec { + // Payload header (16 bytes): flags=0, aux_len=0, total_len + let msg_type: u8 = if data_bytes.is_empty() { 0 } else { 3 }; + let flags_a: u8 = 0; + let flags_b: u8 = 0; + let reserved: u8 = 0; + let aux_len: u32 = 0; + let total_len: u32 = data_bytes.len() as u32; + + let payload_total = 16usize + data_bytes.len(); // payload_hdr + data + + // Message header (32 bytes) + let magic: u32 = 0x1F3D5B79; + let header_len: u32 = 32; + let fragment_id: u16 = 0; + let fragment_count: u16 = 1; + let length: u32 = payload_total as u32; + let conversation_index = incoming_conversation_index + 1; + let expects_reply: u32 = 0; + let wire_channel = if conversation_index.is_multiple_of(2) { + channel + } else { + -channel + }; + + let mut buf = Vec::with_capacity(32 + 16 + data_bytes.len()); + buf.extend_from_slice(&magic.to_le_bytes()); + buf.extend_from_slice(&header_len.to_le_bytes()); + buf.extend_from_slice(&fragment_id.to_le_bytes()); + buf.extend_from_slice(&fragment_count.to_le_bytes()); + buf.extend_from_slice(&length.to_le_bytes()); + buf.extend_from_slice(&incoming_msg_id.to_le_bytes()); + buf.extend_from_slice(&conversation_index.to_le_bytes()); + buf.extend_from_slice(&wire_channel.to_le_bytes()); + buf.extend_from_slice(&expects_reply.to_le_bytes()); + // Payload header + buf.push(msg_type); + buf.push(flags_a); + buf.push(flags_b); + buf.push(reserved); + buf.extend_from_slice(&aux_len.to_le_bytes()); + buf.extend_from_slice(&total_len.to_le_bytes()); + buf.extend_from_slice(&0_u32.to_le_bytes()); + // Data + buf.extend_from_slice(data_bytes); + buf + } +} + +impl std::fmt::Debug for AuxValue { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + AuxValue::Null => write!(f, "Null"), + AuxValue::String(s) => write!(f, "String({s:?})"), + AuxValue::Array(arr) => write!( + f, + "Array(len={}, first_bytes={:?})", + arr.len(), + &arr[..arr.len().min(10)] + ), + AuxValue::U32(n) => write!(f, "U32({n})"), + AuxValue::I64(n) => write!(f, "I64({n})"), + AuxValue::Double(d) => write!(f, "Double({d})"), + AuxValue::PrimitiveDictionary(_) => write!(f, "PrimitiveDictionary"), + } + } +} + +impl std::fmt::Debug for Message { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Message") + .field("message_header", &self.message_header) + .field("payload_header", &self.payload_header) + .field("aux", &self.aux) + .field("data", &self.data.as_ref().map(pretty_print_plist)) + .finish() + } +} diff --git a/src-tauri/vendor/idevice/src/services/dvt/mod.rs b/src-tauri/vendor/idevice/src/services/dvt/mod.rs new file mode 100644 index 0000000..27b95c7 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/dvt/mod.rs @@ -0,0 +1,111 @@ +// Jackson Coxson + +use crate::provider::IdeviceProvider; +use crate::services::lockdown::LockdownClient; +use crate::{Idevice, IdeviceError, ReadWrite, obf}; + +#[cfg(feature = "rsd")] +use crate::RsdService; + +#[cfg(feature = "application_listing")] +pub mod application_listing; +#[cfg(feature = "condition_inducer")] +pub mod condition_inducer; +#[cfg(feature = "device_info")] +pub mod device_info; +#[cfg(any( + feature = "energy_monitor", + feature = "graphics", + feature = "rsd", + feature = "sysmontap" +))] +pub mod energy_monitor; +pub mod errors; +#[cfg(feature = "graphics")] +pub mod graphics; +#[cfg(feature = "location_simulation")] +pub mod location_simulation; +pub mod message; +#[cfg(feature = "network_monitor")] +pub mod network_monitor; +pub mod notifications; +pub mod process_control; +pub mod remote_server; +pub mod screenshot; +#[cfg(feature = "sysmontap")] +pub mod sysmontap; +#[cfg(feature = "xctest")] +pub mod xctest; + +impl RsdService for remote_server::RemoteServerClient> { + fn rsd_service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.instruments.dtservicehub") + } + + async fn from_stream(stream: Box) -> Result { + Ok(Self::new(stream)) + } +} + +// iOS version support notes: +// - com.apple.instruments.dtservicehub (RSD/XPC over HTTP2) is used on iOS 17+. +// - com.apple.instruments.remoteserver is available on pre-iOS 17 (and many older versions). +// - com.apple.instruments.remoteserver.DVTSecureSocketProxy is used by some iOS 14 builds. +// +// This impl enables Lockdown-based connection to Instruments Remote Server for iOS < 17 +// by reusing the same RemoteServerClient but sourcing the transport from StartService. +impl crate::IdeviceService for remote_server::RemoteServerClient> { + fn service_name() -> std::borrow::Cow<'static, str> { + // Primary name for Instruments Remote Server + obf!("com.apple.instruments.remoteserver") + } + + #[allow(async_fn_in_trait)] + async fn connect(provider: &dyn IdeviceProvider) -> Result { + // Establish Lockdown session + let mut lockdown = LockdownClient::connect(provider).await?; + + let legacy = lockdown + .start_session(&provider.get_pairing_file().await?) + .await?; + + // Try main Instruments service first, then DVTSecureSocketProxy (seen on iOS 14) + let try_names = [ + obf!("com.apple.instruments.remoteserver"), + obf!("com.apple.instruments.remoteserver.DVTSecureSocketProxy"), + ]; + + let mut last_err: Option = None; + for name in try_names { + match lockdown.start_service(name).await { + Ok((port, ssl)) => { + let mut idevice = provider.connect(port).await?; + if ssl { + idevice + .start_session(&provider.get_pairing_file().await?, legacy) + .await?; + } + // Convert to transport and build client + let socket = idevice + .get_socket() + .ok_or(IdeviceError::NoEstablishedConnection)?; + return Ok(remote_server::RemoteServerClient::new(socket)); + } + Err(e) => { + last_err = Some(e); + } + } + } + + Err(last_err.unwrap_or(IdeviceError::ServiceNotFound)) + } + + #[allow(async_fn_in_trait)] + async fn from_stream(idevice: Idevice) -> Result { + // Not used in our overridden connect path, but implemented for completeness + let socket = idevice + .get_socket() + .ok_or(IdeviceError::NoEstablishedConnection)?; + Ok(remote_server::RemoteServerClient::new(socket)) + } +} diff --git a/src-tauri/vendor/idevice/src/services/dvt/network_monitor.rs b/src-tauri/vendor/idevice/src/services/dvt/network_monitor.rs new file mode 100644 index 0000000..197c40b --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/dvt/network_monitor.rs @@ -0,0 +1,227 @@ +//! Network monitor service - Monitor network connections on the device + +use plist::Value; + +use super::remote_server::{Channel, RemoteServerClient}; +use crate::{IdeviceError, ReadWrite, obf}; + +pub const MESSAGE_TYPE_INTERFACE_DETECTION: u64 = 0; +pub const MESSAGE_TYPE_CONNECTION_DETECTION: u64 = 1; +pub const MESSAGE_TYPE_CONNECTION_UPDATE: u64 = 2; + +/// A network interface that was detected +#[derive(Debug, Clone)] +pub struct InterfaceDetectionEvent { + pub interface_index: u32, + pub name: String, +} + +/// A socket address (IPv4 or IPv6) +#[derive(Debug, Clone)] +pub struct SocketAddress { + pub family: u8, + pub port: u16, + pub addr: String, +} + +impl SocketAddress { + /// Parse a raw socket address byte slice. + /// Layout: u8 length, u8 family, u16be port, then family-specific address bytes. + pub fn from_bytes(data: &[u8]) -> Option { + if data.len() < 4 { + return None; + } + let length = data[0]; + let family = data[1]; + let port = u16::from_be_bytes([data[2], data[3]]); + + let addr = match length { + // IPv4: 16 bytes total, 4 addr bytes at offset 4 + 0x10 if data.len() >= 8 => { + format!("{}.{}.{}.{}", data[4], data[5], data[6], data[7]) + } + // IPv6: 28 bytes total, 16 addr bytes at offset 8 + 0x1c if data.len() >= 24 => { + let bytes: [u8; 16] = data[8..24].try_into().ok()?; + let addr = std::net::Ipv6Addr::from(bytes); + addr.to_string() + } + _ => format!("unknown(family={})", family), + }; + + Some(SocketAddress { family, port, addr }) + } +} + +/// A new network connection was detected +#[derive(Debug, Clone)] +pub struct ConnectionDetectionEvent { + pub local_address: Option, + pub remote_address: Option, + pub interface_index: u32, + pub pid: u32, + pub recv_buffer_size: u64, + pub recv_buffer_used: u64, + pub serial_number: u64, + pub kind: u32, +} + +/// An existing connection was updated with new stats +#[derive(Debug, Clone, Copy)] +pub struct ConnectionUpdateEvent { + pub rx_packets: u64, + pub rx_bytes: u64, + pub tx_packets: u64, + pub tx_bytes: u64, + pub rx_dups: u64, + pub rx_ooo: u64, + pub tx_retx: u64, + pub min_rtt: u64, + pub avg_rtt: u64, + pub connection_serial: u64, + pub time: u64, +} + +/// A network monitoring event +#[derive(Debug, Clone)] +pub enum NetworkEvent { + InterfaceDetection(InterfaceDetectionEvent), + ConnectionDetection(ConnectionDetectionEvent), + ConnectionUpdate(ConnectionUpdateEvent), + Unknown(u64), +} + +/// Client for monitoring network connections +#[derive(Debug)] +pub struct NetworkMonitorClient<'a, R: ReadWrite> { + channel: Channel<'a, R>, +} + +impl<'a, R: ReadWrite> NetworkMonitorClient<'a, R> { + pub async fn new(client: &'a mut RemoteServerClient) -> Result { + let channel = client + .make_channel(obf!("com.apple.instruments.server.services.networking")) + .await?; + Ok(Self { channel }) + } + + /// Starts monitoring. No reply is expected. + pub async fn start_monitoring(&mut self) -> Result<(), IdeviceError> { + self.channel + .call_method(Some(Value::String("startMonitoring".into())), None, false) + .await + } + + /// Stops monitoring. + pub async fn stop_monitoring(&mut self) -> Result<(), IdeviceError> { + self.channel + .call_method(Some(Value::String("stopMonitoring".into())), None, false) + .await + } + + /// Reads the next network event from the device. + /// The device pushes events as arrays: [type, [args...]]. + pub async fn next_event(&mut self) -> Result { + loop { + let msg = self.channel.read_message().await?; + + let data = match msg.data { + Some(d) => d, + None => continue, + }; + + let arr = match data.into_array() { + Some(a) => a, + None => continue, + }; + if arr.len() < 2 { + continue; + } + let msg_type = match &arr[0] { + Value::Integer(i) => i.as_unsigned().unwrap_or(u64::MAX), + _ => continue, + }; + let args = match arr.into_iter().nth(1).and_then(|v| v.into_array()) { + Some(a) => a, + None => continue, + }; + + let event = match msg_type { + MESSAGE_TYPE_INTERFACE_DETECTION => { + let interface_index = get_u32_at(&args, 0); + let name = get_string_at(&args, 1); + NetworkEvent::InterfaceDetection(InterfaceDetectionEvent { + interface_index, + name, + }) + } + MESSAGE_TYPE_CONNECTION_DETECTION => { + let local_address = get_bytes_at(&args, 0).and_then(SocketAddress::from_bytes); + let remote_address = get_bytes_at(&args, 1).and_then(SocketAddress::from_bytes); + let interface_index = get_u32_at(&args, 2); + let pid = get_u32_at(&args, 3); + let recv_buffer_size = get_u64_at(&args, 4); + let recv_buffer_used = get_u64_at(&args, 5); + let serial_number = get_u64_at(&args, 6); + let kind = get_u32_at(&args, 7); + NetworkEvent::ConnectionDetection(ConnectionDetectionEvent { + local_address, + remote_address, + interface_index, + pid, + recv_buffer_size, + recv_buffer_used, + serial_number, + kind, + }) + } + MESSAGE_TYPE_CONNECTION_UPDATE => { + NetworkEvent::ConnectionUpdate(ConnectionUpdateEvent { + rx_packets: get_u64_at(&args, 0), + rx_bytes: get_u64_at(&args, 1), + tx_packets: get_u64_at(&args, 2), + tx_bytes: get_u64_at(&args, 3), + rx_dups: get_u64_at(&args, 4), + rx_ooo: get_u64_at(&args, 5), + tx_retx: get_u64_at(&args, 6), + min_rtt: get_u64_at(&args, 7), + avg_rtt: get_u64_at(&args, 8), + connection_serial: get_u64_at(&args, 9), + time: get_u64_at(&args, 10), + }) + } + _ => NetworkEvent::Unknown(msg_type), + }; + return Ok(event); + } + } +} + +fn get_u32_at(arr: &[Value], idx: usize) -> u32 { + arr.get(idx) + .and_then(|v| match v { + Value::Integer(i) => i.as_unsigned().map(|x| x as u32), + _ => None, + }) + .unwrap_or(0) +} + +fn get_u64_at(arr: &[Value], idx: usize) -> u64 { + arr.get(idx) + .and_then(|v| match v { + Value::Integer(i) => i.as_unsigned(), + _ => None, + }) + .unwrap_or(0) +} + +fn get_string_at(arr: &[Value], idx: usize) -> String { + arr.get(idx) + .and_then(|v| v.as_string()) + .unwrap_or("") + .to_string() +} + +fn get_bytes_at(arr: &[Value], idx: usize) -> Option<&[u8]> { + arr.get(idx).and_then(|v| v.as_data()) +} diff --git a/src-tauri/vendor/idevice/src/services/dvt/notifications.rs b/src-tauri/vendor/idevice/src/services/dvt/notifications.rs new file mode 100644 index 0000000..761c180 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/dvt/notifications.rs @@ -0,0 +1,166 @@ +//! Notificaitons service client for iOS instruments protocol. +//! +//! Monitor memory and app notifications + +use crate::{ + IdeviceError, ReadWrite, + dvt::{ + message::AuxValue, + remote_server::{Channel, RemoteServerClient}, + }, + obf, +}; +use plist::Value; +use tracing::warn; + +#[derive(Debug)] +pub struct NotificationInfo { + pub notification_type: String, + pub mach_absolute_time: i64, + pub exec_name: String, + pub app_name: String, + pub pid: u32, + pub state_description: String, +} + +#[derive(Debug)] +pub struct NotificationsClient<'a, R: ReadWrite> { + /// The underlying channel used for communication + pub channel: Channel<'a, R>, +} + +impl<'a, R: ReadWrite> NotificationsClient<'a, R> { + /// Opens a new channel on the remote server client for app notifications + /// + /// # Arguments + /// * `client` - The remote server client to connect with + /// + /// # Returns + /// The client on success, IdeviceError on failure + pub async fn new(client: &'a mut RemoteServerClient) -> Result { + let channel = client + .make_channel(obf!( + "com.apple.instruments.server.services.mobilenotifications" + )) + .await?; // Drop `&mut client` before continuing + + Ok(Self { channel }) + } + + /// set the applicaitons and memory notifications enabled + pub async fn start_notifications(&mut self) -> Result<(), IdeviceError> { + let application_method = Value::String("setApplicationStateNotificationsEnabled:".into()); + self.channel + .call_method( + Some(application_method), + Some(vec![AuxValue::archived_value(true)]), + false, + ) + .await?; + let memory_method = Value::String("setMemoryNotificationsEnabled:".into()); + self.channel + .call_method( + Some(memory_method), + Some(vec![AuxValue::archived_value(true)]), + false, + ) + .await?; + Ok(()) + } + + /// Reads the next notification from the service + pub async fn get_notification(&mut self) -> Result { + let message = self.channel.read_message().await?; + let mut notification = NotificationInfo { + notification_type: "".to_string(), + mach_absolute_time: 0, + exec_name: String::new(), + app_name: String::new(), + pid: 0, + state_description: String::new(), + }; + if let Some(aux) = message.aux { + for v in aux.values { + match v { + AuxValue::Array(a) => match ns_keyed_archive::decode::from_bytes(&a) { + Ok(archive) => { + if let Some(dict) = archive.into_dictionary() { + for (key, value) in dict.into_iter() { + match key.as_str() { + "mach_absolute_time" => { + if let Value::Integer(time) = value { + notification.mach_absolute_time = + time.as_signed().unwrap_or(0); + } + } + "execName" => { + if let Value::String(name) = value { + notification.exec_name = name; + } + } + "appName" => { + if let Value::String(name) = value { + notification.app_name = name; + } + } + "pid" => { + if let Value::Integer(pid) = value { + notification.pid = + pid.as_unsigned().unwrap_or(0) as u32; + } + } + "state_description" => { + if let Value::String(desc) = value { + notification.state_description = desc; + } + } + _ => { + warn!("Unknown notificaton key: {} = {:?}", key, value); + } + } + } + } + } + Err(e) => { + warn!("Failed to decode archive: {:?}", e); + } + }, + _ => { + warn!("Non-array aux value: {:?}", v); + } + } + } + } + + if let Some(Value::String(data)) = message.data { + notification.notification_type = data; + Ok(notification) + } else { + Err(IdeviceError::UnexpectedResponse( + "missing notification type string in message data".into(), + )) + } + } + + /// set the applicaitons and memory notifications disable + pub async fn stop_notifications(&mut self) -> Result<(), IdeviceError> { + let application_method = Value::String("setApplicationStateNotificationsEnabled:".into()); + self.channel + .call_method( + Some(application_method), + Some(vec![AuxValue::archived_value(false)]), + false, + ) + .await?; + let memory_method = Value::String("setMemoryNotificationsEnabled:".into()); + self.channel + .call_method( + Some(memory_method), + Some(vec![AuxValue::archived_value(false)]), + false, + ) + .await?; + + Ok(()) + } +} diff --git a/src-tauri/vendor/idevice/src/services/dvt/process_control.rs b/src-tauri/vendor/idevice/src/services/dvt/process_control.rs new file mode 100644 index 0000000..b910e17 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/dvt/process_control.rs @@ -0,0 +1,336 @@ +//! Process Control service client for iOS instruments protocol. +//! +//! This module provides a client for interacting with the process control service +//! on iOS devices through the instruments protocol. It allows launching, killing, +//! and managing processes on the device. +//! +//! # Example +//! ```rust,no_run +//! #[tokio::main] +//! async fn main() -> Result<(), IdeviceError> { +//! // Create base client (implementation specific) +//! let mut client = RemoteServerClient::new(your_transport); +//! +//! // Create process control client +//! let mut process_control = ProcessControlClient::new(&mut client).await?; +//! +//! // Launch an app +//! let pid = process_control.launch_app( +//! "com.example.app", +//! None, // Environment variables +//! None, // Arguments +//! false, // Start suspended +//! true // Kill existing +//! ).await?; +//! println!("Launched app with PID: {}", pid); +//! +//! // Disable memory limits +//! process_control.disable_memory_limit(pid).await?; +//! +//! // Kill the app +//! process_control.kill_app(pid).await?; +//! +//! Ok(()) +//! } +//! ``` + +use plist::{Dictionary, Value}; +use tracing::warn; + +use super::errors::DvtError; +use crate::{IdeviceError, ReadWrite, dvt::message::AuxValue, obf}; + +use super::remote_server::{Channel, RemoteServerClient}; + +/// Client for process control operations on iOS devices +/// +/// Provides methods for launching, killing, and managing processes through the +/// instruments protocol. Each instance maintains its own communication channel. +#[derive(Debug)] +pub struct ProcessControlClient<'a, R: ReadWrite> { + /// The underlying channel for communication + channel: Channel<'a, R>, +} + +fn parse_u64_value(value: &Value) -> Option { + match value { + Value::Integer(v) => v.as_unsigned(), + Value::String(s) => s.parse().ok(), + _ => None, + } +} + +fn extract_ns_error_message(value: &Value) -> Option { + let dict = match value { + Value::Dictionary(dict) => dict, + _ => return None, + }; + + let user_info = match dict.get("NSUserInfo") { + Some(Value::Array(items)) => items, + _ => { + return dict + .get("NSLocalizedDescription") + .and_then(Value::as_string) + .map(ToOwned::to_owned); + } + }; + + let mut description = dict + .get("NSLocalizedDescription") + .and_then(Value::as_string) + .map(ToOwned::to_owned); + let mut reason = dict + .get("NSLocalizedFailureReason") + .and_then(Value::as_string) + .map(ToOwned::to_owned); + + for entry in user_info { + let Value::Dictionary(item) = entry else { + continue; + }; + + let key = item.get("key").and_then(Value::as_string); + let value = item.get("value"); + + match (key, value) { + (Some("NSLocalizedDescription"), Some(Value::String(s))) if description.is_none() => { + description = Some(s.clone()); + } + (Some("NSLocalizedFailureReason"), Some(Value::String(s))) if reason.is_none() => { + reason = Some(s.clone()); + } + (Some("NSUnderlyingError"), Some(v)) => { + if let Some(message) = extract_ns_error_message(v) { + return Some(message); + } + } + _ => {} + } + } + + match (description, reason) { + (Some(description), Some(reason)) => Some(format!("{description}: {reason}")), + (Some(description), None) => Some(description), + (None, Some(reason)) => Some(reason), + (None, None) => None, + } +} + +impl<'a, R: ReadWrite> ProcessControlClient<'a, R> { + /// Creates a new ProcessControlClient + /// + /// # Arguments + /// * `client` - The base RemoteServerClient to use + /// + /// # Returns + /// * `Ok(ProcessControlClient)` - Connected client instance + /// * `Err(IdeviceError)` - If channel creation fails + /// + /// # Errors + /// * Propagates errors from channel creation + pub async fn new(client: &'a mut RemoteServerClient) -> Result { + let channel = client + .make_channel(obf!("com.apple.instruments.server.services.processcontrol")) + .await?; // Drop `&mut client` before continuing + + Ok(Self { channel }) + } + + /// Launches an application on the device + /// + /// # Arguments + /// * `bundle_id` - The bundle identifier of the app to launch + /// * `env_vars` - Optional environment variables dictionary + /// * `arguments` - Optional launch arguments dictionary + /// * `start_suspended` - Whether to start the process suspended + /// * `kill_existing` - Whether to kill existing instances of the app + /// + /// # Returns + /// * `Ok(u64)` - PID of the launched process + /// * `Err(IdeviceError)` - If launch fails + /// + /// # Errors + /// * `IdeviceError::UnexpectedResponse("unexpected response".into())` if server response is invalid + /// * Other communication or serialization errors + pub async fn launch_app( + &mut self, + bundle_id: impl Into, + env_vars: Option, + arguments: Option, + start_suspended: bool, + kill_existing: bool, + ) -> Result { + let method = Value::String( + "launchSuspendedProcessWithDevicePath:bundleIdentifier:environment:arguments:options:" + .into(), + ); + let options = crate::plist!(dict { + "StartSuspendedKey": start_suspended, + "KillExisting": kill_existing + }); + + let env_vars = match env_vars { + Some(e) => e, + None => Dictionary::new(), + }; + let arguments = match arguments { + Some(a) => a, + None => Dictionary::new(), + }; + + let res = self + .channel + .call_method_with_reply( + Some(method), + Some(vec![ + AuxValue::archived_value(""), + AuxValue::archived_value(bundle_id.into()), + AuxValue::archived_value(env_vars), + AuxValue::archived_value(Value::Array( + arguments + .into_iter() + .map(|(_, value)| value) + .collect::>(), + )), + AuxValue::archived_value(options), + ]), + ) + .await?; + + match res.data { + Some(Value::Integer(p)) => match p.as_unsigned() { + Some(p) => Ok(p), + None => { + warn!("PID wasn't unsigned"); + Err(IdeviceError::UnexpectedResponse( + "launch response PID was not an unsigned integer".into(), + )) + } + }, + _ => { + warn!("Did not get integer response"); + Err(IdeviceError::UnexpectedResponse( + "expected integer PID in launch app response".into(), + )) + } + } + } + + /// Launches a process with fully customisable options. + /// + /// This is a lower-level variant of [`launch_app`](Self::launch_app) that + /// accepts a pre-built `args` array (`Vec`) and a custom + /// `options` dictionary (e.g. `{"StartSuspendedKey": false, "ActivateSuspended": true}`). + /// + /// # Arguments + /// * `bundle_id` - Bundle identifier of the app to launch + /// * `env` - Environment variables + /// * `args` - Launch arguments (each element already a `plist::Value::String`) + /// * `options` - Launch options dictionary + /// + /// # Returns + /// * `Ok(u64)` - PID of the launched process + pub(crate) async fn launch_with_options( + &mut self, + bundle_id: impl Into, + env: Dictionary, + args: Vec, + options: Dictionary, + ) -> Result { + let res = self + .channel + .call_method_with_reply( + Some(Value::String( + "launchSuspendedProcessWithDevicePath:bundleIdentifier:environment:arguments:options:" + .into(), + )), + Some(vec![ + AuxValue::archived_value(Value::String(String::new())), // device_path = "" + AuxValue::archived_value(bundle_id.into()), + AuxValue::archived_value(Value::Dictionary(env)), + AuxValue::archived_value(Value::Array(args)), + AuxValue::archived_value(Value::Dictionary(options)), + ]), + ) + .await?; + match res.data { + Some(v) => parse_u64_value(&v).ok_or_else(|| { + if let Some(message) = extract_ns_error_message(&v) { + warn!("Launch failed: {message}"); + return IdeviceError::InternalError(message); + } + warn!("PID wasn't parseable: {v:?}"); + IdeviceError::UnexpectedResponse("unexpected response".into()) + }), + _ => { + warn!("Did not get integer response from launchSuspendedProcess"); + Err(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )) + } + } + } + + /// Kills a running process + /// + /// # Arguments + /// * `pid` - Process ID to kill + /// + /// # Returns + /// * `Ok(())` - If kill request was sent successfully + /// * `Err(IdeviceError)` - If communication fails + /// + /// # Note + /// This method doesn't wait for confirmation that the process was killed. + pub async fn kill_app(&mut self, pid: u64) -> Result<(), IdeviceError> { + self.channel + .call_method( + "killPid:".into(), + Some(vec![AuxValue::U32(pid as u32)]), + false, + ) + .await?; + + Ok(()) + } + + /// Disables memory limits for a process + /// + /// # Arguments + /// * `pid` - Process ID to modify + /// + /// # Returns + /// * `Ok(())` - If memory limits were disabled + /// * `Err(IdeviceError)` - If operation fails + /// + /// # Errors + /// * `IdeviceError::DisableMemoryLimitFailed` if device reports failure + /// * `IdeviceError::UnexpectedResponse("unexpected response".into())` for invalid responses + /// * Other communication errors + pub async fn disable_memory_limit(&mut self, pid: u64) -> Result<(), IdeviceError> { + let res = self + .channel + .call_method_with_reply( + "requestDisableMemoryLimitsForPid:".into(), + Some(vec![AuxValue::U32(pid as u32)]), + ) + .await?; + match res.data { + Some(Value::Boolean(b)) => { + if b { + Ok(()) + } else { + warn!("Failed to disable memory limit"); + Err(DvtError::DisableMemoryLimitFailed.into()) + } + } + _ => { + warn!("Did not receive bool response"); + Err(IdeviceError::UnexpectedResponse( + "expected boolean in disable memory limit response".into(), + )) + } + } + } +} diff --git a/src-tauri/vendor/idevice/src/services/dvt/remote_server.rs b/src-tauri/vendor/idevice/src/services/dvt/remote_server.rs new file mode 100644 index 0000000..0b1e121 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/dvt/remote_server.rs @@ -0,0 +1,1493 @@ +//! Remote Server Client implementation for iOS instruments protocol. +//! +//! This module provides a client for communicating with iOS devices through the +//! remote server protocol used by instruments. It handles channel management and +//! message passing between the host and device. +//! +//! Remote Server communicates via NSKeyedArchives. These archives are binary plists +//! formatted specifically for naive recreation at the target. +//! Requests are sent as method calls to objective C objects on the device. +//! +//! # Overview +//! The client manages multiple communication channels and provides methods for: +//! - Creating new channels +//! - Sending method calls +//! - Reading responses +//! +//! # Example +//! ```rust,no_run +//! use std::sync::Arc; +//! use tokio::net::TcpStream; +//! use your_crate::{ReadWrite, IdeviceError}; +//! use your_crate::instruments::RemoteServerClient; +//! +//! #[tokio::main] +//! async fn main() -> Result<(), IdeviceError> { +//! // Establish connection to device over the tunnel (see XPC docs) +//! let transport = TcpStream::connect("1.2.3.4:1234").await?; +//! +//! // Create client +//! let mut client = RemoteServerClient::new(transport); +//! +//! // Read the first message +//! client.read_message(0).await?; +//! +//! // Call a method on root channel +//! client.call_method( +//! 0, +//! Some("someMethod"), +//! Some(vec![AuxValue::String("param".into())]), +//! true +//! ).await?; +//! +//! // Read response +//! let response = client.read_message(0).await?; +//! println!("Got response: {:?}", response); +//! +//! +//! Ok(()) +//! } +//! ``` + +use std::{ + collections::{HashMap, VecDeque}, + future::Future, + pin::Pin, + sync::{ + Arc, + atomic::{AtomicBool, AtomicU32, Ordering}, + }, +}; + +#[cfg(not(feature = "xctest"))] +use std::io; + +use plist::Dictionary; +use tokio::{ + io::{AsyncWriteExt, ReadHalf, WriteHalf}, + sync::{Mutex, Notify, oneshot}, +}; +use tracing::{debug, warn}; + +use super::errors::DvtError; + +/// Wraps the spawn handle returned from `spawn_reader`. On native we hold a +/// real `tokio::task::JoinHandle` so `Drop` can `.abort()` the reader; on +/// wasm32 there's no join/abort primitive available. The reader exits on +/// transport EOF via the existing read loop, so this is a unit type. +#[cfg(not(target_arch = "wasm32"))] +type ReaderTask = tokio::task::JoinHandle<()>; +#[cfg(target_arch = "wasm32")] +type ReaderTask = (); + +#[cfg(feature = "xctest")] +fn remote_timeout_error(timeout: std::time::Duration) -> IdeviceError { + IdeviceError::XcTestTimeout(timeout.as_secs_f64()) +} + +#[cfg(not(feature = "xctest"))] +fn remote_timeout_error(timeout: std::time::Duration) -> IdeviceError { + IdeviceError::Socket(io::Error::new( + io::ErrorKind::TimedOut, + format!( + "remote server operation timed out after {:.1}s", + timeout.as_secs_f64() + ), + )) +} + +use crate::{ + IdeviceError, ReadWrite, + dvt::message::{Aux, AuxValue, Message, MessageHeader, PayloadHeader}, +}; + +/// Message type identifier for instruments protocol +pub const INSTRUMENTS_MESSAGE_TYPE: u32 = 2; + +/// Client for communicating with iOS remote server protocol +/// +/// Manages multiple communication channels and handles message serialization/deserialization. +/// Each channel operates independently and maintains its own message queue. +pub struct RemoteServerClient { + label: Arc, + shared: Arc>>, + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] + reader_task: ReaderTask, +} + +/// Handle to a specific communication channel +/// +/// Provides channel-specific operations for use on the remote server client. +#[derive(Debug)] +pub struct Channel<'a, R: ReadWrite> { + /// Reference to parent client + client: &'a mut RemoteServerClient, + /// Channel number this handle operates on + channel: i32, +} + +/// Owned handle to a specific communication channel. +/// +/// This mirrors pymobiledevice3's `DTXChannel` lifetime model more closely +/// than the borrowed [`Channel`]: it keeps only the shared transport state and +/// the channel code, so service/proxy wrappers can outlive a temporary +/// `&mut RemoteServerClient` borrow. +#[derive(Debug)] +pub struct OwnedChannel { + label: Arc, + shared: Arc>>, + channel: i32, +} + +impl Clone for OwnedChannel { + fn clone(&self) -> Self { + Self { + label: self.label.clone(), + shared: self.shared.clone(), + channel: self.channel, + } + } +} + +type IncomingMessageHandler = Arc< + dyn Fn( + Message, + ) + -> Pin> + Send>> + + Send + + Sync, +>; + +type IncomingChannelInitializer = Arc< + dyn Fn( + Arc, + Arc>, + i32, + String, + ) -> Pin> + Send>> + + Send + + Sync, +>; + +pub(crate) enum IncomingHandlerOutcome { + Unhandled, + HandledNoReply, + Reply(Vec), +} + +#[derive(Debug, Default)] +struct ChannelQueue { + messages: Mutex>, + notify: Notify, +} + +#[derive(Debug, Clone)] +struct ChannelMetadata { + code: i32, + identifier: String, + remote: bool, +} + +struct IncomingChannelRegistration { + identifiers: Vec, + initializer: IncomingChannelInitializer, +} + +#[derive(Debug, Clone)] +enum CapabilityHandshakeState { + Pending, + Skipped, + Received(Dictionary), +} + +struct RemoteServerShared { + label: Arc, + writer: Mutex, + current_message: AtomicU32, + new_channel: AtomicU32, + channels: Mutex>>, + channel_metadata: Mutex>, + pending_replies: Mutex>>, + handlers: Mutex>, + incoming_channel_registrations: Mutex>>, + registry_notify: Notify, + supported_identifiers: Mutex, + handshake_notify: Notify, + closed: AtomicBool, + closed_notify: Notify, +} + +impl std::fmt::Debug for RemoteServerShared { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RemoteServerShared") + .field( + "current_message", + &self.current_message.load(Ordering::Relaxed), + ) + .field("new_channel", &self.new_channel.load(Ordering::Relaxed)) + .field("closed", &self.closed.load(Ordering::Relaxed)) + .finish_non_exhaustive() + } +} + +impl RemoteServerShared { + fn new(label: Arc, writer: W) -> Self { + let mut channels = HashMap::new(); + channels.insert(0, Arc::new(ChannelQueue::default())); + let mut channel_metadata = HashMap::new(); + channel_metadata.insert( + 0, + ChannelMetadata { + code: 0, + identifier: "ctrl".into(), + remote: false, + }, + ); + Self { + label, + writer: Mutex::new(writer), + current_message: AtomicU32::new(0), + new_channel: AtomicU32::new(1), + channels: Mutex::new(channels), + channel_metadata: Mutex::new(channel_metadata), + pending_replies: Mutex::new(HashMap::new()), + handlers: Mutex::new(HashMap::new()), + incoming_channel_registrations: Mutex::new(Vec::new()), + registry_notify: Notify::new(), + supported_identifiers: Mutex::new(CapabilityHandshakeState::Pending), + handshake_notify: Notify::new(), + closed: AtomicBool::new(false), + closed_notify: Notify::new(), + } + } +} + +impl std::fmt::Debug for RemoteServerClient { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RemoteServerClient") + .field("shared", &"") + .finish() + } +} + +impl RemoteServerClient { + /// Creates a new client with a debug label used in tracing output. + fn with_label_typed(idevice: R, label: impl Into) -> Self + where + R: 'static, + { + let (reader, writer) = tokio::io::split(idevice); + let label: Arc = label.into().into(); + let shared = Arc::new(RemoteServerShared::new(label.clone(), writer)); + let reader_task = Self::spawn_reader(label.clone(), shared.clone(), reader); + Self { + label, + shared, + reader_task, + } + } + + /// Returns a handle to the root channel (channel 0) + pub fn root_channel<'c>(&'c mut self) -> Channel<'c, R> { + Channel { + client: self, + channel: 0, + } + } + + /// Returns a future that resolves when this DTX connection disconnects. + /// + /// This captures the shared state by clone so callers can await it + /// alongside operations that hold a mutable borrow of the client. + pub(crate) fn disconnect_waiter(&self) -> impl Future + Send + 'static + where + R: 'static, + { + let shared = self.shared.clone(); + async move { + if shared.closed.load(Ordering::Relaxed) { + return; + } + shared.closed_notify.notified().await; + } + } + + /// Returns the peer capabilities received during `_notifyOfPublishedCapabilities:`. + pub(crate) async fn supported_identifiers(&self) -> Option { + match &*self.shared.supported_identifiers.lock().await { + CapabilityHandshakeState::Received(dict) => Some(dict.clone()), + CapabilityHandshakeState::Pending | CapabilityHandshakeState::Skipped => None, + } + } + + /// Waits for `_notifyOfPublishedCapabilities:` from the remote side. + pub(crate) async fn wait_for_capabilities( + &self, + timeout: std::time::Duration, + ) -> Result { + crate::time::timeout(timeout, async { + loop { + match &*self.shared.supported_identifiers.lock().await { + CapabilityHandshakeState::Received(dict) => return Ok(dict.clone()), + CapabilityHandshakeState::Skipped => { + return Err(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )); + } + CapabilityHandshakeState::Pending => {} + } + + if self.shared.closed.load(Ordering::Relaxed) { + return Err(Self::closed_error()); + } + + tokio::select! { + _ = self.shared.handshake_notify.notified() => {} + _ = self.shared.closed_notify.notified() => return Err(Self::closed_error()), + } + } + }) + .await + .map_err(|_| remote_timeout_error(timeout))? + } + + /// Performs the DTX capability handshake, mirroring pymobiledevice3's + /// `DTXConnection._perform_handshake()`. + pub(crate) async fn perform_handshake( + &mut self, + capabilities: Option, + timeout: std::time::Duration, + ) -> Result, IdeviceError> { + let already_received = self.supported_identifiers().await; + + { + let mut state = self.shared.supported_identifiers.lock().await; + *state = match (capabilities.is_some(), already_received.as_ref()) { + (false, _) => CapabilityHandshakeState::Skipped, + (true, Some(dict)) => CapabilityHandshakeState::Received(dict.clone()), + (true, None) => CapabilityHandshakeState::Pending, + }; + } + + if let Some(capabilities) = capabilities { + self.root_channel() + .call_method( + Some("_notifyOfPublishedCapabilities:"), + Some(vec![AuxValue::archived_value(plist::Value::Dictionary( + capabilities, + ))]), + false, + ) + .await?; + } else { + return Ok(None); + } + + if let Some(capabilities) = already_received { + return Ok(Some(capabilities)); + } + + crate::time::timeout(timeout, async { + loop { + match &*self.shared.supported_identifiers.lock().await { + CapabilityHandshakeState::Received(dict) => return Ok(Some(dict.clone())), + CapabilityHandshakeState::Skipped => return Ok(None), + CapabilityHandshakeState::Pending => {} + } + + if self.shared.closed.load(Ordering::Relaxed) { + return Err(Self::closed_error()); + } + + tokio::select! { + _ = self.shared.handshake_notify.notified() => {} + _ = self.shared.closed_notify.notified() => return Err(Self::closed_error()), + } + } + }) + .await + .map_err(|_| remote_timeout_error(timeout))? + } + + /// Creates a new channel with the given identifier + /// + /// # Arguments + /// * `identifier` - String identifier for the new channel + /// + /// # Returns + /// * `Ok(Channel)` - Handle to the new channel + /// * `Err(IdeviceError)` - If channel creation fails + /// + /// # Errors + /// * `IdeviceError::UnexpectedResponse("unexpected response".into()) if server responds with unexpected data + /// * Other IO or serialization errors + #[allow(unreachable_code)] + pub async fn make_channel<'c>( + &'c mut self, + identifier: impl Into, + ) -> Result, IdeviceError> { + let code = self.shared.new_channel.fetch_add(1, Ordering::Relaxed) as i32; + let identifier = identifier.into(); + self.register_channel_metadata(code, identifier.clone(), false) + .await; + self.ensure_channel_registered(code).await; + + let args = vec![ + AuxValue::U32( + code.try_into() + .expect("locally opened channels are positive"), + ), + AuxValue::Array( + ns_keyed_archive::encode::encode_to_bytes(plist::Value::String(identifier)) + .expect("Failed to encode"), + ), + ]; + + let reply = self + .call_method_with_reply(0, Some("_requestChannelWithCode:identifier:"), Some(args)) + .await?; + + if reply.data.is_some() { + warn!("make_channel: unexpected reply payload: {:?}", reply.data); + return Err(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )); + } + + self.build_channel(code) + } + + /// Opens a named service channel. + /// + /// This is a service-level alias for `make_channel()` that mirrors the + /// terminology used by pymobiledevice3's `DTXConnection.open_channel()`. + pub(crate) async fn open_service_channel<'c>( + &'c mut self, + identifier: &str, + ) -> Result, IdeviceError> { + self.make_channel(identifier).await + } + + /// Opens a `dtxproxy:` channel assembled from local/remote service names. + /// + /// Mirrors pymobiledevice3's proxy-channel naming model, where the caller + /// reasons about the two sub-services and the transport constructs the + /// wire identifier. + pub(crate) async fn make_proxy_channel<'c>( + &'c mut self, + local_service: &str, + remote_service: &str, + ) -> Result, IdeviceError> { + self.make_channel(format!("dtxproxy:{local_service}:{remote_service}")) + .await + } + + /// Opens a proxied service channel assembled from local/remote service names. + /// + /// This is a service-level alias for `make_proxy_channel()` that matches + /// the "proxy service" terminology used in pymobiledevice3. + pub(crate) async fn open_proxied_service_channel<'c>( + &'c mut self, + local_service: &str, + remote_service: &str, + ) -> Result, IdeviceError> { + self.make_proxy_channel(local_service, remote_service).await + } + + fn build_channel<'c>(&'c mut self, code: i32) -> Result, IdeviceError> { + Ok(Channel { + client: self, + channel: code, + }) + } + + /// Returns an owned handle for an existing registered channel. + pub(crate) fn accept_owned_channel(&self, code: i32) -> OwnedChannel { + OwnedChannel { + label: self.label.clone(), + shared: self.shared.clone(), + channel: code, + } + } + + /// Registers an initializer that runs as soon as the remote opens a + /// matching incoming channel via `_requestChannelWithCode:identifier:`. + /// + /// This mirrors pymobiledevice3's service instantiation timing more + /// closely: the handler is installed before we acknowledge the channel + /// request, so the channel can start handling inbound invokes + /// immediately after the peer receives the OK reply. + pub(crate) async fn register_incoming_channel_initializer( + &mut self, + identifiers: &[&str], + initializer: F, + ) where + F: Fn(OwnedChannel, String) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + { + let identifiers = identifiers + .iter() + .map(|identifier| (*identifier).to_owned()) + .collect(); + let initializer: IncomingChannelInitializer> = + Arc::new(move |label, shared, channel, identifier| { + let owned = OwnedChannel { + label, + shared, + channel, + }; + Box::pin(initializer(owned, identifier)) + }); + self.shared + .incoming_channel_registrations + .lock() + .await + .push(IncomingChannelRegistration { + identifiers, + initializer, + }); + } + + async fn register_channel_metadata(&self, code: i32, identifier: String, remote: bool) { + self.shared.channel_metadata.lock().await.insert( + code, + ChannelMetadata { + code, + identifier, + remote, + }, + ); + self.shared.registry_notify.notify_waiters(); + } + + pub(crate) async fn wait_for_registered_channel_code( + &self, + identifiers: &[&str], + remote: Option, + timeout: Option, + ) -> Result { + let wait_future = async { + loop { + if let Some(code) = self.find_registered_channel_code(identifiers, remote).await { + return Ok(code); + } + + if self.shared.closed.load(Ordering::Relaxed) { + return Err(Self::closed_error()); + } + + tokio::select! { + _ = self.shared.registry_notify.notified() => {} + _ = self.shared.closed_notify.notified() => return Err(Self::closed_error()), + } + } + }; + + match timeout { + Some(timeout) => crate::time::timeout(timeout, wait_future) + .await + .map_err(|_| remote_timeout_error(timeout))?, + None => wait_future.await, + } + } + + /// Waits for the code of a service channel matching one of the given identifiers. + pub(crate) async fn wait_for_service_channel_code( + &self, + identifiers: &[&str], + remote: Option, + timeout: Option, + ) -> Result { + self.wait_for_registered_channel_code(identifiers, remote, timeout) + .await + } + + pub(crate) async fn wait_for_proxied_channel_code( + &self, + identifiers: &[&str], + remote_service: bool, + remote_channel: Option, + timeout: Option, + ) -> Result { + let wait_future = async { + loop { + if let Some(code) = self + .find_registered_proxied_channel_code( + identifiers, + remote_service, + remote_channel, + ) + .await + { + return Ok(code); + } + + if self.shared.closed.load(Ordering::Relaxed) { + return Err(Self::closed_error()); + } + + tokio::select! { + _ = self.shared.registry_notify.notified() => {} + _ = self.shared.closed_notify.notified() => return Err(Self::closed_error()), + } + } + }; + + match timeout { + Some(timeout) => crate::time::timeout(timeout, wait_future) + .await + .map_err(|_| remote_timeout_error(timeout))?, + None => wait_future.await, + } + } + + /// Waits for the code of a proxied service channel whose local or remote + /// sub-service matches one of `identifiers`. + pub(crate) async fn wait_for_proxied_service_channel_code( + &self, + identifiers: &[&str], + remote_service: bool, + remote_channel: Option, + timeout: Option, + ) -> Result { + self.wait_for_proxied_channel_code(identifiers, remote_service, remote_channel, timeout) + .await + } + + async fn find_registered_channel_code( + &self, + identifiers: &[&str], + remote: Option, + ) -> Option { + let metadata = self.shared.channel_metadata.lock().await; + metadata.values().find_map(|entry| { + let matches_identifier = identifiers.contains(&entry.identifier.as_str()); + let matches_remote = remote.is_none_or(|remote_flag| remote_flag == entry.remote); + (matches_identifier && matches_remote).then_some(entry.code) + }) + } + + async fn find_registered_proxied_channel_code( + &self, + identifiers: &[&str], + remote_service: bool, + remote_channel: Option, + ) -> Option { + let metadata = self.shared.channel_metadata.lock().await; + metadata.values().find_map(|entry| { + let matches_remote_channel = + remote_channel.is_none_or(|remote_flag| remote_flag == entry.remote); + if !matches_remote_channel { + return None; + } + + let (local_service, remote_service_name) = + Self::parse_dtxproxy_identifier(&entry.identifier, entry.remote)?; + let candidate = if remote_service { + remote_service_name + } else { + local_service + }; + + identifiers.contains(&candidate).then_some(entry.code) + }) + } + + fn parse_dtxproxy_identifier(identifier: &str, remote_channel: bool) -> Option<(&str, &str)> { + let mut parts = identifier.split(':'); + let prefix = parts.next()?; + let first = parts.next()?; + let second = parts.next()?; + if prefix != "dtxproxy" || parts.next().is_some() { + return None; + } + + if remote_channel { + Some((second, first)) + } else { + Some((first, second)) + } + } + + async fn send_method( + &self, + channel: i32, + identifier: u32, + data: Option>, + args: Option>, + expect_reply: bool, + correlate_reply: bool, + ) -> Result>, IdeviceError> { + let mheader = MessageHeader::new(0, 1, identifier, 0, channel, expect_reply); + let pheader = PayloadHeader::method_invocation(); + let aux = args.map(Aux::from_values); + let data: Option = data.map(Into::into); + + let message = Message::new(mheader, pheader, aux, data); + debug!("[{}] Sending message: {message:#?}", self.label); + + let receiver = if correlate_reply { + let (sender, receiver) = oneshot::channel(); + self.shared + .pending_replies + .lock() + .await + .insert(identifier, sender); + Some(receiver) + } else { + None + }; + + let write_result = self.shared.write_all(&message.serialize()).await; + if write_result.is_err() { + self.shared.pending_replies.lock().await.remove(&identifier); + } + write_result?; + + Ok(receiver) + } + + async fn wait_for_reply( + &self, + identifier: u32, + receiver: oneshot::Receiver, + ) -> Result { + match receiver.await { + Ok(message) => Ok(message), + Err(_) => { + self.shared.pending_replies.lock().await.remove(&identifier); + if self.shared.closed.load(Ordering::Relaxed) { + Err(Self::closed_error()) + } else { + Err(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )) + } + } + } + } + + /// Calls a method on the specified channel + /// + /// # Arguments + /// * `channel` - Channel number to call method on + /// * `data` - Optional method data (plist value) + /// * `args` - Optional arguments for the method + /// * `expect_reply` - Whether to expect a response + /// + /// # Returns + /// * `Ok(())` - If method was successfully called + /// * `Err(IdeviceError)` - If call failed + /// + /// # Errors + /// IO or serialization errors + pub async fn call_method( + &mut self, + channel: i32, + data: Option>, + args: Option>, + expect_reply: bool, + ) -> Result<(), IdeviceError> { + let identifier = self.shared.current_message.fetch_add(1, Ordering::Relaxed) + 1; + self.send_method(channel, identifier, data, args, expect_reply, false) + .await?; + Ok(()) + } + + /// Calls a method and waits for the reply correlated by message identifier. + pub(crate) async fn call_method_with_reply( + &mut self, + channel: i32, + data: Option>, + args: Option>, + ) -> Result { + let identifier = self.shared.current_message.fetch_add(1, Ordering::Relaxed) + 1; + let receiver = self + .send_method(channel, identifier, data, args, true, true) + .await? + .ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + ))?; + self.wait_for_reply(identifier, receiver).await + } + + /// Reads the next message from the specified channel + /// + /// Checks cached messages first, then reads from transport if needed. + /// + /// # Arguments + /// * `channel` - Channel number to read from + /// + /// # Returns + /// * `Ok(Message)` - The received message + /// * `Err(IdeviceError)` - If read failed + /// + /// # Errors + /// * `IdeviceError::UnknownChannel` if channel doesn't exist + /// * Other IO or deserialization errors + pub async fn read_message(&mut self, channel: i32) -> Result { + loop { + let queue = self + .get_channel_queue(channel) + .await + .ok_or_else(|| DvtError::UnknownChannel(channel.unsigned_abs()))?; + + { + let mut messages = queue.messages.lock().await; + if let Some(msg) = messages.pop_front() { + return Ok(msg); + } + } + + if self.shared.closed.load(Ordering::Relaxed) { + return Err(Self::closed_error()); + } + + tokio::select! { + _ = queue.notify.notified() => {} + _ = self.shared.closed_notify.notified() => return Err(Self::closed_error()), + } + } + } + + fn spawn_reader( + label: Arc, + shared: Arc>>, + mut reader: ReadHalf, + ) -> ReaderTask + where + R: 'static, + { + let fut = async move { + loop { + match Message::from_reader(&mut reader).await { + Ok(msg) => { + debug!("[{}] Read message: {msg:#?}", label); + if Self::dispatch_pending_reply(&shared, msg.clone()).await { + continue; + } + if Self::handle_control_message(&shared, &msg).await { + continue; + } + if Self::dispatch_to_handler(&shared, msg.clone()).await { + continue; + } + Self::enqueue_message(&shared, msg).await; + } + Err(e) => { + warn!("[{}] RemoteServer reader exiting: {} ({:?})", label, e, e); + Self::fail_pending_replies(&shared).await; + shared.closed.store(true, Ordering::Relaxed); + shared.closed_notify.notify_waiters(); + break; + } + } + } + }; + + #[cfg(not(target_arch = "wasm32"))] + { + tokio::spawn(fut) + } + #[cfg(target_arch = "wasm32")] + { + wasm_bindgen_futures::spawn_local(fut); + } + } + + async fn handle_control_message( + shared: &Arc>>, + msg: &Message, + ) -> bool { + if msg.message_header.channel != 0 { + return false; + } + + match msg.data.as_ref() { + Some(plist::Value::String(selector)) + if selector == "_notifyOfPublishedCapabilities:" => + { + let aux = match msg.aux.as_ref() { + Some(aux) => aux.values.as_slice(), + None => { + warn!("Capabilities notification without aux payload"); + return true; + } + }; + + let Some(first) = aux.first() else { + warn!("Capabilities notification missing payload"); + return true; + }; + + match Self::decode_capabilities(first) { + Ok(capabilities) => { + debug!("Received remote capabilities: {:?}", capabilities); + *shared.supported_identifiers.lock().await = + CapabilityHandshakeState::Received(capabilities); + shared.handshake_notify.notify_waiters(); + // Preserve pre-XCTest behavior: older DVT callers expect the + // initial capabilities hello to remain observable via + // `read_message(0)` on the root channel. + Self::enqueue_message(shared, msg.clone()).await; + } + Err(e) => warn!("Failed to decode remote capabilities: {}", e), + } + return true; + } + Some(plist::Value::String(selector)) if selector == "_channelCanceled:" => { + let aux = match msg.aux.as_ref() { + Some(aux) => aux.values.as_slice(), + None => { + warn!("Incoming channel cancellation without aux payload"); + return true; + } + }; + + let Some(first) = aux.first() else { + warn!("Incoming channel cancellation missing channel code"); + return true; + }; + + match Self::decode_channel_code(first) { + Ok(channel_code) => { + debug!("Remote cancelled channel {}", channel_code); + Self::remove_channel(shared, channel_code).await; + } + Err(e) => warn!("Failed to decode incoming channel cancellation: {}", e), + } + return true; + } + Some(plist::Value::String(selector)) + if selector == "_requestChannelWithCode:identifier:" => {} + _ => return false, + } + + let aux = match msg.aux.as_ref() { + Some(aux) => aux.values.as_slice(), + None => { + warn!("Incoming channel request without aux payload"); + return false; + } + }; + + if aux.len() < 2 { + warn!("Incoming channel request missing aux values"); + return false; + } + + let code = match aux[0] { + AuxValue::U32(code) => -(code as i32), + _ => { + warn!("Incoming channel request aux[0] is not U32"); + return false; + } + }; + + let identifier = match Self::decode_identifier(&aux[1]) { + Ok(identifier) => identifier, + Err(e) => { + warn!("Failed to decode incoming channel identifier: {}", e); + return false; + } + }; + + debug!( + "Remote requested channel {} with identifier '{}'", + code, identifier + ); + + shared.channel_metadata.lock().await.insert( + code, + ChannelMetadata { + code, + identifier: identifier.clone(), + remote: true, + }, + ); + shared.registry_notify.notify_waiters(); + Self::ensure_channel_registered_shared(shared, code).await; + + if let Err(error) = + Self::run_incoming_channel_initializers(shared, code, identifier.clone()).await + { + warn!( + "Failed to initialize incoming channel {} ('{}'): {}", + code, identifier, error + ); + } + + if let Err(e) = shared + .send_raw_reply( + 0, + msg.message_header.identifier(), + msg.message_header.conversation_index(), + &[], + ) + .await + { + warn!("Failed to acknowledge incoming channel request: {}", e); + shared.closed.store(true, Ordering::Relaxed); + shared.closed_notify.notify_waiters(); + } + + true + } + + async fn run_incoming_channel_initializers( + shared: &Arc>>, + channel: i32, + identifier: String, + ) -> Result<(), IdeviceError> { + let initializer = { + let registrations = shared.incoming_channel_registrations.lock().await; + registrations + .iter() + .find(|registration| { + registration + .identifiers + .iter() + .any(|candidate| candidate == &identifier) + }) + .map(|registration| registration.initializer.clone()) + }; + + let Some(initializer) = initializer else { + return Ok(()); + }; + + initializer(shared.label.clone(), shared.clone(), channel, identifier).await + } + + async fn enqueue_message(shared: &Arc>>, msg: Message) { + if msg.message_header.conversation_index() == 0 { + debug!( + "Queueing unhandled incoming message on channel {} expects_reply={} data={:?}", + msg.message_header.channel, + msg.message_header.expects_reply(), + msg.data + ); + } + if let Some(queue) = + Self::get_channel_queue_shared(shared, msg.message_header.channel).await + { + let notify = &queue.notify; + { + let mut messages = queue.messages.lock().await; + messages.push_back(msg); + } + notify.notify_waiters(); + } else { + warn!( + "Received message for unknown channel: {}", + msg.message_header.channel + ); + } + } + + async fn dispatch_to_handler( + shared: &Arc>>, + msg: Message, + ) -> bool { + if msg.message_header.conversation_index() != 0 { + return false; + } + + let handler = { + let handlers = shared.handlers.lock().await; + handlers.get(&msg.message_header.channel).cloned() + }; + + let Some(handler) = handler else { + return false; + }; + + let expects_reply = msg.message_header.expects_reply(); + let msg_id = msg.message_header.identifier(); + let conversation_index = msg.message_header.conversation_index(); + let channel = msg.message_header.channel; + + match handler(msg).await { + Ok(IncomingHandlerOutcome::Unhandled) => false, + Ok(IncomingHandlerOutcome::HandledNoReply) => { + if expects_reply + && let Err(e) = shared + .send_raw_reply(channel, msg_id, conversation_index, &[]) + .await + { + warn!("Failed to auto-ack handled incoming message: {}", e); + } + true + } + Ok(IncomingHandlerOutcome::Reply(reply_bytes)) => { + if let Err(e) = shared + .send_raw_reply(channel, msg_id, conversation_index, &reply_bytes) + .await + { + warn!("Failed to reply from incoming handler: {}", e); + } + true + } + Err(e) => { + warn!("Incoming message handler failed: {}", e); + false + } + } + } + + async fn dispatch_pending_reply( + shared: &Arc>>, + msg: Message, + ) -> bool { + if msg.message_header.conversation_index() == 0 { + return false; + } + + let pending = shared + .pending_replies + .lock() + .await + .remove(&msg.message_header.identifier()); + + let Some(sender) = pending else { + return false; + }; + + if sender.send(msg).is_err() { + warn!("Reply waiter dropped before correlated reply was delivered"); + } + + true + } + + async fn ensure_channel_registered(&self, code: i32) { + Self::ensure_channel_registered_shared(&self.shared, code).await; + } + + async fn ensure_channel_registered_shared( + shared: &Arc>>, + code: i32, + ) { + let mut channels = shared.channels.lock().await; + channels + .entry(code) + .or_insert_with(|| Arc::new(ChannelQueue::default())); + } + + async fn get_channel_queue(&self, code: i32) -> Option> { + Self::get_channel_queue_shared(&self.shared, code).await + } + + async fn get_channel_queue_shared( + shared: &Arc>>, + code: i32, + ) -> Option> { + let channels = shared.channels.lock().await; + channels.get(&code).cloned() + } + + fn decode_identifier(aux: &AuxValue) -> Result { + match aux { + AuxValue::String(s) => Ok(s.clone()), + AuxValue::Array(bytes) => { + match ns_keyed_archive::decode::from_bytes(bytes).map_err(DvtError::from)? { + plist::Value::String(s) => Ok(s), + _ => Err(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )), + } + } + _ => Err(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )), + } + } + + fn decode_capabilities(aux: &AuxValue) -> Result { + match aux { + AuxValue::Array(bytes) => { + match ns_keyed_archive::decode::from_bytes(bytes).map_err(DvtError::from)? { + plist::Value::Dictionary(dict) => Ok(dict), + _ => Err(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )), + } + } + _ => Err(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )), + } + } + + fn decode_channel_code(aux: &AuxValue) -> Result { + match aux { + AuxValue::U32(code) => i32::try_from(*code) + .map_err(|_| IdeviceError::UnexpectedResponse("unexpected response".into())), + AuxValue::I64(code) => i32::try_from(*code) + .map_err(|_| IdeviceError::UnexpectedResponse("unexpected response".into())), + _ => Err(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )), + } + } + + async fn remove_channel(shared: &Arc>>, channel_code: i32) { + shared.handlers.lock().await.remove(&channel_code); + shared.channels.lock().await.remove(&channel_code); + shared.channel_metadata.lock().await.remove(&channel_code); + shared.registry_notify.notify_waiters(); + } + + async fn fail_pending_replies(shared: &Arc>>) { + shared.pending_replies.lock().await.clear(); + } + + fn closed_error() -> IdeviceError { + IdeviceError::Socket(std::io::Error::new( + std::io::ErrorKind::BrokenPipe, + "remote server connection closed", + )) + } +} + +impl RemoteServerClient> { + /// Creates a new RemoteServerClient with the given transport. + pub fn new(idevice: impl ReadWrite + 'static) -> Self { + Self::with_label(idevice, "remote-server") + } + + /// Creates a new client with a debug label used in tracing output. + pub fn with_label(idevice: impl ReadWrite + 'static, label: impl Into) -> Self { + Self::with_label_typed(Box::new(idevice), label) + } +} + +impl Drop for RemoteServerClient { + fn drop(&mut self) { + // No JoinHandle::abort on wasm32 + #[cfg(not(target_arch = "wasm32"))] + self.reader_task.abort(); + } +} + +impl RemoteServerShared { + async fn write_all(&self, bytes: &[u8]) -> Result<(), IdeviceError> { + let mut writer = self.writer.lock().await; + writer.write_all(bytes).await?; + writer.flush().await?; + Ok(()) + } + + async fn send_raw_reply( + &self, + channel: i32, + incoming_msg_id: u32, + incoming_conversation_index: u32, + data_bytes: &[u8], + ) -> Result<(), IdeviceError> { + let buf = Message::build_raw_reply( + channel, + incoming_msg_id, + incoming_conversation_index, + data_bytes, + ); + self.write_all(&buf).await + } +} + +impl Channel<'_, R> { + /// Converts this borrowed channel handle into an owned/shared one. + pub(crate) fn detach(&self) -> OwnedChannel { + OwnedChannel { + label: self.client.label.clone(), + shared: self.client.shared.clone(), + channel: self.channel, + } + } + + /// Reads the next message from the remote server on this channel + /// + /// # Returns + /// * `Ok(Message)` - The received message + /// * `Err(IdeviceError)` - If read failed + /// + /// # Errors + /// * `IdeviceError::UnknownChannel` if channel doesn't exist + /// * Other IO or deserialization errors + pub async fn read_message(&mut self) -> Result { + self.client.read_message(self.channel).await + } + + /// Calls a method on the specified channel + /// + /// # Arguments + /// * `method` - Optional method data (plist value) + /// * `args` - Optional arguments for the method + /// * `expect_reply` - Whether to expect a response + /// + /// # Returns + /// * `Ok(())` - If method was successfully called + /// * `Err(IdeviceError)` - If call failed + /// + /// # Errors + /// IO or serialization errors + pub async fn call_method( + &mut self, + method: Option>, + args: Option>, + expect_reply: bool, + ) -> Result<(), IdeviceError> { + self.client + .call_method(self.channel, method, args, expect_reply) + .await + } + + /// Calls a method on this channel and waits for the correlated reply. + pub(crate) async fn call_method_with_reply( + &mut self, + method: Option>, + args: Option>, + ) -> Result { + self.client + .call_method_with_reply(self.channel, method, args) + .await + } +} + +impl OwnedChannel { + /// Reads the next queued message from this channel. + pub async fn read_message(&mut self) -> Result { + loop { + let queue = + RemoteServerClient::::get_channel_queue_shared(&self.shared, self.channel) + .await + .ok_or_else(|| DvtError::UnknownChannel(self.channel.unsigned_abs()))?; + + { + let mut messages = queue.messages.lock().await; + if let Some(msg) = messages.pop_front() { + return Ok(msg); + } + } + + if self.shared.closed.load(Ordering::Relaxed) { + return Err(RemoteServerClient::::closed_error()); + } + + tokio::select! { + _ = queue.notify.notified() => {} + _ = self.shared.closed_notify.notified() => { + return Err(RemoteServerClient::::closed_error()) + } + } + } + } + + /// Reads the next queued message with a timeout. + pub(crate) async fn read_message_timeout( + &mut self, + timeout: std::time::Duration, + ) -> Result { + crate::time::timeout(timeout, self.read_message()) + .await + .map_err(|_| remote_timeout_error(timeout))? + } + + /// Calls a method on this channel. + pub async fn call_method( + &mut self, + method: Option>, + args: Option>, + expect_reply: bool, + ) -> Result<(), IdeviceError> { + let identifier = self.shared.current_message.fetch_add(1, Ordering::Relaxed) + 1; + let mheader = MessageHeader::new(0, 1, identifier, 0, self.channel, expect_reply); + let pheader = PayloadHeader::method_invocation(); + let aux = args.map(Aux::from_values); + let data: Option = method.map(Into::into); + let message = Message::new(mheader, pheader, aux, data); + debug!("[{}] Sending message: {message:#?}", self.label); + + self.shared.write_all(&message.serialize()).await?; + + Ok(()) + } + + /// Calls a method on this channel and waits for the correlated reply. + pub(crate) async fn call_method_with_reply( + &mut self, + method: Option>, + args: Option>, + ) -> Result { + let identifier = self.shared.current_message.fetch_add(1, Ordering::Relaxed) + 1; + let mheader = MessageHeader::new(0, 1, identifier, 0, self.channel, true); + let pheader = PayloadHeader::method_invocation(); + let aux = args.map(Aux::from_values); + let data: Option = method.map(Into::into); + let message = Message::new(mheader, pheader, aux, data); + debug!("[{}] Sending message: {message:#?}", self.label); + + let (sender, receiver) = oneshot::channel::(); + self.shared + .pending_replies + .lock() + .await + .insert(identifier, sender); + + let write_result = self.shared.write_all(&message.serialize()).await; + if write_result.is_err() { + self.shared.pending_replies.lock().await.remove(&identifier); + } + write_result?; + + match receiver.await { + Ok(message) => Ok(message), + Err(_) => { + self.shared.pending_replies.lock().await.remove(&identifier); + if self.shared.closed.load(Ordering::Relaxed) { + Err(RemoteServerClient::::closed_error()) + } else { + Err(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )) + } + } + } + } + + /// Registers an incoming handler for this channel. + pub(crate) async fn set_incoming_handler(&mut self, handler: F) + where + F: Fn(Message) -> Fut + Send + Sync + 'static, + Fut: Future> + Send + 'static, + { + let handler: IncomingMessageHandler = Arc::new(move |msg| Box::pin(handler(msg))); + self.shared + .handlers + .lock() + .await + .insert(self.channel, handler); + } + + /// Removes the incoming handler for this channel. + pub(crate) async fn clear_incoming_handler(&mut self) { + self.shared.handlers.lock().await.remove(&self.channel); + } + + /// Sends a raw reply for an incoming message on this channel. + pub(crate) async fn send_raw_reply_for( + &mut self, + incoming_msg_id: u32, + incoming_conversation_index: u32, + data_bytes: &[u8], + ) -> Result<(), IdeviceError> { + self.shared + .send_raw_reply( + self.channel, + incoming_msg_id, + incoming_conversation_index, + data_bytes, + ) + .await + } +} diff --git a/src-tauri/vendor/idevice/src/services/dvt/screenshot.rs b/src-tauri/vendor/idevice/src/services/dvt/screenshot.rs new file mode 100644 index 0000000..121d92b --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/dvt/screenshot.rs @@ -0,0 +1,67 @@ +//! Screenshot service client for iOS instruments protocol. +//! +//! This module provides a client for interacting with the screenshot service +//! on iOS devices through the instruments protocol. It allows taking screenshots from the device. +//! + +use plist::Value; + +use crate::{ + IdeviceError, ReadWrite, + dvt::remote_server::{Channel, RemoteServerClient}, + obf, +}; + +/// Client for take screenshot operations on iOS devices +/// +/// Provides methods for take screnn_shot through the +/// instruments protocol. Each instance maintains its own communication channel. +#[derive(Debug)] +pub struct ScreenshotClient<'a, R: ReadWrite> { + /// The underlying channel for communication + channel: Channel<'a, R>, +} + +impl<'a, R: ReadWrite> ScreenshotClient<'a, R> { + /// Creates a new ScreenshotClient + /// + /// # Arguments + /// * `client` - The base RemoteServerClient to use + /// + /// # Returns + /// * `Ok(ScreenshotClient)` - Connected client instance + /// * `Err(IdeviceError)` - If channel creation fails + /// + /// # Errors + /// * Propagates errors from channel creation + pub async fn new(client: &'a mut RemoteServerClient) -> Result { + let channel = client + .make_channel(obf!("com.apple.instruments.server.services.screenshot")) + .await?; // Drop `&mut client` before continuing + + Ok(Self { channel }) + } + + /// Take screenshot from the device + /// + /// # Returns + /// * `Ok(Vec)` - the bytes of the screenshot + /// * `Err(IdeviceError)` - If communication fails + /// + /// # Errors + /// * `IdeviceError::UnexpectedResponse` if server response is invalid + /// * Other communication or serialization errors + pub async fn take_screenshot(&mut self) -> Result, IdeviceError> { + let method = Value::String("takeScreenshot".into()); + + self.channel.call_method(Some(method), None, true).await?; + + let msg = self.channel.read_message().await?; + match msg.data { + Some(Value::Data(data)) => Ok(data), + _ => Err(IdeviceError::UnexpectedResponse( + "missing screenshot data in DVT response".into(), + )), + } + } +} diff --git a/src-tauri/vendor/idevice/src/services/dvt/sysmontap.rs b/src-tauri/vendor/idevice/src/services/dvt/sysmontap.rs new file mode 100644 index 0000000..326bf0a --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/dvt/sysmontap.rs @@ -0,0 +1,159 @@ +//! Sysmontap service - System monitoring tap for processes and system stats + +use plist::{Dictionary, Value}; + +use super::message::AuxValue; +use super::remote_server::{Channel, RemoteServerClient}; +use crate::{IdeviceError, ReadWrite, obf}; + +/// Configuration for sysmontap sampling +#[derive(Debug, Clone)] +pub struct SysmontapConfig { + /// Sampling interval in milliseconds + pub interval_ms: u32, + /// Process attributes to collect (from DeviceInfoClient::sysmon_process_attributes) + pub process_attributes: Vec, + /// System attributes to collect (from DeviceInfoClient::sysmon_system_attributes) + pub system_attributes: Vec, +} + +impl Default for SysmontapConfig { + fn default() -> Self { + Self { + interval_ms: 500, + process_attributes: Vec::new(), + system_attributes: Vec::new(), + } + } +} + +/// A sysmontap sample row +#[derive(Debug, Clone)] +pub struct SysmontapSample { + /// Per-process data keyed by PID string + pub processes: Option, + /// System-wide attribute array (order matches SysmontapConfig::system_attributes) + pub system: Option>, + /// CPU usage summary + pub system_cpu_usage: Option, +} + +/// Client for system monitoring tap +#[derive(Debug)] +pub struct SysmontapClient<'a, R: ReadWrite> { + channel: Channel<'a, R>, +} + +impl<'a, R: ReadWrite> SysmontapClient<'a, R> { + pub async fn new(client: &'a mut RemoteServerClient) -> Result { + let channel = client + .make_channel(obf!("com.apple.instruments.server.services.sysmontap")) + .await?; + Ok(Self { channel }) + } + + /// Sends the configuration to the device. No reply expected. + pub async fn set_config(&mut self, config: &SysmontapConfig) -> Result<(), IdeviceError> { + let mut cfg = Dictionary::new(); + cfg.insert( + "ur".into(), + Value::Integer((config.interval_ms as i64).into()), + ); + cfg.insert("bm".into(), Value::Integer(0i64.into())); + cfg.insert( + "procAttrs".into(), + Value::Array( + config + .process_attributes + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + cfg.insert( + "sysAttrs".into(), + Value::Array( + config + .system_attributes + .iter() + .map(|s| Value::String(s.clone())) + .collect(), + ), + ); + cfg.insert("cpuUsage".into(), Value::Boolean(true)); + cfg.insert("physFootprint".into(), Value::Boolean(true)); + cfg.insert( + "sampleInterval".into(), + Value::Integer(((config.interval_ms as i64) * 1_000_000).into()), + ); + + self.channel + .call_method( + Some(Value::String("setConfig:".into())), + Some(vec![AuxValue::archived_value(Value::Dictionary(cfg))]), + false, + ) + .await + } + + /// Starts sampling. No reply expected. + /// After start, the device pushes an initial ack message that must be consumed. + pub async fn start(&mut self) -> Result<(), IdeviceError> { + self.channel + .call_method(Some(Value::String("start".into())), None, false) + .await?; + // Consume the initial ack + self.channel.read_message().await?; + Ok(()) + } + + /// Stops sampling. No reply expected. + pub async fn stop(&mut self) -> Result<(), IdeviceError> { + self.channel + .call_method(Some(Value::String("stop".into())), None, false) + .await + } + + /// Reads the next sysmontap data row. + /// The device pushes arrays of row dicts; we iterate until we find one with data. + pub async fn next_sample(&mut self) -> Result { + loop { + let msg = self.channel.read_message().await?; + let Some(decoded) = msg.data else { continue }; + + // The tap pushes an Array of row dicts + let rows: Vec = match decoded { + Value::Array(arr) => arr, + Value::Dictionary(d) => vec![Value::Dictionary(d)], + _ => continue, + }; + + for row in rows { + if let Some(dict) = row.into_dictionary() + && (dict.contains_key("Processes") + || dict.contains_key("System") + || dict.contains_key("SystemCPUUsage")) + { + return Ok(parse_sample_dict(dict)); + } + } + } + } +} + +fn parse_sample_dict(dict: Dictionary) -> SysmontapSample { + let processes = dict + .get("Processes") + .and_then(|v| v.as_dictionary()) + .cloned(); + let system = dict.get("System").and_then(|v| v.as_array()).cloned(); + let system_cpu_usage = dict + .get("SystemCPUUsage") + .and_then(|v| v.as_dictionary()) + .cloned(); + SysmontapSample { + processes, + system, + system_cpu_usage, + } +} diff --git a/src-tauri/vendor/idevice/src/services/dvt/xctest/dtx_services.rs b/src-tauri/vendor/idevice/src/services/dvt/xctest/dtx_services.rs new file mode 100644 index 0000000..8e4fffb --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/dvt/xctest/dtx_services.rs @@ -0,0 +1,205 @@ +//! DTX channel names and method selectors for the XCTest / testmanagerd protocol. +//! +//! These constants correspond 1-to-1 with the Objective-C selector strings used by +//! Xcode's IDE interface and the on-device testmanagerd daemon. They are kept in one +//! place so every other module can import them without magic strings. +// Jackson Coxson + +// --------------------------------------------------------------------------- +// testmanagerd service names +// --------------------------------------------------------------------------- + +/// iOS < 14 — lockdown, no SSL. +pub const TESTMANAGERD_SERVICE: &str = "com.apple.testmanagerd.lockdown"; + +/// iOS 14–16 — lockdown with SSL. +pub const TESTMANAGERD_SECURE_SERVICE: &str = "com.apple.testmanagerd.lockdown.secure"; + +/// iOS 17+ — accessed over the RSD tunnel. +pub const TESTMANAGERD_RSD_SERVICE: &str = "com.apple.dt.testmanagerd.remote"; + +// --------------------------------------------------------------------------- +// DVT (instruments) service names +// --------------------------------------------------------------------------- + +/// iOS < 14 — legacy instruments remote server, lockdown, no SSL. +pub const DVT_LEGACY_SERVICE: &str = "com.apple.instruments.remoteserver"; + +/// iOS 14+ — instruments remote server with DVT secure socket proxy. +pub const DVT_SERVICE: &str = "com.apple.instruments.remoteserver.DVTSecureSocketProxy"; + +// --------------------------------------------------------------------------- +// DTX channel identifiers +// --------------------------------------------------------------------------- + +/// Channel identifier for the XCTest IDE ↔ daemon interface. +/// Used on iOS < 17 (lockdown path). +pub const XCTEST_MANAGER_IDE_INTERFACE: &str = "XCTestManager_IDEInterface"; + +/// Service identifier for the daemon-facing side of the XCTest proxy channel. +pub const XCTEST_MANAGER_DAEMON_CONNECTION_INTERFACE: &str = + "XCTestManager_DaemonConnectionInterface"; + +/// Service identifier for the runner-facing side of the XCTest proxy channel. +pub const XCTEST_DRIVER_INTERFACE: &str = "XCTestDriverInterface"; + +/// iOS 17+ proxy channel: IDE ↔ DaemonConnectionInterface. +/// Format used by pymobiledevice3's DtxProxyService over RSD. +pub const XCTEST_PROXY_IDE_TO_DAEMON: &str = + "dtxproxy:XCTestManager_IDEInterface:XCTestManager_DaemonConnectionInterface"; + +/// iOS 17+ proxy channel: IDE ↔ XCTestDriverInterface (reverse channel from runner). +pub const XCTEST_PROXY_IDE_TO_DRIVER: &str = + "dtxproxy:XCTestManager_IDEInterface:XCTestDriverInterface"; + +// --------------------------------------------------------------------------- +// Xcode version reported to testmanagerd +// --------------------------------------------------------------------------- + +/// Protocol version number reported to testmanagerd as the IDE's Xcode version. +/// The exact value is not significant; 36 matches a recent Xcode release. +pub const XCODE_VERSION: u64 = 36; + +// --------------------------------------------------------------------------- +// Outgoing IDE → daemon selectors +// --------------------------------------------------------------------------- + +/// iOS 17+: initiate the control channel, passing IDE capabilities. +pub const IDE_INITIATE_CTRL_SESSION_WITH_CAPABILITIES: &str = + "_IDE_initiateControlSessionWithCapabilities:"; + +/// iOS 11–16: initiate the control channel with a protocol version number. +pub const IDE_INITIATE_CTRL_SESSION_WITH_PROTOCOL_VERSION: &str = + "_IDE_initiateControlSessionWithProtocolVersion:"; + +/// iOS 17+: initiate the main session with a UUID and IDE capabilities. +pub const IDE_INITIATE_SESSION_WITH_IDENTIFIER_CAPABILITIES: &str = + "_IDE_initiateSessionWithIdentifier:capabilities:"; + +/// iOS 11–16: initiate the main session with a UUID, client string, path, and version. +pub const IDE_INITIATE_SESSION_WITH_IDENTIFIER_FOR_CLIENT_AT_PATH_PROTOCOL_VERSION: &str = + "_IDE_initiateSessionWithIdentifier:forClient:atPath:protocolVersion:"; + +/// iOS 12+: authorise the test session for a launched process ID. +pub const IDE_AUTHORIZE_TEST_SESSION: &str = "_IDE_authorizeTestSessionWithProcessID:"; + +/// iOS 10–11: authorise by PID with a protocol version. +pub const IDE_INITIATE_CTRL_SESSION_FOR_PID_PROTOCOL_VERSION: &str = + "_IDE_initiateControlSessionForTestProcessID:protocolVersion:"; + +/// iOS < 10: authorise by PID only. +pub const IDE_INITIATE_CTRL_SESSION_FOR_PID: &str = "_IDE_initiateControlSessionForTestProcessID:"; + +// --------------------------------------------------------------------------- +// Outgoing IDE → driver selectors +// --------------------------------------------------------------------------- + +/// Signal the test runner to begin executing its test plan. +pub const IDE_START_EXECUTING_TEST_PLAN: &str = "_IDE_startExecutingTestPlanWithProtocolVersion:"; + +// --------------------------------------------------------------------------- +// Incoming runner → IDE callbacks (_XCT_*) +// --------------------------------------------------------------------------- + +/// Test plan has started executing. +pub const XCT_DID_BEGIN_TEST_PLAN: &str = "_XCT_didBeginExecutingTestPlan"; + +/// Test plan has finished executing (terminal event). +pub const XCT_DID_FINISH_TEST_PLAN: &str = "_XCT_didFinishExecutingTestPlan"; + +/// Runner signals readiness and negotiates capabilities (iOS 17+ DDI variant). +pub const XCT_RUNNER_READY_WITH_CAPABILITIES: &str = "_XCT_testRunnerReadyWithCapabilities:"; + +/// Informational log message from the runner. +pub const XCT_LOG_MESSAGE: &str = "_XCT_logMessage:"; + +/// Debug log message from the runner. +pub const XCT_LOG_DEBUG_MESSAGE: &str = "_XCT_logDebugMessage:"; + +/// Protocol version negotiation. +pub const XCT_EXCHANGE_PROTOCOL_VERSION: &str = + "_XCT_exchangeCurrentProtocolVersion_minimumVersion_"; + +/// Test bundle is ready (legacy, no capabilities). +pub const XCT_BUNDLE_READY: &str = "_XCT_testBundleReady"; + +/// Test bundle is ready with a protocol version. +pub const XCT_BUNDLE_READY_WITH_PROTOCOL_VERSION: &str = + "_XCT_testBundleReadyWithProtocolVersion_minimumVersion_"; + +/// UI testing initialization began. +pub const XCT_DID_BEGIN_UI_INIT: &str = "_XCT_didBeginInitializingForUITesting"; + +/// Test runner formed the test plan payload. +pub const XCT_DID_FORM_PLAN: &str = "_XCT_didFormPlanWithData:"; + +/// Runner requested launch progress for a token. +pub const XCT_GET_PROGRESS_FOR_LAUNCH: &str = "_XCT_getProgressForLaunch:"; + +/// UI testing initialization failed. +pub const XCT_UI_INIT_DID_FAIL: &str = "_XCT_initializationForUITestingDidFailWithError:"; + +/// Test runner failed to bootstrap. +pub const XCT_DID_FAIL_BOOTSTRAP: &str = "_XCT_didFailToBootstrapWithError:"; + +// --- suite lifecycle (legacy string-based, pre-iOS 14) -------------------- + +/// Test suite started (legacy). +pub const XCT_SUITE_DID_START: &str = "_XCT_testSuite_didStartAt_"; + +/// Test suite finished (legacy). +pub const XCT_SUITE_DID_FINISH: &str = + "_XCT_testSuite_didFinishAt_runCount_withFailures_unexpected_testDuration_totalDuration_"; + +// --- case lifecycle (legacy string-based, pre-iOS 14) --------------------- + +/// Test case started (legacy). +pub const XCT_CASE_DID_START: &str = "_XCT_testCaseDidStartForTestClass_method_"; + +/// Test case finished (legacy). +pub const XCT_CASE_DID_FINISH: &str = + "_XCT_testCaseDidFinishForTestClass_method_withStatus_duration_"; + +/// Test case recorded a failure (legacy). +pub const XCT_CASE_DID_FAIL: &str = + "_XCT_testCaseDidFailForTestClass_method_withMessage_file_line_"; + +/// Test case stalled on the main thread (legacy). +pub const XCT_CASE_DID_STALL: &str = "_XCT_testCase_method_didStallOnMainThreadInFile_line_"; + +/// Test case will start an activity (legacy). +pub const XCT_CASE_WILL_START_ACTIVITY: &str = "_XCT_testCase_method_willStartActivity_"; + +/// Test case finished an activity (legacy). +pub const XCT_CASE_DID_FINISH_ACTIVITY: &str = "_XCT_testCase_method_didFinishActivity_"; + +// --- suite lifecycle (identifier-based, iOS 14+) -------------------------- + +/// Test suite started, identified by XCTTestIdentifier. +pub const XCT_SUITE_DID_START_ID: &str = "_XCT_testSuiteWithIdentifier:didStartAt:"; + +/// Test suite finished, identified by XCTTestIdentifier. +pub const XCT_SUITE_DID_FINISH_ID: &str = "_XCT_testSuiteWithIdentifier:didFinishAt:runCount:skipCount:failureCount:expectedFailureCount:uncaughtExceptionCount:testDuration:totalDuration:"; + +// --- case lifecycle (identifier-based, iOS 14+) --------------------------- + +/// Test case started, identified by XCTTestIdentifier. +pub const XCT_CASE_DID_START_ID: &str = + "_XCT_testCaseDidStartWithIdentifier:testCaseRunConfiguration:"; + +/// Test case finished, identified by XCTTestIdentifier. +pub const XCT_CASE_DID_FINISH_ID: &str = + "_XCT_testCaseWithIdentifier:didFinishWithStatus:duration:"; + +/// Test case recorded an XCTIssue, identified by XCTTestIdentifier. +pub const XCT_CASE_DID_RECORD_ISSUE: &str = "_XCT_testCaseWithIdentifier:didRecordIssue:"; + +/// Test case will start an activity, identified by XCTTestIdentifier. +pub const XCT_CASE_WILL_START_ACTIVITY_ID: &str = "_XCT_testCaseWithIdentifier:willStartActivity:"; + +/// Test case finished an activity, identified by XCTTestIdentifier. +pub const XCT_CASE_DID_FINISH_ACTIVITY_ID: &str = "_XCT_testCaseWithIdentifier:didFinishActivity:"; + +/// Performance metric measured during a test method. +pub const XCT_METHOD_DID_MEASURE_METRIC: &str = + "_XCT_testMethod_ofClass_didMeasureMetric_file_line_"; diff --git a/src-tauri/vendor/idevice/src/services/dvt/xctest/listener.rs b/src-tauri/vendor/idevice/src/services/dvt/xctest/listener.rs new file mode 100644 index 0000000..b4868c1 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/dvt/xctest/listener.rs @@ -0,0 +1,234 @@ +//! XCUITest lifecycle callback trait. +//! +//! Implement [`XCUITestListener`] and pass it to [`super::XCUITestService::run`] to +//! receive per-test-case and per-suite events as they arrive from the runner. +//! All methods have default no-op implementations; override only what you need. +// Jackson Coxson + +use crate::IdeviceError; + +// --------------------------------------------------------------------------- +// Supporting types +// --------------------------------------------------------------------------- + +/// Result record for a single finished test case. +#[derive(Debug, Clone)] +pub struct XCTestCaseResult { + /// Test class name (e.g. `"UITests"`). + pub test_class: String, + /// Test method name (e.g. `"testLogin"`). + pub method: String, + /// Outcome string: `"passed"`, `"failed"`, or `"skipped"`. + pub status: String, + /// Wall-clock duration of the test case in seconds. + pub duration: f64, +} + +// --------------------------------------------------------------------------- +// Listener trait +// --------------------------------------------------------------------------- + +/// Callback interface for XCUITest lifecycle events. +/// +/// All methods receive `&mut self` so implementors can accumulate state (e.g. +/// counters, log buffers). Every method returns `Result<(), IdeviceError>` so +/// that the orchestrator can propagate fatal listener errors back to the caller. +/// +/// The default implementation is a no-op for every method. +#[allow(async_fn_in_trait)] +pub trait XCUITestListener: Send { + // --- test plan ---------------------------------------------------------- + + /// Invoked when the runner begins executing the test plan. + async fn did_begin_executing_test_plan(&mut self) -> Result<(), IdeviceError> { + Ok(()) + } + + /// Invoked when the runner has finished executing the entire test plan. + async fn did_finish_executing_test_plan(&mut self) -> Result<(), IdeviceError> { + Ok(()) + } + + // --- bundle ready ------------------------------------------------------- + + /// Invoked when the test bundle signals readiness (legacy protocol, no capabilities). + async fn test_bundle_ready(&mut self) -> Result<(), IdeviceError> { + Ok(()) + } + + /// Invoked when the test bundle reports its protocol version. + async fn test_bundle_ready_with_protocol_version( + &mut self, + _protocol_version: u64, + _minimum_version: u64, + ) -> Result<(), IdeviceError> { + Ok(()) + } + + /// Invoked when the runner announces readiness together with its capability set. + async fn test_runner_ready_with_capabilities(&mut self) -> Result<(), IdeviceError> { + Ok(()) + } + + // --- suite lifecycle ---------------------------------------------------- + + /// Invoked when a test suite starts. + async fn test_suite_did_start( + &mut self, + _suite: &str, + _started_at: &str, + ) -> Result<(), IdeviceError> { + Ok(()) + } + + /// Invoked when a test suite finishes. + #[allow(clippy::too_many_arguments)] + async fn test_suite_did_finish( + &mut self, + _suite: &str, + _finished_at: &str, + _run_count: u64, + _failures: u64, + _unexpected: u64, + _test_duration: f64, + _total_duration: f64, + _skipped: u64, + _expected_failures: u64, + _uncaught_exceptions: u64, + ) -> Result<(), IdeviceError> { + Ok(()) + } + + // --- case lifecycle ----------------------------------------------------- + + /// Invoked when a single test case starts. + async fn test_case_did_start( + &mut self, + _test_class: &str, + _method: &str, + ) -> Result<(), IdeviceError> { + Ok(()) + } + + /// Invoked when a single test case finishes. + async fn test_case_did_finish( + &mut self, + _result: XCTestCaseResult, + ) -> Result<(), IdeviceError> { + Ok(()) + } + + /// Invoked when a test case records a failure. + async fn test_case_did_fail( + &mut self, + _test_class: &str, + _method: &str, + _message: &str, + _file: &str, + _line: u64, + ) -> Result<(), IdeviceError> { + Ok(()) + } + + /// Invoked when a test case stalls on the main thread. + async fn test_case_did_stall( + &mut self, + _test_class: &str, + _method: &str, + _file: &str, + _line: u64, + ) -> Result<(), IdeviceError> { + Ok(()) + } + + // --- activities --------------------------------------------------------- + + /// Invoked when a test case is about to start an activity step. + async fn test_case_will_start_activity( + &mut self, + _test_class: &str, + _method: &str, + _activity_title: &str, + ) -> Result<(), IdeviceError> { + Ok(()) + } + + /// Invoked when a test case finishes an activity step. + async fn test_case_did_finish_activity( + &mut self, + _test_class: &str, + _method: &str, + _activity_title: &str, + ) -> Result<(), IdeviceError> { + Ok(()) + } + + // --- metrics ------------------------------------------------------------ + + /// Invoked when a test method measures a performance metric. + async fn test_method_did_measure_metric( + &mut self, + _test_class: &str, + _method: &str, + _metric: &str, + _file: &str, + _line: u64, + ) -> Result<(), IdeviceError> { + Ok(()) + } + + // --- logging ------------------------------------------------------------ + + /// Invoked for informational log messages from the runner. + async fn log_message(&mut self, _message: &str) -> Result<(), IdeviceError> { + Ok(()) + } + + /// Invoked for debug log messages from the runner. + async fn log_debug_message(&mut self, _message: &str) -> Result<(), IdeviceError> { + Ok(()) + } + + // --- protocol negotiation ----------------------------------------------- + + /// Invoked when the runner negotiates protocol versions. + async fn exchange_protocol_version( + &mut self, + _current: u64, + _minimum: u64, + ) -> Result<(), IdeviceError> { + Ok(()) + } + + // --- iOS 14+ UI testing init -------------------------------------------- + + /// Invoked when initialization for UI testing begins. + async fn did_begin_initializing_for_ui_testing(&mut self) -> Result<(), IdeviceError> { + Ok(()) + } + + /// Invoked when the runner forms a test plan payload. + async fn did_form_plan(&mut self, _data: &str) -> Result<(), IdeviceError> { + Ok(()) + } + + /// Invoked when the runner asks for launch progress. + async fn get_progress_for_launch(&mut self, _token: &str) -> Result<(), IdeviceError> { + Ok(()) + } + + /// Invoked when UI testing initialization fails. + async fn initialization_for_ui_testing_did_fail( + &mut self, + _description: &str, + ) -> Result<(), IdeviceError> { + Ok(()) + } + + /// Invoked when the test runner fails to bootstrap. + async fn did_fail_to_bootstrap(&mut self, description: &str) -> Result<(), IdeviceError> { + Err(IdeviceError::UnexpectedResponse(format!( + "test runner failed to bootstrap: {description}" + ))) + } +} diff --git a/src-tauri/vendor/idevice/src/services/dvt/xctest/mod.rs b/src-tauri/vendor/idevice/src/services/dvt/xctest/mod.rs new file mode 100644 index 0000000..32ec718 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/dvt/xctest/mod.rs @@ -0,0 +1,2099 @@ +//! XCTest service client for iOS instruments protocol. +//! +//! This module provides orchestration for running XCTest bundles (including +//! WebDriverAgent) on iOS devices through the instruments and testmanagerd +//! protocols. It handles session setup, test runner launch, and lifecycle +//! event dispatch. +//! +//! Supports iOS 11+ via lockdown and iOS 17+ via RSD tunnel. +//! +//! # Example +//! ```rust,no_run +//! # #[cfg(feature = "xctest")] +//! # { +//! use idevice::services::dvt::xctest::{TestConfig, XCUITestService}; +//! +//! #[tokio::main] +//! async fn main() -> Result<(), idevice::IdeviceError> { +//! // provider setup omitted +//! Ok(()) +//! } +//! # } +//! ``` + +pub mod dtx_services; +pub mod listener; +pub mod types; + +use std::sync::Arc; + +use plist::{Dictionary, Value}; +#[cfg(feature = "wda")] +use serde_json::Value as JsonValue; +use tracing::{debug, warn}; + +#[cfg(feature = "wda")] +use crate::services::wda::{WdaClient, WdaPorts}; +#[cfg(feature = "wda")] +use crate::services::wda_bridge::WdaBridge; +use crate::{ + IdeviceError, IdeviceService, ReadWrite, + dvt::message::{AuxValue, Message}, + provider::{IdeviceProvider, RsdProvider}, + services::{ + core_device_proxy::CoreDeviceProxy, + dvt::{ + process_control::ProcessControlClient, + remote_server::{IncomingHandlerOutcome, OwnedChannel, RemoteServerClient}, + }, + installation_proxy::InstallationProxyClient, + lockdown::LockdownClient, + rsd::RsdHandshake, + }, +}; +use dtx_services::{ + DVT_LEGACY_SERVICE, DVT_SERVICE, IDE_AUTHORIZE_TEST_SESSION, IDE_INITIATE_CTRL_SESSION_FOR_PID, + IDE_INITIATE_CTRL_SESSION_FOR_PID_PROTOCOL_VERSION, + IDE_INITIATE_CTRL_SESSION_WITH_CAPABILITIES, IDE_INITIATE_CTRL_SESSION_WITH_PROTOCOL_VERSION, + IDE_INITIATE_SESSION_WITH_IDENTIFIER_CAPABILITIES, + IDE_INITIATE_SESSION_WITH_IDENTIFIER_FOR_CLIENT_AT_PATH_PROTOCOL_VERSION, + IDE_START_EXECUTING_TEST_PLAN, TESTMANAGERD_RSD_SERVICE, TESTMANAGERD_SECURE_SERVICE, + TESTMANAGERD_SERVICE, XCODE_VERSION, XCT_BUNDLE_READY, XCT_BUNDLE_READY_WITH_PROTOCOL_VERSION, + XCT_CASE_DID_FAIL, XCT_CASE_DID_FINISH, XCT_CASE_DID_FINISH_ACTIVITY, + XCT_CASE_DID_FINISH_ACTIVITY_ID, XCT_CASE_DID_FINISH_ID, XCT_CASE_DID_RECORD_ISSUE, + XCT_CASE_DID_STALL, XCT_CASE_DID_START, XCT_CASE_DID_START_ID, XCT_CASE_WILL_START_ACTIVITY, + XCT_CASE_WILL_START_ACTIVITY_ID, XCT_DID_BEGIN_TEST_PLAN, XCT_DID_BEGIN_UI_INIT, + XCT_DID_FAIL_BOOTSTRAP, XCT_DID_FINISH_TEST_PLAN, XCT_DID_FORM_PLAN, + XCT_EXCHANGE_PROTOCOL_VERSION, XCT_GET_PROGRESS_FOR_LAUNCH, XCT_LOG_DEBUG_MESSAGE, + XCT_LOG_MESSAGE, XCT_METHOD_DID_MEASURE_METRIC, XCT_RUNNER_READY_WITH_CAPABILITIES, + XCT_SUITE_DID_FINISH, XCT_SUITE_DID_FINISH_ID, XCT_SUITE_DID_START, XCT_SUITE_DID_START_ID, + XCT_UI_INIT_DID_FAIL, XCTEST_DRIVER_INTERFACE, XCTEST_MANAGER_DAEMON_CONNECTION_INTERFACE, + XCTEST_MANAGER_IDE_INTERFACE, XCTEST_PROXY_IDE_TO_DRIVER, +}; +use listener::{XCTestCaseResult, XCUITestListener}; +use types::{ + XCActivityRecord, XCTCapabilities, XCTIssue, XCTTestIdentifier, XCTestConfiguration, + archive_nsuuid_to_bytes, archive_xct_capabilities_to_bytes, +}; + +#[cfg(feature = "wda")] +use tokio::task::JoinHandle; + +// --------------------------------------------------------------------------- +// TestConfig +// --------------------------------------------------------------------------- + +/// Launch configuration for the XCTest runner and optional target application. +/// +/// Built from `InstallationProxyClient` and used to generate both the +/// on-device `XCTestConfiguration` file and the process-launch environment. +/// +/// # Example +/// ```rust,no_run +/// # #[cfg(feature = "xctest")] +/// # async fn example() -> Result<(), idevice::IdeviceError> { +/// // let cfg = TestConfig::from_installation_proxy(&mut proxy, "com.example.App.xctrunner", None).await?; +/// # Ok(()) +/// # } +/// ``` +#[derive(Debug, Clone)] +pub struct TestConfig { + // --- Runner app info (from installation_proxy) ------------------------- + /// Bundle identifier of the runner app (e.g. `"com.example.App.xctrunner"`). + pub runner_bundle_id: String, + /// On-device path of the runner app bundle (`"Path"` key). + pub runner_app_path: String, + /// On-device container path of the runner app (`"Container"` key). + pub runner_app_container: String, + /// Executable name inside the runner bundle (`"CFBundleExecutable"` key). + /// Must end with `"-Runner"`. + pub runner_bundle_executable: String, + + // --- Target app (optional) -------------------------------------------- + /// Bundle identifier of the app under test, if any. + pub target_bundle_id: Option, + /// On-device path of the target app bundle, if any. + pub target_app_path: Option, + /// Extra environment variables forwarded to the target app. + pub target_app_env: Option, + /// Extra launch arguments forwarded to the target app. + pub target_app_args: Option>, + + // --- Test filters ------------------------------------------------------ + /// If set, only these test identifiers are run. + pub tests_to_run: Option>, + /// If set, these test identifiers are skipped. + pub tests_to_skip: Option>, + + // --- Runner overrides ------------------------------------------------- + /// Additional environment variables merged into the runner launch env. + pub runner_env: Option, + /// Additional arguments appended to the runner launch args. + pub runner_args: Option>, +} + +impl TestConfig { + /// Constructs a `TestConfig` by querying `InstallationProxyClient` for + /// the runner (and optionally target) application information. + /// + /// # Arguments + /// * `install_proxy` - Connected `InstallationProxyClient` + /// * `runner_bundle_id` - Bundle identifier of the `.xctrunner` app + /// * `target_bundle_id` - Optional bundle identifier of the app under test + /// + /// # Errors + /// * `IdeviceError::AppNotInstalled` if runner or target is not found + /// * `IdeviceError::UnexpectedResponse("unexpected response".into())` if `CFBundleExecutable` does not + /// end with `"-Runner"` or required keys are missing + pub async fn from_installation_proxy( + install_proxy: &mut InstallationProxyClient, + runner_bundle_id: &str, + target_bundle_id: Option<&str>, + ) -> Result { + let app_string = |dict: &Dictionary, key: &str| -> Result { + dict.get(key) + .and_then(|value| value.as_string()) + .map(ToOwned::to_owned) + .ok_or_else(|| { + warn!("Missing or non-string key '{}' in app info dict", key); + IdeviceError::UnexpectedResponse("unexpected response".into()) + }) + }; + + // Build the bundle ID list to look up in one request + let mut ids = vec![runner_bundle_id.to_owned()]; + if let Some(t) = target_bundle_id { + ids.push(t.to_owned()); + } + + let apps = install_proxy.get_apps(None, Some(ids)).await?; + + // --- Runner --- + let runner_info = apps.get(runner_bundle_id).ok_or_else(|| { + warn!("Runner app not installed: {}", runner_bundle_id); + IdeviceError::AppNotInstalled + })?; + + let runner_dict = runner_info.as_dictionary().ok_or_else(|| { + warn!("Runner info is not a dictionary"); + IdeviceError::UnexpectedResponse("unexpected response".into()) + })?; + + let runner_app_path = app_string(runner_dict, "Path")?; + let runner_app_container = app_string(runner_dict, "Container")?; + let runner_bundle_executable = app_string(runner_dict, "CFBundleExecutable")?; + + if !runner_bundle_executable.ends_with("-Runner") { + warn!( + "CFBundleExecutable '{}' does not end with '-Runner'; this is not a valid xctest runner bundle", + runner_bundle_executable + ); + return Err(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )); + } + + // --- Target (optional) --- + let (target_bundle_id_out, target_app_path) = if let Some(t) = target_bundle_id { + let target_info = apps.get(t).ok_or_else(|| { + warn!("Target app not installed: {}", t); + IdeviceError::AppNotInstalled + })?; + let target_dict = target_info.as_dictionary().ok_or_else(|| { + warn!("Target info is not a dictionary"); + IdeviceError::UnexpectedResponse("unexpected response".into()) + })?; + let path = app_string(target_dict, "Path")?; + (Some(t.to_owned()), Some(path)) + } else { + (None, None) + }; + + Ok(Self { + runner_bundle_id: runner_bundle_id.to_owned(), + runner_app_path, + runner_app_container, + runner_bundle_executable, + target_bundle_id: target_bundle_id_out, + target_app_path, + target_app_env: None, + target_app_args: None, + tests_to_run: None, + tests_to_skip: None, + runner_env: None, + runner_args: None, + }) + } + + /// Returns the config name — the executable name with `"-Runner"` stripped. + /// + /// For example, `"WebDriverAgentRunner-Runner"` → `"WebDriverAgentRunner"`. + pub fn config_name(&self) -> &str { + self.runner_bundle_executable + .strip_suffix("-Runner") + .unwrap_or(&self.runner_bundle_executable) + } + + /// Builds an [`XCTestConfiguration`] for this test run. + /// + /// # Arguments + /// * `session_id` - Unique UUID for this test session + /// * `ios_major_version` - iOS major version number (e.g. `17`) + /// + /// # Errors + /// Propagates serialisation errors from nested types. + pub fn build_xctest_configuration( + &self, + session_id: uuid::Uuid, + ios_major_version: u8, + ) -> Result { + let config_name = self.config_name(); + + let test_bundle_url = format!( + "file://{}/PlugIns/{}.xctest", + self.runner_app_path, config_name + ); + + let automation_framework_path = if ios_major_version >= 17 { + "/System/Developer/Library/PrivateFrameworks/XCTAutomationSupport.framework".to_owned() + } else { + "/Developer/Library/PrivateFrameworks/XCTAutomationSupport.framework".to_owned() + }; + + // productModuleName: config_name when a target app is set, else default WDA name + let product_module_name = if self.target_bundle_id.is_some() { + config_name.to_owned() + } else { + "WebDriverAgentRunner".to_owned() + }; + + // When a target app is specified, targetApplicationEnvironment must be at + // least an empty dict (not null) — mirrors Python's `self.target_app_env or {}` + let target_application_environment = if self.target_bundle_id.is_some() { + Some(self.target_app_env.clone().unwrap_or_default()) + } else { + None + }; + + Ok(XCTestConfiguration { + test_bundle_url, + session_identifier: session_id, + product_module_name, + automation_framework_path, + target_application_bundle_id: self.target_bundle_id.clone(), + target_application_path: self.target_app_path.clone(), + target_application_environment, + target_application_arguments: self.target_app_args.clone().unwrap_or_default(), + tests_to_run: self.tests_to_run.clone(), + tests_to_skip: self.tests_to_skip.clone(), + ide_capabilities: XCTCapabilities::ide_defaults(), + }) + } +} + +// --------------------------------------------------------------------------- +// build_launch_env +// --------------------------------------------------------------------------- + +/// Builds the process-launch arguments, environment, and options for the +/// XCTest runner process. +/// +/// # Arguments +/// * `ios_major_version` - iOS major version number +/// * `session_id` - Test session UUID +/// * `runner_app_path` - On-device path of the runner app bundle +/// * `runner_app_container` - On-device container path of the runner app +/// * `target_name` - Config name (executable without `"-Runner"` suffix) +/// * `xctest_config_path` - Device path to the `.xctestconfiguration` file +/// (e.g. `"/tmp/{UUID}.xctestconfiguration"`) +/// * `extra_env` - Additional env vars merged on top of the base set +/// * `extra_args` - Additional args appended after the base set +/// +/// # Returns +/// `(launch_args, launch_env, launch_options)` as `(Vec, Dictionary, Dictionary)` +#[allow(clippy::too_many_arguments)] +pub(crate) fn build_launch_env( + ios_major_version: u8, + session_id: &uuid::Uuid, + runner_app_path: &str, + runner_app_container: &str, + target_name: &str, + xctest_config_path: &str, + extra_env: Option<&Dictionary>, + extra_args: Option<&[String]>, +) -> (Vec, Dictionary, Dictionary) { + let session_upper = session_id.to_string().to_uppercase(); + + // Base environment + let mut env = crate::plist!(dict { + "CA_ASSERT_MAIN_THREAD_TRANSACTIONS": "0", + "CA_DEBUG_TRANSACTIONS": "0", + "DYLD_FRAMEWORK_PATH": format!("{}/Frameworks:", runner_app_path), + "DYLD_LIBRARY_PATH": format!("{}/Frameworks", runner_app_path), + "MTC_CRASH_ON_REPORT": "1", + "NSUnbufferedIO": "YES", + "SQLITE_ENABLE_THREAD_ASSERTIONS": "1", + "WDA_PRODUCT_BUNDLE_IDENTIFIER": "", + "XCTestBundlePath": format!("{}/PlugIns/{}.xctest", runner_app_path, target_name), + "XCTestConfigurationFilePath": format!("{}{}", runner_app_container, xctest_config_path), + "XCODE_DBG_XPC_EXCLUSIONS": "com.apple.dt.xctestSymbolicator", + "XCTestSessionIdentifier": session_upper.clone(), + }); + + // iOS >= 11 + if ios_major_version >= 11 { + let ios11_env = crate::plist!(dict { + "DYLD_INSERT_LIBRARIES": "/Developer/usr/lib/libMainThreadChecker.dylib", + "OS_ACTIVITY_DT_MODE": "YES", + }); + for (key, value) in ios11_env { + env.insert(key, value); + } + } + + // iOS >= 17 — extend DYLD paths and clear config path (sent via capabilities) + if ios_major_version >= 17 { + let existing_fw = env + .get("DYLD_FRAMEWORK_PATH") + .and_then(|v| v.as_string()) + .unwrap_or("") + .to_owned(); + let existing_lib = env + .get("DYLD_LIBRARY_PATH") + .and_then(|v| v.as_string()) + .unwrap_or("") + .to_owned(); + // Prepend '$' so dyld expands the existing path value at launch time, + // matching Python: f"${app_env['DYLD_FRAMEWORK_PATH']}/System/..." + let ios17_env = crate::plist!(dict { + "DYLD_FRAMEWORK_PATH": format!( + "${}/System/Developer/Library/Frameworks:", + existing_fw + ), + "DYLD_LIBRARY_PATH": format!("${}:/System/Developer/usr/lib", existing_lib), + // Config path is sent as return value of _XCT_testRunnerReadyWithCapabilities_ + "XCTestConfigurationFilePath": "", + "XCTestManagerVariant": "DDI", + }); + for (key, value) in ios17_env { + env.insert(key, value); + } + } + + // Merge caller-provided overrides + if let Some(extra) = extra_env { + for (k, v) in extra.iter() { + env.insert(k.clone(), v.clone()); + } + } + + // Launch arguments + let mut args = vec![ + "-NSTreatUnknownArgumentsAsOpen".to_owned(), + "NO".to_owned(), + "-ApplePersistenceIgnoreState".to_owned(), + "YES".to_owned(), + ]; + if let Some(extra) = extra_args { + args.extend_from_slice(extra); + } + + // Launch options + let opts = if ios_major_version >= 12 { + crate::plist!(dict { + "StartSuspendedKey": false, + "ActivateSuspended": true, + }) + } else { + crate::plist!(dict { + "StartSuspendedKey": false, + }) + }; + + (args, env, opts) +} + +// --------------------------------------------------------------------------- +// testmanagerd connections +// --------------------------------------------------------------------------- + +/// Active DTX connections for running XCTest. +/// +/// Holds three `RemoteServerClient` instances: +/// - `ctrl` — testmanagerd control channel connection +/// - `main` — testmanagerd main channel connection +/// - `dvt` — DVT instruments connection (for `ProcessControl`) +pub(super) struct TestManagerConnections { + pub ctrl: RemoteServerClient>, + pub main: RemoteServerClient>, + pub dvt: RemoteServerClient>, + /// Keeps the software tunnel/adapter handles alive for the duration of the session. + #[allow(dead_code)] + rsd_handles: Vec, +} + +/// Connects to a lockdown-based DTX service, trying each name in order. +/// +/// Returns the first successful `RemoteServerClient`. +async fn connect_dtx_service( + provider: &dyn IdeviceProvider, + service_names: &[&str], + read_greeting: bool, +) -> Result>, IdeviceError> { + let mut lockdown = LockdownClient::connect(provider).await?; + lockdown + .start_session(&provider.get_pairing_file().await?) + .await?; + + let mut last_err: Option = None; + for &name in service_names { + match lockdown.start_service(name).await { + Ok((port, ssl)) => { + let mut idevice = provider.connect(port).await?; + if ssl { + idevice + .start_session(&provider.get_pairing_file().await?, false) + .await?; + } + let socket = idevice + .get_socket() + .ok_or(IdeviceError::NoEstablishedConnection)?; + let label = format!("lockdown:{name}"); + let client = RemoteServerClient::with_label(socket, label); + if read_greeting { + // testmanagerd sends a capabilities hello on connect. + let _ = client + .wait_for_capabilities(std::time::Duration::from_secs(10)) + .await; + } + return Ok(client); + } + Err(e) => { + last_err = Some(e); + } + } + } + Err(last_err.unwrap_or(IdeviceError::ServiceNotFound)) +} + +const RSD_GREETING_TIMEOUT_SECS: u64 = 30; + +/// DTX capabilities dict announced to the daemon on each connection. +/// +/// Mirrors `DTXConnection.DEFAULT_CAPABILITIES` from the Instruments protocol. +fn dtx_capabilities_dict(include_process_control_callback: bool) -> plist::Dictionary { + let mut caps = crate::plist!(dict { + "com.apple.private.DTXBlockCompression": 0i64, + "com.apple.private.DTXConnection": 1i64, + }); + if include_process_control_callback { + caps.insert( + "com.apple.instruments.client.processcontrol.capability.terminationCallback".into(), + plist::Value::Integer(1i64.into()), + ); + } + caps +} + +/// Opens a single RSD service port and performs the DTX capability handshake, +/// retrying up to `MAX_ATTEMPTS` times. +async fn rsd_connect( + handle: &mut crate::tcp::handle::AdapterHandle, + handshake: &RsdHandshake, + service_name: &str, + label: &str, + include_process_control_callback: bool, +) -> Result>, IdeviceError> { + const MAX_ATTEMPTS: usize = 5; + let service = handshake + .services + .get(service_name) + .ok_or_else(|| { + warn!("RSD service not found: {}", service_name); + IdeviceError::ServiceNotFound + })? + .clone(); + let port = service.port; + + let mut last_err = None; + for attempt in 1..=MAX_ATTEMPTS { + debug!( + "[{}] opening service '{}' on remote port {} (attempt {}/{})", + label, service_name, port, attempt, MAX_ATTEMPTS + ); + let stream = handle.connect_to_service_port(port).await?; + debug!("[{}] service port {} connected", label, port); + let mut client = RemoteServerClient::with_label(stream, label); + match client + .perform_handshake( + Some(dtx_capabilities_dict(include_process_control_callback)), + std::time::Duration::from_secs(RSD_GREETING_TIMEOUT_SECS), + ) + .await + { + Ok(remote_capabilities) => { + debug!( + "[{}] RSD DTX capabilities exchange complete: {:?}", + label, remote_capabilities + ); + return Ok(client); + } + Err(error) => { + warn!( + "[{}] RSD DTX handshake failed on attempt {}/{}: {}", + label, attempt, MAX_ATTEMPTS, error + ); + last_err = Some(error); + if attempt < MAX_ATTEMPTS { + crate::time::sleep(std::time::Duration::from_millis(750)).await; + } + } + } + } + + Err(last_err.unwrap_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + ))) +} + +/// Attempts a single CoreDeviceProxy + RSD stack setup, returning all three +/// DTX connections on success. +async fn connect_rsd_stack_once( + provider: &dyn IdeviceProvider, +) -> Result { + let proxy = CoreDeviceProxy::connect(provider).await?; + let rsd_port = proxy.tunnel_info().server_rsd_port; + let adapter = proxy.create_software_tunnel()?; + let mut handle = adapter.to_async_handle(); + + debug!("[rsd] connecting to shared RSD port {}", rsd_port); + let rsd_stream = handle.connect_to_service_port(rsd_port).await?; + let handshake = RsdHandshake::new(rsd_stream).await?; + debug!( + "[rsd] shared RSD handshake OK — {} services advertised", + handshake.services.len() + ); + + let dvt = match rsd_connect( + &mut handle, + &handshake, + "com.apple.instruments.dtservicehub", + "dtservicehub", + true, + ) + .await + { + Ok(client) => client, + Err(e) => { + warn!( + "RSD dtservicehub connect failed ({}), falling back to lockdown DVT", + e + ); + connect_dtx_service(provider, &[DVT_SERVICE, DVT_LEGACY_SERVICE], false).await? + } + }; + let ctrl = rsd_connect( + &mut handle, + &handshake, + TESTMANAGERD_RSD_SERVICE, + "testmanagerd-ctrl", + false, + ) + .await?; + let main = rsd_connect( + &mut handle, + &handshake, + TESTMANAGERD_RSD_SERVICE, + "testmanagerd-main", + false, + ) + .await?; + + Ok(TestManagerConnections { + ctrl, + main, + dvt, + rsd_handles: vec![handle], + }) +} + +/// Establishes the three DTX connections for iOS 17+ via CoreDeviceProxy + RSD. +/// +/// Opens a software TCP tunnel through CoreDeviceProxy, does the RSD handshake +/// to discover service ports, then connects to testmanagerd (×2) and +/// `dtservicehub` on their advertised ports. +async fn connect_testmanagerd_rsd( + provider: &dyn IdeviceProvider, +) -> Result { + const RSD_STACK_ATTEMPTS: usize = 3; + + let mut last_err = None; + for attempt in 1..=RSD_STACK_ATTEMPTS { + debug!( + "[rsd] establishing CoreDeviceProxy/software tunnel stack (attempt {}/{})", + attempt, RSD_STACK_ATTEMPTS + ); + match connect_rsd_stack_once(provider).await { + Ok(connections) => return Ok(connections), + Err(error) => { + warn!( + "[rsd] CoreDeviceProxy/software tunnel stack attempt {}/{} failed: {}", + attempt, RSD_STACK_ATTEMPTS, error + ); + last_err = Some(error); + if attempt < RSD_STACK_ATTEMPTS { + crate::time::sleep(std::time::Duration::from_secs(1)).await; + } + } + } + } + + Err(last_err.unwrap_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + ))) +} + +/// Establishes the three DTX connections required for an XCTest run. +/// +/// For iOS 17+ tries `CoreDeviceProxy` + RSD first. Falls back to lockdown +/// for iOS < 17 or if CoreDeviceProxy is unavailable. +/// +/// # Arguments +/// * `provider` - Device connection provider +/// * `ios_major_version` - iOS major version (used to select service names) +pub(super) async fn connect_testmanagerd( + provider: &dyn IdeviceProvider, + ios_major_version: u8, +) -> Result { + // iOS 17+ must use RSD tunnel path + if ios_major_version >= 17 { + return connect_testmanagerd_rsd(provider).await; + } + + // iOS < 17 (or fallback): lockdown path + let tm_service = if ios_major_version >= 14 { + TESTMANAGERD_SECURE_SERVICE + } else { + TESTMANAGERD_SERVICE + }; + + let ctrl = connect_dtx_service(provider, &[tm_service], true).await?; + let main = connect_dtx_service(provider, &[tm_service], true).await?; + let dvt = connect_dtx_service(provider, &[DVT_SERVICE, DVT_LEGACY_SERVICE], false).await?; + + Ok(TestManagerConnections { + ctrl, + main, + dvt, + rsd_handles: Vec::new(), + }) +} + +// --------------------------------------------------------------------------- +// session init + process launch +// --------------------------------------------------------------------------- + +/// Initialises the control session on the ctrl DTX channel. +/// +/// Sends the appropriate IDE-initiation method based on `ios_major_version`. +pub(super) async fn init_ctrl_session( + ctrl_channel: &mut OwnedChannel, + ios_major_version: u8, +) -> Result<(), IdeviceError> { + if ios_major_version >= 17 { + let caps_bytes = + AuxValue::Array(archive_xct_capabilities_to_bytes(&XCTCapabilities::empty())?); + let reply = ctrl_channel + .call_method_with_reply( + Some(IDE_INITIATE_CTRL_SESSION_WITH_CAPABILITIES), + Some(vec![caps_bytes]), + ) + .await?; + debug!("init_ctrl_session (iOS 17+) reply: {:?}", reply.data); + } else if ios_major_version >= 11 { + let version_bytes = AuxValue::archived_value(Value::Integer((XCODE_VERSION as i64).into())); + let reply = ctrl_channel + .call_method_with_reply( + Some(IDE_INITIATE_CTRL_SESSION_WITH_PROTOCOL_VERSION), + Some(vec![version_bytes]), + ) + .await?; + debug!("init_ctrl_session (iOS 11-16) reply: {:?}", reply.data); + } + // iOS < 11: nothing to do + Ok(()) +} + +/// Initialises the main test session on the main DTX channel. +pub(super) async fn init_session( + main_channel: &mut OwnedChannel, + ios_major_version: u8, + session_id: &uuid::Uuid, + xctest_config: &XCTestConfiguration, +) -> Result<(), IdeviceError> { + let uuid_bytes = AuxValue::Array(archive_nsuuid_to_bytes(session_id)?); + + if ios_major_version >= 17 { + let caps_bytes = AuxValue::Array(archive_xct_capabilities_to_bytes( + &XCTCapabilities::ide_defaults(), + )?); + let reply = main_channel + .call_method_with_reply( + Some(IDE_INITIATE_SESSION_WITH_IDENTIFIER_CAPABILITIES), + Some(vec![uuid_bytes, caps_bytes]), + ) + .await?; + debug!("init_session (iOS 17+) reply: {:?}", reply.data); + } else if ios_major_version >= 11 { + let client_bytes = AuxValue::archived_value(Value::String("not-very-important".into())); + let path_bytes = AuxValue::archived_value(Value::String( + "/Applications/Xcode.app/Contents/Developer/usr/bin/xcodebuild".into(), + )); + let version_bytes = AuxValue::archived_value(Value::Integer((XCODE_VERSION as i64).into())); + let reply = main_channel + .call_method_with_reply( + Some(IDE_INITIATE_SESSION_WITH_IDENTIFIER_FOR_CLIENT_AT_PATH_PROTOCOL_VERSION), + Some(vec![uuid_bytes, client_bytes, path_bytes, version_bytes]), + ) + .await?; + debug!("init_session (iOS 11-16) reply: {:?}", reply.data); + } else { + return Ok(()); + } + + let _ = xctest_config; // used by caller for bootstrap reply handling + Ok(()) +} + +/// Launches the XCTest runner process via ProcessControl. +/// +/// Uses `launchSuspendedProcessWithDevicePath:bundleIdentifier:environment:arguments:options:` +/// with the provided arguments and options dictionaries. +/// +/// # Returns +/// PID of the launched process. +pub(super) async fn launch_runner( + process_control: &mut ProcessControlClient<'_, R>, + bundle_id: &str, + launch_args: Vec, + launch_env: Dictionary, + launch_options: Dictionary, +) -> Result { + let args_array: Vec = launch_args.into_iter().map(Value::String).collect(); + + process_control + .launch_with_options(bundle_id, launch_env, args_array, launch_options) + .await +} + +// --------------------------------------------------------------------------- +// authorize + driver channel + start plan +// --------------------------------------------------------------------------- + +/// Authorises the test session for the launched runner process. +pub(super) async fn authorize_test( + ctrl_channel: &mut OwnedChannel, + ios_major_version: u8, + pid: u64, +) -> Result<(), IdeviceError> { + let pid_bytes = AuxValue::archived_value(Value::Integer((pid as i64).into())); + + if ios_major_version >= 12 { + let reply = ctrl_channel + .call_method_with_reply(Some(IDE_AUTHORIZE_TEST_SESSION), Some(vec![pid_bytes])) + .await?; + match reply.data { + Some(Value::Boolean(true)) | None => { + debug!("authorize_test: OK"); + } + Some(Value::Boolean(false)) => { + warn!("authorize_test returned false"); + return Err(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )); + } + other => { + debug!("authorize_test reply: {:?}", other); + } + } + } else if ios_major_version >= 10 { + let version_bytes = AuxValue::archived_value(Value::Integer((XCODE_VERSION as i64).into())); + let reply = ctrl_channel + .call_method_with_reply( + Some(IDE_INITIATE_CTRL_SESSION_FOR_PID_PROTOCOL_VERSION), + Some(vec![pid_bytes, version_bytes]), + ) + .await?; + debug!("authorize_test (<12, >=10) reply: {:?}", reply.data); + } else { + let reply = ctrl_channel + .call_method_with_reply( + Some(IDE_INITIATE_CTRL_SESSION_FOR_PID), + Some(vec![pid_bytes]), + ) + .await?; + debug!("authorize_test (<10) reply: {:?}", reply.data); + } + Ok(()) +} + +struct TestManagerProxy { + channel: OwnedChannel, +} + +impl TestManagerProxy { + async fn open( + client: &mut RemoteServerClient, + ios_major_version: u8, + ) -> Result { + let channel = if testmanager_uses_proxy(ios_major_version) { + client + .open_proxied_service_channel( + XCTEST_MANAGER_IDE_INTERFACE, + XCTEST_MANAGER_DAEMON_CONNECTION_INTERFACE, + ) + .await? + } else { + client + .open_service_channel(XCTEST_MANAGER_IDE_INTERFACE) + .await? + }; + + Ok(Self { + channel: channel.detach(), + }) + } + + async fn install_bootstrap_handler(&mut self, xctest_config: XCTestConfiguration) { + install_early_xctest_handler(&mut self.channel, xctest_config).await; + } + + async fn init_ctrl_session(&mut self, ios_major_version: u8) -> Result<(), IdeviceError> { + init_ctrl_session(&mut self.channel, ios_major_version).await + } + + async fn init_session( + &mut self, + ios_major_version: u8, + session_id: &uuid::Uuid, + xctest_config: &XCTestConfiguration, + ) -> Result<(), IdeviceError> { + init_session( + &mut self.channel, + ios_major_version, + session_id, + xctest_config, + ) + .await + } + + async fn authorize_test( + &mut self, + ios_major_version: u8, + pid: u64, + ) -> Result<(), IdeviceError> { + authorize_test(&mut self.channel, ios_major_version, pid).await + } +} + +struct DriverProxy { + channel: OwnedChannel>, +} + +impl DriverProxy { + async fn wait( + client: &mut RemoteServerClient>, + timeout_secs: f64, + ) -> Result { + Ok(Self { + channel: wait_for_driver_channel(client, timeout_secs).await?, + }) + } + + async fn start_executing_test_plan(&mut self) -> Result<(), IdeviceError> { + start_executing_test_plan(&mut self.channel).await + } +} + +struct XCTestProcessControlChannel<'a, R: ReadWrite> { + service: ProcessControlClient<'a, R>, +} + +impl<'a, R: ReadWrite + 'static> XCTestProcessControlChannel<'a, R> { + async fn open(client: &'a mut RemoteServerClient) -> Result { + Ok(Self { + service: ProcessControlClient::new(client).await?, + }) + } + + async fn launch_suspended_process( + &mut self, + bundle_id: &str, + launch_args: Vec, + launch_env: Dictionary, + launch_options: Dictionary, + ) -> Result { + launch_runner( + &mut self.service, + bundle_id, + launch_args, + launch_env, + launch_options, + ) + .await + } +} + +/// Waits for the test runner to open the reverse `XCTestDriverInterface` channel. +/// +/// After launching, the runner sends `_requestChannelWithCode:identifier:` on root +/// channel 0. This function reads root-channel messages until that request arrives, +/// replies with an empty acknowledgement, registers the channel, and returns a +/// `Channel` handle to it. +fn testmanager_uses_proxy(ios_major_version: u8) -> bool { + ios_major_version >= 17 +} + +async fn wait_for_xctest_service_channel( + main_client: &mut RemoteServerClient>, + plain_identifiers: &[&str], + proxy_remote_identifiers: &[&str], + timeout_secs: f64, +) -> Result>, IdeviceError> { + let timeout = Some(std::time::Duration::from_secs_f64(timeout_secs)); + + let code = match main_client + .wait_for_proxied_service_channel_code(proxy_remote_identifiers, true, Some(true), timeout) + .await + { + Ok(code) => code, + Err(IdeviceError::XcTestTimeout(_)) => match main_client + .wait_for_service_channel_code(plain_identifiers, Some(true), timeout) + .await + { + Ok(code) => code, + Err(IdeviceError::XcTestTimeout(_)) => return Err(IdeviceError::TestRunnerTimeout), + Err(error) => return Err(error), + }, + Err(error) => return Err(error), + }; + + Ok(main_client.accept_owned_channel(code)) +} + +async fn register_early_driver_channel_handler( + main_client: &mut RemoteServerClient>, + xctest_config: &XCTestConfiguration, +) { + let xctest_config = xctest_config.clone(); + main_client + .register_incoming_channel_initializer( + &[XCTEST_DRIVER_INTERFACE, XCTEST_PROXY_IDE_TO_DRIVER], + move |mut channel, _identifier| { + let xctest_config = xctest_config.clone(); + Box::pin(async move { + install_early_xctest_handler(&mut channel, xctest_config).await; + Ok(()) + }) + }, + ) + .await; +} + +async fn initialize_testmanager_sessions( + ctrl_proxy: &mut TestManagerProxy>, + main_proxy: &mut TestManagerProxy>, + xctest_config: &XCTestConfiguration, +) -> Result<(), IdeviceError> { + ctrl_proxy + .install_bootstrap_handler(xctest_config.clone()) + .await; + main_proxy + .install_bootstrap_handler(xctest_config.clone()) + .await; + Ok(()) +} + +async fn initialize_testmanager_daemon_sessions( + ctrl_proxy: &mut TestManagerProxy>, + main_proxy: &mut TestManagerProxy>, + ios_major_version: u8, + session_id: &uuid::Uuid, + xctest_config: &XCTestConfiguration, +) -> Result<(), IdeviceError> { + ctrl_proxy.init_ctrl_session(ios_major_version).await?; + main_proxy + .init_session(ios_major_version, session_id, xctest_config) + .await?; + + Ok(()) +} + +async fn launch_and_authorize_test_runner( + ctrl_proxy: &mut TestManagerProxy>, + process_control: &mut XCTestProcessControlChannel<'_, Box>, + ios_major_version: u8, + runner_bundle_id: &str, + launch_args: Vec, + launch_env: Dictionary, + launch_options: Dictionary, +) -> Result { + let pid = process_control + .launch_suspended_process(runner_bundle_id, launch_args, launch_env, launch_options) + .await?; + debug!("Launched test runner pid={}", pid); + + if ios_major_version < 17 { + crate::time::sleep(std::time::Duration::from_secs(1)).await; + } + + ctrl_proxy.authorize_test(ios_major_version, pid).await?; + Ok(pid) +} + +async fn start_test_plan_session( + main_client: &mut RemoteServerClient>, + _main_proxy: &mut TestManagerProxy>, +) -> Result>, IdeviceError> { + let mut driver_proxy = DriverProxy::wait(main_client, 30.0).await?; + driver_proxy.start_executing_test_plan().await?; + driver_proxy.channel.clear_incoming_handler().await; + Ok(driver_proxy.channel) +} + +pub(super) async fn wait_for_driver_channel( + main_client: &mut RemoteServerClient>, + timeout_secs: f64, +) -> Result>, IdeviceError> { + const DRIVER_SERVICE_IDENTIFIERS: &[&str] = &[XCTEST_DRIVER_INTERFACE]; + wait_for_xctest_service_channel( + main_client, + DRIVER_SERVICE_IDENTIFIERS, + DRIVER_SERVICE_IDENTIFIERS, + timeout_secs, + ) + .await +} + +/// Signals the test runner to begin executing the test plan. +pub(super) async fn start_executing_test_plan( + driver_channel: &mut OwnedChannel, +) -> Result<(), IdeviceError> { + let version_bytes = AuxValue::archived_value(Value::Integer((XCODE_VERSION as i64).into())); + let reply = driver_channel + .call_method_with_reply( + Some(IDE_START_EXECUTING_TEST_PLAN), + Some(vec![version_bytes]), + ) + .await?; + debug!("start_executing_test_plan reply: {:?}", reply.data); + Ok(()) +} + +// --------------------------------------------------------------------------- +// _XCT_* dispatch + run_dispatch_loop + XCUITestService +// --------------------------------------------------------------------------- + +// --- Aux-value helpers ------------------------------------------------------ + +fn decode_aux_archive(aux: &AuxValue) -> Result { + match aux { + AuxValue::Array(bytes) => ns_keyed_archive::decode::from_bytes(bytes) + .map_err(|_| IdeviceError::UnexpectedResponse("unexpected response".into())), + _ => Err(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )), + } +} + +fn aux_as_string(aux: &AuxValue) -> Result { + if let AuxValue::String(s) = aux { + return Ok(s.clone()); + } + match decode_aux_archive(aux)? { + Value::String(s) => Ok(s), + _ => Err(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )), + } +} + +fn aux_as_u64(aux: &AuxValue) -> Result { + match aux { + AuxValue::U32(v) => return Ok(*v as u64), + AuxValue::I64(v) => return Ok(*v as u64), + _ => {} + } + match decode_aux_archive(aux)? { + Value::Integer(i) => i.as_unsigned().ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )), + _ => Err(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )), + } +} + +fn aux_as_f64(aux: &AuxValue) -> Result { + match decode_aux_archive(aux) { + Ok(Value::Real(f)) => return Ok(f), + Ok(Value::Integer(i)) => return Ok(i.as_unsigned().unwrap_or(0) as f64), + _ => {} + } + match aux { + AuxValue::U32(v) => Ok(*v as f64), + AuxValue::I64(v) => Ok(*v as f64), + AuxValue::Double(v) => Ok(*v), + _ => Err(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )), + } +} + +// --- Dispatch --------------------------------------------------------------- + +/// Dispatches a single incoming `_XCT_*` message to the appropriate listener +/// method. +/// +/// Returns `Some(reply_bytes)` if the caller must send a reply (only for +/// `_XCT_testRunnerReadyWithCapabilities_`); `None` otherwise. +pub(super) async fn dispatch_xct_message( + method: &str, + aux: &[AuxValue], + xctest_config: &XCTestConfiguration, + listener: &mut L, + done_flag: &mut bool, +) -> Result>, IdeviceError> { + match method { + // --- logging --- + m if m == XCT_LOG_DEBUG_MESSAGE => { + if let Some(msg) = aux.first().map(aux_as_string).transpose()? { + listener.log_debug_message(&msg).await?; + } + } + m if m == XCT_LOG_MESSAGE => { + if let Some(msg) = aux.first().map(aux_as_string).transpose()? { + listener.log_message(&msg).await?; + } + } + + // --- protocol negotiation --- + m if m == XCT_EXCHANGE_PROTOCOL_VERSION => { + let current = aux.first().map(aux_as_u64).transpose()?.unwrap_or(0); + let minimum = aux.get(1).map(aux_as_u64).transpose()?.unwrap_or(0); + listener.exchange_protocol_version(current, minimum).await?; + } + + // --- bundle ready --- + m if m == XCT_BUNDLE_READY => { + listener.test_bundle_ready().await?; + } + m if m == XCT_BUNDLE_READY_WITH_PROTOCOL_VERSION => { + let proto = aux.first().map(aux_as_u64).transpose()?.unwrap_or(0); + let min = aux.get(1).map(aux_as_u64).transpose()?.unwrap_or(0); + listener + .test_bundle_ready_with_protocol_version(proto, min) + .await?; + } + m if m == XCT_RUNNER_READY_WITH_CAPABILITIES => { + if let Some(raw) = aux.first() + && let Ok(decoded) = decode_aux_archive(raw) + && let Some(caps) = XCTCapabilities::from_plist(&decoded) + { + debug!("testRunnerReadyWithCapabilities: {:?}", caps.capabilities); + } + listener.test_runner_ready_with_capabilities().await?; + let reply = xctest_config.to_archive_bytes()?; + return Ok(Some(reply)); + } + + // --- test plan lifecycle --- + m if m == XCT_DID_BEGIN_TEST_PLAN => { + listener.did_begin_executing_test_plan().await?; + } + m if m == XCT_DID_FINISH_TEST_PLAN => { + *done_flag = true; + listener.did_finish_executing_test_plan().await?; + } + + // --- suite lifecycle (legacy string-based) --- + m if m == XCT_SUITE_DID_START => { + let suite = aux + .first() + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let started_at = aux + .get(1) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + listener.test_suite_did_start(&suite, &started_at).await?; + } + m if m == XCT_SUITE_DID_FINISH => { + let suite = aux + .first() + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let finished_at = aux + .get(1) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let run_count = aux.get(2).map(aux_as_u64).transpose()?.unwrap_or(0); + let failures = aux.get(3).map(aux_as_u64).transpose()?.unwrap_or(0); + let unexpected = aux.get(4).map(aux_as_u64).transpose()?.unwrap_or(0); + let test_dur = aux.get(5).map(aux_as_f64).transpose()?.unwrap_or(0.0); + let total_dur = aux.get(6).map(aux_as_f64).transpose()?.unwrap_or(0.0); + listener + .test_suite_did_finish( + &suite, + &finished_at, + run_count, + failures, + unexpected, + test_dur, + total_dur, + 0, + 0, + 0, + ) + .await?; + } + + // --- suite lifecycle (identifier-based, iOS 14+) --- + m if m == XCT_SUITE_DID_START_ID => { + if let Some(raw) = aux.first() + && let Ok(decoded) = decode_aux_archive(raw) + && let Some(id) = XCTTestIdentifier::from_plist(&decoded) + { + let tc = id.test_class(); + if !tc.is_empty() && tc != "All tests" { + let started_at = aux + .get(1) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + listener.test_suite_did_start(tc, &started_at).await?; + } + } + } + m if m == XCT_SUITE_DID_FINISH_ID => { + if let Some(raw) = aux.first() + && let Ok(decoded) = decode_aux_archive(raw) + && let Some(id) = XCTTestIdentifier::from_plist(&decoded) + { + let tc = id.test_class().to_owned(); + if !tc.is_empty() && tc != "All tests" { + let finished_at = aux + .get(1) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let run_count = aux.get(2).map(aux_as_u64).transpose()?.unwrap_or(0); + let skip_count = aux.get(3).map(aux_as_u64).transpose()?.unwrap_or(0); + let fail_count = aux.get(4).map(aux_as_u64).transpose()?.unwrap_or(0); + let expected_fail = aux.get(5).map(aux_as_u64).transpose()?.unwrap_or(0); + let uncaught = aux.get(6).map(aux_as_u64).transpose()?.unwrap_or(0); + let test_dur = aux.get(7).map(aux_as_f64).transpose()?.unwrap_or(0.0); + let total_dur = aux.get(8).map(aux_as_f64).transpose()?.unwrap_or(0.0); + listener + .test_suite_did_finish( + &tc, + &finished_at, + run_count, + fail_count, + uncaught, + test_dur, + total_dur, + skip_count, + expected_fail, + 0, + ) + .await?; + } + } + } + + // --- case lifecycle (legacy) --- + m if m == XCT_CASE_DID_START => { + let test_class = aux + .first() + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let method_name = aux + .get(1) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + listener + .test_case_did_start(&test_class, &method_name) + .await?; + } + m if m == XCT_CASE_DID_FINISH => { + let test_class = aux + .first() + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let method_name = aux + .get(1) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let status = aux + .get(2) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let duration = aux.get(3).map(aux_as_f64).transpose()?.unwrap_or(0.0); + listener + .test_case_did_finish(XCTestCaseResult { + test_class, + method: method_name, + status, + duration, + }) + .await?; + } + m if m == XCT_CASE_DID_FAIL => { + let test_class = aux + .first() + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let method_name = aux + .get(1) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let message = aux + .get(2) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let file = aux + .get(3) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let line = aux.get(4).map(aux_as_u64).transpose()?.unwrap_or(0); + listener + .test_case_did_fail(&test_class, &method_name, &message, &file, line) + .await?; + } + m if m == XCT_CASE_DID_STALL => { + let test_class = aux + .first() + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let method_name = aux + .get(1) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let file = aux + .get(2) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let line = aux.get(3).map(aux_as_u64).transpose()?.unwrap_or(0); + listener + .test_case_did_stall(&test_class, &method_name, &file, line) + .await?; + } + + // --- case lifecycle (identifier-based, iOS 14+) --- + m if m == XCT_CASE_DID_START_ID => { + if let Some(raw) = aux.first() + && let Ok(decoded) = decode_aux_archive(raw) + && let Some(id) = XCTTestIdentifier::from_plist(&decoded) + { + let method_name = id.test_method().unwrap_or("").to_owned(); + listener + .test_case_did_start(id.test_class(), &method_name) + .await?; + } + } + m if m == XCT_CASE_DID_FINISH_ID => { + if let Some(raw) = aux.first() + && let Ok(decoded) = decode_aux_archive(raw) + && let Some(id) = XCTTestIdentifier::from_plist(&decoded) + { + let test_class = id.test_class().to_owned(); + let method_name = id.test_method().unwrap_or("").to_owned(); + let status = aux + .get(1) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let duration = aux.get(2).map(aux_as_f64).transpose()?.unwrap_or(0.0); + listener + .test_case_did_finish(XCTestCaseResult { + test_class, + method: method_name, + status, + duration, + }) + .await?; + } + } + m if m == XCT_CASE_DID_RECORD_ISSUE => { + if let (Some(id_raw), Some(issue_raw)) = (aux.first(), aux.get(1)) + && let (Ok(id_val), Ok(issue_val)) = + (decode_aux_archive(id_raw), decode_aux_archive(issue_raw)) + && let (Some(id), Some(issue)) = ( + XCTTestIdentifier::from_plist(&id_val), + XCTIssue::from_plist(&issue_val), + ) + { + let test_class = id.test_class().to_owned(); + let method_name = id.test_method().unwrap_or("").to_owned(); + let file = issue + .source_code_context + .as_ref() + .and_then(|c| c.location.as_ref()) + .and_then(|l| l.file_path()) + .unwrap_or("") + .to_owned(); + let line = issue + .source_code_context + .as_ref() + .and_then(|c| c.location.as_ref()) + .map(|l| l.line_number) + .unwrap_or(0); + listener + .test_case_did_fail( + &test_class, + &method_name, + &issue.compact_description, + &file, + line, + ) + .await?; + } + } + + // --- activities (legacy) --- + m if m == XCT_CASE_WILL_START_ACTIVITY => { + let test_class = aux + .first() + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let method_name = aux + .get(1) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + // aux[2] is an XCActivityRecord NSKeyedArchive blob, not a plain string + let title = aux + .get(2) + .and_then(|a| decode_aux_archive(a).ok()) + .and_then(|v| XCActivityRecord::from_plist(&v)) + .map(|r| r.title) + .unwrap_or_default(); + listener + .test_case_will_start_activity(&test_class, &method_name, &title) + .await?; + } + m if m == XCT_CASE_DID_FINISH_ACTIVITY => { + let test_class = aux + .first() + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let method_name = aux + .get(1) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + // aux[2] is an XCActivityRecord NSKeyedArchive blob, not a plain string + let title = aux + .get(2) + .and_then(|a| decode_aux_archive(a).ok()) + .and_then(|v| XCActivityRecord::from_plist(&v)) + .map(|r| r.title) + .unwrap_or_default(); + listener + .test_case_did_finish_activity(&test_class, &method_name, &title) + .await?; + } + + // --- activities (identifier-based) --- + m if m == XCT_CASE_WILL_START_ACTIVITY_ID => { + if let Some(id_raw) = aux.first() + && let Ok(id_val) = decode_aux_archive(id_raw) + && let Some(id) = XCTTestIdentifier::from_plist(&id_val) + { + let method_name = id.test_method().unwrap_or("").to_owned(); + // aux[1] is an XCActivityRecord NSKeyedArchive blob + let title = aux + .get(1) + .and_then(|a| decode_aux_archive(a).ok()) + .and_then(|v| XCActivityRecord::from_plist(&v)) + .map(|r| r.title) + .unwrap_or_default(); + listener + .test_case_will_start_activity(id.test_class(), &method_name, &title) + .await?; + } + } + m if m == XCT_CASE_DID_FINISH_ACTIVITY_ID => { + if let Some(id_raw) = aux.first() + && let Ok(id_val) = decode_aux_archive(id_raw) + && let Some(id) = XCTTestIdentifier::from_plist(&id_val) + { + let method_name = id.test_method().unwrap_or("").to_owned(); + // aux[1] is an XCActivityRecord NSKeyedArchive blob + let title = aux + .get(1) + .and_then(|a| decode_aux_archive(a).ok()) + .and_then(|v| XCActivityRecord::from_plist(&v)) + .map(|r| r.title) + .unwrap_or_default(); + listener + .test_case_did_finish_activity(id.test_class(), &method_name, &title) + .await?; + } + } + + // --- metrics --- + // Python selector: _XCT_testMethod:ofClass:didMeasureMetric:file:line: + // → aux[0]=method, aux[1]=test_class, aux[2]=metric, aux[3]=file, aux[4]=line + m if m == XCT_METHOD_DID_MEASURE_METRIC => { + let method_name = aux + .first() + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let test_class = aux + .get(1) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let metric = aux + .get(2) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let file = aux + .get(3) + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + let line = aux.get(4).map(aux_as_u64).transpose()?.unwrap_or(0); + listener + .test_method_did_measure_metric(&test_class, &method_name, &metric, &file, line) + .await?; + } + + // --- iOS 14+ UI testing --- + m if m == XCT_DID_BEGIN_UI_INIT => { + listener.did_begin_initializing_for_ui_testing().await?; + } + m if m == XCT_DID_FORM_PLAN => { + let data = aux + .first() + .and_then(|value| aux_as_string(value).ok()) + .unwrap_or_default(); + listener.did_form_plan(&data).await?; + } + m if m == XCT_GET_PROGRESS_FOR_LAUNCH => { + let token = aux + .first() + .and_then(|value| aux_as_string(value).ok()) + .unwrap_or_default(); + listener.get_progress_for_launch(&token).await?; + } + m if m == XCT_UI_INIT_DID_FAIL => { + let desc = aux + .first() + .map(aux_as_string) + .transpose()? + .unwrap_or_default(); + listener + .initialization_for_ui_testing_did_fail(&desc) + .await?; + } + m if m == XCT_DID_FAIL_BOOTSTRAP => { + // The aux is an NSKeyedArchived NSError. Try plain string first, + // then decode the archive and pull NSLocalizedDescription out of + // the error dictionary, falling back to a generic message. + let desc = aux + .first() + .and_then(|v| { + // plain string (unlikely but handle it) + if let AuxValue::String(s) = v { + return Some(s.clone()); + } + // NSKeyedArchive -> plist Value + let decoded = decode_aux_archive(v).ok()?; + // NSError serialises as a Dictionary. String fields come + // through as plain values; other fields (domain, userInfo) + // are Uid references into the archive's $objects table + // which the decoder doesn't follow. + if let Value::Dictionary(d) = &decoded { + // Try inline string fields first + if let Some(s) = d + .get("NSLocalizedDescription") + .or_else(|| d.get("NSLocalizedFailureReason")) + .and_then(|v| v.as_string()) + { + return Some(s.to_owned()); + } + // Fall back to the numeric code with a hint for + // the most common values seen from testmanagerd + if let Some(code) = d.get("NSCode").and_then(|v| v.as_signed_integer()) { + let hint = match code { + 103 => " (untrusted developer certificate — go to Settings → General → VPN & Device Management and trust your developer app)", + _ => "", + }; + return Some(format!("NSError code {code}{hint}")); + } + } + None + }) + .unwrap_or_else(|| "unknown error".to_owned()); + listener.did_fail_to_bootstrap(&desc).await?; + } + + other => { + warn!("Unknown _XCT_ method: {}", other); + } + } + + Ok(None) +} + +struct EarlyXCTestBootstrapListener; + +impl XCUITestListener for EarlyXCTestBootstrapListener {} + +fn should_handle_in_bootstrap(method: &str) -> bool { + matches!( + method, + XCT_EXCHANGE_PROTOCOL_VERSION + | XCT_RUNNER_READY_WITH_CAPABILITIES + | XCT_BUNDLE_READY + | XCT_BUNDLE_READY_WITH_PROTOCOL_VERSION + | XCT_LOG_MESSAGE + | XCT_LOG_DEBUG_MESSAGE + ) +} + +async fn install_early_xctest_handler( + main_channel: &mut OwnedChannel, + xctest_config: XCTestConfiguration, +) { + main_channel + .set_incoming_handler(move |msg: Message| { + let xctest_config = xctest_config.clone(); + Box::pin(async move { + let method = match msg.data.as_ref() { + Some(Value::String(method)) => method.as_str(), + _ => return Ok(IncomingHandlerOutcome::Unhandled), + }; + + if !should_handle_in_bootstrap(method) { + return Ok(IncomingHandlerOutcome::Unhandled); + } + + let aux = msg.aux.as_ref().map(|a| a.values.as_slice()).unwrap_or(&[]); + + let mut listener = EarlyXCTestBootstrapListener; + let mut done = false; + let reply = + dispatch_xct_message(method, aux, &xctest_config, &mut listener, &mut done) + .await?; + + Ok(match reply { + Some(reply_bytes) => IncomingHandlerOutcome::Reply(reply_bytes), + None => IncomingHandlerOutcome::HandledNoReply, + }) + }) + }) + .await; +} + +/// Main event loop: reads incoming `_XCT_*` messages and dispatches them until +/// `_XCT_didFinishExecutingTestPlan` or `timeout` elapses. +pub(super) async fn run_dispatch_loop( + driver_channel: &mut OwnedChannel>, + xctest_config: &XCTestConfiguration, + listener: &mut L, + timeout: Option, +) -> Result<(), IdeviceError> { + let deadline = timeout.map(|t| crate::time::Instant::now() + t); + let mut done = false; + + loop { + let remaining = if let Some(dl) = deadline { + let r = dl + .checked_duration_since(crate::time::Instant::now()) + .ok_or_else(|| IdeviceError::XcTestTimeout(timeout.unwrap().as_secs_f64()))?; + Some(r) + } else { + None + }; + + let msg = match remaining { + Some(r) => driver_channel.read_message_timeout(r).await?, + None => driver_channel.read_message().await?, + }; + + let method = match &msg.data { + Some(Value::String(s)) => s.clone(), + None => continue, // heartbeat / empty + _ => { + warn!("Non-string message data on XCTest channel"); + continue; + } + }; + + let aux = msg.aux.as_ref().map(|a| a.values.as_slice()).unwrap_or(&[]); + + let msg_id = msg.message_header.identifier(); + let conversation_index = msg.message_header.conversation_index(); + let reply_opt = + dispatch_xct_message(&method, aux, xctest_config, listener, &mut done).await?; + + if msg.message_header.expects_reply() { + match reply_opt { + Some(reply_bytes) => { + driver_channel + .send_raw_reply_for(msg_id, conversation_index, &reply_bytes) + .await?; + } + None => { + driver_channel + .send_raw_reply_for(msg_id, conversation_index, &[]) + .await?; + } + } + } + + if done { + return Ok(()); + } + } +} + +/// Mirrors pymobiledevice3's "test done vs disconnect" race. +/// +/// Once the test plan has started, the runner may terminate its own DTX +/// connection before `_XCT_didFinishExecutingTestPlan` is delivered. In that +/// case we surface `TestRunnerDisconnected` rather than hanging until timeout. +async fn run_dispatch_loop_until_done_or_disconnect( + main_client: &mut RemoteServerClient>, + mut driver_channel: OwnedChannel>, + xctest_config: &XCTestConfiguration, + listener: &mut L, + timeout: Option, +) -> Result<(), IdeviceError> { + let disconnected = main_client.disconnect_waiter(); + tokio::pin!(disconnected); + + tokio::select! { + result = run_dispatch_loop(&mut driver_channel, xctest_config, listener, timeout) => result, + _ = &mut disconnected => Err(IdeviceError::TestRunnerDisconnected), + } +} + +// --------------------------------------------------------------------------- +// XCUITestService +// --------------------------------------------------------------------------- + +/// High-level service that orchestrates an XCTest (or WDA) run end-to-end. +/// +/// # Example +/// ```rust,no_run +/// # #[cfg(feature = "xctest")] +/// # async fn example() -> Result<(), idevice::IdeviceError> { +/// // let svc = XCUITestService::new(provider); +/// // svc.run(cfg, &mut listener, None).await?; +/// # Ok(()) +/// # } +/// ``` +pub struct XCUITestService { + provider: Arc, +} + +#[cfg(feature = "wda")] +#[derive(Debug)] +pub struct WdaRunHandle { + task: JoinHandle>, + ports: WdaPorts, + status: JsonValue, +} + +#[cfg(feature = "wda")] +#[derive(Debug)] +pub struct WdaBridgedRunHandle { + runner: WdaRunHandle, + bridge: WdaBridge, +} + +#[cfg(feature = "wda")] +impl WdaRunHandle { + /// Returns the device-side ports used by the running WDA instance. + pub fn ports(&self) -> WdaPorts { + self.ports + } + + /// Returns the `/status` payload observed when WDA became reachable. + pub fn status(&self) -> &JsonValue { + &self.status + } + + /// Waits for the underlying xctrunner task to complete. + pub async fn wait(self) -> Result<(), IdeviceError> { + match self.task.await { + Ok(result) => result, + Err(error) => Err(IdeviceError::UnknownErrorType(format!( + "wda runner task join failed: {error}" + ))), + } + } + + /// Aborts the underlying xctrunner task. + pub fn abort(&self) { + self.task.abort(); + } +} + +#[cfg(feature = "wda")] +impl WdaBridgedRunHandle { + /// Returns the localhost bridge for this WDA runner. + pub fn bridge(&self) -> &WdaBridge { + &self.bridge + } + + /// Returns the device-side ports used by the running WDA instance. + pub fn ports(&self) -> WdaPorts { + self.runner.ports() + } + + /// Returns the `/status` payload observed when WDA became reachable. + pub fn status(&self) -> &JsonValue { + self.runner.status() + } + + /// Returns the localhost WDA HTTP URL. + pub fn wda_url(&self) -> &str { + self.bridge.wda_url() + } + + /// Returns the localhost MJPEG URL. + pub fn mjpeg_url(&self) -> &str { + self.bridge.mjpeg_url() + } + + /// Waits for the underlying xctrunner task to complete. + pub async fn wait(self) -> Result<(), IdeviceError> { + self.runner.wait().await + } + + /// Aborts the underlying xctrunner task. + pub fn abort(&self) { + self.runner.abort(); + } +} + +#[cfg(feature = "wda")] +struct NoopXCTestListener; + +#[cfg(feature = "wda")] +impl XCUITestListener for NoopXCTestListener {} + +impl std::fmt::Debug for XCUITestService { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("XCUITestService") + .field("provider", &"") + .finish() + } +} + +impl XCUITestService { + /// Creates a new `XCUITestService` backed by the given provider. + pub fn new(provider: Arc) -> Self { + Self { provider } + } + + /// Runs the XCTest bundle described by `cfg` to completion. + /// + /// # Arguments + /// * `cfg` - Test configuration (runner/target app info, filters, …) + /// * `listener` - Receives lifecycle events as the test runs + /// * `timeout` - Optional wall-clock timeout from when the test plan starts + /// + /// # Errors + /// * `IdeviceError::XcTestTimeout` if `timeout` elapses + /// * `IdeviceError::TestRunnerTimeout` if the runner does not open + /// `XCTestDriverInterface` within 30 seconds + pub async fn run( + &self, + cfg: TestConfig, + listener: &mut L, + timeout: Option, + ) -> Result<(), IdeviceError> { + // 1. Session UUID + config path + let session_id = uuid::Uuid::new_v4(); + let xctest_path = format!( + "/tmp/{}.xctestconfiguration", + session_id.to_string().to_uppercase() + ); + + // 2. iOS major version (needed for service selection and launch env) + let ios_major_version: u8 = { + let mut lockdown = LockdownClient::connect(&*self.provider).await?; + lockdown + .start_session(&self.provider.get_pairing_file().await?) + .await?; + let ver = lockdown.get_value(Some("ProductVersion"), None).await?; + ver.as_string() + .and_then(|s| s.split('.').next()) + .and_then(|s| s.parse().ok()) + .unwrap_or(16u8) + }; + + // 3. Build XCTestConfiguration + let xctest_config = cfg.build_xctest_configuration(session_id, ios_major_version)?; + + // 4. Connect to testmanagerd (ctrl + main) and DVT + let mut conns = connect_testmanagerd(&*self.provider, ios_major_version).await?; + let mut ctrl_proxy = TestManagerProxy::open(&mut conns.ctrl, ios_major_version).await?; + let mut main_proxy = TestManagerProxy::open(&mut conns.main, ios_major_version).await?; + let mut process_control = XCTestProcessControlChannel::open(&mut conns.dvt).await?; + + let config_name = cfg.config_name().to_owned(); + initialize_testmanager_sessions(&mut ctrl_proxy, &mut main_proxy, &xctest_config).await?; + register_early_driver_channel_handler(&mut conns.main, &xctest_config).await; + initialize_testmanager_daemon_sessions( + &mut ctrl_proxy, + &mut main_proxy, + ios_major_version, + &session_id, + &xctest_config, + ) + .await?; + + // Build launch environment from the config + let (launch_args, launch_env, launch_options) = build_launch_env( + ios_major_version, + &session_id, + &cfg.runner_app_path, + &cfg.runner_app_container, + &config_name, + &xctest_path, + cfg.runner_env.as_ref(), + cfg.runner_args.as_deref(), + ); + + let _pid = launch_and_authorize_test_runner( + &mut ctrl_proxy, + &mut process_control, + ios_major_version, + &cfg.runner_bundle_id, + launch_args, + launch_env, + launch_options, + ) + .await?; + + // 6-7. Wait for driver channel and start the test plan. + let driver_channel = start_test_plan_session(&mut conns.main, &mut main_proxy).await?; + + // 8. Dispatch loop, raced against the runner connection dropping. + run_dispatch_loop_until_done_or_disconnect( + &mut conns.main, + driver_channel, + &xctest_config, + listener, + timeout, + ) + .await?; + + Ok(()) + } + + /// Starts an XCTest runner intended to host WebDriverAgent and waits + /// until WDA responds on its device-side HTTP port. + /// + /// The xctrunner orchestration continues on a background task. This is + /// designed for automation use cases where callers want a durable WDA + /// session instead of waiting for the XCTest plan to terminate. + /// + /// Readiness detection currently uses a simple polling loop against + /// `WdaClient::status()`. This is intentionally conservative bootstrap + /// behavior for now; large-scale orchestration should still stagger or + /// back off parallel startup attempts at a higher layer. + #[cfg(feature = "wda")] + pub async fn run_until_wda_ready( + &self, + cfg: TestConfig, + readiness_timeout: std::time::Duration, + ) -> Result { + let provider = self.provider.clone(); + let runner_cfg = cfg.clone(); + let task = tokio::spawn(async move { + let service = XCUITestService::new(provider); + let mut listener = NoopXCTestListener; + service.run(runner_cfg, &mut listener, None).await + }); + + let wda = WdaClient::new(&*self.provider); + let deadline = std::time::Instant::now() + readiness_timeout; + let poll_interval = std::time::Duration::from_millis(250); + + let status = loop { + if task.is_finished() { + let result = match task.await { + Ok(result) => result, + Err(error) => { + return Err(IdeviceError::UnknownErrorType(format!( + "wda runner task join failed: {error}" + ))); + } + }; + result?; + return Err(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )); + } + + match wda.status().await { + Ok(status) => break status, + Err(_) if std::time::Instant::now() < deadline => { + tokio::time::sleep(poll_interval).await; + } + Err(error) => { + task.abort(); + return Err(error); + } + } + }; + + Ok(WdaRunHandle { + task, + ports: wda.ports(), + status, + }) + } + + /// Starts an XCTest-hosted WDA runner, waits until WDA is reachable, and + /// exposes localhost URLs suitable for GUI/web consumers. + #[cfg(feature = "wda")] + pub async fn run_until_wda_ready_with_bridge( + &self, + cfg: TestConfig, + readiness_timeout: std::time::Duration, + ) -> Result { + let runner = self.run_until_wda_ready(cfg, readiness_timeout).await?; + let bridge = WdaBridge::start_with_ports(self.provider.clone(), runner.ports()).await?; + Ok(WdaBridgedRunHandle { runner, bridge }) + } +} diff --git a/src-tauri/vendor/idevice/src/services/dvt/xctest/types.rs b/src-tauri/vendor/idevice/src/services/dvt/xctest/types.rs new file mode 100644 index 0000000..2dd2a2c --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/dvt/xctest/types.rs @@ -0,0 +1,811 @@ +//! NSKeyedArchive type proxies for the XCTest protocol. +//! +//! These types are exchanged as NSKeyedArchive-encoded plists between the IDE +//! and the on-device testmanagerd / test runner. They are distinct from the +//! DTX protocol itself and live here because they are XCTest-specific payloads. +//! +//! Types that are only ever *received* from the runner implement only decode +//! logic. [`XCTestConfiguration`] and [`XCTCapabilities`] must also be +//! encoded because the IDE sends them to the runner. +// Jackson Coxson + +use plist::{Dictionary, Uid, Value}; +use uuid::Uuid; + +use crate::IdeviceError; + +// --------------------------------------------------------------------------- +// Internal NSKeyedArchive encoder +// --------------------------------------------------------------------------- + +/// Builds an NSKeyedArchive `$objects` array incrementally. +/// +/// Each `encode_*` method appends one or more objects and returns the `Uid` +/// (index into `$objects`) of the newly added top-level entry. Call +/// [`ArchiveBuilder::finish`] to wrap the objects array into the complete +/// NSKeyedArchive plist dict. +struct ArchiveBuilder { + objects: Vec, +} + +impl ArchiveBuilder { + fn new() -> Self { + // $objects[0] is always the special "$null" sentinel + Self { + objects: vec![Value::String("$null".into())], + } + } + + /// Returns the UID that represents a null / missing value. + fn null_uid() -> Uid { + Uid::new(0) + } + + /// Appends `v` to `$objects` and returns its index as a `Uid`. + fn push(&mut self, v: Value) -> Uid { + self.objects.push(v); + Uid::new(self.objects.len() as u64 - 1) + } + + /// Returns the UID of the class-info dict for `class_name`, creating it if + /// it does not already exist in `$objects`. + fn get_or_create_class(&mut self, class_name: &str, superclasses: &[&str]) -> Uid { + // Reuse an existing class dict if present + for (i, obj) in self.objects.iter().enumerate() { + if let Some(d) = obj.as_dictionary() + && d.get("$classname").and_then(|v| v.as_string()) == Some(class_name) + { + return Uid::new(i as u64); + } + } + let mut classes: Vec = std::iter::once(class_name) + .chain(superclasses.iter().copied()) + .map(|s| Value::String(s.into())) + .collect(); + // Ensure NSObject is always the last entry + if classes.last().and_then(|v| v.as_string()) != Some("NSObject") { + classes.push(Value::String("NSObject".into())); + } + let mut d = Dictionary::new(); + d.insert("$classname".into(), Value::String(class_name.into())); + d.insert("$classes".into(), Value::Array(classes)); + self.push(Value::Dictionary(d)) + } + + /// Encodes a `&str` as a plain NSString entry. + fn encode_str(&mut self, s: &str) -> Uid { + self.push(Value::String(s.into())) + } + + /// Encodes an `Option<&str>`: `None` maps to `UID(0)` (null). + fn encode_opt_str(&mut self, s: Option<&str>) -> Uid { + match s { + Some(s) => self.encode_str(s), + None => Self::null_uid(), + } + } + + /// Encodes a boolean inline (not a UID reference). + /// + /// In NSKeyedArchive, scalar booleans are stored directly in the object + /// dict, not as a UID reference into `$objects`. + fn bool_value(b: bool) -> Value { + Value::Boolean(b) + } + + /// Encodes an integer inline. + fn int_value(i: u64) -> Value { + Value::Integer(i.into()) + } + + /// Encodes a `plist::Dictionary` as an `NSDictionary` object. + fn encode_nsdict(&mut self, dict: &Dictionary) -> Uid { + // Collect pairs first to avoid simultaneous borrow of self + let pairs: Vec<(String, Value)> = + dict.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); + + let mut key_uids = Vec::with_capacity(pairs.len()); + let mut val_uids = Vec::with_capacity(pairs.len()); + for (k, v) in pairs { + let k_uid = self.encode_str(&k); + let v_uid = self.encode_value(v); + key_uids.push(Value::Uid(k_uid)); + val_uids.push(Value::Uid(v_uid)); + } + + let class_uid = self.get_or_create_class("NSDictionary", &[]); + let mut d = Dictionary::new(); + d.insert("$class".into(), Value::Uid(class_uid)); + d.insert("NS.keys".into(), Value::Array(key_uids)); + d.insert("NS.objects".into(), Value::Array(val_uids)); + self.push(Value::Dictionary(d)) + } + + /// Encodes a slice of `Value` as an `NSArray` object. + fn encode_nsarray(&mut self, items: &[Value]) -> Uid { + let items = items.to_vec(); + let mut obj_uids = Vec::with_capacity(items.len()); + for v in items { + let uid = self.encode_value(v); + obj_uids.push(Value::Uid(uid)); + } + let class_uid = self.get_or_create_class("NSArray", &[]); + let mut d = Dictionary::new(); + d.insert("$class".into(), Value::Uid(class_uid)); + d.insert("NS.objects".into(), Value::Array(obj_uids)); + self.push(Value::Dictionary(d)) + } + + /// Encodes an `NSURL` with a single relative string component. + fn encode_nsurl(&mut self, url: &str) -> Uid { + let str_uid = self.encode_str(url); + let class_uid = self.get_or_create_class("NSURL", &[]); + let mut d = Dictionary::new(); + d.insert("$class".into(), Value::Uid(class_uid)); + d.insert("NS.relative".into(), Value::Uid(str_uid)); + d.insert("NS.base".into(), Value::Uid(Self::null_uid())); + self.push(Value::Dictionary(d)) + } + + /// Encodes an `NSUUID` from its raw 16-byte representation. + fn encode_nsuuid(&mut self, uuid: &Uuid) -> Uid { + let bytes = uuid.as_bytes().to_vec(); + let class_uid = self.get_or_create_class("NSUUID", &[]); + let mut d = Dictionary::new(); + d.insert("$class".into(), Value::Uid(class_uid)); + d.insert("NS.uuidbytes".into(), Value::Data(bytes)); + self.push(Value::Dictionary(d)) + } + + /// Dispatches a generic `plist::Value` to the appropriate encoder. + fn encode_value(&mut self, v: Value) -> Uid { + match v { + Value::Boolean(b) => self.push(Value::Boolean(b)), + Value::Integer(i) => self.push(Value::Integer(i)), + Value::Real(f) => self.push(Value::Real(f)), + Value::String(s) => self.encode_str(&s), + Value::Data(d) => self.push(Value::Data(d)), + Value::Array(arr) => { + let items = arr.clone(); + self.encode_nsarray(&items) + } + Value::Dictionary(d) => { + let dict = d.clone(); + self.encode_nsdict(&dict) + } + // Unknown types map to $null + _ => Self::null_uid(), + } + } + + /// Wraps `$objects` into a complete NSKeyedArchive plist dict. + fn finish(self, root_uid: Uid) -> Value { + let mut top = Dictionary::new(); + top.insert("root".into(), Value::Uid(root_uid)); + + let mut root = Dictionary::new(); + root.insert("$archiver".into(), Value::String("NSKeyedArchiver".into())); + root.insert("$version".into(), Value::Integer(100000u64.into())); + root.insert("$top".into(), Value::Dictionary(top)); + root.insert("$objects".into(), Value::Array(self.objects)); + Value::Dictionary(root) + } +} + +/// Serialises an NSKeyedArchive `Value` to binary plist bytes. +fn archive_to_bytes(archive: Value) -> Result, IdeviceError> { + let buf = Vec::new(); + let mut writer = std::io::BufWriter::new(buf); + plist::to_writer_binary(&mut writer, &archive).map_err(|e| { + tracing::warn!("Failed to serialise NSKeyedArchive: {e}"); + IdeviceError::UnexpectedResponse("failed to serialize NSKeyedArchive".into()) + })?; + Ok(writer.into_inner().unwrap()) +} + +/// Serialises an `NSUUID` object to NSKeyedArchive bytes. +pub(crate) fn archive_nsuuid_to_bytes(uuid: &Uuid) -> Result, IdeviceError> { + let mut class = Dictionary::new(); + class.insert( + "$classes".into(), + Value::Array(vec![Value::String("NSUUID".into())]), + ); + class.insert("$classname".into(), Value::String("NSUUID".into())); + + let mut obj = Dictionary::new(); + obj.insert("$class".into(), Value::Uid(Uid::new(2))); + obj.insert("NS.uuidbytes".into(), Value::Data(uuid.as_bytes().to_vec())); + + let mut top = Dictionary::new(); + top.insert("root".into(), Value::Uid(Uid::new(1))); + + let mut archive = Dictionary::new(); + archive.insert("$archiver".into(), Value::String("NSKeyedArchiver".into())); + archive.insert( + "$objects".into(), + Value::Array(vec![ + Value::String("$null".into()), + Value::Dictionary(obj), + Value::Dictionary(class), + ]), + ); + archive.insert("$top".into(), Value::Dictionary(top)); + archive.insert("$version".into(), Value::Integer(100000u64.into())); + + archive_to_bytes(Value::Dictionary(archive)) +} + +/// Serialises an `XCTCapabilities` object to NSKeyedArchive bytes matching +/// pymobiledevice3's simple `encode_archive` layout. +pub(crate) fn archive_xct_capabilities_to_bytes( + capabilities: &XCTCapabilities, +) -> Result, IdeviceError> { + let mut objects = vec![Value::String("$null".into())]; + + let root_uid = Uid::new(1); + let xct_caps_class_uid = Uid::new(2); + let dict_uid = Uid::new(3); + let nsdict_class_uid = Uid::new(4); + + let mut root = Dictionary::new(); + root.insert("$class".into(), Value::Uid(xct_caps_class_uid)); + root.insert("capabilities-dictionary".into(), Value::Uid(dict_uid)); + objects.push(Value::Dictionary(root)); + + let mut xct_caps_class = Dictionary::new(); + xct_caps_class.insert( + "$classes".into(), + Value::Array(vec![Value::String("XCTCapabilities".into())]), + ); + xct_caps_class.insert("$classname".into(), Value::String("XCTCapabilities".into())); + objects.push(Value::Dictionary(xct_caps_class)); + + let key_base = 5u64; + let mut key_uids = Vec::with_capacity(capabilities.capabilities.len()); + let mut value_uids = Vec::with_capacity(capabilities.capabilities.len()); + + for (idx, (key, value)) in capabilities.capabilities.iter().enumerate() { + let key_uid = Uid::new(key_base + (idx as u64 * 2)); + let value_uid = Uid::new(key_base + (idx as u64 * 2) + 1); + key_uids.push(Value::Uid(key_uid)); + value_uids.push(Value::Uid(value_uid)); + objects.push(Value::String(key.clone())); + objects.push(value.clone()); + } + + let mut dict = Dictionary::new(); + dict.insert("$class".into(), Value::Uid(nsdict_class_uid)); + dict.insert("NS.keys".into(), Value::Array(key_uids)); + dict.insert("NS.objects".into(), Value::Array(value_uids)); + objects.insert(dict_uid.get() as usize, Value::Dictionary(dict)); + + let mut nsdict_class = Dictionary::new(); + nsdict_class.insert( + "$classes".into(), + Value::Array(vec![Value::String("NSDictionary".into())]), + ); + nsdict_class.insert("$classname".into(), Value::String("NSDictionary".into())); + objects.insert( + nsdict_class_uid.get() as usize, + Value::Dictionary(nsdict_class), + ); + + let mut top = Dictionary::new(); + top.insert("root".into(), Value::Uid(root_uid)); + + let mut archive = Dictionary::new(); + archive.insert("$archiver".into(), Value::String("NSKeyedArchiver".into())); + archive.insert("$objects".into(), Value::Array(objects)); + archive.insert("$top".into(), Value::Dictionary(top)); + archive.insert("$version".into(), Value::Integer(100000u64.into())); + + archive_to_bytes(Value::Dictionary(archive)) +} + +// --------------------------------------------------------------------------- +// XCTCapabilities +// --------------------------------------------------------------------------- + +/// Proxy for `XCTCapabilities` — a dictionary wrapper negotiated between the +/// IDE and testmanagerd during session initialisation. +/// +/// The default instance carries the set of capabilities that a modern Xcode +/// IDE advertises. +#[derive(Debug, Clone)] +pub struct XCTCapabilities { + /// The inner `capabilities-dictionary` exchanged with the daemon. + pub capabilities: Dictionary, +} + +impl XCTCapabilities { + /// Creates an empty `XCTCapabilities`. + pub fn empty() -> Self { + Self { + capabilities: Dictionary::new(), + } + } + + /// Returns the default IDE capabilities advertised to testmanagerd. + /// + /// These match the values sent by a recent Xcode release and must be + /// present for the modern DDI protocol variant to work correctly. + pub fn ide_defaults() -> Self { + let caps = crate::plist!(dict { + "expected failure test capability": true, + "test case run configurations": true, + "test timeout capability": true, + "test iterations": true, + "request diagnostics for specific devices": true, + "delayed attachment transfer": true, + "skipped test capability": true, + "daemon container sandbox extension": true, + "ubiquitous test identifiers": true, + "XCTIssue capability": true, + }); + Self { capabilities: caps } + } + + /// Decodes an `XCTCapabilities` from the `plist::Value` received in a DTX + /// message payload (already decoded from NSKeyedArchive by the message + /// layer). + /// + /// # Errors + /// Returns `None` if the value does not contain a + /// `"capabilities-dictionary"` key. + pub fn from_plist(v: &Value) -> Option { + let dict = v.as_dictionary()?; + let caps = dict + .get("capabilities-dictionary") + .and_then(|v| v.as_dictionary()) + .cloned() + .unwrap_or_default(); + Some(Self { capabilities: caps }) + } + + /// Converts to a `plist::Value` dict with the `capabilities-dictionary` wrapper. + /// + /// This is the form expected by `_IDE_initiateControlSessionWithCapabilities:` and + /// `_IDE_initiateSessionWithIdentifier:capabilities:`. + pub fn to_plist_value(&self) -> Value { + let mut d = Dictionary::new(); + d.insert( + "capabilities-dictionary".into(), + Value::Dictionary(self.capabilities.clone()), + ); + Value::Dictionary(d) + } + + /// Encodes this `XCTCapabilities` into the provided [`ArchiveBuilder`] and + /// returns the `Uid` of the resulting object entry. + fn encode_with_builder(&self, builder: &mut ArchiveBuilder) -> Uid { + let dict_uid = builder.encode_nsdict(&self.capabilities); + let class_uid = builder.get_or_create_class("XCTCapabilities", &[]); + let mut obj = Dictionary::new(); + obj.insert("$class".into(), Value::Uid(class_uid)); + obj.insert("capabilities-dictionary".into(), Value::Uid(dict_uid)); + builder.push(Value::Dictionary(obj)) + } +} + +// --------------------------------------------------------------------------- +// XCTestConfiguration +// --------------------------------------------------------------------------- + +/// Launch configuration for an XCTest runner bundle. +/// +/// Built from [`TestConfig`](super::TestConfig) and serialised as an +/// NSKeyedArchive plist that is written to the device before the runner +/// process is launched. +/// +/// All fields mirror the Objective-C `XCTestConfiguration` class. +#[derive(Debug, Clone)] +pub struct XCTestConfiguration { + // --- required fields (no defaults) ------------------------------------ + /// `file://` URL pointing to the `.xctest` bundle inside the app container. + pub test_bundle_url: String, + /// UUID that uniquely identifies this test session. + pub session_identifier: Uuid, + + // --- fields with per-run overrides ------------------------------------ + /// Module name used when `productModuleName` differs from the default. + pub product_module_name: String, + /// Path to the `XCTAutomationSupport.framework`. + pub automation_framework_path: String, + + /// Bundle ID of the target application under test (optional). + pub target_application_bundle_id: Option, + /// On-device path of the target application bundle (optional). + pub target_application_path: Option, + /// Environment variables forwarded to the target app (optional). + pub target_application_environment: Option, + /// Launch arguments forwarded to the target app. + pub target_application_arguments: Vec, + + /// Test identifiers to run; `None` means run all. + pub tests_to_run: Option>, + /// Test identifiers to skip; `None` means skip none. + pub tests_to_skip: Option>, + + // --- fixed defaults --------------------------------------------------- + /// IDE capabilities sent along with the configuration. + pub ide_capabilities: XCTCapabilities, +} + +impl XCTestConfiguration { + /// Serialises this configuration as binary NSKeyedArchive bytes. + /// + /// The resulting bytes are written to `/tmp/.xctestconfiguration` + /// on the device via AFC before the runner process is launched. + /// + /// # Errors + /// Returns [`IdeviceError::UnexpectedResponse`] if plist serialisation + /// fails (should not happen under normal circumstances). + pub fn to_archive_bytes(&self) -> Result, IdeviceError> { + let mut b = ArchiveBuilder::new(); + + // --- nested objects ----------------------------------------------- + + let caps_uid = self.ide_capabilities.encode_with_builder(&mut b); + let bundle_url_uid = b.encode_nsurl(&self.test_bundle_url); + let session_uid = b.encode_nsuuid(&self.session_identifier); + let automation_path_uid = b.encode_str(&self.automation_framework_path); + let product_module_uid = b.encode_str(&self.product_module_name); + + // aggregateStatisticsBeforeCrash: {"XCSuiteRecordsKey": {}} + let mut agg_stats = Dictionary::new(); + agg_stats.insert( + "XCSuiteRecordsKey".into(), + Value::Dictionary(Dictionary::new()), + ); + let agg_stats_uid = b.encode_nsdict(&agg_stats); + + // targetApplicationPath — default placeholder keeps field non-empty + let target_app_path_uid = b.encode_opt_str( + self.target_application_path + .as_deref() + .or(Some("/whatever-it-does-not-matter/but-should-not-be-empty")), + ); + + let target_bundle_uid = b.encode_opt_str(self.target_application_bundle_id.as_deref()); + + let target_env_uid = match &self.target_application_environment { + Some(env) => b.encode_nsdict(env), + None => ArchiveBuilder::null_uid(), + }; + + let target_args_uid = b.encode_nsarray( + &self + .target_application_arguments + .iter() + .map(|s| Value::String(s.clone())) + .collect::>(), + ); + + let tests_to_run_uid = match &self.tests_to_run { + Some(t) => { + let items: Vec = t.iter().map(|s| Value::String(s.clone())).collect(); + b.encode_nsarray(&items) + } + None => ArchiveBuilder::null_uid(), + }; + + let tests_to_skip_uid = match &self.tests_to_skip { + Some(t) => { + let items: Vec = t.iter().map(|s| Value::String(s.clone())).collect(); + b.encode_nsarray(&items) + } + None => ArchiveBuilder::null_uid(), + }; + + let format_version_uid = b.push(Value::Integer(2u64.into())); + + // --- XCTestConfiguration object dict ------------------------------ + + let class_uid = b.get_or_create_class("XCTestConfiguration", &[]); + + let mut obj = Dictionary::new(); + + // Nested-object fields (stored as UID references) + obj.insert("$class".into(), Value::Uid(class_uid)); + obj.insert( + "aggregateStatisticsBeforeCrash".into(), + Value::Uid(agg_stats_uid), + ); + obj.insert( + "automationFrameworkPath".into(), + Value::Uid(automation_path_uid), + ); + obj.insert("IDECapabilities".into(), Value::Uid(caps_uid)); + obj.insert("productModuleName".into(), Value::Uid(product_module_uid)); + obj.insert( + "targetApplicationArguments".into(), + Value::Uid(target_args_uid), + ); + obj.insert( + "targetApplicationBundleID".into(), + Value::Uid(target_bundle_uid), + ); + obj.insert( + "targetApplicationEnvironment".into(), + Value::Uid(target_env_uid), + ); + obj.insert( + "targetApplicationPath".into(), + Value::Uid(target_app_path_uid), + ); + obj.insert("testBundleURL".into(), Value::Uid(bundle_url_uid)); + obj.insert("sessionIdentifier".into(), Value::Uid(session_uid)); + obj.insert("testsToRun".into(), Value::Uid(tests_to_run_uid)); + obj.insert("testsToSkip".into(), Value::Uid(tests_to_skip_uid)); + obj.insert("formatVersion".into(), Value::Uid(format_version_uid)); + + // testApplicationDependencies: {} (empty NSDictionary, not null) + let test_app_deps_uid = b.encode_nsdict(&Dictionary::new()); + obj.insert( + "testApplicationDependencies".into(), + Value::Uid(test_app_deps_uid), + ); + + // Null-valued optional fields + for key in &[ + "baselineFileRelativePath", + "baselineFileURL", + "defaultTestExecutionTimeAllowance", + "maximumTestExecutionTimeAllowance", + "randomExecutionOrderingSeed", + "testApplicationUserOverrides", + "testBundleRelativePath", + ] { + obj.insert((*key).into(), Value::Uid(ArchiveBuilder::null_uid())); + } + + // Inline boolean fields + obj.insert( + "disablePerformanceMetrics".into(), + ArchiveBuilder::bool_value(false), + ); + obj.insert("emitOSLogs".into(), ArchiveBuilder::bool_value(false)); + obj.insert( + "gatherLocalizableStringsData".into(), + ArchiveBuilder::bool_value(false), + ); + obj.insert( + "initializeForUITesting".into(), + ArchiveBuilder::bool_value(true), + ); + obj.insert("reportActivities".into(), ArchiveBuilder::bool_value(true)); + obj.insert( + "reportResultsToIDE".into(), + ArchiveBuilder::bool_value(true), + ); + obj.insert( + "testTimeoutsEnabled".into(), + ArchiveBuilder::bool_value(false), + ); + obj.insert("testsDrivenByIDE".into(), ArchiveBuilder::bool_value(false)); + obj.insert( + "testsMustRunOnMainThread".into(), + ArchiveBuilder::bool_value(true), + ); + obj.insert( + "treatMissingBaselinesAsFailures".into(), + ArchiveBuilder::bool_value(false), + ); + + // Inline integer fields + obj.insert( + "systemAttachmentLifetime".into(), + ArchiveBuilder::int_value(2), + ); + obj.insert("testExecutionOrdering".into(), ArchiveBuilder::int_value(0)); + obj.insert( + "userAttachmentLifetime".into(), + ArchiveBuilder::int_value(0), + ); + obj.insert( + "preferredScreenCaptureFormat".into(), + ArchiveBuilder::int_value(2), + ); + + let config_uid = b.push(Value::Dictionary(obj)); + let archive = b.finish(config_uid); + archive_to_bytes(archive) + } +} + +// --------------------------------------------------------------------------- +// Runtime decode types (received from the runner, decode only) +// --------------------------------------------------------------------------- + +/// Decoded proxy for `XCTTestIdentifier`. +/// +/// `components` is the ordered list of name parts, e.g. +/// `["UITests", "testLogin"]`. Use [`test_class`](Self::test_class) and +/// [`test_method`](Self::test_method) as named accessors. +#[derive(Debug, Clone)] +pub struct XCTTestIdentifier { + /// Ordered name components. + pub components: Vec, +} + +impl XCTTestIdentifier { + /// Returns the test class name (first component). + pub fn test_class(&self) -> &str { + self.components.first().map(|s| s.as_str()).unwrap_or("") + } + + /// Returns the test method name (second component), if present. + pub fn test_method(&self) -> Option<&str> { + self.components.get(1).map(|s| s.as_str()) + } + + /// Decodes from a `plist::Value` received in a DTX payload. + /// + /// The value is expected to be a dictionary with a `"c"` key containing + /// an array of component strings. + pub fn from_plist(v: &Value) -> Option { + let dict = v.as_dictionary()?; + let components = dict + .get("c") + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|v| v.as_string().map(|s| s.to_owned())) + .collect() + }) + .unwrap_or_default(); + Some(Self { components }) + } +} + +/// Decoded proxy for `XCTSourceCodeLocation`. +#[derive(Debug, Clone)] +pub struct XCTSourceCodeLocation { + /// `file://` URL string of the source file, or `None` if absent. + pub file_url: Option, + /// Line number within the source file. + pub line_number: u64, +} + +impl XCTSourceCodeLocation { + /// Returns the local file path, stripping the `file://` prefix if present. + pub fn file_path(&self) -> Option<&str> { + self.file_url + .as_deref() + .map(|u| u.strip_prefix("file://").unwrap_or(u)) + } + + /// Decodes from a `plist::Value` dict. + pub fn from_plist(v: &Value) -> Option { + let dict = v.as_dictionary()?; + // `file-url` arrives as an NSURL object: {"NS.relative": "file://...", ...}. + // Handle both the nested dict form and a plain string as a fallback. + let file_url = dict.get("file-url").and_then(|v| { + v.as_dictionary() + .and_then(|d| d.get("NS.relative")) + .and_then(|v| v.as_string()) + .map(|s| s.to_owned()) + .or_else(|| v.as_string().map(|s| s.to_owned())) + }); + let line_number = dict + .get("line-number") + .and_then(|v| v.as_unsigned_integer()) + .unwrap_or(0); + Some(Self { + file_url, + line_number, + }) + } +} + +/// Decoded proxy for `XCTSourceCodeContext`. +#[derive(Debug, Clone)] +pub struct XCTSourceCodeContext { + /// Source location, if available. + pub location: Option, +} + +impl XCTSourceCodeContext { + /// Decodes from a `plist::Value` dict. + pub fn from_plist(v: &Value) -> Option { + let dict = v.as_dictionary()?; + let location = dict + .get("location") + .and_then(XCTSourceCodeLocation::from_plist); + Some(Self { location }) + } +} + +/// Decoded proxy for `XCTIssue` / `XCTMutableIssue`. +/// +/// `compact_description` is the short human-readable failure message +/// (e.g. `"((false) is true) failed"`). +#[derive(Debug, Clone)] +pub struct XCTIssue { + /// Short failure description. + pub compact_description: String, + /// Detailed description, if available. + pub detailed_description: Option, + /// Source location context, if available. + pub source_code_context: Option, + /// Issue type code. + pub issue_type: i64, +} + +impl XCTIssue { + /// Decodes from a `plist::Value` dict. + pub fn from_plist(v: &Value) -> Option { + let dict = v.as_dictionary()?; + let compact = dict + .get("compact-description") + .and_then(|v| v.as_string()) + .unwrap_or("") + .to_owned(); + let detailed = dict + .get("detailed-description") + .and_then(|v| v.as_string()) + .map(|s| s.to_owned()); + let ctx = dict + .get("source-code-context") + .and_then(XCTSourceCodeContext::from_plist); + let issue_type = dict + .get("type") + .and_then(|v| v.as_signed_integer()) + .unwrap_or(0); + Some(Self { + compact_description: compact, + detailed_description: detailed, + source_code_context: ctx, + issue_type, + }) + } +} + +/// Decoded proxy for `XCActivityRecord` — a single activity step in a test. +#[derive(Debug, Clone)] +pub struct XCActivityRecord { + /// Human-readable title of the activity. + pub title: String, + /// Activity type string. + pub activity_type: String, +} + +impl XCActivityRecord { + /// Decodes from a `plist::Value` dict. + pub fn from_plist(v: &Value) -> Option { + let dict = v.as_dictionary()?; + let title = dict + .get("title") + .and_then(|v| v.as_string()) + .unwrap_or("") + .to_owned(); + let activity_type = dict + .get("activityType") + .and_then(|v| v.as_string()) + .unwrap_or("") + .to_owned(); + Some(Self { + title, + activity_type, + }) + } +} + +/// Decoded proxy for `XCTestCaseRunConfiguration`. +#[derive(Debug, Clone, Copy)] +pub struct XCTestCaseRunConfiguration { + /// Iteration index (1-based) when tests are repeated. + pub iteration: u64, +} + +impl XCTestCaseRunConfiguration { + /// Decodes from a `plist::Value` dict. + pub fn from_plist(v: &Value) -> Option { + let dict = v.as_dictionary()?; + let iteration = dict + .get("iteration") + .and_then(|v| v.as_unsigned_integer()) + .unwrap_or(1); + Some(Self { iteration }) + } +} diff --git a/src-tauri/vendor/idevice/src/services/heartbeat.rs b/src-tauri/vendor/idevice/src/services/heartbeat.rs new file mode 100644 index 0000000..204464f --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/heartbeat.rs @@ -0,0 +1,118 @@ +//! iOS Device Heartbeat Service Abstraction +//! +//! iOS automatically closes service connections if there is no heartbeat client connected and +//! responding. + +use crate::{HeartbeatError, Idevice, IdeviceError, IdeviceService, obf}; + +/// Client for interacting with the iOS device heartbeat service +/// +/// The heartbeat service provides a keep-alive mechanism and can notify when +/// the device enters sleep mode or disconnects. +/// Note that a running heartbeat client is required to access other services on the device. +/// Implements the standard "Marco-Polo" protocol +/// where the host sends "Polo" in response to the device's "Marco". +#[derive(Debug)] +pub struct HeartbeatClient { + /// The underlying device connection with established heartbeat service + pub idevice: Idevice, +} + +impl IdeviceService for HeartbeatClient { + /// Returns the heartbeat service name as registered with lockdownd + fn service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.mobile.heartbeat") + } + async fn from_stream(idevice: Idevice) -> Result { + Ok(Self::new(idevice)) + } +} + +impl HeartbeatClient { + /// Creates a new heartbeat client from an existing device connection + /// + /// # Arguments + /// * `idevice` - Pre-established device connection + pub fn new(idevice: Idevice) -> Self { + Self { idevice } + } + + /// Waits for and processes a "Marco" message from the device + /// + /// This will either: + /// - Return the heartbeat interval if received + /// - Return a timeout error if no message received in time + /// - Return a sleep notification if device is going to sleep + /// + /// # Arguments + /// * `interval` - Timeout in seconds to wait for message + /// + /// # Returns + /// The heartbeat interval in seconds if successful + /// + /// # Errors + /// - `HeartbeatTimeout` if no message received before interval + /// - `HeartbeatSleepyTime` if device is going to sleep + /// - `UnexpectedResponse` for malformed messages + pub async fn get_marco(&mut self, interval: u64) -> Result { + // Get a plist or wait for the interval + let rec = tokio::select! { + rec = self.idevice.read_plist() => rec?, + _ = crate::time::sleep(std::time::Duration::from_secs(interval)) => { + return Err(HeartbeatError::Timeout.into()) + } + }; + match rec.get("Interval") { + Some(plist::Value::Integer(interval)) => { + if let Some(interval) = interval.as_unsigned() { + Ok(interval) + } else { + Err(IdeviceError::UnexpectedResponse( + "heartbeat Interval is not a valid unsigned integer".into(), + )) + } + } + _ => match rec.get("Command") { + Some(plist::Value::String(command)) => { + if command.as_str() == "SleepyTime" { + Err(HeartbeatError::SleepyTime.into()) + } else { + Err(IdeviceError::UnexpectedResponse(format!( + "unexpected heartbeat Command: {command}" + ))) + } + } + _ => Err(IdeviceError::UnexpectedResponse( + "heartbeat response missing both Interval and Command".into(), + )), + }, + } + } + + /// Sends a "Polo" response to the device + /// + /// This acknowledges receipt of a "Marco" message and maintains + /// the connection keep-alive. + /// + /// # Errors + /// Returns `IdeviceError` if the message fails to send + pub async fn send_polo(&mut self) -> Result<(), IdeviceError> { + let req = crate::plist!({ + "Command": "Polo" + }); + self.idevice.send_plist(req).await?; + Ok(()) + } +} + +#[cfg(feature = "rsd")] +impl crate::RsdService for HeartbeatClient { + fn rsd_service_name() -> std::borrow::Cow<'static, str> { + crate::obf!("com.apple.mobile.heartbeat.shim.remote") + } + async fn from_stream(stream: Box) -> Result { + let mut idevice = crate::Idevice::new(stream, ""); + idevice.rsd_checkin().await?; + Ok(Self::new(idevice)) + } +} diff --git a/src-tauri/vendor/idevice/src/services/house_arrest.rs b/src-tauri/vendor/idevice/src/services/house_arrest.rs new file mode 100644 index 0000000..5598796 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/house_arrest.rs @@ -0,0 +1,121 @@ +//! iOS Device HouseArrest Service Abstraction +//! +//! The HouseArrest service allows access to the container and Documents directory of apps +//! installed on an iOS device. This is typically used for file transfer and inspection of +//! app-specific data during development or diagnostics. + +use crate::{Idevice, IdeviceError, IdeviceService, obf}; + +use super::afc::AfcClient; + +/// Client for interacting with the iOS HouseArrest service +/// +/// HouseArrest is used to expose the container or Documents directory of an app to a host machine +/// over AFC (Apple File Conduit). +#[derive(Debug)] +pub struct HouseArrestClient { + /// The underlying device connection with the HouseArrest service + pub idevice: Idevice, +} + +impl IdeviceService for HouseArrestClient { + /// Returns the name of the HouseArrest service as registered with lockdownd + fn service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.mobile.house_arrest") + } + + async fn from_stream(idevice: Idevice) -> Result { + Ok(Self::new(idevice)) + } +} + +impl HouseArrestClient { + /// Creates a new HouseArrest client from an existing device connection + /// + /// # Arguments + /// * `idevice` - A pre-established device connection with the HouseArrest service + pub fn new(idevice: Idevice) -> Self { + Self { idevice } + } + + /// Requests access to the app's full container (Documents, Library, etc.) over AFC + /// + /// # Arguments + /// * `bundle_id` - The bundle identifier of the target app (e.g., "com.example.MyApp") + /// + /// # Returns + /// An `AfcClient` for accessing the container of the specified app + /// + /// # Errors + /// Returns `IdeviceError` if the request or AFC setup fails + pub async fn vend_container( + self, + bundle_id: impl Into, + ) -> Result { + let bundle_id = bundle_id.into(); + self.vend(bundle_id, "VendContainer".into()).await + } + + /// Requests access to the app's Documents directory over AFC. + /// Note that you can only access the /Documents directory. Permission will be denied + /// otherwise. + /// + /// # Arguments + /// * `bundle_id` - The bundle identifier of the target app (e.g., "com.example.MyApp") + /// + /// # Returns + /// An `AfcClient` for accessing the Documents directory of the specified app + /// + /// # Errors + /// Returns `IdeviceError` if the request or AFC setup fails + pub async fn vend_documents( + self, + bundle_id: impl Into, + ) -> Result { + let bundle_id = bundle_id.into(); + self.vend(bundle_id, "VendDocuments".into()).await + } + + /// Sends a HouseArrest command to expose a specific directory over AFC + /// + /// This is an internal method used by `vend_container` and `vend_documents`. + /// + /// # Arguments + /// * `bundle_id` - App bundle identifier + /// * `cmd` - Command to send ("VendContainer" or "VendDocuments") + /// + /// # Returns + /// A connected `AfcClient` instance + /// + /// # Errors + /// Returns `IdeviceError` if the request or AFC setup fails + async fn vend(mut self, bundle_id: String, cmd: String) -> Result { + let req = crate::plist!({ + "Command": cmd.clone(), + "Identifier": bundle_id + }); + + self.idevice.send_plist(req).await?; + let response = self.idevice.read_plist().await?; + + if let Some(err) = response.get("Error").and_then(|e| e.as_string()) { + return Err(IdeviceError::UnexpectedResponse(format!( + "house_arrest {cmd} failed: {err}" + ))); + } + + Ok(AfcClient::new(self.idevice)) + } +} + +#[cfg(feature = "rsd")] +impl crate::RsdService for HouseArrestClient { + fn rsd_service_name() -> std::borrow::Cow<'static, str> { + crate::obf!("com.apple.mobile.house_arrest.shim.remote") + } + async fn from_stream(stream: Box) -> Result { + let mut idevice = crate::Idevice::new(stream, ""); + idevice.rsd_checkin().await?; + Ok(Self::new(idevice)) + } +} diff --git a/src-tauri/vendor/idevice/src/services/installation_proxy.rs b/src-tauri/vendor/idevice/src/services/installation_proxy.rs new file mode 100644 index 0000000..a461c58 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/installation_proxy.rs @@ -0,0 +1,457 @@ +//! iOS Installation Proxy Service Client +//! +//! Provides functionality for interacting with the installation_proxy service on iOS devices, +//! which allows querying and managing installed applications. + +use std::collections::HashMap; + +use plist::Dictionary; +use tracing::warn; + +use crate::{Idevice, IdeviceError, IdeviceService, obf}; + +/// Errors specific to installation proxy operations +#[derive(thiserror::Error, Debug)] +#[non_exhaustive] +pub enum InstallationProxyError { + #[error("installation proxy operation failed: {0}")] + OperationFailed(String), + #[error("malformed package archive: {0}")] + MalformedPackageArchive(#[from] async_zip::error::ZipError), +} + +impl InstallationProxyError { + pub fn sub_code(&self) -> i32 { + match self { + Self::OperationFailed(_) => 1, + Self::MalformedPackageArchive(_) => 2, + } + } +} + +/// Client for interacting with the iOS installation proxy service +/// +/// This service provides access to information about installed applications +/// and can perform application management operations. +#[derive(Debug)] +pub struct InstallationProxyClient { + /// The underlying device connection with established installation_proxy service + pub idevice: Idevice, +} + +impl IdeviceService for InstallationProxyClient { + /// Returns the installation proxy service name as registered with lockdownd + fn service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.mobile.installation_proxy") + } + + async fn from_stream(idevice: Idevice) -> Result { + Ok(Self::new(idevice)) + } +} + +impl InstallationProxyClient { + /// Creates a new installation proxy client from an existing device connection + /// + /// # Arguments + /// * `idevice` - Pre-established device connection + pub fn new(idevice: Idevice) -> Self { + Self { idevice } + } + + /// Retrieves information about installed applications + /// + /// # Arguments + /// * `application_type` - Optional filter for application type: + /// - "System" for system applications + /// - "User" for user-installed applications + /// - "Any" for all applications (default) + /// * `bundle_identifiers` - Optional list of specific bundle IDs to query + /// + /// # Returns + /// A HashMap mapping bundle identifiers to application information plist values + /// + /// # Errors + /// Returns `IdeviceError` if: + /// - Communication fails + /// - The response is malformed + /// - The service returns an error + /// + /// # Example + /// ```rust + /// let apps = client.get_apps(Some("User".to_string()), None).await?; + /// for (bundle_id, info) in apps { + /// println!("{}: {:?}", bundle_id, info); + /// } + /// ``` + pub async fn get_apps( + &mut self, + application_type: Option<&str>, + bundle_identifiers: Option>, + ) -> Result, IdeviceError> { + let application_type = application_type.unwrap_or("Any"); + + let req = crate::plist!({ + "Command": "Lookup", + "ClientOptions": { + "ApplicationType": application_type, + "BundleIDs":? bundle_identifiers, + } + }); + self.idevice.send_plist(req).await?; + + let mut res = self.idevice.read_plist().await?; + match res.remove("LookupResult") { + Some(plist::Value::Dictionary(res)) => { + Ok(res.into_iter().collect::>()) + } + _ => Err(IdeviceError::UnexpectedResponse( + "missing LookupResult dictionary in response".into(), + )), + } + } + + /// Installs an application package on the device + /// + /// # Arguments + /// * `package_path` - Path to the .ipa package in the AFC jail (device's installation directory) + /// * `options` - Optional installation options as a plist dictionary + /// + /// # Returns + /// `Ok(())` on successful installation + /// + /// # Errors + /// Returns `IdeviceError` if: + /// - Communication fails + /// - The installation fails + /// - The service returns an error + /// + /// # Note + /// The package_path should be relative to the AFC jail root + pub async fn install( + &mut self, + package_path: impl Into, + options: Option, + ) -> Result<(), IdeviceError> { + self.install_with_callback(package_path, options, |_| async {}, ()) + .await + } + + /// Installs an application package on the device + /// + /// # Arguments + /// * `package_path` - Path to the .ipa package in the AFC jail (device's installation directory) + /// * `options` - Optional installation options as a plist dictionary + /// * `callback` - Progress callback that receives (percent_complete, state) + /// * `state` - State to pass to the callback + /// + /// # Returns + /// `Ok(())` on successful installation + /// + /// # Errors + /// Returns `IdeviceError` if: + /// - Communication fails + /// - The installation fails + /// - The service returns an error + /// + /// # Note + /// The package_path should be relative to the AFC jail root + pub async fn install_with_callback( + &mut self, + package_path: impl Into, + options: Option, + callback: impl Fn((u64, S)) -> Fut, + state: S, + ) -> Result<(), IdeviceError> + where + Fut: std::future::Future, + S: Clone, + { + let package_path = package_path.into(); + let options = options.unwrap_or(plist::Value::Dictionary(Dictionary::new())); + + let command = crate::plist!({ + "Command": "Install", + "ClientOptions": options, + "PackagePath": package_path, + }); + + self.idevice.send_plist(command).await?; + + self.watch_completion(callback, state).await + } + + /// Upgrades an existing application on the device + /// + /// # Arguments + /// * `package_path` - Path to the .ipa package in the AFC jail (device's installation directory) + /// * `options` - Optional upgrade options as a plist dictionary + /// + /// # Returns + /// `Ok(())` on successful upgrade + /// + /// # Errors + /// Returns `IdeviceError` if: + /// - Communication fails + /// - The upgrade fails + /// - The service returns an error + pub async fn upgrade( + &mut self, + package_path: impl Into, + options: Option, + ) -> Result<(), IdeviceError> { + self.upgrade_with_callback(package_path, options, |_| async {}, ()) + .await + } + + /// Upgrades an existing application on the device + /// + /// # Arguments + /// * `package_path` - Path to the .ipa package in the AFC jail (device's installation directory) + /// * `options` - Optional upgrade options as a plist dictionary + /// * `callback` - Progress callback that receives (percent_complete, state) + /// * `state` - State to pass to the callback + /// + /// # Returns + /// `Ok(())` on successful upgrade + /// + /// # Errors + /// Returns `IdeviceError` if: + /// - Communication fails + /// - The upgrade fails + /// - The service returns an error + pub async fn upgrade_with_callback( + &mut self, + package_path: impl Into, + options: Option, + callback: impl Fn((u64, S)) -> Fut, + state: S, + ) -> Result<(), IdeviceError> + where + Fut: std::future::Future, + S: Clone, + { + let package_path = package_path.into(); + let options = options.unwrap_or(plist::Value::Dictionary(Dictionary::new())); + + let command = crate::plist!({ + "Command": "Upgrade", + "ClientOptions": options, + "PackagePath": package_path, + }); + + self.idevice.send_plist(command).await?; + + self.watch_completion(callback, state).await + } + + /// Uninstalls an application from the device + /// + /// # Arguments + /// * `bundle_id` - Bundle identifier of the application to uninstall + /// * `options` - Optional uninstall options as a plist dictionary + /// + /// # Returns + /// `Ok(())` on successful uninstallation + /// + /// # Errors + /// Returns `IdeviceError` if: + /// - Communication fails + /// - The uninstallation fails + /// - The service returns an error + pub async fn uninstall( + &mut self, + bundle_id: impl Into, + options: Option, + ) -> Result<(), IdeviceError> { + self.uninstall_with_callback(bundle_id, options, |_| async {}, ()) + .await + } + + /// Uninstalls an application from the device + /// + /// # Arguments + /// * `bundle_id` - Bundle identifier of the application to uninstall + /// * `options` - Optional uninstall options as a plist dictionary + /// * `callback` - Progress callback that receives (percent_complete, state) + /// * `state` - State to pass to the callback + /// + /// # Returns + /// `Ok(())` on successful uninstallation + /// + /// # Errors + /// Returns `IdeviceError` if: + /// - Communication fails + /// - The uninstallation fails + /// - The service returns an error + pub async fn uninstall_with_callback( + &mut self, + bundle_id: impl Into, + options: Option, + callback: impl Fn((u64, S)) -> Fut, + state: S, + ) -> Result<(), IdeviceError> + where + Fut: std::future::Future, + S: Clone, + { + let bundle_id = bundle_id.into(); + let options = options.unwrap_or(plist::Value::Dictionary(Dictionary::new())); + + let command = crate::plist!({ + "Command": "Uninstall", + "ApplicationIdentifier": bundle_id, + "ClientOptions": options, + }); + + self.idevice.send_plist(command).await?; + + self.watch_completion(callback, state).await + } + + /// Checks if the device capabilities match the required capabilities + /// + /// # Arguments + /// * `capabilities` - List of required capabilities as plist values + /// * `options` - Optional check options as a plist dictionary + /// + /// # Returns + /// `true` if all capabilities are supported, `false` otherwise + /// + /// # Errors + /// Returns `IdeviceError` if: + /// - Communication fails + /// - The service returns an error + pub async fn check_capabilities_match( + &mut self, + capabilities: Vec, + options: Option, + ) -> Result { + let options = options.unwrap_or(plist::Value::Dictionary(Dictionary::new())); + + let command = crate::plist!({ + "Command": "CheckCapabilitiesMatch", + "ClientOptions": options, + "Capabilities": capabilities + }); + + self.idevice.send_plist(command).await?; + let mut res = self.idevice.read_plist().await?; + + if let Some(caps) = res.remove("LookupResult").and_then(|x| x.as_boolean()) { + Ok(caps) + } else { + Err(IdeviceError::UnexpectedResponse( + "missing LookupResult boolean in CheckCapabilitiesMatch response".into(), + )) + } + } + + /// Browses installed applications on the device + /// + /// # Arguments + /// * `options` - Optional browse options as a plist dictionary + /// + /// # Returns + /// A vector of plist values representing application information + /// + /// # Errors + /// Returns `IdeviceError` if: + /// - Communication fails + /// - The service returns an error + /// + /// # Note + /// This method streams application information in chunks and collects them into a single vector + pub async fn browse( + &mut self, + options: Option, + ) -> Result, IdeviceError> { + let options = options.unwrap_or(plist::Value::Dictionary(Dictionary::new())); + + let command = crate::plist!({ + "Command": "Browse", + "ClientOptions": options, + }); + + self.idevice.send_plist(command).await?; + + let mut values = Vec::new(); + loop { + let mut res = self.idevice.read_plist().await?; + + if let Some(list) = res.remove("CurrentList").and_then(|x| x.into_array()) { + for v in list.into_iter() { + values.push(v); + } + } else { + warn!("browse didn't contain current list"); + break; + } + + if let Some(status) = res.get("Status").and_then(|x| x.as_string()) + && status == "Complete" + { + break; + } + } + Ok(values) + } + + /// Watches for operation completion and handles progress callbacks + /// + /// # Arguments + /// * `callback` - Optional progress callback that receives (percent_complete, state) + /// * `state` - Optional state to pass to the callback + /// + /// # Returns + /// `Ok(())` when the operation completes successfully + /// + /// # Errors + /// Returns `IdeviceError` if: + /// - Communication fails + /// - The operation fails + /// - The service returns an error + async fn watch_completion( + &mut self, + callback: impl Fn((u64, S)) -> Fut, + state: S, + ) -> Result<(), IdeviceError> + where + Fut: std::future::Future, + S: Clone, + { + loop { + let mut res = self.idevice.read_plist().await?; + + if let Some(e) = res.remove("ErrorDescription").and_then(|x| x.into_string()) { + return Err(InstallationProxyError::OperationFailed(e.to_string()).into()); + } + + if let Some(c) = res + .remove("PercentComplete") + .and_then(|x| x.as_unsigned_integer()) + { + callback((c, state.clone())).await; + } + + if let Some(c) = res.remove("Status").and_then(|x| x.into_string()) + && c == "Complete" + { + break; + } + } + Ok(()) + } +} + +#[cfg(feature = "rsd")] +impl crate::RsdService for InstallationProxyClient { + fn rsd_service_name() -> std::borrow::Cow<'static, str> { + crate::obf!("com.apple.mobile.installation_proxy.shim.remote") + } + async fn from_stream(stream: Box) -> Result { + let mut idevice = crate::Idevice::new(stream, ""); + idevice.rsd_checkin().await?; + Ok(Self::new(idevice)) + } +} diff --git a/src-tauri/vendor/idevice/src/services/installcoordination_proxy.rs b/src-tauri/vendor/idevice/src/services/installcoordination_proxy.rs new file mode 100644 index 0000000..b764856 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/installcoordination_proxy.rs @@ -0,0 +1,110 @@ +// Jackson Coxson + +use crate::{IdeviceError, ReadWrite, RemoteXpcClient, RsdService, obf}; + +impl RsdService for InstallcoordinationProxy> { + fn rsd_service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.remote.installcoordination_proxy") + } + + async fn from_stream(stream: Box) -> Result { + let mut client = RemoteXpcClient::new(stream).await?; + client.do_handshake().await?; + Ok(Self { inner: client }) + } +} + +#[derive(Debug)] +pub struct InstallcoordinationProxy { + inner: RemoteXpcClient, +} + +impl InstallcoordinationProxy { + // TODO: implement 2 missing functions + // + // # REVERT STASH + // Revert Stashed App (RequestType: 2) + // This request rolls back an application to a previously "stashed" version, which is typically done after a failed update. + // + // Handler: _handleRevertStashMessage_forRemoteConnection_ + // + // RequestType: 2 + // ProtocolVersion: 1 + // BundleID: The bundle identifier of the app to revert. + // + // Action: The service creates an IXSRemoteReverter object, which calls the IXAppInstallCoordinator to perform the revert. It responds with a success or failure message. + // + // # INSTALL + // This is the most complex request. It tells the service to install a new application. + // Purpose: To stream an application binary from a client and install it on the device. + // + // Handler: _handleInstallBeginMessage_forRemoteConnection_ + // RequestType: 1 + // ProtocolVersion: 1 + // AssetSize: The total size of the app binary. + // AssetStreamFD: A file descriptor from which the service will read the app binary. + // RemoteInstallOptions: A dictionary containing all the app's metadata, like: + // + // { + // BundleID: (String) The application's bundle identifier (e.g., com.example.myapp). + // LocalizedName: (String) The display name of the app. + // InstallMode: (uint64) An enum specifying the installation mode (e.g., full install, update). + // Importance: (uint64) An enum defining the install's priority. 1 for "user" and 2 for "system". + // InstallableType: (uint64) Specifies the type of content being installed (e.g., app, system component). + // StoreMetadata: (Dictionary) A dictionary containing App Store metadata. + // SINFData: (Data) The legacy iTunes Sinf data for DRM. + // ProvisioningProfiles: (Array of Data) An array of provisioning profiles to install alongside the app. + // IconData: (Data, Optional) The raw data for the application's icon. + // IconDataType: (uint64, Optional) An enum specifying the format of the IconData + // } + // + // Action: The service creates an IXSRemoteInstaller object. It reads the app data from the file descriptor and passes it to the system's IXAppInstallCoordinator to handle the installation, placeholder creation, and data management. It sends back progress updates and a final completion message. + + pub async fn uninstall_app(&mut self, bundle_id: &str) -> Result<(), IdeviceError> { + let req = crate::xpc!({ + "RequestVersion": 1u64, + "ProtocolVersion": 1u64, + "RequestType": 3u64, + "BundleID": bundle_id, + }); + + self.inner.send_object(req, true).await?; + let res = self.inner.recv_root().await?; // it responds on the root?? + + match res + .as_dictionary() + .and_then(|x| x.get("Success")) + .and_then(|x| x.as_boolean()) + { + Some(true) => Ok(()), + _ => Err(IdeviceError::UnexpectedResponse( + "missing Success boolean in uninstall response".into(), + )), + } + } + + pub async fn query_app_path(&mut self, bundle_id: &str) -> Result { + let req = crate::xpc!({ + "RequestVersion": 1u64, + "ProtocolVersion": 1u64, + "RequestType": 4u64, + "BundleID": bundle_id, + }); + + self.inner.send_object(req, true).await?; + let res = self.inner.recv_root().await?; // it responds on the root?? + + match res + .as_dictionary() + .and_then(|x| x.get("InstallPath")) + .and_then(|x| x.as_dictionary()) + .and_then(|x| x.get("com.apple.CFURL.string")) + .and_then(|x| x.as_string()) + { + Some(s) => Ok(s.to_string()), + None => Err(IdeviceError::UnexpectedResponse( + "missing InstallPath in query_app_path response".into(), + )), + } + } +} diff --git a/src-tauri/vendor/idevice/src/services/lockdown.rs b/src-tauri/vendor/idevice/src/services/lockdown.rs new file mode 100644 index 0000000..8e7278c --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/lockdown.rs @@ -0,0 +1,553 @@ +//! iOS Lockdown Service Client +//! +//! Provides functionality for interacting with the lockdown service on iOS devices, +//! which is the primary service for device management and service discovery. + +use plist::Value; +use tracing::error; + +use crate::{Idevice, IdeviceError, IdeviceService, obf, pairing_file}; + +/// Client for interacting with the iOS lockdown service +/// +/// This is the primary service for device management and provides: +/// - Access to device information and settings +/// - Service discovery and port allocation +/// - Session management and security +#[derive(Debug)] +pub struct LockdownClient { + /// The underlying device connection with established lockdown service + pub idevice: crate::Idevice, +} + +#[cfg(feature = "rsd")] +impl crate::RsdService for LockdownClient { + fn rsd_service_name() -> std::borrow::Cow<'static, str> { + crate::obf!("com.apple.mobile.lockdown.remote.trusted") + } + async fn from_stream(stream: Box) -> Result { + let mut idevice = crate::Idevice::new(stream, ""); + idevice.rsd_checkin().await?; + Ok(Self::new(idevice)) + } +} + +impl IdeviceService for LockdownClient { + /// Returns the lockdown service name as registered with the device + fn service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.mobile.lockdown") + } + + /// Establishes a connection to the lockdown service + /// + /// # Arguments + /// * `provider` - Device connection provider + /// + /// # Returns + /// A connected `LockdownClient` instance + /// + /// # Errors + /// Returns `IdeviceError` if connection fails + async fn connect( + provider: &dyn crate::provider::IdeviceProvider, + ) -> Result { + let idevice = provider.connect(Self::LOCKDOWND_PORT).await?; + Ok(Self::new(idevice)) + } + + async fn from_stream(idevice: Idevice) -> Result { + Ok(Self::new(idevice)) + } +} + +impl LockdownClient { + /// The default TCP port for the lockdown service + pub const LOCKDOWND_PORT: u16 = 62078; + + /// Creates a new lockdown client from an existing device connection + /// + /// # Arguments + /// * `idevice` - Pre-established device connection + pub fn new(idevice: Idevice) -> Self { + Self { idevice } + } + + /// Retrieves a specific value from the device + /// + /// # Arguments + /// * `value` - The name of the value to retrieve (e.g., "DeviceName") + /// + /// # Returns + /// The requested value as a plist Value + /// + /// # Errors + /// Returns `IdeviceError` if: + /// - Communication fails + /// - The requested value doesn't exist + /// - The response is malformed + /// + /// # Example + /// ```rust + /// let device_name = client.get_value("DeviceName").await?; + /// println!("Device name: {:?}", device_name); + /// ``` + pub async fn get_value( + &mut self, + key: Option<&str>, + domain: Option<&str>, + ) -> Result { + let request = crate::plist!({ + "Label": self.idevice.label.clone(), + "Request": "GetValue", + "Key":? key, + "Domain":? domain + }); + self.idevice.send_plist(request).await?; + let message: plist::Dictionary = self.idevice.read_plist().await?; + match message.get("Value") { + Some(m) => Ok(m.to_owned()), + None => Err(IdeviceError::UnexpectedResponse( + "missing Value in GetValue response".into(), + )), + } + } + + /// Sets a value on the device + /// + /// # Arguments + /// * `key` - The key to set + /// * `value` - The plist value to set + /// * `domain` - An optional domain to set by + /// + /// # Errors + /// Returns `IdeviceError` if: + /// - Communication fails + /// - The response is malformed + /// + /// # Example + /// ```rust + /// client.set_value("EnableWifiDebugging", true.into(), Some("com.apple.mobile.wireless_lockdown".to_string())).await?; + /// ``` + pub async fn set_value( + &mut self, + key: impl Into, + value: Value, + domain: Option<&str>, + ) -> Result<(), IdeviceError> { + let key = key.into(); + + let req = crate::plist!({ + "Label": self.idevice.label.clone(), + "Request": "SetValue", + "Key": key, + "Value": value, + "Domain":? domain + }); + + self.idevice.send_plist(req).await?; + self.idevice.read_plist().await?; + + Ok(()) + } + + /// Removes a value on the device + /// + /// Sends a lockdown `RemoveValue` request. Wire format matches Apple's + /// `AMDeviceRemoveValue`: `{ "Request": "RemoveValue", "Domain"?: domain, "Key": key }`. + /// + /// # Arguments + /// * `key` - The key to remove + /// * `domain` - An optional domain to remove from + /// + /// # Errors + /// Returns `IdeviceError` if communication fails or the device replies with an `Error` + pub async fn remove_value( + &mut self, + key: impl Into, + domain: Option<&str>, + ) -> Result<(), IdeviceError> { + let key = key.into(); + + let req = crate::plist!({ + "Label": self.idevice.label.clone(), + "Request": "RemoveValue", + "Key": key, + "Domain":? domain + }); + + self.idevice.send_plist(req).await?; + let response: plist::Dictionary = self.idevice.read_plist().await?; + if let Some(plist::Value::String(e)) = response.get("Error") { + return Err(IdeviceError::UnexpectedResponse(format!( + "RemoveValue failed: {e}" + ))); + } + + Ok(()) + } + + /// Unpairs the host from the device + /// + /// Sends a lockdown `Unpair` request carrying the host pair-record identity. + /// + /// Note this only removes the pairing from the device side. The host-side pairing record + /// (stored by usbmuxd) should be removed separately via + /// [`crate::usbmuxd::UsbmuxdConnection::delete_pair_record`]. + /// + /// # Arguments + /// * `host_id` - The `HostID` from the pairing record (see + /// [`crate::pairing_file::PairingFile::host_id`]) + /// + /// # Errors + /// Returns `IdeviceError` if communication fails or the device replies with an `Error`. + pub async fn unpair(&mut self, host_id: impl Into) -> Result<(), IdeviceError> { + let host_id = host_id.into(); + + let req = crate::plist!({ + "Label": self.idevice.label.clone(), + "Request": "Unpair", + "PairRecord": { + "HostID": host_id + } + }); + + self.idevice.send_plist(req).await?; + let response: plist::Dictionary = self.idevice.read_plist().await?; + if let Some(plist::Value::String(e)) = response.get("Error") { + return Err(IdeviceError::UnexpectedResponse(format!( + "Unpair failed: {e}" + ))); + } + + Ok(()) + } + + /// Starts a secure TLS session with the device + /// + /// # Arguments + /// * `pairing_file` - Contains the device's identity and certificates + /// + /// # Returns + /// `Ok(legacy)` on successful session establishment, where `legacy` indicates + /// whether the device is running iOS < 5 and requires legacy TLS settings + /// + /// # Errors + /// Returns `IdeviceError` if: + /// - No connection is established + /// - The session request is denied + /// - TLS handshake fails + pub async fn start_session( + &mut self, + pairing_file: &pairing_file::PairingFile, + ) -> Result { + if self.idevice.socket.is_none() { + return Err(IdeviceError::NoEstablishedConnection); + } + + let legacy = self + .get_value(Some("ProductVersion"), None) + .await + .ok() + .as_ref() + .and_then(|x| x.as_string()) + .and_then(|x| x.split(".").next()) + .and_then(|x| x.parse::().ok()) + .map(|x| x < 5) + .unwrap_or(false); + + let request = crate::plist!({ + "Label": self.idevice.label.clone(), + "Request": "StartSession", + "HostID": pairing_file.host_id.clone(), + "SystemBUID": pairing_file.system_buid.clone() + + }); + self.idevice.send_plist(request).await?; + + let response = self.idevice.read_plist().await?; + match response.get("EnableSessionSSL") { + Some(plist::Value::Boolean(enable)) => { + if !enable { + return Err(IdeviceError::UnexpectedResponse( + "EnableSessionSSL is false in StartSession response".into(), + )); + } + } + _ => { + return Err(IdeviceError::UnexpectedResponse( + "missing EnableSessionSSL in StartSession response".into(), + )); + } + } + + self.idevice.start_session(pairing_file, legacy).await?; + Ok(legacy) + } + + /// Requests to start a service on the device + /// + /// # Arguments + /// * `identifier` - The service identifier (e.g., "com.apple.debugserver") + /// + /// # Returns + /// A tuple containing: + /// - The port number where the service is available + /// - A boolean indicating whether SSL should be used + /// + /// # Errors + /// Returns `IdeviceError` if: + /// - The service cannot be started + /// - The response is malformed + /// - The requested service doesn't exist + pub async fn start_service( + &mut self, + identifier: impl Into, + ) -> Result<(u16, bool), IdeviceError> { + self.start_service_with_escrow(identifier, None).await + } + + /// Requests to start a service, optionally presenting an escrow keybag so the device unlocks + /// its data protection for the service session. + /// + /// This mirrors Apple's `AMDeviceSecureStartService` with `UnlockEscrowBag = true`: the + /// `StartService` lockdown request gains an `EscrowBag` key. Without it the device leaves data + /// protection locked, so a service that must write protection-class files (mobilebackup2 + /// restore of an *encrypted* backup, house_arrest) fails device-side with + /// "setting protection class (device locked?)" (MBErrorDomain 208). Non-protected data is + /// unaffected, which is why a non-encrypted restore succeeds without the escrow bag. + /// + /// # Arguments + /// * `identifier` - The service identifier (e.g., "com.apple.mobilebackup2") + /// * `escrow_bag` - The device's escrow keybag from the pairing record; `None` behaves exactly + /// like [`start_service`](Self::start_service). + pub async fn start_service_with_escrow( + &mut self, + identifier: impl Into, + escrow_bag: Option>, + ) -> Result<(u16, bool), IdeviceError> { + let identifier = identifier.into(); + let mut req = crate::plist!({ + "Request": "StartService", + "Service": identifier, + }); + if let (Some(bag), plist::Value::Dictionary(d)) = (escrow_bag, &mut req) { + d.insert("EscrowBag".into(), plist::Value::Data(bag)); + } + self.idevice.send_plist(req).await?; + let response = self.idevice.read_plist().await?; + + let ssl = match response.get("EnableServiceSSL") { + Some(plist::Value::Boolean(ssl)) => ssl.to_owned(), + _ => false, // over USB, this option won't exist + }; + + match response.get("Port") { + Some(plist::Value::Integer(port)) => { + if let Some(port) = port.as_unsigned() { + Ok((port as u16, ssl)) + } else { + error!("Port isn't an unsigned integer!"); + Err(IdeviceError::UnexpectedResponse( + "Port is not an unsigned integer in StartService response".into(), + )) + } + } + _ => { + error!("Response didn't contain an integer port"); + Err(IdeviceError::UnexpectedResponse( + "missing Port in StartService response".into(), + )) + } + } + } + + /// Generates a pairing file and sends it to the device for trusting. + /// Note that this does NOT save the file to usbmuxd's cache. That's a responsibility of the + /// caller. + /// Note that this function is computationally heavy in a debug build. + /// + /// # Arguments + /// * `host_id` - The host ID, in the form of a UUID. Typically generated from the host name + /// * `system_buid` - UUID fetched from usbmuxd. Doesn't appear to affect function. + /// + /// # Returns + /// The newly generated pairing record + /// + /// # Errors + /// Returns `IdeviceError` + #[cfg(feature = "pair")] + pub async fn pair( + &mut self, + host_id: impl Into, + system_buid: impl Into, + host_name: Option<&str>, + ) -> Result { + let host_id = host_id.into(); + let system_buid = system_buid.into(); + + let (req, mut pair_record, private_key) = self + .build_pair_request(&host_id, &system_buid, host_name) + .await?; + + loop { + self.idevice.send_plist(req.clone()).await?; + match self.idevice.read_plist().await { + Ok(escrow) => { + pair_record.insert( + "HostPrivateKey".into(), + plist::Value::Data(private_key.clone()), + ); + if let Some(escrow) = escrow.get("EscrowBag").and_then(|x| x.as_data()) { + pair_record.insert("EscrowBag".into(), plist::Value::Data(escrow.to_vec())); + } + + let p = crate::pairing_file::PairingFile::from_value( + &plist::Value::Dictionary(pair_record), + )?; + + break Ok(p); + } + Err(IdeviceError::PairingDialogResponsePending) => { + crate::time::sleep(std::time::Duration::from_secs(1)).await; + } + Err(e) => break Err(e), + } + } + } + + /// Builds the lockdown `Pair` request and the accompanying host pair record. + /// + /// This fetches the device public key and WiFi MAC, generates the pairing certificates, and + /// assembles the pair record (WITHOUT `HostPrivateKey`, which is inserted only after the device + /// accepts the pairing) plus the outgoing request. + /// + /// # Returns + /// A tuple of `(request, pair_record_without_host_private_key, ca_private_key)`. + #[cfg(feature = "pair")] + async fn build_pair_request( + &mut self, + host_id: &str, + system_buid: &str, + host_name: Option<&str>, + ) -> Result<(plist::Value, plist::Dictionary, Vec), IdeviceError> { + let pub_key = self.get_value(Some("DevicePublicKey"), None).await?; + let pub_key = match pub_key.as_data().map(|x| x.to_vec()) { + Some(p) => p, + None => { + tracing::warn!("Did not get public key data response"); + return Err(IdeviceError::UnexpectedResponse( + "missing DevicePublicKey data in pair response".into(), + )); + } + }; + + let wifi_mac = self.get_value(Some("WiFiAddress"), None).await?; + let wifi_mac = match wifi_mac.as_string() { + Some(w) => w, + None => { + tracing::warn!("Did not get WiFiAddress string"); + return Err(IdeviceError::UnexpectedResponse( + "missing WiFiAddress string in pair response".into(), + )); + } + }; + + let ca = crate::ca::generate_certificates(&pub_key, None).unwrap(); + let pair_record = crate::plist!(dict { + "DevicePublicKey": pub_key, + "DeviceCertificate": ca.dev_cert, + "HostCertificate": ca.host_cert.clone(), + "HostID": host_id, + "RootCertificate": ca.host_cert, + "RootPrivateKey": ca.private_key.clone(), + "WiFiMACAddress": wifi_mac, + "SystemBUID": system_buid, + }); + + let req = crate::plist!({ + "Label": self.idevice.label.clone(), + "Request": "Pair", + "HostName":? host_name, + "PairRecord": pair_record.clone(), + "ProtocolVersion": "2", + "PairingOptions": { + "ExtendedPairingErrors": true + } + }); + + Ok((req, pair_record, ca.private_key)) + } + + /// Attempts to pair with the device exactly ONCE, without looping or sleeping. + /// + /// Unlike [`LockdownClient::pair`], this does NOT block on + /// [`IdeviceError::PairingDialogResponsePending`]: the pending/denied/password-protected + /// errors are propagated directly to the caller so it can drive its own retry/UI flow (e.g. + /// showing a "Trust This Computer" prompt). The lockdown "Error" field is mapped to the + /// corresponding [`IdeviceError`] variant by `read_plist`. + /// + /// Note that this does NOT save the file to usbmuxd's cache. That's a responsibility of the + /// caller. + /// + /// # Arguments + /// * `host_id` - The host ID, in the form of a UUID. Typically generated from the host name + /// * `system_buid` - UUID fetched from usbmuxd. Doesn't appear to affect function. + /// + /// # Returns + /// The newly generated pairing record on success. + /// + /// # Errors + /// Returns `IdeviceError`, including `PairingDialogResponsePending`, `UserDeniedPairing`, and + /// `PasswordProtected` surfaced directly from the device. + #[cfg(feature = "pair")] + pub async fn pair_once( + &mut self, + host_id: impl Into, + system_buid: impl Into, + host_name: Option<&str>, + ) -> Result { + let host_id = host_id.into(); + let system_buid = system_buid.into(); + + let (req, mut pair_record, private_key) = self + .build_pair_request(&host_id, &system_buid, host_name) + .await?; + + self.idevice.send_plist(req).await?; + // Propagates Err(PairingDialogResponsePending)/UserDeniedPairing/PasswordProtected etc. + let escrow = self.idevice.read_plist().await?; + + pair_record.insert("HostPrivateKey".into(), plist::Value::Data(private_key)); + if let Some(escrow) = escrow.get("EscrowBag").and_then(|x| x.as_data()) { + pair_record.insert("EscrowBag".into(), plist::Value::Data(escrow.to_vec())); + } + + crate::pairing_file::PairingFile::from_value(&plist::Value::Dictionary(pair_record)) + } + + /// Tell the device to enter recovery mode + pub async fn enter_recovery(&mut self) -> Result<(), IdeviceError> { + self.idevice + .send_plist(crate::plist!({ + "Request": "EnterRecovery" + })) + .await?; + + let res = self.idevice.read_plist().await?; + + if res.get("Request").and_then(|x| x.as_string()) == Some("EnterRecovery") { + Ok(()) + } else { + Err(IdeviceError::UnexpectedResponse( + "EnterRecovery request not acknowledged".into(), + )) + } + } +} + +impl From for LockdownClient { + /// Converts an existing device connection into a lockdown client + fn from(value: Idevice) -> Self { + Self::new(value) + } +} diff --git a/src-tauri/vendor/idevice/src/services/misagent.rs b/src-tauri/vendor/idevice/src/services/misagent.rs new file mode 100644 index 0000000..340284d --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/misagent.rs @@ -0,0 +1,210 @@ +//! iOS Mobile Installation Agent (misagent) Client +//! +//! Provides functionality for interacting with the misagent service on iOS devices, +//! which manages provisioning profiles and certificates. + +use tracing::warn; + +use crate::{Idevice, IdeviceError, IdeviceService, RsdService, obf}; + +/// Client for interacting with the iOS misagent service +/// +/// The misagent service handles: +/// - Installation of provisioning profiles +/// - Removal of provisioning profiles +/// - Querying installed profiles +#[derive(Debug)] +pub struct MisagentClient { + /// The underlying device connection with established misagent service + pub idevice: Idevice, +} + +impl RsdService for MisagentClient { + fn rsd_service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.misagent.shim.remote") + } + + async fn from_stream(stream: Box) -> Result { + let mut stream = Idevice::new(stream, ""); + stream.rsd_checkin().await?; + Ok(Self::new(stream)) + } +} + +impl IdeviceService for MisagentClient { + /// Returns the misagent service name as registered with lockdownd + fn service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.misagent") + } + + async fn from_stream(idevice: Idevice) -> Result { + Ok(Self::new(idevice)) + } +} + +impl MisagentClient { + /// Creates a new misagent client from an existing device connection + /// + /// # Arguments + /// * `idevice` - Pre-established device connection + pub fn new(idevice: Idevice) -> Self { + Self { idevice } + } + + /// Installs a provisioning profile on the device + /// + /// # Arguments + /// * `profile` - The provisioning profile data to install + /// + /// # Returns + /// `Ok(())` on successful installation + /// + /// # Errors + /// Returns `IdeviceError` if: + /// - Communication fails + /// - The profile is invalid + /// - Installation is not permitted + /// + /// # Example + /// ```rust + /// let profile_data = std::fs::read("profile.mobileprovision")?; + /// client.install(profile_data).await?; + /// ``` + pub async fn install(&mut self, profile: Vec) -> Result<(), IdeviceError> { + let req = crate::plist!({ + "MessageType": "Install", + "Profile": profile, + "ProfileType": "Provisioning" + }); + + self.idevice.send_plist(req).await?; + + let mut res = self.idevice.read_plist().await?; + + match res.remove("Status") { + Some(plist::Value::Integer(status)) => { + if let Some(status) = status.as_unsigned() { + if status == 0 { + Ok(()) + } else { + Err(IdeviceError::MisagentFailure) + } + } else { + warn!("Misagent return status wasn't unsigned"); + Err(IdeviceError::UnexpectedResponse( + "install Status is not an unsigned integer".into(), + )) + } + } + _ => { + warn!("Did not get integer status response"); + Err(IdeviceError::UnexpectedResponse( + "missing integer Status in install response".into(), + )) + } + } + } + + /// Removes a provisioning profile from the device + /// + /// # Arguments + /// * `id` - The UUID of the profile to remove + /// + /// # Returns + /// `Ok(())` on successful removal + /// + /// # Errors + /// Returns `IdeviceError` if: + /// - Communication fails + /// - The profile doesn't exist + /// - Removal is not permitted + /// + /// # Example + /// ```rust + /// client.remove("asdf").await?; + /// ``` + pub async fn remove(&mut self, id: &str) -> Result<(), IdeviceError> { + let req = crate::plist!({ + "MessageType": "Remove", + "ProfileID": id, + "ProfileType": "Provisioning" + }); + + self.idevice.send_plist(req).await?; + + let mut res = self.idevice.read_plist().await?; + + match res.remove("Status") { + Some(plist::Value::Integer(status)) => { + if let Some(status) = status.as_unsigned() { + if status == 0 { + Ok(()) + } else { + Err(IdeviceError::MisagentFailure) + } + } else { + warn!("Misagent return status wasn't unsigned"); + Err(IdeviceError::UnexpectedResponse( + "remove Status is not an unsigned integer".into(), + )) + } + } + _ => { + warn!("Did not get integer status response"); + Err(IdeviceError::UnexpectedResponse( + "missing integer Status in remove response".into(), + )) + } + } + } + + /// Retrieves all provisioning profiles from the device + /// + /// # Returns + /// A vector containing raw profile data for each installed profile + /// + /// # Errors + /// Returns `IdeviceError` if: + /// - Communication fails + /// - The response is malformed + /// + /// # Example + /// ```rust + /// let profiles = client.copy_all().await?; + /// for profile in profiles { + /// println!("Profile size: {} bytes", profile.len()); + /// } + /// ``` + pub async fn copy_all(&mut self) -> Result>, IdeviceError> { + let req = crate::plist!({ + "MessageType": "CopyAll", + "ProfileType": "Provisioning" + }); + + self.idevice.send_plist(req).await?; + + let mut res = self.idevice.read_plist().await?; + match res.remove("Payload") { + Some(plist::Value::Array(a)) => { + let mut res = Vec::new(); + for profile in a { + if let Some(profile) = profile.as_data() { + res.push(profile.to_vec()); + } else { + warn!("Misagent CopyAll did not return data plists"); + return Err(IdeviceError::UnexpectedResponse( + "CopyAll Payload array contains non-data entry".into(), + )); + } + } + Ok(res) + } + _ => { + warn!("Did not get a payload of provisioning profiles as an array"); + Err(IdeviceError::UnexpectedResponse( + "missing Payload array in CopyAll response".into(), + )) + } + } + } +} diff --git a/src-tauri/vendor/idevice/src/services/mobile_image_mounter.rs b/src-tauri/vendor/idevice/src/services/mobile_image_mounter.rs new file mode 100644 index 0000000..57d68d7 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/mobile_image_mounter.rs @@ -0,0 +1,782 @@ +//! iOS Image Mounter Client +//! +//! Provides functionality for mounting disk images on iOS devices, including: +//! - Developer disk images +//! - Personalized images +//! - Cryptex images +//! +//! Handles the complete workflow from uploading images to mounting them with proper signatures. + +use tracing::{debug, warn}; + +#[cfg(feature = "rsd")] +use crate::RsdService; +use crate::{Idevice, IdeviceError, IdeviceService, obf}; +use sha2::{Digest, Sha384}; + +#[cfg(feature = "tss")] +use crate::tss::TSSRequest; + +/// Client for interacting with the iOS mobile image mounter service +/// +/// Manages mounted images on the device. +/// +/// # Important Note +/// A lockdown client must be established and queried after establishing a mounter client, +/// or the device will stop responding to requests. +#[derive(Debug)] +pub struct ImageMounter { + /// The underlying device connection with established image mounter service + idevice: Idevice, +} + +impl IdeviceService for ImageMounter { + /// Returns the image mounter service name as registered with lockdownd + fn service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.mobile.mobile_image_mounter") + } + + async fn from_stream(idevice: Idevice) -> Result { + Ok(Self::new(idevice)) + } +} + +impl ImageMounter { + /// Creates a new image mounter client from an existing device connection + /// + /// # Arguments + /// * `idevice` - Pre-established device connection + pub fn new(idevice: Idevice) -> Self { + Self { idevice } + } + + /// Retrieves a list of currently mounted devices + /// + /// # Returns + /// A vector of plist values describing mounted devices + /// + /// # Errors + /// Returns `IdeviceError` if communication fails or response is malformed + pub async fn copy_devices(&mut self) -> Result, IdeviceError> { + let req = crate::plist!({ + "Command": "CopyDevices" + }); + self.idevice.send_plist(req).await?; + let mut res = self.idevice.read_plist().await?; + + match res.remove("EntryList") { + Some(plist::Value::Array(i)) => Ok(i), + _ => Err(IdeviceError::UnexpectedResponse( + "missing EntryList array in CopyDevices response".into(), + )), + } + } + + /// Looks up an image by type and returns its signature + /// + /// # Arguments + /// * `image_type` - The type of image to lookup (e.g., "Developer") + /// + /// # Returns + /// The image signature if found + /// + /// # Errors + /// Returns `IdeviceError::NotFound` if image doesn't exist + pub async fn lookup_image( + &mut self, + image_type: impl Into<&str>, + ) -> Result, IdeviceError> { + let image_type = image_type.into(); + let req = crate::plist!({ + "Command": "LookupImage", + "ImageType": image_type + }); + self.idevice.send_plist(req).await?; + + let res = self.idevice.read_plist().await?; + match res + .get("ImageSignature") + .and_then(|x| x.as_array()) + .and_then(|x| x.first()) + { + Some(plist::Value::Data(signature)) => Ok(signature.clone()), + _ => Err(IdeviceError::NotFound), + } + } + + /// Uploads an image to the device + /// + /// # Arguments + /// * `image_type` - Type of image being uploaded + /// * `image` - The image data + /// * `signature` - Signature for the image + /// + /// # Errors + /// Returns `IdeviceError` if upload fails + pub async fn upload_image( + &mut self, + image_type: impl Into, + image: &[u8], + signature: Vec, + ) -> Result<(), IdeviceError> { + self.upload_image_with_progress(image_type, image, signature, |_| async {}, ()) + .await + } + + /// Uploads an image with progress callbacks + /// + /// # Arguments + /// * `image_type` - Type of image being uploaded + /// * `image` - The image data + /// * `signature` - Signature for the image + /// * `callback` - Progress callback + /// * `state` - State to pass to callback + /// + /// # Type Parameters + /// * `Fut` - Future type returned by callback + /// * `S` - Type of state passed to callback + /// + /// # Errors + /// Returns `IdeviceError` if upload fails + pub async fn upload_image_with_progress( + &mut self, + image_type: impl Into, + image: &[u8], + signature: Vec, + callback: impl Fn(((usize, usize), S)) -> Fut, + state: S, + ) -> Result<(), IdeviceError> + where + Fut: std::future::Future, + S: Clone, + { + let image_type = image_type.into(); + let image_size = match u64::try_from(image.len()) { + Ok(i) => i, + Err(e) => { + tracing::error!("Could not parse image size as u64: {e:?}"); + return Err(IdeviceError::UnexpectedResponse( + "image size exceeds u64 range".into(), + )); + } + }; + + let req = crate::plist!({ + "Command": "ReceiveBytes", + "ImageType": image_type, + "ImageSize": image_size, + "ImageSignature": signature, + }); + self.idevice.send_plist(req).await?; + + let res = self.idevice.read_plist().await?; + match res.get("Status") { + Some(plist::Value::String(s)) => { + if s.as_str() != "ReceiveBytesAck" { + tracing::error!("Received bad response to SendBytes: {s:?}"); + return Err(IdeviceError::UnexpectedResponse( + "expected ReceiveBytesAck Status in upload response".into(), + )); + } + } + _ => { + return Err(IdeviceError::UnexpectedResponse( + "missing Status in ReceiveBytes response".into(), + )); + } + } + + debug!("Sending image bytes"); + self.idevice + .send_raw_with_progress(image, callback, state) + .await?; + + let res = self.idevice.read_plist().await?; + match res.get("Status") { + Some(plist::Value::String(s)) => { + if s.as_str() != "Complete" { + tracing::error!("Image send failure: {s:?}"); + return Err(IdeviceError::UnexpectedResponse( + "expected Complete Status after image upload".into(), + )); + } + } + _ => { + return Err(IdeviceError::UnexpectedResponse( + "missing Status after image upload".into(), + )); + } + } + + Ok(()) + } + + /// Mounts an image on the device + /// + /// # Arguments + /// * `image_type` - Type of image to mount + /// * `signature` - Signature for the image + /// * `trust_cache` - Optional trust cache data + /// * `info_plist` - Optional info plist for the image + /// + /// # Errors + /// Returns `IdeviceError` if mounting fails + pub async fn mount_image( + &mut self, + image_type: impl Into, + signature: Vec, + trust_cache: Option>, + info_plist: Option, + ) -> Result<(), IdeviceError> { + let image_type = image_type.into(); + + let req = crate::plist!({ + "Command": "MountImage", + "ImageType": image_type, + "ImageSignature": signature, + "ImageTrustCache":? trust_cache, + "ImageInfoPlist":? info_plist, + }); + self.idevice.send_plist(req).await?; + + let res = self.idevice.read_plist().await?; + + match res.get("Status") { + Some(plist::Value::String(s)) => { + if s.as_str() != "Complete" { + tracing::error!("Image send failure: {s:?}"); + return Err(IdeviceError::UnexpectedResponse( + "expected Complete Status in MountImage response".into(), + )); + } + } + _ => { + return Err(IdeviceError::UnexpectedResponse( + "missing Status in MountImage response".into(), + )); + } + } + + Ok(()) + } + + /// Unmounts an image at the specified path + /// + /// # Arguments + /// * `mount_path` - Path where image is mounted: + /// - `/Developer` for pre-iOS 17 developer images + /// - `/System/Developer` for personalized images + /// + /// # Errors + /// Returns `IdeviceError` if unmounting fails + pub async fn unmount_image( + &mut self, + mount_path: impl Into, + ) -> Result<(), IdeviceError> { + let mount_path = mount_path.into(); + let req = crate::plist!({ + "Command": "UnmountImage", + "MountPath": mount_path, + }); + self.idevice.send_plist(req).await?; + + let res = self.idevice.read_plist().await?; + match res.get("Status") { + Some(plist::Value::String(s)) if s.as_str() == "Complete" => Ok(()), + _ => Err(IdeviceError::UnexpectedResponse( + "expected Complete Status in UnmountImage response".into(), + )), + } + } + + /// Queries the personalization manifest from the device + /// + /// # Important + /// On failure, the socket must be closed and reestablished. + /// + /// # Arguments + /// * `image_type` - Type of image to query manifest for + /// * `signature` - Signature of the image + /// + /// # Returns + /// The personalization manifest data + /// + /// # Errors + /// Returns `IdeviceError` if query fails + pub async fn query_personalization_manifest( + &mut self, + image_type: impl Into, + signature: Vec, + ) -> Result, IdeviceError> { + let image_type = image_type.into(); + + let req = crate::plist!({ + "Command": "QueryPersonalizationManifest", + "PersonalizedImageType": image_type.clone(), + "ImageType": image_type, + "ImageSignature": signature + }); + self.idevice.send_plist(req).await?; + + let mut res = self.idevice.read_plist().await?; + match res.remove("ImageSignature") { + Some(plist::Value::Data(i)) => Ok(i), + _ => Err(IdeviceError::NotFound), + } + } + + /// Queries the developer mode status of the device + /// + /// # Returns + /// `true` if developer mode is enabled, `false` otherwise + /// + /// # Errors + /// Returns `IdeviceError` if query fails + pub async fn query_developer_mode_status(&mut self) -> Result { + let req = crate::plist!({ + "Command": "QueryDeveloperModeStatus" + }); + self.idevice.send_plist(req).await?; + + let res = self.idevice.read_plist().await?; + match res.get("DeveloperModeStatus") { + Some(plist::Value::Boolean(status)) => Ok(*status), + _ => Err(IdeviceError::UnexpectedResponse( + "missing DeveloperModeStatus boolean in response".into(), + )), + } + } + + /// Queries the nonce value from the device + /// + /// # Arguments + /// * `personalized_image_type` - Optional image type to get nonce for + /// + /// # Returns + /// The nonce value + /// + /// # Errors + /// Returns `IdeviceError` if query fails + pub async fn query_nonce( + &mut self, + personalized_image_type: Option<&str>, + ) -> Result, IdeviceError> { + let req = crate::plist!({ + "Command": "QueryNonce", + "PersonalizedImageType":? personalized_image_type, + }); + self.idevice.send_plist(req).await?; + + let res = self.idevice.read_plist().await?; + match res.get("PersonalizationNonce") { + Some(plist::Value::Data(nonce)) => Ok(nonce.clone()), + _ => Err(IdeviceError::UnexpectedResponse( + "missing PersonalizationNonce data in response".into(), + )), + } + } + + /// Queries personalization identifiers from the device + /// + /// # Arguments + /// * `image_type` - Optional image type to get identifiers for + /// + /// # Returns + /// Dictionary of personalization identifiers + /// + /// # Errors + /// Returns `IdeviceError` if query fails + pub async fn query_personalization_identifiers( + &mut self, + image_type: Option<&str>, + ) -> Result { + let req = crate::plist!({ + "Command": "QueryPersonalizationIdentifiers", + "PersonalizedImageType":? image_type, + }); + self.idevice.send_plist(req).await?; + + let res = self.idevice.read_plist().await?; + match res.get("PersonalizationIdentifiers") { + Some(plist::Value::Dictionary(identifiers)) => Ok(identifiers.clone()), + _ => Err(IdeviceError::UnexpectedResponse( + "missing PersonalizationIdentifiers dictionary in response".into(), + )), + } + } + + /// Rolls the personalization nonce on the device + /// + /// # Errors + /// Returns `IdeviceError` if operation fails + pub async fn roll_personalization_nonce(&mut self) -> Result<(), IdeviceError> { + let req = crate::plist!({ + "Command": "RollPersonalizationNonce" + }); + self.idevice.send_plist(req).await?; + + Ok(()) + } + + /// Rolls the cryptex nonce on the device + /// + /// # Errors + /// Returns `IdeviceError` if operation fails + pub async fn roll_cryptex_nonce(&mut self) -> Result<(), IdeviceError> { + let req = crate::plist!({ + "Command": "RollCryptexNonce" + }); + self.idevice.send_plist(req).await?; + + Ok(()) + } + + /// Mounts a developer disk image + /// + /// # Arguments + /// * `image` - The developer disk image data + /// * `signature` - Signature for the image + /// + /// # Errors + /// Returns `IdeviceError` if mounting fails + pub async fn mount_developer( + &mut self, + image: &[u8], + signature: Vec, + ) -> Result<(), IdeviceError> { + self.upload_image("Developer", image, signature.clone()) + .await?; + self.mount_image("Developer", signature, None, None).await?; + + Ok(()) + } + + #[cfg(feature = "tss")] + /// Mounts a personalized image with automatic manifest handling + /// + /// # Arguments + /// * `provider` - Device connection provider (used for reconnection if needed) + /// * `image` - The image data + /// * `trust_cache` - Trust cache data + /// * `build_manifest` - Build manifest data + /// * `info_plist` - Optional info plist for the image + /// * `unique_chip_id` - Device's unique chip ID + /// + /// # Errors + /// Returns `IdeviceError` if mounting fails + pub async fn mount_personalized( + &mut self, + provider: &dyn crate::provider::IdeviceProvider, + image: Vec, + trust_cache: Vec, + build_manifest: &[u8], + info_plist: Option, + unique_chip_id: u64, + ) -> Result<(), IdeviceError> { + self.mount_personalized_with_callback( + provider, + image, + trust_cache, + build_manifest, + info_plist, + unique_chip_id, + |_| async {}, + (), + ) + .await + } + + #[cfg(feature = "tss")] + /// Mounts a personalized image with progress callbacks + /// + /// # Important + /// This may close the socket on failure, requiring reconnection. + /// + /// # Arguments + /// * `provider` - Device connection provider + /// * `image` - The image data + /// * `trust_cache` - Trust cache data + /// * `build_manifest` - Build manifest data + /// * `info_plist` - Optional info plist for the image + /// * `unique_chip_id` - Device's unique chip ID + /// * `callback` - Progress callback + /// * `state` - State to pass to callback + /// + /// # Type Parameters + /// * `Fut` - Future type returned by callback + /// * `S` - Type of state passed to callback + /// + /// # Errors + /// Returns `IdeviceError` if mounting fails + #[allow(clippy::too_many_arguments)] + pub async fn mount_personalized_with_callback( + &mut self, + provider: &dyn crate::provider::IdeviceProvider, + image: Vec, + trust_cache: Vec, + build_manifest: &[u8], + info_plist: Option, + unique_chip_id: u64, + callback: impl Fn(((usize, usize), S)) -> Fut, + state: S, + ) -> Result<(), IdeviceError> + where + Fut: std::future::Future, + S: Clone, + { + // Try to fetch personalization manifest + let mut hasher = Sha384::new(); + hasher.update(&image); + let image_hash = hasher.finalize(); + let manifest = match self + .query_personalization_manifest("DeveloperDiskImage", image_hash.to_vec()) + .await + { + Ok(manifest) => manifest, + Err(e) => { + debug!("Device didn't contain a manifest: {e:?}, fetching from TSS"); + + // On failure, the socket closes. Open a new one. + self.idevice = Self::connect(provider).await?.idevice; + + // Get manifest from TSS + let manifest_dict: plist::Dictionary = plist::from_bytes(build_manifest)?; + self.get_manifest_from_tss(&manifest_dict, unique_chip_id) + .await? + } + }; + + debug!("Uploading image"); + self.upload_image_with_progress("Personalized", &image, manifest.clone(), callback, state) + .await?; + + debug!("Mounting image"); + self.mount_image("Personalized", manifest, Some(trust_cache), info_plist) + .await?; + + Ok(()) + } + + /// Mounts a personalized image with automatic manifest handling + /// + /// # Arguments + /// * `provider` - Device connection provider (used for reconnection if needed) + /// * `image` - The image data + /// * `trust_cache` - Trust cache data + /// * `build_manifest` - Build manifest data + /// * `info_plist` - Optional info plist for the image + /// * `unique_chip_id` - Device's unique chip ID + /// + /// # Errors + /// Returns `IdeviceError` if mounting fails + #[cfg(all(feature = "tss", feature = "rsd"))] + #[allow(clippy::too_many_arguments)] + pub async fn mount_personalized_rsd( + &mut self, + provider: &mut impl crate::provider::RsdProvider, + handshake: &mut crate::rsd::RsdHandshake, + image: Vec, + trust_cache: Vec, + build_manifest: &[u8], + info_plist: Option, + unique_chip_id: u64, + ) -> Result<(), IdeviceError> { + self.mount_personalized_with_callback_rsd( + provider, + handshake, + image, + trust_cache, + build_manifest, + info_plist, + unique_chip_id, + |_| async {}, + (), + ) + .await + } + + /// Mounts a personalized image with progress callbacks + /// + /// # Important + /// This may close the socket on failure, requiring reconnection. + /// + /// # Arguments + /// * `provider` - Device connection provider + /// * `image` - The image data + /// * `trust_cache` - Trust cache data + /// * `build_manifest` - Build manifest data + /// * `info_plist` - Optional info plist for the image + /// * `unique_chip_id` - Device's unique chip ID + /// * `callback` - Progress callback + /// * `state` - State to pass to callback + /// + /// # Type Parameters + /// * `Fut` - Future type returned by callback + /// * `S` - Type of state passed to callback + /// + /// # Errors + /// Returns `IdeviceError` if mounting fails + #[cfg(all(feature = "tss", feature = "rsd"))] + #[allow(clippy::too_many_arguments)] + pub async fn mount_personalized_with_callback_rsd( + &mut self, + provider: &mut impl crate::provider::RsdProvider, + handshake: &mut crate::rsd::RsdHandshake, + image: Vec, + trust_cache: Vec, + build_manifest: &[u8], + info_plist: Option, + unique_chip_id: u64, + callback: impl Fn(((usize, usize), S)) -> Fut, + state: S, + ) -> Result<(), IdeviceError> + where + Fut: std::future::Future, + S: Clone, + { + // Try to fetch personalization manifest + let mut hasher = Sha384::new(); + hasher.update(&image); + let image_hash = hasher.finalize(); + let manifest = match self + .query_personalization_manifest("DeveloperDiskImage", image_hash.to_vec()) + .await + { + Ok(manifest) => manifest, + Err(e) => { + debug!("Device didn't contain a manifest: {e:?}, fetching from TSS"); + + // On failure, the socket closes. Open a new one. + self.idevice = Self::connect_rsd(provider, handshake).await?.idevice; + + // Get manifest from TSS + let manifest_dict: plist::Dictionary = plist::from_bytes(build_manifest)?; + self.get_manifest_from_tss(&manifest_dict, unique_chip_id) + .await? + } + }; + + debug!("Uploading image"); + self.upload_image_with_progress("Personalized", &image, manifest.clone(), callback, state) + .await?; + + debug!("Mounting image"); + self.mount_image("Personalized", manifest, Some(trust_cache), info_plist) + .await?; + + Ok(()) + } + + #[cfg(feature = "tss")] + /// Retrieves a personalization manifest from Apple's TSS server + /// + /// # Arguments + /// * `build_manifest` - Build manifest dictionary + /// * `unique_chip_id` - Device's unique chip ID + /// + /// # Returns + /// The manifest data + /// + /// # Errors + /// Returns `IdeviceError` if manifest retrieval fails + pub async fn get_manifest_from_tss( + &mut self, + build_manifest: &plist::Dictionary, + unique_chip_id: u64, + ) -> Result, IdeviceError> { + let mut request = TSSRequest::new(); + + let personalization_identifiers = self.query_personalization_identifiers(None).await?; + request.add_ap_personalization_identifiers(&personalization_identifiers); + + let board_id = match personalization_identifiers.get("BoardId") { + Some(plist::Value::Integer(b)) => match b.as_unsigned() { + Some(b) => b, + None => { + return Err(IdeviceError::UnexpectedResponse( + "BoardId is not an unsigned integer".into(), + )); + } + }, + _ => { + return Err(IdeviceError::UnexpectedResponse( + "missing BoardId in personalization identifiers".into(), + )); + } + }; + let chip_id = match personalization_identifiers.get("ChipID") { + Some(plist::Value::Integer(b)) => match b.as_unsigned() { + Some(b) => b, + None => { + return Err(IdeviceError::UnexpectedResponse( + "ChipID is not an unsigned integer".into(), + )); + } + }, + _ => { + return Err(IdeviceError::UnexpectedResponse( + "missing ChipID in personalization identifiers".into(), + )); + } + }; + + request.set_ap_img4_ticket(true); + request.set_bb_ticket(true); + let ap_nonce = self.query_nonce(Some("DeveloperDiskImage")).await?; + request.add_common_tags( + board_id, + chip_id, + unique_chip_id, + Some(ap_nonce), + Some(vec![0; 20]), + ); + + let build_identity = + crate::tss::select_build_identity(build_manifest, board_id, chip_id, None)?; + + let parameters = crate::plist!(dict { + "ApProductionMode": true, + "ApSecurityMode": true, + "ApSupportsImg4": true + }); + + let ddi_rules: &[plist::Value] = build_identity + .get("Manifest") + .and_then(|m| m.as_dictionary()) + .and_then(|m| m.get("LoadableTrustCache")) + .and_then(|c| c.as_dictionary()) + .and_then(|c| c.get("Info")) + .and_then(|i| i.as_dictionary()) + .and_then(|i| i.get("RestoreRequestRules")) + .and_then(|r| r.as_array()) + .map(|v| v.as_slice()) + .unwrap_or(&[]); + + request.populate_from_manifest(build_identity, ¶meters, Some(ddi_rules))?; + + let res = request.send().await?; + let res = match res { + plist::Value::Dictionary(r) => r, + _ => { + warn!("Apple returned a non-dictionary plist"); + return Err(IdeviceError::UnexpectedResponse( + "TSS response is not a dictionary".into(), + )); + } + }; + + crate::tss::extract_img4_ticket(&res) + } +} + +#[cfg(feature = "rsd")] +impl crate::RsdService for ImageMounter { + fn rsd_service_name() -> std::borrow::Cow<'static, str> { + crate::obf!("com.apple.mobile.mobile_image_mounter.shim.remote") + } + async fn from_stream(stream: Box) -> Result { + let mut idevice = crate::Idevice::new(stream, ""); + idevice.rsd_checkin().await?; + Ok(Self::new(idevice)) + } +} diff --git a/src-tauri/vendor/idevice/src/services/mobileactivationd.rs b/src-tauri/vendor/idevice/src/services/mobileactivationd.rs new file mode 100644 index 0000000..296709d --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/mobileactivationd.rs @@ -0,0 +1,94 @@ +//! mobileactivationd activates iOS devices. +//! This isn't a normal service, as it requires a new connection for each request. +//! As such, this service requires a provider itself, instead of temporary usage of one. + +use plist::Dictionary; + +use crate::{Idevice, IdeviceError, IdeviceService, lockdown::LockdownClient, obf}; + +#[derive(Debug)] +pub struct MobileActivationdClient<'a> { + provider: &'a dyn crate::provider::IdeviceProvider, +} + +/// Internal structure for temporary service connections. +/// This struct exists to take advantage of the service trait. +struct MobileActivationdInternal { + pub idevice: Idevice, +} + +impl IdeviceService for MobileActivationdInternal { + /// Returns the service name as registered with lockdownd + fn service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.mobileactivationd") + } + + async fn from_stream(idevice: Idevice) -> Result { + Ok(Self::new(idevice)) + } +} + +impl MobileActivationdInternal { + fn new(idevice: Idevice) -> Self { + Self { idevice } + } +} + +impl<'a> MobileActivationdClient<'a> { + pub fn new(provider: &'a dyn crate::provider::IdeviceProvider) -> Self { + Self { provider } + } + + pub async fn state(&self) -> Result { + if let Ok(res) = self.send_command("GetActivationStateRequest", None).await + && let Some(v) = res.get("Value").and_then(|x| x.as_string()) + { + Ok(v.to_string()) + } else { + let mut lc = LockdownClient::connect(self.provider).await?; + lc.start_session(&self.provider.get_pairing_file().await?) + .await?; + + let res = lc.get_value(Some("ActivationState"), None).await?; + if let Some(v) = res.as_string() { + Ok(v.to_string()) + } else { + Err(IdeviceError::UnexpectedResponse( + "ActivationState is not a string".into(), + )) + } + } + } + + pub async fn activated(&self) -> Result { + Ok(self.state().await? == "Activated") + } + + /// Deactivates the device. + /// Protocol gives no response on whether it worked or not, so good luck + pub async fn deactivate(&self) -> Result<(), IdeviceError> { + self.send_command("DeactivateRequest", None).await?; + Ok(()) + } + + async fn send_command( + &self, + command: impl Into, + value: Option<&str>, + ) -> Result { + let mut service = self.service_connect().await?; + let command = command.into(); + let req = crate::plist!({ + "Command": command, + "Value":? value, + }); + service.send_plist(req).await?; + service.read_plist().await + } + + async fn service_connect(&self) -> Result { + Ok(MobileActivationdInternal::connect(self.provider) + .await? + .idevice) + } +} diff --git a/src-tauri/vendor/idevice/src/services/mobilebackup2.rs b/src-tauri/vendor/idevice/src/services/mobilebackup2.rs new file mode 100644 index 0000000..8920bef --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/mobilebackup2.rs @@ -0,0 +1,1855 @@ +//! iOS Mobile Backup 2 Service Client +//! +//! Provides functionality for interacting with the mobilebackup2 service on iOS devices, +//! which allows creating, restoring, and managing device backups. + +use plist::Dictionary; +use std::future::Future; +use std::io::{Read, Write}; +use std::path::{Component, Path, PathBuf}; +use std::pin::Pin; +use std::time::SystemTime; +use tokio::io::AsyncReadExt; +use tracing::{debug, warn}; + +use crate::{Idevice, IdeviceError, IdeviceService, obf}; + +/// DeviceLink message codes used in MobileBackup2 binary streams +pub const DL_CODE_SUCCESS: u8 = 0x00; +pub const DL_CODE_ERROR_LOCAL: u8 = 0x06; +pub const DL_CODE_ERROR_REMOTE: u8 = 0x0b; +pub const DL_CODE_FILE_DATA: u8 = 0x0c; + +/// Metadata for a single directory entry returned by [`BackupDelegate::list_dir`]. +#[derive(Debug)] +pub struct DirEntryInfo { + pub name: String, + pub is_dir: bool, + pub is_file: bool, + pub size: u64, + pub modified: Option, +} + +/// Resolves a device-supplied DeviceLink path against the host backup directory. +/// +/// During a backup the device sends paths for the host to read/write/move. Some +/// of these are absolute-looking (e.g. `/.b/6/...`). Passing them straight to +/// [`Path::join`] is unsafe: Rust drops the base when the argument is absolute, +/// so the operation escapes the backup directory which can cause "Operation +/// not permitted" errors that abort the backup or read/write outside +/// it (path traversal). Treat the device path as strictly relative to +/// `host_dir`, keeping only `Normal` components (dropping any root, `.` or `..`). +fn host_path(host_dir: &Path, rel: &str) -> PathBuf { + let mut out = host_dir.to_path_buf(); + for comp in Path::new(rel).components() { + if let Component::Normal(c) = comp { + out.push(c); + } + } + out +} + +/// Delegate trait providing host-side storage and platform operations for the +/// mobilebackup2 DeviceLink loop. +/// +/// All filesystem-like operations go through this trait so that callers can +/// direct backup data to something other than the local filesystem +/// (e.g. a database, cloud storage, or an in-memory buffer). +/// +/// A ready-made [`FsBackupDelegate`] is provided for the common case of +/// reading/writing to the local filesystem via `tokio::fs`. +pub trait BackupDelegate: Send + Sync { + /// Returns the available disk space in bytes for the volume containing `path`. + fn get_free_disk_space(&self, path: &Path) -> u64; + + /// Open an existing file for reading. + #[allow(clippy::type_complexity)] + fn open_file_read<'a>( + &'a self, + path: &'a Path, + ) -> Pin, IdeviceError>> + Send + 'a>>; + + /// Create (or truncate) a file for writing. + #[allow(clippy::type_complexity)] + fn create_file_write<'a>( + &'a self, + path: &'a Path, + ) -> Pin, IdeviceError>> + Send + 'a>>; + + /// Recursively create a directory and all parents. + fn create_dir_all<'a>( + &'a self, + path: &'a Path, + ) -> Pin> + Send + 'a>>; + + /// Remove a path. If it is a directory, remove it recursively. + fn remove<'a>( + &'a self, + path: &'a Path, + ) -> Pin> + Send + 'a>>; + + /// Rename / move `from` to `to`. + fn rename<'a>( + &'a self, + from: &'a Path, + to: &'a Path, + ) -> Pin> + Send + 'a>>; + + /// Copy a file or directory from `src` to `dst`. + fn copy<'a>( + &'a self, + src: &'a Path, + dst: &'a Path, + ) -> Pin> + Send + 'a>>; + + /// Returns `true` if `path` exists. + fn exists<'a>(&'a self, path: &'a Path) -> Pin + Send + 'a>>; + + /// Returns `true` if `path` is a directory. + fn is_dir<'a>(&'a self, path: &'a Path) -> Pin + Send + 'a>>; + + /// List the immediate children of `path` with metadata. + fn list_dir<'a>( + &'a self, + path: &'a Path, + ) -> Pin, IdeviceError>> + Send + 'a>>; + + /// Called after each file is received from the device during backup. + /// + /// `file_count` is the running total of files received in the current upload batch. + fn on_file_received(&self, _path: &str, _file_count: u32) {} + + /// Called periodically during file transfer with byte-level progress. + /// + /// - `bytes_done`: total bytes transferred so far in this upload batch + /// - `bytes_total`: total expected bytes for this batch (0 if unknown) + /// - `overall_progress`: device-reported overall progress percentage (0.0–100.0), + /// or negative if not yet reported + fn on_progress(&self, _bytes_done: u64, _bytes_total: u64, _overall_progress: f64) {} +} + +/// Default [`BackupDelegate`] that reads/writes to the local filesystem via `tokio::fs`. +/// +/// Native-only: `tokio::fs` doesn't compile on `wasm32-unknown-unknown`. +/// Wasm consumers must implement their own [`BackupDelegate`] backed by +/// IndexedDB / OPFS / an in-memory store / etc. +#[cfg(not(target_arch = "wasm32"))] +#[derive(Debug, Clone, Copy)] +pub struct FsBackupDelegate; + +#[cfg(not(target_arch = "wasm32"))] +impl BackupDelegate for FsBackupDelegate { + /// Free space in bytes available to an unprivileged user on the volume backing + /// `path` (resolved through symlinks and mount points), walking up to the + /// nearest existing ancestor. Where the OS can't be queried it reports a large + /// constant. + fn get_free_disk_space(&self, path: &Path) -> u64 { + const ASSUMED_FREE: u64 = 1 << 50; + + // Available bytes on the volume backing an existing `p`, or `None` if the + // query fails (e.g. `p` doesn't exist yet). + #[allow(clippy::unnecessary_cast)] // block-count/size widths vary per platform + fn available(p: &Path) -> Option { + #[cfg(unix)] + { + use std::os::unix::ffi::OsStrExt; + let c = std::ffi::CString::new(p.as_os_str().as_bytes()).ok()?; + // Apple's `statvfs` f_bavail is a 32-bit count that rolls over past + // ~4 TB free; `statfs` is 64-bit. + #[cfg(target_vendor = "apple")] + { + let mut s = std::mem::MaybeUninit::::uninit(); + // SAFETY: `c` is NUL-terminated; `s` is a writable buffer. + if unsafe { libc::statfs(c.as_ptr(), s.as_mut_ptr()) } != 0 { + return None; + } + let s = unsafe { s.assume_init() }; + Some(s.f_bavail as u64 * s.f_bsize as u64) + } + #[cfg(not(target_vendor = "apple"))] + { + let mut s = std::mem::MaybeUninit::::uninit(); + // SAFETY: `c` is NUL-terminated; `s` is a writable buffer. + if unsafe { libc::statvfs(c.as_ptr(), s.as_mut_ptr()) } != 0 { + return None; + } + let s = unsafe { s.assume_init() }; + Some(s.f_bavail as u64 * s.f_frsize as u64) + } + } + #[cfg(windows)] + { + use std::os::windows::ffi::OsStrExt; + // kernel32!GetDiskFreeSpaceExW; lpFreeBytesAvailableToCaller is + // 64-bit bytes available to the caller. + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetDiskFreeSpaceExW( + lpDirectoryName: *const u16, + lpFreeBytesAvailableToCaller: *mut u64, + lpTotalNumberOfBytes: *mut u64, + lpTotalNumberOfFreeBytes: *mut u64, + ) -> i32; + } + let mut wide: Vec = p.as_os_str().encode_wide().collect(); + if wide.contains(&0) { + return None; // a NUL would terminate the path early + } + wide.push(0); + let mut avail = 0u64; + // SAFETY: `wide` is NUL-terminated; the out-params are writable u64 + // slots; `avail` is read only on success. + (unsafe { + GetDiskFreeSpaceExW( + wide.as_ptr(), + &mut avail, + std::ptr::null_mut(), + std::ptr::null_mut(), + ) + } != 0) + .then_some(avail) + } + #[cfg(not(any(unix, windows)))] + { + let _ = p; + None + } + } + + let mut dir = path; + loop { + if let Some(bytes) = available(dir) { + return bytes; + } + match dir.parent() { + Some(parent) => dir = parent, + None => return ASSUMED_FREE, + } + } + } + + fn open_file_read<'a>( + &'a self, + path: &'a Path, + ) -> Pin, IdeviceError>> + Send + 'a>> { + Box::pin(async move { + let file = tokio::fs::File::open(path) + .await + .map_err(|e| IdeviceError::InternalError(e.to_string()))?; + let std_file = file.into_std().await; + Ok(Box::new(std_file) as Box) + }) + } + + fn create_file_write<'a>( + &'a self, + path: &'a Path, + ) -> Pin, IdeviceError>> + Send + 'a>> + { + Box::pin(async move { + let file = tokio::fs::File::create(path) + .await + .map_err(|e| IdeviceError::InternalError(e.to_string()))?; + let std_file = file.into_std().await; + Ok(Box::new(std_file) as Box) + }) + } + + fn create_dir_all<'a>( + &'a self, + path: &'a Path, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + tokio::fs::create_dir_all(path) + .await + .map_err(|e| IdeviceError::InternalError(e.to_string())) + }) + } + + fn remove<'a>( + &'a self, + path: &'a Path, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + let meta = tokio::fs::metadata(path).await; + match meta { + Ok(m) if m.is_dir() => tokio::fs::remove_dir_all(path).await, + _ => tokio::fs::remove_file(path).await, + } + .map_err(|e| IdeviceError::InternalError(e.to_string())) + }) + } + + fn rename<'a>( + &'a self, + from: &'a Path, + to: &'a Path, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + tokio::fs::rename(from, to) + .await + .map_err(|e| IdeviceError::InternalError(e.to_string())) + }) + } + + fn copy<'a>( + &'a self, + src: &'a Path, + dst: &'a Path, + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + let meta = tokio::fs::metadata(src).await; + if meta.is_ok_and(|m| m.is_dir()) { + tokio::fs::create_dir_all(dst).await + } else { + tokio::fs::copy(src, dst).await.map(|_| ()) + } + .map_err(|e| IdeviceError::InternalError(e.to_string())) + }) + } + + fn exists<'a>(&'a self, path: &'a Path) -> Pin + Send + 'a>> { + Box::pin(async move { tokio::fs::try_exists(path).await.unwrap_or(false) }) + } + + fn is_dir<'a>(&'a self, path: &'a Path) -> Pin + Send + 'a>> { + Box::pin(async move { + tokio::fs::metadata(path) + .await + .map(|m| m.is_dir()) + .unwrap_or(false) + }) + } + + fn list_dir<'a>( + &'a self, + path: &'a Path, + ) -> Pin, IdeviceError>> + Send + 'a>> { + Box::pin(async move { + let mut entries = tokio::fs::read_dir(path) + .await + .map_err(|e| IdeviceError::InternalError(e.to_string()))?; + let mut result = Vec::new(); + while let Ok(Some(entry)) = entries.next_entry().await { + let name = entry.file_name().to_string_lossy().to_string(); + let meta = entry.metadata().await.ok(); + result.push(DirEntryInfo { + name, + is_dir: meta.as_ref().is_some_and(|m| m.is_dir()), + is_file: meta.as_ref().is_some_and(|m| m.is_file()), + size: meta.as_ref().map_or(0, |m| m.len()), + modified: meta.and_then(|m| m.modified().ok()), + }); + } + Ok(result) + }) + } +} + +/// Client for interacting with the iOS mobile backup 2 service +/// +/// This service provides access to device backup functionality including +/// creating backups, restoring from backups, and managing backup data. +#[derive(Debug)] +pub struct MobileBackup2Client { + /// The underlying device connection with established mobilebackup2 service + pub idevice: Idevice, + /// Protocol version negotiated with the device + pub protocol_version: f64, +} + +impl IdeviceService for MobileBackup2Client { + /// Returns the mobile backup 2 service name as registered with lockdownd + fn service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.mobilebackup2") + } + + async fn from_stream(idevice: Idevice) -> Result { + let mut client = Self::new(idevice); + // Perform DeviceLink handshake first + client.dl_version_exchange().await?; + // Perform version exchange after connection + client.version_exchange().await?; + Ok(client) + } +} + +/// Backup message types used in the mobilebackup2 protocol +#[derive(Debug, Clone, Copy)] +pub enum BackupMessageType { + /// Request to start a backup operation + BackupMessageTypeBackup, + /// Request to restore from a backup + BackupMessageTypeRestore, + /// Information message + BackupMessageTypeInfo, + /// List available backups + BackupMessageTypeList, + /// Upload files to backup + BackupMessageTypeUploadFiles, + /// Download files from backup + BackupMessageTypeDownloadFiles, + /// Clear backup data + BackupMessageTypeClearBackupData, + /// Move files in backup + BackupMessageTypeMoveFiles, + /// Remove files from backup + BackupMessageTypeRemoveFiles, + /// Create directory in backup + BackupMessageTypeCreateDirectory, + /// Acquire lock for backup operation + BackupMessageTypeAcquireLock, + /// Release lock after backup operation + BackupMessageTypeReleaseLock, + /// Copy item in backup + BackupMessageTypeCopyItem, + /// Disconnect from service + BackupMessageTypeDisconnect, + /// Process message + BackupMessageTypeProcessMessage, + /// Get freespace information + BackupMessageTypeGetFreespace, + /// Factory info + BackupMessageTypeFactoryInfo, + /// Check if backup is encrypted + BackupMessageTypeCheckBackupEncryption, +} + +impl BackupMessageType { + /// Convert message type to string representation + pub fn as_str(&self) -> &'static str { + match self { + // These map to MobileBackup2 request names per libimobiledevice + BackupMessageType::BackupMessageTypeBackup => "Backup", + BackupMessageType::BackupMessageTypeRestore => "Restore", + BackupMessageType::BackupMessageTypeInfo => "Info", + BackupMessageType::BackupMessageTypeList => "List", + // The following are DL control messages and not sent via MessageName + BackupMessageType::BackupMessageTypeUploadFiles => "DLMessageUploadFiles", + BackupMessageType::BackupMessageTypeDownloadFiles => "DLMessageDownloadFiles", + BackupMessageType::BackupMessageTypeClearBackupData => "DLMessageClearBackupData", + BackupMessageType::BackupMessageTypeMoveFiles => "DLMessageMoveFiles", + BackupMessageType::BackupMessageTypeRemoveFiles => "DLMessageRemoveFiles", + BackupMessageType::BackupMessageTypeCreateDirectory => "DLMessageCreateDirectory", + BackupMessageType::BackupMessageTypeAcquireLock => "DLMessageAcquireLock", + BackupMessageType::BackupMessageTypeReleaseLock => "DLMessageReleaseLock", + BackupMessageType::BackupMessageTypeCopyItem => "DLMessageCopyItem", + BackupMessageType::BackupMessageTypeDisconnect => "DLMessageDisconnect", + BackupMessageType::BackupMessageTypeProcessMessage => "DLMessageProcessMessage", + BackupMessageType::BackupMessageTypeGetFreespace => "DLMessageGetFreeDiskSpace", + BackupMessageType::BackupMessageTypeFactoryInfo => "FactoryInfo", + BackupMessageType::BackupMessageTypeCheckBackupEncryption => "CheckBackupEncryption", + } + } +} + +/// Backup information structure +#[derive(Debug, Clone)] +pub struct BackupInfo { + /// Backup UUID + pub uuid: String, + /// Device name + pub device_name: String, + /// Display name + pub display_name: String, + /// Last backup date + pub last_backup_date: Option, + /// Backup version + pub version: String, + /// Whether backup is encrypted + pub is_encrypted: bool, +} + +/// High-level builder for restore options so callers don't need to remember raw keys +#[derive(Debug, Clone)] +pub struct RestoreOptions { + pub reboot: bool, + pub copy: bool, + pub preserve_settings: bool, + pub system_files: bool, + pub remove_items_not_restored: bool, + pub password: Option, +} + +impl Default for RestoreOptions { + fn default() -> Self { + Self { + reboot: true, + copy: true, + preserve_settings: true, + system_files: false, + remove_items_not_restored: false, + password: None, + } + } +} + +impl RestoreOptions { + pub fn new() -> Self { + Self::default() + } + pub fn with_reboot(mut self, reboot: bool) -> Self { + self.reboot = reboot; + self + } + pub fn with_copy(mut self, copy: bool) -> Self { + self.copy = copy; + self + } + pub fn with_preserve_settings(mut self, preserve: bool) -> Self { + self.preserve_settings = preserve; + self + } + pub fn with_system_files(mut self, system: bool) -> Self { + self.system_files = system; + self + } + pub fn with_remove_items_not_restored(mut self, remove: bool) -> Self { + self.remove_items_not_restored = remove; + self + } + pub fn with_password(mut self, password: impl Into) -> Self { + self.password = Some(password.into()); + self + } + + pub fn to_plist(&self) -> Dictionary { + crate::plist!(dict { + "RestoreShouldReboot": self.reboot, + "RestoreDontCopyBackup": !self.copy, + "RestorePreserveSettings": self.preserve_settings, + "RestoreSystemFiles": self.system_files, + "RemoveItemsNotRestored": self.remove_items_not_restored, + "Password":? self.password.clone() + }) + } +} + +impl MobileBackup2Client { + /// Creates a new mobile backup 2 client from an existing device connection + /// + /// # Arguments + /// * `idevice` - Pre-established device connection + pub fn new(idevice: Idevice) -> Self { + Self { + idevice, + protocol_version: 0.0, + } + } + + /// Performs DeviceLink version exchange handshake + /// + /// Sequence: + /// 1) Receive ["DLMessageVersionExchange", major, minor] + /// 2) Send ["DLMessageVersionExchange", "DLVersionsOk", 400] + /// 3) Receive ["DLMessageDeviceReady"] + async fn dl_version_exchange(&mut self) -> Result<(), IdeviceError> { + debug!("Starting DeviceLink version exchange"); + // 1) Receive DLMessageVersionExchange + let (msg, _arr) = self.receive_dl_message().await?; + if msg != "DLMessageVersionExchange" { + warn!("Expected DLMessageVersionExchange, got {msg}"); + return Err(IdeviceError::UnexpectedResponse( + "expected DLMessageVersionExchange during handshake".into(), + )); + } + + // 2) Send DLVersionsOk with version 400 + let out = vec![ + plist::Value::String("DLMessageVersionExchange".into()), + plist::Value::String("DLVersionsOk".into()), + plist::Value::Integer(400u64.into()), + ]; + self.send_dl_array(out).await?; + + // 3) Receive DLMessageDeviceReady + let (msg2, _arr2) = self.receive_dl_message().await?; + if msg2 != "DLMessageDeviceReady" { + warn!("Expected DLMessageDeviceReady, got {msg2}"); + return Err(IdeviceError::UnexpectedResponse( + "expected DLMessageDeviceReady after version exchange".into(), + )); + } + Ok(()) + } + + /// Sends a raw DL array as binary plist + async fn send_dl_array(&mut self, array: Vec) -> Result<(), IdeviceError> { + self.idevice.send_bplist(plist::Value::Array(array)).await + } + + /// Receives any DL* message and returns (message_tag, full_array_value) + pub async fn receive_dl_message(&mut self) -> Result<(String, plist::Value), IdeviceError> { + if let Some(socket) = &mut self.idevice.socket { + let mut buf = [0u8; 4]; + if let Err(e) = socket.read_exact(&mut buf).await { + debug!("Failed to read DL message length: {e}"); + return Err(e.into()); + } + let len = u32::from_be_bytes(buf); + debug!("Reading DL message body: {len} bytes"); + let mut body = vec![0; len as usize]; + socket.read_exact(&mut body).await?; + let value: plist::Value = plist::from_bytes(&body)?; + if let plist::Value::Array(arr) = &value + && let Some(plist::Value::String(tag)) = arr.first() + { + debug!("Received DL message: {tag}"); + return Ok((tag.clone(), value)); + } + warn!("Invalid DL message format"); + Err(IdeviceError::UnexpectedResponse( + "invalid DL message format: expected array with string tag".into(), + )) + } else { + Err(IdeviceError::NoEstablishedConnection) + } + } + + /// Performs version exchange with the device + /// + /// This is required by the mobilebackup2 protocol and must be called + /// before any other operations. + /// + /// # Returns + /// `Ok(())` on successful version negotiation + /// + /// # Errors + /// Returns `IdeviceError` if version exchange fails + async fn version_exchange(&mut self) -> Result<(), IdeviceError> { + debug!("Starting mobilebackup2 version exchange"); + + // Send supported protocol versions (matching libimobiledevice) + let hello_dict = crate::plist!(dict { + "SupportedProtocolVersions": [ + 2.0, 2.1 + ] + }); + + self.send_device_link_message("Hello", Some(hello_dict)) + .await?; + + // Receive response + let response = self.receive_device_link_message("Response").await?; + + // Check for error + if let Some(error_code) = response.get("ErrorCode") + && let Some(code) = error_code.as_unsigned_integer() + && code != 0 + { + warn!("Version exchange failed with error code: {code}"); + return Err(IdeviceError::UnexpectedResponse( + "version exchange failed with non-zero ErrorCode".into(), + )); + } + + // Get negotiated protocol version + if let Some(version) = response.get("ProtocolVersion").and_then(|v| v.as_real()) { + self.protocol_version = version; + debug!("Negotiated protocol version: {version}"); + } else { + warn!("No protocol version in response"); + return Err(IdeviceError::UnexpectedResponse( + "missing ProtocolVersion in version exchange response".into(), + )); + } + + Ok(()) + } + + /// Sends a device link message (DLMessageProcessMessage format) + /// + /// This follows the device_link_service protocol used by mobilebackup2 + /// + /// # Arguments + /// * `message_name` - The message name (e.g., "Hello", "kBackupMessageTypeInfo") + /// * `options` - Optional dictionary of options for the message + /// + /// # Returns + /// `Ok(())` on successful message send + /// + /// # Errors + /// Returns `IdeviceError` if communication fails + async fn send_device_link_message( + &mut self, + message_name: &str, + options: Option, + ) -> Result<(), IdeviceError> { + // Create the actual message dictionary + let message_dict = crate::plist!(dict { + "MessageName": message_name, + : Result { + // Read raw bytes and parse as plist::Value to handle array format + if let Some(socket) = &mut self.idevice.socket { + debug!("Reading response size"); + let mut buf = [0u8; 4]; + socket.read_exact(&mut buf).await?; + let len = u32::from_be_bytes(buf); + let mut buf = vec![0; len as usize]; + socket.read_exact(&mut buf).await?; + let response_value: plist::Value = plist::from_bytes(&buf)?; + + // Parse DLMessageProcessMessage format + if let plist::Value::Array(array) = response_value + && array.len() >= 2 + && let Some(plist::Value::String(dl_message)) = array.first() + && let Some(plist::Value::Dictionary(dict)) = array.get(1) + && dl_message == "DLMessageProcessMessage" + { + // Check MessageName if expected + if !expected_message.is_empty() { + if let Some(message_name) = dict.get("MessageName").and_then(|v| v.as_string()) + { + if message_name != expected_message { + warn!("Expected message '{expected_message}', got '{message_name}'"); + return Err(IdeviceError::UnexpectedResponse( + "device link MessageName does not match expected".into(), + )); + } + } else { + warn!("No MessageName in response"); + return Err(IdeviceError::UnexpectedResponse( + "missing MessageName in DLMessageProcessMessage".into(), + )); + } + } + return Ok(dict.clone()); + } + + warn!("Invalid device link message format"); + Err(IdeviceError::UnexpectedResponse( + "invalid device link message format".into(), + )) + } else { + Err(IdeviceError::NoEstablishedConnection) + } + } + + /// Sends a backup message to the device + /// + /// # Arguments + /// * `message_type` - The type of backup message to send + /// * `options` - Optional dictionary of options for the message + /// + /// # Returns + /// `Ok(())` on successful message send + /// + /// # Errors + /// Returns `IdeviceError` if communication fails + async fn send_backup_message( + &mut self, + message_type: BackupMessageType, + options: Option, + ) -> Result<(), IdeviceError> { + self.send_device_link_message(message_type.as_str(), options) + .await + } + + /// Sends a MobileBackup2 request with proper envelope and identifiers + pub async fn send_request( + &mut self, + request: &str, + target_identifier: Option<&str>, + source_identifier: Option<&str>, + options: Option, + ) -> Result<(), IdeviceError> { + let dict = crate::plist!(dict { + "TargetIdentifier":? target_identifier, + "SourceIdentifier":? source_identifier, + "Options":? options, + // Special cases like Unback/EnableCloudBackup are handled by caller if needed + }); + self.send_device_link_message(request, Some(dict)).await + } + + /// Sends a DLMessageStatusResponse array + pub async fn send_status_response( + &mut self, + status_code: i64, + status1: Option<&str>, + status2: Option, + ) -> Result<(), IdeviceError> { + let arr = vec![ + plist::Value::String("DLMessageStatusResponse".into()), + plist::Value::Integer(status_code.into()), + plist::Value::String(status1.unwrap_or("___EmptyParameterString___").into()), + status2.unwrap_or_else(|| plist::Value::String("___EmptyParameterString___".into())), + ]; + self.send_dl_array(arr).await + } + + /// Receives a response from the backup service + /// + /// # Returns + /// The response as a plist Dictionary + /// + /// # Errors + /// Returns `IdeviceError` if communication fails or response is malformed + async fn receive_backup_response(&mut self) -> Result { + self.receive_device_link_message("").await + } + + /// Requests device information for backup + /// + /// # Returns + /// A dictionary containing device information + /// + /// # Errors + /// Returns `IdeviceError` if the request fails + pub async fn request_backup_info(&mut self) -> Result { + // Per protocol use MessageName "Info" + self.send_backup_message(BackupMessageType::BackupMessageTypeInfo, None) + .await?; + + let response = self.receive_backup_response().await?; + + // Check for error in response + if let Some(error) = response.get("ErrorCode") { + warn!("Backup info request failed with error: {error:?}"); + return Err(IdeviceError::UnexpectedResponse( + "backup info request returned ErrorCode".into(), + )); + } + + Ok(response) + } + + /// Lists available backups on the device + /// + /// # Returns + /// A vector of backup information + /// + /// # Errors + /// Returns `IdeviceError` if the request fails + pub async fn list_backups(&mut self) -> Result, IdeviceError> { + self.send_backup_message(BackupMessageType::BackupMessageTypeList, None) + .await?; + + let response = self.receive_backup_response().await?; + + // Check for error in response + if let Some(error) = response.get("ErrorCode") { + warn!("List backups request failed with error: {error:?}"); + return Err(IdeviceError::UnexpectedResponse( + "list backups request returned ErrorCode".into(), + )); + } + + let mut backups = Vec::new(); + + if let Some(plist::Value::Array(backup_list)) = response.get("BackupList") { + for backup_item in backup_list { + if let plist::Value::Dictionary(backup_dict) = backup_item { + let uuid = backup_dict + .get("BackupUUID") + .and_then(|v| v.as_string()) + .unwrap_or_default() + .to_string(); + + let device_name = backup_dict + .get("DeviceName") + .and_then(|v| v.as_string()) + .unwrap_or_default() + .to_string(); + + let display_name = backup_dict + .get("DisplayName") + .and_then(|v| v.as_string()) + .unwrap_or_default() + .to_string(); + + let last_backup_date = backup_dict + .get("LastBackupDate") + .and_then(|v| v.as_string()) + .map(|s| s.to_string()); + + let version = backup_dict + .get("Version") + .and_then(|v| v.as_string()) + .unwrap_or("Unknown") + .to_string(); + + let is_encrypted = backup_dict + .get("IsEncrypted") + .and_then(|v| v.as_boolean()) + .unwrap_or(false); + + backups.push(BackupInfo { + uuid, + device_name, + display_name, + last_backup_date, + version, + is_encrypted, + }); + } + } + } + + Ok(backups) + } + + /// Starts a backup operation + /// + /// # Arguments + /// * `target_identifier` - Optional target identifier for the backup + /// * `source_identifier` - Optional source identifier for the backup + /// * `options` - Optional backup options + /// + /// # Returns + /// `Ok(())` on successful backup start + /// + /// # Errors + /// Returns `IdeviceError` if the backup fails to start + pub async fn start_backup( + &mut self, + target_identifier: Option<&str>, + source_identifier: Option<&str>, + options: Option, + ) -> Result<(), IdeviceError> { + self.send_request( + BackupMessageType::BackupMessageTypeBackup.as_str(), + target_identifier, + source_identifier, + options, + ) + .await?; + + let response = self.receive_backup_response().await?; + + // Check for error in response + if let Some(error) = response.get("ErrorCode") { + warn!("Backup start failed with error: {error:?}"); + return Err(IdeviceError::UnexpectedResponse( + "backup start returned ErrorCode".into(), + )); + } + + debug!("Backup started successfully"); + Ok(()) + } + + /// Starts a restore operation + /// + /// # Arguments + /// * `backup_uuid` - UUID of the backup to restore from + /// * `options` - Optional restore options + /// + /// # Returns + /// `Ok(())` on successful restore start + /// + /// # Errors + /// Returns `IdeviceError` if the restore fails to start + #[deprecated( + note = "Use restore_from_path; restore via BackupUUID is not supported by device/mobilebackup2" + )] + pub async fn start_restore( + &mut self, + _backup_uuid: &str, + options: Option, + ) -> Result<(), IdeviceError> { + let mut opts = options.unwrap_or_default(); + // Align default restore options with pymobiledevice semantics + // Caller-specified values (if any) take precedence. + if !opts.contains_key("RestoreShouldReboot") { + opts.insert("RestoreShouldReboot".into(), plist::Value::Boolean(true)); + } + if !opts.contains_key("RestoreDontCopyBackup") { + // pymobiledevice: copy=True -> RestoreDontCopyBackup=False + opts.insert("RestoreDontCopyBackup".into(), plist::Value::Boolean(false)); + } + if !opts.contains_key("RestorePreserveSettings") { + opts.insert( + "RestorePreserveSettings".into(), + plist::Value::Boolean(true), + ); + } + if !opts.contains_key("RestoreSystemFiles") { + opts.insert("RestoreSystemFiles".into(), plist::Value::Boolean(false)); + } + if !opts.contains_key("RemoveItemsNotRestored") { + opts.insert( + "RemoveItemsNotRestored".into(), + plist::Value::Boolean(false), + ); + } + // Avoid borrowing self while sending request + let target_udid_owned = self.idevice.udid().map(|s| s.to_string()); + let target_udid = target_udid_owned.as_deref(); + self.send_request( + BackupMessageType::BackupMessageTypeRestore.as_str(), + // default identifiers to current UDID if available + target_udid, + target_udid, + Some(opts), + ) + .await?; + + let response = self.receive_backup_response().await?; + + // Check for error in response + if let Some(error) = response.get("ErrorCode") { + warn!("Restore start failed with error: {error:?}"); + return Err(IdeviceError::UnexpectedResponse( + "restore start returned ErrorCode".into(), + )); + } + + debug!("Restore started successfully"); + Ok(()) + } + + /// High-level API: Create a backup of the device to a local directory + /// + /// - `backup_root` should point to the backup root directory. The device's backup data + /// will be stored in `backup_root//`. + /// - If `source_identifier` is None, the current connected device's UDID will be used. + /// - The backup directory will be created if it does not exist. + /// + /// Returns the final response dictionary from the device on success. If the device + /// reports an error, the dictionary will contain `ErrorCode` and `ErrorDescription`. + pub async fn backup_from_path( + &mut self, + backup_root: &Path, + source_identifier: Option<&str>, + options: Option, + delegate: &dyn BackupDelegate, + ) -> Result, IdeviceError> { + let target_udid_owned = self.idevice.udid().map(|s| s.to_string()); + let target_udid = target_udid_owned.as_deref(); + let source: &str = match source_identifier { + Some(s) => s, + None => target_udid.ok_or(IdeviceError::InvalidHostID)?, + }; + + // Ensure backup subdirectory exists + let backup_dir = backup_root.join(source); + let _ = delegate.create_dir_all(&backup_dir).await; + + self.send_request( + BackupMessageType::BackupMessageTypeBackup.as_str(), + target_udid, + Some(source), + options, + ) + .await?; + + self.process_dl_loop(backup_root, delegate).await + } + + /// High-level API: Restore from a local backup directory using DeviceLink file exchange + /// + /// - `backup_root` should point to the backup root directory (which contains the `` subdirectory) + /// - If `source_identifier` is None, the current connected device's UDID will be used by default + /// - `options` should be constructed using the `RestoreOptions` builder; if not provided, defaults will be used + pub async fn restore_from_path( + &mut self, + backup_root: &Path, + source_identifier: Option<&str>, + options: Option, + delegate: &dyn BackupDelegate, + ) -> Result, IdeviceError> { + // Take owned UDID to avoid aliasing borrows + let target_udid_owned = self.idevice.udid().map(|s| s.to_string()); + let target_udid = target_udid_owned.as_deref(); + let source: &str = match source_identifier { + Some(s) => s, + None => target_udid.ok_or(IdeviceError::InvalidHostID)?, + }; + + // Simple existence check: backup_root/source must exist + let backup_dir = backup_root.join(source); + if !delegate.exists(&backup_dir).await { + return Err(IdeviceError::NotFound); + } + + let opts = options.unwrap_or_default().to_plist(); + self.send_request( + BackupMessageType::BackupMessageTypeRestore.as_str(), + target_udid, + Some(source), + Some(opts), + ) + .await?; + + self.process_dl_loop(backup_root, delegate).await + } + + /// Processes the DeviceLink message loop used by backup, restore, and other operations. + /// + /// Handles all DL* messages from the device until a `DLMessageProcessMessage` (final + /// status) or `DLMessageDisconnect` is received. + async fn process_dl_loop( + &mut self, + host_dir: &Path, + delegate: &dyn BackupDelegate, + ) -> Result, IdeviceError> { + let mut overall_progress: f64 = -1.0; + loop { + let (tag, value) = self.receive_dl_message().await?; + + // Extract overall progress from DL messages that carry it + if let plist::Value::Array(arr) = &value { + let progress_idx = match tag.as_str() { + "DLMessageUploadFiles" => Some(2), + "DLMessageDownloadFiles" + | "DLMessageMoveFiles" + | "DLMessageMoveItems" + | "DLMessageRemoveFiles" + | "DLMessageRemoveItems" => Some(3), + _ => None, + }; + if let Some(idx) = progress_idx + && let Some(plist::Value::Real(p)) = arr.get(idx) + && *p > 0.0 + { + overall_progress = *p; + } + } + + match tag.as_str() { + "DLMessageDownloadFiles" => { + self.handle_download_files(&value, host_dir, delegate) + .await?; + } + "DLMessageUploadFiles" => { + self.handle_upload_files(&value, host_dir, delegate, overall_progress) + .await?; + } + "DLMessageGetFreeDiskSpace" => { + let freespace = delegate.get_free_disk_space(host_dir); + self.send_status_response( + 0, + None, + Some(plist::Value::Integer(freespace.into())), + ) + .await?; + } + "DLContentsOfDirectory" => { + let listing = Self::list_directory_contents(&value, host_dir, delegate).await; + self.send_status_response(0, None, Some(listing)).await?; + } + "DLMessageCreateDirectory" => { + if let plist::Value::Array(arr) = &value + && let Some(plist::Value::String(dir)) = arr.get(1) + { + debug!("Creating directory: {dir}"); + } + + let status = + Self::create_directory_from_message(&value, host_dir, delegate).await; + self.send_status_response(status, None, None).await?; + } + "DLMessageMoveFiles" | "DLMessageMoveItems" => { + let status = Self::move_files_from_message(&value, host_dir, delegate).await; + self.send_status_response( + status, + None, + Some(plist::Value::Dictionary(Dictionary::new())), + ) + .await?; + } + "DLMessageRemoveFiles" | "DLMessageRemoveItems" => { + let status = Self::remove_files_from_message(&value, host_dir, delegate).await; + self.send_status_response( + status, + None, + Some(plist::Value::Dictionary(Dictionary::new())), + ) + .await?; + } + "DLMessageCopyItem" => { + let status = Self::copy_item_from_message(&value, host_dir, delegate).await; + self.send_status_response( + status, + None, + Some(plist::Value::Dictionary(Dictionary::new())), + ) + .await?; + } + "DLMessageProcessMessage" => { + if let plist::Value::Array(arr) = value + && let Some(plist::Value::Dictionary(dict)) = arr.get(1) + { + return Ok(Some(dict.clone())); + } + return Ok(None); + } + "DLMessageDisconnect" => { + return Ok(None); + } + other => { + warn!("Unsupported DL message: {other}"); + self.send_status_response(-1, Some("Operation not supported"), None) + .await?; + } + } + } + } + + async fn handle_download_files( + &mut self, + dl_value: &plist::Value, + host_dir: &Path, + delegate: &dyn BackupDelegate, + ) -> Result<(), IdeviceError> { + let mut errplist = Dictionary::new(); + if let plist::Value::Array(arr) = dl_value + && arr.len() >= 2 + && let Some(plist::Value::Array(files)) = arr.get(1) + { + for pv in files { + if let Some(path) = pv.as_string() { + debug!("Device requested file: {path}"); + if let Some((code, desc)) = + self.send_single_file(host_dir, path, delegate).await? + { + warn!("Failed to send file {path}: {desc} ({code})"); + let mut entry = Dictionary::new(); + entry.insert("DLFileErrorString".into(), plist::Value::String(desc)); + // `code` is a signed device error (e.g. -6 == ENOENT); pass it + // through as a signed integer so the device reads it correctly. + entry.insert("DLFileErrorCode".into(), plist::Value::Integer(code.into())); + errplist.insert(path.to_string(), plist::Value::Dictionary(entry)); + } + } + } + } + // terminating zero dword + self.idevice.send_raw(&0u32.to_be_bytes()).await?; + if !errplist.is_empty() { + self.send_status_response( + -13, + Some("Multi status"), + Some(plist::Value::Dictionary(errplist)), + ) + .await + } else { + self.send_status_response(0, None, Some(plist::Value::Dictionary(Dictionary::new()))) + .await + } + } + + /// Streams one host file to the device in response to `DLMessageDownloadFiles`. + /// + /// Returns `Ok(None)` when the file was sent successfully, or `Ok(Some((device_err, + /// message)))` when a per-file error was framed to the device (so the caller can + /// record it in the Multi status dictionary). Only a fatal socket error yields + /// `Err`. + async fn send_single_file( + &mut self, + host_dir: &Path, + rel_path: &str, + delegate: &dyn BackupDelegate, + ) -> Result, IdeviceError> { + let full = host_path(host_dir, rel_path); + let path_bytes = rel_path.as_bytes().to_vec(); + let nlen = (path_bytes.len() as u32).to_be_bytes(); + self.idevice.send_raw(&nlen).await?; + self.idevice.send_raw(&path_bytes).await?; + + const DEV_ERR_ENOENT: i64 = -6; + const DEV_ERR_GENERIC: i64 = -1; + + let mut f = match delegate.open_file_read(&full).await { + Ok(f) => f, + Err(e) => { + let desc = e.to_string(); + self.send_file_error(&desc).await?; + return Ok(Some((DEV_ERR_ENOENT, desc))); + } + }; + let mut buf = [0u8; 32768]; + loop { + let read = match f.read(&mut buf) { + Ok(0) => break, + Ok(n) => n, + Err(e) => { + let desc = e.to_string(); + self.send_file_error(&desc).await?; + return Ok(Some((DEV_ERR_GENERIC, desc))); + } + }; + let size = ((read as u32) + 1).to_be_bytes(); + let mut hdr = Vec::with_capacity(5); + hdr.extend_from_slice(&size); + hdr.push(DL_CODE_FILE_DATA); + self.idevice.send_raw(&hdr).await?; + self.idevice.send_raw(&buf[..read]).await?; + } + // success trailer + let mut ok = [0u8; 5]; + ok[..4].copy_from_slice(&1u32.to_be_bytes()); + ok[4] = DL_CODE_SUCCESS; + self.idevice.send_raw(&ok).await?; + Ok(None) + } + + /// Sends a single `DL_CODE_ERROR_LOCAL` block: `[u32 len][0x06][msg]`, where + /// `len` counts the code byte plus the message bytes. + async fn send_file_error(&mut self, desc: &str) -> Result<(), IdeviceError> { + let msg = desc.as_bytes(); + let size = ((msg.len() as u32) + 1).to_be_bytes(); + let mut block = Vec::with_capacity(4 + 1 + msg.len()); + block.extend_from_slice(&size); + block.push(DL_CODE_ERROR_LOCAL); + block.extend_from_slice(msg); + self.idevice.send_raw(&block).await + } + + async fn handle_upload_files( + &mut self, + dl_value: &plist::Value, + host_dir: &Path, + delegate: &dyn BackupDelegate, + overall_progress: f64, + ) -> Result<(), IdeviceError> { + let mut file_count: u32 = 0; + let mut bytes_done: u64 = 0; + + // Extract total expected bytes from DLMessageUploadFiles array index 3 + let bytes_total = if let plist::Value::Array(arr) = dl_value { + arr.get(3) + .and_then(|v| v.as_unsigned_integer()) + .unwrap_or(0) + } else { + 0 + }; + + loop { + // Receive directory name + let dlen = self.read_be_u32().await?; + if dlen == 0 { + break; + } + let _dname = self.read_exact_string(dlen as usize).await?; + + // Receive file name + let flen = self.read_be_u32().await?; + if flen == 0 { + break; + } + let fname = self.read_exact_string(flen as usize).await?; + + let dst = host_path(host_dir, &fname); + if let Some(parent) = dst.parent() { + let _ = delegate.create_dir_all(parent).await; + } + + // Read first code+data block + let mut nlen = self.read_be_u32().await?; + if nlen == 0 { + continue; + } + let mut code = self.read_one().await?; + + // Remove existing file and create new one + let _ = delegate.remove(&dst).await; + let mut file = delegate.create_file_write(&dst).await?; + + // Receive file data blocks + while code == DL_CODE_FILE_DATA { + let block_size = (nlen - 1) as usize; + let data = self.read_exact(block_size).await?; + file.write_all(&data) + .map_err(|e| IdeviceError::InternalError(e.to_string()))?; + bytes_done += block_size as u64; + + // Read next block header + nlen = self.read_be_u32().await?; + if nlen > 0 { + code = self.read_one().await?; + } else { + break; + } + } + + file_count += 1; + delegate.on_file_received(&fname, file_count); + delegate.on_progress(bytes_done, bytes_total, overall_progress); + + // Handle trailing error/status message + if nlen > 0 && code != DL_CODE_FILE_DATA && code != DL_CODE_SUCCESS { + // Consume trailing data (error messages, end-of-file markers) + let _ = self.read_exact((nlen - 1) as usize).await?; + } + } + + debug!("Received {file_count} files from device"); + self.send_status_response(0, None, Some(plist::Value::Dictionary(Dictionary::new()))) + .await + } + + async fn read_be_u32(&mut self) -> Result { + let buf = self.idevice.read_raw(4).await?; + Ok(u32::from_be_bytes([buf[0], buf[1], buf[2], buf[3]])) + } + + async fn read_one(&mut self) -> Result { + let buf = self.idevice.read_raw(1).await?; + Ok(buf[0]) + } + + async fn read_exact(&mut self, size: usize) -> Result, IdeviceError> { + self.idevice.read_raw(size).await + } + + async fn read_exact_string(&mut self, size: usize) -> Result { + let buf = self.idevice.read_raw(size).await?; + Ok(String::from_utf8_lossy(&buf).to_string()) + } + + async fn create_directory_from_message( + dl_value: &plist::Value, + host_dir: &Path, + delegate: &dyn BackupDelegate, + ) -> i64 { + if let plist::Value::Array(arr) = dl_value + && arr.len() >= 2 + && let Some(plist::Value::String(dir)) = arr.get(1) + { + let path = host_path(host_dir, dir); + return match delegate.create_dir_all(&path).await { + Ok(_) => 0, + Err(_) => -1, + }; + } + -1 + } + + async fn move_files_from_message( + dl_value: &plist::Value, + host_dir: &Path, + delegate: &dyn BackupDelegate, + ) -> i64 { + if let plist::Value::Array(arr) = dl_value + && arr.len() >= 2 + && let Some(plist::Value::Dictionary(map)) = arr.get(1) + { + for (from, to_v) in map.iter() { + if let Some(to) = to_v.as_string() { + let old = host_path(host_dir, from); + let newp = host_path(host_dir, to); + if let Some(parent) = newp.parent() { + let _ = delegate.create_dir_all(parent).await; + } + if delegate.rename(&old, &newp).await.is_err() { + return -1; + } + } + } + return 0; + } + -1 + } + + async fn remove_files_from_message( + dl_value: &plist::Value, + host_dir: &Path, + delegate: &dyn BackupDelegate, + ) -> i64 { + if let plist::Value::Array(arr) = dl_value + && arr.len() >= 2 + && let Some(plist::Value::Array(items)) = arr.get(1) + { + for it in items { + if let Some(p) = it.as_string() { + let path = host_path(host_dir, p); + if delegate.exists(&path).await && delegate.remove(&path).await.is_err() { + return -1; + } + } + } + return 0; + } + -1 + } + + async fn copy_item_from_message( + dl_value: &plist::Value, + host_dir: &Path, + delegate: &dyn BackupDelegate, + ) -> i64 { + if let plist::Value::Array(arr) = dl_value + && arr.len() >= 3 + && let (Some(plist::Value::String(src)), Some(plist::Value::String(dst))) = + (arr.get(1), arr.get(2)) + { + let from = host_path(host_dir, src); + let to = host_path(host_dir, dst); + if let Some(parent) = to.parent() { + let _ = delegate.create_dir_all(parent).await; + } + return match delegate.copy(&from, &to).await { + Ok(_) => 0, + Err(_) => -1, + }; + } + -1 + } + + /// Starts a restore using the typed RestoreOptions builder + #[deprecated( + note = "Use restore_from_path; restore via BackupUUID is not supported by device/mobilebackup2" + )] + pub async fn start_restore_with( + &mut self, + _backup_uuid: &str, + opts: RestoreOptions, + ) -> Result<(), IdeviceError> { + let dict = opts.to_plist(); + // Avoid borrowing self during request + let target_udid_owned = self.idevice.udid().map(|s| s.to_string()); + let target_udid = target_udid_owned.as_deref(); + self.send_request( + BackupMessageType::BackupMessageTypeRestore.as_str(), + target_udid, + target_udid, + Some(dict), + ) + .await?; + + let response = self.receive_backup_response().await?; + if let Some(error) = response.get("ErrorCode") { + warn!("Restore start failed with error: {error:?}"); + return Err(IdeviceError::UnexpectedResponse( + "restore start returned ErrorCode".into(), + )); + } + debug!("Restore started successfully"); + Ok(()) + } + + /// Assert a readable backup dir structure exists. + /// + /// `Info.plist` is host-side display metadata and is not required for + /// MobileBackup2 to read a modern backup. The files required for modern + /// backup readability are the status plist plus the manifest plist/database. + async fn assert_backup_exists( + backup_root: &Path, + source: &str, + delegate: &dyn BackupDelegate, + ) -> Result<(), IdeviceError> { + let device_dir = backup_root.join(source); + if delegate.exists(&device_dir.join("Manifest.plist")).await + && delegate.exists(&device_dir.join("Manifest.db")).await + && delegate.exists(&device_dir.join("Status.plist")).await + { + Ok(()) + } else { + Err(IdeviceError::NotFound) + } + } + + /// Assert a backup dir has at least a Manifest.plist (enough for unback/extract) + async fn assert_backup_has_manifest( + backup_root: &Path, + source: &str, + delegate: &dyn BackupDelegate, + ) -> Result<(), IdeviceError> { + let device_dir = backup_root.join(source); + if delegate.exists(&device_dir.join("Manifest.plist")).await { + Ok(()) + } else { + Err(IdeviceError::NotFound) + } + } + + /// Get backup information using DeviceLink against a given backup root/source + pub async fn info_from_path( + &mut self, + backup_root: &Path, + source_identifier: Option<&str>, + delegate: &dyn BackupDelegate, + ) -> Result { + let target_udid = self.idevice.udid(); + let source = source_identifier + .or(target_udid) + .ok_or(IdeviceError::InvalidHostID)?; + Self::assert_backup_exists(backup_root, source, delegate).await?; + + let dict = crate::plist!(dict { + "TargetIdentifier": target_udid.unwrap(), + "SourceIdentifier":? source_identifier, + }); + self.send_device_link_message("Info", Some(dict)).await?; + + match self.process_dl_loop(backup_root, delegate).await? { + Some(res) => Ok(res), + None => Err(IdeviceError::UnexpectedResponse( + "info_from_path DL loop returned no response".into(), + )), + } + } + + /// List last backup contents (returns raw response dictionary) + pub async fn list_from_path( + &mut self, + backup_root: &Path, + source_identifier: Option<&str>, + delegate: &dyn BackupDelegate, + ) -> Result { + let target_udid = self.idevice.udid(); + let source = source_identifier + .or(target_udid) + .ok_or(IdeviceError::InvalidHostID)?; + Self::assert_backup_exists(backup_root, source, delegate).await?; + + let dict = crate::plist!(dict { + "MessageName": "List", + "TargetIdentifier": target_udid.unwrap(), + "SourceIdentifier": source, + }); + self.send_device_link_message("List", Some(dict)).await?; + + match self.process_dl_loop(backup_root, delegate).await? { + Some(res) => Ok(res), + None => Err(IdeviceError::UnexpectedResponse( + "list_from_path DL loop returned no response".into(), + )), + } + } + + /// Unpack a complete backup to the device's original directory hierarchy. + /// + /// The device reads the backup manifest and blobs, reassembles the original + /// files, and streams them back to the host under a `_unback_/` subdirectory. + /// + /// **Note:** Apple broke the Unback command in iOS 10+. The device will accept + /// the request and read the manifest, but then drops the connection before + /// sending any unpacked files. This only works reliably on iOS 9 and earlier. + pub async fn unback_from_path( + &mut self, + backup_root: &Path, + password: Option<&str>, + source_identifier: Option<&str>, + delegate: &dyn BackupDelegate, + ) -> Result<(), IdeviceError> { + let target_udid_owned = self.idevice.udid().map(|s| s.to_string()); + let target_udid = target_udid_owned.as_deref(); + let source: &str = match source_identifier { + Some(s) => s, + None => target_udid.ok_or(IdeviceError::InvalidHostID)?, + }; + Self::assert_backup_has_manifest(backup_root, source, delegate).await?; + + let opts = password.map(|pw| crate::plist!(dict { "Password": pw })); + self.send_request("Unback", target_udid, Some(source), opts) + .await?; + let _ = self.process_dl_loop(backup_root, delegate).await?; + Ok(()) + } + + /// Extract a single file from a previous backup + pub async fn extract_from_path( + &mut self, + domain_name: &str, + relative_path: &str, + backup_root: &Path, + password: Option<&str>, + source_identifier: Option<&str>, + delegate: &dyn BackupDelegate, + ) -> Result<(), IdeviceError> { + let target_udid = self.idevice.udid(); + let source = source_identifier + .or(target_udid) + .ok_or(IdeviceError::InvalidHostID)?; + Self::assert_backup_has_manifest(backup_root, source, delegate).await?; + let dict = crate::plist!(dict { + "MessageName": "Extract", + "TargetIdentifier": target_udid.unwrap(), + "DomainName": domain_name, + "RelativePath": relative_path, + "SourceIdentifier": source, + "Password":? password, + }); + self.send_device_link_message("Extract", Some(dict)).await?; + let _ = self.process_dl_loop(backup_root, delegate).await?; + Ok(()) + } + + /// Change backup password (enable/disable if new/old missing) + pub async fn change_password_from_path( + &mut self, + backup_root: &Path, + old: Option<&str>, + new: Option<&str>, + delegate: &dyn BackupDelegate, + ) -> Result<(), IdeviceError> { + let target_udid = self.idevice.udid(); + let dict = crate::plist!(dict { + "MessageName": "ChangePassword", + "TargetIdentifier": target_udid.ok_or(IdeviceError::InvalidHostID)?, + "OldPassword":? old, + "NewPassword":? new + }); + self.send_device_link_message("ChangePassword", Some(dict)) + .await?; + let _ = self.process_dl_loop(backup_root, delegate).await?; + Ok(()) + } + + /// Erase device via mobilebackup2 + pub async fn erase_device_from_path( + &mut self, + backup_root: &Path, + delegate: &dyn BackupDelegate, + ) -> Result<(), IdeviceError> { + let target_udid = self.idevice.udid(); + let dict = crate::plist!(dict { + "MessageName": "EraseDevice", + "TargetIdentifier": target_udid.ok_or(IdeviceError::InvalidHostID)? + }); + self.send_device_link_message("EraseDevice", Some(dict)) + .await?; + let _ = self.process_dl_loop(backup_root, delegate).await?; + Ok(()) + } + + /// Gets free space information from the device + /// + /// # Returns + /// Free space in bytes + /// + /// # Errors + /// Returns `IdeviceError` if the request fails + pub async fn get_freespace(&mut self) -> Result { + // Not a valid host-initiated request in protocol; device asks via DLMessageGetFreeDiskSpace + Err(IdeviceError::UnexpectedResponse( + "get_freespace is not a valid host-initiated request".into(), + )) + } + + /// Checks if backup encryption is enabled + /// + /// # Returns + /// `true` if backup encryption is enabled, `false` otherwise + /// + /// # Errors + /// Returns `IdeviceError` if the request fails + pub async fn check_backup_encryption(&mut self) -> Result { + // Not part of host-initiated MB2 protocol; caller should inspect Manifest/lockdown + Err(IdeviceError::UnexpectedResponse( + "check_backup_encryption is not a valid host-initiated request".into(), + )) + } + + /// Lists the contents of a directory referenced in a `DLContentsOfDirectory` message. + async fn list_directory_contents( + dl_value: &plist::Value, + host_dir: &Path, + delegate: &dyn BackupDelegate, + ) -> plist::Value { + let mut dirlist = Dictionary::new(); + + let rel_path = if let plist::Value::Array(arr) = dl_value + && arr.len() >= 2 + && let Some(plist::Value::String(dir)) = arr.get(1) + { + dir.clone() + } else { + return plist::Value::Dictionary(dirlist); + }; + + let full_path = host_path(host_dir, &rel_path); + if let Ok(entries) = delegate.list_dir(&full_path).await { + for entry in entries { + let mut fdict = Dictionary::new(); + let ftype = if entry.is_dir { + "DLFileTypeDirectory" + } else if entry.is_file { + "DLFileTypeRegular" + } else { + "DLFileTypeUnknown" + }; + fdict.insert("DLFileType".into(), plist::Value::String(ftype.into())); + fdict.insert( + "DLFileSize".into(), + plist::Value::Integer(entry.size.into()), + ); + if let Some(mtime) = entry.modified { + fdict.insert( + "DLFileModificationDate".into(), + plist::Value::Date(mtime.into()), + ); + } + dirlist.insert(entry.name, plist::Value::Dictionary(fdict)); + } + } + + plist::Value::Dictionary(dirlist) + } + + /// Disconnects from the backup service + /// + /// # Returns + /// `Ok(())` on successful disconnection + /// + /// # Errors + /// Returns `IdeviceError` if disconnection fails + pub async fn disconnect(&mut self) -> Result<(), IdeviceError> { + // Send DLMessageDisconnect array per DeviceLink protocol + let arr = crate::plist!(array [ + "DLMessageDisconnect", + "___EmptyParameterString___" + ]); + self.send_dl_array(arr).await?; + debug!("Disconnected from backup service"); + Ok(()) + } +} + +#[cfg(feature = "rsd")] +impl crate::RsdService for MobileBackup2Client { + fn rsd_service_name() -> std::borrow::Cow<'static, str> { + crate::obf!("com.apple.mobilebackup2.shim.remote") + } + async fn from_stream(stream: Box) -> Result { + let mut idevice = crate::Idevice::new(stream, ""); + idevice.rsd_checkin().await?; + let mut client = Self::new(idevice); + client.dl_version_exchange().await?; + client.version_exchange().await?; + Ok(client) + } +} + +#[cfg(all(test, any(unix, windows), not(target_arch = "wasm32")))] +mod tests { + use super::*; + + #[test] + fn fs_delegate_reports_real_free_space() { + let free = FsBackupDelegate.get_free_disk_space(Path::new(".")); + assert!(free > 0, "expected non-zero free space, got {free}"); + } + + #[test] + fn fs_delegate_free_space_walks_up_to_existing_ancestor() { + let free = + FsBackupDelegate.get_free_disk_space(Path::new("./does/not/exist/anywhere/right/now")); + assert!(free > 0, "should resolve an existing ancestor, got {free}"); + } +} diff --git a/src-tauri/vendor/idevice/src/services/mod.rs b/src-tauri/vendor/idevice/src/services/mod.rs new file mode 100644 index 0000000..2c267c3 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/mod.rs @@ -0,0 +1,63 @@ +#[cfg(feature = "afc")] +pub mod afc; +#[cfg(feature = "amfi")] +pub mod amfi; +#[cfg(feature = "bt_packet_logger")] +pub mod bt_packet_logger; +#[cfg(feature = "companion_proxy")] +pub mod companion_proxy; +#[cfg(feature = "core_device")] +pub mod core_device; +#[cfg(feature = "core_device_proxy")] +pub mod core_device_proxy; +#[cfg(feature = "crashreportcopymobile")] +pub mod crashreportcopymobile; +#[cfg(feature = "debug_proxy")] +pub mod debug_proxy; +#[cfg(feature = "diagnostics_relay")] +pub mod diagnostics_relay; +#[cfg(feature = "dvt")] +pub mod dvt; +#[cfg(feature = "heartbeat")] +pub mod heartbeat; +#[cfg(feature = "house_arrest")] +pub mod house_arrest; +#[cfg(feature = "installation_proxy")] +pub mod installation_proxy; +#[cfg(feature = "installcoordination_proxy")] +pub mod installcoordination_proxy; +pub mod lockdown; +#[cfg(feature = "misagent")] +pub mod misagent; +#[cfg(feature = "mobile_image_mounter")] +pub mod mobile_image_mounter; +#[cfg(feature = "mobileactivationd")] +pub mod mobileactivationd; +#[cfg(feature = "mobilebackup2")] +pub mod mobilebackup2; +#[cfg(feature = "notification_proxy")] +pub mod notification_proxy; +#[cfg(feature = "syslog_relay")] +pub mod os_trace_relay; +#[cfg(feature = "pcapd")] +pub mod pcapd; +#[cfg(feature = "preboard_service")] +pub mod preboard_service; +#[cfg(any(feature = "restore", feature = "preboard_service"))] +pub mod restore; +#[cfg(feature = "restore_service")] +pub mod restore_service; +#[cfg(feature = "rsd")] +pub mod rsd; +#[cfg(feature = "screenshotr")] +pub mod screenshotr; +#[cfg(feature = "location_simulation")] +pub mod simulate_location; +#[cfg(feature = "springboardservices")] +pub mod springboardservices; +#[cfg(feature = "syslog_relay")] +pub mod syslog_relay; +#[cfg(feature = "wda")] +pub mod wda; +#[cfg(feature = "wda")] +pub mod wda_bridge; diff --git a/src-tauri/vendor/idevice/src/services/notification_proxy.rs b/src-tauri/vendor/idevice/src/services/notification_proxy.rs new file mode 100644 index 0000000..a3791c1 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/notification_proxy.rs @@ -0,0 +1,228 @@ +//! iOS Device Notification Proxy Service +//! +//! Based on libimobiledevice's notification_proxy implementation +//! +//! Common notification identifiers: +//! Full list: include/libimobiledevice/notification_proxy.h +//! +//! - Notifications that can be sent (PostNotification): +//! - `com.apple.itunes-mobdev.syncWillStart` - Sync will start +//! - `com.apple.itunes-mobdev.syncDidStart` - Sync started +//! - `com.apple.itunes-mobdev.syncDidFinish` - Sync finished +//! - `com.apple.itunes-mobdev.syncLockRequest` - Request sync lock +//! +//! - Notifications that can be observed (ObserveNotification): +//! - `com.apple.itunes-client.syncCancelRequest` - Cancel sync request +//! - `com.apple.itunes-client.syncSuspendRequest` - Suspend sync +//! - `com.apple.itunes-client.syncResumeRequest` - Resume sync +//! - `com.apple.mobile.lockdown.phone_number_changed` - Phone number changed +//! - `com.apple.mobile.lockdown.device_name_changed` - Device name changed +//! - `com.apple.mobile.lockdown.timezone_changed` - Timezone changed +//! - `com.apple.mobile.lockdown.trusted_host_attached` - Trusted host attached +//! - `com.apple.mobile.lockdown.host_detached` - Host detached +//! - `com.apple.mobile.lockdown.host_attached` - Host attached +//! - `com.apple.mobile.lockdown.registration_failed` - Registration failed +//! - `com.apple.mobile.lockdown.activation_state` - Activation state +//! - `com.apple.mobile.lockdown.brick_state` - Brick state +//! - `com.apple.mobile.lockdown.disk_usage_changed` - Disk usage (iOS 4.0+) +//! - `com.apple.mobile.data_sync.domain_changed` - Data sync domain changed +//! - `com.apple.mobile.application_installed` - App installed +//! - `com.apple.mobile.application_uninstalled` - App uninstalled + +use std::pin::Pin; + +use futures::Stream; +use tracing::warn; + +use crate::{HeartbeatError, Idevice, IdeviceError, IdeviceService, obf}; + +/// Client for interacting with the iOS notification proxy service +/// +/// The notification proxy service provides a mechanism to observe and post +/// system notifications. +/// +/// Use `observe_notification` to register for events, then `receive_notification` +/// to wait for them. +#[derive(Debug)] +pub struct NotificationProxyClient { + /// The underlying device connection with established notification_proxy service + pub idevice: Idevice, +} + +impl IdeviceService for NotificationProxyClient { + /// Returns the notification proxy service name as registered with lockdownd + fn service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.mobile.notification_proxy") + } + async fn from_stream(idevice: Idevice) -> Result { + Ok(Self::new(idevice)) + } +} + +impl NotificationProxyClient { + /// Creates a new notification proxy client from an existing device connection + /// + /// # Arguments + /// * `idevice` - Pre-established device connection + pub fn new(idevice: Idevice) -> Self { + Self { idevice } + } + + /// Posts a notification to the device + /// + /// # Arguments + /// * `notification_name` - Name of the notification to post + /// + /// # Errors + /// Returns `IdeviceError` if the notification fails to send + pub async fn post_notification( + &mut self, + notification_name: impl Into, + ) -> Result<(), IdeviceError> { + let request = crate::plist!({ + "Command": "PostNotification", + "Name": notification_name.into() + }); + self.idevice.send_plist(request).await + } + + /// Registers to observe a specific notification + /// + /// After calling this, use `receive_notification` to wait for events. + /// + /// # Arguments + /// * `notification_name` - Name of the notification to observe + /// + /// # Errors + /// Returns `IdeviceError` if the registration fails + pub async fn observe_notification( + &mut self, + notification_name: impl Into, + ) -> Result<(), IdeviceError> { + let request = crate::plist!({ + "Command": "ObserveNotification", + "Name": notification_name.into() + }); + self.idevice.send_plist(request).await + } + + /// Registers to observe multiple notifications at once + /// + /// # Arguments + /// * `notification_names` - Slice of notification names to observe + /// + /// # Errors + /// Returns `IdeviceError` if any registration fails + pub async fn observe_notifications( + &mut self, + notification_names: &[&str], + ) -> Result<(), IdeviceError> { + for name in notification_names { + self.observe_notification(*name).await?; + } + Ok(()) + } + + /// Waits for and receives the next notification from the device + /// + /// # Returns + /// The name of the received notification + /// + /// # Errors + /// - `NotificationProxyDeath` if the proxy connection died + /// - `UnexpectedResponse` if the response format is invalid + pub async fn receive_notification(&mut self) -> Result { + let response = self.idevice.read_plist().await?; + + match response.get("Command").and_then(|c| c.as_string()) { + Some("RelayNotification") => match response.get("Name").and_then(|n| n.as_string()) { + Some(name) => Ok(name.to_string()), + None => Err(IdeviceError::UnexpectedResponse( + "missing Name in RelayNotification".into(), + )), + }, + Some("ProxyDeath") => { + warn!("NotificationProxy died!"); + Err(IdeviceError::NotificationProxyDeath) + } + _ => Err(IdeviceError::UnexpectedResponse( + "unexpected Command in notification response".into(), + )), + } + } + + /// Waits for a notification with a timeout + /// + /// # Arguments + /// * `interval` - Timeout in seconds to wait for a notification + /// + /// # Returns + /// The name of the received notification + /// + /// # Errors + /// - `NotificationProxyDeath` if the proxy connection died + /// - `UnexpectedResponse` if the response format is invalid + /// - `HeartbeatTimeout` if no notification received before interval + pub async fn receive_notification_with_timeout( + &mut self, + interval: u64, + ) -> Result { + tokio::select! { + result = self.receive_notification() => result, + _ = crate::time::sleep(std::time::Duration::from_secs(interval)) => { + Err(HeartbeatError::Timeout.into()) + } + } + } + + /// Continuous stream of notifications. + pub fn into_stream( + mut self, + ) -> Pin> + Send>> { + Box::pin(async_stream::try_stream! { + loop { + let response = self.idevice.read_plist().await?; + + match response.get("Command").and_then(|c| c.as_string()) { + Some("RelayNotification") => { + match response.get("Name").and_then(|n| n.as_string()) { + Some(name) => yield name.to_string(), + None => Err(IdeviceError::UnexpectedResponse("missing Name in RelayNotification stream".into()))?, + } + } + Some("ProxyDeath") => { + warn!("NotificationProxy died!"); + Err(IdeviceError::NotificationProxyDeath)?; + } + _ => Err(IdeviceError::UnexpectedResponse("unexpected Command in notification stream".into()))?, + } + } + }) + } + + /// Shuts down the notification proxy connection + /// + /// # Errors + /// Returns `IdeviceError` if the shutdown command fails to send + pub async fn shutdown(&mut self) -> Result<(), IdeviceError> { + let request = crate::plist!({ + "Command": "Shutdown" + }); + self.idevice.send_plist(request).await?; + // Best-effort: wait for ProxyDeath ack + let _ = self.idevice.read_plist().await; + Ok(()) + } +} + +#[cfg(feature = "rsd")] +impl crate::RsdService for NotificationProxyClient { + fn rsd_service_name() -> std::borrow::Cow<'static, str> { + crate::obf!("com.apple.mobile.notification_proxy.shim.remote") + } + async fn from_stream(stream: Box) -> Result { + let mut idevice = crate::Idevice::new(stream, ""); + idevice.rsd_checkin().await?; + Ok(Self::new(idevice)) + } +} diff --git a/src-tauri/vendor/idevice/src/services/os_trace_relay.rs b/src-tauri/vendor/idevice/src/services/os_trace_relay.rs new file mode 100644 index 0000000..25de144 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/os_trace_relay.rs @@ -0,0 +1,363 @@ +//! iOS Device OsTraceRelay Service Abstraction +//! Note that there are unknown fields that will hopefully be filled in the future. +//! Huge thanks to pymobiledevice3 for the struct implementation +//! https://github.com/doronz88/pymobiledevice3/blob/master/pymobiledevice3/services/os_trace.py + +use chrono::{DateTime, NaiveDateTime}; +use serde::{Deserialize, Serialize}; +use tokio::io::AsyncWriteExt; + +use crate::{Idevice, IdeviceError, IdeviceService, obf}; + +/// Client for interacting with the iOS device OsTraceRelay service +#[derive(Debug)] +pub struct OsTraceRelayClient { + /// The underlying device connection with established OsTraceRelay service + pub idevice: Idevice, +} + +impl IdeviceService for OsTraceRelayClient { + /// Returns the OsTraceRelay service name as registered with lockdownd + fn service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.os_trace_relay") + } + + async fn from_stream(idevice: Idevice) -> Result { + Ok(Self { idevice }) + } +} + +/// An initialized client for receiving logs +#[derive(Debug)] +pub struct OsTraceRelayReceiver { + inner: OsTraceRelayClient, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct OsTraceLog { + pub pid: u32, + pub timestamp: NaiveDateTime, + pub level: LogLevel, + pub image_name: String, + pub filename: String, + pub message: String, + pub label: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct SyslogLabel { + pub subsystem: String, + pub category: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Copy)] +pub enum LogLevel { + Notice = 0, + Info = 1, + Debug = 2, + Error = 10, + Fault = 11, +} + +impl OsTraceRelayClient { + /// Starts the stream of logs from the relay + /// + /// # Arguments + /// * `pid` - An optional pid to stream logs from + pub async fn start_trace( + mut self, + pid: Option, + ) -> Result { + let pid = match pid { + Some(p) => p as i64, + None => -1, + }; + let req = crate::plist!({ + "Request": "StartActivity", + "Pid": pid, + "MessageFilter": 65_535, + "StreamFlags": 60 + }); + + self.idevice.send_bplist(req).await?; + + // Read a single byte + self.idevice.read_raw(1).await?; + + // Result + let res = self.idevice.read_plist().await?; + + match res.get("Status").and_then(|x| x.as_string()) { + Some(r) => { + if r == "RequestSuccessful" { + Ok(OsTraceRelayReceiver { inner: self }) + } else { + Err(IdeviceError::UnexpectedResponse( + "Status was not RequestSuccessful in StartActivity response".into(), + )) + } + } + None => Err(IdeviceError::UnexpectedResponse( + "missing Status in StartActivity response".into(), + )), + } + } + + /// Get the list of available PIDs + pub async fn get_pid_list(&mut self) -> Result, IdeviceError> { + let req = crate::plist!({ + "Request": "PidList" + }); + + self.idevice.send_bplist(req).await?; + + // Read a single byte + self.idevice.read_raw(1).await?; + + // Result + let res = self.idevice.read_plist().await?; + + // Device returns { "Payload": { "": { "ProcessName": "..." }, ... } } + // where the PIDs are the string keys of the Payload dictionary. + if let Some(payload) = res.get("Payload").and_then(|x| x.as_dictionary()) { + payload + .keys() + .map(|k| { + k.parse::().map_err(|_| { + IdeviceError::UnexpectedResponse(format!( + "PidList Payload key is not a valid PID: {k}" + )) + }) + }) + .collect() + } else { + Err(IdeviceError::UnexpectedResponse( + "missing Payload dictionary in PidList response".into(), + )) + } + } + + /// Create a log archive and write it to the provided writer + pub async fn create_archive( + &mut self, + out: &mut W, + size_limit: Option, + age_limit: Option, + start_time: Option, + ) -> Result<(), IdeviceError> { + let req = crate::plist!({ + "Request": "CreateArchive", + "SizeLimit":? size_limit, + "AgeLimit":? age_limit, + "StartTime":? start_time, + }); + + self.idevice.send_bplist(req).await?; + + // Read a single byte + if self.idevice.read_raw(1).await?[0] != 1 { + return Err(IdeviceError::UnexpectedResponse( + "expected leading byte 0x01 in CreateArchive response".into(), + )); + } + + // Check status + let res = self.idevice.read_plist().await?; + match res.get("Status").and_then(|x| x.as_string()) { + Some("RequestSuccessful") => {} + _ => { + return Err(IdeviceError::UnexpectedResponse( + "Status was not RequestSuccessful in CreateArchive response".into(), + )); + } + } + + // Read archive data + loop { + match self.idevice.read_raw(1).await { + Ok(data) if data[0] == 0x03 => { + let length_bytes = self.idevice.read_raw(4).await?; + let length = u32::from_le_bytes([ + length_bytes[0], + length_bytes[1], + length_bytes[2], + length_bytes[3], + ]); + let data = self.idevice.read_raw(length as usize).await?; + out.write_all(&data).await?; + } + Err(IdeviceError::Socket(_)) => break, + _ => { + return Err(IdeviceError::UnexpectedResponse( + "unexpected data format in archive stream".into(), + )); + } + } + } + + Ok(()) + } +} + +impl OsTraceRelayReceiver { + /// Get the next log from the relay + /// + /// # Returns + /// A string containing the log + /// + /// # Errors + /// UnexpectedResponse if the service sends an EOF + pub async fn next(&mut self) -> Result { + // Read 0x02, at the beginning of each packet + if self.inner.idevice.read_raw(1).await?[0] != 0x02 { + return Err(IdeviceError::UnexpectedResponse( + "expected leading byte 0x02 at start of log packet".into(), + )); + } + + // Read the len of the packet + let pl = self.inner.idevice.read_raw(4).await?; + let packet_length = u32::from_le_bytes([pl[0], pl[1], pl[2], pl[3]]); + + let packet = self.inner.idevice.read_raw(packet_length as usize).await?; + + // 9 bytes of padding + let packet = &packet[9..]; + + // Parse PID (4 bytes) + let pid = u32::from_le_bytes([packet[0], packet[1], packet[2], packet[3]]); + let packet = &packet[4..]; + + // Skip 42 unknown bytes + let packet = &packet[42..]; + + // Parse timestamp (seconds + microseconds) + let seconds = u32::from_le_bytes([packet[0], packet[1], packet[2], packet[3]]); + let packet = &packet[8..]; // skip 4 bytes padding after seconds + let microseconds = u32::from_le_bytes([packet[0], packet[1], packet[2], packet[3]]); + let packet = &packet[4..]; + + // Skip 1 byte padding + let packet = &packet[1..]; + + // Parse log level + let log_level = packet[0]; + let log_level: LogLevel = log_level.try_into()?; + let packet = &packet[1..]; + + // Skip 38 unknown bytes + let packet = &packet[38..]; + + // Parse string sizes + let image_name_size = u16::from_le_bytes([packet[0], packet[1]]) as usize; + let packet = &packet[2..]; + let message_size = u16::from_le_bytes([packet[0], packet[1]]) as usize; + let packet = &packet[2..]; + + // Skip 6 bytes + let packet = &packet[6..]; + + // Parse subsystem and category sizes + let subsystem_size = + u32::from_le_bytes([packet[0], packet[1], packet[2], packet[3]]) as usize; + let packet = &packet[4..]; + let category_size = + u32::from_le_bytes([packet[0], packet[1], packet[2], packet[3]]) as usize; + let packet = &packet[4..]; + + // Skip 4 bytes + let packet = &packet[4..]; + + // Parse filename (null-terminated string) + let filename_end = + packet + .iter() + .position(|&b| b == 0) + .ok_or(IdeviceError::UnexpectedResponse( + "filename not null-terminated in log packet".into(), + ))?; + let filename = String::from_utf8_lossy(&packet[..filename_end]).into_owned(); + let packet = &packet[filename_end + 1..]; + + // Parse image name + let image_name_bytes = &packet[..image_name_size]; + let image_name = + String::from_utf8_lossy(&image_name_bytes[..image_name_bytes.len() - 1]).into_owned(); + let packet = &packet[image_name_size..]; + + // Parse message + let message_bytes = &packet[..message_size]; + let message = + String::from_utf8_lossy(&message_bytes[..message_bytes.len() - 1]).into_owned(); + let packet = &packet[message_size..]; + + // Parse label if subsystem and category exist + let label = if subsystem_size > 0 && category_size > 0 && !packet.is_empty() { + let subsystem_bytes = &packet[..subsystem_size]; + let subsystem = + String::from_utf8_lossy(&subsystem_bytes[..subsystem_bytes.len() - 1]).into_owned(); + let packet = &packet[subsystem_size..]; + + let category_bytes = &packet[..category_size]; + let category = + String::from_utf8_lossy(&category_bytes[..category_bytes.len() - 1]).into_owned(); + + Some(SyslogLabel { + subsystem, + category, + }) + } else { + None + }; + + let timestamp = match DateTime::from_timestamp(seconds as i64, microseconds) { + Some(t) => t.naive_local(), + None => { + return Err(IdeviceError::UnexpectedResponse( + "invalid timestamp in log packet".into(), + )); + } + }; + + Ok(OsTraceLog { + pid, + timestamp, + level: log_level, + image_name, + filename, + message, + label, + }) + } +} + +impl TryFrom for LogLevel { + type Error = IdeviceError; + + fn try_from(value: u8) -> Result { + Ok(match value { + 0 => Self::Notice, + 1 => Self::Info, + 2 => Self::Debug, + 0x10 => Self::Error, + 0x11 => Self::Fault, + _ => { + return Err(IdeviceError::UnexpectedResponse( + "unknown log level byte value".into(), + )); + } + }) + } +} + +#[cfg(feature = "rsd")] +impl crate::RsdService for OsTraceRelayClient { + fn rsd_service_name() -> std::borrow::Cow<'static, str> { + crate::obf!("com.apple.os_trace_relay.shim.remote") + } + async fn from_stream(stream: Box) -> Result { + let mut idevice = crate::Idevice::new(stream, ""); + idevice.rsd_checkin().await?; + Ok(Self { idevice }) + } +} diff --git a/src-tauri/vendor/idevice/src/services/pcapd.rs b/src-tauri/vendor/idevice/src/services/pcapd.rs new file mode 100644 index 0000000..6b9e034 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/pcapd.rs @@ -0,0 +1,268 @@ +//! Abstraction for pcapd +//! Note that this service only works over USB or through RSD. + +use plist::Value; +use tokio::io::AsyncWrite; +use tokio::io::AsyncWriteExt; + +use crate::{Idevice, IdeviceError, IdeviceService, RsdService, obf}; + +const ETHERNET_HEADER: &[u8] = &[ + 0xBE, 0xEF, 0xBE, 0xEF, 0xBE, 0xEF, 0xBE, 0xEF, 0xBE, 0xEF, 0xBE, 0xEF, 0x08, 0x00, +]; + +/// Client for interacting with the pcapd service on the device. +/// Note that this service only works over USB or through RSD. +#[derive(Debug)] +pub struct PcapdClient { + /// The underlying device connection with established service + pub idevice: Idevice, +} + +impl IdeviceService for PcapdClient { + fn service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.pcapd") + } + + async fn from_stream(idevice: Idevice) -> Result { + Ok(Self::new(idevice)) + } +} + +impl RsdService for PcapdClient { + fn rsd_service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.pcapd.shim.remote") + } + + async fn from_stream(stream: Box) -> Result { + let mut idevice = Idevice::new(stream, ""); + idevice.rsd_checkin().await?; + Ok(Self::new(idevice)) + } +} + +/// A Rust representation of the iOS pcapd device packet header and data. +#[derive(Debug, Clone)] +pub struct DevicePacket { + pub header_length: u32, + pub header_version: u8, + pub packet_length: u32, + pub interface_type: u8, + pub unit: u16, + pub io: u8, + pub protocol_family: u32, + pub frame_pre_length: u32, + pub frame_post_length: u32, + pub interface_name: String, + pub pid: u32, + pub comm: String, + pub svc: u32, + pub epid: u32, + pub ecomm: String, + pub seconds: u32, + pub microseconds: u32, + pub data: Vec, +} + +impl PcapdClient { + pub fn new(idevice: Idevice) -> Self { + Self { idevice } + } + + pub async fn next_packet(&mut self) -> Result { + let packet = self.idevice.read_plist_value().await?; + let packet = match packet { + Value::Data(p) => p, + _ => { + return Err(IdeviceError::UnexpectedResponse( + "expected Data plist value for packet".into(), + )); + } + }; + let mut packet = DevicePacket::from_vec(&packet)?; + packet.normalize_data(); + Ok(packet) + } +} + +impl DevicePacket { + /// Normalizes the packet data by adding a fake Ethernet header if necessary. + /// This is required for tools like Wireshark to correctly dissect raw IP packets. + pub fn normalize_data(&mut self) { + if self.frame_pre_length == 0 { + // Prepend the fake ethernet header for raw IP packets. + let mut new_data = ETHERNET_HEADER.to_vec(); + new_data.append(&mut self.data); + self.data = new_data; + } else if self.interface_name.starts_with("pdp_ip") { + // For cellular interfaces, skip the first 4 bytes of the original data + // before prepending the header. + if self.data.len() >= 4 { + let mut new_data = ETHERNET_HEADER.to_vec(); + new_data.extend_from_slice(&self.data[4..]); + self.data = new_data; + } + } + } + + /// Parses a byte vector into a DevicePacket. + /// + /// This is the primary method for creating a struct from the raw data + /// received from the device. + /// + /// # Arguments + /// * `bytes` - A `Vec` containing the raw bytes of a single packet frame. + /// + /// # Returns + /// A `Result` containing the parsed `DevicePacket` + pub fn from_vec(bytes: &[u8]) -> Result { + let mut r = ByteReader::new(bytes); + + // --- Parse Header --- + let header_length = r.read_u32_be()?; + let header_version = r.read_u8()?; + let packet_length = r.read_u32_be()?; + let interface_type = r.read_u8()?; + let unit = r.read_u16_be()?; + let io = r.read_u8()?; + let protocol_family = r.read_u32_be()?; + let frame_pre_length = r.read_u32_be()?; + let frame_post_length = r.read_u32_be()?; + let interface_name = r.read_cstr(16)?; + let pid = r.read_u32_le()?; // Little Endian + let comm = r.read_cstr(17)?; + let svc = r.read_u32_be()?; + let epid = r.read_u32_le()?; // Little Endian + let ecomm = r.read_cstr(17)?; + let seconds = r.read_u32_be()?; + let microseconds = r.read_u32_be()?; + + // --- Extract Packet Data --- + // The data starts at an absolute offset defined by `header_length`. + let data_start = header_length as usize; + let data_end = data_start.saturating_add(packet_length as usize); + + if data_end > bytes.len() { + return Err(IdeviceError::NotEnoughBytes(bytes.len(), data_end)); + } + let data = bytes[data_start..data_end].to_vec(); + + Ok(DevicePacket { + header_length, + header_version, + packet_length, + interface_type, + unit, + io, + protocol_family, + frame_pre_length, + frame_post_length, + interface_name, + pid, + comm, + svc, + epid, + ecomm, + seconds, + microseconds, + data, + }) + } +} + +/// A helper struct to safely read from a byte slice. +struct ByteReader<'a> { + slice: &'a [u8], + cursor: usize, +} + +impl<'a> ByteReader<'a> { + fn new(slice: &'a [u8]) -> Self { + Self { slice, cursor: 0 } + } + + /// Reads an exact number of bytes and advances the cursor. + fn read_exact(&mut self, len: usize) -> Result<&'a [u8], IdeviceError> { + let end = self + .cursor + .checked_add(len) + .ok_or(IdeviceError::IntegerOverflow)?; + if end > self.slice.len() { + return Err(IdeviceError::NotEnoughBytes(len, self.slice.len())); + } + let result = &self.slice[self.cursor..end]; + self.cursor = end; + Ok(result) + } + + fn read_u8(&mut self) -> Result { + self.read_exact(1).map(|s| s[0]) + } + + fn read_u16_be(&mut self) -> Result { + self.read_exact(2) + .map(|s| u16::from_be_bytes(s.try_into().unwrap())) + } + + fn read_u32_be(&mut self) -> Result { + self.read_exact(4) + .map(|s| u32::from_be_bytes(s.try_into().unwrap())) + } + + fn read_u32_le(&mut self) -> Result { + self.read_exact(4) + .map(|s| u32::from_le_bytes(s.try_into().unwrap())) + } + + /// Reads a fixed-size, null-padded C-style string. + fn read_cstr(&mut self, len: usize) -> Result { + let buffer = self.read_exact(len)?; + let end = buffer.iter().position(|&b| b == 0).unwrap_or(len); + String::from_utf8(buffer[..end].to_vec()).map_err(IdeviceError::Utf8) + } +} + +/// A writer for creating `.pcap` files from DevicePackets without external dependencies. +#[derive(Debug)] +pub struct PcapFileWriter { + writer: W, +} + +impl PcapFileWriter { + /// Creates a new writer and asynchronously writes the pcap global header. + pub async fn new(mut writer: W) -> Result { + // Correct pcap global header for LINKTYPE_ETHERNET. + // We use big-endian format, as is traditional. + let header = [ + 0xa1, 0xb2, 0xc3, 0xd4, // magic number (big-endian) + 0x00, 0x02, // version_major + 0x00, 0x04, // version_minor + 0x00, 0x00, 0x00, 0x00, // thiszone (GMT) + 0x00, 0x00, 0x00, 0x00, // sigfigs (accuracy) + 0x00, 0x04, 0x00, 0x00, // snaplen (max packet size, 262144) + 0x00, 0x00, 0x00, 0x01, // network (LINKTYPE_ETHERNET) + ]; + writer.write_all(&header).await?; + Ok(Self { writer }) + } + + /// Asynchronously writes a single DevicePacket to the pcap file. + pub async fn write_packet(&mut self, packet: &DevicePacket) -> Result<(), std::io::Error> { + let mut record_header = [0u8; 16]; + + // Use the packet's own timestamp for accuracy. + record_header[0..4].copy_from_slice(&packet.seconds.to_be_bytes()); + record_header[4..8].copy_from_slice(&packet.microseconds.to_be_bytes()); + + // incl_len and orig_len + let len_bytes = (packet.data.len() as u32).to_be_bytes(); + record_header[8..12].copy_from_slice(&len_bytes); + record_header[12..16].copy_from_slice(&len_bytes); + + // Write the record header and packet data sequentially. + self.writer.write_all(&record_header).await?; + self.writer.write_all(&packet.data).await?; + + Ok(()) + } +} diff --git a/src-tauri/vendor/idevice/src/services/preboard_service.rs b/src-tauri/vendor/idevice/src/services/preboard_service.rs new file mode 100644 index 0000000..11b85cf --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/preboard_service.rs @@ -0,0 +1,169 @@ +//! Abstraction for preboard + +use std::time::Duration; + +use plist::Value; +use tracing::{info, warn}; + +use crate::{Idevice, IdeviceError, IdeviceService, RsdService, obf, restore::RestoreError}; + +/// Client for interacting with the preboard service on the device. +#[derive(Debug)] +pub struct PreboardServiceClient { + /// The underlying device connection with established service + pub idevice: Idevice, +} + +impl IdeviceService for PreboardServiceClient { + fn service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.preboardservice_v2") + } + + async fn from_stream(idevice: Idevice) -> Result { + Ok(Self::new(idevice)) + } +} + +#[cfg(feature = "rsd")] +impl RsdService for PreboardServiceClient { + fn rsd_service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.preboardservice_v2.shim.remote") + } + + async fn from_stream(stream: Box) -> Result { + let mut idevice = Idevice::new(stream, ""); + idevice.rsd_checkin().await?; + Ok(Self::new(idevice)) + } +} + +/// The result of a [`create_stashbag`](PreboardServiceClient::create_stashbag) request. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum StashbagOutcome { + /// The device does not need a stashbag (e.g. no passcode set); nothing further + /// to do. + NotRequired, + /// A stashbag was created and must be committed with the AP ticket once it is + /// obtained (see [`commit_stashbag`](PreboardServiceClient::commit_stashbag)). + CommitRequired, +} + +impl PreboardServiceClient { + /// Wraps an already-connected preboard service stream. + pub fn new(idevice: Idevice) -> Self { + Self { idevice } + } + + /// Creates a stashbag, sending the local `manifest` (an unsigned `IM4M` from + /// `restore::img4::build_preboard_manifest`, available with the `restore` + /// feature). + /// + /// If the device requires a stashbag it shows a passcode prompt on-device; the + /// user must enter their passcode. Returns once the device reports the outcome. + /// + /// # Errors + /// Returns [`IdeviceError`] on transport failure, if the device reports an + /// error, or if the user does not enter the passcode in time. + pub async fn create_stashbag( + &mut self, + manifest: &[u8], + ) -> Result { + self.idevice + .send_bplist(crate::plist!({ + "Command": "CreateStashbag", + "Manifest": Value::Data(manifest.to_vec()), + })) + .await?; + + // The device streams status messages; wait up to ~130s (the user may be + // entering a passcode). + for _ in 0..130 { + let msg = match tokio::time::timeout(Duration::from_secs(1), self.idevice.read_plist()) + .await + { + Ok(msg) => msg?, + Err(_) => continue, // 1s read timeout: keep waiting + }; + + if bool_field(&msg, "Skip") { + info!("device does not require a stashbag"); + return Ok(StashbagOutcome::NotRequired); + } + if bool_field(&msg, "ShowDialog") { + info!("device requires a stashbag — enter your passcode on the device"); + continue; + } + if let Some(err) = stashbag_error(&msg) { + return Err(IdeviceError::Restore(RestoreError::Stashbag(format!( + "could not create stashbag: {err}" + )))); + } + if bool_field(&msg, "Timeout") { + return Err(IdeviceError::Restore(RestoreError::Stashbag( + "timed out waiting for the passcode to be entered on the device".into(), + ))); + } + if bool_field(&msg, "HideDialog") { + info!("stashbag created"); + return Ok(StashbagOutcome::CommitRequired); + } + } + + Err(IdeviceError::Restore(RestoreError::Stashbag( + "timed out waiting for stashbag creation".into(), + ))) + } + + /// Commits a previously created stashbag, sending the AP ticket + /// (`ApImg4Ticket`) as the manifest. + /// + /// # Errors + /// Returns [`IdeviceError`] if the device reports an error or does not confirm + /// the commit. + pub async fn commit_stashbag(&mut self, ap_ticket: &[u8]) -> Result<(), IdeviceError> { + self.idevice + .send_bplist(crate::plist!({ + "Command": "CommitStashbag", + "Manifest": Value::Data(ap_ticket.to_vec()), + })) + .await?; + + let msg = tokio::time::timeout(Duration::from_secs(30), self.idevice.read_plist()) + .await + .map_err(|_| { + IdeviceError::Restore(RestoreError::Stashbag( + "timed out committing stashbag".into(), + )) + })??; + + if let Some(err) = stashbag_error(&msg) { + return Err(IdeviceError::Restore(RestoreError::Stashbag(format!( + "could not commit stashbag: {err}" + )))); + } + if bool_field(&msg, "StashbagCommitComplete") { + info!("stashbag committed"); + return Ok(()); + } + warn!("unexpected reply from preboard service: {msg:?}"); + Err(IdeviceError::Restore(RestoreError::Stashbag( + "preboard service did not confirm the stashbag commit".into(), + ))) + } +} + +/// Reads a boolean field, treating absence as false. +fn bool_field(msg: &plist::Dictionary, key: &str) -> bool { + msg.get(key).and_then(Value::as_boolean).unwrap_or(false) +} + +/// Extracts an error description from a preboard reply, if it reports one. +fn stashbag_error(msg: &plist::Dictionary) -> Option { + msg.get("Error")?; + Some( + msg.get("ErrorString") + .and_then(Value::as_string) + .unwrap_or("unknown error") + .to_string(), + ) +} diff --git a/src-tauri/vendor/idevice/src/services/restore/asr.rs b/src-tauri/vendor/idevice/src/services/restore/asr.rs new file mode 100644 index 0000000..910ce94 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/restore/asr.rs @@ -0,0 +1,254 @@ +use std::{future::Future, pin::Pin}; + +use plist::Value; +use sha1::{Digest, Sha1}; +use tokio::io::{AsyncReadExt, AsyncSeekExt}; +use tracing::debug; + +use crate::{ + Idevice, IdeviceError, + services::restore::{ + RestoreError, + data_request::{PROGRESS_STRIDE, emit_transfer, is_cancelled}, + state_machine::{RestoreCancel, RestoreProgressSender}, + }, +}; + +const ASR_VERSION: i64 = 1; +const ASR_STREAM_ID: i64 = 1; +const ASR_FEC_SLICE_STRIDE: i64 = 40; +const ASR_PACKETS_PER_FEC: i64 = 25; +const ASR_PAYLOAD_PACKET_SIZE: i64 = 1450; +const ASR_PAYLOAD_CHUNK_SIZE: u64 = 0x20000; + +/// A seekable, sized source for the filesystem image. +pub trait FilesystemImage: Send { + /// Total size of the image in bytes. + fn size(&mut self) -> Pin> + Send + '_>>; + /// Reads up to `len` bytes starting at `offset` (short only at end of image). + fn read_at( + &mut self, + offset: u64, + len: usize, + ) -> Pin, IdeviceError>> + Send + '_>>; +} + +/// Blanket implementation for any seekable async reader +impl FilesystemImage for T +where + T: AsyncReadExt + AsyncSeekExt + Unpin + Send, +{ + fn size(&mut self) -> Pin> + Send + '_>> { + Box::pin(async move { Ok(self.seek(std::io::SeekFrom::End(0)).await?) }) + } + + fn read_at( + &mut self, + offset: u64, + len: usize, + ) -> Pin, IdeviceError>> + Send + '_>> { + Box::pin(async move { + self.seek(std::io::SeekFrom::Start(offset)).await?; + let mut buf = vec![0u8; len]; + let mut read = 0; + while read < len { + let n = self.read(&mut buf[read..]).await?; + if n == 0 { + break; + } + read += n; + } + buf.truncate(read); + Ok(buf) + }) + } +} + +/// A client speaking the ASR protocol over a data-port connection. +#[derive(Debug)] +pub struct AsrClient { + idevice: Idevice, + checksum_chunks: bool, +} + +impl AsrClient { + pub const DEFAULT_PORT: u16 = 12345; + + /// Wraps a connection to the ASR port and consumes the initial `Initiate`. + pub async fn connect(idevice: Idevice) -> Result { + let mut client = Self { + idevice, + checksum_chunks: false, + }; + let init = client.recv_plist().await?; + match init.get("Command").and_then(Value::as_string) { + Some("Initiate") => {} + other => { + return Err(IdeviceError::UnexpectedResponse(format!( + "expected ASR Initiate, got {other:?}" + ))); + } + } + client.checksum_chunks = init + .get("Checksum Chunks") + .and_then(Value::as_boolean) + .unwrap_or(false); + debug!("ASR checksum_chunks = {}", client.checksum_chunks); + Ok(client) + } + + /// Reads a raw XML plist terminated by `\n`. + async fn recv_plist(&mut self) -> Result { + const TERMINATOR: &[u8] = b"\n"; + let mut buf = Vec::new(); + loop { + let b = self.idevice.read_raw(1).await?; + if b.is_empty() { + return Err(IdeviceError::UnexpectedResponse( + "ASR connection closed mid-plist".into(), + )); + } + buf.push(b[0]); + if buf.ends_with(TERMINATOR) { + break; + } + } + Ok(plist::from_bytes(&buf)?) + } + + /// Sends an XML plist (no length prefix). + /// + /// ASR frames plists on the `\n` terminator, so a trailing newline is + /// appended when the serializer omits it. + async fn send_plist(&mut self, value: &Value) -> Result<(), IdeviceError> { + let mut buf = Vec::new(); + value.to_writer_xml(&mut buf).map_err(IdeviceError::Plist)?; + if !buf.ends_with(b"\n") { + buf.push(b'\n'); + } + self.idevice.send_raw(&buf).await + } + + /// Runs the full validation + payload transfer for `image`. + /// + /// `progress` receives throttled [`RestoreProgressEvent::Transfer`] updates as + /// the payload streams; `cancel`, when set, aborts the transfer per chunk with + /// [`RestoreError::Cancelled`]. This is the longest phase of a restore, so it is + /// where cancellation and byte-progress matter most. + pub async fn send_filesystem( + &mut self, + image: &mut dyn FilesystemImage, + progress: Option, + cancel: Option, + ) -> Result<(), IdeviceError> { + self.perform_validation(image).await?; + self.send_payload(image, progress.as_ref(), cancel.as_ref()) + .await + } + + /// Sends the validation packet and services `OOBData` until `Payload`. + async fn perform_validation( + &mut self, + image: &mut dyn FilesystemImage, + ) -> Result<(), IdeviceError> { + let length = image.size().await?; + + let mut payload_info = plist::Dictionary::new(); + payload_info.insert("Port".into(), 1.into()); + payload_info.insert("Size".into(), (length as i64).into()); + + let mut packet_info = plist::Dictionary::new(); + if self.checksum_chunks { + packet_info.insert( + "Checksum Chunk Size".into(), + (ASR_PAYLOAD_CHUNK_SIZE as i64).into(), + ); + } + packet_info.insert("FEC Slice Stride".into(), ASR_FEC_SLICE_STRIDE.into()); + packet_info.insert("Packet Payload Size".into(), ASR_PAYLOAD_PACKET_SIZE.into()); + packet_info.insert("Packets Per FEC".into(), ASR_PACKETS_PER_FEC.into()); + packet_info.insert("Payload".into(), Value::Dictionary(payload_info)); + packet_info.insert("Stream ID".into(), ASR_STREAM_ID.into()); + packet_info.insert("Version".into(), ASR_VERSION.into()); + + self.send_plist(&Value::Dictionary(packet_info)).await?; + + loop { + let packet = self.recv_plist().await?; + match packet.get("Command").and_then(Value::as_string) { + Some("Payload") => return Ok(()), + Some("OOBData") => self.handle_oob(&packet, image).await?, + other => { + return Err(IdeviceError::UnexpectedResponse(format!( + "unexpected ASR command during validation: {other:?}" + ))); + } + } + } + } + + /// Answers an out-of-band data request with the requested image range. + async fn handle_oob( + &mut self, + packet: &plist::Dictionary, + image: &mut dyn FilesystemImage, + ) -> Result<(), IdeviceError> { + let offset = packet + .get("OOB Offset") + .and_then(Value::as_unsigned_integer) + .ok_or_else(|| IdeviceError::UnexpectedResponse("OOBData missing OOB Offset".into()))?; + let length = packet + .get("OOB Length") + .and_then(Value::as_unsigned_integer) + .ok_or_else(|| IdeviceError::UnexpectedResponse("OOBData missing OOB Length".into()))?; + + let data = image.read_at(offset, length as usize).await?; + if data.len() as u64 != length { + return Err(IdeviceError::Restore(RestoreError::Asr(format!( + "ASR OOB read short: wanted {length}, got {}", + data.len() + )))); + } + self.idevice.send_raw(&data).await + } + + /// Streams the image in chunks, appending a per-chunk SHA-1 when requested. + /// + /// Checks `cancel` before each chunk (so a cancellation lands within one + /// chunk's worth of transfer) and emits throttled byte progress into `progress`. + async fn send_payload( + &mut self, + image: &mut dyn FilesystemImage, + progress: Option<&RestoreProgressSender>, + cancel: Option<&RestoreCancel>, + ) -> Result<(), IdeviceError> { + let length = image.size().await?; + let mut offset = 0u64; + let mut next_report = 0u64; + while offset < length { + if is_cancelled(cancel) { + return Err(IdeviceError::Restore(RestoreError::Cancelled)); + } + let want = ASR_PAYLOAD_CHUNK_SIZE.min(length - offset) as usize; + let mut chunk = image.read_at(offset, want).await?; + if chunk.is_empty() { + break; + } + offset += chunk.len() as u64; + + if self.checksum_chunks { + let digest = Sha1::digest(&chunk); + chunk.extend_from_slice(&digest); + } + self.idevice.send_raw(&chunk).await?; + + if offset >= next_report { + emit_transfer(progress, "filesystem", offset, Some(length)); + next_report = offset + PROGRESS_STRIDE; + } + } + // A final 100% event so the consumer sees the transfer complete. + emit_transfer(progress, "filesystem", offset, Some(length)); + Ok(()) + } +} diff --git a/src-tauri/vendor/idevice/src/services/restore/data_request.rs b/src-tauri/vendor/idevice/src/services/restore/data_request.rs new file mode 100644 index 0000000..ccb690b --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/restore/data_request.rs @@ -0,0 +1,719 @@ +//! `DataRequestMsg` handlers +//! +//! Dispatches on a data request's `DataType` and produces the reply the device +//! expects. Firmware components are personalized with the TSS ticket via +//! [`img4`](super::img4). Some replies go over the restore connection, others to +//! a per-request data port. AEA-encrypted images are decrypted on the device, +//! the host only proxies the `URLAsset`/`StreamedImageDecryptionKey` HTTP +//! requests to Apple. + +use std::time::Duration; + +use plist::Value; +use tracing::{debug, error, info, warn}; + +use super::{ + asr::AsrClient, + img4, ipsw, + state_machine::{RestoreCancel, RestoreContext, RestoreProgressEvent, RestoreProgressSender}, +}; +use crate::{Idevice, IdeviceError, services::restore::RestoreError}; + +const FILE_CHUNK_SIZE: usize = 8192; + +pub(super) const PROGRESS_STRIDE: u64 = 8 * 1024 * 1024; + +pub(super) fn is_cancelled(cancel: Option<&RestoreCancel>) -> bool { + cancel.is_some_and(RestoreCancel::is_cancelled) +} + +pub(super) fn emit_transfer( + progress: Option<&RestoreProgressSender>, + component: &str, + sent: u64, + total: Option, +) { + if let Some(tx) = progress { + tx.send(RestoreProgressEvent::Transfer { + component: component.to_string(), + sent, + total, + }); + } +} + +/// Dispatches a `DataRequestMsg`/`AsyncDataRequestMsg` on its `DataType`. +pub(super) async fn dispatch( + ctx: &mut RestoreContext<'_>, + message: &plist::Dictionary, +) -> Result<(), IdeviceError> { + let data_type = match message.get("DataType").and_then(Value::as_string) { + Some(d) => d.to_string(), + None => { + warn!("data request without a string DataType: {message:?}"); + return Ok(()); + } + }; + + ctx.emit(RestoreProgressEvent::Step(data_type.clone())); + + match data_type.as_str() { + "RootTicket" => send_root_ticket(ctx, message).await, + "SystemImageData" | "RecoveryOSASRImage" => send_filesystem(ctx, message).await, + "BuildIdentityDict" => send_buildidentity(ctx, message).await, + "PersonalizedBootObjectV3" => send_boot_object(ctx, message, false).await, + "SourceBootObjectV4" | "SourceBootObjectV5" => send_boot_object(ctx, message, true).await, + "KernelCache" => send_component(ctx, "KernelCache", "KernelCache").await, + "DeviceTree" => send_component(ctx, "DeviceTree", "DeviceTree").await, + "SystemImageRootHash" => send_component(ctx, "SystemVolume", "SystemImageRootHash").await, + "SystemImageCanonicalMetadata" => { + send_component( + ctx, + "Ap,SystemVolumeCanonicalMetadata", + "SystemImageCanonicalMetadata", + ) + .await + } + "NORData" => send_nor(ctx, message).await, + "FirmwareUpdaterData" => super::fw_updater::send_firmware_updater_data(ctx, message).await, + "BasebandData" => super::fw_updater::send_baseband_data(ctx, message).await, + "FDRTrustData" => send_fdr_trust_data(ctx, message).await, + "FUDData" => { + send_image_data( + ctx, + message, + "FUDImageList", + Some("IsFUDFirmware"), + "FUDImageData", + ) + .await + } + "PersonalizedData" => send_image_data(ctx, message, "ImageList", None, "ImageData").await, + "EANData" => { + send_image_data( + ctx, + message, + "EANImageList", + Some("IsEarlyAccessFirmware"), + "EANData", + ) + .await + } + "ReceiptManifest" => send_manifest(ctx).await, + "URLAsset" => send_url_asset(ctx, message).await, + "StreamedImageDecryptionKey" => send_streamed_image_decryption_key(ctx, message).await, + other => { + warn!("DataType `{other}` not yet implemented; skipping"); + Ok(()) + } + } +} + +async fn personalize_path( + ctx: &mut RestoreContext<'_>, + component_name: &str, + path: &str, +) -> Result, IdeviceError> { + let raw = ctx.components.read_component(path).await?; + let fourcc = img4::restore_fourcc_override(component_name); + img4::stitch_component(&raw, ctx.tss_ticket, fourcc, &[]) +} + +async fn personalize_named( + ctx: &mut RestoreContext<'_>, + component_name: &str, +) -> Result, IdeviceError> { + let path = ipsw::component_path(ctx.build_identity, component_name)?; + personalize_path(ctx, component_name, &path).await +} + +async fn send_root_ticket( + ctx: &mut RestoreContext<'_>, + message: &plist::Dictionary, +) -> Result<(), IdeviceError> { + if ctx.tss_ticket.is_empty() { + return Err(IdeviceError::Restore(RestoreError::Other( + "cannot send RootTicket without a TSS ticket".into(), + ))); + } + info!("sending RootTicket"); + let payload = crate::plist!({ "RootTicketData": ctx.tss_ticket.to_vec() }); + send_to_data_service(ctx, message, payload).await +} + +async fn send_buildidentity( + ctx: &mut RestoreContext<'_>, + message: &plist::Dictionary, +) -> Result<(), IdeviceError> { + info!("sending BuildIdentityDict"); + let variant = message + .get("Arguments") + .and_then(Value::as_dictionary) + .and_then(|a| a.get("Variant")) + .and_then(Value::as_string) + .unwrap_or("Erase") + .to_string(); + let payload = crate::plist!({ + "BuildIdentityDict": Value::Dictionary(ctx.build_identity.clone()), + "Variant": variant, + }); + send_to_data_service(ctx, message, payload).await +} + +async fn connect_data_port( + data_ports: &dyn super::state_machine::DataPortConnector, + port: u16, +) -> Result { + const ATTEMPTS: usize = 30; + let mut last_err = None; + for attempt in 1..=ATTEMPTS { + match data_ports.connect(port).await { + Ok(conn) => return Ok(conn), + Err(e) => { + debug!("data port {port} connect attempt {attempt}/{ATTEMPTS} failed: {e}"); + last_err = Some(e); + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + } + Err(last_err.unwrap_or_else(|| IdeviceError::Restore(RestoreError::DataPortConnect(port)))) +} + +async fn send_filesystem( + ctx: &mut RestoreContext<'_>, + message: &plist::Dictionary, +) -> Result<(), IdeviceError> { + let port = message + .get("DataPort") + .and_then(Value::as_unsigned_integer) + .map(|p| p as u16) + .unwrap_or(AsrClient::DEFAULT_PORT); + info!("streaming filesystem image over ASR (port {port})"); + + let conn = connect_data_port(ctx.data_ports, port).await?; + let mut asr = AsrClient::connect(conn).await?; + + let image = ctx + .filesystem + .take() + .ok_or(IdeviceError::Restore(RestoreError::NoFilesystemImage))?; + + let progress = ctx.progress.clone(); + let cancel = ctx.cancel.clone(); + let mut pump_ctx = ctx.without_filesystem(); + + tokio::select! { + r = asr.send_filesystem(image, progress, cancel) => r?, + // The pump only returns on error; the transfer completing ends the select. + r = run_transfer_pump(&mut pump_ctx) => r?, + } + + Ok(()) +} + +async fn run_transfer_pump(ctx: &mut RestoreContext<'_>) -> Result<(), IdeviceError> { + loop { + ctx.check_cancel()?; + let msg = ctx.restored.recv().await?; + match msg + .get("MsgType") + .and_then(Value::as_string) + .unwrap_or_default() + { + "DataRequestMsg" | "AsyncDataRequestMsg" => { + // Boxed to break the dispatch -> filesystem -> pump -> dispatch cycle. + Box::pin(dispatch(ctx, &msg)).await.inspect_err(|e| { + error!("data request during transfer failed, aborting restore: {e}"); + })?; + } + other => debug!("message during transfer: {other}"), + } + } +} + +async fn send_component( + ctx: &mut RestoreContext<'_>, + component: &str, + reply_name: &str, +) -> Result<(), IdeviceError> { + info!("personalizing and sending {reply_name}"); + let personalized = personalize_named(ctx, component).await?; + let mut reply = plist::Dictionary::new(); + reply.insert(format!("{reply_name}File"), Value::Data(personalized)); + ctx.restored.send(Value::Dictionary(reply)).await +} + +async fn send_boot_object( + ctx: &mut RestoreContext<'_>, + message: &plist::Dictionary, + handle_aea: bool, +) -> Result<(), IdeviceError> { + let image_name = message + .get("Arguments") + .and_then(Value::as_dictionary) + .and_then(|a| a.get("ImageName")) + .and_then(Value::as_string) + .ok_or_else(|| IdeviceError::Restore(RestoreError::MissingField("ImageName".into())))? + .to_string(); + info!("sending boot object {image_name}"); + + let port = message + .get("DataPort") + .and_then(Value::as_unsigned_integer) + .map(|p| p as u16); + + let special = match image_name.as_str() { + "__RestoreVersion__" => Some("RestoreVersion.plist"), + "__SystemVersion__" => Some("SystemVersion.plist"), + "__GlobalManifest__" => { + return Err(IdeviceError::Restore(RestoreError::Unsupported( + "__GlobalManifest__ (macOS)".into(), + ))); + } + _ => None, + }; + + if handle_aea && special.is_none() { + return send_source_boot_object(ctx, &image_name, port).await; + } + + let data = match special { + Some(path) => ctx.components.read_component(path).await?, + None => personalize_named(ctx, &image_name).await?, + }; + + match port { + Some(port) => { + let mut conn = ctx.data_ports.connect(port).await?; + send_file_chunks(&mut conn, &data, handle_aea).await + } + None => { + for chunk in data.chunks(FILE_CHUNK_SIZE) { + ctx.restored + .send(crate::plist!({ "FileData": chunk.to_vec() })) + .await?; + } + ctx.restored + .send(crate::plist!({ "FileDataDone": true })) + .await + } + } +} + +async fn send_source_boot_object( + ctx: &mut RestoreContext<'_>, + image_name: &str, + port: Option, +) -> Result<(), IdeviceError> { + let path = ipsw::component_path(ctx.build_identity, image_name)?; + + // disassemble to not pass entire objects + let RestoreContext { + restored, + components, + data_ports, + progress, + cancel, + .. + } = ctx; + + let mut reader = components.open_component(&path).await?; + let mut sink = match port { + Some(port) => BootSink::Port(connect_data_port(*data_ports, port).await?), + None => BootSink::Restored(restored), + }; + stream_boot_object( + image_name, + &mut *reader, + &mut sink, + progress.as_ref(), + cancel.as_ref(), + ) + .await +} + +enum BootSink<'a> { + Port(Idevice), + Restored(&'a mut super::restored::RestoredClient), +} + +impl BootSink<'_> { + async fn send(&mut self, value: Value) -> Result<(), IdeviceError> { + match self { + BootSink::Port(conn) => conn.send_plist(value).await, + BootSink::Restored(restored) => restored.send(value).await, + } + } + + async fn recv(&mut self) -> Result { + match self { + BootSink::Port(conn) => conn.read_plist().await, + BootSink::Restored(restored) => restored.recv().await, + } + } +} + +async fn stream_boot_object( + component: &str, + reader: &mut dyn super::state_machine::ComponentReader, + sink: &mut BootSink<'_>, + progress: Option<&RestoreProgressSender>, + cancel: Option<&RestoreCancel>, +) -> Result<(), IdeviceError> { + let mut buf = vec![0u8; FILE_CHUNK_SIZE]; + let mut first = true; + let mut sent: u64 = 0; + let mut next_report: u64 = 0; + loop { + if is_cancelled(cancel) { + return Err(IdeviceError::Restore(RestoreError::Cancelled)); + } + let n = reader.read(&mut buf).await?; + if n == 0 { + break; + } + sink.send(crate::plist!({ "FileData": buf[..n].to_vec() })) + .await?; + sent += n as u64; + if sent >= next_report { + emit_transfer(progress, component, sent, None); + next_report = sent + PROGRESS_STRIDE; + } + if first { + first = false; + if buf[..n].starts_with(b"AEA1") + && let Ok(Ok(msg)) = tokio::time::timeout(Duration::from_secs(3), sink.recv()).await + && msg.get("MsgType").and_then(Value::as_string) == Some("URLAsset") + { + let response = url_asset_response(&msg).await?; + sink.send(response).await?; + } + } + } + sink.send(crate::plist!({ "FileDataDone": true })).await +} + +async fn send_file_chunks( + conn: &mut Idevice, + data: &[u8], + handle_aea: bool, +) -> Result<(), IdeviceError> { + for (i, chunk) in data.chunks(FILE_CHUNK_SIZE).enumerate() { + conn.send_plist(crate::plist!({ "FileData": chunk.to_vec() })) + .await?; + if handle_aea + && i == 0 + && chunk.starts_with(b"AEA1") + && let Ok(Ok(msg)) = + tokio::time::timeout(Duration::from_secs(3), conn.read_plist()).await + && msg.get("MsgType").and_then(Value::as_string) == Some("URLAsset") + { + let response = url_asset_response(&msg).await?; + conn.send_plist(response).await?; + } + } + conn.send_plist(crate::plist!({ "FileDataDone": true })) + .await +} + +async fn send_nor( + ctx: &mut RestoreContext<'_>, + message: &plist::Dictionary, +) -> Result<(), IdeviceError> { + info!("assembling NORData"); + let flash_version_1 = message + .get("Arguments") + .and_then(Value::as_dictionary) + .and_then(|a| a.get("FlashVersion1")) + .map(is_truthy) + .unwrap_or(false); + + let llb_path = ipsw::component_path(ctx.build_identity, "LLB")?; + let firmware = firmware_components(ctx.build_identity); + + let mut req = plist::Dictionary::new(); + let llb = personalize_path(ctx, "LLB", &llb_path).await?; + req.insert("LlbImageData".into(), Value::Data(llb)); + + if flash_version_1 { + let mut d = plist::Dictionary::new(); + for (name, path) in &firmware { + if name == "LLB" || name == "RestoreSEP" { + continue; + } + let data = personalize_path(ctx, name, path).await?; + d.insert(name.clone(), Value::Data(data)); + } + req.insert("NorImageData".into(), Value::Dictionary(d)); + } else { + let mut arr: Vec = Vec::new(); + for (name, path) in &firmware { + if name == "LLB" || name == "RestoreSEP" { + continue; + } + let data = Value::Data(personalize_path(ctx, name, path).await?); + if name.starts_with("iBoot") { + arr.insert(0, data); + } else { + arr.push(data); + } + } + req.insert("NorImageData".into(), Value::Array(arr)); + } + + // SEP images are sent under their own keys (SepStage1 -> SEPPatch). + for component in ["RestoreSEP", "SEP", "SepStage1"] { + if let Ok(path) = ipsw::component_path(ctx.build_identity, component) { + let key = if component == "SepStage1" { + "SEPPatch" + } else { + component + }; + let data = personalize_path(ctx, component, &path).await?; + req.insert(format!("{key}ImageData"), Value::Data(data)); + } + } + + info!("sending NORData"); + send_to_data_service(ctx, message, Value::Dictionary(req)).await +} + +async fn send_fdr_trust_data( + ctx: &mut RestoreContext<'_>, + message: &plist::Dictionary, +) -> Result<(), IdeviceError> { + info!("sending FDR trust data"); + send_to_data_service(ctx, message, Value::Dictionary(plist::Dictionary::new())).await +} + +async fn send_manifest(ctx: &mut RestoreContext<'_>) -> Result<(), IdeviceError> { + let manifest = ctx + .build_identity + .get("Manifest") + .cloned() + .unwrap_or_else(|| Value::Dictionary(plist::Dictionary::new())); + ctx.restored + .send(crate::plist!({ "ReceiptManifest": manifest })) + .await +} + +async fn send_image_data( + ctx: &mut RestoreContext<'_>, + message: &plist::Dictionary, + image_list_k: &str, + image_type_k: Option<&str>, + image_data_k: &str, +) -> Result<(), IdeviceError> { + let arguments = message.get("Arguments").and_then(Value::as_dictionary); + let want_list = arguments + .and_then(|a| a.get(image_list_k)) + .map(is_truthy) + .unwrap_or(false); + let mut image_name = arguments + .and_then(|a| a.get("ImageName")) + .and_then(Value::as_string) + .map(str::to_string); + + // The image type key is either fixed or taken from the request. + let type_key = match image_type_k { + Some(k) => k.to_string(), + None => arguments + .and_then(|a| a.get("ImageType")) + .and_then(Value::as_string) + .ok_or_else(|| IdeviceError::Restore(RestoreError::MissingField("ImageType".into())))? + .to_string(), + }; + + let manifest = ctx + .build_identity + .get("Manifest") + .and_then(Value::as_dictionary) + .ok_or(IdeviceError::BadBuildManifest)? + .clone(); + + // Normalize an `Ap...` image name to `Ap,...` if needed. + if let Some(name) = &image_name + && !want_list + && !manifest.contains_key(name) + && name.starts_with("Ap") + { + let fixed = name.replacen("Ap", "Ap,", 1); + if manifest.contains_key(&fixed) { + image_name = Some(fixed); + } + } + + let mut matched: Vec = Vec::new(); + let mut data_dict = plist::Dictionary::new(); + for (component, entry) in &manifest { + let is_type = entry + .as_dictionary() + .and_then(|e| e.get("Info")) + .and_then(Value::as_dictionary) + .and_then(|i| i.get(&type_key)) + .map(is_truthy) + .unwrap_or(false); + if !is_type { + continue; + } + if want_list { + matched.push(component.clone()); + } else if image_name.as_deref().is_none_or(|n| n == component) { + let data = personalize_named(ctx, component).await?; + data_dict.insert(component.clone(), Value::Data(data)); + } + } + + let mut req = plist::Dictionary::new(); + if want_list { + req.insert( + image_list_k.into(), + Value::Array(matched.into_iter().map(Value::String).collect()), + ); + } else if let Some(name) = image_name { + if let Some(data) = data_dict.get(&name) { + req.insert(image_data_k.into(), data.clone()); + } + req.insert("ImageName".into(), Value::String(name)); + } else { + req.insert(image_data_k.into(), Value::Dictionary(data_dict)); + } + + ctx.restored.send(Value::Dictionary(req)).await +} + +async fn send_url_asset( + ctx: &mut RestoreContext<'_>, + message: &plist::Dictionary, +) -> Result<(), IdeviceError> { + let response = url_asset_response(message).await?; + send_to_data_service(ctx, message, response).await +} + +async fn send_streamed_image_decryption_key( + ctx: &mut RestoreContext<'_>, + message: &plist::Dictionary, +) -> Result<(), IdeviceError> { + let reply = streamed_key_response(message).await?; + send_to_data_service(ctx, message, reply).await +} + +async fn streamed_key_response(message: &plist::Dictionary) -> Result { + let arguments = message + .get("Arguments") + .and_then(Value::as_dictionary) + .ok_or_else(|| IdeviceError::Restore(RestoreError::MissingField("Arguments".into())))?; + let url = arguments + .get("RequestURL") + .and_then(Value::as_string) + .ok_or_else(|| IdeviceError::Restore(RestoreError::MissingField("RequestURL".into())))?; + let body = arguments + .get("RequestBody") + .and_then(Value::as_data) + .unwrap_or_default() + .to_vec(); + info!("proxying StreamedImageDecryptionKey POST {url}"); + + crate::ensure_default_crypto_provider(); + let mut req = reqwest::Client::new().post(url).body(body); + if let Some(headers) = arguments + .get("RequestAdditionalHeaders") + .and_then(Value::as_dictionary) + { + for (k, v) in headers { + if let Some(v) = v.as_string() { + req = req.header(k.as_str(), v); + } + } + } + http_response_to_plist(req.send().await?).await +} + +fn is_truthy(v: &Value) -> bool { + match v { + Value::Boolean(b) => *b, + Value::Integer(i) => i.as_unsigned().map(|n| n != 0).unwrap_or(true), + Value::String(s) => !s.is_empty() && s != "0" && s != "false", + _ => true, + } +} + +fn firmware_components(build_identity: &plist::Dictionary) -> Vec<(String, String)> { + let mut out = Vec::new(); + let Some(manifest) = build_identity + .get("Manifest") + .and_then(Value::as_dictionary) + else { + return out; + }; + for (name, entry) in manifest { + let Some(info) = entry + .as_dictionary() + .and_then(|e| e.get("Info")) + .and_then(Value::as_dictionary) + else { + continue; + }; + let is_fw = info + .get("IsFirmwarePayload") + .map(is_truthy) + .unwrap_or(false); + let is_secondary = info + .get("IsSecondaryFirmwarePayload") + .map(is_truthy) + .unwrap_or(false); + let loaded_by_iboot = info.get("IsLoadedByiBoot").map(is_truthy).unwrap_or(false); + if (is_fw || (is_secondary && loaded_by_iboot)) + && let Some(path) = info.get("Path").and_then(Value::as_string) + { + out.push((name.clone(), path.to_string())); + } + } + out +} + +async fn url_asset_response(message: &plist::Dictionary) -> Result { + let arguments = message + .get("Arguments") + .and_then(Value::as_dictionary) + .ok_or_else(|| IdeviceError::Restore(RestoreError::MissingField("Arguments".into())))?; + let url = arguments + .get("RequestURL") + .and_then(Value::as_string) + .ok_or_else(|| IdeviceError::Restore(RestoreError::MissingField("RequestURL".into())))?; + info!("proxying URLAsset GET {url}"); + crate::ensure_default_crypto_provider(); + let response = reqwest::Client::new().get(url).send().await?; + http_response_to_plist(response).await +} + +async fn http_response_to_plist(response: reqwest::Response) -> Result { + let status = response.status().as_u16() as i64; + let mut headers = plist::Dictionary::new(); + for (k, v) in response.headers() { + if let Ok(v) = v.to_str() { + headers.insert(k.as_str().to_string(), Value::String(v.to_string())); + } + } + let body = response.bytes().await?.to_vec(); + Ok(crate::plist!({ + "ResponseBody": body, + "ResponseBodyDone": true, + "ResponseHeaders": Value::Dictionary(headers), + "ResponseStatus": status, + })) +} + +pub(super) async fn send_to_data_service( + ctx: &mut RestoreContext<'_>, + message: &plist::Dictionary, + payload: Value, +) -> Result<(), IdeviceError> { + match message.get("DataPort").and_then(Value::as_unsigned_integer) { + Some(port) => { + let mut conn = ctx.data_ports.connect(port as u16).await?; + conn.send_plist(payload).await + } + None => ctx.restored.send(payload).await, + } +} diff --git a/src-tauri/vendor/idevice/src/services/restore/errors.rs b/src-tauri/vendor/idevice/src/services/restore/errors.rs new file mode 100644 index 0000000..7f6c6cc --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/restore/errors.rs @@ -0,0 +1,113 @@ +//! Errors specific to the IPSW restore flow. + +use thiserror::Error; + +/// Failures that can occur while restoring an IPSW onto a device. +/// +/// Grouped by the stage that produced them so a library consumer can react to a +/// category (e.g. retry a data-port connect, surface a device-reported error to +/// the user). +#[derive(Error, Debug)] +#[non_exhaustive] +pub enum RestoreError { + /// A component named by the build identity could not be found in it. + #[error("component `{0}` not found in build identity")] + ComponentNotFound(String), + + /// Reading or extracting from the IPSW archive failed. + #[error("IPSW archive error: {0}")] + Ipsw(String), + + /// IMG4 personalization (IM4P/IM4M/IM4R stitching) failed. + #[error("IMG4 personalization error: {0}")] + Img4(String), + + /// Assembling or stitching baseband firmware failed. + #[error("baseband firmware error: {0}")] + Baseband(String), + + /// The device rejected the baseband update it was sent. + #[error("device rejected the baseband update: {0}")] + BasebandRejected(String), + + /// The Apple Software Restore filesystem transfer failed. + #[error("ASR filesystem transfer error: {0}")] + Asr(String), + + /// No filesystem image was supplied to a restore that needs one. + #[error("no filesystem image was provided for the restore")] + NoFilesystemImage, + + /// Could not open a connection to a restore-mode data port. + #[error("could not connect to restore data port {0}")] + DataPortConnect(u16), + + /// A recovery- or DFU-mode USB operation failed. + #[error("recovery/DFU error: {0}")] + Recovery(String), + + /// A preboard stashbag operation (create/commit) failed. + #[error("preboard stashbag error: {0}")] + Stashbag(String), + + /// A TSS response was missing or had an unexpected shape. + #[error("malformed TSS response: {0}")] + TssResponse(String), + + /// A message from the device was missing a field the handler required. + #[error("device message missing required field `{0}`")] + MissingField(String), + + /// `restored` crashed partway through the restore. + #[error("restored crashed during the restore")] + RestoredCrashed, + + /// The device reported a fatal, structured restore error. + #[error("device reported a fatal restore error (AMRError={amr_error}): {detail}")] + DeviceReported { + /// The `AMRError` value the device sent, or `-1` when absent. + amr_error: i64, + /// The human-readable error detail extracted from the device message. + detail: String, + }, + + /// A restore feature or code path the device asked for is not implemented. + #[error("{0} is not supported")] + Unsupported(String), + + /// The consumer requested cancellation. The restore stops at the next check + /// point and the device is rebooted toward recovery (see [`run_restore`]). + /// + /// [`run_restore`]: super::run_restore + #[error("the restore was cancelled")] + Cancelled, + + /// Any other restore failure that doesn't fit a more specific variant. + #[error("{0}")] + Other(String), +} + +impl RestoreError { + /// Returns the sub-error code within the restore category, for FFI consumers. + pub fn sub_code(&self) -> i32 { + match self { + Self::ComponentNotFound(_) => 1, + Self::Ipsw(_) => 2, + Self::Img4(_) => 3, + Self::Baseband(_) => 4, + Self::BasebandRejected(_) => 5, + Self::Asr(_) => 6, + Self::NoFilesystemImage => 7, + Self::DataPortConnect(_) => 8, + Self::Recovery(_) => 9, + Self::Stashbag(_) => 10, + Self::TssResponse(_) => 11, + Self::MissingField(_) => 12, + Self::RestoredCrashed => 13, + Self::DeviceReported { .. } => 14, + Self::Unsupported(_) => 15, + Self::Cancelled => 17, + Self::Other(_) => 16, + } + } +} diff --git a/src-tauri/vendor/idevice/src/services/restore/fdr.rs b/src-tauri/vendor/idevice/src/services/restore/fdr.rs new file mode 100644 index 0000000..5991109 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/restore/fdr.rs @@ -0,0 +1,220 @@ +//! FDR (Firmware Diagnostic Relay) trust channel +//! +//! During restore the device opens a trust channel used to proxy TLS connections +//! (e.g. to Apple's signing/trust servers) through the host, plus keep-alive +//! pings. FDR uses little-endian framing: a bare `u16` message tag, and +//! length-prefixed binary plists (4-byte little-endian length). +//! +//! FDR runs concurrently with the main restore loop on its own connection(s); a +//! caller spawns [`run_fdr_listener`] as a background task. + +use std::{future::Future, pin::Pin, sync::Arc}; + +use plist::Value; +use tracing::{debug, warn}; + +use crate::{Idevice, IdeviceError}; + +/// The FDR control port. +pub const FDR_CTRL_PORT: u16 = 0x43A; + +const CTRL_CMD: &[u8] = b"BeginCtrl\0"; +const HELLO_CMD: &[u8] = b"HelloConn\0"; +const CTRL_PROTO_VERSION: i64 = 2; + +const FDR_SYNC_MSG: u16 = 0x1; +const FDR_PROXY_MSG: u16 = 0x105; +const FDR_PLIST_MSG: u16 = 0xBBAA; +const CHUNK_SIZE: u32 = 1 << 20; + +/// Opens new connections to device ports for FDR +pub trait FdrConnector: Send + Sync { + /// Connects to `port` on the restore-mode device. + fn connect_device_port( + &self, + port: u16, + ) -> Pin> + Send>>; +} + +#[derive(Debug)] +pub struct FdrClient { + idevice: Idevice, +} + +impl FdrClient { + /// Wraps a freshly-connected FDR socket. + pub fn new(idevice: Idevice) -> Self { + Self { idevice } + } + + /// Performs the control handshake, returning the `ConnPort` for conn channels. + pub async fn ctrl_handshake(&mut self) -> Result { + self.idevice.send_raw(CTRL_CMD).await?; + let req = crate::plist!({ + "Command": Value::Data(CTRL_CMD.to_vec()), + "CtrlProtoVersion": CTRL_PROTO_VERSION, + }); + let resp = self.send_recv_plist(&req).await?; + resp.get("ConnPort") + .and_then(Value::as_unsigned_integer) + .map(|p| p as u16) + .ok_or_else(|| { + IdeviceError::UnexpectedResponse("FDR ctrl reply missing ConnPort".into()) + }) + } + + /// Performs the conn (sync) handshake. + pub async fn sync_handshake(&mut self) -> Result<(), IdeviceError> { + self.idevice.send_raw(HELLO_CMD).await?; + let reply = self.recv_plist().await?; + match reply.get("Command").and_then(Value::as_string) { + Some("HelloConn") => Ok(()), + other => Err(IdeviceError::UnexpectedResponse(format!( + "expected HelloConn, got {other:?}" + ))), + } + } + + async fn recv_plist(&mut self) -> Result { + let len = self.idevice.read_raw(4).await?; + let len = u32::from_le_bytes([len[0], len[1], len[2], len[3]]) as usize; + let body = self.idevice.read_raw(len).await?; + Ok(plist::from_bytes(&body)?) + } + + async fn send_plist(&mut self, value: &Value) -> Result<(), IdeviceError> { + let mut body = Vec::new(); + value + .to_writer_binary(&mut body) + .map_err(IdeviceError::Plist)?; + self.idevice + .send_raw(&(body.len() as u32).to_le_bytes()) + .await?; + self.idevice.send_raw(&body).await + } + + async fn send_recv_plist(&mut self, value: &Value) -> Result { + self.send_plist(value).await?; + self.recv_plist().await + } + + /// Reads the next little-endian `u16` message tag. + async fn read_message_tag(&mut self) -> Result { + let b = self.idevice.read_raw(2).await?; + Ok(u16::from_le_bytes([b[0], b[1]])) + } + + async fn handle_plist(&mut self) -> Result<(), IdeviceError> { + let d = self.recv_plist().await?; + match d.get("Command").and_then(Value::as_string) { + Some("Ping") => { + let _ = self + .send_recv_plist(&crate::plist!({ "Pong": true })) + .await?; + } + other => warn!("FDR: unknown plist command {other:?}"), + } + Ok(()) + } + + async fn handle_proxy(mut self) -> Result<(), IdeviceError> { + let buf = self.idevice.read_any(CHUNK_SIZE).await?; + debug!("FDR proxy command with {} bytes", buf.len()); + + // Acknowledge the request (u16 = 5) and echo the payload back. + self.idevice.send_raw(&5u16.to_le_bytes()).await?; + if buf.len() < 3 { + debug!("FDR proxy command too short"); + return Ok(()); + } + self.idevice.send_raw(&buf).await?; + + // SOCKS-ish connect request: [0x00, 0x03, hostlen, host..., portBE(2)]. + if buf[0] != 0 || buf[1] != 3 { + return Ok(()); + } + let hostlen = buf[2] as usize; + if 3 + hostlen + 2 > buf.len() { + warn!("FDR proxy connect request truncated"); + return Ok(()); + } + let host = String::from_utf8_lossy(&buf[3..3 + hostlen]).to_string(); + let port = u16::from_be_bytes([buf[buf.len() - 2], buf[buf.len() - 1]]); + debug!("FDR proxy connect to {host}:{port}"); + + use tokio::io::AsyncWriteExt; + + let host_stream = tokio::net::TcpStream::connect((host.as_str(), port)).await?; + let device = self + .idevice + .get_socket() + .ok_or(IdeviceError::NoEstablishedConnection)?; + + // Bidirectionally bridge the device stream and the outbound TCP stream + // until either side closes. + let (mut dr, mut dw) = tokio::io::split(device); + let (mut hr, mut hw) = tokio::io::split(host_stream); + let s2h = async { + let _ = tokio::io::copy(&mut dr, &mut hw).await; + let _ = hw.shutdown().await; + }; + let h2s = async { + let _ = tokio::io::copy(&mut hr, &mut dw).await; + let _ = dw.shutdown().await; + }; + tokio::select! { + _ = s2h => {}, + _ = h2s => {}, + } + Ok(()) + } +} + +/// Runs an FDR listener loop until the connection closes. +/// +/// On a `Sync` message a new conn-channel listener is spawned against +/// `conn_port`; `Proxy` messages take over the connection to bridge traffic; +/// `Plist` messages answer pings. +/// +/// Spawn this as a background task alongside the restore loop. Returns a boxed, +/// `Send` future so the listener can spawn further conn-channel listeners +/// recursively. +pub fn run_fdr_listener( + mut client: FdrClient, + connector: Arc, + conn_port: u16, +) -> Pin> + Send>> { + Box::pin(async move { + loop { + let tag = client.read_message_tag().await?; + match tag { + FDR_SYNC_MSG => { + // Consume the 2-byte sync payload and spin up a new conn channel. + let _ = client.idevice.read_raw(2).await?; + let connector = connector.clone(); + tokio::spawn(async move { + match connector.connect_device_port(conn_port).await { + Ok(idevice) => { + let mut conn = FdrClient::new(idevice); + if let Err(e) = conn.sync_handshake().await { + warn!("FDR conn handshake failed: {e}"); + return; + } + if let Err(e) = run_fdr_listener(conn, connector, conn_port).await { + debug!("FDR conn listener ended: {e}"); + } + } + Err(e) => warn!("FDR conn connect failed: {e}"), + } + }); + } + FDR_PROXY_MSG => { + // The proxy takes over this connection; the listener ends here. + return client.handle_proxy().await; + } + FDR_PLIST_MSG => client.handle_plist().await?, + other => warn!("FDR: ignoring message tag {other:#x}"), + } + } + }) +} diff --git a/src-tauri/vendor/idevice/src/services/restore/fw_updater.rs b/src-tauri/vendor/idevice/src/services/restore/fw_updater.rs new file mode 100644 index 0000000..714f1f7 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/restore/fw_updater.rs @@ -0,0 +1,363 @@ +use async_zip::base::read::seek::ZipFileReader; +use async_zip::base::write::ZipFileWriter; +use async_zip::{Compression, ZipEntryBuilder}; +use futures::AsyncReadExt as _; +use plist::Value; +use tracing::{info, warn}; + +use super::{ipsw, mbn, state_machine::RestoreContext}; +use crate::{IdeviceError, services::restore::RestoreError, tss::TSSRequest}; + +/// Handles a `FirmwareUpdaterData` request. +pub(super) async fn send_firmware_updater_data( + ctx: &mut RestoreContext<'_>, + message: &plist::Dictionary, +) -> Result<(), IdeviceError> { + let arguments = message + .get("Arguments") + .and_then(Value::as_dictionary) + .ok_or_else(|| IdeviceError::Restore(RestoreError::MissingField("Arguments".into())))? + .clone(); + + let updater_name = arguments + .get("MessageArgUpdaterName") + .and_then(Value::as_string) + .unwrap_or("") + .to_string(); + + if arguments.get("MessageArgType").and_then(Value::as_string) != Some("FirmwareResponseData") { + return Err(IdeviceError::Restore(RestoreError::Other(format!( + "unexpected MessageArgType for updater {updater_name}" + )))); + } + + let fwdict = if arguments.get("DeviceGeneratedRequest").is_some() { + get_device_generated_firmware_data(ctx, &arguments).await? + } else { + return Err(IdeviceError::Restore(RestoreError::Unsupported(format!( + "updater `{updater_name}` without a DeviceGeneratedRequest" + )))); + }; + + info!("sending FirmwareResponseData for {updater_name}"); + super::data_request::send_to_data_service( + ctx, + message, + crate::plist!({ "FirmwareResponseData": Value::Dictionary(fwdict) }), + ) + .await +} + +/// Signs a device-generated firmware TSS request and returns the response. +async fn get_device_generated_firmware_data( + ctx: &mut RestoreContext<'_>, + arguments: &plist::Dictionary, +) -> Result { + let device_request = arguments + .get("DeviceGeneratedRequest") + .and_then(Value::as_dictionary) + .ok_or_else(|| { + IdeviceError::Restore(RestoreError::MissingField("DeviceGeneratedRequest".into())) + })?; + + let response_ticket = arguments + .get("DeviceGeneratedTags") + .and_then(Value::as_dictionary) + .and_then(|t| t.get("ResponseTags")) + .and_then(Value::as_array) + .and_then(|a| a.first()) + .and_then(Value::as_string) + .map(str::to_string); + + let build_identity_tags: Vec<&str> = arguments + .get("DeviceGeneratedTags") + .and_then(Value::as_dictionary) + .and_then(|t| t.get("BuildIdentityTags")) + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(Value::as_string).collect()) + .unwrap_or_default(); + + let mut request = TSSRequest::new(); + request.add_common_tags(ctx.board_id, ctx.chip_id, ctx.ecid, None, None); + request.add_build_identity_tags(ctx.build_identity, &["ApBoardID", "ApChipID"]); + request.add_build_identity_tags(ctx.build_identity, &build_identity_tags); + request.set_bb_ticket(true); + request.insert("ApSecurityMode", true); + + // Default production mode true, overridden by any ProductionMode in the info. + let mut production_mode = true; + if let Some(info) = arguments + .get("MessageArgInfo") + .and_then(Value::as_dictionary) + { + for (k, v) in info { + if k.ends_with("ProductionMode") { + production_mode = v.as_boolean().unwrap_or(true); + } + } + } + request.insert("ApProductionMode", production_mode); + + let manifest = ctx + .build_identity + .get("Manifest") + .and_then(Value::as_dictionary); + for (k, v) in device_request { + match v { + Value::Dictionary(dev_node) if dev_node.contains_key("Digest") => { + let mut node = dev_node.clone(); + if let Some(digest) = manifest + .and_then(|m| m.get(k)) + .and_then(Value::as_dictionary) + .and_then(|mn| mn.get("Digest")) + { + node.insert("Digest".into(), digest.clone()); + } + request.insert(k.clone(), Value::Dictionary(node)); + } + _ => request.insert(k.clone(), v.clone()), + } + } + + // This redacted field must not be sent. + request.remove("RequiresUIDMode"); + + let response = request.send().await?; + let response = match response { + Value::Dictionary(d) => d, + _ => { + return Err(IdeviceError::Restore(RestoreError::TssResponse( + "response is not a dictionary".into(), + ))); + } + }; + if let Some(rt) = &response_ticket + && !response.contains_key(rt) + { + warn!("device-generated TSS response missing `{rt}`"); + } + Ok(response) +} + +/// Handles a `BasebandData` request: signs the baseband ticket and sends the +/// stitched baseband firmware. +pub(super) async fn send_baseband_data( + ctx: &mut RestoreContext<'_>, + message: &plist::Dictionary, +) -> Result<(), IdeviceError> { + let arguments = message.get("Arguments").and_then(Value::as_dictionary); + + let bb_chip_id = arguments + .and_then(|a| a.get("ChipID")) + .and_then(Value::as_unsigned_integer); + let bb_cert_id = arguments.and_then(|a| a.get("CertID")).cloned(); + let bb_snum = arguments.and_then(|a| a.get("ChipSerialNo")).cloned(); + let bb_nonce = arguments.and_then(|a| a.get("Nonce")).cloned(); + + // Build the baseband TSS request. + let mut parameters = plist::Dictionary::new(); + parameters.insert("ApECID".into(), (ctx.ecid).into()); + if let Some(n) = &bb_nonce { + parameters.insert("BbNonce".into(), n.clone()); + } + if let Some(id) = bb_chip_id { + parameters.insert("BbChipID".into(), id.into()); + } + if let Some(cert) = &bb_cert_id { + parameters.insert("BbGoldCertId".into(), cert.clone()); + } + if let Some(snum) = &bb_snum { + parameters.insert("BbSNUM".into(), snum.clone()); + } + if let Some(manifest) = ctx.build_identity.get("Manifest") { + parameters.insert("Manifest".into(), manifest.clone()); + } + + // Copy the identity-level baseband tags the TSS request needs: the manifest + // key hashes, the Pearl root pub, and the OS version. + const BB_IDENTITY_KEYS: &[&str] = &[ + "BbProvisioningManifestKeyHash", + "BbActivationManifestKeyHash", + "BbCalibrationManifestKeyHash", + "BbFactoryActivationManifestKeyHash", + "BbFDRSecurityKeyHash", + "BbSkeyId", + "PearlCertificationRootPub", + "Ap,OSLongVersion", + ]; + for &key in BB_IDENTITY_KEYS { + if let Some(v) = ctx.build_identity.get(key) { + parameters.insert(key.into(), v.clone()); + } + } + + let mut request = TSSRequest::new(); + request.add_common_tags(ctx.board_id, ctx.chip_id, ctx.ecid, None, None); + request.add_baseband_tags(¶meters); + let response = request.send().await?; + let bbtss = match response { + Value::Dictionary(d) => d, + _ => { + return Err(IdeviceError::Restore(RestoreError::TssResponse( + "baseband response is not a dictionary".into(), + ))); + } + }; + + // Read and stitch the baseband firmware zip. + let bbfw_path = ipsw::component_path(ctx.build_identity, "BasebandFirmware").or_else(|_| { + ctx.build_identity + .get("Manifest") + .and_then(|m| m.as_dictionary()) + .and_then(|m| m.get("BasebandFirmware")) + .and_then(|b| b.as_dictionary()) + .and_then(|b| b.get("Info")) + .and_then(|i| i.as_dictionary()) + .and_then(|i| i.get("Path")) + .and_then(Value::as_string) + .map(str::to_string) + .ok_or_else(|| { + IdeviceError::Restore(RestoreError::ComponentNotFound("BasebandFirmware".into())) + }) + })?; + let bbfw = ctx.components.read_component(&bbfw_path).await?; + let stitched = sign_bbfw(&bbfw, &bbtss, bb_chip_id).await?; + + info!("sending BasebandData ({} bytes)", stitched.len()); + super::data_request::send_to_data_service( + ctx, + message, + crate::plist!({ "BasebandData": Value::Data(stitched) }), + ) + .await +} + +/// Stitches the per-file signature blobs from `bbtss` into the baseband firmware +/// zip `bbfw`, returning a new zip. +async fn sign_bbfw( + bbfw: &[u8], + bbtss: &plist::Dictionary, + bb_chip_id: Option, +) -> Result, IdeviceError> { + let bbfw_dict = bbtss + .get("BasebandFirmware") + .and_then(Value::as_dictionary) + .ok_or_else(|| { + IdeviceError::Restore(RestoreError::Baseband( + "BBTicket missing BasebandFirmware".into(), + )) + })?; + + fn zip_err(e: E) -> IdeviceError { + IdeviceError::Restore(RestoreError::Ipsw(format!("baseband zip: {e}"))) + } + + // Read every entry of the input zip into memory (baseband firmware is small). + let mut reader = ZipFileReader::with_tokio(std::io::Cursor::new(bbfw.to_vec())) + .await + .map_err(zip_err)?; + let names: Vec = reader + .file() + .entries() + .iter() + .map(|e| e.filename().as_str().unwrap_or_default().to_string()) + .collect(); + let mut files: std::collections::HashMap> = std::collections::HashMap::new(); + for (i, name) in names.iter().enumerate() { + let mut entry = reader.reader_with_entry(i).await.map_err(zip_err)?; + let mut buf = Vec::new(); + entry.read_to_end(&mut buf).await.map_err(zip_err)?; + files.insert(name.clone(), buf); + } + + let mut out = Vec::new(); + let mut writer = ZipFileWriter::with_tokio(&mut out); + + for (key, blob) in bbfw_dict { + if !key.ends_with("-Blob") { + continue; + } + let Value::Data(blob) = blob else { continue }; + let elem = key.split('-').next().unwrap_or(key.as_str()); + let filename = bbfw_filename_for_element(elem, bb_chip_id).ok_or_else(|| { + IdeviceError::Restore(RestoreError::Baseband(format!( + "no baseband file mapping for element `{elem}`" + ))) + })?; + if bb_chip_id == Some(0x1F30E1) || filename.ends_with(".fls") { + return Err(IdeviceError::Restore(RestoreError::Unsupported(format!( + "baseband stitching for `{filename}` (Mav25/fls)" + )))); + } + let data = files.get(filename).ok_or_else(|| { + IdeviceError::Restore(RestoreError::Baseband(format!( + "baseband file `{filename}` missing" + ))) + })?; + let stitched = mbn::mbn_stitch(data, blob)?; + files.insert(filename.to_string(), stitched); + } + + // Keep every firmware file (`.fls/.mbn/.elf/.bin`), now stitched, and drop + // everything else + for (name, data) in &files { + let keep = matches!( + std::path::Path::new(name) + .extension() + .and_then(|e| e.to_str()), + Some("fls" | "mbn" | "elf" | "bin") + ); + if keep { + let entry = ZipEntryBuilder::new(name.clone().into(), Compression::Stored); + writer + .write_entry_whole(entry, data) + .await + .map_err(zip_err)?; + } + } + + // Inject the personalization ticket. For non-fls basebands the updater reads + // the signed ticket from `bbticket.der`. + if let Some(Value::Data(ticket)) = bbtss.get("BBTicket") { + let entry = ZipEntryBuilder::new("bbticket.der".into(), Compression::Stored); + writer + .write_entry_whole(entry, ticket) + .await + .map_err(zip_err)?; + } else { + tracing::warn!("baseband TSS response has no BBTicket; image will be unpersonalized"); + } + + writer.close().await.map_err(zip_err)?; + Ok(out) +} + +/// Maps a baseband ticket element name (e.g. `PSI`) to its firmware file name. +fn bbfw_filename_for_element(elem: &str, bb_chip_id: Option) -> Option<&'static str> { + if bb_chip_id == Some(0x1F30E1) { + // Mav25 (Qualcomm Snapdragon X80). + return Some(match elem { + "Misc" => "multi_image.mbn", + "RestoreSBL1" => "restorexbl_sc.elf", + "SBL1" => "xbl_sc.elf", + "TME" => "signed_firmware_soc_view.elf", + _ => return None, + }); + } + Some(match elem { + "RamPSI" => "psi_ram.fls", + "FlashPSI" => "psi_flash.fls", + "eDBL" => "dbl.mbn", + "RestoreDBL" => "restoredbl.mbn", + "DBL" => "dbl.mbn", + "ENANDPRG" => "ENPRG.mbn", + "RestoreSBL1" => "restoresbl1.mbn", + "SBL1" => "sbl1.mbn", + "RestorePSI" => "restorepsi.bin", + "PSI" => "psi_ram.bin", + "RestorePSI2" => "restorepsi2.bin", + "PSI2" => "psi_ram2.bin", + "Misc" => "multi_image.mbn", + _ => return None, + }) +} diff --git a/src-tauri/vendor/idevice/src/services/restore/img4.rs b/src-tauri/vendor/idevice/src/services/restore/img4.rs new file mode 100644 index 0000000..df3ea23 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/restore/img4.rs @@ -0,0 +1,614 @@ +//! IMG4 personalization (stitching) +//! +//! An IPSW firmware component is stored as an `IM4P` (payload) DER structure and +//! the TSS server returns an `IM4M` manifest (the `ApImg4Ticket`). To make a +//! component the device will accept, the two are combined, along with an +//! optional `IM4R` restore-info element into an outer `IMG4` container: +//! +//! ```text +//! IMG4 ::= SEQUENCE { +//! "IMG4" (IA5String), +//! im4p IM4P, -- embedded verbatim from the IPSW +//! im4m [0] EXPLICIT, -- the ApImg4Ticket, embedded verbatim +//! im4r [1] EXPLICIT -- optional restore info (nonce slot / TBM) +//! } +//! ``` +//! +//! Both the `IM4P` and `IM4M` are already valid DER produced by Apple, so they +//! are embedded byte-for-byte; only the outer container, the (optional) fourcc +//! patch and the `IM4R` element are constructed here. + +use der::{Encode, Length}; + +use crate::{IdeviceError, services::restore::RestoreError}; + +// ASN.1 identifier octets used when assembling the container. +const TAG_BOOLEAN: u8 = 0x01; +const TAG_INTEGER: u8 = 0x02; +const TAG_OCTET_STRING: u8 = 0x04; +const TAG_IA5_STRING: u8 = 0x16; +const TAG_SEQUENCE: u8 = 0x30; // SEQUENCE | CONSTRUCTED +const TAG_SET: u8 = 0x31; // SET | CONSTRUCTED +const TAG_CONTEXT_0: u8 = 0xA0; // [0] CONSTRUCTED +const TAG_CONTEXT_1: u8 = 0xA1; // [1] CONSTRUCTED +// PRIVATE | CONSTRUCTED | high-tag-number form (0xC0 | 0x20 | 0x1F). +const TAG_PRIVATE_HIGH: u8 = 0xFF; + +/// A property placed in the `IM4R` restore-info element. +/// +/// Each property is keyed by a four-character code (e.g. `snid`, `anid`, `ucon`, +/// `ucer`) and carried under a private-class ASN.1 tag whose number is the +/// big-endian fourcc. +#[derive(Debug, Clone)] +pub enum RestoreProperty { + /// An integer-valued property (nonce slot IDs `snid`/`anid`). + Integer { fourcc: [u8; 4], value: u64 }, + /// An octet-string-valued property (`*-TBM` `ucon`/`ucer` blobs). + OctetString { fourcc: [u8; 4], value: Vec }, +} + +/// Encodes an ASN.1 DER length field for `len` bytes using [`der::Length`]. +fn encode_len(len: usize) -> Result, IdeviceError> { + Length::try_from(len) + .and_then(|l| l.to_der()) + .map_err(|e| IdeviceError::Restore(RestoreError::Img4(format!("length {len}: {e}")))) +} + +/// Builds a tag-length-value element with the given identifier octet and content. +fn tlv(tag: u8, content: &[u8]) -> Result, IdeviceError> { + let mut out = Vec::with_capacity(content.len() + 4); + out.push(tag); + out.extend_from_slice(&encode_len(content.len())?); + out.extend_from_slice(content); + Ok(out) +} + +/// Encodes a non-negative integer as DER INTEGER content octets (minimal length, +/// with a leading `0x00` when the most-significant bit would otherwise be set). +fn der_uint(value: u64) -> Vec { + if value == 0 { + return vec![0]; + } + let mut bytes = value.to_be_bytes().to_vec(); + while bytes.len() > 1 && bytes[0] == 0 { + bytes.remove(0); + } + if bytes[0] & 0x80 != 0 { + bytes.insert(0, 0); + } + bytes +} + +/// Encodes `value` as an ASN.1 high-tag-number (base-128) tag number body. +fn base128(mut value: u32) -> Vec { + let mut groups = 0; + let mut tmp = value; + while tmp > 0 { + tmp >>= 7; + groups += 1; + } + if groups == 0 { + groups = 1; + } + let mut out = vec![0u8; groups]; + for i in (0..groups).rev() { + out[i] = (value & 0x7f) as u8; + if i != groups - 1 { + out[i] |= 0x80; + } + value >>= 7; + } + out +} + +/// Builds one `IM4R` property: a private-tagged element wrapping a +/// `SEQUENCE { IA5String fourcc, }`. +fn build_property(prop: &RestoreProperty) -> Result, IdeviceError> { + let (fourcc, mut inner) = match prop { + RestoreProperty::Integer { fourcc, value } => { + let mut inner = tlv(TAG_IA5_STRING, fourcc)?; + inner.extend_from_slice(&tlv(TAG_INTEGER, &der_uint(*value))?); + (*fourcc, inner) + } + RestoreProperty::OctetString { fourcc, value } => { + let mut inner = tlv(TAG_IA5_STRING, fourcc)?; + inner.extend_from_slice(&tlv(TAG_OCTET_STRING, value)?); + (*fourcc, inner) + } + }; + let seq = tlv(TAG_SEQUENCE, &inner)?; + inner.clear(); + + // Private, constructed, high-tag-number element whose tag number is the + // big-endian fourcc, wrapping the sequence. + let mut out = vec![TAG_PRIVATE_HIGH]; + out.extend_from_slice(&base128(u32::from_be_bytes(fourcc))); + out.extend_from_slice(&encode_len(seq.len())?); + out.extend_from_slice(&seq); + Ok(out) +} + +/// Builds the `[1] EXPLICIT { SEQUENCE { IA5String "IM4R", SET { props } } }` +/// restore-info element. +fn build_im4r(props: &[RestoreProperty]) -> Result, IdeviceError> { + let mut set_content = Vec::new(); + for p in props { + set_content.extend_from_slice(&build_property(p)?); + } + let mut seq_content = tlv(TAG_IA5_STRING, b"IM4R")?; + seq_content.extend_from_slice(&tlv(TAG_SET, &set_content)?); + let seq = tlv(TAG_SEQUENCE, &seq_content)?; + tlv(TAG_CONTEXT_1, &seq) +} + +/// Encodes a DER BOOLEAN content octet (`0xFF` = true, `0x00` = false). +fn der_bool(value: bool) -> Vec { + vec![if value { 0xFF } else { 0x00 }] +} + +fn named_element(name: &[u8], value: &[u8]) -> Result, IdeviceError> { + let mut tag4 = [0u8; 4]; + let n = name.len().min(4); + tag4[..n].copy_from_slice(&name[..n]); + let mut inner = tlv(TAG_IA5_STRING, name)?; + inner.extend_from_slice(value); + let seq = tlv(TAG_SEQUENCE, &inner)?; + let mut out = vec![TAG_PRIVATE_HIGH]; + out.extend_from_slice(&base128(u32::from_be_bytes(tag4))); + out.extend_from_slice(&encode_len(seq.len())?); + out.extend_from_slice(&seq); + Ok(out) +} + +/// [`named_element`] for a plain four-character code. +fn fourcc_element(fourcc: [u8; 4], value: &[u8]) -> Result, IdeviceError> { + named_element(&fourcc, value) +} + +/// One component entry in a local `IM4M` manifest. +struct LocalManifestComponent { + /// The element name: a fourcc for mapped components, or the full manifest key + /// for unmapped ones (the full key is written as the IA5 string; only the + /// ASN.1 tag number is truncated to four bytes). + name: Vec, + digest: Option>, + /// `EKEY` (from the manifest's `Trusted`). + ekey: Option, + epro: Option, + esec: Option, + /// SEP Trust Boot Manifest digests (`TBMDigests`), written as `tbms` for + /// `sepi` and `tbmr` for `rsep`. The SEP requires these to accept the manifest. + tbm_digests: Option>, +} + +/// Builds an unsigned ("local") `IM4M` manifest. +/// +/// `IM4M ::= SEQUENCE { IA5String "IM4M", INTEGER 0, SET { MANB } }`, where `MANB` +/// wraps `MANP` (the board/chip properties) followed by one element per component. +fn build_local_manifest( + board_id: u64, + chip_id: u64, + production_mode: bool, + security_domain: u64, + components: &[LocalManifestComponent], +) -> Result, IdeviceError> { + // MANP: manifest properties. + let mut props = Vec::new(); + props.extend(fourcc_element( + *b"BORD", + &tlv(TAG_INTEGER, &der_uint(board_id))?, + )?); + props.extend(fourcc_element(*b"CEPO", &tlv(TAG_INTEGER, &der_uint(0))?)?); + props.extend(fourcc_element( + *b"CHIP", + &tlv(TAG_INTEGER, &der_uint(chip_id))?, + )?); + props.extend(fourcc_element( + *b"CPRO", + &tlv(TAG_BOOLEAN, &der_bool(production_mode))?, + )?); + props.extend(fourcc_element( + *b"CSEC", + &tlv(TAG_BOOLEAN, &der_bool(false))?, + )?); + props.extend(fourcc_element( + *b"SDOM", + &tlv(TAG_INTEGER, &der_uint(security_domain))?, + )?); + + // MANB set: MANP followed by each component. + let mut manb_content = fourcc_element(*b"MANP", &tlv(TAG_SET, &props)?)?; + for c in components { + let mut body = Vec::new(); + if let Some(digest) = &c.digest + && !digest.is_empty() + { + body.extend(fourcc_element(*b"DGST", &tlv(TAG_OCTET_STRING, digest)?)?); + } + if let Some(v) = c.ekey { + body.extend(fourcc_element(*b"EKEY", &tlv(TAG_BOOLEAN, &der_bool(v))?)?); + } + if let Some(v) = c.epro { + body.extend(fourcc_element(*b"EPRO", &tlv(TAG_BOOLEAN, &der_bool(v))?)?); + } + if let Some(v) = c.esec { + body.extend(fourcc_element(*b"ESEC", &tlv(TAG_BOOLEAN, &der_bool(v))?)?); + } + if let Some(tbm) = &c.tbm_digests { + let tag = match c.name.as_slice() { + b"sepi" => Some(*b"tbms"), + b"rsep" => Some(*b"tbmr"), + _ => None, + }; + match tag { + Some(tag) => { + body.extend(fourcc_element(tag, &tlv(TAG_OCTET_STRING, tbm)?)?); + } + None => tracing::warn!( + "unexpected TBMDigests for component {:?}; omitting", + std::str::from_utf8(&c.name) + ), + } + } + manb_content.extend(named_element(&c.name, &tlv(TAG_SET, &body)?)?); + } + let manb = fourcc_element(*b"MANB", &tlv(TAG_SET, &manb_content)?)?; + + let mut content = tlv(TAG_IA5_STRING, b"IM4M")?; + content.extend(tlv(TAG_INTEGER, &der_uint(0))?); + content.extend(tlv(TAG_SET, &manb)?); + tlv(TAG_SEQUENCE, &content) +} + +/// Builds the local `IM4M` manifest for a preboard stashbag request, from a +/// build identity. +/// +/// `ApProductionMode`/`ApSecurityMode` are false and `ApSecurityDomain` is 1, and +/// only trusted firmware components are included, with their restore-request +/// rules applied. +pub fn build_preboard_manifest( + build_identity: &plist::Dictionary, + board_id: u64, + chip_id: u64, +) -> Result, IdeviceError> { + const SECURITY_DOMAIN: u64 = 1; + + let manifest = match build_identity.get("Manifest") { + Some(plist::Value::Dictionary(m)) => m, + _ => return Err(IdeviceError::BadBuildManifest), + }; + + // Parameters the restore-request rules are evaluated against. + let parameters = crate::plist!({ + "ApProductionMode": false, + "ApSecurityMode": false, + "ApSupportsImg4": true, + }); + let parameters = parameters.into_dictionary().unwrap(); + + const SKIP_KEYS: &[&str] = &[ + "BasebandFirmware", + "SE,UpdatePayload", + "BaseSystem", + "Diags", + "Ap,ExclaveOS", + ]; + const FW_FLAGS: &[&str] = &[ + "IsFirmwarePayload", + "IsSecondaryFirmwarePayload", + "IsFUDFirmware", + "IsLoadedByiBoot", + "IsEarlyAccessFirmware", + "IsiBootEANFirmware", + "IsiBootNonEssentialFirmware", + ]; + + let mut components = Vec::new(); + for (key, entry) in manifest { + if SKIP_KEYS.contains(&key.as_str()) { + continue; + } + let entry = match entry { + plist::Value::Dictionary(d) => d, + _ => continue, + }; + let info = match entry.get("Info").and_then(plist::Value::as_dictionary) { + Some(i) => i, + None => continue, + }; + let trusted = entry + .get("Trusted") + .and_then(plist::Value::as_boolean) + .unwrap_or(false); + + // Only trusted firmware payloads. + if !trusted { + continue; + } + let is_fw = FW_FLAGS.iter().any(|f| { + info.get(f) + .and_then(plist::Value::as_boolean) + .unwrap_or(false) + }); + if !is_fw { + continue; + } + if info + .get("IsFTAB") + .and_then(plist::Value::as_boolean) + .unwrap_or(false) + { + continue; + } + + // Prefer the manifest's own `Img4PayloadType`, then the known component + // table. For anything still unmapped, use the full component key as the + // element name + let name: Vec = info + .get("Img4PayloadType") + .and_then(plist::Value::as_string) + .map(|s| s.as_bytes().to_vec()) + .or_else(|| fourcc_for(key).map(|f| f.to_vec())) + .unwrap_or_else(|| { + tracing::debug!("preboard manifest: unmapped component `{key}`, using full key"); + key.as_bytes().to_vec() + }); + + // Apply restore-request rules against the (production-off) parameters. + let mut node = entry.clone(); + node.remove("Info"); + if let Some(plist::Value::Array(rules)) = info.get("RestoreRequestRules") { + crate::tss::apply_restore_request_rules(&mut node, ¶meters, rules); + } + + let digest = match node.get("Digest") { + Some(plist::Value::Data(d)) => Some(d.clone()), + // Trusted components without a digest carry an empty one. + _ => Some(Vec::new()), + }; + components.push(LocalManifestComponent { + name, + digest, + ekey: Some(trusted), + epro: node.get("EPRO").and_then(plist::Value::as_boolean), + esec: node.get("ESEC").and_then(plist::Value::as_boolean), + tbm_digests: match node.get("TBMDigests") { + Some(plist::Value::Data(d)) => Some(d.clone()), + _ => None, + }, + }); + } + + build_local_manifest(board_id, chip_id, false, SECURITY_DOMAIN, &components) +} + +/// Reads a single-byte-tag TLV at `off`, returning `(tag, content_len, content_start)`. +fn read_tlv(buf: &[u8], off: usize) -> Result<(u8, usize, usize), IdeviceError> { + let err = || { + IdeviceError::Restore(RestoreError::Img4( + "truncated IM4P while locating fourcc".into(), + )) + }; + let tag = *buf.get(off).ok_or_else(err)?; + let mut i = off + 1; + let first = *buf.get(i).ok_or_else(err)?; + i += 1; + let len = if first < 0x80 { + first as usize + } else { + let n = (first & 0x7f) as usize; + if n == 0 || n > 8 { + return Err(IdeviceError::Restore(RestoreError::Img4( + "invalid IM4P length field".into(), + ))); + } + let mut l = 0usize; + for _ in 0..n { + l = (l << 8) | *buf.get(i).ok_or_else(err)? as usize; + i += 1; + } + l + }; + if i + len > buf.len() { + return Err(err()); + } + Ok((tag, len, i)) +} + +fn patch_im4p_fourcc(im4p: &[u8], fourcc: [u8; 4]) -> Result, IdeviceError> { + let mut out = im4p.to_vec(); + + let (t, _len, seq_start) = read_tlv(&out, 0)?; + if t != TAG_SEQUENCE { + return Err(IdeviceError::Restore(RestoreError::Img4( + "IM4P is not a SEQUENCE".into(), + ))); + } + let (t0, l0, s0) = read_tlv(&out, seq_start)?; + if t0 != TAG_IA5_STRING { + return Err(IdeviceError::Restore(RestoreError::Img4( + "IM4P magic is not an IA5String".into(), + ))); + } + let (t1, l1, s1) = read_tlv(&out, s0 + l0)?; + if t1 != TAG_IA5_STRING || l1 != 4 { + return Err(IdeviceError::Restore(RestoreError::Img4( + "IM4P type is not a 4-byte IA5String".into(), + ))); + } + out[s1..s1 + 4].copy_from_slice(&fourcc); + Ok(out) +} + +/// Stitches an IPSW component into a personalized `IMG4`. +/// +/// # Errors +/// Returns [`IdeviceError::Img4`] if the `IM4P` is malformed or a field cannot be +/// encoded. +pub fn stitch_component( + im4p: &[u8], + ap_img4_ticket: &[u8], + fourcc_override: Option<[u8; 4]>, + restore_properties: &[RestoreProperty], +) -> Result, IdeviceError> { + let im4p = match fourcc_override { + Some(f) => patch_im4p_fourcc(im4p, f)?, + None => im4p.to_vec(), + }; + + let mut content = tlv(TAG_IA5_STRING, b"IMG4")?; + content.extend_from_slice(&im4p); + content.extend_from_slice(&tlv(TAG_CONTEXT_0, ap_img4_ticket)?); + if !restore_properties.is_empty() { + content.extend_from_slice(&build_im4r(restore_properties)?); + } + + tlv(TAG_SEQUENCE, &content) +} + +/// Returns the IMG4 four-character code for a build-manifest component name, if +/// known. +pub fn fourcc_for(component_name: &str) -> Option<[u8; 4]> { + let s: &[u8; 4] = match component_name { + "ACIBT" => b"acib", + "ACIBTLPEM" => b"lpbt", + "ACIWIFI" => b"aciw", + "ANE" => b"anef", + "ANS" => b"ansf", + "AOP" => b"aopf", + "AVE" => b"avef", + "Alamo" => b"almo", + "Ap,ANE1" => b"ane1", + "Ap,ANE2" => b"ane2", + "Ap,ANE3" => b"ane3", + "Ap,AudioAccessibilityBootChime" => b"auac", + "Ap,AudioBootChime" => b"aubt", + "Ap,AudioPowerAttachChime" => b"aupr", + "Ap,BootabilityBrainTrustCache" => b"trbb", + "Ap,CIO" => b"ciof", + "Ap,HapticAssets" => b"hpas", + "Ap,LocalBoot" => b"lobo", + "Ap,LocalPolicy" => b"lpol", + "Ap,NextStageIM4MHash" => b"nsih", + "Ap,RecoveryOSPolicyNonceHash" => b"ronh", + "Ap,RestoreANE1" => b"ran1", + "Ap,RestoreANE2" => b"ran2", + "Ap,RestoreANE3" => b"ran3", + "Ap,RestoreCIO" => b"rcio", + "Ap,RestoreDCP2" => b"rdc2", + "Ap,RestoreTMU" => b"rtmu", + "Ap,Scorpius" => b"scpf", + "Ap,SystemVolumeCanonicalMetadata" => b"msys", + "Ap,TMU" => b"tmuf", + "Ap,VolumeUUID" => b"vuid", + "Ap,rOSLogo1" => b"rlg1", + "Ap,rOSLogo2" => b"rlg2", + "AppleLogo" => b"logo", + "AudioCodecFirmware" => b"acfw", + "BatteryCharging" => b"glyC", + "BatteryCharging0" => b"chg0", + "BatteryCharging1" => b"chg1", + "BatteryFull" => b"batF", + "BatteryLow0" => b"bat0", + "BatteryLow1" => b"bat1", + "BatteryPlugin" => b"glyP", + "CFELoader" => b"cfel", + "CrownFirmware" => b"crwn", + "DCP" => b"dcpf", + "Dali" => b"dali", + "DeviceTree" => b"dtre", + "Diags" => b"diag", + "EngineeringTrustCache" => b"dtrs", + "ExtDCP" => b"edcp", + "GFX" => b"gfxf", + "Hamm" => b"hamf", + "Homer" => b"homr", + "ISP" => b"ispf", + "InputDevice" => b"ipdf", + "KernelCache" => b"krnl", + "LLB" => b"illb", + "LeapHaptics" => b"lphp", + "Liquid" => b"liqd", + "LoadableTrustCache" => b"ltrs", + "LowPowerWallet0" => b"lpw0", + "LowPowerWallet1" => b"lpw1", + "LowPowerWallet2" => b"lpw2", + "MacEFI" => b"mefi", + "MtpFirmware" => b"mtpf", + "Multitouch" => b"mtfw", + "NeedService" => b"nsrv", + "OS" => return Some([b'O', b'S', 0, 0]), + "OSRamdisk" => b"osrd", + "PEHammer" => b"hmmr", + "PERTOS" => b"pert", + "PHLEET" => b"phlt", + "PMP" => b"pmpf", + "PersonalizedDMG" => b"pdmg", + "RBM" => b"rmbt", + "RTP" => b"rtpf", + "Rap,SoftwareBinaryDsp1" => b"sbd1", + "Rap,RTKitOS" => b"rkos", + "Rap,RestoreRTKitOS" => b"rrko", + "RecoveryMode" => b"recm", + "RestoreANS" => b"rans", + "RestoreDCP" => b"rdcp", + "RestoreDeviceTree" => b"rdtr", + "RestoreExtDCP" => b"recp", + "RestoreKernelCache" => b"rkrn", + "RestoreLogo" => b"rlgo", + "RestoreRTP" => b"rrtp", + "RestoreRamDisk" => b"rdsk", + "RestoreSEP" => b"rsep", + "RestoreTrustCache" => b"rtsc", + "SCE" => b"scef", + "SCE1Firmware" => b"sc1f", + "SEP" => b"sepi", + "SIO" => b"siof", + "StaticTrustCache" => b"trst", + "SystemLocker" => b"lckr", + "SystemVolume" => b"isys", + "WCHFirmwareUpdater" => b"wchf", + "ftap" => b"ftap", + "ftsp" => b"ftsp", + "iBEC" => b"ibec", + "iBSS" => b"ibss", + "iBoot" => b"ibot", + "iBootData" => b"ibdt", + "iBootDataStage1" => b"ibd1", + "iBootTest" => b"itst", + "rfta" => b"rfta", + "rfts" => b"rfts", + "Ap,DCP2" => b"dcp2", + "Ap,RestoreSecureM3Firmware" => b"rsm3", + "Ap,RestoreSecurePageTableMonitor" => b"rspt", + "Ap,RestoreTrustedExecutionMonitor" => b"rtrx", + "Ap,RestorecL4" => b"rxcl", + _ => return None, + }; + Some(*s) +} + +/// Returns the fourcc a `Restore` component must be re-tagged with, if any +pub fn restore_fourcc_override(component_name: &str) -> Option<[u8; 4]> { + const RETAGGED: &[&str] = &[ + "RestoreKernelCache", + "RestoreDeviceTree", + "RestoreSEP", + "RestoreLogo", + "RestoreTrustCache", + "RestoreDCP", + "Ap,RestoreDCP2", + "Ap,RestoreTMU", + "Ap,RestoreCIO", + "Ap,DCP2", + "Ap,RestoreSecureM3Firmware", + "Ap,RestoreSecurePageTableMonitor", + "Ap,RestoreTrustedExecutionMonitor", + "Ap,RestorecL4", + ]; + if RETAGGED.contains(&component_name) { + fourcc_for(component_name) + } else { + None + } +} diff --git a/src-tauri/vendor/idevice/src/services/restore/ipsw.rs b/src-tauri/vendor/idevice/src/services/restore/ipsw.rs new file mode 100644 index 0000000..5ac378b --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/restore/ipsw.rs @@ -0,0 +1,254 @@ +//! IPSW archive access +//! +//! An IPSW is a ZIP archive containing a `BuildManifest.plist` and the firmware +//! components referenced by it. This module wraps [`async_zip`] to read the +//! manifest and extract components by their build-identity path. + +use async_zip::base::read::seek::ZipFileReader; +use futures::AsyncReadExt as _; +use tokio::io::{AsyncBufRead, AsyncSeek, AsyncWriteExt as _}; + +use crate::{IdeviceError, services::restore::RestoreError}; + +/// A reader over an IPSW archive. +/// +/// Generic over any seekable async source (a `tokio::fs::File`, an in-memory +/// cursor, etc.). The central directory is parsed once on construction. +pub struct Ipsw +where + R: AsyncBufRead + AsyncSeek + Unpin, +{ + zip: async_zip::tokio::read::seek::ZipFileReader, +} + +impl std::fmt::Debug for Ipsw +where + R: AsyncBufRead + AsyncSeek + Unpin, +{ + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Ipsw").finish_non_exhaustive() + } +} + +impl Ipsw +where + R: AsyncBufRead + AsyncSeek + Unpin, +{ + /// Opens an IPSW from a seekable async reader. + pub async fn new(reader: R) -> Result { + let zip = ZipFileReader::with_tokio(reader).await.map_err(|e| { + IdeviceError::Restore(RestoreError::Ipsw(format!("failed to open IPSW: {e}"))) + })?; + Ok(Self { zip }) + } + + /// Returns the archive entry index for an exact path, if present. + fn entry_index(&self, path: &str) -> Option { + self.zip + .file() + .entries() + .iter() + .position(|e| e.filename().as_str().map(|f| f == path).unwrap_or(false)) + } + + /// Reads a file from the archive by its exact path into memory. + /// + /// Suitable for firmware components (a few MB at most). The filesystem DMG + /// should be streamed instead (see the ASR path). + /// + /// # Errors + /// Returns [`IdeviceError::Ipsw`] if the entry is absent or cannot be read. + pub async fn read_file(&mut self, path: &str) -> Result, IdeviceError> { + let idx = self.entry_index(path).ok_or_else(|| { + IdeviceError::Restore(RestoreError::Ipsw(format!( + "entry `{path}` not found in IPSW" + ))) + })?; + let mut reader = self.zip.reader_with_entry(idx).await.map_err(|e| { + IdeviceError::Restore(RestoreError::Ipsw(format!( + "failed to open entry `{path}`: {e}" + ))) + })?; + let mut buf = Vec::new(); + reader.read_to_end(&mut buf).await.map_err(|e| { + IdeviceError::Restore(RestoreError::Ipsw(format!( + "failed to read entry `{path}`: {e}" + ))) + })?; + Ok(buf) + } + + /// Streams an archive entry into a caller-supplied async writer (for large + /// images like the filesystem DMG that can't be buffered in memory). + /// + /// idevice makes no host assumptions: the caller owns the sink. Native + /// consumers can pass a `tokio::fs::File`; wasm/embedded consumers can pass + /// whatever y'all use, etc. `writer` is flushed on success. + /// + /// # Errors + /// Returns [`IdeviceError::Ipsw`] if the entry is absent or I/O fails. + pub async fn extract_to_writer( + &mut self, + path: &str, + writer: &mut W, + ) -> Result<(), IdeviceError> + where + W: tokio::io::AsyncWrite + Unpin, + { + let idx = self.entry_index(path).ok_or_else(|| { + IdeviceError::Restore(RestoreError::Ipsw(format!( + "entry `{path}` not found in IPSW" + ))) + })?; + let mut reader = self.zip.reader_with_entry(idx).await.map_err(|e| { + IdeviceError::Restore(RestoreError::Ipsw(format!( + "failed to open entry `{path}`: {e}" + ))) + })?; + + let mut buf = vec![0u8; 1 << 20]; + loop { + let n = reader.read(&mut buf).await.map_err(|e| { + IdeviceError::Restore(RestoreError::Ipsw(format!("read `{path}`: {e}"))) + })?; + if n == 0 { + break; + } + writer.write_all(&buf[..n]).await.map_err(|e| { + IdeviceError::Restore(RestoreError::Ipsw(format!("write `{path}`: {e}"))) + })?; + } + writer.flush().await.map_err(|e| { + IdeviceError::Restore(RestoreError::Ipsw(format!("flush `{path}`: {e}"))) + })?; + Ok(()) + } + + /// Opens a streaming [`ComponentReader`](super::state_machine::ComponentReader) + /// over the archive entry `path`, reading directly from the archive without + /// buffering the whole component in memory. + pub(crate) async fn open_entry_reader<'a>( + &'a mut self, + path: &str, + ) -> Result, IdeviceError> + where + R: Send, + { + let idx = self.entry_index(path).ok_or_else(|| { + IdeviceError::Restore(RestoreError::Ipsw(format!( + "entry `{path}` not found in IPSW" + ))) + })?; + let reader = self.zip.reader_with_entry(idx).await.map_err(|e| { + IdeviceError::Restore(RestoreError::Ipsw(format!( + "failed to open entry `{path}`: {e}" + ))) + })?; + Ok(Box::new(EntryReader { inner: reader })) + } + + /// Reads and parses `BuildManifest.plist`. + pub async fn build_manifest(&mut self) -> Result { + let bytes = self.read_file("BuildManifest.plist").await?; + match plist::from_bytes::(&bytes)? { + plist::Value::Dictionary(d) => Ok(d), + _ => Err(IdeviceError::Restore(RestoreError::Ipsw( + "BuildManifest.plist is not a dictionary".into(), + ))), + } + } + + /// Reads the component named `component` for the given build identity, + /// resolving its path via the identity's `Manifest`. + pub async fn read_component( + &mut self, + build_identity: &plist::Dictionary, + component: &str, + ) -> Result, IdeviceError> { + let path = component_path(build_identity, component)?; + self.read_file(&path).await + } +} + +/// Adapts an `async_zip` per-entry reader (a `futures` `AsyncRead`) to +/// [`ComponentReader`](super::state_machine::ComponentReader). +struct EntryReader { + inner: T, +} + +impl super::state_machine::ComponentReader for EntryReader +where + T: futures::AsyncRead + Unpin + Send, +{ + fn read<'a>( + &'a mut self, + buf: &'a mut [u8], + ) -> std::pin::Pin> + Send + 'a>> + { + Box::pin(async move { + self.inner.read(buf).await.map_err(|e| { + IdeviceError::Restore(RestoreError::Ipsw(format!("read archive entry: {e}"))) + }) + }) + } +} + +/// Resolves the archive path of a component from a build identity's `Manifest`. +/// +/// Looks up `Manifest[component].Info.Path`. +/// +/// # Errors +/// Returns [`IdeviceError::ComponentNotFound`] if the component or its path is +/// missing. +pub fn component_path( + build_identity: &plist::Dictionary, + component: &str, +) -> Result { + build_identity + .get("Manifest") + .and_then(|m| m.as_dictionary()) + .and_then(|m| m.get(component)) + .and_then(|c| c.as_dictionary()) + .and_then(|c| c.get("Info")) + .and_then(|i| i.as_dictionary()) + .and_then(|i| i.get("Path")) + .and_then(|p| p.as_string()) + .map(|s| s.to_string()) + .ok_or_else(|| { + IdeviceError::Restore(RestoreError::ComponentNotFound(component.to_string())) + }) +} + +/// Names of the components iBoot loads during the restore boot, in manifest +/// order: those whose `Info` has `IsLoadedByiBoot` set but not +/// `IsLoadedByiBootStage1`. Each is uploaded and followed by a `firmware` command. +pub fn components_loaded_by_iboot(build_identity: &plist::Dictionary) -> Vec { + let mut out = Vec::new(); + let Some(manifest) = build_identity + .get("Manifest") + .and_then(|m| m.as_dictionary()) + else { + return out; + }; + for (name, node) in manifest { + let Some(info) = node + .as_dictionary() + .and_then(|n| n.get("Info")) + .and_then(|i| i.as_dictionary()) + else { + continue; + }; + let iboot = info + .get("IsLoadedByiBoot") + .and_then(|v| v.as_boolean()) + .unwrap_or(false); + let stage1 = info + .get("IsLoadedByiBootStage1") + .and_then(|v| v.as_boolean()) + .unwrap_or(false); + if iboot && !stage1 { + out.push(name.clone()); + } + } + out +} diff --git a/src-tauri/vendor/idevice/src/services/restore/mbn.rs b/src-tauri/vendor/idevice/src/services/restore/mbn.rs new file mode 100644 index 0000000..2f7b983 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/restore/mbn.rs @@ -0,0 +1,86 @@ +//! Baseband firmware (MBN) stitching + +use tracing::{debug, warn}; + +use crate::{IdeviceError, services::restore::RestoreError}; + +const MBN_V1_MAGIC: &[u8] = &[0x0a, 0x00, 0x00, 0x00]; +const MBN_V2_MAGIC: &[u8] = &[0xd1, 0xdc, 0x4b, 0x84, 0x34, 0x10, 0xd7, 0x73]; +const MBN_BIN_MAGIC: &[u8] = &[0x04, 0x00, 0xea, 0x6c, 0x69, 0x48, 0x55]; +const MBN_BIN_MAGIC_OFFSET: usize = 1; + +/// Header sizes (bytes) of the recognized container formats. +const MBN_V1_HEADER: usize = 40; // 10 * u32 +const MBN_V2_HEADER: usize = 56; // 14 * u32 + +/// Reads a little-endian `u32` at `offset`, if in range. +fn read_u32_le(data: &[u8], offset: usize) -> Option { + data.get(offset..offset + 4) + .map(|b| u32::from_le_bytes([b[0], b[1], b[2], b[3]])) +} + +/// Best-effort detection of the "real" image size (excluding trailing signature), +/// used only to warn on a size mismatch. +fn detect_image_size(data: &[u8]) -> Option { + if data.len() > MBN_V2_MAGIC.len() && data.starts_with(MBN_V2_MAGIC) { + // MBN v2: data_size field at u32 index 10. + let data_size = read_u32_le(data, 10 * 4)? as usize; + Some(data_size + MBN_V2_HEADER) + } else if data.len() > MBN_V1_MAGIC.len() && data.starts_with(MBN_V1_MAGIC) { + // MBN v1: data_size field at u32 index 8. + let data_size = read_u32_le(data, 8 * 4)? as usize; + Some(data_size + MBN_V1_HEADER) + } else if data.len() > MBN_BIN_MAGIC.len() + MBN_BIN_MAGIC_OFFSET + && data[MBN_BIN_MAGIC_OFFSET..MBN_BIN_MAGIC_OFFSET + MBN_BIN_MAGIC.len()] == *MBN_BIN_MAGIC + { + // MBN BIN: total_size at u32 index 1. + read_u32_le(data, 4).map(|s| s as usize) + } else { + None + } +} + +/// Overwrites the trailing signature region of `data` with `blob`. +/// +/// Returns `data` with its final `blob.len()` bytes replaced by `blob`. +/// +/// # Errors +/// Returns [`IdeviceError::Restore`] if either input is empty or `blob` is larger +/// than `data`. +pub fn mbn_stitch(data: &[u8], blob: &[u8]) -> Result, IdeviceError> { + if data.is_empty() { + return Err(IdeviceError::Restore(RestoreError::Baseband( + "mbn_stitch: data is empty".into(), + ))); + } + if blob.is_empty() { + return Err(IdeviceError::Restore(RestoreError::Baseband( + "mbn_stitch: blob is empty".into(), + ))); + } + if blob.len() > data.len() { + return Err(IdeviceError::Restore(RestoreError::Baseband(format!( + "mbn_stitch: blob ({}) larger than data ({})", + blob.len(), + data.len() + )))); + } + + if let Some(parsed) = detect_image_size(data) + && parsed != data.len() + { + warn!( + "mbn_stitch: size mismatch, header says {parsed:#x}, input is {:#x}", + data.len() + ); + } + + let stitch_offset = data.len() - blob.len(); + debug!( + "mbn_stitch: stitching at {stitch_offset:#x}, size {:#x}", + blob.len() + ); + let mut out = data.to_vec(); + out[stitch_offset..].copy_from_slice(blob); + Ok(out) +} diff --git a/src-tauri/vendor/idevice/src/services/restore/mod.rs b/src-tauri/vendor/idevice/src/services/restore/mod.rs new file mode 100644 index 0000000..e8596d6 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/restore/mod.rs @@ -0,0 +1,54 @@ +//! Full IPSW restore +//! +//! Reimplements the device-restore protocol stack in pure Rust: +//! +//! - [`ipsw`] - reading the IPSW archive and its `BuildManifest.plist`. +//! - [`img4`] - IMG4 personalization (stitching a component with its TSS ticket). +//! - [`restored`] - the restore-mode `com.apple.mobile.restored` client. +//! - [`options`] - the `RestoreOptions` sent with `StartRestore`. +//! - [`state_machine`] - the message loop driving the restore to completion. +//! - [`data_request`] - per-`DataType` request handlers. +//! - [`asr`] - Apple Software Restore: streaming the filesystem image. +//! - [`fdr`] - the FDR trust channel (spawned alongside the restore loop). + +#[cfg(feature = "restore")] +pub mod asr; +#[cfg(feature = "restore")] +pub mod data_request; +pub mod errors; +#[cfg(feature = "restore")] +pub mod fdr; +#[cfg(feature = "restore")] +pub mod fw_updater; +#[cfg(feature = "restore")] +pub mod img4; +#[cfg(feature = "restore")] +pub mod ipsw; +#[cfg(feature = "restore")] +pub mod mbn; +#[cfg(feature = "restore")] +pub mod options; +#[cfg(feature = "restore_recovery")] +pub mod recovery; +#[cfg(feature = "restore")] +pub mod restored; +#[cfg(feature = "restore")] +pub mod state_machine; + +#[cfg(feature = "restore")] +pub use asr::{AsrClient, FilesystemImage}; +pub use errors::RestoreError; +#[cfg(feature = "restore")] +pub use fdr::{FdrClient, FdrConnector, run_fdr_listener}; +#[cfg(feature = "restore")] +pub use options::RestoreOptions; +#[cfg(feature = "restore_recovery")] +pub use recovery::{ControlSetup, DeviceInfo, Mode, RecoveryDevice, RecoveryTransport}; +#[cfg(feature = "restore")] +pub use restored::RestoredClient; +#[cfg(feature = "restore")] +pub use state_machine::{ + ComponentReader, ComponentSource, DataPortConnector, ProviderDataPorts, RestoreCancel, + RestoreContext, RestoreProgressEvent, RestoreProgressReceiver, RestoreProgressSender, + progress_channel, run_restore, +}; diff --git a/src-tauri/vendor/idevice/src/services/restore/options.rs b/src-tauri/vendor/idevice/src/services/restore/options.rs new file mode 100644 index 0000000..bd9a810 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/restore/options.rs @@ -0,0 +1,254 @@ +use plist::Value; + +/// Builder for the `RestoreOptions` dictionary of an iOS restore. +#[derive(Debug, Clone)] +pub struct RestoreOptions { + /// Delay (seconds) before the device auto-boots after restore. + pub auto_boot_delay: i64, + /// Whether NOR should be flashed. + pub flash_nor: bool, + /// Whether the baseband should be updated during restore. + pub update_baseband: bool, + /// Whether personalization happens during preflight. + pub personalized_during_preflight: bool, + /// Optional boot-args set on the device before restore. + pub restore_boot_args: Option, + /// Optional baseband nonce (from firmware preflight info). + pub baseband_nonce: Option>, + pub bb_updater_state: Option, + /// Optional TZ0 required capacity (from the SEP manifest info). + pub tz0_required_capacity: Option, + /// The unique restore session UUID (upper-case). + pub uuid: String, +} + +impl Default for RestoreOptions { + fn default() -> Self { + Self { + auto_boot_delay: 0, + flash_nor: true, + update_baseband: false, + personalized_during_preflight: true, + restore_boot_args: None, + baseband_nonce: None, + bb_updater_state: None, + tz0_required_capacity: None, + uuid: uuid::Uuid::new_v4().to_string().to_uppercase(), + } + } +} + +impl RestoreOptions { + /// Creates options with iOS-restore defaults. + pub fn new() -> Self { + Self::default() + } + + /// Serializes the options into the dictionary expected by `StartRestore`. + pub fn build(&self) -> plist::Dictionary { + let mut d = plist::Dictionary::new(); + + d.insert("AutoBootDelay".into(), self.auto_boot_delay.into()); + d.insert("BootImageType".into(), "UserOrInternal".into()); + d.insert("DFUFileType".into(), "RELEASE".into()); + d.insert("DataImage".into(), false.into()); + d.insert("FirmwareDirectory".into(), ".".into()); + d.insert("FlashNOR".into(), self.flash_nor.into()); + d.insert("KernelCacheType".into(), "Release".into()); + d.insert("NORImageType".into(), "production".into()); + d.insert("RestoreBundlePath".into(), "/tmp/Per2.tmp".into()); + d.insert("SystemImageType".into(), "User".into()); + d.insert("UpdateBaseband".into(), self.update_baseband.into()); + + // iOS 18+ additions. + d.insert("HostHasFixFor99053849".into(), true.into()); + d.insert("SystemImageFormat".into(), "AEAWrappedDiskImage".into()); + d.insert( + "WaitForDeviceConnectionToFinishStateMachine".into(), + false.into(), + ); + d.insert( + "SupportedAsyncDataTypes".into(), + Value::Dictionary(supported_async_data_types()), + ); + + d.insert( + "PersonalizedDuringPreflight".into(), + self.personalized_during_preflight.into(), + ); + + d.insert("RootToInstall".into(), false.into()); + d.insert("UUID".into(), self.uuid.clone().into()); + d.insert("CreateFilesystemPartitions".into(), true.into()); + d.insert("SystemImage".into(), true.into()); + d.insert( + "SystemPartitionPadding".into(), + Value::Dictionary(default_system_partition_padding()), + ); + + d.insert( + "SupportedDataTypes".into(), + Value::Dictionary(supported_data_types()), + ); + d.insert( + "SupportedMessageTypes".into(), + Value::Dictionary(supported_message_types()), + ); + + if let Some(args) = &self.restore_boot_args { + d.insert("RestoreBootArgs".into(), args.clone().into()); + } + if let Some(nonce) = &self.baseband_nonce { + d.insert("BasebandNonce".into(), Value::Data(nonce.clone())); + } + if let Some(state) = &self.bb_updater_state { + d.insert("BBUpdaterState".into(), Value::Dictionary(state.clone())); + } + if let Some(cap) = self.tz0_required_capacity { + d.insert("TZ0RequiredCapacity".into(), cap.into()); + } + + d + } +} + +/// The default `SystemPartitionPadding` table. +fn default_system_partition_padding() -> plist::Dictionary { + let mut d = plist::Dictionary::new(); + for (k, v) in [ + ("8", 80i64), + ("16", 160), + ("32", 320), + ("64", 640), + ("128", 1280), + ("256", 1280), + ("512", 1280), + ("768", 1280), + ("1024", 1280), + ] { + d.insert(k.into(), v.into()); + } + d +} + +fn supported_async_data_types() -> plist::Dictionary { + let mut d = plist::Dictionary::new(); + for (k, v) in [ + ("BasebandData", false), + ("RecoveryOSASRImage", false), + ("StreamedImageDecryptionKey", false), + ("SystemImageData", false), + ("URLAsset", true), + ] { + d.insert(k.into(), v.into()); + } + d +} + +/// The `SupportedDataTypes` table advertised to `restored`. +fn supported_data_types() -> plist::Dictionary { + let mut d = plist::Dictionary::new(); + for (k, v) in SUPPORTED_DATA_TYPES { + d.insert((*k).into(), (*v).into()); + } + d +} + +/// The `SupportedMessageTypes` table advertised to `restored`. +fn supported_message_types() -> plist::Dictionary { + let mut d = plist::Dictionary::new(); + for (k, v) in SUPPORTED_MESSAGE_TYPES { + d.insert((*k).into(), (*v).into()); + } + d +} + +/// `(data type, whether it may be sent asynchronously)`. +const SUPPORTED_DATA_TYPES: &[(&str, bool)] = &[ + ("BasebandBootData", false), + ("BasebandData", false), + ("BasebandStackData", false), + ("BasebandUpdaterOutputData", false), + ("BootabilityBundle", false), + ("BuildIdentityDict", false), + ("BuildIdentityDictV2", false), + ("DataType", false), + ("DiagData", false), + ("EANData", false), + ("FDRMemoryCommit", false), + ("FDRTrustData", false), + ("FUDData", false), + ("FileData", false), + ("FileDataDone", false), + ("FirmwareUpdaterData", false), + ("GrapeFWData", false), + ("HPMFWData", false), + ("HostSystemTime", true), + ("KernelCache", false), + ("NORData", false), + ("NitrogenFWData", true), + ("OpalFWData", false), + ("OverlayRootDataCount", false), + ("OverlayRootDataForKey", true), + ("PeppyFWData", true), + ("PersonalizedBootObjectV3", false), + ("PersonalizedData", true), + ("ProvisioningData", false), + ("RamdiskFWData", true), + ("RecoveryOSASRImage", true), + ("RecoveryOSAppleLogo", true), + ("RecoveryOSDeviceTree", true), + ("RecoveryOSFileAssetImage", true), + ("RecoveryOSIBEC", true), + ("RecoveryOSIBootFWFilesImages", true), + ("RecoveryOSImage", true), + ("RecoveryOSKernelCache", true), + ("RecoveryOSLocalPolicy", true), + ("RecoveryOSOverlayRootDataCount", false), + ("RecoveryOSRootTicketData", true), + ("RecoveryOSStaticTrustCache", true), + ("RecoveryOSVersionData", true), + ("RootData", false), + ("RootTicket", false), + ("S3EOverride", false), + ("SourceBootObjectV3", false), + ("SourceBootObjectV4", false), + ("SsoServiceTicket", false), + ("StockholmPostflight", false), + ("SystemImageCanonicalMetadata", false), + ("SystemImageData", false), + ("SystemImageRootHash", false), + ("USBCFWData", false), + ("USBCOverride", false), + ("FirmwareUpdaterPreflight", true), + ("ReceiptManifest", true), + ("FirmwareUpdaterDataV2", false), + ("RestoreLocalPolicy", true), + ("AuthInstallCACert", true), + ("OverlayRootDataForKeyIndex", true), + ("FirmwareUpdaterDataV3", true), + ("MessageUseStreamedImageFile", true), + ("UpdateVolumeOverlayRootDataCount", true), + ("URLAsset", true), +]; + +/// `(message type, whether it may be sent asynchronously)`. +const SUPPORTED_MESSAGE_TYPES: &[(&str, bool)] = &[ + ("BBUpdateStatusMsg", false), + ("CheckpointMsg", true), + ("CrashLog", true), + ("DataRequestMsg", false), + ("FDRSubmit", true), + ("MsgType", false), + ("PreviousRestoreLogMsg", false), + ("ProgressMsg", false), + ("ProvisioningAck", false), + ("ProvisioningInfo", false), + ("ProvisioningStatusMsg", false), + ("ReceivedFinalStatusMsg", false), + ("RestoredCrash", true), + ("StatusMsg", false), + ("AsyncDataRequestMsg", true), + ("AsyncWait", true), + ("RestoreAttestation", true), +]; diff --git a/src-tauri/vendor/idevice/src/services/restore/recovery/dfu.rs b/src-tauri/vendor/idevice/src/services/restore/recovery/dfu.rs new file mode 100644 index 0000000..e14f69f --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/restore/recovery/dfu.rs @@ -0,0 +1,138 @@ +//! DFU-mode firmware upload +//! +//! DFU uploads a firmware image via chunked `DNLOAD` control transfers +//! (`bmRequestType=0x21`, `bRequest=1`), with a trailing salted CRC-32 appended +//! to the final packet, followed by a zero-length `DNLOAD` and a device reset. + +use super::{ControlSetup, RecoveryDevice, TRANSFER_SIZE_DFU, USB_TIMEOUT_MS}; +use crate::{IdeviceError, services::restore::RestoreError}; + +/// The salted suffix folded into the CRC and appended after the image. +const DFU_XBUF: [u8; 12] = [ + 0xFF, 0xFF, 0xFF, 0xFF, 0xAC, 0x05, 0x00, 0x01, 0x55, 0x46, 0x44, 0x10, +]; + +/// Uploads `buf` to a device in DFU/WTF mode. +pub(super) async fn send_buffer_dfu( + dev: &mut RecoveryDevice, + buf: &[u8], +) -> Result<(), IdeviceError> { + let packet_size = TRANSFER_SIZE_DFU; + + // Confirm the device is in DFU IDLE (state 2); otherwise clear/abort. + let state = dev + .transport() + .control_in(ControlSetup::new(0xA1, 5, 0, 0), 1, USB_TIMEOUT_MS) + .await?; + match state.first().copied() { + Some(2) => {} // DFU IDLE + Some(10) => { + dev.transport() + .control_out(ControlSetup::new(0x21, 4, 0, 0), &[], USB_TIMEOUT_MS) + .await?; + return Err(IdeviceError::Restore(RestoreError::Recovery( + "DFU error state; issued CLRSTATUS".into(), + ))); + } + other => { + dev.transport() + .control_out(ControlSetup::new(0x21, 6, 0, 0), &[], USB_TIMEOUT_MS) + .await?; + return Err(IdeviceError::Restore(RestoreError::Recovery(format!( + "unexpected DFU state {other:?}; issued ABORT" + )))); + } + } + + let num_packets = buf.len().div_ceil(packet_size); + let mut offset = 0usize; + let mut packet_index: u16 = 0; + + while offset < buf.len() { + let end = (offset + packet_size).min(buf.len()); + let chunk = &buf[offset..end]; + let is_last = end >= buf.len(); + + if is_last { + // CRC over the entire image, then over the salted suffix. + let mut crc = crc32_zlib(0xFFFF_FFFF, buf); + crc = crc32_zlib(crc, &DFU_XBUF); + + let mut crc_chunk = DFU_XBUF.to_vec(); + crc_chunk.extend_from_slice(&crc.to_le_bytes()); + + if chunk.len() + crc_chunk.len() > packet_size { + // The CRC would overflow the packet: send chunk then CRC separately. + dev.transport() + .control_out( + ControlSetup::new(0x21, 1, packet_index, 0), + chunk, + USB_TIMEOUT_MS, + ) + .await?; + dev.transport() + .control_out( + ControlSetup::new(0x21, 1, packet_index, 0), + &crc_chunk, + USB_TIMEOUT_MS, + ) + .await?; + } else { + let mut combined = chunk.to_vec(); + combined.extend_from_slice(&crc_chunk); + dev.transport() + .control_out( + ControlSetup::new(0x21, 1, packet_index, 0), + &combined, + USB_TIMEOUT_MS, + ) + .await?; + } + } else { + dev.transport() + .control_out( + ControlSetup::new(0x21, 1, packet_index, 0), + chunk, + USB_TIMEOUT_MS, + ) + .await?; + } + + offset = end; + packet_index = packet_index.wrapping_add(1); + } + + // Wait for the device to reach DFU MANIFEST-SYNC (state 5). + while dev.dfu_status().await? != 5 { + crate::time::sleep(std::time::Duration::from_secs(1)).await; + } + + // Zero-length DNLOAD to finish, drain a couple of status reads, then reset. + dev.transport() + .control_out( + ControlSetup::new(0x21, 1, num_packets as u16, 0), + &[], + USB_TIMEOUT_MS, + ) + .await?; + let _ = dev.dfu_status().await?; + let _ = dev.dfu_status().await?; + dev.transport().reset().await?; + + Ok(()) +} + +fn crc32_zlib(mut crc: u32, data: &[u8]) -> u32 { + crc ^= 0xFFFF_FFFF; + for &byte in data { + crc ^= byte as u32; + for _ in 0..8 { + crc = if crc & 1 != 0 { + (crc >> 1) ^ 0xEDB8_8320 + } else { + crc >> 1 + }; + } + } + crc ^ 0xFFFF_FFFF +} diff --git a/src-tauri/vendor/idevice/src/services/restore/recovery/mod.rs b/src-tauri/vendor/idevice/src/services/restore/recovery/mod.rs new file mode 100644 index 0000000..c8a90dc --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/restore/recovery/mod.rs @@ -0,0 +1,264 @@ +//! Recovery / DFU protocol +//! +//! Recovery and DFU modes speak over raw USB rather than usbmux. idevice has +//! a "bring your own transport" philosophy, so the USB layer is a +//! consumer-implemented [`RecoveryTransport`] trait (backed by `nusb`, `rusb`, +//! whatever) and this module implements the iBoot/DFU protocol on top of it: +//! command sending, environment get/set, and firmware upload (bulk in recovery +//! mode, chunked control transfers with a trailing CRC in DFU mode). +//! +//! [`mode`] parses the USB serial string into device identifiers; [`dfu`] holds +//! the DFU upload state machine. + +pub mod dfu; +pub mod mode; + +use std::{future::Future, pin::Pin}; + +use tracing::debug; + +pub use mode::{DeviceInfo, Mode}; + +use crate::{IdeviceError, services::restore::RestoreError}; + +/// Default USB transfer timeout (milliseconds). +pub const USB_TIMEOUT_MS: u32 = 10_000; +/// Bulk transfer chunk size in recovery mode. +pub const TRANSFER_SIZE_RECOVERY: usize = 0x8000; +/// Control transfer chunk size in DFU/WTF mode. +pub const TRANSFER_SIZE_DFU: usize = 0x800; + +/// A boxed, `Send` future returned by [`RecoveryTransport`] methods. +pub type RecoveryFuture<'a, T> = Pin> + Send + 'a>>; + +/// The parameters of a USB control transfer setup packet. +#[derive(Debug, Clone, Copy)] +pub struct ControlSetup { + /// `bmRequestType`. + pub request_type: u8, + /// `bRequest`. + pub request: u8, + /// `wValue`. + pub value: u16, + /// `wIndex`. + pub index: u16, +} + +impl ControlSetup { + /// Convenience constructor. + pub const fn new(request_type: u8, request: u8, value: u16, index: u16) -> Self { + Self { + request_type, + request, + value, + index, + } + } +} + +/// The raw USB surface required to drive a device in recovery/DFU mode. +/// +/// Implemented by the caller over their chosen USB backend. Implementors target +/// the Apple device (VID `0x05AC`) already opened in a recovery/DFU mode. +/// +/// `serial_number` must return the raw iBoot/DFU USB serial-number string (e.g. +/// `"CPID:8010 ... ECID:000... SRTG:[iBoot-...] NONC:... "`). +pub trait RecoveryTransport: Send + Sync + std::fmt::Debug { + /// Host to device control transfer. Returns the number of bytes sent. + fn control_out<'a>( + &'a mut self, + setup: ControlSetup, + data: &'a [u8], + timeout_ms: u32, + ) -> RecoveryFuture<'a, usize>; + + /// Device to host control transfer, reading up to `length` bytes. + fn control_in<'a>( + &'a mut self, + setup: ControlSetup, + length: u16, + timeout_ms: u32, + ) -> RecoveryFuture<'a, Vec>; + + /// Bulk OUT transfer on `endpoint` (used for recovery-mode uploads). + fn bulk_out<'a>( + &'a mut self, + endpoint: u8, + data: &'a [u8], + timeout_ms: u32, + ) -> RecoveryFuture<'a, usize>; + + /// The USB serial-number string (source of mode/ECID/CPID/BDID/SRTG/nonces). + fn serial_number(&mut self) -> RecoveryFuture<'_, String>; + + /// `idProduct` from the device descriptor (used to determine the mode). + fn product_id(&self) -> u16; + + /// Selects the given configuration value. + fn set_configuration(&mut self, configuration: u8) -> RecoveryFuture<'_, ()>; + + /// Claims an interface / alternate setting. + fn claim_interface(&mut self, interface: u8, alt_setting: u8) -> RecoveryFuture<'_, ()>; + + /// Resets the USB device (the device re-enumerates afterwards). + fn reset(&mut self) -> RecoveryFuture<'_, ()>; +} + +/// A device in recovery or DFU mode, driven over a [`RecoveryTransport`]. +#[derive(Debug)] +pub struct RecoveryDevice { + transport: Box, + mode: Mode, + info: DeviceInfo, +} + +impl RecoveryDevice { + /// Opens a recovery/DFU device: reads its descriptors, parses the serial + /// string, and configures the USB interfaces. + pub async fn new(mut transport: Box) -> Result { + let mode = Mode::from_product_id(transport.product_id()).ok_or_else(|| { + IdeviceError::Restore(RestoreError::Recovery(format!( + "not an Apple recovery/DFU product id: {:#06x}", + transport.product_id() + ))) + })?; + let serial = transport.serial_number().await?; + let info = DeviceInfo::parse(&serial); + debug!("recovery device: mode={mode:?} info={info:?}"); + + let mut dev = Self { + transport, + mode, + info, + }; + dev.configure().await?; + Ok(dev) + } + + /// Applies the per-mode configuration/interface setup. + async fn configure(&mut self) -> Result<(), IdeviceError> { + self.transport.set_configuration(1).await?; + self.transport.claim_interface(0, 0).await?; + if self.mode.is_recovery() && self.mode.product_id() > Mode::Recovery2.product_id() { + self.transport.claim_interface(1, 1).await?; + } + Ok(()) + } + + /// The device's current mode. + pub fn mode(&self) -> Mode { + self.mode + } + + /// The parsed device identifiers. + pub fn info(&self) -> &DeviceInfo { + &self.info + } + + /// Sends an iBoot command (`bmRequestType=0x40`), NUL-terminated, using the + /// given `bRequest` (0 for most commands, 1 for `go`/`bootx`). + pub async fn send_command_with_request( + &mut self, + command: &str, + b_request: u8, + ) -> Result<(), IdeviceError> { + debug!("recovery command (req {b_request}): {command}"); + let mut data = command.as_bytes().to_vec(); + data.push(0); + self.transport + .control_out( + ControlSetup::new(0x40, b_request, 0, 0), + &data, + USB_TIMEOUT_MS, + ) + .await?; + Ok(()) + } + + /// Sends an iBoot command (`bmRequestType=0x40`, `bRequest=0`), NUL-terminated. + pub async fn send_command(&mut self, command: &str) -> Result<(), IdeviceError> { + self.send_command_with_request(command, 0).await + } + + /// Issues the DFU-style `bmRequestType=0x21, bRequest=1` zero-length control + /// transfer iBoot expects after certain uploads/commands. + pub async fn finish_transfer(&mut self) -> Result<(), IdeviceError> { + self.transport + .control_out(ControlSetup::new(0x21, 1, 0, 0), &[], USB_TIMEOUT_MS) + .await?; + Ok(()) + } + + /// Reads an environment variable via `getenv`. + pub async fn getenv(&mut self, name: &str) -> Result, IdeviceError> { + self.send_command(&format!("getenv {name}")).await?; + self.transport + .control_in(ControlSetup::new(0xC0, 0, 0, 0), 255, USB_TIMEOUT_MS) + .await + } + + /// Sets an environment variable via `setenv`. + pub async fn setenv(&mut self, name: &str, value: &str) -> Result<(), IdeviceError> { + self.send_command(&format!("setenv {name} {value}")).await + } + + /// Enables or disables auto-boot and persists it (`saveenv`). + pub async fn set_autoboot(&mut self, enable: bool) -> Result<(), IdeviceError> { + self.setenv("auto-boot", if enable { "true" } else { "false" }) + .await?; + self.send_command("saveenv").await + } + + /// Reboots the device. + pub async fn reboot(&mut self) -> Result<(), IdeviceError> { + self.send_command("reboot").await + } + + /// Uploads a firmware image to the device, choosing the transfer discipline + /// by mode (bulk for recovery, chunked control transfers + CRC for DFU). + pub async fn send_buffer(&mut self, buf: &[u8]) -> Result<(), IdeviceError> { + if self.mode.is_recovery() { + self.send_buffer_recovery(buf).await + } else { + dfu::send_buffer_dfu(self, buf).await + } + } + + /// Recovery-mode upload: initiate, then bulk-write chunks on endpoint 0x04. + async fn send_buffer_recovery(&mut self, buf: &[u8]) -> Result<(), IdeviceError> { + // Initiate the transfer. + self.transport + .control_out(ControlSetup::new(0x41, 0, 0, 0), &[], USB_TIMEOUT_MS) + .await?; + + for chunk in buf.chunks(TRANSFER_SIZE_RECOVERY) { + let n = self.transport.bulk_out(0x04, chunk, USB_TIMEOUT_MS).await?; + if n != chunk.len() { + return Err(IdeviceError::Restore(RestoreError::Recovery(format!( + "recovery upload short write: {n} of {}", + chunk.len() + )))); + } + } + Ok(()) + } + + /// Access to the transport (for the DFU module). + pub(crate) fn transport(&mut self) -> &mut dyn RecoveryTransport { + &mut *self.transport + } + + /// Reads the one-byte DFU state via `GETSTATUS` (`bmRequestType=0xA1`, + /// `bRequest=3`), returning `bStatus[4]`. + pub(crate) async fn dfu_status(&mut self) -> Result { + let resp = self + .transport + .control_in(ControlSetup::new(0xA1, 3, 0, 0), 6, USB_TIMEOUT_MS) + .await?; + resp.get(4).copied().ok_or_else(|| { + IdeviceError::Restore(RestoreError::Recovery( + "short DFU GETSTATUS response".into(), + )) + }) + } +} diff --git a/src-tauri/vendor/idevice/src/services/restore/recovery/mode.rs b/src-tauri/vendor/idevice/src/services/restore/recovery/mode.rs new file mode 100644 index 0000000..b4ae541 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/restore/recovery/mode.rs @@ -0,0 +1,124 @@ +use std::collections::HashMap; + +/// `IBFL` bit indicating the iBoot understands IMG4. +const IBOOT_FLAG_IMAGE4_AWARE: u64 = 1 << 2; + +/// A recovery-family USB mode, keyed by `idProduct`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Mode { + /// Recovery mode, `idProduct` 0x1280. + Recovery1, + /// Recovery mode, `idProduct` 0x1281. + Recovery2, + /// Recovery mode, `idProduct` 0x1282. + Recovery3, + /// Recovery mode, `idProduct` 0x1283. + Recovery4, + /// WTF mode, `idProduct` 0x1222. + Wtf, + /// DFU mode, `idProduct` 0x1227. + Dfu, +} + +impl Mode { + /// Maps a USB `idProduct` to a mode, if it is one of Apple's recovery modes. + pub fn from_product_id(product_id: u16) -> Option { + Some(match product_id { + 0x1280 => Mode::Recovery1, + 0x1281 => Mode::Recovery2, + 0x1282 => Mode::Recovery3, + 0x1283 => Mode::Recovery4, + 0x1222 => Mode::Wtf, + 0x1227 => Mode::Dfu, + _ => return None, + }) + } + + /// The USB `idProduct` for this mode. + pub fn product_id(self) -> u16 { + match self { + Mode::Recovery1 => 0x1280, + Mode::Recovery2 => 0x1281, + Mode::Recovery3 => 0x1282, + Mode::Recovery4 => 0x1283, + Mode::Wtf => 0x1222, + Mode::Dfu => 0x1227, + } + } + + /// Whether this is a recovery (iBoot) mode, as opposed to DFU/WTF. + pub fn is_recovery(self) -> bool { + !matches!(self, Mode::Wtf | Mode::Dfu) + } +} + +/// Identifiers parsed from a recovery/DFU device's USB serial-number string. +#[derive(Debug, Clone, Default)] +pub struct DeviceInfo { + /// Chip ID (`CPID`). + pub cpid: Option, + /// Board ID (`BDID`). + pub bdid: Option, + /// Exclusive chip ID (`ECID`). + pub ecid: Option, + /// iBoot flags (`IBFL`). + pub ibfl: Option, + /// Serial number (`SRNM`, brackets stripped). + pub srnm: Option, + /// iBoot version tag (`SRTG`, brackets stripped). + pub srtg: Option, + /// AP nonce (`NONC`). + pub ap_nonce: Option>, + /// SEP nonce (`SNON`). + pub sep_nonce: Option>, + /// All raw key/value pairs. + pub raw: HashMap, +} + +impl DeviceInfo { + /// Parses a serial-number string like + /// `"CPID:8010 BDID:08 ECID:00.. IBFL:3C SRNM:[..] SRTG:[iBoot-..] NONC:.."`. + pub fn parse(serial: &str) -> Self { + let mut info = DeviceInfo::default(); + for component in serial.split(' ') { + let Some((key, value)) = component.split_once(':') else { + continue; + }; + let mut value = value.to_string(); + if (key == "SRNM" || key == "SRTG") && value.starts_with('[') && value.ends_with(']') { + value = value[1..value.len() - 1].to_string(); + } + info.raw.insert(key.to_string(), value.clone()); + match key { + "CPID" => info.cpid = u64::from_str_radix(&value, 16).ok(), + "BDID" => info.bdid = u64::from_str_radix(&value, 16).ok(), + "ECID" => info.ecid = u64::from_str_radix(&value, 16).ok(), + "IBFL" => info.ibfl = u64::from_str_radix(&value, 16).ok(), + "SRNM" => info.srnm = Some(value), + "SRTG" => info.srtg = Some(value), + "NONC" => info.ap_nonce = hex_decode(&value), + "SNON" => info.sep_nonce = hex_decode(&value), + _ => {} + } + } + info + } + + /// Whether the iBoot is IMG4-aware (from `IBFL`). + pub fn is_image4_supported(&self) -> bool { + self.ibfl + .map(|f| f & IBOOT_FLAG_IMAGE4_AWARE != 0) + .unwrap_or(false) + } +} + +/// Decodes a hex string into bytes, or `None` if malformed. +fn hex_decode(s: &str) -> Option> { + if !s.len().is_multiple_of(2) { + return None; + } + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).ok()) + .collect() +} diff --git a/src-tauri/vendor/idevice/src/services/restore/restored.rs b/src-tauri/vendor/idevice/src/services/restore/restored.rs new file mode 100644 index 0000000..631ee3d --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/restore/restored.rs @@ -0,0 +1,232 @@ +//! Restore-mode `restored` client +//! +//! Once a device boots the restore ramdisk it exposes `com.apple.mobile.restored` +//! on port 62078, spoken with the same framing as lockdown (4-byte big-endian +//! length prefix + XML plist). This client wraps an [`crate::Idevice`] connected +//! to that port and drives the request/response and streaming message protocol. + +use plist::Value; +use tracing::debug; + +use crate::{Idevice, IdeviceError}; + +/// The label sent with `restored` requests. +const DEFAULT_LABEL: &str = "idevice"; + +/// A client for the restore-mode `restored` service. +#[derive(Debug)] +pub struct RestoredClient { + /// The underlying connection to port 62078 of a restore-mode device. + pub idevice: Idevice, + /// The `RestoreProtocolVersion` reported by the device (re-sent in `StartRestore`). + pub protocol_version: Value, + /// The label sent with each request. + pub label: String, + /// The usbmux `device_id` this client was found on, when it was discovered + /// over usbmux (via [`connect_by_ecid`](Self::connect_by_ecid)). Data-port and + /// FDR connectors should reuse this so their connections target the *same* + /// physical device rather than whichever USB device usbmux lists first. + pub device_id: Option, +} + +impl RestoredClient { + /// The restore-mode service port (shared with lockdown). + pub const SERVICE_PORT: u16 = 62078; + + /// Wraps an [`Idevice`] already connected to port 62078 and performs the + /// `QueryType` handshake, verifying the peer is `com.apple.mobile.restored`. + /// + /// # Errors + /// Returns [`IdeviceError::UnexpectedResponse`] if the peer is not the + /// restored service. + pub async fn connect(idevice: Idevice) -> Result { + let mut client = Self { + idevice, + protocol_version: Value::Integer(0.into()), + label: DEFAULT_LABEL.to_string(), + device_id: None, + }; + client.query_type().await?; + Ok(client) + } + + /// Sends `QueryType`, validates the service type and records the protocol version. + async fn query_type(&mut self) -> Result<(), IdeviceError> { + let req = crate::plist!({ + "Request": "QueryType", + "Label": self.label.clone(), + }); + self.idevice.send_plist(req).await?; + let res = self.idevice.read_plist().await?; + + match res.get("Type").and_then(Value::as_string) { + Some("com.apple.mobile.restored") => {} + other => { + return Err(IdeviceError::UnexpectedResponse(format!( + "expected com.apple.mobile.restored, got {other:?}" + ))); + } + } + if let Some(v) = res.get("RestoreProtocolVersion") { + self.protocol_version = v.clone(); + } + debug!("restored protocol version: {:?}", self.protocol_version); + Ok(()) + } + + /// Finds a restore-mode device by ECID and connects to `restored`. + #[cfg(feature = "usbmuxd")] + pub async fn connect_by_ecid( + addr: &crate::usbmuxd::UsbmuxdAddr, + ecid: u64, + label: &str, + timeout: std::time::Duration, + ) -> Result { + use crate::usbmuxd::Connection; + + let deadline = crate::time::Instant::now() + timeout; + loop { + let devices = match addr.connect(1).await { + Ok(mut m) => m.get_devices().await.unwrap_or_default(), + Err(_) => Vec::new(), + }; + + for device in devices { + if device.connection_type != Connection::Usb { + continue; + } + // A fresh mux connection per attempt (connect_to_device consumes it). + let mux = match addr.connect(1).await { + Ok(m) => m, + Err(_) => continue, + }; + let idevice = match mux + .connect_to_device(device.device_id, Self::SERVICE_PORT, label) + .await + { + Ok(i) => i, + Err(_) => continue, + }; + let mut client = match Self::connect(idevice).await { + Ok(c) => c, + Err(_) => continue, // not a restored device + }; + if let Ok(found) = client.ecid().await + && found == ecid + { + // Remember which device this was so data-port / FDR connectors + // can target the same one rather than "first USB device". + client.device_id = Some(device.device_id); + return Ok(client); + } + } + + if crate::time::Instant::now() >= deadline { + return Err(IdeviceError::DeviceNotFound); + } + crate::time::sleep(std::time::Duration::from_secs(1)).await; + } + } + + /// Queries a device value by key (`QueryValue`), returning the value stored + /// under that key in the response. + /// + /// # Errors + /// Returns [`IdeviceError::UnexpectedResponse`] if the response omits the key. + pub async fn query_value(&mut self, key: &str) -> Result { + let req = crate::plist!({ + "Request": "QueryValue", + "Label": self.label.clone(), + "QueryKey": key, + }); + self.idevice.send_plist(req).await?; + let mut res = self.idevice.read_plist().await?; + res.remove(key).ok_or_else(|| { + IdeviceError::UnexpectedResponse(format!("QueryValue response missing `{key}`")) + }) + } + + /// Returns the device's `HardwareInfo` dictionary. + pub async fn hardware_info(&mut self) -> Result { + match self.query_value("HardwareInfo").await? { + Value::Dictionary(d) => Ok(d), + _ => Err(IdeviceError::UnexpectedResponse( + "HardwareInfo is not a dictionary".into(), + )), + } + } + + /// Returns the device's ECID (masked to 64 bits), read from `HardwareInfo`. + pub async fn ecid(&mut self) -> Result { + let hw = self.hardware_info().await?; + hw.get("UniqueChipID") + .and_then(Value::as_unsigned_integer) + .ok_or_else(|| { + IdeviceError::UnexpectedResponse("HardwareInfo missing UniqueChipID".into()) + }) + } + + /// Begins the restore by sending `StartRestore` with the given options. + /// + /// This is a fire-and-forget request; the device subsequently drives the + /// process by sending messages that the [state machine](super::state_machine) + /// dispatches. + pub async fn start_restore(&mut self, options: plist::Dictionary) -> Result<(), IdeviceError> { + let mut req = plist::Dictionary::new(); + req.insert("Request".into(), "StartRestore".into()); + req.insert("Label".into(), self.label.clone().into()); + req.insert( + "RestoreProtocolVersion".into(), + self.protocol_version.clone(), + ); + req.insert("RestoreOptions".into(), Value::Dictionary(options)); + self.idevice.send_plist(Value::Dictionary(req)).await + } + + /// Requests a reboot, returning the response dictionary. + /// + /// From within Restore OS this reboots the device; because the restore entry + /// leaves `auto-boot` set to `false`, iBoot then halts in recovery rather than + /// attempting to boot a possibly half-written OS. This is the graceful bail-out + /// used when a restore is cancelled. + pub async fn reboot(&mut self) -> Result { + let req = crate::plist!({ + "Request": "Reboot", + "Label": self.label.clone(), + }); + self.idevice.send_plist(req).await?; + self.idevice.read_plist().await + } + + /// Sends a `Goodbye` request and best-effort reads its acknowledgement. + /// + /// Mirrors idevicerestore's teardown: the restore daemon is told the client is + /// leaving so the connection closes cleanly instead of being dropped mid-stream. + pub async fn goodbye(&mut self) -> Result { + let req = crate::plist!({ + "Request": "Goodbye", + "Label": self.label.clone(), + }); + self.idevice.send_plist(req).await?; + self.idevice.read_plist().await + } + + /// Sends a raw message to `restored`. + pub async fn send(&mut self, message: Value) -> Result<(), IdeviceError> { + self.idevice.send_plist(message).await + } + + /// Receives the next message from `restored`. + /// + /// Uses the raw plist read rather than [`Idevice::read_plist`], because + /// restore `StatusMsg`s carry a structured `Error` (a `CFErrorRef` dictionary) + /// that the latter's error handling would choke on. + pub async fn recv(&mut self) -> Result { + match self.idevice.read_plist_value().await? { + Value::Dictionary(d) => Ok(d), + other => Err(IdeviceError::UnexpectedResponse(format!( + "restored sent a non-dictionary message: {other:?}" + ))), + } + } +} diff --git a/src-tauri/vendor/idevice/src/services/restore/state_machine.rs b/src-tauri/vendor/idevice/src/services/restore/state_machine.rs new file mode 100644 index 0000000..cef5bbc --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/restore/state_machine.rs @@ -0,0 +1,543 @@ +//! Restore-mode state machine +//! +//! Once [`RestoredClient::start_restore`](super::restored::RestoredClient::start_restore) +//! kicks off the restore, the device drives the process by sending a stream of +//! messages. [`run_restore`] reads each message, dispatches on its `MsgType` +//! (and, for data requests, its `DataType`), and replies with the data the +//! device asks for until it reports final success. + +use std::{ + future::Future, + pin::Pin, + sync::{ + Arc, + atomic::{AtomicBool, Ordering}, + }, +}; + +use futures::{ + StreamExt, + channel::mpsc::{UnboundedReceiver, UnboundedSender}, +}; +use plist::Value; +use tracing::{debug, error, info, warn}; + +use super::restored::RestoredClient; +use crate::{Idevice, IdeviceError, services::restore::RestoreError}; + +/// Supplies firmware component bytes by their archive path. +/// +/// Backed by [`Ipsw`](super::ipsw::Ipsw) in production; abstracted so the state +/// machine can be exercised without a real archive. +pub trait ComponentSource: Send { + /// Reads the whole component at `path` into memory. + fn read_component<'a>( + &'a mut self, + path: &'a str, + ) -> Pin, IdeviceError>> + Send + 'a>>; + + /// Opens a streaming reader over the component at `path`. + #[allow(clippy::type_complexity)] + fn open_component<'a>( + &'a mut self, + path: &'a str, + ) -> Pin< + Box< + dyn Future, IdeviceError>> + + Send + + 'a, + >, + > { + Box::pin(async move { + let data = self.read_component(path).await?; + Ok(Box::new(BufferedComponentReader { data, pos: 0 }) + as Box) + }) + } +} + +/// A pull-based, sequential reader over a single component's bytes, yielded by +/// [`ComponentSource::open_component`]. +/// +/// The source is supplied by the consumer, so the library streams large boot +/// objects without assuming a host filesystem. +pub trait ComponentReader: Send { + /// Reads the next chunk into `buf`, returning the number of bytes read + /// (`0` marks end of stream). May return short reads. + fn read<'a>( + &'a mut self, + buf: &'a mut [u8], + ) -> Pin> + Send + 'a>>; +} + +/// The default [`ComponentReader`]: hands out an already-buffered component. +struct BufferedComponentReader { + data: Vec, + pos: usize, +} + +impl ComponentReader for BufferedComponentReader { + fn read<'a>( + &'a mut self, + buf: &'a mut [u8], + ) -> Pin> + Send + 'a>> { + Box::pin(async move { + let n = (self.data.len() - self.pos).min(buf.len()); + buf[..n].copy_from_slice(&self.data[self.pos..self.pos + n]); + self.pos += n; + Ok(n) + }) + } +} + +impl ComponentSource for super::ipsw::Ipsw +where + R: tokio::io::AsyncBufRead + tokio::io::AsyncSeek + Unpin + Send, +{ + fn read_component<'a>( + &'a mut self, + path: &'a str, + ) -> Pin, IdeviceError>> + Send + 'a>> { + Box::pin(async move { self.read_file(path).await }) + } + + #[allow(clippy::type_complexity)] + fn open_component<'a>( + &'a mut self, + path: &'a str, + ) -> Pin< + Box< + dyn Future, IdeviceError>> + + Send + + 'a, + >, + > { + Box::pin(async move { self.open_entry_reader(path).await }) + } +} + +/// Opens fresh connections to restore-mode data ports. +/// +/// Some data requests carry a `DataPort` on which the reply must be sent over a +/// new connection; this abstracts establishing it. +/// +/// Uses `&self` so it can be shared across the concurrent tasks a restore runs +/// (e.g. streaming the filesystem while servicing the device's async key requests). +pub trait DataPortConnector: Send + Sync { + /// Connects to `port` on the restore-mode device. + fn connect( + &self, + port: u16, + ) -> Pin> + Send>>; +} + +/// A [`DataPortConnector`] backed by an [`IdeviceProvider`](crate::provider::IdeviceProvider). +#[derive(Debug)] +pub struct ProviderDataPorts<'p> { + /// The provider used to open new connections to data ports. + pub provider: &'p dyn crate::provider::IdeviceProvider, +} + +impl DataPortConnector for ProviderDataPorts<'_> { + fn connect( + &self, + port: u16, + ) -> Pin> + Send>> { + self.provider.connect(port) + } +} + +/// A progress event emitted while a restore runs. +/// +/// The enum derives [`serde::Serialize`] so it can cross an FFI or wasm boundary. +#[derive(Debug, Clone, serde::Serialize)] +pub enum RestoreProgressEvent { + /// The device's own restore progress, from its `ProgressMsg`: an operation + /// code and its completion percentage (`0`-`100`). This drives the main bar. + Operation { + /// The device's operation code. + operation: u64, + /// The operation's completion percentage (`0`-`100`). + progress: u64, + }, + /// A new host step began, named by the `DataType` the device requested. + Step(String), + /// Byte progress while streaming a large image (the filesystem over ASR, or a + /// source boot object such as `Cryptex1,SystemOS`). `total` is `None` when the + /// size isn't known ahead of time. Emitted at a throttled cadence, not per chunk. + Transfer { + /// The component being streamed. + component: String, + /// Bytes sent so far. + sent: u64, + /// Total size in bytes, when known. + total: Option, + }, +} + +/// A cooperative-cancellation handle for a running restore. +/// +/// Cheap to clone and safe to use from any thread. Hold one, pass a clone as +/// [`RestoreContext::cancel`], and call [`cancel`](Self::cancel) to stop the +/// restore: it halts at the next check point (between device messages, and per +/// chunk while streaming) and [`run_restore`] reboots the device toward recovery, +/// returning [`RestoreError::Cancelled`]. +#[derive(Debug, Clone, Default)] +pub struct RestoreCancel { + flag: Arc, +} + +impl RestoreCancel { + /// Creates a fresh handle that has not been cancelled. + pub fn new() -> Self { + Self::default() + } + + pub fn cancel(&self) { + self.flag.store(true, Ordering::Relaxed); + } + + /// Whether cancellation has been requested. + pub fn is_cancelled(&self) -> bool { + self.flag.load(Ordering::Relaxed) + } +} + +/// The sending half of a restore progress channel, held by +/// [`RestoreContext::progress`]. Created by [`progress_channel`]. +#[derive(Debug, Clone)] +pub struct RestoreProgressSender { + tx: UnboundedSender, +} + +impl RestoreProgressSender { + /// Best-effort emit of one event; a dropped receiver is ignored. + pub(super) fn send(&self, event: RestoreProgressEvent) { + let _ = self.tx.unbounded_send(event); + } +} + +/// The receiving half of a restore progress channel. Await [`recv`](Self::recv) +/// until it returns `None`, which happens once the restore ends and drops the +/// sender. Created by [`progress_channel`]. +#[derive(Debug)] +pub struct RestoreProgressReceiver { + rx: UnboundedReceiver, +} + +impl RestoreProgressReceiver { + /// Waits for the next progress event, or returns `None` once the restore has + /// finished and every sender has been dropped. + pub async fn recv(&mut self) -> Option { + self.rx.next().await + } +} + +/// Creates a progress channel for a restore. +/// +/// Pass the returned [`RestoreProgressSender`] as [`RestoreContext::progress`] and +/// drive a UI by polling the [`RestoreProgressReceiver`] with +/// [`RestoreProgressReceiver::recv`]. This lets a consumer receive progress without +/// depending on the underlying channel crate. +pub fn progress_channel() -> (RestoreProgressSender, RestoreProgressReceiver) { + let (tx, rx) = futures::channel::mpsc::unbounded(); + (RestoreProgressSender { tx }, RestoreProgressReceiver { rx }) +} + +/// All the state and resources a running restore needs. +/// +/// Every I/O surface is a consumer-supplied trait object, so the library acts as +/// the protocol state machine and the caller brings their own transports. +pub struct RestoreContext<'a> { + /// The restore-mode control connection. + pub restored: &'a mut RestoredClient, + /// The selected build identity from the IPSW's `BuildManifest`. + pub build_identity: &'a plist::Dictionary, + /// The device's board ID. + pub board_id: u64, + /// The device's chip ID. + pub chip_id: u64, + /// The device's ECID. + pub ecid: u64, + /// The `ApImg4Ticket` (IM4M) obtained from TSS. + pub tss_ticket: &'a [u8], + /// Source of firmware component bytes. + pub components: &'a mut dyn ComponentSource, + /// The filesystem image (DMG) for ASR, when available. + pub filesystem: Option<&'a mut dyn super::asr::FilesystemImage>, + /// Factory for data-port connections. + pub data_ports: &'a mut dyn DataPortConnector, + /// Optional sink for [`RestoreProgressEvent`]s, for driving a UI. Obtain one + /// from [`progress_channel`]; it is cloned into each concurrent task so every + /// stage (including the ASR transfer) can emit. Omit it (`None`) to run silently. + pub progress: Option, + /// Optional cooperative-cancellation handle. When the consumer calls + /// [`RestoreCancel::cancel`], the restore stops at the next check point (between + /// messages, and per chunk while streaming) and [`run_restore`] reboots the + /// device toward recovery, returning [`RestoreError::Cancelled`]. + pub cancel: Option, +} + +impl std::fmt::Debug for RestoreContext<'_> { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("RestoreContext") + .field("tss_ticket_len", &self.tss_ticket.len()) + .finish_non_exhaustive() + } +} + +impl RestoreContext<'_> { + /// Reborrows this context without its filesystem image. + /// + /// The ASR transfer takes the filesystem while the request pump needs the + /// rest of the resources to service the device's concurrent async requests; + /// this hands the pump a shorter-lived context over the disjoint fields. The + /// progress sink and cancel handle are cheap to clone, so both halves share the + /// same consumer and the same cancel signal. + pub(super) fn without_filesystem(&mut self) -> RestoreContext<'_> { + RestoreContext { + restored: &mut *self.restored, + build_identity: self.build_identity, + board_id: self.board_id, + chip_id: self.chip_id, + ecid: self.ecid, + tss_ticket: self.tss_ticket, + components: &mut *self.components, + filesystem: None, + data_ports: &mut *self.data_ports, + progress: self.progress.clone(), + cancel: self.cancel.clone(), + } + } + + /// Emits a progress event if a sink is attached. Best-effort: a full or + /// dropped channel is ignored, so progress never blocks or fails the restore. + pub(super) fn emit(&self, event: RestoreProgressEvent) { + if let Some(tx) = &self.progress { + tx.send(event); + } + } + + /// Whether the consumer has requested cancellation. + pub(super) fn is_cancelled(&self) -> bool { + self.cancel + .as_ref() + .is_some_and(RestoreCancel::is_cancelled) + } + + /// Returns [`RestoreError::Cancelled`] if cancellation has been requested. + pub(super) fn check_cancel(&self) -> Result<(), IdeviceError> { + if self.is_cancelled() { + Err(IdeviceError::Restore(RestoreError::Cancelled)) + } else { + Ok(()) + } + } +} + +/// Drives the restore to completion. +/// +/// Sends `StartRestore` with `options`, then loops reading messages from the +/// device and dispatching them until a final `StatusMsg` (`Status == 0`) arrives, +/// at which point `ReceivedFinalStatusMsg` is acknowledged and the function +/// returns `Ok(())`. +/// +/// # Cancellation +/// If [`RestoreContext::cancel`] is set to `true`, the restore stops at the next +/// check point between device messages, and per chunk while streaming the +/// filesystem or a boot object. +/// +/// On any exit, success, error, or cancellation, a best-effort `Goodbye` is sent +/// so the connection closes cleanly instead of being dropped mid-stream. +/// +/// # Errors +/// Returns [`IdeviceError`] if the transport fails, the device reports an +/// unrecoverable condition (`RestoredCrash`), or the restore was cancelled. +pub async fn run_restore( + mut ctx: RestoreContext<'_>, + options: plist::Dictionary, +) -> Result<(), IdeviceError> { + ctx.restored.start_restore(options).await?; + + let result = drive_restore(&mut ctx).await; + + if matches!(&result, Err(IdeviceError::Restore(RestoreError::Cancelled))) { + info!("restore cancelled; rebooting device toward recovery"); + if let Err(e) = ctx.restored.reboot().await { + debug!("reboot request during cancel failed (expected if link dropped): {e}"); + } + } + if let Err(e) = ctx.restored.goodbye().await { + debug!("goodbye during teardown failed (expected if link dropped): {e}"); + } + + result +} + +async fn drive_restore(ctx: &mut RestoreContext<'_>) -> Result<(), IdeviceError> { + loop { + // Catch a cancellation requested while we were between messages. The long + // streaming phases poll the same flag per chunk (see `data_request`/`asr`). + ctx.check_cancel()?; + + let message = ctx.restored.recv().await?; + let msg_type = message + .get("MsgType") + .and_then(Value::as_string) + .unwrap_or_default(); + + match msg_type { + "DataRequestMsg" | "AsyncDataRequestMsg" => { + super::data_request::dispatch(ctx, &message) + .await + .inspect_err(|e| { + error!("data request handler failed, aborting restore: {e}") + })?; + } + "ProgressMsg" => handle_progress(ctx, &message), + "StatusMsg" => { + if handle_status(ctx, &message).await? { + info!("restore finished successfully"); + return Ok(()); + } + } + "CheckpointMsg" => debug!("checkpoint: {message:?}"), + "PreviousRestoreLogMsg" => debug!("previous restore log received"), + "BBUpdateStatusMsg" => handle_bb_update_status(&message)?, + "BasebandUpdaterOutputData" => { + debug!("baseband updater output: {message:?}") + } + "RestoredCrash" => { + error!("restored crashed: {message:?}"); + return Err(IdeviceError::Restore(RestoreError::RestoredCrashed)); + } + "AsyncWait" => debug!("async wait: {message:?}"), + "RestoreAttestation" => { + debug!("restore attestation request; declining"); + ctx.restored + .send(crate::plist!({ "RestoreShouldAttest": false })) + .await?; + } + other => warn!("unhandled MsgType `{other}`: {message:?}"), + } + } +} + +fn handle_progress(ctx: &RestoreContext<'_>, message: &plist::Dictionary) { + let op = message + .get("Operation") + .and_then(Value::as_unsigned_integer); + let progress = message.get("Progress").and_then(Value::as_unsigned_integer); + debug!("progress: operation={op:?} progress={progress:?}"); + if let (Some(op), Some(progress)) = (op, progress) { + ctx.emit(RestoreProgressEvent::Operation { + operation: op, + progress, + }); + } +} + +fn handle_bb_update_status(message: &plist::Dictionary) -> Result<(), IdeviceError> { + let accepted = message + .get("Accepted") + .and_then(Value::as_boolean) + .unwrap_or(false); + if !accepted { + let detail = message + .get("Error") + .and_then(Value::as_dictionary) + .and_then(|e| { + e.get("NSLocalizedDescription") + .or_else(|| e.get("NSDescription")) + }) + .and_then(Value::as_string) + .unwrap_or("device did not accept BasebandData"); + error!("baseband update rejected: {detail}"); + return Err(IdeviceError::Restore(RestoreError::BasebandRejected( + detail.to_string(), + ))); + } + + let done = message + .get("Output") + .and_then(Value::as_dictionary) + .and_then(|o| o.get("done")) + .and_then(Value::as_boolean) + .unwrap_or(false); + if done { + info!("baseband update completed"); + } else { + debug!("baseband update in progress"); + } + Ok(()) +} + +async fn handle_status( + ctx: &mut RestoreContext<'_>, + message: &plist::Dictionary, +) -> Result { + let status = message.get("Status").and_then(Value::as_signed_integer); + let amr_error = message.get("AMRError").and_then(Value::as_signed_integer); + if let Some(log) = message.get("Log").and_then(Value::as_string) { + debug!("device log:\n{log}"); + } + + // Success: a final status of 0 with no error. + if status == Some(0) && amr_error.unwrap_or(0) == 0 { + ctx.restored + .send(crate::plist!({ "MsgType": "ReceivedFinalStatusMsg" })) + .await?; + return Ok(true); + } + + // A structured `Error` (or non-zero AMRError) is a fatal device-side failure. + if message.contains_key("Error") || amr_error.unwrap_or(0) != 0 { + let detail = message + .get("Error") + .and_then(collect_error_descriptions) + .unwrap_or_else(|| "no description".into()); + error!("device reported a fatal restore error (AMRError={amr_error:?}): {detail}"); + return Err(IdeviceError::Restore(RestoreError::DeviceReported { + amr_error: amr_error.unwrap_or(-1), + detail, + })); + } + + // A non-zero status without a structured error is informational; keep going. + if let Some(s) = status { + error!("device reported restore status {s}"); + } + Ok(false) +} + +fn collect_error_descriptions(value: &Value) -> Option { + fn walk(value: &Value, out: &mut Vec) { + match value { + Value::Dictionary(d) => { + for (k, v) in d { + if k == "NSDescription" { + // The description is `{ EncodedObjectType, Content: }`. + if let Some(s) = v + .as_dictionary() + .and_then(|c| c.get("Content")) + .and_then(Value::as_string) + .or_else(|| v.as_string()) + { + out.push(s.trim().to_string()); + } + } + walk(v, out); + } + } + Value::Array(a) => a.iter().for_each(|v| walk(v, out)), + _ => {} + } + } + let mut out = Vec::new(); + walk(value, &mut out); + if out.is_empty() { + None + } else { + Some(out.join(" -> ")) + } +} diff --git a/src-tauri/vendor/idevice/src/services/restore_service.rs b/src-tauri/vendor/idevice/src/services/restore_service.rs new file mode 100644 index 0000000..db80a9e --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/restore_service.rs @@ -0,0 +1,250 @@ +//! Restore Service + +use plist::Dictionary; +use tracing::warn; + +use crate::{IdeviceError, ReadWrite, RemoteXpcClient, RsdService, obf}; + +/// Client for interacting with the Restore Service +#[derive(Debug)] +pub struct RestoreServiceClient { + /// The underlying device connection with established Restore Service service + pub stream: RemoteXpcClient>, +} + +impl RsdService for RestoreServiceClient { + fn rsd_service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.RestoreRemoteServices.restoreserviced") + } + + async fn from_stream(stream: Box) -> Result { + Self::new(stream).await + } +} + +impl RestoreServiceClient { + /// Creates a new Restore Service client a socket connection, + /// and connects to the RemoteXPC service. + /// + /// # Arguments + /// * `idevice` - Pre-established device connection + pub async fn new(stream: Box) -> Result { + let mut stream = RemoteXpcClient::new(stream).await?; + stream.do_handshake().await?; + Ok(Self { stream }) + } + + /// Enter recovery + pub async fn enter_recovery(&mut self) -> Result<(), IdeviceError> { + let req = crate::plist!({ + "command": "recovery" + }); + + self.stream.send_object(req, true).await?; + + let res = self.stream.recv().await?; + let mut res = match res { + plist::Value::Dictionary(d) => d, + _ => { + warn!("Did not receive dictionary response from XPC"); + return Err(IdeviceError::UnexpectedResponse( + "enter_recovery XPC response is not a dictionary".into(), + )); + } + }; + + match res.remove("result") { + Some(plist::Value::String(r)) => { + if r == "success" { + Ok(()) + } else { + warn!("Failed to enter recovery"); + Err(IdeviceError::UnexpectedResponse( + "enter_recovery result was not success".into(), + )) + } + } + _ => { + warn!("XPC dictionary did not contain result"); + Err(IdeviceError::UnexpectedResponse( + "missing result in enter_recovery response".into(), + )) + } + } + } + + /// Reboot + pub async fn reboot(&mut self) -> Result<(), IdeviceError> { + let req = crate::plist!({ + "command": "reboot" + }); + self.stream.send_object(req, true).await?; + + let res = self.stream.recv().await?; + let mut res = match res { + plist::Value::Dictionary(d) => d, + _ => { + warn!("Did not receive dictionary response from XPC"); + return Err(IdeviceError::UnexpectedResponse( + "reboot XPC response is not a dictionary".into(), + )); + } + }; + + match res.remove("result") { + Some(plist::Value::String(r)) => { + if r == "success" { + Ok(()) + } else { + warn!("Failed to enter recovery"); + Err(IdeviceError::UnexpectedResponse( + "reboot result was not success".into(), + )) + } + } + _ => { + warn!("XPC dictionary did not contain result"); + Err(IdeviceError::UnexpectedResponse( + "missing result in reboot response".into(), + )) + } + } + } + + /// Get preflightinfo + pub async fn get_preflightinfo(&mut self) -> Result { + let req = crate::plist!({ + "command": "getpreflightinfo" + }); + self.stream.send_object(req, true).await?; + + let res = self.stream.recv().await?; + let mut res = match res { + plist::Value::Dictionary(d) => d, + _ => { + warn!("Did not receive dictionary response from XPC"); + return Err(IdeviceError::UnexpectedResponse( + "getpreflightinfo XPC response is not a dictionary".into(), + )); + } + }; + + let res = match res.remove("preflightinfo") { + Some(plist::Value::Dictionary(i)) => i, + _ => { + warn!("XPC dictionary did not contain preflight info"); + return Err(IdeviceError::UnexpectedResponse( + "missing preflightinfo in response".into(), + )); + } + }; + + Ok(res) + } + + /// Get nonces + /// Doesn't seem to work + pub async fn get_nonces(&mut self) -> Result { + let req = crate::plist!({ + "command": "getnonces" + }); + self.stream.send_object(req, true).await?; + + let res = self.stream.recv().await?; + let mut res = match res { + plist::Value::Dictionary(d) => d, + _ => { + warn!("Did not receive dictionary response from XPC"); + return Err(IdeviceError::UnexpectedResponse( + "getnonces XPC response is not a dictionary".into(), + )); + } + }; + + let res = match res.remove("nonces") { + Some(plist::Value::Dictionary(i)) => i, + _ => { + warn!("XPC dictionary did not contain nonces"); + return Err(IdeviceError::UnexpectedResponse( + "missing nonces in response".into(), + )); + } + }; + + Ok(res) + } + + /// Get app parameters + /// Doesn't seem to work + pub async fn get_app_parameters(&mut self) -> Result { + let req = crate::plist!({ + "command": "getappparameters" + }); + self.stream.send_object(req, true).await?; + + let res = self.stream.recv().await?; + let mut res = match res { + plist::Value::Dictionary(d) => d, + _ => { + warn!("Did not receive dictionary response from XPC"); + return Err(IdeviceError::UnexpectedResponse( + "getappparameters XPC response is not a dictionary".into(), + )); + } + }; + + let res = match res.remove("appparameters") { + Some(plist::Value::Dictionary(i)) => i, + _ => { + warn!("XPC dictionary did not contain parameters"); + return Err(IdeviceError::UnexpectedResponse( + "missing appparameters in response".into(), + )); + } + }; + + Ok(res) + } + + /// Restores the language + /// Doesn't seem to work + pub async fn restore_lang(&mut self, language: impl Into) -> Result<(), IdeviceError> { + let language = language.into(); + + let req = crate::plist!({ + "command": "restorelang", + "argument": language, + }); + self.stream.send_object(req, true).await?; + + let res = self.stream.recv().await?; + let mut res = match res { + plist::Value::Dictionary(d) => d, + _ => { + warn!("Did not receive dictionary response from XPC"); + return Err(IdeviceError::UnexpectedResponse( + "restorelang XPC response is not a dictionary".into(), + )); + } + }; + + match res.remove("result") { + Some(plist::Value::String(r)) => { + if r == "success" { + Ok(()) + } else { + warn!("Failed to restore language"); + Err(IdeviceError::UnexpectedResponse( + "restorelang result was not success".into(), + )) + } + } + _ => { + warn!("XPC dictionary did not contain result"); + Err(IdeviceError::UnexpectedResponse( + "missing result in restorelang response".into(), + )) + } + } + } +} diff --git a/src-tauri/vendor/idevice/src/services/rsd.rs b/src-tauri/vendor/idevice/src/services/rsd.rs new file mode 100644 index 0000000..a9ed724 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/rsd.rs @@ -0,0 +1,190 @@ +//! Remote Service Discovery +//! Communicates via XPC and returns advertised services + +use std::collections::HashMap; + +use serde::Deserialize; +use tracing::{debug, warn}; + +use crate::{IdeviceError, ReadWrite, RemoteXpcClient, provider::RsdProvider}; + +/// Describes an available XPC service +#[derive(Debug, Clone, Deserialize)] +pub struct RsdService { + /// Required entitlement to access this service + pub entitlement: String, + /// Port number where the service is available + pub port: u16, + /// Whether the service uses remote XPC + pub uses_remote_xpc: bool, + /// Optional list of supported features + pub features: Option>, + /// Optional service version number + pub service_version: Option, +} + +#[derive(Debug, Clone)] +pub struct RsdHandshake { + pub services: HashMap, + pub protocol_version: usize, + pub properties: HashMap, + pub uuid: String, +} + +impl RsdHandshake { + pub async fn new(socket: impl ReadWrite) -> Result { + let mut xpc_client = RemoteXpcClient::new(socket).await?; + xpc_client.do_handshake().await?; + xpc_client.send_device_handshake().await?; + let data = xpc_client.recv_root().await?; + + let services_dict = match data + .as_dictionary() + .and_then(|x| x.get("Services")) + .and_then(|x| x.as_dictionary()) + { + Some(d) => d, + None => { + return Err(IdeviceError::UnexpectedResponse( + "missing Services dictionary in RSD handshake".into(), + )); + } + }; + + // Parse available services + let mut services: HashMap = HashMap::new(); + for (name, service) in services_dict.into_iter() { + match service.as_dictionary() { + Some(service) => { + let entitlement = match service.get("Entitlement").and_then(|x| x.as_string()) { + Some(e) => e.to_string(), + None => { + warn!("Service did not contain entitlement string"); + continue; + } + }; + let port = match service + .get("Port") + .and_then(|x| x.as_string()) + .and_then(|x| x.parse::().ok()) + { + Some(e) => e, + None => { + warn!("Service did not contain port string"); + continue; + } + }; + let uses_remote_xpc = match service + .get("Properties") + .and_then(|x| x.as_dictionary()) + .and_then(|x| x.get("UsesRemoteXPC")) + .and_then(|x| x.as_boolean()) + { + Some(e) => e.to_owned(), + None => false, // default is false + }; + + let features = service + .get("Properties") + .and_then(|x| x.as_dictionary()) + .and_then(|x| x.get("Features")) + .and_then(|x| x.as_array()) + .map(|f| { + f.iter() + .filter_map(|x| x.as_string()) + .map(|x| x.to_string()) + .collect::>() + }); + + let service_version = service + .get("Properties") + .and_then(|x| x.as_dictionary()) + .and_then(|x| x.get("ServiceVersion")) + .and_then(|x| x.as_signed_integer()) + .map(|e| e.to_owned()); + + services.insert( + name.to_string(), + RsdService { + entitlement, + port, + uses_remote_xpc, + features, + service_version, + }, + ); + } + None => { + warn!("Service is not a dictionary!"); + continue; + } + } + } + + let protocol_version = match data.as_dictionary().and_then(|x| { + x.get("MessagingProtocolVersion") + .and_then(|x| x.as_signed_integer()) + }) { + Some(p) => p as usize, + None => { + return Err(IdeviceError::UnexpectedResponse( + "missing MessagingProtocolVersion in RSD handshake".into(), + )); + } + }; + + let uuid = match data + .as_dictionary() + .and_then(|x| x.get("UUID").and_then(|x| x.as_string())) + { + Some(u) => u.to_string(), + None => { + return Err(IdeviceError::UnexpectedResponse( + "missing UUID in RSD handshake".into(), + )); + } + }; + + let properties = match data + .as_dictionary() + .and_then(|x| x.get("Properties").and_then(|x| x.as_dictionary())) + { + Some(d) => d + .into_iter() + .map(|(name, prop)| (name.to_owned(), prop.to_owned())) + .collect::>(), + None => { + return Err(IdeviceError::UnexpectedResponse( + "missing Properties dictionary in RSD handshake".into(), + )); + } + }; + + Ok(Self { + services, + protocol_version, + properties, + uuid, + }) + } + + pub async fn connect(&mut self, provider: &mut impl RsdProvider) -> Result + where + T: crate::RsdService, + { + let service_name = T::rsd_service_name(); + let service = match self.services.get(&service_name.to_string()) { + Some(s) => s, + None => { + return Err(IdeviceError::ServiceNotFound); + } + }; + + debug!( + "Connecting to RSD service {service_name} on port {}", + service.port + ); + let stream = provider.connect_to_service_port(service.port).await?; + T::from_stream(stream).await + } +} diff --git a/src-tauri/vendor/idevice/src/services/screenshotr.rs b/src-tauri/vendor/idevice/src/services/screenshotr.rs new file mode 100644 index 0000000..87794cd --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/screenshotr.rs @@ -0,0 +1,152 @@ +//! iOS screenshotr service client +//! +//! Provides functionality for interacting with the screenshot service on iOS devices below iOS 17, +//! which allows taking screenshots. + +use crate::{Idevice, IdeviceError, IdeviceService, obf}; +use std::borrow::Cow; +use tokio::io::AsyncReadExt; +use tracing::{debug, warn}; + +#[derive(Debug)] +pub struct ScreenshotService { + /// Underlying device connection + pub idevice: Idevice, +} + +impl IdeviceService for ScreenshotService { + fn service_name() -> Cow<'static, str> { + obf!("com.apple.mobile.screenshotr") + } + + async fn from_stream(idevice: Idevice) -> Result { + let mut client = Self::new(idevice); + // Perform DeviceLink handshake first + client.dl_version_exchange().await?; + Ok(client) + } +} + +impl ScreenshotService { + pub fn new(idevice: Idevice) -> Self { + Self { idevice } + } + + async fn dl_version_exchange(&mut self) -> Result<(), IdeviceError> { + debug!("Starting DeviceLink version exchange"); + // 1) Receive DLMessageVersionExchange + let (msg, _arr) = self.receive_dl_message().await?; + if msg != "DLMessageVersionExchange" { + warn!("Expected DLMessageVersionExchange, got {msg}"); + return Err(IdeviceError::UnexpectedResponse(format!( + "expected DLMessageVersionExchange, got {msg}" + ))); + } + + // 2) Send DLVersionsOk with version 400 + let out = vec![ + plist::Value::String("DLMessageVersionExchange".into()), + plist::Value::String("DLVersionsOk".into()), + plist::Value::Integer(400u64.into()), + ]; + self.send_dl_array(out).await?; + + // 3) Receive DLMessageDeviceReady + let (msg2, _arr2) = self.receive_dl_message().await?; + if msg2 != "DLMessageDeviceReady" { + warn!("Expected DLMessageDeviceReady, got {msg2}"); + return Err(IdeviceError::UnexpectedResponse(format!( + "expected DLMessageDeviceReady, got {msg2}" + ))); + } + Ok(()) + } + + /// Sends a raw DL array as binary plist + async fn send_dl_array(&mut self, array: Vec) -> Result<(), IdeviceError> { + self.idevice.send_bplist(plist::Value::Array(array)).await + } + + /// Receives any DL* message and returns (message_tag, full_array_value) + pub async fn receive_dl_message(&mut self) -> Result<(String, plist::Value), IdeviceError> { + if let Some(socket) = &mut self.idevice.socket { + let mut buf = [0u8; 4]; + socket.read_exact(&mut buf).await?; + let len = u32::from_be_bytes(buf); + let mut body = vec![0; len as usize]; + socket.read_exact(&mut body).await?; + let value: plist::Value = plist::from_bytes(&body)?; + if let plist::Value::Array(arr) = &value + && let Some(plist::Value::String(tag)) = arr.first() + { + return Ok((tag.clone(), value)); + } + warn!("Invalid DL message format"); + Err(IdeviceError::UnexpectedResponse( + "invalid DL message format, expected array with string tag".into(), + )) + } else { + Err(IdeviceError::NoEstablishedConnection) + } + } + + pub async fn take_screenshot(&mut self) -> Result, IdeviceError> { + // Send DLMessageTakeScreenshot + + let message_type_dict = crate::plist!(dict { + "MessageType": "ScreenShotRequest" + }); + + let out = vec![ + plist::Value::String("DLMessageProcessMessage".into()), + plist::Value::Dictionary(message_type_dict), + ]; + self.send_dl_array(out).await?; + + // Receive DLMessageScreenshotData + let (msg, value) = self.receive_dl_message().await?; + if msg != "DLMessageProcessMessage" { + warn!("Expected DLMessageProcessMessage, got {msg}"); + return Err(IdeviceError::UnexpectedResponse(format!( + "expected DLMessageProcessMessage, got {msg}" + ))); + } + + if let plist::Value::Array(arr) = &value + && arr.len() == 2 + { + if let Some(plist::Value::Dictionary(dict)) = arr.get(1) { + if let Some(plist::Value::Data(data)) = dict.get("ScreenShotData") { + Ok(data.clone()) + } else { + warn!("Invalid ScreenShotData format"); + Err(IdeviceError::UnexpectedResponse( + "missing ScreenShotData in response dictionary".into(), + )) + } + } else { + warn!("Invalid DLMessageScreenshotData format"); + Err(IdeviceError::UnexpectedResponse( + "expected dictionary in DL screenshot response".into(), + )) + } + } else { + warn!("Invalid DLMessageScreenshotData format"); + Err(IdeviceError::UnexpectedResponse( + "expected array with 2 elements in DL screenshot response".into(), + )) + } + } +} + +#[cfg(feature = "rsd")] +impl crate::RsdService for ScreenshotService { + fn rsd_service_name() -> std::borrow::Cow<'static, str> { + crate::obf!("com.apple.screenshotr.shim.remote") + } + async fn from_stream(stream: Box) -> Result { + let mut idevice = crate::Idevice::new(stream, ""); + idevice.rsd_checkin().await?; + Ok(Self::new(idevice)) + } +} diff --git a/src-tauri/vendor/idevice/src/services/simulate_location.rs b/src-tauri/vendor/idevice/src/services/simulate_location.rs new file mode 100644 index 0000000..b0195c8 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/simulate_location.rs @@ -0,0 +1,39 @@ +use crate::{Idevice, IdeviceError, IdeviceService, obf}; + +#[derive(Debug)] +pub struct LocationSimulationService { + idevice: Idevice, +} + +impl IdeviceService for LocationSimulationService { + fn service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.dt.simulatelocation") + } + + async fn from_stream(idevice: Idevice) -> Result { + Ok(Self::new(idevice)) + } +} + +impl LocationSimulationService { + pub fn new(idevice: Idevice) -> Self { + Self { idevice } + } + + pub async fn clear(&mut self) -> Result<(), IdeviceError> { + let message: [u8; 4] = [0x00, 0x00, 0x00, 0x01]; + self.idevice.send_raw(&message).await?; + Ok(()) + } + + pub async fn set(&mut self, latitude: &str, longtiude: &str) -> Result<(), IdeviceError> { + let message: [u8; 4] = [0x00, 0x00, 0x00, 0x00]; + let latitude_len = latitude.len() as u32; + let longtiude_len = longtiude.len() as u32; + let latitude_bytes = [&latitude_len.to_be_bytes(), latitude.as_bytes()].concat(); + let longitude_bytes = [&longtiude_len.to_be_bytes(), longtiude.as_bytes()].concat(); + let data = [&message[..], &latitude_bytes[..], &longitude_bytes[..]].concat(); + self.idevice.send_raw(data.as_slice()).await?; + Ok(()) + } +} diff --git a/src-tauri/vendor/idevice/src/services/springboardservices.rs b/src-tauri/vendor/idevice/src/services/springboardservices.rs new file mode 100644 index 0000000..b553317 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/springboardservices.rs @@ -0,0 +1,377 @@ +//! SpringBoard Services Client +//! +//! Provides functionality for interacting with the SpringBoard services on iOS devices, +//! which manages home screen and app icon related operations. + +use crate::{Idevice, IdeviceError, IdeviceService, obf, utils::plist::truncate_dates_to_seconds}; + +/// Orientation of the device +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum InterfaceOrientation { + /// Orientation is unknown or cannot be determined + Unknown = 0, + /// Portrait mode (normal vertical) + Portrait = 1, + /// Portrait mode upside down + PortraitUpsideDown = 2, + /// Landscape with home button on the right (notch to the left) + LandscapeRight = 3, + /// Landscape with home button on the left (notch to the right) + LandscapeLeft = 4, +} + +/// Client for interacting with the iOS SpringBoard services +/// +/// This service provides access to home screen and app icon functionality, +/// such as retrieving app icons. +#[derive(Debug)] +pub struct SpringBoardServicesClient { + /// The underlying device connection with established SpringBoard services + pub idevice: Idevice, +} + +impl IdeviceService for SpringBoardServicesClient { + /// Returns the SpringBoard services name as registered with lockdownd + fn service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.springboardservices") + } + + async fn from_stream(idevice: Idevice) -> Result { + Ok(Self::new(idevice)) + } +} + +impl SpringBoardServicesClient { + /// Creates a new SpringBoard services client from an existing device connection + /// + /// # Arguments + /// * `idevice` - Pre-established device connection + pub fn new(idevice: Idevice) -> Self { + Self { idevice } + } + + /// Retrieves the PNG icon data for a specified app + /// + /// # Arguments + /// * `bundle_identifier` - The bundle identifier of the app (e.g., "com.apple.Maps") + /// + /// # Returns + /// The raw PNG data of the app icon + /// + /// # Errors + /// Returns `IdeviceError` if: + /// - Communication fails + /// - The app doesn't exist + /// - The response is malformed + /// + /// # Example + /// ```rust + /// let icon_data = client.get_icon_pngdata("com.apple.Maps".to_string()).await?; + /// std::fs::write("maps_icon.png", icon_data)?; + /// ``` + pub async fn get_icon_pngdata( + &mut self, + bundle_identifier: String, + ) -> Result, IdeviceError> { + let req = crate::plist!({ + "command": "getIconPNGData", + "bundleId": bundle_identifier, + }); + self.idevice.send_plist(req).await?; + + let mut res = self.idevice.read_plist().await?; + match res.remove("pngData") { + Some(plist::Value::Data(res)) => Ok(res), + _ => Err(IdeviceError::UnexpectedResponse( + "missing pngData in icon response".into(), + )), + } + } + + /// Retrieves the current icon state from the device + /// + /// The icon state contains the layout and organization of all apps on the home screen, + /// including folder structures and icon positions. This is a read-only operation. + /// + /// # Arguments + /// * `format_version` - Optional format version string for the icon state format + /// + /// # Returns + /// A plist Value containing the complete icon state structure + /// + /// # Errors + /// Returns `IdeviceError` if: + /// - Communication fails + /// - The response is malformed + /// + /// # Example + /// ```rust + /// use idevice::services::springboardservices::SpringBoardServicesClient; + /// + /// let mut client = SpringBoardServicesClient::connect(&provider).await?; + /// let icon_state = client.get_icon_state(None).await?; + /// println!("Icon state: {:?}", icon_state); + /// ``` + /// + /// # Notes + /// This method successfully reads the home screen layout on all iOS versions. + pub async fn get_icon_state( + &mut self, + format_version: Option<&str>, + ) -> Result { + let req = crate::plist!({ + "command": "getIconState", + "formatVersion":? format_version, + }); + + self.idevice.send_plist(req).await?; + let mut res = self.idevice.read_plist_value().await?; + + // Some devices may return an error dictionary instead of icon state. + // Detect this and surface it as an UnexpectedResponse, similar to get_icon_pngdata. + if let plist::Value::Dictionary(ref dict) = res + && (dict.contains_key("error") || dict.contains_key("Error")) + { + return Err(IdeviceError::UnexpectedResponse( + "device returned error in icon state response".into(), + )); + } + + truncate_dates_to_seconds(&mut res); + + Ok(res) + } + + /// Sets the icon state on the device + /// + /// This method allows you to modify the home screen layout by providing a new icon state. + /// The icon state structure should match the format returned by `get_icon_state`. + /// + /// # Arguments + /// * `icon_state` - A plist Value containing the complete icon state structure + /// + /// # Returns + /// Ok(()) if the icon state was successfully set + /// + /// # Errors + /// Returns `IdeviceError` if: + /// - Communication fails + /// - The icon state format is invalid + /// - The device rejects the new layout + /// + /// # Example + /// ```rust + /// use idevice::services::springboardservices::SpringBoardServicesClient; + /// + /// let mut client = SpringBoardServicesClient::connect(&provider).await?; + /// let mut icon_state = client.get_icon_state(None).await?; + /// + /// // Modify the icon state (e.g., swap two icons) + /// // ... modify icon_state ... + /// + /// client.set_icon_state(icon_state).await?; + /// println!("Icon state updated successfully"); + /// ``` + /// + /// # Notes + /// - Changes take effect immediately + /// - The device may validate the icon state structure before applying + /// - Invalid icon states will be rejected by the device + pub async fn set_icon_state(&mut self, icon_state: plist::Value) -> Result<(), IdeviceError> { + let req = crate::plist!({ + "command": "setIconState", + "iconState": icon_state, + }); + + self.idevice.send_plist(req).await?; + Ok(()) + } + + /// Sets the icon state with a specific format version + /// + /// This is similar to `set_icon_state` but allows specifying a format version. + /// + /// # Arguments + /// * `icon_state` - A plist Value containing the complete icon state structure + /// * `format_version` - Optional format version string + /// + /// # Returns + /// Ok(()) if the icon state was successfully set + /// + /// # Errors + /// Returns `IdeviceError` if: + /// - Communication fails + /// - The icon state format is invalid + /// - The device rejects the new layout + pub async fn set_icon_state_with_version( + &mut self, + icon_state: plist::Value, + format_version: Option<&str>, + ) -> Result<(), IdeviceError> { + let req = crate::plist!({ + "command": "setIconState", + "iconState": icon_state, + "formatVersion":? format_version, + }); + + self.idevice.send_plist(req).await?; + Ok(()) + } + + /// Gets the home screen wallpaper preview as PNG data + /// + /// This gets a rendered preview of the home screen wallpaper. + /// + /// # Returns + /// The raw PNG data of the home screen wallpaper preview + /// + /// # Errors + /// Returns `IdeviceError` if: + /// - Communication fails + /// - The device rejects the request + /// - The image is malformed/corupted + /// + /// # Example + /// ```rust + /// let wallpaper = client.get_home_screen_wallpaper_preview_pngdata().await?; + /// std::fs::write("home.png", wallpaper)?; + /// ``` + pub async fn get_home_screen_wallpaper_preview_pngdata( + &mut self, + ) -> Result, IdeviceError> { + let req = crate::plist!({ + "command": "getWallpaperPreviewImage", + "wallpaperName": "homescreen", + }); + self.idevice.send_plist(req).await?; + + let mut res = self.idevice.read_plist().await?; + match res.remove("pngData") { + Some(plist::Value::Data(res)) => Ok(res), + _ => Err(IdeviceError::UnexpectedResponse( + "missing pngData in home screen wallpaper response".into(), + )), + } + } + + /// Gets the lock screen wallpaper preview as PNG data + /// + /// This gets a rendered preview of the lock screen wallpaper. + /// + /// # Returns + /// The raw PNG data of the lock screen wallpaper preview + /// + /// # Errors + /// Returns `IdeviceError` if: + /// - Communication fails + /// - The device rejects the request + /// - The image is malformed/corupted + /// + /// # Example + /// ```rust + /// let wallpaper = client.get_lock_screen_wallpaper_preview_pngdata().await?; + /// std::fs::write("lock.png", wallpaper)?; + /// ``` + pub async fn get_lock_screen_wallpaper_preview_pngdata( + &mut self, + ) -> Result, IdeviceError> { + let req = crate::plist!({ + "command": "getWallpaperPreviewImage", + "wallpaperName": "lockscreen", + }); + self.idevice.send_plist(req).await?; + + let mut res = self.idevice.read_plist().await?; + match res.remove("pngData") { + Some(plist::Value::Data(res)) => Ok(res), + _ => Err(IdeviceError::UnexpectedResponse( + "missing pngData in lock screen wallpaper response".into(), + )), + } + } + + /// Gets the current interface orientation of the device + /// + /// This gets which way the device is currently facing + /// + /// # Returns + /// The current `InterfaceOrientation` of the device + /// + /// # Errors + /// Returns `IdeviceError` if: + /// - Communication fails + /// - The device doesn't support this command + /// - The response format is unexpected + /// + /// # Example + /// ```rust + /// let orientation = client.get_interface_orientation().await?; + /// println!("Device orientation: {:?}", orientation); + /// ``` + pub async fn get_interface_orientation( + &mut self, + ) -> Result { + let req = crate::plist!({ + "command": "getInterfaceOrientation", + }); + self.idevice.send_plist(req).await?; + + let res = self.idevice.read_plist().await?; + let orientation_value = res + .get("interfaceOrientation") + .and_then(|v| v.as_unsigned_integer()) + .ok_or(IdeviceError::UnexpectedResponse( + "missing interfaceOrientation in response".into(), + ))?; + + let orientation = match orientation_value { + 1 => InterfaceOrientation::Portrait, + 2 => InterfaceOrientation::PortraitUpsideDown, + 3 => InterfaceOrientation::LandscapeRight, + 4 => InterfaceOrientation::LandscapeLeft, + _ => InterfaceOrientation::Unknown, + }; + + Ok(orientation) + } + + /// Gets the home screen icon layout metrics + /// + /// Returns icon spacing, size, and positioning information + /// + /// # Returns + /// A `plist::Dictionary` containing the icon layout metrics + /// + /// # Errors + /// Returns `IdeviceError` if: + /// - Communication fails + /// - The response is malformed + /// + /// # Example + /// ```rust + /// let metrics = client.get_homescreen_icon_metrics().await?; + /// println!("{:?}", metrics); + /// ``` + pub async fn get_homescreen_icon_metrics(&mut self) -> Result { + let req = crate::plist!({ + "command": "getHomeScreenIconMetrics", + }); + self.idevice.send_plist(req).await?; + + let res = self.idevice.read_plist().await?; + Ok(res) + } +} + +#[cfg(feature = "rsd")] +impl crate::RsdService for SpringBoardServicesClient { + fn rsd_service_name() -> std::borrow::Cow<'static, str> { + crate::obf!("com.apple.springboardservices.shim.remote") + } + async fn from_stream(stream: Box) -> Result { + let mut idevice = crate::Idevice::new(stream, ""); + idevice.rsd_checkin().await?; + Ok(Self::new(idevice)) + } +} diff --git a/src-tauri/vendor/idevice/src/services/syslog_relay.rs b/src-tauri/vendor/idevice/src/services/syslog_relay.rs new file mode 100644 index 0000000..54513bf --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/syslog_relay.rs @@ -0,0 +1,68 @@ +//! iOS Device SyslogRelay Service Abstraction + +use crate::{Idevice, IdeviceError, IdeviceService, obf}; + +/// Client for interacting with the iOS device SyslogRelay service +#[derive(Debug)] +pub struct SyslogRelayClient { + /// The underlying device connection with established SyslogRelay service + pub idevice: Idevice, + /// Persistent read buffer holding data read past the previous log's delimiter + buffer: bytes::BytesMut, +} + +impl IdeviceService for SyslogRelayClient { + /// Returns the SyslogRelay service name as registered with lockdownd + fn service_name() -> std::borrow::Cow<'static, str> { + obf!("com.apple.syslog_relay") + } + + async fn from_stream(idevice: Idevice) -> Result { + Ok(Self::new(idevice)) + } +} + +impl SyslogRelayClient { + /// Creates a new SyslogRelay client from an existing device connection + /// + /// # Arguments + /// * `idevice` - Pre-established device connection + pub fn new(idevice: Idevice) -> Self { + Self { + idevice, + buffer: bytes::BytesMut::with_capacity(1024), + } + } + + /// Get the next log from the relay + /// + /// # Returns + /// A string containing the log + /// + /// # Errors + /// UnexpectedResponse if the service sends an EOF + pub async fn next(&mut self) -> Result { + let res = self + .idevice + .read_until_delim(&mut self.buffer, b"\n\x00") + .await?; + match res { + Some(res) => Ok(String::from_utf8_lossy(&res).to_string()), + None => Err(IdeviceError::UnexpectedResponse( + "syslog relay returned EOF".into(), + )), + } + } +} + +#[cfg(feature = "rsd")] +impl crate::RsdService for SyslogRelayClient { + fn rsd_service_name() -> std::borrow::Cow<'static, str> { + crate::obf!("com.apple.syslog_relay.shim.remote") + } + async fn from_stream(stream: Box) -> Result { + let mut idevice = crate::Idevice::new(stream, ""); + idevice.rsd_checkin().await?; + Ok(Self::new(idevice)) + } +} diff --git a/src-tauri/vendor/idevice/src/services/wda.rs b/src-tauri/vendor/idevice/src/services/wda.rs new file mode 100644 index 0000000..7429609 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/wda.rs @@ -0,0 +1,1249 @@ +//! Minimal WebDriverAgent bootstrap client over direct device connections. +//! +//! This client talks to WDA on the device port directly through +//! [`crate::provider::IdeviceProvider`], so parallel automation across many +//! devices does not require binding unique localhost ports per device. +//! +//! The API intentionally remains library-first and currently covers session +//! bootstrap plus the most common WDA interactions. It is not yet a full +//! long-lived WebDriver transport and currently assumes simple HTTP JSON +//! request/response flows. + +use std::time::Duration; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use serde_json::{Value, json}; +use tokio::time::{Instant, sleep, timeout}; + +use crate::{Idevice, IdeviceError, provider::IdeviceProvider}; + +/// Default WDA HTTP port on the device. +pub const DEFAULT_WDA_PORT: u16 = 8100; + +/// Default MJPEG streaming port used by many WDA builds. +pub const DEFAULT_WDA_MJPEG_PORT: u16 = 9100; + +/// Poll interval used while waiting for WDA to begin responding. +const WDA_READY_POLL_INTERVAL: Duration = Duration::from_millis(250); + +/// Device-side ports exposed by a WDA runner. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct WdaPorts { + /// Device port for the HTTP WebDriver endpoint. + pub http: u16, + /// Device port for the MJPEG stream endpoint. + pub mjpeg: u16, +} + +impl Default for WdaPorts { + fn default() -> Self { + Self { + http: DEFAULT_WDA_PORT, + mjpeg: DEFAULT_WDA_MJPEG_PORT, + } + } +} + +/// Minimal WDA bootstrap client bound to a specific device provider. +/// +/// This type intentionally opens a fresh direct device connection per request +/// to keep the transport simple and independent per device. +#[derive(Debug)] +pub struct WdaClient<'a> { + provider: &'a dyn IdeviceProvider, + ports: WdaPorts, + timeout: Duration, + session_id: Option, +} + +impl<'a> WdaClient<'a> { + /// Creates a WDA client using the default device-side ports. + pub fn new(provider: &'a dyn IdeviceProvider) -> Self { + Self { + provider, + ports: WdaPorts::default(), + timeout: Duration::from_secs(10), + session_id: None, + } + } + + /// Overrides the device-side WDA ports. + pub fn with_ports(mut self, ports: WdaPorts) -> Self { + self.ports = ports; + self + } + + /// Overrides the per-request timeout. + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + + /// Returns the configured device-side ports. + pub fn ports(&self) -> WdaPorts { + self.ports + } + + /// Returns the currently tracked WDA session id, if one exists. + pub fn session_id(&self) -> Option<&str> { + self.session_id.as_deref() + } + + /// Fetches `/status` from the WDA HTTP endpoint. + pub async fn status(&self) -> Result { + self.request_json("GET", "/status", None).await + } + + /// Waits until WDA begins responding on its HTTP endpoint. + /// + /// This uses a modest polling interval to avoid hammering usbmux/device + /// connects when many devices are starting up in parallel. + pub async fn wait_until_ready( + &self, + timeout_duration: Duration, + ) -> Result { + let deadline = Instant::now() + timeout_duration; + loop { + match self.status().await { + Ok(status) => return Ok(status), + Err(_) if Instant::now() < deadline => { + sleep(WDA_READY_POLL_INTERVAL).await; + } + Err(error) => return Err(error), + } + } + } + + /// Starts a WDA session and returns the session id. + pub async fn start_session(&mut self, bundle_id: Option<&str>) -> Result { + let mut caps = serde_json::Map::new(); + if let Some(bundle_id) = bundle_id { + caps.insert("bundleId".into(), Value::String(bundle_id.to_owned())); + } + + let mut capabilities = serde_json::Map::new(); + capabilities.insert("alwaysMatch".into(), Value::Object(caps.clone())); + + let payload = Value::Object(serde_json::Map::from_iter([ + ("capabilities".into(), Value::Object(capabilities)), + ("desiredCapabilities".into(), Value::Object(caps)), + ])); + + let response = self + .request_json("POST", "/session", Some(&payload)) + .await?; + let session_id = Self::extract_session_id(&response)?; + self.session_id = Some(session_id.clone()); + Ok(session_id) + } + + /// Finds a single element and returns its WDA element id. + pub async fn find_element( + &self, + using: &str, + value: &str, + session_id: Option<&str>, + ) -> Result { + let session_id = self.require_session_id(session_id)?; + let response = self + .request_json( + "POST", + &format!("/session/{session_id}/element"), + Some(&json!({ "using": using, "value": value })), + ) + .await?; + Self::extract_element_id(Self::value_field(&response)?) + } + + /// Finds multiple elements and returns their WDA element ids. + pub async fn find_elements( + &self, + using: &str, + value: &str, + session_id: Option<&str>, + ) -> Result, IdeviceError> { + let session_id = self.require_session_id(session_id)?; + let response = self + .request_json( + "POST", + &format!("/session/{session_id}/elements"), + Some(&json!({ "using": using, "value": value })), + ) + .await?; + let values = + Self::value_field(&response)? + .as_array() + .ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + ))?; + values.iter().map(Self::extract_element_id).collect() + } + + /// Clicks an element by its WDA element id. + pub async fn click( + &self, + element_id: &str, + session_id: Option<&str>, + ) -> Result<(), IdeviceError> { + let session_id = self.require_session_id(session_id)?; + self.request_json( + "POST", + &format!("/session/{session_id}/element/{element_id}/click"), + Some(&json!({})), + ) + .await?; + Ok(()) + } + + /// Returns a raw attribute value for an element. + pub async fn element_attribute( + &self, + element_id: &str, + name: &str, + session_id: Option<&str>, + ) -> Result { + let session_id = self.require_session_id(session_id)?; + let response = self + .request_json( + "GET", + &format!("/session/{session_id}/element/{element_id}/attribute/{name}"), + None, + ) + .await?; + Ok(Self::value_field(&response)?.clone()) + } + + /// Returns the element text-like value as a string when WDA provides it. + pub async fn element_text( + &self, + element_id: &str, + session_id: Option<&str>, + ) -> Result { + self.element_attribute(element_id, "value", session_id) + .await? + .as_str() + .map(ToOwned::to_owned) + .ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )) + } + + /// Returns the element bounds rectangle. + pub async fn element_rect( + &self, + element_id: &str, + session_id: Option<&str>, + ) -> Result { + let session_id = self.require_session_id(session_id)?; + let response = self + .request_json( + "GET", + &format!("/session/{session_id}/element/{element_id}/rect"), + None, + ) + .await?; + Ok(Self::value_field(&response)?.clone()) + } + + /// Returns whether an element is displayed. + pub async fn element_displayed( + &self, + element_id: &str, + session_id: Option<&str>, + ) -> Result { + self.element_bool_state(element_id, "displayed", session_id) + .await + } + + /// Returns whether an element is enabled. + pub async fn element_enabled( + &self, + element_id: &str, + session_id: Option<&str>, + ) -> Result { + self.element_bool_state(element_id, "enabled", session_id) + .await + } + + /// Returns whether an element is selected. + pub async fn element_selected( + &self, + element_id: &str, + session_id: Option<&str>, + ) -> Result { + self.element_bool_state(element_id, "selected", session_id) + .await + } + + /// Presses a hardware button through WDA if the current server supports it. + pub async fn press_button( + &self, + name: &str, + session_id: Option<&str>, + ) -> Result<(), IdeviceError> { + let normalized = normalize_wda_button_name(name); + let payload = json!({ "name": normalized }); + + if let Some(session_id) = session_id.or(self.session_id()) { + match self + .request_json( + "POST", + &format!("/session/{session_id}/wda/pressButton"), + Some(&payload), + ) + .await + { + Ok(_) => return Ok(()), + Err(IdeviceError::UnknownErrorType(message)) if message.contains("404") => {} + Err(error) => return Err(error), + } + + if self.try_keys_endpoint(session_id, &normalized).await? { + return Ok(()); + } + } + + if normalized == "home" { + self.request_json("POST", "/wda/homescreen", Some(&json!({}))) + .await?; + return Ok(()); + } + + Err(IdeviceError::UnknownErrorType( + "WDA does not support pressButton or keys endpoints".into(), + )) + } + + /// Unlocks the device via WDA. + pub async fn unlock(&self, session_id: Option<&str>) -> Result<(), IdeviceError> { + if let Some(session_id) = session_id.or(self.session_id()) { + match self + .request_json( + "POST", + &format!("/session/{session_id}/wda/unlock"), + Some(&json!({})), + ) + .await + { + Ok(_) => return Ok(()), + Err(IdeviceError::UnknownErrorType(message)) if message.contains("404") => {} + Err(error) => return Err(error), + } + } + + self.request_json("POST", "/wda/unlock", Some(&json!({}))) + .await?; + Ok(()) + } + + /// Returns the current UI source tree as XML. + pub async fn source(&self, session_id: Option<&str>) -> Result { + let path = match session_id.or(self.session_id()) { + Some(session_id) => format!("/session/{session_id}/source"), + None => "/source".to_owned(), + }; + let response = self.request_json("GET", &path, None).await?; + Self::value_field(&response)? + .as_str() + .map(ToOwned::to_owned) + .ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )) + } + + /// Returns a PNG screenshot as raw bytes. + pub async fn screenshot(&self, session_id: Option<&str>) -> Result, IdeviceError> { + let path = match session_id.or(self.session_id()) { + Some(session_id) => format!("/session/{session_id}/screenshot"), + None => "/screenshot".to_owned(), + }; + let response = self.request_json("GET", &path, None).await?; + let value = + Self::value_field(&response)? + .as_str() + .ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + ))?; + STANDARD + .decode(value) + .map_err(|_| IdeviceError::UnexpectedResponse("unexpected response".into())) + } + + /// Returns the current window size payload from WDA. + pub async fn window_size(&self, session_id: Option<&str>) -> Result { + let session_id = self.require_session_id(session_id)?; + let response = self + .request_json("GET", &format!("/session/{session_id}/window/size"), None) + .await?; + Ok(Self::value_field(&response)?.clone()) + } + + /// Sends text input to the currently focused element. + pub async fn send_keys( + &self, + text: &str, + session_id: Option<&str>, + ) -> Result<(), IdeviceError> { + let session_id = self.require_session_id(session_id)?; + let payload = json!({ + "value": text.chars().map(|ch| ch.to_string()).collect::>() + }); + + match self + .request_json( + "POST", + &format!("/session/{session_id}/wda/keys"), + Some(&payload), + ) + .await + { + Ok(_) => Ok(()), + Err(IdeviceError::UnknownErrorType(message)) if message.contains("404") => { + self.request_json( + "POST", + &format!("/session/{session_id}/keys"), + Some(&payload), + ) + .await?; + Ok(()) + } + Err(error) => Err(error), + } + } + + /// Swipes from one coordinate to another. + pub async fn swipe( + &self, + start_x: i64, + start_y: i64, + end_x: i64, + end_y: i64, + duration: f64, + session_id: Option<&str>, + ) -> Result<(), IdeviceError> { + let session_id = self.require_session_id(session_id)?; + self.request_json( + "POST", + &format!("/session/{session_id}/wda/dragfromtoforduration"), + Some(&json!({ + "fromX": start_x, + "fromY": start_y, + "toX": end_x, + "toY": end_y, + "duration": duration, + })), + ) + .await?; + Ok(()) + } + + /// Performs a tap gesture on the screen or relative to an element. + pub async fn tap( + &self, + x: Option, + y: Option, + element_id: Option<&str>, + session_id: Option<&str>, + ) -> Result<(), IdeviceError> { + let session_id = self.require_session_id(session_id)?; + match self + .execute_gesture("tap", x, y, element_id, None, Some(session_id)) + .await + { + Ok(()) => Ok(()), + Err(IdeviceError::UnknownErrorType(message)) if message.contains("status=404") => { + let (tap_x, tap_y) = self + .resolve_gesture_coordinates(x, y, element_id, session_id) + .await?; + self.perform_tap_actions(session_id, tap_x, tap_y, 1).await + } + Err(error) => Err(error), + } + } + + /// Performs a double-tap gesture on the screen or relative to an element. + pub async fn double_tap( + &self, + x: Option, + y: Option, + element_id: Option<&str>, + session_id: Option<&str>, + ) -> Result<(), IdeviceError> { + let session_id = self.require_session_id(session_id)?; + match self + .execute_gesture("doubleTap", x, y, element_id, None, Some(session_id)) + .await + { + Ok(()) => Ok(()), + Err(IdeviceError::UnknownErrorType(message)) if message.contains("status=404") => { + let (tap_x, tap_y) = self + .resolve_gesture_coordinates(x, y, element_id, session_id) + .await?; + self.perform_tap_actions(session_id, tap_x, tap_y, 2).await + } + Err(error) => Err(error), + } + } + + /// Performs a long-press gesture on the screen or relative to an element. + pub async fn touch_and_hold( + &self, + duration: f64, + x: Option, + y: Option, + element_id: Option<&str>, + session_id: Option<&str>, + ) -> Result<(), IdeviceError> { + let session_id = self.require_session_id(session_id)?; + match self + .execute_gesture( + "touchAndHold", + x, + y, + element_id, + Some(duration), + Some(session_id), + ) + .await + { + Ok(()) => Ok(()), + Err(IdeviceError::UnknownErrorType(message)) if message.contains("status=404") => { + let (hold_x, hold_y) = self + .resolve_gesture_coordinates(x, y, element_id, session_id) + .await?; + self.perform_touch_and_hold_actions(session_id, hold_x, hold_y, duration) + .await + } + Err(error) => Err(error), + } + } + + /// Scrolls the current view or an element using a WDA mobile command. + /// + /// Typical directions are `up`, `down`, `left`, and `right`. + pub async fn scroll( + &self, + direction: Option<&str>, + name: Option<&str>, + predicate_string: Option<&str>, + to_visible: Option, + element_id: Option<&str>, + session_id: Option<&str>, + ) -> Result<(), IdeviceError> { + let session_id = self.require_session_id(session_id)?; + let mut payload = serde_json::Map::new(); + + if let Some(direction) = direction { + payload.insert("direction".into(), Value::String(direction.to_owned())); + } + if let Some(name) = name { + payload.insert("name".into(), Value::String(name.to_owned())); + } + if let Some(predicate_string) = predicate_string { + payload.insert( + "predicateString".into(), + Value::String(predicate_string.to_owned()), + ); + } + if let Some(to_visible) = to_visible { + payload.insert("toVisible".into(), Value::Bool(to_visible)); + } + if let Some(element_id) = element_id { + payload.insert("elementId".into(), Value::String(element_id.to_owned())); + } + + self.execute_mobile_method(session_id, "scroll", Value::Object(payload)) + .await?; + Ok(()) + } + + /// Returns the current viewport rectangle if the server exposes it. + pub async fn viewport_rect(&self, session_id: Option<&str>) -> Result { + let session_id = self.require_session_id(session_id)?; + let response = self + .execute_mobile_method( + session_id, + "viewportRect", + Value::Object(Default::default()), + ) + .await?; + Ok(Self::value_field(&response)?.clone()) + } + + /// Returns the current orientation if the server exposes it. + pub async fn orientation(&self, session_id: Option<&str>) -> Result { + let session_id = self.require_session_id(session_id)?; + let response = self + .request_json("GET", &format!("/session/{session_id}/orientation"), None) + .await?; + Self::value_field(&response)? + .as_str() + .map(ToOwned::to_owned) + .ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )) + } + + /// Launches or activates an application via WDA. + pub async fn launch_app( + &self, + bundle_id: &str, + arguments: Option<&[String]>, + environment: Option<&serde_json::Map>, + session_id: Option<&str>, + ) -> Result { + let session_id = self.require_session_id(session_id)?; + let mut payload = serde_json::Map::new(); + payload.insert("bundleId".into(), Value::String(bundle_id.to_owned())); + if let Some(arguments) = arguments { + payload.insert( + "arguments".into(), + Value::Array(arguments.iter().cloned().map(Value::String).collect()), + ); + } + if let Some(environment) = environment { + payload.insert("environment".into(), Value::Object(environment.clone())); + } + let response = self + .execute_mobile_method(session_id, "launchApp", Value::Object(payload)) + .await?; + Ok(Self::value_field(&response)?.clone()) + } + + /// Activates an already running application. + pub async fn activate_app( + &self, + bundle_id: &str, + session_id: Option<&str>, + ) -> Result { + let session_id = self.require_session_id(session_id)?; + let response = self + .execute_mobile_method(session_id, "activateApp", json!({ "bundleId": bundle_id })) + .await?; + Ok(Self::value_field(&response)?.clone()) + } + + /// Terminates an application and returns the WDA result. + pub async fn terminate_app( + &self, + bundle_id: &str, + session_id: Option<&str>, + ) -> Result { + let session_id = self.require_session_id(session_id)?; + let response = self + .execute_mobile_method(session_id, "terminateApp", json!({ "bundleId": bundle_id })) + .await?; + Self::value_field(&response)? + .as_bool() + .ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )) + } + + /// Queries the XCTest application state for the given bundle id. + pub async fn query_app_state( + &self, + bundle_id: &str, + session_id: Option<&str>, + ) -> Result { + let session_id = self.require_session_id(session_id)?; + let response = self + .execute_mobile_method( + session_id, + "queryAppState", + json!({ "bundleId": bundle_id }), + ) + .await?; + Self::value_field(&response)? + .as_i64() + .ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )) + } + + /// Backgrounds the current app for the given number of seconds. + /// + /// A negative value means background without restoring. + pub async fn background_app( + &self, + seconds: Option, + session_id: Option<&str>, + ) -> Result { + let session_id = self.require_session_id(session_id)?; + let payload = match seconds { + Some(seconds) => json!({ "seconds": seconds }), + None => json!({}), + }; + let response = self + .execute_mobile_method(session_id, "backgroundApp", payload) + .await?; + Ok(Self::value_field(&response)?.clone()) + } + + /// Returns whether the device is currently locked. + pub async fn is_locked(&self, session_id: Option<&str>) -> Result { + let session_id = self.require_session_id(session_id)?; + let response = self + .execute_mobile_method(session_id, "isLocked", Value::Object(Default::default())) + .await?; + Self::value_field(&response)? + .as_bool() + .ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )) + } + + /// Deletes a session, terminating the app under test. + /// + /// This is the standard W3C WebDriver `DELETE /session/{id}` endpoint and + /// is supported by all WDA builds, unlike the Appium `mobile:` execute routes. + pub async fn delete_session(&self, session_id: &str) -> Result<(), IdeviceError> { + self.request_json("DELETE", &format!("/session/{session_id}"), None) + .await + .map(|_| ()) + } + + /// Sends a single HTTP request over a direct device connection and parses + /// the JSON response body. + /// + /// This intentionally uses `Connection: close` and per-request sockets to + /// keep the transport simple and independent per device. + async fn request_json( + &self, + method: &str, + path: &str, + payload: Option<&Value>, + ) -> Result { + let body = match payload { + Some(payload) => serde_json::to_vec(payload) + .map_err(|_| IdeviceError::UnexpectedResponse("unexpected response".into()))?, + None => Vec::new(), + }; + + let mut request = format!( + "{method} {path} HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\nContent-Length: {}\r\n", + body.len() + ); + if payload.is_some() { + request.push_str("Content-Type: application/json\r\n"); + } + request.push_str("\r\n"); + + let mut idevice = self.provider.connect(self.ports.http).await?; + timeout(self.timeout, async { + idevice.send_raw(request.as_bytes()).await?; + if !body.is_empty() { + idevice.send_raw(&body).await?; + } + Self::read_json_response(&mut idevice).await + }) + .await + .map_err(|_| timeout_error("wda request"))? + } + + /// Reads a non-streaming JSON HTTP response. + /// + /// The current bootstrap client expects either a `Content-Length` body or + /// connection-close semantics and does not yet implement chunked transfer + /// decoding. + async fn read_json_response(idevice: &mut Idevice) -> Result { + let mut response = Vec::new(); + let mut header_end = None; + let mut content_length = None; + + loop { + let chunk = idevice.read_any(8192).await?; + if chunk.is_empty() { + break; + } + + response.extend_from_slice(&chunk); + + if header_end.is_none() + && let Some(offset) = find_bytes(&response, b"\r\n\r\n") + { + let header_len = offset + 4; + header_end = Some(header_len); + let header_text = String::from_utf8_lossy(&response[..offset]); + content_length = parse_content_length(&header_text); + } + + if let (Some(header_len), Some(content_length)) = (header_end, content_length) + && response.len() >= header_len + content_length + { + break; + } + } + + let header_end = header_end.ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + ))?; + let header_text = String::from_utf8_lossy(&response[..header_end - 4]); + let mut lines = header_text.lines(); + let status_line = lines.next().ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + ))?; + let status_code = status_line + .split_whitespace() + .nth(1) + .and_then(|value| value.parse::().ok()) + .ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + ))?; + + let body = &response[header_end..]; + let json: Value = serde_json::from_slice(body) + .map_err(|_| IdeviceError::UnexpectedResponse("unexpected response".into()))?; + + if !(200..300).contains(&status_code) { + return Err(IdeviceError::UnknownErrorType(Self::format_error( + &json, + status_code, + ))); + } + + match json.get("status") { + None | Some(Value::Null) => {} + Some(Value::Number(number)) if number.as_i64() == Some(0) => {} + Some(Value::String(value)) if value == "0" => {} + Some(_) => { + return Err(IdeviceError::UnknownErrorType(Self::format_error( + &json, + status_code, + ))); + } + } + + Ok(json) + } + + fn require_session_id<'b>( + &'b self, + session_id: Option<&'b str>, + ) -> Result<&'b str, IdeviceError> { + session_id + .or(self.session_id()) + .ok_or_else(|| IdeviceError::UnknownErrorType("session_id is required".into())) + } + + fn value_field(response: &Value) -> Result<&Value, IdeviceError> { + response + .get("value") + .ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )) + } + + fn extract_session_id(response: &Value) -> Result { + response + .get("sessionId") + .and_then(Value::as_str) + .or_else(|| { + response + .get("value") + .and_then(Value::as_object) + .and_then(|value| value.get("sessionId")) + .and_then(Value::as_str) + }) + .map(ToOwned::to_owned) + .ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )) + } + + fn extract_element_id(value: &Value) -> Result { + let element = value.as_object().ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + ))?; + element + .get("ELEMENT") + .or_else(|| element.get("element-6066-11e4-a52e-4f735466cecf")) + .or_else(|| element.get("element")) + .and_then(Value::as_str) + .map(ToOwned::to_owned) + .ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )) + } + + fn format_error(data: &Value, status_code: u16) -> String { + let message = data + .get("value") + .map(|value| match value { + Value::Object(object) => object + .get("message") + .or_else(|| object.get("error")) + .cloned() + .unwrap_or_else(|| Value::Object(object.clone())), + other => other.clone(), + }) + .unwrap_or(Value::Null); + format!("WDA error (status={status_code}): {message}") + } + + async fn try_keys_endpoint( + &self, + session_id: &str, + normalized: &str, + ) -> Result { + let key = normalize_wda_key_name(normalized); + let payload = json!({ "keys": [key] }); + match self + .request_json( + "POST", + &format!("/session/{session_id}/wda/keys"), + Some(&payload), + ) + .await + { + Ok(_) => Ok(true), + Err(IdeviceError::UnknownErrorType(message)) if message.contains("404") => Ok(false), + Err(error) => Err(error), + } + } + + async fn execute_mobile_method( + &self, + session_id: &str, + method: &str, + args: Value, + ) -> Result { + let payload = json!({ + "script": format!("mobile: {method}"), + "args": [args], + }); + + match self + .request_json( + "POST", + &format!("/session/{session_id}/execute"), + Some(&payload), + ) + .await + { + Ok(response) => Ok(response), + Err(IdeviceError::UnknownErrorType(message)) if message.contains("status=404") => { + self.request_json( + "POST", + &format!("/session/{session_id}/execute/sync"), + Some(&payload), + ) + .await + } + Err(error) => Err(error), + } + } + + async fn perform_actions(&self, session_id: &str, actions: Value) -> Result<(), IdeviceError> { + self.request_json( + "POST", + &format!("/session/{session_id}/actions"), + Some(&json!({ "actions": actions })), + ) + .await?; + Ok(()) + } + + async fn perform_tap_actions( + &self, + session_id: &str, + x: f64, + y: f64, + tap_count: usize, + ) -> Result<(), IdeviceError> { + let mut gesture_actions = vec![pointer_move_action(0, x, y)]; + for index in 0..tap_count { + gesture_actions.push(pointer_down_action()); + gesture_actions.push(pointer_up_action()); + if index + 1 != tap_count { + gesture_actions.push(pointer_pause_action(100)); + } + } + + self.perform_actions( + session_id, + json!([{ + "type": "pointer", + "id": "finger1", + "parameters": { "pointerType": "touch" }, + "actions": gesture_actions, + }]), + ) + .await + } + + async fn perform_touch_and_hold_actions( + &self, + session_id: &str, + x: f64, + y: f64, + duration: f64, + ) -> Result<(), IdeviceError> { + let hold_duration_ms = duration_to_millis(duration)?; + self.perform_actions( + session_id, + json!([{ + "type": "pointer", + "id": "finger1", + "parameters": { "pointerType": "touch" }, + "actions": [ + pointer_move_action(0, x, y), + pointer_down_action(), + pointer_pause_action(hold_duration_ms), + pointer_up_action(), + ], + }]), + ) + .await + } + + async fn resolve_gesture_coordinates( + &self, + x: Option, + y: Option, + element_id: Option<&str>, + session_id: &str, + ) -> Result<(f64, f64), IdeviceError> { + match (x, y) { + (Some(x), Some(y)) => Ok((x, y)), + (None, None) => { + let element_id = element_id.ok_or_else(|| { + IdeviceError::UnknownErrorType( + "gesture fallback requires coordinates or an element id".into(), + ) + })?; + let rect = self.element_rect(element_id, Some(session_id)).await?; + let center_x = + json_number_field(&rect, "x")? + json_number_field(&rect, "width")? / 2.0; + let center_y = + json_number_field(&rect, "y")? + json_number_field(&rect, "height")? / 2.0; + Ok((center_x, center_y)) + } + _ => Err(IdeviceError::UnknownErrorType( + "gesture fallback requires both x and y coordinates".into(), + )), + } + } + + async fn execute_gesture( + &self, + method: &str, + x: Option, + y: Option, + element_id: Option<&str>, + duration: Option, + session_id: Option<&str>, + ) -> Result<(), IdeviceError> { + let session_id = self.require_session_id(session_id)?; + let mut payload = serde_json::Map::new(); + + if let Some(x) = x { + payload.insert("x".into(), Value::from(x)); + } + if let Some(y) = y { + payload.insert("y".into(), Value::from(y)); + } + if let Some(element_id) = element_id { + payload.insert("elementId".into(), Value::String(element_id.to_owned())); + } + if let Some(duration) = duration { + payload.insert("duration".into(), Value::from(duration)); + } + + self.execute_mobile_method(session_id, method, Value::Object(payload)) + .await?; + Ok(()) + } + + async fn element_bool_state( + &self, + element_id: &str, + state: &str, + session_id: Option<&str>, + ) -> Result { + let session_id = self.require_session_id(session_id)?; + let response = self + .request_json( + "GET", + &format!("/session/{session_id}/element/{element_id}/{state}"), + None, + ) + .await?; + Self::value_field(&response)? + .as_bool() + .ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )) + } +} + +fn parse_content_length(headers: &str) -> Option { + headers.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + if !name.eq_ignore_ascii_case("content-length") { + return None; + } + value.trim().parse::().ok() + }) +} + +fn find_bytes(haystack: &[u8], needle: &[u8]) -> Option { + haystack + .windows(needle.len()) + .position(|window| window == needle) +} + +fn normalize_wda_button_name(name: &str) -> String { + match name + .trim() + .to_ascii_lowercase() + .replace(['-', '_'], "") + .as_str() + { + "home" => "home".into(), + "volumeup" | "volup" | "volumeupbutton" => "volumeUp".into(), + "volumedown" | "voldown" | "volumedownbutton" => "volumeDown".into(), + "lock" | "lockscreen" | "sleep" | "power" => "lock".into(), + _ => name.to_owned(), + } +} + +fn normalize_wda_key_name(name: &str) -> String { + match name + .trim() + .to_ascii_lowercase() + .replace(['-', '_'], "") + .as_str() + { + "home" => "HOME".into(), + "volumeup" | "volup" => "VOLUME_UP".into(), + "volumedown" | "voldown" => "VOLUME_DOWN".into(), + "lock" | "lockscreen" | "sleep" | "power" => "LOCK".into(), + _ => name.to_owned(), + } +} + +fn timeout_error(context: &str) -> IdeviceError { + std::io::Error::new(std::io::ErrorKind::TimedOut, format!("{context} timed out")).into() +} + +fn json_number_field(value: &Value, field: &str) -> Result { + value + .get(field) + .and_then(Value::as_f64) + .ok_or(IdeviceError::UnexpectedResponse( + "unexpected response".into(), + )) +} + +fn pointer_move_action(duration_ms: u64, x: f64, y: f64) -> Value { + json!({ + "type": "pointerMove", + "duration": duration_ms, + "x": x, + "y": y, + "origin": "viewport", + }) +} + +fn pointer_down_action() -> Value { + json!({ + "type": "pointerDown", + "button": 0, + }) +} + +fn pointer_up_action() -> Value { + json!({ + "type": "pointerUp", + "button": 0, + }) +} + +fn pointer_pause_action(duration_ms: u64) -> Value { + json!({ + "type": "pause", + "duration": duration_ms, + }) +} + +fn duration_to_millis(duration: f64) -> Result { + if !duration.is_finite() || duration < 0.0 { + return Err(IdeviceError::UnknownErrorType( + "gesture duration must be a non-negative finite number".into(), + )); + } + Ok((duration * 1000.0).round() as u64) +} + +#[cfg(test)] +mod tests { + use super::{ + DEFAULT_WDA_MJPEG_PORT, DEFAULT_WDA_PORT, WDA_READY_POLL_INTERVAL, WdaPorts, + duration_to_millis, find_bytes, normalize_wda_button_name, normalize_wda_key_name, + parse_content_length, + }; + + #[test] + fn default_ports_match_expected_wda_values() { + let ports = WdaPorts::default(); + assert_eq!(ports.http, DEFAULT_WDA_PORT); + assert_eq!(ports.mjpeg, DEFAULT_WDA_MJPEG_PORT); + } + + #[test] + fn ready_poll_interval_is_conservative() { + assert_eq!( + WDA_READY_POLL_INTERVAL, + std::time::Duration::from_millis(250) + ); + } + + #[test] + fn parse_content_length_is_case_insensitive() { + let headers = "HTTP/1.1 200 OK\r\ncontent-length: 123\r\nConnection: close\r\n"; + assert_eq!(parse_content_length(headers), Some(123)); + } + + #[test] + fn parse_content_length_ignores_missing_header() { + let headers = "HTTP/1.1 200 OK\r\nConnection: close\r\n"; + assert_eq!(parse_content_length(headers), None); + } + + #[test] + fn find_bytes_locates_header_separator() { + let response = b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\n{}"; + assert_eq!(find_bytes(response, b"\r\n\r\n"), Some(34)); + } + + #[test] + fn find_bytes_returns_none_when_missing() { + assert_eq!(find_bytes(b"abcdef", b"xyz"), None); + } + + #[test] + fn normalize_button_aliases() { + assert_eq!(normalize_wda_button_name("home"), "home"); + assert_eq!(normalize_wda_button_name("volume_up"), "volumeUp"); + assert_eq!(normalize_wda_button_name("sleep"), "lock"); + } + + #[test] + fn normalize_key_aliases() { + assert_eq!(normalize_wda_key_name("home"), "HOME"); + assert_eq!(normalize_wda_key_name("vol-down"), "VOLUME_DOWN"); + assert_eq!(normalize_wda_key_name("power"), "LOCK"); + } + + #[test] + fn duration_to_millis_rounds_seconds() { + assert_eq!(duration_to_millis(0.18).unwrap(), 180); + assert_eq!(duration_to_millis(1.25).unwrap(), 1250); + } + + #[test] + fn duration_to_millis_rejects_negative_values() { + assert!(duration_to_millis(-0.1).is_err()); + } +} diff --git a/src-tauri/vendor/idevice/src/services/wda_bridge.rs b/src-tauri/vendor/idevice/src/services/wda_bridge.rs new file mode 100644 index 0000000..311d685 --- /dev/null +++ b/src-tauri/vendor/idevice/src/services/wda_bridge.rs @@ -0,0 +1,245 @@ +//! Localhost bridge for WebDriverAgent HTTP and MJPEG endpoints. +//! +//! This module exposes device-side WDA ports as dynamic localhost URLs so GUI +//! clients (for example Tauri/React) can consume them as ordinary HTTP +//! endpoints without hard-coding host ports. + +use std::{net::SocketAddr, sync::Arc}; + +use tokio::{ + io::copy_bidirectional, + net::{TcpListener, TcpStream}, + task::JoinHandle, +}; +use tracing::{debug, warn}; + +use crate::{IdeviceError, provider::IdeviceProvider}; + +use super::wda::{DEFAULT_WDA_MJPEG_PORT, DEFAULT_WDA_PORT, WdaPorts}; + +/// Localhost URLs assigned to a running WDA bridge. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct WdaBridgeEndpoints { + /// Device UDID when it can be resolved from the pairing file. + pub udid: Option, + /// Local URL forwarding to the device-side WDA HTTP endpoint. + pub wda_url: String, + /// Local URL forwarding to the device-side MJPEG endpoint. + pub mjpeg_url: String, + /// Local ports bound on the host. + pub local_ports: WdaPorts, + /// Original device-side ports. + pub device_ports: WdaPorts, +} + +#[derive(Debug)] +struct TcpPortForward { + local_addr: SocketAddr, + task: JoinHandle<()>, +} + +impl TcpPortForward { + async fn start( + provider: Arc, + device_port: u16, + label: &'static str, + ) -> Result { + let listener = TcpListener::bind(("127.0.0.1", 0)).await?; + let local_addr = listener.local_addr()?; + let provider_label = provider.label().to_string(); + + let task = tokio::spawn(async move { + loop { + let (mut client, client_addr) = match listener.accept().await { + Ok(connection) => connection, + Err(error) => { + warn!("[{}] localhost bridge accept failed: {}", label, error); + break; + } + }; + + let provider = provider.clone(); + let provider_label = provider_label.clone(); + tokio::spawn(async move { + debug!( + "[{}] bridging {} -> {}:{}", + label, client_addr, provider_label, device_port + ); + + let device = match provider.connect(device_port).await { + Ok(device) => device, + Err(error) => { + warn!( + "[{}] failed to connect to device port {}: {}", + label, device_port, error + ); + return; + } + }; + + let mut device_socket = match device.get_socket() { + Some(socket) => socket, + None => { + warn!( + "[{}] failed to extract device socket for port {}", + label, device_port + ); + return; + } + }; + + if let Err(error) = proxy_connection(&mut client, device_socket.as_mut()).await + { + debug!( + "[{}] bridge connection {} -> {} closed with error: {}", + label, client_addr, device_port, error + ); + } + }); + } + }); + + Ok(Self { local_addr, task }) + } + + fn local_port(&self) -> u16 { + self.local_addr.port() + } +} + +impl Drop for TcpPortForward { + fn drop(&mut self) { + self.task.abort(); + } +} + +/// Dynamic localhost bridge for a single device's WDA endpoints. +#[derive(Debug)] +pub struct WdaBridge { + endpoints: WdaBridgeEndpoints, + wda_forward: TcpPortForward, + mjpeg_forward: TcpPortForward, +} + +impl WdaBridge { + /// Starts localhost forwarding for the default WDA HTTP and MJPEG ports. + pub async fn start(provider: Arc) -> Result { + Self::start_with_ports( + provider, + WdaPorts { + http: DEFAULT_WDA_PORT, + mjpeg: DEFAULT_WDA_MJPEG_PORT, + }, + ) + .await + } + + /// Starts localhost forwarding for custom device-side WDA ports. + pub async fn start_with_ports( + provider: Arc, + device_ports: WdaPorts, + ) -> Result { + let udid = provider + .get_pairing_file() + .await + .ok() + .and_then(|pairing| pairing.udid); + let wda_forward = + TcpPortForward::start(provider.clone(), device_ports.http, "wda-http").await?; + let mjpeg_forward = + TcpPortForward::start(provider, device_ports.mjpeg, "wda-mjpeg").await?; + + let local_ports = WdaPorts { + http: wda_forward.local_port(), + mjpeg: mjpeg_forward.local_port(), + }; + + let endpoints = bridge_endpoints(udid, local_ports, device_ports); + + Ok(Self { + endpoints, + wda_forward, + mjpeg_forward, + }) + } + + /// Returns the resolved localhost endpoints. + pub fn endpoints(&self) -> &WdaBridgeEndpoints { + &self.endpoints + } + + /// Returns the localhost WDA HTTP URL. + pub fn wda_url(&self) -> &str { + &self.endpoints.wda_url + } + + /// Returns the localhost MJPEG URL. + pub fn mjpeg_url(&self) -> &str { + &self.endpoints.mjpeg_url + } + + /// Stops the localhost bridge by consuming the handle. + /// + /// Dropping the bridge aborts the underlying accept loops, so an explicit + /// shutdown method is only a convenience wrapper over normal drop + /// semantics. + pub fn shutdown(self) { + let WdaBridge { + endpoints: _, + wda_forward, + mjpeg_forward, + } = self; + drop(wda_forward); + drop(mjpeg_forward); + } +} + +fn bridge_endpoints( + udid: Option, + local_ports: WdaPorts, + device_ports: WdaPorts, +) -> WdaBridgeEndpoints { + WdaBridgeEndpoints { + udid, + wda_url: format!("http://127.0.0.1:{}", local_ports.http), + mjpeg_url: format!("http://127.0.0.1:{}", local_ports.mjpeg), + local_ports, + device_ports, + } +} + +async fn proxy_connection( + client: &mut TcpStream, + device: &mut dyn crate::ReadWrite, +) -> Result<(), IdeviceError> { + let _ = copy_bidirectional(client, device).await?; + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{WdaPorts, bridge_endpoints}; + + #[test] + fn bridge_endpoints_use_local_ports_in_urls() { + let endpoints = bridge_endpoints( + Some("test-udid".into()), + WdaPorts { + http: 38100, + mjpeg: 39100, + }, + WdaPorts { + http: 8100, + mjpeg: 9100, + }, + ); + + assert_eq!(endpoints.udid.as_deref(), Some("test-udid")); + assert_eq!(endpoints.wda_url, "http://127.0.0.1:38100"); + assert_eq!(endpoints.mjpeg_url, "http://127.0.0.1:39100"); + assert_eq!(endpoints.local_ports.http, 38100); + assert_eq!(endpoints.local_ports.mjpeg, 39100); + assert_eq!(endpoints.device_ports.http, 8100); + assert_eq!(endpoints.device_ports.mjpeg, 9100); + } +} diff --git a/src-tauri/vendor/idevice/src/sni.rs b/src-tauri/vendor/idevice/src/sni.rs new file mode 100644 index 0000000..1bf9806 --- /dev/null +++ b/src-tauri/vendor/idevice/src/sni.rs @@ -0,0 +1,82 @@ +// Jackson Coxson +// https://gist.github.com/doroved/2c92ddd5e33f257f901c763b728d1b61 +// +// Why: +// https://github.com/rustls/rustls/issues/578 +// We aren't in control of the certs served. +// Assuming that there's no use for unchecked certs is naive. + +use rustls::{ + ClientConfig, DigitallySignedStruct, + client::{ + WebPkiServerVerifier, + danger::{HandshakeSignatureValid, ServerCertVerified, ServerCertVerifier}, + }, + pki_types::{CertificateDer, PrivateKeyDer, ServerName, UnixTime, pem::PemObject}, +}; +use std::sync::Arc; + +use crate::{IdeviceError, pairing_file::PairingFile}; + +#[derive(Debug)] +pub struct NoServerNameVerification { + inner: Arc, +} + +impl NoServerNameVerification { + pub fn new(inner: Arc) -> Self { + Self { inner } + } +} + +impl ServerCertVerifier for NoServerNameVerification { + fn verify_server_cert( + &self, + _end_entity: &CertificateDer<'_>, + _intermediates: &[CertificateDer<'_>], + _server_name: &ServerName<'_>, + _ocsp: &[u8], + _now: UnixTime, + ) -> Result { + Ok(ServerCertVerified::assertion()) + } + + fn verify_tls12_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn verify_tls13_signature( + &self, + _message: &[u8], + _cert: &CertificateDer<'_>, + _dss: &DigitallySignedStruct, + ) -> Result { + Ok(HandshakeSignatureValid::assertion()) + } + + fn supported_verify_schemes(&self) -> Vec { + self.inner.supported_verify_schemes() + } +} + +pub fn create_client_config(pairing_file: &PairingFile) -> Result { + let mut root_store = rustls::RootCertStore::empty(); + root_store.add(pairing_file.root_certificate.clone())?; + let private_key = PrivateKeyDer::from_pem_slice(&pairing_file.host_private_key)?; + + let mut config = ClientConfig::builder() + .with_root_certificates(root_store.clone()) + .with_client_auth_cert(vec![pairing_file.host_certificate.clone()], private_key) + .unwrap(); + + let inner = rustls::client::WebPkiServerVerifier::builder(Arc::new(root_store)).build()?; + let verifier = Arc::new(NoServerNameVerification::new(inner)); + config.dangerous().set_certificate_verifier(verifier); + + Ok(config) +} diff --git a/src-tauri/vendor/idevice/src/tcp/mod.rs b/src-tauri/vendor/idevice/src/tcp/mod.rs new file mode 100644 index 0000000..103df22 --- /dev/null +++ b/src-tauri/vendor/idevice/src/tcp/mod.rs @@ -0,0 +1,18 @@ +// Jackson Coxson + +pub use jktcp::adapter; +pub use jktcp::handle; +pub use jktcp::packets; +pub use jktcp::stream; + +use crate::{ReadWrite, provider::RsdProvider}; + +impl RsdProvider for handle::AdapterHandle { + async fn connect_to_service_port( + &mut self, + port: u16, + ) -> Result, crate::IdeviceError> { + let s = self.connect(port).await?; + Ok(Box::new(s)) + } +} diff --git a/src-tauri/vendor/idevice/src/tss.rs b/src-tauri/vendor/idevice/src/tss.rs new file mode 100644 index 0000000..22f0540 --- /dev/null +++ b/src-tauri/vendor/idevice/src/tss.rs @@ -0,0 +1,590 @@ +//! Ticket Signature Server (TSS) Client +//! +//! Provides functionality for interacting with Apple's TSS service to: +//! - Request personalized firmware components +//! - Apply restore request rules for device-specific parameters +//! - Handle cryptographic signing operations + +use plist::Value; +use plist_macro::plist_to_xml_bytes; +use tracing::{debug, warn}; + +use crate::IdeviceError; + +/// TSS client version string sent in requests +const TSS_CLIENT_VERSION_STRING: &str = "libauthinstall-1033.0.2"; +/// Apple's TSS endpoint URL +const TSS_CONTROLLER_ACTION_URL: &str = "http://gs.apple.com/TSS/controller?action=2"; + +/// Represents a TSS request to Apple's signing server +#[derive(Debug)] +pub struct TSSRequest { + /// The underlying plist dictionary containing request parameters + inner: plist::Dictionary, +} + +impl TSSRequest { + /// Creates a new TSS request with default headers + /// + /// Initializes with: + /// - Host platform info + /// - Client version string + /// - Random UUID for request identification + pub fn new() -> Self { + let inner = plist_macro::plist!(dict { + "@HostPlatformInfo": "mac", + "@VersionInfo": TSS_CLIENT_VERSION_STRING, + "@UUID": uuid::Uuid::new_v4().to_string().to_uppercase() + }); + Self { inner } + } + + /// Inserts a key-value pair into the TSS request + /// + /// # Arguments + /// * `key` - The parameter name + /// * `val` - The parameter value (will be converted to plist::Value) + pub fn insert(&mut self, key: impl Into, val: impl Into) { + let key = key.into(); + let val = val.into(); + self.inner.insert(key, val); + } + + /// Sends the TSS request to Apple's servers + /// + /// # Returns + /// The parsed plist response from Apple + /// + /// # Errors + /// Returns `IdeviceError` if: + /// - The request fails + /// - The response is malformed + /// - Apple returns a non-success status + /// + /// # Example + /// ```rust + /// let mut request = TSSRequest::new(); + /// request.insert("ApBoardID", board_id); + /// request.insert("ApChipID", chip_id); + /// let response = request.send().await?; + /// ``` + pub async fn send(&self) -> Result { + debug!( + "Sending TSS request: {}", + crate::pretty_print_dictionary(&self.inner) + ); + let client = reqwest::Client::new(); + + let res = client + .post(TSS_CONTROLLER_ACTION_URL) + .header("Cache-Control", "no-cache") + .header("Content-type", "text/xml; charset=\"utf-8\"") + .header("User-Agent", "InetURL/1.0") + .body(plist_to_xml_bytes(&self.inner)) + .send() + .await? + .text() + .await?; + + debug!("Apple responded with {res}"); + let trimmed = res.trim_start_matches("STATUS=0&"); + let trimmed = trimmed.trim_start_matches("MESSAGE="); + if !trimmed.starts_with("SUCCESS") { + // On failure Apple returns `STATUS=&MESSAGE=` (no + // REQUEST_STRING); surface it so the caller sees why it was rejected. + let detail = res + .split("&REQUEST_STRING=") + .next() + .unwrap_or(res.as_str()) + .trim(); + warn!("TSS responded with non-success value: {detail}"); + return Err(IdeviceError::UnexpectedResponse(format!( + "TSS server responded with non-success status ({detail})" + ))); + } + let res = res.split("REQUEST_STRING=").collect::>(); + if res.len() < 2 { + warn!("Response didn't contain a request string"); + return Err(IdeviceError::UnexpectedResponse( + "TSS response missing REQUEST_STRING".into(), + )); + } + Ok(plist::from_bytes(res[1].as_bytes())?) + } + + /// Sets the `@ApImg4Ticket` request flag. + /// + /// When true, the server returns a signed IMG4 manifest (`ApImg4Ticket`). + pub fn set_ap_img4_ticket(&mut self, value: bool) { + self.insert("@ApImg4Ticket", value); + } + + /// Sets the `@BBTicket` request flag (request a baseband ticket). + pub fn set_bb_ticket(&mut self, value: bool) { + self.insert("@BBTicket", value); + } + + /// Adds the common `Ap*` identity tags shared by the developer-disk-image + /// personalization flow and the full IPSW restore flow. + /// + /// Sets `ApBoardID`, `ApChipID`, `ApECID`, `ApProductionMode` (true), + /// `ApSecurityDomain` (1), `ApSecurityMode` (true) and `UID_MODE` (false). + /// `ap_nonce`/`sep_nonce`, when supplied, are inserted as `ApNonce`/`SepNonce` + /// data blobs. + pub fn add_common_tags( + &mut self, + board_id: u64, + chip_id: u64, + ecid: u64, + ap_nonce: Option>, + sep_nonce: Option>, + ) { + self.insert("ApBoardID", board_id); + self.insert("ApChipID", chip_id); + self.insert("ApECID", ecid); + self.insert("ApProductionMode", true); + self.insert("ApSecurityDomain", 1); + self.insert("ApSecurityMode", true); + self.insert("UID_MODE", false); + if let Some(n) = ap_nonce { + self.insert("ApNonce", plist::Value::Data(n)); + } + if let Some(n) = sep_nonce { + self.insert("SepNonce", plist::Value::Data(n)); + } + } + + /// Removes a key from the request, returning the removed value if present. + pub fn remove(&mut self, key: &str) -> Option { + self.inner.remove(key) + } + + pub fn add_build_identity_tags(&mut self, build_identity: &plist::Dictionary, keys: &[&str]) { + for &key in keys { + if let Some(v) = build_identity.get(key) { + let converted = match v { + Value::String(s) if s.starts_with("0x") => { + match u64::from_str_radix(s.trim_start_matches("0x"), 16) { + Ok(n) => Value::from(n), + Err(_) => v.clone(), + } + } + _ => v.clone(), + }; + self.insert(key, converted); + } + } + } + + pub fn add_ap_personalization_identifiers(&mut self, identifiers: &plist::Dictionary) { + for (key, val) in identifiers { + if key.starts_with("Ap,") { + self.insert(key.clone(), val.clone()); + } + } + } + + pub fn add_ap_tags(&mut self, build_identity: &plist::Dictionary) { + const KEYS: &[&str] = &[ + "UniqueBuildID", + "Ap,OSLongVersion", + "Ap,OSReleaseType", + "Ap,ProductType", + "Ap,SDKPlatform", + "Ap,SikaFuse", + "Ap,Target", + "Ap,TargetType", + "Ap,ProductMarketingVersion", + "ApBoardID", + "ApChipID", + "ApSecurityDomain", + "BMU,BoardID", + "BMU,ChipID", + "BbChipID", + "BbProvisioningManifestKeyHash", + "BbActivationManifestKeyHash", + "BbCalibrationManifestKeyHash", + "BbFactoryActivationManifestKeyHash", + "BbFDRSecurityKeyHash", + "BbSkeyId", + "SE,ChipID", + "Savage,ChipID", + "Savage,PatchEpoch", + "Yonkers,BoardID", + "Yonkers,ChipID", + "Yonkers,PatchEpoch", + "Rap,BoardID", + "Rap,ChipID", + "Rap,SecurityDomain", + "Baobab,BoardID", + "Baobab,ChipID", + "Baobab,ManifestEpoch", + "Baobab,SecurityDomain", + "eUICC,ChipID", + "PearlCertificationRootPub", + "Timer,BoardID,1", + "Timer,BoardID,2", + "Timer,ChipID,1", + "Timer,ChipID,2", + "Timer,SecurityDomain,1", + "Timer,SecurityDomain,2", + "NeRDEpoch", + ]; + for &key in KEYS { + if let Some(v) = build_identity.get(key) { + let converted = match v { + Value::String(s) if s.starts_with("0x") => { + match u64::from_str_radix(s.trim_start_matches("0x"), 16) { + Ok(n) => Value::from(n), + Err(_) => v.clone(), + } + } + _ => v.clone(), + }; + self.insert(key, converted); + } + } + + if let Some(r) = build_identity + .get("Info") + .and_then(|i| i.as_dictionary()) + .and_then(|i| i.get("RequiresUIDMode")) + { + self.insert("RequiresUIDMode", r.clone()); + } + } + + pub fn add_ap_manifest_tags( + &mut self, + build_identity: &plist::Dictionary, + parameters: &plist::Dictionary, + ) -> Result<(), IdeviceError> { + const SKIP_KEYS: &[&str] = &[ + "BasebandFirmware", + "SE,UpdatePayload", + "BaseSystem", + "Diags", + "Ap,ExclaveOS", + ]; + + let manifest = match build_identity.get("Manifest") { + Some(plist::Value::Dictionary(m)) => m, + _ => return Err(IdeviceError::BadBuildManifest), + }; + let supports_img4 = parameters + .get("ApSupportsImg4") + .and_then(Value::as_boolean) + .unwrap_or(false); + + for (key, manifest_item) in manifest { + if SKIP_KEYS.contains(&key.as_str()) || key.starts_with("Cryptex1,") { + continue; + } + let manifest_item = match manifest_item { + plist::Value::Dictionary(m) => m, + _ => continue, + }; + let info = match manifest_item.get("Info") { + Some(plist::Value::Dictionary(i)) => i, + _ => continue, + }; + // For IMG4 devices, only components with RestoreRequestRules belong + // in the AP ticket. + let has_rules = info.contains_key("RestoreRequestRules"); + if supports_img4 && !has_rules { + debug!("skipping {key}: no RestoreRequestRules"); + continue; + } + if info + .get("IsFTAB") + .and_then(Value::as_boolean) + .unwrap_or(false) + { + continue; + } + + let mut tss_entry = manifest_item.clone(); + tss_entry.remove("Info"); + + if let Some(plist::Value::Array(rules)) = info.get("RestoreRequestRules") { + apply_restore_request_rules(&mut tss_entry, parameters, rules); + } + + let trusted = manifest_item + .get("Trusted") + .and_then(Value::as_boolean) + .unwrap_or(false); + if trusted && !tss_entry.contains_key("Digest") { + tss_entry.insert("Digest".into(), plist::Value::Data(Vec::new())); + } + + self.insert(key.clone(), tss_entry); + } + + Ok(()) + } + + pub fn add_baseband_tags(&mut self, parameters: &plist::Dictionary) { + self.insert("@BBTicket", true); + + const KEYS: &[&str] = &[ + "BbChipID", + "BbProvisioningManifestKeyHash", + "BbActivationManifestKeyHash", + "BbCalibrationManifestKeyHash", + "BbFactoryActivationManifestKeyHash", + "BbFDRSecurityKeyHash", + "BbSkeyId", + "BbNonce", + "BbGoldCertId", + "BbSNUM", + "PearlCertificationRootPub", + "Ap,OSLongVersion", + ]; + for &key in KEYS { + if let Some(v) = parameters.get(key) { + self.insert(key, v.clone()); + } + } + + if let Some(bbfw) = parameters + .get("Manifest") + .and_then(|m| m.as_dictionary()) + .and_then(|m| m.get("BasebandFirmware")) + .and_then(|b| b.as_dictionary()) + { + let mut bbfwdict = bbfw.clone(); + bbfwdict.remove("Info"); + + let bb_chip_id = parameters + .get("BbChipID") + .and_then(Value::as_unsigned_integer); + let bb_cert_id = parameters + .get("BbGoldCertId") + .and_then(Value::as_unsigned_integer); + if bb_chip_id == Some(0x68) { + if matches!(bb_cert_id, Some(0x26F3_FACC | 0x5CF2_EC4E | 0x8399_785A)) { + bbfwdict.remove("PSI2-PartialDigest"); + bbfwdict.remove("RestorePSI2-PartialDigest"); + } else { + bbfwdict.remove("PSI-PartialDigest"); + bbfwdict.remove("RestorePSI-PartialDigest"); + } + } + self.insert("BasebandFirmware", Value::Dictionary(bbfwdict)); + } + } + + pub fn populate_from_manifest( + &mut self, + build_identity: &plist::Dictionary, + parameters: &plist::Dictionary, + rules_override: Option<&[plist::Value]>, + ) -> Result<(), IdeviceError> { + let manifest = match build_identity.get("Manifest") { + Some(plist::Value::Dictionary(m)) => m, + _ => return Err(IdeviceError::BadBuildManifest), + }; + + for (key, manifest_item) in manifest { + let manifest_item = match manifest_item { + plist::Value::Dictionary(m) => m, + _ => { + debug!("Manifest item {key} wasn't a dictionary"); + continue; + } + }; + + let info = match manifest_item.get("Info") { + Some(plist::Value::Dictionary(i)) => i, + _ => { + debug!("Manifest item {key} didn't contain Info"); + continue; + } + }; + + if !matches!( + manifest_item.get("Trusted"), + Some(plist::Value::Boolean(true)) + ) { + debug!("Manifest item {key} isn't trusted"); + continue; + } + + let mut tss_entry = manifest_item.clone(); + tss_entry.remove("Info"); + + let rules = match rules_override { + Some(r) => Some(r), + None => info + .get("RestoreRequestRules") + .and_then(|v| v.as_array()) + .map(|v| v.as_slice()), + }; + if let Some(rules) = rules { + apply_restore_request_rules(&mut tss_entry, parameters, rules); + } + + if manifest_item.get("Digest").is_none() { + tss_entry.insert("Digest".into(), plist::Value::Data(Vec::new())); + } + + self.insert(key.clone(), tss_entry); + } + + Ok(()) + } +} + +impl Default for TSSRequest { + /// Creates a default TSS request (same as `new()`) + fn default() -> Self { + Self::new() + } +} + +/// Applies restore request rules to modify input parameters +/// +/// # Arguments +/// * `input` - The dictionary to modify based on rules +/// * `parameters` - Device parameters to check conditions against +/// * `rules` - List of rules to apply +/// +/// # Process +/// For each rule: +/// 1. Checks all conditions against the parameters +/// 2. If all conditions are met, applies the rule's actions +/// 3. Actions can add, modify or remove parameters +pub fn apply_restore_request_rules( + input: &mut plist::Dictionary, + parameters: &plist::Dictionary, + rules: &[plist::Value], +) { + for rule in rules { + if let plist::Value::Dictionary(rule) = rule { + let conditions = match rule.get("Conditions") { + Some(plist::Value::Dictionary(c)) => c, + _ => { + warn!("Conditions doesn't exist or wasn't a dictionary!"); + continue; + } + }; + + let mut conditions_fulfilled = true; + for (key, value) in conditions { + let value2 = match key.as_str() { + "ApRawProductionMode" => parameters.get("ApProductionMode"), + "ApCurrentProductionMode" => parameters.get("ApProductionMode"), + "ApRawSecurityMode" => parameters.get("ApSecurityMode"), + "ApRequiresImage4" => parameters.get("ApSupportsImg4"), + "ApDemotionPolicyOverride" => parameters.get("DemotionPolicy"), + "ApInRomDFU" => parameters.get("ApInRomDFU"), + _ => { + warn!("Unhandled key {key}"); + None + } + }; + + if value2.is_none() || value2 != Some(value) { + conditions_fulfilled = false; + break; // Stop checking other conditions immediately + } + } + + if !conditions_fulfilled { + continue; + } + + let actions = match rule.get("Actions") { + Some(plist::Value::Dictionary(a)) => a, + _ => { + warn!("Actions doesn't exist or wasn't a dictionary!"); + continue; + } + }; + + for (key, value) in actions { + // Skip special values (255 typically means "ignore") + if let Some(i) = value.as_unsigned_integer() + && i == 255 + { + continue; + } + if let Some(i) = value.as_signed_integer() + && i == 255 + { + continue; + } + + input.remove(key); // Explicitly remove before inserting + input.insert(key.to_owned(), value.to_owned()); + } + } else { + warn!("Rule wasn't a dictionary"); + } + } +} + +fn parse_hex_field(v: Option<&plist::Value>) -> Option { + match v { + Some(plist::Value::String(s)) => u64::from_str_radix(s.trim_start_matches("0x"), 16).ok(), + Some(plist::Value::Integer(i)) => i.as_unsigned(), + _ => None, + } +} + +pub fn select_build_identity<'a>( + build_manifest: &'a plist::Dictionary, + board_id: u64, + chip_id: u64, + restore_behavior: Option<&str>, +) -> Result<&'a plist::Dictionary, IdeviceError> { + let identities = match build_manifest.get("BuildIdentities") { + Some(plist::Value::Array(i)) => i, + _ => return Err(IdeviceError::BadBuildManifest), + }; + + for id in identities { + let id = match id { + plist::Value::Dictionary(id) => id, + _ => { + debug!("build identity wasn't a dictionary"); + continue; + } + }; + + if parse_hex_field(id.get("ApBoardID")) != Some(board_id) { + continue; + } + if parse_hex_field(id.get("ApChipID")) != Some(chip_id) { + continue; + } + if let Some(behavior) = restore_behavior { + let matches = id + .get("Info") + .and_then(|i| i.as_dictionary()) + .and_then(|i| i.get("RestoreBehavior")) + .and_then(|b| b.as_string()) + == Some(behavior); + if !matches { + continue; + } + } + return Ok(id); + } + + Err(IdeviceError::BadBuildManifest) +} + +/// Extracts the `ApImg4Ticket` blob from a TSS response dictionary. +/// +/// # Errors +/// Returns [`IdeviceError::UnexpectedResponse`] if the ticket is absent. +pub fn extract_img4_ticket(response: &plist::Dictionary) -> Result, IdeviceError> { + match response.get("ApImg4Ticket") { + Some(plist::Value::Data(d)) => Ok(d.clone()), + _ => Err(IdeviceError::UnexpectedResponse( + "missing ApImg4Ticket data in TSS response".into(), + )), + } +} diff --git a/src-tauri/vendor/idevice/src/tunnel.rs b/src-tauri/vendor/idevice/src/tunnel.rs new file mode 100644 index 0000000..bc69d9a --- /dev/null +++ b/src-tauri/vendor/idevice/src/tunnel.rs @@ -0,0 +1,162 @@ +// Jackson Coxson +//! CDTunnel protocol for establishing IPv6 tunnels to iOS devices. +//! +//! This module is transport-agnostic: the CDTunnel handshake and packet I/O +//! work over any [`ReadWrite`] stream, whether that's a direct USB socket +//! (via CoreDeviceProxy), a TLS-PSK encrypted TCP connection (via remoted), +//! or anything else. + +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tracing::debug; + +use crate::{IdeviceError, ReadWrite}; + +const CDTUNNEL_MAGIC: &[u8] = b"CDTunnel"; +const IPV6_HEADER_SIZE: usize = 40; +const DEFAULT_MTU: u16 = 16000; + +/// Result of the CDTunnel handshake containing network configuration. +#[derive(Debug, Clone)] +pub struct TunnelInfo { + /// IPv6 address assigned to the host side of the tunnel + pub client_address: String, + /// Subnet mask for the tunnel (may be empty if not provided) + pub netmask: String, + /// IPv6 address of the device side of the tunnel + pub server_address: String, + /// Negotiated MTU for the tunnel + pub mtu: u16, + /// RSD port on the device (accessible through the tunnel) + pub server_rsd_port: u16, +} + +/// A CDTunnel connection that carries raw IPv6 packets. +/// +/// After handshake, call `send_packet` / `recv_packet` to exchange +/// raw IPv6 packets with the device. These can be fed into jktcp or +/// a TUN device. +#[derive(Debug)] +pub struct CdTunnel { + pub(crate) inner: R, + pub info: TunnelInfo, +} + +impl CdTunnel { + /// Perform the CDTunnel handshake on an already-connected (and optionally + /// TLS-wrapped) stream. Returns a tunnel ready for packet I/O. + pub async fn handshake(mut stream: R) -> Result { + let request = serde_json::json!({ + "type": "clientHandshakeRequest", + "mtu": DEFAULT_MTU + }); + let body = serde_json::to_vec(&request)?; + + stream.write_all(CDTUNNEL_MAGIC).await?; + stream.write_all(&(body.len() as u16).to_be_bytes()).await?; + stream.write_all(&body).await?; + stream.flush().await?; + + debug!("Sent CDTunnel handshake request"); + + let mut magic_buf = vec![0u8; CDTUNNEL_MAGIC.len()]; + stream.read_exact(&mut magic_buf).await?; + if magic_buf != CDTUNNEL_MAGIC { + return Err(IdeviceError::UnexpectedResponse( + "invalid CDTunnel magic in handshake response".into(), + )); + } + + let mut len_buf = [0u8; 2]; + stream.read_exact(&mut len_buf).await?; + let response_len = u16::from_be_bytes(len_buf) as usize; + + let mut response_buf = vec![0u8; response_len]; + stream.read_exact(&mut response_buf).await?; + + let response: serde_json::Value = serde_json::from_slice(&response_buf)?; + + debug!("CDTunnel handshake response: {response:#?}"); + + let client_params = + response + .get("clientParameters") + .ok_or(IdeviceError::UnexpectedResponse( + "missing clientParameters in CDTunnel handshake response".into(), + ))?; + + let client_address = client_params + .get("address") + .and_then(|a| a.as_str()) + .ok_or(IdeviceError::UnexpectedResponse( + "missing client address in CDTunnel handshake response".into(), + ))? + .to_string(); + + let mtu = client_params + .get("mtu") + .and_then(|m| m.as_u64()) + .unwrap_or(1500) as u16; + + let server_address = response + .get("serverAddress") + .and_then(|a| a.as_str()) + .ok_or(IdeviceError::UnexpectedResponse( + "missing serverAddress in CDTunnel handshake response".into(), + ))? + .to_string(); + + let server_rsd_port = response + .get("serverRSDPort") + .and_then(|p| p.as_u64()) + .unwrap_or(0) as u16; + + let info = TunnelInfo { + client_address, + netmask: client_params + .get("netmask") + .and_then(|n| n.as_str()) + .unwrap_or("") + .to_string(), + server_address, + mtu, + server_rsd_port, + }; + + debug!("CDTunnel established: {info:?}"); + + Ok(Self { + inner: stream, + info, + }) + } + + /// Send a raw IPv6 packet to the device through the tunnel. + pub async fn send_packet(&mut self, packet: &[u8]) -> Result<(), IdeviceError> { + self.inner.write_all(packet).await?; + self.inner.flush().await?; + Ok(()) + } + + /// Receive a raw IPv6 packet from the device through the tunnel. + /// Returns the complete IPv6 packet (header + payload). + pub async fn recv_packet(&mut self) -> Result, IdeviceError> { + let mut header = [0u8; IPV6_HEADER_SIZE]; + self.inner.read_exact(&mut header).await?; + + let payload_len = u16::from_be_bytes([header[4], header[5]]) as usize; + + let mut payload = vec![0u8; payload_len]; + self.inner.read_exact(&mut payload).await?; + + let mut packet = Vec::with_capacity(IPV6_HEADER_SIZE + payload_len); + packet.extend_from_slice(&header); + packet.extend_from_slice(&payload); + + Ok(packet) + } + + /// Consume the tunnel and return the underlying stream. + pub fn into_inner(self) -> R { + self.inner + } +} diff --git a/src-tauri/vendor/idevice/src/tunneld.rs b/src-tauri/vendor/idevice/src/tunneld.rs new file mode 100644 index 0000000..eb77c6c --- /dev/null +++ b/src-tauri/vendor/idevice/src/tunneld.rs @@ -0,0 +1,110 @@ +//! Tunneld Client Implementation +//! +//! Provides functionality for interacting with pymobiledevice3's tunneld service, +//! which creates network tunnels to iOS devices over USB. + +use std::{collections::HashMap, net::SocketAddr}; + +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use tracing::warn; + +use crate::IdeviceError; + +/// Default port number for the tunneld service +pub const DEFAULT_PORT: u16 = 49151; + +/// Represents a device connected through tunneld +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct TunneldDevice { + /// Network interface name + pub interface: String, + /// Tunnel IP address + #[serde(rename = "tunnel-address")] + pub tunnel_address: String, + /// Tunnel port number + #[serde(rename = "tunnel-port")] + pub tunnel_port: u16, +} + +/// Retrieves all devices currently connected through tunneld +/// +/// # Arguments +/// * `socket` - Socket address of the tunneld service (typically localhost with DEFAULT_PORT) +/// +/// # Returns +/// A HashMap mapping device UDIDs to their tunnel information +/// +/// # Errors +/// Returns `IdeviceError` if: +/// - The HTTP request fails +/// - The response format is invalid +/// - JSON parsing fails +/// +/// # Example +/// ```rust +/// let host = SocketAddr::new(IpAddr::from_str("127.0.0.1").unwrap(), DEFAULT_PORT); +/// let devices = get_tunneld_devices(host).await?; +/// for (udid, device) in devices { +/// println!("Device {} is available at {}:{}", +/// udid, device.tunnel_address, device.tunnel_port); +/// } +/// ``` +pub async fn get_tunneld_devices( + socket: SocketAddr, +) -> Result, IdeviceError> { + // Make HTTP GET request to tunneld endpoint + let res: Value = reqwest::get(format!("http://{socket}")) + .await? + .json() + .await?; + + // Verify response is a JSON object + let res = match res.as_object() { + Some(r) => r, + None => { + warn!("tunneld return type wasn't a dictionary"); + return Err(IdeviceError::UnexpectedResponse( + "tunneld response was not a JSON object".into(), + )); + } + }; + + // Parse each device entry + let mut to_return = HashMap::new(); + for (udid, v) in res.into_iter() { + let mut v: Vec = match serde_json::from_value(v.clone()) { + Ok(v) => v, + Err(e) => { + warn!("Failed to parse tunneld results as vector of struct: {e:?}"); + continue; + } + }; + + if v.is_empty() { + warn!("Device had no entries"); + continue; + } + + to_return.insert(udid.clone(), v.remove(0)); + } + + Ok(to_return) +} + +#[cfg(test)] +mod tests { + use std::{net::IpAddr, str::FromStr}; + + use super::*; + + /// Test case for verifying tunneld device listing + #[tokio::test] + async fn test_get_tunneld_devices() { + let host = SocketAddr::new(IpAddr::from_str("127.0.0.1").unwrap(), DEFAULT_PORT); + match get_tunneld_devices(host).await { + Ok(devices) => println!("Found tunneld devices: {devices:#?}"), + Err(e) => println!("Error querying tunneld: {e}"), + } + } +} diff --git a/src-tauri/vendor/idevice/src/usbmuxd/des.rs b/src-tauri/vendor/idevice/src/usbmuxd/des.rs new file mode 100644 index 0000000..2a0ae9e --- /dev/null +++ b/src-tauri/vendor/idevice/src/usbmuxd/des.rs @@ -0,0 +1,152 @@ +// Jackson Coxson + +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; + +use serde::Deserialize; +use tracing::{debug, warn}; + +#[cfg(not(windows))] +use libc::{AF_INET, AF_INET6}; + +#[cfg(windows)] +const AF_INET: i32 = 2; +#[cfg(windows)] +const AF_INET6: i32 = 23; + +use crate::{ + IdeviceError, + usbmuxd::{Connection, UsbmuxdDevice}, +}; + +#[derive(Deserialize)] +pub struct ListDevicesResponse { + #[serde(rename = "DeviceList")] + pub device_list: Vec, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct DeviceListResponse { + #[serde(rename = "DeviceID")] + pub device_id: u32, + #[serde(rename = "Properties")] + pub properties: DevicePropertiesResponse, +} + +#[derive(Clone, Debug, Deserialize)] +pub struct DevicePropertiesResponse { + #[serde(rename = "ConnectionType")] + pub connection_type: String, + #[serde(rename = "NetworkAddress")] + pub network_address: Option, + #[serde(rename = "SerialNumber")] + pub serial_number: String, +} + +impl DeviceListResponse { + pub fn into_usbmuxd_dev(self) -> Result { + self.try_into() + } +} + +impl TryFrom for UsbmuxdDevice { + type Error = IdeviceError; + + fn try_from(dev: DeviceListResponse) -> Result { + let connection_type = match dev.properties.connection_type.as_str() { + "Network" => { + if let Some(addr) = dev.properties.network_address { + let addr = &Into::>::into(addr); + if addr.len() < 8 { + warn!("Device address bytes len < 8"); + return Err(IdeviceError::UnexpectedResponse( + "network address too short, expected at least 8 bytes".into(), + )); + } + + // macOS sets the first byte as the len, which are: + // 0x10 -> IPv4 + // 0x1C -> IPv6 + // + // either way, IPv4 is always 16 bytes and IPv6 is always 28 bytes + match addr.as_slice() { + // it's an IPv4 address, but the len is short + [family, ..] | [0x10, family, ..] + if *family == AF_INET as u8 && addr.len() < 0x10 => + { + warn!("IPv4 address is less than 16 bytes"); + return Err(IdeviceError::UnexpectedResponse( + "IPv4 network address too short, expected 16 bytes".into(), + )); + } + + [family, ..] | [0x10, family, ..] if *family == AF_INET as u8 => { + // IPv4 + Connection::Network(IpAddr::V4(Ipv4Addr::new( + addr[4], addr[5], addr[6], addr[7], + ))) + } + + [family, ..] | [0x1C, family, ..] + if *family == AF_INET6 as u8 && addr.len() < 28 => + { + warn!("IPv6 address is less than 28 bytes"); + return Err(IdeviceError::UnexpectedResponse( + "IPv6 network address too short, expected 28 bytes".into(), + )); + } + + [family, ..] | [0x1C, family, ..] if *family == AF_INET6 as u8 => { + // IPv6 + Connection::Network(IpAddr::V6(Ipv6Addr::new( + u16::from_be_bytes([addr[8], addr[9]]), + u16::from_be_bytes([addr[10], addr[11]]), + u16::from_be_bytes([addr[12], addr[13]]), + u16::from_be_bytes([addr[14], addr[15]]), + u16::from_be_bytes([addr[16], addr[17]]), + u16::from_be_bytes([addr[18], addr[19]]), + u16::from_be_bytes([addr[20], addr[21]]), + u16::from_be_bytes([addr[22], addr[23]]), + ))) + } + + // starts with IPv6 len, but it's not IPv6 + [0x1C, addr_family, ..] if *addr_family != AF_INET6 as u8 => { + warn!( + "Expected IPv6 family ({:02X}) but got {:02X} for length 0x1C", + AF_INET6, addr_family + ); + Connection::Unknown(format!("Network {:02X}", addr_family)) + } + + // starts with IPv4 len, but it's not IPv4 + [0x10, addr_family, ..] if *addr_family != AF_INET as u8 => { + warn!( + "Expected IPv4 family ({:02X}) but got {:02X} for length 0x10", + AF_INET, addr_family + ); + Connection::Unknown(format!("Network {:02X}", addr_family)) + } + + _ => { + warn!("Unknown IP address protocol: {:02X}", addr[0]); + Connection::Unknown(format!("Network {:02X}", addr[0])) + } + } + } else { + warn!("Device is network attached, but has no network info"); + return Err(IdeviceError::UnexpectedResponse( + "network device missing NetworkAddress field".into(), + )); + } + } + "USB" => Connection::Usb, + _ => Connection::Unknown(dev.properties.connection_type), + }; + debug!("Connection type: {connection_type:?}"); + Ok(UsbmuxdDevice { + connection_type, + udid: dev.properties.serial_number, + device_id: dev.device_id, + }) + } +} diff --git a/src-tauri/vendor/idevice/src/usbmuxd/errors.rs b/src-tauri/vendor/idevice/src/usbmuxd/errors.rs new file mode 100644 index 0000000..5b09744 --- /dev/null +++ b/src-tauri/vendor/idevice/src/usbmuxd/errors.rs @@ -0,0 +1,35 @@ +// Jackson Coxson + +/// Errors specific to the USB multiplexer daemon protocol +#[derive(thiserror::Error, Debug)] +#[non_exhaustive] +pub enum UsbmuxdError { + #[error("device refused connection")] + ConnectionRefused, + #[error("bad command")] + BadCommand, + #[error("bad device")] + BadDevice, + #[error("usb bad version")] + BadVersion, + #[error("request missing required field: {0}")] + MissingField(&'static str), + #[error("request field had an unexpected type: {0}")] + UnexpectedFieldType(&'static str), + #[error("unknown or unsupported MessageType: {0}")] + UnknownMessageType(String), +} + +impl UsbmuxdError { + pub fn sub_code(&self) -> i32 { + match self { + Self::ConnectionRefused => 1, + Self::BadCommand => 2, + Self::BadDevice => 3, + Self::BadVersion => 4, + Self::MissingField(_) => 5, + Self::UnexpectedFieldType(_) => 6, + Self::UnknownMessageType(_) => 7, + } + } +} diff --git a/src-tauri/vendor/idevice/src/usbmuxd/mod.rs b/src-tauri/vendor/idevice/src/usbmuxd/mod.rs new file mode 100644 index 0000000..50d3ac5 --- /dev/null +++ b/src-tauri/vendor/idevice/src/usbmuxd/mod.rs @@ -0,0 +1,500 @@ +//! USB Multiplexing Daemon (usbmuxd) Client +//! +//! Provides functionality for interacting with the usbmuxd service which manages +//! connections to iOS devices over USB and network and pairing files + +use std::{ + net::{AddrParseError, IpAddr, SocketAddr}, + pin::Pin, + str::FromStr, +}; + +#[cfg(not(unix))] +use std::net::{Ipv4Addr, SocketAddrV4}; + +use futures::Stream; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tracing::{debug, warn}; + +use crate::{ + Idevice, IdeviceError, ReadWrite, pairing_file::PairingFile, provider::UsbmuxdProvider, + usbmuxd::des::DeviceListResponse, +}; + +use errors::UsbmuxdError; + +mod des; +pub mod errors; +mod raw_packet; +pub mod server; + +pub use raw_packet::RawPacket; + +/// Represents the connection type of a device +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Connection { + /// Connected via USB + Usb, + /// Connected via network with specific IP address + Network(IpAddr), + /// Unknown connection type with description + Unknown(String), +} + +/// Represents a device connected through usbmuxd +#[derive(Debug, Clone)] +pub struct UsbmuxdDevice { + /// How the device is connected + pub connection_type: Connection, + /// Unique Device Identifier + pub udid: String, + /// usbmuxd-assigned device ID + pub device_id: u32, +} + +/// Listen events from the socket +#[derive(Debug, Clone)] +pub enum UsbmuxdListenEvent { + Connected(UsbmuxdDevice), + /// The mux ID + Disconnected(u32), +} + +/// Active connection to the usbmuxd service +#[derive(Debug)] +pub struct UsbmuxdConnection { + socket: Box, + tag: u32, +} + +/// Address of the usbmuxd service +#[allow(missing_copy_implementations)] +#[derive(Clone, Debug)] +pub enum UsbmuxdAddr { + /// Unix domain socket path (Unix systems only) + #[cfg(unix)] + UnixSocket(String), + /// TCP socket address + TcpSocket(SocketAddr), +} + +impl UsbmuxdAddr { + /// Default TCP port for usbmuxd + pub const DEFAULT_PORT: u16 = 27015; + /// Default Unix socket path for usbmuxd + pub const SOCKET_FILE: &'static str = "/var/run/usbmuxd"; + + /// Connects to the usbmuxd service + /// + /// # Returns + /// A boxed transport stream + /// + /// # Errors + /// Returns `IdeviceError` if connection fails + pub async fn to_socket(&self) -> Result, IdeviceError> { + Ok(match self { + #[cfg(unix)] + Self::UnixSocket(addr) => Box::new(tokio::net::UnixStream::connect(addr).await?), + Self::TcpSocket(addr) => Box::new(tokio::net::TcpStream::connect(addr).await?), + }) + } + + /// Creates a new usbmuxd connection + /// + /// # Arguments + /// * `tag` - Connection tag/identifier + /// + /// # Returns + /// A connected `UsbmuxdConnection` + pub async fn connect(&self, tag: u32) -> Result { + let socket = self.to_socket().await?; + Ok(UsbmuxdConnection::new(socket, tag)) + } + + /// Creates a UsbmuxdAddr from environment variable + /// + /// Checks `USBMUXD_SOCKET_ADDRESS` environment variable, falls back to default + /// + /// # Returns + /// Configured UsbmuxdAddr or parse error + pub fn from_env_var() -> Result { + Ok(match std::env::var("USBMUXD_SOCKET_ADDRESS") { + Ok(var) => { + #[cfg(unix)] + if var.contains(':') { + Self::TcpSocket(SocketAddr::from_str(&var)?) + } else { + Self::UnixSocket(var) + } + #[cfg(not(unix))] + Self::TcpSocket(SocketAddr::from_str(&var)?) + } + Err(_) => Self::default(), + }) + } +} + +impl Default for UsbmuxdAddr { + /// Creates default usbmuxd address based on platform: + /// - Unix: Uses default socket path + /// - Non-Unix: Uses localhost TCP port + fn default() -> Self { + #[cfg(not(unix))] + { + Self::TcpSocket(SocketAddr::V4(SocketAddrV4::new( + Ipv4Addr::new(127, 0, 0, 1), + Self::DEFAULT_PORT, + ))) + } + #[cfg(unix)] + Self::UnixSocket(Self::SOCKET_FILE.to_string()) + } +} + +impl UsbmuxdConnection { + /// Binary PLIST protocol version + pub const BINARY_PLIST_VERSION: u32 = 0; + /// XML PLIST protocol version + pub const XML_PLIST_VERSION: u32 = 1; + + /// Result message type + pub const RESULT_MESSAGE_TYPE: u32 = 1; + /// PLIST message type + pub const PLIST_MESSAGE_TYPE: u32 = 8; + + /// Creates a default usbmuxd connection + /// + /// Uses default address based on platform + /// + /// # Returns + /// Connected `UsbmuxdConnection` or error + pub async fn default() -> Result { + let socket = UsbmuxdAddr::default().to_socket().await?; + + Ok(Self { + socket: Box::new(socket), + tag: 0, + }) + } + + /// Creates a new usbmuxd connection + /// + /// # Arguments + /// * `socket` - The transport stream + /// * `tag` - Connection tag/identifier + pub fn new(socket: Box, tag: u32) -> Self { + Self { socket, tag } + } + + /// Lists all connected devices + /// + /// # Returns + /// Vector of connected devices + /// + /// # Errors + /// Returns `IdeviceError` if: + /// - Communication fails + /// - Response is malformed + /// - Device info is incomplete + pub async fn get_devices(&mut self) -> Result, IdeviceError> { + let mut req = plist::Dictionary::new(); + req.insert("MessageType".into(), "ListDevices".into()); + req.insert("ClientVersionString".into(), "idevice-rs".into()); + req.insert("kLibUSBMuxVersion".into(), 3.into()); + self.write_plist(req).await?; + let res = self.read_plist().await?; + let res = plist::to_value(&res)?; + let res = plist::from_value::(&res)?; + + let devs = res + .device_list + .into_iter() + .flat_map(|x| x.into_usbmuxd_dev()) + .collect::>(); + + Ok(devs) + } + + /// Gets a specific device by UDID + /// + /// # Arguments + /// * `udid` - The device UDID to find + /// + /// # Returns + /// The matching device or error if not found + pub async fn get_device(&mut self, udid: &str) -> Result { + let devices = self.get_devices().await?; + match devices.into_iter().find(|x| x.udid == udid) { + Some(d) => Ok(d), + None => Err(IdeviceError::DeviceNotFound), + } + } + + /// Gets the pairing record for a device + /// + /// # Arguments + /// * `udid` - The device UDID + /// + /// # Returns + /// The pairing file or error + pub async fn get_pair_record(&mut self, udid: &str) -> Result { + debug!("Getting pair record for {udid}"); + let mut req = plist::Dictionary::new(); + req.insert("MessageType".into(), "ReadPairRecord".into()); + req.insert("PairRecordID".into(), udid.into()); + self.write_plist(req).await?; + let res = self.read_plist().await?; + + match res.get("PairRecordData") { + Some(plist::Value::Data(d)) => PairingFile::from_bytes(d), + _ => Err(IdeviceError::UnexpectedResponse( + "missing PairRecordData in pair record response".into(), + )), + } + } + + /// Gets the BUID + /// + /// # Returns + /// The BUID string or error + pub async fn get_buid(&mut self) -> Result { + let mut req = plist::Dictionary::new(); + req.insert("MessageType".into(), "ReadBUID".into()); + self.write_plist(req).await?; + let mut res = self.read_plist().await?; + + match res.remove("BUID") { + Some(plist::Value::String(s)) => Ok(s), + _ => Err(IdeviceError::UnexpectedResponse( + "missing BUID string in ReadBUID response".into(), + )), + } + } + + /// Connects to a service on the device + /// + /// # Arguments + /// * `device_id` - usbmuxd device ID + /// * `port` - TCP port to connect to (host byte order) + /// * `label` - Connection label + /// + /// # Returns + /// An `Idevice` connection or error + pub async fn connect_to_device( + mut self, + device_id: u32, + port: u16, + label: impl Into, + ) -> Result { + debug!("Connecting to device {device_id} on port {port}"); + let port = port.to_be(); + + let mut req = plist::Dictionary::new(); + req.insert("MessageType".into(), "Connect".into()); + req.insert("DeviceID".into(), device_id.into()); + req.insert("PortNumber".into(), port.into()); + self.write_plist(req).await?; + match self.read_plist().await?.get("Number") { + Some(plist::Value::Integer(i)) => match i.as_unsigned() { + Some(0) => Ok(Idevice::new(self.socket, label)), + Some(1) => Err(UsbmuxdError::BadCommand.into()), + Some(2) => Err(UsbmuxdError::BadDevice.into()), + Some(3) => Err(UsbmuxdError::ConnectionRefused.into()), + Some(6) => Err(UsbmuxdError::BadVersion.into()), + _ => Err(IdeviceError::UnexpectedResponse( + "unknown usbmuxd connect error code".into(), + )), + }, + _ => Err(IdeviceError::UnexpectedResponse( + "missing Number field in usbmuxd connect response".into(), + )), + } + } + + /// Tells usbmuxd to save the pairing record in its storage + /// + /// # Arguments + /// * `device_id` - usbmuxd device ID + /// * `udid` - the device UDID/serial + /// * `pair_record` - a serialized plist of the pair record + pub async fn save_pair_record( + &mut self, + udid: &str, + pair_record: Vec, + ) -> Result<(), IdeviceError> { + let req = crate::plist!(dict { + "MessageType": "SavePairRecord", + "PairRecordData": pair_record, + "PairRecordID": udid, + }); + self.write_plist(req).await?; + let res = self.read_plist().await?; + match res.get("Number").and_then(|x| x.as_unsigned_integer()) { + Some(0) => Ok(()), + _ => Err(IdeviceError::UnexpectedResponse( + "SavePairRecord did not return success".into(), + )), + } + } + + /// Tells usbmuxd to delete the pairing record from its storage + /// + /// # Arguments + /// * `udid` - the device UDID/serial (`PairRecordID`) + pub async fn delete_pair_record(&mut self, udid: &str) -> Result<(), IdeviceError> { + let req = crate::plist!(dict { + "MessageType": "DeletePairRecord", + "PairRecordID": udid, + }); + self.write_plist(req).await?; + let res = self.read_plist().await?; + match res.get("Number").and_then(|x| x.as_unsigned_integer()) { + Some(0) => Ok(()), + _ => Err(IdeviceError::UnexpectedResponse( + "DeletePairRecord did not return success".into(), + )), + } + } + + pub async fn listen<'a>( + &'a mut self, + ) -> Result< + Pin> + 'a>>, + IdeviceError, + > { + let req = crate::plist!(dict { + "MessageType": "Listen", + }); + self.write_plist(req).await?; + + // First, read the handshake response to confirm the "Listen" request was successful + let res = self.read_plist().await?; + match res.get("Number").and_then(|x| x.as_unsigned_integer()) { + Some(0) => { + // Success, now create the stream + let stream = futures::stream::try_unfold(self, |conn| async move { + // This loop is to skip non-Attach/Detach messages + loop { + // Read the next packet. This will propagate IO errors. + let msg = conn.read_plist().await?; + + if let Some(plist::Value::String(s)) = msg.get("MessageType") { + match s.as_str() { + "Attached" => { + if let Ok(props) = plist::from_value::( + &plist::Value::Dictionary(msg), + ) { + let dev: UsbmuxdDevice = match props.into_usbmuxd_dev() { + Ok(d) => d, + Err(e) => { + warn!( + "Failed to convert props into usbmuxd device: {e:?}" + ); + continue; + } + }; + + let res = UsbmuxdListenEvent::Connected(dev); + + // Yield the device and the next state + return Ok(Some((res, conn))); + } else { + warn!( + "Received malformed message during listen (no device props and ID)" + ); + } + } + "Detached" => { + // Log it and continue the loop to wait for the next message + if let Some(id) = + msg.get("DeviceID").and_then(|v| v.as_unsigned_integer()) + { + let res = UsbmuxdListenEvent::Disconnected(id as u32); + return Ok(Some((res, conn))); + } else { + debug!("Device detached (unknown ID)"); + } + // Continue loop + } + _ => { + // Unexpected message type, log and continue + warn!("Received unexpected message type during listen: {}", s); + // Continue loop + } + } + } else { + // Malformed message, log and continue + warn!("Received malformed message during listen (no MessageType)"); + // Continue loop + } + } + }); + + // Box and Pin the stream + Ok(Box::pin(stream)) + } + _ => { + // "Listen" request failed + Err(IdeviceError::UnexpectedResponse( + "usbmuxd Listen request did not return success".into(), + )) + } + } + } + + /// Writes a PLIST message to usbmuxd + async fn write_plist(&mut self, req: plist::Dictionary) -> Result<(), IdeviceError> { + let raw = raw_packet::RawPacket::new( + req, + Self::XML_PLIST_VERSION, + Self::PLIST_MESSAGE_TYPE, + self.tag, + ); + + let raw: Vec = raw.into(); + self.socket.write_all(&raw).await?; + + Ok(()) + } + + /// Reads a PLIST message from usbmuxd + async fn read_plist(&mut self) -> Result { + let mut header_buffer = [0; 16]; + self.socket.read_exact(&mut header_buffer).await?; + + // We are safe to unwrap as it only panics if the buffer isn't 4 + let packet_size = u32::from_le_bytes(header_buffer[..4].try_into().unwrap()) - 16; + debug!("Reading {packet_size} bytes from muxer"); + + let mut body_buffer = vec![0; packet_size as usize]; + self.socket.read_exact(&mut body_buffer).await?; + + let res = plist::from_bytes(&body_buffer)?; + debug!("Read from muxer: {}", crate::pretty_print_dictionary(&res)); + + Ok(res) + } +} + +impl UsbmuxdDevice { + /// Creates a provider for this device + /// + /// # Arguments + /// * `addr` - usbmuxd address + /// * `tag` - Connection tag + /// * `label` - Connection label + /// + /// # Returns + /// Configured `UsbmuxdProvider` + pub fn to_provider(&self, addr: UsbmuxdAddr, label: impl Into) -> UsbmuxdProvider { + let label = label.into(); + + UsbmuxdProvider { + addr, + tag: self.device_id, + udid: self.udid.clone(), + device_id: self.device_id, + label, + } + } +} diff --git a/src-tauri/vendor/idevice/src/usbmuxd/raw_packet.rs b/src-tauri/vendor/idevice/src/usbmuxd/raw_packet.rs new file mode 100644 index 0000000..7bc85a4 --- /dev/null +++ b/src-tauri/vendor/idevice/src/usbmuxd/raw_packet.rs @@ -0,0 +1,116 @@ +// Jackson Coxson + +use plist_macro::plist_to_xml_bytes; +use tracing::warn; + +#[derive(Debug)] +pub struct RawPacket { + pub size: u32, + pub version: u32, + pub message: u32, + pub tag: u32, + pub plist: plist::Dictionary, +} + +impl RawPacket { + pub fn new(plist: plist::Dictionary, version: u32, message: u32, tag: u32) -> RawPacket { + let plist_bytes = plist_to_xml_bytes(&plist); + let size = plist_bytes.len() as u32 + 16; + RawPacket { + size, + version, + message, + tag, + plist, + } + } +} + +impl From for Vec { + fn from(raw_packet: RawPacket) -> Vec { + let mut packet = vec![]; + packet.extend_from_slice(&raw_packet.size.to_le_bytes()); + packet.extend_from_slice(&raw_packet.version.to_le_bytes()); + packet.extend_from_slice(&raw_packet.message.to_le_bytes()); + packet.extend_from_slice(&raw_packet.tag.to_le_bytes()); + packet.extend_from_slice(&plist_to_xml_bytes(&raw_packet.plist)); + packet + } +} + +impl TryFrom<&mut Vec> for RawPacket { + type Error = (); + fn try_from(packet: &mut Vec) -> Result { + let packet: &[u8] = packet; + packet.try_into() + } +} + +impl TryFrom<&[u8]> for RawPacket { + type Error = (); + fn try_from(packet: &[u8]) -> Result { + // Determine if we have enough data to parse + if packet.len() < 16 { + warn!("Not enough data to parse a raw packet header"); + return Err(()); + } + + let packet_size = &packet[0..4]; + let packet_size = u32::from_le_bytes(match packet_size.try_into() { + Ok(packet_size) => packet_size, + Err(_) => { + warn!("Failed to parse packet size"); + return Err(()); + } + }); + + // Determine if we have enough data to parse + if packet.len() < packet_size as usize { + warn!("Not enough data to parse a raw packet body"); + return Err(()); + } + + let packet_version = &packet[4..8]; + let packet_version = u32::from_le_bytes(match packet_version.try_into() { + Ok(packet_version) => packet_version, + Err(_) => { + warn!("Failed to parse packet version"); + return Err(()); + } + }); + + let message = &packet[8..12]; + let message = u32::from_le_bytes(match message.try_into() { + Ok(message) => message, + Err(_) => { + warn!("Failed to parse packet message"); + return Err(()); + } + }); + + let packet_tag = &packet[12..16]; + let packet_tag = u32::from_le_bytes(match packet_tag.try_into() { + Ok(packet_tag) => packet_tag, + Err(_) => { + warn!("Failed to parse packet tag"); + return Err(()); + } + }); + + let plist = &packet[16..packet_size as usize]; + let plist = if let Ok(p) = plist::from_bytes(plist) { + p + } else { + warn!("Failed to parse packet plist"); + return Err(()); + }; + + Ok(RawPacket { + size: packet_size, + version: packet_version, + message, + tag: packet_tag, + plist, + }) + } +} diff --git a/src-tauri/vendor/idevice/src/usbmuxd/server.rs b/src-tauri/vendor/idevice/src/usbmuxd/server.rs new file mode 100644 index 0000000..7e8dd91 --- /dev/null +++ b/src-tauri/vendor/idevice/src/usbmuxd/server.rs @@ -0,0 +1,184 @@ +//! Server-side usbmuxd protocol messages. +//! +//! Only the standard usbmuxd message types are modeled here. Unknown +//! `MessageType`s decode to [`UsbmuxdError::UnknownMessageType`] so a +//! consumer can layer its own extensions on top. + +use super::{RawPacket, UsbmuxdConnection, errors::UsbmuxdError}; +use crate::IdeviceError; + +/// A request sent by a usbmuxd client to the muxer. +#[derive(Debug, Clone)] +pub enum UsbmuxdServerRequest { + /// List every attached device. + ListDevices, + /// Subscribe to attach/detach events for the life of the connection. + Listen, + /// Read this host's BUID. + ReadBuid, + /// Read a stored pairing record by UDID. + ReadPairRecord { + /// `PairRecordID` — the device UDID. + pair_record_id: String, + }, + /// Persist a pairing record. + SavePairRecord { + /// `PairRecordID` if the client supplied one. + pair_record_id: Option, + /// `DeviceID` fallback used when `PairRecordID` is absent. + device_id: Option, + /// Raw serialized pairing record (`PairRecordData`). + pair_record_data: Vec, + }, + /// Open a connection to a port on a device. + Connect { + /// usbmuxd-assigned `DeviceID`. + device_id: u64, + /// Target port in host byte order. The wire carries `PortNumber` in + /// network byte order; this value is already converted back. + port: u16, + }, +} + +impl UsbmuxdServerRequest { + /// Decodes a client request from a received packet. + /// + /// A non-standard `MessageType` surfaces as + /// [`UsbmuxdError::UnknownMessageType`] (wrapped in [`IdeviceError`]). + pub fn decode(packet: &RawPacket) -> Result { + let plist = &packet.plist; + let message_type = match plist.get("MessageType") { + Some(plist::Value::String(s)) => s.as_str(), + Some(_) => return Err(UsbmuxdError::UnexpectedFieldType("MessageType").into()), + None => return Err(UsbmuxdError::MissingField("MessageType").into()), + }; + + Ok(match message_type { + "ListDevices" => Self::ListDevices, + "Listen" => Self::Listen, + "ReadBUID" => Self::ReadBuid, + "ReadPairRecord" => Self::ReadPairRecord { + pair_record_id: get_string(plist, "PairRecordID")?, + }, + "SavePairRecord" => { + let pair_record_data = match plist.get("PairRecordData") { + Some(plist::Value::Data(d)) => d.clone(), + Some(_) => { + return Err(UsbmuxdError::UnexpectedFieldType("PairRecordData").into()); + } + None => return Err(UsbmuxdError::MissingField("PairRecordData").into()), + }; + let pair_record_id = match plist.get("PairRecordID") { + Some(plist::Value::String(s)) => Some(s.clone()), + _ => None, + }; + let device_id = match plist.get("DeviceID") { + Some(plist::Value::Integer(i)) => i.as_unsigned(), + _ => None, + }; + Self::SavePairRecord { + pair_record_id, + device_id, + pair_record_data, + } + } + "Connect" => Self::Connect { + device_id: get_unsigned(plist, "DeviceID")?, + port: get_port(plist)?, + }, + other => return Err(UsbmuxdError::UnknownMessageType(other.to_string()).into()), + }) + } +} + +/// A response (or broadcast event) the muxer sends back to a client. +#[derive(Debug, Clone)] +pub enum UsbmuxdServerResponse { + /// `{ MessageType: Result, Number: n }`. `0` is success. + Result(u32), + /// `{ DeviceList: [...] }` - reply to [`UsbmuxdServerRequest::ListDevices`]. + DeviceList(Vec), + /// `{ PairRecordData: }` - reply to a `ReadPairRecord`. + PairRecord(Vec), + /// `{ BUID: }` - reply to a `ReadBUID`. + Buid(String), + /// An `Attached` broadcast. The dictionary is the full Attached payload + /// (`DeviceID`, `MessageType`, `Properties`). + Attached(plist::Dictionary), + /// A `Detached` broadcast for the given `DeviceID`. + Detached(u64), +} + +impl UsbmuxdServerResponse { + /// Builds the plist body for this response. + pub fn into_dictionary(self) -> plist::Dictionary { + match self { + Self::Result(number) => crate::plist!(dict { + "MessageType": "Result", + "Number": number, + }), + Self::DeviceList(devices) => crate::plist!(dict { + "DeviceList": devices, + }), + Self::PairRecord(data) => crate::plist!(dict { + "PairRecordData": plist::Value::Data(data), + }), + Self::Buid(buid) => crate::plist!(dict { + "BUID": buid, + }), + Self::Attached(payload) => payload, + Self::Detached(device_id) => crate::plist!(dict { + "MessageType": "Detached", + "DeviceID": device_id, + }), + } + } + + /// Wraps this response in a [`RawPacket`] ready to write to the client. + pub fn into_packet(self, tag: u32) -> RawPacket { + RawPacket::new( + self.into_dictionary(), + UsbmuxdConnection::XML_PLIST_VERSION, + UsbmuxdConnection::PLIST_MESSAGE_TYPE, + tag, + ) + } +} + +fn get_string(plist: &plist::Dictionary, key: &'static str) -> Result { + match plist.get(key) { + Some(plist::Value::String(s)) => Ok(s.clone()), + Some(_) => Err(UsbmuxdError::UnexpectedFieldType(key)), + None => Err(UsbmuxdError::MissingField(key)), + } +} + +fn get_unsigned(plist: &plist::Dictionary, key: &'static str) -> Result { + match plist.get(key) { + Some(plist::Value::Integer(i)) => i + .as_unsigned() + .ok_or(UsbmuxdError::UnexpectedFieldType(key)), + Some(_) => Err(UsbmuxdError::UnexpectedFieldType(key)), + None => Err(UsbmuxdError::MissingField(key)), + } +} + +/// Reads `PortNumber` and converts it from the wire's network byte order to +/// host order. The client encodes the port with `to_be()`, so we undo that +/// here. `PortNumber` may arrive as either a signed or unsigned plist integer. +fn get_port(plist: &plist::Dictionary) -> Result { + let raw = match plist.get("PortNumber") { + Some(plist::Value::Integer(i)) => { + if let Some(u) = i.as_unsigned() { + u as u16 + } else if let Some(s) = i.as_signed() { + s as u16 + } else { + return Err(UsbmuxdError::UnexpectedFieldType("PortNumber")); + } + } + Some(_) => return Err(UsbmuxdError::UnexpectedFieldType("PortNumber")), + None => return Err(UsbmuxdError::MissingField("PortNumber")), + }; + Ok(raw.to_be()) +} diff --git a/src-tauri/vendor/idevice/src/utils/installation/helpers.rs b/src-tauri/vendor/idevice/src/utils/installation/helpers.rs new file mode 100644 index 0000000..1c15065 --- /dev/null +++ b/src-tauri/vendor/idevice/src/utils/installation/helpers.rs @@ -0,0 +1,404 @@ +use async_zip::base::read::seek::ZipFileReader; +use futures::AsyncReadExt as _; +use plist_macro::plist; +use std::{io::Cursor, path::Path}; +use tokio::io::{AsyncBufRead, AsyncSeek, BufReader}; + +use crate::{ + IdeviceError, IdeviceService, + afc::{AfcClient, opcode::AfcFopenMode}, + installation_proxy::InstallationProxyError, + provider::IdeviceProvider, +}; + +#[cfg(feature = "rsd")] +use crate::{RsdService, provider::RsdProvider, rsd}; + +pub const PUBLIC_STAGING: &str = "PublicStaging"; + +pub const IPCC_REMOTE_FILE: &str = "idevice.ipcc"; + +pub const IPA_REMOTE_FILE: &str = "idevice.ipa"; + +/// Result of a prepared upload, containing the remote path to use in Install/Upgrade +pub struct InstallPackage { + /// Path inside the AFC jail for InstallationProxy `PackagePath` + pub remote_package_path: String, + + // Each package type has a special option that has to be passed + pub options: plist::Value, +} + +/// Represent the type of package being installed. +pub enum PackageType { + Ipcc, // Carrier bundle package + // an IPA package needs the build id to be installed + Ipa(String), // iOS app package + Unknown, +} + +impl PackageType { + pub fn get_remote_file(&self) -> Result<&'static str, IdeviceError> { + match self { + Self::Ipcc => Ok(IPCC_REMOTE_FILE), + Self::Ipa(_) => Ok(IPA_REMOTE_FILE), + Self::Unknown => { + Err(InstallationProxyError::OperationFailed("invalid package".into()).into()) + } + } + } +} + +/// Ensure `PublicStaging` exists on device via AFC +pub async fn ensure_public_staging(afc: &mut AfcClient) -> Result<(), IdeviceError> { + // Try to stat and if it fails, create directory + match afc.get_file_info(PUBLIC_STAGING).await { + Ok(_) => Ok(()), + Err(_) => afc.mk_dir(PUBLIC_STAGING).await, + } +} + +// Get the bundle id of a package by looping through it's files and looking inside of the +// `Info.plist` +pub async fn get_bundle_id(file: &mut T) -> Result +where + T: AsyncBufRead + AsyncSeek + Unpin, +{ + let mut zip_file = ZipFileReader::with_tokio(file) + .await + .map_err(InstallationProxyError::from)?; + + for i in 0..zip_file.file().entries().len() { + let mut entry_reader = zip_file + .reader_with_entry(i) + .await + .map_err(InstallationProxyError::from)?; + let entry = entry_reader.entry(); + + let inner_file_path = entry + .filename() + .as_str() + .map_err(|_| IdeviceError::Utf8Error)? + .trim_end_matches('/'); + + let path_segments_count = inner_file_path.split('/').count(); + + // there's multiple `Info.plist` files, we only need the one that's in the root of the + // package + // + // 1 2 3 + // which is in this case: Playload -> APP_NAME.app -> Info.plist + if inner_file_path.ends_with("Info.plist") && path_segments_count == 3 { + let mut info_plist_bytes = Vec::new(); + entry_reader.read_to_end(&mut info_plist_bytes).await?; + + let info_plist: plist::Value = plist::from_bytes(&info_plist_bytes)?; + + if let Some(bundle_id) = info_plist + .as_dictionary() + .and_then(|dict| dict.get("CFBundleIdentifier")) + .and_then(|v| v.as_string()) + { + return Ok(bundle_id.to_string()); + } + } + } + + Err(IdeviceError::NotFound) +} + +/// Determines the type of package based on its content (IPA or IPCC). +pub async fn determine_package_type>( + package: &P, +) -> Result { + let mut package_cursor = BufReader::new(Cursor::new(package.as_ref())); + + // Zip entry order isn't guaranteed and directory entries are optional, so we can't + // trust any fixed index to point at `Payload/.{app,bundle}/`. Scan the central + // directory for the first entry under `Payload/` whose top segment carries the + // expected extension. + let folder_name = { + let archive = ZipFileReader::with_tokio(&mut package_cursor) + .await + .map_err(InstallationProxyError::from)?; + + let mut found: Option = None; + for entry in archive.file().entries() { + let path = entry + .filename() + .as_str() + .map_err(|_| IdeviceError::Utf8Error)?; + + let Some(rest) = path.strip_prefix("Payload/") else { + continue; + }; + let Some(segment) = rest.split('/').next().filter(|s| !s.is_empty()) else { + continue; + }; + if segment.ends_with(".app") || segment.ends_with(".bundle") { + found = Some(segment.to_string()); + break; + } + } + found + }; + + let Some(folder_name) = folder_name else { + return Ok(PackageType::Unknown); + }; + + if folder_name.ends_with(".bundle") { + Ok(PackageType::Ipcc) + } else if folder_name.ends_with(".app") { + let bundle_id = get_bundle_id(&mut package_cursor).await?; + Ok(PackageType::Ipa(bundle_id)) + } else { + Ok(PackageType::Unknown) + } +} + +/// Upload a single file to a destination path on device using AFC +pub async fn afc_upload_file>( + afc: &mut AfcClient, + file: F, + remote_path: &str, +) -> Result<(), IdeviceError> { + let mut fd = afc.open(remote_path, AfcFopenMode::WrOnly).await?; + fd.write_entire(file.as_ref()).await?; + fd.close().await +} + +/// Recursively upload a directory to device via AFC (mirror contents) +pub async fn afc_upload_dir( + afc: &mut AfcClient, + local_dir: &Path, + remote_dir: &str, +) -> Result<(), IdeviceError> { + use std::collections::VecDeque; + afc.mk_dir(remote_dir).await.ok(); + + let mut queue: VecDeque<(std::path::PathBuf, String)> = VecDeque::new(); + queue.push_back((local_dir.to_path_buf(), remote_dir.to_string())); + + while let Some((cur_local, cur_remote)) = queue.pop_front() { + let mut rd = tokio::fs::read_dir(&cur_local).await?; + while let Some(entry) = rd.next_entry().await? { + let meta = entry.metadata().await?; + let name = entry.file_name(); + let name = name.to_string_lossy().into_owned(); + if name == "." || name == ".." { + continue; + } + let child_local = entry.path(); + let child_remote = format!("{cur_remote}/{name}"); + if meta.is_dir() { + afc.mk_dir(&child_remote).await.ok(); + queue.push_back((child_local, child_remote)); + } else if meta.is_file() { + afc_upload_file(afc, tokio::fs::read(&child_local).await?, &child_remote).await?; + } + } + } + Ok(()) +} + +/// Upload a file to `PublicStaging` and return its InstallationProxy path +async fn upload_file_to_public_staging>( + provider: &dyn IdeviceProvider, + file: P, +) -> Result { + // Connect to AFC via the generic service connector + let mut afc = AfcClient::connect(provider).await?; + + ensure_public_staging(&mut afc).await?; + + let file = file.as_ref(); + + let package_type = determine_package_type(&file).await?; + + let remote_path = format!("{PUBLIC_STAGING}/{}", package_type.get_remote_file()?); + + afc_upload_file(&mut afc, file, &remote_path).await?; + + let options = match package_type { + PackageType::Ipcc => plist!({"PackageType": "CarrierBundle"}), + PackageType::Ipa(build_id) => plist!({"CFBundleIdentifier": build_id}), + PackageType::Unknown => plist!({}), + }; + + Ok(InstallPackage { + remote_package_path: remote_path, + options, + }) +} + +/// Upload a file to `PublicStaging` over RSD and return its InstallationProxy path +#[cfg(feature = "rsd")] +async fn upload_file_to_public_staging_rsd>( + provider: &mut impl RsdProvider, + handshake: &mut rsd::RsdHandshake, + file: P, +) -> Result { + let mut afc = AfcClient::connect_rsd(provider, handshake).await?; + + ensure_public_staging(&mut afc).await?; + + let file = file.as_ref(); + + let package_type = determine_package_type(&file).await?; + + let remote_path = format!("{PUBLIC_STAGING}/{}", package_type.get_remote_file()?); + + afc_upload_file(&mut afc, file, &remote_path).await?; + + let options = match package_type { + PackageType::Ipcc => plist!({"PackageType": "CarrierBundle"}), + PackageType::Ipa(build_id) => plist!({"CFBundleIdentifier": build_id}), + PackageType::Unknown => plist!({}), + }; + + Ok(InstallPackage { + remote_package_path: remote_path, + options, + }) +} + +/// Recursively Upload a directory of file to `PublicStaging` +async fn upload_dir_to_public_staging>( + provider: &dyn IdeviceProvider, + file: P, +) -> Result { + let mut afc = AfcClient::connect(provider).await?; + + ensure_public_staging(&mut afc).await?; + + let file = file.as_ref(); + let remote_folder_name = file + .iter() + .next_back() + .map(|x| x.to_string_lossy().to_string()) + .unwrap_or(IPA_REMOTE_FILE.to_string()); + + let remote_path = format!("{PUBLIC_STAGING}/{remote_folder_name}"); + + afc_upload_dir(&mut afc, file, &remote_path).await?; + + Ok(InstallPackage { + remote_package_path: remote_path, + options: plist!({"PackageType": "Developer"}), + }) +} + +/// Recursively upload a directory to `PublicStaging` over RSD. +#[cfg(feature = "rsd")] +async fn upload_dir_to_public_staging_rsd>( + provider: &mut impl RsdProvider, + handshake: &mut rsd::RsdHandshake, + file: P, +) -> Result { + let mut afc = AfcClient::connect_rsd(provider, handshake).await?; + + ensure_public_staging(&mut afc).await?; + + let file = file.as_ref(); + let remote_folder_name = file + .iter() + .next_back() + .map(|x| x.to_string_lossy().to_string()) + .unwrap_or(IPA_REMOTE_FILE.to_string()); + + let remote_path = format!("{PUBLIC_STAGING}/{remote_folder_name}"); + + afc_upload_dir(&mut afc, file, &remote_path).await?; + + Ok(InstallPackage { + remote_package_path: remote_path, + options: plist!({"PackageType": "Developer"}), + }) +} + +pub async fn prepare_file_upload( + provider: &dyn IdeviceProvider, + data: impl AsRef<[u8]>, + caller_options: Option, +) -> Result { + let InstallPackage { + remote_package_path, + options, + } = upload_file_to_public_staging(provider, data).await?; + let full_options = plist!({ + :, + caller_options: Option, +) -> Result { + let InstallPackage { + remote_package_path, + options, + } = upload_file_to_public_staging_rsd(provider, handshake, data).await?; + let full_options = plist!({ + :, + caller_options: Option, +) -> Result { + let InstallPackage { + remote_package_path, + options, + } = upload_dir_to_public_staging(provider, &local_path).await?; + + let full_options = plist!({ + :, + caller_options: Option, +) -> Result { + let InstallPackage { + remote_package_path, + options, + } = upload_dir_to_public_staging_rsd(provider, handshake, &local_path).await?; + + let full_options = plist!({ + :`) +//! - For `.ipa` files, we upload the whole file to `PublicStaging/` +//! - For directories (developer bundles), we recursively mirror the directory +//! into `PublicStaging/` and pass that directory path. + +mod helpers; +use std::path::Path; + +use helpers::{InstallPackage, prepare_dir_upload, prepare_file_upload}; + +use crate::{ + IdeviceError, IdeviceService, provider::IdeviceProvider, + services::installation_proxy::InstallationProxyClient, +}; + +#[cfg(feature = "rsd")] +use crate::{RsdService, provider::RsdProvider, rsd}; +#[cfg(feature = "rsd")] +use helpers::{prepare_dir_upload_rsd, prepare_file_upload_rsd}; + +/// Install an application by first uploading the local package and then invoking InstallationProxy. +/// +/// - Accepts a local file path or directory path. +/// - `options` is an InstallationProxy ClientOptions dictionary; pass `None` for defaults. +pub async fn install_package>( + provider: &dyn IdeviceProvider, + local_path: P, + options: Option, +) -> Result<(), IdeviceError> { + install_package_with_callback(provider, local_path, options, |_| async {}, ()).await +} + +/// Same as `install_package` but using the RSD transport. +#[cfg(feature = "rsd")] +pub async fn install_package_rsd>( + provider: &mut impl RsdProvider, + handshake: &mut rsd::RsdHandshake, + local_path: P, + options: Option, +) -> Result<(), IdeviceError> { + install_package_with_callback_rsd(provider, handshake, local_path, options, |_| async {}, ()) + .await +} + +/// Same as `install_package` but providing a callback that receives `(percent_complete, state)` +/// updates while InstallationProxy performs the operation. +pub async fn install_package_with_callback, Fut, S>( + provider: &dyn IdeviceProvider, + local_path: P, + options: Option, + callback: impl Fn((u64, S)) -> Fut, + state: S, +) -> Result<(), IdeviceError> +where + Fut: std::future::Future, + S: Clone, +{ + let metadata = tokio::fs::metadata(&local_path).await?; + + if metadata.is_dir() { + let InstallPackage { + remote_package_path, + options, + } = prepare_dir_upload(provider, local_path, options).await?; + let mut inst = InstallationProxyClient::connect(provider).await?; + + inst.upgrade_with_callback(remote_package_path, Some(options), callback, state) + .await + } else { + let data = tokio::fs::read(&local_path).await?; + install_bytes_with_callback(provider, data, options, callback, state).await + } +} + +/// Same as `install_package` but providing a callback that receives `(percent_complete, state)` +/// updates while InstallationProxy performs the operation. +#[cfg(feature = "rsd")] +pub async fn install_package_with_callback_rsd, Fut, S>( + provider: &mut impl RsdProvider, + handshake: &mut rsd::RsdHandshake, + local_path: P, + options: Option, + callback: impl Fn((u64, S)) -> Fut, + state: S, +) -> Result<(), IdeviceError> +where + Fut: std::future::Future, + S: Clone, +{ + let metadata = tokio::fs::metadata(&local_path).await?; + + if metadata.is_dir() { + let InstallPackage { + remote_package_path, + options, + } = prepare_dir_upload_rsd(provider, handshake, local_path, options).await?; + let mut inst = InstallationProxyClient::connect_rsd(provider, handshake).await?; + + inst.upgrade_with_callback(remote_package_path, Some(options), callback, state) + .await + } else { + let data = tokio::fs::read(&local_path).await?; + install_bytes_with_callback_rsd(provider, handshake, data, options, callback, state).await + } +} + +/// Upgrade an application by first uploading the local package and then invoking InstallationProxy. +/// +/// - Accepts a local file path or directory path. +/// - `options` is an InstallationProxy ClientOptions dictionary; pass `None` for defaults. +pub async fn upgrade_package>( + provider: &dyn IdeviceProvider, + local_path: P, + options: Option, +) -> Result<(), IdeviceError> { + upgrade_package_with_callback(provider, local_path, options, |_| async {}, ()).await +} + +/// Same as `upgrade_package` but using the RSD transport. +#[cfg(feature = "rsd")] +pub async fn upgrade_package_rsd>( + provider: &mut impl RsdProvider, + handshake: &mut rsd::RsdHandshake, + local_path: P, + options: Option, +) -> Result<(), IdeviceError> { + upgrade_package_with_callback_rsd(provider, handshake, local_path, options, |_| async {}, ()) + .await +} + +/// Same as `upgrade_package` but providing a callback that receives `(percent_complete, state)` +/// updates while InstallationProxy performs the operation. +pub async fn upgrade_package_with_callback, Fut, S>( + provider: &dyn IdeviceProvider, + local_path: P, + options: Option, + callback: impl Fn((u64, S)) -> Fut, + state: S, +) -> Result<(), IdeviceError> +where + Fut: std::future::Future, + S: Clone, +{ + let metadata = tokio::fs::metadata(&local_path).await?; + + if metadata.is_dir() { + let InstallPackage { + remote_package_path, + options, + } = prepare_dir_upload(provider, local_path, options).await?; + let mut inst = InstallationProxyClient::connect(provider).await?; + + inst.upgrade_with_callback(remote_package_path, Some(options), callback, state) + .await + } else { + let data = tokio::fs::read(&local_path).await?; + upgrade_bytes_with_callback(provider, data, options, callback, state).await + } +} + +/// Same as `upgrade_package_with_callback` but using the RSD transport. +#[cfg(feature = "rsd")] +pub async fn upgrade_package_with_callback_rsd, Fut, S>( + provider: &mut impl RsdProvider, + handshake: &mut rsd::RsdHandshake, + local_path: P, + options: Option, + callback: impl Fn((u64, S)) -> Fut, + state: S, +) -> Result<(), IdeviceError> +where + Fut: std::future::Future, + S: Clone, +{ + let metadata = tokio::fs::metadata(&local_path).await?; + + if metadata.is_dir() { + let InstallPackage { + remote_package_path, + options, + } = prepare_dir_upload_rsd(provider, handshake, local_path, options).await?; + let mut inst = InstallationProxyClient::connect_rsd(provider, handshake).await?; + + inst.upgrade_with_callback(remote_package_path, Some(options), callback, state) + .await + } else { + let data = tokio::fs::read(&local_path).await?; + upgrade_bytes_with_callback_rsd(provider, handshake, data, options, callback, state).await + } +} + +/// Install an application from raw bytes by first uploading them to `PublicStaging` and then +/// invoking InstallationProxy `Install`. +/// +/// - `remote_name` determines the remote filename under `PublicStaging`. +/// - `options` is an InstallationProxy ClientOptions dictionary; pass `None` for defaults. +pub async fn install_bytes( + provider: &dyn IdeviceProvider, + data: impl AsRef<[u8]>, + options: Option, +) -> Result<(), IdeviceError> { + install_bytes_with_callback(provider, data, options, |_| async {}, ()).await +} + +/// Same as `install_bytes` but using the RSD transport. +#[cfg(feature = "rsd")] +pub async fn install_bytes_rsd( + provider: &mut impl RsdProvider, + handshake: &mut rsd::RsdHandshake, + data: impl AsRef<[u8]>, + options: Option, +) -> Result<(), IdeviceError> { + install_bytes_with_callback_rsd(provider, handshake, data, options, |_| async {}, ()).await +} + +/// Same as `install_bytes` but providing a callback that receives `(percent_complete, state)` +/// updates while InstallationProxy performs the install operation. +/// +/// Tip: +/// - When embedding assets into the binary, you can pass `include_bytes!("path/to/app.ipa")` +/// as the `data` argument and choose a desired `remote_name` (e.g. `"MyApp.ipa"`). +pub async fn install_bytes_with_callback( + provider: &dyn IdeviceProvider, + data: impl AsRef<[u8]>, + options: Option, + callback: impl Fn((u64, S)) -> Fut, + state: S, +) -> Result<(), IdeviceError> +where + Fut: std::future::Future, + S: Clone, +{ + let InstallPackage { + remote_package_path, + options, + } = prepare_file_upload(provider, data, options).await?; + let mut inst = InstallationProxyClient::connect(provider).await?; + + inst.install_with_callback(remote_package_path, Some(options), callback, state) + .await +} + +/// Same as `install_bytes_with_callback` but using the RSD transport. +#[cfg(feature = "rsd")] +pub async fn install_bytes_with_callback_rsd( + provider: &mut impl RsdProvider, + handshake: &mut rsd::RsdHandshake, + data: impl AsRef<[u8]>, + options: Option, + callback: impl Fn((u64, S)) -> Fut, + state: S, +) -> Result<(), IdeviceError> +where + Fut: std::future::Future, + S: Clone, +{ + let InstallPackage { + remote_package_path, + options, + } = prepare_file_upload_rsd(provider, handshake, data, options).await?; + let mut inst = InstallationProxyClient::connect_rsd(provider, handshake).await?; + + inst.install_with_callback(remote_package_path, Some(options), callback, state) + .await +} + +/// Upgrade an application from raw bytes by first uploading them to `PublicStaging` and then +/// invoking InstallationProxy `Upgrade`. +/// +/// - `remote_name` determines the remote filename under `PublicStaging`. +/// - `options` is an InstallationProxy ClientOptions dictionary; pass `None` for defaults. +pub async fn upgrade_bytes( + provider: &dyn IdeviceProvider, + data: impl AsRef<[u8]>, + options: Option, +) -> Result<(), IdeviceError> { + upgrade_bytes_with_callback(provider, data, options, |_| async {}, ()).await +} + +/// Same as `upgrade_bytes` but using the RSD transport. +#[cfg(feature = "rsd")] +pub async fn upgrade_bytes_rsd( + provider: &mut impl RsdProvider, + handshake: &mut rsd::RsdHandshake, + data: impl AsRef<[u8]>, + options: Option, +) -> Result<(), IdeviceError> { + upgrade_bytes_with_callback_rsd(provider, handshake, data, options, |_| async {}, ()).await +} + +/// Same as `upgrade_bytes` but providing a callback that receives `(percent_complete, state)` +/// updates while InstallationProxy performs the upgrade operation. +/// +/// Tip: +/// - When embedding assets into the binary, you can pass `include_bytes!("path/to/app.ipa")` +/// as the `data` argument and choose a desired `remote_name` (e.g. `"MyApp.ipa"`). +pub async fn upgrade_bytes_with_callback( + provider: &dyn IdeviceProvider, + data: impl AsRef<[u8]>, + options: Option, + callback: impl Fn((u64, S)) -> Fut, + state: S, +) -> Result<(), IdeviceError> +where + Fut: std::future::Future, + S: Clone, +{ + let InstallPackage { + remote_package_path, + options, + } = prepare_file_upload(provider, data, options).await?; + let mut inst = InstallationProxyClient::connect(provider).await?; + + inst.upgrade_with_callback(remote_package_path, Some(options), callback, state) + .await +} + +/// Same as `upgrade_bytes_with_callback` but using the RSD transport. +#[cfg(feature = "rsd")] +pub async fn upgrade_bytes_with_callback_rsd( + provider: &mut impl RsdProvider, + handshake: &mut rsd::RsdHandshake, + data: impl AsRef<[u8]>, + options: Option, + callback: impl Fn((u64, S)) -> Fut, + state: S, +) -> Result<(), IdeviceError> +where + Fut: std::future::Future, + S: Clone, +{ + let InstallPackage { + remote_package_path, + options, + } = prepare_file_upload_rsd(provider, handshake, data, options).await?; + let mut inst = InstallationProxyClient::connect_rsd(provider, handshake).await?; + + inst.upgrade_with_callback(remote_package_path, Some(options), callback, state) + .await +} diff --git a/src-tauri/vendor/idevice/src/utils/mod.rs b/src-tauri/vendor/idevice/src/utils/mod.rs new file mode 100644 index 0000000..c2b01af --- /dev/null +++ b/src-tauri/vendor/idevice/src/utils/mod.rs @@ -0,0 +1,10 @@ +// Utility modules for higher-level operations built on top of services + +#[cfg(all( + feature = "afc", + feature = "installation_proxy", + not(target_arch = "wasm32") +))] +pub mod installation; + +pub mod plist; diff --git a/src-tauri/vendor/idevice/src/utils/plist.rs b/src-tauri/vendor/idevice/src/utils/plist.rs new file mode 100644 index 0000000..fb763ac --- /dev/null +++ b/src-tauri/vendor/idevice/src/utils/plist.rs @@ -0,0 +1,155 @@ +/// Utilities for working with plist values +/// +/// Truncates all Date values in a plist structure to second precision. +/// +/// This function recursively walks through a plist Value and truncates any Date values +/// from nanosecond precision to second precision. This is necessary for compatibility +/// with iOS devices that reject high-precision date formats. +/// +/// # Arguments +/// * `value` - The plist Value to normalize (modified in place) +/// +/// # Example +/// ```rust,no_run +/// use idevice::utils::plist::truncate_dates_to_seconds; +/// use plist::Value; +/// +/// let mut icon_state = Value::Array(vec![]); +/// truncate_dates_to_seconds(&mut icon_state); +/// ``` +/// +/// # Details +/// - Converts dates from format: `2026-01-17T03:09:58.332738876Z` (nanosecond precision) +/// - To format: `2026-01-17T03:09:58Z` (second precision) +/// - Recursively processes Arrays and Dictionaries +/// - Other value types are left unchanged +pub fn truncate_dates_to_seconds(value: &mut plist::Value) { + match value { + plist::Value::Date(date) => { + let xml_string = date.to_xml_format(); + if let Some(dot_pos) = xml_string.find('.') + && xml_string[dot_pos..].contains('Z') + { + let truncated_string = format!("{}Z", &xml_string[..dot_pos]); + if let Ok(new_date) = plist::Date::from_xml_format(&truncated_string) { + *date = new_date; + } + } + } + plist::Value::Array(arr) => { + for item in arr.iter_mut() { + truncate_dates_to_seconds(item); + } + } + plist::Value::Dictionary(dict) => { + for (_, v) in dict.iter_mut() { + truncate_dates_to_seconds(v); + } + } + _ => {} + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_truncate_date_with_nanoseconds() { + let date_str = "2026-01-17T03:09:58.332738876Z"; + let date = plist::Date::from_xml_format(date_str).unwrap(); + let mut value = plist::Value::Date(date); + + truncate_dates_to_seconds(&mut value); + + if let plist::Value::Date(truncated_date) = value { + let result = truncated_date.to_xml_format(); + assert!( + !result.contains('.'), + "Date should not contain fractional seconds" + ); + assert!(result.ends_with('Z'), "Date should end with Z"); + assert!( + result.starts_with("2026-01-17T03:09:58"), + "Date should preserve main timestamp" + ); + } else { + panic!("Value should still be a Date"); + } + } + + #[test] + fn test_truncate_date_already_truncated() { + let date_str = "2026-01-17T03:09:58Z"; + let date = plist::Date::from_xml_format(date_str).unwrap(); + let original_format = date.to_xml_format(); + let mut value = plist::Value::Date(date); + + truncate_dates_to_seconds(&mut value); + + if let plist::Value::Date(truncated_date) = value { + let result = truncated_date.to_xml_format(); + assert_eq!( + result, original_format, + "Already truncated date should remain unchanged" + ); + } + } + + #[test] + fn test_truncate_dates_in_array() { + let date1 = plist::Date::from_xml_format("2026-01-17T03:09:58.123456Z").unwrap(); + let date2 = plist::Date::from_xml_format("2026-01-18T04:10:59.987654Z").unwrap(); + let mut value = + plist::Value::Array(vec![plist::Value::Date(date1), plist::Value::Date(date2)]); + + truncate_dates_to_seconds(&mut value); + + if let plist::Value::Array(arr) = value { + for item in arr { + if let plist::Value::Date(date) = item { + let formatted = date.to_xml_format(); + assert!( + !formatted.contains('.'), + "Dates in array should be truncated" + ); + } + } + } + } + + #[test] + fn test_truncate_dates_in_dictionary() { + let date = plist::Date::from_xml_format("2026-01-17T03:09:58.999999Z").unwrap(); + let mut dict = plist::Dictionary::new(); + dict.insert("timestamp".to_string(), plist::Value::Date(date)); + let mut value = plist::Value::Dictionary(dict); + + truncate_dates_to_seconds(&mut value); + + if let plist::Value::Dictionary(dict) = value + && let Some(plist::Value::Date(date)) = dict.get("timestamp") + { + let formatted = date.to_xml_format(); + assert!( + !formatted.contains('.'), + "Date in dictionary should be truncated" + ); + } + } + + #[test] + fn test_other_value_types_unchanged() { + let mut string_val = plist::Value::String("test".to_string()); + let mut int_val = plist::Value::Integer(42.into()); + let mut bool_val = plist::Value::Boolean(true); + + truncate_dates_to_seconds(&mut string_val); + truncate_dates_to_seconds(&mut int_val); + truncate_dates_to_seconds(&mut bool_val); + + assert!(matches!(string_val, plist::Value::String(_))); + assert!(matches!(int_val, plist::Value::Integer(_))); + assert!(matches!(bool_val, plist::Value::Boolean(_))); + } +} diff --git a/src-tauri/vendor/idevice/src/xpc/errors.rs b/src-tauri/vendor/idevice/src/xpc/errors.rs new file mode 100644 index 0000000..698bac0 --- /dev/null +++ b/src-tauri/vendor/idevice/src/xpc/errors.rs @@ -0,0 +1,44 @@ +// Jackson Coxson + +/// Errors specific to the XPC/HTTP2 protocol layer +#[derive(thiserror::Error, Debug)] +#[non_exhaustive] +pub enum XpcError { + #[error("unknown http frame type: {0}")] + UnknownFrame(u8), + #[error("unknown http setting type: {0}")] + UnknownHttpSetting(u16), + #[error("uninitialized stream ID")] + UninitializedStreamId, + #[error("unknown XPC type: {0}")] + UnknownXpcType(u32), + #[error("malformed XPC message")] + MalformedXpc, + #[error("invalid XPC magic")] + InvalidXpcMagic, + #[error("unexpected XPC version")] + UnexpectedXpcVersion, + #[error("invalid C string")] + InvalidCString, + #[error("stream reset")] + HttpStreamReset, + #[error("go away packet received: {0}")] + HttpGoAway(String), +} + +impl XpcError { + pub fn sub_code(&self) -> i32 { + match self { + Self::UnknownFrame(_) => 1, + Self::UnknownHttpSetting(_) => 2, + Self::UninitializedStreamId => 3, + Self::UnknownXpcType(_) => 4, + Self::MalformedXpc => 5, + Self::InvalidXpcMagic => 6, + Self::UnexpectedXpcVersion => 7, + Self::InvalidCString => 8, + Self::HttpStreamReset => 9, + Self::HttpGoAway(_) => 10, + } + } +} diff --git a/src-tauri/vendor/idevice/src/xpc/format.rs b/src-tauri/vendor/idevice/src/xpc/format.rs new file mode 100644 index 0000000..ec47cde --- /dev/null +++ b/src-tauri/vendor/idevice/src/xpc/format.rs @@ -0,0 +1,594 @@ +use plist_macro::plist; +use std::{ + ffi::CString, + io::{BufRead, Cursor, Read}, + ops::{BitOr, BitOrAssign}, +}; + +use indexmap::IndexMap; +use serde::{Deserialize, Serialize}; +use tracing::{debug, warn}; + +use super::errors::XpcError; +use crate::{CdTunnelError, IdeviceError}; + +#[derive(Clone, Copy, Debug)] +#[repr(u32)] +pub enum XPCFlag { + AlwaysSet, + DataFlag, + Reply, + WantingReply, + InitHandshake, + + FileTxStreamRequest, + FileTxStreamResponse, + + Custom(u32), +} + +impl From for u32 { + fn from(value: XPCFlag) -> Self { + match value { + XPCFlag::AlwaysSet => 0x00000001, + XPCFlag::DataFlag => 0x00000100, + XPCFlag::Reply => 0x00020000, + XPCFlag::WantingReply => 0x00010000, + XPCFlag::InitHandshake => 0x00400000, + XPCFlag::FileTxStreamRequest => 0x00100000, + XPCFlag::FileTxStreamResponse => 0x00200000, + XPCFlag::Custom(inner) => inner, + } + } +} + +impl BitOr for XPCFlag { + fn bitor(self, rhs: Self) -> Self::Output { + XPCFlag::Custom(u32::from(self) | u32::from(rhs)) + } + + type Output = XPCFlag; +} + +impl BitOrAssign for XPCFlag { + fn bitor_assign(&mut self, rhs: Self) { + *self = self.bitor(rhs); + } +} + +impl PartialEq for XPCFlag { + fn eq(&self, other: &Self) -> bool { + u32::from(*self) == u32::from(*other) + } +} + +#[repr(u32)] +pub enum XPCType { + Null = 0x00001000, + Bool = 0x00002000, + Dictionary = 0x0000f000, + Array = 0x0000e000, + + Int64 = 0x00003000, + UInt64 = 0x00004000, + Double = 0x00005000, + + Date = 0x00007000, + + String = 0x00009000, + Data = 0x00008000, + Uuid = 0x0000a000, + FileTransfer = 0x0001a000, +} + +impl TryFrom for XPCType { + type Error = IdeviceError; + + fn try_from(value: u32) -> Result { + match value { + 0x00001000 => Ok(Self::Null), + 0x00002000 => Ok(Self::Bool), + 0x0000f000 => Ok(Self::Dictionary), + 0x0000e000 => Ok(Self::Array), + 0x00003000 => Ok(Self::Int64), + 0x00005000 => Ok(Self::Double), + 0x00004000 => Ok(Self::UInt64), + 0x00007000 => Ok(Self::Date), + 0x00009000 => Ok(Self::String), + 0x00008000 => Ok(Self::Data), + 0x0000a000 => Ok(Self::Uuid), + 0x0001a000 => Ok(Self::FileTransfer), + _ => Err(XpcError::UnknownXpcType(value))?, + } + } +} + +pub type Dictionary = IndexMap; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub enum XPCObject { + Null, + Bool(bool), + Dictionary(Dictionary), + Array(Vec), + + Double(f64), + Int64(i64), + UInt64(u64), + + Date(std::time::SystemTime), + + String(String), + Data(Vec), + Uuid(uuid::Uuid), + + FileTransfer { msg_id: u64, data: Box }, +} + +impl From for XPCObject { + fn from(value: plist::Value) -> Self { + match value { + plist::Value::Array(v) => { + XPCObject::Array(v.iter().map(|item| XPCObject::from(item.clone())).collect()) + } + plist::Value::Dictionary(v) => { + let mut dict = Dictionary::new(); + for (k, v) in v.into_iter() { + dict.insert(k.clone(), XPCObject::from(v)); + } + XPCObject::Dictionary(dict) + } + plist::Value::Boolean(v) => XPCObject::Bool(v), + plist::Value::Data(v) => XPCObject::Data(v), + plist::Value::Date(_) => todo!(), + plist::Value::Real(f) => XPCObject::Double(f), + plist::Value::Integer(v) => XPCObject::Int64(v.as_signed().unwrap()), + plist::Value::String(v) => XPCObject::String(v), + plist::Value::Uid(_) => todo!(), + _ => todo!(), + } + } +} + +impl XPCObject { + pub fn to_plist(&self) -> plist::Value { + match self { + Self::Null => plist::Value::String("".into()), + Self::Bool(v) => plist::Value::Boolean(*v), + Self::Uuid(uuid) => plist::Value::String(uuid.to_string()), + Self::Double(f) => plist::Value::Real(*f), + Self::UInt64(v) => plist::Value::Integer({ *v }.into()), + Self::Int64(v) => plist::Value::Integer({ *v }.into()), + Self::Date(d) => plist::Value::Date(plist::Date::from(*d)), + Self::String(v) => plist::Value::String(v.clone()), + Self::Data(v) => plist::Value::Data(v.clone()), + Self::Array(v) => plist::Value::Array(v.iter().map(|item| item.to_plist()).collect()), + Self::Dictionary(v) => { + let mut dict = plist::Dictionary::new(); + for (k, v) in v.into_iter() { + dict.insert(k.clone(), v.to_plist()); + } + plist::Value::Dictionary(dict) + } + Self::FileTransfer { msg_id, data } => { + plist!({ + "msg_id": *msg_id, + "data": data.to_plist(), + }) + } + } + } + + pub fn encode(&self) -> Result, IdeviceError> { + let mut buf = Vec::new(); + buf.extend_from_slice(&0x42133742_u32.to_le_bytes()); + buf.extend_from_slice(&0x00000005_u32.to_le_bytes()); + self.encode_object(&mut buf)?; + Ok(buf) + } + + fn encode_object(&self, buf: &mut Vec) -> Result<(), IdeviceError> { + match self { + XPCObject::Null => buf.extend_from_slice(&(XPCType::Null as u32).to_le_bytes()), + XPCObject::Bool(val) => { + buf.extend_from_slice(&(XPCType::Bool as u32).to_le_bytes()); + buf.push(if *val { 1 } else { 0 }); + buf.extend_from_slice(&[0].repeat(3)); + } + XPCObject::Dictionary(dict) => { + buf.extend_from_slice(&(XPCType::Dictionary as u32).to_le_bytes()); + let mut content_buf = Vec::new(); + content_buf.extend_from_slice(&(dict.len() as u32).to_le_bytes()); + for (k, v) in dict { + let padding = Self::calculate_padding(k.len() + 1); + content_buf.extend_from_slice(k.as_bytes()); + content_buf.push(0); + content_buf.extend_from_slice(&[0].repeat(padding)); + v.encode_object(&mut content_buf)?; + } + buf.extend_from_slice(&(content_buf.len() as u32).to_le_bytes()); + buf.extend_from_slice(&content_buf); + } + XPCObject::Array(items) => { + buf.extend_from_slice(&(XPCType::Array as u32).to_le_bytes()); + let mut content_buf = Vec::new(); + content_buf.extend_from_slice(&(items.len() as u32).to_le_bytes()); + for item in items { + item.encode_object(&mut content_buf)?; + } + buf.extend_from_slice(&(content_buf.len() as u32).to_le_bytes()); + buf.extend_from_slice(&content_buf); + } + + XPCObject::Double(f) => { + buf.extend_from_slice(&(XPCType::Double as u32).to_le_bytes()); + buf.extend_from_slice(&f.to_le_bytes()); + } + XPCObject::Int64(num) => { + buf.extend_from_slice(&(XPCType::Int64 as u32).to_le_bytes()); + buf.extend_from_slice(&num.to_le_bytes()); + } + XPCObject::UInt64(num) => { + buf.extend_from_slice(&(XPCType::UInt64 as u32).to_le_bytes()); + buf.extend_from_slice(&num.to_le_bytes()); + } + XPCObject::Date(date) => { + buf.extend_from_slice(&(XPCType::Date as u32).to_le_bytes()); + buf.extend_from_slice( + &(date + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() as u64) + .to_le_bytes(), + ); + } + XPCObject::String(item) => { + let l = item.len() + 1; + let padding = Self::calculate_padding(l); + buf.extend_from_slice(&(XPCType::String as u32).to_le_bytes()); + buf.extend_from_slice(&(l as u32).to_le_bytes()); + buf.extend_from_slice(item.as_bytes()); + buf.push(0); + buf.extend_from_slice(&[0].repeat(padding)); + } + XPCObject::Data(data) => { + let l = data.len(); + let padding = Self::calculate_padding(l); + buf.extend_from_slice(&(XPCType::Data as u32).to_le_bytes()); + buf.extend_from_slice(&(l as u32).to_le_bytes()); + buf.extend_from_slice(data); + buf.extend_from_slice(&[0].repeat(padding)); + } + XPCObject::Uuid(uuid) => { + buf.extend_from_slice(&(XPCType::Uuid as u32).to_le_bytes()); + buf.extend_from_slice(uuid.as_bytes()); + } + XPCObject::FileTransfer { msg_id, data } => { + buf.extend_from_slice(&(XPCType::FileTransfer as u32).to_le_bytes()); + buf.extend_from_slice(&msg_id.to_le_bytes()); + data.encode_object(buf)?; + } + } + Ok(()) + } + + pub fn decode(buf: &[u8]) -> Result { + if buf.len() < 8 { + return Err(IdeviceError::NotEnoughBytes(buf.len(), 8)); + } + let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]); + if magic != 0x42133742 { + warn!("Invalid magic for XPCObject"); + return Err(XpcError::InvalidXpcMagic.into()); + } + + let version = u32::from_le_bytes([buf[4], buf[5], buf[6], buf[7]]); + if version != 0x00000005 { + warn!("Unexpected version for XPCObject"); + return Err(XpcError::UnexpectedXpcVersion.into()); + } + + Self::decode_object(&mut Cursor::new(&buf[8..])) + } + + fn decode_object(mut cursor: &mut Cursor<&[u8]>) -> Result { + let mut buf_32: [u8; 4] = Default::default(); + cursor.read_exact(&mut buf_32)?; + let xpc_type = u32::from_le_bytes(buf_32); + let xpc_type: XPCType = xpc_type.try_into()?; + match xpc_type { + XPCType::Null => Ok(XPCObject::Null), + XPCType::Dictionary => { + let mut ret = IndexMap::new(); + + cursor.read_exact(&mut buf_32)?; + let _l = u32::from_le_bytes(buf_32); + cursor.read_exact(&mut buf_32)?; + let num_entries = u32::from_le_bytes(buf_32); + for _ in 0..num_entries { + let mut key_buf = Vec::new(); + BufRead::read_until(&mut cursor, 0, &mut key_buf)?; + let key = match CString::from_vec_with_nul(key_buf) + .ok() + .and_then(|x| x.to_str().ok().map(|x| x.to_string())) + { + Some(k) => k, + None => { + return Err(XpcError::InvalidCString.into()); + } + }; + let padding = Self::calculate_padding(key.len() + 1); + + BufRead::consume(&mut cursor, padding); + ret.insert(key, Self::decode_object(cursor)?); + } + Ok(XPCObject::Dictionary(ret)) + } + XPCType::Array => { + cursor.read_exact(&mut buf_32)?; + let _l = u32::from_le_bytes(buf_32); + cursor.read_exact(&mut buf_32)?; + let num_entries = u32::from_le_bytes(buf_32); + + let mut ret = Vec::new(); + for _i in 0..num_entries { + ret.push(Self::decode_object(cursor)?); + } + Ok(XPCObject::Array(ret)) + } + XPCType::Double => { + let mut buf: [u8; 8] = Default::default(); + cursor.read_exact(&mut buf)?; + Ok(XPCObject::Double(f64::from_le_bytes(buf))) + } + XPCType::Int64 => { + let mut buf: [u8; 8] = Default::default(); + cursor.read_exact(&mut buf)?; + Ok(XPCObject::Int64(i64::from_le_bytes(buf))) + } + XPCType::UInt64 => { + let mut buf: [u8; 8] = Default::default(); + cursor.read_exact(&mut buf)?; + Ok(XPCObject::UInt64(u64::from_le_bytes(buf))) + } + + XPCType::Date => { + let mut buf: [u8; 8] = Default::default(); + cursor.read_exact(&mut buf)?; + Ok(XPCObject::Date( + std::time::UNIX_EPOCH + + std::time::Duration::from_nanos(u64::from_le_bytes(buf)), + )) + } + + XPCType::String => { + // 'l' includes utf8 '\0' character. + cursor.read_exact(&mut buf_32)?; + let l = u32::from_le_bytes(buf_32) as usize; + let padding = Self::calculate_padding(l); + + let mut key_buf = vec![0; l]; + cursor.read_exact(&mut key_buf)?; + let key = match CString::from_vec_with_nul(key_buf) + .ok() + .and_then(|x| x.to_str().ok().map(|x| x.to_string())) + { + Some(k) => k, + None => return Err(XpcError::InvalidCString.into()), + }; + BufRead::consume(&mut cursor, padding); + Ok(XPCObject::String(key)) + } + XPCType::Bool => { + let mut buf: [u8; 4] = Default::default(); + cursor.read_exact(&mut buf)?; + Ok(XPCObject::Bool(buf[0] != 0)) + } + XPCType::Data => { + cursor.read_exact(&mut buf_32)?; + let l = u32::from_le_bytes(buf_32) as usize; + let padding = Self::calculate_padding(l); + + let mut data = vec![0; l]; + cursor.read_exact(&mut data)?; + BufRead::consume(&mut cursor, padding); + Ok(XPCObject::Data(data)) + } + XPCType::Uuid => { + let mut data: [u8; 16] = Default::default(); + cursor.read_exact(&mut data)?; + Ok(XPCObject::Uuid(uuid::Builder::from_bytes(data).into_uuid())) + } + XPCType::FileTransfer => { + let mut id_buf = [0u8; 8]; + cursor.read_exact(&mut id_buf)?; + let msg_id = u64::from_le_bytes(id_buf); + + // The next thing in the stream is a full XPC object + let inner = Self::decode_object(cursor)?; + Ok(XPCObject::FileTransfer { + msg_id, + data: Box::new(inner), + }) + } + } + } + + pub fn as_dictionary(&self) -> Option<&Dictionary> { + match self { + XPCObject::Dictionary(dict) => Some(dict), + _ => None, + } + } + + pub fn to_dictionary(self) -> Option { + match self { + XPCObject::Dictionary(dict) => Some(dict), + _ => None, + } + } + + pub fn as_array(&self) -> Option<&Vec> { + match self { + XPCObject::Array(array) => Some(array), + _ => None, + } + } + + pub fn as_string(&self) -> Option<&str> { + match self { + XPCObject::String(s) => Some(s), + _ => None, + } + } + + pub fn as_bool(&self) -> Option<&bool> { + match self { + XPCObject::Bool(b) => Some(b), + _ => None, + } + } + + pub fn as_signed_integer(&self) -> Option { + match self { + XPCObject::String(s) => s.parse().ok(), + XPCObject::Int64(v) => Some(*v), + _ => None, + } + } + + pub fn as_unsigned_integer(&self) -> Option { + match self { + XPCObject::String(s) => s.parse().ok(), + XPCObject::UInt64(v) => Some(*v), + _ => None, + } + } + + fn calculate_padding(len: usize) -> usize { + let c = ((len as f64) / 4.0).ceil(); + (c * 4.0 - (len as f64)) as usize + } +} + +impl From for XPCObject { + fn from(value: Dictionary) -> Self { + XPCObject::Dictionary(value) + } +} + +pub struct XPCMessage { + pub flags: u32, + pub message: Option, + pub message_id: Option, +} + +impl XPCMessage { + pub fn new( + flags: Option, + message: Option, + message_id: Option, + ) -> XPCMessage { + XPCMessage { + flags: flags.unwrap_or(XPCFlag::AlwaysSet).into(), + message, + message_id, + } + } + + pub fn decode(data: &[u8]) -> Result { + if data.len() < 24 { + Err(IdeviceError::NotEnoughBytes(data.len(), 24))? + } + + let magic = u32::from_le_bytes([data[0], data[1], data[2], data[3]]); + if magic != 0x29b00b92_u32 { + warn!("XPCMessage magic is invalid."); + Err(XpcError::MalformedXpc)? + } + + let flags = u32::from_le_bytes([data[4], data[5], data[6], data[7]]); + let body_len = u64::from_le_bytes([ + data[8], data[9], data[10], data[11], data[12], data[13], data[14], data[15], + ]); + debug!("Body_len: {body_len}"); + let message_id = u64::from_le_bytes([ + data[16], data[17], data[18], data[19], data[20], data[21], data[22], data[23], + ]); + if body_len + 24 > data.len() as u64 { + warn!( + "Body length is {body_len}, but received bytes is {}", + data.len() + ); + Err(CdTunnelError::SizeMismatch)? + } + + let res = XPCMessage { + flags, + message: if body_len > 0 { + Some(XPCObject::decode(&data[24..24 + body_len as usize])?) + } else { + None + }, + message_id: Some(message_id), + }; + + debug!("Decoded {res:#?}"); + Ok(res) + } + + pub fn encode(self, message_id: u64) -> Result, IdeviceError> { + let mut out = 0x29b00b92_u32.to_le_bytes().to_vec(); + out.extend_from_slice(&self.flags.to_le_bytes()); + match self.message { + Some(message) => { + let body = message.encode()?; + out.extend_from_slice(&(body.len() as u64).to_le_bytes()); // body length + out.extend_from_slice(&message_id.to_le_bytes()); // messageId + out.extend_from_slice(&body); + } + _ => { + out.extend_from_slice(&0_u64.to_le_bytes()); + out.extend_from_slice(&message_id.to_le_bytes()); + } + } + Ok(out) + } +} + +impl std::fmt::Debug for XPCMessage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut parts = Vec::new(); + + if self.flags & 0x00000001 != 0 { + parts.push("AlwaysSet".to_string()); + } + if self.flags & 0x00000100 != 0 { + parts.push("DataFlag".to_string()); + } + if self.flags & 0x00010000 != 0 { + parts.push("WantingReply".to_string()); + } + if self.flags & 0x00020000 != 0 { + parts.push("Reply".to_string()); + } + if self.flags & 0x00400000 != 0 { + parts.push("InitHandshake".to_string()); + } + + // Check for any unknown bits (not covered by known flags) + let known_mask = 0x00000001 | 0x00000100 | 0x00010000 | 0x00020000 | 0x00400000; + let custom_bits = self.flags & !known_mask; + if custom_bits != 0 { + parts.push(format!("Custom(0x{custom_bits:08X})")); + } + + write!( + f, + "XPCMessage {{ flags: [{}], message_id: {:?}, message: {:?} }}", + parts.join(" | "), + self.message_id, + self.message + ) + } +} diff --git a/src-tauri/vendor/idevice/src/xpc/http2/frame.rs b/src-tauri/vendor/idevice/src/xpc/http2/frame.rs new file mode 100644 index 0000000..d9b5153 --- /dev/null +++ b/src-tauri/vendor/idevice/src/xpc/http2/frame.rs @@ -0,0 +1,198 @@ +// Jackson Coxson + +use crate::IdeviceError; +use crate::xpc::errors::XpcError; + +/// Fixed HTTP/2 frame header size: 3-byte length, 1-byte type, 1-byte flags, +/// 4-byte stream id. +const FRAME_HEADER_LEN: usize = 9; + +pub trait HttpFrame { + fn serialize(&self) -> Vec; +} + +#[derive(Debug)] +#[allow(dead_code)] // we don't care about frames from the device +pub enum Frame { + Settings(SettingsFrame), + WindowUpdate(WindowUpdateFrame), + Headers(HeadersFrame), + Data(DataFrame), +} + +impl Frame { + /// Parse a single frame from the front of `buf`. + /// + /// Returns `Ok(None)` when `buf` does not yet hold a complete frame (the + /// caller should read more bytes and retry). On success returns the frame + /// and how many bytes it consumed, so the caller can drain exactly that much + /// — this keeps frame reassembly cancellation-safe: a partially-received + /// frame stays buffered until it is whole. RST_STREAM and GOAWAY surface as + /// errors (they consume no bytes; the connection is finished either way). + pub fn parse(buf: &[u8]) -> Result, IdeviceError> { + if buf.len() < FRAME_HEADER_LEN { + return Ok(None); + } + let frame_len = u32::from_be_bytes([0x00, buf[0], buf[1], buf[2]]) as usize; + let frame_type = buf[3]; + let flags = buf[4]; + let stream_id = u32::from_be_bytes([buf[5], buf[6], buf[7], buf[8]]); + + let total = FRAME_HEADER_LEN + frame_len; + if buf.len() < total { + return Ok(None); + } + let body = &buf[FRAME_HEADER_LEN..total]; + + let frame = match frame_type { + 0x00 => Self::Data(DataFrame { + stream_id, + payload: body.to_vec(), + }), + 0x01 => Self::Headers(HeadersFrame { stream_id }), + 0x03 => return Err(XpcError::HttpStreamReset.into()), + 0x04 => { + // settings: a sequence of (u16 identifier, u32 value) entries + let mut settings = Vec::new(); + let mut i = 0; + while i + 6 <= body.len() { + let setting_type = u16::from_be_bytes([body[i], body[i + 1]]); + let value = + u32::from_be_bytes([body[i + 2], body[i + 3], body[i + 4], body[i + 5]]); + settings.push(match setting_type { + 0x03 => Setting::MaxConcurrentStreams(value), + 0x04 => Setting::InitialWindowSize(value), + _ => return Err(XpcError::UnknownHttpSetting(setting_type).into()), + }); + i += 6; + } + Self::Settings(SettingsFrame { + settings, + stream_id, + flags, + }) + } + 0x07 => { + let msg = if body.len() < 8 { + "".to_string() + } else { + String::from_utf8_lossy(&body[8..]).to_string() + }; + return Err(XpcError::HttpGoAway(msg).into()); + } + 0x08 => { + if body.len() != 4 { + return Err(IdeviceError::UnexpectedResponse( + "HTTP/2 window update frame body was not 4 bytes".into(), + )); + } + let window = u32::from_be_bytes([body[0], body[1], body[2], body[3]]); + Self::WindowUpdate(WindowUpdateFrame { + increment_size: window, + stream_id, + }) + } + _ => return Err(XpcError::UnknownFrame(frame_type).into()), + }; + + Ok(Some((frame, total))) + } +} + +#[derive(Debug, Clone)] +pub struct SettingsFrame { + pub settings: Vec, + pub stream_id: u32, + pub flags: u8, +} + +#[derive(Debug, Clone)] +pub enum Setting { + MaxConcurrentStreams(u32), + InitialWindowSize(u32), +} + +impl Setting { + fn serialize(&self) -> Vec { + match self { + Setting::MaxConcurrentStreams(m) => { + let mut res = vec![0x00, 0x03]; + res.extend(m.to_be_bytes()); + res + } + Setting::InitialWindowSize(s) => { + let mut res = vec![0x00, 0x04]; + res.extend(s.to_be_bytes()); + res + } + } + } +} + +impl HttpFrame for SettingsFrame { + fn serialize(&self) -> Vec { + let settings = self + .settings + .iter() + .map(|x| x.serialize()) + .collect::>>() + .concat(); + let settings_len = (settings.len() as u32).to_be_bytes(); + let mut res = vec![ + settings_len[1], + settings_len[2], + settings_len[3], + 0x04, + self.flags, + ]; + res.extend(self.stream_id.to_be_bytes()); + res.extend(settings); + res + } +} + +#[derive(Debug, Clone)] +pub struct WindowUpdateFrame { + pub increment_size: u32, + pub stream_id: u32, +} + +impl HttpFrame for WindowUpdateFrame { + fn serialize(&self) -> Vec { + let mut res = vec![0x00, 0x00, 0x04, 0x08, 0x00]; // size, frame ID, flags + res.extend(self.stream_id.to_be_bytes()); + res.extend(self.increment_size.to_be_bytes()); + res + } +} + +#[derive(Debug, Clone)] +/// We don't actually care about this frame according to spec. This is just to open new channels. +pub struct HeadersFrame { + pub stream_id: u32, +} + +impl HttpFrame for HeadersFrame { + fn serialize(&self) -> Vec { + let mut res = vec![0x00, 0x00, 0x00, 0x01, 0x04]; + res.extend(self.stream_id.to_be_bytes()); + res + } +} + +#[derive(Debug, Clone)] +pub struct DataFrame { + pub stream_id: u32, + pub payload: Vec, +} + +impl HttpFrame for DataFrame { + fn serialize(&self) -> Vec { + let mut res = (self.payload.len() as u32).to_be_bytes().to_vec(); + res.remove(0); // only 3 significant bytes + res.extend([0x00, 0x00]); // frame type, flags + res.extend(self.stream_id.to_be_bytes()); + res.extend(self.payload.clone()); + res + } +} diff --git a/src-tauri/vendor/idevice/src/xpc/http2/mod.rs b/src-tauri/vendor/idevice/src/xpc/http2/mod.rs new file mode 100644 index 0000000..9aeb1f7 --- /dev/null +++ b/src-tauri/vendor/idevice/src/xpc/http2/mod.rs @@ -0,0 +1,262 @@ +// Jackson Coxson + +use frame::HttpFrame; +use std::collections::{HashMap, VecDeque}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tracing::{debug, warn}; + +use crate::{IdeviceError, ReadWrite}; + +pub mod frame; +pub use frame::Setting; + +const HTTP2_MAGIC: &[u8] = "PRI * HTTP/2.0\r\n\r\nSM\r\n\r\n".as_bytes(); + +/// HTTP/2 default initial flow-control window for both the connection and each +/// stream (RFC 7540 §6.9.2). The peer can raise the per-stream default via a +/// SETTINGS `InitialWindowSize`. +const DEFAULT_WINDOW: i64 = 65535; + +#[derive(Debug)] +pub struct Http2Client { + inner: R, + cache: HashMap>>, + /// How many payload octets we may still send on the connection as a whole + /// before the peer must replenish it with a connection-level WINDOW_UPDATE. + conn_send_window: i64, + /// Per-stream remaining send window. Lazily seeded to `peer_initial_window`. + stream_send_windows: HashMap, + /// The peer's current SETTINGS `InitialWindowSize` — the window each *new* + /// stream starts with. + peer_initial_window: i64, + /// Raw inbound bytes not yet parsed into a whole frame. Persisting this + /// across reads keeps [`Self::pump`] (and therefore `recv_push`) + /// cancellation-safe: a partially-received frame survives a dropped read. + recv_buf: Vec, +} + +impl Http2Client { + /// Writes the magic and inits the caches + pub async fn new(mut inner: R) -> Result { + inner.write_all(HTTP2_MAGIC).await?; + inner.flush().await?; + Ok(Self { + inner, + cache: HashMap::new(), + conn_send_window: DEFAULT_WINDOW, + stream_send_windows: HashMap::new(), + peer_initial_window: DEFAULT_WINDOW, + recv_buf: Vec::new(), + }) + } + + /// Read the next whole frame, buffering raw bytes in `recv_buf` until one is + /// complete. Cancellation-safe: the single `read` is cancel-safe (no bytes + /// lost if the future is dropped on `Pending`), and any bytes already + /// buffered persist in `self` for the next call. + async fn next_frame(&mut self) -> Result { + loop { + if let Some((frame, consumed)) = frame::Frame::parse(&self.recv_buf)? { + self.recv_buf.drain(..consumed); + return Ok(frame); + } + let mut tmp = [0u8; 16384]; + let n = self.inner.read(&mut tmp).await?; + if n == 0 { + return Err(IdeviceError::UnexpectedResponse( + "HTTP/2 connection closed by peer".into(), + )); + } + self.recv_buf.extend_from_slice(&tmp[..n]); + } + } + + pub async fn set_settings( + &mut self, + settings: Vec, + stream_id: u32, + ) -> Result<(), IdeviceError> { + let frame = frame::SettingsFrame { + settings, + stream_id, + flags: 0, + } + .serialize(); + self.inner.write_all(&frame).await?; + self.inner.flush().await?; + Ok(()) + } + + pub async fn window_update( + &mut self, + increment_size: u32, + stream_id: u32, + ) -> Result<(), IdeviceError> { + let frame = frame::WindowUpdateFrame { + increment_size, + stream_id, + } + .serialize(); + self.inner.write_all(&frame).await?; + self.inner.flush().await?; + Ok(()) + } + + pub async fn open_stream(&mut self, stream_id: u32) -> Result<(), IdeviceError> { + // Sometimes Apple is silly and sends data to a stream that isn't open + self.cache.entry(stream_id).or_default(); + let frame = frame::HeadersFrame { stream_id }.serialize(); + self.inner.write_all(&frame).await?; + self.inner.flush().await?; + Ok(()) + } + + pub async fn send(&mut self, payload: Vec, stream_id: u32) -> Result<(), IdeviceError> { + const MAX_FRAME_SIZE: usize = 16384; + let mut chunks = payload.chunks(MAX_FRAME_SIZE).peekable(); + // Always send at least one frame, even for an empty payload. An empty + // DATA frame costs no flow-control window, so send it directly. + if chunks.peek().is_none() { + let frame = frame::DataFrame { + stream_id, + payload: Vec::new(), + } + .serialize(); + self.inner.write_all(&frame).await?; + self.inner.flush().await?; + return Ok(()); + } + for chunk in chunks { + let need = chunk.len() as i64; + // Respect the peer's flow-control window: a DATA frame must not exceed + // either the connection-level or the stream-level send window, or the + // peer aborts the connection with a GOAWAY (FLOW_CONTROL_ERROR). When + // either window is exhausted, pump inbound frames until the peer grants + // more room with a WINDOW_UPDATE. (Matters for large payloads like + // pasteboard images; small ones fit in the initial 64 KiB window.) + while self.conn_send_window < need || self.stream_send_window(stream_id) < need { + self.pump().await?; + } + let frame = frame::DataFrame { + stream_id, + payload: chunk.to_vec(), + } + .serialize(); + self.inner.write_all(&frame).await?; + self.conn_send_window -= need; + *self + .stream_send_windows + .get_mut(&stream_id) + .expect("seeded by stream_send_window above") -= need; + } + self.inner.flush().await?; + Ok(()) + } + + /// The remaining send window for `stream_id`, seeding it to the peer's current + /// initial window size the first time we touch the stream. + fn stream_send_window(&mut self, stream_id: u32) -> i64 { + *self + .stream_send_windows + .entry(stream_id) + .or_insert(self.peer_initial_window) + } + + pub async fn read(&mut self, stream_id: u32) -> Result, IdeviceError> { + self.cache.entry(stream_id).or_default(); + loop { + // Return any frame already buffered for this stream. + if let Some(d) = self.cache.get_mut(&stream_id).and_then(|c| c.pop_front()) { + return Ok(d); + } + self.pump().await?; + } + } + + /// Read and handle a single inbound frame: ack SETTINGS (applying any + /// `InitialWindowSize` change), apply WINDOW_UPDATEs to our send windows, + /// replenish the peer's receive window for inbound DATA and buffer that DATA + /// by stream. GOAWAY / RST_STREAM surface as errors via [`frame::Frame::next`]. + async fn pump(&mut self) -> Result<(), IdeviceError> { + let frame = self.next_frame().await?; + match frame { + frame::Frame::Settings(settings_frame) if settings_frame.flags != 1 => { + // Adjust every existing stream's send window by the delta in the + // new InitialWindowSize (RFC 7540 §6.9.2), then ack. + for setting in &settings_frame.settings { + if let frame::Setting::InitialWindowSize(new) = setting { + let delta = *new as i64 - self.peer_initial_window; + self.peer_initial_window = *new as i64; + for w in self.stream_send_windows.values_mut() { + *w += delta; + } + } + } + let ack = frame::SettingsFrame { + settings: Vec::new(), + stream_id: settings_frame.stream_id, + flags: 1, + } + .serialize(); + self.inner.write_all(&ack).await?; + self.inner.flush().await?; + } + frame::Frame::WindowUpdate(w) => { + if w.stream_id == 0 { + self.conn_send_window += w.increment_size as i64; + } else { + let initial = self.peer_initial_window; + *self + .stream_send_windows + .entry(w.stream_id) + .or_insert(initial) += w.increment_size as i64; + } + } + frame::Frame::Data(data_frame) => { + debug!( + "Got data frame for {} with {} bytes", + data_frame.stream_id, + data_frame.payload.len() + ); + + let len = data_frame.payload.len() as u32; + let stream_id = data_frame.stream_id; + // Cache the payload BEFORE any await so a cancelled pump (the poll + // tick interrupting `recv_push`) can never drop it. + self.cache + .entry(stream_id) + .or_insert_with(|| { + // Apple sometimes sends data before the stream is "open". + warn!("Received message for stream ID {stream_id} not in cache"); + VecDeque::new() + }) + .push_back(data_frame.payload); + if len > 0 { + // Replenish the peer's view of our receive window so it keeps + // sending (e.g. the rest of a large pasteboard image). Queue + // both WINDOW_UPDATE frames, then flush once: write_all on the + // tunnel stream queues a whole frame without suspending, so a + // cancellation can only land on the flush — by which point both + // frames are already queued (never a torn or dropped update). + let conn = frame::WindowUpdateFrame { + increment_size: len, + stream_id: 0, + } + .serialize(); + let stream = frame::WindowUpdateFrame { + increment_size: len, + stream_id, + } + .serialize(); + self.inner.write_all(&conn).await?; + self.inner.write_all(&stream).await?; + self.inner.flush().await?; + } + } + _ => { + // SETTINGS ack / HEADERS — nothing to do. + } + } + Ok(()) + } +} diff --git a/src-tauri/vendor/idevice/src/xpc/mod.rs b/src-tauri/vendor/idevice/src/xpc/mod.rs new file mode 100644 index 0000000..3c49020 --- /dev/null +++ b/src-tauri/vendor/idevice/src/xpc/mod.rs @@ -0,0 +1,266 @@ +// Jackson Coxson + +use async_stream::try_stream; +use futures::Stream; +use http2::Setting; +use tracing::debug; + +use crate::{CdTunnelError, IdeviceError, ReadWrite, xpc}; + +pub mod errors; +mod format; +mod http2; +pub mod xpc_macro; + +use format::XPCFlag; +pub use format::{Dictionary, XPCMessage, XPCObject}; + +const ROOT_CHANNEL: u32 = 1; +const REPLY_CHANNEL: u32 = 3; + +/// Fixed XPC message-wrapper header: magic + flags + body length + message id. +const XPC_WRAPPER_LEN: usize = 24; + +#[derive(Debug)] +pub struct RemoteXpcClient { + h2_client: http2::Http2Client, + root_id: u64, + // reply_id: u64 // maybe not used? + /// Per-channel bytes accumulated toward the next whole XPC message. Persisted + /// across `recv_from_channel` calls so a partially-received message survives a + /// cancelled read — required for `recv_push` to be safe in a `select!`. + partial: std::collections::HashMap>, +} + +impl RemoteXpcClient { + pub async fn new(socket: R) -> Result { + Ok(Self { + h2_client: http2::Http2Client::new(socket).await?, + root_id: 1, + partial: std::collections::HashMap::new(), + }) + } + + pub async fn do_handshake(&mut self) -> Result<(), IdeviceError> { + self.h2_client + .set_settings( + vec![ + Setting::MaxConcurrentStreams(100), + Setting::InitialWindowSize(1048576), + ], + 0, + ) + .await?; + self.h2_client.window_update(983041, 0).await?; + self.h2_client.open_stream(1).await?; // root channel + + debug!("Sending empty dictionary"); + self.send_root(XPCMessage::new( + Some(XPCFlag::AlwaysSet), + Some(XPCObject::Dictionary(Default::default())), + None, + )) + .await?; + + debug!("Opening reply stream"); + self.h2_client.open_stream(REPLY_CHANNEL).await?; + self.send_reply(XPCMessage::new( + Some(XPCFlag::InitHandshake | XPCFlag::AlwaysSet), + None, + None, + )) + .await?; + + debug!("Sending weird flags"); + self.send_root(XPCMessage::new(Some(XPCFlag::Custom(0x201)), None, None)) + .await?; + + Ok(()) + } + + /// Announce ourselves to the device's `remoted` as a modern (non-legacy) + /// RemoteXPC peer. + /// + /// Send this only on the RSD/remoted control connection + pub async fn send_device_handshake(&mut self) -> Result<(), IdeviceError> { + const REMOTE_XPC_VERSION_FLAGS: u64 = 0x0100_0000_0000_0006; + + let msg = xpc!({ + "MessageType": "Handshake", + "MessagingProtocolVersion": 7u64, + "UUID": uuid::Uuid::new_v4(), + "Properties": { + "RemoteXPCVersionFlags": REMOTE_XPC_VERSION_FLAGS, + "SensitivePropertiesVisible": true, + }, + "Services": XPCObject::Dictionary(Dictionary::new()) + }); + + self.send_object(msg, false).await + } + + pub async fn recv(&mut self) -> Result { + self.recv_from_channel(REPLY_CHANNEL).await + } + + pub async fn recv_root(&mut self) -> Result { + self.recv_from_channel(ROOT_CHANNEL).await + } + + async fn recv_from_channel(&mut self, channel: u32) -> Result { + loop { + // Try to decode a whole message from what's already buffered before + // reading more, so a message split across earlier reads completes. + // Scope the buffer borrow so it's released before the await below. + let decoded = { + let buf = self.partial.entry(channel).or_default(); + match XPCMessage::decode(buf) { + Ok(msg) => { + // A complete wrapper consumes 24 + body_len bytes; drain + // exactly that, preserving any bytes of the next message. + let consumed = (XPC_WRAPPER_LEN + xpc_body_len(buf)).min(buf.len()); + buf.drain(..consumed); + Some(msg) + } + // Not enough bytes yet: fall through to read another frame. + Err(IdeviceError::CdTunnel(CdTunnelError::SizeMismatch)) + | Err(IdeviceError::NotEnoughBytes(..)) => None, + Err(e) => return Err(e), + } + }; + + match decoded { + Some(msg) => match msg.message { + // Skip empty-dictionary keepalives and bodyless frames. + Some(inner) => { + if let Some(d) = inner.as_dictionary() + && d.is_empty() + { + continue; + } + return Ok(inner.to_plist()); + } + None => continue, + }, + None => { + let chunk = self.h2_client.read(channel).await?; + self.partial.entry(channel).or_default().extend(chunk); + } + } + } + } + + pub async fn send_object( + &mut self, + msg: impl Into, + expect_reply: bool, + ) -> Result<(), IdeviceError> { + let msg: XPCObject = msg.into(); + + let mut flag = XPCFlag::DataFlag | XPCFlag::AlwaysSet; + if expect_reply { + flag |= XPCFlag::WantingReply; + } + + let msg = XPCMessage::new(Some(flag), Some(msg), Some(self.root_id)); + self.send_root(msg).await?; + + Ok(()) + } + + async fn send_root(&mut self, msg: XPCMessage) -> Result<(), IdeviceError> { + self.h2_client + .send(msg.encode(self.root_id)?, ROOT_CHANNEL) + .await?; + Ok(()) + } + + async fn send_reply(&mut self, msg: XPCMessage) -> Result<(), IdeviceError> { + self.h2_client + .send(msg.encode(self.root_id)?, REPLY_CHANNEL) + .await?; + Ok(()) + } + + pub fn iter_file_chunks<'a>( + &'a mut self, + total_size: usize, + file_idx: u32, + ) -> impl Stream, IdeviceError>> + 'a { + let stream_id = (file_idx + 1) * 2; + + try_stream! { + fn strip_xpc_wrapper_prefix(buf: &[u8]) -> (&[u8], bool) { + // Returns (data_after_wrapper, stripped_anything) + const MAGIC: u32 = 0x29b00b92; + + if buf.len() < 24 { + return (buf, false); + } + + let magic = u32::from_le_bytes([buf[0], buf[1], buf[2], buf[3]]); + if magic != MAGIC { + return (buf, false); + } + + // flags at [4..8] – not needed to compute size + let body_len = u64::from_le_bytes([ + buf[8], buf[9], buf[10], buf[11], buf[12], buf[13], buf[14], buf[15], + ]) as usize; + + let wrapper_len = 24 + body_len; + if buf.len() < wrapper_len { + // Incomplete wrapper (shouldn’t happen with your read API), keep as-is. + return (buf, false); + } + + (&buf[wrapper_len..], true) + } + self.open_file_stream_for_response(stream_id).await?; + + let mut got = 0usize; + while got < total_size { + let bytes = self.h2_client.read(stream_id).await?; + let (after, stripped) = strip_xpc_wrapper_prefix(&bytes); + if stripped && after.is_empty() { + continue; // pure control wrapper, don't count + } + + let data = if stripped { after.to_vec() } else { bytes }; + + if data.is_empty() { + continue; + } + + got += data.len(); + yield data; + } + } + } + + pub async fn open_file_stream_for_response( + &mut self, + stream_id: u32, + ) -> Result<(), IdeviceError> { + // 1) Open the HTTP/2 stream + self.h2_client.open_stream(stream_id).await?; + + // 2) Send an empty XPC wrapper on that same stream with FILE_TX_STREAM_RESPONSE + let flags = XPCFlag::AlwaysSet | XPCFlag::FileTxStreamResponse; + + let msg = XPCMessage::new(Some(flags), None, Some(0)); + + // IMPORTANT: send on `stream_id`, not ROOT/REPLY + let bytes = msg.encode(0)?; + self.h2_client.send(bytes, stream_id).await?; + Ok(()) + } +} + +/// The XPC message body length from a decoded-OK wrapper: the little-endian u64 +/// at bytes 8..16. `buf` is known to hold a full wrapper (≥ 24 bytes) here. +fn xpc_body_len(buf: &[u8]) -> usize { + u64::from_le_bytes([ + buf[8], buf[9], buf[10], buf[11], buf[12], buf[13], buf[14], buf[15], + ]) as usize +} diff --git a/src-tauri/vendor/idevice/src/xpc/xpc_macro.rs b/src-tauri/vendor/idevice/src/xpc/xpc_macro.rs new file mode 100644 index 0000000..56a7ed0 --- /dev/null +++ b/src-tauri/vendor/idevice/src/xpc/xpc_macro.rs @@ -0,0 +1,609 @@ +// Jackson Coxson +// Ported from serde's json! and the plist! macro + +/// Construct an `XPCObject` from a JSON-like literal. +/// +/// The `xpc!` macro allows you to construct `XPCObject` values with a syntax +/// similar to JSON. It supports signed and unsigned integers, which is a key +/// feature of XPC. +/// +/// ``` +/// # use my_crate::xpc; // Replace with your crate name +/// # use my_crate::xpc_macro::*; +/// # +/// let value = xpc!({ +/// "message": "hello", +/// "is_reply": true, +/// "signed_value": -42, +/// "unsigned_value": 42u64, +/// "payload": { +/// "items": [1, 2, 3], +/// "metadata": null // Becomes an empty string +/// } +/// }); +/// ``` +/// +/// ### Interpolation +/// You can interpolate variables and expressions directly into the macro. +/// Any interpolated value must implement the `XpcConvertible` trait. +/// +/// ``` +/// # use my_crate::xpc; +/// # use my_crate::xpc_macro::*; +/// # +/// let user_id = 1001u64; +/// let is_admin = false; +/// +/// let request = xpc!({ +/// "user_id": user_id, +/// "is_admin": is_admin, +/// "permissions": ["read", "write"], +/// }); +/// ``` +/// +/// ### Optional Fields and Merging +/// The macro supports optional fields using `?` and dictionary merging +/// using `:<` for cleaner construction of complex objects. +/// +/// ``` +/// # use my_crate::xpc; +/// # use my_crate::xpc_macro::*; +/// # +/// let maybe_tag: Option<&str> = Some("important"); +/// let base_config = xpc!({ "timeout": 5000u64 }); +/// +/// let message = xpc!({ +/// "message_id": "msg-123", +/// "tag":? maybe_tag, +/// :< base_config, +/// }); +/// ``` +#[macro_export] +macro_rules! xpc { + // Hide distracting implementation details from the generated rustdoc. + ($($xpc:tt)+) => { + $crate::xpc_internal!($($xpc)+) + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! xpc_internal { + ////////////////////////////////////////////////////////////////////////// + // TT muncher for parsing the inside of an array [...]. + ////////////////////////////////////////////////////////////////////////// + + // Done with trailing comma. + (@array [$($elems:expr,)*]) => { + vec![$($elems,)*] + }; + + // Done without trailing comma. + (@array [$($elems:expr),*]) => { + vec![$($elems),*] + }; + + // Next element is `null`. + (@array [$($elems:expr,)*] null $($rest:tt)*) => { + $crate::xpc_internal!(@array [$($elems,)* $crate::xpc_internal!(null)] $($rest)*) + }; + + // Next element is `true`. + (@array [$($elems:expr,)*] true $($rest:tt)*) => { + $crate::xpc_internal!(@array [$($elems,)* $crate::xpc_internal!(true)] $($rest)*) + }; + + // Next element is `false`. + (@array [$($elems:expr,)*] false $($rest:tt)*) => { + $crate::xpc_internal!(@array [$($elems,)* $crate::xpc_internal!(false)] $($rest)*) + }; + + // Next element is an array. + (@array [$($elems:expr,)*] [$($array:tt)*] $($rest:tt)*) => { + $crate::xpc_internal!(@array [$($elems,)* $crate::xpc_internal!([$($array)*])] $($rest)*) + }; + + // Next element is a map. + (@array [$($elems:expr,)*] {$($map:tt)*} $($rest:tt)*) => { + $crate::xpc_internal!(@array [$($elems,)* $crate::xpc_internal!({$($map)*})] $($rest)*) + }; + + // Optional element + (@array [$($elems:expr,)*] ? $maybe:expr, $($rest:tt)*) => { + if let Some(__v) = $crate::xpc::xpc_macro::xpc_maybe($maybe) { + $crate::xpc_internal!(@array [$($elems,)* __v,] $($rest)*) + } else { + $crate::xpc_internal!(@array [$($elems,)*] $($rest)*) + } + }; + (@array [$($elems:expr,)*] ? $maybe:expr) => { + if let Some(__v) = $crate::xpc_macro::xpc_maybe($maybe) { + $crate::xpc_internal!(@array [$($elems,)* __v]) + } else { + $crate::xpc_internal!(@array [$($elems,)*]) + } + }; + + // Next element is an expression followed by comma. + (@array [$($elems:expr,)*] $next:expr, $($rest:tt)*) => { + $crate::xpc_internal!(@array [$($elems,)* $crate::xpc_internal!($next),] $($rest)*) + }; + + // Last element is an expression with no trailing comma. + (@array [$($elems:expr,)*] $last:expr) => { + $crate::xpc_internal!(@array [$($elems,)* $crate::xpc_internal!($last)]) + }; + + // Comma after the most recent element. + (@array [$($elems:expr),*] , $($rest:tt)*) => { + $crate::xpc_internal!(@array [$($elems,)*] $($rest)*) + }; + + // Unexpected token after most recent element. + (@array [$($elems:expr),*] $unexpected:tt $($rest:tt)*) => { + $crate::xpc_unexpected!($unexpected) + }; + + ////////////////////////////////////////////////////////////////////////// + // TT muncher for parsing the inside of an object {...}. + ////////////////////////////////////////////////////////////////////////// + + // Done. + (@object $object:ident () () ()) => {}; + + // Insert the current entry followed by trailing comma. + (@object $object:ident [$($key:tt)+] ($value:expr) , $($rest:tt)*) => { + let _ = $object.insert(($($key)+).into(), $value); + $crate::xpc_internal!(@object $object () ($($rest)*) ($($rest)*)); + }; + + // Current entry followed by unexpected token. + (@object $object:ident [$($key:tt)+] ($value:expr) $unexpected:tt $($rest:tt)*) => { + $crate::xpc_unexpected!($unexpected); + }; + + // Insert the last entry without trailing comma. + (@object $object:ident [$($key:tt)+] ($value:expr)) => { + let _ = $object.insert(($($key)+).into(), $value); + }; + + // Next value is `null`. + (@object $object:ident ($($key:tt)+) (: null $($rest:tt)*) $copy:tt) => { + $crate::xpc_internal!(@object $object [$($key)+] ($crate::xpc_internal!(null)) $($rest)*); + }; + + // Next value is `true`. + (@object $object:ident ($($key:tt)+) (: true $($rest:tt)*) $copy:tt) => { + $crate::xpc_internal!(@object $object [$($key)+] ($crate::xpc_internal!(true)) $($rest)*); + }; + + // Next value is `false`. + (@object $object:ident ($($key:tt)+) (: false $($rest:tt)*) $copy:tt) => { + $crate::xpc_internal!(@object $object [$($key)+] ($crate::xpc_internal!(false)) $($rest)*); + }; + + // Next value is an array. + (@object $object:ident ($($key:tt)+) (: [$($array:tt)*] $($rest:tt)*) $copy:tt) => { + $crate::xpc_internal!(@object $object [$($key)+] ($crate::xpc_internal!([$($array)*])) $($rest)*); + }; + + // Next value is a map. + (@object $object:ident ($($key:tt)+) (: {$($map:tt)*} $($rest:tt)*) $copy:tt) => { + $crate::xpc_internal!(@object $object [$($key)+] ($crate::xpc_internal!({$($map)*})) $($rest)*); + }; + + // Optional insert: `key:? value` + (@object $object:ident ($($key:tt)+) (:? $value:expr, $($rest:tt)*) $copy:tt) => { + if let Some(__v) = $crate::xpc::xpc_macro::xpc_maybe($value) { + let _ = $object.insert(($($key)+).into(), __v); + } + $crate::xpc_internal!(@object $object () ($($rest)*) ($($rest)*)); + }; + (@object $object:ident ($($key:tt)+) (:? $value:expr) $copy:tt) => { + if let Some(__v) = $crate::xpc_macro::xpc_maybe($value) { + let _ = $object.insert(($($key)+).into(), __v); + } + }; + + // Merge: `:< value` + (@object $object:ident () (:< $value:expr, $($rest:tt)*) $copy:tt) => { + { + let __v = $crate::xpc_internal!($value); + let __dict = $crate::xpc::xpc_macro::IntoXpcDict::into_xpc_dict(__v); + for (__k, __val) in __dict { + let _ = $object.insert(__k, __val); + } + } + $crate::xpc_internal!(@object $object () ($($rest)*) ($($rest)*)); + }; + (@object $object:ident () (:< $value:expr) $copy:tt) => { + { + let __v = $crate::xpc_internal!($value); + let __dict = $crate::xpc_macro::IntoXpcDict::into_xpc_dict(__v); + for (__k, __val) in __dict { + let _ = $object.insert(__k, __val); + } + } + }; + + // Optional merge: `: { + if let Some(__dict) = $crate::xpc::xpc_macro::maybe_into_xpc_dict($value) { + for (__k, __val) in __dict { + let _ = $object.insert(__k, __val); + } + } + $crate::xpc_internal!(@object $object () ($($rest)*) ($($rest)*)); + }; + (@object $object:ident () (:< ? $value:expr) $copy:tt) => { + if let Some(__dict) = $crate::xpc_macro::maybe_into_xpc_dict($value) { + for (__k, __val) in __dict { + let _ = $object.insert(__k, __val); + } + } + }; + + // Next value is an expression followed by comma. + (@object $object:ident ($($key:tt)+) (: $value:expr, $($rest:tt)*) $copy:tt) => { + $crate::xpc_internal!(@object $object [$($key)+] ($crate::xpc_internal!($value)), $($rest)*); + }; + + // Last value is an expression with no trailing comma. + (@object $object:ident ($($key:tt)+) (: $value:expr) $copy:tt) => { + $crate::xpc_internal!(@object $object [$($key)+] ($crate::xpc_internal!($value))); + }; + + // Missing value for last entry. + (@object $object:ident ($($key:tt)+) (:) $copy:tt) => { + $crate::xpc_internal!(); + }; + + // Missing colon and value for last entry. + (@object $object:ident ($($key:tt)+) () $copy:tt) => { + $crate::xpc_internal!(); + }; + + // Misplaced colon. + (@object $object:ident () (: $($rest:tt)*) ($colon:tt $($copy:tt)*)) => { + $crate::xpc_unexpected!($colon); + }; + + // Munch a token into the current key. + (@object $object:ident ($($key:tt)*) ($tt:tt $($rest:tt)*) $copy:tt) => { + $crate::xpc_internal!(@object $object ($($key)* $tt) ($($rest)*) ($($rest)*)); + }; + + ////////////////////////////////////////////////////////////////////////// + // The main implementation. + ////////////////////////////////////////////////////////////////////////// + + (null) => { + // XPC does not have a native null type. We'll use an empty string as a convention. + $crate::xpc::XPCObject::String("".to_string()) + }; + + (true) => { + $crate::xpc::XPCObject::Bool(true) + }; + + (false) => { + $crate::xpc::XPCObject::Bool(false) + }; + + ([ $($tt:tt)+ ]) => { + $crate::xpc::XPCObject::Array($crate::xpc_internal!(@array [] $($tt)+)) + }; + + ({ $($tt:tt)+ }) => { + $crate::xpc::XPCObject::Dictionary({ + let mut object = $crate::xpc::Dictionary::new(); + $crate::xpc_internal!(@object object () ($($tt)+) ($($tt)+)); + object + }) + }; + + // Any other expression that implements XpcConvertible. + // Must be below every other rule. + ($other:expr) => { + $crate::xpc::xpc_macro::xpc_to_value($other) + }; +} + +#[macro_export] +#[doc(hidden)] +macro_rules! xpc_unexpected { + () => {}; +} + +/// Conversion traits and helper functions for the `xpc!` macro. +use crate::xpc::{Dictionary, XPCObject}; +use std::collections::{BTreeMap, HashMap}; + +// Convert various types to XPCObject +pub trait XpcConvertible { + fn to_xpc_value(self) -> XPCObject; +} + +#[doc(hidden)] +pub fn xpc_to_value(value: T) -> XPCObject { + value.to_xpc_value() +} + +// Base implementation +impl XpcConvertible for XPCObject { + fn to_xpc_value(self) -> XPCObject { + self + } +} + +// Primitive implementations +impl XpcConvertible for String { + fn to_xpc_value(self) -> XPCObject { + XPCObject::String(self) + } +} +impl XpcConvertible for &str { + fn to_xpc_value(self) -> XPCObject { + XPCObject::String(self.to_string()) + } +} +impl XpcConvertible for bool { + fn to_xpc_value(self) -> XPCObject { + XPCObject::Bool(self) + } +} + +// Signed integer implementations +macro_rules! impl_xpc_convertible_for_signed { + ($($t:ty),*) => { + $(impl XpcConvertible for $t { + fn to_xpc_value(self) -> XPCObject { XPCObject::Int64(self as i64) } + })* + }; + } +impl_xpc_convertible_for_signed!(i8, i16, i32, i64, isize); + +// Unsigned integer implementations +macro_rules! impl_xpc_convertible_for_unsigned { + ($($t:ty),*) => { + $(impl XpcConvertible for $t { + fn to_xpc_value(self) -> XPCObject { XPCObject::UInt64(self as u64) } + })* + }; + } +impl_xpc_convertible_for_unsigned!(u16, u32, u64, usize); + +// Floating point implementations +impl XpcConvertible for f32 { + fn to_xpc_value(self) -> XPCObject { + XPCObject::Double(self as f64) + } +} +impl XpcConvertible for f64 { + fn to_xpc_value(self) -> XPCObject { + XPCObject::Double(self) + } +} + +// Other XPC-specific types +impl XpcConvertible for Vec { + fn to_xpc_value(self) -> XPCObject { + XPCObject::Data(self) + } +} +impl XpcConvertible for &[u8] { + fn to_xpc_value(self) -> XPCObject { + XPCObject::Data(self.to_vec()) + } +} +impl XpcConvertible for uuid::Uuid { + fn to_xpc_value(self) -> XPCObject { + XPCObject::Uuid(self) + } +} +impl XpcConvertible for std::time::SystemTime { + fn to_xpc_value(self) -> XPCObject { + XPCObject::Date(self) + } +} + +// Collection implementations +impl XpcConvertible for Vec { + fn to_xpc_value(self) -> XPCObject { + XPCObject::Array(self.into_iter().map(XpcConvertible::to_xpc_value).collect()) + } +} +impl XpcConvertible for &[T] { + fn to_xpc_value(self) -> XPCObject { + XPCObject::Array( + self.iter() + .cloned() + .map(XpcConvertible::to_xpc_value) + .collect(), + ) + } +} +impl XpcConvertible for HashMap +where + K: Into, + V: XpcConvertible, +{ + fn to_xpc_value(self) -> XPCObject { + XPCObject::Dictionary( + self.into_iter() + .map(|(k, v)| (k.into(), v.to_xpc_value())) + .collect(), + ) + } +} +impl XpcConvertible for BTreeMap +where + K: Into, + V: XpcConvertible, +{ + fn to_xpc_value(self) -> XPCObject { + XPCObject::Dictionary( + self.into_iter() + .map(|(k, v)| (k.into(), v.to_xpc_value())) + .collect(), + ) + } +} + +// Optional value handling (for `key:? value`) +pub trait MaybeXpc { + fn into_option_xpc(self) -> Option; +} +impl MaybeXpc for T { + fn into_option_xpc(self) -> Option { + Some(self.to_xpc_value()) + } +} +impl MaybeXpc for Option { + fn into_option_xpc(self) -> Option { + self.map(XpcConvertible::to_xpc_value) + } +} + +#[doc(hidden)] +pub fn xpc_maybe(v: T) -> Option { + v.into_option_xpc() +} + +// Dictionary merging (for `:< dict`) +pub trait IntoXpcDict { + fn into_xpc_dict(self) -> Dictionary; +} +impl IntoXpcDict for Dictionary { + fn into_xpc_dict(self) -> Dictionary { + self + } +} +impl IntoXpcDict for XPCObject { + fn into_xpc_dict(self) -> Dictionary { + match self { + XPCObject::Dictionary(d) => d, + other => panic!( + "xpc! macro merge `:<` expects a dictionary, found {:?}", + other + ), + } + } +} +impl IntoXpcDict for HashMap +where + K: Into, + V: XpcConvertible, +{ + fn into_xpc_dict(self) -> Dictionary { + self.into_iter() + .map(|(k, v)| (k.into(), v.to_xpc_value())) + .collect() + } +} + +// Optional dictionary merging (for `: Option; +} +impl MaybeIntoXpcDict for T { + fn into_option_xpc_dict(self) -> Option { + Some(self.into_xpc_dict()) + } +} +impl MaybeIntoXpcDict for Option { + fn into_option_xpc_dict(self) -> Option { + self.map(IntoXpcDict::into_xpc_dict) + } +} + +#[doc(hidden)] +pub fn maybe_into_xpc_dict(v: T) -> Option { + v.into_option_xpc_dict() +} + +#[cfg(test)] +mod tests { + use crate::xpc::{Dictionary, XPCObject}; + use uuid::Uuid; + + #[test] + fn test_xpc_macro_primitives() { + assert_eq!(xpc!(null), XPCObject::String("".to_string())); + assert_eq!(xpc!(true), XPCObject::Bool(true)); + assert_eq!(xpc!(-123), XPCObject::Int64(-123)); + assert_eq!(xpc!(123), XPCObject::Int64(123)); + assert_eq!(xpc!(123u32), XPCObject::UInt64(123)); + assert_eq!(xpc!(123u64), XPCObject::UInt64(123)); + assert_eq!(xpc!(123.45), XPCObject::Double(123.45)); + assert_eq!(xpc!("hello"), XPCObject::String("hello".to_string())); + } + + #[test] + fn test_xpc_macro_collections() { + let arr = xpc!([1, "two", true]); + match arr { + XPCObject::Array(vec) => { + assert_eq!(vec.len(), 3); + assert_eq!(vec[0], XPCObject::Int64(1)); + assert_eq!(vec[1], XPCObject::String("two".to_string())); + assert_eq!(vec[2], XPCObject::Bool(true)); + } + _ => panic!("Expected array"), + } + + let dict = xpc!({ + "key1": 1u64, + "key2": "value2" + }); + match dict { + XPCObject::Dictionary(map) => { + assert_eq!(map.len(), 2); + assert_eq!(map.get("key1"), Some(&XPCObject::UInt64(1))); + assert_eq!( + map.get("key2"), + Some(&XPCObject::String("value2".to_string())) + ); + } + _ => panic!("Expected dictionary"), + } + } + + #[test] + fn test_xpc_macro_interpolation_and_optional() { + let my_uuid = Uuid::new_v4(); + let optional_field: Option = None; + let present_field = Some("I'm here"); + let base = xpc!({ "base_field": true }); + let optional_base: Option = None; + + let obj = xpc!({ + "id": my_uuid, + "optional_field":? optional_field, + "present_field":? present_field, + :< base, + :, 3], + }); + + if let XPCObject::Dictionary(dict) = obj { + assert_eq!(dict.get("id"), Some(&XPCObject::Uuid(my_uuid))); + assert!(dict.get("optional_field").is_none()); + assert_eq!( + dict.get("present_field"), + Some(&XPCObject::String("I'm here".to_string())) + ); + assert_eq!(dict.get("base_field"), Some(&XPCObject::Bool(true))); + + if let Some(XPCObject::Array(arr)) = dict.get("arr") { + assert_eq!(arr.len(), 2); + assert_eq!(arr[0], XPCObject::Int64(1)); + assert_eq!(arr[1], XPCObject::Int64(3)); + } else { + panic!("Expected array for 'arr' key"); + } + } else { + panic!("Expected dictionary"); + } + } +} diff --git a/src-tauri/vendor/isideload/Cargo.toml b/src-tauri/vendor/isideload/Cargo.toml new file mode 100644 index 0000000..9419def --- /dev/null +++ b/src-tauri/vendor/isideload/Cargo.toml @@ -0,0 +1,181 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2024" +name = "isideload" +version = "0.2.22" +authors = ["Nicholas Sharp "] +build = false +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "Sideload iOS/iPadOS applications" +documentation = "https://docs.rs/isideload" +readme = "README.md" +keywords = [ + "ios", + "sideload", +] +license = "MIT" +repository = "https://github.com/nab138/isideload" +resolver = "2" + +[features] +default = [ + "install", + "keyring-storage", +] +fs-storage = [] +install = ["dep:idevice"] +keyring-storage = ["dep:keyring"] + +[lib] +name = "isideload" +path = "src/lib.rs" + +[dependencies.aes] +version = "0.9.0" + +[dependencies.aes-gcm] +version = "0.11.0-rc.3" + +[dependencies.apple-codesign] +version = "0.29.4" +package = "isideload-apple-codesign" + +[dependencies.async-trait] +version = "0.1.89" + +[dependencies.base64] +version = "0.22.1" + +[dependencies.cbc] +version = "0.2.0" +features = ["alloc"] + +[dependencies.futures-util] +version = "0.3.31" + +[dependencies.hex] +version = "0.4.3" + +[dependencies.hmac] +version = "0.13.0" + +[dependencies.idevice] +version = "0.1.58" +features = [ + "afc", + "installation_proxy", +] +optional = true + +[dependencies.keyring] +version = "3.6.3" +features = [ + "apple-native", + "linux-native-sync-persistent", + "windows-native", +] +optional = true + +[dependencies.p12-keystore] +version = "0.3.0-rc4" +package = "isideload-p12-keystore" + +[dependencies.pbkdf2] +version = "0.13.0" + +[dependencies.plist] +version = "1.8" + +[dependencies.plist-macro] +version = "0.1.4" + +[dependencies.rand] +version = "0.10.0" + +[dependencies.rcgen] +version = "0.14.7" +features = [ + "aws_lc_rs", + "pem", +] +default-features = false + +[dependencies.reqwest] +version = "0.13.2" +features = [ + "json", + "gzip", +] + +[dependencies.rootcause] +version = "0.12.0" + +[dependencies.rsa] +version = "0.10.0-rc.17" + +[dependencies.serde] +version = "1.0.228" + +[dependencies.serde_json] +version = "1.0.149" + +[dependencies.sha1] +version = "0.11.0" + +[dependencies.sha2] +version = "0.11.0" + +[dependencies.srp] +version = "0.7.0-rc.3" + +[dependencies.thiserror] +version = "2.0.17" + +[dependencies.tokio] +version = "1.49.0" +features = [ + "fs", + "rt-multi-thread", +] + +[dependencies.tokio-tungstenite] +version = "0.29.0" +features = ["rustls-tls-webpki-roots"] + +[dependencies.tracing] +version = "0.1.44" + +[dependencies.uuid] +version = "1.20.0" +features = ["v4"] + +[dependencies.x509-certificate] +version = "0.25.0" +package = "isideload-x509-certificate" + +[dependencies.zip] +version = "8.3" +features = ["deflate"] +default-features = false + +[target."cfg(windows)".dependencies.reqwest] +version = "0.13.2" +features = [ + "json", + "gzip", + "native-tls", +] diff --git a/src-tauri/vendor/isideload/Cargo.toml.orig b/src-tauri/vendor/isideload/Cargo.toml.orig new file mode 100644 index 0000000..e8a63b7 --- /dev/null +++ b/src-tauri/vendor/isideload/Cargo.toml.orig @@ -0,0 +1,60 @@ +[package] +name = "isideload" +description = "Sideload iOS/iPadOS applications" +license = "MIT" +authors = ["Nicholas Sharp "] +version = "0.2.22" +edition = "2024" +repository = "https://github.com/nab138/isideload" +documentation = "https://docs.rs/isideload" +keywords = ["ios", "sideload"] +readme = "../README.md" + +[features] +default = ["install", "keyring-storage"] +install = ["dep:idevice"] +keyring-storage = ["dep:keyring"] +fs-storage = [] + +# Unfortunately, dependencies are kinda a mess rn, since this requires a beta version of the srp crate. +# Once that becomes stable, hopefuly duplicate dependencies should clean up.\ +# Until then, I will wince in pain every time I see how long the output of cargo tree -d is. +[dependencies] +idevice = { version = "0.1.58", optional = true, features = ["afc", "installation_proxy"]} +plist = "1.8" +plist-macro = "0.1.4" +reqwest = { version = "0.13.2", features = ["json", "gzip"] } +thiserror = "2.0.17" +async-trait = "0.1.89" +serde = "1.0.228" +rand = "0.10.0" +uuid = {version = "1.20.0", features = ["v4"] } +tracing = "0.1.44" +tokio-tungstenite = { version = "0.29.0", features = ["rustls-tls-webpki-roots"] } +rootcause = "0.12.0" +futures-util = "0.3.31" +serde_json = "1.0.149" +base64 = "0.22.1" +hex = "0.4.3" +sha2 = "0.11.0" +srp = "0.7.0-rc.3" +pbkdf2 = "0.13.0" +hmac = "0.13.0" +cbc = { version = "0.2.0", features = ["alloc"] } +aes = "0.9.0" +aes-gcm = "0.11.0-rc.3" +rsa = { version = "0.10.0-rc.17" } +tokio = { version = "1.49.0", features = ["fs", "rt-multi-thread"] } +keyring = { version = "3.6.3", features = ["apple-native", "linux-native-sync-persistent", "windows-native"], optional = true } +x509-certificate = { version = "0.25.0", package = "isideload-x509-certificate" } +rcgen = { version = "0.14.7", default-features = false, features = ["aws_lc_rs", "pem"] } +p12-keystore = { version = "0.3.0-rc4", package = "isideload-p12-keystore" } +zip = { version = "8.3", default-features = false, features = ["deflate"] } +apple-codesign = { version = "0.29.4", package = "isideload-apple-codesign" } +sha1 = "0.11.0" + +# There is a bug in rustls-platform-verifier that causes an invalid certificate error with apple's root cert. +# It has been fixed already but I am waiting for a new release before I can update the dependency. +# Using native-tls avoids the issue. +[target.'cfg(windows)'.dependencies] +reqwest = { version = "0.13.2", features = ["json", "gzip", "native-tls"] } diff --git a/src-tauri/vendor/isideload/README.md b/src-tauri/vendor/isideload/README.md new file mode 100644 index 0000000..5db649d --- /dev/null +++ b/src-tauri/vendor/isideload/README.md @@ -0,0 +1,32 @@ +# isideload + +[![Build isideload](https://github.com/nab138/isideload/actions/workflows/build.yml/badge.svg)](https://github.com/nab138/isideload/actions/workflows/build.yml) + +A Rust library for sideloading iOS applications using an Apple ID. Used in [CrossCode](https://github.com/nab138/CrossCode) and [iloader](https://iloader.app). + +## Usage + +**You must call `isideload::init()` at the start of your program to ensure that errors are properly reported.** If you don't, errors related to network requests will not show any details. + +A full example is available is in [examples/minimal](examples/minimal/). + +## TODO + +Things left todo before the rewrite is considered finished + +- Proper entitlement handling + - actually parse macho files and stuff, right now it just uses the bare minimum and applies extra entitlements for livecontainer +- Reduce duplicate dependencies + - partially just need to wait for the rust crypto ecosystem to get through another release cycle +- More parallelism and caching for better performance + +## Licensing + +This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. + +## Credits + +- The [idevice](https://github.com/jkcoxson/idevice) crate is used to communicate with the device +- A [modified version of apple-platform-rs](https://github.com/nab138/isideload-apple-platform-rs) was used for codesigning, based off [plume-apple-platform-rs](https://github.com/plumeimpactor/plume-apple-platform-rs) +- [Impactor](https://github.com/khcrysalis/Impactor) was used as a reference for cryptography, codesigning, and provision file parsing. +- [Sideloader](https://github.com/Dadoum/Sideloader) was used as a reference for how apple private developer endpoints work diff --git a/src-tauri/vendor/isideload/src/anisette/mod.rs b/src-tauri/vendor/isideload/src/anisette/mod.rs new file mode 100644 index 0000000..bd3701a --- /dev/null +++ b/src-tauri/vendor/isideload/src/anisette/mod.rs @@ -0,0 +1,164 @@ +pub mod remote_v3; + +use crate::auth::grandslam::GrandSlam; +use plist::Dictionary; +use plist_macro::plist; +use reqwest::header::HeaderMap; +use rootcause::prelude::*; +use serde::Deserialize; +use std::{collections::HashMap, sync::Arc, time::SystemTime}; +use tokio::sync::RwLock; +use tracing::warn; + +#[derive(Deserialize, Debug, Clone)] +pub struct AnisetteClientInfo { + pub client_info: String, + pub user_agent: String, +} + +#[derive(Debug, Clone)] +pub struct AnisetteData { + machine_id: String, + one_time_password: String, + pub routing_info: String, + _device_description: String, + device_unique_identifier: String, + _local_user_id: String, + generated_at: SystemTime, +} + +// Some headers don't seem to be required. I guess not including them is technically more efficient soooo +impl AnisetteData { + pub fn get_headers(&self) -> HashMap { + //let dt: DateTime = Utc::now().round_subsecs(0); + + HashMap::from_iter([ + // ( + // "X-Apple-I-Client-Time".to_string(), + // dt.format("%+").to_string().replace("+00:00", "Z"), + // ), + // ("X-Apple-I-SRL-NO".to_string(), serial), + // ("X-Apple-I-TimeZone".to_string(), "UTC".to_string()), + // ("X-Apple-Locale".to_string(), "en_US".to_string()), + // ("X-Apple-I-MD-RINFO".to_string(), self.routing_info.clone()), + // ("X-Apple-I-MD-LU".to_string(), self.local_user_id.clone()), + ( + "X-Mme-Device-Id".to_string(), + self.device_unique_identifier.clone(), + ), + ("X-Apple-I-MD".to_string(), self.one_time_password.clone()), + ("X-Apple-I-MD-M".to_string(), self.machine_id.clone()), + // ( + // "X-Mme-Client-Info".to_string(), + // self.device_description.clone(), + // ), + ]) + } + + pub fn get_header_map(&self) -> Result { + let headers_map = self.get_headers(); + let mut header_map = HeaderMap::new(); + + for (key, value) in headers_map { + header_map.insert( + reqwest::header::HeaderName::from_bytes(key.as_bytes())?, + reqwest::header::HeaderValue::from_str(&value)?, + ); + } + + Ok(header_map) + } + + pub fn get_client_provided_data(&self) -> Dictionary { + let headers = self.get_headers(); + + let mut cpd = plist!(dict { + "bootstrap": "true", + "icscrec": "true", + "loc": "en_US", + "pbe": "false", + "prkgen": "true", + "svct": "iCloud" + }); + + for (key, value) in headers { + cpd.insert(key.to_string(), plist::Value::String(value)); + } + + cpd + } + + pub fn needs_refresh(&self) -> bool { + let elapsed = self.generated_at.elapsed(); + match elapsed { + Ok(elapsed) => elapsed.as_secs() > 60, + Err(_) => { + warn!("Unable to determine anisette data age, treating as expired"); + true + } + } + } +} + +#[async_trait::async_trait] +pub trait AnisetteProvider { + async fn get_anisette_data(&self) -> Result; + + async fn get_client_info(&mut self) -> Result; + + async fn provision(&mut self, gs: Arc) -> Result<(), Report>; + + fn needs_provisioning(&self) -> Result; +} + +#[derive(Clone)] +pub struct AnisetteDataGenerator { + provider: Arc>, + data: Option>, +} + +impl AnisetteDataGenerator { + pub fn new(provider: Arc>) -> Self { + AnisetteDataGenerator { + provider, + data: None, + } + } + + pub async fn get_anisette_data( + &mut self, + gs: Arc, + ) -> Result, Report> { + if let Some(data) = &self.data + && !data.needs_refresh() + { + return Ok(data.clone()); + } + + // trying to avoid locking as write unless necessary to promote concurrency + let provider = self.provider.read().await; + + if provider.needs_provisioning()? { + drop(provider); + let mut provider_write = self.provider.write().await; + provider_write.provision(gs).await?; + drop(provider_write); + + let provider = self.provider.read().await; + let data = provider.get_anisette_data().await?; + let arc_data = Arc::new(data); + self.data = Some(arc_data.clone()); + Ok(arc_data) + } else { + let data = provider.get_anisette_data().await?; + let arc_data = Arc::new(data); + self.data = Some(arc_data.clone()); + Ok(arc_data) + } + } + + pub async fn get_client_info(&self) -> Result { + let mut provider = self.provider.write().await; + provider.get_client_info().await + } +} diff --git a/src-tauri/vendor/isideload/src/anisette/remote_v3/mod.rs b/src-tauri/vendor/isideload/src/anisette/remote_v3/mod.rs new file mode 100644 index 0000000..7c26d75 --- /dev/null +++ b/src-tauri/vendor/isideload/src/anisette/remote_v3/mod.rs @@ -0,0 +1,406 @@ +mod state; + +use std::sync::Arc; +use std::time::SystemTime; + +use base64::prelude::*; +use plist_macro::plist; +use reqwest::header::{CONTENT_TYPE, HeaderMap, HeaderValue}; +use rootcause::option_ext::OptionExt; +use rootcause::prelude::*; +use serde::Deserialize; +use tokio::time::{Duration, timeout}; +use tokio_tungstenite::tungstenite::Message; +use tracing::{debug, info, warn}; + +use crate::SideloadError; +use crate::anisette::remote_v3::state::AnisetteState; +use crate::anisette::{AnisetteClientInfo, AnisetteData, AnisetteProvider}; +use crate::auth::grandslam::GrandSlam; +use crate::util::plist::PlistDataExtract; +use crate::util::storage::{SideloadingStorage, new_storage}; +use futures_util::{SinkExt, StreamExt}; + +pub const DEFAULT_ANISETTE_V3_URL: &str = "https://ani.stikstore.app"; + +pub struct RemoteV3AnisetteProvider { + pub state: Option, + url: String, + storage: Box, + serial_number: String, + client_info: Option, + client: reqwest::Client, +} + +impl RemoteV3AnisetteProvider { + /// Create a new RemoteV3AnisetteProvider with the given URL and config path + /// + /// # Arguments + /// - `url`: The URL of the remote anisette service + /// - `storage`: The storage backend for anisette data + /// - `serial_number`: The serial number of the device + pub fn new( + url: &str, + storage: Box, + serial_number: String, + ) -> Result { + Ok(Self { + state: None, + url: url.to_string(), + storage, + serial_number, + client_info: None, + client: reqwest::ClientBuilder::new() + .build() + .context("Failed to build HTTP client")?, + }) + } + + pub fn default() -> Result { + Self::new( + DEFAULT_ANISETTE_V3_URL, + Box::new(new_storage()), + "0".to_string(), + ) + } + + pub fn set_url(mut self, url: &str) -> RemoteV3AnisetteProvider { + self.url = url.to_string(); + self + } + + pub fn set_storage(mut self, storage: Box) -> RemoteV3AnisetteProvider { + self.storage = storage; + self + } + + pub fn set_serial_number(mut self, serial_number: String) -> RemoteV3AnisetteProvider { + self.serial_number = serial_number; + self + } +} + +#[async_trait::async_trait] +impl AnisetteProvider for RemoteV3AnisetteProvider { + async fn get_anisette_data(&self) -> Result { + let state = self + .state + .as_ref() + .ok_or(SideloadError::AnisetteNotProvisioned)?; + let adi_pb = state + .adi_pb + .as_ref() + .ok_or(SideloadError::AnisetteNotProvisioned)?; + let client_info = self + .client_info + .as_ref() + .ok_or(SideloadError::AnisetteNotProvisioned)?; + + let headers = self + .client + .post(format!("{}/v3/get_headers", self.url)) + .header(CONTENT_TYPE, "application/json") + .body( + serde_json::json!({ + "identifier": BASE64_STANDARD.encode(state.keychain_identifier), + "adi_pb": BASE64_STANDARD.encode(adi_pb) + }) + .to_string(), + ) + .send() + .await? + .error_for_status()? + .json::() + .await?; + + match headers { + AnisetteHeaders::Headers { + machine_id, + one_time_password, + routing_info, + } => { + let data = AnisetteData { + machine_id, + one_time_password, + routing_info, + _device_description: client_info.client_info.clone(), + device_unique_identifier: state.get_device_id(), + _local_user_id: hex::encode(state.get_md_lu()), + generated_at: SystemTime::now(), + }; + + Ok(data) + } + AnisetteHeaders::GetHeadersError { message } => { + Err(report!("Failed to get anisette headers").attach(message)) + } + } + } + + async fn get_client_info(&mut self) -> Result { + match self.client_info { + Some(ref info) => Ok(info.clone()), + None => { + let resp = self + .client + .get(format!("{}/v3/client_info", self.url)) + .send() + .await? + .error_for_status()? + .json::() + .await?; + + self.client_info = Some(resp.clone()); + Ok(resp) + } + } + } + + fn needs_provisioning(&self) -> Result { + if let Some(state) = &self.state { + Ok(!state.is_provisioned() || self.client_info.is_none()) + } else { + Ok(true) + } + } + + async fn provision(&mut self, gs: Arc) -> Result<(), Report> { + self.get_client_info().await?; + self.get_state(gs).await?; + Ok(()) + } +} + +impl RemoteV3AnisetteProvider { + async fn get_state(&mut self, gs: Arc) -> Result<&mut AnisetteState, Report> { + if self.state.is_none() { + if let Ok(Some(state)) = &self.storage.retrieve_data("anisette_state") { + if let Ok(state) = plist::from_bytes(state) { + info!("Loaded existing anisette state"); + self.state = Some(state); + } else { + warn!("Failed to parse existing anisette state, starting fresh"); + self.state = Some(AnisetteState::new()); + } + } else { + info!("No existing anisette state found"); + self.state = Some(AnisetteState::new()); + } + } + + let state = self.state.as_mut().ok_or_report()?; + if !state.is_provisioned() { + info!("Provisioning required..."); + Self::provision(state, gs, &self.url) + .await + .context("Failed to provision")?; + } + let buf = Vec::new(); + let mut writer = std::io::BufWriter::new(buf); + plist::to_writer_xml(&mut writer, &state)?; + self.storage + .store_data("anisette_state", &writer.into_inner()?)?; + + Ok(state) + } + + async fn provisioning_headers(state: &AnisetteState) -> Result { + let mut headers = HeaderMap::new(); + headers.insert( + "X-Apple-I-MD-LU", + HeaderValue::from_str(&hex::encode(state.get_md_lu()))?, + ); + // headers.insert( + // "X-Apple-I-Client-Time", + // HeaderValue::from_str( + // &Utc::now() + // .round_subsecs(0) + // .format("%+") + // .to_string() + // .replace("+00:00", "Z"), + // )?, + // ); + // headers.insert("X-Apple-I-TimeZone", HeaderValue::from_static("UTC")); + // headers.insert("X-Apple-Locale", HeaderValue::from_static("en_US")); + headers.insert( + "X-Mme-Device-Id", + HeaderValue::from_str(&state.get_device_id())?, + ); + + Ok(headers) + } + async fn provision( + state: &mut AnisetteState, + gs: Arc, + url: &str, + ) -> Result<(), Report> { + let start_provisioning = gs.get_url("midStartProvisioning")?; + let end_provisioning = gs.get_url("midFinishProvisioning")?; + + let websocket_url = format!("{}/v3/provisioning_session", url) + .replace("https://", "wss://") + .replace("http://", "ws://"); + + debug!("Starting provisioning at {}", websocket_url); + let (mut ws_stream, _) = timeout( + Duration::from_secs(30), + tokio_tungstenite::connect_async(&websocket_url), + ) + .await + .map_err(|_| { + report!("Timed out connecting to provisioning socket. Try a different anisette server.") + }) + .context("Failed to connect to provisioning socket")? + .context("Failed to connect to provisioning socket")?; + + debug!("Connected to provisioning socket"); + + loop { + let Some(msg) = ws_stream.next().await else { + continue; + }; + let msg = msg.context("Failed to read anisette provisioning socket message")?; + if msg.is_close() { + bail!("Anisette provisioning socket closed unexpectedly"); + } + let msg = msg + .into_text() + .context("Failed to parse provisioning message")?; + + debug!("Received provisioning message: {}", msg); + let provision_msg: ProvisioningMessage = + serde_json::from_str(&msg).context("Unknown provisioning message")?; + + match provision_msg { + ProvisioningMessage::GiveIdentifier => { + ws_stream + .send(Message::Text( + serde_json::json!({ + "identifier": BASE64_STANDARD.encode(state.keychain_identifier), + }) + .to_string() + .into(), + )) + .await + .context("Failed to send identifier")?; + } + ProvisioningMessage::GiveStartProvisioningData => { + let body = plist!(dict { + "Header": {}, + "Request": {} + }); + + let response = gs + .plist_request( + &start_provisioning, + &body, + Some(Self::provisioning_headers(state).await?), + ) + .await + .context("Failed to send start provisioning request")?; + + let spim = response + .get_str("spim") + .context("Start provisioning response missing spim")?; + + ws_stream + .send(Message::Text( + serde_json::json!({ + "spim": spim, + }) + .to_string() + .into(), + )) + .await + .context("Failed to send start provisioning data")?; + } + ProvisioningMessage::GiveEndProvisioningData { cpim } => { + let body = plist!(dict { + "Header": {}, + "Request": { + "cpim": cpim, + } + }); + + let response = gs + .plist_request( + &end_provisioning, + &body, + Some(Self::provisioning_headers(state).await?), + ) + .await + .context("Failed to send end provisioning request")?; + + ws_stream + .send(Message::Text( + serde_json::json!({ + "ptm": response + .get_str("ptm") + .context("End provisioning response missing ptm")?, + "tk": response + .get_str("tk") + .context("End provisioning response missing tk")?, + }) + .to_string() + .into(), + )) + .await + .context("Failed to send start provisioning data")?; + } + ProvisioningMessage::ProvisioningSuccess { adi_pb } => { + state.adi_pb = Some(BASE64_STANDARD.decode(adi_pb)?); + ws_stream.close(None).await?; + info!("Provisioning successful"); + break; + } + ProvisioningMessage::Timeout => bail!("Anisette provisioning timed out"), + ProvisioningMessage::InvalidIdentifier => { + bail!("Anisette provisioning failed: invalid identifier") + } + ProvisioningMessage::StartProvisioningError { message } => { + return Err( + report!("Anisette provisioning failed: start provisioning error") + .attach(message), + ); + } + ProvisioningMessage::EndProvisioningError { message } => { + return Err( + report!("Anisette provisioning failed: end provisioning error") + .attach(message), + ); + } + } + } + + Ok(()) + } +} + +#[derive(Deserialize)] +#[serde(tag = "result")] +enum ProvisioningMessage { + GiveIdentifier, + GiveStartProvisioningData, + GiveEndProvisioningData { cpim: String }, + ProvisioningSuccess { adi_pb: String }, + Timeout, + InvalidIdentifier, + StartProvisioningError { message: String }, + EndProvisioningError { message: String }, +} + +#[derive(Deserialize)] +#[serde(tag = "result")] +enum AnisetteHeaders { + GetHeadersError { + message: String, + }, + Headers { + #[serde(rename = "X-Apple-I-MD-M")] + machine_id: String, + #[serde(rename = "X-Apple-I-MD")] + one_time_password: String, + #[serde(rename = "X-Apple-I-MD-RINFO")] + routing_info: String, + }, +} diff --git a/src-tauri/vendor/isideload/src/anisette/remote_v3/state.rs b/src-tauri/vendor/isideload/src/anisette/remote_v3/state.rs new file mode 100644 index 0000000..a2a1a07 --- /dev/null +++ b/src-tauri/vendor/isideload/src/anisette/remote_v3/state.rs @@ -0,0 +1,81 @@ +// Serialization/Desieralization borrowed from https://github.com/SideStore/apple-private-apis/blob/master/omnisette/src/remote_anisette_v3.rs + +use plist::Data; +use rand::RngExt; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +fn bin_serialize(x: &[u8], s: S) -> Result +where + S: Serializer, +{ + s.serialize_bytes(x) +} + +fn bin_serialize_opt(x: &Option>, s: S) -> Result +where + S: Serializer, +{ + x.clone().map(Data::new).serialize(s) +} + +fn bin_deserialize_opt<'de, D>(d: D) -> Result>, D::Error> +where + D: Deserializer<'de>, +{ + let s: Option = Deserialize::deserialize(d)?; + Ok(s.map(|i| i.into())) +} + +fn bin_deserialize_16<'de, D>(d: D) -> Result<[u8; 16], D::Error> +where + D: Deserializer<'de>, +{ + let s: Data = Deserialize::deserialize(d)?; + let s: Vec = s.into(); + Ok(s.try_into().unwrap()) +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct AnisetteState { + #[serde( + serialize_with = "bin_serialize", + deserialize_with = "bin_deserialize_16" + )] + pub keychain_identifier: [u8; 16], + #[serde( + serialize_with = "bin_serialize_opt", + deserialize_with = "bin_deserialize_opt" + )] + pub adi_pb: Option>, +} + +impl Default for AnisetteState { + fn default() -> Self { + AnisetteState { + keychain_identifier: rand::rng().random::<[u8; 16]>(), + adi_pb: None, + } + } +} + +impl AnisetteState { + pub fn new() -> AnisetteState { + AnisetteState::default() + } + + pub fn is_provisioned(&self) -> bool { + self.adi_pb.is_some() + } + + pub fn get_md_lu(&self) -> [u8; 32] { + let mut hasher = Sha256::new(); + hasher.update(self.keychain_identifier); + hasher.finalize().into() + } + + pub fn get_device_id(&self) -> String { + Uuid::from_bytes(self.keychain_identifier).to_string() + } +} diff --git a/src-tauri/vendor/isideload/src/auth/apple_account.rs b/src-tauri/vendor/isideload/src/auth/apple_account.rs new file mode 100644 index 0000000..137d594 --- /dev/null +++ b/src-tauri/vendor/isideload/src/auth/apple_account.rs @@ -0,0 +1,718 @@ +use std::sync::Arc; + +use crate::{ + anisette::{AnisetteData, AnisetteDataGenerator}, + auth::{ + builder::AppleAccountBuilder, + grandslam::{GrandSlam, GrandSlamErrorChecker}, + }, + util::plist::{PlistDataExtract, SensitivePlistAttachment}, +}; +use aes::{ + Aes256, + cipher::{block_padding::Pkcs7, consts::U16}, +}; +use aes_gcm::{AeadInOut, AesGcm, KeyInit, Nonce}; +use base64::{Engine, prelude::BASE64_STANDARD}; +use cbc::cipher::{BlockModeDecrypt, KeyIvInit}; +use hmac::{Hmac, Mac}; +use plist::Dictionary; +use plist_macro::plist; +use reqwest::header::{HeaderMap, HeaderValue}; +use rootcause::prelude::*; +use sha2::{Digest, Sha256}; +use srp::{ClientVerifier, groups::G2048}; +use tracing::{debug, info, warn}; + +pub struct AppleAccount { + pub email: String, + pub spd: Option, + pub anisette_generator: AnisetteDataGenerator, + pub grandslam_client: Arc, + login_state: LoginState, + debug: bool, +} + +#[derive(Debug)] +pub enum LoginState { + LoggedIn, + NeedsDevice2FA, + NeedsSMS2FA, + NeedsExtraStep(String), + NeedsLogin, +} + +impl AppleAccount { + /// Create a new AppleAccountBuilder with the given email + /// + /// # Arguments + /// - `email`: The Apple ID email address + pub fn builder(email: &str) -> AppleAccountBuilder { + AppleAccountBuilder::new(email) + } + + /// Build the apple account with the given email + /// + /// Reccomended to use the AppleAccountBuilder instead + /// # Arguments + /// - `email`: The Apple ID email address + /// - `anisette_provider`: The anisette provider to use + /// - `debug`: DANGER, If true, accept invalid certificates and enable verbose connection + pub async fn new( + email: &str, + anisette_generator: AnisetteDataGenerator, + debug: bool, + ) -> Result { + if debug { + warn!("Debug mode enabled: this is a security risk!"); + } + + let client_info = anisette_generator + .get_client_info() + .await + .context("Failed to get anisette client info")?; + + let grandslam_client = GrandSlam::new(client_info, debug).await?; + + Ok(AppleAccount { + email: email.to_string(), + spd: None, + anisette_generator, + grandslam_client: Arc::new(grandslam_client), + debug, + login_state: LoginState::NeedsLogin, + }) + } + + /// Log in to the Apple ID account + /// # Arguments + /// - `password`: The Apple ID password + /// - `two_factor_callback`: A callback function that returns the two-factor authentication code + /// # Errors + /// Returns an error if the login fails + pub async fn login( + &mut self, + password: &str, + two_factor_callback: impl Fn() -> Option, + ) -> Result<(), Report> { + info!("Logging in to Apple ID: {}", censor_email(&self.email)); + if self.debug { + warn!("Debug mode enabled: this is a security risk!"); + } + + self.login_state = self + .login_inner(password) + .await + .context("Failed to log in to Apple ID")?; + + debug!("Initial login successful"); + + let mut attempts = 0; + + loop { + attempts += 1; + if attempts > 10 { + bail!( + "Couldn't login after 10 attempts, aborting (current state: {:?})", + self.login_state + ); + } + match &self.login_state { + LoginState::LoggedIn => { + info!("Successfully logged in to Apple ID"); + return Ok(()); + } + LoginState::NeedsDevice2FA => { + self.trusted_device_2fa(&two_factor_callback) + .await + .context("Failed to complete trusted device 2FA")?; + debug!("Trusted device 2FA completed, need to login again"); + self.login_state = LoginState::NeedsLogin; + } + LoginState::NeedsSMS2FA => { + info!("SMS 2FA required"); + self.sms_2fa(&two_factor_callback) + .await + .context("Failed to complete SMS 2FA")?; + debug!("SMS 2FA completed, need to login again"); + self.login_state = LoginState::NeedsLogin; + } + LoginState::NeedsExtraStep(s) => { + info!("Additional authentication step required: {}", s); + if self.get_pet().is_err() { + bail!("Additional authentication required: {}", s); + } + self.login_state = LoginState::LoggedIn; + } + LoginState::NeedsLogin => { + debug!("Logging in again..."); + self.login_state = self + .login_inner(password) + .await + .context("Failed to login again")?; + } + } + } + } + + /// Get the user's first and last name associated with the Apple ID + pub fn get_name(&self) -> Result<(String, String), Report> { + let spd = self + .spd + .as_ref() + .ok_or_else(|| report!("SPD not available, cannot get name"))?; + + Ok((spd.get_string("fn")?, spd.get_string("ln")?)) + } + + fn get_pet(&self) -> Result { + let spd = self + .spd + .as_ref() + .ok_or_else(|| report!("SPD not available, cannot get pet"))?; + + let pet = spd + .get_dict("t")? + .get_dict("com.apple.gs.idms.pet")? + .get_string("token")?; + + Ok(pet) + } + + async fn trusted_device_2fa( + &mut self, + two_factor_callback: impl Fn() -> Option, + ) -> Result<(), Report> { + debug!("Trusted device 2FA required"); + + let anisette_data = self + .anisette_generator + .get_anisette_data(self.grandslam_client.clone()) + .await + .context("Failed to get anisette data for 2FA")?; + + let request_code_url = self + .grandslam_client + .get_url("trustedDeviceSecondaryAuth")?; + + let submit_code_url = self.grandslam_client.get_url("validateCode")?; + + self.grandslam_client + .get(&request_code_url)? + .headers(self.build_2fa_headers(&anisette_data).await?) + .send() + .await + .context("Failed to request trusted device 2fa")? + .error_for_status() + .context("Trusted device 2FA request failed")?; + + info!("Trusted device 2FA request sent"); + + let code = + two_factor_callback().ok_or_else(|| report!("No 2FA code provided, aborting"))?; + + let res = self + .grandslam_client + .get(&submit_code_url)? + .headers(self.build_2fa_headers(&anisette_data).await?) + .header("security-code", code) + .send() + .await + .context("Failed to submit trusted device 2fa code")? + .error_for_status() + .context("Trusted device 2FA code submission failed")? + .text() + .await + .context("Failed to read trusted device 2FA response text")?; + + let plist: Dictionary = plist::from_bytes(res.as_bytes()) + .context("Failed to parse trusted device response plist") + .attach_with(|| res.clone())?; + plist + .check_grandslam_error() + .context("Trusted device 2FA rejected")?; + + Ok(()) + } + + async fn sms_2fa( + &mut self, + two_factor_callback: impl Fn() -> Option, + ) -> Result<(), Report> { + debug!("SMS 2FA required"); + + let anisette_data = self + .anisette_generator + .get_anisette_data(self.grandslam_client.clone()) + .await + .context("Failed to get anisette data for 2FA")?; + + let request_code_url = self.grandslam_client.get_url("secondaryAuth")?; + + self.grandslam_client + .get_sms(&request_code_url)? + .headers(self.build_2fa_headers(&anisette_data).await?) + .send() + .await + .context("Failed to request SMS 2FA")? + .error_for_status() + .context("SMS 2FA request failed")?; + + info!("SMS 2FA request sent"); + + let code = + two_factor_callback().ok_or_else(|| report!("No 2FA code provided, aborting"))?; + + let body = serde_json::json!({ + "securityCode": { + "code": code + }, + "phoneNumber": { + "id": 1 + }, + "mode": "sms" + }); + + let mut headers = self.build_2fa_headers(&anisette_data).await?; + headers.insert("Content-Type", HeaderValue::from_static("application/json")); + headers.insert( + "Accept", + HeaderValue::from_static("application/json, text/javascript, */*; q=0.01"), + ); + + let res = self + .grandslam_client + .post("https://gsa.apple.com/auth/verify/phone/securitycode")? + .headers(headers) + .body(body.to_string()) + .send() + .await + .context("Failed to submit SMS 2FA code")?; + + if !res.status().is_success() { + let status = res.status(); + let text = res + .text() + .await + .context("Failed to read SMS 2FA error response text")?; + // try to parse as json, if it fails, just bail with the text + if let Ok(json) = serde_json::from_str::(&text) + && let Some(service_errors) = json.get("serviceErrors") + && let Some(first_error) = service_errors.as_array().and_then(|arr| arr.first()) + { + let code = first_error + .get("code") + .and_then(|c| c.as_str()) + .unwrap_or("unknown"); + let title = first_error + .get("title") + .and_then(|t| t.as_str()) + .unwrap_or("No title provided"); + let message = first_error + .get("message") + .and_then(|m| m.as_str()) + .unwrap_or("No message provided"); + bail!( + "SMS 2FA code submission failed (code {}): {} - {}", + code, + title, + message + ); + } + bail!( + "SMS 2FA code submission failed with http status {}: {}", + status, + text + ); + }; + + Ok(()) + } + + async fn build_2fa_headers(&self, anisette_data: &AnisetteData) -> Result { + let mut headers = anisette_data.get_header_map()?; + + let spd = self + .spd + .as_ref() + .ok_or_else(|| report!("SPD data not available, cannot build 2FA headers"))?; + + let adsid = spd + .get_str("adsid") + .context("Failed to build 2FA headers")?; + let token = spd + .get_str("GsIdmsToken") + .context("Failed to build 2FA headers")?; + let identity = BASE64_STANDARD.encode(format!("{}:{}", adsid, token)); + + headers.insert( + "X-Apple-Identity-Token", + reqwest::header::HeaderValue::from_str(&identity)?, + ); + headers.insert( + "X-Apple-I-MD-RINFO", + reqwest::header::HeaderValue::from_str(&anisette_data.routing_info)?, + ); + + Ok(headers) + } + + async fn login_inner(&mut self, password: &str) -> Result { + let anisette_data = self + .anisette_generator + .get_anisette_data(self.grandslam_client.clone()) + .await + .context("Failed to get anisette data for login")?; + + let gs_service_url = self.grandslam_client.get_url("gsService")?; + debug!("GrandSlam service URL: {}", gs_service_url); + + let cpd = anisette_data.get_client_provided_data(); + + let srp_client = srp::Client::::new_with_options(false); + let a: Vec = (0..32).map(|_| rand::random::()).collect(); + let a_pub = srp_client.compute_public_ephemeral(&a); + + let req1 = plist!(dict { + "Header": { + "Version": "1.0.1" + }, + "Request": { + "A2k": a_pub, // A2k = client public ephemeral + "cpd": cpd.clone(), // cpd = client provided data + "o": "init", // o = operation + "ps": [ // ps = protocols supported + "s2k", + "s2k_fo" + ], + "u": self.email.clone(), // u = username + } + }); + + debug!("Sending initial login request"); + + let response = self + .grandslam_client + .plist_request(&gs_service_url, &req1, None) + .await + .context("Failed to send initial login request")? + .check_grandslam_error() + .context("GrandSlam error during initial login request")?; + + debug!("Login step 1 completed"); + + let salt = response + .get_data("s") + .context("Failed to parse initial login response")?; + let b_pub = response + .get_data("B") + .context("Failed to parse initial login response")?; + let iters = response + .get_signed_integer("i") + .context("Failed to parse initial login response")?; + let c = response + .get_str("c") + .context("Failed to parse initial login response")?; + let selected_protocol = response + .get_str("sp") + .context("Failed to parse initial login response")?; + + debug!( + "Selected SRP protocol: {}, iterations: {}", + selected_protocol, iters + ); + + if selected_protocol != "s2k" && selected_protocol != "s2k_fo" { + bail!("Unsupported SRP protocol selected: {}", selected_protocol); + } + + let hashed_password = Sha256::digest(password.as_bytes()); + + let password_hash = if selected_protocol == "s2k_fo" { + hex::encode(hashed_password).into_bytes() + } else { + hashed_password.to_vec() + }; + + let mut password_buf = [0u8; 32]; + pbkdf2::pbkdf2::>(&password_hash, salt, iters as u32, &mut password_buf) + .context("Failed to derive password using PBKDF2")?; + + let verifier = srp_client + .process_reply(&a, self.email.as_bytes(), &password_buf, salt, b_pub) + .context("Failed to compute SRP proof")?; + + let req2 = plist!(dict { + "Header": { + "Version": "1.0.1" + }, + "Request": { + "M1": verifier.proof().to_vec(), // A2k = client public ephemeral + "c": c, // c = client proof from step 1 + "cpd": cpd, // cpd = client provided data + "o": "complete", // o = operation + "u": self.email.clone(), // u = username + } + }); + + debug!("Sending proof login request"); + + let mut close_headers = HeaderMap::new(); + close_headers.insert("Connection", HeaderValue::from_static("close")); + + let response2 = self + .grandslam_client + .plist_request(&gs_service_url, &req2, Some(close_headers)) + .await + .context("Failed to send proof login request")? + .check_grandslam_error() + .context("GrandSlam error during proof login request")?; + + debug!("Login step 2 response received, verifying server proof"); + + let m2 = response2 + .get_data("M2") + .context("Failed to parse proof login response")?; + verifier + .verify_server(m2) + .map_err(|e| report!("Negotiation failed, server proof mismatch: {}", e))?; + + debug!("Server proof verified"); + + let spd_encrypted = response2 + .get_data("spd") + .context("Failed to get SPD from login response")?; + + let spd_decrypted = Self::decrypt_cbc(&verifier, spd_encrypted) + .context("Failed to decrypt SPD from login response")?; + let spd: plist::Dictionary = + plist::from_bytes(&spd_decrypted).context("Failed to parse decrypted SPD plist")?; + + self.spd = Some(spd); + + let status = response2 + .get_dict("Status") + .context("Failed to parse proof login response")?; + + debug!("Login step 2 completed"); + + if let Some(plist::Value::String(s)) = status.get("au") { + return Ok(match s.as_str() { + "trustedDeviceSecondaryAuth" => LoginState::NeedsDevice2FA, + "secondaryAuth" => LoginState::NeedsSMS2FA, + "repair" => LoginState::LoggedIn, // Just means that you don't have 2FA set up + unknown => LoginState::NeedsExtraStep(unknown.to_string()), + }); + } + + Ok(LoginState::LoggedIn) + } + + pub async fn get_app_token(&mut self, app: &str) -> Result { + let app = if app.contains("com.apple.gs.") { + app.to_string() + } else { + format!("com.apple.gs.{}", app) + }; + + let anisette_data = self + .anisette_generator + .get_anisette_data(self.grandslam_client.clone()) + .await + .context("Failed to get anisette data for login")?; + + let spd = self + .spd + .as_ref() + .ok_or_else(|| report!("SPD data not available, cannot get app token"))?; + + let dsid = spd.get_str("adsid").context("Failed to get app token")?; + let auth_token = spd + .get_str("GsIdmsToken") + .context("Failed to get app token")?; + let session_key = spd.get_data("sk").context("Failed to get app token")?; + let c = spd.get_data("c").context("Failed to get app token")?; + + let checksum = Hmac::::new_from_slice(session_key) + .context("Failed to create HMAC for app token checksum") + .attach_with(|| SensitivePlistAttachment::new(spd.clone()))? + .chain_update("apptokens".as_bytes()) + .chain_update(dsid.as_bytes()) + .chain_update(app.as_bytes()) + .finalize() + .into_bytes() + .to_vec(); + + let gs_service_url = self.grandslam_client.get_url("gsService")?; + let cpd = anisette_data.get_client_provided_data(); + + let request = plist!(dict { + "Header": { + "Version": "1.0.1" + }, + "Request": { + "app": [app.clone()], + "c": c, + "checksum": checksum, + "cpd": cpd, + "o": "apptokens", + "u": dsid, + "t": auth_token + } + }); + + let resp = self + .grandslam_client + .plist_request(&gs_service_url, &request, None) + .await + .context("Failed to send app token request")? + .check_grandslam_error() + .context("GrandSlam error during app token request")?; + + let encrypted_token = resp + .get_data("et") + .context("Failed to get encrypted token")?; + + debug!("Acquired encrypted token for {}", app); + let decrypted_token = Self::decrypt_gcm(encrypted_token, session_key) + .context("Failed to decrypt app token")?; + debug!("Decrypted app token for {}", app); + + let token: Dictionary = plist::from_bytes(&decrypted_token) + .context("Failed to parse decrypted app token plist")?; + + let status = token + .get_signed_integer("status-code") + .context("Failed to get status code from app token")?; + if status != 200 { + bail!("App token request failed with status code {}", status); + } + let token_dict = token + .get_dict("t") + .context("Failed to get token dictionary from app token")?; + let app_token = token_dict + .get_dict(&app) + .context("Failed to get app token string")?; + + let app_token = AppToken { + token: app_token + .get_str("token") + .context("Failed to get app token string")? + .to_string(), + duration: app_token + .get_signed_integer("duration") + .context("Failed to get app token duration")? as u64, + expiry: app_token + .get_signed_integer("expiry") + .context("Failed to get app token expiry")? as u64, + }; + + info!("Successfully retrieved app token for {}", app); + + Ok(app_token) + } + + fn create_session_key(usr: &ClientVerifier, name: &str) -> Result, Report> { + Ok(Hmac::::new_from_slice(usr.key())? + .chain_update(name.as_bytes()) + .finalize() + .into_bytes() + .to_vec()) + } + + fn decrypt_cbc(usr: &ClientVerifier, data: &[u8]) -> Result, Report> { + let extra_data_key = Self::create_session_key(usr, "extra data key:")?; + let extra_data_iv = Self::create_session_key(usr, "extra data iv:")?; + let extra_data_iv = &extra_data_iv[..16]; + + Ok( + cbc::Decryptor::::new_from_slices(&extra_data_key, extra_data_iv)? + .decrypt_padded_vec::(data)?, + ) + } + + fn decrypt_gcm(data: &[u8], key: &[u8]) -> Result, Report> { + if data.len() < 3 + 16 + 16 { + bail!( + "Encrypted token is too short to be valid (only {} bytes)", + data.len() + ); + } + let header = &data[0..3]; + if header != b"XYZ" { + bail!( + "Encrypted token is in an unknown format: {}", + String::from_utf8_lossy(header) + ); + } + let iv = &data[3..19]; + let ciphertext_and_tag = &data[19..]; + + if key.len() != 32 { + bail!("Session key is not the correct length: {} bytes", key.len()); + } + if iv.len() != 16 { + bail!("IV is not the correct length: {} bytes", iv.len()); + } + + debug!( + "Decrypting GCM data with key of length {} and IV of length {}", + key.len(), + iv.len() + ); + let key = aes_gcm::Key::>::try_from(key)?; + debug!("GCM key created successfully"); + let cipher = AesGcm::::new(&key); + debug!("GCM cipher initialized successfully"); + let nonce = Nonce::::try_from(iv)?; + debug!("GCM nonce created successfully"); + + let mut buf = ciphertext_and_tag.to_vec(); + + cipher + .decrypt_in_place(&nonce, header, &mut buf) + .map_err(|e| report!("Failed to decrypt gcm: {}", e))?; + debug!("GCM decryption successful"); + + Ok(buf) + } +} + +impl std::fmt::Display for AppleAccount { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Apple Account: ")?; + match self.get_name() { + Ok((first, last)) => write!(f, "{} {} ", first, last), + Err(_) => Ok(()), + }?; + write!(f, "{} ({:?})", self.email, self.login_state) + } +} + +#[derive(Debug, Clone)] +pub struct AppToken { + pub token: String, + pub duration: u64, + pub expiry: u64, +} + +fn censor_email(email: &str) -> String { + if std::env::var("DEBUG_SENSITIVE").is_ok() { + return email.to_string(); + } + if let Some(at_pos) = email.find('@') { + let (local, domain) = email.split_at(at_pos); + if local.len() <= 2 { + format!("{}***{}", &local[0..1], &domain) + } else { + format!( + "{}***{}{}", + &local[0..1], + &local[local.len() - 1..], + &domain + ) + } + } else { + "***".to_string() + } +} diff --git a/src-tauri/vendor/isideload/src/auth/apple_root.der b/src-tauri/vendor/isideload/src/auth/apple_root.der new file mode 100644 index 0000000..8a9ff24 Binary files /dev/null and b/src-tauri/vendor/isideload/src/auth/apple_root.der differ diff --git a/src-tauri/vendor/isideload/src/auth/builder.rs b/src-tauri/vendor/isideload/src/auth/builder.rs new file mode 100644 index 0000000..0962335 --- /dev/null +++ b/src-tauri/vendor/isideload/src/auth/builder.rs @@ -0,0 +1,85 @@ +use std::sync::Arc; + +use rootcause::prelude::*; +use tokio::sync::RwLock; + +use crate::{ + anisette::{AnisetteDataGenerator, AnisetteProvider, remote_v3::RemoteV3AnisetteProvider}, + auth::apple_account::AppleAccount, +}; + +pub struct AppleAccountBuilder { + email: String, + debug: Option, + anisette_generator: Option, +} + +impl AppleAccountBuilder { + /// Create a new AppleAccountBuilder with the given email + /// + /// # Arguments + /// - `email`: The Apple ID email address + pub fn new(email: &str) -> Self { + Self { + email: email.to_string(), + debug: None, + anisette_generator: None, + } + } + + /// DANGER Set whether to enable debug mode + /// + /// # Arguments + /// - `debug`: If true, accept invalid certificates and enable verbose connection logging + pub fn danger_debug(mut self, debug: bool) -> Self { + self.debug = Some(debug); + self + } + + pub fn anisette_provider( + mut self, + anisette_provider: impl AnisetteProvider + Send + Sync + 'static, + ) -> Self { + self.anisette_generator = Some(AnisetteDataGenerator::new(Arc::new(RwLock::new( + anisette_provider, + )))); + self + } + + /// Build the AppleAccount without logging in + /// + /// # Errors + /// Returns an error if the reqwest client cannot be built + pub async fn build(self) -> Result { + let debug = self.debug.unwrap_or(false); + let anisette_generator = match self.anisette_generator { + Some(generator) => generator, + None => { + let provider = RemoteV3AnisetteProvider::default()?; + AnisetteDataGenerator::new(Arc::new(RwLock::new(provider))) + } + }; + + AppleAccount::new(&self.email, anisette_generator, debug).await + } + + /// Build the AppleAccount and log in + /// + /// # Arguments + /// - `password`: The Apple ID password + /// - `two_factor_callback`: A callback function that returns the two-factor authentication code + /// # Errors + /// Returns an error if the reqwest client cannot be built + pub async fn login( + self, + password: &str, + two_factor_callback: F, + ) -> Result + where + F: Fn() -> Option, + { + let mut account = self.build().await?; + account.login(password, two_factor_callback).await?; + Ok(account) + } +} diff --git a/src-tauri/vendor/isideload/src/auth/grandslam.rs b/src-tauri/vendor/isideload/src/auth/grandslam.rs new file mode 100644 index 0000000..1876812 --- /dev/null +++ b/src-tauri/vendor/isideload/src/auth/grandslam.rs @@ -0,0 +1,209 @@ +use plist::Dictionary; +use plist_macro::plist_to_xml_string; +use plist_macro::pretty_print_dictionary; +use reqwest::{ + Certificate, ClientBuilder, + header::{HeaderMap, HeaderValue}, +}; +use rootcause::prelude::*; +use tracing::debug; + +use crate::{SideloadError, anisette::AnisetteClientInfo, util::plist::PlistDataExtract}; + +const APPLE_ROOT: &[u8] = include_bytes!("./apple_root.der"); +const URL_BAG: &str = "https://gsa.apple.com/grandslam/GsService2/lookup"; + +pub struct GrandSlam { + pub client: reqwest::Client, + pub client_info: AnisetteClientInfo, + url_bag: Dictionary, +} + +impl GrandSlam { + /// Create a new GrandSlam instance + /// + /// # Arguments + /// - `client`: The reqwest client to use for requests + pub async fn new(client_info: AnisetteClientInfo, debug: bool) -> Result { + let client = Self::build_reqwest_client(debug).context("Failed to build HTTP client")?; + let base_headers = Self::base_headers(&client_info, false)?; + let url_bag = Self::fetch_url_bag(&client, base_headers).await?; + Ok(Self { + client, + client_info, + url_bag, + }) + } + + /// Fetch the URL bag from GrandSlam and cache it + pub async fn fetch_url_bag( + client: &reqwest::Client, + base_headers: HeaderMap, + ) -> Result { + debug!("Fetching URL bag from GrandSlam"); + let resp = client + .get(URL_BAG) + .headers(base_headers) + .send() + .await + .context("Failed to fetch URL Bag")? + .text() + .await + .context("Failed to read URL Bag response text")?; + + let dict: Dictionary = + plist::from_bytes(resp.as_bytes()).context("Failed to parse URL Bag plist")?; + let urls = dict + .get("urls") + .and_then(|v| v.as_dictionary()) + .cloned() + .ok_or_else(|| report!("URL Bag plist missing 'urls' dictionary"))?; + + Ok(urls) + } + + pub fn get_url(&self, key: &str) -> Result { + let url = self + .url_bag + .get_string(key) + .context("Unable to find key in URL bag")?; + Ok(url) + } + + pub fn get(&self, url: &str) -> Result { + let builder = self + .client + .get(url) + .headers(Self::base_headers(&self.client_info, false)?); + + Ok(builder) + } + + pub fn get_sms(&self, url: &str) -> Result { + let builder = self + .client + .get(url) + .headers(Self::base_headers(&self.client_info, true)?); + + Ok(builder) + } + + pub fn post(&self, url: &str) -> Result { + let builder = self + .client + .post(url) + .headers(Self::base_headers(&self.client_info, false)?); + + Ok(builder) + } + + pub fn patch(&self, url: &str) -> Result { + let builder = self + .client + .patch(url) + .headers(Self::base_headers(&self.client_info, false)?); + + Ok(builder) + } + + pub async fn plist_request( + &self, + url: &str, + body: &Dictionary, + additional_headers: Option, + ) -> Result { + let resp = self + .post(url)? + .headers(additional_headers.unwrap_or_else(reqwest::header::HeaderMap::new)) + .body(plist_to_xml_string(body)) + .send() + .await + .context("Failed to send grandslam request")? + .error_for_status() + .context("Received error response from grandslam")? + .text() + .await + .context("Failed to read grandslam response as text")?; + + let dict: Dictionary = plist::from_bytes(resp.as_bytes()) + .context("Failed to parse grandslam response plist") + .attach_with(|| resp.clone())?; + + let response_plist = dict + .get("Response") + .and_then(|v| v.as_dictionary()) + .cloned() + .ok_or_else(|| { + report!("grandslam response missing 'Response'") + .attach(pretty_print_dictionary(&dict)) + })?; + + Ok(response_plist) + } + + fn base_headers( + client_info: &AnisetteClientInfo, + sms: bool, + ) -> Result { + let mut headers = reqwest::header::HeaderMap::new(); + if !sms { + headers.insert("Content-Type", HeaderValue::from_static("text/x-xml-plist")); + headers.insert("Accept", HeaderValue::from_static("text/x-xml-plist")); + } + headers.insert( + "X-Mme-Client-Info", + HeaderValue::from_str(&client_info.client_info)?, + ); + headers.insert( + "User-Agent", + HeaderValue::from_str(&client_info.user_agent)?, + ); + headers.insert("X-Xcode-Version", HeaderValue::from_static("14.2 (14C18)")); + headers.insert( + "X-Apple-App-Info", + HeaderValue::from_static("com.apple.gs.xcode.auth"), + ); + + Ok(headers) + } + + /// Build a reqwest client with the Apple root certificate + /// + /// # Arguments + /// - `debug`: DANGER, If true, accept invalid certificates and enable verbose connection logging + /// # Errors + /// Returns an error if the reqwest client cannot be built + pub fn build_reqwest_client(debug: bool) -> Result { + let cert = Certificate::from_der(APPLE_ROOT)?; + let client = ClientBuilder::new() + .add_root_certificate(cert) + .http1_title_case_headers() + .danger_accept_invalid_certs(debug) + .connection_verbose(debug) + .build()?; + + Ok(client) + } +} + +pub trait GrandSlamErrorChecker { + fn check_grandslam_error(self) -> Result>; +} + +impl GrandSlamErrorChecker for Dictionary { + fn check_grandslam_error(self) -> Result> { + let result = match self.get("Status") { + Some(plist::Value::Dictionary(d)) => d, + _ => &self, + }; + + if result.get_signed_integer("ec").unwrap_or(0) != 0 { + bail!(SideloadError::AuthWithMessage( + result.get_signed_integer("ec").unwrap_or(-1), + result.get_str("em").unwrap_or("Unknown error").to_string(), + )) + } + + Ok(self) + } +} diff --git a/src-tauri/vendor/isideload/src/auth/mod.rs b/src-tauri/vendor/isideload/src/auth/mod.rs new file mode 100644 index 0000000..8ef46dc --- /dev/null +++ b/src-tauri/vendor/isideload/src/auth/mod.rs @@ -0,0 +1,3 @@ +pub mod apple_account; +pub mod builder; +pub mod grandslam; diff --git a/src-tauri/vendor/isideload/src/dev/app_groups.rs b/src-tauri/vendor/isideload/src/dev/app_groups.rs new file mode 100644 index 0000000..c17d2a9 --- /dev/null +++ b/src-tauri/vendor/isideload/src/dev/app_groups.rs @@ -0,0 +1,123 @@ +use crate::dev::{ + app_ids::AppId, + developer_session::DeveloperSession, + device_type::{DeveloperDeviceType, dev_url}, + teams::DeveloperTeam, +}; +use plist_macro::plist; +use rootcause::prelude::*; +use serde::Deserialize; +use tracing::info; + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AppGroup { + pub name: Option, + pub identifier: String, + pub application_group: String, +} + +#[async_trait::async_trait] +pub trait AppGroupsApi { + fn developer_session(&mut self) -> &mut DeveloperSession; + + async fn list_app_groups( + &mut self, + team: &DeveloperTeam, + device_type: impl Into> + Send, + ) -> Result, Report> { + let body = plist!(dict { + "teamId": &team.team_id, + }); + + let app_groups: Vec = self + .developer_session() + .send_dev_request( + &dev_url("listApplicationGroups", device_type), + body, + "applicationGroupList", + ) + .await + .context("Failed to list developer app groups")?; + + Ok(app_groups) + } + + async fn add_app_group( + &mut self, + team: &DeveloperTeam, + name: &str, + identifier: &str, + device_type: impl Into> + Send, + ) -> Result { + let body = plist!(dict { + "teamId": &team.team_id, + "name": name, + "identifier": identifier, + }); + + let app_group: AppGroup = self + .developer_session() + .send_dev_request( + &dev_url("addApplicationGroup", device_type), + body, + "applicationGroup", + ) + .await + .context("Failed to add developer app group")?; + + Ok(app_group) + } + + async fn assign_app_group( + &mut self, + team: &DeveloperTeam, + app_group: &AppGroup, + app_id: &AppId, + device_type: impl Into> + Send, + ) -> Result<(), Report> { + let body = plist!(dict { + "teamId": &team.team_id, + "applicationGroups": &app_group.application_group, + "appIdId": &app_id.app_id_id, + }); + + self.developer_session() + .send_dev_request_no_response( + &dev_url("assignApplicationGroupToAppId", device_type), + body, + ) + .await + .context("Failed to assign developer app group")?; + + Ok(()) + } + + async fn ensure_app_group( + &mut self, + team: &DeveloperTeam, + name: &str, + identifier: &str, + device_type: impl Into> + Send, + ) -> Result { + let device_type = device_type.into(); + let groups = self.list_app_groups(team, device_type.clone()).await?; + let matching_group = groups.iter().find(|g| g.identifier == identifier); + + if let Some(group) = matching_group { + Ok(group.clone()) + } else { + info!("Adding application group"); + let group = self + .add_app_group(team, name, identifier, device_type) + .await?; + Ok(group) + } + } +} + +impl AppGroupsApi for DeveloperSession { + fn developer_session(&mut self) -> &mut DeveloperSession { + self + } +} diff --git a/src-tauri/vendor/isideload/src/dev/app_ids.rs b/src-tauri/vendor/isideload/src/dev/app_ids.rs new file mode 100644 index 0000000..099d336 --- /dev/null +++ b/src-tauri/vendor/isideload/src/dev/app_ids.rs @@ -0,0 +1,242 @@ +use crate::{ + dev::{ + developer_session::DeveloperSession, + device_type::{DeveloperDeviceType, dev_url}, + teams::DeveloperTeam, + }, + util::plist::{PlistDataExtract, SensitivePlistAttachment}, +}; +use plist::{Data, Date, Dictionary, Value}; +use plist_macro::plist; +use reqwest::header::HeaderValue; +use rootcause::prelude::*; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct AppId { + pub app_id_id: String, + pub identifier: String, + pub name: String, + pub features: Dictionary, + pub expiration_date: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ListAppIdsResponse { + pub app_ids: Vec, + pub max_quantity: Option, + pub available_quantity: Option, +} + +#[derive(Debug, Clone, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct Profile { + pub encoded_profile: Data, + pub filename: String, + pub provisioning_profile_id: String, + pub name: String, + pub status: String, + pub r#type: String, + pub distribution_method: String, + pub pro_pro_platorm: Option, + #[serde(rename = "UUID")] + pub uuid: String, + pub date_expire: Date, + pub managing_app: Option, + pub app_id_id: String, + pub is_template_profile: bool, + pub is_team_profile: Option, + pub is_free_provisioning_profile: Option, +} + +#[async_trait::async_trait] +pub trait AppIdsApi { + fn developer_session(&mut self) -> &mut DeveloperSession; + + async fn add_app_id( + &mut self, + team: &DeveloperTeam, + name: &str, + identifier: &str, + device_type: impl Into> + Send, + ) -> Result { + let body = plist!(dict { + "teamId": &team.team_id, + "identifier": identifier, + "name": name, + }); + + let app_id: AppId = self + .developer_session() + .send_dev_request(&dev_url("addAppId", device_type), body, "appId") + .await + .context("Failed to add developer app ID")?; + + Ok(app_id) + } + + async fn list_app_ids( + &mut self, + team: &DeveloperTeam, + device_type: impl Into> + Send, + ) -> Result { + let body = plist!(dict { + "teamId": &team.team_id, + }); + + let response: Value = self + .developer_session() + .send_dev_request_no_response(&dev_url("listAppIds", device_type), body) + .await + .context("Failed to list developer app IDs")? + .into(); + + let app_ids: ListAppIdsResponse = plist::from_value(&response).map_err(|e| { + report!("Failed to deserialize app id response: {:?}", e).attach( + SensitivePlistAttachment::new( + response + .as_dictionary() + .unwrap_or(&Dictionary::new()) + .clone(), + ), + ) + })?; + + Ok(app_ids) + } + + async fn update_app_id( + &mut self, + team: &DeveloperTeam, + app_id: &AppId, + features: Dictionary, + device_type: impl Into> + Send, + ) -> Result { + let mut body = plist!(dict { + "teamId": &team.team_id, + "appIdId": &app_id.app_id_id + }); + + for (key, value) in features { + body.insert(key.clone(), value.clone()); + } + + Ok(self + .developer_session() + .send_dev_request(&dev_url("updateAppId", device_type), body, "appId") + .await + .context("Failed to update developer app ID")?) + } + + async fn delete_app_id( + &mut self, + team: &DeveloperTeam, + app_id_id: &str, + device_type: impl Into> + Send, + ) -> Result<(), Report> { + let body = plist!(dict { + "teamId": &team.team_id, + "appIdId": app_id_id, + }); + + self.developer_session() + .send_dev_request_no_response(&dev_url("deleteAppId", device_type), body) + .await + .context("Failed to delete developer app ID")?; + + Ok(()) + } + + async fn download_team_provisioning_profile( + &mut self, + team: &DeveloperTeam, + app_id: &AppId, + device_type: impl Into> + Send, + ) -> Result { + let body = plist!(dict { + "teamId": &team.team_id, + "appIdId": &app_id.app_id_id, + }); + + let response: Profile = self + .developer_session() + .send_dev_request( + &dev_url("downloadTeamProvisioningProfile", device_type), + body, + "provisioningProfile", + ) + .await + .context("Failed to download provisioning profile")?; + + Ok(response) + } + + async fn add_increased_memory_limit( + &mut self, + team: &DeveloperTeam, + app_id: &AppId, + ) -> Result<(), Report> { + let dev_session = self.developer_session(); + + let mut headers = dev_session + .get_headers() + .await + .context("Failed to get anisette headers")?; + headers.insert( + "Content-Type", + HeaderValue::from_static("application/vnd.api+json"), + ); + headers.insert( + "Accept", + HeaderValue::from_static("application/vnd.api+json"), + ); + + dev_session + .get_grandslam_client() + .patch(&format!( + "https://developerservices2.apple.com/services/v1/bundleIds/{}", + app_id.app_id_id + ))? + .headers(headers) + .body(format!( + "{{\"data\":{{\"relationships\":{{\"bundleIdCapabilities\":{{\"data\":[{{\"relationships\":{{\"capability\":{{\"data\":{{\"id\":\"INCREASED_MEMORY_LIMIT\",\"type\":\"capabilities\"}}}}}},\"type\":\"bundleIdCapabilities\",\"attributes\":{{\"settings\":[],\"enabled\":true}}}}]}}}},\"id\":\"{}\",\"attributes\":{{\"hasExclusiveManagedCapabilities\":false,\"teamId\":\"{}\",\"bundleType\":\"bundle\",\"identifier\":\"{}\",\"seedId\":\"{}\",\"name\":\"{}\"}},\"type\":\"bundleIds\"}}}}", + app_id.app_id_id, team.team_id, app_id.identifier, team.team_id, app_id.name + )) + .send() + .await.context("Failed to request increased memory entitlement")? + .error_for_status().context("Failed to add increased memory entitlement")?; + + Ok(()) + } +} + +impl AppIdsApi for DeveloperSession { + fn developer_session(&mut self) -> &mut DeveloperSession { + self + } +} + +impl AppId { + pub async fn ensure_group_feature( + &mut self, + dev_session: &mut DeveloperSession, + team: &DeveloperTeam, + ) -> Result<(), Report> { + let app_group_feature_enabled = self.features.get_bool("APG3427HIY")?; + + if !app_group_feature_enabled { + let body = plist!(dict { + "APG3427HIY": true, + }); + let new_features = dev_session + .update_app_id(team, self, body, None) + .await? + .features; + self.features = new_features; + } + + Ok(()) + } +} diff --git a/src-tauri/vendor/isideload/src/dev/certificates.rs b/src-tauri/vendor/isideload/src/dev/certificates.rs new file mode 100644 index 0000000..cd485a7 --- /dev/null +++ b/src-tauri/vendor/isideload/src/dev/certificates.rs @@ -0,0 +1,181 @@ +use crate::dev::{ + developer_session::DeveloperSession, + device_type::{DeveloperDeviceType, dev_url}, + teams::DeveloperTeam, +}; +use plist::{Data, Date}; +use plist_macro::plist; +use rootcause::prelude::*; +use serde::Deserialize; +use uuid::Uuid; + +#[derive(Deserialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct DevelopmentCertificate { + pub name: Option, + pub certificate_id: Option, + pub serial_number: Option, + pub machine_id: Option, + pub machine_name: Option, + pub cert_content: Option, + pub certificate_platform: Option, + pub certificate_type: Option, + pub status: Option, + pub status_code: Option, + pub expiration_date: Option, +} + +#[derive(Deserialize, Clone, Debug)] +#[serde(rename_all = "camelCase")] +pub struct CertificateType { + pub certificate_type_display_id: Option, + pub name: Option, + pub platform: Option, + pub permission_type: Option, + pub distribution_type: Option, + pub distribution_method: Option, + pub owner_type: Option, + pub days_overlap: Option, + pub max_active_certs: Option, +} + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct CertRequest { + pub cert_request_id: String, +} + +// the automatic debug implementation spams the console with the cert content bytes +impl std::fmt::Debug for DevelopmentCertificate { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut s = f.debug_struct("DevelopmentCertificate"); + s.field("name", &self.name) + .field("certificate_id", &self.certificate_id) + .field("serial_number", &self.serial_number) + .field("machine_id", &self.machine_id) + .field("machine_name", &self.machine_name) + .field( + "cert_content", + &self + .cert_content + .as_ref() + .map(|c| format!("Some([{} bytes])", c.as_ref().len())) + .unwrap_or("None".to_string()), + ) + .field("certificate_platform", &self.certificate_platform) + .field("certificate_type", &self.certificate_type) + .field("status", &self.status) + .field("status_code", &self.status_code) + .field("expiration_date", &self.expiration_date) + .finish() + } +} + +#[async_trait::async_trait] +pub trait CertificatesApi { + fn developer_session(&mut self) -> &mut DeveloperSession; + + async fn list_all_development_certs( + &mut self, + team: &DeveloperTeam, + device_type: impl Into> + Send, + ) -> Result, Report> { + let body = plist!(dict { + "teamId": &team.team_id, + }); + + let certs: Vec = self + .developer_session() + .send_dev_request( + &dev_url("listAllDevelopmentCerts", device_type), + body, + "certificates", + ) + .await + .context("Failed to list development certificates")?; + + Ok(certs) + } + + async fn list_ios_certs( + &mut self, + team: &DeveloperTeam, + ) -> Result, Report> { + let certs = self + .list_all_development_certs(team, DeveloperDeviceType::Ios) + .await?; + + Ok(certs + .into_iter() + .filter(|c| { + if let Some(platform) = &c.certificate_platform { + platform.to_lowercase() == "ios" + } else if let Some(cert_type) = &c.certificate_type { + if let Some(platform) = &cert_type.platform { + platform.to_lowercase() == "ios" + } else { + // I don't know how consistently these field is populated because apple apis are stupid, and I don't want to break things so just assume + true + } + } else { + true + } + }) + .collect()) + } + + async fn revoke_development_cert( + &mut self, + team: &DeveloperTeam, + serial_number: &str, + device_type: impl Into> + Send, + ) -> Result<(), Report> { + let body = plist!(dict { + "teamId": &team.team_id, + "serialNumber": serial_number, + }); + + self.developer_session() + .send_dev_request_no_response( + &dev_url("revokeDevelopmentCert", device_type), + Some(body), + ) + .await + .context("Failed to revoke development certificate")?; + + Ok(()) + } + + async fn submit_development_csr( + &mut self, + team: &DeveloperTeam, + csr_content: String, + machine_name: String, + device_type: impl Into> + Send, + ) -> Result { + let body = plist!(dict { + "teamId": &team.team_id, + "csrContent": csr_content, + "machineName": machine_name, + "machineId": Uuid::new_v4().to_string().to_uppercase(), + }); + + let cert: CertRequest = self + .developer_session() + .send_dev_request( + &dev_url("submitDevelopmentCSR", device_type), + body, + "certRequest", + ) + .await + .context("Failed to submit development CSR")?; + + Ok(cert) + } +} + +impl CertificatesApi for DeveloperSession { + fn developer_session(&mut self) -> &mut DeveloperSession { + self + } +} diff --git a/src-tauri/vendor/isideload/src/dev/developer_session.rs b/src-tauri/vendor/isideload/src/dev/developer_session.rs new file mode 100644 index 0000000..d53bb24 --- /dev/null +++ b/src-tauri/vendor/isideload/src/dev/developer_session.rs @@ -0,0 +1,188 @@ +use std::sync::Arc; + +use plist::Dictionary; +use plist_macro::{plist, plist_to_xml_string}; +use reqwest::header::{HeaderMap, HeaderValue}; +use rootcause::prelude::*; +use serde::de::DeserializeOwned; +use tracing::{error, warn}; +use uuid::Uuid; + +use crate::{ + SideloadError, + anisette::AnisetteDataGenerator, + auth::{ + apple_account::{AppToken, AppleAccount}, + grandslam::GrandSlam, + }, + util::plist::PlistDataExtract, +}; + +pub use super::app_groups::*; +pub use super::app_ids::*; +pub use super::certificates::*; +pub use super::device_type::DeveloperDeviceType; +pub use super::devices::*; +pub use super::teams::*; + +#[derive(Clone)] +pub struct DeveloperSession { + token: AppToken, + adsid: String, + client: Arc, + anisette_generator: AnisetteDataGenerator, +} + +impl DeveloperSession { + pub fn new( + token: AppToken, + adsid: String, + client: Arc, + anisette_generator: AnisetteDataGenerator, + ) -> Self { + DeveloperSession { + token, + adsid, + client, + anisette_generator, + } + } + + pub async fn from_account(account: &mut AppleAccount) -> Result { + let token = account + .get_app_token("xcode.auth") + .await + .context("Failed to get xcode token from Apple account")?; + + let spd = account + .spd + .as_ref() + .ok_or_else(|| report!("SPD not available, cannot get adsid"))?; + + Ok(DeveloperSession::new( + token, + spd.get_string("adsid")?, + account.grandslam_client.clone(), + account.anisette_generator.clone(), + )) + } + + pub async fn get_headers(&mut self) -> Result { + let mut headers = self + .anisette_generator + .get_anisette_data(self.client.clone()) + .await? + .get_header_map()?; + + headers.insert( + "X-Apple-GS-Token", + HeaderValue::from_str(&self.token.token)?, + ); + headers.insert("X-Apple-I-Identity-Id", HeaderValue::from_str(&self.adsid)?); + + Ok(headers) + } + + pub fn get_grandslam_client(&self) -> Arc { + self.client.clone() + } + + async fn send_dev_request_internal( + &mut self, + url: &str, + body: impl Into>, + ) -> Result<(Dictionary, Option), Report> { + let body = body.into().unwrap_or_else(Dictionary::new); + + let base = plist!(dict { + "clientId": "XABBG36SBA", + "protocolVersion": "QH65B2", + "requestId": Uuid::new_v4().to_string().to_uppercase(), + "userLocale": ["en_US"], + }); + + let body = base.into_iter().chain(body.into_iter()).collect(); + + let text = self + .client + .post(url)? + .body(plist_to_xml_string(&body)) + .headers( + self.get_headers() + .await + .context("Failed to get anisette headers")?, + ) + .send() + .await? + .error_for_status() + .context("Developer request failed")? + .text() + .await + .context("Failed to read developer request response text")?; + + let dict: Dictionary = plist::from_bytes(text.as_bytes()) + .context("Failed to parse developer request plist")?; + + // All this error handling is here to ensure that: + // 1. We always warn/log errors from the server even if it returns the expected data + // 2. We return server errors if the expected data is missing + // 3. We return parsing errors if there is no server error but the expected data is missing + let response_code = dict.get("resultCode").and_then(|v| v.as_signed_integer()); + let mut server_error: Option = None; + if let Some(code) = response_code { + if code != 0 { + let result_string = dict + .get("resultString") + .and_then(|v| v.as_string()) + .unwrap_or("No error message given."); + let user_string = dict + .get("userString") + .and_then(|v| v.as_string()) + .unwrap_or(result_string); + server_error = Some(SideloadError::DeveloperError(code, user_string.to_string())); + + error!( + "Developer request returned error code {}: {} ({})", + code, user_string, result_string + ); + } + } else { + warn!("No resultCode in developer request response"); + } + + Ok((dict, server_error)) + } + + pub async fn send_dev_request( + &mut self, + url: &str, + body: impl Into>, + response_key: &str, + ) -> Result { + let (dict, server_error) = self.send_dev_request_internal(url, body).await?; + + let result: Result = dict.get_struct(response_key); + + if result.is_err() + && let Some(err) = server_error + { + bail!(err); + } + + Ok(result.context("Failed to extract developer request result")?) + } + + pub async fn send_dev_request_no_response( + &mut self, + url: &str, + body: impl Into>, + ) -> Result { + let (dict, server_error) = self.send_dev_request_internal(url, body).await?; + + if let Some(err) = server_error { + bail!(err); + } + + Ok(dict) + } +} diff --git a/src-tauri/vendor/isideload/src/dev/device_type.rs b/src-tauri/vendor/isideload/src/dev/device_type.rs new file mode 100644 index 0000000..d2a1001 --- /dev/null +++ b/src-tauri/vendor/isideload/src/dev/device_type.rs @@ -0,0 +1,29 @@ +#[derive(Debug, Clone)] +pub enum DeveloperDeviceType { + Any, + Ios, + Tvos, + Watchos, +} + +impl DeveloperDeviceType { + pub fn url_segment(&self) -> &'static str { + match self { + DeveloperDeviceType::Any => "", + DeveloperDeviceType::Ios => "ios/", + DeveloperDeviceType::Tvos => "tvos/", + DeveloperDeviceType::Watchos => "watchos/", + } + } +} + +pub fn dev_url(endpoint: &str, device_type: impl Into>) -> String { + format!( + "https://developerservices2.apple.com/services/QH65B2/{}{}.action?clientId=XABBG36SBA", + device_type + .into() + .unwrap_or(DeveloperDeviceType::Ios) + .url_segment(), + endpoint, + ) +} diff --git a/src-tauri/vendor/isideload/src/dev/devices.rs b/src-tauri/vendor/isideload/src/dev/devices.rs new file mode 100644 index 0000000..9f6a4d3 --- /dev/null +++ b/src-tauri/vendor/isideload/src/dev/devices.rs @@ -0,0 +1,109 @@ +use crate::{ + SideloadError, + dev::{ + developer_session::DeveloperSession, + device_type::{DeveloperDeviceType, dev_url}, + teams::DeveloperTeam, + }, +}; +use plist_macro::plist; +use rootcause::prelude::*; +use serde::Deserialize; +use tracing::info; + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct DeveloperDevice { + pub name: Option, + pub device_id: Option, + pub device_number: String, + pub status: Option, +} + +#[async_trait::async_trait] +pub trait DevicesApi { + fn developer_session(&mut self) -> &mut DeveloperSession; + + async fn list_devices( + &mut self, + team: &DeveloperTeam, + device_type: impl Into> + Send, + ) -> Result, Report> { + let body = plist!(dict { + "teamId": &team.team_id, + }); + + let devices: Vec = self + .developer_session() + .send_dev_request(&dev_url("listDevices", device_type), body, "devices") + .await + .context("Failed to list developer devices")?; + + Ok(devices) + } + + async fn add_device( + &mut self, + team: &DeveloperTeam, + name: &str, + udid: &str, + device_type: impl Into> + Send, + ) -> Result { + let body = plist!(dict { + "teamId": &team.team_id, + "name": name, + "deviceNumber": udid, + }); + + let device: DeveloperDevice = self + .developer_session() + .send_dev_request(&dev_url("addDevice", device_type), body, "device") + .await + .context("Failed to add developer device")?; + + Ok(device) + } + + // TODO: This can be skipped if we know the device is already registered + /// Check if the device is a development device, and add it if not + async fn ensure_device_registered( + &mut self, + team: &DeveloperTeam, + name: &str, + udid: &str, + device_type: impl Into> + Send, + ) -> Result<(), Report> { + let device_type = device_type.into(); + let devices = self.list_devices(team, device_type.clone()).await?; + + if devices.iter().any(|d| d.device_number == udid) { + info!("Device is a development device"); + return Ok(()); + } + + info!("Registering development device"); + if let Err(e) = self.add_device(team, name, udid, device_type).await { + // Apple returns developer error 35 ("device already exists") when the + // UDID is already registered to the team under a different platform — + // e.g. an Apple Vision Pro registered under visionOS while we query the + // iOS device list. That is the state we want, so treat it as success + // instead of failing the whole install. + let already_registered = e + .iter_reports() + .find_map(|node| node.downcast_current_context::()) + .is_some_and(|err| matches!(err, SideloadError::DeveloperError(35, _))); + if !already_registered { + return Err(e); + } + info!("Device already registered on team"); + } + + Ok(()) + } +} + +impl DevicesApi for DeveloperSession { + fn developer_session(&mut self) -> &mut DeveloperSession { + self + } +} diff --git a/src-tauri/vendor/isideload/src/dev/mod.rs b/src-tauri/vendor/isideload/src/dev/mod.rs new file mode 100644 index 0000000..e4e7a16 --- /dev/null +++ b/src-tauri/vendor/isideload/src/dev/mod.rs @@ -0,0 +1,7 @@ +pub mod app_groups; +pub mod app_ids; +pub mod certificates; +pub mod developer_session; +pub mod device_type; +pub mod devices; +pub mod teams; diff --git a/src-tauri/vendor/isideload/src/dev/teams.rs b/src-tauri/vendor/isideload/src/dev/teams.rs new file mode 100644 index 0000000..1a4cb66 --- /dev/null +++ b/src-tauri/vendor/isideload/src/dev/teams.rs @@ -0,0 +1,36 @@ +use crate::dev::{ + developer_session::DeveloperSession, + device_type::{DeveloperDeviceType::*, dev_url}, +}; +use rootcause::prelude::*; +use serde::Deserialize; + +#[derive(Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct DeveloperTeam { + pub name: Option, + pub team_id: String, + pub r#type: Option, + pub status: Option, +} + +#[async_trait::async_trait] +pub trait TeamsApi { + fn developer_session(&mut self) -> &mut DeveloperSession; + + async fn list_teams(&mut self) -> Result, Report> { + let response: Vec = self + .developer_session() + .send_dev_request(&dev_url("listTeams", Any), None, "teams") + .await + .context("Failed to list developer teams")?; + + Ok(response) + } +} + +impl TeamsApi for DeveloperSession { + fn developer_session(&mut self) -> &mut DeveloperSession { + self + } +} diff --git a/src-tauri/vendor/isideload/src/lib.rs b/src-tauri/vendor/isideload/src/lib.rs new file mode 100644 index 0000000..2cc5e74 --- /dev/null +++ b/src-tauri/vendor/isideload/src/lib.rs @@ -0,0 +1,59 @@ +use idevice::IdeviceError; +use rootcause::{ + hooks::{Hooks, context_formatter::ContextFormatterHook}, + prelude::*, +}; + +pub mod anisette; +pub mod auth; +pub mod dev; +pub mod sideload; +pub mod util; + +#[derive(Debug, thiserror::Error)] +pub enum SideloadError { + #[error("Auth error {0}: {1}")] + AuthWithMessage(i64, String), + + #[error("Plist parse error: {0}")] + PlistParseError(String), + + #[error("Failed to get anisette data, anisette not provisioned")] + AnisetteNotProvisioned, + + #[error("Developer error {0}: {1}")] + DeveloperError(i64, String), + + #[error("Invalid bundle: {0}")] + InvalidBundle(String), + + #[error("{0}")] + IdeviceError(#[from] IdeviceError), +} + +// The default reqwest error formatter sucks and provides no info +struct ReqwestErrorFormatter; + +impl ContextFormatterHook for ReqwestErrorFormatter { + fn display( + &self, + report: rootcause::ReportRef<'_, reqwest::Error, markers::Uncloneable, markers::Local>, + f: &mut std::fmt::Formatter<'_>, + ) -> std::fmt::Result { + writeln!(f, "{}", report.format_current_context_unhooked())?; + let mut source = report.current_context_error_source(); + while let Some(s) = source { + writeln!(f, "Caused by: {:?}", s)?; + source = s.source(); + } + Ok(()) + } +} + +pub fn init() -> Result<(), Report> { + Hooks::new() + .context_formatter::(ReqwestErrorFormatter) + .install() + .context("Failed to install error reporting hooks")?; + Ok(()) +} diff --git a/src-tauri/vendor/isideload/src/sideload/application.rs b/src-tauri/vendor/isideload/src/sideload/application.rs new file mode 100644 index 0000000..7c65883 --- /dev/null +++ b/src-tauri/vendor/isideload/src/sideload/application.rs @@ -0,0 +1,306 @@ +// This file was made using https://github.com/Dadoum/Sideloader as a reference. +// I'm planning on redoing this later to better handle entitlements, extensions, etc, but it will do for now + +use crate::SideloadError; +use crate::dev::app_ids::{AppId, AppIdsApi}; +use crate::dev::developer_session::DeveloperSession; +use crate::dev::teams::DeveloperTeam; +use crate::sideload::bundle::Bundle; +use crate::sideload::cert_identity::CertificateIdentity; +use rootcause::option_ext::OptionExt; +use rootcause::prelude::*; +use std::fs::File; +use std::path::PathBuf; +use tokio::io::AsyncWriteExt; +use tracing::info; +use zip::ZipArchive; + +pub struct Application { + pub bundle: Bundle, + //pub temp_path: PathBuf, +} + +impl Application { + pub fn new(path: PathBuf) -> Result { + if !path.exists() { + bail!(SideloadError::InvalidBundle( + "Application path does not exist".to_string(), + )); + } + + let mut bundle_path = path.clone(); + //let mut temp_path = PathBuf::new(); + + if path.is_file() { + let temp_dir = std::env::temp_dir(); + let temp_path = temp_dir.join( + path.file_name() + .ok_or_report()? + .to_string_lossy() + .to_string() + + "_extracted", + ); + if temp_path.exists() { + std::fs::remove_dir_all(&temp_path) + .context("Failed to remove existing temporary directory")?; + } + std::fs::create_dir_all(&temp_path).context("Failed to create temporary directory")?; + + let file = File::open(&path).context("Failed to open application archive")?; + let mut archive = + ZipArchive::new(file).context("Failed to open application archive")?; + archive + .extract(&temp_path) + .context("Failed to extract application archive")?; + + let payload_folder = temp_path.join("Payload"); + if payload_folder.exists() && payload_folder.is_dir() { + let app_dirs: Vec<_> = std::fs::read_dir(&payload_folder) + .context("Failed to read Payload directory")? + .filter_map(Result::ok) + .filter(|entry| entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false)) + .filter(|entry| entry.path().extension().is_some_and(|ext| ext == "app")) + .collect(); + if app_dirs.len() == 1 { + bundle_path = app_dirs[0].path(); + } else if app_dirs.is_empty() { + bail!(SideloadError::InvalidBundle( + "No .app directory found in Payload".to_string(), + )); + } else { + bail!(SideloadError::InvalidBundle( + "Multiple .app directories found in Payload".to_string(), + )); + } + } else { + bail!(SideloadError::InvalidBundle( + "No Payload directory found in the application archive".to_string(), + )); + } + } + let bundle = Bundle::new(bundle_path)?; + + Ok(Application { + bundle, /*temp_path*/ + }) + } + + pub fn get_special_app(&self) -> Option { + let bundle_id = self.bundle.bundle_identifier().unwrap_or(""); + let special_app = match bundle_id { + "com.rileytestut.AltStore" => Some(SpecialApp::AltStore), + "com.SideStore.SideStore" => Some(SpecialApp::SideStore), + "app.stik.store" => Some(SpecialApp::StikStore), + _ => None, + }; + if special_app.is_some() { + return special_app; + } + + if self + .bundle + .frameworks() + .iter() + .any(|f| f.bundle_identifier().unwrap_or("") == "com.SideStore.SideStore") + { + return Some(SpecialApp::SideStoreLc); + } + + if bundle_id == "com.kdt.livecontainer" { + return Some(SpecialApp::LiveContainer); + } + + None + } + + pub fn main_bundle_id(&self) -> Result { + let str = self + .bundle + .bundle_identifier() + .ok_or_report() + .context("Failed to get main bundle identifier")? + .to_string(); + + Ok(str) + } + + pub fn main_app_name(&self) -> Result { + let str = self + .bundle + .bundle_name() + .ok_or_report() + .context("Failed to get main app name")? + .to_string(); + + Ok(str) + } + + pub fn update_bundle_id( + &mut self, + main_app_bundle_id: &str, + main_app_id_str: &str, + ) -> Result<(), Report> { + let extensions = self.bundle.app_extensions_mut(); + for ext in extensions.iter_mut() { + if let Some(id) = ext.bundle_identifier() { + if !(id.starts_with(main_app_bundle_id) && id.len() > main_app_bundle_id.len()) { + bail!(SideloadError::InvalidBundle(format!( + "Extension {} is not part of the main app bundle identifier: {}", + ext.bundle_name().unwrap_or("Unknown"), + id + ))); + } else { + ext.set_bundle_identifier(&format!( + "{}{}", + main_app_id_str, + &id[main_app_bundle_id.len()..] + )); + } + } + } + self.bundle.set_bundle_identifier(main_app_id_str); + + Ok(()) + } + + pub async fn register_app_ids( + &self, + //mode: &ExtensionsBehavior, + dev_session: &mut DeveloperSession, + team: &DeveloperTeam, + ) -> Result, Report> { + let extension_refs: Vec<_> = self.bundle.app_extensions().iter().collect(); + let mut bundles_with_app_id = vec![&self.bundle]; + bundles_with_app_id.extend(extension_refs); + + let list_app_ids_response = dev_session + .list_app_ids(team, None) + .await + .context("Failed to list app IDs for the developer team")?; + let app_ids_to_register = bundles_with_app_id + .iter() + .filter(|bundle| { + let bundle_id = bundle.bundle_identifier().unwrap_or(""); + !list_app_ids_response + .app_ids + .iter() + .any(|app_id| app_id.identifier == bundle_id) + }) + .collect::>(); + + if let Some(available) = list_app_ids_response.available_quantity + && app_ids_to_register.len() > available.try_into()? + { + bail!( + "Not enough available app IDs. {} are required, but only {} are available.", + app_ids_to_register.len(), + available + ); + } + + for bundle in app_ids_to_register { + let id = bundle.bundle_identifier().unwrap_or(""); + let name = bundle.bundle_name().unwrap_or(""); + dev_session.add_app_id(team, name, id, None).await?; + } + let list_app_id_response = dev_session.list_app_ids(team, None).await?; + let app_ids: Vec<_> = list_app_id_response + .app_ids + .into_iter() + .filter(|app_id| { + bundles_with_app_id + .iter() + .any(|bundle| app_id.identifier == bundle.bundle_identifier().unwrap_or("")) + }) + .collect(); + + info!("Registered app IDs"); + Ok(app_ids) + } + + pub async fn apply_special_app_behavior( + &mut self, + special: &Option, + group_identifier: &str, + cert: &CertificateIdentity, + ) -> Result<(), Report> { + let Some(special) = special.as_ref() else { + return Ok(()); + }; + + if matches!( + special, + SpecialApp::SideStoreLc + | SpecialApp::SideStore + | SpecialApp::AltStore + | SpecialApp::StikStore + ) { + if !matches!(special, SpecialApp::StikStore) { + self.bundle.app_info.insert( + "ALTAppGroups".to_string(), + plist::Value::Array(vec![plist::Value::String(group_identifier.to_string())]), + ); + } + info!("Injecting certificate for {}", special); + + let target_bundle = + match special { + SpecialApp::SideStoreLc => self.bundle.frameworks_mut().iter_mut().find(|fw| { + fw.bundle_identifier().unwrap_or("") == "com.SideStore.SideStore" + }), + _ => Some(&mut self.bundle), + }; + + if let Some(target_bundle) = target_bundle { + let id_key = match special { + SpecialApp::StikStore => "MachineID", + _ => "ALTCertificateID", + }; + let cert_file_name = match special { + SpecialApp::StikStore => "Certificate.p12", + _ => "ALTCertificate.p12", + }; + target_bundle.app_info.insert( + id_key.to_string(), + plist::Value::String(cert.get_serial_number()), + ); + + let p12_bytes = cert + .as_p12(&cert.machine_id) + .await + .context("Failed to encode cert as p12")?; + let alt_cert_path = target_bundle.bundle_dir.join(cert_file_name); + + let mut file = tokio::fs::File::create(&alt_cert_path) + .await + .context(format!("Failed to create {}", cert_file_name))?; + file.write_all(&p12_bytes) + .await + .context(format!("Failed to write {}", cert_file_name))?; + } + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum SpecialApp { + SideStore, + SideStoreLc, + LiveContainer, + AltStore, + StikStore, +} + +// impl display +impl std::fmt::Display for SpecialApp { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + SpecialApp::SideStore => write!(f, "SideStore"), + SpecialApp::SideStoreLc => write!(f, "SideStore+LiveContainer"), + SpecialApp::LiveContainer => write!(f, "LiveContainer"), + SpecialApp::AltStore => write!(f, "AltStore"), + SpecialApp::StikStore => write!(f, "StikStore"), + } + } +} diff --git a/src-tauri/vendor/isideload/src/sideload/builder.rs b/src-tauri/vendor/isideload/src/sideload/builder.rs new file mode 100644 index 0000000..368037e --- /dev/null +++ b/src-tauri/vendor/isideload/src/sideload/builder.rs @@ -0,0 +1,164 @@ +use std::fmt::Display; + +use crate::{ + dev::{ + certificates::DevelopmentCertificate, developer_session::DeveloperSession, + teams::DeveloperTeam, + }, + sideload::sideloader::Sideloader, + util::storage::SideloadingStorage, +}; + +/// Configuration for selecting a developer team during sideloading +/// +/// If there is only one team, it will be selected automatically regardless of this setting. +/// If there are multiple teams, the behavior will depend on this setting. +#[derive(Clone)] +pub enum TeamSelection { + /// Select the first team automatically + First, + /// Prompt the user to select a team the first time this sideloader is used, and remember the selection for future runs + PromptOnce(fn(&Vec) -> Option), + /// Prompt the user to select a team every time this sideloader is used + PromptAlways(fn(&Vec) -> Option), +} + +impl Display for TeamSelection { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TeamSelection::First => write!(f, "first team"), + TeamSelection::PromptOnce(_) => write!(f, "prompting for team (once)"), + TeamSelection::PromptAlways(_) => write!(f, "prompting for team (always)"), + } + } +} + +/// Behavior when the maximum number of development certificates is reached +pub enum MaxCertsBehavior { + /// If the maximum number of certificates is reached, revoke certs until it is possible to create a new certificate + Revoke, + /// If the maximum number of certificates is reached, return an error instead of creating a new certificate + Error, + /// If the maximum number of certificates is reached, prompt the user to select which certificates to revoke until it is possible to create a new certificate + Prompt(Box) -> Option> + Send + Sync>), +} + +/// The actual behavior choices for extensions (non-prompt variants) +pub enum ExtensionsBehaviorChoice { + /// Use the main app id/profile for all sub-bundles + ReuseMain, + /// Create separate app ids/profiles for each sub-bundle + RegisterAll, + /// Remove all sub-bundles + RemoveExtensions, +} + +// /// Behavior used when an app contains sub bundles +// pub enum ExtensionsBehavior { +// /// Use the main app id/profile for all sub-bundles +// ReuseMain, +// /// Create separate app ids/profiles for each sub-bundle +// RegisterAll, +// /// Remove all sub-bundles +// RemoveExtensions, +// /// Prompt the user to choose one of the above behaviors +// Prompt(fn(&Vec) -> ExtensionsBehaviorChoice), +// } + +// impl From for ExtensionsBehavior { +// fn from(choice: ExtensionsBehaviorChoice) -> Self { +// match choice { +// ExtensionsBehaviorChoice::ReuseMain => ExtensionsBehavior::ReuseMain, +// ExtensionsBehaviorChoice::RegisterAll => ExtensionsBehavior::RegisterAll, +// ExtensionsBehaviorChoice::RemoveExtensions => ExtensionsBehavior::RemoveExtensions, +// } +// } +// } + +pub struct SideloaderBuilder { + developer_session: DeveloperSession, + apple_email: String, + team_selection: Option, + max_certs_behavior: Option, + //extensions_behavior: Option, + storage: Option>, + machine_name: Option, + delete_app_after_install: bool, +} + +impl SideloaderBuilder { + /// Create a new `SideloaderBuilder` with the provided Apple developer session and Apple ID email. + pub fn new(developer_session: DeveloperSession, apple_email: String) -> Self { + SideloaderBuilder { + team_selection: None, + storage: None, + developer_session, + machine_name: None, + apple_email, + max_certs_behavior: None, + delete_app_after_install: true, + // extensions_behavior: None, + } + } + + /// Set the team selection behavior + /// + /// See [`TeamSelection`] for details. + pub fn team_selection(mut self, selection: TeamSelection) -> Self { + self.team_selection = Some(selection); + self + } + + /// Set the storage backend for sideloading data + /// + /// An implementation using `keyring` is provided in the `keyring-storage` feature. + /// See [`SideloadingStorage`] for details. + /// + /// If not set, either keyring storage or in memory storage (not persisted across runs) will be used depending on if the `keyring-storage` feature is enabled. + pub fn storage(mut self, storage: Box) -> Self { + self.storage = Some(storage); + self + } + + /// Set the machine name to use for the development certificate + /// + /// This has no bearing on functionality but can be useful for users to identify where a certificate came from. + /// If not set, a default name of "isideload" will be used. + pub fn machine_name(mut self, machine_name: String) -> Self { + self.machine_name = Some(machine_name); + self + } + + /// Set the behavior for when the maximum number of development certificates is reached + pub fn max_certs_behavior(mut self, behavior: MaxCertsBehavior) -> Self { + self.max_certs_behavior = Some(behavior); + self + } + + /// Set whether to delete the signed app from the temporary storage after installation. Defaults to `true`. + pub fn delete_app_after_install(mut self, delete: bool) -> Self { + self.delete_app_after_install = delete; + self + } + + // pub fn extensions_behavior(mut self, behavior: ExtensionsBehavior) -> Self { + // self.extensions_behavior = Some(behavior); + // self + // } + + /// Build the `Sideloader` instance with the provided configuration + pub fn build(self) -> Sideloader { + Sideloader::new( + self.developer_session, + self.apple_email, + self.team_selection.unwrap_or(TeamSelection::First), + self.max_certs_behavior.unwrap_or(MaxCertsBehavior::Error), + self.machine_name.unwrap_or_else(|| "isideload".to_string()), + self.storage + .unwrap_or_else(|| Box::new(crate::util::storage::new_storage())), + // self.extensions_behavior + // .unwrap_or(ExtensionsBehavior::RegisterAll), + self.delete_app_after_install, + ) + } +} diff --git a/src-tauri/vendor/isideload/src/sideload/bundle.rs b/src-tauri/vendor/isideload/src/sideload/bundle.rs new file mode 100644 index 0000000..fdee936 --- /dev/null +++ b/src-tauri/vendor/isideload/src/sideload/bundle.rs @@ -0,0 +1,234 @@ +// This file was made using https://github.com/Dadoum/Sideloader as a reference. +// I'm planning on redoing this later to better handle entitlements, extensions, etc, but it will do for now + +use plist::{Dictionary, Value}; +use rootcause::prelude::*; +use std::{ + fs, + path::{Path, PathBuf}, +}; + +use crate::SideloadError; + +#[derive(Debug, Clone)] +pub struct Bundle { + pub app_info: Dictionary, + pub bundle_dir: PathBuf, + + app_extensions: Vec, + frameworks: Vec, + _libraries: Vec, +} + +impl Bundle { + pub fn new(bundle_dir: PathBuf) -> Result { + let mut bundle_path = bundle_dir; + // Remove trailing slash/backslash + if let Some(path_str) = bundle_path.to_str() + && (path_str.ends_with('/') || path_str.ends_with('\\')) + { + bundle_path = PathBuf::from(&path_str[..path_str.len() - 1]); + } + + let info_plist_path = bundle_path.join("Info.plist"); + assert_bundle( + info_plist_path.exists(), + &format!("No Info.plist here: {}", info_plist_path.display()), + )?; + + let plist_data = fs::read(&info_plist_path).context(SideloadError::InvalidBundle( + "Failed to read Info.plist".to_string(), + ))?; + + let app_info = plist::from_bytes(&plist_data).context(SideloadError::InvalidBundle( + "Failed to parse Info.plist".to_string(), + ))?; + + // Load app extensions from PlugIns directory + let plug_ins_dir = bundle_path.join("PlugIns"); + let app_extensions = if plug_ins_dir.exists() { + fs::read_dir(&plug_ins_dir) + .context(SideloadError::InvalidBundle( + "Failed to read PlugIns directory".to_string(), + ))? + .filter_map(|entry| entry.ok()) + .filter(|entry| { + entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) + && entry.path().join("Info.plist").exists() + }) + .filter_map(|entry| Bundle::new(entry.path()).ok()) + .collect() + } else { + Vec::new() + }; + + // Load frameworks from Frameworks directory + let frameworks_dir = bundle_path.join("Frameworks"); + let frameworks = if frameworks_dir.exists() { + fs::read_dir(&frameworks_dir) + .context(SideloadError::InvalidBundle( + "Failed to read Frameworks directory".to_string(), + ))? + .filter_map(|entry| entry.ok()) + .filter(|entry| { + entry.file_type().map(|ft| ft.is_dir()).unwrap_or(false) + && entry.path().join("Info.plist").exists() + }) + .filter_map(|entry| Bundle::new(entry.path()).ok()) + .collect() + } else { + Vec::new() + }; + + // Find all .dylib files in the bundle directory (recursive) + let libraries = find_dylibs(&bundle_path, &bundle_path)?; + + Ok(Bundle { + app_info, + bundle_dir: bundle_path, + app_extensions, + frameworks, + _libraries: libraries, + }) + } + + pub fn set_bundle_identifier(&mut self, id: &str) { + self.app_info.insert( + "CFBundleIdentifier".to_string(), + Value::String(id.to_string()), + ); + } + + pub fn bundle_identifier(&self) -> Option<&str> { + self.app_info + .get("CFBundleIdentifier") + .and_then(|v| v.as_string()) + } + + pub fn bundle_name(&self) -> Option<&str> { + self.app_info + .get("CFBundleName") + .and_then(|v| v.as_string()) + } + + pub fn app_extensions(&self) -> &[Bundle] { + &self.app_extensions + } + + pub fn app_extensions_mut(&mut self) -> &mut [Bundle] { + &mut self.app_extensions + } + + pub fn frameworks(&self) -> &[Bundle] { + &self.frameworks + } + + pub fn frameworks_mut(&mut self) -> &mut [Bundle] { + &mut self.frameworks + } + + pub fn write_info(&self) -> Result<(), Report> { + let info_plist_path = self.bundle_dir.join("Info.plist"); + plist::to_file_binary(&info_plist_path, &self.app_info).context( + SideloadError::InvalidBundle("Failed to write Info.plist".to_string()), + )?; + Ok(()) + } + + fn from_dylib_path(dylib_path: PathBuf) -> Self { + Self { + app_info: Dictionary::new(), + bundle_dir: dylib_path, + app_extensions: Vec::new(), + frameworks: Vec::new(), + _libraries: Vec::new(), + } + } + + fn collect_dylib_bundles(&self) -> Vec { + self._libraries + .iter() + .map(|relative| Self::from_dylib_path(self.bundle_dir.join(relative))) + .collect() + } + + fn collect_nested_bundles_into(&self, bundles: &mut Vec) { + for bundle in &self.app_extensions { + bundles.push(bundle.clone()); + bundle.collect_nested_bundles_into(bundles); + } + + for bundle in &self.frameworks { + bundles.push(bundle.clone()); + bundle.collect_nested_bundles_into(bundles); + } + } + + pub fn collect_nested_bundles(&self) -> Vec { + let mut bundles = Vec::new(); + self.collect_nested_bundles_into(&mut bundles); + bundles.extend(self.collect_dylib_bundles()); + bundles + } + + pub fn collect_bundles_sorted(&self) -> Vec { + let mut bundles = self.collect_nested_bundles(); + bundles.push(self.clone()); + bundles.sort_by_key(|b| b.bundle_dir.components().count()); + bundles.reverse(); + bundles + } +} + +fn assert_bundle(condition: bool, msg: &str) -> Result<(), Report> { + if !condition { + bail!(SideloadError::InvalidBundle(msg.to_string())) + } else { + Ok(()) + } +} + +fn find_dylibs(dir: &Path, bundle_root: &Path) -> Result, Report> { + let mut libraries = Vec::new(); + + fn collect_dylibs( + dir: &Path, + bundle_root: &Path, + libraries: &mut Vec, + ) -> Result<(), Report> { + let entries = fs::read_dir(dir).context(SideloadError::InvalidBundle(format!( + "Failed to read directory {}", + dir.display() + )))?; + + for entry in entries { + let entry = entry.context(SideloadError::InvalidBundle( + "Failed to read directory entry".to_string(), + ))?; + + let path = entry.path(); + let file_type = entry.file_type().context(SideloadError::InvalidBundle( + "Failed to get file type".to_string(), + ))?; + + if file_type.is_file() { + if let Some(name) = path.file_name().and_then(|n| n.to_str()) + && name.ends_with(".dylib") + { + // Get relative path from bundle root + if let Ok(relative_path) = path.strip_prefix(bundle_root) + && let Some(relative_str) = relative_path.to_str() + { + libraries.push(relative_str.to_string()); + } + } + } else if file_type.is_dir() { + collect_dylibs(&path, bundle_root, libraries)?; + } + } + Ok(()) + } + + collect_dylibs(dir, bundle_root, &mut libraries)?; + Ok(libraries) +} diff --git a/src-tauri/vendor/isideload/src/sideload/cert_identity.rs b/src-tauri/vendor/isideload/src/sideload/cert_identity.rs new file mode 100644 index 0000000..0af8d2c --- /dev/null +++ b/src-tauri/vendor/isideload/src/sideload/cert_identity.rs @@ -0,0 +1,367 @@ +use apple_codesign::{ + SigningSettings, + cryptography::{InMemoryPrivateKey, PrivateKey}, +}; +use hex::ToHex; +use rcgen::{CertificateParams, DistinguishedName, DnType, KeyPair, PKCS_RSA_SHA256}; +use rootcause::{option_ext::OptionExt, prelude::*}; +use rsa::{ + RsaPrivateKey, + pkcs1::EncodeRsaPublicKey, + pkcs8::{DecodePrivateKey, EncodePrivateKey, LineEnding}, +}; + +use sha1::Sha1; +use sha2::{Digest, Sha256}; +use tracing::{error, info}; +use x509_certificate::CapturedX509Certificate; + +use crate::{ + SideloadError, + dev::{ + certificates::{CertificatesApi, DevelopmentCertificate}, + developer_session::DeveloperSession, + teams::DeveloperTeam, + }, + sideload::builder::MaxCertsBehavior, + util::storage::SideloadingStorage, +}; + +pub struct CertificateIdentity { + pub machine_id: String, + pub machine_name: String, + pub certificate: CapturedX509Certificate, + pub private_key: RsaPrivateKey, + pub signing_key: InMemoryPrivateKey, +} + +impl CertificateIdentity { + // This implementation was mostly borrowed from Impactor (https://github.com/khcrysalis/Impactor/blob/main/crates/plume_core/src/utils/certificate.rs) + /// Exports the certificate and private key as a PKCS#12 archive + /// If you plan to import into SideStore/AltStore, use the machine id as the password + pub async fn as_p12(&self, password: &str) -> Result, Report> { + let cert_der = self.certificate.encode_der()?; + let cert_der_len = cert_der.len(); + let key_der = self.private_key.to_pkcs8_der()?.as_bytes().to_vec(); + let key_der_len = key_der.len(); + + let cert = p12_keystore::Certificate::from_der(&cert_der) + .map_err(|e| report!("Failed to parse certificate: {:?}", e))?; + let cert_subject = cert.subject().to_string(); + let cert_issuer = cert.issuer().to_string(); + + let local_key_id = { + let mut hasher = Sha1::new(); + hasher.update(&key_der); + let hash = hasher.finalize(); + hash[..8].to_vec() + }; + + let key_chain = p12_keystore::PrivateKeyChain::new( + local_key_id, + p12_keystore::PrivateKey::from_der(&key_der)?, + vec![cert], + ); + + let mut keystore = p12_keystore::KeyStore::new(); + keystore.add_entry( + "isideload", + p12_keystore::KeyStoreEntry::PrivateKeyChain(key_chain), + ); + + let writer = keystore.writer(password); + match writer.write() { + Ok(p12) => Ok(p12), + Err(e) => { + let subject_codepoints = cert_subject + .chars() + .map(|c| format!("U+{:04X}", c as u32)) + .collect::>() + .join(" "); + let has_non_bmp_subject_chars = cert_subject.chars().any(|c| (c as u32) > 0xFFFF); + + error!( + cert_subject = %cert_subject, + cert_issuer = %cert_issuer, + cert_subject_codepoints = %subject_codepoints, + has_non_bmp_subject_chars, + cert_der_len, + key_der_len, + password_char_len = password.chars().count(), + "Failed to write PKCS#12 archive" + ); + + let err = format!("Failed to write PKCS#12 archive: {:?}", e); + Err(e).context(err)? + } + } + } + + pub fn get_serial_number(&self) -> String { + let serial: String = self.certificate.serial_number_asn1().encode_hex(); + serial.trim_start_matches('0').to_string().to_uppercase() + } + + pub async fn retrieve( + machine_name: &str, + apple_email: &str, + developer_session: &mut DeveloperSession, + team: &DeveloperTeam, + storage: &dyn SideloadingStorage, + max_certs_behavior: &MaxCertsBehavior, + ) -> Result { + let pr = Self::retrieve_private_key(apple_email, storage).await?; + let signing_key = Self::build_signing_key(&pr)?; + + let found = Self::find_matching(&pr, machine_name, developer_session, team).await; + if let Ok(Some((cert, x509_cert))) = found { + info!("Found matching certificate"); + return Ok(Self { + machine_id: cert.machine_id.clone().unwrap_or_default(), + machine_name: cert.machine_name.clone().unwrap_or_default(), + certificate: x509_cert, + private_key: pr, + signing_key, + }); + } + + if let Err(e) = found { + error!("Failed to check for matching certificate: {:?}", e); + } + info!("Requesting new certificate"); + let (cert, x509_cert) = Self::request_certificate( + &pr, + machine_name.to_string(), + developer_session, + team, + max_certs_behavior, + ) + .await?; + + info!("Successfully obtained certificate"); + + Ok(Self { + machine_id: cert.machine_id.clone().unwrap_or_default(), + machine_name: cert.machine_name.clone().unwrap_or_default(), + certificate: x509_cert, + private_key: pr, + signing_key, + }) + } + + async fn retrieve_private_key( + apple_email: &str, + storage: &dyn SideloadingStorage, + ) -> Result { + let mut hasher = Sha256::new(); + hasher.update(apple_email.as_bytes()); + let email_hash = hex::encode(hasher.finalize()); + + let private_key = storage.retrieve_data(&format!("{}/key", email_hash))?; + if let Some(priv_key) = private_key { + info!("Using existing private key from storage"); + return Ok(RsaPrivateKey::from_pkcs8_der(&priv_key)?); + } + + let mut rng = rand::rng(); + let private_key = RsaPrivateKey::new(&mut rng, 2048)?; + storage.store_data( + &format!("{}/key", email_hash), + private_key.to_pkcs8_der()?.as_bytes(), + )?; + + Ok(private_key) + } + + async fn find_matching( + private_key: &RsaPrivateKey, + machine_name: &str, + developer_session: &mut DeveloperSession, + team: &DeveloperTeam, + ) -> Result, Report> { + let public_key_der = private_key + .to_public_key() + .to_pkcs1_der()? + .as_bytes() + .to_vec(); + for cert in developer_session + .list_ios_certs(team) + .await? + .iter() + .filter(|c| { + c.cert_content.is_some() + && c.machine_name.as_deref().unwrap_or("") == machine_name + && c.machine_id.is_some() + }) + { + let x509_cert = CapturedX509Certificate::from_der( + cert.cert_content.as_ref().ok_or_report()?.as_ref(), + )?; + + if public_key_der == x509_cert.public_key_data().as_ref() { + return Ok(Some((cert.clone(), x509_cert))); + } + } + + Ok(None) + } + + async fn request_certificate( + private_key: &RsaPrivateKey, + machine_name: String, + developer_session: &mut DeveloperSession, + team: &DeveloperTeam, + max_certs_behavior: &MaxCertsBehavior, + ) -> Result<(DevelopmentCertificate, CapturedX509Certificate), Report> { + let csr = Self::build_csr(private_key).context("Failed to generate CSR")?; + + let mut i = 0; + let mut existing_certs: Option> = None; + + while i < 4 { + i += 1; + + let result = developer_session + .submit_development_csr(team, csr.clone(), machine_name.clone(), None) + .await; + + match result { + Ok(request) => { + let apple_certs = developer_session.list_ios_certs(team).await?; + + let apple_cert = apple_certs + .iter() + .find(|c| c.certificate_id == Some(request.cert_request_id.clone())) + .ok_or_else(|| { + report!("Failed to find certificate after submitting CSR") + })?; + + let x509_cert = CapturedX509Certificate::from_der( + apple_cert + .cert_content + .as_ref() + .ok_or_else(|| report!("Certificate content missing"))? + .as_ref(), + )?; + + return Ok((apple_cert.clone(), x509_cert)); + } + Err(e) => { + let error = e + .iter_reports() + .find_map(|node| node.downcast_current_context::()); + if let Some(SideloadError::DeveloperError(code, _)) = error { + if *code == 7460 { + if existing_certs.is_none() { + existing_certs = Some( + developer_session + .list_ios_certs(team) + .await? + .iter() + .filter(|c| c.serial_number.is_some()) + .cloned() + .collect(), + ); + } + Self::revoke_others( + developer_session, + team, + max_certs_behavior, + SideloadError::DeveloperError( + *code, + "Maximum number of certificates reached".to_string(), + ), + existing_certs.as_mut().ok_or_report()?, + ) + .await?; + } else { + return Err(e); + } + } + } + }; + } + + Err(report!("Reached max attempts to request certificate")) + } + + fn build_csr(private_key: &RsaPrivateKey) -> Result { + let mut params = CertificateParams::new(vec![])?; + let mut dn = DistinguishedName::new(); + + dn.push(DnType::CountryName, "US"); + dn.push(DnType::StateOrProvinceName, "STATE"); + dn.push(DnType::LocalityName, "LOCAL"); + dn.push(DnType::OrganizationName, "ORGNIZATION"); + dn.push(DnType::CommonName, "CN"); + params.distinguished_name = dn; + + let subject_key = KeyPair::from_pkcs8_pem_and_sign_algo( + &private_key.to_pkcs8_pem(LineEnding::LF)?, + &PKCS_RSA_SHA256, + )?; + + Ok(params.serialize_request(&subject_key)?.pem()?) + } + + fn build_signing_key(private_key: &RsaPrivateKey) -> Result { + let pkcs8 = private_key.to_pkcs8_der()?; + Ok(InMemoryPrivateKey::from_pkcs8_der(pkcs8.as_bytes())?) + } + + async fn revoke_others( + developer_session: &mut DeveloperSession, + team: &DeveloperTeam, + max_certs_behavior: &MaxCertsBehavior, + error: SideloadError, + existing_certs: &mut Vec, + ) -> Result<(), Report> { + match max_certs_behavior { + MaxCertsBehavior::Revoke => { + if let Some(cert) = existing_certs.pop() { + info!( + "Revoking certificate with name: {:?} ({:?})", + cert.name, cert.machine_name + ); + developer_session + .revoke_development_cert(team, &cert.serial_number.ok_or_report()?, None) + .await?; + Ok(()) + } else { + error!("No more certificates to revoke but still hitting max certs error"); + Err(error.into()) + } + } + MaxCertsBehavior::Error => Err(error.into()), + MaxCertsBehavior::Prompt(prompt_fn) => { + let certs_to_revoke = prompt_fn(existing_certs); + if certs_to_revoke.is_none() { + error!("User did not select any certificates to revoke"); + return Err(error.into()); + } + for serial in certs_to_revoke.ok_or_report()? { + info!("Revoking certificate with serial number: {}", serial); + developer_session + .revoke_development_cert(team, &serial, None) + .await?; + existing_certs.retain(|c| c.serial_number != Some(serial.clone())); + } + Ok(()) + } + } + } + + pub fn setup_signing_settings<'a>( + &'a self, + settings: &mut SigningSettings<'a>, + ) -> Result<(), Report> { + settings.set_signing_key( + self.signing_key.as_key_info_signer(), + self.certificate.clone(), + ); + settings.chain_apple_certificates(); + settings.set_team_id_from_signing_certificate(); + + Ok(()) + } +} diff --git a/src-tauri/vendor/isideload/src/sideload/install.rs b/src-tauri/vendor/isideload/src/sideload/install.rs new file mode 100644 index 0000000..436a3d3 --- /dev/null +++ b/src-tauri/vendor/isideload/src/sideload/install.rs @@ -0,0 +1,96 @@ +use idevice::{ + IdeviceService, afc::AfcClient, installation_proxy::InstallationProxyClient, + provider::IdeviceProvider, +}; +use plist_macro::plist; +use rootcause::option_ext::OptionExt; +use rootcause::prelude::*; + +use crate::SideloadError as Error; +use std::pin::Pin; +use std::{future::Future, path::Path}; + +/// Installs an ***already signed*** app onto your device. +/// To sign and install an app, see [`crate::sideload::sideload_app`] +pub async fn install_app( + provider: &impl IdeviceProvider, + app_path: &Path, + progress_callback: impl Fn(u64), +) -> Result<(), Report> { + let mut afc_client = AfcClient::connect(provider) + .await + .map_err(Error::IdeviceError)?; + + let dir = format!( + "PublicStaging/{}", + app_path.file_name().ok_or_report()?.to_string_lossy() + ); + afc_upload_dir(&mut afc_client, app_path, &dir).await?; + + let mut instproxy_client = InstallationProxyClient::connect(provider) + .await + .map_err(Error::IdeviceError)?; + + let options = plist!(dict { + "PackageType": "Developer" + }); + + instproxy_client + .install_with_callback( + dir, + Some(plist::Value::Dictionary(options)), + async |(percentage, _)| { + progress_callback(percentage); + }, + (), + ) + .await + .map_err(Error::IdeviceError)?; + + Ok(()) +} + +fn afc_upload_dir<'a>( + afc_client: &'a mut AfcClient, + path: &'a Path, + afc_path: &'a str, +) -> Pin> + Send + 'a>> { + Box::pin(async move { + let entries = std::fs::read_dir(path)?; + afc_client + .mk_dir(afc_path) + .await + .map_err(Error::IdeviceError)?; + for entry in entries { + let entry = entry?; + let path = entry.path(); + if path.is_dir() { + let new_afc_path = format!( + "{}/{}", + afc_path, + path.file_name().ok_or_report()?.to_string_lossy() + ); + afc_upload_dir(afc_client, &path, &new_afc_path).await?; + } else { + let mut file_handle = afc_client + .open( + format!( + "{}/{}", + afc_path, + path.file_name().ok_or_report()?.to_string_lossy() + ), + idevice::afc::opcode::AfcFopenMode::WrOnly, + ) + .await + .map_err(Error::IdeviceError)?; + let bytes = std::fs::read(&path)?; + file_handle + .write_entire(&bytes) + .await + .map_err(Error::IdeviceError)?; + file_handle.close().await.map_err(Error::IdeviceError)?; + } + } + Ok(()) + }) +} diff --git a/src-tauri/vendor/isideload/src/sideload/mod.rs b/src-tauri/vendor/isideload/src/sideload/mod.rs new file mode 100644 index 0000000..5e59ac5 --- /dev/null +++ b/src-tauri/vendor/isideload/src/sideload/mod.rs @@ -0,0 +1,9 @@ +pub mod application; +pub mod builder; +pub mod bundle; +pub mod cert_identity; +#[cfg(feature = "install")] +pub mod install; +pub mod sideloader; +pub mod sign; +pub use builder::{SideloaderBuilder, TeamSelection}; diff --git a/src-tauri/vendor/isideload/src/sideload/sideloader.rs b/src-tauri/vendor/isideload/src/sideload/sideloader.rs new file mode 100644 index 0000000..a1ed42f --- /dev/null +++ b/src-tauri/vendor/isideload/src/sideload/sideloader.rs @@ -0,0 +1,265 @@ +use crate::{ + dev::{ + app_groups::AppGroupsApi, + app_ids::AppIdsApi, + developer_session::DeveloperSession, + devices::DevicesApi, + teams::{DeveloperTeam, TeamsApi}, + }, + sideload::{ + TeamSelection, + application::{Application, SpecialApp}, + builder::MaxCertsBehavior, + cert_identity::CertificateIdentity, + sign, + }, + util::{device::IdeviceInfo, storage::SideloadingStorage}, +}; + +use std::path::PathBuf; + +use idevice::provider::IdeviceProvider; +use rootcause::{option_ext::OptionExt, prelude::*}; +use tracing::info; + +pub struct Sideloader { + team_selection: TeamSelection, + storage: Box, + dev_session: DeveloperSession, + machine_name: String, + apple_email: String, + max_certs_behavior: MaxCertsBehavior, + //extensions_behavior: ExtensionsBehavior, + delete_app_after_install: bool, + team: Option, +} + +impl Sideloader { + /// Construct a new `Sideloader` instance with the provided configuration + /// + /// See [`crate::sideload::SideloaderBuilder`] for more details and a more convenient way to construct a `Sideloader`. + pub fn new( + dev_session: DeveloperSession, + apple_email: String, + team_selection: TeamSelection, + max_certs_behavior: MaxCertsBehavior, + machine_name: String, + storage: Box, + //extensions_behavior: ExtensionsBehavior, + delete_app_after_install: bool, + ) -> Self { + Sideloader { + team_selection, + storage, + dev_session, + machine_name, + apple_email, + max_certs_behavior, + //extensions_behavior, + delete_app_after_install, + team: None, + } + } + + /// Sign the app at the provided path and return the path to the signed app bundle (in a temp dir). To sign and install, see [`Self::install_app`]. + pub async fn sign_app( + &mut self, + app_path: PathBuf, + team: Option, + // this will be replaced with proper entitlement handling later + increased_memory_limit: bool, + ) -> Result<(PathBuf, Option), Report> { + let team = match team { + Some(t) => t, + None => self.get_team().await?, + }; + let cert_identity = CertificateIdentity::retrieve( + &self.machine_name, + &self.apple_email, + &mut self.dev_session, + &team, + self.storage.as_ref(), + &self.max_certs_behavior, + ) + .await + .context("Failed to retrieve certificate identity")?; + + let mut app = Application::new(app_path)?; + let special = app.get_special_app(); + + let main_bundle_id = app.main_bundle_id()?; + let main_app_name = app.main_app_name()?; + let main_app_id_str = format!("{}.{}", main_bundle_id, team.team_id); + app.update_bundle_id(&main_bundle_id, &main_app_id_str)?; + let mut app_ids = app + .register_app_ids( + /*&self.extensions_behavior, */ &mut self.dev_session, + &team, + ) + .await?; + let main_app_id = match app_ids + .iter() + .find(|app_id| app_id.identifier == main_app_id_str) + { + Some(id) => id, + None => { + bail!( + "Main app ID {} not found in registered app IDs", + main_app_id_str + ); + } + } + .clone(); + + let group_identifier = format!( + "group.{}", + if Some(SpecialApp::SideStoreLc) == special { + format!("com.SideStore.SideStore.{}", team.team_id) + } else { + main_app_id_str.clone() + } + ); + + let app_group = self + .dev_session + .ensure_app_group(&team, &main_app_name, &group_identifier, None) + .await?; + + for app_id in app_ids.iter_mut() { + app_id + .ensure_group_feature(&mut self.dev_session, &team) + .await?; + + self.dev_session + .assign_app_group(&team, &app_group, app_id, None) + .await?; + + if increased_memory_limit { + self.dev_session + .add_increased_memory_limit(&team, app_id) + .await?; + } + } + + info!("App IDs configured"); + + app.apply_special_app_behavior(&special, &group_identifier, &cert_identity) + .await + .context("Failed to modify app bundle")?; + + let provisioning_profile = self + .dev_session + .download_team_provisioning_profile(&team, &main_app_id, None) + .await?; + + info!("Acquired provisioning profile"); + + app.bundle.write_info()?; + for ext in app.bundle.app_extensions_mut() { + ext.write_info()?; + } + for ext in app.bundle.frameworks_mut() { + ext.write_info()?; + } + + tokio::fs::write( + app.bundle.bundle_dir.join("embedded.mobileprovision"), + provisioning_profile.encoded_profile.as_ref(), + ) + .await?; + + sign::sign( + &mut app, + &cert_identity, + &provisioning_profile, + &special, + &team, + ) + .context("Failed to sign app")?; + + info!("App signed!"); + + Ok((app.bundle.bundle_dir.clone(), special)) + } + + #[cfg(feature = "install")] + /// Sign and install an app to a device. + pub async fn install_app( + &mut self, + device_provider: &impl IdeviceProvider, + app_path: PathBuf, + // this is gross but will be replaced with proper entitlement handling later + increased_memory_limit: bool, + ) -> Result, Report> { + let device_info = IdeviceInfo::from_device(device_provider).await?; + + let team = self.get_team().await?; + self.dev_session + .ensure_device_registered(&team, &device_info.name, &device_info.udid, None) + .await?; + + let (signed_app_path, special_app) = self + .sign_app(app_path, Some(team), increased_memory_limit) + .await?; + + info!("Transferring App..."); + + crate::sideload::install::install_app(device_provider, &signed_app_path, |progress| { + info!("Installing: {}%", progress); + }) + .await + .context("Failed to install app on device")?; + + if self.delete_app_after_install + && let Err(e) = tokio::fs::remove_dir_all(signed_app_path).await + { + tracing::warn!("Failed to remove temporary signed app file: {}", e); + } + + Ok(special_app) + } + + /// Get the developer team according to the configured team selection behavior + pub async fn get_team(&mut self) -> Result { + if let Some(team) = &self.team { + return Ok(team.clone()); + } + let teams = self.dev_session.list_teams().await?; + let team = match teams.len() { + 0 => { + bail!("No developer teams available") + } + 1 => teams.into_iter().next().ok_or_report()?, + _ => { + info!( + "Multiple developer teams found, {} as per configuration", + self.team_selection + ); + match &self.team_selection { + TeamSelection::First => teams.into_iter().next().ok_or_report()?, + TeamSelection::PromptOnce(prompt_fn) + | TeamSelection::PromptAlways(prompt_fn) => { + let selection = + prompt_fn(&teams).ok_or_else(|| report!("No team selected"))?; + teams + .into_iter() + .find(|t| t.team_id == selection) + .ok_or_else(|| report!("No team found with ID {}", selection))? + } + } + } + }; + if !matches!(&self.team_selection, TeamSelection::PromptAlways(_)) { + self.team = Some(team.clone()); + } + Ok(team) + } + + pub fn get_dev_session(&mut self) -> &mut DeveloperSession { + &mut self.dev_session + } + + pub fn get_email(&self) -> &str { + &self.apple_email + } +} diff --git a/src-tauri/vendor/isideload/src/sideload/sign.rs b/src-tauri/vendor/isideload/src/sideload/sign.rs new file mode 100644 index 0000000..3468405 --- /dev/null +++ b/src-tauri/vendor/isideload/src/sideload/sign.rs @@ -0,0 +1,114 @@ +use apple_codesign::{SigningSettings, UnifiedSigner}; +use plist::Dictionary; +use plist_macro::plist_to_xml_string; +use rootcause::{option_ext::OptionExt, prelude::*}; +use tracing::info; + +use crate::{ + dev::{app_ids::Profile, teams::DeveloperTeam}, + sideload::{ + application::{Application, SpecialApp}, + cert_identity::CertificateIdentity, + }, + util::plist::PlistDataExtract, +}; + +pub fn sign( + app: &mut Application, + cert_identity: &CertificateIdentity, + provisioning_profile: &Profile, + special: &Option, + team: &DeveloperTeam, +) -> Result<(), Report> { + let mut settings = signing_settings(cert_identity)?; + let entitlements: Dictionary = entitlements_from_prov( + provisioning_profile.encoded_profile.as_ref(), + special, + team, + )?; + + settings + .set_entitlements_xml( + apple_codesign::SettingsScope::Main, + plist_to_xml_string(&entitlements), + ) + .context("Failed to set entitlements XML")?; + let signer = UnifiedSigner::new(settings); + + for bundle in app.bundle.collect_bundles_sorted() { + info!( + "Signing {}", + bundle + .bundle_dir + .file_name() + .unwrap_or(bundle.bundle_dir.as_os_str()) + .to_string_lossy() + ); + signer + .sign_path_in_place(&bundle.bundle_dir) + .context(format!( + "Failed to sign bundle: {}", + bundle.bundle_dir.display() + ))?; + } + + Ok(()) +} + +pub fn signing_settings<'a>(cert: &'a CertificateIdentity) -> Result, Report> { + let mut settings = SigningSettings::default(); + + cert.setup_signing_settings(&mut settings)?; + settings.set_for_notarization(false); + settings.set_shallow(true); + + Ok(settings) +} + +fn entitlements_from_prov( + data: &[u8], + special: &Option, + team: &DeveloperTeam, +) -> Result { + let start = data + .windows(6) + .position(|w| w == b"") + .ok_or_report()? + + 8; + let plist_data = &data[start..end]; + let plist = plist::Value::from_reader_xml(plist_data)?; + + let mut entitlements = plist + .as_dictionary() + .ok_or_report()? + .get_dict("Entitlements")? + .clone(); + + if matches!( + special, + Some(SpecialApp::SideStoreLc) | Some(SpecialApp::LiveContainer) + ) { + let mut keychain_access = vec![plist::Value::String(format!( + "{}.com.kdt.livecontainer.shared", + team.team_id + ))]; + + for number in 1..128 { + keychain_access.push(plist::Value::String(format!( + "{}.com.kdt.livecontainer.shared.{}", + team.team_id, number + ))); + } + + entitlements.insert( + "keychain-access-groups".to_string(), + plist::Value::Array(keychain_access), + ); + } + + Ok(entitlements) +} diff --git a/src-tauri/vendor/isideload/src/util/device.rs b/src-tauri/vendor/isideload/src/util/device.rs new file mode 100644 index 0000000..2fbcce9 --- /dev/null +++ b/src-tauri/vendor/isideload/src/util/device.rs @@ -0,0 +1,44 @@ +use idevice::{IdeviceService, lockdown::LockdownClient, provider::IdeviceProvider}; +use rootcause::prelude::*; + +pub struct IdeviceInfo { + pub name: String, + pub udid: String, +} + +impl IdeviceInfo { + pub fn new(name: String, udid: String) -> Self { + Self { name, udid } + } + + pub async fn from_device(device: &impl IdeviceProvider) -> Result { + let mut lockdown = LockdownClient::connect(device) + .await + .context("Failed to connect to device lockdown")?; + let pairing = device + .get_pairing_file() + .await + .context("Failed to get device pairing file")?; + lockdown + .start_session(&pairing) + .await + .context("Failed to start lockdown session")?; + let device_name = lockdown + .get_value(Some("DeviceName"), None) + .await + .context("Failed to get device name")? + .as_string() + .ok_or_else(|| report!("Device name is not a string"))? + .to_string(); + + let device_udid = lockdown + .get_value(Some("UniqueDeviceID"), None) + .await + .context("Failed to get device UDID")? + .as_string() + .ok_or_else(|| report!("Device UDID is not a string"))? + .to_string(); + + Ok(Self::new(device_name, device_udid)) + } +} diff --git a/src-tauri/vendor/isideload/src/util/fs_storage.rs b/src-tauri/vendor/isideload/src/util/fs_storage.rs new file mode 100644 index 0000000..4ccb959 --- /dev/null +++ b/src-tauri/vendor/isideload/src/util/fs_storage.rs @@ -0,0 +1,53 @@ +use std::path::{Path, PathBuf}; + +use rootcause::prelude::*; + +use crate::util::storage::SideloadingStorage; + +pub struct FsStorage { + path: PathBuf, +} + +impl FsStorage { + pub fn new(path: PathBuf) -> Self { + FsStorage { path } + } +} + +impl Default for FsStorage { + fn default() -> Self { + Self::new(PathBuf::from(".")) + } +} + +impl SideloadingStorage for FsStorage { + fn store_data(&self, key: &str, data: &[u8]) -> Result<(), Report> { + let path = self.path.join(key); + let parent = path.parent().unwrap_or(Path::new(".")); + std::fs::create_dir_all(parent).context("Failed to create storage directory")?; + std::fs::write(&path, data).context("Failed to write data to file")?; + + Ok(()) + } + + fn retrieve_data(&self, key: &str) -> Result>, Report> { + let path = self.path.join(key); + match std::fs::read(&path) { + Ok(data) => Ok(Some(data)), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(report!(e).context("Failed to read data from file").into()), + } + } + + fn store(&self, key: &str, value: &str) -> Result<(), Report> { + self.store_data(key, value.as_bytes()) + } + + fn retrieve(&self, key: &str) -> Result, Report> { + match self.retrieve_data(key) { + Ok(Some(data)) => Ok(Some(String::from_utf8_lossy(&data).into_owned())), + Ok(None) => Ok(None), + Err(e) => Err(e), + } + } +} diff --git a/src-tauri/vendor/isideload/src/util/keyring_storage.rs b/src-tauri/vendor/isideload/src/util/keyring_storage.rs new file mode 100644 index 0000000..2b7a78a --- /dev/null +++ b/src-tauri/vendor/isideload/src/util/keyring_storage.rs @@ -0,0 +1,64 @@ +use crate::util::storage::SideloadingStorage; +use keyring::Entry; +use rootcause::prelude::*; + +pub struct KeyringStorage { + pub service_name: String, +} + +impl KeyringStorage { + pub fn new(service_name: String) -> Self { + KeyringStorage { service_name } + } +} + +impl Default for KeyringStorage { + fn default() -> Self { + KeyringStorage { + service_name: "isideload".to_string(), + } + } +} + +impl SideloadingStorage for KeyringStorage { + fn store(&self, key: &str, value: &str) -> Result<(), Report> { + Entry::new(&self.service_name, key)?.set_password(value)?; + Ok(()) + } + + fn retrieve(&self, key: &str) -> Result, Report> { + let entry = Entry::new(&self.service_name, key)?; + match entry.get_password() { + Ok(password) => Ok(Some(password)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(e.into()), + } + } + + fn delete(&self, key: &str) -> Result<(), Report> { + let entry = Entry::new(&self.service_name, key)?; + match entry.delete_credential() { + Ok(()) => Ok(()), + Err(keyring::Error::NoEntry) => Ok(()), + Err(e) => Err(e.into()), + } + } + + // Linux doesn't seem to properly retrive binary secrets, so we don't use this implementation and instead let it fall back to base64 encoding. + // Windows fails to store the base64 encoded data because it is too long. + #[cfg(target_os = "windows")] + fn store_data(&self, key: &str, value: &[u8]) -> Result<(), Report> { + Entry::new(&self.service_name, key)?.set_secret(value)?; + Ok(()) + } + + #[cfg(target_os = "windows")] + fn retrieve_data(&self, key: &str) -> Result>, Report> { + let entry = Entry::new(&self.service_name, key)?; + match entry.get_secret() { + Ok(secret) => Ok(Some(secret)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(e.into()), + } + } +} diff --git a/src-tauri/vendor/isideload/src/util/mod.rs b/src-tauri/vendor/isideload/src/util/mod.rs new file mode 100644 index 0000000..abc79df --- /dev/null +++ b/src-tauri/vendor/isideload/src/util/mod.rs @@ -0,0 +1,7 @@ +pub mod device; +#[cfg(feature = "fs-storage")] +pub mod fs_storage; +#[cfg(feature = "keyring-storage")] +pub mod keyring_storage; +pub mod plist; +pub mod storage; diff --git a/src-tauri/vendor/isideload/src/util/plist.rs b/src-tauri/vendor/isideload/src/util/plist.rs new file mode 100644 index 0000000..6fc36e3 --- /dev/null +++ b/src-tauri/vendor/isideload/src/util/plist.rs @@ -0,0 +1,132 @@ +use plist::Dictionary; +use plist_macro::pretty_print_dictionary; +use rootcause::prelude::*; +use serde::de::DeserializeOwned; +use tracing::error; + +pub struct SensitivePlistAttachment { + pub plist: Dictionary, +} + +impl SensitivePlistAttachment { + pub fn new(plist: Dictionary) -> Self { + SensitivePlistAttachment { plist } + } + + pub fn from_text(text: &str) -> Self { + let dict: Result = plist::from_bytes(text.as_bytes()); + match dict { + Err(e) => { + error!( + "Failed to parse plist text for sensitive attachment, returning empty plist: {:?}", + e + ); + return SensitivePlistAttachment::new(Dictionary::new()); + } + Ok(d) => SensitivePlistAttachment::new(d), + } + } + + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // if env variable DEBUG_SENSITIVE is set, print full plist + if std::env::var("DEBUG_SENSITIVE").is_ok() { + return writeln!(f, "{}", pretty_print_dictionary(&self.plist)); + } + writeln!( + f, + "" + ) + } +} + +impl std::fmt::Display for SensitivePlistAttachment { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.fmt(f) + } +} + +impl std::fmt::Debug for SensitivePlistAttachment { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.fmt(f) + } +} + +pub trait PlistDataExtract { + fn get_data(&self, key: &str) -> Result<&[u8], Report>; + fn get_str(&self, key: &str) -> Result<&str, Report>; + fn get_string(&self, key: &str) -> Result; + fn get_signed_integer(&self, key: &str) -> Result; + fn get_dict(&self, key: &str) -> Result<&Dictionary, Report>; + fn get_bool(&self, key: &str) -> Result; + fn get_struct(&self, key: &str) -> Result; +} + +impl PlistDataExtract for Dictionary { + fn get_data(&self, key: &str) -> Result<&[u8], Report> { + self.get(key).and_then(|v| v.as_data()).ok_or_else(|| { + report!("Plist missing data for key '{}'", key) + .attach(SensitivePlistAttachment::new(self.clone())) + }) + } + + fn get_str(&self, key: &str) -> Result<&str, Report> { + self.get(key).and_then(|v| v.as_string()).ok_or_else(|| { + report!("Plist missing string for key '{}'", key) + .attach(SensitivePlistAttachment::new(self.clone())) + }) + } + + fn get_string(&self, key: &str) -> Result { + self.get(key) + .and_then(|v| v.as_string()) + .map(|s| s.to_string()) + .ok_or_else(|| { + report!("Plist missing string for key '{}'", key) + .attach(SensitivePlistAttachment::new(self.clone())) + }) + } + + fn get_signed_integer(&self, key: &str) -> Result { + self.get(key) + .and_then(|v| v.as_signed_integer()) + .ok_or_else(|| { + report!("Plist missing signed integer for key '{}'", key) + .attach(SensitivePlistAttachment::new(self.clone())) + }) + } + + fn get_dict(&self, key: &str) -> Result<&Dictionary, Report> { + self.get(key) + .and_then(|v| v.as_dictionary()) + .ok_or_else(|| { + report!("Plist missing dictionary for key '{}'", key) + .attach(SensitivePlistAttachment::new(self.clone())) + }) + } + + fn get_struct(&self, key: &str) -> Result { + let dict = self.get(key); + let dict = dict.ok_or_else(|| { + report!("Plist missing dictionary for key '{}'", key) + .attach(SensitivePlistAttachment::new(self.clone())) + })?; + let struct_data: T = plist::from_value(dict).map_err(|e| { + report!( + "Failed to deserialize plist struct for key '{}': {:?}", + key, + e + ) + .attach(SensitivePlistAttachment::new( + dict.as_dictionary().cloned().unwrap_or_default(), + )) + })?; + Ok(struct_data) + } + + fn get_bool(&self, key: &str) -> Result { + self.get(key).and_then(|v| v.as_boolean()).ok_or_else(|| { + report!("Plist missing boolean for key '{}'", key) + .attach(SensitivePlistAttachment::new(self.clone())) + }) + } +} diff --git a/src-tauri/vendor/isideload/src/util/storage.rs b/src-tauri/vendor/isideload/src/util/storage.rs new file mode 100644 index 0000000..3a956fc --- /dev/null +++ b/src-tauri/vendor/isideload/src/util/storage.rs @@ -0,0 +1,91 @@ +use std::{collections::HashMap, sync::Mutex}; + +use base64::prelude::*; +use rootcause::prelude::*; + +/// A trait for storing and retrieving sideloading related data, such as anisette state and certificates. +pub trait SideloadingStorage: Send + Sync { + fn store(&self, key: &str, value: &str) -> Result<(), Report>; + fn retrieve(&self, key: &str) -> Result, Report>; + + fn store_data(&self, key: &str, value: &[u8]) -> Result<(), Report> { + self.store(key, &BASE64_STANDARD.encode(value)) + } + + fn retrieve_data(&self, key: &str) -> Result>, Report> { + if let Some(value) = self.retrieve(key)? { + Ok(Some(BASE64_STANDARD.decode(value)?)) + } else { + Ok(None) + } + } + + fn delete(&self, key: &str) -> Result<(), Report> { + self.store(key, "") + } +} + +/// Factory function to create a new storage instance based on enabled features. The priority is `keyring-storage`, then `fs-storage`, and finally an in-memory storage if neither of those features are enabled. +pub fn new_storage() -> impl SideloadingStorage { + #[cfg(feature = "keyring-storage")] + { + return crate::util::keyring_storage::KeyringStorage::default(); + } + #[cfg(all(feature = "fs-storage", not(feature = "keyring-storage")))] + { + return crate::util::fs_storage::FsStorage::default(); + } + #[cfg(not(any(feature = "keyring-storage", feature = "fs-storage")))] + { + tracing::warn!( + "Keyring and fs storage not enabled, falling back to in-memory storage. This means that the anisette state and certificates will not be saved across runs. Enable the 'keyring-storage' or 'fs-storage' feature for persistance." + ); + return InMemoryStorage::new(); + } +} + +pub struct InMemoryStorage { + storage: Mutex>, +} + +impl Default for InMemoryStorage { + fn default() -> Self { + Self::new() + } +} + +impl InMemoryStorage { + pub fn new() -> Self { + InMemoryStorage { + storage: Mutex::new(HashMap::new()), + } + } +} + +impl SideloadingStorage for InMemoryStorage { + fn store(&self, key: &str, value: &str) -> Result<(), Report> { + let mut storage = self + .storage + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + storage.insert(key.to_string(), value.to_string()); + Ok(()) + } + + fn retrieve(&self, key: &str) -> Result, Report> { + let storage = self + .storage + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + Ok(storage.get(key).cloned()) + } + + fn delete(&self, key: &str) -> Result<(), Report> { + let mut storage = self + .storage + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + storage.remove(key); + Ok(()) + } +} diff --git a/src/App.css b/src/App.css index e24c841..d10d331 100644 --- a/src/App.css +++ b/src/App.css @@ -240,7 +240,8 @@ button { button:disabled { cursor: not-allowed; - opacity: 0.5; + opacity: 0.32; + filter: grayscale(0.6); } button:hover:not(:disabled) { diff --git a/src/App.tsx b/src/App.tsx index 85bcd91..ea9b725 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -178,6 +178,11 @@ function App() { return () => window.removeEventListener("keydown", handler); }, [platform, ensureSelectedDevice, ensuredLoggedIn]); + // A Vision Pro currently has only the patched stable SideStore build (there are no + // visionOS nightly / LiveContainer builds yet), so those installers are disabled for + // it. Import IPA still works over the tunnel. + const isVisionSelected = selectedDevice?.transport === "vision"; + return (
@@ -301,17 +306,30 @@ function App() {

{t("app.installers")}

- {t("app.choose_build")} + + {isVisionSelected + ? t("app.vision_stable_only") + : t("app.choose_build")} +
+
+ ); + } +} diff --git a/src/Device.css b/src/Device.css index 939d128..e29c62f 100644 --- a/src/Device.css +++ b/src/Device.css @@ -51,3 +51,20 @@ border: 1px solid rgba(255, 255, 255, 0.18); font-size: 0.9rem; } + +.no-devices { + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.no-devices-hint { + font-size: 0.85rem; + color: var(--text-muted); + line-height: 1.45; + max-width: 46ch; +} + +.no-devices-hint--error { + color: var(--accent-warning, #e0a100); +} diff --git a/src/Device.tsx b/src/Device.tsx index 905359d..e31c103 100644 --- a/src/Device.tsx +++ b/src/Device.tsx @@ -1,20 +1,55 @@ import { useCallback, useEffect, useRef, useState } from "react"; import "./Device.css"; import { invoke } from "@tauri-apps/api/core"; +import { emit, listen } from "@tauri-apps/api/event"; import { toast } from "sonner"; import { useTranslation } from "react-i18next"; import { Modal } from "./components/Modal"; import { useError } from "./ErrorContext"; import { AppError } from "./errors"; +import { usePlatform } from "./PlatformContext"; export type DeviceInfo = { name: string; id: number; uuid: string; - connectionType: "USB" | "Network" | "Unknown"; + connectionType: "USB" | "Network" | "Unknown" | "Wireless"; version: string; + deviceClass?: string; + productType?: string; + /// Transport used to reach the device. "vision" = Apple Vision Pro over an RP + /// tunnel (Wi-Fi, no usbmux); otherwise a usbmux iPhone/iPad. + transport?: "usbmux" | "vision"; + /// Vision Pro IP address (only set for transport === "vision"). + ip?: string; + /// For a Vision Pro: whether a reusable pairing file is already stored, so it can + /// be selected directly instead of prompting for the headset code. + paired?: boolean; }; +/// True when a device must be reached over the Vision Pro RP tunnel rather than +/// usbmux. (A Vision Pro tethered over USB with a dev strap stays on the usbmux path.) +const isVisionTransport = (device: DeviceInfo): boolean => + device.transport === "vision"; + +// Human-friendly device family derived from the lockdown DeviceClass / +// ProductType. Lets an Apple Vision Pro ("RealityDevice" / "RealityDevice17,1") +// be recognised and labelled instead of shown as a generic idevice. +export const deviceFamilyLabel = (device: DeviceInfo): string | null => { + const cls = device.deviceClass?.toLowerCase() ?? ""; + const product = device.productType?.toLowerCase() ?? ""; + if (cls === "realitydevice" || product.startsWith("realitydevice")) + return "Apple Vision Pro"; + if (cls === "ipad") return "iPad"; + if (cls === "iphone") return "iPhone"; + if (cls === "ipod") return "iPod touch"; + if (cls === "appletv") return "Apple TV"; + if (cls === "watch") return "Apple Watch"; + return device.deviceClass ?? null; +}; + +type VisionStatus = null | "connecting" | "awaiting-code" | "verifying" | "paired"; + export const Device = ({ selectedDevice, setSelectedDevice, @@ -25,13 +60,27 @@ export const Device = ({ registerRefresh?: (fn?: () => void) => void; }) => { const { t } = useTranslation(); + const { platform } = usePlatform(); const [devices, setDevices] = useState([]); + // Why the last refresh found nothing, when mDNS itself couldn't start (e.g. macOS + // Local Network permission denied / a firewall ate multicast). `null` = discovery + // is running fine, so the empty state falls back to the generic "check permission" + // hint rather than a specific error. + const [discoveryError, setDiscoveryError] = useState(null); const [waitingToPair, setWaitingToPair] = useState(null); const [showPairingModal, setShowPairingModal] = useState(false); + // Vision Pro first-time pairing (headset shows a code; the user types it here). + const [visionPairing, setVisionPairing] = useState(null); + const [visionStatus, setVisionStatus] = useState(null); + const [visionCode, setVisionCode] = useState(""); + const listingDevices = useRef(false); const pairingRequestId = useRef(0); const pairingModalTimer = useRef | null>(null); + // Re-entry guard for Vision Pro pairing, kept out of hook deps so it doesn't + // churn the identity of selectDevice/loadDevices (which would reload mid-pairing). + const visionPairingActive = useRef(false); const { err } = useError(); @@ -48,8 +97,65 @@ export const Device = ({ }; }, [clearPairingModalTimer]); + // First-time wireless pairing with a Vision Pro. The headset displays a 6-digit + // code; the backend blocks until we send it back via the `vision-pair-code` event. + const startVisionPair = useCallback( + async (device: DeviceInfo) => { + if (visionPairingActive.current) return; + visionPairingActive.current = true; + setVisionPairing(device); + setVisionStatus("connecting"); + setVisionCode(""); + + const unlisten = await listen("vision-pair-status", (e) => + setVisionStatus(e.payload as VisionStatus), + ); + + try { + await invoke("vision_pair", { device }); + toast.success(t("device.vision_paired", { device: device.name })); + // The backend has already made this the selected device; reflect it locally + // and mark the card paired without a full re-list. + const paired = { ...device, paired: true }; + setDevices((prev) => + prev.map((d) => (d.id === device.id ? paired : d)), + ); + setSelectedDevice(paired); + } catch (e) { + const message = String((e as { message?: string })?.message ?? e); + if (!message.toLowerCase().includes("cancel")) { + toast.error(err(t("device.vision_pair_failed"), e as AppError)); + } + } finally { + unlisten(); + visionPairingActive.current = false; + setVisionPairing(null); + setVisionStatus(null); + setVisionCode(""); + } + }, + [setSelectedDevice, err, t], + ); + + const submitVisionCode = useCallback(async () => { + const code = visionCode.replace(/\D/g, "").slice(0, 6); + if (code.length < 6) return; + await emit("vision-pair-code", code); + }, [visionCode]); + + const cancelVisionPair = useCallback(() => { + invoke("cancel_pairing").catch(() => {}); + }, []); + const selectDevice = useCallback( (device: DeviceInfo | null) => { + // An unpaired Vision Pro can't be selected silently — it needs the headset + // code — so route it to the wireless pairing flow instead. + if (device && isVisionTransport(device) && !device.paired) { + startVisionPair(device); + return; + } + const requestId = ++pairingRequestId.current; clearPairingModalTimer(); setShowPairingModal(false); @@ -87,7 +193,7 @@ export const Device = ({ setWaitingToPair(null); }); }, - [clearPairingModalTimer, setSelectedDevice, t], + [clearPairingModalTimer, setSelectedDevice, startVisionPair, t], ); const loadDevices = useCallback(async () => { @@ -109,6 +215,11 @@ export const Device = ({ } setDevices(devices); + // Surface a mDNS-startup failure so an empty list isn't mistaken for "no + // device present" (the common macOS Local Network permission trap). + invoke("vision_discovery_error") + .then((msg) => setDiscoveryError(msg ?? null)) + .catch(() => setDiscoveryError(null)); if (selectedDevice) { const stillAvailable = devices.find( (d) => d.id === selectedDevice.id, @@ -120,6 +231,11 @@ export const Device = ({ if (devices.length > 0) { const devicesWithPairing = await Promise.all( devices.map(async (device) => { + // A Vision Pro reports its paired state directly; other devices are + // checked against the stored RemotePairing cache. + if (isVisionTransport(device)) { + return device.paired ? device : null; + } const hasPairing = await invoke("has_stored_rppairing", { device, }); @@ -197,35 +313,120 @@ export const Device = ({ + +
+ {visionStatus === "paired" ? ( +

{t("device.vision_paired_short")}

+ ) : visionStatus === "awaiting-code" ? ( + <> +

{t("device.vision_enter_code")}

+

+ {t("device.vision_enter_code_hint", { + device: visionPairing?.name ?? "Vision Pro", + })} +

+ + setVisionCode(e.target.value.replace(/\D/g, "").slice(0, 6)) + } + onKeyDown={(e) => { + if (e.key === "Enter") submitVisionCode(); + }} + placeholder="000000" + style={{ + fontSize: 32, + fontWeight: 700, + letterSpacing: 8, + textAlign: "center", + width: 240, + maxWidth: "100%", + padding: "10px 14px", + boxSizing: "border-box", + margin: "12px 0", + }} + /> + + + ) : visionStatus === "verifying" ? ( + <> +
+

{t("device.vision_verifying")}

+ + ) : ( + <> +
+

+ {t("device.vision_connecting", { + device: visionPairing?.name ?? "Vision Pro", + })} +

+

{t("device.vision_connecting_hint")}

+ + )} + +
+

{t("device.title")}

{devices.length === 0 && ( -
{t("device.no_devices_found_period")}
+
+
{t("device.no_devices_found_period")}
+ {discoveryError ? ( +
+ {t("device.discovery_error_hint", { error: discoveryError })} +
+ ) : platform === "mac" ? ( +
+ {t("device.no_devices_hint_mac")} +
+ ) : null} +
)} {devices.map((device) => { const isActive = selectedDevice?.id === device.id; + const needsPairing = + isVisionTransport(device) && !device.paired; return ( ); })} -
diff --git a/src/errors.tsx b/src/errors.tsx index b4baa74..5adfd99 100644 --- a/src/errors.tsx +++ b/src/errors.tsx @@ -12,6 +12,16 @@ export const errorSuggestionKeys = { "error.suggestions.device_coms", ], remote_pairing: ["error.suggestions.trust", "error.suggestions.pairing"], + // The vision_* messages carry their own detailed hints from the backend, so no + // generic suggestions — but the keys MUST exist: getErrorSuggestions indexes this + // map, and an unmapped variant used to crash the error modal's render, unmounting + // the whole app into a black window (shipped in 2.2.13 with vision_unreachable — + // every "couldn't reach the headset" error black-screened instead of explaining). + vision_unreachable: [], + vision_pairing_rejected: [ + "error.suggestions.trust", + "error.suggestions.pairing", + ], lockdown_pairing: ["error.suggestions.trust", "error.suggestions.pairing"], canceled: [], operation_update: [], @@ -120,10 +130,11 @@ export const getErrorSuggestions = ( platform: "mac" | "windows" | "linux", anisetteServer: string, ): string[] => { + // The backend can grow error variants ahead of this map; an unknown one must + // degrade to "no suggestions", never crash the error modal. + const keys = errorSuggestionKeys[type] ?? []; return dedupeSuggestions( - errorSuggestionKeys[type].flatMap((key) => - getSuggestionBlock(t, key, platform, anisetteServer), - ), + keys.flatMap((key) => getSuggestionBlock(t, key, platform, anisetteServer)), ); }; diff --git a/src/locales/en.json b/src/locales/en.json index 17d70e0..1c876e3 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -35,6 +35,7 @@ "select_device": "Select a device", "installers": "Installers", "choose_build": "Choose a build", + "vision_stable_only": "Vision Pro: stable builds only (no nightly)", "sidestore_stable": "SideStore (Stable)", "sidestore_nightly": "SideStore (Nightly)", "livecontainer_sidestore_stable": "LiveContainer + SideStore (Stable)", @@ -89,11 +90,23 @@ "found_devices": "Found devices", "unable_load_devices_prefix": "Unable to load devices", "no_devices_found_period": "No devices found.", + "no_devices_hint_mac": "No Vision Pro? Make sure iloader has Local Network permission (System Settings > Privacy & Security > Local Network), that iloader is in your Applications folder, and that your Mac and Vision Pro are on the same Wi-Fi network.", + "discovery_error_hint": "{{error}}. On macOS, open System Settings > Privacy & Security > Local Network and enable iloader, keep iloader in your Applications folder, and turn off any VPN or firewall that could block local networking.", "selected": "Selected", "pairing_in_progress_header": "Pairing with {{device}}...", "pairing_in_progress_hint": "Please unlock your device and click \"Trust,\" then enter your passcode if prompted.", "pairing_cancel": "Cancel", - "failed_select": "Failed to select device" + "failed_select": "Failed to select device", + "vision_tap_to_pair": "Tap to pair", + "vision_connecting": "Connecting to {{device}}…", + "vision_connecting_hint": "Put on the headset and open the Settings app on your Vision Pro — the 6-digit pairing code appears there. It will not pop up on its own.", + "vision_enter_code": "Enter the code from your Vision Pro", + "vision_enter_code_hint": "Open the Settings app on {{device}} to find the 6-digit code, then enter it here.", + "vision_pair_submit": "Pair", + "vision_verifying": "Verifying pairing…", + "vision_paired": "Paired with {{device}}", + "vision_paired_short": "Paired!", + "vision_pair_failed": "Vision Pro pairing failed" }, "operation": { "failed": "Operation failed.", @@ -105,12 +118,14 @@ "install_sidestore_title": "Installing SideStore", "install_sidestore_success_title": "SideStore Installed!", "install_sidestore_success_message": "Open SideStore and refresh it to complete the installation!", + "install_sidestore_success_message_vision": "Open SideStore and refresh it to complete the installation! Then install LocalDevVPN from the App Store and enable the VPN to sideload apps.", "install_sidestore_step_download": "Download SideStore", "install_sidestore_step_install": "Sign & Install SideStore", "install_sidestore_step_pairing": "Place Pairing File", "install_livecontainer_title": "Installing LiveContainer+SideStore", "install_livecontainer_success_title": "LiveContainer+SideStore Installed!", "install_livecontainer_success_message": "To complete the installation, open LiveContainer, choose settings, and click \"Import Certificate From SideStore.\" Then, choose apps, click the sidestore icon, and refresh LiveContainer.", + "install_livecontainer_success_message_vision": "To complete the installation, open LiveContainer, choose settings, and click \"Import Certificate From SideStore.\" Then install LocalDevVPN from the App Store and enable the VPN to sideload apps.", "install_livecontainer_step_download": "Download LiveContainer+SideStore", "install_livecontainer_step_install": "Sign & Install LiveContainer+SideStore", "install_livecontainer_step_pairing": "Place Pairing File", diff --git a/src/main.tsx b/src/main.tsx index ec8c10c..fba9fb4 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -8,20 +8,23 @@ import { ErrorProvider } from "./ErrorContext"; import { DialogProvider } from "./DialogContext"; import "./i18next"; import { PlatformProvider } from "./PlatformContext"; +import { CrashBoundary } from "./CrashBoundary"; ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - - - - - - - - - - + + + + + + + + + + + + + , );