From 1d1c8c398d3e8f7f59a3632cf479090003baf777 Mon Sep 17 00:00:00 2001 From: adryd Date: Thu, 30 Nov 2023 16:55:35 -0500 Subject: [PATCH] Refactor whew, add autodetection support --- src/Patcher.js | 211 ++++++++++++++++++++++++++++++++++++++++ src/config.js | 12 --- src/index.js | 26 ++++- src/injectEverywhere.js | 54 ++++++++++ src/matchModule.js | 11 +++ src/patcher.js | 192 ------------------------------------ src/wpTools.js | 120 ++++++++++++----------- userscriptTemplate.js | 2 +- 8 files changed, 363 insertions(+), 265 deletions(-) create mode 100644 src/Patcher.js delete mode 100644 src/config.js create mode 100644 src/injectEverywhere.js create mode 100644 src/matchModule.js delete mode 100644 src/patcher.js diff --git a/src/Patcher.js b/src/Patcher.js new file mode 100644 index 0000000..188ef1f --- /dev/null +++ b/src/Patcher.js @@ -0,0 +1,211 @@ +import matchModule from "./matchModule"; +import { getWpToolsFunc } from "./wpTools"; + +export default class Patcher { + constructor(config) { + this.name = config.name; + this.chunkObject = config.chunkObject; + this.webpackVersion = config.webpackVersion; + this.inspectAll = config.inspectAll; + + this.modules = new Set(config.modules ?? []); + this.patches = new Set(config.patches ?? []); + + // Validation + if (typeof this.webpackVersion == "number") { + this.webpackVersion = this.webpackVersion.toString(); + } + + // Populate patches to apply and modules to inject + this.patchesToApply = new Set(); + if (this.patches) { + for (const patch of this.patches) { + this.patchesToApply.add(patch); + } + } + + this.modulesToInject = new Set(); + if (this.modules) { + for (const module of this.modules) { + if (module.needs != undefined && module.needs instanceof Array) { + module.needs = new Set(module.needs); + } + this.modulesToInject.add(module); + } + } + + if (config.injectWpTools) { + this.modulesToInject.add({ + name: "wpTools", + // This is sorta a scope hack. + // If we rewrap this function, it will lose its scope (in this case the match module import and the chunk object name) + run: getWpToolsFunc(this.chunkObject), + entry: true, + }); + } + } + + run() { + if (this.webpackVersion == "4" || this.webpackVersion == "5") { + this._interceptWebpackModern(); + } else { + this._interceptWebpackLegacy; + } + } + + _interceptWebpackModern() { + // This is necesary since some sites (twitter) define the chunk object earlier + let realChunkObject = window[this.chunkObject]; + const patcher = this; + + Object.defineProperty(window, this.chunkObject, { + set: function set(value) { + realChunkObject = value; + // Don't infinitely re-wrap .push() + // Every webpack chunk reassigns the chunk array, triggering the setter every time + // `(self.webpackChunk = self.webpackChunk || [])` + if (!value.push.__wpt_injected) { + realChunkObject = value; + const realPush = value.push; + + value.push = function (chunk) { + // This is necesary because webpack will re-wrap the .push function + // Without this check, we'll patch modules multiple times + if (!chunk.__wpt_processed) { + chunk.__wpt_processed = true; + patcher._patchModules(chunk[1]); + patcher._injectModules(chunk); + } + return realPush.apply(this, arguments); + }; + + value.push.__wpt_injected = true; + if (realPush == Array.prototype.push) { + console.log("[wpTools] Injected " + patcher._chunkObject + " (before webpack runtime)"); + } else { + console.log("[wpTools] Injected " + patcher._chunkObject + " (at webpack runtime)"); + } + } + }, + get: function get() { + return realChunkObject; + }, + configurable: true, + }); + } + + _interceptWebpackLegacy() {} + + _patchModules(modules) { + for (const id in modules) { + if (modules[id].__wpt_processed) { + continue; + } + let funcStr = Function.prototype.toString.apply(modules[id]); + + const matchingPatches = []; + for (const patch of this.patchesToApply) { + if (matchModule(funcStr, patch.find)) { + matchingPatches.push(patch); + this.patchesToApply.delete(patch); + } + } + + for (const patch of matchingPatches) { + funcStr = funcStr.replace(patch.replace.match, patch.replace.replacement); + } + + if (matchingPatches.length > 0 || this.inspectAll) { + let debugString = ""; + if (matchingPatches.length > 0) { + debugString += "Patched by: " + matchingPatches.map((patch) => patch.name).join(", "); + } + + modules[id] = new Function( + "module", + "exports", + "webpackRequire", + `(${funcStr}).apply(this, arguments)\n// ${debugString}\n//# sourceURL=${this.chunkObject}-Module-${id}`, + ); + modules[id].__wpt_patched = true; + } + + modules[id].__wpt_funcStr = funcStr; + modules[id].__wpt_processed = true; + } + } + + _injectModules(chunk) { + const readyModules = new Set(); + + for (const moduleToInject of this.modulesToInject) { + if (moduleToInject?.needs?.size > 0) { + for (const need of moduleToInject.needs) { + for (const wpModule of Object.entries(chunk[1])) { + // match { moduleId: "id" } as well as strings and regex + if ((need?.moduleId && wpModule[0] == need.moduleId) || matchModule(wpModule[1].__wpt_funcStr, need)) { + moduleToInject.needs.delete(need); + if (moduleToInject.needs.size == 0) { + readyModules.add(moduleToInject); + } + break; + } + } + } + } else { + readyModules.add(moduleToInject); + } + } + + if (readyModules.size > 0) { + const injectModules = {}; + const injectEntries = []; + + for (const readyModule of readyModules) { + this.modulesToInject.delete(readyModule); + injectModules[readyModule.name] = readyModule.run; + if (readyModule.entry) { + injectEntries.push(readyModule.name); + } + } + + // Convert array to object for named modules + if (chunk[1] instanceof Array) { + const origChunkArray = chunk[1]; + chunk[1] = {}; + origChunkArray.forEach((module, index) => { + chunk[1][index] = module; + }); + } + + // merge our modules with original modules + chunk[1] = Object.assign(chunk[1], injectModules); + + if (injectEntries.length > 0) { + switch (this.webpackVersion) { + case "5": + if (chunk[2]) { + const originalEntry = chunk[2]; + chunk[2] = function (webpackRequire) { + originalEntry.apply(this, arguments); + injectEntries.forEach(webpackRequire); + }; + } else { + chunk[2] = function (webpackRequire) { + injectEntries.forEach(webpackRequire); + }; + } + break; + case "4": + if (chunk[2]?.[0]) { + chunk[2]?.[0].concat([injectEntries]); + } else { + chunk[2] = [injectEntries]; + } + break; + } + } + console.log(chunk); + } + } +} diff --git a/src/config.js b/src/config.js deleted file mode 100644 index 3de32d1..0000000 --- a/src/config.js +++ /dev/null @@ -1,12 +0,0 @@ -const config = window.__webpackTools_config; -delete window.__webpackTools_config; - -let thisSiteConfig; -for (let siteConfig of config.siteConfigs) { - if (siteConfig.matchSites?.includes(window.location.host)) { - thisSiteConfig = siteConfig; - break; - } -} - -export default thisSiteConfig; diff --git a/src/index.js b/src/index.js index 7eb5280..d014365 100644 --- a/src/index.js +++ b/src/index.js @@ -1,7 +1,25 @@ -import config from "./config"; -import { interceptWebpack } from "./patcher"; + +import Patcher from "./Patcher"; +import { injectEverywhere } from "./injectEverywhere"; + + +export const globalConfig = window.__webpackTools_config; +delete window.__webpackTools_config; + +export const siteConfigs = new Set(); +for (let siteConfig of globalConfig.siteConfigs) { + if (siteConfig.matchSites?.includes(window.location.host)) { + siteConfigs.add(siteConfig); + break; + } +} // todo: magicrequire everywhere impl -if (config) { - interceptWebpack(); +if (siteConfigs.size > 0) { + for (const siteConfig of siteConfigs) { + const patcher = new Patcher(siteConfig); + patcher.run(); + } +} else if (globalConfig.wpToolsEverywhere) { + window.addEventListener("load", injectEverywhere); } diff --git a/src/injectEverywhere.js b/src/injectEverywhere.js new file mode 100644 index 0000000..a9a92bb --- /dev/null +++ b/src/injectEverywhere.js @@ -0,0 +1,54 @@ +import { getWpToolsFunc } from "./wpTools"; + +function getVersion(chunkObject) { + if (chunkObject instanceof Array) { + return "modern"; + } else { + return "legacy"; + } +} + +function injectWpTools(chunkObjectName) { + const chunkObject = window[chunkObjectName]; + + if (chunkObject.__wpt_everywhere_injected) { + return; + } + const version = getVersion(chunkObject); + + console.log("[wpTools] Detected " + chunkObjectName + " using webpack " + version); + + switch (version) { + case "modern": + // Gross Hack to support both webpack 4 and webpack 5 + var load = function (webpackRequire) { + webpackRequire("wpTools"); + }; + load[0] = ["wpTools"]; + load[Symbol.iterator] = function () { + return { + read: false, + next() { + if (!this.read) { + this.read = true; + return { done: false, value: 0 }; + } else { + return { done: true }; + } + }, + }; + }; + chunkObject.__wpt_everywhere_injected = true + chunkObject.push([["wpTools"], { wpTools: getWpToolsFunc(chunkObjectName) }, load]); + break; + } +} + +export function injectEverywhere() { + for (const key of Object.getOwnPropertyNames(window)) { + if ((key.includes("webpackJsonp") || key.includes("webpackChunk") || key.includes("__LOADABLE_LOADED_CHUNKS__")) && !key.startsWith("wpTools")) { + injectWpTools(key); + } + } +} + diff --git a/src/matchModule.js b/src/matchModule.js new file mode 100644 index 0000000..0847fdf --- /dev/null +++ b/src/matchModule.js @@ -0,0 +1,11 @@ +export default function matchModule(moduleStr, queryArg) { + const queryArray = queryArg instanceof Array ? queryArg : [queryArg]; + return queryArray.some((query) => { + // we like our microoptimizations https://jsben.ch/Zk8aw + if (query instanceof RegExp) { + return query.test(moduleStr); + } else { + return moduleStr.includes(query); + } + }); +} diff --git a/src/patcher.js b/src/patcher.js deleted file mode 100644 index a5bfa45..0000000 --- a/src/patcher.js +++ /dev/null @@ -1,192 +0,0 @@ -import config from "./config"; -import wpTools from "./wpTools"; - -let patchesToApply, modulesToInject; - -if (config) { - patchesToApply = new Set(); - if (config.patches) { - for (const patch of config.patches) { - patchesToApply.add(patch); - } - } - - modulesToInject = new Set(); - if (config.modules) { - for (const module of config.modules) { - if (module.needs != undefined && module.needs instanceof Array) { - module.needs = new Set(module.needs); - } - modulesToInject.add(module); - } - } - - modulesToInject.add({ - name: "wpTools", - // This is sorta a scope hack. - // If we rewrap this function, it will lose its scope (in this case the match module import) - run: wpTools, - entry: true, - }); -} - -export function interceptWebpack() { - const chunkObjectName = config.chunkObject; - - // This is necesary since some sites (twitter) define the chunk object earlier - let realChunkObject = window[chunkObjectName]; - - Object.defineProperty(window, chunkObjectName, { - set: function set(value) { - realChunkObject = value; - // Don't infinitely re-wrap .push() - // Every webpack chunk reassigns the chunk array, triggering the setter every time - // `(self.webpackChunk = self.webpackChunk || [])` - if (!value.push.__wpt_injected) { - realChunkObject = value; - const realPush = value.push; - - value.push = function (chunk) { - // This is necesary because webpack will re-wrap the .push function - // Without this check, we'll patch modules multiple times - if (!chunk.__wpt_processed) { - chunk.__wpt_processed = true; - patchModules(chunk[1]); - injectModules(chunk); - } - return realPush.apply(this, arguments); - }; - - value.push.__wpt_injected = true; - if (realPush == Array.prototype.push) { - console.log("Injected " + chunkObjectName + " (before webpack runtime)"); - } else { - console.log("Injected " + chunkObjectName + " (at webpack runtime)"); - } - } - }, - get: function get() { - return realChunkObject; - }, - configurable: true, - }); -} - -export function matchModule(moduleStr, queryArg) { - const queryArray = queryArg instanceof Array ? queryArg : [queryArg]; - return queryArray.some((query) => { - // we like our microoptimizations https://jsben.ch/Zk8aw - if (query instanceof RegExp) { - return query.test(moduleStr); - } else { - return moduleStr.includes(query); - } - }); -} - -function patchModules(modules) { - for (const id in modules) { - let funcStr = Function.prototype.toString.apply(modules[id]); - - const matchingPatches = []; - for (const patch of patchesToApply) { - if (matchModule(funcStr, patch.find)) { - matchingPatches.push(patch); - patchesToApply.delete(patch); - } - } - - for (const patch of matchingPatches) { - funcStr = funcStr.replace(patch.replace.match, patch.replace.replacement); - } - - if (matchingPatches.length > 0 || config.inspectAll) { - const debugString = "Patched by: " + matchingPatches.map((patch) => patch.name).join(", "); - - modules[id] = new Function( - "module", - "exports", - "webpackRequire", - `(${funcStr}).apply(this, arguments)\n// ${debugString}\n//# sourceURL=Webpack-Module-${id}`, - ); - modules[id].__wpt_patched = true; - } - - modules[id].__wpt_funcStr = funcStr; - modules[id].__wpt_processed = true; - } -} - -function injectModules(chunk) { - const readyModules = new Set(); - - for (const moduleToInject of modulesToInject) { - if (moduleToInject?.needs?.size > 0) { - for (const need of moduleToInject.needs) { - for (const wpModule of Object.entries(chunk[1])) { - // match { moduleId: "id" } as well as strings and regex - if ((need?.moduleId && wpModule[0] == need.moduleId) || matchModule(wpModule[1].__wpt_funcStr, need)) { - moduleToInject.needs.delete(need); - if (moduleToInject.needs.size == 0) { - readyModules.add(moduleToInject); - } - break; - } - } - } - } else { - readyModules.add(moduleToInject); - } - } - - if (readyModules.size > 0) { - const injectModules = {}; - const injectEntries = []; - - for (const readyModule of readyModules) { - modulesToInject.delete(readyModule); - injectModules[readyModule.name] = readyModule.run; - if (readyModule.entry) { - injectEntries.push(readyModule.name); - } - } - - // Convert array to object for named modules - if (chunk[1] instanceof Array) { - const origChunkArray = chunk[1]; - chunk[1] = {}; - origChunkArray.forEach((module, index) => { - chunk[1][index] = module; - }); - } - - // merge our modules with original modules - chunk[1] = Object.assign(chunk[1], injectModules); - - if (injectEntries.length > 0) { - switch (config.webpackVersion) { - case "5": - if (chunk[2]) { - const originalEntry = chunk[2]; - chunk[2] = function (webpackRequire) { - originalEntry.apply(this, arguments); - injectEntries.forEach(webpackRequire); - }; - } else { - chunk[2] = function (webpackRequire) { - injectEntries.forEach(webpackRequire); - }; - } - break; - case "4": - if (chunk[2]?.[0]) { - chunk[2]?.[0].concat([injectEntries]); - } else { - chunk[2] = [injectEntries]; - } - break; - } - } - console.log(chunk); - } -} diff --git a/src/wpTools.js b/src/wpTools.js index 78fb6b5..50f9e7e 100644 --- a/src/wpTools.js +++ b/src/wpTools.js @@ -1,65 +1,73 @@ -import { matchModule } from "./patcher"; +import matchModule from "./matchModule"; -export default function wpTools(module, exports, webpackRequire) { - // https://github.com/webpack/webpack/blob/main/lib/RuntimeGlobals.js - // modules functions: webpackRequire.m - // modules require cache (exports): webpackRequire.c +export function getWpToolsFunc(chunkObject) { + function wpTools(module, exports, webpackRequire) { + // https://github.com/webpack/webpack/blob/main/lib/RuntimeGlobals.js + // modules functions: webpackRequire.m + // modules require cache (exports): webpackRequire.c - // TODO: recurse in objects - function findModulesByExports(keysArg) { - const keys = keysArg instanceof Array ? keysArg : [keysArg]; - return Object.entries(webpackRequire.c) - .filter(([moduleId, exportCache]) => { - return !keys.some((searchKey) => { - return !( - exportCache != undefined && - exportCache != window && - (exports?.[searchKey] || exports?.default?.[searchKey]) - ); + // TODO: recurse in objects + function findModulesByExports(keysArg) { + const keys = keysArg instanceof Array ? keysArg : [keysArg]; + return Object.entries(webpackRequire.c) + .filter(([moduleId, exportCache]) => { + return !keys.some((searchKey) => { + return !( + exportCache != undefined && + exportCache != window && + (exports?.[searchKey] || exports?.default?.[searchKey]) + ); + }); + }) + .map(([moduleId, exportCache]) => { + return exportCache; }); - }) - .map(([moduleId, exportCache]) => { - return exportCache; - }); - } + } - function findModulesByMatches(search) { - return Object.entries(webpackRequire.m) - .filter(([moduleId, moduleFunc]) => { - const funcStr = Function.prototype.toString.apply(moduleFunc); - return matchModule(funcStr, search); - }) - .map(([moduleId, moduleFunc]) => { - return { - id: moduleId, - exports: webpackRequire(moduleId), - }; - }); - } + function findModulesByCode(search) { + return Object.entries(webpackRequire.m) + .filter(([moduleId, moduleFunc]) => { + const funcStr = Function.prototype.toString.apply(moduleFunc); + return matchModule(funcStr, search); + }) + .map(([moduleId, moduleFunc]) => { + return { + id: moduleId, + exports: webpackRequire(moduleId), + }; + }); + } - function inspectModule(moduleId) { - /* TODO: rewrap modules if not patched. - * This used to isolate modules like wrapping them in the patcher stage did, - * however this seems to have broken in newer browsers */ - return webpackRequire.m[moduleId]; - } + function inspectModule(moduleId) { + /* TODO: rewrap modules if not patched. + * This used to isolate modules like wrapping them in the patcher stage did, + * however this seems to have broken in newer browsers */ + return webpackRequire.m[moduleId]; + } + + // TODO: Obfuscated code helpers + // function findObjectFromKey(object, key) {} + // function findObjectFromValue(object, value) {} + // function findObjectFromKeyValuePair(object, key, value) {} + // function findFunctionByMatches(object, search) {} - // TODO: Obfuscated code helpers - // function findObjectFromKey(object, key) {} - // function findObjectFromValue(object, value) {} - // function findObjectFromKeyValuePair(object, key, value) {} - // function findFunctionByMatches(object, search) {} + // TODO: SWC helpers + // function getDefault() {} - // TODO: SWC helpers - // function getDefault() {} + window.wpTools = + window["wpTools_" + chunkObject] = + module.exports.default = + exports.default = + { + findModulesByExports, + findModulesByCode, + inspectModule, + webpackRequire, + }; + } - window.wpTools = - module.exports.default = - exports.default = - { - findModulesByExports, - findModulesByMatches, - inspectModule, - webpackRequire, - }; + // Mark as processed as to not loose scope if somehow passed to Patcher._patchModules() + wpTools.__wpt_processed = true; + + return wpTools } diff --git a/userscriptTemplate.js b/userscriptTemplate.js index 76d55a5..0d80873 100644 --- a/userscriptTemplate.js +++ b/userscriptTemplate.js @@ -16,7 +16,7 @@ wpToolsEverywhere: true, // not yet implemented siteConfigs: [ { - _name: "twitter", // Not parsed, for documentation purposes + name: "twitter", // Not parsed, for documentation purposes matchSites: ["twitter.com"], // String or Array of strings of sites to inject on. Matches globs (eg. *.discord.com) chunkObject: "webpackChunk_twitter_responsive_web", // Name of webpack chunk object to intercept webpackVersion: "5", // Version of webpack used to compile. TODO: Document this. Supported are 4 and 5