Measured on the current image (3.29 GB): /root/.cache/uv held 266 MB after sync (--no-cache now); glibc-langpack-* plus their /usr/lib and /usr/share locale data ~460 MB (only en stays); /usr/share doc/man/info + wallpapers ~150 MB. mypy moved from the dev group to a types group that the image does not install (uv sync --frozen --group tracker); pytest and ruff stay because CI tests inside the product environment. uv.lock relocked for the group change.
Frametop as an image
The Frame's runtime is already a container: the dev container (Fedora toolbox
in distrobox) is where everything builds and runs, and setup/dev-container.sh
is its recipe. The recipe is the weak point — nothing in it is pinned. Rebuild
the container tomorrow and you get a different Fedora, a different wlroots, a
different glibc. This directory turns the container from a recipe into an
artifact: an OCI image whose every input is frozen.
What the image contains
Containerfile builds one image, frametop, from three frozen inputs:
- The base image by digest —
fedora-toolbox:44pinned to the exact image, not the floating tag. This freezes the C world: glibc, wlroots 0.20, the Qt/KDE stack. - Python from
uv.lock— the committed lockfile pins every Python dependency (pytest, ruff, the tracker's NumPy/OpenCV stack) to exact versions with aarch64 wheels, installed into/opt/frametop/venvwith its own uv-managed CPython. The venv is deliberately not system-site-packages: it must not depend on which Fedora is current. - OpenVR from the pinned public tag — the same
v2.15.6the Frame-side build scripts pin; Valve no longer publishes release archives, solibopenvr_api.sois built from source in the image and lives in/opt/frametop/lib, found through the binaries' rpath.
The native binaries (ft-screens, ft-pointer, the ft_pointer driver,
ft-gaze, ft-gazepanel, ft-powerd) are compiled inside the image, the
same way the Frame-side build.sh scripts compile them. Hand tracking stays
deferred exactly as in install.sh (ncnn is a heavy, separately triggered
build).
What deliberately stays outside the image for now: the host payload — the
SteamVR driver registration (vrpathreg), ft-camd's file capabilities
(setcap), systemd units, the KWin script. Those need the SteamOS host, and
they are what install.sh does today. Packaging them into a checksummed
payload tarball is the next slice.
The ft wrapper
Everything that runs Frametop goes through the wrapper at the repo root, so the integration points live in one place:
ft dev build # build the image from this repo (docker or podman)
ft update # pull the published (versioned) image, pin its digest
ft dev shell # interactive shell, repo at /src/frametop
ft ft-screens ... # run any program from /opt/frametop
ft dev test # the Python test suites inside the image
ft clean # remove frametop's leftovers only (see the store section)
FT_IMAGE overrides the image reference (repo mode defaults to the locally
built frametop:local). Development commands live behind ft dev so an
installed copy — which has no repo to build from — refuses them. Containers
run through the wrapper get stable names, frametop-<program>. The design
rationale is in design.md.
No :latest on a headset
A moving tag has no place on a headset: it cannot be reproduced in a bug
report and cannot be rolled back. The wrapper enforces this for what gets
deployed — installed mode refuses to run without a pinned reference, and
both the update source and the pinned reference are rejected if they say
:latest. Local development can use any tag it likes (FT_IMAGE is never
second-guessed in repo mode).
How pinning works:
install.sh(next slice) writes a version tag to~/.config/frametop/published— the only place updates come from.ft updatepulls that reference and then writes the digest (repo@sha256:...) to~/.config/frametop/image. Every later run, and every bug report, names exactly that image.- Rollback is editing one file: put the previous digest back into
~/.config/frametop/image. The old image is still in the local store (remove it withft cleanwhen you are done with it).
The shared podman store
On SteamOS, rootless podman has one container/image store, and Frametop
shares it with Valve's Android layer: Lepton names its containers
lepton-<context>, and they live in the same store as ours. Two rules
follow, and both are enforced or documented rather than hoped for:
- Frametop never touches anything it does not own. Containers get stable
frametop-<program>names;ft cleandeletes only containers matchingframetop-*and only images whose reference names frametop. It never runs store-wide commands. - Nobody should run store-wide podman commands on a Frame.
podman rm -a,podman rmi -a, andpodman system prunewould delete Valve's containers and images too. Useft cleanfor Frametop's share of the store and leave the rest of it to Steam.
Network path for pulls
ft update (and install) contact the registry over the Frame's normal
Wi-Fi client interface (wlan0), not the 6 GHz streaming link to the PC
dongle. The image is roughly 1–1.5 GB compressed — pulls ride the home
network, so a weak Wi-Fi link is the bottleneck, not the streaming antenna.
FT_FRAME=1 adds --network=host to runs (for sockets), never to the
pull.
Storage
The image replaces the build toolchain, not adds to it: today's distrobox dev
container with its dnf history is the heavyweight; the pulled image carries
only the runtime plus the frozen build inputs (~1–1.5 GB compressed, a few GB
unpacked, in ~/.local/share/containers). Once install.sh switches to the
image, the dev container is only needed for development and can be removed
from user machines. ft clean reclaims space from superseded frametop
images.
Frame integration (designed, to be validated on the device)
The runtime model on the Frame: podman, not distrobox. Distrobox remains
for development; the product runs as a plain podman container started by the
systemd units. The wrapper carries the Frame's mounts, gated behind
FT_FRAME=1 until each one is validated on the device:
--ipc=host— the hands/gaze programs share results through files in/dev/shm; a container's private IPC namespace would hide them./run/user/1000bind-mounted — the Wayland socket and Frametop's ownframetop-*sockets live there./opt/steamvrread-only — the real runtime (SteamVR's ownlibopenvr_api,vrpathregfor driver registration). The image's own API library keeps the image usable without SteamVR; the mount lets the Frame use the real one.
First device tests, in order: ft ft-powerd (talks to OpenVR only), then
ft ft-screens in a session, then the units. The mount matrix above is
expected to need adjusting there — that is what the gate is for.
CI
.github/workflows/image.yml builds the image on a native arm64 runner on
every push that touches it (and on v* tags), runs the Python suites inside
the built image as a smoke test, and pushes to
ghcr.io/0x1f6/frametop. The fork's GHCR package starts out private — flip
it to public in the package settings after the first successful run (GitHub
web UI → your profile → Packages → frametop → Package settings →
"Change visibility"; there is no API for this). Pulling an image needs no
token once it is public; managing the package over gh needs
gh auth refresh -s read:packages,write:packages.
The longer arc
- ✅ Image + wrapper + CI (this directory)
- Frame validation of the mount matrix, then
install.shputs the wrapper at~/.local/bin/ft(the SteamOS root is read-only, so no /usr/local) and the units switch theirExecStart=lines to it; the installed wrapper runs the image's own copy of the sources — no repo on the device needed - Host payload tarball (driver registration, units, KWin script, models) with checksums, attached to releases
get.shbecomes an artifact installer (download, verify, install); the source path stays for development