Implement Cast Streaming mirroring, DLNA casting, daemon+GUI, and breadd integration
Some checks failed
dev release / build (push) Failing after 12s
Some checks failed
dev release / build (push) Failing after 12s
Builds out the full v1 scope: a vendored+patched openscreen subset for low-latency Cast Streaming (Mirroring receiver 0F5096E8) alongside the existing Cast V2/HLS and new DLNA/AVTransport casting paths, breadcastd's Idle/Casting state machine with a private IPC socket, the breadcast GTK4 popup as a thin IPC client, and bread.cast.*/bread.command.cast.* breadd integration (device discovery, start/stop, mirroring lifecycle events). Also adds bakery/systemd/Forgejo CI packaging. Validated end-to-end against a real Chromecast/Google TV: negotiated Cast Streaming session, live pipeline playback, and daemon+GUI click-to-cast/ stop through the actual popup.
This commit is contained in:
parent
887c29002f
commit
8c745d18e0
283 changed files with 36788 additions and 0 deletions
266
breadcast-core/src/http_server.rs
Normal file
266
breadcast-core/src/http_server.rs
Normal file
|
|
@ -0,0 +1,266 @@
|
|||
use std::net::SocketAddr;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
/// Number of worker threads pulling requests off the server's shared queue.
|
||||
/// tiny_http's `Server::recv()` takes `&self` specifically so it can be
|
||||
/// called from multiple threads concurrently (its own docs' recommended
|
||||
/// pattern) — bounding this at a small fixed number, instead of spawning a
|
||||
/// fresh OS thread per request, keeps the request volume any single LAN
|
||||
/// host can inflict on this process capped, since this server ends up
|
||||
/// reachable by every device on the LAN, not just the Cast receiver it's
|
||||
/// meant for.
|
||||
const WORKER_THREADS: usize = 8;
|
||||
|
||||
/// Serves `root` (the `hlssink3` output directory: `playlist.m3u8` +
|
||||
/// `segment*.ts`) over plain HTTP. Runs a small fixed pool of worker
|
||||
/// threads; dropping the handle does not stop the server (there is no clean
|
||||
/// shutdown yet — matches the smoke-testing scope of the rest of Phase 2).
|
||||
///
|
||||
/// Every servable path is namespaced under a random token
|
||||
/// (`/<token>/playlist.m3u8`, etc. — see [`HttpServer::token`]) rather than
|
||||
/// served at the root. There's no way to add an `Authorization` header a
|
||||
/// Cast receiver will send back, so this is the standard mitigation for a
|
||||
/// server that must stay unauthenticated but shouldn't let every other
|
||||
/// device on the LAN casually load `/playlist.m3u8` and watch this
|
||||
/// machine's screen.
|
||||
///
|
||||
/// Sets `Content-Type` per Google's Cast media docs (the receiver's HTTP
|
||||
/// client needs a correct type to load segments reliably), `Cache-Control`
|
||||
/// (critical for `playlist.m3u8`, which is rewritten every segment — a
|
||||
/// cached stale copy stalls playback with no error anywhere), Range/206
|
||||
/// support, and a permissive CORS header — get this right from the start
|
||||
/// since a Chromecast has no devtools console to debug a silent load
|
||||
/// failure against.
|
||||
pub struct HttpServer {
|
||||
addr: SocketAddr,
|
||||
token: String,
|
||||
}
|
||||
|
||||
impl HttpServer {
|
||||
/// Binds on `bind_addr` (use `0.0.0.0:0` to let the OS pick a free
|
||||
/// port — read the actual port back via [`HttpServer::addr`]) and
|
||||
/// starts serving `root` in the background. Build URLs to hand to a
|
||||
/// Cast device with [`HttpServer::url`], not by hand — it includes the
|
||||
/// required path token.
|
||||
pub fn start(bind_addr: &str, root: PathBuf) -> Result<Self> {
|
||||
let root = root
|
||||
.canonicalize()
|
||||
.with_context(|| format!("failed to canonicalize HLS root {}", root.display()))?;
|
||||
|
||||
let server = tiny_http::Server::http(bind_addr)
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))
|
||||
.with_context(|| format!("failed to bind HTTP server on {bind_addr}"))?;
|
||||
let addr = match server.server_addr() {
|
||||
tiny_http::ListenAddr::IP(addr) => addr,
|
||||
other => anyhow::bail!("HTTP server bound to a non-IP address: {other:?}"),
|
||||
};
|
||||
|
||||
let server = Arc::new(server);
|
||||
let token = random_token();
|
||||
|
||||
for _ in 0..WORKER_THREADS {
|
||||
let server = Arc::clone(&server);
|
||||
let root = root.clone();
|
||||
let token = token.clone();
|
||||
std::thread::spawn(move || {
|
||||
while let Ok(request) = server.recv() {
|
||||
if let Err(e) = handle_request(request, &root, &token) {
|
||||
tracing::warn!(error = %e, "HLS HTTP request failed");
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self { addr, token })
|
||||
}
|
||||
|
||||
/// The bound address, e.g. `0.0.0.0:41823`. Combine with this
|
||||
/// machine's LAN IP (not `0.0.0.0` itself) to build the URL handed to
|
||||
/// the Cast device — `0.0.0.0` only means anything to sockets on this
|
||||
/// host.
|
||||
pub fn addr(&self) -> SocketAddr {
|
||||
self.addr
|
||||
}
|
||||
|
||||
/// Builds a full URL for `relative` (e.g. `"playlist.m3u8"`), rooted at
|
||||
/// `host` (this machine's LAN-reachable IP — see [`HttpServer::addr`]'s
|
||||
/// doc for why that can't just be `self.addr()`), including the
|
||||
/// unguessable path token every request must carry.
|
||||
pub fn url(&self, host: std::net::IpAddr, relative: &str) -> String {
|
||||
format!("http://{host}:{}/{}/{relative}", self.addr.port(), self.token)
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates a 32-hex-character unguessable token from `/dev/urandom`. This
|
||||
/// is a Linux-only project already (PipeWire, Hyprland's portal, VA-API) so
|
||||
/// reaching for the platform's random device directly is fine — no `rand`
|
||||
/// crate dependency for one call site.
|
||||
fn random_token() -> String {
|
||||
let mut bytes = [0u8; 16];
|
||||
let read_ok = std::fs::File::open("/dev/urandom")
|
||||
.and_then(|mut f| {
|
||||
use std::io::Read;
|
||||
f.read_exact(&mut bytes)
|
||||
})
|
||||
.is_ok();
|
||||
if !read_ok {
|
||||
// Unreachable in practice on Linux, but better than a zero-entropy
|
||||
// token if it ever happened.
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut hasher = std::collections::hash_map::DefaultHasher::new();
|
||||
std::time::SystemTime::now().hash(&mut hasher);
|
||||
std::process::id().hash(&mut hasher);
|
||||
bytes[..8].copy_from_slice(&hasher.finish().to_le_bytes());
|
||||
}
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Parses a single-range `Range: bytes=start-end` header value against a
|
||||
/// body of `len` bytes. Returns `None` for anything absent, malformed, or
|
||||
/// multi-range (multipart ranges aren't needed for HLS segment fetches, and
|
||||
/// falling back to a full 200 response for those is always a valid
|
||||
/// response under the HTTP spec).
|
||||
fn parse_range(value: &str, len: usize) -> Option<(usize, usize)> {
|
||||
let spec = value.strip_prefix("bytes=")?;
|
||||
if spec.contains(',') || len == 0 {
|
||||
return None;
|
||||
}
|
||||
let (start, end) = spec.split_once('-')?;
|
||||
let last = len - 1;
|
||||
match (start.trim(), end.trim()) {
|
||||
("", "") => None,
|
||||
("", suffix_len) => {
|
||||
let n: usize = suffix_len.parse().ok()?;
|
||||
Some((len.saturating_sub(n), last))
|
||||
}
|
||||
(start, "") => {
|
||||
let start: usize = start.parse().ok()?;
|
||||
Some((start, last))
|
||||
}
|
||||
(start, end) => {
|
||||
let start: usize = start.parse().ok()?;
|
||||
let end: usize = end.parse().ok()?;
|
||||
Some((start, end))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_request(request: tiny_http::Request, root: &Path, token: &str) -> Result<()> {
|
||||
// `Split::next()` on a non-empty pattern always yields at least one
|
||||
// item, so this never actually hits a `None` case.
|
||||
let url_path = request.url().split('?').next().expect("split always yields at least one item").to_string();
|
||||
let remote = request
|
||||
.remote_addr()
|
||||
.map(|a| a.to_string())
|
||||
.unwrap_or_else(|| "?".to_string());
|
||||
|
||||
// Reject anything not under the unguessable token prefix before even
|
||||
// touching the filesystem — see the struct docs for why this exists.
|
||||
let Some(relative) = url_path
|
||||
.trim_start_matches('/')
|
||||
.strip_prefix(token)
|
||||
.and_then(|rest| rest.strip_prefix('/'))
|
||||
else {
|
||||
tracing::info!(%remote, path = %url_path, status = 404, "HLS request (bad or missing path token)");
|
||||
return respond_status(request, 404);
|
||||
};
|
||||
|
||||
// Reject any path that could escape `root` (e.g. `../../etc/passwd`) —
|
||||
// the URL is attacker-controlled input the moment this server is
|
||||
// reachable from the LAN, which it is by design (the Cast device is a
|
||||
// different host). `root` itself is canonicalized once in `start()`, so
|
||||
// this comparison is meaningful even if `root` was originally relative
|
||||
// or contained a symlinked component — comparing a canonical path
|
||||
// against a non-canonical one would make `starts_with` spuriously fail
|
||||
// and 404 every request.
|
||||
let requested = root.join(relative);
|
||||
let Ok(canonical) = requested.canonicalize() else {
|
||||
tracing::info!(%remote, path = %url_path, status = 404, "HLS request (not found)");
|
||||
return respond_status(request, 404);
|
||||
};
|
||||
if !canonical.starts_with(root) || !canonical.is_file() {
|
||||
tracing::info!(%remote, path = %url_path, status = 404, "HLS request (outside root or not a file)");
|
||||
return respond_status(request, 404);
|
||||
}
|
||||
|
||||
let data = match std::fs::read(&canonical) {
|
||||
Ok(data) => data,
|
||||
Err(_) => {
|
||||
// hlssink3 rotates out old segments (`max-files`) concurrently
|
||||
// with requests for them — a file that existed at
|
||||
// canonicalize() above but is gone by the time it's read is
|
||||
// routine for a live stream, not a server fault. Answer with a
|
||||
// normal 404 (a receiver skips it and asks for the next
|
||||
// segment) instead of letting the request drop unanswered,
|
||||
// which tiny_http turns into an unexplained bare 500.
|
||||
tracing::info!(%remote, path = %url_path, status = 404, "HLS request (file removed before read, likely segment rotation)");
|
||||
return respond_status(request, 404);
|
||||
}
|
||||
};
|
||||
|
||||
let extension = canonical.extension().and_then(|e| e.to_str());
|
||||
let content_type = match extension {
|
||||
Some("m3u8") => "application/vnd.apple.mpegurl",
|
||||
Some("ts") => "video/mp2t",
|
||||
_ => "application/octet-stream",
|
||||
};
|
||||
// The playlist is rewritten in place on every segment — must never be
|
||||
// cached, or a receiver/intermediary replaying a stale copy stalls
|
||||
// playback with no error anywhere. Segments are written once under a
|
||||
// unique numbered filename and never modified after that, so they're
|
||||
// safe to cache aggressively.
|
||||
let cache_control = match extension {
|
||||
Some("m3u8") => "no-cache, no-store, must-revalidate",
|
||||
_ => "public, max-age=3600, immutable",
|
||||
};
|
||||
|
||||
let range = request
|
||||
.headers()
|
||||
.iter()
|
||||
.find(|h| h.field.equiv("Range"))
|
||||
.and_then(|h| parse_range(h.value.as_str(), data.len()));
|
||||
|
||||
let mut headers = vec![
|
||||
("Content-Type".to_string(), content_type.to_string()),
|
||||
("Cache-Control".to_string(), cache_control.to_string()),
|
||||
("Access-Control-Allow-Origin".to_string(), "*".to_string()),
|
||||
("Access-Control-Allow-Headers".to_string(), "Range, Accept-Encoding".to_string()),
|
||||
("Access-Control-Expose-Headers".to_string(), "Content-Length, Content-Range".to_string()),
|
||||
("Accept-Ranges".to_string(), "bytes".to_string()),
|
||||
];
|
||||
|
||||
let (status, body) = match range {
|
||||
Some((start, end)) if start <= end && end < data.len() => {
|
||||
headers.push(("Content-Range".to_string(), format!("bytes {start}-{end}/{}", data.len())));
|
||||
(206u16, data[start..=end].to_vec())
|
||||
}
|
||||
Some(_) => {
|
||||
headers.push(("Content-Range".to_string(), format!("bytes */{}", data.len())));
|
||||
tracing::info!(%remote, path = %url_path, status = 416, "HLS request (unsatisfiable range)");
|
||||
return respond(request, 416, Vec::new(), &headers);
|
||||
}
|
||||
None => (200u16, data),
|
||||
};
|
||||
|
||||
tracing::info!(%remote, path = %url_path, status, "HLS request");
|
||||
respond(request, status, body, &headers)
|
||||
}
|
||||
|
||||
fn respond(request: tiny_http::Request, status: u16, body: Vec<u8>, headers: &[(String, String)]) -> Result<()> {
|
||||
let mut response = tiny_http::Response::from_data(body).with_status_code(status);
|
||||
for (name, value) in headers {
|
||||
if let Ok(header) = tiny_http::Header::from_bytes(name.as_bytes(), value.as_bytes()) {
|
||||
response.add_header(header);
|
||||
}
|
||||
}
|
||||
request.respond(response).context("failed to write HTTP response")
|
||||
}
|
||||
|
||||
fn respond_status(request: tiny_http::Request, status: u16) -> Result<()> {
|
||||
request
|
||||
.respond(tiny_http::Response::empty(status))
|
||||
.context("failed to write HTTP error response")
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue