diff --git a/bun.lockb b/bun.lockb index 478a04e..17cabe7 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/libraries/modules/src/types.d.ts b/libraries/modules/src/types.d.ts index 98388d6..9bd235e 100644 --- a/libraries/modules/src/types.d.ts +++ b/libraries/modules/src/types.d.ts @@ -599,7 +599,17 @@ export namespace DiscordModules { } // Other - export type Slider = FC + export type Slider = FC<{ + value: number + step: number + minimumValue: number + maximumValue: number + onValueChange?: (value: number) => void + onSlidingStart?: () => void + onSlidingComplete?: () => void + startIcon?: ReactNode + endIcon?: ReactNode + }> export type FlashList = FC export type Text = FC< TextProps & { diff --git a/package.json b/package.json index 2d43896..767e0d4 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,8 @@ "license": "BSD-3-Clause", "scripts": { "serve": "bun run ./scripts/serve.mjs", - "build": "bun run ./scripts/build.mjs" + "build": "bun run ./scripts/build.mjs", + "debugger": "node ./scripts/debugger.mjs" }, "dependencies": { "@revenge-mod/app": "workspace:*", @@ -26,10 +27,12 @@ "@swc/helpers": "^0.5.15", "@tsconfig/strictest": "^2.0.5", "chalk": "^5.3.0", + "clipboardy": "^4.0.0", "esbuild": "^0.24.0", "esbuild-plugin-globals": "^0.2.0", "react-native": "^0.76.3", "typescript": "^5.7.2", + "ws": "^8.18.0", "yargs-parser": "^21.1.1" }, "workspaces": ["libraries/*"], diff --git a/scripts/debugger.mjs b/scripts/debugger.mjs new file mode 100644 index 0000000..72c362e --- /dev/null +++ b/scripts/debugger.mjs @@ -0,0 +1,147 @@ +#!/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 'path' +import { existsSync } from 'fs' +import { mkdir, writeFile } from 'fs/promises' + +const debuggerHistoryPath = resolve(join('node_modules', 'debugger')) + +if ('Bun' in globalThis) + throw new Error( + `Bun is unsupported due to it lacking compatibility with node:repl. Please run "node ./scripts/debugger.mjs" or "bun debugger"`, + ) + +let isPrompting = false + +const debuggerColorify = message => (isPrompting ? '\n' : '') + chalk.bold.blue('[Debugger] ') + message + +const clientColorify = (style, message) => + (isPrompting ? '\n' : '') + + (style === 'error' + ? chalk.bold.red('[Revenge] ERR! ') + chalk.red(message) + : style === 'warn' + ? chalk.bold.yellow('[Revenge] ') + chalk.yellow(message) + : chalk.bold.green('[Revenge] ') + message) + +const logAsDebugger = message => console.info(debuggerColorify(message)) + +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 = '--clear' + +export function serve() { + let websocketOpen = false + let nextReply + + const wss = new WebSocketServer({ + port: 9090, + }) + wss.on('connection', ws => { + if (websocketOpen) return + websocketOpen = true + + logAsDebugger('Client connected') + + ws.on('message', data => { + try { + /** @type {{ level: "info" | "warn" | "error", message: string, nonce?: string }} */ + const json = JSON.parse(data.toString()) + + if (nextReply?.cb && nextReply?.nonce && nextReply.nonce === json.nonce) { + if (json.level === 'info' && nextReply.toCopy) { + clipboardy.write(json.message) + nextReply.cb(null, debuggerColorify('Copied result to clipboard')) + } else + nextReply.cb( + null, + json.level === 'error' + ? clientColorify('error', json.message) + : clientColorify(null, json.message), + ) + nextReply = null + isPrompting = true + } else { + if (json.level === 'error') logAsClientError(json.message) + else if (json.level === 'warn') logAsClientWarn(json.message) + else logAsClient(json.message) + + if (isPrompting) rl.displayPrompt(true) + } + } catch {} + }) + + isPrompting = true + const rl = repl.start({ + eval(input, _, __, cb) { + if (!isPrompting) return + if (!input.trim()) return cb() + + try { + isPrompting = false + + const code = input.trim() + if (code === clearHistoryPrompt) { + writeFile(join(debuggerHistoryPath, 'history.txt'), '') + logAsDebugger('Cleared REPL history') + return cb() + } + + nextReply = { + nonce: crypto.randomUUID(), + cb, + toCopy: code.endsWith(copyPrompt), + } + ws.send( + JSON.stringify({ + code: code.endsWith(copyPrompt) ? code.slice(0, -copyPrompt.length) : code, + nonce: nextReply.nonce, + }), + ) + } catch (e) { + cb(e) + } + }, + writer(msg) { + return msg + }, + }) + rl.setupHistory(join(debuggerHistoryPath, 'history.txt'), () => void 0) + + rl.on('close', () => { + isPrompting = false + ws.close() + }) + + ws.on('close', () => { + logAsDebugger('Client disconnected') + rl.close() + websocketOpen = false + }) + }) + + 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 +} + +if (!existsSync(debuggerHistoryPath)) await mkdir(debuggerHistoryPath, { recursive: true }) + +serve() 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/developer-settings/debugger.ts b/src/plugins/developer-settings/debugger.ts new file mode 100644 index 0000000..734d117 --- /dev/null +++ b/src/plugins/developer-settings/debugger.ts @@ -0,0 +1,96 @@ +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