Tests against a fake Frame, and a headset smoke test

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>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-27 17:40:09 +10:00
1 parent ca2a991f03
commit 0181071b83
43 files changed
+4747

No files matched your search

+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env python3
"""Flip the fake Frame's fault switches and read its state; `fakeframe-ctl help`.
Talks to the supervisor's control port, so it works the same over SSH
(`ssh frame fakeframe-ctl steam off`) and from the host container with
FAKEFRAME_CTL=http://fakeframe:9999.
"""
import json
import os
import sys
import urllib.error
import urllib.request
url = os.environ.get('FAKEFRAME_CTL', 'http://127.0.0.1:9999').rstrip('/')
req = urllib.request.Request(url + '/ctl', data=json.dumps({'args': sys.argv[1:]}).encode(),
headers={'Content-Type': 'application/json'})
try:
with urllib.request.urlopen(req, timeout=60) as r:
out = json.load(r)
except urllib.error.HTTPError as e:
out = json.load(e)
except OSError as e:
sys.exit(f'fakeframe-ctl: control port not answering ({e})')
if 'usage' in out:
print(out['usage'])
elif 'error' in out:
sys.exit(f"fakeframe-ctl: {out['error']}")
else:
print(json.dumps(out, indent=1))
if sys.argv[1:2] == ['ping'] and not out.get('ok'):
sys.exit(1)
+38
View File
@@ -0,0 +1,38 @@
#!/usr/bin/env python3
"""Stub for flatpak: --user installs and removals, and `list`, kept in the state file.
Nothing is downloaded; an app id containing "missing" fails like an unknown ref."""
import sys
sys.path.insert(0, '/usr/local/lib/fakeframe')
import fakeframe_state as fs # noqa: E402
args = sys.argv[1:]
fs.log('flatpak', args=args)
words = [a for a in args if not a.startswith('-')]
cmd = words[0] if words else ''
if cmd == 'remote-add':
pass
elif cmd == 'install':
app = words[-1]
if 'missing' in app:
sys.exit(f'error: Nothing matches {app} in remote flathub')
with fs.update() as s:
if not any(f['id'] == app for f in s['flatpaks']):
s['flatpaks'].append({'id': app, 'name': app.rsplit('.', 1)[-1], 'version': '1.0', 'installation': 'user'})
print(f'Installing {app}\nInstallation complete.')
elif cmd == 'uninstall':
app = words[-1]
with fs.update() as s:
before = len(s['flatpaks'])
s['flatpaks'] = [f for f in s['flatpaks'] if f['id'] != app]
gone = len(s['flatpaks']) < before
if not gone:
sys.exit(f'error: {app}/*unspecified*/*unspecified* not installed')
print('Uninstall complete.')
elif cmd == 'list':
for f in fs.read()['flatpaks']:
print('\t'.join((f['id'], f['name'], f['version'], f['installation'])))
elif cmd == 'run':
pass
else:
sys.exit(f'flatpak stub: unsupported {args}')
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env python3
"""Stub for gamescopectl: `screenshot FILE` writes a small PNG, as gamescope does
(asynchronously on the Frame, which server.SCREENSHOT waits out)."""
import struct
import sys
import zlib
sys.path.insert(0, '/usr/local/lib/fakeframe')
import fakeframe_state as fs # noqa: E402
args = sys.argv[1:]
fs.log('gamescopectl', args=args)
if len(args) != 2 or args[0] != 'screenshot':
sys.exit(f'gamescopectl stub: unsupported {args}')
def chunk(kind, data):
return struct.pack('>I', len(data)) + kind + data + struct.pack('>I', zlib.crc32(kind + data))
w, h = 64, 36
rows = b''.join(b'\0' + b''.join(bytes((x * 4, y * 7, 160)) for x in range(w)) for y in range(h))
png = (b'\x89PNG\r\n\x1a\n' + chunk(b'IHDR', struct.pack('>IIBBBBB', w, h, 8, 2, 0, 0, 0))
+ chunk(b'IDAT', zlib.compress(rows)) + chunk(b'IEND', b''))
with open(args[1], 'wb') as f:
f.write(png)
+11
View File
@@ -0,0 +1,11 @@
#!/usr/bin/env python3
"""Stub for nmcli: the Wi-Fi network frame_status.py reads with
`nmcli -t -f active,ssid,signal dev wifi` (':' inside fields escaped as '\\:')."""
import sys
sys.path.insert(0, '/usr/local/lib/fakeframe')
import fakeframe_state as fs # noqa: E402
fs.log('nmcli', args=sys.argv[1:])
print('yes:Fake\\:Frame Wi-Fi:72')
print('no:Neighbours:31')
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
"""Stub for podman, for the fake Lepton instances (lepton.py): ps, stop, exec.
`podman ps --format "{{.Names}} {{.Labels.adb_port}}"` lists what's running,
as frame_android.running_instances reads it (docs/apks.md)."""
import os
import sys
import time
sys.path.insert(0, '/usr/local/lib/fakeframe')
import fakeframe_state as fs # noqa: E402
def alive(c):
try:
os.kill(c['pid'], 0)
return True
except OSError:
return False
args = sys.argv[1:]
fs.log('podman', args=args)
cmd = args[0] if args else ''
containers = {n: c for n, c in fs.read()['lepton'].items() if alive(c)}
if cmd == 'ps':
for name, c in sorted(containers.items()):
print(f"{name} {c['port']}")
elif cmd == 'stop':
name = args[-1]
c = containers.get(name)
if not c:
sys.exit(f'Error: no container with name or ID "{name}" found: no such container')
os.kill(c['pid'], 15)
for _ in range(50):
if not alive(c):
break
time.sleep(0.1)
print(name)
elif cmd == 'exec':
name, rest = args[1], ' '.join(args[2:])
if name not in containers:
sys.exit(f'Error: no container with name or ID "{name}" found: no such container')
if 'pidof' in rest:
print(4242) # the app is "up"; there's no Android to ask
# logcat and anything else: nothing to report
else:
sys.exit(f'podman stub: unsupported {args}')
+19
View File
@@ -0,0 +1,19 @@
#!/usr/bin/env python3
"""Stub for qdbus6: records Klipper setClipboardContents calls, the way
Frame Control sends text to the headset desktop's clipboard (server.PASTE,
verified 2026-09-25)."""
import os
import sys
sys.path.insert(0, '/usr/local/lib/fakeframe')
import fakeframe_state as fs # noqa: E402
args = sys.argv[1:]
fs.log('qdbus6', args=args[:3], bus=os.environ.get('DBUS_SESSION_BUS_ADDRESS'))
if args[:3] == ['org.kde.klipper', '/klipper', 'org.kde.klipper.klipper.setClipboardContents'] and len(args) == 4:
if not os.environ.get('DBUS_SESSION_BUS_ADDRESS'):
sys.exit('Could not connect to D-Bus server')
with fs.update() as s:
s['clipboard'].append(args[3])
else:
sys.exit(f'qdbus6 stub: unsupported {args}')
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env python3
"""Stub for SteamOS's `steam` command: hands steam:// URLs to the running client.
On the Frame, `steam steam://rungameid/N` over SSH starts the game in the
running client (docs/apks.md, docs/steam-games.md, 2026-09-25). How the real
wrapper passes the URL on isn't documented; this writes it as a line on
~/.steam/steam.pipe, which fakesteam reads (guess).
"""
import os
import sys
sys.path.insert(0, '/usr/local/lib/fakeframe')
import fakeframe_state as fs # noqa: E402
args = sys.argv[1:]
running = fs.steam_pid() is not None
fs.log('steam', args=args, client_running=running)
if not running:
# The real command would start the Steam client; this one can't.
print('steam: the Steam client is not running', file=sys.stderr)
sys.exit(0)
fd = os.open(os.path.expanduser('~/.steam/steam.pipe'), os.O_RDWR)
try:
os.write(fd, (' '.join(args) + '\n').encode())
finally:
os.close(fd)
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env python3
"""Stub for PipeWire's wpctl: the default sink's volume and mute, kept in the state file.
Output format as wpctl prints it: "Volume: 0.40" plus " [MUTED]"."""
import sys
sys.path.insert(0, '/usr/local/lib/fakeframe')
import fakeframe_state as fs # noqa: E402
args = sys.argv[1:]
fs.log('wpctl', args=args)
if len(args) == 2 and args[0] == 'get-volume':
v = fs.read()['volume']
print(f"Volume: {v['level']:.2f}" + (' [MUTED]' if v['muted'] else ''))
elif len(args) == 3 and args[0] == 'set-volume':
with fs.update() as s:
s['volume']['level'] = max(0.0, min(1.5, float(args[2])))
elif len(args) == 3 and args[0] == 'set-mute':
with fs.update() as s:
s['volume']['muted'] = args[2] == 'toggle' and not s['volume']['muted'] or args[2] == '1'
else:
sys.exit(f'wpctl stub: unsupported {args}')
@@ -0,0 +1,157 @@
// The fake Steam client's JavaScript context ("SharedJSContext"), for fakesteam's
// DevTools server. fakesteam sends one JSON request per line on stdin:
// {"id": N, "expression": "...", "awaitPromise": true, "steam": {...state...}}
// and gets one line back: {"id": N, "result": <CDP Runtime.evaluate result>, "steam": {...}}.
// The expression runs for real in a V8 context holding the objects below, so
// whatever JavaScript Frame Control sends (async functions, optional chaining,
// Map) behaves as it would in Steam's CEF; only the objects are stand-ins.
//
// Shapes copy what was seen through the Frame's DevTools port on 2026-09-25
// (docs/steam-games.md and docs/apks.md, BUILD_ID 20260922.6101926):
// appStore.allApps, a Map in downloadsStore.m_DownloadOverview keyed by client
// id with "0" for this machine, SteamClient.Installs.GetInstallManagerInfo /
// ContinueInstall, SteamClient.User.GetIPCountry, and SteamClient.Apps.AddShortcut
// + SetShortcutName / SetShortcutStartDir for non-Steam shortcuts.
'use strict';
const vm = require('vm');
const readline = require('readline');
const SHORTCUT_TYPE = 1073741824; // app_type of a non-Steam shortcut, as steam_shortcuts.py filters
function newShortcutId(steam) {
// Real shortcut ids are 32-bit with the top bit set (T3 Code's was 3130509679).
steam.next_shortcut = (steam.next_shortcut || 0) + 1;
return (0x80000000 + ((steam.next_shortcut * 2654435761) >>> 1)) >>> 0;
}
function build(steam) {
const findShortcut = id => steam.shortcuts.find(s => s.appid === Number(id));
const gameOverview = a => ({
appid: a.appid, display_name: a.display_name, sort_as: a.display_name, app_type: 1,
steam_hw_compat_category_packed: a.packed || 0, vr_supported: !!a.vr, vr_only: !!a.vr_only,
size_on_disk: String(a.installed ? a.size : 0), minutes_playtime_forever: a.minutes || 0,
rt_last_time_played: a.last_played || 0,
local_per_client_data: {
installed: !!a.installed, display_status: a.display_status ?? (a.installed ? 1 : 0),
status_percentage: a.status_percentage ?? 0,
},
});
const shortcutOverview = s => ({
appid: s.appid, display_name: s.name, sort_as: s.name, app_type: SHORTCUT_TYPE,
local_per_client_data: { installed: true, display_status: 1, status_percentage: 0 },
});
const allApps = () => [...steam.apps.map(gameOverview), ...steam.shortcuts.map(shortcutOverview)];
const im = () => steam.install_manager;
const queue = appid => {
// State 14: Steam queued the download (Balatro on the Frame, docs/steam-games.md).
const app = steam.apps.find(a => a.appid === appid);
Object.assign(im(), { eInstallState: 14, currentAppID: appid });
if (app) {
steam.download = { update_appid: appid, update_state: 'Downloading', paused: false,
update_is_install: true, overall_percent_complete: 0,
overall_estimated_time_remaining_sec: 7, update_network_bytes_per_second: 9500000 };
}
};
return {
appStore: {
get allApps() { return allApps(); },
GetAppOverviewByAppID(id) { return allApps().find(a => a.appid === Number(id)) || null; },
},
downloadsStore: {
get m_DownloadOverview() { return steam.download ? new Map([['0', { ...steam.download }]]) : new Map(); },
},
SteamClient: {
Apps: {
async AddShortcut(name, exe, launchOptions, cmdLine) {
const appid = newShortcutId(steam);
const base = String(exe).split('/').pop();
steam.shortcuts.push({ appid, name: base, exe: String(exe), start_dir: '', icon: '',
launch_options: String(launchOptions || ''), devkit_gameid: null });
return appid;
},
SetShortcutName(id, name) { const s = findShortcut(id); if (s) s.name = String(name); },
SetShortcutStartDir(id, dir) { const s = findShortcut(id); if (s) s.start_dir = String(dir); },
SetShortcutIcon(id, icon) { const s = findShortcut(id); if (s) s.icon = String(icon); },
SetShortcutExe(id, exe) { const s = findShortcut(id); if (s) s.exe = String(exe); },
RemoveShortcut(id) {
steam.shortcuts = steam.shortcuts.filter(s => s.appid !== Number(id));
delete steam.compat_tools[String(id)];
},
},
Installs: {
async GetInstallManagerInfo() {
const i = im();
return { eInstallState: i.eInstallState, currentAppID: i.currentAppID,
nDiskSpaceRequired: i.nDiskSpaceRequired, nDiskSpaceAvailable: i.nDiskSpaceAvailable,
eAppError: i.eAppError ?? 0, errorDetail: i.errorDetail ?? '' };
},
// Broforce stopped at state 7 and ContinueInstall() queued it (docs/steam-games.md).
ContinueInstall() { if (im().eInstallState === 7) queue(im().currentAppID); },
CancelInstall() { Object.assign(im(), { eInstallState: 16 }); },
// Calling OpenInstallWizard directly did nothing on the Frame: the state stayed 0.
OpenInstallWizard() {},
},
User: {
async GetIPCountry() { return steam.country; },
},
},
};
}
// What CDP's Runtime.evaluate returns with returnByValue.
function remote(value) {
if (value === undefined) return { type: 'undefined' };
if (value === null) return { type: 'object', subtype: 'null', value: null };
const type = typeof value;
if (type === 'object') return { type: 'object', value: JSON.parse(JSON.stringify(value)) };
if (type === 'function') return { type: 'function', description: String(value) };
return { type, value, description: String(value) };
}
function exception(err, inPromise) {
// Chrome puts the stack in description; its first line is enough here.
const description = err && err.name ? `${err.name}: ${err.message}` : String(err);
return {
result: { type: 'object', subtype: 'error', className: (err && err.name) || 'Error', description },
exceptionDetails: {
exceptionId: 1, text: inPromise ? 'Uncaught (in promise)' : 'Uncaught', lineNumber: 0, columnNumber: 0,
exception: { type: 'object', subtype: 'error', className: (err && err.name) || 'Error', description },
},
};
}
async function evaluate(req) {
const steam = req.steam;
const ctx = vm.createContext(build(steam));
let value;
try {
value = vm.runInContext(req.expression, ctx, { timeout: 5000 });
} catch (err) {
return exception(err, false);
}
if (req.awaitPromise && value && typeof value.then === 'function') {
try {
value = await value;
} catch (err) {
return exception(err, true);
}
}
try {
return { result: remote(value) };
} catch (err) {
return exception(err, false);
}
}
// One request at a time: fakesteam holds the state lock around each.
const rl = readline.createInterface({ input: process.stdin });
let chain = Promise.resolve();
rl.on('line', line => {
chain = chain.then(async () => {
const req = JSON.parse(line);
const result = await evaluate(req);
process.stdout.write(JSON.stringify({ id: req.id, result, steam: req.steam }) + '\n');
});
});
@@ -0,0 +1,183 @@
"""Shared state of the fake Frame: one JSON file plus a log of stub calls.
Every fake part (the supervisor, fakesteam, the command stubs) reads and
writes /var/lib/fakeframe/state.json through update(), which holds an
exclusive flock, so separate processes never lose each other's changes.
Tests read the file over SSH (`fakeframe-ctl state`) or the control port.
"""
import contextlib
import fcntl
import json
import os
import time
DIR = '/var/lib/fakeframe'
STATE = os.path.join(DIR, 'state.json')
CALLS = os.path.join(DIR, 'calls.jsonl')
LOCK = os.path.join(DIR, 'state.lock')
HOME = '/home/steamos'
STEAM_ROOT = HOME + '/.local/share/Steam'
DEVKIT_GAMES = HOME + '/devkit-game'
LEPTON = STEAM_ROOT + '/steamapps/common/Lepton/lepton'
# Compat tools and the Steam app ids of their depots. 4183110 is the id Steam
# printed on the Frame for "Steam Linux Runtime 4.0" (docs/sideloading.md,
# BUILD_ID 20260922.6101926); Lepton Development is 3056000 (docs/apks.md).
# The others are placeholders: nothing in Frame Control reads them.
RUNTIME_APPIDS = {'proton-experimental': 1493710, 'proton-stable': 3658110,
'SteamLinuxRuntime_4-arm64': 4183120, 'SteamLinuxRuntime_4': 4183110,
'lepton': 3056000}
RUNTIME_NAMES = {'proton-experimental': 'Proton Experimental', 'proton-stable': 'Proton 11.0',
'SteamLinuxRuntime_4-arm64': 'Steam Linux Runtime 4.0 (arm64)',
'SteamLinuxRuntime_4': 'Steam Linux Runtime 4.0', 'lepton': 'Lepton Development'}
def default_state():
return {
'switches': {
'pairing_mode': False, # Steam Settings > Developer > Pair new host open or not
'pairing_answer': 'approve', # approve | deny | timeout
'steam': True, # Steam client running
'asleep': False, # headset asleep: ports 22 and 32000 accept but never answer
'sshd': True,
'devkit_service': True,
'disk_full': False,
},
# Which compat tools are installed. On the Frame the x86-64 Steam Linux
# Runtime 4.0 wasn't, and a devkit title didn't install it (docs/sideloading.md,
# 2026-09-26, BUILD_ID 20260922.6101926).
'runtimes': {'proton-experimental': True, 'proton-stable': True,
'SteamLinuxRuntime_4-arm64': True, 'SteamLinuxRuntime_4': False, 'lepton': True},
# /sys/class/power_supply values, in the units the kernel uses (µV, µA, tenths
# of °C), as frame_status.py reads them (paths verified on build 20260922; its
# docstring gives the charger type 'C PD [PD_PPS]' at 20 W as seen on the Frame).
'battery': {'capacity': 76, 'status': 'Charging', 'voltage_now': 7700000, 'current_now': 1250000,
'time_to_full_now': 2520, 'temp': 312, 'health': 'Good',
'charger': {'online': 1, 'usb_type': 'C PD [PD_PPS]', 'voltage_now': 9000000,
'current_now': 2220000}},
'thermal_mc': [41500, 38250], # thermal_zone*/temp, millidegrees C
'volume': {'level': 0.4, 'muted': False},
'flatpaks': [],
'clipboard': [],
'steam': {
'country': 'AU', # GetIPCountry() answered "AU" (docs/steam-games.md)
'next_shortcut': 0,
# A few owned games. Balatro went straight to install state 14 and
# Broforce stopped at 7 on the Frame (docs/steam-games.md, 2026-09-25,
# BUILD_ID 20260922.6101926); `wizard` makes the fake do the same.
'apps': [
{'appid': 2379780, 'display_name': 'Balatro', 'installed': False, 'size': 67000000,
'packed': 3 << 8 | 3, 'vr': False, 'wizard': 14},
{'appid': 274190, 'display_name': 'Broforce', 'installed': False, 'size': 600000000,
'packed': 0 << 8 | 2, 'vr': False, 'wizard': 7},
{'appid': 620980, 'display_name': 'Beat Saber', 'installed': True, 'size': 4200000000,
'packed': 3 << 8 | 1, 'vr': True, 'vr_only': True, 'wizard': 14},
],
'shortcuts': [], # {appid, name, exe, start_dir, icon, devkit_gameid}
'compat_tools': {}, # shortcut appid (str) -> compat tool alias (CompatToolMapping)
'install_manager': {'eInstallState': 0, 'currentAppID': 0, 'nDiskSpaceRequired': 0,
'nDiskSpaceAvailable': 0},
'download': None,
'pages': [], # extra DevTools targets, e.g. a store page
},
'devkit_games': {}, # gameid -> what create-shortcut registered
'launches': [], # every launch Steam was asked for, and what it did
'pairing_requests': [],
'lepton': {}, # container name -> {port, pid, package dir}
}
def _share(fd):
# Files are made by root or steamos, whichever comes first; both write them (umask aside).
try:
os.fchmod(fd, 0o666)
except OSError:
pass # not ours: whoever made it already did this
def _ensure_dir():
os.makedirs(DIR, exist_ok=True)
@contextlib.contextmanager
def _locked(kind):
_ensure_dir()
fd = os.open(LOCK, os.O_RDWR | os.O_CREAT, 0o666)
_share(fd)
try:
fcntl.flock(fd, kind)
yield
finally:
os.close(fd)
def _load():
try:
with open(STATE) as f:
return json.load(f)
except (OSError, ValueError):
return default_state()
def _save(state):
tmp = f'{STATE}.{os.getpid()}.tmp'
with open(tmp, 'w') as f:
json.dump(state, f, indent=1, sort_keys=True)
os.chmod(tmp, 0o666) # the supervisor (root) and steamos both write it
os.replace(tmp, STATE)
def read():
with _locked(fcntl.LOCK_SH):
return _load()
@contextlib.contextmanager
def update():
"""with update() as s: change s; it's written back when the block ends without an error."""
with _locked(fcntl.LOCK_EX):
state = _load()
yield state
_save(state)
def reset():
with _locked(fcntl.LOCK_EX):
_save(default_state())
with open(CALLS, 'w'):
pass
os.chmod(CALLS, 0o666)
def log(tool, **fields):
"""Append one call record to calls.jsonl."""
_ensure_dir()
line = json.dumps({'time': round(time.time(), 3), 'tool': tool, **fields}) + '\n'
fd = os.open(CALLS, os.O_WRONLY | os.O_APPEND | os.O_CREAT, 0o666)
_share(fd)
try:
fcntl.flock(fd, fcntl.LOCK_EX)
os.write(fd, line.encode())
finally:
os.close(fd)
def calls(tool=None):
try:
with open(CALLS) as f:
out = [json.loads(line) for line in f if line.strip()]
except OSError:
return []
return [c for c in out if tool is None or c['tool'] == tool]
def steam_pid():
"""The fake Steam client's pid if it's running, as devkit_utils.validate_steam_client checks."""
try:
with open(HOME + '/.steam/steam.pid') as f:
pid = int(f.read())
os.kill(pid, 0)
return pid
except (OSError, ValueError):
return None
+490
View File
@@ -0,0 +1,490 @@
#!/usr/bin/env python3
"""A stand-in for the Frame's Steam client, run as steamos by the supervisor.
What it copies, and from where (BUILD_ID 20260922.6101926 unless noted):
- The devkit IPC Valve's devkit-utils and the devkit service's hooks use:
~/.steam/steam.pid (validate_steam_client), ~/.steam/steam.token, and
"devkit-1 steam://devkit-1/<token>/<command>?<query>" lines on the
~/.steam/steam.pipe FIFO, answered by writing <response> or
<response>.error (with <response>.lock while writing), as
devkit_utils.wait_on_file_response describes. Commands: approve-ssh-key,
create-shortcut, run-game, list-shortcuts and delete-shortcut (all that
devkit-utils and the hooks send).
- steam:// URLs that the `steam` wrapper forwards (rungameid, install, store).
- The DevTools endpoint on 127.0.0.1:8080 with a SharedJSContext target
(docs/steam-games.md, docs/apks.md). Expressions run in node against
cef_shim.js.
State lives in fakeframe_state (the "steam", "devkit_games", "launches" and
"pairing_requests" keys). Anything not seen on a headset is marked "guess".
"""
import base64
import hashlib
import json
import os
import re
import secrets
import signal
import struct
import subprocess
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import parse_qs
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import fakeframe_state as fs # noqa: E402
HOME = fs.HOME
STEAM_DIR = HOME + '/.steam'
PID_FILE, TOKEN_FILE, PIPE = (f'{STEAM_DIR}/steam.{n}' for n in ('pid', 'token', 'pipe'))
CONSOLE_LOG = fs.STEAM_ROOT + '/logs/console_log.txt' # guess: which file Steam logs devkit launches to
DEVTOOLS_PORT = 8080
TARGET_ID = 'F0CA11ED5EA4ED0000000000000000AB'
GAME_LOGS = '/var/log/fakeframe'
# The 403 text /register returned while Steam wasn't on "Pair new host"
# (docs/ssh.md, verified 2026-09-26). approve-ssh-key passes the Steam
# client's error file through as {"error": ...}, so this is what Steam writes.
PAIRING_MODE_ERROR = 'please put the Steam client in pairing mode: Settings -> Developer -> Pair new host'
# Guess: what Steam writes when the prompt is declined hasn't been seen.
PAIRING_DENIED = 'the pairing request was denied on the device'
# Steam refused create-shortcut for ids like "fc-smoke-exe" with this text, and
# took the same program as "FCSmokeProbe" (headset smoke test, 2026-09-27,
# BUILD_ID 20260922.6101926). Valve's client only allows ids matching
# GAMEID_ALLOWED_PATTERN (devkit_client/gui2/gui2.py), so the fake checks that.
INVALID_ARGUMENTS = 'missing/invalid arguments\n'
GAMEID_ALLOWED = re.compile(r'[A-Za-z_][A-Za-z0-9_.]+')
_lock = threading.RLock() # one state change at a time within this process (the file lock covers others)
TOKEN = secrets.token_hex(16)
def console(line):
os.makedirs(os.path.dirname(CONSOLE_LOG), exist_ok=True)
with open(CONSOLE_LOG, 'a') as f:
f.write(time.strftime('[%Y-%m-%d %H:%M:%S] ') + line + '\n')
def respond(path, text=None, error=None):
"""Answer a devkit request the way devkit_utils.wait_on_file_response expects."""
lock = path + '.lock'
open(lock, 'w').close()
with open(path + '.error' if error is not None else path, 'w') as f:
f.write(error if error is not None else text)
os.unlink(lock)
# ---- the Node side of the DevTools endpoint ----------------------------------
class JS:
def __init__(self):
self.proc = None
self.next = 0
def _start(self):
shim = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'cef_shim.js')
self.proc = subprocess.Popen(['node', shim], stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
def evaluate(self, expression, await_promise):
with _lock:
if not self.proc or self.proc.poll() is not None:
self._start()
self.next += 1
with fs.update() as state:
self.proc.stdin.write(json.dumps({'id': self.next, 'expression': expression,
'awaitPromise': await_promise, 'steam': state['steam']}) + '\n')
self.proc.stdin.flush()
reply = json.loads(self.proc.stdout.readline())
state['steam'] = reply['steam']
return reply['result']
JS_WORKER = JS()
# ---- devkit commands ----------------------------------------------------------
def shortcut_for(state, gameid):
return next((s for s in state['steam']['shortcuts'] if s.get('devkit_gameid') == gameid), None)
def new_shortcut_id(steam):
steam['next_shortcut'] = steam.get('next_shortcut', 0) + 1
return (0x80000000 + ((steam['next_shortcut'] * 2654435761 & 0xFFFFFFFF) >> 1)) & 0xFFFFFFFF
def read_json(path, default=None):
try:
with open(path) as f:
return json.load(f)
except (OSError, ValueError):
return default
def cmd_approve_ssh_key(q):
with fs.update() as state:
sw = state['switches']
answer = sw['pairing_answer'] if sw['pairing_mode'] else 'not in pairing mode'
state['pairing_requests'].append({'time': time.time(), 'request': q.get('request', ''), 'answer': answer})
if answer == 'not in pairing mode':
respond(q['response'], error=PAIRING_MODE_ERROR)
elif answer == 'approve':
respond(q['response'], text='approved') # guess: the hook only checks that the file appears
elif answer == 'deny':
respond(q['response'], error=PAIRING_DENIED)
# 'timeout': never answer; the hook gives up after 30 s.
def cmd_create_shortcut(q):
gameid = q.get('gameid', '')
folder = q.get('directory') or fs.DEVKIT_GAMES
path = os.path.join(folder, gameid)
if not GAMEID_ALLOWED.fullmatch(gameid):
respond(q['response'], error=INVALID_ARGUMENTS)
return
if not os.path.isdir(path):
respond(q['response'], error=f'no devkit game folder {path}') # guess: wording
return
argv = read_json(f'{folder}/{gameid}-argv.json', [])
settings = read_json(f'{folder}/{gameid}-settings.json', {})
env = read_json(f'{folder}/{gameid}-env.json', {})
with fs.update() as state:
steam = state['steam']
sc = shortcut_for(state, gameid)
if not sc:
sc = {'appid': new_shortcut_id(steam), 'name': gameid, 'exe': '', 'start_dir': path, 'icon': '',
'launch_options': '', 'devkit_gameid': gameid}
steam['shortcuts'].append(sc)
sc['exe'] = argv[0] if argv else ''
tool = settings.get('compat_tool')
# Steam maps the title to the chosen compat tool (CompatToolMapping and
# compat_log.txt on the Frame, docs/sideloading.md, 2026-09-26).
if tool:
steam['compat_tools'][str(sc['appid'])] = tool
else:
steam['compat_tools'].pop(str(sc['appid']), None)
state['devkit_games'][gameid] = {'appid': sc['appid'], 'directory': path, 'argv': argv,
'settings': settings, 'env': env, 'registered': time.time()}
console(f'devkit create-shortcut: registered devkit game "{gameid}"')
respond(q['response'], text='') # Steam's answer was empty on the Frame (2026-09-27)
def cmd_list_shortcuts(q):
# The reply format devkit_utils.resolve.resolve_shortcuts asserts.
with fs.update() as state:
ids = sorted(state['devkit_games'])
respond(q['response'], text=json.dumps({'version': 2, 'gameids': ids}))
def cmd_delete_shortcut(q):
gameid = q.get('gameid', '')
with fs.update() as state:
sc = shortcut_for(state, gameid)
state['devkit_games'].pop(gameid, None)
if sc:
state['steam']['shortcuts'].remove(sc)
state['steam']['compat_tools'].pop(str(sc['appid']), None)
# Remove also deleted the title's Proton prefix on the Frame (docs/sideloading.md).
subprocess.run(['rm', '-rf', f"{fs.STEAM_ROOT}/steamapps/compatdata/{sc['appid']}"])
console(f'devkit delete-shortcut: removed devkit game "{gameid}"')
respond(q['response'], text=f'deleted {gameid}\n')
def cmd_run_game(q):
gameid = q.get('gameid', '')
with fs.update() as state:
game = state['devkit_games'].get(gameid)
tool = game and state['steam']['compat_tools'].get(str(game['appid']))
runtimes = state['runtimes']
if not game:
respond(q['response'], error=f'unknown devkit game "{gameid}"') # guess: wording
return
target = game['argv'][0] if game['argv'] else ''
full = os.path.join(game['directory'], target.strip('"'))
record = {'kind': 'devkit', 'gameid': gameid, 'appid': game['appid'], 'tool': tool, 'time': time.time()}
if tool and not runtimes.get(tool, False):
# Seen for the x86-64 runtime (docs/sideloading.md, 2026-09-26): Steam
# logs this and the game doesn't start.
name = fs.RUNTIME_NAMES.get(tool, tool)
record.update(started=False, message=f'Tool {fs.RUNTIME_APPIDS.get(tool, 0)} "{name}" is found for '
f'appID {game["appid"]}, but is not installed')
elif tool and tool.startswith('proton'):
# How Steam ran a sideloaded .exe on the Frame (docs/sideloading.md).
prefix = f"{fs.STEAM_ROOT}/steamapps/compatdata/{game['appid']}/pfx"
os.makedirs(prefix, exist_ok=True)
record.update(started=True, command=f'proton waitforexitandrun "{full}"', prefix=prefix)
else:
# An aarch64 title ran natively, without SteamLinuxRuntime_4-arm64's
# _v2-entry-point prefix, even though Steam recorded the mapping
# (docs/sideloading.md, 2026-09-26). So does the fake, for real.
record.update(started=True, command=full)
record.update(execute(full, game['directory'], {**game.get('env', {})}, f"devkit-{gameid}"))
with fs.update() as state:
state['launches'].append(record)
console(record['message'] if not record['started'] else f'devkit run-game: started devkit game "{gameid}"')
respond(q['response'], text='OK\n') # guess: steam-devkit-rpc only checks that it arrives
DEVKIT = {'approve-ssh-key': cmd_approve_ssh_key, 'create-shortcut': cmd_create_shortcut,
'list-shortcuts': cmd_list_shortcuts, 'delete-shortcut': cmd_delete_shortcut,
'run-game': cmd_run_game}
def execute(path, cwd, env, label):
"""Start a program the way Steam would, and note its pid and exit status."""
os.makedirs(GAME_LOGS, exist_ok=True)
log = open(f'{GAME_LOGS}/{label}.log', 'ab')
try:
proc = subprocess.Popen([path], cwd=cwd if os.path.isdir(cwd) else HOME, env={**os.environ, **env},
stdin=subprocess.DEVNULL, stdout=log, stderr=log, start_new_session=True)
except OSError as e:
return {'pid': None, 'exec_error': str(e)}
finally:
log.close()
def reap():
code = proc.wait()
with fs.update() as state:
for r in state['launches']:
if r.get('pid') == proc.pid and 'exit' not in r:
r['exit'] = code
threading.Thread(target=reap, daemon=True).start()
return {'pid': proc.pid}
# ---- steam:// URLs from the `steam` wrapper ----------------------------------
def url_rungameid(gid):
gid = int(gid)
record = {'kind': 'rungameid', 'gameid': gid, 'time': time.time()}
if gid >= 1 << 32:
# A non-Steam shortcut: (appid << 32) | 0x02000000 (docs/apks.md).
appid = gid >> 32
with fs.update() as state:
sc = next((s for s in state['steam']['shortcuts'] if s['appid'] == appid), None)
if not sc:
record.update(started=False, message=f'no shortcut {appid}')
else:
# Steam sets STEAM_FOSSILIZE_DUMP_PATH for shortcut launches but not
# STEAM_COMPAT_SHADER_PATH (docs/apks.md, 2026-09-25).
env = {'SteamAppId': str(appid), 'SteamGameId': str(gid),
'STEAM_FOSSILIZE_DUMP_PATH': f'{fs.STEAM_ROOT}/steamapps/shadercache/{appid}/fozpipelinesv6'}
record.update(appid=appid, started=True, command=sc['exe'])
record.update(execute(sc['exe'], sc.get('start_dir') or HOME, env, f'shortcut-{appid}'))
else:
with fs.update() as state:
app = next((a for a in state['steam']['apps'] if a['appid'] == gid), None)
record.update(appid=gid, started=bool(app and app['installed']),
message=None if app and app['installed'] else 'not installed')
with fs.update() as state:
state['launches'].append(record)
def url_install(appid):
appid = int(appid)
free = os.statvfs(HOME)
with fs.update() as state:
steam = state['steam']
app = next((a for a in steam['apps'] if a['appid'] == appid), None)
im = steam['install_manager']
if not app:
return # not owned: Steam shows a store or license dialog, state stays 0
im.update(currentAppID=appid, nDiskSpaceRequired=app['size'],
nDiskSpaceAvailable=free.f_bavail * free.f_frsize, eInstallState=app.get('wizard', 14))
if im['eInstallState'] == 14:
steam['download'] = {'update_appid': appid, 'update_state': 'Downloading', 'paused': False,
'update_is_install': True, 'overall_percent_complete': 0,
'overall_estimated_time_remaining_sec': 7,
'update_network_bytes_per_second': 9500000}
def url_store(appid):
# A store page showed up in the DevTools page list (docs/steam-games.md).
names = {1145360: 'Hades'}
with fs.update() as state:
state['steam']['pages'].append({'title': f"{names.get(int(appid), 'App ' + appid)} on Steam",
'url': f'https://store.steampowered.com/app/{appid}/'})
URLS = [(re.compile(r'steam://rungameid/(\d+)$'), url_rungameid),
(re.compile(r'steam://install/(\d+)$'), url_install),
(re.compile(r'steam://store/(\d+)$'), url_store)]
def handle_line(line):
line = line.strip()
if not line:
return
fs.log('steam.pipe', line=line)
try:
if line.startswith('devkit-1 '):
m = re.fullmatch(r'steam://devkit-1/([^/]*)/([^?]*)\??(.*)', line.split(' ', 1)[1])
if not m or m[1] != TOKEN:
console('devkit-1: rejected a command with a bad token')
return
cmd = m[2].rstrip('/') # steam-devkit-rpc sends "run-game/?..."
q = {k: v[0] for k, v in parse_qs(m[3], keep_blank_values=True).items()}
handler = DEVKIT.get(cmd)
if not handler:
console(f'devkit-1: unknown command {cmd}')
if 'response' in q:
respond(q['response'], error=f'unknown command {cmd}')
return
handler(q)
return
for pat, fn in URLS:
m = pat.match(line.split()[-1])
if m:
fn(*m.groups())
return
console(f'ignored: {line}')
except Exception as e: # keep reading the pipe whatever one command did
console(f'error handling {line!r}: {type(e).__name__}: {e}')
def read_pipe():
# O_RDWR: there is always a writer, so reads never hit EOF between clients.
fd = os.open(PIPE, os.O_RDWR)
with os.fdopen(fd, 'rb', buffering=0) as f:
buf = b''
while True:
chunk = f.read(4096)
buf += chunk
while b'\n' in buf:
line, buf = buf.split(b'\n', 1)
threading.Thread(target=handle_line, args=(line.decode('utf-8', 'replace'),), daemon=True).start()
# ---- DevTools HTTP + WebSocket -------------------------------------------------
def targets():
base = {'type': 'page', 'description': '', 'faviconUrl': ''}
out = [{**base, 'id': TARGET_ID, 'title': 'SharedJSContext',
'url': 'https://steamloopback.host/index.html',
'devtoolsFrontendUrl': f'/devtools/inspector.html?ws=127.0.0.1:{DEVTOOLS_PORT}/devtools/page/{TARGET_ID}',
'webSocketDebuggerUrl': f'ws://127.0.0.1:{DEVTOOLS_PORT}/devtools/page/{TARGET_ID}'}]
for i, p in enumerate(fs.read()['steam'].get('pages', [])):
tid = f'{i + 1:032X}'
out.append({**base, 'id': tid, 'title': p['title'], 'url': p['url'],
'webSocketDebuggerUrl': f'ws://127.0.0.1:{DEVTOOLS_PORT}/devtools/page/{tid}'})
return out
class DevTools(BaseHTTPRequestHandler):
protocol_version = 'HTTP/1.1'
def log_message(self, fmt, *args):
pass
def do_GET(self):
path = self.path.split('?')[0].rstrip('/')
if self.headers.get('Upgrade', '').lower() == 'websocket':
if path != f'/devtools/page/{TARGET_ID}':
self.send_error(404)
return
self.websocket()
return
if path in ('/json', '/json/list'):
body = json.dumps(targets(), indent=2).encode()
elif path == '/json/version':
body = json.dumps({'Browser': 'Chrome/126.0.6478.183', 'Protocol-Version': '1.3',
'User-Agent': 'Valve Steam Client (fakeframe)'}).encode()
else:
self.send_error(404)
return
self.send_response(200)
self.send_header('Content-Type', 'application/json; charset=UTF-8')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def websocket(self):
key = self.headers.get('Sec-WebSocket-Key', '')
accept = base64.b64encode(hashlib.sha1((key + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').encode()).digest())
self.wfile.write(b'HTTP/1.1 101 WebSocket Protocol Handshake\r\nUpgrade: WebSocket\r\n'
b'Connection: Upgrade\r\nSec-WebSocket-Accept: ' + accept + b'\r\n\r\n')
self.wfile.flush()
self.close_connection = True
try:
while True:
op, data = self.read_frame()
if op == 8:
return
if op == 9:
self.send_frame(data, 10)
continue
if op != 1:
continue
msg = json.loads(data)
reply = {'id': msg.get('id')}
if msg.get('method') == 'Runtime.evaluate':
params = msg.get('params') or {}
reply['result'] = JS_WORKER.evaluate(str(params.get('expression', '')),
bool(params.get('awaitPromise')))
else:
reply['error'] = {'code': -32601, 'message': f"'{msg.get('method')}' wasn't found"}
self.send_frame(json.dumps(reply).encode(), 1)
except (EOFError, OSError, ValueError):
return
def read_exact(self, n):
data = self.rfile.read(n)
if len(data) < n:
raise EOFError
return data
def read_frame(self):
b0, b1 = self.read_exact(2)
n = b1 & 0x7F
if n == 126:
n = struct.unpack('>H', self.read_exact(2))[0]
elif n == 127:
n = struct.unpack('>Q', self.read_exact(8))[0]
mask = self.read_exact(4) if b1 & 0x80 else None
data = self.read_exact(n)
if mask:
data = bytes(b ^ mask[i % 4] for i, b in enumerate(data))
return b0 & 0x0F, data
def send_frame(self, data, op):
n = len(data)
head = bytes([0x80 | op]) + (bytes([n]) if n < 126 else
bytes([126]) + struct.pack('>H', n) if n < 1 << 16 else
bytes([127]) + struct.pack('>Q', n))
self.wfile.write(head + data)
self.wfile.flush()
def main():
os.makedirs(STEAM_DIR, exist_ok=True)
if not os.path.exists(PIPE):
os.mkfifo(PIPE, 0o600)
with open(TOKEN_FILE, 'w') as f:
f.write(TOKEN)
with open(PID_FILE, 'w') as f:
f.write(str(os.getpid()))
def stop(*_):
# Guess: whether Steam removes steam.pid on exit. Either way
# validate_steam_client then says Steam isn't running.
try:
os.unlink(PID_FILE)
except OSError:
pass
if JS_WORKER.proc:
JS_WORKER.proc.kill()
os._exit(0)
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
httpd = ThreadingHTTPServer(('127.0.0.1', DEVTOOLS_PORT), DevTools)
httpd.daemon_threads = True
threading.Thread(target=httpd.serve_forever, daemon=True).start()
console('fakesteam: started (-cef-enable-debugging on 127.0.0.1:8080)')
read_pipe()
if __name__ == '__main__':
main()
+468
View File
@@ -0,0 +1,468 @@
#!/usr/bin/env python3
"""The fake Frame's supervisor, run as root under docker's init.
It starts and stops sshd, Valve's steamos-devkit-service (as steamos),
fakesteam (as steamos) and a few named placeholder processes the status page
looks for, following the switches in the state file. It also serves the
control port (9999) that fakeframe-ctl and the e2e tests use, so a test can
flip a fault switch even while SSH is down.
Control: GET /state, GET /calls, GET /ping, POST /ctl {"args": ["pairing", "on"]}.
See `fakeframe-ctl help` for the commands.
"""
import glob
import json
import os
import pwd
import shutil
import socket
import subprocess
import sys
import threading
import time
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
import fakeframe_state as fs # noqa: E402
LIB = os.path.dirname(os.path.abspath(__file__))
USER = 'steamos'
PW = pwd.getpwnam(USER)
HOME = fs.HOME
KEYS = '/keys' # shared with the host container
HARNESS_KEY = KEYS + '/id_ed25519_frame'
AUTH_KEYS = HOME + '/.ssh/authorized_keys'
BALLAST = fs.DEVKIT_GAMES + '/.fakeframe-ballast'
# Written here; compose mounts the same volumes over /sys/class/power_supply and /sys/class/thermal.
POWER = '/var/lib/fakeframe/sys/power_supply'
THERMAL = '/var/lib/fakeframe/sys/thermal'
CONTROL_PORT = 9999
LOGS = '/var/log/fakeframe'
USAGE = """fakeframe-ctl COMMAND
pairing on|off Steam's "Pair new host" screen open or not
answer approve|deny|timeout how the pairing prompt is answered
steam on|off Steam client running (steam.pid, pipe, DevTools on 8080)
sleep on|off headset asleep: 22 and 32000 accept but never answer
sshd on|off sshd running (off: connection refused)
devkit-service on|off steamos-devkit-service on 32000
disk-full on|off fill the small ~/devkit-game filesystem
runtime NAME installed|missing NAME: proton-experimental, proton-stable,
SteamLinuxRuntime_4-arm64, SteamLinuxRuntime_4, lepton
battery KEY=VALUE... e.g. capacity=15 status=Discharging current_now=-900000
keys harness|none authorized_keys: only the harness key, or empty
authorized-keys what ~/.ssh/authorized_keys holds now
reset default state, nothing installed, harness key only
state | calls [TOOL] | ping"""
_lock = threading.RLock()
_procs = {}
_blackhole = {'socks': [], 'conns': []}
def log(msg):
print(time.strftime('%H:%M:%S ') + msg, flush=True)
def as_user():
env = {'HOME': HOME, 'USER': USER, 'LOGNAME': USER, 'SHELL': '/bin/bash',
'PATH': '/usr/local/bin:/usr/bin:/bin', 'XDG_RUNTIME_DIR': f'/run/user/{PW.pw_uid}',
'LANG': 'C.UTF-8'}
return {'user': PW.pw_uid, 'group': PW.pw_gid, 'extra_groups': [], 'env': env, 'cwd': HOME}
def chown(path):
os.chown(path, PW.pw_uid, PW.pw_gid)
# ---- processes ------------------------------------------------------------------
def spawn(name, argv, user=True, env=None):
os.makedirs(LOGS, exist_ok=True)
out = open(f'{LOGS}/{name}.log', 'ab')
kw = as_user() if user else {'env': dict(os.environ)}
kw['env'].update(env or {})
_procs[name] = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=out, stderr=out,
start_new_session=True, **kw)
out.close()
log(f'started {name} (pid {_procs[name].pid})')
def stop(name, sig=15):
p = _procs.pop(name, None)
if p and p.poll() is None:
p.send_signal(sig)
try:
p.wait(5)
except subprocess.TimeoutExpired:
p.kill()
p.wait()
log(f'stopped {name}')
def running(name):
p = _procs.get(name)
return bool(p and p.poll() is None)
def kill_ssh_sessions():
"""Drop every SSH connection, as losing Wi-Fi does."""
for comm_file in glob.glob('/proc/[0-9]*/comm'):
try:
with open(comm_file) as f:
comm = f.read().strip()
if comm.startswith('sshd'):
os.kill(int(comm_file.split('/')[2]), 9)
except (OSError, ValueError):
pass
class Blackhole:
"""Accept on a port and never answer, so ssh waits and times out. An unreachable
Frame times out rather than refusing (seen over Tailscale on 2026-09-27);
a closed port would fail at once, which hides timeout bugs."""
@staticmethod
def start(port):
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(('0.0.0.0', port))
s.listen(64)
_blackhole['socks'].append(s)
def accept():
while True:
try:
c, _ = s.accept()
except OSError:
return
_blackhole['conns'].append(c)
threading.Thread(target=accept, daemon=True).start()
@staticmethod
def stop():
for s in _blackhole['socks'] + _blackhole['conns']:
try:
s.shutdown(socket.SHUT_RDWR)
except OSError:
pass
s.close()
_blackhole['socks'].clear()
_blackhole['conns'].clear()
def reconcile():
"""Make the running processes match the switches."""
with _lock:
sw = fs.read()['switches']
asleep = sw['asleep']
if asleep and not _blackhole['socks']:
stop('sshd')
stop('devkit-service')
kill_ssh_sessions()
Blackhole.start(22)
Blackhole.start(32000)
if not asleep and _blackhole['socks']:
Blackhole.stop()
want = {'sshd': sw['sshd'] and not asleep, 'devkit-service': sw['devkit_service'] and not asleep,
'steam': sw['steam'], 'vrserver': True, 'plasmashell': True}
for name, on in want.items():
if on and not running(name):
start(name)
elif not on and running(name):
stop(name)
def start(name):
if name == 'sshd':
spawn('sshd', ['/usr/bin/sshd', '-D', '-e'], user=False)
elif name == 'devkit-service':
# Valve's service, unmodified, with a stand-in dbus module (no systemd-resolved here).
spawn('devkit-service', ['python3', '/usr/lib/steamos-devkit/steamos-devkit-service.py'],
env={'PYTHONPATH': f'{LIB}/pystubs'})
elif name == 'steam':
spawn('steam', ['python3', f'{LIB}/fakesteam.py'])
else:
# frame_status.py looks for these by process name (vrserver: SteamVR,
# plasmashell: the headset desktop, which /api/clipboard also needs).
spawn(name, [f'{LIB}/bin/{name}', 'infinity'],
env={'DBUS_SESSION_BUS_ADDRESS': f'unix:path=/run/user/{PW.pw_uid}/bus'})
# ---- the device's files -----------------------------------------------------------
def write(path, text, owner=True):
os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, 'w') as f:
f.write(text)
if owner:
chown(path)
def sysfs(state):
"""Battery, charger and thermal zones, in the files frame_status.py reads.
/sys is read-only in a container, so compose mounts a volume over each of
the two folders and again here, where it can be written
(tests/fakeframe/compose.yaml); without those this does nothing.
"""
b = state['battery']
if not os.path.isdir(POWER) or not os.path.isdir(THERMAL):
log('no fake /sys: see the volumes in tests/fakeframe/compose.yaml')
return
try:
for d in glob.glob(POWER + '/*') + glob.glob(THERMAL + '/thermal_zone*'):
shutil.rmtree(d, ignore_errors=True)
bat = POWER + '/max1720x_battery' # the fuel gauge frame_status.py was written against
for k in ('capacity', 'status', 'voltage_now', 'current_now', 'time_to_full_now', 'temp', 'health'):
write(f'{bat}/{k}', f'{b[k]}\n', owner=False)
write(f'{bat}/type', 'Battery\n', owner=False)
c = b.get('charger') or {}
usb = POWER + '/usb'
write(f'{usb}/type', 'USB\n', owner=False)
write(f'{usb}/online', f"{c.get('online', 0)}\n", owner=False)
for k in ('usb_type', 'voltage_now', 'current_now'):
if k in c:
write(f'{usb}/{k}', f'{c[k]}\n', owner=False)
for i, t in enumerate(state['thermal_mc']):
write(f'{THERMAL}/thermal_zone{i}/temp', f'{t}\n', owner=False)
except OSError as e:
log(f'no fake /sys ({e}); see the volumes in tests/fakeframe/compose.yaml')
def runtimes(state):
"""Installed compat tools as Steam app manifests, and the Lepton launcher itself."""
apps = fs.STEAM_ROOT + '/steamapps'
for alias, installed in state['runtimes'].items():
acf = f'{apps}/appmanifest_{fs.RUNTIME_APPIDS[alias]}.acf'
if installed:
write(acf, '"AppState"\n{\n\t"appid"\t\t"%d"\n\t"name"\t\t"%s"\n\t"SizeOnDisk"\t\t"%d"\n}\n'
% (fs.RUNTIME_APPIDS[alias], fs.RUNTIME_NAMES[alias], 900000000))
elif os.path.exists(acf):
os.unlink(acf)
if state['runtimes'].get('lepton'):
os.makedirs(os.path.dirname(fs.LEPTON), exist_ok=True)
shutil.copy(f'{LIB}/lepton.py', fs.LEPTON)
os.chmod(fs.LEPTON, 0o755)
elif os.path.exists(fs.LEPTON):
os.unlink(fs.LEPTON)
for app in state['steam']['apps']:
acf = f"{apps}/appmanifest_{app['appid']}.acf"
if app['installed']:
write(acf, '"AppState"\n{\n\t"appid"\t\t"%d"\n\t"name"\t\t"%s"\n\t"SizeOnDisk"\t\t"%d"\n}\n'
% (app['appid'], app['display_name'], app['size']))
subprocess.run(['chown', '-R', f'{USER}:{USER}', fs.STEAM_ROOT])
def disk_full(on):
if on:
if os.path.ismount(fs.DEVKIT_GAMES) is False:
raise ValueError(f'disk-full needs {fs.DEVKIT_GAMES} to be its own small filesystem (compose tmpfs)')
st = os.statvfs(fs.DEVKIT_GAMES)
size = max(0, st.f_bavail * st.f_frsize - 64 * 1024)
fd = os.open(BALLAST, os.O_WRONLY | os.O_CREAT, 0o600)
try:
os.posix_fallocate(fd, 0, size)
finally:
os.close(fd)
elif os.path.exists(BALLAST):
os.unlink(BALLAST)
def set_keys(which):
os.makedirs(os.path.dirname(AUTH_KEYS), mode=0o700, exist_ok=True)
chown(os.path.dirname(AUTH_KEYS))
text = ''
if which == 'harness':
with open(HARNESS_KEY + '.pub') as f:
text = f.read()
write(AUTH_KEYS, text)
os.chmod(AUTH_KEYS, 0o600)
def harness_key():
"""The key the host container logs in with, shared through the /keys volume."""
os.makedirs(KEYS, exist_ok=True)
if not os.path.exists(HARNESS_KEY):
subprocess.run(['ssh-keygen', '-q', '-t', 'ed25519', '-N', '', '-C', 'fakeframe-harness',
'-f', HARNESS_KEY], check=True)
os.chmod(HARNESS_KEY, 0o644) # the host container copies it into its own ~/.ssh with 0600
def clean_home():
"""Everything Frame Control or a test put on the "headset"."""
for c in list(fs.read()['lepton'].values()):
try:
os.kill(c['pid'], 15)
except (OSError, KeyError, TypeError):
pass
for pattern in (fs.DEVKIT_GAMES + '/*', fs.DEVKIT_GAMES + '/.[!.]*', HOME + '/devkit-utils',
HOME + '/.devkit-utils.frame-control', HOME + '/Applications', HOME + '/Downloads/*',
fs.STEAM_ROOT + '/steamapps/compatdata', fs.STEAM_ROOT + '/steamapps/shadercache',
fs.STEAM_ROOT + '/logs', '/var/log/fakeframe/devkit-*', '/var/log/fakeframe/shortcut-*'):
for p in glob.glob(pattern):
subprocess.run(['rm', '-rf', p])
os.makedirs(HOME + '/Downloads', exist_ok=True)
chown(HOME + '/Downloads')
chown(fs.DEVKIT_GAMES)
def apply_files():
state = fs.read()
sysfs(state)
runtimes(state)
def reset():
with _lock:
stop('steam')
clean_home()
fs.reset()
env_switches()
set_keys('harness')
disk_full(False)
apply_files()
reconcile()
def env_switches():
"""FAKEFRAME_PAIRING_MODE=1 and the like set a switch's value at start."""
with fs.update() as s:
for k in s['switches']:
v = os.environ.get('FAKEFRAME_' + k.upper())
if v is not None:
s['switches'][k] = v if k == 'pairing_answer' else v in ('1', 'on', 'true', 'yes')
# ---- commands ----------------------------------------------------------------------
ON_OFF = {'on': True, 'off': False}
SWITCH = {'pairing': 'pairing_mode', 'steam': 'steam', 'sleep': 'asleep', 'sshd': 'sshd',
'devkit-service': 'devkit_service'}
def command(args):
if not args or args[0] == 'help':
return {'usage': USAGE}
cmd, rest = args[0], args[1:]
if cmd == 'state':
return fs.read()
if cmd == 'calls':
return fs.calls(rest[0] if rest else None)
if cmd == 'ping':
return ping()
if cmd == 'reset':
reset()
return {'ok': True}
if cmd in SWITCH and len(rest) == 1 and rest[0] in ON_OFF:
with fs.update() as s:
s['switches'][SWITCH[cmd]] = ON_OFF[rest[0]]
reconcile()
return {'ok': True, SWITCH[cmd]: ON_OFF[rest[0]]}
if cmd == 'answer' and rest and rest[0] in ('approve', 'deny', 'timeout'):
with fs.update() as s:
s['switches']['pairing_answer'] = rest[0]
return {'ok': True}
if cmd == 'disk-full' and len(rest) == 1 and rest[0] in ON_OFF:
with _lock:
disk_full(ON_OFF[rest[0]])
with fs.update() as s:
s['switches']['disk_full'] = ON_OFF[rest[0]]
return {'ok': True}
if cmd == 'runtime' and len(rest) == 2 and rest[1] in ('installed', 'missing'):
with fs.update() as s:
if rest[0] not in s['runtimes']:
raise ValueError(f'unknown runtime {rest[0]}')
s['runtimes'][rest[0]] = rest[1] == 'installed'
apply_files()
return {'ok': True}
if cmd == 'battery' and rest:
with fs.update() as s:
for kv in rest:
k, _, v = kv.partition('=')
if k not in s['battery'] or k == 'charger':
raise ValueError(f'unknown battery field {k}')
s['battery'][k] = int(v) if v.lstrip('-').isdigit() else v
apply_files()
return {'ok': True}
if cmd == 'keys' and rest and rest[0] in ('harness', 'none'):
set_keys(rest[0])
return {'ok': True}
if cmd == 'authorized-keys':
with open(AUTH_KEYS) as f:
return {'text': f.read()}
raise ValueError(f'unknown command {" ".join(args)!r}; try help')
def port_open(port):
try:
with socket.create_connection(('127.0.0.1', port), timeout=1):
return True
except OSError:
return False
def ping():
sw = fs.read()['switches']
checks = {}
if sw['sshd'] and not sw['asleep']:
checks['sshd'] = port_open(22)
if sw['devkit_service'] and not sw['asleep']:
checks['devkit_service'] = port_open(32000)
if sw['steam']:
checks['devtools'] = port_open(8080) and fs.steam_pid() is not None
return {'ok': all(checks.values()), 'checks': checks}
class Control(BaseHTTPRequestHandler):
def log_message(self, fmt, *args):
pass
def reply(self, obj, status=200):
body = json.dumps(obj).encode()
self.send_response(status)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
def do_GET(self):
path, _, query = self.path.partition('?')
args = {'/state': ['state'], '/calls': ['calls'] + ([query] if query else []), '/ping': ['ping']}.get(path)
if not args:
self.reply({'error': 'not found'}, 404)
return
self.reply(command(args))
def do_POST(self):
if self.path != '/ctl':
self.reply({'error': 'not found'}, 404)
return
try:
body = json.loads(self.rfile.read(int(self.headers.get('Content-Length') or 0)) or b'{}')
self.reply(command([str(a) for a in body.get('args', [])]))
except (ValueError, OSError) as e:
self.reply({'error': str(e)}, 400)
def main():
os.umask(0o022)
harness_key()
os.makedirs(fs.DIR, mode=0o777, exist_ok=True)
os.chmod(fs.DIR, 0o777)
os.makedirs(f'/run/user/{PW.pw_uid}', exist_ok=True)
chown(f'/run/user/{PW.pw_uid}')
reset()
httpd = ThreadingHTTPServer(('0.0.0.0', CONTROL_PORT), Control)
httpd.daemon_threads = True
threading.Thread(target=httpd.serve_forever, daemon=True).start()
log(f'fake Frame up; control on :{CONTROL_PORT}')
while True: # restart anything that died unasked, as systemd would
time.sleep(1)
try:
reconcile()
except Exception as e: # keep supervising
log(f'reconcile: {type(e).__name__}: {e}')
if __name__ == '__main__':
main()
+83
View File
@@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""Stand-in for Lepton's launcher (~/.local/share/Steam/steamapps/common/Lepton/lepton).
frame/android/lepton-app.sh runs it as `lepton waitforexitandrun -- APK` with
the Steam compat variables set. Like the real one (docs/apks.md, verified
2026-09-25, BUILD_ID 20260922.6101926) it:
- dies with "unbound variable" when STEAM_COMPAT_SHADER_PATH isn't set;
- needs STEAM_COMPAT_DATA_PATH under ~/.local/share/Steam (only that tree is
mounted in the container; elsewhere the app crashes with ENOENT);
- runs a "steamlaunch" container named lepton-steamlaunch-<SteamAppId> when
SteamAppId is set, else Lepton Development (lepton-dev);
- opens ADB on 0.0.0.0: 5555 for Lepton Development, the next free port for
each own instance (README security notes, 2026-09-25);
- shows a flat window only when lepton-show-flatscreen sits next to the APK.
There's no Android inside: it records the launch and holds the port until
`podman stop` (the podman stub) ends it.
"""
import json
import os
import signal
import socket
import sys
import time
sys.path.insert(0, '/usr/local/lib/fakeframe')
import fakeframe_state as fs # noqa: E402
ENV = ('SteamAppId', 'STEAM_COMPAT_INSTALL_PATH', 'STEAM_COMPAT_DATA_PATH', 'STEAM_COMPAT_SHADER_PATH',
'STEAM_FOSSILIZE_DUMP_PATH', 'IS_PARENT')
def main():
args = sys.argv[1:]
env = {k: os.environ.get(k) for k in ENV}
fs.log('lepton', args=args, env=env)
for k in ('STEAM_COMPAT_SHADER_PATH', 'STEAM_COMPAT_DATA_PATH', 'STEAM_COMPAT_INSTALL_PATH'):
if not env[k]:
sys.exit(f'{sys.argv[0]}: line 1: {k}: unbound variable')
if not os.path.realpath(env['STEAM_COMPAT_DATA_PATH']).startswith(fs.STEAM_ROOT + '/'):
sys.exit('ENOENT: STEAM_COMPAT_DATA_PATH is outside ~/.local/share/Steam, which is all the container sees')
apk = args[-1] if args else ''
if not apk.endswith('.apk') or not os.path.isfile(apk):
sys.exit(f'lepton: no APK at {apk!r}')
steamlaunch = bool(env['SteamAppId'])
name = f"lepton-steamlaunch-{env['SteamAppId']}" if steamlaunch else 'lepton-dev'
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
with fs.update() as state:
if name in state['lepton']:
sys.exit(f'lepton: {name} is already running')
used = {c['port'] for c in state['lepton'].values()}
for port in ([5555] if not steamlaunch else range(5556, 5600)):
if port in used:
continue
try:
sock.bind(('0.0.0.0', port))
break
except OSError:
continue
else:
sys.exit('lepton: no free ADB port')
sock.listen(8)
os.makedirs(os.path.join(env['STEAM_COMPAT_DATA_PATH'], 'internal'), exist_ok=True)
state['lepton'][name] = {'port': port, 'pid': os.getpid(), 'apk': apk, 'started': time.time(),
'flatscreen': os.path.exists(os.path.join(os.path.dirname(apk),
'lepton-show-flatscreen'))}
def stop(*_):
with fs.update() as state:
state['lepton'].pop(name, None)
fs.log('lepton', stopped=name)
os._exit(0)
signal.signal(signal.SIGTERM, stop)
signal.signal(signal.SIGINT, stop)
print(json.dumps({'container': name, 'adb_port': port}), flush=True)
while True:
conn, _ = sock.accept() # nothing speaks ADB here; just close
conn.close()
if __name__ == '__main__':
main()
@@ -0,0 +1,50 @@
"""Stand-in for dbus-python, just enough for Valve's steamos-devkit-service.
The service advertises _steamos-devkit._tcp through systemd-resolved's
RegisterService over the system bus (on the Frame, `frame` answered mDNS,
docs/ssh.md, 2026-09-26). A container has no system bus or resolved, so
this records the call in the fake Frame's log instead.
"""
import sys
from . import exceptions # noqa: F401
sys.path.insert(0, '/usr/local/lib/fakeframe')
import fakeframe_state as fs # noqa: E402
class Array(list):
def __init__(self, items=(), signature=None):
super().__init__(items)
class Dictionary(dict):
def __init__(self, items=(), signature=None):
super().__init__(items)
def UInt16(v):
return int(v)
def _plain(v):
if isinstance(v, dict):
return {k: _plain(x) for k, x in v.items()}
if isinstance(v, list):
if all(isinstance(x, int) for x in v):
return bytes(v).decode('utf-8', 'replace')
return [_plain(x) for x in v]
return v
class _Object:
def get_dbus_method(self, name, interface=None):
def call(*args):
fs.log('resolve1', method=name, args=_plain(list(args)))
return f'/org/freedesktop/resolve1/dnssd/{args[0]}' if name == 'RegisterService' else None
return call
class SystemBus:
def get_object(self, bus_name, path):
return _Object()
@@ -0,0 +1,3 @@
class DBusException(Exception):
def get_dbus_name(self):
return None