diff --git a/apps/web/lib/api/__tests__/performance-import-validation.test.ts b/apps/web/lib/api/__tests__/performance-import-validation.test.ts new file mode 100644 index 0000000..c8cf433 --- /dev/null +++ b/apps/web/lib/api/__tests__/performance-import-validation.test.ts @@ -0,0 +1,50 @@ +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) + }) +}) \ No newline at end of file diff --git a/apps/web/lib/api/__tests__/plugin-public.test.ts b/apps/web/lib/api/__tests__/plugin-public.test.ts new file mode 100644 index 0000000..afd9415 --- /dev/null +++ b/apps/web/lib/api/__tests__/plugin-public.test.ts @@ -0,0 +1,143 @@ +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 } shape (no error) when game is missing", async () => { + const r = await buildPluginGameResponse({ game: null }) + expect(r.game).toBeNull() + expect(r.error).toBeUndefined() + 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 + }) + + it("uses provided estFps aggregate over entries-derived computation", 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 }, + ] + // Aggregate says 25 entries averaging 72.3, distinct from the 2-row top-3 avg of 70 + const r = await buildPluginGameResponse({ + game: { id: "g1", steamAppId: 123, title: "X", slug: "x" }, + entries, recent: entries, + estFps: { avg: 72.3, low: 35, onePct: 38, high: 140, count: 25 }, + }) + expect(r.estFps).not.toBeNull() + expect(r.estFps!.avg).toBe(72.3) + expect(r.estFps!.count).toBe(25) + expect(r.estFps!.high).toBe(140) + expect(r.estFps!.low).toBe(35) + // Entries are still trimmed/rendered from the provided rows + expect(r.topEntries.length).toBe(2) + expect(r.recentEntries.length).toBe(2) + }) + + it("handles all-null fps values gracefully", async () => { + const entries = [ + { id: "e1", hardwareSlug: "steamdeck-oled", fpsAvg: 60, fpsLow: null, fpsOnePercentLow: null, fpsHigh: null, + upscalerType: "none", frameGenMethod: "none", protonVersion: null, osVersion: null, tdpWatts: null, + settingsJson: null, upvotes: 0, isPinned: false, createdAt: new Date("2026-01-01"), + userName: null, 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).toBe(60) + expect(r.estFps!.low).toBeNull() + expect(r.estFps!.onePct).toBeNull() + expect(r.estFps!.high).toBeNull() + expect(r.estFps!.count).toBe(1) + }) + + it("trims recent entries separately from topEntries", async () => { + const top = [ + { 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 }, + ] + const recent = [ + { 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: top, recent }) + expect(r.topEntries).toHaveLength(1) + expect(r.topEntries[0].id).toBe("e1") + expect(r.recentEntries).toHaveLength(1) + expect(r.recentEntries[0].id).toBe("e2") + }) +}) \ No newline at end of file diff --git a/apps/web/lib/api/app.ts b/apps/web/lib/api/app.ts index d7d629e..1ce7f7b 100644 --- a/apps/web/lib/api/app.ts +++ b/apps/web/lib/api/app.ts @@ -53,6 +53,7 @@ import { mobileRoutes } from "@/lib/api/mobile" import { gamesLookupRoutes } from "@/lib/api/games-lookup" import { performanceImportRoutes } from "@/lib/api/performance-import" import { pluginPairingRoutes } from "@/lib/api/plugin-pairing" +import { pluginPublicRoutes } from "@/lib/api/plugin-public" const betterAuth = new Elysia({ name: "better-auth" }) .mount(auth.handler) @@ -247,6 +248,7 @@ export const app = new Elysia({ prefix: "/api" }) .use(gamesLookupRoutes) .use(mobileRoutes) .use(pluginPairingRoutes) + .use(pluginPublicRoutes) ) // ── Write routes ─────────────────────────────────────────── .group("", (app) => diff --git a/apps/web/lib/api/performance-import.ts b/apps/web/lib/api/performance-import.ts index 10a71c9..107f2d1 100644 --- a/apps/web/lib/api/performance-import.ts +++ b/apps/web/lib/api/performance-import.ts @@ -19,6 +19,48 @@ type UpscalerType = (typeof VALID_UPSCALER_TYPES)[number] type FrameGenMethod = (typeof VALID_FRAME_GEN_METHODS)[number] type AntiCheatStatus = (typeof VALID_ANTICHEAT_STATUSES)[number] +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 type FpsValues = { + fpsAvg: number + fpsLow: number | null + fpsOnePercentLow: number | null + fpsHigh: number | null +} + +export type FpsValidationResult = + | { ok: true; values: FpsValues } + | { ok: false; error: string } + +export function validateFps(input: FpsInput): FpsValidationResult { + 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 } } +} + export const performanceImportRoutes = new Elysia({ prefix: "/performance", detail: { tags: ["Performance"] }, @@ -38,6 +80,19 @@ export const performanceImportRoutes = new Elysia({ return { error: "Unsupported import format version" } } + // ── Validate FPS fields (before game lookup so a 400 is never masked by a 404) ── + const fpsResult = validateFps({ + fpsAvg: body.fpsAvg, + fpsLow: body.fpsLow, + fpsOnePercentLow: body.fpsOnePercentLow, + fpsHigh: body.fpsHigh, + }) + if (!fpsResult.ok) { + set.status = 400 + return { error: fpsResult.error } + } + const { fpsAvg, fpsLow, fpsOnePercentLow, fpsHigh } = fpsResult.values + // ── Resolve game version from steamAppId ────────────────────── const steamAppId = body.steamAppId if (!steamAppId) { @@ -111,35 +166,6 @@ export const performanceImportRoutes = new Elysia({ } } - // ── Validate FPS fields ─────────────────────────────────────── - const fpsAvg = Number(body.fpsAvg) - if (isNaN(fpsAvg) || fpsAvg < 1 || fpsAvg > 500) { - set.status = 400 - return { error: "fpsAvg must be between 1 and 500" } - } - - const fpsLow = body.fpsLow != null ? Number(body.fpsLow) : null - if (fpsLow !== null && (isNaN(fpsLow) || fpsLow < 0 || fpsLow > 500)) { - set.status = 400 - return { error: "fpsLow must be between 0 and 500" } - } - - const fpsOnePercentLow = - body.fpsOnePercentLow != null ? Number(body.fpsOnePercentLow) : null - if ( - fpsOnePercentLow !== null && - (isNaN(fpsOnePercentLow) || fpsOnePercentLow < 0 || fpsOnePercentLow > 500) - ) { - set.status = 400 - return { error: "fpsOnePercentLow must be between 0 and 500" } - } - - const fpsHigh = body.fpsHigh != null ? Number(body.fpsHigh) : null - if (fpsHigh !== null && (isNaN(fpsHigh) || fpsHigh < 0 || fpsHigh > 500)) { - set.status = 400 - return { error: "fpsHigh must be between 0 and 500" } - } - // ── Validate enums ──────────────────────────────────────────── const rawUpscalerType = body.upscalerType ?? "none" const upscalerType: UpscalerType = VALID_UPSCALER_TYPES.includes( diff --git a/apps/web/lib/api/performance-submit.ts b/apps/web/lib/api/performance-submit.ts index 57176c2..a6370f6 100644 --- a/apps/web/lib/api/performance-submit.ts +++ b/apps/web/lib/api/performance-submit.ts @@ -146,23 +146,23 @@ export const performanceSubmitRoutes = new Elysia({ prefix: "/performance", deta } // ── Validation: fpsAvg bounds ─────────────────────────────── - if (typeof fpsAvg !== "number" || fpsAvg < 1 || fpsAvg > 500) { + if (typeof fpsAvg !== "number" || fpsAvg < 1 || fpsAvg > 1000) { set.status = 400 - return { error: "fpsAvg must be between 1 and 500" } + return { error: "fpsAvg must be between 1 and 1000" } } // ── Validation: optional FPS bounds ───────────────────────── - if (fpsLow !== null && (fpsLow < 0 || fpsLow > 500)) { + if (fpsLow !== null && (fpsLow < 0 || fpsLow > 1000)) { set.status = 400 - return { error: "fpsLow must be between 0 and 500" } + return { error: "fpsLow must be between 0 and 1000" } } - if (fpsHigh !== null && (fpsHigh < 0 || fpsHigh > 500)) { + if (fpsHigh !== null && (fpsHigh < 0 || fpsHigh > 1000)) { set.status = 400 - return { error: "fpsHigh must be between 0 and 500" } + return { error: "fpsHigh must be between 0 and 1000" } } - if (fpsOnePercentLow !== null && (fpsOnePercentLow < 0 || fpsOnePercentLow > 500)) { + if (fpsOnePercentLow !== null && (fpsOnePercentLow < 0 || fpsOnePercentLow > 1000)) { set.status = 400 - return { error: "fpsOnePercentLow must be between 0 and 500" } + return { error: "fpsOnePercentLow must be between 0 and 1000" } } // ── Validation: settingsJson size limits ──────────────────── diff --git a/apps/web/lib/api/plugin-public.ts b/apps/web/lib/api/plugin-public.ts new file mode 100644 index 0000000..c2e9a41 --- /dev/null +++ b/apps/web/lib/api/plugin-public.ts @@ -0,0 +1,286 @@ +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, 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[] + estFps?: { avg: number; low: number | null; onePct: number | null; high: number | null; count: number } | null +}): Promise { + if (!args.game) { + return { game: null, estFps: null, topEntries: [], recentEntries: [] } + } + const entries = args.entries ?? [] + const recent = args.recent ?? [] + // Use provided estFps if available, otherwise compute from entries + const estFps = args.estFps ?? (entries.length > 0 ? { + avg: Math.round((entries.reduce((a, b) => a + b.fpsAvg, 0) / entries.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, + } : null) + if (!estFps) { + return { game: { ...args.game }, estFps: null, topEntries: [], recentEntries: [] } + } + 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) + + // Aggregate estFps from ALL matching entries (not just top-3) + const [agg] = await db + .select({ + avg: sql`round(avg(${performanceEntries.fpsAvg})::numeric, 1)`, + low: sql`min(${performanceEntries.fpsLow})`, + onePct: sql`min(${performanceEntries.fpsOnePercentLow})`, + high: sql`max(${performanceEntries.fpsHigh})`, + count: sql`count(*)::int`, + }) + .from(performanceEntries) + .where(baseWhere) + + set.headers["Cache-Control"] = "public, max-age=60" + set.headers["Vary"] = "search-params" + return await buildPluginGameResponse({ + game, + entries: topRows as unknown as PluginEntryRow[], + recent: recentRows as unknown as PluginEntryRow[], + estFps: agg.avg ? { avg: agg.avg, low: agg.low, onePct: agg.onePct, high: agg.high, count: agg.count } : null, + }) + }, + { + 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) + if (!Number.isInteger(steamAppId) || steamAppId <= 0) { + set.status = 400 + return { error: "Invalid steamAppId", devices: [] } + } + 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(gameVersions.isLatest, true), + eq(performanceEntries.isRemoved, false), + ), + ) + .groupBy(performanceEntries.hardwareSlug, hardware.name) + .orderBy(desc(sql`count(*)`)) + set.headers["Cache-Control"] = "public, max-age=60" + set.headers["Vary"] = "search-params" + return { devices: rows } + }, + { + params: t.Object({ steamAppId: t.Numeric() }), + }, + ) \ No newline at end of file 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 diff --git a/docs/superpowers/specs/2026-07-12-plugin-bugs-and-features-design.md b/docs/superpowers/specs/2026-07-12-plugin-bugs-and-features-design.md new file mode 100644 index 0000000..f1ca915 --- /dev/null +++ b/docs/superpowers/specs/2026-07-12-plugin-bugs-and-features-design.md @@ -0,0 +1,225 @@ +# Plugin Bugs + Features — Design + +**Date:** 2026-07-12 +**Scope:** Decky Loader plugin (`plugins/decky-vault`) + DeckyVault web API (`apps/web/lib/api`). Three bug fixes and two features, unified in one spec. +**No DB migrations required** — all changes reuse existing schema columns and routes. + +--- + +## 1. Background & decisions (from brainstorm) + +### Bugs +1. **Stop/restart recording can crash the game.** Root cause: `handleStart` calls `write_mangohud_config()` (rewrites `~/.config/MangoHud/MangoHud.conf`) and `clear_mangohud_log()` (deletes `/tmp/*MangoHud*`, `/tmp/*.csv`, **and `/tmp/*.log`**) on **every** start, even while MangoHud is running as the game's wrapper. Rewriting a live config + deleting a file the wrapper has open can crash MangoHud, which takes the game with it. +2. **"FPS must be from 0 to 500" on resubmit.** Root causes: (a) `buildImportPayload` sends `fpsAvg: sess.fpsAvg ?? 0` — a failed/short recording with no parsed FPS submits `0`, failing the server's `fpsAvg < 1` check; (b) the server caps **all** FPS fields at 500 — `fpsHigh` (and even `fpsAvg`) can legitimately exceed 500 in menus/2D games; (c) the game-not-found (404) check runs *before* FPS validation in `performance-import.ts`, so the first submit masks the real FPS error and it only surfaces after the user adds the game to the DB. +3. **Photos does not pull screenshots.** Root cause: `list_screenshots` only scans `~/Pictures/Screenshots/` + `Steam Client/` (the Desktop Mode export path). Game Mode (Steam+R1) saves to `~/.local/share/Steam/userdata//760/remote//screenshots/*.jpg`, which is never discovered. + +### Features (unified into one injection surface) +4. **Game-open → show game entry, top entries, est FPS.** +5. **Library focus → inject device-filtered metrics.** + +**Feasibility finding:** `definePlugin` (in our installed `@decky/api@1.1.3` / `@decky/ui@4.11.6`) only exposes a single QAM content panel — there is no `tabs`/`gameTabs` field. However, Decky's **documented** `routerHook.addPatch('/library/app/:appid', patch)` + `createReactTreePatcher` / `afterPatch` / `findInReactTree` / `appDetailsClasses` (all exported by our installed packages; verified) enable injecting a React section into the library/app-details page — the same canonical pattern used by the maintained `HLTB for Deck` plugin. This is **not** raw fragile monkey-patching; it is the supported route-patch API. + +**Unified design:** Both features become a single DeckyVault section injected onto the game's library/app-details page. No new in-QAM tab. The section shows: game entry status, device-scoped est FPS, and top recent/pinned/most-positive entries (3 cards). + +**Decisions locked during brainstorm:** +- Feature 5 surface: `A` — library app-details section via `routerHook.addPatch` (HLTB pattern), guarded so a Steam UI change degrades silently. +- Feature 4 (no in-panel tab): fold into the same injection. +- "Est FPS (based on global)": **device-scoped** to the detected hardware slug (matches Feature 5). +- Device scope control: **D1** — default to detected device, with a small in-section dropdown to switch device or view "All devices". +- Bug 2 handling: **A** — plugin clamps/guards before submit; server raises FPS caps to 1000; NaN still rejected. + +--- + +## 2. Bug fixes — detailed design + +### 2.1 Bug 1 — safe recording start + +**Plugin frontend (`src/index.tsx` → `handleStart`):** +- Remove `await writeMangohudConfig()`. Config is a one-time setup step already exposed via the "Write Config" button in the MangoHud Setup panel. The hot start path must never rewrite a config a live game is using. + +**Plugin backend (`main.py` → `clear_mangohud_log`):** +Rework to be MangoHud-specific and non-destructive of live/foreign files: +- Globs to delete: `/tmp/*MangoHud*` and `/tmp/*MangoHud*.csv` only. **Never** `/tmp/*.log` (system/foreign logs). **Never** bare `/tmp/*.csv` (could belong to other tools). +- Skip any candidate whose `mtime` is within the last 3 seconds (an active session may still have it open). +- Return `{ success, deleted: [{name}], skipped: [{name, reason}] }` for observability. + +**Recording-specific log tracking (frontend + backend):** +- On `handleStop`, after `readAndParseMangohudLog`, store the resolved log path in `session.lastLogPath`. +- On next `handleStart`, if `session.lastLogPath` is known, delete (or rename to `*.bak`) **that specific file** only; otherwise fall back to the safe `clear_mangohud_log` above. +- Add a new lightweight RPC `delete_log_file(path)` that strictly validates the path is under `/tmp` and matches `*MangoHud*` before removal (defence in depth). + +**Tests (`tests/test_clear_log.py`, new):** +- Only `*MangoHud*` files removed; a `/tmp/system.log` and `/tmp/other.csv` untouched. +- A file with `mtime` < 3s ago is skipped. +- `delete_log_file` rejects paths outside `/tmp` or not matching `*MangoHud*`. + +### 2.2 Bug 2 — FPS validation/caps + ordering + +**Plugin frontend (`src/lib/store.ts` → `buildImportPayload`):** +- If `sess.fpsAvg == null` or `<= 0`: the caller (`session-form.tsx` `handleUpload`) must block upload with `setError("No FPS data captured — re-record or export only.")` and **not** call `uploadToDeckyvault`. (Export-to-file remains allowed.) +- Clamp: negatives → 0; `fpsHigh`/`fpsAvg`/`fpsLow`/`fpsOnePercentLow` capped at 1000 before sending (server-side cap matches). +- Remove the `?? 0` fallback for `fpsAvg`; use `null` and let the guard above handle it. New helper `sanitizeFps(value)`. + +**Server (`apps/web/lib/api/performance-import.ts`):** +- Reorder validation: validate **FPS shape first** (non-NaN, range), then hardware, then game-not-found. Rationale: the user should always see the *real* first error regardless of DB state; a 404 must not mask a 400. +- Raise caps to 1000 for `fpsAvg`, `fpsLow`, `fpsOnePercentLow`, `fpsHigh`. Keep rejecting `NaN`. Lower bounds unchanged (`fpsAvg` ≥ 1, others ≥ 0). +- Apply the same changes to `apps/web/lib/api/performance-submit.ts` (the website submit path) for parity. + +**Tests (`apps/web/lib/api/__tests__/performance-import.test.ts`, new/extended):** +- No-data payload (`fpsAvg` missing/null) → 400 with a clear message, never a silent 0. +- `fpsHigh: 750` → 201 (accepted), not 400. +- `fpsAvg: NaN` → 400. +- Validation ordering: a payload that is *both* game-not-found *and* has invalid FPS returns the FPS error (400) first. + +### 2.3 Bug 3 — screenshot discovery + +**Plugin backend (`main.py` → `list_screenshots`):** +Add the Game Mode path and keep existing paths (Desktop Mode export): +- New globs: `~/.local/share/Steam/userdata/*/760/remote/*/screenshots/*.{jpg,png}` (all Steam accounts). +- Existing globs retained: `~/Pictures/Screenshots/*.{jpg,png}` + `Steam Client/*.{jpg,png}`; skip `most_recent.jpg` duplicate as today. +- Merge all, de-duplicate by real path (not just basename), sort by `mtime` desc, slice to `limit`. +- Optional new param `app_id: int | None`: when provided, prefer that app's folder (still include Desktop exports) and tag each screenshot with `{ appId }` in the response so the picker can group/filter. + +**`read_screenshot` (`main.py`):** +- Existing Pillow downscale to `max_width` stays. +- Add a pure-Python fallback when Pillow is absent: if raw size < 1 MB, return the data URL; if ≥ 1 MB, return `{ dataUrl: "", error: "Preview unavailable (too large, no Pillow)" }` so CEF is never handed a giant buffer. The picker will show the placeholder thumbnail + name; upload still works (upload reads the file directly, not the preview). + +**Tests (`tests/test_screenshots.py`, new):** +- A fixture tree with `~/Pictures/Screenshots/x.jpg`, `~/Pictures/Screenshots/Steam Client/y.jpg`, and `~/.local/share/Steam/userdata//760/remote//screenshots/z.jpg` → all three returned, deduped, sorted by mtime. +- `app_id` filter returns only that app's folder screenshots + Desktop exports. +- Fallback `read_screenshot` behaviour under a `PIL ImportError` mock: under 1 MB returns data URL, over 1 MB returns preview-unavailable but no exception. + +--- + +## 3. Features 4 & 5 — library app-details DeckyVault section + +### 3.1 Injection mechanism + +A new module `plugins/decky-vault/src/patches/LibraryApp.tsx` registers a route patch on plugin load and unpatches on dismount, mirroring `HLTB for Deck`'s `patchAppPage`: + +```ts +// sketch (real impl in the plan) +routerHook.addPatch('/library/app/:appid', (routerTree) => { + const routeProps = findInReactTree(routerTree, (x) => x?.renderFunc); + if (!routeProps) return routerTree; // guard: unexpected tree → no-op + afterPatch(routeProps, 'renderFunc', createReactTreePatcher( + [(tree) => { + const child = findInReactTree(tree, (x) => x?.props?.children?.props?.overview); + if (!child) return null; // guard: not a game page + const overview = child.props.children.props.overview; + if (!isSteamGameType(overview.app_type)) return null; // only real games (1/8) + return child.props.children; + }], + (_, ret) => { + const container = findInReactTree(ret, + (x) => Array.isArray(x?.props?.children) && + x?.props?.className?.includes(appDetailsClasses.InnerContainer)); + const arr = container?.props?.children; + const idx = arr?.findIndex(/* locate the splicable anchor, HLTB-style */); + if (idx > -1) arr.splice(idx, 0, ); + return ret; + } + )); + return routerTree; +}); +``` + +**Guardrails (critical):** +- Every `findInReactTree` / array access is null-checked; a missing anchor logs a `console.debug` and returns the tree unmodified (no crash, no splice). Non-game pages (soundtracks, tools) are skipped via `app_type`. +- The injected component is async and self-contained — fetch failures render a tiny "DeckyVault: unavailable" line or nothing, never a thrown error in the tree. +- `onDismount` calls `routerHook.removePatch('/library/app/:appid', patch)`. + +### 3.2 Injected component — `LibraryAppPanel.tsx` + +Props: `{ appId: number, title: string }`. Behaviour: +- Reads the plugin's detected `hardwareSlug` (via a shared store accessor) for the default device scope. +- Fetches `GET /api/plugin/game/:steamAppId?hardware=&limit=3` (see 3.4). +- Caches the response per `appId` in an in-memory LRU (1h TTL) to avoid refetch on every page re-render. +- Renders, inside a Steam-styled container (uses `appDetailsClasses` + `staticClasses`): + 1. **Game entry status** — "In DeckyVault" with a link button (decky `Router.NavigateToURL` gloss) or "Not in DeckyVault — open this game on deckyvault.xyz to add it." CTA when the API returns 404 / `game == null`. + 2. **Est FPS** — device-scoped by default (the detected slug), with a compact device **dropdown** (D1): options = detected device + "All devices" + other devices returned by the optional `/devices` endpoint. Switching the dropdown re-fetches with the new `hardware` query. + 3. **Top 3 entry cards** — pinned / top-upvoted / most-recent, each expandable to show `settingsJson` summary, upscaler/frame-gen, Proton/OS, TDP, and the contributor. Reuses fields already returned by the existing performance endpoint shape. + 4. **Empty state** — "No entries for your device yet — be the first: open the DeckyVault plugin and record." when the game exists but the device scope has no data. + +### 3.3 Plugin data fetching + +`plugins/decky-vault/src/lib/plugin-api.ts` (new) provides typed fetch helpers. Since CEF fetch from Steam's context to `deckyvault.xyz` can be CORS-restricted, we proxy through the Python backend as a reliable fallback: +- Python (`main.py`) gains a small generic RPC `plugin_get(path: str) -> dict` that does an authenticated (none needed — reads are public) `urllib` GET to `${base_url}/api${path}` with the existing SSL fallback, returning parsed JSON or `{ error, status }`. Keeps all network in one place, matches the existing upload pattern. +- TS helper wraps `callable("plugin_get")`, caches in the same LRU. + +### 3.4 New web API — read-only, public + +New module `apps/web/lib/api/plugin-public.ts`, mounted in `apps/web/lib/api/app.ts`: + +- `GET /api/plugin/game/:steamAppId?hardware=&limit=` + - Resolves the game by `steamAppId` (reuses the existing `games-lookup` resolution). Returns `{ game: {...}|null, estFps: {avg, low, onePct, high, count}|null, topEntries: [...], recentEntries: [...] }`. + - `hardware` optional: when a valid slug, scope `estFps` + entries to that device; when omitted/null, all devices. + - `topEntries` = entries ordered by `isPinned desc, upvotes desc` (existing behaviour), sliced to `limit`. + - `recentEntries` = entries ordered by `createdAt desc`, sliced to `limit`. + - `estFps` = aggregates over the (device-scoped) non-removed entries using `avg()`/`min()`/`max()`/`count()` on `performanceEntries` (patterns already used in `dashboard-public.ts`, `hardware-stats.ts`, `compare.ts`). + - Entry shape reuses the `games-performance.ts` row projection (trimmed: id, hardware, fpsAvg/Low/OnePct/High, upscalerType, frameGenMethod, protonVersion, osVersion, tdpWatts, settingsJson, upvotes, isPinned, createdAt, userName, userImage). + - Public (no API key) — community data is already public on the site. Same rate-limit middleware as other public routes. + +- `GET /api/plugin/game/:steamAppId/devices` (optional, drives the dropdown) + - Returns `[{ slug, name, count }]` for hardware slugs with ≥1 non-removed entry for the game. + +**Tests (`apps/web/lib/api/__tests__/plugin-public.test.ts`, new):** +- Game in DB, `hardware=steamdeck-oled` → device-scoped est FPS + entries. +- `hardware` omitted → all-device scope. +- Game not in DB → 404 with `{ game: null }` plus an `error` string (so the panel shows its CTA). +- `limit` honoured; `devices` endpoint returns only slugs with data. + +### 3.5 Activation wiring + +`plugins/decky-vault/src/index.tsx`: +- On `definePlugin` body: create the patch (`patchAppPage()`) and keep its handle. +- `onDismount`: `routerHook.removePatch('/library/app/:appid', handle)` (plus existing cleanup). +- The existing QAM panel (`Content`) stays as-is (Recording → Session Results flow unchanged except Bug 1/2 fixes). + +--- + +## 4. Cross-cutting: error handling, caching, testing + +**Error handling:** +- Library patch: never throws into Steam's tree; every external call is wrapped; render falls back to null/CTA. +- API: standard Elysia error shapes (`{ error }`); 404 for unknown game; 400 only for malformed query. +- Plugin uploads: Bug 2 guard blocks before network call. + +**Caching:** +- Plugin-side per-appid LRU (1h) for library panel reads. +- API: `Cache-Control: public, max-age=60` on the read endpoints (community data, acceptable staleness). + +**Testing summary:** +- Python pytest: Bug 1 safe-clear + `delete_log_file`; Bug 3 screenshot discovery + fallback. +- Vitest (web API): `performance-import` validation ordering + 1000 caps; `plugin-public` contract + device scope + 404. +- Plugin TS: `buildImportPayload` clamp/null guard (unit if harness present, else build + manual smoke). No DB migrations. + +--- + +## 5. Files touched + +**Plugin:** +- `plugins/decky-vault/main.py` — `clear_mangohud_log`, new `delete_log_file`, `list_screenshots`, `read_screenshot`, new `plugin_get`. +- `plugins/decky-vault/src/index.tsx` — `handleStart` cleanup; wire patch + `onDismount`. +- `plugins/decky-vault/src/lib/store.ts` — `buildImportPayload` clamp/null guard + `sanitizeFps`. +- `plugins/decky-vault/src/lib/api.ts` — new `pluginGet` RPC wrapper. +- `plugins/decky-vault/src/lib/plugin-api.ts` (new) — typed library-panel fetch helpers + cache. +- `plugins/decky-vault/src/patches/LibraryApp.tsx` (new) — `routerHook.addPatch` splicer. +- `plugins/decky-vault/src/components/LibraryAppPanel.tsx` (new) — injected section UI. +- `plugins/decky-vault/tests/test_clear_log.py`, `tests/test_screenshots.py` (new) + fixtures. + +**Web API:** +- `apps/web/lib/api/performance-import.ts`, `performance-submit.ts` — caps (1000) + validation order. +- `apps/web/lib/api/plugin-public.ts` (new) — mounted in `app.ts`. +- `apps/web/lib/api/__tests__/performance-import.test.ts`, `plugin-public.test.ts` (new/extended). + +**No schema changes, no migrations.** + +--- + +## 6. Out of scope + +- Any Decky store submission / release packaging. +- The in-QAM "new tab" concept (dropped per user decision — folded into library injection). +- Toast / grid-focus overlays (feature 5 option B, dropped). +- Per-entry rich-text comments rendering inside the library panel (future enhancement). \ No newline at end of file diff --git a/plugins/decky-vault/main.py b/plugins/decky-vault/main.py index 00ea939..f28695d 100644 --- a/plugins/decky-vault/main.py +++ b/plugins/decky-vault/main.py @@ -23,6 +23,52 @@ except ImportError: decky = None +def _collect_screenshots(home: str, app_id: int | None = None, limit: int = 50) -> list[dict]: + """Pure helper: collect screenshots from Desktop and Game Mode paths under `home`. + Returns list of dicts sorted by mtime desc, limited to `limit` entries. + When `app_id` is given, keeps Desktop exports + that app's userdata shots.""" + 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) + f_app_id = None + parts = f.split(os.sep) + if "760" in parts: + idx = parts.index("760") + if idx + 2 < len(parts): + 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 files[:limit] + + def parse_mangohud_log(log_content: str) -> dict: """Parse a MangoHud log file's content and return FPS stats. @@ -308,7 +354,7 @@ exec mangohud "$@" import time candidates = [] now = time.time() - for pattern in ["/tmp/*MangoHud*", "/tmp/*.csv", "/tmp/*.log"]: + for pattern in ["/tmp/*MangoHud*"]: for f in glob.glob(pattern): if os.path.isdir(f): continue @@ -367,20 +413,56 @@ exec mangohud "$@" return {"error": f"Failed to read log: {str(e)}"} async def clear_mangohud_log(self) -> dict: - """RPC: Delete all MangoHud log files in /tmp/ so the next recording starts fresh.""" + """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*", "/tmp/*.csv", "/tmp/*.log"]: + for pattern in ["/tmp/*MangoHud*"]: for f in glob.glob(pattern): - if os.path.isfile(f): - try: - os.remove(f) - except (IOError, PermissionError): - pass - return {"success": True} + 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)} + 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} + async def get_hardware_info(self) -> dict: """RPC: Detect hardware model from DMI. Returns {slug, name, raw}.""" # Steam Deck models: Jupiter = LCD, Galileo = OLED @@ -605,44 +687,14 @@ exec mangohud "$@" except Exception as e: return {"success": False, "error": str(e), "status": 0} - async def list_screenshots(self, limit: int = 50) -> dict: - """RPC: List recent Steam Deck screenshots from ~/Pictures/Screenshots/. - Returns {screenshots: [{path, name, mtime, size}], error?}. - Steam saves timestamped JPGs in a 'Steam Client' subfolder and keeps - a 'most_recent.jpg' symlink-like copy at the top level.""" - import glob - import time + 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.""" try: home = os.path.expanduser("~") - base = os.path.join(home, "Pictures", "Screenshots") - 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"), - ] - seen = set() - files = [] - for pat in patterns: - for f in glob.glob(pat): - if not os.path.isfile(f) or f in seen: - continue - # Skip the most_recent.jpg duplicate if a real timestamped - # copy exists — it's just a pointer to the latest one. - if os.path.basename(f) == "most_recent.jpg": - continue - seen.add(f) - try: - files.append({ - "path": f, - "name": os.path.basename(f), - "mtime": os.path.getmtime(f), - "size": os.path.getsize(f), - }) - except OSError: - continue - files.sort(key=lambda x: x["mtime"], reverse=True) - return {"screenshots": files[:limit]} + files = _collect_screenshots(home, app_id=app_id, limit=limit) + return {"screenshots": files} except Exception as e: return {"screenshots": [], "error": str(e)} @@ -671,9 +723,11 @@ exec mangohud "$@" b64 = base64.b64encode(buf.getvalue()).decode("ascii") return {"dataUrl": f"data:image/jpeg;base64,{b64}"} except ImportError: - # No Pillow — return the raw file as a data URL + # 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}"} except Exception as e: @@ -788,6 +842,49 @@ exec mangohud "$@" except Exception as e: return {"valid": False, "error": str(e)} + 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: + # SSRF guard: only allow http/https schemes + if not base_url.startswith(("http://", "https://")): + return {"error": "Invalid base_url scheme", "status": 0} + 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: + raw_body = e.read().decode("utf-8") + try: + err = json.loads(raw_body) + return {**err, "status": e.code} + except json.JSONDecodeError: + return {"error": f"Server returned status {e.code} (non-JSON response)", "status": e.code, "body": raw_body[:200]} + 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} + async def export_config(self, settings: dict) -> dict: """RPC: Export current settings to Downloads/deckyvault-config.json. Returns {success: bool, path?: str, error?: str}.""" diff --git a/plugins/decky-vault/package.json b/plugins/decky-vault/package.json index d43979a..a19de0d 100644 --- a/plugins/decky-vault/package.json +++ b/plugins/decky-vault/package.json @@ -1,6 +1,6 @@ { "name": "@deckyvault/plugin", - "version": "1.0.0", + "version": "1.1.0", "private": true, "type": "module", "scripts": { diff --git a/plugins/decky-vault/src/components/LibraryAppPanel.tsx b/plugins/decky-vault/src/components/LibraryAppPanel.tsx new file mode 100644 index 0000000..74e98be --- /dev/null +++ b/plugins/decky-vault/src/components/LibraryAppPanel.tsx @@ -0,0 +1,209 @@ +import { useEffect, useState, useRef } 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 + const [fetchError, setFetchError] = useState("") + const reqIdRef = useRef(0) + + useEffect(() => { + setPluginApiBaseUrl(baseUrl) + let cancelled = false + const id = ++reqIdRef.current + async function load() { + setLoading(true) + try { + const d = await fetchPluginGame(appId, device || null, 3) + if (cancelled || id !== reqIdRef.current) return + if (d.error && !d.game) { + setFetchError(d.error) + } else { + setFetchError("") + } + setData(d) + setLoading(false) + const devs = await fetchPluginDevices(appId) + if (!cancelled && id === reqIdRef.current) setDevices(devs) + } catch (e) { + if (!cancelled && id === reqIdRef.current) { + setData(null) + setFetchError("Request failed") + setLoading(false) + } + } + } + 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 (fetchError) { + return ( + + +
+ Could not load DeckyVault data: {fetchError} +
+
+
+ ) + } + + if (!data || !data.game) { + return ( + + +
+ Not in DeckyVault yet. Open {title} on{" "} + deckyvault.xyz to add it. +
+
+
+ ) + } + + return ( + + +
+ In DeckyVault +
+
+ + {/* Est FPS — hidden when "All devices" selected to avoid mixing hardware */} + {device ? ( + <> + +
+ 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. +
+
+ )} + + ) : ( + +
+ Select a device to see estimated FPS. +
+
+ )} + + {/* Device switcher */} + + setDevice(opt.data as string)} + /> + + + {/* Top entries */} + {data.topEntries.length > 0 && ( + <> + +
+ Top entries +
+
+ {data.topEntries.map((e) => )} + + )} + + {/* Recent entries */} + {data.recentEntries.length > 0 && ( + <> + +
+ Recent entries +
+
+ {data.recentEntries.map((e) => )} + + )} +
+ ) +} \ No newline at end of file diff --git a/plugins/decky-vault/src/components/session-form.tsx b/plugins/decky-vault/src/components/session-form.tsx index 9cab570..19f7fac 100644 --- a/plugins/decky-vault/src/components/session-form.tsx +++ b/plugins/decky-vault/src/components/session-form.tsx @@ -211,6 +211,10 @@ export default function SessionForm({ setError("No API key configured. Set one in the Settings tab.") return } + if (session.fpsAvg == null || !(session.fpsAvg > 0)) { + setError("No FPS data captured. Re-record the session, or use Export to File only.") + return + } setError("") setUploadStatus("loading") setStatusMessage("Uploading entry…") diff --git a/plugins/decky-vault/src/index.tsx b/plugins/decky-vault/src/index.tsx index c1273f1..6f02b64 100644 --- a/plugins/decky-vault/src/index.tsx +++ b/plugins/decky-vault/src/index.tsx @@ -1,3 +1,4 @@ +import { useEffect } from "react" import { PanelSection, PanelSectionRow, @@ -5,14 +6,17 @@ import { } from "@decky/ui" import { definePlugin, + routerHook, } from "@decky/api" import { FaChartLine } from "react-icons/fa" import MainPanel from "./components/main-panel" +import { registerLibraryAppPatch, setLibraryAppPanelProps } from "./patches/LibraryApp" import { useSettings, useSession, useGameDetection } from "./lib/store" import { readAndParseMangohudLog, clearMangohudLog, - writeMangohudConfig, + deleteLogFile, + findMangohudLog, startMangohudLogging, stopMangohudLogging, getHardwareInfo, @@ -23,6 +27,10 @@ import { 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, @@ -37,6 +45,7 @@ function Content() { onGameStart, onGameStop, setGameName, + setLastLogPath, } = useSession() // ── Game detection via polling ──────────────────────────────── @@ -44,10 +53,13 @@ function Content() { // ── Handle start recording ──────────────────────────────────── async function handleStart() { - // Write MangoHud config with logging settings - await writeMangohudConfig() - // Clear any previous log file - await clearMangohudLog() + // 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() @@ -60,12 +72,15 @@ function Content() { await stopMangohudLogging() stopRecording() - // Parse the MangoHud log - const logResult = await readAndParseMangohudLog() + // Find the most recent MangoHud log, parse it, remember its path + const logPath = await findMangohudLog() + const logResult = await readAndParseMangohudLog(logPath.path ?? undefined) if (logResult.error) { setError(logResult.error) + setLastLogPath(null) return } + setLastLogPath(logPath.path ?? null) // Read system info in parallel const [hwInfo, osVersion] = await Promise.all([ @@ -191,6 +206,8 @@ function DeckyVaultIcon() { } export default definePlugin(() => { + const libraryAppPatch = registerLibraryAppPatch() + return { name: "DeckyVault", titleView:
DeckyVault
, @@ -198,6 +215,7 @@ export default definePlugin(() => { icon: , alwaysRender: true, onDismount() { + try { routerHook.removePatch("/library/app/:appid", libraryAppPatch) } catch (e) { console.error("[DeckyVault] removePatch failed:", e) } console.log("[DeckyVault] Plugin unloading") }, } diff --git a/plugins/decky-vault/src/lib/api.ts b/plugins/decky-vault/src/lib/api.ts index 4881a5f..56b3ce4 100644 --- a/plugins/decky-vault/src/lib/api.ts +++ b/plugins/decky-vault/src/lib/api.ts @@ -36,9 +36,21 @@ export const readAndParseMangohudLog = callable<[logPath?: string], { export const clearMangohudLog = callable<[], { success: boolean + deleted?: Array<{ name: string }> + skipped?: Array<{ name: string; reason: string }> error?: string }>("clear_mangohud_log") +export const deleteLogFile = callable<[path: string], { + success: boolean + deleted?: boolean + error?: string +}>("delete_log_file") + +export const findMangohudLog = callable<[], { + path: string | null +}>("find_mangohud_log") + export const startMangohudLogging = callable<[], { success: boolean error?: string @@ -81,8 +93,8 @@ export const uploadToDeckyvault = callable<[ status?: number }>("upload_to_deckyvault") -export const listScreenshots = callable<[limit?: number], { - screenshots: Array<{ path: string; name: string; mtime: number; size: number }> +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") @@ -154,4 +166,7 @@ export const checkPairStatus = callable<[token: string, baseUrl?: string], { apiKey?: string keyName?: string error?: string -}>("check_pair_status") \ No newline at end of file +}>("check_pair_status") + +// ── Plugin API Proxy ──────────────────────────────────────────── +export const pluginGet = callable<[path: string, baseUrl?: string], Record & { status?: number; error?: string }>("plugin_get") \ No newline at end of file diff --git a/plugins/decky-vault/src/lib/plugin-api.ts b/plugins/decky-vault/src/lib/plugin-api.ts new file mode 100644 index 0000000..3ddd9ed --- /dev/null +++ b/plugins/decky-vault/src/lib/plugin-api.ts @@ -0,0 +1,77 @@ +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 + // Don't cache error responses — transient failures shouldn't poison the cache + if (!raw.error) { + 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) + } +} \ No newline at end of file diff --git a/plugins/decky-vault/src/lib/store.ts b/plugins/decky-vault/src/lib/store.ts index b6d5553..185b52e 100644 --- a/plugins/decky-vault/src/lib/store.ts +++ b/plugins/decky-vault/src/lib/store.ts @@ -36,6 +36,8 @@ export interface SessionData { protonVersion: string versionString: string buildId: string + // Path of the MangoHud log captured for this session (for targeted cleanup on restart) + lastLogPath: string | null // Manual inputs (filled by user in the form) upscalerType: string upscalerVersion: string @@ -70,6 +72,7 @@ function createEmptySession(): SessionData { protonVersion: "", versionString: "", buildId: "", + lastLogPath: null, upscalerType: "none", upscalerVersion: "", frameGenMethod: "none", @@ -182,6 +185,10 @@ export function useSession() { setSession((prev) => ({ ...prev, gameName: name, appId: appId ?? prev.appId })) }, []) + const setLastLogPath = useCallback((p: string | null) => { + setSession((prev) => ({ ...prev, lastLogPath: p })) + }, []) + return { recordingState, session, @@ -196,6 +203,7 @@ export function useSession() { onGameStart, onGameStop, setGameName, + setLastLogPath, } } @@ -235,6 +243,20 @@ export function useGameDetection( return { detecting } } +// ── FPS Sanitizer ──────────────────────────────────────────────── + +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 +} + // ── Payload Builder ───────────────────────────────────────────── export function buildImportPayload(sess: SessionData): DeckyVaultImportV1 { @@ -242,10 +264,10 @@ export function buildImportPayload(sess: SessionData): DeckyVaultImportV1 { version: 1, steamAppId: sess.appId ?? 0, hardwareSlug: sess.hardwareSlug, - fpsAvg: sess.fpsAvg ?? 0, - fpsLow: sess.fpsLow, - fpsOnePercentLow: sess.fpsOnePercentLow, - fpsHigh: sess.fpsHigh, + fpsAvg: sess.fpsAvg == null || !(sess.fpsAvg > 0) ? 0 : sanitizeFps(sess.fpsAvg)!, + fpsLow: sanitizeFps(sess.fpsLow), + fpsOnePercentLow: sanitizeFps(sess.fpsOnePercentLow), + fpsHigh: sanitizeFps(sess.fpsHigh), protonVersion: sess.protonVersion || null, osVersion: sess.osVersion || null, versionString: sess.versionString || null, diff --git a/plugins/decky-vault/src/patches/LibraryApp.tsx b/plugins/decky-vault/src/patches/LibraryApp.tsx new file mode 100644 index 0000000..947ac6d --- /dev/null +++ b/plugins/decky-vault/src/patches/LibraryApp.tsx @@ -0,0 +1,113 @@ +import { + afterPatch, + appDetailsClasses, + createReactTreePatcher, + findInReactTree, +} from "@decky/ui" +import { routerHook } from "@decky/api" +import type { ReactElement } from "react" +import LibraryAppPanel from "../components/LibraryAppPanel" +import { getSettings } from "../lib/api" + +// 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 +} + +// Read settings directly from the Python backend so the panel has them even +// if the QAM Content tab has never mounted (which is what populates panelProps +// via setLibraryAppPanelProps). Keeps hardwareSlug/baseUrl in sync eagerly. +async function loadPanelProps() { + try { + const s = await getSettings() + panelProps = { + hardwareSlug: (s.hardwareSlug as string) || null, + baseUrl: (s.baseUrl as string) || "https://deckyvault.xyz", + } + } catch { + // Use defaults — panelProps retains its last known value + } +} + +export function registerLibraryAppPatch() { + // Load settings eagerly so the panel has them even if Content never mounted + loadPanelProps() + 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 + // Only inject for valid numeric app IDs + if (typeof overview.appid !== 'number' || overview.appid <= 0) 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: any) => { + 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 + }) +} \ No newline at end of file diff --git a/plugins/decky-vault/tests/test_clear_log.py b/plugins/decky-vault/tests/test_clear_log.py new file mode 100644 index 0000000..e703178 --- /dev/null +++ b/plugins/decky-vault/tests/test_clear_log.py @@ -0,0 +1,156 @@ +"""Tests for safe MangoHud log clearing (Bug 1 fix).""" +import os +import shutil +import sys +import tempfile +import time +import types +import pytest + +# Note: the helpers above (_safe_clear_mangohud_logs, _validate_log_path) are +# intentional mirrors of Plugin.clear_mangohud_log / delete_log_file so tests can +# run without the decky module. The test below (test_delete_log_file_real_plugin) +# exercises the real Plugin.delete_log_file to guard against the mirrors drifting +# from the implementation. + + +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*"), + ] + 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 + + +@pytest.mark.asyncio +async def test_delete_log_file_real_plugin(): + """Exercise the real Plugin.delete_log_file path validation. + + Unlike the mirror tests above, this imports main.Plugin and calls the + actual method. A real /tmp subdirectory is used because delete_log_file + hardcodes the "/tmp/" prefix check, and tempfile.TemporaryDirectory() on + macOS resolves under /var/folders (not /tmp). + """ + # Mock the decky module so main.py imports cleanly even when the real + # decky package (only present on the Deck) is unavailable. + sys.modules.pop("main", None) + if "decky" not in sys.modules: + mock_decky = types.ModuleType("decky") + mock_logger = types.ModuleType("decky.logger") + mock_logger.info = lambda *a, **kw: None + mock_logger.error = lambda *a, **kw: None + mock_decky.logger = mock_logger + mock_decky.DECKY_PLUGIN_NAME = "test" + mock_decky.DECKY_PLUGIN_SETTINGS_DIR = "/tmp/decky-test" + sys.modules["decky"] = mock_decky + + from main import Plugin + plugin = Plugin() + + tmp = "/tmp/deckyvault_test_real_plugin_%d" % os.getpid() + shutil.rmtree(tmp, ignore_errors=True) + os.makedirs(tmp) + try: + # Outside /tmp → rejected with the "outside /tmp" reason. + r = await plugin.delete_log_file("/etc/passwd") + assert r["success"] is False + assert "outside /tmp" in r["error"] + + # Under /tmp but basename has no "MangoHud" → rejected as non-MangoHud. + r = await plugin.delete_log_file(os.path.join(tmp, "system.log")) + assert r["success"] is False + assert "non-MangoHud" in r["error"] + + # Valid MangoHud log under /tmp → deleted. + mangohud_path = os.path.join(tmp, "MangoHud-test.csv") + _touch(mangohud_path, mtime_age=10) + r = await plugin.delete_log_file(mangohud_path) + assert r["success"] is True + assert r["deleted"] is True + assert not os.path.exists(mangohud_path) + + # Same path again → already gone, still success but deleted is False. + r = await plugin.delete_log_file(mangohud_path) + assert r["success"] is True + assert r["deleted"] is False + + # Empty path → rejected. + r = await plugin.delete_log_file("") + assert r["success"] is False + finally: + shutil.rmtree(tmp, ignore_errors=True) + sys.modules.pop("main", None) \ No newline at end of file diff --git a/plugins/decky-vault/tests/test_screenshots.py b/plugins/decky-vault/tests/test_screenshots.py new file mode 100644 index 0000000..da0cdba --- /dev/null +++ b/plugins/decky-vault/tests/test_screenshots.py @@ -0,0 +1,103 @@ +"""Tests for screenshot discovery across Steam Game Mode + Desktop paths (Bug 3).""" +import os +import shutil +import sys +import tempfile +import time +import types +import pytest + +from main import _collect_screenshots + + +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 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 = _collect_screenshots(home) + names = [f["name"] for f in found] + assert set(names) == {"desktop.jpg", "sc.jpg", "game.jpg"} + 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 = _collect_screenshots(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 = _collect_screenshots(home) + names = [f["name"] for f in found] + assert "most_recent.jpg" not in names + assert "2026-01-01.jpg" in names + + +@pytest.mark.asyncio +async def test_read_screenshot_fallback_no_pillow(): + """When Pillow is absent, >1MB files return preview-unavailable, small files return data URL.""" + # Mock the decky module so main.py imports cleanly even when the real + # decky package (only present on the Deck) is unavailable. + sys.modules.pop("main", None) + if "decky" not in sys.modules: + mock_decky = types.ModuleType("decky") + mock_decky.logger = type(sys)("Logger") + mock_decky.logger.info = lambda *a, **kw: None + mock_decky.DECKY_PLUGIN_NAME = "test" + mock_decky.DECKY_PLUGIN_SETTINGS_DIR = "/tmp/decky-test" + sys.modules["decky"] = mock_decky + + from main import Plugin + plugin = Plugin() + + test_dir = f"/tmp/deckyvault_test_screenshot_{os.getpid()}" + try: + os.makedirs(test_dir, exist_ok=True) + small_path = os.path.join(test_dir, "small.jpg") + with open(small_path, "wb") as f: + f.write(b"\xff\xd8\xff\xe0" * 100) # ~400 bytes + + large_path = os.path.join(test_dir, "large.jpg") + with open(large_path, "wb") as f: + f.write(b"\xff\xd8\xff\xe0" * 300000) # ~1.2MB + + # Mock PIL ImportError by temporarily removing PIL from sys.modules + had_pil = "PIL" in sys.modules + if had_pil: + pil_mod = sys.modules.pop("PIL") + + try: + r_small = await plugin.read_screenshot(small_path, max_width=320) + assert r_small["dataUrl"] != "", f"Expected data URL, got: {r_small}" + assert "error" not in r_small or not r_small["error"] + + r_large = await plugin.read_screenshot(large_path, max_width=320) + assert r_large["dataUrl"] == "", f"Expected empty dataUrl, got: {r_large}" + assert "too large" in r_large.get("error", "") + finally: + if had_pil: + sys.modules["PIL"] = pil_mod + finally: + shutil.rmtree(test_dir, ignore_errors=True) + sys.modules.pop("main", None) \ No newline at end of file