From 5aacf0970d262901b30850a551fbf21d4155652a Mon Sep 17 00:00:00 2001 From: ZomkaDEV Date: Fri, 4 Sep 2026 16:18:17 +0300 Subject: [PATCH 1/2] feat: initial support for remote tvos pairing --- Cargo.lock | 56 +++- Cargo.toml | 4 + apps/plumesign/src/commands/mod.rs | 3 + apps/plumesign/src/commands/pair.rs | 198 ++++++++++++ apps/plumesign/src/main.rs | 1 + crates/plume_utils/Cargo.toml | 1 + crates/plume_utils/src/lib.rs | 11 + crates/plume_utils/src/wireless/discovery.rs | 210 ++++++++++++ crates/plume_utils/src/wireless/mod.rs | 10 + crates/plume_utils/src/wireless/pairing.rs | 91 ++++++ crates/plume_utils/src/wireless/store.rs | 320 +++++++++++++++++++ 11 files changed, 903 insertions(+), 2 deletions(-) create mode 100644 apps/plumesign/src/commands/pair.rs create mode 100644 crates/plume_utils/src/wireless/discovery.rs create mode 100644 crates/plume_utils/src/wireless/mod.rs create mode 100644 crates/plume_utils/src/wireless/pairing.rs create mode 100644 crates/plume_utils/src/wireless/store.rs diff --git a/Cargo.lock b/Cargo.lock index c6218a53..d00910e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2474,6 +2474,17 @@ dependencies = [ "zlib-rs", ] +[[package]] +name = "flume" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e139bc46ca777eb5efaf62df0ab8cc5fd400866427e56c68b22e414e53bd3be" +dependencies = [ + "futures-core", + "futures-sink", + "spin", +] + [[package]] name = "fnv" version = "1.0.7" @@ -3766,6 +3777,16 @@ 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 = "ignore" version = "0.4.25" @@ -4155,9 +4176,9 @@ checksum = "b3a6a8c165077efc8f3a971534c50ea6a1a18b329ef4a66e897a7e3a1494565f" [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libloading" @@ -4333,6 +4354,21 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "mdns-sd" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0a19dd805348943831582c4d9e6921c66de689127d6b27665a4e155c2117799" +dependencies = [ + "fastrand", + "flume", + "if-addrs", + "log", + "mio", + "socket-pktinfo", + "socket2", +] + [[package]] name = "memchr" version = "2.8.0" @@ -4439,6 +4475,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" dependencies = [ "libc", + "log", "wasi", "windows-sys 0.61.2", ] @@ -5675,6 +5712,7 @@ dependencies = [ "idevice", "image", "log", + "mdns-sd", "plist", "plume_core", "plume_store", @@ -7277,6 +7315,17 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "socket-pktinfo" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "612942246d0cc239cfd83af1dfd39be47f649208a3524e5e9da651910128e0ac" +dependencies = [ + "libc", + "socket2", + "windows-sys 0.61.2", +] + [[package]] name = "socket2" version = "0.6.3" @@ -7335,6 +7384,9 @@ name = "spin" version = "0.9.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +dependencies = [ + "lock_api", +] [[package]] name = "spirv" diff --git a/Cargo.toml b/Cargo.toml index 14169767..73a26c42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -53,6 +53,10 @@ rustls = { version = "0.23", default-features = false, features = [ "ring", ] } image = { version = "0.25", default-features = false, features = ["png", "jpeg"] } +# Discovery +# `async` (default) is required: it enables the flume async receiver used to +# poll browse events from the tokio runtime. +mdns-sd = "0.21" # Compression flate2 = "1.1" zip = "8.6" diff --git a/apps/plumesign/src/commands/mod.rs b/apps/plumesign/src/commands/mod.rs index b418c48c..d3bdf02f 100644 --- a/apps/plumesign/src/commands/mod.rs +++ b/apps/plumesign/src/commands/mod.rs @@ -3,6 +3,7 @@ use clap::{Parser, Subcommand}; pub mod account; pub mod device; pub mod macho; +pub mod pair; pub mod sign; #[derive(Debug, Parser)] @@ -29,4 +30,6 @@ pub enum Commands { Account(account::AccountArgs), /// Device management commands Device(device::DeviceArgs), + /// Pair with a device over the network + Pair(pair::PairArgs), } diff --git a/apps/plumesign/src/commands/pair.rs b/apps/plumesign/src/commands/pair.rs new file mode 100644 index 00000000..89c07d79 --- /dev/null +++ b/apps/plumesign/src/commands/pair.rs @@ -0,0 +1,198 @@ +use std::io::{BufRead, Write}; +use std::net::{IpAddr, SocketAddr}; +use std::time::Duration; + +use anyhow::{Result, bail}; +use clap::{Args, Subcommand}; +use plume_utils::wireless::{PairingStore, ServiceKind, discover, pair}; + +use crate::get_data_path; + +const DEFAULT_HOST: &str = "plume"; + +#[derive(Debug, Args)] +pub struct PairArgs { + #[command(subcommand)] + pub command: Option, + /// IP address of the device to pair with + #[arg(long = "ip", value_name = "IP", requires = "port")] + pub ip: Option, + /// Port the device advertises its pairing service on + #[arg(long = "port", value_name = "PORT", requires = "ip")] + pub port: Option, + /// PIN shown on the device screen (read from stdin if not given) + #[arg(long = "pin", value_name = "PIN")] + pub pin: Option, + /// Name to present to the device + #[arg(long = "host", value_name = "NAME", default_value = DEFAULT_HOST)] + pub host: String, +} + +#[derive(Debug, Subcommand)] +pub enum PairCommands { + /// Browse the network for devices that can be paired with + Discover(DiscoverArgs), + /// List devices this host has already paired with + List, + /// Identify a paired device from its advertised mDNS identifier and auth tag + Find(FindArgs), + /// Delete a stored pairing + Forget(ForgetArgs), +} + +#[derive(Debug, Args)] +pub struct DiscoverArgs { + /// How long to browse for, in seconds + #[arg( + short = 't', + long = "timeout", + value_name = "SECONDS", + default_value_t = 5 + )] + pub timeout: u64, +} + +#[derive(Debug, Args)] +pub struct FindArgs { + /// `identifier` TXT record from the device's _remotepairing._tcp service + #[arg(long = "identifier", value_name = "IDENTIFIER", required = true)] + pub identifier: String, + /// `authTag` TXT record from the device's _remotepairing._tcp service + #[arg(long = "auth-tag", value_name = "AUTH_TAG", required = true)] + pub auth_tag: String, +} + +#[derive(Debug, Args)] +pub struct ForgetArgs { + /// UDID of the device to forget + #[arg(short = 'u', long = "udid", value_name = "UDID", required = true)] + pub udid: String, +} + +pub async fn execute(args: PairArgs) -> Result<()> { + let store = PairingStore::new(get_data_path().join("remotepairing")); + + match args.command { + Some(PairCommands::Discover(discover_args)) => run_discover(&store, discover_args).await, + Some(PairCommands::List) => run_list(&store).await, + Some(PairCommands::Find(find_args)) => run_find(&store, find_args).await, + Some(PairCommands::Forget(forget_args)) => run_forget(&store, forget_args).await, + None => run_pair(&store, args).await, + } +} + +async fn run_pair(store: &PairingStore, args: PairArgs) -> Result<()> { + let (Some(ip), Some(port)) = (args.ip, args.port) else { + bail!("--ip and --port are required to pair; run `plumesign pair discover` to find them"); + }; + let address = SocketAddr::new(ip, port); + + log::info!("Pairing with {address} as {:?}", args.host); + + let (pairing_file, device) = pair(address, &args.host, || { + let pin = args.pin.clone(); + async move { + match pin { + Some(pin) => pin, + None => read_pin(), + } + } + }) + .await?; + + let path = store.save(&device, &pairing_file).await?; + + log::info!( + "Paired with {} ({}), UDID {}", + device.name, + device.model, + device.udid + ); + println!("{}", path.display()); + + Ok(()) +} + +async fn run_discover(store: &PairingStore, args: DiscoverArgs) -> Result<()> { + let devices = discover(Duration::from_secs(args.timeout)).await?; + + if devices.is_empty() { + log::warn!("No devices found. Check that the device is awake and on this network."); + return Ok(()); + } + + for device in devices { + let known = match (&device.identifier, &device.auth_tag) { + (Some(identifier), Some(auth_tag)) => store.find(identifier, auth_tag).await?, + _ => None, + }; + + match (device.kind, known) { + (_, Some(record)) => println!("{device} paired as {}", record.udid), + (ServiceKind::ManualPairing, None) => println!( + "{device} run: {} pair --ip {} --port {}", + invocation(), + device.address.ip(), + device.address.port() + ), + (ServiceKind::RemotePairing, None) => println!("{device} paired with another host"), + } + } + + Ok(()) +} + +async fn run_list(store: &PairingStore) -> Result<()> { + let records = store.list().await?; + + if records.is_empty() { + log::warn!("No paired devices in {}", store.directory().display()); + return Ok(()); + } + + for record in records { + println!("{record}"); + } + + Ok(()) +} + +async fn run_find(store: &PairingStore, args: FindArgs) -> Result<()> { + match store.find(&args.identifier, &args.auth_tag).await? { + Some(record) => { + println!("{record}"); + Ok(()) + } + None => bail!("No stored pairing matches identifier {}", args.identifier), + } +} + +async fn run_forget(store: &PairingStore, args: ForgetArgs) -> Result<()> { + if store.remove(&args.udid).await? { + log::info!("Forgot pairing for {}", args.udid); + Ok(()) + } else { + bail!("No stored pairing for {}", args.udid) + } +} + +fn invocation() -> String { + std::env::args() + .next() + .filter(|arg| !arg.is_empty()) + .unwrap_or_else(|| "plumesign".to_string()) +} + +/// Plain stdin read rather than a prompt widget: this is often driven by another +/// process writing the PIN to the child's stdin, where there is no terminal. +fn read_pin() -> String { + print!("Enter the PIN shown on the device: "); + let _ = std::io::stdout().flush(); + + let mut line = String::new(); + if std::io::stdin().lock().read_line(&mut line).is_err() { + return String::new(); + } + + line.trim().to_string() +} diff --git a/apps/plumesign/src/main.rs b/apps/plumesign/src/main.rs index b0f933d7..56df77b1 100644 --- a/apps/plumesign/src/main.rs +++ b/apps/plumesign/src/main.rs @@ -19,6 +19,7 @@ async fn main() -> anyhow::Result<()> { Commands::MachO(args) => commands::macho::execute(args).await?, Commands::Account(args) => commands::account::execute(args).await?, Commands::Device(args) => commands::device::execute(args).await?, + Commands::Pair(args) => commands::pair::execute(args).await?, } Ok(()) diff --git a/crates/plume_utils/Cargo.toml b/crates/plume_utils/Cargo.toml index 60ab60ee..94bc2d1e 100644 --- a/crates/plume_utils/Cargo.toml +++ b/crates/plume_utils/Cargo.toml @@ -16,6 +16,7 @@ tokio.workspace = true futures.workspace = true log.workspace = true image.workspace = true +mdns-sd.workspace = true goblin.workspace = true zip.workspace = true flate2.workspace = true diff --git a/crates/plume_utils/src/lib.rs b/crates/plume_utils/src/lib.rs index 14fa36db..cee0f70a 100644 --- a/crates/plume_utils/src/lib.rs +++ b/crates/plume_utils/src/lib.rs @@ -5,6 +5,7 @@ mod options; mod package; mod signer; mod tweak; +pub mod wireless; use std::path::Path; @@ -53,6 +54,16 @@ pub enum Error { Idevice(#[from] idevice::IdeviceError), #[error("Codesign error: {0}")] Codesign(#[from] plume_core::AppleCodesignError), + // Wireless pairing + #[error("mDNS error: {0}")] + Mdns(#[from] mdns_sd::Error), + #[error("The device did not accept the pairing, it needs to be paired again")] + PairingNotAccepted, + #[error("No stored pairing for device: {0}")] + PairingNotFound(String), + #[error("Refusing to use an unsafe device identifier as a file name: {0}")] + PairingInvalidUdid(String), + #[error("Other error: {0}")] Other(String), #[error("Image error: {0}")] diff --git a/crates/plume_utils/src/wireless/discovery.rs b/crates/plume_utils/src/wireless/discovery.rs new file mode 100644 index 00000000..26fe435a --- /dev/null +++ b/crates/plume_utils/src/wireless/discovery.rs @@ -0,0 +1,210 @@ +use std::collections::HashMap; +use std::net::{IpAddr, Ipv4Addr, SocketAddr, SocketAddrV6}; +use std::time::Duration; + +use mdns_sd::{ResolvedService, ScopedIp, ServiceDaemon, ServiceEvent}; + +use crate::Error; + +pub const MANUAL_PAIRING_SERVICE: &str = "_remotepairing-manual-pairing._tcp.local."; +pub const REMOTE_PAIRING_SERVICE: &str = "_remotepairing._tcp.local."; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ServiceKind { + /// Offering to pair, and will show a PIN when connected to. + ManualPairing, + /// Already paired, though not necessarily with this host. + RemotePairing, +} + +impl ServiceKind { + fn service_type(self) -> &'static str { + match self { + Self::ManualPairing => MANUAL_PAIRING_SERVICE, + Self::RemotePairing => REMOTE_PAIRING_SERVICE, + } + } +} + +impl std::fmt::Display for ServiceKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::ManualPairing => write!(f, "pairable"), + Self::RemotePairing => write!(f, "paired"), + } + } +} + +#[derive(Debug, Clone)] +pub struct DiscoveredDevice { + pub kind: ServiceKind, + pub service_name: String, + pub hostname: String, + pub name: Option, + pub identifier: Option, + pub auth_tag: Option, + pub address: SocketAddr, +} + +impl DiscoveredDevice { + pub fn display_name(&self) -> &str { + self.name.as_deref().unwrap_or(&self.hostname) + } + + fn from_resolved(kind: ServiceKind, service: &ResolvedService) -> Option { + if !service.is_valid() { + return None; + } + + Some(Self { + kind, + service_name: service.get_fullname().to_string(), + hostname: trim_local(service.get_hostname()), + name: txt(service, "name"), + identifier: txt(service, "identifier"), + auth_tag: txt(service, "authTag"), + address: best_address(service)?, + }) + } +} + +impl std::fmt::Display for DiscoveredDevice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "[{}] {} ({})", + self.kind, + self.display_name(), + self.address + ) + } +} + +/// Browses both RemotePairing services for `timeout` and returns everything seen. +pub async fn discover(timeout: Duration) -> Result, Error> { + let daemon = ServiceDaemon::new()?; + + let browses = [ + ( + ServiceKind::ManualPairing, + daemon.browse(ServiceKind::ManualPairing.service_type())?, + ), + ( + ServiceKind::RemotePairing, + daemon.browse(ServiceKind::RemotePairing.service_type())?, + ), + ]; + + // Keyed by service name so devices re-announcing every few seconds collapse + // into one entry. + let mut found: HashMap = HashMap::new(); + let deadline = tokio::time::Instant::now() + timeout; + + loop { + let (kind, event) = tokio::select! { + _ = tokio::time::sleep_until(deadline) => break, + event = browses[0].1.recv_async() => (browses[0].0, event), + event = browses[1].1.recv_async() => (browses[1].0, event), + }; + + // Both receivers share a daemon, so a closed channel means neither will + // produce anything further. + let Ok(event) = event else { break }; + + match event { + ServiceEvent::ServiceResolved(service) => { + if let Some(device) = DiscoveredDevice::from_resolved(kind, &service) { + found.insert(device.service_name.clone(), device); + } + } + ServiceEvent::ServiceRemoved(_, service_name) => { + found.remove(&service_name); + } + _ => {} + } + } + + if let Err(e) = daemon.shutdown() { + log::debug!("failed to shut down mDNS daemon: {e}"); + } + + let mut devices: Vec<_> = found.into_values().collect(); + devices.sort_by(|a, b| { + a.display_name() + .cmp(b.display_name()) + .then_with(|| a.service_name.cmp(&b.service_name)) + }); + + Ok(devices) +} + +fn txt(service: &ResolvedService, key: &str) -> Option { + service + .get_property_val_str(key) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn trim_local(hostname: &str) -> String { + hostname + .trim_end_matches('.') + .trim_end_matches(".local") + .to_string() +} + +fn best_address(service: &ResolvedService) -> Option { + let port = service.get_port(); + + // Addresses arrive in a HashSet, so take the lowest rather than the first + // iterated to keep repeated scans stable. + let mut v4: Vec = service + .get_addresses() + .iter() + .filter_map(|ip| match ip { + ScopedIp::V4(v4) => Some(*v4.addr()), + _ => None, + }) + .filter(|addr| !addr.is_loopback() && !addr.is_unspecified()) + .collect(); + v4.sort(); + + if let Some(addr) = v4.first() { + return Some(SocketAddr::new(IpAddr::V4(*addr), port)); + } + + // Link-local v6 is only routable with the scope id of the interface it was + // discovered on. + let mut v6: Vec = service + .get_addresses() + .iter() + .filter_map(|ip| match ip { + ScopedIp::V6(v6) => Some(v6), + _ => None, + }) + .filter(|v6| !v6.addr().is_loopback() && !v6.addr().is_unspecified()) + .map(|v6| SocketAddrV6::new(*v6.addr(), port, 0, v6.scope_id().index)) + .collect(); + v6.sort(); + + v6.into_iter().next().map(SocketAddr::V6) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn trim_local_strips_mdns_suffix() { + assert_eq!(trim_local("Living-Room.local."), "Living-Room"); + assert_eq!(trim_local("Living-Room.local"), "Living-Room"); + assert_eq!(trim_local("Living-Room"), "Living-Room"); + } + + #[test] + fn service_kinds_map_to_distinct_types() { + assert_ne!( + ServiceKind::ManualPairing.service_type(), + ServiceKind::RemotePairing.service_type() + ); + } +} diff --git a/crates/plume_utils/src/wireless/mod.rs b/crates/plume_utils/src/wireless/mod.rs new file mode 100644 index 00000000..cae837af --- /dev/null +++ b/crates/plume_utils/src/wireless/mod.rs @@ -0,0 +1,10 @@ +mod discovery; +mod pairing; +mod store; + +pub use discovery::{ + DiscoveredDevice, MANUAL_PAIRING_SERVICE, REMOTE_PAIRING_SERVICE, ServiceKind, discover, +}; +pub use idevice::remote_pairing::RpPairingFile; +pub use pairing::{PairedDevice, pair, verify}; +pub use store::{PairingRecord, PairingStore}; diff --git a/crates/plume_utils/src/wireless/pairing.rs b/crates/plume_utils/src/wireless/pairing.rs new file mode 100644 index 00000000..36f8fb86 --- /dev/null +++ b/crates/plume_utils/src/wireless/pairing.rs @@ -0,0 +1,91 @@ +use std::future::Future; +use std::net::SocketAddr; +use std::sync::atomic::{AtomicBool, Ordering}; + +use idevice::remote_pairing::{PeerDevice, RemotePairingClient, RpPairingFile, RpPairingSocket}; + +use crate::Error; + +#[derive(Debug, Clone)] +pub struct PairedDevice { + pub udid: String, + pub name: String, + pub model: String, + pub account_id: String, +} + +impl From<&PeerDevice> for PairedDevice { + fn from(peer: &PeerDevice) -> Self { + Self { + udid: peer.remotepairing_udid.clone(), + name: peer.name.clone(), + model: peer.model.clone(), + account_id: peer.account_id.clone(), + } + } +} + +/// Pairs with the device at `address`, prompting for the PIN it shows on screen. +/// +/// `host` is the name the device displays for this computer. It also seeds the +/// pairing file identifier, so keep it stable across pairings. +pub async fn pair( + address: SocketAddr, + host: &str, + pin_callback: F, +) -> Result<(RpPairingFile, PairedDevice), Error> +where + F: Fn() -> Fut, + Fut: Future, +{ + let mut pairing_file = RpPairingFile::generate(host); + + let device = { + let socket = connect(address).await?; + let mut client = RemotePairingClient::new(socket, host, &mut pairing_file); + + client.connect(|_: u8| pin_callback(), 0u8).await?; + PairedDevice::from(client.paired_peer_device()?) + }; + + // The keys aren't proven until the device accepts them without a PIN. + verify(address, host, &mut pairing_file).await?; + + Ok((pairing_file, device)) +} + +/// Reconnects with an existing pairing file to check the device still trusts it. +pub async fn verify( + address: SocketAddr, + host: &str, + pairing_file: &mut RpPairingFile, +) -> Result<(), Error> { + let pin_requested = AtomicBool::new(false); + + let socket = connect(address).await?; + let mut client = RemotePairingClient::new(socket, host, pairing_file); + + let result = client + .connect( + |_: u8| { + pin_requested.store(true, Ordering::Relaxed); + async { String::new() } + }, + 0u8, + ) + .await; + + // Being asked for a PIN means the pairing is dead. Checked before the error, + // which is just the device rejecting the empty PIN above. + if pin_requested.load(Ordering::Relaxed) { + return Err(Error::PairingNotAccepted); + } + + result?; + Ok(()) +} + +async fn connect(address: SocketAddr) -> Result, Error> { + let stream = tokio::net::TcpStream::connect(address).await?; + Ok(RpPairingSocket::new(stream)) +} diff --git a/crates/plume_utils/src/wireless/store.rs b/crates/plume_utils/src/wireless/store.rs new file mode 100644 index 00000000..f77ce5a0 --- /dev/null +++ b/crates/plume_utils/src/wireless/store.rs @@ -0,0 +1,320 @@ +use std::path::{Path, PathBuf}; + +use idevice::remote_pairing::{PeerDevice, RpPairingFile}; +use plist::{Dictionary, Value}; + +use super::pairing::PairedDevice; +use crate::Error; + +#[derive(Debug, Clone)] +pub struct PairingRecord { + pub udid: String, + pub name: String, + pub model: String, + pub account_id: String, + pub pairing_file_path: PathBuf, +} + +impl std::fmt::Display for PairingRecord { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} ({}) [{}]", self.name, self.model, self.udid) + } +} + +/// A directory of pairing files. +/// +/// Each device gets `.plist` in the format idevice reads and writes, plus +/// an `.info.plist` sidecar for the name and model the pairing file +/// doesn't carry. +#[derive(Debug, Clone)] +pub struct PairingStore { + dir: PathBuf, +} + +impl PairingStore { + pub fn new(dir: impl Into) -> Self { + Self { dir: dir.into() } + } + + pub fn directory(&self) -> &Path { + &self.dir + } + + pub fn pairing_file_path(&self, udid: &str) -> Result { + Ok(self.dir.join(format!("{}.plist", file_stem(udid)?))) + } + + fn info_path(&self, udid: &str) -> Result { + Ok(self.dir.join(format!("{}.info.plist", file_stem(udid)?))) + } + + pub async fn save( + &self, + device: &PairedDevice, + pairing_file: &RpPairingFile, + ) -> Result { + let path = self.pairing_file_path(&device.udid)?; + let info_path = self.info_path(&device.udid)?; + + tokio::fs::create_dir_all(&self.dir).await?; + tokio::fs::write(&path, pairing_file.to_bytes()).await?; + + let mut info = Dictionary::new(); + info.insert("udid".into(), Value::String(device.udid.clone())); + info.insert("name".into(), Value::String(device.name.clone())); + info.insert("model".into(), Value::String(device.model.clone())); + info.insert( + "account_id".into(), + Value::String(device.account_id.clone()), + ); + info.insert( + "paired_at".into(), + Value::Date(std::time::SystemTime::now().into()), + ); + + let mut buf = Vec::new(); + plist::to_writer_xml(&mut buf, &info)?; + tokio::fs::write(&info_path, buf).await?; + + Ok(path) + } + + pub async fn load(&self, udid: &str) -> Result { + let path = self.pairing_file_path(udid)?; + if !tokio::fs::try_exists(&path).await? { + return Err(Error::PairingNotFound(udid.to_string())); + } + Ok(RpPairingFile::read_from_file(&path).await?) + } + + pub async fn list(&self) -> Result, Error> { + let mut records = Vec::new(); + + let mut entries = match tokio::fs::read_dir(&self.dir).await { + Ok(entries) => entries, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(records), + Err(e) => return Err(e.into()), + }; + + while let Some(entry) = entries.next_entry().await? { + let path = entry.path(); + let Some(udid) = pairing_file_udid(&path) else { + continue; + }; + + // One bad file shouldn't hide every other paired device. + match self.record(&udid).await { + Ok(record) => records.push(record), + Err(e) => log::warn!("skipping unreadable pairing for {udid}: {e}"), + } + } + + records.sort_by(|a, b| a.name.cmp(&b.name).then_with(|| a.udid.cmp(&b.udid))); + Ok(records) + } + + /// Finds the pairing matching an `identifier`/`authTag` from a + /// `_remotepairing._tcp` TXT record. + /// + /// The device advertises the tag without saying whose it is, so every stored + /// pairing is tested: the tag is a keyed hash of the identifier under that + /// pairing's `alt_irk`, which only its owner can reproduce. + pub async fn find( + &self, + identifier: &str, + auth_tag: &str, + ) -> Result, Error> { + for record in self.list().await? { + let pairing_file = match self.load(&record.udid).await { + Ok(file) => file, + Err(e) => { + log::warn!("skipping unreadable pairing for {}: {e}", record.udid); + continue; + } + }; + + let Some(alt_irk) = pairing_file.alt_irk() else { + continue; + }; + + if PeerDevice::validate_auth_tag(alt_irk, identifier, auth_tag) { + return Ok(Some(record)); + } + } + + Ok(None) + } + + pub async fn remove(&self, udid: &str) -> Result { + let removed = remove_if_present(&self.pairing_file_path(udid)?).await?; + remove_if_present(&self.info_path(udid)?).await?; + Ok(removed) + } + + async fn record(&self, udid: &str) -> Result { + let pairing_file_path = self.pairing_file_path(udid)?; + let info = self.read_info(udid).await?; + + let field = |key: &str| { + info.as_ref() + .and_then(|info| info.get(key)) + .and_then(Value::as_string) + .map(str::to_string) + }; + + Ok(PairingRecord { + // A pairing file alone is still usable, so missing metadata falls back. + name: field("name").unwrap_or_else(|| udid.to_string()), + model: field("model").unwrap_or_else(|| "Unknown".to_string()), + account_id: field("account_id").unwrap_or_default(), + udid: udid.to_string(), + pairing_file_path, + }) + } + + async fn read_info(&self, udid: &str) -> Result, Error> { + let path = self.info_path(udid)?; + match tokio::fs::read(&path).await { + Ok(bytes) => Ok(plist::from_bytes::(&bytes).ok()), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e.into()), + } + } +} + +async fn remove_if_present(path: &Path) -> Result { + match tokio::fs::remove_file(path).await { + Ok(()) => Ok(true), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(e.into()), + } +} + +fn pairing_file_udid(path: &Path) -> Option { + if path.extension()?.to_str()? != "plist" { + return None; + } + + let stem = path.file_stem()?.to_str()?; + if stem.ends_with(".info") { + return None; + } + + Some(stem.to_string()) +} + +/// The UDID comes from the device, so reject anything that would escape the +/// store directory before using it as a file name. +fn file_stem(udid: &str) -> Result<&str, Error> { + let valid = !udid.is_empty() + && udid.len() <= 128 + && udid + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_')); + + if valid { + Ok(udid) + } else { + Err(Error::PairingInvalidUdid(udid.to_string())) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Shared with idevice's own validate_auth_tag test. + const ALT_IRK: [u8; 16] = [ + 0x32, 0x0a, 0x7a, 0x64, 0x63, 0xf3, 0x5c, 0xcd, 0xa4, 0xbb, 0xd6, 0xeb, 0xe3, 0xab, 0xec, + 0x8b, + ]; + const IDENTIFIER: &str = "2BE6E510-0325-4365-923E-B14C6F57DB3A"; + const AUTH_TAG: &str = "kXjlTr2l"; + + fn block_on(future: F) -> F::Output { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap() + .block_on(future) + } + + fn temp_store() -> PairingStore { + let dir = std::env::temp_dir().join(format!("plume-pairing-test-{}", uuid::Uuid::new_v4())); + PairingStore::new(dir) + } + + fn sample_device() -> PairedDevice { + PairedDevice { + udid: "00008110-001A2B3C00000000".to_string(), + name: "Living Room".to_string(), + model: "AppleTV11,1".to_string(), + account_id: "plume".to_string(), + } + } + + #[test] + fn find_matches_a_saved_pairing_by_auth_tag() { + let store = temp_store(); + let device = sample_device(); + + let mut pairing_file = RpPairingFile::generate("plume"); + pairing_file.alt_irk = Some(ALT_IRK.to_vec()); + + block_on(async { + store.save(&device, &pairing_file).await.unwrap(); + + let found = store.find(IDENTIFIER, AUTH_TAG).await.unwrap(); + let found = found.expect("saved pairing should match its own auth tag"); + assert_eq!(found.udid, device.udid); + assert_eq!(found.name, "Living Room"); + assert_eq!(found.model, "AppleTV11,1"); + + assert!(store.find(IDENTIFIER, "AAAAAAAA").await.unwrap().is_none()); + assert!( + store + .find("not-our-identifier", AUTH_TAG) + .await + .unwrap() + .is_none() + ); + + assert!(store.remove(&device.udid).await.unwrap()); + assert!(store.find(IDENTIFIER, AUTH_TAG).await.unwrap().is_none()); + + tokio::fs::remove_dir_all(store.directory()).await.ok(); + }); + } + + #[test] + fn list_is_empty_when_nothing_has_been_paired() { + let store = temp_store(); + assert!(block_on(store.list()).unwrap().is_empty()); + } + + #[test] + fn file_stem_rejects_path_traversal() { + assert!(file_stem("../../etc/passwd").is_err()); + assert!(file_stem("a/b").is_err()); + assert!(file_stem("a.b").is_err()); + assert!(file_stem("").is_err()); + } + + #[test] + fn file_stem_accepts_a_real_udid() { + assert!(file_stem("00008110-001A2B3C00000000").is_ok()); + } + + #[test] + fn pairing_file_udid_ignores_sidecars() { + assert_eq!( + pairing_file_udid(Path::new("/x/00008110-001A.plist")), + Some("00008110-001A".to_string()) + ); + assert_eq!( + pairing_file_udid(Path::new("/x/00008110-001A.info.plist")), + None + ); + assert_eq!(pairing_file_udid(Path::new("/x/notes.txt")), None); + } +} From f4b8d36191887076e7f42bb3b9bf8fcec7543ea1 Mon Sep 17 00:00:00 2001 From: ZomkaDEV Date: Fri, 4 Sep 2026 16:37:00 +0300 Subject: [PATCH 2/2] fix: request tvos provisioning profiles for apple tv --- apps/plumeimpactor/src/refresh.rs | 14 +++- apps/plumeimpactor/src/subscriptions.rs | 10 ++- apps/plumesign/src/commands/account.rs | 18 ++++- apps/plumesign/src/commands/device.rs | 1 + apps/plumesign/src/commands/sign.rs | 10 ++- crates/plume_core/src/developer/mod.rs | 2 + crates/plume_core/src/developer/platform.rs | 66 +++++++++++++++++++ crates/plume_core/src/developer/qh/app_ids.rs | 21 ++++-- crates/plume_core/src/developer/qh/devices.rs | 18 +++-- crates/plume_core/src/developer/qh/profile.rs | 3 + crates/plume_utils/src/device.rs | 32 ++++++++- crates/plume_utils/src/signer.rs | 11 ++-- 12 files changed, 180 insertions(+), 26 deletions(-) create mode 100644 crates/plume_core/src/developer/platform.rs diff --git a/apps/plumeimpactor/src/refresh.rs b/apps/plumeimpactor/src/refresh.rs index 8ea1b42a..ba2b4fbb 100644 --- a/apps/plumeimpactor/src/refresh.rs +++ b/apps/plumeimpactor/src/refresh.rs @@ -261,9 +261,11 @@ impl RefreshDaemon { session: &DeveloperSession, team_id: &str, ) -> Result<(), String> { + let platform = device.developer_platform(); + let team_id_string = team_id.to_string(); session - .qh_ensure_device(&team_id_string, &device.name, &device.udid) + .qh_ensure_device(&team_id_string, &device.name, &device.udid, platform) .await .map_err(|e| format!("Failed to ensure device: {}", e))?; @@ -291,7 +293,7 @@ impl RefreshDaemon { let mut signer = Signer::new(Some(signing_identity), options); signer - .register_bundle(&bundle, session, &team_id.to_string(), true) + .register_bundle(&bundle, session, &team_id.to_string(), true, platform) .await .map_err(|e| format!("Failed to register bundle: {}", e))?; @@ -332,7 +334,13 @@ impl RefreshDaemon { let mut signer = Signer::new(None, options); signer - .register_bundle(&bundle, session, &team_id.to_string(), true) + .register_bundle( + &bundle, + session, + &team_id.to_string(), + true, + device.developer_platform(), + ) .await .map_err(|e| format!("Failed to register bundle: {}", e))?; diff --git a/apps/plumeimpactor/src/subscriptions.rs b/apps/plumeimpactor/src/subscriptions.rs index 89451e1e..93898818 100644 --- a/apps/plumeimpactor/src/subscriptions.rs +++ b/apps/plumeimpactor/src/subscriptions.rs @@ -30,6 +30,7 @@ pub(crate) fn device_listener() -> Subscription { let _ = tx.unbounded_send(Message::DeviceConnected(Device { name: "This Mac".into(), udid: mac_udid, + product_type: None, device_id: u32::MAX, usbmuxd_device: None, is_mac: true, @@ -348,9 +349,14 @@ pub(crate) async fn run_installation( send("Ensuring device is registered...".to_string(), 30); + let platform = device + .as_ref() + .map(|dev| dev.developer_platform()) + .unwrap_or_default(); + if let Some(dev) = &device { session - .qh_ensure_device(team_id, &dev.name, &dev.udid) + .qh_ensure_device(team_id, &dev.name, &dev.udid, platform) .await .map_err(|e| e.to_string())?; } @@ -368,7 +374,7 @@ pub(crate) async fn run_installation( .await .map_err(|e| e.to_string())?; signer - .register_bundle(&bundle, &session, team_id, false) + .register_bundle(&bundle, &session, team_id, false, platform) .await .map_err(|e| e.to_string())?; signer diff --git a/apps/plumesign/src/commands/account.rs b/apps/plumesign/src/commands/account.rs index f58c09a5..48c7f380 100644 --- a/apps/plumesign/src/commands/account.rs +++ b/apps/plumesign/src/commands/account.rs @@ -5,7 +5,11 @@ use anyhow::{Ok, Result}; use clap::{Args, Subcommand}; use dialoguer::Select; -use plume_core::{AnisetteConfiguration, auth::Account, developer::DeveloperSession}; +use plume_core::{ + AnisetteConfiguration, + auth::Account, + developer::{DeveloperPlatform, DeveloperSession}, +}; use plume_store::AccountStore; use crate::get_data_path; @@ -267,7 +271,10 @@ async fn devices(args: DevicesArgs) -> Result<()> { args.team_id.unwrap() }; - let p = session.qh_list_devices(&team_id).await?.devices; + let p = session + .qh_list_devices(&team_id, DeveloperPlatform::default()) + .await? + .devices; log::info!("{:#?}", p); @@ -284,7 +291,12 @@ async fn register_device(args: RegisterDeviceArgs) -> Result<()> { }; let p = session - .qh_add_device(&team_id, &args.name, &args.udid) + .qh_add_device( + &team_id, + &args.name, + &args.udid, + DeveloperPlatform::default(), + ) .await? .device; diff --git a/apps/plumesign/src/commands/device.rs b/apps/plumesign/src/commands/device.rs index feb12262..1bde6d3a 100644 --- a/apps/plumesign/src/commands/device.rs +++ b/apps/plumesign/src/commands/device.rs @@ -53,6 +53,7 @@ pub async fn execute(args: DeviceArgs) -> Result<()> { Device { name: "My Mac".to_string(), udid: String::new(), + product_type: None, device_id: 0, usbmuxd_device: None, is_mac: true, diff --git a/apps/plumesign/src/commands/sign.rs b/apps/plumesign/src/commands/sign.rs index 86a63f7a..3f0c540c 100644 --- a/apps/plumesign/src/commands/sign.rs +++ b/apps/plumesign/src/commands/sign.rs @@ -127,6 +127,7 @@ pub async fn execute(args: SignArgs) -> Result<()> { Some(Device { name: "My Mac".to_string(), udid: String::new(), + product_type: None, device_id: 0, usbmuxd_device: None, is_mac: true, @@ -143,6 +144,11 @@ pub async fn execute(args: SignArgs) -> Result<()> { None }; + let platform = device + .as_ref() + .map(|dev| dev.developer_platform()) + .unwrap_or_default(); + if let Some((session, team_id)) = team_id_opt { signer .modify_bundle(&bundle, &Some(team_id.clone())) @@ -151,12 +157,12 @@ pub async fn execute(args: SignArgs) -> Result<()> { if let Some(ref dev) = device { log::info!("Registering device: {} ({})", dev.name, dev.udid); session - .qh_ensure_device(&team_id, &dev.name, &dev.udid) + .qh_ensure_device(&team_id, &dev.name, &dev.udid, platform) .await?; } signer - .register_bundle(&bundle, &session, &team_id, false) + .register_bundle(&bundle, &session, &team_id, false, platform) .await?; signer.sign_bundle(&bundle).await?; diff --git a/crates/plume_core/src/developer/mod.rs b/crates/plume_core/src/developer/mod.rs index b68d1d95..a1bea0e0 100644 --- a/crates/plume_core/src/developer/mod.rs +++ b/crates/plume_core/src/developer/mod.rs @@ -1,7 +1,9 @@ +mod platform; pub mod qh; mod session; pub mod v1; +pub use platform::DeveloperPlatform; pub use session::{DeveloperSession, RequestType}; #[macro_export] diff --git a/crates/plume_core/src/developer/platform.rs b/crates/plume_core/src/developer/platform.rs new file mode 100644 index 00000000..08631dcc --- /dev/null +++ b/crates/plume_core/src/developer/platform.rs @@ -0,0 +1,66 @@ +use plist::{Dictionary, Value}; + +/// Platform a developer portal request targets. +/// +/// The portal's URL path is `ios` for every platform; a non-iOS target is +/// selected with request fields instead. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum DeveloperPlatform { + #[default] + IOs, + TvOs, +} + +impl DeveloperPlatform { + pub fn request_fields(self) -> &'static [(&'static str, &'static str)] { + match self { + DeveloperPlatform::IOs => &[], + DeveloperPlatform::TvOs => &[("DTDK_Platform", "tvos"), ("subPlatform", "tvOS")], + } + } + + /// Empty for iOS, so those request bodies stay byte-identical to what they + /// were before tvOS was supported. + pub fn apply_to(self, body: &mut Dictionary) { + for (key, value) in self.request_fields() { + body.insert((*key).to_string(), Value::String((*value).to_string())); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn default_is_ios() { + assert_eq!(DeveloperPlatform::default(), DeveloperPlatform::IOs); + } + + #[test] + fn ios_leaves_the_body_untouched() { + let mut body = Dictionary::new(); + body.insert("teamId".to_string(), Value::String("T123".to_string())); + let original = body.clone(); + + DeveloperPlatform::IOs.apply_to(&mut body); + + assert_eq!(body, original); + } + + #[test] + fn tvos_adds_exactly_the_two_platform_fields() { + let mut body = Dictionary::new(); + DeveloperPlatform::TvOs.apply_to(&mut body); + + assert_eq!(body.len(), 2); + assert_eq!( + body.get("DTDK_Platform").and_then(Value::as_string), + Some("tvos") + ); + assert_eq!( + body.get("subPlatform").and_then(Value::as_string), + Some("tvOS") + ); + } +} diff --git a/crates/plume_core/src/developer/qh/app_ids.rs b/crates/plume_core/src/developer/qh/app_ids.rs index 229273cb..510c0725 100644 --- a/crates/plume_core/src/developer/qh/app_ids.rs +++ b/crates/plume_core/src/developer/qh/app_ids.rs @@ -4,15 +4,20 @@ use serde::Deserialize; use crate::Error; use super::{DeveloperSession, QHResponseMeta}; -use crate::developer::strip_invalid_chars; +use crate::developer::{DeveloperPlatform, strip_invalid_chars}; use crate::developer_endpoint; impl DeveloperSession { - pub async fn qh_list_app_ids(&self, team_id: &String) -> Result { + pub async fn qh_list_app_ids( + &self, + team_id: &String, + platform: DeveloperPlatform, + ) -> Result { let endpoint = developer_endpoint!("/QH65B2/ios/listAppIds.action"); let mut body = Dictionary::new(); body.insert("teamId".to_string(), Value::String(team_id.clone())); + platform.apply_to(&mut body); let response = self.qh_send_request(&endpoint, Some(body)).await?; let response_data: AppIDsResponse = plist::from_value(&Value::Dictionary(response))?; @@ -25,6 +30,7 @@ impl DeveloperSession { team_id: &String, name: &String, identifier: &String, + platform: DeveloperPlatform, ) -> Result { let endpoint = developer_endpoint!("/QH65B2/ios/addAppId.action"); @@ -32,6 +38,7 @@ impl DeveloperSession { body.insert("teamId".to_string(), Value::String(team_id.clone())); body.insert("name".to_string(), Value::String(strip_invalid_chars(name))); body.insert("identifier".to_string(), Value::String(identifier.clone())); + platform.apply_to(&mut body); let response = self.qh_send_request(&endpoint, Some(body)).await?; let response_data: AppIDResponse = plist::from_value(&Value::Dictionary(response))?; @@ -82,8 +89,9 @@ impl DeveloperSession { &self, team_id: &String, identifier: &String, + platform: DeveloperPlatform, ) -> Result, Error> { - let response_data = self.qh_list_app_ids(team_id).await?; + let response_data = self.qh_list_app_ids(team_id, platform).await?; let app_id = response_data .app_ids @@ -98,11 +106,14 @@ impl DeveloperSession { team_id: &String, name: &String, identifier: &String, + platform: DeveloperPlatform, ) -> Result { - if let Some(app_id) = self.qh_get_app_id(team_id, identifier).await? { + if let Some(app_id) = self.qh_get_app_id(team_id, identifier, platform).await? { Ok(app_id) } else { - let response = self.qh_add_app_id(team_id, name, identifier).await?; + let response = self + .qh_add_app_id(team_id, name, identifier, platform) + .await?; Ok(response.app_id) } } diff --git a/crates/plume_core/src/developer/qh/devices.rs b/crates/plume_core/src/developer/qh/devices.rs index 43a32041..268fed9d 100644 --- a/crates/plume_core/src/developer/qh/devices.rs +++ b/crates/plume_core/src/developer/qh/devices.rs @@ -4,14 +4,20 @@ use serde::Deserialize; use crate::Error; use super::{DeveloperSession, QHResponseMeta}; +use crate::developer::DeveloperPlatform; use crate::developer_endpoint; impl DeveloperSession { - pub async fn qh_list_devices(&self, team_id: &String) -> Result { + pub async fn qh_list_devices( + &self, + team_id: &String, + platform: DeveloperPlatform, + ) -> Result { let endpoint = developer_endpoint!("/QH65B2/ios/listDevices.action"); let mut body = Dictionary::new(); body.insert("teamId".to_string(), Value::String(team_id.clone())); + platform.apply_to(&mut body); let response = self.qh_send_request(&endpoint, Some(body)).await?; let response_data: DevicesResponse = plist::from_value(&Value::Dictionary(response))?; @@ -24,6 +30,7 @@ impl DeveloperSession { team_id: &String, device_name: &String, device_udid: &String, + platform: DeveloperPlatform, ) -> Result { let endpoint = developer_endpoint!("/QH65B2/ios/addDevice.action"); @@ -34,6 +41,7 @@ impl DeveloperSession { "deviceNumber".to_string(), Value::String(device_udid.clone()), ); + platform.apply_to(&mut body); let response = self.qh_send_request(&endpoint, Some(body)).await?; let response_data: DeviceResponse = plist::from_value(&Value::Dictionary(response))?; @@ -45,8 +53,9 @@ impl DeveloperSession { &self, team_id: &String, device_udid: &String, + platform: DeveloperPlatform, ) -> Result, Error> { - let response_data = self.qh_list_devices(team_id).await?; + let response_data = self.qh_list_devices(team_id, platform).await?; let device = response_data .devices @@ -61,12 +70,13 @@ impl DeveloperSession { team_id: &String, device_name: &String, device_udid: &String, + platform: DeveloperPlatform, ) -> Result { - if let Some(device) = self.qh_get_device(team_id, device_udid).await? { + if let Some(device) = self.qh_get_device(team_id, device_udid, platform).await? { Ok(device) } else { let response = self - .qh_add_device(team_id, device_name, device_udid) + .qh_add_device(team_id, device_name, device_udid, platform) .await?; Ok(response.device) } diff --git a/crates/plume_core/src/developer/qh/profile.rs b/crates/plume_core/src/developer/qh/profile.rs index 92cf54c3..a33dcd55 100644 --- a/crates/plume_core/src/developer/qh/profile.rs +++ b/crates/plume_core/src/developer/qh/profile.rs @@ -4,6 +4,7 @@ use serde::Deserialize; use crate::Error; use super::{DeveloperSession, QHResponseMeta}; +use crate::developer::DeveloperPlatform; use crate::developer_endpoint; impl DeveloperSession { @@ -11,12 +12,14 @@ impl DeveloperSession { &self, team_id: &String, app_id_id: &String, + platform: DeveloperPlatform, ) -> Result { let endpoint = developer_endpoint!("/QH65B2/ios/downloadTeamProvisioningProfile.action"); let mut body = Dictionary::new(); body.insert("teamId".to_string(), Value::String(team_id.clone())); body.insert("appIdId".to_string(), Value::String(app_id_id.clone())); + platform.apply_to(&mut body); let response = self.qh_send_request(&endpoint, Some(body)).await?; let response_data: ProfilesResponse = plist::from_value(&Value::Dictionary(response))?; diff --git a/crates/plume_utils/src/device.rs b/crates/plume_utils/src/device.rs index 793afbdf..4a4f8a4f 100644 --- a/crates/plume_utils/src/device.rs +++ b/crates/plume_utils/src/device.rs @@ -12,6 +12,7 @@ use idevice::usbmuxd::{Connection, UsbmuxdAddr, UsbmuxdDevice}; use idevice::utils::installation; use idevice::{IdeviceService, RemoteXpcClient}; use plume_core::MobileProvision; +use plume_core::developer::DeveloperPlatform; use crate::Error; use crate::options::SignerAppReal; @@ -39,6 +40,7 @@ macro_rules! get_dict_string { pub struct Device { pub name: String, pub udid: String, + pub product_type: Option, pub device_id: u32, pub usbmuxd_device: Option, // On x86_64 macs, `is_mac` variable should never be true @@ -48,12 +50,13 @@ pub struct Device { impl Device { pub async fn new(usbmuxd_device: UsbmuxdDevice) -> Self { - let name = Self::get_name_from_usbmuxd_device(&usbmuxd_device) + let (name, product_type) = Self::get_info_from_usbmuxd_device(&usbmuxd_device) .await .unwrap_or_default(); Device { name, + product_type, udid: usbmuxd_device.udid.clone(), device_id: usbmuxd_device.device_id.clone(), usbmuxd_device: Some(usbmuxd_device), @@ -61,12 +64,35 @@ impl Device { } } - async fn get_name_from_usbmuxd_device(device: &UsbmuxdDevice) -> Result { + async fn get_info_from_usbmuxd_device( + device: &UsbmuxdDevice, + ) -> Result<(String, Option), Error> { let mut lockdown = LockdownClient::connect(&device.to_provider(UsbmuxdAddr::default(), CONNECTION_LABEL)) .await?; let values = lockdown.get_value(None, None).await?; - Ok(get_dict_string!(values, "DeviceName")) + let product_type = get_dict_string!(values, "ProductType"); + + Ok(( + get_dict_string!(values, "DeviceName"), + (!product_type.is_empty()).then_some(product_type), + )) + } + + /// tvOS is identified by product type: `DeviceClass` is not returned by + /// lockdown before a session is established. + pub fn is_tvos(&self) -> bool { + self.product_type + .as_deref() + .is_some_and(|product_type| product_type.starts_with("AppleTV")) + } + + pub fn developer_platform(&self) -> DeveloperPlatform { + if self.is_tvos() { + DeveloperPlatform::TvOs + } else { + DeveloperPlatform::IOs + } } pub async fn installed_apps(&self) -> Result, Error> { diff --git a/crates/plume_utils/src/signer.rs b/crates/plume_utils/src/signer.rs index 5278d786..5bab778b 100644 --- a/crates/plume_utils/src/signer.rs +++ b/crates/plume_utils/src/signer.rs @@ -6,7 +6,7 @@ use tokio::fs; use plume_core::{ CertificateIdentity, MobileProvision, SettingsScope, SigningSettings, UnifiedSigner, - developer::DeveloperSession, + developer::{DeveloperPlatform, DeveloperSession}, }; use crate::{Bundle, BundleType, Error, PlistInfoTrait, SignerApp, SignerMode, SignerOptions}; @@ -228,6 +228,7 @@ impl Signer { session: &DeveloperSession, team_id: &String, is_refresh: bool, + platform: DeveloperPlatform, ) -> Result<(), Error> { if self.options.mode != SignerMode::Pem { return Ok(()); @@ -276,10 +277,12 @@ impl Signer { let name = sub_bundle.get_bundle_name().unwrap_or_else(|| id.clone()); - session.qh_ensure_app_id(&team_id, &name, &id).await?; + session + .qh_ensure_app_id(&team_id, &name, &id, platform) + .await?; let app_id_id = session - .qh_get_app_id(&team_id, &id) + .qh_get_app_id(&team_id, &id, platform) .await? .ok_or_else(|| Error::Other("Failed to get ensured app ID.".into()))?; @@ -337,7 +340,7 @@ impl Signer { } let profiles = session - .qh_get_profile(&team_id, &app_id_id.app_id_id) + .qh_get_profile(&team_id, &app_id_id.app_id_id, platform) .await?; let profile_data = profiles.provisioning_profile.encoded_profile;