BetterDiscordApp-v2/client/src/modules/contentmanager.js

450 lines
16 KiB
JavaScript
Raw Normal View History

2018-01-30 16:59:27 +01:00
/**
* BetterDiscord Content Manager Module
* 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.
*/
2018-08-27 18:16:30 +02:00
import asar from 'asar';
import path, { dirname } from 'path';
import rimraf from 'rimraf';
2018-11-30 21:08:05 +01:00
import { remote } from 'electron';
2018-03-06 01:24:14 +01:00
import Content from './content';
2018-01-30 16:59:27 +01:00
import Globals from './globals';
import Database from './database';
import { Utils, FileUtils, ClientLogger as Logger } from 'common';
2018-03-01 20:00:24 +01:00
import { SettingsSet, ErrorEvent } from 'structs';
2018-02-13 17:44:07 +01:00
import { Modals } from 'ui';
import Combokeys from 'combokeys';
import Settings from './settings';
2018-01-30 16:59:27 +01:00
2018-02-14 13:55:06 +01:00
/**
* Base class for managing external content
2018-02-14 13:55:06 +01:00
*/
2018-01-30 16:59:27 +01:00
export default class {
2018-02-14 13:55:06 +01:00
/**
2018-03-22 03:13:32 +01:00
* Any errors that happened.
* @return {Array}
2018-02-14 13:55:06 +01:00
*/
2018-02-07 17:02:27 +01:00
static get errors() {
return this._errors || (this._errors = []);
}
2018-02-14 13:55:06 +01:00
/**
2018-03-22 03:13:32 +01:00
* Locally stored content.
* @return {Array}
2018-02-14 13:55:06 +01:00
*/
2018-01-30 16:59:27 +01:00
static get localContent() {
return this._localContent ? this._localContent : (this._localContent = []);
}
2018-03-22 17:38:09 +01:00
/**
* The type of content this content manager manages.
*/
static get contentType() {
return undefined;
}
/**
* The name of this content manager.
*/
static get moduleName() {
return undefined;
}
/**
* The path used to store this content manager's content.
*/
static get pathId() {
return undefined;
}
2018-02-14 13:55:06 +01:00
/**
2018-03-22 03:13:32 +01:00
* Local path for content.
* @return {String}
2018-02-14 13:55:06 +01:00
*/
2018-01-30 16:59:27 +01:00
static get contentPath() {
2018-03-19 17:45:20 +01:00
return Globals.getPath(this.pathId);
2018-01-30 16:59:27 +01:00
}
2018-11-30 21:08:05 +01:00
static async packContent(path, contentPath) {
return new Promise((resolve, reject) => {
remote.dialog.showSaveDialog({
title: 'Save Package',
defaultPath: path,
filters: [
{
name: 'BetterDiscord Package',
extensions: ['bd']
}
]
}, filepath => {
if (!filepath) return;
asar.uncache(filepath);
asar.createPackage(contentPath, filepath, () => {
resolve(filepath);
});
});
});
}
2018-02-14 13:55:06 +01:00
/**
2018-03-22 03:13:32 +01:00
* Load all locally stored content.
2018-02-14 13:55:06 +01:00
* @param {bool} suppressErrors Suppress any errors that occur during loading of content
*/
static async loadAllContent(suppressErrors = false) {
2018-01-30 16:59:27 +01:00
try {
2018-01-30 23:21:06 +01:00
await FileUtils.ensureDirectory(this.contentPath);
const directories = await FileUtils.listDirectory(this.contentPath);
2018-08-15 08:01:47 +02:00
for (const dir of directories) {
2018-08-27 18:16:30 +02:00
const packed = dir.endsWith('.bd');
if (!packed) {
try {
await FileUtils.directoryExists(path.join(this.contentPath, dir));
} catch (err) { continue; }
}
2018-03-01 20:00:24 +01:00
2018-01-30 23:21:06 +01:00
try {
2018-08-27 18:16:30 +02:00
if (packed) {
await this.preloadPackedContent(dir);
} else {
await this.preloadContent(dir);
}
2018-01-30 23:21:06 +01:00
} catch (err) {
2018-02-13 23:28:58 +01:00
this.errors.push(new ErrorEvent({
2018-02-07 17:02:27 +01:00
module: this.moduleName,
message: `Failed to load ${dir}`,
err
}));
2018-01-30 23:21:06 +01:00
Logger.err(this.moduleName, err);
}
}
if (this.errors.length && !suppressErrors) {
2018-02-13 17:44:07 +01:00
Modals.error({
header: `${this.moduleName} - ${this.errors.length} ${this.contentType}${this.errors.length !== 1 ? 's' : ''} failed to load`,
2018-02-07 17:02:27 +01:00
module: this.moduleName,
type: 'err',
content: this.errors
});
2018-02-13 17:57:05 +01:00
this._errors = [];
2018-02-07 17:02:27 +01:00
}
2018-01-30 23:21:06 +01:00
} catch (err) {
throw err;
}
}
2018-01-30 16:59:27 +01:00
2018-02-14 13:55:06 +01:00
/**
* Refresh locally stored content
* @param {bool} suppressErrors Suppress any errors that occur during loading of content
2018-02-14 13:55:06 +01:00
*/
static async refreshContent(suppressErrors = false) {
2018-01-30 23:21:06 +01:00
if (!this.localContent.length) return this.loadAllContent();
try {
2018-01-30 16:59:27 +01:00
await FileUtils.ensureDirectory(this.contentPath);
const directories = await FileUtils.listDirectory(this.contentPath);
2018-08-15 08:01:47 +02:00
for (const dir of directories) {
const packed = dir.endsWith('.bd');
2018-03-22 17:38:09 +01:00
// If content is already loaded this should resolve
2018-01-30 23:21:06 +01:00
if (this.getContentByDirName(dir)) continue;
2018-03-01 20:00:24 +01:00
try {
await FileUtils.directoryExists(path.join(this.contentPath, dir));
} catch (err) { continue; }
2018-01-30 16:59:27 +01:00
try {
2018-01-30 23:21:06 +01:00
// Load if not
2018-01-30 16:59:27 +01:00
await this.preloadContent(dir);
} catch (err) {
// We don't want every plugin/theme to fail loading when one does
this.errors.push(new ErrorEvent({
module: this.moduleName,
message: `Failed to load ${dir}`,
err
}));
2018-01-30 16:59:27 +01:00
Logger.err(this.moduleName, err);
}
}
2018-08-15 08:01:47 +02:00
for (const content of this.localContent) {
2018-01-30 23:21:06 +01:00
if (directories.includes(content.dirName)) continue;
try {
// Plugin/theme was deleted manually, stop it and remove any reference
await this.unloadContent(content);
} catch (err) {
this.errors.push(new ErrorEvent({
module: this.moduleName,
2018-02-22 17:19:35 +01:00
message: `Failed to unload ${content.dirName}`,
err
}));
Logger.err(this.moduleName, err);
}
2018-01-30 23:21:06 +01:00
}
if (this.errors.length && !suppressErrors) {
Modals.error({
header: `${this.moduleName} - ${this.errors.length} ${this.contentType}${this.errors.length !== 1 ? 's' : ''} failed to load`,
module: this.moduleName,
type: 'err',
content: this.errors
});
this._errors = [];
}
2018-01-30 16:59:27 +01:00
} catch (err) {
throw err;
}
}
2018-08-27 18:16:30 +02:00
static async preloadPackedContent(pkg, reload = false, index) {
try {
const packagePath = path.join(this.contentPath, pkg);
2018-08-28 17:08:56 +02:00
const packageName = pkg.replace('.bd', '');
2018-08-27 18:16:30 +02:00
await FileUtils.fileExists(packagePath);
const config = JSON.parse(asar.extractFile(packagePath, 'config.json').toString());
2018-08-28 17:08:56 +02:00
const unpackedPath = path.join(Globals.getPath('tmp'), packageName);
2018-08-27 18:16:30 +02:00
asar.extractAll(packagePath, unpackedPath);
2018-08-28 17:08:56 +02:00
return this.preloadContent({
2018-08-27 18:16:30 +02:00
config,
contentPath: unpackedPath,
packagePath: packagePath,
pkg,
2018-08-28 17:08:56 +02:00
packageName,
2018-08-27 18:16:30 +02:00
packed: true
}, reload, index);
} catch (err) {
Logger.log('ContentManager', ['Error extracting packed content', err]);
2018-08-27 18:16:30 +02:00
throw err;
}
}
2018-02-14 13:55:06 +01:00
/**
* Common loading procedure for loading content before passing it to the actual loader
* @param {any} dirName Base directory for content
* @param {any} reload Is content being reloaded
* @param {any} index Index of content in {localContent}
*/
2018-01-30 16:59:27 +01:00
static async preloadContent(dirName, reload = false, index) {
try {
2018-08-27 19:17:07 +02:00
const unsafeAllowed = Settings.getSetting('security', 'default', 'unsafe-content').value;
2018-08-27 18:16:30 +02:00
const packed = typeof dirName === 'object' && dirName.packed;
// Block any unpacked content as they can't be verified
if (!packed && !unsafeAllowed) {
throw 'Blocked unsafe content';
}
2018-08-27 18:16:30 +02:00
const contentPath = packed ? dirName.contentPath : path.join(this.contentPath, dirName);
2018-01-30 16:59:27 +01:00
await FileUtils.directoryExists(contentPath);
2018-03-22 03:13:32 +01:00
if (!reload && this.getContentByPath(contentPath))
throw { 'message': `Attempted to load already loaded user content: ${path}` };
2018-01-30 16:59:27 +01:00
2018-03-22 03:13:32 +01:00
const configPath = path.resolve(contentPath, 'config.json');
2018-08-27 18:16:30 +02:00
const readConfig = packed ? dirName.config : await FileUtils.readJsonFromFile(configPath);
2018-03-22 03:13:32 +01:00
const mainPath = path.join(contentPath, readConfig.main || 'index.js');
2018-01-30 16:59:27 +01:00
const defaultConfig = new SettingsSet({
settings: readConfig.defaultConfig,
schemes: readConfig.configSchemes
});
2018-01-30 16:59:27 +01:00
const userConfig = {
enabled: false,
config: undefined,
data: {}
2018-01-30 16:59:27 +01:00
};
try {
2018-03-22 03:13:32 +01:00
const id = readConfig.info.id || readConfig.info.name.toLowerCase().replace(/[^a-zA-Z0-9-]/g, '-').replace(/--/g, '-');
2018-03-22 17:38:09 +01:00
const readUserConfig = await Database.find({ type: `${this.contentType}-config`, id });
if (readUserConfig.length) {
userConfig.enabled = readUserConfig[0].enabled || false;
userConfig.config = readUserConfig[0].config;
userConfig.data = readUserConfig[0].data || {};
}
2018-03-22 03:13:32 +01:00
} catch (err) {
// We don't care if this fails it either means that user config doesn't exist or there's something wrong with it so we revert to default config
2018-08-27 18:16:30 +02:00
Logger.warn(this.moduleName, [`Failed reading config for ${this.contentType} ${readConfig.info.name} in ${packed ? dirName.pkg : dirName}`, err]);
}
2018-01-30 16:59:27 +01:00
userConfig.config = defaultConfig.clone({ settings: userConfig.config });
userConfig.config.setSaved();
2018-08-15 08:01:47 +02:00
for (const setting of userConfig.config.findSettings(() => true)) {
// This will load custom settings
// Setting the content's path on only the live config (and not the default config) ensures that custom settings will not be loaded on the default settings
setting.setContentPath(contentPath);
}
2018-08-15 08:01:47 +02:00
for (const scheme of userConfig.config.schemes) {
2018-08-06 03:45:59 +02:00
scheme.setContentPath(contentPath);
}
Utils.deepfreeze(defaultConfig, object => object instanceof Combokeys);
2018-01-30 16:59:27 +01:00
const configs = {
defaultConfig,
2018-03-01 20:00:24 +01:00
schemes: userConfig.schemes,
2018-01-30 16:59:27 +01:00
userConfig
2018-02-21 16:58:45 +01:00
};
2018-01-30 16:59:27 +01:00
const paths = {
contentPath,
dirName,
mainPath
2018-02-21 16:58:45 +01:00
};
2018-01-30 16:59:27 +01:00
2018-08-27 18:16:30 +02:00
const content = await this.loadContent(paths, configs, readConfig.info, readConfig.main, readConfig.dependencies, readConfig.permissions, readConfig.mainExport, packed ? dirName : false);
2018-03-04 02:33:06 +01:00
if (!content) return undefined;
2018-02-21 18:46:27 +01:00
if (!reload && this.getContentById(content.id))
2018-08-28 17:08:56 +02:00
throw { message: `A ${this.contentType} with the ID ${content.id} already exists.` };
2018-02-21 18:46:27 +01:00
if (reload) this.localContent.splice(index, 1, content);
2018-01-31 09:17:15 +01:00
else this.localContent.push(content);
2018-01-30 16:59:27 +01:00
return content;
} catch (err) {
throw err;
}
}
/**
* Delete content.
* @param {Content|String} content Content to delete
* @param {Boolean} force If true the content will be deleted even if an exception is thrown when disabling/unloading/deleting
*/
static async deleteContent(content, force) {
content = this.findContent(content);
if (!content) throw {message: `Could not find a ${this.contentType} from ${content}.`};
try {
await Modals.confirm(`Delete ${this.contentType}?`, `Are you sure you want to delete ${content.info.name} ?`, 'Delete').promise;
} catch (err) {
return false;
}
try {
const unload = this.unloadContent(content, force, false);
if (!force)
await unload;
await FileUtils.recursiveDeleteDirectory(content.paths.contentPath);
if (content.packed) await FileUtils.recursiveDeleteDirectory(content.packagePath);
return true;
} catch (err) {
Logger.err(this.moduleName, err);
throw err;
}
}
2018-02-21 18:46:27 +01:00
/**
2018-03-22 03:13:32 +01:00
* Unload content.
* @param {Content|String} content Content to unload
* @param {Boolean} force If true the content will be unloaded even if an exception is thrown when disabling/unloading
2018-03-22 03:13:32 +01:00
* @param {Boolean} reload Whether to reload the content after
* @return {Content}
2018-02-21 18:46:27 +01:00
*/
static async unloadContent(content, force, reload) {
2018-02-21 18:46:27 +01:00
content = this.findContent(content);
if (!content) throw {message: `Could not find a ${this.contentType} from ${content}.`};
try {
const disablePromise = content.disable(false);
const unloadPromise = content.emit('unload', reload);
if (!force) {
await disablePromise;
await unloadPromise;
}
2018-03-06 01:24:14 +01:00
2018-02-21 18:46:27 +01:00
const index = this.getContentIndex(content);
if (this.unloadContentHook) this.unloadContentHook(content);
2018-02-21 18:46:27 +01:00
if (reload) return content.packed ? this.preloadPackedContent(content.packagePath, true, index) : this.preloadContent(content.dirName, true, index);
2018-08-15 08:10:11 +02:00
this.localContent.splice(index, 1);
2018-02-21 18:46:27 +01:00
} catch (err) {
Logger.err(this.moduleName, err);
throw err;
}
}
/**
2018-03-22 03:13:32 +01:00
* Reload content.
* @param {Content|String} content Content to reload
* @param {Boolean} force If true the content will be unloaded even if an exception is thrown when disabling/unloading
2018-03-22 03:13:32 +01:00
* @return {Content}
2018-02-21 18:46:27 +01:00
*/
static reloadContent(content, force) {
return this.unloadContent(content, force, true);
2018-02-21 18:46:27 +01:00
}
/**
* Checks if the passed object is an instance of this content type.
2018-03-22 03:13:32 +01:00
* @param {Any} content Object to check
* @return {Boolean}
2018-02-21 18:46:27 +01:00
*/
static isThisContent(content) {
2018-03-06 01:24:14 +01:00
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);
2018-02-21 18:46:27 +01:00
}
2018-02-14 13:55:06 +01:00
/**
* Wildcard content finder
2018-03-22 03:13:32 +01:00
* @param {String} wild Content ID / directory name / path / name
* @param {Boolean} nonunique Allow searching attributes that may not be unique
* @return {Content}
2018-02-14 13:55:06 +01:00
*/
2018-02-21 18:46:27 +01:00
static findContent(wild, nonunique) {
if (this.isThisContent(wild)) return wild;
let content;
2018-02-22 17:19:35 +01:00
content = this.getContentById(wild); if (content) return content;
content = this.getContentByDirName(wild); if (content) return content;
content = this.getContentByPath(wild); if (content) return content;
content = this.getContentByName(wild); if (content && nonunique) return content;
2018-01-30 23:21:06 +01:00
}
static getContentIndex(content) { return this.localContent.findIndex(c => c === content) }
static getContentById(id) { return this.localContent.find(c => c.id === id) }
static getContentByDirName(dirName) { return this.localContent.find(c => c.dirName === dirName) }
2018-03-06 01:24:14 +01:00
static getContentByPath(path) { return this.localContent.find(c => c.contentPath === path) }
static getContentByName(name) { return this.localContent.find(c => c.name === name) }
2018-01-30 23:21:06 +01:00
2018-02-14 13:55:06 +01:00
/**
* Wait for content to load
2018-03-22 03:13:32 +01:00
* @param {String} content_id
2018-06-23 00:16:42 +02:00
* @return {Promise => Content}
2018-02-14 13:55:06 +01:00
*/
2018-02-12 23:49:44 +01:00
static waitForContent(content_id) {
2018-06-23 00:16:42 +02:00
return Utils.until(() => this.getContentById(content_id), 100);
2018-02-12 23:49:44 +01:00
}
}