mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 05:02:50 +02:00
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:
1 parent
1a0e54d8bd
commit
6c4d387ef3
26 files changed
+3191
-26
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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
|
||||
Executable
+107
@@ -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())
|
||||
@@ -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))
|
||||
Executable
+48
@@ -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)
|
||||
@@ -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))
|
||||
Executable
+443
@@ -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'}")
|
||||
@@ -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))
|
||||
@@ -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
|
||||
@@ -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
|
||||
))
|
||||
Reference in new issue
Block a user