diff --git a/bun.lock b/bun.lock index 14ccc22..24cac0d 100644 --- a/bun.lock +++ b/bun.lock @@ -8,7 +8,7 @@ "@parcel/watcher": "^2.5.1", "@react-native-clipboard/clipboard": "^1.16.2", "@swc/core": "^1.11.31", - "@types/bun": "latest", + "@types/bun": "^1.2.15", "@types/node": "^22.15.30", "@unbound-mod/hermesc": "^1.0.3", "chalk": "^5.4.1", diff --git a/lib/patcher/README.md b/lib/patcher/README.md index 1154940..c6c8658 100644 --- a/lib/patcher/README.md +++ b/lib/patcher/README.md @@ -4,4 +4,268 @@ This is a JavaScript library that allows to monkey patch functions in a way that It is made by [ryan-0324](https://github.com/ryan-0324) [(source uploaded on Discord)](https://discord.com/channels/1205207689832038522/1284131216156655646/1316076586981789709) and licensed under [CC0](./LICENSE). -This source code is adapted to work with the Revenge Modification Project, and we do not claim any rights to this library. You may use it following the license. +This source code is polished and adapted to work with Revenge. We do not claim any rights to this library. You may use it granted you follow the license. + +## How does this work? + +Think of hooks as separate functions that are called `before`, `after`, or `instead` of the original function. +You can use them to modify the behavior of the original function, or to add new functionality. + +### Before hooks + +Before hooks allow you to modify the arguments passed to the original function, or to perform some action before the original function is called. + +```js +import { before } from '@revenge-mod/patcher' + +const obj = { + method: (a) => console.log('Original method called with:', a) +} + +before(obj, 'method', ([a]) => { + console.log('Before method called with:', a) + // Modify arguments by returning new array + return [a + 1] +}) + +obj.method(2) +// CONSOLE OUTPUT: +// Before method called with: 2 +// Original method called with: 3 +``` + +### After hooks + +After hooks allow you to modify the return value of the original function, or to perform some action after the original function is called. + +```js +import { after } from '@revenge-mod/patcher' + +const obj = { + method: (a) => a * 2 +} + +after(obj, 'method', (result) => { + console.log('After method called with result:', result) + // Modify return value + return result + 1 +}) + +console.log(obj.method(2)) // 5 +// CONSOLE OUTPUT: +// After method called with result: 4 +// 5 +``` + +### Instead hooks + +Instead hooks allow you to completely replace the original function with a new one, while still being able to call the original function if needed. + +```js +import { instead } from '@revenge-mod/patcher' + +const obj = { + method: (a) => { + console.log('Original method called with:', a) + return 'original result' + } +} + +instead(obj, 'method', ([a], original) => { + console.log('Instead method called with:', a) + // Call the original function if needed + const originalResult = original('modified') + console.log('Original method was called') + + // Return a new value + return 'new value' +}) + +console.log(obj.method('test')) // 'new value' +// CONSOLE OUTPUT: +// Instead method called with: test +// Original method called with: modified +// Original method was called +// new value +``` + +### Combining hooks + +You can combine multiple hooks on the same function. + +```js +import { before, after, instead } from '@revenge-mod/patcher' + +const obj = { + method: (a) => a * 2 +} + +before(obj, 'method', ([a]) => { + console.log('Before method called with:', a) + return [a + 1] // 2 becomes 3 +}) + +instead(obj, 'method', ([a], original) => { + console.log('Instead method called with:', a) + const result = original(a) // Call original with modified arg (3) + console.log('Original method returned:', result) + return result + 1 // 6 becomes 7 +}) + +after(obj, 'method', (result) => { + console.log('After method called with result:', result) + return result + 1 // 7 becomes 8 +}) + +console.log(obj.method(2)) // 8 +// CONSOLE OUTPUT: +// Before method called with: 2 +// Instead method called with: 3 +// Original method returned: 6 +// After method called with result: 7 +// 8 +``` + +### Stacking hooks + +Stacking multiple hooks of same type will execute them in a specific order: + +1. **Before hooks** (execute in reverse order - last added runs first) +2. **Instead hooks** (only the last added executes, unless calling original) +3. **After hooks** (execute in order - first added runs first) + +Using multiple `before` hooks will execute them in **reverse order** (LIFO - Last In, First Out): + +```js +import { before } from '@revenge-mod/patcher' + +const obj = { + method: (a) => console.log('Original method called with:', a) +} + +before(obj, 'method', ([a]) => { + console.log('First before hook called with:', a) + return [a + 1] +}) + +before(obj, 'method', ([a]) => { + console.log('Second before hook called with:', a) + return [a + 2] +}) + +obj.method(2) +// CONSOLE OUTPUT: +// Second before hook called with: 2 +// First before hook called with: 4 +// Original method called with: 5 +``` + +Multiple `after` hooks execute in **normal order** (FIFO - First In, First Out): + +```js +import { after } from '@revenge-mod/patcher' + +const obj = { + method: (a) => a * 2 +} + +after(obj, 'method', (result) => { + console.log('First after hook called with result:', result) + return result + 1 +}) + +after(obj, 'method', (result) => { + console.log('Second after hook called with result:', result) + return result + 2 +}) + +console.log(obj.method(2)) // 7 +// CONSOLE OUTPUT: +// First after hook called with result: 4 +// Second after hook called with result: 5 +// 7 +``` + +Multiple `instead` hooks will only execute the **last one added**. To chain them, you must call the original function: + +```js +import { instead } from '@revenge-mod/patcher' + +const obj = { + method: (a) => `original-${a}` +} + +instead(obj, 'method', ([a], original) => { + console.log('First instead hook called with:', a) + return `first-${original(a)}` +}) + +instead(obj, 'method', ([a], original) => { + console.log('Second instead hook called with:', a) + // Calling original will execute the previous instead hook + return `second-${original(a)}` +}) + +console.log(obj.method('test')) // 'second-first-original-test' +// CONSOLE OUTPUT: +// Second instead hook called with: test +// First instead hook called with: test +// second-first-original-test +``` + +### Removing hooks + +Each hook returns an unpatch function that removes the hook: + +```js +import { before } from '@revenge-mod/patcher' + +const obj = { + method: (a) => console.log('Original method called with:', a) +} + +const unpatch = before(obj, 'method', ([a]) => { + console.log('Before method called with:', a) + return [a + 1] +}) + +obj.method(2) +// CONSOLE OUTPUT: +// Before method called with: 2 +// Original method called with: 3 + +unpatch() // Remove the hook + +obj.method(2) +// CONSOLE OUTPUT: +// Original method called with: 2 +``` + +Unpatch functions are safe to call multiple times and won't throw errors. + +### Constructor patching + +You can also patch constructor functions: + +```js +import { before } from '@revenge-mod/patcher' + +class MyClass { + constructor(value) { + this.value = value + } +} + +const exports = { MyClass } + +before(exports, 'MyClass', ([value]) => { + console.log('Constructor called with:', value) + return [`modified-${value}`] +}) + +const instance = new exports.MyClass('test') +console.log(instance.value) // 'modified-test' +// CONSOLE OUTPUT: +// Constructor called with: test +// modified-test +``` diff --git a/lib/patcher/src/index.test.ts b/lib/patcher/src/index.test.ts new file mode 100644 index 0000000..5da6f69 --- /dev/null +++ b/lib/patcher/src/index.test.ts @@ -0,0 +1,472 @@ +import { beforeEach, describe, expect, mock, test } from 'bun:test' +import { after, before, instead } from '.' + +describe('Patcher', () => { + let originalMethod: (...args: any[]) => string + let obj: { method: typeof originalMethod } + + beforeEach(() => { + originalMethod = mock(s => s ?? 'original') + obj = { + method: originalMethod, + } + }) + + describe('before', () => { + test('should execute callback before original method', () => { + const beforeCallback = mock(args => args) + before(obj, 'method', beforeCallback) + + obj.method('arg1', 'arg2') + + expect(beforeCallback).toHaveBeenCalledWith(['arg1', 'arg2']) + expect(originalMethod).toHaveBeenCalled() + }) + + test('should allow modifying arguments', () => { + before(obj, 'method', args => { + args[0] = 'modified' + return args + }) + + obj.method('original') + + expect(originalMethod).toHaveBeenCalledWith('modified') + }) + + test('should unpatch correctly', () => { + const beforeCallback = mock(args => { + args[0] = 'modified' + return args + }) + const unpatch = before(obj, 'method', beforeCallback) + + // Test that patch is working + obj.method('original') + expect(originalMethod).toHaveBeenCalledWith('modified') + + // Unpatch + unpatch() + + // Test that patch is removed + obj.method('original') + expect(originalMethod).toHaveBeenCalledWith('original') + expect(beforeCallback).toHaveBeenCalledTimes(1) // Only called before unpatch + }) + + test('should handle multiple before hooks and unpatch correctly', () => { + const firstHook = mock(args => { + args[0] = 'first' + return args + }) + const secondHook = mock(args => { + args[0] = `${args[0]}-second` + return args + }) + + const unpatch2 = before(obj, 'method', secondHook) + const unpatch1 = before(obj, 'method', firstHook) + + obj.method('original') + expect(originalMethod).toHaveBeenCalledWith('first-second') + + // Unpatch second hook + unpatch2() + + obj.method('original') + expect(originalMethod).toHaveBeenCalledWith('first') + + // Unpatch first hook + unpatch1() + + obj.method('original') + expect(originalMethod).toHaveBeenCalledWith('original') + }) + + test('should not crash when unpatch is called multiple times', () => { + const beforeCallback = mock(args => args) + const unpatch = before(obj, 'method', beforeCallback) + + unpatch() + expect(() => unpatch()).not.toThrow() + expect(() => unpatch()).not.toThrow() + }) + }) + + describe('after', () => { + test('should execute callback after original method', () => { + const afterCallback = mock(() => 'original') + after(obj, 'method', afterCallback) + + const result = obj.method() + + expect(afterCallback).toHaveBeenCalledWith('original') + expect(result).toBe('original') + }) + + test('should allow modifying return value', () => { + after(obj, 'method', () => 'modified') + + const result = obj.method() + + expect(result).toBe('modified') + }) + + test('should unpatch correctly', () => { + const afterCallback = mock(() => 'modified') + const unpatch = after(obj, 'method', afterCallback) + + // Test that patch is working + let result = obj.method() + expect(result).toBe('modified') + expect(afterCallback).toHaveBeenCalledWith('original') + + // Unpatch + unpatch() + + // Test that patch is removed + result = obj.method() + expect(result).toBe('original') + expect(afterCallback).toHaveBeenCalledTimes(1) // Only called before unpatch + }) + + test('should handle multiple after hooks and unpatch correctly', () => { + const firstHook = mock(result => `${result}-first`) + const secondHook = mock(result => `${result}-second`) + + const unpatch1 = after(obj, 'method', firstHook) + const unpatch2 = after(obj, 'method', secondHook) + + let result = obj.method() + expect(result).toBe('original-second-first') + + // Unpatch second hook (most recent) + unpatch2() + result = obj.method() + expect(result).toBe('original-first') + + // Unpatch first hook + unpatch1() + result = obj.method() + expect(result).toBe('original') + }) + + test('should not crash when unpatch is called multiple times', () => { + const afterCallback = mock(result => result) + const unpatch = after(obj, 'method', afterCallback) + + unpatch() + expect(() => unpatch()).not.toThrow() + expect(() => unpatch()).not.toThrow() + }) + }) + + describe('instead', () => { + test('should replace original method', () => { + const replacement = mock(() => 'replaced') + instead(obj, 'method', replacement) + + const result = obj.method('arg1') + + expect(replacement).toHaveBeenCalledWith( + ['arg1'], + expect.any(Function), + ) + expect(originalMethod).not.toHaveBeenCalled() + expect(result).toBe('replaced') + }) + + test('should provide access to original method', () => { + instead(obj, 'method', (_args, original) => { + return `${original()} modified` + }) + + const result = obj.method() + + expect(result).toBe('original modified') + }) + + test('should unpatch correctly', () => { + const replacement = mock(() => 'replaced') + const unpatch = instead(obj, 'method', replacement) + + // Test that patch is working + let result = obj.method() + expect(result).toBe('replaced') + expect(originalMethod).not.toHaveBeenCalled() + + // Unpatch + unpatch() + + // Test that patch is removed + result = obj.method() + expect(result).toBe('original') + expect(originalMethod).toHaveBeenCalled() + expect(replacement).toHaveBeenCalledTimes(1) // Only called before unpatch + }) + + test('should handle multiple instead hooks and unpatch correctly', () => { + const firstHook = mock((_args, original) => `first-${original()}`) + const secondHook = mock((_args, original) => `second-${original()}`) + + const unpatch1 = instead(obj, 'method', firstHook) + const unpatch2 = instead(obj, 'method', secondHook) + + let result = obj.method() + expect(result).toBe('second-first-original') + + // Unpatch second hook (most recent) + unpatch2() + result = obj.method() + expect(result).toBe('first-original') + + // Unpatch first hook + unpatch1() + result = obj.method() + expect(result).toBe('original') + }) + + test('should not crash when unpatch is called multiple times', () => { + const replacement = mock(() => 'replaced') + const unpatch = instead(obj, 'method', replacement) + + unpatch() + expect(() => unpatch()).not.toThrow() + expect(() => unpatch()).not.toThrow() + }) + }) + + describe('mixed hooks', () => { + test('should handle before + after', () => { + const beforeHook = mock(args => { + args[0] = 'modified' + return args + }) + const afterHook = mock(result => `${result}-after`) + + const unpatchBefore = before(obj, 'method', beforeHook) + const unpatchAfter = after(obj, 'method', afterHook) + + let result = obj.method('original') + expect(result).toBe('modified-after') + expect(beforeHook).toHaveBeenCalledWith(['modified']) + expect(afterHook).toHaveBeenCalledWith('modified') + + // Unpatch after hook + unpatchAfter() + result = obj.method('original') + expect(result).toBe('modified') + + // Unpatch before hook + unpatchBefore() + result = obj.method('original') + expect(result).toBe('original') + }) + test('should handle instead + before', () => { + const insteadHook = mock( + (args, original) => `${original(...args)}-instead`, + ) + const beforeHook = mock(args => { + args[0] = 'modified' + return args + }) + + const unpatchInstead = instead(obj, 'method', insteadHook) + const unpatchBefore = before(obj, 'method', beforeHook) + + let result = obj.method('original') + expect(result).toBe('modified-instead') + expect(insteadHook).toHaveBeenCalledWith( + ['modified'], + expect.any(Function), + ) + expect(beforeHook).toHaveBeenCalledWith(['modified']) + + // Unpatch before hook + unpatchBefore() + result = obj.method('original') + expect(result).toBe('original-instead') + + // Unpatch instead hook + unpatchInstead() + result = obj.method('original') + expect(result).toBe('original') + }) + + test('should handle instead + after', () => { + const insteadHook = mock(() => 'oops') + const afterHook = mock(result => `${result}-after`) + + const unpatchInstead = instead(obj, 'method', insteadHook) + const unpatchAfter = after(obj, 'method', afterHook) + + let result = obj.method('original') + expect(result).toBe('oops-after') + expect(insteadHook).toHaveBeenCalledWith( + ['original'], + expect.any(Function), + ) + expect(afterHook).toHaveBeenCalledWith('oops') + + // Unpatch after hook + unpatchAfter() + result = obj.method('original') + expect(result).toBe('oops') + + // Unpatch instead hook + unpatchInstead() + result = obj.method('original') + expect(result).toBe('original') + }) + + test('should handle instead + before + after', () => { + const insteadHook = mock( + (args, original) => `${original(...args)}-instead`, + ) + + const beforeHook = mock(args => { + args[0] = 'modified' + return args + }) + + const afterHook = mock(result => `${result}-after`) + + const unpatchInstead = instead(obj, 'method', insteadHook) + const unpatchBefore = before(obj, 'method', beforeHook) + const unpatchAfter = after(obj, 'method', afterHook) + + let result = obj.method('original') + expect(result).toBe('modified-instead-after') + expect(insteadHook).toHaveBeenCalledWith( + ['modified'], + expect.any(Function), + ) + expect(beforeHook).toHaveBeenCalledWith(['modified']) + expect(afterHook).toHaveBeenCalledWith('modified-instead') + + // Unpatch after hook + unpatchAfter() + result = obj.method('original') + expect(result).toBe('modified-instead') + + // Unpatch before hook + unpatchBefore() + result = obj.method('original') + expect(result).toBe('original-instead') + + // Unpatch instead hook + unpatchInstead() + result = obj.method('original') + expect(result).toBe('original') + }) + + test('should handle before + after + instead', () => { + const beforeHook = mock(args => { + args[0] = 'before-modified' + return args + }) + + const afterHook = mock(result => `${result}-after`) + + const insteadHook = mock( + (args, original) => `${original(...args)}-instead`, + ) + + const unpatchBefore = before(obj, 'method', beforeHook) + const unpatchAfter = after(obj, 'method', afterHook) + const unpatchInstead = instead(obj, 'method', insteadHook) + + let result = obj.method('original') + expect(result).toBe('before-modified-instead-after') + expect(beforeHook).toHaveBeenCalledWith(['before-modified']) + expect(afterHook).toHaveBeenCalledWith('before-modified-instead') + expect(insteadHook).toHaveBeenCalledWith( + ['before-modified'], + expect.any(Function), + ) + + // Unpatch after hook + unpatchAfter() + result = obj.method('original') + expect(result).toBe('before-modified-instead') + + // Unpatch before hook + unpatchBefore() + result = obj.method('original') + expect(result).toBe('original-instead') + + // Unpatch instead hook + unpatchInstead() + result = obj.method('original') + expect(result).toBe('original') + }) + + test('should restore original method when all hooks are unpatched', () => { + const originalRef = obj.method + + const unpatch1 = before(obj, 'method', args => args) + const unpatch2 = after(obj, 'method', result => result) + + // Method should be patched + expect(obj.method).not.toBe(originalRef) + + unpatch1() + // Still patched because after hook remains + expect(obj.method).not.toBe(originalRef) + + unpatch2() + // Should be restored to original + expect(obj.method).toBe(originalRef) + }) + }) + + // describe('error handling', () => { + // test('should throw error for invalid parent', () => { + // expect(() => before(null as any, 'method', () => [])).toThrow() + // expect(() => before(undefined as any, 'method', () => [])).toThrow() + // expect(() => before('string' as any, 'method', () => [])).toThrow() + // }) + + // test('should throw error for non-existent property', () => { + // expect(() => before(obj, 'nonExistent' as any, () => [])).toThrow( + // "Property 'nonExistent' does not exist on parent", + // ) + // }) + + // test('should throw error for non-function property', () => { + // const objWithNonFunction = { prop: 'not a function' } + // expect(() => + // before(objWithNonFunction, 'prop' as any, () => []), + // ).toThrow("Property 'prop' is not a function") + // }) + + // test('should throw error for invalid hook', () => { + // expect(() => before(obj, 'method', null as any)).toThrow() + // expect(() => before(obj, 'method', 'string' as any)).toThrow() + // }) + // }) + + describe('constructor patching', () => { + test('should patch constructor functions', () => { + class TestClass { + value: string + constructor(value: string) { + this.value = value + } + } + + const obj = { TestClass } + + const beforeHook = mock(args => { + args[0] = 'modified' + return args + }) + + before(obj, 'TestClass', beforeHook) + + const instance = new obj.TestClass('original') + expect(instance.value).toBe('modified') + expect(beforeHook).toHaveBeenCalledWith(['modified']) + }) + }) +}) diff --git a/lib/patcher/tsconfig.json b/lib/patcher/tsconfig.json new file mode 100644 index 0000000..2906ca8 --- /dev/null +++ b/lib/patcher/tsconfig.json @@ -0,0 +1,29 @@ +{ + "compilerOptions": { + // Environment setup & latest features + "lib": ["ESNext"], + "target": "ESNext", + "module": "ESNext", + "moduleDetection": "force", + "types": ["bun"], + + // Bundler mode + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "noEmit": true, + + // Best practices + "strict": true, + "skipLibCheck": true, + "skipDefaultLibCheck": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedIndexedAccess": true, + + // Some stricter flags (disabled by default) + "noUnusedLocals": false, + "noUnusedParameters": false, + "noPropertyAccessFromIndexSignature": false + }, + "include": ["src"] +} diff --git a/package.json b/package.json index d621dbb..e548e8b 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "@parcel/watcher": "^2.5.1", "@react-native-clipboard/clipboard": "^1.16.2", "@swc/core": "^1.11.31", - "@types/bun": "latest", + "@types/bun": "^1.2.15", "@types/node": "^22.15.30", "@unbound-mod/hermesc": "^1.0.3", "chalk": "^5.4.1",