Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,7 +158,7 @@ The sender scans for receivers automatically. Select one from the list — its p
```
openplay-sender
--config <path> Use a custom config file
--name <name> Override the display name shown in the window
--name <name> Override the display name shown in the window and sent to AirPlay receivers

openplay-receiver
--config <path> Use a custom config file
Expand Down
8 changes: 7 additions & 1 deletion crates/openplay-airplay/examples/control_probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,13 @@ async fn main() -> anyhow::Result<()> {
let probes: Vec<(&str, Vec<u8>)> = vec![
(
"POST /stream",
http_session::build_stream_request(1920, 1080, 30, "probe-session")?,
http_session::build_stream_request(
1920,
1080,
30,
"probe-session",
http_session::DEFAULT_DEVICE_NAME,
)?,
),
(
"POST /fp-setup",
Expand Down
8 changes: 7 additions & 1 deletion crates/openplay-airplay/examples/pair_probe.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,13 @@ async fn main() -> anyhow::Result<()> {
let session_id = uuid::Uuid::new_v4().to_string().to_uppercase();
match TcpStream::connect(addr).await {
Ok(mut stream) => {
match openplay_airplay::http_session::get_info_raw(&mut stream, &session_id).await {
match openplay_airplay::http_session::get_info_raw(
&mut stream,
&session_id,
openplay_airplay::http_session::DEFAULT_DEVICE_NAME,
)
.await
{
Ok((headers, body)) => {
let status = headers.lines().next().unwrap_or("(no status line)");
println!(" status: {status}");
Expand Down
100 changes: 90 additions & 10 deletions crates/openplay-airplay/src/http_session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,27 @@ use crate::AirPlayError;
/// AirPlay client version string — must match a known AirPlay version to avoid
/// rejection by third-party receivers (LG TVs, Samsung TVs, NOVO boards, etc.).
const AIRPLAY_USER_AGENT: &str = "AirPlay/550.10";
/// Device name advertised to the receiver.
const OPENPLAY_DEVICE_NAME: &str = "OpenPlay";
/// Device name sent to the receiver when the caller has no usable one.
pub const DEFAULT_DEVICE_NAME: &str = "OpenPlay";

/// Makes a display name safe to send as the `X-Apple-Device-Name` header.
///
/// The name comes from `config.toml` or `--name`. An HTTP header ends at the
/// first line break, so a name containing one would cut the request short and
/// let the rest of the name be read as further headers. Control characters
/// are dropped, surrounding whitespace is trimmed, and a name with nothing
/// left falls back to [`DEFAULT_DEVICE_NAME`] rather than sending an empty
/// header. Anything else — including non-ASCII, which Apple's own senders
/// use — is kept as typed.
pub fn header_safe_device_name(name: &str) -> String {
let cleaned: String = name.chars().filter(|c| !c.is_control()).collect();
let cleaned = cleaned.trim();
if cleaned.is_empty() {
DEFAULT_DEVICE_NAME.to_string()
} else {
cleaned.to_string()
}
}

/// Result of AirPlay HTTP negotiation.
pub struct NegotiatedStream {
Expand Down Expand Up @@ -53,6 +72,7 @@ pub async fn negotiate(
height: u32,
fps: u32,
session_id: &str,
device_name: &str,
) -> Result<NegotiatedStream, AirPlayError> {
let mut stream = TcpStream::connect(addr)
.await
Expand All @@ -61,7 +81,7 @@ pub async fn negotiate(
info!(%addr, "Connected to AirPlay receiver");

// Step 1: GET /info
let server_info = get_info(&mut stream, session_id).await?;
let server_info = get_info(&mut stream, session_id, device_name).await?;
info!(
model = %server_info.model,
name = %server_info.device_name,
Expand All @@ -82,7 +102,7 @@ pub async fn negotiate(
}

// Step 2: POST /stream
post_stream(&mut stream, width, height, fps, session_id).await?;
post_stream(&mut stream, width, height, fps, session_id, device_name).await?;

Ok(NegotiatedStream {
stream,
Expand All @@ -91,11 +111,16 @@ pub async fn negotiate(
}

/// Sends GET /info with proper AirPlay headers and parses the binary plist response.
async fn get_info(stream: &mut TcpStream, session_id: &str) -> Result<ServerInfo, AirPlayError> {
async fn get_info(
stream: &mut TcpStream,
session_id: &str,
device_name: &str,
) -> Result<ServerInfo, AirPlayError> {
let device_name = header_safe_device_name(device_name);
let request = format!(
"GET /info HTTP/1.1\r\n\
User-Agent: {AIRPLAY_USER_AGENT}\r\n\
X-Apple-Device-Name: {OPENPLAY_DEVICE_NAME}\r\n\
X-Apple-Device-Name: {device_name}\r\n\
X-Apple-Session-ID: {session_id}\r\n\
X-Apple-ProtocolVersion: 1\r\n\
Content-Length: 0\r\n\r\n"
Expand All @@ -118,7 +143,9 @@ async fn post_stream(
height: u32,
fps: u32,
session_id: &str,
device_name: &str,
) -> Result<(), AirPlayError> {
let device_name = header_safe_device_name(device_name);
let mut params = BTreeMap::new();
params.insert("width".to_string(), plist::Value::Integer(width.into()));
params.insert("height".to_string(), plist::Value::Integer(height.into()));
Expand All @@ -143,7 +170,7 @@ async fn post_stream(
let request = format!(
"POST /stream HTTP/1.1\r\n\
User-Agent: {AIRPLAY_USER_AGENT}\r\n\
X-Apple-Device-Name: {OPENPLAY_DEVICE_NAME}\r\n\
X-Apple-Device-Name: {device_name}\r\n\
X-Apple-Session-ID: {session_id}\r\n\
X-Apple-ProtocolVersion: 1\r\n\
Content-Type: application/x-apple-binary-plist\r\n\
Expand Down Expand Up @@ -299,11 +326,13 @@ fn parse_info_response(body: &[u8]) -> Result<ServerInfo, AirPlayError> {
pub async fn get_info_raw(
stream: &mut TcpStream,
session_id: &str,
device_name: &str,
) -> Result<(String, Vec<u8>), AirPlayError> {
let device_name = header_safe_device_name(device_name);
let request = format!(
"GET /info HTTP/1.1\r\n\
User-Agent: {AIRPLAY_USER_AGENT}\r\n\
X-Apple-Device-Name: {OPENPLAY_DEVICE_NAME}\r\n\
X-Apple-Device-Name: {device_name}\r\n\
X-Apple-Session-ID: {session_id}\r\n\
X-Apple-ProtocolVersion: 1\r\n\
Content-Length: 0\r\n\r\n"
Expand All @@ -326,7 +355,9 @@ pub fn build_stream_request(
height: u32,
fps: u32,
session_id: &str,
device_name: &str,
) -> Result<Vec<u8>, AirPlayError> {
let device_name = header_safe_device_name(device_name);
let mut params = BTreeMap::new();
params.insert("width".to_string(), plist::Value::Integer(width.into()));
params.insert("height".to_string(), plist::Value::Integer(height.into()));
Expand All @@ -351,7 +382,7 @@ pub fn build_stream_request(
let mut request = format!(
"POST /stream HTTP/1.1\r\n\
User-Agent: {AIRPLAY_USER_AGENT}\r\n\
X-Apple-Device-Name: {OPENPLAY_DEVICE_NAME}\r\n\
X-Apple-Device-Name: {device_name}\r\n\
X-Apple-Session-ID: {session_id}\r\n\
X-Apple-ProtocolVersion: 1\r\n\
Content-Type: application/x-apple-binary-plist\r\n\
Expand All @@ -369,6 +400,55 @@ pub async fn post_stream_on(
height: u32,
fps: u32,
session_id: &str,
device_name: &str,
) -> Result<(), AirPlayError> {
post_stream(stream, width, height, fps, session_id).await
post_stream(stream, width, height, fps, session_id, device_name).await
}

#[cfg(test)]
mod tests {
use super::*;

/// Everything before the blank line that ends the headers.
fn header_block(request: &[u8]) -> String {
let text = String::from_utf8_lossy(request);
text.split("\r\n\r\n").next().unwrap_or("").to_string()
}

#[test]
fn stream_request_carries_the_configured_device_name() {
let request = build_stream_request(1920, 1080, 30, "S1", "Nikhil's Laptop").unwrap();
let headers = header_block(&request);
assert!(
headers.contains("X-Apple-Device-Name: Nikhil's Laptop\r\n"),
"{headers}"
);
}

#[test]
fn a_line_break_in_the_name_cannot_add_a_header() {
let request =
build_stream_request(1920, 1080, 30, "S1", "Laptop\r\nX-Injected: yes").unwrap();
let headers = header_block(&request);
assert!(!headers.contains("\r\nX-Injected"), "{headers}");
assert!(
headers.contains("X-Apple-Device-Name: LaptopX-Injected: yes\r\n"),
"{headers}"
);
}

#[test]
fn a_blank_name_falls_back_to_the_default() {
assert_eq!(header_safe_device_name(""), DEFAULT_DEVICE_NAME);
assert_eq!(header_safe_device_name(" "), DEFAULT_DEVICE_NAME);
assert_eq!(header_safe_device_name("\r\n\t"), DEFAULT_DEVICE_NAME);
}

#[test]
fn whitespace_is_trimmed_and_non_ascii_is_kept() {
assert_eq!(
header_safe_device_name(" Nikhil’s Laptop "),
"Nikhil’s Laptop"
);
}
}
66 changes: 41 additions & 25 deletions crates/openplay-airplay/src/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,17 @@ pub enum SessionEvent {
Ended(Option<AirPlayError>),
}

/// What `run_session` needs to negotiate with the receiver, besides its
/// address: the video format to ask for, the session id, and the name the
/// receiver shows for this sender.
struct SessionParams {
width: u32,
height: u32,
fps: u32,
session_id: String,
device_name: String,
}

/// Orchestrates the full AirPlay mirroring session lifecycle:
/// 1. Start NTP server
/// 2. HTTP negotiate (GET /info → POST /stream)
Expand All @@ -51,29 +62,29 @@ impl AirPlaySession {
/// * `width` - Video width
/// * `height` - Video height
/// * `fps` - Target framerate
/// * `device_name` - Name the receiver shows for this sender, sent as
/// `X-Apple-Device-Name` on every request. Usually the configured
/// `display_name`.
pub async fn start(
receiver_addr: SocketAddr,
width: u32,
height: u32,
fps: u32,
device_name: &str,
) -> Result<Self, AirPlayError> {
let (cmd_tx, cmd_rx) = mpsc::channel(64);
let (evt_tx, evt_rx) = mpsc::channel(16);

let session_id = uuid::Uuid::new_v4().to_string();
let params = SessionParams {
width,
height,
fps,
session_id: uuid::Uuid::new_v4().to_string(),
device_name: device_name.to_string(),
};

tokio::spawn(async move {
if let Err(e) = run_session(
receiver_addr,
width,
height,
fps,
session_id,
cmd_rx,
evt_tx.clone(),
)
.await
{
if let Err(e) = run_session(receiver_addr, params, cmd_rx, evt_tx.clone()).await {
error!(%e, "AirPlay session error");
let _ = evt_tx.send(SessionEvent::Ended(Some(e))).await;
}
Expand Down Expand Up @@ -116,10 +127,7 @@ impl AirPlaySession {

async fn run_session(
receiver_addr: SocketAddr,
width: u32,
height: u32,
fps: u32,
session_id: String,
params: SessionParams,
mut cmd_rx: mpsc::Receiver<SessionCommand>,
evt_tx: mpsc::Sender<SessionEvent>,
) -> Result<(), AirPlayError> {
Expand All @@ -128,8 +136,15 @@ async fn run_session(
info!("NTP server running on port {}", AIRPLAY_NTP_PORT);

// Step 2: HTTP negotiate — try basic first, then authenticated if needed
let negotiated = match http_session::negotiate(receiver_addr, width, height, fps, &session_id)
.await
let negotiated = match http_session::negotiate(
receiver_addr,
params.width,
params.height,
params.fps,
&params.session_id,
&params.device_name,
)
.await
{
Ok(n) => {
info!("AirPlay negotiation complete (no auth required)");
Expand All @@ -138,7 +153,7 @@ async fn run_session(
Err(AirPlayError::Negotiation(ref msg)) if msg.contains("501") || msg.contains("403") => {
// Server requires authentication — try HAP pairing
info!("Receiver requires authentication, attempting HAP pairing");
negotiate_with_auth(receiver_addr, width, height, fps, &session_id).await?
negotiate_with_auth(receiver_addr, &params).await?
}
Err(e) => return Err(e),
};
Expand Down Expand Up @@ -199,17 +214,18 @@ async fn run_session(
/// 4. POST /stream on the verified connection
async fn negotiate_with_auth(
receiver_addr: SocketAddr,
width: u32,
height: u32,
fps: u32,
session_id: &str,
params: &SessionParams,
) -> Result<http_session::NegotiatedStream, AirPlayError> {
let (width, height, fps) = (params.width, params.height, params.fps);
let session_id = params.session_id.as_str();
let device_name = params.device_name.as_str();

// Step 1: Reconnect and GET /info to check device type (with proper AirPlay headers).
let mut stream = TcpStream::connect(receiver_addr)
.await
.map_err(|e| AirPlayError::Connection(format!("Failed to connect: {e}")))?;

let (_headers, body) = http_session::get_info_raw(&mut stream, session_id).await?;
let (_headers, body) = http_session::get_info_raw(&mut stream, session_id, device_name).await?;
let server_info = http_session::parse_info_response_pub(&body)?;
drop(stream); // Close the info connection

Expand Down Expand Up @@ -241,7 +257,7 @@ async fn negotiate_with_auth(

info!("Encrypted control channel established, sending POST /stream");

let request = http_session::build_stream_request(width, height, fps, session_id)?;
let request = http_session::build_stream_request(width, height, fps, session_id, device_name)?;
let response = control
.request(&request)
.await
Expand Down
12 changes: 8 additions & 4 deletions crates/openplay-sender/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ use openplay_common::AppConfig;
use openplay_discovery::{AirPlayBrowser, DiscoveryEvent, MiracastBrowser, ReceiverBrowser};
use tracing::{info, warn};

use crate::casting::{start_airplay_cast, start_miracast_cast, CastStopHandle};
use crate::casting::{start_airplay_cast, start_miracast_cast, CastSettings, CastStopHandle};
use crate::receiver_list::{DiscoveredReceiver, MiracastMode, MiracastReceiver, Protocol};

// ─── App state ────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -291,6 +291,12 @@ impl SenderApp {
let bitrate = self.config.max_bitrate_kbps;
let fps = self.config.framerate;
let force_sw = self.config.force_sw_encode;
let settings = CastSettings {
bitrate_kbps: bitrate,
framerate: fps,
force_sw_encode: force_sw,
display_name: self.config.display_name.clone(),
};
let handle = self.tokio_rt.handle().clone();
let stop = CastStopHandle::new();
self.stop_handle = Some(stop.clone());
Expand All @@ -310,9 +316,7 @@ impl SenderApp {
.enable_all()
.build()
.unwrap();
rt.block_on(start_airplay_cast(
addr, bitrate, fps, force_sw, handle, stop, status_cb,
));
rt.block_on(start_airplay_cast(addr, settings, handle, stop, status_cb));
});
}
}
Expand Down
Loading
Loading