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.
vizality/renderer/src/entities/Plugin.js

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();
}
}
}