diff --git a/apps/web/app/pair/page.tsx b/apps/web/app/pair/page.tsx new file mode 100644 index 0000000..b7f3b69 --- /dev/null +++ b/apps/web/app/pair/page.tsx @@ -0,0 +1,291 @@ +"use client" + +import { useState, useEffect, Suspense } from "react" +import { useSearchParams, useRouter } from "next/navigation" +import Link from "next/link" +import { useSession } from "@/lib/auth-client" +import { + QrCodeIcon, + CheckCircle2, + Loader2, + AlertCircle, + Gamepad2, + ShieldCheck, + ArrowRight, +} from "lucide-react" +import { motion } from "motion/react" + +function PairContent() { + const searchParams = useSearchParams() + const router = useRouter() + const token = searchParams.get("token") + const { data: session, isPending } = useSession() + + const [status, setStatus] = useState<"idle" | "confirming" | "success" | "error">("idle") + const [error, setError] = useState(null) + const [checkedToken, setCheckedToken] = useState(false) + const [tokenValid, setTokenValid] = useState(null) + + // Validate the token exists on the server before showing the confirm UI + useEffect(() => { + if (!token) { + setCheckedToken(true) + setTokenValid(false) + return + } + let cancelled = false + fetch(`/api/plugin/pair/status/${encodeURIComponent(token)}`) + .then(async (res) => { + if (cancelled) return + const data = await res.json() + if (data.status === "pending") { + setTokenValid(true) + } else if (data.status === "confirmed") { + setTokenValid(false) + setError("This pairing link has already been used.") + } else { + setTokenValid(false) + setError(data.error || "This pairing link is invalid or expired.") + } + }) + .catch(() => { + if (!cancelled) { + setTokenValid(false) + setError("Could not verify pairing link.") + } + }) + .finally(() => { + if (!cancelled) setCheckedToken(true) + }) + return () => { + cancelled = true + } + }, [token]) + + async function handleConfirm() { + if (!token) return + setStatus("confirming") + setError(null) + try { + const res = await fetch("/api/plugin/pair/confirm", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ token }), + }) + const data = await res.json() + if (!res.ok) { + setStatus("error") + setError(data.error || "Failed to link plugin.") + return + } + setStatus("success") + } catch { + setStatus("error") + setError("Network error. Please try again.") + } + } + + // ── Loading ─────────────────────────────────────────────── + if (isPending || !checkedToken) { + return ( +
+ +
+ ) + } + + // ── No token ────────────────────────────────────────────── + if (!token || tokenValid === false) { + return ( + + +

+ Invalid Pairing Link +

+

+ {error || + "This link is missing a token or has expired. Start a new pairing session from the DeckyVault plugin on your Steam Deck."} +

+ + Go Home + +
+ ) + } + + // ── Not logged in ───────────────────────────────────────── + if (!session) { + const callback = encodeURIComponent(`/pair?token=${token}`) + return ( + + +

+ Log in to link your plugin +

+

+ You need to be logged in to DeckyVault so we can create an API + key for your account. +

+
+ + Log In + + + + Create Account + +
+
+ ) + } + + // ── Success ─────────────────────────────────────────────── + if (status === "success") { + return ( + + + + +

+ Plugin Linked! +

+

+ Your DeckyVault plugin is now connected to{" "} + {session.user.name} + 's account. An API key named{" "} + Decky Loader Plugin{" "} + was created. You can close this page and return to your Steam Deck. +

+ + Manage API Keys + +
+ ) + } + + // ── Confirm ─────────────────────────────────────────────── + return ( + +
+
+ +
+
+ +
+
+ +

+ Link DeckyVault Plugin +

+

+ Confirm to connect the DeckyVault plugin on your Steam Deck to your + account. We'll create an API key so the plugin can upload + performance entries on your behalf. +

+ +
+
+ {session.user.image ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
+ {session.user.name?.[0]?.toUpperCase()} +
+ )} +
+

+ {session.user.name} +

+

+ {session.user.email} +

+
+
+
+ +
+ + + An API key (Decky Loader Plugin) + will be created for your account. You can revoke it anytime from + Settings → API Keys. + +
+ + {error && ( +
+ + {error} +
+ )} + + +
+ ) +} + +function CenterCard({ children }: { children: React.ReactNode }) { + return ( +
+ + {children} + +
+ ) +} + +export default function PairPage() { + return ( + + + + } + > + + + ) +} \ No newline at end of file diff --git a/apps/web/drizzle/0028_thin_bloodstrike.sql b/apps/web/drizzle/0028_thin_bloodstrike.sql new file mode 100644 index 0000000..4d6e682 --- /dev/null +++ b/apps/web/drizzle/0028_thin_bloodstrike.sql @@ -0,0 +1,12 @@ +CREATE TABLE "plugin_pairings" ( + "token" text PRIMARY KEY NOT NULL, + "user_id" text, + "api_key_id" text, + "api_key" text, + "status" text DEFAULT 'pending' NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "confirmed_at" timestamp, + "expires_at" timestamp NOT NULL +); +--> statement-breakpoint +CREATE INDEX "plugin_pairings_userId_idx" ON "plugin_pairings" USING btree ("user_id"); \ No newline at end of file diff --git a/apps/web/drizzle/meta/0028_snapshot.json b/apps/web/drizzle/meta/0028_snapshot.json new file mode 100644 index 0000000..134b1b0 --- /dev/null +++ b/apps/web/drizzle/meta/0028_snapshot.json @@ -0,0 +1,2719 @@ +{ + "id": "a32b2356-e9db-4a67-8e4d-09c595327a41", + "prevId": "1e0b0a96-5871-49cf-9081-29944205d936", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_userId_idx": { + "name": "account_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.apikey": { + "name": "apikey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "config_id": { + "name": "config_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "start": { + "name": "start", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "prefix": { + "name": "prefix", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refill_interval": { + "name": "refill_interval", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "refill_amount": { + "name": "refill_amount", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "rate_limit_enabled": { + "name": "rate_limit_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "rate_limit_time_window": { + "name": "rate_limit_time_window", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "rate_limit_max": { + "name": "rate_limit_max", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "request_count": { + "name": "request_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "remaining": { + "name": "remaining", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "last_request": { + "name": "last_request", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "permissions": { + "name": "permissions", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "apikey_config_id_idx": { + "name": "apikey_config_id_idx", + "columns": [ + { + "expression": "config_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikey_reference_id_idx": { + "name": "apikey_reference_id_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "apikey_key_idx": { + "name": "apikey_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.passkey": { + "name": "passkey", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "public_key": { + "name": "public_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "counter": { + "name": "counter", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backed_up": { + "name": "backed_up", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "transports": { + "name": "transports", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "aaguid": { + "name": "aaguid", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "passkey_userId_idx": { + "name": "passkey_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "passkey_credentialID_idx": { + "name": "passkey_credentialID_idx", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "passkey_user_id_user_id_fk": { + "name": "passkey_user_id_user_id_fk", + "tableFrom": "passkey", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_pairings": { + "name": "plugin_pairings", + "schema": "", + "columns": { + "token": { + "name": "token", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key_id": { + "name": "api_key_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "api_key": { + "name": "api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "confirmed_at": { + "name": "confirmed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "plugin_pairings_userId_idx": { + "name": "plugin_pairings_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_userId_idx": { + "name": "session_userId_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_login_method": { + "name": "last_login_method", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.community_suggestions": { + "name": "community_suggestions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_name": { + "name": "field_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "current_value": { + "name": "current_value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "proposed_value": { + "name": "proposed_value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "suggestion_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "reviewed_by": { + "name": "reviewed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reviewed_at": { + "name": "reviewed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "review_note": { + "name": "review_note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "community_suggestions_game_field_user": { + "name": "community_suggestions_game_field_user", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "field_name", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "suggestions_status_idx": { + "name": "suggestions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "community_suggestions_game_id_games_id_fk": { + "name": "community_suggestions_game_id_games_id_fk", + "tableFrom": "community_suggestions", + "tableTo": "games", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "community_suggestions_user_id_user_id_fk": { + "name": "community_suggestions_user_id_user_id_fk", + "tableFrom": "community_suggestions", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "community_suggestions_reviewed_by_user_id_fk": { + "name": "community_suggestions_reviewed_by_user_id_fk", + "tableFrom": "community_suggestions", + "tableTo": "user", + "columnsFrom": [ + "reviewed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.entry_screenshots": { + "name": "entry_screenshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "entry_id": { + "name": "entry_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order_index": { + "name": "order_index", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "width": { + "name": "width", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "height": { + "name": "height", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "entry_screenshots_entry_idx": { + "name": "entry_screenshots_entry_idx", + "columns": [ + { + "expression": "entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "entry_screenshots_entry_id_performance_entries_id_fk": { + "name": "entry_screenshots_entry_id_performance_entries_id_fk", + "tableFrom": "entry_screenshots", + "tableTo": "performance_entries", + "columnsFrom": [ + "entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_comments": { + "name": "game_comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "upvotes": { + "name": "upvotes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_removed": { + "name": "is_removed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "comments_game_created_idx": { + "name": "comments_game_created_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "comments_parent_idx": { + "name": "comments_parent_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "game_comments_game_id_games_id_fk": { + "name": "game_comments_game_id_games_id_fk", + "tableFrom": "game_comments", + "tableTo": "games", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "game_comments_user_id_user_id_fk": { + "name": "game_comments_user_id_user_id_fk", + "tableFrom": "game_comments", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "game_comments_parent_id_game_comments_id_fk": { + "name": "game_comments_parent_id_game_comments_id_fk", + "tableFrom": "game_comments", + "tableTo": "game_comments", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_platform_support": { + "name": "game_platform_support", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hardware_slug": { + "name": "hardware_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_supported": { + "name": "is_supported", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "proton_status": { + "name": "proton_status", + "type": "proton_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "anti_cheat_relevant": { + "name": "anti_cheat_relevant", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "anti_cheat_name": { + "name": "anti_cheat_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "anti_cheat_version": { + "name": "anti_cheat_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "anti_cheat_status": { + "name": "anti_cheat_status", + "type": "anti_cheat_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "playability_status": { + "name": "playability_status", + "type": "playability_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "playability_override": { + "name": "playability_override", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "playability_calculated_at": { + "name": "playability_calculated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "game_platform_support_game_id_games_id_fk": { + "name": "game_platform_support_game_id_games_id_fk", + "tableFrom": "game_platform_support", + "tableTo": "games", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "game_platform_support_hardware_slug_hardware_slug_fk": { + "name": "game_platform_support_hardware_slug_hardware_slug_fk", + "tableFrom": "game_platform_support", + "tableTo": "hardware", + "columnsFrom": [ + "hardware_slug" + ], + "columnsTo": [ + "slug" + ], + "onDelete": "restrict", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "game_hardware_unique": { + "name": "game_hardware_unique", + "nullsNotDistinct": false, + "columns": [ + "game_id", + "hardware_slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.game_versions": { + "name": "game_versions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "build_id": { + "name": "build_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "version_string": { + "name": "version_string", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_latest": { + "name": "is_latest", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "perf_game_lookup_idx": { + "name": "perf_game_lookup_idx", + "columns": [ + { + "expression": "game_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "game_versions_game_id_games_id_fk": { + "name": "game_versions_game_id_games_id_fk", + "tableFrom": "game_versions", + "tableTo": "games", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "game_build_unique": { + "name": "game_build_unique", + "nullsNotDistinct": false, + "columns": [ + "game_id", + "build_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.games": { + "name": "games", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "steam_app_id": { + "name": "steam_app_id", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "game_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'steam'" + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "publisher": { + "name": "publisher", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "developer": { + "name": "developer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "genres": { + "name": "genres", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "header_image": { + "name": "header_image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "capsule_image": { + "name": "capsule_image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "store_url": { + "name": "store_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "online_multiplayer_status": { + "name": "online_multiplayer_status", + "type": "online_multiplayer_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "system_requirements": { + "name": "system_requirements", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metacritic_score": { + "name": "metacritic_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metacritic_url": { + "name": "metacritic_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "recommendations_total": { + "name": "recommendations_total", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "steam_review_score": { + "name": "steam_review_score", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "steam_review_sentiment": { + "name": "steam_review_sentiment", + "type": "steam_review_sentiment", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "steam_review_count": { + "name": "steam_review_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "playability_status": { + "name": "playability_status", + "type": "playability_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "playability_override": { + "name": "playability_override", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "playability_calculated_at": { + "name": "playability_calculated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "price_current": { + "name": "price_current", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "price_initial": { + "name": "price_initial", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "price_currency": { + "name": "price_currency", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_free": { + "name": "is_free", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "release_date": { + "name": "release_date", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "categories": { + "name": "categories", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "platforms": { + "name": "platforms", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "last_sync": { + "name": "last_sync", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "sync_status": { + "name": "sync_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'pending'" + }, + "sync_error": { + "name": "sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sync_retry_count": { + "name": "sync_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "sync_next_retry": { + "name": "sync_next_retry", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "games_source_idx": { + "name": "games_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "games_sync_status_idx": { + "name": "games_sync_status_idx", + "columns": [ + { + "expression": "sync_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "steam_app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "games_steam_app_id_unique": { + "name": "games_steam_app_id_unique", + "nullsNotDistinct": false, + "columns": [ + "steam_app_id" + ] + }, + "games_slug_unique": { + "name": "games_slug_unique", + "nullsNotDistinct": false, + "columns": [ + "slug" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.hardware": { + "name": "hardware", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "device_type": { + "name": "device_type", + "type": "device_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "watt_hours": { + "name": "watt_hours", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "tdp_max": { + "name": "tdp_max", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.performance_entries": { + "name": "performance_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "version_id": { + "name": "version_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hardware_slug": { + "name": "hardware_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "fps_avg": { + "name": "fps_avg", + "type": "real", + "primaryKey": false, + "notNull": true + }, + "fps_low": { + "name": "fps_low", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "fps_one_percent_low": { + "name": "fps_one_percent_low", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "fps_high": { + "name": "fps_high", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "proton_version": { + "name": "proton_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "os_version": { + "name": "os_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "upscaler_type": { + "name": "upscaler_type", + "type": "upscaler_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "upscaler_version": { + "name": "upscaler_version", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "frame_gen_method": { + "name": "frame_gen_method", + "type": "frame_gen_method", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'none'" + }, + "load_time_ssd": { + "name": "load_time_ssd", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "load_time_sd": { + "name": "load_time_sd", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "launch_options": { + "name": "launch_options", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "settings_json": { + "name": "settings_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "user_notes": { + "name": "user_notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tdp_watts": { + "name": "tdp_watts", + "type": "real", + "primaryKey": false, + "notNull": false + }, + "youtube_video_id": { + "name": "youtube_video_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "custom_system": { + "name": "custom_system", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_removed": { + "name": "is_removed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "is_pinned": { + "name": "is_pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "pinned_at": { + "name": "pinned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "removed_reason": { + "name": "removed_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "upvotes": { + "name": "upvotes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "downvotes": { + "name": "downvotes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "verified_at": { + "name": "verified_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "verified_by": { + "name": "verified_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "perf_hardware_upscaler_idx": { + "name": "perf_hardware_upscaler_idx", + "columns": [ + { + "expression": "hardware_slug", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "upscaler_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "perf_version_idx": { + "name": "perf_version_idx", + "columns": [ + { + "expression": "version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "perf_user_idx": { + "name": "perf_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "perf_removed_created_idx": { + "name": "perf_removed_created_idx", + "columns": [ + { + "expression": "is_removed", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "perf_upvotes_idx": { + "name": "perf_upvotes_idx", + "columns": [ + { + "expression": "upvotes", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "performance_entries_version_id_game_versions_id_fk": { + "name": "performance_entries_version_id_game_versions_id_fk", + "tableFrom": "performance_entries", + "tableTo": "game_versions", + "columnsFrom": [ + "version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "performance_entries_hardware_slug_hardware_slug_fk": { + "name": "performance_entries_hardware_slug_hardware_slug_fk", + "tableFrom": "performance_entries", + "tableTo": "hardware", + "columnsFrom": [ + "hardware_slug" + ], + "columnsTo": [ + "slug" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, + "performance_entries_user_id_user_id_fk": { + "name": "performance_entries_user_id_user_id_fk", + "tableFrom": "performance_entries", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "performance_entries_verified_by_user_id_fk": { + "name": "performance_entries_verified_by_user_id_fk", + "tableFrom": "performance_entries", + "tableTo": "user", + "columnsFrom": [ + "verified_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_games": { + "name": "saved_games", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "game_id": { + "name": "game_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "saved_games_user_id_user_id_fk": { + "name": "saved_games_user_id_user_id_fk", + "tableFrom": "saved_games", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "saved_games_game_id_games_id_fk": { + "name": "saved_games_game_id_games_id_fk", + "tableFrom": "saved_games", + "tableTo": "games", + "columnsFrom": [ + "game_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "saved_games_user_game_unique": { + "name": "saved_games_user_game_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "game_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.reports": { + "name": "reports", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "entry_id": { + "name": "entry_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reporter_id": { + "name": "reporter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reason": { + "name": "reason", + "type": "report_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "details": { + "name": "details", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "report_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'open'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "reports_entry_reporter_unique": { + "name": "reports_entry_reporter_unique", + "columns": [ + { + "expression": "entry_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "reporter_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "reports_status_idx": { + "name": "reports_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "reports_entry_id_performance_entries_id_fk": { + "name": "reports_entry_id_performance_entries_id_fk", + "tableFrom": "reports", + "tableTo": "performance_entries", + "columnsFrom": [ + "entry_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "reports_reporter_id_user_id_fk": { + "name": "reports_reporter_id_user_id_fk", + "tableFrom": "reports", + "tableTo": "user", + "columnsFrom": [ + "reporter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.saved_filters": { + "name": "saved_filters", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filters": { + "name": "filters", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "saved_filters_user_id_user_id_fk": { + "name": "saved_filters_user_id_user_id_fk", + "tableFrom": "saved_filters", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "saved_filters_user_name": { + "name": "saved_filters_user_name", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.storage_objects": { + "name": "storage_objects", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bucket": { + "name": "bucket", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "last_accessed_at": { + "name": "last_accessed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_orphaned": { + "name": "is_orphaned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "storage_entity_idx": { + "name": "storage_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "storage_key_idx": { + "name": "storage_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "storage_objects_uploaded_by_user_id_fk": { + "name": "storage_objects_uploaded_by_user_id_fk", + "tableFrom": "storage_objects", + "tableTo": "user", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.suggestion_status": { + "name": "suggestion_status", + "schema": "public", + "values": [ + "pending", + "approved", + "rejected" + ] + }, + "public.anti_cheat_status": { + "name": "anti_cheat_status", + "schema": "public", + "values": [ + "none", + "supported", + "unsupported", + "unknown" + ] + }, + "public.proton_status": { + "name": "proton_status", + "schema": "public", + "values": [ + "native", + "proton", + "unsupported", + "unknown" + ] + }, + "public.game_source": { + "name": "game_source", + "schema": "public", + "values": [ + "steam", + "manual", + "gog", + "epic" + ] + }, + "public.online_multiplayer_status": { + "name": "online_multiplayer_status", + "schema": "public", + "values": [ + "none", + "supported", + "unknown" + ] + }, + "public.playability_status": { + "name": "playability_status", + "schema": "public", + "values": [ + "great", + "playable", + "needs_tweaks", + "unplayable", + "unknown" + ] + }, + "public.steam_review_sentiment": { + "name": "steam_review_sentiment", + "schema": "public", + "values": [ + "overwhelmingly_positive", + "very_positive", + "positive", + "mostly_positive", + "mixed", + "mostly_negative", + "negative", + "very_negative", + "overwhelmingly_negative" + ] + }, + "public.device_type": { + "name": "device_type", + "schema": "public", + "values": [ + "handheld", + "console" + ] + }, + "public.frame_gen_method": { + "name": "frame_gen_method", + "schema": "public", + "values": [ + "none", + "fsr_fg", + "dlss_fg", + "lsfg", + "other" + ] + }, + "public.upscaler_type": { + "name": "upscaler_type", + "schema": "public", + "values": [ + "none", + "fsr", + "dlss", + "xess", + "lsfg", + "other" + ] + }, + "public.report_reason": { + "name": "report_reason", + "schema": "public", + "values": [ + "inaccurate", + "spam", + "inappropriate", + "other" + ] + }, + "public.report_status": { + "name": "report_status", + "schema": "public", + "values": [ + "open", + "reviewed", + "dismissed" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/web/drizzle/meta/_journal.json b/apps/web/drizzle/meta/_journal.json index 4d2d5b4..28b02f4 100644 --- a/apps/web/drizzle/meta/_journal.json +++ b/apps/web/drizzle/meta/_journal.json @@ -197,6 +197,13 @@ "when": 1782593630361, "tag": "0027_slimy_stryfe", "breakpoints": true + }, + { + "idx": 28, + "version": "7", + "when": 1782655457218, + "tag": "0028_thin_bloodstrike", + "breakpoints": true } ] } \ No newline at end of file diff --git a/apps/web/lib/api/app.ts b/apps/web/lib/api/app.ts index 508f2cb..d7d629e 100644 --- a/apps/web/lib/api/app.ts +++ b/apps/web/lib/api/app.ts @@ -52,6 +52,7 @@ import { eq } from "drizzle-orm" import { mobileRoutes } from "@/lib/api/mobile" import { gamesLookupRoutes } from "@/lib/api/games-lookup" import { performanceImportRoutes } from "@/lib/api/performance-import" +import { pluginPairingRoutes } from "@/lib/api/plugin-pairing" const betterAuth = new Elysia({ name: "better-auth" }) .mount(auth.handler) @@ -245,6 +246,7 @@ export const app = new Elysia({ prefix: "/api" }) .use(screenshotRoutes) .use(gamesLookupRoutes) .use(mobileRoutes) + .use(pluginPairingRoutes) ) // ── Write routes ─────────────────────────────────────────── .group("", (app) => diff --git a/apps/web/lib/api/plugin-pairing.ts b/apps/web/lib/api/plugin-pairing.ts new file mode 100644 index 0000000..d13ff0b --- /dev/null +++ b/apps/web/lib/api/plugin-pairing.ts @@ -0,0 +1,200 @@ +import { Elysia, t } from "elysia" +import { randomBytes } from "node:crypto" +import { db } from "@/lib/db/index" +import { pluginPairing } from "@/lib/db/schema" +import { eq, and, lt } from "drizzle-orm" +import { auth } from "@/lib/auth" + +const PAIRING_TTL_MS = 10 * 60 * 1000 // 10 minutes + +function generatePairingToken(): string { + return "pair_" + randomBytes(24).toString("hex") +} + +/** + * Build the public site origin used in the QR code URL. + * Prefers the configured BETTER_AUTH_URL, falls back to the request origin. + */ +function getSiteOrigin(request: Request): string { + const configured = process.env.BETTER_AUTH_URL + if (configured) return configured.replace(/\/$/, "") + const url = new URL(request.url) + return `${url.protocol}//${url.host}` +} + +export const pluginPairingRoutes = new Elysia({ + prefix: "/plugin", + detail: { tags: ["Plugin"] }, +}) + // ── Initiate: plugin requests a pairing token (no auth) ────── + .post( + "/pair/initiate", + async ({ request, set }) => { + const token = generatePairingToken() + const now = new Date() + const expiresAt = new Date(now.getTime() + PAIRING_TTL_MS) + const origin = getSiteOrigin(request) + + await db.insert(pluginPairing).values({ + token, + status: "pending", + createdAt: now, + expiresAt, + }) + + return { + token, + qrUrl: `${origin}/pair?token=${token}`, + expiresAt: expiresAt.toISOString(), + } + }, + { + detail: { + summary: "Initiate Decky plugin pairing", + description: + "Creates a short-lived pairing token. The returned qrUrl should be shown as a QR code in the plugin for the user to scan with a logged-in phone.", + }, + }, + ) + // ── Status: plugin polls until confirmed (no auth, token-gated) ── + .get( + "/pair/status/:token", + async ({ params, set }) => { + const [row] = await db + .select() + .from(pluginPairing) + .where(eq(pluginPairing.token, params.token)) + .limit(1) + + if (!row) { + set.status = 404 + return { status: "invalid", error: "Pairing session not found" } + } + + // Expired and not confirmed + if (row.expiresAt < new Date() && row.status !== "confirmed") { + set.status = 410 + return { status: "expired", error: "Pairing session expired" } + } + + if (row.status === "confirmed" && row.apiKey) { + // Hand over the plaintext key and clear it from the row so it + // can only be retrieved once. + const apiKey = row.apiKey + await db + .update(pluginPairing) + .set({ apiKey: null }) + .where(eq(pluginPairing.token, params.token)) + + return { + status: "confirmed", + apiKey, + keyName: "Decky Loader Plugin", + } + } + + return { status: "pending" } + }, + { + detail: { + summary: "Check Decky plugin pairing status", + description: + "Polled by the plugin until the user confirms on their phone. Returns the plaintext API key once confirmed (one-time retrieval).", + }, + }, + ) + // ── Confirm: user on phone confirms pairing (session auth) ──── + .post( + "/pair/confirm", + async ({ body, request, set }) => { + const session = await auth.api.getSession({ headers: request.headers }) + if (!session) { + set.status = 401 + return { error: "You must be logged in to confirm pairing" } + } + + const { token } = body + const [row] = await db + .select() + .from(pluginPairing) + .where(eq(pluginPairing.token, token)) + .limit(1) + + if (!row) { + set.status = 404 + return { error: "Pairing session not found" } + } + + if (row.expiresAt < new Date()) { + set.status = 410 + return { error: "Pairing session expired. Start again on your Deck." } + } + + if (row.status === "confirmed") { + set.status = 409 + return { error: "This pairing session has already been confirmed" } + } + + // Create an API key for this user via Better Auth (server-side). + // name is required by our api-key plugin config (requireName: true). + let created: { key: string; id: string } | null = null + try { + const result = (await auth.api.createApiKey({ + body: { + name: "Decky Loader Plugin", + userId: session.user.id, + }, + })) as unknown as { key: string; id: string } + created = { key: result.key, id: result.id } + } catch (err) { + console.error("[plugin-pairing] createApiKey failed:", err) + set.status = 500 + return { error: "Failed to create API key" } + } + + if (!created || !created.key) { + set.status = 500 + return { error: "Failed to create API key" } + } + + // Link the pairing session to the user + key + await db + .update(pluginPairing) + .set({ + userId: session.user.id, + apiKeyId: created.id, + apiKey: created.key, + status: "confirmed", + confirmedAt: new Date(), + }) + .where(eq(pluginPairing.token, token)) + + return { + success: true, + keyName: "Decky Loader Plugin", + } + }, + { + body: t.Object({ + token: t.String(), + }), + detail: { + summary: "Confirm Decky plugin pairing", + description: + "Called from the /pair page by a logged-in user. Creates an API key for the account and links it to the pairing token so the plugin can retrieve it.", + }, + }, + ) + // ── Cleanup: periodically delete expired pairings ──────────── + .post( + "/pair/cleanup", + async () => { + await db + .delete(pluginPairing) + .where(lt(pluginPairing.expiresAt, new Date())) + return { success: true } + }, + { + detail: { hide: true }, + }, + ) \ No newline at end of file diff --git a/apps/web/lib/db/schema/auth.ts b/apps/web/lib/db/schema/auth.ts index 2b1b8cc..f01b08c 100644 --- a/apps/web/lib/db/schema/auth.ts +++ b/apps/web/lib/db/schema/auth.ts @@ -178,3 +178,30 @@ export const apikeyRelations = relations(apikey, ({ one }) => ({ references: [user.id], }), })) + +// ── Decky plugin pairing ──────────────────────────────────── +// Short-lived pairing sessions that let a Steam Deck link to a +// user account by scanning a QR code on a logged-in phone. +export const pluginPairing = pgTable( + "plugin_pairings", + { + token: text("token").primaryKey(), + userId: text("user_id"), + apiKeyId: text("api_key_id"), + // Plaintext API key, only present between confirm and the plugin + // retrieving it. Cleared once the plugin has fetched it. + apiKey: text("api_key"), + status: text("status").default("pending").notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + confirmedAt: timestamp("confirmed_at"), + expiresAt: timestamp("expires_at").notNull(), + }, + (table) => [index("plugin_pairings_userId_idx").on(table.userId)], +) + +export const pluginPairingRelations = relations(pluginPairing, ({ one }) => ({ + user: one(user, { + fields: [pluginPairing.userId], + references: [user.id], + }), +})) diff --git a/bun.lock b/bun.lock index f4be83f..2c3b24d 100644 --- a/bun.lock +++ b/bun.lock @@ -83,10 +83,11 @@ }, "plugins/decky-vault": { "name": "@deckyvault/plugin", - "version": "0.1.0", + "version": "1.0.0", "dependencies": { "@decky/api": "^1.1.3", "@deckyvault/shared": "workspace:*", + "qrcode.react": "^4.2.0", "react-icons": "^5.3.0", "tslib": "^2.7.0", }, @@ -1669,6 +1670,8 @@ "pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="], + "qrcode.react": ["qrcode.react@4.2.0", "", { "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA=="], + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], "react": ["react@19.2.6", "", {}, "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q=="], diff --git a/plugins/decky-vault/main.py b/plugins/decky-vault/main.py index 15cf6c5..3f673c7 100644 --- a/plugins/decky-vault/main.py +++ b/plugins/decky-vault/main.py @@ -400,13 +400,29 @@ exec mangohud "$@" return {"slug": slug, "name": name, "raw": product_name} async def get_os_version(self) -> str: - """RPC: Read OS version from /etc/os-release.""" + """RPC: Read OS version from /etc/os-release. + SteamOS only puts 'SteamOS' in PRETTY_NAME, so we build a more + useful string from VERSION_ID (and BUILD_ID) instead.""" try: + pretty = "" + version_id = "" + build_id = "" 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" + pretty = line.split("=", 1)[1].strip().strip('"') + elif line.startswith("VERSION_ID="): + version_id = line.split("=", 1)[1].strip().strip('"') + elif line.startswith("BUILD_ID="): + build_id = line.split("=", 1)[1].strip().strip('"') + # For SteamOS, combine name + version id for a meaningful label + if version_id: + name = "SteamOS" if (pretty == "SteamOS" or not pretty) else pretty + label = f"{name} {version_id}".strip() + if build_id: + label += f" (build {build_id})" + return label + return pretty or "unknown" except (IOError, FileNotFoundError): return "unknown" @@ -646,4 +662,70 @@ exec mangohud "$@" } } except Exception as e: - return {"success": False, "error": str(e)} \ No newline at end of file + return {"success": False, "error": str(e)} + + async def initiate_pair(self, base_url: str = "https://deckyvault.xyz") -> dict: + """RPC: Start a plugin pairing session. Returns {success, token, qrUrl, expiresAt, error?}. + The qrUrl should be shown as a QR code in the plugin UI.""" + import urllib.request + import urllib.error + try: + url = f"{base_url}/api/plugin/pair/initiate" + req = urllib.request.Request( + url, + data=b"{}", + headers={ + "Content-Type": "application/json", + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; rv:136.0) Gecko/20100101 Firefox/136.0", + "Accept": "application/json", + }, + method="POST" + ) + context = _get_ssl_context() + with urllib.request.urlopen(req, timeout=10, context=context) as response: + result = json.loads(response.read().decode('utf-8')) + return { + "success": True, + "token": result.get("token", ""), + "qrUrl": result.get("qrUrl", ""), + "expiresAt": result.get("expiresAt", ""), + } + except urllib.error.HTTPError as e: + return {"success": False, "error": f"Server returned status {e.code}"} + except urllib.error.URLError as e: + return {"success": False, "error": f"Network error: {str(e.reason)}"} + except Exception as e: + return {"success": False, "error": str(e)} + + async def check_pair_status(self, token: str, base_url: str = "https://deckyvault.xyz") -> dict: + """RPC: Poll pairing status. Returns {status: 'pending'|'confirmed'|'expired'|'invalid', apiKey?, error?}.""" + import urllib.request + import urllib.error + try: + url = f"{base_url}/api/plugin/pair/status/{token}" + 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: + result = json.loads(response.read().decode('utf-8')) + return { + "status": result.get("status", "pending"), + "apiKey": result.get("apiKey"), + "keyName": result.get("keyName"), + } + except urllib.error.HTTPError as e: + try: + err = json.loads(e.read().decode('utf-8')) + return {"status": err.get("status", "invalid"), "error": err.get("error", f"status {e.code}")} + except Exception: + return {"status": "invalid", "error": f"Server returned status {e.code}"} + except urllib.error.URLError as e: + return {"status": "invalid", "error": f"Network error: {str(e.reason)}"} + except Exception as e: + return {"status": "invalid", "error": str(e)} \ No newline at end of file diff --git a/plugins/decky-vault/package.json b/plugins/decky-vault/package.json index a2c5dd5..f7432f2 100644 --- a/plugins/decky-vault/package.json +++ b/plugins/decky-vault/package.json @@ -10,6 +10,7 @@ "dependencies": { "@decky/api": "^1.1.3", "@deckyvault/shared": "workspace:*", + "qrcode.react": "^4.2.0", "react-icons": "^5.3.0", "tslib": "^2.7.0" }, diff --git a/plugins/decky-vault/src/components/main-panel.tsx b/plugins/decky-vault/src/components/main-panel.tsx index 7be8d4b..56876d9 100644 --- a/plugins/decky-vault/src/components/main-panel.tsx +++ b/plugins/decky-vault/src/components/main-panel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react" +import { useEffect, useState, useRef } from "react" import { ButtonItem, PanelSection, @@ -19,6 +19,8 @@ import { FaFileExport, FaFileImport, FaSearch, + FaQrcode, + FaLink, } from "react-icons/fa" import type { RecordingState, SessionData, RecentSession, PluginSettings } from "../lib/store" import { KNOWN_HARDWARE_SLUGS } from "@deckyvault/shared" @@ -29,7 +31,10 @@ import { getMangohudConfig, exportConfig, importConfig, + initiatePair, + checkPairStatus, } from "../lib/api" +import { QRCodeSVG } from "qrcode.react" import SessionForm from "./session-form" interface MainPanelProps { @@ -53,6 +58,34 @@ const HARDWARE_OPTIONS = [ ...KNOWN_HARDWARE_SLUGS.map((slug) => ({ label: slug, data: slug })), ] +function SetupStep({ number, title, body }: { number: number; title: string; body: string }) { + return ( + +
+
+ {number} +
+
+
{title}
+
{body}
+
+
+
+ ) +} + export default function MainPanel({ recordingState, session, @@ -86,6 +119,15 @@ export default function MainPanel({ }>({ checked: false, valid: false, message: "" }) const [configStatus, setConfigStatus] = useState<{ message: string; isError: boolean } | null>(null) + // ── Pairing state ─────────────────────────────────────── + const [pairState, setPairState] = useState<{ + status: "idle" | "starting" | "showing-qr" | "polling" | "linked" | "error" + qrUrl: string + token: string + error: string + }>({ status: "idle", qrUrl: "", token: "", error: "" }) + const pairPollRef = useRef | null>(null) + // Timer for recording state useEffect(() => { if (recordingState !== "recording") { @@ -198,6 +240,69 @@ export default function MainPanel({ } } + // ── Pairing handlers ──────────────────────────────────── + function stopPairPolling() { + if (pairPollRef.current) { + clearInterval(pairPollRef.current) + pairPollRef.current = null + } + } + + async function handleStartPairing() { + setPairState({ status: "starting", qrUrl: "", token: "", error: "" }) + const result = await initiatePair(settings.baseUrl) + if (!result.success || !result.token || !result.qrUrl) { + setPairState({ + status: "error", + qrUrl: "", + token: "", + error: result.error || "Could not start pairing.", + }) + return + } + setPairState({ + status: "showing-qr", + qrUrl: result.qrUrl || "", + token: result.token || "", + error: "", + }) + + // Begin polling for confirmation + const token = result.token + const baseUrl = settings.baseUrl || "https://deckyvault.xyz" + stopPairPolling() + pairPollRef.current = setInterval(async () => { + const status = await checkPairStatus(token, baseUrl) + if (status.status === "confirmed" && status.apiKey) { + stopPairPolling() + onUpdateSetting("apiKey", status.apiKey) + setPairState({ + status: "linked", + qrUrl: result.qrUrl || "", + token, + error: "", + }) + } else if (status.status === "expired" || status.status === "invalid") { + stopPairPolling() + setPairState((prev) => ({ + ...prev, + status: "error", + error: status.error || "Pairing session expired. Try again.", + })) + } + }, 3000) + } + + function handleCancelPairing() { + stopPairPolling() + setPairState({ status: "idle", qrUrl: "", token: "", error: "" }) + } + + // Clean up polling on unmount + useEffect(() => { + return () => stopPairPolling() + }, []) + // ── Stopped state: show the session form ────────────────────── if (recordingState === "stopped") { return ( @@ -330,6 +435,111 @@ export default function MainPanel({ )} + {/* ── Account ─────────────────────────────────────────── */} + + {pairState.status === "idle" && ( + <> + +
+ Link this plugin to your DeckyVault account by scanning a QR code with your phone — no manual key entry needed. +
+
+ + +
+ + Pair with Phone +
+
+
+ + )} + + {pairState.status === "starting" && ( + +
+ Starting pairing session… +
+
+ )} + + {(pairState.status === "showing-qr" || pairState.status === "polling") && ( + <> + +
+ Scan this code with your phone's camera, then confirm on the page that opens. +
+
+ +
+ +
+
+ +
+ Waiting for confirmation… +
+
+ + +
+ + Cancel +
+
+
+ + )} + + {pairState.status === "linked" && ( + <> + +
+ Plugin linked to your account! +
+
+ +
+ API key saved. You can now upload performance entries. +
+
+ + +
+ + Done +
+
+
+ + )} + + {pairState.status === "error" && ( + <> + +
+ {pairState.error} +
+
+ + +
+ + Try Again +
+
+
+ + +
+ Dismiss +
+
+
+ + )} +
+ {/* ── Usage Instructions ──────────────────────────────────── */} @@ -458,22 +668,22 @@ export default function MainPanel({ )} - {/* ── MangoHud Setup Guide ────────────────────────────────── */} + {/* ── MangoHud Setup Guide ────────────────────────────── */} -
- Steam Deck (SteamOS): MangoHud is pre-installed. Add mangohud %command% to your game's Steam launch options (right-click → Properties → Launch Options). +
+ Follow these steps once to enable performance logging.
+ + + + + + -
- Other Linux: Install via sudo apt install mangohud or flatpak install ...VulkanLayer.MangoHud. See{" "} - github.com/flightlessmango/MangoHud. -
-
- -
- Troubleshooting: Log empty? Check MangoHud is enabled. Not attaching? Add mangohud %command% to launch options explicitly. +
+ Steam Deck ships with MangoHud pre-installed. On other Linux distros, install it with sudo apt install mangohud or via Flatpak.
diff --git a/plugins/decky-vault/src/lib/api.ts b/plugins/decky-vault/src/lib/api.ts index d5627b1..97f676b 100644 --- a/plugins/decky-vault/src/lib/api.ts +++ b/plugins/decky-vault/src/lib/api.ts @@ -116,4 +116,20 @@ export const importConfig = callable<[], { hardwareSlug: string | null } error?: string -}>("import_config") \ No newline at end of file +}>("import_config") + +// ── Plugin Pairing ────────────────────────────────────────── +export const initiatePair = callable<[baseUrl?: string], { + success: boolean + token?: string + qrUrl?: string + expiresAt?: string + error?: string +}>("initiate_pair") + +export const checkPairStatus = callable<[token: string, baseUrl?: string], { + status: "pending" | "confirmed" | "expired" | "invalid" + apiKey?: string + keyName?: string + error?: string +}>("check_pair_status") \ No newline at end of file