A Spotlight-like search pill (docs/spotwatch.md). Meta+Space (the input
relay's new spotwatch action, bindable to mouse and controller buttons
too) opens a faux-glass pill straight ahead of the head. Typing finds
apps and remote displays ({host} - {display}). Enter brings a running
app's most recent window to the front with the lazy susan, launches an
app that isn't running as a floating window, or connects and spins to a
remote display. Shift+Enter or a right click opens the item's menu: the
app's desktop actions, Open new window, Open docked on this screen, and
its windows; a display's Go to and Disconnect.
- spotwatch/ft-spotwatch (Python, in the Plasma session's autostart):
the index, ranking (spotsearch.py), keys via libxkbcommon, usage.
- spotwatch/ft-spotpanel.cpp (SteamVR overlay, built by
screens/build.sh): the pill, drawn on the CPU; --render for a look
without a headset.
- ft-screens: "spin to N", "front N", and "spotwatch on|off", which
routes every key to the search while it's open (a lease, renewed
every second) and dismisses it on a click elsewhere or a pause.
- ft-floatd: "spotlist", "activate ID", and launch action=/screen=;
the KWin script tracks when each window was last active.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
voice/ft-voiced (was frame_voice.py) runs in Frametop's container as
frametop-voice.service. It keeps the mic, the wake word and live text,
and hands the keyboard side to the input relay:
- push-to-talk keys are key combinations (dictate, dictate_hold); the
relay sends "toggle|start|stop" to @frametop_voice, taken only from
this user
- text goes to the relay as "type TEXT" (ASCII, in pieces of 2000
bytes), so it lands where typing goes; the relay holds it back while
a modifier is held
- controller buttons are read from vrserver's web socket with
input/vrws.py, in a thread, so the websockets package goes
No own uinput keyboard, fd store, evdev, or controller service any more.
whisper-server runs only while the wake word is on, started and stopped
by ft-voiced. Voice commands and hooks run on the host through
distrobox-host-exec (env passed through env(1)); their FRAME_VOICE_*
variables keep frame-voice's names.
Settings: ~/.config/frametop-voice.json (voice_settings.py, standard
library), wake word off by default, "advanced" for the old config.toml
tunables. voice/run.sh install carries frame-voice's settings over once
(its keys become key combinations) and turns the standalone units off.
Install: install.sh step 8/11 asks (yes by default; --no-voice through
get.sh, install-release.sh and a FrameDrop checkbox). voice/build.sh
builds whisper-server (whisper.cpp d09f61a, Vulkan, static) and
build/venv; voice/models.sh fetches the 322 MB of models into
~/.local/share/frametop/voice by fixed URL and sha256. The image gets
glslc, spirv-headers-devel and pipewire-utils, uv.lock a voice group (sherpa-onnx with its
core pinned: uv dropped it), CI smoke whisper-server and the venv.
uninstall.sh asks about the models; update-check and report.sh know the
service (report.sh leaves out the lines with what was said).
Tests: voice/test (settings, carrying over, typing through a stand-in
relay, push-to-talk, controller buttons from a stand-in web socket) in
just test.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The plan changed from two repos to one: DeeJanuz/frametop transfers to the Frametop
organization, so issues and pull requests go to Frametop/frametop as well (report.sh,
keys-report.py, README, the hand recorder page). The consent text keeps its old link, which
GitHub redirects, so its wording doesn't change. pack/README.md says how the move keeps the old
links and the old one-liner working.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
New clones, the one-liners (frametop.github.io/frametop), the SteamOS table that releases
check, and FrameDrop's download URLs now point at Frametop/frametop, where CI builds the
releases. Issues and pull requests stay on DeeJanuz/frametop, the upstream it mirrors.
get.sh's menu offers a stable release next to the experimental one (3 and 4). SteamOS 0.4.5
(20261007.6125817) is marked tested with Frametop 0.2.2. pack/README.md says how the two repos
and a release fit together.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Headset tests on 2026-10-09: Bluetooth reconnects, doctor ok, gaze with
our own tracker and SteamOS 0.4's eye-tracker layout, and the desktop
without Steam's autostart and with its own cursor theme. Release
installs stop asking before they install on build 20261007.6125817.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
deejanuz.github.io serves DeeJanuz/frametop's main, whose get.sh has no
--release yet ("unknown option: --release"). frametop.github.io serves
Frametop/frametop's experimental, where the releases are built.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Found in the runtime trial (2026-10-07): the clean-up after an install loops over
releases/*/, which includes the "current" link to the release just installed, so it removed
that release's container and image, and the services it had just started kept failing until
the release was installed again. Links are skipped now.
pack/trial.sh switches this Frame to a release and back: on saves what the release's
install.sh replaces (the frametop-* units with their drop-ins and .wants links, the launcher
and menu entries, the driver's folder and SteamVR's registry, frametop.conf, the desktop's
shortcuts), moves the drop-ins aside, installs from the unpacked zip with
--no-eye-tracker --no-bluetooth, and restarts SteamVR; desktop restarts the VR desktop from
the release; status shows which container each program runs in; off puts everything back,
restarts SteamVR, and stops the release's container.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
User decision (2026-10-07): people download one file from GitHub, installed through FrameDrop
or unpacked on the headset, holding everything, with no container registry.
Frametop.zip (framedrop/build.sh --image, about 1.1 GB) holds the image as an OCI archive
(podman save), frametop-release.json (pack/release-info.py: version, commit, the archive's
sha256, the image's ID, and the SteamOS table), the install window, and
pack/install-release.sh. That script checks this SteamOS build (against main's steamos.json
when it can fetch it, else the release's copy), checks the archive's sha256, loads it and
checks the ID, copies the release's tree out and runs its install.sh, then keeps the release
before and removes older ones. pack/release-box.sh checks the image's ID before making the
container. The install window runs install-release.sh when the zip has one (get.sh in a test
zip), and reaches systemd through the user's real bus when it's opened from a VR desktop.
get.sh --release now downloads the zip from GitHub (newest stable, --experimental,
--version V, or --zip FILE|URL), resumable and per release, and runs its installer; a
damaged download (exit status 3) is fetched again next time. The registry release list and
its writer are gone.
.github/workflows/release.yml replaces image.yml: on a v* tag or by hand only, on
depot-ubuntu-24.04-arm-4, it builds the image with podman, runs the test gate in it, checks
what a release installs, builds the zip, and makes a draft release (prerelease for a tag
with a "-") with the zip, its FrameDrop manifest, frametop-release.json, and SHA256SUMS.
pack/release-notes.md is the draft's install text.
Tested on the Frame: the release tests (24 checks), a real zip from the local image installed
with get.sh --release --zip --clone-only (sha256, load, ID, copy), a damaged archive (exit 3),
and release-box.sh refusing an image with another ID.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The install window now asks first: our own eye tracker (on by default) and the Bluetooth fixes,
which both need sudo, and the SteamOS password for them, checked with sudo -v. A user without
a password (SteamOS starts without one) is told how to set one, and both parts are left out.
Then it starts the install service itself, with the zip's own get.sh, and with
--release --manifest frametop-releases.json when framedrop/build.sh --releases put a release
list in the zip.
The password stays in the window's memory until the install ends. The service gets
SUDO_ASKPASS=askpass, which asks the window over a socket in a 0700 folder in XDG_RUNTIME_DIR;
the window answers only processes in the install's service (peer credentials and cgroup), and
asks again on the headset when it was reopened without it. Never in a file, a log, the
service's environment, or a command line. Tested on the Frame with sudo -A true: a process in
the service gets it, one outside gets nothing, and an ask without it waits for the window.
install.sh and get.sh get --no-eye-tracker, for an unticked eye tracker with the Bluetooth
fixes ticked. pack/README.md describes the runtime and releases, and notes that releases and
the ft wrapper's installed mode pin the image two ways, to merge before this ships.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
get.sh --release reads a release list (frametop.release/v1: releases/stable.json or
experimental.json on Frametop's page, or --manifest), picks the newest release tested on this
SteamOS build, else the newest not known to break on it (asking first), and refuses a build
every release breaks on (--any-steamos overrides). It pulls the release's image by digest,
copies the image's /src/frametop out to ~/.local/share/frametop/releases/VERSION with a
.frametop-release (version, image, commit, channel, container), and runs that copy's
install.sh. The release before stays, for going back; older ones go, with their containers
and images.
In a release tree (FRAME_RELEASE in _env.sh), install.sh builds nothing: it installs the
distrobox the image brings (pack/build/distrobox) and makes the release's own container from
its image (pack/release-box.sh, named after the digest, so installing a release never stops
the one running). scripts/in-box then runs the programs there. install.sh also gets
--bluetooth, to install the Bluetooth fixes without asking.
pack/steamos.json is the SteamOS table: builds tested, and builds that break releases from
"from" up to "fixed_in". pack/release-manifest.py writes release lists with it, for CI. The
pick logic and the lists are tested offline (pack/test/release-test.py, in the gate).
uninstall.sh removes releases, their containers, and their images in its second step.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
A container's own network namespace starts with net.unix.max_dgram_qlen
at the kernel's 10; systemd sets 512 on the host. The input relay sends
without blocking and drops what a full queue refuses, so in the image
keys-test.py saw a starting relay's release burst lose its last five
mouse-button releases. ft runs and CI's test step now set 512 and keep
their own namespace, so tests can't collide with a live desktop's
sockets. pack/design.md adds the queue length to what the runtime on the
Frame must keep (the host namespace has it).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The README and AGENTS.md no longer say Frametop can run from the image
or that the units become one-liners: no installer uses it yet. AGENTS.md
keeps the rules that hold now (pin every input, one build recipe, tests
green in the image) and drops FT_FRAME. pack/README.md fixes the Python
and OpenVR descriptions and the CI section, and ends with the plan: a
headset trial of the runtime, a release that builds the image and the
host payload from one commit, then get.sh and FrameDrop installing it.
pack/design.md keeps the device findings for OpenVR clients, lists what
each program needs from the host, and compares a distrobox from the image
with Quadlet units.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The binaries come from screens/, pointer/, gaze/, and power/build.sh
(FRAME_IN_BOX=1), so the image and the Frame build them one way.
The venv now sits on Fedora's python3 with the system site-packages.
With uv's own CPython 3.12 first on PATH, python3 couldn't import the dnf
PySide6 (built for Fedora's 3.14), so the settings apps and the hand
recorder couldn't start, and their tests only skipped. The base image is
pinned by digest, so its python3 is frozen too. The layer checks that
PySide6, numpy, and cv2 import together.
uv and libopenvr_api.so are checked against pinned sha256 sums; the
headers and stb_truetype come through the build scripts.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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.
The runtime image needs no git: builds use the copied sources, the
installed mode has no repo at all, and report.sh falls back gracefully
outside a checkout. test-bash now lists shipped shell scripts with find
— git ls-files broke silently in CI (dubious ownership on a mounted
checkout, for-loop word list swallowed the failure) and only tested ft.
A count guard fails the gate on a suspiciously short list instead.
The refusal applies to the pinned image and the update source — the two
references a headset depends on. Repo mode never second-guesses FT_IMAGE,
so a developer can run :latest locally if they want. Wording in the
wrapper header and pack/README aligned ("No :latest on a headset").
On a Frame, frametop's rootless podman store is shared with Valve's
lepton-* containers. ft clean removes only frametop-* containers and
frametop-named images; store-wide podman commands are now explicitly
documented as off-limits.
Installed mode refuses to run without a pinned reference, and :latest is
rejected for both the pinned image and the update source. ft update pins
the pulled digest to ~/.config/frametop/image, so bug reports name an
exact image and rollback is a one-file edit. The published reference is
version-tag-only and must be configured (install.sh will write it).
pack/README: shared-store rules, digest-pinning workflow, storage note
(the image replaces the toolchain, net smaller), network path for pulls;
GHCR visibility flip documented. design.md: tagging open decision
narrowed to cadence.
ft gains the dev/runtime split: build, shell, and test live behind
'ft dev' so an installed copy refuses them, programs run under stable
container names (frametop-<program>, leftovers replaced), and the
macOS bash 3.2 empty-array edge is handled.
pack/design.md is the packaging rationale — the image as the product,
what it solves, why OCI and not uv alone or Flatpak, how the wrapper,
tests, and the Frame runtime fit in, and the open decisions. The
README gets a short Packaging section pointing there, AGENTS.md gets
the working rules for pack/ (pin every input, ft as the single
integration point, build and test through the image, host
dependencies explicit), and pack/README.md follows the ft dev
renaming.
Repo mode (pack/Containerfile beside the script) keeps building and testing
from the sources and mounts them at /src/frametop. Installed mode (the copy
install.sh will put in ~/.local/bin, since the SteamOS root is read-only) runs
the image's own copy: no repo on the device needed. The image reference
resolves > ~/.config/frametop/image (written by install.sh, so
installs pin what was installed) > the published image in installed mode,
the local build in repo mode. update always pulls the published image.
The container becomes an artifact instead of a recipe: the base toolbox image
is pinned by digest, Python dependencies come from the committed uv.lock
(uv-managed CPython, clean venv at /opt/frametop/venv, no system-site-packages),
and OpenVR's header and libopenvr_api come from the same pinned public tag the
Frame-side build scripts pin. The native binaries compile inside the image the
same way the build.sh scripts compile them; hand tracking stays deferred as in
install.sh.
- ft: one wrapper for everything that runs (build, shell, run, test, update);
Frame mounts (ipc=host, /run/user, /opt/steamvr) are designed behind
FT_FRAME=1 until validated on the device (pack/README.md)
- justfile: runner for the in-image actions (test, lint as report, check)
- CI: native arm64 runner builds and pushes ghcr.io/0x1f6/frametop and runs
the suites inside the built image as a smoke job; actions pinned to SHAs
Validated locally (docker, aarch64): full build, all six binaries, and
./ft test green (unittest suites, check scripts, 11 pytest tests).