SmartestHome/hosts/steam-tv-box/configs/session/steam-shortcut-prism

269 lines
11 KiB
Python
Executable File

#!/usr/bin/env python3
"""Registers Prism Launcher as a Steam "non-Steam game". Installed to
/usr/local/bin/steam-shortcut-prism, run by steam-session before Steam starts.
WHY
---
Launching Prism directly gets you a plain Flatpak window and a Minecraft that sees a
raw evdev gamepad, which is to say: no controller support worth the name. Launching it
*through* Steam puts it inside the Steam Runtime with Steam Input active, which is what
makes the Steam Controller API present for it. Concretely that means the overlay works,
the pad shows up as a configurable controller with Steam's own per-game bindings, the
box's Steam Controller / Deck-style pads get their gyro and back buttons, and Big
Picture treats Minecraft like any other title in the library instead of a hole you fall
out of the UI into.
There is no command-line flag for this. Steam's only mechanism for a non-Steam game is
an entry in the user's binary `shortcuts.vdf`, which is what this writes.
WHY IT CANNOT BE BAKED INTO THE IMAGE
-------------------------------------
shortcuts.vdf lives under ~/.steam/steam/userdata/<steamID3>/config/, and that
directory does not exist until somebody has logged into Steam on this machine. The
account is not known at build time and must not be — no Steam credentials go into an
ISO. So this runs per-session instead, is idempotent, and no-ops quietly when Steam has
never been logged into (the common state on a freshly flashed box, right up until
somebody signs in on the TV).
WHY IT RUNS BEFORE STEAM STARTS
-------------------------------
Steam reads shortcuts.vdf at startup and rewrites it from memory at shutdown. Writing
it underneath a running client means the change is silently reverted the next time
Steam exits. steam-session calls this first for that reason; running it by hand while
Steam is up is refused below rather than being allowed to quietly do nothing.
FORMAT NOTE / VERIFY BEFORE TRUSTING IT
---------------------------------------
The binary VDF encoding below (0x00 map, 0x01 string, 0x02 int32, 0x08 end) and the
non-Steam AppID derivation (CRC32 of Exe+AppName, high bit set) are the long-standing,
widely-reimplemented community format — Valve documents neither. They are believed
correct but were NOT verified against a real Steam client from this environment. The
check on first boot is simply: does "Prism Launcher" appear in Big Picture's library,
and does the pad work inside Minecraft. If the file turns out to be malformed Steam
discards it silently, which is why this keeps a .bak (see below) rather than writing in
place.
"""
from __future__ import annotations
import binascii
import glob
import os
import shutil
import subprocess
import sys
import time
APP_NAME = "Prism Launcher"
# The wrapper, not `flatpak run …` directly: prism-launch is where the "is it even
# installed" check and the Java/instance notes live, and pointing Steam at a stable
# path means this entry does not change when the Flatpak app ID does.
EXE = "/usr/local/bin/prism-launch"
START_DIR = "/usr/local/bin"
# --steam tells prism-launch it is already inside Steam's runtime, so it does not
# recurse back through `steam steam://rungameid/...` and launch itself forever.
LAUNCH_OPTIONS = "--steam"
USERDATA_GLOB = os.path.expanduser("~/.steam/steam/userdata/*/config")
# Flatpak'd and Snap'd Steam put userdata elsewhere; this image installs Steam from
# apt, so the path above is the real one. The alternates are checked anyway because
# somebody debugging on a laptop will have one of them.
ALT_GLOBS = (
os.path.expanduser("~/.local/share/Steam/userdata/*/config"),
os.path.expanduser("~/.var/app/com.valvesoftware.Steam/data/Steam/userdata/*/config"),
)
def log(message: str) -> None:
print(f"steam-shortcut-prism: {message}")
# --- binary VDF -----------------------------------------------------------------
def _string(key: str, value: str) -> bytes:
return b"\x01" + key.encode("utf-8") + b"\x00" + value.encode("utf-8") + b"\x00"
def _int32(key: str, value: int) -> bytes:
return b"\x02" + key.encode("utf-8") + b"\x00" + value.to_bytes(4, "little", signed=False)
def shortcut_app_id(exe: str, app_name: str) -> int:
"""The 32-bit ID Steam gives a non-Steam shortcut.
CRC32 of the Exe field concatenated with AppName, with the top bit set. Steam
quotes the Exe field in the file it writes, and the CRC is taken over the quoted
form — getting that wrong produces an ID that no `steam://rungameid/` URL matches,
which looks exactly like "the shortcut didn't work" with nothing in any log.
"""
key = f'"{exe}"{app_name}'.encode("utf-8")
return binascii.crc32(key) | 0x80000000
def run_game_id(app_id: int) -> int:
"""The 64-bit ID `steam://rungameid/` wants for a shortcut."""
return (app_id << 32) | 0x02000000
def encode_shortcuts(entries: list[dict]) -> bytes:
out = bytearray(b"\x00shortcuts\x00")
for index, entry in enumerate(entries):
out += b"\x00" + str(index).encode("ascii") + b"\x00"
out += _int32("appid", entry["appid"])
out += _string("AppName", entry["AppName"])
out += _string("Exe", entry["Exe"])
out += _string("StartDir", entry["StartDir"])
out += _string("icon", entry.get("icon", ""))
out += _string("ShortcutPath", entry.get("ShortcutPath", ""))
out += _string("LaunchOptions", entry.get("LaunchOptions", ""))
out += _int32("IsHidden", 0)
# AllowDesktopConfig + AllowOverlay are the two that matter for the whole point
# of this file: the overlay is what carries Steam Input's binding UI, and
# desktop-config is what lets a pad still work when Big Picture is not focused.
out += _int32("AllowDesktopConfig", 1)
out += _int32("AllowOverlay", 1)
out += _int32("OpenVR", 0)
out += _int32("Devkit", 0)
out += _string("DevkitGameID", "")
out += _int32("DevkitOverrideAppID", 0)
out += _int32("LastPlayTime", entry.get("LastPlayTime", 0))
out += b"\x00tags\x00\x08"
out += b"\x08"
out += b"\x08\x08"
return bytes(out)
def decode_app_names(data: bytes) -> list[str]:
"""Just enough parsing to answer "is our entry already in here?".
A full VDF reader is not needed and would be more to get wrong: this only has to
decide between rewriting the file and leaving it alone.
"""
names = []
marker = b"\x01AppName\x00"
position = data.find(marker)
while position != -1:
start = position + len(marker)
end = data.find(b"\x00", start)
if end == -1:
break
names.append(data[start:end].decode("utf-8", "replace"))
position = data.find(marker, end)
return names
# --- the work -------------------------------------------------------------------
def config_dirs() -> list[str]:
found = sorted(glob.glob(USERDATA_GLOB))
for pattern in ALT_GLOBS:
found += sorted(glob.glob(pattern))
# userdata/0/ is Steam's placeholder for "no account", not a real profile.
return [d for d in found if os.path.basename(os.path.dirname(d)) != "0"]
def steam_is_running() -> bool:
try:
return subprocess.run(
["pgrep", "-u", str(os.getuid()), "-x", "steam"],
capture_output=True,
check=False,
timeout=5,
).returncode == 0
except (OSError, subprocess.SubprocessError):
return False
def write_shortcut(config_dir: str, app_id: int) -> bool:
path = os.path.join(config_dir, "shortcuts.vdf")
existing = b""
if os.path.exists(path):
try:
with open(path, "rb") as handle:
existing = handle.read()
except OSError as exc:
log(f"could not read {path}: {exc}")
return False
if APP_NAME in decode_app_names(existing):
log(f"{APP_NAME} is already in {path}")
return True
if existing:
# Anything already in shortcuts.vdf was put there by hand, and this parser is
# not good enough to rewrite the file without losing it. So: back it up, tell
# the human exactly what to do, and refuse rather than destroy their entries.
backup = f"{path}.bak-{int(time.time())}"
try:
shutil.copyfile(path, backup)
except OSError as exc:
log(f"could not back up {path}: {exc}")
return False
log(f"{path} already has other shortcuts in it; backed it up to {backup}.")
log("Refusing to rewrite it — this script only knows how to write a file it")
log("owns entirely, and rewriting would drop the entries already there.")
log(f"Add {APP_NAME} by hand in Steam (Games -> Add a Non-Steam Game -> {EXE}),")
log(f"then set its launch options to: {LAUNCH_OPTIONS}")
return False
entry = {
"appid": app_id,
"AppName": APP_NAME,
"Exe": f'"{EXE}"',
"StartDir": f'"{START_DIR}"',
"LaunchOptions": LAUNCH_OPTIONS,
}
try:
os.makedirs(config_dir, exist_ok=True)
temporary = f"{path}.tmp"
with open(temporary, "wb") as handle:
handle.write(encode_shortcuts([entry]))
os.replace(temporary, path)
except OSError as exc:
log(f"could not write {path}: {exc}")
return False
log(f"registered {APP_NAME} in {path} (appid {app_id})")
return True
def main() -> int:
app_id = shortcut_app_id(EXE, APP_NAME)
game_id = run_game_id(app_id)
# Written unconditionally, even when there is no Steam profile yet: prism-launch
# reads this to build its steam://rungameid/ URL, and it is derived from two
# constants in this file, so it is correct whether or not the shortcut exists yet.
state_dir = os.path.expanduser("~/.local/state/steamtv")
try:
os.makedirs(state_dir, exist_ok=True)
with open(os.path.join(state_dir, "prism-gameid"), "w", encoding="utf-8") as handle:
handle.write(f"{game_id}\n")
except OSError as exc:
log(f"could not record the game id: {exc}")
if steam_is_running():
log("Steam is running — it would overwrite shortcuts.vdf on exit and discard")
log("anything written now. Quit Steam and re-run, or just let the next session")
log("do it (steam-session runs this before Steam starts).")
return 1
dirs = config_dirs()
if not dirs:
log("no Steam userdata directory yet — nobody has logged into Steam on this")
log("machine. Nothing to do; this will register itself on the session after")
log("the first Steam login.")
return 0
ok = True
for config_dir in dirs:
# Every logged-in account on the box gets the entry: which one is signed in at
# any moment is not knowable here, and a stale entry for an account that never
# plays Minecraft costs nothing.
ok = write_shortcut(config_dir, app_id) and ok
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())