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

20
breadcast/Cargo.toml Normal file
View file

@ -0,0 +1,20 @@
[package]
name = "breadcast"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
description = "breadcast GTK4 popup: device picker and cast controls (thin IPC client of breadcastd)"
[[bin]]
name = "breadcast"
path = "src/main.rs"
[dependencies]
anyhow = { workspace = true }
serde_json = { workspace = true }
breadcast-core = { path = "../breadcast-core" }
bread-theme = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1", features = ["gtk"] }
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.7.1", features = ["gtk"] }
gtk4 = { version = "0.11", features = ["v4_12"] }
gtk4-layer-shell = "0.8"

62
breadcast/src/css.rs Normal file
View file

@ -0,0 +1,62 @@
use bread_theme::Palette;
/// Plain GTK4 CSS (not libadwaita) — matches `bos-settings`'/`breadclip`'s
/// precedent: libadwaita rows can't be width-constrained from outside a
/// fixed-width popup panel the way a plain `ListBoxRow` can.
pub fn build_css(palette: &Palette) -> String {
format!(
r#"
.cast-panel {{
background-color: {bg};
border-radius: 12px;
border: 1px solid alpha({fg}, 0.08);
padding: 12px;
}}
.cast-title {{
font-weight: 700;
font-size: 1.1em;
color: {fg};
}}
.cast-status-pill {{
border-radius: 999px;
padding: 3px 10px;
font-size: 0.85em;
background-color: {surface};
color: {overlay};
}}
.cast-status-pill.casting {{
background-color: alpha({accent}, 0.25);
color: {accent};
}}
.cast-device-row {{
border-radius: 8px;
padding: 8px 10px;
color: {fg};
}}
.cast-device-row:hover {{
background-color: alpha({fg}, 0.06);
}}
.cast-device-row:selected {{
background-color: alpha({accent}, 0.18);
}}
.cast-device-model {{
font-size: 0.85em;
color: {overlay};
}}
.cast-empty-label {{
color: {overlay};
padding: 24px 8px;
}}
.cast-stop-button {{
background-color: alpha(#e35b5b, 0.15);
color: #e35b5b;
border-radius: 8px;
}}
"#,
bg = palette.background,
fg = palette.foreground,
surface = palette.color0,
overlay = palette.color7,
accent = palette.color4,
)
}

View file

@ -0,0 +1,68 @@
//! A thin, synchronous client for breadcastd's IPC socket (see
//! `breadcast_core::ipc` for the wire types and
//! `breadcastd/src/ipc.rs` for the server side). No async runtime here —
//! GTK4 already has its own main loop (glib), so this uses a plain
//! blocking reader thread feeding an `mpsc::Receiver` the GTK side polls
//! via `glib::timeout_add_local` (see `main.rs`), rather than pulling in
//! tokio just for one socket.
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::UnixStream;
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::mpsc;
use anyhow::{Context, Result};
use breadcast_core::ipc::{ClientRequest, ServerMessage, socket_path};
pub struct IpcClient {
write_half: UnixStream,
next_id: AtomicU64,
}
impl IpcClient {
/// Connects to breadcastd's socket and starts a background thread
/// forwarding every parsed `ServerMessage` (both responses and pushed
/// events — the caller distinguishes them, see `ServerMessage`'s
/// variants) to the returned receiver, until the connection closes
/// (breadcastd not running, or it exited).
pub fn connect() -> Result<(Self, mpsc::Receiver<ServerMessage>)> {
let path = socket_path()?;
let write_half = UnixStream::connect(&path)
.with_context(|| format!("failed to connect to breadcastd at {} — is it running?", path.display()))?;
let read_half = write_half.try_clone().context("failed to duplicate the IPC socket handle")?;
let (tx, rx) = mpsc::channel();
std::thread::spawn(move || {
let reader = BufReader::new(read_half);
for line in reader.lines() {
let Ok(line) = line else { break };
if line.trim().is_empty() {
continue;
}
match serde_json::from_str::<ServerMessage>(&line) {
Ok(message) => {
if tx.send(message).is_err() {
break;
}
}
Err(e) => eprintln!("breadcast: malformed IPC message from breadcastd, ignoring: {e} ({line})"),
}
}
});
Ok((Self { write_half, next_id: AtomicU64::new(1) }, rx))
}
/// Sends a request and returns its id (so the caller can match it
/// against the `ServerMessage::Response` that arrives later on the
/// receiver from [`Self::connect`] — this method doesn't itself wait
/// for a reply, matching the GTK main loop's non-blocking event style).
pub fn send(&mut self, method: &str, params: serde_json::Value) -> Result<u64> {
let id = self.next_id.fetch_add(1, Ordering::Relaxed);
let request = ClientRequest { id, method: method.to_string(), params };
let mut line = serde_json::to_string(&request).context("failed to serialize IPC request")?;
line.push('\n');
self.write_half.write_all(line.as_bytes()).context("failed to write to breadcastd's IPC socket")?;
Ok(id)
}
}

202
breadcast/src/main.rs Normal file
View file

@ -0,0 +1,202 @@
//! breadcast — GTK4 layer-shell popup: a thin IPC client of `breadcastd`
//! (see `breadcast_core::ipc` for the wire protocol). Shows discovered Cast
//! devices and start/stop controls; holds no pipeline or protocol code of
//! its own — that all lives in `breadcastd`, so closing this popup never
//! interrupts an active cast.
mod css;
mod ipc_client;
use std::cell::RefCell;
use std::rc::Rc;
use bread_theme::load_palette;
use breadcast_core::ipc::{DeviceInfo, Protocol, ServerMessage, StateInfo};
use gtk4::prelude::*;
use gtk4::{Align, Application, Box as GBox, Button, Label, ListBox, Orientation, SelectionMode, glib};
use ipc_client::IpcClient;
const PANEL_WIDTH: i32 = 360;
fn main() {
let _singleton_guard = match bread_utils::singleton::toggle_or_kill("breadcast") {
Ok(bread_utils::singleton::Toggle::Started(guard)) => Some(guard),
Ok(bread_utils::singleton::Toggle::KilledExisting) => return,
Err(e) => {
eprintln!("breadcast: single-instance lock unavailable ({e}); continuing without it");
None
}
};
let app = Application::builder().application_id("com.breadway.breadcast").build();
app.connect_activate(build_ui);
app.run();
}
fn build_ui(app: &Application) {
bread_theme::gtk::apply_shared();
bread_theme::gtk::apply_app_css(|| css::build_css(&load_palette()));
let window = bread_utils::gtk_popup::new_overlay_window(app, "breadcast");
let panel = GBox::new(Orientation::Vertical, 8);
panel.add_css_class("cast-panel");
panel.set_size_request(PANEL_WIDTH, -1);
panel.set_halign(Align::Center);
panel.set_valign(Align::Center);
let header = GBox::new(Orientation::Horizontal, 8);
let title = Label::new(Some("Cast"));
title.add_css_class("cast-title");
title.set_hexpand(true);
title.set_halign(Align::Start);
let status_pill = Label::new(Some("Idle"));
status_pill.add_css_class("cast-status-pill");
header.append(&title);
header.append(&status_pill);
panel.append(&header);
let stop_button = Button::with_label("Stop mirroring");
stop_button.add_css_class("cast-stop-button");
stop_button.set_visible(false);
panel.append(&stop_button);
let list = ListBox::new();
list.set_selection_mode(SelectionMode::Browse);
panel.append(&list);
let empty_label = Label::new(Some("Searching for devices..."));
empty_label.add_css_class("cast-empty-label");
panel.append(&empty_label);
window.set_child(Some(&panel));
bread_utils::gtk_popup::close_on_outside_click(&window, &panel, {
let window = window.clone();
move || window.close()
});
match IpcClient::connect() {
Ok((client, rx)) => {
let client = Rc::new(RefCell::new(client));
let _ = client.borrow_mut().send("list_devices", serde_json::Value::Null);
let _ = client.borrow_mut().send("get_state", serde_json::Value::Null);
list.connect_row_activated({
let client = client.clone();
move |_, row| {
let Some(device_id) = (unsafe { row.data::<String>("device_id") }) else { return };
let device_id = unsafe { device_id.as_ref() }.clone();
let _ = client.borrow_mut().send("start_cast", serde_json::json!({ "device_id": device_id }));
}
});
stop_button.connect_clicked({
let client = client.clone();
move |_| {
let _ = client.borrow_mut().send("stop_cast", serde_json::Value::Null);
}
});
let list = list.clone();
let empty_label = empty_label.clone();
let status_pill = status_pill.clone();
let stop_button = stop_button.clone();
glib::timeout_add_local(std::time::Duration::from_millis(100), move || {
while let Ok(message) = rx.try_recv() {
handle_server_message(message, &list, &empty_label, &status_pill, &stop_button);
}
glib::ControlFlow::Continue
});
}
Err(e) => {
empty_label.set_label(&format!("breadcastd isn't running ({e})"));
}
}
window.present();
}
/// Both a successful `"list_devices"`/`"get_state"` response and the
/// corresponding pushed event carry the same JSON shape in `result`/`data`
/// respectively — this normalizes both into one dispatch so there's only
/// one device-list/state rendering path to keep in sync.
fn handle_server_message(
message: ServerMessage,
list: &ListBox,
empty_label: &Label,
status_pill: &Label,
stop_button: &Button,
) {
let payload = match message {
ServerMessage::Event { event, data } => Some((event, data)),
ServerMessage::Response { result: Some(result), .. } if result.is_array() => {
Some(("device_list_changed".to_string(), result))
}
ServerMessage::Response { result: Some(result), .. } if result.get("state").is_some() => {
Some(("state_changed".to_string(), result))
}
ServerMessage::Response { error: Some(error), .. } => {
eprintln!("breadcast: request failed: {error}");
None
}
_ => None,
};
let Some((event, data)) = payload else { return };
match event.as_str() {
"device_list_changed" => update_device_list(list, empty_label, data),
"state_changed" => update_state(status_pill, stop_button, data),
_ => {}
}
}
fn update_device_list(list: &ListBox, empty_label: &Label, data: serde_json::Value) {
let Ok(devices) = serde_json::from_value::<Vec<DeviceInfo>>(data) else { return };
while let Some(row) = list.row_at_index(0) {
list.remove(&row);
}
empty_label.set_visible(devices.is_empty());
list.set_visible(!devices.is_empty());
for device in &devices {
let row = gtk4::ListBoxRow::new();
unsafe { row.set_data("device_id", device.id.clone()) };
let row_box = GBox::new(Orientation::Vertical, 2);
row_box.add_css_class("cast-device-row");
let name = Label::new(Some(&device.name));
name.set_halign(Align::Start);
let model = Label::new(Some(&format!("{} · {}", device.model, protocol_label(device.protocol))));
model.add_css_class("cast-device-model");
model.set_halign(Align::Start);
row_box.append(&name);
row_box.append(&model);
row.set_child(Some(&row_box));
list.append(&row);
}
}
fn protocol_label(protocol: Protocol) -> &'static str {
match protocol {
Protocol::Cast => "Cast",
Protocol::Dlna => "DLNA",
}
}
fn update_state(status_pill: &Label, stop_button: &Button, data: serde_json::Value) {
let Ok(state) = serde_json::from_value::<StateInfo>(data) else { return };
match state {
StateInfo::Idle => {
status_pill.set_label("Idle");
status_pill.remove_css_class("casting");
stop_button.set_visible(false);
}
StateInfo::Casting { device_name, .. } => {
status_pill.set_label(&format!("Casting to {device_name}"));
status_pill.add_css_class("casting");
stop_button.set_visible(true);
}
}
}