fix(plugin): poll-based game detection via Python backend, remove broken SteamClient events
This commit is contained in:
+37
-17
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user