81 KiB
DeckyVault Decky Loader Plugin Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Build a Decky Loader plugin that records Steam Deck performance metrics via MangoHud, lets users fill in manual details, and exports/uploads them to DeckyVault.
Architecture: Dual-halved Decky plugin — a React/TypeScript frontend (runs in Steam's CEF context, handles UI + SteamClient events) and a Python backend (main.py, handles filesystem/shell/network I/O). Frontend calls backend via callable RPC from @decky/api. The plugin lives in the plugins/decky-vault/ workspace of the Bun monorepo.
Tech Stack: TypeScript + React (frontend), Python 3 (backend), @decky/api + @decky/ui + @decky/rollup (Decky SDK), rollup (bundler), pytest (Python tests), @deckyvault/shared (workspace types)
File Structure
plugins/decky-vault/
├── main.py # Python backend — Plugin class with RPC methods
├── plugin.json # Decky plugin metadata (api_version, flags, publish)
├── package.json # Frontend deps (@decky/api, @decky/ui, @decky/rollup)
├── rollup.config.js # Uses @decky/rollup
├── tsconfig.json # TS config for Decky frontend
├── src/
│ ├── index.tsx # Entry point — definePlugin, SteamClient events, tab nav
│ ├── types.d.ts # SteamClient global type declarations
│ ├── lib/
│ │ ├── api.ts # Typed RPC wrappers (callable functions → Python backend)
│ │ └── store.ts # Frontend state management (React context + hooks)
│ └── components/
│ ├── main-panel.tsx # Record/Stop button, session status, recent recordings
│ ├── session-form.tsx # Post-session summary (auto-captured + manual inputs + actions)
│ └── settings-panel.tsx # API key, export path, hardware override, MangoHud setup
├── tests/
│ ├── test_mangohud_parser.py # Unit tests for log parsing
│ ├── test_settings.py # Unit tests for settings persistence
│ └── fixtures/
│ └── sample_mangohud.log # Sample MangoHud log for parser tests
└── dist/
└── index.js # Built frontend bundle (output of rollup)
Backend responsibilities (main.py):
- Settings persistence (JSON in
DECKY_PLUGIN_SETTINGS_DIR) - MangoHud: check install, write config, read/parse log
- System info: hardware model, OS version
- Export: write
.deckyvault.jsonto disk - Upload: HTTP POST to DeckyVault API (avoids CORS issues from CEF context)
Frontend responsibilities (src/):
- UI rendering (Decky UI components)
- SteamClient game start/stop event registration
- Session state management (recording status, parsed results)
- RPC calls to Python backend
- Building the
DeckyVaultImportV1payload from combined auto + manual data
Task 1: Update package.json with correct Decky dependencies
Files:
-
Modify:
plugins/decky-vault/package.json -
Step 1: Rewrite package.json
Replace the entire contents with:
{
"name": "@deckyvault/plugin",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"build": "rollup -c",
"watch": "rollup -c -w"
},
"dependencies": {
"@decky/api": "^1.1.3",
"@deckyvault/shared": "workspace:*",
"react-icons": "^5.3.0",
"tslib": "^2.7.0"
},
"devDependencies": {
"@decky/rollup": "^1.0.2",
"@decky/ui": "^4.11.6",
"@types/react": "^19.1.1",
"@types/react-dom": "^19.1.1",
"rollup": "^4.53.3",
"typescript": "^5.6.2"
}
}
Note: React/react-dom are NOT listed — they are provided by Steam's CEF runtime. @decky/rollup handles marking them as external.
- Step 2: Install dependencies
Run: cd plugins/decky-vault && bun install
Expected: packages installed successfully (may need --no-cache if Bun has stale cache)
- Step 3: Commit
git add plugins/decky-vault/package.json
git commit -m "feat(plugin): update package.json with @decky SDK dependencies"
Task 2: Update rollup.config.js to use @decky/rollup
Files:
-
Modify:
plugins/decky-vault/rollup.config.js -
Step 1: Rewrite rollup.config.js
Replace entire contents with:
import deckyPlugin from "@decky/rollup";
export default deckyPlugin({
// Add extra rollup options here if needed
});
- Step 2: Verify build runs (may fail on missing src, that's OK)
Run: cd plugins/decky-vault && bun run build 2>&1 | head -20
Expected: Either builds (if src/index.tsx exists) or fails on TypeScript errors (expected — we haven't rewritten the entry point yet). The important thing is that @decky/rollup loads without "module not found" errors.
- Step 3: Commit
git add plugins/decky-vault/rollup.config.js
git commit -m "feat(plugin): use @decky/rollup for build config"
Task 3: Update plugin.json with correct Decky fields
Files:
-
Modify:
plugins/decky-vault/plugin.json -
Step 1: Rewrite plugin.json
Replace entire contents with:
{
"name": "DeckyVault",
"author": "DeckyVault",
"flags": ["debug"],
"api_version": 1,
"publish": {
"tags": ["performance", "benchmark", "mangohud"],
"description": "Record performance metrics and export/upload them to DeckyVault",
"image": ""
}
}
- Step 2: Commit
git add plugins/decky-vault/plugin.json
git commit -m "feat(plugin): update plugin.json with correct Decky metadata fields"
Task 4: Update tsconfig.json for Decky frontend
Files:
-
Modify:
plugins/decky-vault/tsconfig.json -
Step 1: Rewrite tsconfig.json
Replace entire contents with:
{
"compilerOptions": {
"target": "ES2022",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"jsx": "react-jsx",
"jsxImportSource": "react",
"outDir": "./dist",
"rootDir": "./src",
"declaration": false,
"sourceMap": false,
"esModuleInterop": true,
"skipLibCheck": true,
"lib": ["ES2022", "DOM"],
"types": ["react", "react-dom"]
},
"include": ["src"],
"references": [
{ "path": "../../packages/shared" }
]
}
Added skipLibCheck (avoids errors from @decky/ui's internal types), lib with DOM (needed for browser globals), and types for React.
- Step 2: Commit
git add plugins/decky-vault/tsconfig.json
git commit -m "feat(plugin): update tsconfig for Decky frontend build"
Task 5: Create Python backend skeleton with settings management
Files:
-
Create:
plugins/decky-vault/main.py -
Create:
plugins/decky-vault/tests/test_settings.py -
Step 1: Write the test for settings persistence
Create plugins/decky-vault/tests/test_settings.py:
"""Tests for settings persistence in the Python backend."""
import json
import os
import sys
import tempfile
import pytest
# We test the settings logic directly, not through the Plugin class,
# so we can run tests without the decky module.
def write_settings(settings_path, settings):
"""Write settings JSON to the given path."""
os.makedirs(os.path.dirname(settings_path), exist_ok=True)
with open(settings_path, 'w') as f:
json.dump(settings, f, indent=2)
def read_settings(settings_path):
"""Read settings JSON from the given path, return empty dict if missing."""
if os.path.exists(settings_path):
with open(settings_path, 'r') as f:
return json.load(f)
return {}
def test_read_settings_returns_empty_when_file_missing():
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "settings.json")
assert read_settings(path) == {}
def test_write_then_read_settings():
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "subdir", "settings.json")
write_settings(path, {"apiKey": "dv_test123", "exportPath": "/home/deck/Downloads"})
result = read_settings(path)
assert result["apiKey"] == "dv_test123"
assert result["exportPath"] == "/home/deck/Downloads"
def test_write_settings_creates_directory():
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "newdir", "settings.json")
write_settings(path, {"key": "value"})
assert os.path.exists(path)
def test_read_settings_handles_corrupt_json():
with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "settings.json")
with open(path, 'w') as f:
f.write("{invalid json")
with pytest.raises(json.JSONDecodeError):
read_settings(path)
- Step 2: Run test to verify it fails (functions not defined in main.py yet)
Run: cd plugins/decky-vault && python -m pytest tests/test_settings.py -v 2>&1 | head -20
Expected: FAIL — the test defines its own helper functions so it should actually PASS. Wait — these are self-contained test helpers. Let me adjust: we're testing the pattern, not importing from main.py. Run it to confirm the pattern works.
Expected: PASS (the test helpers are self-contained). This validates our settings approach before wiring it into main.py.
- Step 3: Create main.py with Plugin class and settings methods
Create plugins/decky-vault/main.py:
import asyncio
import json
import os
try:
import decky
except ImportError:
# Allow running tests without the decky module (tests mock the path)
decky = None
class Plugin:
async def _main(self):
if decky:
decky.logger.info(f"DeckyVault plugin loaded: {decky.DECKY_PLUGIN_NAME}")
self._settings_path = self._get_settings_path()
self._settings = self._read_settings()
async def _unload(self):
if decky:
decky.logger.info("DeckyVault plugin unloading")
async def _uninstall(self):
if decky:
decky.logger.info("DeckyVault plugin uninstalled")
def _get_settings_path(self):
if decky:
return os.path.join(decky.DECKY_PLUGIN_SETTINGS_DIR, "settings.json")
return os.path.join(os.path.expanduser("~"), ".deckyvault-test", "settings.json")
def _read_settings(self):
"""Read settings from JSON file. Returns empty dict if file missing."""
if os.path.exists(self._settings_path):
try:
with open(self._settings_path, 'r') as f:
return json.load(f)
except (json.JSONDecodeError, IOError):
return {}
return {}
def _write_settings(self, settings):
"""Write settings to JSON file, creating directory if needed."""
os.makedirs(os.path.dirname(self._settings_path), exist_ok=True)
with open(self._settings_path, 'w') as f:
json.dump(settings, f, indent=2)
async def get_settings(self) -> dict:
"""RPC: Return all plugin settings."""
return self._settings
async def set_setting(self, key: str, value) -> dict:
"""RPC: Set a single setting and persist. Returns updated settings."""
self._settings[key] = value
self._write_settings(self._settings)
return self._settings
- Step 4: Commit
git add plugins/decky-vault/main.py plugins/decky-vault/tests/test_settings.py
git commit -m "feat(plugin): add Python backend skeleton with settings management"
Task 6: Implement MangoHud status check and config writing
Files:
-
Modify:
plugins/decky-vault/main.py -
Step 1: Add MangoHud methods to the Plugin class
Add these methods to the Plugin class in main.py (after the settings methods):
async def check_mangohud(self) -> dict:
"""RPC: Check if MangoHud is installed. Returns {installed: bool, path: str, version: str}."""
import subprocess
try:
result = subprocess.run(
["which", "mangohud"],
capture_output=True, text=True, timeout=5
)
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": ""}
except Exception as e:
return {"installed": False, "path": "", "version": "", "error": str(e)}
async def write_mangohud_config(self) -> dict:
"""RPC: Write the MangoHud logging config to ~/.config/MangoHud/MangoHud.conf.
Returns {success: bool, path: str, error: str?}."""
try:
home = os.path.expanduser("~")
config_dir = os.path.join(home, ".config", "MangoHud")
config_path = os.path.join(config_dir, "MangoHud.conf")
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 = """\
# DeckyVault MangoHud logging config
output_folder=/tmp
output_file=deckyvault-mangohud.log
log_duration=0
fps
frame_timing
cpu_power
gpu_power
cpu_temp
gpu_temp
benchmark_percentiles=97,AVG,1,0.1
"""
with open(config_path, 'w') as f:
f.write(config_content)
return {"success": True, "path": config_path}
except Exception as e:
return {"success": False, "path": "", "error": str(e)}
async def get_mangohud_config(self) -> dict:
"""RPC: Read the current MangoHud config. Returns {exists: bool, content: str, path: str}."""
home = os.path.expanduser("~")
config_path = os.path.join(home, ".config", "MangoHud", "MangoHud.conf")
if os.path.exists(config_path):
with open(config_path, 'r') as f:
return {"exists": True, "content": f.read(), "path": config_path}
return {"exists": False, "content": "", "path": config_path}
- Step 2: Commit
git add plugins/decky-vault/main.py
git commit -m "feat(plugin): add MangoHud status check and config writing to backend"
Task 7: Implement MangoHud log parser with tests
Files:
-
Create:
plugins/decky-vault/tests/fixtures/sample_mangohud.log -
Create:
plugins/decky-vault/tests/test_mangohud_parser.py -
Modify:
plugins/decky-vault/main.py -
Step 1: Create a sample MangoHud log fixture
Create plugins/decky-vault/tests/fixtures/sample_mangohud.log:
# MangoHud v0.8.4
# note: session started at 2026-06-28 14:30:00
# preset: 0
fps,frametime,cpu_load,gpu_load,cpu_temp,gpu_temp,gpu_power,cpu_power
60,16.67,45,80,55,65,15,10
62,16.13,46,82,55,65,15,10
58,17.24,44,78,56,66,14,10
61,16.39,45,81,55,65,15,10
59,16.95,44,79,56,66,14,10
60,16.67,45,80,55,65,15,10
63,15.87,47,83,55,65,15,10
57,17.54,43,77,56,66,14,10
60,16.67,45,80,55,65,15,10
61,16.39,46,81,55,65,15,10
# benchmark summary
97%, 62
AVG, 60
1%, 57
0.1%, 57
- Step 2: Write the parser test
Create plugins/decky-vault/tests/test_mangohud_parser.py:
"""Tests for MangoHud log parsing."""
import os
# Parser functions — these will be imported from main.py once implemented.
# For now we define them here to test the logic, then move to main.py.
def parse_mangohud_log(log_content: str) -> dict:
"""Parse a MangoHud log file's content and return FPS stats.
Returns: {fpsAvg, fpsLow, fpsHigh, fpsOnePercentLow, tdpWatts, error?}
"""
lines = log_content.strip().split('\n')
# Find the header row (first non-comment, non-empty line that looks like column names)
header_idx = None
fps_col = 0
frametime_col = None
gpu_power_col = None
for i, line in enumerate(lines):
stripped = line.strip()
if not stripped or stripped.startswith('#'):
continue
# Check if this is a header (contains 'fps')
if 'fps' in stripped.lower() and ',' in stripped:
columns = [c.strip().lower() for c in stripped.split(',')]
if 'fps' in columns:
fps_col = columns.index('fps')
if 'frametime' in columns:
frametime_col = columns.index('frametime')
if 'gpu_power' in columns:
gpu_power_col = columns.index('gpu_power')
header_idx = i
break
if header_idx is None:
return {"error": "Could not find FPS column in log header"}
# Extract data rows (lines after header that start with a number)
fps_values = []
frametime_values = []
gpu_power_values = []
for line in lines[header_idx + 1:]:
stripped = line.strip()
if not stripped or stripped.startswith('#'):
continue
# Check if it's a summary line (e.g. "97%,\t62" or "AVG,\t60")
if stripped.startswith(('97%', 'AVG', '1%', '0.1%', '5%')):
continue
parts = [p.strip() for p in stripped.split(',')]
try:
fps = float(parts[fps_col])
fps_values.append(fps)
if frametime_col is not None and frametime_col < len(parts):
ft = float(parts[frametime_col])
frametime_values.append(ft)
if gpu_power_col is not None and gpu_power_col < len(parts):
gp = float(parts[gpu_power_col])
gpu_power_values.append(gp)
except (ValueError, IndexError):
continue
if not fps_values:
return {"error": "No FPS data found in log"}
# Compute stats
fps_avg = round(sum(fps_values) / len(fps_values), 1)
fps_low = round(min(fps_values), 1)
fps_high = round(max(fps_values), 1)
# 1% low: sort frame times, take 1st percentile, convert to FPS
if frametime_values:
sorted_ft = sorted(frametime_values)
one_percent_idx = max(0, int(len(sorted_ft) * 0.01))
one_percent_ft = sorted_ft[one_percent_idx]
fps_one_percent_low = round(1000.0 / one_percent_ft, 1) if one_percent_ft > 0 else None
else:
# Fall back: sort FPS values, take 1st percentile from bottom
sorted_fps = sorted(fps_values)
one_percent_idx = max(0, int(len(sorted_fps) * 0.01))
fps_one_percent_low = round(sorted_fps[one_percent_idx], 1)
tdp_watts = None
if gpu_power_values:
tdp_watts = round(sum(gpu_power_values) / len(gpu_power_values), 1)
return {
"fpsAvg": fps_avg,
"fpsLow": fps_low,
"fpsHigh": fps_high,
"fpsOnePercentLow": fps_one_percent_low,
"tdpWatts": tdp_watts,
}
# ── Tests ──────────────────────────────────────────────────────────
def test_parse_basic_log():
log = """\
# MangoHud v0.8.4
fps,frametime,cpu_load,gpu_load,cpu_temp,gpu_temp,gpu_power,cpu_power
60,16.67,45,80,55,65,15,10
62,16.13,46,82,55,65,15,10
58,17.24,44,78,56,66,14,10
"""
result = parse_mangohud_log(log)
assert "error" not in result
assert result["fpsAvg"] == 60.0
assert result["fpsLow"] == 58.0
assert result["fpsHigh"] == 62.0
assert result["tdpWatts"] == 14.3 # avg of 15,15,14
def test_parse_log_with_summary_section():
"""The summary section (97%, AVG, 1%, 0.1%) should be skipped as data."""
log = """\
# MangoHud v0.8.4
fps,frametime,cpu_load,gpu_power
60,16.67,45,15
62,16.13,46,15
58,17.24,44,14
# benchmark summary
97%, 62
AVG, 60
1%, 57
0.1%, 57
"""
result = parse_mangohud_log(log)
assert "error" not in result
assert result["fpsAvg"] == 60.0
# Should not have tried to parse summary lines as data
assert result["fpsLow"] == 58.0
assert result["fpsHigh"] == 62.0
def test_parse_empty_log_returns_error():
result = parse_mangohud_log("")
assert "error" in result
def test_parse_log_without_fps_column():
log = """\
# no fps here
cpu_load,gpu_load
45,80
"""
result = parse_mangohud_log(log)
assert "error" in result
def test_parse_log_without_gpu_power():
"""tdpWatts should be None if gpu_power column is absent."""
log = """\
fps,frametime,cpu_load
60,16.67,45
62,16.13,46
"""
result = parse_mangohud_log(log)
assert result["tdpWatts"] is None
def test_parse_one_percent_low_from_frametime():
"""1% low should be computed from frame times when available."""
log = """\
fps,frametime
60,16.67
30,33.33
60,16.67
60,16.67
60,16.67
60,16.67
60,16.67
60,16.67
60,16.67
60,16.67
"""
result = parse_mangohud_log(log)
# The 33.33ms frame time is the worst — 1% low should be ~30 fps
assert result["fpsOnePercentLow"] is not None
assert result["fpsOnePercentLow"] <= 35 # roughly 1000/33.33 = 30
def test_parse_fixture_file():
"""Parse the actual fixture file."""
fixture_path = os.path.join(
os.path.dirname(__file__), "fixtures", "sample_mangohud.log"
)
with open(fixture_path, 'r') as f:
content = f.read()
result = parse_mangohud_log(content)
assert "error" not in result
assert result["fpsAvg"] == 60.1 # avg of the 10 data rows
assert result["fpsLow"] == 57.0
assert result["fpsHigh"] == 63.0
assert result["tdpWatts"] is not None
- Step 3: Run the parser tests
Run: cd plugins/decky-vault && python -m pytest tests/test_mangohud_parser.py -v
Expected: All 7 tests PASS
- Step 4: Add the parser to main.py
Add this method to the Plugin class in main.py (after the MangoHud config methods). Also add a standalone parse_mangohud_log function at module level that the method delegates to:
def parse_mangohud_log(log_content: str) -> dict:
"""Parse a MangoHud log file's content and return FPS stats.
Returns: {fpsAvg, fpsLow, fpsHigh, fpsOnePercentLow, tdpWatts, error?}
"""
lines = log_content.strip().split('\n')
header_idx = None
fps_col = 0
frametime_col = None
gpu_power_col = None
for i, line in enumerate(lines):
stripped = line.strip()
if not stripped or stripped.startswith('#'):
continue
if 'fps' in stripped.lower() and ',' in stripped:
columns = [c.strip().lower() for c in stripped.split(',')]
if 'fps' in columns:
fps_col = columns.index('fps')
if 'frametime' in columns:
frametime_col = columns.index('frametime')
if 'gpu_power' in columns:
gpu_power_col = columns.index('gpu_power')
header_idx = i
break
if header_idx is None:
return {"error": "Could not find FPS column in log header"}
fps_values = []
frametime_values = []
gpu_power_values = []
for line in lines[header_idx + 1:]:
stripped = line.strip()
if not stripped or stripped.startswith('#'):
continue
if stripped.startswith(('97%', 'AVG', '1%', '0.1%', '5%')):
continue
parts = [p.strip() for p in stripped.split(',')]
try:
fps = float(parts[fps_col])
fps_values.append(fps)
if frametime_col is not None and frametime_col < len(parts):
frametime_values.append(float(parts[frametime_col]))
if gpu_power_col is not None and gpu_power_col < len(parts):
gpu_power_values.append(float(parts[gpu_power_col]))
except (ValueError, IndexError):
continue
if not fps_values:
return {"error": "No FPS data found in log"}
fps_avg = round(sum(fps_values) / len(fps_values), 1)
fps_low = round(min(fps_values), 1)
fps_high = round(max(fps_values), 1)
if frametime_values:
sorted_ft = sorted(frametime_values)
one_percent_idx = max(0, int(len(sorted_ft) * 0.01))
one_percent_ft = sorted_ft[one_percent_idx]
fps_one_percent_low = round(1000.0 / one_percent_ft, 1) if one_percent_ft > 0 else None
else:
sorted_fps = sorted(fps_values)
one_percent_idx = max(0, int(len(sorted_fps) * 0.01))
fps_one_percent_low = round(sorted_fps[one_percent_idx], 1)
tdp_watts = None
if gpu_power_values:
tdp_watts = round(sum(gpu_power_values) / len(gpu_power_values), 1)
return {
"fpsAvg": fps_avg,
"fpsLow": fps_low,
"fpsHigh": fps_high,
"fpsOnePercentLow": fps_one_percent_low,
"tdpWatts": tdp_watts,
}
Then add this method to the Plugin class:
async def read_and_parse_mangohud_log(self, log_path: str = "/tmp/deckyvault-mangohud.log") -> dict:
"""RPC: Read the MangoHud log file and return parsed FPS stats.
Returns parsed stats dict or {error: str}."""
if not os.path.exists(log_path):
return {"error": f"MangoHud log not found at {log_path}. Make sure MangoHud is enabled and logging."}
try:
with open(log_path, 'r') as f:
content = f.read()
if not content.strip():
return {"error": "MangoHud log is empty. Recording may have been too short."}
return parse_mangohud_log(content)
except Exception as e:
return {"error": f"Failed to read log: {str(e)}"}
async def clear_mangohud_log(self, log_path: str = "/tmp/deckyvault-mangohud.log") -> dict:
"""RPC: Delete the MangoHud log file so the next recording starts fresh."""
try:
if os.path.exists(log_path):
os.remove(log_path)
return {"success": True}
except Exception as e:
return {"success": False, "error": str(e)}
- Step 5: Commit
git add plugins/decky-vault/main.py plugins/decky-vault/tests/
git commit -m "feat(plugin): add MangoHud log parser with unit tests"
Task 8: Implement system info reader (Python backend)
Files:
-
Modify:
plugins/decky-vault/main.py -
Step 1: Add system info methods to the Plugin class
Add these methods to main.py (after the MangoHud methods):
async def get_hardware_info(self) -> dict:
"""RPC: Detect hardware model from DMI. Returns {slug, name, raw}."""
# Steam Deck models: Jupiter = LCD, Galileo = OLED
product_name = ""
try:
with open("/sys/class/dmi/id/product_name", 'r') as f:
product_name = f.read().strip()
except (IOError, FileNotFoundError):
pass
slug = "unknown"
name = "Unknown Device"
if product_name == "Jupiter":
slug = "steamdeck-lcd"
name = "Steam Deck LCD"
elif product_name == "Galileo":
slug = "steamdeck-oled"
name = "Steam Deck OLED"
elif product_name:
name = product_name
slug = product_name.lower().replace(" ", "-")
return {"slug": slug, "name": name, "raw": product_name}
async def get_os_version(self) -> str:
"""RPC: Read OS version from /etc/os-release."""
try:
with open("/etc/os-release", 'r') as f:
for line in f:
if line.startswith("PRETTY_NAME="):
return line.split("=", 1)[1].strip().strip('"')
return "unknown"
except (IOError, FileNotFoundError):
return "unknown"
async def get_proton_version(self, app_id: int) -> str:
"""RPC: Attempt to read the Proton version for a Steam app.
Reads from the Steam compatdata directory."""
try:
home = os.path.expanduser("~")
# Steam compat data lives in ~/.steam/steam/steamapps/compatdata/<appid>/
compat_path = os.path.join(home, ".steam", "steam", "steamapps", "compatdata", str(app_id))
version_file = os.path.join(compat_path, "version")
if os.path.exists(version_file):
with open(version_file, 'r') as f:
return f.read().strip()
return ""
except (IOError, FileNotFoundError):
return ""
async def get_launch_options(self, app_id: int) -> str:
"""RPC: Read launch options for a Steam app from localconfig.vdf.
This is best-effort — the VDF format is not officially documented."""
try:
home = os.path.expanduser("~")
# localconfig.vdf path varies; try common locations
config_paths = [
os.path.join(home, ".steam", "steam", "usercfg", "localconfig.vdf"),
os.path.join(home, ".local", "share", "Steam", "usercfg", "localconfig.vdf"),
]
for config_path in config_paths:
if os.path.exists(config_path):
with open(config_path, 'r') as f:
content = f.read()
# Best-effort: look for LaunchOptions near the app ID
# This is a simple heuristic — VDF parsing is complex
app_str = f'"{app_id}"'
idx = content.find(app_str)
if idx != -1:
# Search for LaunchOptions within ~2000 chars after app ID
search_region = content[idx:idx + 2000]
lo_idx = search_region.find('"LaunchOptions"')
if lo_idx != -1:
# Extract the value between quotes
value_start = search_region.find('"', lo_idx + len('"LaunchOptions"')) + 1
value_end = search_region.find('"', value_start)
if value_start > 0 and value_end > value_start:
return search_region[value_start:value_end]
return ""
return ""
except (IOError, FileNotFoundError):
return ""
- Step 2: Commit
git add plugins/decky-vault/main.py
git commit -m "feat(plugin): add system info reader (hardware, OS, Proton, launch options)"
Task 9: Implement export to file (Python backend)
Files:
-
Modify:
plugins/decky-vault/main.py -
Step 1: Add export method to the Plugin class
Add this method to main.py (after the system info methods):
async def export_to_file(self, data: dict, export_path: str) -> dict:
"""RPC: Write a DeckyVaultImportV1 payload as JSON to the given path.
Returns {success: bool, path: str, error: str?}."""
try:
# Sanitize the filename — the frontend passes a full path including filename
export_dir = os.path.dirname(export_path)
if export_dir and not os.path.exists(export_dir):
os.makedirs(export_dir, exist_ok=True)
with open(export_path, 'w') as f:
json.dump(data, f, indent=2)
return {"success": True, "path": export_path}
except PermissionError:
return {"success": False, "path": "", "error": f"Permission denied writing to {export_path}"}
except Exception as e:
return {"success": False, "path": "", "error": str(e)}
- Step 2: Commit
git add plugins/decky-vault/main.py
git commit -m "feat(plugin): add export-to-file method in backend"
Task 10: Implement upload to DeckyVault (Python backend)
Files:
-
Modify:
plugins/decky-vault/main.py -
Step 1: Add upload method to the Plugin class
Add this method to main.py (after the export method):
async def upload_to_deckyvault(self, data: dict, api_key: str, base_url: str = "https://deckyvault.xyz") -> dict:
"""RPC: Upload a DeckyVaultImportV1 payload to the DeckyVault API.
Uses urllib to avoid external dependencies.
Returns {success: bool, data: dict?, error: str?, status: int?}."""
import urllib.request
import urllib.error
try:
url = f"{base_url}/api/performance/import"
payload = json.dumps(data).encode('utf-8')
req = urllib.request.Request(
url,
data=payload,
headers={
"Content-Type": "application/json",
"x-api-key": api_key,
},
method="POST"
)
with urllib.request.urlopen(req, timeout=30) as response:
status = response.status
body = response.read().decode('utf-8')
result = json.loads(body)
if status == 201:
return {"success": True, "data": result, "status": status}
else:
return {"success": False, "error": result.get("error", "Upload failed"), "status": status}
except urllib.error.HTTPError as e:
error_body = e.read().decode('utf-8')
try:
error_msg = json.loads(error_body).get("error", error_body)
except json.JSONDecodeError:
error_msg = error_body
return {"success": False, "error": error_msg, "status": e.code}
except urllib.error.URLError as e:
return {"success": False, "error": f"Network error: {str(e.reason)}", "status": 0}
except Exception as e:
return {"success": False, "error": str(e), "status": 0}
async def test_api_key(self, api_key: str, base_url: str = "https://deckyvault.xyz") -> dict:
"""RPC: Test if an API key is valid by calling the games lookup endpoint.
Returns {valid: bool, error: str?}."""
import urllib.request
import urllib.error
try:
url = f"{base_url}/api/games/lookup?steamAppId=0"
req = urllib.request.Request(
url,
headers={"x-api-key": api_key},
method="GET"
)
with urllib.request.urlopen(req, timeout=10) as response:
# A 404 (game not found) still means the API key is valid
return {"valid": True}
except urllib.error.HTTPError as e:
if e.code == 401:
return {"valid": False, "error": "Invalid API key"}
elif e.code == 404:
return {"valid": True} # Key works, just no game with ID 0
else:
return {"valid": False, "error": f"Server returned status {e.code}"}
except urllib.error.URLError as e:
return {"valid": False, "error": f"Network error: {str(e.reason)}"}
except Exception as e:
return {"valid": False, "error": str(e)}
- Step 2: Commit
git add plugins/decky-vault/main.py
git commit -m "feat(plugin): add upload and API key test methods in backend"
Task 11: Update types.d.ts with correct SteamClient API
Files:
-
Modify:
plugins/decky-vault/src/types.d.ts -
Step 1: Rewrite types.d.ts
Replace entire contents with:
// Steam Deck CEF context globals — these are Valve's internal APIs,
// available in the Steam Deck game mode browser context.
// Not part of @decky/api; accessed directly from the global scope.
declare global {
const SteamClient: {
Apps: {
RegisterForGameStarted: (
callback: (appId: number) => void,
) => { unregister: () => void }
RegisterForGameStopped: (
callback: (appId: number) => void,
) => { unregister: () => void }
GetCurrentGameInfo: () => Promise<{
appId: number
strAppName: string
}>
}
System: {
GetOSVersion: () => Promise<string>
}
UI: {
GetUIMode: () => Promise<number>
}
}
}
export {}
Removed the non-existent DeckyPlugin global (we use @decky/api instead). Added GetCurrentGameInfo and GetUIMode. Fixed GetAppData to the simpler GetCurrentGameInfo pattern.
- Step 2: Commit
git add plugins/decky-vault/src/types.d.ts
git commit -m "feat(plugin): update SteamClient type declarations"
Task 12: Create frontend RPC wrappers
Files:
-
Create:
plugins/decky-vault/src/lib/api.ts -
Step 1: Write the RPC wrappers
Create plugins/decky-vault/src/lib/api.ts:
import { callable } from "@decky/api"
// ── Settings ────────────────────────────────────────────────────
export const getSettings = callable<[], Record<string, unknown>>("get_settings")
export const setSetting = callable<[key: string, value: unknown], Record<string, unknown>>("set_setting")
// ── MangoHud ────────────────────────────────────────────────────
export const checkMangohud = callable<[], {
installed: boolean
path: string
version: string
error?: string
}>("check_mangohud")
export const writeMangohudConfig = callable<[], {
success: boolean
path: string
error?: string
}>("write_mangohud_config")
export const getMangohudConfig = callable<[], {
exists: boolean
content: string
path: string
}>("get_mangohud_config")
export const readAndParseMangohudLog = callable<[logPath?: string], {
fpsAvg?: number
fpsLow?: number
fpsHigh?: number
fpsOnePercentLow?: number | null
tdpWatts?: number | null
error?: string
}>("read_and_parse_mangohud_log")
export const clearMangohudLog = callable<[logPath?: string], {
success: boolean
error?: string
}>("clear_mangohud_log")
// ── System Info ─────────────────────────────────────────────────
export const getHardwareInfo = callable<[], {
slug: string
name: string
raw: string
}>("get_hardware_info")
export const getOsVersion = callable<[], string>("get_os_version")
export const getProtonVersion = callable<[appId: number], string>("get_proton_version")
export const getLaunchOptions = callable<[appId: number], string>("get_launch_options")
// ── Export ──────────────────────────────────────────────────────
export const exportToFile = callable<[data: Record<string, unknown>, exportPath: string], {
success: boolean
path: string
error?: string
}>("export_to_file")
// ── Upload ──────────────────────────────────────────────────────
export const uploadToDeckyvault = callable<[
data: Record<string, unknown>,
apiKey: string,
baseUrl?: string
], {
success: boolean
data?: { id: string; gameId: string; versionId: string; createdAt: string; authMethod: string }
error?: string
status?: number
}>("upload_to_deckyvault")
export const testApiKey = callable<[apiKey: string, baseUrl?: string], {
valid: boolean
error?: string
}>("test_api_key")
- Step 2: Commit
git add plugins/decky-vault/src/lib/api.ts
git commit -m "feat(plugin): add typed RPC wrappers for Python backend"
Task 13: Create frontend store (state management)
Files:
-
Create:
plugins/decky-vault/src/lib/store.ts -
Step 1: Write the store module
Create plugins/decky-vault/src/lib/store.ts:
import { useState, useEffect, useCallback, useRef } from "react"
import type { DeckyVaultImportV1, HardwareSlug } from "@deckyvault/shared"
import { getSettings, setSetting } from "./api"
// ── Types ───────────────────────────────────────────────────────
export interface PluginSettings {
apiKey: string
exportPath: string
hardwareSlug: string | null // null = auto-detect
baseUrl: string
}
const DEFAULT_SETTINGS: PluginSettings = {
apiKey: "",
exportPath: "/home/deck/Downloads",
hardwareSlug: null,
baseUrl: "https://deckyvault.xyz",
}
export type RecordingState = "idle" | "recording" | "stopped"
export interface SessionData {
appId: number | null
gameName: string
startedAt: number
// Auto-captured (filled after stop)
fpsAvg: number | null
fpsLow: number | null
fpsHigh: number | null
fpsOnePercentLow: number | null
tdpWatts: number | null
hardwareSlug: string
hardwareName: string
osVersion: string
protonVersion: string
// Manual inputs (filled by user in the form)
upscalerType: string
upscalerVersion: string
frameGenMethod: string
settingsJson: string
loadTimeSsd: string
loadTimeSd: string
launchOptions: string
userNotes: string
}
export interface RecentSession {
appId: number | null
gameName: string
fpsAvg: number | null
date: string // ISO string
}
function createEmptySession(): SessionData {
return {
appId: null,
gameName: "",
startedAt: 0,
fpsAvg: null,
fpsLow: null,
fpsHigh: null,
fpsOnePercentLow: null,
tdpWatts: null,
hardwareSlug: "",
hardwareName: "",
osVersion: "",
protonVersion: "",
upscalerType: "none",
upscalerVersion: "",
frameGenMethod: "none",
settingsJson: "",
loadTimeSsd: "",
loadTimeSd: "",
launchOptions: "",
userNotes: "",
}
}
// ── Settings Hook ───────────────────────────────────────────────
export function useSettings() {
const [settings, setSettings] = useState<PluginSettings>(DEFAULT_SETTINGS)
const [loaded, setLoaded] = useState(false)
useEffect(() => {
async function load() {
try {
const raw = await getSettings()
setSettings({
apiKey: (raw.apiKey as string) || "",
exportPath: (raw.exportPath as string) || DEFAULT_SETTINGS.exportPath,
hardwareSlug: (raw.hardwareSlug as string) || null,
baseUrl: (raw.baseUrl as string) || DEFAULT_SETTINGS.baseUrl,
})
} catch (e) {
console.error("Failed to load settings:", e)
} finally {
setLoaded(true)
}
}
load()
}, [])
const updateSetting = useCallback(async (key: keyof PluginSettings, value: string | null) => {
setSettings((prev) => ({ ...prev, [key]: value }))
try {
await setSetting(key, value)
} catch (e) {
console.error(`Failed to save setting ${key}:`, e)
}
}, [])
return { settings, updateSetting, loaded }
}
// ── Session Hook ────────────────────────────────────────────────
export function useSession() {
const [recordingState, setRecordingState] = useState<RecordingState>("idle")
const [session, setSession] = useState<SessionData>(createEmptySession())
const [recentSessions, setRecentSessions] = useState<RecentSession[]>([])
const [error, setError] = useState<string>("")
const currentAppIdRef = useRef<number | null>(null)
const currentAppNameRef = useRef<string>("")
const startRecording = useCallback(() => {
setError("")
setSession({
...createEmptySession(),
appId: currentAppIdRef.current,
gameName: currentAppNameRef.current,
startedAt: Date.now(),
})
setRecordingState("recording")
}, [])
const stopRecording = useCallback(() => {
setRecordingState("stopped")
}, [])
const updateSession = useCallback((updates: Partial<SessionData>) => {
setSession((prev) => ({ ...prev, ...updates }))
}, [])
const addToRecent = useCallback((sess: SessionData) => {
const recent: RecentSession = {
appId: sess.appId,
gameName: sess.gameName,
fpsAvg: sess.fpsAvg,
date: new Date().toISOString(),
}
setRecentSessions((prev) => [recent, ...prev].slice(0, 5))
}, [])
const reset = useCallback(() => {
setRecordingState("idle")
setSession(createEmptySession())
setError("")
}, [])
// Called when a game starts (via SteamClient event)
const onGameStart = useCallback((appId: number, gameName: string) => {
currentAppIdRef.current = appId
currentAppNameRef.current = gameName
}, [])
// Called when a game stops (via SteamClient event)
const onGameStop = useCallback(() => {
currentAppIdRef.current = null
currentAppNameRef.current = ""
}, [])
return {
recordingState,
session,
recentSessions,
error,
setError,
startRecording,
stopRecording,
updateSession,
addToRecent,
reset,
onGameStart,
onGameStop,
}
}
// ── Payload Builder ─────────────────────────────────────────────
export function buildImportPayload(sess: SessionData): DeckyVaultImportV1 {
return {
version: 1,
steamAppId: sess.appId ?? 0,
hardwareSlug: sess.hardwareSlug,
fpsAvg: sess.fpsAvg ?? 0,
fpsLow: sess.fpsLow,
fpsOnePercentLow: sess.fpsOnePercentLow,
fpsHigh: sess.fpsHigh,
protonVersion: sess.protonVersion || null,
osVersion: sess.osVersion || null,
upscalerType: sess.upscalerType,
upscalerVersion: sess.upscalerVersion || null,
frameGenMethod: sess.frameGenMethod,
tdpWatts: sess.tdpWatts,
loadTimeSsd: sess.loadTimeSsd ? Number(sess.loadTimeSsd) : null,
loadTimeSd: sess.loadTimeSd ? Number(sess.loadTimeSd) : null,
launchOptions: sess.launchOptions || null,
settingsJson: sess.settingsJson ? tryParseJson(sess.settingsJson) : null,
userNotes: sess.userNotes || null,
}
}
function tryParseJson(text: string): unknown[] | null {
try {
const parsed = JSON.parse(text)
return Array.isArray(parsed) ? parsed : [parsed]
} catch {
return [{ text }]
}
}
export { DEFAULT_SETTINGS }
export type { HardwareSlug }
- Step 2: Commit
git add plugins/decky-vault/src/lib/store.ts
git commit -m "feat(plugin): add frontend state management (settings + session hooks)"
Task 14: Create the main panel component
Files:
-
Create:
plugins/decky-vault/src/components/main-panel.tsx -
Step 1: Write the main panel component
Create plugins/decky-vault/src/components/main-panel.tsx:
import { useEffect, useState } from "react"
import {
ButtonItem,
PanelSection,
PanelSectionRow,
staticClasses,
} from "@decky/ui"
import {
FaPlay,
FaStop,
FaClock,
} from "react-icons/fa"
import type { RecordingState, SessionData, RecentSession } from "../lib/store"
import SessionForm from "./session-form"
import type { PluginSettings } from "../lib/store"
interface MainPanelProps {
recordingState: RecordingState
session: SessionData
recentSessions: RecentSession[]
error: string
settings: PluginSettings
onStart: () => void
onStop: () => void
onUpdateSession: (updates: Partial<SessionData>) => void
onAddToRecent: (sess: SessionData) => void
onReset: () => void
setError: (msg: string) => void
}
export default function MainPanel({
recordingState,
session,
recentSessions,
error,
settings,
onStart,
onStop,
onUpdateSession,
onAddToRecent,
onReset,
setError,
}: MainPanelProps) {
const [elapsed, setElapsed] = useState(0)
// Timer for recording state
useEffect(() => {
if (recordingState !== "recording") {
setElapsed(0)
return
}
const interval = setInterval(() => {
setElapsed(Math.floor((Date.now() - session.startedAt) / 1000))
}, 1000)
return () => clearInterval(interval)
}, [recordingState, session.startedAt])
function formatTime(seconds: number): string {
const m = Math.floor(seconds / 60)
const s = seconds % 60
return `${m}:${s.toString().padStart(2, "0")}`
}
// ── Stopped state: show the session form ──────────────────────
if (recordingState === "stopped") {
return (
<SessionForm
session={session}
error={error}
settings={settings}
onUpdateSession={onUpdateSession}
onAddToRecent={onAddToRecent}
onReset={onReset}
setError={setError}
/>
)
}
// ── Idle or Recording state ───────────────────────────────────
return (
<PanelSection title="Recording">
{error && (
<PanelSectionRow>
<div className={staticClasses.Text} style={{ color: "#e74c3c", padding: "8px" }}>
{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>
<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>
</>
)}
{recordingState === "idle" && (
<PanelSectionRow>
<div className={staticClasses.Text} style={{ padding: "8px 0", fontSize: "12px", opacity: 0.7 }}>
Enable MangoHud for your game, then press Start Recording before launching.
Configure MangoHud in the Settings tab.
</div>
</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>
)
}
- Step 2: Commit
git add plugins/decky-vault/src/components/main-panel.tsx
git commit -m "feat(plugin): add main panel component (record/stop + recent sessions)"
Task 15: Create the session form component
Files:
-
Create:
plugins/decky-vault/src/components/session-form.tsx -
Step 1: Write the session form component
Create plugins/decky-vault/src/components/session-form.tsx:
import { useState } from "react"
import {
ButtonItem,
PanelSection,
PanelSectionRow,
Field,
DropdownItem,
staticClasses,
} from "@decky/ui"
import {
FaFileExport,
FaCloudUploadAlt,
FaCheck,
FaTimes,
} from "react-icons/fa"
import type { SessionData } from "../lib/store"
import { buildImportPayload } from "../lib/store"
import type { PluginSettings } from "../lib/store"
import { exportToFile, uploadToDeckyvault } from "../lib/api"
interface SessionFormProps {
session: SessionData
error: string
settings?: PluginSettings
onUpdateSession: (updates: Partial<SessionData>) => void
onAddToRecent: (sess: SessionData) => void
onReset: () => void
setError: (msg: string) => void
}
const UPSCALER_OPTIONS = [
{ label: "None", value: "none" },
{ label: "FSR", value: "fsr" },
{ label: "DLSS", value: "dlss" },
{ label: "XeSS", value: "xess" },
{ label: "LSFG", value: "lsfg" },
{ label: "Other", value: "other" },
]
const FRAME_GEN_OPTIONS = [
{ label: "None", value: "none" },
{ label: "FSR FG", value: "fsr_fg" },
{ label: "DLSS FG", value: "dlss_fg" },
{ label: "LSFG", value: "lsfg" },
{ label: "Other", value: "other" },
]
export default function SessionForm({
session,
error,
settings,
onUpdateSession,
onAddToRecent,
onReset,
setError,
}: SessionFormProps) {
const [exportStatus, setExportStatus] = useState<"idle" | "success" | "error">("idle")
const [uploadStatus, setUploadStatus] = useState<"idle" | "loading" | "success" | "error">("idle")
const [statusMessage, setStatusMessage] = useState("")
async function handleExport() {
if (!settings) return
setError("")
setExportStatus("idle")
const payload = buildImportPayload(session)
const gameSlug = session.gameName.toLowerCase().replace(/[^a-z0-9]/g, "-") || "unknown"
const date = new Date().toISOString().slice(0, 10)
const filename = `${gameSlug}-${date}.deckyvault.json`
const fullPath = `${settings.exportPath}/${filename}`
const result = await exportToFile(payload as unknown as Record<string, unknown>, fullPath)
if (result.success) {
setExportStatus("success")
setStatusMessage(`Saved to ${result.path}`)
onAddToRecent(session)
} else {
setExportStatus("error")
setStatusMessage(result.error || "Export failed")
}
}
async function handleUpload() {
if (!settings) return
if (!settings.apiKey) {
setError("No API key configured. Set one in the Settings tab.")
return
}
setError("")
setUploadStatus("loading")
setStatusMessage("")
const payload = buildImportPayload(session)
const result = await uploadToDeckyvault(
payload as unknown as Record<string, unknown>,
settings.apiKey,
settings.baseUrl,
)
if (result.success) {
setUploadStatus("success")
setStatusMessage(`Uploaded! Entry ID: ${result.data?.id}`)
onAddToRecent(session)
} else {
setUploadStatus("error")
setStatusMessage(result.error || "Upload failed")
if (result.status === 404) {
setStatusMessage("This game isn't in DeckyVault yet. Submit it on the website first, or export to file.")
}
}
}
return (
<PanelSection title="Session Results">
{/* ── Auto-captured summary ──────────────────────────────── */}
<PanelSection title="Captured Metrics">
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "13px", padding: "4px 0" }}>
<strong>Game:</strong> {session.gameName || "Unknown"}<br />
{session.appId && <><strong>App ID:</strong> {session.appId}<br /></>}
<strong>FPS:</strong> {session.fpsAvg ?? "—"} avg / {session.fpsLow ?? "—"} min / {session.fpsOnePercentLow ?? "—"} 1% low / {session.fpsHigh ?? "—"} max<br />
<strong>TDP:</strong> {session.tdpWatts ? `${session.tdpWatts}W` : "—"}<br />
<strong>Hardware:</strong> {session.hardwareName || session.hardwareSlug || "—"}<br />
<strong>OS:</strong> {session.osVersion || "—"}<br />
<strong>Proton:</strong> {session.protonVersion || "—"}
</div>
</PanelSectionRow>
</PanelSection>
{/* ── Manual inputs ──────────────────────────────────────── */}
<PanelSection title="Additional Details">
<PanelSectionRow>
<DropdownItem
label="Upscaler"
rgOptions={UPSCALER_OPTIONS}
selectedOption={session.upscalerType}
onChange={(opt) => onUpdateSession({ upscalerType: opt.data as string })}
/>
</PanelSectionRow>
<PanelSectionRow>
<Field label="Upscaler Version" bottomSeparator="none">
<input
type="text"
value={session.upscalerVersion}
onChange={(e) => onUpdateSession({ upscalerVersion: e.target.value })}
placeholder="e.g. 2.4"
style={{ width: "100%", padding: "4px 8px" }}
/>
</Field>
</PanelSectionRow>
<PanelSectionRow>
<DropdownItem
label="Frame Generation"
rgOptions={FRAME_GEN_OPTIONS}
selectedOption={session.frameGenMethod}
onChange={(opt) => onUpdateSession({ frameGenMethod: opt.data as string })}
/>
</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>
<Field label="Load Time - SSD (seconds)" bottomSeparator="none">
<input
type="number"
value={session.loadTimeSsd}
onChange={(e) => onUpdateSession({ loadTimeSsd: e.target.value })}
placeholder="e.g. 12.5"
style={{ width: "100%", padding: "4px 8px" }}
/>
</Field>
</PanelSectionRow>
<PanelSectionRow>
<Field label="Load Time - SD Card (seconds)" bottomSeparator="none">
<input
type="number"
value={session.loadTimeSd}
onChange={(e) => onUpdateSession({ loadTimeSd: e.target.value })}
placeholder="e.g. 25.0"
style={{ width: "100%", padding: "4px 8px" }}
/>
</Field>
</PanelSectionRow>
<PanelSectionRow>
<Field label="Launch Options" bottomSeparator="none">
<input
type="text"
value={session.launchOptions}
onChange={(e) => onUpdateSession({ launchOptions: e.target.value })}
placeholder="e.g. mangohud %command%"
style={{ width: "100%", padding: "4px 8px" }}
/>
</Field>
</PanelSectionRow>
<PanelSectionRow>
<Field label="Notes" bottomSeparator="none">
<textarea
value={session.userNotes}
onChange={(e) => onUpdateSession({ userNotes: e.target.value })}
placeholder="Any observations about performance..."
rows={3}
maxLength={5000}
style={{ width: "100%", padding: "4px 8px", resize: "vertical" }}
/>
</Field>
</PanelSectionRow>
</PanelSection>
{/* ── Error display ──────────────────────────────────────── */}
{error && (
<PanelSectionRow>
<div className={staticClasses.Text} style={{ color: "#e74c3c", padding: "8px" }}>
{error}
</div>
</PanelSectionRow>
)}
{/* ── Status messages ────────────────────────────────────── */}
{statusMessage && (
<PanelSectionRow>
<div
className={staticClasses.Text}
style={{
padding: "8px",
color: exportStatus === "success" || uploadStatus === "success" ? "#2ecc71" : "#e74c3c",
}}
>
{exportStatus === "success" && <FaCheck />}{" "}
{exportStatus === "error" && <FaTimes />}{" "}
{uploadStatus === "success" && <FaCheck />}{" "}
{uploadStatus === "error" && <FaTimes />}{" "}
{statusMessage}
</div>
</PanelSectionRow>
)}
{/* ── Action buttons ─────────────────────────────────────── */}
<PanelSectionRow>
<ButtonItem layout="below" onClick={handleExport} disabled={false}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<FaFileExport />
Export to File
</div>
</ButtonItem>
</PanelSectionRow>
<PanelSectionRow>
<ButtonItem
layout="below"
onClick={handleUpload}
disabled={uploadStatus === "loading"}
>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<FaCloudUploadAlt />
{uploadStatus === "loading" ? "Uploading..." : "Upload to DeckyVault"}
</div>
</ButtonItem>
</PanelSectionRow>
<PanelSectionRow>
<ButtonItem layout="below" onClick={onReset} disabled={false}>
New Recording
</ButtonItem>
</PanelSectionRow>
</PanelSection>
)
}
- Step 2: Commit
git add plugins/decky-vault/src/components/session-form.tsx
git commit -m "feat(plugin): add session form component (manual inputs + export/upload)"
Task 16: Create the settings panel component
Files:
-
Create:
plugins/decky-vault/src/components/settings-panel.tsx -
Step 1: Write the settings panel component
Create plugins/decky-vault/src/components/settings-panel.tsx:
import { useState } from "react"
import {
ButtonItem,
PanelSection,
PanelSectionRow,
Field,
DropdownItem,
staticClasses,
} from "@decky/ui"
import {
FaCheck,
FaTimes,
FaDownload,
FaCog,
} from "react-icons/fa"
import type { PluginSettings } from "../lib/store"
import { KNOWN_HARDWARE_SLUGS } from "@deckyvault/shared"
import { testApiKey, checkMangohud, writeMangohudConfig, getMangohudConfig } from "../lib/api"
interface SettingsPanelProps {
settings: PluginSettings
onUpdateSetting: <K extends keyof PluginSettings>(
key: K,
value: string | null
) => void
}
const HARDWARE_OPTIONS = [
{ label: "Auto-detect", value: "" },
...KNOWN_HARDWARE_SLUGS.map((slug) => ({ label: slug, value: slug })),
]
export default function SettingsPanel({
settings,
onUpdateSetting,
}: SettingsPanelProps) {
const [keyTestStatus, setKeyTestStatus] = useState<"idle" | "testing" | "valid" | "invalid">("idle")
const [keyTestMessage, setKeyTestMessage] = useState("")
const [mangohudStatus, setMangohudStatus] = useState<{
checked: boolean
installed: boolean
path: string
version: string
}>({ checked: false, installed: false, path: "", version: "" })
const [showMangohudGuide, setShowMangohudGuide] = useState(false)
const [configWritten, setConfigWritten] = useState(false)
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 handleCheckMangohud() {
const result = await checkMangohud()
setMangohudStatus({
checked: true,
installed: result.installed,
path: result.path,
version: result.version,
})
}
async function handleWriteConfig() {
const result = await writeMangohudConfig()
setConfigWritten(result.success)
}
return (
<>
{/* ── API Key ─────────────────────────────────────────────── */}
<PanelSection title="DeckyVault Account">
<PanelSectionRow>
<Field label="API Key" bottomSeparator="none">
<input
type="password"
value={settings.apiKey}
onChange={(e) => onUpdateSetting("apiKey", e.target.value)}
placeholder="dv_..."
style={{ width: "100%", padding: "4px 8px" }}
/>
</Field>
</PanelSectionRow>
<PanelSectionRow>
<ButtonItem layout="below" onClick={handleTestKey} disabled={keyTestStatus === "testing"}>
{keyTestStatus === "testing" ? "Testing..." : "Test 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>
)}
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "11px", opacity: 0.6, padding: "4px 0" }}>
Get your API key from DeckyVault → Profile → Settings → API Keys
</div>
</PanelSectionRow>
</PanelSection>
{/* ── Export Path ─────────────────────────────────────────── */}
<PanelSection title="Export">
<PanelSectionRow>
<Field label="Export Path" bottomSeparator="none">
<input
type="text"
value={settings.exportPath}
onChange={(e) => onUpdateSetting("exportPath", e.target.value)}
placeholder="/home/deck/Downloads"
style={{ width: "100%", padding: "4px 8px" }}
/>
</Field>
</PanelSectionRow>
<PanelSectionRow>
<Field label="Server URL" bottomSeparator="none">
<input
type="text"
value={settings.baseUrl}
onChange={(e) => onUpdateSetting("baseUrl", e.target.value)}
placeholder="https://deckyvault.xyz"
style={{ width: "100%", padding: "4px 8px" }}
/>
</Field>
</PanelSectionRow>
<PanelSectionRow>
<DropdownItem
label="Default Hardware"
rgOptions={HARDWARE_OPTIONS}
selectedOption={settings.hardwareSlug || ""}
onChange={(opt) => onUpdateSetting("hardwareSlug", opt.data as string || null)}
/>
</PanelSectionRow>
</PanelSection>
{/* ── MangoHud Setup ──────────────────────────────────────── */}
<PanelSection title="MangoHud Setup">
<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: "8px 0" }}>
{mangohudStatus.installed ? (
<>
<FaCheck style={{ color: "#2ecc71" }} /> MangoHud installed
<br />
<span style={{ opacity: 0.7 }}>
Path: {mangohudStatus.path}
<br />
Version: {mangohudStatus.version}
</span>
</>
) : (
<>
<FaTimes style={{ color: "#e74c3c" }} /> MangoHud not found
<br />
<span style={{ opacity: 0.7 }}>See installation guide below</span>
</>
)}
</div>
</PanelSectionRow>
)}
<PanelSectionRow>
<ButtonItem layout="below" onClick={handleWriteConfig}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<FaDownload />
Write MangoHud Config
</div>
</ButtonItem>
</PanelSectionRow>
{configWritten && (
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", color: "#2ecc71", padding: "4px 0" }}>
<FaCheck /> Config written to ~/.config/MangoHud/MangoHud.conf
</div>
</PanelSectionRow>
)}
<PanelSectionRow>
<ButtonItem layout="below" onClick={() => setShowMangohudGuide(!showMangohudGuide)}>
{showMangohudGuide ? "Hide Guide" : "Show Installation Guide"}
</ButtonItem>
</PanelSectionRow>
{showMangohudGuide && (
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", padding: "8px", lineHeight: "1.6" }}>
<strong>Steam Deck (SteamOS):</strong>
<br />
MangoHud is pre-installed. Enable it per-game by adding
<code style={{ display: "block", margin: "4px 0", padding: "4px", background: "rgba(255,255,255,0.1)" }}>
mangohud %command%
</code>
to the game's Steam launch options (right-click game → Properties → Launch Options).
<br /><br />
<strong>Other Linux handhelds</strong> (ROG Ally, Legion Go):
<br />
Install via package manager:
<code style={{ display: "block", margin: "4px 0", padding: "4px", background: "rgba(255,255,255,0.1)" }}>
sudo apt install mangohud
</code>
or Flatpak:
<code style={{ display: "block", margin: "4px 0", padding: "4px", background: "rgba(255,255,255,0.1)" }}>
flatpak install flathub org.freedesktop.Platform.VulkanLayer.MangoHud
</code>
<br /><br />
<strong>Manual build:</strong>
<br />
See{" "}
<a href="https://github.com/flightlessmango/MangoHud" style={{ color: "#66c0f4" }}>
github.com/flightlessmango/MangoHud
</a>
<br /><br />
<strong>Troubleshooting:</strong>
<br />
• Log file empty? Check MangoHud is enabled for the game and the config was written.
<br />
• Wrong path? Ensure the plugin can write to /tmp/.
<br />
• Not attaching? Try adding <code>mangohud %command%</code> to Steam launch options explicitly.
</div>
</PanelSectionRow>
)}
</PanelSection>
</>
)
}
- Step 2: Commit
git add plugins/decky-vault/src/components/settings-panel.tsx
git commit -m "feat(plugin): add settings panel (API key, export path, MangoHud setup)"
Task 17: Wire everything together in index.tsx
Files:
-
Modify:
plugins/decky-vault/src/index.tsx -
Step 1: Rewrite index.tsx
Replace entire contents with:
import { useState, useEffect, useRef } from "react"
import {
PanelSection,
PanelSectionRow,
Navigation,
staticClasses,
} from "@decky/ui"
import {
definePlugin,
toaster,
} from "@decky/api"
import { FaVaultDollar } from "react-icons/fa"
import MainPanel from "./components/main-panel"
import SettingsPanel from "./components/settings-panel"
import { useSettings, useSession } from "./lib/store"
import {
readAndParseMangohudLog,
clearMangohudLog,
getHardwareInfo,
getOsVersion,
getProtonVersion,
getLaunchOptions,
} from "./lib/api"
function Content() {
const { settings, updateSetting, loaded } = useSettings()
const {
recordingState,
session,
recentSessions,
error,
setError,
startRecording,
stopRecording,
updateSession,
addToRecent,
reset,
onGameStart,
onGameStop,
} = useSession()
const [activeTab, setActiveTab] = useState<"main" | "settings">("main")
const gameStartedUnregRef = useRef<{ unregister: () => void } | null>(null)
const gameStoppedUnregRef = useRef<{ unregister: () => void } | null>(null)
// ── Register SteamClient game events ──────────────────────────
useEffect(() => {
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 ────────────────────────────────────
async function handleStart() {
// Clear any previous log file
await clearMangohudLog()
startRecording()
}
// ── Handle stop recording: parse log + read system info ────────
async function handleStop() {
stopRecording()
// Parse the MangoHud log
const logResult = await readAndParseMangohudLog()
if (logResult.error) {
setError(logResult.error)
// Still transition to stopped state so user can see the error + manual fields
return
}
// Read system info in parallel
const [hwInfo, osVersion] = await Promise.all([
getHardwareInfo(),
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
}
// 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) {
return (
<PanelSection title="DeckyVault">
<PanelSectionRow>
<div className={staticClasses.Text} style={{ padding: "16px", textAlign: "center" }}>
Loading...
</div>
</PanelSectionRow>
</PanelSection>
)
}
return (
<>
{/* ── Tab navigation ──────────────────────────────────────── */}
<PanelSectionRow>
<div style={{ display: "flex", gap: "0", marginBottom: "8px" }}>
<button
onClick={() => setActiveTab("main")}
style={{
flex: 1,
padding: "8px",
background: activeTab === "main" ? "rgba(255,255,255,0.15)" : "transparent",
border: "none",
color: activeTab === "main" ? "#fff" : "rgba(255,255,255,0.5)",
cursor: "pointer",
borderRadius: "4px 0 0 4px",
}}
>
Record
</button>
<button
onClick={() => setActiveTab("settings")}
style={{
flex: 1,
padding: "8px",
background: activeTab === "settings" ? "rgba(255,255,255,0.15)" : "transparent",
border: "none",
color: activeTab === "settings" ? "#fff" : "rgba(255,255,255,0.5)",
cursor: "pointer",
borderRadius: "0 4px 4px 0",
}}
>
Settings
</button>
</div>
</PanelSectionRow>
{activeTab === "main" ? (
<MainPanel
recordingState={recordingState}
session={session}
recentSessions={recentSessions}
error={error}
settings={settings}
onStart={handleStart}
onStop={handleStop}
onUpdateSession={updateSession}
onAddToRecent={addToRecent}
onReset={reset}
setError={setError}
/>
) : (
<SettingsPanel
settings={settings}
onUpdateSetting={updateSetting}
/>
)}
</>
)
}
export default definePlugin(() => {
return {
name: "DeckyVault",
titleView: <div className={staticClasses.Title}>DeckyVault</div>,
content: <Content />,
icon: <FaVaultDollar />,
alwaysRender: false,
onDismount() {
console.log("[DeckyVault] Plugin unloading")
},
}
})
- Step 2: Commit
git add plugins/decky-vault/src/index.tsx
git commit -m "feat(plugin): wire together entry point with definePlugin and SteamClient events"
Task 18: Build the plugin and verify output
Files:
-
Modify:
plugins/decky-vault/.gitignore(add dist/) -
Step 1: Add dist/ to .gitignore
Check if plugins/decky-vault/.gitignore exists, or add to root .gitignore. Add dist/ for the plugin:
Run: cd /Users/adrianbonpin/Documents/Code/personal/deckyvault && grep -q "plugins/decky-vault/dist" .gitignore || echo "plugins/decky-vault/dist/" >> .gitignore
- Step 2: Run the build
Run: cd plugins/decky-vault && bun run build 2>&1
Expected: Build completes, dist/index.js is created. May have TypeScript warnings but should produce output.
- Step 3: Verify dist/index.js exists
Run: ls -la plugins/decky-vault/dist/index.js
Expected: File exists, non-zero size
- Step 4: Run Python tests to verify backend logic
Run: cd plugins/decky-vault && python -m pytest tests/ -v
Expected: All tests pass (settings + MangoHud parser tests)
- Step 5: Commit
git add .gitignore
git commit -m "chore(plugin): build plugin and verify dist output"
Task 19: Final verification and cleanup
- Step 1: Verify the complete file structure
Run:
cd plugins/decky-vault && find . -not -path './node_modules/*' -not -path './dist/*' -not -path './.git/*' -type f | sort
Expected: All source files present:
-
main.py -
package.json -
plugin.json -
rollup.config.js -
tsconfig.json -
src/index.tsx -
src/types.d.ts -
src/lib/api.ts -
src/lib/store.ts -
src/components/main-panel.tsx -
src/components/session-form.tsx -
src/components/settings-panel.tsx -
tests/test_mangohud_parser.py -
tests/test_settings.py -
tests/fixtures/sample_mangohud.log -
Step 2: Push all commits to dev
Run: cd /Users/adrianbonpin/Documents/Code/personal/deckyvault && git push origin dev
Expected: All commits pushed
- Step 3: Manual testing note (cannot be automated)
Document that the following needs manual testing on a Steam Deck:
- Install the plugin via Decky Loader (copy
plugins/decky-vault/to the Deck) - Configure MangoHud (Settings tab → Write Config)
- Start a game with
mangohud %command%launch option - Press Record, play, press Stop
- Verify FPS stats appear in the form
- Test Export to File (check the JSON file in /home/deck/Downloads)
- Test Upload to DeckyVault (with a valid API key)
Self-Review Notes
Spec coverage:
- ✅ Architecture (frontend/backend split) — Tasks 1-17
- ✅ Recording flow (start/stop, MangoHud log parse) — Tasks 6, 7, 17
- ✅ Auto-captured metrics (FPS, TDP, hardware, OS, Proton) — Tasks 7, 8, 17
- ✅ Manual inputs (upscaler, frame gen, settings, load times, notes) — Task 15
- ✅ Export to file — Tasks 9, 15
- ✅ Upload to DeckyVault — Tasks 10, 15
- ✅ Main panel UI (record/stop, timer, recent recordings) — Task 14
- ✅ Settings panel (API key, export path, hardware, MangoHud setup) — Task 16
- ✅ MangoHud setup guide with installation instructions — Task 16
- ✅ Error handling (no log, empty log, no game, invalid key, 404) — Tasks 7, 10, 15, 17
- ✅ Edge case: log parsing failure → manual FPS entry (form still shows) — Task 17
Architecture adjustment from spec: The spec described pure TypeScript modules (mangohud.ts, system-info.ts, etc.). The research revealed Decky plugins need a Python backend for filesystem/shell/network access. The plan splits logic: Python backend handles I/O (file reads, shell commands, HTTP uploads), TypeScript frontend handles UI + state + SteamClient events. The lib/api.ts module provides typed RPC wrappers, replacing the originally planned mangohud.ts/system-info.ts/api-client.ts/exporter.ts modules.
Testing scope: Python backend logic (log parsing, settings) is unit-tested with pytest. Frontend React components are not unit-tested (standard for Decky plugins — they run in a specialized CEF context). End-to-end testing is manual on a Steam Deck.