diff --git a/.gitignore b/.gitignore index 2c8ed63..3c05175 100644 --- a/.gitignore +++ b/.gitignore @@ -32,4 +32,5 @@ Icon? /demo-media/ /test-media/ /build/ +/ideas/ /ext/ diff --git a/README.md b/README.md index 2ec32f3..083c453 100644 --- a/README.md +++ b/README.md @@ -58,9 +58,47 @@ VuIO features a built-in web dashboard at `http://:` (default: ### Casting compatibility -The dashboard and MCP tools discover DLNA renderers through SSDP, Chromecast and Google TV devices through `_googlecast._tcp` mDNS, and URL-video-capable AirPlay receivers through `_airplay._tcp` mDNS. Chromecast uses Google's Default Media Receiver and does not require a custom Cast application ID. +The dashboard and MCP tools discover DLNA renderers through SSDP, Chromecast and Google TV devices through `_googlecast._tcp` mDNS, and URL-video-capable AirPlay receivers through `_airplay._tcp` mDNS. Chromecast uses Google's Default Media Receiver and does not require a custom Cast application ID. The dashboard always asks which discovered renderer to use for each new cast. -Casting is direct-play only. DLNA behavior is unchanged. Chromecast accepts supported MP4, WebM, MPEG-TS, MP3, M4A, OGG, WAV, FLAC, and common image containers, subject to the codecs supported by the receiving model. AirPlay support is limited to unpaired receivers that advertise URL-video playback and to progressive MP4 video; AirPlay audio, mirroring, protected media, and AirPlay 2 pairing are not supported. Unsupported files produce an error instead of being transcoded or silently skipped. +Casting is direct-play only. DLNA behavior is unchanged. Chromecast accepts supported MP4, WebM, MPEG-TS, MP3, M4A, OGG, WAV, FLAC, and common image containers, subject to the codecs supported by the receiving model. AirPlay is offered for receivers that advertise AirPlay video v1 (feature bit 0), which is the `POST /play` URL-video endpoint — Apple TV, macOS, and legacy receivers. VuIO performs Pair-Verify with a stored PIN pairing, or transient Pair Setup where the receiver advertises system/CoreUtils pairing (no PIN and nothing persisted), then opens an encrypted control session with an RTSP `SETUP`/`RECORD`, serves the reverse event channel and an NTP timing server, and starts playback with an HTTP `POST /play` carrying the media URL as-is; the receiver fetches the file directly from VuIO over HTTP with range support. Third-party sets that advertise only AirPlay video v2 (bit 49, e.g. Sony Android TV) expose no URL-video surface to a non-Apple sender — every HTTP video path answers 404, and the play-queue command channel cannot be bound — so they are listed for audio only (`video: false, audio: true`); cast video to those over Chromecast or DLNA. Pairing keys are stored in VuIO's database. + +#### AirPlay receiver compatibility + +AirPlay receivers fall into two groups, and the difference decides whether VuIO can cast video to them. + +**URL-video receivers** advertise feature bit 0 (`SupportsAirPlayVideoV1`) and implement `POST /play`, the endpoint that hands the receiver a URL to fetch. Apple TV, macOS, and legacy receivers do this, and VuIO drives them: Pair-Verify with a stored PIN pairing (or transient Pair Setup where the receiver advertises system/CoreUtils pairing, which needs no PIN and stores nothing), an encrypted control session with RTSP `SETUP`/`RECORD`, the reverse event channel, an NTP timing server, then `POST /play` with the media URL as-is. + +**Audio and mirroring receivers** — most third-party AirPlay 2 TVs, including Sony Android TV — advertise only bit 49 (`SupportsAirPlayVideoV2`) and expose no URL-video endpoint at all. They are listed as audio-only renderers, so music casts to them and video does not. Set `VUIO_AIRPLAY_ALLOW_V2_ONLY=1` to offer video anyway and have the cast report exactly what the receiver answered. + +##### Why video does not work on these sets + +This is not a codec, container, or file problem: a 404 is the receiver reporting that the URL path does not exist, and it is returned before the request body naming the media is ever examined. The same 404 comes back for an H.264 High / AAC LC MP4 that plays fine over Chromecast to the same TV. An iPad cannot play video to these sets either. + +Probing a Sony XR-75X90L (AirPlay SDK 3.6.0.72, `features=0x7F8AD0,0x18BCF46`) found: + +| Request | Result | +| --- | --- | +| `OPTIONS *` | 200 — `ANNOUNCE, SETUP, RECORD, PAUSE, FLUSH, FLUSHBUFFERED, TEARDOWN, OPTIONS, POST, GET, PUT` | +| `/play`, `/playback-info`, `/server-info`, `/scrub`, `/playqueue`, `/rate`, `/stop` | 404, over both HTTP and RTSP | +| `POST /command` | 400 on a malformed body — the endpoint exists | +| `POST /command` with `{"params":{"data":…}}` | 500 for every payload, including an empty one — nothing can be bound to it | +| `SETUP` with `isRemoteControlOnly` | 200 | +| `SETUP` for the type-130 remote-control stream | 400 — refused | +| `SETUP` stream types 96 / 103 / 110 | 400 (96 succeeds once a media session exists, see below) | +| Port scan | only 7000 open for AirPlay; 8008/8009 are Chromecast, 52323 is DLNA | + +The `Public` method list is pure RAOP audio plus mirroring, and the type-130 stream that carries Apple's play-queue command set is refused — pyatv likewise only attempts that stream against Apple TV and HomePod. `/fp-setup` exists (400, not 404), which points at FairPlay as the gate. That is Apple-proprietary and not reproducible from public documentation, so these sets grant video to authenticated Apple senders and to nobody else. Cast video to them over Chromecast or DLNA, both of which VuIO supports and which work on the same hardware. + +##### Audio on these sets + +Audio is a different story. The same Sony grants a buffered-audio stream: + +``` +SETUP (type 96, sr 44100, spf 352) → 200 +{"streams":[{"type":96,"dataPort":45528,"controlPort":41402}]} +``` + +VuIO streams audio to these receivers. AirPlay never carries MP3 — its `audioFormat` bitmask covers only PCM, ALAC, AAC-LC, AAC-ELD and OPUS — so, exactly like an iPhone, VuIO decodes the source itself and pushes `PCM/44100/16/2` (`0x800`). MP3, M4A/AAC, FLAC, WAV, ALAC and Vorbis are decoded with Symphonia and resampled to 44.1 kHz stereo. A realtime AirPlay receiver hardcodes ALAC and ignores the `ct`/`audioFormat` it is offered, so each 352-frame packet is wrapped in an uncompressed ALAC element, encrypted with ChaCha20-Poly1305 (key: the first 32 bytes of the pairing secret, verbatim), and paced in real time against an NTP timing server with clock-sync packets on the control channel. Track title, artist, album and cover art are read from the file's tags and pushed as DMAP metadata, and a `progress` parameter drives the receiver's seek bar. Casting a folder hands the whole queue to the receiver, which plays through it gaplessly on one RTP timeline; the receiver's own remote reaches the sender as DACP command codes on the event channel, so its next/previous buttons skip tracks. Audio therefore reaches receivers that cannot play video at all, and the renderer list reports `video: false, audio: true` for them. Volume stays under the receiver's control: these sets report their own level and forward their remote's volume presses to the sender. ## Quick Start diff --git a/crates/vuio-core/Cargo.toml b/crates/vuio-core/Cargo.toml index b4d6883..7457941 100644 --- a/crates/vuio-core/Cargo.toml +++ b/crates/vuio-core/Cargo.toml @@ -51,6 +51,16 @@ oxicast = { path = "../oxicast", default-features = false } redb = "4.1" jwalk = "0.9" tokio-stream = "0.1" +hap-crypto = "1.4" +hap-transport = "1.3" +hap-tlv8 = "1.0" +hkdf = "0.12" +sha2 = "0.10" +num-bigint = "0.4" +symphonia = { version = "0.6", default-features = false, features = ["mp3", "aac", "isomp4", "flac", "wav", "pcm", "ogg", "vorbis", "alac", "id3v1", "id3v2", "ape"] } +getrandom = "0.3" +plist = "1.8" +hex = "0.4" [target.'cfg(windows)'.dependencies] windows = { version = "0.62", features = [ diff --git a/crates/vuio-core/src/casting/airplay/audio.rs b/crates/vuio-core/src/casting/airplay/audio.rs new file mode 100644 index 0000000..9bec035 --- /dev/null +++ b/crates/vuio-core/src/casting/airplay/audio.rs @@ -0,0 +1,346 @@ +//! Decode a media file into the PCM stream AirPlay carries. +//! +//! AirPlay never transports MP3. Its `audioFormat` bitmask covers PCM, ALAC, +//! AAC-LC, AAC-ELD and OPUS only, so a sender always decodes the source first; +//! an iPhone streaming an MP3 decodes it locally too. VuIO targets +//! `PCM/44100/16/2` (bit 11, `0x800`) because it is the one format that needs a +//! decoder and no encoder, and a Sony XR-75X90L accepts it. + +use anyhow::{Context, Result}; +use std::path::Path; +use symphonia::core::codecs::audio::AudioDecoderOptions; +use symphonia::core::formats::probe::Hint; +use symphonia::core::formats::{FormatOptions, FormatReader, TrackType}; +use symphonia::core::io::MediaSourceStream; +use symphonia::core::meta::MetadataOptions; + +/// The sample rate, channel count and sample size AirPlay's `0x800` format names. +pub const SAMPLE_RATE: u32 = 44100; +pub const CHANNELS: usize = 2; +pub const BYTES_PER_FRAME: usize = CHANNELS * 2; + +/// What the receiver shows on its now-playing screen. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct TrackMetadata { + pub title: Option, + pub artist: Option, + pub album: Option, + /// Track length in seconds, which is what gives the seek bar its extent. + pub duration_seconds: Option, + /// Embedded cover art, with its media type. + pub artwork: Option<(String, Vec)>, +} + +/// A decoded, resampled PCM stream ready to be packetised. +pub struct PcmSource { + format: Box, + decoder: Box, + track_id: u32, + source_rate: u32, + source_channels: usize, + /// Decoded frames at the source rate, interleaved, awaiting resampling. + decoded: Vec, + /// Fractional read position into `decoded`, in source frames. + position: f64, + /// Output frames at 44100 Hz, interleaved 16-bit. + ready: Vec, + exhausted: bool, + metadata: TrackMetadata, +} + +impl PcmSource { + pub fn open(path: &Path) -> Result { + let file = std::fs::File::open(path) + .with_context(|| format!("opening {} for AirPlay audio", path.display()))?; + let stream = MediaSourceStream::new(Box::new(file), Default::default()); + let mut hint = Hint::new(); + if let Some(extension) = path.extension().and_then(|value| value.to_str()) { + hint.with_extension(extension); + } + let mut format = symphonia::default::get_probe() + .probe( + &hint, + stream, + FormatOptions::default(), + MetadataOptions::default(), + ) + .with_context(|| format!("{} is not a decodable audio container", path.display()))?; + let track = format + .default_track(TrackType::Audio) + .context("the file has no audio track")?; + let track_id = track.id; + let duration_seconds = track + .num_frames + .zip( + track + .codec_params + .as_ref() + .and_then(|params| params.audio()), + ) + .and_then(|(frames, audio)| { + audio + .sample_rate + .filter(|rate| *rate > 0) + .map(|rate| frames / u64::from(rate)) + }); + let parameters = track + .codec_params + .as_ref() + .and_then(|params| params.audio()) + .context("the audio track has no codec parameters")? + .clone(); + let decoder = symphonia::default::get_codecs() + .make_audio_decoder(¶meters, &AudioDecoderOptions::default()) + .with_context(|| { + format!( + "no decoder is available for the codec in {}", + path.display() + ) + })?; + let mut metadata = TrackMetadata { + duration_seconds, + ..TrackMetadata::default() + }; + { + let mut tags = format.metadata(); + if let Some(revision) = tags.skip_to_latest() { + if let Some(visual) = revision.media.visuals.first() { + metadata.artwork = Some(( + visual + .media_type + .clone() + .unwrap_or_else(|| "image/jpeg".into()), + visual.data.to_vec(), + )); + } + for tag in &revision.media.tags { + match &tag.std { + Some(symphonia::core::meta::StandardTag::TrackTitle(value)) => { + metadata.title = Some(value.to_string()) + } + Some(symphonia::core::meta::StandardTag::Artist(value)) => { + metadata.artist = Some(value.to_string()) + } + Some(symphonia::core::meta::StandardTag::Album(value)) => { + metadata.album = Some(value.to_string()) + } + _ => {} + } + } + } + } + // Fall back to the filename so the receiver always shows something. + if metadata.title.is_none() { + metadata.title = path + .file_stem() + .and_then(|value| value.to_str()) + .map(str::to_string); + } + + let source_rate = parameters.sample_rate.unwrap_or(SAMPLE_RATE); + let source_channels = parameters + .channels + .as_ref() + .map_or(CHANNELS, |channels| channels.count()); + Ok(Self { + format, + decoder, + track_id, + source_rate, + source_channels: source_channels.max(1), + decoded: Vec::new(), + position: 0.0, + ready: Vec::new(), + exhausted: false, + metadata, + }) + } + + pub fn metadata(&self) -> &TrackMetadata { + &self.metadata + } + + /// Read the next packet from the container and decode it into `decoded`. + /// + /// Returns `false` once the stream is finished. Undecodable packets are + /// skipped rather than aborting playback, which matches how players treat a + /// damaged frame in the middle of a track. + fn decode_more(&mut self) -> Result { + loop { + let packet = match self.format.next_packet() { + Ok(Some(packet)) => packet, + Ok(None) => return Ok(false), + Err(symphonia::core::errors::Error::IoError(error)) + if error.kind() == std::io::ErrorKind::UnexpectedEof => + { + return Ok(false) + } + Err(error) => return Err(error).context("reading the next audio packet"), + }; + if packet.track_id != self.track_id { + continue; + } + match self.decoder.decode(&packet) { + Ok(buffer) => { + // Copy out before touching `self` again: the buffer borrows + // the decoder. + let rate = buffer.spec().rate(); + let channels = buffer.spec().channels().count().max(1); + let mut samples: Vec = Vec::new(); + buffer.copy_to_vec_interleaved(&mut samples); + self.source_rate = rate; + self.source_channels = channels; + self.decoded.extend_from_slice(&samples); + return Ok(true); + } + Err(symphonia::core::errors::Error::DecodeError(error)) => { + tracing::debug!(%error, "skipping an undecodable audio packet"); + continue; + } + Err(error) => return Err(error).context("decoding audio"), + } + } + } + + /// Resample what is buffered into 44100 Hz stereo 16-bit frames. + /// + /// Linear interpolation is enough here: the common case is a 44100 Hz source + /// where `step` is exactly 1.0 and samples pass through untouched. + fn resample(&mut self) { + let channels = self.source_channels; + let available = self.decoded.len() / channels; + if available == 0 { + return; + } + let step = f64::from(self.source_rate) / f64::from(SAMPLE_RATE); + // Interpolation needs the frame after `position`, so stop one short + // unless the stream is finished and there is nothing more coming. + let limit = if self.exhausted { + available.saturating_sub(1) + } else { + available.saturating_sub(2) + }; + while (self.position as usize) < limit { + let index = self.position as usize; + let fraction = (self.position - index as f64) as f32; + for channel in 0..CHANNELS { + // Mono sources feed both output channels. + let source_channel = channel.min(channels - 1); + let first = self.decoded[index * channels + source_channel]; + let second = self.decoded[(index + 1) * channels + source_channel]; + let value = first + (second - first) * fraction; + let scaled = (value.clamp(-1.0, 1.0) * 32767.0) as i16; + self.ready.extend_from_slice(&scaled.to_le_bytes()); + } + self.position += step; + } + // Drop consumed frames and rebase the cursor. + let consumed = self.position as usize; + if consumed > 0 { + self.decoded.drain(..consumed * channels); + self.position -= consumed as f64; + } + } + + /// Return exactly `frames` frames of PCM, zero-padded at end of stream. + /// + /// Returns `None` once the source is fully drained, which tells the sender + /// to stop reading and start its latency run-out. + pub fn read_frames(&mut self, frames: usize) -> Result>> { + let wanted = frames * BYTES_PER_FRAME; + while self.ready.len() < wanted && !self.exhausted { + if !self.decode_more()? { + self.exhausted = true; + } + self.resample(); + } + if self.ready.is_empty() { + return Ok(None); + } + let mut chunk: Vec = self.ready.drain(..wanted.min(self.ready.len())).collect(); + chunk.resize(wanted, 0); + Ok(Some(chunk)) + } +} + +/// Whether VuIO can decode this file for AirPlay audio. +pub fn is_streamable_audio(mime: &str, filename: &str) -> bool { + let mime = mime + .split(';') + .next() + .unwrap_or(mime) + .trim() + .to_ascii_lowercase(); + if mime.starts_with("audio/") && mime != "audio/radio" { + return true; + } + filename.rsplit_once('.').is_some_and(|(_, extension)| { + matches!( + extension.to_ascii_lowercase().as_str(), + "mp3" | "m4a" | "aac" | "flac" | "wav" | "ogg" | "oga" | "alac" + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn streamable_audio_covers_the_library_formats() { + assert!(is_streamable_audio("audio/mpeg", "song.mp3")); + assert!(is_streamable_audio("audio/mp4", "song.m4a")); + assert!(is_streamable_audio("audio/flac", "song.flac")); + assert!(is_streamable_audio("application/octet-stream", "song.wav")); + assert!(!is_streamable_audio("audio/radio", "stream")); + assert!(!is_streamable_audio("video/mp4", "movie.mp4")); + } + + /// A synthesised WAV exercises decode, resample and framing without needing + /// a fixture file. + #[test] + fn wav_decodes_into_44100_stereo_frames() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("tone.wav"); + write_test_wav(&path, 22050, 4410); + + let mut source = PcmSource::open(&path).unwrap(); + let mut total = 0usize; + while let Some(chunk) = source.read_frames(352).unwrap() { + assert_eq!(chunk.len(), 352 * BYTES_PER_FRAME); + total += chunk.len(); + } + // 4410 frames at 22050 Hz is 0.2s, so 8820 frames once resampled to + // 44100 Hz. The final packet is zero-padded to 352 frames, so the total + // lands on the next packet boundary: 26 * 352 = 9152. + let produced = total / BYTES_PER_FRAME; + assert_eq!(produced % 352, 0, "packets must be whole: {produced}"); + assert!( + (8820..=8820 + 352).contains(&produced), + "expected 8820 frames rounded up to a packet boundary, got {produced}" + ); + } + + fn write_test_wav(path: &std::path::Path, rate: u32, frames: u32) { + let data_len = frames * 4; // stereo, 16-bit + let mut wav = Vec::new(); + wav.extend_from_slice(b"RIFF"); + wav.extend_from_slice(&(36 + data_len).to_le_bytes()); + wav.extend_from_slice(b"WAVEfmt "); + wav.extend_from_slice(&16u32.to_le_bytes()); + wav.extend_from_slice(&1u16.to_le_bytes()); // PCM + wav.extend_from_slice(&2u16.to_le_bytes()); // stereo + wav.extend_from_slice(&rate.to_le_bytes()); + wav.extend_from_slice(&(rate * 4).to_le_bytes()); + wav.extend_from_slice(&4u16.to_le_bytes()); + wav.extend_from_slice(&16u16.to_le_bytes()); + wav.extend_from_slice(b"data"); + wav.extend_from_slice(&data_len.to_le_bytes()); + for frame in 0..frames { + let value = ((frame as f32 / 20.0).sin() * 8000.0) as i16; + wav.extend_from_slice(&value.to_le_bytes()); + wav.extend_from_slice(&value.to_le_bytes()); + } + std::fs::write(path, wav).unwrap(); + } +} diff --git a/crates/vuio-core/src/casting/airplay/credentials.rs b/crates/vuio-core/src/casting/airplay/credentials.rs new file mode 100644 index 0000000..8a05cd0 --- /dev/null +++ b/crates/vuio-core/src/casting/airplay/credentials.rs @@ -0,0 +1,240 @@ +use crate::database::SecretStore; +use anyhow::{Context, Result}; +use hap_crypto::{AccessoryPairing, ControllerKeypair}; +use serde::{Deserialize, Serialize}; +use std::{collections::HashMap, sync::Arc}; +use tokio::sync::Mutex; + +/// Key under which the whole pairing document lives in the `secrets` table. +const SECRET_KEY: &str = "airplay.pairings"; + +#[derive(Clone)] +pub struct CredentialStore { + inner: Arc, +} + +struct Inner { + /// `None` keeps the store in memory, which is what tests and the + /// non-persistent constructor use. + secrets: Option>, + document: Mutex, +} + +#[derive(Default, Serialize, Deserialize)] +struct Document { + #[serde(default = "document_version")] + version: u32, + controller: Option, + #[serde(default)] + receivers: HashMap, +} + +#[derive(Serialize, Deserialize)] +struct ControllerRecord { + id: String, + seed: String, +} + +#[derive(Serialize, Deserialize)] +struct ReceiverRecord { + pairing_id: String, + ltpk: String, +} + +const fn document_version() -> u32 { + 1 +} + +impl CredentialStore { + pub fn memory() -> Self { + Self { + inner: Arc::new(Inner { + secrets: None, + document: Mutex::new(Document { + version: document_version(), + ..Document::default() + }), + }), + } + } + + /// Load pairings from the application database. + /// + /// Keys live in the `secrets` table rather than the OS credential vault so + /// that a VuIO instance is self-contained: the database file is the single + /// thing to back up, move between hosts, or mount into a container, and + /// headless and Docker deployments have no system keychain to talk to. + pub async fn load(secrets: Arc) -> Result { + let stored = secrets + .get_secret(SECRET_KEY) + .await + .context("reading AirPlay credentials")?; + let document = match stored { + Some(bytes) => parse_document(&String::from_utf8_lossy(&bytes)) + .context("parsing stored AirPlay credentials")?, + None => Document { + version: document_version(), + ..Document::default() + }, + }; + Ok(Self { + inner: Arc::new(Inner { + secrets: Some(secrets), + document: Mutex::new(document), + }), + }) + } + + pub async fn is_paired(&self, renderer_id: &str) -> bool { + self.inner + .document + .lock() + .await + .receivers + .contains_key(renderer_id) + } + + pub async fn controller(&self) -> Result { + let mut document = self.inner.document.lock().await; + if let Some(record) = &document.controller { + return controller_from_record(record); + } + let controller = ControllerKeypair::generate(uuid::Uuid::new_v4().to_string()); + document.controller = Some(ControllerRecord { + id: controller.id.clone(), + seed: hex::encode(controller.seed()), + }); + self.persist_locked(&document).await?; + Ok(controller) + } + + pub async fn pairing( + &self, + renderer_id: &str, + ) -> Result> { + let document = self.inner.document.lock().await; + let Some(controller) = document.controller.as_ref() else { + return Ok(None); + }; + let Some(receiver) = document.receivers.get(renderer_id) else { + return Ok(None); + }; + let ltpk: [u8; 32] = decode_fixed(&receiver.ltpk, "receiver public key")?; + Ok(Some(( + controller_from_record(controller)?, + AccessoryPairing { + pairing_id: receiver.pairing_id.clone(), + ltpk, + }, + ))) + } + + pub async fn save_pairing(&self, renderer_id: &str, pairing: &AccessoryPairing) -> Result<()> { + let mut document = self.inner.document.lock().await; + document.receivers.insert( + renderer_id.to_string(), + ReceiverRecord { + pairing_id: pairing.pairing_id.clone(), + ltpk: hex::encode(pairing.ltpk), + }, + ); + self.persist_locked(&document).await + } + + pub async fn forget(&self, renderer_id: &str) -> Result { + let mut document = self.inner.document.lock().await; + let removed = document.receivers.remove(renderer_id).is_some(); + if removed { + self.persist_locked(&document).await?; + } + Ok(removed) + } + + async fn persist_locked(&self, document: &Document) -> Result<()> { + let Some(secrets) = &self.inner.secrets else { + return Ok(()); + }; + let encoded = serde_json::to_string(document).context("encoding AirPlay credentials")?; + secrets + .set_secret(SECRET_KEY, encoded.as_bytes()) + .await + .context("storing AirPlay credentials") + } +} + +fn controller_from_record(record: &ControllerRecord) -> Result { + let seed: [u8; 32] = decode_fixed(&record.seed, "controller private key")?; + Ok(ControllerKeypair::from_seed(record.id.clone(), seed)) +} + +fn decode_fixed(value: &str, label: &str) -> Result<[u8; N]> { + hex::decode(value) + .with_context(|| format!("decoding {label}"))? + .try_into() + .map_err(|_| anyhow::anyhow!("invalid {label} length")) +} + +fn parse_document(value: &str) -> Result { + let document: Document = serde_json::from_str(value)?; + anyhow::ensure!( + document.version == document_version(), + "unsupported credential version" + ); + Ok(document) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn memory_store_round_trips_pairing_without_exposing_keys() { + let store = CredentialStore::memory(); + let _ = store.controller().await.unwrap(); + let pairing = AccessoryPairing { + pairing_id: "AA:BB:CC:DD:EE:FF".to_string(), + ltpk: [7; 32], + }; + store.save_pairing("airplay:test", &pairing).await.unwrap(); + assert!(store.is_paired("airplay:test").await); + let (_, loaded) = store.pairing("airplay:test").await.unwrap().unwrap(); + assert_eq!(loaded, pairing); + assert!(store.forget("airplay:test").await.unwrap()); + assert!(!store.is_paired("airplay:test").await); + } + + #[tokio::test] + async fn pairings_survive_a_restart_through_the_database() { + use crate::database::DatabaseManager as _; + + let temp = tempfile::tempdir().unwrap(); + let database = std::sync::Arc::new( + crate::database::redb::RedbDatabase::new(temp.path().join("pairings.redb")) + .await + .unwrap(), + ); + database.initialize().await.unwrap(); + let secrets: Arc = database.clone(); + + let pairing = AccessoryPairing { + pairing_id: "7C:58:BC:93:AE:C4".to_string(), + ltpk: [9; 32], + }; + { + let store = CredentialStore::load(secrets.clone()).await.unwrap(); + let controller = store.controller().await.unwrap(); + store.save_pairing("airplay:sony", &pairing).await.unwrap(); + assert!(!controller.id.is_empty()); + } + + // A fresh store over the same database is what a restart looks like. + let reopened = CredentialStore::load(secrets).await.unwrap(); + assert!(reopened.is_paired("airplay:sony").await); + let (_, loaded) = reopened.pairing("airplay:sony").await.unwrap().unwrap(); + assert_eq!(loaded, pairing); + assert!(reopened.forget("airplay:sony").await.unwrap()); + + let after_forget = CredentialStore::load(database).await.unwrap(); + assert!(!after_forget.is_paired("airplay:sony").await); + } +} diff --git a/crates/vuio-core/src/casting/airplay/dacp.rs b/crates/vuio-core/src/casting/airplay/dacp.rs new file mode 100644 index 0000000..e480a0b --- /dev/null +++ b/crates/vuio-core/src/casting/airplay/dacp.rs @@ -0,0 +1,181 @@ +//! The control endpoint a receiver calls back into. +//! +//! An AirPlay receiver does not invent transport controls: it offers them only +//! once the sender advertises somewhere to send them. That is DACP -- the +//! `DACP-ID` and `Active-Remote` headers on every RTSP request name a +//! `_dacp._tcp` service the receiver looks up as `iTunes_Ctrl_`, then +//! calls with `GET /ctrl-int/1/`. Without the advertisement the +//! receiver has no callback address, so its remote shows no buttons at all. + +use super::raop::Transport; +use anyhow::{Context, Result}; +use mdns_sd::{ServiceDaemon, ServiceInfo}; +use std::net::{IpAddr, SocketAddr}; +use std::sync::atomic::Ordering; +use std::sync::Arc; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +const SERVICE_TYPE: &str = "_dacp._tcp.local."; + +/// A live DACP advertisement. Dropping it withdraws the service and stops the +/// listener, so a receiver stops offering controls for a finished session. +pub struct DacpServer { + daemon: ServiceDaemon, + fullname: String, + task: tokio::task::JoinHandle<()>, +} + +impl Drop for DacpServer { + fn drop(&mut self) { + let _ = self.daemon.unregister(&self.fullname); + self.task.abort(); + } +} + +impl DacpServer { + /// Publish a control endpoint for `dacp_id` and route commands to `transport`. + pub async fn start(dacp_id: &str, local_ip: IpAddr, transport: Arc) -> Result { + let listener = tokio::net::TcpListener::bind(SocketAddr::new(local_ip, 0)) + .await + .context("binding the AirPlay DACP control port")?; + let port = listener.local_addr()?.port(); + + let daemon = ServiceDaemon::new().context("starting mDNS for the DACP service")?; + let instance = format!("iTunes_Ctrl_{dacp_id}"); + let host = format!("vuio-{dacp_id}.local."); + let service = ServiceInfo::new(SERVICE_TYPE, &instance, &host, local_ip, port, None) + .context("describing the DACP service")?; + let fullname = service.get_fullname().to_string(); + daemon + .register(service) + .context("advertising the DACP service")?; + tracing::info!(instance, port, "AirPlay DACP control endpoint advertised"); + + let task = tokio::spawn(async move { + loop { + let Ok((stream, _)) = listener.accept().await else { + return; + }; + let transport = transport.clone(); + tokio::spawn(async move { + if let Err(error) = serve(stream, transport).await { + tracing::debug!(%error, "AirPlay DACP request failed"); + } + }); + } + }); + + Ok(Self { + daemon, + fullname, + task, + }) + } +} + +/// Handle one control request. The receiver keeps the connection alive and +/// sends a request per button press. +async fn serve(mut stream: tokio::net::TcpStream, transport: Arc) -> Result<()> { + let mut buffer = Vec::new(); + let mut chunk = [0u8; 1024]; + loop { + let read = stream.read(&mut chunk).await?; + if read == 0 { + return Ok(()); + } + buffer.extend_from_slice(&chunk[..read]); + // Requests are header-only, so a blank line ends each one. + while let Some(end) = buffer + .windows(4) + .position(|window| window == b"\r\n\r\n") + .map(|position| position + 4) + { + let request: Vec = buffer.drain(..end).collect(); + let head = String::from_utf8_lossy(&request); + let path = head + .lines() + .next() + .and_then(|line| line.split_whitespace().nth(1)) + .unwrap_or_default(); + apply(path, &transport); + stream + .write_all(b"HTTP/1.1 204 No Content\r\nContent-Length: 0\r\n\r\n") + .await?; + } + anyhow::ensure!(buffer.len() < 64 * 1024, "oversized DACP request"); + } +} + +/// Map a DACP path onto a transport action. +fn apply(path: &str, transport: &Arc) { + let command = path.rsplit('/').next().unwrap_or_default(); + // Commands can carry query parameters, e.g. `setproperty?dmcp.volume=50`. + let command = command.split('?').next().unwrap_or(command); + match command { + "nextitem" => { + transport.skip_next.store(true, Ordering::Relaxed); + tracing::info!("AirPlay DACP: next track"); + } + "previtem" => { + transport.restart.store(true, Ordering::Relaxed); + tracing::info!("AirPlay DACP: previous track"); + } + "play" | "pause" | "playpause" | "stop" => { + transport.paused.fetch_xor(true, Ordering::Relaxed); + tracing::info!(command, "AirPlay DACP: play/pause"); + } + other => tracing::debug!(command = other, "AirPlay DACP command ignored"), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Registering is not the same as being discoverable; browse for the + /// service we just published and confirm it comes back. + #[tokio::test] + async fn advertised_service_is_discoverable() { + let local_ip: IpAddr = "127.0.0.1".parse().unwrap(); + let transport = Arc::new(Transport::default()); + let Ok(server) = DacpServer::start("TESTDACPID01", local_ip, transport).await else { + eprintln!("mDNS unavailable in this environment; skipping"); + return; + }; + + let daemon = ServiceDaemon::new().unwrap(); + let receiver = daemon.browse(SERVICE_TYPE).unwrap(); + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(10); + let mut found = false; + while let Ok(Ok(event)) = tokio::time::timeout_at(deadline, receiver.recv_async()).await { + if let mdns_sd::ServiceEvent::ServiceResolved(info) = event { + if info.get_fullname().contains("iTunes_Ctrl_TESTDACPID01") { + found = true; + break; + } + } + } + drop(server); + assert!(found, "the DACP service was registered but never resolved"); + } + + #[test] + fn dacp_paths_map_to_transport_actions() { + let transport = Arc::new(Transport::default()); + apply("/ctrl-int/1/nextitem", &transport); + assert!(transport.skip_next.load(Ordering::Relaxed)); + + apply("/ctrl-int/1/previtem", &transport); + assert!(transport.restart.load(Ordering::Relaxed)); + + assert!(!transport.paused.load(Ordering::Relaxed)); + apply("/ctrl-int/1/playpause", &transport); + assert!(transport.paused.load(Ordering::Relaxed)); + apply("/ctrl-int/1/playpause", &transport); + assert!(!transport.paused.load(Ordering::Relaxed)); + + // Unknown commands, and query strings, must not panic or misfire. + apply("/ctrl-int/1/setproperty?dmcp.volume=50", &transport); + apply("/", &transport); + } +} diff --git a/crates/vuio-core/src/casting/airplay/mod.rs b/crates/vuio-core/src/casting/airplay/mod.rs index 74c76d5..d1707cc 100644 --- a/crates/vuio-core/src/casting/airplay/mod.rs +++ b/crates/vuio-core/src/casting/airplay/mod.rs @@ -1,27 +1,263 @@ -//! Dependency-free AirPlay URL-video discovery and control. +//! AirPlay URL-video discovery, pairing, and control. + +mod audio; +mod credentials; +mod dacp; +mod pair_verify; +mod raop; +mod transient; +mod transport; use super::{ - is_safe_renderer_address, CastProvider, PlaybackAction, PlaybackItem, PlaybackState, - PlaybackStatus, RendererCapabilities, RendererDevice, RendererEndpoint, RendererProtocol, + is_safe_renderer_address, CastProvider, PairingChallenge, PairingStatus, PlaybackAction, + PlaybackItem, PlaybackState, PlaybackStatus, RendererCapabilities, RendererDevice, + RendererEndpoint, RendererProtocol, }; +use anyhow::Context as _; use async_trait::async_trait; use futures_util::StreamExt; +use hap_crypto::{PairSetupClient, PairSetupStep, SessionKeys}; use mdns_sd::{ServiceDaemon, ServiceEvent}; use quick_xml::{events::Event, Reader}; use std::{collections::HashMap, net::SocketAddr, time::Duration}; use tokio::sync::Mutex; +use self::{ + credentials::CredentialStore, + pair_verify::{derive_key_from, PairVerifier}, + transport::AirplayConnection, +}; + const SERVICE_TYPE: &str = "_airplay._tcp.local."; const MAX_RESPONSE_BYTES: usize = 256 * 1024; +const PAIRING_TTL: Duration = Duration::from_secs(120); +const AIRPLAY_VIDEO_V1: u64 = 1 << 0; +const AIRPLAY_VIDEO_V2: u64 = 1 << 49; +const AIRPLAY_AUDIO: u64 = 1 << 9; +const AIRPLAY_SYSTEM_PAIRING: u64 = 1 << 43; +const AIRPLAY_CORE_UTILS_PAIRING: u64 = 1 << 48; +const AIRPLAY_2_ENABLED: bool = true; +const PLAY_RETRIES: u32 = 3; +const PLAYBACK_INFO_ATTEMPTS: u32 = 5; +const EVENT_CHANNEL_ATTEMPTS: u32 = 5; +const USER_AGENT: &str = "AirPlay/550.10"; +const BINARY_PLIST: &str = "application/x-apple-binary-plist"; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(10); +const FEEDBACK_INTERVAL: Duration = Duration::from_secs(2); + +struct PendingPairing { + renderer_id: String, + connection: AirplayConnection, + first_response: Vec, + expires_at: tokio::time::Instant, +} + +struct SecureSession { + control: std::sync::Arc>, + event_task: tokio::task::JoinHandle<()>, + feedback_task: tokio::task::JoinHandle<()>, + timing_task: tokio::task::JoinHandle<()>, + /// RTP sender and clock-sync tasks, present only for audio sessions. + audio_tasks: Vec>, + /// Tracks queued behind the one playing, drained by the RTP sender. + audio_queue: Option, + /// The advertised control endpoint. Held for its lifetime only: dropping it + /// withdraws the mDNS service and stops the listener. + #[allow(dead_code)] + dacp: Option, + /// Pause/skip flags the RTP sender polls, for audio sessions. + transport: Option>, + /// RTSP session URI, needed to TEARDOWN so the receiver stops rendering + /// instead of playing out whatever it has buffered. + rtsp_session: String, +} + +/// Files waiting to be streamed on an open audio session. +type AudioQueue = std::sync::Arc>>; + +/// Identifiers a receiver expects to stay constant for the life of a session. +/// pyatv randomises them once per `RtspSession` and repeats them on every request. +struct SessionHeaders { + /// Lowercase UUID sent as `X-Apple-Session-ID` on the HTTP `/play` request. + session_id: String, + dacp_id: String, + active_remote: String, +} + +impl SessionHeaders { + fn new() -> Self { + let bytes = uuid::Uuid::new_v4().into_bytes(); + let dacp_id = u64::from_be_bytes([ + bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7], + ]); + let active_remote = + u32::from_be_bytes([bytes[8], bytes[9], bytes[10], bytes[11]]).saturating_add(1); + Self { + session_id: uuid::Uuid::new_v4().to_string(), + dacp_id: format!("{dacp_id:X}"), + active_remote: active_remote.to_string(), + } + } +} + +struct SecureControl { + connection: AirplayConnection, + headers: SessionHeaders, + cseq: u32, +} + +impl SecureControl { + fn new(connection: AirplayConnection) -> Self { + Self { + connection, + headers: SessionHeaders::new(), + cseq: 0, + } + } + + /// `SETUP`, `RECORD`, `/feedback`, `/setProperty` and `/rate` travel as RTSP, + /// mirroring pyatv's `RtspSession.exchange`. + async fn rtsp( + &mut self, + method: &str, + path: &str, + content_type: Option<&str>, + body: &[u8], + ) -> anyhow::Result { + let sequence = self.cseq; + self.cseq = self.cseq.saturating_add(1); + let mut headers = vec![ + ("User-Agent", USER_AGENT.to_string()), + ("CSeq", sequence.to_string()), + ("DACP-ID", self.headers.dacp_id.clone()), + ("Active-Remote", self.headers.active_remote.clone()), + ("Client-Instance", self.headers.dacp_id.clone()), + ]; + if let Some(content_type) = content_type { + headers.push(("Content-Type", content_type.to_string())); + } + self.request("RTSP/1.0", method, path, &headers, body).await + } + + /// RTSP with additional headers, for the few requests that need them. + async fn rtsp_with( + &mut self, + method: &str, + path: &str, + content_type: Option<&str>, + extra_headers: &[(&'static str, String)], + body: &[u8], + ) -> anyhow::Result { + let sequence = self.cseq; + self.cseq = self.cseq.saturating_add(1); + let mut headers = vec![ + ("User-Agent", USER_AGENT.to_string()), + ("CSeq", sequence.to_string()), + ("DACP-ID", self.headers.dacp_id.clone()), + ("Active-Remote", self.headers.active_remote.clone()), + ("Client-Instance", self.headers.dacp_id.clone()), + ]; + if let Some(content_type) = content_type { + headers.push(("Content-Type", content_type.to_string())); + } + headers.extend(extra_headers.iter().cloned()); + self.request("RTSP/1.0", method, path, &headers, body).await + } + + /// `/play`, `/playback-info` and `/stop` are plain HTTP even inside an RTSP + /// session: pyatv sends them through `RtspSession.connection`, not `exchange`. + /// Receivers that dispatch on the protocol line reject them as RTSP. + async fn http( + &mut self, + method: &str, + path: &str, + extra_headers: &[(&'static str, String)], + body: &[u8], + ) -> anyhow::Result { + let mut headers = vec![("User-Agent", USER_AGENT.to_string())]; + headers.extend(extra_headers.iter().cloned()); + self.request("HTTP/1.1", method, path, &headers, body).await + } + + async fn request( + &mut self, + protocol: &str, + method: &str, + path: &str, + headers: &[(&str, String)], + body: &[u8], + ) -> anyhow::Result { + tracing::trace!( + request = %format!("{method} {path} {protocol}"), + headers = %headers + .iter() + .map(|(name, value)| format!("{name}: {value}")) + .collect::>() + .join(" | "), + body = %describe_body(body), + "AirPlay request" + ); + let response = tokio::time::timeout( + REQUEST_TIMEOUT, + self.connection + .request_while_serving_events(method, path, protocol, headers, body), + ) + .await + .with_context(|| format!("AirPlay {method} {path} timed out"))??; + tracing::debug!( + request = %format!("{method} {path} {protocol}"), + status = response.status, + body = %describe_body(&response.body), + "AirPlay response" + ); + Ok(response) + } +} + +impl Drop for SecureSession { + fn drop(&mut self) { + self.event_task.abort(); + self.feedback_task.abort(); + self.timing_task.abort(); + for task in &self.audio_tasks { + task.abort(); + } + } +} + +enum ActiveSession { + Legacy(String), + Secure(Box), +} pub struct AirplayProvider { - sessions: Mutex>, + /// Feature bits seen during discovery, keyed by renderer id. They decide + /// which handshake and which playback command set a receiver expects. + features: Mutex>, + sessions: Mutex>, + pending_pairings: Mutex>, + credentials: CredentialStore, } impl AirplayProvider { pub fn new() -> Self { + Self::with_credentials(CredentialStore::memory()) + } + + pub async fn persistent( + secrets: std::sync::Arc, + ) -> anyhow::Result { + Ok(Self::with_credentials( + CredentialStore::load(secrets).await?, + )) + } + + fn with_credentials(credentials: CredentialStore) -> Self { Self { + features: Mutex::new(HashMap::new()), sessions: Mutex::new(HashMap::new()), + pending_pairings: Mutex::new(HashMap::new()), + credentials, } } @@ -34,10 +270,13 @@ impl AirplayProvider { sessions.remove(&oldest); } } - sessions + match sessions .entry(device.id.clone()) - .or_insert_with(|| uuid::Uuid::new_v4().to_string()) - .clone() + .or_insert_with(|| ActiveSession::Legacy(uuid::Uuid::new_v4().to_string())) + { + ActiveSession::Legacy(session) => session.clone(), + ActiveSession::Secure(_) => uuid::Uuid::new_v4().to_string(), + } } async fn active_session(&self, device: &RendererDevice) -> anyhow::Result { @@ -45,7 +284,10 @@ impl AirplayProvider { .lock() .await .get(&device.id) - .cloned() + .and_then(|session| match session { + ActiveSession::Legacy(id) => Some(id.clone()), + ActiveSession::Secure(_) => None, + }) .ok_or_else(|| anyhow::anyhow!("no active AirPlay session for this renderer")) } @@ -94,6 +336,1296 @@ impl AirplayProvider { } Ok(String::from_utf8_lossy(&bytes).into_owned()) } + + async fn pairing_request( + connection: &mut AirplayConnection, + path: &str, + body: &[u8], + ) -> anyhow::Result> { + let response = connection + .request( + "POST", + path, + "HTTP/1.1", + &[ + ("User-Agent", "AirPlay/550.10".to_string()), + ("Connection", "keep-alive".to_string()), + ("X-Apple-HKP", "3".to_string()), + ("Content-Type", "application/octet-stream".to_string()), + ], + body, + ) + .await?; + anyhow::ensure!( + (200..300).contains(&response.status), + "AirPlay pairing request {path} failed with status {}", + response.status + ); + Ok(response.body) + } + + async fn verified_connection( + &self, + device: &RendererDevice, + ) -> anyhow::Result<(AirplayConnection, [u8; 32])> { + let (controller, accessory) = self + .credentials + .pairing(&device.id) + .await? + .ok_or_else(|| anyhow::anyhow!("AirPlay pairing is required for this receiver"))?; + let mut connection = AirplayConnection::connect(socket_endpoint(device)?).await?; + let mut verify = PairVerifier::new(controller, accessory); + let first = Self::pairing_request(&mut connection, "/pair-verify", &verify.start()).await?; + let second_request = verify.handle_m2(&first)?; + let second = + Self::pairing_request(&mut connection, "/pair-verify", &second_request).await?; + let verified = verify.finish(&second)?; + connection.secure(verified.keys); + Ok((connection, verified.shared_secret)) + } + + /// Establish a session with no stored pairing at all. + /// + /// Receivers advertising `SupportsSystemPairing` or + /// `SupportsCoreUtilsPairingAndEncryption` accept transient Pair Setup: + /// SRP M1-M4 against the fixed code 3939, after which the SRP session key + /// is the shared secret. This is the handshake iOS uses with third-party + /// AirPlay 2 sets, and it needs no PIN and nothing persisted. + async fn transient_connection( + &self, + address: SocketAddr, + ) -> anyhow::Result<(AirplayConnection, Vec)> { + let mut connection = AirplayConnection::connect(address).await?; + let mut pairing = transient::TransientPairing::new()?; + let first = Self::transient_request(&mut connection, &pairing.start()).await?; + let second_request = pairing.handle_m2(&first)?; + let second = Self::transient_request(&mut connection, &second_request).await?; + let shared = pairing.finish(&second)?; + connection.secure(SessionKeys { + read_key: derive_key_from(&shared, b"Control-Salt", b"Control-Read-Encryption-Key")?, + write_key: derive_key_from(&shared, b"Control-Salt", b"Control-Write-Encryption-Key")?, + }); + tracing::debug!("AirPlay transient session established"); + Ok((connection, shared)) + } + + /// Transient pairing is announced with `X-Apple-HKP: 4`; regular HAP + /// pairing uses 3. + async fn transient_request( + connection: &mut AirplayConnection, + body: &[u8], + ) -> anyhow::Result> { + let response = connection + .request( + "POST", + "/pair-setup", + "HTTP/1.1", + &[ + ("User-Agent", "AirPlay/550.10".to_string()), + ("Connection", "keep-alive".to_string()), + ("X-Apple-HKP", "4".to_string()), + ("Content-Type", "application/octet-stream".to_string()), + ], + body, + ) + .await?; + anyhow::ensure!( + (200..300).contains(&response.status), + "AirPlay transient pairing failed with status {}{}", + response.status, + describe_body(&response.body) + ); + Ok(response.body) + } + + /// Stream a decoded audio file to the receiver over RTP. + /// + /// This is the only media path a video-less AirPlay 2 receiver exposes, and + /// it is also how audio reaches receivers that do support video. + async fn audio_play( + &self, + device: &RendererDevice, + item: &PlaybackItem, + connection: AirplayConnection, + shared_secret: Vec, + ) -> anyhow::Result<()> { + let address = socket_endpoint(device)?; + let path = std::path::PathBuf::from(&item.local_path); + let source = tokio::task::spawn_blocking(move || audio::PcmSource::open(&path)) + .await + .context("joining the AirPlay audio decoder")??; + + let mut control = SecureControl::new(connection); + let session_bytes = uuid::Uuid::new_v4().into_bytes(); + let session_id = u32::from_be_bytes([ + session_bytes[0], + session_bytes[1], + session_bytes[2], + session_bytes[3], + ]); + let rtsp_session = format!( + "rtsp://{}/{}", + control.connection.local_addr()?.ip(), + session_id + ); + + // The device-level SETUP must land before any stream can be allocated. + let device_id = controller_device_id(); + let timing_socket = tokio::net::UdpSocket::bind(match address.ip() { + std::net::IpAddr::V4(_) => "0.0.0.0:0", + std::net::IpAddr::V6(_) => "[::]:0", + }) + .await?; + let timing_port = i64::from(timing_socket.local_addr()?.port()); + // A receiver requires `GET /info` before it will accept SETUP. + match control.rtsp("GET", "/info", None, &[]).await { + Ok(info) => tracing::debug!(status = info.status, "AirPlay audio /info"), + Err(error) => tracing::debug!(%error, "AirPlay audio /info failed"), + } + + let setup_uuid = uuid::Uuid::new_v4().to_string().to_uppercase(); + let setup = binary_plist(setup_parameters(&device_id, &setup_uuid, timing_port))?; + let response = control + .rtsp("SETUP", &rtsp_session, Some(BINARY_PLIST), &setup) + .await?; + anyhow::ensure!( + (200..300).contains(&response.status), + "AirPlay audio SETUP failed with status {}{}", + response.status, + describe_body(&response.body) + ); + let mut timing_task = AbortOnDrop(Some(tokio::spawn(run_timing_server(timing_socket)))); + + // The receiver expects the reverse event channel to be connected before + // RECORD; leaving it idle makes third-party sets stall the request. + let event_port = plist_port(&response.body, "eventPort") + .context("AirPlay audio SETUP did not return an event port")?; + let mut event_connection = + connect_event_channel(SocketAddr::new(address.ip(), event_port)).await?; + event_connection.secure(SessionKeys { + write_key: derive_key_from( + &shared_secret, + b"Events-Salt", + b"Events-Read-Encryption-Key", + )?, + read_key: derive_key_from( + &shared_secret, + b"Events-Salt", + b"Events-Write-Encryption-Key", + )?, + }); + let (event_sender, _event_replies) = tokio::sync::mpsc::unbounded_channel(); + let (command_sender, mut commands) = tokio::sync::mpsc::unbounded_channel(); + let mut event_task = AbortOnDrop(Some(tokio::spawn(async move { + if let Err(error) = event_connection + .serve_events_with_commands(event_sender, Some(command_sender)) + .await + { + tracing::debug!(%error, "AirPlay audio event channel closed"); + } + }))); + + // The receiver's remote reaches us as DACP command codes on that channel. + let transport = std::sync::Arc::new(raop::Transport::default()); + let session_transport = transport.clone(); + let remote_transport = transport.clone(); + tokio::spawn(async move { + use std::sync::atomic::Ordering; + while let Some(command) = commands.recv().await { + tracing::info!(command, "AirPlay receiver remote command"); + match command.as_str() { + "nextitem" => remote_transport.skip_next.store(true, Ordering::Relaxed), + "previtem" => remote_transport.restart.store(true, Ordering::Relaxed), + _ => {} + } + } + }); + + // RECORD goes after the session SETUP and before the stream SETUP; the + // reverse order yields RECORD=500 / FLUSH=455. + let record = control.rtsp("RECORD", &rtsp_session, None, &[]).await?; + if !(200..300).contains(&record.status) { + tracing::warn!(status = record.status, "AirPlay audio RECORD was refused"); + } + + let key = raop::stream_key(&shared_secret)?; + if std::env::var("VUIO_AIRPLAY_PROBE_FORMATS") + .is_ok_and(|value| matches!(value.trim(), "1" | "true" | "yes")) + { + probe_audio_formats(&mut control, &rtsp_session, &key, session_id).await; + } + let stream = binary_plist(audio_stream_parameters(&key, session_id))?; + let response = control + .rtsp("SETUP", &rtsp_session, Some(BINARY_PLIST), &stream) + .await?; + anyhow::ensure!( + (200..300).contains(&response.status), + "AirPlay audio stream SETUP failed with status {}{}", + response.status, + describe_body(&response.body) + ); + let (data_port, control_port) = parse_audio_ports(&response.body) + .context("AirPlay audio SETUP did not return stream ports")?; + let receiver_session = response.headers.get("session").cloned(); + tracing::info!(data_port, control_port, "AirPlay audio stream allocated"); + + let rtptime = std::sync::Arc::new(std::sync::atomic::AtomicU32::new(0)); + let (mut sender, control_socket, control_target) = raop::AudioSender::connect( + address, + data_port, + control_port, + key, + session_id, + rtptime.clone(), + ) + .await?; + // FLUSH anchors the stream: it tells the receiver which sequence number + // and RTP timestamp the audio about to arrive starts from. Without it a + // receiver has nothing to align against and renders noise, then keeps + // playing whatever it buffered. + let mut flush_headers = vec![ + ("Range", "npt=0-".to_string()), + ( + "RTP-Info", + format!( + "seq={};rtptime={}", + sender.start_sequence(), + sender.start_rtptime() + ), + ), + ]; + if let Some(session) = receiver_session.clone() { + flush_headers.push(("Session", session)); + } + // FLUSH declares where the stream restarts: it tells the receiver to + // drop anything buffered and expect audio from this seq/rtptime. A + // fresh session has nothing to discard, so it is optional at start, but + // it is the mechanism a seek or track skip needs. + let flush = control + .rtsp_with("FLUSH", &rtsp_session, None, &flush_headers, &[]) + .await?; + if !(200..300).contains(&flush.status) { + tracing::warn!(status = flush.status, "AirPlay audio FLUSH was refused"); + } else { + tracing::debug!(status = flush.status, "AirPlay audio stream anchored"); + } + + // Only now start announcing the clock, and seed it with the position + // the first packet will carry -- a zero here makes the very first sync + // packet claim a stream position of `0 - latency`, which wraps. + rtptime.store(sender.start_rtptime(), std::sync::atomic::Ordering::Relaxed); + let mut sync_task = AbortOnDrop(Some(raop::spawn_sync_task( + control_socket, + control_target, + rtptime, + ))); + + let control = std::sync::Arc::new(Mutex::new(control)); + let mut feedback_task = AbortOnDrop(Some(spawn_feedback_task(control.clone()))); + + // Advertise the control endpoint before audio starts, so the receiver + // has somewhere to send its remote's button presses. + let local_ip = control.lock().await.connection.local_addr()?.ip(); + let dacp_id = control.lock().await.headers.dacp_id.clone(); + let dacp = match dacp::DacpServer::start(&dacp_id, local_ip, transport.clone()).await { + Ok(server) => Some(server), + Err(error) => { + tracing::warn!(%error, "AirPlay DACP controls unavailable"); + None + } + }; + + let queue: AudioQueue = std::sync::Arc::new(Mutex::new(std::collections::VecDeque::new())); + let stream_queue = queue.clone(); + let stream_control = control.clone(); + let stream_rtsp_session = rtsp_session.clone(); + let stream_session = receiver_session.clone(); + let stream_task = tokio::spawn(async move { + if let Err(error) = sender + .stream( + source, + stream_queue, + stream_control, + stream_rtsp_session, + stream_session, + transport, + ) + .await + { + tracing::warn!(%error, "AirPlay audio streaming stopped"); + } + }); + + self.sessions.lock().await.insert( + device.id.clone(), + ActiveSession::Secure(Box::new(SecureSession { + control, + event_task: event_task.take(), + feedback_task: feedback_task.take(), + timing_task: timing_task.take(), + audio_tasks: vec![stream_task, sync_task.take()], + audio_queue: Some(queue), + dacp, + transport: Some(session_transport), + rtsp_session: rtsp_session.clone(), + })), + ); + Ok(()) + } + + async fn secure_play( + &self, + device: &RendererDevice, + item: &PlaybackItem, + ) -> anyhow::Result<()> { + let address = socket_endpoint(device)?; + let (connection, shared_secret) = self.verified_connection(device).await?; + self.start_secure_session( + &device.id, + address, + connection, + shared_secret.to_vec(), + &item.url, + ) + .await + } + + /// Establish an AirPlay 2 media session and start playback. + /// + /// This is a port of pyatv's `AirPlayV2.play_url`: `SETUP`, encrypted event + /// channel, NTP timing server, feedback loop, `RECORD`, then `POST /play` + /// carrying the media URL untouched. Taking the receiver address explicitly + /// (rather than re-deriving it from `device`) keeps the sequence testable + /// against a loopback receiver. + async fn start_secure_session( + &self, + renderer_id: &str, + address: SocketAddr, + connection: AirplayConnection, + shared_secret: Vec, + media_url: &str, + ) -> anyhow::Result<()> { + let mut control = SecureControl::new(connection); + let setup_uuid = uuid::Uuid::new_v4().to_string().to_uppercase(); + let media_id = uuid::Uuid::new_v4().to_string(); + let device_id = controller_device_id(); + + let bind_address = match address.ip() { + std::net::IpAddr::V4(_) => "0.0.0.0:0", + std::net::IpAddr::V6(_) => "[::]:0", + }; + let timing_socket = tokio::net::UdpSocket::bind(bind_address).await?; + let timing_port = i64::from(timing_socket.local_addr()?.port()); + + let rtsp_session_bytes = uuid::Uuid::new_v4().into_bytes(); + let rtsp_session_id = u32::from_be_bytes([ + rtsp_session_bytes[0], + rtsp_session_bytes[1], + rtsp_session_bytes[2], + rtsp_session_bytes[3], + ]); + let rtsp_session = format!( + "rtsp://{}/{}", + control.connection.local_addr()?.ip(), + rtsp_session_id + ); + + // Diagnostic only: `/info` reports which endpoints and features the + // receiver actually implements, which is the fastest way to tell a + // legacy `/play` receiver apart from a play-queue-only one. pyatv sends + // it with `allow_error=True`, so a failure here is never fatal. + match control.rtsp("GET", "/info", None, &[]).await { + Ok(info) => tracing::info!( + status = info.status, + info = %describe_body(&info.body), + "AirPlay receiver /info" + ), + Err(error) => tracing::debug!(%error, "AirPlay receiver did not answer /info"), + } + + let setup_body = binary_plist(setup_parameters(&device_id, &setup_uuid, timing_port))?; + let setup_response = control + .rtsp("SETUP", &rtsp_session, Some(BINARY_PLIST), &setup_body) + .await?; + anyhow::ensure!( + (200..300).contains(&setup_response.status), + "AirPlay 2 SETUP failed with status {}{}", + setup_response.status, + describe_body(&setup_response.body) + ); + let event_port = plist::Value::from_reader(std::io::Cursor::new(&setup_response.body)) + .ok() + .and_then(|value| value.into_dictionary()) + .and_then(|dictionary| { + dictionary + .get("eventPort") + .and_then(plist::Value::as_unsigned_integer) + }) + .and_then(|port| u16::try_from(port).ok()) + .context("AirPlay 2 SETUP did not return a valid event port")?; + + let event_address = SocketAddr::new(address.ip(), event_port); + let mut event_connection = connect_event_channel(event_address).await?; + // Sender write uses the receiver's "Read" label and vice versa (HAP event channel). + event_connection.secure(SessionKeys { + write_key: derive_key_from( + &shared_secret, + b"Events-Salt", + b"Events-Read-Encryption-Key", + )?, + read_key: derive_key_from( + &shared_secret, + b"Events-Salt", + b"Events-Write-Encryption-Key", + )?, + }); + // Serve events and timing before RECORD/play. Leaving the reverse event + // TCP idle during session setup can stall third-party receivers. + let (event_reply_sender, mut event_replies) = tokio::sync::mpsc::unbounded_channel(); + let mut event_task = AbortOnDrop(Some(tokio::spawn(async move { + if let Err(error) = event_connection.serve_events(event_reply_sender).await { + tracing::debug!(%error, "AirPlay event channel closed"); + } + }))); + let mut timing_task = AbortOnDrop(Some(tokio::spawn(run_timing_server(timing_socket)))); + tokio::spawn(async move { + while let Some((sequence, body)) = event_replies.recv().await { + tracing::debug!( + sequence, + body = %describe_body(&body), + "AirPlay event data-stream reply" + ); + } + }); + + let control = std::sync::Arc::new(Mutex::new(control)); + // pyatv starts feedback before RECORD; some receivers drop a session that + // stays silent between SETUP and playback. + let mut feedback_task = AbortOnDrop(Some(spawn_feedback_task(control.clone()))); + + let record_response = control + .lock() + .await + .rtsp("RECORD", &rtsp_session, None, &[]) + .await?; + if !(200..300).contains(&record_response.status) { + // Some third-party stacks accept the session even when RECORD is odd. + tracing::warn!( + status = record_response.status, + "AirPlay 2 RECORD returned non-success; continuing" + ); + } + + let session_id = control.lock().await.headers.session_id.clone(); + let play_headers = [ + ("Content-Type", BINARY_PLIST.to_string()), + ("X-Apple-ProtocolVersion", "1".to_string()), + ("X-Apple-Session-ID", session_id), + ("X-Apple-Stream-ID", "1".to_string()), + ]; + let play_body = binary_plist(play_parameters(media_url, &media_id, &device_id))?; + let mut play_response = None; + for attempt in 1..=PLAY_RETRIES { + let response = control + .lock() + .await + .http("POST", "/play", &play_headers, &play_body) + .await?; + // Receivers routinely answer the first attempt with a 500 while they + // are still tearing down a previous session. + if response.status == 500 && attempt < PLAY_RETRIES { + tracing::debug!( + attempt, + retries = PLAY_RETRIES, + "AirPlay 2 POST /play returned 500; retrying" + ); + tokio::time::sleep(Duration::from_secs(1)).await; + continue; + } + play_response = Some(response); + break; + } + let play_response = + play_response.context("AirPlay 2 POST /play exhausted its retries with no response")?; + if play_response.status == 404 { + // /play is the AirPlay-video-v1 endpoint. Receivers that do not + // advertise SupportsAirPlayVideoV1 (bit 0) simply do not have it -- + // they expect the play-queue command set instead. + tracing::warn!( + "AirPlay 2 receiver has no /play endpoint; probing its playback surface" + ); + probe_playback_surface(&control).await; + if let Err(error) = + probe_audio_stream(&control, &rtsp_session, &shared_secret, rtsp_session_id).await + { + tracing::info!(%error, "AirPlay audio-stream probe stopped"); + } + anyhow::bail!( + "this AirPlay receiver has no video endpoint (HTTP 404 on /play); it is an \ + AirPlay audio and mirroring receiver only. Cast video to it over Chromecast \ + or DLNA instead" + ); + } + anyhow::ensure!( + (200..300).contains(&play_response.status), + "AirPlay 2 POST /play was rejected with status {}{}", + play_response.status, + describe_body(&play_response.body) + ); + tracing::info!(url = %media_url, "AirPlay 2 POST /play accepted"); + + // pyatv's order matters here: /rate is what actually starts playback, and + // the end-time properties are only accepted once a rate has been set. + let interested = binary_plist(property_value(plist::Value::Boolean(true)))?; + let action_at_end = binary_plist(property_value(plist::Value::Integer(0.into())))?; + let end_time = binary_plist(property_value(plist::Value::Dictionary({ + let mut value = plist::Dictionary::new(); + value.insert("flags".into(), plist::Value::Integer(0.into())); + value.insert("value".into(), plist::Value::Integer(0.into())); + value.insert("epoch".into(), plist::Value::Integer(0.into())); + value.insert("timescale".into(), plist::Value::Integer(0.into())); + value + })))?; + for (method, path, body) in [ + ( + "PUT", + "/setProperty?isInterestedInDateRange", + interested.as_slice(), + ), + ( + "PUT", + "/setProperty?actionAtItemEnd", + action_at_end.as_slice(), + ), + ("POST", "/rate?value=1.000000", [].as_slice()), + ("PUT", "/setProperty?forwardEndTime", end_time.as_slice()), + ("PUT", "/setProperty?reverseEndTime", end_time.as_slice()), + ] { + let content_type = (!body.is_empty()).then_some(BINARY_PLIST); + let response = control + .lock() + .await + .rtsp(method, path, content_type, body) + .await?; + if !(200..300).contains(&response.status) { + tracing::debug!(path, status = response.status, "AirPlay 2 command skipped"); + } + } + + soft_check_playback_info(&control).await?; + + let mut sessions = self.sessions.lock().await; + if sessions.len() >= crate::runtime_state::ACTIVE_CAST_MAX_ENTRIES + && !sessions.contains_key(renderer_id) + { + if let Some(oldest) = sessions.keys().next().cloned() { + sessions.remove(&oldest); + } + } + sessions.insert( + renderer_id.to_string(), + ActiveSession::Secure(Box::new(SecureSession { + control, + event_task: event_task.take(), + feedback_task: feedback_task.take(), + timing_task: timing_task.take(), + audio_tasks: Vec::new(), + audio_queue: None, + dacp: None, + transport: None, + rtsp_session: rtsp_session.clone(), + })), + ); + Ok(()) + } +} + +fn controller_device_id() -> String { + let bytes = uuid::Uuid::new_v4().into_bytes(); + bytes[..6] + .iter() + .map(|byte| format!("{byte:02X}")) + .collect::>() + .join(":") +} + +/// The `SETUP` payload pyatv sends for an AirPlay 2 media session. +fn setup_parameters(device_id: &str, session_uuid: &str, timing_port: i64) -> plist::Value { + let mut setup = plist::Dictionary::new(); + setup.insert( + "deviceID".into(), + plist::Value::String(device_id.to_string()), + ); + setup.insert( + "sessionUUID".into(), + plist::Value::String(session_uuid.to_string()), + ); + setup.insert( + "timingPort".into(), + plist::Value::Integer(timing_port.into()), + ); + setup.insert("timingProtocol".into(), plist::Value::String("NTP".into())); + setup.insert("isMultiSelectAirPlay".into(), plist::Value::Boolean(true)); + setup.insert( + "groupContainsGroupLeader".into(), + plist::Value::Boolean(false), + ); + setup.insert("senderSupportsRelay".into(), plist::Value::Boolean(false)); + setup.insert( + "statsCollectionEnabled".into(), + plist::Value::Boolean(false), + ); + setup.insert( + "macAddress".into(), + plist::Value::String(device_id.to_string()), + ); + setup.insert("name".into(), plist::Value::String("VuIO".into())); + setup.insert("model".into(), plist::Value::String("iPhone14,3".into())); + setup.insert( + "osBuildVersion".into(), + plist::Value::String("20F66".into()), + ); + setup.insert("osName".into(), plist::Value::String("iPhone OS".into())); + setup.insert("osVersion".into(), plist::Value::String("16.5".into())); + setup.insert( + "sourceVersion".into(), + plist::Value::String("690.7.1".into()), + ); + plist::Value::Dictionary(setup) +} + +/// The `POST /play` payload pyatv sends. `Content-Location` is the media URL +/// exactly as advertised -- receivers fetch it themselves over plain HTTP, so +/// rewriting it (for example into a synthesised HLS playlist) only breaks them. +fn play_parameters(media_url: &str, media_id: &str, device_id: &str) -> plist::Value { + let mut play = plist::Dictionary::new(); + play.insert( + "Content-Location".into(), + plist::Value::String(media_url.to_string()), + ); + play.insert("Start-Position-Seconds".into(), plist::Value::Real(0.0)); + play.insert("uuid".into(), plist::Value::String(media_id.to_string())); + play.insert("streamType".into(), plist::Value::Integer(1.into())); + play.insert("mediaType".into(), plist::Value::String("file".into())); + play.insert( + "mightSupportStorePastisKeyRequests".into(), + plist::Value::Boolean(true), + ); + play.insert( + "playbackRestrictions".into(), + plist::Value::Integer(0.into()), + ); + play.insert( + "referenceRestrictions".into(), + plist::Value::Integer(3.into()), + ); + play.insert( + "SenderMACAddress".into(), + plist::Value::String(device_id.to_string()), + ); + play.insert("model".into(), plist::Value::String("iPhone14,3".into())); + play.insert( + "clientBundleID".into(), + plist::Value::String("dev.vuio.app".into()), + ); + play.insert("clientProcName".into(), plist::Value::String("VuIO".into())); + play.insert( + "osBuildVersion".into(), + plist::Value::String("20G1116".into()), + ); + play.insert("volume".into(), plist::Value::Real(1.0)); + play.insert("rate".into(), plist::Value::Real(1.0)); + play.insert( + "secureConnectionMs".into(), + plist::Value::Integer(22.into()), + ); + play.insert("infoMs".into(), plist::Value::Integer(122.into())); + play.insert("connectMs".into(), plist::Value::Integer(18.into())); + for field in ["authMs", "bonjourMs", "postAuthMs"] { + play.insert(field.into(), plist::Value::Integer(0.into())); + } + plist::Value::Dictionary(play) +} + +fn property_value(value: plist::Value) -> plist::Value { + let mut wrapper = plist::Dictionary::new(); + wrapper.insert("value".into(), value); + plist::Value::Dictionary(wrapper) +} + +/// Render a response body for an error message or log line, preferring the +/// decoded plist so receiver-side error codes stay readable. +fn describe_body(body: &[u8]) -> String { + // Generous, because `/info` payloads are the ones worth reading in full. + const MAX_DESCRIPTION_BYTES: usize = 4096; + if body.is_empty() { + return String::new(); + } + let mut described = match plist::Value::from_reader(std::io::Cursor::new(body)) { + Ok(value) => format!("{value:?}"), + Err(_) => String::from_utf8_lossy(body).trim().to_string(), + }; + if described.len() > MAX_DESCRIPTION_BYTES { + described.truncate( + (0..=MAX_DESCRIPTION_BYTES) + .rev() + .find(|index| described.is_char_boundary(*index)) + .unwrap_or(0), + ); + described.push('…'); + } + format!(": {described}") +} + +/// Probe the receiver's playback surface and log what it answers. +/// +/// `insertPlayQueueItem` and the rest of the AirPlay 2 "unified media control" +/// command set are undocumented, so when the legacy `/play` endpoint is missing +/// the only reliable way forward is to ask the receiver what it does implement. +/// Every probe here is read-only or already-failed state, and a 405 (rather than +/// 404) is the interesting answer: it means the path exists under another method. +async fn probe_playback_surface(control: &std::sync::Arc>) { + // RTSP OPTIONS advertises the supported method set in its `Public` header. + match control.lock().await.rtsp("OPTIONS", "*", None, &[]).await { + Ok(response) => tracing::info!( + status = response.status, + public = response + .headers + .get("public") + .map_or("", String::as_str), + "AirPlay probe: OPTIONS *" + ), + Err(error) => tracing::info!(%error, "AirPlay probe: OPTIONS * failed"), + } + + for (protocol, method, path) in [ + // Does /play exist at all under a different method? + ("HTTP/1.1", "GET", "/play"), + ("RTSP/1.0", "POST", "/play"), + ("HTTP/1.1", "GET", "/playback-info"), + ("HTTP/1.1", "GET", "/server-info"), + ("HTTP/1.1", "GET", "/playqueue"), + ("HTTP/1.1", "POST", "/playqueue"), + ("HTTP/1.1", "GET", "/scrub"), + ("RTSP/1.0", "GET", "/playback-info"), + // The AirPlay 2 remote-control command endpoint, which is where the + // play-queue command set lives when there is no HTTP video surface. + ("RTSP/1.0", "POST", "/command"), + ("HTTP/1.1", "POST", "/command"), + ("RTSP/1.0", "GET", "/getProperty?playbackState"), + ("RTSP/1.0", "POST", "/audioMode"), + ("RTSP/1.0", "POST", "/rate?value=1.000000"), + ("HTTP/1.1", "POST", "/stop"), + ("RTSP/1.0", "POST", "/fp-setup"), + ("RTSP/1.0", "POST", "/auth-setup"), + ] { + let mut guard = control.lock().await; + let result = if protocol == "HTTP/1.1" { + guard.http(method, path, &[], &[]).await + } else { + guard.rtsp(method, path, None, &[]).await + }; + drop(guard); + match result { + Ok(response) => tracing::info!( + probe = %format!("{method} {path} {protocol}"), + status = response.status, + body = %describe_body(&response.body), + "AirPlay probe" + ), + Err(error) => { + tracing::info!(probe = %format!("{method} {path} {protocol}"), %error, "AirPlay probe failed"); + // A transport error means the session is gone; further probes + // would only produce noise. + return; + } + } + } +} + +/// Ask the receiver to allocate a buffered-audio stream (RAOP type 96). +/// +/// This is pyatv's `AirPlayV2.setup_audio_stream` body. A 2xx reply naming +/// `dataPort` and `controlPort` means the receiver will accept RTP audio, which +/// is the only media path a video-less AirPlay 2 set exposes. +async fn probe_audio_stream( + control: &std::sync::Arc>, + rtsp_session: &str, + shared_secret: &[u8], + session_id: u32, +) -> anyhow::Result<()> { + let shared_key = derive_key_from( + shared_secret, + b"Events-Salt", + b"Events-Write-Encryption-Key", + )?; + let mut stream = plist::Dictionary::new(); + stream.insert("audioFormat".into(), plist::Value::Integer(0x800.into())); + stream.insert("audioMode".into(), plist::Value::String("default".into())); + stream.insert("controlPort".into(), plist::Value::Integer(0.into())); + stream.insert("ct".into(), plist::Value::Integer(2.into())); + stream.insert("isMedia".into(), plist::Value::Boolean(true)); + stream.insert("latencyMax".into(), plist::Value::Integer(88200.into())); + stream.insert("latencyMin".into(), plist::Value::Integer(11025.into())); + stream.insert("shk".into(), plist::Value::Data(shared_key.to_vec())); + stream.insert("spf".into(), plist::Value::Integer(352.into())); + stream.insert("sr".into(), plist::Value::Integer(44100.into())); + stream.insert("type".into(), plist::Value::Integer(0x60.into())); + stream.insert( + "supportsDynamicStreamID".into(), + plist::Value::Boolean(false), + ); + stream.insert( + "streamConnectionID".into(), + plist::Value::Integer(i64::from(session_id).into()), + ); + let mut body = plist::Dictionary::new(); + body.insert( + "streams".into(), + plist::Value::Array(vec![plist::Value::Dictionary(stream)]), + ); + let encoded = binary_plist(plist::Value::Dictionary(body))?; + let response = control + .lock() + .await + .rtsp("SETUP", rtsp_session, Some(BINARY_PLIST), &encoded) + .await?; + tracing::info!( + status = response.status, + body = %describe_body(&response.body), + "AirPlay buffered-audio SETUP probe" + ); + Ok(()) +} + +/// The realtime audio stream description. +/// +/// `audioFormat`/`ct` announce ALAC because that is what the receiver decodes; +/// it hardcodes ALAC on this stream and ignores what it was offered. +fn audio_stream_parameters(shared_key: &[u8; 32], session_id: u32) -> plist::Value { + let mut stream = plist::Dictionary::new(); + stream.insert("audioFormat".into(), plist::Value::Integer(0x40000.into())); + stream.insert("audioMode".into(), plist::Value::String("default".into())); + stream.insert("controlPort".into(), plist::Value::Integer(0.into())); + stream.insert("ct".into(), plist::Value::Integer(2.into())); + stream.insert("isMedia".into(), plist::Value::Boolean(true)); + stream.insert("latencyMax".into(), plist::Value::Integer(88200.into())); + stream.insert("latencyMin".into(), plist::Value::Integer(11025.into())); + stream.insert("shk".into(), plist::Value::Data(shared_key.to_vec())); + stream.insert( + "spf".into(), + plist::Value::Integer((raop::FRAMES_PER_PACKET as i64).into()), + ); + stream.insert("sr".into(), plist::Value::Integer(44100.into())); + stream.insert("type".into(), plist::Value::Integer(96.into())); + stream.insert( + "supportsDynamicStreamID".into(), + plist::Value::Boolean(false), + ); + stream.insert( + "streamConnectionID".into(), + plist::Value::Integer(i64::from(session_id).into()), + ); + let mut body = plist::Dictionary::new(); + body.insert( + "streams".into(), + plist::Value::Array(vec![plist::Value::Dictionary(stream)]), + ); + plist::Value::Dictionary(body) +} + +fn plist_port(body: &[u8], key: &str) -> Option { + plist::Value::from_reader(std::io::Cursor::new(body)) + .ok()? + .into_dictionary()? + .get(key) + .and_then(plist::Value::as_unsigned_integer) + .and_then(|port| u16::try_from(port).ok()) +} + +fn parse_audio_ports(body: &[u8]) -> Option<(u16, u16)> { + let stream = plist::Value::from_reader(std::io::Cursor::new(body)) + .ok()? + .into_dictionary()? + .get("streams") + .cloned()? + .into_array()? + .into_iter() + .next()? + .into_dictionary()?; + let port = |key: &str| { + stream + .get(key) + .and_then(plist::Value::as_unsigned_integer) + .and_then(|value| u16::try_from(value).ok()) + }; + Some((port("dataPort")?, port("controlPort")?)) +} + +/// Ask the receiver which audio formats it will actually allocate a stream for. +/// +/// `/info` does not advertise an `audioFormats` list, so the only way to learn +/// what a set supports is to request each one and see which SETUPs succeed. +/// Enabled with `VUIO_AIRPLAY_PROBE_FORMATS=1`. +async fn probe_audio_formats( + control: &mut SecureControl, + rtsp_session: &str, + shared_key: &[u8; 32], + session_id: u32, +) { + for (label, format) in [ + ("PCM/44100/16/2", 0x800i64), + ("PCM/44100/24/2", 0x2000), + ("PCM/48000/16/2", 0x8000), + ("ALAC/44100/16/2", 0x40000), + ("ALAC/44100/24/2", 0x80000), + ("AAC-LC/44100/2", 0x400000), + ("AAC-ELD/44100/2", 0x1000000), + ("nonsense", 0x2), + ] { + let mut value = audio_stream_parameters(shared_key, session_id); + if let Some(stream) = value + .as_dictionary_mut() + .and_then(|body| body.get_mut("streams")) + .and_then(plist::Value::as_array_mut) + .and_then(|streams| streams.first_mut()) + .and_then(plist::Value::as_dictionary_mut) + { + stream.insert("audioFormat".into(), plist::Value::Integer(format.into())); + } + let Ok(body) = binary_plist(value) else { + continue; + }; + match control + .rtsp("SETUP", rtsp_session, Some(BINARY_PLIST), &body) + .await + { + Ok(response) => tracing::info!( + format = label, + bitmask = format!("{format:#x}"), + status = response.status, + body = %describe_body(&response.body), + "AirPlay audio format probe" + ), + Err(error) => { + tracing::info!(format = label, %error, "AirPlay audio format probe failed"); + return; + } + } + } +} + +/// Encode one DMAP tag: a four-character code, a big-endian length, the value. +fn dmap_tag(code: &[u8; 4], value: &[u8]) -> Vec { + let mut tag = Vec::with_capacity(8 + value.len()); + tag.extend_from_slice(code); + tag.extend_from_slice(&(value.len() as u32).to_be_bytes()); + tag.extend_from_slice(value); + tag +} + +/// The now-playing payload a receiver renders: title, album, artist. +fn dmap_metadata(metadata: &audio::TrackMetadata) -> Vec { + let mut payload = Vec::new(); + if let Some(title) = &metadata.title { + payload.extend_from_slice(&dmap_tag(b"minm", title.as_bytes())); + } + if let Some(album) = &metadata.album { + payload.extend_from_slice(&dmap_tag(b"asal", album.as_bytes())); + } + if let Some(artist) = &metadata.artist { + payload.extend_from_slice(&dmap_tag(b"asar", artist.as_bytes())); + } + dmap_tag(b"mlit", &payload) +} + +/// Announce a track to the receiver's now-playing screen. +/// +/// `progress` carries RTP timestamps and is what gives the seek bar its extent +/// and position; the DMAP payload supplies the text. +#[allow(clippy::too_many_arguments)] +async fn announce_track( + control: &std::sync::Arc>, + rtsp_session: &str, + receiver_session: Option<&str>, + sequence: u16, + rtptime: u32, + end: u32, + metadata: &audio::TrackMetadata, +) { + let progress = format!("progress: {rtptime}/{rtptime}/{end}\r\n"); + let mut guard = control.lock().await; + if let Err(error) = guard + .rtsp( + "SET_PARAMETER", + rtsp_session, + Some("text/parameters"), + progress.as_bytes(), + ) + .await + { + tracing::debug!(%error, "AirPlay progress update failed"); + return; + } + + let mut headers = vec![("RTP-Info", format!("seq={sequence};rtptime={rtptime}"))]; + if let Some(session) = receiver_session { + headers.push(("Session", session.to_string())); + } + match guard + .rtsp_with( + "SET_PARAMETER", + rtsp_session, + Some("application/x-dmap-tagged"), + &headers, + &dmap_metadata(metadata), + ) + .await + { + Ok(response) => tracing::debug!( + status = response.status, + title = metadata.title.as_deref().unwrap_or(""), + "AirPlay now-playing metadata sent" + ), + Err(error) => tracing::debug!(%error, "AirPlay metadata update failed"), + } + + if let Some((media_type, artwork)) = &metadata.artwork { + match guard + .rtsp_with("SET_PARAMETER", "/", Some(media_type), &headers, artwork) + .await + { + Ok(response) => tracing::debug!( + status = response.status, + bytes = artwork.len(), + "AirPlay cover art sent" + ), + Err(error) => tracing::debug!(%error, "AirPlay cover art failed"), + } + } +} + +/// Refresh only the position, leaving the text metadata in place. +async fn update_progress( + control: &std::sync::Arc>, + rtsp_session: &str, + start: u32, + now: u32, + end: u32, +) { + let progress = format!("progress: {start}/{now}/{end}\r\n"); + if let Err(error) = control + .lock() + .await + .rtsp( + "SET_PARAMETER", + rtsp_session, + Some("text/parameters"), + progress.as_bytes(), + ) + .await + { + tracing::debug!(%error, "AirPlay progress refresh failed"); + } +} + +/// Named AirPlay feature bits, from pyatv's `AirPlayFlags`. Only the ones that +/// decide which playback path a receiver supports are listed. +const FEATURE_NAMES: &[(u64, &str)] = &[ + (0, "SupportsAirPlayVideoV1"), + (7, "SupportsAirPlayScreen"), + (9, "SupportsAirPlayAudio"), + (27, "SupportsLegacyPairing"), + (33, "SupportsAirPlayVideoPlayQueue"), + (38, "SupportsUnifiedMediaControl"), + (43, "SupportsSystemPairing"), + (46, "SupportsHKPairingAndAccessControl"), + (48, "SupportsCoreUtilsPairingAndEncryption"), + (49, "SupportsAirPlayVideoV2"), + (58, "SupportsHangdogRemoteControl"), +]; + +fn describe_features(bits: u64) -> String { + let named = FEATURE_NAMES + .iter() + .filter(|(bit, _)| bits & (1 << bit) != 0) + .map(|(_, name)| *name) + .collect::>(); + if named.is_empty() { + return "none of the known playback features".to_string(); + } + named.join(", ") +} + +/// Receivers advertise the event port in their `SETUP` reply before they start +/// listening on it, so a first connect can legitimately be refused. +async fn connect_event_channel(address: SocketAddr) -> anyhow::Result { + let mut last_error = None; + for attempt in 1..=EVENT_CHANNEL_ATTEMPTS { + match AirplayConnection::connect(address).await { + Ok(connection) => return Ok(connection), + Err(error) => { + tracing::debug!( + attempt, + attempts = EVENT_CHANNEL_ATTEMPTS, + %error, + "AirPlay event channel is not accepting connections yet" + ); + last_error = Some(error); + } + } + if attempt < EVENT_CHANNEL_ATTEMPTS { + tokio::time::sleep(Duration::from_secs(1)).await; + } + } + Err(last_error.unwrap_or_else(|| anyhow::anyhow!("AirPlay event channel could not be reached"))) +} + +async fn run_timing_server(socket: tokio::net::UdpSocket) { + let mut request = [0u8; 64]; + loop { + let Ok((length, peer)) = socket.recv_from(&mut request).await else { + return; + }; + if length < 32 { + continue; + } + let (seconds, fraction) = ntp_now(); + let mut response = [0u8; 32]; + response[0] = request[0]; + response[1] = 0xD3; + response[2..4].copy_from_slice(&7u16.to_be_bytes()); + response[8..16].copy_from_slice(&request[24..32]); + response[16..20].copy_from_slice(&seconds.to_be_bytes()); + response[20..24].copy_from_slice(&fraction.to_be_bytes()); + response[24..28].copy_from_slice(&seconds.to_be_bytes()); + response[28..32].copy_from_slice(&fraction.to_be_bytes()); + let _ = socket.send_to(&response, peer).await; + } +} + +fn ntp_now() -> (u32, u32) { + let duration = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + let seconds = duration.as_secs().saturating_add(2_208_988_800) as u32; + let fraction = ((u64::from(duration.subsec_nanos()) << 32) / 1_000_000_000) as u32; + (seconds, fraction) +} + +fn binary_plist(value: plist::Value) -> anyhow::Result> { + let mut body = Vec::new(); + plist::to_writer_binary(&mut body, &value)?; + Ok(body) +} + +struct AbortOnDrop(Option>); + +impl AbortOnDrop { + fn take(&mut self) -> tokio::task::JoinHandle<()> { + self.0.take().expect("AbortOnDrop already taken") + } +} + +impl Drop for AbortOnDrop { + fn drop(&mut self) { + if let Some(handle) = self.0.take() { + handle.abort(); + } + } +} + +fn spawn_feedback_task( + control: std::sync::Arc>, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let mut interval = tokio::time::interval(FEEDBACK_INTERVAL); + loop { + // The first tick completes immediately, matching pyatv's feedback loop. + interval.tick().await; + let result = control + .lock() + .await + .rtsp("POST", "/feedback", None, &[]) + .await; + if let Err(error) = result { + tracing::debug!(%error, "AirPlay feedback loop stopped"); + return; + } + } + }) +} + +/// Poll `/playback-info` until the receiver reports a duration, mirroring +/// pyatv's `_wait_for_media_to_end` start-up window. A receiver-side error is +/// fatal; anything else is treated as "still buffering" and left alone. +async fn soft_check_playback_info( + control: &std::sync::Arc>, +) -> anyhow::Result<()> { + for attempt in 1..=PLAYBACK_INFO_ATTEMPTS { + let response = match control + .lock() + .await + .http("GET", "/playback-info", &[], &[]) + .await + { + Ok(response) => response, + Err(error) => { + tracing::debug!(%error, "AirPlay /playback-info unavailable"); + return Ok(()); + } + }; + if !(200..300).contains(&response.status) { + tracing::debug!( + status = response.status, + "AirPlay /playback-info unavailable; continuing" + ); + return Ok(()); + } + if let Some(message) = playback_info_error_message(&response.body) { + anyhow::bail!("{message}"); + } + if playback_info_reports_duration(&response.body) { + tracing::debug!(attempt, "AirPlay playback-info reported duration"); + return Ok(()); + } + tokio::time::sleep(Duration::from_secs(1)).await; + } + Ok(()) +} + +fn playback_info_error_message(body: &[u8]) -> Option { + let value = plist::Value::from_reader(std::io::Cursor::new(body)).ok()?; + let error = value.as_dictionary()?.get("error")?.as_dictionary()?; + let code = error.get("code").map(|value| match value { + plist::Value::Integer(integer) => integer + .as_signed() + .map(|signed| signed.to_string()) + .or_else(|| integer.as_unsigned().map(|unsigned| unsigned.to_string())) + .unwrap_or_else(|| format!("{value:?}")), + other => format!("{other:?}"), + })?; + let domain = error + .get("domain") + .and_then(plist::Value::as_string) + .unwrap_or("unknown domain"); + Some(format!( + "AirPlay playback failed with error {code} ({domain})" + )) +} + +fn playback_info_reports_duration(body: &[u8]) -> bool { + if let Ok(value) = plist::Value::from_reader(std::io::Cursor::new(body)) { + if value + .as_dictionary() + .is_some_and(|dictionary| dictionary.contains_key("duration")) + { + return true; + } + } + parse_playback_info(&String::from_utf8_lossy(body)) + .ok() + .is_some_and(|values| values.contains_key("duration")) } #[async_trait] @@ -111,7 +1643,19 @@ impl CastProvider for AirplayProvider { let ServiceEvent::ServiceResolved(info) = event else { continue; }; - if !supports_url_video(info.get_property_val_str("features")) { + let feature_text = info + .get_property_val_str("features") + .or_else(|| info.get_property_val_str("ft")); + let Some(features) = parse_features(feature_text) else { + continue; + }; + tracing::info!( + name = info.get_property_val_str("name").unwrap_or(info.get_fullname()), + raw_features = feature_text.unwrap_or(""), + features = %describe_features(features), + "AirPlay receiver discovered" + ); + if !supports_url_video_bits(features) && !supports_audio_bits(features) { continue; } let Some(ip) = info @@ -128,6 +1672,19 @@ impl CastProvider for AirplayProvider { .filter(|value| !value.is_empty()) .unwrap_or(info.get_fullname()); let id = format!("airplay:{raw_id}"); + self.features.lock().await.insert(id.clone(), features); + // Only ask for a PIN when the receiver leaves us no alternative. + // A set that advertises system or CoreUtils pairing takes a + // transient session, which needs no code and stores nothing. + let pairing = if !airplay2_enabled() || features & AIRPLAY_VIDEO_V2 == 0 { + PairingStatus::NotRequired + } else if self.credentials.is_paired(&id).await { + PairingStatus::Paired + } else if supports_transient_pairing(features) { + PairingStatus::NotRequired + } else { + PairingStatus::Required + }; devices.entry(id.clone()).or_insert_with(|| RendererDevice { id, friendly_name: info @@ -141,9 +1698,10 @@ impl CastProvider for AirplayProvider { .unwrap_or("AirPlay") .to_string(), protocol: RendererProtocol::Airplay, + pairing, capabilities: RendererCapabilities { - video: true, - audio: false, + video: features & AIRPLAY_VIDEO_V1 != 0, + audio: true, image: false, playlists: true, controls: vec![ @@ -161,18 +1719,67 @@ impl CastProvider for AirplayProvider { } fn validate(&self, item: &PlaybackItem) -> Result<(), String> { - if item.mime_type == "video/mp4" { + // Audio is decoded and pushed as PCM, so the container does not matter + // as long as VuIO can decode it. + if audio::is_streamable_audio(&item.mime_type, &item.filename) { + return Ok(()); + } + if is_native_airplay_video(&item.mime_type, &item.filename) { Ok(()) } else { Err(format!( - "AirPlay URL video only supports progressive MP4; {} is {}", + "{} ({}) is not an AirPlay-native video container; use MP4/M4V, MOV, HLS, or MPEG-TS", item.filename, item.mime_type )) } } async fn play(&self, device: &RendererDevice, item: &PlaybackItem) -> anyhow::Result<()> { - self.validate(item).map_err(anyhow::Error::msg)?; + let wants_audio = audio::is_streamable_audio(&item.mime_type, &item.filename); + if !wants_audio { + self.validate(item).map_err(anyhow::Error::msg)?; + } + if airplay2_enabled() { + // Prefer the handshake that needs no user interaction. A receiver + // advertising system or CoreUtils pairing takes a transient session, + // which is what iOS uses with these sets; a stored PIN pairing is + // only the fallback, and asking for a PIN is the last resort. + let features = self.features.lock().await.get(&device.id).copied(); + if features.is_some_and(supports_transient_pairing) { + let address = socket_endpoint(device)?; + match self.transient_connection(address).await { + Ok((connection, shared)) => { + if wants_audio { + return self.audio_play(device, item, connection, shared).await; + } + return self + .start_secure_session( + &device.id, address, connection, shared, &item.url, + ) + .await; + } + // macOS answers 403 when access control is on, so fall + // through to the paired and PIN paths. + Err(error) => tracing::debug!( + %error, + "AirPlay transient pairing refused; trying a stored pairing" + ), + } + } + if self.credentials.is_paired(&device.id).await { + if wants_audio { + let (connection, shared) = self.verified_connection(device).await?; + return self + .audio_play(device, item, connection, shared.to_vec()) + .await; + } + return self.secure_play(device, item).await; + } + } + anyhow::ensure!( + device.pairing != PairingStatus::Required, + "AirPlay pairing is required for this receiver" + ); let session = self.session_for_play(device).await; self.request( device, @@ -189,6 +1796,75 @@ impl CastProvider for AirplayProvider { } async fn control(&self, device: &RendererDevice, action: PlaybackAction) -> anyhow::Result<()> { + let mut sessions = self.sessions.lock().await; + if let Some(ActiveSession::Secure(session)) = sessions.get_mut(&device.id) { + let rtsp_session = session.rtsp_session.clone(); + + // An audio session is a push stream: pausing it means sending + // silence rather than issuing a rate change, which has no meaning + // for RTP. + if let Some(transport) = session.transport.clone() { + match action { + PlaybackAction::Play => { + transport + .paused + .store(false, std::sync::atomic::Ordering::Relaxed); + return Ok(()); + } + PlaybackAction::Pause => { + transport + .paused + .store(true, std::sync::atomic::Ordering::Relaxed); + return Ok(()); + } + PlaybackAction::Stop => {} + } + } + + if action == PlaybackAction::Stop { + // TEARDOWN is best effort: a receiver commonly drops the + // connection the moment it accepts one, so a missing response + // still means the session ended. Reporting that as a failure + // would leave the session in the map and the UI stuck. + let mut control = session.control.lock().await; + match control.rtsp("TEARDOWN", &rtsp_session, None, &[]).await { + Ok(response) => { + tracing::debug!(status = response.status, "AirPlay session torn down") + } + Err(error) => tracing::debug!( + %error, + "AirPlay receiver closed the connection on TEARDOWN, which ends the session anyway" + ), + } + drop(control); + // Dropping the session aborts its streaming and keep-alive tasks. + sessions.remove(&device.id); + return Ok(()); + } + + let mut control = session.control.lock().await; + let response = match action { + PlaybackAction::Play => { + control + .rtsp("POST", "/rate?value=1.000000", None, &[]) + .await + } + PlaybackAction::Pause => { + control + .rtsp("POST", "/rate?value=0.000000", None, &[]) + .await + } + PlaybackAction::Stop => unreachable!("stop is handled above"), + }?; + anyhow::ensure!( + (200..300).contains(&response.status), + "AirPlay control failed with status {}{}", + response.status, + describe_body(&response.body) + ); + return Ok(()); + } + drop(sessions); let session = self.active_session(device).await?; let path = match action { PlaybackAction::Play => "/rate?value=1.000000", @@ -204,6 +1880,23 @@ impl CastProvider for AirplayProvider { } async fn status(&self, device: &RendererDevice) -> anyhow::Result { + let mut sessions = self.sessions.lock().await; + if let Some(ActiveSession::Secure(session)) = sessions.get_mut(&device.id) { + let response = session + .control + .lock() + .await + .http("GET", "/playback-info", &[], &[]) + .await?; + anyhow::ensure!( + (200..300).contains(&response.status), + "AirPlay status failed with status {}{}", + response.status, + describe_body(&response.body) + ); + return playback_status_from_bytes(&response.body); + } + drop(sessions); let session = self.active_session(device).await?; let body = self .request( @@ -236,27 +1929,233 @@ impl CastProvider for AirplayProvider { }) } + async fn begin_pairing(&self, device: &RendererDevice) -> anyhow::Result { + anyhow::ensure!(airplay2_enabled(), "AirPlay 2 pairing is disabled"); + anyhow::ensure!( + device.protocol == RendererProtocol::Airplay, + "renderer protocol mismatch" + ); + let controller = self.credentials.controller().await?; + let mut connection = AirplayConnection::connect(socket_endpoint(device)?).await?; + Self::pairing_request(&mut connection, "/pair-pin-start", &[]).await?; + let first_request = PairSetupClient::new("0000", controller)?.start(); + let first_response = + Self::pairing_request(&mut connection, "/pair-setup", &first_request).await?; + let id = uuid::Uuid::new_v4().to_string(); + let mut pending = self.pending_pairings.lock().await; + pending.retain(|_, challenge| challenge.expires_at > tokio::time::Instant::now()); + anyhow::ensure!( + pending.len() < 16, + "too many pending AirPlay pairing attempts" + ); + pending.insert( + id.clone(), + PendingPairing { + renderer_id: device.id.clone(), + connection, + first_response, + expires_at: tokio::time::Instant::now() + PAIRING_TTL, + }, + ); + Ok(PairingChallenge { + id, + renderer_id: device.id.clone(), + expires_in_seconds: PAIRING_TTL.as_secs(), + }) + } + + async fn finish_pairing(&self, challenge_id: &str, pin: &str) -> anyhow::Result<()> { + anyhow::ensure!(airplay2_enabled(), "AirPlay 2 pairing is disabled"); + anyhow::ensure!( + valid_pin(pin), + "enter the PIN displayed by the AirPlay receiver" + ); + let mut challenge = self + .pending_pairings + .lock() + .await + .remove(challenge_id) + .ok_or_else(|| anyhow::anyhow!("AirPlay pairing request expired or was not found"))?; + anyhow::ensure!( + challenge.expires_at > tokio::time::Instant::now(), + "AirPlay pairing request expired" + ); + let controller = self.credentials.controller().await?; + let mut setup = PairSetupClient::new(pin, controller)?; + let _ = setup.start(); + let mut response = challenge.first_response; + loop { + match setup.handle(&response)? { + PairSetupStep::Send(outgoing) => { + response = + Self::pairing_request(&mut challenge.connection, "/pair-setup", &outgoing) + .await?; + } + PairSetupStep::Done(pairing) => { + self.credentials + .save_pairing(&challenge.renderer_id, &pairing) + .await?; + return Ok(()); + } + } + } + } + + /// Append a track to an open audio session so folders play through. + async fn queue_next( + &self, + device: &RendererDevice, + item: &PlaybackItem, + ) -> anyhow::Result { + if !audio::is_streamable_audio(&item.mime_type, &item.filename) { + return Ok(false); + } + let sessions = self.sessions.lock().await; + let Some(ActiveSession::Secure(session)) = sessions.get(&device.id) else { + return Ok(false); + }; + let Some(queue) = session.audio_queue.as_ref() else { + return Ok(false); + }; + queue.lock().await.push_back(item.local_path.clone()); + Ok(true) + } + + async fn forget_pairing(&self, device: &RendererDevice) -> anyhow::Result { + self.sessions.lock().await.remove(&device.id); + self.credentials.forget(&device.id).await + } + async fn shutdown(&self) { - self.sessions.lock().await.clear(); + // Without TEARDOWN a receiver keeps rendering its buffer after VuIO + // exits, so tell it to stop before dropping the sessions. + let sessions: Vec<_> = self.sessions.lock().await.drain().collect(); + for (id, session) in &sessions { + let ActiveSession::Secure(session) = session else { + continue; + }; + let rtsp_session = session.rtsp_session.clone(); + let result = tokio::time::timeout(Duration::from_secs(2), async { + session + .control + .lock() + .await + .rtsp("TEARDOWN", &rtsp_session, None, &[]) + .await + }) + .await; + match result { + Ok(Ok(response)) => { + tracing::debug!(id, status = response.status, "AirPlay session torn down") + } + Ok(Err(error)) => tracing::debug!(id, %error, "AirPlay TEARDOWN failed"), + Err(_) => tracing::debug!(id, "AirPlay TEARDOWN timed out"), + } + } + drop(sessions); + self.pending_pairings.lock().await.clear(); } } +fn valid_pin(pin: &str) -> bool { + let digit_count = pin.bytes().filter(u8::is_ascii_digit).count(); + (4..=8).contains(&digit_count) + && pin.len() <= 16 + && pin + .bytes() + .all(|byte| byte.is_ascii_digit() || byte == b'-') +} + +fn is_native_airplay_video(mime: &str, filename: &str) -> bool { + matches!( + mime.split(';') + .next() + .unwrap_or(mime) + .trim() + .to_ascii_lowercase() + .as_str(), + "video/mp4" + | "video/x-m4v" + | "video/quicktime" + | "application/vnd.apple.mpegurl" + | "application/x-mpegurl" + | "video/mp2t" + ) || filename.rsplit_once('.').is_some_and(|(_, extension)| { + matches!( + extension.to_ascii_lowercase().as_str(), + "mp4" | "m4v" | "mov" | "m3u8" | "ts" + ) + }) +} + impl Default for AirplayProvider { fn default() -> Self { Self::new() } } +#[cfg(test)] fn supports_url_video(features: Option<&str>) -> bool { - let Some(first) = features.and_then(|value| value.split(',').next()) else { - return false; + parse_features(features).is_some_and(supports_url_video_bits) +} + +/// `/play` is the AirPlay-video-v1 endpoint, so bit 0 decides whether a +/// receiver can play a *URL* at all. +/// +/// A receiver advertising only v2 (bit 49) drives video through Apple's +/// play-queue command set. Probing a Sony XR-75X90L showed there is no way to +/// reach it as a third-party sender: every HTTP video path answers 404, +/// `/command` exists but 500s because no stream can be bound to it, the +/// type-130 remote-control stream is refused (pyatv likewise only attempts it +/// against Apple TV and HomePod), and `OPTIONS *` advertises just the RAOP +/// audio and mirroring methods. Such a receiver is still listed, because audio +/// reaches it over RTP -- it simply reports no video capability. +fn supports_url_video_bits(bits: u64) -> bool { + bits & AIRPLAY_VIDEO_V1 != 0 || (allow_video_v2_only() && bits & AIRPLAY_VIDEO_V2 != 0) +} + +/// Receivers that accept a buffered-audio stream, which is every AirPlay 2 set. +fn supports_audio_bits(bits: u64) -> bool { + bits & AIRPLAY_AUDIO != 0 +} + +/// Escape hatch for retrying video on v2-only receivers, e.g. after a firmware +/// update. Set `VUIO_AIRPLAY_ALLOW_V2_ONLY=1`. Audio never needs this. +fn allow_video_v2_only() -> bool { + std::env::var("VUIO_AIRPLAY_ALLOW_V2_ONLY") + .is_ok_and(|value| matches!(value.trim(), "1" | "true" | "yes")) +} + +/// Receivers advertising system or CoreUtils pairing accept a transient +/// session, so no PIN and no stored credentials are needed. +fn supports_transient_pairing(bits: u64) -> bool { + bits & (AIRPLAY_SYSTEM_PAIRING | AIRPLAY_CORE_UTILS_PAIRING) != 0 +} + +fn airplay2_enabled() -> bool { + AIRPLAY_2_ENABLED +} + +fn parse_features(features: Option<&str>) -> Option { + let mut words = features?.split(','); + let low = parse_feature_word(words.next()?)?; + let high = match words.next() { + Some(word) => parse_feature_word(word)?, + None => 0, }; - let parsed = first - .strip_prefix("0x") - .or_else(|| first.strip_prefix("0X")) - .map(|value| u64::from_str_radix(value, 16)) - .unwrap_or_else(|| first.parse::()); - parsed.is_ok_and(|bits| bits & 1 != 0) + if words.next().is_some() || low > u32::MAX as u64 || high > u32::MAX as u64 { + return None; + } + Some(low | (high << 32)) +} + +fn parse_feature_word(word: &str) -> Option { + let word = word.trim(); + if let Some(hex) = word.strip_prefix("0x").or_else(|| word.strip_prefix("0X")) { + u64::from_str_radix(hex, 16).ok() + } else { + word.parse().ok() + } } fn socket_endpoint(device: &RendererDevice) -> anyhow::Result { @@ -304,6 +2203,45 @@ fn parse_playback_info(xml: &str) -> anyhow::Result> { Ok(values) } +fn playback_status_from_bytes(bytes: &[u8]) -> anyhow::Result { + let values = if let Ok(plist::Value::Dictionary(dictionary)) = + plist::Value::from_reader(std::io::Cursor::new(bytes)) + { + dictionary + .into_iter() + .filter_map(|(key, value)| { + let number = match value { + plist::Value::Real(value) => Some(value), + plist::Value::Integer(value) => value.as_signed().map(|value| value as f64), + _ => None, + }?; + Some((key, number)) + }) + .collect() + } else { + parse_playback_info(&String::from_utf8_lossy(bytes))? + }; + let rate = values.get("rate").copied().unwrap_or(0.0); + let duration = values.get("duration").copied(); + let position = values.get("position").copied(); + let state = if rate > 0.0 { + PlaybackState::Playing + } else if duration + .zip(position) + .is_some_and(|(duration, position)| duration > 0.0 && duration - position <= 1.0) + { + PlaybackState::Finished + } else if duration.is_some() { + PlaybackState::Paused + } else { + PlaybackState::Stopped + }; + Ok(PlaybackStatus { + state, + current_url: None, + }) +} + #[cfg(test)] mod tests { use super::*; @@ -317,6 +2255,7 @@ mod tests { location_url: format!("airplay://{address}"), model_name: "AppleTV".to_string(), protocol: RendererProtocol::Airplay, + pairing: PairingStatus::NotRequired, capabilities: RendererCapabilities { video: true, audio: false, @@ -336,10 +2275,82 @@ mod tests { fn feature_parser_requires_url_video_bit() { assert!(supports_url_video(Some("0x1,0x0"))); assert!(supports_url_video(Some("3"))); + // Bit 49 alone is the play-queue-only case; see + // `url_video_requires_the_airplay_video_v1_bit`. + assert!(!supports_url_video(Some("0x0,0x20000"))); + // Both receivers on the test network advertise transient pairing. + assert!(supports_transient_pairing( + parse_features(Some("0x7F8AD0,0x18BCF46")).unwrap() + )); + assert!(supports_transient_pairing( + parse_features(Some("0x4A7FCFD5,0x38174FDE")).unwrap() + )); + assert!(!supports_transient_pairing(AIRPLAY_VIDEO_V1)); assert!(!supports_url_video(Some("0x200"))); + assert!(!supports_url_video(Some("invalid"))); assert!(!supports_url_video(None)); } + #[test] + fn url_video_requires_the_airplay_video_v1_bit() { + assert!(airplay2_enabled()); + assert!(supports_url_video_bits(AIRPLAY_VIDEO_V1)); + assert!(supports_url_video_bits(AIRPLAY_VIDEO_V1 | AIRPLAY_VIDEO_V2)); + // A v2-only receiver has no /play endpoint; a Sony XR-75X90L reports + // 0x7F8AD0,0x18BCF46 and 404s every HTTP playback path. + assert!(!supports_url_video_bits(AIRPLAY_VIDEO_V2)); + assert!(!supports_url_video(Some("0x7F8AD0,0x18BCF46"))); + // A macOS receiver reports 0x4A7FCFD5,0x38174FDE, which does have bit 0. + assert!(supports_url_video(Some("0x4A7FCFD5,0x38174FDE"))); + assert!(!supports_url_video_bits(0)); + } + + #[test] + fn feature_parser_combines_both_airplay_words() { + let features = parse_features(Some("0x7F8AD0,0x18BCF46")).unwrap(); + assert_ne!(features & (1 << 38), 0); + assert_ne!(features & (1 << 46), 0); + assert_ne!(features & (1 << 48), 0); + assert_ne!(features & (1 << 49), 0); + } + + #[test] + fn play_parameters_send_the_media_url_untouched() { + let value = play_parameters( + "http://192.168.1.2:8080/media/15.mp4", + "36c0f1ba-3f8f-4a1e-8b0f-1b7a2a3d4e5f", + "AA:BB:CC:DD:EE:FF", + ); + let play = value.as_dictionary().unwrap(); + assert_eq!( + play.get("Content-Location") + .and_then(plist::Value::as_string), + Some("http://192.168.1.2:8080/media/15.mp4") + ); + assert_eq!( + play.get("mediaType").and_then(plist::Value::as_string), + Some("file") + ); + assert_eq!(play.get("rate").and_then(plist::Value::as_real), Some(1.0)); + } + + #[test] + fn playback_info_plist_error_is_detected() { + let mut error = plist::Dictionary::new(); + error.insert("code".into(), plist::Value::Integer((-6707).into())); + error.insert( + "domain".into(), + plist::Value::String("NSOSStatusErrorDomain".into()), + ); + let mut dictionary = plist::Dictionary::new(); + dictionary.insert("error".into(), plist::Value::Dictionary(error)); + let body = binary_plist(plist::Value::Dictionary(dictionary)).unwrap(); + let message = playback_info_error_message(&body).unwrap(); + assert!(message.contains("-6707")); + assert!(message.contains("NSOSStatusErrorDomain")); + assert!(playback_info_error_message(&[]).is_none()); + } + #[test] fn playback_info_extracts_timeline() { let values = parse_playback_info( @@ -351,6 +2362,52 @@ mod tests { assert_eq!(values.get("rate"), Some(&1.0)); } + #[test] + fn native_airplay_video_matrix_accepts_supported_containers() { + for (mime, filename) in [ + ("video/mp4", "movie.mp4"), + ("video/x-m4v", "movie.m4v"), + ("video/quicktime", "movie.mov"), + ("application/vnd.apple.mpegurl", "movie.m3u8"), + ("video/mp2t", "movie.ts"), + ] { + assert!(is_native_airplay_video(mime, filename), "{mime}"); + } + assert!(!is_native_airplay_video("video/x-matroska", "movie.mkv")); + assert!(!is_native_airplay_video("video/webm", "movie.webm")); + } + + #[test] + fn pairing_pin_validation_accepts_receiver_pins_only() { + assert!(valid_pin("1234")); + assert!(valid_pin("123-45-678")); + assert!(!valid_pin("123")); + assert!(!valid_pin("1234\r\nX-Test: injected")); + } + + /// A receiver that requires pairing must surface that, not a transient + /// failure, so the dashboard offers the PIN flow. + #[tokio::test] + async fn pairing_required_receivers_ask_for_a_pin() { + let provider = AirplayProvider::new(); + let mut renderer = renderer(); + renderer.pairing = PairingStatus::Required; + let item = PlaybackItem { + id: 1, + url: "http://192.168.1.2:8080/media/1.mp4".to_string(), + local_path: std::path::PathBuf::from("/tmp/test.mp4"), + title: "Test".to_string(), + filename: "test.mp4".to_string(), + mime_type: "video/mp4".to_string(), + }; + let error = provider + .play(&renderer, &item) + .await + .unwrap_err() + .to_string(); + assert!(error.contains("pairing is required"), "{error}"); + } + #[tokio::test] async fn session_ids_are_valid_and_stable_for_a_playlist() { let provider = AirplayProvider::new(); @@ -361,3 +2418,287 @@ mod tests { assert!(uuid::Uuid::parse_str(&first).is_ok()); } } + +#[cfg(test)] +mod session_tests { + use super::*; + use hap_transport::record_test_support::{decrypt_frame, encrypt_frame, NonceCounter}; + use std::sync::{Arc, Mutex as StdMutex}; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + use tokio::net::{TcpListener, TcpStream}; + + const SHARED_SECRET: [u8; 32] = [7u8; 32]; + + /// Request head plus body, exactly as the receiver saw it. + type CapturedRequests = Arc)>>>; + + /// A minimal AirPlay 2 receiver: HAP record framing plus just enough of the + /// RTSP/HTTP surface to complete one media session. It answers every request + /// with 200 so the test asserts on what VuIO *sent*, not on receiver quirks. + struct FakeReceiver { + stream: TcpStream, + read_key: [u8; 32], + write_key: [u8; 32], + read_counter: NonceCounter, + write_counter: NonceCounter, + wire: Vec, + plain: Vec, + } + + impl FakeReceiver { + fn new(stream: TcpStream) -> anyhow::Result { + Ok(Self { + stream, + // Mirror image of the sender: it writes with the "Write" key. + read_key: derive_key_from( + &SHARED_SECRET, + b"Control-Salt", + b"Control-Write-Encryption-Key", + )?, + write_key: derive_key_from( + &SHARED_SECRET, + b"Control-Salt", + b"Control-Read-Encryption-Key", + )?, + read_counter: NonceCounter::new(), + write_counter: NonceCounter::new(), + wire: Vec::new(), + plain: Vec::new(), + }) + } + + async fn fill(&mut self) -> anyhow::Result<()> { + loop { + if let Some(block) = + decrypt_frame(&self.read_key, &mut self.read_counter, &self.wire)? + { + self.wire.drain(..2 + block.len() + 16); + self.plain.extend_from_slice(&block); + return Ok(()); + } + let read = self.stream.read_buf(&mut self.wire).await?; + anyhow::ensure!(read != 0, "sender closed the connection"); + } + } + + async fn read_request(&mut self) -> anyhow::Result<(String, Vec)> { + loop { + if let Some(end) = self + .plain + .windows(4) + .position(|window| window == b"\r\n\r\n") + { + let head = String::from_utf8(self.plain[..end].to_vec())?; + let length = content_length(&head); + if self.plain.len() >= end + 4 + length { + let body = self.plain[end + 4..end + 4 + length].to_vec(); + self.plain.drain(..end + 4 + length); + return Ok((head, body)); + } + } + self.fill().await?; + } + } + + async fn respond(&mut self, protocol: &str, body: &[u8]) -> anyhow::Result<()> { + let mut message = format!( + "{protocol} 200 OK\r\nContent-Length: {}\r\n\r\n", + body.len() + ) + .into_bytes(); + message.extend_from_slice(body); + for block in message.chunks(1024) { + let frame = encrypt_frame(&self.write_key, &mut self.write_counter, block)?; + self.stream.write_all(&frame).await?; + } + self.stream.flush().await?; + Ok(()) + } + } + + fn content_length(head: &str) -> usize { + head.split("\r\n") + .skip(1) + .filter_map(|line| line.split_once(':')) + .find(|(name, _)| name.trim().eq_ignore_ascii_case("content-length")) + .and_then(|(_, value)| value.trim().parse().ok()) + .unwrap_or(0) + } + + fn request_line(head: &str) -> &str { + head.split("\r\n").next().unwrap_or_default() + } + + async fn secured_sender(address: SocketAddr) -> AirplayConnection { + let mut connection = AirplayConnection::connect(address).await.unwrap(); + connection.secure(SessionKeys { + read_key: derive_key_from( + &SHARED_SECRET, + b"Control-Salt", + b"Control-Read-Encryption-Key", + ) + .unwrap(), + write_key: derive_key_from( + &SHARED_SECRET, + b"Control-Salt", + b"Control-Write-Encryption-Key", + ) + .unwrap(), + }); + connection + } + + /// Drive a whole session against the fake receiver and return every request + /// VuIO sent, in order. + async fn run_session(media_url: &str) -> Vec<(String, Vec)> { + let control_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let control_address = control_listener.local_addr().unwrap(); + let event_listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let event_port = event_listener.local_addr().unwrap().port(); + + tokio::spawn(async move { + // Accept the reverse event channel and hold it open; the sender only + // reads from it in this scenario. + let _connection = event_listener.accept().await; + std::future::pending::<()>().await; + }); + + let requests: CapturedRequests = Arc::new(StdMutex::new(Vec::new())); + let recorded = requests.clone(); + tokio::spawn(async move { + let (stream, _) = control_listener.accept().await.unwrap(); + let mut receiver = FakeReceiver::new(stream).unwrap(); + while let Ok((head, body)) = receiver.read_request().await { + let line = request_line(&head).to_string(); + let protocol = line + .split_whitespace() + .nth(2) + .unwrap_or("RTSP/1.0") + .to_string(); + recorded.lock().unwrap().push((head, body)); + let reply = if line.starts_with("SETUP ") { + let mut setup = plist::Dictionary::new(); + setup.insert( + "eventPort".into(), + plist::Value::Integer(u64::from(event_port).into()), + ); + binary_plist(plist::Value::Dictionary(setup)).unwrap() + } else if line.starts_with("GET /playback-info ") { + let mut info = plist::Dictionary::new(); + info.insert("duration".into(), plist::Value::Real(120.0)); + info.insert("position".into(), plist::Value::Real(0.0)); + info.insert("rate".into(), plist::Value::Real(1.0)); + binary_plist(plist::Value::Dictionary(info)).unwrap() + } else { + Vec::new() + }; + if receiver.respond(&protocol, &reply).await.is_err() { + return; + } + } + }); + + let connection = secured_sender(control_address).await; + let provider = AirplayProvider::new(); + provider + .start_secure_session( + "airplay:test", + control_address, + connection, + SHARED_SECRET.to_vec(), + media_url, + ) + .await + .expect("the session should establish against a well-behaved receiver"); + + let captured = requests.lock().unwrap().clone(); + captured + } + + #[tokio::test] + async fn secure_session_follows_the_reference_play_sequence() { + let media_url = "http://192.168.1.2:8080/media/15.mp4"; + let requests = run_session(media_url).await; + let lines: Vec = requests + .iter() + .map(|(head, _)| request_line(head).to_string()) + .collect(); + + // The /info probe and SETUP both run before the event channel and the + // feedback loop exist, so their positions are deterministic. + assert_eq!( + lines.first().map(String::as_str), + Some("GET /info RTSP/1.0"), + "{lines:?}" + ); + let setup = lines.get(1).expect("no SETUP request was captured"); + assert!(setup.starts_with("SETUP rtsp://"), "{lines:?}"); + assert!(setup.ends_with(" RTSP/1.0"), "{lines:?}"); + + let position = |prefix: &str| { + lines + .iter() + .position(|line| line.starts_with(prefix)) + .unwrap_or_else(|| panic!("no request matched {prefix:?} in {lines:?}")) + }; + + // /play must go out as HTTP: receivers that dispatch on the protocol + // line reject "POST /play RTSP/1.0". + let play = position("POST /play "); + assert_eq!(lines[play], "POST /play HTTP/1.1", "{lines:?}"); + assert_eq!( + lines[position("GET /playback-info ")], + "GET /playback-info HTTP/1.1" + ); + + // RECORD precedes playback, and /rate lands before the end-time + // properties, matching pyatv's ordering. + assert!(position("RECORD ") < play, "{lines:?}"); + assert!(position("POST /rate?value=1.000000 ") > play, "{lines:?}"); + assert!( + position("POST /rate?value=1.000000 ") < position("PUT /setProperty?forwardEndTime "), + "{lines:?}" + ); + assert_eq!( + lines[position("POST /rate?value=1.000000 ")], + "POST /rate?value=1.000000 RTSP/1.0" + ); + } + + #[tokio::test] + async fn play_request_carries_the_media_url_and_apple_session_headers() { + let media_url = "http://192.168.1.2:8080/media/15.mp4"; + let requests = run_session(media_url).await; + + let (head, body) = requests + .iter() + .find(|(head, _)| request_line(head).starts_with("POST /play ")) + .expect("no /play request was sent"); + assert!( + head.contains("Content-Type: application/x-apple-binary-plist"), + "{head}" + ); + assert!(head.contains("X-Apple-ProtocolVersion: 1"), "{head}"); + assert!(head.contains("X-Apple-Session-ID: "), "{head}"); + assert!(head.contains("X-Apple-Stream-ID: 1"), "{head}"); + assert!(head.contains("User-Agent: AirPlay/550.10"), "{head}"); + + let play = plist::Value::from_reader(std::io::Cursor::new(body)).unwrap(); + let play = play.as_dictionary().unwrap(); + // The receiver fetches this URL itself; it must arrive unmodified. + assert_eq!( + play.get("Content-Location") + .and_then(plist::Value::as_string), + Some(media_url) + ); + + // RTSP requests carry the session identity triple pyatv sends. + let (setup_head, _) = requests + .iter() + .find(|(head, _)| request_line(head).starts_with("SETUP ")) + .expect("no SETUP request was sent"); + assert!(setup_head.contains("DACP-ID: "), "{setup_head}"); + assert!(setup_head.contains("Active-Remote: "), "{setup_head}"); + assert!(setup_head.contains("Client-Instance: "), "{setup_head}"); + } +} diff --git a/crates/vuio-core/src/casting/airplay/pair_verify.rs b/crates/vuio-core/src/casting/airplay/pair_verify.rs new file mode 100644 index 0000000..a598b57 --- /dev/null +++ b/crates/vuio-core/src/casting/airplay/pair_verify.rs @@ -0,0 +1,152 @@ +use anyhow::{Context, Result}; +use hap_crypto::{ + aead::{chacha20poly1305_open, chacha20poly1305_seal}, + verify_ed25519, AccessoryPairing, ControllerKeypair, EphemeralKeypair, SessionKeys, +}; +use hap_tlv8::{Tlv8Map, Tlv8Writer}; +use hkdf::Hkdf; +use sha2::Sha512; + +const IDENTIFIER: u8 = 0x01; +const PUBLIC_KEY: u8 = 0x03; +const ENCRYPTED_DATA: u8 = 0x05; +const STATE: u8 = 0x06; +const ERROR: u8 = 0x07; +const SIGNATURE: u8 = 0x0A; + +pub struct PairVerifier { + controller: ControllerKeypair, + accessory: AccessoryPairing, + ephemeral: EphemeralKeypair, + shared: Option<[u8; 32]>, +} + +pub struct VerifiedSession { + pub keys: SessionKeys, + pub shared_secret: [u8; 32], +} + +impl PairVerifier { + pub fn new(controller: ControllerKeypair, accessory: AccessoryPairing) -> Self { + Self { + controller, + accessory, + ephemeral: EphemeralKeypair::generate(), + shared: None, + } + } + + pub fn start(&self) -> Vec { + let mut body = Vec::new(); + let mut writer = Tlv8Writer::new(&mut body); + writer.push_u8(STATE, 1); + writer.push(PUBLIC_KEY, &self.ephemeral.public()); + body + } + + pub fn handle_m2(&mut self, response: &[u8]) -> Result> { + let map = checked_map(response, 2)?; + let accessory_public: [u8; 32] = map + .get(PUBLIC_KEY) + .context("AirPlay Pair Verify M2 omitted the public key")? + .try_into() + .map_err(|_| anyhow::anyhow!("AirPlay Pair Verify public key has invalid length"))?; + let encrypted = map + .get(ENCRYPTED_DATA) + .context("AirPlay Pair Verify M2 omitted encrypted data")?; + let controller_public = self.ephemeral.public(); + let shared = self.ephemeral.diffie_hellman(&accessory_public); + let verification_key = derive_key( + &shared, + b"Pair-Verify-Encrypt-Salt", + b"Pair-Verify-Encrypt-Info", + )?; + let plaintext = + chacha20poly1305_open(&verification_key, &hap_nonce(b"PV-Msg02"), b"", encrypted)?; + let inner = Tlv8Map::parse(&plaintext)?; + let identifier = inner + .get(IDENTIFIER) + .context("AirPlay Pair Verify M2 omitted the receiver identity")?; + anyhow::ensure!( + identifier == self.accessory.pairing_id.as_bytes(), + "AirPlay receiver identity does not match the saved pairing" + ); + let signature: [u8; 64] = inner + .get(SIGNATURE) + .context("AirPlay Pair Verify M2 omitted its signature")? + .try_into() + .map_err(|_| anyhow::anyhow!("AirPlay receiver signature has invalid length"))?; + let mut signed = Vec::with_capacity(64 + identifier.len()); + signed.extend_from_slice(&accessory_public); + signed.extend_from_slice(identifier); + signed.extend_from_slice(&controller_public); + verify_ed25519(&self.accessory.ltpk, &signed, &signature) + .context("AirPlay receiver signature verification failed")?; + + let controller_id = self.controller.id.as_bytes(); + let mut controller_signed = Vec::with_capacity(64 + controller_id.len()); + controller_signed.extend_from_slice(&controller_public); + controller_signed.extend_from_slice(controller_id); + controller_signed.extend_from_slice(&accessory_public); + let signature = self.controller.sign(&controller_signed); + let mut inner_body = Vec::new(); + let mut inner_writer = Tlv8Writer::new(&mut inner_body); + inner_writer.push(IDENTIFIER, controller_id); + inner_writer.push(SIGNATURE, &signature); + let encrypted = + chacha20poly1305_seal(&verification_key, &hap_nonce(b"PV-Msg03"), b"", &inner_body)?; + let mut body = Vec::new(); + let mut writer = Tlv8Writer::new(&mut body); + writer.push_u8(STATE, 3); + writer.push(ENCRYPTED_DATA, &encrypted); + self.shared = Some(shared); + Ok(body) + } + + pub fn finish(self, response: &[u8]) -> Result { + checked_map(response, 4)?; + let shared = self + .shared + .context("AirPlay Pair Verify finished before M2")?; + Ok(VerifiedSession { + keys: SessionKeys { + read_key: derive_key(&shared, b"Control-Salt", b"Control-Read-Encryption-Key")?, + write_key: derive_key(&shared, b"Control-Salt", b"Control-Write-Encryption-Key")?, + }, + shared_secret: shared, + }) + } +} + +pub fn derive_key(shared: &[u8; 32], salt: &[u8], info: &[u8]) -> Result<[u8; 32]> { + derive_key_from(shared, salt, info) +} + +/// HKDF over a shared secret of any length. Pair Verify produces a 32-byte +/// X25519 secret; transient pairing produces a 64-byte SRP session key. +pub fn derive_key_from(shared: &[u8], salt: &[u8], info: &[u8]) -> Result<[u8; 32]> { + let mut output = [0u8; 32]; + Hkdf::::new(Some(salt), shared) + .expand(info, &mut output) + .map_err(|_| anyhow::anyhow!("AirPlay key derivation failed"))?; + Ok(output) +} + +fn checked_map(response: &[u8], expected_state: u8) -> Result { + let map = Tlv8Map::parse(response)?; + anyhow::ensure!( + map.get(ERROR).is_none_or(|value| value.is_empty()), + "AirPlay receiver rejected Pair Verify" + ); + anyhow::ensure!( + map.get(STATE) == Some(&[expected_state][..]), + "AirPlay Pair Verify response had an unexpected state" + ); + Ok(map) +} + +fn hap_nonce(label: &[u8; 8]) -> [u8; 12] { + let mut nonce = [0u8; 12]; + nonce[4..].copy_from_slice(label); + nonce +} diff --git a/crates/vuio-core/src/casting/airplay/raop.rs b/crates/vuio-core/src/casting/airplay/raop.rs new file mode 100644 index 0000000..f5388e5 --- /dev/null +++ b/crates/vuio-core/src/casting/airplay/raop.rs @@ -0,0 +1,487 @@ +//! Push PCM audio to an AirPlay 2 receiver over RTP. +//! +//! Unlike video, which is a URL hand-off, audio is a push protocol: the sender +//! paces RTP packets in real time and keeps the receiver's clock aligned with +//! periodic sync packets. This is a port of pyatv's `raop::stream_client` for +//! the buffered-audio (`type 96`) stream, carrying `PCM/44100/16/2`. + +use super::audio::{PcmSource, BYTES_PER_FRAME, SAMPLE_RATE}; +use anyhow::{Context, Result}; +use hap_crypto::aead::chacha20poly1305_seal; +use std::net::SocketAddr; +use std::time::Duration; +use tokio::net::UdpSocket; + +/// Frames carried by one RTP packet, as announced in SETUP (`spf`). +pub const FRAMES_PER_PACKET: usize = 352; +/// pyatv's latency: one second of audio plus half a second of slack. +const LATENCY: u32 = 22050 + SAMPLE_RATE; +const SYNC_INTERVAL: Duration = Duration::from_secs(1); +/// How often the receiver's seek bar is refreshed. +const PROGRESS_INTERVAL: Duration = Duration::from_secs(2); + +/// Transport requests the receiver's own remote can make of the sender. +#[derive(Default)] +pub struct Transport { + /// Advance to the next queued track. + pub skip_next: std::sync::atomic::AtomicBool, + /// Restart the current track, which is what a "previous" press does first. + pub restart: std::sync::atomic::AtomicBool, + /// While set, silence is sent instead of audio. The stream has to keep + /// flowing to hold the receiver's clock, so a pause cannot simply stop. + pub paused: std::sync::atomic::AtomicBool, +} + +/// Seconds between the NTP epoch (1900) and the Unix epoch (1970). +const NTP_UNIX_OFFSET: u64 = 2_208_988_800; + +pub struct AudioSender { + data_socket: UdpSocket, + data_target: SocketAddr, + key: [u8; 32], + counter: u64, + sequence: u16, + head_ts: u32, + start_ts: u32, + ssrc: u32, + /// Published for the sync task, which must announce the *current* stream + /// position; a stale value leaves the receiver's clock parked and silent. + published_rtptime: std::sync::Arc, +} + +/// Pack interleaved 16-bit stereo frames into one uncompressed ALAC element. +/// +/// A realtime AirPlay 2 receiver hardcodes ALAC and ignores the `ct` and +/// `audioFormat` it was given, so raw PCM is fed to an ALAC decoder and comes +/// out as hiss. ALAC's escape mode carries the samples verbatim, so this needs +/// no encoder: a 23-bit header, the samples MSB-first, then a 3-bit END tag. +fn pcm_to_uncompressed_alac(frames: &[u8]) -> Vec { + let mut writer = BitWriter::default(); + writer.write(1, 3); // element type: stereo channel pair (CPE) + writer.write(0, 4); // element instance tag + writer.write(0, 12); // unused + writer.write(0, 1); // hasSize + writer.write(0, 2); // unused + writer.write(1, 1); // isNotCompressed + for frame in frames.chunks_exact(BYTES_PER_FRAME) { + let left = u16::from_le_bytes([frame[0], frame[1]]); + let right = u16::from_le_bytes([frame[2], frame[3]]); + writer.write(u32::from(left), 16); + writer.write(u32::from(right), 16); + } + writer.write(7, 3); // END element + writer.finish() +} + +/// Minimal MSB-first bit writer, which is the order ALAC elements use. +#[derive(Default)] +struct BitWriter { + bytes: Vec, + partial: u8, + used: u32, +} + +impl BitWriter { + fn write(&mut self, value: u32, bits: u32) { + for index in (0..bits).rev() { + let bit = ((value >> index) & 1) as u8; + self.partial = (self.partial << 1) | bit; + self.used += 1; + if self.used == 8 { + self.bytes.push(self.partial); + self.partial = 0; + self.used = 0; + } + } + } + + /// Flush the trailing partial byte, padding with zeros. + fn finish(mut self) -> Vec { + if self.used > 0 { + self.bytes.push(self.partial << (8 - self.used)); + } + self.bytes + } +} + +/// The current NTP timestamp as (seconds, fraction). +fn ntp_now() -> (u32, u32) { + let duration = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default(); + let seconds = duration.as_secs().saturating_add(NTP_UNIX_OFFSET) as u32; + let fraction = ((u64::from(duration.subsec_nanos()) << 32) / 1_000_000_000) as u32; + (seconds, fraction) +} + +/// The NTP instant a sync packet pairs with the stream position it announces. +/// +/// The stream is paced in real time from a start timestamp taken off the same +/// clock, so "now" is by construction the NTP time of the frames just queued. +fn sync_instant() -> (u32, u32) { + ntp_now() +} + +/// The RTP timestamp the stream starts from, derived from the wall clock. +fn ntp_to_ts() -> u32 { + let (seconds, fraction) = ntp_now(); + let total = (u64::from(seconds) << 32) | u64::from(fraction); + ((total as u128 * u128::from(SAMPLE_RATE)) >> 32) as u32 +} + +impl AudioSender { + pub async fn connect( + receiver: SocketAddr, + data_port: u16, + control_port: u16, + key: [u8; 32], + ssrc: u32, + published_rtptime: std::sync::Arc, + ) -> Result<(Self, UdpSocket, SocketAddr)> { + let bind = match receiver.ip() { + std::net::IpAddr::V4(_) => "0.0.0.0:0", + std::net::IpAddr::V6(_) => "[::]:0", + }; + let data_socket = UdpSocket::bind(bind).await?; + let control_socket = UdpSocket::bind(bind).await?; + let start_ts = ntp_to_ts(); + Ok(( + Self { + data_socket, + data_target: SocketAddr::new(receiver.ip(), data_port), + key, + counter: 0, + sequence: 0, + head_ts: start_ts, + start_ts, + ssrc, + published_rtptime, + }, + control_socket, + SocketAddr::new(receiver.ip(), control_port), + )) + } + + /// The receiver-side presentation time for what has been queued so far. + fn rtptime(&self) -> u32 { + self.head_ts + .wrapping_sub(self.start_ts.wrapping_sub(LATENCY)) + } + + /// The sequence number the stream starts from. + pub fn start_sequence(&self) -> u16 { + self.sequence + } + + /// The RTP timestamp the first packet will carry. + pub fn start_rtptime(&self) -> u32 { + self.rtptime() + } + + /// Send one packet of PCM, encrypted, and advance the stream clock. + /// + /// The payload is an uncompressed ALAC element, which is what a realtime + /// receiver decodes regardless of the format it was offered. + async fn send_packet(&mut self, frames: &[u8], first: bool) -> Result<()> { + let mut header = Vec::with_capacity(12); + header.push(0x80); + header.push(if first { 0xE0 } else { 0x60 }); + header.extend_from_slice(&self.sequence.to_be_bytes()); + header.extend_from_slice(&self.rtptime().to_be_bytes()); + header.extend_from_slice(&self.ssrc.to_be_bytes()); + + // The nonce is four zero bytes plus a little-endian counter; the low + // eight bytes travel at the end of the packet so the receiver can + // reconstruct it. Bytes 4..12 of the header are the AEAD's AAD. + let mut nonce = [0u8; 12]; + nonce[4..].copy_from_slice(&self.counter.to_le_bytes()); + + let payload = pcm_to_uncompressed_alac(frames); + let mut packet = header; + let sealed = chacha20poly1305_seal(&self.key, &nonce, &packet[4..12], &payload) + .map_err(|error| anyhow::anyhow!("encrypting AirPlay audio: {error}"))?; + packet.extend_from_slice(&sealed); + packet.extend_from_slice(&nonce[4..]); + self.data_socket.send_to(&packet, self.data_target).await?; + + self.counter = self.counter.wrapping_add(1); + self.sequence = self.sequence.wrapping_add(1); + self.head_ts = self + .head_ts + .wrapping_add((frames.len() / BYTES_PER_FRAME) as u32); + self.published_rtptime + .store(self.rtptime(), std::sync::atomic::Ordering::Relaxed); + Ok(()) + } + + /// Stream a decoded file and everything queued behind it, pacing packets + /// against the audio clock. + /// + /// Tracks continue on the same RTP timeline, so a folder plays gaplessly. + /// After the queue drains, silence is sent until a full latency window has + /// been queued, so the receiver plays the tail instead of cutting it. + pub async fn stream( + &mut self, + first: PcmSource, + queue: std::sync::Arc>>, + control: std::sync::Arc>, + rtsp_session: String, + receiver_session: Option, + transport: std::sync::Arc, + ) -> Result<()> { + let started = tokio::time::Instant::now(); + let mut total_frames: u64 = 0; + let mut padding_frames: u32 = 0; + let mut is_first_packet = true; + let silence = vec![0u8; FRAMES_PER_PACKET * BYTES_PER_FRAME]; + let mut announce: Option = Some(first.metadata().clone()); + let mut source = Some(first); + let mut last_progress = tokio::time::Instant::now(); + // Where the current track began, and where it ends. Both stay fixed for + // the track's lifetime -- the position is the only thing that moves. + let mut track_start = self.rtptime(); + let mut track_end = track_start; + // Kept so a "previous" press can reopen what is playing. + let mut current_path: Option = None; + + loop { + // Honour the receiver remote's transport buttons between packets. + if transport + .restart + .swap(false, std::sync::atomic::Ordering::Relaxed) + { + if let Some(path) = current_path.clone() { + if let Ok(reopened) = + tokio::task::spawn_blocking(move || PcmSource::open(&path)).await? + { + tracing::info!("AirPlay audio restarting the current track"); + announce = Some(reopened.metadata().clone()); + source = Some(reopened); + } + } + } + if transport + .skip_next + .swap(false, std::sync::atomic::Ordering::Relaxed) + { + tracing::info!("AirPlay audio skipping to the next track"); + source = None; + } + + // Announce a track once its first packet position is known, so the + // receiver's seek bar starts from the right place. + if let Some(metadata) = announce.take() { + track_start = self.rtptime(); + track_end = track_start.wrapping_add( + metadata + .duration_seconds + .and_then(|seconds| u32::try_from(seconds * u64::from(SAMPLE_RATE)).ok()) + .unwrap_or(0), + ); + super::announce_track( + &control, + &rtsp_session, + receiver_session.as_deref(), + self.sequence, + track_start, + track_end, + &metadata, + ) + .await; + last_progress = tokio::time::Instant::now(); + } + // A paused stream keeps its timeline alive with silence; stopping + // outright would make the receiver drop the session. + if transport.paused.load(std::sync::atomic::Ordering::Relaxed) { + self.send_packet(&silence, is_first_packet).await?; + is_first_packet = false; + total_frames += FRAMES_PER_PACKET as u64; + let stream_position = total_frames as f64 / f64::from(SAMPLE_RATE); + let elapsed = started.elapsed().as_secs_f64(); + if stream_position > elapsed { + tokio::time::sleep(Duration::from_secs_f64(stream_position - elapsed)).await; + } + continue; + } + + let frames = match source.as_mut().map(|s| s.read_frames(FRAMES_PER_PACKET)) { + Some(Ok(Some(frames))) => frames, + other => { + if let Some(Err(error)) = other { + tracing::warn!(%error, "AirPlay audio decode failed; skipping track"); + } + // Current track is done: pull the next one, if any. + let next = queue.lock().await.pop_front(); + match next { + Some(path) => { + current_path = Some(path.clone()); + match tokio::task::spawn_blocking(move || PcmSource::open(&path)) + .await + .context("joining the AirPlay audio decoder")? + { + Ok(opened) => { + tracing::info!("AirPlay audio advancing to the next track"); + announce = Some(opened.metadata().clone()); + source = Some(opened); + } + Err(error) => { + tracing::warn!(%error, "skipping an unplayable queued track"); + source = None; + } + } + continue; + } + None => { + source = None; + if padding_frames >= LATENCY { + break; + } + padding_frames += FRAMES_PER_PACKET as u32; + silence.clone() + } + } + } + }; + self.send_packet(&frames, is_first_packet).await?; + is_first_packet = false; + total_frames += (frames.len() / BYTES_PER_FRAME) as u64; + if total_frames % (u64::from(SAMPLE_RATE) * 5) < FRAMES_PER_PACKET as u64 { + tracing::debug!( + streamed_seconds = total_frames as f64 / f64::from(SAMPLE_RATE), + elapsed_seconds = started.elapsed().as_secs_f64(), + packets = self.sequence, + "AirPlay audio progress" + ); + } + + // Refresh the position. `start` and `end` are the track's fixed + // bounds; only the middle value advances, which is what moves the bar. + if last_progress.elapsed() >= PROGRESS_INTERVAL && track_end != track_start { + super::update_progress( + &control, + &rtsp_session, + track_start, + self.rtptime(), + track_end, + ) + .await; + last_progress = tokio::time::Instant::now(); + } + + // Sleep until this many frames should actually have been played. + let stream_position = total_frames as f64 / f64::from(SAMPLE_RATE); + let elapsed = started.elapsed().as_secs_f64(); + if stream_position > elapsed { + tokio::time::sleep(Duration::from_secs_f64(stream_position - elapsed)).await; + } + } + tracing::info!( + seconds = total_frames as f64 / f64::from(SAMPLE_RATE), + "AirPlay audio stream finished" + ); + Ok(()) + } +} + +/// Keep the receiver's clock aligned while audio is in flight. +pub fn spawn_sync_task( + socket: UdpSocket, + target: SocketAddr, + rtptime: std::sync::Arc, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let mut first = true; + let mut inbound = [0u8; 64]; + loop { + // A receiver that is actually decoding asks for lost packets here, + // so anything arriving is a sign the stream is being consumed. + while let Ok(Ok((length, from))) = + tokio::time::timeout(Duration::from_millis(1), socket.recv_from(&mut inbound)).await + { + tracing::debug!( + bytes = length, + %from, + kind = inbound.get(1).copied().unwrap_or(0), + "AirPlay audio control packet from receiver" + ); + } + let now = rtptime.load(std::sync::atomic::Ordering::Relaxed); + let (seconds, fraction) = sync_instant(); + let mut packet = Vec::with_capacity(20); + packet.push(if first { 0x90 } else { 0x80 }); + packet.push(0xD4); + packet.extend_from_slice(&7u16.to_be_bytes()); + packet.extend_from_slice(&now.wrapping_sub(LATENCY).to_be_bytes()); + packet.extend_from_slice(&seconds.to_be_bytes()); + packet.extend_from_slice(&fraction.to_be_bytes()); + packet.extend_from_slice(&now.to_be_bytes()); + if socket.send_to(&packet, target).await.is_err() { + return; + } + first = false; + tokio::time::sleep(SYNC_INTERVAL).await; + } + }) +} + +/// The `shk` value and packet key for the realtime audio stream. +/// +/// This is the first 32 bytes of the pairing shared secret **verbatim** -- no +/// HKDF. The same bytes are sent in the stream SETUP plist and used as the +/// ChaCha20-Poly1305 key for every audio packet. +pub fn stream_key(shared_secret: &[u8]) -> Result<[u8; 32]> { + let key: [u8; 32] = shared_secret + .get(..32) + .context("AirPlay pairing secret is too short for an audio key")? + .try_into() + .expect("a 32-byte slice converts to [u8; 32]"); + Ok(key) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rtp_timestamps_start_at_the_latency_offset() { + let start = 1_000_000u32; + let sender_rtptime = |head: u32| head.wrapping_sub(start.wrapping_sub(LATENCY)); + assert_eq!(sender_rtptime(start), LATENCY); + assert_eq!(sender_rtptime(start + 352), LATENCY + 352); + } + + #[test] + fn uncompressed_alac_carries_the_samples_verbatim() { + // One frame, L = 0x1234, R = 0x5678 (little-endian on the way in). + let element = pcm_to_uncompressed_alac(&[0x34, 0x12, 0x78, 0x56]); + // 23-bit header: 001 0000 000000000000 0 00 1 -> 0x20 0x00 0x01... + let mut bits = String::new(); + for byte in &element { + bits.push_str(&format!("{byte:08b}")); + } + assert!(bits.starts_with("001"), "element type must be CPE: {bits}"); + assert_eq!(&bits[3..7], "0000"); + assert_eq!(&bits[7..19], "000000000000"); + assert_eq!(&bits[19..20], "0", "hasSize"); + assert_eq!(&bits[20..22], "00"); + assert_eq!(&bits[22..23], "1", "isNotCompressed"); + // Samples follow MSB-first, left then right. + assert_eq!(&bits[23..39], "0001001000110100", "left = 0x1234"); + assert_eq!(&bits[39..55], "0101011001111000", "right = 0x5678"); + assert_eq!(&bits[55..58], "111", "END element"); + + // A full packet: 23 + 352*32 + 3 bits, rounded up to whole bytes. + let packet = vec![0u8; FRAMES_PER_PACKET * BYTES_PER_FRAME]; + let expected = (23 + FRAMES_PER_PACKET * 32 + 3).div_ceil(8); + assert_eq!(pcm_to_uncompressed_alac(&packet).len(), expected); + } + + #[test] + fn stream_key_is_derived_from_the_event_salt() { + let first = stream_key(&[7u8; 32]).unwrap(); + let second = stream_key(&[7u8; 32]).unwrap(); + let other = stream_key(&[9u8; 32]).unwrap(); + assert_eq!(first, second); + assert_ne!(first, other); + } +} diff --git a/crates/vuio-core/src/casting/airplay/transient.rs b/crates/vuio-core/src/casting/airplay/transient.rs new file mode 100644 index 0000000..5dc5586 --- /dev/null +++ b/crates/vuio-core/src/casting/airplay/transient.rs @@ -0,0 +1,320 @@ +//! Transient Pair Setup (HAP M1-M4 with the fixed 3939 code). +//! +//! Receivers that advertise `SupportsSystemPairing` (bit 43) or +//! `SupportsCoreUtilsPairingAndEncryption` (bit 48) -- which includes every +//! third-party AirPlay 2 TV tested so far -- accept a session that is +//! established without any stored pairing at all: SRP runs only through M1-M4 +//! against the well-known code 3939, and the resulting SRP session key `K` is +//! the shared secret every channel key is derived from. +//! +//! `hap-crypto` keeps its SRP client crate-private and its `PairSetupClient` +//! always runs the full M1-M6 exchange, so the SRP-6a client is implemented +//! here against the same HAP conventions: RFC 5054 Appendix A 3072-bit group, +//! `g = 5`, SHA-512, username `Pair-Setup`. + +use anyhow::{Context, Result}; +use hap_tlv8::{Tlv8Map, Tlv8Writer}; +use num_bigint::BigUint; +use sha2::{Digest, Sha512}; + +const METHOD: u8 = 0x00; +const PUBLIC_KEY: u8 = 0x03; +const PROOF: u8 = 0x04; +const STATE: u8 = 0x06; +const ERROR: u8 = 0x07; +const SALT: u8 = 0x02; +const FLAGS: u8 = 0x13; + +const TRANSIENT_PAIRING: u8 = 0x10; +/// The fixed setup code a transient exchange authenticates against. +const TRANSIENT_SETUP_CODE: &[u8] = b"3939"; +const USERNAME: &[u8] = b"Pair-Setup"; + +/// RFC 5054 Appendix A 3072-bit group modulus, as used by HAP. +const N_3072: [u8; 384] = [ + 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xC9, 0x0F, 0xDA, 0xA2, 0x21, 0x68, 0xC2, 0x34, + 0xC4, 0xC6, 0x62, 0x8B, 0x80, 0xDC, 0x1C, 0xD1, 0x29, 0x02, 0x4E, 0x08, 0x8A, 0x67, 0xCC, 0x74, + 0x02, 0x0B, 0xBE, 0xA6, 0x3B, 0x13, 0x9B, 0x22, 0x51, 0x4A, 0x08, 0x79, 0x8E, 0x34, 0x04, 0xDD, + 0xEF, 0x95, 0x19, 0xB3, 0xCD, 0x3A, 0x43, 0x1B, 0x30, 0x2B, 0x0A, 0x6D, 0xF2, 0x5F, 0x14, 0x37, + 0x4F, 0xE1, 0x35, 0x6D, 0x6D, 0x51, 0xC2, 0x45, 0xE4, 0x85, 0xB5, 0x76, 0x62, 0x5E, 0x7E, 0xC6, + 0xF4, 0x4C, 0x42, 0xE9, 0xA6, 0x37, 0xED, 0x6B, 0x0B, 0xFF, 0x5C, 0xB6, 0xF4, 0x06, 0xB7, 0xED, + 0xEE, 0x38, 0x6B, 0xFB, 0x5A, 0x89, 0x9F, 0xA5, 0xAE, 0x9F, 0x24, 0x11, 0x7C, 0x4B, 0x1F, 0xE6, + 0x49, 0x28, 0x66, 0x51, 0xEC, 0xE4, 0x5B, 0x3D, 0xC2, 0x00, 0x7C, 0xB8, 0xA1, 0x63, 0xBF, 0x05, + 0x98, 0xDA, 0x48, 0x36, 0x1C, 0x55, 0xD3, 0x9A, 0x69, 0x16, 0x3F, 0xA8, 0xFD, 0x24, 0xCF, 0x5F, + 0x83, 0x65, 0x5D, 0x23, 0xDC, 0xA3, 0xAD, 0x96, 0x1C, 0x62, 0xF3, 0x56, 0x20, 0x85, 0x52, 0xBB, + 0x9E, 0xD5, 0x29, 0x07, 0x70, 0x96, 0x96, 0x6D, 0x67, 0x0C, 0x35, 0x4E, 0x4A, 0xBC, 0x98, 0x04, + 0xF1, 0x74, 0x6C, 0x08, 0xCA, 0x18, 0x21, 0x7C, 0x32, 0x90, 0x5E, 0x46, 0x2E, 0x36, 0xCE, 0x3B, + 0xE3, 0x9E, 0x77, 0x2C, 0x18, 0x0E, 0x86, 0x03, 0x9B, 0x27, 0x83, 0xA2, 0xEC, 0x07, 0xA2, 0x8F, + 0xB5, 0xC5, 0x5D, 0xF0, 0x6F, 0x4C, 0x52, 0xC9, 0xDE, 0x2B, 0xCB, 0xF6, 0x95, 0x58, 0x17, 0x18, + 0x39, 0x95, 0x49, 0x7C, 0xEA, 0x95, 0x6A, 0xE5, 0x15, 0xD2, 0x26, 0x18, 0x98, 0xFA, 0x05, 0x10, + 0x15, 0x72, 0x8E, 0x5A, 0x8A, 0xAA, 0xC4, 0x2D, 0xAD, 0x33, 0x17, 0x0D, 0x04, 0x50, 0x7A, 0x33, + 0xA8, 0x55, 0x21, 0xAB, 0xDF, 0x1C, 0xBA, 0x64, 0xEC, 0xFB, 0x85, 0x04, 0x58, 0xDB, 0xEF, 0x0A, + 0x8A, 0xEA, 0x71, 0x57, 0x5D, 0x06, 0x0C, 0x7D, 0xB3, 0x97, 0x0F, 0x85, 0xA6, 0xE1, 0xE4, 0xC7, + 0xAB, 0xF5, 0xAE, 0x8C, 0xDB, 0x09, 0x33, 0xD7, 0x1E, 0x8C, 0x94, 0xE0, 0x4A, 0x25, 0x61, 0x9D, + 0xCE, 0xE3, 0xD2, 0x26, 0x1A, 0xD2, 0xEE, 0x6B, 0xF1, 0x2F, 0xFA, 0x06, 0xD9, 0x8A, 0x08, 0x64, + 0xD8, 0x76, 0x02, 0x73, 0x3E, 0xC8, 0x6A, 0x64, 0x52, 0x1F, 0x2B, 0x18, 0x17, 0x7B, 0x20, 0x0C, + 0xBB, 0xE1, 0x17, 0x57, 0x7A, 0x61, 0x5D, 0x6C, 0x77, 0x09, 0x88, 0xC0, 0xBA, 0xD9, 0x46, 0xE2, + 0x08, 0xE2, 0x4F, 0xA0, 0x74, 0xE5, 0xAB, 0x31, 0x43, 0xDB, 0x5B, 0xFC, 0xE0, 0xFD, 0x10, 0x8E, + 0x4B, 0x82, 0xD1, 0x20, 0xA9, 0x3A, 0xD2, 0xCA, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, +]; + +/// The generator for the HAP group. +const GENERATOR: u8 = 5; + +pub struct TransientPairing { + a_private: BigUint, + a_public: BigUint, + state: Option, +} + +struct Pending { + session_key: Vec, + proof: Vec, +} + +impl TransientPairing { + pub fn new() -> Result { + let mut seed = [0u8; 32]; + getrandom::fill(&mut seed).map_err(|error| anyhow::anyhow!("{error}"))?; + let a_private = BigUint::from_bytes_be(&seed); + let a_public = generator().modpow(&a_private, &modulus()); + anyhow::ensure!( + !(&a_public % modulus()).eq(&BigUint::ZERO), + "SRP public ephemeral A is zero mod N" + ); + Ok(Self { + a_private, + a_public, + state: None, + }) + } + + /// M1: start a transient exchange. The `Flags` TLV is what distinguishes it + /// from a regular PIN pairing. + pub fn start(&self) -> Vec { + let mut body = Vec::new(); + let mut writer = Tlv8Writer::new(&mut body); + writer.push_u8(STATE, 1); + writer.push_u8(METHOD, 0); + writer.push_u8(FLAGS, TRANSIENT_PAIRING); + body + } + + /// M2 -> M3: consume the receiver's salt and `B`, return `A` plus our proof. + pub fn handle_m2(&mut self, response: &[u8]) -> Result> { + let map = checked_map(response, 2)?; + let salt = map + .get(SALT) + .context("AirPlay transient pairing M2 omitted the salt")? + .to_vec(); + let b_public = BigUint::from_bytes_be( + map.get(PUBLIC_KEY) + .context("AirPlay transient pairing M2 omitted the public key")?, + ); + let n = modulus(); + anyhow::ensure!( + !(&b_public % &n).eq(&BigUint::ZERO), + "AirPlay receiver sent a zero SRP public key" + ); + + let k = compute_k(); + let x = compute_x(&salt, TRANSIENT_SETUP_CODE); + let u = compute_u(&self.a_public, &b_public); + anyhow::ensure!(u != BigUint::ZERO, "SRP scrambling parameter is zero"); + + // base = (B - k * g^x) mod N, kept non-negative by adding N first. + let gx = generator().modpow(&x, &n); + let kgx = (&k * &gx) % &n; + let base = (&b_public + &n - kgx) % &n; + let premaster = base.modpow(&(&self.a_private + (&u * &x)), &n); + let session_key = sha512(&[&pad(&premaster)]); + let proof = proof_m1(&salt, &self.a_public, &b_public, &session_key); + + let mut body = Vec::new(); + let mut writer = Tlv8Writer::new(&mut body); + writer.push_u8(STATE, 3); + writer.push(PUBLIC_KEY, &pad(&self.a_public)); + writer.push(PROOF, &proof); + self.state = Some(Pending { session_key, proof }); + Ok(body) + } + + /// M4: verify the receiver's proof and yield the SRP session key. + pub fn finish(self, response: &[u8]) -> Result> { + let map = checked_map(response, 4)?; + let pending = self + .state + .context("AirPlay transient pairing finished before M2")?; + let received = map + .get(PROOF) + .context("AirPlay transient pairing M4 omitted the receiver proof")?; + let expected = proof_m2(&self.a_public, &pending.proof, &pending.session_key); + anyhow::ensure!( + constant_time_eq(&expected, received), + "AirPlay receiver proof did not verify" + ); + Ok(pending.session_key) + } +} + +fn modulus() -> BigUint { + BigUint::from_bytes_be(&N_3072) +} + +fn generator() -> BigUint { + BigUint::from(GENERATOR) +} + +/// Left-pad to the width of N, which is what every HAP `PAD()` targets. +fn pad(value: &BigUint) -> Vec { + let raw = value.to_bytes_be(); + if raw.len() >= N_3072.len() { + return raw; + } + let mut padded = vec![0u8; N_3072.len() - raw.len()]; + padded.extend_from_slice(&raw); + padded +} + +fn sha512(parts: &[&[u8]]) -> Vec { + let mut hasher = Sha512::new(); + for part in parts { + hasher.update(part); + } + hasher.finalize().to_vec() +} + +/// `k = H(N | PAD(g))` +fn compute_k() -> BigUint { + BigUint::from_bytes_be(&sha512(&[&N_3072, &pad(&generator())])) +} + +/// `x = H(s | H(I | ":" | P))` +fn compute_x(salt: &[u8], password: &[u8]) -> BigUint { + let inner = sha512(&[USERNAME, b":", password]); + BigUint::from_bytes_be(&sha512(&[salt, &inner])) +} + +/// `u = H(PAD(A) | PAD(B))` +fn compute_u(a_public: &BigUint, b_public: &BigUint) -> BigUint { + BigUint::from_bytes_be(&sha512(&[&pad(a_public), &pad(b_public)])) +} + +/// `M1 = H( H(N) XOR H(g) | H(I) | s | PAD(A) | PAD(B) | K )` +fn proof_m1(salt: &[u8], a_public: &BigUint, b_public: &BigUint, session_key: &[u8]) -> Vec { + let hash_n = sha512(&[&N_3072]); + let hash_g = sha512(&[&generator().to_bytes_be()]); + let xored: Vec = hash_n + .iter() + .zip(hash_g.iter()) + .map(|(left, right)| left ^ right) + .collect(); + let hash_username = sha512(&[USERNAME]); + sha512(&[ + &xored, + &hash_username, + salt, + &pad(a_public), + &pad(b_public), + session_key, + ]) +} + +/// `M2 = H( PAD(A) | M1 | K )` +fn proof_m2(a_public: &BigUint, proof: &[u8], session_key: &[u8]) -> Vec { + sha512(&[&pad(a_public), proof, session_key]) +} + +fn constant_time_eq(left: &[u8], right: &[u8]) -> bool { + if left.len() != right.len() { + return false; + } + left.iter() + .zip(right.iter()) + .fold(0u8, |accumulator, (a, b)| accumulator | (a ^ b)) + == 0 +} + +fn checked_map(response: &[u8], expected_state: u8) -> Result { + let map = Tlv8Map::parse(response)?; + if let Some(error) = map.get(ERROR).and_then(|value| value.first()) { + anyhow::bail!("AirPlay receiver rejected transient pairing with error {error}"); + } + anyhow::ensure!( + map.get(STATE) == Some(&[expected_state][..]), + "AirPlay transient pairing response had an unexpected state" + ); + Ok(map) +} + +#[cfg(test)] +mod tests { + use super::*; + use hap_crypto::HapPairSetupSrpServer; + + fn tlv(entries: &[(u8, Vec)]) -> Vec { + let mut body = Vec::new(); + let mut writer = Tlv8Writer::new(&mut body); + for (kind, value) in entries { + writer.push(*kind, value); + } + body + } + + /// Drive the client against `hap-crypto`'s own SRP server. If the hand-rolled + /// SRP-6a here diverges from the HAP conventions in any detail -- the group, + /// the hash, `PAD()`, the proof layout -- the proofs stop matching. + #[test] + fn srp_client_interoperates_with_a_hap_server() { + let (server, salt) = HapPairSetupSrpServer::new("3939").unwrap(); + let mut client = TransientPairing::new().unwrap(); + + let start = Tlv8Map::parse(&client.start()).unwrap(); + assert_eq!(start.get(STATE), Some(&[1u8][..])); + assert_eq!(start.get(FLAGS), Some(&[TRANSIENT_PAIRING][..])); + + let m2 = tlv(&[ + (STATE, vec![2]), + (SALT, salt.clone()), + (PUBLIC_KEY, server.b_pub_bytes()), + ]); + let m3 = Tlv8Map::parse(&client.handle_m2(&m2).unwrap()).unwrap(); + assert_eq!(m3.get(STATE), Some(&[3u8][..])); + let a_public = m3.get(PUBLIC_KEY).unwrap().to_vec(); + let proof = m3.get(PROOF).unwrap().to_vec(); + + // The server only produces M2 if our M1 proof verifies. + let server_proof = server.verify_m1_prove_m2(&a_public, &proof).unwrap(); + let m4 = tlv(&[(STATE, vec![4]), (PROOF, server_proof)]); + let session_key = client.finish(&m4).unwrap(); + + assert_eq!(session_key, server.session_key(&a_public).unwrap()); + assert_eq!(session_key.len(), 64); + } + + #[test] + fn wrong_setup_code_is_rejected() { + let (server, salt) = HapPairSetupSrpServer::new("1234").unwrap(); + let mut client = TransientPairing::new().unwrap(); + let m2 = tlv(&[ + (STATE, vec![2]), + (SALT, salt), + (PUBLIC_KEY, server.b_pub_bytes()), + ]); + let m3 = Tlv8Map::parse(&client.handle_m2(&m2).unwrap()).unwrap(); + let a_public = m3.get(PUBLIC_KEY).unwrap().to_vec(); + let proof = m3.get(PROOF).unwrap().to_vec(); + assert!(server.verify_m1_prove_m2(&a_public, &proof).is_err()); + } + + #[test] + fn receiver_errors_and_bad_states_are_surfaced() { + let mut client = TransientPairing::new().unwrap(); + let error = tlv(&[(STATE, vec![2]), (ERROR, vec![0x02])]); + assert!(client.handle_m2(&error).is_err()); + + let mut client = TransientPairing::new().unwrap(); + let wrong_state = tlv(&[(STATE, vec![4]), (SALT, vec![0; 16])]); + assert!(client.handle_m2(&wrong_state).is_err()); + } +} diff --git a/crates/vuio-core/src/casting/airplay/transport.rs b/crates/vuio-core/src/casting/airplay/transport.rs new file mode 100644 index 0000000..4c9e3b7 --- /dev/null +++ b/crates/vuio-core/src/casting/airplay/transport.rs @@ -0,0 +1,559 @@ +use anyhow::{Context, Result}; +use hap_crypto::SessionKeys; +use hap_transport::record_test_support::{decrypt_frame, encrypt_frame, NonceCounter}; +use std::{collections::HashMap, net::SocketAddr, time::Duration}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +const MAX_MESSAGE_BYTES: usize = 1024 * 1024; +const DATA_STREAM_HEADER_BYTES: usize = 32; + +pub struct AirplayConnection { + stream: tokio::net::TcpStream, + secure: Option, + wire_buffer: Vec, + plain_buffer: Vec, + /// Where receiver-pushed media commands are delivered, when serving events. + commands: Option>, +} + +struct SecureState { + keys: SessionKeys, + read_counter: NonceCounter, + write_counter: NonceCounter, +} + +pub struct Response { + pub status: u16, + pub headers: HashMap, + pub body: Vec, +} + +impl AirplayConnection { + pub async fn connect(address: SocketAddr) -> Result { + let stream = tokio::time::timeout( + Duration::from_secs(5), + tokio::net::TcpStream::connect(address), + ) + .await + .context("AirPlay connection timed out")??; + stream.set_nodelay(true)?; + Ok(Self { + stream, + secure: None, + wire_buffer: Vec::new(), + plain_buffer: Vec::new(), + commands: None, + }) + } + + pub fn secure(&mut self, keys: SessionKeys) { + self.secure = Some(SecureState { + keys, + read_counter: NonceCounter::new(), + write_counter: NonceCounter::new(), + }); + } + + pub fn local_addr(&self) -> Result { + self.stream + .local_addr() + .context("reading the local AirPlay socket address") + } + + pub async fn request( + &mut self, + method: &str, + path: &str, + protocol: &str, + headers: &[(&str, String)], + body: &[u8], + ) -> Result { + self.write_request(method, path, protocol, headers, body) + .await?; + self.read_response().await + } + + pub async fn request_while_serving_events( + &mut self, + method: &str, + path: &str, + protocol: &str, + headers: &[(&str, String)], + body: &[u8], + ) -> Result { + self.write_request(method, path, protocol, headers, body) + .await?; + loop { + // A buffered message is only ours when it opens with a status line. + // Anything else the receiver pushes here is an event request that has + // to be answered before our response can arrive. + if self.plain_buffer.starts_with(b"HTTP/") || self.plain_buffer.starts_with(b"RTSP/") { + if let Some(response) = take_response(&mut self.plain_buffer)? { + return Ok(response); + } + } else if complete_message(&self.plain_buffer)? { + self.handle_http_event_request().await?; + continue; + } + anyhow::ensure!( + self.plain_buffer.len() <= MAX_MESSAGE_BYTES, + "AirPlay response exceeded {MAX_MESSAGE_BYTES} bytes" + ); + self.read_plain_chunk().await?; + } + } + + async fn write_request( + &mut self, + method: &str, + path: &str, + protocol: &str, + headers: &[(&str, String)], + body: &[u8], + ) -> Result<()> { + let mut message = format!("{method} {path} {protocol}\r\n").into_bytes(); + let mut has_length = false; + for (name, value) in headers { + anyhow::ensure!( + !name.contains(['\r', '\n']) && !value.contains(['\r', '\n']), + "invalid AirPlay header" + ); + has_length |= name.eq_ignore_ascii_case("content-length"); + message.extend_from_slice(name.as_bytes()); + message.extend_from_slice(b": "); + message.extend_from_slice(value.as_bytes()); + message.extend_from_slice(b"\r\n"); + } + if !has_length { + message.extend_from_slice(format!("Content-Length: {}\r\n", body.len()).as_bytes()); + } + message.extend_from_slice(b"\r\n"); + message.extend_from_slice(body); + self.write_plain(&message).await + } + + pub async fn serve_events( + self, + replies: tokio::sync::mpsc::UnboundedSender<(u64, Vec)>, + ) -> Result<()> { + self.serve_events_with_commands(replies, None).await + } + + /// Serve the event channel, forwarding any `sendMediaRemoteCommand` the + /// receiver pushes (its remote's transport buttons) to `commands`. + pub async fn serve_events_with_commands( + mut self, + replies: tokio::sync::mpsc::UnboundedSender<(u64, Vec)>, + commands: Option>, + ) -> Result<()> { + self.commands = commands; + loop { + while self.plain_buffer.len() < 4 { + self.read_plain_chunk().await?; + } + if starts_data_stream_message(&self.plain_buffer) { + let message = loop { + if let Some(message) = take_data_stream_message(&mut self.plain_buffer)? { + break message; + } + self.read_plain_chunk().await?; + }; + if message.message_type.starts_with(b"sync") { + let reply = data_stream_frame(b"rply", b"\0\0\0\0", message.sequence, &[])?; + self.write_plain(&reply).await?; + } else if message.message_type.starts_with(b"rply") { + let _ = replies.send((message.sequence, message.body)); + } else { + tracing::debug!( + sequence = message.sequence, + message_type = ?String::from_utf8_lossy(&message.message_type), + "ignored unrelated AirPlay event data-stream message" + ); + } + continue; + } + self.handle_http_event_request().await?; + } + } + + async fn handle_http_event_request(&mut self) -> Result<()> { + while !complete_message(&self.plain_buffer)? { + anyhow::ensure!( + self.plain_buffer.len() <= MAX_MESSAGE_BYTES, + "AirPlay event exceeded {MAX_MESSAGE_BYTES} bytes" + ); + self.read_plain_chunk().await?; + } + let header_end = self + .plain_buffer + .windows(4) + .position(|window| window == b"\r\n\r\n") + .context("AirPlay event omitted its headers")?; + let header = std::str::from_utf8(&self.plain_buffer[..header_end])?; + let length = content_length(header)?; + let request_line = header + .lines() + .next() + .unwrap_or("unknown AirPlay event") + .to_string(); + let protocol = request_line + .split_whitespace() + .nth(2) + .unwrap_or("HTTP/1.1") + .to_string(); + let cseq = header_value(header, "cseq").map(str::to_string); + let stream_id = header_value(header, "x-apple-stream-id").map(str::to_string); + let content_type = header_value(header, "content-type").map(str::to_string); + let body = &self.plain_buffer[header_end + 4..header_end + 4 + length]; + if let Ok(value) = plist::Value::from_reader(std::io::Cursor::new(body)) { + let dictionary = value.as_dictionary(); + let keys = dictionary + .map(|dictionary| dictionary.keys().cloned().collect::>()) + .unwrap_or_default(); + let nested = dictionary + .and_then(|dictionary| dictionary.get("params")) + .and_then(plist::Value::as_dictionary) + .and_then(|parameters| parameters.get("data")) + .and_then(plist::Value::as_data) + .and_then(|data| plist::Value::from_reader(std::io::Cursor::new(data)).ok()); + let nested_dictionary = nested.as_ref().and_then(plist::Value::as_dictionary); + let nested_keys = nested_dictionary + .map(|dictionary| dictionary.keys().cloned().collect::>()) + .unwrap_or_default(); + let nested_type = nested_dictionary + .and_then(|dictionary| dictionary.get("type")) + .and_then(plist::Value::as_string); + let nested_error = nested_dictionary + .and_then(|dictionary| dictionary.get("errorCode")) + .and_then(plist::Value::as_signed_integer); + let nested_name = nested_dictionary + .and_then(|dictionary| dictionary.get("name")) + .and_then(plist::Value::as_string); + // The receiver's own remote arrives here as DACP command codes. + if let Some(sender) = &self.commands { + let is_command = dictionary + .and_then(|dictionary| dictionary.get("type")) + .and_then(plist::Value::as_string) + == Some("sendMediaRemoteCommand"); + if is_command { + if let Some(command) = dictionary + .and_then(|dictionary| dictionary.get("value")) + .and_then(plist::Value::as_string) + { + let _ = sender.send(command.to_string()); + } + } + } + let nested_reason = nested_dictionary.and_then(|dictionary| dictionary.get("reason")); + let nested_item_uuid = nested_dictionary + .and_then(|dictionary| dictionary.get("itemCurrent")) + .and_then(plist::Value::as_dictionary) + .and_then(|item| item.get("uuid")) + .and_then(plist::Value::as_string); + tracing::debug!( + request_line, + cseq, + stream_id, + content_type, + payload = ?value, + ?keys, + ?nested_keys, + nested_type, + nested_error, + nested_name, + ?nested_reason, + nested_item_uuid, + "received AirPlay event" + ); + } else { + tracing::debug!(request_line, body_length = length, "received AirPlay event"); + } + self.plain_buffer.drain(..header_end + 4 + length); + // The reply must carry nothing beyond Server and CSeq. Adding + // `Audio-Latency` or `Content-Length` corrupts the receiver's realtime + // timeline: the session stays connected and renders silence. + let mut response = format!("{protocol} 200 OK\r\nServer: AirPlay/550.10\r\n"); + if let Some(cseq) = cseq { + response.push_str(&format!("CSeq: {cseq}\r\n")); + } + response.push_str("\r\n"); + self.write_plain(response.as_bytes()).await + } + + async fn write_plain(&mut self, bytes: &[u8]) -> Result<()> { + if let Some(secure) = &mut self.secure { + for block in bytes.chunks(1024) { + let frame = + encrypt_frame(&secure.keys.write_key, &mut secure.write_counter, block)?; + self.stream.write_all(&frame).await?; + } + } else { + self.stream.write_all(bytes).await?; + } + self.stream.flush().await?; + Ok(()) + } + + async fn read_response(&mut self) -> Result { + loop { + if let Some(response) = take_response(&mut self.plain_buffer)? { + return Ok(response); + } + anyhow::ensure!( + self.plain_buffer.len() <= MAX_MESSAGE_BYTES, + "AirPlay response exceeded {MAX_MESSAGE_BYTES} bytes" + ); + self.read_plain_chunk().await?; + } + } + + async fn read_plain_chunk(&mut self) -> Result<()> { + if let Some(secure) = &mut self.secure { + loop { + if let Some(block) = decrypt_frame( + &secure.keys.read_key, + &mut secure.read_counter, + &self.wire_buffer, + )? { + let frame_len = 2 + block.len() + 16; + self.wire_buffer.drain(..frame_len); + self.plain_buffer.extend_from_slice(&block); + return Ok(()); + } + let read = self.stream.read_buf(&mut self.wire_buffer).await?; + anyhow::ensure!(read != 0, "AirPlay receiver closed the secure connection"); + } + } + let read = self.stream.read_buf(&mut self.plain_buffer).await?; + anyhow::ensure!(read != 0, "AirPlay receiver closed the connection"); + Ok(()) + } +} + +struct DataStreamMessage { + message_type: [u8; 12], + sequence: u64, + body: Vec, +} + +fn data_stream_frame( + message_type: &[u8; 4], + command: &[u8; 4], + sequence: u64, + body: &[u8], +) -> Result> { + let size = DATA_STREAM_HEADER_BYTES + .checked_add(body.len()) + .context("AirPlay data-stream message is too large")?; + anyhow::ensure!( + size <= MAX_MESSAGE_BYTES, + "AirPlay data-stream message is too large" + ); + let size = u32::try_from(size).context("AirPlay data-stream message is too large")?; + let mut frame = Vec::with_capacity(size as usize); + frame.extend_from_slice(&size.to_be_bytes()); + frame.extend_from_slice(message_type); + frame.extend_from_slice(&[0; 8]); + frame.extend_from_slice(command); + frame.extend_from_slice(&sequence.to_be_bytes()); + frame.extend_from_slice(&0u32.to_be_bytes()); + frame.extend_from_slice(body); + Ok(frame) +} + +fn take_data_stream_message(buffer: &mut Vec) -> Result> { + if buffer.len() < DATA_STREAM_HEADER_BYTES { + return Ok(None); + } + let size = u32::from_be_bytes(buffer[..4].try_into()?) as usize; + anyhow::ensure!( + (DATA_STREAM_HEADER_BYTES..=MAX_MESSAGE_BYTES).contains(&size), + "invalid AirPlay data-stream message size" + ); + if buffer.len() < size { + return Ok(None); + } + let message_type = buffer[4..16].try_into()?; + let sequence = u64::from_be_bytes(buffer[20..28].try_into()?); + let body = buffer[DATA_STREAM_HEADER_BYTES..size].to_vec(); + buffer.drain(..size); + Ok(Some(DataStreamMessage { + message_type, + sequence, + body, + })) +} + +fn starts_data_stream_message(buffer: &[u8]) -> bool { + buffer + .get(..4) + .and_then(|size| <[u8; 4]>::try_from(size).ok()) + .map(u32::from_be_bytes) + .is_some_and(|size| { + (DATA_STREAM_HEADER_BYTES as u32..=MAX_MESSAGE_BYTES as u32).contains(&size) + }) +} + +fn take_response(buffer: &mut Vec) -> Result> { + let Some(header_end) = buffer.windows(4).position(|window| window == b"\r\n\r\n") else { + return Ok(None); + }; + let header_bytes = &buffer[..header_end]; + let header = std::str::from_utf8(header_bytes).context("invalid AirPlay response headers")?; + let mut lines = header.split("\r\n"); + let status_line = lines.next().context("missing AirPlay status line")?; + let status = status_line + .split_whitespace() + .nth(1) + .context("missing AirPlay status")? + .parse::() + .context("invalid AirPlay status")?; + let mut headers = HashMap::new(); + for line in lines { + let (name, value) = line + .split_once(':') + .context("malformed AirPlay response header")?; + headers.insert(name.trim().to_ascii_lowercase(), value.trim().to_string()); + } + let length = headers + .get("content-length") + .map(|value| value.parse::()) + .transpose() + .context("invalid AirPlay content length")? + .unwrap_or(0); + anyhow::ensure!( + length <= MAX_MESSAGE_BYTES, + "AirPlay response body is too large" + ); + let total = header_end + 4 + length; + if buffer.len() < total { + return Ok(None); + } + let body = buffer[header_end + 4..total].to_vec(); + buffer.drain(..total); + Ok(Some(Response { + status, + headers, + body, + })) +} + +fn content_length(header: &str) -> Result { + for line in header.split("\r\n").skip(1) { + if let Some((name, value)) = line.split_once(':') { + if name.trim().eq_ignore_ascii_case("content-length") { + return value + .trim() + .parse() + .context("invalid AirPlay content length"); + } + } + } + Ok(0) +} + +fn header_value<'a>(header: &'a str, expected_name: &str) -> Option<&'a str> { + header.split("\r\n").skip(1).find_map(|line| { + let (name, value) = line.split_once(':')?; + name.trim() + .eq_ignore_ascii_case(expected_name) + .then(|| value.trim()) + }) +} + +fn complete_message(buffer: &[u8]) -> Result { + let Some(header_end) = buffer.windows(4).position(|window| window == b"\r\n\r\n") else { + return Ok(false); + }; + let header = std::str::from_utf8(&buffer[..header_end])?; + let length = content_length(header)?; + anyhow::ensure!( + length <= MAX_MESSAGE_BYTES, + "AirPlay event body is too large" + ); + Ok(buffer.len() >= header_end + 4 + length) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn response_parser_preserves_following_message() { + let mut bytes = b"HTTP/1.1 200 OK\r\nContent-Length: 3\r\nX-Test: yes\r\n\r\noneRTSP/1.0 204 OK\r\nContent-Length: 0\r\n\r\n".to_vec(); + let first = take_response(&mut bytes).unwrap().unwrap(); + assert_eq!(first.status, 200); + assert_eq!(first.body, b"one"); + assert_eq!(first.headers.get("x-test").map(String::as_str), Some("yes")); + assert_eq!(take_response(&mut bytes).unwrap().unwrap().status, 204); + assert!(bytes.is_empty()); + } + + #[tokio::test] + async fn request_answers_an_event_that_arrives_before_the_response() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let receiver = tokio::spawn(async move { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut buffer = vec![0u8; 1024]; + let read = stream.read(&mut buffer).await.unwrap(); + let request = String::from_utf8_lossy(&buffer[..read]).into_owned(); + // Push an event request ahead of the response we owe the sender. + stream + .write_all(b"POST /event RTSP/1.0\r\nCSeq: 9\r\nContent-Length: 0\r\n\r\n") + .await + .unwrap(); + stream + .write_all(b"RTSP/1.0 200 OK\r\nContent-Length: 2\r\n\r\nok") + .await + .unwrap(); + stream.flush().await.unwrap(); + let mut acknowledgement = vec![0u8; 1024]; + let read = stream.read(&mut acknowledgement).await.unwrap(); + ( + request, + String::from_utf8_lossy(&acknowledgement[..read]).into_owned(), + ) + }); + + let mut connection = AirplayConnection::connect(address).await.unwrap(); + let response = connection + .request_while_serving_events( + "RECORD", + "rtsp://127.0.0.1/1", + "RTSP/1.0", + &[("CSeq", "1".to_string())], + &[], + ) + .await + .unwrap(); + assert_eq!(response.status, 200); + assert_eq!(response.body, b"ok"); + + let (request, acknowledgement) = receiver.await.unwrap(); + assert!( + request.starts_with("RECORD rtsp://127.0.0.1/1 RTSP/1.0\r\n"), + "{request}" + ); + assert!( + acknowledgement.starts_with("RTSP/1.0 200 OK\r\n"), + "{acknowledgement}" + ); + assert!(acknowledgement.contains("CSeq: 9"), "{acknowledgement}"); + } + + #[test] + fn data_stream_parser_preserves_following_frame() { + let mut bytes = data_stream_frame(b"sync", b"comm", 7, b"one").unwrap(); + bytes.extend(data_stream_frame(b"rply", b"\0\0\0\0", 8, b"two").unwrap()); + let first = take_data_stream_message(&mut bytes).unwrap().unwrap(); + assert_eq!(&first.message_type[..4], b"sync"); + assert_eq!(first.sequence, 7); + assert_eq!(first.body, b"one"); + let second = take_data_stream_message(&mut bytes).unwrap().unwrap(); + assert_eq!(&second.message_type[..4], b"rply"); + assert_eq!(second.sequence, 8); + assert_eq!(second.body, b"two"); + assert!(bytes.is_empty()); + } +} diff --git a/crates/vuio-core/src/casting/chromecast/mod.rs b/crates/vuio-core/src/casting/chromecast/mod.rs index a24f446..11304cb 100644 --- a/crates/vuio-core/src/casting/chromecast/mod.rs +++ b/crates/vuio-core/src/casting/chromecast/mod.rs @@ -107,6 +107,7 @@ impl CastProvider for ChromecastProvider { .unwrap_or("Google Cast") .to_string(), protocol: RendererProtocol::Chromecast, + pairing: super::PairingStatus::NotRequired, capabilities: RendererCapabilities { video: true, audio: true, @@ -266,6 +267,7 @@ mod tests { fn item(mime_type: &str) -> PlaybackItem { PlaybackItem { + local_path: std::path::PathBuf::new(), id: 1, url: "http://192.168.1.2/media/1".to_string(), title: "Test".to_string(), diff --git a/crates/vuio-core/src/casting/dlna.rs b/crates/vuio-core/src/casting/dlna.rs index 590c669..1859cf1 100644 --- a/crates/vuio-core/src/casting/dlna.rs +++ b/crates/vuio-core/src/casting/dlna.rs @@ -24,6 +24,7 @@ impl CastProvider for DlnaProvider { location_url: tv.location_url.clone(), model_name: tv.model_name, protocol: RendererProtocol::Dlna, + pairing: super::PairingStatus::NotRequired, capabilities: RendererCapabilities { video: true, audio: true, diff --git a/crates/vuio-core/src/casting/mod.rs b/crates/vuio-core/src/casting/mod.rs index 9c3b6f4..0893b70 100644 --- a/crates/vuio-core/src/casting/mod.rs +++ b/crates/vuio-core/src/casting/mod.rs @@ -16,6 +16,21 @@ pub enum RendererProtocol { Airplay, } +#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum PairingStatus { + NotRequired, + Required, + Paired, +} + +#[derive(Clone, Debug, Serialize)] +pub struct PairingChallenge { + pub id: String, + pub renderer_id: String, + pub expires_in_seconds: u64, +} + #[derive(Clone, Debug, Eq, PartialEq, Serialize)] pub struct RendererCapabilities { pub video: bool, @@ -42,6 +57,7 @@ pub struct RendererDevice { pub location_url: String, pub model_name: String, pub protocol: RendererProtocol, + pub pairing: PairingStatus, pub capabilities: RendererCapabilities, #[serde(skip)] pub endpoint: RendererEndpoint, @@ -64,6 +80,9 @@ impl RendererDevice { pub struct PlaybackItem { pub id: i64, pub url: String, + /// On-disk location. AirPlay audio is a push protocol, so the sender has to + /// decode the file itself rather than hand the receiver a URL. + pub local_path: std::path::PathBuf, pub title: String, pub filename: String, pub mime_type: String, @@ -101,6 +120,15 @@ pub trait CastProvider: Send + Sync { async fn play(&self, device: &RendererDevice, item: &PlaybackItem) -> anyhow::Result<()>; async fn control(&self, device: &RendererDevice, action: PlaybackAction) -> anyhow::Result<()>; async fn status(&self, device: &RendererDevice) -> anyhow::Result; + async fn begin_pairing(&self, _device: &RendererDevice) -> anyhow::Result { + anyhow::bail!("this renderer protocol does not support pairing") + } + async fn finish_pairing(&self, _challenge_id: &str, _pin: &str) -> anyhow::Result<()> { + anyhow::bail!("this renderer protocol does not support pairing") + } + async fn forget_pairing(&self, _device: &RendererDevice) -> anyhow::Result { + anyhow::bail!("this renderer protocol does not support pairing") + } async fn queue_next( &self, _device: &RendererDevice, @@ -122,11 +150,23 @@ pub struct DiscoveryBatch { impl CastingManager { pub fn new() -> Self { + Self::with_airplay(airplay::AirplayProvider::new()) + } + + pub async fn persistent( + secrets: Arc, + ) -> anyhow::Result { + Ok(Self::with_airplay( + airplay::AirplayProvider::persistent(secrets).await?, + )) + } + + fn with_airplay(airplay: airplay::AirplayProvider) -> Self { Self { providers: vec![ Arc::new(dlna::DlnaProvider), Arc::new(chromecast::ChromecastProvider::new()), - Arc::new(airplay::AirplayProvider::new()), + Arc::new(airplay), ], } } @@ -199,6 +239,25 @@ impl CastingManager { self.provider(device.protocol)?.status(device).await } + pub async fn begin_pairing(&self, device: &RendererDevice) -> anyhow::Result { + self.provider(device.protocol)?.begin_pairing(device).await + } + + pub async fn finish_pairing( + &self, + protocol: RendererProtocol, + challenge_id: &str, + pin: &str, + ) -> anyhow::Result<()> { + self.provider(protocol)? + .finish_pairing(challenge_id, pin) + .await + } + + pub async fn forget_pairing(&self, device: &RendererDevice) -> anyhow::Result { + self.provider(device.protocol)?.forget_pairing(device).await + } + pub async fn queue_next( &self, device: &RendererDevice, diff --git a/crates/vuio-core/src/database/mod.rs b/crates/vuio-core/src/database/mod.rs index b8f164f..b2cd05e 100644 --- a/crates/vuio-core/src/database/mod.rs +++ b/crates/vuio-core/src/database/mod.rs @@ -741,10 +741,29 @@ pub trait StatsRepository: Send + Sync { async fn get_stats(&self) -> Result; } +/// Opaque secret storage, kept object-safe on purpose. +/// +/// `DatabaseManager` has generic methods and so cannot be made into a trait +/// object; components that only need to persist a blob (AirPlay pairing keys, +/// for example) take `Arc` instead of being made generic over +/// the whole database. +#[async_trait] +pub trait SecretStore: Send + Sync { + async fn get_secret(&self, key: &str) -> Result>>; + async fn set_secret(&self, key: &str, value: &[u8]) -> Result<()>; + async fn delete_secret(&self, key: &str) -> Result; +} + /// Aggregate database capability used by the application. #[async_trait] pub trait DatabaseManager: - MediaRepository + PlaylistRepository + HealthRepository + StatsRepository + Send + Sync + MediaRepository + + PlaylistRepository + + HealthRepository + + StatsRepository + + SecretStore + + Send + + Sync { /// Initialize the database and create tables if needed. async fn initialize(&self) -> Result<()>; diff --git a/crates/vuio-core/src/database/redb/mod.rs b/crates/vuio-core/src/database/redb/mod.rs index 26e044d..1680e43 100644 --- a/crates/vuio-core/src/database/redb/mod.rs +++ b/crates/vuio-core/src/database/redb/mod.rs @@ -24,8 +24,8 @@ use super::{ DatabaseBackend, DatabaseHealth, DatabaseManager, DatabaseReadSession, DatabaseStats, DirectoryView, FileFingerprint, FileLocation, HealthRepository, IndexSnapshot, MediaDirectory, MediaFile, MediaFileQuery, MediaFileView, MediaRepository, MusicCategory, MusicCategoryType, - Playlist, PlaylistRepository, PlaylistView, RemovalSummary, RootAvailability, StatsRepository, - VisitSummary, + Playlist, PlaylistRepository, PlaylistView, RemovalSummary, RootAvailability, SecretStore, + StatsRepository, VisitSummary, }; include!("schema.rs"); @@ -34,6 +34,7 @@ mod health; mod media_repo; mod playlist_repo; mod root_repo; +mod secret_repo; mod stats; /// RedbDatabase - ACID-compliant embedded database diff --git a/crates/vuio-core/src/database/redb/schema.rs b/crates/vuio-core/src/database/redb/schema.rs index 82db681..4237095 100644 --- a/crates/vuio-core/src/database/redb/schema.rs +++ b/crates/vuio-core/src/database/redb/schema.rs @@ -21,6 +21,7 @@ macro_rules! redb_schema { $callback!(multimap, SOURCE_PLAYLISTS, &str, i64, "source_playlists", derived); $callback!(table, METADATA_TABLE, &str, u64, "metadata", primary); $callback!(table, ROOT_AVAILABILITY, &str, &[u8], "root_availability", primary); + $callback!(table, SECRETS_TABLE, &str, &[u8], "secrets", primary); $callback!(multimap, ARTIST_INDEX, &str, i64, "artist_index", derived); $callback!(multimap, ALBUM_INDEX, &str, i64, "album_index", derived); $callback!(multimap, GENRE_INDEX, &str, i64, "genre_index", derived); diff --git a/crates/vuio-core/src/database/redb/secret_repo.rs b/crates/vuio-core/src/database/redb/secret_repo.rs new file mode 100644 index 0000000..fae5ef5 --- /dev/null +++ b/crates/vuio-core/src/database/redb/secret_repo.rs @@ -0,0 +1,42 @@ +use super::*; + +impl RedbDatabase { + pub(super) async fn get_secret_impl(&self, key: &str) -> Result>> { + let key = key.to_string(); + self.execute_read(move |database| { + let transaction = database.begin_read()?; + let table = transaction.open_table(SECRETS_TABLE)?; + Ok(table.get(key.as_str())?.map(|value| value.value().to_vec())) + }) + .await + } + + pub(super) async fn set_secret_impl(&self, key: &str, value: &[u8]) -> Result<()> { + let key = key.to_string(); + let value = value.to_vec(); + self.execute_write(move |database| { + let transaction = database.begin_write()?; + transaction + .open_table(SECRETS_TABLE)? + .insert(key.as_str(), value.as_slice())?; + transaction.commit()?; + Ok(()) + }) + .await + } + + pub(super) async fn delete_secret_impl(&self, key: &str) -> Result { + let key = key.to_string(); + self.execute_write(move |database| { + let transaction = database.begin_write()?; + let removed = { + let mut table = transaction.open_table(SECRETS_TABLE)?; + let previous = table.remove(key.as_str())?; + previous.is_some() + }; + transaction.commit()?; + Ok(removed) + }) + .await + } +} diff --git a/crates/vuio-core/src/database/redb/tests.rs b/crates/vuio-core/src/database/redb/tests.rs index fb7ca9a..3d77234 100644 --- a/crates/vuio-core/src/database/redb/tests.rs +++ b/crates/vuio-core/src/database/redb/tests.rs @@ -307,7 +307,35 @@ fn schema_registry_has_unique_names() { redb_schema!(collect_schema_name); let unique = names.iter().copied().collect::>(); assert_eq!(names.len(), unique.len()); - assert_eq!(names.len(), 20); + assert_eq!(names.len(), 21); +} + +#[tokio::test] +async fn secrets_round_trip_in_the_database() { + let temp = tempdir().unwrap(); + let db = RedbDatabase::new(temp.path().join("secrets.redb")) + .await + .unwrap(); + db.initialize().await.unwrap(); + + assert_eq!(db.get_secret("airplay.pairings").await.unwrap(), None); + db.set_secret("airplay.pairings", b"{\"version\":1}") + .await + .unwrap(); + assert_eq!( + db.get_secret("airplay.pairings").await.unwrap().as_deref(), + Some(&b"{\"version\":1}"[..]) + ); + db.set_secret("airplay.pairings", b"replaced") + .await + .unwrap(); + assert_eq!( + db.get_secret("airplay.pairings").await.unwrap().as_deref(), + Some(&b"replaced"[..]) + ); + assert!(db.delete_secret("airplay.pairings").await.unwrap()); + assert!(!db.delete_secret("airplay.pairings").await.unwrap()); + assert_eq!(db.get_secret("airplay.pairings").await.unwrap(), None); } #[tokio::test] diff --git a/crates/vuio-core/src/database/redb/traits.rs b/crates/vuio-core/src/database/redb/traits.rs index edb2fed..04de2f3 100644 --- a/crates/vuio-core/src/database/redb/traits.rs +++ b/crates/vuio-core/src/database/redb/traits.rs @@ -287,6 +287,21 @@ impl StatsRepository for RedbDatabase { } } +#[async_trait] +impl SecretStore for RedbDatabase { + async fn get_secret(&self, key: &str) -> Result>> { + RedbDatabase::get_secret_impl(self, key).await + } + + async fn set_secret(&self, key: &str, value: &[u8]) -> Result<()> { + RedbDatabase::set_secret_impl(self, key, value).await + } + + async fn delete_secret(&self, key: &str) -> Result { + RedbDatabase::delete_secret_impl(self, key).await + } +} + #[async_trait] impl DatabaseManager for RedbDatabase { async fn initialize(&self) -> Result<()> { diff --git a/crates/vuio-core/src/lifecycle/runner.rs b/crates/vuio-core/src/lifecycle/runner.rs index 5626e23..dc90059 100644 --- a/crates/vuio-core/src/lifecycle/runner.rs +++ b/crates/vuio-core/src/lifecycle/runner.rs @@ -135,6 +135,9 @@ where config_manager.get_config_path(), runtime_options.auth, )?); + let renderer_cache = crate::runtime_state::RendererCache::persistent(database.clone()) + .await + .context("Failed to initialize AirPlay credential storage")?; let app_state = AppState { config: config.clone(), live_config: Arc::new(crate::state::LiveConfig::new(config.clone())), @@ -160,7 +163,7 @@ where active_casts: Arc::new(tokio::sync::Mutex::new( crate::runtime_state::ActiveCastRegistry::new(), )), - discovered_tvs: Arc::new(crate::runtime_state::RendererCache::new()), + discovered_tvs: Arc::new(renderer_cache), upnp_subscriptions: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())), cancellation: cancellation.clone(), background_tasks: background_tasks.clone(), @@ -439,8 +442,11 @@ where } info!("Shutting down gracefully..."); - shutdown.cancel(); + // Tear renderer sessions down first: a receiver keeps rendering what it has + // buffered unless it is told to stop, and the control connection has to + // still be alive to tell it. app_state.discovered_tvs.shutdown().await; + shutdown.cancel(); background_tasks.close(); if let Err(error) = file_watcher.stop_watching().await { warn!("Failed to stop file watcher cleanly: {}", error); diff --git a/crates/vuio-core/src/runtime_state.rs b/crates/vuio-core/src/runtime_state.rs index b681f3a..936a2b5 100644 --- a/crates/vuio-core/src/runtime_state.rs +++ b/crates/vuio-core/src/runtime_state.rs @@ -285,6 +285,16 @@ impl RendererCache { } } + pub async fn persistent( + secrets: std::sync::Arc, + ) -> anyhow::Result { + Ok(Self { + snapshot: tokio::sync::RwLock::new(RendererSnapshot::default()), + refresh: tokio::sync::Mutex::new(()), + casting: crate::casting::CastingManager::persistent(secrets).await?, + }) + } + pub async fn snapshot(&self) -> Vec { self.snapshot.read().await.renderers.clone() } @@ -424,6 +434,28 @@ impl RendererCache { self.casting.status(renderer).await } + pub async fn begin_pairing( + &self, + renderer: &RendererDevice, + ) -> anyhow::Result { + self.casting.begin_pairing(renderer).await + } + + pub async fn finish_pairing( + &self, + protocol: crate::casting::RendererProtocol, + challenge_id: &str, + pin: &str, + ) -> anyhow::Result<()> { + self.casting + .finish_pairing(protocol, challenge_id, pin) + .await + } + + pub async fn forget_pairing(&self, renderer: &RendererDevice) -> anyhow::Result { + self.casting.forget_pairing(renderer).await + } + pub async fn queue_next( &self, renderer: &RendererDevice, @@ -491,6 +523,7 @@ mod tests { location_url: location_url.clone(), model_name: model.to_string(), protocol: crate::casting::RendererProtocol::Dlna, + pairing: crate::casting::PairingStatus::NotRequired, capabilities: crate::casting::RendererCapabilities { video: true, audio: true, diff --git a/crates/vuio-core/src/web/casting.rs b/crates/vuio-core/src/web/casting.rs index 58fe8b6..c62461e 100644 --- a/crates/vuio-core/src/web/casting.rs +++ b/crates/vuio-core/src/web/casting.rs @@ -21,11 +21,43 @@ pub struct ApiCastRequest { pub source: ApiCastSource, } +#[derive(serde::Deserialize)] +pub struct ApiCastControlRequest { + pub renderer_id: String, + /// `play`, `pause` or `stop`. + pub action: String, +} + +#[derive(serde::Deserialize)] +pub struct ApiPairingStartRequest { + pub renderer_id: String, +} + +#[derive(serde::Deserialize)] +pub struct ApiPairingFinishRequest { + pub renderer_id: String, + pub challenge_id: String, + pub pin: String, +} + +#[derive(serde::Deserialize)] +pub struct ApiPairingForgetRequest { + pub renderer_id: String, +} + #[derive(serde::Deserialize)] #[serde(tag = "kind", rename_all = "snake_case")] pub enum ApiCastSource { - File { file_id: i64 }, - Folder { components: Vec }, + File { + file_id: i64, + }, + Folder { + components: Vec, + /// Restrict the queue to one media kind. Casting an audio folder should + /// not drag in the videos sitting beside it. + #[serde(default)] + media: Option, + }, } /// Discover supported playback devices and return their public details as JSON. @@ -44,6 +76,87 @@ pub async fn api_list_renderers( } } +async fn renderer_by_id( + state: &AppState, + renderer_id: &str, +) -> Result)> { + let renderers = state + .discovered_tvs + .get_or_refresh() + .await + .map_err(|error| { + error!(%error, "Renderer lookup failed"); + cast_error(StatusCode::INTERNAL_SERVER_ERROR, "Device discovery failed") + })?; + renderers + .into_iter() + .find(|renderer| renderer.id == renderer_id) + .ok_or_else(|| cast_error(StatusCode::NOT_FOUND, "Playback device not found")) +} + +pub async fn api_pairing_start( + State(state): State>, + axum::Json(payload): axum::Json, +) -> impl IntoResponse { + let renderer = match renderer_by_id(&state, &payload.renderer_id).await { + Ok(renderer) => renderer, + Err(response) => return response.into_response(), + }; + match state.discovered_tvs.begin_pairing(&renderer).await { + Ok(challenge) => (StatusCode::OK, axum::Json(serde_json::json!(challenge))).into_response(), + Err(error) => { + error!(%error, renderer_id = %renderer.id, "AirPlay pairing start failed"); + cast_error(StatusCode::BAD_REQUEST, &error.to_string()).into_response() + } + } +} + +pub async fn api_pairing_finish( + State(state): State>, + axum::Json(payload): axum::Json, +) -> impl IntoResponse { + let renderer = match renderer_by_id(&state, &payload.renderer_id).await { + Ok(renderer) => renderer, + Err(response) => return response.into_response(), + }; + match state + .discovered_tvs + .finish_pairing(renderer.protocol, &payload.challenge_id, payload.pin.trim()) + .await + { + Ok(()) => ( + StatusCode::OK, + axum::Json(serde_json::json!({ "paired": true })), + ) + .into_response(), + Err(error) => { + error!(%error, renderer_id = %renderer.id, "AirPlay pairing finish failed"); + cast_error(StatusCode::BAD_REQUEST, &error.to_string()).into_response() + } + } +} + +pub async fn api_pairing_forget( + State(state): State>, + axum::Json(payload): axum::Json, +) -> impl IntoResponse { + let renderer = match renderer_by_id(&state, &payload.renderer_id).await { + Ok(renderer) => renderer, + Err(response) => return response.into_response(), + }; + match state.discovered_tvs.forget_pairing(&renderer).await { + Ok(removed) => ( + StatusCode::OK, + axum::Json(serde_json::json!({ "removed": removed })), + ) + .into_response(), + Err(error) => { + error!(%error, renderer_id = %renderer.id, "AirPlay pairing removal failed"); + cast_error(StatusCode::BAD_REQUEST, &error.to_string()).into_response() + } + } +} + pub async fn api_cast( State(state): State>, axum::Json(payload): axum::Json, @@ -51,9 +164,12 @@ pub async fn api_cast( let result = match payload.source { ApiCastSource::File { file_id } => { let file = match state.database.get_file_location_by_id(file_id).await { - Ok(Some(file)) if file.mime_type.starts_with("video/") => file, + Ok(Some(file)) if is_castable_mime(&file.mime_type) => file, Ok(Some(_)) => { - return cast_error(StatusCode::BAD_REQUEST, "Only video files can be cast"); + return cast_error( + StatusCode::BAD_REQUEST, + "Only video and audio files can be cast", + ); } Ok(None) => return cast_error(StatusCode::NOT_FOUND, "Video file not found"), Err(error) => { @@ -63,13 +179,14 @@ pub async fn api_cast( }; crate::web::mcp::cast_file_helper(&state, file.id, &payload.renderer_id).await } - ApiCastSource::Folder { components } => { - let tracks = match resolve_video_folder(&state, &components).await { + ApiCastSource::Folder { components, media } => { + let tracks = match resolve_castable_folder(&state, &components, media.as_deref()).await + { Ok(tracks) if !tracks.is_empty() => tracks, Ok(_) => { return cast_error( StatusCode::BAD_REQUEST, - "No video files found in this folder", + "No castable media found in this folder", ); } Err(message) => return cast_error(StatusCode::BAD_REQUEST, &message), @@ -84,13 +201,60 @@ pub async fn api_cast( } } +/// Control what is already playing on a renderer. +/// +/// Stopping matters for push protocols like AirPlay audio: the sender has to +/// tear the session down, otherwise the receiver keeps rendering. +pub async fn api_cast_control( + State(state): State>, + axum::Json(payload): axum::Json, +) -> impl IntoResponse { + let action = match payload.action.to_ascii_lowercase().as_str() { + "play" => crate::casting::PlaybackAction::Play, + "pause" => crate::casting::PlaybackAction::Pause, + "stop" => crate::casting::PlaybackAction::Stop, + other => { + return cast_error( + StatusCode::BAD_REQUEST, + &format!("Unknown action '{other}'. Use play, pause or stop."), + ); + } + }; + + let renderers = match state.discovered_tvs.get_or_refresh().await { + Ok(renderers) => renderers, + Err(error) => { + return cast_error(StatusCode::INTERNAL_SERVER_ERROR, &error.to_string()); + } + }; + let Some(renderer) = renderers + .iter() + .find(|renderer| renderer.id == payload.renderer_id) + else { + return cast_error(StatusCode::NOT_FOUND, "No renderer found with that ID"); + }; + + match state.discovered_tvs.control(renderer, action).await { + Ok(()) => ( + StatusCode::OK, + axum::Json(serde_json::json!({ + "status": "ok", + "action": payload.action, + "renderer": renderer.friendly_name, + })), + ), + Err(error) => cast_error(StatusCode::BAD_REQUEST, &error.to_string()), + } +} + fn cast_error(status: StatusCode, message: &str) -> (StatusCode, axum::Json) { (status, axum::Json(serde_json::json!({ "error": message }))) } -async fn resolve_video_folder( +async fn resolve_castable_folder( state: &AppState, components: &[String], + media: Option<&str>, ) -> Result, String> { if components.len() > 64 || components.iter().any(|component| { @@ -118,7 +282,7 @@ async fn resolve_video_folder( .await .map_err(|error| format!("Database error: {error}"))?; for file in files { - if !file.mime_type().starts_with("video/") { + if !is_castable_mime(file.mime_type()) || !matches_media_kind(file.mime_type(), media) { continue; } let Some(location) = file.to_file_location() else { @@ -139,6 +303,33 @@ async fn resolve_video_folder( Ok(videos.into_iter().map(|(_, file)| file).collect()) } +/// Whether a MIME type belongs to the requested media kind. +/// +/// `None` accepts anything castable, which is what the folder API does when a +/// caller does not care. +fn matches_media_kind(mime: &str, media: Option<&str>) -> bool { + match media { + Some("audio") => mime.starts_with("audio/"), + Some("video") => !mime.starts_with("audio/"), + _ => true, + } +} + +/// Media the cast API will hand to a provider. +/// +/// Audio is included because AirPlay receivers take an RTP audio stream even +/// when they cannot play video. Each provider still validates the item, so a +/// renderer that cannot take audio rejects it with its own message. +fn is_castable_mime(mime: &str) -> bool { + let base = mime.split(';').next().unwrap_or(mime).trim(); + mime.starts_with("video/") + || (base.starts_with("audio/") && base != "audio/radio") + || matches!( + base, + "application/vnd.apple.mpegurl" | "application/x-mpegurl" + ) +} + async fn create_and_cast_playlist( state: &AppState, renderer_id: &str, diff --git a/crates/vuio-core/src/web/mcp/tools/casting.rs b/crates/vuio-core/src/web/mcp/tools/casting.rs index 752b9f5..272f4aa 100644 --- a/crates/vuio-core/src/web/mcp/tools/casting.rs +++ b/crates/vuio-core/src/web/mcp/tools/casting.rs @@ -35,7 +35,7 @@ pub async fn cast_file_helper( let origin = state .advertised_http_origin_for_peer(&matched_tv.location_url) .await; - let media_url = format!("{origin}/media/{}", file.id); + let media_url = playback_url(&file, &origin); let item = playback_item(&file, &origin); state.discovered_tvs.validate(matched_tv, &item)?; @@ -142,13 +142,29 @@ pub async fn cached_renderers( pub(crate) fn playback_item(file: &FileLocation, origin: &str) -> PlaybackItem { PlaybackItem { id: file.id, - url: format!("{origin}/media/{}", file.id), + url: playback_url(file, origin), + local_path: file.path.clone(), title: file.title.clone().unwrap_or_else(|| file.filename.clone()), filename: file.filename.clone(), mime_type: file.mime_type.clone(), } } +fn playback_url(file: &FileLocation, origin: &str) -> String { + let extension = std::path::Path::new(&file.filename) + .extension() + .and_then(|value| value.to_str()) + .filter(|value| { + !value.is_empty() + && value.len() <= 16 + && value.bytes().all(|byte| byte.is_ascii_alphanumeric()) + }); + match extension { + Some(extension) => format!("{origin}/media/{}.{extension}", file.id), + None => format!("{origin}/media/{}", file.id), + } +} + pub(crate) async fn playlist_file_locations( state: &AppState, playlist_id: i64, @@ -208,7 +224,6 @@ pub async fn cast_tracks_helper( } let selected_track = &tracks[track_index]; - let file_id = selected_track.id; let renderers = cached_renderers(state).await?; @@ -229,7 +244,7 @@ pub async fn cast_tracks_helper( let origin = state .advertised_http_origin_for_peer(&matched_tv.location_url) .await; - let media_url = format!("{origin}/media/{file_id}"); + let media_url = playback_url(selected_track, &origin); let playback_items = tracks .iter() @@ -262,16 +277,30 @@ pub async fn cast_tracks_helper( } } + // Hand the renderer as much of the queue as it will take. A renderer with a + // real queue (AirPlay audio) accepts the whole remainder and advances on its + // own; one without accepts nothing and the status monitor below drives the + // transitions instead. let mut queued_file = None; - if let Some(next_item) = playback_items.get(track_index + 1) { + let mut queued_count = 0usize; + for next_item in playback_items.iter().skip(track_index + 1) { match state.discovered_tvs.queue_next(matched_tv, next_item).await { - Ok(true) => queued_file = Some(next_item.filename.clone()), - Ok(false) => {} + Ok(true) => { + if queued_file.is_none() { + queued_file = Some(next_item.filename.clone()); + } + queued_count += 1; + } + Ok(false) => break, Err(error) => { tracing::warn!(%error, "Renderer does not support native next-item queueing"); + break; } } } + if queued_count > 0 { + tracing::info!(queued_count, "Queued tracks on the renderer"); + } // Spawn new queue monitor to dynamically handle subsequent track transitions let monitor_id = Uuid::new_v4(); diff --git a/crates/vuio-core/src/web/mod.rs b/crates/vuio-core/src/web/mod.rs index b6c69bf..a19b153 100644 --- a/crates/vuio-core/src/web/mod.rs +++ b/crates/vuio-core/src/web/mod.rs @@ -40,7 +40,20 @@ pub fn create_router(state: AppState) -> Router let json_routes = Router::new() .route("/api/cast", post(casting::api_cast::)) + .route("/api/cast/control", post(casting::api_cast_control::)) .route("/api/cast/playlist", post(casting::api_cast_playlist::)) + .route( + "/api/renderers/pair/start", + post(casting::api_pairing_start::), + ) + .route( + "/api/renderers/pair/finish", + post(casting::api_pairing_finish::), + ) + .route( + "/api/renderers/pair/forget", + post(casting::api_pairing_forget::), + ) .route("/mcp/message", post(mcp::message_handler::)) .layer(DefaultBodyLimit::max(JSON_BODY_LIMIT)); diff --git a/crates/vuio-core/src/web/streaming.rs b/crates/vuio-core/src/web/streaming.rs index eb713b7..8daf210 100644 --- a/crates/vuio-core/src/web/streaming.rs +++ b/crates/vuio-core/src/web/streaming.rs @@ -71,7 +71,7 @@ pub async fn serve_media( ) -> Result { let start_time = Instant::now(); - let file_id = id.parse::().map_err(|_| { + let file_id = media_id_from_path_segment(&id).ok_or_else(|| { state.web_metrics.record_error(); AppError::NotFound })?; @@ -262,14 +262,41 @@ pub async fn serve_media( .web_metrics .record_file_serve(response_time, is_actual_serve); - debug!( - "Served media file ID {} ({} bytes from offset {}) in {}ms", - file_id, len, start, response_time - ); + if method == Method::GET { + tracing::info!( + file_id, + filename = %file_info.filename, + client = %client_addr.ip(), + start, + len, + "media GET" + ); + } else { + debug!( + "Served media file ID {} ({} bytes from offset {}) in {}ms", + file_id, len, start, response_time + ); + } Ok(response_builder.status(response_status).body(body)?) } +fn media_id_from_path_segment(segment: &str) -> Option { + let id = match segment.split_once('.') { + Some((id, extension)) => { + if extension.is_empty() + || extension.len() > 16 + || !extension.bytes().all(|byte| byte.is_ascii_alphanumeric()) + { + return None; + } + id + } + None => segment, + }; + id.parse().ok() +} + // Helper function to parse range header manually fn parse_range_header(range_str: &str, file_size: u64) -> Result<(u64, u64), AppError> { if file_size == 0 { @@ -463,6 +490,15 @@ mod range_tests { assert!(!value.split("filename*=",).next().unwrap().contains('\n')); } + #[test] + fn media_paths_accept_a_safe_format_extension() { + assert_eq!(media_id_from_path_segment("15"), Some(15)); + assert_eq!(media_id_from_path_segment("15.mp4"), Some(15)); + assert_eq!(media_id_from_path_segment("15.m3u8"), Some(15)); + assert_eq!(media_id_from_path_segment("15."), None); + assert_eq!(media_id_from_path_segment("15.mp4/cover"), None); + } + #[test] fn empty_files_reject_every_range_without_underflowing() { for range in ["bytes=0-", "bytes=-1", "bytes=0-0"] { diff --git a/crates/vuio-core/src/web/ui.rs b/crates/vuio-core/src/web/ui.rs index 4caf72a..02f5c6a 100644 --- a/crates/vuio-core/src/web/ui.rs +++ b/crates/vuio-core/src/web/ui.rs @@ -166,5 +166,8 @@ mod tests { assert!(DASHBOARD_TEMPLATE.contains("/api/cast")); assert!(DASHBOARD_TEMPLATE.contains("playVideoFileOnTv(file)")); assert!(DASHBOARD_TEMPLATE.contains("showRendererSelectionModal(renderers, label, source)")); + assert!(!DASHBOARD_TEMPLATE.contains("renderers.length === 1")); + assert!(DASHBOARD_TEMPLATE.contains("renderer.pairing === 'required'")); + assert!(DASHBOARD_TEMPLATE.contains("/api/renderers/pair/start")); } } diff --git a/crates/vuio-core/src/web/ui/dashboard.html b/crates/vuio-core/src/web/ui/dashboard.html index 71305e9..140fb55 100644 --- a/crates/vuio-core/src/web/ui/dashboard.html +++ b/crates/vuio-core/src/web/ui/dashboard.html @@ -916,6 +916,12 @@ + +
@@ -1239,6 +1245,9 @@

'; + function playFolder(folderName) { const targetPath = [...currentPath, folderName]; @@ -1278,6 +1287,21 @@

`; btn.querySelector('.tv-friendly-name').textContent = renderer.friendly_name || 'Unknown renderer'; - btn.querySelector('.tv-protocol').textContent = renderer.protocol || 'dlna'; + const protocol = renderer.protocol || 'dlna'; + btn.querySelector('.tv-protocol').textContent = renderer.pairing === 'required' + ? protocol + ' · PIN required' + : protocol; container.appendChild(btn); + if (renderer.protocol === 'airplay' && renderer.pairing === 'paired') { + const forget = document.createElement('button'); + forget.type = 'button'; + forget.textContent = 'Forget saved AirPlay pairing'; + forget.style.cssText = 'background: transparent; border: 0; color: var(--text-secondary); cursor: pointer; font-size: 0.72rem; text-align: right; padding: 0 0.35rem;'; + forget.onclick = async () => { + forget.disabled = true; + try { + const response = await fetch('/api/renderers/pair/forget', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ renderer_id: renderer.id }) + }); + const result = await response.json().catch(() => ({})); + if (!response.ok || result.error) { + throw new Error(result.error || 'Could not forget pairing'); + } + renderer.pairing = 'required'; + closeTvModal(); + showToast('Saved AirPlay pairing removed.', 'success'); + } catch (error) { + forget.disabled = false; + showToast('Failed to remove pairing: ' + error.message, 'error'); + } + }; + container.appendChild(forget); + } }); document.getElementById('tv-modal').style.display = 'flex'; @@ -1334,6 +1389,77 @@

({})); + if (!startResponse.ok || challenge.error) { + throw new Error(challenge.error || 'Pairing could not be started'); + } + const pin = window.prompt( + 'Enter the AirPlay PIN displayed on "' + renderer.friendly_name + '":' + ); + if (!pin) { + showToast("AirPlay pairing cancelled.", "info"); + return false; + } + const finishResponse = await fetch('/api/renderers/pair/finish', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + renderer_id: renderer.id, + challenge_id: challenge.id, + pin: pin.trim() + }) + }); + const result = await finishResponse.json().catch(() => ({})); + if (!finishResponse.ok || result.error) { + throw new Error(result.error || 'Pairing failed'); + } + showToast("AirPlay pairing saved securely.", "success"); + return true; + } catch (error) { + console.error("AirPlay pairing failed", error); + showToast("Failed to pair with device: " + error.message, "error"); + return false; + } + } + + // Remembered so playback can be stopped without re-picking the device. + let lastCastRenderer = null; + + async function stopCurrentCast() { + if (!lastCastRenderer) { + showToast("Nothing is casting from this page.", "info"); + return; + } + const name = lastCastRenderer.friendly_name; + const rendererId = lastCastRenderer.id; + // Stopping is one-way: once asked, this page is no longer casting + // regardless of what the receiver says, so clear the control first. + document.getElementById('stop-cast-btn').style.display = 'none'; + lastCastRenderer = null; + try { + const response = await fetch('/api/cast/control', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ renderer_id: rendererId, action: 'stop' }) + }); + const data = await response.json().catch(() => ({})); + if (!response.ok || data.error) { + throw new Error(data.error || 'Stop request failed'); + } + showToast("Stopped playback on " + name + ".", "success"); + } catch (error) { + showToast("Stopped, but the receiver reported: " + error.message, "info"); + } + } + async function castToRenderer(renderer, source) { showToast("Casting to " + renderer.friendly_name + "...", "info"); try { @@ -1347,6 +1473,9 @@

{ - if (file.cat !== 'video') return false; + const folderContains = category => filesData.filter(file => { + if (file.cat !== category) return false; const comps = getRelativeComponents(file.path); if (comps.length <= targetPath.length) return false; for (let i = 0; i < targetPath.length; i++) { @@ -1956,6 +2085,8 @@

@@ -1986,6 +2117,19 @@

{ + event.stopPropagation(); + playAudioFileOnTv(file); + }); + actionArea.prepend(castButton); + } if (file.cat === 'video') { const tvButton = document.createElement('button'); tvButton.className = 'btn-action'; diff --git a/installer.nsi b/packaging/windows/installer.nsi similarity index 100% rename from installer.nsi rename to packaging/windows/installer.nsi