Merge branch 'dev' into prod
This commit is contained in:
@@ -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
@@ -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()
|
||||||
|
|||||||
@@ -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,60 +213,59 @@ export default function MainPanel({
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Idle or Recording state ───────────────────────────────────
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{/* ── Recording ──────────────────────────────────────────── */}
|
||||||
<PanelSection title="Recording">
|
<PanelSection title="Recording">
|
||||||
{error && (
|
{error && (
|
||||||
<PanelSectionRow>
|
<PanelSectionRow>
|
||||||
<div className={staticClasses.Text} style={{ color: "#e74c3c", padding: "8px" }}>
|
<div className={staticClasses.Text} style={{ color: "#e74c3c", padding: "8px" }}>{error}</div>
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
</PanelSectionRow>
|
</PanelSectionRow>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{recordingState === "idle" && (
|
||||||
|
<>
|
||||||
|
<PanelSectionRow>
|
||||||
|
<TextField
|
||||||
|
label="Game Name"
|
||||||
|
value={session.gameName}
|
||||||
|
onChange={(e) => setGameName(e.target.value)}
|
||||||
|
placeholder="e.g. Cyberpunk 2077"
|
||||||
|
/>
|
||||||
|
</PanelSectionRow>
|
||||||
<PanelSectionRow>
|
<PanelSectionRow>
|
||||||
{recordingState === "idle" ? (
|
|
||||||
<ButtonItem layout="below" onClick={onStart}>
|
<ButtonItem layout="below" onClick={onStart}>
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
|
<div style={{ display: "flex", alignItems: "center", gap: "8px", justifyContent: "center" }}>
|
||||||
<FaPlay />
|
<FaPlay />
|
||||||
Start Recording
|
Start Recording
|
||||||
</div>
|
</div>
|
||||||
</ButtonItem>
|
</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>
|
|
||||||
<div className={staticClasses.Text} style={{ padding: "8px 0" }}>
|
|
||||||
<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 === "recording" && (
|
||||||
|
<>
|
||||||
<PanelSectionRow>
|
<PanelSectionRow>
|
||||||
<div className={staticClasses.Text} style={{ padding: "8px 0", fontSize: "12px", opacity: 0.7 }}>
|
<div className={staticClasses.Text} style={{ padding: "8px 0", textAlign: "center" }}>
|
||||||
Enable MangoHud for your game, then press Start Recording before launching.
|
<div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: "6px", marginBottom: "4px" }}>
|
||||||
Configure MangoHud in the Settings tab.
|
<FaClock />
|
||||||
|
<strong>{formatTime(elapsed)}</strong>
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: "13px", opacity: 0.7 }}>
|
||||||
|
{session.gameName ? `Recording: ${session.gameName}` : "Recording..."}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PanelSectionRow>
|
</PanelSectionRow>
|
||||||
|
<PanelSectionRow>
|
||||||
|
<ButtonItem layout="below" onClick={onStop}>
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: "8px", justifyContent: "center" }}>
|
||||||
|
<FaStop />
|
||||||
|
Stop Recording
|
||||||
|
</div>
|
||||||
|
</ButtonItem>
|
||||||
|
</PanelSectionRow>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{recentSessions.length > 0 && recordingState === "idle" && (
|
{recentSessions.length > 0 && recordingState === "idle" && (
|
||||||
@@ -139,8 +276,7 @@ export default function MainPanel({
|
|||||||
<strong>{rs.gameName || "Unknown game"}</strong>
|
<strong>{rs.gameName || "Unknown game"}</strong>
|
||||||
<br />
|
<br />
|
||||||
<span style={{ opacity: 0.6 }}>
|
<span style={{ opacity: 0.6 }}>
|
||||||
{rs.fpsAvg ? `${rs.fpsAvg} FPS avg` : "No data"} ·{" "}
|
{rs.fpsAvg ? `${rs.fpsAvg} FPS avg` : "No data"} · {new Date(rs.date).toLocaleDateString()}
|
||||||
{new Date(rs.date).toLocaleDateString()}
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</PanelSectionRow>
|
</PanelSectionRow>
|
||||||
@@ -148,5 +284,199 @@ export default function MainPanel({
|
|||||||
</PanelSection>
|
</PanelSection>
|
||||||
)}
|
)}
|
||||||
</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>
|
||||||
|
|
||||||
|
|||||||
@@ -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,61 +36,34 @@ 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() {
|
||||||
|
try {
|
||||||
|
// Try to stop MangoHud logging (best-effort, may fail if game already closed)
|
||||||
|
await stopMangohudLogging()
|
||||||
stopRecording()
|
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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,13 +76,18 @@ function Content() {
|
|||||||
// Read Proton version + launch options if we have an app ID
|
// Read Proton version + launch options if we have an app ID
|
||||||
let protonVersion = ""
|
let protonVersion = ""
|
||||||
let launchOptions = ""
|
let launchOptions = ""
|
||||||
if (session.appId) {
|
const currentAppId = session.appId
|
||||||
|
if (currentAppId) {
|
||||||
|
try {
|
||||||
const [pv, lo] = await Promise.all([
|
const [pv, lo] = await Promise.all([
|
||||||
getProtonVersion(session.appId),
|
getProtonVersion(currentAppId),
|
||||||
getLaunchOptions(session.appId),
|
getLaunchOptions(currentAppId),
|
||||||
])
|
])
|
||||||
protonVersion = pv
|
protonVersion = pv
|
||||||
launchOptions = lo
|
launchOptions = lo
|
||||||
|
} catch {
|
||||||
|
// Non-critical, continue without
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use settings hardware override if set, otherwise auto-detected
|
// Use settings hardware override if set, otherwise auto-detected
|
||||||
@@ -126,6 +105,10 @@ function Content() {
|
|||||||
protonVersion,
|
protonVersion,
|
||||||
launchOptions,
|
launchOptions,
|
||||||
})
|
})
|
||||||
|
} catch (e) {
|
||||||
|
console.error("[DeckyVault] Error stopping recording:", e)
|
||||||
|
setError("Failed to process recording. Check the MangoHud log.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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")
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Vendored
+26
-8
@@ -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 {}
|
||||||
Reference in New Issue
Block a user