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
3
breadcast-core/src/capture/mod.rs
Normal file
3
breadcast-core/src/capture/mod.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
pub mod portal;
|
||||
|
||||
pub use portal::CaptureSession;
|
||||
144
breadcast-core/src/capture/portal.rs
Normal file
144
breadcast-core/src/capture/portal.rs
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
use std::os::unix::fs::{DirBuilderExt, OpenOptionsExt};
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use ashpd::desktop::PersistMode;
|
||||
use ashpd::desktop::Session;
|
||||
use ashpd::desktop::screencast::{CursorMode, Screencast, SelectSourcesOptions, SourceType};
|
||||
|
||||
/// Where the portal's restore token is cached, so re-mirroring doesn't
|
||||
/// require re-clicking the system picker every single time — the token
|
||||
/// (opaque to us) is what lets a later `SelectSources` call skip straight
|
||||
/// to "yes, the same source as last time" instead of prompting again.
|
||||
///
|
||||
/// Filters out an *empty* `XDG_CACHE_HOME` in addition to an unset one —
|
||||
/// some environments export it but leave it blank, which would otherwise
|
||||
/// resolve to a relative path against the current working directory.
|
||||
fn restore_token_path() -> PathBuf {
|
||||
let base = std::env::var("XDG_CACHE_HOME")
|
||||
.ok()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| {
|
||||
PathBuf::from(std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string())).join(".cache")
|
||||
});
|
||||
base.join("breadcast").join("portal-restore-token")
|
||||
}
|
||||
|
||||
/// A live `xdg-desktop-portal` ScreenCast session. Keeping this alive keeps
|
||||
/// the underlying PipeWire stream(s) open; dropping it without calling
|
||||
/// [`CaptureSession::close`] leaves the portal to notice the D-Bus
|
||||
/// connection went away rather than an explicit teardown.
|
||||
pub struct CaptureSession {
|
||||
session: Session<Screencast>,
|
||||
video_node_id: u32,
|
||||
}
|
||||
|
||||
impl CaptureSession {
|
||||
/// Opens the portal's screen-cast picker (monitor/window selection is
|
||||
/// the portal's own native UI — see `xdg-desktop-portal-hyprland`'s own
|
||||
/// picker dialog — not anything breadcast draws itself) and returns a
|
||||
/// session bound to whatever the user picked.
|
||||
///
|
||||
/// `CursorMode::Embedded` bakes the cursor into the captured frames,
|
||||
/// which is what you want for "mirror my screen" (as opposed to
|
||||
/// `Metadata`, meant for apps that composite their own cursor).
|
||||
pub async fn start() -> Result<Self> {
|
||||
let proxy = Screencast::new()
|
||||
.await
|
||||
.context("failed to connect to the ScreenCast portal (is xdg-desktop-portal running?)")?;
|
||||
|
||||
let session = proxy
|
||||
.create_session(Default::default())
|
||||
.await
|
||||
.context("failed to create a portal screencast session")?;
|
||||
|
||||
// Everything past this point can fail (denied/cancelled picker, no
|
||||
// streams, etc.) — ashpd's `Session` has no `Drop` impl, so on any
|
||||
// of those paths the session would otherwise leak for the rest of
|
||||
// this process's life (and in a long-running daemon, potentially a
|
||||
// leaked PipeWire node per cancelled picker). Route every error
|
||||
// through an explicit close instead of an early `?` return.
|
||||
match Self::negotiate(&proxy, &session).await {
|
||||
Ok(video_node_id) => Ok(Self { session, video_node_id }),
|
||||
Err(e) => {
|
||||
let _ = session.close().await;
|
||||
Err(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn negotiate(proxy: &Screencast, session: &Session<Screencast>) -> Result<u32> {
|
||||
let token_path = restore_token_path();
|
||||
let existing_token = std::fs::read_to_string(&token_path).ok();
|
||||
|
||||
let mut select_options = SelectSourcesOptions::default()
|
||||
.set_cursor_mode(CursorMode::Embedded)
|
||||
.set_sources(SourceType::Monitor | SourceType::Window)
|
||||
.set_multiple(false)
|
||||
.set_persist_mode(PersistMode::ExplicitlyRevoked);
|
||||
if let Some(token) = existing_token.as_deref() {
|
||||
select_options = select_options.set_restore_token(token);
|
||||
}
|
||||
|
||||
proxy
|
||||
.select_sources(session, select_options)
|
||||
.await
|
||||
.context("failed to send SelectSources to the portal")?
|
||||
.response()
|
||||
.context("SelectSources request was denied or cancelled")?;
|
||||
|
||||
let response = proxy
|
||||
.start(session, None, Default::default())
|
||||
.await
|
||||
.context("failed to send Start to the portal")?
|
||||
.response()
|
||||
.context("screen cast was cancelled (user closed the portal picker)")?;
|
||||
|
||||
// Persist whatever token came back so the *next* start() can skip
|
||||
// the picker. A stale/invalid token is not a failure mode to guard
|
||||
// against here — the portal falls back to prompting again on its
|
||||
// own if the token no longer resolves to a valid grant.
|
||||
//
|
||||
// The token is a no-prompt capability to re-open a screen capture
|
||||
// of this user's session, so it's written 0600 in a 0700 directory
|
||||
// rather than relying on umask — any other local user being able to
|
||||
// read it would let them silently re-grant themselves the same
|
||||
// capture access.
|
||||
if let Some(token) = response.restore_token() {
|
||||
if let Some(parent) = token_path.parent() {
|
||||
let _ = std::fs::DirBuilder::new().recursive(true).mode(0o700).create(parent);
|
||||
}
|
||||
if let Ok(mut file) = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.truncate(true)
|
||||
.mode(0o600)
|
||||
.open(&token_path)
|
||||
{
|
||||
use std::io::Write;
|
||||
let _ = file.write_all(token.as_bytes());
|
||||
}
|
||||
}
|
||||
|
||||
let stream = response
|
||||
.streams()
|
||||
.first()
|
||||
.context("portal returned zero streams")?;
|
||||
Ok(stream.pipe_wire_node_id())
|
||||
}
|
||||
|
||||
/// The PipeWire node id for the selected video source — this is what
|
||||
/// gets passed to GStreamer's `pipewiresrc path=<id>`.
|
||||
pub fn video_node_id(&self) -> u32 {
|
||||
self.video_node_id
|
||||
}
|
||||
|
||||
/// Explicitly closes the portal session, ending the PipeWire stream.
|
||||
pub async fn close(self) -> Result<()> {
|
||||
self.session
|
||||
.close()
|
||||
.await
|
||||
.context("failed to close the portal screencast session")
|
||||
}
|
||||
}
|
||||
335
breadcast-core/src/cast_sender.rs
Normal file
335
breadcast-core/src/cast_sender.rs
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
use std::sync::mpsc;
|
||||
use std::thread;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use rust_cast::{
|
||||
CastDevice as RustCastDevice, ChannelMessage,
|
||||
channels::{
|
||||
heartbeat::HeartbeatResponse,
|
||||
media::{Media, MediaResponse, Status, StreamType},
|
||||
receiver::CastDeviceApp,
|
||||
},
|
||||
message_manager::CastMessagePayload,
|
||||
};
|
||||
|
||||
use crate::device::CastDevice;
|
||||
|
||||
enum Command {
|
||||
Load {
|
||||
content_url: String,
|
||||
content_type: String,
|
||||
stream_type: StreamType,
|
||||
reply: mpsc::Sender<Result<Status>>,
|
||||
},
|
||||
/// Sends a message on an arbitrary namespace to the launched app's
|
||||
/// transport id — used for the Cast Streaming OFFER/ANSWER exchange
|
||||
/// (see [`crate::caststream`]), which the built-in `media`/`receiver`
|
||||
/// channels have no support for. Routed through this session's io
|
||||
/// thread rather than sent directly, for the same reason `Load`/`Stop`
|
||||
/// are: `rust_cast`'s `MessageManager` has no way to demultiplex
|
||||
/// responses across threads (see this struct's own doc comment).
|
||||
SendRaw {
|
||||
namespace: String,
|
||||
message: String,
|
||||
reply: mpsc::Sender<Result<()>>,
|
||||
},
|
||||
Stop {
|
||||
reply: mpsc::Sender<Result<()>>,
|
||||
},
|
||||
}
|
||||
|
||||
/// An unsolicited message received on a namespace none of `rust_cast`'s
|
||||
/// built-in channels claim (`ChannelMessage::Raw`) — e.g. an ANSWER on Cast
|
||||
/// Streaming's `urn:x-cast:com.google.cast.webrtc` namespace. Binary
|
||||
/// payloads are dropped (Cast Streaming's control messages are always JSON
|
||||
/// text; nothing in this project's protocol usage sends binary here).
|
||||
pub struct RawMessage {
|
||||
pub source_id: String,
|
||||
pub namespace: String,
|
||||
pub message: String,
|
||||
}
|
||||
|
||||
/// The stream of [`RawMessage`]s received on namespaces
|
||||
/// [`CastSession::connect`]'s default app (the Default Media Receiver)
|
||||
/// never sends, but a custom-app session started via
|
||||
/// [`CastSession::connect_app`] (e.g. the Cast Streaming Mirroring receiver)
|
||||
/// does. Draining this is required for such sessions to make any progress —
|
||||
/// see `cast_stream_test.rs`.
|
||||
pub struct RawMessages {
|
||||
rx: mpsc::Receiver<RawMessage>,
|
||||
}
|
||||
|
||||
impl RawMessages {
|
||||
pub fn recv(&self) -> Option<RawMessage> {
|
||||
self.rx.recv().ok()
|
||||
}
|
||||
}
|
||||
|
||||
/// A live CASTV2 session to a device: connected, Default Media Receiver
|
||||
/// launched, ready to load media.
|
||||
///
|
||||
/// All device I/O happens on a single dedicated thread spawned by
|
||||
/// [`CastSession::connect`] — `rust_cast`'s `MessageManager` has no way to
|
||||
/// demultiplex responses by request id across threads. `load()`/`stop()`
|
||||
/// each do their own blocking read internally (`receive_find_map`); if a
|
||||
/// *different* thread were concurrently blocked in `device.receive()` (e.g.
|
||||
/// pumping heartbeats/media status, as a previous version of this struct
|
||||
/// did), whichever thread's read happens to be in flight when a response
|
||||
/// arrives "wins" it — the other blocks forever waiting for a response that
|
||||
/// was already consumed and handled elsewhere. Routing every command and
|
||||
/// every unsolicited message through one thread's loop avoids that race
|
||||
/// entirely. `CastSession` is `Clone` (cheap: just clones a channel sender)
|
||||
/// so multiple callers can issue commands concurrently without needing
|
||||
/// `&mut` or reintroducing the race.
|
||||
#[derive(Clone)]
|
||||
pub struct CastSession {
|
||||
command_tx: mpsc::Sender<Command>,
|
||||
/// The launched app's transport id — also its CASTV2 destination id on
|
||||
/// namespaces outside the built-in channels (see
|
||||
/// [`Self::send_raw_message`]/[`crate::caststream`]).
|
||||
transport_id: String,
|
||||
}
|
||||
|
||||
/// The stream of unsolicited `MediaResponse` messages (player-state
|
||||
/// transitions, load failures, etc.) pushed by the device — separate from
|
||||
/// [`CastSession`] so a caller can hold one "watch what's happening" handle
|
||||
/// alongside any number of cheap [`CastSession`] clones used to issue
|
||||
/// commands, without either needing exclusive access.
|
||||
pub struct MediaEvents {
|
||||
rx: mpsc::Receiver<MediaResponse>,
|
||||
}
|
||||
|
||||
impl MediaEvents {
|
||||
/// Blocks until the next `MediaResponse`, or returns `None` once the
|
||||
/// session's io thread has ended (device disconnected, session
|
||||
/// stopped, or a fatal receive error).
|
||||
pub fn recv(&self) -> Option<MediaResponse> {
|
||||
self.rx.recv().ok()
|
||||
}
|
||||
|
||||
/// Like [`Self::recv`] in a loop, invoking `on_media` for each message
|
||||
/// until the session ends. Useful for interactive smoke testing.
|
||||
pub fn pump_with_media_callback(&self, mut on_media: impl FnMut(&MediaResponse)) {
|
||||
while let Some(media) = self.recv() {
|
||||
on_media(&media);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl CastSession {
|
||||
/// Connects to `target`, launches the Default Media Receiver, and spawns
|
||||
/// the session's io thread (which immediately starts servicing
|
||||
/// heartbeats on its own — a caller no longer needs to run anything just
|
||||
/// to keep the connection alive). Returns a `CastSession` for issuing
|
||||
/// commands plus a `MediaEvents` for observing player state.
|
||||
///
|
||||
/// Uses `connect_without_host_verification`: Cast receivers present a
|
||||
/// self-signed certificate by design (every Cast sender, including
|
||||
/// Google's own, connects this way) — this is not a shortcut to harden
|
||||
/// later, verifying against a CA chain will simply never succeed here.
|
||||
pub fn connect(target: &CastDevice) -> Result<(Self, MediaEvents)> {
|
||||
let (session, media, _raw) = Self::connect_app(target, CastDeviceApp::DefaultMediaReceiver)?;
|
||||
Ok((session, media))
|
||||
}
|
||||
|
||||
/// Like [`Self::connect`], but launches an arbitrary app (e.g.
|
||||
/// [`crate::caststream::MIRRORING_APP_ID`] via
|
||||
/// `CastDeviceApp::Custom(MIRRORING_APP_ID.to_string())`) and also
|
||||
/// returns a [`RawMessages`] stream for namespaces the built-in channels
|
||||
/// don't claim — required for anything beyond the Default Media
|
||||
/// Receiver's `media`/`receiver` namespaces, e.g. Cast Streaming's
|
||||
/// OFFER/ANSWER exchange.
|
||||
pub fn connect_app(target: &CastDevice, app: CastDeviceApp) -> Result<(Self, MediaEvents, RawMessages)> {
|
||||
let device: RustCastDevice<'static> =
|
||||
RustCastDevice::connect_without_host_verification(target.host.clone(), target.port)
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))
|
||||
.with_context(|| format!("failed to connect to {} ({}:{})", target.name, target.host, target.port))?;
|
||||
|
||||
device
|
||||
.connection
|
||||
.connect("receiver-0")
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))
|
||||
.context("failed to open the receiver-0 connection")?;
|
||||
|
||||
let launched = device
|
||||
.receiver
|
||||
.launch_app(&app)
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))
|
||||
.with_context(|| format!("failed to launch app {app:?}"))?;
|
||||
|
||||
device
|
||||
.connection
|
||||
.connect(launched.transport_id.as_str())
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))
|
||||
.context("failed to open the app transport connection")?;
|
||||
|
||||
let (command_tx, command_rx) = mpsc::channel();
|
||||
let (media_tx, media_rx) = mpsc::channel();
|
||||
let (raw_tx, raw_rx) = mpsc::channel();
|
||||
|
||||
let transport_id = launched.transport_id.clone();
|
||||
thread::spawn(move || {
|
||||
run_io_loop(device, launched.transport_id, launched.session_id, command_rx, media_tx, raw_tx)
|
||||
});
|
||||
|
||||
Ok((
|
||||
Self { command_tx, transport_id },
|
||||
MediaEvents { rx: media_rx },
|
||||
RawMessages { rx: raw_rx },
|
||||
))
|
||||
}
|
||||
|
||||
/// The launched app's transport id — the CASTV2 destination id to use
|
||||
/// with [`Self::send_raw_message`] and, equivalently, as the
|
||||
/// `receiver_id` a [`crate::caststream::CastStreamSender`] targets.
|
||||
pub fn transport_id(&self) -> &str {
|
||||
&self.transport_id
|
||||
}
|
||||
|
||||
/// Sends `message` on `namespace` to the launched app's transport id.
|
||||
/// Blocks until the session's io thread has handed it off to
|
||||
/// `rust_cast` (not until any reply — Cast Streaming's ANSWER, for
|
||||
/// example, arrives later as a [`RawMessage`] on the [`RawMessages`]
|
||||
/// stream, not as this call's return value).
|
||||
pub fn send_raw_message(&self, namespace: &str, message: &str) -> Result<()> {
|
||||
let (reply_tx, reply_rx) = mpsc::channel();
|
||||
self.command_tx
|
||||
.send(Command::SendRaw {
|
||||
namespace: namespace.to_string(),
|
||||
message: message.to_string(),
|
||||
reply: reply_tx,
|
||||
})
|
||||
.map_err(|_| anyhow::anyhow!("cast session io thread has already ended"))?;
|
||||
reply_rx
|
||||
.recv()
|
||||
.map_err(|_| anyhow::anyhow!("cast session io thread ended before replying to send_raw_message"))?
|
||||
}
|
||||
|
||||
/// Loads `content_url` for playback. `content_type` is the MIME type
|
||||
/// (e.g. `"video/mp4"` for a one-shot file, `"application/vnd.apple.mpegurl"`
|
||||
/// for an HLS stream). Blocks until the device acknowledges the load or
|
||||
/// the session's io thread ends.
|
||||
pub fn load(&self, content_url: &str, content_type: &str, stream_type: StreamType) -> Result<Status> {
|
||||
let (reply_tx, reply_rx) = mpsc::channel();
|
||||
self.command_tx
|
||||
.send(Command::Load {
|
||||
content_url: content_url.to_string(),
|
||||
content_type: content_type.to_string(),
|
||||
stream_type,
|
||||
reply: reply_tx,
|
||||
})
|
||||
.map_err(|_| anyhow::anyhow!("cast session io thread has already ended"))?;
|
||||
reply_rx
|
||||
.recv()
|
||||
.map_err(|_| anyhow::anyhow!("cast session io thread ended before replying to load"))?
|
||||
}
|
||||
|
||||
/// Stops the receiver app and disconnects, ending the session (and its
|
||||
/// io thread) cleanly. Without this, the TV is left showing a frozen
|
||||
/// last frame indefinitely after the sender process exits — there is no
|
||||
/// `Drop` impl doing this automatically because it needs a round trip
|
||||
/// with the device that can fail, and silently swallowing that on drop
|
||||
/// would hide exactly the kind of failure this project has already lost
|
||||
/// a lot of time chasing blind.
|
||||
pub fn stop(&self) -> Result<()> {
|
||||
let (reply_tx, reply_rx) = mpsc::channel();
|
||||
if self.command_tx.send(Command::Stop { reply: reply_tx }).is_err() {
|
||||
return Ok(()); // io thread already ended — nothing left to stop
|
||||
}
|
||||
reply_rx
|
||||
.recv()
|
||||
.map_err(|_| anyhow::anyhow!("cast session io thread ended before replying to stop"))?
|
||||
}
|
||||
}
|
||||
|
||||
/// Owns the device connection for the session's lifetime, on its own
|
||||
/// thread. Interleaves servicing queued commands (each a blocking
|
||||
/// request/response round trip via `rust_cast`'s internals — safe here
|
||||
/// since this is the only thread ever calling into `device`) with draining
|
||||
/// unsolicited messages (heartbeat ping -> pong, media status -> forwarded
|
||||
/// to `media_tx`).
|
||||
///
|
||||
/// Commands are only picked up between `device.receive()` calls, so
|
||||
/// worst-case latency to service one is bounded by how often the device
|
||||
/// pushes a message — in practice the receiver's own heartbeat ping (every
|
||||
/// few seconds), not "forever": `rust_cast` gives no way to set a read
|
||||
/// timeout on the underlying stream to poll more eagerly than that.
|
||||
fn run_io_loop(
|
||||
device: RustCastDevice<'static>,
|
||||
transport_id: String,
|
||||
session_id: String,
|
||||
command_rx: mpsc::Receiver<Command>,
|
||||
media_tx: mpsc::Sender<MediaResponse>,
|
||||
raw_tx: mpsc::Sender<RawMessage>,
|
||||
) {
|
||||
loop {
|
||||
match command_rx.try_recv() {
|
||||
Ok(Command::Load { content_url, content_type, stream_type, reply }) => {
|
||||
let media = Media {
|
||||
content_id: content_url,
|
||||
stream_type,
|
||||
content_type,
|
||||
metadata: None,
|
||||
duration: None,
|
||||
};
|
||||
let result = device
|
||||
.media
|
||||
.load(transport_id.as_str(), session_id.as_str(), &media)
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))
|
||||
.context("failed to load media");
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
Ok(Command::SendRaw { namespace, message, reply }) => {
|
||||
let result = device
|
||||
.send_message(&namespace, transport_id.as_str(), &message)
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))
|
||||
.context("failed to send raw message");
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
Ok(Command::Stop { reply }) => {
|
||||
let result = device
|
||||
.receiver
|
||||
.stop_app(session_id.as_str())
|
||||
.map_err(|e| anyhow::anyhow!("{e}"))
|
||||
.context("failed to stop the receiver app");
|
||||
let _ = device.connection.disconnect(transport_id.as_str());
|
||||
let _ = reply.send(result);
|
||||
return; // session over — end the io thread
|
||||
}
|
||||
Err(mpsc::TryRecvError::Empty) => {}
|
||||
Err(mpsc::TryRecvError::Disconnected) => return, // every CastSession clone dropped
|
||||
}
|
||||
|
||||
match device.receive() {
|
||||
// Only pong an actual PING — matching every `Heartbeat(_)`
|
||||
// variant (as a previous version did) would also fire on PONGs,
|
||||
// which is harmless only by accident today since nothing here
|
||||
// sends its own PING yet.
|
||||
Ok(ChannelMessage::Heartbeat(HeartbeatResponse::Ping)) => {
|
||||
if device.heartbeat.pong().is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Ok(ChannelMessage::Heartbeat(_)) => {}
|
||||
Ok(ChannelMessage::Media(media_response)) => {
|
||||
let _ = media_tx.send(media_response); // no listener is fine — nobody's watching
|
||||
}
|
||||
Ok(ChannelMessage::Raw(msg)) => {
|
||||
if let CastMessagePayload::String(message) = msg.payload {
|
||||
let _ = raw_tx.send(RawMessage {
|
||||
source_id: msg.source,
|
||||
namespace: msg.namespace,
|
||||
message,
|
||||
});
|
||||
}
|
||||
// Binary payloads are silently dropped — see `RawMessage`'s
|
||||
// doc comment for why that's fine for this project's usage.
|
||||
}
|
||||
Ok(_) => {}
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, "cast session io loop ending: receive error");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
292
breadcast-core/src/caststream.rs
Normal file
292
breadcast-core/src/caststream.rs
Normal file
|
|
@ -0,0 +1,292 @@
|
|||
//! Safe wrapper over `breadcast-caststream-sys`'s raw FFI to the vendored
|
||||
//! openscreen Cast Streaming sender — the same low-latency mirroring
|
||||
//! protocol Chrome's tab/desktop casting uses (unlike [`crate::cast_sender`]'s
|
||||
//! HLS approach, which targets the Default Media Receiver instead). See
|
||||
//! `breadcast-caststream-sys/vendor/openscreen/PATCHES.md` for how the
|
||||
//! vendored C++ this wraps was built.
|
||||
//!
|
||||
//! This does *not* replicate [`CastSession`](crate::cast_sender::CastSession)'s
|
||||
//! own single-io-thread actor pattern internally — the underlying C++ already
|
||||
//! runs its own dedicated TaskRunner/networking threads (see `facade.h`'s
|
||||
//! threading contract), so every method here just marshals across FFI rather
|
||||
//! than through a Rust-owned loop. What *does* need a Rust-side thread is
|
||||
//! draining [`CastStreamEvents`] and forwarding [`CastStreamEvent::OutboundMessage`]
|
||||
//! over the existing CASTV2 connection — see `cast_stream_test.rs` for the
|
||||
//! intended pattern (pump events on one thread, call `on_message`/
|
||||
//! `enqueue_frame` from others).
|
||||
|
||||
use std::ffi::c_void;
|
||||
use std::os::raw::c_char;
|
||||
use std::sync::mpsc;
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use breadcast_caststream_sys::{
|
||||
self as sys, breadcast_caststream_sender_create, breadcast_caststream_sender_destroy,
|
||||
breadcast_caststream_sender_enqueue_frame, breadcast_caststream_sender_estimated_bandwidth_bps,
|
||||
breadcast_caststream_sender_needs_key_frame, breadcast_caststream_sender_negotiate,
|
||||
breadcast_caststream_sender_on_message,
|
||||
};
|
||||
|
||||
/// The Cast Streaming ("Mirroring") receiver app id, pre-installed on every
|
||||
/// Chromecast/Google TV — distinct from [`rust_cast::channels::receiver::CastDeviceApp::DefaultMediaReceiver`]'s
|
||||
/// `CC1AD845`, which is what [`crate::cast_sender::CastSession`] launches for
|
||||
/// the HLS path.
|
||||
pub const MIRRORING_APP_ID: &str = "0F5096E8";
|
||||
|
||||
/// The CASTV2 namespace Cast Streaming's OFFER/ANSWER exchange runs on.
|
||||
pub const WEBRTC_NAMESPACE: &str = "urn:x-cast:com.google.cast.webrtc";
|
||||
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct VideoParams {
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
pub max_bitrate_bps: i32,
|
||||
pub max_frame_rate_numerator: i32,
|
||||
pub max_frame_rate_denominator: i32,
|
||||
}
|
||||
|
||||
impl Default for VideoParams {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
width: 1920,
|
||||
height: 1080,
|
||||
max_bitrate_bps: 8_000_000,
|
||||
max_frame_rate_numerator: 30,
|
||||
max_frame_rate_denominator: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Events pushed from the underlying C++ TaskRunner thread — see this
|
||||
/// module's doc comment on why a Rust-owned pump loop is still needed even
|
||||
/// though the FFI layer runs its own threads.
|
||||
#[derive(Debug)]
|
||||
pub enum CastStreamEvent {
|
||||
/// The C++ side needs this JSON `message` sent to `destination_id` on
|
||||
/// [`WEBRTC_NAMESPACE`] over the existing CASTV2 connection (e.g. the
|
||||
/// OFFER). The caller is expected to do that via
|
||||
/// `rust_cast::CastDevice::send_message` (see the `send_message` patch
|
||||
/// documented in `vendor/rust_cast-0.21.0/PATCHES.md`).
|
||||
OutboundMessage { destination_id: String, message: String },
|
||||
/// OFFER/ANSWER negotiation succeeded; `enqueue_frame` will now accept
|
||||
/// frames.
|
||||
Negotiated,
|
||||
/// A negotiation or session error occurred.
|
||||
Error(String),
|
||||
/// The receiver reported picture loss and wants a key frame ASAP (also
|
||||
/// obtainable via the pull-style [`CastStreamSender::needs_key_frame`]).
|
||||
PictureLost,
|
||||
}
|
||||
|
||||
struct CallbackContext {
|
||||
events_tx: mpsc::Sender<CastStreamEvent>,
|
||||
}
|
||||
|
||||
/// A live Cast Streaming sender session. See the module doc comment for the
|
||||
/// threading model.
|
||||
pub struct CastStreamSender {
|
||||
raw: *mut sys::CastStreamSender,
|
||||
// Kept alive for `raw`'s lifetime -- its address is the FFI `user_data`
|
||||
// every callback trampoline below casts back. Never read directly
|
||||
// through this field; the callbacks access it via the raw pointer, so
|
||||
// this exists purely to own the allocation and free it (after
|
||||
// `destroy()`, in `Drop`) rather than leak it.
|
||||
_context: Box<CallbackContext>,
|
||||
}
|
||||
|
||||
// Safety: the underlying C++ handle has no thread-affinity for the FFI
|
||||
// entry points themselves (see facade.h's threading contract) -- every
|
||||
// `breadcast_caststream_sender_*` call internally marshals onto the
|
||||
// TaskRunner thread via `TaskRunner::PostTask`, which is documented
|
||||
// thread-safe regardless of caller thread. All methods below take `&self`
|
||||
// only (no interior mutation outside that marshaling), so concurrent calls
|
||||
// from multiple threads sharing an `Arc<CastStreamSender>` are as safe as
|
||||
// they are from a single thread -- hence `Sync` too, not just `Send`.
|
||||
unsafe impl Send for CastStreamSender {}
|
||||
unsafe impl Sync for CastStreamSender {}
|
||||
|
||||
impl CastStreamSender {
|
||||
/// Starts a Cast Streaming session targeting `remote_ip` (the same IP
|
||||
/// `rust_cast` already connected to for the CASTV2 control channel).
|
||||
/// `local_source_id`/`receiver_id` are the CASTV2 source/destination IDs
|
||||
/// to use on [`WEBRTC_NAMESPACE`] -- `receiver_id` should be the
|
||||
/// launched Mirroring app's `transport_id` (the same id
|
||||
/// `connection`/`media` channels already target), matching how every
|
||||
/// other namespace conversation with a launched app is addressed.
|
||||
///
|
||||
/// Returns the sender plus a receiver for [`CastStreamEvent`]s -- drain
|
||||
/// it on a dedicated thread; `OutboundMessage` events in particular need
|
||||
/// prompt forwarding for negotiation to make progress.
|
||||
pub fn start(
|
||||
remote_ip: &str,
|
||||
local_source_id: &str,
|
||||
receiver_id: &str,
|
||||
params: VideoParams,
|
||||
) -> Result<(Self, mpsc::Receiver<CastStreamEvent>)> {
|
||||
let (events_tx, events_rx) = mpsc::channel();
|
||||
let context = Box::into_raw(Box::new(CallbackContext { events_tx }));
|
||||
|
||||
let raw = unsafe {
|
||||
breadcast_caststream_sender_create(
|
||||
remote_ip.as_ptr() as *const c_char,
|
||||
remote_ip.len(),
|
||||
local_source_id.as_ptr() as *const c_char,
|
||||
local_source_id.len(),
|
||||
receiver_id.as_ptr() as *const c_char,
|
||||
receiver_id.len(),
|
||||
params.width,
|
||||
params.height,
|
||||
params.max_bitrate_bps,
|
||||
params.max_frame_rate_numerator,
|
||||
params.max_frame_rate_denominator,
|
||||
context as *mut c_void,
|
||||
post_message_trampoline,
|
||||
on_negotiated_trampoline,
|
||||
on_error_trampoline,
|
||||
on_picture_lost_trampoline,
|
||||
)
|
||||
};
|
||||
|
||||
if raw.is_null() {
|
||||
// SAFETY: `context` was created by the `Box::into_raw` above and
|
||||
// has not been handed to any live C++ object (create() failed
|
||||
// before storing it anywhere), so reclaiming and dropping it
|
||||
// here is the only way to avoid leaking it.
|
||||
drop(unsafe { Box::from_raw(context) });
|
||||
bail!("breadcast_caststream_sender_create failed (invalid remote_ip?)");
|
||||
}
|
||||
|
||||
// SAFETY: `context` was created by `Box::into_raw` immediately
|
||||
// above and its address was just handed to the C++ side as
|
||||
// `user_data` -- reconstructing the `Box` here doesn't move or free
|
||||
// the underlying allocation (only dropping it would), so the
|
||||
// pointer C++ holds stays valid for as long as this `Box` lives,
|
||||
// i.e. until `Drop` runs (after `destroy()`, see below).
|
||||
let context = unsafe { Box::from_raw(context) };
|
||||
|
||||
Ok((Self { raw, _context: context }, events_rx))
|
||||
}
|
||||
|
||||
/// Sends the OFFER and begins waiting for an ANSWER (delivered via
|
||||
/// [`Self::on_message`]). Completion is reported as a
|
||||
/// [`CastStreamEvent::Negotiated`] or [`CastStreamEvent::Error`] on the
|
||||
/// event receiver returned by [`Self::start`].
|
||||
pub fn negotiate(&self) {
|
||||
unsafe { breadcast_caststream_sender_negotiate(self.raw) };
|
||||
}
|
||||
|
||||
/// Delivers a message received on [`WEBRTC_NAMESPACE`] (e.g. the
|
||||
/// receiver's ANSWER) into the session.
|
||||
pub fn on_message(&self, source_id: &str, message_namespace: &str, message: &str) {
|
||||
unsafe {
|
||||
breadcast_caststream_sender_on_message(
|
||||
self.raw,
|
||||
source_id.as_ptr() as *const c_char,
|
||||
source_id.len(),
|
||||
message_namespace.as_ptr() as *const c_char,
|
||||
message_namespace.len(),
|
||||
message.as_ptr() as *const c_char,
|
||||
message.len(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Enqueues one encoded video access unit (Annex-B H.264) for sending.
|
||||
/// `capture_time_us` only needs to be monotonically increasing and
|
||||
/// proportional to real elapsed time between frames -- it does not need
|
||||
/// to be wall-clock-accurate.
|
||||
///
|
||||
/// Returns an error if the session isn't negotiated yet or the frame
|
||||
/// was rejected under backpressure; callers should treat the latter as
|
||||
/// a dropped frame, not a fatal condition (see
|
||||
/// [`Self::needs_key_frame`]/[`Self::estimated_bandwidth_bps`] for how
|
||||
/// to react).
|
||||
pub fn enqueue_frame(&self, data: &[u8], is_key_frame: bool, capture_time_us: i64) -> Result<()> {
|
||||
let result = unsafe {
|
||||
breadcast_caststream_sender_enqueue_frame(
|
||||
self.raw,
|
||||
data.as_ptr(),
|
||||
data.len(),
|
||||
is_key_frame as i32,
|
||||
capture_time_us,
|
||||
)
|
||||
};
|
||||
if result != 0 {
|
||||
bail!("frame not enqueued (session not negotiated yet)");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// True if the receiver wants a key frame as soon as possible. Cheap to
|
||||
/// poll frequently (e.g. once per captured frame, before encoding it).
|
||||
pub fn needs_key_frame(&self) -> bool {
|
||||
unsafe { breadcast_caststream_sender_needs_key_frame(self.raw) != 0 }
|
||||
}
|
||||
|
||||
/// Best-effort current bandwidth estimate in bits per second, meant to
|
||||
/// drive the video encoder's target bitrate -- this vendored subset of
|
||||
/// openscreen only does flow control, not congestion control. Cheap to
|
||||
/// poll frequently.
|
||||
pub fn estimated_bandwidth_bps(&self) -> i32 {
|
||||
unsafe { breadcast_caststream_sender_estimated_bandwidth_bps(self.raw) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CastStreamSender {
|
||||
fn drop(&mut self) {
|
||||
// Blocks until the C++ side's threads stop -- after this returns,
|
||||
// no more callbacks will fire, so it's safe for `_context` to be
|
||||
// freed right after (implicitly, as this struct finishes dropping).
|
||||
unsafe { breadcast_caststream_sender_destroy(self.raw) };
|
||||
}
|
||||
}
|
||||
|
||||
unsafe fn context_from_user_data<'a>(user_data: *mut c_void) -> &'a CallbackContext {
|
||||
// SAFETY: every callback below is only ever invoked by the C++ facade
|
||||
// with the exact `user_data` pointer passed into `sender_create`, which
|
||||
// is `_context`'s address for the lifetime of the owning
|
||||
// `CastStreamSender` (see its field doc comment) -- and per facade.h's
|
||||
// threading contract, no callback fires after `sender_destroy` returns,
|
||||
// which is also the last point `_context` could be dropped.
|
||||
unsafe { &*(user_data as *const CallbackContext) }
|
||||
}
|
||||
|
||||
unsafe fn str_from_raw_parts<'a>(ptr: *const c_char, len: usize) -> std::borrow::Cow<'a, str> {
|
||||
// SAFETY: every callback below documents (matching facade.h) that these
|
||||
// buffers are borrowed and valid only for the duration of the call --
|
||||
// this is called synchronously within that window, and the result is
|
||||
// copied (via `.into_owned()` at each call site) before returning.
|
||||
let bytes = unsafe { std::slice::from_raw_parts(ptr as *const u8, len) };
|
||||
String::from_utf8_lossy(bytes)
|
||||
}
|
||||
|
||||
extern "C" fn post_message_trampoline(
|
||||
user_data: *mut c_void,
|
||||
destination_id: *const c_char,
|
||||
destination_id_len: usize,
|
||||
_message_namespace: *const c_char,
|
||||
_message_namespace_len: usize,
|
||||
message: *const c_char,
|
||||
message_len: usize,
|
||||
) {
|
||||
let ctx = unsafe { context_from_user_data(user_data) };
|
||||
let destination_id = unsafe { str_from_raw_parts(destination_id, destination_id_len) }.into_owned();
|
||||
let message = unsafe { str_from_raw_parts(message, message_len) }.into_owned();
|
||||
let _ = ctx.events_tx.send(CastStreamEvent::OutboundMessage { destination_id, message });
|
||||
}
|
||||
|
||||
extern "C" fn on_negotiated_trampoline(user_data: *mut c_void) {
|
||||
let ctx = unsafe { context_from_user_data(user_data) };
|
||||
let _ = ctx.events_tx.send(CastStreamEvent::Negotiated);
|
||||
}
|
||||
|
||||
extern "C" fn on_error_trampoline(user_data: *mut c_void, message: *const c_char, message_len: usize) {
|
||||
let ctx = unsafe { context_from_user_data(user_data) };
|
||||
let message = unsafe { str_from_raw_parts(message, message_len) }.into_owned();
|
||||
let _ = ctx.events_tx.send(CastStreamEvent::Error(message));
|
||||
}
|
||||
|
||||
extern "C" fn on_picture_lost_trampoline(user_data: *mut c_void) {
|
||||
let ctx = unsafe { context_from_user_data(user_data) };
|
||||
let _ = ctx.events_tx.send(CastStreamEvent::PictureLost);
|
||||
}
|
||||
14
breadcast-core/src/device.rs
Normal file
14
breadcast-core/src/device.rs
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// A Chromecast / Google TV device discovered on the LAN via mDNS.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct CastDevice {
|
||||
/// Stable device id from the `id=` TXT record.
|
||||
pub id: String,
|
||||
/// Friendly name from the `fn=` TXT record (e.g. "Living Room TV").
|
||||
pub name: String,
|
||||
/// Model name from the `md=` TXT record (e.g. "Chromecast").
|
||||
pub model: String,
|
||||
pub host: String,
|
||||
pub port: u16,
|
||||
}
|
||||
186
breadcast-core/src/discovery.rs
Normal file
186
breadcast-core/src/discovery.rs
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
use std::collections::HashMap;
|
||||
use std::net::IpAddr;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use mdns_sd::{ScopedIp, ServiceDaemon, ServiceEvent};
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use crate::device::CastDevice;
|
||||
|
||||
/// How long a resolved address is trusted without being re-seen in a later
|
||||
/// `ServiceResolved` event. mDNS re-resolves well inside this window in
|
||||
/// normal operation, so an address that goes quiet for this long is more
|
||||
/// likely stale (DHCP lease change, interface removed) than merely unlucky
|
||||
/// timing.
|
||||
const ADDRESS_STALE_AFTER: Duration = Duration::from_secs(300);
|
||||
|
||||
/// Picks the best address to connect to a resolved device on: routable IPv4
|
||||
/// first (works unambiguously with rust_cast's TLS connect and with URLs
|
||||
/// embedded in Cast media-load requests), falling back to a non-link-local
|
||||
/// IPv6 address. Link-local IPv6 (`fe80::...%wlan0`) is deliberately last
|
||||
/// resort — its zone-id suffix doesn't round-trip through `IpAddr`'s
|
||||
/// `FromStr`/`Display`, so it can silently become unconnectable downstream.
|
||||
///
|
||||
/// `addresses` is sorted most-recently-seen first (with a stable tie-break)
|
||||
/// before picking, rather than iterated in whatever order a `HashSet` (or a
|
||||
/// `Vec` built from one) happens to produce — with a device that resolves
|
||||
/// to two IPv4 addresses (e.g. wired + wireless, or a DHCP lease change),
|
||||
/// an unordered choice can silently pick a stale/unreachable one, and pick
|
||||
/// a *different* one across otherwise-identical runs.
|
||||
fn pick_address(addresses: &[(ScopedIp, Instant)]) -> Option<IpAddr> {
|
||||
let now = Instant::now();
|
||||
let mut candidates: Vec<&(ScopedIp, Instant)> = addresses
|
||||
.iter()
|
||||
.filter(|(_, seen)| now.duration_since(*seen) < ADDRESS_STALE_AFTER)
|
||||
.collect();
|
||||
candidates.sort_by(|a, b| {
|
||||
b.1.cmp(&a.1) // most-recently-seen first
|
||||
.then_with(|| a.0.to_ip_addr().to_string().cmp(&b.0.to_ip_addr().to_string()))
|
||||
});
|
||||
|
||||
candidates
|
||||
.iter()
|
||||
.find(|(ip, _)| ip.is_ipv4())
|
||||
.or_else(|| {
|
||||
candidates.iter().find(|(ip, _)| match ip.to_ip_addr() {
|
||||
IpAddr::V6(v6) => !v6.is_unicast_link_local(),
|
||||
IpAddr::V4(_) => false,
|
||||
})
|
||||
})
|
||||
.or_else(|| candidates.first())
|
||||
.map(|(ip, _)| ip.to_ip_addr())
|
||||
}
|
||||
|
||||
const SERVICE_TYPE: &str = "_googlecast._tcp.local.";
|
||||
|
||||
/// A change in the set of Cast devices visible on the LAN.
|
||||
///
|
||||
/// `Found` fires on every mDNS re-resolution of a device (e.g. once per
|
||||
/// network interface, or periodically as records refresh), not just the
|
||||
/// first sighting — consumers should treat it as an upsert keyed by
|
||||
/// `CastDevice::id`, not an append-only log.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DiscoveryEvent {
|
||||
Found(CastDevice),
|
||||
Lost { id: String },
|
||||
}
|
||||
|
||||
/// Browses `_googlecast._tcp.local.` on a background thread and forwards
|
||||
/// found/lost devices over an unbounded channel. Dropping the returned
|
||||
/// `Discovery` stops the underlying mDNS daemon (via an explicit `Drop`
|
||||
/// impl below — `mdns_sd::ServiceDaemon` has none of its own, so without
|
||||
/// it every dropped `Discovery` would leak its daemon thread and 5353
|
||||
/// multicast socket for the rest of the process's life).
|
||||
pub struct Discovery {
|
||||
daemon: ServiceDaemon,
|
||||
}
|
||||
|
||||
impl Drop for Discovery {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.daemon.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
impl Discovery {
|
||||
/// Starts browsing and returns the handle plus a receiver of events.
|
||||
/// `id=` TXT records are used to dedupe: a `ServiceResolved` for an
|
||||
/// already-known id is treated as an update, not a duplicate `Found`.
|
||||
pub fn start() -> Result<(Self, mpsc::UnboundedReceiver<DiscoveryEvent>)> {
|
||||
let daemon = ServiceDaemon::new().context("failed to start mDNS daemon")?;
|
||||
let browse_rx = daemon
|
||||
.browse(SERVICE_TYPE)
|
||||
.context("failed to browse _googlecast._tcp.local.")?;
|
||||
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
|
||||
// mdns-sd's receiver is a blocking `flume` channel, so it needs its
|
||||
// own OS thread rather than a tokio task; forwarding into an
|
||||
// unbounded tokio channel is a non-blocking send from here.
|
||||
std::thread::spawn(move || {
|
||||
// fullname -> last-known device id, so a ServiceRemoved (which
|
||||
// only carries the fullname) can still emit the right Lost{id}.
|
||||
let mut fullname_to_id: HashMap<String, String> = HashMap::new();
|
||||
// fullname -> every address seen for it, with a last-seen
|
||||
// timestamp each. mDNS resolves progressively: the first
|
||||
// ServiceResolved for a device often carries only a link-local
|
||||
// IPv6 address, with the routable IPv4/global-IPv6 address
|
||||
// arriving in a later event for the same fullname. Accumulating
|
||||
// (not replacing) means `pick_address` always chooses from
|
||||
// everything seen so far, not just whatever happened to be in
|
||||
// the latest packet — the timestamp lets it also prefer the
|
||||
// freshest address and ignore ones that have gone stale.
|
||||
let mut fullname_to_addrs: HashMap<String, Vec<(ScopedIp, std::time::Instant)>> = HashMap::new();
|
||||
|
||||
while let Ok(event) = browse_rx.recv() {
|
||||
match event {
|
||||
ServiceEvent::ServiceResolved(info) => {
|
||||
let Some(id) = info.get_property_val_str("id").map(str::to_string) else {
|
||||
warn!(fullname = %info.get_fullname(), "cast device missing id= TXT record, skipping");
|
||||
continue;
|
||||
};
|
||||
let name = info
|
||||
.get_property_val_str("fn")
|
||||
.unwrap_or_else(|| info.get_hostname())
|
||||
.to_string();
|
||||
let model = info
|
||||
.get_property_val_str("md")
|
||||
.unwrap_or("Chromecast")
|
||||
.to_string();
|
||||
|
||||
let addrs = fullname_to_addrs
|
||||
.entry(info.get_fullname().to_string())
|
||||
.or_default();
|
||||
let now = std::time::Instant::now();
|
||||
for addr in info.get_addresses().iter().cloned() {
|
||||
match addrs.iter_mut().find(|(seen, _)| *seen == addr) {
|
||||
Some(entry) => entry.1 = now,
|
||||
None => addrs.push((addr, now)),
|
||||
}
|
||||
}
|
||||
|
||||
let Some(host) = pick_address(addrs).map(|ip| ip.to_string()) else {
|
||||
warn!(%id, "cast device resolved with no usable addresses, skipping");
|
||||
continue;
|
||||
};
|
||||
|
||||
fullname_to_id.insert(info.get_fullname().to_string(), id.clone());
|
||||
|
||||
let device = CastDevice {
|
||||
id,
|
||||
name,
|
||||
model,
|
||||
host,
|
||||
port: info.get_port(),
|
||||
};
|
||||
debug!(?device, "cast device found");
|
||||
if tx.send(DiscoveryEvent::Found(device)).is_err() {
|
||||
break; // receiver dropped, stop the thread
|
||||
}
|
||||
}
|
||||
ServiceEvent::ServiceRemoved(_ty, fullname) => {
|
||||
fullname_to_addrs.remove(&fullname);
|
||||
if let Some(id) = fullname_to_id.remove(&fullname) {
|
||||
debug!(%id, "cast device lost");
|
||||
if tx.send(DiscoveryEvent::Lost { id }).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok((Self { daemon }, rx))
|
||||
}
|
||||
|
||||
/// Stops the mDNS daemon and its browse thread.
|
||||
pub fn stop(self) -> Result<()> {
|
||||
self.daemon
|
||||
.shutdown()
|
||||
.context("failed to shut down mDNS daemon")?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
16
breadcast-core/src/dlna/device.rs
Normal file
16
breadcast-core/src/dlna/device.rs
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
/// A DLNA/UPnP media renderer discovered on the LAN via SSDP — the class of
|
||||
/// device Windows' own "Cast to Device" (Win+K) targets, and what most
|
||||
/// non-Chromecast smart TVs (Samsung, LG/Tizen, Sony) expose alongside or
|
||||
/// instead of Google Cast.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct DlnaDevice {
|
||||
/// Human-readable name from the device's `friendlyName` element.
|
||||
pub friendly_name: String,
|
||||
/// The device description XML URL (e.g. `http://192.168.1.50:1400/desc.xml`).
|
||||
/// This, not a separately-tracked id, is what uniquely identifies a UPnP
|
||||
/// device here — UPnP itself has a UDN concept, but reading it requires
|
||||
/// enabling `rupnp`'s `full_device_spec` feature for that one field,
|
||||
/// while the description URL is already available for free and is
|
||||
/// exactly what `rupnp::Device` itself keys its own identity on.
|
||||
pub url: String,
|
||||
}
|
||||
131
breadcast-core/src/dlna/discovery.rs
Normal file
131
breadcast-core/src/dlna/discovery.rs
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
use std::collections::{HashMap, HashSet};
|
||||
use std::time::Duration;
|
||||
|
||||
use rupnp::ssdp::SearchTarget;
|
||||
use tokio::sync::mpsc;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
use super::device::DlnaDevice;
|
||||
use super::AV_TRANSPORT;
|
||||
|
||||
/// How often to re-issue an SSDP search burst. Unlike mDNS (continuous
|
||||
/// multicast browsing via `mdns-sd` in [`crate::discovery`]), SSDP has no
|
||||
/// "subscribe and get pushed updates" primitive exposed by `rupnp` —
|
||||
/// discovery here is "ask, wait [`SEARCH_TIMEOUT`], collect whoever
|
||||
/// answered," repeated on this interval, rather than event-driven.
|
||||
const POLL_INTERVAL: Duration = Duration::from_secs(30);
|
||||
|
||||
/// How long to wait for M-SEARCH responses on each poll. SSDP devices are
|
||||
/// expected to jitter their response within a device-chosen window, so this
|
||||
/// needs to be more than an instant, but this is still per-poll latency
|
||||
/// before newly-found devices are reported.
|
||||
const SEARCH_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
|
||||
/// A device not reconfirmed within this many consecutive polls is reported
|
||||
/// lost. More than one (rather than declaring it gone after a single missed
|
||||
/// poll) tolerates an occasional dropped UDP response — SSDP runs over
|
||||
/// unreliable multicast, so one missed reply is routine, not a signal the
|
||||
/// device actually left the network.
|
||||
const MISSED_POLLS_BEFORE_LOST: u32 = 2;
|
||||
|
||||
/// A change in the set of DLNA renderers visible on the LAN. Mirrors
|
||||
/// [`crate::discovery::DiscoveryEvent`]'s shape for consistency between the
|
||||
/// two casting protocols, though the underlying mechanism differs (see
|
||||
/// [`DlnaDiscovery`]'s docs).
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum DlnaDiscoveryEvent {
|
||||
Found(DlnaDevice),
|
||||
Lost { url: String },
|
||||
}
|
||||
|
||||
/// Periodically searches for UPnP `AVTransport` services (i.e. media
|
||||
/// renderers — smart TVs, DLNA-capable receivers, and what Windows' own
|
||||
/// "Cast to Device" targets) on the LAN via SSDP, and forwards found/lost
|
||||
/// devices over a channel.
|
||||
///
|
||||
/// Runs as a background `tokio` task rather than the dedicated OS thread
|
||||
/// [`crate::discovery::Discovery`] needs — `mdns-sd`'s browse channel is a
|
||||
/// blocking `flume` receiver with no async-friendly interface, but `rupnp`
|
||||
/// and `ssdp-client` are natively `tokio`-async, so a plain spawned task is
|
||||
/// both sufficient and more idiomatic here. Dropping the returned
|
||||
/// `DlnaDiscovery` aborts that task.
|
||||
pub struct DlnaDiscovery {
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl Drop for DlnaDiscovery {
|
||||
fn drop(&mut self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
impl DlnaDiscovery {
|
||||
/// Starts polling and returns the handle plus a receiver of events.
|
||||
pub fn start() -> (Self, mpsc::UnboundedReceiver<DlnaDiscoveryEvent>) {
|
||||
let (tx, rx) = mpsc::unbounded_channel();
|
||||
|
||||
let task = tokio::spawn(async move {
|
||||
// description URL -> (device, consecutive polls since last confirmed)
|
||||
let mut known: HashMap<String, (DlnaDevice, u32)> = HashMap::new();
|
||||
|
||||
loop {
|
||||
let search_target = SearchTarget::URN(AV_TRANSPORT);
|
||||
match rupnp::discover(&search_target, SEARCH_TIMEOUT, None).await {
|
||||
Ok(stream) => {
|
||||
use futures_util::StreamExt;
|
||||
let mut stream = std::pin::pin!(stream);
|
||||
let mut confirmed: HashSet<String> = HashSet::new();
|
||||
|
||||
while let Some(result) = stream.next().await {
|
||||
let device = match result {
|
||||
Ok(device) => device,
|
||||
Err(e) => {
|
||||
warn!(error = %e, "failed to resolve a discovered UPnP device, skipping");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
|
||||
let url = device.url().to_string();
|
||||
confirmed.insert(url.clone());
|
||||
|
||||
if let Some(entry) = known.get_mut(&url) {
|
||||
entry.1 = 0;
|
||||
continue;
|
||||
}
|
||||
|
||||
let dlna_device = DlnaDevice {
|
||||
friendly_name: device.friendly_name().to_string(),
|
||||
url: url.clone(),
|
||||
};
|
||||
known.insert(url, (dlna_device.clone(), 0));
|
||||
debug!(?dlna_device, "DLNA renderer found");
|
||||
if tx.send(DlnaDiscoveryEvent::Found(dlna_device)).is_err() {
|
||||
return; // receiver dropped, stop polling
|
||||
}
|
||||
}
|
||||
|
||||
known.retain(|url, (_, missed)| {
|
||||
if confirmed.contains(url) {
|
||||
true
|
||||
} else {
|
||||
*missed += 1;
|
||||
if *missed >= MISSED_POLLS_BEFORE_LOST {
|
||||
debug!(%url, "DLNA renderer lost");
|
||||
let _ = tx.send(DlnaDiscoveryEvent::Lost { url: url.clone() });
|
||||
false
|
||||
} else {
|
||||
true
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
Err(e) => warn!(error = %e, "SSDP search for AVTransport devices failed"),
|
||||
}
|
||||
|
||||
tokio::time::sleep(POLL_INTERVAL).await;
|
||||
}
|
||||
});
|
||||
|
||||
(Self { task }, rx)
|
||||
}
|
||||
}
|
||||
23
breadcast-core/src/dlna/mod.rs
Normal file
23
breadcast-core/src/dlna/mod.rs
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
//! DLNA/UPnP media-renderer casting: discovery via SSDP, control via the
|
||||
//! `AVTransport` service's SOAP actions. This is the protocol behind most
|
||||
//! non-Chromecast smart TVs (Samsung, LG/Tizen, Sony) and behind Windows'
|
||||
//! own "Cast to Device" (Win+K) flyout — a different device population
|
||||
//! than [`crate::cast_sender`]'s Cast V2/CASTV2, not an alternate path to
|
||||
//! the same devices.
|
||||
//!
|
||||
//! Reuses the rest of breadcast-core as-is: the same [`crate::capture`],
|
||||
//! [`crate::pipeline`], and [`crate::http_server`] produce the HLS stream a
|
||||
//! [`DlnaSession`] is handed a URL to — only discovery and the
|
||||
//! device-control protocol differ from the Cast path.
|
||||
|
||||
mod device;
|
||||
mod discovery;
|
||||
mod session;
|
||||
|
||||
use rupnp::ssdp::URN;
|
||||
|
||||
pub use device::DlnaDevice;
|
||||
pub use discovery::{DlnaDiscovery, DlnaDiscoveryEvent};
|
||||
pub use session::DlnaSession;
|
||||
|
||||
const AV_TRANSPORT: URN = URN::service("schemas-upnp-org", "AVTransport", 1);
|
||||
122
breadcast-core/src/dlna/session.rs
Normal file
122
breadcast-core/src/dlna/session.rs
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
use anyhow::{Context, Result};
|
||||
use rupnp::Service;
|
||||
|
||||
use super::device::DlnaDevice;
|
||||
use super::AV_TRANSPORT;
|
||||
|
||||
/// A connected UPnP `AVTransport` control point for a single DLNA media
|
||||
/// renderer.
|
||||
///
|
||||
/// Unlike [`CastSession`](crate::CastSession), there is no persistent
|
||||
/// connection or background thread to manage here: every UPnP action is an
|
||||
/// independent SOAP-over-HTTP request, so `DlnaSession` is just a cheap,
|
||||
/// `Clone`-able handle to the renderer's resolved control endpoint.
|
||||
/// Concurrent `load()`/`stop()` calls are naturally safe — each is its own
|
||||
/// HTTP request — with none of the single-socket message-demultiplexing
|
||||
/// hazard `CastSession` has to run an actor thread to avoid for CASTV2.
|
||||
#[derive(Clone)]
|
||||
pub struct DlnaSession {
|
||||
device_url: rupnp::http::Uri,
|
||||
service: Service,
|
||||
}
|
||||
|
||||
impl DlnaSession {
|
||||
/// Fetches `device`'s full description and resolves its `AVTransport`
|
||||
/// service. Fails if the device no longer answers, or turns out not to
|
||||
/// expose `AVTransport` after all — shouldn't happen given discovery
|
||||
/// already searched for exactly that service, but a device's
|
||||
/// description can in principle change between being found and being
|
||||
/// connected to.
|
||||
pub async fn connect(device: &DlnaDevice) -> Result<Self> {
|
||||
let device_url: rupnp::http::Uri = device
|
||||
.url
|
||||
.parse()
|
||||
.with_context(|| format!("invalid device description URL: {}", device.url))?;
|
||||
|
||||
let full_device = rupnp::Device::from_url(device_url.clone())
|
||||
.await
|
||||
.with_context(|| format!("failed to fetch device description from {}", device.url))?;
|
||||
|
||||
let service = full_device
|
||||
.find_service(&AV_TRANSPORT)
|
||||
.with_context(|| format!("{} has no AVTransport service", device.friendly_name))?
|
||||
.clone();
|
||||
|
||||
Ok(Self { device_url, service })
|
||||
}
|
||||
|
||||
/// Sets `content_url` as the renderer's current transport URI and
|
||||
/// starts playback.
|
||||
///
|
||||
/// `content_url` is XML-escaped before being embedded in the SOAP
|
||||
/// request body — in breadcast's own usage it's always a URL this
|
||||
/// process generated itself (safe by construction), but nothing about
|
||||
/// this function's signature guarantees that stays true for every
|
||||
/// caller, and an unescaped `&` alone would produce malformed XML the
|
||||
/// renderer would reject with no useful diagnostic.
|
||||
pub async fn load(&self, content_url: &str) -> Result<()> {
|
||||
let escaped = xml_escape(content_url);
|
||||
let set_uri_payload = format!(
|
||||
"<InstanceID>0</InstanceID><CurrentURI>{escaped}</CurrentURI><CurrentURIMetaData></CurrentURIMetaData>"
|
||||
);
|
||||
self.service
|
||||
.action(&self.device_url, "SetAVTransportURI", &set_uri_payload)
|
||||
.await
|
||||
.context("SetAVTransportURI failed")?;
|
||||
|
||||
self.service
|
||||
.action(&self.device_url, "Play", "<InstanceID>0</InstanceID><Speed>1</Speed>")
|
||||
.await
|
||||
.context("Play failed")?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Stops playback. Unlike `CastSession::stop`, there's no persistent
|
||||
/// session or launched app to tear down — this is just the `Stop`
|
||||
/// action.
|
||||
pub async fn stop(&self) -> Result<()> {
|
||||
self.service
|
||||
.action(&self.device_url, "Stop", "<InstanceID>0</InstanceID>")
|
||||
.await
|
||||
.context("Stop failed")?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Polls `GetTransportInfo` and returns the renderer's own reported
|
||||
/// `CurrentTransportState` (e.g. `"PLAYING"`, `"TRANSITIONING"`,
|
||||
/// `"STOPPED"`, `"NO_MEDIA_PRESENT"`).
|
||||
///
|
||||
/// This is a request/response poll, not a push subscription —
|
||||
/// `AVTransport` does support UPnP eventing (`Service::subscribe` in
|
||||
/// `rupnp`) for state pushed as it changes, but that needs a locally
|
||||
/// bound HTTP callback listener, which is more machinery than a status
|
||||
/// check is worth for now. A caller that wants live updates polls this
|
||||
/// on an interval instead.
|
||||
pub async fn transport_state(&self) -> Result<String> {
|
||||
let response = self
|
||||
.service
|
||||
.action(&self.device_url, "GetTransportInfo", "<InstanceID>0</InstanceID>")
|
||||
.await
|
||||
.context("GetTransportInfo failed")?;
|
||||
response
|
||||
.get("CurrentTransportState")
|
||||
.cloned()
|
||||
.context("GetTransportInfo response had no CurrentTransportState")
|
||||
}
|
||||
}
|
||||
|
||||
fn xml_escape(input: &str) -> String {
|
||||
let mut escaped = String::with_capacity(input.len());
|
||||
for c in input.chars() {
|
||||
match c {
|
||||
'&' => escaped.push_str("&"),
|
||||
'<' => escaped.push_str("<"),
|
||||
'>' => escaped.push_str(">"),
|
||||
'"' => escaped.push_str("""),
|
||||
'\'' => escaped.push_str("'"),
|
||||
other => escaped.push(other),
|
||||
}
|
||||
}
|
||||
escaped
|
||||
}
|
||||
36
breadcast-core/src/examples/apple_hls_test.rs
Normal file
36
breadcast-core/src/examples/apple_hls_test.rs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
use breadcast_core::{CastSession, Discovery, DiscoveryEvent};
|
||||
use rust_cast::channels::media::{MediaResponse, StreamType};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
let (_d, mut events) = Discovery::start()?;
|
||||
let device = loop {
|
||||
let ev = tokio::time::timeout(std::time::Duration::from_secs(45), events.recv())
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("timed out waiting for a matching device"))?
|
||||
.ok_or_else(|| anyhow::anyhow!("discovery channel closed before a matching device was found"))?;
|
||||
if let DiscoveryEvent::Found(d) = ev {
|
||||
if d.name.to_lowercase().contains("master bedroom") && d.model.to_lowercase().contains("chromecast") && d.host.parse::<std::net::Ipv4Addr>().is_ok() {
|
||||
break d;
|
||||
}
|
||||
}
|
||||
};
|
||||
println!("Found {}", device.name);
|
||||
let (session, media_events) = CastSession::connect(&device)?;
|
||||
let stream_type = std::env::args().nth(1).unwrap_or_default();
|
||||
let stream_type = if stream_type == "live" { StreamType::Live } else { StreamType::Buffered };
|
||||
println!("Using stream_type={stream_type:?}");
|
||||
let status = session.load("http://devimages.apple.com/iphone/samples/bipbop/bipbopall.m3u8", "application/vnd.apple.mpegurl", stream_type)?;
|
||||
println!("Load status: {status:#?}");
|
||||
std::thread::spawn(move || {
|
||||
media_events.pump_with_media_callback(|m| match m {
|
||||
MediaResponse::Status(s) => for e in &s.entries { println!(" player_state={:?} idle_reason={:?}", e.player_state, e.idle_reason); },
|
||||
MediaResponse::LoadFailed(f) => println!(" LOAD FAILED: {f:?}"),
|
||||
other => println!(" other: {other:?}"),
|
||||
})
|
||||
});
|
||||
tokio::time::sleep(std::time::Duration::from_secs(30)).await;
|
||||
let _ = session.stop();
|
||||
Ok(())
|
||||
}
|
||||
22
breadcast-core/src/examples/capture_test.rs
Normal file
22
breadcast-core/src/examples/capture_test.rs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
//! Phase 2 step 1: opens the portal's screen-cast picker and prints the
|
||||
//! PipeWire node id it hands back. Run with:
|
||||
//! cargo run -p breadcast-core --example capture_test
|
||||
//! A system picker dialog should appear — pick a monitor or window.
|
||||
|
||||
use breadcast_core::CaptureSession;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
println!("Opening the portal screen-cast picker (look for a system dialog)...");
|
||||
let session = CaptureSession::start().await?;
|
||||
println!("Got PipeWire video node id: {}", session.video_node_id());
|
||||
|
||||
println!("Holding the session open for 5s, then closing...");
|
||||
tokio::time::sleep(std::time::Duration::from_secs(5)).await;
|
||||
session.close().await?;
|
||||
println!("Closed.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
202
breadcast-core/src/examples/cast_stream_test.rs
Normal file
202
breadcast-core/src/examples/cast_stream_test.rs
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
//! Low-latency mirroring via Cast Streaming (the same protocol Chrome's
|
||||
//! tab/desktop casting uses) instead of `mirror_test`'s HLS approach: no
|
||||
//! HTTP server, no multi-second segment-buffering floor, and it targets the
|
||||
//! Chromecast's built-in Mirroring receiver (app id `0F5096E8`) instead of
|
||||
//! the Default Media Receiver. Run with:
|
||||
//! cargo run -p breadcast-core --example cast_stream_test [name substring]
|
||||
//! Defaults to "master bedroom" if no argument is given.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicBool, Ordering};
|
||||
use std::time::Duration;
|
||||
|
||||
use breadcast_core::caststream::{CastStreamEvent, VideoParams, WEBRTC_NAMESPACE};
|
||||
use breadcast_core::net::local_lan_ip;
|
||||
use breadcast_core::pipeline::{
|
||||
build_video_pipeline_for_streaming, pull_encoded_frame, request_key_frame, set_video_bitrate_kbps,
|
||||
};
|
||||
use breadcast_core::{CastSession, CastStreamSender, Discovery, DiscoveryEvent};
|
||||
use gstreamer as gst;
|
||||
use gstreamer::prelude::*;
|
||||
use rust_cast::channels::receiver::CastDeviceApp;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
gst::init()?;
|
||||
|
||||
let name_filter = std::env::args()
|
||||
.nth(1)
|
||||
.unwrap_or_else(|| "master bedroom".to_string())
|
||||
.to_lowercase();
|
||||
|
||||
let lan_ip = local_lan_ip()?;
|
||||
println!("This machine's LAN IP: {lan_ip}");
|
||||
|
||||
println!("Looking for a Cast device matching \"{name_filter}\"...");
|
||||
let (_discovery, mut events) = Discovery::start()?;
|
||||
let device = loop {
|
||||
let event = tokio::time::timeout(Duration::from_secs(25), events.recv())
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("timed out waiting for a device matching \"{name_filter}\""))?
|
||||
.ok_or_else(|| anyhow::anyhow!("discovery channel closed"))?;
|
||||
|
||||
if let DiscoveryEvent::Found(device) = event {
|
||||
let name = device.name.to_lowercase();
|
||||
let model = device.model.to_lowercase();
|
||||
if name.contains(&name_filter)
|
||||
&& model.contains("chromecast")
|
||||
&& device.host.parse::<std::net::Ipv4Addr>().is_ok()
|
||||
{
|
||||
break device;
|
||||
}
|
||||
}
|
||||
};
|
||||
println!("Found {} ({}) at {}:{}", device.name, device.model, device.host, device.port);
|
||||
|
||||
println!("Opening the portal screen-cast picker (look for a system dialog)...");
|
||||
let capture = breadcast_core::CaptureSession::start().await?;
|
||||
println!("Got PipeWire video node id: {}", capture.video_node_id());
|
||||
|
||||
let (pipeline, appsink, encoder) = build_video_pipeline_for_streaming(capture.video_node_id())?;
|
||||
|
||||
// Watch the encode pipeline's own bus in the background -- see
|
||||
// mirror_test.rs's identical block for why this matters.
|
||||
{
|
||||
let pipeline_watch = pipeline.clone();
|
||||
std::thread::spawn(move || {
|
||||
match breadcast_core::pipeline::run_until_error_or_timeout(&pipeline_watch, gst::ClockTime::from_seconds(120)) {
|
||||
Ok(outcome) => tracing::debug!(?outcome, "encode pipeline bus watcher ended"),
|
||||
Err(e) => eprintln!("ENCODE PIPELINE ERROR: {e:?}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
println!("Connecting and launching the Mirroring receiver ({} )...", breadcast_core::caststream::MIRRORING_APP_ID);
|
||||
let (session, _media_events, raw_messages) =
|
||||
CastSession::connect_app(&device, CastDeviceApp::Custom(breadcast_core::caststream::MIRRORING_APP_ID.to_string()))?;
|
||||
println!("Mirroring receiver launched, transport_id={}", session.transport_id());
|
||||
|
||||
let (sender, stream_events) = CastStreamSender::start(
|
||||
&device.host,
|
||||
"sender-0",
|
||||
session.transport_id(),
|
||||
VideoParams::default(),
|
||||
)?;
|
||||
let sender = Arc::new(sender);
|
||||
|
||||
// Forwards inbound webrtc-namespace messages (the ANSWER) from the
|
||||
// existing CASTV2 connection into the Cast Streaming session.
|
||||
let inbound_pump = {
|
||||
let sender = sender.clone();
|
||||
std::thread::spawn(move || {
|
||||
while let Some(msg) = raw_messages.recv() {
|
||||
if msg.namespace == WEBRTC_NAMESPACE {
|
||||
sender.on_message(&msg.source_id, &msg.namespace, &msg.message);
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
let negotiated = Arc::new(AtomicBool::new(false));
|
||||
|
||||
// Forwards outbound webrtc-namespace messages (the OFFER) out over the
|
||||
// existing CASTV2 connection, and tracks negotiation completion.
|
||||
let outbound_pump = {
|
||||
let session = session.clone();
|
||||
let negotiated = negotiated.clone();
|
||||
std::thread::spawn(move || {
|
||||
while let Ok(event) = stream_events.recv() {
|
||||
match event {
|
||||
CastStreamEvent::OutboundMessage { message, .. } => {
|
||||
if let Err(e) = session.send_raw_message(WEBRTC_NAMESPACE, &message) {
|
||||
eprintln!("failed to send Cast Streaming message: {e:?}");
|
||||
}
|
||||
}
|
||||
CastStreamEvent::Negotiated => {
|
||||
println!("Cast Streaming negotiated -- receiver is ready for frames.");
|
||||
negotiated.store(true, Ordering::Release);
|
||||
}
|
||||
CastStreamEvent::Error(message) => {
|
||||
eprintln!("Cast Streaming error: {message}");
|
||||
}
|
||||
CastStreamEvent::PictureLost => {
|
||||
println!("receiver reported picture loss");
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
};
|
||||
|
||||
println!("Sending OFFER...");
|
||||
sender.negotiate();
|
||||
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
while !negotiated.load(Ordering::Acquire) && std::time::Instant::now() < deadline {
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
if !negotiated.load(Ordering::Acquire) {
|
||||
anyhow::bail!("never received an ANSWER (negotiation timed out after 10s)");
|
||||
}
|
||||
|
||||
pipeline.set_state(gst::State::Playing)?;
|
||||
println!("Pipeline playing. Mirroring for up to 60s -- check the TV.");
|
||||
|
||||
let sender_for_frames = sender.clone();
|
||||
let frame_pump = std::thread::spawn(move || -> anyhow::Result<()> {
|
||||
let mut last_bitrate_update = std::time::Instant::now();
|
||||
loop {
|
||||
let Some((data, is_key_frame, capture_time_us)) = pull_encoded_frame(&appsink)? else {
|
||||
return Ok(()); // EOS
|
||||
};
|
||||
|
||||
if sender_for_frames.needs_key_frame() && !is_key_frame {
|
||||
request_key_frame(&appsink);
|
||||
}
|
||||
|
||||
if let Err(e) = sender_for_frames.enqueue_frame(&data, is_key_frame, capture_time_us) {
|
||||
tracing::debug!(error = ?e, "dropped a frame (not negotiated yet or backpressure)");
|
||||
}
|
||||
|
||||
if last_bitrate_update.elapsed() >= Duration::from_secs(1) {
|
||||
let bps = sender_for_frames.estimated_bandwidth_bps();
|
||||
// Leave headroom below the raw estimate for RTP/RTCP
|
||||
// overhead and estimation noise, and never go below a
|
||||
// usable floor.
|
||||
let target_kbps = ((bps as f64 * 0.85) / 1000.0).max(500.0) as u32;
|
||||
set_video_bitrate_kbps(&encoder, target_kbps);
|
||||
last_bitrate_update = std::time::Instant::now();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mirror_duration = Duration::from_secs(60);
|
||||
let mut elapsed = Duration::ZERO;
|
||||
while elapsed < mirror_duration && !frame_pump.is_finished() && !outbound_pump.is_finished() {
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
elapsed += Duration::from_secs(1);
|
||||
}
|
||||
|
||||
pipeline.set_state(gst::State::Null)?;
|
||||
capture.close().await?;
|
||||
if let Err(e) = session.stop() {
|
||||
eprintln!("failed to cleanly stop the cast session: {e:?}");
|
||||
}
|
||||
match frame_pump.join() {
|
||||
Ok(Ok(())) => {}
|
||||
Ok(Err(e)) => eprintln!("frame pump ended with an error: {e:?}"),
|
||||
Err(panic) => eprintln!("frame pump thread panicked: {panic:?}"),
|
||||
}
|
||||
// `session.stop()` above ends the CastSession's io thread, which in turn
|
||||
// closes the raw_messages/stream_events channels these two pump threads
|
||||
// are blocked reading from -- so both should already be finishing.
|
||||
if let Err(panic) = inbound_pump.join() {
|
||||
eprintln!("inbound message pump thread panicked: {panic:?}");
|
||||
}
|
||||
if let Err(panic) = outbound_pump.join() {
|
||||
eprintln!("outbound message pump thread panicked: {panic:?}");
|
||||
}
|
||||
println!("Stopped.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
95
breadcast-core/src/examples/cast_test.rs
Normal file
95
breadcast-core/src/examples/cast_test.rs
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
//! Phase 3 smoke test: discovers a Cast device by name substring and casts a
|
||||
//! sample video to it, to prove real CASTV2 device control ahead of building
|
||||
//! the actual capture pipeline.
|
||||
//! Run with: cargo run -p breadcast-core --example cast_test [name substring]
|
||||
//! Defaults to "master bedroom" if no argument is given.
|
||||
|
||||
use breadcast_core::{CastSession, Discovery, DiscoveryEvent};
|
||||
use rust_cast::channels::media::{MediaResponse, StreamType};
|
||||
|
||||
// Google's old gtv-videos-bucket sample assets (from the classic Cast SDK
|
||||
// docs) now 403 — confirmed dead via `curl -I`, not a Cast-side issue. The
|
||||
// 21MB samplelib.com/mp4/sample-30s.mp4 loaded but then the receiver killed
|
||||
// the sender connection ("failed to fill whole buffer" / EOF) on both real
|
||||
// devices tested — consistent with a non-"fast-start" MP4 (moov atom at the
|
||||
// end) stalling the receiver's simple HTML5 video player. This one is small
|
||||
// (788KB/10s) and known-good.
|
||||
const SAMPLE_VIDEO: &str = "https://www.w3schools.com/html/mov_bbb.mp4";
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let name_filter = std::env::args()
|
||||
.nth(1)
|
||||
.unwrap_or_else(|| "master bedroom".to_string())
|
||||
.to_lowercase();
|
||||
|
||||
let (_discovery, mut events) = Discovery::start()?;
|
||||
println!("Looking for a Cast device matching \"{name_filter}\"...");
|
||||
|
||||
let device = loop {
|
||||
let event = tokio::time::timeout(std::time::Duration::from_secs(15), events.recv())
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("timed out waiting for a device matching \"{name_filter}\""))?
|
||||
.ok_or_else(|| anyhow::anyhow!("discovery channel closed"))?;
|
||||
|
||||
if let DiscoveryEvent::Found(device) = event {
|
||||
let name = device.name.to_lowercase();
|
||||
let model = device.model.to_lowercase();
|
||||
// Restrict to actual Chromecast-family receivers (excludes a
|
||||
// TV's own "Smart TV" Cast platform, which shares the "TV" name
|
||||
// prefix with its paired dongle in this household).
|
||||
//
|
||||
// Hold out for an IPv4 address specifically, not just
|
||||
// non-link-local: mDNS resolution races between interfaces, and
|
||||
// a device's *global* IPv6 address can arrive in a Found event
|
||||
// before its IPv4 address does, or before its IPv4 address is
|
||||
// even reachable (seen in practice: "No route to host" on a
|
||||
// global IPv6 the LAN doesn't actually route). IPv4 is the safe
|
||||
// default on this network; a later phase can add real
|
||||
// reachability probing instead of an address-family heuristic.
|
||||
if name.contains(&name_filter)
|
||||
&& model.contains("chromecast")
|
||||
&& device.host.parse::<std::net::Ipv4Addr>().is_ok()
|
||||
{
|
||||
break device;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
println!(
|
||||
"Found {} ({}) at {}:{} — connecting...",
|
||||
device.name, device.model, device.host, device.port
|
||||
);
|
||||
|
||||
let (session, media_events) = CastSession::connect(&device)?;
|
||||
println!("Connected, Default Media Receiver launched. Loading sample video...");
|
||||
|
||||
let status = session.load(SAMPLE_VIDEO, "video/mp4", StreamType::Buffered)?;
|
||||
println!("Receiver acknowledged the load: {status:#?}");
|
||||
println!("Watching player state for 90s (video is ~30s, but buffering can be slow)...");
|
||||
|
||||
let pump = std::thread::spawn(move || {
|
||||
media_events.pump_with_media_callback(|media| {
|
||||
if let MediaResponse::Status(status) = media {
|
||||
for entry in &status.entries {
|
||||
println!(
|
||||
" player_state={:?} idle_reason={:?} current_time={:?} extended_status={:?}",
|
||||
entry.player_state, entry.idle_reason, entry.current_time,
|
||||
entry.extended_status.as_ref().map(|e| e.player_state)
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_secs(90)).await;
|
||||
if let Err(e) = session.stop() {
|
||||
eprintln!("failed to cleanly stop the cast session: {e:?}");
|
||||
}
|
||||
let _ = pump.join(); // media_events channel is now closed (io thread ended), so this returns immediately
|
||||
println!("Done.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
25
breadcast-core/src/examples/discover.rs
Normal file
25
breadcast-core/src/examples/discover.rs
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
//! Prints Chromecast/Google TV devices as they appear/disappear on the LAN.
|
||||
//! Run with: cargo run -p breadcast-core --example discover
|
||||
|
||||
use breadcast_core::{Discovery, DiscoveryEvent};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let (_discovery, mut events) = Discovery::start()?;
|
||||
println!("Browsing for _googlecast._tcp.local. devices... (Ctrl+C to stop)");
|
||||
|
||||
while let Some(event) = events.recv().await {
|
||||
match event {
|
||||
DiscoveryEvent::Found(device) => {
|
||||
println!("+ {} ({}) at {}:{} [{}]", device.name, device.model, device.host, device.port, device.id);
|
||||
}
|
||||
DiscoveryEvent::Lost { id } => {
|
||||
println!("- {id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
27
breadcast-core/src/examples/dlna_discover.rs
Normal file
27
breadcast-core/src/examples/dlna_discover.rs
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
//! Prints DLNA/UPnP media renderers as they appear/disappear on the LAN
|
||||
//! (via periodic SSDP search for `AVTransport` services — the class of
|
||||
//! device Windows' own "Cast to Device", Win+K, targets). Run with:
|
||||
//! cargo run -p breadcast-core --example dlna_discover
|
||||
|
||||
use breadcast_core::{DlnaDiscovery, DlnaDiscoveryEvent};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
let (_discovery, mut events) = DlnaDiscovery::start();
|
||||
println!("Searching for DLNA/UPnP AVTransport devices every 30s... (Ctrl+C to stop)");
|
||||
|
||||
while let Some(event) = events.recv().await {
|
||||
match event {
|
||||
DlnaDiscoveryEvent::Found(device) => {
|
||||
println!("+ {} [{}]", device.friendly_name, device.url);
|
||||
}
|
||||
DlnaDiscoveryEvent::Lost { url } => {
|
||||
println!("- {url}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
107
breadcast-core/src/examples/dlna_mirror_test.rs
Normal file
107
breadcast-core/src/examples/dlna_mirror_test.rs
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
//! DLNA equivalent of `mirror_test`: captures this machine's screen,
|
||||
//! encodes+serves it as HLS, discovers a DLNA/UPnP media renderer by name
|
||||
//! substring, and casts the live stream to it via `AVTransport`. Run with:
|
||||
//! cargo run -p breadcast-core --example dlna_mirror_test [name substring]
|
||||
//! Matches any renderer found if no argument is given.
|
||||
//!
|
||||
//! This proves the same milestone `mirror_test` proved for Cast — a real
|
||||
//! receiver actually playing this pipeline's live HLS output — but for a
|
||||
//! different, unproven protocol path. DLNA renderer support for a
|
||||
//! *live-growing* (never-ending) HLS playlist is much less consistent
|
||||
//! across devices than Chromecast's: DLNA's classic use case is "play this
|
||||
//! one finite file," and plenty of renderers' UPnP stacks predate HLS
|
||||
//! entirely. Whether a given real renderer handles this is exactly what
|
||||
//! this example is for finding out, not something to assume from the Cast
|
||||
//! path working.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use breadcast_core::net::local_lan_ip;
|
||||
use breadcast_core::pipeline::{hls_output_dir, wait_for_playlist_segments};
|
||||
use breadcast_core::{CaptureSession, DlnaDiscovery, DlnaDiscoveryEvent, DlnaSession};
|
||||
use gstreamer as gst;
|
||||
use gstreamer::prelude::*;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
gst::init()?;
|
||||
|
||||
let name_filter = std::env::args().nth(1).unwrap_or_default().to_lowercase();
|
||||
|
||||
let lan_ip = local_lan_ip()?;
|
||||
println!("This machine's LAN IP: {lan_ip}");
|
||||
|
||||
println!("Opening the portal screen-cast picker (look for a system dialog)...");
|
||||
let capture = CaptureSession::start().await?;
|
||||
println!("Got PipeWire video node id: {}", capture.video_node_id());
|
||||
|
||||
let output_dir = hls_output_dir("dlna-mirror")?;
|
||||
let pipeline = breadcast_core::pipeline::build_video_pipeline(capture.video_node_id(), &output_dir)?;
|
||||
pipeline.set_state(gst::State::Playing)?;
|
||||
println!("Pipeline playing, writing HLS to {}", output_dir.display());
|
||||
|
||||
// Watch the encode pipeline's own bus in the background — see
|
||||
// mirror_test.rs's identical block for why this matters.
|
||||
{
|
||||
let pipeline_watch = pipeline.clone();
|
||||
std::thread::spawn(move || {
|
||||
match breadcast_core::pipeline::run_until_error_or_timeout(&pipeline_watch, gst::ClockTime::from_seconds(120)) {
|
||||
Ok(outcome) => tracing::debug!(?outcome, "encode pipeline bus watcher ended"),
|
||||
Err(e) => eprintln!("ENCODE PIPELINE ERROR: {e:?}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// A different fixed port than mirror_test's 8825, so both examples can
|
||||
// run at once (e.g. testing Cast and DLNA against the same live
|
||||
// desktop) without a bind conflict.
|
||||
let http = breadcast_core::http_server::HttpServer::start("0.0.0.0:8826", output_dir.clone())?;
|
||||
let stream_url = http.url(lan_ip, "playlist.m3u8");
|
||||
println!("Serving at {stream_url}");
|
||||
|
||||
wait_for_playlist_segments(&output_dir.join("playlist.m3u8"), 3, Duration::from_secs(20)).await?;
|
||||
println!("Playlist has segments, proceeding to cast.");
|
||||
|
||||
println!("Searching for a DLNA renderer matching \"{name_filter}\"...");
|
||||
let (_discovery, mut events) = DlnaDiscovery::start();
|
||||
let device = loop {
|
||||
let event = tokio::time::timeout(Duration::from_secs(35), events.recv())
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("timed out waiting for a renderer matching \"{name_filter}\""))?
|
||||
.ok_or_else(|| anyhow::anyhow!("discovery channel closed"))?;
|
||||
|
||||
if let DlnaDiscoveryEvent::Found(device) = event {
|
||||
if device.friendly_name.to_lowercase().contains(&name_filter) {
|
||||
break device;
|
||||
}
|
||||
}
|
||||
};
|
||||
println!("Found {} [{}]", device.friendly_name, device.url);
|
||||
|
||||
let session = DlnaSession::connect(&device).await?;
|
||||
println!("Connected to AVTransport. Loading the live stream...");
|
||||
session.load(&stream_url).await?;
|
||||
println!("Renderer accepted the load.");
|
||||
|
||||
println!("Mirroring for up to 60s — check the display. Polling transport state:");
|
||||
let mirror_duration = Duration::from_secs(60);
|
||||
let mut elapsed = Duration::ZERO;
|
||||
while elapsed < mirror_duration {
|
||||
match session.transport_state().await {
|
||||
Ok(state) => println!(" transport_state={state}"),
|
||||
Err(e) => println!(" failed to poll transport state: {e:?}"),
|
||||
}
|
||||
tokio::time::sleep(Duration::from_secs(3)).await;
|
||||
elapsed += Duration::from_secs(3);
|
||||
}
|
||||
|
||||
if let Err(e) = session.stop().await {
|
||||
eprintln!("failed to cleanly stop the DLNA session: {e:?}");
|
||||
}
|
||||
pipeline.set_state(gst::State::Null)?;
|
||||
capture.close().await?;
|
||||
println!("Stopped.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
134
breadcast-core/src/examples/mirror_test.rs
Normal file
134
breadcast-core/src/examples/mirror_test.rs
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
//! Phase 2 finale: captures this machine's screen, encodes+serves it as
|
||||
//! HLS, discovers a Cast device by name substring, and casts the live
|
||||
//! stream to it. Run with:
|
||||
//! cargo run -p breadcast-core --example mirror_test [name substring]
|
||||
//! Defaults to "master bedroom" if no argument is given.
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use breadcast_core::pipeline::{hls_output_dir, wait_for_playlist_segments};
|
||||
use breadcast_core::{CaptureSession, CastSession, Discovery, DiscoveryEvent};
|
||||
use breadcast_core::net::local_lan_ip;
|
||||
use gstreamer as gst;
|
||||
use gstreamer::prelude::*;
|
||||
use rust_cast::channels::media::{MediaResponse, StreamType};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
gst::init()?;
|
||||
|
||||
let name_filter = std::env::args()
|
||||
.nth(1)
|
||||
.unwrap_or_else(|| "master bedroom".to_string())
|
||||
.to_lowercase();
|
||||
let stream_type = match std::env::args().nth(2).as_deref() {
|
||||
Some("live") => StreamType::Live,
|
||||
_ => StreamType::Buffered,
|
||||
};
|
||||
println!("Using stream_type={stream_type:?}");
|
||||
|
||||
let lan_ip = local_lan_ip()?;
|
||||
println!("This machine's LAN IP: {lan_ip}");
|
||||
|
||||
println!("Opening the portal screen-cast picker (look for a system dialog)...");
|
||||
let capture = CaptureSession::start().await?;
|
||||
println!("Got PipeWire video node id: {}", capture.video_node_id());
|
||||
|
||||
let output_dir = hls_output_dir("cast-mirror")?;
|
||||
let pipeline = breadcast_core::pipeline::build_video_pipeline(capture.video_node_id(), &output_dir)?;
|
||||
pipeline.set_state(gst::State::Playing)?;
|
||||
println!("Pipeline playing, writing HLS to {}", output_dir.display());
|
||||
|
||||
// Watch the encode pipeline's own bus in the background — without
|
||||
// this, a vah264enc/hlssink3 error partway through mirroring is
|
||||
// invisible (nothing else polls this pipeline's bus), and the failure
|
||||
// would only ever show up indirectly as the Cast session stalling.
|
||||
{
|
||||
let pipeline_watch = pipeline.clone();
|
||||
std::thread::spawn(move || {
|
||||
match breadcast_core::pipeline::run_until_error_or_timeout(&pipeline_watch, gst::ClockTime::from_seconds(120)) {
|
||||
Ok(outcome) => tracing::debug!(?outcome, "encode pipeline bus watcher ended"),
|
||||
Err(e) => eprintln!("ENCODE PIPELINE ERROR: {e:?}"),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Fixed port (not 0/ephemeral) so a firewall rule can be added once and
|
||||
// stay valid across runs instead of chasing a random port every time.
|
||||
let http = breadcast_core::http_server::HttpServer::start("0.0.0.0:8825", output_dir.clone())?;
|
||||
let stream_url = http.url(lan_ip, "playlist.m3u8");
|
||||
println!("Serving at {stream_url}");
|
||||
|
||||
wait_for_playlist_segments(&output_dir.join("playlist.m3u8"), 3, Duration::from_secs(20)).await?;
|
||||
println!("Playlist has segments, proceeding to cast.");
|
||||
|
||||
println!("Looking for a Cast device matching \"{name_filter}\"...");
|
||||
let (_discovery, mut events) = Discovery::start()?;
|
||||
let device = loop {
|
||||
let event = tokio::time::timeout(std::time::Duration::from_secs(25), events.recv())
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("timed out waiting for a device matching \"{name_filter}\""))?
|
||||
.ok_or_else(|| anyhow::anyhow!("discovery channel closed"))?;
|
||||
|
||||
if let DiscoveryEvent::Found(device) = event {
|
||||
let name = device.name.to_lowercase();
|
||||
let model = device.model.to_lowercase();
|
||||
if name.contains(&name_filter)
|
||||
&& model.contains("chromecast")
|
||||
&& device.host.parse::<std::net::Ipv4Addr>().is_ok()
|
||||
{
|
||||
break device;
|
||||
}
|
||||
}
|
||||
};
|
||||
println!("Found {} ({}) at {}:{}", device.name, device.model, device.host, device.port);
|
||||
|
||||
let (session, media_events) = CastSession::connect(&device)?;
|
||||
println!("Connected, Default Media Receiver launched. Loading the live stream...");
|
||||
let status = session.load(&stream_url, "application/vnd.apple.mpegurl", stream_type)?;
|
||||
println!("Receiver acknowledged the load: {status:#?}");
|
||||
|
||||
println!("Mirroring for up to 60s — check the TV. Watching player state live:");
|
||||
let pump = std::thread::spawn(move || {
|
||||
media_events.pump_with_media_callback(|media| match media {
|
||||
MediaResponse::Status(status) => {
|
||||
for entry in &status.entries {
|
||||
println!(
|
||||
" player_state={:?} idle_reason={:?} extended_status={:?}",
|
||||
entry.player_state,
|
||||
entry.idle_reason,
|
||||
entry.extended_status.as_ref().map(|e| e.player_state)
|
||||
);
|
||||
}
|
||||
}
|
||||
MediaResponse::LoadFailed(f) => println!(" LOAD FAILED: {f:?}"),
|
||||
MediaResponse::LoadCancelled(c) => println!(" LOAD CANCELLED: {c:?}"),
|
||||
MediaResponse::Error(e) => println!(" MEDIA ERROR: {e:?}"),
|
||||
MediaResponse::InvalidRequest(r) => println!(" INVALID REQUEST: {r:?}"),
|
||||
other => println!(" other media message: {other:?}"),
|
||||
})
|
||||
});
|
||||
|
||||
let mirror_duration = Duration::from_secs(60);
|
||||
let mut elapsed = Duration::ZERO;
|
||||
while elapsed < mirror_duration && !pump.is_finished() {
|
||||
tokio::time::sleep(Duration::from_secs(1)).await;
|
||||
elapsed += Duration::from_secs(1);
|
||||
}
|
||||
if pump.is_finished() {
|
||||
println!("Cast session ended early (after {elapsed:?}) — connection likely dropped or the receiver stopped playback.");
|
||||
}
|
||||
|
||||
if let Err(e) = session.stop() {
|
||||
eprintln!("failed to cleanly stop the cast session: {e:?}");
|
||||
}
|
||||
pipeline.set_state(gst::State::Null)?;
|
||||
capture.close().await?;
|
||||
if let Err(panic) = pump.join() {
|
||||
eprintln!("cast session pump thread panicked: {panic:?}");
|
||||
}
|
||||
println!("Stopped.");
|
||||
|
||||
Ok(())
|
||||
}
|
||||
44
breadcast-core/src/examples/video_test.rs
Normal file
44
breadcast-core/src/examples/video_test.rs
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
//! Phase 2 step 2: opens the portal picker, captures video only, encodes
|
||||
//! via VA-API, and writes an HLS playlist+segments to a local dir. Run
|
||||
//! with: cargo run -p breadcast-core --example video_test [output_dir]
|
||||
//! Then, in another terminal: ffplay <output_dir>/playlist.m3u8
|
||||
|
||||
use breadcast_core::CaptureSession;
|
||||
use gstreamer as gst;
|
||||
use gstreamer::prelude::*;
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> anyhow::Result<()> {
|
||||
tracing_subscriber::fmt::init();
|
||||
gst::init()?;
|
||||
|
||||
let output_dir = std::env::args()
|
||||
.nth(1)
|
||||
.unwrap_or_else(|| "/tmp/breadcast-hls-test".to_string());
|
||||
let output_dir = std::path::PathBuf::from(output_dir);
|
||||
|
||||
println!("Opening the portal screen-cast picker (look for a system dialog)...");
|
||||
let capture = CaptureSession::start().await?;
|
||||
println!("Got PipeWire video node id: {}", capture.video_node_id());
|
||||
|
||||
let pipeline = breadcast_core::pipeline::build_video_pipeline(capture.video_node_id(), &output_dir)?;
|
||||
pipeline.set_state(gst::State::Playing)?;
|
||||
println!(
|
||||
"Pipeline playing. Writing HLS to {}. Point ffplay/vlc at {}/playlist.m3u8 now.",
|
||||
output_dir.display(),
|
||||
output_dir.display()
|
||||
);
|
||||
println!("Running for 30s...");
|
||||
|
||||
let outcome =
|
||||
breadcast_core::pipeline::run_until_error_or_timeout(&pipeline, gst::ClockTime::from_seconds(30));
|
||||
|
||||
pipeline.set_state(gst::State::Null)?;
|
||||
capture.close().await?;
|
||||
|
||||
match outcome? {
|
||||
breadcast_core::pipeline::RunOutcome::Eos => println!("Done: pipeline reached end-of-stream (source stopped sharing)."),
|
||||
breadcast_core::pipeline::RunOutcome::Timeout => println!("Done: 30s elapsed with no pipeline errors."),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
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")
|
||||
}
|
||||
86
breadcast-core/src/ipc.rs
Normal file
86
breadcast-core/src/ipc.rs
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
//! Message shapes for breadcastd's private control socket
|
||||
//! (`$XDG_RUNTIME_DIR/breadcast/breadcastd.sock`, newline-delimited JSON) —
|
||||
//! shared between `breadcastd` (which serves them) and `breadcast` (the
|
||||
//! GTK4 popup, which is the only client) so the two never drift out of
|
||||
//! sync with each other. See `breadcastd/src/ipc.rs` for the actual
|
||||
//! socket-handling code; this crate only holds the wire types, since it's
|
||||
//! the one both binaries already depend on.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ClientRequest {
|
||||
pub id: u64,
|
||||
pub method: String,
|
||||
#[serde(default)]
|
||||
pub params: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum ServerMessage {
|
||||
#[serde(rename = "response")]
|
||||
Response {
|
||||
id: u64,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
result: Option<Value>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
error: Option<String>,
|
||||
},
|
||||
#[serde(rename = "event")]
|
||||
Event { event: String, data: Value },
|
||||
}
|
||||
|
||||
impl ServerMessage {
|
||||
pub fn ok(id: u64, result: Value) -> Self {
|
||||
Self::Response { id, result: Some(result), error: None }
|
||||
}
|
||||
|
||||
pub fn err(id: u64, error: impl std::fmt::Display) -> Self {
|
||||
Self::Response { id, result: None, error: Some(error.to_string()) }
|
||||
}
|
||||
}
|
||||
|
||||
/// Which casting protocol a [`DeviceInfo`]/[`StateInfo::Casting`] refers to
|
||||
/// — Cast V2/Cast Streaming ([`crate::cast_sender`]/[`crate::caststream`])
|
||||
/// or DLNA/UPnP AVTransport ([`crate::dlna`]). The two protocols discover
|
||||
/// disjoint device populations (see `dlna/mod.rs`'s doc comment), so a
|
||||
/// unified device list needs this to tell them apart — a Cast device id and
|
||||
/// a DLNA device url share no namespace, but both are opaque strings to the
|
||||
/// GTK client, which otherwise has no way to know which `start_cast`
|
||||
/// dispatch path it's picking.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Protocol {
|
||||
Cast,
|
||||
Dlna,
|
||||
}
|
||||
|
||||
/// A discovered device as reported by `"list_devices"`/`"device_list_changed"`
|
||||
/// — a protocol-agnostic projection of either a [`crate::CastDevice`] (`id`
|
||||
/// is its mDNS id) or a [`crate::dlna::DlnaDevice`] (`id` is its description URL,
|
||||
/// the closest thing DLNA has to a stable identifier — see
|
||||
/// `dlna/device.rs`'s doc comment on [`crate::dlna::DlnaDevice::url`]).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DeviceInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub model: String,
|
||||
pub protocol: Protocol,
|
||||
}
|
||||
|
||||
/// The daemon's current activity — pushed as a `"state_changed"` event and
|
||||
/// returned by the `"get_state"` request.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "state", rename_all = "snake_case")]
|
||||
pub enum StateInfo {
|
||||
Idle,
|
||||
Casting { device_id: String, device_name: String, protocol: Protocol },
|
||||
}
|
||||
|
||||
/// Returns `$XDG_RUNTIME_DIR/breadcast/breadcastd.sock`.
|
||||
pub fn socket_path() -> anyhow::Result<std::path::PathBuf> {
|
||||
let runtime_dir = std::env::var("XDG_RUNTIME_DIR").map_err(|_| anyhow::anyhow!("XDG_RUNTIME_DIR is not set"))?;
|
||||
Ok(std::path::Path::new(&runtime_dir).join("breadcast").join("breadcastd.sock"))
|
||||
}
|
||||
17
breadcast-core/src/lib.rs
Normal file
17
breadcast-core/src/lib.rs
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
pub mod capture;
|
||||
pub mod cast_sender;
|
||||
pub mod caststream;
|
||||
pub mod device;
|
||||
pub mod discovery;
|
||||
pub mod dlna;
|
||||
pub mod http_server;
|
||||
pub mod ipc;
|
||||
pub mod net;
|
||||
pub mod pipeline;
|
||||
|
||||
pub use capture::CaptureSession;
|
||||
pub use cast_sender::CastSession;
|
||||
pub use caststream::{CastStreamEvent, CastStreamSender, VideoParams as CastStreamVideoParams};
|
||||
pub use device::CastDevice;
|
||||
pub use discovery::{Discovery, DiscoveryEvent};
|
||||
pub use dlna::{DlnaDevice, DlnaDiscovery, DlnaDiscoveryEvent, DlnaSession};
|
||||
61
breadcast-core/src/net.rs
Normal file
61
breadcast-core/src/net.rs
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
use std::net::{IpAddr, Ipv4Addr};
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
|
||||
/// Finds this machine's LAN-reachable IPv4 address by enumerating network
|
||||
/// interfaces directly, rather than the more common "UDP-connect to a
|
||||
/// public address and read back the local endpoint" trick — that trick
|
||||
/// asks the kernel's *default route*, which Tailscale can silently take
|
||||
/// over via policy routing (confirmed on a real machine: with Tailscale
|
||||
/// active, `ip route get 8.8.8.8` resolves via `tailscale0`, not the real
|
||||
/// LAN interface). Using it handed a Cast device a 100.64.0.0/10 Tailscale
|
||||
/// CGNAT address it could never reach, which surfaced only as an
|
||||
/// unexplained `LOAD FAILED` — exactly the class of silent failure this
|
||||
/// project has already spent a lot of time chasing. Explicitly excluding
|
||||
/// VPN/virtual interface name prefixes sidesteps the whole problem
|
||||
/// regardless of what the default route happens to be.
|
||||
///
|
||||
/// Shared by every casting protocol (Cast, DLNA, ...) — they all need to
|
||||
/// embed this machine's own address in a URL handed to a receiver device.
|
||||
pub fn local_lan_ip() -> Result<IpAddr> {
|
||||
const EXCLUDED_PREFIXES: &[&str] = &["tailscale", "wg", "docker", "veth", "br-", "virbr", "lo"];
|
||||
|
||||
let output = std::process::Command::new("ip")
|
||||
.args(["-4", "-o", "addr", "show", "scope", "global", "up"])
|
||||
.output()
|
||||
.context("failed to run `ip addr show` to find this machine's LAN IP")?;
|
||||
if !output.status.success() {
|
||||
anyhow::bail!("`ip addr show` exited with {}", output.status);
|
||||
}
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
|
||||
let mut candidates: Vec<(String, Ipv4Addr)> = Vec::new();
|
||||
for line in text.lines() {
|
||||
// Format: "3: wlan0 inet 10.179.161.89/23 brd ... scope global dynamic wlan0"
|
||||
let mut fields = line.split_whitespace();
|
||||
let Some(_index) = fields.next() else { continue };
|
||||
let Some(iface) = fields.next() else { continue };
|
||||
if EXCLUDED_PREFIXES.iter().any(|p| iface.starts_with(p)) {
|
||||
continue;
|
||||
}
|
||||
if fields.next() != Some("inet") {
|
||||
continue;
|
||||
}
|
||||
let Some(cidr) = fields.next() else { continue };
|
||||
let Some(addr) = cidr.split('/').next().and_then(|a| a.parse::<Ipv4Addr>().ok()) else { continue };
|
||||
if !addr.is_private() {
|
||||
continue;
|
||||
}
|
||||
candidates.push((iface.to_string(), addr));
|
||||
}
|
||||
|
||||
// Prefer a conventionally-named physical/Wi-Fi interface when there's a
|
||||
// choice, but any private, non-excluded address is acceptable.
|
||||
candidates.sort_by_key(|(iface, _)| !(iface.starts_with("wl") || iface.starts_with("en") || iface.starts_with("eth")));
|
||||
|
||||
candidates
|
||||
.into_iter()
|
||||
.map(|(_, addr)| IpAddr::V4(addr))
|
||||
.next()
|
||||
.context("no LAN-reachable IPv4 address found (excluding loopback/VPN/virtual interfaces) — is this machine connected to a network?")
|
||||
}
|
||||
312
breadcast-core/src/pipeline/mod.rs
Normal file
312
breadcast-core/src/pipeline/mod.rs
Normal file
|
|
@ -0,0 +1,312 @@
|
|||
use std::os::unix::fs::DirBuilderExt;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Duration;
|
||||
|
||||
use anyhow::{Context, Result, bail};
|
||||
use gstreamer as gst;
|
||||
use gstreamer::prelude::*;
|
||||
use gstreamer_app as gst_app;
|
||||
use gstreamer_video as gst_video;
|
||||
|
||||
/// Builds (but doesn't start) the capture → encode → mux → HLS pipeline for
|
||||
/// a single video source. `output_dir` is created if it doesn't exist;
|
||||
/// `hlssink3` writes `segment%05d.ts` files and `playlist.m3u8` there.
|
||||
///
|
||||
/// Idempotently calls `gst::init()` itself rather than requiring every
|
||||
/// caller to remember to — `gst::parse::launch` panics
|
||||
/// (`assert_initialized_main_thread!()`) if GStreamer was never
|
||||
/// initialized, which every current caller happens to do first, but that's
|
||||
/// a footgun for a `pub` function once something other than a smoke-test
|
||||
/// example calls it (e.g. `breadcastd`).
|
||||
///
|
||||
/// Uses a `gst::parse::launch` string rather than the typed element-builder
|
||||
/// API — this is the prototyping-first approach: get the pipeline shape
|
||||
/// right and provable against real hardware before hardening it into typed
|
||||
/// Rust with per-element error handling. Filesystem paths are deliberately
|
||||
/// *not* interpolated into that string, though: `output_dir` is caller
|
||||
/// (eventually user-facing) input, and a path containing `"`, `\`, or `!`
|
||||
/// would either break `gst::parse::launch`'s own string syntax or inject
|
||||
/// extra elements into the parsed graph. `hlssink3`'s `location`/
|
||||
/// `playlist-location` are set as plain element properties after parsing
|
||||
/// instead, which need no escaping at all.
|
||||
///
|
||||
/// `vah264enc` (not the deprecated `vaapih264enc`) needs `gst-plugin-va`
|
||||
/// installed (`pacman -S gst-plugin-va`) — it's a separate Arch package
|
||||
/// from `gst-plugins-bad` itself, not bundled in. `hlssink3` similarly
|
||||
/// needs `gst-plugin-hlssink3`. `hlssink3` (not `hlscmafsink`) is
|
||||
/// deliberate: the Chromecast Default Media Receiver only plays classic
|
||||
/// MPEG-TS-segmented HLS, not fMP4/CMAF — and `hlssink3` does its own
|
||||
/// internal MPEG-TS muxing per segment via its `video`/`audio` *request*
|
||||
/// pads, so no separate `mpegtsmux` element goes in front of it (confirmed
|
||||
/// via `gst-inspect-1.0 hlssink3`: its only pad templates are `video` and
|
||||
/// `audio`, not a generic always-available `sink`).
|
||||
pub fn build_video_pipeline(video_node_id: u32, output_dir: &Path) -> Result<gst::Pipeline> {
|
||||
gst::init().context("failed to initialize GStreamer")?;
|
||||
|
||||
std::fs::create_dir_all(output_dir)
|
||||
.with_context(|| format!("failed to create HLS output dir {}", output_dir.display()))?;
|
||||
|
||||
let segment_pattern = output_dir.join("segment%05d.ts");
|
||||
let playlist_path = output_dir.join("playlist.m3u8");
|
||||
|
||||
// Capped to 1280x720@30 and H.264 Main profile: this machine's native
|
||||
// 1920x1200 at an uncapped framerate (observed via ffprobe as a
|
||||
// nonsensical 120fps/240tbr — pipewiresrc doesn't cap the rate on its
|
||||
// own) was confirmed via a real Chromecast to fetch fine over HTTP
|
||||
// (200s on the playlist and first segment) but then fail to actually
|
||||
// play — consistent with exceeding what an older Chromecast's H.264
|
||||
// decoder profile/level supports, not a network/CORS/HLS-structure
|
||||
// problem. 720p30 Main is a conservative, broadly-compatible baseline;
|
||||
// revisit upward (1080p, High profile) once a specific device's real
|
||||
// ceiling is known. Note this ignores the source's 16:10 aspect ratio
|
||||
// (stretches to 16:9) — correctness/compatibility first, an
|
||||
// aspect-preserving scale (letterbox via `videoscale
|
||||
// add-borders=true`) is a follow-up, not a blocker.
|
||||
let pipeline_str = format!(
|
||||
"pipewiresrc path={video_node_id} do-timestamp=true ! \
|
||||
videoconvert ! videoscale ! videorate ! \
|
||||
video/x-raw,format=NV12,width=1280,height=720,framerate=30/1 ! \
|
||||
vah264enc bitrate=4000 key-int-max=60 rate-control=cbr ! \
|
||||
video/x-h264,profile=main ! \
|
||||
h264parse config-interval=1 ! \
|
||||
hlssink.video \
|
||||
hlssink3 name=hlssink target-duration=2 playlist-length=6 max-files=10"
|
||||
);
|
||||
|
||||
let element = gst::parse::launch(&pipeline_str).context("failed to parse GStreamer pipeline")?;
|
||||
let Ok(pipeline) = element.downcast::<gst::Pipeline>() else {
|
||||
bail!("parsed GStreamer graph was not a top-level Pipeline");
|
||||
};
|
||||
|
||||
let hlssink = pipeline
|
||||
.by_name("hlssink")
|
||||
.context("parsed pipeline has no element named 'hlssink'")?;
|
||||
hlssink.set_property(
|
||||
"location",
|
||||
segment_pattern.to_str().context("HLS segment path is not valid UTF-8")?,
|
||||
);
|
||||
hlssink.set_property(
|
||||
"playlist-location",
|
||||
playlist_path.to_str().context("HLS playlist path is not valid UTF-8")?,
|
||||
);
|
||||
|
||||
Ok(pipeline)
|
||||
}
|
||||
|
||||
/// Builds (but doesn't start) the capture → encode → `appsink` pipeline used
|
||||
/// for low-latency Cast Streaming mirroring (see [`crate::caststream`]) —
|
||||
/// the counterpart to [`build_video_pipeline`]'s HLS path, which the
|
||||
/// Chromecast Mirroring receiver can't play (it speaks RTP, not HLS).
|
||||
///
|
||||
/// Differs from the HLS pipeline in exactly the ways that matter for
|
||||
/// feeding openscreen's `Sender::EnqueueFrame`, which wants standalone,
|
||||
/// receiver-decodable Annex-B access units, not a muxed container:
|
||||
/// - `h264parse config-interval=-1` re-inserts SPS/PPS before every key
|
||||
/// frame (not just once) — required since there's no container-level
|
||||
/// "here's the codec config" the receiver can fall back on, unlike HLS's
|
||||
/// `.ts` segments.
|
||||
/// - An explicit `video/x-h264,stream-format=byte-stream,alignment=au` caps
|
||||
/// filter after `h264parse` — `vah264enc`'s default output is `avc`
|
||||
/// (4-byte length-prefixed NAL units, the ISO/MP4 convention), but
|
||||
/// RTP/Cast Streaming payloads need Annex-B (0x00 0x00 0x00 0x01 start
|
||||
/// codes), the same format `h264parse` can produce but won't unless asked.
|
||||
/// - `appsink` instead of `hlssink3`: each pulled `gst::Sample` is one
|
||||
/// complete access unit (`alignment=au`), ready to hand to
|
||||
/// `CastStreamSender::enqueue_frame` — see `cast_stream_test.rs` for the
|
||||
/// pull loop. `sync=false` since these are being forwarded over the
|
||||
/// network as fast as produced, not paced against a clock for local
|
||||
/// playback; `drop=true`/`max-buffers=4` bounds memory if the pull loop
|
||||
/// ever falls behind rather than growing an unbounded backlog.
|
||||
///
|
||||
/// Returns the pipeline plus its `appsink` and the `vah264enc` element (the
|
||||
/// latter so a caller can drive its `bitrate` property from
|
||||
/// `CastStreamSender::estimated_bandwidth_bps()` — see
|
||||
/// [`request_key_frame`]/`set_video_bitrate_kbps` for the two knobs a
|
||||
/// congestion-control loop needs).
|
||||
pub fn build_video_pipeline_for_streaming(
|
||||
video_node_id: u32,
|
||||
) -> Result<(gst::Pipeline, gst_app::AppSink, gst::Element)> {
|
||||
gst::init().context("failed to initialize GStreamer")?;
|
||||
|
||||
// Same 1280x720@30 Main-profile baseline as build_video_pipeline, for
|
||||
// the same reason (see its doc comment) -- broad decoder compatibility
|
||||
// first, revisit upward once a specific device's real ceiling is known.
|
||||
let pipeline_str = "pipewiresrc path=%VIDEO_NODE_ID% do-timestamp=true ! \
|
||||
videoconvert ! videoscale ! videorate ! \
|
||||
video/x-raw,format=NV12,width=1280,height=720,framerate=30/1 ! \
|
||||
vah264enc name=venc bitrate=4000 key-int-max=60 rate-control=cbr ! \
|
||||
video/x-h264,profile=main ! \
|
||||
h264parse name=h264parse config-interval=-1 ! \
|
||||
video/x-h264,stream-format=byte-stream,alignment=au ! \
|
||||
appsink name=appsink emit-signals=false sync=false max-buffers=4 drop=true"
|
||||
.replace("%VIDEO_NODE_ID%", &video_node_id.to_string());
|
||||
|
||||
let element = gst::parse::launch(&pipeline_str).context("failed to parse GStreamer pipeline")?;
|
||||
let Ok(pipeline) = element.downcast::<gst::Pipeline>() else {
|
||||
bail!("parsed GStreamer graph was not a top-level Pipeline");
|
||||
};
|
||||
|
||||
let appsink = pipeline
|
||||
.by_name("appsink")
|
||||
.context("parsed pipeline has no element named 'appsink'")?
|
||||
.downcast::<gst_app::AppSink>()
|
||||
.map_err(|_| anyhow::anyhow!("'appsink' element was not a GstAppSink"))?;
|
||||
|
||||
let encoder = pipeline.by_name("venc").context("parsed pipeline has no element named 'venc'")?;
|
||||
|
||||
Ok((pipeline, appsink, encoder))
|
||||
}
|
||||
|
||||
/// Pulls one complete Annex-B H.264 access unit from `appsink`, blocking
|
||||
/// until one is available. Returns `None` once the pipeline reaches EOS or
|
||||
/// the sink otherwise stops (e.g. pipeline torn down from another thread).
|
||||
pub fn pull_encoded_frame(appsink: &gst_app::AppSink) -> Result<Option<(Vec<u8>, bool, i64)>> {
|
||||
let sample = match appsink.pull_sample() {
|
||||
Ok(sample) => sample,
|
||||
Err(_) if appsink.is_eos() => return Ok(None),
|
||||
Err(e) => bail!("appsink pull_sample failed: {e}"),
|
||||
};
|
||||
let buffer = sample.buffer().context("pulled sample had no buffer")?;
|
||||
let map = buffer.map_readable().context("failed to map sample buffer readable")?;
|
||||
let is_key_frame = !buffer.flags().contains(gst::BufferFlags::DELTA_UNIT);
|
||||
// `.unwrap_or(0)` rather than propagating a missing PTS as an error:
|
||||
// CastStreamSender::enqueue_frame only needs monotonically-increasing,
|
||||
// real-elapsed-time-proportional values (see its doc comment) -- an
|
||||
// occasional buffer with no PTS shouldn't abort an otherwise-live
|
||||
// stream over it.
|
||||
let capture_time_us = buffer.pts().map(|t| t.useconds() as i64).unwrap_or(0);
|
||||
Ok(Some((map.as_slice().to_vec(), is_key_frame, capture_time_us)))
|
||||
}
|
||||
|
||||
/// Sends an upstream "force key unit" event from `appsink`, propagating to
|
||||
/// `vah264enc` and causing it to emit an IDR frame on its next output --
|
||||
/// the mechanism `cast_stream_test.rs`'s pull loop uses when
|
||||
/// `CastStreamSender::needs_key_frame()` reports true.
|
||||
pub fn request_key_frame(appsink: &gst_app::AppSink) {
|
||||
let event = gst_video::UpstreamForceKeyUnitEvent::builder().all_headers(true).build();
|
||||
let _ = appsink.send_event(event);
|
||||
}
|
||||
|
||||
/// Updates `encoder`'s (a `vah264enc` element, as returned by
|
||||
/// [`build_video_pipeline_for_streaming`]) target bitrate in kbps. Meant to
|
||||
/// be driven periodically from `CastStreamSender::estimated_bandwidth_bps()`
|
||||
/// -- this vendored subset of openscreen only does flow control, not
|
||||
/// congestion control (see `Sender`'s class comment in
|
||||
/// `breadcast-caststream-sys/vendor/openscreen/cast/streaming/public/sender.h`),
|
||||
/// so actually throttling the encoder in response is this project's own
|
||||
/// responsibility.
|
||||
pub fn set_video_bitrate_kbps(encoder: &gst::Element, kbps: u32) {
|
||||
encoder.set_property("bitrate", kbps);
|
||||
}
|
||||
|
||||
/// Why [`run_until_error_or_timeout`] returned successfully — distinct from
|
||||
/// each other because a caller (e.g. a UI reporting "mirroring stopped")
|
||||
/// needs to tell "the user hit Stop-sharing in the portal picker, EOS is
|
||||
/// expected" apart from "nothing happened for N seconds, which for a smoke
|
||||
/// test just means the run duration elapsed normally." Collapsing both into
|
||||
/// a bare `Ok(())`, as a previous version of this function did, is exactly
|
||||
/// the kind of silent-success-that-wasn't this project has already lost a
|
||||
/// lot of time chasing elsewhere (the Cast `LOAD FAILED` debugging).
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum RunOutcome {
|
||||
/// The pipeline reached end-of-stream (e.g. the portal source ended
|
||||
/// because the user stopped sharing).
|
||||
Eos,
|
||||
/// `timeout` elapsed with no error or EOS.
|
||||
Timeout,
|
||||
}
|
||||
|
||||
/// Blocks the calling thread until the pipeline reports an error or EOS, or
|
||||
/// `timeout` elapses (whichever first). Returns which of those happened, or
|
||||
/// `Err` on a real pipeline error. Meant for smoke-testing from a
|
||||
/// synchronous `main`/example; the real daemon will want an async/watch-based
|
||||
/// version instead of blocking a thread.
|
||||
pub fn run_until_error_or_timeout(pipeline: &gst::Pipeline, timeout: gst::ClockTime) -> Result<RunOutcome> {
|
||||
let bus = pipeline.bus().context("pipeline has no bus")?;
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from(timeout);
|
||||
|
||||
loop {
|
||||
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
|
||||
if remaining.is_zero() {
|
||||
return Ok(RunOutcome::Timeout);
|
||||
}
|
||||
let Some(msg) = bus.timed_pop_filtered(
|
||||
gst::ClockTime::from_mseconds(remaining.as_millis().min(500) as u64),
|
||||
&[gst::MessageType::Error, gst::MessageType::Eos, gst::MessageType::Warning],
|
||||
) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
use gst::MessageView;
|
||||
match msg.view() {
|
||||
MessageView::Error(e) => {
|
||||
bail!(
|
||||
"GStreamer pipeline error from {:?}: {} ({:?})",
|
||||
e.src().map(|s| s.path_string()),
|
||||
e.error(),
|
||||
e.debug()
|
||||
);
|
||||
}
|
||||
MessageView::Warning(w) => {
|
||||
tracing::warn!(
|
||||
src = ?w.src().map(|s| s.path_string()),
|
||||
error = %w.error(),
|
||||
"GStreamer pipeline warning"
|
||||
);
|
||||
}
|
||||
MessageView::Eos(_) => return Ok(RunOutcome::Eos),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A private, per-run HLS output directory under `$XDG_RUNTIME_DIR` (0700,
|
||||
/// tmpfs, cleared on logout) rather than a fixed path under `/tmp`. A fixed
|
||||
/// `/tmp` path is predictable and `/tmp` is world-writable: another local
|
||||
/// user could pre-create or symlink it before this runs, to either read the
|
||||
/// screen-recording segments this then serves on the LAN, or plant files
|
||||
/// for the HTTP server to hand out. `XDG_RUNTIME_DIR` is exclusively
|
||||
/// readable/writable by this user, so predictability of the subdirectory
|
||||
/// name under it doesn't matter.
|
||||
///
|
||||
/// `label` distinguishes concurrent sessions of different kinds (e.g.
|
||||
/// `"cast-mirror"` vs `"dlna-mirror"`) from colliding on the same path if
|
||||
/// ever run at once on the same machine; the process id further
|
||||
/// distinguishes concurrent runs of the *same* kind.
|
||||
pub fn hls_output_dir(label: &str) -> Result<PathBuf> {
|
||||
let runtime_dir = std::env::var("XDG_RUNTIME_DIR").context("XDG_RUNTIME_DIR is not set")?;
|
||||
let dir = PathBuf::from(runtime_dir)
|
||||
.join("breadcast")
|
||||
.join(format!("{label}-{}", std::process::id()));
|
||||
std::fs::DirBuilder::new()
|
||||
.recursive(true)
|
||||
.mode(0o700)
|
||||
.create(&dir)
|
||||
.with_context(|| format!("failed to create HLS output dir {}", dir.display()))?;
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Polls `playlist_path` until it contains at least `min_segments` `#EXTINF`
|
||||
/// entries or `timeout` elapses. Casting/loading a URL before the encode
|
||||
/// pipeline has actually produced any segments — which an earlier version
|
||||
/// of this project's examples did unconditionally, via a fixed sleep
|
||||
/// regardless of whether encoding had actually started — hands the
|
||||
/// receiver a 404 playlist and produces an unexplained load failure.
|
||||
pub async fn wait_for_playlist_segments(playlist_path: &Path, min_segments: usize, timeout: Duration) -> Result<()> {
|
||||
let deadline = tokio::time::Instant::now() + timeout;
|
||||
loop {
|
||||
if let Ok(contents) = std::fs::read_to_string(playlist_path) {
|
||||
if contents.lines().filter(|l| l.starts_with("#EXTINF")).count() >= min_segments {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
if tokio::time::Instant::now() >= deadline {
|
||||
anyhow::bail!(
|
||||
"HLS playlist at {} never accumulated {min_segments} segments within {timeout:?} — \
|
||||
the encode pipeline may not be producing output (check for a GStreamer error above)",
|
||||
playlist_path.display()
|
||||
);
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue