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.
976 lines
27 KiB
976 lines
27 KiB
/**
|
|
*
|
|
*/
|
|
|
|
import { toKebabCase, toTitleCase, toHash, toPlural } from '@vizality/util/string';
|
|
import fs, { stat, renameSync, readFileSync, existsSync, lstatSync, readdirSync } from 'fs';
|
|
import { AddonInfoMessage, AddonUninstallModal } from '@vizality/components/addon';
|
|
import { removeDirRecursive } from '@vizality/util/file';
|
|
import { log, warn, error } from '@vizality/util/logger';
|
|
import { openModal } from '@vizality/modal';
|
|
import { isArray } from '@vizality/util/array';
|
|
import { Avatars, Events as _Events, Protocols } from '@vizality/constants';
|
|
import http from 'isomorphic-git/http/node';
|
|
import { join, resolve, sep, extname } from 'path';
|
|
import { clone } from 'isomorphic-git';
|
|
import { Messages } from '@vizality/i18n';
|
|
import { watch } from 'chokidar';
|
|
import Events from 'events';
|
|
import React from 'react';
|
|
|
|
const requiredManifestKeys = [ 'name', 'version', 'description', 'author' ];
|
|
|
|
const ErrorTypes = Object.freeze({
|
|
ADDON_ALREADY_INSTALLED: 'ADDON_ALREADY_INSTALLED',
|
|
ADDON_ALREADY_INSTALLING: 'ADDON_ALREADY_INSTALLING',
|
|
DIRECTORY_ALREADY_EXISTS: 'DIRECTORY_ALREADY_EXISTS'
|
|
});
|
|
|
|
/**
|
|
* @extends Events
|
|
*/
|
|
export default class AddonManager extends Events {
|
|
constructor (type, dir) {
|
|
super();
|
|
this.dir = dir;
|
|
this.type = type;
|
|
this._items = new Map();
|
|
this._watcherEnabled = null;
|
|
this._watcher = {};
|
|
this._labels = [ 'Manager', this.type ];
|
|
}
|
|
|
|
/**
|
|
*
|
|
*/
|
|
get count () {
|
|
return this._items.size;
|
|
}
|
|
|
|
/**
|
|
*
|
|
*/
|
|
get values () {
|
|
return this._items.values();
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @returns
|
|
*/
|
|
get keys () {
|
|
return [ ...this._items.keys() ];
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {string} addonId Addon ID
|
|
* @returns
|
|
*/
|
|
has (addonId) {
|
|
return this._items.has(addonId);
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {string} addonId Addon ID
|
|
* @returns
|
|
*/
|
|
get (addonId) {
|
|
return this._items.get(addonId);
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @returns
|
|
*/
|
|
getAll () {
|
|
return this._items;
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {string} addonId Addon ID
|
|
* @returns
|
|
*/
|
|
isInstalled (addonId) {
|
|
return this.has(addonId);
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {string} addonId Addon ID
|
|
* @returns
|
|
*/
|
|
isEnabled (addonId) {
|
|
return !vizality.settings.get(`disabled${toTitleCase(toPlural(this.type))}`, [])
|
|
.filter(addon => this.isInstalled(addon))
|
|
.includes(addonId);
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {string} addonId Addon ID
|
|
* @returns
|
|
*/
|
|
isDisabled (addonId) {
|
|
return !this.isEnabled(addonId);
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {string} addonId Addon ID
|
|
* @returns
|
|
*/
|
|
hasSettings (addonId) {
|
|
try {
|
|
const addon = this.get(addonId);
|
|
return Boolean(addon?.sections?.settings);
|
|
} catch (err) {
|
|
return this._error(`An error occurred while checking for settings for "${addonId}"!`, err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {string} addonId Addon ID
|
|
* @returns
|
|
*/
|
|
hasScreenshots (addonId) {
|
|
try {
|
|
const addon = this.get(addonId);
|
|
return Boolean(addon?.manifest?.screenshots);
|
|
} catch (err) {
|
|
return this._error(`An error occurred while checking for screenshots for "${addonId}"!`, err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {string} addonId Addon ID
|
|
* @returns
|
|
*/
|
|
hasReadme (addonId) {
|
|
try {
|
|
const addon = this.get(addonId);
|
|
return Boolean(addon?.sections?.readme);
|
|
} catch (err) {
|
|
return this._error(`An error occurred while checking for readme for "${addonId}"!`, err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {string} addonId Addon ID
|
|
* @returns
|
|
*/
|
|
hasChangelog (addonId) {
|
|
try {
|
|
const addon = this.get(addonId);
|
|
return Boolean(addon?.sections?.changelog);
|
|
} catch (err) {
|
|
return this._error(`An error occurred while checking for changelog for "${addonId}"!`, err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @returns
|
|
*/
|
|
getEnabledKeys () {
|
|
const addons = this.keys;
|
|
return addons.filter(addon => this.isEnabled(addon));
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @returns
|
|
*/
|
|
getEnabled () {
|
|
const enabled = new Map();
|
|
this.getEnabledKeys()
|
|
.sort((a, b) => a - b)
|
|
.map(addon => this.get(addon))
|
|
.forEach(addon => enabled.set(addon.addonId, addon));
|
|
|
|
return enabled;
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @returns
|
|
*/
|
|
getDisabledKeys () {
|
|
const addons = this.keys;
|
|
return addons.filter(addon => this.isDisabled(addon));
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @returns
|
|
*/
|
|
getDisabled () {
|
|
const disabled = new Map();
|
|
this.getDisabledKeys()
|
|
.sort((a, b) => a - b)
|
|
.map(addon => this.get(addon))
|
|
.forEach(addon => disabled.set(addon.addonId, addon));
|
|
|
|
return disabled;
|
|
}
|
|
|
|
/**
|
|
* Initializes an addon.
|
|
* @param {string} addonId Addon ID
|
|
*/
|
|
async mount (addonId) {
|
|
let manifest;
|
|
try {
|
|
manifest = Object.assign({
|
|
appMode: 'app'
|
|
}, await import(resolve(this.dir, addonId, 'manifest.json')));
|
|
} catch (err) {
|
|
return this._error(`${toTitleCase(this.type)} "${addonId}" doesn't have a valid manifest. Initialization aborted.`);
|
|
}
|
|
|
|
if (!requiredManifestKeys.every(key => manifest.hasOwnProperty(key))) {
|
|
return this._error(`${toTitleCase(this.type)} "${addonId}" doesn't have a valid manifest. Initialization aborted.`);
|
|
}
|
|
|
|
try {
|
|
const addonModule = await import(resolve(this.dir, addonId));
|
|
const Addon = addonModule?.__esModule ? addonModule.default : addonModule;
|
|
|
|
Object.defineProperties(Addon?.prototype, {
|
|
addonId: {
|
|
get: () => addonId,
|
|
set: () => {
|
|
throw new Error(`${toTitleCase(toPlural(this.type))} cannot update their ID at runtime!`);
|
|
}
|
|
},
|
|
manifest: {
|
|
get: () => manifest,
|
|
set: () => {
|
|
throw new Error(`${toTitleCase(toPlural(this.type))} cannot update manifest at runtime!`);
|
|
}
|
|
}
|
|
});
|
|
|
|
this._setAddonIcon(addonId, manifest);
|
|
this._setScreenshotImages(addonId, manifest);
|
|
this._items.set(addonId, new Addon());
|
|
} catch (err) {
|
|
return this._error(`An error occurred while initializing "${addonId}"!`, err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Uninitializes an addon.
|
|
* @param {string} addonId Addon ID
|
|
* @param {boolean} showLogs Whether to show unloading log messages
|
|
*/
|
|
async unmount (addonId, showLogs = true) {
|
|
try {
|
|
const addon = this.get(addonId);
|
|
if (!addon) {
|
|
throw new Error(`Tried to unmount a non-installed ${this.type}: "${addon}"!`);
|
|
}
|
|
await addon._unload(showLogs);
|
|
Object.keys(require.cache).forEach(key => {
|
|
if (key.includes(addonId)) {
|
|
delete require.cache[key];
|
|
}
|
|
});
|
|
this._items.delete(addonId);
|
|
} catch (err) {
|
|
return this._error(`An error occurred while unmounting "${addonId}"!`, err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Re-initializes an addon.
|
|
* @param {string} addonId Addon ID
|
|
* @param {boolean} showLogs Whether to show loading and unloading log messages
|
|
*/
|
|
async remount (addonId, showLogs = true) {
|
|
try {
|
|
await this.unmount(addonId, showLogs);
|
|
} catch (err) {
|
|
return this._error(`An error occurred while remounting "${addonId}"!`, err);
|
|
}
|
|
/*
|
|
* @note I have these separated like this because it seems to cause problems if they're
|
|
* not separated for some reason. Not confirmed yet though.
|
|
*/
|
|
try {
|
|
await this.mount(addonId);
|
|
await this.get(addonId)?._load(showLogs);
|
|
} catch (err) {
|
|
return this._error(`An error occurred while remounting "${addonId}"!`, err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reinitializes all addons.
|
|
*/
|
|
async remountAll () {
|
|
try {
|
|
const addons = this.getEnabledKeys();
|
|
for (const addon of addons) {
|
|
await this.remount(addon, false);
|
|
}
|
|
} catch (err) {
|
|
return this._error(`An error occurred while remounting all ${toPlural(this.type)}!`, err);
|
|
}
|
|
return this._log(`All ${toPlural(this.type)} have been re-initialized!`);
|
|
}
|
|
|
|
/**
|
|
* Initializes all addons.
|
|
*/
|
|
async initialize () {
|
|
let addonId;
|
|
try {
|
|
this._enableWatcher();
|
|
if (this._watcherEnabled) {
|
|
await this._watchFiles();
|
|
}
|
|
const ignorePath = join(this.dir, '.vzignore');
|
|
const ignore = existsSync(ignorePath) ? readFileSync(ignorePath, 'utf-8').trim().split(/\r?\n/) : [];
|
|
const files = readdirSync(this.dir).sort(this._sortBuiltins);
|
|
for (const filename of files) {
|
|
/**
|
|
* Remove any leftover addons that were being installed.
|
|
*/
|
|
if (filename.startsWith('__installing__')) {
|
|
await removeDirRecursive(resolve(this.dir, filename));
|
|
continue;
|
|
}
|
|
addonId = filename;
|
|
// If it's a file or listed in .vzignore, skip it
|
|
if (lstatSync(join(this.dir, addonId)).isFile() || ignore.includes(addonId)) {
|
|
continue;
|
|
}
|
|
// Mount the addon
|
|
await this.mount(addonId);
|
|
// If addon didn't mount, skip it
|
|
if (!this.get(addonId)) {
|
|
continue;
|
|
}
|
|
/**
|
|
* If the addon is not disabled in the user's settings, load it.
|
|
*/
|
|
if (!this.getDisabledKeys().includes(addonId)) {
|
|
await this.get(addonId)?._load();
|
|
}
|
|
}
|
|
this.emit(_Events.VIZALITY_ADDONS_READY, { type: this.type });
|
|
} catch (err) {
|
|
return this._error(`An error occurred while initializing "${addonId}"!`, err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Shuts down and unloads all addons.
|
|
*/
|
|
async stop () {
|
|
try {
|
|
this._disableWatcher();
|
|
const addons = this.keys;
|
|
for (const addon of addons) {
|
|
if (this.isEnabled(addon)) {
|
|
await this.unmount(addon, false);
|
|
} else {
|
|
Object.keys(require.cache).forEach(key => {
|
|
if (key.includes(addon)) {
|
|
delete require.cache[key];
|
|
}
|
|
});
|
|
this._items.delete(addon);
|
|
}
|
|
}
|
|
} catch (err) {
|
|
return this._error(`There was a problem shutting down ${toPlural(this.type)}!`, err);
|
|
}
|
|
return this._log(`All ${toPlural(this.type)} have been unloaded!`);
|
|
}
|
|
|
|
/**
|
|
* Toggles an addon with a given ID.
|
|
* @param {string} addonId Addon ID
|
|
*/
|
|
async toggle (addonId, sendEvent = true) {
|
|
try {
|
|
const addon = this.get(addonId);
|
|
|
|
/**
|
|
* Make sure the addon is installed.
|
|
*/
|
|
if (!addon) {
|
|
throw new Error(`Tried to toggle a non-installed ${this.type}: "${addonId}"!`);
|
|
}
|
|
|
|
/**
|
|
* Toggle the addon.
|
|
*/
|
|
let enabled;
|
|
if (this.isEnabled(addonId)) {
|
|
await this.disable(addonId, false);
|
|
enabled = false;
|
|
} else if (this.isDisabled(addonId, false)) {
|
|
await this.enable(addonId);
|
|
enabled = true;
|
|
}
|
|
|
|
/**
|
|
* Check if we should send the event.
|
|
*/
|
|
if (sendEvent) {
|
|
this.emit(_Events.VIZALITY_ADDON_TOGGLE, { addonId, type: this.type, enabled });
|
|
}
|
|
} catch (err) {
|
|
return this._error(err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enables an addon with a given ID.
|
|
* @param {string} addonId Addon ID
|
|
*/
|
|
async enable (addonId, sendEvent = true) {
|
|
try {
|
|
const addon = this.get(addonId);
|
|
|
|
if (!addon) {
|
|
throw new Error(`Tried to enable a non-installed ${this.type}: "${addonId}"!`);
|
|
}
|
|
|
|
if ((this.type === 'plugin' || this.type === 'builtin') && addon._ready) {
|
|
throw new Error(`Tried to enable an already-loaded ${this.type}: "${addonId}"!`);
|
|
}
|
|
|
|
vizality.settings.set(`disabled${toTitleCase(toPlural(this.type))}`,
|
|
vizality.settings.get(`disabled${toTitleCase(toPlural(this.type))}`, [])
|
|
.filter(addon => addon !== addonId));
|
|
|
|
await addon._load(true);
|
|
|
|
/**
|
|
* Check if we should send the event.
|
|
*/
|
|
if (sendEvent) {
|
|
this.emit(_Events.VIZALITY_ADDON_ENABLE, { addonId, type: this.type });
|
|
}
|
|
} catch (err) {
|
|
return this._error(err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Disables an addon with a given ID.
|
|
* @param {string} addonId Addon ID
|
|
*/
|
|
async disable (addonId) {
|
|
try {
|
|
const addon = this.get(addonId);
|
|
if (!addon) {
|
|
throw new Error(`Tried to disable a non-installed ${this.type}: "${addonId}"!`);
|
|
}
|
|
|
|
if ((this.type !== 'theme') && !addon._ready) {
|
|
throw new Error(`Tried to disable a non-loaded ${this.type}: "${addon}"!`);
|
|
}
|
|
|
|
vizality.settings.set(`disabled${toTitleCase(toPlural(this.type))}`, [
|
|
...vizality.settings.get(`disabled${toTitleCase(toPlural(this.type))}`, []),
|
|
addonId
|
|
]);
|
|
|
|
await addon._unload(true);
|
|
this.emit(_Events.VIZALITY_ADDON_DISABLE, { addonId, type: this.type });
|
|
} catch (err) {
|
|
return this._error(err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reloads an addon.
|
|
* @param {string} addonId Addon ID
|
|
*/
|
|
async reload (addonId) {
|
|
try {
|
|
await this.disable(addonId);
|
|
await this.enable(addonId);
|
|
} catch (err) {
|
|
return this._error(err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Reloads all addons.
|
|
*/
|
|
async reloadAll () {
|
|
try {
|
|
const addons = this.getEnabledKeys();
|
|
for (const addon of addons) {
|
|
await this.reload(addon);
|
|
}
|
|
} catch (err) {
|
|
return this._error(err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enables all addons.
|
|
*/
|
|
async enableAll () {
|
|
try {
|
|
const addons = this.getDisabledKeys();
|
|
for (const addon of addons) {
|
|
await this.enable(addon);
|
|
}
|
|
} catch (err) {
|
|
return this._error(err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Disables all addons.
|
|
*/
|
|
async disableAll () {
|
|
try {
|
|
const addons = this.getEnabledKeys();
|
|
for (const addon of addons) {
|
|
await this.disable(addon);
|
|
}
|
|
} catch (err) {
|
|
return this._error(err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Installs an addon.
|
|
* @param {string} addonId Addon ID or GitHub repository URL
|
|
*/
|
|
async install (addonId) {
|
|
try {
|
|
let git;
|
|
if (vizality.manager.community[toPlural(this.type)].has(addonId)) {
|
|
({ git } = vizality.manager.community[toPlural(this.type)].get(addonId));
|
|
}
|
|
|
|
if (!new RegExp(/^(((https?:\/\/)(((([a-zA-Z0-9][a-zA-Z0-9\-_]{1,252})\.){1,8}[a-zA-Z]{2,63})\/))|((ssh:\/\/)?git@)(((([a-zA-Z0-9][a-zA-Z0-9\-_]{1,252})\.){1,8}[a-zA-Z]{2,63})(:)))([a-zA-Z0-9][a-zA-Z0-9_-]{1,36})(\/)([a-zA-Z0-9][a-zA-Z0-9_-]{1,36})((\.git)?)$/).test(git || addonId)) {
|
|
throw new Error('You must provide a valid GitHub repository URL or an addon ID from https://github.com/vizality-community!');
|
|
} else if (vizality.manager.community.plugins.has(addonId)) {
|
|
git = `https://github.com/vizality-community/${addonId}`;
|
|
}
|
|
|
|
/**
|
|
* The addonId supplied might be a GitHub repository URL
|
|
*/
|
|
if (!git) {
|
|
git = addonId;
|
|
}
|
|
|
|
/**
|
|
* The URL must end in git to get processed by isomorphic-git below.
|
|
*/
|
|
if (!git.endsWith('.git')) {
|
|
git = `${git}.git`;
|
|
}
|
|
|
|
addonId = git.split('.git')[0].split('/')[git.split('.git')[0].split('/').length - 1];
|
|
addonId = toKebabCase(addonId);
|
|
|
|
if (this.isInstalled(addonId)) {
|
|
throw {
|
|
name: ErrorTypes.ADDON_ALREADY_INSTALLED,
|
|
message: Messages.VIZALITY_ADDON_ALREADY_INSTALLED_DESC.format({ type: this.type, addonId }),
|
|
stack: `\n${(new Error()).stack.replace('Error\n', '')}`,
|
|
addonId
|
|
};
|
|
}
|
|
|
|
if (existsSync(join(this.dir, `__installing__${addonId}`)) && lstatSync(join(this.dir, `__installing__${addonId}`))?.isDirectory()) {
|
|
throw {
|
|
name: ErrorTypes.ADDON_ALREADY_INSTALLING,
|
|
message: Messages.VIZALITY_ADDON_ALREADY_INSTALLING_DESC.format({ type: this.type, addonId }),
|
|
stack: `\n${(new Error()).stack.replace('Error\n', '')}`,
|
|
addonId
|
|
};
|
|
}
|
|
|
|
if (existsSync(join(this.dir, addonId)) && lstatSync(join(this.dir, addonId))?.isDirectory()) {
|
|
throw {
|
|
name: ErrorTypes.DIRECTORY_ALREADY_EXISTS,
|
|
message: Messages.VIZALITY_ADDON_DIRECTORY_ALREADY_EXISTS_DESC.format({ type: this.type, addonId }),
|
|
stack: `\n${(new Error()).stack.replace('Error\n', '')}`,
|
|
addonId
|
|
};
|
|
}
|
|
|
|
try {
|
|
await clone({
|
|
fs,
|
|
http,
|
|
singleBranch: true,
|
|
depth: 1,
|
|
dir: join(this.dir, `__installing__${addonId}`),
|
|
url: git,
|
|
onProgress: evt => {
|
|
// console.log(evt);
|
|
}
|
|
});
|
|
} catch (err) {
|
|
/**
|
|
* @note isomorphic-git creates the directory before it checks anything, whether there is
|
|
* a response or not, so let's remove it if there's an error here.
|
|
*/
|
|
await removeDirRecursive(resolve(this.dir, addonId));
|
|
throw new Error(`There was a problem while attempting to install "${addonId}"!`, err);
|
|
}
|
|
|
|
try {
|
|
renameSync(join(this.dir, `__installing__${addonId}`), join(this.dir, addonId));
|
|
} catch (err) {
|
|
console.log(err);
|
|
}
|
|
|
|
/**
|
|
* Send a success toast.
|
|
*/
|
|
vizality.api.notifications.sendToast({
|
|
id: 'addon-installed',
|
|
header: Messages.VIZALITY_ADDON_SUCCESSFULLY_INSTALLED.format({ type: toTitleCase(this.type) }),
|
|
content: <AddonInfoMessage addon={this.get(addonId) || { manifest: { name: addonId } }} message={Messages.VIZALITY_ADDON_SUCCESSFULLY_INSTALLED_DESC.format({ type: toTitleCase(this.type) })} />,
|
|
icon: this.type,
|
|
buttons: [
|
|
{
|
|
text: 'View',
|
|
onClick: () => vizality.api.routes.navigateTo(`/vizality/${this.type}/${addonId}`)
|
|
}
|
|
]
|
|
});
|
|
} catch (err) {
|
|
let addon;
|
|
if (err.addonId) {
|
|
addon = this.get(err.addonId);
|
|
}
|
|
switch (err.name) {
|
|
/**
|
|
* Addon Already Installed
|
|
*/
|
|
case ErrorTypes.ADDON_ALREADY_INSTALLED: {
|
|
this._error(err.message, err.addonId, err.stack);
|
|
vizality.api.notifications.sendToast({
|
|
id: ErrorTypes.ADDON_ALREADY_INSTALLED,
|
|
header: Messages.VIZALITY_ADDON_ALREADY_INSTALLED_DESC.format({ type: toTitleCase(this.type) }),
|
|
content: <AddonInfoMessage addon={addon} message={err.message} />,
|
|
icon: this.type,
|
|
buttons: [
|
|
{
|
|
text: Messages.VIZALITY_UNINSTALL,
|
|
color: 'red',
|
|
onClick: () => {
|
|
vizality.api.notifications.closeToast(ErrorTypes.ADDON_ALREADY_INSTALLED);
|
|
this.uninstall(err.addonId);
|
|
}
|
|
},
|
|
{
|
|
text: 'View',
|
|
onClick: () => vizality.api.routes.navigateTo(`/${toPlural(this.type)}/${err.addonId}`)
|
|
}
|
|
]
|
|
});
|
|
break;
|
|
}
|
|
/**
|
|
* Addon Already Installing
|
|
*/
|
|
case ErrorTypes.ADDON_ALREADY_INSTALLING: {
|
|
this._error(err.message, err.addonId, err.stack);
|
|
vizality.api.notifications.sendToast({
|
|
id: ErrorTypes.ADDON_ALREADY_INSTALLING,
|
|
header: Messages.VIZALITY_ADDON_ALREADY_INSTALLING_DESC.format({ type: toTitleCase(this.type) }),
|
|
content: <AddonInfoMessage addon={addon} message={err.message} />,
|
|
icon: this.type,
|
|
buttons: [
|
|
{
|
|
text: 'Cancel',
|
|
color: 'red',
|
|
onClick: async () => {
|
|
vizality.api.notifications.closeToast(ErrorTypes.ADDON_ALREADY_INSTALLING);
|
|
await removeDirRecursive(resolve(this.dir, `__installing__${err.addonId}`));
|
|
}
|
|
}
|
|
]
|
|
});
|
|
break;
|
|
}
|
|
/**
|
|
* Addon Directory Already Exists
|
|
*/
|
|
case ErrorTypes.ADDON_DIRECTORY_ALREADY_EXISTS: {
|
|
this._error(err.message, err.addonId, err.stack);
|
|
vizality.api.notifications.sendToast({
|
|
id: ErrorTypes.ADDON_DIRECTORY_ALREADY_EXISTS,
|
|
header: Messages.ADDON_DIRECTORY_ALREADY_EXISTS_DESC.format({ type: toTitleCase(this.type) }),
|
|
content: <AddonInfoMessage addon={addon} message={err.message} />,
|
|
icon: this.type
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Uninstalls a plugin or theme.
|
|
* @param {string} addonId Addon ID
|
|
* @returns {Promise<void>}
|
|
* @private
|
|
*/
|
|
async _uninstall (addonId) {
|
|
try {
|
|
if (!this.isInstalled(addonId)) {
|
|
console.error(`Can't uninstall non-installed thing`);
|
|
}
|
|
await this.unmount(addonId);
|
|
await removeDirRecursive(resolve(this.dir, addonId));
|
|
this.emit(_Events.VIZALITY_ADDON_UNINSTALL, { addonId, type: this.type });
|
|
} catch (err) {
|
|
return this._error(err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Sets an addon's icon image URL.
|
|
* @private
|
|
* @param {string} addonId Addon ID
|
|
* @param {object} manifest Addon manifest
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async _setAddonIcon (addonId, manifest) {
|
|
try {
|
|
const validExtensions = [ '.png', '.jpg', '.jpeg' ];
|
|
if (manifest.icon) {
|
|
if (!validExtensions.some(ext => manifest.icon.endsWith(ext))) {
|
|
this._warn(`${toTitleCase(this.type)} icon must be of type .png, .jpg, or .jpeg.`);
|
|
} else {
|
|
return manifest.icon = `${Protocols[toPlural(this.type).toUpperCase()]}/${addonId}/${manifest.icon}`;
|
|
}
|
|
}
|
|
if (validExtensions.some(ext => existsSync(resolve(this.dir, addonId, 'assets', `icon${ext}`)))) {
|
|
for (const ext of validExtensions) {
|
|
if (existsSync(resolve(this.dir, addonId, 'assets', `icon${ext}`))) {
|
|
manifest.icon = `${Protocols[toPlural(this.type).toUpperCase()]}/${addonId}/assets/icon${ext}`;
|
|
break;
|
|
}
|
|
}
|
|
} else {
|
|
return manifest.icon = this._getDefaultAddonIcon(addonId);
|
|
}
|
|
} catch (err) {
|
|
return this._error(err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Gets a default addon icon image URL.
|
|
* @private
|
|
* @param {string} addonId Addon ID
|
|
* @returns {string}
|
|
*/
|
|
_getDefaultAddonIcon (addonId) {
|
|
try {
|
|
const addonIdHash = toHash(addonId);
|
|
return Avatars[`DEFAULT_${this.type.toUpperCase()}_${(addonIdHash % 5) + 1}`];
|
|
} catch (err) {
|
|
return this._error(err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {string} addonId Addon ID
|
|
* @param {AddonManifest} manifest Addon manifest
|
|
* @returns {void}
|
|
*/
|
|
_setScreenshotImages (addonId, manifest) {
|
|
try {
|
|
const validExtensions = [ '.png', '.jpg', '.jpeg', '.gif', '.webp' ];
|
|
const screenshotUrls = [];
|
|
if (isArray(manifest.screenshots)) {
|
|
manifest.screenshot.forEach(screenshot => {
|
|
if (!validExtensions.some(ext => screenshot.endsWith(ext))) {
|
|
this._warn(`${toTitleCase(this.type)} screenshots must be of type .png, .jpg, .jpeg, .gif, or .webp`);
|
|
} else {
|
|
screenshotUrls.push(`${Protocols[toPlural(this.type).toUpperCase()]}/${addonId}/${screenshot}`);
|
|
}
|
|
});
|
|
} else {
|
|
const screenshotsDir = join(this.dir, addonId, 'screenshots');
|
|
const hasScreenshots = existsSync(screenshotsDir) && lstatSync(screenshotsDir)?.isDirectory();
|
|
if (hasScreenshots) {
|
|
const previewImages = [];
|
|
const validExtensions = [ '.png', '.gif', '.jpg', '.jpeg', '.webp' ];
|
|
readdirSync(screenshotsDir)
|
|
.filter(file => validExtensions.indexOf(extname(file).toLowerCase()) !== -1)
|
|
.map(file => previewImages.push(`${Protocols[toPlural(this.type).toUpperCase()]}/${addonId}/screenshots/${file}`));
|
|
return manifest.screenshots = previewImages;
|
|
}
|
|
}
|
|
} catch (err) {
|
|
return this._error(err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Enables the addon directory watcher.
|
|
* @private
|
|
* @returns {void}
|
|
*/
|
|
_enableWatcher () {
|
|
this._watcherEnabled = true;
|
|
}
|
|
|
|
/**
|
|
* Disables the addon directory watcher.
|
|
* @private
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async _disableWatcher () {
|
|
this._watcherEnabled = false;
|
|
if (this._watcher?.close) {
|
|
await this._watcher.close();
|
|
this._watcher = {};
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Initiates the addon directory watcher.
|
|
* @private
|
|
* @returns {Promise<void>}
|
|
*/
|
|
async _watchFiles () {
|
|
this._watcher = watch(this.dir, {
|
|
ignored: [ /.exists/, /__installing__/ ],
|
|
ignoreInitial: true,
|
|
depth: 0
|
|
});
|
|
|
|
/**
|
|
* Makes sure that the directory added has been completely copied by the operating
|
|
* system before it attempts to do anything with the addon.
|
|
* @see {@link https://memorytin.com/2015/07/08/node-js-chokidar-wait-for-file-copy-to-complete-before-modifying/}
|
|
* @param {string} path Addon folder path
|
|
* @param {object} prev Previous folder stats info @see {@link https://nodejs.org/api/fs.html#fs_class_fs_stats}
|
|
* @returns {void}
|
|
*/
|
|
const checkAddDirComplete = (path, prev) => {
|
|
try {
|
|
stat(path, async (err, stat) => {
|
|
if (err) {
|
|
throw err;
|
|
}
|
|
if (stat.mtime.getTime() === prev.mtime.getTime()) {
|
|
const addonId = path.replace(this.dir + sep, '');
|
|
if (addonId !== toKebabCase(addonId)) {
|
|
renameSync(path, join(this.dir, toKebabCase(addonId)));
|
|
}
|
|
await this.mount(addonId);
|
|
await this.get(addonId)?._load();
|
|
} else {
|
|
setTimeout(checkAddDirComplete, 2000, path, stat);
|
|
}
|
|
});
|
|
} catch (err) {
|
|
this._error(err);
|
|
}
|
|
};
|
|
|
|
/**
|
|
*
|
|
*/
|
|
this._watcher
|
|
/**
|
|
*
|
|
*/
|
|
.on('addDir', (path, stat) => {
|
|
setTimeout(checkAddDirComplete, 2000, path, stat);
|
|
})
|
|
/**
|
|
*
|
|
*/
|
|
.on('unlinkDir', path => {
|
|
const addonId = path.replace(this.dir + sep, '');
|
|
Object.keys(require.cache).forEach(key => {
|
|
if (key.includes(addonId)) {
|
|
delete require.cache[key];
|
|
}
|
|
});
|
|
this._items.delete(addonId);
|
|
});
|
|
}
|
|
|
|
/**
|
|
*
|
|
* @param {string} addonId Addon ID
|
|
* @returns {void}
|
|
*/
|
|
uninstall (addonId) {
|
|
try {
|
|
if (!this.isInstalled(addonId)) {
|
|
console.error(`Can't uninstall non-installed thing`);
|
|
}
|
|
const addon = this.get(addonId);
|
|
if (!addon) {
|
|
return;
|
|
}
|
|
|
|
openModal(() => props => <AddonUninstallModal {...props} addon={addon} type={this.type} />);
|
|
} catch (err) {
|
|
return this._error(err);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_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 });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_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 });
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @private
|
|
*/
|
|
_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 });
|
|
}
|
|
}
|
|
}
|