mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 04:04:21 +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,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)
|
||||
Reference in new issue
Block a user