feat: QR code phone pairing for Decky plugin + MangoHud guide revision + SteamOS version fix

- Add plugin_pairings table + migration (0028) for short-lived pairing sessions
- Add pairing API: POST /api/plugin/pair/initiate, GET /status/:token, POST /confirm
- Add /pair web page: user scans QR on phone, confirms, API key auto-created
- Plugin: 'Pair with Phone' button renders QR code (qrcode.react), polls for status, saves API key
- Plugin: new initiate_pair + check_pair_status Python RPCs
- Revise MangoHud Setup Guide into clean numbered steps
- Fix SteamOS version detection: read VERSION_ID + BUILD_ID (was just 'SteamOS')
This commit is contained in:
2026-06-28 22:09:39 +08:00
parent b637b55011
commit 1de08ed4ec
12 changed files with 3588 additions and 18 deletions
+222 -12
View File
@@ -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 (
<PanelSectionRow>
<div style={{ display: "flex", gap: "10px", padding: "6px 0", alignItems: "flex-start" }}>
<div style={{
flexShrink: 0,
width: "22px",
height: "22px",
borderRadius: "50%",
background: "#1b9bf3",
color: "white",
fontSize: "12px",
fontWeight: 700,
display: "flex",
alignItems: "center",
justifyContent: "center",
}}>
{number}
</div>
<div className={staticClasses.Text} style={{ fontSize: "12px", lineHeight: "1.45", flex: 1 }}>
<div style={{ fontWeight: 600, marginBottom: "2px" }}>{title}</div>
<div style={{ opacity: 0.7 }}>{body}</div>
</div>
</div>
</PanelSectionRow>
)
}
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<ReturnType<typeof setInterval> | 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({
)}
</PanelSection>
{/* ── Account ─────────────────────────────────────────── */}
<PanelSection title="Account">
{pairState.status === "idle" && (
<>
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", padding: "4px 0", lineHeight: "1.5", opacity: 0.7 }}>
Link this plugin to your DeckyVault account by scanning a QR code with your phone no manual key entry needed.
</div>
</PanelSectionRow>
<PanelSectionRow>
<ButtonItem layout="below" onClick={handleStartPairing}>
<div style={{ display: "flex", alignItems: "center", gap: "8px", justifyContent: "center" }}>
<FaQrcode />
Pair with Phone
</div>
</ButtonItem>
</PanelSectionRow>
</>
)}
{pairState.status === "starting" && (
<PanelSectionRow>
<div className={staticClasses.Text} style={{ padding: "12px 0", textAlign: "center", fontSize: "13px", opacity: 0.7 }}>
Starting pairing session
</div>
</PanelSectionRow>
)}
{(pairState.status === "showing-qr" || pairState.status === "polling") && (
<>
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", padding: "4px 0", lineHeight: "1.5", opacity: 0.8 }}>
Scan this code with your phone's camera, then confirm on the page that opens.
</div>
</PanelSectionRow>
<PanelSectionRow>
<div style={{ display: "flex", justifyContent: "center", padding: "12px 0", background: "#fff", borderRadius: "12px" }}>
<QRCodeSVG value={pairState.qrUrl} size={180} level="M" />
</div>
</PanelSectionRow>
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", padding: "4px 0", textAlign: "center", opacity: 0.6 }}>
Waiting for confirmation…
</div>
</PanelSectionRow>
<PanelSectionRow>
<ButtonItem layout="below" onClick={handleCancelPairing}>
<div style={{ display: "flex", alignItems: "center", gap: "8px", justifyContent: "center" }}>
<FaTimes />
Cancel
</div>
</ButtonItem>
</PanelSectionRow>
</>
)}
{pairState.status === "linked" && (
<>
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "13px", color: "#2ecc71", padding: "4px 0", textAlign: "center" }}>
<FaCheck /> Plugin linked to your account!
</div>
</PanelSectionRow>
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "11px", opacity: 0.6, padding: "4px 0", textAlign: "center" }}>
API key saved. You can now upload performance entries.
</div>
</PanelSectionRow>
<PanelSectionRow>
<ButtonItem layout="below" onClick={handleCancelPairing}>
<div style={{ display: "flex", alignItems: "center", gap: "8px", justifyContent: "center" }}>
<FaLink />
Done
</div>
</ButtonItem>
</PanelSectionRow>
</>
)}
{pairState.status === "error" && (
<>
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", color: "#e74c3c", padding: "4px 0" }}>
<FaTimes /> {pairState.error}
</div>
</PanelSectionRow>
<PanelSectionRow>
<ButtonItem layout="below" onClick={handleStartPairing}>
<div style={{ display: "flex", alignItems: "center", gap: "8px", justifyContent: "center" }}>
<FaQrcode />
Try Again
</div>
</ButtonItem>
</PanelSectionRow>
<PanelSectionRow>
<ButtonItem layout="below" onClick={handleCancelPairing}>
<div style={{ display: "flex", alignItems: "center", gap: "8px", justifyContent: "center" }}>
Dismiss
</div>
</ButtonItem>
</PanelSectionRow>
</>
)}
</PanelSection>
{/* ── Usage Instructions ──────────────────────────────────── */}
<PanelSection title="Usage Instructions">
<PanelSectionRow>
@@ -458,22 +668,22 @@ export default function MainPanel({
)}
</PanelSection>
{/* ── MangoHud Setup Guide ────────────────────────────────── */}
{/* ── MangoHud Setup Guide ────────────────────────────── */}
<PanelSection title="MangoHud Setup Guide">
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", padding: "8px", lineHeight: "1.6" }}>
<strong>Steam Deck (SteamOS):</strong> MangoHud is pre-installed. Add <code>mangohud %command%</code> to your game's Steam launch options (right-click Properties Launch Options).
<div className={staticClasses.Text} style={{ fontSize: "12px", padding: "4px 0 8px 0", lineHeight: "1.5", opacity: 0.7 }}>
Follow these steps once to enable performance logging.
</div>
</PanelSectionRow>
<SetupStep number={1} title="Write MangoHud Config" body="Tap 'Write Config' above. This creates the logging config and a wrapper script automatically." />
<SetupStep number={2} title="Add the Launch Option" body="Right-click your game in Steam Properties Launch Options, and paste the launch option above." />
<SetupStep number={3} title="Launch the Game" body="Start the game from Steam. MangoHud loads automatically using the wrapper script." />
<SetupStep number={4} title="Record While Playing" body="Once in-game, open this panel and press Start Recording. Press Stop when done." />
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", padding: "8px", lineHeight: "1.6" }}>
<strong>Other Linux:</strong> Install via <code>sudo apt install mangohud</code> or <code>flatpak install ...VulkanLayer.MangoHud</code>. See{" "}
<a href="https://github.com/flightlessmango/MangoHud" style={{ color: "#66c0f4" }}>github.com/flightlessmango/MangoHud</a>.
</div>
</PanelSectionRow>
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", padding: "8px", lineHeight: "1.6" }}>
<strong>Troubleshooting:</strong> Log empty? Check MangoHud is enabled. Not attaching? Add <code>mangohud %command%</code> to launch options explicitly.
<div className={staticClasses.Text} style={{ fontSize: "11px", padding: "10px 0 0 0", lineHeight: "1.5", opacity: 0.5, borderTop: "1px solid rgba(255,255,255,0.08)", marginTop: "8px" }}>
Steam Deck ships with MangoHud pre-installed. On other Linux distros, install it with <span style={{ fontFamily: "monospace", opacity: 0.8 }}>sudo apt install mangohud</span> or via Flatpak.
</div>
</PanelSectionRow>
</PanelSection>
+17 -1
View File
@@ -116,4 +116,20 @@ export const importConfig = callable<[], {
hardwareSlug: string | null
}
error?: string
}>("import_config")
}>("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")