Fetcher
A fetcher produces a Payload for a widget to render. It's the WHAT
half of the composition — it doesn't know who will draw its output,
just that it must return a body whose shape it declared.
Fetchers come in two flavors, distinguished by how often they run and what I/O they may do:
- Cached — touches I/O (git, HTTP, filesystem). Runs on a detached child, writes to the on-disk cache, next render reads from the cache.
- Realtime — the value is "right now". Runs synchronously on every draw tick. Must be under a millisecond and infallible.
Pick flavor by asking "can this fetch ever take more than a millisecond or ever fail?" If yes, it's Cached. If genuinely no — wall-clock math, in-memory counters — it's Realtime.
The Cached contract
Section titled “The Cached contract”#[async_trait]pub trait Fetcher: Send + Sync { fn name(&self) -> &str; fn safety(&self) -> Safety; fn shapes(&self) -> &[Shape]; fn default_shape(&self) -> Shape { self.shapes()[0] } fn option_schemas(&self) -> &[OptionSchema] { &[] } fn sample_body(&self, shape: Shape) -> Option<Body> { … canonical fallback … } fn cache_key(&self, ctx: &FetchContext) -> String { default_cache_key(…) } async fn fetch(&self, ctx: &FetchContext) -> Result<Payload, FetchError>;}Cached fetchers power most of the catalog: git_*, github_*,
weather, quote_of_day, and anything user-written via
basic_read_store. They run off the critical render path, so the
splash paints instantly with whatever's in the cache and the fresh
payload lands on the next cd.
fetch(ctx)
Section titled “fetch(ctx)”Called from a detached child via splashboard --fetch-only. Receives
a FetchContext carrying the widget's format, options, shape,
and a per-fetch timeout. Returns Result<Payload, FetchError>; errors
surface as an in-band placeholder in the widget's slot on the next
render.
Cache behaviour
Section titled “Cache behaviour”The cache key is fetcher.cache_key(ctx). The default covers the
common case (name + format); fetchers whose output depends on more
(cwd, repo, URL) override it to include those. Two widgets that share
a cache key share a payload — which is how clock with the same
format in two different widgets only refreshes once.
Cache files live at $HOME/.splashboard/cache/<key>.json with a
sibling <key>.lock for the refresh-coalescing lock. TTL is
configurable per widget in settings ([general] / per-widget in
config).
sample_body(shape)
Section titled “sample_body(shape)”A representative payload body used for docs previews (and the
dashboard snapshots under docs-site/src/assets/rendered/). Defaults
to samples::canonical_sample(shape); fetchers override to surface
something closer to real output ("main +2 ◆3" for git_status,
"14:32" for clock). Only touched by docs / tests — the runtime
always goes through the real fetch / compute path.
The Realtime contract
Section titled “The Realtime contract”pub trait RealtimeFetcher: Send + Sync { fn name(&self) -> &str; fn safety(&self) -> Safety; fn shapes(&self) -> &[Shape]; fn default_shape(&self) -> Shape { self.shapes()[0] } fn option_schemas(&self) -> &[OptionSchema] { &[] } fn sample_body(&self, shape: Shape) -> Option<Body> { … } fn compute(&self, ctx: &FetchContext) -> Payload;}Realtime fetchers are the "per-frame" crowd: clock, clock_derived,
clock_ratio, clock_timezones, clock_almanac, system_*,
quote_of_day. compute is called on every draw tick with the widget's
context; the output is used immediately without cache.
The contract is strict: < 1 ms, infallible, no I/O. Anything that could block the render thread (disk reads, HTTP, shelling out) belongs in a cached fetcher.
FetchContext
Section titled “FetchContext”Same context type for both flavors, populated from the widget config:
pub struct FetchContext { pub widget_id: String, pub format: Option<String>, // e.g. "%H:%M" for clock pub timeout: Duration, // per-fetch ceiling (Cached only) pub file_format: Option<String>, // ReadStore payload encoding pub shape: Option<Shape>, // target shape for multi-shape fetchers pub options: Option<toml::Value>, // deserialized by each fetcher}Multi-shape fetchers (clock, basic_read_store) branch on
ctx.shape inside fetch / compute to pick which variant to emit.
Single-shape fetchers ignore it.
Safety classes
Section titled “Safety classes”Every fetcher declares a Safety at compile time. The trust gate
consults it to decide whether the widget runs or renders a
🔒 requires trust placeholder in an untrusted local config:
| class | rule |
|---|---|
Safe | Always runs. Local-only reads, or network calls with hard-coded hosts (api.github.com in the fetcher struct). The credential can only leave to the known destination. |
Network | Runs only after splashboard trust for local project dashboards. For configs whose URL or query is user-supplied (RSS, calendar feeds, custom HTTP). The config can steer traffic to an arbitrary host — which is the entire threat. |
Exec | Subprocess widgets. Permanently closed — splashboard is a curated renderer, not a shell script host. The variant exists so the trust gate stays future-proof. |
Home-scoped configs ($HOME/.splashboard/*.dashboard.toml) are
implicitly trusted — you own HOME, so anything you put there is
authoritative. The trust gate only applies to project-local dashboards
that travel with a cloned repo. See
Trust model for the full threat model.
What counts as "fixed host"?
Section titled “What counts as "fixed host"?”github_* fetchers talk to api.github.com. The URL is hard-coded in
the fetcher struct; config can't redirect the token elsewhere. That's
Safe even though authentication is involved — the credential stays
with the known destination.
A hypothetical http_fetch that takes a user-supplied URL is
Network, even with no credentials — the config controls where
traffic goes, which is what the classification is checking.
Options
Section titled “Options”Fetchers accept typed options via [widget.options]. The fetcher
defines an Options struct with serde defaults and declares its
schema via option_schemas() so the docs generator can list every
knob per-fetcher automatically:
#[derive(Deserialize)]struct Options { #[serde(default = "default_count")] count: usize, #[serde(default)] relative: bool,}[[widget]]id = "commits"fetcher = "git_recent_commits" [widget.options] count = 5 relative = trueUnknown keys are ignored — adding a new option never breaks an existing config, and typos silently fall back to defaults rather than erroring.
Escape hatches
Section titled “Escape hatches”Two built-in fetchers cover custom widgets without code:
-
basic_static— ships a constant body. The widget'sformatfield is the literal value. Used for labels / section headers / decorative strings.[[widget]]id = "greeting_prefix"fetcher = "basic_static"format = "good "render = { type = "text_plain", align = "right" } -
basic_read_store— reads$HOME/.splashboard/store/<id>.<ext>and deserialises it as the shape the paired renderer accepts. The filename matches the widget'sid;file_formatpicks the encoding ("json","toml", or"text"). Ideal for "I want a widget for X and don't want to write a fetcher".[[widget]]id = "weight" # reads store/weight.jsonfetcher = "basic_read_store"file_format = "json"render = { type = "chart_sparkline" } # pins the shape to NumberSeriesSee ReadStore.
The rule of thumb:
- Needs curated UX (auth, rate limits, stateful update) → built-in fetcher PR.
- "A number I can cron to a file" →
basic_read_store. - A literal string →
basic_static.
Adding a fetcher
Section titled “Adding a fetcher”The recipe lives in src/fetcher/ — the shortest path is to copy an
existing family module (clock/, system/, github/) and adapt.
High-level steps:
- Add a new file (or module) under
src/fetcher/with your struct. - Implement
Fetcher(async) orRealtimeFetcher(sync). - Declare
shapes()honestly — every shape your fetcher may emit, not an aspirational list. - Pick a
Safetyclass. Err on the side ofNetworkif config can steer the destination. - Register in
Registry::with_builtins(). - Add a sample row to the catalog issue (#62–#68) so the roadmap stays honest.
- Run
cargo xtask— the reference page under/reference/fetchers/<family>/<name>/generates from your impl.
The test harness already checks every fetcher is registered,
round-trips its sample body through its declared shapes, and renders
via its default renderer without panicking. Add fetcher-specific
tests in a #[cfg(test)] block next to the impl.