diff options
Diffstat (limited to 'frontend/src')
| -rw-r--r-- | frontend/src/components/DeckyErrorBoundary.tsx | 278 | ||||
| -rw-r--r-- | frontend/src/components/DeckyToasterState.tsx | 3 | ||||
| -rw-r--r-- | frontend/src/components/PluginView.tsx | 4 | ||||
| -rw-r--r-- | frontend/src/components/QuickAccessVisibleState.tsx | 4 | ||||
| -rw-r--r-- | frontend/src/components/TitleView.tsx | 4 | ||||
| -rw-r--r-- | frontend/src/errorboundary-hook.tsx | 37 | ||||
| -rw-r--r-- | frontend/src/utils/errors.ts | 48 |
7 files changed, 224 insertions, 154 deletions
diff --git a/frontend/src/components/DeckyErrorBoundary.tsx b/frontend/src/components/DeckyErrorBoundary.tsx index a851b2e1..6fe234b7 100644 --- a/frontend/src/components/DeckyErrorBoundary.tsx +++ b/frontend/src/components/DeckyErrorBoundary.tsx @@ -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> + </> ); }; diff --git a/frontend/src/components/DeckyToasterState.tsx b/frontend/src/components/DeckyToasterState.tsx index 8d0a5d45..715ed76d 100644 --- a/frontend/src/components/DeckyToasterState.tsx +++ b/frontend/src/components/DeckyToasterState.tsx @@ -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 }) => { diff --git a/frontend/src/components/PluginView.tsx b/frontend/src/components/PluginView.tsx index ce20ac4a..997e576b 100644 --- a/frontend/src/components/PluginView.tsx +++ b/frontend/src/components/PluginView.tsx @@ -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(); diff --git a/frontend/src/components/QuickAccessVisibleState.tsx b/frontend/src/components/QuickAccessVisibleState.tsx index 1bfe0e65..f5c05061 100644 --- a/frontend/src/components/QuickAccessVisibleState.tsx +++ b/frontend/src/components/QuickAccessVisibleState.tsx @@ -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 diff --git a/frontend/src/components/TitleView.tsx b/frontend/src/components/TitleView.tsx index c49e6df6..8b45aae4 100644 --- a/frontend/src/components/TitleView.tsx +++ b/frontend/src/components/TitleView.tsx @@ -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(); diff --git a/frontend/src/errorboundary-hook.tsx b/frontend/src/errorboundary-hook.tsx index 175b3ff6..6963f207 100644 --- a/frontend/src/errorboundary-hook.tsx +++ b/frontend/src/errorboundary-hook.tsx @@ -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(); } } diff --git a/frontend/src/utils/errors.ts b/frontend/src/utils/errors.ts new file mode 100644 index 00000000..0bf82986 --- /dev/null +++ b/frontend/src/utils/errors.ts @@ -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]; +} |
