Files
saphidandClaude Opus 5.5 6c4d387ef3 Sideload Linux and Windows builds as Steam Devkit Games
Dropping a game's .zip, folder or .exe on Send to Frame now adds it to the
headset's Steam library through Valve's SteamOS Devkit title path, with the
runtime picked from the program's header: Windows PE -> Proton Experimental
(steam_play=1), aarch64 ELF -> SteamLinuxRuntime_4-arm64, x86-64 ELF ->
SteamLinuxRuntime_4 (through FEX). Other architectures are refused.

- frame/devkit-utils: Valve's devkit-utils vendored unmodified (MIT,
  steamos-devkit v0.20260925.1), synced to ~/devkit-utils by stamp, bundled in
  the app and compiled in CI.
- ui/frame_titles.py: inspect (safe unzip, ELF/PE classification, launch
  target ranking), install(path, name=None, exe=None, runtime=None,
  progress=None), list, launch, remove, plus a CLI.
- ui/server.py: /api/titles (inspect/install/discard/launch/remove),
  /api/titles/job progress, and an upload mode 'title'.
- ui/index.html: confirm dialog (name, launch target, runtime), install
  progress, and a Sideloaded titles list with Launch and Remove. The app's
  preload passes a dropped folder's path.
- tests and docs/sideloading.md. Device-side behaviour is inferred from
  Valve's source; the headset was offline, so none of it has been checked on
  a Frame yet.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-09-26 20:38:14 +10:00

444 lines
17 KiB
Python
Executable File

#!/usr/bin/env python3
import sys
import os
import re
import shutil
import subprocess
import logging
import enum
import argparse
import json
import shlex
import datetime
import pathlib
import socket
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger(__name__)
DEVKIT_TOOL_FOLDER = os.path.expanduser('~/devkit-game')
STEAM_EXTRA_ARGS_FILE = os.path.expanduser('~/.config/systemd/user/steam.service.d/extra_args.conf')
WIRELESS_DISABLE_POWER_MANAGEMENT = '/usr/bin/steamos-polkit-helpers/steamos-disable-wireless-power-management'
# Must match in gui2.py
class SteamStatus(enum.Enum):
NOT_RUNNING = 0
ERROR = 1
OS = 2
OS_DEV = 3
SIDELOADED = 4
@classmethod
def from_string(cls, status_str):
if not status_str:
return cls.ERROR
try:
if '.' in status_str:
name = status_str.split('.')[-1]
else:
name = status_str
return cls[name]
except KeyError:
return cls.ERROR
@property
def description(self):
DESCRIPTIONS = {
SteamStatus.NOT_RUNNING: 'not running',
SteamStatus.OS: 'OS client',
SteamStatus.OS_DEV: 'OS client dev mode',
SteamStatus.SIDELOADED: 'sideloaded client',
SteamStatus.ERROR: 'error',
}
return DESCRIPTIONS[self]
class SteamConfig(enum.Enum):
ERROR = 1
# Matching the SteamStatus numeric values
OS = 2
OS_DEV = 3
SIDELOADED = 4
@property
def description(self):
DESCRIPTIONS = {
SteamConfig.OS: 'OS client',
SteamConfig.OS_DEV: 'OS client dev mode',
SteamConfig.SIDELOADED: 'sideloaded client',
SteamConfig.ERROR: 'error',
}
return DESCRIPTIONS[self]
SESSION_NAMES = ['gamescope', 'plasma-x11', 'plasma-x11-persistent', 'plasma-wayland', 'plasma-wayland-persistent']
class SessionConfig(enum.IntEnum):
# note: matches SESSION_NAMES indexes
GAMESCOPE = 0
PLASMA_X11 = 1
PLASMA_X11_PERSISTENT = 2
PLASMA_WAYLAND = 3
PLASMA_WAYLAND_PERSISTENT = 4
ERROR = 5
def cef_debugging():
'''Only sane way to check is to look for the listening port.'''
ret = subprocess.run('/usr/bin/ss -l -t -n -p | grep steamwebhelper | grep 8080 > /dev/null', shell=True)
return ( ret.returncode == 0 )
def steam_process_get_path_and_args():
ret = subprocess.run(['pgrep', '-a', '-x', 'steam'], capture_output=True, text=True)
if ret.returncode != 0:
return None
# Proton may run a dummy 'steam' process that confused previous implementations of this logic
# look for a process who's real filename is 'steam'
for l in ret.stdout.splitlines():
try:
pid = int(l.split(' ')[0])
except:
continue
rp = os.path.realpath(f'/proc/{pid}/exe')
if os.path.basename(rp) == 'steam':
try:
with open(f'/proc/{pid}/cmdline', 'rb') as f:
cmdline = f.read()
argv = [a.decode('utf-8', errors='replace') for a in cmdline.split(b'\x00') if a]
if len(argv) >= 2:
path = argv[0]
args = argv[1:]
# strip -srt-logger-opened: injected by steam.sh at runtime
args = [a for a in args if a != '-srt-logger-opened']
return (path, args)
return (argv[0], [])
except Exception as e:
logger.warning(f'Failed to read /proc/{pid}/cmdline: {e}')
return None
def steam_process_get_path():
try:
(path, _) = steam_process_get_path_and_args()
except:
return None
return path
def steam_process_get_args():
try:
(_, args) = steam_process_get_path_and_args()
except:
return ''
return args
def steam_status():
'''What is the status of the Steam client on the system?'''
s = steam_process_get_path()
if s is None:
return SteamStatus.NOT_RUNNING
if s.find('.local/share/Steam/') != -1:
if os.path.exists(os.path.expanduser('~/devkit-game/devkit-steam')):
return SteamStatus.OS_DEV
return SteamStatus.OS
if s.find('devkit-game/steam/') != -1 or s.find('devkit-game/steamdeckard/') != -1:
return SteamStatus.SIDELOADED
logger.warning(f'could not interpret pgrep result to determine steam client status: {s!r}')
return SteamStatus.ERROR
def steam_configuration():
'''How is the Steam client configured to run?'''
devkit_steam_trampoline_path = os.path.join(DEVKIT_TOOL_FOLDER, 'devkit-steam')
if not os.path.exists(devkit_steam_trampoline_path):
return SteamConfig.OS
t = open(devkit_steam_trampoline_path, 'rt').read()
if t.find('SteamStatus.OS_DEV') != -1:
return SteamConfig.OS_DEV
if t.find('SteamStatus.SIDELOADED') != -1:
return SteamConfig.SIDELOADED
logger.warning(f'could not determine what {devkit_steam_trampoline_path} means to do')
return SteamConfig.ERROR
def osclient_branch(is_deckard):
'''Which branch is the default Steam 'OS client' configured to use?'''
# makes more sense to return strings here
beta_path = os.path.expanduser('~/.steam/steam/package/beta')
if not os.path.exists(beta_path):
return 'default' # not sure that's valid actually - would be the desktop client, which will only run in desktop mode ..
t = open(beta_path, 'rt').readline().strip('\n')
try:
p = 'steamdeck_(.*)'
if re.match(p, t):
branch = re.split(p, t)[1]
return branch
# internal builds
p = 'steampal_(.*)_.*'
if re.match(p, t):
branch = re.split(p, t)[1]
return branch
if is_deckard:
p = 'linux_arm64_(.*)_.*'
if re.match(p, t):
branch = re.split(p, t)[1]
return branch
raise Exception('no match')
except: # noqa: E722
logger.warning(f'could not determine the OS client branch config: {t!r}')
return 'error'
def osclient_version(conf):
'''Which version is the Steam 'OS client'?'''
if conf.is_deckard:
# old Steam client was using linuxarm64/, which is now reserved for the SDK binaries
for folder in ('linuxarm64', 'steamrtarm64'):
fn = os.path.expanduser(f'~/.steam/steam/{folder}/builddate.txt')
if os.path.exists(fn):
return open(fn, 'rt').read()
return 'Unknown - no builddate.txt'
beta_path = os.path.expanduser('~/.steam/steam/package/beta')
if not os.path.exists(beta_path):
logger.warning(f'not found: {beta_path}')
return None
t = open(beta_path, 'rt').readline().strip('\n')
manifest = os.path.expanduser(f'~/.steam/steam/package/steam_client_{t}_ubuntu12.manifest')
if not os.path.exists(manifest):
logger.warning(f'not found: {manifest}')
return None
try:
version = int(re.search('"version".*"(.*)"', open(manifest,'rt').read()).group(1))
return version
except:
logger.warning(f'could not parse version out of {manifest}')
return None
def session_config():
'''What is the graphics session configuration?'''
# RESTART_SESSION writes this file
conf_file = '/etc/sddm.conf.d/zz-steamos-autologin.conf'
if not os.path.exists(conf_file):
# fallback to the OS default
conf_file = '/etc/sddm.conf.d/steamos.conf'
if os.path.exists(conf_file):
s = open(conf_file, 'rt').read()
if s.find('plasmawayland.desktop') != -1:
return SessionConfig.PLASMA_WAYLAND_PERSISTENT
if s.find('plasma.desktop') != -1:
return SessionConfig.PLASMA_X11_PERSISTENT
if s.find('gamescope-wayland.desktop') != -1:
return SessionConfig.GAMESCOPE
if s.find('plasma-steamos-oneshot.desktop') != -1:
return SessionConfig.PLASMA_X11
if s.find('plasma-steamos-wayland-oneshot.desktop') != -1:
return SessionConfig.PLASMA_WAYLAND
else:
# if the conf file doesn't exist we are likely in the default config
# check for a running gamescope for sanity
if subprocess.call('pgrep -a -x gamescope', shell=True, stdout=subprocess.DEVNULL) == 0:
return SessionConfig.GAMESCOPE
# couldn't figure it out, halp
return SessionConfig.ERROR
def session_select_command():
if shutil.which('holo-session-select'):
return 'holo-session-select'
return 'steamos-session-select'
def get_os_info():
os_info = {}
try:
for k, v in [ s.split('=') for s in open('/etc/os-release').read().split('\n') if len(s) > 0 ]:
os_info[k] = v.strip('"')
except Exception as e:
logger.error(e)
logger.error('Failed to parse OS release file')
return os_info
def steam_default_args(conf):
if conf.is_deckard:
# Frame currently uses a different setup
return []
try:
if os.path.exists('/usr/lib/steamos/steam-launcher'):
output = subprocess.check_output('cat /usr/lib/steamos/steam-launcher | grep ^steamargs=',
shell=True,
universal_newlines=True)
ret = [ v.strip('"') for v in re.findall('\".*?\"', output) ]
return ret
except:
logger.warning('Failed to obtain steam default arguments from /usr/lib/steamos/steam-launcher')
# Legacy SteamOS
try:
output = subprocess.check_output('cat /usr/bin/gamescope-session | grep ^steamargs',
shell=True,
universal_newlines=True)
ret = [ v.strip('"') for v in re.findall('\".*?\"', output) ]
except:
logger.warning('Failed to obtain steam default arguments from /usr/bin/gamescope-session')
# Hardcoded fallback
return ['-steamos3', '-steampal', '-steamdeck', '-gamepadui']
def frame_osclient_extra_args(conf, steam_status):
if not conf.is_deckard or steam_status != SteamStatus.OS:
return None
if os.path.exists(STEAM_EXTRA_ARGS_FILE):
try:
content = open(STEAM_EXTRA_ARGS_FILE, 'rt').read()
match = re.search(r'Environment="STEAM_EXTRA_ARGS=(.*)"', content)
if match:
return match.group(1).replace('\\"', '"')
except Exception as e:
logger.warning(f'Failed to parse steam extra args: {e}')
return None
def user_password_is_set():
ret = subprocess.run('passwd', stdin=subprocess.DEVNULL, shell=True, capture_output=True, universal_newlines=True)
logger.debug(repr(ret))
return (ret.stderr.find('Current password:') != -1)
def steam_launch_flags():
'''Pull various steam flags that affect title execution.'''
ret = {}
if not 'XDG_RUNTIME_DIR' in os.environ:
logger.warning('XDK_RUNTIME_DIR is not set')
return ret
env_folder = os.path.join(os.environ['XDG_RUNTIME_DIR'], 'steam/env')
if not os.path.isdir(env_folder):
return ret
for fn in os.listdir(env_folder):
filepath = os.path.join(env_folder, fn)
content = open(filepath, 'rt').read()
# Check if this is a declaration file with key=value pairs
if content.count('\n') > 1 or '=' in content:
# Parse key=value format with comments
for line in content.splitlines():
line = line.strip()
# Skip comments and empty lines
if not line or line.startswith('#'):
continue
# Parse key=value pairs
if '=' in line:
key, value = line.split('=', 1)
ret[key.strip()] = value.strip()
else:
# Legacy format: filename is the key, file content is the value
ret[fn] = content.strip('\n')
return ret
def renderdoc_replay_server_running():
ret = subprocess.run(['pgrep', '-x', 'renderdoccmd'], capture_output=True)
return ret.returncode == 0
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--verbose', required=False, action='store_true')
parser.add_argument('--json', required=False, action='store_true')
conf = parser.parse_args()
if conf.verbose:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
os_info = get_os_info()
assert os_info is not None
conf.is_deckard = os_info.get('VARIANT_ID', None) == 'vr'
os_name = os_info.get('PRETTY_NAME', None)
os_version = os_info.get('BUILD_ID', None)
_steam_launch_flags = steam_launch_flags()
if not conf.is_deckard:
# this bit of cargo cult is Steam Deck only
try:
# disable wireless power management for devkit usage: less latency on commands
subprocess.check_call(WIRELESS_DISABLE_POWER_MANAGEMENT)
except subprocess.CalledProcessError as e:
logger.warning(e)
session_config = session_config()
# enum -> human readable
session_status = SESSION_NAMES[session_config] if session_config != SessionConfig.ERROR else 'error'
steam_status = steam_status()
cef_debugging_enabled = False
if steam_status != SteamStatus.NOT_RUNNING:
cef_debugging_enabled = cef_debugging()
steam_configuration = steam_configuration()
osclient_branch = osclient_branch(conf.is_deckard)
osclient_version = osclient_version(conf)
steam_status_description = steam_status.description
if steam_status in (SteamStatus.OS, SteamStatus.OS_DEV) :
steam_status_description += f', on branch {osclient_branch!r}'
if osclient_version is not None:
if conf.is_deckard:
# we get builddate.txt
steam_status_description += f', {osclient_version}'
else:
utc_date_string = datetime.datetime.fromtimestamp(osclient_version, datetime.UTC).isoformat()
steam_status_description += f', version {osclient_version} {utc_date_string}'
has_side_loaded_client = os.path.exists(
os.path.join(
DEVKIT_TOOL_FOLDER,
'steam'
)
)
_user_password_is_set = user_password_is_set()
_renderdoc_replay_server_running = renderdoc_replay_server_running()
_renderdoc_layer_enabled = _steam_launch_flags.get('ENABLE_VULKAN_RENDERDOC_CAPTURE', '0') == '1'
_hostname = socket.gethostname()
if conf.json:
ret = {
'is_deckard': conf.is_deckard,
'hostname': _hostname,
'os_name': os_name,
'os_version': os_version,
'os_info': os_info,
'session_status': session_status,
'session_options': SESSION_NAMES,
'session_select': session_select_command(),
'steam_status': str(steam_status),
'cef_debugging_enabled': cef_debugging_enabled,
'steam_status_description': steam_status_description,
'steam_configuration': str(steam_configuration),
'steam_osclient_branch': osclient_branch,
'steam_osclient_version': osclient_version,
'has_side_loaded_client': has_side_loaded_client,
'steam_default_args': steam_default_args(conf),
'steam_current_args': steam_process_get_args(),
'frame_osclient_extra_args': frame_osclient_extra_args(conf, steam_status),
'user_password_is_set': _user_password_is_set,
'steam_launch_flags': _steam_launch_flags,
'renderdoc_layer_enabled': _renderdoc_layer_enabled,
'renderdoc_replay_server_running': _renderdoc_replay_server_running,
}
json.dump(ret, sys.stdout, sort_keys=True, indent=4)
else:
logger.info(f'Hostname : {_hostname}')
logger.info(f'OS : {os_name}')
logger.info(f'OS version : {os_version}')
logger.info(f'Session mode is : {session_status}')
logger.info(f'Session select command : {session_select_command()}')
logger.info(f'Steam client status : {steam_status_description}')
logger.info(f'Steam client args : {steam_process_get_args()!r}')
logger.info(f"Steam extra args (Frame) : {frame_osclient_extra_args(conf, steam_status)!r}")
logger.info(f"Steam CEF debug : {'enabled' if cef_debugging_enabled else 'disabled'}")
logger.info(f'Steam client config : {steam_configuration.description}')
logger.info(f'Steam OS client branch : {osclient_branch}')
logger.info(f'Steam OS client version : {osclient_version}')
logger.info(f"Sideloaded client : {'available' if has_side_loaded_client else 'not installed'}")
logger.info(f'OS client arguments : {steam_default_args(conf)!r}')
logger.info(f"User password is set : {'yes' if _user_password_is_set else 'no'}")
logger.info(f"Steam launch flags : {_steam_launch_flags}")
logger.info(f"RenderDoc layer enabled : {'yes' if _renderdoc_layer_enabled else 'no'}")
logger.info(f"RenderDoc replay running : {'yes' if _renderdoc_replay_server_running else 'no'}")