diff options
Diffstat (limited to 'toolkit/components/translations/actors')
5 files changed, 3394 insertions, 0 deletions
diff --git a/toolkit/components/translations/actors/AboutTranslationsChild.sys.mjs b/toolkit/components/translations/actors/AboutTranslationsChild.sys.mjs new file mode 100644 index 0000000000..112ac3c444 --- /dev/null +++ b/toolkit/components/translations/actors/AboutTranslationsChild.sys.mjs @@ -0,0 +1,312 @@ +/* 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 { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +const lazy = {}; + +XPCOMUtils.defineLazyGetter(lazy, "console", () => { + return console.createInstance({ + maxLogLevelPref: "browser.translations.logLevel", + prefix: "Translations", + }); +}); + +/** + * @typedef {import("./TranslationsChild.sys.mjs").LanguageIdEngine} LanguageIdEngine + * @typedef {import("./TranslationsChild.sys.mjs").TranslationsEngine} TranslationsEngine + * @typedef {import("./TranslationsChild.sys.mjs").SupportedLanguages} SupportedLanguages + */ + +/** + * The AboutTranslationsChild is responsible for coordinating what privileged APIs + * are exposed to the un-privileged scope of the about:translations page. + */ +export class AboutTranslationsChild extends JSWindowActorChild { + /** @type {LanguageIdEngine | null} */ + languageIdEngine = null; + + /** @type {TranslationsEngine | null} */ + translationsEngine = null; + + /** + * The translations engine uses text translations by default in about:translations, + * but it can be changed to translate HTML by setting this pref to true. This is + * useful for manually testing HTML translation behavior, but is not useful to surface + * as a user-facing feature. + * + * @type {bool} + */ + #isHtmlTranslation = Services.prefs.getBoolPref( + "browser.translations.useHTML" + ); + + handleEvent(event) { + if (event.type === "DOMDocElementInserted") { + this.#exportFunctions(); + } + + if ( + event.type === "DOMContentLoaded" && + Services.prefs.getBoolPref("browser.translations.enable") + ) { + this.#sendEventToContent({ type: "enable" }); + } + } + + /** + * @param {object} detail + */ + #sendEventToContent(detail) { + this.contentWindow.dispatchEvent( + new this.contentWindow.CustomEvent("AboutTranslationsChromeToContent", { + detail: Cu.cloneInto(detail, this.contentWindow), + }) + ); + } + + /** + * @returns {TranslationsChild} + */ + #getTranslationsChild() { + const child = this.contentWindow.windowGlobalChild.getActor("Translations"); + if (!child) { + throw new Error("Unable to find the TranslationsChild"); + } + return child; + } + + /** + * A privileged promise can't be used in the content page, so convert a privileged + * promise into a content one. + * + * @param {Promise<any>} promise + * @returns {Promise<any>} + */ + #convertToContentPromise(promise) { + return new this.contentWindow.Promise((resolve, reject) => + promise.then(resolve, error => { + let contentWindow; + try { + contentWindow = this.contentWindow; + } catch (error) { + // The content window is no longer available. + reject(); + return; + } + // Create an error in the content window, if the content window is still around. + let message = "An error occured in the AboutTranslations actor."; + if (typeof error === "string") { + message = error; + } + if (typeof error?.message === "string") { + message = error.message; + } + if (typeof error?.stack === "string") { + message += `\n\nOriginal stack:\n\n${error.stack}\n`; + } + + reject(new contentWindow.Error(message)); + }) + ); + } + + /** + * Export any of the child functions that start with "AT_" to the unprivileged content + * page. This restricts the security capabilities of the the content page. + */ + #exportFunctions() { + const window = this.contentWindow; + + const fns = [ + "AT_log", + "AT_logError", + "AT_getAppLocale", + "AT_getSupportedLanguages", + "AT_isTranslationEngineSupported", + "AT_createLanguageIdEngine", + "AT_createTranslationsEngine", + "AT_identifyLanguage", + "AT_translate", + "AT_destroyTranslationsEngine", + "AT_getScriptDirection", + ]; + for (const name of fns) { + Cu.exportFunction(this[name].bind(this), window, { defineAs: name }); + } + } + + /** + * Log messages if "browser.translations.logLevel" is set to "All". + * + * @param {...any} args + */ + AT_log(...args) { + lazy.console.log(...args); + } + + /** + * Report an error to the console. + * + * @param {...any} args + */ + AT_logError(...args) { + lazy.console.error(...args); + } + + /** + * Returns the app's locale. + * + * @returns {Intl.Locale} + */ + AT_getAppLocale() { + return Services.locale.appLocaleAsBCP47; + } + + /** + * Wire this function to the TranslationsChild. + * + * @returns {Promise<SupportedLanguages>} + */ + AT_getSupportedLanguages() { + return this.#convertToContentPromise( + this.#getTranslationsChild() + .getSupportedLanguages() + .then(data => Cu.cloneInto(data, this.contentWindow)) + ); + } + + /** + * Does this device support the translation engine? + * @returns {Promise<boolean>} + */ + AT_isTranslationEngineSupported() { + return this.#convertToContentPromise( + this.#getTranslationsChild().isTranslationsEngineSupported + ); + } + + /** + * Creates the LanguageIdEngine which attempts to identify in which + * human language a string is written. + * + * Unlike TranslationsEngine, which handles only a single language pair + * and must be rebuilt to handle a new language pair, the LanguageIdEngine + * is a one-to-many engine that can recognize all of its supported languages. + * + * Subsequent calls to this function after the engine is initialized will do nothing + * instead of rebuilding the engine. + * + * @returns {Promise<void>} + */ + AT_createLanguageIdEngine() { + if (this.languageIdEngine) { + return this.#convertToContentPromise(Promise.resolve()); + } + return this.#convertToContentPromise( + this.#getTranslationsChild() + .createLanguageIdEngine() + .then(engine => { + this.languageIdEngine = engine; + }) + ); + } + + /** + * Creates the TranslationsEngine which is responsible for translating + * from one language to the other. + * + * The instantiated TranslationsEngine is unique to its language pair. + * In order to translate a different language pair, a new engine must be + * created for that pair. + * + * Subsequent calls to this function will destroy the existing engine and + * rebuild a new engine for the new language pair. + * + * @param {string} fromLanguage + * @param {string} toLanguage + * @returns {Promise<void>} + */ + AT_createTranslationsEngine(fromLanguage, toLanguage) { + if (this.translationsEngine) { + this.translationsEngine.terminate(); + this.translationsEngine = null; + } + return this.#convertToContentPromise( + this.#getTranslationsChild() + .createTranslationsEngine(fromLanguage, toLanguage) + .then(engine => { + this.translationsEngine = engine; + }) + ); + } + + /** + * Attempts to identify the human language in which the message is written. + * @see LanguageIdEngine#identifyLanguage for more detailed documentation. + * + * @param {string} message + * @returns {Promise<{ langTag: string, confidence: number }>} + */ + AT_identifyLanguage(message) { + if (!this.languageIdEngine) { + const { Promise, Error } = this.contentWindow; + return Promise.reject( + new Error("The language identification was not created.") + ); + } + + return this.#convertToContentPromise( + this.languageIdEngine + .identifyLanguage(message) + .then(data => Cu.cloneInto(data, this.contentWindow)) + ); + } + + /** + * @param {string[]} messageBatch + * @param {number} innerWindowId + * @returns {Promise<string[]>} + */ + AT_translate(messageBatch, innerWindowId) { + if (!this.translationsEngine) { + throw new this.contentWindow.Error( + "The translations engine was not created." + ); + } + const promise = this.#isHtmlTranslation + ? this.translationsEngine.translateHTML(messageBatch, innerWindowId) + : this.translationsEngine.translateText(messageBatch, innerWindowId); + + return this.#convertToContentPromise( + promise.then(translations => + Cu.cloneInto(translations, this.contentWindow) + ) + ); + } + + /** + * This is not strictly necessary, but could free up resources quicker. + */ + AT_destroyTranslationsEngine() { + if (this.translationsEngine) { + this.translationsEngine.terminate(); + this.translationsEngine = null; + } + } + + /** + * TODO - Remove this when Intl.Locale.prototype.textInfo is available to + * content scripts. + * + * https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/textInfo + * https://bugzilla.mozilla.org/show_bug.cgi?id=1693576 + * + * @param {string} locale + * @returns {string} + */ + AT_getScriptDirection(locale) { + return Services.intl.getScriptDirection(locale); + } +} diff --git a/toolkit/components/translations/actors/AboutTranslationsParent.sys.mjs b/toolkit/components/translations/actors/AboutTranslationsParent.sys.mjs new file mode 100644 index 0000000000..097a0a1d92 --- /dev/null +++ b/toolkit/components/translations/actors/AboutTranslationsParent.sys.mjs @@ -0,0 +1,13 @@ +/* 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/. */ + +/** + * This parent is blank because the Translations actor handles most of the features + * needed in AboutTranslations. + */ +export class AboutTranslationsParent extends JSWindowActorParent { + constructor() { + super(); + } +} diff --git a/toolkit/components/translations/actors/TranslationsChild.sys.mjs b/toolkit/components/translations/actors/TranslationsChild.sys.mjs new file mode 100644 index 0000000000..4a5388ec88 --- /dev/null +++ b/toolkit/components/translations/actors/TranslationsChild.sys.mjs @@ -0,0 +1,1106 @@ +/* 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/. */ + +/** + * @typedef {import("../content/translations-document.sys.mjs").TranslationsDocument} TranslationsDocument + * @typedef {import("../translations").LanguageIdEnginePayload} LanguageIdEnginePayload + * @typedef {import("../translations").LanguageTranslationModelFiles} LanguageTranslationModelFiles + * @typedef {import("../translations").TranslationsEnginePayload} TranslationsEnginePayload + * @typedef {import("../translations").LanguagePair} LanguagePair + * @typedef {import("../translations").SupportedLanguages} SupportedLanguages + * @typedef {import("../translations").LangTags} LangTags + */ + +/** + * @type {{ + * TranslationsDocument: typeof TranslationsDocument + * console: typeof console + * }} + */ +const lazy = {}; + +/** + * The threshold that the language-identification confidence + * value must be greater than in order to provide the detected language + * tag for translations. + * + * This value should ideally be one that does not allow false positives + * while also not being too restrictive. + * + * At this time, this value is not driven by statistical data or analysis. + */ +const DOC_LANGUAGE_DETECTION_THRESHOLD = 0.65; + +/** + * The length of the substring to pull from the document's text for language + * identification. + * + * This value should ideally be one that is large enough to yield a confident + * identification result without being too large or expensive to extract. + * + * At this time, this value is not driven by statistical data or analysis. + */ +const DOC_TEXT_TO_IDENTIFY_LENGTH = 1024; + +const PIVOT_LANGUAGE = "en"; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +ChromeUtils.defineESModuleGetters(lazy, { + setTimeout: "resource://gre/modules/Timer.sys.mjs", + clearTimeout: "resource://gre/modules/Timer.sys.mjs", + TranslationsDocument: + "chrome://global/content/translations/translations-document.sys.mjs", + TranslationsTelemetry: + "chrome://global/content/translations/TranslationsTelemetry.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "console", () => { + return console.createInstance({ + maxLogLevelPref: "browser.translations.logLevel", + prefix: "Translations", + }); +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "logLevel", + "browser.translations.logLevel" +); + +export class LanguageIdEngine { + /** @type {Worker} */ + #languageIdWorker; + // Multiple messages can be sent before a response is received. This ID is used to keep + // track of the messages. It is incremented on every use. + #messageId = 0; + + /** + * Construct and initialize the language-id worker. + * + * @param {Object} data + * @param {string} data.type - The message type, expects "initialize". + * @param {ArrayBuffer} data.wasmBuffer - The buffer containing the wasm binary. + * @param {ArrayBuffer} data.modelBuffer - The buffer containing the language-id model binary. + * @param {null | string} data.mockedLangTag - The mocked language tag value (only present when mocking). + * @param {null | number} data.mockedConfidence - The mocked confidence value (only present when mocking). + * @param {boolean} data.isLoggingEnabled + */ + constructor(data) { + this.#languageIdWorker = new Worker( + "chrome://global/content/translations/language-id-engine-worker.js" + ); + + this.isReady = new Promise((resolve, reject) => { + const onMessage = ({ data }) => { + if (data.type === "initialization-success") { + resolve(); + } else if (data.type === "initialization-error") { + reject(data.error); + } + this.#languageIdWorker.removeEventListener("message", onMessage); + }; + this.#languageIdWorker.addEventListener("message", onMessage); + }); + + const transferables = []; + // Make sure the ArrayBuffers are transferred, not cloned. + // https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects + transferables.push(data.wasmBuffer, data.modelBuffer); + + this.#languageIdWorker.postMessage(data, transferables); + } + + /** + * Attempts to identify the human language in which the message is written. + * Generally, the longer a message is, the higher the likelihood that the + * identified language will be correct. Shorter messages increase the chance + * of false identification. + * + * The returned confidence is a number between 0.0 and 1.0 of how confident + * the language identification model was that it identified the correct language. + * + * @param {string} message + * @returns {Promise<{ langTag: string, confidence: number }>} + */ + identifyLanguage(message) { + const messageId = this.#messageId++; + return new Promise((resolve, reject) => { + const onMessage = ({ data }) => { + if (data.messageId !== messageId) { + // Multiple translation requests can be sent before a response is received. + // Ensure that the response received here is the correct one. + return; + } + if (data.type === "language-id-response") { + let { langTag, confidence } = data; + resolve({ langTag, confidence }); + } + if (data.type === "language-id-error") { + reject(data.error); + } + this.#languageIdWorker.removeEventListener("message", onMessage); + }; + this.#languageIdWorker.addEventListener("message", onMessage); + this.#languageIdWorker.postMessage({ + type: "language-id-request", + message, + messageId, + }); + }); + } +} + +// How long the cache remains alive between uses, in milliseconds. +const CACHE_TIMEOUT_MS = 10_000; + +class TranslationsEngineCache { + /** @type {Record<string, Promise<TranslationsEngine>>} */ + #engines = {}; + + /** @type {Record<string, TimeoutID>} */ + #timeouts = {}; + + /** + * Returns a getter function that will create a translations engine on the first + * call, and then return the cached one. After a timeout when the engine hasn't + * been used, it is destroyed. + * + * @param {TranslationsChild} actor + * @param {string} fromLanguage + * @param {string} toLanguage + * @returns {(() => Promise<TranslationsEngine>) | ((onlyFromCache: true) => Promise<TranslationsEngine | null>)} + */ + createGetter(actor, fromLanguage, toLanguage) { + return async (onlyFromCache = false) => { + let enginePromise = + this.#engines[ + TranslationsChild.languagePairKey(fromLanguage, toLanguage) + ]; + if (enginePromise) { + return enginePromise; + } + if (onlyFromCache) { + return null; + } + + // A new engine needs to be created. + enginePromise = actor.createTranslationsEngine(fromLanguage, toLanguage); + + const key = TranslationsChild.languagePairKey(fromLanguage, toLanguage); + this.#engines[key] = enginePromise; + + // Remove the engine if it fails to initialize. + enginePromise.catch(error => { + lazy.console.log( + `The engine failed to load for translating "${fromLanguage}" to "${toLanguage}". Removing it from the cache.`, + error + ); + this.#engines[key] = null; + }); + + const engine = await enginePromise; + + // These methods will be spied on, and when called they will keep the engine alive. + this.spyOn(engine, "translateText"); + this.spyOn(engine, "translateHTML"); + this.spyOn(engine, "discardTranslationQueue"); + this.keepAlive(fromLanguage, toLanguage); + + return engine; + }; + } + + /** + * Spies on a method, so that when it is called, the engine is kept alive. + * + * @param {TranslationsEngine} engine + * @param {string} methodName + */ + spyOn(engine, methodName) { + const method = engine[methodName].bind(engine); + engine[methodName] = (...args) => { + this.keepAlive(engine.fromLanguage, engine.toLanguage); + return method(...args); + }; + } + + /** + * @param {string} fromLanguage + * @param {string} toLanguage + */ + keepAlive(fromLanguage, toLanguage) { + const key = TranslationsChild.languagePairKey(fromLanguage); + const timeoutId = this.#timeouts[key]; + if (timeoutId) { + lazy.clearTimeout(timeoutId); + } + const enginePromise = this.#engines[key]; + if (!enginePromise) { + // It appears that the engine is already dead. + return; + } + this.#timeouts[key] = lazy.setTimeout(() => { + // Delete the caches. + delete this.#engines[key]; + delete this.#timeouts[key]; + + // Terminate the engine worker. + enginePromise.then(engine => engine.terminate()); + }, CACHE_TIMEOUT_MS); + } + + /** + * Sees if an engine is still in the cache. + */ + isInCache(fromLanguage, toLanguage) { + this.keepAlive(fromLanguage, toLanguage); + return Boolean( + this.#engines[TranslationsChild.languagePairKey(fromLanguage, toLanguage)] + ); + } +} + +const translationsEngineCache = new TranslationsEngineCache(); + +/** + * The TranslationsEngine encapsulates the logic for translating messages. It can + * only be set up for a single language translation pair. In order to change languages + * a new engine should be constructed. + * + * The actual work for the translations happens in a worker. This class manages + * instantiating and messaging the worker. + */ +export class TranslationsEngine { + /** @type {Worker} */ + #translationsWorker; + // Multiple messages can be sent before a response is received. This ID is used to keep + // track of the messages. It is incremented on every use. + #messageId = 0; + + /** + * Construct and initialize the worker. + * + * @param {string} fromLanguage + * @param {string} toLanguage + * @param {TranslationsEnginePayload} enginePayload - If there is no engine payload + * then the engine will be mocked. This allows this class to be used in tests. + * @param {number} innerWindowId - This only used for creating profiler markers in + * the initial creation of the engine. + */ + constructor(fromLanguage, toLanguage, enginePayload, innerWindowId) { + /** @type {string} */ + this.fromLanguage = fromLanguage; + /** @type {string} */ + this.toLanguage = toLanguage; + this.#translationsWorker = new Worker( + "chrome://global/content/translations/translations-engine-worker.js" + ); + + /** @type {Promise<void>} */ + this.isReady = new Promise((resolve, reject) => { + const onMessage = ({ data }) => { + lazy.console.log("Received initialization message", data); + if (data.type === "initialization-success") { + resolve(); + } else if (data.type === "initialization-error") { + reject(data.error); + } + this.#translationsWorker.removeEventListener("message", onMessage); + }; + this.#translationsWorker.addEventListener("message", onMessage); + }); + + // Make sure the ArrayBuffers are transferred, not cloned. + // https://developer.mozilla.org/en-US/docs/Web/API/Web_Workers_API/Transferable_objects + const transferables = []; + if (enginePayload) { + transferables.push(enginePayload.bergamotWasmArrayBuffer); + for (const files of enginePayload.languageModelFiles) { + for (const { buffer } of Object.values(files)) { + transferables.push(buffer); + } + } + } + + this.#translationsWorker.postMessage( + { + type: "initialize", + fromLanguage, + toLanguage, + enginePayload, + innerWindowId, + messageId: this.#messageId++, + logLevel: lazy.logLevel, + }, + transferables + ); + } + + /** + * Translate text without any HTML. + * + * @param {string[]} messageBatch + * @param {number} innerWindowId + * @returns {Promise<string[]>} + */ + translateText(messageBatch, innerWindowId) { + return this.#translate(messageBatch, false, innerWindowId); + } + + /** + * Translate valid HTML. Note that this method throws if invalid markup is provided. + * + * @param {string[]} messageBatch + * @param {number} innerWindowId + * @returns {Promise<string[]>} + */ + translateHTML(messageBatch, innerWindowId) { + return this.#translate(messageBatch, true, innerWindowId); + } + + /** + * The implementation for translation. Use translateText or translateHTML for the + * public API. + * + * @param {string[]} messageBatch + * @param {boolean} isHTML + * @param {number} innerWindowId + * @returns {Promise<string[]>} + */ + #translate(messageBatch, isHTML, innerWindowId) { + const messageId = this.#messageId++; + + return new Promise((resolve, reject) => { + const onMessage = ({ data }) => { + if ( + data.type === "translations-discarded" && + data.innerWindowId === innerWindowId + ) { + // The page was unloaded, and we no longer need to listen for a response. + this.#translationsWorker.removeEventListener("message", onMessage); + return; + } + + if (data.messageId !== messageId) { + // Multiple translation requests can be sent before a response is received. + // Ensure that the response received here is the correct one. + return; + } + + if (data.type === "translation-response") { + resolve(data.translations); + } + if (data.type === "translation-error") { + reject(data.error); + } + this.#translationsWorker.removeEventListener("message", onMessage); + }; + + this.#translationsWorker.addEventListener("message", onMessage); + + this.#translationsWorker.postMessage({ + type: "translation-request", + isHTML, + messageBatch, + messageId, + innerWindowId, + }); + }); + } + + /** + * The worker should be GCed just fine on its own, but go ahead and signal to + * the worker that it's no longer needed. This will immediately cancel any in-progress + * translations. + */ + terminate() { + this.#translationsWorker.terminate(); + } + + /** + * Stop processing the translation queue. All in-progress messages will be discarded. + * + * @param {number} innerWindowId + */ + discardTranslationQueue(innerWindowId) { + ChromeUtils.addProfilerMarker( + "TranslationsChild", + null, + "Request to discard translation queue" + ); + this.#translationsWorker.postMessage({ + type: "discard-translation-queue", + innerWindowId, + }); + } +} + +/** + * See the TranslationsParent for documentation. + */ +export class TranslationsChild extends JSWindowActorChild { + constructor() { + super(); + ChromeUtils.addProfilerMarker( + "TranslationsChild", + null, + "TranslationsChild constructor" + ); + } + + /** + * The getter for the TranslationsEngine, managed by the EngineCache. + * + * @type {null | (() => Promise<TranslationsEngine>) | ((fromCache: true) => Promise<TranslationsEngine | null>)} + */ + #getTranslationsEngine = null; + + /** + * The actor can be destroyed leaving dangling references to dead objects. + */ + #isDestroyed = false; + + /** + * Store this at the beginning so that there is no risk of access a dead object + * to read it. + * @type {number | null} + */ + innerWindowId = null; + + /** + * @type {TranslationsDocument | null} + */ + translatedDoc = null; + + /** + * The matched language tags for the page. Used to find a default language pair for + * translations. + * + * @type {null | LangTags} + * */ + #langTags = null; + + /** + * Creates a lookup key that is unique to each fromLanguage-toLanguage pair. + * + * @param {string} fromLanguage + * @param {string} toLanguage + * @returns {string} + */ + static languagePairKey(fromLanguage, toLanguage) { + return `${fromLanguage},${toLanguage}`; + } + + /** + * Retrieve a substring of text from the document body to be + * analyzed by the LanguageIdEngine to determine the page's language. + * + * @returns {string} + */ + #getTextToIdentify() { + let encoder = Cu.createDocumentEncoder("text/plain"); + encoder.init(this.document, "text/plain", encoder.SkipInvisibleContent); + return encoder + .encodeToStringWithMaxLength(DOC_TEXT_TO_IDENTIFY_LENGTH) + .replaceAll("\r", "") + .replaceAll("\n", " "); + } + + /** + * @overrides JSWindowActorChild.prototype.handleEvent + * @param {{ type: string }} event + */ + handleEvent(event) { + ChromeUtils.addProfilerMarker( + "TranslationsChild", + null, + "Event: " + event.type + ); + switch (event.type) { + case "DOMContentLoaded": + this.innerWindowId = this.contentWindow.windowGlobalChild.innerWindowId; + this.maybeOfferTranslation().catch(error => lazy.console.log(error)); + break; + case "pagehide": + lazy.console.log( + "pagehide", + this.contentWindow.location, + this.#langTags + ); + this.reportDetectedLangTagsToParent(null); + break; + } + return undefined; + } + + /** + * This is used to conditionally add the translations button. + * @param {null | LangTags} langTags + */ + reportDetectedLangTagsToParent(langTags) { + this.sendAsyncMessage("Translations:ReportDetectedLangTags", { + langTags, + }); + } + + /** + * Returns the principal from the content window's origin. + * @returns {nsIPrincipal} + */ + getContentWindowPrincipal() { + return Services.scriptSecurityManager.createContentPrincipalFromOrigin( + this.contentWindow.location.origin + ); + } + + /** + * Only translate pages that match certain protocols, that way internal pages like + * about:* pages will not be translated. + */ + #isRestrictedPage() { + const { href } = this.contentWindow.location; + // Keep this logic up to date with TranslationsParent.isRestrictedPage. + return !( + href.startsWith("http://") || + href.startsWith("https://") || + href.startsWith("file:///") + ); + } + + /** + * Determine if the page should be translated by checking the App's languages and + * comparing it to the reported language of the page. Return the best translation fit + * (if available). + * + * @param {number} [translationsStart] + * @returns {Promise<LangTags>} + */ + async getLangTagsForTranslation(translationsStart = this.docShell.now()) { + if (this.#langTags) { + return this.#langTags; + } + + const langTags = { + docLangTag: null, + userLangTag: null, + isDocLangTagSupported: false, + }; + this.#langTags = langTags; + + if (this.#isRestrictedPage()) { + // The langTags are still blank here. + return langTags; + } + let languagePairs = await this.getLanguagePairs(); + + const determineIsDocLangTagSupported = docLangTag => + Boolean( + languagePairs.find(({ fromLang }) => fromLang === langTags.docLangTag) + ); + + // First try to get the langTag from the document's markup. + try { + const docLocale = new Intl.Locale(this.document.documentElement.lang); + langTags.docLangTag = docLocale.language; + langTags.isDocLangTagSupported = determineIsDocLangTagSupported( + docLocale.language + ); + } catch (error) {} + + // If the document's markup had no specified langTag, attempt + // to identify the page's language using the LanguageIdEngine. + if (!langTags.docLangTag) { + let languageIdEngine = await this.createLanguageIdEngine(); + let { langTag, confidence } = await languageIdEngine.identifyLanguage( + this.#getTextToIdentify() + ); + lazy.console.log( + `${langTag}(${confidence.toFixed(2)}) Detected Page Language` + ); + if (confidence >= DOC_LANGUAGE_DETECTION_THRESHOLD) { + langTags.docLangTag = langTag; + langTags.isDocLangTagSupported = + determineIsDocLangTagSupported(langTag); + } + } + + const preferredLanguages = await this.getPreferredLanguages(); + + if (!langTags.docLangTag) { + const message = "No valid language detected."; + ChromeUtils.addProfilerMarker( + "TranslationsChild", + { innerWindowId: this.innerWindowId }, + message + ); + lazy.console.log(message, this.contentWindow.location.href); + + const languagePairs = await this.getLanguagePairs(); + + // Attempt to find a good language to select for the user. + langTags.userLangTag = + preferredLanguages.find(langTag => langTag === languagePairs.toLang) ?? + null; + + return langTags; + } + + ChromeUtils.addProfilerMarker( + "TranslationsChild", + { innerWindowId: this.innerWindowId, startTime: translationsStart }, + "Time to determine langTags" + ); + + // This is a special case where we do not offer a translation if the main app language + // and the doc language match. The main app language should be the first preferred + // language. + if (preferredLanguages[0] === langTags.docLangTag) { + // The doc language and the main language match. + const message = + "The app and document languages match, so not translating."; + ChromeUtils.addProfilerMarker( + "TranslationsChild", + { innerWindowId: this.innerWindowId }, + message + ); + lazy.console.log(message, this.contentWindow.location.href); + // The docLangTag will be set, while the userLangTag will be null. + return langTags; + } + + // Attempt to find a matching language pair for a preferred language. + for (const preferredLangTag of preferredLanguages) { + if ( + translationsEngineCache.isInCache(langTags.docLangTag, preferredLangTag) + ) { + // There is no reason to look at the language pairs if the engine is already in + // the cache. + langTags.userLangTag = preferredLangTag; + break; + } + + if (!langTags.isDocLangTagSupported) { + if (languagePairs.some(({ toLang }) => toLang === preferredLangTag)) { + // Only match the "to" language, since the "from" is not supported. + langTags.userLangTag = preferredLangTag; + } + break; + } + + // Is there a direct language pair match? + if ( + languagePairs.some( + ({ fromLang, toLang }) => + fromLang === langTags.docLangTag && toLang === preferredLangTag + ) + ) { + // A match was found in one of the preferred languages. + langTags.userLangTag = preferredLangTag; + break; + } + + // Is there a pivot language match? + if ( + // Match doc -> pivot + languagePairs.some( + ({ fromLang, toLang }) => + fromLang === langTags.docLangTag && toLang === PIVOT_LANGUAGE + ) && + // Match pivot -> preferred language + languagePairs.some( + ({ fromLang, toLang }) => + fromLang === PIVOT_LANGUAGE && toLang === preferredLangTag + ) + ) { + langTags.userLangTag = preferredLangTag; + break; + } + } + + if (!langTags.userLangTag) { + // No language pairs match. + const message = `No matching translation pairs were found for translating from "${langTags.docLangTag}".`; + ChromeUtils.addProfilerMarker( + "TranslationsChild", + { innerWindowId: this.innerWindowId }, + message + ); + lazy.console.log(message, languagePairs); + } + + return langTags; + } + + /** + * Deduce the language tags on the page, and either: + * 1. Show an offer to translate. + * 2. Auto-translate. + * 3. Do nothing. + */ + async maybeOfferTranslation() { + const translationsStart = this.docShell.now(); + + const isSupported = await this.isTranslationsEngineSupported; + if (!isSupported) { + return; + } + + const langTags = await this.getLangTagsForTranslation(translationsStart); + + this.#langTags = langTags; + this.reportDetectedLangTagsToParent(langTags); + + if (langTags.docLangTag && langTags.userLangTag) { + const { maybeAutoTranslate, maybeNeverTranslate } = await this.sendQuery( + "Translations:GetTranslationConditions", + langTags + ); + if (maybeAutoTranslate && !maybeNeverTranslate) { + lazy.TranslationsTelemetry.onTranslate({ + fromLanguage: langTags.docLangTag, + toLanguage: langTags.userLangTag, + autoTranslate: maybeAutoTranslate, + }); + this.translatePage( + langTags.docLangTag, + langTags.userLangTag, + translationsStart + ); + } + } + } + + /** + * Lazily initialize this value. It doesn't change after being set. + * + * @type {Promise<boolean>} + */ + get isTranslationsEngineSupported() { + // Delete the getter and set the real value directly onto the TranslationsChild's + // prototype. This value never changes while a browser is open. + delete TranslationsChild.isTranslationsEngineSupported; + return (TranslationsChild.isTranslationsEngineSupported = this.sendQuery( + "Translations:GetIsTranslationsEngineSupported" + )); + } + + /** + * Load the translation engine and translate the page. + * + * @param {{fromLanguage: string, toLanguage: string}} langTags + * @param {number} [translationsStart] + * @returns {Promise<void>} + */ + async translatePage( + fromLanguage, + toLanguage, + translationsStart = this.docShell.now() + ) { + if (this.translatedDoc) { + lazy.console.warn("This page was already translated."); + return; + } + if (this.#isRestrictedPage()) { + lazy.console.warn("Attempting to translate a restricted page."); + return; + } + + try { + const engineLoadStart = this.docShell.now(); + // Create a function to get an engine. These engines are pretty heavy in terms + // of memory usage, so they will be destroyed when not in use, and attempt to + // be re-used when loading a new page. + this.#getTranslationsEngine = await translationsEngineCache.createGetter( + this, + fromLanguage, + toLanguage + ); + if (this.#isDestroyed) { + return; + } + + // Start loading the engine if it doesn't exist. + this.#getTranslationsEngine().then( + () => { + ChromeUtils.addProfilerMarker( + "TranslationsChild", + { innerWindowId: this.innerWindowId, startTime: engineLoadStart }, + "Load Translations Engine" + ); + }, + error => { + lazy.console.log("Failed to load the translations engine.", error); + } + ); + } catch (error) { + lazy.TranslationsTelemetry.onError(error); + lazy.console.log( + "Failed to load the translations engine", + error, + this.contentWindow.location.href + ); + this.sendAsyncMessage("Translations:FullPageTranslationFailed", { + reason: "engine-load-failure", + }); + return; + } + + // Ensure the translation engine loads correctly at least once before instantiating + // the TranslationsDocument. + try { + await this.#getTranslationsEngine(); + } catch (error) { + lazy.TranslationsTelemetry.onError(error); + this.sendAsyncMessage("Translations:FullPageTranslationFailed", { + reason: "engine-load-failure", + }); + return; + } + + this.translatedDoc = new lazy.TranslationsDocument( + this.document, + fromLanguage, + this.innerWindowId, + html => + this.#getTranslationsEngine().then(engine => + engine.translateHTML([html], this.innerWindowId) + ), + text => + this.#getTranslationsEngine().then(engine => + engine.translateText([text], this.innerWindowId) + ), + () => this.docShell.now() + ); + + lazy.console.log( + "Beginning to translate.", + this.contentWindow.location.href + ); + + this.sendAsyncMessage("Translations:EngineIsReady"); + + this.translatedDoc.addRootElement(this.document.querySelector("title")); + this.translatedDoc.addRootElement( + this.document.body, + true /* reportWordsInViewport */ + ); + + { + const startTime = this.docShell.now(); + this.translatedDoc.viewportTranslated.then(() => { + ChromeUtils.addProfilerMarker( + "TranslationsChild", + { innerWindowId: this.innerWindowId, startTime }, + "Viewport translations" + ); + ChromeUtils.addProfilerMarker( + "TranslationsChild", + { innerWindowId: this.innerWindowId, startTime: translationsStart }, + "Time to first translation" + ); + }); + } + } + + /** + * Receive a message from the parent. + * + * @param {{ name: string, data: any }} message + */ + receiveMessage({ name, data }) { + switch (name) { + case "Translations:TranslatePage": + const langTags = data ?? this.#langTags; + if (!langTags) { + lazy.console.warn( + "Attempting to translate a page, but no language tags were given." + ); + break; + } + lazy.TranslationsTelemetry.onTranslate({ + fromLanguage: langTags.fromLanguage, + toLanguage: langTags.toLanguage, + autoTranslate: false, + }); + this.translatePage(langTags.fromLanguage, langTags.toLanguage); + break; + case "Translations:GetLangTagsForTranslation": + return this.getLangTagsForTranslation(); + case "Translations:GetContentWindowPrincipal": + return this.getContentWindowPrincipal(); + default: + lazy.console.warn("Unknown message.", name); + } + return undefined; + } + + /** + * Get the list of languages and their display names, sorted by their display names. + * This is more expensive of a call than getLanguagePairs since the display names + * are looked up. + * + * @returns {Promise<Array<SupportedLanguages>>} + */ + getSupportedLanguages() { + return this.sendQuery("Translations:GetSupportedLanguages"); + } + + /** + * @param {string} language The BCP 47 language tag. + */ + hasAllFilesForLanguage(language) { + return this.sendQuery("Translations:HasAllFilesForLanguage", { + language, + }); + } + + /** + * @param {string} language The BCP 47 language tag. + */ + deleteLanguageFiles(language) { + return this.sendQuery("Translations:DeleteLanguageFiles", { + language, + }); + } + + /** + * @param {string} language The BCP 47 language tag. + */ + downloadLanguageFiles(language) { + return this.sendQuery("Translations:DownloadLanguageFiles", { + language, + }); + } + + /** + * Download all files from Remote Settings. + */ + downloadAllFiles() { + return this.sendQuery("Translations:DownloadAllFiles"); + } + + /** + * Delete all language files. + * @returns {Promise<string[]>} Returns a list of deleted record ids. + */ + deleteAllLanguageFiles() { + return this.sendQuery("Translations:DeleteAllLanguageFiles"); + } + + /** + * Get the language pairs that can be used for translations. This is cheaper than + * the getSupportedLanguages call, since the localized display names of the languages + * are not needed. + * + * @returns {Promise<Array<LanguagePair>>} + */ + getLanguagePairs() { + return this.sendQuery("Translations:GetLanguagePairs"); + } + + /** + * The ordered list of preferred BCP 47 language tags. + * + * 1. App languages + * 2. Web requested languages + * 3. OS languages + * + * @returns {Promise<string[]>} + */ + getPreferredLanguages() { + return this.sendQuery("Translations:GetPreferredLanguages"); + } + + /** + * Retrieve the payload for creating a LanguageIdEngine. + * + * @returns {Promise<LanguageIdEnginePayload>} + */ + async #getLanguageIdEnginePayload() { + return this.sendQuery("Translations:GetLanguageIdEnginePayload"); + } + + /** + * @param {string} fromLanguage + * @param {string} toLanguage + * @returns {TranslationsEnginePayload} + */ + async #getTranslationsEnginePayload(fromLanguage, toLanguage) { + return this.sendQuery("Translations:GetTranslationsEnginePayload", { + fromLanguage, + toLanguage, + }); + } + + /** + * Construct and initialize the LanguageIdEngine. + * + * @returns {LanguageIdEngine} + */ + async createLanguageIdEngine() { + const payload = await this.#getLanguageIdEnginePayload(); + const engine = new LanguageIdEngine({ + type: "initialize", + isLoggingEnabled: lazy.logLevel === "All", + ...payload, + }); + await engine.isReady; + return engine; + } + + /** + * Construct and initialize the Translations Engine. + * + * @param {string} fromLanguage + * @param {string} toLanguage + * @returns {TranslationsEngine | null} + */ + async createTranslationsEngine(fromLanguage, toLanguage) { + const startTime = this.docShell.now(); + const enginePayload = await this.#getTranslationsEnginePayload( + fromLanguage, + toLanguage + ); + + const engine = new TranslationsEngine( + fromLanguage, + toLanguage, + enginePayload, + this.innerWindowId + ); + + await engine.isReady; + + ChromeUtils.addProfilerMarker( + "TranslationsChild", + { innerWindowId: this.innerWindowId, startTime }, + `Translations engine loaded for "${fromLanguage}" to "${toLanguage}"` + ); + return engine; + } + + /** + * Override JSWindowActorChild.prototype.didDestroy. This is called by the actor + * manager when the actor was destroyed. + */ + async didDestroy() { + this.#isDestroyed = true; + const getTranslationsEngine = this.#getTranslationsEngine; + if (!getTranslationsEngine) { + return; + } + const engine = await getTranslationsEngine( + // Just get it from cache, don't create a new one. + true + ); + if (engine) { + // Discard the queue otherwise the worker will continue to translate. + engine.discardTranslationQueue(this.innerWindowId); + + // Keep it alive long enough for another page load. + translationsEngineCache.keepAlive(engine.fromLanguage, engine.toLanguage); + } + } +} diff --git a/toolkit/components/translations/actors/TranslationsParent.sys.mjs b/toolkit/components/translations/actors/TranslationsParent.sys.mjs new file mode 100644 index 0000000000..5b3953be50 --- /dev/null +++ b/toolkit/components/translations/actors/TranslationsParent.sys.mjs @@ -0,0 +1,1953 @@ +/* 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/. */ + +/** + * The pivot language is used to pivot between two different language translations + * when there is not a model available to translate directly between the two. In this + * case "en" is common between the various supported models. + * + * For instance given the following two models: + * "fr" -> "en" + * "en" -> "it" + * + * You can accomplish: + * "fr" -> "it" + * + * By doing: + * "fr" -> "en" -> "it" + */ +const PIVOT_LANGUAGE = "en"; + +const TRANSLATIONS_PERMISSION = "translations"; +const ALWAYS_TRANSLATE_LANGS_PREF = + "browser.translations.alwaysTranslateLanguages"; +const NEVER_TRANSLATE_LANGS_PREF = + "browser.translations.neverTranslateLanguages"; + +const lazy = {}; + +import { XPCOMUtils } from "resource://gre/modules/XPCOMUtils.sys.mjs"; + +ChromeUtils.defineESModuleGetters(lazy, { + RemoteSettings: "resource://services-settings/remote-settings.sys.mjs", + setTimeout: "resource://gre/modules/Timer.sys.mjs", +}); + +XPCOMUtils.defineLazyGetter(lazy, "console", () => { + return console.createInstance({ + maxLogLevelPref: "browser.translations.logLevel", + prefix: "Translations", + }); +}); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "translationsEnabledPref", + "browser.translations.enable" +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "autoTranslatePagePref", + "browser.translations.autoTranslate" +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "chaosErrorsPref", + "browser.translations.chaos.errors" +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "chaosTimeoutMSPref", + "browser.translations.chaos.timeoutMS" +); + +/** + * Returns the always-translate language tags as an array. + */ +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "alwaysTranslateLangTags", + ALWAYS_TRANSLATE_LANGS_PREF, + /* aDefaultPrefValue */ "", + /* onUpdate */ null, + /* aTransform */ rawLangTags => (rawLangTags ? rawLangTags.split(",") : []) +); + +/** + * Returns the never-translate language tags as an array. + */ +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "neverTranslateLangTags", + NEVER_TRANSLATE_LANGS_PREF, + /* aDefaultPrefValue */ "", + /* onUpdate */ null, + /* aTransform */ rawLangTags => (rawLangTags ? rawLangTags.split(",") : []) +); + +XPCOMUtils.defineLazyPreferenceGetter( + lazy, + "simulateUnsupportedEnginePref", + "browser.translations.simulateUnsupportedEngine" +); + +// At this time the signatures of the files are not being checked when they are being +// loaded from disk. This signature check involves hitting the network, and translations +// are explicitly an offline-capable feature. See Bug 1827265 for re-enabling this +// check. +const VERIFY_SIGNATURES_FROM_FS = false; + +/** + * @typedef {import("../translations").TranslationModelRecord} TranslationModelRecord + * @typedef {import("../translations").RemoteSettingsClient} RemoteSettingsClient + * @typedef {import("../translations").LanguageIdEngineMockedPayload} LanguageIdEngineMockedPayload + * @typedef {import("../translations").LanguageTranslationModelFiles} LanguageTranslationModelFiles + * @typedef {import("../translations").WasmRecord} WasmRecord + * @typedef {import("../translations").LangTags} LangTags + * @typedef {import("../translations").LanguagePair} LanguagePair + * @typedef {import("../translations").SupportedLanguages} SupportedLanguages + * @typedef {import("../translations").LanguageIdModelRecord} LanguageIdModelRecord + * @typedef {import("../translations").TranslationErrors} TranslationErrors + */ + +/** + * @typedef {Object} TranslationPair + * @prop {string} fromLanguage + * @prop {string} toLanguage + * @prop {string} [fromDisplayLanguage] + * @prop {string} [toDisplayLanguage] + */ + +/** + * The translations parent is used to orchestrate translations in Firefox. It can + * download the wasm translation engines, and the machine learning language models. + * + * See Bug 971044 for more details of planned work. + */ +export class TranslationsParent extends JSWindowActorParent { + /** + * Contains the state that would affect UI. Anytime this state is changed, a dispatch + * event is sent so that UI can react to it. The actor is inside of /toolkit and + * needs a way of notifying /browser code (or other users) of when the state changes. + * + * @type {TranslationsLanguageState} + */ + languageState; + + actorCreated() { + this.languageState = new TranslationsLanguageState(this); + + if (TranslationsParent.#translateOnPageReload) { + // The actor was recreated after a page reload, start the translation. + const { fromLanguage, toLanguage } = + TranslationsParent.#translateOnPageReload; + TranslationsParent.#translateOnPageReload = null; + + lazy.console.log( + `Translating on a page reload from "${fromLanguage}" to "${toLanguage}".` + ); + + this.translate(fromLanguage, toLanguage); + } + } + + /** + * The remote settings client that retrieves the language-identification model binary. + * + * @type {RemoteSettingsClient | null} + */ + static #languageIdModelsRemoteClient = null; + + /** + * A map of the TranslationModelRecord["id"] to the record of the model in Remote Settings. + * Used to coordinate the downloads. + * + * @type {Map<string, TranslationModelRecord>} + */ + #translationModelRecords = new Map(); + + /** + * The RemoteSettingsClient that downloads the translation models. + * + * @type {RemoteSettingsClient | null} + */ + static #translationModelsRemoteClient = null; + + /** + * The RemoteSettingsClient that downloads the wasm binaries. + * + * @type {RemoteSettingsClient | null} + */ + static #translationsWasmRemoteClient = null; + + /** + * If "browser.translations.autoTranslate" is set to "true" then the page will + * auto-translate. A user can restore the page to the original UI. This flag indicates + * that an auto-translate should be skipped. + */ + static #isPageRestoredForAutoTranslate = false; + + /** + * Allows the actor's behavior to be changed when the translations engine is mocked via + * a dummy RemoteSettingsClient. + * + * @type {bool} + */ + static #isTranslationsEngineMocked = false; + + /** + * The language identification engine can be mocked for testing + * by pre-defining this value. + * + * @type {string | null} + */ + static #mockedLangTag = null; + + /** + * The language identification engine can be mocked for testing + * by pre-defining this value. + * + * @type {number | null} + */ + static #mockedLanguageIdConfidence = null; + + /** + * @type {null | Promise<boolean>} + */ + static #isTranslationsEngineSupported = null; + + /** + * When reloading the page, store the translation pair that needs translating. + * + * @type {null | TranslationPair} + */ + static #translateOnPageReload = null; + + /** + * An ordered list of preferred languages based on: + * 1. App languages + * 2. Web requested languages + * 3. OS language + * + * @type {null | string[]} + */ + static #preferredLanguages = null; + static #observingLanguages = false; + + // On a fast connection, 10 concurrent downloads were measured to be the fastest when + // downloading all of the language files. + static MAX_CONCURRENT_DOWNLOADS = 10; + static MAX_DOWNLOAD_RETRIES = 3; + + /** + * Detect if Wasm SIMD is supported, and cache the value. It's better to check + * for support before downloading large binary blobs to a user who can't even + * use the feature. This function also respects mocks and simulating unsupported + * engines. + * + * @type {Promise<boolean>} + */ + static getIsTranslationsEngineSupported() { + if (lazy.simulateUnsupportedEnginePref) { + // Use the non-lazy console.log so that the user is always informed as to why + // the translations engine is not working. + console.log( + "Translations: The translations engine is disabled through the pref " + + '"browser.translations.simulateUnsupportedEngine".' + ); + + // The user is manually testing unsupported engines. + return Promise.resolve(false); + } + + if (TranslationsParent.#isTranslationsEngineMocked) { + // A mocked translations engine is always supported. + return Promise.resolve(true); + } + + if (TranslationsParent.#isTranslationsEngineSupported === null) { + TranslationsParent.#isTranslationsEngineSupported = detectSimdSupport(); + + TranslationsParent.#isTranslationsEngineSupported.then( + isSupported => () => { + // Use the non-lazy console.log so that the user is always informed as to why + // the translations engine is not working. + if (!isSupported) { + console.log( + "Translations: The translations engine is not supported on your device as " + + "it does not support Wasm SIMD operations." + ); + } + } + ); + } + + return TranslationsParent.#isTranslationsEngineSupported; + } + + /** + * Only translate pages that match certain protocols, that way internal pages like + * about:* pages will not be translated. + * @param {string} url + */ + static isRestrictedPage(url) { + // Keep this logic up to date with TranslationsChild.prototype.#isRestrictedPage. + return !( + url.startsWith("http://") || + url.startsWith("https://") || + url.startsWith("file:///") + ); + } + + static #resetPreferredLanguages() { + TranslationsParent.#preferredLanguages = null; + TranslationsParent.getPreferredLanguages(); + } + + static async observe(_subject, topic, _data) { + switch (topic) { + case "nsPref:changed": + case "intl:app-locales-changed": { + this.#resetPreferredLanguages(); + break; + } + default: + throw new Error("Unknown observer event", topic); + } + } + + /** + * Provide a way for tests to override the system locales. + * @type {null | string[]} + */ + mockedSystemLocales = null; + + /** + * An ordered list of preferred languages based on: + * + * 1. App languages + * 2. Web requested languages + * 3. OS language + * + * @returns {string[]} + */ + static getPreferredLanguages() { + if (TranslationsParent.#preferredLanguages) { + return TranslationsParent.#preferredLanguages; + } + + if (!TranslationsParent.#observingLanguages) { + Services.obs.addObserver( + TranslationsParent.#resetPreferredLanguages, + "intl:app-locales-changed" + ); + Services.prefs.addObserver( + "intl.accept_languages", + TranslationsParent.#resetPreferredLanguages + ); + TranslationsParent.#observingLanguages = true; + } + + // The "Accept-Language" values that the localizer or user has indicated for + // the preferences for the web. https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Accept-Language + // Note that this preference often falls back ultimately to English, even if the + // user doesn't actually speak English, or to other languages they do not speak. + // However, this preference will be used as an indication that a user may prefer + // this language. + const webLanguages = Services.prefs + .getComplexValue("intl.accept_languages", Ci.nsIPrefLocalizedString) + .data.split(/\s*,\s*/g); + + // The system language could also be a good option for a language to offer the user. + const osPrefs = Cc["@mozilla.org/intl/ospreferences;1"].getService( + Ci.mozIOSPreferences + ); + const systemLocales = this.mockedSystemLocales ?? osPrefs.systemLocales; + + // Combine the locales together. + const preferredLocales = new Set([ + ...Services.locale.appLocalesAsBCP47, + ...webLanguages, + ...systemLocales, + ]); + + // Attempt to convert the locales to lang tags. Do not completely trust the + // values coming from preferences and the OS to have been validated as correct + // BCP 47 locale identifiers. + const langTags = new Set(); + for (const locale of preferredLocales) { + try { + langTags.add(new Intl.Locale(locale).language); + } catch (_) { + // The locale was invalid, discard it. + } + } + + // Convert the Set to an array to indicate that it is an ordered listing of languages. + TranslationsParent.#preferredLanguages = [...langTags]; + + return TranslationsParent.#preferredLanguages; + } + + async receiveMessage({ name, data }) { + switch (name) { + case "Translations:GetTranslationsEnginePayload": { + const { fromLanguage, toLanguage } = data; + const bergamotWasmArrayBuffer = this.#getBergamotWasmArrayBuffer(); + + let files = await this.getLanguageTranslationModelFiles( + fromLanguage, + toLanguage + ); + + let languageModelFiles; + if (files) { + languageModelFiles = [files]; + } else { + // No matching model was found, try to pivot between English. + const [files1, files2] = await Promise.all([ + this.getLanguageTranslationModelFiles(fromLanguage, PIVOT_LANGUAGE), + this.getLanguageTranslationModelFiles(PIVOT_LANGUAGE, toLanguage), + ]); + if (!files1 || !files2) { + throw new Error( + `No language models were found for ${fromLanguage} to ${toLanguage}` + ); + } + languageModelFiles = [files1, files2]; + } + + return { + bergamotWasmArrayBuffer: await bergamotWasmArrayBuffer, + languageModelFiles, + isMocked: TranslationsParent.#isTranslationsEngineMocked, + }; + } + case "Translations:GetLanguageIdEnginePayload": { + const [modelBuffer, wasmBuffer] = await Promise.all([ + this.#getLanguageIdModelArrayBuffer(), + this.#getLanguageIdWasmArrayBuffer(), + ]); + return { + modelBuffer, + wasmBuffer, + mockedConfidence: TranslationsParent.#mockedLanguageIdConfidence, + mockedLangTag: TranslationsParent.#mockedLangTag, + }; + } + case "Translations:GetIsTranslationsEngineMocked": { + return TranslationsParent.#isTranslationsEngineMocked; + } + case "Translations:GetIsTranslationsEngineSupported": { + return TranslationsParent.getIsTranslationsEngineSupported(); + } + case "Translations:FullPageTranslationFailed": { + this.languageState.error = data.reason; + break; + } + case "Translations:GetSupportedLanguages": { + return this.getSupportedLanguages(); + } + case "Translations:HasAllFilesForLanguage": { + return this.hasAllFilesForLanguage(data.language); + } + case "Translations:DownloadLanguageFiles": { + return this.downloadLanguageFiles(data.language); + } + case "Translations:DownloadAllFiles": { + return this.downloadAllFiles(); + } + case "Translations:DeleteAllLanguageFiles": { + return this.deleteAllLanguageFiles(); + } + case "Translations:DeleteLanguageFiles": { + return this.deleteLanguageFiles(data.language); + } + case "Translations:GetLanguagePairs": { + return this.getLanguagePairs(); + } + case "Translations:GetPreferredLanguages": { + return TranslationsParent.getPreferredLanguages(); + } + case "Translations:EngineIsReady": { + this.isEngineReady = true; + this.languageState.isEngineReady = true; + break; + } + case "Translations:GetTranslationConditions": { + const maybeAutoTranslate = TranslationsParent.#maybeAutoTranslate( + data.docLangTag + ); + const maybeNeverTranslate = + TranslationsParent.shouldNeverTranslateLanguage(data.docLangTag) || + (await this.shouldNeverTranslateSite()); + + if (maybeAutoTranslate && !maybeNeverTranslate) { + this.languageState.requestedTranslationPair = { + fromLanguage: data.docLangTag, + toLanguage: data.userLangTag, + }; + } + + return { maybeAutoTranslate, maybeNeverTranslate }; + } + case "Translations:ReportDetectedLangTags": { + this.languageState.detectedLanguages = data.langTags; + return undefined; + } + } + return undefined; + } + + /** + * Returns true if translations should auto-translate from the given + * language, otherwise returns false. + * + * @param {string} langTag - A BCP-47 language tag + * @returns {boolean} + */ + static #maybeAutoTranslate(langTag) { + if ( + // The user has not marked this language as always translate. + !TranslationsParent.shouldAlwaysTranslateLanguage(langTag) && + // The pref to always auto-translate is off. + !lazy.autoTranslatePagePref + ) { + return false; + } + + if (TranslationsParent.#isPageRestoredForAutoTranslate) { + // The user clicked the restore button. Respect it for one page load. + TranslationsParent.#isPageRestoredForAutoTranslate = false; + + // Skip this auto-translation. + return false; + } + + // The page can be auto-translated + return true; + } + + /** + * Retrieves the language-identification model binary from remote settings. + * + * @returns {Promise<ArrayBuffer>} + */ + async #getLanguageIdModelArrayBuffer() { + lazy.console.log("Getting language-identification model array buffer."); + const now = Date.now(); + const client = this.#getLanguageIdModelRemoteClient(); + + /** @type {LanguageIdModelRecord[]} */ + let modelRecords = await TranslationsParent.getMaxVersionRecords(client); + + if (modelRecords.length === 0) { + throw new Error( + "Unable to get language-identification model record from remote settings" + ); + } + + if (modelRecords.length > 1) { + TranslationsParent.reportError( + new Error( + "Expected the language-identification model collection to have only 1 record." + ), + modelRecords + ); + } + const [modelRecord] = modelRecords; + + await chaosMode(1 / 3); + + /** @type {{buffer: ArrayBuffer}} */ + const { buffer } = await client.attachments.download(modelRecord); + + const duration = (Date.now() - now) / 1000; + lazy.console.log( + `Remote language-identification model loaded in ${duration} seconds.` + ); + + return buffer; + } + + /** + * Initializes the RemoteSettingsClient for the language-identification model binary. + * + * @returns {RemoteSettingsClient} + */ + #getLanguageIdModelRemoteClient() { + if (TranslationsParent.#languageIdModelsRemoteClient) { + return TranslationsParent.#languageIdModelsRemoteClient; + } + + /** @type {RemoteSettingsClient} */ + const client = lazy.RemoteSettings("translations-identification-models"); + + TranslationsParent.#languageIdModelsRemoteClient = client; + return client; + } + + /** + * Retrieves the language-identification wasm binary from remote settings. + * + * @returns {Promise<ArrayBuffer>} + */ + async #getLanguageIdWasmArrayBuffer() { + const start = Date.now(); + const client = this.#getTranslationsWasmRemoteClient(); + + // Load the wasm binary from remote settings, if it hasn't been already. + lazy.console.log(`Getting remote language-identification wasm binary.`); + + /** @type {WasmRecord[]} */ + let wasmRecords = await TranslationsParent.getMaxVersionRecords(client, { + filters: { name: "fasttext-wasm" }, + }); + + if (wasmRecords.length === 0) { + // The remote settings client provides an empty list of records when there is + // an error. + throw new Error( + 'Unable to get "fasttext-wasm" language-identification wasm binary from Remote Settings.' + ); + } + + if (wasmRecords.length > 1) { + TranslationsParent.reportError( + new Error( + 'Expected the "fasttext-wasm" language-identification wasm collection to only have 1 record.' + ), + wasmRecords + ); + } + + // Unlike the models, greedily download the wasm. It will pull it from a locale + // cache on disk if it's already been downloaded. Do not retain a copy, as + // this will be running in the parent process. It's not worth holding onto + // this much memory, so reload it every time it is needed. + + await chaosMode(1 / 3); + + /** @type {{buffer: ArrayBuffer}} */ + const { buffer } = await client.attachments.download(wasmRecords[0]); + + const duration = (Date.now() - start) / 1000; + lazy.console.log( + `Remote language-identification wasm binary loaded in ${duration} seconds.` + ); + + return buffer; + } + + /** + * Creates a lookup key that is unique to each fromLanguage-toLanguage pair. + * + * @param {string} fromLanguage + * @param {string} toLanguage + * @returns {string} + */ + static languagePairKey(fromLanguage, toLanguage) { + return `${fromLanguage},${toLanguage}`; + } + + /** + * Get the list of translation pairs supported by the translations engine. + * + * @returns {Promise<Array<LanguagePair>>} + */ + async getLanguagePairs() { + const records = await this.#getTranslationModelRecords(); + const languagePairMap = new Map(); + + for (const { fromLang, toLang, version } of records.values()) { + const isBeta = Services.vc.compare(version, "1.0") < 0; + const key = TranslationsParent.languagePairKey(fromLang, toLang); + if (!languagePairMap.has(key)) { + languagePairMap.set(key, { fromLang, toLang, isBeta }); + } + } + + return Array.from(languagePairMap.values()); + } + + /** + * Returns all of the information needed to render dropdowns for translation + * language selection. + * + * @returns {Promise<SupportedLanguages>} + */ + async getSupportedLanguages() { + const languagePairs = await this.getLanguagePairs(); + + /** @type {Map<string, boolean>} */ + const fromLanguages = new Map(); + /** @type {Map<string, boolean>} */ + const toLanguages = new Map(); + + for (const { fromLang, toLang, isBeta } of languagePairs) { + // [BetaLanguage, BetaLanguage] => isBeta == true, + // [BetaLanguage, NonBetaLanguage] => isBeta == true, + // [NonBetaLanguage, BetaLanguage] => isBeta == true, + // [NonBetaLanguage, NonBetaLanguage] => isBeta == false, + if (isBeta) { + // If these languages are part of a beta languagePair, at least one of them is a beta language + // but the other may not be, so only tentatively mark them as beta if there is no entry. + if (!fromLanguages.has(fromLang)) { + fromLanguages.set(fromLang, isBeta); + } + if (!toLanguages.has(toLang)) { + toLanguages.set(toLang, isBeta); + } + } else { + // If these languages are part of a non-beta languagePair, then they are both + // guaranteed to be non-beta languages. Idempotently overwrite any previous entry. + fromLanguages.set(fromLang, isBeta); + toLanguages.set(toLang, isBeta); + } + } + + // Build a map of the langTag to the display name. + /** @type {Map<string, string>} */ + const displayNames = new Map(); + { + const dn = new Services.intl.DisplayNames(undefined, { + type: "language", + }); + + for (const langTagSet of [fromLanguages, toLanguages]) { + for (const langTag of langTagSet.keys()) { + if (displayNames.has(langTag)) { + continue; + } + displayNames.set(langTag, dn.of(langTag)); + } + } + } + + const addDisplayName = ([langTag, isBeta]) => ({ + langTag, + isBeta, + displayName: displayNames.get(langTag), + }); + + const sort = (a, b) => a.displayName.localeCompare(b.displayName); + + return { + languagePairs, + fromLanguages: Array.from(fromLanguages.entries()) + .map(addDisplayName) + .sort(sort), + toLanguages: Array.from(toLanguages.entries()) + .map(addDisplayName) + .sort(sort), + }; + } + + /** + * Lazily initializes the RemoteSettingsClient for the language models. + * + * @returns {RemoteSettingsClient} + */ + #getTranslationModelsRemoteClient() { + if (TranslationsParent.#translationModelsRemoteClient) { + return TranslationsParent.#translationModelsRemoteClient; + } + + /** @type {RemoteSettingsClient} */ + const client = lazy.RemoteSettings("translations-models"); + TranslationsParent.#translationModelsRemoteClient = client; + + client.on("sync", async ({ data: { created, updated, deleted } }) => { + // Language model attachments will only be downloaded when they are used. + lazy.console.log( + `Remote Settings "sync" event for remote language models `, + { + created, + updated, + deleted, + } + ); + + // Remove all the deleted records. + for (const record of deleted) { + await client.attachments.deleteDownloaded(record); + this.#translationModelRecords.delete(record.id); + } + + // Pre-emptively remove the old downloads, and set the new updated record. + for (const { old: oldRecord, new: newRecord } of updated) { + await client.attachments.deleteDownloaded(oldRecord); + // The language pairs should be the same on the update, but use the old + // record just in case. + this.#translationModelRecords.delete(oldRecord.id); + this.#translationModelRecords.set(newRecord.id, newRecord); + } + + // Add the new records, but don't download any attachments. + for (const record of created) { + this.#translationModelRecords.set(record.id, record); + } + }); + + return client; + } + + /** + * Retrieves the maximum version of each record in the RemoteSettingsClient. + * + * If the client contains two different-version copies of the same record (e.g. 1.0 and 1.1) + * then only the 1.1-version record will be returned in the resulting collection. + * + * @param {RemoteSettingsClient} remoteSettingsClient + * @param {Object} [options] + * @param {Object} [options.filters={}] + * The filters to apply when retrieving the records from RemoteSettings. + * Filters should correspond to properties on the RemoteSettings records themselves. + * For example, A filter to retrieve only records with a `fromLang` value of "en" and a `toLang` value of "es": + * { filters: { fromLang: "en", toLang: "es" } } + * @param {Function} [options.lookupKey=(record => record.name)] + * The function to use to extract a lookup key from each record. + * This function should take a record as input and return a string that represents the lookup key for the record. + * For most record types, the name (default) is sufficient, however if a collection contains records with + * non-unique name values, it may be necessary to provide an alternative function here. + * @returns {Array<TranslationModelRecord | LanguageIdModelRecord | WasmRecord>} + */ + static async getMaxVersionRecords( + remoteSettingsClient, + { filters = {}, lookupKey = record => record.name } = {} + ) { + try { + await chaosMode(1 / 4); + } catch (_error) { + // Simulate an error by providing empty records. + return []; + } + const retrievedRecords = await remoteSettingsClient.get({ + // Pull the records from the network. + syncIfEmpty: true, + // Don't verify the signature if the client is mocked. + verifySignature: VERIFY_SIGNATURES_FROM_FS, + // Apply any filters for retrieving the records. + filters, + }); + + // Create a mapping to only the max version of each record discriminated by + // the result of the lookupKey() function. + const maxVersionRecordMap = retrievedRecords.reduce((records, record) => { + const key = lookupKey(record); + const existing = records.get(key); + if ( + !existing || + // existing version less than record version + Services.vc.compare(existing.version, record.version) < 0 + ) { + records.set(key, record); + } + return records; + }, new Map()); + + return Array.from(maxVersionRecordMap.values()); + } + + /** + * Lazily initializes the model records, and returns the cached ones if they + * were already retrieved. The key of the returned `Map` is the record id. + * + * @returns {Promise<Map<string, TranslationModelRecord>>} + */ + async #getTranslationModelRecords() { + if (this.#translationModelRecords.size > 0) { + return this.#translationModelRecords; + } + + const now = Date.now(); + const client = this.#getTranslationModelsRemoteClient(); + + // Load the models. If no data is present, then there will be an initial sync. + // Rely on Remote Settings for the syncing strategy for receiving updates. + lazy.console.log(`Getting remote language models.`); + + /** @type {TranslationModelRecord[]} */ + const translationModelRecords = + await TranslationsParent.getMaxVersionRecords(client, { + // Names in this collection are not unique, so we are appending the languagePairKey + // to guarantee uniqueness. + lookupKey: record => + `${record.name}${TranslationsParent.languagePairKey( + record.fromLang, + record.toLang + )}`, + }); + + if (translationModelRecords.length === 0) { + throw new Error("Unable to retrieve the translation models."); + } + + for (const record of TranslationsParent.ensureLanguagePairsHavePivots( + translationModelRecords + )) { + this.#translationModelRecords.set(record.id, record); + } + + const duration = (Date.now() - now) / 1000; + lazy.console.log( + `Remote language models loaded in ${duration} seconds.`, + this.#translationModelRecords + ); + + return this.#translationModelRecords; + } + + /** + * This implementation assumes that every language pair has access to the + * pivot language. If any languages are added without a pivot language, or the + * pivot language is changed, then this implementation will need a more complicated + * language solver. This means that any UI pickers would need to be updated, and + * the pivot language selection would need a solver. + * + * @param {TranslationModelRecord[] | LanguagePair[]} records + */ + static ensureLanguagePairsHavePivots(records) { + // lang -> pivot + const hasToPivot = new Set(); + // pivot -> en + const hasFromPivot = new Set(); + + const fromLangs = new Set(); + const toLangs = new Set(); + + for (const { fromLang, toLang } of records) { + fromLangs.add(fromLang); + toLangs.add(toLang); + + if (toLang === PIVOT_LANGUAGE) { + // lang -> pivot + hasToPivot.add(fromLang); + } + if (fromLang === PIVOT_LANGUAGE) { + // pivot -> en + hasFromPivot.add(toLang); + } + } + + const fromLangsToRemove = new Set(); + const toLangsToRemove = new Set(); + + for (const lang of fromLangs) { + if (lang === PIVOT_LANGUAGE) { + continue; + } + // Check for "lang -> pivot" + if (!hasToPivot.has(lang)) { + TranslationsParent.reportError( + new Error( + `The "from" language model "${lang}" is being discarded as it doesn't have a pivot language.` + ) + ); + fromLangsToRemove.add(lang); + } + } + + for (const lang of toLangs) { + if (lang === PIVOT_LANGUAGE) { + continue; + } + // Check for "pivot -> lang" + if (!hasFromPivot.has(lang)) { + TranslationsParent.reportError( + new Error( + `The "to" language model "${lang}" is being discarded as it doesn't have a pivot language.` + ) + ); + toLangsToRemove.add(lang); + } + } + + const after = records.filter(record => { + if (fromLangsToRemove.has(record.fromLang)) { + return false; + } + if (toLangsToRemove.has(record.toLang)) { + return false; + } + return true; + }); + return after; + } + + /** + * Lazily initializes the RemoteSettingsClient for the downloaded wasm binary data. + * + * @returns {RemoteSettingsClient} + */ + #getTranslationsWasmRemoteClient() { + if (TranslationsParent.#translationsWasmRemoteClient) { + return TranslationsParent.#translationsWasmRemoteClient; + } + + /** @type {RemoteSettingsClient} */ + const client = lazy.RemoteSettings("translations-wasm"); + + TranslationsParent.#translationsWasmRemoteClient = client; + + client.on("sync", async ({ data: { created, updated, deleted } }) => { + lazy.console.log(`"sync" event for remote bergamot wasm `, { + created, + updated, + deleted, + }); + + // Remove all the deleted records. + for (const record of deleted) { + await client.attachments.deleteDownloaded(record); + } + + // Remove any updated records, and download the new ones. + for (const { old: oldRecord } of updated) { + await client.attachments.deleteDownloaded(oldRecord); + } + + // Do nothing for the created records. + }); + + return client; + } + + /** + * Bergamot is the translation engine that has been compiled to wasm. It is shipped + * to the user via Remote Settings. + * + * https://github.com/mozilla/bergamot-translator/ + */ + /** + * @returns {Promise<ArrayBuffer>} + */ + async #getBergamotWasmArrayBuffer() { + const start = Date.now(); + const client = this.#getTranslationsWasmRemoteClient(); + + // Load the wasm binary from remote settings, if it hasn't been already. + lazy.console.log(`Getting remote bergamot-translator wasm records.`); + + /** @type {WasmRecord[]} */ + const wasmRecords = await TranslationsParent.getMaxVersionRecords(client, { + filters: { name: "bergamot-translator" }, + }); + + if (wasmRecords.length === 0) { + // The remote settings client provides an empty list of records when there is + // an error. + throw new Error( + "Unable to get the bergamot translator from Remote Settings." + ); + } + + if (wasmRecords.length > 1) { + TranslationsParent.reportError( + new Error("Expected the bergamot-translator to only have 1 record."), + wasmRecords + ); + } + + // Unlike the models, greedily download the wasm. It will pull it from a locale + // cache on disk if it's already been downloaded. Do not retain a copy, as + // this will be running in the parent process. It's not worth holding onto + // this much memory, so reload it every time it is needed. + + await chaosModeError(1 / 3); + + /** @type {{buffer: ArrayBuffer}} */ + const { buffer } = await client.attachments.download(wasmRecords[0]); + + const duration = Date.now() - start; + lazy.console.log( + `"bergamot-translator" wasm binary loaded in ${duration / 1000} seconds` + ); + + return buffer; + } + + /** + * Deletes language files that match a language. + * + * @param {string} requestedLanguage The BCP 47 language tag. + */ + async deleteLanguageFiles(language) { + const client = this.#getTranslationModelsRemoteClient(); + const isForDeletion = true; + return Promise.all( + Array.from( + await this.getRecordsForTranslatingToAndFromAppLanguage( + language, + isForDeletion + ) + ).map(record => { + lazy.console.log("Deleting record", record); + return client.attachments.deleteDownloaded(record); + }) + ); + } + + /** + * Download language files that match a language. + * + * @param {string} requestedLanguage The BCP 47 language tag. + */ + async downloadLanguageFiles(language) { + const client = this.#getTranslationModelsRemoteClient(); + + const queue = []; + + for (const record of await this.getRecordsForTranslatingToAndFromAppLanguage( + language + )) { + const download = () => { + lazy.console.log("Downloading record", record.name, record.id); + return client.attachments.download(record); + }; + queue.push({ download }); + } + + return downloadManager(queue); + } + + /** + * Download all files used for translations. + */ + async downloadAllFiles() { + const client = this.#getTranslationModelsRemoteClient(); + + const queue = []; + + for (const [recordId, record] of await this.#getTranslationModelRecords()) { + queue.push({ + onSuccess: () => { + this.sendQuery("Translations:DownloadedLanguageFile", { recordId }); + }, + // The download may be attempted multiple times. + onFailure: () => { + this.sendQuery("Translations:DownloadLanguageFileError", { + recordId, + }); + }, + download: () => client.attachments.download(record), + }); + } + + queue.push({ download: () => this.#getBergamotWasmArrayBuffer() }); + queue.push({ download: () => this.#getLanguageIdModelArrayBuffer() }); + queue.push({ download: () => this.#getLanguageIdWasmArrayBuffer() }); + + return downloadManager(queue); + } + + /** + * Delete all language model files. + * @returns {Promise<string[]>} A list of record IDs. + */ + async deleteAllLanguageFiles() { + const client = this.#getTranslationModelsRemoteClient(); + await chaosMode(); + await client.attachments.deleteAll(); + return [...(await this.#getTranslationModelRecords()).keys()]; + } + + /** + * Only returns true if all language files are present for a requested language. + * It's possible only half the files exist for a pivot translation into another + * language, or there was a download error, and we're still missing some files. + * + * @param {string} requestedLanguage The BCP 47 language tag. + */ + async hasAllFilesForLanguage(requestedLanguage) { + const client = this.#getTranslationModelsRemoteClient(); + for (const record of await this.getRecordsForTranslatingToAndFromAppLanguage( + requestedLanguage, + true + )) { + if (!(await client.attachments.isDownloaded(record))) { + return false; + } + } + + return true; + } + + /** + * Get the necessary files for translating to and from the app language and a + * requested language. This may require the files for a pivot language translation + * if there is no language model for a direct translation. + * + * @param {string} requestedLanguage The BCP 47 language tag. + * @param {boolean} isForDeletion - Return a more restrictive set of languages, as + * these files are marked for deletion. We don't want to remove + * files that are needed for some other language's pivot translation. + * @returns {Set<TranslationModelRecord>} + */ + async getRecordsForTranslatingToAndFromAppLanguage( + requestedLanguage, + isForDeletion = false + ) { + const records = await this.#getTranslationModelRecords(); + const appLanguage = new Intl.Locale(Services.locale.appLocaleAsBCP47) + .language; + + let matchedRecords = new Set(); + + if (requestedLanguage === appLanguage) { + // There are no records if the requested language and app language are the same. + return matchedRecords; + } + + const addLanguagePair = (fromLang, toLang) => { + let matchFound = false; + for (const record of records.values()) { + if (record.fromLang === fromLang && record.toLang === toLang) { + matchedRecords.add(record); + matchFound = true; + } + } + return matchFound; + }; + + if ( + // Is there a direct translation? + !addLanguagePair(requestedLanguage, appLanguage) + ) { + // This is no direct translation, get the pivot files. + addLanguagePair(requestedLanguage, PIVOT_LANGUAGE); + // These files may be required for other pivot translations, so don't remove + // them if we are deleting records. + if (!isForDeletion) { + addLanguagePair(PIVOT_LANGUAGE, appLanguage); + } + } + + if ( + // Is there a direct translation? + !addLanguagePair(appLanguage, requestedLanguage) + ) { + // This is no direct translation, get the pivot files. + addLanguagePair(PIVOT_LANGUAGE, requestedLanguage); + // These files may be required for other pivot translations, so don't remove + // them if we are deleting records. + if (!isForDeletion) { + addLanguagePair(appLanguage, PIVOT_LANGUAGE); + } + } + + return matchedRecords; + } + + /** + * Gets the language model files in an array buffer by downloading attachments from + * Remote Settings, or retrieving them from the local cache. Each translation + * requires multiple files. + * + * Results are only returned if the model is found. + * + * @param {string} fromLanguage + * @param {string} toLanguage + * @param {boolean} withQualityEstimation + * @returns {null | LanguageTranslationModelFiles} + */ + async getLanguageTranslationModelFiles( + fromLanguage, + toLanguage, + withQualityEstimation = false + ) { + const client = this.#getTranslationModelsRemoteClient(); + + lazy.console.log( + `Beginning model downloads: "${fromLanguage}" to "${toLanguage}"` + ); + + const records = [...(await this.#getTranslationModelRecords()).values()]; + + /** @type {LanguageTranslationModelFiles} */ + let results; + + // Use Promise.all to download (or retrieve from cache) the model files in parallel. + await Promise.all( + records.map(async record => { + if (record.fileType === "qualityModel" && !withQualityEstimation) { + // Do not include the quality models if they aren't needed. + return; + } + + if (record.fromLang !== fromLanguage || record.toLang !== toLanguage) { + // Only use models that match. + return; + } + + if (!results) { + results = {}; + } + + const start = Date.now(); + + // Download or retrieve from the local cache: + + await chaosMode(1 / 3); + + /** @type {{buffer: ArrayBuffer }} */ + const { buffer } = await client.attachments.download(record); + + results[record.fileType] = { + buffer, + record, + }; + + const duration = Date.now() - start; + lazy.console.log( + `Translation model fetched in ${duration / 1000} seconds:`, + record.fromLang, + record.toLang, + record.fileType + ); + }) + ); + + if (!results) { + // No model files were found, pivoting will be required. + return null; + } + + // Validate that all of the files we expected were actually available and + // downloaded. + + if (!results.model) { + throw new Error( + `No model file was found for "${fromLanguage}" to "${toLanguage}."` + ); + } + + if (!results.lex) { + throw new Error( + `No lex file was found for "${fromLanguage}" to "${toLanguage}."` + ); + } + + if (withQualityEstimation && !results.qualityModel) { + throw new Error( + `No quality file was found for "${fromLanguage}" to "${toLanguage}."` + ); + } + + if (results.vocab) { + if (results.srcvocab) { + throw new Error( + `A srcvocab and vocab file were both included for "${fromLanguage}" to "${toLanguage}." Only one is needed.` + ); + } + if (results.trgvocab) { + throw new Error( + `A trgvocab and vocab file were both included for "${fromLanguage}" to "${toLanguage}." Only one is needed.` + ); + } + } else if (!results.srcvocab || !results.srcvocab) { + throw new Error( + `No vocab files were provided for "${fromLanguage}" to "${toLanguage}."` + ); + } + + return results; + } + + /** + * For testing purposes, allow the Translations Engine to be mocked. If called + * with `null` the mock is removed. + * + * @param {null | RemoteSettingsClient} [translationModelsRemoteClient] + * @param {null | RemoteSettingsClient} [translationsWasmRemoteClient] + */ + static mockTranslationsEngine( + translationModelsRemoteClient, + translationsWasmRemoteClient + ) { + lazy.console.log("Mocking RemoteSettings for the translations engine."); + TranslationsParent.#translationModelsRemoteClient = + translationModelsRemoteClient; + TranslationsParent.#translationsWasmRemoteClient = + translationsWasmRemoteClient; + TranslationsParent.#isTranslationsEngineMocked = true; + } + + /** + * Remove the mocks. + */ + static unmockTranslationsEngine() { + lazy.console.log( + "Removing RemoteSettings mock for the translations engine." + ); + TranslationsParent.#translationModelsRemoteClient = null; + TranslationsParent.#translationsWasmRemoteClient = null; + TranslationsParent.#isTranslationsEngineMocked = false; + } + + /** + * For testing purposes, allow the LanguageIdEngine to be mocked. If called + * with `null` in each argument, the mock is removed. + * + * @param {string} langTag - The BCP 47 language tag. + * @param {number} confidence - The confidence score of the detected language. + * @param {RemoteSettingsClient} client + */ + static mockLanguageIdentification(langTag, confidence, client) { + lazy.console.log("Mocking language identification.", { + langTag, + confidence, + }); + TranslationsParent.#mockedLangTag = langTag; + TranslationsParent.#mockedLanguageIdConfidence = confidence; + TranslationsParent.#languageIdModelsRemoteClient = client; + } + + /** + * Remove the mocks + */ + static unmockLanguageIdentification() { + lazy.console.log("Removing language identification mock."); + TranslationsParent.#mockedLangTag = null; + TranslationsParent.#mockedLanguageIdConfidence = null; + TranslationsParent.#languageIdModelsRemoteClient = null; + } + /** + * Report an error. Having this as a method allows tests to check that an error + * was properly reported. + * @param {Error} error - Providing an Error object makes sure the stack is properly + * reported. + * @param {any[]} args - Any args to pass on to console.error. + */ + static reportError(error, ...args) { + lazy.console.log(error, ...args); + } + + /** + * @param {string} fromLanguage + * @param {string} toLanguage + */ + translate(fromLanguage, toLanguage) { + if (this.languageState.requestedTranslationPair) { + // This page has already been translated, restore it and translate it + // again once the actor has been recreated. + TranslationsParent.#translateOnPageReload = { fromLanguage, toLanguage }; + this.restorePage(fromLanguage); + } else { + this.languageState.requestedTranslationPair = { + fromLanguage, + toLanguage, + }; + this.sendAsyncMessage("Translations:TranslatePage", { + fromLanguage, + toLanguage, + }); + } + } + + /** + * Restore the page to the original language by doing a hard reload. + * + * @param {string} fromLanguage A BCP-47 language tag + */ + restorePage(fromLanguage) { + if ( + lazy.autoTranslatePagePref || + TranslationsParent.shouldAlwaysTranslateLanguage(fromLanguage) + ) { + // Skip auto-translate for one page load. + TranslationsParent.#isPageRestoredForAutoTranslate = true; + } + this.languageState.requestedTranslationPair = null; + + const browser = this.browsingContext.embedderElement; + browser.reload(); + } + + /** + * Keep track of when the location changes. + */ + static #locationChangeId = 0; + + static onLocationChange(browser) { + if (!lazy.translationsEnabledPref) { + // The pref isn't enabled, so don't attempt to get the actor. + return; + } + let windowGlobal = browser.browsingContext.currentWindowGlobal; + let actor = windowGlobal.getActor("Translations"); + TranslationsParent.#locationChangeId++; + actor.languageState.locationChangeId = TranslationsParent.#locationChangeId; + } + + /** + * Is this actor active for the current location change? + * + * @param {number} locationChangeId - The id sent by the "TranslationsParent:LanguageState" event. + * @returns {boolean} + */ + static isActiveLocation(locationChangeId) { + return locationChangeId === TranslationsParent.#locationChangeId; + } + + /** + * Returns the lang tags that should be offered for translation. + * + * @returns {Promise<LangTags>} + */ + getLangTagsForTranslation() { + return this.sendQuery("Translations:GetLangTagsForTranslation"); + } + + /** + * Returns the principal from the content window's origin. + * @returns {nsIPrincipal} + */ + getContentWindowPrincipal() { + return this.sendQuery("Translations:GetContentWindowPrincipal"); + } + + /** + * Returns true if the given language tag is present in the always-translate + * languages preference, otherwise false. + * + * @param {string} langTag - A BCP-47 language tag + * @returns {boolean} + */ + static shouldAlwaysTranslateLanguage(langTag) { + return lazy.alwaysTranslateLangTags.includes(langTag); + } + + /** + * Returns true if the given language tag is present in the never-translate + * languages preference, otherwise false. + * + * @param {string} langTag - A BCP-47 language tag + * @returns {boolean} + */ + static shouldNeverTranslateLanguage(langTag) { + return lazy.neverTranslateLangTags.includes(langTag); + } + + /** + * Returns true if the current site is denied permissions to translate, + * otherwise returns false. + * + * @returns {Promise<boolean>} + */ + async shouldNeverTranslateSite() { + let principal; + try { + principal = await this.getContentWindowPrincipal(); + } catch { + // Unable to get content window principal. + return false; + } + const perms = Services.perms; + const permission = perms.getPermissionObject( + principal, + TRANSLATIONS_PERMISSION, + /* exactHost */ false + ); + return permission?.capability === perms.DENY_ACTION; + } + + /** + * Removes the given language tag from the given preference. + * + * @param {string} langTag - A BCP-47 language tag + * @param {string} prefName - The pref name + */ + static #removeLangTagFromPref(langTag, prefName) { + const langTags = + prefName === ALWAYS_TRANSLATE_LANGS_PREF + ? lazy.alwaysTranslateLangTags + : lazy.neverTranslateLangTags; + const newLangTags = langTags.filter(tag => tag !== langTag); + Services.prefs.setCharPref(prefName, newLangTags.join(",")); + } + + /** + * Adds the given language tag to the given preference. + * + * @param {string} langTag - A BCP-47 language tag + * @param {string} prefName - The pref name + */ + static #addLangTagToPref(langTag, prefName) { + const langTags = + prefName === ALWAYS_TRANSLATE_LANGS_PREF + ? lazy.alwaysTranslateLangTags + : lazy.neverTranslateLangTags; + if (!langTags.includes(langTag)) { + langTags.push(langTag); + } + Services.prefs.setCharPref(prefName, langTags.join(",")); + } + + /** + * Toggles the always-translate language preference by adding the language + * to the pref list if it is not present, or removing it if it is present. + * + * @param {string} langTag - A BCP-47 language tag + */ + static toggleAlwaysTranslateLanguagePref(langTag) { + if (TranslationsParent.shouldAlwaysTranslateLanguage(langTag)) { + // The pref was toggled off for this langTag + this.#removeLangTagFromPref(langTag, ALWAYS_TRANSLATE_LANGS_PREF); + return; + } + + // The pref was toggled on for this langTag + this.#addLangTagToPref(langTag, ALWAYS_TRANSLATE_LANGS_PREF); + this.#removeLangTagFromPref(langTag, NEVER_TRANSLATE_LANGS_PREF); + } + + /** + * Toggles the never-translate language preference by adding the language + * to the pref list if it is not present, or removing it if it is present. + * + * @param {string} langTag - A BCP-47 language tag + */ + static toggleNeverTranslateLanguagePref(langTag) { + if (TranslationsParent.shouldNeverTranslateLanguage(langTag)) { + // The pref was toggled off for this langTag + this.#removeLangTagFromPref(langTag, NEVER_TRANSLATE_LANGS_PREF); + return; + } + + // The pref was toggled on for this langTag + this.#addLangTagToPref(langTag, NEVER_TRANSLATE_LANGS_PREF); + this.#removeLangTagFromPref(langTag, ALWAYS_TRANSLATE_LANGS_PREF); + } + + /** + * Toggles the never-translate site permissions by adding DENY_ACTION to + * the site principal if it is not present, or removing it if it is present. + */ + async toggleNeverTranslateSitePermissions() { + const perms = Services.perms; + const principal = await this.getContentWindowPrincipal(); + const shouldNeverTranslateSite = await this.shouldNeverTranslateSite(); + if (shouldNeverTranslateSite) { + perms.removeFromPrincipal(principal, TRANSLATIONS_PERMISSION); + } else { + perms.addFromPrincipal( + principal, + TRANSLATIONS_PERMISSION, + perms.DENY_ACTION + ); + } + } +} + +/** + * WebAssembly modules must be instantiated from a Worker, since it's considered + * unsafe eval. + */ +function detectSimdSupport() { + return new Promise(resolve => { + lazy.console.log("Loading wasm simd detector worker."); + + const worker = new Worker( + "chrome://global/content/translations/simd-detect-worker.js" + ); + + // This should pretty much immediately resolve, so it does not need Firefox shutdown + // detection. + worker.addEventListener("message", ({ data }) => { + resolve(data.isSimdSupported); + worker.terminate(); + }); + }); +} + +/** + * State that affects the UI. Any of the state that gets set triggers a dispatch to update + * the UI. + */ +class TranslationsLanguageState { + /** + * @param {TranslationsParent} actor + */ + constructor(actor) { + this.#actor = actor; + this.dispatch(); + } + + /** + * The data members for TranslationsLanguageState, see the getters for their + * documentation. + */ + + /** @type {TranslationsParent} */ + #actor; + + /** @type {TranslationPair | null} */ + #requestedTranslationPair = null; + + /** @type {LangTags | null} */ + #detectedLanguages = null; + + /** @type {number} */ + #locationChangeId = -1; + + /** @type {null | TranslationErrors} */ + #error = null; + + #isEngineReady = false; + + /** + * Dispatch anytime the language details change, so that any UI can react to it. + */ + dispatch() { + if (!TranslationsParent.isActiveLocation(this.#locationChangeId)) { + // Do not dispatch as this location is not active. + return; + } + + const browser = this.#actor.browsingContext.top.embedderElement; + if (!browser) { + return; + } + const { CustomEvent } = browser.ownerGlobal; + browser.dispatchEvent( + new CustomEvent("TranslationsParent:LanguageState", { + bubbles: true, + detail: { + detectedLanguages: this.#detectedLanguages, + requestedTranslationPair: this.#requestedTranslationPair, + error: this.#error, + isEngineReady: this.#isEngineReady, + }, + }) + ); + } + + /** + * When a translation is requested, this contains the translation pair. This means + * that the TranslationsChild should be creating a TranslationsDocument and keep + * the page updated with the target language. + * + * @returns {TranslationPair | null} + */ + get requestedTranslationPair() { + return this.#requestedTranslationPair; + } + + set requestedTranslationPair(requestedTranslationPair) { + this.#error = null; + this.#isEngineReady = false; + this.#requestedTranslationPair = requestedTranslationPair; + this.dispatch(); + } + + /** + * The TranslationsChild will detect languages and offer them up for translation. + * The results are stored here. + * + * @returns {LangTags | null} + */ + get detectedLanguages() { + return this.#detectedLanguages; + } + + set detectedLanguages(detectedLanguages) { + this.#detectedLanguages = detectedLanguages; + this.dispatch(); + } + + /** + * This id represents the last location change that happened for this actor. This + * allows the UI to disambiguate when there are races and out of order events that + * are dispatched. Only the most up to date `locationChangeId` is used. + * + * @returns {number} + */ + get locationChangeId() { + return this.#locationChangeId; + } + + set locationChangeId(locationChangeId) { + this.#locationChangeId = locationChangeId; + + // When the location changes remove the previous error. + this.#error = null; + + this.dispatch(); + } + + /** + * The last error that occured during translation. + */ + get error() { + return this.#error; + } + + set error(error) { + this.#error = error; + // Setting an error invalidates the requested translation pair. + this.#requestedTranslationPair = null; + this.#isEngineReady = false; + this.dispatch(); + } + + /** + * Stores when the translations engine is ready. The wasm and language files must + * be downloaded, which can take some time. + */ + get isEngineReady() { + return this.#isEngineReady; + } + + set isEngineReady(isEngineReady) { + this.#isEngineReady = isEngineReady; + this.dispatch(); + } +} + +/** + * @typedef {Object} QueueItem + * @prop {Function} download + * @prop {Function} [onSuccess] + * @prop {Function} [onFailure] + * @prop {number} [retriesLeft] + */ + +/** + * Manage the download of the files by providing a maximum number of concurrent files + * and the ability to retry a file download in case of an error. + * + * @param {QueueItem[]} queue + */ +async function downloadManager(queue) { + const NOOP = () => {}; + + const pendingDownloadAttempts = new Set(); + let failCount = 0; + let index = 0; + const start = Date.now(); + const originalQueueLength = queue.length; + + while (index < queue.length || pendingDownloadAttempts.size > 0) { + // Start new downloads up to the maximum limit + while ( + index < queue.length && + pendingDownloadAttempts.size < TranslationsParent.MAX_CONCURRENT_DOWNLOADS + ) { + lazy.console.log(`Starting download ${index + 1} of ${queue.length}`); + + const { + download, + onSuccess = NOOP, + onFailure = NOOP, + retriesLeft = TranslationsParent.MAX_DOWNLOAD_RETRIES, + } = queue[index]; + + const handleFailedDownload = error => { + // The download failed. Either retry it, or report the failure. + TranslationsParent.reportError( + new Error("Failed to download file."), + error + ); + + const newRetriesLeft = retriesLeft - 1; + + if (retriesLeft > 0) { + lazy.console.log( + `Queueing another attempt. ${newRetriesLeft} attempts left.` + ); + queue.push({ + download, + retriesLeft: newRetriesLeft, + onSuccess, + onFailure, + }); + } else { + // Give up on this download. + failCount++; + onFailure(); + } + }; + + const afterDownloadAttempt = () => { + pendingDownloadAttempts.delete(downloadAttempt); + }; + + // Kick off the download. If it fails, retry it a certain number of attempts. + // This is done asynchronously from the rest of the for loop. + const downloadAttempt = download() + .then(onSuccess, handleFailedDownload) + .then(afterDownloadAttempt); + + pendingDownloadAttempts.add(downloadAttempt); + index++; + } + + // Wait for any active downloads to complete. + await Promise.race(pendingDownloadAttempts); + } + + const duration = ((Date.now() - start) / 1000).toFixed(3); + + if (failCount > 0) { + const message = `Finished downloads in ${duration} seconds, but ${failCount} download(s) failed.`; + lazy.console.log( + `Finished downloads in ${duration} seconds, but ${failCount} download(s) failed.` + ); + throw new Error(message); + } + + lazy.console.log( + `Finished ${originalQueueLength} downloads in ${duration} seconds.` + ); +} + +/** + * The translations code has lots of async code and fallible network requests. To test + * this manually while using the feature, enable chaos mode by setting "errors" to true + * and "timeoutMS" to a positive number of milliseconds. + * prefs to true: + * + * - browser.translations.chaos.timeoutMS + * - browser.translations.chaos.errors + */ +async function chaosMode(probability = 0.5) { + await chaosModeTimer(); + await chaosModeError(probability); +} + +/** + * The translations code has lots of async code that relies on the network. To test + * this manually while using the feature, enable chaos mode by setting the following pref + * to a positive number of milliseconds. + * + * - browser.translations.chaos.timeoutMS + */ +async function chaosModeTimer() { + if (lazy.chaosTimeoutMSPref) { + const timeout = Math.random() * lazy.chaosTimeoutMSPref; + lazy.console.log( + `Chaos mode timer started for ${(timeout / 1000).toFixed(1)} seconds.` + ); + await new Promise(resolve => lazy.setTimeout(resolve, timeout)); + } +} + +/** + * The translations code has lots of async code that is fallible. To test this manually + * while using the feature, enable chaos mode by setting the following pref to true. + * + * - browser.translations.chaos.errors + */ +async function chaosModeError(probability = 0.5) { + if (lazy.chaosErrorsPref && Math.random() < probability) { + lazy.console.trace(`Chaos mode error generated.`); + throw new Error( + `Chaos Mode error from the pref "browser.translations.chaos.errors".` + ); + } +} diff --git a/toolkit/components/translations/actors/moz.build b/toolkit/components/translations/actors/moz.build new file mode 100644 index 0000000000..b05acabab5 --- /dev/null +++ b/toolkit/components/translations/actors/moz.build @@ -0,0 +1,10 @@ +# 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/. + +FINAL_TARGET_FILES.actors += [ + "AboutTranslationsChild.sys.mjs", + "AboutTranslationsParent.sys.mjs", + "TranslationsChild.sys.mjs", + "TranslationsParent.sys.mjs", +] |