fix(plugin): poll-based game detection via Python backend, remove broken SteamClient events

This commit is contained in:
2026-06-28 21:25:57 +08:00
parent 0c44b39e9e
commit 9b6b37f8a4
3 changed files with 80 additions and 62 deletions
+37 -17
View File
@@ -430,39 +430,59 @@ exec mangohud "$@"
return "" return ""
async def detect_current_game(self) -> dict: async def detect_current_game(self) -> dict:
"""RPC: Detect the currently running game by checking active window and processes. """RPC: Detect the currently running game by checking processes.
Returns {appId: int?, name: str}.""" Returns {appId: int?, name: str}."""
import subprocess import subprocess
import re import re
# Method 1: Try xdotool to get active window title # Method 1: Check for Steam game processes by looking at cmdline
# Steam games run under Proton, so we look for the game's .exe in cmdline
try: try:
r = subprocess.run( r = subprocess.run(
["xdotool", "getactivewindow", "getwindowname"], ["ps", "-eo", "pid,args", "--no-headers"],
capture_output=True, text=True, timeout=3 capture_output=True, text=True, timeout=3
) )
if r.returncode == 0: if r.returncode == 0:
title = r.stdout.strip() for line in r.stdout.split('\n'):
if title and title != "Steam" and "Steam" not in title: # Look for Proton game processes (contain .exe)
return {"appId": None, "name": title} if '.exe' in line.lower() and 'proton' in line.lower():
# Extract game name from path
m = re.search(r'/([^/]+)\.exe', line, re.IGNORECASE)
if m:
return {"appId": None, "name": m.group(1)}
# Also check for native Linux games
if 'gameoverlayrenderer' in line and 'steamapps/common' in line:
m = re.search(r'steamapps/common/([^/]+)', line)
if m:
return {"appId": None, "name": m.group(1)}
except: except:
pass pass
# Method 2: Check for Steam game processes # Method 2: Check Steam's running game state via appmanifest
try: try:
home = os.path.expanduser("~")
steam_path = os.path.join(home, ".steam", "steam")
# Check if any compatdata directories have active processes
compat_dir = os.path.join(steam_path, "steamapps", "compatdata")
if os.path.exists(compat_dir):
for app_id_str in os.listdir(compat_dir):
if not app_id_str.isdigit():
continue
# Check if this app has a running process
r = subprocess.run( r = subprocess.run(
["ps", "-eo", "comm", "--no-headers"], ["pgrep", "-f", app_id_str],
capture_output=True, text=True, timeout=3 capture_output=True, timeout=2
) )
if r.returncode == 0: if r.returncode == 0:
# Common game-related processes # Found a running game! Get its name from appmanifest
game_procs = [p for p in r.stdout.split('\n') if p and p not in ( manifest_path = os.path.join(steam_path, "steamapps", f"appmanifest_{app_id_str}.acf")
'steam', 'steamwebhelper', 'steamservice', 'steamclient', if os.path.exists(manifest_path):
'bash', 'python3', 'mangohud', 'gamescope', 'Xorg', with open(manifest_path, 'r') as f:
'kwin_wayland', 'plasmashell', 'konsole', 'dolphin' content = f.read()
)] m = re.search(r'"name"\s+"([^"]+)"', content)
if game_procs: if m:
return {"appId": None, "name": game_procs[0]} return {"appId": int(app_id_str), "name": m.group(1)}
return {"appId": int(app_id_str), "name": f"App {app_id_str}"}
except: except:
pass pass
+3 -41
View File
@@ -1,4 +1,3 @@
import { useEffect, useRef } from "react"
import { import {
PanelSection, PanelSection,
PanelSectionRow, PanelSectionRow,
@@ -9,7 +8,7 @@ import {
} from "@decky/api" } from "@decky/api"
import { FaChartLine } from "react-icons/fa" import { FaChartLine } from "react-icons/fa"
import MainPanel from "./components/main-panel" import MainPanel from "./components/main-panel"
import { useSettings, useSession } from "./lib/store" import { useSettings, useSession, useGameDetection } from "./lib/store"
import { import {
readAndParseMangohudLog, readAndParseMangohudLog,
clearMangohudLog, clearMangohudLog,
@@ -39,46 +38,9 @@ function Content() {
onGameStop, onGameStop,
setGameName, setGameName,
} = useSession() } = useSession()
const gameStartedUnregRef = useRef<{ unregister: () => void } | null>(null)
// ── Register SteamClient game events ────────────────────────── // ── Game detection via polling ────────────────────────────────
useEffect(() => { useGameDetection(setGameName, recordingState)
try {
// Use RegisterForAppLifetimeNotifications — the correct API for
// detecting when games start/stop on Steam Deck.
const reg = SteamClient.GameSessions.RegisterForAppLifetimeNotifications(
(notification: AppLifetimeNotification) => {
if (notification.bRunning) {
// Game started — try to get the display name from appStore
let gameName = `App ${notification.unAppID}`
try {
const overview = window.appStore?.GetAppOverviewByAppID(notification.unAppID)
if (overview?.display_name) {
gameName = overview.display_name
}
} catch {
// fallback
}
onGameStart(notification.unAppID, gameName)
} else {
// Game stopped
onGameStop()
}
},
)
gameStartedUnregRef.current = reg
} catch (e) {
console.warn("[DeckyVault] SteamClient event registration failed:", e)
}
return () => {
try {
gameStartedUnregRef.current?.unregister()
} catch {
// ignore
}
}
}, [onGameStart, onGameStop])
// ── Handle start recording ──────────────────────────────────── // ── Handle start recording ────────────────────────────────────
async function handleStart() { async function handleStart() {
+37 -1
View File
@@ -1,6 +1,6 @@
import { useState, useEffect, useCallback, useRef } from "react" import { useState, useEffect, useCallback, useRef } from "react"
import type { DeckyVaultImportV1, HardwareSlug } from "@deckyvault/shared" import type { DeckyVaultImportV1, HardwareSlug } from "@deckyvault/shared"
import { getSettings, setSetting } from "./api" import { getSettings, setSetting, detectCurrentGame } from "./api"
// ── Types ─────────────────────────────────────────────────────── // ── Types ───────────────────────────────────────────────────────
@@ -195,6 +195,42 @@ export function useSession() {
} }
} }
// ── Game Detection Hook ──────────────────────────────────────────
// Polls the Python backend to detect the currently running game.
// Falls back to manual input if no game is detected.
export function useGameDetection(
setGameName: (name: string, appId?: number) => void,
recordingState: RecordingState,
) {
const [detecting, setDetecting] = useState(false)
const lastDetectedRef = useRef<string>("")
useEffect(() => {
// Don't poll while recording (user is already in-game)
if (recordingState !== "idle") return
const interval = setInterval(async () => {
try {
setDetecting(true)
const result = await detectCurrentGame()
if (result.name && result.name !== lastDetectedRef.current) {
lastDetectedRef.current = result.name
setGameName(result.name, result.appId ?? undefined)
}
} catch {
// Silently retry
} finally {
setDetecting(false)
}
}, 3000)
return () => clearInterval(interval)
}, [recordingState, setGameName])
return { detecting }
}
// ── Payload Builder ───────────────────────────────────────────── // ── Payload Builder ─────────────────────────────────────────────
export function buildImportPayload(sess: SessionData): DeckyVaultImportV1 { export function buildImportPayload(sess: SessionData): DeckyVaultImportV1 {