Skip to main content

max / alloy_tui

Containerfile: multi-stage cargo builds; first-boot Flatpak; boot fixes Two rust-build stages produce ironbar/anyrun/wl-screenrec and Gram (Zed fork), copied into /usr/local/bin in the runtime image. Runtime picks up gtk3/gtk4 + layer-shell, ffmpeg-free, vulkan-loader/mesa drivers. Flatpak provisioning moves to alloy-flatpak-firstboot.service so /var stays empty at build time and bootc container lint passes. Fixes uncovered by first QEMU boot (2026-07-04): - usr/lib/bootc/install/00-alloy.toml declares xfs root so bootc-image -builder no longer aborts on DefaultRootFs. - systemctl preset-all runs at build so 50-alloy.preset actually activates greetd on first boot. - etc/greetd/config.toml rewritten single-line — the multi-line basic string with backslash continuations tripped greetd's TOML parser. - useradd -M -r -s /sbin/nologin greeter — greetd's default session user is missing from Fedora bootc's minimal user database. - usr/lib/bootc/kargs.d/10-alloy.toml adds quiet + loglevel=3 so hostnamed / user-runtime-dir printk doesn't overwrite the greeter VT. Verified 2026-07-04 by rebuilding alloy:local, regenerating qcow2 with no CLI overrides, booting: greetd enabled from first boot, tuigreet renders on the Alloy palette, no kernel overlay.
Co-Authored-By
Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-07-13 16:20 UTC
Signed with PGP, not checked
Commit: 94ec7ac9bf378c40032ab32859d65d7bae180d2a
Parent: 8ada270
8 files changed, +161 insertions, -32 deletions
M Containerfile +90 -21
@@ -6,7 +6,59 @@
6 6 # Layer order optimizes rebuild speed: repos first (rarely change),
7 7 # then package installs (change with STACK.md), then config tree
8 8 # (changes most often, so lives at the tail).
9 + #
10 + # Multi-stage: two cargo-install stages produce Rust binaries not
11 + # packaged in Fedora, then the final bootc stage COPYs them in.
12 + # Keeps the Rust toolchain out of the runtime image.
9 13
14 + # =====================================================================
15 + # rust-build: ironbar, anyrun, wl-screenrec
16 + # =====================================================================
17 + # All three are small crates. Grouped in one stage so cargo's registry
18 + # cache is amortized across them.
19 + FROM registry.fedoraproject.org/fedora:42 AS rust-build
20 + RUN dnf install -y \
21 + rust cargo git \
22 + gcc make pkgconf-pkg-config \
23 + gtk3-devel gtk-layer-shell-devel \
24 + gtk4-devel gtk4-layer-shell-devel \
25 + wayland-devel libdrm-devel \
26 + ffmpeg-free-devel \
27 + openssl-devel \
28 + && dnf clean all
29 + ENV CARGO_HOME=/build/cargo \
30 + CARGO_TARGET_DIR=/build/target
31 + # ironbar and wl-screenrec are on crates.io; anyrun is git-only.
32 + RUN cargo install --locked --root /build/out ironbar && \
33 + cargo install --locked --root /build/out wl-screenrec && \
34 + cargo install --locked --root /build/out \
35 + --git https://github.com/anyrun-org/anyrun anyrun
36 +
37 + # =====================================================================
38 + # gram-build: Zed fork (graphical editor)
39 + # =====================================================================
40 + # Heavier than the trio above (LLVM, wasm, vulkan headers), so its
41 + # own stage keeps rust-build fast when only the small crates change.
42 + # Source: codeberg.org/GramEditor/gram (GPLv3).
43 + FROM registry.fedoraproject.org/fedora:42 AS gram-build
44 + RUN dnf install -y \
45 + rust cargo git \
46 + gcc make cmake clang mold \
47 + pkgconf-pkg-config \
48 + wayland-devel libxkbcommon-devel \
49 + openssl-devel fontconfig-devel freetype-devel \
50 + alsa-lib-devel \
51 + vulkan-loader-devel vulkan-headers \
52 + && dnf clean all
53 + ENV CARGO_HOME=/build/cargo \
54 + CARGO_TARGET_DIR=/build/target
55 + RUN git clone --depth 1 https://codeberg.org/GramEditor/gram /build/gram
56 + WORKDIR /build/gram
57 + RUN cargo build --release --locked --bin gram
58 +
59 + # =====================================================================
60 + # Runtime image — the bootable container itself.
61 + # =====================================================================
10 62 FROM quay.io/fedora/fedora-bootc:42
11 63
12 64 # =====================================================================
@@ -56,7 +108,6 @@
56 108 niri \
57 109 xdg-desktop-portal xdg-desktop-portal-gtk xdg-desktop-portal-gnome \
58 110 # Bar, notifications, screenshot, wallpaper
59 - # (ironbar, anyrun still to source — see cargo-install stage below)
60 111 mako \
61 112 satty \
62 113 swww \
@@ -77,7 +128,6 @@
77 128 bottom \
78 129 dua-cli \
79 130 # Wayland session glue
80 - # (wl-screenrec still to source — see cargo-install stage)
81 131 cliphist \
82 132 swayosd \
83 133 playerctl \
@@ -94,10 +144,19 @@
94 144 adw-gtk3-theme \
95 145 # Screenshot region-select helper
96 146 slurp \
97 - # Flatpak for shipping Floorp and Rnote
147 + # Flatpak client (Flathub + Floorp/Rnote install at first boot)
98 148 flatpak \
99 149 # Font tools
100 150 unzip fontconfig \
151 + # Runtime libs for the cargo-built binaries copied in below:
152 + # ironbar → gtk4 + gtk4-layer-shell
153 + # anyrun → gtk3 + gtk-layer-shell
154 + # wl-screenrec → ffmpeg-free + libdrm
155 + # gram → vulkan-loader + mesa-vulkan-drivers + fontconfig
156 + gtk3 gtk-layer-shell \
157 + gtk4 gtk4-layer-shell \
158 + ffmpeg-free libdrm \
159 + vulkan-loader mesa-vulkan-drivers \
101 160 && dnf clean all
102 161
103 162 # =====================================================================
@@ -119,16 +178,15 @@
119 178 fc-cache -fv
120 179
121 180 # =====================================================================
122 - # TODO(follow-up): multi-stage cargo build for the three Rust tools
123 - # without published Fedora packages — ironbar (bar), anyrun (launcher),
124 - # wl-screenrec (screen recorder). Adds a `FROM fedora:42 as rust-build`
125 - # stage that runs `cargo install`, then copies the binaries to
126 - # /usr/local/bin. Keeps toolchain out of the runtime image.
127 - #
128 - # For now, these are missing from the image; users get niri without a
129 - # bar, anyrun-plugin-less, and no wl-screenrec. Restoring them is the
130 - # next Containerfile change after the base image builds green.
181 + # Rust binaries not packaged in Fedora — pulled from the multi-stage
182 + # builds above. Landing under /usr/local/bin keeps them out of dnf's
183 + # view of /usr/bin, which is what the atomic tooling expects for
184 + # hand-installed binaries.
131 185 # =====================================================================
186 + COPY --from=rust-build /build/out/bin/ironbar /usr/local/bin/ironbar
187 + COPY --from=rust-build /build/out/bin/anyrun /usr/local/bin/anyrun
188 + COPY --from=rust-build /build/out/bin/wl-screenrec /usr/local/bin/wl-screenrec
189 + COPY --from=gram-build /build/target/release/gram /usr/local/bin/gram
132 190
133 191 # =====================================================================
134 192 # Package removals — stock desktop pieces Alloy replaces
@@ -143,16 +201,21 @@
143 201 || true
144 202
145 203 # =====================================================================
146 - # Flathub + Floorp + Rnote
204 + # Flathub + Floorp + Rnote — deferred to first boot
147 205 # =====================================================================
148 - # Floorp is Flatpak-only. Rnote is available via Fedora but the
149 - # Flatpak track is what upstream recommends for consistent behavior
150 - # under Wayland.
151 - RUN flatpak remote-add --if-not-exists flathub \
152 - https://dl.flathub.org/repo/flathub.flatpakrepo && \
153 - flatpak install --system --noninteractive flathub \
154 - one.ablaze.floorp \
155 - com.github.flxzt.rnote
206 + # bootc prefers /var empty at build time; `flatpak remote-add` and
207 + # `flatpak install --system` both write to /var/lib/flatpak, which
208 + # tripped `bootc container lint` on the first build. The provisioning
209 + # now runs via alloy-flatpak-firstboot.service (see
210 + # usr/lib/systemd/system/), enabled by the system preset.
211 +
212 + # =====================================================================
213 + # System user for greetd. greetd drops privileges to this account
214 + # before spawning tuigreet; without it greetd exits with
215 + # "configured default session user 'greeter' not found". Fedora
216 + # bootc's minimal user database does not include it.
217 + # =====================================================================
218 + RUN useradd -M -r -s /sbin/nologin greeter
156 219
157 220 # =====================================================================
158 221 # Config tree — the etc/ and usr/ trees in the repo map 1:1 into
@@ -168,7 +231,13 @@
168 231 # in the config tree above. Split across system-preset (greetd,
169 232 # tailscaled) and user-preset (swayosd, syncthing, gammastep,
170 233 # alloy-hinged). See docs/CONTINUITY.md for rationale.
234 + #
235 + # The preset files declare the intended enable/disable state; they
236 + # do not by themselves create the wants/ symlinks. `systemctl
237 + # preset-all` reads every preset file and applies it — this must
238 + # run after the config tree is in place, so it lives here.
171 239 # =====================================================================
240 + RUN systemctl preset-all
172 241
173 242 # =====================================================================
174 243 # Branding
M docs/todo.md +26 -3
@@ -15,12 +15,35 @@
15 15
16 16 ## Follow-up build work
17 17
18 - - [ ] Multi-stage cargo-install build for Rust tools without published Fedora packages: `ironbar` (bar), `anyrun` (launcher), `wl-screenrec` (screen recorder), and **Gram** (graphical editor — Zed fork, larger build). New `FROM fedora:42 as rust-build` stage runs `cargo install`, then copies binaries to `/usr/local/bin` in the runtime stage. Gram may deserve its own stage due to build weight.
19 - - [ ] Address bootc lint's `/var` warnings — Flatpak install writes to `/var/lib/flatpak` at build time, but bootc prefers `/var` empty at build so systemd-tmpfiles populates at first boot. Options: (a) accept the warnings, (b) move Flatpak installs to a first-boot systemd oneshot instead of build-time, (c) `bootc container lint`'s official escape hatch if there is one.
20 - - [ ] Boot the image in a VM (`bootc-image-builder` → qcow2 or ISO) to verify it actually runs as an OS beyond just linting clean.
18 + - [x] Multi-stage cargo-install build for Rust tools without published Fedora packages: `ironbar`, `anyrun`, `wl-screenrec` in one `rust-build` stage; **Gram** (Zed fork) in its own `gram-build` stage per todo's build-weight note. Both stages land on `registry.fedoraproject.org/fedora:42`; binaries copied to `/usr/local/bin` in the runtime stage. Runtime libs (gtk3/gtk4 + layer-shell, ffmpeg-free, vulkan-loader/mesa-vulkan-drivers) added to the runtime dnf install. anyrun pulled `--git` (not on crates.io); ironbar and wl-screenrec pulled from crates.io. First rebuild will validate; if wl-screenrec turns out to be git-only, flip that one line to `--git https://github.com/russelltg/wl-screenrec`.
19 + - [x] Address bootc lint's `/var` warnings — moved Flatpak provisioning (flathub remote-add + Floorp/Rnote install) to `usr/lib/systemd/system/alloy-flatpak-firstboot.service`, a first-boot oneshot with `ConditionPathExists=!/var/lib/alloy/flatpak-firstboot.done`. Enabled via `etc/systemd/system-preset/50-alloy.preset`. Build-time Flatpak lines removed from the Containerfile.
20 + - [x] Boot the image in a VM (`bootc-image-builder` → qcow2 or ISO) to verify it actually runs as an OS beyond just linting clean. Booted 2026-07-04 on fw13 via `podman run … bootc-image-builder:latest build --type qcow2 --rootfs xfs …`, then `qemu-system-x86_64 -enable-kvm -machine q35` with OVMF. Kernel + initrd + systemd all clean; tuigreet reached VT1 after the fixes below were applied by hand in the running VM. Punch list captured under "Punch list from first QEMU boot" below.
21 21 - [x] Package Alloy-authored config files that STACK.md references but the Containerfile doesn't yet ship. Shipped 2026-07-05 under `etc/skel/.config/`: `niri/config.kdl`, `ironbar/{config.toml,style.css}`, `anyrun/{config.ron,style.css}`, `rio/config.toml`, `yazi/{yazi.toml,theme.toml}`, `mako/config`, `satty/config.toml`, `swaylock/config`, `starship.toml`. All applied Alloy light-mode palette per docs/TOKENS.md and IosevkaTerm Nerd Font per docs/STACK.md#fonts. Live-system verification pending — see the follow-up item below.
22 22 - [ ] Live-system verification of the packaged configs. Test on Framework 13 (or QEMU with hardware passthrough where feasible): rio → yazi kitty-graphics preview, Niri Fn-key binds → swayosd overlays, Print → screenshot to `~/Pictures/Screenshots/`, Mod+Print → satty on latest, Mod+Ctrl+L → swaylock with palette, ironbar workspaces reflecting Niri IPC, anyrun launcher, mako urgency colors, starship prompt on nu login. Any config that breaks on real hardware gets fixed and re-shipped as a follow-up.
23 23
24 + ## Punch list from first QEMU boot (2026-07-04)
25 +
26 + All five items are Containerfile bugs uncovered by actually booting the image. Reproducer for each is "boot the vanilla `alloy:local` qcow2, observe." All landed and verified 2026-07-04 by rebuilding `alloy:local`, regenerating the qcow2 with no CLI overrides, and booting: greetd is `enabled` from first boot, `active (running)`, PAM session opens for `greeter`, tuigreet renders on the Alloy palette with no kernel printk overlay.
27 +
28 + - [x] **Declare a default root filesystem.** bootc-image-builder aborts with `missing required info: DefaultRootFs` unless `--rootfs` is passed. Shipped as `usr/lib/bootc/install/00-alloy.toml` with `[install.filesystem.root] type = "xfs"`. Verified by rebuilding the qcow2 without `--rootfs` on the CLI.
29 + - [x] **Apply presets at build time.** `50-alloy.preset` is copied into `/etc/systemd/system-preset/` but nothing was running `systemctl preset-all` during the build, so greetd stayed disabled at first boot even though the preset said enable. Fix: `RUN systemctl preset-all` in the Containerfile immediately after `COPY etc/` / `COPY usr/`.
30 + - [x] **Single-line `etc/greetd/config.toml` command.** The multi-line basic string with backslash continuations tripped greetd's TOML parser (`expected equals sign on line, but found none`). Rewritten as a single-line `command = "tuigreet …"` with the Alloy theme preserved on the same line. Verified by observing the correct palette in the second-boot screenshot.
31 + - [x] **Create the `greeter` system user.** Greetd exits `error: configured default session user 'greeter' not found` without it — Fedora bootc's minimal user database doesn't include it. Added `RUN useradd -M -r -s /sbin/nologin greeter` to the Containerfile. Landed as a system user (uid < 1000). The `video` group also isn't in the base image; not needed for the greetd path, revisit when niri actually needs GPU nodes.
32 + - [x] **Silence kernel `printk` on the greeter VT.** Audit lines from `systemd-hostnamed` and `user-runtime-dir` were overwriting the greeter UI. Shipped `usr/lib/bootc/kargs.d/10-alloy.toml` with `kargs = ["quiet", "loglevel=3"]`. Verified — `/proc/cmdline` in the fresh image includes both flags, and the second-boot screenshot has a clean greeter frame.
33 +
34 + ## New findings from the second QEMU boot (2026-07-04)
35 +
36 + Surfaced during verification of the fixes above; separate work items.
37 +
38 + - [x] **Ship a default `/etc/hostname`.** Shipped as `etc/hostname` with `alloy`. Static for now; if per-install randomization becomes desirable later, replace with a first-boot systemd unit that writes a randomized suffix.
39 +
40 + ## Meta findings from the same session
41 +
42 + Not Containerfile issues, but worth capturing so the next person doesn't rediscover them.
43 +
44 + - **bootc-image-builder must find the source image in the same podman storage it uses.** `sudo podman run … bootc-image-builder … localhost/alloy:local` fails with `image not known` if the image was built rootless. Either build the image as root (`sudo podman build`), or shuttle it across with `podman save localhost/alloy:local | sudo podman load`. The current builder image no longer pulls sources; the error message says so explicitly.
45 + - **First QEMU launch parameters that worked**, for reference: `qemu-system-x86_64 -enable-kvm -cpu host -smp 4 -m 4096 -machine q35 -drive if=pflash,format=raw,readonly=on,file=/usr/share/OVMF/OVMF_CODE_4M.fd -drive if=pflash,format=raw,file=OVMF_VARS.fd -drive if=virtio,file=disk.qcow2,format=qcow2 -netdev user,id=n0,hostfwd=tcp:127.0.0.1:2222-:22 -device virtio-net-pci,netdev=n0 -vga virtio -vnc 127.0.0.1:1 -serial mon:stdio`. UEFI + q35 is required (bootc images do not have a legacy BIOS path). `hostfwd` for port 22 makes remote diagnosis trivial once sshd is up.
46 +
24 47 ## When distribution starts
25 48
26 49 Deferred until Alloy has something worth pulling. Re-add `sign` and `push` tasks to `.builds/alloy-image.yml` and populate the sr.ht secrets when doing this.
@@ -2,18 +2,15 @@
2 2 #
3 3 # Boots into tuigreet on VT1. tuigreet prompts for user and password,
4 4 # then execs niri-session. Alloy palette applied via tuigreet's --theme.
5 + #
6 + # Command must stay on a single line: greetd's TOML parser rejects the
7 + # multi-line basic-string / backslash-continuation form ("expected
8 + # equals sign on line, but found none").
5 9
6 10 [terminal]
7 11 vt = 1
8 12 switch = true
9 13
10 14 [default_session]
11 - command = """
12 - tuigreet \
13 - --time \
14 - --remember \
15 - --asterisks \
16 - --cmd niri-session \
17 - --theme 'container=white;border=dark_gray;text=black;time=dark_gray;prompt=yellow;input=black;action=dark_gray;button=yellow'
18 - """
15 + command = "tuigreet --time --remember --asterisks --cmd niri-session --theme 'container=white;border=dark_gray;text=black;time=dark_gray;prompt=yellow;input=black;action=dark_gray;button=yellow'"
19 16 user = "greeter"
@@ -7,10 +7,15 @@
7 7 # - tailscaled: opt-in only. Enrollment happens via `alloy tail`
8 8 # (see docs/CONTINUITY.md — Alloy does not phone home
9 9 # before the user says yes).
10 + # - alloy-flatpak-firstboot: adds flathub and installs Floorp + Rnote
11 + # the first time the machine reaches multi-user with
12 + # network. Kept out of the build so /var stays empty
13 + # at image-build time (bootc lint requirement).
10 14 #
11 15 # Format: `enable|disable <unit>`. First match wins; entries without
12 16 # a match fall through to /usr/lib/systemd/system-preset/ defaults.
13 17
14 18 enable greetd.service
19 + enable alloy-flatpak-firstboot.service
15 20
16 21 disable tailscaled.service
@@ -1,0 +1,1 @@
1 + alloy
@@ -1,0 +1,9 @@
1 + # Alloy bootc install configuration.
2 + #
3 + # Declares the root filesystem type used when the image is installed
4 + # to disk. Without this, bootc-image-builder fails with
5 + # "missing required info: DefaultRootFs" and any consumer has to pass
6 + # --rootfs on the CLI. xfs matches Fedora bootc's own default.
7 +
8 + [install.filesystem.root]
9 + type = "xfs"
@@ -1,0 +1,8 @@
1 + # Alloy default kernel cmdline additions.
2 + #
3 + # `quiet` + `loglevel=3` suppresses kernel/audit printk on tty1 so the
4 + # greetd/tuigreet UI isn't overwritten by systemd-hostnamed and
5 + # user-runtime-dir audit messages. Anyone debugging boot problems can
6 + # still get the full log via `journalctl -k` or `dmesg`.
7 +
8 + kargs = ["quiet", "loglevel=3"]
@@ -1,0 +1,17 @@
1 + [Unit]
2 + Description=Alloy first-boot Flatpak provisioning (Floorp, Rnote)
3 + Documentation=file:///usr/share/alloy/README
4 + After=network-online.target
5 + Wants=network-online.target
6 + ConditionPathExists=!/var/lib/alloy/flatpak-firstboot.done
7 +
8 + [Service]
9 + Type=oneshot
10 + RemainAfterExit=yes
11 + ExecStart=/usr/bin/flatpak remote-add --system --if-not-exists flathub https://dl.flathub.org/repo/flathub.flatpakrepo
12 + ExecStart=/usr/bin/flatpak install --system --noninteractive --or-update flathub one.ablaze.floorp com.github.flxzt.rnote
13 + ExecStart=/usr/bin/mkdir -p /var/lib/alloy
14 + ExecStart=/usr/bin/touch /var/lib/alloy/flatpak-firstboot.done
15 +
16 + [Install]
17 + WantedBy=multi-user.target