Files
deckyvault/docs/superpowers/plans/2026-06-28-decky-vault-plugin.md
T

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.json to 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 DeckyVaultImportV1 payload 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:

  1. Install the plugin via Decky Loader (copy plugins/decky-vault/ to the Deck)
  2. Configure MangoHud (Settings tab → Write Config)
  3. Start a game with mangohud %command% launch option
  4. Press Record, play, press Stop
  5. Verify FPS stats appear in the form
  6. Test Export to File (check the JSON file in /home/deck/Downloads)
  7. 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.