mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 03:00:18 +02:00
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:
1 parent
6ccf562756
commit
d4486a7681
56 files changed
+19298
-11
No files matched your search
Executable
+21
@@ -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"
|
||||
Executable
+68
@@ -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
|
||||
Executable
+46
@@ -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
|
||||
Executable
+100
@@ -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
|
||||
Executable
+112
@@ -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}}"
|
||||
Reference in new issue
Block a user