Files
Frametop--frametop/pack
DeeJanuzandClaude Opus 5.5 6dc4521905 Image: remote displays, our eye tracker, distrobox, and SteamVR's library path
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>
2026-10-07 20:52:27 -06:00
..

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:

  1. The base image by digest — fedora-toolbox:44 pinned to the exact image, not the floating tag. This freezes the C world: glibc, wlroots 0.20, the Qt/KDE stack.
  2. 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 own python3, which the base digest freezes, and sees the system site-packages: PySide6 and Kirigami come from dnf for that interpreter, so one python3 imports both, as in the dev container.
  3. OpenVR from the pinned public tag — v2.15.6, in scripts/openvr.sh. The build scripts fetch its headers and check their sha256. The image has no SteamVR, so it links the SDK's prebuilt libopenvr_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 update pulls 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 with ft clean when 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:

  1. Frametop never touches anything it does not own. Containers get stable frametop-<program> names; ft clean deletes only containers matching frametop-* and only images whose reference names frametop. It never runs store-wide commands.
  2. Nobody should run store-wide podman commands on a Frame. podman rm -a, podman rmi -a, and podman system prune would delete Valve's containers and images too. Use ft clean for 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:

  1. Image, wrapper, and CI (this directory).
  2. 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.
  3. 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.
  4. get.sh and the FrameDrop package install a release: download, verify, install the host payload, pull the image. The source install stays for development.