Skip to content

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.

#[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.

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.

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).

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.

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.

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.

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:

classrule
SafeAlways 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.
NetworkRuns 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.
ExecSubprocess 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.

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.

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 = true

Unknown keys are ignored — adding a new option never breaks an existing config, and typos silently fall back to defaults rather than erroring.

Two built-in fetchers cover custom widgets without code:

  • basic_static — ships a constant body. The widget's format field 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's id; file_format picks 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.json
    fetcher = "basic_read_store"
    file_format = "json"
    render = { type = "chart_sparkline" } # pins the shape to NumberSeries

    See 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.

The recipe lives in src/fetcher/ — the shortest path is to copy an existing family module (clock/, system/, github/) and adapt. High-level steps:

  1. Add a new file (or module) under src/fetcher/ with your struct.
  2. Implement Fetcher (async) or RealtimeFetcher (sync).
  3. Declare shapes() honestly — every shape your fetcher may emit, not an aspirational list.
  4. Pick a Safety class. Err on the side of Network if config can steer the destination.
  5. Register in Registry::with_builtins().
  6. Add a sample row to the catalog issue (#62–#68) so the roadmap stays honest.
  7. 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.