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