1 Architecture
Vincent S. edited this page 2026-06-28 01:08:01 +02:00
This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

Architecture

Crates

Three crates:

  • alfred-core — config + shared types + the worker IPC protocol. No I/O.
  • alfred — the daemon and the CLI client.
  • alfred-ytm — an isolated worker binary, deliberately kept out of the daemon's dependency tree (see Worker subprocesses).

alfred-core::types::ModuleKind is the central enum nearly every subsystem matches on. See Modules for the full "adding a module" checklist.

The module contract — modules/mod.rs

Every module implements Module { kind, capabilities, collect }.

collect() returns a ModuleOutput with waybar fields (text / tooltip / class / percentage), plus:

  • a stale flag,
  • alert_data: HashMap<String, f64> — structured numerics (e.g. media_playing, muted, net_connected),
  • meta: HashMap<String, String> — structured strings (e.g. artist / title / album / sink).

spawn_all launches one tokio task per enabled module via the spawn_if_enabled! macro. Each task loops on tokio::select! between its interval ticker and a force-refresh broadcast channel, then calls state.publish(). Default intervals and the stale_after window (3× interval, min 5s) live here.

Shared state — serve/state.rs

AppState is cheaply-cloneable, all-Arc fields: the per-module cache, one broadcast::Sender per module for /stream subscribers, one force-refresh sender per module, capabilities, and an optional Journal + AutomationHandle.

publish():

  1. stamps updated_at_ms,
  2. writes the cache,
  3. fans out to /stream subscribers,
  4. runs log_state_transitions.

log_state_transitions derives semantic events (track changed, sink changed, link up/down) and persisted music history from each module's structured alert_data and metanot by parsing the human-facing tooltip. A module that wants its transitions logged must populate meta / alert_data, not just the tooltip.

HTTP layer — serve/server.rs

An axum router shared by the Unix and TCP listeners. See HTTP API for the route list.

control_* handlers run the side effect (often shelling out to wpctl / playerctl / hyprctl, or native Hyprland dispatch), then publish + force_refresh so the next get is fresh. YTMD volume has an optimistic-update path (ytmd_volume_hint + a debounced background flush worker) to keep scroll handlers snappy. The /music/* routes forward to the ytm worker subprocess.

Worker subprocesses — worker.rs + alfred_core::ipc

For capabilities that need a heavy or brittle dependency the lean daemon shouldn't link (currently YouTube Music search/library via rustypipe, ~227 crates), the daemon spawns a child process and talks to it over newline-delimited JSON on the child's stdio (DaemonMsg / WorkerMsg / WorkerRole, PROTO_VERSION; stderr = worker logs, kept off the protocol stream).

  • WorkerClient does the handshake and an id-correlated request/response mux.
  • WorkerSupervisor (held in AppState, e.g. ytm_worker) keeps one worker alive and respawns it on crash.
  • sibling_binary() resolves the helper next to the running alfred exe (falling back to $PATH).

The first consumer is alfred-ytm serve, which answers music.search / music.library.

The protocol is deliberately built to generalize to modules: a worker can declare WorkerRole::Module { kind, capabilities } and stream WorkerMsg::Output (each → state.publish) — the path for moving in-process modules to subprocesses for hot-reload/live-edit. Only the RPC Methods role is wired today; dispatch() logs Output / Event as a placeholder.

⚠️ WorkerClient must retain the tokio::process::Child (it's kill_on_drop); dropping the handle kills the worker. ⚠️ Dep-pin gotcha: pin alloc-no-stdlib to 2.0.4 in the lockfile or brotli (pulled by reqwest) fails to compile.

Client — client/

Each subcommand opens its own connection to the socket and speaks minimal hand-rolled HTTP (client/mod.rs). Client subcommands must keep stdout clean (they're waybar children) — that's why logging is only initialised for serve. doctor / diagnose / config-check are introspection helpers; note automation / config-check / list read the on-disk config directly, not what the running daemon loaded.

Alert engine — serve/alerts.rs

A separate 500ms task snapshots the cache, flattens every module's alert_data into one flat namespace, evaluates simple field op value conditions, and fires shell exec commands (with {{field}} substitution) on threshold cross / recover, with per-alert cooldown.

Automation engine — automation/

Runs on its own dedicated std::thread (not the tokio runtime), driven by an mpsc channel of EngineMessages. See Automation for the Lua API.

Journal — journal.rs

SQLite-backed event log (state transitions, alerts, actions, errors, staleness) plus a music-play history table, written from a background thread. Queried by alfred events / alfred music. Default DB under $XDG_STATE_HOME (falls back via HOME).