aboutsummaryrefslogtreecommitdiffstats
path: root/games/games.py
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-27 12:58:43 +0100
committerDanilo M. <danix@danix.xyz>2026-10-27 12:58:43 +0100
commita35688e2e3a4e94d0f43f2548dfa6384a1bf1276 (patch)
tree4c39412347f1877fc44d10fc4279f01339e65004 /games/games.py
parente320c1e0e4452d9bf4903d64e0dd3e1b3ced95d2 (diff)
downloadquickshell-a35688e2e3a4e94d0f43f2548dfa6384a1bf1276.tar.gz
quickshell-a35688e2e3a4e94d0f43f2548dfa6384a1bf1276.zip
games: the Steam library as an endless strip with a large preview
An overlay on DP-1: a looping PathView of slanted tiles along the bottom, the centred one shown large above it in Steam's hero art with its title. Installed games and non-Steam shortcuts come first, by last played; owned games that are not installed follow, framed in peach, and Enter installs them. Launches go through steam -silent so the Steam window stays down. games.py reads appmanifests, the binary shortcuts.vdf and Steam's library cache on every opening; owned games come from the Web API through a cache refreshed in the background. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'games/games.py')
-rwxr-xr-xgames/games.py190
1 files changed, 190 insertions, 0 deletions
diff --git a/games/games.py b/games/games.py
new file mode 100755
index 0000000..eaf7d33
--- /dev/null
+++ b/games/games.py
@@ -0,0 +1,190 @@
+#!/usr/bin/python3
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+
+"""The Steam library as JSON: installed games, non-Steam shortcuts, and the
+owned games that are not installed.
+
+ games.py print the list (owned games from the cache)
+ games.py --refresh fetch the owned games from the Steam Web API
+ games.py --check run the self-check
+
+Installed games and shortcuts are read from Steam's own files on every run.
+Owned games need the Web API, so they come from a cache that --refresh
+rewrites; the key is read from `pass` (GAMES_PASS_ENTRY).
+"""
+
+import json
+import os
+import re
+import subprocess
+import sys
+import urllib.request
+from pathlib import Path
+
+STEAM = Path(os.environ.get("GAMES_STEAM", "~/.local/share/Steam")).expanduser()
+CACHE = Path(os.environ.get("XDG_CACHE_HOME", "~/.cache")).expanduser() / "keystone/games"
+OWNED = CACHE / "owned.json"
+PASS_ENTRY = os.environ.get("GAMES_PASS_ENTRY", "store.steampowered.com/qs-launcher")
+API = "https://api.steampowered.com/IPlayerService/GetOwnedGames/v1/"
+# Installed like games, but runtimes Steam pulls in on its own.
+TOOLS = re.compile(r"^(Proton\b|Steam Linux Runtime|Steamworks Common)")
+STEAMID_BASE = 76561197960265728
+
+
+def kv(text):
+ """The "key" "value" pairs of a text VDF, flattened. Enough for an
+ appmanifest, whose wanted keys are all unique."""
+ return dict(re.findall(r'"([^"]+)"\s+"([^"]*)"', text))
+
+
+def binvdf(data, i=0):
+ """Parse a binary VDF map starting at i; return (dict, next index).
+ Keys are lowercased: Steam has written both `appid` and `AppName`."""
+ out = {}
+ while True:
+ t = data[i]
+ i += 1
+ if t == 8:
+ return out, i
+ end = data.index(b"\0", i)
+ key = data[i:end].decode(errors="replace").lower()
+ i = end + 1
+ if t == 0:
+ out[key], i = binvdf(data, i)
+ elif t == 1:
+ end = data.index(b"\0", i)
+ out[key] = data[i:end].decode(errors="replace")
+ i = end + 1
+ elif t == 2:
+ out[key] = int.from_bytes(data[i:i + 4], "little")
+ i += 4
+ else:
+ raise ValueError(f"unknown binary VDF type {t}")
+
+
+def shortcut_gameid(appid):
+ """The id steam://rungameid/ takes for a non-Steam shortcut."""
+ return str((appid & 0xFFFFFFFF) << 32 | 0x02000000)
+
+
+def user():
+ """The Steam account's userdata dir: the most recently touched one."""
+ dirs = [d for d in (STEAM / "userdata").glob("*") if d.name.isdigit() and d.name != "0"]
+ return max(dirs, key=lambda d: d.stat().st_mtime) if dirs else None
+
+
+def art(appid):
+ """The portrait cover and the wide hero image from Steam's library
+ cache. Older entries keep them at the top of the app's directory, newer
+ ones in hashed subdirectories, and the cover there is library_capsule."""
+ d = STEAM / "appcache/librarycache" / str(appid)
+ found = {}
+ for key, names in (("cover", ("library_600x900*.jpg", "library_capsule*.jpg")),
+ ("hero", ("library_hero.jpg",))):
+ hits = [h for n in names for h in sorted(d.glob(f"**/{n}"))]
+ found[key] = str(hits[0]) if hits else ""
+ return found
+
+
+def installed():
+ libs = re.findall(r'"path"\s+"([^"]+)"', (STEAM / "steamapps/libraryfolders.vdf").read_text())
+ out = {}
+ for lib in libs:
+ for acf in Path(lib, "steamapps").glob("appmanifest_*.acf"):
+ m = kv(acf.read_text(errors="replace"))
+ name = m.get("name", "").strip()
+ if name and not TOOLS.match(name):
+ out[m["appid"]] = {"name": name, "last": int(m.get("LastPlayed", 0))}
+ return out
+
+
+def shortcuts(u):
+ path = u / "config/shortcuts.vdf" if u else None
+ if not path or not path.exists():
+ return []
+ try:
+ root, _ = binvdf(path.read_bytes())
+ except (ValueError, IndexError):
+ return []
+ out = []
+ for s in root.get("shortcuts", {}).values():
+ # Custom artwork set in Steam: <appid>p for the cover, <appid>_hero.
+ grid = lambda suffix: next(iter((u / "config/grid").glob(f"{s.get('appid', 0)}{suffix}.*")), "")
+ out.append({"name": s.get("appname", "?"), "id": shortcut_gameid(s.get("appid", 0)),
+ "appid": "", "installed": True, "cover": str(grid("p")),
+ "hero": str(grid("_hero")), "last": s.get("lastplaytime", 0)})
+ return out
+
+
+def games():
+ u = user()
+ lib = installed()
+ out = [{"name": g["name"], "id": a, "appid": a, "installed": True,
+ "last": g["last"], **art(a)} for a, g in lib.items()]
+ try:
+ owned = json.loads(OWNED.read_text())
+ except (OSError, ValueError):
+ owned = []
+ for g in owned:
+ a = str(g["appid"])
+ if a not in lib:
+ out.append({"name": g["name"], "id": a, "appid": a, "installed": False,
+ "last": g.get("rtime_last_played", 0), **art(a)})
+ out += shortcuts(u)
+ # Installed first, most recently played first, then by name.
+ out.sort(key=lambda g: (not g["installed"], -g["last"], g["name"].lower()))
+ return out
+
+
+def refresh():
+ u = user()
+ if not u:
+ sys.exit("no Steam account in userdata")
+ key = subprocess.run(["pass", "show", PASS_ENTRY], capture_output=True, text=True,
+ timeout=30, check=True).stdout.split("\n")[0]
+ url = (f"{API}?key={key}&steamid={STEAMID_BASE + int(u.name)}"
+ "&include_appinfo=1&include_played_free_games=1")
+ with urllib.request.urlopen(url, timeout=15) as r:
+ owned = json.load(r)["response"].get("games", [])
+ CACHE.mkdir(parents=True, exist_ok=True)
+ tmp = OWNED.with_suffix(".tmp")
+ tmp.write_text(json.dumps(owned))
+ tmp.replace(OWNED)
+ print(f"{len(owned)} owned")
+
+
+def check():
+ data = (b"\x00shortcuts\x00"
+ b"\x000\x00"
+ b"\x02appid\x00" + (0xE716847D).to_bytes(4, "little") +
+ b"\x01AppName\x00Example Game\x00"
+ b"\x00tags\x00\x08"
+ b"\x08\x08\x08")
+ root, end = binvdf(data)
+ assert end == len(data), end
+ s = root["shortcuts"]["0"]
+ assert s == {"appid": 0xE716847D, "appname": "Example Game", "tags": {}}, s
+ assert shortcut_gameid(0xE716847D) == str(0xE716847D << 32 | 0x02000000)
+ # Signed and unsigned readings of the same id give the same game id.
+ assert shortcut_gameid(0xE716847D - (1 << 32)) == shortcut_gameid(0xE716847D)
+ assert kv('"AppState"\n{\n\t"appid"\t\t"42"\n\t"name"\t\t"X "\n}') == {"appid": "42", "name": "X "}
+ assert TOOLS.match("Proton 9.0") and not TOOLS.match("Protonaut")
+ print("ok")
+
+
+if __name__ == "__main__":
+ if "--check" in sys.argv:
+ check()
+ elif "--refresh" in sys.argv:
+ refresh()
+ else:
+ print(json.dumps(games()))