UNPKG

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
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 }; }); }