diff --git a/browser/base/content/browser-syncui.js b/browser/base/content/browser-syncui.js index 7294794e7a10..d5c21070a1b9 100644 --- a/browser/base/content/browser-syncui.js +++ b/browser/base/content/browser-syncui.js @@ -2,8 +2,10 @@ # 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/. -// gSyncUI handles updating the tools menu +// gSyncUI handles updating the tools menu and displaying notifications. let gSyncUI = { + DEFAULT_EOL_URL: "https://www.mozilla.org/firefox/?utm_source=synceol", + _obs: ["weave:service:sync:start", "weave:service:sync:delayed", "weave:service:quota:remaining", @@ -16,11 +18,14 @@ let gSyncUI = { "weave:ui:sync:error", "weave:ui:sync:finish", "weave:ui:clear-error", + "weave:eol", ], _unloaded: false, - init: function SUI_init() { + init: function () { + Cu.import("resource://services-common/stringbundle.js"); + // Proceed to set up the UI if Sync has already started up. // Otherwise we'll do it when Sync is firing up. let xps = Components.classes["@mozilla.org/weave/service;1"] @@ -208,6 +213,41 @@ let gSyncUI = { Weave.Notifications.replaceTitle(notification); }, + _getAppName: function () { + try { + let syncStrings = new StringBundle("chrome://browser/locale/sync.properties"); + return syncStrings.getFormattedString("sync.defaultAccountApplication", [brandName]); + } catch (ex) {} + let brand = new StringBundle("chrome://branding/locale/brand.properties"); + return brand.get("brandShortName"); + }, + + onEOLNotice: function (data) { + let code = data.code; + let kind = (code == "hard-eol") ? "error" : "warning"; + let url = data.url || gSyncUI.DEFAULT_EOL_URL; + + let title = this._stringBundle.GetStringFromName(kind + ".sync.eol.label"); + let description = this._stringBundle.formatStringFromName(kind + ".sync.eol.description", + [this._getAppName()], + 1); + + let buttons = []; + buttons.push(new Weave.NotificationButton( + this._stringBundle.GetStringFromName("sync.eol.learnMore.label"), + this._stringBundle.GetStringFromName("sync.eol.learnMore.accesskey"), + function() { + window.openUILinkIn(url, "tab"); + return true; + } + )); + + let priority = (kind == "error") ? Weave.Notifications.PRIORITY_WARNING : + Weave.Notifications.PRIORITY_INFO; + let notification = new Weave.Notification(title, description, null, priority, buttons); + Weave.Notifications.replaceTitle(notification); + }, + openServerStatus: function () { let statusURL = Services.prefs.getCharPref("services.sync.statusURL"); window.openUILinkIn(statusURL, "tab"); @@ -405,6 +445,13 @@ let gSyncUI = { return; } + // Unwrap, just like Svc.Obs, but without pulling in that dependency. + if (subject && typeof subject == "object" && + ("wrappedJSObject" in subject) && + ("observersModuleSubjectWrapper" in subject.wrappedJSObject)) { + subject = subject.wrappedJSObject.object; + } + switch (topic) { case "weave:service:sync:start": this.onActivityStart(); @@ -448,6 +495,9 @@ let gSyncUI = { case "weave:ui:clear-error": this.clearError(); break; + case "weave:eol": + this.onEOLNotice(subject); + break; } }, diff --git a/services/sync/locales/en-US/sync.properties b/services/sync/locales/en-US/sync.properties index 2c2c0fa31131..f2839808be53 100644 --- a/services/sync/locales/en-US/sync.properties +++ b/services/sync/locales/en-US/sync.properties @@ -40,3 +40,11 @@ error.sync.quota.label = Server Quota Exceeded error.sync.quota.description = Sync failed because it exceeded the server quota. Please review which data to sync. error.sync.viewQuotaButton.label = View Quota error.sync.viewQuotaButton.accesskey = V +warning.sync.eol.label = Service Shutting Down +# %1: the app name (Firefox) +warning.sync.eol.description = Your Firefox Sync service is shutting down soon. Upgrade %1$S to keep syncing. +error.sync.eol.label = Service Unavailable +# %1: the app name (Firefox) +error.sync.eol.description = Your Firefox Sync service is no longer available. You need to upgrade %1$S to keep syncing. +sync.eol.learnMore.label = Learn more +sync.eol.learnMore.accesskey = L diff --git a/services/sync/modules/policies.js b/services/sync/modules/policies.js index 8cd8ab46b6dd..1d141b993d50 100644 --- a/services/sync/modules/policies.js +++ b/services/sync/modules/policies.js @@ -40,6 +40,7 @@ SyncScheduler.prototype = { this.idleInterval = Svc.Prefs.get("scheduler.idleInterval") * 1000; this.activeInterval = Svc.Prefs.get("scheduler.activeInterval") * 1000; this.immediateInterval = Svc.Prefs.get("scheduler.immediateInterval") * 1000; + this.eolInterval = Svc.Prefs.get("scheduler.eolInterval") * 1000; // A user is non-idle on startup by default. this.idle = false; @@ -238,11 +239,18 @@ SyncScheduler.prototype = { }, adjustSyncInterval: function adjustSyncInterval() { + if (Status.eol) { + this._log.debug("Server status is EOL; using eolInterval."); + this.syncInterval = this.eolInterval; + return; + } + if (this.numClients <= 1) { this._log.trace("Adjusting syncInterval to singleDeviceInterval."); this.syncInterval = this.singleDeviceInterval; return; } + // Only MULTI_DEVICE clients will enter this if statement // since SINGLE_USER clients will be handled above. if (this.idle) { @@ -474,6 +482,7 @@ this.ErrorHandler = function ErrorHandler(service) { this.init(); } ErrorHandler.prototype = { + MINIMUM_ALERT_INTERVAL_MSEC: 604800000, // One week. /** * Flag that turns on error reporting for all errors, incl. network errors. @@ -767,12 +776,97 @@ ErrorHandler.prototype = { [Status.login, Status.sync].indexOf(LOGIN_FAILED_NETWORK_ERROR) == -1); }, + get currentAlertMode() { + return Svc.Prefs.get("errorhandler.alert.mode"); + }, + + set currentAlertMode(str) { + return Svc.Prefs.set("errorhandler.alert.mode", str); + }, + + get earliestNextAlert() { + return Svc.Prefs.get("errorhandler.alert.earliestNext", 0) * 1000; + }, + + set earliestNextAlert(msec) { + return Svc.Prefs.set("errorhandler.alert.earliestNext", msec / 1000); + }, + + clearServerAlerts: function () { + // If we have any outstanding alerts, apparently they're no longer relevant. + Svc.Prefs.resetBranch("errorhandler.alert"); + }, + + /** + * X-Weave-Alert headers can include a JSON object: + * + * { + * "code": // One of "hard-eol", "soft-eol". + * "url": // For "Learn more" link. + * "message": // Logged in Sync logs. + * } + */ + handleServerAlert: function (xwa) { + if (!xwa.code) { + this._log.warn("Got structured X-Weave-Alert, but no alert code."); + return; + } + + switch (xwa.code) { + // Gently and occasionally notify the user that this service will be + // shutting down. + case "soft-eol": + // Fall through. + + // Tell the user that this service has shut down, and drop our syncing + // frequency dramatically. + case "hard-eol": + // Note that both of these alerts should be subservient to future "sign + // in with your Firefox Account" storage alerts. + if ((this.currentAlertMode != xwa.code) || + (this.earliestNextAlert < Date.now())) { + Utils.nextTick(function() { + Svc.Obs.notify("weave:eol", xwa); + }, this); + this._log.error("X-Weave-Alert: " + xwa.code + ": " + xwa.message); + this.earliestNextAlert = Date.now() + this.MINIMUM_ALERT_INTERVAL_MSEC; + this.currentAlertMode = xwa.code; + } + break; + default: + this._log.debug("Got unexpected X-Weave-Alert code: " + xwa.code); + } + }, + /** * Handle HTTP response results or exceptions and set the appropriate * Status.* bits. + * + * This method also looks for "side-channel" warnings. */ - checkServerError: function checkServerError(resp) { + checkServerError: function (resp) { switch (resp.status) { + case 200: + case 404: + case 513: + let xwa = resp.headers['x-weave-alert']; + + // Only process machine-readable alerts. + if (!xwa || !xwa.startsWith("{")) { + this.clearServerAlerts(); + return; + } + + try { + xwa = JSON.parse(xwa); + } catch (ex) { + this._log.warn("Malformed X-Weave-Alert from server: " + xwa); + return; + } + + this.handleServerAlert(xwa); + break; + case 400: if (resp == RESPONSE_OVER_QUOTA) { Status.sync = OVER_QUOTA; diff --git a/services/sync/modules/service.js b/services/sync/modules/service.js index 595b1de82a40..1d786f622e05 100644 --- a/services/sync/modules/service.js +++ b/services/sync/modules/service.js @@ -506,9 +506,10 @@ Sync11Service.prototype = { }, /** - * Perform the info fetch as part of a login or key fetch. + * Perform the info fetch as part of a login or key fetch, or + * inside engine sync. */ - _fetchInfo: function _fetchInfo(url) { + _fetchInfo: function (url) { let infoURL = url || this.infoURL; this._log.trace("In _fetchInfo: " + infoURL); @@ -519,9 +520,11 @@ Sync11Service.prototype = { this.errorHandler.checkServerError(ex); throw ex; } + + // Always check for errors; this is also where we look for X-Weave-Alert. + this.errorHandler.checkServerError(info); if (!info.success) { - this.errorHandler.checkServerError(info); - throw "aborting sync, failed to get collections"; + throw "Aborting sync: failed to get collections."; } return info; }, diff --git a/services/sync/modules/status.js b/services/sync/modules/status.js index f17736a94b7a..08577ae82097 100644 --- a/services/sync/modules/status.js +++ b/services/sync/modules/status.js @@ -57,6 +57,15 @@ this.Status = { this.service = code == SYNC_SUCCEEDED ? STATUS_OK : SYNC_FAILED; }, + get eol() { + let modePref = PREFS_BRANCH + "errorhandler.alert.mode"; + try { + return Services.prefs.getCharPref(modePref) == "hard-eol"; + } catch (ex) { + return false; + } + }, + get engines() { return this._engines; }, diff --git a/services/sync/services-sync.js b/services/sync/services-sync.js index 282358865b57..ff931258f15c 100644 --- a/services/sync/services-sync.js +++ b/services/sync/services-sync.js @@ -13,6 +13,7 @@ pref("services.sync.syncKeyHelpURL", "https://services.mozilla.com/help/synckey" pref("services.sync.lastversion", "firstrun"); pref("services.sync.sendVersionInfo", true); +pref("services.sync.scheduler.eolInterval", 604800); // 1 week pref("services.sync.scheduler.singleDeviceInterval", 86400); // 1 day pref("services.sync.scheduler.idleInterval", 3600); // 1 hour pref("services.sync.scheduler.activeInterval", 600); // 10 minutes diff --git a/services/sync/tests/unit/head_http_server.js b/services/sync/tests/unit/head_http_server.js index 40ea16950ddf..7a7f9376c107 100644 --- a/services/sync/tests/unit/head_http_server.js +++ b/services/sync/tests/unit/head_http_server.js @@ -852,7 +852,7 @@ SyncServer.prototype = { // TODO: verify if this is spec-compliant. if (req.method != "DELETE") { respond(405, "Method Not Allowed", "[]", {"Allow": "DELETE"}); - return; + return undefined; } // Delete all collections and track the timestamp for the response. @@ -860,7 +860,7 @@ SyncServer.prototype = { // Return timestamp and OK for deletion. respond(200, "OK", JSON.stringify(timestamp)); - return; + return undefined; } let match = this.storageRE.exec(rest); @@ -875,11 +875,11 @@ SyncServer.prototype = { if (!coll) { if (wboID) { respond(404, "Not found", "Not found"); - return; + return undefined; } // *cries inside*: Bug 687299. respond(200, "OK", "[]"); - return; + return undefined; } if (!wboID) { return coll.collectionHandler(req, resp); @@ -887,7 +887,7 @@ SyncServer.prototype = { let wbo = coll.wbo(wboID); if (!wbo) { respond(404, "Not found", "Not found"); - return; + return undefined; } return wbo.handler()(req, resp); @@ -895,7 +895,7 @@ SyncServer.prototype = { case "DELETE": if (!coll) { respond(200, "OK", "{}"); - return; + return undefined; } if (wboID) { let wbo = coll.wbo(wboID); @@ -904,7 +904,7 @@ SyncServer.prototype = { this.callback.onItemDeleted(username, collection, wboID); } respond(200, "OK", "{}"); - return; + return undefined; } coll.collectionHandler(req, resp); @@ -935,7 +935,7 @@ SyncServer.prototype = { for (let i = 0; i < deleted.length; ++i) { this.callback.onItemDeleted(username, collection, deleted[i]); } - return; + return undefined; case "POST": case "PUT": if (!coll) { diff --git a/services/sync/tests/unit/test_errorhandler_eol.js b/services/sync/tests/unit/test_errorhandler_eol.js new file mode 100644 index 000000000000..215470583c47 --- /dev/null +++ b/services/sync/tests/unit/test_errorhandler_eol.js @@ -0,0 +1,131 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +Cu.import("resource://services-sync/service.js"); +Cu.import("resource://services-sync/status.js"); +Cu.import("resource://services-sync/util.js"); + +Cu.import("resource://testing-common/services/sync/fakeservices.js"); +Cu.import("resource://testing-common/services/sync/utils.js"); + +function baseHandler(eolCode, request, response, statusCode, status, body) { + let alertBody = { + code: eolCode, + message: "Service is EOLed.", + url: "http://getfirefox.com", + }; + response.setHeader("X-Weave-Timestamp", "" + new_timestamp(), false); + response.setHeader("X-Weave-Alert", "" + JSON.stringify(alertBody), false); + response.setStatusLine(request.httpVersion, statusCode, status); + response.bodyOutputStream.write(body, body.length); +} + +function handler513(request, response) { + let statusCode = 513; + let status = "Upgrade Required"; + let body = "{}"; + baseHandler("hard-eol", request, response, statusCode, status, body); +} + +function handler200(eolCode) { + return function (request, response) { + let statusCode = 200; + let status = "OK"; + let body = "{\"meta\": 123456789010}"; + baseHandler(eolCode, request, response, statusCode, status, body); + }; +} + +function sync_httpd_setup(infoHandler) { + let handlers = { + "/1.1/johndoe/info/collections": infoHandler, + }; + return httpd_setup(handlers); +} + +function setUp(server) { + setBasicCredentials("johndoe", "ilovejane", "aabcdeabcdeabcdeabcdeabcde"); + Service.serverURL = server.baseURI + "/"; + Service.clusterURL = server.baseURI + "/"; + new FakeCryptoService(); +} + +function run_test() { + run_next_test(); +} + +function do_check_soft_eol(eh, start) { + // We subtract 1000 because the stored value is in second precision. + do_check_true(eh.earliestNextAlert >= (start + eh.MINIMUM_ALERT_INTERVAL_MSEC - 1000)); + do_check_eq("soft-eol", eh.currentAlertMode); +} +function do_check_hard_eol(eh, start) { + // We subtract 1000 because the stored value is in second precision. + do_check_true(eh.earliestNextAlert >= (start + eh.MINIMUM_ALERT_INTERVAL_MSEC - 1000)); + do_check_eq("hard-eol", eh.currentAlertMode); + do_check_true(Status.eol); +} + +add_test(function test_200_hard() { + let eh = Service.errorHandler; + let start = Date.now(); + let server = sync_httpd_setup(handler200("hard-eol")); + setUp(server); + + let obs = function (subject, topic, data) { + Svc.Obs.remove("weave:eol", obs); + do_check_eq("hard-eol", subject.code); + do_check_hard_eol(eh, start); + do_check_eq(Service.scheduler.eolInterval, Service.scheduler.syncInterval); + eh.clearServerAlerts(); + server.stop(run_next_test); + }; + + Svc.Obs.add("weave:eol", obs); + Service._fetchInfo(); + Service.scheduler.adjustSyncInterval(); // As if we failed or succeeded in syncing. +}); + +add_test(function test_513_hard() { + let eh = Service.errorHandler; + let start = Date.now(); + let server = sync_httpd_setup(handler513); + setUp(server); + + let obs = function (subject, topic, data) { + Svc.Obs.remove("weave:eol", obs); + do_check_eq("hard-eol", subject.code); + do_check_hard_eol(eh, start); + do_check_eq(Service.scheduler.eolInterval, Service.scheduler.syncInterval); + eh.clearServerAlerts(); + server.stop(run_next_test); + }; + + Svc.Obs.add("weave:eol", obs); + try { + Service._fetchInfo(); + Service.scheduler.adjustSyncInterval(); // As if we failed or succeeded in syncing. + } catch (ex) { + // Because fetchInfo will fail on a 513. + } +}); + +add_test(function test_200_soft() { + let eh = Service.errorHandler; + let start = Date.now(); + let server = sync_httpd_setup(handler200("soft-eol")); + setUp(server); + + let obs = function (subject, topic, data) { + Svc.Obs.remove("weave:eol", obs); + do_check_eq("soft-eol", subject.code); + do_check_soft_eol(eh, start); + do_check_eq(Service.scheduler.singleDeviceInterval, Service.scheduler.syncInterval); + eh.clearServerAlerts(); + server.stop(run_next_test); + }; + + Svc.Obs.add("weave:eol", obs); + Service._fetchInfo(); + Service.scheduler.adjustSyncInterval(); // As if we failed or succeeded in syncing. +}); diff --git a/services/sync/tests/unit/xpcshell.ini b/services/sync/tests/unit/xpcshell.ini index 1132e5b9c211..c654cc7bac57 100644 --- a/services/sync/tests/unit/xpcshell.ini +++ b/services/sync/tests/unit/xpcshell.ini @@ -100,6 +100,7 @@ skip-if = os == "android" [test_errorhandler_sync_checkServerError.js] # Bug 676978: test hangs on Android (see also testing/xpcshell/xpcshell.ini) skip-if = os == "android" +[test_errorhandler_eol.js] [test_hmac_error.js] [test_interval_triggers.js] [test_node_reassignment.js]