Implement Cast Streaming mirroring, DLNA casting, daemon+GUI, and breadd integration
Some checks failed
dev release / build (push) Failing after 12s
Some checks failed
dev release / build (push) Failing after 12s
Builds out the full v1 scope: a vendored+patched openscreen subset for low-latency Cast Streaming (Mirroring receiver 0F5096E8) alongside the existing Cast V2/HLS and new DLNA/AVTransport casting paths, breadcastd's Idle/Casting state machine with a private IPC socket, the breadcast GTK4 popup as a thin IPC client, and bread.cast.*/bread.command.cast.* breadd integration (device discovery, start/stop, mirroring lifecycle events). Also adds bakery/systemd/Forgejo CI packaging. Validated end-to-end against a real Chromecast/Google TV: negotiated Cast Streaming session, live pipeline playback, and daemon+GUI click-to-cast/ stop through the actual popup.
This commit is contained in:
parent
887c29002f
commit
8c745d18e0
283 changed files with 36788 additions and 0 deletions
175
breadcastd/src/ipc.rs
Normal file
175
breadcastd/src/ipc.rs
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
//! breadcastd's private control socket: newline-delimited JSON over a
|
||||
//! `UnixListener` at `$XDG_RUNTIME_DIR/breadcast/breadcastd.sock`, separate
|
||||
//! from breadd's own pub/sub bus (`bread_events.rs`) — that's for external
|
||||
//! automation (a Hyprland keybind emitting `bread.command.cast.*`), this is
|
||||
//! for the `breadcast` GTK4 popup's actual live state, which needs pushed
|
||||
//! updates a best-effort pub/sub bus doesn't fit as naturally.
|
||||
//!
|
||||
//! The wire message types (`ClientRequest`/`ServerMessage`/`StateInfo`) live
|
||||
//! in `breadcast_core::ipc`, shared with the `breadcast` GUI client so the
|
||||
//! two never drift out of sync.
|
||||
//!
|
||||
//! Multiple clients (in practice: zero or one `breadcast` popup instance at
|
||||
//! a time, but nothing here assumes that) can connect concurrently; each
|
||||
//! gets its own copy of every broadcast event.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use breadcast_core::ipc::{ClientRequest, ServerMessage, socket_path};
|
||||
use serde_json::Value;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::{UnixListener, UnixStream};
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
|
||||
use crate::daemon::DaemonCommand;
|
||||
|
||||
/// Removes any stale socket left behind by an unclean shutdown, then binds
|
||||
/// and serves forever. `daemon_tx` is how request handling reaches the
|
||||
/// daemon's single state-owning actor (see `daemon.rs`); `events_tx`'s
|
||||
/// receiver half is (re-)subscribed per connection, so every connection
|
||||
/// sees the same `device_list_changed`/`state_changed` events from the
|
||||
/// point it connects onward.
|
||||
pub async fn serve(daemon_tx: mpsc::Sender<DaemonCommand>, events_tx: broadcast::Sender<ServerMessage>) -> Result<()> {
|
||||
let socket_path = socket_path()?;
|
||||
if let Some(parent) = socket_path.parent() {
|
||||
std::fs::create_dir_all(parent)
|
||||
.with_context(|| format!("failed to create socket dir {}", parent.display()))?;
|
||||
}
|
||||
// A stale socket file from an unclean previous exit makes bind() fail
|
||||
// with AddrInUse even though nothing is actually listening -- the
|
||||
// daemon's own singleton lock (see main.rs) already guarantees at most
|
||||
// one live breadcastd, so it's always safe to clear this before binding.
|
||||
let _ = std::fs::remove_file(&socket_path);
|
||||
|
||||
let listener = UnixListener::bind(&socket_path)
|
||||
.with_context(|| format!("failed to bind {}", socket_path.display()))?;
|
||||
tracing::info!(path = %socket_path.display(), "IPC socket listening");
|
||||
|
||||
loop {
|
||||
let (stream, _addr) = listener.accept().await.context("failed to accept IPC connection")?;
|
||||
let daemon_tx = daemon_tx.clone();
|
||||
let events_rx = events_tx.subscribe();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_connection(stream, daemon_tx, events_rx).await {
|
||||
tracing::debug!(error = %e, "IPC connection ended");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_connection(
|
||||
stream: UnixStream,
|
||||
daemon_tx: mpsc::Sender<DaemonCommand>,
|
||||
mut events_rx: broadcast::Receiver<ServerMessage>,
|
||||
) -> Result<()> {
|
||||
let (read_half, write_half) = stream.into_split();
|
||||
let mut reader = BufReader::new(read_half).lines();
|
||||
|
||||
// A single task owns the write half and serializes everything written
|
||||
// to it (responses and pushed events both funnel through `writer_tx`)
|
||||
// -- two independent tasks (one per direction) writing directly to the
|
||||
// same stream could interleave partial JSON lines.
|
||||
let (writer_tx, mut writer_rx) = mpsc::unbounded_channel::<ServerMessage>();
|
||||
let writer_task = tokio::spawn(async move {
|
||||
let mut write_half = write_half;
|
||||
while let Some(msg) = writer_rx.recv().await {
|
||||
let Ok(mut line) = serde_json::to_string(&msg) else { continue };
|
||||
line.push('\n');
|
||||
if write_half.write_all(line.as_bytes()).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let forward_tx = writer_tx.clone();
|
||||
let forward_task = tokio::spawn(async move {
|
||||
loop {
|
||||
match events_rx.recv().await {
|
||||
Ok(event) => {
|
||||
if forward_tx.send(event).is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(_)) => continue,
|
||||
Err(broadcast::error::RecvError::Closed) => return,
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
while let Some(line) = reader.next_line().await? {
|
||||
if line.trim().is_empty() {
|
||||
continue;
|
||||
}
|
||||
let request: ClientRequest = match serde_json::from_str(&line) {
|
||||
Ok(req) => req,
|
||||
Err(e) => {
|
||||
tracing::debug!(error = %e, %line, "malformed IPC request, ignoring");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let response = handle_request(request, &daemon_tx).await;
|
||||
if writer_tx.send(response).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
forward_task.abort();
|
||||
drop(writer_tx);
|
||||
let _ = writer_task.await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn handle_request(request: ClientRequest, daemon_tx: &mpsc::Sender<DaemonCommand>) -> ServerMessage {
|
||||
let id = request.id;
|
||||
match request.method.as_str() {
|
||||
"list_devices" => {
|
||||
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
|
||||
if daemon_tx.send(DaemonCommand::ListDevices { reply: reply_tx }).await.is_err() {
|
||||
return ServerMessage::err(id, "daemon actor has stopped");
|
||||
}
|
||||
match reply_rx.await {
|
||||
Ok(devices) => ServerMessage::ok(id, serde_json::json!(devices)),
|
||||
Err(_) => ServerMessage::err(id, "daemon actor dropped the reply"),
|
||||
}
|
||||
}
|
||||
"get_state" => {
|
||||
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
|
||||
if daemon_tx.send(DaemonCommand::GetState { reply: reply_tx }).await.is_err() {
|
||||
return ServerMessage::err(id, "daemon actor has stopped");
|
||||
}
|
||||
match reply_rx.await {
|
||||
Ok(state) => ServerMessage::ok(id, serde_json::json!(state)),
|
||||
Err(_) => ServerMessage::err(id, "daemon actor dropped the reply"),
|
||||
}
|
||||
}
|
||||
"start_cast" => {
|
||||
let Some(device_id) = request.params.get("device_id").and_then(Value::as_str) else {
|
||||
return ServerMessage::err(id, "start_cast requires a string \"device_id\" param");
|
||||
};
|
||||
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
|
||||
if daemon_tx
|
||||
.send(DaemonCommand::StartCast { device_id: device_id.to_string(), reply: reply_tx })
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
return ServerMessage::err(id, "daemon actor has stopped");
|
||||
}
|
||||
match reply_rx.await {
|
||||
Ok(Ok(())) => ServerMessage::ok(id, Value::Null),
|
||||
Ok(Err(e)) => ServerMessage::err(id, e),
|
||||
Err(_) => ServerMessage::err(id, "daemon actor dropped the reply"),
|
||||
}
|
||||
}
|
||||
"stop_cast" => {
|
||||
let (reply_tx, reply_rx) = tokio::sync::oneshot::channel();
|
||||
if daemon_tx.send(DaemonCommand::StopCast { reply: reply_tx }).await.is_err() {
|
||||
return ServerMessage::err(id, "daemon actor has stopped");
|
||||
}
|
||||
match reply_rx.await {
|
||||
Ok(Ok(())) => ServerMessage::ok(id, Value::Null),
|
||||
Ok(Err(e)) => ServerMessage::err(id, e),
|
||||
Err(_) => ServerMessage::err(id, "daemon actor dropped the reply"),
|
||||
}
|
||||
}
|
||||
other => ServerMessage::err(id, format!("unknown method \"{other}\"")),
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue