#!/usr/bin/env python3 """Steamify CachyOS - graphical app (v2). A controller-, TV remote- and keyboard-friendly front end for steamify.sh: the components and their logic stay in lib/*.sh, reached through `steamify.sh --backend status|apply` (lib/backend.sh). Needs PySide6 (pacman -S pyside6). steamify-ui [--windowed|--fullscreen] """ import json import os import select import shutil import stat import struct import sys import tempfile import threading import time # Started from Steam (a non-Steam game, so Steam Input hands it the # controller): Steam preloads its overlay into every game, and Qt aborts with # it loaded. Start again without it, before Qt is imported. if "gameoverlayrenderer" in os.environ.get("LD_PRELOAD", ""): os.environ["LD_PRELOAD"] = ":".join(p for p in os.environ["LD_PRELOAD"].split(":") if p and "gameoverlayrenderer" not in p) os.execv(sys.executable, [sys.executable] + sys.argv) HERE = os.path.dirname(os.path.realpath(__file__)) try: from PySide6.QtCore import (QObject, QProcess, QProcessEnvironment, Property, Signal, Slot, QUrl, SLOT) from PySide6.QtGui import QGuiApplication, QIcon from PySide6.QtQml import QQmlApplicationEngine from PySide6.QtDBus import QDBusConnection except ImportError: msg = ("Steamify's app needs PySide6 (Qt for Python).\n" "Install it with: sudo pacman -S pyside6\n" "or run the terminal version: steamify.sh") print(msg, file=sys.stderr) if shutil.which("kdialog"): os.system("kdialog --title Steamify --sorry " + json.dumps(msg)) sys.exit(1) def find_script(): """steamify.sh next to the app: the repository (../steamify.sh) or an install that keeps both in one folder.""" for p in (os.path.join(HERE, "..", "steamify.sh"), os.path.join(HERE, "steamify.sh")): if os.path.isfile(p): return os.path.realpath(p) return None # --- Controller input ------------------------------------------------------- # Qt 6 has no gamepad module: read the evdev device directly. Controllers # are tagged uaccess, so the logged-in user may read them. EV_KEY, EV_ABS = 0x01, 0x03 BUTTONS = {0x130: "a", 0x131: "b", 0x133: "y", 0x134: "x", # SOUTH EAST NORTH WEST 0x13a: "select", 0x13b: "start", 0x13c: "guide", 0x220: "up", 0x221: "down", 0x222: "left", 0x223: "right"} # BTN_DPAD_* ABS_X, ABS_Y, ABS_HAT0X, ABS_HAT0Y = 0x00, 0x01, 0x10, 0x11 EVENT = struct.Struct("llHHi") def controller_devices(): """/dev/input/eventN of every device that has a gamepad's south button.""" found = [] base = "/sys/class/input" for name in sorted(os.listdir(base)): if not name.startswith("event"): continue try: with open(os.path.join(base, name, "device", "capabilities", "key")) as f: words = f.read().split() except OSError: continue bits = 0 for w in words: bits = (bits << 64) | int(w, 16) if bits >> 0x130 & 1: found.append("/dev/input/" + name) return found class Gamepad(QObject): """Emits button("a"|"b"|"x"|"y"|"up"|...) from any controller, and "_up" when a button is released.""" button = Signal(str) def __init__(self): super().__init__() self._stop = False threading.Thread(target=self._run, daemon=True).start() def _run(self): files = {} stick = {ABS_X: 0, ABS_Y: 0} last_scan = 0 while not self._stop: if time.time() - last_scan > 2: # plugged in or out last_scan = time.time() for path in controller_devices(): if path not in files: try: files[path] = open(path, "rb", buffering=0) except OSError: pass if not files: time.sleep(0.5) continue try: ready, _, _ = select.select(list(files.values()), [], [], 0.5) except (OSError, ValueError): ready = [] for f in ready: try: data = f.read(EVENT.size * 32) except OSError: data = b"" if not data: files = {p: x for p, x in files.items() if x is not f} continue for off in range(0, len(data) - EVENT.size + 1, EVENT.size): _, _, typ, code, value = EVENT.unpack_from(data, off) if typ == EV_KEY and code in BUTTONS and value in (0, 1): # Presses as "a"; releases as "a_up" (the BIOS # confirmation is a 5-second hold). self.button.emit(BUTTONS[code] + ("" if value else "_up")) elif typ == EV_ABS and code in (ABS_HAT0X, ABS_HAT0Y) and value: self.button.emit({(ABS_HAT0X, -1): "left", (ABS_HAT0X, 1): "right", (ABS_HAT0Y, -1): "up", (ABS_HAT0Y, 1): "down"}[(code, value)]) elif typ == EV_ABS and code in stick: # Left stick as a D-pad: one step per push past half-way. was = stick[code] now = -1 if value < -16000 else (1 if value > 16000 else 0) stick[code] = now if now and not was: self.button.emit({(ABS_X, -1): "left", (ABS_X, 1): "right", (ABS_Y, -1): "up", (ABS_Y, 1): "down"}[(code, now)]) # --- Backend ---------------------------------------------------------------- class Backend(QObject): statusChanged = Signal() event = Signal("QVariant") # one parsed JSON event from apply busyChanged = Signal() remotePressed = Signal() # a key from the TV remote (cecd) def __init__(self, script): super().__init__() self._script = script self._status = {} self._busy = False self._proc = None self._buf = b"" self._secret_dir = None # The TV remote's keys reach Qt as keys; cecd also announces them on # D-Bus, which tells the app they came from the remote. try: QDBusConnection.sessionBus().connect( "com.steampowered.CecDaemon1", "", "com.steampowered.CecDaemon1.CecDevice1", "UserControlPressed", self, SLOT("_on_remote()")) except Exception as e: # no remote detection; keys still work print("steamify-ui: no CEC remote detection:", e, file=sys.stderr) @Slot() def _on_remote(self): self.remotePressed.emit() def _get_status(self): return self._status status = Property("QVariant", _get_status, notify=statusChanged) def _get_busy(self): return self._busy busy = Property(bool, _get_busy, notify=busyChanged) @Slot(result=str) def user(self): return os.environ.get("USER") or os.getlogin() @Slot() def refresh(self): p = QProcess(self) p.setProgram("bash") p.setArguments([self._script, "--backend", "status"]) def done(*_): try: self._status = json.loads(bytes(p.readAllStandardOutput()).decode()) except ValueError: self._status = {"error": bytes(p.readAllStandardError()).decode()[-500:]} self.statusChanged.emit() p.deleteLater() p.finished.connect(done) p.start() def _make_askpass(self, password): """A private folder with a helper that `sudo -A` runs for the password, and a `sudo` that always uses it (makepkg and yay call plain sudo). The password is never written to a file: the helper reads it from a named pipe, and the app writes it into the pipe from memory each time sudo asks, until _drop_askpass.""" base = os.environ.get("XDG_RUNTIME_DIR") or tempfile.gettempdir() d = tempfile.mkdtemp(prefix="steamify-", dir=base) os.chmod(d, stat.S_IRWXU) pipe = os.path.join(d, "pipe") os.mkfifo(pipe, 0o600) askpass = os.path.join(d, "askpass") with open(askpass, "w") as f: f.write("#!/bin/sh\nexec cat %s\n" % json.dumps(pipe)) os.chmod(askpass, 0o700) bindir = os.path.join(d, "bin") os.mkdir(bindir) wrapper = os.path.join(bindir, "sudo") with open(wrapper, "w") as f: f.write("#!/bin/sh\nexec /usr/bin/sudo -A \"$@\"\n") os.chmod(wrapper, 0o700) stop = threading.Event() data = (password + "\n").encode() def serve(): while not stop.is_set(): try: fd = os.open(pipe, os.O_WRONLY) # waits for a reader except OSError: return try: if not stop.is_set(): os.write(fd, data) except OSError: pass finally: os.close(fd) threading.Thread(target=serve, daemon=True).start() self._askpass_stop = (stop, pipe) return d, askpass, bindir def _drop_askpass(self): if getattr(self, "_askpass_stop", None): stop, pipe = self._askpass_stop self._askpass_stop = None stop.set() try: # wakes the writer waiting for a reader, which then ends os.close(os.open(pipe, os.O_RDONLY | os.O_NONBLOCK)) except OSError: pass if self._secret_dir: shutil.rmtree(self._secret_dir, ignore_errors=True) self._secret_dir = None def _start(self, args, password): if self._busy: return env = QProcessEnvironment.systemEnvironment() if password: self._secret_dir, askpass, bindir = self._make_askpass(password) env.insert("SUDO_ASKPASS", askpass) env.insert("PATH", bindir + ":" + env.value("PATH")) p = QProcess(self) p.setProcessEnvironment(env) p.setProcessChannelMode(QProcess.MergedChannels) p.setProgram("bash") p.setArguments([self._script, "--backend"] + args) self._buf = b"" p.readyReadStandardOutput.connect(lambda: self._read(p)) p.finished.connect(lambda *_: self._finished(p)) self._proc = p self._busy = True self.busyChanged.emit() p.start() @Slot("QVariant", str, bool, str) def apply(self, ids, boot, reapply, password): try: if hasattr(ids, "toVariant"): # a JS array from QML ids = ids.toVariant() args = ["apply"] if reapply: args.append("--reapply") if boot: args += ["--boot", boot] self._start(args + [str(i) for i in (ids or [])], password) except Exception as e: # Never leave the app on the progress screen. print("steamify-ui: couldn't start:", e, file=sys.stderr) self._drop_askpass() self.event.emit({"event": "finished", "failed": [], "restart": False, "error": "start"}) @Slot(str) def biosPrepare(self, password): """Download, checksum, fwupd check: ends with a bios-ready event.""" self._start(["bios-prepare"], password) @Slot(str) def biosFlash(self, password): """Only after both warnings in the app.""" self._start(["bios-flash"], password) def _read(self, p): self._buf += bytes(p.readAllStandardOutput()) while b"\n" in self._buf: line, self._buf = self._buf.split(b"\n", 1) text = line.decode(errors="replace") try: ev = json.loads(text) except ValueError: ev = {"event": "log", "id": "", "line": text} if isinstance(ev, dict): self.event.emit(ev) def _finished(self, p): self._read(p) self._drop_askpass() self._busy = False self.busyChanged.emit() p.deleteLater() self.refresh() @Slot(str, result=bool) def checkPassword(self, password): """True if sudo accepts the password (or needs none).""" import subprocess if subprocess.run(["sudo", "-n", "true"], capture_output=True).returncode == 0: return True r = subprocess.run(["sudo", "-S", "-k", "-p", "", "true"], input=(password + "\n").encode(), capture_output=True) return r.returncode == 0 @Slot(result=bool) def needsPassword(self): import subprocess return subprocess.run(["sudo", "-n", "true"], capture_output=True).returncode != 0 @Slot() def restart(self): QProcess.startDetached("systemctl", ["reboot"]) def shutdown(self): if self._proc and self._proc.state() != QProcess.NotRunning: self._proc.kill() self._proc.waitForFinished(3000) self._drop_askpass() def main(): script = find_script() if not script: print("steamify-ui: can't find steamify.sh next to the app.", file=sys.stderr) return 1 in_gamescope = bool(os.environ.get("GAMESCOPE_WAYLAND_DISPLAY")) or \ os.environ.get("XDG_CURRENT_DESKTOP", "").lower() == "gamescope" fullscreen = "--fullscreen" in sys.argv or (in_gamescope and "--windowed" not in sys.argv) app = QGuiApplication(sys.argv) app.setApplicationName("Steamify") app.setDesktopFileName("steamify-ui") icon = os.path.join(HERE, "..", "assets", "steam-gaming-settings.svg") if os.path.isfile(icon): app.setWindowIcon(QIcon(icon)) backend = Backend(script) pad = Gamepad() engine = QQmlApplicationEngine() engine.setInitialProperties({"backend": backend, "gamepad": pad, "fullscreen": fullscreen, # Started from Steam: its keys are a Steam # Controller's buttons (desktop layout). "fromSteam": bool(os.environ.get("SteamGameId")), "iconUrl": QUrl.fromLocalFile(os.path.realpath(icon)).toString()}) engine.load(os.path.join(HERE, "qml", "Main.qml")) if not engine.rootObjects(): return 1 app.aboutToQuit.connect(backend.shutdown) backend.refresh() return app.exec() if __name__ == "__main__": sys.exit(main())