diff --git a/docs/superpowers/plans/2026-07-12-plugin-bugs-and-features.md b/docs/superpowers/plans/2026-07-12-plugin-bugs-and-features.md new file mode 100644 index 0000000..4d282e7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-12-plugin-bugs-and-features.md @@ -0,0 +1,1743 @@ +# 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 88–140 and the game-lookup block ~lines 58–80) +- Modify: `apps/web/lib/api/performance-submit.ts` (FPS validation block ~lines 145–165) +- 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`: + +```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: + +```ts +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 145–165). 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** + +```bash +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`: + +```ts +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: + +```ts + 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: + +```ts + 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`: + +```ts + 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: + +```ts + 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** + +```bash +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`: + +```python +"""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: + +```python + 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): + +```ts + lastLogPath: string | null +``` + +In `createEmptySession()` add: + +```ts + lastLogPath: null, +``` + +In `useSession`, add an updater so `handleStop` can record the log path. Add to the returned object a new function: + +```ts + 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: + +```ts +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`: + +```ts + 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: + +```ts + // 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`: + +```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`: + +```python + 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** + +```bash +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`: + +```python +"""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: + +```python + 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 (....//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: + +```python + 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: + +```ts +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** + +```bash +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`: + +```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`: + +```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[] + recentEntries: ReturnType[] + 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 { + 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((m, e) => (m == null ? e.fpsLow : Math.min(m, e.fpsLow ?? m)), null), + onePct: entries.reduce((m, e) => (m == null ? e.fpsOnePercentLow : Math.min(m, e.fpsOnePercentLow ?? m)), null), + high: entries.reduce((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`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: + +```ts +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** + +```bash +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`): + +```python + 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`: + +```ts +export const pluginGet = callable<[path: string, baseUrl?: string], Record & { 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`: + +```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() +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 { + 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 { + 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** + +```bash +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`: + +```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 ( + +
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" }} + > +
+ {e.fpsAvg} FPS avg + {label} +
+
+ {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}` : ""} +
+ {expanded && ( +
+
By {e.userName ?? "unknown"} · {new Date(e.createdAt).toLocaleDateString()}
+
{settingsCount} settings
+ {e.osVersion &&
OS: {e.osVersion}
} +
+ )} +
+
+ ) +} + +export default function LibraryAppPanel({ appId, title, hardwareSlug, baseUrl }: Props) { + const [data, setData] = useState(null) + const [devices, setDevices] = useState([]) + const [loading, setLoading] = useState(true) + const [device, setDevice] = useState(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 ( + + +
Loading DeckyVault…
+
+
+ ) + } + + 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 ( + + +
+ Not in DeckyVault yet. Open {title} on{" "} + deckyvault.xyz to add it. +
+
+
+ ) + } + + return ( + + +
+ In DeckyVault +
+
+ + {/* Est FPS */} + +
+ Est FPS +
+
+ {data.estFps ? ( + +
+ {data.estFps.avg} avg · {data.estFps.low ?? "—"} low · {data.estFps.onePct ?? "—"} 1% · {data.estFps.high ?? "—"} high + · {data.estFps.count} entries +
+
+ ) : ( + +
+ No entries for this device yet — be the first: open the DeckyVault plugin and record. +
+
+ )} + + {/* Device switcher */} + + setDevice(opt.data as string)} + /> + + + {/* Top entries */} + {data.topEntries.length > 0 && ( + <> + +
+ Top entries +
+
+ {data.topEntries.map((e) => )} + + )} +
+ ) +} +``` + +- [ ] **Step 2: Create the `LibraryApp.tsx` patch** + +Create `plugins/decky-vault/src/patches/LibraryApp.tsx`: + +```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[], 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, + , + ) + } 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: + +```ts +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: + +```ts +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 ( + + +
Loading...
+
+
+ ) + } + + return ( + <> + + + ) + } + + return { + name: "DeckyVault", + titleView:
DeckyVault
, + content: , + icon: , + 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"`: + +```ts +import { definePlugin, routerHook } from "@decky/api" +``` + +Add `useEffect` to the React import: + +```ts +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** + +```bash +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: + +```bash +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 1–2; Bug 3 → Task 4; Feature (4&5 unified) → Tasks 5–7. 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. \ No newline at end of file