diff --git a/bun.lockb b/bun.lockb index 3c12cc6..17cabe7 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/scripts/debugger.mjs b/scripts/debugger.mjs index 78e51b5..72c362e 100644 --- a/scripts/debugger.mjs +++ b/scripts/debugger.mjs @@ -1,11 +1,12 @@ #!/usr/bin/env node import * as repl from 'node:repl' import { WebSocketServer } from 'ws' +import os from 'os' import chalk from 'chalk' import clipboardy from 'clipboardy' -import { join, resolve } from 'node:path' -import { existsSync } from 'node:fs' -import { mkdir, writeFile } from 'node:fs/promises' +import { join, resolve } from 'path' +import { existsSync } from 'fs' +import { mkdir, writeFile } from 'fs/promises' const debuggerHistoryPath = resolve(join('node_modules', 'debugger')) @@ -32,12 +33,12 @@ const logAsClient = message => console.info(clientColorify(null, message)) const logAsClientWarn = message => console.warn(clientColorify('warn', message)) const logAsClientError = message => console.error(clientColorify('error', message)) -const copyPrompt = ' --copy' -const clearHistoryPrompt = '--ch' +const copyPrompt = '--copy' +const clearHistoryPrompt = '--clear' export function serve() { let websocketOpen = false - let awaitingReply + let nextReply const wss = new WebSocketServer({ port: 9090, @@ -46,25 +47,25 @@ export function serve() { if (websocketOpen) return websocketOpen = true - logAsDebugger('Starting debugger session') + logAsDebugger('Client connected') ws.on('message', data => { try { /** @type {{ level: "info" | "warn" | "error", message: string, nonce?: string }} */ const json = JSON.parse(data.toString()) - if (awaitingReply?.cb && awaitingReply?.nonce && awaitingReply.nonce === json.nonce) { - if (json.level === 'info' && awaitingReply.toCopy) { + if (nextReply?.cb && nextReply?.nonce && nextReply.nonce === json.nonce) { + if (json.level === 'info' && nextReply.toCopy) { clipboardy.write(json.message) - awaitingReply.cb(null, debuggerColorify('Copied result to clipboard')) + nextReply.cb(null, debuggerColorify('Copied result to clipboard')) } else - awaitingReply.cb( + nextReply.cb( null, json.level === 'error' ? clientColorify('error', json.message) : clientColorify(null, json.message), ) - awaitingReply = null + nextReply = null isPrompting = true } else { if (json.level === 'error') logAsClientError(json.message) @@ -88,11 +89,11 @@ export function serve() { const code = input.trim() if (code === clearHistoryPrompt) { writeFile(join(debuggerHistoryPath, 'history.txt'), '') - logAsDebugger('Cleared repl history') + logAsDebugger('Cleared REPL history') return cb() } - awaitingReply = { + nextReply = { nonce: crypto.randomUUID(), cb, toCopy: code.endsWith(copyPrompt), @@ -100,7 +101,7 @@ export function serve() { ws.send( JSON.stringify({ code: code.endsWith(copyPrompt) ? code.slice(0, -copyPrompt.length) : code, - nonce: awaitingReply.nonce, + nonce: nextReply.nonce, }), ) } catch (e) { @@ -116,19 +117,27 @@ export function serve() { rl.on('close', () => { isPrompting = false ws.close() - logAsDebugger('Closing debugger, press Ctrl+C to exit') }) ws.on('close', () => { - logAsDebugger('Websocket was closed') + logAsDebugger('Client disconnected') rl.close() websocketOpen = false }) }) - logAsDebugger('Debugger ready at :9090') - logAsDebugger(`Add${chalk.bold(copyPrompt)} to your prompt to copy the result to clipboard`) - logAsDebugger(`Type ${chalk.bold(clearHistoryPrompt)} to clear your repl history `) + console.info(chalk.red('\nDebugger ready, available on:\n')) + const netInterfaces = os.networkInterfaces() + for (const netinterfaces of Object.values(netInterfaces)) { + for (const details of netinterfaces || []) { + if (details.family !== 'IPv4') continue + const port = chalk.yellowBright(wss.address()?.port.toString()) + console.info(` ${chalk.gray('http://')}${details.address}:${port}`) + } + } + + console.log(chalk.gray.underline(`\nRun with ${chalk.bold.white(copyPrompt)} to your prompt to copy the result to clipboard`)) + console.log(chalk.gray.underline(`Run with ${chalk.bold.white(clearHistoryPrompt)} to clear your REPL history\n`)) return wss } diff --git a/src/plugins/debugger/index.tsx b/src/plugins/debugger/index.tsx deleted file mode 100644 index 6291bb0..0000000 --- a/src/plugins/debugger/index.tsx +++ /dev/null @@ -1,115 +0,0 @@ -import { getAssetIndexByName } from '@revenge-mod/assets' -import type { PluginContextFor } from '@revenge-mod/plugins' -import { sleep } from '@revenge-mod/utils/functions' -import { registerPlugin } from 'libraries/plugins/src/internals' -import DebuggerSettingsPage from './pages/Debugger' -import { connectToDebugger, DebuggerContext } from './debugger' -import { BundleUpdaterManager } from '@revenge-mod/modules/native' - -const plugin = registerPlugin<{ - connectOnStartup: boolean - debuggerUrl: string -}>( - { - name: 'Debugger', - author: 'Revenge', - description: 'A simple WebSocket debugger for Revenge to make development easier', - id: 'revenge.debugger', - version: '1.0.0', - icon: 'LinkIcon', - async afterAppRender(context) { - const { - revenge: { - ui: { settings: sui }, - }, - patcher, - cleanup, - storage: { connectOnStartup, debuggerUrl }, - } = context - - if (connectOnStartup) connectToDebugger(debuggerUrl, context) - - // Wait for the section to be added by the Settings plugin - await sleep(0) - - // biome-ignore lint/suspicious/noExplicitAny: globalThis can be anything - const win = globalThis as any - - const doCleanup = new Set<() => void>() - - cleanup( - sui.addRowsToSection('Revenge', { - Debugger: { - type: 'route', - label: 'Debugger', - icon: getAssetIndexByName('LinkIcon'), - component: () => ( - - - - ), - }, - }), - - (() => { - win.debgr = { - reload: () => BundleUpdaterManager.reload(), - patcher: { - // biome-ignore lint/suspicious/noExplicitAny: These arguments can be anything lol - snipe: (object: any, key: any, callback?: (args: unknown) => void) => { - doCleanup.add( - patcher.after( - object, - key, - callback ?? ((args, ret) => console.log('[SNIPER]', args, ret)), - 'debgr.patcher.snipe', - ), - ) - }, - // biome-ignore lint/suspicious/noExplicitAny: These arguments can be anything lol 2 - noop: (object: any, key: any) => { - doCleanup.add(patcher.instead(object, key, () => void 0, 'debgr.patcher.noop')) - }, - wipe: () => { - for (const c of doCleanup) c() - doCleanup.clear() - }, - }, - } - - return () => (win.debgr = undefined) - })(), - - () => { - for (const c of doCleanup) c() - }, - - patcher.before( - win, - 'nativeLoggingHook', - ([message, level]) => { - if (DebuggerContext.ws?.readyState === WebSocket.OPEN) - DebuggerContext.ws.send( - JSON.stringify({ - level: level === 3 ? 'error' : level === 2 ? 'warn' : 'info', - message, - }), - ) - }, - 'loggerPatch', - ), - ) - }, - initializeStorage() { - return { - connectOnStartup: false, - debuggerUrl: 'localhost:9090', - } - }, - }, - true, - true, -) - -export type DebuggerContextType = PluginContextFor -export const PluginContext = React.createContext(null!) diff --git a/src/plugins/debugger/pages/Debugger.tsx b/src/plugins/debugger/pages/Debugger.tsx deleted file mode 100644 index 8a00a9b..0000000 --- a/src/plugins/debugger/pages/Debugger.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { useContext, useEffect, useRef, useState } from 'react' -import { PluginContext } from '..' -import { useObservable } from '@revenge-mod/storage' -import { ScrollView } from 'react-native' -import PageWrapper from 'src/plugins/settings/pages/(Wrapper)' -import { - Stack, - TableRow, - TableRowGroup, - TableRowIcon, - TableSwitchRow, - TextInput, -} from '@revenge-mod/modules/common/components' -import { toasts } from '@revenge-mod/modules/common' -import { - connectToDebugger, - DebuggerContext, - DebuggerEvents, - disconnectFromDebugger, - type DebuggerEventsListeners, -} from '../debugger' - -export default function DebuggerSettingsPage() { - const context = useContext(PluginContext) - const { - storage, - revenge: { assets }, - } = context - useObservable([storage]) - - const tempDebuggerUrl = useRef(storage.debuggerUrl || 'localhost:9090') - const [connected, setConnected] = useState(DebuggerContext.connected) - - useEffect(() => { - const listener: DebuggerEventsListeners['*'] = evt => { - if (evt === 'connect') setConnected(true) - else setConnected(false) - } - - DebuggerEvents.on('*', listener) - - return () => void DebuggerEvents.off('*', listener) - }, []) - - return ( - - - - (tempDebuggerUrl.current = text)} - onBlur={() => { - if (tempDebuggerUrl.current === storage.debuggerUrl) return - storage.debuggerUrl = tempDebuggerUrl.current - - toasts.open({ - key: 'revenge.debugger.savedurl', - content: 'Saved debugger URL!', - }) - }} - returnKeyType="done" - /> - {/* Rerender when connected changes */} - - {connected ? ( - } - onPress={() => disconnectFromDebugger()} - /> - ) : ( - } - onPress={() => connectToDebugger(storage.debuggerUrl, context)} - /> - )} - } - value={storage.connectOnStartup} - onValueChange={v => (storage.connectOnStartup = v)} - /> - - - - - ) -} diff --git a/src/plugins/developer-settings/debugger.d.ts b/src/plugins/developer-settings/debugger.d.ts new file mode 100644 index 0000000..a3ea97d --- /dev/null +++ b/src/plugins/developer-settings/debugger.d.ts @@ -0,0 +1,14 @@ +declare global { + var dbgr: { + reload(): void + patcher: { + // biome-ignore lint/suspicious/noExplicitAny: The object can be anything + snipe(object: any, key: string, callback?: (args: unknown) => void): void + // biome-ignore lint/suspicious/noExplicitAny: The object can be anything + noop(object: any, key: string): void + wipe(): void + } + } | undefined +} + +export {} \ No newline at end of file diff --git a/src/plugins/debugger/debugger.ts b/src/plugins/developer-settings/debugger.ts similarity index 60% rename from src/plugins/debugger/debugger.ts rename to src/plugins/developer-settings/debugger.ts index 802c9a7..734d117 100644 --- a/src/plugins/debugger/debugger.ts +++ b/src/plugins/developer-settings/debugger.ts @@ -1,116 +1,96 @@ -import { toasts } from '@revenge-mod/modules/common' -import { EventEmitter } from 'events' -import type { DebuggerContextType } from '.' - -export const DebuggerEvents = new EventEmitter() - -export type DebuggerEventsListeners = { - connect: () => void - disconnect: () => void - // biome-ignore lint/suspicious/noExplicitAny: Anything can be thrown - error: (err: any) => void - // biome-ignore lint/suspicious/noExplicitAny: Anything can be thrown - '*': (event: keyof DebuggerEventsListeners, err?: any) => void -} - -export const DebuggerContext = { - ws: undefined, - connected: false, -} as { - ws: WebSocket | undefined - connected: boolean -} - -export function disconnectFromDebugger() { - DebuggerContext.ws!.close() - DebuggerContext.connected = false -} - -export function connectToDebugger(addr: string, context: DebuggerContextType) { - const ws = (DebuggerContext.ws = new WebSocket(`ws://${addr}`)) - - ws.addEventListener('open', () => { - DebuggerContext.connected = true - DebuggerEvents.emit('connect') - DebuggerEvents.emit('*', 'connect') - - toasts.open({ - key: 'revenge.debugger.connected', - content: 'Connected to debugger!', - }) - }) - - ws.addEventListener('close', () => { - DebuggerContext.connected = false - DebuggerEvents.emit('disconnect') - DebuggerEvents.emit('*', 'disconnect') - - toasts.open({ - key: 'revenge.debugger.disconnected', - content: 'Disconnected from debugger!', - }) - }) - - ws.addEventListener('error', e => { - DebuggerContext.connected = false - DebuggerEvents.emit('error', e) - DebuggerEvents.emit('*', 'error', e) - - toasts.open({ - key: 'revenge.debugger.errored', - content: 'Debugger errored!', - }) - }) - - ws.addEventListener('message', e => { - try { - const json = JSON.parse(e.data) as { - code: string - nonce: string - } - - if (typeof json.code === 'string' && typeof json.nonce === 'string') { - let res: unknown - try { - // biome-ignore lint/security/noGlobalEval: This is intentional - res = globalThis.eval(json.code) - } catch (e) { - res = e - } - - const inspect = - context.revenge.modules.findProp< - (val: unknown, opts?: { depth?: number; showHidden?: boolean; color?: boolean }) => string - >('inspect')! - - try { - if (res instanceof Error) - ws.send( - JSON.stringify({ - level: 'error', - message: String(res), - nonce: json.nonce, - }), - ) - else { - ws.send( - JSON.stringify({ - level: 'info', - message: inspect(res, { showHidden: true }), - nonce: json.nonce, - }), - ) - } - } catch (e) { - ws.send( - JSON.stringify({ - level: 'error', - message: `DebuggerInternalError: ${String(e)}`, - nonce: json.nonce, - }), - ) - } - } - } catch {} - }) -} +import { EventEmitter } from 'events' +import type { RevengeLibrary } from '@revenge-mod/revenge' + +export const DebuggerEvents = new EventEmitter() + +export type DebuggerEventsListeners = { + connect: () => void + disconnect: () => void + // biome-ignore lint/suspicious/noExplicitAny: Anything can be thrown + error: (err: any) => void + // biome-ignore lint/suspicious/noExplicitAny: Anything can be thrown + '*': (event: keyof DebuggerEventsListeners, err?: any) => void +} + +export const DebuggerContext = { + ws: undefined, + connected: false, +} as { + ws: WebSocket | undefined + connected: boolean +} + +export function disconnectFromDebugger() { + DebuggerContext.ws!.close() + DebuggerContext.connected = false +} + +export function connectToDebugger(addr: string, revenge: RevengeLibrary) { + const ws = (DebuggerContext.ws = new WebSocket(`ws://${addr}`)) + + ws.addEventListener('open', () => { + DebuggerContext.connected = true + DebuggerEvents.emit('connect') + DebuggerEvents.emit('*', 'connect') + }) + + ws.addEventListener('close', () => { + DebuggerContext.connected = false + DebuggerEvents.emit('disconnect') + DebuggerEvents.emit('*', 'disconnect') + }) + + ws.addEventListener('error', e => { + DebuggerContext.connected = false + DebuggerEvents.emit('error', e) + DebuggerEvents.emit('*', 'error', e) + }) + + ws.addEventListener('message', e => { + try { + const json = JSON.parse(e.data) as { + code: string + nonce: string + } + + if (typeof json.code === 'string' && typeof json.nonce === 'string') { + let res: unknown + try { + // biome-ignore lint/security/noGlobalEval: This is intentional + res = globalThis.eval(json.code) + } catch (e) { + res = e + } + + const inspect = + revenge.modules.findProp< + (val: unknown, opts?: { depth?: number; showHidden?: boolean; color?: boolean }) => string + >('inspect')! + + try { + ws.send( + res instanceof Error + ? JSON.stringify({ + level: 'error', + message: String(res), + nonce: json.nonce, + }) + : JSON.stringify({ + level: 'info', + message: inspect(res, { showHidden: true }), + nonce: json.nonce, + }), + ) + } catch (e) { + ws.send( + JSON.stringify({ + level: 'error', + message: `DebuggerError: ${String(e)}`, + nonce: json.nonce, + }), + ) + } + } + } catch {} + }) +} diff --git a/src/plugins/developer-settings/index.tsx b/src/plugins/developer-settings/index.tsx index c3d3393..bdbf8a1 100644 --- a/src/plugins/developer-settings/index.tsx +++ b/src/plugins/developer-settings/index.tsx @@ -1,3 +1,5 @@ +/// + import { toasts } from '@revenge-mod/modules/common' import { registerPlugin } from '@revenge-mod/plugins/internals' import { sleep } from '@revenge-mod/utils/functions' @@ -6,16 +8,22 @@ import AssetBrowserSettingsPage from './pages/AssetBrowser' import DebugPerformanceTimesSettingsPage from './pages/DebugPerformanceTimes' import DeveloperSettingsPage from './pages/Developer' +import { connectToDebugger, DebuggerContext } from './debugger' import { DevToolsEvents, connectToDevTools } from './devtools' import type { PluginContextFor } from '@revenge-mod/plugins' import type { FunctionComponent } from 'react' +import { BundleUpdaterManager } from '@revenge-mod/modules/native' const plugin = registerPlugin<{ reactDevTools: { address: string autoConnect: boolean } + debugger: { + address: string + autoConnect: boolean + } }>( { name: 'Developer Settings', @@ -58,6 +66,9 @@ const plugin = registerPlugin<{ if (storage.reactDevTools.autoConnect && globalThis.__reactDevTools) connectToDevTools(storage.reactDevTools.address) + if (storage.debugger.autoConnect) connectToDebugger(storage.debugger.address, context.revenge) + + setupDebugger(context) // Wait for the section to be added by the Settings plugin await sleep(0) @@ -92,10 +103,62 @@ const plugin = registerPlugin<{ address: 'localhost:8097', autoConnect: false, }, + debugger: { + address: 'localhost:9090', + autoConnect: false, + }, }), }, true, true, ) +function setupDebugger({ patcher, cleanup }: PluginContextFor) { + const debuggerCleanups = new Set<() => unknown>() + + patcher.before( + globalThis, + 'nativeLoggingHook', + ([message, level]) => { + if (DebuggerContext.ws?.readyState === WebSocket.OPEN) + DebuggerContext.ws.send( + JSON.stringify({ + level: level === 3 ? 'error' : level === 2 ? 'warn' : 'info', + message, + }), + ) + }, + 'loggerPatch', + ) + + globalThis.dbgr = { + reload: () => BundleUpdaterManager.reload(), + patcher: { + snipe: (object, key, callback) => + debuggerCleanups.add( + patcher.after( + object, + key, + callback ?? ((args, ret) => console.log('[SNIPER]', args, ret)), + 'revenge.plugins.developer-settings.debugger.patcher.snipe', + ), + ), + noop: (object, key) => + debuggerCleanups.add(patcher.instead(object, key, () => void 0, 'revenge.plugins.developer-settings.debugger.patcher.noop')), + wipe: () => { + for (const c of debuggerCleanups) c() + debuggerCleanups.clear() + }, + }, + } + + cleanup( + // biome-ignore lint/performance/noDelete: This happens once + () => delete globalThis.dbgr, + () => { + for (const c of debuggerCleanups) c() + }, + ) +} + export const PluginContext = React.createContext>(null!) diff --git a/src/plugins/developer-settings/pages/Developer.tsx b/src/plugins/developer-settings/pages/Developer.tsx index 8ba3a7a..c15fc90 100644 --- a/src/plugins/developer-settings/pages/Developer.tsx +++ b/src/plugins/developer-settings/pages/Developer.tsx @@ -22,6 +22,13 @@ import { connectToDevTools, disconnectFromDevTools, } from '../devtools' +import { + connectToDebugger, + DebuggerContext, + DebuggerEvents, + disconnectFromDebugger, + type DebuggerEventsListeners, +} from '../debugger' import { settings } from '@revenge-mod/preferences' import { ScrollView } from 'react-native' @@ -41,12 +48,15 @@ export default function DeveloperSettingsPage() { const navigation = NavigationNative.useNavigation() const refDevToolsAddr = useRef(storage.reactDevTools.address || 'localhost:8097') - const [connected, setConnected] = useState(DevToolsContext.connected) + const [rdtConnected, setRdtConnected] = useState(DevToolsContext.connected) + + const refDebuggerAddr = useRef(storage.debugger.address || 'localhost:9090') + const [dbgConnected, setDbgConnected] = useState(DebuggerContext.connected) useEffect(() => { const listener: DevToolsEventsListeners['*'] = evt => { - if (evt === 'connect') setConnected(true) - else setConnected(false) + if (evt === 'connect') setRdtConnected(true) + else setRdtConnected(false) } DevToolsEvents.on('*', listener) @@ -54,60 +64,115 @@ export default function DeveloperSettingsPage() { return () => void DevToolsEvents.off('*', listener) }, []) + useEffect(() => { + const listener: DebuggerEventsListeners['*'] = evt => { + if (evt === 'connect') setDbgConnected(true) + else setDbgConnected(false) + } + + DebuggerEvents.on('*', listener) + + return () => void DebuggerEvents.off('*', listener) + }, []) + return ( - {typeof __reactDevTools !== 'undefined' && ( - - (refDevToolsAddr.current = text)} - onBlur={() => { - if (refDevToolsAddr.current === storage.reactDevTools.address) return - storage.reactDevTools.address = refDevToolsAddr.current - - toasts.open({ - key: 'revenge.plugins.settings.react-devtools.saved', - content: 'Saved DevTools address!', - }) - }} - returnKeyType="done" - /> - {/* Rerender when connected changes */} - - {connected ? ( - - } - onPress={() => disconnectFromDevTools()} - /> - ) : ( - + {typeof __reactDevTools !== 'undefined' && ( + <> + (refDevToolsAddr.current = text)} + onBlur={() => { + if (refDevToolsAddr.current === storage.reactDevTools.address) return + storage.reactDevTools.address = refDevToolsAddr.current + + toasts.open({ + key: 'revenge.plugins.settings.react-devtools.saved', + content: 'Saved DevTools address!', + }) + }} + returnKeyType="done" + /> + {/* Rerender when connected changes */} + + {rdtConnected ? ( + + } + onPress={() => disconnectFromDevTools()} + /> + ) : ( + } + onPress={() => connectToDevTools(refDevToolsAddr.current)} + /> + )} + } - onPress={() => connectToDevTools(refDevToolsAddr.current)} + value={storage.reactDevTools.autoConnect} + onValueChange={v => (storage.reactDevTools.autoConnect = v)} /> - )} - } - value={storage.reactDevTools.autoConnect} - onValueChange={v => (storage.reactDevTools.autoConnect = v)} + + + )} + (refDebuggerAddr.current = text)} + onBlur={() => { + if (refDebuggerAddr.current === storage.debugger.address) return + storage.debugger.address = refDebuggerAddr.current + + toasts.open({ + key: 'revenge.plugins.developer-settings.debugger.saved', + content: 'Saved debugger address!', + }) + }} + returnKeyType="done" + /> + {/* Rerender when connected changes */} + + {dbgConnected ? ( + } + onPress={() => disconnectFromDebugger()} /> - - - )} + ) : ( + } + onPress={() => connectToDebugger(storage.debugger.address, context.revenge)} + /> + )} + } + value={storage.debugger.autoConnect} + onValueChange={v => (storage.debugger.autoConnect = v)} + /> + + unknown, timeout?: number): number /** * Calls the garbage collector