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

1743 lines
66 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`:
```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 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**
```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 (..../<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:
```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<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:
```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<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`:
```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**
```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 (
<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`:
```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:
```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 (
<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"`:
```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 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.