Skip to main content

max / alloy

Ship the Akari Night skeleton and pick it at session start Every themed config now renders twice. skelgen gained a `variants` directive: a template whose first line declares one is rendered against both Akari themes, the light render landing at etc/skel and the dark one at usr/share/alloy/skel-night. Files that carry no color are copied once, as before. `alloy theme apply` moves a home between the two. It reads the mode the settings theme row writes to ~/.config/alloy/mode and copies the matching render of each themed file, deciding "did the user edit this" from content alone by comparing against both shipped renders. A file matching neither is the user's and is kept. Nothing in the verb can fail a login: every error path warns and exits 0. usr/bin/alloy-session runs it before exec'ing sway, and greetd's --cmd now points at that wrapper, because sway reads its config once at start. Build guards, none of them a hardcoded count: the rendered file count is derived from the templates and their directives, both vt tables are checked, and the day and night trees must be a bijection that does not overlap the repo's own etc/skel. A themed file that quietly loses its dark render would otherwise ship a light sway border on a dark desktop. Also corrects the sway config's startup comment. Systemd user units do run in this session, via sway-systemd's session.sh, confirmed on the installed machine's journal, so the xdg-user-dirs exec stays out.
Author: Max Johnson <me@maxj.phd> · 2026-07-30 02:31 UTC
Signed with PGP, not checked
Commit: 758686a3929382f4c6c5b223126f63648309eaf8
Parent: 0ce955c
38 files changed, +2196 insertions, -182 deletions
@@ -6,8 +6,10 @@
6 6 # every run.
7 7 #
8 8 # Keep this in step with the COPY lines in the Containerfiles: the image
9 - # build reads Cargo.toml, Cargo.lock, crates/, etc/ and usr/, and
10 - # build/Containerfile.iso additionally reads build/make-iso.sh.
9 + # build reads Cargo.toml, Cargo.lock, crates/, templates/, etc/ and usr/,
10 + # and build/Containerfile.iso additionally reads build/make-iso.sh. etc/ is
11 + # read twice, once into the runtime image and once into the rust-build stage,
12 + # where etc/skel is checked against the rendered skeleton for overlap.
11 13
12 14 # Cargo artifacts. The rust-build stage compiles from scratch inside the
13 15 # image on purpose — host artifacts are built against a different libc and
M Containerfile +153 -8
@@ -81,6 +81,17 @@
81 81 RUN /src/target/release/alloy-skelgen --help | grep -q -- '--templates' \
82 82 || { echo "built skelgen is the stub; the cache-split cleanup matched nothing" >&2; exit 1; }
83 83
84 + # The one subcommand name that is spelled out in a shell script rather than
85 + # resolved by the compiler. usr/bin/alloy-session runs `alloy theme apply` and
86 + # swallows its exit code, because nothing before the exec is allowed to fail a
87 + # login; the cost of that `|| true` is that renaming the verb makes the wrapper
88 + # a no-op and leaves every account on the day render forever, with one line in
89 + # the journal per login and no other symptom. clap answers `--help` before any
90 + # dispatch, so this asks the binary whether the verb exists without needing a
91 + # theme, a home directory, or a skeleton.
92 + RUN /src/target/release/alloy theme apply --help >/dev/null \
93 + || { echo "the binary has no 'theme apply'; usr/bin/alloy-session would silently do nothing" >&2; exit 1; }
94 +
84 95 # The console's themes, staged at a path the runtime stage can name.
85 96 #
86 97 # theme.rs has no built-in palette on purpose (docs/TOKENS.md: no hex in
@@ -116,10 +127,46 @@
116 127 # mapping and no two of which agreed.
117 128 #
118 129 # skelgen fails the build on an unknown token or an empty render, so the
119 - # assertions the vtrgb and theme-glob steps needed are inside it. The one worth
120 - # keeping outside is the count: a template tree that silently matched nothing
121 - # would copy an empty overlay and every program in the image would fall back to
122 - # its own defaults, with the build log saying nothing at all.
130 + # assertions the vtrgb and theme-glob steps needed are inside it. What has to
131 + # stay out here is everything about the shape of the output tree, because a
132 + # template tree that silently matched nothing, or a night set that silently went
133 + # missing, copies a plausible-looking overlay and says nothing at all.
134 + #
135 + # Two renders per themed template now, the plain one and a `.night` sibling. The
136 + # sibling is written next to its target, which is not where it belongs: left
137 + # there, every new user gets a ~/.config/mako/config.night and a
138 + # userChrome.css.night inside their Firefox profile. No program picks those up —
139 + # helix scans for *.toml, sway reads `config` — so the failure is clutter rather
140 + # than breakage, which is exactly the kind that ships. Hence the relocation
141 + # below, and the assertion that it left nothing behind.
142 + #
143 + # Every loop is `for x in $(find ...)` rather than `find | while read`, and that
144 + # is not a style choice: a piped `while` runs its body in a subshell, so an
145 + # `exit 1` inside it exits the subshell and the RUN succeeds anyway. A build
146 + # guard that cannot fail the build is worse than no guard. Word splitting is safe
147 + # here because no path in templates/ contains whitespace, and the counterpart
148 + # assertions below would catch it if one ever did.
149 + #
150 + # Four guards, none of them a hardcoded number, because the old `-ge 17` floor
151 + # went stale the day the night set landed and a floor that passes by accident
152 + # reads exactly like a floor that passes on purpose:
153 + #
154 + # 1. The file count is derived, not written down: one output per template, plus
155 + # one more for each template whose first line is a `variants` directive.
156 + # Equality rather than a floor, so a render that stops fanning out fails
157 + # here, and adding a template needs no edit.
158 + # 2. Both vt tables are checked, not only the day one. setvtrgb rejects
159 + # anything that is not three rows of sixteen values, and it rejects it at
160 + # boot on the greeter, where nobody is reading logs.
161 + # 3. Every rendered file under /etc/skel must have a night render, with the two
162 + # helix palettes named as the deliberate exceptions: those two files are the
163 + # polarities themselves, picked by filename, so a `.night` sibling of either
164 + # would be a second copy of one of them under the wrong name. This is the
165 + # guard that catches a themed template losing its directive, which guard 1
166 + # cannot: guard 1 derives its expectation from the same first lines.
167 + # 4. Every night render must have a plain counterpart, which is what
168 + # `alloy theme apply`'s pristine test compares against. Together with 3 it
169 + # is a bijection, so a stray file on either side fails the build.
123 170 COPY templates/ /src/templates/
124 171 RUN set -eux; \
125 172 /src/target/release/alloy-skelgen \
@@ -127,9 +174,53 @@
127 174 --out /staged-skel \
128 175 --theme default=/staged-themes/akari-dawn.toml \
129 176 --theme night=/staged-themes/akari-night.toml; \
130 - [ "$(find /staged-skel -type f | wc -l)" -ge 17 ] \
131 - || { echo "skelgen produced fewer files than the tree has templates" >&2; exit 1; }; \
132 - awk 'NF != 16 { exit 1 } END { if (NR != 3) exit 1 }' /staged-skel/usr/share/alloy/vtrgb
177 + templates="$(find /src/templates -type f | wc -l)"; \
178 + variants="$(find /src/templates -type f -exec awk 'FNR == 1 && /^[[:space:]]*@\{! *variants *=/ { print FILENAME }' {} + | wc -l)"; \
179 + rendered="$(find /staged-skel -type f | wc -l)"; \
180 + [ "$rendered" -eq "$((templates + variants))" ] \
181 + || { echo "skelgen wrote $rendered files; $templates templates of which $variants render twice should have produced $((templates + variants))" >&2; exit 1; }; \
182 + for table in /staged-skel/usr/share/alloy/vtrgb /staged-skel/usr/share/alloy/vtrgb.night; do \
183 + awk 'NF != 16 { exit 1 } END { if (NR != 3) exit 1 }' "$table" \
184 + || { echo "$table is not three rows of sixteen values; setvtrgb would reject it" >&2; exit 1; }; \
185 + done; \
186 + for night in $(find /staged-skel/etc/skel -type f -name '*.night'); do \
187 + rel="${night#/staged-skel/etc/skel/}"; \
188 + dest="/staged-skel/usr/share/alloy/skel-night/${rel%.night}"; \
189 + mkdir -p "$(dirname "$dest")"; \
190 + mv "$night" "$dest"; \
191 + done; \
192 + [ -z "$(find /staged-skel/etc/skel -name '*.night')" ] \
193 + || { echo "a night render was left in /etc/skel; every new user would get it" >&2; exit 1; }; \
194 + for day in $(find /staged-skel/etc/skel -type f); do \
195 + rel="${day#/staged-skel/etc/skel/}"; \
196 + case "$rel" in .config/helix/themes/akari-dawn.toml|.config/helix/themes/akari-night.toml) continue;; esac; \
197 + [ -f "/staged-skel/usr/share/alloy/skel-night/$rel" ] \
198 + || { echo "$rel has no night render; a night session would keep the light one forever" >&2; exit 1; }; \
199 + done; \
200 + for applied in $(find /staged-skel/usr/share/alloy/skel-night -type f); do \
201 + rel="${applied#/staged-skel/usr/share/alloy/skel-night/}"; \
202 + [ -f "/staged-skel/etc/skel/$rel" ] \
203 + || { echo "night render $rel has no plain counterpart in /etc/skel" >&2; exit 1; }; \
204 + done
205 +
206 + # The two skeleton sources must not overlap.
207 + #
208 + # The runtime stage lays the repo's etc/skel down first and this rendered tree
209 + # second, so a path present in both silently resolves to the render and leaves a
210 + # committed file in the repo that nothing in the image ever reads. That is how
211 + # etc/skel/.config/helix/config.toml would come back: it moved into templates/
212 + # to carry `theme = "@{meta.id}"`, and a restored copy would look like the
213 + # source of truth while the image used the other one.
214 + #
215 + # etc/skel is copied in here rather than compared in the runtime stage because
216 + # this is the only stage that holds both trees, and this stage is discarded.
217 + COPY etc/skel/ /src/etc-skel/
218 + RUN set -eux; \
219 + for staged in $(find /src/etc-skel -type f); do \
220 + rel="${staged#/src/etc-skel/}"; \
221 + [ ! -e "/staged-skel/etc/skel/$rel" ] \
222 + || { echo "etc/skel/$rel is also rendered from templates/; one of the two is dead" >&2; exit 1; }; \
223 + done
133 224
134 225 # =====================================================================
135 226 # Runtime image — the bootable container itself.
@@ -579,12 +670,36 @@
579 670 # tokens. The generated tree mirrors `/` the same way, so it lands by the same
580 671 # 1:1 rule and simply completes the tree rather than patching it: no file
581 672 # appears in both, and a stale copy of a themed file cannot shadow its
582 - # generated version because there is no copy to go stale.
673 + # generated version because there is no copy to go stale. "No file appears in
674 + # both" is checked in the rust-build stage rather than assumed here, since this
675 + # is the stage where a collision would resolve quietly in the render's favor.
583 676 # =====================================================================
584 677 COPY etc/ /etc/
585 678 COPY usr/ /usr/
586 679 COPY --from=rust-build /staged-skel/ /
587 680
681 + # =====================================================================
682 + # The session wrapper — assert it can actually run.
683 + #
684 + # etc/greetd/config.toml points --cmd at alloy-session rather than at
685 + # sway, which puts this one script on the boot path: missing,
686 + # non-executable, or carrying a bad shebang, and nobody can log into the
687 + # machine at all, with the virtual console as the only recovery. Both
688 + # failures are silent at build time and total at runtime, so they are
689 + # checked here for the same reason the polkit and setvtrgb steps check
690 + # theirs.
691 + #
692 + # `sh -n` parses without running, which is the whole check worth making:
693 + # the script's job is one alloy call and one exec, and a syntax error is
694 + # the only way that fails before it has a chance to matter.
695 + # =====================================================================
696 + RUN test -x /usr/bin/alloy-session \
697 + || { echo "alloy-session is missing or not executable; greetd --cmd would fail" >&2; exit 1; }
698 + RUN sh -n /usr/bin/alloy-session \
699 + || { echo "alloy-session does not parse; no user could log in" >&2; exit 1; }
700 + RUN grep -q -- '--cmd alloy-session' /etc/greetd/config.toml \
701 + || { echo "greetd does not launch the session wrapper; the skeleton would never be applied" >&2; exit 1; }
702 +
588 703 # =====================================================================
589 704 # polkit rules — assert the grant is not inert.
590 705 #
@@ -785,6 +900,36 @@
785 900 RUN test -s /usr/share/alloy/vtrgb \
786 901 || { echo "the console palette did not arrive with the rendered tree" >&2; exit 1; }
787 902
903 + # vtrgb.night is the dark console table, and nothing reads it yet. It is here so
904 + # that switching the greeter's palette is a unit change rather than a render
905 + # change: alloy-vtrgb.service runs before any user exists, so the per-user mode
906 + # file cannot reach it, and the system-scoped setting that will choose between
907 + # these two tables is still to come. Asserted anyway, because an unread file is
908 + # exactly the kind that quietly stops being produced. The note lives here rather
909 + # than in the template: templates/usr/share/alloy/vtrgb.in renders to nothing but
910 + # the table, and setvtrgb rejects any line that is not sixteen values.
911 + RUN test -s /usr/share/alloy/vtrgb.night \
912 + || { echo "the dark console palette did not arrive; the greeter would have nothing to switch to" >&2; exit 1; }
913 +
914 + # The dark half of the skeleton, which `alloy theme apply` reads its whole
915 + # manifest from. An empty or missing tree makes the verb a no-op that says so
916 + # once per login and leaves every user on the light render forever, so it is
917 + # worth one test here rather than a report from a booted machine.
918 + #
919 + # The pairing was already proved in the rust-build stage; what this repeats it
920 + # for is the finished image, where /etc/skel is also written by every package
921 + # that ships a skeleton file and by every COPY above. A night render whose day
922 + # counterpart went missing between there and here is a file the pristine test
923 + # can only decide by keeping, so the user stays on whatever they have.
924 + RUN set -eux; \
925 + test -n "$(find /usr/share/alloy/skel-night -type f -print -quit)" \
926 + || { echo "the night skeleton did not arrive; alloy theme apply would have nothing to apply" >&2; exit 1; }; \
927 + for applied in $(find /usr/share/alloy/skel-night -type f); do \
928 + rel="${applied#/usr/share/alloy/skel-night/}"; \
929 + [ -f "/etc/skel/$rel" ] \
930 + || { echo "/etc/skel/$rel is gone; the night render of it has nothing to switch back to" >&2; exit 1; }; \
931 + done
932 +
788 933 # =====================================================================
789 934 # bootc validation — fails the build if the image isn't a valid
790 935 # bootable container.
@@ -26,10 +26,21 @@
26 26 alloy setup # the first-boot offer: mesh and sync [shipped]
27 27 alloy settings # system settings and app configs: two tabs over one form
28 28 alloy config <path> # one config file, opened directly, without the tab chrome
29 + alloy theme apply # put the chosen day/night skeleton in place [shipped]
29 30 alloy theme <name> # swap the runtime theme; reads makeover's themes/*.toml
30 31 # or ~/.config/alloy/themes/*.toml via makeover
31 32 ```
32 33
34 + `alloy theme apply` is the one verb here that draws nothing. It reads
35 + `~/.config/alloy/mode`, which the settings theme row writes, and copies the
36 + matching render of every themed config out of the image: `/etc/skel` for `day`,
37 + `/usr/share/alloy/skel-night` for `night`. `usr/bin/alloy-session` runs it before
38 + exec'ing sway, and greetd's `--cmd` points at that wrapper, so a theme chosen in
39 + the console reaches sway, mako, Firefox and the rest at the next login. A file
40 + whose contents match neither render is one the user edited, and it is kept and
41 + named rather than replaced; `--force` is the only way past that, and it leaves a
42 + `.alloy-bak`.
43 +
33 44 `alloy mesh` was named `alloy tail` when this document was written. It is
34 45 generic now, for two reasons. Someone who has never heard of Tailscale should
35 46 still find the screen that lists the machines they can reach, and Headscale is
M docs/IMAGE.md +4 -3
@@ -75,9 +75,10 @@
75 75 3. **Package additions:** the full Alloy stack from [STACK.md](STACK.md): compositor, bar, launcher, notifications, terminal, editor, shell, viewers, utilities, continuity daemons, fonts, themes.
76 76 4. **Package removals:** stock Silverblue desktop pieces Alloy replaces (gnome-shell, gdm; the latter gated on the greeter pick).
77 77 5. **Config tree:** the tree at `etc/skel/.config/*` and `etc/skel/.mozilla/*` (new-user defaults, including the Firefox first-launch profile seed), `etc/*` (system-wide, including `etc/firefox/policies/policies.json`), `usr/lib64/firefox/*` (Firefox autoconfig + `mozilla.cfg` default prefs), and `usr/share/polkit-1/rules.d/*` (which system settings the console may change without a prompt) in the repo maps 1:1 into the image. The polkit rule is asserted at build time against the actions the image actually defines, since a grant naming a renamed action is inert and silent about it.
78 - 6. **Systemd presets:** which services are enabled by default (syncthing off by default, gammastep off until enrolled, alloy-hinged conditionally on FW12, etc.).
79 - 7. **Branding:** os-release, plymouth splash.
80 - 8. **Validation:** `bootc container lint` runs at build.
78 + 6. **Rendered tree:** everything in the image that carries a color is not in the repo as a finished file. `templates/` holds it with the palette left as tokens, and `skelgen` renders it against the two Akari themes into a second tree that mirrors `/` the same way the config tree does. Themed skeleton files render twice: the light one lands at `etc/skel/<rel>`, the dark one at `usr/share/alloy/skel-night/<rel>`, and `alloy theme apply` copies whichever the user's mode file names into `$HOME` at login. The build asserts the two trees are a bijection and that they do not overlap the repo's own `etc/skel`, because a themed file that quietly loses its dark render leaves a light sway border on a dark desktop and nothing else.
79 + 7. **Systemd presets:** which services are enabled by default (syncthing off by default, gammastep off until enrolled, alloy-hinged conditionally on FW12, etc.).
80 + 8. **Branding:** os-release, plymouth splash.
81 + 9. **Validation:** `bootc container lint` runs at build.
81 82
82 83 The base browser (Firefox) ships as an RPM baked into the image: one code path, no first-boot delay, and enterprise policies (`/etc/firefox/policies/policies.json`) take effect immediately. The `flatpak` client is included so users can pull ungoogled-chromium and other Flathub-only apps on demand post-install; no Flatpaks are provisioned at build or first-boot time.
83 84
M docs/STACK.md +4 -2
@@ -292,7 +292,9 @@
292 292
293 293 ### Volume/brightness OSD: **swayosd**
294 294
295 - Rust, systemd user daemon, GTK-rendered overlays for volume/brightness/caps-lock/num-lock. Config at [`etc/skel/.config/swayosd/`](../etc/skel/.config/swayosd/): Alloy palette CSS with amber `accent-warn` progress bar.
295 + Rust, GTK-rendered overlays for volume/brightness/caps-lock/num-lock. Config at [`templates/etc/skel/.config/swayosd/`](../templates/etc/skel/.config/swayosd/): Alloy palette CSS with amber `accent-warn` progress bar.
296 +
297 + Two processes, split across the session boundary, which is why the wiring looks uneven. `swayosd-server` draws the overlays and has no unit in the package, so the sway config exec's it. `swayosd-libinput-backend` watches the lock keys, needs the input devices, and is the package's one unit: a system unit enabled in [`etc/systemd/system-preset/50-alloy.preset`](../etc/systemd/system-preset/50-alloy.preset), with a Containerfile shim because Fedora installs it where systemd does not look.
296 298
297 299 **Sway integration:** Fn keys bound to `swayosd-client --output-volume raise` and similar in the sway config (see the config for the block).
298 300
@@ -391,7 +393,7 @@
391 393
392 394 ## Greeter
393 395
394 - **greetd + tuigreet.** Rust, minimal, ratatui-rendered. greetd is the daemon that owns VT1; tuigreet is the ratatui client that prompts for user/password and execs `sway` on successful auth. Config at [`etc/greetd/`](../etc/greetd/): `/etc/greetd/config.toml` sets up VT1 with tuigreet and a `--theme` written in ANSI color names.
396 + **greetd + tuigreet.** Rust, minimal, ratatui-rendered. greetd is the daemon that owns VT1; tuigreet is the ratatui client that prompts for user/password and execs [`alloy-session`](../usr/bin/alloy-session) on successful auth, which applies the chosen day/night skeleton and execs `sway`. Config at [`etc/greetd/`](../etc/greetd/): `/etc/greetd/config.toml` sets up VT1 with tuigreet and a `--theme` written in ANSI color names.
395 397
396 398 **The palette reaches it through the console, not through `--theme`.** tuigreet 0.9.1 parses only the sixteen ANSI names; a hex value is ignored rather than rejected, and the affected element falls back to a default, so `prompt=#8a4530` renders red. This doc claimed an Akari greeter for a while on the strength of a `--theme` that could not deliver one. What delivers it is [`usr/lib/bootc/kargs.d/10-alloy.toml`](../usr/lib/bootc/kargs.d/10-alloy.toml), which sets the Linux console's 16-color table via `vt.default_red/grn/blu`. tuigreet then asks for `white` and gets `surface.page`. The same substitution themes every VT, including the debug shell and any getty, and it costs one boot-time kernel argument rather than a patch to tuigreet.
397 399