feat: app freeze detection and recovery mode

This requires loader support and can only be triggered from a native dialog.
Recovery mode toggle won't be a functionality for now. If there's a use, such as binary searching problematic plugins, then it'll be added (similar to VSCode extension bisect).
This commit is contained in:
PalmDevs 2026-07-31 16:43:55 +07:00
parent 457df3ebb5
commit 1497e8ef8d
No known key found for this signature in database
GPG key ID: 6FAE8F8677ACA8BA
5 changed files with 146 additions and 4 deletions

View file

@ -13,11 +13,18 @@ export const PluginStatus = {
Stopping: 1 << 6,
}
const { storageRootPath } = callNativeMethodSync(
const { storageRootPath, defaultsOnly } = callNativeMethodSync(
'revenge.plugins.getConstants',
[],
)
/**
* Whether this boot ignores saved plugin states.
*
* Only essential and enabled-by-default plugins run. Saved states will come back on the next reload.
*/
export const isDefaultsOnlyBoot = defaultsOnly
/**
* Absolute path to per-plugin storage directory.
*/

View file

@ -321,6 +321,9 @@ export interface PluginSettingsComponent<
declare module '@revenge-mod/modules/native' {
export interface NativeMethods {
'revenge.plugins.getConstants': [[], { storageRootPath: string }]
'revenge.plugins.getConstants': [
[],
{ storageRootPath: string; defaultsOnly: boolean },
]
}
}

View file

@ -0,0 +1,90 @@
import { AlertActionCreators } from '@revenge-mod/discord/actions'
import { Design } from '@revenge-mod/discord/design'
import { onFluxEventDispatched } from '@revenge-mod/discord/flux'
import { RootNavigationRef } from '@revenge-mod/discord/modules/main_tabs_v2'
import { callNativeMethod } from '@revenge-mod/modules/native'
import {
InternalPluginFlags,
PluginFlags,
registerInternalPlugin,
} from '@revenge-mod/plugins/_'
import { isDefaultsOnlyBoot } from '@revenge-mod/plugins/constants'
import { asap } from '@revenge-mod/utils/callback'
import { FullVersion } from '~constants'
import { RouteNames } from '../settings.plugins/constants'
registerInternalPlugin(
{
id: 'revenge.recovery',
name: 'Recovery',
description: 'Provides troubleshooting options.',
author: 'Revenge',
icon: 'WrenchIcon',
},
{
start() {
// Freeze detection
const id = setTimeout(() => {
try {
callNativeMethod('revenge.alertError', [
'Application seems frozen. This is likely caused by a plugin.\n\nYou can launch with default plugins in the Recovery menu.',
FullVersion,
])
} catch (e) {
console.error(
'Failed to call native method "revenge.alertError":',
e,
)
}
}, 10000)
const unsub = onFluxEventDispatched('APP_STATE_UPDATE', e => {
clearTimeout(id)
unsub()
return e
})
if (isDefaultsOnlyBoot) {
asap(() => {
AlertActionCreators.openAlert(
'revenge-recovery',
<RecoveryModal />,
)
})
}
},
},
PluginFlags.Enabled,
InternalPluginFlags.Internal | InternalPluginFlags.Essential,
)
function RecoveryModal() {
const navigation = RootNavigationRef.getRootNavigationRef()
return (
<Design.AlertModal
title="Recovery Mode"
content={
'Running with default plugins. Your enabled plugins are saved and will come back when you reload.\n\nUninstall plugins that might be causing issues, then reload the app to get back on track.'
}
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"
/>
</>
}
/>
)
}

View file

@ -1,5 +1,6 @@
import TableRowAssetIcon from '@revenge-mod/components/TableRowAssetIcon'
import { isPluginEnabled, pEmitter, pList } from '@revenge-mod/plugins/_'
import { isDefaultsOnlyBoot } from '@revenge-mod/plugins/constants'
import { useReRender } from '@revenge-mod/utils/react'
import { useEffect } from 'react'
import { RouteNames, Setting } from '../constants'
@ -11,7 +12,9 @@ const RevengePluginsSetting: SettingsItem = {
type: 'route',
IconComponent: () => <TableRowAssetIcon name="PuzzlePieceIcon" />,
useTitle: () => 'Plugins',
useTrailing: () => `${useEnabledPluginCount()} enabled`,
useTrailing: () =>
`${useEnabledPluginCount()} enabled` +
(isDefaultsOnlyBoot ? ' (recovery)' : ''),
screen: {
route: RouteNames[Setting.RevengePlugins],
getComponent: () => RevengePluginsSettingScreen.default,

View file

@ -5,6 +5,7 @@ import Page from '@revenge-mod/components/Page'
import SearchInput from '@revenge-mod/components/SearchInput'
import { ActionSheetActionCreators } from '@revenge-mod/discord/actions'
import { Design } from '@revenge-mod/discord/design'
import { BundleUpdaterManager } from '@revenge-mod/discord/native'
import {
getInternalPluginMeta,
InternalPluginFlags,
@ -16,6 +17,7 @@ import {
pEmitter,
pList,
} from '@revenge-mod/plugins/_'
import { isDefaultsOnlyBoot } from '@revenge-mod/plugins/constants'
import { debounce } from '@revenge-mod/utils/callback'
import {
useCallback,
@ -38,7 +40,8 @@ import type { NavigationProp, RouteProp } from '@react-navigation/core'
import type { ReactNavigationParamList } from '@revenge-mod/externals/react-navigation'
import type { FilterAndSortActionSheetProps } from '../components/FilterAndSortActionSheet'
const { Stack, IconButton, FloatingActionButton, LayerScope } = Design
const { Stack, IconButton, FloatingActionButton, LayerScope, Card, Text } =
Design
const FiltersHorizontalIcon = getAssetIdByName('FiltersHorizontalIcon', 'png')!
const SettingsIcon = getAssetIdByName('SettingsIcon')!
@ -148,6 +151,41 @@ function BrowseFloatingActionButton() {
)
}
function DefaultsOnlyBanner() {
const [hide, setHide] = useState(false)
if (!isDefaultsOnlyBoot || hide) return null
return (
<Card>
<Stack spacing={16}>
<Text variant="text-sm/medium">
Running with default plugins. Your enabled plugins are saved
and will come back when you reload.{'\n\n'}
Uninstall plugins that might be causing issues, then reload
the app to get back on track.
</Text>
<Stack spacing={8}>
<Design.Button
icon={getAssetIdByName('RetryIcon')!}
size="sm"
text="Reload with all plugins enabled"
onPress={() => BundleUpdaterManager.reload()}
/>
{/* In case it's taking too much vertical space */}
<Design.Button
icon={getAssetIdByName('EyeSlashIcon')!}
size="sm"
variant="secondary"
text="Hide this message"
onPress={() => setHide(true)}
/>
</Stack>
</Stack>
</Card>
)
}
function snapshotPlugins() {
return [...pList.values()].map(
plugin => [plugin, getInternalPluginMeta(plugin)] as const,
@ -225,6 +263,7 @@ function Screen() {
return (
<>
<DefaultsOnlyBanner />
<Stack direction="horizontal">
<View style={styles.grow}>
<SearchInput onChange={debouncedSetSearch} size="md" />