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

+32
View File
@@ -0,0 +1,32 @@
#!/bin/bash
# Frame-side: run one Android app in its own persistent Lepton instance.
# Copied into ~/Applications/Android/<package>/launch.sh by the Mac-side
# installer, next to app.apk, instance.id and (for 2D apps) the empty
# lepton-show-flatscreen marker. A non-Steam shortcut points at this file.
#
# Why not Lepton Development: it wipes every app it installed when it exits.
# A "steamlaunch" context (SteamAppId set) keeps app data in
# compatdata/<id>/internal across restarts and APK updates. Pattern from
# frame/t3code/launch.sh; see docs/apks.md.
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
LEPTON="$HOME/.local/share/Steam/steamapps/common/Lepton/lepton"
[[ -x "$LEPTON" ]] || { echo "Lepton isn't installed (Steam app 3056000)" >&2; exit 1; }
for need in "$DIR/app.apk" "$DIR/instance.id"; do
[[ -f "$need" ]] || { echo "launch.sh: missing $need" >&2; exit 1; }
done
# A number that isn't a real Steam app; it names this app's Lepton context.
export SteamAppId="$(cat "$DIR/instance.id")"
export STEAM_COMPAT_INSTALL_PATH="$DIR"
# Must be under ~/.local/share/Steam: only that tree is mounted in the container.
export STEAM_COMPAT_DATA_PATH="$HOME/.local/share/Steam/steamapps/compatdata/$SteamAppId"
export STEAM_COMPAT_SHADER_PATH="$HOME/.local/share/Steam/steamapps/shadercache/$SteamAppId"
export STEAM_FOSSILIZE_DUMP_PATH="$STEAM_COMPAT_SHADER_PATH/fozpipelinesv6/steamapp_pipeline_cache"
mkdir -p "$STEAM_COMPAT_DATA_PATH" "$STEAM_FOSSILIZE_DUMP_PATH"
# Lepton's setpgid --foreground re-exec needs a terminal that Steam shortcuts
# and SSH don't have; give it its own session instead.
export IS_PARENT=true
exec setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/app.apk"
+111
View File
@@ -0,0 +1,111 @@
#!/usr/bin/env python3
"""Frame-side: manage non-Steam shortcuts through the Steam client's CEF debug
port (127.0.0.1:8080, target SharedJSContext), without restarting Steam.
Python stdlib only; the Mac runs it with `ssh frame python3 - <args> < this`.
steam_shortcuts.py add NAME EXE START_DIR [ICON] -> prints the shortcut app id
steam_shortcuts.py list -> JSON [{appid, name, exe}]
steam_shortcuts.py remove APPID
"""
import base64, json, os, socket, struct, sys, urllib.request
DEVTOOLS = 'http://127.0.0.1:8080/json'
def target_ws():
for t in json.load(urllib.request.urlopen(DEVTOOLS, timeout=5)):
if t.get('title') == 'SharedJSContext':
return t['webSocketDebuggerUrl']
sys.exit('SharedJSContext not found: is the Steam client running?')
class WS:
"""Just enough RFC 6455 for one CDP request/response on loopback."""
def __init__(self, url):
host_port, path = url[len('ws://'):].split('/', 1)
host, port = host_port.split(':')
self.s = socket.create_connection((host, int(port)), timeout=20)
key = base64.b64encode(os.urandom(16)).decode()
self.s.sendall((f'GET /{path} HTTP/1.1\r\nHost: {host_port}\r\nUpgrade: websocket\r\n'
f'Connection: Upgrade\r\nSec-WebSocket-Key: {key}\r\n'
'Sec-WebSocket-Version: 13\r\n\r\n').encode())
buf = b''
while b'\r\n\r\n' not in buf:
buf += self.s.recv(4096)
if b' 101 ' not in buf.split(b'\r\n', 1)[0]:
sys.exit('websocket handshake failed')
self.rest = buf.split(b'\r\n\r\n', 1)[1]
def _read(self, n):
while len(self.rest) < n:
chunk = self.s.recv(65536)
if not chunk:
raise EOFError
self.rest += chunk
out, self.rest = self.rest[:n], self.rest[n:]
return out
def send(self, text):
data = text.encode()
mask = os.urandom(4)
n = len(data)
head = bytes([0x81]) + (bytes([0x80 | n]) if n < 126 else
bytes([0x80 | 126]) + struct.pack('>H', n) if n < 65536 else
bytes([0x80 | 127]) + struct.pack('>Q', n))
self.s.sendall(head + mask + bytes(b ^ mask[i % 4] for i, b in enumerate(data)))
def recv(self):
msg = b''
while True:
b0, b1 = self._read(2)
n = b1 & 0x7f
if n == 126:
n = struct.unpack('>H', self._read(2))[0]
elif n == 127:
n = struct.unpack('>Q', self._read(8))[0]
msg += self._read(n)
if b0 & 0x80:
return msg.decode()
def evaluate(js):
ws = WS(target_ws())
ws.send(json.dumps({'id': 1, 'method': 'Runtime.evaluate', 'params': {
'expression': js, 'awaitPromise': True, 'returnByValue': True}}))
while True:
r = json.loads(ws.recv())
if r.get('id') == 1:
break
res = r.get('result', {})
if 'exceptionDetails' in res:
sys.exit('JS error: ' + json.dumps(res['exceptionDetails'])[:500])
return res.get('result', {}).get('value')
def main():
cmd, args = sys.argv[1], sys.argv[2:]
if cmd == 'add':
name, exe, start_dir = args[:3]
icon = args[3] if len(args) > 3 else ''
js = f'''(async () => {{
const id = await SteamClient.Apps.AddShortcut({json.dumps(name)}, {json.dumps(exe)}, "", "");
SteamClient.Apps.SetShortcutName(id, {json.dumps(name)});
SteamClient.Apps.SetShortcutStartDir(id, {json.dumps(start_dir)});
if ({json.dumps(icon)}) SteamClient.Apps.SetShortcutIcon(id, {json.dumps(icon)});
return id;
}})()'''
print(evaluate(js))
elif cmd == 'list':
js = '''(() => appStore.allApps.filter(a => a.app_type === 1073741824)
.map(a => ({appid: a.appid, name: a.display_name})))()'''
print(json.dumps(evaluate(js)))
elif cmd == 'remove':
evaluate(f'SteamClient.Apps.RemoveShortcut({int(args[0])})')
print('removed')
else:
sys.exit(__doc__)
if __name__ == '__main__':
main()
+41
View File
@@ -0,0 +1,41 @@
#!/bin/bash
# Frame-side: run T3 Code in its own Lepton (Android) instance whose data
# survives restarts. Lepton Development wipes its apps when it exits; a
# "steamlaunch" context (SteamAppId set) keeps them.
#
# Lives in ~/Applications/T3Code next to t3code.apk and the empty
# lepton-show-flatscreen marker (without it Lepton runs the app headless).
#
# App data (T3's pairing) lives in compatdata/<id>/internal and survives
# everything. Lepton rebuilds its Android system snapshot (compatdata/<id>/baked)
# when the APK changes or the app exits within 30 seconds of starting.
set -euo pipefail
DIR="$(cd "$(dirname "$0")" && pwd)"
LEPTON="$HOME/.local/share/Steam/steamapps/common/Lepton/lepton"
# Without the marker Lepton runs T3 headless: Steam says "running" but no panel
# appears. Fail loudly instead.
for need in "$DIR/t3code.apk" "$DIR/lepton-show-flatscreen"; do
[[ -f "$need" ]] || { echo "launch.sh: missing $need" >&2; exit 1; }
done
[[ -x "$LEPTON" ]] || { echo "launch.sh: Lepton not installed at $LEPTON" >&2; exit 1; }
# Any fixed number that isn't a real Steam app: it names the Lepton context.
export SteamAppId=2873873873
export STEAM_COMPAT_INSTALL_PATH="$DIR"
# Must sit under ~/.local/share/Steam: Lepton symlinks /data/data/<app> and
# /data/media/0 to host paths here, and only the Steam dir is mounted inside.
export STEAM_COMPAT_DATA_PATH="$HOME/.local/share/Steam/steamapps/compatdata/$SteamAppId"
# A Steam shortcut launch sets STEAM_FOSSILIZE_DUMP_PATH but not the shader
# path Lepton derives it from (unbound under `set -u`), so set both.
export STEAM_COMPAT_SHADER_PATH="$HOME/.local/share/Steam/steamapps/shadercache/$SteamAppId"
export STEAM_FOSSILIZE_DUMP_PATH="$STEAM_COMPAT_SHADER_PATH/fozpipelinesv6/steamapp_pipeline_cache"
mkdir -p "$STEAM_COMPAT_DATA_PATH" "$STEAM_FOSSILIZE_DUMP_PATH"
# Lepton re-execs itself through `setpgid --foreground`, which needs a
# controlling terminal that Steam shortcuts and systemd don't have. Skip that
# step and give Lepton its own session instead: its teardown kills its whole
# process group. --wait keeps this script alive so Steam sees the app running.
export IS_PARENT=true
exec setsid --wait "$LEPTON" waitforexitandrun -- "$DIR/t3code.apk"