feat(plugins/settings): add react devtools support

This commit is contained in:
PalmDevs 2025-05-05 19:13:01 +07:00
parent 1d32f75f79
commit 8f74dfc79c
No known key found for this signature in database
GPG key ID: 6FAE8F8677ACA8BA
8 changed files with 194 additions and 13 deletions

View file

@ -47,7 +47,7 @@ declare global {
declare global {
var __REACT_DEVTOOLS_GLOBAL_HOOK__: unknown | undefined
var __reactDevTools:
var __REACT_DEVTOOLS__:
| {
version: string
exports: {

View file

@ -8,6 +8,7 @@ export const MobileSetting = {
REVENGE_DEVELOPER: 'REVENGE_DEVELOPER',
// SUBSETTINGS
REVENGE_DISCORD_SERVER: 'REVENGE_DISCORD_SERVER',
REVENGE_GITHUB_ORGANIZATION: 'REVENGE_GITHUB_ORGANIZATION',
RELOAD_APP: 'RELOAD_APP',
@ -17,6 +18,9 @@ export const MobileSetting = {
REACT_NATIVE_VERSION: 'REACT_NATIVE_VERSION',
HERMES_VERSION: 'HERMES_VERSION',
REACT_DEVTOOLS_VERSION: 'REACT_DEVTOOLS_VERSION',
REACT_DEVTOOLS_CONNECT: 'REACT_DEVTOOLS_CONNECT',
REACT_DEVTOOLS_DISCONNECT: 'REACT_DEVTOOLS_DISCONNECT',
EVALUATE_JAVASCRIPT: 'EVALUATE_JAVASCRIPT',
REVENGE_NOT_IMPLEMENTED: 'REVENGE_NOT_IMPLEMENTED',

View file

@ -0,0 +1,21 @@
import { React } from '@revenge-mod/react'
import AssetIcon from '../components/icons/AssetIcon'
import { connectToDevTools, DevToolsContext, useIsDevToolsOpen } from '../devtools'
import { MobileSetting } from '../SettingsConstants'
import type { SettingsItem } from '@revenge-mod/discord/ui/settings'
const ReactDevToolsConnectSetting: SettingsItem = {
parent: MobileSetting.REVENGE_DEVELOPER,
IconComponent: () => <AssetIcon name="LinkIcon" />,
title: () => 'Connect to React DevTools',
usePredicate: () => {
const dtOpen = useIsDevToolsOpen()
return DevToolsContext.available && !dtOpen
},
onPress: connectToDevTools,
type: 'pressable',
}
export default ReactDevToolsConnectSetting

View file

@ -0,0 +1,19 @@
import { React } from '@revenge-mod/react'
import AssetIcon from '../components/icons/AssetIcon'
import { disconnectFromDevTools, useIsDevToolsOpen } from '../devtools'
import { MobileSetting } from '../SettingsConstants'
import type { SettingsItem } from '@revenge-mod/discord/ui/settings'
const ReactDevToolsDisconnectSetting: SettingsItem = {
parent: MobileSetting.REVENGE_DEVELOPER,
IconComponent: () => <AssetIcon name="DenyIcon" variant="danger" />,
variant: 'danger',
title: () => 'Disconnect from React DevTools',
usePredicate: useIsDevToolsOpen,
onPress: disconnectFromDevTools,
type: 'pressable',
}
export default ReactDevToolsDisconnectSetting

View file

@ -0,0 +1,18 @@
import { React } from '@revenge-mod/react'
import AssetIcon from '../components/icons/AssetIcon'
import { MobileSetting } from '../SettingsConstants'
import type { SettingsItem } from '@revenge-mod/discord/ui/settings'
import { DevToolsContext } from '../devtools'
const ReactDevToolsVersionSetting: SettingsItem = {
parent: MobileSetting.REVENGE_DEVELOPER,
IconComponent: () => <AssetIcon name="ScienceIcon" />,
title: () => 'React DevTools',
useDescription: () => __REACT_DEVTOOLS__!.version,
usePredicate: () => DevToolsContext.available,
type: 'static',
}
export default ReactDevToolsVersionSetting

View file

@ -0,0 +1,84 @@
import { getAssetByName } from '@revenge-mod/assets'
import { ToastActionCreators } from '@revenge-mod/discord/actions'
import { React } from '@revenge-mod/react'
import { getErrorStack } from '@revenge-mod/utils/errors'
import { useReRender } from '@revenge-mod/utils/react'
import { pluginApi } from '.'
export const DevToolsContext: {
ws: WebSocket | undefined
address: string
open: boolean
available: boolean
} = {
available: Boolean(globalThis.__REACT_DEVTOOLS__),
ws: undefined,
address: 'localhost:8097',
open: false,
}
type Subscription = (event: 1 | 2 | 3, err?: unknown) => void
const subscriptions: Set<Subscription> = new Set()
subscriptions.add((e, err) => {
if (e === 3) {
const actualError = (err as { message: string }).message ?? getErrorStack(err)
pluginApi.logger.error('React DevTools error:', actualError)
ToastActionCreators.open({
key: 'DEVTOOLS_ERROR',
icon: getAssetByName('CircleXIcon-primary')!.id,
content: actualError,
})
}
})
export function connectToDevTools() {
if (!DevToolsContext.available || DevToolsContext.ws) return
const websocket = (DevToolsContext.ws = new WebSocket(`ws://${DevToolsContext.address}`))
websocket.addEventListener('open', () => {
DevToolsContext.open = true
for (const sub of subscriptions) sub(1)
})
websocket.addEventListener('close', () => {
cleanup()
for (const sub of subscriptions) sub(2)
})
websocket.addEventListener('error', e => {
cleanup()
for (const sub of subscriptions) sub(3, e)
})
__REACT_DEVTOOLS__!.exports.connectToDevTools({
websocket,
})
}
function cleanup() {
DevToolsContext.open = false
DevToolsContext.ws = undefined
}
export function disconnectFromDevTools() {
if (DevToolsContext.ws) DevToolsContext.ws.close()
}
export function useIsDevToolsOpen() {
const rerender = useReRender()
React.useEffect(() => {
const sub: Subscription = e => (e === 1 || e === 2) && rerender()
subscriptions.add(sub)
return () => {
subscriptions.delete(sub)
}
}, [])
return DevToolsContext.open
}

View file

@ -13,6 +13,9 @@ import RevengeVersionSetting from './definitions/RevengeVersionSetting'
import HermesVersionSetting from './definitions/HermesVersionSetting'
import RevengeNotImplementedSetting from './definitions/RevengeNotImplementedSetting'
import EvaluateJavaScriptSetting from './definitions/EvaluateJavaScriptSetting'
import ReactDevToolsVersionSetting from './definitions/ReactDevToolsVersionSetting'
import ReactDevToolsConnectSetting from './definitions/ReactDevToolsConnectSetting'
import ReactDevToolsDisconnectSetting from './definitions/ReactDevToolsDisconnectSetting'
import ReloadAppSetting from './definitions/ReloadAppSetting'
import RevengeDiscordServerSetting from './definitions/RevengeDiscordServerSetting'
import RevengeGitHubOrganizationSetting from './definitions/RevengeGitHubOrganizationSetting'
@ -30,6 +33,9 @@ registerSettingsItems({
[MobileSetting.REACT_VERSION]: ReactVersionSetting,
[MobileSetting.REACT_NATIVE_VERSION]: ReactNativeVersionSetting,
[MobileSetting.HERMES_VERSION]: HermesVersionSetting,
[MobileSetting.REACT_DEVTOOLS_VERSION]: ReactDevToolsVersionSetting,
[MobileSetting.REACT_DEVTOOLS_CONNECT]: ReactDevToolsConnectSetting,
[MobileSetting.REACT_DEVTOOLS_DISCONNECT]: ReactDevToolsDisconnectSetting,
[MobileSetting.EVALUATE_JAVASCRIPT]: EvaluateJavaScriptSetting,
[MobileSetting.REVENGE_NOT_IMPLEMENTED]: RevengeNotImplementedSetting,
})

View file

@ -1,20 +1,49 @@
import { React, ReactNative } from '@revenge-mod/react'
import { Components } from '@revenge-mod/discord/ui'
import { SettingListRenderer } from '@revenge-mod/discord/ui/settings'
import { MobileSetting } from '../SettingsConstants'
import { DevToolsContext } from '../devtools'
// TODO(plugins/settings): debug bridge
export default function RevengeDeveloperSettingScreen() {
return (
<SettingListRenderer.SettingsList
sections={[
{
label: 'React DevTools',
settings: [MobileSetting.REVENGE_NOT_IMPLEMENTED],
},
{
label: 'Tools',
settings: [MobileSetting.EVALUATE_JAVASCRIPT],
},
]}
/>
<Components.Stack spacing={0} style={{ flexGrow: 1 }}>
{globalThis.__REACT_DEVTOOLS__ && <ReactDevToolsAddressSetting />}
<SettingListRenderer.SettingsList
sections={[
{
settings: [],
},
{
settings: [
MobileSetting.REACT_DEVTOOLS_VERSION,
MobileSetting.REACT_DEVTOOLS_CONNECT,
MobileSetting.REACT_DEVTOOLS_DISCONNECT,
],
},
{
label: 'Tools',
settings: [MobileSetting.EVALUATE_JAVASCRIPT],
},
]}
/>
</Components.Stack>
)
}
export function ReactDevToolsAddressSetting() {
return (
<ReactNative.View style={{ paddingHorizontal: 16, paddingTop: 16 }}>
<Components.TextInput
editable={!DevToolsContext.open}
isDisabled={DevToolsContext.open}
leadingText="ws://"
defaultValue={DevToolsContext.address}
label="React DevTools"
onChange={text => (DevToolsContext.address = text)}
returnKeyType="done"
/>
</ReactNative.View>
)
}