From 835fab260077feb03dab253fd61a48352a0c69ec Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sun, 28 Jun 2026 05:33:13 +0800 Subject: [PATCH 01/30] chore: add .worktrees/ to gitignore --- .gitignore | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.gitignore b/.gitignore index ef07c1c..45cf206 100644 --- a/.gitignore +++ b/.gitignore @@ -42,4 +42,10 @@ yarn-error.log* .superpowers/ # docs/superpowers -/docs/superpowers \ No newline at end of file +/docs/superpowers + +# git worktrees +.worktrees/ + +# stray +post.md \ No newline at end of file From a63cc52108710a71e8108116eae64c3cde3262d8 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sun, 28 Jun 2026 05:41:45 +0800 Subject: [PATCH 02/30] fix: upgrade better-auth to v1.6.22 for api-key plugin compatibility --- apps/web/package.json | 2 +- bun.lock | 43 +++++++++++++++++++++++++++---------------- package.json | 3 +++ 3 files changed, 31 insertions(+), 17 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 0aafd7c..03dcf94 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -37,7 +37,7 @@ "@tiptap/pm": "^3.22.4", "@tiptap/react": "^3.22.4", "@tiptap/starter-kit": "^3.22.4", - "better-auth": "^1.6.9", + "better-auth": "^1.6.22", "dotenv": "^17.4.2", "drizzle-orm": "^0.45.2", "echarts": "^6.0.0", diff --git a/bun.lock b/bun.lock index 3a6682f..76449cf 100644 --- a/bun.lock +++ b/bun.lock @@ -4,6 +4,9 @@ "workspaces": { "": { "name": "deckyvault", + "dependencies": { + "better-auth": "^1.6.22", + }, }, "apps/web": { "name": "@deckyvault/web", @@ -29,7 +32,7 @@ "@tiptap/pm": "^3.22.4", "@tiptap/react": "^3.22.4", "@tiptap/starter-kit": "^3.22.4", - "better-auth": "^1.6.9", + "better-auth": "^1.6.22", "dotenv": "^17.4.2", "drizzle-orm": "^0.45.2", "echarts": "^6.0.0", @@ -216,27 +219,27 @@ "@better-auth/api-key": ["@better-auth/api-key@1.6.22", "", { "dependencies": { "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.6.22", "@better-auth/utils": "0.4.2", "better-auth": "^1.6.22", "better-call": "1.3.7" } }, "sha512-HDiiLYF0ov0zqhKv4CMTyLwpjTZ3UWl2dug451uTw40VM9zGxWSNxwILDcMDZ6hS5evaTHmmk7R5gciskEk2nQ=="], - "@better-auth/core": ["@better-auth/core@1.6.9", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.5", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-ADFk5pwmLybmc+LvYvXJ6M1x2oY/EyYLkwLuH0x28FUq12DfjL0wnE7g+WRDf3yozDO+qIxTpFGXDGwLKbfz0w=="], + "@better-auth/core": ["@better-auth/core@1.6.22", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.39.0", "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@cloudflare/workers-types": ">=4", "@opentelemetry/api": "^1.9.0", "better-call": "1.3.7", "jose": "^6.1.0", "kysely": "^0.28.5 || ^0.29.0", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types", "@opentelemetry/api"] }, "sha512-aFH/5nzmR501jAJPKjJfiVg4BrkcjVCqq9WS9JnhTruE/2PIWopv1QGMiRIRqxXaPbHczri7cqRdhep3Lg5PMw=="], - "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.6.9", "", { "peerDependencies": { "@better-auth/core": "^1.6.9", "@better-auth/utils": "0.4.0", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["drizzle-orm"] }, "sha512-Lcco5hOGrMgc4XKAkvB6x72eQm4wCcya8IevMg4wBHY9W9GVg8pu23rpRX6VsVQSO4Ux13S7lFwUWtF7/r9aKw=="], + "@better-auth/drizzle-adapter": ["@better-auth/drizzle-adapter@1.6.22", "", { "peerDependencies": { "@better-auth/core": "^1.6.22", "@better-auth/utils": "0.4.2", "drizzle-orm": "^0.45.2" }, "optionalPeers": ["drizzle-orm"] }, "sha512-uNa9qH53CfxBmuKP8kbLWxY90oIRwUPn5BcHhO+szK05e2yh6EYwSNNivDSqV3YG5HPfjtPHulklInjq1wtm3w=="], "@better-auth/expo": ["@better-auth/expo@1.6.11", "", { "dependencies": { "@better-fetch/fetch": "1.1.21", "better-call": "1.3.5", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.6.11", "better-auth": "^1.6.11", "expo-constants": ">=17.0.0", "expo-linking": ">=7.0.0", "expo-network": ">=8.0.7", "expo-web-browser": ">=14.0.0" }, "optionalPeers": ["expo-constants", "expo-linking", "expo-network", "expo-web-browser"] }, "sha512-ahqtpj5DRF4Tu8+PZuLPkR10Q6b8AntQNsn4LPcOIp6za5IJDAsaD/go1I5qCYIRi+8YKiIXk9vh4qQM54u+hA=="], - "@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.6.9", "", { "peerDependencies": { "@better-auth/core": "^1.6.9", "@better-auth/utils": "0.4.0", "kysely": "^0.28.14" }, "optionalPeers": ["kysely"] }, "sha512-gyjuuxJtZ4o9G9z9q4kqn24X2kvMSp7F+KHogYxF03SnXY/2WleAcuj57iC4wP3e9mGDbjPOrnM5K6Kr3Ktdpw=="], + "@better-auth/kysely-adapter": ["@better-auth/kysely-adapter@1.6.22", "", { "peerDependencies": { "@better-auth/core": "^1.6.22", "@better-auth/utils": "0.4.2", "kysely": "^0.28.17 || ^0.29.0" }, "optionalPeers": ["kysely"] }, "sha512-4k/07lPRizlQi+B+uOE5CwTfH3w+Lq8ZDX1nDN1+e+glRVKAIfHoLvC9cfAVcCbio3DDrl0RTbwjLwjEhG0LxA=="], - "@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.6.9", "", { "peerDependencies": { "@better-auth/core": "^1.6.9", "@better-auth/utils": "0.4.0" } }, "sha512-XmIG4tUnOXZ+KEcWjHUjOI9Z5donD09dC2t/AQTXifAUIqx7cySg86w0KTM09ArzAxRx1fCqO36Wkt5nULnrkQ=="], + "@better-auth/memory-adapter": ["@better-auth/memory-adapter@1.6.22", "", { "peerDependencies": { "@better-auth/core": "^1.6.22", "@better-auth/utils": "0.4.2" } }, "sha512-rbepe/gHhWs0aF4fAu6+l+wNPJxT9XN4U+Hqa1Y/5HhjtT9y5evo3INrSWlkIOymsMaQ0cBPrSL5pm9Z195hcA=="], - "@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.6.9", "", { "peerDependencies": { "@better-auth/core": "^1.6.9", "@better-auth/utils": "0.4.0", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-h+AiRJ/TsBSi+ZDjySASBpbJ/9QCXBre34PSKgCz7QmTHrFM9Cg2EM4AM7LjR5lPXipEE+2rWPBc9wfnUBjhcw=="], + "@better-auth/mongo-adapter": ["@better-auth/mongo-adapter@1.6.22", "", { "peerDependencies": { "@better-auth/core": "^1.6.22", "@better-auth/utils": "0.4.2", "mongodb": "^6.0.0 || ^7.0.0" }, "optionalPeers": ["mongodb"] }, "sha512-OYnfySHlVkIx7y6XNBsCHjKhl6IYGprRkFwifc/TAuPBVjoRKhLKRAXuzMdWTQYFt4FYb6holbhjNUrXxPIWcw=="], "@better-auth/passkey": ["@better-auth/passkey@1.6.9", "", { "dependencies": { "@simplewebauthn/browser": "^13.2.2", "@simplewebauthn/server": "^13.2.3", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/core": "^1.6.9", "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", "better-auth": "^1.6.9", "better-call": "1.3.5", "nanostores": "^1.0.1" } }, "sha512-MFpi+2G/pG2wVcTuL/PcnWxP2ddFL4jmFByTCbgvr61tp7u96d5liBptxpTqfS5IuCi2o8bBRmjiQnDZAvxmHg=="], - "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.6.9", "", { "peerDependencies": { "@better-auth/core": "^1.6.9", "@better-auth/utils": "0.4.0", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-XHks01ntK20orqK/jICq8wmEbJ/zT6dct49Fk8zTQKN9QNGDc+Ix5+7z/Kvui0DXGFf790GfvRozquzaLtXa8Q=="], + "@better-auth/prisma-adapter": ["@better-auth/prisma-adapter@1.6.22", "", { "peerDependencies": { "@better-auth/core": "^1.6.22", "@better-auth/utils": "0.4.2", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0" }, "optionalPeers": ["@prisma/client", "prisma"] }, "sha512-I6lWQwLva732V600u5dLM2kRcQ94pRZOVVfZ87+Ow9RBxMUnV+I+YQ5h2yggdN2tmsmITacZTy1DSZbDxGu0LQ=="], - "@better-auth/telemetry": ["@better-auth/telemetry@1.6.9", "", { "peerDependencies": { "@better-auth/core": "^1.6.9", "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21" } }, "sha512-0u5zkhSCAQFoN3DHvUkLHOF6MBbVTDAa6mU8mhPwiysdz1x21vMzhzfaAKN/ZGWaQ09v91/F+2qu42G/bhUV4A=="], + "@better-auth/telemetry": ["@better-auth/telemetry@1.6.22", "", { "peerDependencies": { "@better-auth/core": "^1.6.22", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1" } }, "sha512-glq/oEk9qP+zGh9k/WUH5+pwvBCMolNNhaAVBCtYQrkADFee2gP3VoPs1YeO9coNuOmBhc+AYSIHs+fL9DoJnw=="], - "@better-auth/utils": ["@better-auth/utils@0.4.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA=="], + "@better-auth/utils": ["@better-auth/utils@0.4.2", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-AUxrvu+HaaODsUyzDxFgwd/8RZ1yZaYo42LXKSrU2oGgR38pS1ij8nqQKNgtTWoYGpNevNXtCfgTy6loHveW9A=="], - "@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="], + "@better-fetch/fetch": ["@better-fetch/fetch@1.3.1", "", {}, "sha512-ABkD1WhyfPZprKRQI3bhATjeiFuNWC9PXhfGWqL+sg/gKrM977oFrYkdb4msM3hgUGonr7KlOsOFT5TU2rht9g=="], "@borewit/text-codec": ["@borewit/text-codec@0.2.2", "", {}, "sha512-DDaRehssg1aNrH4+2hnj1B7vnUGEjU6OIlyRdkMd0aUdIUvKXrJfXsy8LVtXAy7DRvYVluWbMspsRhz2lcW0mQ=="], @@ -950,9 +953,9 @@ "baseline-browser-mapping": ["baseline-browser-mapping@2.10.21", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-Q+rUQ7Uz8AHM7DEaNdwvfFCTq7a43lNTzuS94eiWqwyxfV/wJv+oUivef51T91mmRY4d4A1u9rcSvkeufCVXlA=="], - "better-auth": ["better-auth@1.6.9", "", { "dependencies": { "@better-auth/core": "1.6.9", "@better-auth/drizzle-adapter": "1.6.9", "@better-auth/kysely-adapter": "1.6.9", "@better-auth/memory-adapter": "1.6.9", "@better-auth/mongo-adapter": "1.6.9", "@better-auth/prisma-adapter": "1.6.9", "@better-auth/telemetry": "1.6.9", "@better-auth/utils": "0.4.0", "@better-fetch/fetch": "1.1.21", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.5", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.14", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": "^0.45.2", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-EBFURtglyiEZxbx4NJBoqUD8J65dX24yC+6I9AUbIXNgUkt76mshzGbHkxZ3n/lB7Dwq3kBC+hHt0hUQsnL7HA=="], + "better-auth": ["better-auth@1.6.22", "", { "dependencies": { "@better-auth/core": "1.6.22", "@better-auth/drizzle-adapter": "1.6.22", "@better-auth/kysely-adapter": "1.6.22", "@better-auth/memory-adapter": "1.6.22", "@better-auth/mongo-adapter": "1.6.22", "@better-auth/prisma-adapter": "1.6.22", "@better-auth/telemetry": "1.6.22", "@better-auth/utils": "0.4.2", "@better-fetch/fetch": "1.3.1", "@noble/ciphers": "^2.1.1", "@noble/hashes": "^2.0.1", "better-call": "1.3.7", "defu": "^6.1.4", "jose": "^6.1.3", "kysely": "^0.28.17 || ^0.29.0", "nanostores": "^1.1.1", "zod": "^4.3.6" }, "peerDependencies": { "@lynx-js/react": "*", "@prisma/client": "^5.0.0 || ^6.0.0 || ^7.0.0", "@sveltejs/kit": "^2.0.0", "@tanstack/react-start": "^1.0.0", "@tanstack/solid-start": "^1.0.0", "better-sqlite3": "^12.0.0", "drizzle-kit": ">=0.31.4", "drizzle-orm": "^0.45.2", "mongodb": "^6.0.0 || ^7.0.0", "mysql2": "^3.0.0", "next": "^14.0.0 || ^15.0.0 || ^16.0.0", "pg": "^8.0.0", "prisma": "^5.0.0 || ^6.0.0 || ^7.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0", "solid-js": "^1.0.0", "svelte": "^4.0.0 || ^5.0.0", "vitest": "^2.0.0 || ^3.0.0 || ^4.0.0", "vue": "^3.0.0" }, "optionalPeers": ["@lynx-js/react", "@prisma/client", "@sveltejs/kit", "@tanstack/react-start", "@tanstack/solid-start", "better-sqlite3", "drizzle-kit", "drizzle-orm", "mongodb", "mysql2", "next", "pg", "prisma", "react", "react-dom", "solid-js", "svelte", "vitest", "vue"] }, "sha512-B5s6+lPsDWp8rGLRnvNyr5h9tftG9zLRjNrlkEJdYRhcuhPhJiw9b8o6ibgxEFpSAdUqoDaP5/FLqfu8QsXIVg=="], - "better-call": ["better-call@1.3.5", "", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA=="], + "better-call": ["better-call@1.3.7", "", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-Al51/hjp2SSp6CRTa3F2ptcx4yQVS1xWKoY6jcVXqNYOap6mHFP2jUBn5EwIL4iIed1/Sq4hlQ+Umm6EflZG+w=="], "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], @@ -1334,7 +1337,7 @@ "kolorist": ["kolorist@1.8.0", "", {}, "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ=="], - "kysely": ["kysely@0.28.16", "", {}, "sha512-3i5pmOiZvMDj00qhrIVbH0AnioVTx22DMP7Vn5At4yJO46iy+FM8Y/g61ltenLVSo3fiO8h8Q3QOFgf/gQ72ww=="], + "kysely": ["kysely@0.29.2", "", {}, "sha512-s6WVJyEZrbm6jhBpiKHsGHyePMrVQKJ85wZCFCr9W4QHv6WTjWIrdvTmO9hDEA3bNK0xkrE2DqrHsXMLWuZpQg=="], "language-subtag-registry": ["language-subtag-registry@0.3.23", "", {}, "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ=="], @@ -1872,7 +1875,15 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], - "@better-auth/core/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], + "@better-auth/expo/@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="], + + "@better-auth/expo/better-call": ["better-call@1.3.5", "", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA=="], + + "@better-auth/passkey/@better-auth/utils": ["@better-auth/utils@0.4.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA=="], + + "@better-auth/passkey/@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="], + + "@better-auth/passkey/better-call": ["better-call@1.3.5", "", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA=="], "@better-auth/passkey/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], @@ -1906,8 +1917,6 @@ "@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], - "better-auth/zod": ["zod@4.3.6", "", {}, "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg=="], - "echarts/tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="], "eslint/@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], @@ -1952,6 +1961,8 @@ "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + "@better-auth/expo/better-call/@better-auth/utils": ["@better-auth/utils@0.4.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA=="], + "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="], "@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="], diff --git a/package.json b/package.json index f16ea4c..93b4814 100644 --- a/package.json +++ b/package.json @@ -20,5 +20,8 @@ "overrides": { "@types/react": "19.2.14", "@types/react-dom": "19.2.3" + }, + "dependencies": { + "better-auth": "^1.6.22" } } \ No newline at end of file From 4cf8a427424472c2bea8639b8c0836197edcc7dd Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sun, 28 Jun 2026 05:44:46 +0800 Subject: [PATCH 03/30] fix: add zod as direct dependency for bun workspace resolution --- apps/web/package.json | 3 ++- bun.lock | 17 ++++++++++++++++- 2 files changed, 18 insertions(+), 2 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 03dcf94..5d4c4dd 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -59,7 +59,8 @@ "resend": "^6.12.2", "serwist": "^9.5.11", "sharp": "^0.34.5", - "web-haptics": "^0.0.6" + "web-haptics": "^0.0.6", + "zod": "^4.4.3" }, "devDependencies": { "@tailwindcss/postcss": "^4", diff --git a/bun.lock b/bun.lock index 76449cf..50467fd 100644 --- a/bun.lock +++ b/bun.lock @@ -55,6 +55,7 @@ "serwist": "^9.5.11", "sharp": "^0.34.5", "web-haptics": "^0.0.6", + "zod": "^4.4.3", }, "devDependencies": { "@tailwindcss/postcss": "^4", @@ -1853,7 +1854,7 @@ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], - "zod": ["zod@4.4.1", "", {}, "sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q=="], + "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "zod-validation-error": ["zod-validation-error@4.0.2", "", { "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" } }, "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ=="], @@ -1875,10 +1876,16 @@ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "@better-auth/api-key/zod": ["zod@4.4.1", "", {}, "sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q=="], + + "@better-auth/core/zod": ["zod@4.4.1", "", {}, "sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q=="], + "@better-auth/expo/@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="], "@better-auth/expo/better-call": ["better-call@1.3.5", "", { "dependencies": { "@better-auth/utils": "^0.4.0", "@better-fetch/fetch": "^1.1.21", "rou3": "^0.7.12", "set-cookie-parser": "^3.0.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-kOFJkBP7utAQLEYrobZm3vkTH8mXq5GNgvjc5/XEST1ilVHaxXUXfeDeFlqoETMtyqS4+3/h4ONX2i++ebZrvA=="], + "@better-auth/expo/zod": ["zod@4.4.1", "", {}, "sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q=="], + "@better-auth/passkey/@better-auth/utils": ["@better-auth/utils@0.4.0", "", { "dependencies": { "@noble/hashes": "^2.0.1" } }, "sha512-RpMtLUIQAEWMgdPLNVbIF5ON2mm+CH0U3rCdUCU1VyeAUui4m38DyK7/aXMLZov2YDjG684pS1D0MBllrmgjQA=="], "@better-auth/passkey/@better-fetch/fetch": ["@better-fetch/fetch@1.1.21", "", {}, "sha512-/ImESw0sskqlVR94jB+5+Pxjf+xBwDZF/N5+y2/q4EqD7IARUTSpPfIo8uf39SYpCxyOCtbyYpUrZ3F/k0zT4A=="], @@ -1897,6 +1904,12 @@ "@rolldown/binding-wasm32-wasi/@napi-rs/wasm-runtime": ["@napi-rs/wasm-runtime@1.1.4", "", { "dependencies": { "@tybys/wasm-util": "^0.10.1" }, "peerDependencies": { "@emnapi/core": "^1.7.1", "@emnapi/runtime": "^1.7.1" } }, "sha512-3NQNNgA1YSlJb/kMH1ildASP9HW7/7kYnRI2szWJaofaS1hWmbGI4H+d3+22aGzXXN9IJ+n+GiFVcGipJP18ow=="], + "@serwist/build/zod": ["zod@4.4.1", "", {}, "sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q=="], + + "@serwist/next/zod": ["zod@4.4.1", "", {}, "sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q=="], + + "@serwist/webpack-plugin/zod": ["zod@4.4.1", "", {}, "sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q=="], + "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" }, "bundled": true }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/runtime": ["@emnapi/runtime@1.10.0", "", { "dependencies": { "tslib": "^2.4.0" }, "bundled": true }, "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA=="], @@ -1917,6 +1930,8 @@ "@vitest/mocker/estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + "better-auth/zod": ["zod@4.4.1", "", {}, "sha512-a6ENMBBGZBsnlSebQ/eKCguSBeGKSf4O7BPnqVPmYGtpBYI7VSqoVqw+QcB7kPRjbqPwhYTpFbVj/RqNz/CT0Q=="], + "echarts/tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="], "eslint/@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], From eaa7c85e09a13b692a63fd337be02d775300f992 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sun, 28 Jun 2026 05:55:00 +0800 Subject: [PATCH 04/30] fix: prevent passkey conditional ui unhandled rejection blocking login --- apps/web/components/auth/login-form.tsx | 31 ++++++++++++++++--------- 1 file changed, 20 insertions(+), 11 deletions(-) diff --git a/apps/web/components/auth/login-form.tsx b/apps/web/components/auth/login-form.tsx index 8dbfddd..50cd0da 100644 --- a/apps/web/components/auth/login-form.tsx +++ b/apps/web/components/auth/login-form.tsx @@ -57,23 +57,32 @@ export default function LoginForm() { // Preload passkeys for conditional UI — must be called on mount when // both email + password fields are in the DOM. + // Note: WebAuthn conditional mediation can throw NetworkError on pages + // with self-signed certs (common in dev). We catch and suppress it. useEffect(() => { mountedRef.current = true if ("PublicKeyCredential" in window && !passkeyInitiatedRef.current) { passkeyInitiatedRef.current = true suppressPasskeyErrors = true - authClient.signIn.passkey({ - autoFill: true, - fetchOptions: { - onSuccess: handleLoginSuccess, - }, - }).catch((err) => { - if (!isWebAuthnAbortError(err)) { - console.warn("[passkey-conditional-ui]", err) + + const startPasskeyAutoFill = async () => { + try { + await authClient.signIn.passkey({ + autoFill: true, + fetchOptions: { + onSuccess: handleLoginSuccess, + }, + }) + } catch (err) { + if (!isWebAuthnAbortError(err)) { + console.warn("[passkey-conditional-ui]", err) + } + } finally { + suppressPasskeyErrors = false } - }).finally(() => { - suppressPasskeyErrors = false - }) + } + + startPasskeyAutoFill() } return () => { mountedRef.current = false } }, [handleLoginSuccess]) From 927bbe13a34df7a8fdefdcf58c7ceb59eb9b58c3 Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sun, 28 Jun 2026 07:38:41 +0800 Subject: [PATCH 05/30] docs: add DeckyVault plugin design spec --- .../2026-06-28-decky-vault-plugin-design.md | 206 ++++++++++++++++++ 1 file changed, 206 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-28-decky-vault-plugin-design.md diff --git a/docs/superpowers/specs/2026-06-28-decky-vault-plugin-design.md b/docs/superpowers/specs/2026-06-28-decky-vault-plugin-design.md new file mode 100644 index 0000000..fc11a8c --- /dev/null +++ b/docs/superpowers/specs/2026-06-28-decky-vault-plugin-design.md @@ -0,0 +1,206 @@ +# DeckyVault Decky Loader Plugin — Design Spec + +## Overview + +A Decky Loader plugin that records Steam Deck (and other Linux handheld) performance metrics during gameplay, then exports or uploads them to DeckyVault as benchmark entries. + +The plugin uses **MangoHud** for FPS/power logging, reads **system files** for hardware/OS/Proton detection, and lets the user fill in the few fields that can't be auto-detected (upscaler, frame gen, in-game settings, load times). + +## Architecture + +``` +plugins/decky-vault/src/ +├── index.tsx # Entry point — registers plugin with Decky Loader +├── components/ +│ ├── main-panel.tsx # Record/Stop button, session status, recent recordings +│ ├── session-form.tsx # Post-session summary with manual inputs + export/upload +│ └── settings-panel.tsx # API key, export path, hardware, MangoHud setup +├── lib/ +│ ├── mangohud.ts # Start/stop MangoHud logging, parse log for FPS stats +│ ├── system-info.ts # Read hardware model, OS version, Proton version, TDP +│ ├── api-client.ts # Upload to DeckyVault via API key +│ ├── exporter.ts # Save .deckyvault.json to disk +│ └── store.ts # Plugin state (settings, current session, recent sessions) +└── types.d.ts # Decky Loader API declarations (exists) +``` + +Each module has one clear purpose: + +- **`mangohud.ts`** — writes MangoHud logging config, reads/parses the log file after a session. Computes FPS avg/min/max/1% low and average TDP. +- **`system-info.ts`** — reads `/sys/class/dmi/id/product_name` for hardware detection, `SteamClient.System.GetOSVersion()` for OS, Steam app info for Proton version. +- **`api-client.ts`** — wraps `fetch` calls to DeckyVault's `/api/performance/import` with `x-api-key` header. Also calls `/api/games/lookup` to verify the API key. +- **`exporter.ts`** — builds a `DeckyVaultImportV1` object and writes it as JSON to the configured export path. +- **`store.ts`** — holds plugin settings (API key, export path, hardware override) and session state (recording status, current app ID, start time, parsed results). Also persists the last 5 recent sessions to Decky Loader's plugin storage so they survive plugin reloads. The single source of truth that components read from. + +## Recording Flow + +### Start Recording + +1. User presses "Start Recording" in the plugin panel. +2. Plugin writes a MangoHud config that enables logging. The config sets `output_folder=/tmp`, `output_file=deckyvault-mangohud.log`, and enables: `fps`, `frame_timing`, `cpu_power`, `gpu_power`, `cpu_temp`, `gpu_temp`. MangoHud's log includes a summary section with benchmark percentiles (configurable via `benchmark_percentiles`, default `97,AVG,1,0.1`). The plugin can parse either the raw frame data or the summary section for FPS stats. +3. Plugin records the current timestamp and (if a game is running) the active app ID via `SteamClient.Apps`. +4. Plugin checks if MangoHud is running. If not, shows a warning guiding the user to enable MangoHud for their game (via Steam launch options `mangohud %command%` or the Decky MangoHud toggle). Recording continues regardless — the log file will be populated once MangoHud is active. + +### During Recording + +- The main panel shows a live status: "Recording — [game name]" with an elapsed timer. +- The MangoHud log file accumulates FPS and power samples in the background. +- User plays their game normally. + +### Stop Recording + +1. User presses "Stop Recording". +2. Plugin reads the MangoHud log file at `/tmp/deckyvault-mangohud.log` and computes: + - `fpsAvg` — mean of all FPS samples + - `fpsLow` — minimum FPS + - `fpsHigh` — maximum FPS + - `fpsOnePercentLow` — 1st percentile of frame times, converted to FPS + - `tdpWatts` — average power draw across samples (if logged) +3. Plugin reads system info: + - `hardwareSlug` — from `/sys/class/dmi/id/product_name` ("Jupiter" → `steamdeck-lcd`, "Galileo" → `steamdeck-oled`; falls back to user-configured default) + - `osVersion` — `SteamClient.System.GetOSVersion()` + - `protonVersion` — from the Steam app info or process environment +4. Plugin transitions to the session summary form. + +### Edge Cases + +- **No MangoHud log found** → error: "MangoHud logging not detected. Make sure MangoHud is enabled for this game." with a link to the Settings → MangoHud Setup section. +- **Log file is empty** → error: "Recording was too short or MangoHud didn't capture data. Try again." +- **No game running when Record pressed** → allowed, but shows a warning that game info won't be auto-filled. User can still manually enter the Steam App ID in the form. +- **MangoHud log parsing fails** (corrupt/unexpected format) → error with raw log preview, fall back to manual FPS entry in the form. + +## Session Summary Form + +After stopping, the plugin shows a form with two sections: + +### Auto-Captured (read-only summary card) + +- Game name + Steam App ID +- FPS: avg / min / 1% low / max +- TDP (watts, average) +- Hardware (e.g. "Steam Deck OLED") +- OS version +- Proton version + +### Manual Inputs (user fills in) + +| Field | Type | Required | Notes | +|---|---|---|---| +| Upscaler type | dropdown | yes (default: None) | None / FSR / DLSS / XeSS / LSFG / Other | +| Upscaler version | text | no | e.g. "2.4" | +| Frame gen method | dropdown | yes (default: None) | None / FSR FG / DLSS FG / LSFG / Other | +| In-game settings | free text (stored as JSON array) | no | preset, graphics quality, resolution, etc. Maps to `settingsJson` in the import format. | +| Load time (SSD) | number (seconds) | no | | +| Load time (SD card) | number (seconds) | no | | +| Launch options | text | no | auto-filled from Steam if available | +| User notes | textarea | no | max 5000 chars | + +### Export / Upload Actions + +Two buttons at the bottom of the form: + +- **Export to File** — builds a `DeckyVaultImportV1` payload and saves it as `[game-name]-[date].deckyvault.json` to the configured export path. +- **Upload to DeckyVault** — builds the same payload and POSTs to `/api/performance/import` with the `x-api-key` header. Shows success or error response. + +Both build the identical `DeckyVaultImportV1` payload. If upload fails (invalid API key, game not in DeckyVault DB, network error), the error message is shown inline and the user can retry or fall back to file export. + +## Plugin UI Layout + +Two tabs in the Decky Loader Quick Access panel: + +### Tab 1: DeckyVault (Main) + +**Idle state:** +- Big "Start Recording" button +- Brief instructions: "Enable MangoHud for your game, then press Record before launching." + +**Recording state:** +- "Stop Recording" button +- Live elapsed timer +- Current game name (or "No game detected" if none running) + +**Stopped state:** +- The session summary form (above) replaces the button area. + +**Below (always visible):** +- "Recent recordings" list — last 5 sessions showing game name, FPS avg, date. Clickable to re-view the form and re-export/re-upload. + +### Tab 2: Settings + +- **API Key** — text input (prefixed `dv_`), with a "Test Key" button that calls `GET /api/games/lookup?steamAppId=0` to verify the key works. Shows ✓ valid or ✗ invalid. +- **Export Path** — text input, defaults to `/home/deck/Downloads`. Where `.deckyvault.json` files are saved. +- **Default Hardware** — auto-detected but overrideable dropdown. Options from `KNOWN_HARDWARE_SLUGS`. Useful for non-standard setups. +- **MangoHud Setup** — see below. + +### MangoHud Setup Section + +- **"Check MangoHud status" button** — runs `which mangohud` and reports: installed/not installed + version if available. + +- **Installation guide** (collapsible): + - **Steam Deck (SteamOS)**: MangoHud is pre-installed. Enable per-game via Steam launch options (`mangohud %command%`) or the Decky MangoHud toggle plugin. + - **Other Linux handhelds** (ROG Ally, Legion Go, etc.): install via Flatpak (`flatpak install flathub org.freedesktop.Platform.VulkanLayer.MangoHud`) or system package manager. + - Link to MangoHud GitHub (`https://github.com/flightlessmango/MangoHud`) for manual builds. + +- **Configuration guide** — shows the exact MangoHud config the plugin writes (so users can verify). Includes a "Write config now" button that writes the logging config to `~/.config/MangoHud/MangoHud.conf`. + +- **Troubleshooting** (collapsible): + - Log file empty → check MangoHud is enabled for the game, check the log path. + - Wrong path → ensure the plugin has write access to `/tmp/`. + - MangoHud not attaching → try adding `mangohud %command%` to the game's Steam launch options explicitly. + +## Data Flow + +``` +[Start Recording] + │ + ▼ +[MangoHud logs to /tmp/deckyvault-mangohud.log] + │ + ▼ (user plays game) +[Stop Recording] + │ + ├──► mangohud.ts parses log → FPS stats, TDP + ├──► system-info.ts reads → hardware, OS, Proton + │ + ▼ +[Session Summary Form] + │ (user fills manual fields) + ├──► exporter.ts → .deckyvault.json on disk + └──► api-client.ts → POST /api/performance/import + │ + ▼ + [DeckyVault database] +``` + +## Error Handling + +- **MangoHud not installed** → Settings tab shows installation guide. Main tab warns when Record is pressed. +- **MangoHud not enabled for game** → post-session error with link to Settings → MangoHud Setup. +- **Invalid API key** → upload fails with 401, message shown in form. User directed to Settings to re-enter key. +- **Game not in DeckyVault DB** → upload returns 404, message: "This game isn't in DeckyVault yet. Submit it on the website first, or export to file for now." +- **Network error during upload** → message shown, user can retry or export to file. +- **Filesystem write error (export)** → message: "Couldn't write to [path]. Check the path in Settings." + +## Testing + +Since the plugin runs on Steam Deck hardware in the Decky Loader environment, testing is primarily manual: + +1. **MangoHud log parsing** — unit-testable with sample log files. Create test fixtures of MangoHud CSV output and verify FPS computation. +2. **System info reading** — testable on any Linux machine with mock `/sys` files. +3. **API client** — testable with mock fetch responses (success, 401, 404, network error). +4. **Exporter** — testable by writing to a temp directory and verifying JSON structure matches `DeckyVaultImportV1`. +5. **End-to-end** — manual testing on a Steam Deck with MangoHud enabled, recording a game session and verifying upload/export. + +## Dependencies + +- `@deckyvault/shared` — workspace package (types: `DeckyVaultImportV1`, `KNOWN_HARDWARE_SLUGS`) +- React — provided by Decky Loader runtime +- MangoHud — external dependency, must be installed by the user (pre-installed on Steam Deck) + +## Out of Scope (for this phase) + +- Auto-detection of upscaler / frame gen / in-game settings (manual input only) +- Automatic recording on game start (manual start/stop only) +- Real-time performance overlay in the plugin (MangoHud already provides this) +- Multi-session batch upload (one session at a time) +- Support for non-Steam games (requires manual Steam App ID entry, which is handled in the form) \ No newline at end of file From 57e5cf2dc64e5a8e1b50f916d0f84d6f6b6271ee Mon Sep 17 00:00:00 2001 From: Adrian Bonpin Date: Sun, 28 Jun 2026 07:48:25 +0800 Subject: [PATCH 06/30] docs: add DeckyVault plugin implementation plan --- .../plans/2026-06-28-decky-vault-plugin.md | 2528 +++++++++++++++++ 1 file changed, 2528 insertions(+) create mode 100644 docs/superpowers/plans/2026-06-28-decky-vault-plugin.md diff --git a/docs/superpowers/plans/2026-06-28-decky-vault-plugin.md b/docs/superpowers/plans/2026-06-28-decky-vault-plugin.md new file mode 100644 index 0000000..56ebd75 --- /dev/null +++ b/docs/superpowers/plans/2026-06-28-decky-vault-plugin.md @@ -0,0 +1,2528 @@ +# DeckyVault Decky Loader Plugin Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a Decky Loader plugin that records Steam Deck performance metrics via MangoHud, lets users fill in manual details, and exports/uploads them to DeckyVault. + +**Architecture:** Dual-halved Decky plugin — a React/TypeScript frontend (runs in Steam's CEF context, handles UI + SteamClient events) and a Python backend (`main.py`, handles filesystem/shell/network I/O). Frontend calls backend via `callable` RPC from `@decky/api`. The plugin lives in the `plugins/decky-vault/` workspace of the Bun monorepo. + +**Tech Stack:** TypeScript + React (frontend), Python 3 (backend), `@decky/api` + `@decky/ui` + `@decky/rollup` (Decky SDK), rollup (bundler), pytest (Python tests), `@deckyvault/shared` (workspace types) + +--- + +## File Structure + +``` +plugins/decky-vault/ +├── main.py # Python backend — Plugin class with RPC methods +├── plugin.json # Decky plugin metadata (api_version, flags, publish) +├── package.json # Frontend deps (@decky/api, @decky/ui, @decky/rollup) +├── rollup.config.js # Uses @decky/rollup +├── tsconfig.json # TS config for Decky frontend +├── src/ +│ ├── index.tsx # Entry point — definePlugin, SteamClient events, tab nav +│ ├── types.d.ts # SteamClient global type declarations +│ ├── lib/ +│ │ ├── api.ts # Typed RPC wrappers (callable functions → Python backend) +│ │ └── store.ts # Frontend state management (React context + hooks) +│ └── components/ +│ ├── main-panel.tsx # Record/Stop button, session status, recent recordings +│ ├── session-form.tsx # Post-session summary (auto-captured + manual inputs + actions) +│ └── settings-panel.tsx # API key, export path, hardware override, MangoHud setup +├── tests/ +│ ├── test_mangohud_parser.py # Unit tests for log parsing +│ ├── test_settings.py # Unit tests for settings persistence +│ └── fixtures/ +│ └── sample_mangohud.log # Sample MangoHud log for parser tests +└── dist/ + └── index.js # Built frontend bundle (output of rollup) +``` + +**Backend responsibilities (main.py):** +- Settings persistence (JSON in `DECKY_PLUGIN_SETTINGS_DIR`) +- MangoHud: check install, write config, read/parse log +- System info: hardware model, OS version +- Export: write `.deckyvault.json` to disk +- Upload: HTTP POST to DeckyVault API (avoids CORS issues from CEF context) + +**Frontend responsibilities (src/):** +- UI rendering (Decky UI components) +- SteamClient game start/stop event registration +- Session state management (recording status, parsed results) +- RPC calls to Python backend +- Building the `DeckyVaultImportV1` payload from combined auto + manual data + +--- + +### Task 1: Update package.json with correct Decky dependencies + +**Files:** +- Modify: `plugins/decky-vault/package.json` + +- [ ] **Step 1: Rewrite package.json** + +Replace the entire contents with: + +```json +{ + "name": "@deckyvault/plugin", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "build": "rollup -c", + "watch": "rollup -c -w" + }, + "dependencies": { + "@decky/api": "^1.1.3", + "@deckyvault/shared": "workspace:*", + "react-icons": "^5.3.0", + "tslib": "^2.7.0" + }, + "devDependencies": { + "@decky/rollup": "^1.0.2", + "@decky/ui": "^4.11.6", + "@types/react": "^19.1.1", + "@types/react-dom": "^19.1.1", + "rollup": "^4.53.3", + "typescript": "^5.6.2" + } +} +``` + +Note: React/react-dom are NOT listed — they are provided by Steam's CEF runtime. `@decky/rollup` handles marking them as external. + +- [ ] **Step 2: Install dependencies** + +Run: `cd plugins/decky-vault && bun install` +Expected: packages installed successfully (may need `--no-cache` if Bun has stale cache) + +- [ ] **Step 3: Commit** + +```bash +git add plugins/decky-vault/package.json +git commit -m "feat(plugin): update package.json with @decky SDK dependencies" +``` + +--- + +### Task 2: Update rollup.config.js to use @decky/rollup + +**Files:** +- Modify: `plugins/decky-vault/rollup.config.js` + +- [ ] **Step 1: Rewrite rollup.config.js** + +Replace entire contents with: + +```js +import deckyPlugin from "@decky/rollup"; + +export default deckyPlugin({ + // Add extra rollup options here if needed +}); +``` + +- [ ] **Step 2: Verify build runs (may fail on missing src, that's OK)** + +Run: `cd plugins/decky-vault && bun run build 2>&1 | head -20` +Expected: Either builds (if src/index.tsx exists) or fails on TypeScript errors (expected — we haven't rewritten the entry point yet). The important thing is that `@decky/rollup` loads without "module not found" errors. + +- [ ] **Step 3: Commit** + +```bash +git add plugins/decky-vault/rollup.config.js +git commit -m "feat(plugin): use @decky/rollup for build config" +``` + +--- + +### Task 3: Update plugin.json with correct Decky fields + +**Files:** +- Modify: `plugins/decky-vault/plugin.json` + +- [ ] **Step 1: Rewrite plugin.json** + +Replace entire contents with: + +```json +{ + "name": "DeckyVault", + "author": "DeckyVault", + "flags": ["debug"], + "api_version": 1, + "publish": { + "tags": ["performance", "benchmark", "mangohud"], + "description": "Record performance metrics and export/upload them to DeckyVault", + "image": "" + } +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add plugins/decky-vault/plugin.json +git commit -m "feat(plugin): update plugin.json with correct Decky metadata fields" +``` + +--- + +### Task 4: Update tsconfig.json for Decky frontend + +**Files:** +- Modify: `plugins/decky-vault/tsconfig.json` + +- [ ] **Step 1: Rewrite tsconfig.json** + +Replace entire contents with: + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "jsx": "react-jsx", + "jsxImportSource": "react", + "outDir": "./dist", + "rootDir": "./src", + "declaration": false, + "sourceMap": false, + "esModuleInterop": true, + "skipLibCheck": true, + "lib": ["ES2022", "DOM"], + "types": ["react", "react-dom"] + }, + "include": ["src"], + "references": [ + { "path": "../../packages/shared" } + ] +} +``` + +Added `skipLibCheck` (avoids errors from @decky/ui's internal types), `lib` with DOM (needed for browser globals), and `types` for React. + +- [ ] **Step 2: Commit** + +```bash +git add plugins/decky-vault/tsconfig.json +git commit -m "feat(plugin): update tsconfig for Decky frontend build" +``` + +--- + +### Task 5: Create Python backend skeleton with settings management + +**Files:** +- Create: `plugins/decky-vault/main.py` +- Create: `plugins/decky-vault/tests/test_settings.py` + +- [ ] **Step 1: Write the test for settings persistence** + +Create `plugins/decky-vault/tests/test_settings.py`: + +```python +"""Tests for settings persistence in the Python backend.""" +import json +import os +import sys +import tempfile +import pytest + +# We test the settings logic directly, not through the Plugin class, +# so we can run tests without the decky module. + +def write_settings(settings_path, settings): + """Write settings JSON to the given path.""" + os.makedirs(os.path.dirname(settings_path), exist_ok=True) + with open(settings_path, 'w') as f: + json.dump(settings, f, indent=2) + +def read_settings(settings_path): + """Read settings JSON from the given path, return empty dict if missing.""" + if os.path.exists(settings_path): + with open(settings_path, 'r') as f: + return json.load(f) + return {} + +def test_read_settings_returns_empty_when_file_missing(): + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "settings.json") + assert read_settings(path) == {} + +def test_write_then_read_settings(): + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "subdir", "settings.json") + write_settings(path, {"apiKey": "dv_test123", "exportPath": "/home/deck/Downloads"}) + result = read_settings(path) + assert result["apiKey"] == "dv_test123" + assert result["exportPath"] == "/home/deck/Downloads" + +def test_write_settings_creates_directory(): + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "newdir", "settings.json") + write_settings(path, {"key": "value"}) + assert os.path.exists(path) + +def test_read_settings_handles_corrupt_json(): + with tempfile.TemporaryDirectory() as tmpdir: + path = os.path.join(tmpdir, "settings.json") + with open(path, 'w') as f: + f.write("{invalid json") + with pytest.raises(json.JSONDecodeError): + read_settings(path) +``` + +- [ ] **Step 2: Run test to verify it fails (functions not defined in main.py yet)** + +Run: `cd plugins/decky-vault && python -m pytest tests/test_settings.py -v 2>&1 | head -20` +Expected: FAIL — the test defines its own helper functions so it should actually PASS. Wait — these are self-contained test helpers. Let me adjust: we're testing the pattern, not importing from main.py. Run it to confirm the pattern works. + +Expected: PASS (the test helpers are self-contained). This validates our settings approach before wiring it into main.py. + +- [ ] **Step 3: Create main.py with Plugin class and settings methods** + +Create `plugins/decky-vault/main.py`: + +```python +import asyncio +import json +import os + +try: + import decky +except ImportError: + # Allow running tests without the decky module (tests mock the path) + decky = None + + +class Plugin: + async def _main(self): + if decky: + decky.logger.info(f"DeckyVault plugin loaded: {decky.DECKY_PLUGIN_NAME}") + self._settings_path = self._get_settings_path() + self._settings = self._read_settings() + + async def _unload(self): + if decky: + decky.logger.info("DeckyVault plugin unloading") + + async def _uninstall(self): + if decky: + decky.logger.info("DeckyVault plugin uninstalled") + + def _get_settings_path(self): + if decky: + return os.path.join(decky.DECKY_PLUGIN_SETTINGS_DIR, "settings.json") + return os.path.join(os.path.expanduser("~"), ".deckyvault-test", "settings.json") + + def _read_settings(self): + """Read settings from JSON file. Returns empty dict if file missing.""" + if os.path.exists(self._settings_path): + try: + with open(self._settings_path, 'r') as f: + return json.load(f) + except (json.JSONDecodeError, IOError): + return {} + return {} + + def _write_settings(self, settings): + """Write settings to JSON file, creating directory if needed.""" + os.makedirs(os.path.dirname(self._settings_path), exist_ok=True) + with open(self._settings_path, 'w') as f: + json.dump(settings, f, indent=2) + + async def get_settings(self) -> dict: + """RPC: Return all plugin settings.""" + return self._settings + + async def set_setting(self, key: str, value) -> dict: + """RPC: Set a single setting and persist. Returns updated settings.""" + self._settings[key] = value + self._write_settings(self._settings) + return self._settings +``` + +- [ ] **Step 4: Commit** + +```bash +git add plugins/decky-vault/main.py plugins/decky-vault/tests/test_settings.py +git commit -m "feat(plugin): add Python backend skeleton with settings management" +``` + +--- + +### Task 6: Implement MangoHud status check and config writing + +**Files:** +- Modify: `plugins/decky-vault/main.py` + +- [ ] **Step 1: Add MangoHud methods to the Plugin class** + +Add these methods to the `Plugin` class in `main.py` (after the settings methods): + +```python + async def check_mangohud(self) -> dict: + """RPC: Check if MangoHud is installed. Returns {installed: bool, path: str, version: str}.""" + import subprocess + try: + result = subprocess.run( + ["which", "mangohud"], + capture_output=True, text=True, timeout=5 + ) + if result.returncode == 0: + mangohud_path = result.stdout.strip() + # Get version + version_result = subprocess.run( + ["mangohud", "--version"], + capture_output=True, text=True, timeout=5 + ) + version = version_result.stdout.strip() if version_result.returncode == 0 else "unknown" + return {"installed": True, "path": mangohud_path, "version": version} + else: + return {"installed": False, "path": "", "version": ""} + except Exception as e: + return {"installed": False, "path": "", "version": "", "error": str(e)} + + async def write_mangohud_config(self) -> dict: + """RPC: Write the MangoHud logging config to ~/.config/MangoHud/MangoHud.conf. + Returns {success: bool, path: str, error: str?}.""" + try: + home = os.path.expanduser("~") + config_dir = os.path.join(home, ".config", "MangoHud") + config_path = os.path.join(config_dir, "MangoHud.conf") + os.makedirs(config_dir, exist_ok=True) + + # MangoHud config that enables logging with the metrics we need. + # output_folder is required for logging to work. + # We log to /tmp so the plugin can read it after the session. + config_content = """\ +# DeckyVault MangoHud logging config +output_folder=/tmp +output_file=deckyvault-mangohud.log +log_duration=0 +fps +frame_timing +cpu_power +gpu_power +cpu_temp +gpu_temp +benchmark_percentiles=97,AVG,1,0.1 +""" + with open(config_path, 'w') as f: + f.write(config_content) + + return {"success": True, "path": config_path} + except Exception as e: + return {"success": False, "path": "", "error": str(e)} + + async def get_mangohud_config(self) -> dict: + """RPC: Read the current MangoHud config. Returns {exists: bool, content: str, path: str}.""" + home = os.path.expanduser("~") + config_path = os.path.join(home, ".config", "MangoHud", "MangoHud.conf") + if os.path.exists(config_path): + with open(config_path, 'r') as f: + return {"exists": True, "content": f.read(), "path": config_path} + return {"exists": False, "content": "", "path": config_path} +``` + +- [ ] **Step 2: Commit** + +```bash +git add plugins/decky-vault/main.py +git commit -m "feat(plugin): add MangoHud status check and config writing to backend" +``` + +--- + +### Task 7: Implement MangoHud log parser with tests + +**Files:** +- Create: `plugins/decky-vault/tests/fixtures/sample_mangohud.log` +- Create: `plugins/decky-vault/tests/test_mangohud_parser.py` +- Modify: `plugins/decky-vault/main.py` + +- [ ] **Step 1: Create a sample MangoHud log fixture** + +Create `plugins/decky-vault/tests/fixtures/sample_mangohud.log`: + +``` +# MangoHud v0.8.4 +# note: session started at 2026-06-28 14:30:00 +# preset: 0 +fps,frametime,cpu_load,gpu_load,cpu_temp,gpu_temp,gpu_power,cpu_power +60,16.67,45,80,55,65,15,10 +62,16.13,46,82,55,65,15,10 +58,17.24,44,78,56,66,14,10 +61,16.39,45,81,55,65,15,10 +59,16.95,44,79,56,66,14,10 +60,16.67,45,80,55,65,15,10 +63,15.87,47,83,55,65,15,10 +57,17.54,43,77,56,66,14,10 +60,16.67,45,80,55,65,15,10 +61,16.39,46,81,55,65,15,10 +# benchmark summary +97%, 62 +AVG, 60 +1%, 57 +0.1%, 57 +``` + +- [ ] **Step 2: Write the parser test** + +Create `plugins/decky-vault/tests/test_mangohud_parser.py`: + +```python +"""Tests for MangoHud log parsing.""" +import os + +# Parser functions — these will be imported from main.py once implemented. +# For now we define them here to test the logic, then move to main.py. + +def parse_mangohud_log(log_content: str) -> dict: + """Parse a MangoHud log file's content and return FPS stats. + + Returns: {fpsAvg, fpsLow, fpsHigh, fpsOnePercentLow, tdpWatts, error?} + """ + lines = log_content.strip().split('\n') + + # Find the header row (first non-comment, non-empty line that looks like column names) + header_idx = None + fps_col = 0 + frametime_col = None + gpu_power_col = None + + for i, line in enumerate(lines): + stripped = line.strip() + if not stripped or stripped.startswith('#'): + continue + # Check if this is a header (contains 'fps') + if 'fps' in stripped.lower() and ',' in stripped: + columns = [c.strip().lower() for c in stripped.split(',')] + if 'fps' in columns: + fps_col = columns.index('fps') + if 'frametime' in columns: + frametime_col = columns.index('frametime') + if 'gpu_power' in columns: + gpu_power_col = columns.index('gpu_power') + header_idx = i + break + + if header_idx is None: + return {"error": "Could not find FPS column in log header"} + + # Extract data rows (lines after header that start with a number) + fps_values = [] + frametime_values = [] + gpu_power_values = [] + + for line in lines[header_idx + 1:]: + stripped = line.strip() + if not stripped or stripped.startswith('#'): + continue + # Check if it's a summary line (e.g. "97%,\t62" or "AVG,\t60") + if stripped.startswith(('97%', 'AVG', '1%', '0.1%', '5%')): + continue + parts = [p.strip() for p in stripped.split(',')] + try: + fps = float(parts[fps_col]) + fps_values.append(fps) + if frametime_col is not None and frametime_col < len(parts): + ft = float(parts[frametime_col]) + frametime_values.append(ft) + if gpu_power_col is not None and gpu_power_col < len(parts): + gp = float(parts[gpu_power_col]) + gpu_power_values.append(gp) + except (ValueError, IndexError): + continue + + if not fps_values: + return {"error": "No FPS data found in log"} + + # Compute stats + fps_avg = round(sum(fps_values) / len(fps_values), 1) + fps_low = round(min(fps_values), 1) + fps_high = round(max(fps_values), 1) + + # 1% low: sort frame times, take 1st percentile, convert to FPS + if frametime_values: + sorted_ft = sorted(frametime_values) + one_percent_idx = max(0, int(len(sorted_ft) * 0.01)) + one_percent_ft = sorted_ft[one_percent_idx] + fps_one_percent_low = round(1000.0 / one_percent_ft, 1) if one_percent_ft > 0 else None + else: + # Fall back: sort FPS values, take 1st percentile from bottom + sorted_fps = sorted(fps_values) + one_percent_idx = max(0, int(len(sorted_fps) * 0.01)) + fps_one_percent_low = round(sorted_fps[one_percent_idx], 1) + + tdp_watts = None + if gpu_power_values: + tdp_watts = round(sum(gpu_power_values) / len(gpu_power_values), 1) + + return { + "fpsAvg": fps_avg, + "fpsLow": fps_low, + "fpsHigh": fps_high, + "fpsOnePercentLow": fps_one_percent_low, + "tdpWatts": tdp_watts, + } + + +# ── Tests ────────────────────────────────────────────────────────── + +def test_parse_basic_log(): + log = """\ +# MangoHud v0.8.4 +fps,frametime,cpu_load,gpu_load,cpu_temp,gpu_temp,gpu_power,cpu_power +60,16.67,45,80,55,65,15,10 +62,16.13,46,82,55,65,15,10 +58,17.24,44,78,56,66,14,10 +""" + result = parse_mangohud_log(log) + assert "error" not in result + assert result["fpsAvg"] == 60.0 + assert result["fpsLow"] == 58.0 + assert result["fpsHigh"] == 62.0 + assert result["tdpWatts"] == 14.3 # avg of 15,15,14 + + +def test_parse_log_with_summary_section(): + """The summary section (97%, AVG, 1%, 0.1%) should be skipped as data.""" + log = """\ +# MangoHud v0.8.4 +fps,frametime,cpu_load,gpu_power +60,16.67,45,15 +62,16.13,46,15 +58,17.24,44,14 +# benchmark summary +97%, 62 +AVG, 60 +1%, 57 +0.1%, 57 +""" + result = parse_mangohud_log(log) + assert "error" not in result + assert result["fpsAvg"] == 60.0 + # Should not have tried to parse summary lines as data + assert result["fpsLow"] == 58.0 + assert result["fpsHigh"] == 62.0 + + +def test_parse_empty_log_returns_error(): + result = parse_mangohud_log("") + assert "error" in result + + +def test_parse_log_without_fps_column(): + log = """\ +# no fps here +cpu_load,gpu_load +45,80 +""" + result = parse_mangohud_log(log) + assert "error" in result + + +def test_parse_log_without_gpu_power(): + """tdpWatts should be None if gpu_power column is absent.""" + log = """\ +fps,frametime,cpu_load +60,16.67,45 +62,16.13,46 +""" + result = parse_mangohud_log(log) + assert result["tdpWatts"] is None + + +def test_parse_one_percent_low_from_frametime(): + """1% low should be computed from frame times when available.""" + log = """\ +fps,frametime +60,16.67 +30,33.33 +60,16.67 +60,16.67 +60,16.67 +60,16.67 +60,16.67 +60,16.67 +60,16.67 +60,16.67 +""" + result = parse_mangohud_log(log) + # The 33.33ms frame time is the worst — 1% low should be ~30 fps + assert result["fpsOnePercentLow"] is not None + assert result["fpsOnePercentLow"] <= 35 # roughly 1000/33.33 = 30 + + +def test_parse_fixture_file(): + """Parse the actual fixture file.""" + fixture_path = os.path.join( + os.path.dirname(__file__), "fixtures", "sample_mangohud.log" + ) + with open(fixture_path, 'r') as f: + content = f.read() + result = parse_mangohud_log(content) + assert "error" not in result + assert result["fpsAvg"] == 60.1 # avg of the 10 data rows + assert result["fpsLow"] == 57.0 + assert result["fpsHigh"] == 63.0 + assert result["tdpWatts"] is not None +``` + +- [ ] **Step 3: Run the parser tests** + +Run: `cd plugins/decky-vault && python -m pytest tests/test_mangohud_parser.py -v` +Expected: All 7 tests PASS + +- [ ] **Step 4: Add the parser to main.py** + +Add this method to the `Plugin` class in `main.py` (after the MangoHud config methods). Also add a standalone `parse_mangohud_log` function at module level that the method delegates to: + +```python +def parse_mangohud_log(log_content: str) -> dict: + """Parse a MangoHud log file's content and return FPS stats. + + Returns: {fpsAvg, fpsLow, fpsHigh, fpsOnePercentLow, tdpWatts, error?} + """ + lines = log_content.strip().split('\n') + + header_idx = None + fps_col = 0 + frametime_col = None + gpu_power_col = None + + for i, line in enumerate(lines): + stripped = line.strip() + if not stripped or stripped.startswith('#'): + continue + if 'fps' in stripped.lower() and ',' in stripped: + columns = [c.strip().lower() for c in stripped.split(',')] + if 'fps' in columns: + fps_col = columns.index('fps') + if 'frametime' in columns: + frametime_col = columns.index('frametime') + if 'gpu_power' in columns: + gpu_power_col = columns.index('gpu_power') + header_idx = i + break + + if header_idx is None: + return {"error": "Could not find FPS column in log header"} + + fps_values = [] + frametime_values = [] + gpu_power_values = [] + + for line in lines[header_idx + 1:]: + stripped = line.strip() + if not stripped or stripped.startswith('#'): + continue + if stripped.startswith(('97%', 'AVG', '1%', '0.1%', '5%')): + continue + parts = [p.strip() for p in stripped.split(',')] + try: + fps = float(parts[fps_col]) + fps_values.append(fps) + if frametime_col is not None and frametime_col < len(parts): + frametime_values.append(float(parts[frametime_col])) + if gpu_power_col is not None and gpu_power_col < len(parts): + gpu_power_values.append(float(parts[gpu_power_col])) + except (ValueError, IndexError): + continue + + if not fps_values: + return {"error": "No FPS data found in log"} + + fps_avg = round(sum(fps_values) / len(fps_values), 1) + fps_low = round(min(fps_values), 1) + fps_high = round(max(fps_values), 1) + + if frametime_values: + sorted_ft = sorted(frametime_values) + one_percent_idx = max(0, int(len(sorted_ft) * 0.01)) + one_percent_ft = sorted_ft[one_percent_idx] + fps_one_percent_low = round(1000.0 / one_percent_ft, 1) if one_percent_ft > 0 else None + else: + sorted_fps = sorted(fps_values) + one_percent_idx = max(0, int(len(sorted_fps) * 0.01)) + fps_one_percent_low = round(sorted_fps[one_percent_idx], 1) + + tdp_watts = None + if gpu_power_values: + tdp_watts = round(sum(gpu_power_values) / len(gpu_power_values), 1) + + return { + "fpsAvg": fps_avg, + "fpsLow": fps_low, + "fpsHigh": fps_high, + "fpsOnePercentLow": fps_one_percent_low, + "tdpWatts": tdp_watts, + } +``` + +Then add this method to the `Plugin` class: + +```python + async def read_and_parse_mangohud_log(self, log_path: str = "/tmp/deckyvault-mangohud.log") -> dict: + """RPC: Read the MangoHud log file and return parsed FPS stats. + Returns parsed stats dict or {error: str}.""" + if not os.path.exists(log_path): + return {"error": f"MangoHud log not found at {log_path}. Make sure MangoHud is enabled and logging."} + try: + with open(log_path, 'r') as f: + content = f.read() + if not content.strip(): + return {"error": "MangoHud log is empty. Recording may have been too short."} + return parse_mangohud_log(content) + except Exception as e: + return {"error": f"Failed to read log: {str(e)}"} + + async def clear_mangohud_log(self, log_path: str = "/tmp/deckyvault-mangohud.log") -> dict: + """RPC: Delete the MangoHud log file so the next recording starts fresh.""" + try: + if os.path.exists(log_path): + os.remove(log_path) + return {"success": True} + except Exception as e: + return {"success": False, "error": str(e)} +``` + +- [ ] **Step 5: Commit** + +```bash +git add plugins/decky-vault/main.py plugins/decky-vault/tests/ +git commit -m "feat(plugin): add MangoHud log parser with unit tests" +``` + +--- + +### Task 8: Implement system info reader (Python backend) + +**Files:** +- Modify: `plugins/decky-vault/main.py` + +- [ ] **Step 1: Add system info methods to the Plugin class** + +Add these methods to `main.py` (after the MangoHud methods): + +```python + async def get_hardware_info(self) -> dict: + """RPC: Detect hardware model from DMI. Returns {slug, name, raw}.""" + # Steam Deck models: Jupiter = LCD, Galileo = OLED + product_name = "" + try: + with open("/sys/class/dmi/id/product_name", 'r') as f: + product_name = f.read().strip() + except (IOError, FileNotFoundError): + pass + + slug = "unknown" + name = "Unknown Device" + + if product_name == "Jupiter": + slug = "steamdeck-lcd" + name = "Steam Deck LCD" + elif product_name == "Galileo": + slug = "steamdeck-oled" + name = "Steam Deck OLED" + elif product_name: + name = product_name + slug = product_name.lower().replace(" ", "-") + + return {"slug": slug, "name": name, "raw": product_name} + + async def get_os_version(self) -> str: + """RPC: Read OS version from /etc/os-release.""" + try: + with open("/etc/os-release", 'r') as f: + for line in f: + if line.startswith("PRETTY_NAME="): + return line.split("=", 1)[1].strip().strip('"') + return "unknown" + except (IOError, FileNotFoundError): + return "unknown" + + async def get_proton_version(self, app_id: int) -> str: + """RPC: Attempt to read the Proton version for a Steam app. + Reads from the Steam compatdata directory.""" + try: + home = os.path.expanduser("~") + # Steam compat data lives in ~/.steam/steam/steamapps/compatdata// + compat_path = os.path.join(home, ".steam", "steam", "steamapps", "compatdata", str(app_id)) + version_file = os.path.join(compat_path, "version") + if os.path.exists(version_file): + with open(version_file, 'r') as f: + return f.read().strip() + return "" + except (IOError, FileNotFoundError): + return "" + + async def get_launch_options(self, app_id: int) -> str: + """RPC: Read launch options for a Steam app from localconfig.vdf. + This is best-effort — the VDF format is not officially documented.""" + try: + home = os.path.expanduser("~") + # localconfig.vdf path varies; try common locations + config_paths = [ + os.path.join(home, ".steam", "steam", "usercfg", "localconfig.vdf"), + os.path.join(home, ".local", "share", "Steam", "usercfg", "localconfig.vdf"), + ] + for config_path in config_paths: + if os.path.exists(config_path): + with open(config_path, 'r') as f: + content = f.read() + # Best-effort: look for LaunchOptions near the app ID + # This is a simple heuristic — VDF parsing is complex + app_str = f'"{app_id}"' + idx = content.find(app_str) + if idx != -1: + # Search for LaunchOptions within ~2000 chars after app ID + search_region = content[idx:idx + 2000] + lo_idx = search_region.find('"LaunchOptions"') + if lo_idx != -1: + # Extract the value between quotes + value_start = search_region.find('"', lo_idx + len('"LaunchOptions"')) + 1 + value_end = search_region.find('"', value_start) + if value_start > 0 and value_end > value_start: + return search_region[value_start:value_end] + return "" + return "" + except (IOError, FileNotFoundError): + return "" +``` + +- [ ] **Step 2: Commit** + +```bash +git add plugins/decky-vault/main.py +git commit -m "feat(plugin): add system info reader (hardware, OS, Proton, launch options)" +``` + +--- + +### Task 9: Implement export to file (Python backend) + +**Files:** +- Modify: `plugins/decky-vault/main.py` + +- [ ] **Step 1: Add export method to the Plugin class** + +Add this method to `main.py` (after the system info methods): + +```python + async def export_to_file(self, data: dict, export_path: str) -> dict: + """RPC: Write a DeckyVaultImportV1 payload as JSON to the given path. + Returns {success: bool, path: str, error: str?}.""" + try: + # Sanitize the filename — the frontend passes a full path including filename + export_dir = os.path.dirname(export_path) + if export_dir and not os.path.exists(export_dir): + os.makedirs(export_dir, exist_ok=True) + + with open(export_path, 'w') as f: + json.dump(data, f, indent=2) + + return {"success": True, "path": export_path} + except PermissionError: + return {"success": False, "path": "", "error": f"Permission denied writing to {export_path}"} + except Exception as e: + return {"success": False, "path": "", "error": str(e)} +``` + +- [ ] **Step 2: Commit** + +```bash +git add plugins/decky-vault/main.py +git commit -m "feat(plugin): add export-to-file method in backend" +``` + +--- + +### Task 10: Implement upload to DeckyVault (Python backend) + +**Files:** +- Modify: `plugins/decky-vault/main.py` + +- [ ] **Step 1: Add upload method to the Plugin class** + +Add this method to `main.py` (after the export method): + +```python + async def upload_to_deckyvault(self, data: dict, api_key: str, base_url: str = "https://deckyvault.xyz") -> dict: + """RPC: Upload a DeckyVaultImportV1 payload to the DeckyVault API. + Uses urllib to avoid external dependencies. + Returns {success: bool, data: dict?, error: str?, status: int?}.""" + import urllib.request + import urllib.error + + try: + url = f"{base_url}/api/performance/import" + payload = json.dumps(data).encode('utf-8') + + req = urllib.request.Request( + url, + data=payload, + headers={ + "Content-Type": "application/json", + "x-api-key": api_key, + }, + method="POST" + ) + + with urllib.request.urlopen(req, timeout=30) as response: + status = response.status + body = response.read().decode('utf-8') + result = json.loads(body) + + if status == 201: + return {"success": True, "data": result, "status": status} + else: + return {"success": False, "error": result.get("error", "Upload failed"), "status": status} + + except urllib.error.HTTPError as e: + error_body = e.read().decode('utf-8') + try: + error_msg = json.loads(error_body).get("error", error_body) + except json.JSONDecodeError: + error_msg = error_body + return {"success": False, "error": error_msg, "status": e.code} + except urllib.error.URLError as e: + return {"success": False, "error": f"Network error: {str(e.reason)}", "status": 0} + except Exception as e: + return {"success": False, "error": str(e), "status": 0} + + async def test_api_key(self, api_key: str, base_url: str = "https://deckyvault.xyz") -> dict: + """RPC: Test if an API key is valid by calling the games lookup endpoint. + Returns {valid: bool, error: str?}.""" + import urllib.request + import urllib.error + + try: + url = f"{base_url}/api/games/lookup?steamAppId=0" + req = urllib.request.Request( + url, + headers={"x-api-key": api_key}, + method="GET" + ) + with urllib.request.urlopen(req, timeout=10) as response: + # A 404 (game not found) still means the API key is valid + return {"valid": True} + except urllib.error.HTTPError as e: + if e.code == 401: + return {"valid": False, "error": "Invalid API key"} + elif e.code == 404: + return {"valid": True} # Key works, just no game with ID 0 + else: + return {"valid": False, "error": f"Server returned status {e.code}"} + except urllib.error.URLError as e: + return {"valid": False, "error": f"Network error: {str(e.reason)}"} + except Exception as e: + return {"valid": False, "error": str(e)} +``` + +- [ ] **Step 2: Commit** + +```bash +git add plugins/decky-vault/main.py +git commit -m "feat(plugin): add upload and API key test methods in backend" +``` + +--- + +### Task 11: Update types.d.ts with correct SteamClient API + +**Files:** +- Modify: `plugins/decky-vault/src/types.d.ts` + +- [ ] **Step 1: Rewrite types.d.ts** + +Replace entire contents with: + +```typescript +// Steam Deck CEF context globals — these are Valve's internal APIs, +// available in the Steam Deck game mode browser context. +// Not part of @decky/api; accessed directly from the global scope. + +declare global { + const SteamClient: { + Apps: { + RegisterForGameStarted: ( + callback: (appId: number) => void, + ) => { unregister: () => void } + RegisterForGameStopped: ( + callback: (appId: number) => void, + ) => { unregister: () => void } + GetCurrentGameInfo: () => Promise<{ + appId: number + strAppName: string + }> + } + System: { + GetOSVersion: () => Promise + } + UI: { + GetUIMode: () => Promise + } + } +} + +export {} +``` + +Removed the non-existent `DeckyPlugin` global (we use `@decky/api` instead). Added `GetCurrentGameInfo` and `GetUIMode`. Fixed `GetAppData` to the simpler `GetCurrentGameInfo` pattern. + +- [ ] **Step 2: Commit** + +```bash +git add plugins/decky-vault/src/types.d.ts +git commit -m "feat(plugin): update SteamClient type declarations" +``` + +--- + +### Task 12: Create frontend RPC wrappers + +**Files:** +- Create: `plugins/decky-vault/src/lib/api.ts` + +- [ ] **Step 1: Write the RPC wrappers** + +Create `plugins/decky-vault/src/lib/api.ts`: + +```typescript +import { callable } from "@decky/api" + +// ── Settings ──────────────────────────────────────────────────── +export const getSettings = callable<[], Record>("get_settings") +export const setSetting = callable<[key: string, value: unknown], Record>("set_setting") + +// ── MangoHud ──────────────────────────────────────────────────── +export const checkMangohud = callable<[], { + installed: boolean + path: string + version: string + error?: string +}>("check_mangohud") + +export const writeMangohudConfig = callable<[], { + success: boolean + path: string + error?: string +}>("write_mangohud_config") + +export const getMangohudConfig = callable<[], { + exists: boolean + content: string + path: string +}>("get_mangohud_config") + +export const readAndParseMangohudLog = callable<[logPath?: string], { + fpsAvg?: number + fpsLow?: number + fpsHigh?: number + fpsOnePercentLow?: number | null + tdpWatts?: number | null + error?: string +}>("read_and_parse_mangohud_log") + +export const clearMangohudLog = callable<[logPath?: string], { + success: boolean + error?: string +}>("clear_mangohud_log") + +// ── System Info ───────────────────────────────────────────────── +export const getHardwareInfo = callable<[], { + slug: string + name: string + raw: string +}>("get_hardware_info") + +export const getOsVersion = callable<[], string>("get_os_version") + +export const getProtonVersion = callable<[appId: number], string>("get_proton_version") + +export const getLaunchOptions = callable<[appId: number], string>("get_launch_options") + +// ── Export ────────────────────────────────────────────────────── +export const exportToFile = callable<[data: Record, exportPath: string], { + success: boolean + path: string + error?: string +}>("export_to_file") + +// ── Upload ────────────────────────────────────────────────────── +export const uploadToDeckyvault = callable<[ + data: Record, + apiKey: string, + baseUrl?: string +], { + success: boolean + data?: { id: string; gameId: string; versionId: string; createdAt: string; authMethod: string } + error?: string + status?: number +}>("upload_to_deckyvault") + +export const testApiKey = callable<[apiKey: string, baseUrl?: string], { + valid: boolean + error?: string +}>("test_api_key") +``` + +- [ ] **Step 2: Commit** + +```bash +git add plugins/decky-vault/src/lib/api.ts +git commit -m "feat(plugin): add typed RPC wrappers for Python backend" +``` + +--- + +### Task 13: Create frontend store (state management) + +**Files:** +- Create: `plugins/decky-vault/src/lib/store.ts` + +- [ ] **Step 1: Write the store module** + +Create `plugins/decky-vault/src/lib/store.ts`: + +```typescript +import { useState, useEffect, useCallback, useRef } from "react" +import type { DeckyVaultImportV1, HardwareSlug } from "@deckyvault/shared" +import { getSettings, setSetting } from "./api" + +// ── Types ─────────────────────────────────────────────────────── + +export interface PluginSettings { + apiKey: string + exportPath: string + hardwareSlug: string | null // null = auto-detect + baseUrl: string +} + +const DEFAULT_SETTINGS: PluginSettings = { + apiKey: "", + exportPath: "/home/deck/Downloads", + hardwareSlug: null, + baseUrl: "https://deckyvault.xyz", +} + +export type RecordingState = "idle" | "recording" | "stopped" + +export interface SessionData { + appId: number | null + gameName: string + startedAt: number + // Auto-captured (filled after stop) + fpsAvg: number | null + fpsLow: number | null + fpsHigh: number | null + fpsOnePercentLow: number | null + tdpWatts: number | null + hardwareSlug: string + hardwareName: string + osVersion: string + protonVersion: string + // Manual inputs (filled by user in the form) + upscalerType: string + upscalerVersion: string + frameGenMethod: string + settingsJson: string + loadTimeSsd: string + loadTimeSd: string + launchOptions: string + userNotes: string +} + +export interface RecentSession { + appId: number | null + gameName: string + fpsAvg: number | null + date: string // ISO string +} + +function createEmptySession(): SessionData { + return { + appId: null, + gameName: "", + startedAt: 0, + fpsAvg: null, + fpsLow: null, + fpsHigh: null, + fpsOnePercentLow: null, + tdpWatts: null, + hardwareSlug: "", + hardwareName: "", + osVersion: "", + protonVersion: "", + upscalerType: "none", + upscalerVersion: "", + frameGenMethod: "none", + settingsJson: "", + loadTimeSsd: "", + loadTimeSd: "", + launchOptions: "", + userNotes: "", + } +} + +// ── Settings Hook ─────────────────────────────────────────────── + +export function useSettings() { + const [settings, setSettings] = useState(DEFAULT_SETTINGS) + const [loaded, setLoaded] = useState(false) + + useEffect(() => { + async function load() { + try { + const raw = await getSettings() + setSettings({ + apiKey: (raw.apiKey as string) || "", + exportPath: (raw.exportPath as string) || DEFAULT_SETTINGS.exportPath, + hardwareSlug: (raw.hardwareSlug as string) || null, + baseUrl: (raw.baseUrl as string) || DEFAULT_SETTINGS.baseUrl, + }) + } catch (e) { + console.error("Failed to load settings:", e) + } finally { + setLoaded(true) + } + } + load() + }, []) + + const updateSetting = useCallback(async (key: keyof PluginSettings, value: string | null) => { + setSettings((prev) => ({ ...prev, [key]: value })) + try { + await setSetting(key, value) + } catch (e) { + console.error(`Failed to save setting ${key}:`, e) + } + }, []) + + return { settings, updateSetting, loaded } +} + +// ── Session Hook ──────────────────────────────────────────────── + +export function useSession() { + const [recordingState, setRecordingState] = useState("idle") + const [session, setSession] = useState(createEmptySession()) + const [recentSessions, setRecentSessions] = useState([]) + const [error, setError] = useState("") + const currentAppIdRef = useRef(null) + const currentAppNameRef = useRef("") + + const startRecording = useCallback(() => { + setError("") + setSession({ + ...createEmptySession(), + appId: currentAppIdRef.current, + gameName: currentAppNameRef.current, + startedAt: Date.now(), + }) + setRecordingState("recording") + }, []) + + const stopRecording = useCallback(() => { + setRecordingState("stopped") + }, []) + + const updateSession = useCallback((updates: Partial) => { + setSession((prev) => ({ ...prev, ...updates })) + }, []) + + const addToRecent = useCallback((sess: SessionData) => { + const recent: RecentSession = { + appId: sess.appId, + gameName: sess.gameName, + fpsAvg: sess.fpsAvg, + date: new Date().toISOString(), + } + setRecentSessions((prev) => [recent, ...prev].slice(0, 5)) + }, []) + + const reset = useCallback(() => { + setRecordingState("idle") + setSession(createEmptySession()) + setError("") + }, []) + + // Called when a game starts (via SteamClient event) + const onGameStart = useCallback((appId: number, gameName: string) => { + currentAppIdRef.current = appId + currentAppNameRef.current = gameName + }, []) + + // Called when a game stops (via SteamClient event) + const onGameStop = useCallback(() => { + currentAppIdRef.current = null + currentAppNameRef.current = "" + }, []) + + return { + recordingState, + session, + recentSessions, + error, + setError, + startRecording, + stopRecording, + updateSession, + addToRecent, + reset, + onGameStart, + onGameStop, + } +} + +// ── Payload Builder ───────────────────────────────────────────── + +export function buildImportPayload(sess: SessionData): DeckyVaultImportV1 { + return { + version: 1, + steamAppId: sess.appId ?? 0, + hardwareSlug: sess.hardwareSlug, + fpsAvg: sess.fpsAvg ?? 0, + fpsLow: sess.fpsLow, + fpsOnePercentLow: sess.fpsOnePercentLow, + fpsHigh: sess.fpsHigh, + protonVersion: sess.protonVersion || null, + osVersion: sess.osVersion || null, + upscalerType: sess.upscalerType, + upscalerVersion: sess.upscalerVersion || null, + frameGenMethod: sess.frameGenMethod, + tdpWatts: sess.tdpWatts, + loadTimeSsd: sess.loadTimeSsd ? Number(sess.loadTimeSsd) : null, + loadTimeSd: sess.loadTimeSd ? Number(sess.loadTimeSd) : null, + launchOptions: sess.launchOptions || null, + settingsJson: sess.settingsJson ? tryParseJson(sess.settingsJson) : null, + userNotes: sess.userNotes || null, + } +} + +function tryParseJson(text: string): unknown[] | null { + try { + const parsed = JSON.parse(text) + return Array.isArray(parsed) ? parsed : [parsed] + } catch { + return [{ text }] + } +} + +export { DEFAULT_SETTINGS } +export type { HardwareSlug } +``` + +- [ ] **Step 2: Commit** + +```bash +git add plugins/decky-vault/src/lib/store.ts +git commit -m "feat(plugin): add frontend state management (settings + session hooks)" +``` + +--- + +### Task 14: Create the main panel component + +**Files:** +- Create: `plugins/decky-vault/src/components/main-panel.tsx` + +- [ ] **Step 1: Write the main panel component** + +Create `plugins/decky-vault/src/components/main-panel.tsx`: + +```tsx +import { useEffect, useState } from "react" +import { + ButtonItem, + PanelSection, + PanelSectionRow, + staticClasses, +} from "@decky/ui" +import { + FaPlay, + FaStop, + FaClock, +} from "react-icons/fa" +import type { RecordingState, SessionData, RecentSession } from "../lib/store" +import SessionForm from "./session-form" + +import type { PluginSettings } from "../lib/store" + +interface MainPanelProps { + recordingState: RecordingState + session: SessionData + recentSessions: RecentSession[] + error: string + settings: PluginSettings + onStart: () => void + onStop: () => void + onUpdateSession: (updates: Partial) => void + onAddToRecent: (sess: SessionData) => void + onReset: () => void + setError: (msg: string) => void +} + +export default function MainPanel({ + recordingState, + session, + recentSessions, + error, + settings, + onStart, + onStop, + onUpdateSession, + onAddToRecent, + onReset, + setError, +}: MainPanelProps) { + const [elapsed, setElapsed] = useState(0) + + // Timer for recording state + useEffect(() => { + if (recordingState !== "recording") { + setElapsed(0) + return + } + const interval = setInterval(() => { + setElapsed(Math.floor((Date.now() - session.startedAt) / 1000)) + }, 1000) + return () => clearInterval(interval) + }, [recordingState, session.startedAt]) + + function formatTime(seconds: number): string { + const m = Math.floor(seconds / 60) + const s = seconds % 60 + return `${m}:${s.toString().padStart(2, "0")}` + } + + // ── Stopped state: show the session form ────────────────────── + if (recordingState === "stopped") { + return ( + + ) + } + + // ── Idle or Recording state ─────────────────────────────────── + return ( + + {error && ( + +
+ {error} +
+
+ )} + + + {recordingState === "idle" ? ( + +
+ + Start Recording +
+
+ ) : ( + +
+ + Stop Recording +
+
+ )} +
+ + {recordingState === "recording" && ( + <> + +
+
+ + {formatTime(elapsed)} +
+
+ {session.gameName + ? `Recording: ${session.gameName}` + : "No game detected — recording anyway"} +
+
+
+ + )} + + {recordingState === "idle" && ( + +
+ Enable MangoHud for your game, then press Start Recording before launching. + Configure MangoHud in the Settings tab. +
+
+ )} + + {recentSessions.length > 0 && recordingState === "idle" && ( + + {recentSessions.map((rs, i) => ( + +
+ {rs.gameName || "Unknown game"} +
+ + {rs.fpsAvg ? `${rs.fpsAvg} FPS avg` : "No data"} ·{" "} + {new Date(rs.date).toLocaleDateString()} + +
+
+ ))} +
+ )} +
+ ) +} +``` + +- [ ] **Step 2: Commit** + +```bash +git add plugins/decky-vault/src/components/main-panel.tsx +git commit -m "feat(plugin): add main panel component (record/stop + recent sessions)" +``` + +--- + +### Task 15: Create the session form component + +**Files:** +- Create: `plugins/decky-vault/src/components/session-form.tsx` + +- [ ] **Step 1: Write the session form component** + +Create `plugins/decky-vault/src/components/session-form.tsx`: + +```tsx +import { useState } from "react" +import { + ButtonItem, + PanelSection, + PanelSectionRow, + Field, + DropdownItem, + staticClasses, +} from "@decky/ui" +import { + FaFileExport, + FaCloudUploadAlt, + FaCheck, + FaTimes, +} from "react-icons/fa" +import type { SessionData } from "../lib/store" +import { buildImportPayload } from "../lib/store" +import type { PluginSettings } from "../lib/store" +import { exportToFile, uploadToDeckyvault } from "../lib/api" + +interface SessionFormProps { + session: SessionData + error: string + settings?: PluginSettings + onUpdateSession: (updates: Partial) => void + onAddToRecent: (sess: SessionData) => void + onReset: () => void + setError: (msg: string) => void +} + +const UPSCALER_OPTIONS = [ + { label: "None", value: "none" }, + { label: "FSR", value: "fsr" }, + { label: "DLSS", value: "dlss" }, + { label: "XeSS", value: "xess" }, + { label: "LSFG", value: "lsfg" }, + { label: "Other", value: "other" }, +] + +const FRAME_GEN_OPTIONS = [ + { label: "None", value: "none" }, + { label: "FSR FG", value: "fsr_fg" }, + { label: "DLSS FG", value: "dlss_fg" }, + { label: "LSFG", value: "lsfg" }, + { label: "Other", value: "other" }, +] + +export default function SessionForm({ + session, + error, + settings, + onUpdateSession, + onAddToRecent, + onReset, + setError, +}: SessionFormProps) { + const [exportStatus, setExportStatus] = useState<"idle" | "success" | "error">("idle") + const [uploadStatus, setUploadStatus] = useState<"idle" | "loading" | "success" | "error">("idle") + const [statusMessage, setStatusMessage] = useState("") + + async function handleExport() { + if (!settings) return + setError("") + setExportStatus("idle") + const payload = buildImportPayload(session) + const gameSlug = session.gameName.toLowerCase().replace(/[^a-z0-9]/g, "-") || "unknown" + const date = new Date().toISOString().slice(0, 10) + const filename = `${gameSlug}-${date}.deckyvault.json` + const fullPath = `${settings.exportPath}/${filename}` + + const result = await exportToFile(payload as unknown as Record, fullPath) + if (result.success) { + setExportStatus("success") + setStatusMessage(`Saved to ${result.path}`) + onAddToRecent(session) + } else { + setExportStatus("error") + setStatusMessage(result.error || "Export failed") + } + } + + async function handleUpload() { + if (!settings) return + if (!settings.apiKey) { + setError("No API key configured. Set one in the Settings tab.") + return + } + setError("") + setUploadStatus("loading") + setStatusMessage("") + + const payload = buildImportPayload(session) + const result = await uploadToDeckyvault( + payload as unknown as Record, + settings.apiKey, + settings.baseUrl, + ) + + if (result.success) { + setUploadStatus("success") + setStatusMessage(`Uploaded! Entry ID: ${result.data?.id}`) + onAddToRecent(session) + } else { + setUploadStatus("error") + setStatusMessage(result.error || "Upload failed") + if (result.status === 404) { + setStatusMessage("This game isn't in DeckyVault yet. Submit it on the website first, or export to file.") + } + } + } + + return ( + + {/* ── Auto-captured summary ──────────────────────────────── */} + + +
+ Game: {session.gameName || "Unknown"}
+ {session.appId && <>App ID: {session.appId}
} + FPS: {session.fpsAvg ?? "—"} avg / {session.fpsLow ?? "—"} min / {session.fpsOnePercentLow ?? "—"} 1% low / {session.fpsHigh ?? "—"} max
+ TDP: {session.tdpWatts ? `${session.tdpWatts}W` : "—"}
+ Hardware: {session.hardwareName || session.hardwareSlug || "—"}
+ OS: {session.osVersion || "—"}
+ Proton: {session.protonVersion || "—"} +
+
+
+ + {/* ── Manual inputs ──────────────────────────────────────── */} + + + onUpdateSession({ upscalerType: opt.data as string })} + /> + + + + + onUpdateSession({ upscalerVersion: e.target.value })} + placeholder="e.g. 2.4" + style={{ width: "100%", padding: "4px 8px" }} + /> + + + + + onUpdateSession({ frameGenMethod: opt.data as string })} + /> + + + + +