mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 05:02:50 +02:00
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:
1 parent
ca2a991f03
commit
0181071b83
43 files changed
+4747
No files matched your search
+31
@@ -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)
|
||||
Executable
+38
@@ -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
@@ -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)
|
||||
Executable
+11
@@ -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')
|
||||
Executable
+47
@@ -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}')
|
||||
Executable
+19
@@ -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}')
|
||||
Executable
+26
@@ -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)
|
||||
Executable
+21
@@ -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
@@ -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
@@ -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()
|
||||
@@ -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
|
||||
Reference in new issue
Block a user