mirror of
https://github.com/FrankerFaceZ/FrankerFaceZ.git
synced 2025-06-28 05:15:54 +00:00
* 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.
348 lines
9.3 KiB
JavaScript
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');
|
|
}
|
|
}
|
|
}
|