Skip to main content

max / makenotwork

magicmirror: a source can be a file on disk, not only a URL Every source until now was a daemon to poll, which is the wrong shape for a producer that runs for hours and then stops. Making witchbroom grow an HTTP listener to be visible would pay a daemon's cost for something that is not one, and the property a batch producer actually needs is already here: the UI keeps the last payload across a failed poll, so a sweep that finished at 03:20 still reads at 15:00. A source now sets exactly one of `url` and `path`, and past the read nothing downstream can tell them apart. A missing file is the condition an unreachable host is in and reports the same way. A leading `~` expands, since the path an operator writes is the one in witchbroom's own default. A file source is refused `token_env` and `allow_actions` rather than having them ignored. Both are quiet when ignored and both read as a promise the source cannot keep -- authenticated, or drivable -- and an action is a URL with no base here to resolve it against.
Author: Max Johnson <me@maxj.phd> · 2026-08-12 23:06 UTC
Signed with PGP, not checked
Commit: 6ac466b7bb1f9260da21dc95fe0617b79f53ee46
Parent: 5260b44
5 files changed, +376 insertions, -66 deletions
@@ -2898,38 +2898,6 @@
2898 2898 source = "registry+https://github.com/rust-lang/crates.io-index"
2899 2899 checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b"
2900 2900
2901 - [[patch.unused]]
2902 - name = "docengine"
2903 - version = "0.7.0"
2904 -
2905 - [[patch.unused]]
2906 - name = "kberg"
2907 - version = "0.1.0"
2908 -
2909 - [[patch.unused]]
2910 - name = "painhours"
2911 - version = "0.1.0"
2912 -
2913 - [[patch.unused]]
2914 - name = "tagtree"
2915 - version = "0.4.0"
2916 -
2917 - [[patch.unused]]
2918 - name = "makeover-build"
2919 - version = "0.26.0"
2920 -
2921 - [[patch.unused]]
2922 - name = "makeover-immediate"
2923 - version = "0.15.0"
2924 -
2925 - [[patch.unused]]
2926 - name = "makeover-touch"
2927 - version = "0.10.0"
2928 -
2929 - [[patch.unused]]
2930 - name = "makeover-webview"
2931 - version = "0.33.0"
2932 -
2933 2901 [[patch.unused]]
2934 2902 name = "quasi-axum"
2935 2903 version = "0.1.0"
@@ -2954,6 +2922,22 @@
2954 2922 name = "quasi-webview"
2955 2923 version = "0.1.0"
2956 2924
2925 + [[patch.unused]]
2926 + name = "docengine"
2927 + version = "0.7.0"
2928 +
2929 + [[patch.unused]]
2930 + name = "kberg"
2931 + version = "0.1.0"
2932 +
2933 + [[patch.unused]]
2934 + name = "painhours"
2935 + version = "0.1.0"
2936 +
2937 + [[patch.unused]]
2938 + name = "tagtree"
2939 + version = "0.4.0"
2940 +
2957 2941 [[patch.unused]]
2958 2942 name = "synckit-client"
2959 2943 version = "0.8.0"
@@ -2961,3 +2945,19 @@
2961 2945 [[patch.unused]]
2962 2946 name = "synckit-config"
2963 2947 version = "0.2.0"
2948 +
2949 + [[patch.unused]]
2950 + name = "makeover-build"
2951 + version = "0.26.0"
2952 +
2953 + [[patch.unused]]
2954 + name = "makeover-immediate"
2955 + version = "0.15.0"
2956 +
2957 + [[patch.unused]]
2958 + name = "makeover-touch"
2959 + version = "0.10.0"
2960 +
2961 + [[patch.unused]]
2962 + name = "makeover-webview"
2963 + version = "0.33.0"
@@ -19,7 +19,7 @@
19 19 # intents-to-ratatui-colours mapping. No hex value is written in this crate.
20 20 makeover = "2.5.0"
21 21 makeover-tui = { version = "0.16.1", features = ["theme"] }
22 - tokio = { version = "1.50.0", features = ["macros", "rt-multi-thread", "time", "sync", "signal"] }
22 + tokio = { version = "1.50.0", features = ["macros", "rt-multi-thread", "time", "sync", "signal", "fs"] }
23 23 # `rustls-no-provider` rather than `rustls`: the latter is an alias for
24 24 # `__rustls-aws-lc-rs`, which links a C crypto backend. The provider is ring
25 25 # (pure Rust), installed process-wide by `crate::tls` before any client is
@@ -5,7 +5,15 @@
5 5 #
6 6 # Adding a service is an edit to this file and nothing else. magicmirror knows
7 7 # nothing about tiers, gates, apps or targets; it renders whatever the source
8 - # emits at GET /status.json. A new daemon gets a UI by emitting the payload.
8 + # emits. A new producer gets a UI by emitting the payload.
9 + #
10 + # A source sets exactly one of `url` and `path`. `url` is a daemon to poll, with
11 + # /status.json appended. `path` is a payload on disk, which is what a batch
12 + # producer leaves behind: something that runs for hours and then stops has
13 + # nothing to poll, and making it grow an HTTP listener would be paying a
14 + # daemon's cost for a producer that is not one. Either way magicmirror keeps the
15 + # last payload it read across a failed poll, so a producer that finished at 03:20
16 + # still reads at 15:00.
9 17 #
10 18 # magicmirror is read-only until a source sets allow_actions = true. A source
11 19 # with it off still shows the actions its nodes declare; it just refuses to
@@ -78,3 +86,22 @@
78 86 token_env = "POM_API_TOKEN"
79 87 poll_secs = 30
80 88 stale_after_secs = 600
89 +
90 + # witchbroom: the nightly repo x check sweep on astra. A batch producer, so this
91 + # is a file rather than a URL — it writes its payload at the end of a run and
92 + # stops. The path is witchbroom's own `status_path` default; change both or
93 + # neither. Only useful for a magicmirror on astra, since the file is local.
94 + #
95 + # A file source takes no token_env and no allow_actions: there is nothing to
96 + # authenticate to and no base to resolve an action's URL against. Both are
97 + # refused at load rather than ignored.
98 + #
99 + # stale_after_secs is a day and a half, not sixty seconds. The sweep runs
100 + # nightly, so anything shorter reports it degraded for twenty-three hours out of
101 + # every twenty-four; a day and a half is "it missed a night", which is the thing
102 + # actually worth seeing.
103 + [[source]]
104 + name = "witchbroom"
105 + path = "~/.local/state/witchbroom/status.json"
106 + poll_secs = 60
107 + stale_after_secs = 129600
@@ -5,7 +5,7 @@
5 5 //! than merely tabbed, and it is worth defending: the moment a source needs a
6 6 //! special case here, the shell has started learning domain vocabulary.
7 7
8 - use std::path::Path;
8 + use std::path::{Path, PathBuf};
9 9 use std::time::Duration;
10 10
11 11 use anyhow::{Context, Result};
@@ -39,12 +39,37 @@
39 39 pub sources: Vec<Source>,
40 40 }
41 41
42 + /// Where one source's payload comes from.
43 + ///
44 + /// Two shapes, because two shapes of producer exist. A daemon is polled over
45 + /// HTTP. A batch producer runs, writes, and stops, so it has nothing to poll:
46 + /// witchbroom's sweep takes hours and then the machine is idle until tomorrow.
47 + /// Making it grow an HTTP listener to be visible would be paying a daemon's
48 + /// cost for a producer that is not one, and the panel already keeps the last
49 + /// payload across a failed poll, which is exactly the property a batch producer
50 + /// needs. So a source can be a file, read on the same tick.
51 + #[derive(Debug, Clone, PartialEq, Eq)]
52 + pub(crate) enum Origin {
53 + /// The base URL with `/status.json` appended.
54 + Http(String),
55 + File(PathBuf),
56 + }
57 +
42 58 #[derive(Debug, Clone, Deserialize)]
43 59 pub(crate) struct Source {
44 60 /// Tab label, and the `source` name the payload should carry.
45 61 pub name: String,
46 - /// Base URL. `/status.json` is appended.
47 - pub url: String,
62 + /// Base URL of a daemon to poll. `/status.json` is appended. Exactly one of
63 + /// this and `path` is set; `validate` refuses anything else.
64 + #[serde(default)]
65 + pub url: Option<String>,
66 + /// A payload on disk, rewritten by a batch producer at the end of its run.
67 + ///
68 + /// A leading `~` expands at load. Nothing else about it is interpreted: the
69 + /// full filename is given, not a base to append `status.json` to, because a
70 + /// producer writing one file has no reason to own a directory.
71 + #[serde(default)]
72 + pub path: Option<PathBuf>,
48 73 /// Name of the environment variable holding this source's bearer token.
49 74 ///
50 75 /// The token is named, never inlined: this file describes topology and has
@@ -70,6 +95,21 @@
70 95 pub allow_actions: bool,
71 96 }
72 97
98 + /// `~` in a configured path, against `$HOME`.
99 + ///
100 + /// A file source's path is written by hand and the obvious one to write is
101 + /// `~/.local/state/witchbroom/status.json`. Without this it resolves to a
102 + /// literal `./~` and the tab reads as a producer that never ran.
103 + fn expand_tilde(p: &Path) -> PathBuf {
104 + let Ok(rest) = p.strip_prefix("~") else {
105 + return p.to_path_buf();
106 + };
107 + match std::env::var_os("HOME") {
108 + Some(home) => PathBuf::from(home).join(rest),
109 + None => p.to_path_buf(),
110 + }
111 + }
112 +
73 113 fn default_poll() -> u64 {
74 114 DEFAULT_POLL_SECS
75 115 }
@@ -79,8 +119,17 @@
79 119 }
80 120
81 121 impl Source {
82 - pub(crate) fn status_url(&self) -> String {
83 - format!("{}/status.json", self.url.trim_end_matches('/'))
122 + /// Where this source's payload comes from.
123 + ///
124 + /// Total, because `validate` has already refused every config that would
125 + /// make it partial. A source with neither is rejected at load rather than
126 + /// becoming a tab that can never say anything.
127 + pub(crate) fn origin(&self) -> Origin {
128 + match (&self.url, &self.path) {
129 + (Some(url), _) => Origin::Http(format!("{}/status.json", url.trim_end_matches('/'))),
130 + (None, Some(path)) => Origin::File(path.clone()),
131 + (None, None) => unreachable!("validate rejects a source with neither url nor path"),
132 + }
84 133 }
85 134
86 135 pub(crate) fn poll_interval(&self) -> Duration {
@@ -92,13 +141,20 @@
92 141 /// An action carries a path (`/rollback/b`); a producer that instead gives a
93 142 /// full URL is honored as-is, so a daemon can point an action at somewhere
94 143 /// other than itself without magicmirror second-guessing it.
144 + ///
145 + /// A file source is refused `allow_actions` at load and the UI refuses to
146 + /// fire without it, so the no-base arm is unreachable rather than a policy:
147 + /// a path is not something an action's URL can be resolved against.
95 148 pub(crate) fn action_url(&self, path: &str) -> String {
96 149 if path.starts_with("http://") || path.starts_with("https://") {
97 150 return path.to_string();
98 151 }
152 + let Some(base) = self.url.as_deref() else {
153 + return path.to_string();
154 + };
99 155 format!(
100 156 "{}/{}",
101 - self.url.trim_end_matches('/'),
157 + base.trim_end_matches('/'),
102 158 path.trim_start_matches('/')
103 159 )
104 160 }
@@ -116,8 +172,13 @@
116 172 pub(crate) fn load(path: &Path) -> Result<Self> {
117 173 let raw = std::fs::read_to_string(path)
118 174 .with_context(|| format!("reading magicmirror config at {}", path.display()))?;
119 - let cfg: Config = toml::from_str(&raw)
175 + let mut cfg: Config = toml::from_str(&raw)
120 176 .with_context(|| format!("parsing magicmirror config at {}", path.display()))?;
177 + for source in &mut cfg.sources {
178 + if let Some(p) = &source.path {
179 + source.path = Some(expand_tilde(p));
180 + }
181 + }
121 182 cfg.validate()?;
122 183 Ok(cfg)
123 184 }
@@ -134,11 +195,39 @@
134 195 "duplicate source name {:?}: tabs would be ambiguous",
135 196 source.name
136 197 );
137 - anyhow::ensure!(
138 - source.url.starts_with("http://") || source.url.starts_with("https://"),
139 - "source {:?} url must start with http:// or https://",
140 - source.name
141 - );
198 + match (&source.url, &source.path) {
199 + (Some(url), None) => anyhow::ensure!(
200 + url.starts_with("http://") || url.starts_with("https://"),
201 + "source {:?} url must start with http:// or https://",
202 + source.name
203 + ),
204 + (None, Some(_)) => {
205 + // Both are things a URL source has and a file has no
206 + // equivalent of, and both are quiet when ignored. A token
207 + // silently unused reads as a source that is authenticated;
208 + // `allow_actions = true` silently unused reads as a source
209 + // that can be driven. Refusing says which it is.
210 + anyhow::ensure!(
211 + source.token_env.is_none(),
212 + "source {:?} is a file and cannot present a bearer token",
213 + source.name
214 + );
215 + anyhow::ensure!(
216 + !source.allow_actions,
217 + "source {:?} is a file: an action is a URL and there is no base to \
218 + resolve one against",
219 + source.name
220 + );
221 + }
222 + (Some(_), Some(_)) => anyhow::bail!(
223 + "source {:?} sets both url and path: one payload, one origin",
224 + source.name
225 + ),
226 + (None, None) => anyhow::bail!(
227 + "source {:?} sets neither url nor path: it could never say anything",
228 + source.name
229 + ),
230 + }
142 231 }
143 232 Ok(())
144 233 }
@@ -178,7 +267,10 @@
178 267 "#,
179 268 );
180 269 let cfg = Config::load(&path).unwrap();
181 - assert_eq!(cfg.sources[0].status_url(), "http://fw13:8080/status.json");
270 + assert_eq!(
271 + cfg.sources[0].origin(),
272 + Origin::Http("http://fw13:8080/status.json".into())
273 + );
182 274 assert_eq!(cfg.sources[0].poll_interval().as_secs(), DEFAULT_POLL_SECS);
183 275 assert_eq!(
184 276 cfg.stale_after(&cfg.sources[0]),
@@ -219,7 +311,122 @@
219 311 "#,
220 312 );
221 313 let cfg = Config::load(&path).unwrap();
222 - assert_eq!(cfg.sources[0].status_url(), "http://fw13:8090/status.json");
314 + assert_eq!(
315 + cfg.sources[0].origin(),
316 + Origin::Http("http://fw13:8090/status.json".into())
317 + );
318 + }
319 +
320 + /// The batch-producer shape. witchbroom runs for hours and stops, so it has
321 + /// nothing to poll; the file it leaves behind is the source.
322 + #[test]
323 + fn a_source_can_be_a_file_on_disk() {
324 + let (_dir, path) = write(
325 + r#"
326 + [[source]]
327 + name = "witchbroom"
328 + path = "/var/lib/witchbroom/status.json"
329 + poll_secs = 60
330 + stale_after_secs = 129600
331 + "#,
332 + );
333 + let cfg = Config::load(&path).unwrap();
334 + assert_eq!(
335 + cfg.sources[0].origin(),
336 + Origin::File("/var/lib/witchbroom/status.json".into())
337 + );
338 + // A nightly producer is not stale at 60 seconds, and the per-source
339 + // override is what makes one legible next to a daemon polled at 5.
340 + assert_eq!(cfg.stale_after(&cfg.sources[0]).num_seconds(), 129_600);
341 + }
342 +
343 + /// The path an operator actually writes. Left literal it resolves to `./~`
344 + /// and the tab reads as a producer that never ran.
345 + #[test]
346 + fn a_leading_tilde_in_a_path_expands() {
347 + let home = std::env::var("HOME").unwrap();
348 + let (_dir, path) = write(
349 + r#"
350 + [[source]]
351 + name = "witchbroom"
352 + path = "~/.local/state/witchbroom/status.json"
353 + "#,
354 + );
355 + let cfg = Config::load(&path).unwrap();
356 + assert_eq!(
357 + cfg.sources[0].origin(),
358 + Origin::File(
359 + std::path::PathBuf::from(home).join(".local/state/witchbroom/status.json")
360 + )
361 + );
362 + }
363 +
364 + /// One payload, one origin. Both set is a config whose meaning has to be
365 + /// guessed, and either guess makes half of it dead text.
366 + #[test]
367 + fn a_source_sets_exactly_one_of_url_and_path() {
368 + let (_dir, path) = write(
369 + r#"
370 + [[source]]
371 + name = "witchbroom"
372 + url = "http://astra:9000"
373 + path = "/var/lib/witchbroom/status.json"
374 + "#,
375 + );
376 + assert!(
377 + Config::load(&path)
378 + .unwrap_err()
379 + .to_string()
380 + .contains("one payload, one origin")
381 + );
382 +
383 + let (_dir, path) = write(
384 + r#"
385 + [[source]]
386 + name = "witchbroom"
387 + "#,
388 + );
389 + assert!(
390 + Config::load(&path)
391 + .unwrap_err()
392 + .to_string()
393 + .contains("neither url nor path")
394 + );
395 + }
396 +
397 + /// Both of these are quiet when ignored, and both read as a promise the
398 + /// file source cannot keep: authenticated, or drivable.
399 + #[test]
400 + fn a_file_source_refuses_a_token_and_refuses_actions() {
401 + let (_dir, path) = write(
402 + r#"
403 + [[source]]
404 + name = "witchbroom"
405 + path = "/var/lib/witchbroom/status.json"
406 + token_env = "WITCHBROOM_TOKEN"
407 + "#,
408 + );
409 + assert!(
410 + Config::load(&path)
411 + .unwrap_err()
412 + .to_string()
413 + .contains("bearer token")
414 + );
415 +
416 + let (_dir, path) = write(
417 + r#"
418 + [[source]]
419 + name = "witchbroom"
420 + path = "/var/lib/witchbroom/status.json"
421 + allow_actions = true
422 + "#,
423 + );
424 + assert!(
425 + Config::load(&path)
426 + .unwrap_err()
427 + .to_string()
428 + .contains("no base to resolve")
429 + );
223 430 }
224 431
225 432 #[test]
@@ -1,17 +1,24 @@
1 - //! Fetching each source on its own interval.
1 + //! Reading each source on its own interval.
2 2 //!
3 3 //! One task per source, each reporting into a channel the UI drains. Sources
4 4 //! are independent on purpose: a daemon that hangs must not stop the others
5 5 //! from updating, because the tab that stops updating is exactly the one you
6 6 //! need to see.
7 + //!
8 + //! Two origins, one loop. An HTTP source is fetched and a file source is read,
9 + //! and past that nothing downstream can tell them apart: both produce a
10 + //! `Payload` or a short reason, on the same tick, into the same channel. A batch
11 + //! producer is legible here because the UI already keeps the last payload across
12 + //! a failed poll, so a sweep that finished at 03:20 still reads at 15:00.
7 13
14 + use std::path::Path;
8 15 use std::time::Duration;
9 16
10 17 use chrono::Utc;
11 18 use ops_status::Payload;
12 19 use tokio::sync::mpsc;
13 20
14 - use crate::config::Source;
21 + use crate::config::{Origin, Source};
15 22
16 23 /// A poll result, tagged with which source it came from.
17 24 pub(crate) struct Update {
@@ -39,21 +46,28 @@
39 46 }
40 47
41 48 async fn poll_forever(index: usize, source: Source, tx: mpsc::Sender<Update>) {
42 - let client = match crate::tls::client(REQUEST_TIMEOUT) {
43 - Ok(client) => client,
44 - Err(e) => {
45 - let _ = tx
46 - .send(Update {
47 - index,
48 - at: Utc::now(),
49 - result: Err(format!("client: {e}")),
50 - })
51 - .await;
52 - return;
49 + let origin = source.origin();
50 + // Built once per task, and only for an HTTP source: a file source has no
51 + // transport to fail, so a broken TLS setup must not take its tab down with
52 + // the others.
53 + let client = if matches!(origin, Origin::Http(_)) {
54 + match crate::tls::client(REQUEST_TIMEOUT) {
55 + Ok(client) => Some(client),
56 + Err(e) => {
57 + let _ = tx
58 + .send(Update {
59 + index,
60 + at: Utc::now(),
61 + result: Err(format!("client: {e}")),
62 + })
63 + .await;
64 + return;
65 + }
53 66 }
67 + } else {
68 + None
54 69 };
55 70
56 - let url = source.status_url();
57 71 let mut ticker = tokio::time::interval(source.poll_interval());
58 72 // A poll that overruns its interval must not cause a burst of catch-up
59 73 // requests at the next tick.
@@ -61,9 +75,15 @@
61 75
62 76 loop {
63 77 ticker.tick().await;
64 - let result = fetch(&client, &url, source.token().as_deref()).await;
65 - // The token is read per request rather than captured once, so rotating
66 - // it does not require restarting magicmirror.
78 + let result = match &origin {
79 + // The token is read per request rather than captured once, so
80 + // rotating it does not require restarting magicmirror.
81 + Origin::Http(url) => match &client {
82 + Some(client) => fetch(client, url, source.token().as_deref()).await,
83 + None => Err("client: not built".into()),
84 + },
85 + Origin::File(path) => read(path).await,
86 + };
67 87 if tx
68 88 .send(Update {
69 89 index,
@@ -103,6 +123,29 @@
103 123 serde_json::from_slice(&body).map_err(|e| format!("bad payload: {e}"))
104 124 }
105 125
126 + /// Read a payload a batch producer left on disk.
127 + ///
128 + /// Every way this fails is the same condition an unreachable daemon is in --
129 + /// nothing current to show -- so it reports the same shape, and the UI keeps the
130 + /// last good payload either way. A missing file is the ordinary case on a fresh
131 + /// machine: the producer has not run yet.
132 + async fn read(path: &Path) -> Result<Payload, String> {
133 + let bytes = tokio::fs::read(path).await.map_err(short_io_error)?;
134 + serde_json::from_slice(&bytes).map_err(|e| format!("bad payload: {e}"))
135 + }
136 +
137 + /// The rollup has one column for this, and an io error stringifies with the
138 + /// whole path in it, which is already the tab's own configuration.
139 + #[allow(clippy::needless_pass_by_value)]
140 + fn short_io_error(e: std::io::Error) -> String {
141 + match e.kind() {
142 + std::io::ErrorKind::NotFound => "no payload written yet".into(),
143 + std::io::ErrorKind::PermissionDenied => "permission denied".into(),
144 + std::io::ErrorKind::IsADirectory => "path is a directory".into(),
145 + kind => kind.to_string(),
146 + }
147 + }
148 +
106 149 /// reqwest errors stringify into a paragraph with a full URL chain. The rollup
107 150 /// has one column for this, so keep the part that says what went wrong.
108 151 // e is consumed into the short string.
@@ -138,7 +181,40 @@
138 181 "#,
139 182 )
140 183 .unwrap();
141 - assert_eq!(source.status_url(), "http://fw13:8080/status.json");
184 + assert_eq!(
185 + source.origin(),
186 + Origin::Http("http://fw13:8080/status.json".into())
187 + );
188 + }
189 +
190 + /// The batch producer's ordinary state on a fresh machine: configured,
191 + /// pointed at the right place, and nothing has run yet. It has to read as a
192 + /// source with nothing current rather than as a broken config.
193 + #[tokio::test]
194 + async fn a_payload_that_has_not_been_written_yet_is_a_short_reason() {
195 + let dir = tempfile::tempdir().unwrap();
196 + let err = read(&dir.path().join("status.json")).await.unwrap_err();
197 + assert_eq!(err, "no payload written yet");
198 + }
199 +
200 + #[tokio::test]
201 + async fn a_payload_on_disk_reads_the_same_as_one_off_the_wire() {
202 + let dir = tempfile::tempdir().unwrap();
203 + let path = dir.path().join("status.json");
204 + let written = Payload::new("witchbroom", Utc::now());
205 + std::fs::write(&path, serde_json::to_string(&written).unwrap()).unwrap();
206 +
207 + let got = read(&path).await.unwrap();
208 + assert_eq!(got.source, "witchbroom");
209 + assert_eq!(got, written);
210 + }
211 +
212 + #[tokio::test]
213 + async fn a_file_that_is_not_a_payload_says_so_rather_than_panicking() {
214 + let dir = tempfile::tempdir().unwrap();
215 + let path = dir.path().join("status.json");
216 + std::fs::write(&path, b"half a write").unwrap();
217 + assert!(read(&path).await.unwrap_err().starts_with("bad payload"));
142 218 }
143 219
144 220 // Opens a socket, which miri has no shim for. See the note in `exec`.