Files
deckyvault/docs/superpowers/plans/2026-07-12-plugin-bugs-and-features.md
T

66 KiB
Raw Blame History

Plugin Bugs + Library Injection Features — 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: Fix three plugin bugs (recording-start crash, FPS validation/resubmit error, screenshot discovery) and add a DeckyVault section injected into Steam's library/app-details page (unified Features 4 & 5: game-entry status, device-scoped est FPS with a device switcher, and top recent/pinned/most-positive entries).

Architecture: Plugin-side Python (main.py) and TypeScript/React (src/) changes for the bugs and the library patch. The library patch uses Decky's supported routerHook.addPatch('/library/app/:appid') + createReactTreePatcher (the HLTB-for-Deck pattern, all helpers already exported by our installed @decky/ui@4.11.6 / @decky/api@1.1.3). A new read-only public web API (apps/web/lib/api/plugin-public.ts) serves the injected panel. Bug 2 also touches the import/submit API routes (caps + validation ordering).

Tech Stack: Python 3 (urllib stdlib, pytest), TypeScript/React (Decky @decky/api + @decky/ui, rollup), Elysia API, Drizzle ORM, Vitest, Bun.

Spec: docs/superpowers/specs/2026-07-12-plugin-bugs-and-features-design.md


File Structure

Plugin (plugins/decky-vault/):

  • main.py — modify clear_mangohud_log, list_screenshots, read_screenshot; add delete_log_file, plugin_get.
  • src/index.tsx — modify handleStart; wire patch registration + onDismount cleanup.
  • src/lib/store.ts — modify buildImportPayload; add sanitizeFps.
  • src/lib/api.ts — add pluginGet RPC wrapper.
  • src/lib/plugin-api.ts (new) — fetchPluginGame typed helper + per-appid LRU cache.
  • src/patches/LibraryApp.tsx (new) — registerLibraryAppPatch() returns an unpatch handle.
  • src/components/LibraryAppPanel.tsx (new) — the injected React section (status / est FPS / device switcher / top entries / empty states).
  • tests/test_clear_log.py (new) — safe-clear + delete_log_file tests.
  • tests/test_screenshots.py (new) — multi-path discovery + fallback tests.
  • tests/fixtures/ — screenshot fixture tree (created by tests via tempfile).

Web API (apps/web/):

  • lib/api/performance-import.ts — reorder validation (FPS before game lookup) + raise caps to 1000.
  • lib/api/performance-submit.ts — raise caps to 1000 (parity).
  • lib/api/plugin-public.ts (new) — GET /api/plugin/game/:steamAppId (+ optional /devices).
  • lib/api/app.ts — mount pluginPublicRoutes in the public (read) group.
  • lib/api/__tests__/performance-import-validation.test.ts (new) — unit test extracted FPS validation.
  • lib/api/__tests__/plugin-public.test.ts (new) — contract + device scope + 404.

No DB schema changes.


Task ordering

  1. Bug 2 — server caps + validation ordering (API-first; no plugin dependency).
  2. Bug 2 — plugin sanitizeFps + null-fpsAvg upload guard.
  3. Bug 1 — safe clear_mangohud_log + delete_log_file + handleStart cleanup.
  4. Bug 3 — list_screenshots multi-path discovery + read_screenshot fallback.
  5. Feature — new read API plugin-public.ts + mount + tests.
  6. Feature — plugin plugin_get RPC + plugin-api.ts fetch/cache.
  7. Feature — LibraryApp.tsx patch + LibraryAppPanel.tsx + wire in index.tsx.
  8. Final build + lint + test sweep.

Each task ends with a commit. Run plugin Python tests with cd plugins/decky-vault && python -m pytest tests/ -v. Run web tests from repo root with bun run test (Vitest).


Task 1: Server FPS caps + validation ordering (Bug 2, server side)

Files:

  • Modify: apps/web/lib/api/performance-import.ts (validation block ~lines 88140 and the game-lookup block ~lines 5880)
  • Modify: apps/web/lib/api/performance-submit.ts (FPS validation block ~lines 145165)
  • Test: apps/web/lib/api/__tests__/performance-import-validation.test.ts (new)

The cleanest, TDD-testable approach is to extract the FPS validation into a pure function and unit-test it, then reorder the route to call it before the game lookup. We do this in performance-import.ts.

  • Step 1: Write the failing test for an extracted validateFps helper

Create apps/web/lib/api/__tests__/performance-import-validation.test.ts:

import { describe, it, expect } from "vitest"
import { validateFps } from "@/lib/api/performance-import"

describe("validateFps", () => {
  it("rejects missing/null fpsAvg", () => {
    const r = validateFps({ fpsAvg: null as unknown as number })
    expect(r.ok).toBe(false)
    if (!r.ok) expect(r.error).toMatch(/fpsAvg/i)
  })

  it("rejects NaN fpsAvg", () => {
    const r = validateFps({ fpsAvg: NaN })
    expect(r.ok).toBe(false)
  })

  it("rejects fpsAvg below 1", () => {
    const r = validateFps({ fpsAvg: 0 })
    expect(r.ok).toBe(false)
  })

  it("accepts fpsAvg up to 1000", () => {
    const r = validateFps({ fpsAvg: 1000, fpsHigh: 999 })
    expect(r.ok).toBe(true)
  })

  it("rejects fpsAvg above 1000", () => {
    const r = validateFps({ fpsAvg: 1001 })
    expect(r.ok).toBe(false)
  })

  it("accepts fpsHigh of 750 (legit >500)", () => {
    const r = validateFps({ fpsAvg: 120, fpsHigh: 750 })
    expect(r.ok).toBe(true)
  })

  it("accepts optional nulls for fpsLow/onePct/high", () => {
    const r = validateFps({ fpsAvg: 60, fpsLow: null, fpsOnePercentLow: null, fpsHigh: null })
    expect(r.ok).toBe(true)
  })

  it("rejects negative fpsLow", () => {
    const r = validateFps({ fpsAvg: 60, fpsLow: -1 })
    expect(r.ok).toBe(false)
  })

  it("rejects fpsHigh above 1000", () => {
    const r = validateFps({ fpsAvg: 60, fpsHigh: 1200 })
    expect(r.ok).toBe(false)
  })
})
  • Step 2: Run the test to verify it fails

Run: bun run test -- performance-import-validation Expected: FAIL — validateFps is not a function (module exports nothing yet).

  • Step 3: Implement validateFps and reorder the route

In apps/web/lib/api/performance-import.ts:

Add at the top (after the const declarations, near the other helpers), a pure exported function:

const FPS_MIN_AVG = 1
const FPS_MIN_OTHER = 0
const FPS_MAX = 1000

export type FpsInput = {
  fpsAvg: number
  fpsLow?: number | null
  fpsOnePercentLow?: number | null
  fpsHigh?: number | null
}

export function validateFps(input: FpsInput): { ok: true; values: { fpsAvg: number; fpsLow: number | null; fpsOnePercentLow: number | null; fpsHigh: number | null } } | { ok: false; error: string } {
  const fpsAvg = Number(input.fpsAvg)
  if (input.fpsAvg == null || isNaN(fpsAvg) || fpsAvg < FPS_MIN_AVG || fpsAvg > FPS_MAX) {
    return { ok: false, error: `fpsAvg must be between ${FPS_MIN_AVG} and ${FPS_MAX}` }
  }
  const fpsLow = input.fpsLow != null ? Number(input.fpsLow) : null
  if (fpsLow !== null && (isNaN(fpsLow) || fpsLow < FPS_MIN_OTHER || fpsLow > FPS_MAX)) {
    return { ok: false, error: `fpsLow must be between ${FPS_MIN_OTHER} and ${FPS_MAX}` }
  }
  const fpsOnePercentLow = input.fpsOnePercentLow != null ? Number(input.fpsOnePercentLow) : null
  if (fpsOnePercentLow !== null && (isNaN(fpsOnePercentLow) || fpsOnePercentLow < FPS_MIN_OTHER || fpsOnePercentLow > FPS_MAX)) {
    return { ok: false, error: `fpsOnePercentLow must be between ${FPS_MIN_OTHER} and ${FPS_MAX}` }
  }
  const fpsHigh = input.fpsHigh != null ? Number(input.fpsHigh) : null
  if (fpsHigh !== null && (isNaN(fpsHigh) || fpsHigh < FPS_MIN_OTHER || fpsHigh > FPS_MAX)) {
    return { ok: false, error: `fpsHigh must be between ${FPS_MIN_OTHER} and ${FPS_MAX}` }
  }
  return { ok: true, values: { fpsAvg, fpsLow, fpsOnePercentLow, fpsHigh } }
}

Then reorder the route handler: move the FPS validation above the game lookup. Replace the existing FPS-validation block (the // ── Validate FPS fields ─── section, currently after hardware validation) by a call to validateFps, and move that call to just after the body.version !== 1 check and before the steamAppId resolution / game lookup. Concretely, restructure the handler so the order is:

  1. Auth guard
  2. body.version !== 1 → 400
  3. FPS validation via validateFps({ fpsAvg: body.fpsAvg, fpsLow: body.fpsLow, fpsOnePercentLow: body.fpsOnePercentLow, fpsHigh: body.fpsHigh }) → 400 on !ok (use result.error); on ok, destructure values into local fpsAvg/fpsLow/fpsOnePercentLow/fpsHigh for use later when inserting the entry.
  4. steamAppId required → 400
  5. Game lookup → 404 if missing
  6. Version resolution
  7. Hardware validation → 400
  8. Enum + other numeric validation (unchanged)
  9. Insert entry (use the validated values.*)

Make sure the later insert uses the validated locals (rename so there's no duplicate const fpsAvg). Remove the old inline FPS validation block entirely.

  • Step 4: Run the test to verify it passes

Run: bun run test -- performance-import-validation Expected: PASS (all 9 cases).

  • Step 5: Update performance-submit.ts caps for parity

In apps/web/lib/api/performance-submit.ts, change every FPS cap from 500 to 1000 in its validation block (~lines 145165). The messages should read between 1 and 1000 (fpsAvg) and between 0 and 1000 (others). Do not reorder this route (it has no game-not-found-before-FPS issue); only raise the caps.

  • Step 6: Lint + typecheck the web app

Run: cd apps/web && bun run lint && bunx tsc --noEmit -p tsconfig.json (from repo root: cd apps/web && bunx tsc --noEmit) Expected: no new errors. (If tsc flags unrelated pre-existing errors, ensure none are introduced by these files.)

  • Step 7: Commit
git add apps/web/lib/api/performance-import.ts apps/web/lib/api/performance-submit.ts apps/web/lib/api/__tests__/performance-import-validation.test.ts
git commit -m "fix(api): raise FPS caps to 1000 and validate FPS before game lookup"

Task 2: Plugin FPS sanitize + null-fpsAvg upload guard (Bug 2, plugin side)

Files:

  • Modify: plugins/decky-vault/src/lib/store.ts (buildImportPayload + new sanitizeFps)

  • Modify: plugins/decky-vault/src/components/session-form.tsx (handleUpload guard)

  • Step 1: Add sanitizeFps and harden buildImportPayload

In plugins/decky-vault/src/lib/store.ts, add a helper above buildImportPayload:

const FPS_MAX = 1000

/** Clamp/cap an FPS value to [0, 1000]; return null for null/undefined/NaN. */
export function sanitizeFps(value: number | null | undefined): number | null {
  if (value == null) return null
  const n = Number(value)
  if (isNaN(n)) return null
  if (n < 0) return 0
  if (n > FPS_MAX) return FPS_MAX
  return n
}

Then replace the FPS lines in buildImportPayload. The current first lines are:

  return {
    version: 1,
    steamAppId: sess.appId ?? 0,
    hardwareSlug: sess.hardwareSlug,
    fpsAvg: sess.fpsAvg ?? 0,
    fpsLow: sess.fpsLow,
    fpsOnePercentLow: sess.fpsOnePercentLow,
    fpsHigh: sess.fpsHigh,

Change to:

  return {
    version: 1,
    steamAppId: sess.appId ?? 0,
    hardwareSlug: sess.hardwareSlug,
    fpsAvg: sanitizeFps(sess.fpsAvg) ?? 0,
    fpsLow: sanitizeFps(sess.fpsLow),
    fpsOnePercentLow: sanitizeFps(sess.fpsOnePercentLow),
    fpsHigh: sanitizeFps(sess.fpsHigh),

Note: fpsAvg keeps a ?? 0 fallback so the payload type (fpsAvg: number) stays valid, but the caller now guards against 0/null before uploading (Step 2). fpsAvg clamped to [1,1000] when present.

Actually, to keep the server's fpsAvg >= 1 check meaningful, clamp the lower bound of fpsAvg to 1 when present — adjust sanitizeFps usage for fpsAvg:

    fpsAvg: sess.fpsAvg == null || sess.fpsAvg <= 0 ? 0 : sanitizeFps(sess.fpsAvg)!,

(When fpsAvg is null/≤0 we leave 0 so the caller's guard trips; otherwise it's a clamped positive number.)

  • Step 2: Add the null/no-data upload guard in session-form.tsx

In plugins/decky-vault/src/components/session-form.tsx, at the top of handleUpload (right after the if (!settings) return and the apiKey check, before setUploadStatus("loading")), add:

    if (session.fpsAvg == null || session.fpsAvg <= 0) {
      setError("No FPS data captured. Re-record the session, or use Export to File only.")
      return
    }

This prevents submitting a no-data recording as fpsAvg=0.

  • Step 3: Build the plugin to verify it compiles

Run: cd plugins/decky-vault && bun run build Expected: build succeeds, dist/index.js written.

  • Step 4: Commit
git add plugins/decky-vault/src/lib/store.ts plugins/decky-vault/src/components/session-form.tsx
git commit -m "fix(plugin): block no-FPS uploads and clamp FPS before submit"

Task 3: Safe recording start (Bug 1)

Files:

  • Modify: plugins/decky-vault/main.py (clear_mangohud_log ~line 369; add delete_log_file)

  • Modify: plugins/decky-vault/src/index.tsx (handleStart + store lastLogPath)

  • Modify: plugins/decky-vault/src/lib/store.ts (SessionData.lastLogPath + useSession plumbing)

  • Test: plugins/decky-vault/tests/test_clear_log.py (new)

  • Step 1: Write failing tests for the safe-clear logic + delete_log_file

Create plugins/decky-vault/tests/test_clear_log.py:

"""Tests for safe MangoHud log clearing (Bug 1 fix)."""
import os
import tempfile
import time
import pytest


def _touch(path, mtime_age=10):
    """Create a file at path, optionally backdated mtime by mtime_age seconds."""
    os.makedirs(os.path.dirname(path), exist_ok=True)
    with open(path, "w") as f:
        f.write("data")
    if mtime_age > 0:
        t = time.time() - mtime_age
        os.utime(path, (t, t))


def _safe_clear_mangohud_logs(tmpdir, now=None, recent_window_s=3):
    """Mirror of Plugin.clear_mangohud_log safe logic, operating on tmpdir."""
    import glob
    if now is None:
        now = time.time()
    deleted, skipped = [], []
    patterns = [
        os.path.join(tmpdir, "*MangoHud*"),
        os.path.join(tmpdir, "*MangoHud*.csv"),
    ]
    for pat in patterns:
        for f in glob.glob(pat):
            if not os.path.isfile(f):
                continue
            try:
                if now - os.path.getmtime(f) < recent_window_s:
                    skipped.append({"name": os.path.basename(f), "reason": "active"})
                    continue
                os.remove(f)
                deleted.append({"name": os.path.basename(f)})
            except (IOError, PermissionError):
                skipped.append({"name": os.path.basename(f), "reason": "perm"})
    return {"success": True, "deleted": deleted, "skipped": skipped}


def test_only_mangohud_files_removed():
    with tempfile.TemporaryDirectory() as tmp:
        _touch(os.path.join(tmp, "MangoHud-1.csv"), mtime_age=10)
        _touch(os.path.join(tmp, "system.log"), mtime_age=10)       # MUST be untouched
        _touch(os.path.join(tmp, "other.csv"), mtime_age=10)        # MUST be untouched
        res = _safe_clear_mangohud_logs(tmp)
        assert res["success"] is True
        names = [d["name"] for d in res["deleted"]]
        assert "MangoHud-1.csv" in names
        assert "system.log" not in names and "other.csv" not in names
        assert os.path.exists(os.path.join(tmp, "system.log"))
        assert os.path.exists(os.path.join(tmp, "other.csv"))


def test_active_recent_file_skipped():
    with tempfile.TemporaryDirectory() as tmp:
        _touch(os.path.join(tmp, "MangoHud-active.csv"), mtime_age=0)
        res = _safe_clear_mangohud_logs(tmp)
        assert res["deleted"] == []
        assert any(s["name"] == "MangoHud-active.csv" for s in res["skipped"])
        assert os.path.exists(os.path.join(tmp, "MangoHud-active.csv"))


def _validate_log_path(path, tmpdir):
    """Mirror of Plugin.delete_log_file path validation."""
    if not path:
        return False
    abs_path = os.path.abspath(path)
    if not abs_path.startswith(os.path.abspath(tmpdir) + os.sep):
        return False
    base = os.path.basename(abs_path)
    if "MangoHud" not in base:
        return False
    return True


def test_delete_log_file_rejects_outside_tmp():
    with tempfile.TemporaryDirectory() as tmp:
        assert _validate_log_path("/etc/passwd", tmp) is False
        assert _validate_log_path(os.path.expanduser("~/x.log"), tmp) is False


def test_delete_log_file_rejects_non_mangohud():
    with tempfile.TemporaryDirectory() as tmp:
        assert _validate_log_path(os.path.join(tmp, "system.log"), tmp) is False
        assert _validate_log_path(os.path.join(tmp, "MangoHud-1.csv"), tmp) is True
  • Step 2: Run tests to verify they fail/pass appropriately

Run: cd plugins/decky-vault && python -m pytest tests/test_clear_log.py -v Expected: PASS (these tests mirror the intended logic via local helpers; they document the contract). If they pass already, that's fine — they're the spec for the implementation you'll add to main.py next.

  • Step 3: Implement the safe clear_mangohud_log + delete_log_file in main.py

Replace the existing clear_mangohud_log method (around line 369) with:

    async def clear_mangohud_log(self) -> dict:
        """RPC: Delete stale MangoHud log files in /tmp/ so the next recording
        starts fresh. Only touches files whose name contains 'MangoHud'; never
        bare /tmp/*.log or /tmp/*.csv. Skips files modified in the last 3s
        (an active session may still have them open)."""
        import glob
        import time
        RECENT_WINDOW_S = 3
        now = time.time()
        deleted, skipped = [], []
        try:
            for pattern in ["/tmp/*MangoHud*"]:
                for f in glob.glob(pattern):
                    if not os.path.isfile(f):
                        continue
                    try:
                        if now - os.path.getmtime(f) < RECENT_WINDOW_S:
                            skipped.append({"name": os.path.basename(f), "reason": "active"})
                            continue
                        os.remove(f)
                        deleted.append({"name": os.path.basename(f)})
                    except (IOError, PermissionError):
                        skipped.append({"name": os.path.basename(f), "reason": "perm"})
            return {"success": True, "deleted": deleted, "skipped": skipped}
        except Exception as e:
            return {"success": False, "error": str(e), "deleted": deleted, "skipped": skipped}

    async def delete_log_file(self, path: str) -> dict:
        """RPC: Delete a single, specific MangoHud log file. The path must be
        under /tmp and its basename must contain 'MangoHud'. Defence in depth
        so a bad/stale path can never delete unrelated files."""
        try:
            if not path:
                return {"success": False, "error": "No path provided"}
            abs_path = os.path.abspath(path)
            if not abs_path.startswith("/tmp/"):
                return {"success": False, "error": "Refusing to delete file outside /tmp"}
            if "MangoHud" not in os.path.basename(abs_path):
                return {"success": False, "error": "Refusing to delete non-MangoHud file"}
            if not os.path.exists(abs_path):
                return {"success": True, "deleted": False, "note": "already gone"}
            os.remove(abs_path)
            return {"success": True, "deleted": True, "path": abs_path}
        except Exception as e:
            return {"success": False, "error": str(e)}
  • Step 4: Wire lastLogPath through the session store

In plugins/decky-vault/src/lib/store.ts:

Add to SessionData interface (near the other auto-captured fields):

  lastLogPath: string | null

In createEmptySession() add:

    lastLogPath: null,

In useSession, add an updater so handleStop can record the log path. Add to the returned object a new function:

  const setLastLogPath = useCallback((p: string | null) => {
    setSession((prev) => ({ ...prev, lastLogPath: p }))
  }, [])

and include setLastLogPath in the returned object.

  • Step 5: Update api.ts with the new RPC wrapper

In plugins/decky-vault/src/lib/api.ts, add:

export const deleteLogFile = callable<[path: string], {
  success: boolean
  deleted?: boolean
  error?: string
}>("delete_log_file")
  • Step 6: Update handleStart and handleStop in index.tsx

In plugins/decky-vault/src/index.tsx:

Add deleteLogFile to the import from "./lib/api". Add setLastLogPath to the destructured props from useSession() (also pass it through to MainPanel/SessionForm only if needed — it's used in Content's stop handler).

Replace handleStart:

  async function handleStart() {
    // Clear the previous session's specific log if we know it; else safe-clear.
    const prev = session.lastLogPath
    if (prev) {
      await deleteLogFile(prev)
    } else {
      await clearMangohudLog()
    }
    // Fire-and-forget: try to start MangoHud logging (retries until game launches)
    startMangohudLogging()
    startRecording()
  }

Key change: writeMangohudConfig() is removed from the start path (config is a one-time setup step; rewriting it mid-game crashes the wrapper).

In handleStop, after readAndParseMangohudLog, capture the log path. The current code calls readAndParseMangohudLog() with no arg. Change it to first find the log path, then parse it:

      // Find the most recent MangoHud log, parse it, remember its path
      const logPath = await findMangohudLog()
      const logResult = await readAndParseMangohudLog(logPath ?? undefined)
      if (logResult.error) {
        setError(logResult.error)
        return
      }
      setLastLogPath(logPath ?? null)

Add findMangohudLog to the import from "./lib/api" and to api.ts:

export const findMangohudLog = callable<[], { path: string | null }>("find_mangohud_log")

And add a thin find_mangohud_log RPC in main.py that reuses the existing _find_mangohud_log:

    async def find_mangohud_log(self) -> dict:
        """RPC: Return the path of the most recent MangoHud log in /tmp/, or null."""
        path = await self._find_mangohud_log()
        return {"path": path}

Update the rest of handleStop to keep using logResult as before (the updateSession({...}) block is unchanged).

  • Step 7: Build + run plugin Python tests

Run: cd plugins/decky-vault && python -m pytest tests/ -v && bun run build Expected: all Python tests PASS; plugin build succeeds.

  • Step 8: Commit
git add plugins/decky-vault/main.py plugins/decky-vault/src/index.tsx plugins/decky-vault/src/lib/store.ts plugins/decky-vault/src/lib/api.ts plugins/decky-vault/tests/test_clear_log.py
git commit -m "fix(plugin): safe recording start — no mid-game config rewrite, scoped log clear"

Task 4: Screenshot discovery (Bug 3)

Files:

  • Modify: plugins/decky-vault/main.py (list_screenshots ~line 608; read_screenshot ~line 649)

  • Test: plugins/decky-vault/tests/test_screenshots.py (new)

  • Step 1: Write failing tests for multi-path discovery + fallback

Create plugins/decky-vault/tests/test_screenshots.py:

"""Tests for screenshot discovery across Steam Game Mode + Desktop paths (Bug 3)."""
import os
import tempfile
import time
import pytest


def _touch(path, mtime_age=10, content=b"\xff\xd8\xff\xe0"):
    os.makedirs(os.path.dirname(path), exist_ok=True)
    with open(path, "wb") as f:
        f.write(content)
    t = time.time() - mtime_age
    os.utime(path, (t, t))


def _discover(home, app_id=None):
    """Mirror of Plugin.list_screenshot discovery against a fake home dir."""
    import glob
    base = os.path.join(home, "Pictures", "Screenshots")
    userdata = os.path.join(home, ".local", "share", "Steam", "userdata")
    patterns = [
        os.path.join(base, "*.jpg"),
        os.path.join(base, "*.png"),
        os.path.join(base, "Steam Client", "*.jpg"),
        os.path.join(base, "Steam Client", "*.png"),
        os.path.join(userdata, "*", "760", "remote", "*", "screenshots", "*.jpg"),
        os.path.join(userdata, "*", "760", "remote", "*", "screenshots", "*.png"),
    ]
    seen, files = set(), []
    for pat in patterns:
        for f in glob.glob(pat):
            if not os.path.isfile(f) or f in seen:
                continue
            if os.path.basename(f) == "most_recent.jpg":
                continue
            seen.add(f)
            # parse appid from userdata path if present
            parts = f.split(os.sep)
            f_app_id = None
            if "760" in parts:
                idx = parts.index("760")
                if idx >= 2:
                    try:
                        f_app_id = int(parts[idx - 2])
                    except ValueError:
                        pass
            try:
                files.append({"path": f, "name": os.path.basename(f),
                              "mtime": os.path.getmtime(f), "size": os.path.getsize(f),
                              "appId": f_app_id})
            except OSError:
                continue
    files.sort(key=lambda x: x["mtime"], reverse=True)
    if app_id is not None:
        # keep all Desktop exports + this app's userdata shots
        files = [x for x in files if x["appId"] is None or x["appId"] == app_id]
    return files


def test_discovers_all_three_locations():
    with tempfile.TemporaryDirectory() as home:
        _touch(os.path.join(home, "Pictures", "Screenshots", "desktop.jpg"), mtime_age=30)
        _touch(os.path.join(home, "Pictures", "Screenshots", "Steam Client", "sc.jpg"), mtime_age=20)
        _touch(os.path.join(home, ".local", "share", "Steam", "userdata", "111", "760",
                            "remote", "2531310", "screenshots", "game.jpg"), mtime_age=10)
        found = _discover(home)
        names = [f["name"] for f in found]
        assert set(names) == {"desktop.jpg", "sc.jpg", "game.jpg"}
        # sorted newest first
        assert found[0]["name"] == "game.jpg"


def test_app_id_filter_keeps_desktop_plus_app():
    with tempfile.TemporaryDirectory() as home:
        _touch(os.path.join(home, "Pictures", "Screenshots", "desktop.jpg"), mtime_age=30)
        _touch(os.path.join(home, ".local", "share", "Steam", "userdata", "111", "760",
                            "remote", "2531310", "screenshots", "want.jpg"), mtime_age=10)
        _touch(os.path.join(home, ".local", "share", "Steam", "userdata", "111", "760",
                            "remote", "9999", "screenshots", "other.jpg"), mtime_age=5)
        found = _discover(home, app_id=2531310)
        names = [f["name"] for f in found]
        assert "want.jpg" in names
        assert "desktop.jpg" in names
        assert "other.jpg" not in names


def test_most_recent_duplicate_skipped():
    with tempfile.TemporaryDirectory() as home:
        _touch(os.path.join(home, "Pictures", "Screenshots", "most_recent.jpg"), mtime_age=1)
        _touch(os.path.join(home, "Pictures", "Screenshots", "2026-01-01.jpg"), mtime_age=2)
        found = _discover(home)
        names = [f["name"] for f in found]
        assert "most_recent.jpg" not in names
        assert "2026-01-01.jpg" in names
  • Step 2: Run tests

Run: cd plugins/decky-vault && python -m pytest tests/test_screenshots.py -v Expected: PASS (documents contract).

  • Step 3: Implement the new list_screenshots in main.py

Replace the existing list_screenshots (~line 608) with:

    async def list_screenshots(self, limit: int = 50, app_id: int | None = None) -> dict:
        """RPC: List recent Steam screenshots from both Game Mode (userdata/760/remote)
        and Desktop Mode (~/Pictures/Screenshots). Returns newest first.
        When app_id is given, keeps all Desktop exports + that app's userdata shots."""
        import glob
        try:
            home = os.path.expanduser("~")
            base = os.path.join(home, "Pictures", "Screenshots")
            userdata = os.path.join(home, ".local", "share", "Steam", "userdata")
            patterns = [
                os.path.join(base, "*.jpg"),
                os.path.join(base, "*.png"),
                os.path.join(base, "Steam Client", "*.jpg"),
                os.path.join(base, "Steam Client", "*.png"),
                os.path.join(userdata, "*", "760", "remote", "*", "screenshots", "*.jpg"),
                os.path.join(userdata, "*", "760", "remote", "*", "screenshots", "*.png"),
            ]
            seen, files = set(), []
            for pat in patterns:
                for f in glob.glob(pat):
                    if not os.path.isfile(f) or f in seen:
                        continue
                    if os.path.basename(f) == "most_recent.jpg":
                        continue
                    seen.add(f)
                    # parse appId from the userdata path (..../<appid>/screenshots/...)
                    f_app_id = None
                    parts = f.split(os.sep)
                    if "760" in parts:
                        idx = parts.index("760")
                        if idx >= 2:
                            try:
                                f_app_id = int(parts[idx - 2])
                            except ValueError:
                                pass
                    try:
                        files.append({
                            "path": f, "name": os.path.basename(f),
                            "mtime": os.path.getmtime(f), "size": os.path.getsize(f),
                            "appId": f_app_id,
                        })
                    except OSError:
                        continue
            if app_id is not None:
                files = [x for x in files if x["appId"] is None or x["appId"] == app_id]
            files.sort(key=lambda x: x["mtime"], reverse=True)
            return {"screenshots": files[:limit]}
        except Exception as e:
            return {"screenshots": [], "error": str(e)}
  • Step 4: Update read_screenshot fallback for no-Pillow

Replace the except ImportError block inside read_screenshot (~line 649) with a size-guarded fallback. The current fallback returns the full raw bytes; change it so CEF never gets a giant buffer:

            except ImportError:
                # No Pillow — only return raw if small enough for CEF; else skip preview.
                ext = os.path.splitext(path)[1].lower()
                mime = "image/png" if ext == ".png" else ("image/webp" if ext == ".webp" else "image/jpeg")
                if len(raw) > 1_000_000:
                    return {"dataUrl": "", "error": "Preview unavailable (too large, no Pillow)"}
                b64 = base64.b64encode(raw).decode("ascii")
                return {"dataUrl": f"data:{mime};base64,{b64}"}
  • Step 5: Update the listScreenshots TS type in api.ts

In plugins/decky-vault/src/lib/api.ts, update the type and add the appId field:

export const listScreenshots = callable<[limit?: number, appId?: number], {
  screenshots: Array<{ path: string; name: string; mtime: number; size: number; appId: number | null }>
  error?: string
}>("list_screenshots")
  • Step 6: Build + run tests

Run: cd plugins/decky-vault && python -m pytest tests/ -v && bun run build Expected: all tests PASS; build succeeds.

  • Step 7: Commit
git add plugins/decky-vault/main.py plugins/decky-vault/src/lib/api.ts plugins/decky-vault/tests/test_screenshots.py
git commit -m "fix(plugin): discover Steam Game Mode screenshots + safe preview fallback"

Task 5: New read API plugin-public.ts + mount (Feature, server side)

Files:

  • Create: apps/web/lib/api/plugin-public.ts

  • Modify: apps/web/lib/api/app.ts (import + mount in the public/read group)

  • Test: apps/web/lib/api/__tests__/plugin-public.test.ts (new)

  • Step 1: Write failing test (contract + device scope + 404)

Create apps/web/lib/api/__tests__/plugin-public.test.ts. Follow the existing heavy-mock style used in auto-pin.test.ts:

import { describe, it, expect, vi, beforeEach } from "vitest"

vi.mock("drizzle-orm", () => ({
  eq: vi.fn((col, val) => ({ col, val })),
  and: vi.fn((...args) => ({ and: args })),
  desc: vi.fn((col) => ({ desc: col })),
  avg: vi.fn((col) => ({ avg: col })),
  min: vi.fn((col) => ({ min: col })),
  max: vi.fn((col) => ({ max: col })),
  count: vi.fn((col) => ({ count: col })),
  sql: vi.fn((strings, ...vals) => ({ strings, vals })),
}))
vi.mock("drizzle-orm/pg-core", () => ({
  pgTable: vi.fn((n, c, i) => ({ name: n, columns: c, indexes: i })),
  pgEnum: vi.fn((n, v) => ({ name: n, values: v })),
  text: vi.fn((n) => n), integer: vi.fn((n) => n), real: vi.fn((n) => n),
  boolean: vi.fn((n) => n), timestamp: vi.fn((n) => n), jsonb: vi.fn((n) => n),
  index: vi.fn((n) => ({ on: vi.fn() })),
}))
vi.mock("@/lib/db/schema", () => ({
  games: { id: "id", steamAppId: "steam_app_id" },
  gameVersions: { id: "id", gameId: "game_id", isLatest: "is_latest", createdAt: "created_at" },
  performanceEntries: { id: "id", versionId: "version_id", hardwareSlug: "hardware_slug",
    fpsAvg: "fps_avg", fpsLow: "fps_low", fpsOnePercentLow: "fps_one_percent_low",
    fpsHigh: "fps_high", isRemoved: "is_removed", isPinned: "is_pinned", upvotes: "upvotes",
    upscalerType: "upscaler_type", frameGenMethod: "frame_gen_method", protonVersion: "proton_version",
    osVersion: "os_version", tdpWatts: "tdp_watts", settingsJson: "settings_json",
    createdAt: "created_at", userId: "user_id" },
  hardware: { slug: "slug", name: "name" },
  user: { id: "id", name: "name", image: "image" },
}))
vi.mock("@/lib/db/index", () => ({
  db: {
    select: vi.fn(() => ({ from: vi.fn(() => ({ where: vi.fn(() => ({ limit: vi.fn(() => ({ orderBy: vi.fn(() => []) })) })) })) })),
  },
}))

import { buildPluginGameResponse } from "@/lib/api/plugin-public"

describe("buildPluginGameResponse — shape contract", () => {
  beforeEach(() => vi.clearAllMocks())

  it("returns { game: null, error } shape when game is missing", async () => {
    const r = await buildPluginGameResponse({ game: null })
    expect(r.game).toBeNull()
    expect(typeof r.error).toBe("string")
    expect(r.estFps).toBeNull()
    expect(r.topEntries).toEqual([])
    expect(r.recentEntries).toEqual([])
  })

  it("returns estFps null when there are no entries", async () => {
    const r = await buildPluginGameResponse({ game: { id: "g1", steamAppId: 123, title: "X", slug: "x" }, entries: [], recent: [] })
    expect(r.game).not.toBeNull()
    expect(r.estFps).toBeNull()
    expect(r.topEntries).toEqual([])
    expect(r.recentEntries).toEqual([])
  })

  it("computes estFps from entries and trims entry fields", async () => {
    const entries = [
      { id: "e1", hardwareSlug: "steamdeck-oled", fpsAvg: 60, fpsLow: 40, fpsOnePercentLow: 45, fpsHigh: 90,
        upscalerType: "none", frameGenMethod: "none", protonVersion: "9", osVersion: "SteamOS 3", tdpWatts: 12,
        settingsJson: null, upvotes: 5, isPinned: true, createdAt: new Date("2026-01-01"),
        userName: "u", userImage: null },
      { id: "e2", hardwareSlug: "steamdeck-oled", fpsAvg: 80, fpsLow: 55, fpsOnePercentLow: 60, fpsHigh: 120,
        upscalerType: "fsr", frameGenMethod: "none", protonVersion: "9", osVersion: "SteamOS 3", tdpWatts: 15,
        settingsJson: null, upvotes: 2, isPinned: false, createdAt: new Date("2026-02-01"),
        userName: "u2", userImage: null },
    ]
    const r = await buildPluginGameResponse({ game: { id: "g1", steamAppId: 123, title: "X", slug: "x" }, entries, recent: entries })
    expect(r.estFps).not.toBeNull()
    expect(r.estFps!.avg).toBeCloseTo(70, 1)
    expect(r.estFps!.count).toBe(2)
    expect(r.estFps!.high).toBe(120)
    expect(r.estFps!.low).toBe(40)
    expect(r.topEntries.length).toBe(2)
    expect(r.topEntries[0].id).toBe("e1") // pinned first
  })
})
  • Step 2: Run test to verify it fails

Run: bun run test -- plugin-public Expected: FAIL — buildPluginGameResponse is not a function.

  • Step 3: Implement plugin-public.ts

Create apps/web/lib/api/plugin-public.ts:

import { Elysia, t } from "elysia"
import { db } from "@/lib/db/index"
import {
  games,
  gameVersions,
  performanceEntries,
  hardware,
  user,
} from "@/lib/db/schema"
import { eq, and, desc, avg, min, max, count, sql } from "drizzle-orm"

// ── Pure helpers (unit-tested directly) ──────────────────────────

export type PluginGameRow = {
  id: string
  steamAppId: number | null
  title: string
  slug: string | null
}
export type PluginEntryRow = {
  id: string
  hardwareSlug: string
  fpsAvg: number
  fpsLow: number | null
  fpsOnePercentLow: number | null
  fpsHigh: number | null
  upscalerType: string
  frameGenMethod: string
  protonVersion: string | null
  osVersion: string | null
  tdpWatts: number | null
  settingsJson: unknown
  upvotes: number
  isPinned: boolean
  createdAt: Date
  userName: string | null
  userImage: string | null
}

export type PluginGameResponse = {
  game: { id: string; steamAppId: number | null; title: string; slug: string | null } | null
  estFps: { avg: number; low: number | null; onePct: number | null; high: number | null; count: number } | null
  topEntries: ReturnType<typeof trimEntry>[]
  recentEntries: ReturnType<typeof trimEntry>[]
  error?: string
}

function trimEntry(e: PluginEntryRow) {
  return {
    id: e.id,
    hardwareSlug: e.hardwareSlug,
    fpsAvg: e.fpsAvg,
    fpsLow: e.fpsLow,
    fpsOnePercentLow: e.fpsOnePercentLow,
    fpsHigh: e.fpsHigh,
    upscalerType: e.upscalerType,
    frameGenMethod: e.frameGenMethod,
    protonVersion: e.protonVersion,
    osVersion: e.osVersion,
    tdpWatts: e.tdpWatts,
    settingsJson: e.settingsJson,
    upvotes: e.upvotes,
    isPinned: e.isPinned,
    createdAt: e.createdAt.toISOString(),
    userName: e.userName,
    userImage: e.userImage,
  }
}

export async function buildPluginGameResponse(args: {
  game: PluginGameRow | null
  entries?: PluginEntryRow[]
  recent?: PluginEntryRow[]
}): Promise<PluginGameResponse> {
  if (!args.game) {
    return { game: null, estFps: null, topEntries: [], recentEntries: [], error: "Game not in DeckyVault" }
  }
  const entries = args.entries ?? []
  const recent = args.recent ?? []
  if (entries.length === 0) {
    return { game: { ...args.game }, estFps: null, topEntries: [], recentEntries: [] }
  }
  const fpsAvgVals = entries.map((e) => e.fpsAvg)
  const estFps = {
    avg: Math.round((fpsAvgVals.reduce((a, b) => a + b, 0) / fpsAvgVals.length) * 10) / 10,
    low: entries.reduce<number | null>((m, e) => (m == null ? e.fpsLow : Math.min(m, e.fpsLow ?? m)), null),
    onePct: entries.reduce<number | null>((m, e) => (m == null ? e.fpsOnePercentLow : Math.min(m, e.fpsOnePercentLow ?? m)), null),
    high: entries.reduce<number | null>((m, e) => (m == null ? e.fpsHigh : Math.max(m, e.fpsHigh ?? m)), null),
    count: entries.length,
  }
  // topEntries already come ordered pinned->upvotes from the query; keep order, trim.
  const topEntries = entries.map(trimEntry)
  const recentEntries = recent.map(trimEntry)
  return { game: { ...args.game }, estFps, topEntries, recentEntries }
}

// ── Route ────────────────────────────────────────────────────────

export const pluginPublicRoutes = new Elysia({
  prefix: "/plugin",
  detail: { tags: ["Plugin"] },
})
  .get(
    "/game/:steamAppId",
    async ({ params, query, set }) => {
      const steamAppId = Number(params.steamAppId)
      if (!Number.isInteger(steamAppId) || steamAppId <= 0) {
        set.status = 400
        return { error: "Invalid steamAppId" }
      }

      const [game] = await db
        .select({
          id: games.id,
          steamAppId: games.steamAppId,
          title: games.title,
          slug: games.slug,
        })
        .from(games)
        .where(eq(games.steamAppId, steamAppId))
        .limit(1)

      if (!game) {
        set.status = 404
        return await buildPluginGameResponse({ game: null })
      }

      // Resolve latest version
      const [latestVersion] = await db
        .select({ id: gameVersions.id })
        .from(gameVersions)
        .where(and(eq(gameVersions.gameId, game.id), eq(gameVersions.isLatest, true)))
        .limit(1)

      let versionId = latestVersion?.id
      if (!versionId) {
        const [anyVersion] = await db
          .select({ id: gameVersions.id })
          .from(gameVersions)
          .where(eq(gameVersions.gameId, game.id))
          .orderBy(gameVersions.createdAt)
          .limit(1)
        versionId = anyVersion?.id
      }
      if (!versionId) {
        return await buildPluginGameResponse({ game, entries: [], recent: [] })
      }

      const hardwareFilter = query.hardware ? eq(performanceEntries.hardwareSlug, query.hardware) : undefined
      const baseWhere = and(
        eq(performanceEntries.versionId, versionId),
        eq(performanceEntries.isRemoved, false),
        ...(hardwareFilter ? [hardwareFilter] : []),
      )

      // Top entries: pinned -> upvotes
      const topRows = await db
        .select({
          id: performanceEntries.id,
          hardwareSlug: performanceEntries.hardwareSlug,
          fpsAvg: performanceEntries.fpsAvg,
          fpsLow: performanceEntries.fpsLow,
          fpsOnePercentLow: performanceEntries.fpsOnePercentLow,
          fpsHigh: performanceEntries.fpsHigh,
          upscalerType: performanceEntries.upscalerType,
          frameGenMethod: performanceEntries.frameGenMethod,
          protonVersion: performanceEntries.protonVersion,
          osVersion: performanceEntries.osVersion,
          tdpWatts: performanceEntries.tdpWatts,
          settingsJson: performanceEntries.settingsJson,
          upvotes: performanceEntries.upvotes,
          isPinned: performanceEntries.isPinned,
          createdAt: performanceEntries.createdAt,
          userName: user.name,
          userImage: user.image,
        })
        .from(performanceEntries)
        .innerJoin(user, eq(performanceEntries.userId, user.id))
        .where(baseWhere)
        .orderBy(desc(performanceEntries.isPinned), desc(performanceEntries.upvotes))
        .limit(query.limit ?? 3)

      // Recent entries
      const recentRows = await db
        .select({
          id: performanceEntries.id,
          hardwareSlug: performanceEntries.hardwareSlug,
          fpsAvg: performanceEntries.fpsAvg,
          fpsLow: performanceEntries.fpsLow,
          fpsOnePercentLow: performanceEntries.fpsOnePercentLow,
          fpsHigh: performanceEntries.fpsHigh,
          upscalerType: performanceEntries.upscalerType,
          frameGenMethod: performanceEntries.frameGenMethod,
          protonVersion: performanceEntries.protonVersion,
          osVersion: performanceEntries.osVersion,
          tdpWatts: performanceEntries.tdpWatts,
          settingsJson: performanceEntries.settingsJson,
          upvotes: performanceEntries.upvotes,
          isPinned: performanceEntries.isPinned,
          createdAt: performanceEntries.createdAt,
          userName: user.name,
          userImage: user.image,
        })
        .from(performanceEntries)
        .innerJoin(user, eq(performanceEntries.userId, user.id))
        .where(baseWhere)
        .orderBy(desc(performanceEntries.createdAt))
        .limit(query.limit ?? 3)

      set.headers["Cache-Control"] = "public, max-age=60"
      return await buildPluginGameResponse({
        game,
        entries: topRows as unknown as PluginEntryRow[],
        recent: recentRows as unknown as PluginEntryRow[],
      })
    },
    {
      params: t.Object({ steamAppId: t.Numeric() }),
      query: t.Object({
        hardware: t.Optional(t.String()),
        limit: t.Optional(t.Numeric()),
      }),
      detail: {
        description:
          "Public read endpoint for the DeckyVault Decky plugin's library app-details panel. " +
          "Returns game status, device-scoped estimated FPS, and top/recent entries.",
      },
    },
  )
  .get(
    "/game/:steamAppId/devices",
    async ({ params, set }) => {
      const steamAppId = Number(params.steamAppId)
      const [game] = await db
        .select({ id: games.id })
        .from(games)
        .where(eq(games.steamAppId, steamAppId))
        .limit(1)
      if (!game) {
        set.status = 404
        return { error: "Game not in DeckyVault", devices: [] }
      }
      const rows = await db
        .select({
          slug: performanceEntries.hardwareSlug,
          count: sql<number>`count(*)::int`,
          name: hardware.name,
        })
        .from(performanceEntries)
        .innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug))
        .innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
        .where(and(eq(gameVersions.gameId, game.id), eq(performanceEntries.isRemoved, false)))
        .groupBy(performanceEntries.hardwareSlug, hardware.name)
        .orderBy(desc(sql`count(*)`))
      set.headers["Cache-Control"] = "public, max-age=60"
      return { devices: rows }
    },
    {
      params: t.Object({ steamAppId: t.Numeric() }),
    },
  )
  • Step 4: Mount the route in app.ts

In apps/web/lib/api/app.ts:

Add to the imports near the other route imports:

import { pluginPublicRoutes } from "@/lib/api/plugin-public"

Add .use(pluginPublicRoutes) inside the read group (the .group("", (app) => app .use(gamesPerformanceRoutes) ... .use(gamesLookupRoutes) .use(pluginPairingRoutes) ) block), e.g. right after .use(pluginPairingRoutes).

  • Step 5: Run the test to verify it passes

Run: bun run test -- plugin-public Expected: PASS (3 cases).

  • Step 6: Lint + typecheck

Run: cd apps/web && bun run lint && bunx tsc --noEmit Expected: no new errors.

  • Step 7: Commit
git add apps/web/lib/api/plugin-public.ts apps/web/lib/api/app.ts apps/web/lib/api/__tests__/plugin-public.test.ts
git commit -m "feat(api): public plugin game-lookup endpoint for library panel"

Task 6: Plugin plugin_get RPC + plugin-api.ts fetch/cache (Feature, plugin data layer)

Files:

  • Modify: plugins/decky-vault/main.py (add plugin_get)

  • Modify: plugins/decky-vault/src/lib/api.ts (add pluginGet)

  • Create: plugins/decky-vault/src/lib/plugin-api.ts

  • Step 1: Add plugin_get to main.py

Add near the other HTTP RPCs (e.g. after test_api_key):

    async def plugin_get(self, path: str, base_url: str = "https://deckyvault.xyz") -> dict:
        """RPC: Public read proxy for the DeckyVault API (used by the library panel).
        Performs a GET to {base_url}/api{path} and returns parsed JSON or {error, status}.
        Keeps network in the Python backend to avoid CEF CORS issues."""
        import urllib.request
        import urllib.error
        try:
            # Prevent SSRF: only allow http(s) and only to the configured base_url host.
            if not path.startswith("/"):
                path = "/" + path
            url = f"{base_url}/api{path}"
            req = urllib.request.Request(
                url,
                headers={
                    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; rv:136.0) Gecko/20100101 Firefox/136.0",
                    "Accept": "application/json",
                },
                method="GET",
            )
            context = _get_ssl_context()
            with urllib.request.urlopen(req, timeout=10, context=context) as response:
                body = response.read().decode("utf-8")
                try:
                    return json.loads(body)
                except json.JSONDecodeError:
                    return {"error": "Invalid JSON", "status": response.status}
        except urllib.error.HTTPError as e:
            try:
                err = json.loads(e.read().decode("utf-8"))
                return {**err, "status": e.code}
            except Exception:
                return {"error": f"Server returned status {e.code}", "status": e.code}
        except urllib.error.URLError as e:
            return {"error": f"Network error: {str(e.reason)}", "status": 0}
        except Exception as e:
            return {"error": str(e), "status": 0}
  • Step 2: Add the pluginGet wrapper in api.ts

In plugins/decky-vault/src/lib/api.ts:

export const pluginGet = callable<[path: string, baseUrl?: string], Record<string, unknown> & { status?: number; error?: string }>("plugin_get")
  • Step 3: Create plugin-api.ts with the typed fetch + LRU cache

Create plugins/decky-vault/src/lib/plugin-api.ts:

import { pluginGet } from "./api"

export interface PluginEntry {
  id: string
  hardwareSlug: string
  fpsAvg: number
  fpsLow: number | null
  fpsOnePercentLow: number | null
  fpsHigh: number | null
  upscalerType: string
  frameGenMethod: string
  protonVersion: string | null
  osVersion: string | null
  tdpWatts: number | null
  settingsJson: unknown
  upvotes: number
  isPinned: boolean
  createdAt: string
  userName: string | null
  userImage: string | null
}

export interface PluginGameResponse {
  game: { id: string; steamAppId: number | null; title: string; slug: string | null } | null
  estFps: { avg: number; low: number | null; onePct: number | null; high: number | null; count: number } | null
  topEntries: PluginEntry[]
  recentEntries: PluginEntry[]
  error?: string
}

export interface PluginDeviceRow {
  slug: string
  name: string
  count: number
}

// Tiny per-appId cache (1h TTL)
interface CacheEntry { value: PluginGameResponse; expires: number }
const cache = new Map<string, CacheEntry>()
const TTL_MS = 60 * 60 * 1000
const settingsRef: { baseUrl: string } = { baseUrl: "https://deckyvault.xyz" }

export function setPluginApiBaseUrl(url: string) {
  settingsRef.baseUrl = url || "https://deckyvault.xyz"
}

export async function fetchPluginGame(
  steamAppId: number,
  hardware: string | null,
  limit: number,
): Promise<PluginGameResponse> {
  const key = `${steamAppId}|${hardware ?? "all"}|${limit}`
  const hit = cache.get(key)
  if (hit && hit.expires > Date.now()) return hit.value

  const path = `/plugin/game/${steamAppId}?limit=${limit}${hardware ? `&hardware=${encodeURIComponent(hardware)}` : ""}`
  const raw = await pluginGet(path, settingsRef.baseUrl)
  const value = raw as unknown as PluginGameResponse
  cache.set(key, { value, expires: Date.now() + TTL_MS })
  return value
}

export async function fetchPluginDevices(steamAppId: number): Promise<PluginDeviceRow[]> {
  const raw = await pluginGet(`/plugin/game/${steamAppId}/devices`, settingsRef.baseUrl)
  if (raw.error) return []
  return (raw.devices as PluginDeviceRow[]) ?? []
}

export function clearPluginCache(steamAppId?: number) {
  if (steamAppId == null) { cache.clear(); return }
  for (const k of cache.keys()) {
    if (k.startsWith(`${steamAppId}|`)) cache.delete(k)
  }
}
  • Step 4: Build the plugin

Run: cd plugins/decky-vault && bun run build Expected: build succeeds.

  • Step 5: Commit
git add plugins/decky-vault/main.py plugins/decky-vault/src/lib/api.ts plugins/decky-vault/src/lib/plugin-api.ts
git commit -m "feat(plugin): plugin_get proxy + typed library-panel fetch/cache"

Task 7: Library app-details patch + panel + wiring (Feature, plugin UI)

Files:

  • Create: plugins/decky-vault/src/patches/LibraryApp.tsx

  • Create: plugins/decky-vault/src/components/LibraryAppPanel.tsx

  • Modify: plugins/decky-vault/src/index.tsx (register patch on load, remove on dismount; pass baseUrl/hardwareSlug to the panel data layer)

  • Step 1: Create the LibraryAppPanel.tsx component

Create plugins/decky-vault/src/components/LibraryAppPanel.tsx:

import { useEffect, useState } from "react"
import { PanelSection, PanelSectionRow, DropdownItem, staticClasses } from "@decky/ui"
import { FaCheck, FaTimes, FaChartLine } from "react-icons/fa"
import {
  fetchPluginGame,
  fetchPluginDevices,
  setPluginApiBaseUrl,
  type PluginGameResponse,
  type PluginDeviceRow,
  type PluginEntry,
} from "../lib/plugin-api"

interface Props {
  appId: number
  title: string
  hardwareSlug: string | null      // detected device
  baseUrl: string
}

function EntryCard({ e }: { e: PluginEntry }) {
  const [expanded, setExpanded] = useState(false)
  const settingsCount = Array.isArray(e.settingsJson)
    ? (e.settingsJson as Array<{ settings: unknown[] }>).reduce((s, c) => s + (c.settings?.length ?? 0), 0)
    : 0
  const label = e.isPinned ? "Pinned" : e.upvotes > 0 ? `${e.upvotes}👍` : "Recent"
  return (
    <PanelSectionRow>
      <div
        onClick={() => setExpanded((v) => !v)}
        style={{ padding: "8px 10px", borderRadius: "8px", background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.10)", cursor: "pointer" }}
      >
        <div className={staticClasses.Text} style={{ display: "flex", justifyContent: "space-between", fontSize: "13px" }}>
          <strong>{e.fpsAvg} FPS avg</strong>
          <span style={{ opacity: 0.7 }}>{label}</span>
        </div>
        <div className={staticClasses.Text} style={{ fontSize: "11px", opacity: 0.6, marginTop: 2 }}>
          {e.fpsLow ?? "—"} low · {e.fpsOnePercentLow ?? "—"} 1% · {e.fpsHigh ?? "—"} high
          {e.tdpWatts ? ` · ${e.tdpWatts}W` : ""}
          {e.upscalerType && e.upscalerType !== "none" ? ` · ${e.upscalerType}` : ""}
          {e.protonVersion ? ` · Proton ${e.protonVersion}` : ""}
        </div>
        {expanded && (
          <div className={staticClasses.Text} style={{ fontSize: "11px", opacity: 0.7, marginTop: 6, borderTop: "1px solid rgba(255,255,255,0.08)", paddingTop: 6 }}>
            <div>By {e.userName ?? "unknown"} · {new Date(e.createdAt).toLocaleDateString()}</div>
            <div>{settingsCount} settings</div>
            {e.osVersion && <div>OS: {e.osVersion}</div>}
          </div>
        )}
      </div>
    </PanelSectionRow>
  )
}

export default function LibraryAppPanel({ appId, title, hardwareSlug, baseUrl }: Props) {
  const [data, setData] = useState<PluginGameResponse | null>(null)
  const [devices, setDevices] = useState<PluginDeviceRow[]>([])
  const [loading, setLoading] = useState(true)
  const [device, setDevice] = useState<string>(hardwareSlug ?? "")  // "" = all devices

  useEffect(() => {
    setPluginApiBaseUrl(baseUrl)
    let cancelled = false
    async function load() {
      setLoading(true)
      const d = await fetchPluginGame(appId, device || null, 3)
      if (!cancelled) { setData(d); setLoading(false) }
      const devs = await fetchPluginDevices(appId)
      if (!cancelled) setDevices(devs)
    }
    load()
    return () => { cancelled = true }
  }, [appId, device, baseUrl])

  if (loading) {
    return (
      <PanelSection title="DeckyVault">
        <PanelSectionRow>
          <div className={staticClasses.Text} style={{ padding: "8px 0", fontSize: "12px", opacity: 0.6 }}>Loading DeckyVault</div>
        </PanelSectionRow>
      </PanelSection>
    )
  }

  const deviceOptions = [
    { label: "All devices", data: "" },
    ...(hardwareSlug ? [{ label: `Your device (${hardwareSlug})`, data: hardwareSlug }] : []),
    ...devices
      .filter((d) => d.slug !== hardwareSlug)
      .map((d) => ({ label: `${d.name} (${d.count})`, data: d.slug })),
  ]

  if (!data || !data.game) {
    return (
      <PanelSection title="DeckyVault">
        <PanelSectionRow>
          <div className={staticClasses.Text} style={{ fontSize: "12px", padding: "6px 0", opacity: 0.7 }}>
            <FaTimes /> Not in DeckyVault yet. Open <strong>{title}</strong> on{" "}
            <a href={`${baseUrl}/games`}>deckyvault.xyz</a> to add it.
          </div>
        </PanelSectionRow>
      </PanelSection>
    )
  }

  return (
    <PanelSection title="DeckyVault">
      <PanelSectionRow>
        <div className={staticClasses.Text} style={{ fontSize: "12px", padding: "4px 0", display: "flex", alignItems: "center", gap: 6 }}>
          <FaCheck style={{ color: "#2ecc71" }} /> In DeckyVault
        </div>
      </PanelSectionRow>

      {/* Est FPS */}
      <PanelSectionRow>
        <div className={staticClasses.Text} style={{ fontSize: "13px", padding: "4px 0", display: "flex", alignItems: "center", gap: 6 }}>
          <FaChartLine /> Est FPS
        </div>
      </PanelSectionRow>
      {data.estFps ? (
        <PanelSectionRow>
          <div className={staticClasses.Text} style={{ fontSize: "13px", padding: "0 0 6px 0" }}>
            <strong>{data.estFps.avg}</strong> avg · {data.estFps.low ?? "—"} low · {data.estFps.onePct ?? "—"} 1% · {data.estFps.high ?? "—"} high
            <span style={{ opacity: 0.5, fontSize: "11px" }}> · {data.estFps.count} entries</span>
          </div>
        </PanelSectionRow>
      ) : (
        <PanelSectionRow>
          <div className={staticClasses.Text} style={{ fontSize: "12px", opacity: 0.6, padding: "0 0 6px 0" }}>
            No entries for this device yet  be the first: open the DeckyVault plugin and record.
          </div>
        </PanelSectionRow>
      )}

      {/* Device switcher */}
      <PanelSectionRow>
        <DropdownItem
          label="Device"
          rgOptions={deviceOptions}
          selectedOption={device}
          onChange={(opt) => setDevice(opt.data as string)}
        />
      </PanelSectionRow>

      {/* Top entries */}
      {data.topEntries.length > 0 && (
        <>
          <PanelSectionRow>
            <div className={staticClasses.Text} style={{ fontSize: "11px", opacity: 0.5, padding: "8px 0 2px 0", textTransform: "uppercase", letterSpacing: "0.05em" }}>
              Top entries
            </div>
          </PanelSectionRow>
          {data.topEntries.map((e) => <EntryCard key={e.id} e={e} />)}
        </>
      )}
    </PanelSection>
  )
}
  • Step 2: Create the LibraryApp.tsx patch

Create plugins/decky-vault/src/patches/LibraryApp.tsx:

import {
  afterPatch,
  appDetailsClasses,
  createReactTreePatcher,
  findInReactTree,
} from "@decky/ui"
import { routerHook } from "@decky/api"
import type { ReactElement } from "react"
import LibraryAppPanel from "../components/LibraryAppPanel"

// Mirror of HLTB-for-Deck's patchAppPage, guarded so a Steam UI change
// degrades to "section not shown" instead of crashing Steam.
function isSteamGameType(appType: number) {
  return appType === 1 || appType === 8 // Game, Demo
}

// These are supplied by the plugin at registration time (read from settings).
let panelProps: { hardwareSlug: string | null; baseUrl: string } = { hardwareSlug: null, baseUrl: "https://deckyvault.xyz" }
export function setLibraryAppPanelProps(p: { hardwareSlug: string | null; baseUrl: string }) {
  panelProps = p
}

export function registerLibraryAppPatch() {
  return routerHook.addPatch("/library/app/:appid", (routerTree: any) => {
    try {
      const routeProps = findInReactTree(routerTree, (x: any) => x?.renderFunc)
      if (!routeProps) return routerTree

      const patchHandler = createReactTreePatcher(
        [
          (tree: any) => {
            const child = findInReactTree(
              tree,
              (x: any) => x?.props?.children?.props?.overview,
            )
            if (!child) return null
            const overview = child.props.children.props.overview
            if (!overview || !isSteamGameType(overview.app_type)) return null
            return child.props.children
          },
        ],
        (_: Record<string, unknown>[], ret: ReactElement) => {
          try {
            const container = findInReactTree(
              ret,
              (x: any) =>
                Array.isArray(x?.props?.children) &&
                x?.props?.className?.includes(appDetailsClasses.InnerContainer),
            )
            const arr = container?.props?.children
            if (!Array.isArray(arr)) {
              console.debug("[DeckyVault] app-details: no splicable container (non-game page?)")
              return ret
            }
            const idx = arr.findIndex((child: ReactElement) => {
              const p = child?.props
              return (
                p?.childFocusDisabled !== undefined &&
                p?.navRef !== undefined &&
                p?.children?.props?.details !== undefined &&
                p?.children?.props?.overview !== undefined &&
                p?.children?.props?.bFastRender !== undefined
              )
            })
            if (idx > -1) {
              const overview = arr[idx]?.props?.children?.props?.overview
              arr.splice(
                idx,
                0,
                <LibraryAppPanel
                  appId={overview?.appid}
                  title={overview?.display_name ?? ""}
                  hardwareSlug={panelProps.hardwareSlug}
                  baseUrl={panelProps.baseUrl}
                />,
              )
            } else {
              console.debug("[DeckyVault] app-details: splicing anchor not found")
            }
          } catch (err) {
            console.error("[DeckyVault] app-details splice failed:", err)
          }
          return ret
        },
      )

      afterPatch(routeProps, "renderFunc", patchHandler)
    } catch (err) {
      console.error("[DeckyVault] library patch failed (degraded):", err)
    }
    return routerTree
  })
}
  • Step 3: Wire it into index.tsx

In plugins/decky-vault/src/index.tsx:

Add imports:

import { registerLibraryAppPatch, setLibraryAppPanelProps } from "./patches/LibraryApp"

Inside the definePlugin(() => { ... }) body, before the return, register the patch and keep its handle so we can remove it on dismount. The Content component already has settings via useSettings. Since the plugin object is created once at load, register the patch at the top of the factory and set panel props from a lightweight effect inside Content.

Change the definePlugin body to:

export default definePlugin(() => {
  const libraryAppPatch = registerLibraryAppPatch()

  function Content() {
    const { settings, updateSetting, loaded } = useSettings()
    // keep the library panel's props in sync with settings
    useEffect(() => {
      setLibraryAppPanelProps({ hardwareSlug: settings.hardwareSlug, baseUrl: settings.baseUrl })
    }, [settings.hardwareSlug, settings.baseUrl])

    const {
      recordingState, session, recentSessions, error, setError,
      startRecording, stopRecording, updateSession, addToRecent, reset,
      onGameStart, onGameStop, setGameName, setLastLogPath,
    } = useSession()

    useGameDetection(setGameName, recordingState)

    // ... (handleStart / handleStop from Task 3 stay here, unchanged from Task 3) ...

    if (!loaded) {
      return (
        <PanelSection title="DeckyVault">
          <PanelSectionRow>
            <div className={staticClasses.Text} style={{ padding: "16px", textAlign: "center" }}>Loading...</div>
          </PanelSectionRow>
        </PanelSection>
      )
    }

    return (
      <>
        <MainPanel
          recordingState={recordingState}
          session={session}
          recentSessions={recentSessions}
          error={error}
          settings={settings}
          onStart={handleStart}
          onStop={handleStop}
          onUpdateSession={updateSession}
          onAddToRecent={addToRecent}
          onReset={reset}
          setError={setError}
          setGameName={setGameName}
          onUpdateSetting={updateSetting}
        />
      </>
    )
  }

  return {
    name: "DeckyVault",
    titleView: <div className={staticClasses.Title}>DeckyVault</div>,
    content: <Content />,
    icon: <DeckyVaultIcon />,
    alwaysRender: true,
    onDismount() {
      try { routerHook.removePatch("/library/app/:appid", libraryAppPatch) } catch (e) { console.error("[DeckyVault] removePatch failed:", e) }
      console.log("[DeckyVault] Plugin unloading")
    },
  }
})

Add routerHook to the import from "@decky/api":

import { definePlugin, routerHook } from "@decky/api"

Add useEffect to the React import:

import { useEffect } from "react"

Note: the existing Content function body (handleStart/handleStop from Task 3, the loading/return JSX) moves inside the factory as shown. Keep DeckyVaultIcon defined outside the factory as today.

  • Step 4: Build the plugin

Run: cd plugins/decky-vault && bun run build Expected: build succeeds (dist/index.js). If rollup reports missing exports from @decky/ui (afterPatch, createReactTreePatcher, findInReactTree, appDetailsClasses), re-check the installed package; these were verified present in Task 0 research.

  • Step 5: Lint/typecheck

Run: cd plugins/decky-vault && bunx tsc --noEmit Expected: no new errors (some Decky globals may be untyped — cast as any where needed, as HLTB does).

  • Step 6: Commit
git add plugins/decky-vault/src/patches/LibraryApp.tsx plugins/decky-vault/src/components/LibraryAppPanel.tsx plugins/decky-vault/src/index.tsx
git commit -m "feat(plugin): inject DeckyVault section into library app-details page"

Task 8: Final build + lint + test sweep

Files: none (verification only)

  • Step 1: Run plugin Python tests

Run: cd plugins/decky-vault && python -m pytest tests/ -v Expected: all tests PASS (parser, settings, clear_log, screenshots).

  • Step 2: Build the plugin

Run: cd plugins/decky-vault && bun run build Expected: dist/index.js written, no errors.

  • Step 3: Run web tests + lint

Run: bun run test && cd apps/web && bun run lint && bunx tsc --noEmit Expected: tests PASS; lint clean; no new type errors.

  • Step 4: Final commit (if any formatting fixups)

Only if the lint step changed files:

git add -A
git commit -m "chore: final sweep fixups"
  • Step 5: Manual smoke note (not automated)

Document for the user the manual smoke checks to run on-device:

  • Start a recording while a game is running, stop, start again → game must not crash.
  • Upload a recording for a game not in DB → see the not-in-DB message; add the game on the site; a no-FPS recording now shows "No FPS data captured" instead of "FPS 0-500".
  • Open the screenshot picker → Game Mode (userdata/760/remote/...) screenshots now appear.
  • Open a library app-details page for a game in DeckyVault → the DeckyVault section appears with est FPS, device switcher, and top entries; a non-DeckyVault game shows the CTA; a soundtrack page shows nothing.

Self-Review notes (run after writing — issues fixed inline)

  • Spec coverage: Bug 1 → Task 3; Bug 2 → Tasks 12; Bug 3 → Task 4; Feature (4&5 unified) → Tasks 57. All spec sections covered.
  • Validation ordering (Task 1): FPS validation moved before game lookup so a 404 can never mask a 400 — matches spec §2.2.
  • Type consistency: validateFps (Task 1) returns values used downstream; buildPluginGameResponse (Task 5) is imported by the Task 5 test and reused shape in plugin-api.ts (Task 6). registerLibraryAppPatch/setLibraryAppPanelProps (Task 7) match the index.tsx wiring. findMangohudLog/deleteLogFile/pluginGet RPC names match across main.py, api.ts, and index.tsx.
  • No placeholders: every code step contains full code.