From 9c8db576f5cea498c70d00a0764d7f3c6c9cef65 Mon Sep 17 00:00:00 2001 From: AAGaming Date: Mon, 27 May 2024 17:21:27 -0400 Subject: [PATCH] error boundary now properly reports steam errors --- .github/workflows/typecheck.yml | 2 +- backend/decky_loader/plugin/plugin.py | 10 + .../decky_loader/plugin/sandboxed_plugin.py | 6 - frontend/package.json | 3 +- frontend/pnpm-lock.yaml | 17 +- .../src/components/DeckyErrorBoundary.tsx | 278 +++++++++--------- frontend/src/components/DeckyToasterState.tsx | 3 +- frontend/src/components/PluginView.tsx | 4 +- .../components/QuickAccessVisibleState.tsx | 4 +- frontend/src/components/TitleView.tsx | 4 +- frontend/src/errorboundary-hook.tsx | 37 ++- frontend/src/utils/errors.ts | 48 +++ 12 files changed, 247 insertions(+), 169 deletions(-) create mode 100644 frontend/src/utils/errors.ts diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index 181a4211e..763d8590d 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -41,4 +41,4 @@ jobs: - name: Run tsc (TypeScript) working-directory: frontend - run: $(pnpm bin)/tsc --noEmit \ No newline at end of file + run: pnpm run typecheck \ No newline at end of file diff --git a/backend/decky_loader/plugin/plugin.py b/backend/decky_loader/plugin/plugin.py index 75e52c6a3..aff35e5c0 100644 --- a/backend/decky_loader/plugin/plugin.py +++ b/backend/decky_loader/plugin/plugin.py @@ -8,6 +8,7 @@ from .messages import MethodCallRequest, SocketMessageType from ..enums import PluginLoadType from ..localplatform.localsocket import LocalSocket +from ..helpers import get_homebrew_path, mkdir_as_user from typing import Any, Callable, Coroutine, Dict, List @@ -50,6 +51,15 @@ def __init__(self, file: str, plugin_directory: str, plugin_path: str, emit_call # TODO enable this after websocket release self.legacy_method_warning = False + home = get_homebrew_path() + mkdir_as_user(path.join(home, "settings", self.plugin_directory)) + # TODO maybe dont chown this? + mkdir_as_user(path.join(home, "data")) + mkdir_as_user(path.join(home, "data", self.plugin_directory)) + # TODO maybe dont chown this? + mkdir_as_user(path.join(home, "logs")) + mkdir_as_user(path.join(home, "logs", self.plugin_directory)) + def __str__(self) -> str: return self.name diff --git a/backend/decky_loader/plugin/sandboxed_plugin.py b/backend/decky_loader/plugin/sandboxed_plugin.py index 6c2bcee27..cb1498165 100644 --- a/backend/decky_loader/plugin/sandboxed_plugin.py +++ b/backend/decky_loader/plugin/sandboxed_plugin.py @@ -60,14 +60,8 @@ def initialize(self, socket: LocalSocket): environ["DECKY_USER_HOME"] = helpers.get_home_path() environ["DECKY_HOME"] = helpers.get_homebrew_path() environ["DECKY_PLUGIN_SETTINGS_DIR"] = path.join(environ["DECKY_HOME"], "settings", self.plugin_directory) - helpers.mkdir_as_user(path.join(environ["DECKY_HOME"], "settings")) - helpers.mkdir_as_user(environ["DECKY_PLUGIN_SETTINGS_DIR"]) environ["DECKY_PLUGIN_RUNTIME_DIR"] = path.join(environ["DECKY_HOME"], "data", self.plugin_directory) - helpers.mkdir_as_user(path.join(environ["DECKY_HOME"], "data")) - helpers.mkdir_as_user(environ["DECKY_PLUGIN_RUNTIME_DIR"]) environ["DECKY_PLUGIN_LOG_DIR"] = path.join(environ["DECKY_HOME"], "logs", self.plugin_directory) - helpers.mkdir_as_user(path.join(environ["DECKY_HOME"], "logs")) - helpers.mkdir_as_user(environ["DECKY_PLUGIN_LOG_DIR"]) environ["DECKY_PLUGIN_DIR"] = path.join(self.plugin_path, self.plugin_directory) environ["DECKY_PLUGIN_NAME"] = self.name if self.version: diff --git a/frontend/package.json b/frontend/package.json index 45be0b986..2fb9c617c 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -7,10 +7,11 @@ "build": "rollup -c", "watch": "rollup -c -w", "lint": "prettier -c src", + "typecheck": "tsc --noEmit", "format": "prettier -c src -w" }, "devDependencies": { - "@decky/api": "^1.0.3", + "@decky/api": "^1.0.4", "@rollup/plugin-commonjs": "^21.1.0", "@rollup/plugin-image": "^3.0.3", "@rollup/plugin-json": "^4.1.0", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index e3a241431..10ba5c2ca 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -35,8 +35,8 @@ dependencies: devDependencies: '@decky/api': - specifier: ^1.0.3 - version: 1.0.3 + specifier: ^1.0.4 + version: 1.0.4 '@rollup/plugin-commonjs': specifier: ^21.1.0 version: 21.1.0(rollup@2.79.1) @@ -318,8 +318,8 @@ packages: to-fast-properties: 2.0.0 dev: true - /@decky/api@1.0.3: - resolution: {integrity: sha512-7hMKEHWcyz/bttx7DcKXqsOXcrtmC4CB6UwxRVrtlb/aolQtv1NVKHIEkIM6ND5hqTUU/VJ2HPUmCOwKm3Of0Q==} + /@decky/api@1.0.4: + resolution: {integrity: sha512-YChHjlk//lOiIM2tlNSd6Qk9aduFJOtG+uRv1JaTzLewPRj4dDeupC+mbJJfarMGYa4nsLnJ6BsubTqboeb+VQ==} dev: true /@esbuild/aix-ppc64@0.20.2: @@ -1142,7 +1142,7 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true dependencies: - caniuse-lite: 1.0.30001621 + caniuse-lite: 1.0.30001623 electron-to-chromium: 1.4.783 node-releases: 2.0.14 update-browserslist-db: 1.0.16(browserslist@4.23.0) @@ -1190,8 +1190,8 @@ packages: engines: {node: '>=4'} dev: true - /caniuse-lite@1.0.30001621: - resolution: {integrity: sha512-+NLXZiviFFKX0fk8Piwv3PfLPGtRqJeq2TiNoUff/qB5KJgwecJTvCXDpmlyP/eCI/GUEmp/h/y5j0yckiiZrA==} + /caniuse-lite@1.0.30001623: + resolution: {integrity: sha512-X/XhAVKlpIxWPpgRTnlgZssJrF0m6YtRA0QDWgsBNT12uZM6LPRydR7ip405Y3t1LamD8cP2TZFEDZFBf5ApcA==} dev: true /ccount@2.0.1: @@ -1816,6 +1816,7 @@ packages: /glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported dependencies: fs.realpath: 1.0.0 inflight: 1.0.6 @@ -3174,6 +3175,7 @@ packages: /rimraf@2.7.1: resolution: {integrity: sha512-uWjbaKIK3T1OSVptzX7Nl6PvQ3qAGtKEtVRjRuazjfL3Bx5eI409VZSqgND+4UNnmzLVdPj9FqFJNPqBZFve4w==} + deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true dependencies: glob: 7.2.3 @@ -3181,6 +3183,7 @@ packages: /rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true dependencies: glob: 7.2.3 diff --git a/frontend/src/components/DeckyErrorBoundary.tsx b/frontend/src/components/DeckyErrorBoundary.tsx index a851b2e17..6fe234b7c 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 = ({ error, reset }) => { +const DeckyErrorBoundary: FunctionComponent = ({ error, identifier, reset }) => { const [actionLog, addLogLine] = useReducer((log: string, line: string) => (log += '\n' + line), ''); const [actionsEnabled, setActionsEnabled] = useState(true); const [debugAllowed, setDebugAllowed] = useState(true); - const [errorSource, wasCausedByPlugin] = getLikelyErrorSource(error); - + // Intentionally doesn't use DeckyState. + const [versionInfo, setVersionInfo] = useState(); + const [errorSource, wasCausedByPlugin] = getLikelyErrorSourceFromValveReactError(error); + useEffect(() => { + DeckyPluginLoader.updateVersion().then(setVersionInfo); + }, []); return ( -
-

+ +
- ⚠️ An error occured rendering this content. -

-

This error likely occured in {getLikelyErrorSource(error)}.

- {actionLog?.length > 0 && ( -
+        

+ ⚠️ An error occured rendering this content. +

+
           
-            Running actions...
-            {actionLog}
+            {identifier && `Error Reference: ${identifier}`}
+            {versionInfo?.current && `\nDecky Version: ${versionInfo.current}`}
           
         
- )} - {actionsEnabled && ( - <> -

Actions:

-

Use the touch screen.

-
- - -
-
- - -
- {debugAllowed && ( +

This error likely occured in {errorSource}.

+ {actionLog?.length > 0 && ( +
+            
+              Running actions...
+              {actionLog}
+            
+          
+ )} + {actionsEnabled && ( + <> +

Actions:

+

Use the touch screen.

+
- )} - {wasCausedByPlugin && (
- {'\n'} +
- )} - - )} + {debugAllowed && ( +
+ +
+ )} + {wasCausedByPlugin && ( +
+ {'\n'} + +
+ )} + + )} -
-        
-          {error.error.stack}
-          {'\n\n'}
-          Component Stack:
-          {error.info.componentStack}
-        
-      
-
+
+          
+            {error.error.stack}
+            {'\n\n'}
+            Component Stack:
+            {error.info.componentStack}
+          
+        
+ + ); }; diff --git a/frontend/src/components/DeckyToasterState.tsx b/frontend/src/components/DeckyToasterState.tsx index 8d0a5d454..715ed76dd 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; @@ -41,6 +41,7 @@ export const useDeckyToasterState = () => useContext(DeckyToasterContext); interface Props { deckyToasterState: DeckyToasterState; + children: ReactNode; } export const DeckyToasterStateContextProvider: FC = ({ children, deckyToasterState }) => { diff --git a/frontend/src/components/PluginView.tsx b/frontend/src/components/PluginView.tsx index ce20ac4ac..997e576b8 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 1bfe0e658..f5c050615 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(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(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 c49e6df60..8b45aae49 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 175b3ff68..6963f207e 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 ( - this.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 000000000..0bf829861 --- /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]; +}