diff --git a/package.json b/package.json index d1ef6f1a..88580726 100755 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "frankerfacez", "author": "Dan Salvato LLC", - "version": "4.77.12", + "version": "4.78.0", "description": "FrankerFaceZ is a Twitch enhancement suite.", "private": true, "license": "Apache-2.0", diff --git a/src/entry_ext.js b/src/entry_ext.js index 586f3f75..a05104dc 100644 --- a/src/entry_ext.js +++ b/src/entry_ext.js @@ -2,6 +2,7 @@ 'use strict'; (() => { const browser = globalThis.browser ?? globalThis.chrome; + const is_firefox = (typeof browser === 'object' && browser.runtime.getURL('').startsWith('moz')); if ( // Don't run on certain sub-domains. @@ -21,6 +22,7 @@ } document.body.dataset.ffzSource = 'extension'; + document.body.dataset.ffzExtension = browser.runtime.id; // Make sure to wake the service worker up early. browser.runtime.sendMessage({ @@ -29,75 +31,34 @@ // Set up a bridge for connections, since Firefox // doesn't support externally_connectable. - const connections = new Map; - - function handleConnect(id) { - if ( connections.has(id) ) - return; - - const port = browser.runtime.connect(); - connections.set(id, port); - + if (is_firefox) { + const port = browser.runtime.connect({ name: 'ffz-cs-bridge' }); port.onMessage.addListener(msg => { window.postMessage({ - type: 'ffz-con-message', - id, - payload: msg - }) + ffz_from_worker: true, + ...msg + }, window.location.origin); }); - port.onDisconnect.addListener(() => { - connections.delete(id); - window.postMessage({ - type: 'ffz-con-disconnect', - id - }); + window.addEventListener('message', evt => { + if (evt.source !== window || ! evt.data?.ffz_to_worker ) + return; + + port.postMessage(evt.data); }); } - function handleDisconnect(id) { - const port = connections.get(id); - if ( port ) { - connections.delete(id); - port.disconnect(); - } - } - - function handleMessage(id, payload) { - const port = connection.get(id); - if ( port ) { - port.postMessage(payload); - } - } - - window.addEventListener('message', evt => { - if (evt.source !== window || ! evt.data ) - return; - - const { type, id, payload } = evt.data; - - if ( type === 'ffz-con-connect' ) - handleConnect(id); - - else if ( type === 'ffz-con-message' ) - handleMessage(id, payload); - - else if ( type === 'ffz-con-disconnect' ) - handleDisconnect(id); - }); - - // Let the extension send messages to the page directly. - browser.runtime.onMessage.addListener((msg, sender) => { - if (msg?.type === 'ffz_to_page') - window.postMessage(msg.data, '*'); + browser.runtime.onMessage.addListener(msg => { + if (msg?.ffz_from_worker) + window.postMessage(msg, window.location.origin); return false; }); // Now, inject our script into the page context. const HOST = location.hostname, - SERVER = browser.runtime.getURL("web"), + SERVER = browser.runtime.getURL('web'), script = document.createElement('script'); let FLAVOR = diff --git a/src/main.ts b/src/main.ts index 029437a3..2a981170 100644 --- a/src/main.ts +++ b/src/main.ts @@ -38,6 +38,7 @@ import * as Utility_Time from 'utilities/time'; import * as Utility_Tooltip from 'utilities/tooltip'; import * as Utility_I18n from 'utilities/translation-core'; import * as Utility_Filtering from 'utilities/filtering'; +import { installPort } from './utilities/extension_port'; class FrankerFaceZ extends Module { @@ -128,6 +129,9 @@ class FrankerFaceZ extends Module { // Core Systems // ======================================================================== + if (Utility_Constants.EXTENSION) + installPort(this); + this.inject('settings', SettingsManager); this.inject('experiments', ExperimentManager); this.inject('i18n', TranslationManager); diff --git a/src/settings/index.ts b/src/settings/index.ts index 3d7fd1d5..4c00dae7 100644 --- a/src/settings/index.ts +++ b/src/settings/index.ts @@ -19,8 +19,9 @@ import * as CLEARABLES from './clearables'; import type { SettingsProfileMetadata, ContextData, ExportedFullDump, SettingsClearable, SettingDefinition, SettingProcessor, SettingUiDefinition, SettingValidator, SettingType, ExportedBlobMetadata, SettingsKeys, AllSettingsKeys, ConcreteLocalStorageData } from './types'; import type { FilterType } from 'utilities/filtering'; -import { AdvancedSettingsProvider, IndexedDBProvider, LocalStorageProvider, Providers, type SettingsProvider } from './providers'; +import { AdvancedSettingsProvider, IndexedDBProvider, LocalStorageProvider, Providers, SettingsProvider } from './providers'; import type { AddonInfo, SettingsTypeMap } from 'utilities/types'; +import { FFZEvent } from '../utilities/events'; export {parse as parse_path} from 'utilities/path-parser'; @@ -42,6 +43,21 @@ export const NO_SYNC_KEYS = ['session']; // Registration // ============================================================================ +type FFZProviderConstructorEvent = { + settings: SettingsManager; + Provider: typeof SettingsProvider; + AdvancedProvider: typeof AdvancedSettingsProvider; + registerProvider: (key: string, provider: typeof SettingsProvider) => void; +}; + +type FFZProviderConstructor = (evt: FFZProviderConstructorEvent) => void; + +declare global { + interface Window { + ffz_providers?: FFZProviderConstructor[]; + } +} + declare module 'utilities/types' { interface ModuleEventMap { settings: SettingsEvents; @@ -147,10 +163,33 @@ export default class SettingsManager extends Module<'settings', SettingsEvents> this.providers = {}; for(const [key, provider] of Object.entries(Providers)) { - if ( provider.supported() ) + if ( provider.supported(this) ) this.providers[key] = provider; } + // Load any dynamic providers that have been registered. + // Now that we're here, no further providers can be registered, so seal them. + window.ffz_providers = window.ffz_providers || []; + Object.seal(window.ffz_providers); + if ( window.ffz_providers.length > 0 ) { + const evt = { + settings: this, + Provider: SettingsProvider, + AdvancedProvider: AdvancedSettingsProvider, + registerProvider: (key: string, provider: typeof SettingsProvider) => { + if ( ! this.providers[key] && provider.supported(this) ) + this.providers[key] = provider; + } + }; + + for(const p of window.ffz_providers) + try { + p(evt); + } catch(err) { + this.log.error('Error while registering external settings provider:', err); + } + } + // This cannot be modified at a future time, as providers NEED // to be ready very early in FFZ intitialization. Seal it. Object.seal(this.providers); @@ -689,13 +728,35 @@ export default class SettingsManager extends Module<'settings', SettingsEvents> * @returns {SettingsProvider} The provider to store everything. */ async _createProvider() { - // If we should be using Cross-Origin Storage Bridge, do so. - //if ( this.providers.cosb && this.providers.cosb.supported() ) - // return new this.providers.cosb(this); + // There are a couple situations where we might want to ignore a + // pre-set provider and sniff anyways. These are situations where + // a user can't open the control center. So, the player embeds, + // clips pages, and embedded chat that's embedded within other sites. + let ignore_choice = false; + if ( (this.root as any).host === 'twitch' ) { + // Player or clips + if ( location.hostname.startsWith('player.') || location.hostname.startsWith('clips.') ) + ignore_choice = true; - let wanted = localStorage.ffzProviderv2; - if ( wanted == null ) - wanted = localStorage.ffzProviderv2 = await this.sniffProvider(); + // Embedded stuff + else if ( location.pathname.startsWith('/embed/') && ( + // ancestorOrigins > 0 + ( location.ancestorOrigins && location.ancestorOrigins.length ) + || + // parent.location mismatch + ( window.parent && window.parent.location !== location ) + ) ) + ignore_choice = true; + } + + let wanted; + if (ignore_choice) + wanted = await this.sniffProvider(); + else { + wanted = localStorage.ffzProviderv2; + if ( wanted == null ) + wanted = localStorage.ffzProviderv2 = await this.sniffProvider(); + } if ( this.providers[wanted] ) { const provider = new (this.providers[wanted] as any)(this) as SettingsProvider; @@ -726,7 +787,7 @@ export default class SettingsManager extends Module<'settings', SettingsEvents> ); for(const [key, provider] of providers) { - if ( provider.supported() && await provider.hasContent() ) // eslint-disable-line no-await-in-loop + if ( provider.supported(this) && await provider.hasContent(this) ) // eslint-disable-line no-await-in-loop return key; } diff --git a/src/settings/providers.ts b/src/settings/providers.ts index ccbcefcd..37a2dd28 100644 --- a/src/settings/providers.ts +++ b/src/settings/providers.ts @@ -51,7 +51,7 @@ export abstract class SettingsProvider extends EventEmitter { static title: string; static description: string; - static hasContent: () => OptionalPromise; + static hasContent: (manager: SettingsManager) => OptionalPromise; manager: SettingsManager; @@ -70,7 +70,7 @@ export abstract class SettingsProvider extends EventEmitter { this.disabled = false; } - static supported() { + static supported(manager: SettingsManager) { return false; } @@ -103,14 +103,14 @@ export abstract class SettingsProvider extends EventEmitter { ): ProviderTypeMap[K]; abstract get( key: K - ): ProviderTypeMap[K] | null; + ): ProviderTypeMap[K] | undefined; abstract get( key: Exclude, default_value: T ): T; abstract get( key: Exclude - ): T | null; + ): T | undefined; abstract set(key: K, value: ProviderTypeMap[K]): void; abstract set(key: Exclude, value: unknown): void; @@ -230,7 +230,12 @@ export abstract class RemoteSettingsProvider extends AdvancedSettingsProvider { // Provider Methods - get(key: string, default_value?: T): T { + get(key: string): T | undefined; + get(key: string, default_value: T): T; + get( + key: string, + default_value?: T + ): T | undefined { return this._cached.has(key) ? this._cached.get(key) : default_value; @@ -412,9 +417,8 @@ export class LocalStorageProvider extends SettingsProvider { return true; } - static hasContent(prefix?: string) { - if ( ! prefix ) - prefix = 'FFZ:setting:'; + static hasContent() { + const prefix = 'FFZ:setting:'; for(const key in localStorage) if ( key.startsWith(prefix) && has(localStorage, key) ) @@ -432,9 +436,9 @@ export class LocalStorageProvider extends SettingsProvider { private _boundHandleMessage?: ((event: MessageEvent) => void) | null; private _boundHandleStorage?: ((event: StorageEvent) => void) | null; - constructor(manager: SettingsManager, prefix?: string) { + constructor(manager: SettingsManager) { super(manager); - this.prefix = prefix = prefix == null ? 'FFZ:setting:' : prefix; + const prefix = this.prefix = 'FFZ:setting:'; const cache = this._cached = new Map, len = prefix.length; @@ -564,10 +568,12 @@ export class LocalStorageProvider extends SettingsProvider { } } + get(key: string): T | undefined; + get(key: string, default_value: T): T; get( key: string, default_value?: T - ): T { + ): T | undefined { return this._cached.has(key) ? this._cached.get(key) : default_value; @@ -887,7 +893,12 @@ export class IndexedDBProvider extends AdvancedSettingsProvider { // Synchronous Methods - get(key: string, default_value?: T): T { + get(key: string): T | undefined; + get(key: string, default_value: T): T; + get( + key: string, + default_value?: T + ): T | undefined { return this._cached.has(key) ? this._cached.get(key) : default_value; @@ -1513,7 +1524,7 @@ export class ExtensionProvider extends RemoteSettingsProvider { static supported() { return EXTENSION } - static hasContent() { + static hasContent(manager: SettingsManager) { if ( ! ExtensionProvider.supported() ) return false; @@ -1523,19 +1534,11 @@ export class ExtensionProvider extends RemoteSettingsProvider { let responded = false, timeout: ReturnType | null = null ; - const listener = (evt: MessageEvent) => { - if (evt.source !== window) - return; - - if (evt.data && evt.data.type === 'ffz_from_ext') { - const msg = evt.data.data, - type = msg?.ffz_type; - - if (type === 'has-keys') { - responded = true; - resolve(msg.value); - cleanup(); - } + const listener = (msg: any) => { + if ( msg.type === 'has-keys' ) { + responded = true; + resolve(msg.value); + cleanup(); } }; @@ -1550,17 +1553,11 @@ export class ExtensionProvider extends RemoteSettingsProvider { timeout = null; } - window.removeEventListener('message', listener); + manager.off('ext:message', listener); } - window.addEventListener('message', listener); - - window.postMessage({ - type: 'ffz_to_ext', - data: { - ffz_type: 'check-has-keys' - } - }, '*'); + manager.on('ext:message', listener); + manager.emit('ext:message', { type: 'check-has-keys' }); timeout = setTimeout(cleanup, 1000); }); @@ -1578,8 +1575,8 @@ export class ExtensionProvider extends RemoteSettingsProvider { constructor(manager: SettingsManager) { super(manager); - this.onExtMessage = this.onExtMessage.bind(this); - window.addEventListener('message', this.onExtMessage); + manager.on('ext:message', this.handleMessage, this); + this.send('ready'); } // Stuff @@ -1589,36 +1586,16 @@ export class ExtensionProvider extends RemoteSettingsProvider { } disableEvents() { - + this.manager.off('ext:message', this.handleMessage, this); } // Communication - onExtMessage(evt: MessageEvent) { - if (evt.source !== window) - return; - - if (evt.data?.type === 'ffz_from_ext' && evt.data.data?.ffz_type) - this.handleMessage(evt.data.data); - } - send(msg: string | CorsMessage, transfer?: OptionalArray) { if ( typeof msg === 'string' ) msg = {ffz_type: msg} as any; - try { - window.postMessage( - { - type: 'ffz_to_ext', - data: msg - }, - '*', - transfer ? (Array.isArray(transfer) ? transfer : [transfer]) : undefined - ); - - } catch(err) { - this.manager.log.error('Error sending message to extension.', err, msg, transfer); - } + this.manager.emit('ext:message', msg); } } diff --git a/src/sites/twitch-twilight/modules/loadable.tsx b/src/sites/twitch-twilight/modules/loadable.tsx index f68ba155..b52d0475 100644 --- a/src/sites/twitch-twilight/modules/loadable.tsx +++ b/src/sites/twitch-twilight/modules/loadable.tsx @@ -23,6 +23,7 @@ declare module 'utilities/types' { interface SettingsTypeMap { 'chat.hype.show-pinned': boolean; 'layout.turbo-cta': boolean; + 'layout.subtember': boolean; } } @@ -95,6 +96,10 @@ export default class Loadable extends Module { this.toggle('TopNav__TurboButton_Available', val); }); + this.settings.getChanges('layout.subtember', val => { + this.toggle('TokenizedCommerceBanner', val); + }); + this.ErrorBoundaryComponent.ready((cls, instances) => { this.log.debug('Found Error Boundary component wrapper.'); diff --git a/src/sites/twitch-twilight/modules/theme/index.js b/src/sites/twitch-twilight/modules/theme/index.js index 2eb475bf..854529fb 100644 --- a/src/sites/twitch-twilight/modules/theme/index.js +++ b/src/sites/twitch-twilight/modules/theme/index.js @@ -575,7 +575,7 @@ export default class ThemeEngine extends Module { const selector = dark ? '' : `:not(.settings-menu-button-component--forced-dark-theme)`; if ( bits.length ) - this.css_tweaks.set('colors', `body,body .tw-root--theme-light,body .tw-root--theme-dark${selector} {${bits.join('\n')}}.channel-info-content .tw-accent-region,.channel-info-content div[class^="ScAccentRegion"]{${accent_bits.join('\n')}}`); + this.css_tweaks.set('colors', `body,body .tw-root--theme-light.tw-root--theme-light,body .tw-root--theme-dark.tw-root--theme-dark${selector} {${bits.join('\n')}}.channel-info-content .tw-accent-region,.channel-info-content div[class^="ScAccentRegion"]{${accent_bits.join('\n')}}`); else this.css_tweaks.delete('colors'); } diff --git a/src/utilities/compat/subpump.ts b/src/utilities/compat/subpump.ts index b22c111f..f68b6478 100644 --- a/src/utilities/compat/subpump.ts +++ b/src/utilities/compat/subpump.ts @@ -150,7 +150,12 @@ export default class Subpump extends Module<'site.subpump', SubpumpEvents> { if ( instance ) { this.instance = instance; - this.hookClient(instance); + try { + this.hookClient(instance); + } catch(err) { + this.instance = null; + this.log.error('Error hooking PubSub client.', err); + } } /* diff --git a/src/utilities/extension_port.ts b/src/utilities/extension_port.ts new file mode 100644 index 00000000..e5df77b0 --- /dev/null +++ b/src/utilities/extension_port.ts @@ -0,0 +1,54 @@ +import Module from "./module"; + +type PortType = { + postMessage: (msg: any) => void; +} + +export function installPort(module: Module) { + let port: PortType | null = null; + + function initialize() { + try { + // Try connecting with externally_connectable first. + const cp = port = chrome.runtime.connect(document.body.dataset.ffzExtension!, { + name: 'ffz-ext-port' + }); + + cp.onMessage.addListener(msg => { + module.emit('ext:message', msg); + }); + + cp.onDisconnect.addListener(p => { + module.log.warn('Extension port disconnected.', (p as any)?.error ?? chrome.runtime.lastError); + port = null; + }); + + return; + } catch(err) { + module.log.info('Unable to connect using externally_connectable, falling back to bridge.'); + } + + window.addEventListener('message', evt => { + if ( evt.source !== window || ! evt.data || evt.data.ffz_from_worker ) + return; + + module.emit('ext:message', evt.data); + }); + + port = { + postMessage(msg) { + window.postMessage({ + ffz_to_worker: true, + ...msg + }, window.location.origin); + } + }; + } + + module.on('ext:post-message', msg => { + if ( ! port ) + initialize(); + + port!.postMessage(msg); + }); +} diff --git a/src/utilities/module.ts b/src/utilities/module.ts index f333959d..aca29ace 100644 --- a/src/utilities/module.ts +++ b/src/utilities/module.ts @@ -60,7 +60,9 @@ export type ModuleEvents = { ':loaded': [module: GenericModule], ':unloaded': [module: GenericModule], ':enabled': [module: GenericModule], - ':disabled': [module: GenericModule] + ':disabled': [module: GenericModule], + 'ext:message': [msg: any], + 'ext:post-message': [msg: any] }; export type GenericModule = Module;