mirror of https://github.com/vizality/vizality
You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
518 lines
15 KiB
518 lines
15 KiB
import { log, warn, error, PRESET_LABELS } from '@vizality/util/logger';
|
|
import { toPlural, toTitleCase } from '@vizality/util/string';
|
|
import { resolveCompiler } from '@vizality/compilers';
|
|
import { unpatchAll } from '@vizality/patcher';
|
|
import { jsonToReact } from '@vizality/util/react';
|
|
import { createElement } from '@vizality/util/dom';
|
|
import { Directories } from '@vizality/constants';
|
|
import { isArray } from '@vizality/util/array';
|
|
import { debounce } from 'lodash';
|
|
import { join, sep, extname } from 'path';
|
|
import { watch } from 'chokidar';
|
|
import { existsSync, readFileSync } from 'fs';
|
|
|
|
import Updatable from './Updatable';
|
|
|
|
/*
|
|
* @property {boolean} _ready Whether the plugin is ready or not
|
|
* @property {SettingsCategory} settings Plugin settings
|
|
* @property {object<string, Compiler>} styles Styles the plugin loaded
|
|
*/
|
|
|
|
/**
|
|
* @extends Updatable
|
|
* @extends Events
|
|
*/
|
|
export default class Plugin extends Updatable {
|
|
constructor () {
|
|
super(Directories.PLUGINS);
|
|
this.settings = vizality.api.settings._buildCategoryObject(this.addonId);
|
|
this.styles = {};
|
|
this.sections = {};
|
|
this.type = 'plugin';
|
|
this._ready = false;
|
|
this._watcherEnabled = null;
|
|
this._watcher = {};
|
|
this._labels = [ 'Plugin', this.manifest?.name || this.constructor?.name ];
|
|
this._registerSections();
|
|
}
|
|
|
|
/**
|
|
* Injects a style element containing the styles from the specified stylesheet into the
|
|
* document head. Style element (and styles) are automatically removed on
|
|
* plugin disable/unload.
|
|
* @param {string} path Stylesheet path. Either absolute or relative to the plugin root
|
|
* @param {boolean} [suppress=false] Whether or not to suppress errors in console
|
|
*/
|
|
injectStyles (path, suppress = false) {
|
|
let compiler, style, compiled, id;
|
|
try {
|
|
let resolvedPath = path;
|
|
if (!existsSync(resolvedPath)) {
|
|
// Assume it's a relative path and try resolving it
|
|
resolvedPath = join(this.path, path);
|
|
if (!existsSync(resolvedPath)) {
|
|
throw new Error(`Cannot find "${path}"! Make sure the file exists and try again.`);
|
|
}
|
|
}
|
|
|
|
id = Math.random().toString(36).slice(2);
|
|
compiler = resolveCompiler(resolvedPath);
|
|
style = createElement('style', {
|
|
id: `${this.type}-${this.addonId}-${id}`,
|
|
'vz-style': '',
|
|
[`vz-${this.type}`]: ''
|
|
});
|
|
|
|
document.head.appendChild(style);
|
|
} catch (err) {
|
|
return this.error(err);
|
|
}
|
|
|
|
const compile = debounce(async () => {
|
|
try {
|
|
compiled = await compiler.compile();
|
|
style.innerHTML = compiled;
|
|
} catch (err) {
|
|
if (!suppress) {
|
|
return this.error('There was a problem compiling!', err);
|
|
}
|
|
}
|
|
}, 300);
|
|
|
|
try {
|
|
this.styles[id] = {
|
|
compiler,
|
|
compile
|
|
};
|
|
|
|
compiler.enableWatcher();
|
|
compiler.on('src-update', compile);
|
|
return compile();
|
|
} catch (err) {
|
|
return this.error(err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {*} render
|
|
*/
|
|
registerSettings (render) {
|
|
vizality.api.settings.registerSettings({
|
|
type: this.type,
|
|
addonId: this.addonId,
|
|
render
|
|
});
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {...any} message
|
|
*/
|
|
log (...message) {
|
|
// In case the addon wants to provide their own labels
|
|
if (isArray(message[0])) {
|
|
const _message = message.slice(1);
|
|
log({ labels: message[0], message: _message });
|
|
} else {
|
|
log({ labels: this._labels, message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {...any} message
|
|
*/
|
|
warn (...message) {
|
|
// In case the addon wants to provide their own labels
|
|
if (isArray(message[0])) {
|
|
const _message = message.slice(1);
|
|
warn({ labels: message[0], message: _message });
|
|
} else {
|
|
warn({ labels: this._labels, message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {...any} message
|
|
*/
|
|
error (...message) {
|
|
// In case the addon wants to provide their own labels
|
|
if (isArray(message[0])) {
|
|
const _message = message.slice(1);
|
|
error({ labels: message[0], message: _message });
|
|
} else {
|
|
error({ labels: this._labels, message });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Update the addon.
|
|
* @private
|
|
* @param {boolean} [force=false] Whether to force update the addon
|
|
* @returns {Promise<boolean>}
|
|
*/
|
|
async _update (force = false) {
|
|
try {
|
|
const success = await super._update(force);
|
|
if (success && this._ready) {
|
|
this.log(`${toTitleCase(this.type)} has been successfully updated.`);
|
|
vizality.api.notifications.sendToast({
|
|
icon: this.manifest.icon,
|
|
header: `${toTitleCase(this.type)} \`${this.manifest.name}\` has been successfully updated.`
|
|
});
|
|
await vizality.manager[toPlural(this.type)].remount(this.addonId, false);
|
|
}
|
|
return success;
|
|
} catch (err) {
|
|
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enables the file watcher.
|
|
* @private
|
|
*/
|
|
async _enableWatcher () {
|
|
/**
|
|
* @note Don't enable the watcher for builtins unless the user is a Vizality developer.
|
|
* No need to use extra resources watching something that shouldn't need it.
|
|
*/
|
|
if (!this.manifest) {
|
|
if (!vizality.settings.get('developer', false)) {
|
|
this._watcherEnabled = false;
|
|
} else {
|
|
this._watcherEnabled = vizality.settings.get('hotReload', false);
|
|
}
|
|
} else {
|
|
if (typeof this.manifest.hotReload?.enable === 'boolean') {
|
|
this._watcherEnabled = this.manifest.hotReload.enable;
|
|
} else {
|
|
this._watcherEnabled = vizality.settings.get('hotReload', false);
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Disables the file watcher. MUST be called if you no longer need the compiler and the watcher
|
|
* was previously enabled.
|
|
* @private
|
|
*/
|
|
async _disableWatcher () {
|
|
this._watcherEnabled = false;
|
|
if (this._watcher?.close) {
|
|
await this._watcher.close();
|
|
this._watcher = {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
async _watchFiles () {
|
|
const ignored = [];
|
|
|
|
/**
|
|
*
|
|
*/
|
|
if (this.manifest?.hotReload?.ignore) {
|
|
/**
|
|
*
|
|
*/
|
|
if (isArray(this.manifest.hotReload.ignore)) {
|
|
for (const ign of this.manifest.hotReload.ignore) {
|
|
/**
|
|
*
|
|
*/
|
|
if (ign.startsWith('*')) {
|
|
ignored.push(ign);
|
|
} else {
|
|
ignored.push(new RegExp(ign));
|
|
}
|
|
}
|
|
} else {
|
|
/**
|
|
*
|
|
*/
|
|
if (this.manifest.hotReload.ignore.startsWith('*')) {
|
|
ignored.push(this.manifest.hotReload.ignore);
|
|
} else {
|
|
ignored.push(new RegExp(this.manifest.hotReload.ignore));
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
*/
|
|
this._watcher = watch(this.path, {
|
|
ignored: [ /node_modules/, /.git/, /manifest.json/, /.scss/, /.css/ ].concat(ignored),
|
|
ignoreInitial: true
|
|
});
|
|
|
|
/**
|
|
* Set up a shorthand for the watcher labels used below.
|
|
*/
|
|
const watcherLabels = this._labels.concat({ text: 'Watcher', color: PRESET_LABELS.watcher.toString() });
|
|
|
|
/**
|
|
*
|
|
*/
|
|
this._watcher
|
|
.on('add', path => this.log(watcherLabels, `File "${path.replace(this.path + sep, '')}" has been added.`))
|
|
.on('change', path => this.log(watcherLabels, `File "${path.replace(this.path + sep, '')}" has been changed.`))
|
|
.on('unlink', path => this.log(watcherLabels, `File "${path.replace(this.path + sep, '')}" has been removed.`))
|
|
.on('addDir', path => this.log(watcherLabels, `Directory "${path.replace(this.path + sep, '')}" has been added.`))
|
|
.on('unlinkDir', path => this.log(watcherLabels, `Directory "${path.replace(this.path + sep, '')}" has been removed.`))
|
|
.on('error', err => this.error(watcherLabels, err))
|
|
.on('all', debounce(async () => vizality.manager[toPlural(this.type)].remount(this.addonId), 300));
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
async _load (showLogs = true) {
|
|
try {
|
|
/**
|
|
*
|
|
*/
|
|
if (typeof this.start === 'function') {
|
|
const before = performance.now();
|
|
const startPlugin = () => {
|
|
return new Promise(resolve => {
|
|
resolve(this.start());
|
|
});
|
|
};
|
|
startPlugin()
|
|
.then(() => {
|
|
if (showLogs) {
|
|
const after = performance.now();
|
|
const time = parseFloat((after - before).toFixed()).toString().replace(/^0+/, '') || 0;
|
|
/**
|
|
* Let's format the milliseconds to seconds.
|
|
*/
|
|
let formattedTime = Math.round((time / 1000 + Number.EPSILON) * 100) / 100;
|
|
/**
|
|
* If it ends up being so fast that it rounds to 0, let's show formatting
|
|
* to 3 decimal places, otherwise show 2 decimal places.
|
|
*/
|
|
if (formattedTime === 0) {
|
|
formattedTime = Math.round((time / 1000 + Number.EPSILON) * 1000) / 1000;
|
|
}
|
|
/**
|
|
* If it is still 0, let's just say it's fast.
|
|
*/
|
|
if (formattedTime === 0) {
|
|
return this.log(`${toTitleCase(this.type)} loaded. Startup was nearly instant!`);
|
|
}
|
|
return this.log(`${toTitleCase(this.type)} loaded. Startup took ${formattedTime} seconds!`);
|
|
}
|
|
});
|
|
|
|
/**
|
|
*
|
|
*/
|
|
await this._registerSettings();
|
|
} else {
|
|
this.warn(`${toTitleCase(this.type)} has no "start" method!`);
|
|
}
|
|
} catch (err) {
|
|
return this.error('An error occurred during initialization!', err);
|
|
}
|
|
|
|
this._ready = true;
|
|
await this._enableWatcher();
|
|
if (this._watcherEnabled) {
|
|
await this._watchFiles();
|
|
}
|
|
|
|
if (Array.isArray(this.manifest?.settings)) {
|
|
const settings = this._mapSettings(this.manifest.settings);
|
|
this.registerSettings(() => jsonToReact(settings, (id, value) => {
|
|
this.settings.set(id, value);
|
|
}));
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @private
|
|
*/
|
|
async _registerSettings () {
|
|
try {
|
|
/**
|
|
*
|
|
*/
|
|
if (!this.sections.settings && this.type !== 'builtin') {
|
|
let Render;
|
|
if (this.manifest?.sections?.settings) {
|
|
Render = await import(join(this.path, this.manifest.sections.settings));
|
|
} else if (existsSync(join(this.path, 'Settings.jsx'))) {
|
|
Render = await import(join(this.path, 'Settings.jsx'));
|
|
} else if (existsSync(join(this.path, 'components', 'Settings.jsx'))) {
|
|
Render = await import(join(this.path, 'components', 'Settings.jsx'));
|
|
}
|
|
|
|
/**
|
|
*
|
|
*/
|
|
if (Render) {
|
|
vizality.api.settings.registerSettings({
|
|
type: this.type,
|
|
addonId: this.addonId,
|
|
render: Render
|
|
});
|
|
}
|
|
}
|
|
} catch (err) {
|
|
return this.error(err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @private
|
|
*/
|
|
async _registerSections () {
|
|
try {
|
|
[ 'readme', 'changelog' ].forEach(section => {
|
|
/**
|
|
*
|
|
*/
|
|
let sectionPath;
|
|
if (this.sections && !this.sections[section]) {
|
|
if (this.manifest?.sections && this.manifest.sections[section]) {
|
|
if (existsSync(join(this.path, this.manifest.sections[section]))) {
|
|
sectionPath = join(this.path, this.manifest.sections[section]);
|
|
}
|
|
/**
|
|
* @todo Possibly allow any case for these file names.
|
|
*/
|
|
} else if (existsSync(join(this.path, `${section.toUpperCase()}.md`))) {
|
|
sectionPath = join(this.path, `${section.toUpperCase()}.md`);
|
|
} else if (existsSync(join(this.path, section.toUpperCase()))) {
|
|
sectionPath = join(this.path, section.toUpperCase());
|
|
}
|
|
|
|
if (sectionPath) {
|
|
this.sections[section] = readFileSync(sectionPath, 'utf8');
|
|
}
|
|
}
|
|
});
|
|
|
|
return;
|
|
/**
|
|
*
|
|
*/
|
|
let changelogPath;
|
|
if (!this.sections.changelog) {
|
|
if (this.manifest?.sections?.changelog) {
|
|
if (existsSync(join(this.path, this.manifest.sections.changelog))) {
|
|
changelogPath = join(this.path, this.manifest.sections.changelog);
|
|
}
|
|
} else if (existsSync(join(this.path, 'CHANGELOG.md'))) {
|
|
changelogPath = join(this.path, 'CHANGELOG.md');
|
|
} else if (existsSync(join(this.path, 'CHANGELOG'))) {
|
|
changelogPath = join(this.path, 'CHANGELOG');
|
|
}
|
|
}
|
|
if (changelogPath) {
|
|
this.sections.changelog = readFileSync(changelogPath, 'utf8');
|
|
}
|
|
|
|
let readmePath;
|
|
if (!this.sections.overview) {
|
|
if (this.manifest?.sections?.overview) {
|
|
if (existsSync(join(this.path, this.manifest.sections.overview))) {
|
|
readmePath = join(this.path, this.manifest.sections.overview);
|
|
}
|
|
} else if (existsSync(join(this.path, 'README.md'))) {
|
|
readmePath = join(this.path, 'README.md');
|
|
} else if (existsSync(join(this.path, 'README'))) {
|
|
readmePath = join(this.path, 'README');
|
|
}
|
|
}
|
|
if (readmePath) {
|
|
this.sections.overview = readFileSync(readmePath, 'utf8');
|
|
}
|
|
} catch (err) {
|
|
return this.error(err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @private
|
|
* @param {*} settings
|
|
* @returns
|
|
*/
|
|
_mapSettings (settings) {
|
|
return settings.map(setting => {
|
|
if (setting.type === 'category') {
|
|
return {
|
|
...setting,
|
|
items: this._mapSettings(setting.items)
|
|
};
|
|
}
|
|
if (setting.type === 'divider' || setting.type === 'markdown') {
|
|
return setting;
|
|
}
|
|
return {
|
|
...setting,
|
|
get value () { return this.settings.get(setting.id, setting.defaultValue); },
|
|
settings: this.settings
|
|
};
|
|
});
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
* @param {boolean} showLogs Whether to show console log messages
|
|
*/
|
|
async _unload (showLogs = true) {
|
|
try {
|
|
for (const id in this.styles) {
|
|
this.styles[id].compiler.on('src-update', this.styles[id].compile);
|
|
this.styles[id].compiler.disableWatcher();
|
|
if (document.getElementById(`${this.type}-${this.addonId}-${id}`)) {
|
|
document.getElementById(`${this.type}-${this.addonId}-${id}`).remove();
|
|
}
|
|
}
|
|
|
|
this.styles = {};
|
|
if (typeof this.stop === 'function') {
|
|
await this.stop();
|
|
}
|
|
|
|
if (this.type !== 'builtin') {
|
|
// Unregister settings
|
|
if (this.sections.settings) {
|
|
vizality.api.settings.unregisterSettings(this.addonId, 'plugin');
|
|
}
|
|
|
|
unpatchAll(this.addonId);
|
|
}
|
|
|
|
if (showLogs) {
|
|
this.log(`${toTitleCase(this.type)} unloaded!`);
|
|
}
|
|
} catch (err) {
|
|
this.error(`An error occurred while shutting down! It's heavily recommended that you reload Discord to ensure there are no conflicts.`, err);
|
|
} finally {
|
|
this._ready = false;
|
|
/**
|
|
* Disable any leftover watcher.
|
|
*/
|
|
if (this._watcher) {
|
|
await this._disableWatcher();
|
|
}
|
|
/**
|
|
* Remove any event listeners added to the plugin.
|
|
*/
|
|
this.removeAllListeners();
|
|
}
|
|
}
|
|
}
|