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' ;
2018-03-09 02:48:41 +01:00
import Database from './database' ;
2018-03-04 01:22:05 +01:00
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' ;
2018-03-09 02:48:41 +01:00
import Combokeys from 'combokeys' ;
2018-08-27 18:36:06 +02:00
import Settings from './settings' ;
2018-01-30 16:59:27 +01:00
2018-02-14 13:55:06 +01:00
/ * *
2018-03-09 02:48:41 +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-02-11 20:31:24 +01:00
2018-01-30 23:21:06 +01:00
Logger . err ( this . moduleName , err ) ;
}
}
2018-02-14 00:23:52 +01:00
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
2018-02-21 19:06:44 +01:00
* @ param { bool } suppressErrors Suppress any errors that occur during loading of content
2018-02-14 13:55:06 +01:00
* /
2018-02-21 19:06:44 +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 ) {
2018-08-27 18:36:06 +02:00
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 ) {
2018-02-21 19:06:44 +01:00
// 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 ;
2018-02-21 19:06:44 +01:00
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 } ` ,
2018-02-21 19:06:44 +01:00
err
} ) ) ;
Logger . err ( this . moduleName , err ) ;
}
2018-01-30 23:21:06 +01:00
}
2018-02-21 19:06:44 +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 ) ;
2019-03-12 20:10:09 +01:00
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 ) {
2019-03-12 20:10:09 +01:00
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 ;
2018-08-27 18:36:06 +02:00
// 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
2018-03-04 01:22:05 +01:00
const defaultConfig = new SettingsSet ( {
settings : readConfig . defaultConfig ,
schemes : readConfig . configSchemes
} ) ;
2018-01-30 16:59:27 +01:00
const userConfig = {
enabled : false ,
2018-03-04 01:22:05 +01:00
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 } ) ;
2018-03-07 09:28:14 +01:00
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-02-03 10:25:34 +01:00
}
2018-01-30 16:59:27 +01:00
2018-03-04 01:22:05 +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 ) ) {
2018-03-09 02:48:41 +01:00
// 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
2018-03-04 01:22:05 +01:00
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 ) ;
}
2018-03-09 02:48:41 +01:00
Utils . deepfreeze ( defaultConfig , object => object instanceof Combokeys ) ;
2018-03-04 01:22:05 +01:00
2018-01-30 16:59:27 +01:00
const configs = {
2018-03-04 01:22:05 +01:00
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
2018-03-31 01:50:03 +02: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 ;
}
}
2018-02-04 21:17:22 +01:00
2018-08-21 02:45:54 +02:00
/ * *
* 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 {
2018-08-22 15:27:06 +02:00
await Modals . confirm ( ` Delete ${ this . contentType } ? ` , ` Are you sure you want to delete ${ content . info . name } ? ` , 'Delete' ) . promise ;
2018-08-21 02:45:54 +02:00
} catch ( err ) {
return false ;
}
try {
const unload = this . unloadContent ( content , force , false ) ;
if ( ! force )
await unload ;
2018-08-22 15:27:06 +02:00
await FileUtils . recursiveDeleteDirectory ( content . paths . contentPath ) ;
2019-03-12 20:10:09 +01:00
if ( content . packed ) await FileUtils . recursiveDeleteDirectory ( content . packagePath ) ;
2018-08-21 02:45:54 +02:00
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
2018-05-29 22:48:58 +02:00
* @ 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
* /
2018-05-29 22:48:58 +02: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 {
2018-05-29 22:48:58 +02:00
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 ) ;
2018-08-22 18:44:30 +02:00
if ( this . unloadContentHook ) this . unloadContentHook ( content ) ;
2018-02-21 18:46:27 +01:00
2019-03-12 20:10:09 +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
2018-05-29 22:48:58 +02:00
* @ 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
* /
2018-05-29 22:48:58 +02: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
}
2018-02-05 15:33:30 +01:00
}