webext-store-incompat-fixer
Version:
Package which clones a packed webextension and fixes incompatibilities with certain extension stores
344 lines (277 loc) • 10.9 kB
JavaScript
import fs from 'node:fs';
import JSZip from 'jszip';
import webextManifestBrowserPolyfill from 'webext-manifest-browser-polyfill';
const storeTheming = {
chrome: '\u001B[33m',
edge: '\u001B[32m',
firefox: '\u001B[31;1m',
opera: '\u001B[31m',
safari: '\u001B[34m',
samsung: '\u001B[34m',
whale: '\u001B[36m',
};
const whaleLocaleFallbacks = {
ca_valencia: 'ca',
sr_Latn: 'sr',
zh_Hans: 'zh_CN',
zh_Hant: 'zh_TW',
zh_Hant_HK: 'zh_HK',
};
function stringifyInOriginalFormat (originalString, newJson) {
let space = null;
if (originalString.indexOf('\t') !== -1) {
space = '\t';
} else if (originalString.indexOf(' ') !== -1) {
space = 2;
}
return JSON.stringify(newJson, null, space);
}
function handleEdgeLocaleExclusions ({
zip, manifestString, manifestJson, params,
}) {
const defaultLocale = manifestJson.default_locale;
const messagesMatch = manifestString.match(/__MSG_(.+?)__/g);
let changed = false;
if (!defaultLocale || !messagesMatch) {
return Promise.resolve(changed);
}
const messagesToRemove = messagesMatch.map((item) => item.match(/__MSG_(.+)__/)[1]);
if (messagesToRemove.length === 0) {
return Promise.resolve(changed);
}
const forcedInclusions = Array.isArray(params.edgeLocaleInclusions)
? params.edgeLocaleInclusions
: [];
forcedInclusions.push(defaultLocale);
const updatePromises = [];
const localeRegex = /^_locales\/(.+)\/messages\.json$/;
zip.file(localeRegex).forEach((file) => {
const fileName = file.name;
const localeId = fileName.match(localeRegex)[1];
if (forcedInclusions.includes(localeId)) return;
const readPromise = file.async('string').then((result) => {
const localeJson = JSON.parse(result);
let messageFound = false;
messagesToRemove.forEach((messageId) => {
if (localeJson[messageId]) {
messageFound = true;
delete localeJson[messageId];
}
});
if (!messageFound) return null;
changed = true;
const localeString = stringifyInOriginalFormat(result, localeJson);
return zip.file(fileName, localeString);
});
updatePromises.push(readPromise);
});
return Promise.all(updatePromises).then(() => changed);
}
function handleManifestMinimumVersion ({ manifestJson, store, packageChanges }) {
if (store !== 'chrome') return;
const manifestVersion = manifestJson.manifest_version;
if (manifestVersion < 3) return;
const minimumChromeVersion = manifestJson.minimum_chrome_version;
if (!minimumChromeVersion) return;
const mainVersionNumber = minimumChromeVersion | 0;
if (mainVersionNumber > 87) return;
packageChanges.push('Removed minimum_chrome_version property as it requires to be at least 88 with manifest v3.');
delete manifestJson.minimum_chrome_version;
}
function handleBackgroundScripting ({ manifestJson, store, packageChanges }) {
if (store !== 'edge') return;
const manifestVersion = manifestJson.manifest_version;
if (manifestVersion < 3) return;
const backgroundData = manifestJson.background;
if (!backgroundData) return;
const scripts = backgroundData.scripts;
if (!scripts) return;
const serviceWorker = backgroundData.service_worker;
if (!serviceWorker) return;
packageChanges.push('Removed background.scripts property as it is not accepted in manifest v3 by the Edge store. See: https://github.com/microsoft/MicrosoftEdge-Extensions/issues/136');
delete backgroundData.scripts;
}
function truncatePropertyValue (manifestJson, key, limit, packageChanges) {
const originalValue = manifestJson[key];
if (typeof originalValue !== 'string') return;
// do not truncate if it includes i18n replacements
if (originalValue.includes('__MSG_')) return;
// only truncate if exceeding the limits
if (originalValue.length <= limit) return;
let newValue = originalValue.replace(/\s/g, '');
if (newValue.length > limit) {
newValue = originalValue.slice(0, limit);
}
manifestJson[key] = newValue;
packageChanges.push(`Truncated "${key}": "${originalValue}" to "${newValue}".`);
}
function handleManifestName ({ manifestJson, store, packageChanges }) {
// name
const extensionName = manifestJson.name;
if (store === 'whale' && typeof extensionName === 'string' && extensionName.includes('™')) {
// remove tm symbol if found, as the store doesn't render it correctly
manifestJson.name = extensionName.replace(/™/g, '');
packageChanges.push('Removed ™ symbol from name. See: https://forum.whale.naver.com/topic/39748/');
}
const nameLimit = store === 'chrome' || store === 'firefox' ? 45 : Infinity;
truncatePropertyValue(manifestJson, 'name', nameLimit, packageChanges);
// short_name
let shortNameLimit = Infinity;
if (store === 'opera') {
shortNameLimit = 12;
} else if (store === 'firefox' || store === 'chrome') {
shortNameLimit = 45;
}
truncatePropertyValue(manifestJson, 'short_name', shortNameLimit, packageChanges);
// description
let descriptionLimit = Infinity;
if (store === 'chrome') {
descriptionLimit = 132;
} else if (store === 'safari') {
descriptionLimit = 112;
}
truncatePropertyValue(manifestJson, 'description', descriptionLimit, packageChanges);
}
function handleManifestDefaultLocale ({ manifestJson, store, packageChanges }) {
const defaultLocale = manifestJson.default_locale;
if (!defaultLocale) return;
if (store !== 'whale') return;
const fallbackLocale = whaleLocaleFallbacks[defaultLocale];
if (!fallbackLocale) return;
manifestJson.default_locale = fallbackLocale;
packageChanges.push(`Replaced default_locale ${defaultLocale} with ${fallbackLocale}.`);
}
function handleIncompatibleLocaleFiles ({ zip, packageChanges }) {
// handle incompatible locale files
const incompatLocaleMatch = /^_locales\/(zh_Hans|zh_Hant|sr_Latn|ca_valencia)\/messages\.json$/;
zip.file(incompatLocaleMatch).forEach((file) => {
const localeId = file.name.match(incompatLocaleMatch)[1];
const fallbackLocaleId = whaleLocaleFallbacks[localeId];
const fallbackName = '_locales/' + fallbackLocaleId + '/messages.json';
const fallbackFile = zip.file(fallbackName);
if (fallbackFile) {
packageChanges.push('Removed ' + localeId + ' from package. See: https://forum.whale.naver.com/topic/39749/');
} else {
packageChanges.push('Renamed ' + localeId + ' translations to ' + fallbackLocaleId + '. See: https://forum.whale.naver.com/topic/39749/');
zip.file(fallbackName, file.async('uint8array'));
}
zip.remove('_locales/' + localeId);
});
}
function handleSinglePackage ({ zipOrigin, store, params }) {
const packageChanges = [];
const zip = new JSZip();
return zip.loadAsync(zipOrigin).then(() => zip.file('manifest.json').async('string')).then((manifestString) => {
const manifestJson = JSON.parse(manifestString);
if (params.beta) {
const result = webextManifestBrowserPolyfill({
manifestJson: manifestJson,
targetVersion: manifestJson.manifest_version || 3,
backgroundStrategy: null, // TODO what to do here?
stores: [store],
});
console.log(result);
packageChanges.push(...result.changes);
} else {
handleManifestDefaultLocale({ manifestJson, store, packageChanges });
handleManifestName({ manifestJson, store, packageChanges });
handleManifestMinimumVersion({ manifestJson, store, packageChanges });
handleBackgroundScripting({ manifestJson, store, packageChanges });
}
if (packageChanges.length > 0) {
const manifestFinal = stringifyInOriginalFormat(manifestString, manifestJson);
zip.file('manifest.json', manifestFinal);
}
if (store === 'whale') {
handleIncompatibleLocaleFiles({ zip, packageChanges });
}
// removes translations from messages used in the manifest file
// this allows the inclusion of translation without the need
// to enter all store assets for each language
if (store === 'edge' && params.edgeLocaleInclusions) {
return handleEdgeLocaleExclusions({
zip, manifestString, manifestJson, params,
}).then((changed) => {
if (changed) {
packageChanges.push('Removed translations for messages in manifest.json.');
}
});
}
return null;
}).then(() => ({ zip, packageChanges }));
}
function cleanStoreInput (inputStores) {
const supportedStores = [
'chrome',
'edge',
'opera',
'samsung',
'whale',
'firefox',
'safari',
];
if (!Array.isArray(inputStores) || inputStores.length === 0) return supportedStores;
return supportedStores.filter((store) => inputStores.includes(store));
}
function writeZipToDisk ({ zip, outputPath }) {
const outputStream = fs.createWriteStream(outputPath);
const inputStream = zip.generateNodeStream({
type: 'nodebuffer',
platform: process.platform,
streamFiles: true,
});
return new Promise((resolve) => {
inputStream.pipe(outputStream).on('finish', () => {
resolve();
});
});
}
function readJsonFile (filePath) {
try {
const fileText = fs.readFileSync(filePath);
return JSON.parse(fileText) || {};
} catch {
return {};
}
}
function getVersion () {
return process.env.npm_package_version
|| readJsonFile('./package.json').version
|| readJsonFile('./manifest.json').version
|| 'unknown';
}
function transformSingleStore ({
zipOrigin, store, params, inputPath,
}) {
const messageLog = [];
return handleSinglePackage({ zipOrigin, store, params }).then((result) => {
const fancyStoreName = store.toUpperCase();
const canonicalStoreName = store.toLowerCase().split('-')[0];
const colorReset = '\u001B[0m';
messageLog.push('\u001B[1m\n' + storeTheming[canonicalStoreName] + fancyStoreName + colorReset);
const { zip, packageChanges } = result;
if (packageChanges.length === 0) {
messageLog.push('\u001B[2m- No adaptions needed\u001B[0m.');
return null;
}
const outputPath = inputPath.replace('.zip', '__adapted_for_' + store + '.zip');
packageChanges.forEach((changeMessage) => {
messageLog.push('- ' + changeMessage);
});
return writeZipToDisk({ zip, outputPath });
}).then(() => messageLog);
}
export default function generate (params) {
const stores = cleanStoreInput(params.stores);
const version = getVersion();
const inputPath = params.inputPath.replace('{version}', version);
const zipOrigin = fs.promises.readFile(inputPath);
const storeTransformations = stores.map((store) => transformSingleStore({
zipOrigin, store, params, inputPath,
}));
return Promise.all(storeTransformations).then((messageLogMap) => {
const messageLog = messageLogMap.flat();
messageLog.push('\nHandled store incompatibilities');
return { messageLog };
});
}