feat(plugins/recovery): detect freezes much earlier + show stack traces
This commit is contained in:
parent
858244cab9
commit
b1d65fbda2
7 changed files with 255 additions and 246 deletions
|
|
@ -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 = {})
|
||||
}
|
||||
|
|
|
|||
50
src/plugins/preinit/recovery/components/alerts.tsx
Normal file
50
src/plugins/preinit/recovery/components/alerts.tsx
Normal file
|
|
@ -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 (
|
||||
<Design.AlertModal
|
||||
title="Recovery Mode"
|
||||
content={
|
||||
"You are now running with default plugins. Additional plugins can't be started in Recovery Mode.\n\nDisable or uninstall plugins that might be causing issues, then reload the app to exit Recovery Mode."
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Design.AlertActionButton
|
||||
variant="primary"
|
||||
text="View plugins"
|
||||
onPress={() => {
|
||||
// React Navigation will bubble our requests to the main navigator if we're already in settings
|
||||
navigation.navigate('settings', {
|
||||
screen: RouteNames.RevengePlugins,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<Design.AlertActionButton
|
||||
variant="secondary"
|
||||
text="Got it"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function LoaderOutdatedModal() {
|
||||
const info = getBridgeInfo()
|
||||
if (!info) throw new Error('Failed to get native bridge info')
|
||||
|
||||
return (
|
||||
<Design.AlertModal
|
||||
title="Loader Outdated"
|
||||
content={`Your loader is outdated. Update to the latest version to receive fixes and ensure compatibility with all plugins.\n\nYou're currently running ${info.name} v${info.version}`}
|
||||
actions={
|
||||
<Design.AlertActionButton variant="primary" text="Got it" />
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
190
src/plugins/preinit/recovery/index.tsx
Normal file
190
src/plugins/preinit/recovery/index.tsx
Normal file
|
|
@ -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',
|
||||
<Alerts.RecoveryModal />,
|
||||
)
|
||||
}
|
||||
|
||||
if (assetsCache.outdated || modulesCache.outdated) {
|
||||
Actions.AlertActionCreators.openAlert(
|
||||
'revenge-loader-outdated',
|
||||
<Alerts.LoaderOutdatedModal />,
|
||||
)
|
||||
}
|
||||
})
|
||||
},
|
||||
},
|
||||
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 = <T,>(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<typeof DiscordErrorBoundary>('ErrorBoundary'),
|
||||
exports => {
|
||||
unsub()
|
||||
|
||||
instead(
|
||||
exports.prototype,
|
||||
'render',
|
||||
function (this: DiscordErrorBoundary) {
|
||||
if (this.state.error)
|
||||
return (
|
||||
<ErrorBoundaryScreen.default
|
||||
error={this.state.error}
|
||||
reload={this.handleReload.bind(this)}
|
||||
rerender={() => {
|
||||
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
|
||||
}
|
||||
13
src/plugins/preinit/recovery/settings.tsx
Normal file
13
src/plugins/preinit/recovery/settings.tsx
Normal file
|
|
@ -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,
|
||||
)
|
||||
})
|
||||
|
|
@ -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',
|
||||
<RecoveryModal />,
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
if (assetsCache.outdated || modulesCache.outdated) {
|
||||
asap(() => {
|
||||
AlertActionCreators.openAlert(
|
||||
'revenge-loader-outdated',
|
||||
<LoaderOutdatedModal />,
|
||||
)
|
||||
})
|
||||
}
|
||||
},
|
||||
},
|
||||
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 = <T,>(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<typeof DiscordErrorBoundary>('ErrorBoundary'),
|
||||
exports => {
|
||||
unsub()
|
||||
|
||||
instead(
|
||||
exports.prototype,
|
||||
'render',
|
||||
function (this: DiscordErrorBoundary) {
|
||||
if (this.state.error)
|
||||
return (
|
||||
<ErrorBoundaryScreen
|
||||
error={this.state.error}
|
||||
reload={this.handleReload.bind(this)}
|
||||
rerender={() => {
|
||||
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 (
|
||||
<Design.AlertModal
|
||||
title="Recovery Mode"
|
||||
content={
|
||||
"You are now running with default plugins. Additional plugins can't be started in Recovery Mode.\n\nDisable or uninstall plugins that might be causing issues, then reload the app to exit Recovery Mode."
|
||||
}
|
||||
actions={
|
||||
<>
|
||||
<Design.AlertActionButton
|
||||
variant="primary"
|
||||
text="View plugins"
|
||||
onPress={() => {
|
||||
// React Navigation will bubble our requests to the main navigator if we're already in settings
|
||||
navigation.navigate('settings', {
|
||||
screen: RouteNames.RevengePlugins,
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<Design.AlertActionButton
|
||||
variant="secondary"
|
||||
text="Got it"
|
||||
/>
|
||||
</>
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
function LoaderOutdatedModal() {
|
||||
const info = getBridgeInfo()
|
||||
if (!info) throw new Error('Failed to get native bridge info')
|
||||
|
||||
return (
|
||||
<Design.AlertModal
|
||||
title="Loader Outdated"
|
||||
content={`Your loader is outdated. Update to the latest version to receive fixes and ensure compatibility with all plugins.\n\nYou're currently running ${info.name} v${info.version}`}
|
||||
actions={
|
||||
<Design.AlertActionButton variant="primary" text="Got it" />
|
||||
}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue