The keyboard and trackpad no longer fetch KDE Connect from Valve's package
repository on the Frame. The desktop apps and the iPhone app's Frame bundle
carry Valve's arm64 build of kdeconnect 24.02.2-1 and the five libraries it
links (kcontacts, kpeople, modemmanager-qt, pulseaudio-qt, libfakekey),
pinned by SHA-256 in frame/kdeconnect/packages.json and downloaded at build
time from the kdeconnect-frame-24.02.2-1 release, which also holds Valve's
complete source package for each.
On first use the computer copies them over its SSH connection (the iPhone
bundle already has them on the Frame); the agent checks each SHA-256,
unpacks them and stamps which build it is, so later starts copy nothing.
No internet on the Frame, 3.6 MB instead of 8 MB, 18 MB unpacked instead of
82 MB (ModemManager and friends were packaging-only dependencies).
GPL/LGPL compliance: frame/kdeconnect/NOTICE.md names each exact version,
licence and source; per-project licence texts in frame/kdeconnect/LICENSES;
THIRD_PARTY_NOTICES.md; an About and licences dialog in the app.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Brings in #4, #5, #8 (fbl100's verified Remmina/VNC mirror), APK
alternatives and the website. docs/streaming.md: Mac in the headset stays
the recommendation (now verified on the Frame); Remmina keeps #8's verified
evidence as the whole-screen fallback. docs/mac-in-headset.md cites #8's
lag finding.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Analytics go to the maintainer's PostHog US project 343535, tagged
$lib = frame-control. Every event carries $ip 0.0.0.0, since PostHog
stores the sender's address otherwise (checked live), including events
queued by earlier versions.
- Report a problem sends a private problem_report event to PostHog instead
of a public GitHub issue, with its own random id so a contact address
can't be linked to analytics. The dialog asks how to reach the person and
shows a reference. Maintainers read reports on the PostHog dashboard or
with `python3 ui/frame_report.py inbox`.
- Community sync pages by timestamp in UTC: PostHog refuses OFFSET for
personal API keys.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Home → Keyboard and trackpad, in every version of Frame Control (Mac,
Windows, Linux, iPhone, iPad) with nothing to install on the device in
your hand. On a phone: a trackpad (drag, tap, two-finger scroll and
right-click) and a field that types on the Frame. On a computer: click
the pad to pass the mouse and keyboard through; Esc to stop.
First-party route: ui/frame_input_agent.py runs on the Frame and talks
KDE Connect's LAN protocol (v7) to kdeconnectd as if it were a phone.
KDE Connect isn't on the image, but Valve's package repository has it;
the agent fetches it and four libraries into ~ (no root, survives
updates), starts it, pairs by itself (accepting over D-Bus), and stops
it again when the last device disconnects. Each device has its own
identity; a stuck KDE Connect is restarted once.
Verified against the real Frame (SteamOS 0.4.1): first-time install,
pairing, pointer moves from the Mac's server and the iPhone app
(Simulator), Mac and iPhone at once, two installs at once, a frozen
daemon replaced, and the daemon stopping when the app quits.
Reviewed by GPT-6 Astra (xhigh) over seven rounds; all findings fixed
except per-event delivery acknowledgement (documented known limit).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Flatpak installs record their outcome inside main's background job; failed
jobs are diagnostics too. The Privacy panel lives on the Tools page (#privacy
opens it), tab analytics use the four page names, and "Test it now?" reads the
install job's result.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Anonymous PostHog analytics (ui/frame_telemetry.py): usage on by default
after a first-run notice; compatibility results and error details opt-in,
offered together by the notice's "Share more to help fix problems" button.
Random id, no person profiles or GeoIP, scrubbed text, an offline outbox,
and "Show what's been sent" in the new Privacy panel. Inert without a
project key, from a source checkout, or with DO_NOT_TRACK=1.
- APK installs now record install_failed when the APK itself won't install,
and offer a 20-second test after installing. Opted-in reports reach the
shared database through PostHog and `frame_compat_db.py sync`.
- The desktop app updates itself from published releases (app/updater.js):
update.json from releases/latest/download, SHA-256 checked, no downgrades;
macOS bundle swap, Windows NSIS, Linux AppImage, otherwise the release page.
scripts/publish-release.sh publishes a tested draft with its manifest.
- Report a problem (header button, Privacy panel, Help menu) files a GitHub
issue through the website's feedback API, with a previewed, scrubbed
diagnostics snapshot; activity and logs only when asked for.
Reviewed by GPT-6 Astra (xhigh, read-only) three times; all findings fixed.
Docs: docs/privacy.md, docs/releasing.md.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- site/: landing page, /feedback/ and /privacy/ on Cloudflare Pages
(frame-control.pages.dev). POST /api/feedback validates the form and opens
a labelled issue with a fine-grained token; honeypot, minimum fill time and
KV rate limits keep spam out. Ko-fi donate buttons appear once the page
name is set in site/public/js/site.js.
- .github: the issue and PR gate from badlogic/pi-mono. New contributors'
issues and PRs are auto-closed; a maintainer replying lgtmi/lgtm approves
them via APPROVED_CONTRIBUTORS. Issue templates and CONTRIBUTING.md.
- CI runs the website tests; README points feedback at the form.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Frame Control can now show any Mac window, or a whole screen, as its own
SteamVR panel on the Steam Frame (Tools -> Mac in the headset, macOS only).
Place it with the SteamVR dashboard; the laser clicks and scrolls, and the
Mac's own keyboard types.
- mac/frame-mac-view (Swift, no dependencies): ScreenCaptureKit capture per
window or display, VideoToolbox H.264 with low-latency rate control (JPEG
fallback), a loopback HTTP/WebSocket server, CGEvent/AX input playback,
and a display-awake assertion while anyone watches.
- ui/frame_macview.py: starts the agent, runs an ssh -R tunnel with a
supervisor that reopens it on the same port, and launches a Chromium app
window per stream on gamescope's :0, tagged with STEAM_GAME for its own panel.
- Frame Control's key never leaves the Mac: viewers get single-use,
per-source tickets and reconnect keys that Stop revokes.
- ui/mac-view.html: WebCodecs decode, keyframe recovery, pointer/wheel/keys back.
- Bundled in the Mac app build; tests/test_macview.py builds and drives the
agent on macOS.
Verified on the Frame (build 20260925.6191901) with the test pattern: panel
in about 1.5 s, about 60 fps, Mac-to-window about 11-17 ms, tunnel recovery
in 4 s. Laser input and real window capture still need a person in the headset.
Also commits the other thread's first-party rule (steam-frame skill) and
the first-party options table in docs/streaming.md.
Reviewed by GPT-6 Astra (xhigh, read-only) over six rounds; all findings fixed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A thumbnail under the screenshot opens the 66-second trailer, which is attached to the 'trailer' release.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
tests/fakeframe: a container that stands in for the Frame (Arch Linux, or
Arch Linux ARM on arm64) with sshd, rsync, Valve's steamos-devkit-service
and hooks (vendored unmodified), a fake Steam client for the devkit pipe and
the DevTools port (the app's JavaScript runs in Node against stand-in
SteamClient/appStore objects), stubs for steam, wpctl, flatpak, podman,
Lepton and friends, battery and thermal files under /sys, and fault
switches (fakeframe-ctl): pairing mode, approve/deny/timeout, Steam not
running, headset asleep, sshd off, disk full, runtimes missing. A second
container is the computer running Frame Control.
tests/e2e: 29 tests driving the real ui/server.py, frame_connect.py and
frame_titles.py against it; skipped unless FRAME_E2E=1. scripts/e2e.sh
builds, runs and tears down; CI runs it on ubuntu-24.04-arm.
tests/smoke + scripts/frame-smoke.sh: the core cases against a real Frame,
recorded with its BUILD_ID, cleaning up after itself; --pair to pair a
throwaway key. docs/testing.md describes the layers.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- docs/recovery-and-images.md: where Valve's Frame images are (not linked from
the SteamOS download page), file names, sizes and our checksums, the GPT
layout with exact start sectors, what's in rootfs-A (btrfs, SteamOS 0.3.0
build 20260922.5152327, users, sudo and sshd config), extracting it, running
it without the headset, and Valve/Collabora's Holo Core aarch64 preview.
- how-the-frame-works.md: correct the recovery image file names; add verified
facts on the SSH server, tools on the image (no adb), Lepton instances as
podman containers, going off the network when asleep, and the battery
reading at full charge.
- ssh.md: pairing from an iPhone and why devkit RSA pairing doesn't fit it.
- open-questions.md, README.md and the steam-frame skill point to the new pages.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
An iPhone can't run Python or ssh, but the Frame can. The app (ios/, SwiftUI)
connects with its own SSH key (Citadel), copies the server and helpers to
~/.cache/frame-control/<version> on the Frame once per version, starts
ui/server.py there with FRAME_LOCAL=1 on the Frame's 127.0.0.1, and shows the
page through an SSH tunnel. The server exits when the phone disconnects.
Server: FRAME_LOCAL=1 puts ui/local-bin on PATH, whose ssh stand-in runs each
`ssh frame COMMAND` locally (and serves as rsync's transport), so desktop and
phone share one code path. Android display goes through podman exec there, as
the Frame has no adb. FRAME_UI_KEY replaces the fixed X-Frame-UI value with a
per-session key. Power actions take the Developer Mode password via sudo -S.
--port 0 now prints the port it took.
Page: a bottom tab bar and safe areas on phones, Play buttons visible on touch
screens, saving through the share sheet, SSH/SFTP/Steam Link/remote desktop
opening in their iOS apps, and a password dialog for power.
App: pairing with the Developer Mode password once (never stored) or with a
key the user adds; host key pinned on first use; plain-language connection
errors with quiet retries; frame-control://install links; alerts and confirms.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The page was one 6,800px scroll with nine nav links (hidden below 1150px).
It is now four tabs, Home, Games, Android and Tools, switched with 1-4; old
section links still land on the right tab.
When the Frame can't be reached, the server turns ssh's connection errors into
one plain message (503, offline: true), the page shows a single banner with
Retry and Set Up Connection, retries every 8 s, and reloads every panel when
the Frame answers. Panels say "Waiting for the Frame" instead of raw ssh text.
Flatpak and Android catalogue installs run as background jobs the page polls,
so a slow install no longer holds a request for up to 15 minutes or reports a
false failure; the bottom bar counts running installs.
Files can be dropped anywhere in the window, as the README already said.
Recent reports show the newest five, with Show all. Android display explains
an empty or failed read. A topped-up headset on a charger reads as not
charging rather than "still draining, using 0.0 W".
Fixes a race where the catalogue and reports loads wrote the compat-db
mirror's .tmp file at once and one failed with a 500.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Tested on a Frame (BUILD_ID 20260922.6101926):
- Devkit pairing: the service runs and answers properties.json, but /register
refuses with "please put the Steam client in pairing mode" unless Steam is on
Settings > Developer > Pair new host. Both setup paths now say so and keep
asking for 2 minutes before falling back to the password.
- Sideloading: registering, launching, quoted start paths and Remove work; a
Windows exe runs under Proton 11 through FEX. An aarch64 build starts but
outside the runtime container, and an x86-64 Linux build doesn't start
because the x86-64 Steam Linux Runtime 4.0 isn't installed. The inspect note
for x86-64 Linux builds now says so.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Combines the three feature branches on bundle-deps. Conflicts in server.py,
index.html, preload.js, README and test_server.py keep both sides. The
web-install downloader now uses urllib's default HTTPS context, so the
bundled CA list from 770f26c applies to it on Windows too.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Dropping a game's .zip, folder or .exe on Send to Frame now adds it to the
headset's Steam library through Valve's SteamOS Devkit title path, with the
runtime picked from the program's header: Windows PE -> Proton Experimental
(steam_play=1), aarch64 ELF -> SteamLinuxRuntime_4-arm64, x86-64 ELF ->
SteamLinuxRuntime_4 (through FEX). Other architectures are refused.
- frame/devkit-utils: Valve's devkit-utils vendored unmodified (MIT,
steamos-devkit v0.20260925.1), synced to ~/devkit-utils by stamp, bundled in
the app and compiled in CI.
- ui/frame_titles.py: inspect (safe unzip, ELF/PE classification, launch
target ranking), install(path, name=None, exe=None, runtime=None,
progress=None), list, launch, remove, plus a CLI.
- ui/server.py: /api/titles (inspect/install/discard/launch/remove),
/api/titles/job progress, and an upload mode 'title'.
- ui/index.html: confirm dialog (name, launch target, runtime), install
progress, and a Sideloaded titles list with Launch and Remove. The app's
preload passes a dropped folder's path.
- tests and docs/sideloading.md. Device-side behaviour is inferred from
Valve's source; the headset was offline, so none of it has been checked on
a Frame yet.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
A site can link to frame-control://install?manifest=URL (or ?url=URL) to
install a title with Frame Control. Manifests use FrameDrop's format, so
framedrop.install/v1 is accepted as well as frame-control.install/v1.
- app/install-link.js parses links; main.js registers the scheme (plus
electron-builder protocols for Info.plist and the .desktop file), takes
links from open-url, second-instance argv and the first argv, and holds
them until the page asks for them through preload's onInstallLink.
- ui/frame_webinstall.py checks the URLs (HTTPS only; localhost over http
only when the link itself is local; no userinfo; every address public,
rechecked on redirects and pinned for the connection), reads the
manifest, downloads with a size cap and sha256 check, and dispatch()
sends .apk to frame_android and .zip/.exe to frame_titles when present.
- server.py adds /api/webinstall/check, start, job and cancel behind the
existing Host and X-Frame-UI guards; a start needs a one-time id from
check. Downloads stop on cancel and on shutdown, and leftovers from a
killed server are swept by PID.
- index.html asks before anything downloads (name, source host, file,
type, size, whether a sha256 was given) and shows progress.
- docs/web-install.md, docs/install.html (landing page, unpublished).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
connect.sh and frame_connect.py now try Valve's steamos-devkit-service
first: GET /properties.json for the login user, then POST /register with
a new RSA key (~/.ssh/id_rsa_frame_devkit, the only type it accepts), so
the user approves a prompt in the headset instead of typing a password.
Port 32000 closed, a timeout or a 403 falls back to the existing
password copy. The Host frame block lists both keys; a host counts as
found if port 22 or 32000 answers; with no host given, dns-sd or
avahi-browse look for _steamos-devkit._tcp. Inferred from Valve's
source, not yet verified on a Frame.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- app/build/fetch-deps.js downloads a standalone Python 3.12
(python-build-standalone) for every build and adb from Google's
platform-tools, pinned by SHA-256, and prunes what the server never
uses. It replaces the Windows-only embeddable Python.
- The app runs the bundled Python with -I -u -B -X utf8, so a PYTHONHOME
or PYTHONPATH meant for another Python can't break it and nothing is
written inside the signed macOS bundle.
- An adb you already have still goes first, so two adb versions don't
keep restarting each other's server. arm64 Linux has no official
platform-tools and keeps using the system adb.
- ui/frame_apk.py reads APK badging (package, label, version, min SDK,
ABIs, icon) from the binary manifest and resources.arsc, replacing
aapt2. It matches aapt2 on nine F-Droid APKs and finds launcher icons
the old path missed with adaptive icons.
- The app reads the computer's clipboard through Electron, so Linux no
longer needs wl-clipboard or xclip.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Run the server with -X utf8: the bundled Windows Python ignores PYTHON* variables.
- Quote every argument in Windows terminal commands, so cmd metacharacters are literal.
- frame_connect: accept HOST:PORT, validate input, retry the config swap while
Windows' ssh.exe holds ~/.ssh/config locked, and don't apply 0o700 on Windows.
- Never use rsync on Windows; unbounded stream queue; validate FRAME_ALIAS;
more Linux terminals; bundle the window icon; docs and wording fixes.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Without the maintainer's key, Android compatibility reports stay on the
Mac and the UI says so; the shared database is never contacted.
- Remove personal infrastructure details from scripts and docs: the Drive
folder and gog wrapper now come from the environment, and the Chromium
build host is required instead of defaulted.
- Add an MIT license, tester instructions in the README, and bump to 0.2.0.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Live in the headset view now streams video instead of polling stereo
screenshots (~2 fps). SteamVR's steamvr-v4l2cam.service mirrors the headset
view into /dev/video99; ffmpeg on the Frame encodes it with x264 (720p30 by
default, AUD + repeated SPS/PPS), /api/stream relays the raw H.264 over SSH,
and the page splits it on access unit delimiters and decodes it with
WebCodecs into the existing viewer. Capture still takes a stereo still; the
desktop panel keeps capture polling, and the page falls back to it if the
video can't start.
The remote ffmpeg runs under a shell that kills it when the SSH channel
closes, stderr goes to a temp file, and a 10 s stall ends the stream. One
stream at a time; a new one supersedes the last.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- Screenshots: list the Frame's Steam screenshots, open them in the
viewer, and save new ones to ~/Pictures/SteamFrame. Ids are validated
before any shell, and copies land atomically.
- Tailscale: scripts/tailscale-on-frame.sh installs a userspace tailscaled
as a lingering systemd --user service with no sudo, SHA-256 checked, safe
to re-run, with --uninstall. docs/tailscale.md covers setup and warns that
in userspace mode every Frame port, including loopback-only DevTools and
ADB, is reachable from the tailnet.
- push-vr-video.sh: filenames starting with "-" are safe, symlinks are
followed, and a real Videos\VR directory triggers a warning.
- Tests cover the screenshot routes (19 total).
Docs keep placeholder addresses for the headset and tailnet.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
- app: startup shell, python and ssh probes run asynchronously so a slow
shell profile can't freeze the window; PATH comes from the user's real
login shell and a failed lookup isn't cached; a server that never
answers is killed; the setup offer runs once per launch, only after the
UI loads, and decides from HostName alone; connect.sh is started through
`env ... zsh` so it works whatever the login shell is.
- server: volume validates the level before muting or changing anything.
- Steam: null-safe install-manager fields, http.client errors caught in
store ratings, price fallback when a sale has no final price.
- tests: server output kept for diagnosis, any startup error retried, and
captures asserted non-cacheable.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Package the Frame Control web UI as an installable Electron Mac app and
bring in the tooling built alongside it.
- app/: Electron wrapper that starts ui/server.py on a free loopback port,
hardened window (sandbox, no navigation, runAsNode fuse off), login-shell
PATH so Homebrew tools work from Finder, first-run offer to run
connect.sh, ad-hoc signed DMG/zip via electron-builder.
- ui/: headset view (OpenVR screenshots), device status, library, Steam
"Get games" (owned games, install, store search), Android apps as
persistent Lepton instances with a rated F-Droid catalogue and a private
compatibility database, Android display controls over ADB, file and
clipboard transfer, Flatpaks, remote and power actions.
- apk-catalog/, compat-db/, frame/: catalogue build pipeline, Lakebed
capsule for compatibility reports, Frame-side launchers.
- tests/ and CI: server guard and validation tests plus Steam helper tests,
run on Python 3.9 with script and app syntax checks.
- Docs: README leads with the Mac app; new Android, panels, Steam games and
field-notes docs; security notes on LAN-exposed ADB ports.
Screenshot values for the headset's IP and Wi-Fi name are placeholders.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Borrows the nested Plasma session's display and D-Bus variables from
plasmashell and starts the app detached. Tested on the Frame: error paths,
argument quoting, ~ expansion, and `mac-screen` opening a VNC connection.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The headset desktop is nested Plasma in gamescope with no wl-copy/xclip,
so paste-to-frame.sh now calls Klipper over plasmashell's D-Bus bus.
connect.sh, push.sh, paste-to-frame.sh and install-apps.sh were exercised
on SteamOS 0.3.0 (build 20260922); docs record what was confirmed.
Cross-provider review skipped at Alex's request (Astra quota exhausted).
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
README with the minimum-typing checklist (Developer Mode toggle + Set User
Password; the rest runs from the Mac), docs for SSH, streaming, file
transfer, and open questions with sourced confidence levels, plus Mac-side
zsh helpers and a fallback headset bootstrap.
Scripts are UNTESTED against hardware: checked with zsh -n / bash -n /
shellcheck only. Two SWE-2 Max read-only review passes (devin -p --model
swe-2-max); verified findings fixed.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>