breadbar/src/widgets/client.rs
Breadway 175af5d483
Some checks failed
dev release / build (push) Failing after 2s
Will change this commit message to mean something later
2026-07-22 19:53:51 +08:00

106 lines
4.9 KiB
Rust

//! Connects to breadd's IPC socket and keeps the bar's widget set in sync.
//!
//! breadbar is level-triggered here, not edge-triggered: `bread.widget.*`
//! events are used purely as a "something changed, go re-fetch" signal, not
//! applied as incremental patches. Every dirty signal (and the initial
//! connect) re-requests the complete widget list and hands it to `update()`
//! as one `AppInput::WidgetsUpdate`, which reconciles the bar's containers
//! from scratch. This sidesteps event-ordering/drop concerns entirely, and
//! widget registries are small enough that re-fetching the full list on
//! every change is not a real cost.
use crate::{App, AppInput};
use bread_shared::widget::WidgetSpec;
use bread_utils::bread_client::BreadClient;
use relm4::ComponentSender;
use std::time::Duration;
/// breadbar's own registered app id — already reserved in
/// `bread_shared::apps::KNOWN_APPS` (see `Documentation.md`'s Namespaces
/// section). Used both to fetch widgets and to publish click events.
pub const APP_ID: &str = "bar";
/// Safety-net poll interval. `bread.widget.cleared` (emitted once per
/// daemon reload, including a full restart — see breadd's `reload_internal`)
/// is meant to catch the case where a module stops registering widgets
/// without anything else re-triggering a fetch, but a *restart* (as opposed
/// to a live `bread reload`) drops the subscription entirely; if that one
/// event fires before `BreadClient::subscribe`'s reconnect-with-backoff
/// finishes re-establishing the stream, it's missed and there's no second
/// chance from the event side. This poll is the backstop for that race —
/// infrequent enough that it's not a real cost, frequent enough that a missed
/// event self-heals well within a session rather than needing a manual
/// breadbar restart to clear stale widgets.
const POLL_INTERVAL: Duration = Duration::from_secs(30);
/// Start the widget subsystem: an initial fetch, a live subscription that
/// re-fetches on every `bread.widget.*` change, and a low-frequency poll as
/// a backstop against the reconnect race described above. Call once from
/// `init`.
pub fn spawn(sender: ComponentSender<App>) {
// BreadClient::request is blocking std I/O; run it off the tokio
// runtime breadbar's other pollers rely on, same as the reasoning in
// `BreadClient::subscribe`'s own background-thread design.
let initial = sender.clone();
std::thread::spawn(move || fetch_and_send(&initial));
// `subscribe` already reconnects with backoff on its own background
// thread for the lifetime of the process — there is no natural point to
// stop it before the app exits, so the handle is intentionally leaked
// rather than threaded through App just to be dropped at shutdown.
let live = sender.clone();
let client = BreadClient::connect(APP_ID);
let subscription = client.subscribe("bread.widget.**", move |_event| {
fetch_and_send(&live);
});
std::mem::forget(subscription);
let polled = sender.clone();
relm4::spawn(async move {
loop {
tokio::time::sleep(POLL_INTERVAL).await;
let polled = polled.clone();
std::thread::spawn(move || fetch_and_send(&polled));
}
});
}
fn fetch_and_send(sender: &ComponentSender<App>) {
let client = BreadClient::connect(APP_ID);
let Some(result) = client.request("widgets.list", serde_json::Value::Null) else {
return;
};
// Decode element-wise rather than `Vec<WidgetSpec>` in one shot — one
// malformed entry from any module (a bad `class`, an unknown enum value,
// ...) must not blank out every other module's widgets.
let raw: Vec<serde_json::Value> = serde_json::from_value(result).unwrap_or_default();
let specs: Vec<WidgetSpec> = raw
.into_iter()
.filter_map(|v| {
// `id`/`module` are read before the value is consumed by the
// failed parse below, so a malformed spec still names itself in
// the warning instead of just printing a bare serde error.
let id = v.get("id").and_then(|x| x.as_str()).unwrap_or("?").to_string();
let module = v.get("module").and_then(|x| x.as_str()).unwrap_or("?").to_string();
match serde_json::from_value::<WidgetSpec>(v) {
Ok(spec) => Some(spec),
Err(e) => {
eprintln!(
"breadbar: dropping malformed widget spec (id={id}, module={module}): {e}"
);
None
}
}
})
.collect();
sender.input(AppInput::WidgetsUpdate(specs));
}
/// Publish a widget click back to breadd. `action` is whatever opaque value
/// the Lua module put in the clicked node's `on_click`.
pub fn emit_click(widget_id: &str, action: &serde_json::Value) {
BreadClient::connect(APP_ID).emit(
"bread.bar.widget_clicked",
serde_json::json!({ "widget_id": widget_id, "action": action }),
);
}