revenge-bundle-next/src/plugins/preinit/recovery/components/ErrorBoundaryScreen.tsx

268 lines
9.1 KiB
TypeScript

import { getAssetIdByName } from '@revenge-mod/assets'
import { styles } from '@revenge-mod/components/_'
import { Tokens } from '@revenge-mod/discord/common/tokens'
import { Design } from '@revenge-mod/discord/design'
import { ClientInfoModule } from '@revenge-mod/discord/native'
import { Clipboard } from '@revenge-mod/externals/react-native-clipboard'
import { ReactNativeSafeAreaContext } from '@revenge-mod/externals/react-native-safe-area-context'
import { reloadApp } from '@revenge-mod/modules/native/app'
import { requestNextBootDefaultsOnly } from '@revenge-mod/plugins/_'
import { getErrorStack } from '@revenge-mod/utils/error'
import { ScrollView, StyleSheet, View } from 'react-native'
import { FullVersion } from '~/constants'
import { getRNVersion } from '~plugins/start/settings/definitions/shared'
import type { ComponentProps } from 'react'
const { createStyles, Button, Card, IconButton, Stack, Text } = Design
const useErrorBoundaryStyles = createStyles({
view: {
backgroundColor: Tokens.default.colors.BACKGROUND_BASE_LOWER,
paddingHorizontal: 16,
paddingVertical: 24,
flex: 1,
gap: 16,
},
})
const additionalStyles = StyleSheet.create({
headerContainer: {
gap: 4,
},
scrollView: {
gap: 8,
flex: 1,
},
stackTrace: {
fontFamily: 'monospace',
fontWeight: 'bold',
marginBottom: 8,
},
actions: {
flexShrink: 0,
},
})
interface RenderError extends Error {
componentStack?: string
}
export default function ErrorBoundaryScreen({
error,
rerender,
reload,
}: {
error: RenderError | unknown
rerender: () => void
reload: () => void
}) {
const errorBoundaryStyles = useErrorBoundaryStyles()
const Client = ClientInfoModule.getConstants()
return (
<ReactNativeSafeAreaContext.SafeAreaView
style={errorBoundaryStyles.view}
>
<View style={additionalStyles.headerContainer}>
<Text variant="display-lg">Error!</Text>
<Text variant="text-md/normal">
An error was thrown while rendering components. This could
be caused by plugins, Revenge or Discord.
</Text>
<Text variant="text-sm/semibold" color="text-muted">
{Client.Version} ({Client.Build}) • RN {getRNVersion()} •{' '}
{FullVersion}
</Text>
</View>
<LabeledCard label="Error" rawContent={getErrorStack(error)}>
<Text variant="text-md/medium">{String(error)}</Text>
{error instanceof Error && error.stack && (
<ScrollView
style={additionalStyles.scrollView}
fadingEdgeLength={64}
>
{parseStackTrace(
error.stack?.slice(String(error).length + 1),
).map(({ at, file, line, column }) => (
// biome-ignore lint/correctness/useJsxKeyInIterable: This never gets rerendered
<Text
variant="text-sm/bold"
style={additionalStyles.stackTrace}
>
{at}
{'\n'}
<Text color="text-muted">
{file}
{
<>
:{line}:{column}
</>
}
</Text>
</Text>
))}
</ScrollView>
)}
</LabeledCard>
{error instanceof Error &&
(error as RenderError).componentStack && (
<LabeledCard
scrollable
label="Component Stack"
style={styles.flex}
rawContent={(error as RenderError).componentStack!}
>
<Text selectable variant="text-md/medium">
{...parseStackTrace(
(error as RenderError).componentStack!.slice(1),
).map(({ at }) => [
'<',
// biome-ignore lint/correctness/useJsxKeyInIterable: This never gets rerendered
<Text variant="text-md/bold">{at}</Text>,
'/>\n',
])}
</Text>
</LabeledCard>
)}
<Stack spacing={8} style={additionalStyles.actions}>
<Button
icon={getAssetIdByName('ShieldIcon')}
text="Enter Recovery Mode"
onPress={() => {
requestNextBootDefaultsOnly()
reloadApp()
}}
/>
<Stack direction="horizontal" spacing={16}>
<Button
icon={getAssetIdByName('SendMessageIcon')}
style={styles.flex}
variant="secondary"
text="Retry Render"
onPress={rerender}
/>
<Button
icon={getAssetIdByName('RetryIcon')}
style={styles.flex}
variant="destructive"
text="Reload"
onPress={reload}
/>
</Stack>
</Stack>
</ReactNativeSafeAreaContext.SafeAreaView>
)
}
export interface LabeledCardProps extends ComponentProps<typeof Card> {
label: string
rawContent?: string
scrollable?: boolean
}
export function LabeledCard(props: LabeledCardProps) {
const ViewComponent = props.scrollable ? ScrollView : View
return (
<Card
{...props}
style={[
additionalStyles.scrollView,
...(Array.isArray(props.style) ? props.style : [props.style]),
]}
>
<Stack direction="horizontal">
<Text variant="heading-xl/semibold" style={styles.grow}>
{props.label}
</Text>
{props.rawContent && (
<IconButton
icon={getAssetIdByName('CopyIcon')!}
accessibilityHint="Copy"
variant="secondary"
size="sm"
onPress={() => {
Clipboard.setString(props.rawContent as string)
}}
/>
)}
</Stack>
<ViewComponent
style={additionalStyles.scrollView}
fadingEdgeLength={32}
>
{props.children}
</ViewComponent>
</Card>
)
}
interface StackFrame {
at: string
file: string
line: number | null
column: number | null
}
const IndexBundleFilePath = (
HermesInternal as HermesInternalObject
).getFunctionLocation(alert).fileName
const RevengeFilePath = (
HermesInternal as HermesInternalObject
).getFunctionLocation(ErrorBoundaryScreen).fileName
const StackFrameRegex = /at (.+) \(([^:]+):(\d+):(\d+)\)|at (.+)? \(([^)]+)\)/
function parseStackTrace(stackTrace: string): StackFrame[] {
const frames: StackFrame[] = []
const lines = stackTrace.split('\n')
for (const line of lines) {
const match = StackFrameRegex.exec(line.trim())
if (match) {
let at: string
let path: string
let ln: number | null = null
let col: number | null = null
if (match[3] && match[4]) {
// Case where line and column numbers are present
at = match[1]!
path = match[2]!
ln = Number(match[3])
col = Number(match[4])
} else {
// Case where line and column numbers are missing (native functions or just file path)
at = match[5]!
path = match[6]!
}
switch (path) {
case IndexBundleFilePath:
path = 'Discord'
break
case RevengeFilePath:
path = 'Revenge'
break
}
frames.push({
at: at,
file: path,
line: ln,
column: col,
})
} else {
// For lines that don't match the expected format
frames.push({
at: 'UNABLE TO PARSE LINE',
file: line,
line: null,
column: null,
})
}
}
return frames
}