diff --git a/src/utils/index.ts b/src/utils/index.ts new file mode 100644 index 0000000..3d891c4 --- /dev/null +++ b/src/utils/index.ts @@ -0,0 +1,10 @@ +export * from "./patcher"; +export * from "./react"; + +export function joinClassNames(...classes: string[]): string { + return classes.join(" "); +} + +export function sleep(ms: number) { + return new Promise(res => setTimeout(res, ms)); +} \ No newline at end of file diff --git a/src/utils/patcher.ts b/src/utils/patcher.ts new file mode 100644 index 0000000..0e6bdf0 --- /dev/null +++ b/src/utils/patcher.ts @@ -0,0 +1,112 @@ +// TODO: implement storing patches as an option so we can offer unpatchAll selectively +// Return this in a replacePatch to call the original method (can still modify args). +export let callOriginal = Symbol("DECKY_CALL_ORIGINAL"); + +export interface PatchOptions { + singleShot?: boolean +} + +type GenericPatchHandler = (args: any[], ret?: any) => any; + +export interface Patch { + original: Function; + property: string; + object: any; + patchedFunction: any; + hasUnpatched: boolean; + handler: GenericPatchHandler; + + unpatch: () => void +}; + +// let patches = new Set(); + +export function beforePatch(object: any, property: string, handler: (args: any[]) => any, options: PatchOptions = {}): Patch { + const orig = object[property]; + object[property] = function (...args: any[]) { + handler.call(this, args); + const ret = patch.original.call(this, ...args); + if (options.singleShot) { + patch.unpatch(); + } + return ret; + } + const patch = processPatch(object, property, handler, object[property], orig); + return patch; +} + +export function afterPatch(object: any, property: string, handler: (args: any[], ret: any) => any, options: PatchOptions = {}): Patch { + const orig = object[property]; + object[property] = function (...args: any[]) { + let ret = patch.original.call(this, ...args); + ret = handler.call(this, args, ret); + if (options.singleShot) { + patch.unpatch(); + } + return ret; + } + const patch = processPatch(object, property, handler, object[property], orig); + return patch; +} + +export function replacePatch(object: any, property: string, handler: (args: any[]) => any, options: PatchOptions = {}): Patch { + const orig = object[property]; + object[property] = function (...args: any[]) { + const ret = handler.call(this, args); + if (ret == callOriginal) return patch.original.call(this, ...args); + if (options.singleShot) { + patch.unpatch(); + } + return ret; + }; + const patch = processPatch(object, property, handler, object[property], orig); + return patch; +} + +function processPatch(object: any, property: any, handler: GenericPatchHandler, patchedFunction: any, original: any): Patch { + // Assign all props of original function to new one + Object.assign(object[property], original); + // Allow toString webpack filters to continue to work + object[property].toString = () => original.toString(); + + // HACK: for compatibility, remove when all plugins are using new patcher + Object.defineProperty(object[property], "__deckyOrig", { + get: () => patch.original, + set: (val: any) => patch.original = val + }) + + // Build a Patch object of this patch + const patch: Patch = { + object, + property, + handler, + patchedFunction, + original, + hasUnpatched: false, + unpatch: () => unpatch(patch) + }; + + object[property].__deckyPatch = patch; + + return patch; +} + +function unpatch(patch: Patch): void { + const { object, property, handler, patchedFunction, original } = patch; + if (patch.hasUnpatched) throw new Error("Function is already unpatched.") + let realProp = property; + let realObject = object; + console.debug("[Patcher] unpatching", {realObject, realProp, object, property, handler, patchedFunction, original, isEqual: realObject[realProp] === patchedFunction}) + + // If another patch has been applied to this function after this one, move down until we find the correct patch + while (realObject[realProp] && realObject[realProp] !== patchedFunction) { + realObject = realObject[realProp].__deckyPatch; + realProp = "original"; + console.debug("[Patcher] moved to next", {realObject, realProp, object, property, handler, patchedFunction, original, isEqual: realObject[realProp] === patchedFunction}) + } + + realObject[realProp] = realObject[realProp].__deckyPatch.original + + patch.hasUnpatched = true; + console.debug("[Patcher] unpatched", {realObject, realProp, object, property, handler, patchedFunction, original, isEqual: realObject[realProp] === patchedFunction}) +} \ No newline at end of file diff --git a/src/utils.ts b/src/utils/react.ts similarity index 58% rename from src/utils.ts rename to src/utils/react.ts index 259e231..cf84180 100644 --- a/src/utils.ts +++ b/src/utils/react.ts @@ -42,62 +42,6 @@ export function fakeRenderComponent(fun: Function): any { return res; } -export interface PatchOptions { - singleShot?: boolean -} - -export function beforePatch(obj: any, name: string, fnc: (args: any[]) => any, options: PatchOptions = {}): void { - const orig = obj[name]; - obj[name] = function (...args: any[]) { - fnc.call(this, args); - const ret = orig.call(this, ...args); - if (options.singleShot) { - unpatch(obj, name); - } - return ret; - } - Object.assign(obj[name], orig); - obj[name].toString = () => orig.toString(); - obj[name].__deckyOrig = orig; -} - -export function afterPatch(obj: any, name: string, fnc: (args: any[], ret: any) => any, options: PatchOptions = {}): void { - const orig = obj[name]; - obj[name] = function (...args: any[]) { - let ret = orig.call(this, ...args); - ret = fnc.call(this, args, ret); - if (options.singleShot) { - unpatch(obj, name); - } - return ret; - } - Object.assign(obj[name], orig); - obj[name].toString = () => orig.toString(); - obj[name].__deckyOrig = orig; -} - -export function replacePatch(obj: any, name: string, fnc: (args: any[]) => any, options: PatchOptions = {}): void { - const orig = obj[name]; - obj[name] = function (...args: any[]) { - let ret = fnc.call(this, args); - if (ret == 'CALL_ORIGINAL') ret = orig.call(this, ...args); - if (options.singleShot) { - unpatch(obj, name); - } - return ret; - }; - Object.assign(obj[name], orig); - obj[name].toString = () => orig.toString(); - obj[name].__deckyOrig = orig; -} - -// TODO allow one method to be patched and unpatched multiple times independently using IDs in a Map or something -export function unpatch(obj: any, name: any): void { - if (obj[name].__deckyOrig !== undefined) { - obj[name] = obj[name].__deckyOrig; - } -} - export function wrapReactType(node: any, prop: any = 'type') { return node[prop] = {...node[prop]}; } @@ -112,14 +56,6 @@ export function getReactInstance(o: HTMLElement | Element | Node) { return o[Object.keys(o).find(k => k.startsWith('__reactInternalInstance')) as string] } -export function joinClassNames(...classes: string[]): string { - return classes.join(" "); -} - -export function sleep(ms: number) { - return new Promise(res => setTimeout(res, ms)); -} - // Based on https://github.com/GooseMod/GooseMod/blob/9ef146515a9e59ed4e25665ed365fd72fc0dcf23/src/util/react.js#L20 export interface findInTreeOpts { walkable?: string[], @@ -147,4 +83,4 @@ export const findInTree = (parent: any, filter: findInTreeFilter, opts: findInTr export const findInReactTree = (node: any, filter: findInTreeFilter) => findInTree(node, filter, { // Specialised findInTree for React nodes walkable: [ 'props', 'children', 'child', 'sibling' ] -}); +}); \ No newline at end of file