1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-09-16 10:06:54 +00:00
* Fixed: FFZ failing to load correctly due to an issue with a pubsub module.
* Fixed: The Subtember banner not being hidden.
* Fixed: Some themed elements not having their colors correctly changed.
* API Changed: Introduce way for the user-script to register a settings provider for cross-origin storage of settings.
* API Changed: Partial work for registering a settings provider for extension storage.
This commit is contained in:
SirStendec 2025-09-02 14:52:52 -04:00
parent 6e49b689e9
commit b62e2f7530
10 changed files with 196 additions and 127 deletions

View file

@ -1,7 +1,7 @@
{ {
"name": "frankerfacez", "name": "frankerfacez",
"author": "Dan Salvato LLC", "author": "Dan Salvato LLC",
"version": "4.77.12", "version": "4.78.0",
"description": "FrankerFaceZ is a Twitch enhancement suite.", "description": "FrankerFaceZ is a Twitch enhancement suite.",
"private": true, "private": true,
"license": "Apache-2.0", "license": "Apache-2.0",

View file

@ -2,6 +2,7 @@
'use strict'; 'use strict';
(() => { (() => {
const browser = globalThis.browser ?? globalThis.chrome; const browser = globalThis.browser ?? globalThis.chrome;
const is_firefox = (typeof browser === 'object' && browser.runtime.getURL('').startsWith('moz'));
if ( if (
// Don't run on certain sub-domains. // Don't run on certain sub-domains.
@ -21,6 +22,7 @@
} }
document.body.dataset.ffzSource = 'extension'; document.body.dataset.ffzSource = 'extension';
document.body.dataset.ffzExtension = browser.runtime.id;
// Make sure to wake the service worker up early. // Make sure to wake the service worker up early.
browser.runtime.sendMessage({ browser.runtime.sendMessage({
@ -29,75 +31,34 @@
// Set up a bridge for connections, since Firefox // Set up a bridge for connections, since Firefox
// doesn't support externally_connectable. // doesn't support externally_connectable.
const connections = new Map; if (is_firefox) {
const port = browser.runtime.connect({ name: 'ffz-cs-bridge' });
function handleConnect(id) {
if ( connections.has(id) )
return;
const port = browser.runtime.connect();
connections.set(id, port);
port.onMessage.addListener(msg => { port.onMessage.addListener(msg => {
window.postMessage({ window.postMessage({
type: 'ffz-con-message', ffz_from_worker: true,
id, ...msg
payload: msg }, window.location.origin);
})
}); });
port.onDisconnect.addListener(() => { window.addEventListener('message', evt => {
connections.delete(id); if (evt.source !== window || ! evt.data?.ffz_to_worker )
window.postMessage({ return;
type: 'ffz-con-disconnect',
id 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. // Let the extension send messages to the page directly.
browser.runtime.onMessage.addListener((msg, sender) => { browser.runtime.onMessage.addListener(msg => {
if (msg?.type === 'ffz_to_page') if (msg?.ffz_from_worker)
window.postMessage(msg.data, '*'); window.postMessage(msg, window.location.origin);
return false; return false;
}); });
// Now, inject our script into the page context. // Now, inject our script into the page context.
const HOST = location.hostname, const HOST = location.hostname,
SERVER = browser.runtime.getURL("web"), SERVER = browser.runtime.getURL('web'),
script = document.createElement('script'); script = document.createElement('script');
let FLAVOR = let FLAVOR =

View file

@ -38,6 +38,7 @@ import * as Utility_Time from 'utilities/time';
import * as Utility_Tooltip from 'utilities/tooltip'; import * as Utility_Tooltip from 'utilities/tooltip';
import * as Utility_I18n from 'utilities/translation-core'; import * as Utility_I18n from 'utilities/translation-core';
import * as Utility_Filtering from 'utilities/filtering'; import * as Utility_Filtering from 'utilities/filtering';
import { installPort } from './utilities/extension_port';
class FrankerFaceZ extends Module { class FrankerFaceZ extends Module {
@ -128,6 +129,9 @@ class FrankerFaceZ extends Module {
// Core Systems // Core Systems
// ======================================================================== // ========================================================================
if (Utility_Constants.EXTENSION)
installPort(this);
this.inject('settings', SettingsManager); this.inject('settings', SettingsManager);
this.inject('experiments', ExperimentManager); this.inject('experiments', ExperimentManager);
this.inject('i18n', TranslationManager); this.inject('i18n', TranslationManager);

View file

@ -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 { SettingsProfileMetadata, ContextData, ExportedFullDump, SettingsClearable, SettingDefinition, SettingProcessor, SettingUiDefinition, SettingValidator, SettingType, ExportedBlobMetadata, SettingsKeys, AllSettingsKeys, ConcreteLocalStorageData } from './types';
import type { FilterType } from 'utilities/filtering'; 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 type { AddonInfo, SettingsTypeMap } from 'utilities/types';
import { FFZEvent } from '../utilities/events';
export {parse as parse_path} from 'utilities/path-parser'; export {parse as parse_path} from 'utilities/path-parser';
@ -42,6 +43,21 @@ export const NO_SYNC_KEYS = ['session'];
// Registration // 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' { declare module 'utilities/types' {
interface ModuleEventMap { interface ModuleEventMap {
settings: SettingsEvents; settings: SettingsEvents;
@ -147,10 +163,33 @@ export default class SettingsManager extends Module<'settings', SettingsEvents>
this.providers = {}; this.providers = {};
for(const [key, provider] of Object.entries(Providers)) { for(const [key, provider] of Object.entries(Providers)) {
if ( provider.supported() ) if ( provider.supported(this) )
this.providers[key] = provider; 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 // This cannot be modified at a future time, as providers NEED
// to be ready very early in FFZ intitialization. Seal it. // to be ready very early in FFZ intitialization. Seal it.
Object.seal(this.providers); Object.seal(this.providers);
@ -689,13 +728,35 @@ export default class SettingsManager extends Module<'settings', SettingsEvents>
* @returns {SettingsProvider} The provider to store everything. * @returns {SettingsProvider} The provider to store everything.
*/ */
async _createProvider() { async _createProvider() {
// If we should be using Cross-Origin Storage Bridge, do so. // There are a couple situations where we might want to ignore a
//if ( this.providers.cosb && this.providers.cosb.supported() ) // pre-set provider and sniff anyways. These are situations where
// return new this.providers.cosb(this); // 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; // Embedded stuff
if ( wanted == null ) else if ( location.pathname.startsWith('/embed/') && (
wanted = localStorage.ffzProviderv2 = await this.sniffProvider(); // 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] ) { if ( this.providers[wanted] ) {
const provider = new (this.providers[wanted] as any)(this) as SettingsProvider; 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) { 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; return key;
} }

View file

@ -51,7 +51,7 @@ export abstract class SettingsProvider extends EventEmitter<ProviderEvents> {
static title: string; static title: string;
static description: string; static description: string;
static hasContent: () => OptionalPromise<boolean>; static hasContent: (manager: SettingsManager) => OptionalPromise<boolean>;
manager: SettingsManager; manager: SettingsManager;
@ -70,7 +70,7 @@ export abstract class SettingsProvider extends EventEmitter<ProviderEvents> {
this.disabled = false; this.disabled = false;
} }
static supported() { static supported(manager: SettingsManager) {
return false; return false;
} }
@ -103,14 +103,14 @@ export abstract class SettingsProvider extends EventEmitter<ProviderEvents> {
): ProviderTypeMap[K]; ): ProviderTypeMap[K];
abstract get<K extends keyof ProviderTypeMap>( abstract get<K extends keyof ProviderTypeMap>(
key: K key: K
): ProviderTypeMap[K] | null; ): ProviderTypeMap[K] | undefined;
abstract get<T>( abstract get<T>(
key: Exclude<string, keyof ProviderTypeMap>, key: Exclude<string, keyof ProviderTypeMap>,
default_value: T default_value: T
): T; ): T;
abstract get<T>( abstract get<T>(
key: Exclude<string, keyof ProviderTypeMap> key: Exclude<string, keyof ProviderTypeMap>
): T | null; ): T | undefined;
abstract set<K extends keyof ProviderTypeMap>(key: K, value: ProviderTypeMap[K]): void; abstract set<K extends keyof ProviderTypeMap>(key: K, value: ProviderTypeMap[K]): void;
abstract set<K extends string>(key: Exclude<K, keyof ProviderTypeMap>, value: unknown): void; abstract set<K extends string>(key: Exclude<K, keyof ProviderTypeMap>, value: unknown): void;
@ -230,7 +230,12 @@ export abstract class RemoteSettingsProvider extends AdvancedSettingsProvider {
// Provider Methods // Provider Methods
get<T>(key: string, default_value?: T): T { get<T>(key: string): T | undefined;
get<T>(key: string, default_value: T): T;
get<T>(
key: string,
default_value?: T
): T | undefined {
return this._cached.has(key) return this._cached.has(key)
? this._cached.get(key) ? this._cached.get(key)
: default_value; : default_value;
@ -412,9 +417,8 @@ export class LocalStorageProvider extends SettingsProvider {
return true; return true;
} }
static hasContent(prefix?: string) { static hasContent() {
if ( ! prefix ) const prefix = 'FFZ:setting:';
prefix = 'FFZ:setting:';
for(const key in localStorage) for(const key in localStorage)
if ( key.startsWith(prefix) && has(localStorage, key) ) if ( key.startsWith(prefix) && has(localStorage, key) )
@ -432,9 +436,9 @@ export class LocalStorageProvider extends SettingsProvider {
private _boundHandleMessage?: ((event: MessageEvent) => void) | null; private _boundHandleMessage?: ((event: MessageEvent) => void) | null;
private _boundHandleStorage?: ((event: StorageEvent) => void) | null; private _boundHandleStorage?: ((event: StorageEvent) => void) | null;
constructor(manager: SettingsManager, prefix?: string) { constructor(manager: SettingsManager) {
super(manager); super(manager);
this.prefix = prefix = prefix == null ? 'FFZ:setting:' : prefix; const prefix = this.prefix = 'FFZ:setting:';
const cache = this._cached = new Map, const cache = this._cached = new Map,
len = prefix.length; len = prefix.length;
@ -564,10 +568,12 @@ export class LocalStorageProvider extends SettingsProvider {
} }
} }
get<T>(key: string): T | undefined;
get<T>(key: string, default_value: T): T;
get<T>( get<T>(
key: string, key: string,
default_value?: T default_value?: T
): T { ): T | undefined {
return this._cached.has(key) return this._cached.has(key)
? this._cached.get(key) ? this._cached.get(key)
: default_value; : default_value;
@ -887,7 +893,12 @@ export class IndexedDBProvider extends AdvancedSettingsProvider {
// Synchronous Methods // Synchronous Methods
get<T>(key: string, default_value?: T): T { get<T>(key: string): T | undefined;
get<T>(key: string, default_value: T): T;
get<T>(
key: string,
default_value?: T
): T | undefined {
return this._cached.has(key) return this._cached.has(key)
? this._cached.get(key) ? this._cached.get(key)
: default_value; : default_value;
@ -1513,7 +1524,7 @@ export class ExtensionProvider extends RemoteSettingsProvider {
static supported() { return EXTENSION } static supported() { return EXTENSION }
static hasContent() { static hasContent(manager: SettingsManager) {
if ( ! ExtensionProvider.supported() ) if ( ! ExtensionProvider.supported() )
return false; return false;
@ -1523,19 +1534,11 @@ export class ExtensionProvider extends RemoteSettingsProvider {
let responded = false, let responded = false,
timeout: ReturnType<typeof setTimeout> | null = null ; timeout: ReturnType<typeof setTimeout> | null = null ;
const listener = (evt: MessageEvent<any>) => { const listener = (msg: any) => {
if (evt.source !== window) if ( msg.type === 'has-keys' ) {
return; responded = true;
resolve(msg.value);
if (evt.data && evt.data.type === 'ffz_from_ext') { cleanup();
const msg = evt.data.data,
type = msg?.ffz_type;
if (type === 'has-keys') {
responded = true;
resolve(msg.value);
cleanup();
}
} }
}; };
@ -1550,17 +1553,11 @@ export class ExtensionProvider extends RemoteSettingsProvider {
timeout = null; timeout = null;
} }
window.removeEventListener('message', listener); manager.off('ext:message', listener);
} }
window.addEventListener('message', listener); manager.on('ext:message', listener);
manager.emit('ext:message', { type: 'check-has-keys' });
window.postMessage({
type: 'ffz_to_ext',
data: {
ffz_type: 'check-has-keys'
}
}, '*');
timeout = setTimeout(cleanup, 1000); timeout = setTimeout(cleanup, 1000);
}); });
@ -1578,8 +1575,8 @@ export class ExtensionProvider extends RemoteSettingsProvider {
constructor(manager: SettingsManager) { constructor(manager: SettingsManager) {
super(manager); super(manager);
this.onExtMessage = this.onExtMessage.bind(this); manager.on('ext:message', this.handleMessage, this);
window.addEventListener('message', this.onExtMessage); this.send('ready');
} }
// Stuff // Stuff
@ -1589,36 +1586,16 @@ export class ExtensionProvider extends RemoteSettingsProvider {
} }
disableEvents() { disableEvents() {
this.manager.off('ext:message', this.handleMessage, this);
} }
// Communication // Communication
onExtMessage(evt: MessageEvent<any>) {
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<Transferable>) { send(msg: string | CorsMessage, transfer?: OptionalArray<Transferable>) {
if ( typeof msg === 'string' ) if ( typeof msg === 'string' )
msg = {ffz_type: msg} as any; msg = {ffz_type: msg} as any;
try { this.manager.emit('ext:message', msg);
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);
}
} }
} }

View file

@ -23,6 +23,7 @@ declare module 'utilities/types' {
interface SettingsTypeMap { interface SettingsTypeMap {
'chat.hype.show-pinned': boolean; 'chat.hype.show-pinned': boolean;
'layout.turbo-cta': 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.toggle('TopNav__TurboButton_Available', val);
}); });
this.settings.getChanges('layout.subtember', val => {
this.toggle('TokenizedCommerceBanner', val);
});
this.ErrorBoundaryComponent.ready((cls, instances) => { this.ErrorBoundaryComponent.ready((cls, instances) => {
this.log.debug('Found Error Boundary component wrapper.'); this.log.debug('Found Error Boundary component wrapper.');

View file

@ -575,7 +575,7 @@ export default class ThemeEngine extends Module {
const selector = dark ? '' : `:not(.settings-menu-button-component--forced-dark-theme)`; const selector = dark ? '' : `:not(.settings-menu-button-component--forced-dark-theme)`;
if ( bits.length ) 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 else
this.css_tweaks.delete('colors'); this.css_tweaks.delete('colors');
} }

View file

@ -150,7 +150,12 @@ export default class Subpump extends Module<'site.subpump', SubpumpEvents> {
if ( instance ) { if ( instance ) {
this.instance = instance; this.instance = instance;
this.hookClient(instance); try {
this.hookClient(instance);
} catch(err) {
this.instance = null;
this.log.error('Error hooking PubSub client.', err);
}
} }
/* /*

View file

@ -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);
});
}

View file

@ -60,7 +60,9 @@ export type ModuleEvents = {
':loaded': [module: GenericModule], ':loaded': [module: GenericModule],
':unloaded': [module: GenericModule], ':unloaded': [module: GenericModule],
':enabled': [module: GenericModule], ':enabled': [module: GenericModule],
':disabled': [module: GenericModule] ':disabled': [module: GenericModule],
'ext:message': [msg: any],
'ext:post-message': [msg: any]
}; };
export type GenericModule = Module<any, any, any, any>; export type GenericModule = Module<any, any, any, any>;