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:
@@ -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)}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
Reference in New Issue
Block a user