From b1d65fbda2ca133de9dcb66cceb8f7ba44673a5f Mon Sep 17 00:00:00 2001 From: PalmDevs Date: Fri, 14 Aug 2026 01:48:08 +0700 Subject: [PATCH] feat(plugins/recovery): detect freezes much earlier + show stack traces --- lib/modules/src/metro/runtime.ts | 4 +- .../components/ErrorBoundaryScreen.tsx | 0 .../preinit/recovery/components/alerts.tsx | 50 ++++ .../RevengeEnterRecoveryModeSetting.tsx | 0 src/plugins/preinit/recovery/index.tsx | 190 ++++++++++++++ src/plugins/preinit/recovery/settings.tsx | 13 + src/plugins/start/recovery/index.tsx | 244 ------------------ 7 files changed, 255 insertions(+), 246 deletions(-) rename src/plugins/{start => preinit}/recovery/components/ErrorBoundaryScreen.tsx (100%) create mode 100644 src/plugins/preinit/recovery/components/alerts.tsx rename src/plugins/{start => preinit}/recovery/definitions/RevengeEnterRecoveryModeSetting.tsx (100%) create mode 100644 src/plugins/preinit/recovery/index.tsx create mode 100644 src/plugins/preinit/recovery/settings.tsx delete mode 100644 src/plugins/start/recovery/index.tsx diff --git a/lib/modules/src/metro/runtime.ts b/lib/modules/src/metro/runtime.ts index 81f0e13..98404f5 100644 --- a/lib/modules/src/metro/runtime.ts +++ b/lib/modules/src/metro/runtime.ts @@ -15,7 +15,7 @@ import { } from './subscriptions/_internal' import type { Metro } from '../types' -export const mErrorChain: Metro.ModuleID[] = [] +export const mErrorChain: [Metro.ModuleID, unknown][] = [] export const Initialized = 1 << 0 const HasError = 1 << 1 @@ -80,7 +80,7 @@ export const metroRequire = (moduleId => { // if (global.ErrorUtils) global.ErrorUtils.reportFatalError(e) // else throw e - mErrorChain.push(moduleId) + mErrorChain.push([moduleId, e]) return (moduleObject.exports = {}) } diff --git a/src/plugins/start/recovery/components/ErrorBoundaryScreen.tsx b/src/plugins/preinit/recovery/components/ErrorBoundaryScreen.tsx similarity index 100% rename from src/plugins/start/recovery/components/ErrorBoundaryScreen.tsx rename to src/plugins/preinit/recovery/components/ErrorBoundaryScreen.tsx diff --git a/src/plugins/preinit/recovery/components/alerts.tsx b/src/plugins/preinit/recovery/components/alerts.tsx new file mode 100644 index 0000000..9c7ec52 --- /dev/null +++ b/src/plugins/preinit/recovery/components/alerts.tsx @@ -0,0 +1,50 @@ +import { Design } from '@revenge-mod/discord/design' +import { RootNavigationRef } from '@revenge-mod/discord/modules/main_tabs_v2' +import { getBridgeInfo } from '@revenge-mod/modules/native' +import { RouteNames } from '~plugins/start/settings.plugins/constants' + +export function RecoveryModal() { + const navigation = RootNavigationRef.getRootNavigationRef() + + return ( + + { + // React Navigation will bubble our requests to the main navigator if we're already in settings + navigation.navigate('settings', { + screen: RouteNames.RevengePlugins, + }) + }} + /> + + + } + /> + ) +} + +export function LoaderOutdatedModal() { + const info = getBridgeInfo() + if (!info) throw new Error('Failed to get native bridge info') + + return ( + + } + /> + ) +} diff --git a/src/plugins/start/recovery/definitions/RevengeEnterRecoveryModeSetting.tsx b/src/plugins/preinit/recovery/definitions/RevengeEnterRecoveryModeSetting.tsx similarity index 100% rename from src/plugins/start/recovery/definitions/RevengeEnterRecoveryModeSetting.tsx rename to src/plugins/preinit/recovery/definitions/RevengeEnterRecoveryModeSetting.tsx diff --git a/src/plugins/preinit/recovery/index.tsx b/src/plugins/preinit/recovery/index.tsx new file mode 100644 index 0000000..2120ef8 --- /dev/null +++ b/src/plugins/preinit/recovery/index.tsx @@ -0,0 +1,190 @@ +import defer * as Actions from '@revenge-mod/discord/actions' +import { onFluxEventDispatched } from '@revenge-mod/discord/flux' +import { waitForModules } from '@revenge-mod/modules/finders' +import { withName } from '@revenge-mod/modules/finders/filters' +import { callNativeMethod } from '@revenge-mod/modules/native' +import { instead } from '@revenge-mod/patcher' +import { + InternalPluginFlags, + isDefaultsOnlyBoot, + PluginFlags, + registerInternalPlugin, +} from '@revenge-mod/plugins/_' +import { onRunApplicationFinished } from '@revenge-mod/react/native' +import { asap } from '@revenge-mod/utils/callback' +import { getErrorStack } from '@revenge-mod/utils/error' +import { AppState } from 'react-native' +import { FullVersion } from '~constants' +import { cache as assetsCache } from '../../../../lib/assets/src/caches' +import { cache as modulesCache } from '../../../../lib/modules/src/caches' +import { mErrorChain } from '../../../../lib/modules/src/metro/runtime' +import pluginSettings from '../../start/settings' +import defer * as Alerts from './components/alerts' +import defer * as ErrorBoundaryScreen from './components/ErrorBoundaryScreen' +import type { Component, ReactNode } from 'react' + +registerInternalPlugin( + { + id: 'revenge.recovery', + name: 'Recovery', + description: + 'Handles errors and provides troubleshooting options for Revenge.', + author: 'Revenge', + icon: 'ShieldIcon', + dependencies: { [pluginSettings]: {} }, + }, + { + preInit({ cleanup }) { + cleanup(freezeDetectionService()) + }, + start({ cleanup }) { + // @as-require + import('./settings') + + cleanup(errorBoundaryService()) + + asap(() => { + if (isDefaultsOnlyBoot) { + Actions.AlertActionCreators.openAlert( + 'revenge-recovery', + , + ) + } + + if (assetsCache.outdated || modulesCache.outdated) { + Actions.AlertActionCreators.openAlert( + 'revenge-loader-outdated', + , + ) + } + }) + }, + }, + PluginFlags.Enabled, + InternalPluginFlags.Internal | InternalPluginFlags.Essential, +) + +const FreezeDetectionTimeout = 5000 + +function freezeDetectionService() { + let currentId: number | undefined + let cleared = false + + let sub: { remove(): void } | undefined + let unsubFlux: (() => void) | undefined + + const setTimer = () => { + onRunApplicationFinished(() => { + if (cleared) return + if (currentId !== undefined) clearTimeout(currentId) + + currentId = setTimeout(() => { + currentId = undefined + + if (AppState.currentState !== 'active') return + + try { + let text = + 'App was unable to start. This is likely caused by a plugin.\n\n' + + 'You can launch Recovery Mode in the Recovery menu.' + + if (mErrorChain.length > 0) { + text += `\n\nErroring modules chain: ${mErrorChain.map(([id]) => id).join(', ')}` + text += `\n\nStacks:\n\n${mErrorChain.map(([id, stack]) => `[${id}] ${getErrorStack(stack)}`).join('\n\n')}` + } else { + text += '\n\nNo erroring modules were detected.' + } + + callNativeMethod('revenge.alertError', [text, FullVersion]) + } catch (e) { + console.error( + 'Failed to call native method "revenge.alertError":', + e, + ) + } + }, FreezeDetectionTimeout) + }) + } + + const clear = (e?: T) => { + cleared = true + + if (currentId !== undefined) { + clearTimeout(currentId) + currentId = undefined + } + + sub?.remove() + unsubFlux?.() + + return e + } + + unsubFlux = onFluxEventDispatched('APP_STATE_UPDATE', clear) + // clear() may have run before unsubStore/unsubFlux was assigned, so run it again to unsub + if (cleared) { + clear() + return clear + } + + sub = AppState.addEventListener('change', state => { + if (state === 'active') setTimer() + else if (currentId !== undefined) { + clearTimeout(currentId) + currentId = undefined + } + }) + + if (AppState.currentState === 'active') setTimer() + + return clear +} + +function errorBoundaryService() { + const unsub = waitForModules( + withName('ErrorBoundary'), + exports => { + unsub() + + instead( + exports.prototype, + 'render', + function (this: DiscordErrorBoundary) { + if (this.state.error) + return ( + { + this.setState({ + error: null, + info: null, + }) + }} + /> + ) + + return this.props.children + }, + ) + }, + { + cached: true, + }, + ) + + return unsub +} + +declare class DiscordErrorBoundary extends Component< + { children: ReactNode }, + { + error: (Error & { componentStack?: string }) | unknown | null + info: { componentStack?: string } | null + } +> { + // render() is always called with `this` as the instance of DiscordErrorBoundary + render(this: DiscordErrorBoundary): ReactNode + discordErrorsSet: boolean + handleReload(): void +} diff --git a/src/plugins/preinit/recovery/settings.tsx b/src/plugins/preinit/recovery/settings.tsx new file mode 100644 index 0000000..a2f5907 --- /dev/null +++ b/src/plugins/preinit/recovery/settings.tsx @@ -0,0 +1,13 @@ +import { + onSettingsModulesLoaded, + registerSettingsItem, +} from '@revenge-mod/discord/modules/settings' +import { Setting } from '~plugins/start/settings/constants' +import RevengeEnterRecoveryModeSetting from './definitions/RevengeEnterRecoveryModeSetting' + +onSettingsModulesLoaded(() => { + registerSettingsItem( + Setting.RevengeEnterRecoveryMode, + RevengeEnterRecoveryModeSetting, + ) +}) diff --git a/src/plugins/start/recovery/index.tsx b/src/plugins/start/recovery/index.tsx deleted file mode 100644 index 5fdf73d..0000000 --- a/src/plugins/start/recovery/index.tsx +++ /dev/null @@ -1,244 +0,0 @@ -import { AlertActionCreators } from '@revenge-mod/discord/actions' -import { Design } from '@revenge-mod/discord/design' -import { getStore, onFluxEventDispatched } from '@revenge-mod/discord/flux' -import { RootNavigationRef } from '@revenge-mod/discord/modules/main_tabs_v2' -import { - onSettingsModulesLoaded, - registerSettingsItem, -} from '@revenge-mod/discord/modules/settings' -import { waitForModules } from '@revenge-mod/modules/finders' -import { withName } from '@revenge-mod/modules/finders/filters' -import { callNativeMethod, getBridgeInfo } from '@revenge-mod/modules/native' -import { instead } from '@revenge-mod/patcher' -import { - InternalPluginFlags, - isDefaultsOnlyBoot, - PluginFlags, - registerInternalPlugin, -} from '@revenge-mod/plugins/_' -import { asap } from '@revenge-mod/utils/callback' -import { AppState } from 'react-native' -import { FullVersion } from '~constants' -import { cache as assetsCache } from '../../../../lib/assets/src/caches' -import { cache as modulesCache } from '../../../../lib/modules/src/caches' -import { mErrorChain } from '../../../../lib/modules/src/metro/runtime' -import pluginSettings from '../settings' -import { Setting } from '../settings/constants' -import { RouteNames } from '../settings.plugins/constants' -import ErrorBoundaryScreen from './components/ErrorBoundaryScreen' -import RevengeEnterRecoveryModeSetting from './definitions/RevengeEnterRecoveryModeSetting' -import type { Component, ReactNode } from 'react' - -registerInternalPlugin( - { - id: 'revenge.recovery', - name: 'Recovery', - description: - 'Handles errors and provides troubleshooting options for Revenge.', - author: 'Revenge', - icon: 'ShieldIcon', - dependencies: { [pluginSettings]: {} }, - }, - { - start({ cleanup }) { - onSettingsModulesLoaded(() => { - registerSettingsItem( - Setting.RevengeEnterRecoveryMode, - RevengeEnterRecoveryModeSetting, - ) - }) - - cleanup(errorBoundaryService(), freezeDetectionService()) - - if (isDefaultsOnlyBoot) { - asap(() => { - AlertActionCreators.openAlert( - 'revenge-recovery', - , - ) - }) - } - - if (assetsCache.outdated || modulesCache.outdated) { - asap(() => { - AlertActionCreators.openAlert( - 'revenge-loader-outdated', - , - ) - }) - } - }, - }, - PluginFlags.Enabled, - InternalPluginFlags.Internal | InternalPluginFlags.Essential, -) - -const FreezeDetectionTimeout = 5000 - -function freezeDetectionService() { - let currentId: number | undefined - let cleared = false - - let sub: { remove(): void } | undefined - let unsubStore: (() => void) | undefined - let unsubFlux: (() => void) | undefined - - const setTimer = () => { - if (cleared) return - if (currentId !== undefined) clearTimeout(currentId) - - currentId = setTimeout(() => { - currentId = undefined - - if (AppState.currentState !== 'active') return - - try { - callNativeMethod('revenge.alertError', [ - `App was unable to start. This is likely caused by a plugin.\n\nYou can launch Recovery Mode in the Recovery menu.\n\nErroring modules chain: ${mErrorChain.join(', ') || 'None'}`, - FullVersion, - ]) - } catch (e) { - console.error( - 'Failed to call native method "revenge.alertError":', - e, - ) - } - }, FreezeDetectionTimeout) - } - - const clear = (e?: T) => { - cleared = true - - if (currentId !== undefined) { - clearTimeout(currentId) - currentId = undefined - } - - sub?.remove() - unsubStore?.() - unsubFlux?.() - - return e - } - - // If the store initializes, the app is probably working fine, so we can cancel the timer - unsubStore = getStore('AppStateStore', clear) - // clear() may have run before unsubStore was assigned, so run it again to unsub - if (cleared) { - clear() - return clear - } - - unsubFlux = onFluxEventDispatched('APP_STATE_UPDATE', clear) - if (cleared) { - clear() - return clear - } - - sub = AppState.addEventListener('change', state => { - if (state === 'active') setTimer() - else if (currentId !== undefined) { - clearTimeout(currentId) - currentId = undefined - } - }) - - if (AppState.currentState === 'active') setTimer() - - return clear -} - -function errorBoundaryService() { - const unsub = waitForModules( - withName('ErrorBoundary'), - exports => { - unsub() - - instead( - exports.prototype, - 'render', - function (this: DiscordErrorBoundary) { - if (this.state.error) - return ( - { - this.setState({ - error: null, - info: null, - }) - }} - /> - ) - - return this.props.children - }, - ) - }, - { - cached: true, - }, - ) - - return unsub -} - -declare class DiscordErrorBoundary extends Component< - { children: ReactNode }, - { - error: (Error & { componentStack?: string }) | unknown | null - info: { componentStack?: string } | null - } -> { - // render() is always called with `this` as the instance of DiscordErrorBoundary - render(this: DiscordErrorBoundary): ReactNode - discordErrorsSet: boolean - handleReload(): void -} - -function RecoveryModal() { - const navigation = RootNavigationRef.getRootNavigationRef() - - return ( - - { - // React Navigation will bubble our requests to the main navigator if we're already in settings - navigation.navigate('settings', { - screen: RouteNames.RevengePlugins, - }) - }} - /> - - - } - /> - ) -} - -function LoaderOutdatedModal() { - const info = getBridgeInfo() - if (!info) throw new Error('Failed to get native bridge info') - - return ( - - } - /> - ) -}