error boundary now properly reports steam errors

This commit is contained in:
AAGaming
2024-05-27 17:21:27 -04:00
parent a84a13c76d
commit 9c8db576f5
12 changed files with 247 additions and 169 deletions
+137 -141
View File
@@ -1,17 +1,14 @@
import { sleep } from '@decky/ui';
import { ErrorInfo, FunctionComponent, useReducer, useState } from 'react';
import { FunctionComponent, useEffect, useReducer, useState } from 'react';
import { uninstallPlugin } from '../plugin';
import { doRestart, doShutdown } from '../updater';
interface ReactErrorInfo {
error: Error;
info: ErrorInfo;
}
import { VerInfo, doRestart, doShutdown } from '../updater';
import { ValveReactErrorInfo, getLikelyErrorSourceFromValveReactError } from '../utils/errors';
interface DeckyErrorBoundaryProps {
error: ReactErrorInfo;
error: ValveReactErrorInfo;
errorKey: string;
identifier: string;
reset: () => void;
}
@@ -21,32 +18,6 @@ declare global {
}
}
const pluginErrorRegex = /\(http:\/\/localhost:1337\/plugins\/(.*)\//;
const pluginSourceMapErrorRegex = /\(decky:\/\/decky\/plugin\/(.*)\//;
const legacyPluginErrorRegex = /\(decky:\/\/decky\/legacy_plugin\/(.*)\/index.js/;
function getLikelyErrorSource(error: ReactErrorInfo): [source: string, wasPlugin: boolean] {
const pluginMatch = error.error.stack?.match(pluginErrorRegex);
if (pluginMatch) {
return [decodeURIComponent(pluginMatch[1]), true];
}
const pluginMatchViaMap = error.error.stack?.match(pluginSourceMapErrorRegex);
if (pluginMatchViaMap) {
return [decodeURIComponent(pluginMatchViaMap[1]), true];
}
const legacyPluginMatch = error.error.stack?.match(legacyPluginErrorRegex);
if (legacyPluginMatch) {
return [decodeURIComponent(legacyPluginMatch[1]), true];
}
if (error.error.stack?.includes('http://localhost:1337/')) {
return ['the Decky frontend', false];
}
return ['Steam', false];
}
export const startSSH = DeckyBackend.callable('utilities/start_ssh');
export const starrCEFForwarding = DeckyBackend.callable('utilities/allow_remote_debugging');
@@ -55,146 +26,171 @@ function ipToString(ip: number) {
}
// Intentionally not localized since we can't really trust React here
const DeckyErrorBoundary: FunctionComponent<DeckyErrorBoundaryProps> = ({ error, reset }) => {
const DeckyErrorBoundary: FunctionComponent<DeckyErrorBoundaryProps> = ({ error, identifier, reset }) => {
const [actionLog, addLogLine] = useReducer((log: string, line: string) => (log += '\n' + line), '');
const [actionsEnabled, setActionsEnabled] = useState<boolean>(true);
const [debugAllowed, setDebugAllowed] = useState<boolean>(true);
const [errorSource, wasCausedByPlugin] = getLikelyErrorSource(error);
// Intentionally doesn't use DeckyState.
const [versionInfo, setVersionInfo] = useState<VerInfo>();
const [errorSource, wasCausedByPlugin] = getLikelyErrorSourceFromValveReactError(error);
useEffect(() => {
DeckyPluginLoader.updateVersion().then(setVersionInfo);
}, []);
return (
<div
style={{
overflow: 'scroll',
marginLeft: '15px',
color: 'white',
fontSize: '16px',
userSelect: 'auto',
backgroundColor: 'black',
marginTop: '48px', // Incase this is a page
}}
>
<h1
<>
<style>
{`
*:has(> .deckyErrorBoundary) {
overflow: scroll !important;
}
`}
</style>
<div
style={{
fontSize: '20px',
display: 'inline-block',
marginTop: '15px',
overflow: 'auto',
marginLeft: '15px',
color: 'white',
fontSize: '16px',
userSelect: 'auto',
backgroundColor: 'black',
marginTop: '48px', // Incase this is a page
}}
className="deckyErrorBoundary"
>
An error occured rendering this content.
</h1>
<p>This error likely occured in {getLikelyErrorSource(error)}.</p>
{actionLog?.length > 0 && (
<pre>
<h1
style={{
fontSize: '20px',
display: 'inline-block',
userSelect: 'auto',
}}
>
An error occured rendering this content.
</h1>
<pre style={{}}>
<code>
Running actions...
{actionLog}
{identifier && `Error Reference: ${identifier}`}
{versionInfo?.current && `\nDecky Version: ${versionInfo.current}`}
</code>
</pre>
)}
{actionsEnabled && (
<>
<h3>Actions: </h3>
<p>Use the touch screen.</p>
<div style={{ display: 'block', marginBottom: '5px' }}>
<button style={{ marginRight: '5px', padding: '5px' }} onClick={reset}>
Retry
</button>
<button style={{ marginRight: '5px', padding: '5px' }} onClick={() => SteamClient.User.StartRestart()}>
Restart Steam
</button>
</div>
<div style={{ display: 'block', marginBottom: '5px' }}>
<button
style={{ marginRight: '5px', padding: '5px' }}
onClick={async () => {
setActionsEnabled(false);
addLogLine('Restarting Decky...');
doRestart();
await sleep(2000);
addLogLine('Reloading UI...');
}}
>
Restart Decky
</button>
<button
style={{ marginRight: '5px', padding: '5px' }}
onClick={async () => {
setActionsEnabled(false);
addLogLine('Stopping Decky...');
doShutdown();
await sleep(5000);
addLogLine('Restarting Steam...');
SteamClient.User.StartRestart();
}}
>
Disable Decky until next boot
</button>
</div>
{debugAllowed && (
<p>This error likely occured in {errorSource}.</p>
{actionLog?.length > 0 && (
<pre>
<code>
Running actions...
{actionLog}
</code>
</pre>
)}
{actionsEnabled && (
<>
<h3>Actions: </h3>
<p>Use the touch screen.</p>
<div style={{ display: 'block', marginBottom: '5px' }}>
<button style={{ marginRight: '5px', padding: '5px' }} onClick={reset}>
Retry
</button>
<button
style={{ marginRight: '5px', padding: '5px' }}
onClick={async () => {
setDebugAllowed(false);
addLogLine('Enabling CEF debugger forwarding...');
await starrCEFForwarding();
addLogLine('Enabling SSH...');
await startSSH();
addLogLine('Ready for debugging!');
if (window?.SystemNetworkStore?.wirelessNetworkDevice?.ip4?.addresses?.[0]?.ip) {
const ip = ipToString(window.SystemNetworkStore.wirelessNetworkDevice.ip4.addresses[0].ip);
addLogLine(`CEF Debugger: http://${ip}:8081`);
addLogLine(`SSH: deck@${ip}`);
}
onClick={() => {
addLogLine('Restarting Steam...');
SteamClient.User.StartRestart();
}}
>
Allow remote debugging and SSH until next boot
Restart Steam
</button>
</div>
)}
{wasCausedByPlugin && (
<div style={{ display: 'block', marginBottom: '5px' }}>
{'\n'}
<button
style={{ marginRight: '5px', padding: '5px' }}
onClick={async () => {
setActionsEnabled(false);
addLogLine(`Uninstalling ${errorSource}...`);
await uninstallPlugin(errorSource);
await DeckyPluginLoader.frozenPluginsService.invalidate();
await DeckyPluginLoader.hiddenPluginsService.invalidate();
await sleep(1000);
addLogLine('Restarting Decky...');
doRestart();
await sleep(2000);
addLogLine('Reloading UI...');
}}
>
Restart Decky
</button>
<button
style={{ marginRight: '5px', padding: '5px' }}
onClick={async () => {
setActionsEnabled(false);
addLogLine('Stopping Decky...');
doShutdown();
await sleep(5000);
addLogLine('Restarting Steam...');
await sleep(500);
SteamClient.User.StartRestart();
}}
>
Uninstall {errorSource} and restart Decky
Disable Decky until next boot
</button>
</div>
)}
</>
)}
{debugAllowed && (
<div style={{ display: 'block', marginBottom: '5px' }}>
<button
style={{ marginRight: '5px', padding: '5px' }}
onClick={async () => {
setDebugAllowed(false);
addLogLine('Enabling CEF debugger forwarding...');
await starrCEFForwarding();
addLogLine('Enabling SSH...');
await startSSH();
addLogLine('Ready for debugging!');
if (window?.SystemNetworkStore?.wirelessNetworkDevice?.ip4?.addresses?.[0]?.ip) {
const ip = ipToString(window.SystemNetworkStore.wirelessNetworkDevice.ip4.addresses[0].ip);
addLogLine(`CEF Debugger: http://${ip}:8081`);
addLogLine(`SSH: deck@${ip}`);
}
}}
>
Allow remote debugging and SSH until next boot
</button>
</div>
)}
{wasCausedByPlugin && (
<div style={{ display: 'block', marginBottom: '5px' }}>
{'\n'}
<button
style={{ marginRight: '5px', padding: '5px' }}
onClick={async () => {
setActionsEnabled(false);
addLogLine(`Uninstalling ${errorSource}...`);
await uninstallPlugin(errorSource);
await DeckyPluginLoader.frozenPluginsService.invalidate();
await DeckyPluginLoader.hiddenPluginsService.invalidate();
await sleep(1000);
addLogLine('Restarting Decky...');
doRestart();
await sleep(2000);
addLogLine('Restarting Steam...');
await sleep(500);
SteamClient.User.StartRestart();
}}
>
Uninstall {errorSource} and restart Decky
</button>
</div>
)}
</>
)}
<pre
style={{
marginTop: '15px',
opacity: 0.7,
userSelect: 'auto',
}}
>
<code>
{error.error.stack}
{'\n\n'}
Component Stack:
{error.info.componentStack}
</code>
</pre>
</div>
<pre
style={{
marginTop: '15px',
opacity: 0.7,
userSelect: 'auto',
}}
>
<code>
{error.error.stack}
{'\n\n'}
Component Stack:
{error.info.componentStack}
</code>
</pre>
</div>
</>
);
};
@@ -1,5 +1,5 @@
import type { ToastData } from '@decky/api';
import { FC, createContext, useContext, useEffect, useState } from 'react';
import { FC, ReactNode, createContext, useContext, useEffect, useState } from 'react';
interface PublicDeckyToasterState {
toasts: Set<ToastData>;
@@ -41,6 +41,7 @@ export const useDeckyToasterState = () => useContext(DeckyToasterContext);
interface Props {
deckyToasterState: DeckyToasterState;
children: ReactNode;
}
export const DeckyToasterStateContextProvider: FC<Props> = ({ children, deckyToasterState }) => {
+2 -2
View File
@@ -1,5 +1,5 @@
import { ButtonItem, Focusable, PanelSection, PanelSectionRow } from '@decky/ui';
import { VFC, useEffect, useState } from 'react';
import { FC, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FaEyeSlash } from 'react-icons/fa';
@@ -9,7 +9,7 @@ import NotificationBadge from './NotificationBadge';
import { useQuickAccessVisible } from './QuickAccessVisibleState';
import TitleView from './TitleView';
const PluginView: VFC = () => {
const PluginView: FC = () => {
const { hiddenPlugins } = useDeckyState();
const { plugins, updates, activePlugin, pluginOrder, setActivePlugin, closeActivePlugin } = useDeckyState();
const visible = useQuickAccessVisible();
@@ -1,10 +1,10 @@
import { FC, createContext, useContext, useState } from 'react';
import { FC, ReactNode, createContext, useContext, useState } from 'react';
const QuickAccessVisibleState = createContext<boolean>(false);
export const useQuickAccessVisible = () => useContext(QuickAccessVisibleState);
export const QuickAccessVisibleStateProvider: FC<{ tab: any }> = ({ children, tab }) => {
export const QuickAccessVisibleStateProvider: FC<{ tab: any; children: ReactNode }> = ({ children, tab }) => {
const initial = tab.initialVisibility;
const [visible, setVisible] = useState<boolean>(initial);
// HACK but i can't think of a better way to do this
+2 -2
View File
@@ -1,5 +1,5 @@
import { DialogButton, Focusable, Router, staticClasses } from '@decky/ui';
import { CSSProperties, VFC } from 'react';
import { CSSProperties, FC } from 'react';
import { useTranslation } from 'react-i18next';
import { BsGearFill } from 'react-icons/bs';
import { FaArrowLeft, FaStore } from 'react-icons/fa';
@@ -14,7 +14,7 @@ const titleStyles: CSSProperties = {
top: '0px',
};
const TitleView: VFC = () => {
const TitleView: FC = () => {
const { activePlugin, closeActivePlugin } = useDeckyState();
const { t } = useTranslation();
+31 -6
View File
@@ -2,6 +2,7 @@ import { Patch, callOriginal, findModuleExport, replacePatch } from '@decky/ui';
import DeckyErrorBoundary from './components/DeckyErrorBoundary';
import Logger from './logger';
import { getLikelyErrorSourceFromValveError } from './utils/errors';
declare global {
interface Window {
@@ -11,6 +12,7 @@ declare global {
class ErrorBoundaryHook extends Logger {
private errorBoundaryPatch?: Patch;
private errorCheckPatch?: Patch;
constructor() {
super('ErrorBoundaryHook');
@@ -35,13 +37,29 @@ class ErrorBoundaryHook extends Logger {
const errorReportingStore = initErrorReportingStore();
// NUH UH.
Object.defineProperty(Object.getPrototypeOf(errorReportingStore), 'reporting_enabled', {
get: () => false,
});
errorReportingStore.m_bEnabled = false;
// Object.defineProperty(Object.getPrototypeOf(errorReportingStore), 'reporting_enabled', {
// get: () => false,
// });
// errorReportingStore.m_bEnabled = false;
// @ts-ignore
// window.errorStore = errorReportingStore;
window.errorStore = errorReportingStore;
const react15069WorkaroundRegex = / at .+\.componentDidCatch\..+\.callback /;
this.errorCheckPatch = replacePatch(Object.getPrototypeOf(errorReportingStore), 'BIsBlacklisted', (args: any[]) => {
const [errorSource, wasPlugin, shouldReport] = getLikelyErrorSourceFromValveError(args[0]);
this.debug('Caught an error', args, { errorSource, wasPlugin, shouldReport });
// react#15069 workaround. this took 2 hours to figure out.
if (
args[0]?.message?.[3]?.[0] &&
args[0]?.message?.[1]?.[0] == ' at console.error ' &&
react15069WorkaroundRegex.test(args[0].message[3][0])
) {
this.debug('ignoring early report caused by react#15069');
return true;
}
return shouldReport ? callOriginal : true;
});
const ValveErrorBoundary = findModuleExport(
(e) => e.InstallErrorReportingStore && e?.prototype?.Reset && e?.prototype?.componentDidCatch,
@@ -53,8 +71,14 @@ class ErrorBoundaryHook extends Logger {
this.errorBoundaryPatch = replacePatch(ValveErrorBoundary.prototype, 'render', function (this: any) {
if (this.state.error) {
const store = Object.getPrototypeOf(this)?.constructor?.sm_ErrorReportingStore || errorReportingStore;
return (
<DeckyErrorBoundary error={this.state.error} errorKey={this.state.errorKey} reset={() => this.Reset()} />
<DeckyErrorBoundary
error={this.state.error}
errorKey={this.props.errorKey}
identifier={`${store.product}_${store.version}_${this.state.identifierHash}`}
reset={() => this.Reset()}
/>
);
}
return callOriginal;
@@ -62,6 +86,7 @@ class ErrorBoundaryHook extends Logger {
}
deinit() {
this.errorCheckPatch?.unpatch();
this.errorBoundaryPatch?.unpatch();
}
}
+48
View File
@@ -0,0 +1,48 @@
import { ErrorInfo } from 'react';
const pluginErrorRegex = /http:\/\/localhost:1337\/plugins\/([^\/]*)\//;
const pluginSourceMapErrorRegex = /decky:\/\/decky\/plugin\/([^\/]*)\//;
const legacyPluginErrorRegex = /decky:\/\/decky\/legacy_plugin\/([^\/]*)\/index.js/;
export interface ValveReactErrorInfo {
error: Error;
info: ErrorInfo;
}
export interface ValveError {
identifier: string;
identifierHash: string;
message: string | [func: string, src: string, line: number, column: number];
}
export type ErrorSource = [source: string, wasPlugin: boolean, shouldReportToValve: boolean];
export function getLikelyErrorSourceFromValveError(error: ValveError): ErrorSource {
return getLikelyErrorSource(JSON.stringify(error?.message));
}
export function getLikelyErrorSourceFromValveReactError(error: ValveReactErrorInfo): ErrorSource {
return getLikelyErrorSource(error?.error?.stack);
}
export function getLikelyErrorSource(error?: string): ErrorSource {
const pluginMatch = error?.match(pluginErrorRegex);
if (pluginMatch) {
return [decodeURIComponent(pluginMatch[1]), true, false];
}
const pluginMatchViaMap = error?.match(pluginSourceMapErrorRegex);
if (pluginMatchViaMap) {
return [decodeURIComponent(pluginMatchViaMap[1]), true, false];
}
const legacyPluginMatch = error?.match(legacyPluginErrorRegex);
if (legacyPluginMatch) {
return [decodeURIComponent(legacyPluginMatch[1]), true, false];
}
if (error?.includes('http://localhost:1337/')) {
return ['the Decky frontend', false, false];
}
return ['Steam', false, true];
}