diff --git a/src/zen/mods/ZenMods.mjs b/src/zen/mods/ZenMods.mjs index 3f071f124..ab9c1e804 100644 --- a/src/zen/mods/ZenMods.mjs +++ b/src/zen/mods/ZenMods.mjs @@ -2,482 +2,114 @@ // 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/. -class ZenMods extends ZenPreloadedFeature { - // private properties start - #kZenStylesheetModHeader = '/* Zen Mods - Generated by ZenMods.'; - #kZenStylesheetModHeaderBody = `* DO NOT EDIT THIS FILE DIRECTLY! +{ + class ZenMods extends ZenPreloadedFeature { + // 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 = ` + #kZenStylesheetModFooter = ` /* End of Zen Mods */ `; - #getCurrentDateTime = () => - new Intl.DateTimeFormat('en-US', { - dateStyle: 'full', - timeStyle: 'full', - }).format(new Date().getTime()); + #getCurrentDateTime = () => + new Intl.DateTimeFormat('en-US', { + dateStyle: 'full', + timeStyle: 'full', + }).format(new Date().getTime()); - constructor() { - console.log('[ZenMods]: Initializing ZenMods module'); + constructor() { + console.log('[ZenMods]: Initializing ZenMods module'); - super(); - } - - // Stylesheet service - #sss = null; - - get #stylesheetService() { - if (!this.#sss) { - this.#sss = Cc['@mozilla.org/content/style-sheet-service;1'].getService( - Ci.nsIStyleSheetService - ); + super(); } - return this.#sss; - } - #ssu = null; + // Stylesheet service + #sss = null; - get #styleSheetUri() { - if (!this.#ssu) { - this.#ssu = Services.io.newFileURI(new FileUtils.File(this.#styleSheetPath)); + get #stylesheetService() { + if (!this.#sss) { + this.#sss = Cc['@mozilla.org/content/style-sheet-service;1'].getService( + Ci.nsIStyleSheetService + ); + } + return this.#sss; } - return this.#ssu; - } - get #styleSheetPath() { - return PathUtils.join(PathUtils.profileDir, 'chrome', 'zen-themes.css'); - } + #ssu = null; - async #handleDisableMods() { - if (Services.prefs.getBoolPref('zen.themes.disable-all', false)) { - console.log('[ZenMods]: Disabling mods module.'); - - await this.#removeStylesheet(); - } else { - console.log('[ZenMods]: Enabling mods module.'); - - await this.#rebuildModsStylesheet(); + get #styleSheetUri() { + if (!this.#ssu) { + this.#ssu = Services.io.newFileURI(new FileUtils.File(this.#styleSheetPath)); + } + return this.#ssu; } - } - #getStylesheetURIForMod(mod) { - return Services.io.newFileURI( - new FileUtils.File(PathUtils.join(this.getModFolder(mod.id), 'chrome.css')) - ); - } + get #styleSheetPath() { + return PathUtils.join(PathUtils.profileDir, 'chrome', 'zen-themes.css'); + } - async #insertStylesheet() { - if (await IOUtils.exists(this.#styleSheetPath)) { - await this.#stylesheetService.loadAndRegisterSheet( - this.#styleSheetUri, - this.#stylesheetService.AGENT_SHEET + async #handleDisableMods() { + if (Services.prefs.getBoolPref('zen.themes.disable-all', false)) { + console.log('[ZenMods]: Disabling mods module.'); + + await this.#removeStylesheet(); + } else { + console.log('[ZenMods]: Enabling mods module.'); + + await this.#rebuildModsStylesheet(); + } + } + + #getStylesheetURIForMod(mod) { + return Services.io.newFileURI( + new FileUtils.File(PathUtils.join(this.getModFolder(mod.id), 'chrome.css')) ); } - if ( - !this.#stylesheetService.sheetRegistered( - this.#styleSheetUri, - this.#stylesheetService.AGENT_SHEET - ) - ) { - console.error(`[ZenMods]: Failed to register stylesheet at ${this.#styleSheetUri.spec}.`); - } - } - - async #removeStylesheet() { - await this.#stylesheetService.unregisterSheet( - this.#styleSheetUri, - this.#stylesheetService.AGENT_SHEET - ); - const rv = this.#stylesheetService.sheetRegistered( - this.#styleSheetUri, - this.#stylesheetService.AGENT_SHEET - ); - await IOUtils.remove(this.#styleSheetPath, { ignoreAbsent: true }); - - if (rv || (await IOUtils.exists(this.#styleSheetPath))) { - console.error(`[ZenMods]: Failed to unregister stylesheet at ${this.#styleSheetUri.spec}.`); - } - } - - async #rebuildModsStylesheet() { - await this.#removeStylesheet(); - - 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() { - 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; + async #insertStylesheet() { + if (await IOUtils.exists(this.#styleSheetPath)) { + await this.#stylesheetService.loadAndRegisterSheet( + this.#styleSheetUri, + this.#stylesheetService.AGENT_SHEET + ); } - for (const { type, property, defaultValue } of preferences) { - if (defaultValue === undefined) { - continue; - } - - if (type === 'checkbox') { - const value = Services.prefs.getBoolPref(property, false); - if (typeof defaultValue !== 'boolean') { - console.warn( - '[ZenMods]: Warning, invalid data type received for expected type boolean, skipping.' - ); - continue; - } - - if (!value) { - Services.prefs.setBoolPref(property, defaultValue); - } - } else { - const value = Services.prefs.getStringPref(property, 'zen-property-no-saved'); - - if (typeof defaultValue !== 'string' && typeof defaultValue !== 'number') { - console.warn( - `[ZenMods]: Warning, invalid data type received (${typeof defaultValue}), skipping.` - ); - continue; - } - - if (value === 'zen-property-no-saved') { - Services.prefs.setStringPref(property, defaultValue.toString()); - } - } - } - } - } - - #writeToDom(modsWithPreferences) { - for (const browser of ZenMultiWindowFeature.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; - } - - default: { - } - } - } - } - } - } - - async #writeStylesheet(modList = []) { - const mods = []; - - for (let mod of modList) { - mod._chromeURL = this.#getStylesheetURIForMod(mod).spec; - 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`; - } - - content += `@import url("${mod._chromeURL}");\n`; - } - - 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`; - } - - 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(); - - let content = data; - - if (isStyleSheet) { - content = '@-moz-document url-prefix("chrome:") {\n'; - - for (const line of data.split('\n')) { - content += ` ${line}\n`; - } - - content += '}'; - } - - // convert the data into a Uint8Array - const buffer = new TextEncoder().encode(content); - 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.themes.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 + !this.#stylesheetService.sheetRegistered( + this.#styleSheetUri, + this.#stylesheetService.AGENT_SHEET + ) ) { - console.log('[ZenMods]: Mods disabled by user or in safe mode.'); - return; + console.error(`[ZenMods]: Failed to register stylesheet at ${this.#styleSheetUri.spec}.`); } + } + + async #removeStylesheet() { + await this.#stylesheetService.unregisterSheet( + this.#styleSheetUri, + this.#stylesheetService.AGENT_SHEET + ); + const rv = this.#stylesheetService.sheetRegistered( + this.#styleSheetUri, + this.#stylesheetService.AGENT_SHEET + ); + await IOUtils.remove(this.#styleSheetPath, { ignoreAbsent: true }); + + if (rv || (await IOUtils.exists(this.#styleSheetPath))) { + console.error(`[ZenMods]: Failed to unregister stylesheet at ${this.#styleSheetUri.spec}.`); + } + } + + async #rebuildModsStylesheet() { + await this.#removeStylesheet(); - await this.getMods(); // Check for any errors in the themes data file const mods = await this.#getEnabledMods(); + await this.#writeStylesheet(mods); + const modsWithPreferences = await Promise.all( mods.map(async (mod) => { const preferences = await this.getModPreferences(mod); @@ -490,233 +122,614 @@ class ZenMods extends ZenPreloadedFeature { }) ); + this.#setDefaults(modsWithPreferences); 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); - } + async #getEnabledMods() { + const modsObject = await this.getMods(); + const mods = Object.values(modsObject).filter( + (mod) => mod.enabled === undefined || mod.enabled + ); - #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'); - } - } + const modList = mods.map(({ name }) => name).join(', '); - #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); + const message = + modList !== '' + ? `[ZenMods]: Loading enabled Zen mods: ${modList}.` + : '[ZenMods]: No enabled Zen mods.'; - return ( - (Services.prefs.getBoolPref('zen.mods.auto-update', true) && - daysSinceUpdate >= daysBeforeUpdate) || - lastUpdatedSec < 0 - ); - } + console.log(message); - 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(); + return mods; } - 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) { + #setDefaults(modsWithPreferences) { + for (const { preferences, enabled } of modsWithPreferences) { + if (enabled !== undefined && !enabled) { continue; } - if (!(await IOUtils.exists(this.getModFolder(modId)))) { - await this.installMod(mod); + for (const { type, property, defaultValue } of preferences) { + if (defaultValue === undefined) { + continue; + } + + if (type === 'checkbox') { + const value = Services.prefs.getBoolPref(property, false); + if (typeof defaultValue !== 'boolean') { + console.warn( + '[ZenMods]: Warning, invalid data type received for expected type boolean, skipping.' + ); + continue; + } + + if (!value) { + Services.prefs.setBoolPref(property, defaultValue); + } + } else { + const value = Services.prefs.getStringPref(property, 'zen-property-no-saved'); + + if (typeof defaultValue !== 'string' && typeof defaultValue !== 'number') { + console.warn( + `[ZenMods]: Warning, invalid data type received (${typeof defaultValue}), skipping.` + ); + continue; + } + + if (value === 'zen-property-no-saved') { + Services.prefs.setStringPref(property, defaultValue.toString()); + } + } + } + } + } + + #writeToDom(modsWithPreferences) { + for (const browser of ZenMultiWindowFeature.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; + } + + default: { + } + } + } + } + } + } + + async #writeStylesheet(modList = []) { + const mods = []; + + for (let mod of modList) { + mod._chromeURL = this.#getStylesheetURIForMod(mod).spec; + 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`; + } + + content += `@import url("${mod._chromeURL}");\n`; + } + + 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`; + } + + 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(); + + let content = data; + + if (isStyleSheet) { + content = '@-moz-document url-prefix("chrome:") {\n'; + + for (const line of data.split('\n')) { + content += ` ${line}\n`; + } + + content += '}'; + } + + // convert the data into a Uint8Array + const buffer = new TextEncoder().encode(content); + 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.themes.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 checking for mod changes', 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'); } } - this.triggerModsUpdate(); - } + #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); - async requestMod(modId) { - const url = this.#composeModApiUrl(modId); + return ( + (Services.prefs.getBoolPref('zen.mods.auto-update', true) && + daysSinceUpdate >= daysBeforeUpdate) || + lastUpdatedSec < 0 + ); + } - console.debug(`[ZenMods]: Fetching mod ${modId} info from ${url}`); + async checkForModsUpdates() { + const mods = await this.getMods(); - const data = await fetch(url, { - mode: 'no-cors', - }); + const updates = await Promise.all( + Object.values(mods).map(async (currentMod) => { + try { + const possibleNewModVersion = await this.requestMod(currentMod.id); - if (data.ok) { + 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 obj = await data.json(); + const modPath = PathUtils.join(this.modsRootPath, mod.id); + await IOUtils.makeDirectory(modPath, { ignoreExisting: true }); - return obj; + 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 parsing mod ${modId} info:`, e); + console.error('[ZenMods]: Error installing mod', mod.id, e); } - } else { - console.error(`[ZenMods]: Error fetching mod ${modId} info:`, data.status); } - return null; + 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 } - async isModInstalled(modId) { - const mods = await this.getMods(); + window.gZenMods = new ZenMods(); - return Boolean(mods?.[modId]); - } - - // public properties end -} - -window.gZenMods = new ZenMods(); - -gZenActorsManager.addJSWindowActor('ZenModsMarketplace', { - parent: { - esModuleURI: 'resource:///actors/ZenModsMarketplaceParent.sys.mjs', - }, - child: { - esModuleURI: 'resource:///actors/ZenModsMarketplaceChild.sys.mjs', - events: { - DOMContentLoaded: {}, + gZenActorsManager.addJSWindowActor('ZenModsMarketplace', { + parent: { + esModuleURI: 'resource:///actors/ZenModsMarketplaceParent.sys.mjs', }, - }, - matches: [ - ...Services.prefs.getStringPref('zen.injections.match-urls').split(','), - 'about:preferences', - ], -}); + child: { + esModuleURI: 'resource:///actors/ZenModsMarketplaceChild.sys.mjs', + events: { + DOMContentLoaded: {}, + }, + }, + matches: [ + ...Services.prefs.getStringPref('zen.injections.match-urls').split(','), + 'about:preferences', + ], + }); +}