// Copyright (C) 2022-2025 IITC-CE - GPL-3.0 with Store Exception - see LICENSE and COPYING.STORE
import { Worker } from './worker.js';
import * as migrations from './migrations.js';
import { getUID, isSet, sanitizeFileName } from './helpers.js';
import * as backup from './backup.js';
/**
* @classdesc This class contains methods for managing IITC and plugins.
*/
export class Manager extends Worker {
/**
* Changes the update channel and calls for an update.
*
* @async
* @param {"release" | "beta" | "custom"} channel - Update channel for IITC and plugins.
* @return {Promise<void>}
*/
async setChannel(channel) {
// Get active plugins from current channel and notify about removal
const oldEnabledPlugins = await this.getEnabledPlugins();
await this._sendPluginsEvent(channel, Object.keys(oldEnabledPlugins), 'remove');
// Change channel in storage and object
this.channel = channel;
await this._save(channel, { channel: channel, last_check_update: null });
// Ensure minimal data structures exist for new channel
const newChannelData = await this.storage.get([`${channel}_plugins_flat`, `${channel}_plugins_local`, `${channel}_plugins_user`]);
// Initialize missing structures if needed
const updates = {};
if (!newChannelData[`${channel}_plugins_flat`]) updates[`${channel}_plugins_flat`] = {};
if (!newChannelData[`${channel}_plugins_local`]) updates[`${channel}_plugins_local`] = {};
if (!newChannelData[`${channel}_plugins_user`]) updates[`${channel}_plugins_user`] = {};
// Save initializations if needed
if (Object.keys(updates).length > 0) {
await this.storage.set(updates);
}
// Get active plugins from new channel and notify about addition
const newEnabledPlugins = await this.getEnabledPlugins();
await this._sendPluginsEvent(channel, Object.keys(newEnabledPlugins), 'add');
await this.checkUpdates(true);
}
/**
* Changes the update check interval. If the interval for the current channel changes, a forced update check is started to apply the new interval.
*
* @async
* @param {number} interval - Update check interval in seconds.
* @param {"release" | "beta" | "custom" | undefined} [channel=undefined] - Update channel for IITC and plugins.
* If not specified, the current channel is used.
* @return {Promise<void>}
*/
async setUpdateCheckInterval(interval, channel) {
if (typeof channel === 'undefined') channel = this.channel;
const data = {};
data[channel + '_update_check_interval'] = interval;
await this.storage.set(data);
if (channel === this.channel) await this.checkUpdates(true);
}
/**
* Changes the URL of the repository with IITC and plugins for the custom channel.
*
* @async
* @param {string} url - URL of the repository.
* @return {Promise<void>}
*/
async setCustomChannelUrl(url) {
const network_host = await this.storage.get(['network_host']).then((data) => data.network_host);
network_host.custom = url;
await this.storage.set({ network_host: network_host });
this.network_host = network_host;
}
/**
* Running the IITC and plugins manager.
* Migrates data storage as needed, then loads or updates UserScripts from the repositories.
*
* @async
* @return {Promise<void>}
*/
async run() {
if (!this.is_initialized) {
await new Promise((resolve) => setTimeout(resolve, 1));
return await this.run();
}
const is_migrated = await migrations.migrate(this.storage);
await this.checkUpdates(is_migrated);
}
/**
* Returns an object of all enabled plugins, including IITC core, with plugin UID as the key and plugin data as the value.
*
* @async
* @returns {Promise<Object>} A promise that resolves to an object containing enabled plugins and IITC core data.
*/
async getEnabledPlugins() {
const channel = this.channel;
const storage = await this.storage.get([
`${channel}_iitc_core`,
`${channel}_iitc_core_user`,
`${channel}_plugins_flat`,
`${channel}_plugins_local`,
`${channel}_plugins_user`,
]);
const plugins_flat = storage[`${channel}_plugins_flat`] || {};
const plugins_local = storage[`${channel}_plugins_local`] || {};
const plugins_user = storage[`${channel}_plugins_user`] || {};
const enabled_plugins = {};
let iitc_script = await this.getIITCCore(storage);
if (iitc_script !== null) {
enabled_plugins[this.iitc_main_script_uid] = iitc_script;
for (const uid in plugins_flat) {
if (plugins_flat[uid]['status'] === 'on') {
// If the plugin is marked as 'user', use its 'user' version; otherwise, use its 'local' version
enabled_plugins[uid] = plugins_flat[uid]['user'] === true ? plugins_user[uid] || {} : plugins_local[uid] || {};
}
}
}
return enabled_plugins;
}
/**
* Invokes the injection of IITC core script and plugins to the page.
* IITC core is injected first to ensure it initializes before any plugins. This is crucial because
* the initialization of IITC takes some time, and during this time, plugins can be added to `window.bootPlugins`
* without being started immediately. Injecting IITC first also prevents plugins from throwing errors
* when attempting to access IITC, leaflet, or other dependencies during their initialization.
*
* @async
* @returns {Promise<void>}
*/
async inject() {
const plugins = await this.getEnabledPlugins();
// Ensure IITC core is injected first
if (plugins[this.iitc_main_script_uid]) {
this.inject_user_script(plugins[this.iitc_main_script_uid].code);
this.inject_plugin(plugins[this.iitc_main_script_uid]);
delete plugins[this.iitc_main_script_uid]; // Remove IITC core from the list to avoid re-injecting
}
// Now inject the rest of the plugins
for (const uid in plugins) {
const plugin = plugins[uid];
if (plugin && plugin.code) {
this.inject_user_script(plugin.code);
this.inject_plugin(plugin);
}
}
}
/**
* Runs periodic checks and installs updates for IITC, internal and external plugins.
*
* @async
* @param {boolean} [force=false] - Forced to run the update right now.
* @return {Promise<void>}
*/
async checkUpdates(force) {
await Promise.all([this._checkInternalUpdates(force), this._checkExternalUpdates(force)]);
}
/**
* Controls the plugin. Allows you to enable, disable and remove the plugin.
*
* @async
* @param {string} uid - Unique identifier of the plugin.
* @param {"on" | "off" | "delete"} action - Type of action with the plugin.
* @return {Promise<void>}
*/
async managePlugin(uid, action) {
const channel = this.channel;
let local = await this.storage.get([`${channel}_plugins_flat`, `${channel}_plugins_local`, `${channel}_plugins_user`]);
let plugins_flat = local[`${channel}_plugins_flat`];
let plugins_local = local[`${channel}_plugins_local`];
let plugins_user = local[`${channel}_plugins_user`];
if (!isSet(plugins_local)) plugins_local = {};
if (!isSet(plugins_user)) plugins_user = {};
const currentTime = Math.floor(Date.now() / 1000);
if (action === 'on') {
const isUserPlugin = plugins_flat[uid]['user'];
if ((isUserPlugin === false && plugins_local[uid] !== undefined) || isUserPlugin === true) {
plugins_flat[uid]['status'] = 'on';
plugins_flat[uid]['statusChangedAt'] = currentTime;
if (isUserPlugin) {
plugins_user[uid]['status'] = 'on';
plugins_user[uid]['statusChangedAt'] = currentTime;
} else {
plugins_local[uid]['status'] = 'on';
plugins_local[uid]['statusChangedAt'] = currentTime;
}
this.inject_user_script(isUserPlugin === true ? plugins_user[uid]['code'] : plugins_local[uid]['code']);
this.inject_plugin(isUserPlugin === true ? plugins_user[uid] : plugins_local[uid]);
await this._save(channel, {
plugins_flat: plugins_flat,
plugins_local: plugins_local,
plugins_user: plugins_user,
});
await this._sendPluginsEvent(channel, [uid], 'add');
} else {
let filename = plugins_flat[uid]['filename'];
let response = await this._getUrl(`${this.network_host[channel]}/plugins/${filename}`);
if (response) {
plugins_flat[uid]['status'] = 'on';
plugins_flat[uid]['statusChangedAt'] = currentTime;
plugins_flat[uid]['code'] = response;
plugins_local[uid] = { ...plugins_flat[uid] };
this.inject_user_script(plugins_local[uid]['code']);
this.inject_plugin(plugins_local[uid]);
await this._save(channel, {
plugins_flat: plugins_flat,
plugins_local: plugins_local,
});
await this._sendPluginsEvent(channel, [uid], 'add');
}
}
}
if (action === 'off') {
plugins_flat[uid]['status'] = 'off';
plugins_flat[uid]['statusChangedAt'] = currentTime;
if (plugins_flat[uid]['user']) {
plugins_user[uid]['status'] = 'off';
plugins_user[uid]['statusChangedAt'] = currentTime;
} else {
plugins_local[uid]['status'] = 'off';
plugins_local[uid]['statusChangedAt'] = currentTime;
}
await this._save(channel, {
plugins_flat: plugins_flat,
plugins_local: plugins_local,
plugins_user: plugins_user,
});
await this._sendPluginsEvent(channel, [uid], 'remove');
}
if (action === 'delete') {
if (uid === this.iitc_main_script_uid) {
await this._save(channel, {
iitc_core_user: {},
});
await this._sendPluginsEvent(channel, [uid], 'update');
} else {
const isEnabled = plugins_flat[uid]['status'] === 'on';
if (plugins_flat[uid]['override']) {
if (plugins_local[uid] !== undefined) {
plugins_flat[uid] = { ...plugins_local[uid] };
}
plugins_flat[uid]['user'] = false;
plugins_flat[uid]['override'] = false;
plugins_flat[uid]['status'] = 'off';
delete plugins_flat[uid]['addedAt'];
} else {
delete plugins_flat[uid];
}
delete plugins_user[uid];
await this._save(channel, {
plugins_flat: plugins_flat,
plugins_local: plugins_local,
plugins_user: plugins_user,
});
if (isEnabled) {
await this._sendPluginsEvent(channel, [uid], 'remove');
}
}
}
}
/**
* Allows adding third-party UserScript plugins to IITC.
* Returns the dictionary of installed or updated plugins.
*
* @async
* @param {Object[]} scripts - Array of UserScripts.
* @param {plugin} scripts[].meta - Parsed "meta" object of UserScript.
* @param {string} scripts[].code - UserScript code.
* @return {Promise<Object.<string, plugin>>}
*/
async addUserScripts(scripts) {
const channel = this.channel;
let local = await this.storage.get([
`${channel}_iitc_core_user`,
`${channel}_categories`,
`${channel}_plugins_flat`,
`${channel}_plugins_local`,
`${channel}_plugins_user`,
]);
let iitc_core_user = local[`${channel}_iitc_core_user`];
let categories = local[`${channel}_categories`];
let plugins_flat = local[`${channel}_plugins_flat`];
let plugins_local = local[`${channel}_plugins_local`];
let plugins_user = local[`${channel}_plugins_user`];
if (!isSet(categories)) categories = {};
if (!isSet(plugins_flat)) plugins_flat = {};
if (!isSet(plugins_local)) plugins_local = {};
if (!isSet(plugins_user)) plugins_user = {};
const added_uids = [];
const updated_uids = [];
const installed_scripts = {};
const currentTime = Math.floor(Date.now() / 1000);
scripts.forEach((script) => {
let meta = script['meta'];
const code = script['code'];
const plugin_uid = getUID(meta);
if (plugin_uid === null) throw new Error('The plugin has an incorrect ==UserScript== header');
if (plugin_uid === this.iitc_main_script_uid) {
iitc_core_user = Object.assign(meta, {
uid: plugin_uid,
code: code,
});
updated_uids.push(plugin_uid);
installed_scripts[plugin_uid] = iitc_core_user;
} else {
const is_user_plugins = plugins_user[plugin_uid] !== undefined;
plugins_user[plugin_uid] = Object.assign(meta, {
uid: plugin_uid,
status: 'on',
filename: meta['filename'] ? meta['filename'] : sanitizeFileName(`${meta['name']}.user.js`),
code: code,
addedAt: currentTime,
statusChangedAt: currentTime,
});
if (plugin_uid in plugins_flat && !is_user_plugins) {
if (plugin_uid in plugins_local && plugins_flat[plugin_uid]['status'] !== 'off') {
plugins_local[plugin_uid]['status'] = 'off';
}
plugins_flat[plugin_uid]['status'] = 'on';
plugins_flat[plugin_uid]['code'] = code;
plugins_flat[plugin_uid]['override'] = true;
plugins_flat[plugin_uid]['addedAt'] = currentTime;
plugins_flat[plugin_uid]['statusChangedAt'] = currentTime;
updated_uids.push(plugin_uid);
} else {
let category = plugins_user[plugin_uid]['category'];
if (category === undefined) {
category = 'Misc';
plugins_user[plugin_uid]['category'] = category;
}
if (!(category in categories)) {
categories[category] = {
name: category,
description: '',
};
}
plugins_flat[plugin_uid] = { ...plugins_user[plugin_uid] };
added_uids.push(plugin_uid);
}
plugins_flat[plugin_uid]['user'] = true;
installed_scripts[plugin_uid] = plugins_flat[plugin_uid];
}
});
await this._save(channel, {
iitc_core_user: iitc_core_user,
categories: categories,
plugins_flat: plugins_flat,
plugins_local: plugins_local,
plugins_user: plugins_user,
});
if (added_uids.length) await this._sendPluginsEvent(channel, added_uids, 'add');
if (updated_uids.length) await this._sendPluginsEvent(channel, updated_uids, 'update');
return installed_scripts;
}
/**
* Returns information about requested plugin by UID.
*
* @async
* @param {string} uid - Plugin UID.
* @return {Promise<plugin|null>}
*/
async getPluginInfo(uid) {
let all_plugins = await this.storage.get([this.channel + '_plugins_flat']).then((data) => data[this.channel + '_plugins_flat']);
if (all_plugins === undefined) return null;
return all_plugins[uid];
}
/**
* Returns IITC core script.
*
* @async
* @param {Object|undefined} [storage=undefined] - Storage object with keys `channel_iitc_core` and `channel_iitc_core_user`.
* If not specified, the data is queried from the storage.
* @param {"release" | "beta" | "custom" | undefined} [channel=undefined] - Current channel.
* If not specified, the current channel is used.
* @return {Promise<plugin|null>}
*/
async getIITCCore(storage, channel) {
if (typeof channel === 'undefined') channel = this.channel;
if (storage === undefined || !isSet(storage[`${channel}_iitc_core`])) {
storage = await this.storage.get([`${channel}_iitc_core`, `${channel}_iitc_core_user`]);
}
const iitc_core = storage[`${channel}_iitc_core`];
const iitc_core_user = storage[`${channel}_iitc_core_user`];
let iitc_script = null;
if (isSet(iitc_core_user) && isSet(iitc_core_user['code'])) {
iitc_script = iitc_core_user;
iitc_script['override'] = true;
} else if (isSet(iitc_core) && isSet(iitc_core['code'])) {
iitc_script = iitc_core;
}
return iitc_script;
}
/**
* Asynchronously retrieves backup data based on the specified parameters.
*
* @async
* @param {BackupParams} params - The parameters for the backup data retrieval.
* @return {Promise<object>} A promise that resolves to the backup data.
*/
async getBackupData(params) {
// Process the input parameters using the 'paramsProcessing' function from the 'backup' module.
params = backup.paramsProcessing(params);
// Initialize the backup_data object with its properties.
const backup_data = {
external_plugins: {},
data: {
iitc_settings: {},
plugins_data: {},
app: 'IITC Button',
},
};
// Retrieve all_storage using the 'get' method of 'storage' module.
const all_storage = await this.storage.get(null);
if (params.settings) backup_data.data.iitc_settings = backup.exportIitcSettings(all_storage);
if (params.data) backup_data.data.plugins_data = backup.exportPluginsSettings(all_storage);
if (params.external) backup_data.external_plugins = backup.exportExternalPlugins(all_storage);
// Return the backup_data object.
return backup_data;
}
/**
* Asynchronously sets backup data based on the specified parameters.
*
* This function takes the provided parameters and backup data object and sets the data
* accordingly. The input parameters are processed using the 'paramsProcessing' function
* from the 'backup' module. Depending on the parameters, the function imports IITC settings,
* plugin data, and external plugins into the 'this' object using appropriate functions from
* the 'backup' module.
*
* @async
* @param {BackupParams} params - The parameters for setting the backup data.
* @param {object} backup_data - The backup data object containing the data to be set.
* @return {Promise<void>} A promise that resolves when the backup data is set.
*/
async setBackupData(params, backup_data) {
// Process the input parameters using the 'paramsProcessing' function from the 'backup' module.
params = backup.paramsProcessing(params);
if (params.settings) await backup.importIitcSettings(this, backup_data.data.iitc_settings);
if (params.data) await backup.importPluginsSettings(this, backup_data.data.plugins_data);
if (params.external) await backup.importExternalPlugins(this, backup_data.external_plugins);
}
}