2019-06-01 02:11:22 -04:00
'use strict' ;
// ============================================================================
// Add-On System
// ============================================================================
import Module from 'utilities/module' ;
2019-06-01 13:58:12 -04:00
import { SERVER } from 'utilities/constants' ;
2019-06-01 02:11:22 -04:00
import { createElement } from 'utilities/dom' ;
import { timeout , has } from 'utilities/object' ;
2019-06-03 19:47:41 -04:00
import { getBuster } from 'utilities/time' ;
2019-06-01 02:11:22 -04:00
2019-06-20 15:15:54 -04:00
const fetchJSON = ( url , options ) => fetch ( url , options ) . then ( r => r . ok ? r . json ( ) : null ) . catch ( ( ) => null ) ;
2019-06-01 02:11:22 -04:00
// ============================================================================
// AddonManager
// ============================================================================
export default class AddonManager extends Module {
constructor ( ... args ) {
super ( ... args ) ;
this . should _enable = true ;
this . inject ( 'settings' ) ;
this . inject ( 'i18n' ) ;
2020-07-22 21:31:41 -04:00
this . load _requires = [ 'settings' ] ;
2021-03-02 16:55:25 -05:00
this . target = this . parent . flavor || 'unknown' ;
2019-09-12 17:12:32 -04:00
this . has _dev = false ;
2019-06-01 02:11:22 -04:00
this . reload _required = false ;
this . addons = { } ;
this . enabled _addons = [ ] ;
}
2020-07-22 21:31:41 -04:00
onLoad ( ) {
this . _loader = this . loadAddonData ( ) ;
}
2020-08-04 18:26:11 -04:00
onEnable ( ) {
2019-06-01 02:11:22 -04:00
this . settings . addUI ( 'add-ons' , {
2019-06-14 21:24:48 -04:00
path : 'Add-Ons @{"description": "Add-Ons are additional modules, often written by other people, that can be loaded automatically by FrankerFaceZ to add new capabilities and behaviors to the extension and Twitch.", "profile_warning": false}' ,
2019-06-01 13:58:12 -04:00
component : 'addon-list' ,
2019-06-01 02:11:22 -04:00
title : 'Add-Ons' ,
no _filter : true ,
2021-03-12 20:16:39 -05:00
getExtraTerms : ( ) => Object . values ( this . addons ) . map ( addon => addon . search _terms ) ,
2019-06-01 02:11:22 -04:00
isReady : ( ) => this . enabled ,
getAddons : ( ) => Object . values ( this . addons ) ,
hasAddon : id => this . hasAddon ( id ) ,
getVersion : id => this . getVersion ( id ) ,
2021-03-02 16:55:25 -05:00
doesAddonTarget : id => this . doesAddonTarget ( id ) ,
2019-06-01 02:11:22 -04:00
isAddonEnabled : id => this . isAddonEnabled ( id ) ,
isAddonExternal : id => this . isAddonExternal ( id ) ,
enableAddon : id => this . enableAddon ( id ) ,
disableAddon : id => this . disableAddon ( id ) ,
isReloadRequired : ( ) => this . reload _required ,
refresh : ( ) => window . location . reload ( ) ,
on : ( ... args ) => this . on ( ... args ) ,
off : ( ... args ) => this . off ( ... args )
} ) ;
this . settings . add ( 'addons.dev.server' , {
default : false ,
ui : {
path : 'Add-Ons >> Development' ,
title : 'Use Local Development Server' ,
description : 'Attempt to load add-ons from local development server on port 8001.' ,
component : 'setting-check-box'
}
} ) ;
this . on ( 'i18n:update' , this . rebuildAddonSearch , this ) ;
this . settings . provider . on ( 'changed' , this . onProviderChange , this ) ;
2020-07-22 21:31:41 -04:00
this . _loader . then ( ( ) => {
this . enabled _addons = this . settings . provider . get ( 'addons.enabled' , [ ] ) ;
2019-06-01 02:11:22 -04:00
2020-07-22 21:31:41 -04:00
// We do not await enabling add-ons because that would delay the
// main script's execution.
for ( const id of this . enabled _addons )
2021-03-02 16:55:25 -05:00
if ( this . hasAddon ( id ) && this . doesAddonTarget ( id ) )
2021-11-19 17:12:17 -05:00
this . _enableAddon ( id ) . catch ( err => {
this . log . error ( ` An error occured while enabling the add-on " ${ id } ": ` , err ) ;
this . log . capture ( err ) ;
} ) ;
2019-06-01 02:11:22 -04:00
2020-07-22 21:31:41 -04:00
this . emit ( ':ready' ) ;
} ) ;
2019-06-01 02:11:22 -04:00
}
2021-03-02 16:55:25 -05:00
doesAddonTarget ( id ) {
const data = this . addons [ id ] ;
if ( ! data )
return false ;
const targets = data . targets ? ? [ 'main' ] ;
if ( ! Array . isArray ( targets ) )
return false ;
return targets . includes ( this . target ) ;
}
2019-06-05 00:30:45 -04:00
generateLog ( ) {
const out = [ 'Known' ] ;
for ( const [ id , addon ] of Object . entries ( this . addons ) )
out . push ( ` ${ id } | ${ this . isAddonEnabled ( id ) ? 'enabled' : 'disabled' } | ${ addon . dev ? 'dev | ' : '' } ${ this . isAddonExternal ( id ) ? 'external | ' : '' } ${ addon . short _name } v ${ addon . version } ` ) ;
out . push ( '' ) ;
out . push ( 'Modules' ) ;
for ( const [ key , module ] of Object . entries ( this . _ _modules ) ) {
if ( module )
out . push ( ` ${ module . loaded ? 'loaded ' : module . loading ? 'loading ' : 'unloaded' } | ${ module . enabled ? 'enabled ' : module . enabling ? 'enabling' : 'disabled' } | ${ key } ` )
}
return out . join ( '\n' ) ;
}
2019-06-01 02:11:22 -04:00
onProviderChange ( key , value ) {
if ( key != 'addons.enabled' )
return ;
if ( ! value )
value = [ ] ;
const old _enabled = [ ... this . enabled _addons ] ;
// Add-ons to disable
for ( const id of old _enabled )
if ( ! value . includes ( id ) )
this . disableAddon ( id , false ) ;
// Add-ons to enable
for ( const id of value )
if ( ! old _enabled . includes ( id ) )
this . enableAddon ( id , false ) ;
}
async loadAddonData ( ) {
const [ cdn _data , local _data ] = await Promise . all ( [
2019-06-03 19:47:41 -04:00
fetchJSON ( ` ${ SERVER } /script/addons.json?_= ${ getBuster ( 30 ) } ` ) ,
2019-06-01 02:11:22 -04:00
this . settings . get ( 'addons.dev.server' ) ?
2019-06-03 19:47:41 -04:00
fetchJSON ( ` https://localhost:8001/script/addons.json?_= ${ getBuster ( ) } ` ) : null
2019-06-01 02:11:22 -04:00
] ) ;
if ( Array . isArray ( cdn _data ) )
for ( const addon of cdn _data )
this . addAddon ( addon , false ) ;
2019-09-12 17:12:32 -04:00
if ( Array . isArray ( local _data ) ) {
this . has _dev = true ;
2019-06-01 02:11:22 -04:00
for ( const addon of local _data )
this . addAddon ( addon , true ) ;
2019-09-12 17:12:32 -04:00
}
2019-06-01 02:11:22 -04:00
2021-03-26 00:29:49 -04:00
this . settings . updateContext ( {
addonDev : this . has _dev
} ) ;
2019-06-01 02:11:22 -04:00
this . rebuildAddonSearch ( ) ;
2019-09-12 17:12:32 -04:00
this . emit ( ':data-loaded' ) ;
2019-06-01 02:11:22 -04:00
}
addAddon ( addon , is _dev = false ) {
const old = this . addons [ addon . id ] ;
this . addons [ addon . id ] = addon ;
2019-10-06 20:01:22 -04:00
/ * a d d o n . n a m e _ i 1 8 n = a d d o n . n a m e _ i 1 8 n | | ` a d d o n . $ { a d d o n . i d } . n a m e ` ;
2019-06-01 02:11:22 -04:00
addon . short _name _i18n = addon . short _name _i18n || ` addon. ${ addon . id } .short_name ` ;
2019-10-06 20:01:22 -04:00
addon . author _i18n = addon . author _i18n || ` addon. ${ addon . id } .author ` ; * /
2019-06-01 02:11:22 -04:00
addon . dev = is _dev ;
addon . requires = addon . requires || [ ] ;
addon . required _by = Array . isArray ( old ) ? old : old && old . required _by || [ ] ;
2021-03-13 16:35:59 -05:00
if ( addon . updated )
addon . updated = new Date ( addon . updated ) ;
if ( addon . created )
addon . created = new Date ( addon . created ) ;
2019-06-01 02:11:22 -04:00
addon . _search = addon . search _terms ;
for ( const id of addon . requires ) {
const target = this . addons [ id ] ;
if ( Array . isArray ( target ) )
target . push ( addon . id ) ;
else if ( target )
target . required _by . push ( addon . id ) ;
else
this . addons [ id ] = [ addon . id ] ;
}
2019-06-01 02:30:01 -04:00
this . emit ( ':added' ) ;
2019-06-01 02:11:22 -04:00
}
rebuildAddonSearch ( ) {
for ( const addon of Object . values ( this . addons ) ) {
const terms = new Set ( [
addon . _search ,
addon . name ,
addon . short _name ,
addon . author ,
addon . description ,
] ) ;
if ( this . i18n . locale !== 'en' ) {
2019-10-07 03:35:53 -04:00
if ( addon . name _i18n )
terms . add ( this . i18n . t ( addon . name _i18n , addon . name ) ) ;
if ( addon . short _name _i18n )
terms . add ( this . i18n . t ( addon . short _name _i18n , addon . short _name ) ) ;
if ( addon . author _i18n )
terms . add ( this . i18n . t ( addon . author _i18n , addon . author ) ) ;
if ( addon . description _i18n )
terms . add ( this . i18n . t ( addon . description _i18n , addon . description ) ) ;
2019-06-01 02:11:22 -04:00
}
addon . search _terms = [ ... terms ] . map ( term => term ? term . toLocaleLowerCase ( ) : '' ) . join ( '\n' ) ;
}
}
isAddonEnabled ( id ) {
2019-06-01 13:58:12 -04:00
if ( this . isAddonExternal ( id ) )
return true ;
2019-06-01 02:11:22 -04:00
return this . enabled _addons . includes ( id ) ;
}
getAddon ( id ) {
const addon = this . addons [ id ] ;
return Array . isArray ( addon ) ? null : addon ;
}
hasAddon ( id ) {
return this . getAddon ( id ) != null ;
}
getVersion ( id ) {
const addon = this . getAddon ( id ) ;
if ( ! addon )
throw new Error ( ` Unknown add-on id: ${ id } ` ) ;
const module = this . resolve ( ` addon. ${ id } ` ) ;
if ( module ) {
if ( has ( module , 'version' ) )
return module . version ;
else if ( module . constructor && has ( module . constructor , 'version' ) )
return module . constructor . version ;
}
return addon . version ;
}
isAddonExternal ( id ) {
if ( ! this . hasAddon ( id ) )
throw new Error ( ` Unknown add-on id: ${ id } ` ) ;
const module = this . resolve ( ` addon. ${ id } ` ) ;
// If we can't find it, assume it isn't.
if ( ! module )
return false ;
// Check for one of our script tags. If we didn't load
// it ourselves, then it's external.
const script = document . head . querySelector ( ` script#ffz-loaded-addon- ${ id } ` ) ;
if ( ! script )
return true ;
// Finally, let the module flag itself as external.
return module . external || ( module . constructor && module . constructor . external ) ;
}
async _enableAddon ( id ) {
const addon = this . getAddon ( id ) ;
if ( ! addon )
throw new Error ( ` Unknown add-on id: ${ id } ` ) ;
await this . loadAddon ( id ) ;
const module = this . resolve ( ` addon. ${ id } ` ) ;
if ( module && ! module . enabled )
await module . enable ( ) ;
}
async loadAddon ( id ) {
const addon = this . getAddon ( id ) ;
if ( ! addon )
throw new Error ( ` Unknown add-on id: ${ id } ` ) ;
2021-11-12 16:58:35 -05:00
await this . i18n . loadChunk ( ` addon. ${ id } ` ) ;
2019-06-01 02:11:22 -04:00
let module = this . resolve ( ` addon. ${ id } ` ) ;
if ( module ) {
if ( ! module . loaded )
await module . load ( ) ;
this . emit ( ':addon-loaded' , id ) ;
return ;
}
document . head . appendChild ( createElement ( 'script' , {
id : ` ffz-loaded-addon- ${ addon . id } ` ,
type : 'text/javascript' ,
2019-06-03 19:47:41 -04:00
src : addon . src || ` ${ addon . dev ? 'https://localhost:8001' : SERVER } /script/addons/ ${ addon . id } /script.js?_= ${ getBuster ( 30 ) } ` ,
2019-06-01 02:11:22 -04:00
crossorigin : 'anonymous'
} ) ) ;
// Error if this takes more than 5 seconds.
2021-09-04 20:14:58 -04:00
await timeout ( this . waitFor ( ` addon. ${ id } :registered ` ) , 60000 ) ;
2019-06-01 02:11:22 -04:00
module = this . resolve ( ` addon. ${ id } ` ) ;
if ( module && ! module . loaded )
await module . load ( ) ;
this . emit ( ':addon-loaded' , id ) ;
}
unloadAddon ( id ) {
const module = this . resolve ( ` addon. ${ id } ` ) ;
if ( module )
return module . unload ( ) ;
}
enableAddon ( id , save = true ) {
const addon = this . getAddon ( id ) ;
if ( ! addon )
throw new Error ( ` Unknown add-on id: ${ id } ` ) ;
if ( this . isAddonEnabled ( id ) )
return ;
if ( Array . isArray ( addon . requires ) ) {
for ( const id of addon . requires ) {
if ( ! this . hasAddon ( id ) )
throw new Error ( ` Unknown add-on id: ${ id } ` ) ;
this . enableAddon ( id ) ;
}
}
this . emit ( ':addon-enabled' , id ) ;
this . enabled _addons . push ( id ) ;
if ( save )
this . settings . provider . set ( 'addons.enabled' , this . enabled _addons ) ;
// Actually load it.
2021-03-02 16:55:25 -05:00
if ( this . doesAddonTarget ( id ) )
2021-11-19 17:12:17 -05:00
this . _enableAddon ( id ) . catch ( err => {
this . log . error ( ` An error occured while enabling the add-on " ${ id } ": ` , err ) ;
this . log . capture ( err ) ;
} ) ;
2019-06-01 02:11:22 -04:00
}
async disableAddon ( id , save = true ) {
const addon = this . getAddon ( id ) ;
if ( ! addon )
throw new Error ( ` Unknown add-on id: ${ id } ` ) ;
if ( this . isAddonExternal ( id ) )
throw new Error ( ` Cannot disable external add-on with id: ${ id } ` ) ;
if ( ! this . isAddonEnabled ( id ) )
return ;
if ( Array . isArray ( addon . required _by ) ) {
const promises = [ ] ;
for ( const id of addon . required _by )
promises . push ( this . disableAddon ( id ) ) ;
await Promise . all ( promises ) ;
}
this . emit ( ':addon-disabled' , id ) ;
this . enabled _addons . splice ( this . enabled _addons . indexOf ( id ) , 1 ) ;
if ( save )
this . settings . provider . set ( 'addons.enabled' , this . enabled _addons ) ;
// Try disabling loaded modules.
try {
const module = this . resolve ( ` addon. ${ id } ` ) ;
if ( module )
await module . disable ( ) ;
} catch ( err ) {
this . reload _required = true ;
this . emit ( ':reload-required' ) ;
}
}
}