ultrawidify/src/ext/lib/settings/Settings.ts

502 lines
17 KiB
TypeScript
Raw Normal View History

import Debug from '../../conf/Debug';
import ExtensionConf from '../../conf/ExtensionConf';
import ObjectCopy from '../ObjectCopy';
import StretchType from '../../../common/enums/StretchType.enum';
import ExtensionConfPatch from '../../conf/ExtConfPatches';
import SettingsInterface from '../../../common/interfaces/SettingsInterface';
import AspectRatioType from '../../../common/enums/AspectRatioType.enum';
import { GetSiteSettingsOptions, SiteSettings } from './SiteSettings';
2025-12-11 02:43:39 +01:00
import { SettingsSnapshot, SettingsSnapshotManager } from './SettingsSnapshotManager';
import { ComponentLogger } from '../logging/ComponentLogger';
import { LogAggregator } from '../logging/LogAggregator';
2025-12-11 02:43:39 +01:00
import { getDiff } from 'json-difference'
2020-04-13 15:20:29 +02:00
if(process.env.CHANNEL !== 'stable'){
2020-12-03 01:05:39 +01:00
console.info("Loading Settings");
2020-04-13 15:20:29 +02:00
}
2025-05-01 00:55:22 +02:00
interface SettingsOptions {
logAggregator: LogAggregator,
onSettingsChanged?: () => void,
afterSettingsSaved?: () => void,
activeSettings?: SettingsInterface,
}
2025-04-22 02:42:33 +02:00
interface SetSettingsOptions {
forcePreserveVersion?: boolean,
}
2025-05-01 00:55:22 +02:00
const SETTINGS_LOGGER_STYLES = {
log: 'color: #81d288',
}
class Settings {
//#region flags
useSync: boolean = false;
version: string;
//#endregion
//#region helper classes
2025-05-01 00:55:22 +02:00
logAggregator: LogAggregator;
logger: ComponentLogger;
//#endregion
//#region data
default: SettingsInterface; // default settings
active: SettingsInterface; // currently active settings
//#endregion
//#region callbacks
onSettingsChanged: any;
afterSettingsSaved: any;
onChangedCallbacks: (() => void)[] = [];
afterSettingsChangedCallbacks: (() => void)[] = [];
2025-04-20 16:11:17 +02:00
2025-04-22 02:42:33 +02:00
public snapshotManager: SettingsSnapshotManager;
//#endregion
2025-05-01 00:55:22 +02:00
constructor(options: SettingsOptions) {
2019-09-03 00:28:35 +02:00
// Options: activeSettings, updateCallback, logger
this.logger = options.logAggregator && new ComponentLogger(options.logAggregator, 'Settings', {styles: SETTINGS_LOGGER_STYLES}) || undefined;
2025-05-01 00:55:22 +02:00
this.onSettingsChanged = options.onSettingsChanged;
this.afterSettingsSaved = options.afterSettingsSaved;
this.active = options.activeSettings ?? undefined;
this.default = ExtensionConf;
2025-04-22 02:42:33 +02:00
this.snapshotManager = new SettingsSnapshotManager();
2019-08-25 01:52:04 +02:00
this.default['version'] = this.getExtensionVersion();
chrome.storage.onChanged.addListener((changes, area) => {this.storageChangeListener(changes, area)});
2019-10-24 00:44:27 +02:00
}
updateOptions(options: SettingsOptions) {
if (options.logAggregator) {
this.logger = options.logAggregator && new ComponentLogger(options.logAggregator, 'Settings', {styles: SETTINGS_LOGGER_STYLES})
}
if (options.onSettingsChanged) {
this.onSettingsChanged = options.onSettingsChanged;
}
if (options.afterSettingsSaved) {
this.afterSettingsSaved = options.afterSettingsSaved;
}
if (options.activeSettings) {
this.active = options.activeSettings;
}
}
private storageChangeListener(changes, area) {
if (!changes.uwSettings) {
return;
}
2025-12-15 19:42:07 +01:00
console.log('new settings change.')
2025-05-01 00:55:22 +02:00
this.logger?.info('storageOnChange', "Settings have been changed outside of here. Updating active settings. Changes:", changes, "storage area:", area);
// if (changes['uwSettings'] && changes['uwSettings'].newValue) {
// this.logger?.log('info', 'settings',"[Settings::<storage/on change>] new settings object:", JSON.parse(changes.uwSettings.newValue));
// }
const parsedSettings = JSON.parse(changes.uwSettings.newValue);
2025-12-11 02:43:39 +01:00
const diff = getDiff(this.active, parsedSettings, {isLodashLike: true});
const validChangeFn = (x: any) =>
x[0] !== 'lastModified';
const validAdditions = diff.added.filter(validChangeFn);
const validEdits = diff.edited.filter(validChangeFn);
const validRemovals = diff.removed.filter(validChangeFn).filter(x => x[0] !== 'version');
this.logger?.info('storageOnChange', "Diff between current and changed settings:", diff, 'valid changes:', {validAdditions, validEdits, validRemovals});
if (
validAdditions.length === 0
&& validRemovals.length === 0
&& validEdits.length === 0
) {
this.logger?.warn('storageOnChange', "storageChangeListener fired, but no changes were detected. Sus.\nchanges:", changes, "storage area:", area, 'current:', this.active, 'diff:', diff);
return;
}
this.setActive(parsedSettings);
2025-05-01 00:55:22 +02:00
this.logger?.info('storageOnChange', 'Does parsedSettings.preventReload exist?', parsedSettings.preventReload, "Does callback exist?", !!this.onSettingsChanged);
2019-10-24 00:44:27 +02:00
if (!parsedSettings.preventReload) {
2025-12-11 02:43:39 +01:00
this.active.preventReload = true;
2019-10-24 00:44:27 +02:00
try {
for (const fn of this.onChangedCallbacks) {
try {
fn();
} catch (e) {
2025-12-15 19:42:07 +01:00
this.logger?.warn('storageOnChange', "afterSettingsChanged fallback failed. It's possible that a vue component got destroyed, and this function is nothing more than vestigial remains. It would be nice if we implemented something that allows us to remove callback functions from array, and remove vue callbacks from the callback array when their respective UI component gets destroyed. Or this could be an error with the function itself. IDK, here's the error.", e)
}
}
if (this.onSettingsChanged) {
this.onSettingsChanged();
}
2025-05-01 00:55:22 +02:00
this.logger?.info('storageOnChange', 'Update callback finished.')
2019-10-24 00:44:27 +02:00
} catch (e) {
2025-05-01 00:55:22 +02:00
this.logger?.error('storageOnChange', "CALLING UPDATE CALLBACK FAILED. Reason:", e)
2019-10-24 00:44:27 +02:00
}
}
for (const fn of this.afterSettingsChangedCallbacks) {
try {
fn();
} catch (e) {
2025-12-15 19:42:07 +01:00
this.logger?.warn('storageOnChange', "afterSettingsChanged fallback failed. It's possible that a vue component got destroyed, and this function is nothing more than vestigial remains. It would be nice if we implemented something that allows us to remove callback functions from array, and remove vue callbacks from the callback array when their respective UI component gets destroyed. Or this could be an error with the function itself. IDK, here's the error.", e)
}
}
2020-12-21 23:27:45 +01:00
if (this.afterSettingsSaved) {
this.afterSettingsSaved();
}
}
static getExtensionVersion(): string {
return chrome.runtime.getManifest().version;
}
getExtensionVersion(): string {
return Settings.getExtensionVersion();
2019-08-25 01:52:04 +02:00
}
private compareExtensionVersions(a, b) {
let aa = a.split('.');
let bb = b.split('.');
2019-09-03 00:28:35 +02:00
if (+aa[0] !== +bb[0]) {
// difference on first digit
return +aa[0] - +bb[0];
2019-09-03 00:28:35 +02:00
} if (+aa[1] !== +bb[1]) {
// first digit same, difference on second digit
return +aa[1] - +bb[1];
2019-09-03 00:28:35 +02:00
} if (+aa[2] !== +bb[2]) {
return +aa[2] - +bb[2];
2019-09-03 00:28:35 +02:00
// first two digits the same, let's check the third digit
} else {
// fourth digit is optional. When not specified, 0 is implied
// btw, ++(aa[3] || 0) - ++(bb[3] || 0) doesn't work
// Since some things are easier if we actually have a value for
// the fourth digit, we turn a possible undefined into a zero
aa[3] = aa[3] === undefined ? 0 : aa[3];
bb[3] = bb[3] === undefined ? 0 : bb[3];
// also, the fourth digit can start with a letter.
// versions that start with a letter are ranked lower than
// versions x.x.x.0
if (
(isNaN(+aa[3]) && !isNaN(+bb[3]))
|| (!isNaN(+aa[3]) && isNaN(+bb[3]))
) {
return isNaN(+aa[3]) ? -1 : 1;
}
// at this point, either both version numbers are a NaN or
// both versions are a number.
if (!isNaN(+aa[3])) {
return +aa[3] - +bb[3];
}
// letters have their own hierarchy:
// dev < a < b < rc
let av = this.getPrereleaseVersionHierarchy(aa[3]);
let bv = this.getPrereleaseVersionHierarchy(bb[3]);
if (av !== bv) {
return av - bv;
} else {
return +(aa[3].replace(/\D/g,'')) - +(bb[3].replace(/\D/g, ''));
}
}
}
private getPrereleaseVersionHierarchy(version) {
if (version.startsWith('dev')) {
return 0;
2019-09-03 00:28:35 +02:00
}
if (version.startsWith('a')) {
return 1;
}
if (version.startsWith('b')) {
return 2;
}
return 3;
2019-09-03 00:28:35 +02:00
}
private sortConfPatches(patchesIn) {
return patchesIn.sort( (a, b) => this.compareExtensionVersions(a.forVersion, b.forVersion));
2019-09-03 00:28:35 +02:00
}
2025-04-20 16:11:17 +02:00
private findFirstNecessaryPatch(version) {
2025-04-26 01:09:49 +02:00
return ExtensionConfPatch.findIndex(x => this.compareExtensionVersions(x.forVersion, version) > 0);
2019-09-03 00:28:35 +02:00
}
2025-12-11 02:43:39 +01:00
private applySettingsPatches(currentSettings, oldVersion, options?: {skipSnapshot?: boolean}) {
2025-04-20 16:11:17 +02:00
let index = this.findFirstNecessaryPatch(oldVersion);
2019-09-03 00:28:35 +02:00
if (index === -1) {
2025-05-01 00:55:22 +02:00
this.logger?.info('applySettingsPatches','There are no pending conf patches.');
2019-09-03 00:28:35 +02:00
return;
}
2025-04-22 02:42:33 +02:00
// save current settings object
2025-12-11 02:43:39 +01:00
if (!options?.skipSnapshot) {
this.snapshotManager.createSnapshot(
JSON.parse(JSON.stringify(currentSettings)),
{
label: 'Pre-migration snapshot',
isAutomatic: true
}
);
}
2025-04-22 02:42:33 +02:00
2019-09-03 00:28:35 +02:00
// apply all remaining patches
2025-12-11 02:43:39 +01:00
const pendingPatchesCount = ExtensionConfPatch.length - index;
this.logger?.info('applySettingsPatches', `There are ${pendingPatchesCount} settings patches to apply`);
const defaultSettings = this.getDefaultSettings();
2025-04-26 01:09:49 +02:00
while (index < ExtensionConfPatch.length) {
const updateFn = ExtensionConfPatch[index].updateFn;
2025-12-11 02:43:39 +01:00
this.logger.info('applySettingsPatches', `Processing patch ${index} / ${ExtensionConfPatch.length}. Patch ${!!updateFn ? 'has' : 'does NOT have'} an update function.`);
if (updateFn) {
try {
2025-12-11 02:43:39 +01:00
this.logger.log('applySettingsPatches', `Starting to update patch for version ${ExtensionConfPatch[index].forVersion} (${pendingPatchesCount - (ExtensionConfPatch.length - index)} / ${pendingPatchesCount}) | index: ${index}, patches length: ${ExtensionConfPatch.length}`);
updateFn(currentSettings, defaultSettings, this.logger);
this.logger.log('applySettingsPatches', `Patch for version ${ExtensionConfPatch[index].forVersion} applied. (${pendingPatchesCount - (ExtensionConfPatch.length - index)} / ${pendingPatchesCount})`);
} catch (e) {
2025-05-01 00:55:22 +02:00
this.logger?.error('applySettingsPatches', 'Failed to execute update function. Keeping settings object as-is. Error:', e);
2019-11-02 01:05:36 +01:00
}
2025-01-01 22:15:22 +01:00
}
index++;
}
2025-12-11 02:43:39 +01:00
return currentSettings;
2019-09-03 00:28:35 +02:00
}
2025-12-11 02:43:39 +01:00
async init(options?: {dryRun?: boolean, snapshot?: SettingsSnapshot, skipSnapshot?: boolean}) {
let settings;
2025-04-22 02:42:33 +02:00
2025-12-11 02:43:39 +01:00
if (options?.snapshot) {
this.logger?.info('init', 'Snapshot was provided — will load settings from snapshot.', options.snapshot);
settings = options.snapshot.settings;
} else if (settings?.dev?.loadFromSnapshot) {
2025-05-01 00:55:22 +02:00
this.logger?.info('init', 'Dev mode is enabled, Loading settings from snapshot:', settings.dev.loadFromSnapshot);
2025-04-22 02:42:33 +02:00
const snapshot = await this.snapshotManager.getSnapshot();
if (snapshot) {
settings = snapshot.settings;
}
2025-12-11 02:43:39 +01:00
} else {
settings = await this.get();
2025-04-22 02:42:33 +02:00
}
2025-12-11 02:43:39 +01:00
const currentVersion = this.getExtensionVersion();
// |—> on first setup, settings is undefined & settings.version is haram
// | since new installs ship with updates by default, no patching is
// | needed. In this case, we assume we're on the current version
2025-12-11 02:43:39 +01:00
const oldVersion = settings?.version ?? currentVersion;
2019-10-27 00:10:49 +02:00
if (settings) {
2025-05-01 00:55:22 +02:00
this.logger?.info('init', "Configuration fetched from storage:", settings,
"\nlast saved with:", settings.version,
2025-12-11 02:43:39 +01:00
"\ncurrent version:", currentVersion
);
}
// if there's no settings saved, return default settings.
2018-08-07 23:31:28 +02:00
if(! settings || (Object.keys(settings).length === 0 && settings.constructor === Object)) {
2025-05-01 00:55:22 +02:00
this.logger?.info(
'init',
'settings don\'t exist. Using defaults.\n#keys:',
settings ? Object.keys(settings).length : 0,
'\nsettings:',
2019-09-25 07:10:36 +02:00
settings
);
2025-04-26 01:09:49 +02:00
2025-12-11 02:43:39 +01:00
settings = this.getDefaultSettings();
settings.version = currentVersion;
if (!options?.dryRun) {
this.active = settings;
this.version = currentVersion;
await this.save();
}
return settings;
}
// if there's settings, set saved object as active settings
// if version number is undefined, we make it defined
// this should only happen on first extension initialization
2025-12-11 02:43:39 +01:00
if (!settings.version) {
settings.version = currentVersion;
if (!options?.dryRun) {
this.active = settings;
this.version = currentVersion;
await this.save();
}
return settings;
}
2019-08-25 01:52:04 +02:00
// check if extension has been updated. If not, return settings as they were retrieved
2025-12-11 02:43:39 +01:00
if (settings.version === currentVersion) {
2025-05-01 00:55:22 +02:00
this.logger?.info('init', "extension was saved with current version of ultrawidify. Returning object as-is.");
2025-12-11 02:43:39 +01:00
if (!options?.dryRun) {
this.active = settings;
}
return settings;
}
2019-07-05 23:45:29 +02:00
// in case settings in previous version contained a fucky wucky, we overwrite existing settings with a patch
2025-12-11 02:43:39 +01:00
settings = this.applySettingsPatches(settings, oldVersion, {skipSnapshot: options?.dryRun || options?.skipSnapshot});
2019-07-05 23:45:29 +02:00
2019-09-03 00:28:35 +02:00
// set 'whatsNewChecked' flag to false when updating, always
2025-12-11 02:43:39 +01:00
settings.whatsNewChecked = false;
2019-08-25 01:52:04 +02:00
// update settings version to current
2025-12-11 02:43:39 +01:00
settings.version = currentVersion;
2025-12-11 02:43:39 +01:00
if (!options?.dryRun) {
this.active = settings;
this.version = currentVersion;
await this.save();
}
return settings;
}
2025-04-22 02:42:33 +02:00
async get(): Promise<SettingsInterface | undefined> {
2019-05-10 19:21:17 +02:00
let ret;
ret = await chrome.storage.local.get('uwSettings');
2019-05-10 19:21:17 +02:00
2025-05-01 00:55:22 +02:00
this.logger?.info('get', 'Got settings:', ret && ret.uwSettings && JSON.parse(ret.uwSettings));
2019-05-10 19:21:17 +02:00
try {
2025-04-22 02:42:33 +02:00
return JSON.parse(ret.uwSettings) as SettingsInterface;
2019-05-10 19:21:17 +02:00
} catch(e) {
return undefined;
}
}
2025-04-22 02:42:33 +02:00
async set(extensionConf, options?: SetSettingsOptions) {
2019-11-02 01:05:36 +01:00
if (!options || !options.forcePreserveVersion) {
extensionConf.version = this.version;
}
2025-05-01 00:55:22 +02:00
this.logger?.info('set', "setting new settings:", extensionConf)
return chrome.storage.local.set( {'uwSettings': JSON.stringify(extensionConf)});
}
2018-08-07 23:31:28 +02:00
async setActive(activeSettings) {
this.active = activeSettings;
}
2025-01-25 21:17:20 +01:00
/**
* Sets value of a prop at given path.
* @param propPath path to property we want to set. If prop path does not exist,
* this function will recursively create it. It is assumed that uninitialized properties
* are objects.
* @param value
*/
async setProp(propPath: string | string[], value: any, options?: {forceReload?: boolean}, currentPath?: any) {
if (!Array.isArray(propPath)) {
propPath = propPath.split('.');
}
if (!currentPath) {
currentPath = this.active;
}
const currentProp = propPath.shift();
if (propPath.length) {
if (!currentPath[currentProp]) {
currentPath[currentProp] = {};
}
2025-01-22 11:26:25 +01:00
return this.setProp(propPath, value, options, currentPath[currentProp]);
2025-01-25 21:17:20 +01:00
} else {
currentPath[currentProp] = value;
if (options?.forceReload) {
2025-01-22 11:26:25 +01:00
return this.save();
2025-01-25 21:17:20 +01:00
} else {
2025-01-22 11:26:25 +01:00
return this.saveWithoutReload();
2025-01-25 21:17:20 +01:00
}
}
2018-08-07 23:31:28 +02:00
}
2025-04-22 02:42:33 +02:00
async save(options?: SetSettingsOptions) {
if (Debug.debug && Debug.storage) {
2025-04-26 01:09:49 +02:00
console.log("[Settings::save] Saving active settings — save options", options, "; settings:", this.active);
}
this.active.preventReload = undefined;
this.active.lastModified = new Date();
2019-11-02 01:05:36 +01:00
await this.set(this.active, options);
2018-08-07 23:31:28 +02:00
}
2019-10-24 00:44:27 +02:00
2025-04-22 02:42:33 +02:00
async saveWithoutReload(options?: SetSettingsOptions) {
this.active.preventReload = true;
this.active.lastModified = new Date();
2025-04-22 02:42:33 +02:00
await this.set(this.active, options);
2019-10-24 00:44:27 +02:00
}
async rollback() {
this.active = await this.get();
}
getDefaultSettings() {
return JSON.parse(JSON.stringify(this.default));
}
/**
* Gets default site configuration. Only returns essential settings.
* @returns
*/
getDefaultSiteConfiguration() {
return {
type: 'user-added',
defaultCrop: {
type: AspectRatioType.Automatic, // AARD is enabled by default
},
defaultStretch: {
type: StretchType.NoStretch, // because we aren't uncultured savages
},
}
}
getSiteSettings(options: GetSiteSettingsOptions = {site: window.location.hostname}): SiteSettings {
return new SiteSettings(this, options);
}
listenOnChange(fn: () => void): void {
this.onChangedCallbacks.push(fn);
}
removeOnChangeListener(fn: () => void): void {
this.onChangedCallbacks = this.afterSettingsChangedCallbacks.filter(x => x !== fn);
}
listenAfterChange(fn: () => void): void {
this.afterSettingsChangedCallbacks.push(fn);
}
removeAfterChangeListener(fn: () => void): void {
this.afterSettingsChangedCallbacks = this.afterSettingsChangedCallbacks.filter(x => x !== fn);
}
2025-12-11 02:43:39 +01:00
async testMigration(snapshot: SettingsSnapshot) {
const afterMigrationSettings = await this.init({dryRun: true, snapshot});
this.logger.log('testMigration', 'Settings migrated. Settings model after initialization:', afterMigrationSettings);
}
}
export default Settings;
2020-12-03 01:05:39 +01:00
if(process.env.CHANNEL !== 'stable'){
console.info("Settings loaded");
}