diff --git a/.travis.yml b/.travis.yml deleted file mode 100644 index 64e56fa3..00000000 --- a/.travis.yml +++ /dev/null @@ -1,31 +0,0 @@ -language: node_js - -node_js: -- "node" - -git: - autocrlf: true - -branches: - only: - - development - -install: - - npm ci - -script: - - npm run lint-prod - - npm run test-prod - - npm run deploy - -before_deploy: "echo 'node_modules' > .gitignore" -deploy: - provider: pages - skip_cleanup: true - github_token: $TRAVIS_ACCESS - keep_history: true - local_dir: . - name: BetterDiscord Deployment - target_branch: gh-pages-development - on: - branch: development \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index b9346be4..c4ce4b5b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,46 @@ This changelog starts with the restructured 1.0.0 release that happened after context isolation changes. The changelogs here should more-or-less mirror the ones that get shown in the client but probably with less formatting and pizzazz. +## 1.8.0 + +### Added +- Proper updater system with UI. +- Tooltip component for plugins. +- Highly expanded plugin API. + +### Removed + +### Changed +- Reverted how internal webpack module searches are performed. +- New options for webpack searches. + +### Fixed +- Fixed many issues regarding memory leaks and out-of-memory errors! +- Fixed a major issue where webpack searches would iterate by default. +- Fixed an issue with `byStrings` and `combine` filters in the API. +- Fixed an issue where searching for multiple modules could yield the same module multiple times. +- Fixed an issue where misnamed addon files could prevent startup. +- Fixed an issue where the `request` module would not follow redirects. +- Fixed an issue where certain modals could crash the client. +- Fixed an issue where toasts would not show on the crash screen. + +## 1.7.0 + +### Added +- Polyfill for certain node modules. + +### Removed +- Proxy protection for certain modules. + +### Changed +- Changed how internal webpack module searches are performed. +- New location for public servers button. +- Switch to pnpm with workspaces. +- Improved startup errors. + +### Fixed +- Fixed several issues for Discord's internal changes. + ## 1.6.3 ### Added diff --git a/package.json b/package.json index cd8e8dad..46f41493 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "betterdiscord", - "version": "1.7.0", + "version": "1.8.0", "description": "Enhances Discord by adding functionality and themes.", "main": "src/index.js", "scripts": { @@ -14,7 +14,7 @@ "lint": "eslint --ext .js common/ && pnpm --filter injector lint && pnpm --filter preload lint && pnpm --filter renderer lint-js", "test": "mocha --require @babel/register --recursive \"./tests/renderer/*.js\"", "dist": "pnpm run build-prod && node scripts/pack.js", - "api": "jsdoc -X renderer/src/modules/pluginapi.js > jsdoc-ast.json" + "api": "jsdoc -X -r renderer/src/modules/api/ > jsdoc-ast.json" }, "devDependencies": { "asar": "^3.2.0", diff --git a/preload/src/api/filesystem.js b/preload/src/api/filesystem.js index a5829ed6..69f0b006 100644 --- a/preload/src/api/filesystem.js +++ b/preload/src/api/filesystem.js @@ -44,6 +44,14 @@ export function renameSync(oldPath, newPath) { return fs.renameSync(oldPath, newPath); } +export function rm(pathToFile) { + return fs.rmSync(pathToFile); +} + +export function rmSync(pathToFile) { + return fs.rmSync(pathToFile); +} + export function unlinkSync(fileToDelete) { return fs.unlinkSync(fileToDelete); } diff --git a/renderer/src/builtins/customcss.js b/renderer/src/builtins/customcss.js index f2e4a8da..6f830f1c 100644 --- a/renderer/src/builtins/customcss.js +++ b/renderer/src/builtins/customcss.js @@ -39,7 +39,7 @@ export default new class CustomCSS extends Builtin { if (this.isDetached) return; if (this.nativeOpen) return this.openNative(); else if (this.startDetached) return this.openDetached(this.savedCss); - const settingsView = Utilities.findInRenderTree(thisObject._reactInternals, m => m && m.onSetSection, {walkable: ["child", "memoizedProps", "props", "children"]}); + const settingsView = Utilities.findInTree(thisObject._reactInternals, m => m && m.onSetSection, {walkable: ["child", "memoizedProps", "props", "children"]}); if (settingsView && settingsView.onSetSection) settingsView.onSetSection(this.id); } }); diff --git a/renderer/src/builtins/developer/debuglogs.js b/renderer/src/builtins/developer/debuglogs.js index 03404328..d10a58bb 100644 --- a/renderer/src/builtins/developer/debuglogs.js +++ b/renderer/src/builtins/developer/debuglogs.js @@ -2,7 +2,6 @@ const fs = require("fs"); const path = require("path"); import Builtin from "../../structs/builtin"; import DataStore from "../../modules/datastore"; -import Utilities from "../../modules/utilities"; const timestamp = () => new Date().toISOString().replace("T", " ").replace("Z", ""); @@ -18,6 +17,11 @@ const getCircularReplacer = () => { }; }; +const occurrences = (source, substring) => { + const regex = new RegExp(substring, "g"); + return (source.match(regex) || []).length; +}; + export default new class DebugLogs extends Builtin { get name() {return "DebugLogs";} get category() {return "developer";} @@ -45,7 +49,7 @@ export default new class DebugLogs extends Builtin { for (let i = 0; i < args.length; i++) { const arg = args[i]; if (typeof(arg) === "string") { - const styleCount = Utilities.occurrences(arg, "%c"); + const styleCount = occurrences(arg, "%c"); sanitized.push(arg.replace(/%c/g, "")); if (styleCount > 0) i += styleCount; } diff --git a/renderer/src/builtins/emotes/emotemenu.js b/renderer/src/builtins/emotes/emotemenu.js index 0ff37072..f1c37bfd 100644 --- a/renderer/src/builtins/emotes/emotemenu.js +++ b/renderer/src/builtins/emotes/emotemenu.js @@ -20,7 +20,7 @@ export default new class EmoteMenu extends Builtin { enabled() { this.after(EmojiPicker, "type", (_, __, returnValue) => { - const originalChildren = Utilities.getNestedProp(returnValue, "props.children.props.children"); + const originalChildren = returnValue?.props?.children?.props?.children; if (!originalChildren || originalChildren.__patched) return; const activePicker = useExpressionPickerStore((state) => state.activeView); @@ -30,8 +30,8 @@ export default new class EmoteMenu extends Builtin { // Attach a try {} catch {} because this might crash the user. try { - const head = Utilities.findInReactTree(childrenReturn, (e) => e?.role === "tablist")?.children; - const body = Utilities.findInReactTree(childrenReturn, (e) => e?.[0]?.type === "nav"); + const head = Utilities.findInTree(childrenReturn, (e) => e?.role === "tablist", {walkable: ["props", "children", "return", "stateNode"]})?.children; + const body = Utilities.findInTree(childrenReturn, (e) => e?.[0]?.type === "nav", {walkable: ["props", "children", "return", "stateNode"]}); if (!head || !body) return childrenReturn; const isActive = activePicker == "bd-emotes"; diff --git a/renderer/src/builtins/emotes/emotes.js b/renderer/src/builtins/emotes/emotes.js index a8e0bad7..1afef9f1 100644 --- a/renderer/src/builtins/emotes/emotes.js +++ b/renderer/src/builtins/emotes/emotes.js @@ -1,7 +1,7 @@ import Builtin from "../../structs/builtin"; import {EmoteConfig, Config} from "data"; -import {Utilities, WebpackModules, DataStore, DiscordModules, Events, Settings, Strings} from "modules"; +import {WebpackModules, DataStore, DiscordModules, Events, Settings, Strings} from "modules"; import BDEmote from "../../ui/emote"; import Modals from "../../ui/modals"; import Toasts from "../../ui/toasts"; @@ -24,6 +24,10 @@ const Emotes = { FrankerFaceZ: {} }; +const escape = (s) => { + return s.replace(/[-/\\^$*+?.()|[\]{}]/g, "\\$&"); +}; + const blocklist = []; const overrides = ["twitch", "subscriber", "bttv", "ffz"]; const modifiers = ["flip", "spin", "pulse", "spin2", "spin3", "1spin", "2spin", "3spin", "tr", "bl", "br", "shake", "shake2", "shake3", "flap"]; @@ -53,7 +57,7 @@ const modifiers = ["flip", "spin", "pulse", "spin2", "spin3", "1spin", "2spin", getUrl(category, name) {return EmoteURLs[category].format({id: Emotes[category][name]});} getCategory(category) {return Emotes[category];} - getRemoteFile(category) {return Utilities.repoUrl(`assets/emotes/${category.toLowerCase()}.json`);} + getRemoteFile(category) {return `https://cdn.staticaly.com/gh/BetterDiscord/BetterDiscord/${Config.hash}/assets/emotes/${category.toLowerCase()}.json`;} initialize() { super.initialize(); @@ -154,7 +158,7 @@ const modifiers = ["flip", "spin", "pulse", "spin2", "spin3", "1spin", "2spin", } if (!Emotes[current][emoteName]) continue; - const results = nodes[n].match(new RegExp(`([\\s]|^)${Utilities.escape(emoteModifier ? emoteName + ":" + emoteModifier : emoteName)}([\\s]|$)`)); + const results = nodes[n].match(new RegExp(`([\\s]|^)${escape(emoteModifier ? emoteName + ":" + emoteModifier : emoteName)}([\\s]|$)`)); if (!results) continue; const pre = nodes[n].substring(0, results.index + results[1].length); const post = nodes[n].substring(results.index + results[0].length - results[2].length); diff --git a/renderer/src/builtins/general/publicservers.js b/renderer/src/builtins/general/publicservers.js index 395acb4b..7dc3dfe5 100644 --- a/renderer/src/builtins/general/publicservers.js +++ b/renderer/src/builtins/general/publicservers.js @@ -1,5 +1,5 @@ import Builtin from "../../structs/builtin"; -import {DiscordModules, WebpackModules, Strings, DOM, React} from "modules"; +import {DiscordModules, WebpackModules, Strings, DOMManager, React, ReactDOM} from "modules"; import PublicServersMenu from "../../ui/publicservers/menu"; import Globe from "../../ui/icons/globe"; @@ -44,49 +44,87 @@ export default new class PublicServers extends Builtin { get id() {return "publicServers";} enabled() { - // let target = null; - // WebpackModules.getModule((_, m) => { - // if (m.exports?.toString().includes("privateChannelIds")) { - // target = m.exports; - // } - // }); - // if (!target || !target.Z) return; - // const PrivateChannelListComponents = WebpackModules.getByProps("LinkButton"); - // this.after(target, "Z", (_, __, returnValue) => { - // const destination = returnValue?.props?.children?.props?.children; - // if (!destination || !Array.isArray(destination)) return; - // if (destination.find(b => b?.props?.children?.props?.id === "public-server-button")) return; + const PrivateChannelList = WebpackModules.getModule(m => m?.toString().includes("privateChannelIds"), {defaultExport: false}); + if (!PrivateChannelList || !PrivateChannelList.Z) return this.warn("Could not find PrivateChannelList", PrivateChannelList); + const PrivateChannelButton = WebpackModules.getModule(m => m?.prototype?.render?.toString().includes("linkButton"), {searchExports: true}); + if (!PrivateChannelButton) return this.warn("Could not find PrivateChannelButton", PrivateChannelButton); + + this.after(PrivateChannelList, "Z", (_, __, returnValue) => { + const destination = returnValue?.props?.children?.props?.children; + if (!destination || !Array.isArray(destination)) return; + if (destination.find(b => b?.props?.children?.props?.id === "public-servers-button")) return; // If it exists, don't try to add again - // destination.push( - // React.createElement(ErrorBoundary, null, - // React.createElement(PrivateChannelListComponents.LinkButton, - // { - // id: "public-server-button", - // onClick: () => this.openPublicServers(), - // text: "Public Servers", - // icon: () => React.createElement(Globe, {color: "currentColor"}) - // } - // ) - // ) - // ); - // }); + destination.push( + React.createElement(ErrorBoundary, null, + React.createElement(PrivateChannelButton, + { + id: "public-servers-button", + onClick: () => this.openPublicServers(), + text: "Public Servers", + icon: () => React.createElement(Globe, {color: "currentColor"}) + } + ) + ) + ); + }); + + /** + * On being first enabled, we have no way of forceUpdating the list, + * so clone and modify an existing button and add it to the end + * of the button list. + */ + const header = document.querySelector(`[class*="privateChannelsHeaderContainer-"]`); + if (!header) return; // No known element + const oldButton = header.previousElementSibling; + if (!oldButton.className.includes("channel-")) return; // Not what we expected to be there + + // Clone existing button and set click handler + const newButton = oldButton.cloneNode(true); + newButton.addEventListener("click", (event) => { + event.stopImmediatePropagation(); + event.stopPropagation(); + event.preventDefault(); + this.openPublicServers(); + }); + + // Remove existing route and id + const aSlot = newButton.querySelector("a"); + aSlot.href = ""; + aSlot.dataset.listItemId = "public-servers"; + + // Remove any badges + const badge = newButton.querySelector(`[class*="premiumTrial"]`); + badge?.remove?.(); + + // Render our icon in the avatar slot + const avatarSlot = newButton.querySelector(`[class*="avatar-"]`); + avatarSlot.replaceChildren(); + ReactDOM.render(React.createElement(Globe, {color: "currentColor"}), avatarSlot); + DOMManager.onRemoved(avatarSlot, () => ReactDOM.unmountComponentAtNode(avatarSlot)); + + // Replace the existing name + const nameSlot = newButton.querySelector(`[class*="name-"]`); + nameSlot.textContent = "Public Servers"; + + // Insert before the header, end of the list + header.parentNode.insertBefore(newButton, header); } disabled() { - // this.unpatchAll(); + this.unpatchAll(); // DOM.query("#bd-pub-li").remove(); } async _appendButton() { await new Promise(r => setTimeout(r, 1000)); - const existing = DOM.query("#bd-pub-li"); + const existing = document.querySelector("#bd-pub-li"); if (existing) return; - const guilds = DOM.query(`.${DiscordModules.GuildClasses.guilds} .${DiscordModules.GuildClasses.listItem}`); + const guilds = document.querySelector(`.${DiscordModules.GuildClasses.guilds} .${DiscordModules.GuildClasses.listItem}`); if (!guilds) return; - DOM.after(guilds, this.button); + guilds.parentNode.insertBefore(this.button, guilds.nextSibling); } openPublicServers() { @@ -94,8 +132,8 @@ export default new class PublicServers extends Builtin { } get button() { - const btn = DOM.createElement(`
`); - const label = DOM.createElement(`
${Strings.PublicServers.button}
`); + const btn = DOMManager.parseHTML(`
`); + const label = DOMManager.parseHTML(`
${Strings.PublicServers.button}
`); label.addEventListener("click", () => {this.openPublicServers();}); btn.append(label); return btn; diff --git a/renderer/src/data/changelog.js b/renderer/src/data/changelog.js index 8569a382..cec42bff 100644 --- a/renderer/src/data/changelog.js +++ b/renderer/src/data/changelog.js @@ -1,23 +1,31 @@ // fixed, improved, added, progress export default { - description: "BetterDiscord is alive! At least... _sorta_.", + description: "Big improvements have been made!", changes: [ { - title: "Known Issues", + title: "What's New?", type: "improved", items: [ - "**Many many plugins are either completely broken or missing functionality.** Please refer to the respective developers for ETAs.", - "The Twitch Emote system is completely broken, and there is no ETA on being fixed.", - "The Public Servers module is also broken with no ETA for a fix.", + "BetterDiscord now has a built-in update system to help update broken plugins and themes.", + "New API options for plugin developers.", + "`Patcher` now works with the configurable getters.", + "A new tooltip component for use in plugins.", + "The plugin API now includes context menu capabilties.", + "Public servers button has found a new home on your Discord homepage above the DM list." ] }, { - title: "Important News!", + title: "Bug Fixes", type: "fixed", items: [ - "Due to recent and upcoming changes, BetterDiscord is going to go through a rewrite.", - "There is no ETA or timeline for this rewrite.", - "We will continue to try and __maintain__ this version of BetterDiscord without adding new features." + "Fixed many issues regarding memory leaks and out-of-memory errors!", + "Fixed a major issue where webpack searches would iterate by default.", + "Fixed an issue with `byStrings` and `combine` filters in the API.", + "Fixed an issue where searching for multiple modules could yield the same module multiple times.", + "Fixed an issue where misnamed addon files could prevent startup.", + "Fixed an issue where the `request` module would not follow redirects.", + "Fixed an issue where certain modals could crash the client.", + "Fixed an issue where toasts would not show on the crash screen." ] } ] diff --git a/renderer/src/data/settings.js b/renderer/src/data/settings.js index 5fb700ea..0209d474 100644 --- a/renderer/src/data/settings.js +++ b/renderer/src/data/settings.js @@ -70,5 +70,31 @@ export default [ {type: "switch", id: "inspectElement", value: false, enableWith: "devTools"}, {type: "switch", id: "devToolsWarning", value: false, enableWith: "devTools"}, ] - } + }, + // { + // type: "category", + // id: "debug", + // name: "Debug", + // collapsible: true, + // shown: true, + // settings: [ + // {name: "Text test", note: "Just testing it", type: "text", id: "texttest", value: ""}, + // {name: "Slider test", note: "Just testing it", type: "slider", id: "slidertest", value: 30, min: 20, max: 50, step: 10}, + // { + // name: "Radio test", + // note: "Just testing it", + // type: "radio", + // id: "radiotest", + // value: "test", + // options: [ + // {name: "First", value: 30, description: "little hint"}, + // {name: "IDK", value: "test", description: "who cares"}, + // {name: "Something", value: 666, description: "something else"}, + // {name: "Last", value: "last", description: "nothing more to add"} + // ] + // }, + // {name: "Keybind test", note: "Just testing it", type: "keybind", id: "keybindtest", value: ["Control", "H"]}, + // {name: "Color test", note: "Just testing it", type: "color", id: "colortest", value: "#ff0000", defaultValue: "#ffffff"}, + // ] + // } ]; \ No newline at end of file diff --git a/renderer/src/index.js b/renderer/src/index.js index 21208310..674b2d6d 100644 --- a/renderer/src/index.js +++ b/renderer/src/index.js @@ -2,11 +2,15 @@ import require from "./polyfill"; // eslint-disable-line no-unused-vars import secure from "./secure"; import LoadingIcon from "./loadingicon"; import BetterDiscord from "./modules/core"; -import BdApi from "./modules/pluginapi"; +import BdApi from "./modules/api/index"; // Perform some setup secure(); -window.BdApi = BdApi; +Object.defineProperty(window, "BdApi", { + value: BdApi, + writable: false, + configurable: false +}); window.global = window; // Add loading icon at the bottom right diff --git a/renderer/src/modules/addonmanager.js b/renderer/src/modules/addonmanager.js index 7eed3965..245c0a76 100644 --- a/renderer/src/modules/addonmanager.js +++ b/renderer/src/modules/addonmanager.js @@ -1,4 +1,3 @@ -import Utilities from "./utilities"; import Logger from "common/logger"; import Settings from "./settingsmanager"; import Events from "./emitter"; @@ -138,9 +137,14 @@ export default class AddonManager { parseOldMeta(fileContent, filename) { const meta = fileContent.split("\n")[0]; const metaData = meta.substring(meta.lastIndexOf("//META") + 6, meta.lastIndexOf("*//")); - const parsed = Utilities.testJSON(metaData); - if (!parsed) throw new AddonError(filename, filename, Strings.Addons.metaError, {message: "", stack: meta}, this.prefix); - if (!parsed.name) throw new AddonError(filename, filename, Strings.Addons.missingNameData, {message: "", stack: meta}, this.prefix); + let parsed = null; + try { + parsed = JSON.parse(metaData); + } + catch (err) { + throw new AddonError(filename, filename, Strings.Addons.metaError, err, this.prefix); + } + if (!parsed || !parsed.name) throw new AddonError(filename, filename, Strings.Addons.missingNameData, {message: "", stack: meta}, this.prefix); parsed.format = "json"; return parsed; } @@ -202,6 +206,7 @@ export default class AddonManager { if (partialAddon) { partialAddon.partial = true; this.state[partialAddon.id] = false; + this.emit("loaded", partialAddon); } return e; } @@ -211,11 +216,12 @@ export default class AddonManager { if (error) { this.state[addon.id] = false; addon.partial = true; + this.emit("loaded", addon); return error; } if (shouldToast) Toasts.success(`${addon.name} v${addon.version} was loaded.`); - this.emit("loaded", addon.id); + this.emit("loaded", addon); if (!this.state[addon.id]) return this.state[addon.id] = false; return this.startAddon(addon); @@ -228,7 +234,7 @@ export default class AddonManager { if (this.state[addon.id]) isReload ? this.stopAddon(addon) : this.disableAddon(addon); this.addonList.splice(this.addonList.indexOf(addon), 1); - this.emit("unloaded", addon.id); + this.emit("unloaded", addon); if (shouldToast) Toasts.success(`${addon.name} was unloaded.`); return true; } diff --git a/renderer/src/modules/addonupdater.js b/renderer/src/modules/addonupdater.js deleted file mode 100644 index 91114cf6..00000000 --- a/renderer/src/modules/addonupdater.js +++ /dev/null @@ -1,96 +0,0 @@ -import request from "request"; -import fileSystem from "fs"; -import {Config} from "data"; -import path from "path"; - -import PluginManager from "./pluginmanager"; -import ThemeManager from "./thememanager"; - -import Toasts from "../ui/toasts"; -import Notices from "../ui/notices"; -import Logger from "common/logger"; - -const base = "https://api.betterdiscord.app/v2/store/"; -const route = r => `${base}${r}`; -const redirect = addonId => `https://betterdiscord.app/gh-redirect?id=${addonId}`; - -const getJSON = url => { - return new Promise(resolve => { - request(url, (error, _, body) => { - if (error) return resolve([]); - resolve(JSON.parse(body)); - }); - }); -}; - -const reducer = (acc, addon) => { - if (addon.version === "Unknown") return acc; - acc[addon.file_name] = {name: addon.name, version: addon.version, id: addon.id, type: addon.type}; - return acc; -}; - -export default class AddonUpdater { - - static async initialize() { - this.cache = {}; - this.shown = false; - this.pending = []; - - const pluginData = await getJSON(route("plugins")); - const themeData = await getJSON(route("themes")); - - pluginData.reduce(reducer, this.cache); - themeData.reduce(reducer, this.cache); - - for (const addon of PluginManager.addonList) this.checkForUpdate(addon.filename, addon.version); - for (const addon of ThemeManager.addonList) this.checkForUpdate(addon.filename, addon.version); - - this.showUpdateNotice(); - } - - static clearPending() { - this.pending.splice(0, this.pending.length); - } - - static async checkForUpdate(filename, currentVersion) { - const info = this.cache[path.basename(filename)]; - if (!info) return; - const hasUpdate = info.version > currentVersion; - if (!hasUpdate) return; - this.pending.push(filename); - } - - static async updatePlugin(filename) { - const info = this.cache[filename]; - request(redirect(info.id), (error, _, body) => { - if (error) { - Logger.stacktrace("AddonUpdater", `Failed to download body for ${info.id}:`, error); - return; - } - - const file = path.join(path.resolve(Config.dataPath, info.type + "s"), filename); - fileSystem.writeFile(file, body.toString(), () => { - Toasts.success(`${info.name} has been updated to version ${info.version}!`); - }); - }); - } - - static showUpdateNotice() { - if (this.shown || !this.pending.length) return; - this.shown = true; - const close = Notices.info(`BetterDiscord has found updates for ${this.pending.length} of your plugins and themes!`, { - timeout: 0, - buttons: [{ - label: "Update Now", - onClick: async () => { - for (const name of this.pending) await this.updatePlugin(name); - close(); - } - }], - onClose: () => { - this.shown = false; - this.clearPending(); - } - }); - } -} diff --git a/renderer/src/modules/api/addonapi.js b/renderer/src/modules/api/addonapi.js new file mode 100644 index 00000000..295551ab --- /dev/null +++ b/renderer/src/modules/api/addonapi.js @@ -0,0 +1,64 @@ +/** + * `AddonAPI` is a utility class for working with plugins and themes. Instances are accessible through the {@link BdApi}. + * @name AddonAPI + */ + class AddonAPI { + #manager; + + constructor(manager) {this.#manager = manager;} + + /** + * The path to the addon folder. + * @type string + */ + get folder() {return this.#manager.addonFolder;} + + /** + * Determines if a particular adon is enabled. + * @param {string} idOrFile Addon id or filename. + * @returns {boolean} + */ + isEnabled(idOrFile) {return this.#manager.isEnabled(idOrFile);} + + /** + * Enables the given addon. + * @param {string} idOrFile Addon id or filename. + */ + enable(idOrAddon) {return this.#manager.enableAddon(idOrAddon);} + + /** + * Disables the given addon. + * @param {string} idOrFile Addon id or filename. + */ + disable(idOrAddon) {return this.#manager.disableAddon(idOrAddon);} + + /** + * Toggles if a particular addon is enabled. + * @param {string} idOrFile Addon id or filename. + */ + toggle(idOrAddon) {return this.#manager.toggleAddon(idOrAddon);} + + /** + * Reloads if a particular addon is enabled. + * @param {string} idOrFile Addon id or filename. + */ + reload(idOrFileOrAddon) {return this.#manager.reloadAddon(idOrFileOrAddon);} + + /** + * Gets a particular addon. + * @param {string} idOrFile Addon id or filename. + * @returns {object} Addon instance + */ + get(idOrFile) {return this.#manager.getAddon(idOrFile);} + + /** + * Gets all addons of this type. + * @returns {Array} Array of all addon instances + */ + getAll() {return this.#manager.addonList.map(a => this.#manager.getAddon(a.id));} +} + +Object.freeze(AddonAPI); +Object.freeze(AddonAPI.prototype); + +export default AddonAPI; \ No newline at end of file diff --git a/renderer/src/modules/api/contextmenu.js b/renderer/src/modules/api/contextmenu.js new file mode 100644 index 00000000..9e0bd2c5 --- /dev/null +++ b/renderer/src/modules/api/contextmenu.js @@ -0,0 +1,312 @@ +import WebpackModules from "../webpackmodules"; +import Patcher from "../patcher"; +import Logger from "common/logger"; +import {React} from "../modules"; + +const MenuComponents = (() => { + const out = {}; + const componentMap = { + separator: "Separator", + checkbox: "CheckboxItem", + radio: "RadioItem", + control: "ControlItem", + groupstart: "Group", + customitem: "Item" + }; + + let ContextMenuIndex = null; + const ContextMenuModule = WebpackModules.getModule((m, _, id) => Object.values(m).some(v => v?.FLEXIBLE) && (ContextMenuIndex = id), {searchExports: false}); + const rawMatches = WebpackModules.require.m[ContextMenuIndex].toString().matchAll(/if\(\w+\.type===\w+\.(\w+)\).+?type:"(.+?)"/g); + + out.Menu = Object.values(ContextMenuModule).find(v => v.toString().includes(".isUsingKeyboardNavigation")); + + for (const [, identifier, type] of rawMatches) { + out[componentMap[type]] = ContextMenuModule[identifier]; + } + + return out; +})(); + +const ContextMenuActions = (() => { + const out = {}; + + const ActionsModule = WebpackModules.getModule(m => Object.values(m).some(m => typeof m === "function" && m.toString().includes("CONTEXT_MENU_CLOSE")), {searchExports: false}); + + for (const key of Object.keys(ActionsModule)) { + if (ActionsModule[key].toString().includes("CONTEXT_MENU_CLOSE")) { + out.closeContextMenu = ActionsModule[key]; + } + else if (ActionsModule[key].toString().includes("renderLazy")) { + out.openContextMenu = ActionsModule[key]; + } + } + + return out; +})(); + +class MenuPatcher { + static MAX_PATCH_ITERATIONS = 10; + static patches = {}; + static subPatches = new WeakMap(); + + static initialize() { + const {module, key} = (() => { + const module = WebpackModules.getModule(m => Object.values(m).some(v => typeof v === "function" && v.toString().includes("CONTEXT_MENU_CLOSE")), {searchExports: false}); + const key = Object.keys(module).find(key => module[key].length === 3); + + return {module, key}; + })(); + + Patcher.before("ContextMenuPatcher", module, key, (_, methodArguments) => { + const promise = methodArguments[1]; + methodArguments[1] = async function () { + const render = await promise.apply(this, arguments); + + return props => { + const res = render(props); + + if (res?.props.navId) { + MenuPatcher.runPatches(res.props.navId, res, props); + } + else if (typeof res?.type === "function") { + MenuPatcher.patchRecursive(res, "type"); + } + + return res; + }; + }; + }); + } + + static patchRecursive(target, method, iteration = 0) { + if (iteration >= this.MAX_PATCH_ITERATIONS) return; + + const proxyFunction = this.subPatches.get(target[method]) ?? (() => { + const originalFunction = target[method]; + function patch() { + const res = originalFunction.apply(this, arguments); + + if (!res) return res; + + if (res.props?.navId) { + MenuPatcher.runPatches(res.props.navId, res, arguments[0]); + } + else { + const layer = res.props.children ? res.props.children : res; + + if (typeof layer?.type == "function") { + MenuPatcher.patchRecursive(layer, "type", ++iteration); + } + } + + return res; + } + + patch._originalFunction = originalFunction; + Object.assign(patch, originalFunction); + this.subPatches.set(originalFunction, patch); + + return patch; + })(); + + target[method] = proxyFunction; + } + + static runPatches(id, res, props) { + if (!this.patches[id]) return; + + for (const patch of this.patches[id]) { + try { + patch(res, props); + } + catch (error) { + Logger.error("ContextMenu~runPatches", `Could not run ${id} patch for`, patch, error); + } + } + } + + static patch(id, callback) { + this.patches[id] ??= new Set(); + this.patches[id].add(callback); + } + + static unpatch(id, callback) { + this.patches[id]?.delete(callback); + } +} + +/** + * `ContextMenu` is a module to help patch and create context menus. Instance is accessible through the {@link BdApi}. + * @type ContextMenu + * @summary {@link ContextMenu} is a utility class for interacting with React internals. + * @name ContextMenu + */ +class ContextMenu { + + /** + * Allows you to patch a given context menu. Acts as a wrapper around the `Patcher`. + * + * @param {string} navId Discord's internal navId used to identify context menus + * @param {function} callback callback function that accepts the react render tree + * @returns {function} a function that automatically unpatches + */ + patch(navId, callback) { + MenuPatcher.patch(navId, callback); + + return () => MenuPatcher.unpatch(navId, callback); + } + + /** + * Allows you to remove the patch added to a given context menu. + * + * @param {string} navId the original navId from patching + * @param {function} callback the original callback from patching + */ + unpatch(navId, callback) { + MenuPatcher.unpatch(navId, callback); + } + + /** + * Builds a single menu item. The only prop shown here is the type, the rest should + * match the actual component being built. View those to see what options exist + * for each, they often have less in common than you might think. + * + * @param {object} props - props used to build the item + * @param {string} [props.type="text"] - type of the item, options: text, submenu, toggle, radio, custom, separator + * @returns {object} the created component + * + * @example + * // Creates a single menu item that prints "MENU ITEM" on click + * ContextMenu.buildItem({ + * label: "Menu Item", + * action: () => {console.log("MENU ITEM");} + * }); + * + * @example + * // Creates a single toggle item that starts unchecked + * // and print the new value on every toggle + * ContextMenu.buildItem({ + * type: "toggle", + * label: "Item Toggle", + * checked: false, + * action: (newValue) => {console.log(newValue);} + * }); + */ + buildItem(props) { + const {type} = props; + if (type === "separator") return React.createElement(MenuComponents.Separator); + + let Component = MenuComponents.Item; + if (type === "submenu") { + if (!props.children) props.children = this.buildMenuChildren(props.render || props.items); + } + else if (type === "toggle" || type === "radio") { + Component = type === "toggle" ? MenuComponents.CheckboxItem : MenuComponents.RadioItem; + if (props.active) props.checked = props.active; + } + else if (type === "control") { + Component = MenuComponents.ControlItem; + } + if (!props.id) props.id = `${props.label.replace(/^[^a-z]+|[^\w-]+/gi, "-")}`; + if (props.danger) props.color = "colorDanger"; + if (props.onClick && !props.action) props.action = props.onClick; + props.extended = true; + + return React.createElement(Component, props); + } + + /** + * Creates the all the items **and groups** of a context menu recursively. + * There is no hard limit to the number of groups within groups or number + * of items in a menu. + * @param {Array} setup - array of item props used to build items. See {@link ContextMenu.buildItem} + * @returns {Array} array of the created component + * + * @example + * // Creates a single item group item with a toggle item + * ContextMenu.buildMenuChildren([{ + * type: "group", + * items: [{ + * type: "toggle", + * label: "Item Toggle", + * active: false, + * action: (newValue) => {console.log(newValue);} + * }] + * }]); + * + * @example + * // Creates two item groups with a single toggle item each + * ContextMenu.buildMenuChildren([{ + * type: "group", + * items: [{ + * type: "toggle", + * label: "Item Toggle", + * active: false, + * action: (newValue) => { + * console.log(newValue); + * } + * }] + * }, { + * type: "group", + * items: [{ + * type: "toggle", + * label: "Item Toggle", + * active: false, + * action: (newValue) => { + * console.log(newValue); + * } + * }] + * }]); + */ + buildMenuChildren(setup) { + const mapper = s => { + if (s.type === "group") return buildGroup(s); + return this.buildItem(s); + }; + const buildGroup = function(group) { + const items = group.items.map(mapper).filter(i => i); + return React.createElement(MenuComponents.Group, null, items); + }; + return setup.map(mapper).filter(i => i); + } + + /** + * Creates the menu *component* including the wrapping `ContextMenu`. + * Calls {@link ContextMenu.buildMenuChildren} under the covers. + * Used to call in combination with {@link ContextMenu.open}. + * @param {Array} setup - array of item props used to build items. See {@link ContextMenu.buildMenuChildren} + * @returns {function} the unique context menu component + */ + buildMenu(setup) { + return (props) => {return React.createElement(MenuComponents.Menu, props, this.buildMenuChildren(setup));}; + } + + /** + * Function that allows you to open an entire context menu. Recommended to build the menu with this module. + * + * @param {MouseEvent} event - The context menu event. This can be emulated, requires target, and all X, Y locations. + * @param {function} menuComponent - Component to render. This can be any react component or output of {@link ContextMenu.buildMenu} + * @param {object} config - configuration/props for the context menu + * @param {string} [config.position="right"] - default position for the menu, options: "left", "right" + * @param {string} [config.align="top"] - default alignment for the menu, options: "bottom", "top" + * @param {function} [config.onClose] - function to run when the menu is closed + * @param {boolean} [config.noBlurEvent=false] - No clue + */ + open(event, menuComponent, config) { + return ContextMenuActions.openContextMenu(event, function(e) { + return React.createElement(menuComponent, Object.assign({}, e, {onClose: ContextMenuActions.closeContextMenu})); + }, config); + } + + /** + * Closes the current opened context menu immediately. + */ + close() {ContextMenuActions.closeContextMenu();} +} + +Object.assign(ContextMenu.prototype, MenuComponents); +Object.freeze(ContextMenu); +Object.freeze(ContextMenu.prototype); +MenuPatcher.initialize(); + +export default ContextMenu; diff --git a/renderer/src/modules/api/data.js b/renderer/src/modules/api/data.js new file mode 100644 index 00000000..1a048d20 --- /dev/null +++ b/renderer/src/modules/api/data.js @@ -0,0 +1,67 @@ +import DataStore from "../datastore"; + +/** + * `Data` is a simple utility class for the management of plugin data. An instance is available on {@link BdApi}. + * @type Data + * @summary {@link Data} is a simple utility class for the management of plugin data. + * @name Data + */ +class Data { + + #callerName = ""; + + constructor(callerName) { + if (!callerName) return; + this.#callerName = callerName; + } + + /** + * Saves JSON-serializable data. + * + * @param {string} pluginName Name of the plugin saving data + * @param {string} key Which piece of data to store + * @param {any} data The data to be saved + */ + save(pluginName, key, data) { + if (this.#callerName) { + data = key; + key = pluginName; + pluginName = this.#callerName; + } + return DataStore.setPluginData(pluginName, key, data); + } + + /** + * Loads previously stored data. + * + * @param {string} pluginName Name of the plugin loading data + * @param {string} key Which piece of data to load + * @returns {any} The stored data + */ + load(pluginName, key) { + if (this.#callerName) { + key = pluginName; + pluginName = this.#callerName; + } + return DataStore.getPluginData(pluginName, key); + } + + /** + * Deletes a piece of stored data, this is different than saving as null or undefined. + * + * @param {string} pluginName Name of the plugin deleting data + * @param {string} key Which piece of data to delete + */ + delete(pluginName, key) { + if (this.#callerName) { + key = pluginName; + pluginName = this.#callerName; + } + return DataStore.deletePluginData(pluginName, key); + } + +} + +Object.freeze(Data); +Object.freeze(Data.prototype); +export default Data; \ No newline at end of file diff --git a/renderer/src/modules/api/dom.js b/renderer/src/modules/api/dom.js new file mode 100644 index 00000000..cc285433 --- /dev/null +++ b/renderer/src/modules/api/dom.js @@ -0,0 +1,120 @@ +import DOMManager from "../dommanager"; + +/** + * `DOM` is a simple utility class for dom manipulation. An instance is available on {@link BdApi}. + * @type DOM + * @summary {@link DOM} is a simple utility class for dom manipulation. + * @name DOM + */ +class DOM { + + /** + * Current width of the user's screen. + * @type {number} + */ + get screenWidth() {return Math.max(document.documentElement.clientWidth, window.innerWidth || 0);} + + /** + * Current height of the user's screen. + * @type {number} + */ + get screenHeight() {return Math.max(document.documentElement.clientHeight, window.innerHeight || 0);} + + #callerName = ""; + + constructor(callerName) { + if (!callerName) return; + this.#callerName = callerName; + } + + /** + * Adds a ``)); - } - - /** - * Removes a style from the document. - * @param {string} id - original identifier used - */ - static removeStyle(id) { - const element = document.getElementById(id); - if (element) element.remove(); - } - - /** - * Adds/requires a remote script to be loaded - * @param {string} id - identifier to use for this script - * @param {string} url - url from which to load the script - * @returns {Promise} promise that resolves when the script is loaded - */ - static addScript(id, url) { - return new Promise(resolve => { - const script = document.createElement("script"); - script.id = id; - script.src = url; - script.type = "text/javascript"; - script.onload = resolve; - document.head.append(script); - }); - } - - /** - * Removes a remote script from the document. - * @param {string} id - original identifier used - */ - static removeScript(id) { - id = this.escapeID(id); - const element = document.getElementById(id); - if (element) element.remove(); - } - - // https://javascript.info/js-animation - static animate({timing = _ => _, update, duration}) { - const start = performance.now(); - - requestAnimationFrame(function animate(time) { - // timeFraction goes from 0 to 1 - let timeFraction = (time - start) / duration; - if (timeFraction > 1) timeFraction = 1; - - // calculate the current animation state - const progress = timing(timeFraction); - - update(progress); // draw it - - if (timeFraction < 1) { - requestAnimationFrame(animate); - } - - }); - } - - /** - * This is my shit version of not having to use `$` from jQuery. Meaning - * that you can pass a selector and it will automatically run {@link module:DOMTools.query}. - * It also means that you can pass a string of html and it will perform and return `parseHTML`. - * @see module:DOMTools.parseHTML - * @see module:DOMTools.query - * @param {string} selector - Selector to query or HTML to parse - * @returns {(DocumentFragment|NodeList|HTMLElement)} - Either the result of `parseHTML` or `query` - */ - static Q(selector) { - const element = this.parseHTML(selector); - const isHTML = element instanceof NodeList ? Array.from(element).some(n => n.nodeType === 1) : element.nodeType === 1; - if (isHTML) return element; - return this.query(selector); - } - - /** - * Essentially a shorthand for `document.querySelector`. If the `baseElement` is not provided - * `document` is used by default. - * @param {string} selector - Selector to query - * @param {Element} [baseElement] - Element to base the query from - * @returns {(Element|null)} - The found element or null if not found - */ - static query(selector, baseElement) { - if (!baseElement) baseElement = document; - return baseElement.querySelector(selector); - } - - /** - * Essentially a shorthand for `document.querySelectorAll`. If the `baseElement` is not provided - * `document` is used by default. - * @param {string} selector - Selector to query - * @param {Element} [baseElement] - Element to base the query from - * @returns {Array} - Array of all found elements - */ - static queryAll(selector, baseElement) { - if (!baseElement) baseElement = document; - return baseElement.querySelectorAll(selector); - } - - /** - * Parses a string of HTML and returns the results. If the second parameter is true, - * the parsed HTML will be returned as a document fragment {@see https://developer.mozilla.org/en-US/docs/Web/API/DocumentFragment}. - * This is extremely useful if you have a list of elements at the top level, they can then be appended all at once to another node. - * - * If the second parameter is false, then the return value will be the list of parsed - * nodes and there were multiple top level nodes, otherwise the single node is returned. - * @param {string} html - HTML to be parsed - * @param {boolean} [fragment=false] - Whether or not the return should be the raw `DocumentFragment` - * @returns {(DocumentFragment|NodeList|HTMLElement)} - The result of HTML parsing - */ - static parseHTML(html, fragment = false) { - const template = document.createElement("template"); - template.innerHTML = html; - const node = template.content.cloneNode(true); - if (fragment) return node; - return node.childNodes.length > 1 ? node.childNodes : node.childNodes[0]; - } - - /** Alternate name for {@link module:DOMTools.parseHTML} */ - static createElement(html, fragment = false) {return this.parseHTML(html, fragment);} - - /** - * Takes a string of html and escapes it using the brower's own escaping mechanism. - * @param {String} html - html to be escaped - */ - static escapeHTML(html) { - const textNode = document.createTextNode(""); - const spanElement = document.createElement("span"); - spanElement.append(textNode); - textNode.nodeValue = html; - return spanElement.innerHTML; - } - - /** - * Adds a list of classes from the target element. - * @param {Element} element - Element to edit classes of - * @param {...string} classes - Names of classes to add - * @returns {Element} - `element` to allow for chaining - */ - static addClass(element, ...classes) { - classes = classes.flat().filter(c => c); - for (let c = 0; c < classes.length; c++) classes[c] = classes[c].toString().split(" "); - classes = classes.flat().filter(c => c); - element.classList.add(...classes); - return element; - } - - /** - * Removes a list of classes from the target element. - * @param {Element} element - Element to edit classes of - * @param {...string} classes - Names of classes to remove - * @returns {Element} - `element` to allow for chaining - */ - static removeClass(element, ...classes) { - for (let c = 0; c < classes.length; c++) classes[c] = classes[c].toString().split(" "); - classes = classes.flat().filter(c => c); - element.classList.remove(...classes); - return element; - } - - /** - * When only one argument is present: Toggle class value; - * i.e., if class exists then remove it and return false, if not, then add it and return true. - * When a second argument is present: - * If the second argument evaluates to true, add specified class value, and if it evaluates to false, remove it. - * @param {Element} element - Element to edit classes of - * @param {string} classname - Name of class to toggle - * @param {boolean} [indicator] - Optional indicator for if the class should be toggled - * @returns {Element} - `element` to allow for chaining - */ - static toggleClass(element, classname, indicator) { - classname = classname.toString().split(" ").filter(c => c); - if (typeof(indicator) !== "undefined") classname.forEach(c => element.classList.toggle(c, indicator)); - else classname.forEach(c => element.classList.toggle(c)); - return element; - } - - /** - * Checks if an element has a specific class - * @param {Element} element - Element to edit classes of - * @param {string} classname - Name of class to check - * @returns {boolean} - `true` if the element has the class, `false` otherwise. - */ - static hasClass(element, classname) { - return classname.toString().split(" ").filter(c => c).every(c => element.classList.contains(c)); - } - - /** - * Replaces one class with another - * @param {Element} element - Element to edit classes of - * @param {string} oldName - Name of class to replace - * @param {string} newName - New name for the class - * @returns {Element} - `element` to allow for chaining - */ - static replaceClass(element, oldName, newName) { - element.classList.replace(oldName, newName); - return element; - } - - /** - * Appends `thisNode` to `thatNode` - * @param {Node} thisNode - Node to be appended to another node - * @param {Node} thatNode - Node for `thisNode` to be appended to - * @returns {Node} - `thisNode` to allow for chaining - */ - static appendTo(thisNode, thatNode) { - if (typeof(thatNode) == "string") thatNode = this.query(thatNode); - if (!thatNode) return null; - thatNode.append(thisNode); - return thisNode; - } - - /** - * Prepends `thisNode` to `thatNode` - * @param {Node} thisNode - Node to be prepended to another node - * @param {Node} thatNode - Node for `thisNode` to be prepended to - * @returns {Node} - `thisNode` to allow for chaining - */ - static prependTo(thisNode, thatNode) { - if (typeof(thatNode) == "string") thatNode = this.query(thatNode); - if (!thatNode) return null; - thatNode.prepend(thisNode); - return thisNode; - } - - /** - * Insert after a specific element, similar to jQuery's `thisElement.insertAfter(otherElement)`. - * @param {Node} thisNode - The node to insert - * @param {Node} targetNode - Node to insert after in the tree - * @returns {Node} - `thisNode` to allow for chaining - */ - static insertAfter(thisNode, targetNode) { - targetNode.parentNode.insertBefore(thisNode, targetNode.nextSibling); - return thisNode; - } - - /** - * Insert after a specific element, similar to jQuery's `thisElement.after(newElement)`. - * @param {Node} thisNode - The node to insert - * @param {Node} newNode - Node to insert after in the tree - * @returns {Node} - `thisNode` to allow for chaining - */ - static after(thisNode, newNode) { - thisNode.parentNode.insertBefore(newNode, thisNode.nextSibling); - return thisNode; - } - - /** - * Gets the next sibling element that matches the selector. - * @param {Element} element - Element to get the next sibling of - * @param {string} [selector=""] - Optional selector - * @returns {Element} - The sibling element - */ - static next(element, selector = "") { - return selector ? element.querySelector("+ " + selector) : element.nextElementSibling; - } - - /** - * Gets all subsequent siblings. - * @param {Element} element - Element to get next siblings of - * @returns {NodeList} - The list of siblings - */ - static nextAll(element) { - return element.querySelectorAll("~ *"); - } - - /** - * Gets the subsequent siblings until an element matches the selector. - * @param {Element} element - Element to get the following siblings of - * @param {string} selector - Selector to stop at - * @returns {Array} - The list of siblings - */ - static nextUntil(element, selector) { - const next = []; - while (element.nextElementSibling && !element.nextElementSibling.matches(selector)) next.push(element = element.nextElementSibling); - return next; - } - - /** - * Gets the previous sibling element that matches the selector. - * @param {Element} element - Element to get the previous sibling of - * @param {string} [selector=""] - Optional selector - * @returns {Element} - The sibling element - */ - static previous(element, selector = "") { - const previous = element.previousElementSibling; - if (selector) return previous && previous.matches(selector) ? previous : null; - return previous; - } - - /** - * Gets all preceeding siblings. - * @param {Element} element - Element to get preceeding siblings of - * @returns {NodeList} - The list of siblings - */ - static previousAll(element) { - const previous = []; - while (element.previousElementSibling) previous.push(element = element.previousElementSibling); - return previous; - } - - /** - * Gets the preceeding siblings until an element matches the selector. - * @param {Element} element - Element to get the preceeding siblings of - * @param {string} selector - Selector to stop at - * @returns {Array} - The list of siblings - */ - static previousUntil(element, selector) { - const previous = []; - while (element.previousElementSibling && !element.previousElementSibling.matches(selector)) previous.push(element = element.previousElementSibling); - return previous; - } - - /** - * Find which index in children a certain node is. Similar to jQuery's `$.index()` - * @param {HTMLElement} node - The node to find its index in parent - * @returns {number} Index of the node - */ - static indexInParent(node) { - const children = node.parentNode.childNodes; - let num = 0; - for (let i = 0; i < children.length; i++) { - if (children[i] == node) return num; - if (children[i].nodeType == 1) num++; - } - return -1; - } - - /** Shorthand for {@link module:DOMTools.indexInParent} */ - static index(node) {return this.indexInParent(node);} - - /** - * Gets the parent of the element if it matches the selector, - * otherwise returns null. - * @param {Element} element - Element to get parent of - * @param {string} [selector=""] - Selector to match parent - * @returns {(Element|null)} - The sibling element or null - */ - static parent(element, selector = "") { - return !selector || element.parentElement.matches(selector) ? element.parentElement : null; - } - - /** - * Gets all children of Element that match the selector if provided. - * @param {Element} element - Element to get all children of - * @param {string} selector - Selector to match the children to - * @returns {Array} - The list of children - */ - static findChild(element, selector) { - return element.querySelector(":scope > " + selector); - } - - /** - * Gets all children of Element that match the selector if provided. - * @param {Element} element - Element to get all children of - * @param {string} selector - Selector to match the children to - * @returns {Array} - The list of children - */ - static findChildren(element, selector) { - return element.querySelectorAll(":scope > " + selector); - } - - /** - * Gets all ancestors of Element that match the selector if provided. - * @param {Element} element - Element to get all parents of - * @param {string} [selector=""] - Selector to match the parents to - * @returns {Array} - The list of parents - */ - static parents(element, selector = "") { - const parents = []; - if (selector) while (element.parentElement && element.parentElement.closest(selector)) parents.push(element = element.parentElement.closest(selector)); - else while (element.parentElement) parents.push(element = element.parentElement); - return parents; - } - - /** - * Gets the ancestors until an element matches the selector. - * @param {Element} element - Element to get the ancestors of - * @param {string} selector - Selector to stop at - * @returns {Array} - The list of parents - */ - static parentsUntil(element, selector) { - const parents = []; - while (element.parentElement && !element.parentElement.matches(selector)) parents.push(element = element.parentElement); - return parents; - } - - /** - * Gets all siblings of the element that match the selector. - * @param {Element} element - Element to get all siblings of - * @param {string} [selector="*"] - Selector to match the siblings to - * @returns {Array} - The list of siblings - */ - static siblings(element, selector = "*") { - return Array.from(element.parentElement.children).filter(e => e != element && e.matches(selector)); - } - - /** - * Sets or gets css styles for a specific element. If `value` is provided - * then it sets the style and returns the element to allow for chaining, - * otherwise returns the style. - * @param {Element} element - Element to set the CSS of - * @param {string} attribute - Attribute to get or set - * @param {string} [value] - Value to set for attribute - * @returns {Element|string} - When setting a value, element is returned for chaining, otherwise the value is returned. - */ - static css(element, attribute, value) { - if (typeof(value) == "undefined") return global.getComputedStyle(element)[attribute]; - element.style[attribute] = value; - return element; - } - - /** - * Sets or gets the width for a specific element. If `value` is provided - * then it sets the width and returns the element to allow for chaining, - * otherwise returns the width. - * @param {Element} element - Element to set the CSS of - * @param {string} [value] - Width to set - * @returns {Element|string} - When setting a value, element is returned for chaining, otherwise the value is returned. - */ - static width(element, value) { - if (typeof(value) == "undefined") return parseInt(getComputedStyle(element).width); - element.style.width = value; - return element; - } - - /** - * Sets or gets the height for a specific element. If `value` is provided - * then it sets the height and returns the element to allow for chaining, - * otherwise returns the height. - * @param {Element} element - Element to set the CSS of - * @param {string} [value] - Height to set - * @returns {Element|string} - When setting a value, element is returned for chaining, otherwise the value is returned. - */ - static height(element, value) { - if (typeof(value) == "undefined") return parseInt(getComputedStyle(element).height); - element.style.height = value; - return element; - } - - /** - * Sets the inner text of an element if given a value, otherwise returns it. - * @param {Element} element - Element to set the text of - * @param {string} [text] - Content to set - * @returns {string} - Either the string set by this call or the current text content of the node. - */ - static text(element, text) { - if (typeof(text) == "undefined") return element.textContent; - return element.textContent = text; - } - - /** - * Returns the innerWidth of the element. - * @param {Element} element - Element to retrieve inner width of - * @return {number} - The inner width of the element. - */ - static innerWidth(element) { - return element.clientWidth; - } - - /** - * Returns the innerHeight of the element. - * @param {Element} element - Element to retrieve inner height of - * @return {number} - The inner height of the element. - */ - static innerHeight(element) { - return element.clientHeight; - } - - /** - * Returns the outerWidth of the element. - * @param {Element} element - Element to retrieve outer width of - * @return {number} - The outer width of the element. - */ - static outerWidth(element) { - return element.offsetWidth; - } - - /** - * Returns the outerHeight of the element. - * @param {Element} element - Element to retrieve outer height of - * @return {number} - The outer height of the element. - */ - static outerHeight(element) { - return element.offsetHeight; - } - - /** - * Gets the offset of the element in the page. - * @param {Element} element - Element to get offset of - * @return {Offset} - The offset of the element - */ - static offset(element) { - return element.getBoundingClientRect(); - } - - static get listeners() {return this._listeners || (this._listeners = {});} - - /** - * This is similar to jQuery's `on` function and can *hopefully* be used in the same way. - * - * Rather than attempt to explain, I'll show some example usages. - * - * The following will add a click listener (in the `myPlugin` namespace) to `element`. - * `DOMTools.on(element, "click.myPlugin", () => {console.log("clicked!");});` - * - * The following will add a click listener (in the `myPlugin` namespace) to `element` that only fires when the target is a `.block` element. - * `DOMTools.on(element, "click.myPlugin", ".block", () => {console.log("clicked!");});` - * - * The following will add a click listener (without namespace) to `element`. - * `DOMTools.on(element, "click", () => {console.log("clicked!");});` - * - * The following will add a click listener (without namespace) to `element` that only fires once. - * `const cancel = DOMTools.on(element, "click", () => {console.log("fired!"); cancel();});` - * - * @param {Element} element - Element to add listener to - * @param {string} event - Event to listen to with option namespace (e.g. "event.namespace") - * @param {(string|callable)} delegate - Selector to run on element to listen to - * @param {callable} [callback] - Function to fire on event - * @returns {module:DOMTools~CancelListener} - A function that will undo the listener - */ - static on(element, event, delegate, callback) { - const [type, namespace] = event.split("."); - const hasDelegate = delegate && callback; - if (!callback) callback = delegate; - const eventFunc = !hasDelegate ? callback : function(ev) { - if (ev.target.matches(delegate)) { - callback(ev); - } - }; - - element.addEventListener(type, eventFunc); - const cancel = () => { - element.removeEventListener(type, eventFunc); - }; - if (namespace) { - if (!this.listeners[namespace]) this.listeners[namespace] = []; - const newCancel = () => { - cancel(); - this.listeners[namespace].splice(this.listeners[namespace].findIndex(l => l.event == type && l.element == element), 1); - }; - this.listeners[namespace].push({ - event: type, - element: element, - cancel: newCancel - }); - return newCancel; - } - return cancel; - } - - /** - * Functionality for this method matches {@link module:DOMTools.on} but automatically cancels itself - * and removes the listener upon the first firing of the desired event. - * - * @param {Element} element - Element to add listener to - * @param {string} event - Event to listen to with option namespace (e.g. "event.namespace") - * @param {(string|callable)} delegate - Selector to run on element to listen to - * @param {callable} [callback] - Function to fire on event - * @returns {module:DOMTools~CancelListener} - A function that will undo the listener - */ - static once(element, event, delegate, callback) { - const [type, namespace] = event.split("."); - const hasDelegate = delegate && callback; - if (!callback) callback = delegate; - const eventFunc = !hasDelegate ? function(ev) { - callback(ev); - element.removeEventListener(type, eventFunc); - } : function(ev) { - if (!ev.target.matches(delegate)) return; - callback(ev); - element.removeEventListener(type, eventFunc); - }; - - element.addEventListener(type, eventFunc); - const cancel = () => { - element.removeEventListener(type, eventFunc); - }; - if (namespace) { - if (!this.listeners[namespace]) this.listeners[namespace] = []; - const newCancel = () => { - cancel(); - this.listeners[namespace].splice(this.listeners[namespace].findIndex(l => l.event == type && l.element == element), 1); - }; - this.listeners[namespace].push({ - event: type, - element: element, - cancel: newCancel - }); - return newCancel; - } - return cancel; - } - - static __offAll(event, element) { - const [type, namespace] = event.split("."); - let matchFilter = listener => listener.event == type, defaultFilter = _ => _; - if (element) { - matchFilter = l => l.event == type && l.element == element; - defaultFilter = l => l.element == element; - } - const listeners = this.listeners[namespace] || []; - const list = type ? listeners.filter(matchFilter) : listeners.filter(defaultFilter); - for (let c = 0; c < list.length; c++) list[c].cancel(); - } - - /** - * This is similar to jQuery's `off` function and can *hopefully* be used in the same way. - * - * Rather than attempt to explain, I'll show some example usages. - * - * The following will remove a click listener called `onClick` (in the `myPlugin` namespace) from `element`. - * `DOMTools.off(element, "click.myPlugin", onClick);` - * - * The following will remove a click listener called `onClick` (in the `myPlugin` namespace) from `element` that only fired when the target is a `.block` element. - * `DOMTools.off(element, "click.myPlugin", ".block", onClick);` - * - * The following will remove a click listener (without namespace) from `element`. - * `DOMTools.off(element, "click", onClick);` - * - * The following will remove all listeners in namespace `myPlugin` from `element`. - * `DOMTools.off(element, ".myPlugin");` - * - * The following will remove all click listeners in namespace `myPlugin` from *all elements*. - * `DOMTools.off("click.myPlugin");` - * - * The following will remove all listeners in namespace `myPlugin` from *all elements*. - * `DOMTools.off(".myPlugin");` - * - * @param {(Element|string)} element - Element to remove listener from - * @param {string} [event] - Event to listen to with option namespace (e.g. "event.namespace") - * @param {(string|callable)} [delegate] - Selector to run on element to listen to - * @param {callable} [callback] - Function to fire on event - * @returns {Element} - The original element to allow for chaining - */ - static off(element, event, delegate, callback) { - if (typeof(element) == "string") return this.__offAll(element); - const [type, namespace] = event.split("."); - if (namespace) return this.__offAll(event, element); - - const hasDelegate = delegate && callback; - if (!callback) callback = delegate; - const eventFunc = !hasDelegate ? callback : function(ev) { - if (ev.target.matches(delegate)) { - callback(ev); - } - }; - - element.removeEventListener(type, eventFunc); - return element; - } - - /** - * Adds a listener for when the node is added/removed from the document body. - * The listener is automatically removed upon firing. - * @param {HTMLElement} node - node to wait for - * @param {callable} callback - function to be performed on event - * @param {boolean} onMount - determines if it should fire on Mount or on Unmount - */ - static onMountChange(node, callback, onMount = true) { - const wrappedCallback = () => { - this.observer.unsubscribe(wrappedCallback); - callback(); - }; - this.observer.subscribe(wrappedCallback, mutation => { - const nodes = Array.from(onMount ? mutation.addedNodes : mutation.removedNodes); - const directMatch = nodes.indexOf(node) > -1; - const parentMatch = nodes.some(parent => parent.contains(node)); - return directMatch || parentMatch; - }); - return node; - } - - /** Shorthand for {@link module:DOMTools.onMountChange} with third parameter `true` */ - static onMount(node, callback) {return this.onMountChange(node, callback);} - - /** Shorthand for {@link module:DOMTools.onMountChange} with third parameter `false` */ - static onUnmount(node, callback) {return this.onMountChange(node, callback, false);} - - /** Alias for {@link module:DOMTools.onMount} */ - static onAdded(node, callback) {return this.onMount(node, callback);} - - /** Alias for {@link module:DOMTools.onUnmount} */ - static onRemoved(node, callback) {return this.onUnmount(node, callback, false);} - - /** - * Helper function which combines multiple elements into one parent element - * @param {Array} elements - array of elements to put into a single parent - */ - static wrap(elements) { - const domWrapper = this.parseHTML(`
`); - for (let e = 0; e < elements.length; e++) domWrapper.appendChild(elements[e]); - return domWrapper; - } -} \ No newline at end of file diff --git a/renderer/src/modules/localemanager.js b/renderer/src/modules/localemanager.js index 87a86300..77439b5f 100644 --- a/renderer/src/modules/localemanager.js +++ b/renderer/src/modules/localemanager.js @@ -3,7 +3,7 @@ import DiscordModules from "./discordmodules"; import Utilities from "./utilities"; import Events from "./emitter"; -const {Dispatcher, LocaleStore} = DiscordModules; +const {LocaleStore} = DiscordModules; export default new class LocaleManager { get discordLocale() {return LocaleStore?.locale ?? this.defaultLocale;} @@ -16,7 +16,7 @@ export default new class LocaleManager { initialize() { this.setLocale(this.discordLocale); - Dispatcher.subscribe("USER_SETTINGS_UPDATE", (newLocale) => this.setLocale(newLocale)); + LocaleStore?.addChangeListener((newLocale) => this.setLocale(newLocale)); } setLocale(newLocale) { diff --git a/renderer/src/modules/modules.js b/renderer/src/modules/modules.js index 62215184..b1377797 100644 --- a/renderer/src/modules/modules.js +++ b/renderer/src/modules/modules.js @@ -10,7 +10,6 @@ export {default as DataStore} from "./datastore"; export {default as Events} from "./emitter"; export {default as Settings} from "./settingsmanager"; export {default as DOMManager} from "./dommanager"; -export {default as DOM} from "./domtools"; export {default as Patcher} from "./patcher"; export {default as LocaleManager} from "./localemanager"; export {default as Strings} from "./strings"; diff --git a/renderer/src/modules/patcher.js b/renderer/src/modules/patcher.js index 2efe8b1b..eec16abe 100644 --- a/renderer/src/modules/patcher.js +++ b/renderer/src/modules/patcher.js @@ -1,232 +1,276 @@ -/** - * Patcher that can patch other functions allowing you to run code before, after or - * instead of the original function. Can also alter arguments and return values. - * - * This is from Zerebos' library {@link https://github.com/rauenzi/BDPluginLibrary} - * - * @module Patcher - * @version 0.0.2 - */ - -import Logger from "common/logger"; -import DiscordModules from "./discordmodules"; -import WebpackModules from "./webpackmodules"; - -export default class Patcher { - - static get patches() {return this._patches || (this._patches = []);} - - /** - * Returns all the patches done by a specific caller - * @param {string} name - Name of the patch caller - * @method - */ - static getPatchesByCaller(name) { - if (!name) return []; - const patches = []; - for (const patch of this.patches) { - for (const childPatch of patch.children) { - if (childPatch.caller === name) patches.push(childPatch); - } - } - return patches; - } - - /** - * Unpatches all patches passed, or when a string is passed unpatches all - * patches done by that specific caller. - * @param {Array|string} patches - Either an array of patches to unpatch or a caller name - */ - static unpatchAll(patches) { - if (typeof patches === "string") patches = this.getPatchesByCaller(patches); - - for (const patch of patches) { - patch.unpatch(); - } - } - - static resolveModule(module) { - if (!module || typeof(module) === "function" || (typeof(module) === "object" && !Array.isArray(module))) return module; - if (typeof module === "string") return DiscordModules[module]; - if (Array.isArray(module)) return WebpackModules.findByUniqueProperties(module); - return null; - } - - static makeOverride(patch) { - return function () { - let returnValue; - if (!patch.children || !patch.children.length) return patch.originalFunction.apply(this, arguments); - for (const superPatch of patch.children.filter(c => c.type === "before")) { - try { - superPatch.callback(this, arguments); - } - catch (err) { - Logger.err("Patcher", `Could not fire before callback of ${patch.functionName} for ${superPatch.caller}`, err); - } - } - - const insteads = patch.children.filter(c => c.type === "instead"); - if (!insteads.length) {returnValue = patch.originalFunction.apply(this, arguments);} - else { - for (const insteadPatch of insteads) { - try { - const tempReturn = insteadPatch.callback(this, arguments, patch.originalFunction.bind(this)); - if (typeof(tempReturn) !== "undefined") returnValue = tempReturn; - } - catch (err) { - Logger.err("Patcher", `Could not fire instead callback of ${patch.functionName} for ${insteadPatch.caller}`, err); - } - } - } - - for (const slavePatch of patch.children.filter(c => c.type === "after")) { - try { - const tempReturn = slavePatch.callback(this, arguments, returnValue); - if (typeof(tempReturn) !== "undefined") returnValue = tempReturn; - } - catch (err) { - Logger.err("Patcher", `Could not fire after callback of ${patch.functionName} for ${slavePatch.caller}`, err); - } - } - return returnValue; - }; - } - - static rePatch(patch) { - patch.proxyFunction = patch.module[patch.functionName] = this.makeOverride(patch); - } - - static makePatch(module, functionName, name) { - const patch = { - name, - module, - functionName, - originalFunction: module[functionName], - proxyFunction: null, - revert: () => { // Calling revert will destroy any patches added to the same module after this - patch.module[patch.functionName] = patch.originalFunction; - patch.proxyFunction = null; - patch.children = []; - }, - counter: 0, - children: [] - }; - patch.proxyFunction = module[functionName] = this.makeOverride(patch); - Object.assign(module[functionName], patch.originalFunction); - module[functionName].__originalFunction = patch.originalFunction; - module[functionName].toString = () => patch.originalFunction.toString(); - this.patches.push(patch); - return patch; - } - - /** - * Function with no arguments and no return value that may be called to revert changes made by {@link module:Patcher}, restoring (unpatching) original method. - * @callback module:Patcher~unpatch - */ - - /** - * A callback that modifies method logic. This callback is called on each call of the original method and is provided all data about original call. Any of the data can be modified if necessary, but do so wisely. - * - * The third argument for the callback will be `undefined` for `before` patches. `originalFunction` for `instead` patches and `returnValue` for `after` patches. - * - * @callback module:Patcher~patchCallback - * @param {object} thisObject - `this` in the context of the original function. - * @param {arguments} arguments - The original arguments of the original function. - * @param {(function|*)} extraValue - For `instead` patches, this is the original function from the module. For `after` patches, this is the return value of the function. - * @return {*} Makes sense only when using an `instead` or `after` patch. If something other than `undefined` is returned, the returned value replaces the value of `returnValue`. If used for `before` the return value is ignored. - */ - - /** - * This method patches onto another function, allowing your code to run beforehand. - * Using this, you are also able to modify the incoming arguments before the original method is run. - * - * @param {string} caller - Name of the caller of the patch function. Using this you can undo all patches with the same name using {@link module:Patcher.unpatchAll}. Use `""` if you don't care. - * @param {object} moduleToPatch - Object with the function to be patched. Can also patch an object's prototype. - * @param {string} functionName - Name of the method to be patched - * @param {module:Patcher~patchCallback} callback - Function to run before the original method - * @param {object} options - Object used to pass additional options. - * @param {string} [options.displayName] You can provide meaningful name for class/object provided in `what` param for logging purposes. By default, this function will try to determine name automatically. - * @param {boolean} [options.forcePatch=true] Set to `true` to patch even if the function doesnt exist. (Adds noop function in place). - * @return {module:Patcher~unpatch} Function with no arguments and no return value that should be called to cancel (unpatch) this patch. You should save and run it when your plugin is stopped. - */ - static before(caller, moduleToPatch, functionName, callback, options = {}) {return this.pushChildPatch(caller, moduleToPatch, functionName, callback, Object.assign(options, {type: "before"}));} - - /** - * This method patches onto another function, allowing your code to run after. - * Using this, you are also able to modify the return value, using the return of your code instead. - * - * @param {string} caller - Name of the caller of the patch function. Using this you can undo all patches with the same name using {@link module:Patcher.unpatchAll}. Use `""` if you don't care. - * @param {object} moduleToPatch - Object with the function to be patched. Can also patch an object's prototype. - * @param {string} functionName - Name of the method to be patched - * @param {module:Patcher~patchCallback} callback - Function to run instead of the original method - * @param {object} options - Object used to pass additional options. - * @param {string} [options.displayName] You can provide meaningful name for class/object provided in `what` param for logging purposes. By default, this function will try to determine name automatically. - * @param {boolean} [options.forcePatch=true] Set to `true` to patch even if the function doesnt exist. (Adds noop function in place). - * @return {module:Patcher~unpatch} Function with no arguments and no return value that should be called to cancel (unpatch) this patch. You should save and run it when your plugin is stopped. - */ - static after(caller, moduleToPatch, functionName, callback, options = {}) {return this.pushChildPatch(caller, moduleToPatch, functionName, callback, Object.assign(options, {type: "after"}));} - - /** - * This method patches onto another function, allowing your code to run instead. - * Using this, you are also able to modify the return value, using the return of your code instead. - * - * @param {string} caller - Name of the caller of the patch function. Using this you can undo all patches with the same name using {@link module:Patcher.unpatchAll}. Use `""` if you don't care. - * @param {object} moduleToPatch - Object with the function to be patched. Can also patch an object's prototype. - * @param {string} functionName - Name of the method to be patched - * @param {module:Patcher~patchCallback} callback - Function to run after the original method - * @param {object} options - Object used to pass additional options. - * @param {string} [options.displayName] You can provide meaningful name for class/object provided in `what` param for logging purposes. By default, this function will try to determine name automatically. - * @param {boolean} [options.forcePatch=true] Set to `true` to patch even if the function doesnt exist. (Adds noop function in place). - * @return {module:Patcher~unpatch} Function with no arguments and no return value that should be called to cancel (unpatch) this patch. You should save and run it when your plugin is stopped. - */ - static instead(caller, moduleToPatch, functionName, callback, options = {}) {return this.pushChildPatch(caller, moduleToPatch, functionName, callback, Object.assign(options, {type: "instead"}));} - - /** - * This method patches onto another function, allowing your code to run before, instead or after the original function. - * Using this you are able to modify the incoming arguments before the original function is run as well as the return - * value before the original function actually returns. - * - * @param {string} caller - Name of the caller of the patch function. Using this you can undo all patches with the same name using {@link module:Patcher.unpatchAll}. Use `""` if you don't care. - * @param {object} moduleToPatch - Object with the function to be patched. Can also patch an object's prototype. - * @param {string} functionName - Name of the method to be patched - * @param {module:Patcher~patchCallback} callback - Function to run after the original method - * @param {object} options - Object used to pass additional options. - * @param {string} [options.type=after] - Determines whether to run the function `before`, `instead`, or `after` the original. - * @param {string} [options.displayName] You can provide meaningful name for class/object provided in `what` param for logging purposes. By default, this function will try to determine name automatically. - * @param {boolean} [options.forcePatch=true] Set to `true` to patch even if the function doesnt exist. (Adds noop function in place). - * @return {module:Patcher~unpatch} Function with no arguments and no return value that should be called to cancel (unpatch) this patch. You should save and run it when your plugin is stopped. - */ - static pushChildPatch(caller, moduleToPatch, functionName, callback, options = {}) { - const {type = "after", forcePatch = true} = options; - const module = this.resolveModule(moduleToPatch); - if (!module) return null; - if (!module[functionName] && forcePatch) module[functionName] = function() {}; - if (!(module[functionName] instanceof Function)) return null; - - if (typeof moduleToPatch === "string") options.displayName = moduleToPatch; - const displayName = options.displayName || module.displayName || module.name || module.constructor.displayName || module.constructor.name; - - const patchId = `${displayName}.${functionName}`; - const patch = this.patches.find(p => p.module == module && p.functionName == functionName) || this.makePatch(module, functionName, patchId); - if (!patch.proxyFunction) this.rePatch(patch); - const child = { - caller, - type, - id: patch.counter, - callback, - unpatch: () => { - patch.children.splice(patch.children.findIndex(cpatch => cpatch.id === child.id && cpatch.type === type), 1); - if (patch.children.length <= 0) { - const patchNum = this.patches.findIndex(p => p.module == module && p.functionName == functionName); - if (patchNum < 0) return; - this.patches[patchNum].revert(); - this.patches.splice(patchNum, 1); - } - } - }; - patch.children.push(child); - patch.counter++; - return child.unpatch; - } - -} \ No newline at end of file +/** + * Patcher that can patch other functions allowing you to run code before, after or + * instead of the original function. Can also alter arguments and return values. + * + * This is from Zerebos' library {@link https://github.com/rauenzi/BDPluginLibrary} + * + * @module Patcher + * @version 0.0.2 + */ + +import Logger from "common/logger"; +import DiscordModules from "./discordmodules"; +import WebpackModules from "./webpackmodules"; + +export default class Patcher { + + static get patches() {return this._patches || (this._patches = []);} + + /** + * Returns all the patches done by a specific caller + * @param {string} name - Name of the patch caller + * @method + */ + static getPatchesByCaller(name) { + if (!name) return []; + const patches = []; + for (const patch of this.patches) { + for (const childPatch of patch.children) { + if (childPatch.caller === name) patches.push(childPatch); + } + } + return patches; + } + + /** + * Unpatches all patches passed, or when a string is passed unpatches all + * patches done by that specific caller. + * @param {Array|string} patches - Either an array of patches to unpatch or a caller name + */ + static unpatchAll(patches) { + if (typeof patches === "string") patches = this.getPatchesByCaller(patches); + + for (const patch of patches) { + patch.unpatch(); + } + } + + static resolveModule(module) { + if (!module || typeof(module) === "function" || (typeof(module) === "object" && !Array.isArray(module))) return module; + if (typeof module === "string") return DiscordModules[module]; + if (Array.isArray(module)) return WebpackModules.findByUniqueProperties(module); + return null; + } + + static makeOverride(patch) { + return function () { + let returnValue; + if (!patch.children || !patch.children.length) return patch.originalFunction.apply(this, arguments); + for (const superPatch of patch.children.filter(c => c.type === "before")) { + try { + superPatch.callback(this, arguments); + } + catch (err) { + Logger.err("Patcher", `Could not fire before callback of ${patch.functionName} for ${superPatch.caller}`, err); + } + } + + const insteads = patch.children.filter(c => c.type === "instead"); + if (!insteads.length) {returnValue = patch.originalFunction.apply(this, arguments);} + else { + for (const insteadPatch of insteads) { + try { + const tempReturn = insteadPatch.callback(this, arguments, patch.originalFunction.bind(this)); + if (typeof(tempReturn) !== "undefined") returnValue = tempReturn; + } + catch (err) { + Logger.err("Patcher", `Could not fire instead callback of ${patch.functionName} for ${insteadPatch.caller}`, err); + } + } + } + + for (const slavePatch of patch.children.filter(c => c.type === "after")) { + try { + const tempReturn = slavePatch.callback(this, arguments, returnValue); + if (typeof(tempReturn) !== "undefined") returnValue = tempReturn; + } + catch (err) { + Logger.err("Patcher", `Could not fire after callback of ${patch.functionName} for ${slavePatch.caller}`, err); + } + } + return returnValue; + }; + } + + static rePatch(patch) { + patch.proxyFunction = patch.module[patch.functionName] = this.makeOverride(patch); + } + + static makePatch(module, functionName, name) { + const patch = { + name, + module, + functionName, + originalFunction: module[functionName], + proxyFunction: null, + revert: () => { // Calling revert will destroy any patches added to the same module after this + if (patch.getter) { + Object.defineProperty(patch.module, functionName, { + get: () => patch.originalFunction, + configurable: true, + enumerable: true + }); + } else { + patch.module[patch.functionName] = patch.originalFunction; + } + + patch.proxyFunction = null; + patch.children = []; + }, + counter: 0, + children: [] + }; + + patch.proxyFunction = this.makeOverride(patch); + + const descriptor = Object.getOwnPropertyDescriptor(module, functionName); + + if (descriptor.get) { + patch.getter = true; + Object.defineProperty(module, functionName, { + get: () => patch.proxyFunction, + set: value => (patch.originalFunction = value), + configurable: true, + enumerable: true + }); + } else { + patch.getter = false; + module[functionName] = patch.proxyFunction; + } + + const descriptors = Object.assign({}, Object.getOwnPropertyDescriptors(patch.originalFunction), { + __originalFunction: { + get: () => patch.originalFunction, + configurable: true, + enumerable: true, + writeable: true + }, + toString: { + value: () => patch.originalFunction.toString(), + configurable: true, + enumerable: true, + writeable: true + } + }); + + Object.defineProperties(patch.proxyFunction, descriptors); + + this.patches.push(patch); + return patch; + } + + /** + * Function with no arguments and no return value that may be called to revert changes made by {@link module:Patcher}, restoring (unpatching) original method. + * @callback module:Patcher~unpatch + */ + + /** + * A callback that modifies method logic. This callback is called on each call of the original method and is provided all data about original call. Any of the data can be modified if necessary, but do so wisely. + * + * The third argument for the callback will be `undefined` for `before` patches. `originalFunction` for `instead` patches and `returnValue` for `after` patches. + * + * @callback module:Patcher~patchCallback + * @param {object} thisObject - `this` in the context of the original function. + * @param {arguments} arguments - The original arguments of the original function. + * @param {(function|*)} extraValue - For `instead` patches, this is the original function from the module. For `after` patches, this is the return value of the function. + * @return {*} Makes sense only when using an `instead` or `after` patch. If something other than `undefined` is returned, the returned value replaces the value of `returnValue`. If used for `before` the return value is ignored. + */ + + /** + * This method patches onto another function, allowing your code to run beforehand. + * Using this, you are also able to modify the incoming arguments before the original method is run. + * + * @param {string} caller - Name of the caller of the patch function. Using this you can undo all patches with the same name using {@link module:Patcher.unpatchAll}. Use `""` if you don't care. + * @param {object} moduleToPatch - Object with the function to be patched. Can also patch an object's prototype. + * @param {string} functionName - Name of the method to be patched + * @param {module:Patcher~patchCallback} callback - Function to run before the original method + * @param {object} options - Object used to pass additional options. + * @param {string} [options.displayName] You can provide meaningful name for class/object provided in `what` param for logging purposes. By default, this function will try to determine name automatically. + * @param {boolean} [options.forcePatch=true] Set to `true` to patch even if the function doesnt exist. (Adds noop function in place). + * @return {module:Patcher~unpatch} Function with no arguments and no return value that should be called to cancel (unpatch) this patch. You should save and run it when your plugin is stopped. + */ + static before(caller, moduleToPatch, functionName, callback, options = {}) {return this.pushChildPatch(caller, moduleToPatch, functionName, callback, Object.assign(options, {type: "before"}));} + + /** + * This method patches onto another function, allowing your code to run after. + * Using this, you are also able to modify the return value, using the return of your code instead. + * + * @param {string} caller - Name of the caller of the patch function. Using this you can undo all patches with the same name using {@link module:Patcher.unpatchAll}. Use `""` if you don't care. + * @param {object} moduleToPatch - Object with the function to be patched. Can also patch an object's prototype. + * @param {string} functionName - Name of the method to be patched + * @param {module:Patcher~patchCallback} callback - Function to run instead of the original method + * @param {object} options - Object used to pass additional options. + * @param {string} [options.displayName] You can provide meaningful name for class/object provided in `what` param for logging purposes. By default, this function will try to determine name automatically. + * @param {boolean} [options.forcePatch=true] Set to `true` to patch even if the function doesnt exist. (Adds noop function in place). + * @return {module:Patcher~unpatch} Function with no arguments and no return value that should be called to cancel (unpatch) this patch. You should save and run it when your plugin is stopped. + */ + static after(caller, moduleToPatch, functionName, callback, options = {}) {return this.pushChildPatch(caller, moduleToPatch, functionName, callback, Object.assign(options, {type: "after"}));} + + /** + * This method patches onto another function, allowing your code to run instead. + * Using this, you are also able to modify the return value, using the return of your code instead. + * + * @param {string} caller - Name of the caller of the patch function. Using this you can undo all patches with the same name using {@link module:Patcher.unpatchAll}. Use `""` if you don't care. + * @param {object} moduleToPatch - Object with the function to be patched. Can also patch an object's prototype. + * @param {string} functionName - Name of the method to be patched + * @param {module:Patcher~patchCallback} callback - Function to run after the original method + * @param {object} options - Object used to pass additional options. + * @param {string} [options.displayName] You can provide meaningful name for class/object provided in `what` param for logging purposes. By default, this function will try to determine name automatically. + * @param {boolean} [options.forcePatch=true] Set to `true` to patch even if the function doesnt exist. (Adds noop function in place). + * @return {module:Patcher~unpatch} Function with no arguments and no return value that should be called to cancel (unpatch) this patch. You should save and run it when your plugin is stopped. + */ + static instead(caller, moduleToPatch, functionName, callback, options = {}) {return this.pushChildPatch(caller, moduleToPatch, functionName, callback, Object.assign(options, {type: "instead"}));} + + /** + * This method patches onto another function, allowing your code to run before, instead or after the original function. + * Using this you are able to modify the incoming arguments before the original function is run as well as the return + * value before the original function actually returns. + * + * @param {string} caller - Name of the caller of the patch function. Using this you can undo all patches with the same name using {@link module:Patcher.unpatchAll}. Use `""` if you don't care. + * @param {object} moduleToPatch - Object with the function to be patched. Can also patch an object's prototype. + * @param {string} functionName - Name of the method to be patched + * @param {module:Patcher~patchCallback} callback - Function to run after the original method + * @param {object} options - Object used to pass additional options. + * @param {string} [options.type=after] - Determines whether to run the function `before`, `instead`, or `after` the original. + * @param {string} [options.displayName] You can provide meaningful name for class/object provided in `what` param for logging purposes. By default, this function will try to determine name automatically. + * @param {boolean} [options.forcePatch=true] Set to `true` to patch even if the function doesnt exist. (Adds noop function in place). + * @return {module:Patcher~unpatch} Function with no arguments and no return value that should be called to cancel (unpatch) this patch. You should save and run it when your plugin is stopped. + */ + static pushChildPatch(caller, moduleToPatch, functionName, callback, options = {}) { + const {type = "after", forcePatch = true} = options; + const module = this.resolveModule(moduleToPatch); + if (!module) return null; + if (!module[functionName] && forcePatch) module[functionName] = function() {}; + if (!(module[functionName] instanceof Function)) return null; + if (!Object.getOwnPropertyDescriptor(module, functionName)?.configurable) { + Logger.err("Patcher", `Cannot patch ${functionName} of Module, property is readonly.`); + return null; + } + + if (typeof moduleToPatch === "string") options.displayName = moduleToPatch; + const displayName = options.displayName || module.displayName || module.name || module.constructor.displayName || module.constructor.name; + + const patchId = `${displayName}.${functionName}`; + const patch = this.patches.find(p => p.module == module && p.functionName == functionName) || this.makePatch(module, functionName, patchId); + if (!patch.proxyFunction) this.rePatch(patch); + const child = { + caller, + type, + id: patch.counter, + callback, + unpatch: () => { + patch.children.splice(patch.children.findIndex(cpatch => cpatch.id === child.id && cpatch.type === type), 1); + if (patch.children.length <= 0) { + const patchNum = this.patches.findIndex(p => p.module == module && p.functionName == functionName); + if (patchNum < 0) return; + this.patches[patchNum].revert(); + this.patches.splice(patchNum, 1); + } + } + }; + patch.children.push(child); + patch.counter++; + return child.unpatch; + } + +} diff --git a/renderer/src/modules/pluginapi.js b/renderer/src/modules/pluginapi.js deleted file mode 100644 index 4d71868a..00000000 --- a/renderer/src/modules/pluginapi.js +++ /dev/null @@ -1,691 +0,0 @@ -import {Config} from "data"; -import Utilities from "./utilities"; -import WebpackModules, {Filters} from "./webpackmodules"; -import DiscordModules from "./discordmodules"; -import DataStore from "./datastore"; -import DOMManager from "./dommanager"; -import Toasts from "../ui/toasts"; -import Notices from "../ui/notices"; -import Modals from "../ui/modals"; -import PluginManager from "./pluginmanager"; -import ThemeManager from "./thememanager"; -import Settings from "./settingsmanager"; -import Logger from "common/logger"; -import Patcher from "./patcher"; -import Emotes from "../builtins/emotes/emotes"; -import ipc from "./ipc"; - -/** - * `BdApi` is a globally (`window.BdApi`) accessible object for use by plugins and developers to make their lives easier. - * @name BdApi - */ -const BdApi = { - /** - * The React module being used inside Discord. - * @type React - * */ - get React() {return DiscordModules.React;}, - - /** - * The ReactDOM module being used inside Discord. - * @type ReactDOM - */ - get ReactDOM() {return DiscordModules.ReactDOM;}, - - /** - * A reference object to get BD's settings. - * @type object - * @deprecated - */ - get settings() {return Settings.collections;}, - - /** - * A reference object for BD's emotes. - * @type object - * @deprecated - */ - get emotes() { - return new Proxy(Emotes.Emotes, { - get(obj, category) { - if (category === "blocklist") return Emotes.blocklist; - const group = Emotes.Emotes[category]; - if (!group) return undefined; - return new Proxy(group, { - get(cat, emote) {return group[emote];}, - set() {Logger.warn("BdApi.emotes", "Addon policy for plugins #5 https://github.com/BetterDiscord/BetterDiscord/wiki/Addon-Policies#plugins");} - }); - }, - set() {Logger.warn("BdApi.emotes", "Addon policy for plugins #5 https://github.com/BetterDiscord/BetterDiscord/wiki/Addon-Policies#plugins");} - }); - }, - - /** - * A reference string for BD's version. - * @type string - */ - get version() {return Config.version;} -}; - - -/** - * Adds a `