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
+86 -4
View File
@@ -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)}
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)}
+1
View File
@@ -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"
},
+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")