Merge branch 'dev' into prod
This commit is contained in:
@@ -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")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -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) =>
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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 ────────────────────
|
||||||
|
|||||||
@@ -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).
|
||||||
+143
-46
@@ -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):
|
||||||
try:
|
continue
|
||||||
os.remove(f)
|
try:
|
||||||
except (IOError, PermissionError):
|
if now - os.path.getmtime(f) < RECENT_WINDOW_S:
|
||||||
pass
|
skipped.append({"name": os.path.basename(f), "reason": "active"})
|
||||||
return {"success": True}
|
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:
|
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,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…")
|
||||||
|
|||||||
@@ -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 clearMangohudLog()
|
await deleteLogFile(prev)
|
||||||
|
} else {
|
||||||
|
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")
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
Reference in New Issue
Block a user