Files
desktop/src/zen/mods/ZenMods.mjs
mr. m 0e8b8da845 chore: Convert the codebase into modules, p=#11364
* chore: Convert the codebase into modules, b=no-bug, c=common, compact-mode, folders, fonts, glance, images, kbs, media, mods, split-view, tabs, tests, vendor, welcome, workspaces

* feat: Added update popup, b=no-bug, c=media, common
2025-11-20 18:14:46 +01:00

662 lines
18 KiB
JavaScript

// This Source Code Form is subject to the terms of the Mozilla Public
// License, v. 2.0. If a copy of the MPL was not distributed with this
// file, You can obtain one at http://mozilla.org/MPL/2.0/.
import {
nsZenPreloadedFeature,
nsZenMultiWindowFeature,
} from 'chrome://browser/content/zen-components/ZenCommonUtils.mjs';
class nsZenMods extends nsZenPreloadedFeature {
// private properties start
#kZenStylesheetModHeader = '/* Zen Mods - Generated by ZenMods.';
#kZenStylesheetModHeaderBody = `* DO NOT EDIT THIS FILE DIRECTLY!
* Your changes will be overwritten.
* Instead, go to the preferences and edit the mods there.
*/
`;
#kZenStylesheetModFooter = `
/* End of Zen Mods */
`;
#getCurrentDateTime = () =>
new Intl.DateTimeFormat('en-US', {
dateStyle: 'full',
timeStyle: 'full',
}).format(new Date().getTime());
constructor() {
super();
}
// Stylesheet service
#_modsBackend = null;
get #modsBackend() {
if (!this.#_modsBackend) {
this.#_modsBackend = Cc['@mozilla.org/zen/mods-backend;1'].getService(Ci.nsIZenModsBackend);
}
return this.#_modsBackend;
}
get #styleSheetPath() {
return PathUtils.join(PathUtils.profileDir, 'chrome', 'zen-themes.css');
}
async #handleDisableMods() {
await this.#rebuildModsStylesheet();
}
#getStylesheetPathForMod(mod) {
return PathUtils.join(this.getModFolder(mod.id), 'chrome.css');
}
async #readStylesheet() {
const path = this.modsRootPath;
if (!(await IOUtils.exists(path))) {
return '';
}
return await IOUtils.readUTF8(this.#styleSheetPath);
}
async #insertStylesheet() {
try {
const content = await this.#readStylesheet();
this.#modsBackend.rebuildModsStyles(content);
} catch (e) {
console.warn('[ZenMods]: Error rebuilding mods styles:', e);
}
}
async #rebuildModsStylesheet() {
const mods = await this.#getEnabledMods();
await this.#writeStylesheet(mods);
const modsWithPreferences = await Promise.all(
mods.map(async (mod) => {
const preferences = await this.getModPreferences(mod);
return {
name: mod.name,
enabled: mod.enabled,
preferences,
};
})
);
this.#setDefaults(modsWithPreferences);
this.#writeToDom(modsWithPreferences);
await this.#insertStylesheet();
}
async #getEnabledMods() {
if (Services.prefs.getBoolPref('zen.themes.disable-all', false)) {
console.log('[ZenMods]: Mods are disabled by user preference.');
return [];
}
const modsObject = await this.getMods();
const mods = Object.values(modsObject).filter(
(mod) => mod.enabled === undefined || mod.enabled
);
const modList = mods.map(({ name }) => name).join(', ');
const message =
modList !== ''
? `[ZenMods]: Loading enabled Zen mods: ${modList}.`
: '[ZenMods]: No enabled Zen mods.';
console.log(message);
return mods;
}
#setDefaults(modsWithPreferences) {
for (const { preferences, enabled } of modsWithPreferences) {
if (enabled !== undefined && !enabled) {
continue;
}
for (const { type, property, defaultValue } of preferences) {
if (defaultValue === undefined) {
continue;
}
const getProperty =
type === 'checkbox' ? Services.prefs.getBoolPref : Services.prefs.getStringPref;
const setProperty =
type === 'checkbox' ? Services.prefs.setBoolPref : Services.prefs.setStringPref;
try {
getProperty(property);
} catch {
console.debug(
`[ZenMods]: Setting default value for ${property} to ${defaultValue} (${typeof defaultValue})`
);
if (
typeof defaultValue !== 'boolean' &&
typeof defaultValue !== 'string' &&
typeof defaultValue !== 'number'
) {
console.warn(
`[ZenMods]: Warning, invalid data type received (${typeof defaultValue}), skipping.`
);
continue;
}
setProperty(
property,
typeof defaultValue === 'boolean' ? defaultValue : defaultValue.toString()
);
}
}
}
}
#writeToDom(modsWithPreferences) {
for (const browser of nsZenMultiWindowFeature.browsers) {
for (const { enabled, preferences, name } of modsWithPreferences) {
const sanitizedName = this.sanitizeModName(name);
if (enabled !== undefined && !enabled) {
const element = browser.document.getElementById(sanitizedName);
if (element) {
element.remove();
}
for (const { property } of preferences.filter(({ type }) => type !== 'checkbox')) {
const sanitizedProperty = property?.replaceAll(/\./g, '-');
browser.document.querySelector(':root').style.removeProperty(`--${sanitizedProperty}`);
}
continue;
}
for (const { property, type } of preferences) {
const value = Services.prefs.getStringPref(property, '');
const sanitizedProperty = property?.replaceAll(/\./g, '-');
switch (type) {
case 'dropdown': {
if (value !== '') {
let element = browser.document.getElementById(sanitizedName);
if (!element) {
element = browser.document.createElement('div');
element.style.display = 'none';
element.setAttribute('id', sanitizedName);
browser.document.body.appendChild(element);
}
element.setAttribute(sanitizedProperty, value);
}
break;
}
case 'string': {
if (value === '') {
browser.document
.querySelector(':root')
.style.removeProperty(`--${sanitizedProperty}`);
} else {
browser.document
.querySelector(':root')
.style.setProperty(`--${sanitizedProperty}`, value);
}
break;
}
}
}
}
}
}
async #writeStylesheet(modList = []) {
const mods = [];
for (let mod of modList) {
mod._filePath = this.#getStylesheetPathForMod(mod);
mods.push(mod);
}
let content = this.#kZenStylesheetModHeader;
content += `\n* FILE GENERATED AT: ${this.#getCurrentDateTime()}\n`;
content += this.#kZenStylesheetModHeaderBody;
for (let mod of mods) {
if (mod.enabled !== undefined && !mod.enabled) {
continue;
}
content += `\n/* Name: ${mod.name} */\n`;
content += `/* Description: ${mod.description} */\n`;
content += `/* Author: @${mod.author} */\n`;
if (mod._readmeURL) {
content += `/* Readme: ${mod.readme} */\n`;
}
const chromeContent = await IOUtils.readUTF8(mod._filePath);
content += chromeContent;
}
content += this.#kZenStylesheetModFooter;
const buffer = new TextEncoder().encode(content);
await IOUtils.write(this.#styleSheetPath, buffer);
}
#compareVersions(version1, version2) {
let result = false;
if (typeof version1 !== 'object') {
version1 = version1.toString().split('.');
}
if (typeof version2 !== 'object') {
version2 = version2.toString().split('.');
}
for (let i = 0; i < Math.max(version1.length, version2.length); i++) {
if (version1[i] == undefined) {
version1[i] = 0;
}
if (version2[i] == undefined) {
version2[i] = 0;
}
if (Number(version1[i]) < Number(version2[i])) {
result = true;
break;
}
if (version1[i] != version2[i]) {
break;
}
}
return result;
}
#composeModApiUrl(modId) {
// keeping theme here as it would require changes to CI to change the name
return `https://zen-browser.github.io/theme-store/themes/${modId}/theme.json`;
}
/* eslint-disable no-unused-vars */
async #downloadUrlToFile(url, path, isStyleSheet = false, maxRetries = 3, retryDelayMs = 500) {
let attempt = 0;
while (attempt < maxRetries) {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status} for url: ${url}`);
}
const data = await response.text();
// convert the data into a Uint8Array
const buffer = new TextEncoder().encode(data);
await IOUtils.write(path, buffer);
return; // to exit the loop
} catch (e) {
attempt++;
if (attempt >= maxRetries) {
console.error('[ZenMods]: Error downloading file after retries', url, e);
} else {
console.warn(
`[ZenMods]: Download failed (attempt ${attempt} of ${maxRetries}), retrying in ${retryDelayMs}ms...`,
url,
e
);
await new Promise((res) => setTimeout(res, retryDelayMs));
}
}
}
}
// private properties end
// public properties start
throttle(mainFunction, delay) {
let timerFlag = null;
return (...args) => {
if (timerFlag === null) {
mainFunction(...args);
timerFlag = setTimeout(() => {
timerFlag = null;
}, delay);
}
};
}
debounce(mainFunction, wait) {
let timerFlag;
return (...args) => {
clearTimeout(timerFlag);
timerFlag = setTimeout(() => {
mainFunction(...args);
}, wait);
};
}
sanitizeModName(name) {
// Do not change to "mod-" for backwards compatibility
return `theme-${name?.replaceAll(/\s/g, '-')?.replaceAll(/[^A-Za-z_-]+/g, '')}`;
}
get updatePref() {
return 'zen.mods.updated-value-observer';
}
get modsRootPath() {
return PathUtils.join(PathUtils.profileDir, 'chrome', 'zen-themes');
}
get modsDataFile() {
return PathUtils.join(PathUtils.profileDir, 'zen-themes.json');
}
getModFolder(modId) {
return PathUtils.join(this.modsRootPath, modId);
}
async getMods() {
if (!(await IOUtils.exists(this.modsDataFile))) {
await IOUtils.writeJSON(this.modsDataFile, {});
return {};
}
let mods = {};
try {
mods = await IOUtils.readJSON(this.modsDataFile);
if (mods === null || typeof mods !== 'object') {
throw new Error('Mods data file is invalid');
}
} catch {
// If we have a corrupted file, reset it
await IOUtils.writeJSON(this.modsDataFile, {});
Services.wm
.getMostRecentWindow('navigator:browser')
.gZenUIManager.showToast('zen-themes-corrupted', {
timeout: 8000,
});
}
return mods;
}
async getModPreferences(mod) {
const modPath = PathUtils.join(this.modsRootPath, mod.id, 'preferences.json');
if (!(await IOUtils.exists(modPath)) || !mod.preferences) {
return [];
}
try {
const preferences = await IOUtils.readJSON(modPath);
return preferences.filter(({ disabledOn = [] }) => {
return !disabledOn.includes(gZenOperatingSystemCommonUtils.currentOperatingSystem);
});
} catch (e) {
console.error(`[ZenMods]: Error reading mod preferences for ${mod.name}:`, e);
return [];
}
}
async init() {
try {
await SessionStore.promiseInitialized;
if (
Services.prefs.getBoolPref('zen.themes.disable-all', false) ||
Services.appinfo.inSafeMode
) {
console.log('[ZenMods]: Mods disabled by user or in safe mode.');
return;
}
await this.getMods(); // Check for any errors in the themes data file
const mods = await this.#getEnabledMods();
const modsWithPreferences = await Promise.all(
mods.map(async (mod) => {
const preferences = await this.getModPreferences(mod);
return {
name: mod.name,
enabled: mod.enabled,
preferences,
};
})
);
this.#writeToDom(modsWithPreferences);
await this.#insertStylesheet();
this.#setNewMilestoneIfNeeded();
if (this.#shouldAutoUpdate()) {
requestIdleCallback(
() => {
if (!window.closed) {
requestAnimationFrame(() => {
this.checkForModsUpdates();
});
}
},
{ timeout: 1000 }
);
}
} catch (e) {
console.error('[ZenMods]: Error loading Zen Mods:', e);
}
Services.prefs.addObserver(this.updatePref, this.#rebuildModsStylesheet.bind(this), false);
Services.prefs.addObserver('zen.themes.disable-all', this.#handleDisableMods.bind(this), false);
}
#setNewMilestoneIfNeeded() {
const previousMilestone = Services.prefs.getStringPref('zen.mods.milestone', '');
if (previousMilestone != Services.appinfo.version) {
Services.prefs.setStringPref('zen.mods.milestone', Services.appinfo.version);
Services.prefs.clearUserPref('zen.mods.last-update');
}
}
#shouldAutoUpdate() {
const daysBeforeUpdate = Services.prefs.getIntPref('zen.mods.auto-update-days');
const lastUpdatedSec = Services.prefs.getIntPref('zen.mods.last-update', -1);
const nowSec = Math.floor(Date.now() / 1000);
const daysSinceUpdate = (nowSec - lastUpdatedSec) / (60 * 60 * 24);
return (
(Services.prefs.getBoolPref('zen.mods.auto-update', true) &&
daysSinceUpdate >= daysBeforeUpdate) ||
lastUpdatedSec < 0
);
}
async checkForModsUpdates() {
const mods = await this.getMods();
const updates = await Promise.all(
Object.values(mods).map(async (currentMod) => {
try {
const possibleNewModVersion = await this.requestMod(currentMod.id);
if (!possibleNewModVersion) {
return null;
}
if (
!this.#compareVersions(possibleNewModVersion.version, currentMod.version ?? '0.0.0') &&
possibleNewModVersion.version != currentMod.version
) {
console.log(
`[ZenMods]: Mod update found for mod ${currentMod.name} (${currentMod.id}), current: ${currentMod.version}, new: ${possibleNewModVersion.version}`
);
possibleNewModVersion.enabled = currentMod.enabled;
await this.removeMod(currentMod.id, false);
mods[currentMod.id] = possibleNewModVersion;
return possibleNewModVersion;
}
return null;
} catch (e) {
console.error('[ZenMods]: Error checking for mod updates', e);
return null;
}
})
);
await this.updateMods(mods);
Services.prefs.setIntPref('zen.mods.last-update', Math.floor(Date.now() / 1000));
return updates.filter((update) => {
return update !== null;
});
}
async removeMod(modId, triggerUpdate = true) {
const modPath = this.getModFolder(modId);
console.log(`[ZenMods]: Removing mod ${modPath}`);
await IOUtils.remove(modPath, { recursive: true, ignoreAbsent: true });
const mods = await this.getMods();
delete mods[modId];
await IOUtils.writeJSON(this.modsDataFile, mods);
if (triggerUpdate) {
this.triggerModsUpdate();
}
}
async enableMod(modId) {
const mods = await this.getMods();
const mod = mods[modId];
console.log(`[ZenMods]: Enabling mod ${mod.name}`);
mod.enabled = true;
await IOUtils.writeJSON(this.modsDataFile, mods);
}
async disableMod(modId) {
const mods = await this.getMods();
const mod = mods[modId];
console.log(`[ZenMods]: Disabling mod ${mod.name}`);
mod.enabled = false;
await IOUtils.writeJSON(this.modsDataFile, mods);
}
async updateMods(mods = undefined) {
if (!mods) {
mods = await this.getMods();
}
await IOUtils.writeJSON(this.modsDataFile, mods);
await this.checkForModChanges();
}
triggerModsUpdate() {
Services.prefs.setBoolPref(this.updatePref, !Services.prefs.getBoolPref(this.updatePref));
}
async installMod(mod) {
try {
const modPath = PathUtils.join(this.modsRootPath, mod.id);
await IOUtils.makeDirectory(modPath, { ignoreExisting: true });
await this.#downloadUrlToFile(mod.style, PathUtils.join(modPath, 'chrome.css'), true);
await this.#downloadUrlToFile(mod.readme, PathUtils.join(modPath, 'readme.md'));
if (mod.preferences) {
await this.#downloadUrlToFile(mod.preferences, PathUtils.join(modPath, 'preferences.json'));
}
} catch (e) {
console.error('[ZenMods]: Error installing mod', mod.id, e);
}
}
async checkForModChanges() {
const mods = await this.getMods();
for (const [modId, mod] of Object.entries(mods)) {
try {
if (!mod) {
continue;
}
if (!(await IOUtils.exists(this.getModFolder(modId)))) {
await this.installMod(mod);
}
} catch (e) {
console.error('[ZenMods]: Error checking for mod changes', e);
}
}
this.triggerModsUpdate();
}
async requestMod(modId) {
const url = this.#composeModApiUrl(modId);
console.debug(`[ZenMods]: Fetching mod ${modId} info from ${url}`);
const data = await fetch(url, {
mode: 'no-cors',
});
if (data.ok) {
try {
const obj = await data.json();
return obj;
} catch (e) {
console.error(`[ZenMods]: Error parsing mod ${modId} info:`, e);
}
} else {
console.error(`[ZenMods]: Error fetching mod ${modId} info:`, data.status);
}
return null;
}
async isModInstalled(modId) {
const mods = await this.getMods();
return Boolean(mods?.[modId]);
}
// public properties end
}
window.gZenMods = new nsZenMods();