Bug 757261: Apps in the Cloud Manager and Service; r=mconnor
authorAnant Narayanan <anant@kix.in>
Sat, 02 Jun 2012 23:32:37 -0700
changeset 95698 3b82c67c4e123041ba2a5fbc27e774e54fa3e002
parent 95697 9dfd11585710de51a809e1dd98946b958978d4f5
child 95699 406bcd86ef0bb2fd5d9dda50329c4784d96d4260
push id1
push userroot
push dateMon, 20 Oct 2014 17:29:22 +0000
reviewersmconnor
bugs757261
milestone15.0a1
Bug 757261: Apps in the Cloud Manager and Service; r=mconnor
browser/installer/package-manifest.in
services/aitc/Aitc.js
services/aitc/AitcComponents.manifest
services/aitc/Makefile.in
services/aitc/modules/main.js
services/aitc/modules/manager.js
services/aitc/service.js
services/aitc/services-aitc.js
services/aitc/tests/unit/test_load_modules.js
--- a/browser/installer/package-manifest.in
+++ b/browser/installer/package-manifest.in
@@ -433,16 +433,17 @@
 @BINPATH@/components/nsINIProcessor.manifest
 @BINPATH@/components/nsINIProcessor.js
 @BINPATH@/components/nsPrompter.manifest
 @BINPATH@/components/nsPrompter.js
 #ifdef MOZ_SERVICES_SYNC
 @BINPATH@/components/SyncComponents.manifest
 @BINPATH@/components/AitcComponents.manifest
 @BINPATH@/components/Weave.js
+@BINPATH@/components/Aitc.js
 #endif
 @BINPATH@/components/TelemetryPing.js
 @BINPATH@/components/TelemetryPing.manifest
 @BINPATH@/components/messageWakeupService.js
 @BINPATH@/components/messageWakeupService.manifest
 @BINPATH@/components/SettingsManager.js
 @BINPATH@/components/SettingsManager.manifest
 @BINPATH@/components/Webapps.js
@@ -507,16 +508,17 @@
 #endif
 
 ; [Default Preferences]
 ; All the pref files must be part of base to prevent migration bugs
 @BINPATH@/@PREF_DIR@/firefox.js
 @BINPATH@/@PREF_DIR@/firefox-branding.js
 #ifdef MOZ_SERVICES_SYNC
 @BINPATH@/@PREF_DIR@/services-sync.js
+@BINPATH@/@PREF_DIR@/services-aitc.js
 #endif
 @BINPATH@/greprefs.js
 @BINPATH@/defaults/autoconfig/platform.js
 @BINPATH@/defaults/autoconfig/prefcalls.js
 ; Warning: changing the path to channel-prefs.js can cause bugs (Bug 756325)
 @BINPATH@/defaults/pref/channel-prefs.js
 @BINPATH@/defaults/profile/prefs.js
 
new file mode 100644
--- /dev/null
+++ b/services/aitc/Aitc.js
@@ -0,0 +1,121 @@
+/* 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/. */
+
+"use strict";
+
+const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
+
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/PlacesUtils.jsm");
+
+Cu.import("resource://services-common/utils.js");
+
+function AitcService() {
+  this.aitc = null;
+  this.wrappedJSObject = this;
+}
+AitcService.prototype = {
+  classID: Components.ID("{a3d387ca-fd26-44ca-93be-adb5fda5a78d}"),
+
+  QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver,
+                                         Ci.nsINavHistoryObserver,
+                                         Ci.nsISupportsWeakReference]),
+
+  observe: function observe(subject, topic, data) {
+    switch (topic) {
+      case "app-startup":
+        // We listen for this event beacause Aitc won't work until there is
+        // atleast 1 visible top-level XUL window.
+        Services.obs.addObserver(this, "sessionstore-windows-restored", true);
+        break;
+      case "sessionstore-windows-restored":
+        Services.obs.removeObserver(this, "sessionstore-windows-restored");
+
+        // Don't start AITC if classic sync is on.
+        Cu.import("resource://services-common/preferences.js");
+        if (Preferences.get("services.sync.engine.apps", false)) {
+          return;
+        }
+        // Start AITC only if it is enabled.
+        if (!Preferences.get("services.aitc.enabled", false)) {
+          return;
+        }
+
+        // Start AITC service if apps.enabled is true. If false, we look
+        // in the browser history to determine if they're an "apps user". If
+        // an entry wasn't found, we'll watch for navigation to either the
+        // marketplace or dashboard and switch ourselves on then.
+
+        if (Preferences.get("apps.enabled", false)) {
+          this.start();
+          return;
+        }
+
+        // Set commonly used URLs.
+        this.DASHBOARD_URL = CommonUtils.makeURI(
+          Preferences.get("services.aitc.dashboard.url")
+        );
+        this.MARKETPLACE_URL = CommonUtils.makeURI(
+          Preferences.get("services.aitc.marketplace.url")
+        );
+
+        if (this.hasUsedApps()) {
+          Preferences.set("apps.enabled", true);
+          this.start();
+          return;
+        }
+
+        // Wait and see if the user wants anything apps related.
+        PlacesUtils.history.addObserver(this, true);
+        break;
+    }
+  },
+
+  start: function start() {
+    Cu.import("resource://services-aitc/main.js");
+    if (!this.aitc) {
+      this.aitc = new Aitc();
+    }
+  },
+
+  hasUsedApps: function hasUsedApps() {
+    // There is no easy way to determine whether a user is "using apps".
+    // The best we can do right now is to see if they have visited either
+    // the Mozilla dashboard or Marketplace. See bug 760898.
+    let gh = PlacesUtils.ghistory2;
+    if (gh.isVisited(this.DASHBOARD_URL)) {
+      return true;
+    }
+    if (gh.isVisited(this.MARKETPLACE_URL)) {
+      return true;
+    }
+    return false;
+  },
+
+  // nsINavHistoryObserver. We are only interested in onVisit().
+  onBeforeDeleteURI: function() {},
+  onBeginUpdateBatch: function() {},
+  onClearHistory: function() {},
+  onDeleteURI: function() {},
+  onDeleteVisits: function() {},
+  onEndUpdateBatch: function() {},
+  onPageChanged: function() {},
+  onPageExpired: function() {},
+  onTitleChanged: function() {},
+
+  onVisit: function onVisit(uri) {
+    if (!uri.equals(this.MARKETPLACE_URL) && !uri.equals(this.DASHBOARD_URL)) {
+      return;
+    }
+
+    PlacesUtils.history.removeObserver(this);
+    Preferences.set("apps.enabled", true);
+    this.start();
+    return;
+  },
+};
+
+const components = [AitcService];
+const NSGetFactory = XPCOMUtils.generateNSGetFactory(components);
--- a/services/aitc/AitcComponents.manifest
+++ b/services/aitc/AitcComponents.manifest
@@ -1,6 +1,6 @@
-# service.js
-component {a3d387ca-fd26-44ca-93be-adb5fda5a78d} service.js
+# Aitc.js
+component {a3d387ca-fd26-44ca-93be-adb5fda5a78d} Aitc.js
 contract @mozilla.org/services/aitc;1 {a3d387ca-fd26-44ca-93be-adb5fda5a78d}
 category app-startup AitcService service,@mozilla.org/services/aitc;1
 # Register resource aliases
 resource services-aitc resource:///modules/services-aitc/
--- a/services/aitc/Makefile.in
+++ b/services/aitc/Makefile.in
@@ -6,17 +6,17 @@ DEPTH     = ../..
 topsrcdir = @top_srcdir@
 srcdir    = @srcdir@
 VPATH     = @srcdir@
 
 include $(DEPTH)/config/autoconf.mk
 
 EXTRA_COMPONENTS = \
   AitcComponents.manifest \
-  service.js \
+  Aitc.js \
   $(NULL)
 
 PREF_JS_EXPORTS = $(srcdir)/services-aitc.js
 
 libs::
 	$(NSINSTALL) $(srcdir)/modules/* $(FINAL_TARGET)/modules/services-aitc
 
 TEST_DIRS += tests
new file mode 100644
--- /dev/null
+++ b/services/aitc/modules/main.js
@@ -0,0 +1,161 @@
+/* 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/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["Aitc"];
+
+const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/Webapps.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/XPCOMUtils.jsm");
+
+Cu.import("resource://services-aitc/manager.js");
+Cu.import("resource://services-common/utils.js");
+Cu.import("resource://services-common/log4moz.js");
+Cu.import("resource://services-common/preferences.js");
+
+function Aitc() {
+  this._log = Log4Moz.repository.getLogger("Service.AITC");
+  this._log.level = Log4Moz.Level[Preferences.get(
+    "services.aitc.service.log.level"
+  )];
+  this._log.info("Loading AitC");
+
+  this.DASHBOARD_ORIGIN = CommonUtils.makeURI(
+    Preferences.get("services.aitc.dashboard.url")
+  ).prePath;
+
+  this._manager = new AitcManager(this._init.bind(this));
+}
+Aitc.prototype = {
+  // The goal of the init function is to be ready to activate the AITC
+  // client whenever the user is looking at the dashboard.
+  _init: function init() {
+    let self = this;
+
+    // This is called iff the user is currently looking the dashboard.
+    function dashboardLoaded(browser) {
+      let win = browser.contentWindow;
+      self._log.info("Dashboard was accessed " + win);
+
+      // If page is ready to go, fire immediately.
+      if (win.document && win.document.readyState == "complete") {
+        self._manager.userActive(win);
+        return;
+      }
+
+      // Only fire event after the page fully loads.
+      browser.contentWindow.addEventListener(
+        "DOMContentLoaded",
+        function _contentLoaded(event) {
+          self._manager.userActive(win);
+        },
+        false
+      );
+    }
+
+    // This is called when the user's attention is elsewhere.
+    function dashboardUnloaded() {
+      self._log.info("Dashboard closed or in background");
+      self._manager.userIdle();
+    }
+
+    // Called when a URI is loaded in any tab. We have to listen for this
+    // because tabSelected is not called if I open a new tab which loads
+    // about:home and then navigate to the dashboard, or navigation via
+    // links on the currently open tab.
+    let listener = {
+      onLocationChange: function onLocationChange(browser, pr, req, loc, flag) {
+        let win = Services.wm.getMostRecentWindow("navigator:browser");
+        if (win.gBrowser.selectedBrowser == browser) {
+          if (loc.prePath == self.DASHBOARD_ORIGIN) {
+            dashboardLoaded(browser);
+          }
+        }
+      }
+    };
+    // Called when the current tab selection changes.
+    function tabSelected(event) {
+      let browser = event.target.linkedBrowser;
+      if (browser.currentURI.prePath == self.DASHBOARD_ORIGIN) {
+        dashboardLoaded(browser);
+      } else {
+        dashboardUnloaded();
+      }
+    }
+
+    // Add listeners for all windows opened in the future.
+    function winWatcher(subject, topic) {
+      if (topic != "domwindowopened") {
+        return;
+      }
+      subject.addEventListener("load", function winWatcherLoad() {
+        subject.removeEventListener("load", winWatcherLoad, false);
+        let doc = subject.document.documentElement;
+        if (doc.getAttribute("windowtype") == "navigator:browser") {
+          let browser = subject.gBrowser;
+          browser.addTabsProgressListener(listener);
+          browser.tabContainer.addEventListener("TabSelect", tabSelected);
+        }
+      }, false);
+    }
+    Services.ww.registerNotification(winWatcher);
+
+    // Add listeners for all current open windows.
+    let enumerator = Services.wm.getEnumerator("navigator:browser");
+    while (enumerator.hasMoreElements()) {
+      let browser = enumerator.getNext().gBrowser;
+      browser.addTabsProgressListener(listener);
+      browser.tabContainer.addEventListener("TabSelect", tabSelected);
+
+      // Also check the currently open URI.
+      if (browser.currentURI.prePath == this.DASHBOARD_ORIGIN) {
+        dashboardLoaded(browser);
+      }
+    }
+
+    // Add listeners for app installs/uninstall.
+    Services.obs.addObserver(this, "webapps-sync-install", false);
+    Services.obs.addObserver(this, "webapps-sync-uninstall", false);
+
+    // Add listener for idle service.
+    let idleSvc = Cc["@mozilla.org/widget/idleservice;1"].
+                  getService(Ci.nsIIdleService);
+    idleSvc.addIdleObserver(this,
+                            Preferences.get("services.aitc.main.idleTime"));
+  },
+
+  observe: function(subject, topic, data) {
+    let app;
+    switch (topic) {
+      case "webapps-sync-install":
+        app = JSON.parse(data);
+        this._log.info(app.origin + " was installed, initiating PUT");
+        this._manager.appEvent("install", app);
+        break;
+      case "webapps-sync-uninstall":
+        app = JSON.parse(data);
+        this._log.info(app.origin + " was uninstalled, initiating PUT");
+        this._manager.appEvent("uninstall", app);
+        break;
+      case "idle":
+        this._log.info("User went idle");
+        if (this._manager) {
+          this._manager.userIdle();
+        }
+        break;
+      case "back":
+        this._log.info("User is no longer idle");
+        let win = Services.wm.getMostRecentWindow("navigator:browser");
+        if (win && win.gBrowser.currentURI.prePath == this.DASHBOARD_ORIGIN &&
+            this._manager) {
+          this._manager.userActive();
+        }
+        break;
+    }
+  },
+
+};
new file mode 100644
--- /dev/null
+++ b/services/aitc/modules/manager.js
@@ -0,0 +1,573 @@
+/* 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/. */
+
+"use strict";
+
+const EXPORTED_SYMBOLS = ["AitcManager"];
+
+const {classes: Cc, interfaces: Ci, utils: Cu} = Components;
+
+Cu.import("resource://gre/modules/Webapps.jsm");
+Cu.import("resource://gre/modules/Services.jsm");
+Cu.import("resource://gre/modules/FileUtils.jsm");
+
+Cu.import("resource://services-aitc/client.js");
+Cu.import("resource://services-aitc/browserid.js");
+Cu.import("resource://services-aitc/storage.js");
+Cu.import("resource://services-common/log4moz.js");
+Cu.import("resource://services-common/preferences.js");
+Cu.import("resource://services-common/tokenserverclient.js");
+Cu.import("resource://services-common/utils.js");
+
+const PREFS = new Preferences("services.aitc.");
+const TOKEN_TIMEOUT = 240000; // 4 minutes
+const DASHBOARD_URL = PREFS.get("dashboard.url");
+const MARKETPLACE_URL = PREFS.get("marketplace.url");
+
+/**
+ * The constructor for the manager takes a callback, which will be invoked when
+ * the manager is ready (construction is asynchronous). *DO NOT* call any
+ * methods on this object until the callback has been invoked, doing so will
+ * lead to undefined behaviour.
+ */
+function AitcManager(cb) {
+  this._client = null;
+  this._getTimer = null;
+  this._putTimer = null;
+
+  this._lastToken = 0;
+  this._lastEmail = null;
+  this._dashboardWindow = null;
+
+  this._log = Log4Moz.repository.getLogger("Service.AITC.Manager");
+  this._log.level = Log4Moz.Level[Preferences.get("manager.log.level")];
+  this._log.info("Loading AitC manager module");
+
+  // Check if we have pending PUTs from last time.
+  let self = this;
+  this._pending = new AitcQueue("webapps-pending.json", function _queueDone() {
+    // Inform the AitC service that we're good to go!
+    self._log.info("AitC manager has finished loading");
+    try {
+      cb(true);
+    } catch (e) {
+      self._log.error(new Error("AitC manager callback threw " + e));
+    }
+
+    // Schedule them, but only if we can get a silent assertion.
+    self._makeClient(function(err, client) {
+      if (!err && client) {
+        self._client = client;
+        self._processQueue();
+      }
+    }, false);
+  });
+}
+AitcManager.prototype = {
+  /**
+   * State of the user. ACTIVE implies user is looking at the dashboard,
+   * PASSIVE means either not at the dashboard or the idle timer started.
+   */
+  _ACTIVE: 1,
+  _PASSIVE: 2,
+
+  /**
+   * Smart setter that will only call _setPoll is the value changes.
+   */
+  _clientState: null,
+  get _state() {
+    return this._clientState;
+  },
+  set _state(value) {
+    if (this._clientState == value) {
+      return;
+    }
+    this._clientState = value;
+    this._setPoll();
+  },
+
+  /**
+   * Local app was just installed or uninstalled, ask client to PUT if user
+   * is logged in.
+   */
+  appEvent: function appEvent(type, app) {
+    // Add this to the equeue.
+    let self = this;
+    let obj = {type: type, app: app, retries: 0, lastTime: 0};
+    this._pending.enqueue(obj, function _enqueued(err, rec) {
+      if (err) {
+        self._log.error("Could not add " + type + " " + app + " to queue");
+        return;
+      }
+
+      // If we already have a client (i.e. user is logged in), attempt to PUT.
+      if (self._client) {
+        self._processQueue();
+        return;
+      }
+
+      // If not, try a silent client creation.
+      self._makeClient(function(err, client) {
+        if (!err && client) {
+          self._client = client;
+          self._processQueue();
+        }
+        // If user is not logged in, we'll just have to try later.
+      });
+    });
+  },
+
+  /**
+   * User is looking at dashboard. Start polling actively, but if user isn't
+   * logged in, prompt for them to login via a dialog.
+   */
+  userActive: function userActive(win) {
+    // Stash a reference to the dashboard window in case we need to prompt
+    this._dashboardWindow = win;
+
+    if (this._client) {
+      this._state = this._ACTIVE;
+      return;
+    }
+
+    // Make client will first try silent login, if it doesn't work, a popup
+    // will be shown in the context of the dashboard. We shouldn't be
+    // trying to make a client every time this function is called, there is
+    // room for optimization (Bug 750607).
+    let self = this;
+    this._makeClient(function(err, client) {
+      if (err) {
+        // Notify user of error (Bug 750610).
+        self._log.error("Client not created at Dashboard");
+        return;
+      }
+      self._client = client;
+      self._state = self._ACTIVE;
+    }, true, win);
+  },
+
+  /**
+   * User is idle, (either by idle observer, or by not being on the dashboard).
+   * When the user is no longer idle and the dashboard is the current active
+   * page, a call to userActive MUST be made.
+   */
+  userIdle: function userIdle() {
+    this._state = this._PASSIVE;
+    this._dashboardWindow = null;
+  },
+
+  /**
+   * Poll the AITC server for any changes and process them. It is safe to call
+   * this function multiple times. Last caller wins. The function will
+   * grab the current user state from _state and act accordingly.
+   *
+   * Invalid states will cause this function to throw.
+   */
+  _setPoll: function _setPoll() {
+    if (this._state == this._ACTIVE && !this._client) {
+      throw new Error("_setPoll(ACTIVE) called without client");
+    }
+    if (this._state != this._ACTIVE && this._state != this._PASSIVE) {
+      throw new Error("_state is invalid " + this._state);
+    }
+
+    if (!this._client) {
+      // User is not logged in, we cannot do anything.
+      self._log.warn("_setPoll called but user not logged in, ignoring");
+      return;
+    }
+
+    // Check if there are any PUTs pending first.
+    if (this._pending.length && !(this._putTimer)) {
+      // There are pending PUTs and no timer, so let's process them. GETs will
+      // resume after the PUTs finish (see processQueue)
+      this._processQueue();
+      return;
+    }
+    
+    // Do one GET soon, but only if user is active.
+    let getFreq;
+    if (this._state == this._ACTIVE) {
+      CommonUtils.nextTick(this._checkServer, this);
+      getFreq = PREFS.get("manager.getActiveFreq");
+    } else {
+      getFreq = PREFS.get("manager.getPassiveFreq");
+    }
+
+    // Cancel existing timer, if any.
+    if (this._getTimer) {
+      this._getTimer.cancel();
+      this._getTimer = null;
+    }
+
+    // Start the timer for GETs.
+    let self = this;
+    this._log.info("Starting GET timer");
+    this._getTimer = Cc["@mozilla.org/timer;1"].createInstance(Ci.nsITimer);
+    this._getTimer.initWithCallback({notify: this._checkServer.bind(this)},
+                                    getFreq, Ci.nsITimer.TYPE_REPEATING_SLACK);
+
+    this._log.info("GET timer set, next attempt in " + getFreq + "ms");
+  },
+
+  /**
+   * Checks if the current token we hold is valid. If not, we obtain a new one
+   * and execute the provided func. If a token could not be obtained, func will
+   * not be called and an error will be logged.
+   */
+  _validateToken: function _validateToken(func) {
+    if (Date.now() - this._lastToken < TOKEN_TIMEOUT) {
+      func();
+      return;
+    }
+
+    let win;
+    if (this._state == this.ACTIVE) {
+      win = this._dashboardWindow;
+    }
+
+    let self = this;
+    this._refreshToken(function(err, done) {
+      if (!done) {
+        this._log.warn("_checkServer could not refresh token, aborting");
+        return;
+      }
+      func();
+    }, win);
+  },
+
+  /**
+   * Do a GET check on the server to see if we have any new apps. Abort if
+   * there are pending PUTs. If we GET some apps, send to storage for
+   * further processing.
+   */
+  _checkServer: function _checkServer() {
+    if (!this._client) {
+      throw new Error("_checkServer called without a client");
+    }
+
+    if (this._pending.length) {
+      this._log.warn("_checkServer aborted because of pending PUTs");
+      return;
+    }
+
+    this._validateToken(this._getApps.bind(this));
+  },
+
+  _getApps: function _getApps() {
+    // Do a GET
+    this._log.info("Attempting to getApps");
+
+    let self = this;
+    this._client.getApps(function gotApps(err, apps) {
+      if (err) {
+        // Error was logged in client.
+        return;
+      }
+      if (!apps) {
+        // No changes, got 304.
+        return;
+      }
+      if (!apps.length) {
+        // Empty array, nothing to process
+        self._log.info("No apps found on remote server");
+        return;
+      }
+
+      // Send list of remote apps to storage to apply locally
+      AitcStorage.processApps(apps, function processedApps() {
+        self._log.info("processApps completed successfully, changes applied");
+      });
+    });
+  },
+
+  /**
+   * Go through list of apps to PUT and attempt each one. If we fail, try
+   * again in PUT_FREQ. Will throw if called with an empty, _reschedule()
+   * makes sure that we don't.
+   */
+  _processQueue: function _processQueue() {
+    if (!this._client) {
+      throw new Error("_processQueue called without a client");
+    }
+    if (!this._pending.length) {
+      throw new Error("_processQueue called with an empty queue");
+    }
+
+    if (this._putInProgress) {
+      // The network request sent out as a result to the last call to
+      // _processQueue still isn't done. A timer is created they all
+      // finish to make sure this function is called again if neccessary.
+      return;
+    }
+
+    this._validateToken(this._putApps.bind(this));
+  },
+
+  _putApps: function _putApps() {
+    this._putInProgress = true;
+    let record = this._pending.peek();
+
+    this._log.info("Processing record type " + record.type);
+
+    let self = this;
+    function _clientCallback(err, done) {
+      // Send to end of queue if unsuccessful or err.removeFromQueue is false.
+      if (err && !err.removeFromQueue) {
+        self._log.info("PUT failed, re-adding to queue");
+
+        // Update retries and time
+        record.retries += 1;
+        record.lastTime = new Date().getTime();
+
+        // Add updated record to the end of the queue.
+        self._pending.enqueue(record, function(err, done) {
+          if (err) {
+            self._log.error("Enqueue failed " + err);
+            _reschedule();
+            return;
+          }
+          // If record was successfully added, remove old record.
+          self._pending.dequeue(function(err, done) {
+            if (err) {
+              self._log.error("Dequeue failed " + err);
+            }
+            _reschedule();
+            return;
+          });
+        });
+      }
+
+      // If succeeded or client told us to remove from queue
+      self._log.info("_putApp asked us to remove it from queue");
+      self._pending.dequeue(function(err, done) {
+        if (err) {
+          self._log.error("Dequeue failed " + e);
+        }
+        _reschedule();
+      });
+    }
+
+    function _reschedule() {
+      // Release PUT lock
+      self._putInProgress = false;
+
+      // We just finished PUTting an object, try the next one immediately,
+      // but only if haven't tried it already in the last putFreq (ms).
+      if (!self._pending.length) {
+        // Start GET timer now that we're done with PUTs.
+        self._setPoll();
+        return;
+      }
+
+      let obj = self._pending.peek();
+      let cTime = new Date().getTime();
+      let freq = PREFS.get("manager.putFreq");
+
+      // We tried this object recently, we'll come back to it later.
+      if (obj.lastTime && ((cTime - obj.lastTime) < freq)) {
+        self._log.info("Scheduling next processQueue in " + freq);
+        CommonUtils.namedTimer(self._processQueue, freq, self, "_putTimer");
+        return;
+      }
+
+      // Haven't tried this PUT yet, do it immediately.
+      self._log.info("Queue non-empty, processing next PUT");
+      self._processQueue();
+    }
+
+    switch (record.type) {
+      case "install":
+        this._client.remoteInstall(record.app, _clientCallback);
+        break;
+      case "uninstall":
+        record.app.deleted = true;
+        this._client.remoteUninstall(record.app, _clientCallback);
+        break;
+      default:
+        this._log.warn(
+          "Unrecognized type " + record.type + " in queue, removing"
+        );
+        let self = this;
+        this._pending.dequeue(function _dequeued(err) {
+          if (err) {
+            self._log.error("Dequeue of unrecognized app type failed");
+          }
+          _reschedule();
+        });
+    }
+  },
+
+  /* Obtain a (new) token from the Sagrada token server. If win is is specified,
+   * the user will be asked to login via UI, if required. The callback's
+   * signature is cb(err, done). If a token is obtained successfully, done will
+   * be true and err will be null.
+   */
+  _refreshToken: function _refreshToken(cb, win) {
+    if (!this._client) {
+      throw new Error("_refreshToken called without an active client");
+    }
+
+    this._log.info("Token refresh requested");
+
+    let self = this;
+    function refreshedAssertion(err, assertion) {
+      if (!err) {
+        self._getToken(assertion, function(err, token) {
+          if (err) {
+            cb(err, null);
+            return;
+          }
+          self._lastToken = Date.now();
+          self._client.updateToken(token);
+          cb(null, true);
+        });
+        return;
+      }
+
+      // Silent refresh was asked for.
+      if (!win) {
+        cb(err, null);
+        return;
+      }
+      
+      // Prompt user to login.
+      self._makeClient(function(err, client) {
+        if (err) {
+          cb(err, null);
+          return;
+        }
+      
+        // makeClient sets an updated token.
+        self._client = client;
+        cb(null, true);
+      }, win);
+    }
+
+    let options = { audience: DASHBOARD_URL };
+    if (this._lastEmail) {
+      options.requiredEmail = this._lastEmail;
+    } else {
+      options.sameEmailAs = MARKETPLACE_URL;
+    }
+    BrowserID.getAssertion(refreshedAssertion, options);
+  },
+
+  /* Obtain a token from Sagrada token server, given a BrowserID assertion
+   * cb(err, token) will be invoked on success or failure.
+   */
+  _getToken: function _getToken(assertion, cb) {
+    let url = PREFS.get("tokenServer.url") + "/1.0/aitc/1.0";
+    let client = new TokenServerClient();
+
+    this._log.info("Obtaining token from " + url);
+
+    let self = this;
+    try {
+      client.getTokenFromBrowserIDAssertion(url, assertion, function(err, tok) {
+        self._gotToken(err, tok, cb);
+      });
+    } catch (e) {
+      cb(new Error(e), null);
+    }
+  },
+
+  // Token recieved from _getToken.
+  _gotToken: function _gotToken(err, tok, cb) {
+    if (!err) {
+      this._log.info("Got token from server: " + JSON.stringify(tok));
+      cb(null, tok);
+      return;
+    }
+
+    let msg = err.name + " in _getToken: " + err.error;
+    this._log.error(msg);
+    cb(msg, null);
+  },
+
+  // Extract the email address from a BrowserID assertion.
+  _extractEmail: function _extractEmail(assertion) {
+    // Please look the other way while I do this. Thanks.
+    let chain = assertion.split("~");
+    let len = chain.length;
+    if (len < 2) {
+      return null;
+    }
+
+    try {
+      // We need CommonUtils.decodeBase64URL.
+      let cert = JSON.parse(atob(
+        chain[0].split(".")[1].replace("-", "+", "g").replace("_", "/", "g")
+      ));
+      return cert.principal.email;
+    } catch (e) {
+      return null;
+    }
+  },
+
+  /* To start the AitcClient we need a token, for which we need a BrowserID
+   * assertion. If login is true, makeClient will ask the user to login in
+   * the context of win. cb is called with (err, client).
+   */
+  _makeClient: function makeClient(cb, login, win) {
+    if (!cb) {
+      throw new Error("makeClient called without callback");
+    }
+    if (login && !win) {
+      throw new Error("makeClient called with login as true but no win");
+    }
+
+    let self = this;
+    let ctxWin = win;
+    function processAssertion(val) {
+      // Store the email we got the token for so we can refresh.
+      self._lastEmail = self._extractEmail(val);
+      self._log.info("Got assertion from BrowserID, creating token");
+      self._getToken(val, function(err, token) {
+        if (err) {
+          cb(err, null);
+          return;
+        }
+
+        // Store when we got the token so we can refresh it as needed.
+        self._lastToken = Date.now();
+
+        // We only create one client instance, store values in a pref tree
+        cb(null, new AitcClient(
+          token, new Preferences("services.aitc.client.")
+        ));
+      });
+    }
+    function gotSilentAssertion(err, val) {
+      self._log.info("gotSilentAssertion called");
+      if (err) {
+        // If we were asked to let the user login, do the popup method.
+        if (login) {
+          self._log.info("Could not obtain silent assertion, retrying login");
+          BrowserID.getAssertionWithLogin(function gotAssertion(err, val) {
+            if (err) {
+              self._log.error(err);
+              cb(err, false);
+              return;
+            }
+            processAssertion(val);
+          }, ctxWin);
+          return;
+        }
+        self._log.warn("Could not obtain assertion in _makeClient");
+        cb(err, false);
+      } else {
+        processAssertion(val);
+      }
+    }
+
+    // Check if we can get assertion silently first
+    self._log.info("Attempting to obtain assertion silently")
+    BrowserID.getAssertion(gotSilentAssertion, {
+      audience: DASHBOARD_URL,
+      sameEmailAs: MARKETPLACE_URL
+    });
+  },
+
+};
deleted file mode 100644
--- a/services/aitc/service.js
+++ /dev/null
@@ -1,47 +0,0 @@
-/* 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/. */
-
-"use strict";
-
-const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components;
-Cu.import("resource://gre/modules/XPCOMUtils.jsm");
-Cu.import("resource://gre/modules/Services.jsm");
-
-function AitcService() {
-  this.wrappedJSObject = this;
-}
-AitcService.prototype = {
-  classID: Components.ID("{a3d387ca-fd26-44ca-93be-adb5fda5a78d}"),
-
-  QueryInterface: XPCOMUtils.generateQI([Ci.nsIObserver,
-                                         Ci.nsISupportsWeakReference]),
-
-  observe: function observe(subject, topic, data) {
-    switch (topic) {
-      case "app-startup":
-        let os = Cc["@mozilla.org/observer-service;1"]
-                   .getService(Ci.nsIObserverService);
-        os.addObserver(this, "final-ui-startup", true);
-        break;
-      case "final-ui-startup":
-        // Start AITC service after 2000ms, only if classic sync is off.
-        Cu.import("resource://services-common/preferences.js");
-        if (Preferences.get("services.sync.engine.apps", false)) {
-          return;
-        }
-        if (!Preferences.get("services.aitc.enabled", true)) {
-          return;
-        }
-
-        Cu.import("resource://services-common/utils.js");
-        CommonUtils.namedTimer(function() {
-          // Kick-off later!
-        }, 2000, this, "timer");
-        break;
-    }
-  }
-};
-
-const components = [AitcService];
-const NSGetFactory = XPCOMUtils.generateNSGetFactory(components);
--- a/services/aitc/services-aitc.js
+++ b/services/aitc/services-aitc.js
@@ -1,9 +1,23 @@
 pref("services.aitc.browserid.url", "https://browserid.org/sign_in");
 pref("services.aitc.browserid.log.level", "Debug");
 
 pref("services.aitc.client.log.level", "Debug");
-pref("services.aitc.storage.log.level", "Debug");
+pref("services.aitc.client.timeout", 120); // 120 seconds
+
+pref("services.aitc.dashboard.url", "https://myapps.mozillalabs.com");
+
+pref("services.aitc.main.idleTime", 120000); // 2 minutes
 
-pref("services.aitc.client.timeout", 120);
+pref("services.aitc.manager.putFreq", 10000); // 10 seconds
+pref("services.aitc.manager.getActiveFreq", 120000); // 2 minutes
+pref("services.aitc.manager.getPassiveFreq", 7200000); // 2 hours
+pref("services.aitc.manager.log.level", "Debug");
+
+pref("services.aitc.marketplace.url", "https://marketplace.mozilla.org");
+
+pref("services.aitc.service.log.level", "Debug");
+
+// Temporary value. Change to the production server when we get the OK from server ops
+pref("services.aitc.tokenServer.url", "https://stage-token.services.mozilla.com");
 
 pref("services.aitc.storage.log.level", "Debug");
--- a/services/aitc/tests/unit/test_load_modules.js
+++ b/services/aitc/tests/unit/test_load_modules.js
@@ -1,11 +1,13 @@
 const modules = [
   "client.js",
   "browserid.js",
+  "main.js",
+  "manager.js",
   "storage.js"
 ];
 
 function run_test() {
   for each (let m in modules) {
     Cu.import("resource://services-aitc/" + m, {});
   }
 }