feat(plugin): restructure layout like LSFG-VK, use mangohudctl for logging

This commit is contained in:
2026-06-28 20:21:33 +08:00
parent c230367d14
commit 6e1957524b
4 changed files with 441 additions and 81 deletions
+34 -1
View File
@@ -192,7 +192,6 @@ class Plugin:
# DeckyVault MangoHud logging config # DeckyVault MangoHud logging config
output_folder=/tmp output_folder=/tmp
output_file=deckyvault-mangohud.log output_file=deckyvault-mangohud.log
autostart_log=0
fps fps
frame_timing frame_timing
cpu_power cpu_power
@@ -217,6 +216,40 @@ benchmark_percentiles=97,AVG,1,0.1
return {"exists": True, "content": f.read(), "path": config_path} return {"exists": True, "content": f.read(), "path": config_path}
return {"exists": False, "content": "", "path": config_path} return {"exists": False, "content": "", "path": config_path}
async def start_mangohud_logging(self) -> dict:
"""RPC: Start MangoHud logging via mangohudctl."""
import subprocess
try:
result = subprocess.run(
["mangohudctl", "set", "log_session", "true"],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
return {"success": True}
else:
return {"success": False, "error": result.stderr.strip() or "mangohudctl failed"}
except FileNotFoundError:
return {"success": False, "error": "mangohudctl not found. Is MangoHud running?"}
except Exception as e:
return {"success": False, "error": str(e)}
async def stop_mangohud_logging(self) -> dict:
"""RPC: Stop MangoHud logging via mangohudctl."""
import subprocess
try:
result = subprocess.run(
["mangohudctl", "set", "log_session", "false"],
capture_output=True, text=True, timeout=5
)
if result.returncode == 0:
return {"success": True}
else:
return {"success": False, "error": result.stderr.strip() or "mangohudctl failed"}
except FileNotFoundError:
return {"success": False, "error": "mangohudctl not found. Is MangoHud running?"}
except Exception as e:
return {"success": False, "error": str(e)}
async def _find_mangohud_log(self) -> str | None: async def _find_mangohud_log(self) -> str | None:
"""Find the most recent MangoHud log file in /tmp/. """Find the most recent MangoHud log file in /tmp/.
MangoHud creates log files with the game name and timestamp.""" MangoHud creates log files with the game name and timestamp."""
+389 -74
View File
@@ -4,15 +4,32 @@ import {
PanelSection, PanelSection,
PanelSectionRow, PanelSectionRow,
TextField, TextField,
DropdownItem,
staticClasses, staticClasses,
} from "@decky/ui" } from "@decky/ui"
import { import {
FaPlay, FaPlay,
FaStop, FaStop,
FaClock, FaClock,
FaGamepad, FaCopy,
FaCheck,
FaTimes,
FaDownload,
FaCog,
FaFileExport,
FaFileImport,
FaSearch,
} from "react-icons/fa" } from "react-icons/fa"
import type { RecordingState, SessionData, RecentSession, PluginSettings } from "../lib/store" import type { RecordingState, SessionData, RecentSession, PluginSettings } from "../lib/store"
import { KNOWN_HARDWARE_SLUGS } from "@deckyvault/shared"
import {
testApiKey,
checkMangohud,
writeMangohudConfig,
getMangohudConfig,
exportConfig,
importConfig,
} from "../lib/api"
import SessionForm from "./session-form" import SessionForm from "./session-form"
interface MainPanelProps { interface MainPanelProps {
@@ -28,8 +45,14 @@ interface MainPanelProps {
onReset: () => void onReset: () => void
setError: (msg: string) => void setError: (msg: string) => void
setGameName: (name: string, appId?: number) => void setGameName: (name: string, appId?: number) => void
onUpdateSetting: <K extends keyof PluginSettings>(key: K, value: string | null) => void
} }
const HARDWARE_OPTIONS = [
{ label: "Auto-detect", data: "" },
...KNOWN_HARDWARE_SLUGS.map((slug) => ({ label: slug, data: slug })),
]
export default function MainPanel({ export default function MainPanel({
recordingState, recordingState,
session, session,
@@ -43,8 +66,25 @@ export default function MainPanel({
onReset, onReset,
setError, setError,
setGameName, setGameName,
onUpdateSetting,
}: MainPanelProps) { }: MainPanelProps) {
const [elapsed, setElapsed] = useState(0) const [elapsed, setElapsed] = useState(0)
const [mangohudStatus, setMangohudStatus] = useState<{
checked: boolean
installed: boolean
path: string
version: string
}>({ checked: false, installed: false, path: "", version: "" })
const [keyTestStatus, setKeyTestStatus] = useState<"idle" | "testing" | "valid" | "invalid">("idle")
const [keyTestMessage, setKeyTestMessage] = useState("")
const [copiedLaunchOpt, setCopiedLaunchOpt] = useState(false)
const [configWritten, setConfigWritten] = useState(false)
const [configVerified, setConfigVerified] = useState<{
checked: boolean
valid: boolean
message: string
}>({ checked: false, valid: false, message: "" })
const [configStatus, setConfigStatus] = useState<{ message: string; isError: boolean } | null>(null)
// Timer for recording state // Timer for recording state
useEffect(() => { useEffect(() => {
@@ -64,6 +104,97 @@ export default function MainPanel({
return `${m}:${s.toString().padStart(2, "0")}` return `${m}:${s.toString().padStart(2, "0")}`
} }
async function handleCheckMangohud() {
const result = await checkMangohud()
setMangohudStatus({
checked: true,
installed: result.installed,
path: result.path,
version: result.version,
})
}
async function handleTestKey() {
if (!settings.apiKey) {
setKeyTestStatus("invalid")
setKeyTestMessage("Enter an API key first")
return
}
setKeyTestStatus("testing")
setKeyTestMessage("")
const result = await testApiKey(settings.apiKey, settings.baseUrl)
if (result.valid) {
setKeyTestStatus("valid")
setKeyTestMessage("API key is valid")
} else {
setKeyTestStatus("invalid")
setKeyTestMessage(result.error || "Invalid API key")
}
}
async function handleCopyLaunchOption() {
try {
await navigator.clipboard.writeText("mangohud %command%")
setCopiedLaunchOpt(true)
setTimeout(() => setCopiedLaunchOpt(false), 2000)
} catch {
const ta = document.createElement("textarea")
ta.value = "mangohud %command%"
document.body.appendChild(ta)
ta.select()
document.execCommand("copy")
document.body.removeChild(ta)
setCopiedLaunchOpt(true)
setTimeout(() => setCopiedLaunchOpt(false), 2000)
}
}
async function handleWriteConfig() {
const result = await writeMangohudConfig()
setConfigWritten(result.success)
}
async function handleVerifyConfig() {
const result = await getMangohudConfig()
if (!result.exists) {
setConfigVerified({ checked: true, valid: false, message: "No MangoHud config found. Write one first." })
return
}
const content = result.content
const hasOutputFolder = content.includes("output_folder=/tmp")
const hasOutputFile = content.includes("output_file=deckyvault-mangohud.log")
const hasFps = content.includes("fps")
if (hasOutputFolder && hasOutputFile && hasFps) {
setConfigVerified({ checked: true, valid: true, message: "Config looks good" })
} else {
setConfigVerified({ checked: true, valid: false, message: "Config is missing required settings. Write it again." })
}
}
async function handleExportConfig() {
setConfigStatus(null)
const result = await exportConfig(settings)
if (result.success) {
setConfigStatus({ message: `Config saved to ${result.path}`, isError: false })
} else {
setConfigStatus({ message: result.error || "Export failed", isError: true })
}
}
async function handleImportConfig() {
setConfigStatus(null)
const result = await importConfig()
if (result.success && result.settings) {
onUpdateSetting("apiKey", result.settings.apiKey || "")
onUpdateSetting("exportPath", result.settings.exportPath || "/home/deck/Downloads")
onUpdateSetting("baseUrl", result.settings.baseUrl || "https://deckyvault.xyz")
onUpdateSetting("hardwareSlug", result.settings.hardwareSlug || null)
setConfigStatus({ message: "Config imported from Downloads", isError: false })
} else {
setConfigStatus({ message: result.error || "No config file found in Downloads", isError: true })
}
}
// ── Stopped state: show the session form ────────────────────── // ── Stopped state: show the session form ──────────────────────
if (recordingState === "stopped") { if (recordingState === "stopped") {
return ( return (
@@ -79,88 +210,272 @@ export default function MainPanel({
) )
} }
// ── Idle or Recording state ───────────────────────────────────
return ( return (
<PanelSection title="Recording"> <>
{error && ( {/* ── Status ──────────────────────────────────────────────── */}
<PanelSection title="Status">
<PanelSectionRow> <PanelSectionRow>
<div className={staticClasses.Text} style={{ color: "#e74c3c", padding: "8px" }}> <ButtonItem layout="below" onClick={handleCheckMangohud}>
{error} <div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<FaCog />
Check MangoHud Status
</div>
</ButtonItem>
</PanelSectionRow>
{mangohudStatus.checked && (
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "13px", padding: "4px 0" }}>
{mangohudStatus.installed ? (
<><FaCheck style={{ color: "#2ecc71" }} /> MangoHud {mangohudStatus.version}</>
) : (
<><FaTimes style={{ color: "#e74c3c" }} /> MangoHud not found</>
)}
</div>
</PanelSectionRow>
)}
<PanelSectionRow>
<ButtonItem layout="below" onClick={handleTestKey} disabled={keyTestStatus === "testing"}>
{keyTestStatus === "testing" ? "Testing..." : "Test API Key"}
{keyTestStatus === "valid" && <FaCheck style={{ color: "#2ecc71", marginLeft: "8px" }} />}
{keyTestStatus === "invalid" && <FaTimes style={{ color: "#e74c3c", marginLeft: "8px" }} />}
</ButtonItem>
</PanelSectionRow>
{keyTestMessage && (
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", color: keyTestStatus === "valid" ? "#2ecc71" : "#e74c3c", padding: "4px 0" }}>
{keyTestMessage}
</div>
</PanelSectionRow>
)}
</PanelSection>
{/* ── Usage Instructions ──────────────────────────────────── */}
<PanelSection title="Usage Instructions">
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", padding: "4px 0", lineHeight: "1.5" }}>
Add this to your game's Steam launch options, then press Start Recording before launching.
</div> </div>
</PanelSectionRow> </PanelSectionRow>
)} <PanelSectionRow>
<div style={{
background: "rgba(255,255,255,0.1)",
borderRadius: "8px",
padding: "10px 14px",
fontFamily: "monospace",
fontSize: "14px",
textAlign: "center",
}}>
mangohud %command%
</div>
</PanelSectionRow>
<PanelSectionRow>
<ButtonItem layout="below" onClick={handleCopyLaunchOption}>
<div style={{ display: "flex", alignItems: "center", gap: "8px", justifyContent: "center" }}>
<FaCopy />
{copiedLaunchOpt ? "Copied to clipboard" : "Copy Launch Option"}
</div>
</ButtonItem>
</PanelSectionRow>
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "11px", opacity: 0.6, padding: "4px 0" }}>
The MangoHud config is stored in ~/.config/MangoHud/MangoHud.conf
</div>
</PanelSectionRow>
</PanelSection>
<PanelSectionRow> {/* ── Recording ──────────────────────────────────────────── */}
{recordingState === "idle" ? ( <PanelSection title="Recording">
<ButtonItem layout="below" onClick={onStart}> {error && (
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}> <PanelSectionRow>
<FaPlay /> <div className={staticClasses.Text} style={{ color: "#e74c3c", padding: "8px" }}>{error}</div>
Start Recording </PanelSectionRow>
</div>
</ButtonItem>
) : (
<ButtonItem layout="below" onClick={onStop} disabled={false}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<FaStop />
Stop Recording
</div>
</ButtonItem>
)} )}
</PanelSectionRow>
{recordingState === "recording" && ( {recordingState === "idle" && (
<> <>
<PanelSectionRow> <PanelSectionRow>
<div className={staticClasses.Text} style={{ padding: "8px 0" }}> <TextField
<div style={{ display: "flex", alignItems: "center", gap: "6px", marginBottom: "4px" }}> label="Game Name"
<FaClock /> value={session.gameName}
<strong>{formatTime(elapsed)}</strong> onChange={(e) => setGameName(e.target.value)}
</div> placeholder="e.g. Cyberpunk 2077"
<div> />
{session.gameName </PanelSectionRow>
? `Recording: ${session.gameName}` <PanelSectionRow>
: "No game detected — recording anyway"} <ButtonItem layout="below" onClick={onStart}>
</div> <div style={{ display: "flex", alignItems: "center", gap: "8px", justifyContent: "center" }}>
</div> <FaPlay />
</PanelSectionRow> Start Recording
</> </div>
)} </ButtonItem>
</PanelSectionRow>
</>
)}
{recordingState === "idle" && ( {recordingState === "recording" && (
<> <>
<PanelSectionRow> <PanelSectionRow>
<TextField <div className={staticClasses.Text} style={{ padding: "8px 0", textAlign: "center" }}>
label="Game Name" <div style={{ display: "flex", alignItems: "center", justifyContent: "center", gap: "6px", marginBottom: "4px" }}>
value={session.gameName} <FaClock />
onChange={(e) => setGameName(e.target.value)} <strong>{formatTime(elapsed)}</strong>
placeholder="e.g. Cyberpunk 2077" </div>
/> <div style={{ fontSize: "13px", opacity: 0.7 }}>
</PanelSectionRow> {session.gameName ? `Recording: ${session.gameName}` : "Recording..."}
<PanelSectionRow> </div>
<div className={staticClasses.Text} style={{ padding: "8px 0", fontSize: "12px", opacity: 0.7 }}>
Enable MangoHud for your game, then press Start Recording before launching.
Configure MangoHud in the Settings tab.
</div>
</PanelSectionRow>
</>
)}
{recentSessions.length > 0 && recordingState === "idle" && (
<PanelSection title="Recent Recordings">
{recentSessions.map((rs, i) => (
<PanelSectionRow key={i}>
<div className={staticClasses.Text} style={{ padding: "4px 0", fontSize: "13px" }}>
<strong>{rs.gameName || "Unknown game"}</strong>
<br />
<span style={{ opacity: 0.6 }}>
{rs.fpsAvg ? `${rs.fpsAvg} FPS avg` : "No data"} ·{" "}
{new Date(rs.date).toLocaleDateString()}
</span>
</div> </div>
</PanelSectionRow> </PanelSectionRow>
))} <PanelSectionRow>
</PanelSection> <ButtonItem layout="below" onClick={onStop}>
)} <div style={{ display: "flex", alignItems: "center", gap: "8px", justifyContent: "center" }}>
</PanelSection> <FaStop />
Stop Recording
</div>
</ButtonItem>
</PanelSectionRow>
</>
)}
{recentSessions.length > 0 && recordingState === "idle" && (
<PanelSection title="Recent Recordings">
{recentSessions.map((rs, i) => (
<PanelSectionRow key={i}>
<div className={staticClasses.Text} style={{ padding: "4px 0", fontSize: "13px" }}>
<strong>{rs.gameName || "Unknown game"}</strong>
<br />
<span style={{ opacity: 0.6 }}>
{rs.fpsAvg ? `${rs.fpsAvg} FPS avg` : "No data"} · {new Date(rs.date).toLocaleDateString()}
</span>
</div>
</PanelSectionRow>
))}
</PanelSection>
)}
</PanelSection>
{/* ── MangoHud Config ────────────────────────────────────── */}
<PanelSection title="MangoHud Config">
<PanelSectionRow>
<ButtonItem layout="below" onClick={handleWriteConfig}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<FaDownload />
Write Config
</div>
</ButtonItem>
</PanelSectionRow>
{configWritten && (
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", color: "#2ecc71", padding: "4px 0" }}>
<FaCheck /> Config written
</div>
</PanelSectionRow>
)}
<PanelSectionRow>
<ButtonItem layout="below" onClick={handleVerifyConfig}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<FaSearch />
Verify Config
</div>
</ButtonItem>
</PanelSectionRow>
{configVerified.checked && (
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", padding: "4px 0", color: configVerified.valid ? "#2ecc71" : "#e74c3c" }}>
{configVerified.valid ? <FaCheck /> : <FaTimes />} {configVerified.message}
</div>
</PanelSectionRow>
)}
</PanelSection>
{/* ── Configuration ────────────────────────────────────────── */}
<PanelSection title="Configuration">
<PanelSectionRow>
<TextField
label="API Key"
value={settings.apiKey}
onChange={(e) => onUpdateSetting("apiKey", e.target.value)}
placeholder="dv_..."
bIsPassword
/>
</PanelSectionRow>
<PanelSectionRow>
<TextField
label="Export Path"
value={settings.exportPath}
onChange={(e) => onUpdateSetting("exportPath", e.target.value)}
placeholder="/home/deck/Downloads"
/>
</PanelSectionRow>
<PanelSectionRow>
<TextField
label="Server URL"
value={settings.baseUrl}
onChange={(e) => onUpdateSetting("baseUrl", e.target.value)}
placeholder="https://deckyvault.xyz"
/>
</PanelSectionRow>
<PanelSectionRow>
<DropdownItem
label="Default Hardware"
rgOptions={HARDWARE_OPTIONS}
selectedOption={settings.hardwareSlug || ""}
onChange={(opt) => onUpdateSetting("hardwareSlug", opt.data as string || null)}
/>
</PanelSectionRow>
<PanelSectionRow>
<ButtonItem layout="below" onClick={handleExportConfig}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<FaFileExport />
Export Config to Downloads
</div>
</ButtonItem>
</PanelSectionRow>
<PanelSectionRow>
<ButtonItem layout="below" onClick={handleImportConfig}>
<div style={{ display: "flex", alignItems: "center", gap: "8px" }}>
<FaFileImport />
Import Config from Downloads
</div>
</ButtonItem>
</PanelSectionRow>
{configStatus && (
<PanelSectionRow>
<div className={staticClasses.Text} style={{ fontSize: "12px", padding: "4px 0", color: configStatus.isError ? "#e74c3c" : "#2ecc71" }}>
{configStatus.isError ? <FaTimes /> : <FaCheck />} {configStatus.message}
</div>
</PanelSectionRow>
)}
</PanelSection>
{/* ── 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><br />
MangoHud is pre-installed. Enable it per-game by adding<br />
<code style={{ display: "block", margin: "4px 0", padding: "4px", background: "rgba(255,255,255,0.1)" }}>
mangohud %command%
</code>
to the game's Steam launch options (right-click game Properties Launch Options).
<br /><br />
<strong>Other Linux handhelds</strong> (ROG Ally, Legion Go):<br />
Install via package manager:
<code style={{ display: "block", margin: "4px 0", padding: "4px", background: "rgba(255,255,255,0.1)" }}>
sudo apt install mangohud
</code>
or Flatpak:
<code style={{ display: "block", margin: "4px 0", padding: "4px", background: "rgba(255,255,255,0.1)" }}>
flatpak install flathub org.freedesktop.Platform.VulkanLayer.MangoHud
</code>
<br /><br />
<strong>Troubleshooting:</strong><br />
Log file empty? Check MangoHud is enabled for the game and the config was written.<br />
Not attaching? Try adding <code>mangohud %command%</code> to Steam launch options explicitly.
</div>
</PanelSectionRow>
</PanelSection>
</>
) )
} }
+7 -5
View File
@@ -9,12 +9,13 @@ import {
} from "@decky/api" } from "@decky/api"
import { FaChartLine } from "react-icons/fa" import { FaChartLine } from "react-icons/fa"
import MainPanel from "./components/main-panel" import MainPanel from "./components/main-panel"
import SettingsPanel from "./components/settings-panel"
import { useSettings, useSession } from "./lib/store" import { useSettings, useSession } from "./lib/store"
import { import {
readAndParseMangohudLog, readAndParseMangohudLog,
clearMangohudLog, clearMangohudLog,
writeMangohudConfig, writeMangohudConfig,
startMangohudLogging,
stopMangohudLogging,
getHardwareInfo, getHardwareInfo,
getOsVersion, getOsVersion,
getProtonVersion, getProtonVersion,
@@ -71,16 +72,20 @@ function Content() {
// ── Handle start recording ──────────────────────────────────── // ── Handle start recording ────────────────────────────────────
async function handleStart() { async function handleStart() {
// Write MangoHud config with autostart_log so logging begins immediately // Write MangoHud config with logging settings
await writeMangohudConfig() await writeMangohudConfig()
// Clear any previous log file // Clear any previous log file
await clearMangohudLog() await clearMangohudLog()
// Start MangoHud logging via mangohudctl
await startMangohudLogging()
startRecording() startRecording()
} }
// ── Handle stop recording: parse log + read system info ──────── // ── Handle stop recording: parse log + read system info ────────
async function handleStop() { async function handleStop() {
try { try {
// Stop MangoHud logging via mangohudctl
await stopMangohudLogging()
stopRecording() stopRecording()
// Parse the MangoHud log // Parse the MangoHud log
@@ -161,9 +166,6 @@ function Content() {
onReset={reset} onReset={reset}
setError={setError} setError={setError}
setGameName={setGameName} setGameName={setGameName}
/>
<SettingsPanel
settings={settings}
onUpdateSetting={updateSetting} onUpdateSetting={updateSetting}
/> />
</> </>
+11 -1
View File
@@ -33,11 +33,21 @@ export const readAndParseMangohudLog = callable<[logPath?: string], {
error?: string error?: string
}>("read_and_parse_mangohud_log") }>("read_and_parse_mangohud_log")
export const clearMangohudLog = callable<[logPath?: string], { export const clearMangohudLog = callable<[], {
success: boolean success: boolean
error?: string error?: string
}>("clear_mangohud_log") }>("clear_mangohud_log")
export const startMangohudLogging = callable<[], {
success: boolean
error?: string
}>("start_mangohud_logging")
export const stopMangohudLogging = callable<[], {
success: boolean
error?: string
}>("stop_mangohud_logging")
// ── System Info ───────────────────────────────────────────────── // ── System Info ─────────────────────────────────────────────────
export const getHardwareInfo = callable<[], { export const getHardwareInfo = callable<[], {
slug: string slug: string