diff --git a/backend/decky_loader/locales/en-US.json b/backend/decky_loader/locales/en-US.json index 1f87fe3bf..a472db1ba 100644 --- a/backend/decky_loader/locales/en-US.json +++ b/backend/decky_loader/locales/en-US.json @@ -192,6 +192,37 @@ "ip_label": "IP", "label": "Enable React DevTools" }, + "test_report": { + "create": "Create", + "copy_desc": "Open the Deckbrew LogPaste link below in your browser, or copy the link or report to the clipboard.", + "copy_failed": "Failed to copy.", + "copy_link": "Copy Link", + "copy_link_success": "Link copied.", + "copy_report": "Copy Report", + "copy_report_success": "Report copied.", + "description": "Create and share a plugin test report", + "close": "Close", + "cloud_notice": "Clicking 'Upload Report' uploads your report to Deckbrew LogPaste (lp.deckbrew.xyz) so you can easily share it when reporting issues. No private data is uploaded — only the report content shown here and your summary notes.", + "header": "Reports", + "major_issues": "Major blocking issues", + "minor_issues": "Minor non-blocking issues", + "paste_url": "Report Link", + "no_plugins": "No plugins found.", + "option_label": "Generate Plugin Test Report", + "plugins": "Installed Plugins", + "summary": "Summary", + "summary_placeholder": "Leave a brief summary of how you tested the plugin...", + "qr_label": "Share", + "qr_desc": "Scan the QR code to open the Deckbrew LogPaste report on an external device browser.", + "copy_title": "Copy Link / Report", + "toast_body": "Report generated and uploaded successfully.", + "toast_title": "Report Ready", + "upload_report": "Upload Report", + "sending": "Sending...", + "system_error": "Failed to fetch system or plugins.", + "system_info": "System Info", + "title": "Generate Plugin Test Report" + }, "third_party_plugins": { "button_install": "Install", "button_zip": "Browse", diff --git a/backend/decky_loader/main.py b/backend/decky_loader/main.py index 16caca2e7..5aff1588f 100644 --- a/backend/decky_loader/main.py +++ b/backend/decky_loader/main.py @@ -34,6 +34,7 @@ from .settings import SettingsManager from .updater import Updater from .utilities import Utilities +from .reporting import Reporting from .enums import UserType from .wsrouter import WSRouter @@ -76,6 +77,7 @@ def __init__(self, loop: AbstractEventLoop) -> None: self.plugin_browser = PluginBrowser(plugin_path, self.plugin_loader.plugins, self.plugin_loader, self.settings) self.utilities = Utilities(self) self.updater = Updater(self) + self.reporting = Reporting(self) self.last_webhelper_exit: float = 0 self.webhelper_crash_count: int = 0 self.inject_fallback: bool = False diff --git a/backend/decky_loader/reporting.py b/backend/decky_loader/reporting.py new file mode 100644 index 000000000..826cda477 --- /dev/null +++ b/backend/decky_loader/reporting.py @@ -0,0 +1,264 @@ +from __future__ import annotations + +import re +import shutil +import subprocess +from logging import getLogger +from pathlib import Path +from typing import Dict, List, TYPE_CHECKING + +from aiohttp import ClientSession, ClientTimeout, web + +from . import helpers +from .helpers import get_home_path +from .localplatform.localplatform import ON_LINUX +if TYPE_CHECKING: + from .main import PluginManager + +logger = getLogger("Reporting") +paste_timeout = ClientTimeout(total=10) +clipboard_commands = ( + ["wl-copy"], + ["xclip", "-selection", "clipboard"], + ["xsel", "--clipboard", "--input"], +) + + +def _parse_os_release(contents: str) -> Dict[str, str]: + data: Dict[str, str] = {} + for line in contents.splitlines(): + if not line or "=" not in line: + continue + key, value = line.split("=", 1) + value = value.strip().strip('"').strip("'") + data[key.strip()] = value + return data + + +def _get_steamos_version() -> str: + if not ON_LINUX: + return "unknown" + try: + with open("/etc/os-release", "r", encoding="utf-8") as f: + info = _parse_os_release(f.read()) + if "PRETTY_NAME" in info and info["PRETTY_NAME"].strip(): + pretty = info["PRETTY_NAME"].strip() + if pretty.lower() == "steamos": + version = info.get("VERSION_ID") or info.get("VERSION") or "" + build = info.get("BUILD_ID") or info.get("STEAMOS_BUILD_ID") or "" + if version and build: + return f"{pretty} {version}_{build}" + if version: + return f"{pretty} {version}" + return pretty + if "NAME" in info and "VERSION" in info: + return f'{info["NAME"]} {info["VERSION"]}' + if "NAME" in info and "VERSION_ID" in info: + return f'{info["NAME"]} {info["VERSION_ID"]}' + return info.get("NAME", "unknown") + except Exception as e: + logger.warning(f"Failed to read /etc/os-release: {e}") + return "unknown" + + +class Reporting: + def __init__(self, context: "PluginManager") -> None: + self.context = context + routes = [ + web.get("/report/system", self.get_system), + web.get("/report/plugins", self.get_plugins), + web.post("/report/paste", self.upload_report), + web.post("/report/clipboard", self.copy_to_clipboard), + ] + context.web_app.add_routes(routes) + + def _get_steamos_branch(self) -> str: + if not ON_LINUX: + return "Stable" + candidates = [ + "/etc/steamos-update", + "/etc/steamos-update.conf", + "/etc/steamos-branch", + "/etc/steamos-channel", + ] + branch_hint = "" + for path in candidates: + try: + if Path(path).exists(): + branch_hint = Path(path).read_text(encoding="utf-8", errors="ignore") + break + except Exception: + continue + if branch_hint: + lowered = branch_hint.lower() + if any(key in lowered for key in ["beta", "preview", "main"]): + return "Beta" + return "Stable" + + def _get_decky_branch(self) -> str: + branch = self.context.settings.getSetting("branch", 0) + if branch == 1: + return "Pre-Release" + if branch == 2: + return "Testing" + return "Stable" + + def _get_steam_branch(self, steam_config: str) -> str: + match = re.search(r"\"BetaParticipation\"\\s*\"([^\"]*)\"", steam_config) + if not match: + return "Stable" + value = match.group(1).strip().lower() + return "Beta" if value else "Stable" + + def _get_steam_version_from_dir(self, package_dir: Path, branch: str) -> str | None: + candidates: List[str] + if branch == "Beta": + candidates = [ + "steam_client_publicbeta", + "steam_client_beta", + "steam_client", + "steam_client_ubuntu12", + ] + else: + candidates = [ + "steam_client", + "steam_client_ubuntu12", + "steam_client_publicbeta", + "steam_client_beta", + ] + for name in candidates: + path = package_dir.joinpath(name) + try: + if path.exists(): + return path.read_text(encoding="utf-8", errors="ignore").strip() or None + except Exception: + continue + return None + + def _get_steam_version_from_logs(self, home: str) -> str | None: + log_paths = [ + Path(home).joinpath(".steam", "steam", "logs", "steam_update.log"), + Path(home).joinpath(".steam", "steam", "logs", "bootstrap_log.txt"), + Path(home).joinpath(".local", "share", "Steam", "logs", "steam_update.log"), + Path(home).joinpath(".local", "share", "Steam", "logs", "bootstrap_log.txt"), + ] + for path in log_paths: + try: + if not path.exists(): + continue + content = path.read_text(encoding="utf-8", errors="ignore") + matches = re.findall(r"\b\d{9,10}\b", content) + if matches: + return matches[-1] + except Exception: + continue + return None + + def _get_steam_version(self, home: str, branch: str) -> str: + package_dirs = [ + Path(home).joinpath(".steam", "steam", "package"), + Path(home).joinpath(".local", "share", "Steam", "package"), + ] + for package_dir in package_dirs: + version = self._get_steam_version_from_dir(package_dir, branch) + if version: + return version + log_version = self._get_steam_version_from_logs(home) + if log_version: + return log_version + return "unknown" + + def _get_steam_info(self) -> Dict[str, str]: + home = get_home_path() + config_path = Path(home).joinpath(".steam", "steam", "config", "config.vdf") + config_text = "" + try: + if config_path.exists(): + config_text = config_path.read_text(encoding="utf-8", errors="ignore") + except Exception: + config_text = "" + branch = self._get_steam_branch(config_text) + version = self._get_steam_version(home, branch) + return {"version": version, "branch": branch} + + async def get_system(self, _: web.Request) -> web.Response: + steam_info = self._get_steam_info() + return web.json_response( + { + "steamos": _get_steamos_version(), + "steamos_branch": self._get_steamos_branch(), + "steam": steam_info["version"], + "steam_branch": steam_info["branch"], + "decky": helpers.get_loader_version(), + "decky_branch": self._get_decky_branch(), + } + ) + + async def get_plugins(self, _: web.Request) -> web.Response: + plugins = await self.context.plugin_loader.get_plugins() + return web.json_response( + { + "plugins": [ + {"name": plugin["name"], "version": plugin.get("version")} + for plugin in plugins + ] + } + ) + + async def upload_report(self, request: web.Request) -> web.Response: + try: + data = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON payload"}, status=400) + + body = data.get("body") + if not isinstance(body, str): + return web.json_response({"error": "Missing or invalid fields"}, status=400) + + try: + async with ClientSession(timeout=paste_timeout) as session: + async with session.put( + "https://lp.deckbrew.xyz/", + data=body, + headers={ + "User-Agent": helpers.user_agent, + "Content-Type": "text/plain; charset=utf-8", + }, + ssl=helpers.get_ssl_context(), + ) as res: + if res.status < 200 or res.status >= 300: + text = await res.text() + logger.error(f"lp.deckbrew.xyz upload failed: {res.status} {text}") + return web.json_response({"error": "Paste upload failed"}, status=502) + payload = await res.json() + paste_id = payload.get("id") + if not isinstance(paste_id, str) or not paste_id: + logger.error(f"lp.deckbrew.xyz returned invalid payload: {payload}") + return web.json_response({"error": "Paste upload failed"}, status=502) + url = f"https://lp.deckbrew.xyz/{paste_id}" + except Exception as e: + logger.error(f"Failed to upload report: {e}") + return web.json_response({"error": "Paste upload failed"}, status=502) + + return web.json_response({"success": True, "url": url}) + + async def copy_to_clipboard(self, request: web.Request) -> web.Response: + try: + data = await request.json() + except Exception: + return web.json_response({"error": "Invalid JSON payload"}, status=400) + + text = data.get("text") + if not isinstance(text, str): + return web.json_response({"error": "Missing or invalid fields"}, status=400) + + for cmd in clipboard_commands: + if shutil.which(cmd[0]): + try: + subprocess.run(cmd, input=text.encode("utf-8"), check=True) + return web.json_response({"success": True}) + except Exception as e: + logger.error(f"Clipboard copy failed with {cmd[0]}: {e}") + continue + + return web.json_response({"error": "No clipboard utility available (install wl-clipboard)"}, status=502) diff --git a/frontend/src/components/modals/TestReportModal.tsx b/frontend/src/components/modals/TestReportModal.tsx new file mode 100644 index 000000000..eaffc4f4f --- /dev/null +++ b/frontend/src/components/modals/TestReportModal.tsx @@ -0,0 +1,466 @@ +import { + DialogBody, + DialogButton, + DialogControlsSection, + DialogControlsSectionHeader, + Field, + Focusable, + ModalRoot, + Spinner, + TextField, + Toggle, +} from '@decky/ui'; +import { FC, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { FaFileAlt } from 'react-icons/fa'; + +type SystemInfo = { + steamos: string; + steamos_branch: string; + steam: string; + steam_branch: string; + decky: string; + decky_branch: string; +}; + +type PluginsInfo = { + plugins: { name: string; version: string | null }[]; +}; + +interface TestReportModalProps { + closeModal?(): void; +} + +const apiFetchJson = async (path: string, init?: RequestInit): Promise => { + const res = await fetch(`http://127.0.0.1:1337${path}`, { + ...init, + headers: { + 'X-Decky-Auth': deckyAuthToken, + ...(init?.headers || {}), + }, + }); + if (!res.ok) { + let message = res.statusText; + try { + const body = await res.json(); + message = body?.error || message; + } catch { + try { + message = await res.text(); + } catch {} + } + throw new Error(message); + } + return res.json() as Promise; +}; + +const buildReportFull = ( + system: SystemInfo, + plugins: PluginsInfo, + majorIssues: boolean, + minorIssues: boolean, + majorIssuesNotes: string, + minorIssuesNotes: string, + summary: string, +) => { + const pluginLines = plugins.plugins.length + ? plugins.plugins.map((plugin) => `- ${plugin.name} - ${plugin.version ?? 'unknown'}`) + : ['- None']; + + return [ + '# Plugin Testing Report', + '', + '', + '## Installed Plugins', + '', + ...pluginLines, + '', + '## Specifications', + '', + `- SteamOS ${system.steamos} (${system.steamos_branch})`, + `- Steam ${system.steam} (${system.steam_branch})`, + `- Decky ${system.decky} (${system.decky_branch})`, + '', + '## Issues', + `**Has the following major blocking issue(s):** ${majorIssues ? majorIssuesNotes || 'Yes' : 'No'}`, + `**Has the following minor non-blocking issue(s):** ${minorIssues ? minorIssuesNotes || 'Yes' : 'No'}`, + '', + '## Summary', + '', + summary || 'REPLACE_WITH_SUMMARY', + ].join('\n'); +}; + +const buildReportSimple = (system: SystemInfo, plugins: PluginsInfo) => { + const pluginLines = plugins.plugins.length + ? plugins.plugins.map((plugin) => `- ${plugin.name} - ${plugin.version ?? 'unknown'}`) + : ['- None']; + + return [ + '# System Info Report', + '', + '## Installed Plugins', + ...pluginLines, + '', + '## Specifications', + `- SteamOS ${system.steamos} (${system.steamos_branch})`, + `- Steam ${system.steam} (${system.steam_branch})`, + `- Decky ${system.decky} (${system.decky_branch})`, + ].join('\n'); +}; + +const getPasteOrigin = (url: string | null) => { + if (!url) { + return ''; + } + + try { + return new URL(url).origin; + } catch { + return ''; + } +}; + +const TestReportModal: FC = ({ closeModal }) => { + const { t } = useTranslation(); + const [loading, setLoading] = useState(false); + const [sending, setSending] = useState(false); + const [error, setError] = useState(null); + const [systemInfo, setSystemInfo] = useState(null); + const [pluginsInfo, setPluginsInfo] = useState(null); + const [copyMessage, setCopyMessage] = useState(null); + const [copyMessageType, setCopyMessageType] = useState<'error' | 'success' | null>(null); + const [pasteUrl, setPasteUrl] = useState(null); + const [lastReport, setLastReport] = useState(null); + const [majorIssues, setMajorIssues] = useState(false); + const [minorIssues, setMinorIssues] = useState(false); + const [majorIssuesNotes, setMajorIssuesNotes] = useState(''); + const [minorIssuesNotes, setMinorIssuesNotes] = useState(''); + const [summary, setSummary] = useState(''); + + useEffect(() => { + let active = true; + const loadInfo = async () => { + setLoading(true); + setError(null); + try { + const [system, plugins] = await Promise.all([ + apiFetchJson('/report/system'), + apiFetchJson('/report/plugins'), + ]); + if (!active) return; + setSystemInfo(system); + setPluginsInfo(plugins); + } catch (e) { + if (!active) return; + setError((e as Error).message || t('SettingsDeveloperIndex.test_report.system_error')); + } finally { + if (active) setLoading(false); + } + }; + loadInfo(); + return () => { + active = false; + }; + }, [t]); + + const handleSend = async () => { + if (!systemInfo || !pluginsInfo) return; + setSending(true); + setError(null); + setCopyMessage(null); + setCopyMessageType(null); + setPasteUrl(null); + try { + const hasCustomContent = + majorIssues || + minorIssues || + (majorIssues && majorIssuesNotes.trim().length > 0) || + (minorIssues && minorIssuesNotes.trim().length > 0) || + summary.trim().length > 0; + const report = hasCustomContent + ? buildReportFull( + systemInfo, + pluginsInfo, + majorIssues, + minorIssues, + majorIssuesNotes, + minorIssuesNotes, + summary, + ) + : buildReportSimple(systemInfo, pluginsInfo); + setLastReport(report); + const response = await apiFetchJson<{ url: string }>('/report/paste', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ body: report }), + }); + DeckyPluginLoader.toaster.toast({ + title: t('SettingsDeveloperIndex.test_report.toast_title'), + body: t('SettingsDeveloperIndex.test_report.toast_body'), + icon: , + }); + setPasteUrl(response.url); + } catch (e) { + setError((e as Error).message || t('SettingsDeveloperIndex.test_report.system_error')); + } finally { + setSending(false); + } + }; + + const handleCopy = async (value: string | null, successKey: string, failKey: string) => { + if (!value) return; + try { + await apiFetchJson('/report/clipboard', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text: value }), + }); + setCopyMessage(t(successKey)); + setCopyMessageType('success'); + return; + } catch (e) { + try { + const textarea = document.createElement('textarea'); + textarea.value = value; + textarea.style.position = 'fixed'; + textarea.style.opacity = '0'; + document.body.appendChild(textarea); + textarea.focus(); + textarea.select(); + const ok = document.execCommand('copy'); + document.body.removeChild(textarea); + if (ok) { + setCopyMessage(t(successKey)); + setCopyMessageType('success'); + return; + } + } catch {} + const message = e instanceof Error && e.message ? e.message : t(failKey); + setCopyMessage(message); + setCopyMessageType('error'); + } + }; + + return ( + closeModal?.()}> + + + {t('SettingsDeveloperIndex.test_report.title')} + {loading && } +
+
+ {systemInfo && ( + +
{`SteamOS ${systemInfo.steamos} (${systemInfo.steamos_branch})`}
+
{`Steam ${systemInfo.steam} (${systemInfo.steam_branch})`}
+
{`Decky ${systemInfo.decky} (${systemInfo.decky_branch})`}
+
+ } + /> + )} +
+ + { + setMajorIssues(value); + if (!value) { + setMajorIssuesNotes(''); + } + }} + /> + + + { + setMinorIssues(value); + if (!value) { + setMinorIssuesNotes(''); + } + }} + /> + +
+
+
+ {pluginsInfo && ( + + {pluginsInfo.plugins.length === 0 && ( +
{t('SettingsDeveloperIndex.test_report.no_plugins')}
+ )} + {pluginsInfo.plugins.map((plugin) => ( +
+ {plugin.name} - {plugin.version ?? 'unknown'} +
+ ))} +
+ } + /> + )} + + + {majorIssues && ( + setMajorIssuesNotes(e?.target.value || '')} /> + } + /> + )} + {minorIssues && ( + setMinorIssuesNotes(e?.target.value || '')} /> + } + /> + )} + +
+ {t('SettingsDeveloperIndex.test_report.summary_placeholder')} +
+ setSummary(e?.target.value || '')} /> + + } + /> + {pasteUrl && ( +
+
+ +
+ {t('SettingsDeveloperIndex.test_report.qr_desc')} +
+
+ {t('SettingsDeveloperIndex.test_report.qr_label')} +
+
+ } + /> +
+
+ +
+ {t('SettingsDeveloperIndex.test_report.copy_desc')} +
+
+ {}} + /> + + handleCopy( + pasteUrl, + 'SettingsDeveloperIndex.test_report.copy_link_success', + 'SettingsDeveloperIndex.test_report.copy_failed', + ) + } + > + {t('SettingsDeveloperIndex.test_report.copy_link')} + + + handleCopy( + lastReport, + 'SettingsDeveloperIndex.test_report.copy_report_success', + 'SettingsDeveloperIndex.test_report.copy_failed', + ) + } + > + {t('SettingsDeveloperIndex.test_report.copy_report')} + +
+ {copyMessage || ''} +
+
+
+ } + /> + + + )} + {error &&
{error}
} + + + {sending + ? t('SettingsDeveloperIndex.test_report.sending') + : t('SettingsDeveloperIndex.test_report.upload_report')} + + closeModal?.()}> + {t('SettingsDeveloperIndex.test_report.close')} + + +
+ {t('SettingsDeveloperIndex.test_report.cloud_notice')} +
+
+
+
+ ); +}; + +export default TestReportModal; diff --git a/frontend/src/components/settings/pages/developer/index.tsx b/frontend/src/components/settings/pages/developer/index.tsx index 05989806d..538de661d 100644 --- a/frontend/src/components/settings/pages/developer/index.tsx +++ b/frontend/src/components/settings/pages/developer/index.tsx @@ -7,10 +7,11 @@ import { Navigation, TextField, Toggle, + showModal, } from '@decky/ui'; import { useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { FaFileArchive, FaLink, FaReact, FaSteamSymbol, FaTerminal } from 'react-icons/fa'; +import { FaFileAlt, FaFileArchive, FaLink, FaReact, FaSteamSymbol, FaTerminal } from 'react-icons/fa'; import { setShouldConnectToReactDevTools, setShowValveInternal } from '../../../../developer'; import Logger from '../../../../logger'; @@ -18,6 +19,7 @@ import { installFromURL } from '../../../../store'; import { useSetting } from '../../../../utils/hooks/useSetting'; import { getSetting } from '../../../../utils/settings'; import { FileSelectionType } from '../../../modals/filepicker'; +import TestReportModal from '../../../modals/TestReportModal'; import RemoteDebuggingSettings from '../general/RemoteDebugging'; const logger = new Logger('DeveloperIndex'); @@ -155,6 +157,16 @@ export default function DeveloperSettings() { }} /> + {t('SettingsDeveloperIndex.test_report.header')} + } + > + showModal()}> + {t('SettingsDeveloperIndex.test_report.create')} + + );