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>
get.sh --release asks for a channel and offers stable first, and the
Frametop organization has no stable release yet, so the notes' line
fetched releases/latest and got a 404 unless you picked 2.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
So a fork's test release (Frametop/frametop, where Depot's runners are set up) points at its
own zip, and get.sh --release can install from it.
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>
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 runner has podman as well as docker, so ft picked podman, which
pulled the last image published to GHCR: the wrapper smoke tested that
image, not the one the run built (on the fork, ghcr.io/0x1f6/frametop:
pack-frametop-image). On a branch nothing is in GHCR, so ft update failed.
FT_ENGINE now picks the engine, the job sets it to docker, and ft update
pulls this run's image from a registry started inside the job.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
github.repository keeps the owner's capitals, and image names must be
lowercase: under DeeJanuz every run failed at the first docker run. The
workflow_dispatch choice of a self-hosted runner goes too (that runner
and its setup script exist only on the fork), and scripts/** now
triggers a build, since the build scripts the image runs live there.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Feature branches are validated, not published — the registry holds only
states a user would actually run. Branch validation still covers the
full build/test/smoke chain against the local image.
Separate jobs meant every stage re-pulled the just-pushed image from
GHCR (~1.5 GB each). One runner keeps it in the local Docker cache:
build (no push) → test → smoke → push → artifact. The registry only
ever receives a green image, and the artifact's image-digest.txt names
a digest that exists because the push preceded it.
The smoke job ran 'just test' beside the new test job — the same suites
twice, once lenient, once strict. Smoke now checks only the artifact
(binaries, venv) and the wrapper paths; 'just test' is a thin alias for
test-python test-c test-bash, so there is exactly one definition of
what passing means.
An artifact job (needs build+test+smoke) extracts /src/frametop/ft from
the image the run built, verifies it byte-for-byte against the checkout,
and uploads it with its sha256 and the image's RepoDigest. The artifact,
the image, and the commit are thereby provably one thing — and install.sh
can later verify the wrapper it installs against ft.sha256.
The unset landed before the sandbox published-file was written, so
update pulled the empty reference (podman: 'repository name must have
at least one component', exit 125). The wrapper was never at fault.
The hard wrapper/image pairing broke the smoke job under Docker (exit
125, 'repository name must have at least one component' right after the
digest pin). Deferred until the smoke job can say exactly where it
fails; ft update stays the simple, locally verified pull + digest pin,
and the published reference must be configured explicitly again (no
silent fallback). The smoke job drops the pair cmp accordingly.
just test-python runs every Python suite strictly (hands and gaze
included — all pass off-device); test-c compiles the controller-click
unit test with -Werror in the image (sides_test stays with the hands
ncnn build, documented); test-bash syntax-gates every shipped shell
script. The new test job runs inside the image the build pushed, next
to smoke: smoke asks if the artifact is sound, test asks if the code
is right. controller-click-test.c #undefs NDEBUG so a release build
still tests. hands/** joins the CI trigger paths.
ft update now also copies /src/frametop/ft out of the pulled (digest-
pinned) image over the installed wrapper, backing the old copy up as
ft.previous — wrapper and image are one artifact, built from the same
commit by CI, and they cannot drift apart on a device. The update
source falls back to the channel baked into the wrapper
(FT_DEFAULT_PUBLISHED) instead of failing when install.sh has not
written a config yet.
CI smoke: after the sandbox update, the refreshed wrapper must be
byte-identical to the checkout's ft — the pair test.
The wrapper is the counterpart of the image — every device run goes
through it. The smoke job now runs it against the image the build
pushed: repo-mode program run, installed-mode update with digest-pin
verification, and the :latest refusal. ft joins the CI trigger paths.
The cloud arm64 runner stays the default on push. For on-demand testing while
it sits in the queue, workflow_dispatch takes a 'local' choice: both jobs then
run on a self-hosted runner labeled ft-arm64 — an ephemeral actions-runner
container in OrbStack (native arm64, same architecture the Frame runs), set up
by a local script that stays out of the repo. The runner fetches a single-use
registration token per start; the PAT stays in the macOS Keychain and only
ever scopes this one repository. The workflow triggers on push and
workflow_dispatch only, never pull_request — that is what keeps a self-hosted
runner safe on a public repo.
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).