mirror of
https://github.com/saphid/frame-control.git
synced 2026-10-06 01:00:18 +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>
158 lines
6.4 KiB
Python
158 lines
6.4 KiB
Python
#!/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
|
|
))
|