269 lines
11 KiB
Python
Executable File
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())
|