diff --git a/client/src/modules/content.js b/client/src/modules/content.js new file mode 100644 index 00000000..cddd81de --- /dev/null +++ b/client/src/modules/content.js @@ -0,0 +1,162 @@ +/** + * BetterDiscord Content Base + * Copyright (c) 2015-present Jiiks/JsSucks - https://github.com/Jiiks / https://github.com/JsSucks + * All rights reserved. + * https://betterdiscord.net + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. +*/ + +import { Utils, FileUtils, AsyncEventEmitter } from 'common'; +import { Modals } from 'ui'; + +export default class Content { + + constructor(internals) { + this.__internals = internals; + + this.settings.on('setting-updated', event => this.events.emit('setting-updated', event)); + this.settings.on('settings-updated', event => this.events.emit('settings-updated', event)); + this.settings.on('settings-updated', event => this.__settingsUpdated(event)); + + // Add hooks + if (this.onstart) this.on('start', event => this.onstart(event)); + if (this.onStart) this.on('start', event => this.onStart(event)); + if (this.onstop) this.on('stop', event => this.onstop(event)); + if (this.onStop) this.on('stop', event => this.onStop(event)); + if (this.onunload) this.on('unload', event => this.onunload(event)); + if (this.onUnload) this.on('unload', event => this.onUnload(event)); + if (this.settingUpdated) this.on('setting-updated', event => this.settingUpdated(event)); + if (this.settingsUpdated) this.on('settings-updated', event => this.settingsUpdated(event)); + } + + get type() { return undefined } + get configs() { return this.__internals.configs } + get info() { return this.__internals.info } + get paths() { return this.__internals.paths } + get main() { return this.__internals.main } + get defaultConfig() { return this.configs.defaultConfig } + get userConfig() { return this.configs.userConfig } + get configSchemes() { return this.configs.schemes } + get id() { return this.info.id || this.name.toLowerCase().replace(/[^a-zA-Z0-9-]/g, '-').replace(/--/g, '-') } + get name() { return this.info.name } + get icon() { return this.info.icon } + get description() { return this.info.description } + get authors() { return this.info.authors } + get version() { return this.info.version } + get contentPath() { return this.paths.contentPath } + get dirName() { return this.paths.dirName } + get enabled() { return this.userConfig.enabled } + get settings() { return this.userConfig.config } + get config() { return this.settings.categories } + get data() { return this.userConfig.data || (this.userConfig.data = {}) } + get events() { return this.EventEmitter || (this.EventEmitter = new AsyncEventEmitter()) } + + /** + * Opens a settings modal for this content. + */ + showSettingsModal() { + return Modals.contentSettings(this); + } + + /** + * Whether this content has any settings. + */ + get hasSettings() { + return !!this.settings.findSetting(() => true); + } + + /** + * Saves the content's current configuration. + */ + async saveConfiguration() { + try { + await FileUtils.writeFile(`${this.contentPath}/user.config.json`, JSON.stringify({ + enabled: this.enabled, + config: this.settings.strip().settings, + data: this.data + })); + + this.settings.setSaved(); + } catch (err) { + Logger.err(this.name, ['Failed to save configuration', err]); + throw err; + } + } + + /** + * Called when settings are updated. + * This can be overridden by other content types. + */ + __settingsUpdated(event) { + return this.saveConfiguration(); + } + + /** + * Enables the content. + * @param {Boolean} save Whether to save the new enabled state + * @return {Promise} + */ + async enable(save = true) { + if (this.enabled) return; + await this.emit('enable'); + await this.emit('start'); + + this.userConfig.enabled = true; + if (save) await this.saveConfiguration(); + } + + /** + * Disables the content. + * @param {Boolean} save Whether to save the new enabled state + * @return {Promise} + */ + async disable(save = true) { + if (!this.enabled) return; + await this.emit('stop'); + await this.emit('disable'); + + this.userConfig.enabled = false; + if (save) await this.saveConfiguration(); + } + + /** + * Adds an event listener. + * @param {String} event The event to add the listener to + * @param {Function} callback The function to call when the event is emitted + */ + on(...args) { + return this.events.on(...args); + } + + /** + * Removes an event listener. + * @param {String} event The event to remove the listener from + * @param {Function} callback The bound callback (optional) + */ + off(...args) { + return this.events.removeListener(...args); + } + + /** + * Adds an event listener that removes itself when called, therefore only being called once. + * @param {String} event The event to add the listener to + * @param {Function} callback The function to call when the event is emitted + * @return {Promise|undefined} + */ + once(...args) { + return this.events.once(...args); + } + + /** + * Emits an event. + * @param {String} event The event to emit + * @param {Any} data Data to be passed to listeners + * @return {Promise|undefined} + */ + emit(...args) { + return this.events.emit(...args); + } + +} diff --git a/client/src/modules/contentmanager.js b/client/src/modules/contentmanager.js index b5620b87..789a7975 100644 --- a/client/src/modules/contentmanager.js +++ b/client/src/modules/contentmanager.js @@ -8,6 +8,7 @@ * LICENSE file in the root directory of this source tree. */ +import Content from './content'; import Globals from './globals'; import { Utils, FileUtils, ClientLogger as Logger } from 'common'; import path from 'path'; @@ -245,17 +246,19 @@ export default class { if (!content) throw {message: `Could not find a ${this.contentType} from ${content}.`}; try { - if (content.enabled && content.disable) content.disable(false); - if (content.enabled && content.stop) content.stop(false); - if (content.onunload) content.onunload(reload); - if (content.onUnload) content.onUnload(reload); + await content.disable(false); + await content.emit('unload', reload); + const index = this.getContentIndex(content); delete window.require.cache[window.require.resolve(content.paths.mainPath)]; if (reload) { const newcontent = await this.preloadContent(content.dirName, true, index); - if (newcontent.enabled && newcontent.start) newcontent.start(false); + if (newcontent.enabled) { + newcontent.userConfig.enabled = false; + newcontent.start(false); + } return newcontent; } else this.localContent.splice(index, 1); } catch (err) { @@ -268,7 +271,7 @@ export default class { * Reload content * @param {any} content Content to reload */ - static async reloadContent(content) { + static reloadContent(content) { return this.unloadContent(content, true); } @@ -295,12 +298,20 @@ export default class { * @param {any} content Object to check */ static isThisContent(content) { - return false; + return content instanceof Content; + } + + /** + * Returns the first content where calling {function} returns true. + * @param {Function} function A function to call to filter content + */ + static find(f) { + return this.localContent.find(f); } /** * Wildcard content finder - * @param {any} wild Content name | id | path | dirname + * @param {any} wild Content ID / directory name / path / name * @param {bool} nonunique Allow searching attributes that may not be unique */ static findContent(wild, nonunique) { @@ -313,10 +324,10 @@ export default class { } static getContentIndex(content) { return this.localContent.findIndex(c => c === content) } - static getContentByName(name) { return this.localContent.find(c => c.name === name) } static getContentById(id) { return this.localContent.find(c => c.id === id) } - static getContentByPath(path) { return this.localContent.find(c => c.contentPath === path) } static getContentByDirName(dirName) { return this.localContent.find(c => c.dirName === dirName) } + static getContentByPath(path) { return this.localContent.find(c => c.contentPath === path) } + static getContentByName(name) { return this.localContent.find(c => c.name === name) } /** * Wait for content to load diff --git a/client/src/modules/extmodule.js b/client/src/modules/extmodule.js index 43f37811..4b8fb0db 100644 --- a/client/src/modules/extmodule.js +++ b/client/src/modules/extmodule.js @@ -8,37 +8,15 @@ * LICENSE file in the root directory of this source tree. */ -import { AsyncEventEmitter } from 'common'; -import { EventEmitter } from 'events'; +import Content from './content'; -export default class ExtModule { +export default class ExtModule extends Content { - constructor(pluginInternals) { - this.__pluginInternals = pluginInternals; + constructor(internals) { + super(internals); this.__require = window.require(this.paths.mainPath); - this.hasSettings = false; } get type() { return 'module' } - get configs() { return this.__pluginInternals.configs } - get info() { return this.__pluginInternals.info } - get icon() { return this.info.icon } - get paths() { return this.__pluginInternals.paths } - get main() { return this.__pluginInternals.main } - get defaultConfig() { return this.configs.defaultConfig } - get userConfig() { return this.configs.userConfig } - get configSchemes() { return this.configs.schemes } - get id() { return this.info.id || this.name.toLowerCase().replace(/[^a-zA-Z0-9-]/g, '-').replace(/--/g, '-') } - get name() { return this.info.name } - get description() { return this.info.description } - get authors() { return this.info.authors } - get version() { return this.info.version } - get contentPath() { return this.paths.contentPath } - get modulePath() { return this.paths.contentPath } - get dirName() { return this.paths.dirName } - get enabled() { return true } - get config() { return this.userConfig.config || [] } - get data() { return this.userConfig.data || (this.userConfig.data = {}) } - get events() { return this.EventEmitter ? this.EventEmitter : (this.EventEmitter = new AsyncEventEmitter()) } } diff --git a/client/src/modules/plugin.js b/client/src/modules/plugin.js index 889e2b4d..7b786eff 100644 --- a/client/src/modules/plugin.js +++ b/client/src/modules/plugin.js @@ -8,93 +8,19 @@ * LICENSE file in the root directory of this source tree. */ -import { Utils, FileUtils, AsyncEventEmitter } from 'common'; -import { Modals } from 'ui'; -import { EventEmitter } from 'events'; import PluginManager from './pluginmanager'; -import { SettingUpdatedEvent, SettingsUpdatedEvent } from 'structs'; +import Content from './content'; -export default class Plugin { - - constructor(pluginInternals) { - this.__pluginInternals = pluginInternals; - this.saveConfiguration = this.saveConfiguration.bind(this); - this.hasSettings = this.config && this.config.length > 0; - this.start = this.start.bind(this); - this.stop = this.stop.bind(this); - - this.settings.on('setting-updated', event => this.events.emit('setting-updated', event)); - this.settings.on('settings-updated', event => this.events.emit('settings-updated', event)); - this.settings.on('settings-updated', event => this.saveConfiguration()); - } +export default class Plugin extends Content { get type() { return 'plugin' } - get configs() { return this.__pluginInternals.configs } - get info() { return this.__pluginInternals.info } - get icon() { return this.info.icon } - get paths() { return this.__pluginInternals.paths } - get main() { return this.__pluginInternals.main } - get defaultConfig() { return this.configs.defaultConfig } - get userConfig() { return this.configs.userConfig } - get configSchemes() { return this.configs.schemes } - get id() { return this.info.id || this.name.toLowerCase().replace(/[^a-zA-Z0-9-]/g, '-').replace(/--/g, '-') } - get name() { return this.info.name } - get description() { return this.info.description } - get authors() { return this.info.authors } - get version() { return this.info.version } - get contentPath() { return this.paths.contentPath } - get pluginPath() { return this.paths.contentPath } - get dirName() { return this.paths.dirName } - get enabled() { return this.userConfig.enabled } - get settings() { return this.userConfig.config } - get config() { return this.settings.settings } + + // Don't use - these will eventually be removed! + get pluginPath() { return this.contentPath } get pluginConfig() { return this.config } - get data() { return this.userConfig.data || (this.userConfig.data = {}) } - get exports() { return this._exports ? this._exports : (this._exports = this.getExports()) } - get events() { return this.EventEmitter ? this.EventEmitter : (this.EventEmitter = new AsyncEventEmitter()) } - showSettingsModal() { - return Modals.contentSettings(this); - } - - async saveConfiguration() { - try { - await FileUtils.writeFile(`${this.pluginPath}/user.config.json`, JSON.stringify({ - enabled: this.enabled, - config: this.settings.strip().settings, - data: this.data - })); - - this.settings.setSaved(); - } catch (err) { - console.error(`Plugin ${this.id} configuration failed to save`, err); - throw err; - } - } - - start(save = true) { - if (this.onstart && !this.onstart()) return false; - if (this.onStart && !this.onStart()) return false; - - if (!this.enabled) { - this.userConfig.enabled = true; - if (save) this.saveConfiguration(); - } - - return true; - } - - stop(save = true) { - if (this.onstop && !this.onstop()) return false; - if (this.onStop && !this.onStop()) return false; - - if (this.enabled) { - this.userConfig.enabled = false; - if (save) this.saveConfiguration(); - } - - return true; - } + get start() { return this.enable } + get stop() { return this.disable } unload() { PluginManager.unloadPlugin(this); diff --git a/client/src/modules/pluginmanager.js b/client/src/modules/pluginmanager.js index d042f915..6f8a3b12 100644 --- a/client/src/modules/pluginmanager.js +++ b/client/src/modules/pluginmanager.js @@ -41,11 +41,13 @@ export default class extends ContentManager { const loadAll = await this.loadAllContent(true); this.loaded = true; for (let plugin of this.localPlugins) { + if (!plugin.enabled) continue; + plugin.userConfig.enabled = false; + try { - if (plugin.enabled) plugin.start(); + plugin.start(false); } catch (err) { // Disable the plugin but don't save it - the next time BetterDiscord is started the plugin will attempt to start again - plugin.userConfig.enabled = false; this.errors.push(new ErrorEvent({ module: this.moduleName, message: `Failed to start ${plugin.name}`, @@ -72,7 +74,6 @@ export default class extends ContentManager { static get loadContent() { return this.loadPlugin } static async loadPlugin(paths, configs, info, main, dependencies, permissions) { - if (permissions && permissions.length > 0) { for (let perm of permissions) { console.log(`Permission: ${Permissions.permissionText(perm).HEADER} - ${Permissions.permissionText(perm).BODY}`); @@ -107,7 +108,10 @@ export default class extends ContentManager { } }); - if (instance.enabled && this.loaded) instance.start(); + if (instance.enabled && this.loaded) { + instance.userConfig.enabled = false; + instance.start(false); + } return instance; } diff --git a/client/src/modules/theme.js b/client/src/modules/theme.js index da96e5ae..bd68cef9 100644 --- a/client/src/modules/theme.js +++ b/client/src/modules/theme.js @@ -8,26 +8,17 @@ * LICENSE file in the root directory of this source tree. */ +import Content from './content'; import Settings from './settings'; import ThemeManager from './thememanager'; -import { EventEmitter } from 'events'; -import { SettingUpdatedEvent, SettingsUpdatedEvent } from 'structs'; -import { DOM, Modals } from 'ui'; -import { Utils, FileUtils, ClientIPC, ClientLogger as Logger, AsyncEventEmitter } from 'common'; +import { DOM } from 'ui'; +import { FileUtils, ClientIPC, ClientLogger as Logger } from 'common'; import filewatcher from 'filewatcher'; -export default class Theme { +export default class Theme extends Content { - constructor(themeInternals) { - this.__themeInternals = themeInternals; - this.hasSettings = this.config && this.config.length > 0; - this.saveConfiguration = this.saveConfiguration.bind(this); - this.enable = this.enable.bind(this); - this.disable = this.disable.bind(this); - - this.settings.on('setting-updated', event => this.events.emit('setting-updated', event)); - this.settings.on('settings-updated', event => this.events.emit('settings-updated', event)); - this.settings.on('settings-updated', event => this.recompile()); + constructor(internals) { + super(internals); const watchfiles = Settings.getSetting('css', 'default', 'watch-files'); if (watchfiles.value) this.watchfiles = this.files; @@ -37,63 +28,39 @@ export default class Theme { }); } - get configs() { return this.__themeInternals.configs } - get info() { return this.__themeInternals.info } - get icon() { return this.info.icon } - get paths() { return this.__themeInternals.paths } - get main() { return this.__themeInternals.main } - get loaded() { return this.__themeInternals.loaded } - get defaultConfig() { return this.configs.defaultConfig } - get userConfig() { return this.configs.userConfig } - get configSchemes() { return this.configs.schemes } - get id() { return this.info.id || this.name.toLowerCase().replace(/[^a-zA-Z0-9-]/g, '-').replace(/\s+/g, '-') } - get name() { return this.info.name } - get description() { return this.info.description } - get authors() { return this.info.authors } - get version() { return this.info.version } - get contentPath() { return this.paths.contentPath } - get themePath() { return this.paths.contentPath } - get dirName() { return this.paths.dirName } - get enabled() { return this.userConfig.enabled } - get settings() { return this.userConfig.config } - get config() { return this.settings.settings } - get themeConfig() { return this.config } - get data() { return this.userConfig.data || (this.userConfig.data = {}) } + get type() { return 'theme' } get css() { return this.data.css } - get events() { return this.EventEmitter ? this.EventEmitter : (this.EventEmitter = new AsyncEventEmitter()) } - showSettingsModal() { - return Modals.contentSettings(this); + // Don't use - these will eventually be removed! + get themePath() { return this.contentPath } + get themeConfig() { return this.config } + + /** + * Called when settings are updated. + * This can be overridden by other content types. + */ + __settingsUpdated(event) { + return this.recompile(); } - async saveConfiguration() { - try { - await FileUtils.writeFile(`${this.themePath}/user.config.json`, JSON.stringify({ - enabled: this.enabled, - config: this.settings.strip().settings, - data: this.data - })); - - this.settings.setSaved(); - } catch (err) { - throw err; - } - } - - enable(save = true) { - if (!this.enabled) { - this.userConfig.enabled = true; - if (save) this.saveConfiguration(); - } + /** + * This is called when the theme is enabled. + */ + onstart() { DOM.injectTheme(this.css, this.id); } - disable(save = true) { - this.userConfig.enabled = false; - if (save) this.saveConfiguration(); + /** + * This is called when the theme is disabled. + */ + onstop() { DOM.deleteTheme(this.id); } + /** + * Compiles the theme and returns an object containing the CSS and an array of files that were included. + * @return {Promise} + */ async compile() { console.log('Compiling CSS'); @@ -117,11 +84,15 @@ export default class Theme { }; } else { return { - css: FileUtils.readFile(this.paths.mainPath) + css: await FileUtils.readFile(this.paths.mainPath) }; } } + /** + * Compiles the theme and updates and saves the CSS and the list of include files. + * @return {Promise} + */ async recompile() { const data = await this.compile(); this.data.css = data.css; @@ -136,7 +107,7 @@ export default class Theme { } /** - * An array of files that are imported in custom CSS. + * An array of files that are imported in the theme's SCSS. * @return {Array} Files being watched */ get files() { @@ -144,7 +115,7 @@ export default class Theme { } /** - * Sets all files that are imported in custom CSS. + * Sets all files that are imported in the theme's SCSS. * @param {Array} files Files to watch */ set files(files) { diff --git a/client/src/ui/components/bd/PluginCard.vue b/client/src/ui/components/bd/PluginCard.vue index 901f3b30..c184dc2c 100644 --- a/client/src/ui/components/bd/PluginCard.vue +++ b/client/src/ui/components/bd/PluginCard.vue @@ -12,7 +12,7 @@ - + diff --git a/client/src/ui/components/bd/PluginsView.vue b/client/src/ui/components/bd/PluginsView.vue index 2a1684ec..8d42d851 100644 --- a/client/src/ui/components/bd/PluginsView.vue +++ b/client/src/ui/components/bd/PluginsView.vue @@ -23,7 +23,7 @@
- +

Coming Soon

@@ -93,8 +93,10 @@ console.error(err); } }, - showSettings(plugin) { - return Modals.contentSettings(plugin); + showSettings(plugin, dont_clone) { + return Modals.contentSettings(plugin, null, { + dont_clone + }); } } } diff --git a/client/src/ui/components/bd/ThemeCard.vue b/client/src/ui/components/bd/ThemeCard.vue index 5776f4a0..37dd817e 100644 --- a/client/src/ui/components/bd/ThemeCard.vue +++ b/client/src/ui/components/bd/ThemeCard.vue @@ -12,7 +12,7 @@ - + diff --git a/client/src/ui/components/bd/ThemesView.vue b/client/src/ui/components/bd/ThemesView.vue index 2a7b2f46..91f34e27 100644 --- a/client/src/ui/components/bd/ThemesView.vue +++ b/client/src/ui/components/bd/ThemesView.vue @@ -23,7 +23,7 @@
- +

Coming Soon

@@ -94,8 +94,10 @@ console.error(err); } }, - showSettings(theme) { - return Modals.contentSettings(theme); + showSettings(theme, dont_clone) { + return Modals.contentSettings(theme, null, { + dont_clone + }); } } } diff --git a/client/src/ui/components/bd/setting/Array.vue b/client/src/ui/components/bd/setting/Array.vue index d4f2c80f..4524d9c5 100644 --- a/client/src/ui/components/bd/setting/Array.vue +++ b/client/src/ui/components/bd/setting/Array.vue @@ -57,18 +57,6 @@ if (this.setting.disabled || this.setting.min && this.setting.items.length <= this.setting.min) return; this.setting.removeItem(item); }, - changeInItem(item, category_id, setting_id, value) { - console.log('Setting', item, category_id, setting_id, 'to', value); - - const category = item.settings.find(c => c.category === category_id); - if (!category) return; - - const setting = category.settings.find(s => s.id === setting_id); - if (!setting || Utils.compare(setting.value, value)) return; - - setting.value = value; - setting.changed = !Utils.compare(setting.value, setting.old_value); - }, showModal(item, index) { Modals.settings(item, this.setting.headertext ? this.setting.headertext.replace(/%n/, index + 1) : this.setting.text + ` #${index + 1}`); }, diff --git a/client/src/ui/modals.js b/client/src/ui/modals.js index 11bcd361..5166ebdb 100644 --- a/client/src/ui/modals.js +++ b/client/src/ui/modals.js @@ -240,8 +240,8 @@ export default class Modals { * @param {String} headertext A string that will be displayed in the modal header * @return {Modal} */ - static contentSettings(content, headertext) { - return this.settings(content.settings, headertext ? headertext : content.name + ' Settings'); + static contentSettings(content, headertext, options) { + return this.settings(content.settings, headertext ? headertext : content.name + ' Settings', options); } /**