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>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-26 20:38:14 +10:00
1 parent 1a0e54d8bd
commit 6c4d387ef3
26 files changed
+3191 -26

No files matched your search

+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017-2022 Valve Software inc., Collabora Ltd
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+18
View File
@@ -0,0 +1,18 @@
# Valve's devkit-utils (vendored)
Unmodified copy of `client/devkit-utils/` from Valve's
[SteamOS Devkit Client](https://gitlab.steamos.cloud/devkit/steamos-devkit),
MIT licensed (see `LICENSE`; Valve's own notes are in `VALVE-README.md`).
- Source: steamos-devkit, commit `6f0711a` ("Code drop."),
release **v0.20260925.1** (ChangeLog entry dated 2026-09-25).
`ui/frame_titles.py` copies this folder to `~/devkit-utils` on the Frame (where
Valve's own client puts it) and uses `steamos-prepare-upload`,
`steam-client-create-shortcut`, `steam-devkit-rpc` and `steamos-delete` to
register uploaded builds as Steam "Devkit Games". See `docs/sideloading.md`.
To update: copy the folder from a newer checkout over this one, keep this
README, and update the version line above. The stamp Frame Control compares
on the headset is a hash of these files, so a changed copy is re-synced on the
next use.
+4
View File
@@ -0,0 +1,4 @@
These scripts and supporting utility module are uploaded to the devkit by the devkit client:
- steamos-* : scripts that operate (mostly) at SteamOS level for devkit functionality purposes
- steam-client-* : scripts that relay commands to the local running Steam client
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import sys
import os
import time
import subprocess
import logging
import argparse
import json
import datetime
import io
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger(__name__)
def main():
parser = argparse.ArgumentParser(description='Capture screenshot and videos on Steam Frame device')
parser.add_argument('--filename', '-f',
default='/tmp/screenshot.png',
help='Output')
parser.add_argument('--timestamp', action='store_true',
help='Add timestamp')
parser.add_argument('--json', action='store_true',
help='Output result as JSON')
args = parser.parse_args()
output_buffer = io.StringIO()
try:
steamvr_path = subprocess.check_output(['steamvr', 'path'], stderr=subprocess.STDOUT, universal_newlines=True).strip()
cdd = os.path.join(steamvr_path, 'bin/linuxarm64')
run_vrcmd = os.path.join(cdd, 'vrcmd')
assert os.path.exists(run_vrcmd), "vrcmd not found"
# Enable recording
cmd = [run_vrcmd, '--mailboxcmd', 'vrcompositor_systemlayer', 'set_local_video_record?enabled=true']
output_buffer.write(f"Command: {' '.join(cmd)}\n")
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
output_buffer.write(result.stdout)
if result.returncode != 0:
raise subprocess.CalledProcessError(result.returncode, cmd)
# Wait for the video device to produce frames.
# Note that even when disabled it outputs roughly 2 blank frames per second.
cmd = ['timeout', '1', 'ffmpeg', '-f', 'v4l2', '-i', '/dev/video99', '-frames:v', '4', '-f', 'null', '-', '-v', 'error']
output_buffer.write(f"Command: {' '.join(cmd)}\n")
retries = 2
while True:
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
output_buffer.write(result.stdout)
if result.returncode == 0:
break
retries -= 1
if retries <= 0:
raise Exception("Failed to get video frames from /dev/video99. Is VR active? Is the v4l2 configuration correct?")
output_filename = args.filename
if args.timestamp:
timestamp = datetime.datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
base, ext = os.path.splitext(output_filename)
output_filename = f"{base}-{timestamp}{ext}"
# Capture screenshot
cmd = ['ffmpeg', '-f', 'v4l2', '-i', '/dev/video99', '-frames:v', '1', '-q:v', '1', '-y', output_filename]
output_buffer.write(f"Command: {' '.join(cmd)}\n")
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
output_buffer.write(result.stdout)
if result.returncode != 0:
raise subprocess.CalledProcessError(result.returncode, cmd)
# Disable recording
cmd = [run_vrcmd, '--mailboxcmd', 'vrcompositor_systemlayer', 'set_local_video_record?enabled=false']
output_buffer.write(f"Command: {' '.join(cmd)}\n")
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
output_buffer.write(result.stdout)
if result.returncode != 0:
raise subprocess.CalledProcessError(result.returncode, cmd)
except Exception as e:
error_msg = str(e)
# Print collected output to stderr on error
print(output_buffer.getvalue(), file=sys.stderr)
logger.error(error_msg)
if args.json:
result = {
'success': False,
'error': error_msg
}
print(json.dumps(result))
else:
print(f"Error: {error_msg}", file=sys.stderr)
return 1
if args.json:
result = {
'success': True,
'output': output_filename
}
print(json.dumps(result))
else:
print(f"Screenshot saved to {output_filename}")
if __name__ == '__main__':
sys.exit(main())
+300
View File
@@ -0,0 +1,300 @@
#!/usr/bin/env python
# encoding: utf-8
"""Utility functions for the Steam client hook scripts"""
import sys
import os
import traceback
import tempfile
import json
import logging
import fcntl
import errno
import contextlib
import time
import fcntl
import logging as logging_module
logger = logging_module.getLogger(__name__)
@contextlib.contextmanager
def wrap_outputs(stderr_prefix):
# capture stderr to file to support debugging
stderr_fd = sys.stderr.fileno()
tf = tempfile.NamedTemporaryFile(
mode='w+',
prefix=stderr_prefix,
delete=True)
if sys.version_info >= (3, 4):
# this API in the os module is only available for python3
# but it does not seem to work with subprocess anyway
os.set_inheritable(tf.file.fileno(), True)
assert os.get_inheritable(tf.file.fileno())
sys.stderr = tf.file
# we can only write out a json response to stdout,
# so redirect stdout to stderr,
# and keep a handle on the original stdout for the response
stdout_fd = os.dup(sys.stdout.fileno())
os.dup2(sys.stderr.fileno(), sys.stdout.fileno())
ctx = {}
try:
yield ctx
except:
logger.error(traceback.format_exc())
finally:
tf.flush()
tf.seek(0)
os.write(stderr_fd, tf.read().encode('utf-8'))
if 'ret' in ctx:
os.write(stdout_fd, json.dumps(ctx['ret']).encode('utf-8'))
class SteamClientNotRunningException(Exception):
def __init__(self, error_message):
self.error_message = error_message
def __str__(self):
return self.error_message
def validate_steam_client():
"""Verify that the steam client is running, and permissions are adequate"""
pid_path = os.path.normpath(
os.path.realpath(
os.path.expanduser('~/.steam/steam.pid')))
if not os.path.exists(pid_path):
raise SteamClientNotRunningException('{0} does not exist'.format(pid_path))
try:
pid = int(open(pid_path, 'rt').read())
except Exception:
raise SteamClientNotRunningException('{0} is invalid'.format(pid_path))
try:
os.kill(pid, 0)
except OSError:
raise SteamClientNotRunningException('{0} does not refer to a valid process'.format(pid_path))
logger.info('Found steam client pid %s', pid)
def execute_steam_client_command(cmd):
"""Send a command to the steam client over the IPC pipe"""
pipe_path = os.path.normpath(
os.path.realpath(
os.path.expanduser('~/.steam/steam.pipe')))
try:
pipe = open(pipe_path, 'wb+', 0)
except IOError:
raise Exception('cannot open steam client pipe')
session_token = open(os.path.expanduser('~/.steam/steam.token')).read()
#pipe_cmd = 'steam://{0}'.format(cmd)
# ^ hack to execute a normal command over the IPC directly - sometimes useful
pipe_cmd = 'devkit-1 steam://devkit-1/{0}/{1}'.format(
session_token,
cmd
)
logger.debug('Sending command line:')
logger.debug(pipe_cmd)
pipe.write('{0}\n'.format(pipe_cmd).encode('utf-8'))
pipe.close()
def save_argv(gameid, argv):
"""Save command line and arguments if provided"""
if argv is None:
return
argvfile = os.path.join(os.getenv("HOME"), "devkit-game",
gameid + "-argv.json")
try:
with open(argvfile, "w") as argvf:
fcntl.flock(argvf, fcntl.LOCK_EX)
json.dump(argv, argvf)
fcntl.flock(argvf, fcntl.LOCK_UN)
except IOError:
raise Exception(
"Unable to open argv file for writing: {0}".format(argvfile))
def obtain_argv(gameid, argv):
"""Obtain command line with arguments"""
# If present and not None or [], just return the local arguments
if argv:
return argv
# From here, expect arguments to have been saved previously
argvfile = os.path.join(os.getenv("HOME"), "devkit-game",
gameid + "-argv.json")
try:
with open(argvfile, "r") as argvf:
fcntl.flock(argvf, fcntl.LOCK_EX)
argv = json.load(argvf)
fcntl.flock(argvf, fcntl.LOCK_UN)
except IOError:
raise Exception(
"Unable to open argv file for reading: {0}".format(argvfile))
return argv
def save_env(gameid, env):
"""Save environment variables if provided"""
if not env:
return
envfile = os.path.join(os.getenv("HOME"), "devkit-game",
gameid + "-env.json")
try:
with open(envfile, "w") as envf:
fcntl.flock(envf, fcntl.LOCK_EX)
json.dump(env, envf)
fcntl.flock(envf, fcntl.LOCK_UN)
except IOError:
raise Exception(
"Unable to open env file for writing: {0}".format(envfile))
def obtain_env(gameid):
"""Obtain environment variables for a game, if any were saved"""
envfile = os.path.join(os.getenv("HOME"), "devkit-game",
gameid + "-env.json")
try:
with open(envfile, "r") as envf:
fcntl.flock(envf, fcntl.LOCK_EX)
env = json.load(envf)
fcntl.flock(envf, fcntl.LOCK_UN)
except IOError:
return {}
return env
def save_settings(gameid, data):
"""Save settings"""
settingsfile = os.path.join(os.getenv("HOME"), "devkit-game",
gameid + "-settings.json")
settings = dict()
if data.get('clear_settings', False):
settings = {}
else:
try:
with open(settingsfile, "r") as f:
fcntl.flock(f, fcntl.LOCK_EX)
settings = json.load(f)
fcntl.flock(f, fcntl.LOCK_UN)
except IOError as e:
if (e.errno != errno.ENOENT):
raise
# Merge settings from new json
if 'settings' in data:
settings.update(data['settings'])
try:
with open(settingsfile, "w") as f:
fcntl.flock(f, fcntl.LOCK_EX)
json.dump(settings, f)
fcntl.flock(f, fcntl.LOCK_UN)
except (IOError):
raise Exception(
"Unable to open settings file for writing: {0}".format(
settingsfile
))
return settings
def load_settings(gameid):
settingsfile = os.path.join(os.getenv("HOME"), "devkit-game", gameid + '-settings.json')
if not os.path.isfile(settingsfile):
return None
with open(settingsfile, "r") as f:
fcntl.flock(f, fcntl.LOCK_EX)
settings = json.load(f)
fcntl.flock(f, fcntl.LOCK_UN)
return settings
class SteamResponse_Timeout(Exception):
pass
class SteamResponse_Error(Exception):
def __init__(self, error_response):
self.error_response = error_response
def __str__(self):
return self.error_response
@contextlib.contextmanager
def wait_on_file_response(path, timeout=5):
"""
The pipe to the Steam Client is one way.
Responses from the Steam Client are written to filesystem.
Protocol is as follows:
- Steam Client creates a 'path.lock' file
- Steam Client writes either 'path' or 'path.error' to indicate a problem
- Steam Client deletes 'path.lock'
- Caller (us) can then read the response
NOTE 1: this function is used as a context manager and will block until a response comes in or timeout.
NOTE 2: the files are created by Steam when responding to a command. If the files already exist the response protocol will break.
"""
lock_path = '{0}.lock'.format(path)
error_path = '{0}.error'.format(path)
max_count = timeout
while True:
time.sleep(1)
if os.path.exists(error_path) or os.path.exists(path) and not os.path.exists(lock_path):
if os.path.exists(error_path):
with open(error_path, 'r') as f:
fcntl.flock(f, fcntl.LOCK_EX)
error_response = f.read()
fcntl.flock(f, fcntl.LOCK_UN)
raise SteamResponse_Error(error_response)
with open(path, 'r') as f:
fcntl.flock(f, fcntl.LOCK_EX)
success_response = f.read()
yield success_response
fcntl.flock(f, fcntl.LOCK_UN)
return
max_count -= 1
if max_count > 0:
continue
raise SteamResponse_Timeout()
# Setting up as a context manager so we never miss the deletion
# Creating a temporary .lock file to guard the create operation
@contextlib.contextmanager
def create_pid(pid_path):
os.makedirs(os.path.dirname(pid_path), exist_ok=True)
lock_path = '{0}.lock'.format(pid_path)
try:
lock_file = os.open(lock_path, os.O_CREAT | os.O_EXCL)
except IOError as e:
logger.error('cannot create lock file %s for pid file %s', lock_path, pid_path)
logger.error('remove the lock file manually and run again if you are confident no other instance is active')
raise
pid_file = open(pid_path,'w')
pid_file.write(str(os.getpid()))
pid_file.flush()
os.close(lock_file)
os.unlink(lock_path)
try:
yield pid_file
finally:
pid_file.close()
# Assume that's atomic and all is well, no need for another .lock
os.unlink(pid_path)
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
import sys
import os
import logging
from urllib.parse import quote_plus as urllib_quote_plus
import json
import tempfile
from . import validate_steam_client
from . import execute_steam_client_command
from . import wait_on_file_response
import logging as logging_module
logger = logging_module.getLogger(__name__)
def resolve_shortcuts():
# make sure there is a steam client online that we can talk to before doing anything
validate_steam_client()
# scan the devkit games
installed_gameids = set([])
devkit_game_path = os.path.expanduser('~/devkit-game')
if not os.path.exists(devkit_game_path):
logger.info('%r does not exist, creating', devkit_game_path)
os.mkdir(devkit_game_path)
entries = sorted(os.scandir(devkit_game_path), key=lambda entry: entry.name)
directories = [e for e in entries if e.is_dir()]
for d in directories:
gameid = d.name
file_names = [f.name for f in entries if f.is_file() and f.name.startswith(gameid)]
has_argv = '{0}-argv.json'.format(gameid) in file_names
has_settings = '{0}-settings.json'.format(gameid) in file_names
if (not has_argv and not has_settings):
logger.info('Subfolder %r in %r is not accompanied by devkit configuration files, ignoring', d.name, devkit_game_path)
continue
logger.info('Found installed Devkit Game: %r', gameid)
installed_gameids.add(gameid)
# ask the Steam Client which Devkit Games are registered
with tempfile.TemporaryDirectory(prefix='list-shortcuts') as tempdir:
response = os.path.join(tempdir, 'shortcuts.json')
cmd = 'list-shortcuts?response={}'.format(
urllib_quote_plus(os.path.join(response))
)
# send the request
execute_steam_client_command(cmd)
with wait_on_file_response(response) as response:
client_shortcuts = json.loads(response)
logger.debug(client_shortcuts)
assert client_shortcuts['version'] == 2
registered_gameids = set([])
logger.info('Steam Client has %d registered devkit game(s)', len(client_shortcuts['gameids']))
for gameid in client_shortcuts['gameids']:
logger.info('Found Devkit Game registered with Steam Client: %r', gameid)
registered_gameids.add(gameid)
# any registered game that is not found installed on disk needs to be removed
for remove_gameid in registered_gameids - installed_gameids:
with tempfile.TemporaryDirectory(prefix='delete-shortcut') as tempdir:
logger.info('Removing stale registered Devkit Game: %r', remove_gameid)
response = os.path.join(tempdir, 'shortcut-deleted')
cmd = 'delete-shortcut?response={}&gameid={}'.format(
urllib_quote_plus(response),
remove_gameid
)
execute_steam_client_command(cmd)
with wait_on_file_response(response) as response:
logger.info('from Steam Client: %s', response.strip())
# any installed game that is not found registered needs to be added
for add_gameid in installed_gameids - registered_gameids:
with tempfile.TemporaryDirectory(prefix='create-shortcut') as tempdir:
logger.info('Registering installed Dekit Game: %r', add_gameid)
response = os.path.join(tempdir, 'registered')
cmd = 'create-shortcut?response={}&gameid={}&directory={}'.format(
urllib_quote_plus(response),
add_gameid,
urllib_quote_plus(devkit_game_path)
)
execute_steam_client_command(cmd)
with wait_on_file_response(response) as response:
logger.info('from Steam Client: %s', response.strip())
if __name__ == '__main__':
resolve_shortcuts()
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
import os
import logging
import argparse
import json
import platform
import tempfile
from urllib.parse import quote_plus as urllib_quote_plus
import devkit_utils
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger()
DEVKIT_TOOL_FOLDER = os.path.expanduser('~/devkit-game')
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--verbose', required=False, action='store_true')
parser.add_argument('--parms', required=True, action='store')
conf = parser.parse_args()
if conf.verbose:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
parms = json.loads(conf.parms)
gameid = parms['gameid']
directory = parms['directory']
assert os.path.isdir(directory)
force_appid = parms['force_appid']
steam_appid_path = os.path.join(directory, 'steam_appid.txt')
if force_appid:
with open(steam_appid_path, 'w') as f:
f.write(force_appid + '\n')
logger.info(f'Wrote {steam_appid_path} with AppID {force_appid}')
elif os.path.exists(steam_appid_path):
# don't overwrite an existing steam_appid.txt file from the content tree
# NOTE: if the user sets an AppID through the tool, then delete it, we may leave it in place ..
# (that's ok for now, do a clean upload if you want to get rid of it)
logger.info(f'{steam_appid_path} already exists, leaving it in place')
# Lepton (Android runtime) titles: write UECommandLine.txt next to the .apk
is_lepton = parms['settings']['compat_tool'] == 'lepton'
uecommandline = parms['lepton_args'] if is_lepton else ''
uecommandline_path = os.path.join(directory, 'UECommandLine.txt')
if uecommandline:
with open(uecommandline_path, 'w') as f:
f.write(uecommandline + '\n')
logger.info(f'Wrote {uecommandline_path}')
elif os.path.exists(uecommandline_path):
# don't overwrite an existing UECommandLine.txt file from the content tree
# NOTE: if the user sets cmdline args through the tool, then clears them, we may leave it in place ..
# (that's ok for now, do a clean upload if you want to get rid of it)
logger.info(f'{uecommandline_path} already exists, leaving it in place')
logger.info(f'Updating command line and runtime settings for {gameid} on {platform.node()}')
devkit_utils.save_argv(gameid, parms['argv'])
devkit_utils.save_env(gameid, parms['env'])
devkit_utils.save_settings(gameid, parms)
ret = {}
try:
devkit_utils.validate_steam_client()
except devkit_utils.SteamClientNotRunningException as e:
skipping = 'The Steam client is not running. Registration did not complete.'
logger.warning(skipping)
ret['error'] = skipping
else:
with tempfile.TemporaryDirectory(prefix='create-shortcut') as tempdir:
logger.info(f'Registering Devkit Game {gameid} with Steam Client')
response = os.path.join(tempdir, 'registered')
cmd = 'create-shortcut?response={}&gameid={}'.format(
urllib_quote_plus(response),
gameid,
)
devkit_utils.execute_steam_client_command(cmd)
try:
with devkit_utils.wait_on_file_response(response) as success_response:
logger.debug(success_response)
ret['success'] = success_response
except devkit_utils.SteamResponse_Timeout:
ret['error'] = 'timeout - Steam client did not respond to registration request'
except devkit_utils.SteamResponse_Error as e:
ret['error'] = e.error_response
# response gets written out to stdout
print(json.dumps(ret))
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
import sys
import os
import logging
import argparse
import tempfile
import urllib.parse
import re
import devkit_utils
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger()
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('command')
parser.add_argument('args', nargs='*')
conf = parser.parse_args()
try:
devkit_utils.validate_steam_client()
except devkit_utils.SteamClientNotRunningException as e:
logger.error(repr(e))
sys.exit(-1)
else:
with tempfile.TemporaryDirectory(prefix='steam-devkit-rpc') as tempdir:
response = os.path.join(tempdir, 'steam-devkit-rpc')
parms = {
'response' : response,
}
for arg in conf.args:
(k, v) = re.split('=', arg)
parms[k] = v
cmd = f'{conf.command}/?{urllib.parse.urlencode(parms)}'
devkit_utils.execute_steam_client_command(cmd)
try:
with devkit_utils.wait_on_file_response(response) as success_response:
logger.info('success')
sys.stdout.write(success_response)
sys.exit(0)
except devkit_utils.SteamResponse_Timeout:
logger.error('timeout')
except devkit_utils.SteamResponse_Error as e:
logger.error('failed')
sys.stdout.write(e.error_response)
sys.exit(-1)
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
import sys
import os
import shutil
import logging
import argparse
import subprocess
import devkit_utils.resolve
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger(__name__)
DEVKIT_TOOL_FOLDER = os.path.expanduser('~/devkit-game')
def session_select_command():
if shutil.which('holo-session-select'):
return 'holo-session-select'
return 'steamos-session-select'
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--verbose', required=False, action='store_true')
parser.add_argument('--delete-title', required=False, action='store', help='Delete a devkit title by name')
parser.add_argument('--delete-all-titles', required=False, action='store_true', default=False, help='Delete all devkit titles uploaded')
parser.add_argument('--reset-steam-client', required=False, action='store_true', default=False, help='Reset Steam client and delete all local Steam content')
conf = parser.parse_args()
if conf.verbose:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
if conf.delete_all_titles:
subprocess.check_call('rm -rf ~/devkit-game/*', shell=True)
elif conf.delete_title:
gamepath = os.path.expanduser( os.path.join( '~/devkit-game', conf.delete_title ) )
if not os.path.isdir(gamepath):
print(f'Not found: {gamepath}')
else:
subprocess.check_call(f'rm -r {gamepath}', shell=True)
# synchronize the Steam client's view of the devkit games with the on disk state
try:
devkit_utils.resolve.resolve_shortcuts()
except Exception as e:
logger.warning(f'Steam client sync of devkit games failed: {e}')
if conf.reset_steam_client:
# first make sure any sideloaded trampoline has been deleted
devkit_steam_trampoline_path = os.path.join(DEVKIT_TOOL_FOLDER, 'devkit-steam')
if os.path.exists(devkit_steam_trampoline_path):
os.unlink(devkit_steam_trampoline_path)
# wipe the local Steam install
subprocess.check_call(f'rm -rf ~/.local/share/Steam', shell=True)
# restart the session, which will initiate a reinstall of Steam from the OS client
subprocess.check_call([session_select_command(), 'gamescope'])
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
import os
import logging
import argparse
import tempfile
import json
from urllib.parse import quote_plus as urllib_quote_plus
import devkit_utils
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger(__name__)
DEVKIT_TOOL_FOLDER = os.path.expanduser('~/devkit-game')
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--verbose', required=False, action='store_true')
parser.add_argument('--appid', required=False, action='store')
parser.add_argument('--gameid', required=False, action='store')
conf = parser.parse_args()
if conf.verbose:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
ret = {}
try:
devkit_utils.validate_steam_client()
except devkit_utils.SteamClientNotRunningException as e:
skipping = 'The Steam client is not running.'
logger.warning(skipping)
ret['error'] = skipping
else:
with tempfile.TemporaryDirectory(prefix='controller-config') as tempdir:
response = os.path.join(tempdir, 'dumpcontrollerconfig')
cmd = f'dumpcontrollerconfig?response={urllib_quote_plus(response)}'
if conf.appid:
cmd += f'&appid={conf.appid}'
if conf.gameid:
cmd += f'&gameid={conf.gameid}'
logger.debug(f'command: {cmd}')
devkit_utils.execute_steam_client_command(cmd)
try:
with devkit_utils.wait_on_file_response(response) as success_response:
logger.debug(success_response)
ret['success'] = success_response
except devkit_utils.SteamResponse_Timeout:
ret['error'] = 'timeout - Steam did not respond to the command request'
except devkit_utils.SteamResponse_Error as e:
ret['error'] = e.error_response
# response gets written out to stdout
print(json.dumps(ret))
+443
View File
@@ -0,0 +1,443 @@
#!/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'}")
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env python3
import os
import logging
import argparse
import getpass
import json
from subprocess import DEVNULL
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger(__name__)
DEVKIT_TOOL_FOLDER = os.path.expanduser('~/devkit-game')
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--verbose', required=False, action='store_true')
conf = parser.parse_args()
if conf.verbose:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
ret = []
if os.path.isdir(DEVKIT_TOOL_FOLDER):
for filename in os.listdir(DEVKIT_TOOL_FOLDER):
gamefolder = os.path.join(DEVKIT_TOOL_FOLDER, filename)
if os.path.isdir(gamefolder):
ret.append( {
'gameid': filename,
} )
print(json.dumps(ret))
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
import sys
import os
import logging
import argparse
import getpass
import json
import shutil
import subprocess
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger(__name__)
DEVKIT_TOOL_FOLDER = os.path.expanduser('~/devkit-game')
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--verbose', required=False, action='store_true')
parser.add_argument('--gameid', required=True, action='store')
parser.add_argument('--restart-steam', required=False, default='0', action='store')
parser.add_argument('--use-mask-unmask', required=False, default='0', action='store')
parser.add_argument('--prevent-auto-repair', required=False, default='0', action='store')
conf = parser.parse_args()
if conf.verbose:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
directory = os.path.join(
os.path.expanduser(DEVKIT_TOOL_FOLDER),
conf.gameid
)
os.makedirs(directory, exist_ok=True)
INHIBIT_SENTINEL = os.path.expanduser('~/.config/inhibit-short-session-tracker')
if int(conf.prevent_auto_repair) == 1:
open(INHIBIT_SENTINEL, 'w').close()
logger.info(f'Created sentinel file: {INHIBIT_SENTINEL}')
elif os.path.exists(INHIBIT_SENTINEL):
os.remove(INHIBIT_SENTINEL)
logger.info(f'Removed sentinel file: {INHIBIT_SENTINEL}')
ret = {
'user': getpass.getuser(),
'directory': directory,
}
print(json.dumps(ret))
@@ -0,0 +1,7 @@
#!/bin/bash
# meant to be executed remotely/interactively for password prompts
# there's some annoying trash at the top of the remote ssh screen
clear
passwd
sleep 2
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
import sys
import os
import logging
import argparse
import enum
import subprocess
import shutil
import pathlib
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger(__name__)
DEVKIT_TOOL_FOLDER = os.path.expanduser('~/devkit-game')
# NOTE: only relevant to Frame + OS client with extra arguments
# sideloaded client on Frame supports full command line edit instead
STEAM_EXTRA_ARGS_FILE = os.path.expanduser('~/.config/systemd/user/steam.service.d/extra_args.conf')
class SteamStatus(enum.Enum):
# Supported values from steamos-get-status
OS = 2
OS_DEV = 3
SIDELOADED = 4
STATUS_STRINGS = [
( SteamStatus.OS, 'SteamStatus.OS' ),
( SteamStatus.OS_DEV, 'SteamStatus.OS_DEV' ),
( SteamStatus.SIDELOADED, 'SteamStatus.SIDELOADED' ),
]
# gamescope-session passes the execution to this script if it exists rather than start steam itself
DEVKIT_STEAM_TRAMPOLINE = os.path.expanduser('~/devkit-game/devkit-steam')
# this script executes the sideloaded Steam client (part of the steamos-devkit-service package)
SIDE_LOADED_STEAM_CLIENT = '/usr/share/steamos-devkit/bin/devkit-standalone.py'
def write_trampoline(text):
with open(DEVKIT_STEAM_TRAMPOLINE, 'w') as devkit_steam:
devkit_steam.write(text)
devkit_steam.flush()
os.chmod(DEVKIT_STEAM_TRAMPOLINE, 0o770)
# trying really hard to avoid leaving a zero sized trampoline if the deck is about to hang on the session restart coming next
subprocess.run(['/usr/bin/sync', DEVKIT_TOOL_FOLDER])
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
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--verbose', required=False, action='store_true')
parser.add_argument('--client', action='store', required=True, choices=[ v[1] for v in STATUS_STRINGS ])
parser.add_argument('--args', action='store', required=False, help='steam client command line arguments')
parser.add_argument('--gameid', required=True, action='store')
parser.add_argument('--gdbserver', action='store_true', required=False)
conf = parser.parse_args()
if conf.verbose:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
target = [ v for v in STATUS_STRINGS if v[1] == conf.client ][0][0]
logging.info(f'Set steam client on device to {target}')
if os.path.exists(DEVKIT_STEAM_TRAMPOLINE):
os.unlink(DEVKIT_STEAM_TRAMPOLINE)
os_info = get_os_info()
assert os_info is not None
is_deckard = os_info.get('VARIANT_ID', None) == 'vr'
if target == SteamStatus.OS:
if is_deckard:
# conf.args is the extra arguments for the normal Steam 'OS client', update it now
if conf.args is None or conf.args == '':
logger.info('Clearning extra arguments for normal Steam client')
if os.path.exists(STEAM_EXTRA_ARGS_FILE):
os.unlink(STEAM_EXTRA_ARGS_FILE)
subprocess.run(['systemctl', '--user', 'daemon-reload'], check=True)
else:
logger.info(f'Setting extra arguments for normal Steam client: {conf.args}')
os.makedirs(os.path.dirname(STEAM_EXTRA_ARGS_FILE), exist_ok=True)
with open(STEAM_EXTRA_ARGS_FILE, 'wt') as extra_args_file:
escaped_args = conf.args.replace('"', '\\"')
extra_args_file.write(f'[Service]\nEnvironment="STEAM_EXTRA_ARGS={escaped_args}"')
subprocess.run(['systemctl', '--user', 'daemon-reload'], check=True)
# When disabling a sideloaded client, also delete the ~/.steam symlinks:
# They will be re-created by the OS client when starting,
# this prevents SteamVR trying to use the sideloaded binaries that are still there for the steam API.
# (this may happen because SteamVR starts before Steam starts and has a chance to set those symlinks correctly)
for path in pathlib.Path(os.path.expanduser('~/.steam')).glob('*'):
if path.is_symlink():
try:
lnk = path.resolve()
if 'devkit-game' in str(lnk):
path.unlink()
print(f'Deleted: {path} -> {lnk}')
except Exception as e:
print(f'Error processing {path}: {e}')
logger.info('Devkit Steam client override is disabled - default OS client execution will resume.')
sys.exit(0)
os.makedirs(os.path.dirname(DEVKIT_STEAM_TRAMPOLINE), exist_ok=True)
# OS client
steam_client = '$HOME/.local/share/Steam/steam.sh'
if target == SteamStatus.SIDELOADED:
steam_client = '$HOME/devkit-game/steam/steam.sh'
if is_deckard:
# RUNSTEAM.sh checks for SIDELOADED_STEAMROOT="${HOME}/devkit-game/steam"
# this is consistent with sideload on Steam Deck, but we use a different name 'steamdeckard'
# will be addressed when reworking the sideload and debug strategy, for now just drop in a symlink
steam_symlink = os.path.expanduser('~/devkit-game/steam')
if os.path.lexists(steam_symlink):
if os.path.islink(steam_symlink):
os.unlink(steam_symlink)
else:
# this happens if an upload in Steam Deck mode was attempted against a Steam Frame for instance
# was an easy mistake to make before recent changes
logger.warning('warning: ~/devkit-game/steam exists but is not a symlink. Removing anyway.')
shutil.rmtree(steam_symlink)
os.symlink(
os.path.expanduser('~/devkit-game/steamdeckard'),
steam_symlink,
)
args = '"$@"'
if conf.args is not None:
args = conf.args
gdbserver = ''
if conf.gdbserver:
logger.info('Configuring for remote debugging via gdbserver')
gdbserver = 'export DEBUGGER="gdbserver 0.0.0.0:2345"'
write_trampoline('''#!/bin/bash
# Generated by steamos-set-steam-client, do not edit!
# configuration tag (do not delete): {}
{}
mkdir -p $HOME/.steam/steam/logs
exec {} {}
'''.format(
conf.client,
gdbserver,
steam_client,
args
))