1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-06-28 05:15:54 +00:00
FrankerFaceZ/src/addons.js
SirStendec 275248ca36 4.4.1
* Added: `Current Channel` rule for profiles, to match all pages associated with a certain channel without needing many page rules.
* Fixed: Unreadable text in light theme when importing a profile.
* Changed: Display a matching page URL in the `Current Page` rule for profiles.
* Changed: Do not display an inactive profile warning on the Add-Ons settings page, since those are not affected by profiles.
* Changed: Update Vue to a more recent version.
* Maintenance: Update the chat types enum based on the latest version of Twitch.
* API Added: `TwitchData` module (`site.twitch_data`) for querying Twitch's API for data.
2019-06-14 21:24:48 -04:00

348 lines
9.3 KiB
JavaScript

'use strict';
// ============================================================================
// Add-On System
// ============================================================================
import Module from 'utilities/module';
import { SERVER } from 'utilities/constants';
import { createElement } from 'utilities/dom';
import { timeout, has } from 'utilities/object';
import { getBuster } from 'utilities/time';
const fetchJSON = (url, options) => {
return fetch(url, options).then(r => r.ok ? r.json() : null).catch(() => null);
}
// ============================================================================
// AddonManager
// ============================================================================
export default class AddonManager extends Module {
constructor(...args) {
super(...args);
this.should_enable = true;
this.inject('settings');
this.inject('i18n');
this.reload_required = false;
this.addons = {};
this.enabled_addons = [];
}
async onEnable() {
this.settings.addUI('add-ons', {
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}',
component: 'addon-list',
title: 'Add-Ons',
no_filter: true,
getExtraSearch: () => Object.values(this.addons).map(addon => addon.search_terms),
isReady: () => this.enabled,
getAddons: () => Object.values(this.addons),
hasAddon: id => this.hasAddon(id),
getVersion: id => this.getVersion(id),
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);
await this.loadAddonData();
this.enabled_addons = this.settings.provider.get('addons.enabled', []);
// We do not await enabling add-ons because that would delay the
// main script's execution.
for(const id of this.enabled_addons)
if ( this.hasAddon(id) )
this._enableAddon(id);
this.emit(':ready');
}
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');
}
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([
fetchJSON(`${SERVER}/script/addons.json?_=${getBuster(30)}`),
this.settings.get('addons.dev.server') ?
fetchJSON(`https://localhost:8001/script/addons.json?_=${getBuster()}`) : null
]);
if ( Array.isArray(cdn_data) )
for(const addon of cdn_data )
this.addAddon(addon, false);
if ( Array.isArray(local_data) )
for(const addon of local_data)
this.addAddon(addon, true);
this.rebuildAddonSearch();
}
addAddon(addon, is_dev = false) {
const old = this.addons[addon.id];
this.addons[addon.id] = addon;
addon.name_i18n = addon.name_i18n || `addon.${addon.id}.name`;
addon.short_name_i18n = addon.short_name_i18n || `addon.${addon.id}.short_name`;
addon.author_i18n = addon.author_i18n || `addon.${addon.id}.author`;
addon.dev = is_dev;
addon.requires = addon.requires || [];
addon.required_by = Array.isArray(old) ? old : old && old.required_by || [];
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];
}
this.emit(':added');
}
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' ) {
terms.add(this.i18n.t(addon.name_i18n, addon.name));
terms.add(this.i18n.t(addon.short_name_i18n, addon.short_name));
terms.add(this.i18n.t(addon.author_i18n, addon.author));
terms.add(this.i18n.t(addon.description_i18n, addon.description));
}
addon.search_terms = [...terms].map(term => term ? term.toLocaleLowerCase() : '').join('\n');
}
}
isAddonEnabled(id) {
if ( this.isAddonExternal(id) )
return true;
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}`);
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',
src: addon.src || `${addon.dev ? 'https://localhost:8001' : SERVER}/script/addons/${addon.id}/script.js?_=${getBuster(30)}`,
crossorigin: 'anonymous'
}));
// Error if this takes more than 5 seconds.
await timeout(this.waitFor(`addon.${id}:registered`), 5000);
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.
this._enableAddon(id);
}
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');
}
}
}