The image now builds everything install.sh installs: - ft-stream (remote displays) with moonlight's build dependencies, its OpenVR from scripts/openvr.sh like the other components; - ft-eyegrab and ft-eyes' build/venv. In the image that venv is a small one that sees the locked venv's numpy and OpenCV (uv.lock has requirements.txt's versions), not a copy; - distrobox 1.8.2.5, by commit, for the release installer to run the image with; - /opt/steamvr -> /run/host/opt/steamvr, as setup/dev-container.sh makes in the dev container, so the programs load SteamVR's libopenvr_api on the Frame. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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/venv. The venv sits on Fedora's ownpython3, which the base digest freezes, and sees the system site-packages: PySide6 and Kirigami come from dnf for that interpreter, so onepython3imports both, as in the dev container. - OpenVR from the pinned public tag —
v2.15.6, inscripts/openvr.sh. The build scripts fetch its headers and check their sha256. The image has no SteamVR, so it links the SDK's prebuiltlibopenvr_api.so(in/opt/frametop/lib, also checked); the binaries' rpath names SteamVR's folder first, so on the Frame they load SteamVR's own library, as the on-device builds do.
The native binaries (ft-screens, ft-pointer, the ft_pointer driver,
ft-gaze, ft-gazepanel, ft-powerd) are built by the components' own
build.sh scripts, run inside the image with FRAME_IN_BOX=1, so there is
one build recipe for the image and the Frame. They stay in their build/
folders under /src/frametop and are copied to /opt/frametop. 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 are named frametop-<program>-<pid>. Runs get no
access to the host beyond the repo mount: how Frametop's programs run on the
Frame is still open (see 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.
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.
Running on the Frame
Not decided yet. The programs need much more of the host than a plain
podman run gives them; design.md lists
what, the device findings so far, and the options.
CI
.github/workflows/image.yml builds the image on a native arm64 runner on
every push that touches it (and on v* tags), then, inside the built image,
runs the test gate (just test-python test-c test-bash) and a smoke test of
the image and the ft wrapper. Only main and v* tags push the image to
ghcr.io/<owner>/frametop (lowercase) and publish the wrapper with its
checksums; other branches are built and checked, not published. A new GHCR
package may start out private: check its visibility in the package settings
after the first push.
The longer arc
The image only pays off when it makes installing Frametop easier, so none of this ships until the whole path works:
- Image, wrapper, and CI (this directory).
- A headset trial: Frametop's services run from the image (the runtime question above), next to an install time measured against today's on-device build. Go or no-go here.
- A release pipeline: one tag builds the image and a checksummed tarball of the host payload (units, desktop files, the KWin script, the driver) from the same commit.
get.shand the FrameDrop package install a release: download, verify, install the host payload, pull the image. The source install stays for development.