1
0
Fork 0
mirror of https://github.com/FrankerFaceZ/FrankerFaceZ.git synced 2025-09-16 10:06:54 +00:00
* Fixed: Certain page elements not being correctly hidden when FFZ first loads.
* Fixed: Backup and Restore not allowing you to restore a backup with blobs if the current settings provider does not support blobs. It now prompts you to confirm the restoration.
* Fixed: The settings provider switcher now more accurately displays if providers contain existing data.
* Fixed: Issue where the FFZ Control Center fails to load in a pop-out on Firefox.
* Fixed: FFZ believing it is in developer mode when loaded from an extension in some cases.
* Changed: The setting to hide Stories now hides them in the directory as well.
* Changed: The settings provider switcher now displays a warning if transferring your settings would erase data in the destination provider.
* Changed: The settings provider switcher now displays a tag on providers that are Cross-Origin Compatible.
This commit is contained in:
SirStendec 2025-09-03 14:26:42 -04:00
parent b62e2f7530
commit 3551d5c650
8 changed files with 247 additions and 55 deletions

View file

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

View file

@ -4,7 +4,32 @@
{{ t('setting.backup-restore.about', 'This tool allows you to backup and restore your FrankerFaceZ settings, including all settings from the Control Center along with other data such as favorited emotes and blocked games.') }}
</div>
<div class="tw-flex tw-align-items-center tw-justify-content-center tw-mg-b-1">
<div
v-if="checking_allow_no_blobs"
class="tw-c-background-accent-alt-2 tw-c-text-overlay tw-pd-1 tw-mg-b-1"
>
<h3 class="ffz-i-attention ffz-font-size-3">
{{ t('setting.backup-restore.blob-notice', 'This backup contains binary data not supported by the current storage provider.') }}
</h3>
<div>
{{ t('setting.backup-restore.blob-notice.desc', 'The binary data will be discarded and certain settings, notably ones with custom sound files, may not work correctly. Do you wish to continue? Alternatively, you will need to change your settings provider to one supporting binary data.') }}
</div>
<div class="tw-flex tw-align-items-center tw-justify-content-center tw-mg-b-1">
<button
class="tw-button tw-mg-x-1"
@click="continueBlob"
>
<span class="tw-button__icon tw-button__icon--left">
<figure class="ffz-i-download" />
</span>
<span class="tw-button__text">
{{ t('setting.backup-restore.blob-notice.continue', 'Continue Restoration') }}
</span>
</button>
</div>
</div>
<div v-else-if="! error" class="tw-flex tw-align-items-center tw-justify-content-center tw-mg-b-1">
<button
class="tw-button tw-mg-x-1"
@click="backup"
@ -57,6 +82,8 @@ export default {
return {
error_desc: null,
error: false,
checking_allow_no_blobs: false,
confirmed_no_blobs: false,
message: null
}
},
@ -153,6 +180,14 @@ export default {
});
},
continueBlob() {
this.checking_allow_no_blobs = false;
this.confirmed_no_blobs = true;
const file = this.file;
this.file = null;
return this.restoreZip(file);
},
async restoreZip(file) {
const JSZip = (await import(/* webpackChunkName: "zip" */ 'jszip')).default;
let input, blobs, data;
@ -194,43 +229,54 @@ export default {
const provider = settings.provider;
await provider.awaitReady();
if ( Object.keys(blobs).length && ! provider.supportsBlobs ) {
this.error_desc = this.t('setting.backup-restore.blob-error', 'This backup contains binary data not supported by the current storage provider. Please change your storage provider in Data Management > Storage >> Provider.');
this.error = true;
return;
}
let b = 0;
// Attempt to load all the blobs, to make sure they're all valid.
const loaded_blobs = {};
for(const [safe_key, data] of Object.entries(blobs)) {
let blob;
if ( data.type === 'file' ) {
blob = await input.file(`blobs/${safe_key}`).async('blob'); // eslint-disable-line no-await-in-loop
blob = new File([blob], data.name, {lastModified: data.modified, type: data.mime});
} else if ( data.type === 'blob' )
blob = await input.file(`blobs/${safe_key}`).async('blob'); // eslint-disable-line no-await-in-loop
else if ( data.type === 'ab' )
blob = await input.file(`blobs/${safe_key}`).async('arraybuffer'); // eslint-disable-line no-await-in-loop
else if ( data.type === 'ui8' )
blob = await input.file(`blobs/${safe_key}`).async('uint8array'); // eslint-disable-line no-await-in-loop
else {
this.error_desc = this.t('setting.backup-restore.invalid-blob', 'This file contains a binary blob with an invalid type: {type}', data);
this.error = true;
// Blobs
if ( Object.keys(blobs).length ) {
if ( ! provider.supportsBlobs && ! this.confirmed_no_blobs ) {
this.checking_allow_no_blobs = true;
this.file = file;
return;
}
loaded_blobs[data.key] = blob;
}
this.confirmed_no_blobs = false;
// We've loaded all data, let's get this installed.
// Blobs first.
let b = 0;
await provider.clearBlobs();
if ( provider.supportsBlobs ) {
// Attempt to load all the blobs, to make sure they're all valid.
const loaded_blobs = {};
for(const [key, blob] of Object.entries(loaded_blobs)) {
await provider.setBlob(key, blob); // eslint-disable-line no-await-in-loop
b++;
}
for(const [safe_key, data] of Object.entries(blobs)) {
let blob;
if ( data.type === 'file' ) {
blob = await input.file(`blobs/${safe_key}`).async('blob'); // eslint-disable-line no-await-in-loop
blob = new File([blob], data.name, {lastModified: data.modified, type: data.mime});
} else if ( data.type === 'blob' )
blob = await input.file(`blobs/${safe_key}`).async('blob'); // eslint-disable-line no-await-in-loop
else if ( data.type === 'ab' )
blob = await input.file(`blobs/${safe_key}`).async('arraybuffer'); // eslint-disable-line no-await-in-loop
else if ( data.type === 'ui8' )
blob = await input.file(`blobs/${safe_key}`).async('uint8array'); // eslint-disable-line no-await-in-loop
else {
this.error_desc = this.t('setting.backup-restore.invalid-blob', 'This file contains a binary blob with an invalid type: {type}', data);
this.error = true;
}
loaded_blobs[data.key] = blob;
}
// We've loaded all data, let's get this installed.
// Blobs first.
await provider.clearBlobs();
for(const [key, blob] of Object.entries(loaded_blobs)) {
await provider.setBlob(key, blob); // eslint-disable-line no-await-in-loop
b++;
}
}
} else if ( provider.supportsBlobs )
// Make sure blobs are cleared either way.
await provider.clearBlobs();
// Settings second.
provider.clear();

View file

@ -41,14 +41,29 @@
<span class="tw-strong">
{{ t(val.i18n_key, val.title) }}
</span>
<span v-if="val.key === current" class="tw-mg-l-1 tw-c-text-alt">
<span v-if="val.key === current" class="tw-mg-l-1 tw-c-text-alt tw-relative ffz-il-tooltip__container">
{{ t('setting.provider.selected', '(Current)') }}
<div class="ffz-il-tooltip ffz-il-tooltip--align-center ffz-il-tooltip--up">
{{ t('setting.provider.selected.about', 'This is the currently active settings provider.') }}
</div>
</span>
<span v-if="val.has_data" class="tw-mg-l-1 tw-c-text-alt">
<span v-if="val.has_data" class="tw-mg-l-1 tw-c-text-alt tw-relative ffz-il-tooltip__container">
{{ t('setting.provider.has-data', '(Has Data)') }}
<div class="ffz-il-tooltip ffz-il-tooltip--align-center ffz-il-tooltip--up">
{{ t('setting.provider.has-data.about', 'This provider has data saved in it.') }}
</div>
</span>
<span v-if="val.has_blobs" class="tw-mg-l-1 tw-c-text-alt">
<span v-if="val.has_crossorigin" class="tw-mg-l-1 tw-c-text-alt tw-relative ffz-il-tooltip__container">
{{ t('setting.provider.cross-origin', '(Cross-Origin Compatible)') }}
<div class="ffz-il-tooltip ffz-il-tooltip--align-center ffz-il-tooltip--up">
{{ t('setting.provider.cross-origin.about', 'This means that your settings will work across different domains, subdomains, and embeds.') }}
</div>
</span>
<span v-if="val.has_blobs" class="tw-mg-l-1 tw-c-text-alt tw-relative ffz-il-tooltip__container">
{{ t('setting.provider.has-blobs', '(Supports Binary Data)') }}
<div class="ffz-il-tooltip ffz-il-tooltip--align-center ffz-il-tooltip--up">
{{ t('setting.provider.has-blobs.about', 'This provider supports binary data such as custom sound files.') }}
</div>
</span>
</div>
<section v-if="val.description" class="tw-c-text-alt-2">
@ -69,6 +84,17 @@
</label>
</div>
<section v-if="canTransfer" class="tw-c-text-alt-2 tw-pd-b-05" style="padding-left:2.5rem">
<div
v-if="selected !== current && prov_keys[selected].has_data"
class="ffz--notice tw-c-background-accent tw-c-text-overlay tw-pd-1 tw-mg-y-1"
>
<h3 class="ffz-i-attention ffz-font-size-3">
{{ t('setting.provider.warn.title', 'Be careful!') }}
</h3>
<div>
<markdown :source="t('setting.provider.transfer.warning', 'The provider you selected contains data, and that data will be lost if you transfer the data from your current provider to it. Make sure you have a backup of your settings in case anything goes wrong.')" />
</div>
</div>
<markdown :source="t('setting.provider.transfer.desc', '**Note:** It is recommended to leave this enabled unless you know what you\'re doing.')" />
</section>
<div v-else class="tw-flex tw-align-items-center" style="padding-left:2.5rem">
@ -110,6 +136,7 @@ export default {
const ffz = this.context.getFFZ(),
settings = ffz.resolve('settings'),
providers = [],
prov_keys = {},
transfers = {};
for(const [key, val] of Object.entries(settings.getProviders())) {
@ -117,7 +144,8 @@ export default {
key,
priority: val.priority || 0,
has_data: null,
has_blobs: val.supportsBlobs,
has_blobs: val.canSupportBlobs(settings),
has_crossorigin: val.crossOrigin(settings),
has_trans: val.allowTransfer,
i18n_key: `setting.provider.${key}.title`,
title: val.title || key,
@ -125,10 +153,11 @@ export default {
description: val.description
};
prov_keys[key] = prov;
transfers[key] = val.allowTransfer;
if ( val.supported() )
Promise.resolve(val.hasContent()).then(v => {
if ( val.supported(settings) )
Promise.resolve(val.hasContent(settings)).then(v => {
prov.has_data = v;
});
@ -143,6 +172,7 @@ export default {
backup: false,
not_www: window.location.host !== 'www.twitch.tv',
providers,
prov_keys,
transfers,
current,
selected: current

View file

@ -19,7 +19,7 @@ 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, SettingsProvider } from './providers';
import { AdvancedSettingsProvider, IGNORE_CONTENT_KEYS, LocalStorageProvider, Providers, SettingsProvider } from './providers';
import type { AddonInfo, SettingsTypeMap } from 'utilities/types';
import { FFZEvent } from '../utilities/events';
@ -170,12 +170,17 @@ export default class SettingsManager extends Module<'settings', SettingsEvents>
// 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);
try {
Object.seal(window.ffz_providers);
} catch(err) {
this.log.warn('Unable to seal window.ffz_providers:', err);
}
if ( window.ffz_providers.length > 0 ) {
const evt = {
settings: this,
Provider: SettingsProvider,
AdvancedProvider: AdvancedSettingsProvider,
IGNORE_CONTENT_KEYS: IGNORE_CONTENT_KEYS,
registerProvider: (key: string, provider: typeof SettingsProvider) => {
if ( ! this.providers[key] && provider.supported(this) )
this.providers[key] = provider;
@ -786,8 +791,21 @@ export default class SettingsManager extends Module<'settings', SettingsEvents>
((a[1] as any).priority ?? 0)
);
// Remove unsupported providers.
for(let i = providers.length - 1; i >= 0; i--) {
if ( ! providers[i][1].supported(this) )
providers.splice(i, 1);
}
// If there's a provider that has content, then use it.
for(const [key, provider] of providers) {
if ( provider.supported(this) && await provider.hasContent(this) ) // eslint-disable-line no-await-in-loop
if ( await provider.hasContent(this) ) // eslint-disable-line no-await-in-loop
return key;
}
// Select the first provider that allows itself to be the default.
for(const [key, provider] of providers) {
if ( provider.allowAsDefault(this) )
return key;
}

View file

@ -1,23 +1,30 @@
'use strict';
import { isValidBlob, deserializeBlob, serializeBlob, BlobLike, SerializedBlobLike } from 'utilities/blobs';
import { EXTENSION } from 'utilities/constants';
import {EventEmitter} from 'utilities/events';
import {TicketLock, has, once} from 'utilities/object';
import type { OptionalArray, OptionalPromise, ProviderTypeMap } from '../utilities/types';
// ============================================================================
// Settings Providers
// ============================================================================
import {EventEmitter} from 'utilities/events';
import {TicketLock, has, once} from 'utilities/object';
import type SettingsManager from '.';
import type { OptionalArray, OptionalPromise, ProviderTypeMap } from '../utilities/types';
import { EXTENSION } from '../utilities/constants';
const DB_VERSION = 1,
NOT_WWW_TWITCH = window.location.host !== 'www.twitch.tv',
NOT_WWW_YT = window.location.host !== 'www.youtube.com';
export const IGNORE_CONTENT_KEYS = [
'client-id',
'cfg-seen',
'cfg-collapsed'
];
// ============================================================================
// Types
// ============================================================================
@ -51,6 +58,9 @@ export abstract class SettingsProvider extends EventEmitter<ProviderEvents> {
static title: string;
static description: string;
static crossOrigin(manager: SettingsManager) { return false; }
static canSupportBlobs(manager: SettingsManager) { return false; }
static hasContent: (manager: SettingsManager) => OptionalPromise<boolean>;
@ -74,6 +84,10 @@ export abstract class SettingsProvider extends EventEmitter<ProviderEvents> {
return false;
}
static allowAsDefault(manager: SettingsManager) {
return true;
}
static allowTransfer = true;
static shouldUpdate = true;
@ -129,6 +143,8 @@ export abstract class SettingsProvider extends EventEmitter<ProviderEvents> {
export abstract class AdvancedSettingsProvider extends SettingsProvider {
static canSupportBlobs() { return true; }
get supportsBlobs() { return true; }
isValidBlob(blob: any): blob is BlobLike {
@ -421,7 +437,7 @@ export class LocalStorageProvider extends SettingsProvider {
const prefix = 'FFZ:setting:';
for(const key in localStorage)
if ( key.startsWith(prefix) && has(localStorage, key) )
if ( key.startsWith(prefix) && ! IGNORE_CONTENT_KEYS.includes(key.slice(prefix.length)) && has(localStorage, key) )
return true;
return false;
@ -695,7 +711,11 @@ export class IndexedDBProvider extends AdvancedSettingsProvider {
}
r2.onsuccess = () => {
const success = Array.isArray(r2.result) && r2.result.length > 0;
let success = false;
if ( Array.isArray(r2.result) && r2.result.length > 0 ) {
success = r2.result.filter(key => !IGNORE_CONTENT_KEYS.includes(key as string)).length > 0;
}
db.close();
return resolve(success);
}
@ -1446,6 +1466,7 @@ export class CrossOriginStorageBridge extends RemoteSettingsProvider {
static hasContent() {
return CrossOriginStorageBridge.supported();
}
static allowAsDefault() { return false; }
static priority = 100;
static title = 'Cross-Origin Storage Bridge';

View file

@ -403,9 +403,9 @@ export default class ChatHook extends Module {
ui: {
path: 'Chat > Filtering > Block >> Callout Types @{"description":"This filter allows you to remove callouts of specific types from Twitch chat. Callouts are special messages that can be pinned to the bottom of chat and often have associated actions, like claiming a drop or sharing your resubscription."}',
component: 'blocked-types',
getExtraTerms: () => Object.keys(this.callout_types).filter(key => ! UNBLOCKABLE_CALLOUTS.includes(key)),
getExtraTerms: () => Object.keys(this.callout_types ?? CALLOUT_TYPES).filter(key => ! UNBLOCKABLE_CALLOUTS.includes(key)),
data: () => Object
.keys(this.callout_types)
.keys(this.callout_types ?? CALLOUT_TYPES)
.filter(key => ! UNBLOCKABLE_CALLOUTS.includes(key))
.sort()
}

View file

@ -24,6 +24,7 @@ declare module 'utilities/types' {
'chat.hype.show-pinned': boolean;
'layout.turbo-cta': boolean;
'layout.subtember': boolean;
'layout.side-nav.hide-stories': boolean;
}
}
@ -43,6 +44,13 @@ type ErrorBoundaryNode = ReactStateNode<{
onErrorBoundaryTestEmit: any
}
type SettingToggleNode = ReactStateNode<{
name: string;
children: any;
}> & {
render: AnyFunction;
};
export default class Loadable extends Module {
@ -53,10 +61,12 @@ export default class Loadable extends Module {
// State
overrides: Map<string, boolean>;
setting_overrides: Map<string, boolean>;
// Fine
ErrorBoundaryComponent: FineWrapper<ErrorBoundaryNode>;
LoadableComponent: FineWrapper<LoadableNode>;
SettingsToggleComponent: FineWrapper<SettingToggleNode>;
constructor(name?: string, parent?: GenericModule) {
super(name, parent);
@ -83,8 +93,17 @@ export default class Loadable extends Module {
(n as ErrorBoundaryNode).onErrorBoundaryTestEmit
);
this.overrides = new Map();
this.SettingsToggleComponent = this.fine.define(
'settings-toggle-component',
n =>
(n as SettingToggleNode).props?.name &&
(n as SettingToggleNode).props?.children &&
(n as SettingToggleNode).render &&
(n as SettingToggleNode).render.toString().includes('defaultThreshold')
);
this.overrides = new Map();
this.setting_overrides = new Map();
}
onEnable() {
@ -100,6 +119,10 @@ export default class Loadable extends Module {
this.toggle('TokenizedCommerceBanner', val);
});
this.settings.getChanges('layout.side-nav.hide-stories', val => {
this.toggleSetting('stories_web', !val);
})
this.ErrorBoundaryComponent.ready((cls, instances) => {
this.log.debug('Found Error Boundary component wrapper.');
@ -122,6 +145,7 @@ export default class Loadable extends Module {
}
this.ErrorBoundaryComponent.updateInstances();
this.ErrorBoundaryComponent.forceUpdate();
});
this.LoadableComponent.ready((cls, instances) => {
@ -167,6 +191,32 @@ export default class Loadable extends Module {
}
this.LoadableComponent.updateInstances();
this.LoadableComponent.forceUpdate();
});
this.SettingsToggleComponent.ready((cls, instances) => {
this.log.debug('Found Settings Toggle component wrapper.');
const t = this,
proto = cls.prototype as SettingToggleNode,
old_render = proto.render;
(proto as any)._ffz_wrapped_render = old_render;
proto.render = function() {
try {
const type = this.props.name;
if ( t.setting_overrides.has(type) && ! t.shouldRenderSetting(type) )
return null;
} catch(err) {
/* no-op */
console.error(err);
}
return old_render.call(this);
}
this.SettingsToggleComponent.updateInstances();
this.SettingsToggleComponent.forceUpdate();
});
}
@ -184,6 +234,20 @@ export default class Loadable extends Module {
}
}
toggleSetting(cmp: string, state: boolean | null = null) {
const existing = this.setting_overrides.get(cmp) ?? true;
if ( state == null )
state = ! existing;
else
state = !! state;
if ( state !== existing ) {
this.setting_overrides.set(cmp, state);
this.updateSetting(cmp);
}
}
update(cmp: string) {
for(const inst of this.LoadableComponent.instances) {
const type = inst.props?.component;
@ -200,8 +264,21 @@ export default class Loadable extends Module {
}
}
updateSetting(cmp: string) {
for(const inst of this.SettingsToggleComponent.instances) {
const name = inst.props?.name;
if ( name && name === cmp ) {
inst.forceUpdate();
}
}
}
shouldRender(cmp: string) {
return this.overrides.get(cmp) ?? true;
}
shouldRenderSetting(cmp: string) {
return this.setting_overrides.get(cmp) ?? true;
}
}

View file

@ -2,12 +2,12 @@ declare global {
let __extension__: string | undefined;
}
/** Whether or not FrankerFaceZ was loaded from a development server. */
export const DEBUG = localStorage.ffzDebugMode === 'true' && document.body.classList.contains('ffz-dev');
/** Whether or not FrankerFaceZ was loaded as a packed web extension. */
export const EXTENSION = !!__extension__;
/** Whether or not FrankerFaceZ was loaded from a development server. */
export const DEBUG = localStorage.ffzDebugMode === 'true' && document.body.classList.contains('ffz-dev') && !EXTENSION;
/** The base URL of the FrankerFaceZ CDN. */
export const SERVER = DEBUG ? 'https://localhost:8000' : 'https://cdn2.frankerfacez.com';