Merge branch 'dev' into prod

This commit is contained in:
2026-06-28 21:35:03 +08:00
8 changed files with 799 additions and 223 deletions
+1 -2
View File
@@ -8,7 +8,7 @@ import {
gamePlatformSupport, gamePlatformSupport,
entryScreenshots, entryScreenshots,
} from "@/lib/db/schema" } from "@/lib/db/schema"
import { and, desc, eq, sql } from "drizzle-orm" import { and, desc, eq } from "drizzle-orm"
import { getR2PublicUrl } from "@/lib/storage" import { getR2PublicUrl } from "@/lib/storage"
/** /**
@@ -86,7 +86,6 @@ export const gamesPerformanceRoutes = new Elysia({
and( and(
eq(gameVersions.gameId, gameId), eq(gameVersions.gameId, gameId),
eq(performanceEntries.isRemoved, false), eq(performanceEntries.isRemoved, false),
sql`${performanceEntries.settingsJson} IS NOT NULL`,
), ),
) )
.orderBy( .orderBy(
+240 -41
View File
@@ -34,6 +34,7 @@ def parse_mangohud_log(log_content: str) -> dict:
fps_col = 0 fps_col = 0
frametime_col = None frametime_col = None
gpu_power_col = None gpu_power_col = None
cpu_power_col = None
for i, line in enumerate(lines): for i, line in enumerate(lines):
stripped = line.strip() stripped = line.strip()
@@ -47,6 +48,8 @@ def parse_mangohud_log(log_content: str) -> dict:
frametime_col = columns.index('frametime') frametime_col = columns.index('frametime')
if 'gpu_power' in columns: if 'gpu_power' in columns:
gpu_power_col = columns.index('gpu_power') gpu_power_col = columns.index('gpu_power')
if 'cpu_power' in columns:
cpu_power_col = columns.index('cpu_power')
header_idx = i header_idx = i
break break
@@ -56,6 +59,7 @@ def parse_mangohud_log(log_content: str) -> dict:
fps_values = [] fps_values = []
frametime_values = [] frametime_values = []
gpu_power_values = [] gpu_power_values = []
cpu_power_values = []
for line in lines[header_idx + 1:]: for line in lines[header_idx + 1:]:
stripped = line.strip() stripped = line.strip()
@@ -71,6 +75,8 @@ def parse_mangohud_log(log_content: str) -> dict:
frametime_values.append(float(parts[frametime_col])) frametime_values.append(float(parts[frametime_col]))
if gpu_power_col is not None and gpu_power_col < len(parts): if gpu_power_col is not None and gpu_power_col < len(parts):
gpu_power_values.append(float(parts[gpu_power_col])) gpu_power_values.append(float(parts[gpu_power_col]))
if cpu_power_col is not None and cpu_power_col < len(parts):
cpu_power_values.append(float(parts[cpu_power_col]))
except (ValueError, IndexError): except (ValueError, IndexError):
continue continue
@@ -96,8 +102,17 @@ def parse_mangohud_log(log_content: str) -> dict:
fps_one_percent_low = round(sorted_fps[one_percent_idx], 1) fps_one_percent_low = round(sorted_fps[one_percent_idx], 1)
tdp_watts = None tdp_watts = None
if gpu_power_values: total_power_values = []
tdp_watts = round(sum(gpu_power_values) / len(gpu_power_values), 1) if gpu_power_values and cpu_power_values:
# Sum GPU and CPU power for total APU power
for gp, cp in zip(gpu_power_values, cpu_power_values):
total_power_values.append(gp + cp)
elif gpu_power_values:
total_power_values = gpu_power_values
elif cpu_power_values:
total_power_values = cpu_power_values
if total_power_values:
tdp_watts = round(sum(total_power_values) / len(total_power_values), 1)
return { return {
"fpsAvg": fps_avg, "fpsAvg": fps_avg,
@@ -155,44 +170,46 @@ class Plugin:
return self._settings return self._settings
async def check_mangohud(self) -> dict: async def check_mangohud(self) -> dict:
"""RPC: Check if MangoHud is installed. Returns {installed: bool, path: str, version: str}.""" """RPC: Check if MangoHud is installed."""
import subprocess import os
import re
try: try:
result = subprocess.run( path = "/usr/bin/mangohud"
["which", "mangohud"], exists = os.path.exists(path)
capture_output=True, text=True, timeout=5 if not exists:
)
if result.returncode == 0:
mangohud_path = result.stdout.strip()
# Get version
version_result = subprocess.run(
["mangohud", "--version"],
capture_output=True, text=True, timeout=5
)
version = version_result.stdout.strip() if version_result.returncode == 0 else "unknown"
return {"installed": True, "path": mangohud_path, "version": version}
else:
return {"installed": False, "path": "", "version": ""} return {"installed": False, "path": "", "version": ""}
# Read version from the shell script itself
version = ""
try:
with open(path, 'r') as f:
content = f.read()
# Look for the version line: echo v0.8.3-rc1-24-g33c2c7dd+
m = re.search(r'echo\s+(v?[\d.]+[^\s]*)', content)
if m:
version = m.group(1)
if "-" in version:
version = version.split("-")[0]
except:
pass
return {"installed": True, "path": path, "version": version}
except Exception as e: except Exception as e:
return {"installed": False, "path": "", "version": "", "error": str(e)} return {"installed": False, "path": "", "version": "", "error": str(e)}
async def write_mangohud_config(self) -> dict: async def write_mangohud_config(self) -> dict:
"""RPC: Write the MangoHud logging config to ~/.config/MangoHud/MangoHud.conf. """RPC: Write the MangoHud logging config and a wrapper script."""
Returns {success: bool, path: str, error: str?}."""
try: try:
home = os.path.expanduser("~") home = os.path.expanduser("~")
config_dir = os.path.join(home, ".config", "MangoHud") config_dir = os.path.join(home, ".config", "MangoHud")
config_path = os.path.join(config_dir, "MangoHud.conf") config_path = os.path.join(config_dir, "MangoHud.conf")
os.makedirs(config_dir, exist_ok=True) os.makedirs(config_dir, exist_ok=True)
# MangoHud config that enables logging with the metrics we need.
# output_folder is required for logging to work.
# We log to /tmp so the plugin can read it after the session.
config_content = """\ config_content = """\
# DeckyVault MangoHud logging config # DeckyVault MangoHud logging config
output_folder=/tmp output_folder=/tmp
output_file=deckyvault-mangohud.log control=mangohud
log_duration=0 autostart_log=1
fps fps
frame_timing frame_timing
cpu_power cpu_power
@@ -204,7 +221,18 @@ benchmark_percentiles=97,AVG,1,0.1
with open(config_path, 'w') as f: with open(config_path, 'w') as f:
f.write(config_content) f.write(config_content)
return {"success": True, "path": config_path} # Write a wrapper script that forces MangoHud to use our config
wrapper_path = os.path.join(home, "deckyvault-mangohud.sh")
wrapper_content = """\
#!/bin/bash
export MANGOHUD_CONFIGFILE="$HOME/.config/MangoHud/MangoHud.conf"
exec mangohud "$@"
"""
with open(wrapper_path, 'w') as f:
f.write(wrapper_content)
os.chmod(wrapper_path, 0o755)
return {"success": True, "path": config_path, "wrapper": wrapper_path}
except Exception as e: except Exception as e:
return {"success": False, "path": "", "error": str(e)} return {"success": False, "path": "", "error": str(e)}
@@ -217,11 +245,111 @@ benchmark_percentiles=97,AVG,1,0.1
return {"exists": True, "content": f.read(), "path": config_path} return {"exists": True, "content": f.read(), "path": config_path}
return {"exists": False, "content": "", "path": config_path} return {"exists": False, "content": "", "path": config_path}
async def read_and_parse_mangohud_log(self, log_path: str = "/tmp/deckyvault-mangohud.log") -> dict: async def start_mangohud_logging(self) -> dict:
"""RPC: Start MangoHud logging via mangohudctl."""
import subprocess
try:
result = subprocess.run(
["mangohudctl", "set", "log_session", "true"],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
return {"success": True}
else:
return {"success": False, "error": result.stderr.strip() or "mangohudctl failed"}
except FileNotFoundError:
return {"success": False, "error": "mangohudctl not found. Is MangoHud running?"}
except Exception as e:
return {"success": False, "error": str(e)}
async def start_mangohud_logging(self) -> dict:
"""RPC: Start MangoHud logging via mangohudctl.
Retries a few times in case MangoHud hasn't started yet."""
import subprocess
import time
for attempt in range(5):
try:
result = subprocess.run(
["mangohudctl", "set", "log_session", "true"],
capture_output=True, text=True, timeout=2
)
if result.returncode == 0:
return {"success": True}
except (subprocess.TimeoutExpired, FileNotFoundError):
pass
if attempt < 4:
await asyncio.sleep(2)
return {"success": False, "error": "Could not connect to MangoHud. Is the game running?"}
async def stop_mangohud_logging(self) -> dict:
"""RPC: Stop MangoHud logging via mangohudctl. Best-effort."""
import subprocess
try:
result = subprocess.run(
["mangohudctl", "set", "log_session", "false"],
capture_output=True, text=True, timeout=2
)
if result.returncode == 0:
return {"success": True}
return {"success": False, "error": result.stderr.strip() or "mangohudctl failed"}
except Exception as e:
return {"success": False, "error": str(e)}
async def _find_mangohud_log(self) -> str | None:
"""Find the most recent MangoHud log file in /tmp/."""
import glob
import time
candidates = []
now = time.time()
for pattern in ["/tmp/*MangoHud*", "/tmp/*.csv", "/tmp/*.log"]:
for f in glob.glob(pattern):
if os.path.isdir(f):
continue
# Only consider files modified in the last hour
try:
mtime = os.path.getmtime(f)
if now - mtime > 3600:
continue
except:
pass
# Check if it looks like a MangoHud log
try:
with open(f, 'r') as fh:
first_lines = "".join(fh.readline() for _ in range(5))
if 'fps' in first_lines.lower() or 'MangoHud' in first_lines:
candidates.append(f)
except:
candidates.append(f) # Add anyway if we can't read it
if not candidates:
return None
candidates.sort(key=lambda f: os.path.getmtime(f), reverse=True)
return candidates[0]
async def debug_list_tmp(self) -> dict:
"""RPC: List all files in /tmp/ for debugging."""
import glob
files = []
for f in glob.glob("/tmp/*"):
if os.path.isfile(f):
try:
mtime = os.path.getmtime(f)
size = os.path.getsize(f)
files.append({"name": os.path.basename(f), "size": size, "mtime": mtime})
except:
pass
files.sort(key=lambda x: x["mtime"], reverse=True)
return {"files": files[:30]}
async def read_and_parse_mangohud_log(self, log_path: str | None = None) -> dict:
"""RPC: Read the MangoHud log file and return parsed FPS stats. """RPC: Read the MangoHud log file and return parsed FPS stats.
If no log_path given, searches /tmp/ for the most recent MangoHud log.
Returns parsed stats dict or {error: str}.""" Returns parsed stats dict or {error: str}."""
if log_path is None:
log_path = await self._find_mangohud_log()
if log_path is None:
return {"error": "No MangoHud log found in /tmp/. Make sure MangoHud is enabled and logging."}
if not os.path.exists(log_path): if not os.path.exists(log_path):
return {"error": f"MangoHud log not found at {log_path}. Make sure MangoHud is enabled and logging."} return {"error": f"MangoHud log not found at {log_path}."}
try: try:
with open(log_path, 'r') as f: with open(log_path, 'r') as f:
content = f.read() content = f.read()
@@ -231,11 +359,17 @@ benchmark_percentiles=97,AVG,1,0.1
except Exception as e: except Exception as e:
return {"error": f"Failed to read log: {str(e)}"} return {"error": f"Failed to read log: {str(e)}"}
async def clear_mangohud_log(self, log_path: str = "/tmp/deckyvault-mangohud.log") -> dict: async def clear_mangohud_log(self) -> dict:
"""RPC: Delete the MangoHud log file so the next recording starts fresh.""" """RPC: Delete all MangoHud log files in /tmp/ so the next recording starts fresh."""
import glob
try: try:
if os.path.exists(log_path): for pattern in ["/tmp/*MangoHud*", "/tmp/*.csv", "/tmp/*.log"]:
os.remove(log_path) for f in glob.glob(pattern):
if os.path.isfile(f):
try:
os.remove(f)
except (IOError, PermissionError):
pass
return {"success": True} return {"success": True}
except Exception as e: except Exception as e:
return {"success": False, "error": str(e)} return {"success": False, "error": str(e)}
@@ -277,20 +411,80 @@ benchmark_percentiles=97,AVG,1,0.1
return "unknown" return "unknown"
async def get_proton_version(self, app_id: int) -> str: async def get_proton_version(self, app_id: int) -> str:
"""RPC: Attempt to read the Proton version for a Steam app. """RPC: Read the Proton version for a Steam app from config_info."""
Reads from the Steam compatdata directory."""
try: try:
home = os.path.expanduser("~") home = os.path.expanduser("~")
# Steam compat data lives in ~/.steam/steam/steamapps/compatdata/<appid>/ config_path = os.path.join(home, ".steam", "steam", "steamapps", "compatdata", str(app_id), "config_info")
compat_path = os.path.join(home, ".steam", "steam", "steamapps", "compatdata", str(app_id)) if os.path.exists(config_path):
version_file = os.path.join(compat_path, "version") with open(config_path, 'r') as f:
if os.path.exists(version_file): lines = f.readlines()
with open(version_file, 'r') as f: if len(lines) >= 2:
return f.read().strip() proton_path = lines[1].strip()
import re
m = re.search(r'Proton[\s]+([\d.]+)', proton_path)
if m:
return m.group(1)
return proton_path.split("/")[-1] if proton_path else ""
return "" return ""
except (IOError, FileNotFoundError): except (IOError, FileNotFoundError):
return "" return ""
async def detect_current_game(self) -> dict:
"""RPC: Detect the currently running game by checking processes.
Returns {appId: int?, name: str}."""
import subprocess
import re
home = os.path.expanduser("~")
steam_path = os.path.join(home, ".steam", "steam")
compat_dir = os.path.join(steam_path, "steamapps", "compatdata")
# Get all running PIDs and their cmdlines
try:
r = subprocess.run(
["ps", "-eo", "pid,args", "--no-headers"],
capture_output=True, text=True, timeout=3
)
if r.returncode != 0:
return {"appId": None, "name": ""}
all_procs = r.stdout
except:
return {"appId": None, "name": ""}
# Check each compatdata directory for running processes
if os.path.exists(compat_dir):
for app_id_str in sorted(os.listdir(compat_dir), reverse=True):
if not app_id_str.isdigit():
continue
# Check if this app has a running process by searching for the app ID
# in the process tree (Steam runtime includes app ID in some form)
try:
r = subprocess.run(
["pgrep", "-f", app_id_str],
capture_output=True, timeout=2
)
if r.returncode == 0:
# Found a running game! Get its proper name from appmanifest
manifest_path = os.path.join(steam_path, "steamapps", f"appmanifest_{app_id_str}.acf")
if os.path.exists(manifest_path):
with open(manifest_path, 'r') as f:
content = f.read()
m = re.search(r'"name"\s+"([^"]+)"', content)
if m:
return {"appId": int(app_id_str), "name": m.group(1)}
return {"appId": int(app_id_str), "name": f"App {app_id_str}"}
except:
continue
# Fallback: extract name from .exe path
for line in all_procs.split('\n'):
if '.exe' in line.lower() and 'proton' in line.lower():
m = re.search(r'/([^/]+)\.exe', line, re.IGNORECASE)
if m:
return {"appId": None, "name": m.group(1)}
return {"appId": None, "name": ""}
async def get_launch_options(self, app_id: int) -> str: async def get_launch_options(self, app_id: int) -> str:
"""RPC: Read launch options for a Steam app from localconfig.vdf. """RPC: Read launch options for a Steam app from localconfig.vdf.
This is best-effort — the VDF format is not officially documented.""" This is best-effort — the VDF format is not officially documented."""
@@ -359,6 +553,8 @@ benchmark_percentiles=97,AVG,1,0.1
headers={ headers={
"Content-Type": "application/json", "Content-Type": "application/json",
"x-api-key": api_key, "x-api-key": api_key,
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; rv:136.0) Gecko/20100101 Firefox/136.0",
"Accept": "application/json",
}, },
method="POST" method="POST"
) )
@@ -396,7 +592,10 @@ benchmark_percentiles=97,AVG,1,0.1
url = f"{base_url}/api/games/lookup?steamAppId=0" url = f"{base_url}/api/games/lookup?steamAppId=0"
req = urllib.request.Request( req = urllib.request.Request(
url, url,
headers={"x-api-key": api_key}, headers={
"x-api-key": api_key,
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; rv:136.0) Gecko/20100101 Firefox/136.0",
},
method="GET" method="GET"
) )
context = _get_ssl_context() context = _get_ssl_context()
+396 -66
View File
@@ -3,14 +3,33 @@ import {
ButtonItem, ButtonItem,
PanelSection, PanelSection,
PanelSectionRow, PanelSectionRow,
TextField,
DropdownItem,
staticClasses, staticClasses,
} from "@decky/ui" } from "@decky/ui"
import { import {
FaPlay, FaPlay,
FaStop, FaStop,
FaClock, FaClock,
FaCopy,
FaCheck,
FaTimes,
FaDownload,
FaCog,
FaFileExport,
FaFileImport,
FaSearch,
} from "react-icons/fa" } from "react-icons/fa"
import type { RecordingState, SessionData, RecentSession, PluginSettings } from "../lib/store" import type { RecordingState, SessionData, RecentSession, PluginSettings } from "../lib/store"
import { KNOWN_HARDWARE_SLUGS } from "@deckyvault/shared"
import {
testApiKey,
checkMangohud,
writeMangohudConfig,
getMangohudConfig,
exportConfig,
importConfig,
} from "../lib/api"
import SessionForm from "./session-form" import SessionForm from "./session-form"
interface MainPanelProps { interface MainPanelProps {
@@ -25,8 +44,15 @@ interface MainPanelProps {
onAddToRecent: (sess: SessionData) => void onAddToRecent: (sess: SessionData) => void
onReset: () => void onReset: () => void
setError: (msg: string) => void setError: (msg: string) => void
setGameName: (name: string, appId?: number) => void
onUpdateSetting: <K extends keyof PluginSettings>(key: K, value: string | null) => void
} }
const HARDWARE_OPTIONS = [
{ label: "Auto-detect", data: "" },
...KNOWN_HARDWARE_SLUGS.map((slug) => ({ label: slug, data: slug })),
]
export default function MainPanel({ export default function MainPanel({
recordingState, recordingState,
session, session,
@@ -39,8 +65,26 @@ export default function MainPanel({
onAddToRecent, onAddToRecent,
onReset, onReset,
setError, setError,
setGameName,
onUpdateSetting,
}: MainPanelProps) { }: MainPanelProps) {
const [elapsed, setElapsed] = useState(0) const [elapsed, setElapsed] = useState(0)
const [mangohudStatus, setMangohudStatus] = useState<{
checked: boolean
installed: boolean
path: string
version: string
}>({ checked: false, installed: false, path: "", version: "" })
const [keyTestStatus, setKeyTestStatus] = useState<"idle" | "testing" | "valid" | "invalid">("idle")
const [keyTestMessage, setKeyTestMessage] = useState("")
const [copiedLaunchOpt, setCopiedLaunchOpt] = useState(false)
const [configWritten, setConfigWritten] = useState(false)
const [configVerified, setConfigVerified] = useState<{
checked: boolean
valid: boolean
message: string
}>({ checked: false, valid: false, message: "" })
const [configStatus, setConfigStatus] = useState<{ message: string; isError: boolean } | null>(null)
// Timer for recording state // Timer for recording state
useEffect(() => { useEffect(() => {
@@ -60,6 +104,100 @@ export default function MainPanel({
return `${m}:${s.toString().padStart(2, "0")}` return `${m}:${s.toString().padStart(2, "0")}`
} }
async function handleCheckMangohud() {
const result = await checkMangohud()
setMangohudStatus({
checked: true,
installed: result.installed,
path: result.path,
version: result.version,
})
if (result.debug) {
console.log("[DeckyVault] MangoHud debug:", result.debug)
}
}
async function handleTestKey() {
if (!settings.apiKey) {
setKeyTestStatus("invalid")
setKeyTestMessage("Enter an API key first")
return
}
setKeyTestStatus("testing")
setKeyTestMessage("")
const result = await testApiKey(settings.apiKey, settings.baseUrl)
if (result.valid) {
setKeyTestStatus("valid")
setKeyTestMessage("API key is valid")
} else {
setKeyTestStatus("invalid")
setKeyTestMessage(result.error || "Invalid API key")
}
}
async function handleCopyLaunchOption() {
try {
await navigator.clipboard.writeText("~/deckyvault-mangohud.sh %command%")
setCopiedLaunchOpt(true)
setTimeout(() => setCopiedLaunchOpt(false), 2000)
} catch {
const ta = document.createElement("textarea")
ta.value = "~/deckyvault-mangohud.sh %command%"
document.body.appendChild(ta)
ta.select()
document.execCommand("copy")
document.body.removeChild(ta)
setCopiedLaunchOpt(true)
setTimeout(() => setCopiedLaunchOpt(false), 2000)
}
}
async function handleWriteConfig() {
const result = await writeMangohudConfig()
setConfigWritten(result.success)
}
async function handleVerifyConfig() {
const result = await getMangohudConfig()
if (!result.exists) {
setConfigVerified({ checked: true, valid: false, message: "No MangoHud config found. Write one first." })
return
}
const content = result.content
const hasOutputFolder = content.includes("output_folder=/tmp")
const hasFps = content.includes("fps")
const hasFrameTiming = content.includes("frame_timing")
if (hasOutputFolder && hasFps) {
setConfigVerified({ checked: true, valid: true, message: "Config looks good" })
} else {
setConfigVerified({ checked: true, valid: false, message: "Config is missing required settings. Write it again." })
}
}
async function handleExportConfig() {
setConfigStatus(null)
const result = await exportConfig(settings)
if (result.success) {
setConfigStatus({ message: `Config saved to ${result.path}`, isError: false })
} else {
setConfigStatus({ message: result.error || "Export failed", isError: true })
}
}
async function handleImportConfig() {
setConfigStatus(null)
const result = await importConfig()
if (result.success && result.settings) {
onUpdateSetting("apiKey", result.settings.apiKey || "")
onUpdateSetting("exportPath", result.settings.exportPath || "/home/deck/Downloads")
onUpdateSetting("baseUrl", result.settings.baseUrl || "https://deckyvault.xyz")
onUpdateSetting("hardwareSlug", result.settings.hardwareSlug || null)
setConfigStatus({ message: "Config imported from Downloads", isError: false })
} else {
setConfigStatus({ message: result.error || "No config file found in Downloads", isError: true })
}
}
// ── Stopped state: show the session form ────────────────────── // ── Stopped state: show the session form ──────────────────────
if (recordingState === "stopped") { if (recordingState === "stopped") {
return ( return (
@@ -75,78 +213,270 @@ export default function MainPanel({
) )
} }
// ── Idle or Recording state ───────────────────────────────────
return ( return (
<PanelSection title="Recording"> <>
{error && ( {/* ── Recording ──────────────────────────────────────────── */}
<PanelSectionRow> <PanelSection title="Recording">
<div className={staticClasses.Text} style={{ color: "#e74c3c", padding: "8px" }}> {error && (
{error}
</div>
</PanelSectionRow>
)}
<PanelSectionRow>
{recordingState === "idle" ? (
<ButtonItem layout="below" onClick={onStart}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<FaPlay />
Start Recording
</div>
</ButtonItem>
) : (
<ButtonItem layout="below" onClick={onStop} disabled={false}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<FaStop />
Stop Recording
</div>
</ButtonItem>
)}
</PanelSectionRow>
{recordingState === "recording" && (
<>
<PanelSectionRow> <PanelSectionRow>
<div className={staticClasses.Text} style={{ padding: "8px 0" }}> <div className={staticClasses.Text} style={{ color: "#e74c3c", padding: "8px" }}>{error}</div>
<div style={{ display: "flex", alignItems: "center", gap: "6px", marginBottom: "4px" }}>
<FaClock />
<strong>{formatTime(elapsed)}</strong>
</div>
<div>
{session.gameName
? `Recording: ${session.gameName}`
: "No game detected — recording anyway"}
</div>
</div>
</PanelSectionRow> </PanelSectionRow>
</> )}
)}
{recordingState === "idle" && ( {recordingState === "idle" && (
<PanelSectionRow> <>
<div className={staticClasses.Text} style={{ padding: "8px 0", fontSize: "12px", opacity: 0.7 }}> <PanelSectionRow>
Enable MangoHud for your game, then press Start Recording before launching. <TextField
Configure MangoHud in the Settings tab. label="Game Name"
</div> value={session.gameName}
</PanelSectionRow> onChange={(e) => setGameName(e.target.value)}
)} placeholder="e.g. Cyberpunk 2077"
/>
</PanelSectionRow>
<PanelSectionRow>
<ButtonItem layout="below" onClick={onStart}>
<div style={{ display: "flex", alignItems: "center", gap: "8px", justifyContent: "center" }}>
<FaPlay />
Start Recording
</div>
</ButtonItem>
</PanelSectionRow>
</>
)}
{recentSessions.length > 0 && recordingState === "idle" && ( {recordingState === "recording" && (
<PanelSection title="Recent Recordings"> <>
{recentSessions.map((rs, i) => ( <PanelSectionRow>
<PanelSectionRow key={i}> <div className={staticClasses.Text} style={{ padding: "8px 0", textAlign: "center" }}>
<div className={staticClasses.Text} style={{ padding: "4px 0", fontSize: "13px" }}> <div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: "6px", marginBottom: "4px" }}>
<strong>{rs.gameName || "Unknown game"}</strong> <FaClock />
<br /> <strong>{formatTime(elapsed)}</strong>
<span style={{ opacity: 0.6 }}> </div>
{rs.fpsAvg ? `${rs.fpsAvg} FPS avg` : "No data"} ·{" "} <div style={{ fontSize: "13px", opacity: 0.7 }}>
{new Date(rs.date).toLocaleDateString()} {session.gameName ? `Recording: ${session.gameName}` : "Recording..."}
</span> </div>
</div> </div>
</PanelSectionRow> </PanelSectionRow>
))} <PanelSectionRow>
</PanelSection> <ButtonItem layout="below" onClick={onStop}>
)} <div style={{ display: "flex", alignItems: "center", gap: "8px", justifyContent: "center" }}>
</PanelSection> <FaStop />
Stop Recording
</div>
</ButtonItem>
</PanelSectionRow>
</>
)}
{recentSessions.length > 0 && recordingState === "idle" && (
<PanelSection title="Recent Recordings">
{recentSessions.map((rs, i) => (
<PanelSectionRow key={i}>
<div className={staticClasses.Text} style={{ padding: "4px 0", fontSize: "13px" }}>
<strong>{rs.gameName || "Unknown game"}</strong>
<br />
<span style={{ opacity: 0.6 }}>
{rs.fpsAvg ? `${rs.fpsAvg} FPS avg` : "No data"} · {new Date(rs.date).toLocaleDateString()}
</span>
</div>
</PanelSectionRow>
))}
</PanelSection>
)}
</PanelSection>
{/* ── Status ──────────────────────────────────────────────── */}
<PanelSection title="Status">
<PanelSectionRow>
<ButtonItem layout="below" onClick={handleCheckMangohud}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<FaCog />
Check MangoHud Status
</div>
</ButtonItem>
</PanelSectionRow>
{mangohudStatus.checked && (
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "13px", padding: "4px 0" }}>
{mangohudStatus.installed ? (
<><FaCheck style={{ color: "#2ecc71" }} /> MangoHud {mangohudStatus.version}</>
) : (
<><FaTimes style={{ color: "#e74c3c" }} /> MangoHud not found</>
)}
</div>
</PanelSectionRow>
)}
{session.gameName && (
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "13px", padding: "4px 0" }}>
<strong>Game:</strong> {session.gameName}
{session.appId && <> <strong>App ID:</strong> {session.appId}</>}
</div>
</PanelSectionRow>
)}
<PanelSectionRow>
<ButtonItem layout="below" onClick={handleTestKey} disabled={keyTestStatus === "testing"}>
{keyTestStatus === "testing" ? "Testing..." : "Test API Key"}
{keyTestStatus === "valid" && <FaCheck style={{ color: "#2ecc71", marginLeft: "8px" }} />}
{keyTestStatus === "invalid" && <FaTimes style={{ color: "#e74c3c", marginLeft: "8px" }} />}
</ButtonItem>
</PanelSectionRow>
{keyTestMessage && (
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", color: keyTestStatus === "valid" ? "#2ecc71" : "#e74c3c", padding: "4px 0" }}>
{keyTestMessage}
</div>
</PanelSectionRow>
)}
</PanelSection>
{/* ── Usage Instructions ──────────────────────────────────── */}
<PanelSection title="Usage Instructions">
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", padding: "4px 0", lineHeight: "1.5" }}>
Add this to your game's Steam launch options, then launch the game. Press Start Recording once you're in-game and ready to benchmark.
</div>
</PanelSectionRow>
<PanelSectionRow>
<div style={{
background: "rgba(255,255,255,0.1)",
borderRadius: "8px",
padding: "10px 14px",
fontFamily: "monospace",
fontSize: "14px",
textAlign: "center",
}}>
~/deckyvault-mangohud.sh %command%
</div>
</PanelSectionRow>
<PanelSectionRow>
<ButtonItem layout="below" onClick={handleCopyLaunchOption}>
<div style={{ display: "flex", alignItems: "center", gap: "8px", justifyContent: "center" }}>
<FaCopy />
{copiedLaunchOpt ? "Copied to clipboard" : "Copy Launch Option"}
</div>
</ButtonItem>
</PanelSectionRow>
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "11px", opacity: 0.6, padding: "4px 0" }}>
Config stored in ~/.config/MangoHud/MangoHud.conf
</div>
</PanelSectionRow>
</PanelSection>
{/* ── MangoHud Config ────────────────────────────────────── */}
<PanelSection title="MangoHud Config">
<PanelSectionRow>
<ButtonItem layout="below" onClick={handleWriteConfig}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<FaDownload />
Write Config
</div>
</ButtonItem>
</PanelSectionRow>
{configWritten && (
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", color: "#2ecc71", padding: "4px 0" }}>
<FaCheck /> Config written
</div>
</PanelSectionRow>
)}
<PanelSectionRow>
<ButtonItem layout="below" onClick={handleVerifyConfig}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<FaSearch />
Verify Config
</div>
</ButtonItem>
</PanelSectionRow>
{configVerified.checked && (
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", padding: "4px 0", color: configVerified.valid ? "#2ecc71" : "#e74c3c" }}>
{configVerified.valid ? <FaCheck /> : <FaTimes />} {configVerified.message}
</div>
</PanelSectionRow>
)}
</PanelSection>
{/* ── Configuration ────────────────────────────────────────── */}
<PanelSection title="Configuration">
<PanelSectionRow>
<TextField
label="API Key"
value={settings.apiKey}
onChange={(e) => onUpdateSetting("apiKey", e.target.value)}
placeholder="dv_..."
bIsPassword
/>
</PanelSectionRow>
<PanelSectionRow>
<TextField
label="Export Path"
value={settings.exportPath}
onChange={(e) => onUpdateSetting("exportPath", e.target.value)}
placeholder="/home/deck/Downloads"
/>
</PanelSectionRow>
<PanelSectionRow>
<TextField
label="Server URL"
value={settings.baseUrl}
onChange={(e) => onUpdateSetting("baseUrl", e.target.value)}
placeholder="https://deckyvault.xyz"
/>
</PanelSectionRow>
<PanelSectionRow>
<DropdownItem
label="Default Hardware"
rgOptions={HARDWARE_OPTIONS}
selectedOption={settings.hardwareSlug || ""}
onChange={(opt) => onUpdateSetting("hardwareSlug", opt.data as string || null)}
/>
</PanelSectionRow>
<PanelSectionRow>
<ButtonItem layout="below" onClick={handleExportConfig}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<FaFileExport />
Export Config to Downloads
</div>
</ButtonItem>
</PanelSectionRow>
<PanelSectionRow>
<ButtonItem layout="below" onClick={handleImportConfig}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<FaFileImport />
Import Config from Downloads
</div>
</ButtonItem>
</PanelSectionRow>
{configStatus && (
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", padding: "4px 0", color: configStatus.isError ? "#e74c3c" : "#2ecc71" }}>
{configStatus.isError ? <FaTimes /> : <FaCheck />} {configStatus.message}
</div>
</PanelSectionRow>
)}
</PanelSection>
{/* ── MangoHud Setup Guide ────────────────────────────────── */}
<PanelSection title="MangoHud Setup Guide">
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", padding: "8px", lineHeight: "1.6" }}>
<strong>Steam Deck (SteamOS):</strong> MangoHud is pre-installed. Add <code>mangohud %command%</code> to your game's Steam launch options (right-click Properties Launch Options).
</div>
</PanelSectionRow>
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", padding: "8px", lineHeight: "1.6" }}>
<strong>Other Linux:</strong> Install via <code>sudo apt install mangohud</code> or <code>flatpak install ...VulkanLayer.MangoHud</code>. See{" "}
<a href="https://github.com/flightlessmango/MangoHud" style={{ color: "#66c0f4" }}>github.com/flightlessmango/MangoHud</a>.
</div>
</PanelSectionRow>
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", padding: "8px", lineHeight: "1.6" }}>
<strong>Troubleshooting:</strong> Log empty? Check MangoHud is enabled. Not attaching? Add <code>mangohud %command%</code> to launch options explicitly.
</div>
</PanelSectionRow>
</PanelSection>
</>
) )
} }
@@ -5,6 +5,7 @@ import {
PanelSectionRow, PanelSectionRow,
DropdownItem, DropdownItem,
TextField, TextField,
Field,
staticClasses, staticClasses,
} from "@decky/ui" } from "@decky/ui"
import { import {
@@ -155,18 +156,6 @@ export default function SessionForm({
/> />
</PanelSectionRow> </PanelSectionRow>
<PanelSectionRow>
<Field label="In-game Settings" bottomSeparator="none">
<textarea
value={session.settingsJson}
onChange={(e) => onUpdateSession({ settingsJson: e.target.value })}
placeholder="e.g. High preset, 1280x800, TAA"
rows={3}
style={{ width: "100%", padding: "4px 8px", resize: "vertical" }}
/>
</Field>
</PanelSectionRow>
<PanelSectionRow> <PanelSectionRow>
<TextField <TextField
label="Load Time - SSD (seconds)" label="Load Time - SSD (seconds)"
@@ -197,16 +186,12 @@ export default function SessionForm({
</PanelSectionRow> </PanelSectionRow>
<PanelSectionRow> <PanelSectionRow>
<Field label="Notes" bottomSeparator="none"> <TextField
<textarea label="Notes"
value={session.userNotes} value={session.userNotes}
onChange={(e) => onUpdateSession({ userNotes: e.target.value })} onChange={(e) => onUpdateSession({ userNotes: e.target.value })}
placeholder="Any observations about performance..." placeholder="Any observations about performance..."
rows={3} />
maxLength={5000}
style={{ width: "100%", padding: "4px 8px", resize: "vertical" }}
/>
</Field>
</PanelSectionRow> </PanelSectionRow>
</PanelSection> </PanelSection>
+63 -82
View File
@@ -1,4 +1,3 @@
import { useEffect, useRef } from "react"
import { import {
PanelSection, PanelSection,
PanelSectionRow, PanelSectionRow,
@@ -9,11 +8,13 @@ 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 SettingsPanel from "./components/settings-panel" import { useSettings, useSession, useGameDetection } from "./lib/store"
import { useSettings, useSession } from "./lib/store"
import { import {
readAndParseMangohudLog, readAndParseMangohudLog,
clearMangohudLog, clearMangohudLog,
writeMangohudConfig,
startMangohudLogging,
stopMangohudLogging,
getHardwareInfo, getHardwareInfo,
getOsVersion, getOsVersion,
getProtonVersion, getProtonVersion,
@@ -35,97 +36,79 @@ function Content() {
reset, reset,
onGameStart, onGameStart,
onGameStop, onGameStop,
setGameName,
} = useSession() } = useSession()
const gameStartedUnregRef = useRef<{ unregister: () => void } | null>(null)
const gameStoppedUnregRef = useRef<{ unregister: () => void } | null>(null)
// ── Register SteamClient game events ────────────────────────── // ── Game detection via polling ────────────────────────────────
useEffect(() => { useGameDetection(setGameName, recordingState)
try {
const startedReg = SteamClient.Apps.RegisterForGameStarted(async (appId: number) => {
let gameName = `App ${appId}`
try {
const info = await SteamClient.Apps.GetCurrentGameInfo()
if (info.appId === appId) {
gameName = info.strAppName
}
} catch {
// GetCurrentGameInfo may not be available in all contexts
}
onGameStart(appId, gameName)
})
gameStartedUnregRef.current = startedReg
const stoppedReg = SteamClient.Apps.RegisterForGameStopped((_appId: number) => {
onGameStop()
})
gameStoppedUnregRef.current = stoppedReg
} catch (e) {
console.warn("[DeckyVault] SteamClient event registration failed:", e)
}
return () => {
try {
gameStartedUnregRef.current?.unregister()
gameStoppedUnregRef.current?.unregister()
} catch {
// ignore
}
}
}, [onGameStart, onGameStop])
// ── Handle start recording ──────────────────────────────────── // ── Handle start recording ────────────────────────────────────
async function handleStart() { async function handleStart() {
// Write MangoHud config with logging settings
await writeMangohudConfig()
// Clear any previous log file // Clear any previous log file
await clearMangohudLog() await clearMangohudLog()
// Fire-and-forget: try to start MangoHud logging (retries until game launches)
startMangohudLogging()
startRecording() startRecording()
} }
// ── Handle stop recording: parse log + read system info ──────── // ── Handle stop recording: parse log + read system info ────────
async function handleStop() { async function handleStop() {
stopRecording() try {
// Try to stop MangoHud logging (best-effort, may fail if game already closed)
await stopMangohudLogging()
stopRecording()
// Parse the MangoHud log // Parse the MangoHud log
const logResult = await readAndParseMangohudLog() const logResult = await readAndParseMangohudLog()
if (logResult.error) { if (logResult.error) {
setError(logResult.error) setError(logResult.error)
// Still transition to stopped state so user can see the error + manual fields return
return }
}
// Read system info in parallel // Read system info in parallel
const [hwInfo, osVersion] = await Promise.all([ const [hwInfo, osVersion] = await Promise.all([
getHardwareInfo(), getHardwareInfo(),
getOsVersion(), getOsVersion(),
])
// Read Proton version + launch options if we have an app ID
let protonVersion = ""
let launchOptions = ""
if (session.appId) {
const [pv, lo] = await Promise.all([
getProtonVersion(session.appId),
getLaunchOptions(session.appId),
]) ])
protonVersion = pv
launchOptions = lo // Read Proton version + launch options if we have an app ID
let protonVersion = ""
let launchOptions = ""
const currentAppId = session.appId
if (currentAppId) {
try {
const [pv, lo] = await Promise.all([
getProtonVersion(currentAppId),
getLaunchOptions(currentAppId),
])
protonVersion = pv
launchOptions = lo
} catch {
// Non-critical, continue without
}
}
// Use settings hardware override if set, otherwise auto-detected
const hardwareSlug = settings.hardwareSlug || hwInfo.slug
updateSession({
fpsAvg: logResult.fpsAvg ?? null,
fpsLow: logResult.fpsLow ?? null,
fpsHigh: logResult.fpsHigh ?? null,
fpsOnePercentLow: logResult.fpsOnePercentLow ?? null,
tdpWatts: logResult.tdpWatts ?? null,
hardwareSlug,
hardwareName: hwInfo.name,
osVersion,
protonVersion,
launchOptions,
})
} catch (e) {
console.error("[DeckyVault] Error stopping recording:", e)
setError("Failed to process recording. Check the MangoHud log.")
} }
// Use settings hardware override if set, otherwise auto-detected
const hardwareSlug = settings.hardwareSlug || hwInfo.slug
updateSession({
fpsAvg: logResult.fpsAvg ?? null,
fpsLow: logResult.fpsLow ?? null,
fpsHigh: logResult.fpsHigh ?? null,
fpsOnePercentLow: logResult.fpsOnePercentLow ?? null,
tdpWatts: logResult.tdpWatts ?? null,
hardwareSlug,
hardwareName: hwInfo.name,
osVersion,
protonVersion,
launchOptions,
})
} }
if (!loaded) { if (!loaded) {
@@ -154,9 +137,7 @@ function Content() {
onAddToRecent={addToRecent} onAddToRecent={addToRecent}
onReset={reset} onReset={reset}
setError={setError} setError={setError}
/> setGameName={setGameName}
<SettingsPanel
settings={settings}
onUpdateSetting={updateSetting} onUpdateSetting={updateSetting}
/> />
</> </>
@@ -215,7 +196,7 @@ export default definePlugin(() => {
titleView: <div className={staticClasses.Title}>DeckyVault</div>, titleView: <div className={staticClasses.Title}>DeckyVault</div>,
content: <Content />, content: <Content />,
icon: <DeckyVaultIcon />, icon: <DeckyVaultIcon />,
alwaysRender: false, alwaysRender: true,
onDismount() { onDismount() {
console.log("[DeckyVault] Plugin unloading") console.log("[DeckyVault] Plugin unloading")
}, },
+21 -1
View File
@@ -10,6 +10,7 @@ export const checkMangohud = callable<[], {
path: string path: string
version: string version: string
error?: string error?: string
debug?: string
}>("check_mangohud") }>("check_mangohud")
export const writeMangohudConfig = callable<[], { export const writeMangohudConfig = callable<[], {
@@ -33,11 +34,21 @@ export const readAndParseMangohudLog = callable<[logPath?: string], {
error?: string error?: string
}>("read_and_parse_mangohud_log") }>("read_and_parse_mangohud_log")
export const clearMangohudLog = callable<[logPath?: string], { export const clearMangohudLog = callable<[], {
success: boolean success: boolean
error?: string error?: string
}>("clear_mangohud_log") }>("clear_mangohud_log")
export const startMangohudLogging = callable<[], {
success: boolean
error?: string
}>("start_mangohud_logging")
export const stopMangohudLogging = callable<[], {
success: boolean
error?: string
}>("stop_mangohud_logging")
// ── System Info ───────────────────────────────────────────────── // ── System Info ─────────────────────────────────────────────────
export const getHardwareInfo = callable<[], { export const getHardwareInfo = callable<[], {
slug: string slug: string
@@ -75,6 +86,15 @@ export const testApiKey = callable<[apiKey: string, baseUrl?: string], {
error?: string error?: string
}>("test_api_key") }>("test_api_key")
export const detectCurrentGame = callable<[], {
appId: number | null
name: string
}>("detect_current_game")
export const debugListTmp = callable<[], {
files: Array<{ name: string; size: number; mtime: number }>
}>("debug_list_tmp")
// ── Config Export/Import ──────────────────────────────────────── // ── Config Export/Import ────────────────────────────────────────
export const exportConfig = callable<[settings: { export const exportConfig = callable<[settings: {
apiKey: string apiKey: string
+45 -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 ───────────────────────────────────────────────────────
@@ -171,6 +171,13 @@ export function useSession() {
currentAppNameRef.current = "" currentAppNameRef.current = ""
}, []) }, [])
// Manual game name override (fallback when SteamClient events don't fire)
const setGameName = useCallback((name: string, appId?: number) => {
currentAppNameRef.current = name
if (appId !== undefined) currentAppIdRef.current = appId
setSession((prev) => ({ ...prev, gameName: name, appId: appId ?? prev.appId }))
}, [])
return { return {
recordingState, recordingState,
session, session,
@@ -184,9 +191,46 @@ export function useSession() {
reset, reset,
onGameStart, onGameStart,
onGameStop, onGameStop,
setGameName,
} }
} }
// ── 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 {
+26 -8
View File
@@ -4,17 +4,18 @@
declare global { declare global {
const SteamClient: { const SteamClient: {
GameSessions: {
RegisterForAppLifetimeNotifications: (
callback: (notification: AppLifetimeNotification) => void,
) => { unregister: () => void }
}
Apps: { Apps: {
RegisterForGameStarted: ( RegisterForGameActionStart: (
callback: (appId: number) => void, callback: (gameActionId: number, appId: string, action: string, source: number) => void,
) => { unregister: () => void } ) => { unregister: () => void }
RegisterForGameStopped: ( RegisterForGameActionEnd: (
callback: (appId: number) => void, callback: (gameActionId: number) => void,
) => { unregister: () => void } ) => { unregister: () => void }
GetCurrentGameInfo: () => Promise<{
appId: number
strAppName: string
}>
} }
System: { System: {
GetOSVersion: () => Promise<string> GetOSVersion: () => Promise<string>
@@ -23,6 +24,23 @@ declare global {
GetUIMode: () => Promise<number> GetUIMode: () => Promise<number>
} }
} }
interface AppLifetimeNotification {
unAppID: number
nInstanceID: number
bRunning: boolean
}
interface Window {
appStore: {
GetAppOverviewByAppID: (appId: number) => SteamAppOverview | null
}
}
interface SteamAppOverview {
appid: number
display_name: string
}
} }
export {} export {}