├── .eslintignore ├── .eslintrc.js ├── .gitignore ├── INCOMPATIBILITIES.md ├── README.md ├── TODO.md ├── components └── BDPluginManager.js ├── index.js ├── libraries ├── BDApi.js ├── BDContentManager.js └── BDV2.js ├── manifest.json ├── reactcomponents ├── DeleteConfirm.jsx ├── Icons.jsx ├── Plugin.jsx ├── PluginList.jsx ├── PluginSettings.jsx └── Settings.jsx └── style.css /.eslintignore: -------------------------------------------------------------------------------- 1 | /config 2 | /plugins 3 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['eslint:recommended', 'intrnl'], 3 | env: { node: true }, 4 | } 5 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /config 2 | /plugins 3 | -------------------------------------------------------------------------------- /INCOMPATIBILITIES.md: -------------------------------------------------------------------------------- 1 | # Notes on incompatibiltiies 2 | 3 | - Regarding monkey patching 4 | - Some plugins, especially those that uses their own plugin library, patches Discord's functions, and if it's not done through BD's API for monkey patching then it can lead to breakages. 5 | - One notable note is with [DevilBro's plugins](https://github.com/mwittrien/BetterDiscordAddons), the library used in his plugins tries to patch method responsible for handling the guild header, and thus it breaks some plugins like Powercord's Badges plugin (`pc-badges`) 6 | 7 | - Plugins that enhances BetterDiscord's emotes feature 8 | - Affects plugins like EmoteSearch 9 | - No, they don't work. Why? Go figure. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # pc-bdCompat 2 | 3 | Compatibility layer for running BetterDiscord plugins in Powercord 4 | 5 | [![Screenshot showing a list of BetterDiscord plugins](https://i.imgur.com/pUfaXLf.png)](https://imgur.com/a/2gWgY7q) 6 | 7 | ## Installation 8 | 9 | Clone this repository to your Powercord install's plugins folder 10 | 11 | ``` 12 | git clone https://github.com/intrnl/pc-bdCompat 13 | ``` 14 | 15 | ## Installing BD plugins 16 | 17 | Before you download and install any BD plugins, please take a look at the incompatibilites note on `INCOMPATIBILITIES.md` file 18 | 19 | - Put the plugin in the `plugins` folder, if it doesn't exist then create one. 20 | - Reload your Discord. 21 | - Go to User Settings and head to the `BetterDiscord Plugins` section 22 | - Enable the said plugin 23 | -------------------------------------------------------------------------------- /TODO.md: -------------------------------------------------------------------------------- 1 | # To do 2 | 3 | - Revisit the idea of replacing the require function to allow directly requiring the plugin files without creating temporary files 4 | - Hot reloading of plugins 5 | - Should already be possible, just needs a function that unloads and loads, and a watcher. 6 | -------------------------------------------------------------------------------- /components/BDPluginManager.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const electron = require('electron') 4 | const process = require('process') 5 | const path = require('path') 6 | const fs = require('fs') 7 | const { Module } = require('module') 8 | 9 | Module.globalPaths.push(path.resolve(electron.remote.app.getAppPath(), 'node_modules')) 10 | 11 | 12 | class BDPluginManager { 13 | constructor () { 14 | this.currentWindow = electron.remote.getCurrentWindow() 15 | this.currentWindow.webContents.on('did-navigate-in-page', () => this.onSwitchListener()) 16 | 17 | // DevilBro's plugins checks whether or not it's running on ED 18 | // This isn't BetterDiscord, so we'd be better off doing this. 19 | // eslint-disable-next-line no-process-env 20 | process.env.injDir = __dirname 21 | 22 | // Wait for jQuery, then load the plugins 23 | window.BdApi.linkJS('jquery', '//ajax.googleapis.com/ajax/libs/jquery/2.0.0/jquery.min.js') 24 | .then(() => { 25 | this.__log('Loaded jQuery') 26 | this.loadAllPlugins() 27 | this.startAllEnabledPlugins() 28 | }) 29 | } 30 | 31 | get pluginDirectory () { 32 | const pluginDir = path.join(__dirname, '..', 'plugins/') 33 | 34 | if (!fs.existsSync(pluginDir)) fs.mkdirSync(pluginDir) 35 | 36 | return pluginDir 37 | } 38 | 39 | destroy () { 40 | window.BdApi.unlinkJS('jquery') 41 | this.currentWindow.webContents.off('did-navigate-in-page', () => this.onSwitchListener()) 42 | 43 | this.stopAllPlugins() 44 | 45 | // eslint-disable-next-line no-process-env 46 | process.env.injDir = '' 47 | } 48 | 49 | 50 | startAllEnabledPlugins () { 51 | const plugins = Object.keys(window.bdplugins) 52 | 53 | plugins.forEach((pluginName) => { 54 | if (window.BdApi.loadData('BDCompat-EnabledPlugins', pluginName) === true) this.startPlugin(pluginName) 55 | }) 56 | } 57 | 58 | stopAllPlugins () { 59 | const plugins = Object.keys(window.bdplugins) 60 | 61 | plugins.forEach((pluginName) => { 62 | this.stopPlugin(pluginName) 63 | }) 64 | } 65 | 66 | 67 | isEnabled (pluginName) { 68 | const plugin = window.bdplugins[pluginName] 69 | if (!plugin) return this.__error(null, `Tried to access a missing plugin: ${pluginName}`) 70 | 71 | return plugin.__started 72 | } 73 | 74 | startPlugin (pluginName) { 75 | const plugin = window.bdplugins[pluginName] 76 | if (!plugin) return this.__error(null, `Tried to start a missing plugin: ${pluginName}`) 77 | 78 | if (plugin.__started) return 79 | 80 | try { 81 | plugin.start() 82 | plugin.__started = true 83 | this.__log(`Started plugin ${plugin.getName()}`) 84 | } catch (err) { 85 | this.__error(err, `Could not start ${plugin.getName()}`) 86 | window.BdApi.saveData('BDCompat-EnabledPlugins', plugin.getName(), false) 87 | } 88 | } 89 | stopPlugin (pluginName) { 90 | const plugin = window.bdplugins[pluginName] 91 | if (!plugin) return this.__error(null, `Tried to stop a missing plugin: ${pluginName}`) 92 | 93 | if (!plugin.__started) return 94 | 95 | try { 96 | plugin.stop() 97 | plugin.__started = false 98 | this.__log(`Stopped plugin ${plugin.getName()}`) 99 | } catch (err) { 100 | this.__error(err, `Could not stop ${plugin.getName()}`) 101 | window.BdApi.saveData('BDCompat-EnabledPlugins', plugin.getName(), false) 102 | } 103 | } 104 | 105 | enablePlugin (pluginName) { 106 | const plugin = window.bdplugins[pluginName] 107 | if (!plugin) return this.__error(null, `Tried to enable a missing plugin: ${pluginName}`) 108 | 109 | window.BdApi.saveData('BDCompat-EnabledPlugins', plugin.getName(), true) 110 | this.startPlugin(pluginName) 111 | } 112 | disablePlugin (pluginName) { 113 | const plugin = window.bdplugins[pluginName] 114 | if (!plugin) return this.__error(null, `Tried to disable a missing plugin: ${pluginName}`) 115 | 116 | window.BdApi.saveData('BDCompat-EnabledPlugins', plugin.getName(), false) 117 | this.stopPlugin(pluginName) 118 | } 119 | 120 | loadAllPlugins () { 121 | const plugins = fs.readdirSync(this.pluginDirectory) 122 | .filter((pluginFile) => pluginFile.endsWith('.plugin.js')) 123 | .map((pluginFile) => pluginFile.slice(0, -('.plugin.js'.length))) 124 | 125 | plugins.forEach((pluginName) => this.loadPlugin(pluginName)) 126 | } 127 | loadPlugin (pluginName) { 128 | const pluginPath = path.join(this.pluginDirectory, `${pluginName}.plugin.js`) 129 | if (!fs.existsSync(pluginPath)) return this.__error(null, `Tried to load a nonexistant plugin: ${pluginName}`) 130 | 131 | let content = fs.readFileSync(pluginPath, 'utf8') 132 | if (content.charCodeAt(0) === 0xFEFF) content = content.slice(1) 133 | 134 | const meta = this.extractMeta(content) 135 | content += `\nmodule.exports = ${meta.name};` 136 | 137 | const tempPluginPath = path.join(this.pluginDirectory, `__${pluginName}.plugin.js`) 138 | fs.writeFileSync(tempPluginPath, content) 139 | 140 | // eslint-disable-next-line global-require 141 | const Plugin = require(tempPluginPath) 142 | const plugin = new Plugin 143 | plugin.__meta = meta 144 | plugin.__filePath = pluginPath 145 | 146 | if (window.bdplugins[plugin.getName()]) window.bdplugins[plugin.getName()].stop() 147 | delete window.bdplugins[plugin.getName()] 148 | window.bdplugins[plugin.getName()] = plugin 149 | 150 | if (plugin.load && typeof plugin.load === 'function') 151 | try { 152 | plugin.load() 153 | } catch (err) { 154 | this.__error(err, `Failed to preload ${plugin.getName()}`) 155 | } 156 | 157 | 158 | this.__log(`Loaded ${plugin.getName()} v${plugin.getVersion()} by ${plugin.getAuthor()}`) 159 | fs.unlinkSync(tempPluginPath) 160 | delete require.cache[require.resolve(tempPluginPath)] 161 | } 162 | 163 | extractMeta (content) { 164 | const metaLine = content.split('\n')[0] 165 | const rawMeta = metaLine.substring(metaLine.lastIndexOf('//META') + 6, metaLine.lastIndexOf('*//')) 166 | 167 | if (metaLine.indexOf('META') < 0) throw new Error('META was not found.') 168 | if (!window.BdApi.testJSON(rawMeta)) throw new Error('META could not be parsed') 169 | 170 | const parsed = JSON.parse(rawMeta) 171 | if (!parsed.name) throw new Error('META missing name data') 172 | 173 | return parsed 174 | } 175 | 176 | deletePlugin (pluginName) { 177 | const plugin = window.bdplugins[pluginName] 178 | if (!plugin) return this.__error(null, `Tried to delete a missing plugin: ${pluginName}`) 179 | 180 | this.disablePlugin(pluginName) 181 | if (typeof plugin.unload === 'function') plugin.unload() 182 | delete window.bdplugins[pluginName] 183 | 184 | fs.unlinkSync(plugin.__filePath) 185 | } 186 | 187 | 188 | fireEvent (event, ...args) { 189 | for (const plug in window.bdplugins) { 190 | const plugin = window.bdplugins[plug] 191 | if (!plugin[event] || typeof plugin[event] !== 'function') continue 192 | 193 | try { 194 | plugin[event](...args) 195 | } catch (err) { 196 | this.__error(err, `Could not fire ${event} event for ${plugin.name}`) 197 | } 198 | } 199 | } 200 | 201 | onSwitchListener () { 202 | this.fireEvent('onSwitch') 203 | } 204 | 205 | 206 | __log (...message) { 207 | console.log('%c[BDCompat:BDPluginManager]', 'color: #3a71c1;', ...message) 208 | } 209 | 210 | __warn (...message) { 211 | console.log('%c[BDCompat:BDPluginManager]', 'color: #e8a400;', ...message) 212 | } 213 | 214 | __error (error, ...message) { 215 | console.log('%c[BDCompat:BDPluginManager]', 'color: red;', ...message) 216 | 217 | if (error) { 218 | console.groupCollapsed(`%cError: ${error.message}`, 'color: red;') 219 | console.error(error.stack) 220 | console.groupEnd() 221 | } 222 | } 223 | } 224 | 225 | module.exports = BDPluginManager 226 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { Plugin } = require('powercord/entities') 4 | const { React } = require('powercord/webpack') 5 | const path = require('path') 6 | 7 | const BDApi = require('./libraries/BDApi.js') 8 | const BDV2 = require('./libraries/BDV2.js') 9 | const BDContentManager = require('./libraries/BDContentManager.js') 10 | 11 | const BDPluginManager = require('./components/BDPluginManager.js') 12 | const Settings = require('./reactcomponents/Settings.jsx') 13 | 14 | 15 | class BDCompat extends Plugin { 16 | startPlugin () { 17 | this.loadCSS(path.join(__dirname, 'style.css')) 18 | this.defineGlobals() 19 | 20 | this.PluginManager = new BDPluginManager 21 | 22 | this.registerSettings( 23 | 'pc-bdCompat', 24 | 'BetterDiscord Plugins', 25 | () => React.createElement(Settings, { settings: this.settings }) 26 | ) 27 | } 28 | 29 | pluginWillUnload () { 30 | this.PluginManager.destroy() 31 | 32 | this.unloadCSS() 33 | this.destroyGlobals() 34 | 35 | powercord.pluginManager 36 | .get('pc-settings') 37 | .unregister('pc-bdCompat') 38 | } 39 | 40 | defineGlobals () { 41 | window.bdConfig = { dataPath: __dirname } 42 | window.settingsCookie = {} 43 | 44 | window.bdplugins = {} 45 | window.pluginCookie = {} 46 | window.bdpluginErrors = [] 47 | 48 | window.bdthemes = {} 49 | window.themeCookie = {} 50 | window.bdthemeErrors = [] 51 | 52 | window.BdApi = BDApi 53 | window.bdPluginStorage = { get: BDApi.getData, set: BDApi.setData } 54 | window.Utils = { monkeyPatch: BDApi.monkeyPatch, suppressErrors: BDApi.suppressErrors, escapeID: BDApi.escapeID } 55 | 56 | window.BDV2 = BDV2 57 | window.ContentManager = BDContentManager 58 | 59 | this.log('Defined BetterDiscord globals') 60 | } 61 | 62 | destroyGlobals () { 63 | delete window.bdConfig 64 | delete window.settingsCookie 65 | delete window.bdplugins 66 | delete window.pluginCookie 67 | delete window.bdpluginErrors 68 | delete window.bdthemes 69 | delete window.themeCookie 70 | delete window.bdthemeErrors 71 | delete window.BdApi 72 | delete window.bdPluginStorage 73 | delete window.Utils 74 | delete window.BDV2 75 | 76 | this.log('Destroyed BetterDiscord globals') 77 | } 78 | } 79 | 80 | module.exports = BDCompat 81 | -------------------------------------------------------------------------------- /libraries/BDApi.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const path = require('path') 4 | const fs = require('fs') 5 | const crypto = require('crypto') 6 | 7 | const { React, ReactDOM } = require('powercord/webpack') 8 | const { getModule, getAllModules } = require('powercord/webpack') 9 | const { getOwnerInstance } = require('powercord/util') 10 | const { inject, uninject } = require('powercord/injector') 11 | 12 | const PluginData = {} 13 | 14 | 15 | // __ is not part of BdApi entirely 16 | // _ is part of BD but not exactly in BdApi, but kept here anyway for easier maintain 17 | 18 | class BdApi { 19 | // React 20 | static get React () { 21 | return React 22 | } 23 | static get ReactDOM () { 24 | return ReactDOM 25 | } 26 | 27 | 28 | // General 29 | static getCore () { 30 | return null 31 | } 32 | static escapeID (id) { 33 | return id.replace(/^[^a-z]+|[^\w-]+/giu, '') 34 | } 35 | 36 | static suppressErrors (method, message = '') { 37 | return (...params) => { 38 | try { 39 | return method(...params) 40 | } catch (err) { 41 | BdApi.__error(err, `Error occured in ${message}`) 42 | } 43 | } 44 | } 45 | 46 | static testJSON (data) { 47 | try { 48 | JSON.parse(data) 49 | 50 | return true 51 | } catch (err) { 52 | return false 53 | } 54 | } 55 | 56 | 57 | // Style tag 58 | static get __styleParent () { 59 | return BdApi.__elemParent('style') 60 | } 61 | 62 | static injectCSS (id, css) { 63 | const style = document.createElement('style') 64 | 65 | style.id = `bd-style-${BdApi.escapeID(id)}` 66 | style.innerHTML = css 67 | 68 | BdApi.__styleParent.append(style) 69 | } 70 | 71 | static clearCSS (id) { 72 | const elem = document.getElementById(`bd-style-${BdApi.escapeID(id)}`) 73 | if (elem) elem.remove() 74 | } 75 | 76 | 77 | // Script tag 78 | static get __scriptParent () { 79 | return BdApi.__elemParent('script') 80 | } 81 | 82 | static linkJS (id, url) { 83 | return new Promise((resolve) => { 84 | const script = document.createElement('script') 85 | 86 | script.id = `bd-script-${BdApi.escapeID(id)}` 87 | script.src = url 88 | script.type = 'text/javascript' 89 | script.onload = resolve 90 | 91 | BdApi.__scriptParent.append(script) 92 | }) 93 | } 94 | 95 | static unlinkJS (id) { 96 | const elem = document.getElementById(`bd-script-${BdApi.escapeID(id)}`) 97 | if (elem) elem.remove() 98 | } 99 | 100 | 101 | // Plugin data 102 | static get __pluginData () { 103 | return PluginData 104 | } 105 | 106 | static __getPluginConfigPath (pluginName) { 107 | return path.join(__dirname, '..', 'config', pluginName + '.json') 108 | } 109 | 110 | static __getPluginConfig (pluginName) { 111 | const configPath = BdApi.__getPluginConfigPath(pluginName) 112 | 113 | if (typeof BdApi.__pluginData[pluginName] === 'undefined') 114 | if (!fs.existsSync(configPath)) { 115 | BdApi.__pluginData[pluginName] = {} 116 | } else { 117 | BdApi.__pluginData[pluginName] = JSON.parse(fs.readFileSync(configPath)) 118 | } 119 | 120 | 121 | return BdApi.__pluginData[pluginName] 122 | } 123 | 124 | static __savePluginConfig (pluginName) { 125 | const configPath = BdApi.__getPluginConfigPath(pluginName) 126 | const configFolder = path.join(__dirname, '..', 'config/') 127 | 128 | if (!fs.existsSync(configFolder)) fs.mkdirSync(configFolder) 129 | fs.writeFileSync(configPath, JSON.stringify(BdApi.__pluginData[pluginName], null, 2)) 130 | } 131 | 132 | 133 | static loadData (pluginName, key) { 134 | const config = BdApi.__getPluginConfig(pluginName) 135 | 136 | return config[key] 137 | } 138 | 139 | static get getData () { 140 | return BdApi.loadData 141 | } 142 | 143 | 144 | static saveData (pluginName, key, value) { 145 | if (typeof value === 'undefined') return 146 | 147 | const config = BdApi.__getPluginConfig(pluginName) 148 | 149 | config[key] = value 150 | 151 | BdApi.__savePluginConfig(pluginName) 152 | } 153 | 154 | static get setData () { 155 | return BdApi.saveData 156 | } 157 | 158 | static deleteData (pluginName, key) { 159 | const config = BdApi.__getPluginConfig(pluginName) 160 | 161 | if (typeof config[key] === 'undefined') return 162 | delete config[key] 163 | 164 | BdApi.__savePluginConfig(pluginName) 165 | } 166 | 167 | 168 | // Plugin communication 169 | static getPlugin (name) { 170 | if (window.bdplugins[name]) return window.bdplugins[name].plugin 171 | } 172 | 173 | 174 | // Alerts and toasts 175 | static alert (title, body) { 176 | const ModalStack = getModule(['push', 'update', 'pop', 'popWithKey']) 177 | const AlertModal = getModule((module) => module.prototype && 178 | module.prototype.handleCancel && module.prototype.handleSubmit && module.prototype.handleMinorConfirm) 179 | 180 | ModalStack.push((props) => BdApi.React.createElement(AlertModal, { title, body, ...props })) 181 | } 182 | 183 | static showToast (content, options = {}) { 184 | const { type = '', icon = true, timeout = 3000 } = options 185 | 186 | const toastElem = document.createElement('div') 187 | toastElem.classList.add('bd-toast') 188 | toastElem.innerText = content 189 | 190 | if (type) toastElem.classList.add(`toast-${type}`) 191 | if (type && icon) toastElem.classList.add('icon') 192 | 193 | const toastWrapper = BdApi.__createToastWrapper() 194 | toastWrapper.appendChild(toastElem) 195 | 196 | setTimeout(() => { 197 | toastElem.classList.add('closing') 198 | 199 | setTimeout(() => { 200 | toastElem.remove() 201 | if (!document.querySelectorAll('.bd-toasts .bd-toast').length) toastWrapper.remove() 202 | }, 300) 203 | }, timeout) 204 | } 205 | 206 | static __createToastWrapper () { 207 | const toastWrapperElem = document.querySelector('.bd-toasts') 208 | 209 | if (!toastWrapperElem) { 210 | const DiscordElements = { 211 | settings: '.contentColumn-2hrIYH, .customColumn-Rb6toI', 212 | chat: '.chat-3bRxxu form', 213 | friends: '.container-3gCOGc', 214 | serverDiscovery: '.pageWrapper-1PgVDX', 215 | applicationStore: '.applicationStore-1pNvnv', 216 | gameLibrary: '.gameLibrary-TTDw4Y', 217 | activityFeed: '.activityFeed-28jde9', 218 | } 219 | 220 | const boundingElement = document.querySelector(Object.keys(DiscordElements).map((component) => DiscordElements[component]).join(', ')) 221 | 222 | const toastWrapper = document.createElement('div') 223 | toastWrapper.classList.add('bd-toasts') 224 | toastWrapper.style.setProperty('width', boundingElement ? boundingElement.offsetWidth + 'px' : '100%') 225 | toastWrapper.style.setProperty('left', boundingElement ? boundingElement.getBoundingClientRect().left + 'px' : '0px') 226 | toastWrapper.style.setProperty( 227 | 'bottom', 228 | (document.querySelector(DiscordElements.chat) ? document.querySelector(DiscordElements.chat).offsetHeight + 20 : 80) + 'px' 229 | ) 230 | 231 | document.querySelector('#app-mount > div[class^="app-"]').appendChild(toastWrapper) 232 | 233 | return toastWrapper 234 | } 235 | 236 | return toastWrapperElem 237 | } 238 | 239 | 240 | // Discord's internals manipulation and such 241 | static onRemoved (node, callback) { 242 | const observer = new MutationObserver((mutations) => { 243 | for (const mut in mutations) { 244 | const mutation = mutations[mut] 245 | const nodes = Array.from(mutation.removedNodes) 246 | 247 | const directMatch = nodes.indexOf(node) > -1 248 | const parentMatch = nodes.some((parent) => parent.contains(node)) 249 | 250 | if (directMatch || parentMatch) { 251 | observer.disconnect() 252 | 253 | return callback() 254 | } 255 | } 256 | }) 257 | 258 | observer.observe(document.body, { subtree: true, childList: true }) 259 | } 260 | 261 | static getInternalInstance (node) { 262 | if (!(node instanceof window.jQuery) && !(node instanceof Element)) return undefined // eslint-disable-line no-undefined 263 | if (node instanceof window.jQuery) node = node[0] // eslint-disable-line no-param-reassign 264 | 265 | return getOwnerInstance(node) 266 | } 267 | 268 | static findModule (filter) { 269 | return getModule(filter) 270 | } 271 | 272 | static findAllModules (filter) { 273 | return getAllModules(filter) 274 | } 275 | 276 | static findModuleByProps (...props) { 277 | return BdApi.findModule((module) => props.every((prop) => typeof module[prop] !== 'undefined')) 278 | } 279 | 280 | static _findModuleByDisplayName (displayName) { 281 | return BdApi.findModule((module) => module.displayName === displayName) 282 | } 283 | 284 | static monkeyPatch (what, methodName, options = {}) { 285 | const displayName = options.displayName || 286 | what.displayName || what.name || what.constructor.displayName || what.constructor.name || 287 | 'MissingName' 288 | 289 | if (options.instead) return BdApi.__warn('Powercord API currently does not support replacing the entire method!') 290 | 291 | if (!what[methodName]) 292 | if (options.force) { 293 | // eslint-disable-next-line no-empty-function 294 | what[methodName] = function forcedFunction () {} 295 | } else { 296 | return BdApi.__error(null, `${methodName} doesn't exist in ${displayName}!`) 297 | } 298 | 299 | 300 | if (!options.silent) 301 | BdApi.__log(`Patching ${displayName}'s ${methodName} method`) 302 | 303 | 304 | const patches = [] 305 | if (options.before) patches.push(BdApi.__injectBefore({ what, methodName, options, displayName })) 306 | if (options.after) patches.push(BdApi.__injectAfter({ what, methodName, options, displayName })) 307 | 308 | const finalCancelPatch = () => patches.forEach((patch) => patch()) 309 | 310 | return finalCancelPatch 311 | } 312 | 313 | static __injectBefore (data) { 314 | const patchID = `bd-patch-before-${data.displayName.toLowerCase()}-${crypto.randomBytes(4).toString('hex')}` 315 | 316 | const cancelPatch = () => { 317 | if (!data.options.silent) BdApi.__log(`Unpatching before of ${data.displayName} ${data.methodName}`) 318 | uninject(patchID) 319 | } 320 | 321 | inject(patchID, data.what, data.methodName, function beforePatch (args, res) { 322 | const patchData = { 323 | // eslint-disable-next-line no-invalid-this 324 | thisObject: this, 325 | methodArguments: args, 326 | returnValue: res, 327 | cancelPatch: cancelPatch, 328 | // originalMethod, 329 | // callOriginalMethod, 330 | } 331 | 332 | try { 333 | data.options.before(patchData) 334 | } catch (err) { 335 | BdApi.__error(err, `Error in before callback of ${data.displayName} ${data.methodName}`) 336 | } 337 | 338 | if (data.options.once) cancelPatch() 339 | 340 | return patchData.returnValue 341 | }, true) 342 | 343 | return cancelPatch 344 | } 345 | 346 | static __injectAfter (data) { 347 | const patchID = `bd-patch-after-${data.displayName.toLowerCase()}-${crypto.randomBytes(4).toString('hex')}` 348 | 349 | const cancelPatch = () => { 350 | if (!data.options.silent) BdApi.__log(`Unpatching after of ${data.displayName} ${data.methodName}`) 351 | uninject(patchID) 352 | } 353 | 354 | inject(patchID, data.what, data.methodName, function afterPatch (args, res) { 355 | const patchData = { 356 | // eslint-disable-next-line no-invalid-this 357 | thisObject: this, 358 | methodArguments: args, 359 | returnValue: res, 360 | cancelPatch: cancelPatch, 361 | // originalMethod, 362 | // callOriginalMethod, 363 | } 364 | 365 | try { 366 | data.options.after(patchData) 367 | } catch (err) { 368 | BdApi.__error(err, `Error in after callback of ${data.displayName} ${data.methodName}`) 369 | } 370 | 371 | if (data.options.once) cancelPatch() 372 | 373 | return patchData.returnValue 374 | }, false) 375 | 376 | return cancelPatch 377 | } 378 | 379 | 380 | // Miscellaneous, things that aren't part of BD 381 | static __elemParent (id) { 382 | const elem = document.getElementsByTagName(`bd-${id}`)[0] 383 | if (elem) return elem 384 | 385 | const newElem = document.createElement(`bd-${id}`) 386 | document.head.append(newElem) 387 | 388 | return newElem 389 | } 390 | 391 | static __log (...message) { 392 | console.log('%c[BDCompat:BdApi]', 'color: #3a71c1;', ...message) 393 | } 394 | 395 | static __warn (...message) { 396 | console.log('%c[BDCompat:BdApi]', 'color: #e8a400;', ...message) 397 | } 398 | 399 | static __error (error, ...message) { 400 | console.log('%c[BDCompat:BdApi]', 'color: red;', ...message) 401 | 402 | if (error) { 403 | console.groupCollapsed(`%cError: ${error.message}`, 'color: red;') 404 | console.error(error.stack) 405 | console.groupEnd() 406 | } 407 | } 408 | } 409 | 410 | module.exports = BdApi 411 | -------------------------------------------------------------------------------- /libraries/BDContentManager.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | 3 | // Purposefully incomplete ContentManager 4 | class ContentManager { 5 | static get pluginsFolder () { 6 | return window.powercord.pluginManager.plugins.get('pc-bdCompat').PluginManager.pluginDirectory 7 | } 8 | static get themesFolder () { 9 | // We'll just pretend it exists. 10 | return path.join(ContentManager.pluginsFolder, '..', 'themes') 11 | } 12 | 13 | static get extractMeta () { 14 | return window.powercord.pluginManager.plugins.get('pc-bdCompat').PluginManager.extractMeta 15 | } 16 | } 17 | 18 | module.exports = ContentManager -------------------------------------------------------------------------------- /libraries/BDV2.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | // Not to be confused with the actual BDv2's API 4 | class V2 { 5 | static get WebpackModules () { 6 | return { 7 | find: window.BdApi.findModule, 8 | findAll: window.BdApi.findAll, 9 | findByUniqueProperties: window.BdApi.findModuleByProps, 10 | findByDisplayName: window.BdApi._findModuleByDisplayName, 11 | } 12 | } 13 | 14 | static get react () { 15 | return window.BdApi.React 16 | } 17 | static get reactDom () { 18 | return window.BdApi.ReactDOM 19 | } 20 | 21 | static get getInternalInstance () { 22 | return window.BdApi.getInternalInstance 23 | } 24 | } 25 | 26 | module.exports = V2 27 | -------------------------------------------------------------------------------- /manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "BetterDiscord plugin compatibility", 3 | "version": "0.2.0", 4 | "description": "Adds support for running BetterDiscord plugins", 5 | "author": "intrnl", 6 | "license": "MIT", 7 | "repo": "https://github.com/intrnl/pc-bdCompat" 8 | } 9 | -------------------------------------------------------------------------------- /reactcomponents/DeleteConfirm.jsx: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { React, getModule } = require('powercord/webpack') 4 | const { Confirm: ConfirmModal } = require('powercord/components/modal') 5 | const { close: closeModal } = require('powercord/modal') 6 | 7 | const Text = getModule(['Sizes', 'Weights']) 8 | 9 | class DeleteConfirm extends React.Component { 10 | constructor (props) { 11 | super(props) 12 | } 13 | 14 | render () { 15 | return ( 16 | closeModal()} 23 | > 24 | 28 | Are you sure you want to delete {this.props.plugin.getName()}? This can't be undone! 29 | 30 | 31 | ) 32 | } 33 | } 34 | 35 | module.exports = DeleteConfirm 36 | -------------------------------------------------------------------------------- /reactcomponents/Icons.jsx: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { React } = require('powercord/webpack'); 4 | 5 | module.exports = { 6 | Author: (props) =>
7 | 8 | 12 | 13 |
, 14 | Version: (props) =>
15 | 16 | 20 | 21 |
, 22 | Description: (props) =>
23 | 24 | 28 | 29 |
, 30 | License: (props) =>
31 | 32 | 36 | 37 |
, 38 | Info: (props) =>
39 | 40 | 44 | 45 |
46 | }; 47 | -------------------------------------------------------------------------------- /reactcomponents/Plugin.jsx: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { shell: { openExternal } } = require('electron') 4 | 5 | const { React } = require('powercord/webpack') 6 | const { Tooltip, Switch, Button } = require('powercord/components') 7 | const { open: openModal } = require('powercord/modal') 8 | 9 | const Icons = require('./Icons.jsx') 10 | const SettingsModal = require('./PluginSettings.jsx') 11 | const DeleteConfirm = require('./DeleteConfirm.jsx') 12 | 13 | class Plugin extends React.Component { 14 | constructor (props) { 15 | super(props) 16 | } 17 | 18 | render () { 19 | this.props.enabled = this.props.plugin.__started 20 | 21 | // We're reusing Powercord's plugin manager classes 22 | return ( 23 |
24 |
25 |

{this.props.plugin.getName()}

26 | 27 |
28 | this.togglePlugin()}/> 29 |
30 |
31 |
32 | 33 |
34 |
35 | 36 | 37 | 38 | {this.props.plugin.getAuthor()} 39 |
40 | 41 |
42 | 43 | 44 | 45 | v{this.props.plugin.getVersion()} 46 |
47 | 48 |
49 | 50 | 51 | 52 | {this.props.plugin.getDescription()} 53 |
54 |
55 | 56 |
57 | {this.props.meta.source && 58 | 66 | } 67 | 68 | {this.props.meta.website && 69 | 77 | } 78 | 79 |
80 | 81 | {typeof this.props.plugin.getSettingsPanel === 'function' && 82 | 90 | } 91 | {typeof this.props.plugin.getSettingsPanel === 'function' && 92 |
93 | } 94 | 95 | 103 |
104 |
105 | ) 106 | } 107 | 108 | togglePlugin () { 109 | if (this.props.enabled) { 110 | this.props.onDisable() 111 | } else { 112 | this.props.onEnable() 113 | } 114 | 115 | this.forceUpdate() 116 | } 117 | } 118 | 119 | module.exports = Plugin 120 | 121 | -------------------------------------------------------------------------------- /reactcomponents/PluginList.jsx: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { shell: { openItem } } = require('electron') 4 | 5 | const { React } = require('powercord/webpack') 6 | const { Button } = require('powercord/components') 7 | const { TextInput } = require('powercord/components/settings') 8 | 9 | const Plugin = require('./Plugin.jsx') 10 | 11 | 12 | class PluginList extends React.Component { 13 | constructor (props) { 14 | super(props) 15 | 16 | this.state = { 17 | search: '', 18 | } 19 | } 20 | render () { 21 | const plugins = this.__getPlugins() 22 | 23 | return ( 24 |
25 |
26 | this.setState({ search: val })} 29 | placeholder='What are you looking for?' 30 | > 31 | Search plugins 32 | 33 | 34 | 40 |
41 | 42 |
43 | {plugins.map((plugin) => 44 | this.props.pluginManager.enablePlugin(plugin.getName())} 49 | onDisable={() => this.props.pluginManager.disablePlugin(plugin.getName())} 50 | onDelete={() => this.__deletePlugin(plugin.getName())} 51 | /> 52 | )} 53 |
54 |
55 | ) 56 | } 57 | 58 | __getPlugins () { 59 | let plugins = Object.keys(window.bdplugins) 60 | .map((plugin) => window.bdplugins[plugin]) 61 | 62 | if (this.state.search !== '') { 63 | const search = this.state.search.toLowerCase() 64 | 65 | plugins = plugins.filter((plugin) => 66 | plugin.getName().toLowerCase().includes(search) || 67 | plugin.getAuthor().toLowerCase().includes(search) || 68 | plugin.getDescription().toLowerCase().includes(search) 69 | ) 70 | } 71 | 72 | return plugins.sort((a, b) => { 73 | const nameA = a.getName().toLowerCase() 74 | const nameB = b.getName().toLowerCase() 75 | 76 | if (nameA < nameB) return -1 77 | if (nameA > nameB) return 1 78 | 79 | return 0 80 | }) 81 | } 82 | 83 | __deletePlugin (pluginName) { 84 | this.props.pluginManager.deletePlugin(pluginName) 85 | 86 | this.forceUpdate() 87 | } 88 | } 89 | 90 | module.exports = PluginList 91 | -------------------------------------------------------------------------------- /reactcomponents/PluginSettings.jsx: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { React } = require('powercord/webpack') 4 | const { Modal } = require('powercord/components/modal') 5 | const { getModuleByDisplayName } = require('powercord/webpack') 6 | const { close: closeModal } = require('powercord/modal') 7 | 8 | const FormTitle = getModuleByDisplayName('FormTitle') 9 | 10 | class PluginSettings extends React.Component { 11 | constructor (props) { 12 | super(props) 13 | } 14 | 15 | render () { 16 | const plugin = this.props.plugin 17 | 18 | return ( 19 | 20 | 21 | {plugin.getName()} Settings 22 | 23 | 24 | 25 |
this.PluginSettingsContainer = node}>
26 |
27 |
28 | ) 29 | } 30 | 31 | componentDidMount () { 32 | if (!this.PluginSettingsContainer) return 33 | this.PluginSettingsContainer.appendChild(this.props.plugin.getSettingsPanel()) 34 | } 35 | } 36 | 37 | module.exports = PluginSettings 38 | -------------------------------------------------------------------------------- /reactcomponents/Settings.jsx: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | const { React } = require('powercord/webpack') 4 | 5 | const PluginList = require('./PluginList.jsx') 6 | 7 | class Settings extends React.Component { 8 | constructor (props) { 9 | super(props) 10 | 11 | this.pluginManager = powercord.pluginManager.plugins.get('pc-bdCompat').PluginManager 12 | } 13 | 14 | render () { 15 | return ( 16 | 17 | ) 18 | } 19 | } 20 | 21 | module.exports = Settings 22 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | 2 | 3 | #bd-settingspane-container h2.ui-form-title { 4 | font-size: 16px; 5 | font-weight: 600; 6 | line-height: 20px; 7 | text-transform: uppercase; 8 | display: inline-block; 9 | margin-bottom: 20px; 10 | } 11 | 12 | #bd-settingspane-container h2.ui-form-title { 13 | color: #f6f6f7; 14 | } 15 | 16 | .theme-light #bd-settingspane-container h2.ui-form-title { 17 | color: #4f545c; 18 | } 19 | 20 | #bd-settingspane-container .ui-switch-item { 21 | flex-direction: column; 22 | margin-top: 8px; 23 | } 24 | 25 | #bd-settingspane-container .ui-switch-item h3 { 26 | font-size: 16px; 27 | font-weight: 500; 28 | line-height: 24px; 29 | flex: 1; 30 | } 31 | 32 | #bd-settingspane-container .ui-switch-item h3 { 33 | color: #f6f6f7; 34 | } 35 | 36 | .theme-light #bd-settingspane-container .ui-switch-item h3 { 37 | color: #4f545c; 38 | } 39 | 40 | #bd-settingspane-container .ui-switch-item .style-description { 41 | font-size: 14px; 42 | font-weight: 500; 43 | line-height: 20px; 44 | margin-bottom: 10px; 45 | padding-bottom: 10px; 46 | border-bottom: 1px solid hsla(218, 5%, 47%, .3); 47 | } 48 | 49 | #bd-settingspane-container .ui-switch-item .style-description { 50 | color: #72767d; 51 | } 52 | 53 | .theme-light #bd-settingspane-container .ui-switch-item .style-description { 54 | color: rgba(114, 118, 125, .6); 55 | } 56 | 57 | #bd-settingspane-container .ui-switch-item .ui-switch-wrapper { 58 | -webkit-user-select: none; 59 | -moz-user-select: none; 60 | -ms-user-select: none; 61 | user-select: none; 62 | position: relative; 63 | width: 44px; 64 | height: 24px; 65 | display: block; 66 | flex: 0 0 auto; 67 | } 68 | 69 | #bd-settingspane-container .ui-switch-item .ui-switch-wrapper input { 70 | position: absolute; 71 | opacity: 0; 72 | cursor: pointer; 73 | width: 100%; 74 | height: 100%; 75 | z-index: 1; 76 | } 77 | 78 | #bd-settingspane-container .ui-switch-item .ui-switch-wrapper .ui-switch { 79 | background: #7289da; 80 | position: absolute; 81 | top: 0; 82 | right: 0; 83 | bottom: 0; 84 | left: 0; 85 | background: #72767d; 86 | border-radius: 14px; 87 | transition: background .15s ease-in-out, box-shadow .15s ease-in-out, border .15s ease-in-out; 88 | } 89 | 90 | #bd-settingspane-container .ui-switch-item .ui-switch-wrapper .ui-switch:before { 91 | content: ''; 92 | display: block; 93 | width: 18px; 94 | height: 18px; 95 | position: absolute; 96 | top: 3px; 97 | left: 3px; 98 | bottom: 3px; 99 | background: #f6f6f7; 100 | border-radius: 10px; 101 | transition: all .15s ease; 102 | box-shadow: 0 3px 1px 0 rgba(0, 0, 0, .05), 0 2px 2px 0 rgba(0, 0, 0, .1), 0 3px 3px 0 rgba(0, 0, 0, .05); 103 | } 104 | 105 | #bd-settingspane-container .ui-switch-item .ui-switch-wrapper .ui-switch.checked { 106 | background: #7289da; 107 | } 108 | 109 | #bd-settingspane-container .ui-switch-item .ui-switch-wrapper .ui-switch.checked:before { 110 | transform: translateX(20px); 111 | } 112 | 113 | #bd-settingspane-container .plugin-settings { 114 | padding: 0 12px 12px 20px; 115 | } 116 | 117 | @keyframes bd-modal-backdrop { 118 | to { 119 | opacity: 0.85; 120 | } 121 | } 122 | 123 | @keyframes bd-modal-anim { 124 | to { 125 | transform: scale(1); 126 | opacity: 1; 127 | } 128 | } 129 | 130 | @keyframes bd-modal-backdrop-closing { 131 | to { 132 | opacity: 0; 133 | } 134 | } 135 | 136 | @keyframes bd-modal-closing { 137 | to { 138 | transform: scale(0.7); 139 | opacity: 0; 140 | } 141 | } 142 | 143 | #bd-settingspane-container .backdrop { 144 | animation: bd-modal-backdrop 250ms ease; 145 | animation-fill-mode: forwards; 146 | background-color: rgb(0, 0, 0); 147 | transform: translateZ(0px); 148 | } 149 | 150 | #bd-settingspane-container.closing .backdrop { 151 | animation: bd-modal-backdrop-closing 200ms linear; 152 | animation-fill-mode: forwards; 153 | animation-delay: 50ms; 154 | opacity: 0.85; 155 | } 156 | 157 | #bd-settingspane-container.closing .modal { 158 | animation: bd-modal-closing 250ms cubic-bezier(0.19, 1, 0.22, 1); 159 | animation-fill-mode: forwards; 160 | opacity: 1; 161 | transform: scale(1); 162 | } 163 | 164 | #bd-settingspane-container .modal { 165 | animation: bd-modal-anim 250ms cubic-bezier(0.175, 0.885, 0.32, 1.275); 166 | animation-fill-mode: forwards; 167 | transform: scale(0.7); 168 | transform-origin: 50% 50%; 169 | } 170 | 171 | 172 | .bdc-spacer { 173 | flex-grow: 1; 174 | flex-shrink: 1; 175 | } 176 | .bdc-margin { 177 | width: 20px; 178 | } 179 | .bdc-justifystart { 180 | justify-content: flex-start; 181 | } 182 | 183 | /* Toast CSS */ 184 | 185 | .bd-toasts { 186 | position: fixed; 187 | display: flex; 188 | top: 0; 189 | flex-direction: column; 190 | align-items: center; 191 | justify-content: flex-end; 192 | pointer-events: none; 193 | z-index: 4000; 194 | } 195 | 196 | @keyframes toast-up { 197 | from { 198 | transform: translateY(0); 199 | opacity: 0; 200 | } 201 | } 202 | 203 | .bd-toast { 204 | animation: toast-up 300ms ease; 205 | transform: translateY(-10px); 206 | background: #36393F; 207 | padding: 10px; 208 | border-radius: 5px; 209 | box-shadow: 0 0 0 1px rgba(32, 34, 37, .6), 0 2px 10px 0 rgba(0, 0, 0, .2); 210 | font-weight: 500; 211 | color: #fff; 212 | user-select: text; 213 | font-size: 14px; 214 | opacity: 1; 215 | margin-top: 10px; 216 | pointer-events: none; 217 | user-select: none; 218 | } 219 | 220 | @keyframes toast-down { 221 | to { 222 | transform: translateY(0px); 223 | opacity: 0; 224 | } 225 | } 226 | 227 | .bd-toast.closing { 228 | animation: toast-down 200ms ease; 229 | animation-fill-mode: forwards; 230 | opacity: 1; 231 | transform: translateY(-10px); 232 | } 233 | 234 | .bd-toast.icon { 235 | padding-left: 30px; 236 | background-size: 20px 20px; 237 | background-repeat: no-repeat; 238 | background-position: 6px 50%; 239 | } 240 | 241 | .bd-toast.toast-info { 242 | background-color: #4a90e2; 243 | } 244 | 245 | .bd-toast.toast-info.icon { 246 | background-image: url(data:image/svg+xml;base64,PHN2ZyBmaWxsPSIjRkZGRkZGIiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiIHdpZHRoPSIyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4gICAgPHBhdGggZD0iTTAgMGgyNHYyNEgweiIgZmlsbD0ibm9uZSIvPiAgICA8cGF0aCBkPSJNMTIgMkM2LjQ4IDIgMiA2LjQ4IDIgMTJzNC40OCAxMCAxMCAxMCAxMC00LjQ4IDEwLTEwUzE3LjUyIDIgMTIgMnptMSAxNWgtMnYtNmgydjZ6bTAtOGgtMlY3aDJ2MnoiLz48L3N2Zz4=); 247 | } 248 | 249 | .bd-toast.toast-success { 250 | background-color: #43b581; 251 | } 252 | 253 | .bd-toast.toast-success.icon { 254 | background-image: url(data:image/svg+xml;base64,PHN2ZyBmaWxsPSIjRkZGRkZGIiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiIHdpZHRoPSIyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4gICAgPHBhdGggZD0iTTAgMGgyNHYyNEgweiIgZmlsbD0ibm9uZSIvPiAgICA8cGF0aCBkPSJNMTIgMkM2LjQ4IDIgMiA2LjQ4IDIgMTJzNC40OCAxMCAxMCAxMCAxMC00LjQ4IDEwLTEwUzE3LjUyIDIgMTIgMnptLTIgMTVsLTUtNSAxLjQxLTEuNDFMMTAgMTQuMTdsNy41OS03LjU5TDE5IDhsLTkgOXoiLz48L3N2Zz4=); 255 | } 256 | 257 | .bd-toast.toast-danger, 258 | .bd-toast.toast-error { 259 | background-color: #f04747; 260 | } 261 | 262 | .bd-toast.toast-danger.icon, 263 | .bd-toast.toast-error.icon { 264 | background-image: url(data:image/svg+xml;base64,PHN2ZyBmaWxsPSIjRkZGRkZGIiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiIHdpZHRoPSIyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4gICAgPHBhdGggZD0iTTEyIDJDNi40NyAyIDIgNi40NyAyIDEyczQuNDcgMTAgMTAgMTAgMTAtNC40NyAxMC0xMFMxNy41MyAyIDEyIDJ6bTUgMTMuNTlMMTUuNTkgMTcgMTIgMTMuNDEgOC40MSAxNyA3IDE1LjU5IDEwLjU5IDEyIDcgOC40MSA4LjQxIDcgMTIgMTAuNTkgMTUuNTkgNyAxNyA4LjQxIDEzLjQxIDEyIDE3IDE1LjU5eiIvPiAgICA8cGF0aCBkPSJNMCAwaDI0djI0SDB6IiBmaWxsPSJub25lIi8+PC9zdmc+); 265 | } 266 | 267 | .bd-toast.toast-warning, 268 | .bd-toast.toast-warn { 269 | background-color: #FFA600; 270 | color: white; 271 | } 272 | 273 | .bd-toast.toast-warning.icon, 274 | .bd-toast.toast-warn.icon { 275 | background-image: url(data:image/svg+xml;base64,PHN2ZyBmaWxsPSIjRkZGRkZGIiBoZWlnaHQ9IjI0IiB2aWV3Qm94PSIwIDAgMjQgMjQiIHdpZHRoPSIyNCIgeG1sbnM9Imh0dHA6Ly93d3cudzMub3JnLzIwMDAvc3ZnIj4gICAgPHBhdGggZD0iTTAgMGgyNHYyNEgweiIgZmlsbD0ibm9uZSIvPiAgICA8cGF0aCBkPSJNMSAyMWgyMkwxMiAyIDEgMjF6bTEyLTNoLTJ2LTJoMnYyem0wLTRoLTJ2LTRoMnY0eiIvPjwvc3ZnPg==); 276 | } 277 | --------------------------------------------------------------------------------