diff --git a/docs/superpowers/plans/2026-06-28-decky-vault-plugin.md b/docs/superpowers/plans/2026-06-28-decky-vault-plugin.md new file mode 100644 index 0000000..56ebd75 --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-decky-vault-plugin.md @@ -0,0 +1,2528 @@ +# 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: + +```json +{ + "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** + +```bash +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: + +```js +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** + +```bash +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: + +```json +{ + "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** + +```bash +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: + +```json +{ + "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** + +```bash +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`: + +```python +"""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`: + +```python +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** + +```bash +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): + +```python + 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** + +```bash +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`: + +```python +"""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: + +```python +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: + +```python + 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** + +```bash +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): + +```python + 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// + 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** + +```bash +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): + +```python + 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** + +```bash +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): + +```python + 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** + +```bash +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: + +```typescript +// 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 + } + UI: { + GetUIMode: () => Promise + } + } +} + +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** + +```bash +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`: + +```typescript +import { callable } from "@decky/api" + +// ── Settings ──────────────────────────────────────────────────── +export const getSettings = callable<[], Record>("get_settings") +export const setSetting = callable<[key: string, value: unknown], Record>("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, exportPath: string], { + success: boolean + path: string + error?: string +}>("export_to_file") + +// ── Upload ────────────────────────────────────────────────────── +export const uploadToDeckyvault = callable<[ + data: Record, + 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** + +```bash +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`: + +```typescript +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(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("idle") + const [session, setSession] = useState(createEmptySession()) + const [recentSessions, setRecentSessions] = useState([]) + const [error, setError] = useState("") + const currentAppIdRef = useRef(null) + const currentAppNameRef = useRef("") + + 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) => { + 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** + +```bash +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`: + +```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) => 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 ( + + ) + } + + // ── Idle or Recording state ─────────────────────────────────── + return ( + + {error && ( + +
+ {error} +
+
+ )} + + + {recordingState === "idle" ? ( + +
+ + Start Recording +
+
+ ) : ( + +
+ + Stop Recording +
+
+ )} +
+ + {recordingState === "recording" && ( + <> + +
+
+ + {formatTime(elapsed)} +
+
+ {session.gameName + ? `Recording: ${session.gameName}` + : "No game detected — recording anyway"} +
+
+
+ + )} + + {recordingState === "idle" && ( + +
+ Enable MangoHud for your game, then press Start Recording before launching. + Configure MangoHud in the Settings tab. +
+
+ )} + + {recentSessions.length > 0 && recordingState === "idle" && ( + + {recentSessions.map((rs, i) => ( + +
+ {rs.gameName || "Unknown game"} +
+ + {rs.fpsAvg ? `${rs.fpsAvg} FPS avg` : "No data"} ·{" "} + {new Date(rs.date).toLocaleDateString()} + +
+
+ ))} +
+ )} +
+ ) +} +``` + +- [ ] **Step 2: Commit** + +```bash +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`: + +```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) => 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, 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, + 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 ( + + {/* ── Auto-captured summary ──────────────────────────────── */} + + +
+ Game: {session.gameName || "Unknown"}
+ {session.appId && <>App ID: {session.appId}
} + FPS: {session.fpsAvg ?? "—"} avg / {session.fpsLow ?? "—"} min / {session.fpsOnePercentLow ?? "—"} 1% low / {session.fpsHigh ?? "—"} max
+ TDP: {session.tdpWatts ? `${session.tdpWatts}W` : "—"}
+ Hardware: {session.hardwareName || session.hardwareSlug || "—"}
+ OS: {session.osVersion || "—"}
+ Proton: {session.protonVersion || "—"} +
+
+
+ + {/* ── Manual inputs ──────────────────────────────────────── */} + + + onUpdateSession({ upscalerType: opt.data as string })} + /> + + + + + onUpdateSession({ upscalerVersion: e.target.value })} + placeholder="e.g. 2.4" + style={{ width: "100%", padding: "4px 8px" }} + /> + + + + + onUpdateSession({ frameGenMethod: opt.data as string })} + /> + + + + +