breadcast/breadcastd/src/ipc.rs
Breadway 8c745d18e0
Some checks failed
dev release / build (push) Failing after 12s
Implement Cast Streaming mirroring, DLNA casting, daemon+GUI, and breadd integration
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.
2026-08-03 09:07:21 +08:00

175 lines
7.5 KiB
Rust

//! 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}\"")),
}
}