Merge branch 'dev' into prod

This commit is contained in:
2026-07-13 06:01:15 +08:00
19 changed files with 3387 additions and 98 deletions
@@ -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)
})
})
@@ -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")
})
})
+2
View File
@@ -53,6 +53,7 @@ import { mobileRoutes } from "@/lib/api/mobile"
import { gamesLookupRoutes } from "@/lib/api/games-lookup" import { gamesLookupRoutes } from "@/lib/api/games-lookup"
import { performanceImportRoutes } from "@/lib/api/performance-import" import { performanceImportRoutes } from "@/lib/api/performance-import"
import { pluginPairingRoutes } from "@/lib/api/plugin-pairing" import { pluginPairingRoutes } from "@/lib/api/plugin-pairing"
import { pluginPublicRoutes } from "@/lib/api/plugin-public"
const betterAuth = new Elysia({ name: "better-auth" }) const betterAuth = new Elysia({ name: "better-auth" })
.mount(auth.handler) .mount(auth.handler)
@@ -247,6 +248,7 @@ export const app = new Elysia({ prefix: "/api" })
.use(gamesLookupRoutes) .use(gamesLookupRoutes)
.use(mobileRoutes) .use(mobileRoutes)
.use(pluginPairingRoutes) .use(pluginPairingRoutes)
.use(pluginPublicRoutes)
) )
// ── Write routes ─────────────────────────────────────────── // ── Write routes ───────────────────────────────────────────
.group("", (app) => .group("", (app) =>
+55 -29
View File
@@ -19,6 +19,48 @@ type UpscalerType = (typeof VALID_UPSCALER_TYPES)[number]
type FrameGenMethod = (typeof VALID_FRAME_GEN_METHODS)[number] type FrameGenMethod = (typeof VALID_FRAME_GEN_METHODS)[number]
type AntiCheatStatus = (typeof VALID_ANTICHEAT_STATUSES)[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({ export const performanceImportRoutes = new Elysia({
prefix: "/performance", prefix: "/performance",
detail: { tags: ["Performance"] }, detail: { tags: ["Performance"] },
@@ -38,6 +80,19 @@ export const performanceImportRoutes = new Elysia({
return { error: "Unsupported import format version" } 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 ────────────────────── // ── Resolve game version from steamAppId ──────────────────────
const steamAppId = body.steamAppId const steamAppId = body.steamAppId
if (!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 ──────────────────────────────────────────── // ── Validate enums ────────────────────────────────────────────
const rawUpscalerType = body.upscalerType ?? "none" const rawUpscalerType = body.upscalerType ?? "none"
const upscalerType: UpscalerType = VALID_UPSCALER_TYPES.includes( const upscalerType: UpscalerType = VALID_UPSCALER_TYPES.includes(
+8 -8
View File
@@ -146,23 +146,23 @@ export const performanceSubmitRoutes = new Elysia({ prefix: "/performance", deta
} }
// ── Validation: fpsAvg bounds ─────────────────────────────── // ── Validation: fpsAvg bounds ───────────────────────────────
if (typeof fpsAvg !== "number" || fpsAvg < 1 || fpsAvg > 500) { if (typeof fpsAvg !== "number" || fpsAvg < 1 || fpsAvg > 1000) {
set.status = 400 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 ───────────────────────── // ── Validation: optional FPS bounds ─────────────────────────
if (fpsLow !== null && (fpsLow < 0 || fpsLow > 500)) { if (fpsLow !== null && (fpsLow < 0 || fpsLow > 1000)) {
set.status = 400 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 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 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 ──────────────────── // ── Validation: settingsJson size limits ────────────────────
+286
View File
@@ -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<typeof trimEntry>[]
recentEntries: ReturnType<typeof trimEntry>[]
error?: string
}
function trimEntry(e: PluginEntryRow) {
return {
id: e.id,
hardwareSlug: e.hardwareSlug,
fpsAvg: e.fpsAvg,
fpsLow: e.fpsLow,
fpsOnePercentLow: e.fpsOnePercentLow,
fpsHigh: e.fpsHigh,
upscalerType: e.upscalerType,
frameGenMethod: e.frameGenMethod,
protonVersion: e.protonVersion,
osVersion: e.osVersion,
tdpWatts: e.tdpWatts,
settingsJson: e.settingsJson,
upvotes: e.upvotes,
isPinned: e.isPinned,
createdAt: e.createdAt.toISOString(),
userName: e.userName,
userImage: e.userImage,
}
}
export async function buildPluginGameResponse(args: {
game: PluginGameRow | null
entries?: PluginEntryRow[]
recent?: PluginEntryRow[]
estFps?: { avg: number; low: number | null; onePct: number | null; high: number | null; count: number } | null
}): Promise<PluginGameResponse> {
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<number | null>((m, e) => (m == null ? e.fpsLow : Math.min(m, e.fpsLow ?? m)), null),
onePct: entries.reduce<number | null>((m, e) => (m == null ? e.fpsOnePercentLow : Math.min(m, e.fpsOnePercentLow ?? m)), null),
high: entries.reduce<number | null>((m, e) => (m == null ? e.fpsHigh : Math.max(m, e.fpsHigh ?? m)), null),
count: entries.length,
} : 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<number>`round(avg(${performanceEntries.fpsAvg})::numeric, 1)`,
low: sql<number | null>`min(${performanceEntries.fpsLow})`,
onePct: sql<number | null>`min(${performanceEntries.fpsOnePercentLow})`,
high: sql<number | null>`max(${performanceEntries.fpsHigh})`,
count: sql<number>`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<number>`count(*)::int`,
name: hardware.name,
})
.from(performanceEntries)
.innerJoin(hardware, eq(performanceEntries.hardwareSlug, hardware.slug))
.innerJoin(gameVersions, eq(performanceEntries.versionId, gameVersions.id))
.where(
and(
eq(gameVersions.gameId, game.id),
eq(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() }),
},
)
File diff suppressed because it is too large Load Diff
@@ -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/<steamId>/760/remote/<appId>/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/<id>/760/remote/<appid>/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, <LibraryAppPanel appId={overview.appid} title={overview.display_name} />);
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=<slug>&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=<slug|null>&limit=<n>`
- 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).
+140 -43
View File
@@ -23,6 +23,52 @@ except ImportError:
decky = None 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: def parse_mangohud_log(log_content: str) -> dict:
"""Parse a MangoHud log file's content and return FPS stats. """Parse a MangoHud log file's content and return FPS stats.
@@ -308,7 +354,7 @@ exec mangohud "$@"
import time import time
candidates = [] candidates = []
now = time.time() now = time.time()
for pattern in ["/tmp/*MangoHud*", "/tmp/*.csv", "/tmp/*.log"]: for pattern in ["/tmp/*MangoHud*"]:
for f in glob.glob(pattern): for f in glob.glob(pattern):
if os.path.isdir(f): if os.path.isdir(f):
continue continue
@@ -367,20 +413,56 @@ exec mangohud "$@"
return {"error": f"Failed to read log: {str(e)}"} return {"error": f"Failed to read log: {str(e)}"}
async def clear_mangohud_log(self) -> dict: 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 glob
import time
RECENT_WINDOW_S = 3
now = time.time()
deleted, skipped = [], []
try: try:
for pattern in ["/tmp/*MangoHud*", "/tmp/*.csv", "/tmp/*.log"]: for pattern in ["/tmp/*MangoHud*"]:
for f in glob.glob(pattern): for f in glob.glob(pattern):
if os.path.isfile(f): if not os.path.isfile(f):
continue
try: try:
if now - os.path.getmtime(f) < RECENT_WINDOW_S:
skipped.append({"name": os.path.basename(f), "reason": "active"})
continue
os.remove(f) os.remove(f)
deleted.append({"name": os.path.basename(f)})
except (IOError, PermissionError): except (IOError, PermissionError):
pass skipped.append({"name": os.path.basename(f), "reason": "perm"})
return {"success": True} 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: except Exception as e:
return {"success": False, "error": str(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: async def get_hardware_info(self) -> dict:
"""RPC: Detect hardware model from DMI. Returns {slug, name, raw}.""" """RPC: Detect hardware model from DMI. Returns {slug, name, raw}."""
# Steam Deck models: Jupiter = LCD, Galileo = OLED # Steam Deck models: Jupiter = LCD, Galileo = OLED
@@ -605,44 +687,14 @@ exec mangohud "$@"
except Exception as e: except Exception as e:
return {"success": False, "error": str(e), "status": 0} return {"success": False, "error": str(e), "status": 0}
async def list_screenshots(self, limit: int = 50) -> dict: async def list_screenshots(self, limit: int = 50, app_id: int | None = None) -> dict:
"""RPC: List recent Steam Deck screenshots from ~/Pictures/Screenshots/. """RPC: List recent Steam screenshots from both Game Mode (userdata/760/remote)
Returns {screenshots: [{path, name, mtime, size}], error?}. and Desktop Mode (~/Pictures/Screenshots). Returns newest first.
Steam saves timestamped JPGs in a 'Steam Client' subfolder and keeps When app_id is given, keeps all Desktop exports + that app's userdata shots."""
a 'most_recent.jpg' symlink-like copy at the top level."""
import glob
import time
try: try:
home = os.path.expanduser("~") home = os.path.expanduser("~")
base = os.path.join(home, "Pictures", "Screenshots") files = _collect_screenshots(home, app_id=app_id, limit=limit)
patterns = [ return {"screenshots": files}
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]}
except Exception as e: except Exception as e:
return {"screenshots": [], "error": str(e)} return {"screenshots": [], "error": str(e)}
@@ -671,9 +723,11 @@ exec mangohud "$@"
b64 = base64.b64encode(buf.getvalue()).decode("ascii") b64 = base64.b64encode(buf.getvalue()).decode("ascii")
return {"dataUrl": f"data:image/jpeg;base64,{b64}"} return {"dataUrl": f"data:image/jpeg;base64,{b64}"}
except ImportError: 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() ext = os.path.splitext(path)[1].lower()
mime = "image/png" if ext == ".png" else ("image/webp" if ext == ".webp" else "image/jpeg") 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") b64 = base64.b64encode(raw).decode("ascii")
return {"dataUrl": f"data:{mime};base64,{b64}"} return {"dataUrl": f"data:{mime};base64,{b64}"}
except Exception as e: except Exception as e:
@@ -788,6 +842,49 @@ exec mangohud "$@"
except Exception as e: except Exception as e:
return {"valid": False, "error": str(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: async def export_config(self, settings: dict) -> dict:
"""RPC: Export current settings to Downloads/deckyvault-config.json. """RPC: Export current settings to Downloads/deckyvault-config.json.
Returns {success: bool, path?: str, error?: str}.""" Returns {success: bool, path?: str, error?: str}."""
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "@deckyvault/plugin", "name": "@deckyvault/plugin",
"version": "1.0.0", "version": "1.1.0",
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
@@ -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 (
<PanelSectionRow>
<div
onClick={() => setExpanded((v) => !v)}
style={{ padding: "8px 10px", borderRadius: "8px", background: "rgba(255,255,255,0.06)", border: "1px solid rgba(255,255,255,0.10)", cursor: "pointer" }}
>
<div className={staticClasses.Text} style={{ display: "flex", justifyContent: "space-between", fontSize: "13px" }}>
<strong>{e.fpsAvg} FPS avg</strong>
<span style={{ opacity: 0.7 }}>{label}</span>
</div>
<div className={staticClasses.Text} style={{ fontSize: "11px", opacity: 0.6, marginTop: 2 }}>
{e.fpsLow ?? "—"} low · {e.fpsOnePercentLow ?? "—"} 1% · {e.fpsHigh ?? "—"} high
{e.tdpWatts ? ` · ${e.tdpWatts}W` : ""}
{e.upscalerType && e.upscalerType !== "none" ? ` · ${e.upscalerType}` : ""}
{e.protonVersion ? ` · Proton ${e.protonVersion}` : ""}
</div>
{expanded && (
<div className={staticClasses.Text} style={{ fontSize: "11px", opacity: 0.7, marginTop: 6, borderTop: "1px solid rgba(255,255,255,0.08)", paddingTop: 6 }}>
<div>By {e.userName ?? "unknown"} · {new Date(e.createdAt).toLocaleDateString()}</div>
<div>{settingsCount} settings</div>
{e.osVersion && <div>OS: {e.osVersion}</div>}
</div>
)}
</div>
</PanelSectionRow>
)
}
export default function LibraryAppPanel({ appId, title, hardwareSlug, baseUrl }: Props) {
const [data, setData] = useState<PluginGameResponse | null>(null)
const [devices, setDevices] = useState<PluginDeviceRow[]>([])
const [loading, setLoading] = useState(true)
const [device, setDevice] = useState<string>(hardwareSlug ?? "") // "" = all devices
const [fetchError, setFetchError] = useState<string>("")
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 (
<PanelSection title="DeckyVault">
<PanelSectionRow>
<div className={staticClasses.Text} style={{ padding: "8px 0", fontSize: "12px", opacity: 0.6 }}>Loading DeckyVault</div>
</PanelSectionRow>
</PanelSection>
)
}
const deviceOptions = [
{ label: "All devices", data: "" },
...(hardwareSlug ? [{ label: `Your device (${hardwareSlug})`, data: hardwareSlug }] : []),
...devices
.filter((d) => d.slug !== hardwareSlug)
.map((d) => ({ label: `${d.name} (${d.count})`, data: d.slug })),
]
if (fetchError) {
return (
<PanelSection title="DeckyVault">
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", padding: "6px 0", opacity: 0.7, color: "#e74c3c" }}>
<FaTimes /> Could not load DeckyVault data: {fetchError}
</div>
</PanelSectionRow>
</PanelSection>
)
}
if (!data || !data.game) {
return (
<PanelSection title="DeckyVault">
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", padding: "6px 0", opacity: 0.7 }}>
<FaTimes /> Not in DeckyVault yet. Open <strong>{title}</strong> on{" "}
<a href={`${baseUrl}/games`}>deckyvault.xyz</a> to add it.
</div>
</PanelSectionRow>
</PanelSection>
)
}
return (
<PanelSection title="DeckyVault">
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", padding: "4px 0", display: "flex", alignItems: "center", gap: 6 }}>
<FaCheck style={{ color: "#2ecc71" }} /> In DeckyVault
</div>
</PanelSectionRow>
{/* Est FPS — hidden when "All devices" selected to avoid mixing hardware */}
{device ? (
<>
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "13px", padding: "4px 0", display: "flex", alignItems: "center", gap: 6 }}>
<FaChartLine /> Est FPS
</div>
</PanelSectionRow>
{data.estFps ? (
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "13px", padding: "0 0 6px 0" }}>
<strong>{data.estFps.avg}</strong> avg · {data.estFps.low ?? "—"} low · {data.estFps.onePct ?? "—"} 1% · {data.estFps.high ?? "—"} high
<span style={{ opacity: 0.5, fontSize: "11px" }}> · {data.estFps.count} entries</span>
</div>
</PanelSectionRow>
) : (
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", opacity: 0.6, padding: "0 0 6px 0" }}>
No entries for this device yet be the first: open the DeckyVault plugin and record.
</div>
</PanelSectionRow>
)}
</>
) : (
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", opacity: 0.6, padding: "4px 0" }}>
Select a device to see estimated FPS.
</div>
</PanelSectionRow>
)}
{/* Device switcher */}
<PanelSectionRow>
<DropdownItem
label="Device"
rgOptions={deviceOptions}
selectedOption={device}
onChange={(opt) => setDevice(opt.data as string)}
/>
</PanelSectionRow>
{/* Top entries */}
{data.topEntries.length > 0 && (
<>
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "11px", opacity: 0.5, padding: "8px 0 2px 0", textTransform: "uppercase", letterSpacing: "0.05em" }}>
Top entries
</div>
</PanelSectionRow>
{data.topEntries.map((e) => <EntryCard key={e.id} e={e} />)}
</>
)}
{/* Recent entries */}
{data.recentEntries.length > 0 && (
<>
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "11px", opacity: 0.5, padding: "8px 0 2px 0", textTransform: "uppercase", letterSpacing: "0.05em" }}>
Recent entries
</div>
</PanelSectionRow>
{data.recentEntries.map((e) => <EntryCard key={e.id} e={e} />)}
</>
)}
</PanelSection>
)
}
@@ -211,6 +211,10 @@ export default function SessionForm({
setError("No API key configured. Set one in the Settings tab.") setError("No API key configured. Set one in the Settings tab.")
return 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("") setError("")
setUploadStatus("loading") setUploadStatus("loading")
setStatusMessage("Uploading entry…") setStatusMessage("Uploading entry…")
+24 -6
View File
@@ -1,3 +1,4 @@
import { useEffect } from "react"
import { import {
PanelSection, PanelSection,
PanelSectionRow, PanelSectionRow,
@@ -5,14 +6,17 @@ import {
} from "@decky/ui" } from "@decky/ui"
import { import {
definePlugin, definePlugin,
routerHook,
} from "@decky/api" } from "@decky/api"
import { FaChartLine } from "react-icons/fa" import { FaChartLine } from "react-icons/fa"
import MainPanel from "./components/main-panel" import MainPanel from "./components/main-panel"
import { registerLibraryAppPatch, setLibraryAppPanelProps } from "./patches/LibraryApp"
import { useSettings, useSession, useGameDetection } from "./lib/store" import { useSettings, useSession, useGameDetection } from "./lib/store"
import { import {
readAndParseMangohudLog, readAndParseMangohudLog,
clearMangohudLog, clearMangohudLog,
writeMangohudConfig, deleteLogFile,
findMangohudLog,
startMangohudLogging, startMangohudLogging,
stopMangohudLogging, stopMangohudLogging,
getHardwareInfo, getHardwareInfo,
@@ -23,6 +27,10 @@ import {
function Content() { function Content() {
const { settings, updateSetting, loaded } = useSettings() 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 { const {
recordingState, recordingState,
session, session,
@@ -37,6 +45,7 @@ function Content() {
onGameStart, onGameStart,
onGameStop, onGameStop,
setGameName, setGameName,
setLastLogPath,
} = useSession() } = useSession()
// ── Game detection via polling ──────────────────────────────── // ── Game detection via polling ────────────────────────────────
@@ -44,10 +53,13 @@ function Content() {
// ── Handle start recording ──────────────────────────────────── // ── Handle start recording ────────────────────────────────────
async function handleStart() { async function handleStart() {
// Write MangoHud config with logging settings // Clear the previous session's specific log if we know it; else safe-clear.
await writeMangohudConfig() const prev = session.lastLogPath
// Clear any previous log file if (prev) {
await deleteLogFile(prev)
} else {
await clearMangohudLog() await clearMangohudLog()
}
// Fire-and-forget: try to start MangoHud logging (retries until game launches) // Fire-and-forget: try to start MangoHud logging (retries until game launches)
startMangohudLogging() startMangohudLogging()
startRecording() startRecording()
@@ -60,12 +72,15 @@ function Content() {
await stopMangohudLogging() await stopMangohudLogging()
stopRecording() stopRecording()
// Parse the MangoHud log // Find the most recent MangoHud log, parse it, remember its path
const logResult = await readAndParseMangohudLog() const logPath = await findMangohudLog()
const logResult = await readAndParseMangohudLog(logPath.path ?? undefined)
if (logResult.error) { if (logResult.error) {
setError(logResult.error) setError(logResult.error)
setLastLogPath(null)
return return
} }
setLastLogPath(logPath.path ?? null)
// Read system info in parallel // Read system info in parallel
const [hwInfo, osVersion] = await Promise.all([ const [hwInfo, osVersion] = await Promise.all([
@@ -191,6 +206,8 @@ function DeckyVaultIcon() {
} }
export default definePlugin(() => { export default definePlugin(() => {
const libraryAppPatch = registerLibraryAppPatch()
return { return {
name: "DeckyVault", name: "DeckyVault",
titleView: <div className={staticClasses.Title}>DeckyVault</div>, titleView: <div className={staticClasses.Title}>DeckyVault</div>,
@@ -198,6 +215,7 @@ export default definePlugin(() => {
icon: <DeckyVaultIcon />, icon: <DeckyVaultIcon />,
alwaysRender: true, alwaysRender: true,
onDismount() { onDismount() {
try { routerHook.removePatch("/library/app/:appid", libraryAppPatch) } catch (e) { console.error("[DeckyVault] removePatch failed:", e) }
console.log("[DeckyVault] Plugin unloading") console.log("[DeckyVault] Plugin unloading")
}, },
} }
+17 -2
View File
@@ -36,9 +36,21 @@ export const readAndParseMangohudLog = callable<[logPath?: string], {
export const clearMangohudLog = callable<[], { export const clearMangohudLog = callable<[], {
success: boolean success: boolean
deleted?: Array<{ name: string }>
skipped?: Array<{ name: string; reason: string }>
error?: string error?: string
}>("clear_mangohud_log") }>("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<[], { export const startMangohudLogging = callable<[], {
success: boolean success: boolean
error?: string error?: string
@@ -81,8 +93,8 @@ export const uploadToDeckyvault = callable<[
status?: number status?: number
}>("upload_to_deckyvault") }>("upload_to_deckyvault")
export const listScreenshots = callable<[limit?: number], { export const listScreenshots = callable<[limit?: number, appId?: number], {
screenshots: Array<{ path: string; name: string; mtime: number; size: number }> screenshots: Array<{ path: string; name: string; mtime: number; size: number; appId: number | null }>
error?: string error?: string
}>("list_screenshots") }>("list_screenshots")
@@ -155,3 +167,6 @@ export const checkPairStatus = callable<[token: string, baseUrl?: string], {
keyName?: string keyName?: string
error?: string error?: string
}>("check_pair_status") }>("check_pair_status")
// ── Plugin API Proxy ────────────────────────────────────────────
export const pluginGet = callable<[path: string, baseUrl?: string], Record<string, unknown> & { status?: number; error?: string }>("plugin_get")
+77
View File
@@ -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<string, CacheEntry>()
const TTL_MS = 60 * 60 * 1000
const settingsRef: { baseUrl: string } = { baseUrl: "https://deckyvault.xyz" }
export function setPluginApiBaseUrl(url: string) {
settingsRef.baseUrl = url || "https://deckyvault.xyz"
}
export async function fetchPluginGame(
steamAppId: number,
hardware: string | null,
limit: number,
): Promise<PluginGameResponse> {
const key = `${steamAppId}|${hardware ?? "all"}|${limit}`
const hit = cache.get(key)
if (hit && hit.expires > Date.now()) return hit.value
const path = `/plugin/game/${steamAppId}?limit=${limit}${hardware ? `&hardware=${encodeURIComponent(hardware)}` : ""}`
const raw = await pluginGet(path, settingsRef.baseUrl)
const value = raw as unknown as PluginGameResponse
// 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<PluginDeviceRow[]> {
const raw = await pluginGet(`/plugin/game/${steamAppId}/devices`, settingsRef.baseUrl)
if (raw.error) return []
return (raw.devices as PluginDeviceRow[]) ?? []
}
export function clearPluginCache(steamAppId?: number) {
if (steamAppId == null) { cache.clear(); return }
for (const k of cache.keys()) {
if (k.startsWith(`${steamAppId}|`)) cache.delete(k)
}
}
+26 -4
View File
@@ -36,6 +36,8 @@ export interface SessionData {
protonVersion: string protonVersion: string
versionString: string versionString: string
buildId: 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) // Manual inputs (filled by user in the form)
upscalerType: string upscalerType: string
upscalerVersion: string upscalerVersion: string
@@ -70,6 +72,7 @@ function createEmptySession(): SessionData {
protonVersion: "", protonVersion: "",
versionString: "", versionString: "",
buildId: "", buildId: "",
lastLogPath: null,
upscalerType: "none", upscalerType: "none",
upscalerVersion: "", upscalerVersion: "",
frameGenMethod: "none", frameGenMethod: "none",
@@ -182,6 +185,10 @@ export function useSession() {
setSession((prev) => ({ ...prev, gameName: name, appId: appId ?? prev.appId })) setSession((prev) => ({ ...prev, gameName: name, appId: appId ?? prev.appId }))
}, []) }, [])
const setLastLogPath = useCallback((p: string | null) => {
setSession((prev) => ({ ...prev, lastLogPath: p }))
}, [])
return { return {
recordingState, recordingState,
session, session,
@@ -196,6 +203,7 @@ export function useSession() {
onGameStart, onGameStart,
onGameStop, onGameStop,
setGameName, setGameName,
setLastLogPath,
} }
} }
@@ -235,6 +243,20 @@ export function useGameDetection(
return { detecting } 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 ───────────────────────────────────────────── // ── Payload Builder ─────────────────────────────────────────────
export function buildImportPayload(sess: SessionData): DeckyVaultImportV1 { export function buildImportPayload(sess: SessionData): DeckyVaultImportV1 {
@@ -242,10 +264,10 @@ export function buildImportPayload(sess: SessionData): DeckyVaultImportV1 {
version: 1, version: 1,
steamAppId: sess.appId ?? 0, steamAppId: sess.appId ?? 0,
hardwareSlug: sess.hardwareSlug, hardwareSlug: sess.hardwareSlug,
fpsAvg: sess.fpsAvg ?? 0, fpsAvg: sess.fpsAvg == null || !(sess.fpsAvg > 0) ? 0 : sanitizeFps(sess.fpsAvg)!,
fpsLow: sess.fpsLow, fpsLow: sanitizeFps(sess.fpsLow),
fpsOnePercentLow: sess.fpsOnePercentLow, fpsOnePercentLow: sanitizeFps(sess.fpsOnePercentLow),
fpsHigh: sess.fpsHigh, fpsHigh: sanitizeFps(sess.fpsHigh),
protonVersion: sess.protonVersion || null, protonVersion: sess.protonVersion || null,
osVersion: sess.osVersion || null, osVersion: sess.osVersion || null,
versionString: sess.versionString || null, versionString: sess.versionString || null,
@@ -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<string, unknown>[], ret: ReactElement) => {
try {
const container = findInReactTree(
ret,
(x: any) =>
Array.isArray(x?.props?.children) &&
x?.props?.className?.includes(appDetailsClasses.InnerContainer),
)
const arr = container?.props?.children
if (!Array.isArray(arr)) {
console.debug("[DeckyVault] app-details: no splicable container (non-game page?)")
return ret
}
const idx = arr.findIndex((child: 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,
<LibraryAppPanel
appId={overview?.appid}
title={overview?.display_name ?? ""}
hardwareSlug={panelProps.hardwareSlug}
baseUrl={panelProps.baseUrl}
/>,
)
} else {
console.debug("[DeckyVault] app-details: splicing anchor not found")
}
} catch (err) {
console.error("[DeckyVault] app-details splice failed:", err)
}
return ret
},
)
afterPatch(routeProps, "renderFunc", patchHandler)
} catch (err) {
console.error("[DeckyVault] library patch failed (degraded):", err)
}
return routerTree
})
}
+156
View File
@@ -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)
@@ -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)