diff options
| -rw-r--r-- | AGENTS.md | 2 | ||||
| -rw-r--r-- | README.md | 1 | ||||
| -rw-r--r-- | games/Games.qml | 276 | ||||
| -rw-r--r-- | games/README.md | 39 | ||||
| l--------- | games/Theme.qml | 1 | ||||
| -rwxr-xr-x | games/games.py | 190 | ||||
| -rw-r--r-- | games/shell.qml | 23 |
7 files changed, 532 insertions, 0 deletions
@@ -28,6 +28,8 @@ modules. bookmarks/ Firefox's bookmarks by folder, with favicons and dead-link badges, on SUPER+b notifications/ balloons for the notification daemon's published files + games/ the Steam library as an endless strip with a large + preview, on the Search key (XF86Search) They are started from `~/.config/hypr/sections/autostart.lua` and keep running for the whole session. @@ -22,6 +22,7 @@ repos stay independent, this one has no build-time dependency on that one. window-switcher/ open windows as live previews in a grid, on ALT+TAB keybinds/ live keybind reminder, on SUPER+k bookmarks/ Firefox bookmarks with favicons and dead links, on SUPER+b + games/ Steam library as an endless strip, on the Search key Each directory has its own README covering what it does and how to run it. diff --git a/games/Games.qml b/games/Games.qml new file mode 100644 index 0000000..2d22460 --- /dev/null +++ b/games/Games.qml @@ -0,0 +1,276 @@ +// 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. + +import Quickshell +import Quickshell.Io +import Quickshell.Wayland +import QtQuick + +Scope { + id: root + + property bool open: false + property string monitor: "DP-1" + property var items: [] + property string query: "" + + // Horizontal shear of the strip's tiles, as a fraction of their height. + readonly property real slant: -0.25 + readonly property int step: 210 + + readonly property var screenObj: + Quickshell.screens.find(s => s.name === root.monitor) ?? Quickshell.screens[0] + + readonly property var shown: { + const q = root.query.toLowerCase(); + return root.items.filter(g => g.name.toLowerCase().includes(q)); + } + + // The strip is a loop, so the list repeats until it fills the screen + // width: a short library would otherwise leave gaps in the circle. + readonly property var reel: { + if (!root.shown.length) return []; + const slots = Math.ceil(root.screenObj.width / root.step) + 2; + let out = []; + while (out.length < slots) out = out.concat(root.shown); + return out; + } + + function launch(g) { + if (!g) return; + // -silent keeps the Steam window down whether or not Steam is running. + // Ids are strings: a shortcut's game id does not fit a JS number. + Quickshell.execDetached(["steam", "-silent", + g.installed ? `steam://rungameid/${g.id}` : `steam://install/${g.appid}`]); + root.open = false; + } + + // The list is read on every opening; the owned games come from a cache + // that a Web API call refreshes in the background, then the list is + // read again. + onOpenChanged: if (open) { + root.query = ""; + list.running = true; + refresh.running = true; + } + + Process { + id: list + command: ["/usr/bin/python3", `${Quickshell.shellDir}/games.py`] + stdout: StdioCollector { + onStreamFinished: { + try { root.items = JSON.parse(text); } catch (e) { root.items = []; } + } + } + } + + Process { + id: refresh + command: ["/usr/bin/python3", `${Quickshell.shellDir}/games.py`, "--refresh"] + onExited: code => { if (code === 0 && root.open) list.running = true; } + } + + // A config with no visible window exits, reporting nothing. This 1x1 + // click-through window holds the shell open while the overlay is hidden. + PanelWindow { + anchors { top: true; left: true } + implicitWidth: 1 + implicitHeight: 1 + color: "transparent" + exclusionMode: ExclusionMode.Ignore + mask: Region {} + WlrLayershell.keyboardFocus: WlrKeyboardFocus.None + } + + LazyLoader { + active: root.open + + PanelWindow { + id: win + readonly property var game: root.reel[strip.currentIndex] + readonly property color bg: Qt.rgba(Theme.crust.r, Theme.crust.g, Theme.crust.b, 0.94) + + screen: root.screenObj + anchors { top: true; left: true; right: true; bottom: true } + color: win.bg + exclusionMode: ExclusionMode.Ignore + WlrLayershell.layer: WlrLayer.Overlay + WlrLayershell.namespace: "keystone-games" + WlrLayershell.keyboardFocus: WlrKeyboardFocus.Exclusive + + MouseArea { + anchors.fill: parent + onClicked: root.open = false + } + + // The selected game, large: Steam's wide hero art, or the cover + // when there is none, fading into the background at the bottom. + Item { + anchors { top: parent.top; left: parent.left; right: parent.right; bottom: strip.top } + + Image { + anchors.fill: parent + source: win.game ? (win.game.hero || win.game.cover ? `file://${win.game.hero || win.game.cover}` : "") : "" + fillMode: Image.PreserveAspectCrop + asynchronous: true + } + + Rectangle { + anchors.fill: parent + gradient: Gradient { + GradientStop { position: 0.45; color: "transparent" } + GradientStop { position: 1.0; color: win.bg } + } + } + + Column { + anchors { left: parent.left; bottom: parent.bottom; margins: 80 } + spacing: 8 + + Text { + text: win.game?.name ?? (root.items.length ? "No match" : "") + color: Theme.text + font.family: Theme.fontFamily + font.pixelSize: 56 + font.bold: true + } + Text { + visible: !!win.game + text: !win.game ? "" + : !win.game.installed ? "Not installed · Enter installs" + : win.game.last ? `Last played ${new Date(win.game.last * 1000).toLocaleDateString(Qt.locale(), "d MMMM yyyy")}` + : "Never played" + color: win.game?.installed ? Theme.subtext : Theme.peach + font.family: Theme.fontFamily + font.pixelSize: 20 + } + } + } + + // Keys reach a focused item, not a window: the search field + // takes them all and steers the strip. It shows only what is typed. + TextInput { + id: field + anchors { right: parent.right; top: parent.top; margins: 60 } + focus: true + color: Theme.text + font.family: Theme.fontFamily + font.pixelSize: 28 + onTextEdited: root.query = text + + Keys.onEscapePressed: root.open = false + Keys.onLeftPressed: strip.decrementCurrentIndex() + Keys.onRightPressed: strip.incrementCurrentIndex() + Keys.onReturnPressed: root.launch(win.game) + Keys.onEnterPressed: root.launch(win.game) + } + + // An endless strip: PathView wraps, and the tile at the centre + // is the selection. + PathView { + id: strip + anchors { left: parent.left; right: parent.right; bottom: parent.bottom; bottomMargin: 120 } + height: 300 + model: root.reel + pathItemCount: Math.min(count, Math.ceil((width + 2 * root.step) / root.step)) + preferredHighlightBegin: 0.5 + preferredHighlightEnd: 0.5 + highlightRangeMode: PathView.StrictlyEnforceRange + highlightMoveDuration: 220 + onModelChanged: currentIndex = 0 + + path: Path { + startX: -root.step + startY: strip.height / 2 + PathLine { x: strip.width + root.step; y: strip.height / 2 } + } + + delegate: Item { + id: tile + required property var modelData + required property int index + readonly property bool current: PathView.isCurrentItem + width: 160 + height: 160 + z: current ? 1 : 0 + scale: current ? 1.5 : 1 + Behavior on scale { NumberAnimation { duration: 160 } } + + // A parallelogram: the tile is sheared about its middle + // and clips to that shape; the art inside is sheared back + // so it stands straight, and widened to reach both edges. + Item { + anchors.fill: parent + clip: true + transform: Matrix4x4 { + matrix: Qt.matrix4x4(1, root.slant, 0, -root.slant * tile.height / 2, + 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) + } + + Rectangle { anchors.fill: parent; color: Theme.surface } + + Image { + x: -tile.height * Math.abs(root.slant) / 2 + width: parent.width + tile.height * Math.abs(root.slant) + height: parent.height + transform: Matrix4x4 { + matrix: Qt.matrix4x4(1, -root.slant, 0, root.slant * tile.height / 2, + 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1) + } + source: tile.modelData.cover ? `file://${tile.modelData.cover}` : "" + sourceSize.width: 480 + fillMode: Image.PreserveAspectCrop + asynchronous: true + // Not installed: greyed until selected. + opacity: tile.modelData.installed || tile.current ? 1 : 0.35 + } + + // No cover: the initial stands in for it. + Text { + anchors.centerIn: parent + visible: !tile.modelData.cover + text: tile.modelData.name.charAt(0) + color: Theme.text + font.family: Theme.fontFamily + font.pixelSize: 64 + font.bold: true + } + + // Not installed: a peach frame, the colour of the + // preview's "Not installed" line. + Rectangle { + anchors.fill: parent + color: "transparent" + border.width: tile.current ? 3 : tile.modelData.installed ? 0 : 2 + border.color: tile.modelData.installed ? Theme.accent : Theme.peach + } + } + + MouseArea { + anchors.fill: parent + onClicked: tile.current ? root.launch(tile.modelData) : strip.currentIndex = tile.index + } + } + + // The wheel steps one tile a notch. + MouseArea { + anchors.fill: parent + acceptedButtons: Qt.NoButton + onWheel: wheel => { + const d = wheel.angleDelta.y || wheel.angleDelta.x; + if (d < 0) strip.incrementCurrentIndex(); + else if (d > 0) strip.decrementCurrentIndex(); + } + } + } + } + } +} diff --git a/games/README.md b/games/README.md new file mode 100644 index 0000000..26e592e --- /dev/null +++ b/games/README.md @@ -0,0 +1,39 @@ +# games + +A game launcher on DP-1: an endless horizontal strip of small slanted +tiles along the bottom, and the selected game, the one at the centre, +shown large above it in Steam's wide hero art with its title. Installed +games come first, most recently played first; owned games that are not +installed follow, greyed and framed in peach. Non-Steam shortcuts added to +Steam count as installed. A short library repeats to fill the loop. + +Left and Right or the wheel move, Enter or a click on the centre tile +launches, a click on another tile selects it. Typing filters by name. +Escape or a click on the background closes. Everything goes through +`steam -silent steam://...`, so the Steam window stays down: `rungameid` +for an installed game or shortcut (keeping its Proton and launch options), +`install` for one that is not. + +`games.py` reads, on every opening: + +- installed games from the `appmanifest_*.acf` files of every library in + `steamapps/libraryfolders.vdf`, leaving out Proton and the runtimes; +- shortcuts from the binary `userdata/<account>/config/shortcuts.vdf`, + whose game id is `appid << 32 | 0x02000000`; +- covers and hero art from Steam's own `appcache/librarycache`, so + nothing is fetched for them. A tile with no cover shows the initial. + +Owned games need the Steam Web API (`IPlayerService/GetOwnedGames`), so +they come from `~/.cache/keystone/games/owned.json`, which +`games.py --refresh` rewrites in the background on every opening. The API +key is read with `pass show "$GAMES_PASS_ENTRY"`; the SteamID is derived +from the userdata account directory. + +## Running + + qs -p ./games + qs -p ./games ipc call games toggle # bound to XF86Search + +## Check + + /usr/bin/python3 games/games.py --check diff --git a/games/Theme.qml b/games/Theme.qml new file mode 120000 index 0000000..3d2e40f --- /dev/null +++ b/games/Theme.qml @@ -0,0 +1 @@ +../shared/Theme.qml
\ No newline at end of file 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())) diff --git a/games/shell.qml b/games/shell.qml new file mode 100644 index 0000000..c667ec4 --- /dev/null +++ b/games/shell.qml @@ -0,0 +1,23 @@ +// 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. + +import Quickshell +import Quickshell.Io + +ShellRoot { + Games { id: games } + + // `qs -p <this dir> ipc call games toggle` + IpcHandler { + target: "games" + function toggle() { games.open = !games.open; } + } +} |
