mirror of
https://github.com/zen-browser/desktop.git
synced 2026-08-13 02:05:18 +00:00
* 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
662 lines
18 KiB
JavaScript
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();
|