import Utilities from "./utilities"; import Logger from "common/logger"; import Settings from "./settingsmanager"; import Events from "./emitter"; import DataStore from "./datastore"; import AddonError from "../structs/addonerror"; import MetaError from "../structs/metaerror"; import Toasts from "../ui/toasts"; import DiscordModules from "./discordmodules"; import Strings from "./strings"; import AddonEditor from "../ui/misc/addoneditor"; import FloatingWindows from "../ui/floatingwindows"; const React = DiscordModules.React; const path = require("path"); const fs = require("fs"); const Module = require("module").Module; const splitRegex = /[^\S\r\n]*?\r?(?:\r\n|\n)[^\S\r\n]*?\*[^\S\r\n]?/; const escapedAtRegex = /^\\@/; const stripBOM = function(fileContent) { if (fileContent.charCodeAt(0) === 0xFEFF) { fileContent = fileContent.slice(1); } return fileContent; }; export default class AddonManager { get name() {return "";} get moduleExtension() {return "";} get extension() {return "";} get duplicatePattern() {return /./;} get addonFolder() {return "";} get language() {return "";} get prefix() {return "addon";} get collection() {return "settings";} get category() {return "addons";} get id() {return "autoReload";} emit(event, ...args) {return Events.emit(`${this.prefix}-${event}`, ...args);} constructor() { this.timeCache = {}; this.addonList = []; this.state = {}; this.windows = new Set(); } async initialize() { this.originalRequire = Module._extensions[this.moduleExtension]; Module._extensions[this.moduleExtension] = this.getAddonRequire(); Settings.on(this.collection, this.category, this.id, (enabled) => { if (enabled) this.watchAddons(); else this.unwatchAddons(); }); return await this.loadAllAddons(); } // Subclasses should overload this and modify the addon object as needed to fully load it initializeAddon() {return;} // Subclasses should overload this and modify the fileContent as needed to require() the file getFileModification(module, fileContent) {return fileContent;} startAddon() {return;} stopAddon() {return;} loadState() { const saved = DataStore.getData(`${this.prefix}s`); if (!saved) return; Object.assign(this.state, saved); } saveState() { DataStore.setData(`${this.prefix}s`, this.state); } watchAddons() { if (this.watcher) return Logger.err(this.name, `Already watching ${this.prefix} addons.`); Logger.log(this.name, `Starting to watch ${this.prefix} addons.`); this.watcher = fs.watch(this.addonFolder, {persistent: false}, async (eventType, filename) => { if (!eventType || !filename) return; const absolutePath = path.resolve(this.addonFolder, filename); if (!filename.endsWith(this.extension)) { // Lets check to see if this filename has the duplicated file pattern `something(1).ext` const match = filename.match(this.duplicatePattern); if (!match) return; const ext = match[0]; const truncated = filename.replace(ext, ""); const newFilename = truncated + this.extension; // If this file already exists, give a warning and move on. if (fs.existsSync(newFilename)) { Logger.warn("AddonManager", `Duplicate files found: ${filename} and ${newFilename}`); return; } // Rename the file and let it go on fs.renameSync(absolutePath, path.resolve(this.addonFolder, newFilename)); } await new Promise(r => setTimeout(r, 100)); try { const stats = fs.statSync(absolutePath); if (!stats.isFile()) return; if (!stats || !stats.mtime || !stats.mtime.getTime()) return; if (typeof(stats.mtime.getTime()) !== "number") return; if (this.timeCache[filename] == stats.mtime.getTime()) return; this.timeCache[filename] = stats.mtime.getTime(); if (eventType == "rename") this.loadAddon(filename, true); if (eventType == "change") this.reloadAddon(filename, true); } catch (err) { if (err.code !== "ENOENT") return; delete this.timeCache[filename]; this.unloadAddon(filename, true); } }); } unwatchAddons() { if (!this.watcher) return Logger.error(this.name, `Was not watching ${this.prefix} addons.`); this.watcher.close(); delete this.watcher; Logger.log(this.name, `No longer watching ${this.prefix} addons.`); } extractMeta(fileContent) { const firstLine = fileContent.split("\n")[0]; const hasOldMeta = firstLine.includes("//META"); if (hasOldMeta) return this.parseOldMeta(fileContent); const hasNewMeta = firstLine.includes("/**"); if (hasNewMeta) return this.parseNewMeta(fileContent); throw new MetaError(Strings.Addons.metaNotFound); } parseOldMeta(fileContent) { 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 MetaError(Strings.Addons.metaError); if (!parsed.name) throw new MetaError(Strings.Addons.missingNameData); parsed.format = "json"; return parsed; } parseNewMeta(fileContent) { const block = fileContent.split("/**", 2)[1].split("*/", 1)[0]; const out = {}; let field = ""; let accum = ""; for (const line of block.split(splitRegex)) { if (line.length === 0) continue; if (line.charAt(0) === "@" && line.charAt(1) !== " ") { out[field] = accum; const l = line.indexOf(" "); field = line.substr(1, l - 1); accum = line.substr(l + 1); } else { accum += " " + line.replace("\\n", "\n").replace(escapedAtRegex, "@"); } } out[field] = accum.trim(); delete out[""]; out.format = "jsdoc"; return out; } getAddonRequire() { const self = this; // const baseFolder = this.addonFolder; const originalRequire = this.originalRequire; return function(module, filename) { const possiblePath = path.resolve(self.addonFolder, path.basename(filename)); if (!fs.existsSync(possiblePath) || filename !== fs.realpathSync(possiblePath)) return Reflect.apply(originalRequire, this, arguments); let fileContent = fs.readFileSync(filename, "utf8"); fileContent = stripBOM(fileContent); const stats = fs.statSync(filename); const meta = self.extractMeta(fileContent); if (!meta.author) meta.author = Strings.Addons.unknownAuthor; if (!meta.version) meta.version = "???"; if (!meta.description) meta.description = Strings.Addons.noDescription; meta.id = meta.name; meta.filename = path.basename(filename); meta.added = stats.atimeMs; meta.modified = stats.mtimeMs; meta.size = stats.size; fileContent = self.getFileModification(module, fileContent, meta); module._compile(fileContent, filename); }; } // Subclasses should use the return (if not AddonError) and push to this.addonList async loadAddon(filename, shouldToast = false) { if (typeof(filename) === "undefined") return; try { const addon = __non_webpack_require__(path.resolve(this.addonFolder, filename)); await Promise.resolve(addon); } catch (error) { return new AddonError(filename, filename, Strings.Addons.compileError, {message: error.message, stack: error.stack}); } const addon = __non_webpack_require__(path.resolve(this.addonFolder, filename)); // console.log(addon); // await Promise.resolve(addon); // addon = __non_webpack_require__(path.resolve(this.addonFolder, filename)); // console.log(addon); if (this.addonList.find(c => c.id == addon.id)) return new AddonError(addon.name, filename, Strings.Addons.alreadyExists.format({type: this.prefix, name: addon.name})); const error = this.initializeAddon(addon); if (error) return error; this.addonList.push(addon); if (shouldToast) Toasts.success(`${addon.name} v${addon.version} was loaded.`); this.emit("loaded", addon.id); if (!this.state[addon.id]) return this.state[addon.id] = false; return this.startAddon(addon); } unloadAddon(idOrFileOrAddon, shouldToast = true, isReload = false) { const addon = typeof(idOrFileOrAddon) == "string" ? this.addonList.find(c => c.id == idOrFileOrAddon || c.filename == idOrFileOrAddon) : idOrFileOrAddon; if (!addon) return false; if (this.state[addon.id]) isReload ? this.stopAddon(addon) : this.disableAddon(addon); delete __non_webpack_require__.cache[__non_webpack_require__.resolve(path.resolve(this.addonFolder, addon.filename))]; this.addonList.splice(this.addonList.indexOf(addon), 1); this.emit("unloaded", addon.id); if (shouldToast) Toasts.success(`${addon.name} was unloaded.`); return true; } async reloadAddon(idOrFileOrAddon, shouldToast = true) { const addon = typeof(idOrFileOrAddon) == "string" ? this.addonList.find(c => c.id == idOrFileOrAddon || c.filename == idOrFileOrAddon) : idOrFileOrAddon; const didUnload = this.unloadAddon(addon, shouldToast, true); if (addon && !didUnload) return didUnload; return await this.loadAddon(addon ? addon.filename : idOrFileOrAddon, shouldToast); } isLoaded(idOrFile) { const addon = this.addonList.find(c => c.id == idOrFile || c.filename == idOrFile); if (!addon) return false; return true; } isEnabled(idOrFile) { const addon = this.addonList.find(c => c.id == idOrFile || c.filename == idOrFile); if (!addon) return false; return this.state[addon.id]; } getAddon(idOrFile) { return this.addonList.find(c => c.id == idOrFile || c.filename == idOrFile); } enableAddon(idOrAddon) { const addon = typeof(idOrAddon) == "string" ? this.addonList.find(p => p.id == idOrAddon) : idOrAddon; if (!addon) return; if (this.state[addon.id]) return; this.state[addon.id] = true; this.startAddon(addon); this.saveState(); } disableAddon(idOrAddon) { const addon = typeof(idOrAddon) == "string" ? this.addonList.find(p => p.id == idOrAddon) : idOrAddon; if (!addon) return; if (!this.state[addon.id]) return; this.state[addon.id] = false; this.stopAddon(addon); this.saveState(); } toggleAddon(id) { if (this.state[id]) this.disableAddon(id); else this.enableAddon(id); } loadNewAddons() { const files = fs.readdirSync(this.addonFolder); const removed = this.addonList.filter(t => !files.includes(t.filename)).map(c => c.id); const added = files.filter(f => !this.addonList.find(t => t.filename == f) && f.endsWith(this.extension) && fs.statSync(path.resolve(this.addonFolder, f)).isFile()); return {added, removed}; } updateList() { const results = this.loadNewAddons(); for (const filename of results.added) this.loadAddon(filename); for (const name of results.removed) this.unloadAddon(name); } async loadAllAddons() { this.loadState(); const errors = []; const files = fs.readdirSync(this.addonFolder); for (const filename of files) { const absolutePath = path.resolve(this.addonFolder, filename); const stats = fs.statSync(absolutePath); if (!stats || !stats.isFile()) continue; this.timeCache[filename] = stats.mtime.getTime(); if (!filename.endsWith(this.extension)) { // Lets check to see if this filename has the duplicated file pattern `something(1).ext` const match = filename.match(this.duplicatePattern); if (!match) continue; const ext = match[0]; const truncated = filename.replace(ext, ""); const newFilename = truncated + this.extension; // If this file already exists, give a warning and move on. if (fs.existsSync(newFilename)) { Logger.warn("AddonManager", `Duplicate files found: ${filename} and ${newFilename}`); continue; } // Rename the file and let it go on fs.renameSync(absolutePath, path.resolve(this.addonFolder, newFilename)); } const addon = await this.loadAddon(filename, false); if (addon instanceof AddonError) errors.push(addon); } this.saveState(); if (Settings.get(this.collection, this.category, this.id)) this.watchAddons(); return errors; } deleteAddon(idOrFileOrAddon) { const addon = typeof(idOrFileOrAddon) == "string" ? this.addonList.find(c => c.id == idOrFileOrAddon || c.filename == idOrFileOrAddon) : idOrFileOrAddon; return fs.unlinkSync(path.resolve(this.addonFolder, addon.filename)); } saveAddon(idOrFileOrAddon, content) { const addon = typeof(idOrFileOrAddon) == "string" ? this.addonList.find(c => c.id == idOrFileOrAddon || c.filename == idOrFileOrAddon) : idOrFileOrAddon; return fs.writeFileSync(path.resolve(this.addonFolder, addon.filename), content); } editAddon(idOrFileOrAddon, system) { const addon = typeof(idOrFileOrAddon) == "string" ? this.addonList.find(c => c.id == idOrFileOrAddon || c.filename == idOrFileOrAddon) : idOrFileOrAddon; const fullPath = path.resolve(this.addonFolder, addon.filename); if (typeof(system) == "undefined") system = Settings.get("settings", "addons", "editAction") == "system"; if (system) return require("electron").shell.openItem(`${fullPath}`); return this.openDetached(addon); } openDetached(addon) { const fullPath = path.resolve(this.addonFolder, addon.filename); const content = fs.readFileSync(fullPath).toString(); if (this.windows.has(fullPath)) return; this.windows.add(fullPath); const editorRef = React.createRef(); const editor = React.createElement(AddonEditor, { id: "bd-floating-editor-" + addon.id, ref: editorRef, content: content, save: this.saveAddon.bind(this, addon), openNative: this.editAddon.bind(this, addon, true), language: this.language }); FloatingWindows.open({ onClose: () => { this.windows.delete(fullPath); }, onResize: () => { if (!editorRef || !editorRef.current || !editorRef.current.resize) return; editorRef.current.resize(); }, title: addon.name, id: "bd-floating-window-" + addon.id, className: "floating-addon-window", height: 470, width: 410, center: true, resizable: true, children: editor, confirmClose: () => { if (!editorRef || !editorRef.current) return false; return editorRef.current.hasUnsavedChanges; }, confirmationText: Strings.Addons.confirmationText.format({name: addon.name}) }); } }