Wed, 31 Dec 2014 06:09:35 +0100
Cloned upstream origin tor-browser at tor-browser-31.3.0esr-4.5-1-build1
revision ID fc1c9ff7c1b2defdbc039f12214767608f46423f for hacking purpose.
1 /* This Source Code Form is subject to the terms of the Mozilla Public
2 * License, v. 2.0. If a copy of the MPL was not distributed with this
3 * file, You can obtain one at http://mozilla.org/MPL/2.0/. */
5 "use strict";
7 this.EXPORTED_SYMBOLS = [
8 "Experiments",
9 "ExperimentsProvider",
10 ];
12 const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
14 Cu.import("resource://gre/modules/XPCOMUtils.jsm");
15 Cu.import("resource://gre/modules/Services.jsm");
16 Cu.import("resource://gre/modules/Task.jsm");
17 Cu.import("resource://gre/modules/Promise.jsm");
18 Cu.import("resource://gre/modules/osfile.jsm");
19 Cu.import("resource://gre/modules/Log.jsm");
20 Cu.import("resource://gre/modules/Preferences.jsm");
21 Cu.import("resource://gre/modules/AsyncShutdown.jsm");
23 XPCOMUtils.defineLazyModuleGetter(this, "UpdateChannel",
24 "resource://gre/modules/UpdateChannel.jsm");
25 XPCOMUtils.defineLazyModuleGetter(this, "AddonManager",
26 "resource://gre/modules/AddonManager.jsm");
27 XPCOMUtils.defineLazyModuleGetter(this, "AddonManagerPrivate",
28 "resource://gre/modules/AddonManager.jsm");
29 XPCOMUtils.defineLazyModuleGetter(this, "TelemetryPing",
30 "resource://gre/modules/TelemetryPing.jsm");
31 XPCOMUtils.defineLazyModuleGetter(this, "TelemetryLog",
32 "resource://gre/modules/TelemetryLog.jsm");
33 XPCOMUtils.defineLazyModuleGetter(this, "CommonUtils",
34 "resource://services-common/utils.js");
35 XPCOMUtils.defineLazyModuleGetter(this, "Metrics",
36 "resource://gre/modules/Metrics.jsm");
38 // CertUtils.jsm doesn't expose a single "CertUtils" object like a normal .jsm
39 // would.
40 XPCOMUtils.defineLazyGetter(this, "CertUtils",
41 function() {
42 var mod = {};
43 Cu.import("resource://gre/modules/CertUtils.jsm", mod);
44 return mod;
45 });
47 XPCOMUtils.defineLazyServiceGetter(this, "gCrashReporter",
48 "@mozilla.org/xre/app-info;1",
49 "nsICrashReporter");
51 const FILE_CACHE = "experiments.json";
52 const EXPERIMENTS_CHANGED_TOPIC = "experiments-changed";
53 const MANIFEST_VERSION = 1;
54 const CACHE_VERSION = 1;
56 const KEEP_HISTORY_N_DAYS = 180;
57 const MIN_EXPERIMENT_ACTIVE_SECONDS = 60;
59 const PREF_BRANCH = "experiments.";
60 const PREF_ENABLED = "enabled"; // experiments.enabled
61 const PREF_ACTIVE_EXPERIMENT = "activeExperiment"; // whether we have an active experiment
62 const PREF_LOGGING = "logging";
63 const PREF_LOGGING_LEVEL = PREF_LOGGING + ".level"; // experiments.logging.level
64 const PREF_LOGGING_DUMP = PREF_LOGGING + ".dump"; // experiments.logging.dump
65 const PREF_MANIFEST_URI = "manifest.uri"; // experiments.logging.manifest.uri
66 const PREF_MANIFEST_CHECKCERT = "manifest.cert.checkAttributes"; // experiments.manifest.cert.checkAttributes
67 const PREF_MANIFEST_REQUIREBUILTIN = "manifest.cert.requireBuiltin"; // experiments.manifest.cert.requireBuiltin
68 const PREF_FORCE_SAMPLE = "force-sample-value"; // experiments.force-sample-value
70 const PREF_HEALTHREPORT_ENABLED = "datareporting.healthreport.service.enabled";
72 const PREF_BRANCH_TELEMETRY = "toolkit.telemetry.";
73 const PREF_TELEMETRY_ENABLED = "enabled";
75 const URI_EXTENSION_STRINGS = "chrome://mozapps/locale/extensions/extensions.properties";
76 const STRING_TYPE_NAME = "type.%ID%.name";
78 const TELEMETRY_LOG = {
79 // log(key, [kind, experimentId, details])
80 ACTIVATION_KEY: "EXPERIMENT_ACTIVATION",
81 ACTIVATION: {
82 // Successfully activated.
83 ACTIVATED: "ACTIVATED",
84 // Failed to install the add-on.
85 INSTALL_FAILURE: "INSTALL_FAILURE",
86 // Experiment does not meet activation requirements. Details will
87 // be provided.
88 REJECTED: "REJECTED",
89 },
91 // log(key, [kind, experimentId, optionalDetails...])
92 TERMINATION_KEY: "EXPERIMENT_TERMINATION",
93 TERMINATION: {
94 // The Experiments service was disabled.
95 SERVICE_DISABLED: "SERVICE_DISABLED",
96 // Add-on uninstalled.
97 ADDON_UNINSTALLED: "ADDON_UNINSTALLED",
98 // The experiment disabled itself.
99 FROM_API: "FROM_API",
100 // The experiment expired (e.g. by exceeding the end date).
101 EXPIRED: "EXPIRED",
102 // Disabled after re-evaluating conditions. If this is specified,
103 // details will be provided.
104 RECHECK: "RECHECK",
105 },
106 };
108 const gPrefs = new Preferences(PREF_BRANCH);
109 const gPrefsTelemetry = new Preferences(PREF_BRANCH_TELEMETRY);
110 let gExperimentsEnabled = false;
111 let gAddonProvider = null;
112 let gExperiments = null;
113 let gLogAppenderDump = null;
114 let gPolicyCounter = 0;
115 let gExperimentsCounter = 0;
116 let gExperimentEntryCounter = 0;
117 let gPreviousProviderCounter = 0;
119 // Tracks active AddonInstall we know about so we can deny external
120 // installs.
121 let gActiveInstallURLs = new Set();
123 // Tracks add-on IDs that are being uninstalled by us. This allows us
124 // to differentiate between expected uninstalled and user-driven uninstalls.
125 let gActiveUninstallAddonIDs = new Set();
127 let gLogger;
128 let gLogDumping = false;
130 function configureLogging() {
131 if (!gLogger) {
132 gLogger = Log.repository.getLogger("Browser.Experiments");
133 gLogger.addAppender(new Log.ConsoleAppender(new Log.BasicFormatter()));
134 }
135 gLogger.level = gPrefs.get(PREF_LOGGING_LEVEL, Log.Level.Warn);
137 let logDumping = gPrefs.get(PREF_LOGGING_DUMP, false);
138 if (logDumping != gLogDumping) {
139 if (logDumping) {
140 gLogAppenderDump = new Log.DumpAppender(new Log.BasicFormatter());
141 gLogger.addAppender(gLogAppenderDump);
142 } else {
143 gLogger.removeAppender(gLogAppenderDump);
144 gLogAppenderDump = null;
145 }
146 gLogDumping = logDumping;
147 }
148 }
150 // Takes an array of promises and returns a promise that is resolved once all of
151 // them are rejected or resolved.
152 function allResolvedOrRejected(promises) {
153 if (!promises.length) {
154 return Promise.resolve([]);
155 }
157 let countdown = promises.length;
158 let deferred = Promise.defer();
160 for (let p of promises) {
161 let helper = () => {
162 if (--countdown == 0) {
163 deferred.resolve();
164 }
165 };
166 Promise.resolve(p).then(helper, helper);
167 }
169 return deferred.promise;
170 }
172 // Loads a JSON file using OS.file. file is a string representing the path
173 // of the file to be read, options contains additional options to pass to
174 // OS.File.read.
175 // Returns a Promise resolved with the json payload or rejected with
176 // OS.File.Error or JSON.parse() errors.
177 function loadJSONAsync(file, options) {
178 return Task.spawn(function() {
179 let rawData = yield OS.File.read(file, options);
180 // Read json file into a string
181 let data;
182 try {
183 // Obtain a converter to read from a UTF-8 encoded input stream.
184 let converter = new TextDecoder();
185 data = JSON.parse(converter.decode(rawData));
186 } catch (ex) {
187 gLogger.error("Experiments: Could not parse JSON: " + file + " " + ex);
188 throw ex;
189 }
190 throw new Task.Result(data);
191 });
192 }
194 function telemetryEnabled() {
195 return gPrefsTelemetry.get(PREF_TELEMETRY_ENABLED, false);
196 }
198 // Returns a promise that is resolved with the AddonInstall for that URL.
199 function addonInstallForURL(url, hash) {
200 let deferred = Promise.defer();
201 AddonManager.getInstallForURL(url, install => deferred.resolve(install),
202 "application/x-xpinstall", hash);
203 return deferred.promise;
204 }
206 // Returns a promise that is resolved with an Array<Addon> of the installed
207 // experiment addons.
208 function installedExperimentAddons() {
209 let deferred = Promise.defer();
210 AddonManager.getAddonsByTypes(["experiment"], (addons) => {
211 deferred.resolve([a for (a of addons) if (!a.appDisabled)]);
212 });
213 return deferred.promise;
214 }
216 // Takes an Array<Addon> and returns a promise that is resolved when the
217 // addons are uninstalled.
218 function uninstallAddons(addons) {
219 let ids = new Set([a.id for (a of addons)]);
220 let deferred = Promise.defer();
222 let listener = {};
223 listener.onUninstalled = addon => {
224 if (!ids.has(addon.id)) {
225 return;
226 }
228 ids.delete(addon.id);
229 if (ids.size == 0) {
230 AddonManager.removeAddonListener(listener);
231 deferred.resolve();
232 }
233 };
235 AddonManager.addAddonListener(listener);
237 for (let addon of addons) {
238 // Disabling the add-on before uninstalling is necessary to cause tests to
239 // pass. This might be indicative of a bug in XPIProvider.
240 // TODO follow up in bug 992396.
241 addon.userDisabled = true;
242 addon.uninstall();
243 }
245 return deferred.promise;
246 }
248 /**
249 * The experiments module.
250 */
252 let Experiments = {
253 /**
254 * Provides access to the global `Experiments.Experiments` instance.
255 */
256 instance: function () {
257 if (!gExperiments) {
258 gExperiments = new Experiments.Experiments();
259 }
261 return gExperiments;
262 },
263 };
265 /*
266 * The policy object allows us to inject fake enviroment data from the
267 * outside by monkey-patching.
268 */
270 Experiments.Policy = function () {
271 this._log = Log.repository.getLoggerWithMessagePrefix(
272 "Browser.Experiments.Policy",
273 "Policy #" + gPolicyCounter++ + "::");
275 // Set to true to ignore hash verification on downloaded XPIs. This should
276 // not be used outside of testing.
277 this.ignoreHashes = false;
278 };
280 Experiments.Policy.prototype = {
281 now: function () {
282 return new Date();
283 },
285 random: function () {
286 let pref = gPrefs.get(PREF_FORCE_SAMPLE);
287 if (pref !== undefined) {
288 let val = Number.parseFloat(pref);
289 this._log.debug("random sample forced: " + val);
290 if (isNaN(val) || val < 0) {
291 return 0;
292 }
293 if (val > 1) {
294 return 1;
295 }
296 return val;
297 }
298 return Math.random();
299 },
301 futureDate: function (offset) {
302 return new Date(this.now().getTime() + offset);
303 },
305 oneshotTimer: function (callback, timeout, thisObj, name) {
306 return CommonUtils.namedTimer(callback, timeout, thisObj, name);
307 },
309 updatechannel: function () {
310 return UpdateChannel.get();
311 },
313 locale: function () {
314 let chrome = Cc["@mozilla.org/chrome/chrome-registry;1"].getService(Ci.nsIXULChromeRegistry);
315 return chrome.getSelectedLocale("global");
316 },
318 /*
319 * @return Promise<> Resolved with the payload data.
320 */
321 healthReportPayload: function () {
322 return Task.spawn(function*() {
323 let reporter = Cc["@mozilla.org/datareporting/service;1"]
324 .getService(Ci.nsISupports)
325 .wrappedJSObject
326 .healthReporter;
327 yield reporter.onInit();
328 let payload = yield reporter.collectAndObtainJSONPayload();
329 throw new Task.Result(payload);
330 });
331 },
333 telemetryPayload: function () {
334 return TelemetryPing.getPayload();
335 },
336 };
338 function AlreadyShutdownError(message="already shut down") {
339 this.name = "AlreadyShutdownError";
340 this.message = message;
341 }
343 AlreadyShutdownError.prototype = new Error();
344 AlreadyShutdownError.prototype.constructor = AlreadyShutdownError;
346 /**
347 * Manages the experiments and provides an interface to control them.
348 */
350 Experiments.Experiments = function (policy=new Experiments.Policy()) {
351 this._log = Log.repository.getLoggerWithMessagePrefix(
352 "Browser.Experiments.Experiments",
353 "Experiments #" + gExperimentsCounter++ + "::");
354 this._log.trace("constructor");
356 this._policy = policy;
358 // This is a Map of (string -> ExperimentEntry), keyed with the experiment id.
359 // It holds both the current experiments and history.
360 // Map() preserves insertion order, which means we preserve the manifest order.
361 // This is null until we've successfully completed loading the cache from
362 // disk the first time.
363 this._experiments = null;
364 this._refresh = false;
365 this._terminateReason = null; // or TELEMETRY_LOG.TERMINATION....
366 this._dirty = false;
368 // Loading the cache happens once asynchronously on startup
369 this._loadTask = null;
371 // The _main task handles all other actions:
372 // * refreshing the manifest off the network (if _refresh)
373 // * disabling/enabling experiments
374 // * saving the cache (if _dirty)
375 this._mainTask = null;
377 // Timer for re-evaluating experiment status.
378 this._timer = null;
380 this._shutdown = false;
382 // We need to tell when we first evaluated the experiments to fire an
383 // experiments-changed notification when we only loaded completed experiments.
384 this._firstEvaluate = true;
386 this.init();
387 };
389 Experiments.Experiments.prototype = {
390 QueryInterface: XPCOMUtils.generateQI([Ci.nsITimerCallback, Ci.nsIObserver]),
392 init: function () {
393 this._shutdown = false;
394 configureLogging();
396 gExperimentsEnabled = gPrefs.get(PREF_ENABLED, false);
397 this._log.trace("enabled=" + gExperimentsEnabled + ", " + this.enabled);
399 gPrefs.observe(PREF_LOGGING, configureLogging);
400 gPrefs.observe(PREF_MANIFEST_URI, this.updateManifest, this);
401 gPrefs.observe(PREF_ENABLED, this._toggleExperimentsEnabled, this);
403 gPrefsTelemetry.observe(PREF_TELEMETRY_ENABLED, this._telemetryStatusChanged, this);
405 AsyncShutdown.profileBeforeChange.addBlocker("Experiments.jsm shutdown",
406 this.uninit.bind(this));
408 this._registerWithAddonManager();
410 let deferred = Promise.defer();
412 this._loadTask = this._loadFromCache();
413 this._loadTask.then(
414 () => {
415 this._log.trace("_loadTask finished ok");
416 this._loadTask = null;
417 this._run().then(deferred.resolve, deferred.reject);
418 },
419 (e) => {
420 this._log.error("_loadFromCache caught error: " + e);
421 deferred.reject(e);
422 }
423 );
425 return deferred.promise;
426 },
428 /**
429 * Uninitialize this instance.
430 *
431 * This function is susceptible to race conditions. If it is called multiple
432 * times before the previous uninit() has completed or if it is called while
433 * an init() operation is being performed, the object may get in bad state
434 * and/or deadlock could occur.
435 *
436 * @return Promise<>
437 * The promise is fulfilled when all pending tasks are finished.
438 */
439 uninit: Task.async(function* () {
440 this._log.trace("uninit: started");
441 yield this._loadTask;
442 this._log.trace("uninit: finished with _loadTask");
444 if (!this._shutdown) {
445 this._log.trace("uninit: no previous shutdown");
446 this._unregisterWithAddonManager();
448 gPrefs.ignore(PREF_LOGGING, configureLogging);
449 gPrefs.ignore(PREF_MANIFEST_URI, this.updateManifest, this);
450 gPrefs.ignore(PREF_ENABLED, this._toggleExperimentsEnabled, this);
452 gPrefsTelemetry.ignore(PREF_TELEMETRY_ENABLED, this._telemetryStatusChanged, this);
454 if (this._timer) {
455 this._timer.clear();
456 }
457 }
459 this._shutdown = true;
460 if (this._mainTask) {
461 try {
462 this._log.trace("uninit: waiting on _mainTask");
463 yield this._mainTask;
464 } catch (e if e instanceof AlreadyShutdownError) {
465 // We error out of tasks after shutdown via that exception.
466 }
467 }
469 this._log.info("Completed uninitialization.");
470 }),
472 _registerWithAddonManager: function (previousExperimentsProvider) {
473 this._log.trace("Registering instance with Addon Manager.");
475 AddonManager.addAddonListener(this);
476 AddonManager.addInstallListener(this);
478 if (!gAddonProvider) {
479 // The properties of this AddonType should be kept in sync with the
480 // experiment AddonType registered in XPIProvider.
481 this._log.trace("Registering previous experiment add-on provider.");
482 gAddonProvider = previousExperimentsProvider || new Experiments.PreviousExperimentProvider(this);
483 AddonManagerPrivate.registerProvider(gAddonProvider, [
484 new AddonManagerPrivate.AddonType("experiment",
485 URI_EXTENSION_STRINGS,
486 STRING_TYPE_NAME,
487 AddonManager.VIEW_TYPE_LIST,
488 11000,
489 AddonManager.TYPE_UI_HIDE_EMPTY),
490 ]);
491 }
493 },
495 _unregisterWithAddonManager: function () {
496 this._log.trace("Unregistering instance with Addon Manager.");
498 if (gAddonProvider) {
499 this._log.trace("Unregistering previous experiment add-on provider.");
500 AddonManagerPrivate.unregisterProvider(gAddonProvider);
501 gAddonProvider = null;
502 }
504 AddonManager.removeInstallListener(this);
505 AddonManager.removeAddonListener(this);
506 },
508 /*
509 * Change the PreviousExperimentsProvider that this instance uses.
510 * For testing only.
511 */
512 _setPreviousExperimentsProvider: function (provider) {
513 this._unregisterWithAddonManager();
514 this._registerWithAddonManager(provider);
515 },
517 /**
518 * Throws an exception if we've already shut down.
519 */
520 _checkForShutdown: function() {
521 if (this._shutdown) {
522 throw new AlreadyShutdownError("uninit() already called");
523 }
524 },
526 /**
527 * Whether the experiments feature is enabled.
528 */
529 get enabled() {
530 return gExperimentsEnabled;
531 },
533 /**
534 * Toggle whether the experiments feature is enabled or not.
535 */
536 set enabled(enabled) {
537 this._log.trace("set enabled(" + enabled + ")");
538 gPrefs.set(PREF_ENABLED, enabled);
539 },
541 _toggleExperimentsEnabled: Task.async(function* (enabled) {
542 this._log.trace("_toggleExperimentsEnabled(" + enabled + ")");
543 let wasEnabled = gExperimentsEnabled;
544 gExperimentsEnabled = enabled && telemetryEnabled();
546 if (wasEnabled == gExperimentsEnabled) {
547 return;
548 }
550 if (gExperimentsEnabled) {
551 yield this.updateManifest();
552 } else {
553 yield this.disableExperiment(TELEMETRY_LOG.TERMINATION.SERVICE_DISABLED);
554 if (this._timer) {
555 this._timer.clear();
556 }
557 }
558 }),
560 _telemetryStatusChanged: function () {
561 this._toggleExperimentsEnabled(gExperimentsEnabled);
562 },
564 /**
565 * Returns a promise that is resolved with an array of `ExperimentInfo` objects,
566 * which provide info on the currently and recently active experiments.
567 * The array is in chronological order.
568 *
569 * The experiment info is of the form:
570 * {
571 * id: <string>,
572 * name: <string>,
573 * description: <string>,
574 * active: <boolean>,
575 * endDate: <integer>, // epoch ms
576 * detailURL: <string>,
577 * ... // possibly extended later
578 * }
579 *
580 * @return Promise<Array<ExperimentInfo>> Array of experiment info objects.
581 */
582 getExperiments: function () {
583 return Task.spawn(function*() {
584 yield this._loadTask;
585 let list = [];
587 for (let [id, experiment] of this._experiments) {
588 if (!experiment.startDate) {
589 // We only collect experiments that are or were active.
590 continue;
591 }
593 list.push({
594 id: id,
595 name: experiment._name,
596 description: experiment._description,
597 active: experiment.enabled,
598 endDate: experiment.endDate.getTime(),
599 detailURL: experiment._homepageURL,
600 branch: experiment.branch,
601 });
602 }
604 // Sort chronologically, descending.
605 list.sort((a, b) => b.endDate - a.endDate);
606 return list;
607 }.bind(this));
608 },
610 /**
611 * Returns the ExperimentInfo for the active experiment, or null
612 * if there is none.
613 */
614 getActiveExperiment: function () {
615 let experiment = this._getActiveExperiment();
616 if (!experiment) {
617 return null;
618 }
620 let info = {
621 id: experiment.id,
622 name: experiment._name,
623 description: experiment._description,
624 active: experiment.enabled,
625 endDate: experiment.endDate.getTime(),
626 detailURL: experiment._homepageURL,
627 };
629 return info;
630 },
632 /**
633 * Experiment "branch" support. If an experiment has multiple branches, it
634 * can record the branch with the experiment system and it will
635 * automatically be included in data reporting (FHR/telemetry payloads).
636 */
638 /**
639 * Set the experiment branch for the specified experiment ID.
640 * @returns Promise<>
641 */
642 setExperimentBranch: Task.async(function*(id, branchstr) {
643 yield this._loadTask;
644 let e = this._experiments.get(id);
645 if (!e) {
646 throw new Error("Experiment not found");
647 }
648 e.branch = String(branchstr);
649 this._dirty = true;
650 Services.obs.notifyObservers(null, EXPERIMENTS_CHANGED_TOPIC, null);
651 yield this._run();
652 }),
653 /**
654 * Get the branch of the specified experiment. If the experiment is unknown,
655 * throws an error.
656 *
657 * @param id The ID of the experiment. Pass null for the currently running
658 * experiment.
659 * @returns Promise<string|null>
660 * @throws Error if the specified experiment ID is unknown, or if there is no
661 * current experiment.
662 */
663 getExperimentBranch: Task.async(function*(id=null) {
664 yield this._loadTask;
665 let e;
666 if (id) {
667 e = this._experiments.get(id);
668 if (!e) {
669 throw new Error("Experiment not found");
670 }
671 } else {
672 e = this._getActiveExperiment();
673 if (e === null) {
674 throw new Error("No active experiment");
675 }
676 }
677 return e.branch;
678 }),
680 /**
681 * Determine whether another date has the same UTC day as now().
682 */
683 _dateIsTodayUTC: function (d) {
684 let now = this._policy.now();
686 return stripDateToMidnight(now).getTime() == stripDateToMidnight(d).getTime();
687 },
689 /**
690 * Obtain the entry of the most recent active experiment that was active
691 * today.
692 *
693 * If no experiment was active today, this resolves to nothing.
694 *
695 * Assumption: Only a single experiment can be active at a time.
696 *
697 * @return Promise<object>
698 */
699 lastActiveToday: function () {
700 return Task.spawn(function* getMostRecentActiveExperimentTask() {
701 let experiments = yield this.getExperiments();
703 // Assumption: Ordered chronologically, descending, with active always
704 // first.
705 for (let experiment of experiments) {
706 if (experiment.active) {
707 return experiment;
708 }
710 if (experiment.endDate && this._dateIsTodayUTC(experiment.endDate)) {
711 return experiment;
712 }
713 }
714 return null;
715 }.bind(this));
716 },
718 _run: function() {
719 this._log.trace("_run");
720 this._checkForShutdown();
721 if (!this._mainTask) {
722 this._mainTask = Task.spawn(this._main.bind(this));
723 this._mainTask.then(
724 () => {
725 this._log.trace("_main finished, scheduling next run");
726 this._mainTask = null;
727 this._scheduleNextRun();
728 },
729 (e) => {
730 this._log.error("_main caught error: " + e);
731 this._mainTask = null;
732 }
733 );
734 }
735 return this._mainTask;
736 },
738 _main: function*() {
739 do {
740 this._log.trace("_main iteration");
741 yield this._loadTask;
742 if (!gExperimentsEnabled) {
743 this._refresh = false;
744 }
746 if (this._refresh) {
747 yield this._loadManifest();
748 }
749 yield this._evaluateExperiments();
750 if (this._dirty) {
751 yield this._saveToCache();
752 }
753 // If somebody called .updateManifest() or disableExperiment()
754 // while we were running, go again right now.
755 }
756 while (this._refresh || this._terminateReason);
757 },
759 _loadManifest: function*() {
760 this._log.trace("_loadManifest");
761 let uri = Services.urlFormatter.formatURLPref(PREF_BRANCH + PREF_MANIFEST_URI);
763 this._checkForShutdown();
765 this._refresh = false;
766 try {
767 let responseText = yield this._httpGetRequest(uri);
768 this._log.trace("_loadManifest() - responseText=\"" + responseText + "\"");
770 if (this._shutdown) {
771 return;
772 }
774 let data = JSON.parse(responseText);
775 this._updateExperiments(data);
776 } catch (e) {
777 this._log.error("_loadManifest - failure to fetch/parse manifest (continuing anyway): " + e);
778 }
779 },
781 /**
782 * Fetch an updated list of experiments and trigger experiment updates.
783 * Do only use when experiments are enabled.
784 *
785 * @return Promise<>
786 * The promise is resolved when the manifest and experiment list is updated.
787 */
788 updateManifest: function () {
789 this._log.trace("updateManifest()");
791 if (!gExperimentsEnabled) {
792 return Promise.reject(new Error("experiments are disabled"));
793 }
795 if (this._shutdown) {
796 return Promise.reject(Error("uninit() alrady called"));
797 }
799 this._refresh = true;
800 return this._run();
801 },
803 notify: function (timer) {
804 this._log.trace("notify()");
805 this._checkForShutdown();
806 return this._run();
807 },
809 // START OF ADD-ON LISTENERS
811 onUninstalled: function (addon) {
812 this._log.trace("onUninstalled() - addon id: " + addon.id);
813 if (gActiveUninstallAddonIDs.has(addon.id)) {
814 this._log.trace("matches pending uninstall");
815 return;
816 }
817 let activeExperiment = this._getActiveExperiment();
818 if (!activeExperiment || activeExperiment._addonId != addon.id) {
819 return;
820 }
822 this.disableExperiment(TELEMETRY_LOG.TERMINATION.ADDON_UNINSTALLED);
823 },
825 onInstallStarted: function (install) {
826 if (install.addon.type != "experiment") {
827 return;
828 }
830 this._log.trace("onInstallStarted() - " + install.addon.id);
831 if (install.addon.appDisabled) {
832 // This is a PreviousExperiment
833 return;
834 }
836 // We want to be in control of all experiment add-ons: reject installs
837 // for add-ons that we don't know about.
839 // We have a race condition of sorts to worry about here. We have 2
840 // onInstallStarted listeners. This one (the global one) and the one
841 // created as part of ExperimentEntry._installAddon. Because of the order
842 // they are registered in, this one likely executes first. Unfortunately,
843 // this means that the add-on ID is not yet set on the ExperimentEntry.
844 // So, we can't just look at this._trackedAddonIds because the new experiment
845 // will have its add-on ID set to null. We work around this by storing a
846 // identifying field - the source URL of the install - in a module-level
847 // variable (so multiple Experiments instances doesn't cancel each other
848 // out).
850 if (this._trackedAddonIds.has(install.addon.id)) {
851 this._log.info("onInstallStarted allowing install because add-on ID " +
852 "tracked by us.");
853 return;
854 }
856 if (gActiveInstallURLs.has(install.sourceURI.spec)) {
857 this._log.info("onInstallStarted allowing install because install " +
858 "tracked by us.");
859 return;
860 }
862 this._log.warn("onInstallStarted cancelling install of unknown " +
863 "experiment add-on: " + install.addon.id);
864 return false;
865 },
867 // END OF ADD-ON LISTENERS.
869 _getExperimentByAddonId: function (addonId) {
870 for (let [, entry] of this._experiments) {
871 if (entry._addonId === addonId) {
872 return entry;
873 }
874 }
876 return null;
877 },
879 /*
880 * Helper function to make HTTP GET requests. Returns a promise that is resolved with
881 * the responseText when the request is complete.
882 */
883 _httpGetRequest: function (url) {
884 this._log.trace("httpGetRequest(" + url + ")");
885 let xhr = Cc["@mozilla.org/xmlextras/xmlhttprequest;1"].createInstance(Ci.nsIXMLHttpRequest);
886 try {
887 xhr.open("GET", url);
888 } catch (e) {
889 this._log.error("httpGetRequest() - Error opening request to " + url + ": " + e);
890 return Promise.reject(new Error("Experiments - Error opening XHR for " + url));
891 }
893 let deferred = Promise.defer();
895 let log = this._log;
896 xhr.onerror = function (e) {
897 log.error("httpGetRequest::onError() - Error making request to " + url + ": " + e.error);
898 deferred.reject(new Error("Experiments - XHR error for " + url + " - " + e.error));
899 };
901 xhr.onload = function (event) {
902 if (xhr.status !== 200 && xhr.state !== 0) {
903 log.error("httpGetRequest::onLoad() - Request to " + url + " returned status " + xhr.status);
904 deferred.reject(new Error("Experiments - XHR status for " + url + " is " + xhr.status));
905 return;
906 }
908 let certs = null;
909 if (gPrefs.get(PREF_MANIFEST_CHECKCERT, true)) {
910 certs = CertUtils.readCertPrefs(PREF_BRANCH + "manifest.certs.");
911 }
912 try {
913 let allowNonBuiltin = !gPrefs.get(PREF_MANIFEST_REQUIREBUILTIN, true);
914 CertUtils.checkCert(xhr.channel, allowNonBuiltin, certs);
915 }
916 catch (e) {
917 log.error("manifest fetch failed certificate checks", [e]);
918 deferred.reject(new Error("Experiments - manifest fetch failed certificate checks: " + e));
919 return;
920 }
922 deferred.resolve(xhr.responseText);
923 };
925 if (xhr.channel instanceof Ci.nsISupportsPriority) {
926 xhr.channel.priority = Ci.nsISupportsPriority.PRIORITY_LOWEST;
927 }
929 xhr.send(null);
930 return deferred.promise;
931 },
933 /*
934 * Path of the cache file we use in the profile.
935 */
936 get _cacheFilePath() {
937 return OS.Path.join(OS.Constants.Path.profileDir, FILE_CACHE);
938 },
940 /*
941 * Part of the main task to save the cache to disk, called from _main.
942 */
943 _saveToCache: function* () {
944 this._log.trace("_saveToCache");
945 let path = this._cacheFilePath;
946 let textData = JSON.stringify({
947 version: CACHE_VERSION,
948 data: [e[1].toJSON() for (e of this._experiments.entries())],
949 });
951 let encoder = new TextEncoder();
952 let data = encoder.encode(textData);
953 let options = { tmpPath: path + ".tmp", compression: "lz4" };
954 yield OS.File.writeAtomic(path, data, options);
955 this._dirty = false;
956 this._log.debug("_saveToCache saved to " + path);
957 },
959 /*
960 * Task function, load the cached experiments manifest file from disk.
961 */
962 _loadFromCache: Task.async(function* () {
963 this._log.trace("_loadFromCache");
964 let path = this._cacheFilePath;
965 try {
966 let result = yield loadJSONAsync(path, { compression: "lz4" });
967 this._populateFromCache(result);
968 } catch (e if e instanceof OS.File.Error && e.becauseNoSuchFile) {
969 // No cached manifest yet.
970 this._experiments = new Map();
971 }
972 }),
974 _populateFromCache: function (data) {
975 this._log.trace("populateFromCache() - data: " + JSON.stringify(data));
977 // If the user has a newer cache version than we can understand, we fail
978 // hard; no experiments should be active in this older client.
979 if (CACHE_VERSION !== data.version) {
980 throw new Error("Experiments::_populateFromCache() - invalid cache version");
981 }
983 let experiments = new Map();
984 for (let item of data.data) {
985 let entry = new Experiments.ExperimentEntry(this._policy);
986 if (!entry.initFromCacheData(item)) {
987 continue;
988 }
989 experiments.set(entry.id, entry);
990 }
992 this._experiments = experiments;
993 },
995 /*
996 * Update the experiment entries from the experiments
997 * array in the manifest
998 */
999 _updateExperiments: function (manifestObject) {
1000 this._log.trace("_updateExperiments() - experiments: " + JSON.stringify(manifestObject));
1002 if (manifestObject.version !== MANIFEST_VERSION) {
1003 this._log.warning("updateExperiments() - unsupported version " + manifestObject.version);
1004 }
1006 let experiments = new Map(); // The new experiments map
1008 // Collect new and updated experiments.
1009 for (let data of manifestObject.experiments) {
1010 let entry = this._experiments.get(data.id);
1012 if (entry) {
1013 if (!entry.updateFromManifestData(data)) {
1014 this._log.error("updateExperiments() - Invalid manifest data for " + data.id);
1015 continue;
1016 }
1017 } else {
1018 entry = new Experiments.ExperimentEntry(this._policy);
1019 if (!entry.initFromManifestData(data)) {
1020 continue;
1021 }
1022 }
1024 if (entry.shouldDiscard()) {
1025 continue;
1026 }
1028 experiments.set(entry.id, entry);
1029 }
1031 // Make sure we keep experiments that are or were running.
1032 // We remove them after KEEP_HISTORY_N_DAYS.
1033 for (let [id, entry] of this._experiments) {
1034 if (experiments.has(id)) {
1035 continue;
1036 }
1038 if (!entry.startDate || entry.shouldDiscard()) {
1039 this._log.trace("updateExperiments() - discarding entry for " + id);
1040 continue;
1041 }
1043 experiments.set(id, entry);
1044 }
1046 this._experiments = experiments;
1047 this._dirty = true;
1048 },
1050 getActiveExperimentID: function() {
1051 if (!this._experiments) {
1052 return null;
1053 }
1054 let e = this._getActiveExperiment();
1055 if (!e) {
1056 return null;
1057 }
1058 return e.id;
1059 },
1061 getActiveExperimentBranch: function() {
1062 if (!this._experiments) {
1063 return null;
1064 }
1065 let e = this._getActiveExperiment();
1066 if (!e) {
1067 return null;
1068 }
1069 return e.branch;
1070 },
1072 _getActiveExperiment: function () {
1073 let enabled = [experiment for ([,experiment] of this._experiments) if (experiment._enabled)];
1075 if (enabled.length == 1) {
1076 return enabled[0];
1077 }
1079 if (enabled.length > 1) {
1080 this._log.error("getActiveExperimentId() - should not have more than 1 active experiment");
1081 throw new Error("have more than 1 active experiment");
1082 }
1084 return null;
1085 },
1087 /**
1088 * Disables all active experiments.
1089 *
1090 * @return Promise<> Promise that will get resolved once the task is done or failed.
1091 */
1092 disableExperiment: function (reason) {
1093 if (!reason) {
1094 throw new Error("Must specify a termination reason.");
1095 }
1097 this._log.trace("disableExperiment()");
1098 this._terminateReason = reason;
1099 return this._run();
1100 },
1102 /**
1103 * The Set of add-on IDs that we know about from manifests.
1104 */
1105 get _trackedAddonIds() {
1106 if (!this._experiments) {
1107 return new Set();
1108 }
1110 return new Set([e._addonId for ([,e] of this._experiments) if (e._addonId)]);
1111 },
1113 /*
1114 * Task function to check applicability of experiments, disable the active
1115 * experiment if needed and activate the first applicable candidate.
1116 */
1117 _evaluateExperiments: function*() {
1118 this._log.trace("_evaluateExperiments");
1120 this._checkForShutdown();
1122 // The first thing we do is reconcile our state against what's in the
1123 // Addon Manager. It's possible that the Addon Manager knows of experiment
1124 // add-ons that we don't. This could happen if an experiment gets installed
1125 // when we're not listening or if there is a bug in our synchronization
1126 // code.
1127 //
1128 // We have a few options of what to do with unknown experiment add-ons
1129 // coming from the Addon Manager. Ideally, we'd convert these to
1130 // ExperimentEntry instances and stuff them inside this._experiments.
1131 // However, since ExperimentEntry contain lots of metadata from the
1132 // manifest and trying to make up data could be error prone, it's safer
1133 // to not try. Furthermore, if an experiment really did come from us, we
1134 // should have some record of it. In the end, we decide to discard all
1135 // knowledge for these unknown experiment add-ons.
1136 let installedExperiments = yield installedExperimentAddons();
1137 let expectedAddonIds = this._trackedAddonIds;
1138 let unknownAddons = [a for (a of installedExperiments) if (!expectedAddonIds.has(a.id))];
1139 if (unknownAddons.length) {
1140 this._log.warn("_evaluateExperiments() - unknown add-ons in AddonManager: " +
1141 [a.id for (a of unknownAddons)].join(", "));
1143 yield uninstallAddons(unknownAddons);
1144 }
1146 let activeExperiment = this._getActiveExperiment();
1147 let activeChanged = false;
1148 let now = this._policy.now();
1150 if (!activeExperiment) {
1151 // Avoid this pref staying out of sync if there were e.g. crashes.
1152 gPrefs.set(PREF_ACTIVE_EXPERIMENT, false);
1153 }
1155 // Ensure the active experiment is in the proper state. This may install,
1156 // uninstall, upgrade, or enable the experiment add-on. What exactly is
1157 // abstracted away from us by design.
1158 if (activeExperiment) {
1159 let changes;
1160 let shouldStopResult = yield activeExperiment.shouldStop();
1161 if (shouldStopResult.shouldStop) {
1162 let expireReasons = ["endTime", "maxActiveSeconds"];
1163 let kind, reason;
1165 if (expireReasons.indexOf(shouldStopResult.reason[0]) != -1) {
1166 kind = TELEMETRY_LOG.TERMINATION.EXPIRED;
1167 reason = null;
1168 } else {
1169 kind = TELEMETRY_LOG.TERMINATION.RECHECK;
1170 reason = shouldStopResult.reason;
1171 }
1172 changes = yield activeExperiment.stop(kind, reason);
1173 }
1174 else if (this._terminateReason) {
1175 changes = yield activeExperiment.stop(this._terminateReason);
1176 }
1177 else {
1178 changes = yield activeExperiment.reconcileAddonState();
1179 }
1181 if (changes) {
1182 this._dirty = true;
1183 activeChanged = true;
1184 }
1186 if (!activeExperiment._enabled) {
1187 activeExperiment = null;
1188 activeChanged = true;
1189 }
1190 }
1192 this._terminateReason = null;
1194 if (!activeExperiment && gExperimentsEnabled) {
1195 for (let [id, experiment] of this._experiments) {
1196 let applicable;
1197 let reason = null;
1198 try {
1199 applicable = yield experiment.isApplicable();
1200 }
1201 catch (e) {
1202 applicable = false;
1203 reason = e;
1204 }
1206 if (!applicable && reason && reason[0] != "was-active") {
1207 // Report this from here to avoid over-reporting.
1208 let desc = TELEMETRY_LOG.ACTIVATION;
1209 let data = [TELEMETRY_LOG.ACTIVATION.REJECTED, id];
1210 data = data.concat(reason);
1211 TelemetryLog.log(TELEMETRY_LOG.ACTIVATION_KEY, data);
1212 }
1214 if (!applicable) {
1215 continue;
1216 }
1218 this._log.debug("evaluateExperiments() - activating experiment " + id);
1219 try {
1220 yield experiment.start();
1221 activeChanged = true;
1222 activeExperiment = experiment;
1223 this._dirty = true;
1224 break;
1225 } catch (e) {
1226 // On failure, clean up the best we can and try the next experiment.
1227 this._log.error("evaluateExperiments() - Unable to start experiment: " + e.message);
1228 experiment._enabled = false;
1229 yield experiment.reconcileAddonState();
1230 }
1231 }
1232 }
1234 gPrefs.set(PREF_ACTIVE_EXPERIMENT, activeExperiment != null);
1236 if (activeChanged || this._firstEvaluate) {
1237 Services.obs.notifyObservers(null, EXPERIMENTS_CHANGED_TOPIC, null);
1238 this._firstEvaluate = false;
1239 }
1241 if ("@mozilla.org/toolkit/crash-reporter;1" in Cc && activeExperiment) {
1242 try {
1243 gCrashReporter.annotateCrashReport("ActiveExperiment", activeExperiment.id);
1244 } catch (e) {
1245 // It's ok if crash reporting is disabled.
1246 }
1247 }
1248 },
1250 /*
1251 * Schedule the soonest re-check of experiment applicability that is needed.
1252 */
1253 _scheduleNextRun: function () {
1254 this._checkForShutdown();
1256 if (this._timer) {
1257 this._timer.clear();
1258 }
1260 if (!gExperimentsEnabled || this._experiments.length == 0) {
1261 return;
1262 }
1264 let time = null;
1265 let now = this._policy.now().getTime();
1267 for (let [id, experiment] of this._experiments) {
1268 let scheduleTime = experiment.getScheduleTime();
1269 if (scheduleTime > now) {
1270 if (time !== null) {
1271 time = Math.min(time, scheduleTime);
1272 } else {
1273 time = scheduleTime;
1274 }
1275 }
1276 }
1278 if (time === null) {
1279 // No schedule time found.
1280 return;
1281 }
1283 this._log.trace("scheduleExperimentEvaluation() - scheduling for "+time+", now: "+now);
1284 this._policy.oneshotTimer(this.notify, time - now, this, "_timer");
1285 },
1286 };
1289 /*
1290 * Represents a single experiment.
1291 */
1293 Experiments.ExperimentEntry = function (policy) {
1294 this._policy = policy || new Experiments.Policy();
1295 this._log = Log.repository.getLoggerWithMessagePrefix(
1296 "Browser.Experiments.Experiments",
1297 "ExperimentEntry #" + gExperimentEntryCounter++ + "::");
1299 // Is the experiment supposed to be running.
1300 this._enabled = false;
1301 // When this experiment was started, if ever.
1302 this._startDate = null;
1303 // When this experiment was ended, if ever.
1304 this._endDate = null;
1305 // The condition data from the manifest.
1306 this._manifestData = null;
1307 // For an active experiment, signifies whether we need to update the xpi.
1308 this._needsUpdate = false;
1309 // A random sample value for comparison against the manifest conditions.
1310 this._randomValue = null;
1311 // When this entry was last changed for respecting history retention duration.
1312 this._lastChangedDate = null;
1313 // Has this experiment failed to activate before?
1314 this._failedStart = false;
1315 // The experiment branch
1316 this._branch = null;
1318 // We grab these from the addon after download.
1319 this._name = null;
1320 this._description = null;
1321 this._homepageURL = null;
1322 this._addonId = null;
1323 };
1325 Experiments.ExperimentEntry.prototype = {
1326 MANIFEST_REQUIRED_FIELDS: new Set([
1327 "id",
1328 "xpiURL",
1329 "xpiHash",
1330 "startTime",
1331 "endTime",
1332 "maxActiveSeconds",
1333 "appName",
1334 "channel",
1335 ]),
1337 MANIFEST_OPTIONAL_FIELDS: new Set([
1338 "maxStartTime",
1339 "minVersion",
1340 "maxVersion",
1341 "version",
1342 "minBuildID",
1343 "maxBuildID",
1344 "buildIDs",
1345 "os",
1346 "locale",
1347 "sample",
1348 "disabled",
1349 "frozen",
1350 "jsfilter",
1351 ]),
1353 SERIALIZE_KEYS: new Set([
1354 "_enabled",
1355 "_manifestData",
1356 "_needsUpdate",
1357 "_randomValue",
1358 "_failedStart",
1359 "_name",
1360 "_description",
1361 "_homepageURL",
1362 "_addonId",
1363 "_startDate",
1364 "_endDate",
1365 "_branch",
1366 ]),
1368 DATE_KEYS: new Set([
1369 "_startDate",
1370 "_endDate",
1371 ]),
1373 UPGRADE_KEYS: new Map([
1374 ["_branch", null],
1375 ]),
1377 ADDON_CHANGE_NONE: 0,
1378 ADDON_CHANGE_INSTALL: 1,
1379 ADDON_CHANGE_UNINSTALL: 2,
1380 ADDON_CHANGE_ENABLE: 4,
1382 /*
1383 * Initialize entry from the manifest.
1384 * @param data The experiment data from the manifest.
1385 * @return boolean Whether initialization succeeded.
1386 */
1387 initFromManifestData: function (data) {
1388 if (!this._isManifestDataValid(data)) {
1389 return false;
1390 }
1392 this._manifestData = data;
1394 this._randomValue = this._policy.random();
1395 this._lastChangedDate = this._policy.now();
1397 return true;
1398 },
1400 get enabled() {
1401 return this._enabled;
1402 },
1404 get id() {
1405 return this._manifestData.id;
1406 },
1408 get branch() {
1409 return this._branch;
1410 },
1412 set branch(v) {
1413 this._branch = v;
1414 },
1416 get startDate() {
1417 return this._startDate;
1418 },
1420 get endDate() {
1421 if (!this._startDate) {
1422 return null;
1423 }
1425 let endTime = 0;
1427 if (!this._enabled) {
1428 return this._endDate;
1429 }
1431 let maxActiveMs = 1000 * this._manifestData.maxActiveSeconds;
1432 endTime = Math.min(1000 * this._manifestData.endTime,
1433 this._startDate.getTime() + maxActiveMs);
1435 return new Date(endTime);
1436 },
1438 get needsUpdate() {
1439 return this._needsUpdate;
1440 },
1442 /*
1443 * Initialize entry from the cache.
1444 * @param data The entry data from the cache.
1445 * @return boolean Whether initialization succeeded.
1446 */
1447 initFromCacheData: function (data) {
1448 for (let [key, dval] of this.UPGRADE_KEYS) {
1449 if (!(key in data)) {
1450 data[key] = dval;
1451 }
1452 }
1454 for (let key of this.SERIALIZE_KEYS) {
1455 if (!(key in data) && !this.DATE_KEYS.has(key)) {
1456 this._log.error("initFromCacheData() - missing required key " + key);
1457 return false;
1458 }
1459 };
1461 if (!this._isManifestDataValid(data._manifestData)) {
1462 return false;
1463 }
1465 // Dates are restored separately from epoch ms, everything else is just
1466 // copied in.
1468 this.SERIALIZE_KEYS.forEach(key => {
1469 if (!this.DATE_KEYS.has(key)) {
1470 this[key] = data[key];
1471 }
1472 });
1474 this.DATE_KEYS.forEach(key => {
1475 if (key in data) {
1476 let date = new Date();
1477 date.setTime(data[key]);
1478 this[key] = date;
1479 }
1480 });
1482 this._lastChangedDate = this._policy.now();
1484 return true;
1485 },
1487 /*
1488 * Returns a JSON representation of this object.
1489 */
1490 toJSON: function () {
1491 let obj = {};
1493 // Dates are serialized separately as epoch ms.
1495 this.SERIALIZE_KEYS.forEach(key => {
1496 if (!this.DATE_KEYS.has(key)) {
1497 obj[key] = this[key];
1498 }
1499 });
1501 this.DATE_KEYS.forEach(key => {
1502 if (this[key]) {
1503 obj[key] = this[key].getTime();
1504 }
1505 });
1507 return obj;
1508 },
1510 /*
1511 * Update from the experiment data from the manifest.
1512 * @param data The experiment data from the manifest.
1513 * @return boolean Whether updating succeeded.
1514 */
1515 updateFromManifestData: function (data) {
1516 let old = this._manifestData;
1518 if (!this._isManifestDataValid(data)) {
1519 return false;
1520 }
1522 if (this._enabled) {
1523 if (old.xpiHash !== data.xpiHash) {
1524 // A changed hash means we need to update active experiments.
1525 this._needsUpdate = true;
1526 }
1527 } else if (this._failedStart &&
1528 (old.xpiHash !== data.xpiHash) ||
1529 (old.xpiURL !== data.xpiURL)) {
1530 // Retry installation of previously invalid experiments
1531 // if hash or url changed.
1532 this._failedStart = false;
1533 }
1535 this._manifestData = data;
1536 this._lastChangedDate = this._policy.now();
1538 return true;
1539 },
1541 /*
1542 * Is this experiment applicable?
1543 * @return Promise<> Resolved if the experiment is applicable.
1544 * If it is not applicable it is rejected with
1545 * a Promise<string> which contains the reason.
1546 */
1547 isApplicable: function () {
1548 let versionCmp = Cc["@mozilla.org/xpcom/version-comparator;1"]
1549 .getService(Ci.nsIVersionComparator);
1550 let app = Cc["@mozilla.org/xre/app-info;1"].getService(Ci.nsIXULAppInfo);
1551 let runtime = Cc["@mozilla.org/xre/app-info;1"]
1552 .getService(Ci.nsIXULRuntime);
1554 let locale = this._policy.locale();
1555 let channel = this._policy.updatechannel();
1556 let data = this._manifestData;
1558 let now = this._policy.now() / 1000; // The manifest times are in seconds.
1559 let minActive = MIN_EXPERIMENT_ACTIVE_SECONDS;
1560 let maxActive = data.maxActiveSeconds || 0;
1561 let startSec = (this.startDate || 0) / 1000;
1563 this._log.trace("isApplicable() - now=" + now
1564 + ", randomValue=" + this._randomValue
1565 + ", data=" + JSON.stringify(this._manifestData));
1567 // Not applicable if it already ran.
1569 if (!this.enabled && this._endDate) {
1570 return Promise.reject(["was-active"]);
1571 }
1573 // Define and run the condition checks.
1575 let simpleChecks = [
1576 { name: "failedStart",
1577 condition: () => !this._failedStart },
1578 { name: "disabled",
1579 condition: () => !data.disabled },
1580 { name: "frozen",
1581 condition: () => !data.frozen || this._enabled },
1582 { name: "startTime",
1583 condition: () => now >= data.startTime },
1584 { name: "endTime",
1585 condition: () => now < data.endTime },
1586 { name: "maxStartTime",
1587 condition: () => !data.maxStartTime || now <= data.maxStartTime },
1588 { name: "maxActiveSeconds",
1589 condition: () => !this._startDate || now <= (startSec + maxActive) },
1590 { name: "appName",
1591 condition: () => !data.appName || data.appName.indexOf(app.name) != -1 },
1592 { name: "minBuildID",
1593 condition: () => !data.minBuildID || app.platformBuildID >= data.minBuildID },
1594 { name: "maxBuildID",
1595 condition: () => !data.maxBuildID || app.platformBuildID <= data.maxBuildID },
1596 { name: "buildIDs",
1597 condition: () => !data.buildIDs || data.buildIDs.indexOf(app.platformBuildID) != -1 },
1598 { name: "os",
1599 condition: () => !data.os || data.os.indexOf(runtime.OS) != -1 },
1600 { name: "channel",
1601 condition: () => !data.channel || data.channel.indexOf(channel) != -1 },
1602 { name: "locale",
1603 condition: () => !data.locale || data.locale.indexOf(locale) != -1 },
1604 { name: "sample",
1605 condition: () => data.sample === undefined || this._randomValue <= data.sample },
1606 { name: "version",
1607 condition: () => !data.version || data.version.indexOf(app.version) != -1 },
1608 { name: "minVersion",
1609 condition: () => !data.minVersion || versionCmp.compare(app.version, data.minVersion) >= 0 },
1610 { name: "maxVersion",
1611 condition: () => !data.maxVersion || versionCmp.compare(app.version, data.maxVersion) <= 0 },
1612 ];
1614 for (let check of simpleChecks) {
1615 let result = check.condition();
1616 if (!result) {
1617 this._log.debug("isApplicable() - id="
1618 + data.id + " - test '" + check.name + "' failed");
1619 return Promise.reject([check.name]);
1620 }
1621 }
1623 if (data.jsfilter) {
1624 return this._runFilterFunction(data.jsfilter);
1625 }
1627 return Promise.resolve(true);
1628 },
1630 /*
1631 * Run the jsfilter function from the manifest in a sandbox and return the
1632 * result (forced to boolean).
1633 */
1634 _runFilterFunction: function (jsfilter) {
1635 this._log.trace("runFilterFunction() - filter: " + jsfilter);
1637 return Task.spawn(function ExperimentEntry_runFilterFunction_task() {
1638 const nullprincipal = Cc["@mozilla.org/nullprincipal;1"].createInstance(Ci.nsIPrincipal);
1639 let options = {
1640 sandboxName: "telemetry experiments jsfilter sandbox",
1641 wantComponents: false,
1642 };
1644 let sandbox = Cu.Sandbox(nullprincipal);
1645 let context = {};
1646 context.healthReportPayload = yield this._policy.healthReportPayload();
1647 context.telemetryPayload = yield this._policy.telemetryPayload();
1649 try {
1650 Cu.evalInSandbox(jsfilter, sandbox);
1651 } catch (e) {
1652 this._log.error("runFilterFunction() - failed to eval jsfilter: " + e.message);
1653 throw ["jsfilter-evalfailed"];
1654 }
1656 // You can't insert arbitrarily complex objects into a sandbox, so
1657 // we serialize everything through JSON.
1658 sandbox._hr = JSON.stringify(yield this._policy.healthReportPayload());
1659 Object.defineProperty(sandbox, "_t",
1660 { get: () => JSON.stringify(this._policy.telemetryPayload()) });
1662 let result = false;
1663 try {
1664 result = !!Cu.evalInSandbox("filter({healthReportPayload: JSON.parse(_hr), telemetryPayload: JSON.parse(_t)})", sandbox);
1665 }
1666 catch (e) {
1667 this._log.debug("runFilterFunction() - filter function failed: "
1668 + e.message + ", " + e.stack);
1669 throw ["jsfilter-threw", e.message];
1670 }
1671 finally {
1672 Cu.nukeSandbox(sandbox);
1673 }
1675 if (!result) {
1676 throw ["jsfilter-false"];
1677 }
1679 throw new Task.Result(true);
1680 }.bind(this));
1681 },
1683 /*
1684 * Start running the experiment.
1685 *
1686 * @return Promise<> Resolved when the operation is complete.
1687 */
1688 start: Task.async(function* () {
1689 this._log.trace("start() for " + this.id);
1691 this._enabled = true;
1692 return yield this.reconcileAddonState();
1693 }),
1695 // Async install of the addon for this experiment, part of the start task above.
1696 _installAddon: Task.async(function* () {
1697 let deferred = Promise.defer();
1699 let hash = this._policy.ignoreHashes ? null : this._manifestData.xpiHash;
1701 let install = yield addonInstallForURL(this._manifestData.xpiURL, hash);
1702 gActiveInstallURLs.add(install.sourceURI.spec);
1704 let failureHandler = (install, handler) => {
1705 let message = "AddonInstall " + handler + " for " + this.id + ", state=" +
1706 (install.state || "?") + ", error=" + install.error;
1707 this._log.error("_installAddon() - " + message);
1708 this._failedStart = true;
1709 gActiveInstallURLs.delete(install.sourceURI.spec);
1711 TelemetryLog.log(TELEMETRY_LOG.ACTIVATION_KEY,
1712 [TELEMETRY_LOG.ACTIVATION.INSTALL_FAILURE, this.id]);
1714 deferred.reject(new Error(message));
1715 };
1717 let listener = {
1718 _expectedID: null,
1720 onDownloadEnded: install => {
1721 this._log.trace("_installAddon() - onDownloadEnded for " + this.id);
1723 if (install.existingAddon) {
1724 this._log.warn("_installAddon() - onDownloadEnded, addon already installed");
1725 }
1727 if (install.addon.type !== "experiment") {
1728 this._log.error("_installAddon() - onDownloadEnded, wrong addon type");
1729 install.cancel();
1730 }
1731 },
1733 onInstallStarted: install => {
1734 this._log.trace("_installAddon() - onInstallStarted for " + this.id);
1736 if (install.existingAddon) {
1737 this._log.warn("_installAddon() - onInstallStarted, addon already installed");
1738 }
1740 if (install.addon.type !== "experiment") {
1741 this._log.error("_installAddon() - onInstallStarted, wrong addon type");
1742 return false;
1743 }
1744 },
1746 onInstallEnded: install => {
1747 this._log.trace("_installAddon() - install ended for " + this.id);
1748 gActiveInstallURLs.delete(install.sourceURI.spec);
1750 this._lastChangedDate = this._policy.now();
1751 this._startDate = this._policy.now();
1752 this._enabled = true;
1754 TelemetryLog.log(TELEMETRY_LOG.ACTIVATION_KEY,
1755 [TELEMETRY_LOG.ACTIVATION.ACTIVATED, this.id]);
1757 let addon = install.addon;
1758 this._name = addon.name;
1759 this._addonId = addon.id;
1760 this._description = addon.description || "";
1761 this._homepageURL = addon.homepageURL || "";
1763 // Experiment add-ons default to userDisabled=true. Enable if needed.
1764 if (addon.userDisabled) {
1765 this._log.trace("Add-on is disabled. Enabling.");
1766 listener._expectedID = addon.id;
1767 AddonManager.addAddonListener(listener);
1768 addon.userDisabled = false;
1769 } else {
1770 this._log.trace("Add-on is enabled. start() completed.");
1771 deferred.resolve();
1772 }
1773 },
1775 onEnabled: addon => {
1776 this._log.info("onEnabled() for " + addon.id);
1778 if (addon.id != listener._expectedID) {
1779 return;
1780 }
1782 AddonManager.removeAddonListener(listener);
1783 deferred.resolve();
1784 },
1785 };
1787 ["onDownloadCancelled", "onDownloadFailed", "onInstallCancelled", "onInstallFailed"]
1788 .forEach(what => {
1789 listener[what] = install => failureHandler(install, what)
1790 });
1792 install.addListener(listener);
1793 install.install();
1795 return yield deferred.promise;
1796 }),
1798 /**
1799 * Stop running the experiment if it is active.
1800 *
1801 * @param terminationKind (optional)
1802 * The termination kind, e.g. ADDON_UNINSTALLED or EXPIRED.
1803 * @param terminationReason (optional)
1804 * The termination reason details for termination kind RECHECK.
1805 * @return Promise<> Resolved when the operation is complete.
1806 */
1807 stop: Task.async(function* (terminationKind, terminationReason) {
1808 this._log.trace("stop() - id=" + this.id + ", terminationKind=" + terminationKind);
1809 if (!this._enabled) {
1810 throw new Error("Must not call stop() on an inactive experiment.");
1811 }
1813 this._enabled = false;
1814 let now = this._policy.now();
1815 this._lastChangedDate = now;
1816 this._endDate = now;
1818 let changes = yield this.reconcileAddonState();
1819 this._logTermination(terminationKind, terminationReason);
1821 if (terminationKind == TELEMETRY_LOG.TERMINATION.ADDON_UNINSTALLED) {
1822 changes |= this.ADDON_CHANGE_UNINSTALL;
1823 }
1825 return changes;
1826 }),
1828 /**
1829 * Reconcile the state of the add-on against what it's supposed to be.
1830 *
1831 * If we are active, ensure the add-on is enabled and up to date.
1832 *
1833 * If we are inactive, ensure the add-on is not installed.
1834 */
1835 reconcileAddonState: Task.async(function* () {
1836 this._log.trace("reconcileAddonState()");
1838 if (!this._enabled) {
1839 if (!this._addonId) {
1840 this._log.trace("reconcileAddonState() - Experiment is not enabled and " +
1841 "has no add-on. Doing nothing.");
1842 return this.ADDON_CHANGE_NONE;
1843 }
1845 let addon = yield this._getAddon();
1846 if (!addon) {
1847 this._log.trace("reconcileAddonState() - Inactive experiment has no " +
1848 "add-on. Doing nothing.");
1849 return this.ADDON_CHANGE_NONE;
1850 }
1852 this._log.info("reconcileAddonState() - Uninstalling add-on for inactive " +
1853 "experiment: " + addon.id);
1854 gActiveUninstallAddonIDs.add(addon.id);
1855 yield uninstallAddons([addon]);
1856 gActiveUninstallAddonIDs.delete(addon.id);
1857 return this.ADDON_CHANGE_UNINSTALL;
1858 }
1860 // If we get here, we're supposed to be active.
1862 let changes = 0;
1864 // That requires an add-on.
1865 let currentAddon = yield this._getAddon();
1867 // If we have an add-on but it isn't up to date, uninstall it
1868 // (to prepare for reinstall).
1869 if (currentAddon && this._needsUpdate) {
1870 this._log.info("reconcileAddonState() - Uninstalling add-on because update " +
1871 "needed: " + currentAddon.id);
1872 gActiveUninstallAddonIDs.add(currentAddon.id);
1873 yield uninstallAddons([currentAddon]);
1874 gActiveUninstallAddonIDs.delete(currentAddon.id);
1875 changes |= this.ADDON_CHANGE_UNINSTALL;
1876 }
1878 if (!currentAddon || this._needsUpdate) {
1879 this._log.info("reconcileAddonState() - Installing add-on.");
1880 yield this._installAddon();
1881 changes |= this.ADDON_CHANGE_INSTALL;
1882 }
1884 let addon = yield this._getAddon();
1885 if (!addon) {
1886 throw new Error("Could not obtain add-on for experiment that should be " +
1887 "enabled.");
1888 }
1890 // If we have the add-on and it is enabled, we are done.
1891 if (!addon.userDisabled) {
1892 return changes;
1893 }
1895 let deferred = Promise.defer();
1897 // Else we need to enable it.
1898 let listener = {
1899 onEnabled: enabledAddon => {
1900 if (enabledAddon.id != addon.id) {
1901 return;
1902 }
1904 AddonManager.removeAddonListener(listener);
1905 deferred.resolve();
1906 },
1907 };
1909 this._log.info("Activating add-on: " + addon.id);
1910 AddonManager.addAddonListener(listener);
1911 addon.userDisabled = false;
1912 yield deferred.promise;
1913 changes |= this.ADDON_CHANGE_ENABLE;
1915 this._log.info("Add-on has been enabled: " + addon.id);
1916 return changes;
1917 }),
1919 /**
1920 * Obtain the underlying Addon from the Addon Manager.
1921 *
1922 * @return Promise<Addon|null>
1923 */
1924 _getAddon: function () {
1925 if (!this._addonId) {
1926 return Promise.resolve(null);
1927 }
1929 let deferred = Promise.defer();
1931 AddonManager.getAddonByID(this._addonId, (addon) => {
1932 if (addon && addon.appDisabled) {
1933 // Don't return PreviousExperiments.
1934 addon = null;
1935 }
1937 deferred.resolve(addon);
1938 });
1940 return deferred.promise;
1941 },
1943 _logTermination: function (terminationKind, terminationReason) {
1944 if (terminationKind === undefined) {
1945 return;
1946 }
1948 if (!(terminationKind in TELEMETRY_LOG.TERMINATION)) {
1949 this._log.warn("stop() - unknown terminationKind " + terminationKind);
1950 return;
1951 }
1953 let data = [terminationKind, this.id];
1954 if (terminationReason) {
1955 data = data.concat(terminationReason);
1956 }
1958 TelemetryLog.log(TELEMETRY_LOG.TERMINATION_KEY, data);
1959 },
1961 /**
1962 * Determine whether an active experiment should be stopped.
1963 */
1964 shouldStop: function () {
1965 if (!this._enabled) {
1966 throw new Error("shouldStop must not be called on disabled experiments.");
1967 }
1969 let data = this._manifestData;
1970 let now = this._policy.now() / 1000; // The manifest times are in seconds.
1971 let maxActiveSec = data.maxActiveSeconds || 0;
1973 let deferred = Promise.defer();
1974 this.isApplicable().then(
1975 () => deferred.resolve({shouldStop: false}),
1976 reason => deferred.resolve({shouldStop: true, reason: reason})
1977 );
1979 return deferred.promise;
1980 },
1982 /*
1983 * Should this be discarded from the cache due to age?
1984 */
1985 shouldDiscard: function () {
1986 let limit = this._policy.now();
1987 limit.setDate(limit.getDate() - KEEP_HISTORY_N_DAYS);
1988 return (this._lastChangedDate < limit);
1989 },
1991 /*
1992 * Get next date (in epoch-ms) to schedule a re-evaluation for this.
1993 * Returns 0 if it doesn't need one.
1994 */
1995 getScheduleTime: function () {
1996 if (this._enabled) {
1997 let now = this._policy.now();
1998 let startTime = this._startDate.getTime();
1999 let maxActiveTime = startTime + 1000 * this._manifestData.maxActiveSeconds;
2000 return Math.min(1000 * this._manifestData.endTime, maxActiveTime);
2001 }
2003 if (this._endDate) {
2004 return this._endDate.getTime();
2005 }
2007 return 1000 * this._manifestData.startTime;
2008 },
2010 /*
2011 * Perform sanity checks on the experiment data.
2012 */
2013 _isManifestDataValid: function (data) {
2014 this._log.trace("isManifestDataValid() - data: " + JSON.stringify(data));
2016 for (let key of this.MANIFEST_REQUIRED_FIELDS) {
2017 if (!(key in data)) {
2018 this._log.error("isManifestDataValid() - missing required key: " + key);
2019 return false;
2020 }
2021 }
2023 for (let key in data) {
2024 if (!this.MANIFEST_OPTIONAL_FIELDS.has(key) &&
2025 !this.MANIFEST_REQUIRED_FIELDS.has(key)) {
2026 this._log.error("isManifestDataValid() - unknown key: " + key);
2027 return false;
2028 }
2029 }
2031 return true;
2032 },
2033 };
2037 /**
2038 * Strip a Date down to its UTC midnight.
2039 *
2040 * This will return a cloned Date object. The original is unchanged.
2041 */
2042 let stripDateToMidnight = function (d) {
2043 let m = new Date(d);
2044 m.setUTCHours(0, 0, 0, 0);
2046 return m;
2047 };
2049 function ExperimentsLastActiveMeasurement1() {
2050 Metrics.Measurement.call(this);
2051 }
2052 function ExperimentsLastActiveMeasurement2() {
2053 Metrics.Measurement.call(this);
2054 }
2056 const FIELD_DAILY_LAST_TEXT = {type: Metrics.Storage.FIELD_DAILY_LAST_TEXT};
2058 ExperimentsLastActiveMeasurement1.prototype = Object.freeze({
2059 __proto__: Metrics.Measurement.prototype,
2061 name: "info",
2062 version: 1,
2064 fields: {
2065 lastActive: FIELD_DAILY_LAST_TEXT,
2066 }
2067 });
2068 ExperimentsLastActiveMeasurement2.prototype = Object.freeze({
2069 __proto__: Metrics.Measurement.prototype,
2071 name: "info",
2072 version: 2,
2074 fields: {
2075 lastActive: FIELD_DAILY_LAST_TEXT,
2076 lastActiveBranch: FIELD_DAILY_LAST_TEXT,
2077 }
2078 });
2080 this.ExperimentsProvider = function () {
2081 Metrics.Provider.call(this);
2083 this._experiments = null;
2084 };
2086 ExperimentsProvider.prototype = Object.freeze({
2087 __proto__: Metrics.Provider.prototype,
2089 name: "org.mozilla.experiments",
2091 measurementTypes: [
2092 ExperimentsLastActiveMeasurement1,
2093 ExperimentsLastActiveMeasurement2,
2094 ],
2096 _OBSERVERS: [
2097 EXPERIMENTS_CHANGED_TOPIC,
2098 ],
2100 postInit: function () {
2101 for (let o of this._OBSERVERS) {
2102 Services.obs.addObserver(this, o, false);
2103 }
2105 return Promise.resolve();
2106 },
2108 onShutdown: function () {
2109 for (let o of this._OBSERVERS) {
2110 Services.obs.removeObserver(this, o);
2111 }
2113 return Promise.resolve();
2114 },
2116 observe: function (subject, topic, data) {
2117 switch (topic) {
2118 case EXPERIMENTS_CHANGED_TOPIC:
2119 this.recordLastActiveExperiment();
2120 break;
2121 }
2122 },
2124 collectDailyData: function () {
2125 return this.recordLastActiveExperiment();
2126 },
2128 recordLastActiveExperiment: function () {
2129 if (!gExperimentsEnabled) {
2130 return Promise.resolve();
2131 }
2133 if (!this._experiments) {
2134 this._experiments = Experiments.instance();
2135 }
2137 let m = this.getMeasurement(ExperimentsLastActiveMeasurement2.prototype.name,
2138 ExperimentsLastActiveMeasurement2.prototype.version);
2140 return this.enqueueStorageOperation(() => {
2141 return Task.spawn(function* recordTask() {
2142 let todayActive = yield this._experiments.lastActiveToday();
2143 if (!todayActive) {
2144 this._log.info("No active experiment on this day: " +
2145 this._experiments._policy.now());
2146 return;
2147 }
2149 this._log.info("Recording last active experiment: " + todayActive.id);
2150 yield m.setDailyLastText("lastActive", todayActive.id,
2151 this._experiments._policy.now());
2152 let branch = todayActive.branch;
2153 if (branch) {
2154 yield m.setDailyLastText("lastActiveBranch", branch,
2155 this._experiments._policy.now());
2156 }
2157 }.bind(this));
2158 });
2159 },
2160 });
2162 /**
2163 * An Add-ons Manager provider that knows about old experiments.
2164 *
2165 * This provider exposes read-only add-ons corresponding to previously-active
2166 * experiments. The existence of this provider (and the add-ons it knows about)
2167 * facilitates the display of old experiments in the Add-ons Manager UI with
2168 * very little custom code in that component.
2169 */
2170 this.Experiments.PreviousExperimentProvider = function (experiments) {
2171 this._experiments = experiments;
2172 this._experimentList = [];
2173 this._log = Log.repository.getLoggerWithMessagePrefix(
2174 "Browser.Experiments.Experiments",
2175 "PreviousExperimentProvider #" + gPreviousProviderCounter++ + "::");
2176 }
2178 this.Experiments.PreviousExperimentProvider.prototype = Object.freeze({
2179 startup: function () {
2180 this._log.trace("startup()");
2181 Services.obs.addObserver(this, EXPERIMENTS_CHANGED_TOPIC, false);
2182 },
2184 shutdown: function () {
2185 this._log.trace("shutdown()");
2186 Services.obs.removeObserver(this, EXPERIMENTS_CHANGED_TOPIC);
2187 },
2189 observe: function (subject, topic, data) {
2190 switch (topic) {
2191 case EXPERIMENTS_CHANGED_TOPIC:
2192 this._updateExperimentList();
2193 break;
2194 }
2195 },
2197 getAddonByID: function (id, cb) {
2198 for (let experiment of this._experimentList) {
2199 if (experiment.id == id) {
2200 cb(new PreviousExperimentAddon(experiment));
2201 return;
2202 }
2203 }
2205 cb(null);
2206 },
2208 getAddonsByTypes: function (types, cb) {
2209 if (types && types.length > 0 && types.indexOf("experiment") == -1) {
2210 cb([]);
2211 return;
2212 }
2214 cb([new PreviousExperimentAddon(e) for (e of this._experimentList)]);
2215 },
2217 _updateExperimentList: function () {
2218 return this._experiments.getExperiments().then((experiments) => {
2219 let list = [e for (e of experiments) if (!e.active)];
2221 let newMap = new Map([[e.id, e] for (e of list)]);
2222 let oldMap = new Map([[e.id, e] for (e of this._experimentList)]);
2224 let added = [e.id for (e of list) if (!oldMap.has(e.id))];
2225 let removed = [e.id for (e of this._experimentList) if (!newMap.has(e.id))];
2227 for (let id of added) {
2228 this._log.trace("updateExperimentList() - adding " + id);
2229 let wrapper = new PreviousExperimentAddon(newMap.get(id));
2230 AddonManagerPrivate.callInstallListeners("onExternalInstall", null, wrapper, null, false);
2231 AddonManagerPrivate.callAddonListeners("onInstalling", wrapper, false);
2232 }
2234 for (let id of removed) {
2235 this._log.trace("updateExperimentList() - removing " + id);
2236 let wrapper = new PreviousExperimentAddon(oldMap.get(id));
2237 AddonManagerPrivate.callAddonListeners("onUninstalling", plugin, false);
2238 }
2240 this._experimentList = list;
2242 for (let id of added) {
2243 let wrapper = new PreviousExperimentAddon(newMap.get(id));
2244 AddonManagerPrivate.callAddonListeners("onInstalled", wrapper);
2245 }
2247 for (let id of removed) {
2248 let wrapper = new PreviousExperimentAddon(oldMap.get(id));
2249 AddonManagerPrivate.callAddonListeners("onUninstalled", wrapper);
2250 }
2252 return this._experimentList;
2253 });
2254 },
2255 });
2257 /**
2258 * An add-on that represents a previously-installed experiment.
2259 */
2260 function PreviousExperimentAddon(experiment) {
2261 this._id = experiment.id;
2262 this._name = experiment.name;
2263 this._endDate = experiment.endDate;
2264 this._description = experiment.description;
2265 }
2267 PreviousExperimentAddon.prototype = Object.freeze({
2268 // BEGIN REQUIRED ADDON PROPERTIES
2270 get appDisabled() {
2271 return true;
2272 },
2274 get blocklistState() {
2275 Ci.nsIBlocklistService.STATE_NOT_BLOCKED
2276 },
2278 get creator() {
2279 return new AddonManagerPrivate.AddonAuthor("");
2280 },
2282 get foreignInstall() {
2283 return false;
2284 },
2286 get id() {
2287 return this._id;
2288 },
2290 get isActive() {
2291 return false;
2292 },
2294 get isCompatible() {
2295 return true;
2296 },
2298 get isPlatformCompatible() {
2299 return true;
2300 },
2302 get name() {
2303 return this._name;
2304 },
2306 get pendingOperations() {
2307 return AddonManager.PENDING_NONE;
2308 },
2310 get permissions() {
2311 return 0;
2312 },
2314 get providesUpdatesSecurely() {
2315 return true;
2316 },
2318 get scope() {
2319 return AddonManager.SCOPE_PROFILE;
2320 },
2322 get type() {
2323 return "experiment";
2324 },
2326 get userDisabled() {
2327 return true;
2328 },
2330 get version() {
2331 return null;
2332 },
2334 // END REQUIRED PROPERTIES
2336 // BEGIN OPTIONAL PROPERTIES
2338 get description() {
2339 return this._description;
2340 },
2342 get updateDate() {
2343 return new Date(this._endDate);
2344 },
2346 // END OPTIONAL PROPERTIES
2348 // BEGIN REQUIRED METHODS
2350 isCompatibleWith: function (appVersion, platformVersion) {
2351 return true;
2352 },
2354 findUpdates: function (listener, reason, appVersion, platformVersion) {
2355 AddonManagerPrivate.callNoUpdateListeners(this, listener, reason,
2356 appVersion, platformVersion);
2357 },
2359 // END REQUIRED METHODS
2361 /**
2362 * The end-date of the experiment, required for the Addon Manager UI.
2363 */
2365 get endDate() {
2366 return this._endDate;
2367 },
2369 });