Implement Cast Streaming mirroring, DLNA casting, daemon+GUI, and breadd integration
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:
Breadway 2026-08-03 09:07:21 +08:00
parent 887c29002f
commit 8c745d18e0
283 changed files with 36788 additions and 0 deletions

63
breadcast-core/Cargo.toml Normal file
View file

@ -0,0 +1,63 @@
[package]
name = "breadcast-core"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
description = "Shared, GTK-agnostic logic for breadcast: Chromecast + DLNA discovery/control, capture/encode/serve pipeline"
[dependencies]
anyhow = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tracing = { workspace = true }
tokio = { workspace = true }
mdns-sd = "0.20"
rust_cast = { version = "0.21", features = ["thread_safe"] }
ashpd = { version = "0.13", features = ["screencast"] }
gstreamer = "0.25"
gstreamer-app = "0.25"
gstreamer-video = "0.25"
tiny_http = "0.12"
rupnp = "3.0.0"
futures-util = "0.3.33"
breadcast-caststream-sys = { path = "../breadcast-caststream-sys" }
[dev-dependencies]
tracing-subscriber = { workspace = true }
[[example]]
name = "discover"
path = "src/examples/discover.rs"
[[example]]
name = "cast_test"
path = "src/examples/cast_test.rs"
[[example]]
name = "capture_test"
path = "src/examples/capture_test.rs"
[[example]]
name = "video_test"
path = "src/examples/video_test.rs"
[[example]]
name = "mirror_test"
path = "src/examples/mirror_test.rs"
[[example]]
name = "apple_hls_test"
path = "src/examples/apple_hls_test.rs"
[[example]]
name = "dlna_discover"
path = "src/examples/dlna_discover.rs"
[[example]]
name = "dlna_mirror_test"
path = "src/examples/dlna_mirror_test.rs"
[[example]]
name = "cast_stream_test"
path = "src/examples/cast_stream_test.rs"

View file

@ -0,0 +1,3 @@
pub mod portal;
pub use portal::CaptureSession;

View 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")
}
}

View 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;
}
}
}
}

View 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);
}

View 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,
}

View 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(())
}
}

View 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,
}

View 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)
}
}

View 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);

View 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("&amp;"),
'<' => escaped.push_str("&lt;"),
'>' => escaped.push_str("&gt;"),
'"' => escaped.push_str("&quot;"),
'\'' => escaped.push_str("&apos;"),
other => escaped.push(other),
}
}
escaped
}

View 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(())
}

View 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(())
}

View 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(())
}

View 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(())
}

View 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(())
}

View 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(())
}

View 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(())
}

View 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(())
}

View 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(())
}

View 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
View 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
View 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
View 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?")
}

View 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;
}
}