diff options
Diffstat (limited to 'comm/mail/components/accountcreation/AccountCreationUtils.jsm')
-rw-r--r-- | comm/mail/components/accountcreation/AccountCreationUtils.jsm | 717 |
1 files changed, 717 insertions, 0 deletions
diff --git a/comm/mail/components/accountcreation/AccountCreationUtils.jsm b/comm/mail/components/accountcreation/AccountCreationUtils.jsm new file mode 100644 index 0000000000..f4efb96b2d --- /dev/null +++ b/comm/mail/components/accountcreation/AccountCreationUtils.jsm @@ -0,0 +1,717 @@ +/* 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/. */ +/** + * Some common, generic functions + */ + +const EXPORTED_SYMBOLS = ["AccountCreationUtils"]; + +const lazy = {}; + +ChromeUtils.defineModuleGetter( + lazy, + "Sanitizer", + "resource:///modules/accountcreation/Sanitizer.jsm" +); + +const { AddonManager } = ChromeUtils.importESModule( + "resource://gre/modules/AddonManager.sys.mjs" +); +const { ConsoleAPI } = ChromeUtils.importESModule( + "resource://gre/modules/Console.sys.mjs" +); +const { clearInterval, clearTimeout, setTimeout } = ChromeUtils.importESModule( + "resource://gre/modules/Timer.sys.mjs" +); + +// -------------------------- +// Low level, basic functions + +function assert(test, errorMsg) { + if (!test) { + throw new NotReached( + errorMsg ? errorMsg : "Programming bug. Assertion failed, see log." + ); + } +} + +function makeCallback(obj, func) { + return func.bind(obj); +} + +/** + * Runs the given function sometime later + * + * Currently implemented using setTimeout(), but + * can later be replaced with an nsITimer impl, + * when code wants to use it in a module. + * + * @see |TimeoutAbortable| + */ +function runAsync(func) { + return setTimeout(func, 0); +} + +/** + * Reads UTF8 data from a URL. + * + * @param uri {nsIURI} - what you want to read + * @returns {Array of String} the contents of the file, one string per line + */ +function readURLasUTF8(uri) { + assert(uri instanceof Ci.nsIURI, "uri must be an nsIURI"); + let chan = Services.io.newChannelFromURI( + uri, + null, + Services.scriptSecurityManager.getSystemPrincipal(), + null, + Ci.nsILoadInfo.SEC_ALLOW_CROSS_ORIGIN_SEC_CONTEXT_IS_NULL, + Ci.nsIContentPolicy.TYPE_OTHER + ); + let is = Cc["@mozilla.org/intl/converter-input-stream;1"].createInstance( + Ci.nsIConverterInputStream + ); + is.init( + chan.open(), + "UTF-8", + 1024, + Ci.nsIConverterInputStream.DEFAULT_REPLACEMENT_CHARACTER + ); + + let content = ""; + let strOut = {}; + try { + while (is.readString(1024, strOut) != 0) { + content += strOut.value; + } + } finally { + is.close(); + } + + return content; + // TODO this has a numeric error message. We need to ship translations + // into human language. +} + +/** + * @param bundleURI {String} - chrome URL to properties file + * @returns nsIStringBundle + */ +function getStringBundle(bundleURI) { + try { + return Services.strings.createBundle(bundleURI); + } catch (e) { + throw new Exception( + "Failed to get stringbundle URI <" + bundleURI + ">. Error: " + e + ); + } +} + +// --------- +// Exception + +function Exception(msg) { + this._message = msg; + this.stack = Components.stack.formattedStack; +} +Exception.prototype = { + get message() { + return this._message; + }, + toString() { + return this._message; + }, +}; + +function NotReached(msg) { + Exception.call(this, msg); // call super constructor + console.error(this); +} +// Make NotReached extend Exception. +NotReached.prototype = Object.create(Exception.prototype); +NotReached.prototype.constructor = NotReached; + +// --------- +// Abortable + +/** + * A handle for an async function which you can cancel. + * The async function will return an object of this type (a subtype) + * and you can call cancel() when you feel like killing the function. + */ +function Abortable() {} +Abortable.prototype = { + cancel(e) {}, +}; + +function CancelledException(msg) { + Exception.call(this, msg); +} +CancelledException.prototype = Object.create(Exception.prototype); +CancelledException.prototype.constructor = CancelledException; + +function UserCancelledException(msg) { + // The user knows they cancelled so I don't see a need + // for a message to that effect. + if (!msg) { + msg = "User cancelled"; + } + CancelledException.call(this, msg); +} +UserCancelledException.prototype = Object.create(CancelledException.prototype); +UserCancelledException.prototype.constructor = UserCancelledException; + +/** + * Utility implementation, for waiting for a promise to resolve, + * but allowing its result to be cancelled. + */ +function PromiseAbortable(promise, successCallback, errorCallback) { + Abortable.call(this); // call super constructor + let complete = false; + this.cancel = function (e) { + if (!complete) { + complete = true; + errorCallback(e || new CancelledException()); + } + }; + promise + .then(function (result) { + if (!complete) { + successCallback(result); + complete = true; + } + }) + .catch(function (e) { + if (!complete) { + complete = true; + errorCallback(e); + } + }); +} +PromiseAbortable.prototype = Object.create(Abortable.prototype); +PromiseAbortable.prototype.constructor = PromiseAbortable; + +/** + * Utility implementation, for allowing to abort a setTimeout. + * Use like: return new TimeoutAbortable(setTimeout(function(){ ... }, 0)); + * + * @param setTimeoutID {Integer} - Return value of setTimeout() + */ +function TimeoutAbortable(setTimeoutID) { + Abortable.call(this); // call super constructor + this._id = setTimeoutID; +} +TimeoutAbortable.prototype = Object.create(Abortable.prototype); +TimeoutAbortable.prototype.constructor = TimeoutAbortable; +TimeoutAbortable.prototype.cancel = function () { + clearTimeout(this._id); +}; + +/** + * Utility implementation, for allowing to abort a setTimeout. + * Use like: return new TimeoutAbortable(setTimeout(function(){ ... }, 0)); + * + * @param setIntervalID {Integer} - Return value of setInterval() + */ +function IntervalAbortable(setIntervalID) { + Abortable.call(this); // call super constructor + this._id = setIntervalID; +} +IntervalAbortable.prototype = Object.create(Abortable.prototype); +IntervalAbortable.prototype.constructor = IntervalAbortable; +IntervalAbortable.prototype.cancel = function () { + clearInterval(this._id); +}; + +/** + * Allows you to make several network calls, + * but return only one |Abortable| object. + */ +function SuccessiveAbortable() { + Abortable.call(this); // call super constructor + this._current = null; +} +SuccessiveAbortable.prototype = { + __proto__: Abortable.prototype, + get current() { + return this._current; + }, + set current(abortable) { + assert( + abortable instanceof Abortable || abortable == null, + "need an Abortable object (or null)" + ); + this._current = abortable; + }, + cancel(e) { + if (this._current) { + this._current.cancel(e); + } + }, +}; + +/** + * Allows you to make several network calls in parallel. + */ +function ParallelAbortable() { + Abortable.call(this); // call super constructor + // { Array of ParallelCall } + this._calls = []; + // { Array of Function } + this._finishedObservers = []; +} +ParallelAbortable.prototype = { + __proto__: Abortable.prototype, + /** + * @returns {Array of ParallelCall} + */ + get results() { + return this._calls; + }, + /** + * @returns {ParallelCall} + */ + addCall() { + let call = new ParallelCall(this); + call.position = this._calls.length; + this._calls.push(call); + return call; + }, + /** + * Observers will be called once one of the functions + * finishes, i.e. returns successfully or fails. + * + * @param {Function({ParallelCall} call)} func + */ + addOneFinishedObserver(func) { + assert(typeof func == "function"); + this._finishedObservers.push(func); + }, + /** + * Will be called once *all* of the functions finished, + * It gives you a list of all functions that succeeded or failed, + * respectively. + * + * @param {Function( + * {Array of ParallelCall} succeeded, + * {Array of ParallelCall} failed + * )} func + */ + addAllFinishedObserver(func) { + assert(typeof func == "function"); + this.addOneFinishedObserver(() => { + if (this._calls.some(call => !call.finished)) { + return; + } + let succeeded = this._calls.filter(call => call.succeeded); + let failed = this._calls.filter(call => !call.succeeded); + func(succeeded, failed); + }); + }, + _notifyFinished(call) { + for (let observer of this._finishedObservers) { + try { + observer(call); + } catch (e) { + console.error(e); + } + } + }, + cancel(e) { + for (let call of this._calls) { + if (!call.finished && call.callerAbortable) { + call.callerAbortable.cancel(e); + } + } + }, +}; + +/** + * Returned by ParallelAbortable.addCall(). + * Do not create this object directly + * + * @param {ParallelAbortable} parallelAbortable - The controlling ParallelAbortable + */ +function ParallelCall(parallelAbortable) { + assert(parallelAbortable instanceof ParallelAbortable); + // {ParallelAbortable} the parent + this._parallelAbortable = parallelAbortable; + // {Abortable} Abortable of the caller function that should run in parallel + this.callerAbortable = null; + // {Integer} the order in which the function was added, and its priority + this.position = null; + // {boolean} false = running, pending, false = success or failure + this.finished = false; + // {boolean} if finished: true = returned with success, false = returned with error + this.succeeded = false; + // {Exception} if failed: the error or exception that the caller function returned + this.e = null; + // {Object} if succeeded: the result of the caller function + this.result = null; + + this._time = Date.now(); +} +ParallelCall.prototype = { + /** + * Returns a successCallback(result) function that you pass + * to your function that runs in parallel. + * + * @returns {Function(result)} successCallback + */ + successCallback() { + return result => { + ddump( + "call " + + this.position + + " took " + + (Date.now() - this._time) + + "ms and succeeded" + + (this.callerAbortable && this.callerAbortable._url + ? " at <" + this.callerAbortable._url + ">" + : "") + ); + this.result = result; + this.finished = true; + this.succeeded = true; + this._parallelAbortable._notifyFinished(this); + }; + }, + /** + * Returns an errorCallback(e) function that you pass + * to your function that runs in parallel. + * + * @returns {Function(e)} errorCallback + */ + errorCallback() { + return e => { + ddump( + "call " + + this.position + + " took " + + (Date.now() - this._time) + + "ms and failed with " + + (typeof e.code == "number" ? e.code + " " : "") + + (e.toString() + ? e.toString() + : "unknown error, probably no host connection") + + (this.callerAbortable && this.callerAbortable._url + ? " at <" + this.callerAbortable._url + ">" + : "") + ); + this.e = e; + this.finished = true; + this.succeeded = false; + this._parallelAbortable._notifyFinished(this); + }; + }, + /** + * Call your function that needs to run in parallel + * and pass the resulting |Abortable| of your function here. + * + * @param {Abortable} abortable + */ + setAbortable(abortable) { + assert(abortable instanceof Abortable); + this.callerAbortable = abortable; + }, +}; + +/** + * Runs several calls in parallel. + * Returns the result of the "highest" priority call that succeeds. + * Unlike Promise.race(), does not return the fastest, + * but the first in the order they were added. + * So, the order in which the calls were added determines their priority, + * with the first to be added being the most desirable. + * + * E.g. the first failed, the second is pending, the third succeeded, and the forth is pending. + * It aborts the forth (because the third succeeded), and it waits for the second to return. + * If the second succeeds, it is the result, otherwise the third is the result. + * + * @param {Function( + * {Object} result - Result of winner call + * {ParallelCall} call - Winner call info + * )} successCallback - A call returned successfully + * @param {Function(e, allErrors)} errorCallback - All calls failed. + * {Exception} e - The first CancelledException, and otherwise + * the exception returned by the first call. + * This is just to adhere to the standard API of errorCallback(e). + * {Array of Exception} allErrors - The exceptions from all calls. + */ +function PriorityOrderAbortable(successCallback, errorCallback) { + assert(typeof successCallback == "function"); + assert(typeof errorCallback == "function"); + ParallelAbortable.call(this); // call super constructor + this._successfulCall = null; + + this.addOneFinishedObserver(finishedCall => { + for (let call of this._calls) { + if (!call.finished) { + if (this._successfulCall) { + // abort + if (call.callerAbortable) { + call.callerAbortable.cancel( + new NoLongerNeededException("Another higher call succeeded") + ); + } + continue; + } + // It's pending. do nothing and wait for it. + return; + } + if (!call.succeeded) { + // it failed. ignore it. + continue; + } + if (this._successfulCall) { + // we already have a winner. ignore it. + continue; + } + try { + successCallback(call.result, call); + // This is the winner. + this._successfulCall = call; + } catch (e) { + console.error(e); + // If the handler failed with this data, treat this call as failed. + call.e = e; + call.succeeded = false; + } + } + if (!this._successfulCall) { + // all failed + let allErrors = this._calls.map(call => call.e); + let e = + allErrors.find(e => e instanceof CancelledException) || allErrors[0]; + errorCallback(e, allErrors); // see docs above + } + }); +} +PriorityOrderAbortable.prototype = Object.create(ParallelAbortable.prototype); +PriorityOrderAbortable.prototype.constructor = PriorityOrderAbortable; + +function NoLongerNeededException(msg) { + CancelledException.call(this, msg); +} +NoLongerNeededException.prototype = Object.create(CancelledException.prototype); +NoLongerNeededException.prototype.constructor = NoLongerNeededException; + +// ------------------- +// High level features + +/** + * Allows you to install an addon. + * + * Example: + * var installer = new AddonInstaller({ xpiURL : "https://...xpi", id: "...", ...}); + * installer.install(); + * + * @param {object} args - Contains parameters: + * @param {string} name (Optional) - Name of the addon (not important) + * @param {string} id (Optional) - Addon ID + * If you pass an ID, and the addon is already installed (and the version matches), + * then install() will do nothing. + * After the XPI is downloaded, the ID will be verified. If it doesn't match, the + * install will fail. + * If you don't pass an ID, these checks will be skipped and the addon be installed + * unconditionally. + * It is recommended to pass at least an ID, because it can confuse some addons + * to be reloaded at runtime. + * @param {string} minVersion (Optional) - Minimum version of the addon + * If you pass a minVersion (in addition to ID), and the installed addon is older than this, + * the install will be done anyway. If the downloaded addon has a lower version, + * the install will fail. + * If you do not pass a minVersion, there will be no version check. + * @param {URL} xpiURL - Where to download the XPI from + */ +function AddonInstaller(args) { + Abortable.call(this); + this._name = lazy.Sanitizer.label(args.name); + this._id = lazy.Sanitizer.string(args.id); + this._minVersion = lazy.Sanitizer.string(args.minVersion); + this._url = lazy.Sanitizer.url(args.xpiURL); +} +AddonInstaller.prototype = Object.create(Abortable.prototype); +AddonInstaller.prototype.constructor = AddonInstaller; + +/** + * Checks whether the passed-in addon matches the + * id and minVersion requested by the caller. + * + * @param {nsIAddon} addon + * @returns {boolean} is OK + */ +AddonInstaller.prototype.matches = function (addon) { + return ( + !this._id || + (this._id == addon.id && + (!this._minVersion || + Services.vc.compare(addon.version, this._minVersion) >= 0)) + ); +}; + +/** + * Start the installation + * + * @throws Exception in case of failure + */ +AddonInstaller.prototype.install = async function () { + if (await this.isInstalled()) { + return; + } + await this._installDirect(); +}; + +/** + * Checks whether we already have an addon installed that matches the + * id and minVersion requested by the caller. + * + * @returns {boolean} is already installed and enabled + */ +AddonInstaller.prototype.isInstalled = async function () { + if (!this._id) { + return false; + } + var addon = await AddonManager.getAddonByID(this._id); + return addon && this.matches(addon) && addon.isActive; +}; + +/** + * Checks whether we already have an addon but it is disabled. + * + * @returns {boolean} is already installed but disabled + */ +AddonInstaller.prototype.isDisabled = async function () { + if (!this._id) { + return false; + } + let addon = await AddonManager.getAddonByID(this._id); + return addon && !addon.isActive; +}; + +/** + * Downloads and installs the addon. + * The downloaded XPI will be checked using prompt(). + */ +AddonInstaller.prototype._installDirect = async function () { + var installer = (this._installer = await AddonManager.getInstallForURL( + this._url, + { name: this._name } + )); + installer.promptHandler = makeCallback(this, this.prompt); + await installer.install(); // throws, if failed + + var addon = await AddonManager.getAddonByID(this._id); + await addon.enable(); + + // Wait for addon startup code to finish + // Fixes: verify password fails with NOT_AVAILABLE in createIncomingServer() + if ("startupPromise" in addon) { + await addon.startupPromise; + } + let wait = ms => new Promise(resolve => setTimeout(resolve, ms)); + await wait(1000); +}; + +/** + * Install confirmation. You may override this, if needed. + * + * @throws Exception If you want to cancel install, then throw an exception. + */ +AddonInstaller.prototype.prompt = async function (info) { + if (!this.matches(info.addon)) { + // happens only when we got the wrong XPI + throw new Exception( + "The downloaded addon XPI does not match the minimum requirements" + ); + } +}; + +AddonInstaller.prototype.cancel = function () { + if (this._installer) { + try { + this._installer.cancel(); + } catch (e) { + // if install failed + ddump(e); + } + } +}; + +// ------------ +// Debug output + +function deepCopy(org) { + if (typeof org == "undefined") { + return undefined; + } + if (org == null) { + return null; + } + if (typeof org == "string") { + return org; + } + if (typeof org == "number") { + return org; + } + if (typeof org == "boolean") { + return org; + } + if (typeof org == "function") { + return org; + } + if (typeof org != "object") { + throw new Error("can't copy objects of type " + typeof org + " yet"); + } + + // TODO still instanceof org != instanceof copy + // var result = new org.constructor(); + var result = {}; + if (typeof org.length != "undefined") { + result = []; + } + for (var prop in org) { + result[prop] = deepCopy(org[prop]); + } + return result; +} + +var gAccountSetupLogger = new ConsoleAPI({ + prefix: "mail.setup", + maxLogLevel: "warn", + maxLogLevelPref: "mail.setup.loglevel", +}); + +function ddump(text) { + gAccountSetupLogger.info(text); +} + +function alertPrompt(alertTitle, alertMsg) { + Services.prompt.alert( + Services.wm.getMostRecentWindow(""), + alertTitle, + alertMsg + ); +} + +var AccountCreationUtils = { + Abortable, + AddonInstaller, + alertPrompt, + assert, + CancelledException, + ddump, + deepCopy, + Exception, + gAccountSetupLogger, + getStringBundle, + NotReached, + PriorityOrderAbortable, + PromiseAbortable, + readURLasUTF8, + runAsync, + SuccessiveAbortable, + TimeoutAbortable, + UserCancelledException, +}; |