Frame Control: Mac app, web UI, Android and Steam tooling

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>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-25 22:21:20 +10:00
1 parent 6ccf562756
commit d4486a7681
56 files changed
+19298 -11

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env zsh
# Mac-side: refresh the Android app catalogue that Frame Control shows
# (apk-catalog/). Fetches the latest F-Droid index, scans new or updated APKs
# with HTTP range requests, and rebuilds apk-catalog/site/apps.js.
# Frame Control picks up the new data on its next page load.
#
# Usage: scripts/apk-catalog.sh (the first full scan takes about an hour;
# later runs only scan what changed)
set -euo pipefail
CAT="${0:A:h}/../apk-catalog"
[[ "${1:-}" == -h || "${1:-}" == --help ]] && { sed -n '2,8p' "$0"; exit 0; }
print "==> Fetching the F-Droid index"
curl -fL --progress-bar -o "$CAT/data/index-v2.json.part" https://f-droid.org/repo/index-v2.json
mv "$CAT/data/index-v2.json.part" "$CAT/data/index-v2.json"
print "==> Scanning new or updated APKs"
WORKERS=40 python3 "$CAT/scan.py"
WORKERS=40 python3 "$CAT/scan2.py"
print "==> Rebuilding"
python3 "$CAT/build.py"
+68
View File
@@ -0,0 +1,68 @@
#!/usr/bin/env zsh
# Mac-side: back up Frame Control's compatibility database (the private
# Lakebed capsule at https://frame-compat.lakebed.app).
#
# Exports every report through the app's own key (ui/frame_compat_db.py), keeps
# dated copies in ~/Library/Application Support/Frame Control/compat-db/backups (newest
# 60), and uploads to Google Drive (the backup folder) when
# the data changed since the last upload. Run daily by the LaunchAgent
# frame-compat-backup (see docs/apks.md).
#
# Usage: scripts/compat-db-backup.sh [--no-upload] [--force-upload] [--accept-shrink]
# Env: DRIVE_FOLDER_ID, GOG_WRAPPER
set -euo pipefail
ROOT="${0:A:h}/.."
DEST="$HOME/Library/Application Support/Frame Control/compat-db/backups"
DRIVE_FOLDER_ID=${DRIVE_FOLDER_ID:-<drive-folder-id>}
GOG_WRAPPER=${GOG_WRAPPER:-$HOME/bin/gog-with-keyring.sh}
upload=1 force=0 accept_shrink=0
for arg in "$@"; do
case "$arg" in
--no-upload) upload=0 ;;
--force-upload) force=1 ;;
--accept-shrink) accept_shrink=1 ;;
-h|--help) sed -n '2,12p' "$0"; exit 0 ;;
*) print -u2 "unknown option $arg"; exit 2 ;;
esac
done
mkdir -p "$DEST"
stamp=$(date -u +%Y%m%dT%H%M%SZ)
out="$DEST/frame-compat-$stamp.json"
python3 "$ROOT/ui/frame_compat_db.py" export "$out"
# A backup that lost data is worse than none: refuse to shrink. Compare with the
# last backup that passed this check (.last-good), never with a refused one, so a
# loss keeps failing every day until someone looks and passes --accept-shrink.
count=$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["count"])' "$out")
good="$DEST/.last-good"
if [[ -f "$good" ]]; then
read -r good_count good_file < "$good"
if (( count < good_count )) && (( ! accept_shrink )); then
mv "$out" "$DEST/refused-${out:t}"
print -u2 "!! export has $count reports; the last good backup ($good_file) had $good_count."
print -u2 "!! Not uploading. Kept it as refused-${out:t}. If the loss is expected, rerun with --accept-shrink."
exit 1
fi
fi
print -r -- "$count ${out:t}" > "$good"
# Only the reports decide whether anything changed (not the export timestamp).
digest=$(python3 -c 'import json,sys,hashlib; r=json.load(open(sys.argv[1]))["reports"]; print(hashlib.sha256(json.dumps(sorted(r, key=lambda x: x["id"]), sort_keys=True).encode()).hexdigest())' "$out")
shasum -a 256 "$out" > "$out.sha256"
ls -1t "$DEST"/frame-compat-*.json | tail -n +61 | while read -r old; do rm -f "$old" "$old.sha256"; done
print "==> $count reports backed up to $out"
if (( upload )); then
last="$DEST/.last-uploaded-digest"
if (( ! force )) && [[ -f "$last" && "$(cat "$last")" == "$digest" ]]; then
print "==> Unchanged since the last Drive upload; skipped"
exit 0
fi
[[ -x "$GOG_WRAPPER" ]] || { print -u2 "gog wrapper not found at $GOG_WRAPPER"; exit 1; }
"$GOG_WRAPPER" drive upload "$out" --parent "$DRIVE_FOLDER_ID" --json --no-input >/dev/null
"$GOG_WRAPPER" drive upload "$out.sha256" --parent "$DRIVE_FOLDER_ID" --json --no-input >/dev/null
print -r -- "$digest" > "$last"
print "==> Uploaded to Google Drive (the backup folder)"
fi
+46
View File
@@ -0,0 +1,46 @@
#!/usr/bin/env zsh
# Mac-side: start Frame Control (ui/server.py) and open it in its own window.
#
# Needs scripts/connect.sh to have been run once. Ctrl-C stops the server.
#
# Usage: scripts/frame-ui.sh [--no-open]
# Env: PORT (default 47810), FRAME_ALIAS (default frame).
set -euo pipefail
PORT=${PORT:-47810}
here=${0:A:h}
url="http://127.0.0.1:$PORT/"
open_window=1
[[ "${1:-}" == "--no-open" ]] && open_window=0
[[ "${1:-}" == -h || "${1:-}" == --help ]] && { sed -n '2,8p' "$0"; exit 0; }
show() {
(( open_window )) || return 0
# A Chrome app window looks like a native app; fall back to the default browser.
if [[ -d "/Applications/Google Chrome.app" ]]; then
open -na "Google Chrome" --args --app="$url" --window-size=1400,950
else
open "$url"
fi
}
# The Server header tells our server apart from anything else on the port.
ours() { curl -fsS -D - -o /dev/null "$url" 2>/dev/null | grep -qi '^server: FrameControl'; }
if ours; then
print "Frame Control is already running at $url"
show
exit 0
fi
python3 "$here/../ui/server.py" --port "$PORT" &
server=$!
trap 'kill $server 2>/dev/null' EXIT INT TERM
for i in {1..50}; do
ours && break
kill -0 $server 2>/dev/null || { print -u2 "Server exited (port $PORT in use? try PORT=... $0)"; exit 1; }
(( i == 50 )) && { print -u2 "Server didn't start"; exit 1; }
sleep 0.1
done
show
wait $server
+100
View File
@@ -0,0 +1,100 @@
#!/usr/bin/env zsh
# Mac-side: install APKs on the Frame.
#
# Default: each APK becomes its own app, in its own persistent Lepton
# instance with a Steam library shortcut (ui/frame_android.py). Nothing is
# lost when it closes.
#
# --dev: the old way, ADB into Lepton Development over an SSH tunnel. Apps
# installed like this are deleted when Lepton Development exits.
#
# Verified on a Frame 2026-09-25 (--split untested).
#
# Usage:
# scripts/install-apk.sh APP.apk [APP2.apk ...] # own instance each
# scripts/install-apk.sh --dev APP.apk [APP2.apk ...] # into Lepton Development
# scripts/install-apk.sh --dev --split BASE.apk SPLIT.apk ...
#
# Env: FRAME_ALIAS (default frame), LOCAL_PORT (default: first free port from 15555).
set -euo pipefail
FRAME_ALIAS=${FRAME_ALIAS:-frame}
LEPTON_APPID=3056000
if [[ -z "${LOCAL_PORT:-}" ]]; then
for LOCAL_PORT in {15555..15575}; do
lsof -nP -iTCP:$LOCAL_PORT -sTCP:LISTEN >/dev/null 2>&1 || break
done
fi
SERIAL="127.0.0.1:$LOCAL_PORT"
CTL="${TMPDIR:-/tmp}/frame-adb-$$.sock"
split=0
dev=0
apks=()
for arg in "$@"; do
case "$arg" in
--split) split=1 ;;
--dev) dev=1 ;;
-h|--help) sed -n '2,18p' "$0"; exit 0 ;;
*) apks+=("$arg") ;;
esac
done
(( ${#apks} )) || { sed -n '14,16p' "$0" >&2; exit 2; }
if (( ! dev )); then
(( split )) && { print -u2 "--split needs --dev for now"; exit 2; }
for apk in "${apks[@]}"; do
print "==> Installing $apk as its own app"
FRAME_ALIAS=$FRAME_ALIAS python3 "${0:A:h}/../ui/frame_android.py" install "$apk"
done
exit 0
fi
command -v adb >/dev/null || { print -u2 "adb missing: brew install android-platform-tools"; exit 1; }
for apk in "${apks[@]}"; do
[[ -f "$apk" ]] || { print -u2 "not a file: $apk"; exit 1; }
# The Frame is ARM64: native code must include lib/arm64-v8a/.
libs=$(unzip -Z1 "$apk" 2>/dev/null | grep -E '^lib/[^/]+/' | cut -d/ -f2 | sort -u || true)
if [[ -n "$libs" && "$libs" != *arm64-v8a* ]]; then
print -u2 "!! $apk has native code for ${(j:, :)${(f)libs}} only; the Frame needs arm64-v8a"
exit 1
fi
done
lepton_listening() { ssh "$FRAME_ALIAS" 'ss -ltn | grep -q ":5555 "'; }
if ! lepton_listening; then
print "==> Starting Lepton Development on the Frame"
ssh "$FRAME_ALIAS" "steam steam://rungameid/$LEPTON_APPID >/dev/null 2>&1"
for i in {1..30}; do
lepton_listening && break
(( i == 30 )) && { print -u2 "Lepton didn't open port 5555 within 60s. Is Lepton Development installed?"; exit 1; }
sleep 2
done
fi
print "==> Tunnelling ADB over SSH (localhost:$LOCAL_PORT -> $FRAME_ALIAS:5555)"
ssh -f -N -M -S "$CTL" -o ExitOnForwardFailure=yes \
-L "127.0.0.1:$LOCAL_PORT:127.0.0.1:5555" "$FRAME_ALIAS"
cleanup() {
adb disconnect "$SERIAL" >/dev/null 2>&1 || true
ssh -S "$CTL" -O exit "$FRAME_ALIAS" >/dev/null 2>&1 || true
}
trap cleanup EXIT
adb connect "$SERIAL" | grep -q "connected to" || { print -u2 "adb connect $SERIAL failed"; exit 1; }
# A freshly started Lepton accepts ADB before Android has finished booting.
for i in {1..45}; do
[[ "$(adb -s "$SERIAL" shell getprop sys.boot_completed 2>/dev/null)" == 1 ]] && break
(( i == 45 )) && { print -u2 "Android in Lepton didn't finish booting within 90s"; exit 1; }
sleep 2
done
if (( split )); then
print "==> Installing split APK set (${#apks} files)"
adb -s "$SERIAL" install-multiple -r "${apks[@]}"
else
for apk in "${apks[@]}"; do
print "==> Installing $apk"
adb -s "$SERIAL" install -r "$apk"
done
fi
+112
View File
@@ -0,0 +1,112 @@
#!/usr/bin/env zsh
# Mac-side: start a Linux app on the Steam Frame as its OWN floating VR panel,
# separate from the Plasma desktop panel, so you can place it anywhere.
#
# How it works (verified 2026-09-25): gamescope runs with
# --virtual-connector-strategy PerAppId, so every distinct Steam app id gets
# its own SteamVR overlay (valve.steam.desktopgame.<id>). Steam normally sets
# that id on a game's X11 windows through the STEAM_GAME property. This script
# starts the app as an X11 client of gamescope (DISPLAY=:0), then tags each new
# top-level window with a per-panel id, which makes a new panel appear.
#
# Usage:
# scripts/panel-on-frame.sh [--id N] [--name LABEL] konsole
# scripts/panel-on-frame.sh --name notes -- kate '~/notes.md'
# scripts/panel-on-frame.sh org.mozilla.firefox # Flatpak app ID
# scripts/panel-on-frame.sh mac-screen # Remmina into the Mac
#
# Apps sharing an id share a panel. The default id is derived from --name (or
# the command), so re-running the same app reuses its panel slot.
# A leading "~/" in any argument is expanded on the Frame (quote it on the Mac).
set -euo pipefail
FRAME_ALIAS=${FRAME_ALIAS:-frame}
REMMINA_PROFILE="~/.var/app/org.remmina.Remmina/data/remmina/mac-screen-sharing.remmina"
id="" name=""
while (( $# )); do
case "$1" in
-h|--help) sed -n '2,20p' "$0"; exit 0 ;;
--id) id=${2:?--id needs a number}; shift 2 ;;
--name) name=${2:?--name needs a label}; shift 2 ;;
*) break ;;
esac
done
case "${1:-}" in
"") sed -n '2,20p' "$0"; exit 2 ;;
remmina) cmd=(flatpak run org.remmina.Remmina) ;;
mac-screen) cmd=(flatpak run org.remmina.Remmina -c "$REMMINA_PROFILE") ;;
--) shift; (( $# )) || { print -u2 "panel-on-frame: missing command after --"; exit 2; }
cmd=("$@") ;;
*)
if [[ "$1" =~ '^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+){2,}$' ]]; then
cmd=(flatpak run "$@")
else
cmd=("$@")
fi ;;
esac
if [[ -z "$id" ]]; then
# Stable id per label, well above real Steam app ids (< 5,000,000 today).
label=${name:-${cmd[*]}}
id=$(( 2000000000 + $(print -rn -- "$label" | cksum | cut -d' ' -f1) % 1000000 ))
fi
[[ "$id" == <1-4294967295> ]] || { print -u2 "panel-on-frame: --id must be a positive 32-bit number"; exit 2; }
# Runs on the Frame with the app id as $1 and the command as the rest.
remote=$(cat <<'EOF'
set -u
appid=$1; shift
export DISPLAY=:0
unset WAYLAND_DISPLAY
# Make toolkits pick X11 so the window lands on gamescope's Xwayland.
export QT_QPA_PLATFORM=xcb GDK_BACKEND=x11 SDL_VIDEODRIVER=x11 MOZ_ENABLE_WAYLAND=0
if ! xprop -root GAMESCOPE_FOCUSABLE_WINDOWS >/dev/null 2>&1; then
echo "gamescope's X display :0 isn't reachable; is the headset awake?" >&2
exit 2
fi
toplevels() { xwininfo -root -children 2>/dev/null | awk '/^ +0x/ {print $1}' | sort; }
before=$(toplevels)
if [ -z "$before" ]; then
echo "couldn't list windows on :0 (is xwininfo installed?)" >&2
exit 2
fi
args=()
for a in "$@"; do
case "$a" in "~/"*) a="$HOME/${a#\~/}" ;; esac
args+=("$a")
done
log=$(mktemp /tmp/panel-on-frame.XXXXXX)
setsid nohup "${args[@]}" > "$log" 2>&1 < /dev/null &
child=$!
tagged=0 first=0
# Tag new mapped windows: keep watching ~3s after the first (splash screens,
# secondary windows), up to 20s in total for slow Flatpaks.
for i in $(seq 1 40); do
sleep 0.5
[ "$tagged" -eq 0 ] && ! kill -0 "$child" 2>/dev/null && break
for w in $(comm -13 <(printf '%s\n' "$before") <(toplevels)); do
xwininfo -id "$w" 2>/dev/null | grep -q 'Map State: IsViewable' || continue
xprop -id "$w" STEAM_GAME 2>/dev/null | grep -q '= ' && continue
xprop -id "$w" -f STEAM_GAME 32c -set STEAM_GAME "$appid" 2>/dev/null && tagged=$((tagged + 1))
done
[ "$tagged" -gt 0 ] && [ "$first" -eq 0 ] && first=$i
[ "$first" -gt 0 ] && [ "$i" -ge $((first + 6)) ] && break
done
if [ "$tagged" -gt 0 ]; then
echo "panel: ${args[*]} -> valve.steam.desktopgame.$appid ($tagged window(s), pid $child, log $log)"
elif kill -0 "$child" 2>/dev/null; then
echo "started ${args[*]} (pid $child) but no new X11 window appeared." >&2
echo "It may be Wayland-only or single-instance (already running elsewhere). Log: $log" >&2
exit 1
else
echo "failed: ${args[*]} exited. Log:" >&2
tail -n 20 "$log" >&2
exit 1
fi
EOF
)
b64=$(print -rn -- "$remote" | base64)
ssh "$FRAME_ALIAS" "bash -c \"\$(echo $b64 | base64 -d)\" panel-on-frame $id ${(j: :)${(@q)cmd}}"