diff --git a/browser/app/profile/firefox.js b/browser/app/profile/firefox.js index de2e2d27e97..1d9c011c2d2 100644 --- a/browser/app/profile/firefox.js +++ b/browser/app/profile/firefox.js @@ -1298,7 +1298,7 @@ pref("services.sync.prefs.sync.extensions.update.enabled", true); pref("services.sync.prefs.sync.intl.accept_languages", true); pref("services.sync.prefs.sync.javascript.enabled", true); pref("services.sync.prefs.sync.layout.spellcheckDefault", true); -pref("services.sync.prefs.sync.lightweightThemes.isThemeSelected", true); +pref("services.sync.prefs.sync.lightweightThemes.selectedThemeID", true); pref("services.sync.prefs.sync.lightweightThemes.usedThemes", true); pref("services.sync.prefs.sync.network.cookie.cookieBehavior", true); pref("services.sync.prefs.sync.network.cookie.lifetimePolicy", true); @@ -1884,3 +1884,4 @@ pref("browser.readinglist.enabled", true); pref("browser.readinglist.sidebarEverOpened", false); // Enable the readinglist engine by default. pref("readinglist.scheduler.enabled", true); +pref("readinglist.server", "https://readinglist.services.mozilla.com/v1"); diff --git a/browser/base/content/browser-devedition.js b/browser/base/content/browser-devedition.js index d5b1eb39120..934d70bd963 100644 --- a/browser/base/content/browser-devedition.js +++ b/browser/base/content/browser-devedition.js @@ -9,7 +9,7 @@ let DevEdition = { _prefName: "browser.devedition.theme.enabled", _themePrefName: "general.skins.selectedSkin", - _lwThemePrefName: "lightweightThemes.isThemeSelected", + _lwThemePrefName: "lightweightThemes.selectedThemeID", _devtoolsThemePrefName: "devtools.theme", styleSheetLocation: "chrome://browser/skin/devedition.css", @@ -76,7 +76,7 @@ let DevEdition = { _updateStyleSheetFromPrefs: function() { let lightweightThemeSelected = false; try { - lightweightThemeSelected = Services.prefs.getBoolPref(this._lwThemePrefName); + lightweightThemeSelected = !!Services.prefs.getCharPref(this._lwThemePrefName); } catch(e) {} let defaultThemeSelected = false; diff --git a/browser/base/content/browser-syncui.js b/browser/base/content/browser-syncui.js index d956efc80da..767c7c4f5b4 100644 --- a/browser/base/content/browser-syncui.js +++ b/browser/base/content/browser-syncui.js @@ -457,7 +457,7 @@ let gSyncUI = { let lastSync, threshold, prolonged; try { lastSync = new Date(Services.prefs.getCharPref("readinglist.scheduler.lastSync")); - threshold = new Date(Date.now() - Services.prefs.getIntPref("services.sync.errorhandler.networkFailureReportTimeout")); + threshold = new Date(Date.now() - Services.prefs.getIntPref("services.sync.errorhandler.networkFailureReportTimeout") * 1000); prolonged = lastSync <= threshold; } catch (ex) { // no pref, assume not prolonged. diff --git a/browser/base/content/test/general/browser_devedition.js b/browser/base/content/test/general/browser_devedition.js index f0df44523bb..c6c04431844 100644 --- a/browser/base/content/test/general/browser_devedition.js +++ b/browser/base/content/test/general/browser_devedition.js @@ -6,14 +6,18 @@ */ const PREF_DEVEDITION_THEME = "browser.devedition.theme.enabled"; -const PREF_LWTHEME = "lightweightThemes.isThemeSelected"; +const PREF_LWTHEME = "lightweightThemes.selectedThemeID"; +const PREF_LWTHEME_USED_THEMES = "lightweightThemes.usedThemes"; const PREF_DEVTOOLS_THEME = "devtools.theme"; +const {LightweightThemeManager} = Components.utils.import("resource://gre/modules/LightweightThemeManager.jsm", {}); registerCleanupFunction(() => { // Set preferences back to their original values + LightweightThemeManager.currentTheme = null; Services.prefs.clearUserPref(PREF_DEVEDITION_THEME); Services.prefs.clearUserPref(PREF_LWTHEME); Services.prefs.clearUserPref(PREF_DEVTOOLS_THEME); + Services.prefs.clearUserPref(PREF_LWTHEME_USED_THEMES); }); add_task(function* startTests() { @@ -28,12 +32,12 @@ add_task(function* startTests() { ok (DevEdition.styleSheet, "There is a devedition stylesheet when no themes are applied and pref is set."); info ("Adding a lightweight theme."); - Services.prefs.setBoolPref(PREF_LWTHEME, true); + LightweightThemeManager.currentTheme = dummyLightweightTheme("preview0"); ok (!DevEdition.styleSheet, "The devedition stylesheet has been removed when a lightweight theme is applied."); info ("Removing a lightweight theme."); let onAttributeAdded = waitForBrightTitlebarAttribute(); - Services.prefs.setBoolPref(PREF_LWTHEME, false); + LightweightThemeManager.currentTheme = null; ok (DevEdition.styleSheet, "The devedition stylesheet has been added when a lightweight theme is removed."); yield onAttributeAdded; @@ -85,16 +89,14 @@ function dummyLightweightTheme(id) { return { id: id, name: id, - headerURL: "http://lwttest.invalid/a.png", - footerURL: "http://lwttest.invalid/b.png", + headerURL: "resource:///chrome/browser/content/browser/defaultthemes/1.header.jpg", + iconURL: "resource:///chrome/browser/content/browser/defaultthemes/1.icon.jpg", textcolor: "red", accentcolor: "blue" }; } add_task(function* testLightweightThemePreview() { - let {LightweightThemeManager} = Components.utils.import("resource://gre/modules/LightweightThemeManager.jsm", {}); - info ("Turning the pref on, then previewing lightweight themes"); Services.prefs.setBoolPref(PREF_DEVEDITION_THEME, true); ok (DevEdition.styleSheet, "The devedition stylesheet is enabled."); diff --git a/browser/components/customizableui/test/browser_1007336_lwthemes_in_customize_mode.js b/browser/components/customizableui/test/browser_1007336_lwthemes_in_customize_mode.js index 089961b6518..6b4ea200c75 100644 --- a/browser/components/customizableui/test/browser_1007336_lwthemes_in_customize_mode.js +++ b/browser/components/customizableui/test/browser_1007336_lwthemes_in_customize_mode.js @@ -49,7 +49,7 @@ add_task(function () { let defaultTheme = header.nextSibling; defaultTheme.doCommand(); - is(Services.prefs.getBoolPref("lightweightThemes.isThemeSelected"), false, "No lwtheme should be selected"); + is(Services.prefs.prefHasUserValue("lightweightThemes.selectedThemeID"), false, "No lwtheme should be selected"); }); add_task(function asyncCleanup() { diff --git a/browser/components/loop/MozLoopAPI.jsm b/browser/components/loop/MozLoopAPI.jsm index d38af8d40a1..67d538ae45b 100644 --- a/browser/components/loop/MozLoopAPI.jsm +++ b/browser/components/loop/MozLoopAPI.jsm @@ -639,6 +639,13 @@ function injectLoopAPI(targetWindow) { } }, + SHARING_STATE_CHANGE: { + enumerable: true, + get: function() { + return Cu.cloneInto(SHARING_STATE_CHANGE, targetWindow); + } + }, + fxAEnabled: { enumerable: true, get: function() { diff --git a/browser/components/loop/MozLoopService.jsm b/browser/components/loop/MozLoopService.jsm index 74fb7619051..c9eb04903e6 100644 --- a/browser/components/loop/MozLoopService.jsm +++ b/browser/components/loop/MozLoopService.jsm @@ -29,6 +29,19 @@ const TWO_WAY_MEDIA_CONN_LENGTH = { MORE_THAN_5M: "MORE_THAN_5M", }; +/** + * Buckets that we segment sharing state change telemetry probes into. + * + * @type {{WINDOW_ENABLED: String, WINDOW_DISABLED: String, + * BROWSER_ENABLED: String, BROWSER_DISABLED: String}} + */ +const SHARING_STATE_CHANGE = { + WINDOW_ENABLED: "WINDOW_ENABLED", + WINDOW_DISABLED: "WINDOW_DISABLED", + BROWSER_ENABLED: "BROWSER_ENABLED", + BROWSER_DISABLED: "BROWSER_DISABLED" +}; + // See LOG_LEVELS in Console.jsm. Common examples: "All", "Info", "Warn", & "Error". const PREF_LOG_LEVEL = "loop.debug.loglevel"; @@ -42,7 +55,8 @@ Cu.import("resource://gre/modules/FxAccountsOAuthClient.jsm"); Cu.importGlobalProperties(["URL"]); -this.EXPORTED_SYMBOLS = ["MozLoopService", "LOOP_SESSION_TYPE", "TWO_WAY_MEDIA_CONN_LENGTH"]; +this.EXPORTED_SYMBOLS = ["MozLoopService", "LOOP_SESSION_TYPE", + "TWO_WAY_MEDIA_CONN_LENGTH", "SHARING_STATE_CHANGE"]; XPCOMUtils.defineLazyModuleGetter(this, "injectLoopAPI", "resource:///modules/loop/MozLoopAPI.jsm"); diff --git a/browser/components/loop/content/shared/js/actions.js b/browser/components/loop/content/shared/js/actions.js index aa6df2777b8..68f019203e2 100644 --- a/browser/components/loop/content/shared/js/actions.js +++ b/browser/components/loop/content/shared/js/actions.js @@ -361,7 +361,9 @@ loop.shared.actions = (function() { * XXX: should move to some roomActions module - refs bug 1079284 */ RoomFailure: Action.define("roomFailure", { - error: Object + error: Object, + // True when the failures occurs in the join room request to the loop-server. + failedJoinRequest: Boolean }), /** diff --git a/browser/components/loop/content/shared/js/activeRoomStore.js b/browser/components/loop/content/shared/js/activeRoomStore.js index 0f1f73cb700..8446352cc70 100644 --- a/browser/components/loop/content/shared/js/activeRoomStore.js +++ b/browser/components/loop/content/shared/js/activeRoomStore.js @@ -105,7 +105,7 @@ loop.store.ActiveRoomStore = (function() { }); this._leaveRoom(actionData.error.errno === REST_ERRNOS.ROOM_FULL ? - ROOM_STATES.FULL : ROOM_STATES.FAILED); + ROOM_STATES.FULL : ROOM_STATES.FAILED, actionData.failedJoinRequest); }, /** @@ -161,7 +161,10 @@ loop.store.ActiveRoomStore = (function() { this._mozLoop.rooms.get(actionData.roomToken, function(error, roomData) { if (error) { - this.dispatchAction(new sharedActions.RoomFailure({error: error})); + this.dispatchAction(new sharedActions.RoomFailure({ + error: error, + failedJoinRequest: false + })); return; } @@ -293,7 +296,15 @@ loop.store.ActiveRoomStore = (function() { this._mozLoop.rooms.join(this._storeState.roomToken, function(error, responseData) { if (error) { - this.dispatchAction(new sharedActions.RoomFailure({error: error})); + this.dispatchAction(new sharedActions.RoomFailure({ + error: error, + // This is an explicit flag to avoid the leave happening if join + // fails. We can't track it on ROOM_STATES.JOINING as the user + // might choose to leave the room whilst the XHR is in progress + // which would then mean we'd run the race condition of not + // notifying the server of a leave. + failedJoinRequest: true + })); return; } @@ -555,7 +566,10 @@ loop.store.ActiveRoomStore = (function() { this._storeState.sessionToken, function(error, responseData) { if (error) { - this.dispatchAction(new sharedActions.RoomFailure({error: error})); + this.dispatchAction(new sharedActions.RoomFailure({ + error: error, + failedJoinRequest: false + })); return; } @@ -567,9 +581,12 @@ loop.store.ActiveRoomStore = (function() { * Handles leaving a room. Clears any membership timeouts, then * signals to the server the leave of the room. * - * @param {ROOM_STATES} nextState The next state to switch to. + * @param {ROOM_STATES} nextState The next state to switch to. + * @param {Boolean} failedJoinRequest Optional. Set to true if the join + * request to loop-server failed. It + * will skip the leave message. */ - _leaveRoom: function(nextState) { + _leaveRoom: function(nextState, failedJoinRequest) { if (loop.standaloneMedia) { loop.standaloneMedia.multiplexGum.reset(); } @@ -592,10 +609,11 @@ loop.store.ActiveRoomStore = (function() { delete this._timeout; } - if (this._storeState.roomState === ROOM_STATES.JOINING || - this._storeState.roomState === ROOM_STATES.JOINED || - this._storeState.roomState === ROOM_STATES.SESSION_CONNECTED || - this._storeState.roomState === ROOM_STATES.HAS_PARTICIPANTS) { + if (!failedJoinRequest && + (this._storeState.roomState === ROOM_STATES.JOINING || + this._storeState.roomState === ROOM_STATES.JOINED || + this._storeState.roomState === ROOM_STATES.SESSION_CONNECTED || + this._storeState.roomState === ROOM_STATES.HAS_PARTICIPANTS)) { this._mozLoop.rooms.leave(this._storeState.roomToken, this._storeState.sessionToken); } diff --git a/browser/components/loop/content/shared/js/otSdkDriver.js b/browser/components/loop/content/shared/js/otSdkDriver.js index fe5aa06d8b1..e213c4d40a6 100644 --- a/browser/components/loop/content/shared/js/otSdkDriver.js +++ b/browser/components/loop/content/shared/js/otSdkDriver.js @@ -165,6 +165,8 @@ loop.OTSdkDriver = (function() { config); this.screenshare.on("accessAllowed", this._onScreenShareGranted.bind(this)); this.screenshare.on("accessDenied", this._onScreenShareDenied.bind(this)); + + this._noteSharingState(options.videoSource, true); }, /** @@ -196,6 +198,7 @@ loop.OTSdkDriver = (function() { this.screenshare.off("accessAllowed accessDenied"); this.screenshare.destroy(); delete this.screenshare; + this._noteSharingState(this._windowId ? "browser" : "window", false); delete this._windowId; return true; }, @@ -648,15 +651,15 @@ loop.OTSdkDriver = (function() { * @private */ _noteConnectionLength: function(callLengthSeconds) { + var buckets = this.mozLoop.TWO_WAY_MEDIA_CONN_LENGTH; - var bucket = this.mozLoop.TWO_WAY_MEDIA_CONN_LENGTH.SHORTER_THAN_10S; - + var bucket = buckets.SHORTER_THAN_10S; if (callLengthSeconds >= 10 && callLengthSeconds <= 30) { - bucket = this.mozLoop.TWO_WAY_MEDIA_CONN_LENGTH.BETWEEN_10S_AND_30S; + bucket = buckets.BETWEEN_10S_AND_30S; } else if (callLengthSeconds > 30 && callLengthSeconds <= 300) { - bucket = this.mozLoop.TWO_WAY_MEDIA_CONN_LENGTH.BETWEEN_30S_AND_5M; + bucket = buckets.BETWEEN_30S_AND_5M; } else if (callLengthSeconds > 300) { - bucket = this.mozLoop.TWO_WAY_MEDIA_CONN_LENGTH.MORE_THAN_5M; + bucket = buckets.MORE_THAN_5M; } this.mozLoop.telemetryAddKeyedValue("LOOP_TWO_WAY_MEDIA_CONN_LENGTH", @@ -705,7 +708,32 @@ loop.OTSdkDriver = (function() { * If set to true, make it easy to test/verify 2-way media connection * telemetry code operation by viewing the logs. */ - _debugTwoWayMediaTelemetry: false + _debugTwoWayMediaTelemetry: false, + + /** + * Note the sharing state. If this.mozLoop is not defined, we're assumed to + * be running in the standalone client and return immediately. + * + * @param {String} type Type of sharing that was flipped. May be 'window' + * or 'tab'. + * @param {Boolean} enabled Flag that tells us if the feature was flipped on + * or off. + * @private + */ + _noteSharingState: function(type, enabled) { + if (!this.mozLoop) { + return; + } + + var bucket = this.mozLoop.SHARING_STATE_CHANGE[type.toUpperCase() + "_" + + (enabled ? "ENABLED" : "DISABLED")]; + if (!bucket) { + console.error("No sharing state bucket found for '" + type + "'"); + return; + } + + this.mozLoop.telemetryAddKeyedValue("LOOP_SHARING_STATE_CHANGE", bucket); + } }; return OTSdkDriver; diff --git a/browser/components/loop/test/mochitest/browser_mozLoop_telemetry.js b/browser/components/loop/test/mochitest/browser_mozLoop_telemetry.js index 84a82c49ffa..6df06a069c7 100644 --- a/browser/components/loop/test/mochitest/browser_mozLoop_telemetry.js +++ b/browser/components/loop/test/mochitest/browser_mozLoop_telemetry.js @@ -47,3 +47,29 @@ add_task(function* test_mozLoop_telemetryAdd_buckets() { is(snapshot["BETWEEN_30S_AND_5M"].sum, 3, "TWO_WAY_MEDIA_CONN_LENGTH.BETWEEN_30S_AND_5M"); is(snapshot["MORE_THAN_5M"].sum, 4, "TWO_WAY_MEDIA_CONN_LENGTH.MORE_THAN_5M"); }); + +add_task(function* test_mozLoop_telemetryAdd_sharing_buckets() { + let histogramId = "LOOP_SHARING_STATE_CHANGE"; + let histogram = Services.telemetry.getKeyedHistogramById(histogramId); + const SHARING_STATES = gMozLoopAPI.SHARING_STATE_CHANGE; + + histogram.clear(); + for (let value of [SHARING_STATES.WINDOW_ENABLED, + SHARING_STATES.WINDOW_DISABLED, + SHARING_STATES.WINDOW_DISABLED, + SHARING_STATES.BROWSER_ENABLED, + SHARING_STATES.BROWSER_ENABLED, + SHARING_STATES.BROWSER_ENABLED, + SHARING_STATES.BROWSER_DISABLED, + SHARING_STATES.BROWSER_DISABLED, + SHARING_STATES.BROWSER_DISABLED, + SHARING_STATES.BROWSER_DISABLED]) { + gMozLoopAPI.telemetryAddKeyedValue(histogramId, value); + } + + let snapshot = histogram.snapshot(); + Assert.strictEqual(snapshot["WINDOW_ENABLED"].sum, 1, "SHARING_STATE_CHANGE.WINDOW_ENABLED"); + Assert.strictEqual(snapshot["WINDOW_DISABLED"].sum, 2, "SHARING_STATE_CHANGE.WINDOW_DISABLED"); + Assert.strictEqual(snapshot["BROWSER_ENABLED"].sum, 3, "SHARING_STATE_CHANGE.BROWSER_ENABLED"); + Assert.strictEqual(snapshot["BROWSER_DISABLED"].sum, 4, "SHARING_STATE_CHANGE.BROWSER_DISABLED"); +}); diff --git a/browser/components/loop/test/shared/activeRoomStore_test.js b/browser/components/loop/test/shared/activeRoomStore_test.js index 60e05429f51..d8300d00d32 100644 --- a/browser/components/loop/test/shared/activeRoomStore_test.js +++ b/browser/components/loop/test/shared/activeRoomStore_test.js @@ -95,7 +95,10 @@ describe("loop.store.ActiveRoomStore", function () { }); it("should log the error", function() { - store.roomFailure({error: fakeError}); + store.roomFailure(new sharedActions.RoomFailure({ + error: fakeError, + failedJoinRequest: false + })); sinon.assert.calledOnce(console.error); sinon.assert.calledWith(console.error, @@ -105,13 +108,19 @@ describe("loop.store.ActiveRoomStore", function () { it("should set the state to `FULL` on server error room full", function() { fakeError.errno = REST_ERRNOS.ROOM_FULL; - store.roomFailure({error: fakeError}); + store.roomFailure(new sharedActions.RoomFailure({ + error: fakeError, + failedJoinRequest: false + })); expect(store._storeState.roomState).eql(ROOM_STATES.FULL); }); it("should set the state to `FAILED` on generic error", function() { - store.roomFailure({error: fakeError}); + store.roomFailure(new sharedActions.RoomFailure({ + error: fakeError, + failedJoinRequest: false + })); expect(store._storeState.roomState).eql(ROOM_STATES.FAILED); expect(store._storeState.failureReason).eql(FAILURE_DETAILS.UNKNOWN); @@ -121,7 +130,10 @@ describe("loop.store.ActiveRoomStore", function () { "invalid token", function() { fakeError.errno = REST_ERRNOS.INVALID_TOKEN; - store.roomFailure({error: fakeError}); + store.roomFailure(new sharedActions.RoomFailure({ + error: fakeError, + failedJoinRequest: false + })); expect(store._storeState.roomState).eql(ROOM_STATES.FAILED); expect(store._storeState.failureReason).eql(FAILURE_DETAILS.EXPIRED_OR_INVALID); @@ -131,14 +143,20 @@ describe("loop.store.ActiveRoomStore", function () { "expired", function() { fakeError.errno = REST_ERRNOS.EXPIRED; - store.roomFailure({error: fakeError}); + store.roomFailure(new sharedActions.RoomFailure({ + error: fakeError, + failedJoinRequest: false + })); expect(store._storeState.roomState).eql(ROOM_STATES.FAILED); expect(store._storeState.failureReason).eql(FAILURE_DETAILS.EXPIRED_OR_INVALID); }); it("should reset the multiplexGum", function() { - store.roomFailure({error: fakeError}); + store.roomFailure(new sharedActions.RoomFailure({ + error: fakeError, + failedJoinRequest: false + })); sinon.assert.calledOnce(fakeMultiplexGum.reset); }); @@ -146,14 +164,20 @@ describe("loop.store.ActiveRoomStore", function () { it("should set screen sharing inactive", function() { store.setStoreState({windowId: "1234"}); - store.roomFailure({error: fakeError}); + store.roomFailure(new sharedActions.RoomFailure({ + error: fakeError, + failedJoinRequest: false + })); sinon.assert.calledOnce(fakeMozLoop.setScreenShareState); sinon.assert.calledWithExactly(fakeMozLoop.setScreenShareState, "1234", false); }); it("should disconnect from the servers via the sdk", function() { - store.roomFailure({error: fakeError}); + store.roomFailure(new sharedActions.RoomFailure({ + error: fakeError, + failedJoinRequest: false + })); sinon.assert.calledOnce(fakeSdkDriver.disconnectSession); }); @@ -162,7 +186,10 @@ describe("loop.store.ActiveRoomStore", function () { sandbox.stub(window, "clearTimeout"); store._timeout = {}; - store.roomFailure({error: fakeError}); + store.roomFailure(new sharedActions.RoomFailure({ + error: fakeError, + failedJoinRequest: false + })); sinon.assert.calledOnce(clearTimeout); }); @@ -174,18 +201,33 @@ describe("loop.store.ActiveRoomStore", function () { })); // Now simulate room failure. - store.roomFailure({error: fakeError}); + store.roomFailure(new sharedActions.RoomFailure({ + error: fakeError, + failedJoinRequest: false + })); sinon.assert.calledOnce(fakeMozLoop.removeBrowserSharingListener); }); it("should call mozLoop.rooms.leave", function() { - store.roomFailure({error: fakeError}); + store.roomFailure(new sharedActions.RoomFailure({ + error: fakeError, + failedJoinRequest: false + })); sinon.assert.calledOnce(fakeMozLoop.rooms.leave); sinon.assert.calledWithExactly(fakeMozLoop.rooms.leave, "fakeToken", "1627384950"); }); + + it("should not call mozLoop.rooms.leave if failedJoinRequest is true", function() { + store.roomFailure(new sharedActions.RoomFailure({ + error: fakeError, + failedJoinRequest: true + })); + + sinon.assert.notCalled(fakeMozLoop.rooms.leave); + }); }); describe("#setupWindowData", function() { @@ -271,7 +313,8 @@ describe("loop.store.ActiveRoomStore", function () { sinon.assert.calledOnce(dispatcher.dispatch); sinon.assert.calledWithExactly(dispatcher.dispatch, new sharedActions.RoomFailure({ - error: fakeError + error: fakeError, + failedJoinRequest: false })); }); }); @@ -449,7 +492,10 @@ describe("loop.store.ActiveRoomStore", function () { sinon.assert.calledOnce(dispatcher.dispatch); sinon.assert.calledWith(dispatcher.dispatch, - new sharedActions.RoomFailure({error: fakeError})); + new sharedActions.RoomFailure({ + error: fakeError, + failedJoinRequest: true + })); }); }); @@ -582,7 +628,8 @@ describe("loop.store.ActiveRoomStore", function () { sinon.assert.calledOnce(dispatcher.dispatch); sinon.assert.calledWith(dispatcher.dispatch, new sharedActions.RoomFailure({ - error: fakeError + error: fakeError, + failedJoinRequest: false })); }); }); diff --git a/browser/components/loop/test/shared/otSdkDriver_test.js b/browser/components/loop/test/shared/otSdkDriver_test.js index 11fc6cfc937..e30b480b5b4 100644 --- a/browser/components/loop/test/shared/otSdkDriver_test.js +++ b/browser/components/loop/test/shared/otSdkDriver_test.js @@ -71,6 +71,12 @@ describe("loop.OTSdkDriver", function () { BETWEEN_10S_AND_30S: "BETWEEN_10S_AND_30S", BETWEEN_30S_AND_5M: "BETWEEN_30S_AND_5M", MORE_THAN_5M: "MORE_THAN_5M" + }, + SHARING_STATE_CHANGE: { + WINDOW_ENABLED: "WINDOW_ENABLED", + WINDOW_DISABLED: "WINDOW_DISABLED", + BROWSER_ENABLED: "BROWSER_ENABLED", + BROWSER_DISABLED: "BROWSER_DISABLED" } }; @@ -189,6 +195,7 @@ describe("loop.OTSdkDriver", function () { beforeEach(function() { sandbox.stub(dispatcher, "dispatch"); + sandbox.stub(driver, "_noteSharingState"); fakeElement = { className: "fakeVideo" @@ -214,6 +221,19 @@ describe("loop.OTSdkDriver", function () { sinon.assert.calledOnce(sdk.initPublisher); sinon.assert.calledWithMatch(sdk.initPublisher, fakeElement, options); }); + + it("should log a telemetry action", function() { + var options = { + videoSource: "browser", + constraints: { + browserWindow: 42, + scrollWithPage: true + } + }; + driver.startScreenShare(options); + + sinon.assert.calledWithExactly(driver._noteSharingState, "browser", true); + }); }); describe("#switchAcquiredWindow", function() { @@ -251,26 +271,70 @@ describe("loop.OTSdkDriver", function () { beforeEach(function() { driver.getScreenShareElementFunc = function() {}; - driver.startScreenShare({ - videoSource: "window" - }); - sandbox.stub(dispatcher, "dispatch"); - - driver.session = session; + sandbox.stub(driver, "_noteSharingState"); }); it("should unpublish the share", function() { + driver.startScreenShare({ + videoSource: "window" + }); + driver.session = session; + driver.endScreenShare(new sharedActions.EndScreenShare()); sinon.assert.calledOnce(session.unpublish); }); + it("should log a telemetry action", function() { + driver.startScreenShare({ + videoSource: "window" + }); + driver.session = session; + + driver.endScreenShare(new sharedActions.EndScreenShare()); + + sinon.assert.calledWithExactly(driver._noteSharingState, "window", false); + }); + it("should destroy the share", function() { + driver.startScreenShare({ + videoSource: "window" + }); + driver.session = session; + expect(driver.endScreenShare()).to.equal(true); sinon.assert.calledOnce(publisher.destroy); }); + + it("should unpublish the share too when type is 'browser'", function() { + driver.startScreenShare({ + videoSource: "browser", + constraints: { + browserWindow: 42 + } + }); + driver.session = session; + + driver.endScreenShare(new sharedActions.EndScreenShare()); + + sinon.assert.calledOnce(session.unpublish); + }); + + it("should log a telemetry action too when type is 'browser'", function() { + driver.startScreenShare({ + videoSource: "browser", + constraints: { + browserWindow: 42 + } + }); + driver.session = session; + + driver.endScreenShare(new sharedActions.EndScreenShare()); + + sinon.assert.calledWithExactly(driver._noteSharingState, "browser", false); + }); }); describe("#connectSession", function() { @@ -431,6 +495,44 @@ describe("loop.OTSdkDriver", function () { }); }); + describe("#_noteSharingState", function() { + it("should record enabled sharing states for window", function() { + driver._noteSharingState("window", true); + + sinon.assert.calledOnce(mozLoop.telemetryAddKeyedValue); + sinon.assert.calledWithExactly(mozLoop.telemetryAddKeyedValue, + "LOOP_SHARING_STATE_CHANGE", + mozLoop.SHARING_STATE_CHANGE.WINDOW_ENABLED); + }); + + it("should record enabled sharing states for browser", function() { + driver._noteSharingState("browser", true); + + sinon.assert.calledOnce(mozLoop.telemetryAddKeyedValue); + sinon.assert.calledWithExactly(mozLoop.telemetryAddKeyedValue, + "LOOP_SHARING_STATE_CHANGE", + mozLoop.SHARING_STATE_CHANGE.BROWSER_ENABLED); + }); + + it("should record disabled sharing states for window", function() { + driver._noteSharingState("window", false); + + sinon.assert.calledOnce(mozLoop.telemetryAddKeyedValue); + sinon.assert.calledWithExactly(mozLoop.telemetryAddKeyedValue, + "LOOP_SHARING_STATE_CHANGE", + mozLoop.SHARING_STATE_CHANGE.WINDOW_DISABLED); + }); + + it("should record disabled sharing states for browser", function() { + driver._noteSharingState("browser", false); + + sinon.assert.calledOnce(mozLoop.telemetryAddKeyedValue); + sinon.assert.calledWithExactly(mozLoop.telemetryAddKeyedValue, + "LOOP_SHARING_STATE_CHANGE", + mozLoop.SHARING_STATE_CHANGE.BROWSER_DISABLED); + }); + }); + describe("#forceDisconnectAll", function() { it("should not disconnect anything when not connected", function() { driver.session = session; diff --git a/browser/components/readinglist/ReadingList.jsm b/browser/components/readinglist/ReadingList.jsm index cd4c847681f..1ee6161e85f 100644 --- a/browser/components/readinglist/ReadingList.jsm +++ b/browser/components/readinglist/ReadingList.jsm @@ -58,6 +58,17 @@ const ITEM_RECORD_PROPERTIES = ` readPosition `.trim().split(/\s+/); +// Article objects that are passed to ReadingList.addItem may contain +// some properties that are known but are not currently stored in the +// ReadingList records. This is the list of properties that are knowingly +// disregarded before the item is normalized. +const ITEM_DISREGARDED_PROPERTIES = ` + byline + dir + content + length +`.trim().split(/\s+/); + /** * A reading list contains ReadingListItems. * @@ -853,6 +864,9 @@ ReadingListItemIterator.prototype = { function normalizeRecord(nonNormalizedRecord) { let record = {}; for (let prop in nonNormalizedRecord) { + if (ITEM_DISREGARDED_PROPERTIES.includes(prop)) { + continue; + } if (!ITEM_RECORD_PROPERTIES.includes(prop)) { throw new Error("Unrecognized item property: " + prop); } diff --git a/browser/components/readinglist/ServerClient.jsm b/browser/components/readinglist/ServerClient.jsm new file mode 100644 index 00000000000..6f6c677acba --- /dev/null +++ b/browser/components/readinglist/ServerClient.jsm @@ -0,0 +1,166 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +// The client used to access the ReadingList server. + +"use strict"; + +const { classes: Cc, interfaces: Ci, utils: Cu } = Components; + +Cu.import("resource://gre/modules/XPCOMUtils.jsm"); +Cu.import("resource://gre/modules/Services.jsm"); +Cu.import("resource://gre/modules/Log.jsm"); +Cu.import("resource://gre/modules/Task.jsm"); + +XPCOMUtils.defineLazyModuleGetter(this, "RESTRequest", "resource://services-common/rest.js"); +XPCOMUtils.defineLazyModuleGetter(this, "CommonUtils", "resource://services-common/utils.js"); +XPCOMUtils.defineLazyModuleGetter(this, "fxAccounts", "resource://gre/modules/FxAccounts.jsm"); + +let log = Log.repository.getLogger("readinglist.serverclient"); + +const OAUTH_SCOPE = "readinglist"; // The "scope" on the oauth token we request. + +this.EXPORTED_SYMBOLS = [ + "ServerClient", +]; + +// utf-8 joy. rest.js, which we use for the underlying requests, does *not* +// encode the request as utf-8 even though it wants to know the encoding. +// It does, however, explicitly decode the response. This seems insane, but is +// what it is. +// The end result being we need to utf-8 the request and let the response take +// care of itself. +function objectToUTF8Json(obj) { + // FTR, unescape(encodeURIComponent(JSON.stringify(obj))) also works ;) + return CommonUtils.encodeUTF8(JSON.stringify(obj)); +} + +function ServerClient(fxa = fxAccounts) { + this.fxa = fxa; +} + +ServerClient.prototype = { + + request(options) { + return this._request(options.path, options.method, options.body, options.headers); + }, + + get serverURL() { + return Services.prefs.getCharPref("readinglist.server"); + }, + + _getURL(path) { + let result = this.serverURL; + // we expect the path to have a leading slash, so remove any trailing + // slashes on the pref. + if (result.endsWith("/")) { + result = result.slice(0, -1); + } + return result + path; + }, + + // Hook points for testing. + _getToken() { + // Assume token-caching is in place - if it's not we should avoid doing + // this each request. + return this.fxa.getOAuthToken({scope: OAUTH_SCOPE}); + }, + + _removeToken(token) { + // XXX - remove this check once tokencaching landsin FxA. + if (!this.fxa.removeCachedOAuthToken) { + dump("XXX - token caching support is yet to land - can't remove token!"); + return; + } + return this.fxa.removeCachedOAuthToken({token}); + }, + + // Converts an error from the RESTRequest object to an error we export. + _convertRestError(error) { + return error; // XXX - errors? + }, + + // Converts an error from a try/catch handler to an error we export. + _convertJSError(error) { + return error; // XXX - errors? + }, + + /* + * Perform a request - handles authentication + */ + _request: Task.async(function* (path, method, body, headers) { + let token = yield this._getToken(); + let response = yield this._rawRequest(path, method, body, headers, token); + log.debug("initial request got status ${status}", response); + if (response.status == 401) { + // an auth error - assume our token has expired or similar. + this._removeToken(token); + token = yield this._getToken(); + response = yield this._rawRequest(path, method, body, headers, token); + log.debug("retry of request got status ${status}", response); + } + return response; + }), + + /* + * Perform a request *without* abstractions such as auth etc + * + * On success (which *includes* non-200 responses) returns an object like: + * { + * status: 200, # http status code + * headers: {}, # header values keyed by header name. + * body: {}, # parsed json + } + */ + + _rawRequest(path, method, body, headers, oauthToken) { + return new Promise((resolve, reject) => { + let url = this._getURL(path); + log.debug("dispatching request to", url); + let request = new RESTRequest(url); + method = method.toUpperCase(); + + request.setHeader("Accept", "application/json"); + request.setHeader("Content-Type", "application/json; charset=utf-8"); + request.setHeader("Authorization", "Bearer " + oauthToken); + // and additional header specified for this request. + if (headers) { + for (let [headerName, headerValue] in Iterator(headers)) { + log.trace("Caller specified header: ${headerName}=${headerValue}", {headerName, headerValue}); + request.setHeader(headerName, headerValue); + } + } + + request.onComplete = error => { + if (error) { + return reject(this._convertRestError(error)); + } + + let response = request.response; + log.debug("received response status: ${status} ${statusText}", response); + // Handle response status codes we know about + let result = { + status: response.status, + headers: response.headers + }; + try { + if (response.body) { + result.body = JSON.parse(response.body); + } + } catch (e) { + log.info("Failed to parse JSON body |${body}|: ${e}", + {body: response.body, e}); + // We don't reject due to this (and don't even make a huge amount of + // log noise - eg, a 50X error from a load balancer etc may not write + // JSON. + } + + resolve(result); + } + // We are assuming the body has already been decoded and thus contains + // unicode, but the server expects utf-8. encodeURIComponent does that. + request.dispatch(method, objectToUTF8Json(body)); + }); + }, +}; diff --git a/browser/components/readinglist/moz.build b/browser/components/readinglist/moz.build index 89d9ee43a0b..db1a99bb3b8 100644 --- a/browser/components/readinglist/moz.build +++ b/browser/components/readinglist/moz.build @@ -6,6 +6,8 @@ JAR_MANIFESTS += ['jar.mn'] EXTRA_JS_MODULES.readinglist += [ 'ReadingList.jsm', + 'Scheduler.jsm', + 'ServerClient.jsm', 'SQLiteStore.jsm', ] @@ -17,9 +19,5 @@ BROWSER_CHROME_MANIFESTS += ['test/browser/browser.ini'] XPCSHELL_TESTS_MANIFESTS += ['test/xpcshell/xpcshell.ini'] -EXTRA_JS_MODULES.readinglist += [ - 'Scheduler.jsm', -] - with Files('**'): BUG_COMPONENT = ('Firefox', 'Reading List') diff --git a/browser/components/readinglist/sidebar.js b/browser/components/readinglist/sidebar.js index 085883cf1c2..e8d81cc3b2d 100644 --- a/browser/components/readinglist/sidebar.js +++ b/browser/components/readinglist/sidebar.js @@ -161,6 +161,7 @@ let RLSidebar = { } else { thumb.style.removeProperty("background-image"); } + thumb.classList.toggle("preview-available", !!item.preview); }, /** @@ -200,7 +201,7 @@ let RLSidebar = { return; } - log.debug(`Setting activeItem: ${node ? node.id : null}`); + log.trace(`Setting activeItem: ${node ? node.id : null}`); if (node && node.classList.contains("active")) { return; @@ -233,7 +234,7 @@ let RLSidebar = { return; } - log.debug(`Setting activeItem: ${node ? node.id : null}`); + log.trace(`Setting selectedItem: ${node ? node.id : null}`); let prevItem = document.querySelector("#list > .item.selected"); if (prevItem) { @@ -270,7 +271,7 @@ let RLSidebar = { }, set selectedIndex(index) { - log.debug(`Setting selectedIndex: ${index}`); + log.trace(`Setting selectedIndex: ${index}`); if (index == -1) { this.selectedItem = null; diff --git a/browser/components/readinglist/test/xpcshell/head.js b/browser/components/readinglist/test/xpcshell/head.js index caf9f95a955..65dd71c9492 100644 --- a/browser/components/readinglist/test/xpcshell/head.js +++ b/browser/components/readinglist/test/xpcshell/head.js @@ -5,3 +5,52 @@ const {classes: Cc, interfaces: Ci, utils: Cu, results: Cr} = Components; Cu.import("resource://gre/modules/XPCOMUtils.jsm"); Cu.import("resource://gre/modules/Services.jsm"); + +do_get_profile(); // fxa needs a profile directory for storage. + +Cu.import("resource://gre/modules/FxAccounts.jsm"); +Cu.import("resource://gre/modules/FxAccountsClient.jsm"); + +// Create a mocked FxAccounts object with a signed-in, verified user. +function* createMockFxA() { + + function MockFxAccountsClient() { + this._email = "nobody@example.com"; + this._verified = false; + + this.accountStatus = function(uid) { + let deferred = Promise.defer(); + deferred.resolve(!!uid && (!this._deletedOnServer)); + return deferred.promise; + }; + + this.signOut = function() { return Promise.resolve(); }; + + FxAccountsClient.apply(this); + } + + MockFxAccountsClient.prototype = { + __proto__: FxAccountsClient.prototype + } + + function MockFxAccounts() { + return new FxAccounts({ + fxAccountsClient: new MockFxAccountsClient(), + getAssertion: () => Promise.resolve("assertion"), + }); + } + + let fxa = new MockFxAccounts(); + let credentials = { + email: "foo@example.com", + uid: "1234@lcip.org", + assertion: "foobar", + sessionToken: "dead", + kA: "beef", + kB: "cafe", + verified: true + }; + + yield fxa.setSignedInUser(credentials); + return fxa; +} diff --git a/browser/components/readinglist/test/xpcshell/test_ServerClient.js b/browser/components/readinglist/test/xpcshell/test_ServerClient.js new file mode 100644 index 00000000000..8528e4685ad --- /dev/null +++ b/browser/components/readinglist/test/xpcshell/test_ServerClient.js @@ -0,0 +1,209 @@ +/* Any copyright is dedicated to the Public Domain. + * http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +Cu.import("resource://testing-common/httpd.js"); +Cu.import("resource:///modules/readinglist/ServerClient.jsm"); +Cu.import("resource://gre/modules/Log.jsm"); + +let appender = new Log.DumpAppender(); +for (let logName of ["FirefoxAccounts", "readinglist.serverclient"]) { + Log.repository.getLogger(logName).addAppender(appender); +} + +// Some test servers we use. +let Server = function(handlers) { + this._server = null; + this._handlers = handlers; +} + +Server.prototype = { + start() { + this._server = new HttpServer(); + for (let [path, handler] in Iterator(this._handlers)) { + // httpd.js seems to swallow exceptions + let thisHandler = handler; + let wrapper = (request, response) => { + try { + thisHandler(request, response); + } catch (ex) { + print("**** Handler for", path, "failed:", ex, ex.stack); + throw ex; + } + } + this._server.registerPathHandler(path, wrapper); + } + this._server.start(-1); + }, + + stop() { + return new Promise(resolve => { + this._server.stop(resolve); + this._server = null; + }); + }, + + get host() { + return "http://localhost:" + this._server.identity.primaryPort; + }, +}; + +// An OAuth server that hands out tokens. +function OAuthTokenServer() { + let server; + let handlers = { + "/v1/authorization": (request, response) => { + response.setStatusLine("1.1", 200, "OK"); + let token = "token" + server.numTokenFetches; + print("Test OAuth server handing out token", token); + server.numTokenFetches += 1; + server.activeTokens.add(token); + response.write(JSON.stringify({access_token: token})); + }, + "/v1/destroy": (request, response) => { + // Getting the body seems harder than it should be! + let sis = Cc["@mozilla.org/scriptableinputstream;1"] + .createInstance(Ci.nsIScriptableInputStream); + sis.init(request.bodyInputStream); + let body = JSON.parse(sis.read(sis.available())); + sis.close(); + let token = body.token; + ok(server.activeTokens.delete(token)); + print("after destroy have", server.activeTokens.size, "tokens left.") + response.setStatusLine("1.1", 200, "OK"); + response.write('{}'); + }, + } + server = new Server(handlers); + server.numTokenFetches = 0; + server.activeTokens = new Set(); + return server; +} + +// The tests. +function run_test() { + run_next_test(); +} + +// Arrange for the first token we hand out to be rejected - the client should +// notice the 401 and silently get a new token and retry the request. +add_task(function testAuthRetry() { + let handlers = { + "/v1/batch": (request, response) => { + // We know the first token we will get is "token0", so we simulate that + // "expiring" by only accepting "token1". Then we just echo the response + // back. + let authHeader; + try { + authHeader = request.getHeader("Authorization"); + } catch (ex) {} + if (authHeader != "Bearer token1") { + response.setStatusLine("1.1", 401, "Unauthorized"); + response.write("wrong token"); + return; + } + response.setStatusLine("1.1", 200, "OK"); + response.write(JSON.stringify({ok: true})); + } + }; + let rlserver = new Server(handlers); + rlserver.start(); + let authServer = OAuthTokenServer(); + authServer.start(); + try { + Services.prefs.setCharPref("readinglist.server", rlserver.host + "/v1"); + Services.prefs.setCharPref("identity.fxaccounts.remote.oauth.uri", authServer.host + "/v1"); + + let fxa = yield createMockFxA(); + let sc = new ServerClient(fxa); + + let response = yield sc.request({ + path: "/batch", + method: "post", + body: {foo: "bar"}, + }); + equal(response.status, 200, "got the 200 we expected"); + equal(authServer.numTokenFetches, 2, "took 2 tokens to get the 200") + deepEqual(response.body, {ok: true}); + } finally { + yield authServer.stop(); + yield rlserver.stop(); + } +}); + +// Check that specified headers are seen by the server, and that server headers +// in the response are seen by the client. +add_task(function testHeaders() { + let handlers = { + "/v1/batch": (request, response) => { + ok(request.hasHeader("x-foo"), "got our foo header"); + equal(request.getHeader("x-foo"), "bar", "foo header has the correct value"); + response.setHeader("Server-Sent-Header", "hello"); + response.setStatusLine("1.1", 200, "OK"); + response.write("{}"); + } + }; + let rlserver = new Server(handlers); + rlserver.start(); + try { + Services.prefs.setCharPref("readinglist.server", rlserver.host + "/v1"); + + let fxa = yield createMockFxA(); + let sc = new ServerClient(fxa); + sc._getToken = () => Promise.resolve(); + + let response = yield sc.request({ + path: "/batch", + method: "post", + headers: {"X-Foo": "bar"}, + body: {foo: "bar"}}); + equal(response.status, 200, "got the 200 we expected"); + equal(response.headers["server-sent-header"], "hello", "got the server header"); + } finally { + yield rlserver.stop(); + } +}); + +// Check that unicode ends up as utf-8 in requests, and vice-versa in responses. +// (Note the ServerClient assumes all strings in and out are UCS, and thus have +// already been encoded/decoded (ie, it never expects to receive stuff already +// utf-8 encoded, and never returns utf-8 encoded responses.) +add_task(function testUTF8() { + let handlers = { + "/v1/hello": (request, response) => { + // Get the body as bytes. + let sis = Cc["@mozilla.org/scriptableinputstream;1"] + .createInstance(Ci.nsIScriptableInputStream); + sis.init(request.bodyInputStream); + let body = sis.read(sis.available()); + sis.close(); + // The client sent "{"copyright: "\xa9"} where \xa9 is the copyright symbol. + // It should have been encoded as utf-8 which is \xc2\xa9 + equal(body, '{"copyright":"\xc2\xa9"}', "server saw utf-8 encoded data"); + // and just write it back unchanged. + response.setStatusLine("1.1", 200, "OK"); + response.write(body); + } + }; + let rlserver = new Server(handlers); + rlserver.start(); + try { + Services.prefs.setCharPref("readinglist.server", rlserver.host + "/v1"); + + let fxa = yield createMockFxA(); + let sc = new ServerClient(fxa); + sc._getToken = () => Promise.resolve(); + + let body = {copyright: "\xa9"}; // see above - \xa9 is the copyright symbol + let response = yield sc.request({ + path: "/hello", + method: "post", + body: body + }); + equal(response.status, 200, "got the 200 we expected"); + deepEqual(response.body, body); + } finally { + yield rlserver.stop(); + } +}); diff --git a/browser/components/readinglist/test/xpcshell/xpcshell.ini b/browser/components/readinglist/test/xpcshell/xpcshell.ini index f89f63d8999..d10cbc176f1 100644 --- a/browser/components/readinglist/test/xpcshell/xpcshell.ini +++ b/browser/components/readinglist/test/xpcshell/xpcshell.ini @@ -3,5 +3,6 @@ head = head.js firefox-appdir = browser [test_ReadingList.js] +[test_ServerClient.js] [test_scheduler.js] [test_SQLiteStore.js] diff --git a/browser/components/sessionstore/SessionStore.jsm b/browser/components/sessionstore/SessionStore.jsm index 4cd3bcc17ec..8203d698bef 100644 --- a/browser/components/sessionstore/SessionStore.jsm +++ b/browser/components/sessionstore/SessionStore.jsm @@ -45,7 +45,7 @@ const WINDOW_HIDEABLE_FEATURES = [ ]; // Messages that will be received via the Frame Message Manager. -const FMM_MESSAGES = [ +const MESSAGES = [ // The content script gives us a reference to an object that performs // synchronous collection of session data. "SessionStore:setupSyncHandler", @@ -70,11 +70,15 @@ const FMM_MESSAGES = [ // A tab that is being restored was reloaded. We call restoreTabContent to // finish restoring it right away. "SessionStore:reloadPendingTab", + + // A crashed tab was revived by navigating to a different page. Remove its + // browser from the list of crashed browsers to stop ignoring its messages. + "SessionStore:crashedTabRevived", ]; // The list of messages we accept from s that have no tab // assigned. Those are for example the ones that preload about:newtab pages. -const FMM_NOTAB_MESSAGES = new Set([ +const NOTAB_MESSAGES = new Set([ // For a description see above. "SessionStore:setupSyncHandler", @@ -82,15 +86,13 @@ const FMM_NOTAB_MESSAGES = new Set([ "SessionStore:update", ]); -// Messages that will be received via the Parent Process Message Manager. -const PPMM_MESSAGES = [ - // A tab is being revived from the crashed state. The sender of this - // message should actually be running in the parent process, since this - // will be the crashed tab interface. We use the Child and Parent Process - // Message Managers because the message is sent during framescript unload - // when the Frame Message Manager is not available. - "SessionStore:RemoteTabRevived", -]; +// The list of messages we want to receive even during the short period after a +// frame has been removed from the DOM and before its frame script has finished +// unloading. +const CLOSED_MESSAGES = new Set([ + // For a description see above. + "SessionStore:crashedTabRevived", +]); // These are tab events that we listen to. const TAB_EVENTS = [ @@ -423,8 +425,6 @@ let SessionStoreInternal = { Services.obs.addObserver(this, aTopic, true); }, this); - PPMM_MESSAGES.forEach(msg => ppmm.addMessageListener(msg, this)); - this._initPrefs(); this._initialized = true; }, @@ -554,8 +554,6 @@ let SessionStoreInternal = { // Make sure to cancel pending saves. SessionSaver.cancel(); - - PPMM_MESSAGES.forEach(msg => ppmm.removeMessageListener(msg, this)); }, /** @@ -602,12 +600,6 @@ let SessionStoreInternal = { * and thus enables communication with OOP tabs. */ receiveMessage(aMessage) { - // We'll deal with any Parent Process Message Manager messages first... - if (aMessage.name == "SessionStore:RemoteTabRevived") { - this._crashedBrowsers.delete(aMessage.objects.browser.permanentKey); - return; - } - // If we got here, that means we're dealing with a frame message // manager message, so the target will be a . var browser = aMessage.target; @@ -616,7 +608,7 @@ let SessionStoreInternal = { // Ensure we receive only specific messages from s that // have no tab assigned, e.g. the ones that preload about:newtab pages. - if (!tab && !FMM_NOTAB_MESSAGES.has(aMessage.name)) { + if (!tab && !NOTAB_MESSAGES.has(aMessage.name)) { throw new Error(`received unexpected message '${aMessage.name}' ` + `from a browser that has no tab`); } @@ -709,6 +701,9 @@ let SessionStoreInternal = { } } break; + case "SessionStore:crashedTabRevived": + this._crashedBrowsers.delete(browser.permanentKey); + break; default: throw new Error(`received unknown message '${aMessage.name}'`); break; @@ -799,7 +794,10 @@ let SessionStoreInternal = { aWindow.__SSi = this._generateWindowID(); let mm = aWindow.getGroupMessageManager("browsers"); - FMM_MESSAGES.forEach(msg => mm.addMessageListener(msg, this)); + MESSAGES.forEach(msg => { + let listenWhenClosed = CLOSED_MESSAGES.has(msg); + mm.addMessageListener(msg, this, listenWhenClosed); + }); // Load the frame script after registering listeners. mm.loadFrameScript("chrome://browser/content/content-sessionStore.js", true); @@ -1128,7 +1126,7 @@ let SessionStoreInternal = { DyingWindowCache.set(aWindow, winData); let mm = aWindow.getGroupMessageManager("browsers"); - FMM_MESSAGES.forEach(msg => mm.removeMessageListener(msg, this)); + MESSAGES.forEach(msg => mm.removeMessageListener(msg, this)); delete aWindow.__SSi; }, diff --git a/browser/components/sessionstore/content/content-sessionStore.js b/browser/components/sessionstore/content/content-sessionStore.js index 7066c9473ad..b48547ed1b8 100644 --- a/browser/components/sessionstore/content/content-sessionStore.js +++ b/browser/components/sessionstore/content/content-sessionStore.js @@ -749,12 +749,8 @@ function handleRevivedTab() { removeEventListener("pagehide", handleRevivedTab); - // We can't send a message using the frame message manager because by - // the time we reach the unload event handler, it's "too late", and messages - // won't be sent or received. The child-process message manager works though, - // despite the fact that we're really running in the parent process. - let browser = docShell.chromeEventHandler; - cpmm.sendAsyncMessage("SessionStore:RemoteTabRevived", null, {browser: browser}); + // Notify the parent. + sendAsyncMessage("SessionStore:crashedTabRevived"); } } diff --git a/browser/devtools/framework/gDevTools.jsm b/browser/devtools/framework/gDevTools.jsm index 07d02aa1801..2cd5a269525 100644 --- a/browser/devtools/framework/gDevTools.jsm +++ b/browser/devtools/framework/gDevTools.jsm @@ -31,8 +31,8 @@ const Telemetry = devtools.require("devtools/shared/telemetry"); const TABS_OPEN_PEAK_HISTOGRAM = "DEVTOOLS_TABS_OPEN_PEAK_LINEAR"; const TABS_OPEN_AVG_HISTOGRAM = "DEVTOOLS_TABS_OPEN_AVERAGE_LINEAR"; -const TABS_PINNED_PEAK_HISTOGRAM = "DEVTOOLS_TABS_PINNED_PEAK_EXPONENTIAL"; -const TABS_PINNED_AVG_HISTOGRAM = "DEVTOOLS_TABS_PINNED_AVERAGE_EXPONENTIAL"; +const TABS_PINNED_PEAK_HISTOGRAM = "DEVTOOLS_TABS_PINNED_PEAK_LINEAR"; +const TABS_PINNED_AVG_HISTOGRAM = "DEVTOOLS_TABS_PINNED_AVERAGE_LINEAR"; const FORBIDDEN_IDS = new Set(["toolbox", ""]); const MAX_ORDINAL = 99; @@ -47,6 +47,7 @@ this.DevTools = function DevTools() { this._tools = new Map(); // Map this._themes = new Map(); // Map this._toolboxes = new Map(); // Map + this._telemetry = new Telemetry(); // destroy() is an observer's handler so we need to preserve context. this.destroy = this.destroy.bind(this); diff --git a/browser/devtools/shared/devices.js b/browser/devtools/shared/devices.js index c105ab98c32..5aa6685ccad 100644 --- a/browser/devtools/shared/devices.js +++ b/browser/devtools/shared/devices.js @@ -5,599 +5,70 @@ "use strict"; const { Ci, Cc } = require("chrome"); +const { getJSON } = require("devtools/shared/getjson"); const { Services } = require("resource://gre/modules/Services.jsm"); +const promise = require("promise"); + +const DEVICES_URL = "devtools.devices.url"; const Strings = Services.strings.createBundle("chrome://browser/locale/devtools/device.properties"); -/* `Devices` is a catalog of existing devices and their properties, intended - * for (mobile) device emulation tools and features. +/* This is a catalog of common web-enabled devices and their properties, + * intended for (mobile) device emulation. * * The properties of a device are: - * - name: Device brand and model(s). - * - width: Viewport width. - * - height: Viewport height. - * - pixelRatio: Screen pixel ratio to viewport. - * - userAgent: Device UserAgent string. - * - touch: Whether the screen is touch-enabled. + * - name: brand and model(s). + * - width: viewport width. + * - height: viewport height. + * - pixelRatio: ratio from viewport to physical screen pixels. + * - userAgent: UA string of the device's browser. + * - touch: whether it has a touch screen. + * - firefoxOS: whether Firefox OS is supported. * - * To add more devices to this catalog, either patch this file, or push new - * device descriptions from your own code (e.g. an addon) like so: + * The device types are: + * ["phones", "tablets", "laptops", "televisions", "consoles", "watches"]. + * + * You can easily add more devices to this catalog from your own code (e.g. an + * addon) like so: * * var myPhone = { name: "My Phone", ... }; - * require("devtools/shared/devices").Devices.Others.phones.push(myPhone); + * require("devtools/shared/devices").AddDevice(myPhone, "phones"); */ -let Devices = { - Types: ["phones", "tablets", "notebooks", "televisions", "watches"], +// Local devices catalog that addons can add to. +let localDevices = {}; - // Get the localized string of a device type. - GetString(deviceType) { - return Strings.GetStringFromName("device." + deviceType); - }, -}; -exports.Devices = Devices; +// Add a device to the local catalog. +function AddDevice(device, type = "phones") { + let list = localDevices[type]; + if (!list) { + list = localDevices[type] = []; + } + list.push(device); +} +exports.AddDevice = AddDevice; +// Get the complete devices catalog. +function GetDevices(bypassCache = false) { + let deferred = promise.defer(); -// The `Devices.FirefoxOS` list was put together from various sources online. -Devices.FirefoxOS = { - phones: [ - { - name: "Firefox OS Flame", - width: 320, - height: 570, - pixelRatio: 1.5, - userAgent: "Mozilla/5.0 (Mobile; rv:28.0) Gecko/28.0 Firefox/28.0", - touch: true, - }, - { - name: "Alcatel One Touch Fire, Fire C", - width: 320, - height: 480, - pixelRatio: 1, - userAgent: "Mozilla/5.0 (Mobile; ALCATELOneTouch4012X; rv:28.0) Gecko/28.0 Firefox/28.0", - touch: true, - }, - { - name: "Alcatel Fire E", - width: 320, - height: 480, - pixelRatio: 2, - userAgent: "Mozilla/5.0 (Mobile; ALCATELOneTouch4012X; rv:28.0) Gecko/28.0 Firefox/28.0", - touch: true, - }, - { - name: "Geeksphone Keon", - width: 320, - height: 480, - pixelRatio: 1, - userAgent: "Mozilla/5.0 (Mobile; rv:28.0) Gecko/28.0 Firefox/28.0", - touch: true, - }, - { - name: "Geeksphone Peak, Revolution", - width: 360, - height: 640, - pixelRatio: 1.5, - userAgent: "Mozilla/5.0 (Mobile; rv:28.0) Gecko/28.0 Firefox/28.0", - touch: true, - }, - { - name: "Intex Cloud Fx", - width: 320, - height: 480, - pixelRatio: 1, - userAgent: "Mozilla/5.0 (Mobile; rv:28.0) Gecko/28.0 Firefox/28.0", - touch: true, - }, - { - name: "LG Fireweb", - width: 320, - height: 480, - pixelRatio: 1, - userAgent: "Mozilla/5.0 (Mobile; LG-D300; rv:28.0) Gecko/28.0 Firefox/28.0", - touch: true, - }, - { - name: "Spice Fire One Mi-FX1", - width: 320, - height: 480, - pixelRatio: 1, - userAgent: "Mozilla/5.0 (Mobile; rv:28.0) Gecko/28.0 Firefox/28.0", - touch: true, - }, - { - name: "Symphony GoFox F15", - width: 320, - height: 480, - pixelRatio: 1, - userAgent: "Mozilla/5.0 (Mobile; rv:28.0) Gecko/28.0 Firefox/28.0", - touch: true, - }, - { - name: "Zen Fire 105", - width: 320, - height: 480, - pixelRatio: 1, - userAgent: "Mozilla/5.0 (Mobile; rv:28.0) Gecko/28.0 Firefox/28.0", - touch: true, - }, - { - name: "ZTE Open", - width: 320, - height: 480, - pixelRatio: 1, - userAgent: "Mozilla/5.0 (Mobile; ZTEOPEN; rv:28.0) Gecko/28.0 Firefox/28.0", - touch: true, - }, - { - name: "ZTE Open C", - width: 320, - height: 450, - pixelRatio: 1.5, - userAgent: "Mozilla/5.0 (Mobile; OPENC; rv:28.0) Gecko/28.0 Firefox/28.0", - touch: true, - }, - ], - tablets: [ - { - name: "Foxconn InFocus", - width: 1280, - height: 800, - pixelRatio: 1, - userAgent: "Mozilla/5.0 (Mobile; rv:28.0) Gecko/28.0 Firefox/28.0", - touch: true, - }, - { - name: "VIA Vixen", - width: 1024, - height: 600, - pixelRatio: 1, - userAgent: "Mozilla/5.0 (Mobile; rv:28.0) Gecko/28.0 Firefox/28.0", - touch: true, - }, - ], - notebooks: [ - ], - televisions: [ - { - name: "720p HD Television", - width: 1280, - height: 720, - pixelRatio: 1, - userAgent: "", - touch: false, - }, - { - name: "1080p Full HD Television", - width: 1920, - height: 1080, - pixelRatio: 1, - userAgent: "", - touch: false, - }, - { - name: "4K Ultra HD Television", - width: 3840, - height: 2160, - pixelRatio: 1, - userAgent: "", - touch: false, - }, - ], - watches: [ - { - name: "LG G Watch", - width: 280, - height: 280, - pixelRatio: 1, - userAgent: "", - touch: true, - }, - { - name: "LG G Watch R", - width: 320, - height: 320, - pixelRatio: 1, - userAgent: "", - touch: true, - }, - { - name: "Moto 360", - width: 320, - height: 290, - pixelRatio: 1, - userAgent: "", - touch: true, - }, - { - name: "Samsung Gear Live", - width: 320, - height: 320, - pixelRatio: 1, - userAgent: "", - touch: true, - }, - ], -}; + // Fetch common devices from Mozilla's CDN. + getJSON(DEVICES_URL, bypassCache).then(devices => { + for (let type in localDevices) { + if (!devices[type]) { + devices.TYPES.push(type); + devices[type] = []; + } + devices[type] = localDevices[type].concat(devices[type]); + } + deferred.resolve(devices); + }); -// `Devices.Others` was derived from the Chromium source code: -// - chromium/src/third_party/WebKit/Source/devtools/front_end/toolbox/OverridesUI.js -Devices.Others = { - phones: [ - { - name: "Apple iPhone 3GS", - width: 320, - height: 480, - pixelRatio: 1, - userAgent: "Mozilla/5.0 (iPhone; U; CPU iPhone OS 4_2_1 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8C148 Safari/6533.18.5", - touch: true, - }, - { - name: "Apple iPhone 4", - width: 320, - height: 480, - pixelRatio: 2, - userAgent: "Mozilla/5.0 (iPhone; U; CPU iPhone OS 4_2_1 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8C148 Safari/6533.18.5", - touch: true, - }, - { - name: "Apple iPhone 5", - width: 320, - height: 568, - pixelRatio: 2, - userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 7_0 like Mac OS X; en-us) AppleWebKit/537.51.1 (KHTML, like Gecko) Version/7.0 Mobile/11A465 Safari/9537.53", - touch: true, - }, - { - name: "Apple iPhone 6", - width: 375, - height: 667, - pixelRatio: 2, - userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 8_0 like Mac OS X) AppleWebKit/600.1.3 (KHTML, like Gecko) Version/8.0 Mobile/12A4345d Safari/600.1.4", - touch: true, - }, - { - name: "Apple iPhone 6 Plus", - width: 414, - height: 736, - pixelRatio: 3, - userAgent: "Mozilla/5.0 (iPhone; CPU iPhone OS 8_0 like Mac OS X) AppleWebKit/600.1.3 (KHTML, like Gecko) Version/8.0 Mobile/12A4345d Safari/600.1.4", - touch: true, - }, - { - name: "BlackBerry Z10", - width: 384, - height: 640, - pixelRatio: 2, - userAgent: "Mozilla/5.0 (BB10; Touch) AppleWebKit/537.10+ (KHTML, like Gecko) Version/10.0.9.2372 Mobile Safari/537.10+", - touch: true, - }, - { - name: "BlackBerry Z30", - width: 360, - height: 640, - pixelRatio: 2, - userAgent: "Mozilla/5.0 (BB10; Touch) AppleWebKit/537.10+ (KHTML, like Gecko) Version/10.0.9.2372 Mobile Safari/537.10+", - touch: true, - }, - { - name: "Google Nexus 4", - width: 384, - height: 640, - pixelRatio: 2, - userAgent: "Mozilla/5.0 (Linux; Android 4.2.1; en-us; Nexus 4 Build/JOP40D) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.166 Mobile Safari/535.19", - touch: true, - }, - { - name: "Google Nexus 5", - width: 360, - height: 640, - pixelRatio: 3, - userAgent: "Mozilla/5.0 (Linux; Android 4.2.1; en-us; Nexus 5 Build/JOP40D) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.166 Mobile Safari/535.19", - touch: true, - }, - { - name: "Google Nexus S", - width: 320, - height: 533, - pixelRatio: 1.5, - userAgent: "Mozilla/5.0 (Linux; U; Android 2.3.4; en-us; Nexus S Build/GRJ22) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1", - touch: true, - }, - { - name: "HTC Evo, Touch HD, Desire HD, Desire", - width: 320, - height: 533, - pixelRatio: 1.5, - userAgent: "Mozilla/5.0 (Linux; U; Android 2.2; en-us; Sprint APA9292KT Build/FRF91) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1", - touch: true, - }, - { - name: "HTC One X, EVO LTE", - width: 360, - height: 640, - pixelRatio: 2, - userAgent: "Mozilla/5.0 (Linux; Android 4.0.3; HTC One X Build/IML74K) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.133 Mobile Safari/535.19", - touch: true, - }, - { - name: "HTC Sensation, Evo 3D", - width: 360, - height: 640, - pixelRatio: 1.5, - userAgent: "Mozilla/5.0 (Linux; U; Android 4.0.3; en-us; HTC Sensation Build/IML74K) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30", - touch: true, - }, - { - name: "LG Optimus 2X, Optimus 3D, Optimus Black", - width: 320, - height: 533, - pixelRatio: 1.5, - userAgent: "Mozilla/5.0 (Linux; U; Android 2.2; en-us; LG-P990/V08c Build/FRG83) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1 MMS/LG-Android-MMS-V1.0/1.2", - touch: true, - }, - { - name: "LG Optimus G", - width: 384, - height: 640, - pixelRatio: 2, - userAgent: "Mozilla/5.0 (Linux; Android 4.0; LG-E975 Build/IMM76L) AppleWebKit/535.19 (KHTML, like Gecko) Chrome/18.0.1025.166 Mobile Safari/535.19", - touch: true, - }, - { - name: "LG Optimus LTE, Optimus 4X HD", - width: 424, - height: 753, - pixelRatio: 1.7, - userAgent: "Mozilla/5.0 (Linux; U; Android 2.3; en-us; LG-P930 Build/GRJ90) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1", - touch: true, - }, - { - name: "LG Optimus One", - width: 213, - height: 320, - pixelRatio: 1.5, - userAgent: "Mozilla/5.0 (Linux; U; Android 2.2.1; en-us; LG-MS690 Build/FRG83) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1", - touch: true, - }, - { - name: "Motorola Defy, Droid, Droid X, Milestone", - width: 320, - height: 569, - pixelRatio: 1.5, - userAgent: "Mozilla/5.0 (Linux; U; Android 2.0; en-us; Milestone Build/ SHOLS_U2_01.03.1) AppleWebKit/530.17 (KHTML, like Gecko) Version/4.0 Mobile Safari/530.17", - touch: true, - }, - { - name: "Motorola Droid 3, Droid 4, Droid Razr, Atrix 4G, Atrix 2", - width: 540, - height: 960, - pixelRatio: 1, - userAgent: "Mozilla/5.0 (Linux; U; Android 2.2; en-us; Droid Build/FRG22D) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1", - touch: true, - }, - { - name: "Motorola Droid Razr HD", - width: 720, - height: 1280, - pixelRatio: 1, - userAgent: "Mozilla/5.0 (Linux; U; Android 2.3; en-us; DROID RAZR 4G Build/6.5.1-73_DHD-11_M1-29) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1", - touch: true, - }, - { - name: "Nokia C5, C6, C7, N97, N8, X7", - width: 360, - height: 640, - pixelRatio: 1, - userAgent: "NokiaN97/21.1.107 (SymbianOS/9.4; Series60/5.0 Mozilla/5.0; Profile/MIDP-2.1 Configuration/CLDC-1.1) AppleWebkit/525 (KHTML, like Gecko) BrowserNG/7.1.4", - touch: true, - }, - { - name: "Nokia Lumia 7X0, Lumia 8XX, Lumia 900, N800, N810, N900", - width: 320, - height: 533, - pixelRatio: 1.5, - userAgent: "Mozilla/5.0 (compatible; MSIE 10.0; Windows Phone 8.0; Trident/6.0; IEMobile/10.0; ARM; Touch; NOKIA; Lumia 820)", - touch: true, - }, - { - name: "Samsung Galaxy Note 3", - width: 360, - height: 640, - pixelRatio: 3, - userAgent: "Mozilla/5.0 (Linux; U; Android 4.3; en-us; SM-N900T Build/JSS15J) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30", - touch: true, - }, - { - name: "Samsung Galaxy Note II", - width: 360, - height: 640, - pixelRatio: 2, - userAgent: "Mozilla/5.0 (Linux; U; Android 4.1; en-us; GT-N7100 Build/JRO03C) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30", - touch: true, - }, - { - name: "Samsung Galaxy Note", - width: 400, - height: 640, - pixelRatio: 2, - userAgent: "Mozilla/5.0 (Linux; U; Android 2.3; en-us; SAMSUNG-SGH-I717 Build/GINGERBREAD) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1", - touch: true, - }, - { - name: "Samsung Galaxy S III, Galaxy Nexus", - width: 360, - height: 640, - pixelRatio: 2, - userAgent: "Mozilla/5.0 (Linux; U; Android 4.0; en-us; GT-I9300 Build/IMM76D) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30", - touch: true, - }, - { - name: "Samsung Galaxy S, S II, W", - width: 320, - height: 533, - pixelRatio: 1.5, - userAgent: "Mozilla/5.0 (Linux; U; Android 2.1; en-us; GT-I9000 Build/ECLAIR) AppleWebKit/525.10+ (KHTML, like Gecko) Version/3.0.4 Mobile Safari/523.12.2", - touch: true, - }, - { - name: "Samsung Galaxy S4", - width: 360, - height: 640, - pixelRatio: 3, - userAgent: "Mozilla/5.0 (Linux; Android 4.2.2; GT-I9505 Build/JDQ39) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/31.0.1650.59 Mobile Safari/537.36", - touch: true, - }, - { - name: "Sony Xperia S, Ion", - width: 360, - height: 640, - pixelRatio: 2, - userAgent: "Mozilla/5.0 (Linux; U; Android 4.0; en-us; LT28at Build/6.1.C.1.111) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30", - touch: true, - }, - { - name: "Sony Xperia Sola, U", - width: 480, - height: 854, - pixelRatio: 1, - userAgent: "Mozilla/5.0 (Linux; U; Android 2.3; en-us; SonyEricssonST25i Build/6.0.B.1.564) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1", - touch: true, - }, - { - name: "Sony Xperia Z, Z1", - width: 360, - height: 640, - pixelRatio: 3, - userAgent: "Mozilla/5.0 (Linux; U; Android 4.2; en-us; SonyC6903 Build/14.1.G.1.518) AppleWebKit/534.30 (KHTML, like Gecko) Version/4.0 Mobile Safari/534.30", - touch: true, - }, - ], - tablets: [ - { - name: "Amazon Kindle Fire HDX 7″", - width: 1920, - height: 1200, - pixelRatio: 2, - userAgent: "Mozilla/5.0 (Linux; U; en-us; KFTHWI Build/JDQ39) AppleWebKit/535.19 (KHTML, like Gecko) Silk/3.13 Safari/535.19 Silk-Accelerated=true", - touch: true, - }, - { - name: "Amazon Kindle Fire HDX 8.9″", - width: 2560, - height: 1600, - pixelRatio: 2, - userAgent: "Mozilla/5.0 (Linux; U; en-us; KFAPWI Build/JDQ39) AppleWebKit/535.19 (KHTML, like Gecko) Silk/3.13 Safari/535.19 Silk-Accelerated=true", - touch: true, - }, - { - name: "Amazon Kindle Fire (First Generation)", - width: 1024, - height: 600, - pixelRatio: 1, - userAgent: "Mozilla/5.0 (Macintosh; U; Intel Mac OS X 10_6_3; en-us; Silk/1.0.141.16-Gen4_11004310) AppleWebkit/533.16 (KHTML, like Gecko) Version/5.0 Safari/533.16 Silk-Accelerated=true", - touch: true, - }, - { - name: "Apple iPad 1 / 2 / iPad Mini", - width: 1024, - height: 768, - pixelRatio: 1, - userAgent: "Mozilla/5.0 (iPad; CPU OS 4_3_5 like Mac OS X; en-us) AppleWebKit/533.17.9 (KHTML, like Gecko) Version/5.0.2 Mobile/8L1 Safari/6533.18.5", - touch: true, - }, - { - name: "Apple iPad 3 / 4", - width: 1024, - height: 768, - pixelRatio: 2, - userAgent: "Mozilla/5.0 (iPad; CPU OS 7_0 like Mac OS X) AppleWebKit/537.51.1 (KHTML, like Gecko) Version/7.0 Mobile/11A465 Safari/9537.53", - touch: true, - }, - { - name: "BlackBerry PlayBook", - width: 1024, - height: 600, - pixelRatio: 1, - userAgent: "Mozilla/5.0 (PlayBook; U; RIM Tablet OS 2.1.0; en-US) AppleWebKit/536.2+ (KHTML like Gecko) Version/7.2.1.0 Safari/536.2+", - touch: true, - }, - { - name: "Google Nexus 10", - width: 1280, - height: 800, - pixelRatio: 2, - userAgent: "Mozilla/5.0 (Linux; Android 4.3; Nexus 10 Build/JSS15Q) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/29.0.1547.72 Safari/537.36", - touch: true, - }, - { - name: "Google Nexus 7 2", - width: 960, - height: 600, - pixelRatio: 2, - userAgent: "Mozilla/5.0 (Linux; Android 4.3; Nexus 7 Build/JSS15Q) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/29.0.1547.72 Safari/537.36", - touch: true, - }, - { - name: "Google Nexus 7", - width: 966, - height: 604, - pixelRatio: 1.325, - userAgent: "Mozilla/5.0 (Linux; Android 4.3; Nexus 7 Build/JSS15Q) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/29.0.1547.72 Safari/537.36", - touch: true, - }, - { - name: "Motorola Xoom, Xyboard", - width: 1280, - height: 800, - pixelRatio: 1, - userAgent: "Mozilla/5.0 (Linux; U; Android 3.0; en-us; Xoom Build/HRI39) AppleWebKit/525.10 (KHTML, like Gecko) Version/3.0.4 Mobile Safari/523.12.2", - touch: true, - }, - { - name: "Samsung Galaxy Tab 7.7, 8.9, 10.1", - width: 1280, - height: 800, - pixelRatio: 1, - userAgent: "Mozilla/5.0 (Linux; U; Android 2.2; en-us; SCH-I800 Build/FROYO) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1", - touch: true, - }, - { - name: "Samsung Galaxy Tab", - width: 1024, - height: 600, - pixelRatio: 1, - userAgent: "Mozilla/5.0 (Linux; U; Android 2.2; en-us; SCH-I800 Build/FROYO) AppleWebKit/533.1 (KHTML, like Gecko) Version/4.0 Mobile Safari/533.1", - touch: true, - }, - ], - notebooks: [ - { - name: "Notebook with touch", - width: 1280, - height: 950, - pixelRatio: 1, - userAgent: "", - touch: true, - }, - { - name: "Notebook with HiDPI screen", - width: 1440, - height: 900, - pixelRatio: 2, - userAgent: "", - touch: false, - }, - { - name: "Generic notebook", - width: 1280, - height: 800, - pixelRatio: 1, - userAgent: "", - touch: false, - }, - ], - televisions: [ - ], - watches: [ - ], -}; + return deferred.promise; +} +exports.GetDevices = GetDevices; + +// Get the localized string for a device type. +function GetDeviceString(deviceType) { + return Strings.GetStringFromName("device." + deviceType); +} +exports.GetDeviceString = GetDeviceString; diff --git a/browser/devtools/webide/modules/remote-resources.js b/browser/devtools/shared/getjson.js similarity index 60% rename from browser/devtools/webide/modules/remote-resources.js rename to browser/devtools/shared/getjson.js index 9a64a84fc32..e942b61d7a5 100644 --- a/browser/devtools/webide/modules/remote-resources.js +++ b/browser/devtools/shared/getjson.js @@ -3,23 +3,22 @@ * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ const {Cu, CC} = require("chrome"); -const {Promise: promise} = Cu.import("resource://gre/modules/Promise.jsm", {}); +const promise = require("promise"); const {Services} = Cu.import("resource://gre/modules/Services.jsm"); const XMLHttpRequest = CC("@mozilla.org/xmlextras/xmlhttprequest;1"); -function getJSON(bypassCache, pref) { +// Downloads and caches a JSON file from a URL given by the pref. +exports.getJSON = function (prefName, bypassCache) { if (!bypassCache) { try { - let str = Services.prefs.getCharPref(pref + "_cache"); + let str = Services.prefs.getCharPref(prefName + "_cache"); let json = JSON.parse(str); return promise.resolve(json); } catch(e) {/* no pref or invalid json. Let's continue */} } - let deferred = promise.defer(); - let xhr = new XMLHttpRequest(); xhr.onload = () => { @@ -27,9 +26,9 @@ function getJSON(bypassCache, pref) { try { json = JSON.parse(xhr.responseText); } catch(e) { - return deferred.reject("Not valid JSON"); + return deferred.reject("Invalid JSON"); } - Services.prefs.setCharPref(pref + "_cache", xhr.responseText); + Services.prefs.setCharPref(prefName + "_cache", xhr.responseText); deferred.resolve(json); } @@ -37,18 +36,8 @@ function getJSON(bypassCache, pref) { deferred.reject("Network error"); } - xhr.open("get", Services.prefs.getCharPref(pref)); + xhr.open("get", Services.prefs.getCharPref(prefName)); xhr.send(); return deferred.promise; } - - - -exports.GetTemplatesJSON = function(bypassCache) { - return getJSON(bypassCache, "devtools.webide.templatesURL"); -} - -exports.GetAddonsJSON = function(bypassCache) { - return getJSON(bypassCache, "devtools.webide.addonsURL"); -} diff --git a/browser/devtools/shared/moz.build b/browser/devtools/shared/moz.build index e7f2995d1d5..31ff5d2e7e8 100644 --- a/browser/devtools/shared/moz.build +++ b/browser/devtools/shared/moz.build @@ -51,6 +51,7 @@ EXTRA_JS_MODULES.devtools.shared += [ 'devices.js', 'doorhanger.js', 'frame-script-utils.js', + 'getjson.js', 'inplace-editor.js', 'observable-object.js', 'options-view.js', @@ -61,6 +62,7 @@ EXTRA_JS_MODULES.devtools.shared += [ ] EXTRA_JS_MODULES.devtools.shared.widgets += [ + 'widgets/CubicBezierPresets.js', 'widgets/CubicBezierWidget.js', 'widgets/FastListWidget.js', 'widgets/Spectrum.js', diff --git a/browser/devtools/shared/test/browser.ini b/browser/devtools/shared/test/browser.ini index 71bfda0caa1..3ad70bf7838 100644 --- a/browser/devtools/shared/test/browser.ini +++ b/browser/devtools/shared/test/browser.ini @@ -7,6 +7,7 @@ support-files = browser_templater_basic.html browser_toolbar_basic.html browser_toolbar_webconsole_errors_count.html + browser_devices.json doc_options-view.xul head.js leakhunt.js @@ -15,6 +16,9 @@ support-files = [browser_cubic-bezier-01.js] [browser_cubic-bezier-02.js] [browser_cubic-bezier-03.js] +[browser_cubic-bezier-04.js] +[browser_cubic-bezier-05.js] +[browser_cubic-bezier-06.js] [browser_flame-graph-01.js] [browser_flame-graph-02.js] [browser_flame-graph-03a.js] @@ -98,3 +102,4 @@ skip-if = buildapp == 'mulet' || e10s # The developertoolbar error count isn't c [browser_treeWidget_basic.js] [browser_treeWidget_keyboard_interaction.js] [browser_treeWidget_mouse_interaction.js] +[browser_devices.js] diff --git a/browser/devtools/shared/test/browser_cubic-bezier-01.js b/browser/devtools/shared/test/browser_cubic-bezier-01.js index 2e288c0ea74..85cd2ba4f19 100644 --- a/browser/devtools/shared/test/browser_cubic-bezier-01.js +++ b/browser/devtools/shared/test/browser_cubic-bezier-01.js @@ -7,16 +7,20 @@ // Tests that the CubicBezierWidget generates content in a given parent node const TEST_URI = "chrome://browser/content/devtools/cubic-bezier-frame.xhtml"; -const {CubicBezierWidget} = devtools.require("devtools/shared/widgets/CubicBezierWidget"); +const {CubicBezierWidget} = + devtools.require("devtools/shared/widgets/CubicBezierWidget"); add_task(function*() { yield promiseTab("about:blank"); let [host, win, doc] = yield createHost("bottom", TEST_URI); - info("Checking that the markup is created in the parent"); + info("Checking that the graph markup is created in the parent"); let container = doc.querySelector("#container"); let w = new CubicBezierWidget(container); + ok(container.querySelector(".display-wrap"), + "The display has been added"); + ok(container.querySelector(".coordinate-plane"), "The coordinate plane has been added"); let buttons = container.querySelectorAll("button"); diff --git a/browser/devtools/shared/test/browser_cubic-bezier-02.js b/browser/devtools/shared/test/browser_cubic-bezier-02.js index 30887a74d53..254614e52ea 100644 --- a/browser/devtools/shared/test/browser_cubic-bezier-02.js +++ b/browser/devtools/shared/test/browser_cubic-bezier-02.js @@ -7,26 +7,37 @@ // Tests the CubicBezierWidget events const TEST_URI = "chrome://browser/content/devtools/cubic-bezier-frame.xhtml"; -const {CubicBezierWidget, PREDEFINED} = +const {CubicBezierWidget} = devtools.require("devtools/shared/widgets/CubicBezierWidget"); +const {PREDEFINED} = require("devtools/shared/widgets/CubicBezierPresets"); add_task(function*() { yield promiseTab("about:blank"); let [host, win, doc] = yield createHost("bottom", TEST_URI); + // Required or widget will be clipped inside of 'bottom' + // host by -14. Setting `fixed` zeroes this which is needed for + // calculating offsets. Occurs in test env only. + doc.body.setAttribute("style", "position: fixed"); + let container = doc.querySelector("#container"); let w = new CubicBezierWidget(container, PREDEFINED.linear); - yield pointsCanBeDragged(w, win, doc); - yield curveCanBeClicked(w, win, doc); - yield pointsCanBeMovedWithKeyboard(w, win, doc); + let rect = w.curve.getBoundingClientRect(); + rect.graphTop = rect.height * w.bezierCanvas.padding[0]; + rect.graphBottom = rect.height - rect.graphTop; + rect.graphHeight = rect.graphBottom - rect.graphTop; + + yield pointsCanBeDragged(w, win, doc, rect); + yield curveCanBeClicked(w, win, doc, rect); + yield pointsCanBeMovedWithKeyboard(w, win, doc, rect); w.destroy(); host.destroy(); gBrowser.removeCurrentTab(); }); -function* pointsCanBeDragged(widget, win, doc) { +function* pointsCanBeDragged(widget, win, doc, offsets) { info("Checking that the control points can be dragged with the mouse"); info("Listening for the update event"); @@ -34,7 +45,7 @@ function* pointsCanBeDragged(widget, win, doc) { info("Generating a mousedown/move/up on P1"); widget._onPointMouseDown({target: widget.p1}); - doc.onmousemove({pageX: 0, pageY: 100}); + doc.onmousemove({pageX: offsets.left, pageY: offsets.graphTop}); doc.onmouseup(); let bezier = yield onUpdated; @@ -48,7 +59,7 @@ function* pointsCanBeDragged(widget, win, doc) { info("Generating a mousedown/move/up on P2"); widget._onPointMouseDown({target: widget.p2}); - doc.onmousemove({pageX: 200, pageY: 300}); + doc.onmousemove({pageX: offsets.right, pageY: offsets.graphBottom}); doc.onmouseup(); bezier = yield onUpdated; @@ -56,14 +67,16 @@ function* pointsCanBeDragged(widget, win, doc) { is(bezier.P2[1], 0, "The new P2 progress coordinate is correct"); } -function* curveCanBeClicked(widget, win, doc) { +function* curveCanBeClicked(widget, win, doc, offsets) { info("Checking that clicking on the curve moves the closest control point"); info("Listening for the update event"); let onUpdated = widget.once("updated"); info("Click close to P1"); - widget._onCurveClick({pageX: 50, pageY: 150}); + let x = offsets.left + (offsets.width / 4.0); + let y = offsets.graphTop + (offsets.graphHeight / 4.0); + widget._onCurveClick({pageX: x, pageY: y}); let bezier = yield onUpdated; ok(true, "The widget fired the updated event"); @@ -76,7 +89,9 @@ function* curveCanBeClicked(widget, win, doc) { onUpdated = widget.once("updated"); info("Click close to P2"); - widget._onCurveClick({pageX: 150, pageY: 250}); + x = offsets.right - (offsets.width / 4); + y = offsets.graphBottom - (offsets.graphHeight / 4); + widget._onCurveClick({pageX: x, pageY: y}); bezier = yield onUpdated; is(bezier.P2[0], 0.75, "The new P2 time coordinate is correct"); @@ -85,57 +100,89 @@ function* curveCanBeClicked(widget, win, doc) { is(bezier.P1[1], 0.75, "P1 progress coordinate remained unchanged"); } -function* pointsCanBeMovedWithKeyboard(widget, win, doc) { +function* pointsCanBeMovedWithKeyboard(widget, win, doc, offsets) { info("Checking that points respond to keyboard events"); + let singleStep = 3; + let shiftStep = 30; + info("Moving P1 to the left"); + let newOffset = parseInt(widget.p1.style.left) - singleStep; + let x = widget.bezierCanvas. + offsetsToCoordinates({style: {left: newOffset}})[0]; + let onUpdated = widget.once("updated"); widget._onPointKeyDown(getKeyEvent(widget.p1, 37)); let bezier = yield onUpdated; - is(bezier.P1[0], 0.235, "The new P1 time coordinate is correct"); + + is(bezier.P1[0], x, "The new P1 time coordinate is correct"); is(bezier.P1[1], 0.75, "The new P1 progress coordinate is correct"); info("Moving P1 to the left, fast"); + newOffset = parseInt(widget.p1.style.left) - shiftStep; + x = widget.bezierCanvas. + offsetsToCoordinates({style: {left: newOffset}})[0]; + onUpdated = widget.once("updated"); widget._onPointKeyDown(getKeyEvent(widget.p1, 37, true)); bezier = yield onUpdated; - is(bezier.P1[0], 0.085, "The new P1 time coordinate is correct"); + is(bezier.P1[0], x, "The new P1 time coordinate is correct"); is(bezier.P1[1], 0.75, "The new P1 progress coordinate is correct"); info("Moving P1 to the right, fast"); + newOffset = parseInt(widget.p1.style.left) + shiftStep; + x = widget.bezierCanvas. + offsetsToCoordinates({style: {left: newOffset}})[0]; + onUpdated = widget.once("updated"); widget._onPointKeyDown(getKeyEvent(widget.p1, 39, true)); bezier = yield onUpdated; - is(bezier.P1[0], 0.235, "The new P1 time coordinate is correct"); + is(bezier.P1[0], x, "The new P1 time coordinate is correct"); is(bezier.P1[1], 0.75, "The new P1 progress coordinate is correct"); info("Moving P1 to the bottom"); + newOffset = parseInt(widget.p1.style.top) + singleStep; + let y = widget.bezierCanvas. + offsetsToCoordinates({style: {top: newOffset}})[1]; + onUpdated = widget.once("updated"); widget._onPointKeyDown(getKeyEvent(widget.p1, 40)); bezier = yield onUpdated; - is(bezier.P1[0], 0.235, "The new P1 time coordinate is correct"); - is(bezier.P1[1], 0.735, "The new P1 progress coordinate is correct"); + is(bezier.P1[0], x, "The new P1 time coordinate is correct"); + is(bezier.P1[1], y, "The new P1 progress coordinate is correct"); info("Moving P1 to the bottom, fast"); + newOffset = parseInt(widget.p1.style.top) + shiftStep; + y = widget.bezierCanvas. + offsetsToCoordinates({style: {top: newOffset}})[1]; + onUpdated = widget.once("updated"); widget._onPointKeyDown(getKeyEvent(widget.p1, 40, true)); bezier = yield onUpdated; - is(bezier.P1[0], 0.235, "The new P1 time coordinate is correct"); - is(bezier.P1[1], 0.585, "The new P1 progress coordinate is correct"); + is(bezier.P1[0], x, "The new P1 time coordinate is correct"); + is(bezier.P1[1], y, "The new P1 progress coordinate is correct"); info("Moving P1 to the top, fast"); + newOffset = parseInt(widget.p1.style.top) - shiftStep; + y = widget.bezierCanvas. + offsetsToCoordinates({style: {top: newOffset}})[1]; + onUpdated = widget.once("updated"); widget._onPointKeyDown(getKeyEvent(widget.p1, 38, true)); bezier = yield onUpdated; - is(bezier.P1[0], 0.235, "The new P1 time coordinate is correct"); - is(bezier.P1[1], 0.735, "The new P1 progress coordinate is correct"); + is(bezier.P1[0], x, "The new P1 time coordinate is correct"); + is(bezier.P1[1], y, "The new P1 progress coordinate is correct"); info("Checking that keyboard events also work with P2"); info("Moving P2 to the left"); + newOffset = parseInt(widget.p2.style.left) - singleStep; + x = widget.bezierCanvas. + offsetsToCoordinates({style: {left: newOffset}})[0]; + onUpdated = widget.once("updated"); widget._onPointKeyDown(getKeyEvent(widget.p2, 37)); bezier = yield onUpdated; - is(bezier.P2[0], 0.735, "The new P2 time coordinate is correct"); + is(bezier.P2[0], x, "The new P2 time coordinate is correct"); is(bezier.P2[1], 0.25, "The new P2 progress coordinate is correct"); } diff --git a/browser/devtools/shared/test/browser_cubic-bezier-03.js b/browser/devtools/shared/test/browser_cubic-bezier-03.js index 2ce5fe4561d..2c231d5d921 100644 --- a/browser/devtools/shared/test/browser_cubic-bezier-03.js +++ b/browser/devtools/shared/test/browser_cubic-bezier-03.js @@ -7,8 +7,9 @@ // Tests that coordinates can be changed programatically in the CubicBezierWidget const TEST_URI = "chrome://browser/content/devtools/cubic-bezier-frame.xhtml"; -const {CubicBezierWidget, PREDEFINED} = +const {CubicBezierWidget} = devtools.require("devtools/shared/widgets/CubicBezierWidget"); +const {PREDEFINED} = require("devtools/shared/widgets/CubicBezierPresets"); add_task(function*() { yield promiseTab("about:blank"); diff --git a/browser/devtools/shared/test/browser_cubic-bezier-04.js b/browser/devtools/shared/test/browser_cubic-bezier-04.js new file mode 100644 index 00000000000..d6f447f8859 --- /dev/null +++ b/browser/devtools/shared/test/browser_cubic-bezier-04.js @@ -0,0 +1,51 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the CubicBezierPresetWidget generates markup. + +const TEST_URI = "chrome://browser/content/devtools/cubic-bezier-frame.xhtml"; +const {CubicBezierPresetWidget} = + devtools.require("devtools/shared/widgets/CubicBezierWidget"); +const {PRESETS} = require("devtools/shared/widgets/CubicBezierPresets"); + +add_task(function*() { + yield promiseTab("about:blank"); + let [host, win, doc] = yield createHost("bottom", TEST_URI); + + let container = doc.querySelector("#container"); + let w = new CubicBezierPresetWidget(container); + + info("Checking that the presets are created in the parent"); + ok(container.querySelector(".preset-pane"), + "The preset pane has been added"); + + ok(container.querySelector("#preset-categories"), + "The preset categories have been added"); + let categories = container.querySelectorAll(".category"); + is(categories.length, Object.keys(PRESETS).length, + "The preset categories have been added"); + Object.keys(PRESETS).forEach(category => { + ok(container.querySelector("#" + category), `${category} has been added`); + ok(container.querySelector("#preset-category-" + category), + `The preset list for ${category} has been added.`); + }); + + info("Checking that each of the presets and its preview have been added"); + Object.keys(PRESETS).forEach(category => { + Object.keys(PRESETS[category]).forEach(presetLabel => { + let preset = container.querySelector("#" + presetLabel); + ok(preset, `${presetLabel} has been added`); + ok(preset.querySelector("canvas"), + `${presetLabel}'s canvas preview has been added`); + ok(preset.querySelector("p"), + `${presetLabel}'s label has been added`); + }); + }); + + w.destroy(); + host.destroy(); + gBrowser.removeCurrentTab(); +}); diff --git a/browser/devtools/shared/test/browser_cubic-bezier-05.js b/browser/devtools/shared/test/browser_cubic-bezier-05.js new file mode 100644 index 00000000000..5c9ad0c57bc --- /dev/null +++ b/browser/devtools/shared/test/browser_cubic-bezier-05.js @@ -0,0 +1,49 @@ +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests that the CubicBezierPresetWidget cycles menus + +const TEST_URI = "chrome://browser/content/devtools/cubic-bezier-frame.xhtml"; +const {CubicBezierPresetWidget} = + devtools.require("devtools/shared/widgets/CubicBezierWidget"); +const {PREDEFINED, PRESETS, DEFAULT_PRESET_CATEGORY} = + require("devtools/shared/widgets/CubicBezierPresets"); + +add_task(function*() { + yield promiseTab("about:blank"); + let [host, win, doc] = yield createHost("bottom", TEST_URI); + + let container = doc.querySelector("#container"); + let w = new CubicBezierPresetWidget(container); + + info("Checking that preset is selected if coordinates are known"); + + w.refreshMenu([0, 0, 0, 0]); + is(w.activeCategory, container.querySelector(`#${DEFAULT_PRESET_CATEGORY}`), + "The default category is selected"); + is(w._activePreset, null, "There is no selected category"); + + w.refreshMenu(PREDEFINED["linear"]); + is(w.activeCategory, container.querySelector("#ease-in-out"), + "The ease-in-out category is active"); + is(w._activePreset, container.querySelector("#ease-in-out-linear"), + "The ease-in-out-linear preset is active"); + + w.refreshMenu(PRESETS["ease-out"]["ease-out-sine"]); + is(w.activeCategory, container.querySelector("#ease-out"), + "The ease-out category is active"); + is(w._activePreset, container.querySelector("#ease-out-sine"), + "The ease-out-sine preset is active"); + + w.refreshMenu([0, 0, 0, 0]); + is(w.activeCategory, container.querySelector("#ease-out"), + "The ease-out category is still active"); + is(w._activePreset, null, "No preset is active"); + + w.destroy(); + host.destroy(); + gBrowser.removeCurrentTab(); +}); diff --git a/browser/devtools/shared/test/browser_cubic-bezier-06.js b/browser/devtools/shared/test/browser_cubic-bezier-06.js new file mode 100644 index 00000000000..612589ee8b9 --- /dev/null +++ b/browser/devtools/shared/test/browser_cubic-bezier-06.js @@ -0,0 +1,80 @@ + +/* vim: set ts=2 et sw=2 tw=80: */ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +// Tests the integration between CubicBezierWidget and CubicBezierPresets + +const TEST_URI = "chrome://browser/content/devtools/cubic-bezier-frame.xhtml"; +const {CubicBezierWidget} = + devtools.require("devtools/shared/widgets/CubicBezierWidget"); +const {PRESETS} = require("devtools/shared/widgets/CubicBezierPresets"); + +add_task(function*() { + yield promiseTab("about:blank"); + let [host, win, doc] = yield createHost("bottom", TEST_URI); + + let container = doc.querySelector("#container"); + let w = new CubicBezierWidget(container, + PRESETS["ease-in"]["ease-in-sine"]); + w.presets.refreshMenu(PRESETS["ease-in"]["ease-in-sine"]); + + let rect = w.curve.getBoundingClientRect(); + rect.graphTop = rect.height * w.bezierCanvas.padding[0]; + + yield adjustingBezierUpdatesPreset(w, win, doc, rect); + yield selectingPresetUpdatesBezier(w, win, doc, rect); + + w.destroy(); + host.destroy(); + gBrowser.removeCurrentTab(); +}); + +function* adjustingBezierUpdatesPreset(widget, win, doc, rect) { + info("Checking that changing the bezier refreshes the preset menu"); + + is(widget.presets.activeCategory, + doc.querySelector("#ease-in"), + "The selected category is ease-in"); + + is(widget.presets._activePreset, + doc.querySelector("#ease-in-sine"), + "The selected preset is ease-in-sine"); + + info("Generating custom bezier curve by dragging"); + widget._onPointMouseDown({target: widget.p1}); + doc.onmousemove({pageX: rect.left, pageY: rect.graphTop}); + doc.onmouseup(); + + is(widget.presets.activeCategory, + doc.querySelector("#ease-in"), + "The selected category is still ease-in"); + + is(widget.presets._activePreset, null, + "There is no active preset"); + } + +function* selectingPresetUpdatesBezier(widget, win, doc, rect) { + info("Checking that selecting a preset updates bezier curve"); + + info("Listening for the new coordinates event"); + let onNewCoordinates = widget.presets.once("new-coordinates"); + let onUpdated = widget.once("updated"); + + info("Click a preset"); + let preset = doc.querySelector("#ease-in-sine"); + widget.presets._onPresetClick({currentTarget: preset}); + + yield onNewCoordinates; + ok(true, "The preset widget fired the new-coordinates event"); + + let bezier = yield onUpdated; + ok(true, "The bezier canvas fired the updated event"); + + is(bezier.P1[0], preset.coordinates[0], "The new P1 time coordinate is correct"); + is(bezier.P1[1], preset.coordinates[1], "The new P1 progress coordinate is correct"); + is(bezier.P2[0], preset.coordinates[2], "P2 time coordinate is correct "); + is(bezier.P2[1], preset.coordinates[3], "P2 progress coordinate is correct"); +} diff --git a/browser/devtools/shared/test/browser_devices.js b/browser/devtools/shared/test/browser_devices.js new file mode 100644 index 00000000000..47657e8d8b1 --- /dev/null +++ b/browser/devtools/shared/test/browser_devices.js @@ -0,0 +1,50 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +let { GetDevices, GetDeviceString, AddDevice } = devtools.require("devtools/shared/devices"); + +add_task(function*() { + Services.prefs.setCharPref("devtools.devices.url", TEST_URI_ROOT + "browser_devices.json"); + + let devices = yield GetDevices(); + + is(devices.TYPES.length, 1, "Found 1 device type."); + + let type1 = devices.TYPES[0]; + + is(devices[type1].length, 2, "Found 2 devices of type #1."); + + let string = GetDeviceString(type1); + ok(typeof string === "string" && string.length > 0, "Able to localize type #1."); + + let device1 = { + name: "SquarePhone", + width: 320, + height: 320, + pixelRatio: 2, + userAgent: "Mozilla/5.0 (Mobile; rv:42.0)", + touch: true, + firefoxOS: true + }; + AddDevice(device1, type1); + devices = yield GetDevices(); + + is(devices[type1].length, 3, "Added new device of type #1."); + ok(devices[type1].filter(d => d.name === device1.name), "Found the new device."); + + let type2 = "appliances"; + let device2 = { + name: "Mr Freezer", + width: 800, + height: 600, + pixelRatio: 5, + userAgent: "Mozilla/5.0 (Appliance; rv:42.0)", + touch: true, + firefoxOS: true + }; + AddDevice(device2, type2); + devices = yield GetDevices(); + + is(devices.TYPES.length, 2, "Added device type #2."); + is(devices[type2].length, 1, "Added new device of type #2."); +}); diff --git a/browser/devtools/shared/test/browser_devices.json b/browser/devtools/shared/test/browser_devices.json new file mode 100644 index 00000000000..cc7722a7fd7 --- /dev/null +++ b/browser/devtools/shared/test/browser_devices.json @@ -0,0 +1,23 @@ +{ + "TYPES": [ "phones" ], + "phones": [ + { + "name": "Small Phone", + "width": 320, + "height": 480, + "pixelRatio": 1, + "userAgent": "Mozilla/5.0 (Mobile; rv:39.0) Gecko/39.0 Firefox/39.0", + "touch": true, + "firefoxOS": true + }, + { + "name": "Big Phone", + "width": 360, + "height": 640, + "pixelRatio": 3, + "userAgent": "Mozilla/5.0 (Mobile; rv:39.0) Gecko/39.0 Firefox/39.0", + "touch": true, + "firefoxOS": true + } + ] +} diff --git a/browser/devtools/shared/test/browser_graphs-07a.js b/browser/devtools/shared/test/browser_graphs-07a.js index c55d75f5387..2b641190aad 100644 --- a/browser/devtools/shared/test/browser_graphs-07a.js +++ b/browser/devtools/shared/test/browser_graphs-07a.js @@ -17,14 +17,18 @@ function* performTest() { let [host, win, doc] = yield createHost(); let graph = new LineGraphWidget(doc.body, "fps"); yield graph.once("ready"); - - testGraph(graph); - + testGraph(graph, normalDragStop); yield graph.destroy(); + + let graph2 = new LineGraphWidget(doc.body, "fps"); + yield graph2.once("ready"); + testGraph(graph2, buggyDragStop); + yield graph2.destroy(); + host.destroy(); } -function testGraph(graph) { +function testGraph(graph, dragStop) { graph.setData(TEST_DATA); info("Making a selection."); @@ -186,13 +190,24 @@ function dragStart(graph, x, y = 1) { graph._onMouseDown({ clientX: x, clientY: y }); } -function dragStop(graph, x, y = 1) { +function normalDragStop(graph, x, y = 1) { x /= window.devicePixelRatio; y /= window.devicePixelRatio; graph._onMouseMove({ clientX: x, clientY: y }); graph._onMouseUp({ clientX: x, clientY: y }); } +function buggyDragStop(graph, x, y = 1) { + x /= window.devicePixelRatio; + y /= window.devicePixelRatio; + + // Only fire a mousemove instead of a mouseup. + // This happens when the mouseup happens outside of the toolbox, + // see Bug 1066504. + graph._onMouseMove({ clientX: x, clientY: y }); + graph._onMouseMove({ clientX: x, clientY: y, buttons: 0 }); +} + function scroll(graph, wheel, x, y = 1) { x /= window.devicePixelRatio; y /= window.devicePixelRatio; diff --git a/browser/devtools/shared/test/unit/test_bezierCanvas.js b/browser/devtools/shared/test/unit/test_bezierCanvas.js index 21ffad1f07d..55d2e8dbcdc 100644 --- a/browser/devtools/shared/test/unit/test_bezierCanvas.js +++ b/browser/devtools/shared/test/unit/test_bezierCanvas.js @@ -104,7 +104,10 @@ function getCanvasMock(w=200, h=400) { stroke: () => {}, arc: () => {}, fill: () => {}, - bezierCurveTo: () => {} + bezierCurveTo: () => {}, + save: () => {}, + restore: () => {}, + setTransform: () => {} }; }, width: w, diff --git a/browser/devtools/shared/test/unit/test_cubicBezier.js b/browser/devtools/shared/test/unit/test_cubicBezier.js index b8a6231b2ab..ee6c0e07bfd 100644 --- a/browser/devtools/shared/test/unit/test_cubicBezier.js +++ b/browser/devtools/shared/test/unit/test_cubicBezier.js @@ -19,6 +19,7 @@ function run_test() { coordinatesToStringOutputsAString(); pointGettersReturnPointCoordinatesArrays(); toStringOutputsCubicBezierValue(); + toStringOutputsCssPresetValues(); } function throwsWhenMissingCoordinates() { @@ -84,8 +85,27 @@ function pointGettersReturnPointCoordinatesArrays() { function toStringOutputsCubicBezierValue() { do_print("toString() outputs the cubic-bezier() value"); + let c = new CubicBezier([0, 1, 1, 0]); + do_check_eq(c.toString(), "cubic-bezier(0,1,1,0)"); +} + +function toStringOutputsCssPresetValues() { + do_print("toString() outputs the css predefined values"); + let c = new CubicBezier([0, 0, 1, 1]); - do_check_eq(c.toString(), "cubic-bezier(0,0,1,1)"); + do_check_eq(c.toString(), "linear"); + + c = new CubicBezier([0.25, 0.1, 0.25, 1]); + do_check_eq(c.toString(), "ease"); + + c = new CubicBezier([0.42, 0, 1, 1]); + do_check_eq(c.toString(), "ease-in"); + + c = new CubicBezier([0, 0, 0.58, 1]); + do_check_eq(c.toString(), "ease-out"); + + c = new CubicBezier([0.42, 0, 0.58, 1]); + do_check_eq(c.toString(), "ease-in-out"); } function do_check_throws(cb, info) { diff --git a/browser/devtools/shared/widgets/CubicBezierPresets.js b/browser/devtools/shared/widgets/CubicBezierPresets.js new file mode 100644 index 00000000000..d2a77a85c46 --- /dev/null +++ b/browser/devtools/shared/widgets/CubicBezierPresets.js @@ -0,0 +1,64 @@ +/** + * 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/. + */ + +// Set of preset definitions for use with CubicBezierWidget +// Credit: http://easings.net + +"use strict"; + +const PREDEFINED = { + "ease": [0.25, 0.1, 0.25, 1], + "linear": [0, 0, 1, 1], + "ease-in": [0.42, 0, 1, 1], + "ease-out": [0, 0, 0.58, 1], + "ease-in-out": [0.42, 0, 0.58, 1] +}; + +const PRESETS = { + "ease-in": { + "ease-in-linear": [0, 0, 1, 1], + "ease-in-ease-in": [0.42, 0, 1, 1], + "ease-in-sine": [0.47, 0, 0.74, 0.71], + "ease-in-quadratic": [0.55, 0.09, 0.68, 0.53], + "ease-in-cubic": [0.55, 0.06, 0.68, 0.19], + "ease-in-quartic": [0.9, 0.03, 0.69, 0.22], + "ease-in-quintic": [0.76, 0.05, 0.86, 0.06], + "ease-in-exponential": [0.95, 0.05, 0.8, 0.04], + "ease-in-circular": [0.6, 0.04, 0.98, 0.34], + "ease-in-backward": [0.6, -0.28, 0.74, 0.05] + }, + "ease-out": { + "ease-out-linear": [0, 0, 1, 1], + "ease-out-ease-out": [0, 0, 0.58, 1], + "ease-out-sine": [0.39, 0.58, 0.57, 1], + "ease-out-quadratic": [0.25, 0.46, 0.45, 0.94], + "ease-out-cubic": [0.22, 0.61, 0.36, 1], + "ease-out-quartic": [0.17, 0.84, 0.44, 1], + "ease-out-quintic": [0.23, 1, 0.32, 1], + "ease-out-exponential": [0.19, 1, 0.22, 1], + "ease-out-circular": [0.08, 0.82, 0.17, 1], + "ease-out-backward": [0.18, 0.89, 0.32, 1.28] + }, + "ease-in-out": { + "ease-in-out-linear": [0, 0, 1, 1], + "ease-in-out-ease": [0.25, 0.1, 0.25, 1], + "ease-in-out-ease-in-out": [0.42, 0, 0.58, 1], + "ease-in-out-sine": [0.45, 0.05, 0.55, 0.95], + "ease-in-out-quadratic": [0.46, 0.03, 0.52, 0.96], + "ease-in-out-cubic": [0.65, 0.05, 0.36, 1], + "ease-in-out-quartic": [0.77, 0, 0.18, 1], + "ease-in-out-quintic": [0.86, 0, 0.07, 1], + "ease-in-out-exponential": [1, 0, 0, 1], + "ease-in-out-circular": [0.79, 0.14, 0.15, 0.86], + "ease-in-out-backward": [0.68, -0.55, 0.27, 1.55] + } +}; + +const DEFAULT_PRESET_CATEGORY = Object.keys(PRESETS)[0]; + +exports.PRESETS = PRESETS; +exports.PREDEFINED = PREDEFINED; +exports.DEFAULT_PRESET_CATEGORY = DEFAULT_PRESET_CATEGORY; diff --git a/browser/devtools/shared/widgets/CubicBezierWidget.js b/browser/devtools/shared/widgets/CubicBezierWidget.js index 9177253b80b..a4e3fa3ad27 100644 --- a/browser/devtools/shared/widgets/CubicBezierWidget.js +++ b/browser/devtools/shared/widgets/CubicBezierWidget.js @@ -27,14 +27,7 @@ const EventEmitter = require("devtools/toolkit/event-emitter"); const {setTimeout, clearTimeout} = require("sdk/timers"); - -const PREDEFINED = exports.PREDEFINED = { - "ease": [.25, .1, .25, 1], - "linear": [0, 0, 1, 1], - "ease-in": [.42, 0, 1, 1], - "ease-out": [0, 0, .58, 1], - "ease-in-out": [.42, 0, .58, 1] -}; +const {PREDEFINED, PRESETS, DEFAULT_PRESET_CATEGORY} = require("devtools/shared/widgets/CubicBezierPresets"); /** * CubicBezier data structure helper @@ -59,7 +52,7 @@ function CubicBezier(coordinates) { return this.map(n => { return (Math.round(n * 100)/100 + '').replace(/^0\./, '.'); }) + ""; - } + }; } exports.CubicBezier = CubicBezier; @@ -74,7 +67,11 @@ CubicBezier.prototype = { }, toString: function() { - return 'cubic-bezier(' + this.coordinates + ')'; + // Check first if current coords are one of css predefined functions + let predefName = Object.keys(PREDEFINED) + .find(key => coordsAreEqual(PREDEFINED[key], this.coordinates)); + + return predefName || 'cubic-bezier(' + this.coordinates + ')'; } }; @@ -97,7 +94,7 @@ function BezierCanvas(canvas, bezier, padding) { -canvas.height * (1 - p[0] - p[2])); this.ctx.translate(p[3] / (1 - p[1] - p[3]), -1 - p[0] / (1 - p[0] - p[2])); -}; +} exports.BezierCanvas = BezierCanvas; @@ -115,7 +112,7 @@ BezierCanvas.prototype = { }, { left: w * (this.bezier.coordinates[2] * (1 - p[3] - p[1]) - p[3]) + 'px', top: h * (1 - this.bezier.coordinates[3] * (1 - p[0] - p[2]) - p[0]) + 'px' - }] + }]; }, /** @@ -128,8 +125,8 @@ BezierCanvas.prototype = { p = p.map(function(a, i) { return a * (i % 2? w : h)}); return [ - (parseInt(element.style.left) - p[3]) / (w + p[1] + p[3]), - (h - parseInt(element.style.top) - p[2]) / (h - p[0] - p[2]) + (parseFloat(element.style.left) - p[3]) / (w + p[1] + p[3]), + (h - parseFloat(element.style.top) - p[2]) / (h - p[0] - p[2]) ]; }, @@ -143,40 +140,48 @@ BezierCanvas.prototype = { handleColor: '#666', handleThickness: .008, bezierColor: '#4C9ED9', - bezierThickness: .015 + bezierThickness: .015, + drawHandles: true }; for (let setting in settings) { defaultSettings[setting] = settings[setting]; } - this.ctx.clearRect(-.5,-.5, 2, 2); + // Clear the canvas –making sure to clear the + // whole area by resetting the transform first. + this.ctx.save(); + this.ctx.setTransform(1, 0, 0, 1, 0, 0); + this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + this.ctx.restore(); - // Draw control handles - this.ctx.beginPath(); - this.ctx.fillStyle = defaultSettings.handleColor; - this.ctx.lineWidth = defaultSettings.handleThickness; - this.ctx.strokeStyle = defaultSettings.handleColor; + if (defaultSettings.drawHandles) { + // Draw control handles + this.ctx.beginPath(); + this.ctx.fillStyle = defaultSettings.handleColor; + this.ctx.lineWidth = defaultSettings.handleThickness; + this.ctx.strokeStyle = defaultSettings.handleColor; - this.ctx.moveTo(0, 0); - this.ctx.lineTo(xy[0], xy[1]); - this.ctx.moveTo(1,1); - this.ctx.lineTo(xy[2], xy[3]); + this.ctx.moveTo(0, 0); + this.ctx.lineTo(xy[0], xy[1]); + this.ctx.moveTo(1,1); + this.ctx.lineTo(xy[2], xy[3]); - this.ctx.stroke(); - this.ctx.closePath(); + this.ctx.stroke(); + this.ctx.closePath(); - function circle(ctx, cx, cy, r) { - return ctx.beginPath(); - ctx.arc(cx, cy, r, 0, 2*Math.PI, !1); - ctx.closePath(); + var circle = function(ctx, cx, cy, r) { + return ctx.beginPath(); + ctx.arc(cx, cy, r, 0, 2*Math.PI, !1); + ctx.closePath(); + }; + + circle(this.ctx, xy[0], xy[1], 1.5 * defaultSettings.handleThickness); + this.ctx.fill(); + circle(this.ctx, xy[2], xy[3], 1.5 * defaultSettings.handleThickness); + this.ctx.fill(); } - circle(this.ctx, xy[0], xy[1], 1.5 * defaultSettings.handleThickness); - this.ctx.fill(); - circle(this.ctx, xy[2], xy[3], 1.5 * defaultSettings.handleThickness); - this.ctx.fill(); - // Draw bezier curve this.ctx.beginPath(); this.ctx.lineWidth = defaultSettings.bezierThickness; @@ -197,18 +202,20 @@ BezierCanvas.prototype = { * Emits "updated" events whenever the curve is changed. Along with the event is * sent a CubicBezier object */ -function CubicBezierWidget(parent, coordinates=PREDEFINED["ease-in-out"]) { +function CubicBezierWidget(parent, coordinates=PRESETS["ease-in"]["ease-in-sine"]) { + EventEmitter.decorate(this); + this.parent = parent; let {curve, p1, p2} = this._initMarkup(); - this.curve = curve; this.curveBoundingBox = curve.getBoundingClientRect(); + this.curve = curve; this.p1 = p1; this.p2 = p2; // Create and plot the bezier curve this.bezierCanvas = new BezierCanvas(this.curve, - new CubicBezier(coordinates), [.25, 0]); + new CubicBezier(coordinates), [0.30, 0]); this.bezierCanvas.plot(); // Place the control points @@ -221,12 +228,15 @@ function CubicBezierWidget(parent, coordinates=PREDEFINED["ease-in-out"]) { this._onPointMouseDown = this._onPointMouseDown.bind(this); this._onPointKeyDown = this._onPointKeyDown.bind(this); this._onCurveClick = this._onCurveClick.bind(this); - this._initEvents(); + this._onNewCoordinates = this._onNewCoordinates.bind(this); + + // Add preset preview menu + this.presets = new CubicBezierPresetWidget(parent); // Add the timing function previewer this.timingPreview = new TimingFunctionPreviewWidget(parent); - EventEmitter.decorate(this); + this._initEvents(); } exports.CubicBezierWidget = CubicBezierWidget; @@ -235,6 +245,9 @@ CubicBezierWidget.prototype = { _initMarkup: function() { let doc = this.parent.ownerDocument; + let wrap = doc.createElement("div"); + wrap.className = "display-wrap"; + let plane = doc.createElement("div"); plane.className = "coordinate-plane"; @@ -249,22 +262,24 @@ CubicBezierWidget.prototype = { plane.appendChild(p2); let curve = doc.createElement("canvas"); - curve.setAttribute("height", "400"); - curve.setAttribute("width", "200"); + curve.setAttribute("width", 150); + curve.setAttribute("height", 370); curve.id = "curve"; - plane.appendChild(curve); - this.parent.appendChild(plane); + plane.appendChild(curve); + wrap.appendChild(plane); + + this.parent.appendChild(wrap); return { p1: p1, p2: p2, curve: curve - } + }; }, _removeMarkup: function() { - this.parent.ownerDocument.querySelector(".coordinate-plane").remove(); + this.parent.ownerDocument.querySelector(".display-wrap").remove(); }, _initEvents: function() { @@ -275,6 +290,8 @@ CubicBezierWidget.prototype = { this.p2.addEventListener("keydown", this._onPointKeyDown); this.curve.addEventListener("click", this._onCurveClick); + + this.presets.on("new-coordinates", this._onNewCoordinates); }, _removeEvents: function() { @@ -285,6 +302,8 @@ CubicBezierWidget.prototype = { this.p2.removeEventListener("keydown", this._onPointKeyDown); this.curve.removeEventListener("click", this._onCurveClick); + + this.presets.off("new-coordinates", this._onNewCoordinates); }, _onPointMouseDown: function(event) { @@ -317,7 +336,7 @@ CubicBezierWidget.prototype = { doc.onmouseup = function () { point.focus(); doc.onmousemove = doc.onmouseup = null; - } + }; }, _onPointKeyDown: function(event) { @@ -344,6 +363,8 @@ CubicBezierWidget.prototype = { }, _onCurveClick: function(event) { + this.curveBoundingBox = this.curve.getBoundingClientRect(); + let left = this.curveBoundingBox.left; let top = this.curveBoundingBox.top; let x = event.pageX - left; @@ -362,14 +383,19 @@ CubicBezierWidget.prototype = { this._updateFromPoints(); }, + _onNewCoordinates: function(event, coordinates) { + this.coordinates = coordinates; + }, + /** * Get the current point coordinates and redraw the curve to match */ _updateFromPoints: function() { // Get the new coordinates from the point's offsets - let coordinates = this.bezierCanvas.offsetsToCoordinates(this.p1) + let coordinates = this.bezierCanvas.offsetsToCoordinates(this.p1); coordinates = coordinates.concat(this.bezierCanvas.offsetsToCoordinates(this.p2)); + this.presets.refreshMenu(coordinates); this._redraw(coordinates); }, @@ -391,7 +417,7 @@ CubicBezierWidget.prototype = { * @param {Array} coordinates */ set coordinates(coordinates) { - this._redraw(coordinates) + this._redraw(coordinates); // Move the points let offsets = this.bezierCanvas.offsets; @@ -420,6 +446,7 @@ CubicBezierWidget.prototype = { coordinates = value.replace(/cubic-bezier|\(|\)/g, "").split(",").map(parseFloat); } + this.presets.refreshMenu(coordinates); this.coordinates = coordinates; }, @@ -428,11 +455,262 @@ CubicBezierWidget.prototype = { this._removeMarkup(); this.timingPreview.destroy(); + this.presets.destroy(); this.curve = this.p1 = this.p2 = null; } }; +/** + * CubicBezierPreset widget. + * Builds a menu of presets from CubicBezierPresets + * @param {DOMNode} parent The container where the preset panel should be created + * + * Emits "new-coordinate" event along with the coordinates + * whenever a preset is selected. + */ +function CubicBezierPresetWidget(parent) { + this.parent = parent; + + let {presetPane, presets, categories} = this._initMarkup(); + this.presetPane = presetPane; + this.presets = presets; + this.categories = categories; + + this._activeCategory = null; + this._activePresetList = null; + this._activePreset = null; + + this._onCategoryClick = this._onCategoryClick.bind(this); + this._onPresetClick = this._onPresetClick.bind(this); + + EventEmitter.decorate(this); + this._initEvents(); +} + +exports.CubicBezierPresetWidget = CubicBezierPresetWidget; + +CubicBezierPresetWidget.prototype = { + /* + * Constructs a list of all preset categories and a list + * of presets for each category. + * + * High level markup: + * div .preset-pane + * div .preset-categories + * div .category + * div .category + * ... + * div .preset-container + * div .presetList + * div .preset + * ... + * div .presetList + * div .preset + * ... + */ + _initMarkup: function() { + let doc = this.parent.ownerDocument; + + let presetPane = doc.createElement("div"); + presetPane.className = "preset-pane"; + + let categoryList = doc.createElement("div"); + categoryList.id = "preset-categories"; + + let presetContainer = doc.createElement("div"); + presetContainer.id = "preset-container"; + + Object.keys(PRESETS).forEach(categoryLabel => { + let category = this._createCategory(categoryLabel); + categoryList.appendChild(category); + + let presetList = this._createPresetList(categoryLabel); + presetContainer.appendChild(presetList); + }); + + presetPane.appendChild(categoryList); + presetPane.appendChild(presetContainer); + + this.parent.appendChild(presetPane); + + let allCategories = presetPane.querySelectorAll(".category"); + let allPresets = presetPane.querySelectorAll(".preset"); + + return { + presetPane: presetPane, + presets: allPresets, + categories: allCategories + }; + }, + + _createCategory: function(categoryLabel) { + let doc = this.parent.ownerDocument; + + let category = doc.createElement("div"); + category.id = categoryLabel; + category.classList.add("category"); + + let categoryDisplayLabel = this._normalizeCategoryLabel(categoryLabel); + category.textContent = categoryDisplayLabel; + + return category; + }, + + _normalizeCategoryLabel: function(categoryLabel) { + return categoryLabel.replace("/-/g", " "); + }, + + _createPresetList: function(categoryLabel) { + let doc = this.parent.ownerDocument; + + let presetList = doc.createElement("div"); + presetList.id = "preset-category-" + categoryLabel; + presetList.classList.add("preset-list"); + + Object.keys(PRESETS[categoryLabel]).forEach(presetLabel => { + let preset = this._createPreset(categoryLabel, presetLabel); + presetList.appendChild(preset); + }); + + return presetList; + }, + + _createPreset: function(categoryLabel, presetLabel) { + let doc = this.parent.ownerDocument; + + let preset = doc.createElement("div"); + preset.classList.add("preset"); + preset.id = presetLabel; + preset.coordinates = PRESETS[categoryLabel][presetLabel]; + + // Create preset preview + let curve = doc.createElement("canvas"); + let bezier = new CubicBezier(preset.coordinates); + + curve.setAttribute("height", 55); + curve.setAttribute("width", 55); + + preset.bezierCanvas = new BezierCanvas(curve, bezier, [0.15, 0]); + preset.bezierCanvas.plot({ + drawHandles: false, + bezierThickness: 0.025 + }); + + preset.appendChild(curve); + + // Create preset label + let presetLabelElem = doc.createElement("p"); + let presetDisplayLabel = this._normalizePresetLabel(categoryLabel, presetLabel); + presetLabelElem.textContent = presetDisplayLabel; + preset.appendChild(presetLabelElem); + + return preset; + }, + + _normalizePresetLabel: function(categoryLabel, presetLabel) { + return presetLabel.replace(categoryLabel + "-", "").replace("/-/g", " "); + }, + + _initEvents: function() { + for (let category of this.categories) { + category.addEventListener("click", this._onCategoryClick); + } + + for (let preset of this.presets) { + preset.addEventListener("click", this._onPresetClick); + } + }, + + _removeEvents: function() { + for (let category of this.categories) { + category.removeEventListener("click", this._onCategoryClick); + } + + for (let preset of this.presets) { + preset.removeEventListener("click", this._onPresetClick); + } + }, + + _onPresetClick: function(event) { + this.emit("new-coordinates", event.currentTarget.coordinates); + this.activePreset = event.currentTarget; + }, + + _onCategoryClick: function(event) { + this.activeCategory = event.target; + }, + + _setActivePresetList: function(presetListId) { + let presetList = this.presetPane.querySelector("#" + presetListId); + swapClassName("active-preset-list", this._activePresetList, presetList); + this._activePresetList = presetList; + }, + + set activeCategory(category) { + swapClassName("active-category", this._activeCategory, category); + this._activeCategory = category; + this._setActivePresetList("preset-category-" + category.id); + }, + + get activeCategory() { + return this._activeCategory; + }, + + set activePreset(preset) { + swapClassName("active-preset", this._activePreset, preset); + this._activePreset = preset; + }, + + get activePreset() { + return this._activePreset; + }, + + /** + * Called by CubicBezierWidget onload and when + * the curve is modified via the canvas. + * Attempts to match the new user setting with an + * existing preset. + * @param {Array} coordinates new coords [i, j, k, l] + */ + refreshMenu: function(coordinates) { + // If we cannot find a matching preset, keep + // menu on last known preset category. + let category = this._activeCategory; + + // If we cannot find a matching preset + // deselect any selected preset. + let preset = null; + + // If a category has never been viewed before + // show the default category. + if (!category) { + category = this.parent.querySelector("#" + DEFAULT_PRESET_CATEGORY); + } + + // If the new coordinates do match a preset, + // set its category and preset button as active. + Object.keys(PRESETS).forEach(categoryLabel => { + + Object.keys(PRESETS[categoryLabel]).forEach(presetLabel => { + if (coordsAreEqual(PRESETS[categoryLabel][presetLabel], coordinates)) { + category = this.parent.querySelector("#" + categoryLabel); + preset = this.parent.querySelector("#" + presetLabel); + } + }); + + }); + + this.activeCategory = category; + this.activePreset = preset; + }, + + destroy: function() { + this._removeEvents(); + this.parent.querySelector(".preset-pane").remove(); + } +}; + /** * The TimingFunctionPreviewWidget animates a dot on a scale with a given * timing-function @@ -554,3 +832,29 @@ function isValidTimingFunction(value) { return false; } + +/** + * Removes a class from a node and adds it to another. + * @param {String} className the class to swap + * @param {DOMNode} from the node to remove the class from + * @param {DOMNode} to the node to add the class to + */ +function swapClassName(className, from, to) { + if (from !== null) { + from.classList.remove(className); + } + + if (to !== null) { + to.classList.add(className); + } +} + +/** + * Compares two arrays of coordinates [i, j, k, l] + * @param {Array} c1 first coordinate array to compare + * @param {Array} c2 second coordinate array to compare + * @return {Boolean} + */ +function coordsAreEqual(c1, c2) { + return c1.reduce((prev, curr, index) => prev && (curr === c2[index]), true); +} diff --git a/browser/devtools/shared/widgets/Graphs.jsm b/browser/devtools/shared/widgets/Graphs.jsm index b7f644bc8fb..657158cbd40 100644 --- a/browser/devtools/shared/widgets/Graphs.jsm +++ b/browser/devtools/shared/widgets/Graphs.jsm @@ -181,6 +181,7 @@ this.AbstractCanvasGraph = function(parent, name, sharpness) { this._selection = new GraphArea(); this._selectionDragger = new GraphAreaDragger(); this._selectionResizer = new GraphAreaResizer(); + this._isMouseActive = false; this._onAnimationFrame = this._onAnimationFrame.bind(this); this._onMouseMove = this._onMouseMove.bind(this); @@ -952,13 +953,23 @@ AbstractCanvasGraph.prototype = { * Listener for the "mousemove" event on the graph's container. */ _onMouseMove: function(e) { + let resizer = this._selectionResizer; + let dragger = this._selectionDragger; + + // If a mouseup happened outside the toolbox and the current operation + // is causing the selection changed, then end it. + if (e.buttons == 0 && (this.hasSelectionInProgress() || + resizer.margin != null || + dragger.origin != null)) { + return this._onMouseUp(e); + } + let offset = this._getContainerOffset(); let mouseX = (e.clientX - offset.left) * this._pixelRatio; let mouseY = (e.clientY - offset.top) * this._pixelRatio; this._cursor.x = mouseX; this._cursor.y = mouseY; - let resizer = this._selectionResizer; if (resizer.margin != null) { this._selection[resizer.margin] = mouseX; this._shouldRedraw = true; @@ -966,7 +977,6 @@ AbstractCanvasGraph.prototype = { return; } - let dragger = this._selectionDragger; if (dragger.origin != null) { this._selection.start = dragger.anchor.start - dragger.origin + mouseX; this._selection.end = dragger.anchor.end - dragger.origin + mouseX; @@ -1013,6 +1023,7 @@ AbstractCanvasGraph.prototype = { * Listener for the "mousedown" event on the graph's container. */ _onMouseDown: function(e) { + this._isMouseActive = true; let offset = this._getContainerOffset(); let mouseX = (e.clientX - offset.left) * this._pixelRatio; @@ -1051,6 +1062,7 @@ AbstractCanvasGraph.prototype = { * Listener for the "mouseup" event on the graph's container. */ _onMouseUp: function(e) { + this._isMouseActive = false; let offset = this._getContainerOffset(); let mouseX = (e.clientX - offset.left) * this._pixelRatio; @@ -1161,21 +1173,17 @@ AbstractCanvasGraph.prototype = { this.emit("scroll"); }, - /** + /** * Listener for the "mouseout" event on the graph's container. + * Clear any active cursors if a drag isn't happening. */ - _onMouseOut: function() { - if (this.hasSelectionInProgress()) { - this.dropSelection(); + _onMouseOut: function(e) { + if (!this._isMouseActive) { + this._cursor.x = null; + this._cursor.y = null; + this._canvas.removeAttribute("input"); + this._shouldRedraw = true; } - - this._cursor.x = null; - this._cursor.y = null; - this._selectionResizer.margin = null; - this._selectionDragger.origin = null; - - this._canvas.removeAttribute("input"); - this._shouldRedraw = true; }, /** diff --git a/browser/devtools/shared/widgets/Tooltip.js b/browser/devtools/shared/widgets/Tooltip.js index 7b29cb998d5..5607196fe04 100644 --- a/browser/devtools/shared/widgets/Tooltip.js +++ b/browser/devtools/shared/widgets/Tooltip.js @@ -795,8 +795,8 @@ Tooltip.prototype = { // Create an iframe to host the cubic-bezier widget let iframe = this.doc.createElementNS(XHTML_NS, "iframe"); iframe.setAttribute("transparent", true); - iframe.setAttribute("width", "200"); - iframe.setAttribute("height", "415"); + iframe.setAttribute("width", "410"); + iframe.setAttribute("height", "360"); iframe.setAttribute("flex", "1"); iframe.setAttribute("class", "devtools-tooltip-iframe"); diff --git a/browser/devtools/shared/widgets/cubic-bezier-frame.xhtml b/browser/devtools/shared/widgets/cubic-bezier-frame.xhtml index f3c7a65b06b..8e2ac45fec8 100644 --- a/browser/devtools/shared/widgets/cubic-bezier-frame.xhtml +++ b/browser/devtools/shared/widgets/cubic-bezier-frame.xhtml @@ -8,14 +8,15 @@ - +