diff --git a/client/src/index.js b/client/src/index.js index 7c178387..94307dc5 100644 --- a/client/src/index.js +++ b/client/src/index.js @@ -10,7 +10,7 @@ import { DOM, BdUI, Modals, Reflection } from 'ui'; import BdCss from './styles/index.scss'; -import { Patcher, Vendor, Events, CssEditor, Globals, ExtModuleManager, PluginManager, ThemeManager, ModuleManager, WebpackModules, Settings, Database, ReactComponents, ReactAutoPatcher, DiscordApi } from 'modules'; +import { Patcher, MonkeyPatch, Vendor, Events, CssEditor, Globals, ExtModuleManager, PluginManager, ThemeManager, ModuleManager, WebpackModules, Settings, Database, ReactComponents, ReactAutoPatcher, DiscordApi } from 'modules'; import { ClientLogger as Logger, ClientIPC, Utils } from 'common'; import { EmoteModule } from 'builtin'; const ignoreExternal = false; @@ -25,6 +25,7 @@ class BetterDiscord { Modals, Reflection, Patcher, + MonkeyPatch, Vendor, Events, CssEditor, diff --git a/client/src/modules/modules.js b/client/src/modules/modules.js index 99e14d98..8bd1b7eb 100644 --- a/client/src/modules/modules.js +++ b/client/src/modules/modules.js @@ -15,5 +15,5 @@ export { default as Permissions } from './permissionmanager'; export { default as Database } from './database'; export { default as EventsWrapper } from './eventswrapper'; export { default as DiscordApi } from './discordapi'; -export { default as Patcher } from './patcher'; +export * from './patcher'; export * from './reactcomponents'; diff --git a/client/src/modules/patcher.js b/client/src/modules/patcher.js index 105128df..55e8ee80 100644 --- a/client/src/modules/patcher.js +++ b/client/src/modules/patcher.js @@ -9,31 +9,63 @@ */ import { WebpackModules } from './webpackmodules'; -import { ClientLogger as Logger } from 'common'; +import { ClientLogger as Logger, Utils } from 'common'; -export default class Patcher { +export class Patcher { static get patches() { return this._patches || (this._patches = {}) } + static getPatchesByCaller(id) { + const patches = []; + for (const patch in this.patches) { + if (this.patches.hasOwnProperty(patch)) { + if (this.patches[patch].caller === id) patches.push(this.patches[patch]); + } + } + return patches; + } + static unpatchAll(patches) { + for (const patch of patches) { + for (const child of patch.children) { + child.unpatch(); + } + } + } static resolveModule(module) { if (module instanceof Function || (module instanceof Object && !(module instanceof Array))) return module; if ('string' === typeof module) return WebpackModules.getModuleByName(module); if (module instanceof Array) return WebpackModules.getModuleByProps(module); return null; } + static overrideFn(patch) { return function () { - for (const superPatch of patch.supers) { + let retVal = null; + if (!patch.children) return patch.originalFunction.apply(this, arguments); + for (const superPatch of patch.children.filter(c => c.type === 'before')) { try { - superPatch.callback.apply(this, arguments); + superPatch.callback(this, arguments); } catch (err) { - Logger.err('Patcher', err); + Logger.err(`Patcher:${patch.id}`, err); } } - const retVal = patch.originalFunction.apply(this, arguments); - for (const slavePatch of patch.slaves) { + + const insteads = patch.children.filter(c => c.type === 'instead'); + if (!insteads.length) { + retVal = patch.originalFunction.apply(this, arguments); + } else { + for (const insteadPatch of insteads) { + try { + retVal = insteadPatch.callback(this, arguments); + } catch (err) { + Logger.err(`Patcher:${patch.id}`, err); + } + } + } + + for (const slavePatch of patch.children.filter(c => c.type === 'after')) { try { - slavePatch.callback.apply(this, [arguments, { patch, retVal }]); + slavePatch.callback(this, arguments, retVal); } catch (err) { - Logger.err('Patcher', err); + Logger.err(`Patcher:${patch.id}`, err); } } return retVal; @@ -44,61 +76,56 @@ export default class Patcher { patch.proxyFunction = patch.module[patch.functionName] = this.overrideFn(patch); } - static pushPatch(id, module, functionName) { + static pushPatch(caller, id, module, functionName) { const patch = { + caller, + id, module, functionName, originalFunction: module[functionName], proxyFunction: null, - revert: () => { + revert: () => { // Calling revert will destroy any patches added to the same module after this patch.module[patch.functionName] = patch.originalFunction; patch.proxyFunction = null; patch.slaves = patch.supers = []; }, - supers: [], - slaves: [] + counter: 0, + children: [] }; patch.proxyFunction = module[functionName] = this.overrideFn(patch); return this.patches[id] = patch; } - static get before() { return this.superpatch; } - static superpatch(unresolveModule, functionName, callback, displayName) { - const module = this.resolveModule(unresolveModule); + static before() { return this.pushChildPatch(...arguments, 'before') } + static after() { return this.pushChildPatch(...arguments, 'after') } + static instead() { return this.pushChildPatch(...arguments, 'instead') } + static pushChildPatch(caller, unresolvedModule, functionName, callback, displayName, type = 'after') { + const module = this.resolveModule(unresolvedModule); if (!module || !module[functionName] || !(module[functionName] instanceof Function)) return null; - displayName = 'string' === typeof unresolveModule ? unresolveModule : displayName || module.displayName || module.name || module.constructor.displayName || module.constructor.name; - const patchId = `${displayName}:${functionName}`; + displayName = 'string' === typeof unresolvedModule ? unresolvedModule : displayName || module.displayName || module.name || module.constructor.displayName || module.constructor.name; + const patchId = `${displayName}:${functionName}:${caller}`; - const patch = this.patches[patchId] || this.pushPatch(patchId, module, functionName); + const patch = this.patches[patchId] || this.pushPatch(caller, patchId, module, functionName); if (!patch.proxyFunction) this.rePatch(patch); - const id = patch.supers.length + 1; - const superPatch = { - id, + const child = { + caller, + type, + id: patch.counter, callback, - unpactch: () => patch.slaves.splice(patch.slaves.findIndex(slave => slave.id === id), 1) // This doesn't actually work correctly not, fix in a moment + unpatch: () => { + patch.children.splice(patch.children.findIndex(cpatch => cpatch.id === child.id && cpatch.type === type), 1); + if (patch.children.length <= 0) delete this.patches[patchId]; + } }; - - patch.supers.push(superPatch); - return superPatch; + patch.children.push(child); + patch.counter++; + return child.unpatch; } - static get after() { return this.slavepatch; } - static slavepatch(unresolveModule, functionName, callback, displayName) { - const module = this.resolveModule(unresolveModule); - if (!module || !module[functionName] || !(module[functionName] instanceof Function)) return null; - displayName = 'string' === typeof unresolveModule ? unresolveModule : displayName || module.displayName || module.name || module.constructor.displayName || module.constructor.name; - const patchId = `${displayName}:${functionName}`; - - const patch = this.patches[patchId] || this.pushPatch(patchId, module, functionName); - if (!patch.proxyFunction) this.rePatch(patch); - const id = patch.slaves.length + 1; - const slavePatch = { - id, - callback, - unpactch: () => patch.slaves.splice(patch.slaves.findIndex(slave => slave.id === id), 1) // This doesn't actually work correctly not, fix in a moment - }; - - patch.slaves.push(slavePatch); - return slavePatch; - } } + +export const MonkeyPatch = (caller, module, displayName) => ({ + before: (functionName, callBack) => Patcher.before(caller, module, functionName, callBack, displayName), + after: (functionName, callBack) => Patcher.after(caller, module, functionName, callBack, displayName), + instead: (functionName, callBack) => Patcher.instead(caller, module, functionName, callBack, displayName) +}); diff --git a/client/src/modules/pluginapi.js b/client/src/modules/pluginapi.js index 39ed9986..3bb93be8 100644 --- a/client/src/modules/pluginapi.js +++ b/client/src/modules/pluginapi.js @@ -20,6 +20,7 @@ import { SettingsSet, SettingsCategory, Setting, SettingsScheme } from 'structs' import { BdMenuItems, Modals, DOM, Reflection } from 'ui'; import DiscordApi from './discordapi'; import { ReactComponents } from './reactcomponents'; +import { MonkeyPatch } from './patcher'; export default class PluginApi { @@ -39,6 +40,9 @@ export default class PluginApi { get Reflection() { return Reflection; } + get MonkeyPatch() { + return module => MonkeyPatch(this.pluginInfo.id, module); + } get plugin() { return PluginManager.getPluginById(this.pluginInfo.id || this.pluginInfo.name.toLowerCase().replace(/[^a-zA-Z0-9-]/g, '-').replace(/--/g, '-')); } diff --git a/client/src/modules/reactcomponents.js b/client/src/modules/reactcomponents.js index ab888646..e7db20b2 100644 --- a/client/src/modules/reactcomponents.js +++ b/client/src/modules/reactcomponents.js @@ -9,7 +9,7 @@ * LICENSE file in the root directory of this source tree. */ -import Patcher from './patcher'; +import { MonkeyPatch, Patcher } from './patcher'; import { WebpackModules, Filters } from './webpackmodules'; import DiscordApi from './discordapi'; import { EmoteModule } from 'builtin'; @@ -42,7 +42,7 @@ class Helpers { return this.recursiveArray(parent, key, count); } static get recursiveChildren() { - return function*(parent, key, index = 0, count = 1) { + return function* (parent, key, index = 0, count = 1) { const item = parent[key]; yield { item, parent, key, index, count }; if (item && item.props && item.props.children) { @@ -132,6 +132,18 @@ class Helpers { } return null; } + static findProp(obj, what) { + if (obj.hasOwnProperty(what)) return obj[what]; + if (obj.props && !obj.children) return this.findProp(obj.props, what); + if (!obj.children) return null; + if (!(obj.children instanceof Array)) return this.findProp(obj.children, what); + for (const child of obj.children) { + if (!child) continue; + const findInChild = this.findProp(child, what); + if (findInChild) return findInChild; + } + return null; + } static get ReactDOM() { return WebpackModules.getModuleByName('ReactDOM'); } @@ -142,206 +154,23 @@ class ReactComponent { this._id = id; this._component = component; this._retVal = retVal; - const self = this; - Patcher.slavepatch(this.component.prototype, 'componentWillMount', function(args, parv) { - self.eventCallback('componentWillMount', { - component: this, - retVal: parv.retVal - }); - }); - Patcher.slavepatch(this.component.prototype, 'render', function (args, parv) { - self.eventCallback('render', { - component: this, - retVal: parv.retVal - }); - }); - Patcher.slavepatch(this.component.prototype, 'componentDidMount', function (args, parv) { - self.eventCallback('componentDidMount', { - component: this, - props: this.props, - state: this.state, - element: Helpers.ReactDOM.findDOMNode(this), - retVal: parv.retVal - }); - }); - Patcher.slavepatch(this.component.prototype, 'componentWillReceiveProps', function (args, parv) { - const [nextProps] = args; - self.eventCallback('componentWillReceiveProps', { - component: this, - nextProps, - retVal: parv.retVal - }); - }); - Patcher.slavepatch(this.component.prototype, 'shouldComponentUpdate', function (args, parv) { - const [nextProps, nextState] = args; - self.eventCallback('shouldComponentUpdate', { - component: this, - nextProps, - nextState, - retVal: parv.retVal - }); - }); - Patcher.slavepatch(this.component.prototype, 'componentWillUpdate', function (args, parv) { - const [nextProps, nextState] = args; - self.eventCallback('componentWillUpdate', { - component: this, - nextProps, - nextState, - retVal: parv.retVal - }); - }); - Patcher.slavepatch(this.component.prototype, 'componentDidUpdate', function(args, parv) { - const [prevProps, prevState] = args; - self.eventCallback('componentDidUpdate', { - component: this, - prevProps, - prevState, - props: this.props, - state: this.state, - element: Helpers.ReactDOM.findDOMNode(this), - retVal: parv.retVal - }); - }); - Patcher.slavepatch(this.component.prototype, 'componentWillUnmount', function (args, parv) { - self.eventCallback('componentWillUnmount', { - component: this, - retVal: parv.retVal - }); - }); - Patcher.slavepatch(this.component.prototype, 'componentDidCatch', function (args, parv) { - const [error, info] = args; - self.eventCallback('componentDidCatch', { - component: this, - error, - info, - retVal: parv.retVal - }); - }); } - - eventCallback(event, eventData) { - for (const listener of this.events.find(e => e.id === event).listeners) { - listener(eventData); - } - } - - get events() { - return this._events || (this._events = [ - { id: 'componentWillMount', listeners: [] }, - { id: 'render', listeners: [] }, - { id: 'componentDidMount', listeners: [] }, - { id: 'componentWillReceiveProps', listeners: [] }, - { id: 'shouldComponentUpdate', listeners: [] }, - { id: 'componentWillUpdate', listeners: [] }, - { id: 'componentDidUpdate', listeners: [] }, - { id: 'componentWillUnmount', listeners: [] }, - { id: 'componentDidCatch', listeners: [] } - ]); - } - - on(event, callback) { - const have = this.events.find(e => e.id === event); - if (!have) return; - have.listeners.push(callback); - } - get id() { return this._id; } - get component() { return this._component; } - get retVal() { return this._retVal; } - - forceUpdateOthers() { - - } -} - -export class ReactAutoPatcher { - static async autoPatch() { - await this.ensureReact(); - Patcher.superpatch('React', 'createElement', (component, retVal) => ReactComponents.push(component, retVal)); - this.patchem(); - return 1; - } - static async ensureReact() { - while (!window.webpackJsonp || !WebpackModules.getModuleByName('React')) await new Promise(resolve => setTimeout(resolve, 10)); - return 1; - } - static patchem() { - this.patchMessage(); - this.patchMessageGroup(); - this.patchChannelMember(); - } - - static async patchMessage() { - this.Message.component = await ReactComponents.getComponent('Message', true, { selector: '.message' }); - this.Message.component.on('render', ({ component, retVal, p }) => { - const { message } = component.props; - const { id, colorString, bot, author, attachments, embeds } = message; - retVal.props['data-message-id'] = id; - retVal.props['data-colourstring'] = colorString; - if (author && author.id) retVal.props['data-user-id'] = author.id; - if (bot || (author && author.bot)) retVal.props.className += ' bd-isBot'; - if (attachments && attachments.length) retVal.props.className += ' bd-hasAttachments'; - if (embeds && embeds.length) retVal.props.className += ' bd-hasEmbeds'; - if (author && author.id === DiscordApi.currentUser.id) retVal.props.className += ' bd-isCurrentUser'; - try { - const markup = Helpers.findByProp(retVal, 'className', 'markup').children; // First child has all the actual text content, second is the edited timestamp - markup[0] = EmoteModule.processMarkup(markup[0]); - } catch (err) { - console.error('MARKUP PARSER ERROR', err); - } - }); - } - - static async patchMessageGroup() { - ReactComponents.setName('MessageGroup', this.MessageGroup.filter); - this.MessageGroup.component = await ReactComponents.getComponent('MessageGroup', true, { selector: '.message-group' }); - this.MessageGroup.component.on('render', ({ component, retVal, p }) => { - const authorid = component.props.messages[0].author.id; - retVal.props['data-author-id'] = authorid; - if (authorid === DiscordApi.currentUser.id) retVal.props.className += ' bd-isCurrentUser'; - }); - } - - static async patchChannelMember() { - this.ChannelMember.component = await ReactComponents.getComponent('ChannelMember'); - this.ChannelMember.component.on('render', ({ component, retVal, p }) => { - const { user, isOwner } = component.props; - retVal.props.children.props['data-member-id'] = user.id; - if (user.id === DiscordApi.currentUser.id) retVal.props.children.props.className += ' bd-isCurrentUser'; - if (isOwner) retVal.props.children.props.className += ' bd-isOwner'; - }); - } - - static get MessageGroup() { - return this._messageGroup || ( - this._messageGroup = { - filter: Filters.byCode(/"message-group"[\s\S]*"has-divider"[\s\S]*"hide-overflow"[\s\S]*"is-local-bot-message"/, c => c.prototype && c.prototype.render) - }); - } - - static get Message() { - return this._message || (this._message = {}); - } - - static get ChannelMember() { - return this._channelMember || ( - this._channelMember = {}); - } } export class ReactComponents { static get components() { return this._components || (this._components = []) } - static get unknownComponents() { return this._unknownComponents || (this._unknownComponents = [])} + static get unknownComponents() { return this._unknownComponents || (this._unknownComponents = []) } static get listeners() { return this._listeners || (this._listeners = []) } - static get nameSetters() { return this._nameSetters || (this._nameSetters =[])} + static get nameSetters() { return this._nameSetters || (this._nameSetters = []) } static push(component, retVal) { if (!(component instanceof Function)) return null; @@ -362,7 +191,7 @@ export class ReactComponents { return c; } - static async getComponent(name, important, importantArgs) { + static async getComponent(name, important) { const have = this.components.find(c => c.id === name); if (have) return have; if (important) { @@ -372,11 +201,11 @@ export class ReactComponents { clearInterval(importantInterval); return; } - const select = document.querySelector(importantArgs.selector); + const select = document.querySelector(important.selector); if (!select) return; const reflect = Reflection(select); if (!reflect.component) { - clearInterval(important); + clearInterval(importantInterval); console.error(`FAILED TO GET IMPORTANT COMPONENT ${name} WITH REFLECTION FROM`, select); return; } @@ -413,7 +242,8 @@ export class ReactComponents { static processUnknown(component, retVal) { const have = this.unknownComponents.find(c => c.component === component); for (const [fi, filter] of this.nameSetters.entries()) { - if (filter.filter(component)) { + if (filter.filter.filter(component)) { + console.log('filter match!'); component.displayName = filter.name; this.nameSetters.splice(fi, 1); return this.push(component, retVal); @@ -424,3 +254,61 @@ export class ReactComponents { return component; } } + +export class ReactAutoPatcher { + static async autoPatch() { + await this.ensureReact(); + this.React = {}; + this.React.unpatchCreateElement = MonkeyPatch('BD:ReactComponents:createElement', 'React').before('createElement', (component, args) => { + ReactComponents.push(args[0]); + }); + this.patchComponents(); + return 1; + } + + static async ensureReact() { + while (!window.webpackJsonp || !WebpackModules.getModuleByName('React')) await new Promise(resolve => setTimeout(resolve, 10)); + return 1; + } + + static async patchComponents() { + this.patchMessage(); + this.patchMessageGroup(); + this.patchChannelMember(); + } + + static async patchMessage() { + this.Message = await ReactComponents.getComponent('Message', { selector: '.message' }); + this.unpatchMessageRender = MonkeyPatch('BD:ReactComponents', this.Message.component.prototype).after('render', (component, args, retVal) => { + const { message } = component.props; + const { id, colorString, bot, author, attachments, embeds } = message; + retVal.props['data-message-id'] = id; + retVal.props['data-colourstring'] = colorString; + if (author && author.id) retVal.props['data-user-id'] = author.id; + if (bot || (author && author.bot)) retVal.props.className += ' bd-isBot'; + if (attachments && attachments.length) retVal.props.className += ' bd-hasAttachments'; + if (embeds && embeds.length) retVal.props.className += ' bd-hasEmbeds'; + if (author && author.id === DiscordApi.currentUser.id) retVal.props.className += ' bd-isCurrentUser'; + }); + } + + static async patchMessageGroup() { + this.MessageGroup = await ReactComponents.getComponent('MessageGroup', { selector: '.message-group' }); + this.unpatchMessageGroupRender = MonkeyPatch('BD:ReactComponents', this.MessageGroup.component.prototype).after('render', (component, args, retVal) => { + const { author, type } = component.props.messages[0]; + retVal.props['data-author-id'] = author.id; + if (author.id === DiscordApi.currentUser.id) retVal.props.className += ' bd-isCurrentUser'; + if (type !== 0) retVal.props.className += ' bd-isSystemMessage'; + }); + } + + static async patchChannelMember() { + this.ChannelMember = await ReactComponents.getComponent('ChannelMember', { selector: '.member.member-status' }); + this.unpatchChannelMemberRender = MonkeyPatch('BD:ReactComponents', this.ChannelMember.component.prototype).after('render', (component, args, retVal) => { + if (!retVal.props || !retVal.props.children || !retVal.props.children.length) return; + const user = Helpers.findProp(component, 'user'); + if (!user) return; + retVal.props['data-user-id'] = user.id; + }); + } +} diff --git a/client/src/ui/reflection.js b/client/src/ui/reflection.js index 9d6a0b58..2f3ff7ef 100644 --- a/client/src/ui/reflection.js +++ b/client/src/ui/reflection.js @@ -89,7 +89,20 @@ class Reflection { } } - static getComponent(node) { + static getComponent(node, first = true) { + try { + return this.reactInternalInstance(node).return.type; + } catch (err) { + return null; + } + if (!node) return null; + if (first) node = this.reactInternalInstance(node); + if (node.hasOwnProperty('return')) { + if (node.return.hasOwnProperty('return') && !node.return.type) return node.type; + return this.getComponent(node.return, false); + } + if (node.hasOwnProperty('type')) return node.type; + return null; // IMPORTANT TODO Currently only checks the first found component. For example channel-member will not return the correct component try { return this.reactInternalInstance(node).return.type; diff --git a/common/modules/utils.js b/common/modules/utils.js index b31f72c6..c47a30fa 100644 --- a/common/modules/utils.js +++ b/common/modules/utils.js @@ -18,6 +18,9 @@ import { Vendor } from 'modules'; import filetype from 'file-type'; export class Utils { + static isArrowFunction(fn) { + return !fn.toString().startsWith('function'); + } static overload(fn, cb) { const orig = fn; return function (...args) { diff --git a/tests/plugins/Patcher Test/config.json b/tests/plugins/Patcher Test/config.json new file mode 100644 index 00000000..3c8cc3a9 --- /dev/null +++ b/tests/plugins/Patcher Test/config.json @@ -0,0 +1,12 @@ +{ + "info": { + "id": "patcher-test", + "name": "Patcher Test", + "authors": [ "Jiiks" ], + "version": 1.0, + "description": "Patcher Test Description" + }, + "main": "index.js", + "type": "plugin", + "defaultConfig": [] +} diff --git a/tests/plugins/Patcher Test/index.js b/tests/plugins/Patcher Test/index.js new file mode 100644 index 00000000..8c7eefaf --- /dev/null +++ b/tests/plugins/Patcher Test/index.js @@ -0,0 +1,26 @@ +module.exports = (Plugin, Api, Vendor) => { + + const { ReactComponents } = Api; + + return class extends Plugin { + test() { + + } + onStart() { + this.patchMessage(); + return true; + } + async patchMessage() { + const Message = await ReactComponents.getComponent('Message'); + this.unpatchTest = Api.MonkeyPatch(Message.component.prototype).after('render', () => { + console.log('MESSAGE RENDER!'); + }); + } + + onStop() { + this.unpatchTest(); // The automatic unpatcher is not there yet + return true; + } + } +} +