Sideload Linux and Windows builds as Steam Devkit Games

Dropping a game's .zip, folder or .exe on Send to Frame now adds it to the
headset's Steam library through Valve's SteamOS Devkit title path, with the
runtime picked from the program's header: Windows PE -> Proton Experimental
(steam_play=1), aarch64 ELF -> SteamLinuxRuntime_4-arm64, x86-64 ELF ->
SteamLinuxRuntime_4 (through FEX). Other architectures are refused.

- frame/devkit-utils: Valve's devkit-utils vendored unmodified (MIT,
  steamos-devkit v0.20260925.1), synced to ~/devkit-utils by stamp, bundled in
  the app and compiled in CI.
- ui/frame_titles.py: inspect (safe unzip, ELF/PE classification, launch
  target ranking), install(path, name=None, exe=None, runtime=None,
  progress=None), list, launch, remove, plus a CLI.
- ui/server.py: /api/titles (inspect/install/discard/launch/remove),
  /api/titles/job progress, and an upload mode 'title'.
- ui/index.html: confirm dialog (name, launch target, runtime), install
  progress, and a Sideloaded titles list with Launch and Remove. The app's
  preload passes a dropped folder's path.
- tests and docs/sideloading.md. Device-side behaviour is inferred from
  Valve's source; the headset was offline, so none of it has been checked on
  a Frame yet.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
saphidandClaude Opus 5.5 committed 2026-09-26 20:38:14 +10:00
1 parent 1a0e54d8bd
commit 6c4d387ef3
26 files changed
+3191 -26

No files matched your search

+4 -1
View File
@@ -27,7 +27,10 @@ jobs:
esac
done
- name: Python compiles
run: python -m py_compile ui/*.py apk-catalog/*.py frame/android/*.py
run: |
python -m py_compile ui/*.py apk-catalog/*.py frame/android/*.py
# Valve's devkit-utils (vendored; run by the Frame's python3). Most have no .py suffix.
python -m py_compile $(find frame/devkit-utils -type f ! -name '*.*' ! -name LICENSE) frame/devkit-utils/devkit_utils/*.py
- name: Server tests
run: python -m unittest discover -s tests -v
- name: App syntax
+7 -4
View File
@@ -58,8 +58,8 @@ About 4,500 F-Droid apps rated for the Frame. One click installs each as its own
<tr>
<td valign="top">
**📁 Files and clipboard**<br>
Drag files onto the window to send them. Send text or your clipboard straight to the headset's desktop.
**📁 Files, games and clipboard**<br>
Drag files onto the window to send them. Drop a game's .zip, folder or .exe to add it to the Steam library, with Proton or the Linux runtime picked for you. Send text or your clipboard straight to the headset's desktop.
</td>
<td valign="top">
@@ -86,7 +86,8 @@ SSH, SFTP, Steam Link, remote desktop, volume, sleep, restart and shut down.
</table>
Nothing is installed on the Frame for any of this: the app uses what SteamOS
already ships. [How each feature works](docs/frame-control.md).
already ships (sideloading a game copies Valve's own devkit scripts to
`~/devkit-utils`, as Valve's Devkit Client does). [How each feature works](docs/frame-control.md).
## Install
@@ -151,7 +152,8 @@ computer.
same network. For anywhere else, see [Tailscale](docs/tailscale.md).
**What it changes:** only what you click. Installs go to your user account on
the Frame (`--user` Flatpaks, Lepton instances, Steam downloads), and nothing
the Frame (`--user` Flatpaks, Lepton instances, Steam downloads, sideloaded
games in `~/devkit-game`), and nothing
needs `sudo` except the power buttons. On your computer it adds a `Host frame`
entry to `~/.ssh/config` and a key at `~/.ssh/id_ed25519_frame`.
@@ -177,6 +179,7 @@ Frame's software fits together, all checked against a real headset and labelled
| [Scripts and headset setup](docs/scripts.md) | The command-line helpers, minimum typing, streaming options, floating panels |
| [How the Frame works](docs/how-the-frame-works.md) | SteamVR → gamescope → Plasma, verified facts, debugging |
| [Android apps (Lepton)](docs/apks.md) | Sideloading, the rated F-Droid catalogue, per-app instances |
| [Sideloading Linux and Windows games](docs/sideloading.md) | A .zip, folder or .exe as a Steam Devkit Game, runtime detection |
| [Steam games](docs/steam-games.md) · [VR video](docs/vr-video.md) · [WebXR in Chromium](docs/webxr-chromium.md) | Installing and buying, watching VR180/360, the Chromium build |
| [SSH](docs/ssh.md) · [Streaming](docs/streaming.md) · [Files](docs/file-transfer.md) · [Panels](docs/panels.md) · [Tailscale](docs/tailscale.md) | Topic notes |
| [Open questions](docs/open-questions.md) | What's still unchecked |
+8
View File
@@ -55,6 +55,14 @@
"*.py"
]
},
{
"from": "../frame/devkit-utils",
"to": "frame/devkit-utils",
"filter": [
"**/*",
"!**/__pycache__/**"
]
},
{
"from": "../apk-catalog",
"to": "apk-catalog",
+5 -2
View File
@@ -1,7 +1,10 @@
// Lets the page read this computer's clipboard through Electron, so sending it
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard.
const { contextBridge, ipcRenderer } = require("electron");
// to the Frame needs no pbpaste, PowerShell, xclip or wl-clipboard. Also tells
// the page where a dropped file or folder lives, so a folder can be sideloaded
// as a title without zipping it (the local server reads it from there).
const { contextBridge, ipcRenderer, webUtils } = require("electron");
contextBridge.exposeInMainWorld("frameApp", {
readClipboard: () => ipcRenderer.invoke("clipboard:read"),
pathForFile: (file) => { try { return webUtils.getPathForFile(file) || ""; } catch { return ""; } },
});
+6 -3
View File
@@ -56,7 +56,10 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
(0.85–1.3×) over ADB (`wm size`, `wm density`, `font_scale`). Reset puts all
three back. Whether the settings survive the app relaunching is untested.
- **Transfer**: drag and drop files to `~/Downloads`; `.apk` files install as
their own Android app. Send typed text, or your computer's clipboard, to the
their own Android app. A game's `.zip`, folder or `.exe` becomes a title in
the Steam library (Valve's Devkit Game path, with Proton or the Steam Linux
Runtime picked from the program's header), listed under **Sideloaded titles**
with Launch and Remove; see [sideloading.md](sideloading.md). Send typed text, or your computer's clipboard, to the
Frame clipboard.
- **Flatpaks**: install and remove them (quick picks: Moonlight, Firefox, VLC,
Remmina).
@@ -69,7 +72,7 @@ python3 ui/server.py # anywhere: then open http://127.0.0.1:47810
`app/` is an Electron shell. It starts `ui/server.py` on a free loopback port
and shows it in its own window; the server stops when you quit the app. The
app bundles `ui/`, `scripts/`, `frame/android/` and the rated catalogue from
app bundles `ui/`, `scripts/`, `frame/android/`, Valve's `frame/devkit-utils/` and the rated catalogue from
`apk-catalog/`, plus a standalone Python
([python-build-standalone](https://github.com/astral-sh/python-build-standalone))
and `adb` from Google's platform-tools, so there's nothing else to install.
@@ -93,7 +96,7 @@ library capsules and green Play buttons.
both capture modes (headset view while in use, and a blank frame in standby,
which the UI labels), clipboard, volume, file push, and input validation.
**Not yet exercised from the UI:** Launch, Flatpak install/remove, APK drop,
and the power buttons. Each of these calls a command that was verified
title sideloading (not yet run on a headset at all), and the power buttons. Each of these calls a command that was verified
separately.
## Per-platform notes
+153
View File
@@ -0,0 +1,153 @@
# Sideloading Linux and Windows games
A game you have as files (an itch.io download, your own build, a DRM-free
release) can go into the Frame's Steam library without a Steam store page.
Frame Control uses the same path as Valve's
[SteamOS Devkit Client](https://gitlab.steamos.cloud/devkit/steamos-devkit):
the title becomes a Steam **Devkit Game**, with a runtime (Proton or a Steam
Linux Runtime) chosen from the program itself.
For Android APKs, see [apks.md](apks.md) instead.
**Status: nothing here has run on a headset yet.** Every device-side step is
**inferred from Valve's steamos-devkit source** (release v0.20260925.1). The
local steps (reading the zip, picking the program and runtime, building the
request) are covered by `tests/test_frame_titles.py`.
## Using it
Drop a game's `.zip`, folder or `.exe` on **Send to Frame**. (Folders need the
desktop app, which knows where a dropped folder lives; in a plain browser, zip
it.) A dialog shows:
- **Name**: what Steam shows. Steam uses the title id as the name, so it's
limited to letters, digits, `_` and `-`; the dialog shows the result.
- **Launches**: the program picked to start the game, with the other
candidates in the list.
- **Runtime**: picked from the program, see below. Windows programs can switch
between Proton Experimental and Proton (stable).
Install copies it to the Frame and registers it with Steam; progress shows in
the bar and the activity log. **Sideloaded titles** lists what's installed,
with Launch and Remove. **Copy to ~/Downloads instead** keeps the old
behaviour for a zip that isn't a game.
From a terminal:
```sh
python3 ui/frame_titles.py inspect Game.zip # what would be installed, no headset needed
python3 ui/frame_titles.py install Game.zip [--name N] [--exe REL] [--runtime R]
python3 ui/frame_titles.py list | launch ID | remove ID
```
## Choosing the runtime
The program's header decides, not its file name:
| Program | Runtime (Steam compat tool) | `steam_play` | Confidence |
|---|---|---|---|
| Windows `.exe`, x86-64 (PE machine `0x8664`) | `proton-experimental` | 1 | Inferred: ARM64 Proton runs x86-64 code through FEX |
| Windows `.exe`, 32-bit x86 (`0x14c`) or ARM64 (`0xaa64`) | `proton-experimental` | 1 | Inferred |
| Linux ELF, aarch64 (`e_machine` `0xB7`) | `SteamLinuxRuntime_4-arm64` | 0 | Inferred: native |
| Linux ELF, x86-64 (`0x3E`) | `SteamLinuxRuntime_4` | 0 | Inferred: runs through FEX; the least certain row |
| Shell script | the runtime of the Linux binary beside it, else `SteamLinuxRuntime_4-arm64` | 0 | Guess |
| Anything else (32-bit Linux, other CPUs, DLLs, data) | refused with a message | | |
Proton Experimental is the default rather than stable because the Frame's
ARM64 Proton and FEX stack is new and Proton fixes reach Experimental first.
If a game misbehaves, reinstall it with Proton (stable).
The aliases and settings are the ones Valve's client sends: `RUNTIME_ALIASES`
in `devkit_client/__init__.py`, and `gui2._update_game`, which sets
`steam_play=1, steam_play_debug=0, steam_play_debug_version=2019` for Proton
and `steam_play=0` otherwise, plus `compat_tool=<alias>`. Valve's client only
offers `SteamLinuxRuntime_4-arm64` and Lepton when the device reports itself
as Deckard (the Frame).
## Picking the program
`ui/frame_titles.py` reads every file's header: ELF executables (PIE ones are
told from shared libraries by their `PT_INTERP` segment), PE executables (not
DLLs) and scripts with `#!`. A zip with a single top-level folder is treated
as that folder. Candidates are ranked by:
1. Not a helper: names like `UnityCrashHandler64`, `CrashReportClient`,
`*setup*`, `unins*`, `vc_redist*`, `dxsetup`, `*prereq*`, and anything under
`_CommonRedist`, `Redist`, `DirectX` or `Engine` go last.
2. Platform: native ARM64 Linux, then Windows x86-64, then x86-64 Linux, then
other Windows builds.
3. Name: a program named like the zip or folder (build words such as
`-linux-arm64` or `_v1.2` are dropped from the name).
4. Depth, then size: Unreal's top-level `Game.exe` beats
`Game/Binaries/Win64/Game-Win64-Shipping.exe`.
A top-level shell script beats a Linux binary one folder down (`run.sh` +
`bin/game`); a binary next to a script wins. The list in the dialog lets you
pick another.
## What happens on the Frame (inferred)
1. **Tools.** `frame/devkit-utils/` (Valve's scripts, vendored unmodified, MIT)
is copied to `~/devkit-utils`, where Valve's client puts it, unless the
stamp file there already matches. Files are merged, not replaced, so a
newer copy from Valve's client keeps its extra files.
2. **Folder.** `python3 ~/devkit-utils/steamos-prepare-upload --gameid ID`
makes `~/devkit-game/ID` and prints `{user, directory}`.
3. **Copy.** The files go there with `rsync -a --delete` on macOS and Linux,
or `scp -r` into a fresh folder that then replaces it on Windows. Then
`chmod -R 755`, the modes Valve's client gives an upload.
4. **Register.** `python3 ~/devkit-utils/steam-client-create-shortcut --parms JSON`
with `{gameid, directory, argv: [target], env: {}, settings, clear_settings,
force_appid: "", lepton_args: ""}`. It writes `ID-argv.json`,
`ID-env.json` and `ID-settings.json` next to the folder, then sends
`create-shortcut` to the running Steam client over `~/.steam/steam.pipe`
(authenticated by `~/.steam/steam.token`) and waits up to 5 s for Steam's
answer file. Its `error`, for example "The Steam client is not running",
is shown as the install error. The files stay, so installing again with
Steam running finishes the job.
5. **Launch** is `steam-devkit-rpc run-game gameid=ID`. **Remove** is
`steamos-delete --delete-title ID`, which deletes the folder and has Steam
drop shortcuts with no folder. Frame Control then removes the `ID-*.json`
files that Valve's script leaves behind.
Frame Control also writes `~/devkit-game/ID-framecontrol.json` (name, source
file, target, runtime, size). **Sideloaded titles** lists every folder in
`~/devkit-game`, including titles uploaded with Valve's client.
`argv` is one string, as in Valve's client (the start command may carry
arguments), so a program path with spaces is sent in double quotes. How Steam
splits that string is **not checked**.
## Safety
- Zips are unpacked on your computer first. Entries with absolute paths, `..`,
drive letters or `:` anywhere in the path, or links that point outside the
zip (or at a folder they're in) are refused. So are zips over 64 GB
unpacked, over 200,000 entries, more than 200× compressed past 1 GB, or
bigger than the free space.
- No symlink is created while unpacking, so no write can be redirected
through one. A link to a file inside the zip (`libfoo.so.1 → libfoo.so.1.2`)
becomes a copy of that file, which also works on Windows. Links to folders,
loops and dangling links are left out.
- A dropped folder that contains symlinks (or Windows junctions) is copied on your computer first,
with the same rule, because `scp -r` would follow a link out of the folder
and upload whatever it points at.
- Installs run one at a time, and Remove is refused while one runs.
- The title id is limited to `[A-Za-z0-9_-]`, at most 64 characters. Valve's
scripts pass it to a shell (`steamos-delete` runs `rm -r` on it). Valve's
reserved sideload names (`steam`, `steamvr`, and their `deckard` forms,
which would replace the Steam client itself) get `-game` added.
- Nothing needs `sudo`; everything goes to your home folder on the Frame.
- In the app, a dropped folder is read from its local path by the app's own
server, which only accepts requests from its own page (see
[frame-control.md](frame-control.md#how-it-works)).
## To check on a headset
- [ ] `create-shortcut` registers a title and it shows in the library under its id.
- [ ] An aarch64 build launches in `SteamLinuxRuntime_4-arm64`.
- [ ] An x86-64 Windows `.exe` launches under Proton Experimental through FEX.
- [ ] An x86-64 Linux build launches in `SteamLinuxRuntime_4` through FEX.
- [ ] `steam-devkit-rpc run-game` starts it, and `steamos-delete` removes the shortcut.
- [ ] How Steam splits a start command with a quoted path.
- [ ] Whether these titles open as flat panels or need anything VR-specific.
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2017-2022 Valve Software inc., Collabora Ltd
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+18
View File
@@ -0,0 +1,18 @@
# Valve's devkit-utils (vendored)
Unmodified copy of `client/devkit-utils/` from Valve's
[SteamOS Devkit Client](https://gitlab.steamos.cloud/devkit/steamos-devkit),
MIT licensed (see `LICENSE`; Valve's own notes are in `VALVE-README.md`).
- Source: steamos-devkit, commit `6f0711a` ("Code drop."),
release **v0.20260925.1** (ChangeLog entry dated 2026-09-25).
`ui/frame_titles.py` copies this folder to `~/devkit-utils` on the Frame (where
Valve's own client puts it) and uses `steamos-prepare-upload`,
`steam-client-create-shortcut`, `steam-devkit-rpc` and `steamos-delete` to
register uploaded builds as Steam "Devkit Games". See `docs/sideloading.md`.
To update: copy the folder from a newer checkout over this one, keep this
README, and update the version line above. The stamp Frame Control compares
on the headset is a hash of these files, so a changed copy is re-synced on the
next use.
+4
View File
@@ -0,0 +1,4 @@
These scripts and supporting utility module are uploaded to the devkit by the devkit client:
- steamos-* : scripts that operate (mostly) at SteamOS level for devkit functionality purposes
- steam-client-* : scripts that relay commands to the local running Steam client
+107
View File
@@ -0,0 +1,107 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import sys
import os
import time
import subprocess
import logging
import argparse
import json
import datetime
import io
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger(__name__)
def main():
parser = argparse.ArgumentParser(description='Capture screenshot and videos on Steam Frame device')
parser.add_argument('--filename', '-f',
default='/tmp/screenshot.png',
help='Output')
parser.add_argument('--timestamp', action='store_true',
help='Add timestamp')
parser.add_argument('--json', action='store_true',
help='Output result as JSON')
args = parser.parse_args()
output_buffer = io.StringIO()
try:
steamvr_path = subprocess.check_output(['steamvr', 'path'], stderr=subprocess.STDOUT, universal_newlines=True).strip()
cdd = os.path.join(steamvr_path, 'bin/linuxarm64')
run_vrcmd = os.path.join(cdd, 'vrcmd')
assert os.path.exists(run_vrcmd), "vrcmd not found"
# Enable recording
cmd = [run_vrcmd, '--mailboxcmd', 'vrcompositor_systemlayer', 'set_local_video_record?enabled=true']
output_buffer.write(f"Command: {' '.join(cmd)}\n")
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
output_buffer.write(result.stdout)
if result.returncode != 0:
raise subprocess.CalledProcessError(result.returncode, cmd)
# Wait for the video device to produce frames.
# Note that even when disabled it outputs roughly 2 blank frames per second.
cmd = ['timeout', '1', 'ffmpeg', '-f', 'v4l2', '-i', '/dev/video99', '-frames:v', '4', '-f', 'null', '-', '-v', 'error']
output_buffer.write(f"Command: {' '.join(cmd)}\n")
retries = 2
while True:
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
output_buffer.write(result.stdout)
if result.returncode == 0:
break
retries -= 1
if retries <= 0:
raise Exception("Failed to get video frames from /dev/video99. Is VR active? Is the v4l2 configuration correct?")
output_filename = args.filename
if args.timestamp:
timestamp = datetime.datetime.now().strftime("%Y-%m-%d-%H-%M-%S")
base, ext = os.path.splitext(output_filename)
output_filename = f"{base}-{timestamp}{ext}"
# Capture screenshot
cmd = ['ffmpeg', '-f', 'v4l2', '-i', '/dev/video99', '-frames:v', '1', '-q:v', '1', '-y', output_filename]
output_buffer.write(f"Command: {' '.join(cmd)}\n")
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
output_buffer.write(result.stdout)
if result.returncode != 0:
raise subprocess.CalledProcessError(result.returncode, cmd)
# Disable recording
cmd = [run_vrcmd, '--mailboxcmd', 'vrcompositor_systemlayer', 'set_local_video_record?enabled=false']
output_buffer.write(f"Command: {' '.join(cmd)}\n")
result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
output_buffer.write(result.stdout)
if result.returncode != 0:
raise subprocess.CalledProcessError(result.returncode, cmd)
except Exception as e:
error_msg = str(e)
# Print collected output to stderr on error
print(output_buffer.getvalue(), file=sys.stderr)
logger.error(error_msg)
if args.json:
result = {
'success': False,
'error': error_msg
}
print(json.dumps(result))
else:
print(f"Error: {error_msg}", file=sys.stderr)
return 1
if args.json:
result = {
'success': True,
'output': output_filename
}
print(json.dumps(result))
else:
print(f"Screenshot saved to {output_filename}")
if __name__ == '__main__':
sys.exit(main())
+300
View File
@@ -0,0 +1,300 @@
#!/usr/bin/env python
# encoding: utf-8
"""Utility functions for the Steam client hook scripts"""
import sys
import os
import traceback
import tempfile
import json
import logging
import fcntl
import errno
import contextlib
import time
import fcntl
import logging as logging_module
logger = logging_module.getLogger(__name__)
@contextlib.contextmanager
def wrap_outputs(stderr_prefix):
# capture stderr to file to support debugging
stderr_fd = sys.stderr.fileno()
tf = tempfile.NamedTemporaryFile(
mode='w+',
prefix=stderr_prefix,
delete=True)
if sys.version_info >= (3, 4):
# this API in the os module is only available for python3
# but it does not seem to work with subprocess anyway
os.set_inheritable(tf.file.fileno(), True)
assert os.get_inheritable(tf.file.fileno())
sys.stderr = tf.file
# we can only write out a json response to stdout,
# so redirect stdout to stderr,
# and keep a handle on the original stdout for the response
stdout_fd = os.dup(sys.stdout.fileno())
os.dup2(sys.stderr.fileno(), sys.stdout.fileno())
ctx = {}
try:
yield ctx
except:
logger.error(traceback.format_exc())
finally:
tf.flush()
tf.seek(0)
os.write(stderr_fd, tf.read().encode('utf-8'))
if 'ret' in ctx:
os.write(stdout_fd, json.dumps(ctx['ret']).encode('utf-8'))
class SteamClientNotRunningException(Exception):
def __init__(self, error_message):
self.error_message = error_message
def __str__(self):
return self.error_message
def validate_steam_client():
"""Verify that the steam client is running, and permissions are adequate"""
pid_path = os.path.normpath(
os.path.realpath(
os.path.expanduser('~/.steam/steam.pid')))
if not os.path.exists(pid_path):
raise SteamClientNotRunningException('{0} does not exist'.format(pid_path))
try:
pid = int(open(pid_path, 'rt').read())
except Exception:
raise SteamClientNotRunningException('{0} is invalid'.format(pid_path))
try:
os.kill(pid, 0)
except OSError:
raise SteamClientNotRunningException('{0} does not refer to a valid process'.format(pid_path))
logger.info('Found steam client pid %s', pid)
def execute_steam_client_command(cmd):
"""Send a command to the steam client over the IPC pipe"""
pipe_path = os.path.normpath(
os.path.realpath(
os.path.expanduser('~/.steam/steam.pipe')))
try:
pipe = open(pipe_path, 'wb+', 0)
except IOError:
raise Exception('cannot open steam client pipe')
session_token = open(os.path.expanduser('~/.steam/steam.token')).read()
#pipe_cmd = 'steam://{0}'.format(cmd)
# ^ hack to execute a normal command over the IPC directly - sometimes useful
pipe_cmd = 'devkit-1 steam://devkit-1/{0}/{1}'.format(
session_token,
cmd
)
logger.debug('Sending command line:')
logger.debug(pipe_cmd)
pipe.write('{0}\n'.format(pipe_cmd).encode('utf-8'))
pipe.close()
def save_argv(gameid, argv):
"""Save command line and arguments if provided"""
if argv is None:
return
argvfile = os.path.join(os.getenv("HOME"), "devkit-game",
gameid + "-argv.json")
try:
with open(argvfile, "w") as argvf:
fcntl.flock(argvf, fcntl.LOCK_EX)
json.dump(argv, argvf)
fcntl.flock(argvf, fcntl.LOCK_UN)
except IOError:
raise Exception(
"Unable to open argv file for writing: {0}".format(argvfile))
def obtain_argv(gameid, argv):
"""Obtain command line with arguments"""
# If present and not None or [], just return the local arguments
if argv:
return argv
# From here, expect arguments to have been saved previously
argvfile = os.path.join(os.getenv("HOME"), "devkit-game",
gameid + "-argv.json")
try:
with open(argvfile, "r") as argvf:
fcntl.flock(argvf, fcntl.LOCK_EX)
argv = json.load(argvf)
fcntl.flock(argvf, fcntl.LOCK_UN)
except IOError:
raise Exception(
"Unable to open argv file for reading: {0}".format(argvfile))
return argv
def save_env(gameid, env):
"""Save environment variables if provided"""
if not env:
return
envfile = os.path.join(os.getenv("HOME"), "devkit-game",
gameid + "-env.json")
try:
with open(envfile, "w") as envf:
fcntl.flock(envf, fcntl.LOCK_EX)
json.dump(env, envf)
fcntl.flock(envf, fcntl.LOCK_UN)
except IOError:
raise Exception(
"Unable to open env file for writing: {0}".format(envfile))
def obtain_env(gameid):
"""Obtain environment variables for a game, if any were saved"""
envfile = os.path.join(os.getenv("HOME"), "devkit-game",
gameid + "-env.json")
try:
with open(envfile, "r") as envf:
fcntl.flock(envf, fcntl.LOCK_EX)
env = json.load(envf)
fcntl.flock(envf, fcntl.LOCK_UN)
except IOError:
return {}
return env
def save_settings(gameid, data):
"""Save settings"""
settingsfile = os.path.join(os.getenv("HOME"), "devkit-game",
gameid + "-settings.json")
settings = dict()
if data.get('clear_settings', False):
settings = {}
else:
try:
with open(settingsfile, "r") as f:
fcntl.flock(f, fcntl.LOCK_EX)
settings = json.load(f)
fcntl.flock(f, fcntl.LOCK_UN)
except IOError as e:
if (e.errno != errno.ENOENT):
raise
# Merge settings from new json
if 'settings' in data:
settings.update(data['settings'])
try:
with open(settingsfile, "w") as f:
fcntl.flock(f, fcntl.LOCK_EX)
json.dump(settings, f)
fcntl.flock(f, fcntl.LOCK_UN)
except (IOError):
raise Exception(
"Unable to open settings file for writing: {0}".format(
settingsfile
))
return settings
def load_settings(gameid):
settingsfile = os.path.join(os.getenv("HOME"), "devkit-game", gameid + '-settings.json')
if not os.path.isfile(settingsfile):
return None
with open(settingsfile, "r") as f:
fcntl.flock(f, fcntl.LOCK_EX)
settings = json.load(f)
fcntl.flock(f, fcntl.LOCK_UN)
return settings
class SteamResponse_Timeout(Exception):
pass
class SteamResponse_Error(Exception):
def __init__(self, error_response):
self.error_response = error_response
def __str__(self):
return self.error_response
@contextlib.contextmanager
def wait_on_file_response(path, timeout=5):
"""
The pipe to the Steam Client is one way.
Responses from the Steam Client are written to filesystem.
Protocol is as follows:
- Steam Client creates a 'path.lock' file
- Steam Client writes either 'path' or 'path.error' to indicate a problem
- Steam Client deletes 'path.lock'
- Caller (us) can then read the response
NOTE 1: this function is used as a context manager and will block until a response comes in or timeout.
NOTE 2: the files are created by Steam when responding to a command. If the files already exist the response protocol will break.
"""
lock_path = '{0}.lock'.format(path)
error_path = '{0}.error'.format(path)
max_count = timeout
while True:
time.sleep(1)
if os.path.exists(error_path) or os.path.exists(path) and not os.path.exists(lock_path):
if os.path.exists(error_path):
with open(error_path, 'r') as f:
fcntl.flock(f, fcntl.LOCK_EX)
error_response = f.read()
fcntl.flock(f, fcntl.LOCK_UN)
raise SteamResponse_Error(error_response)
with open(path, 'r') as f:
fcntl.flock(f, fcntl.LOCK_EX)
success_response = f.read()
yield success_response
fcntl.flock(f, fcntl.LOCK_UN)
return
max_count -= 1
if max_count > 0:
continue
raise SteamResponse_Timeout()
# Setting up as a context manager so we never miss the deletion
# Creating a temporary .lock file to guard the create operation
@contextlib.contextmanager
def create_pid(pid_path):
os.makedirs(os.path.dirname(pid_path), exist_ok=True)
lock_path = '{0}.lock'.format(pid_path)
try:
lock_file = os.open(lock_path, os.O_CREAT | os.O_EXCL)
except IOError as e:
logger.error('cannot create lock file %s for pid file %s', lock_path, pid_path)
logger.error('remove the lock file manually and run again if you are confident no other instance is active')
raise
pid_file = open(pid_path,'w')
pid_file.write(str(os.getpid()))
pid_file.flush()
os.close(lock_file)
os.unlink(lock_path)
try:
yield pid_file
finally:
pid_file.close()
# Assume that's atomic and all is well, no need for another .lock
os.unlink(pid_path)
@@ -0,0 +1,87 @@
#!/usr/bin/env python3
import sys
import os
import logging
from urllib.parse import quote_plus as urllib_quote_plus
import json
import tempfile
from . import validate_steam_client
from . import execute_steam_client_command
from . import wait_on_file_response
import logging as logging_module
logger = logging_module.getLogger(__name__)
def resolve_shortcuts():
# make sure there is a steam client online that we can talk to before doing anything
validate_steam_client()
# scan the devkit games
installed_gameids = set([])
devkit_game_path = os.path.expanduser('~/devkit-game')
if not os.path.exists(devkit_game_path):
logger.info('%r does not exist, creating', devkit_game_path)
os.mkdir(devkit_game_path)
entries = sorted(os.scandir(devkit_game_path), key=lambda entry: entry.name)
directories = [e for e in entries if e.is_dir()]
for d in directories:
gameid = d.name
file_names = [f.name for f in entries if f.is_file() and f.name.startswith(gameid)]
has_argv = '{0}-argv.json'.format(gameid) in file_names
has_settings = '{0}-settings.json'.format(gameid) in file_names
if (not has_argv and not has_settings):
logger.info('Subfolder %r in %r is not accompanied by devkit configuration files, ignoring', d.name, devkit_game_path)
continue
logger.info('Found installed Devkit Game: %r', gameid)
installed_gameids.add(gameid)
# ask the Steam Client which Devkit Games are registered
with tempfile.TemporaryDirectory(prefix='list-shortcuts') as tempdir:
response = os.path.join(tempdir, 'shortcuts.json')
cmd = 'list-shortcuts?response={}'.format(
urllib_quote_plus(os.path.join(response))
)
# send the request
execute_steam_client_command(cmd)
with wait_on_file_response(response) as response:
client_shortcuts = json.loads(response)
logger.debug(client_shortcuts)
assert client_shortcuts['version'] == 2
registered_gameids = set([])
logger.info('Steam Client has %d registered devkit game(s)', len(client_shortcuts['gameids']))
for gameid in client_shortcuts['gameids']:
logger.info('Found Devkit Game registered with Steam Client: %r', gameid)
registered_gameids.add(gameid)
# any registered game that is not found installed on disk needs to be removed
for remove_gameid in registered_gameids - installed_gameids:
with tempfile.TemporaryDirectory(prefix='delete-shortcut') as tempdir:
logger.info('Removing stale registered Devkit Game: %r', remove_gameid)
response = os.path.join(tempdir, 'shortcut-deleted')
cmd = 'delete-shortcut?response={}&gameid={}'.format(
urllib_quote_plus(response),
remove_gameid
)
execute_steam_client_command(cmd)
with wait_on_file_response(response) as response:
logger.info('from Steam Client: %s', response.strip())
# any installed game that is not found registered needs to be added
for add_gameid in installed_gameids - registered_gameids:
with tempfile.TemporaryDirectory(prefix='create-shortcut') as tempdir:
logger.info('Registering installed Dekit Game: %r', add_gameid)
response = os.path.join(tempdir, 'registered')
cmd = 'create-shortcut?response={}&gameid={}&directory={}'.format(
urllib_quote_plus(response),
add_gameid,
urllib_quote_plus(devkit_game_path)
)
execute_steam_client_command(cmd)
with wait_on_file_response(response) as response:
logger.info('from Steam Client: %s', response.strip())
if __name__ == '__main__':
resolve_shortcuts()
@@ -0,0 +1,92 @@
#!/usr/bin/env python3
import os
import logging
import argparse
import json
import platform
import tempfile
from urllib.parse import quote_plus as urllib_quote_plus
import devkit_utils
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger()
DEVKIT_TOOL_FOLDER = os.path.expanduser('~/devkit-game')
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--verbose', required=False, action='store_true')
parser.add_argument('--parms', required=True, action='store')
conf = parser.parse_args()
if conf.verbose:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
parms = json.loads(conf.parms)
gameid = parms['gameid']
directory = parms['directory']
assert os.path.isdir(directory)
force_appid = parms['force_appid']
steam_appid_path = os.path.join(directory, 'steam_appid.txt')
if force_appid:
with open(steam_appid_path, 'w') as f:
f.write(force_appid + '\n')
logger.info(f'Wrote {steam_appid_path} with AppID {force_appid}')
elif os.path.exists(steam_appid_path):
# don't overwrite an existing steam_appid.txt file from the content tree
# NOTE: if the user sets an AppID through the tool, then delete it, we may leave it in place ..
# (that's ok for now, do a clean upload if you want to get rid of it)
logger.info(f'{steam_appid_path} already exists, leaving it in place')
# Lepton (Android runtime) titles: write UECommandLine.txt next to the .apk
is_lepton = parms['settings']['compat_tool'] == 'lepton'
uecommandline = parms['lepton_args'] if is_lepton else ''
uecommandline_path = os.path.join(directory, 'UECommandLine.txt')
if uecommandline:
with open(uecommandline_path, 'w') as f:
f.write(uecommandline + '\n')
logger.info(f'Wrote {uecommandline_path}')
elif os.path.exists(uecommandline_path):
# don't overwrite an existing UECommandLine.txt file from the content tree
# NOTE: if the user sets cmdline args through the tool, then clears them, we may leave it in place ..
# (that's ok for now, do a clean upload if you want to get rid of it)
logger.info(f'{uecommandline_path} already exists, leaving it in place')
logger.info(f'Updating command line and runtime settings for {gameid} on {platform.node()}')
devkit_utils.save_argv(gameid, parms['argv'])
devkit_utils.save_env(gameid, parms['env'])
devkit_utils.save_settings(gameid, parms)
ret = {}
try:
devkit_utils.validate_steam_client()
except devkit_utils.SteamClientNotRunningException as e:
skipping = 'The Steam client is not running. Registration did not complete.'
logger.warning(skipping)
ret['error'] = skipping
else:
with tempfile.TemporaryDirectory(prefix='create-shortcut') as tempdir:
logger.info(f'Registering Devkit Game {gameid} with Steam Client')
response = os.path.join(tempdir, 'registered')
cmd = 'create-shortcut?response={}&gameid={}'.format(
urllib_quote_plus(response),
gameid,
)
devkit_utils.execute_steam_client_command(cmd)
try:
with devkit_utils.wait_on_file_response(response) as success_response:
logger.debug(success_response)
ret['success'] = success_response
except devkit_utils.SteamResponse_Timeout:
ret['error'] = 'timeout - Steam client did not respond to registration request'
except devkit_utils.SteamResponse_Error as e:
ret['error'] = e.error_response
# response gets written out to stdout
print(json.dumps(ret))
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
import sys
import os
import logging
import argparse
import tempfile
import urllib.parse
import re
import devkit_utils
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger()
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('command')
parser.add_argument('args', nargs='*')
conf = parser.parse_args()
try:
devkit_utils.validate_steam_client()
except devkit_utils.SteamClientNotRunningException as e:
logger.error(repr(e))
sys.exit(-1)
else:
with tempfile.TemporaryDirectory(prefix='steam-devkit-rpc') as tempdir:
response = os.path.join(tempdir, 'steam-devkit-rpc')
parms = {
'response' : response,
}
for arg in conf.args:
(k, v) = re.split('=', arg)
parms[k] = v
cmd = f'{conf.command}/?{urllib.parse.urlencode(parms)}'
devkit_utils.execute_steam_client_command(cmd)
try:
with devkit_utils.wait_on_file_response(response) as success_response:
logger.info('success')
sys.stdout.write(success_response)
sys.exit(0)
except devkit_utils.SteamResponse_Timeout:
logger.error('timeout')
except devkit_utils.SteamResponse_Error as e:
logger.error('failed')
sys.stdout.write(e.error_response)
sys.exit(-1)
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
import sys
import os
import shutil
import logging
import argparse
import subprocess
import devkit_utils.resolve
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger(__name__)
DEVKIT_TOOL_FOLDER = os.path.expanduser('~/devkit-game')
def session_select_command():
if shutil.which('holo-session-select'):
return 'holo-session-select'
return 'steamos-session-select'
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--verbose', required=False, action='store_true')
parser.add_argument('--delete-title', required=False, action='store', help='Delete a devkit title by name')
parser.add_argument('--delete-all-titles', required=False, action='store_true', default=False, help='Delete all devkit titles uploaded')
parser.add_argument('--reset-steam-client', required=False, action='store_true', default=False, help='Reset Steam client and delete all local Steam content')
conf = parser.parse_args()
if conf.verbose:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
if conf.delete_all_titles:
subprocess.check_call('rm -rf ~/devkit-game/*', shell=True)
elif conf.delete_title:
gamepath = os.path.expanduser( os.path.join( '~/devkit-game', conf.delete_title ) )
if not os.path.isdir(gamepath):
print(f'Not found: {gamepath}')
else:
subprocess.check_call(f'rm -r {gamepath}', shell=True)
# synchronize the Steam client's view of the devkit games with the on disk state
try:
devkit_utils.resolve.resolve_shortcuts()
except Exception as e:
logger.warning(f'Steam client sync of devkit games failed: {e}')
if conf.reset_steam_client:
# first make sure any sideloaded trampoline has been deleted
devkit_steam_trampoline_path = os.path.join(DEVKIT_TOOL_FOLDER, 'devkit-steam')
if os.path.exists(devkit_steam_trampoline_path):
os.unlink(devkit_steam_trampoline_path)
# wipe the local Steam install
subprocess.check_call(f'rm -rf ~/.local/share/Steam', shell=True)
# restart the session, which will initiate a reinstall of Steam from the OS client
subprocess.check_call([session_select_command(), 'gamescope'])
@@ -0,0 +1,57 @@
#!/usr/bin/env python3
import os
import logging
import argparse
import tempfile
import json
from urllib.parse import quote_plus as urllib_quote_plus
import devkit_utils
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger(__name__)
DEVKIT_TOOL_FOLDER = os.path.expanduser('~/devkit-game')
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--verbose', required=False, action='store_true')
parser.add_argument('--appid', required=False, action='store')
parser.add_argument('--gameid', required=False, action='store')
conf = parser.parse_args()
if conf.verbose:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
ret = {}
try:
devkit_utils.validate_steam_client()
except devkit_utils.SteamClientNotRunningException as e:
skipping = 'The Steam client is not running.'
logger.warning(skipping)
ret['error'] = skipping
else:
with tempfile.TemporaryDirectory(prefix='controller-config') as tempdir:
response = os.path.join(tempdir, 'dumpcontrollerconfig')
cmd = f'dumpcontrollerconfig?response={urllib_quote_plus(response)}'
if conf.appid:
cmd += f'&appid={conf.appid}'
if conf.gameid:
cmd += f'&gameid={conf.gameid}'
logger.debug(f'command: {cmd}')
devkit_utils.execute_steam_client_command(cmd)
try:
with devkit_utils.wait_on_file_response(response) as success_response:
logger.debug(success_response)
ret['success'] = success_response
except devkit_utils.SteamResponse_Timeout:
ret['error'] = 'timeout - Steam did not respond to the command request'
except devkit_utils.SteamResponse_Error as e:
ret['error'] = e.error_response
# response gets written out to stdout
print(json.dumps(ret))
+443
View File
@@ -0,0 +1,443 @@
#!/usr/bin/env python3
import sys
import os
import re
import shutil
import subprocess
import logging
import enum
import argparse
import json
import shlex
import datetime
import pathlib
import socket
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger(__name__)
DEVKIT_TOOL_FOLDER = os.path.expanduser('~/devkit-game')
STEAM_EXTRA_ARGS_FILE = os.path.expanduser('~/.config/systemd/user/steam.service.d/extra_args.conf')
WIRELESS_DISABLE_POWER_MANAGEMENT = '/usr/bin/steamos-polkit-helpers/steamos-disable-wireless-power-management'
# Must match in gui2.py
class SteamStatus(enum.Enum):
NOT_RUNNING = 0
ERROR = 1
OS = 2
OS_DEV = 3
SIDELOADED = 4
@classmethod
def from_string(cls, status_str):
if not status_str:
return cls.ERROR
try:
if '.' in status_str:
name = status_str.split('.')[-1]
else:
name = status_str
return cls[name]
except KeyError:
return cls.ERROR
@property
def description(self):
DESCRIPTIONS = {
SteamStatus.NOT_RUNNING: 'not running',
SteamStatus.OS: 'OS client',
SteamStatus.OS_DEV: 'OS client dev mode',
SteamStatus.SIDELOADED: 'sideloaded client',
SteamStatus.ERROR: 'error',
}
return DESCRIPTIONS[self]
class SteamConfig(enum.Enum):
ERROR = 1
# Matching the SteamStatus numeric values
OS = 2
OS_DEV = 3
SIDELOADED = 4
@property
def description(self):
DESCRIPTIONS = {
SteamConfig.OS: 'OS client',
SteamConfig.OS_DEV: 'OS client dev mode',
SteamConfig.SIDELOADED: 'sideloaded client',
SteamConfig.ERROR: 'error',
}
return DESCRIPTIONS[self]
SESSION_NAMES = ['gamescope', 'plasma-x11', 'plasma-x11-persistent', 'plasma-wayland', 'plasma-wayland-persistent']
class SessionConfig(enum.IntEnum):
# note: matches SESSION_NAMES indexes
GAMESCOPE = 0
PLASMA_X11 = 1
PLASMA_X11_PERSISTENT = 2
PLASMA_WAYLAND = 3
PLASMA_WAYLAND_PERSISTENT = 4
ERROR = 5
def cef_debugging():
'''Only sane way to check is to look for the listening port.'''
ret = subprocess.run('/usr/bin/ss -l -t -n -p | grep steamwebhelper | grep 8080 > /dev/null', shell=True)
return ( ret.returncode == 0 )
def steam_process_get_path_and_args():
ret = subprocess.run(['pgrep', '-a', '-x', 'steam'], capture_output=True, text=True)
if ret.returncode != 0:
return None
# Proton may run a dummy 'steam' process that confused previous implementations of this logic
# look for a process who's real filename is 'steam'
for l in ret.stdout.splitlines():
try:
pid = int(l.split(' ')[0])
except:
continue
rp = os.path.realpath(f'/proc/{pid}/exe')
if os.path.basename(rp) == 'steam':
try:
with open(f'/proc/{pid}/cmdline', 'rb') as f:
cmdline = f.read()
argv = [a.decode('utf-8', errors='replace') for a in cmdline.split(b'\x00') if a]
if len(argv) >= 2:
path = argv[0]
args = argv[1:]
# strip -srt-logger-opened: injected by steam.sh at runtime
args = [a for a in args if a != '-srt-logger-opened']
return (path, args)
return (argv[0], [])
except Exception as e:
logger.warning(f'Failed to read /proc/{pid}/cmdline: {e}')
return None
def steam_process_get_path():
try:
(path, _) = steam_process_get_path_and_args()
except:
return None
return path
def steam_process_get_args():
try:
(_, args) = steam_process_get_path_and_args()
except:
return ''
return args
def steam_status():
'''What is the status of the Steam client on the system?'''
s = steam_process_get_path()
if s is None:
return SteamStatus.NOT_RUNNING
if s.find('.local/share/Steam/') != -1:
if os.path.exists(os.path.expanduser('~/devkit-game/devkit-steam')):
return SteamStatus.OS_DEV
return SteamStatus.OS
if s.find('devkit-game/steam/') != -1 or s.find('devkit-game/steamdeckard/') != -1:
return SteamStatus.SIDELOADED
logger.warning(f'could not interpret pgrep result to determine steam client status: {s!r}')
return SteamStatus.ERROR
def steam_configuration():
'''How is the Steam client configured to run?'''
devkit_steam_trampoline_path = os.path.join(DEVKIT_TOOL_FOLDER, 'devkit-steam')
if not os.path.exists(devkit_steam_trampoline_path):
return SteamConfig.OS
t = open(devkit_steam_trampoline_path, 'rt').read()
if t.find('SteamStatus.OS_DEV') != -1:
return SteamConfig.OS_DEV
if t.find('SteamStatus.SIDELOADED') != -1:
return SteamConfig.SIDELOADED
logger.warning(f'could not determine what {devkit_steam_trampoline_path} means to do')
return SteamConfig.ERROR
def osclient_branch(is_deckard):
'''Which branch is the default Steam 'OS client' configured to use?'''
# makes more sense to return strings here
beta_path = os.path.expanduser('~/.steam/steam/package/beta')
if not os.path.exists(beta_path):
return 'default' # not sure that's valid actually - would be the desktop client, which will only run in desktop mode ..
t = open(beta_path, 'rt').readline().strip('\n')
try:
p = 'steamdeck_(.*)'
if re.match(p, t):
branch = re.split(p, t)[1]
return branch
# internal builds
p = 'steampal_(.*)_.*'
if re.match(p, t):
branch = re.split(p, t)[1]
return branch
if is_deckard:
p = 'linux_arm64_(.*)_.*'
if re.match(p, t):
branch = re.split(p, t)[1]
return branch
raise Exception('no match')
except: # noqa: E722
logger.warning(f'could not determine the OS client branch config: {t!r}')
return 'error'
def osclient_version(conf):
'''Which version is the Steam 'OS client'?'''
if conf.is_deckard:
# old Steam client was using linuxarm64/, which is now reserved for the SDK binaries
for folder in ('linuxarm64', 'steamrtarm64'):
fn = os.path.expanduser(f'~/.steam/steam/{folder}/builddate.txt')
if os.path.exists(fn):
return open(fn, 'rt').read()
return 'Unknown - no builddate.txt'
beta_path = os.path.expanduser('~/.steam/steam/package/beta')
if not os.path.exists(beta_path):
logger.warning(f'not found: {beta_path}')
return None
t = open(beta_path, 'rt').readline().strip('\n')
manifest = os.path.expanduser(f'~/.steam/steam/package/steam_client_{t}_ubuntu12.manifest')
if not os.path.exists(manifest):
logger.warning(f'not found: {manifest}')
return None
try:
version = int(re.search('"version".*"(.*)"', open(manifest,'rt').read()).group(1))
return version
except:
logger.warning(f'could not parse version out of {manifest}')
return None
def session_config():
'''What is the graphics session configuration?'''
# RESTART_SESSION writes this file
conf_file = '/etc/sddm.conf.d/zz-steamos-autologin.conf'
if not os.path.exists(conf_file):
# fallback to the OS default
conf_file = '/etc/sddm.conf.d/steamos.conf'
if os.path.exists(conf_file):
s = open(conf_file, 'rt').read()
if s.find('plasmawayland.desktop') != -1:
return SessionConfig.PLASMA_WAYLAND_PERSISTENT
if s.find('plasma.desktop') != -1:
return SessionConfig.PLASMA_X11_PERSISTENT
if s.find('gamescope-wayland.desktop') != -1:
return SessionConfig.GAMESCOPE
if s.find('plasma-steamos-oneshot.desktop') != -1:
return SessionConfig.PLASMA_X11
if s.find('plasma-steamos-wayland-oneshot.desktop') != -1:
return SessionConfig.PLASMA_WAYLAND
else:
# if the conf file doesn't exist we are likely in the default config
# check for a running gamescope for sanity
if subprocess.call('pgrep -a -x gamescope', shell=True, stdout=subprocess.DEVNULL) == 0:
return SessionConfig.GAMESCOPE
# couldn't figure it out, halp
return SessionConfig.ERROR
def session_select_command():
if shutil.which('holo-session-select'):
return 'holo-session-select'
return 'steamos-session-select'
def get_os_info():
os_info = {}
try:
for k, v in [ s.split('=') for s in open('/etc/os-release').read().split('\n') if len(s) > 0 ]:
os_info[k] = v.strip('"')
except Exception as e:
logger.error(e)
logger.error('Failed to parse OS release file')
return os_info
def steam_default_args(conf):
if conf.is_deckard:
# Frame currently uses a different setup
return []
try:
if os.path.exists('/usr/lib/steamos/steam-launcher'):
output = subprocess.check_output('cat /usr/lib/steamos/steam-launcher | grep ^steamargs=',
shell=True,
universal_newlines=True)
ret = [ v.strip('"') for v in re.findall('\".*?\"', output) ]
return ret
except:
logger.warning('Failed to obtain steam default arguments from /usr/lib/steamos/steam-launcher')
# Legacy SteamOS
try:
output = subprocess.check_output('cat /usr/bin/gamescope-session | grep ^steamargs',
shell=True,
universal_newlines=True)
ret = [ v.strip('"') for v in re.findall('\".*?\"', output) ]
except:
logger.warning('Failed to obtain steam default arguments from /usr/bin/gamescope-session')
# Hardcoded fallback
return ['-steamos3', '-steampal', '-steamdeck', '-gamepadui']
def frame_osclient_extra_args(conf, steam_status):
if not conf.is_deckard or steam_status != SteamStatus.OS:
return None
if os.path.exists(STEAM_EXTRA_ARGS_FILE):
try:
content = open(STEAM_EXTRA_ARGS_FILE, 'rt').read()
match = re.search(r'Environment="STEAM_EXTRA_ARGS=(.*)"', content)
if match:
return match.group(1).replace('\\"', '"')
except Exception as e:
logger.warning(f'Failed to parse steam extra args: {e}')
return None
def user_password_is_set():
ret = subprocess.run('passwd', stdin=subprocess.DEVNULL, shell=True, capture_output=True, universal_newlines=True)
logger.debug(repr(ret))
return (ret.stderr.find('Current password:') != -1)
def steam_launch_flags():
'''Pull various steam flags that affect title execution.'''
ret = {}
if not 'XDG_RUNTIME_DIR' in os.environ:
logger.warning('XDK_RUNTIME_DIR is not set')
return ret
env_folder = os.path.join(os.environ['XDG_RUNTIME_DIR'], 'steam/env')
if not os.path.isdir(env_folder):
return ret
for fn in os.listdir(env_folder):
filepath = os.path.join(env_folder, fn)
content = open(filepath, 'rt').read()
# Check if this is a declaration file with key=value pairs
if content.count('\n') > 1 or '=' in content:
# Parse key=value format with comments
for line in content.splitlines():
line = line.strip()
# Skip comments and empty lines
if not line or line.startswith('#'):
continue
# Parse key=value pairs
if '=' in line:
key, value = line.split('=', 1)
ret[key.strip()] = value.strip()
else:
# Legacy format: filename is the key, file content is the value
ret[fn] = content.strip('\n')
return ret
def renderdoc_replay_server_running():
ret = subprocess.run(['pgrep', '-x', 'renderdoccmd'], capture_output=True)
return ret.returncode == 0
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--verbose', required=False, action='store_true')
parser.add_argument('--json', required=False, action='store_true')
conf = parser.parse_args()
if conf.verbose:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
os_info = get_os_info()
assert os_info is not None
conf.is_deckard = os_info.get('VARIANT_ID', None) == 'vr'
os_name = os_info.get('PRETTY_NAME', None)
os_version = os_info.get('BUILD_ID', None)
_steam_launch_flags = steam_launch_flags()
if not conf.is_deckard:
# this bit of cargo cult is Steam Deck only
try:
# disable wireless power management for devkit usage: less latency on commands
subprocess.check_call(WIRELESS_DISABLE_POWER_MANAGEMENT)
except subprocess.CalledProcessError as e:
logger.warning(e)
session_config = session_config()
# enum -> human readable
session_status = SESSION_NAMES[session_config] if session_config != SessionConfig.ERROR else 'error'
steam_status = steam_status()
cef_debugging_enabled = False
if steam_status != SteamStatus.NOT_RUNNING:
cef_debugging_enabled = cef_debugging()
steam_configuration = steam_configuration()
osclient_branch = osclient_branch(conf.is_deckard)
osclient_version = osclient_version(conf)
steam_status_description = steam_status.description
if steam_status in (SteamStatus.OS, SteamStatus.OS_DEV) :
steam_status_description += f', on branch {osclient_branch!r}'
if osclient_version is not None:
if conf.is_deckard:
# we get builddate.txt
steam_status_description += f', {osclient_version}'
else:
utc_date_string = datetime.datetime.fromtimestamp(osclient_version, datetime.UTC).isoformat()
steam_status_description += f', version {osclient_version} {utc_date_string}'
has_side_loaded_client = os.path.exists(
os.path.join(
DEVKIT_TOOL_FOLDER,
'steam'
)
)
_user_password_is_set = user_password_is_set()
_renderdoc_replay_server_running = renderdoc_replay_server_running()
_renderdoc_layer_enabled = _steam_launch_flags.get('ENABLE_VULKAN_RENDERDOC_CAPTURE', '0') == '1'
_hostname = socket.gethostname()
if conf.json:
ret = {
'is_deckard': conf.is_deckard,
'hostname': _hostname,
'os_name': os_name,
'os_version': os_version,
'os_info': os_info,
'session_status': session_status,
'session_options': SESSION_NAMES,
'session_select': session_select_command(),
'steam_status': str(steam_status),
'cef_debugging_enabled': cef_debugging_enabled,
'steam_status_description': steam_status_description,
'steam_configuration': str(steam_configuration),
'steam_osclient_branch': osclient_branch,
'steam_osclient_version': osclient_version,
'has_side_loaded_client': has_side_loaded_client,
'steam_default_args': steam_default_args(conf),
'steam_current_args': steam_process_get_args(),
'frame_osclient_extra_args': frame_osclient_extra_args(conf, steam_status),
'user_password_is_set': _user_password_is_set,
'steam_launch_flags': _steam_launch_flags,
'renderdoc_layer_enabled': _renderdoc_layer_enabled,
'renderdoc_replay_server_running': _renderdoc_replay_server_running,
}
json.dump(ret, sys.stdout, sort_keys=True, indent=4)
else:
logger.info(f'Hostname : {_hostname}')
logger.info(f'OS : {os_name}')
logger.info(f'OS version : {os_version}')
logger.info(f'Session mode is : {session_status}')
logger.info(f'Session select command : {session_select_command()}')
logger.info(f'Steam client status : {steam_status_description}')
logger.info(f'Steam client args : {steam_process_get_args()!r}')
logger.info(f"Steam extra args (Frame) : {frame_osclient_extra_args(conf, steam_status)!r}")
logger.info(f"Steam CEF debug : {'enabled' if cef_debugging_enabled else 'disabled'}")
logger.info(f'Steam client config : {steam_configuration.description}')
logger.info(f'Steam OS client branch : {osclient_branch}')
logger.info(f'Steam OS client version : {osclient_version}')
logger.info(f"Sideloaded client : {'available' if has_side_loaded_client else 'not installed'}")
logger.info(f'OS client arguments : {steam_default_args(conf)!r}')
logger.info(f"User password is set : {'yes' if _user_password_is_set else 'no'}")
logger.info(f"Steam launch flags : {_steam_launch_flags}")
logger.info(f"RenderDoc layer enabled : {'yes' if _renderdoc_layer_enabled else 'no'}")
logger.info(f"RenderDoc replay running : {'yes' if _renderdoc_replay_server_running else 'no'}")
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env python3
import os
import logging
import argparse
import getpass
import json
from subprocess import DEVNULL
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger(__name__)
DEVKIT_TOOL_FOLDER = os.path.expanduser('~/devkit-game')
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--verbose', required=False, action='store_true')
conf = parser.parse_args()
if conf.verbose:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
ret = []
if os.path.isdir(DEVKIT_TOOL_FOLDER):
for filename in os.listdir(DEVKIT_TOOL_FOLDER):
gamefolder = os.path.join(DEVKIT_TOOL_FOLDER, filename)
if os.path.isdir(gamefolder):
ret.append( {
'gameid': filename,
} )
print(json.dumps(ret))
+50
View File
@@ -0,0 +1,50 @@
#!/usr/bin/env python3
import sys
import os
import logging
import argparse
import getpass
import json
import shutil
import subprocess
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger(__name__)
DEVKIT_TOOL_FOLDER = os.path.expanduser('~/devkit-game')
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--verbose', required=False, action='store_true')
parser.add_argument('--gameid', required=True, action='store')
parser.add_argument('--restart-steam', required=False, default='0', action='store')
parser.add_argument('--use-mask-unmask', required=False, default='0', action='store')
parser.add_argument('--prevent-auto-repair', required=False, default='0', action='store')
conf = parser.parse_args()
if conf.verbose:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
directory = os.path.join(
os.path.expanduser(DEVKIT_TOOL_FOLDER),
conf.gameid
)
os.makedirs(directory, exist_ok=True)
INHIBIT_SENTINEL = os.path.expanduser('~/.config/inhibit-short-session-tracker')
if int(conf.prevent_auto_repair) == 1:
open(INHIBIT_SENTINEL, 'w').close()
logger.info(f'Created sentinel file: {INHIBIT_SENTINEL}')
elif os.path.exists(INHIBIT_SENTINEL):
os.remove(INHIBIT_SENTINEL)
logger.info(f'Removed sentinel file: {INHIBIT_SENTINEL}')
ret = {
'user': getpass.getuser(),
'directory': directory,
}
print(json.dumps(ret))
@@ -0,0 +1,7 @@
#!/bin/bash
# meant to be executed remotely/interactively for password prompts
# there's some annoying trash at the top of the remote ssh screen
clear
passwd
sleep 2
+157
View File
@@ -0,0 +1,157 @@
#!/usr/bin/env python3
import sys
import os
import logging
import argparse
import enum
import subprocess
import shutil
import pathlib
logging.basicConfig(format='%(message)s', level=logging.DEBUG)
logger = logging.getLogger(__name__)
DEVKIT_TOOL_FOLDER = os.path.expanduser('~/devkit-game')
# NOTE: only relevant to Frame + OS client with extra arguments
# sideloaded client on Frame supports full command line edit instead
STEAM_EXTRA_ARGS_FILE = os.path.expanduser('~/.config/systemd/user/steam.service.d/extra_args.conf')
class SteamStatus(enum.Enum):
# Supported values from steamos-get-status
OS = 2
OS_DEV = 3
SIDELOADED = 4
STATUS_STRINGS = [
( SteamStatus.OS, 'SteamStatus.OS' ),
( SteamStatus.OS_DEV, 'SteamStatus.OS_DEV' ),
( SteamStatus.SIDELOADED, 'SteamStatus.SIDELOADED' ),
]
# gamescope-session passes the execution to this script if it exists rather than start steam itself
DEVKIT_STEAM_TRAMPOLINE = os.path.expanduser('~/devkit-game/devkit-steam')
# this script executes the sideloaded Steam client (part of the steamos-devkit-service package)
SIDE_LOADED_STEAM_CLIENT = '/usr/share/steamos-devkit/bin/devkit-standalone.py'
def write_trampoline(text):
with open(DEVKIT_STEAM_TRAMPOLINE, 'w') as devkit_steam:
devkit_steam.write(text)
devkit_steam.flush()
os.chmod(DEVKIT_STEAM_TRAMPOLINE, 0o770)
# trying really hard to avoid leaving a zero sized trampoline if the deck is about to hang on the session restart coming next
subprocess.run(['/usr/bin/sync', DEVKIT_TOOL_FOLDER])
def get_os_info():
os_info = {}
try:
for k, v in [ s.split('=') for s in open('/etc/os-release').read().split('\n') if len(s) > 0 ]:
os_info[k] = v.strip('"')
except Exception as e:
logger.error(e)
logger.error('Failed to parse OS release file')
return os_info
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--verbose', required=False, action='store_true')
parser.add_argument('--client', action='store', required=True, choices=[ v[1] for v in STATUS_STRINGS ])
parser.add_argument('--args', action='store', required=False, help='steam client command line arguments')
parser.add_argument('--gameid', required=True, action='store')
parser.add_argument('--gdbserver', action='store_true', required=False)
conf = parser.parse_args()
if conf.verbose:
logger.setLevel(logging.DEBUG)
else:
logger.setLevel(logging.INFO)
target = [ v for v in STATUS_STRINGS if v[1] == conf.client ][0][0]
logging.info(f'Set steam client on device to {target}')
if os.path.exists(DEVKIT_STEAM_TRAMPOLINE):
os.unlink(DEVKIT_STEAM_TRAMPOLINE)
os_info = get_os_info()
assert os_info is not None
is_deckard = os_info.get('VARIANT_ID', None) == 'vr'
if target == SteamStatus.OS:
if is_deckard:
# conf.args is the extra arguments for the normal Steam 'OS client', update it now
if conf.args is None or conf.args == '':
logger.info('Clearning extra arguments for normal Steam client')
if os.path.exists(STEAM_EXTRA_ARGS_FILE):
os.unlink(STEAM_EXTRA_ARGS_FILE)
subprocess.run(['systemctl', '--user', 'daemon-reload'], check=True)
else:
logger.info(f'Setting extra arguments for normal Steam client: {conf.args}')
os.makedirs(os.path.dirname(STEAM_EXTRA_ARGS_FILE), exist_ok=True)
with open(STEAM_EXTRA_ARGS_FILE, 'wt') as extra_args_file:
escaped_args = conf.args.replace('"', '\\"')
extra_args_file.write(f'[Service]\nEnvironment="STEAM_EXTRA_ARGS={escaped_args}"')
subprocess.run(['systemctl', '--user', 'daemon-reload'], check=True)
# When disabling a sideloaded client, also delete the ~/.steam symlinks:
# They will be re-created by the OS client when starting,
# this prevents SteamVR trying to use the sideloaded binaries that are still there for the steam API.
# (this may happen because SteamVR starts before Steam starts and has a chance to set those symlinks correctly)
for path in pathlib.Path(os.path.expanduser('~/.steam')).glob('*'):
if path.is_symlink():
try:
lnk = path.resolve()
if 'devkit-game' in str(lnk):
path.unlink()
print(f'Deleted: {path} -> {lnk}')
except Exception as e:
print(f'Error processing {path}: {e}')
logger.info('Devkit Steam client override is disabled - default OS client execution will resume.')
sys.exit(0)
os.makedirs(os.path.dirname(DEVKIT_STEAM_TRAMPOLINE), exist_ok=True)
# OS client
steam_client = '$HOME/.local/share/Steam/steam.sh'
if target == SteamStatus.SIDELOADED:
steam_client = '$HOME/devkit-game/steam/steam.sh'
if is_deckard:
# RUNSTEAM.sh checks for SIDELOADED_STEAMROOT="${HOME}/devkit-game/steam"
# this is consistent with sideload on Steam Deck, but we use a different name 'steamdeckard'
# will be addressed when reworking the sideload and debug strategy, for now just drop in a symlink
steam_symlink = os.path.expanduser('~/devkit-game/steam')
if os.path.lexists(steam_symlink):
if os.path.islink(steam_symlink):
os.unlink(steam_symlink)
else:
# this happens if an upload in Steam Deck mode was attempted against a Steam Frame for instance
# was an easy mistake to make before recent changes
logger.warning('warning: ~/devkit-game/steam exists but is not a symlink. Removing anyway.')
shutil.rmtree(steam_symlink)
os.symlink(
os.path.expanduser('~/devkit-game/steamdeckard'),
steam_symlink,
)
args = '"$@"'
if conf.args is not None:
args = conf.args
gdbserver = ''
if conf.gdbserver:
logger.info('Configuring for remote debugging via gdbserver')
gdbserver = 'export DEBUGGER="gdbserver 0.0.0.0:2345"'
write_trampoline('''#!/bin/bash
# Generated by steamos-set-steam-client, do not edit!
# configuration tag (do not delete): {}
{}
mkdir -p $HOME/.steam/steam/logs
exec {} {}
'''.format(
conf.client,
gdbserver,
steam_client,
args
))
+382
View File
@@ -0,0 +1,382 @@
"""frame_titles without a headset: executable headers, launch targets, zips, runtimes."""
import json
import os
import shutil
import struct
import sys
import tempfile
import unittest
import zipfile
sys.path.insert(0, os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'ui'))
import frame_titles # noqa: E402
from frame_titles import FrameError # noqa: E402
def elf(machine, e_type=3, interp=True, pad=0):
"""A 64-bit little-endian ELF header plus one program header (PT_INTERP or PT_LOAD)."""
ident = b'\x7fELF' + bytes([2, 1, 1]) + b'\0' * 9
header = ident + struct.pack('<HHIQQQIHHHHHH', e_type, machine, 1, 0, 64, 0, 0, 64, 56, 1, 0, 0, 0)
phdr = struct.pack('<IIQQQQQQ', 3 if interp else 1, 4, 0, 0, 0, 0, 0, 0)
return header + phdr + b'\0' * pad
def pe(machine, dll=False, pad=0):
"""An MZ stub pointing at a PE signature and COFF header."""
mz = b'MZ' + b'\0' * 0x3A + struct.pack('<I', 0x40)
coff = b'PE\0\0' + struct.pack('<HHIIIHH', machine, 1, 0, 0, 0, 0xF0, 0x2022 if dll else 0x0022)
return mz + coff + b'\0' * pad
class Classify(unittest.TestCase):
def setUp(self):
self.dir = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.dir)
def check(self, data, name='f'):
p = os.path.join(self.dir, name)
with open(p, 'wb') as f:
f.write(data)
return frame_titles.classify(p)
def test_elf_machines(self):
self.assertEqual(self.check(elf(0xB7)), {'format': 'elf', 'arch': 'arm64', 'exe': True})
self.assertEqual(self.check(elf(0x3E))['arch'], 'x86_64')
self.assertEqual(self.check(elf(0x3E, e_type=2, interp=False))['exe'], True) # ET_EXEC
def test_shared_library_is_not_a_program(self):
self.assertFalse(self.check(elf(0xB7, interp=False))['exe'])
def test_pe_machines(self):
self.assertEqual(self.check(pe(0x8664)), {'format': 'pe', 'arch': 'x86_64', 'exe': True})
self.assertEqual(self.check(pe(0xAA64))['arch'], 'arm64')
self.assertEqual(self.check(pe(0x14C))['arch'], 'x86')
self.assertFalse(self.check(pe(0x8664, dll=True))['exe'])
def test_lying_headers_are_not_programs(self):
bad = bytearray(elf(0xB7))
struct.pack_into('<HH', bad, 54, 1, 1) # one-byte program header entries
self.assertFalse(self.check(bytes(bad))['exe'])
self.assertIsNone(self.check(b'MZ' + b'\0' * 0x3A + struct.pack('<I', 0xFFFFFFF0)))
def test_scripts_and_data(self):
self.assertEqual(self.check(b'#!/bin/sh\necho hi\n')['format'], 'script')
self.assertIsNone(self.check(b'MZ-not-really'))
self.assertIsNone(self.check(b'just text'))
class Targets(unittest.TestCase):
def tree(self, files):
root = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, root)
for rel, data in files.items():
p = os.path.join(root, *rel.split('/'))
os.makedirs(os.path.dirname(p), exist_ok=True)
with open(p, 'wb') as f:
f.write(data)
return root
def plan(self, files, name):
return frame_titles.inspect(self.tree(files), name)
def test_unity_windows_build(self):
# UnityCrashHandler64.exe is bigger than the game's own exe; the name decides.
p = self.plan({'MyGame/MyGame.exe': pe(0x8664, pad=600),
'MyGame/UnityCrashHandler64.exe': pe(0x8664, pad=5000),
'MyGame/UnityPlayer.dll': pe(0x8664, dll=True, pad=9000),
'MyGame/MyGame_Data/Plugins/x86_64/steam_api64.dll': pe(0x8664, dll=True)}, 'MyGame')
self.assertEqual(p['target'], 'MyGame.exe')
self.assertEqual(p['runtime'], 'proton-experimental')
self.assertEqual(p['runtimes'], ['proton-experimental', 'proton-stable'])
crash = next(c for c in p['candidates'] if c['path'] == 'UnityCrashHandler64.exe')
self.assertTrue(crash['skip'])
self.assertNotIn('UnityPlayer.dll', [c['path'] for c in p['candidates']])
def test_unreal_prefers_top_level_bootstrap(self):
p = self.plan({'Game.exe': pe(0x8664, pad=200),
'Game/Binaries/Win64/Game-Win64-Shipping.exe': pe(0x8664, pad=9000),
'Engine/Extras/Redist/en-us/UEPrereqSetup_x64.exe': pe(0x8664, pad=9000)}, 'Game')
self.assertEqual(p['target'], 'Game.exe')
def test_installers_lose_to_the_game(self):
p = self.plan({'setup.exe': pe(0x8664, pad=9000), 'unins000.exe': pe(0x14C, pad=9000),
'_CommonRedist/vc_redist.x64.exe': pe(0x8664, pad=9000),
'Tool.exe': pe(0x8664)}, 'Something')
self.assertEqual(p['target'], 'Tool.exe')
def test_arm64_linux_build(self):
p = self.plan({'game.arm64': elf(0xB7, pad=100), 'lib/libfoo.so': elf(0xB7, interp=False, pad=900)}, 'game')
self.assertEqual((p['target'], p['runtime']), ('game.arm64', 'SteamLinuxRuntime_4-arm64'))
self.assertEqual(p['runtimes'], ['SteamLinuxRuntime_4-arm64'])
def test_x86_64_linux_build_is_marked_inferred(self):
p = self.plan({'game.x86_64': elf(0x3E)}, 'game')
self.assertEqual(p['runtime'], 'SteamLinuxRuntime_4')
self.assertIn('inferred', p['note'])
def test_native_arm64_beats_x86_64(self):
p = self.plan({'game.x86_64': elf(0x3E, pad=900), 'game.arm64': elf(0xB7)}, 'game')
self.assertEqual(p['target'], 'game.arm64')
def test_top_level_script_beats_nested_binary(self):
p = self.plan({'run.sh': b'#!/bin/sh\nexec bin/game\n', 'bin/game': elf(0xB7)}, 'game')
self.assertEqual(p['target'], 'run.sh')
self.assertEqual(p['runtime'], 'SteamLinuxRuntime_4-arm64')
def test_binary_beside_script_wins(self):
p = self.plan({'start.sh': b'#!/bin/sh\n', 'game': elf(0x3E)}, 'game')
self.assertEqual(p['target'], 'game')
def test_other_architectures_are_refused(self):
with self.assertRaisesRegex(FrameError, 'x86 Linux'):
self.plan({'game': elf(0x03)}, 'game')
with self.assertRaisesRegex(FrameError, 'no Linux or Windows program'):
self.plan({'readme.txt': b'hello'}, 'game')
def test_runtime_override_and_exe_choice(self):
p = self.plan({'A.exe': pe(0x8664), 'B.exe': pe(0x8664)}, 'A')
frame_titles._choose(p, 'B.exe', 'proton-stable')
self.assertEqual((p['target'], p['runtime']), ('B.exe', 'proton-stable'))
with self.assertRaises(FrameError):
frame_titles._choose(p, 'A.exe', 'SteamLinuxRuntime_4-arm64')
with self.assertRaises(FrameError):
frame_titles._choose(p, '../outside.exe')
class Zips(unittest.TestCase):
def setUp(self):
self.dir = tempfile.mkdtemp()
self.addCleanup(shutil.rmtree, self.dir)
def zip(self, members, name='Cool Game-v1.2-win64.zip'):
p = os.path.join(self.dir, name)
with zipfile.ZipFile(p, 'w') as z:
for n, data in members.items():
z.writestr(n, data)
return p
def test_wrapper_folder_and_name(self):
plan = frame_titles.inspect(self.zip({'Cool Game/Cool Game.exe': pe(0x8664),
'__MACOSX/Cool Game/._Cool Game.exe': b'x'}))
try:
self.assertEqual(plan['name'], 'Cool Game')
self.assertEqual(plan['id'], 'Cool_Game')
self.assertEqual(plan['target'], 'Cool Game.exe')
self.assertTrue(os.path.isfile(os.path.join(plan['root'], 'Cool Game.exe')))
finally:
frame_titles.discard(plan)
self.assertFalse(os.path.exists(plan['work']))
def test_zip_slip_is_refused(self):
for bad in ('../evil.exe', 'ok/../../evil.exe', '/abs/evil.exe', 'C:/evil.exe', '..\\evil.exe'):
with self.subTest(bad=bad):
out = tempfile.mkdtemp(dir=self.dir)
with self.assertRaisesRegex(FrameError, 'absolute|climbs'):
frame_titles.extract_zip(self.zip({bad: pe(0x8664)}, 'bad.zip'), out)
self.assertFalse(os.path.exists(os.path.join(self.dir, 'evil.exe')))
def test_link_out_of_the_zip_is_refused(self):
p = os.path.join(self.dir, 'link.zip')
with zipfile.ZipFile(p, 'w') as z:
info = zipfile.ZipInfo('game/escape')
info.external_attr = (0o120777 << 16)
z.writestr(info, '../../etc/passwd')
with self.assertRaisesRegex(FrameError, 'outside'):
frame_titles.extract_zip(p, tempfile.mkdtemp(dir=self.dir))
def link_zip(self, members):
"""members: (name, data, is_link) in order."""
p = os.path.join(self.dir, 'links.zip')
with zipfile.ZipFile(p, 'w') as z:
for name, data, is_link in members:
info = zipfile.ZipInfo(name)
info.external_attr = ((0o120777 if is_link else 0o100644) << 16)
z.writestr(info, data)
return p
def test_chained_links_cannot_escape(self):
# alias -> . ; alias/alias/escape -> ../.. ; escape/victim would land outside if links were real.
p = self.link_zip([('alias', '.', True), ('alias/alias/escape', '../..', True), ('escape/victim', b'x', False)])
out = tempfile.mkdtemp(dir=self.dir)
frame_titles.extract_zip(p, out)
self.assertTrue(os.path.isfile(os.path.join(out, 'escape', 'victim'))) # stayed inside
self.assertFalse(os.path.exists(os.path.join(self.dir, 'victim')))
self.assertFalse(os.path.exists(os.path.join(os.path.dirname(self.dir), 'victim')))
for root, dirs, files in os.walk(out):
self.assertFalse([n for n in dirs + files if os.path.islink(os.path.join(root, n))])
def test_folder_links_are_dropped_and_order_does_not_matter(self):
# b -> a/file listed before a -> dir; and a folder link that would contain itself.
p = self.link_zip([('dir/file', b'data', False), ('b', 'a/file', True), ('a', 'dir', True),
('dir/sub/loop', '../../a', True)])
out = tempfile.mkdtemp(dir=self.dir)
frame_titles.extract_zip(p, out)
with open(os.path.join(out, 'b'), 'rb') as f:
self.assertEqual(f.read(), b'data')
self.assertFalse(os.path.lexists(os.path.join(out, 'a')))
self.assertFalse(os.path.lexists(os.path.join(out, 'dir', 'sub', 'loop')))
def test_link_components_resolve_before_parent_steps(self):
# alias -> dirlink/../game.exe, dirlink -> deep/subdir: that's deep/game.exe, not game.exe.
p = self.link_zip([('deep/subdir/x', b'', False), ('deep/game.exe', b'deep one', False),
('game.exe', b'top one', False), ('dirlink', 'deep/subdir', True),
('alias', 'dirlink/../game.exe', True)])
out = tempfile.mkdtemp(dir=self.dir)
frame_titles.extract_zip(p, out)
with open(os.path.join(out, 'alias'), 'rb') as f:
self.assertEqual(f.read(), b'deep one')
def test_many_links_to_one_file_count_against_the_limit(self):
# The zip (1 KB) and the copies (15 KB) each fit under the limit; together they don't.
members = [('big', b'x' * 1000, False)] + [(f'alias{i}', 'big', True) for i in range(15)]
old = frame_titles.MAX_UNPACKED
frame_titles.MAX_UNPACKED = 15500
out = tempfile.mkdtemp(dir=self.dir)
try:
with self.assertRaisesRegex(FrameError, 'links would copy'):
frame_titles.extract_zip(self.link_zip(members), out)
finally:
frame_titles.MAX_UNPACKED = old
self.assertEqual(os.listdir(out), ['big']) # refused before copying any link
def test_oversized_link_is_refused(self):
p = self.link_zip([('big', 'x' * 5000, True)])
with self.assertRaisesRegex(FrameError, 'oversized link'):
frame_titles.extract_zip(p, tempfile.mkdtemp(dir=self.dir))
@unittest.skipIf(os.name == 'nt', 'needs symlinks')
def test_unwrap_never_steps_through_a_link(self):
# A folder whose only entry links elsewhere (a junction on Windows) stays the boundary.
outside, game = os.path.join(self.dir, 'outside'), os.path.join(self.dir, 'Game')
os.makedirs(outside)
os.makedirs(game)
with open(os.path.join(outside, 'Other.exe'), 'wb') as f:
f.write(pe(0x8664))
os.symlink(outside, os.path.join(game, 'inner'))
with self.assertRaisesRegex(FrameError, 'no Linux or Windows program'):
frame_titles.inspect(game)
@unittest.skipIf(os.name == 'nt', 'needs symlinks')
def test_folder_with_outside_link_is_staged_without_it(self):
game, secret = os.path.join(self.dir, 'Game'), os.path.join(self.dir, 'secret')
os.makedirs(game)
os.makedirs(secret)
with open(os.path.join(secret, 'key'), 'wb') as f:
f.write(b'private')
with open(os.path.join(game, 'Game.exe'), 'wb') as f:
f.write(pe(0x8664))
os.symlink(secret, os.path.join(game, 'leak'))
os.symlink(os.path.join(secret, 'key'), os.path.join(game, 'leak-file'))
os.symlink('Game.exe', os.path.join(game, 'Alias.exe'))
plan = frame_titles.inspect(game)
try:
self.assertNotEqual(os.path.realpath(plan['root']), os.path.realpath(game))
self.assertEqual(sorted(os.listdir(plan['root'])), ['Alias.exe', 'Game.exe'])
self.assertFalse(os.path.islink(os.path.join(plan['root'], 'Alias.exe')))
finally:
frame_titles.discard(plan)
def test_links_become_copies(self):
# No symlinks on disk (Windows may not allow them); the library a link names is still there.
p = self.link_zip([('game/lib/libfoo.so.1.2', b'ELF-ish', False), ('game/lib/libfoo.so.1', 'libfoo.so.1.2', True),
('game/lib/libfoo.so', 'libfoo.so.1', True), ('game/dangling', 'nowhere', True)])
out = tempfile.mkdtemp(dir=self.dir)
frame_titles.extract_zip(p, out)
for name in ('libfoo.so.1', 'libfoo.so'):
path = os.path.join(out, 'game', 'lib', name)
self.assertFalse(os.path.islink(path))
with open(path, 'rb') as f:
self.assertEqual(f.read(), b'ELF-ish')
self.assertFalse(os.path.lexists(os.path.join(out, 'game', 'dangling')))
def test_drive_qualified_parts_are_refused(self):
for bad in ('sub/C:../C:../victim.txt', 'game/file.exe:stream'):
with self.subTest(bad=bad):
with self.assertRaisesRegex(FrameError, 'drive or stream'):
frame_titles.extract_zip(self.zip({bad: b'x'}, 'drive.zip'), tempfile.mkdtemp(dir=self.dir))
def test_absurd_size_is_refused(self):
p = self.zip({'game.exe': pe(0x8664)}, 'bomb.zip')
old = frame_titles.MAX_UNPACKED
frame_titles.MAX_UNPACKED = 10
try:
with self.assertRaisesRegex(FrameError, 'looks wrong'):
frame_titles.extract_zip(p, tempfile.mkdtemp(dir=self.dir))
finally:
frame_titles.MAX_UNPACKED = old
def test_not_a_zip(self):
p = os.path.join(self.dir, 'x.zip')
with open(p, 'wb') as f:
f.write(b'nope')
with self.assertRaisesRegex(FrameError, 'not a readable zip'):
frame_titles.inspect(p)
class Names(unittest.TestCase):
def test_title_id(self):
self.assertEqual(frame_titles.title_id('Hollow Knight: Silksong!'), 'Hollow_Knight_Silksong')
self.assertEqual(frame_titles.title_id('steam'), 'steam-game') # Valve's reserved sideload names
self.assertEqual(frame_titles.title_id('--rm -rf /'), 'rm_-rf')
self.assertEqual(len(frame_titles.title_id('x' * 200)), 64)
with self.assertRaises(FrameError):
frame_titles.title_id('!!!')
def test_display_name(self):
self.assertEqual(frame_titles.display_name('MyGame-linux-arm64.zip'), 'MyGame')
self.assertEqual(frame_titles.display_name('Portal 2.zip'), 'Portal 2')
self.assertEqual(frame_titles.display_name('Game_v1.0.3_Win64.zip'), 'Game')
class Parms(unittest.TestCase):
def test_proton_parms(self):
p = frame_titles.shortcut_parms('Cool_Game', '/home/steamos/devkit-game/Cool_Game',
'Cool Game.exe', 'proton-experimental')
self.assertEqual(p, {'gameid': 'Cool_Game', 'directory': '/home/steamos/devkit-game/Cool_Game',
'argv': ['"Cool Game.exe"'], 'env': {},
'settings': {'steam_play': '1', 'steam_play_debug': '0',
'steam_play_debug_version': '2019',
'compat_tool': 'proton-experimental'},
'clear_settings': True, 'force_appid': '', 'lepton_args': ''})
json.dumps(p)
def test_linux_parms(self):
p = frame_titles.shortcut_parms('g', '/home/steamos/devkit-game/g', 'bin/game', 'SteamLinuxRuntime_4-arm64')
self.assertEqual(p['argv'], ['bin/game'])
self.assertEqual(p['settings'], {'steam_play': '0', 'compat_tool': 'SteamLinuxRuntime_4-arm64'})
def test_cleanup_names_only_this_title(self):
# A glob like Game-*.json would also delete Game-Deluxe's files.
self.assertEqual(frame_titles._json_files('Game').split(),
['devkit-game/Game-argv.json', 'devkit-game/Game-env.json',
'devkit-game/Game-settings.json', 'devkit-game/Game-framecontrol.json'])
def test_launch_needs_steam_to_answer(self):
# steam-devkit-rpc exits 0 after a timeout; only its 'success' line means Steam took it.
calls = []
old = frame_titles.ssh, frame_titles._check_id, frame_titles.ensure_utils
frame_titles._check_id, frame_titles.ensure_utils = (lambda g: g), (lambda: False)
try:
frame_titles.ssh = lambda cmd, **kw: calls.append(cmd) or 'Found steam client pid 1\ntimeout\n'
with self.assertRaisesRegex(FrameError, "didn't confirm"):
frame_titles.launch('Game')
frame_titles.ssh = lambda cmd, **kw: 'Found steam client pid 1\nsuccess\n{}'
self.assertEqual(frame_titles.launch('Game'), {'id': 'Game'})
finally:
frame_titles.ssh, frame_titles._check_id, frame_titles.ensure_utils = old
self.assertIn('steam-devkit-rpc run-game gameid=Game', calls[0])
def test_remove_waits_for_installs(self):
with frame_titles._install_lock:
with self.assertRaisesRegex(FrameError, 'install is running'):
frame_titles.remove('Game')
def test_vendored_utils_are_present(self):
for name in ('steamos-prepare-upload', 'steam-client-create-shortcut', 'steam-devkit-rpc',
'steamos-delete', 'devkit_utils/__init__.py', 'LICENSE'):
self.assertTrue(os.path.isfile(os.path.join(frame_titles.UTILS_LOCAL, name)), name)
self.assertEqual(len(frame_titles.utils_stamp()), 20)
if __name__ == '__main__':
unittest.main()
+35 -1
View File
@@ -6,14 +6,17 @@ request guards and input validation, which all run before any SSH call.
Run: python3 -m unittest discover -s tests
"""
import http.client
import io
import json
import os
import socket
import struct
import subprocess
import sys
import tempfile
import time
import unittest
import zipfile
from pathlib import Path
from urllib.parse import quote
@@ -54,7 +57,7 @@ class ServerGuards(unittest.TestCase):
@classmethod
def request(cls, method, path, body=None, headers=None):
conn = http.client.HTTPConnection("127.0.0.1", cls.port, timeout=10)
data = json.dumps(body).encode() if body is not None else None
data = body if isinstance(body, bytes) else json.dumps(body).encode() if body is not None else None
conn.request(method, path, body=data, headers=headers or {})
r = conn.getresponse()
payload = r.read()
@@ -130,6 +133,37 @@ class ServerGuards(unittest.TestCase):
status, _ = self.post("/api/launch", ["not", "an", "object"])
self.assertEqual(status, 400)
def test_title_upload_is_inspected_then_discarded(self):
# A zip holding a Windows x86-64 program: inspected locally, no SSH until install.
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as z:
z.writestr("Tiny Game/Tiny Game.exe",
b"MZ" + b"\0" * 0x3A + struct.pack("<I", 0x40) + b"PE\0\0" + struct.pack("<HHIIIHH", 0x8664, 1, 0, 0, 0, 0xF0, 0x22))
status, _, payload = self.request("POST", "/api/upload", buf.getvalue(),
{"X-Frame-UI": "1", "X-Mode": "title", "X-Filename": quote("Tiny Game-win64.zip")})
r = json.loads(payload)
self.assertEqual(status, 200, r)
self.assertEqual((r["plan"]["id"], r["plan"]["target"], r["plan"]["runtime"]),
("Tiny_Game", "Tiny Game.exe", "proton-experimental"))
self.assertNotIn("root", r["plan"])
self.assertEqual(self.post("/api/titles", {"action": "discard", "token": r["token"]})[0], 200)
self.assertEqual(self.post("/api/titles", {"action": "install", "token": r["token"]})[0], 400)
def test_title_input_validation(self):
status, _, _ = self.request("POST", "/api/upload", b"not a zip",
{"X-Frame-UI": "1", "X-Mode": "title", "X-Filename": "x.zip"})
self.assertEqual(status, 400)
for body in ({"action": "inspect", "path": "relative/game.zip"},
{"action": "inspect", "path": "/nonexistent/frame-control/game.zip"},
{"action": "install", "token": "nope"},
{"action": "launch", "id": "x; rm -rf ~"},
{"action": "remove", "id": "../etc"},
{"action": "explode"}):
status, payload = self.post("/api/titles", body)
self.assertEqual(status, 400, f"{body} -> {payload}")
self.assertEqual(self.request("GET", "/api/titles/job?token=nope", headers={"X-Frame-UI": "1"})[0], 404)
self.assertEqual(self.request("POST", "/api/titles", {"action": "list"})[0], 403)
def test_unknown_routes(self):
self.assertEqual(self.request("GET", "/nope")[0], 404)
self.assertEqual(self.post("/api/nope", {})[0], 404)
+797
View File
@@ -0,0 +1,797 @@
"""Linux and Windows builds on the Frame as Steam "Devkit Games".
A .zip, a folder or a single executable becomes a title in the Steam library,
through the same path as Valve's SteamOS Devkit Client: its devkit-utils
(vendored in frame/devkit-utils, synced to ~/devkit-utils on the Frame) make
~/devkit-game/<id>/, the files are copied there, and steam-client-create-shortcut
asks the running Steam client to register it with a runtime:
Windows .exe -> Proton Experimental (steam_play=1; x86-64 runs through FEX)
aarch64 ELF -> Steam Linux Runtime 4.0 ARM64 (steam_play=0)
x86-64 ELF -> Steam Linux Runtime 4.0 (steam_play=0; runs through FEX)
Everything device-side is inferred from Valve's steamos-devkit source until
checked on a headset; see docs/sideloading.md.
Python stdlib only. CLI:
python3 ui/frame_titles.py inspect PATH
python3 ui/frame_titles.py install PATH [--name N] [--exe REL] [--runtime R]
python3 ui/frame_titles.py list | launch ID | remove ID
"""
import hashlib, json, os, posixpath, re, shlex, shutil, stat, struct, subprocess, sys, tempfile, threading, time, zipfile
import frame_android
import frame_host
from frame_android import FrameError
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
UTILS_LOCAL = os.path.join(ROOT, 'frame', 'devkit-utils')
UTILS = 'devkit-utils' # on the Frame, relative to $HOME (where Valve's client puts it)
GAMES = 'devkit-game' # ditto; steamos-prepare-upload makes <id>/ in here
STAMP = '.frame-control-stamp'
PY = 'python3 ~/' + UTILS + '/'
# Valve's reserved sideload names: uploading one of these replaces the Steam client itself.
RESERVED_IDS = ('steam', 'steamdeckard', 'steamvr', 'steamvrdeckard')
ID_RE = re.compile(r'^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$')
DIR_RE = re.compile(r'^/[A-Za-z0-9_./-]+$')
# Zip limits: well above any real game, well below a zip bomb.
MAX_UNPACKED = 64 * 1024**3
MAX_ENTRIES = 200000
MAX_RATIO = 200 # uncompressed / compressed, once past 1 GB
# The Steam compat tool aliases Valve's client uses (devkit_client RUNTIME_ALIASES).
RUNTIMES = {
'proton-experimental': {'label': 'Proton Experimental', 'steam_play': True},
'proton-stable': {'label': 'Proton (stable)', 'steam_play': True},
'SteamLinuxRuntime_4-arm64': {'label': 'Steam Linux Runtime 4.0 (ARM64)', 'steam_play': False},
'SteamLinuxRuntime_4': {'label': 'Steam Linux Runtime 4.0 (x86-64, through FEX)', 'steam_play': False},
}
# Experimental rather than stable: the Frame's ARM64 Proton + FEX stack is new,
# and Proton fixes reach Experimental first. --runtime proton-stable switches.
DEFAULT_PROTON = 'proton-experimental'
ELF_MACHINES = {0xB7: 'arm64', 0x3E: 'x86_64', 0x03: 'x86', 0x28: 'arm'}
PE_MACHINES = {0x8664: 'x86_64', 0xAA64: 'arm64', 0x14C: 'x86', 0x1C4: 'arm'}
# Executables that are never the game: crash reporters, installers, redistributables.
SKIP_RE = re.compile(r'crash|unins|setup|install|redist|dxsetup|dxwebsetup|dotnet|prereq|'
r'easyanticheat|eac_|updater|uploader|report|sandbox|helper', re.I)
SKIP_DIRS = re.compile(r'^(_*commonredist|redist|redistributables?|directx|vcredist|__installer|'
r'installers?|prereqs?|support|engine|__macosx)$', re.I)
# Trailing words of an archive name that describe the build, not the game.
BUILD_WORDS = re.compile(r'([ ._-]+(win(dows)?(32|64)?|linux(32|64)?|x64|x86(_64)?|amd64|arm64|aarch64|'
r'build|release|portable|steamos|v\d+([._]\d+)*|\d+([._]\d+)+))+$', re.I)
def classify(path):
"""{'format': 'elf'|'pe'|'script', 'arch', 'exe'} for an executable file, else None."""
try:
with open(path, 'rb') as f:
head = f.read(4096)
if head[:4] == b'\x7fELF':
return _elf(f, head)
if head[:2] == b'MZ':
return _pe(f, head)
except (OSError, struct.error, ValueError, OverflowError):
return None # unreadable, or a header that lies about its sizes
if head[:2] == b'#!' or path.lower().endswith('.sh'):
return {'format': 'script', 'arch': None, 'exe': True}
return None
def _elf(f, head):
if len(head) < 64 or head[4] not in (1, 2) or head[5] not in (1, 2):
return None
wide, end = head[4] == 2, '<' if head[5] == 1 else '>'
e_type, machine = struct.unpack_from(end + 'HH', head, 16)
arch = ELF_MACHINES.get(machine, f'elf-0x{machine:x}')
if e_type == 2: # ET_EXEC
return {'format': 'elf', 'arch': arch, 'exe': True}
if e_type != 3: # not ET_DYN either: object file, core dump
return {'format': 'elf', 'arch': arch, 'exe': False}
# ET_DYN is a PIE executable or a shared library; only executables ask for an interpreter.
if wide:
phoff, = struct.unpack_from(end + 'Q', head, 32)
phentsize, phnum = struct.unpack_from(end + 'HH', head, 54)
else:
phoff, = struct.unpack_from(end + 'I', head, 28)
phentsize, phnum = struct.unpack_from(end + 'HH', head, 42)
if phentsize < (56 if wide else 32) or phnum > 256:
return {'format': 'elf', 'arch': arch, 'exe': False}
f.seek(phoff)
table = f.read(phentsize * phnum)
interp = any(struct.unpack_from(end + 'I', table, i * phentsize)[0] == 3 # PT_INTERP
for i in range(len(table) // phentsize))
return {'format': 'elf', 'arch': arch, 'exe': interp}
def _pe(f, head):
if len(head) < 0x40:
return None
lfanew, = struct.unpack_from('<I', head, 0x3C)
f.seek(lfanew)
coff = f.read(24)
if len(coff) < 24 or coff[:4] != b'PE\0\0':
return None # a DOS program, or not an executable at all
machine, = struct.unpack_from('<H', coff, 4)
characteristics, = struct.unpack_from('<H', coff, 22)
return {'format': 'pe', 'arch': PE_MACHINES.get(machine, f'pe-0x{machine:x}'),
'exe': not characteristics & 0x2000} # IMAGE_FILE_DLL
def title_id(name):
"""The Devkit Game id Steam shows as the title's name: [A-Za-z0-9_-], at most 64."""
s = re.sub(r'[^A-Za-z0-9_-]+', '_', str(name or '').strip())
s = re.sub(r'_+', '_', s).strip('_-')[:64].strip('_-')
if not s:
raise FrameError('the title needs a name with some letters or digits')
if s.lower() in RESERVED_IDS:
s += '-game'
return s
def display_name(filename):
"""A title name from a zip, folder or exe name: no extension or build/platform words."""
base = os.path.basename(str(filename).rstrip('/\\'))
stem, ext = os.path.splitext(base)
if ext.lower() in ('.zip', '.exe', '.sh', '.x86_64', '.arm64', '.aarch64', '.bin'):
base = stem
return BUILD_WORDS.sub('', base) or base
def _norm(s):
return re.sub(r'[^a-z0-9]', '', s.lower())
# ---- payload: zip, folder or single file -> a local tree to upload ----------
def extract_zip(zpath, dest):
"""Unpack a zip into dest, refusing paths that escape it and absurd sizes."""
try:
z = zipfile.ZipFile(zpath)
except (zipfile.BadZipFile, OSError) as e:
raise FrameError(f'{os.path.basename(zpath)} is not a readable zip: {e}')
with z:
infos = z.infolist()
if len(infos) > MAX_ENTRIES:
raise FrameError(f'the zip has {len(infos)} entries; the limit is {MAX_ENTRIES}')
total = sum(i.file_size for i in infos)
packed = max(1, os.path.getsize(zpath))
if total > MAX_UNPACKED or (total > 1024**3 and total > packed * MAX_RATIO):
raise FrameError(f'the zip would unpack to {total / 1024**3:.1f} GB, which looks wrong')
free = shutil.disk_usage(dest).free
if total + 256 * 1024**2 > free:
raise FrameError(f'not enough space on this computer to unpack the zip '
f'({total / 1024**3:.1f} GB needed, {free / 1024**3:.1f} GB free)')
try:
_extract_members(z, infos, os.path.realpath(dest))
except FrameError:
raise # a RuntimeError too, but already worded
except (zipfile.BadZipFile, RuntimeError, NotImplementedError, EOFError, OSError) as e:
# Encrypted or corrupt members, unsupported compression, clashing names, a full disk.
raise FrameError(f'could not unpack {os.path.basename(zpath)}: {e}')
def _extract_members(z, infos, root):
# No symlink is ever created here, so no write can be redirected through one
# (chained links, Windows without the privilege). Links inside the zip are
# resolved on paper and materialised as copies once the real files are out.
links = {}
for info in infos:
rel = _safe_member(info.filename)
if rel is None:
continue
target = _inside(root, rel, info.filename)
mode = info.external_attr >> 16
if info.is_dir():
os.makedirs(target, exist_ok=True)
continue
os.makedirs(os.path.dirname(target), exist_ok=True)
if stat.S_ISLNK(mode):
if info.file_size > 4096: # a link's content is a path, never this big
raise FrameError(f'the zip has an oversized link: {info.filename}')
link = z.read(info).decode('utf-8', 'replace').replace('\\', '/')
dest = posixpath.normpath(posixpath.join(posixpath.dirname(rel), link))
if link.startswith('/') or ':' in link or dest == '..' or dest.startswith('../'):
raise FrameError(f'the zip has a link that points outside it: {info.filename}')
links[rel] = link # as written: resolved later, one component at a time
continue
with z.open(info) as src, open(target, 'wb') as out:
shutil.copyfileobj(src, out, 1 << 20)
if mode & 0o111:
os.chmod(target, 0o755)
_materialise_links(root, links)
def _inside(root, rel, name):
"""rel's path under root, refusing anything that resolves outside it."""
target = os.path.join(root, *rel.split('/'))
if not (os.path.realpath(target) + os.sep).startswith(root + os.sep):
raise FrameError(f'the zip has a path that climbs out of it: {name}')
return target
def _resolve_link(path, links):
"""path with every link in it followed, on paper; None if it loops or leaves the zip.
Like the kernel: each component in turn, so 'dirlink/..' is the parent of
where dirlink points, not the folder dirlink sits in.
"""
todo, done, hops = path.split('/'), [], 0
while todo:
part = todo.pop(0)
if part in ('', '.'):
continue
if part == '..':
if not done:
return None
done.pop()
continue
done.append(part)
text = links.get('/'.join(done))
if text is not None:
hops += 1
if hops > 40:
return None
done.pop() # link text is relative to the link's folder
todo = text.split('/') + todo
return '/'.join(done)
def _materialise_links(root, links):
"""Copy the file each link names into its place (lib.so.1 -> lib.so.1.2.3 and the like).
Only links to files: a folder link could hold itself, and game builds link
libraries, not folders. Folder, looping and dangling links are dropped.
"""
copies = []
for rel in sorted(links):
dest = _resolve_link(rel, links)
if not dest:
continue # loops, escapes, or the zip's own top folder
src, target = _inside(root, dest, rel), _inside(root, rel, rel)
if os.path.isfile(src) and not os.path.lexists(target): # a real entry may have the name
copies.append((src, target))
# Many links to one big file could fill the disk: the same limits as the zip itself.
need = sum(os.path.getsize(src) for src, _ in copies)
if need + _tree_size(root) > MAX_UNPACKED:
raise FrameError('the zip\'s links would copy more than it holds, which looks wrong')
if need + 256 * 1024**2 > shutil.disk_usage(root).free:
raise FrameError(f'not enough space on this computer for the zip\'s linked files ({need / 1024**3:.1f} GB)')
for src, target in copies:
os.makedirs(os.path.dirname(target), exist_ok=True)
shutil.copy2(src, target)
def _safe_member(name):
"""The member's relative path with / separators, None to skip it; raises if it escapes."""
rel = name.replace('\\', '/')
if rel.startswith('/') or re.match(r'^[A-Za-z]:', rel):
raise FrameError(f'the zip has an absolute path: {name}')
parts = [p for p in rel.split('/') if p not in ('', '.')]
if any(p == '..' for p in parts):
raise FrameError(f'the zip has a path that climbs out of it: {name}')
if any(':' in p for p in parts):
# Drive-qualified parts ('C:..') climb out on Windows; ':' is an NTFS stream elsewhere.
raise FrameError(f'the zip has a path with a drive or stream name: {name}')
if not parts or parts[0] == '__MACOSX' or parts[-1] in ('.DS_Store', 'Thumbs.db'):
return None
return '/'.join(parts)
def _redirected(path, expected):
"""True if path is a symlink, or resolves somewhere else (a Windows junction isn't islink)."""
return os.path.islink(path) or os.path.normcase(os.path.realpath(path)) != os.path.normcase(expected)
def _has_links(root):
real = os.path.realpath(root)
for dirpath, dirnames, filenames in os.walk(real):
for n in dirnames + filenames:
if _redirected(os.path.join(dirpath, n), os.path.join(dirpath, n)):
return True
return False
def _stage_folder(src, dest):
"""Copy src to dest; links to files inside src become copies, all other links are left out."""
real = os.path.realpath(src)
inside = lambda p: os.path.normcase(p).startswith(os.path.normcase(real) + os.sep) # noqa: E731
folders, copies = [], [] # decide everything first, so the space check sees the same files
for dirpath, dirnames, filenames in os.walk(real):
out = os.path.join(dest, os.path.relpath(dirpath, real))
folders.append(out)
# Only descend into real folders: not symlinked ones, not junctions (os.walk follows those).
linked = [d for d in dirnames if _redirected(os.path.join(dirpath, d), os.path.join(dirpath, d))]
dirnames[:] = [d for d in dirnames if d not in linked]
for fn in filenames + linked:
target = os.path.realpath(os.path.join(dirpath, fn))
if inside(target) and os.path.isfile(target):
copies.append((target, os.path.join(out, fn)))
if shutil.disk_usage(os.path.dirname(dest)).free < sum(os.path.getsize(t) for t, _ in copies) + 256 * 1024**2:
raise FrameError('not enough space on this computer to stage the folder')
for out in folders:
os.makedirs(out, exist_ok=True)
for target, out in copies:
shutil.copy2(target, out)
return dest
def _unwrap(root):
"""Step into a single top-level folder, the usual shape of a zipped build.
Never through a link or junction: the folder you chose (or unpacked) stays the boundary.
"""
root = os.path.realpath(root)
for _ in range(4):
entries = [e for e in os.listdir(root) if e not in ('__MACOSX', '.DS_Store', 'Thumbs.db')]
if len(entries) != 1:
break
only = os.path.join(root, entries[0])
if not os.path.isdir(only) or _redirected(only, only):
break
root = only
return root
def candidates(root, title=''):
"""Executables under root, best launch target first."""
found = []
want = _norm(title)
for dirpath, dirnames, filenames in os.walk(root):
dirnames[:] = sorted(d for d in dirnames if not os.path.islink(os.path.join(dirpath, d)))
rel_dir = os.path.relpath(dirpath, root)
parts = [] if rel_dir == '.' else rel_dir.split(os.sep)
for fn in sorted(filenames):
full = os.path.join(dirpath, fn)
if os.path.islink(full) or not os.path.isfile(full):
continue
c = classify(full)
if not c or not c['exe']:
continue
stem = _norm(os.path.splitext(fn)[0])
skip = bool(SKIP_RE.search(fn)) or any(SKIP_DIRS.match(p) for p in parts)
match = 2 if want and stem == want else 1 if want and stem and (want in stem or stem in want) else 0
found.append({'path': '/'.join(parts + [fn]), 'format': c['format'], 'arch': c['arch'],
'size': os.path.getsize(full), 'depth': len(parts), 'skip': skip, 'match': match})
found.sort(key=_rank)
_prefer_launcher_script(found)
return found
def _platform_rank(c):
# Native ARM64 first, then Proton, then x86-64 Linux through FEX; scripts are placed separately.
order = {('elf', 'arm64'): 0, ('pe', 'x86_64'): 1, ('elf', 'x86_64'): 2, ('pe', 'x86'): 3, ('pe', 'arm64'): 3}
return order.get((c['format'], c['arch']), 5 if c['format'] == 'script' else 6)
def _rank(c):
return (c['skip'], _platform_rank(c), -c['match'], c['depth'], -c['size'], c['path'])
def _prefer_launcher_script(found):
"""A top-level shell script beats a Linux binary one folder down (run.sh + bin/game)."""
if not found or found[0]['format'] != 'elf' or found[0]['depth'] == 0:
return
for i, c in enumerate(found):
if c['format'] == 'script' and c['depth'] == 0 and not c['skip']:
found.insert(0, found.pop(i))
return
def runtime_for(target, found=()):
"""(compat tool alias, note) for a launch target, or raise FrameError if it can't run."""
fmt, arch = target['format'], target['arch']
if fmt == 'script':
# A script runs in the runtime of the binaries next to it; alone, natively.
elf = next((c for c in found if c['format'] == 'elf' and not c['skip']), None)
if elf:
return runtime_for(elf)[0], 'A shell script; runtime chosen from the Linux binary next to it.'
return 'SteamLinuxRuntime_4-arm64', 'A shell script with no Linux binary beside it; run natively.'
if fmt == 'pe':
if arch not in ('x86_64', 'x86', 'arm64'):
raise FrameError(f"{target['path']} is a Windows program for {arch}, which Proton can't run")
note = 'Windows x86-64 build: Proton runs it through FEX.' if arch == 'x86_64' else \
f'Windows {arch} build under Proton.'
return DEFAULT_PROTON, note
if fmt == 'elf' and arch == 'arm64':
return 'SteamLinuxRuntime_4-arm64', 'Native ARM64 Linux build.'
if fmt == 'elf' and arch == 'x86_64':
return 'SteamLinuxRuntime_4', 'x86-64 Linux build: runs through FEX (inferred, not yet checked).'
raise FrameError(f"{target['path']} is a {arch} Linux program; the Frame runs ARM64 and x86-64 (through FEX) only")
def allowed_runtimes(target, found=()):
alias, _ = runtime_for(target, found)
return ['proton-experimental', 'proton-stable'] if RUNTIMES[alias]['steam_play'] else [alias]
def inspect(path, name=None):
"""Read a .zip, folder or executable into an install plan (a JSON-safe dict).
A zip is unpacked into a temporary folder, plan['work']; pass the plan to
discard() when done with it. Raises FrameError if nothing in it can run.
"""
path = os.path.abspath(path)
if not os.path.exists(path):
raise FrameError(f'{path} does not exist')
work = None
try:
if os.path.isdir(path):
root = _unwrap(path)
if _has_links(root):
# scp -r follows links, so a link out of the folder could upload
# anything; copy the folder with its links made safe first.
work = tempfile.mkdtemp(prefix='frame-title-')
root = _stage_folder(root, os.path.join(work, os.path.basename(root)))
elif path.lower().endswith('.zip'):
work = tempfile.mkdtemp(prefix='frame-title-')
extract_zip(path, work)
root = _unwrap(work)
elif classify(path):
# A single executable is uploaded on its own; don't copy a whole Downloads folder.
work = tempfile.mkdtemp(prefix='frame-title-')
shutil.copy2(path, os.path.join(work, os.path.basename(path)))
root = work
else:
raise FrameError(f'{os.path.basename(path)} is not a .zip, a folder or a program')
title = name or display_name(os.path.basename(path.rstrip('/\\')))
found = candidates(root, title)
if not found:
raise FrameError(f'no Linux or Windows program found in {os.path.basename(path)}')
plan = {'source': os.path.basename(path.rstrip('/\\')), 'name': title, 'id': title_id(title),
'root': root, 'work': work, 'candidates': found,
'size': _tree_size(root), 'warnings': []}
_choose(plan, found[0]['path'])
return plan
except BaseException:
if work:
shutil.rmtree(work, ignore_errors=True)
raise
def _tree_size(root):
total = 0
for dirpath, _, filenames in os.walk(root):
for fn in filenames:
full = os.path.join(dirpath, fn)
if os.path.isfile(full) and not os.path.islink(full):
total += os.path.getsize(full)
return total
def _choose(plan, rel, runtime=None):
"""Set plan's launch target (a path relative to root) and its runtime."""
target = next((c for c in plan['candidates'] if c['path'] == rel), None)
if target is None:
full = os.path.realpath(os.path.join(plan['root'], *rel.replace('\\', '/').split('/')))
root = os.path.realpath(plan['root'])
if not (full + os.sep).startswith(root + os.sep) or not os.path.isfile(full):
raise FrameError(f'{rel} is not a file in the title')
c = classify(full)
if not c or not c['exe']:
raise FrameError(f"{rel} isn't a program the Frame can start")
target = {'path': os.path.relpath(full, root).replace(os.sep, '/'), 'format': c['format'],
'arch': c['arch'], 'size': os.path.getsize(full), 'depth': rel.count('/'),
'skip': False, 'match': 0}
alias, note = runtime_for(target, plan['candidates'])
allowed = allowed_runtimes(target, plan['candidates'])
if runtime:
if runtime not in allowed:
raise FrameError(f"{target['path']} can't use {runtime}; choose one of {', '.join(allowed)}")
alias = runtime
plan.update(target=target['path'], format=target['format'], arch=target['arch'], runtime=alias,
runtime_label=RUNTIMES[alias]['label'], runtimes=allowed, note=note)
plan['warnings'] = (['This looks like an installer or helper, not the game itself.'] if target['skip'] else [])
return plan
def discard(plan):
if plan and plan.get('work'):
shutil.rmtree(plan['work'], ignore_errors=True)
def argv_for(rel):
# Valve's client sends the start command as one string (it may carry arguments),
# so a path with spaces is quoted. How Steam splits it is inferred.
return ['"' + rel + '"' if re.search(r'\s', rel) else rel]
def shortcut_parms(gameid, directory, rel, runtime):
"""The JSON steam-client-create-shortcut takes, as devkit_client.new_or_ensure_game builds it."""
settings = {'steam_play': '1' if RUNTIMES[runtime]['steam_play'] else '0'}
if RUNTIMES[runtime]['steam_play']:
# gui2._update_game sends these with every Proton title; debugging stays off.
settings.update(steam_play_debug='0', steam_play_debug_version='2019')
settings['compat_tool'] = runtime
return {'gameid': gameid, 'directory': directory, 'argv': argv_for(rel), 'env': {},
'settings': settings, 'clear_settings': True, 'force_appid': '', 'lepton_args': ''}
# ---- the Frame side ----------------------------------------------------------
def ssh(cmd, input=None, timeout=120):
return frame_android.ssh(cmd, input=input, timeout=timeout)
def _json_out(out, what):
"""The JSON object a devkit-utils script prints last (its logging goes to stderr)."""
for line in reversed(out.strip().splitlines()):
line = line.strip()
if line.startswith('{') or line.startswith('['):
try:
return json.loads(line)
except ValueError:
break
raise FrameError(f'{what} gave no usable answer: {out.strip()[-300:]!r}')
def utils_stamp():
"""Hash of the vendored devkit-utils, compared with the copy on the Frame."""
h = hashlib.sha256()
for dirpath, dirnames, filenames in os.walk(UTILS_LOCAL):
dirnames[:] = sorted(d for d in dirnames if d != '__pycache__')
for fn in sorted(filenames):
if fn.endswith('.pyc'):
continue
full = os.path.join(dirpath, fn)
h.update(os.path.relpath(full, UTILS_LOCAL).replace(os.sep, '/').encode() + b'\0')
with open(full, 'rb') as f:
h.update(f.read())
return h.hexdigest()[:20]
def _json_files(gid):
# Exact names: a glob like Game-*.json would also match another title called Game-Deluxe.
return ' '.join(f'{GAMES}/{gid}-{k}.json' for k in ('argv', 'env', 'settings', 'framecontrol'))
_utils_lock = threading.Lock()
def ensure_utils():
"""Copy frame/devkit-utils to ~/devkit-utils on the Frame unless it's already this version."""
with _utils_lock: # one sync at a time: they share a staging folder
return _ensure_utils()
def _ensure_utils():
stamp = utils_stamp()
have = ssh(f'cat {UTILS}/{STAMP} 2>/dev/null || true', timeout=30).strip()
if have == stamp:
return False
# Merge rather than replace: Valve's own client may have put newer files there.
tmp = f'.{UTILS}.frame-control'
ssh(f'rm -rf {tmp}', timeout=30)
_copy_tree(UTILS_LOCAL, tmp, timeout=300)
ssh(f'mkdir -p {UTILS} && cp -R {tmp}/. {UTILS}/ && rm -rf {tmp} {UTILS}/__pycache__ '
f'&& echo {stamp} > {UTILS}/{STAMP}', timeout=60)
return True
def _copy_tree(src, dest, timeout=3 * 3600, delete=False):
"""Copy a local folder's contents to dest on the Frame (dest ends up a copy of src).
rsync where installed (not on Windows; see server.push_file), else scp -r
into a fresh dest, which is what Windows has. dest must be a plain path.
"""
name = os.path.basename(src.rstrip('/\\'))
opts = frame_android.SSH_OPTS # read now: the server swaps in its multiplexed options
if _rsync():
cmd = ['rsync', '-a', *(['--delete'] if delete else []), '-e', shlex.join(['ssh', *opts]),
src.rstrip('/') + '/', f'{frame_android.FRAME}:{dest.rstrip("/")}/']
else:
# scp -r copies src *into* dest when dest exists, so dest must not.
ssh(f'rm -rf {shlex.quote(dest)}', timeout=60)
cmd = ['scp', *opts, '-r', src, f'{frame_android.FRAME}:{dest}']
try:
subprocess.run(cmd, check=True, capture_output=True, stdin=subprocess.DEVNULL, text=True,
errors='replace', timeout=timeout)
except subprocess.TimeoutExpired:
raise FrameError(f'copying {name} to the Frame timed out')
except subprocess.CalledProcessError as e:
raise FrameError(f'copying {name} to the Frame failed: {(e.stderr or "").strip()[-300:]}')
def _rsync():
# Not on Windows: a Windows rsync (cwRsync, MSYS2) wouldn't take the POSIX -e quoting.
return not frame_host.WINDOWS and bool(shutil.which('rsync'))
_install_lock = threading.Lock()
def install(path, name=None, exe=None, runtime=None, progress=None):
"""Sideload a .zip, folder or executable as a Devkit Game; returns the title dict.
name: the Steam name (sanitised to the title id), default from the file name.
exe: launch target relative to the title's root, default the best candidate.
runtime: a compat tool alias from RUNTIMES that suits the target (e.g.
'proton-stable' instead of the default Proton Experimental).
progress: optional callable(stage_text, fraction 0..1).
"""
plan = inspect(path, name)
try:
return install_plan(plan, name=name, exe=exe, runtime=runtime, progress=progress)
finally:
discard(plan)
def install_plan(plan, name=None, exe=None, runtime=None, progress=None):
"""Install an inspect() plan, optionally with another name, target or runtime."""
if name:
plan['name'], plan['id'] = name, title_id(name)
if exe or runtime:
_choose(plan, exe or plan['target'], runtime)
step = progress or (lambda *a: None)
with _install_lock:
return _install(plan, step)
def _install(plan, step):
gid = plan['id']
if not ID_RE.match(gid) or gid.lower() in RESERVED_IDS:
raise FrameError(f'bad title id {gid!r}')
step("Syncing Valve's devkit tools to the Frame", 0.02)
ensure_utils()
existed = ssh(f'test -d {GAMES}/{gid} && echo yes || true', timeout=30).strip() == 'yes'
step('Preparing the title folder', 0.05)
ready = _json_out(ssh(f'{PY}steamos-prepare-upload --gameid {gid}', timeout=60), 'steamos-prepare-upload')
directory = str(ready.get('directory') or '')
if not DIR_RE.match(directory) or not directory.endswith(f'/{GAMES}/{gid}'):
raise FrameError(f'steamos-prepare-upload returned an unexpected folder {directory!r}')
registered = False
try:
step(f"Copying {plan['size'] / 1e6:.0f} MB to the Frame", 0.1)
if _rsync():
_copy_tree(plan['root'], directory, delete=True)
else:
part = f"{directory.rsplit('/', 1)[0]}/.{gid}.upload"
_copy_tree(plan['root'], part)
ssh(f'rm -rf {directory} && mv {part} {directory}', timeout=120)
# Same modes Valve's client gives an upload (rsync --chmod=Du=rwx,Dgo=rx,Fu=rwx,Fog=rx).
ssh(f'chmod -R 755 {directory}', timeout=300)
step('Registering with Steam', 0.9)
parms = shortcut_parms(gid, directory, plan['target'], plan['runtime'])
reply = _json_out(ssh(f'{PY}steam-client-create-shortcut --parms {shlex.quote(json.dumps(parms))}',
timeout=90), 'steam-client-create-shortcut')
meta = {'id': gid, 'name': plan['name'], 'target': plan['target'], 'runtime': plan['runtime'],
'source': plan['source'], 'size': plan['size'], 'installed': time.strftime('%Y-%m-%dT%H:%M:%S')}
ssh(f'cat > {GAMES}/{gid}-framecontrol.json', input=json.dumps(meta, indent=1), timeout=30)
registered = True # the files stay: Steam registers them once it's running
if 'error' in reply:
raise FrameError(f"Uploaded, but Steam didn't register it: {reply['error']}. "
"With Steam running on the Frame, install it again.")
step('Done', 1.0)
meta.update(runtime_label=RUNTIMES[plan['runtime']]['label'], steam=str(reply.get('success', '')).strip())
return meta
finally:
if not registered and not existed:
# A first install that failed part-way: don't leave an orphan folder behind.
try:
ssh(f'rm -rf {GAMES}/{gid} {GAMES}/.{gid}.upload {_json_files(gid)}', timeout=60)
except FrameError:
pass
LIST_SCRIPT = r'''
import json, os
root = os.path.expanduser('~/devkit-game')
reserved = %r
out = []
for d in sorted(os.listdir(root)) if os.path.isdir(root) else []:
if d.startswith('.') or d.lower() in reserved or not os.path.isdir(os.path.join(root, d)):
continue
t = {'id': d}
for key, suffix in (('settings', '-settings.json'), ('argv', '-argv.json'), ('meta', '-framecontrol.json')):
try:
with open(os.path.join(root, d + suffix)) as f:
t[key] = json.load(f)
except (OSError, ValueError):
t[key] = None
out.append(t)
print(json.dumps(out))
''' % (RESERVED_IDS,)
def list_titles():
"""The Devkit Games on the Frame (any uploaded by Valve's client too)."""
raw = _json_out(ssh('python3 -', input=LIST_SCRIPT, timeout=30), 'the title list')
titles = []
for t in raw if isinstance(raw, list) else []:
if not ID_RE.match(str(t.get('id', ''))):
continue
settings, meta = t.get('settings') or {}, t.get('meta') or {}
argv = t.get('argv') if isinstance(t.get('argv'), list) else []
alias = str(settings.get('compat_tool') or '')
titles.append({'id': t['id'], 'name': str(meta.get('name') or t['id']),
'target': str(meta.get('target') or (argv[0] if argv else '')),
'runtime': alias, 'runtime_label': RUNTIMES.get(alias, {}).get('label', alias or 'not set'),
'source': str(meta.get('source') or ''), 'size': meta.get('size'),
'installed': meta.get('installed'), 'registered': t.get('settings') is not None,
'frame_control': bool(meta)})
return titles
def _check_id(gid):
gid = str(gid or '')
if not ID_RE.match(gid) or gid.lower() in RESERVED_IDS:
raise FrameError(f'bad title id {gid!r}')
if ssh(f'test -d {GAMES}/{gid} && echo yes || true', timeout=30).strip() != 'yes':
raise FrameError(f'{gid} is not installed')
return gid
def launch(gid):
gid = _check_id(gid)
ensure_utils()
# steam-devkit-rpc logs 'success' when Steam answers, but exits 0 after a
# 5 s timeout too, so read its log (stderr) rather than trust the exit code.
out = ssh(f'{PY}steam-devkit-rpc run-game gameid={gid} 2>&1', timeout=60)
if 'success' not in [line.strip() for line in out.splitlines()]:
raise FrameError(f"Steam didn't confirm the launch: {out.strip()[-300:] or 'no answer'}")
return {'id': gid}
def remove(gid):
# Not while an install runs: it could be this title, half copied or about to register.
if not _install_lock.acquire(blocking=False):
raise FrameError('an install is running; remove the title when it has finished')
try:
gid = _check_id(gid)
ensure_utils()
# steamos-delete removes the folder and syncs Steam's shortcuts; its json files stay, so clear them too.
ssh(f'{PY}steamos-delete --delete-title {gid}', timeout=120)
ssh(f'rm -f {_json_files(gid)}', timeout=30)
return {'id': gid}
finally:
_install_lock.release()
def public(plan):
"""A plan without its local paths, for the UI, with the runtimes each candidate may use."""
out = {k: v for k, v in plan.items() if k not in ('root', 'work', 'candidates')}
out['candidates'] = []
for c in plan['candidates']:
c = dict(c)
try:
c['runtimes'] = allowed_runtimes(c, plan['candidates'])
except FrameError as e:
c['runtimes'], c['blocked'] = [], str(e)
out['candidates'].append(c)
out['runtime_labels'] = {k: v['label'] for k, v in RUNTIMES.items()}
return out
def main():
cmd, *args = sys.argv[1:] or ['help']
def opt(flag):
return args[args.index(flag) + 1] if flag in args and args.index(flag) + 1 < len(args) else None
try:
if cmd == 'inspect' and args:
plan = inspect(args[0], opt('--name'))
try:
if opt('--exe') or opt('--runtime'):
_choose(plan, opt('--exe') or plan['target'], opt('--runtime'))
r = public(plan)
finally:
discard(plan)
elif cmd == 'install' and args:
r = install(args[0], name=opt('--name'), exe=opt('--exe'), runtime=opt('--runtime'),
progress=lambda text, _: print(text + '…', file=sys.stderr))
elif cmd == 'list':
r = list_titles()
elif cmd in ('launch', 'remove') and args:
r = globals()[cmd](args[0])
else:
sys.exit(__doc__)
except FrameError as e:
sys.exit(f'error: {e}')
print(json.dumps(r, indent=1))
if __name__ == '__main__':
main()
+178 -12
View File
@@ -221,12 +221,18 @@
.and-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: 22px; align-items: start; }
.and-col { display: grid; gap: 22px; align-content: start; }
.rep-item .s { white-space: normal; }
#repDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
#repDlg, #titleDlg { background: #1e2329; color: var(--text); border: 1px solid rgba(255,255,255,.1); border-radius: 4px;
padding: 22px; width: min(560px, 92vw); box-shadow: 0 20px 60px rgba(0,0,0,.6); }
#repDlg::backdrop { background: rgba(0,0,0,.55); }
#repDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
#repForm label { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
#repForm label input[type=text], #repForm textarea { margin-top: 5px; }
#repDlg::backdrop, #titleDlg::backdrop { background: rgba(0,0,0,.55); }
#repDlg h2, #titleDlg h2 { margin: 0 0 14px; font-size: 15px; letter-spacing: 1.5px; text-transform: uppercase; color: var(--bright); }
#repForm label, #titleForm label { display: block; font-size: 12.5px; color: var(--muted); margin-top: 10px; }
#repForm label input[type=text], #repForm textarea, #titleForm label input, #titleForm label select { margin-top: 5px; }
#titleForm select { width: 100%; background: rgba(0,0,0,.28); color: var(--text); border: 1px solid transparent;
border-radius: 3px; padding: 8px 10px; font: inherit; }
#titleForm select:focus { outline: none; border-color: var(--blue); }
#titleForm .note { font-size: 12.5px; color: var(--muted); margin-top: 10px; }
#titleForm .note.warn { color: #d9a23a; }
#titleList { margin-top: 8px; }
#repForm fieldset { border: 0; padding: 0; margin: 12px 0 0; }
#repForm legend { font-size: 12.5px; color: var(--muted); padding: 0; margin-bottom: 4px; }
#repForm label.opt { display: inline-flex; align-items: center; gap: 6px; margin: 4px 14px 0 0; color: var(--text); font-size: 13.5px; }
@@ -458,10 +464,13 @@
<div class="shelf-head"><h2>Send to Frame</h2></div>
<div class="drop" id="drop" tabindex="0" role="button" aria-label="Choose files to send">
<b>Drop files here</b>
Files land in <code>~/Downloads</code>. <code>.apk</code> files install as their own Android app.
Files land in <code>~/Downloads</code>. <code>.apk</code> files install as their own Android app;
a game's <code>.zip</code>, folder or <code>.exe</code> becomes a title in the Steam library.
<input type="file" id="fileInput" multiple hidden>
</div>
<div class="progress" id="prog"><i></i></div>
<div class="shelf-head" style="margin-top:16px"><h2>Sideloaded titles</h2><span class="count" id="titleCount"></span></div>
<div class="list" id="titleList"><div class="sub">Loading…</div></div>
<textarea id="clipText" style="margin-top:16px" placeholder="Text to put on the Frame's clipboard…"></textarea>
<div class="row" style="margin-top:8px">
<button class="action small" id="clipSend">Send text</button>
@@ -567,6 +576,22 @@
<button type="submit" class="action small" id="repSave">Save report</button></div>
</form>
</dialog>
<dialog id="titleDlg" aria-labelledby="titleTitle">
<form method="dialog" id="titleForm">
<h2 id="titleTitle">Add to the Steam library</h2>
<div class="sub" id="titleSrc"></div>
<label>Name<input type="text" id="titleName" maxlength="120" required></label>
<div class="note" id="titleIdNote"></div>
<label>Launches<select id="titleExe"></select></label>
<label>Runtime<select id="titleRt"></select></label>
<div class="note" id="titleNote"></div>
<div class="note warn" id="titleWarn"></div>
<div class="row rep-actions"><span class="sub" id="titleMsg"></span><span class="spacer"></span>
<button type="button" class="small" id="titleCancel">Cancel</button>
<button type="button" class="small" id="titlePush">Copy to ~/Downloads instead</button>
<button type="submit" class="action small" id="titleInstall">Install</button></div>
</form>
</dialog>
<div class="toast" id="toast"></div>
<script>
@@ -1022,9 +1047,9 @@ document.querySelectorAll("[data-view]").forEach(b => b.onclick = () => setView(
document.querySelectorAll("[data-eye]").forEach(b => b.onclick = () => setEye(b.dataset.eye));
// ---- controls ----
$("refreshAll").onclick = refresh;
$("refreshAll").onclick = () => { refresh(); loadTitles(); }; // titles too: the Frame may have been asleep at start
document.addEventListener("keydown", e => {
if (e.key === "r" && !e.metaKey && !e.ctrlKey && !/INPUT|TEXTAREA/.test(document.activeElement.tagName)) refresh();
if (e.key === "r" && !e.metaKey && !e.ctrlKey && !/INPUT|TEXTAREA|SELECT/.test(document.activeElement.tagName)) $("refreshAll").onclick();
});
document.body.addEventListener("click", async (e) => {
const b = e.target.closest("button");
@@ -1085,7 +1110,12 @@ drop.onkeydown = e => { if (e.key === "Enter" || e.key === " ") { e.preventDefau
$("fileInput").onchange = () => sendFiles([...$("fileInput").files]);
["dragenter", "dragover"].forEach(t => drop.addEventListener(t, e => { e.preventDefault(); drop.classList.add("over"); }));
["dragleave", "drop"].forEach(t => drop.addEventListener(t, e => { e.preventDefault(); drop.classList.remove("over"); }));
drop.addEventListener("drop", e => sendFiles([...e.dataTransfer.files]));
// Folders only show up as directory entries, and only during the drop event itself.
drop.addEventListener("drop", e => {
const items = [...(e.dataTransfer.items || [])].filter(it => it.kind === "file");
const dirs = new Set(items.map((it, i) => it.webkitGetAsEntry && it.webkitGetAsEntry()?.isDirectory ? i : -1).filter(i => i >= 0));
sendFiles([...e.dataTransfer.files], dirs);
});
function upload(file, mode) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
@@ -1105,9 +1135,17 @@ function upload(file, mode) {
xhr.send(file);
});
}
async function sendFiles(files) {
for (const f of files) {
if (!f.size) { toast(`${f.name}: folders and empty files aren't supported here; zip the folder first`, true); continue; }
const TITLE_EXT = /\.(zip|exe)$/i;
async function sendFiles(files, dirs = new Set()) {
for (const [i, f] of files.entries()) {
const path = window.frameApp?.pathForFile ? window.frameApp.pathForFile(f) : "";
if (dirs.has(i)) {
if (path) await sideload(f, path, true);
else toast(`${f.name}: zip the folder first (folders need the Frame Control app)`, true);
continue;
}
if (!f.size) { toast(`${f.name}: empty files aren't supported`, true); continue; }
if (TITLE_EXT.test(f.name)) { await sideload(f, path); continue; }
const apk = f.name.toLowerCase().endsWith(".apk");
await act(apk ? `Install ${f.name}` : `Copy ${f.name} to ~/Downloads`, () => upload(f, apk ? "apk" : "push"));
}
@@ -1115,6 +1153,134 @@ async function sendFiles(files) {
refresh();
}
// ---- sideloaded titles (a Linux or Windows build as a Steam Devkit Game) ----
const RESERVED_IDS = ["steam", "steamdeckard", "steamvr", "steamvrdeckard"];
function titleId(name) { // mirrors frame_titles.title_id: the name Steam shows
const trim = x => x.replace(/^[_-]+|[_-]+$/g, "");
let s = trim(trim(String(name).trim().replace(/[^A-Za-z0-9_-]+/g, "_").replace(/_+/g, "_")).slice(0, 64));
if (RESERVED_IDS.includes(s.toLowerCase())) s += "-game";
return s;
}
const ARCH = { arm64: "ARM64", x86_64: "x86-64", x86: "32-bit x86" };
function candLabel(c) {
const kind = c.format === "pe" ? `Windows ${ARCH[c.arch] || c.arch}` : c.format === "elf" ? `Linux ${ARCH[c.arch] || c.arch}` : "script";
return `${c.path} · ${kind} · ${gb(c.size)}${c.skip ? " · helper?" : ""}`;
}
// Inspect (upload, or the local path in the app), confirm in the dialog, then install with progress.
// One at a time, so a second drop doesn't take over the dialog of the first.
let sideloadQueue = Promise.resolve();
function sideload(...args) {
const run = sideloadQueue.then(() => sideloadOne(...args));
sideloadQueue = run.catch(() => {});
return run;
}
async function sideloadOne(file, path, isDir = false) {
const canPush = !isDir && file.size > 0;
log(`Reading ${file.name}…`);
let r;
try { r = path ? await api("/api/titles", { action: "inspect", path }) : await upload(file, "title"); }
catch (e) {
log(`${file.name}: ${e.message}`, "e");
if (canPush && confirm(`${file.name}: ${e.message}\n\nCopy it to ~/Downloads instead?`))
await act(`Copy ${file.name} to ~/Downloads`, () => upload(file, "push"));
return;
}
const choice = await confirmTitle(r.plan, canPush);
if (choice === "push") {
api("/api/titles", { action: "discard", token: r.token }).catch(() => {});
await act(`Copy ${file.name} to ~/Downloads`, () => upload(file, "push"));
return;
}
if (!choice) { api("/api/titles", { action: "discard", token: r.token }).catch(() => {}); return; }
await installTitle(r.token, choice);
}
function confirmTitle(plan, canPush) {
return new Promise(resolve => {
const dlg = $("titleDlg");
$("titlePush").hidden = !canPush;
$("titleSrc").textContent = `${plan.source} · ${gb(plan.size)}`;
$("titleName").value = plan.name;
const idNote = () => { const id = titleId($("titleName").value);
$("titleIdNote").textContent = id ? `Shows in Steam as ${id}` : "Needs some letters or digits"; };
$("titleName").oninput = idNote; idNote();
$("titleExe").innerHTML = plan.candidates.map(c =>
`<option value="${esc(c.path)}"${c.path === plan.target ? " selected" : ""}${c.blocked ? " disabled" : ""}>${esc(candLabel(c))}</option>`).join("");
const runtimes = () => {
const c = plan.candidates.find(x => x.path === $("titleExe").value) || {};
const list = c.runtimes || [];
$("titleRt").innerHTML = list.map(k => `<option value="${esc(k)}">${esc(plan.runtime_labels[k] || k)}</option>`).join("");
$("titleRt").disabled = list.length < 2;
if (c.path === plan.target) {
$("titleRt").value = plan.runtime;
$("titleNote").textContent = plan.note + " Device behaviour is inferred from Valve's devkit tools.";
} else $("titleNote").textContent = "Runtime follows the chosen program.";
$("titleWarn").textContent = c.skip ? "This looks like an installer or helper, not the game itself." : "";
};
$("titleExe").onchange = runtimes; runtimes();
$("titleMsg").textContent = "";
// Resolve on this dialog's own close event (it arrives a frame later), so a
// queued next title can't receive it. Escape closes with the choice left null.
let choice = null;
dlg.addEventListener("close", () => resolve(choice), { once: true });
const finish = v => { choice = v; dlg.close(); };
$("titleCancel").onclick = () => finish(null);
$("titlePush").onclick = () => finish("push");
$("titleForm").onsubmit = e => {
e.preventDefault();
if (!titleId($("titleName").value)) return ($("titleMsg").textContent = "Enter a name");
finish({ name: $("titleName").value.trim(), exe: $("titleExe").value, runtime: $("titleRt").value });
};
dlg.showModal();
$("titleName").focus();
});
}
async function installTitle(token, choice) {
const label = `Install ${titleId(choice.name)}`;
const start = await act(label, () => api("/api/titles", { action: "install", token, ...choice }));
if (!start) return;
const bar = $("prog").firstElementChild;
$("prog").style.display = "block"; bar.style.width = "0";
let stage = "";
try {
for (;;) {
await new Promise(ok => setTimeout(ok, 1000));
let j;
try { j = await api(`/api/titles/job?token=${encodeURIComponent(start.job)}`); }
catch (e) { log(`${label}: ${e.message}`, "e"); return; }
bar.style.width = (100 * (j.fraction || 0)) + "%";
if (j.stage && j.stage !== stage && !j.done) { stage = j.stage; log(`${stage}…`); $("lastLog").textContent = stage + "…"; }
if (j.done) {
if (j.error) { log(`${label} failed: ${j.error}`, "e"); toast(`${label} failed: ${j.error}`, true); }
else { log(j.message, "ok"); toast(j.message); }
break;
}
}
} finally { $("prog").style.display = "none"; loadTitles(); }
}
async function loadTitles() {
let list;
try { list = (await api("/api/titles")).titles; }
catch (e) { $("titleList").innerHTML = `<div class="sub">${esc(e.message)}</div>`; return; }
$("titleCount").textContent = list.length ? `${list.length}` : "";
$("titleList").innerHTML = list.length ? list.map(t => `
<div class="item">
<div class="grow"><div class="t">${esc(t.id)}</div>
<div class="s" title="${esc(t.target)}">${esc(t.runtime_label)}${t.target ? " · " + esc(t.target) : ""}</div></div>
<button class="play small" data-title="launch" data-id="${esc(t.id)}" title="Launch in the headset">▶</button>
<button class="small danger" data-title="remove" data-id="${esc(t.id)}">Remove</button>
</div>`).join("") : `<div class="sub">None yet. Drop a game's .zip, folder or .exe above.</div>`;
}
$("titleList").onclick = async e => {
const b = e.target.closest("[data-title]"); if (!b) return;
const id = b.dataset.id;
if (b.dataset.title === "remove") {
if (!confirm(`Remove ${id} and its files from the Frame?`)) return;
await act(`Remove ${id}`, () => api("/api/titles", { action: "remove", id }), b);
loadTitles();
} else await act(`Launch ${id}`, () => api("/api/titles", { action: "launch", id }), b);
};
loadTitles();
// ---- Steam games: install owned games, find and buy on the store ----
const FLABEL = { 3: "Frame Verified", 2: "Playable", 1: "Unsupported", 0: "Unknown" };
+131 -3
View File
@@ -15,6 +15,7 @@ import json
import os
import queue
import re
import secrets
import shlex
import shutil
import signal
@@ -36,6 +37,7 @@ import frame_android # noqa: E402
import frame_catalog # noqa: E402
import frame_host # noqa: E402
import frame_store # noqa: E402
import frame_titles # noqa: E402
HERE = Path(__file__).resolve().parent
FRAME = os.environ.get("FRAME_ALIAS", "frame")
@@ -470,6 +472,120 @@ def android(body):
raise Failure("unknown action", 400)
# ---- Sideloaded titles (Linux/Windows builds as Steam Devkit Games) --------
#
# Installing is two steps: inspect (a dropped file is uploaded and a zip
# unpacked here, once) returns a token and the detected target and runtime for
# the page to confirm; install then runs in the background with progress the
# page polls. Unconfirmed uploads are dropped after STAGE_TTL.
STAGE_TTL = 3600
_titles_lock = threading.Lock()
_staged = {} # token -> {"plan", "dir" (an upload's temp dir or None), "time"}
_title_jobs = {} # token -> {"stage", "fraction", "done", "error", "title", "time"}
def _drop_staged(entry):
frame_titles.discard(entry["plan"])
if entry.get("dir"):
shutil.rmtree(entry["dir"], ignore_errors=True)
def _purge_titles(now=None):
now = now or time.time()
with _titles_lock:
stale = [_staged.pop(t) for t in [t for t, e in _staged.items() if now - e["time"] > STAGE_TTL]]
for t in [t for t, j in _title_jobs.items() if j["done"] and now - j["time"] > STAGE_TTL]:
del _title_jobs[t]
for e in stale:
_drop_staged(e)
def stage_title(path, temp_dir=None, name=None):
"""Inspect a .zip, folder or program and keep it for install; returns the plan and a token."""
_purge_titles()
try:
plan = frame_titles.inspect(path, name)
except BaseException as e:
# Whatever went wrong, nothing will ever claim this upload.
if temp_dir:
shutil.rmtree(temp_dir, ignore_errors=True)
if isinstance(e, frame_android.FrameError):
raise Failure(str(e), 400)
raise
token = secrets.token_hex(12)
with _titles_lock:
_staged[token] = {"plan": plan, "dir": temp_dir, "time": time.time()}
return {"message": f"Read {plan['source']}: {plan['target']} with {plan['runtime_label']}",
"token": token, "plan": frame_titles.public(plan)}
def _run_title_install(token, entry, name, exe, runtime):
def update(**fields): # the page reads jobs from other threads; change them under the lock
with _titles_lock:
_title_jobs[token].update(fields)
try:
m = frame_titles.install_plan(entry["plan"], name=name, exe=exe, runtime=runtime,
progress=lambda stage, fraction: update(stage=stage, fraction=fraction))
update(title=m, message=f"Installed {m['id']} in the Steam library ({m['runtime_label']})")
except frame_android.FrameError as e:
update(error=str(e))
except Exception as e:
update(error=f"{type(e).__name__}: {e}")
finally:
_drop_staged(entry)
update(done=True, time=time.time())
def titles(body):
"""Sideloaded titles (frame_titles.py): inspect a local path, install, discard, launch, remove."""
action = body.get("action")
if action == "inspect":
# The app's page passes a dropped folder's path (Electron knows it); browsers upload instead.
path = str(body.get("path") or "")
if not os.path.isabs(path) or not os.path.exists(path):
raise Failure("inspect needs the absolute path of a .zip, folder or program", 400)
return stage_title(path, name=body.get("name") or None)
if action in ("install", "discard"):
token = str(body.get("token") or "")
with _titles_lock:
entry = _staged.pop(token, None)
if not entry:
raise Failure("that upload has expired; drop the file again", 400)
if action == "discard":
_drop_staged(entry)
return {"message": "Discarded"}
with _titles_lock:
_title_jobs[token] = {"stage": "Starting", "fraction": 0, "done": False, "error": None,
"message": None, "title": None, "time": time.time()}
ensure_master()
opt = lambda k: str(body.get(k) or "") or None # noqa: E731
threading.Thread(target=_run_title_install, daemon=True,
args=(token, entry, opt("name"), opt("exe"), opt("runtime"))).start()
return {"message": f"Installing {entry['plan']['source']}", "job": token}
if action not in ("launch", "remove"):
raise Failure("unknown action", 400)
gid = str(body.get("id", ""))
if not frame_titles.ID_RE.match(gid):
raise Failure("bad title id", 400)
ensure_master()
try:
m = getattr(frame_titles, action)(gid)
except frame_android.FrameError as e:
raise Failure(str(e))
return {"message": f"{'Launching' if action == 'launch' else 'Removed'} {m['id']}"}
def title_job(query):
with _titles_lock:
job = _title_jobs.get((parse_qs(query).get("token") or [""])[0])
snapshot = job and {k: v for k, v in job.items() if k != "time"}
if not snapshot:
raise Failure("no such install", 404)
return snapshot
# ---- Android display (wm size / wm density / font_scale over ADB) -----------
#
# Each running Lepton instance listens for ADB on the Frame (5555 is Lepton
@@ -760,7 +876,7 @@ def android_display(body):
return {"message": f"Port {port}: " + "; ".join(c.split(";")[0] for c in cmds), "display": now}
POST = {"/api/android/display": android_display, "/api/android": android,"/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
POST = {"/api/android/display": android_display, "/api/android": android, "/api/titles": titles, "/api/launch": launch, "/api/steam": steam, "/api/volume": set_volume, "/api/clipboard": clipboard,
"/api/flatpak": flatpak, "/api/open": open_thing, "/api/shots/save": save_shots}
@@ -862,6 +978,11 @@ class Handler(BaseHTTPRequestHandler):
elif path == "/api/android":
ensure_master()
self.send_json({"apps": frame_android.list_apps()})
elif path == "/api/titles":
ensure_master()
self.send_json({"titles": frame_titles.list_titles()})
elif path == "/api/titles/job":
self.send_json(title_job(url.query))
elif path == "/api/android/displays":
self.send_json(android_displays())
elif path == "/api/android/reports":
@@ -980,7 +1101,8 @@ class Handler(BaseHTTPRequestHandler):
_live_tunnels.discard(proc)
def upload(self):
"""Raw file body. X-Filename names it; X-Mode is 'push', 'apk' (install) or 'apkinfo' (read only)."""
"""Raw file body. X-Filename names it; X-Mode is 'push', 'apk' (install), 'apkinfo' (read only)
or 'title' (a .zip or program to sideload: inspected and kept for /api/titles install)."""
name = os.path.basename(unquote(self.headers.get("X-Filename", "")))
mode = self.headers.get("X-Mode", "push")
length = int(self.headers.get("Content-Length") or 0)
@@ -991,6 +1113,7 @@ class Handler(BaseHTTPRequestHandler):
if mode in ("apk", "apkinfo") and not name.lower().endswith(".apk"):
raise Failure("APK install needs a .apk file", 400)
tmp = Path(tempfile.mkdtemp(prefix="frame-ui-"))
keep = False
try:
dest = tmp / name
with open(dest, "wb") as f:
@@ -1014,6 +1137,9 @@ class Handler(BaseHTTPRequestHandler):
except frame_android.FrameError as e:
info["blocker"] = str(e)
return {"message": f"Read {info['label']} {info['version']}", "apk": info}
if mode == "title":
keep = True # stage_title owns tmp now, and removes it on failure
return stage_title(str(dest), temp_dir=str(tmp))
if mode == "apk":
ensure_master()
try:
@@ -1023,7 +1149,8 @@ class Handler(BaseHTTPRequestHandler):
return {"message": f"Installed {m['label']} as its own app in the Steam library", "app": m}
return {"message": push_file(dest)}
finally:
shutil.rmtree(tmp, ignore_errors=True)
if not keep:
shutil.rmtree(tmp, ignore_errors=True)
def main():
@@ -1059,6 +1186,7 @@ def main():
for proc in list(_live_tunnels): # ADB forwards and video streams cut off mid-way
if proc.poll() is None:
proc.terminate()
_purge_titles(now=float("inf")) # unconfirmed title uploads
if __name__ == "__main__":