diff options
Diffstat (limited to 'toolkit/actors')
48 files changed, 8843 insertions, 0 deletions
diff --git a/toolkit/actors/AboutHttpsOnlyErrorChild.jsm b/toolkit/actors/AboutHttpsOnlyErrorChild.jsm new file mode 100644 index 0000000000..d0b621ef5d --- /dev/null +++ b/toolkit/actors/AboutHttpsOnlyErrorChild.jsm @@ -0,0 +1,13 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["AboutHttpsOnlyErrorChild"]; + +const { RemotePageChild } = ChromeUtils.import( + "resource://gre/actors/RemotePageChild.jsm" +); + +class AboutHttpsOnlyErrorChild extends RemotePageChild {} diff --git a/toolkit/actors/AboutHttpsOnlyErrorParent.jsm b/toolkit/actors/AboutHttpsOnlyErrorParent.jsm new file mode 100644 index 0000000000..b949cb065a --- /dev/null +++ b/toolkit/actors/AboutHttpsOnlyErrorParent.jsm @@ -0,0 +1,127 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["AboutHttpsOnlyErrorParent"]; + +const { HomePage } = ChromeUtils.import("resource:///modules/HomePage.jsm"); +const { PrivateBrowsingUtils } = ChromeUtils.import( + "resource://gre/modules/PrivateBrowsingUtils.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { SessionStore } = ChromeUtils.import( + "resource:///modules/sessionstore/SessionStore.jsm" +); + +class AboutHttpsOnlyErrorParent extends JSWindowActorParent { + get browser() { + return this.browsingContext.top.embedderElement; + } + + receiveMessage(aMessage) { + switch (aMessage.name) { + case "goBack": + this.goBackFromErrorPage(this.browser.ownerGlobal); + break; + case "openInsecure": + this.openWebsiteInsecure(this.browser, aMessage.data.inFrame); + break; + } + } + + goBackFromErrorPage(aWindow) { + if (!aWindow.gBrowser) { + return; + } + + let state = JSON.parse( + SessionStore.getTabState(aWindow.gBrowser.selectedTab) + ); + if (state.index == 1) { + // If the unsafe page is the first or the only one in history, go to the + // start page. + aWindow.gBrowser.loadURI(this.getDefaultHomePage(aWindow), { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }); + } else { + aWindow.gBrowser.goBack(); + } + } + + openWebsiteInsecure(aBrowser, aIsIFrame) { + // No matter if the the error-page shows up within an iFrame or not, we always + // create an exception for the top-level page. + const currentURI = aBrowser.currentURI; + const isViewSource = currentURI.schemeIs("view-source"); + + let innerURI = isViewSource + ? currentURI.QueryInterface(Ci.nsINestedURI).innerURI + : currentURI; + + if (!innerURI.schemeIs("https") && !innerURI.schemeIs("http")) { + // This should never happen + throw new Error( + "Exceptions can only be created for http or https sites." + ); + } + + // If the error page is within an iFrame, we create an exception for whatever + // scheme the top-level site is currently on, because the user wants to + // unbreak the iFrame and not the top-level page. When the error page shows up + // on a top-level request, then we replace the scheme with http, because the + // user wants to unbreak the whole page. + let newURI = aIsIFrame + ? innerURI + : innerURI + .mutate() + .setScheme("http") + .finalize(); + + const oldOriginAttributes = aBrowser.contentPrincipal.originAttributes; + const hasFpiAttribute = !!oldOriginAttributes.firstPartyDomain.length; + + // Create new content principal for the permission. If first-party isolation + // is enabled, we have to replace the about-page first-party domain with the + // one from the exempt website. + let principal = Services.scriptSecurityManager.createContentPrincipal( + newURI, + { + ...oldOriginAttributes, + firstPartyDomain: hasFpiAttribute + ? Services.eTLD.getBaseDomain(newURI) + : "", + } + ); + + // Create exception for this website that expires with the session. + Services.perms.addFromPrincipal( + principal, + "https-only-load-insecure", + Ci.nsIHttpsOnlyModePermission.LOAD_INSECURE_ALLOW_SESSION, + Ci.nsIPermissionManager.EXPIRE_SESSION + ); + + const insecureSpec = isViewSource + ? `view-source:${newURI.spec}` + : newURI.spec; + aBrowser.loadURI(insecureSpec, { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_REPLACE_HISTORY, + }); + } + + getDefaultHomePage(win) { + let url = win.BROWSER_NEW_TAB_URL; + if (PrivateBrowsingUtils.isWindowPrivate(win)) { + return url; + } + url = HomePage.getDefault(); + // If url is a pipe-delimited set of pages, just take the first one. + if (url.includes("|")) { + url = url.split("|")[0]; + } + return url; + } +} diff --git a/toolkit/actors/AudioPlaybackChild.jsm b/toolkit/actors/AudioPlaybackChild.jsm new file mode 100644 index 0000000000..7c1e624743 --- /dev/null +++ b/toolkit/actors/AudioPlaybackChild.jsm @@ -0,0 +1,23 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["AudioPlaybackChild"]; + +class AudioPlaybackChild extends JSWindowActorChild { + observe(subject, topic, data) { + if (topic === "audio-playback") { + let name = "AudioPlayback:"; + if (data === "activeMediaBlockStart") { + name += "ActiveMediaBlockStart"; + } else if (data === "activeMediaBlockStop") { + name += "ActiveMediaBlockStop"; + } else { + name += data === "active" ? "Start" : "Stop"; + } + this.sendAsyncMessage(name); + } + } +} diff --git a/toolkit/actors/AudioPlaybackParent.jsm b/toolkit/actors/AudioPlaybackParent.jsm new file mode 100644 index 0000000000..5c54058528 --- /dev/null +++ b/toolkit/actors/AudioPlaybackParent.jsm @@ -0,0 +1,45 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["AudioPlaybackParent"]; + +class AudioPlaybackParent extends JSWindowActorParent { + constructor() { + super(); + this._hasAudioPlayback = false; + this._hasBlockMedia = false; + } + receiveMessage(aMessage) { + const browser = this.browsingContext.top.embedderElement; + switch (aMessage.name) { + case "AudioPlayback:Start": + this._hasAudioPlayback = true; + browser.audioPlaybackStarted(); + break; + case "AudioPlayback:Stop": + this._hasAudioPlayback = false; + browser.audioPlaybackStopped(); + break; + case "AudioPlayback:ActiveMediaBlockStart": + this._hasBlockMedia = true; + browser.activeMediaBlockStarted(); + break; + case "AudioPlayback:ActiveMediaBlockStop": + this._hasBlockMedia = false; + browser.activeMediaBlockStopped(); + break; + } + } + didDestroy() { + const browser = this.browsingContext.top.embedderElement; + if (browser && this._hasAudioPlayback) { + browser.audioPlaybackStopped(); + } + if (browser && this._hasBlockMedia) { + browser.activeMediaBlockStopped(); + } + } +} diff --git a/toolkit/actors/AutoCompleteChild.jsm b/toolkit/actors/AutoCompleteChild.jsm new file mode 100644 index 0000000000..b860d10c21 --- /dev/null +++ b/toolkit/actors/AutoCompleteChild.jsm @@ -0,0 +1,211 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* 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/. */ + +var EXPORTED_SYMBOLS = ["AutoCompleteChild"]; + +/* eslint no-unused-vars: ["error", {args: "none"}] */ + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +ChromeUtils.defineModuleGetter( + this, + "BrowserUtils", + "resource://gre/modules/BrowserUtils.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "ContentDOMReference", + "resource://gre/modules/ContentDOMReference.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "LoginHelper", + "resource://gre/modules/LoginHelper.jsm" +); + +let autoCompleteListeners = new Set(); + +class AutoCompleteChild extends JSWindowActorChild { + constructor() { + super(); + + this._input = null; + this._popupOpen = false; + } + + static addPopupStateListener(listener) { + autoCompleteListeners.add(listener); + } + + static removePopupStateListener(listener) { + autoCompleteListeners.delete(listener); + } + + receiveMessage(message) { + switch (message.name) { + case "FormAutoComplete:HandleEnter": { + this.selectedIndex = message.data.selectedIndex; + + let controller = Cc[ + "@mozilla.org/autocomplete/controller;1" + ].getService(Ci.nsIAutoCompleteController); + controller.handleEnter(message.data.isPopupSelection); + break; + } + + case "FormAutoComplete:PopupClosed": { + this._popupOpen = false; + this.notifyListeners(message.name, message.data); + break; + } + + case "FormAutoComplete:PopupOpened": { + this._popupOpen = true; + this.notifyListeners(message.name, message.data); + break; + } + + case "FormAutoComplete:Focus": { + // XXX See bug 1582722 + // Before bug 1573836, the messages here didn't match + // ("FormAutoComplete:Focus" versus "FormAutoComplete:RequestFocus") + // so this was never called. However this._input is actually a + // nsIAutoCompleteInput, which doesn't have a focus() method, so it + // wouldn't have worked anyway. So for now, I have just disabled this. + /* + if (this._input) { + this._input.focus(); + } + */ + break; + } + } + } + + notifyListeners(messageName, data) { + for (let listener of autoCompleteListeners) { + try { + listener.popupStateChanged(messageName, data, this.contentWindow); + } catch (ex) { + Cu.reportError(ex); + } + } + } + + get input() { + return this._input; + } + + set selectedIndex(index) { + this.sendAsyncMessage("FormAutoComplete:SetSelectedIndex", { index }); + } + + get selectedIndex() { + // selectedIndex getter must be synchronous because we need the + // correct value when the controller is in controller::HandleEnter. + // We can't easily just let the parent inform us the new value every + // time it changes because not every action that can change the + // selectedIndex is trivial to catch (e.g. moving the mouse over the + // list). + let selectedIndexResult = Services.cpmm.sendSyncMessage( + "FormAutoComplete:GetSelectedIndex", + { + browsingContext: this.browsingContext, + } + ); + + if ( + selectedIndexResult.length != 1 || + !Number.isInteger(selectedIndexResult[0]) + ) { + throw new Error("Invalid autocomplete selectedIndex"); + } + return selectedIndexResult[0]; + } + + get popupOpen() { + return this._popupOpen; + } + + openAutocompletePopup(input, element) { + if (this._popupOpen || !input) { + return; + } + + let rect = BrowserUtils.getElementBoundingScreenRect(element); + let window = element.ownerGlobal; + let dir = window.getComputedStyle(element).direction; + let results = this.getResultsFromController(input); + let formOrigin = LoginHelper.getLoginOrigin( + element.ownerDocument.documentURI + ); + let inputElementIdentifier = ContentDOMReference.get(element); + + this.sendAsyncMessage("FormAutoComplete:MaybeOpenPopup", { + results, + rect, + dir, + inputElementIdentifier, + formOrigin, + }); + + this._input = input; + } + + closePopup() { + // We set this here instead of just waiting for the + // PopupClosed message to do it so that we don't end + // up in a state where the content thinks that a popup + // is open when it isn't (or soon won't be). + this._popupOpen = false; + this.sendAsyncMessage("FormAutoComplete:ClosePopup", {}); + } + + invalidate() { + if (this._popupOpen) { + let results = this.getResultsFromController(this._input); + this.sendAsyncMessage("FormAutoComplete:Invalidate", { results }); + } + } + + selectBy(reverse, page) { + Services.cpmm.sendSyncMessage("FormAutoComplete:SelectBy", { + browsingContext: this.browsingContext, + reverse, + page, + }); + } + + getResultsFromController(inputField) { + let results = []; + + if (!inputField) { + return results; + } + + let controller = inputField.controller; + if (!(controller instanceof Ci.nsIAutoCompleteController)) { + return results; + } + + for (let i = 0; i < controller.matchCount; ++i) { + let result = {}; + result.value = controller.getValueAt(i); + result.label = controller.getLabelAt(i); + result.comment = controller.getCommentAt(i); + result.style = controller.getStyleAt(i); + result.image = controller.getImageAt(i); + results.push(result); + } + + return results; + } +} + +AutoCompleteChild.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIAutoCompletePopup", +]); diff --git a/toolkit/actors/AutoCompleteParent.jsm b/toolkit/actors/AutoCompleteParent.jsm new file mode 100644 index 0000000000..8204034568 --- /dev/null +++ b/toolkit/actors/AutoCompleteParent.jsm @@ -0,0 +1,536 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["AutoCompleteParent"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +XPCOMUtils.defineLazyModuleGetters(this, { + GeckoViewAutocomplete: "resource://gre/modules/GeckoViewAutocomplete.jsm", +}); + +XPCOMUtils.defineLazyPreferenceGetter( + this, + "DELEGATE_AUTOCOMPLETE", + "toolkit.autocomplete.delegate", + false +); + +ChromeUtils.defineModuleGetter( + this, + "setTimeout", + "resource://gre/modules/Timer.jsm" +); + +const PREF_SECURITY_DELAY = "security.notification_enable_delay"; + +// Stores the browser and actor that has the active popup, used by formfill +let currentBrowserWeakRef = null; +let currentActor = null; + +let autoCompleteListeners = new Set(); + +function compareContext(message) { + if ( + !currentActor || + (currentActor.browsingContext != message.data.browsingContext && + currentActor.browsingContext.top != message.data.browsingContext) + ) { + return false; + } + + return true; +} + +// These are two synchronous messages sent by the child. +// The browsingContext within the message data is either the one that has +// the active autocomplete popup or the top-level of the one that has +// the active autocomplete popup. +Services.ppmm.addMessageListener( + "FormAutoComplete:GetSelectedIndex", + message => { + if (compareContext(message)) { + let actor = currentActor; + if (actor && actor.openedPopup) { + return actor.openedPopup.selectedIndex; + } + } + + return -1; + } +); + +Services.ppmm.addMessageListener("FormAutoComplete:SelectBy", message => { + if (compareContext(message)) { + let actor = currentActor; + if (actor && actor.openedPopup) { + actor.openedPopup.selectBy(message.data.reverse, message.data.page); + } + } +}); + +// AutoCompleteResultView is an abstraction around a list of results. +// It implements enough of nsIAutoCompleteController and +// nsIAutoCompleteInput to make the richlistbox popup work. Since only +// one autocomplete popup should be open at a time, this is a singleton. +var AutoCompleteResultView = { + // nsISupports + QueryInterface: ChromeUtils.generateQI([ + "nsIAutoCompleteController", + "nsIAutoCompleteInput", + ]), + + // Private variables + results: [], + + // The AutoCompleteParent currently showing results or null otherwise. + currentActor: null, + + // nsIAutoCompleteController + get matchCount() { + return this.results.length; + }, + + getValueAt(index) { + return this.results[index].value; + }, + + getFinalCompleteValueAt(index) { + return this.results[index].value; + }, + + getLabelAt(index) { + // Backwardly-used by richlist autocomplete - see getCommentAt. + // The label is used for secondary information. + return this.results[index].comment; + }, + + getCommentAt(index) { + // The richlist autocomplete popup uses comment for its main + // display of an item, which is why we're returning the label + // here instead. + return this.results[index].label; + }, + + getStyleAt(index) { + return this.results[index].style; + }, + + getImageAt(index) { + return this.results[index].image; + }, + + handleEnter(aIsPopupSelection) { + if (this.currentActor) { + this.currentActor.handleEnter(aIsPopupSelection); + } + }, + + stopSearch() {}, + + searchString: "", + + // nsIAutoCompleteInput + get controller() { + return this; + }, + + get popup() { + return null; + }, + + _focus() { + if (this.currentActor) { + this.currentActor.requestFocus(); + } + }, + + // Internal JS-only API + clearResults() { + this.currentActor = null; + this.results = []; + }, + + setResults(actor, results) { + this.currentActor = actor; + this.results = results; + }, +}; + +class AutoCompleteParent extends JSWindowActorParent { + didDestroy() { + if (this.openedPopup) { + this.openedPopup.closePopup(); + } + } + + static getCurrentActor() { + return currentActor; + } + + static getCurrentBrowser() { + return currentBrowserWeakRef ? currentBrowserWeakRef.get() : null; + } + + static addPopupStateListener(listener) { + autoCompleteListeners.add(listener); + } + + static removePopupStateListener(listener) { + autoCompleteListeners.delete(listener); + } + + handleEvent(evt) { + switch (evt.type) { + case "popupshowing": { + this.sendAsyncMessage("FormAutoComplete:PopupOpened", {}); + break; + } + + case "popuphidden": { + let selectedIndex = this.openedPopup.selectedIndex; + let selectedRowComment = + selectedIndex != -1 + ? AutoCompleteResultView.getCommentAt(selectedIndex) + : ""; + let selectedRowStyle = + selectedIndex != -1 + ? AutoCompleteResultView.getStyleAt(selectedIndex) + : ""; + this.sendAsyncMessage("FormAutoComplete:PopupClosed", { + selectedRowComment, + selectedRowStyle, + }); + AutoCompleteResultView.clearResults(); + // adjustHeight clears the height from the popup so that + // we don't have a big shrink effect if we closed with a + // large list, and then open on a small one. + this.openedPopup.adjustHeight(); + this.openedPopup = null; + currentBrowserWeakRef = null; + currentActor = null; + evt.target.removeEventListener("popuphidden", this); + evt.target.removeEventListener("popupshowing", this); + break; + } + } + } + + showPopupWithResults({ rect, dir, results }) { + if (!results.length || this.openedPopup) { + // We shouldn't ever be showing an empty popup, and if we + // already have a popup open, the old one needs to close before + // we consider opening a new one. + return; + } + + let browser = this.browsingContext.top.embedderElement; + let window = browser.ownerGlobal; + // Also check window top in case this is a sidebar. + if ( + Services.focus.activeWindow !== window.top && + Services.focus.focusedWindow.top !== window.top + ) { + // We were sent a message from a window or tab that went into the + // background, so we'll ignore it for now. + return; + } + + // Non-empty result styles + let resultStyles = new Set(results.map(r => r.style).filter(r => !!r)); + currentBrowserWeakRef = Cu.getWeakReference(browser); + currentActor = this; + this.openedPopup = browser.autoCompletePopup; + // the layout varies according to different result type + this.openedPopup.setAttribute("resultstyles", [...resultStyles].join(" ")); + this.openedPopup.hidden = false; + // don't allow the popup to become overly narrow + this.openedPopup.setAttribute("width", Math.max(100, rect.width)); + this.openedPopup.style.direction = dir; + + AutoCompleteResultView.setResults(this, results); + this.openedPopup.view = AutoCompleteResultView; + this.openedPopup.selectedIndex = -1; + + // Reset fields that were set from the last time the search popup was open + this.openedPopup.mInput = AutoCompleteResultView; + // Temporarily increase the maxRows as we don't want to show + // the scrollbar in login or form autofill popups. + if ( + resultStyles.size && + (resultStyles.has("autofill-profile") || resultStyles.has("loginsFooter")) + ) { + this.openedPopup._normalMaxRows = this.openedPopup.maxRows; + this.openedPopup.mInput.maxRows = 10; + } + this.openedPopup.addEventListener("popuphidden", this); + this.openedPopup.addEventListener("popupshowing", this); + this.openedPopup.openPopupAtScreenRect( + "after_start", + rect.left, + rect.top, + rect.width, + rect.height, + false, + false + ); + this.openedPopup.invalidate(); + this._maybeRecordTelemetryEvents(results); + + // This is a temporary solution. We should replace it with + // proper meta information about the popup once such field + // becomes available. + let isCreditCard = results.some(result => + result?.comment?.includes("cc-number") + ); + + if (isCreditCard) { + this.delayPopupInput(); + } + } + + /** + * @param {object[]} results - Non-empty array of autocomplete results. + */ + _maybeRecordTelemetryEvents(results) { + let actor = this.browsingContext.currentWindowGlobal.getActor( + "LoginManager" + ); + actor.maybeRecordPasswordGenerationShownTelemetryEvent(results); + + // Assume the result with the start time (loginsFooter) is last. + let lastResult = results[results.length - 1]; + if (lastResult.style != "loginsFooter") { + return; + } + + // The comment field of `loginsFooter` results have many additional pieces of + // information for telemetry purposes. After bug 1555209, this information + // can be passed to the parent process outside of nsIAutoCompleteResult APIs + // so we won't need this hack. + let rawExtraData = JSON.parse(lastResult.comment); + if (!rawExtraData.searchStartTimeMS) { + throw new Error("Invalid autocomplete search start time"); + } + + if (rawExtraData.stringLength > 1) { + // To reduce event volume, only record for lengths 0 and 1. + return; + } + + let duration = + Services.telemetry.msSystemNow() - rawExtraData.searchStartTimeMS; + delete rawExtraData.searchStartTimeMS; + + delete rawExtraData.formHostname; + + // Add counts by result style to rawExtraData. + results.reduce((accumulated, r) => { + // Ignore learn more as it is only added after importable logins. + if (r.style === "importableLearnMore") { + return accumulated; + } + + // Keys can be a maximum of 15 characters and values must be strings. + // Also treat both "loginWithOrigin" and "login" as "login" as extra_keys + // is limited to 10. + let truncatedStyle = r.style.substring( + 0, + r.style === "loginWithOrigin" ? 5 : 15 + ); + accumulated[truncatedStyle] = (accumulated[truncatedStyle] || 0) + 1; + return accumulated; + }, rawExtraData); + + // Convert extra values to strings since recordEvent requires that. + let extraStrings = Object.fromEntries( + Object.entries(rawExtraData).map(([key, val]) => { + let stringVal = ""; + if (typeof val == "boolean") { + stringVal += val ? "1" : "0"; + } else { + stringVal += val; + } + return [key, stringVal]; + }) + ); + + Services.telemetry.recordEvent( + "form_autocomplete", + "show", + "logins", + // Convert to a string + duration + "", + extraStrings + ); + } + + invalidate(results) { + if (!this.openedPopup) { + return; + } + + if (!results.length) { + this.closePopup(); + } else { + AutoCompleteResultView.setResults(this, results); + this.openedPopup.invalidate(); + this._maybeRecordTelemetryEvents(results); + } + } + + closePopup() { + if (this.openedPopup) { + // Note that hidePopup() closes the popup immediately, + // so popuphiding or popuphidden events will be fired + // and handled during this call. + this.openedPopup.hidePopup(); + } + } + + receiveMessage(message) { + let browser = this.browsingContext.top.embedderElement; + + if (!browser || (!DELEGATE_AUTOCOMPLETE && !browser.autoCompletePopup)) { + // If there is no browser or popup, just make sure that the popup has been closed. + if (this.openedPopup) { + this.openedPopup.closePopup(); + } + + // Returning false to pacify ESLint, but this return value is + // ignored by the messaging infrastructure. + return false; + } + + switch (message.name) { + case "FormAutoComplete:SetSelectedIndex": { + let { index } = message.data; + if (this.openedPopup) { + this.openedPopup.selectedIndex = index; + } + break; + } + + case "FormAutoComplete:MaybeOpenPopup": { + let { + results, + rect, + dir, + inputElementIdentifier, + formOrigin, + } = message.data; + if (DELEGATE_AUTOCOMPLETE) { + GeckoViewAutocomplete.delegateSelection({ + browsingContext: this.browsingContext, + options: results, + inputElementIdentifier, + formOrigin, + }); + } else { + this.showPopupWithResults({ results, rect, dir }); + this.notifyListeners(); + } + break; + } + + case "FormAutoComplete:Invalidate": { + let { results } = message.data; + this.invalidate(results); + break; + } + + case "FormAutoComplete:ClosePopup": { + this.closePopup(); + break; + } + + case "FormAutoComplete:Disconnect": { + // The controller stopped controlling the current input, so clear + // any cached data. This is necessary cause otherwise we'd clear data + // only when starting a new search, but the next input could not support + // autocomplete and it would end up inheriting the existing data. + AutoCompleteResultView.clearResults(); + break; + } + } + // Returning false to pacify ESLint, but this return value is + // ignored by the messaging infrastructure. + return false; + } + + // Imposes a brief period during which the popup will not respond to + // a click, so as to reduce the chances of a successful clickjacking + // attempt + delayPopupInput() { + if (!this.openedPopup) { + return; + } + const popupDelay = Services.prefs.getIntPref(PREF_SECURITY_DELAY); + + // Mochitests set this to 0, and many will fail on integration + // if we make the popup items inactive, even briefly. + if (!popupDelay) { + return; + } + + const items = Array.from( + this.openedPopup.getElementsByTagName("richlistitem") + ); + items.forEach(item => (item.disabled = true)); + + setTimeout( + () => items.forEach(item => (item.disabled = false)), + popupDelay + ); + } + + notifyListeners() { + let window = this.browsingContext.top.embedderElement.ownerGlobal; + for (let listener of autoCompleteListeners) { + try { + listener(window); + } catch (ex) { + Cu.reportError(ex); + } + } + } + + /** + * Despite its name, this handleEnter is only called when the user clicks on + * one of the items in the popup since the popup is rendered in the parent process. + * The real controller's handleEnter is called directly in the content process + * for other methods of completing a selection (e.g. using the tab or enter + * keys) since the field with focus is in that process. + * @param {boolean} aIsPopupSelection + */ + handleEnter(aIsPopupSelection) { + if (this.openedPopup) { + this.sendAsyncMessage("FormAutoComplete:HandleEnter", { + selectedIndex: this.openedPopup.selectedIndex, + isPopupSelection: aIsPopupSelection, + }); + } + } + + stopSearch() {} + + /** + * Sends a message to the browser that is requesting the input + * that the open popup should be focused. + */ + requestFocus() { + // Bug 1582722 - See the response in AutoCompleteChild.jsm for why this disabled. + /* + if (this.openedPopup) { + this.sendAsyncMessage("FormAutoComplete:Focus"); + } + */ + } +} diff --git a/toolkit/actors/AutoScrollChild.jsm b/toolkit/actors/AutoScrollChild.jsm new file mode 100644 index 0000000000..4ca86090ee --- /dev/null +++ b/toolkit/actors/AutoScrollChild.jsm @@ -0,0 +1,392 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* 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/. */ + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +var EXPORTED_SYMBOLS = ["AutoScrollChild"]; + +class AutoScrollChild extends JSWindowActorChild { + constructor() { + super(); + + this._scrollable = null; + this._scrolldir = ""; + this._startX = null; + this._startY = null; + this._screenX = null; + this._screenY = null; + this._lastFrame = null; + this._autoscrollHandledByApz = false; + this._scrollId = null; + + this.observer = new AutoScrollObserver(this); + this.autoscrollLoop = this.autoscrollLoop.bind(this); + } + + isAutoscrollBlocker(node) { + let mmPaste = Services.prefs.getBoolPref("middlemouse.paste"); + let mmScrollbarPosition = Services.prefs.getBoolPref( + "middlemouse.scrollbarPosition" + ); + let content = node.ownerGlobal; + + while (node) { + if ( + (node instanceof content.HTMLAnchorElement || + node instanceof content.HTMLAreaElement) && + node.hasAttribute("href") + ) { + return true; + } + + if ( + mmPaste && + (node instanceof content.HTMLInputElement || + node instanceof content.HTMLTextAreaElement) + ) { + return true; + } + + if ( + node instanceof content.XULElement && + ((mmScrollbarPosition && + (node.localName == "scrollbar" || + node.localName == "scrollcorner")) || + node.localName == "treechildren") + ) { + return true; + } + + node = node.parentNode; + } + return false; + } + + isScrollableElement(aNode) { + let content = aNode.ownerGlobal; + if (aNode instanceof content.HTMLElement) { + return !(aNode instanceof content.HTMLSelectElement) || aNode.multiple; + } + + return aNode instanceof content.XULElement; + } + + computeWindowScrollDirection(global) { + if (!global.scrollbars.visible) { + return null; + } + if (global.scrollMaxX != global.scrollMinX) { + return global.scrollMaxY != global.scrollMinY ? "NSEW" : "EW"; + } + if (global.scrollMaxY != global.scrollMinY) { + return "NS"; + } + return null; + } + + computeNodeScrollDirection(node) { + if (!this.isScrollableElement(node)) { + return null; + } + + let global = node.ownerGlobal; + + // this is a list of overflow property values that allow scrolling + const scrollingAllowed = ["scroll", "auto"]; + + let cs = global.getComputedStyle(node); + let overflowx = cs.getPropertyValue("overflow-x"); + let overflowy = cs.getPropertyValue("overflow-y"); + // we already discarded non-multiline selects so allow vertical + // scroll for multiline ones directly without checking for a + // overflow property + let scrollVert = + node.scrollTopMax && + (node instanceof global.HTMLSelectElement || + scrollingAllowed.includes(overflowy)); + + // do not allow horizontal scrolling for select elements, it leads + // to visual artifacts and is not the expected behavior anyway + if ( + !(node instanceof global.HTMLSelectElement) && + node.scrollLeftMin != node.scrollLeftMax && + scrollingAllowed.includes(overflowx) + ) { + return scrollVert ? "NSEW" : "EW"; + } + + if (scrollVert) { + return "NS"; + } + + return null; + } + + findNearestScrollableElement(aNode) { + // go upward in the DOM and find any parent element that has a overflow + // area and can therefore be scrolled + this._scrollable = null; + for (let node = aNode; node; node = node.flattenedTreeParentNode) { + // do not use overflow based autoscroll for <html> and <body> + // Elements or non-html/non-xul elements such as svg or Document nodes + // also make sure to skip select elements that are not multiline + let direction = this.computeNodeScrollDirection(node); + if (direction) { + this._scrolldir = direction; + this._scrollable = node; + break; + } + } + + if (!this._scrollable) { + let direction = this.computeWindowScrollDirection(aNode.ownerGlobal); + if (direction) { + this._scrolldir = direction; + this._scrollable = aNode.ownerGlobal; + } else if (aNode.ownerGlobal.frameElement) { + // Note, in case of out of process iframes frameElement is null, and + // a caller is supposed to communicate to iframe's parent on its own to + // support cross process scrolling. + this.findNearestScrollableElement(aNode.ownerGlobal.frameElement); + } + } + } + + async startScroll(event) { + this.findNearestScrollableElement(event.originalTarget); + if (!this._scrollable) { + this.sendAsyncMessage("Autoscroll:MaybeStartInParent", { + browsingContextId: this.browsingContext.id, + screenX: event.screenX, + screenY: event.screenY, + }); + return; + } + + let content = event.originalTarget.ownerGlobal; + + // In some configurations like Print Preview, content.performance + // (which we use below) is null. Autoscrolling is broken in Print + // Preview anyways (see bug 1393494), so just don't start it at all. + if (!content.performance) { + return; + } + + let domUtils = content.windowUtils; + let scrollable = this._scrollable; + if (scrollable instanceof Ci.nsIDOMWindow) { + // getViewId() needs an element to operate on. + scrollable = scrollable.document.documentElement; + } + this._scrollId = null; + try { + this._scrollId = domUtils.getViewId(scrollable); + } catch (e) { + // No view ID - leave this._scrollId as null. Receiving side will check. + } + let presShellId = domUtils.getPresShellId(); + let { autoscrollEnabled, usingApz } = await this.sendQuery( + "Autoscroll:Start", + { + scrolldir: this._scrolldir, + screenX: event.screenX, + screenY: event.screenY, + scrollId: this._scrollId, + presShellId, + browsingContext: this.browsingContext, + } + ); + if (!autoscrollEnabled) { + this._scrollable = null; + return; + } + + Services.els.addSystemEventListener(this.document, "mousemove", this, true); + this.document.addEventListener("pagehide", this, true); + + this._ignoreMouseEvents = true; + this._startX = event.screenX; + this._startY = event.screenY; + this._screenX = event.screenX; + this._screenY = event.screenY; + this._scrollErrorX = 0; + this._scrollErrorY = 0; + this._autoscrollHandledByApz = usingApz; + + if (!usingApz) { + // If the browser didn't hand the autoscroll off to APZ, + // scroll here in the main thread. + this.startMainThreadScroll(); + } else { + // Even if the browser did hand the autoscroll to APZ, + // APZ might reject it in which case it will notify us + // and we need to take over. + Services.obs.addObserver(this.observer, "autoscroll-rejected-by-apz"); + } + + if (Cu.isInAutomation) { + Services.obs.notifyObservers(content, "autoscroll-start"); + } + } + + startMainThreadScroll() { + let content = this.document.defaultView; + this._lastFrame = content.performance.now(); + content.requestAnimationFrame(this.autoscrollLoop); + + const kAutoscroll = 15; // defined in mozilla/layers/ScrollInputMethods.h + Services.telemetry + .getHistogramById("SCROLL_INPUT_METHODS") + .add(kAutoscroll); + } + + stopScroll() { + if (this._scrollable) { + this._scrollable.mozScrollSnap(); + this._scrollable = null; + + Services.els.removeSystemEventListener( + this.document, + "mousemove", + this, + true + ); + this.document.removeEventListener("pagehide", this, true); + if (this._autoscrollHandledByApz) { + Services.obs.removeObserver( + this.observer, + "autoscroll-rejected-by-apz" + ); + } + } + } + + accelerate(curr, start) { + const speed = 12; + var val = (curr - start) / speed; + + if (val > 1) { + return val * Math.sqrt(val) - 1; + } + if (val < -1) { + return val * Math.sqrt(-val) + 1; + } + return 0; + } + + roundToZero(num) { + if (num > 0) { + return Math.floor(num); + } + return Math.ceil(num); + } + + autoscrollLoop(timestamp) { + if (!this._scrollable) { + // Scrolling has been canceled + return; + } + + // avoid long jumps when the browser hangs for more than + // |maxTimeDelta| ms + const maxTimeDelta = 100; + var timeDelta = Math.min(maxTimeDelta, timestamp - this._lastFrame); + // we used to scroll |accelerate()| pixels every 20ms (50fps) + var timeCompensation = timeDelta / 20; + this._lastFrame = timestamp; + + var actualScrollX = 0; + var actualScrollY = 0; + // don't bother scrolling vertically when the scrolldir is only horizontal + // and the other way around + if (this._scrolldir != "EW") { + var y = this.accelerate(this._screenY, this._startY) * timeCompensation; + var desiredScrollY = this._scrollErrorY + y; + actualScrollY = this.roundToZero(desiredScrollY); + this._scrollErrorY = desiredScrollY - actualScrollY; + } + if (this._scrolldir != "NS") { + var x = this.accelerate(this._screenX, this._startX) * timeCompensation; + var desiredScrollX = this._scrollErrorX + x; + actualScrollX = this.roundToZero(desiredScrollX); + this._scrollErrorX = desiredScrollX - actualScrollX; + } + + this._scrollable.scrollBy({ + left: actualScrollX, + top: actualScrollY, + behavior: "instant", + }); + + this._scrollable.ownerGlobal.requestAnimationFrame(this.autoscrollLoop); + } + + handleEvent(event) { + if (event.type == "mousemove") { + this._screenX = event.screenX; + this._screenY = event.screenY; + } else if (event.type == "mousedown") { + if ( + event.isTrusted & !event.defaultPrevented && + event.button == 1 && + !this._scrollable && + !this.isAutoscrollBlocker(event.originalTarget) + ) { + this.startScroll(event); + } + } else if (event.type == "pagehide") { + if (this._scrollable) { + var doc = this._scrollable.ownerDocument || this._scrollable.document; + if (doc == event.target) { + this.sendAsyncMessage("Autoscroll:Cancel"); + this.stopScroll(); + } + } + } + } + + receiveMessage(msg) { + let data = msg.data; + switch (msg.name) { + case "Autoscroll:MaybeStart": + for (let child of this.browsingContext.children) { + if (data.browsingContextId == child.id) { + this.startScroll({ + screenX: data.screenX, + screenY: data.screenY, + originalTarget: child.embedderElement, + }); + break; + } + } + break; + case "Autoscroll:Stop": { + this.stopScroll(); + break; + } + } + } + + rejectedByApz(data) { + // The caller passes in the scroll id via 'data'. + if (data == this._scrollId) { + this._autoscrollHandledByApz = false; + this.startMainThreadScroll(); + Services.obs.removeObserver(this.observer, "autoscroll-rejected-by-apz"); + } + } +} + +class AutoScrollObserver { + constructor(actor) { + this.actor = actor; + } + + observe(subject, topic, data) { + if (topic === "autoscroll-rejected-by-apz") { + this.actor.rejectedByApz(data); + } + } +} diff --git a/toolkit/actors/AutoScrollParent.jsm b/toolkit/actors/AutoScrollParent.jsm new file mode 100644 index 0000000000..f5e6b7b8c3 --- /dev/null +++ b/toolkit/actors/AutoScrollParent.jsm @@ -0,0 +1,33 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["AutoScrollParent"]; + +class AutoScrollParent extends JSWindowActorParent { + receiveMessage(msg) { + let browser = this.manager.browsingContext.top.embedderElement; + if (!browser) { + return null; + } + + let data = msg.data; + switch (msg.name) { + case "Autoscroll:Start": + return Promise.resolve(browser.startScroll(data)); + case "Autoscroll:MaybeStartInParent": + let parent = this.browsingContext.parent; + if (parent) { + let actor = parent.currentWindowGlobal.getActor("AutoScroll"); + actor.sendAsyncMessage("Autoscroll:MaybeStart", data); + } + break; + case "Autoscroll:Cancel": + browser.cancelScroll(); + break; + } + return null; + } +} diff --git a/toolkit/actors/AutoplayChild.jsm b/toolkit/actors/AutoplayChild.jsm new file mode 100644 index 0000000000..54071266ce --- /dev/null +++ b/toolkit/actors/AutoplayChild.jsm @@ -0,0 +1,13 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["AutoplayChild"]; + +class AutoplayChild extends JSWindowActorChild { + handleEvent(event) { + this.sendAsyncMessage("GloballyAutoplayBlocked", {}); + } +} diff --git a/toolkit/actors/AutoplayParent.jsm b/toolkit/actors/AutoplayParent.jsm new file mode 100644 index 0000000000..4daa791630 --- /dev/null +++ b/toolkit/actors/AutoplayParent.jsm @@ -0,0 +1,20 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["AutoplayParent"]; + +class AutoplayParent extends JSWindowActorParent { + receiveMessage(aMessage) { + let topBrowsingContext = this.manager.browsingContext.top; + let browser = topBrowsingContext.embedderElement; + let document = browser.ownerDocument; + let event = document.createEvent("CustomEvent"); + event.initCustomEvent("GloballyAutoplayBlocked", true, false, { + url: this.documentURI, + }); + browser.dispatchEvent(event); + } +} diff --git a/toolkit/actors/BackgroundThumbnailsChild.jsm b/toolkit/actors/BackgroundThumbnailsChild.jsm new file mode 100644 index 0000000000..da365a4288 --- /dev/null +++ b/toolkit/actors/BackgroundThumbnailsChild.jsm @@ -0,0 +1,100 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["BackgroundThumbnailsChild"]; +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +ChromeUtils.defineModuleGetter( + this, + "PageThumbUtils", + "resource://gre/modules/PageThumbUtils.jsm" +); + +// NOTE: Copied from nsSandboxFlags.h +/** + * This flag prevents content from creating new auxiliary browsing contexts, + * e.g. using the target attribute, or the window.open() method. + */ +const SANDBOXED_AUXILIARY_NAVIGATION = 0x2; + +class BackgroundThumbnailsChild extends JSWindowActorChild { + receiveMessage(message) { + switch (message.name) { + case "Browser:Thumbnail:ContentInfo": { + if ( + message.data.isImage || + this.document instanceof this.contentWindow.ImageDocument + ) { + // To avoid sending additional messages between processes, we return + // the image data directly with the size info. + return PageThumbUtils.createImageThumbnailCanvas( + this.contentWindow, + this.document.location, + message.data.targetWidth, + message.data.backgroundColor + ); + } + + let [width, height] = PageThumbUtils.getContentSize(this.contentWindow); + return { width, height }; + } + + case "Browser:Thumbnail:LoadURL": { + let docShell = this.docShell.QueryInterface(Ci.nsIWebNavigation); + + // We want a low network priority for this service - lower than b/g tabs + // etc - so set it to the lowest priority available. + docShell + .QueryInterface(Ci.nsIDocumentLoader) + .loadGroup.QueryInterface(Ci.nsISupportsPriority).priority = + Ci.nsISupportsPriority.PRIORITY_LOWEST; + + docShell.allowMedia = false; + docShell.allowPlugins = false; + docShell.allowContentRetargeting = false; + let defaultFlags = + Ci.nsIRequest.LOAD_ANONYMOUS | + Ci.nsIRequest.LOAD_BYPASS_CACHE | + Ci.nsIRequest.INHIBIT_CACHING | + Ci.nsIWebNavigation.LOAD_FLAGS_BYPASS_HISTORY; + docShell.defaultLoadFlags = defaultFlags; + this.browsingContext.sandboxFlags |= SANDBOXED_AUXILIARY_NAVIGATION; + docShell.useTrackingProtection = true; + + // Get the document to force a content viewer to be created, otherwise + // the first load can fail. + if (!this.document) { + return false; + } + + let loadURIOptions = { + // Bug 1498603 verify usages of systemPrincipal here + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + loadFlags: Ci.nsIWebNavigation.LOAD_FLAGS_STOP_CONTENT, + }; + try { + docShell.loadURI(message.data.url, loadURIOptions); + } catch (ex) { + return false; + } + + return true; + } + } + + return undefined; + } + + handleEvent(event) { + if (event.type == "DOMDocElementInserted") { + // Arrange to prevent (most) popup dialogs for this window - popups done + // in the parent (eg, auth) aren't prevented, but alert() etc are. + // disableDialogs only works on the current inner window, so it has + // to be called every page load, but before scripts run. + this.contentWindow.windowUtils.disableDialogs(); + } + } +} diff --git a/toolkit/actors/BrowserElementChild.jsm b/toolkit/actors/BrowserElementChild.jsm new file mode 100644 index 0000000000..ab02a52a7f --- /dev/null +++ b/toolkit/actors/BrowserElementChild.jsm @@ -0,0 +1,38 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["BrowserElementChild"]; + +class BrowserElementChild extends JSWindowActorChild { + handleEvent(event) { + if ( + event.type == "DOMWindowClose" && + !this.manager.browsingContext.parent + ) { + this.sendAsyncMessage("DOMWindowClose", {}); + } + } + + receiveMessage(message) { + switch (message.name) { + case "EnterModalState": { + this.contentWindow.windowUtils.enterModalState(); + break; + } + + case "LeaveModalState": { + if ( + !message.data.forceLeave && + !this.contentWindow.windowUtils.isInModalState() + ) { + break; + } + this.contentWindow.windowUtils.leaveModalState(); + break; + } + } + } +} diff --git a/toolkit/actors/BrowserElementParent.jsm b/toolkit/actors/BrowserElementParent.jsm new file mode 100644 index 0000000000..4e6d36d677 --- /dev/null +++ b/toolkit/actors/BrowserElementParent.jsm @@ -0,0 +1,39 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["BrowserElementParent"]; + +/** + * The BrowserElementParent is for performing actions on one or more subframes of + * a <xul:browser> from the browser element binding. + */ +class BrowserElementParent extends JSWindowActorParent { + receiveMessage(message) { + switch (message.name) { + case "DOMWindowClose": { + // This message is sent whenever window.close() is called within a window + // that had originally been opened via window.open. Double-check that this is + // coming from a top-level frame, and then dispatch the DOMWindowClose event + // on the browser so that the front-end code can do the right thing with the + // request to close. + if (!this.manager.browsingContext.parent) { + let browser = this.manager.browsingContext.embedderElement; + let win = browser.ownerGlobal; + // If this is a non-remote browser, the DOMWindowClose event will bubble + // up naturally, and doesn't need to be re-dispatched. + if (browser.isRemoteBrowser) { + browser.dispatchEvent( + new win.CustomEvent("DOMWindowClose", { + bubbles: true, + }) + ); + } + } + break; + } + } + } +} diff --git a/toolkit/actors/ControllersChild.jsm b/toolkit/actors/ControllersChild.jsm new file mode 100644 index 0000000000..2a4e9e4cb6 --- /dev/null +++ b/toolkit/actors/ControllersChild.jsm @@ -0,0 +1,35 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["ControllersChild"]; + +class ControllersChild extends JSWindowActorChild { + receiveMessage(message) { + switch (message.name) { + case "ControllerCommands:Do": + if (this.docShell && this.docShell.isCommandEnabled(message.data)) { + this.docShell.doCommand(message.data); + } + break; + + case "ControllerCommands:DoWithParams": + var data = message.data; + if (this.docShell && this.docShell.isCommandEnabled(data.cmd)) { + var params = Cu.createCommandParams(); + for (var name in data.params) { + var value = data.params[name]; + if (value.type == "long") { + params.setLongValue(name, parseInt(value.value)); + } else { + throw Components.Exception("", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + } + this.docShell.doCommandWithParams(data.cmd, params); + } + break; + } + } +} diff --git a/toolkit/actors/ControllersParent.jsm b/toolkit/actors/ControllersParent.jsm new file mode 100644 index 0000000000..3f912076cb --- /dev/null +++ b/toolkit/actors/ControllersParent.jsm @@ -0,0 +1,100 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["ControllersParent"]; + +class ControllersParent extends JSWindowActorParent { + constructor() { + super(); + + // A map of commands that have had their enabled/disabled state assigned. The + // value of each key will be true if enabled, and false if disabled. + this.supportedCommands = {}; + } + + get browser() { + return this.browsingContext.top.embedderElement; + } + + // Update the set of enabled and disabled commands. + enableDisableCommands(aAction, aEnabledCommands, aDisabledCommands) { + // Clear the list first + this.supportedCommands = {}; + + for (let command of aEnabledCommands) { + this.supportedCommands[command] = true; + } + + for (let command of aDisabledCommands) { + this.supportedCommands[command] = false; + } + + let browser = this.browser; + if (browser) { + browser.ownerGlobal.updateCommands(aAction); + } + } + + isCommandEnabled(aCommand) { + return this.supportedCommands[aCommand] || false; + } + + supportsCommand(aCommand) { + return aCommand in this.supportedCommands; + } + + doCommand(aCommand) { + this.sendAsyncMessage("ControllerCommands:Do", aCommand); + } + + getCommandStateWithParams(aCommand, aCommandParams) { + throw Components.Exception("Not implemented", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + doCommandWithParams(aCommand, aCommandParams) { + let cmd = { + cmd: aCommand, + params: null, + }; + if (aCommand == "cmd_lookUpDictionary") { + // Although getBoundingClientRect of the element is logical pixel, but + // x and y parameter of cmd_lookUpDictionary are device pixel. + // So we need calculate child process's coordinate using correct unit. + let browser = this.browser; + let rect = browser.getBoundingClientRect(); + let scale = browser.ownerGlobal.devicePixelRatio; + cmd.params = { + x: { + type: "long", + value: aCommandParams.getLongValue("x") - rect.left * scale, + }, + y: { + type: "long", + value: aCommandParams.getLongValue("y") - rect.top * scale, + }, + }; + } else { + throw Components.Exception( + "Not implemented", + Cr.NS_ERROR_NOT_IMPLEMENTED + ); + } + this.sendAsyncMessage("ControllerCommands:DoWithParams", cmd); + } + + getSupportedCommands() { + throw Components.Exception("Not implemented", Cr.NS_ERROR_NOT_IMPLEMENTED); + } + + onEvent() {} +} + +ControllersParent.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIBrowserController", + "nsIController", + "nsICommandController", +]); diff --git a/toolkit/actors/DateTimePickerChild.jsm b/toolkit/actors/DateTimePickerChild.jsm new file mode 100644 index 0000000000..0bfce7f0a7 --- /dev/null +++ b/toolkit/actors/DateTimePickerChild.jsm @@ -0,0 +1,213 @@ +/* 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/. */ + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +ChromeUtils.defineModuleGetter( + this, + "BrowserUtils", + "resource://gre/modules/BrowserUtils.jsm" +); + +var EXPORTED_SYMBOLS = ["DateTimePickerChild"]; + +/** + * DateTimePickerChild is the communication channel between the input box + * (content) for date/time input types and its picker (chrome). + */ +class DateTimePickerChild extends JSWindowActorChild { + /** + * On init, just listen for the event to open the picker, once the picker is + * opened, we'll listen for update and close events. + */ + constructor() { + super(); + + this._inputElement = null; + } + + /** + * Cleanup function called when picker is closed. + */ + close() { + this.removeListeners(this._inputElement); + let dateTimeBoxElement = this._inputElement.dateTimeBoxElement; + if (!dateTimeBoxElement) { + this._inputElement = null; + return; + } + + if (this._inputElement.openOrClosedShadowRoot) { + // dateTimeBoxElement is within UA Widget Shadow DOM. + // An event dispatch to it can't be accessed by document. + let win = this._inputElement.ownerGlobal; + dateTimeBoxElement.dispatchEvent( + new win.CustomEvent("MozSetDateTimePickerState", { detail: false }) + ); + } + + this._inputElement = null; + } + + /** + * Called after picker is opened to start listening for input box update + * events. + */ + addListeners(aElement) { + aElement.ownerGlobal.addEventListener("pagehide", this); + } + + /** + * Stop listeneing for events when picker is closed. + */ + removeListeners(aElement) { + aElement.ownerGlobal.removeEventListener("pagehide", this); + } + + /** + * Helper function that returns the CSS direction property of the element. + */ + getComputedDirection(aElement) { + return aElement.ownerGlobal + .getComputedStyle(aElement) + .getPropertyValue("direction"); + } + + /** + * Helper function that returns the rect of the element, which is the position + * relative to the left/top of the content area. + */ + getBoundingContentRect(aElement) { + return BrowserUtils.getElementBoundingScreenRect(aElement); + } + + getTimePickerPref() { + return Services.prefs.getBoolPref("dom.forms.datetime.timepicker"); + } + + /** + * nsIMessageListener. + */ + receiveMessage(aMessage) { + switch (aMessage.name) { + case "FormDateTime:PickerClosed": { + this.close(); + break; + } + case "FormDateTime:PickerValueChanged": { + if (!this._inputElement) { + return; + } + + let dateTimeBoxElement = this._inputElement.dateTimeBoxElement; + if (!dateTimeBoxElement) { + return; + } + + let win = this._inputElement.ownerGlobal; + + if (this._inputElement.openOrClosedShadowRoot) { + // dateTimeBoxElement is within UA Widget Shadow DOM. + // An event dispatch to it can't be accessed by document. + dateTimeBoxElement.dispatchEvent( + new win.CustomEvent("MozPickerValueChanged", { + detail: Cu.cloneInto(aMessage.data, win), + }) + ); + } + break; + } + default: + break; + } + } + + /** + * nsIDOMEventListener, for chrome events sent by the input element and other + * DOM events. + */ + handleEvent(aEvent) { + switch (aEvent.type) { + case "MozOpenDateTimePicker": { + // Time picker is disabled when preffed off + if ( + !( + aEvent.originalTarget instanceof + aEvent.originalTarget.ownerGlobal.HTMLInputElement + ) || + (aEvent.originalTarget.type == "time" && !this.getTimePickerPref()) + ) { + return; + } + + if (this._inputElement) { + // This happens when we're trying to open a picker when another picker + // is still open. We ignore this request to let the first picker + // close gracefully. + return; + } + + this._inputElement = aEvent.originalTarget; + + let dateTimeBoxElement = this._inputElement.dateTimeBoxElement; + if (!dateTimeBoxElement) { + throw new Error( + "How do we get this event without a UA Widget or XBL binding?" + ); + } + + if (this._inputElement.openOrClosedShadowRoot) { + // dateTimeBoxElement is within UA Widget Shadow DOM. + // An event dispatch to it can't be accessed by document, because + // the event is not composed. + let win = this._inputElement.ownerGlobal; + dateTimeBoxElement.dispatchEvent( + new win.CustomEvent("MozSetDateTimePickerState", { detail: true }) + ); + } + + this.addListeners(this._inputElement); + + let value = this._inputElement.getDateTimeInputBoxValue(); + this.sendAsyncMessage("FormDateTime:OpenPicker", { + rect: this.getBoundingContentRect(this._inputElement), + dir: this.getComputedDirection(this._inputElement), + type: this._inputElement.type, + detail: { + // Pass partial value if it's available, otherwise pass input + // element's value. + value: Object.keys(value).length ? value : this._inputElement.value, + min: this._inputElement.getMinimum(), + max: this._inputElement.getMaximum(), + step: this._inputElement.getStep(), + stepBase: this._inputElement.getStepBase(), + }, + }); + break; + } + case "MozUpdateDateTimePicker": { + let value = this._inputElement.getDateTimeInputBoxValue(); + value.type = this._inputElement.type; + this.sendAsyncMessage("FormDateTime:UpdatePicker", { value }); + break; + } + case "MozCloseDateTimePicker": { + this.sendAsyncMessage("FormDateTime:ClosePicker", {}); + this.close(); + break; + } + case "pagehide": { + if ( + this._inputElement && + this._inputElement.ownerDocument == aEvent.target + ) { + this.sendAsyncMessage("FormDateTime:ClosePicker", {}); + this.close(); + } + break; + } + default: + break; + } + } +} diff --git a/toolkit/actors/DateTimePickerParent.jsm b/toolkit/actors/DateTimePickerParent.jsm new file mode 100644 index 0000000000..d3296581ca --- /dev/null +++ b/toolkit/actors/DateTimePickerParent.jsm @@ -0,0 +1,138 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +const DEBUG = false; +function debug(aStr) { + if (DEBUG) { + dump("-*- DateTimePickerParent: " + aStr + "\n"); + } +} + +var EXPORTED_SYMBOLS = ["DateTimePickerParent"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +ChromeUtils.defineModuleGetter( + this, + "DateTimePickerPanel", + "resource://gre/modules/DateTimePickerPanel.jsm" +); + +/* + * DateTimePickerParent receives message from content side (input box) and + * is reposible for opening, closing and updating the picker. Similarly, + * DateTimePickerParent listens for picker's events and notifies the content + * side (input box) about them. + */ +class DateTimePickerParent extends JSWindowActorParent { + receiveMessage(aMessage) { + debug("receiveMessage: " + aMessage.name); + switch (aMessage.name) { + case "FormDateTime:OpenPicker": { + let topBrowsingContext = this.manager.browsingContext.top; + let browser = topBrowsingContext.embedderElement; + this.showPicker(browser, aMessage.data); + break; + } + case "FormDateTime:ClosePicker": { + if (!this._picker) { + return; + } + this._picker.closePicker(); + this.close(); + break; + } + case "FormDateTime:UpdatePicker": { + if (!this._picker) { + return; + } + this._picker.setPopupValue(aMessage.data); + break; + } + default: + break; + } + } + + handleEvent(aEvent) { + debug("handleEvent: " + aEvent.type); + switch (aEvent.type) { + case "DateTimePickerValueChanged": { + this.sendAsyncMessage("FormDateTime:PickerValueChanged", aEvent.detail); + break; + } + case "popuphidden": { + this.sendAsyncMessage("FormDateTime:PickerClosed", {}); + this._picker.closePicker(); + this.close(); + break; + } + default: + break; + } + } + + // Get picker from browser and show it anchored to the input box. + showPicker(aBrowser, aData) { + let rect = aData.rect; + let type = aData.type; + let detail = aData.detail; + + debug("Opening picker with details: " + JSON.stringify(detail)); + + let window = aBrowser.ownerGlobal; + let tabbrowser = window.gBrowser; + if ( + Services.focus.activeWindow != window || + (tabbrowser && tabbrowser.selectedBrowser != aBrowser) + ) { + // We were sent a message from a window or tab that went into the + // background, so we'll ignore it for now. + return; + } + + let panel; + if (tabbrowser) { + panel = tabbrowser._getAndMaybeCreateDateTimePickerPanel(); + } else { + panel = aBrowser.dateTimePicker; + } + if (!panel) { + debug("aBrowser.dateTimePicker not found, exiting now."); + return; + } + this._picker = new DateTimePickerPanel(panel); + this._picker.openPicker(type, rect, detail); + + this.addPickerListeners(); + } + + // Picker is closed, do some cleanup. + close() { + this.removePickerListeners(); + this._picker = null; + } + + // Listen to picker's event. + addPickerListeners() { + if (!this._picker) { + return; + } + this._picker.element.addEventListener("popuphidden", this); + this._picker.element.addEventListener("DateTimePickerValueChanged", this); + } + + // Stop listening to picker's event. + removePickerListeners() { + if (!this._picker) { + return; + } + this._picker.element.removeEventListener("popuphidden", this); + this._picker.element.removeEventListener( + "DateTimePickerValueChanged", + this + ); + } +} diff --git a/toolkit/actors/ExtFindChild.jsm b/toolkit/actors/ExtFindChild.jsm new file mode 100644 index 0000000000..b39be9beaf --- /dev/null +++ b/toolkit/actors/ExtFindChild.jsm @@ -0,0 +1,34 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["ExtFindChild"]; + +ChromeUtils.defineModuleGetter( + this, + "FindContent", + "resource://gre/modules/FindContent.jsm" +); + +class ExtFindChild extends JSWindowActorChild { + receiveMessage(message) { + if (!this._findContent) { + this._findContent = new FindContent(this.docShell); + } + + switch (message.name) { + case "ext-Finder:CollectResults": + this.finderInited = true; + return this._findContent.findRanges(message.data); + case "ext-Finder:HighlightResults": + return this._findContent.highlightResults(message.data); + case "ext-Finder:ClearHighlighting": + this._findContent.highlighter.highlight(false); + break; + } + + return null; + } +} diff --git a/toolkit/actors/FindBarChild.jsm b/toolkit/actors/FindBarChild.jsm new file mode 100644 index 0000000000..f7fddb3a36 --- /dev/null +++ b/toolkit/actors/FindBarChild.jsm @@ -0,0 +1,158 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["FindBarChild"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "BrowserUtils", + "resource://gre/modules/BrowserUtils.jsm" +); + +class FindBarChild extends JSWindowActorChild { + constructor() { + super(); + + this._findKey = null; + + XPCOMUtils.defineLazyProxy( + this, + "FindBarContent", + () => { + let tmp = {}; + ChromeUtils.import("resource://gre/modules/FindBarContent.jsm", tmp); + return new tmp.FindBarContent(this); + }, + { inQuickFind: false, inPassThrough: false } + ); + } + + receiveMessage(msg) { + if (msg.name == "Findbar:UpdateState") { + let { FindBarContent } = this; + FindBarContent.updateState(msg.data); + } + } + + /** + * Check whether this key event will start the findbar in the parent, + * in which case we should pass any further key events to the parent to avoid + * them being lost. + * @param aEvent the key event to check. + */ + eventMatchesFindShortcut(aEvent) { + if (!this._findKey) { + this._findKey = Services.cpmm.sharedData.get("Findbar:Shortcut"); + if (!this._findKey) { + return false; + } + } + for (let k in this._findKey) { + if (this._findKey[k] != aEvent[k]) { + return false; + } + } + return true; + } + + handleEvent(event) { + if (event.type == "keypress") { + this.onKeypress(event); + } + } + + onKeypress(event) { + let { FindBarContent } = this; + + if (!FindBarContent.inPassThrough && this.eventMatchesFindShortcut(event)) { + return FindBarContent.start(event); + } + + // disable FAYT in about:blank to prevent FAYT opening unexpectedly. + let location = this.document.location.href; + if (location == "about:blank") { + return null; + } + + if ( + event.ctrlKey || + event.altKey || + event.metaKey || + event.defaultPrevented || + !BrowserUtils.mimeTypeIsTextBased(this.document.contentType) || + !BrowserUtils.canFindInPage(location) + ) { + return null; + } + + if (FindBarContent.inPassThrough || FindBarContent.inQuickFind) { + return FindBarContent.onKeypress(event); + } + + if (event.charCode && this.shouldFastFind(event.target)) { + let key = String.fromCharCode(event.charCode); + if ((key == "/" || key == "'") && FindBarChild.manualFAYT) { + return FindBarContent.startQuickFind(event); + } + if (key != " " && FindBarChild.findAsYouType) { + return FindBarContent.startQuickFind(event, true); + } + } + return null; + } + + /** + * Return true if we should FAYT for this node: + * + * @param elt + * The element that is focused + */ + shouldFastFind(elt) { + if (elt) { + let win = elt.ownerGlobal; + if (elt instanceof win.HTMLInputElement && elt.mozIsTextField(false)) { + return false; + } + + if (elt.isContentEditable || win.document.designMode == "on") { + return false; + } + + if ( + elt instanceof win.HTMLTextAreaElement || + elt instanceof win.HTMLSelectElement || + elt instanceof win.HTMLObjectElement || + elt instanceof win.HTMLEmbedElement + ) { + return false; + } + + if (elt instanceof win.HTMLIFrameElement && elt.mozbrowser) { + // If we're targeting a mozbrowser iframe, it should be allowed to + // handle FastFind itself. + return false; + } + } + + return true; + } +} + +XPCOMUtils.defineLazyPreferenceGetter( + FindBarChild, + "findAsYouType", + "accessibility.typeaheadfind" +); +XPCOMUtils.defineLazyPreferenceGetter( + FindBarChild, + "manualFAYT", + "accessibility.typeaheadfind.manual" +); diff --git a/toolkit/actors/FindBarParent.jsm b/toolkit/actors/FindBarParent.jsm new file mode 100644 index 0000000000..b71f8e729b --- /dev/null +++ b/toolkit/actors/FindBarParent.jsm @@ -0,0 +1,50 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["FindBarParent"]; + +// Map of browser elements to findbars. +let findbars = new WeakMap(); + +class FindBarParent extends JSWindowActorParent { + setFindbar(browser, findbar) { + if (findbar) { + findbars.set(browser, findbar); + } else { + findbars.delete(browser, findbar); + } + } + + receiveMessage(message) { + let browser = this.manager.browsingContext.top.embedderElement; + if (!browser) { + return; + } + + let respondToMessage = () => { + let findBar = findbars.get(browser); + if (!findBar) { + return; + } + + switch (message.name) { + case "Findbar:Keypress": + findBar._onBrowserKeypress(message.data); + break; + case "Findbar:Mouseup": + findBar.onMouseUp(); + break; + } + }; + + let findPromise = browser.ownerGlobal.gFindBarPromise; + if (findPromise) { + findPromise.then(respondToMessage); + } else { + respondToMessage(); + } + } +} diff --git a/toolkit/actors/FinderChild.jsm b/toolkit/actors/FinderChild.jsm new file mode 100644 index 0000000000..7fb6f39b0f --- /dev/null +++ b/toolkit/actors/FinderChild.jsm @@ -0,0 +1,127 @@ +// -*- indent-tabs-mode: nil; js-indent-level: 2 -*- +// vim: set ts=2 sw=2 sts=2 et tw=80: */ +// 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/. + +var EXPORTED_SYMBOLS = ["FinderChild"]; + +ChromeUtils.defineModuleGetter( + this, + "Finder", + "resource://gre/modules/Finder.jsm" +); + +class FinderChild extends JSWindowActorChild { + get finder() { + if (!this._finder) { + this._finder = new Finder(this.docShell); + } + return this._finder; + } + + receiveMessage(aMessage) { + let data = aMessage.data; + + switch (aMessage.name) { + case "Finder:CaseSensitive": + this.finder.caseSensitive = data.caseSensitive; + break; + + case "Finder:MatchDiacritics": + this.finder.matchDiacritics = data.matchDiacritics; + break; + + case "Finder:EntireWord": + this.finder.entireWord = data.entireWord; + break; + + case "Finder:SetSearchStringToSelection": { + return new Promise(resolve => { + resolve(this.finder.setSearchStringToSelection()); + }); + } + + case "Finder:GetInitialSelection": { + return new Promise(resolve => { + resolve(this.finder.getActiveSelectionText()); + }); + } + + case "Finder:Find": + return this.finder.find(data); + + case "Finder:Highlight": + return this.finder + .highlight( + data.highlight, + data.searchString, + data.linksOnly, + data.useSubFrames + ) + .then(result => { + if (result) { + result.browsingContextId = this.browsingContext.id; + } + return result; + }); + + case "Finder:UpdateHighlightAndMatchCount": + return this.finder.updateHighlightAndMatchCount(data).then(result => { + if (result) { + result.browsingContextId = this.browsingContext.id; + } + return result; + }); + + case "Finder:HighlightAllChange": + this.finder.onHighlightAllChange(data.highlightAll); + break; + + case "Finder:EnableSelection": + this.finder.enableSelection(); + break; + + case "Finder:RemoveSelection": + this.finder.removeSelection(data.keepHighlight); + break; + + case "Finder:FocusContent": + this.finder.focusContent(); + break; + + case "Finder:FindbarClose": + this.finder.onFindbarClose(); + break; + + case "Finder:FindbarOpen": + this.finder.onFindbarOpen(); + break; + + case "Finder:KeyPress": + var KeyboardEvent = this.finder._getWindow().KeyboardEvent; + this.finder.keyPress(new KeyboardEvent("keypress", data)); + break; + + case "Finder:MatchesCount": + return this.finder + .requestMatchesCount( + data.searchString, + data.linksOnly, + data.useSubFrames + ) + .then(result => { + if (result) { + result.browsingContextId = this.browsingContext.id; + } + return result; + }); + + case "Finder:ModalHighlightChange": + this.finder.onModalHighlightChange(data.useModalHighlight); + break; + } + + return null; + } +} diff --git a/toolkit/actors/InlineSpellCheckerChild.jsm b/toolkit/actors/InlineSpellCheckerChild.jsm new file mode 100644 index 0000000000..acfa0d30ae --- /dev/null +++ b/toolkit/actors/InlineSpellCheckerChild.jsm @@ -0,0 +1,41 @@ +/* -*- mode: js; indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["InlineSpellCheckerChild"]; + +ChromeUtils.defineModuleGetter( + this, + "InlineSpellCheckerContent", + "resource://gre/modules/InlineSpellCheckerContent.jsm" +); + +class InlineSpellCheckerChild extends JSWindowActorChild { + receiveMessage(msg) { + switch (msg.name) { + case "InlineSpellChecker:selectDictionary": + InlineSpellCheckerContent.selectDictionary(msg.data.localeCode); + break; + + case "InlineSpellChecker:replaceMisspelling": + InlineSpellCheckerContent.replaceMisspelling(msg.data.index); + break; + + case "InlineSpellChecker:toggleEnabled": + InlineSpellCheckerContent.toggleEnabled(); + break; + + case "InlineSpellChecker:recheck": + InlineSpellCheckerContent.recheck(); + break; + + case "InlineSpellChecker:uninit": + InlineSpellCheckerContent.uninitContextMenu(); + break; + } + } +} diff --git a/toolkit/actors/InlineSpellCheckerParent.jsm b/toolkit/actors/InlineSpellCheckerParent.jsm new file mode 100644 index 0000000000..84b8a616cc --- /dev/null +++ b/toolkit/actors/InlineSpellCheckerParent.jsm @@ -0,0 +1,52 @@ +/* -*- mode: js; indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["InlineSpellCheckerParent"]; + +class InlineSpellCheckerParent extends JSWindowActorParent { + selectDictionary({ localeCode }) { + this.sendAsyncMessage("InlineSpellChecker:selectDictionary", { + localeCode, + }); + } + + replaceMisspelling({ index }) { + this.sendAsyncMessage("InlineSpellChecker:replaceMisspelling", { index }); + } + + toggleEnabled() { + this.sendAsyncMessage("InlineSpellChecker:toggleEnabled", {}); + } + + recheckSpelling() { + this.sendAsyncMessage("InlineSpellChecker:recheck", {}); + } + + uninit() { + // This method gets called by InlineSpellChecker when the context menu + // goes away and the InlineSpellChecker instance is still alive. + // Stop referencing it and tidy the child end of us. + this.sendAsyncMessage("InlineSpellChecker:uninit", {}); + } + + _destructionObservers = new Set(); + registerDestructionObserver(obj) { + this._destructionObservers.add(obj); + } + + unregisterDestructionObserver(obj) { + this._destructionObservers.delete(obj); + } + + didDestroy() { + for (let obs of this._destructionObservers) { + obs.actorDestroyed(this); + } + this._destructionObservers = null; + } +} diff --git a/toolkit/actors/KeyPressEventModelCheckerChild.jsm b/toolkit/actors/KeyPressEventModelCheckerChild.jsm new file mode 100644 index 0000000000..ba3980acff --- /dev/null +++ b/toolkit/actors/KeyPressEventModelCheckerChild.jsm @@ -0,0 +1,116 @@ +/* -*- mode: js; indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["KeyPressEventModelCheckerChild"]; + +const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +class KeyPressEventModelCheckerChild extends JSWindowActorChild { + // Currently, the event is dispatched only when the document becomes editable + // because of contenteditable. If you need to add new editor which is in + // designMode, you need to change MaybeDispatchCheckKeyPressEventModelEvent() + // of Document. + handleEvent(aEvent) { + if (!AppConstants.DEBUG) { + // Stop propagation in opt build to save the propagation cost. + // However, the event is necessary for running test_bug1514940.html. + // Therefore, we need to keep propagating it at least on debug build. + aEvent.stopImmediatePropagation(); + } + + // Currently, even if we set Document.KEYPRESS_EVENT_MODEL_CONFLATED + // here, conflated model isn't used forcibly. If you need it, you need + // to change WidgetKeyboardEvent, dom::KeyboardEvent and PresShell. + let model = Document.KEYPRESS_EVENT_MODEL_DEFAULT; + if ( + this._isOldOfficeOnlineServer(aEvent.target) || + this._isOldConfluence(aEvent.target.ownerGlobal) + ) { + model = Document.KEYPRESS_EVENT_MODEL_SPLIT; + } + aEvent.target.setKeyPressEventModel(model); + } + + _isOldOfficeOnlineServer(aDocument) { + let editingElement = aDocument.getElementById( + "WACViewPanel_EditingElement" + ); + // If it's not Office Online Server, don't include it into the telemetry + // because we just need to collect percentage of old version in all loaded + // Office Online Server instances. + if (!editingElement) { + return false; + } + let isOldVersion = !editingElement.classList.contains( + "WACViewPanel_DisableLegacyKeyCodeAndCharCode" + ); + Services.telemetry.keyedScalarAdd( + "dom.event.office_online_load_count", + isOldVersion ? "old" : "new", + 1 + ); + return isOldVersion; + } + + _isOldConfluence(aWindow) { + if (!aWindow) { + return false; + } + // aWindow should be an editor window in <iframe>. However, we don't know + // whether it can be without <iframe>. Anyway, there should be tinyMCE + // object in the parent window or in the window. + let tinyMCEObject; + // First, try to retrieve tinyMCE object from parent window. + try { + tinyMCEObject = ChromeUtils.waiveXrays(aWindow.parent).tinyMCE; + } catch (e) { + // Ignore the exception for now. + } + // Next, if there is no tinyMCE object in the parent window, let's check + // the window. + if (!tinyMCEObject) { + try { + tinyMCEObject = ChromeUtils.waiveXrays(aWindow).tinyMCE; + } catch (e) { + // Fallthrough to return false below. + } + // If we couldn't find tinyMCE object, let's assume that it's not + // Confluence instance. + if (!tinyMCEObject) { + return false; + } + } + // If there is tinyMCE object, we can assume that we loaded Confluence + // instance. So, let's check the version whether it allows conflated + // keypress event model. + try { + let { + author, + version, + } = new tinyMCEObject.plugins.CursorTargetPlugin().getInfo(); + // If it's not Confluence, don't include it into the telemetry because + // we just need to collect percentage of old version in all loaded + // Confluence instances. + if (author !== "Atlassian") { + return false; + } + let isOldVersion = version === "1.0"; + Services.telemetry.keyedScalarAdd( + "dom.event.confluence_load_count", + isOldVersion ? "old" : "new", + 1 + ); + return isOldVersion; + } catch (e) { + return false; + } + } +} diff --git a/toolkit/actors/PictureInPictureChild.jsm b/toolkit/actors/PictureInPictureChild.jsm new file mode 100644 index 0000000000..06f99e06d5 --- /dev/null +++ b/toolkit/actors/PictureInPictureChild.jsm @@ -0,0 +1,1632 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +var EXPORTED_SYMBOLS = [ + "PictureInPictureChild", + "PictureInPictureToggleChild", + "PictureInPictureLauncherChild", +]; + +ChromeUtils.defineModuleGetter( + this, + "DeferredTask", + "resource://gre/modules/DeferredTask.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "Services", + "resource://gre/modules/Services.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "KEYBOARD_CONTROLS", + "resource://gre/modules/PictureInPictureControls.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "TOGGLE_POLICIES", + "resource://gre/modules/PictureInPictureControls.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "TOGGLE_POLICY_STRINGS", + "resource://gre/modules/PictureInPictureControls.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "Rect", + "resource://gre/modules/Geometry.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "ContentDOMReference", + "resource://gre/modules/ContentDOMReference.jsm" +); + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +const TOGGLE_ENABLED_PREF = + "media.videocontrols.picture-in-picture.video-toggle.enabled"; +const TOGGLE_TESTING_PREF = + "media.videocontrols.picture-in-picture.video-toggle.testing"; +const MOUSEMOVE_PROCESSING_DELAY_MS = 50; +const TOGGLE_HIDING_TIMEOUT_MS = 2000; + +// The ToggleChild does not want to capture events from the PiP +// windows themselves. This set contains all currently open PiP +// players' content windows +var gPlayerContents = new WeakSet(); + +// To make it easier to write tests, we have a process-global +// WeakSet of all <video> elements that are being tracked for +// mouseover +var gWeakIntersectingVideosForTesting = new WeakSet(); + +// Overrides are expected to stay constant for the lifetime of a +// content process, so we set this as a lazy process global. +// See PictureInPictureToggleChild.getSiteOverrides for a +// sense of what the return types are. +XPCOMUtils.defineLazyGetter(this, "gSiteOverrides", () => { + return PictureInPictureToggleChild.getSiteOverrides(); +}); + +class PictureInPictureLauncherChild extends JSWindowActorChild { + handleEvent(event) { + switch (event.type) { + case "MozTogglePictureInPicture": { + if (event.isTrusted) { + this.togglePictureInPicture(event.target); + } + break; + } + } + } + + receiveMessage(message) { + switch (message.name) { + case "PictureInPicture:KeyToggle": { + this.keyToggle(); + break; + } + } + } + + /** + * Tells the parent to open a Picture-in-Picture window hosting + * a clone of the passed video. If we know about a pre-existing + * Picture-in-Picture window existing, this tells the parent to + * close it before opening the new one. + * + * @param {Element} video The <video> element to view in a Picture + * in Picture window. + * + * @return {Promise} + * @resolves {undefined} Once the new Picture-in-Picture window + * has been requested. + */ + async togglePictureInPicture(video) { + if (video.isCloningElementVisually) { + // The only way we could have entered here for the same video is if + // we are toggling via the context menu, since we hide the inline + // Picture-in-Picture toggle when a video is being displayed in + // Picture-in-Picture. Turn off PiP in this case + const stopPipEvent = new this.contentWindow.CustomEvent( + "MozStopPictureInPicture", + { + bubbles: true, + detail: { reason: "context-menu" }, + } + ); + video.dispatchEvent(stopPipEvent); + return; + } + + // All other requests to toggle PiP should open a new PiP + // window + const videoRef = ContentDOMReference.get(video); + this.sendAsyncMessage("PictureInPicture:Request", { + isMuted: PictureInPictureChild.videoIsMuted(video), + playing: PictureInPictureChild.videoIsPlaying(video), + videoHeight: video.videoHeight, + videoWidth: video.videoWidth, + videoRef, + }); + } + + // + /** + * The keyboard was used to attempt to open Picture-in-Picture. In this case, + * find the focused window, and open Picture-in-Picture for the first + * playing video, or if none, the largest dimension video. We suspect this + * heuristic will handle most cases, though we might refine this later on. + */ + keyToggle() { + let focusedWindow = Services.focus.focusedWindow; + if (focusedWindow) { + let doc = focusedWindow.document; + if (doc) { + let listOfVideos = [...doc.querySelectorAll("video")]; + // Get the first non-paused video, otherwise the longest video. This + // fallback is designed to skip over "preview"-style videos on sidebars. + let video = + listOfVideos.filter(v => !v.paused)[0] || + listOfVideos.sort((a, b) => b.duration - a.duration)[0]; + if (video) { + this.togglePictureInPicture(video); + } + } + } + } +} + +/** + * The PictureInPictureToggleChild is responsible for displaying the overlaid + * Picture-in-Picture toggle over top of <video> elements that the mouse is + * hovering. + */ +class PictureInPictureToggleChild extends JSWindowActorChild { + constructor() { + super(); + // We need to maintain some state about various things related to the + // Picture-in-Picture toggles - however, for now, the same + // PictureInPictureToggleChild might be re-used for different documents. + // We keep the state stashed inside of this WeakMap, keyed on the document + // itself. + this.weakDocStates = new WeakMap(); + this.toggleEnabled = Services.prefs.getBoolPref(TOGGLE_ENABLED_PREF); + this.toggleTesting = Services.prefs.getBoolPref(TOGGLE_TESTING_PREF, false); + + // Bug 1570744 - JSWindowActorChild's cannot be used as nsIObserver's + // directly, so we create a new function here instead to act as our + // nsIObserver, which forwards the notification to the observe method. + this.observerFunction = (subject, topic, data) => { + this.observe(subject, topic, data); + }; + Services.prefs.addObserver(TOGGLE_ENABLED_PREF, this.observerFunction); + Services.cpmm.sharedData.addEventListener("change", this); + } + + didDestroy() { + this.stopTrackingMouseOverVideos(); + Services.prefs.removeObserver(TOGGLE_ENABLED_PREF, this.observerFunction); + Services.cpmm.sharedData.removeEventListener("change", this); + } + + observe(subject, topic, data) { + if (topic != "nsPref:changed") { + return; + } + + this.toggleEnabled = Services.prefs.getBoolPref(TOGGLE_ENABLED_PREF); + + if (this.toggleEnabled) { + // We have enabled the Picture-in-Picture toggle, so we need to make + // sure we register all of the videos that might already be on the page. + this.contentWindow.requestIdleCallback(() => { + let videos = this.document.querySelectorAll("video"); + for (let video of videos) { + this.registerVideo(video); + } + }); + } + } + + /** + * Returns the state for the current document referred to via + * this.document. If no such state exists, creates it, stores it + * and returns it. + */ + get docState() { + let state = this.weakDocStates.get(this.document); + if (!state) { + state = { + // A reference to the IntersectionObserver that's monitoring for videos + // to become visible. + intersectionObserver: null, + // A WeakSet of videos that are supposedly visible, according to the + // IntersectionObserver. + weakVisibleVideos: new WeakSet(), + // The number of videos that are supposedly visible, according to the + // IntersectionObserver + visibleVideosCount: 0, + // The DeferredTask that we'll arm every time a mousemove event occurs + // on a page where we have one or more visible videos. + mousemoveDeferredTask: null, + // A weak reference to the last video we displayed the toggle over. + weakOverVideo: null, + // True if the user is in the midst of clicking the toggle. + isClickingToggle: false, + // Set to the original target element on pointerdown if the user is clicking + // the toggle - this way, we can determine if a "click" event will need to be + // suppressed ("click" events don't fire if a "mouseup" occurs on a different + // element from the "pointerdown" / "mousedown" event). + clickedElement: null, + // This is a DeferredTask to hide the toggle after a period of mouse + // inactivity. + hideToggleDeferredTask: null, + // If we reach a point where we're tracking videos for mouse movements, + // then this will be true. If there are no videos worth tracking, then + // this is false. + isTrackingVideos: false, + togglePolicy: TOGGLE_POLICIES.DEFAULT, + toggleVisibilityThreshold: 1.0, + // The documentURI that has been checked with toggle policies and + // visibility thresholds for this document. Note that the documentURI + // might change for a document via the history API, so we remember + // the last checked documentURI to determine if we need to check again. + checkedPolicyDocumentURI: null, + }; + this.weakDocStates.set(this.document, state); + } + + return state; + } + + /** + * Returns the video that the user was last hovering with the mouse if it + * still exists. + * + * @return {Element} the <video> element that the user was last hovering, + * or null if there was no such <video>, or the <video> no longer exists. + */ + getWeakOverVideo() { + let { weakOverVideo } = this.docState; + if (weakOverVideo) { + // Bug 800957 - Accessing weakrefs at the wrong time can cause us to + // throw NS_ERROR_XPC_BAD_CONVERT_NATIVE + try { + return weakOverVideo.get(); + } catch (e) { + return null; + } + } + return null; + } + + handleEvent(event) { + if (!event.isTrusted) { + // We don't care about synthesized events that might be coming from + // content JS. + return; + } + + // Don't capture events from Picture-in-Picture content windows + if (gPlayerContents.has(this.contentWindow)) { + return; + } + + switch (event.type) { + case "change": { + const { changedKeys } = event; + if (changedKeys.includes("PictureInPicture:SiteOverrides")) { + // For now we only update our cache if the site overrides change. + // the user will need to refresh the page for changes to apply. + try { + gSiteOverrides = PictureInPictureToggleChild.getSiteOverrides(); + } catch (e) { + // Ignore resulting TypeError if gSiteOverrides is still unloaded + if (!(e instanceof TypeError)) { + throw e; + } + } + } + break; + } + case "UAWidgetSetupOrChange": { + if ( + this.toggleEnabled && + event.target instanceof this.contentWindow.HTMLVideoElement && + event.target.ownerDocument == this.document + ) { + this.registerVideo(event.target); + } + break; + } + case "contextmenu": { + if (this.toggleEnabled) { + this.checkContextMenu(event); + } + break; + } + case "mouseout": { + this.onMouseOut(event); + break; + } + case "mousedown": + case "pointerup": + case "mouseup": + case "click": { + this.onMouseButtonEvent(event); + break; + } + case "pointerdown": { + this.onPointerDown(event); + break; + } + case "mousemove": { + this.onMouseMove(event); + break; + } + case "pageshow": { + this.onPageShow(event); + break; + } + case "pagehide": { + this.onPageHide(event); + break; + } + } + } + + /** + * Adds a <video> to the IntersectionObserver so that we know when it becomes + * visible. + * + * @param {Element} video The <video> element to register. + */ + registerVideo(video) { + let state = this.docState; + if (!state.intersectionObserver) { + let fn = this.onIntersection.bind(this); + state.intersectionObserver = new this.contentWindow.IntersectionObserver( + fn, + { + threshold: [0.0, 0.5], + } + ); + } + + state.intersectionObserver.observe(video); + } + + /** + * Called by the IntersectionObserver callback once a video becomes visible. + * This adds some fine-grained checking to ensure that a sufficient amount of + * the video is visible before we consider showing the toggles on it. For now, + * that means that the entirety of the video must be in the viewport. + * + * @param {IntersectionEntry} intersectionEntry An IntersectionEntry passed to + * the IntersectionObserver callback. + * @return bool Whether or not we should start tracking mousemove events for + * this registered video. + */ + worthTracking(intersectionEntry) { + return intersectionEntry.isIntersecting; + } + + /** + * Called by the IntersectionObserver once a video crosses one of the + * thresholds dictated by the IntersectionObserver configuration. + * + * @param {Array<IntersectionEntry>} A collection of one or more + * IntersectionEntry's for <video> elements that might have entered or exited + * the viewport. + */ + onIntersection(entries) { + // The IntersectionObserver will also fire when a previously intersecting + // element is removed from the DOM. We know, however, that the node is + // still alive and referrable from the WeakSet because the + // IntersectionObserverEntry holds a strong reference to the video. + let state = this.docState; + let oldVisibleVideosCount = state.visibleVideosCount; + for (let entry of entries) { + let video = entry.target; + if (this.worthTracking(entry)) { + if (!state.weakVisibleVideos.has(video)) { + state.weakVisibleVideos.add(video); + state.visibleVideosCount++; + if (this.toggleTesting) { + gWeakIntersectingVideosForTesting.add(video); + } + } + } else if (state.weakVisibleVideos.has(video)) { + state.weakVisibleVideos.delete(video); + state.visibleVideosCount--; + if (this.toggleTesting) { + gWeakIntersectingVideosForTesting.delete(video); + } + } + } + + // For testing, especially in debug or asan builds, we might not + // run this idle callback within an acceptable time. While we're + // testing, we'll bypass the idle callback performance optimization + // and run our callbacks as soon as possible during the next idle + // period. + if (!oldVisibleVideosCount && state.visibleVideosCount) { + if (this.toggleTesting) { + this.beginTrackingMouseOverVideos(); + } else { + this.contentWindow.requestIdleCallback(() => { + this.beginTrackingMouseOverVideos(); + }); + } + } else if (oldVisibleVideosCount && !state.visibleVideosCount) { + if (this.toggleTesting) { + this.stopTrackingMouseOverVideos(); + } else { + this.contentWindow.requestIdleCallback(() => { + this.stopTrackingMouseOverVideos(); + }); + } + } + } + + addMouseButtonListeners() { + // We want to try to cancel the mouse events from continuing + // on into content if the user has clicked on the toggle, so + // we don't use the mozSystemGroup here, and add the listener + // to the parent target of the window, which in this case, + // is the windowRoot. Since this event listener is attached to + // part of the outer window, we need to also remove it in a + // pagehide event listener in the event that the page unloads + // before stopTrackingMouseOverVideos fires. + this.contentWindow.windowRoot.addEventListener("pointerdown", this, { + capture: true, + }); + this.contentWindow.windowRoot.addEventListener("mousedown", this, { + capture: true, + }); + this.contentWindow.windowRoot.addEventListener("mouseup", this, { + capture: true, + }); + this.contentWindow.windowRoot.addEventListener("pointerup", this, { + capture: true, + }); + this.contentWindow.windowRoot.addEventListener("click", this, { + capture: true, + }); + this.contentWindow.windowRoot.addEventListener("mouseout", this, { + capture: true, + }); + } + + removeMouseButtonListeners() { + // This can be null when closing the tab, but the event + // listeners should be removed in that case already. + if (!this.contentWindow.windowRoot) { + return; + } + + this.contentWindow.windowRoot.removeEventListener("pointerdown", this, { + capture: true, + }); + this.contentWindow.windowRoot.removeEventListener("mousedown", this, { + capture: true, + }); + this.contentWindow.windowRoot.removeEventListener("mouseup", this, { + capture: true, + }); + this.contentWindow.windowRoot.removeEventListener("pointerup", this, { + capture: true, + }); + this.contentWindow.windowRoot.removeEventListener("click", this, { + capture: true, + }); + this.contentWindow.windowRoot.removeEventListener("mouseout", this, { + capture: true, + }); + } + + /** + * One of the challenges of displaying this toggle is that many sites put + * things over top of <video> elements, like custom controls, or images, or + * all manner of things that might intercept mouseevents that would normally + * fire directly on the <video>. In order to properly detect when the mouse + * is over top of one of the <video> elements in this situation, we currently + * add a mousemove event handler to the entire document, and stash the most + * recent mousemove that fires. At periodic intervals, that stashed mousemove + * event is checked to see if it's hovering over one of our registered + * <video> elements. + * + * This sort of thing will not be necessary once bug 1539652 is fixed. + */ + beginTrackingMouseOverVideos() { + let state = this.docState; + if (!state.mousemoveDeferredTask) { + state.mousemoveDeferredTask = new DeferredTask(() => { + this.checkLastMouseMove(); + }, MOUSEMOVE_PROCESSING_DELAY_MS); + } + this.document.addEventListener("mousemove", this, { + mozSystemGroup: true, + capture: true, + }); + this.contentWindow.addEventListener("pageshow", this, { + mozSystemGroup: true, + }); + this.contentWindow.addEventListener("pagehide", this, { + mozSystemGroup: true, + }); + this.addMouseButtonListeners(); + state.isTrackingVideos = true; + } + + /** + * If we no longer have any interesting videos in the viewport, we deregister + * the mousemove and click listeners, and also remove any toggles that might + * be on the page still. + */ + stopTrackingMouseOverVideos() { + let state = this.docState; + // We initialize `mousemoveDeferredTask` in `beginTrackingMouseOverVideos`. + // If it doesn't exist, that can't have happened. Nothing else ever sets + // this value (though we arm/disarm in various places). So we don't need + // to do anything else here and can return early. + if (!state.mousemoveDeferredTask) { + return; + } + state.mousemoveDeferredTask.disarm(); + this.document.removeEventListener("mousemove", this, { + mozSystemGroup: true, + capture: true, + }); + this.contentWindow.removeEventListener("pageshow", this, { + mozSystemGroup: true, + }); + this.contentWindow.removeEventListener("pagehide", this, { + mozSystemGroup: true, + }); + this.removeMouseButtonListeners(); + let oldOverVideo = this.getWeakOverVideo(); + if (oldOverVideo) { + this.onMouseLeaveVideo(oldOverVideo); + } + state.isTrackingVideos = false; + } + + /** + * This pageshow event handler will get called if and when we complete a tab + * tear out or in. If we happened to be tracking videos before the tear + * occurred, we re-add the mouse event listeners so that they're attached to + * the right WindowRoot. + * + * @param {Event} event The pageshow event fired when completing a tab tear + * out or in. + */ + onPageShow(event) { + let state = this.docState; + if (state.isTrackingVideos) { + this.addMouseButtonListeners(); + } + } + + /** + * This pagehide event handler will get called if and when we start a tab + * tear out or in. If we happened to be tracking videos before the tear + * occurred, we remove the mouse event listeners. We'll re-add them when the + * pageshow event fires. + * + * @param {Event} event The pagehide event fired when starting a tab tear + * out or in. + */ + onPageHide(event) { + let state = this.docState; + if (state.isTrackingVideos) { + this.removeMouseButtonListeners(); + } + } + + /** + * If we're tracking <video> elements, this pointerdown event handler is run anytime + * a pointerdown occurs on the document. This function is responsible for checking + * if the user clicked on the Picture-in-Picture toggle. It does this by first + * checking if the video is visible beneath the point that was clicked. Then + * it tests whether or not the pointerdown occurred within the rectangle of the + * toggle. If so, the event's propagation is stopped, and Picture-in-Picture is + * triggered. + * + * @param {Event} event The mousemove event. + */ + onPointerDown(event) { + // The toggle ignores non-primary mouse clicks. + if (event.button != 0) { + return; + } + + let video = this.getWeakOverVideo(); + if (!video) { + return; + } + + let shadowRoot = video.openOrClosedShadowRoot; + if (!shadowRoot) { + return; + } + + let state = this.docState; + let { clientX, clientY } = event; + let winUtils = this.contentWindow.windowUtils; + // We use winUtils.nodesFromRect instead of document.elementsFromPoint, + // since document.elementsFromPoint always flushes layout. The 1's in that + // function call are for the size of the rect that we want, which is 1x1. + // + // We pass the aOnlyVisible boolean argument to check that the video isn't + // occluded by anything visible at the point of mousedown. If it is, we'll + // ignore the mousedown. + let elements = winUtils.nodesFromRect( + clientX, + clientY, + 1, + 1, + 1, + 1, + true, + false, + true /* aOnlyVisible */, + state.toggleVisibilityThreshold + ); + if (!Array.from(elements).includes(video)) { + return; + } + + let toggle = this.getToggleElement(shadowRoot); + if (this.isMouseOverToggle(toggle, event)) { + state.isClickingToggle = true; + state.clickedElement = Cu.getWeakReference(event.originalTarget); + event.stopImmediatePropagation(); + + Services.telemetry.keyedScalarAdd( + "pictureinpicture.opened_method", + "toggle", + 1 + ); + + let pipEvent = new this.contentWindow.CustomEvent( + "MozTogglePictureInPicture", + { + bubbles: true, + } + ); + video.dispatchEvent(pipEvent); + + // Since we've initiated Picture-in-Picture, we can go ahead and + // hide the toggle now. + this.onMouseLeaveVideo(video); + } + } + + /** + * Called for mousedown, pointerup, mouseup and click events. If we + * detected that the user is clicking on the Picture-in-Picture toggle, + * these events are cancelled in the capture-phase before they reach + * content. The state for suppressing these events is cleared on the + * click event (unless the mouseup occurs on a different element from + * the mousedown, in which case, the state is cleared on mouseup). + * + * @param {Event} event A mousedown, pointerup, mouseup or click event. + */ + onMouseButtonEvent(event) { + // The toggle ignores non-primary mouse clicks. + if (event.button != 0) { + return; + } + + let state = this.docState; + if (state.isClickingToggle) { + event.stopImmediatePropagation(); + + // If this is a mouseup event, check to see if we have a record of what + // the original target was on pointerdown. If so, and if it doesn't match + // the mouseup original target, that means we won't get a click event, and + // we can clear the "clicking the toggle" state right away. + // + // Otherwise, we wait for the click event to do that. + let isMouseUpOnOtherElement = + event.type == "mouseup" && + (!state.clickedElement || + state.clickedElement.get() != event.originalTarget); + + if (isMouseUpOnOtherElement || event.type == "click") { + // The click is complete, so now we reset the state so that + // we stop suppressing these events. + state.isClickingToggle = false; + state.clickedElement = null; + } + } + } + + /** + * Called on mouseout events to determine whether or not the mouse has + * exited the window. + * + * @param {Event} event The mouseout event. + */ + onMouseOut(event) { + if (!event.relatedTarget) { + // For mouseout events, if there's no relatedTarget (which normally + // maps to the element that the mouse entered into) then this means that + // we left the window. + let video = this.getWeakOverVideo(); + if (!video) { + return; + } + + this.onMouseLeaveVideo(video); + } + } + + /** + * Called for each mousemove event when we're tracking those events to + * determine if the cursor is hovering over a <video>. + * + * @param {Event} event The mousemove event. + */ + onMouseMove(event) { + let state = this.docState; + + if (state.hideToggleDeferredTask) { + state.hideToggleDeferredTask.disarm(); + state.hideToggleDeferredTask.arm(); + } + + state.lastMouseMoveEvent = event; + state.mousemoveDeferredTask.arm(); + } + + /** + * Called by the DeferredTask after MOUSEMOVE_PROCESSING_DELAY_MS + * milliseconds. Checked to see if that mousemove happens to be overtop of + * any interesting <video> elements that we want to display the toggle + * on. If so, puts the toggle on that video. + */ + checkLastMouseMove() { + let state = this.docState; + let event = state.lastMouseMoveEvent; + let { clientX, clientY } = event; + let winUtils = this.contentWindow.windowUtils; + // We use winUtils.nodesFromRect instead of document.elementsFromPoint, + // since document.elementsFromPoint always flushes layout. The 1's in that + // function call are for the size of the rect that we want, which is 1x1. + let elements = winUtils.nodesFromRect( + clientX, + clientY, + 1, + 1, + 1, + 1, + true, + false, + true + ); + + for (let element of elements) { + if ( + state.weakVisibleVideos.has(element) && + !element.isCloningElementVisually + ) { + this.onMouseOverVideo(element, event); + return; + } + } + + let oldOverVideo = this.getWeakOverVideo(); + if (oldOverVideo) { + this.onMouseLeaveVideo(oldOverVideo); + } + } + + /** + * Called once it has been determined that the mouse is overtop of a video + * that is in the viewport. + * + * @param {Element} video The video the mouse is over. + */ + onMouseOverVideo(video, event) { + let oldOverVideo = this.getWeakOverVideo(); + let shadowRoot = video.openOrClosedShadowRoot; + + if (video != oldOverVideo) { + if (video.getTransformToViewport().a == -1) { + shadowRoot.firstChild.setAttribute("flipped", true); + } else { + shadowRoot.firstChild.removeAttribute("flipped"); + } + } + + // It seems from automated testing that if it's still very early on in the + // lifecycle of a <video> element, it might not yet have a shadowRoot, + // in which case, we can bail out here early. + if (!shadowRoot) { + if (oldOverVideo) { + // We also clear the hover state on the old video we were hovering, + // if there was one. + this.onMouseLeaveVideo(oldOverVideo); + } + + return; + } + + let state = this.docState; + let toggle = this.getToggleElement(shadowRoot); + let controlsOverlay = shadowRoot.querySelector(".controlsOverlay"); + + if (state.checkedPolicyDocumentURI != this.document.documentURI) { + state.togglePolicy = TOGGLE_POLICIES.DEFAULT; + // We cache the matchers process-wide. We'll skip this while running tests to make that + // easier. + let siteOverrides = this.toggleTesting + ? PictureInPictureToggleChild.getSiteOverrides() + : gSiteOverrides; + + // Do we have any toggle overrides? If so, try to apply them. + for (let [override, { policy, visibilityThreshold }] of siteOverrides) { + if ( + (policy || visibilityThreshold) && + override.matches(this.document.documentURI) + ) { + state.togglePolicy = policy || TOGGLE_POLICIES.DEFAULT; + state.toggleVisibilityThreshold = visibilityThreshold || 1.0; + break; + } + } + + state.checkedPolicyDocumentURI = this.document.documentURI; + } + + // The built-in <video> controls are along the bottom, which would overlap the + // toggle if the override is set to BOTTOM, so we ignore overrides that set + // a policy of BOTTOM for <video> elements with controls. + if ( + state.togglePolicy != TOGGLE_POLICIES.DEFAULT && + !(state.togglePolicy == TOGGLE_POLICIES.BOTTOM && video.controls) + ) { + toggle.setAttribute("policy", TOGGLE_POLICY_STRINGS[state.togglePolicy]); + } else { + toggle.removeAttribute("policy"); + } + + controlsOverlay.removeAttribute("hidetoggle"); + + // The hideToggleDeferredTask we create here is for automatically hiding + // the toggle after a period of no mousemove activity for + // TOGGLE_HIDING_TIMEOUT_MS. If the mouse moves, then the DeferredTask + // timer is reset. + // + // We disable the toggle hiding timeout during testing to reduce + // non-determinism from timers when testing the toggle. + if (!state.hideToggleDeferredTask && !this.toggleTesting) { + state.hideToggleDeferredTask = new DeferredTask(() => { + controlsOverlay.setAttribute("hidetoggle", true); + }, TOGGLE_HIDING_TIMEOUT_MS); + } + + if (oldOverVideo) { + if (oldOverVideo == video) { + // If we're still hovering the old video, we might have entered or + // exited the toggle region. + this.checkHoverToggle(toggle, event); + return; + } + + // We had an old video that we were hovering, and we're not hovering + // it anymore. Let's leave it. + this.onMouseLeaveVideo(oldOverVideo); + } + + state.weakOverVideo = Cu.getWeakReference(video); + controlsOverlay.classList.add("hovering"); + + if ( + state.togglePolicy != TOGGLE_POLICIES.HIDDEN && + !toggle.hasAttribute("hidden") + ) { + Services.telemetry.scalarAdd("pictureinpicture.saw_toggle", 1); + } + + // Now that we're hovering the video, we'll check to see if we're + // hovering the toggle too. + this.checkHoverToggle(toggle, event); + } + + /** + * Checks if a mouse event is happening over a toggle element. If it is, + * sets the hovering class on it. Otherwise, it clears the hovering + * class. + * + * @param {Element} toggle The Picture-in-Picture toggle to check. + * @param {MouseEvent} event A MouseEvent to test. + */ + checkHoverToggle(toggle, event) { + toggle.classList.toggle("hovering", this.isMouseOverToggle(toggle, event)); + } + + /** + * Called once it has been determined that the mouse is no longer overlapping + * a video that we'd previously called onMouseOverVideo with. + * + * @param {Element} video The video that the mouse left. + */ + onMouseLeaveVideo(video) { + let state = this.docState; + let shadowRoot = video.openOrClosedShadowRoot; + + if (shadowRoot) { + let controlsOverlay = shadowRoot.querySelector(".controlsOverlay"); + let toggle = this.getToggleElement(shadowRoot); + controlsOverlay.classList.remove("hovering"); + toggle.classList.remove("hovering"); + } + + state.weakOverVideo = null; + + if (!this.toggleTesting) { + state.hideToggleDeferredTask.disarm(); + state.mousemoveDeferredTask.disarm(); + } + + state.hideToggleDeferredTask = null; + } + + /** + * Given a reference to a Picture-in-Picture toggle element, determines + * if a MouseEvent event is occurring within its bounds. + * + * @param {Element} toggle The Picture-in-Picture toggle. + * @param {MouseEvent} event A MouseEvent to test. + * + * @return {Boolean} + */ + isMouseOverToggle(toggle, event) { + let toggleRect = toggle.ownerGlobal.windowUtils.getBoundsWithoutFlushing( + toggle + ); + + // The way the toggle is currently implemented with + // absolute positioning, the root toggle element bounds don't actually + // contain all of the toggle child element bounds. Until we find a way to + // sort that out, we workaround the issue by having each clickable child + // elements of the toggle have a clicklable class, and then compute the + // smallest rect that contains all of their bounding rects and use that + // as the hitbox. + toggleRect = Rect.fromRect(toggleRect); + let clickableChildren = toggle.querySelectorAll(".clickable"); + for (let child of clickableChildren) { + let childRect = Rect.fromRect( + child.ownerGlobal.windowUtils.getBoundsWithoutFlushing(child) + ); + toggleRect.expandToContain(childRect); + } + + // If the toggle has no dimensions, we're definitely not over it. + if (!toggleRect.width || !toggleRect.height) { + return false; + } + + let { clientX, clientY } = event; + + return ( + clientX >= toggleRect.left && + clientX <= toggleRect.right && + clientY >= toggleRect.top && + clientY <= toggleRect.bottom + ); + } + + /** + * Checks a contextmenu event to see if the mouse is currently over the + * Picture-in-Picture toggle. If so, sends a message to the parent process + * to open up the Picture-in-Picture toggle context menu. + * + * @param {MouseEvent} event A contextmenu event. + */ + checkContextMenu(event) { + let video = this.getWeakOverVideo(); + if (!video) { + return; + } + + let shadowRoot = video.openOrClosedShadowRoot; + if (!shadowRoot) { + return; + } + + let toggle = this.getToggleElement(shadowRoot); + if (this.isMouseOverToggle(toggle, event)) { + event.stopImmediatePropagation(); + event.preventDefault(); + + this.sendAsyncMessage("PictureInPicture:OpenToggleContextMenu", { + screenX: event.screenX, + screenY: event.screenY, + mozInputSource: event.mozInputSource, + }); + } + } + + /** + * Returns the appropriate root element for the Picture-in-Picture toggle, + * depending on whether or not we're using the experimental toggle preference. + * + * @param {Element} shadowRoot The shadowRoot of the video element. + * @returns {Element} The toggle element. + */ + getToggleElement(shadowRoot) { + return shadowRoot.getElementById("pictureInPictureToggle"); + } + + /** + * This is a test-only function that returns true if a video is being tracked + * for mouseover events after having intersected the viewport. + */ + static isTracking(video) { + return gWeakIntersectingVideosForTesting.has(video); + } + + /** + * Gets any Picture-in-Picture site-specific overrides stored in the + * sharedData struct, and returns them as an Array of two-element Arrays, + * where the first element is a MatchPattern and the second element is an + * object of the form { policy, keyboardControls } (where each property + * may be missing or undefined). + * + * @returns {Array<Array<2>>} Array of 2-element Arrays where the first element + * is a MatchPattern and the second element is an object with optional policy + * and/or keyboardControls properties. + */ + static getSiteOverrides() { + let result = []; + let patterns = Services.cpmm.sharedData.get( + "PictureInPicture:SiteOverrides" + ); + for (let pattern in patterns) { + let matcher = new MatchPattern(pattern); + result.push([matcher, patterns[pattern]]); + } + return result; + } +} + +class PictureInPictureChild extends JSWindowActorChild { + // A weak reference to this PiP window's video element + weakVideo = null; + + // A weak reference to this PiP window's content window + weakPlayerContent = null; + + /** + * Returns a reference to the PiP's <video> element being displayed in Picture-in-Picture + * mode. + * + * @return {Element} The <video> being displayed in Picture-in-Picture mode, or null + * if that <video> no longer exists. + */ + getWeakVideo() { + if (this.weakVideo) { + // Bug 800957 - Accessing weakrefs at the wrong time can cause us to + // throw NS_ERROR_XPC_BAD_CONVERT_NATIVE + try { + return this.weakVideo.get(); + } catch (e) { + return null; + } + } + return null; + } + + /** + * Returns a reference to the inner window of the about:blank document that is + * cloning the originating <video> in the always-on-top player <xul:browser>. + * + * @return {Window} The inner window of the about:blank player <xul:browser>, or + * null if that window has been closed. + */ + getWeakPlayerContent() { + if (this.weakPlayerContent) { + // Bug 800957 - Accessing weakrefs at the wrong time can cause us to + // throw NS_ERROR_XPC_BAD_CONVERT_NATIVE + try { + return this.weakPlayerContent.get(); + } catch (e) { + return null; + } + } + return null; + } + + /** + * Returns true if the passed video happens to be the one that this + * content process is running in a Picture-in-Picture window. + * + * @param {Element} video The <video> element to check. + * + * @return {Boolean} + */ + inPictureInPicture(video) { + return this.getWeakVideo() === video; + } + + static videoIsPlaying(video) { + return !!(!video.paused && !video.ended && video.readyState > 2); + } + + static videoIsMuted(video) { + return video.muted; + } + + handleEvent(event) { + switch (event.type) { + case "MozStopPictureInPicture": { + if (event.isTrusted && event.target === this.getWeakVideo()) { + const reason = event.detail?.reason || "video-el-remove"; + this.closePictureInPicture({ reason }); + } + break; + } + case "pagehide": { + // The originating video's content document has unloaded, + // so close Picture-in-Picture. + this.closePictureInPicture({ reason: "pagehide" }); + break; + } + case "MozDOMFullscreen:Request": { + this.closePictureInPicture({ reason: "fullscreen" }); + break; + } + case "play": { + this.sendAsyncMessage("PictureInPicture:Playing"); + break; + } + case "pause": { + this.sendAsyncMessage("PictureInPicture:Paused"); + break; + } + case "volumechange": { + let video = this.getWeakVideo(); + + // Just double-checking that we received the event for the right + // video element. + if (video !== event.target) { + Cu.reportError( + "PictureInPictureChild received volumechange for " + + "the wrong video!" + ); + return; + } + + if (video.muted) { + this.sendAsyncMessage("PictureInPicture:Muting"); + } else { + this.sendAsyncMessage("PictureInPicture:Unmuting"); + } + break; + } + case "resize": { + let video = event.target; + if (this.inPictureInPicture(video)) { + this.sendAsyncMessage("PictureInPicture:Resize", { + videoHeight: video.videoHeight, + videoWidth: video.videoWidth, + }); + } + break; + } + } + } + + /** + * Tells the parent to close a pre-existing Picture-in-Picture + * window. + * + * @return {Promise} + * + * @resolves {undefined} Once the pre-existing Picture-in-Picture + * window has unloaded. + */ + async closePictureInPicture({ reason }) { + let video = this.getWeakVideo(); + if (video) { + this.untrackOriginatingVideo(video); + } + this.sendAsyncMessage("PictureInPicture:Close", { + reason, + }); + + let playerContent = this.getWeakPlayerContent(); + if (playerContent) { + if (!playerContent.closed) { + await new Promise(resolve => { + playerContent.addEventListener("unload", resolve, { + once: true, + }); + }); + } + // Nothing should be holding a reference to the Picture-in-Picture + // player window content at this point, but just in case, we'll + // clear the weak reference directly so nothing else can get a hold + // of it from this angle. + this.weakPlayerContent = null; + } + } + + receiveMessage(message) { + switch (message.name) { + case "PictureInPicture:SetupPlayer": { + const { videoRef } = message.data; + this.setupPlayer(videoRef); + break; + } + case "PictureInPicture:Play": { + this.play(); + break; + } + case "PictureInPicture:Pause": { + if (message.data && message.data.reason == "pip-closed") { + let video = this.getWeakVideo(); + + // Currently in Firefox srcObjects are MediaStreams. However, by spec a srcObject + // can be either a MediaStream, MediaSource or Blob. In case of future changes + // we do not want to pause MediaStream srcObjects and we want to maintain current + // behavior for non-MediaStream srcObjects. + if (video && video.srcObject instanceof MediaStream) { + break; + } + } + this.pause(); + break; + } + case "PictureInPicture:Mute": { + this.mute(); + break; + } + case "PictureInPicture:Unmute": { + this.unmute(); + break; + } + case "PictureInPicture:KeyDown": { + this.keyDown(message.data); + break; + } + } + } + + /** + * Keeps an eye on the originating video's document. If it ever + * goes away, this will cause the Picture-in-Picture window for any + * of its content to go away as well. + */ + trackOriginatingVideo(originatingVideo) { + let originatingWindow = originatingVideo.ownerGlobal; + if (originatingWindow) { + originatingWindow.addEventListener("pagehide", this); + originatingVideo.addEventListener("play", this); + originatingVideo.addEventListener("pause", this); + originatingVideo.addEventListener("volumechange", this); + originatingVideo.addEventListener("resize", this); + + let chromeEventHandler = originatingWindow.docShell.chromeEventHandler; + chromeEventHandler.addEventListener( + "MozDOMFullscreen:Request", + this, + true + ); + chromeEventHandler.addEventListener( + "MozStopPictureInPicture", + this, + true + ); + } + } + + /** + * Stops tracking the originating video's document. This should + * happen once the Picture-in-Picture window goes away (or is about + * to go away), and we no longer care about hearing when the originating + * window's document unloads. + */ + untrackOriginatingVideo(originatingVideo) { + let originatingWindow = originatingVideo.ownerGlobal; + if (originatingWindow) { + originatingWindow.removeEventListener("pagehide", this); + originatingVideo.removeEventListener("play", this); + originatingVideo.removeEventListener("pause", this); + originatingVideo.removeEventListener("volumechange", this); + originatingVideo.removeEventListener("resize", this); + + let chromeEventHandler = originatingWindow.docShell.chromeEventHandler; + chromeEventHandler.removeEventListener( + "MozDOMFullscreen:Request", + this, + true + ); + chromeEventHandler.removeEventListener( + "MozStopPictureInPicture", + this, + true + ); + } + } + + /** + * Runs in an instance of PictureInPictureChild for the + * player window's content, and not the originating video + * content. Sets up the player so that it clones the originating + * video. If anything goes wrong during set up, a message is + * sent to the parent to close the Picture-in-Picture window. + * + * @param videoRef {ContentDOMReference} + * A reference to the video element that a Picture-in-Picture window + * is being created for + * @return {Promise} + * @resolves {undefined} Once the player window has been set up + * properly, or a pre-existing Picture-in-Picture window has gone + * away due to an unexpected error. + */ + async setupPlayer(videoRef) { + const video = await ContentDOMReference.resolve(videoRef); + + this.weakVideo = Cu.getWeakReference(video); + let originatingVideo = this.getWeakVideo(); + if (!originatingVideo) { + // If the video element has gone away before we've had a chance to set up + // Picture-in-Picture for it, tell the parent to close the Picture-in-Picture + // window. + await this.closePictureInPicture({ reason: "setup-failure" }); + return; + } + + let loadPromise = new Promise(resolve => { + this.contentWindow.addEventListener("load", resolve, { + once: true, + mozSystemGroup: true, + capture: true, + }); + }); + this.contentWindow.location.reload(); + await loadPromise; + + // We're committed to adding the video to this window now. Ensure we track + // the content window before we do so, so that the toggle actor can + // distinguish this new video we're creating from web-controlled ones. + this.weakPlayerContent = Cu.getWeakReference(this.contentWindow); + gPlayerContents.add(this.contentWindow); + + let doc = this.document; + let playerVideo = doc.createElement("video"); + + doc.body.style.overflow = "hidden"; + doc.body.style.margin = "0"; + + // Force the player video to assume maximum height and width of the + // containing window + playerVideo.style.height = "100vh"; + playerVideo.style.width = "100vw"; + playerVideo.style.backgroundColor = "#000"; + + doc.body.appendChild(playerVideo); + + originatingVideo.cloneElementVisually(playerVideo); + + let shadowRoot = originatingVideo.openOrClosedShadowRoot; + if (originatingVideo.getTransformToViewport().a == -1) { + shadowRoot.firstChild.setAttribute("flipped", true); + playerVideo.style.transform = "scaleX(-1)"; + } + + this.trackOriginatingVideo(originatingVideo); + + this.contentWindow.addEventListener( + "unload", + () => { + let video = this.getWeakVideo(); + if (video) { + this.untrackOriginatingVideo(video); + video.stopCloningElementVisually(); + } + this.weakVideo = null; + }, + { once: true } + ); + } + + play() { + let video = this.getWeakVideo(); + if (video) { + video.play(); + } + } + + pause() { + let video = this.getWeakVideo(); + if (video) { + video.pause(); + } + } + + mute() { + let video = this.getWeakVideo(); + if (video) { + video.muted = true; + } + } + + unmute() { + let video = this.getWeakVideo(); + if (video) { + video.muted = false; + } + } + + /** + * This checks if a given keybinding has been disabled for the specific site + * currently being viewed. + */ + isKeyEnabled(key) { + const video = this.getWeakVideo(); + if (!video) { + return false; + } + const { documentURI } = video.ownerDocument; + if (!documentURI) { + return true; + } + for (let [override, { keyboardControls }] of gSiteOverrides) { + if (keyboardControls !== undefined && override.matches(documentURI)) { + if (keyboardControls === KEYBOARD_CONTROLS.NONE) { + return false; + } + return keyboardControls & key; + } + } + return true; + } + + /** + * This reuses the keyHandler logic in the VideoControlsWidget + * https://searchfox.org/mozilla-central/rev/cfd1cc461f1efe0d66c2fdc17c024a203d5a2fd8/toolkit/content/widgets/videocontrols.js#1687-1810. + * There are future plans to eventually combine the two implementations. + */ + /* eslint-disable complexity */ + keyDown({ altKey, shiftKey, metaKey, ctrlKey, keyCode }) { + let video = this.getWeakVideo(); + if (!video) { + return; + } + + var keystroke = ""; + if (altKey) { + keystroke += "alt-"; + } + if (shiftKey) { + keystroke += "shift-"; + } + if (this.contentWindow.navigator.platform.startsWith("Mac")) { + if (metaKey) { + keystroke += "accel-"; + } + if (ctrlKey) { + keystroke += "control-"; + } + } else { + if (metaKey) { + keystroke += "meta-"; + } + if (ctrlKey) { + keystroke += "accel-"; + } + } + + switch (keyCode) { + case this.contentWindow.KeyEvent.DOM_VK_UP: + keystroke += "upArrow"; + break; + case this.contentWindow.KeyEvent.DOM_VK_DOWN: + keystroke += "downArrow"; + break; + case this.contentWindow.KeyEvent.DOM_VK_LEFT: + keystroke += "leftArrow"; + break; + case this.contentWindow.KeyEvent.DOM_VK_RIGHT: + keystroke += "rightArrow"; + break; + case this.contentWindow.KeyEvent.DOM_VK_HOME: + keystroke += "home"; + break; + case this.contentWindow.KeyEvent.DOM_VK_END: + keystroke += "end"; + break; + case this.contentWindow.KeyEvent.DOM_VK_SPACE: + keystroke += "space"; + break; + } + + const isVideoStreaming = video.duration == +Infinity; + var oldval, newval; + + try { + switch (keystroke) { + case "space" /* Toggle Play / Pause */: + if (!this.isKeyEnabled(KEYBOARD_CONTROLS.PLAY_PAUSE)) { + return; + } + if (video.paused || video.ended) { + video.play(); + } else { + video.pause(); + } + break; + case "downArrow" /* Volume decrease */: + if (!this.isKeyEnabled(KEYBOARD_CONTROLS.VOLUME)) { + return; + } + oldval = video.volume; + video.volume = oldval < 0.1 ? 0 : oldval - 0.1; + video.muted = false; + break; + case "upArrow" /* Volume increase */: + if (!this.isKeyEnabled(KEYBOARD_CONTROLS.VOLUME)) { + return; + } + oldval = video.volume; + video.volume = oldval > 0.9 ? 1 : oldval + 0.1; + video.muted = false; + break; + case "accel-downArrow" /* Mute */: + if (!this.isKeyEnabled(KEYBOARD_CONTROLS.MUTE_UNMUTE)) { + return; + } + video.muted = true; + break; + case "accel-upArrow" /* Unmute */: + if (!this.isKeyEnabled(KEYBOARD_CONTROLS.MUTE_UNMUTE)) { + return; + } + video.muted = false; + break; + case "leftArrow": /* Seek back 15 seconds */ + case "accel-leftArrow" /* Seek back 10% */: + if (isVideoStreaming || !this.isKeyEnabled(KEYBOARD_CONTROLS.SEEK)) { + return; + } + + oldval = video.currentTime; + if (keystroke == "leftArrow") { + newval = oldval - 15; + } else { + newval = oldval - video.duration / 10; + } + video.currentTime = newval >= 0 ? newval : 0; + break; + case "rightArrow": /* Seek forward 15 seconds */ + case "accel-rightArrow" /* Seek forward 10% */: + if (isVideoStreaming || !this.isKeyEnabled(KEYBOARD_CONTROLS.SEEK)) { + return; + } + + oldval = video.currentTime; + var maxtime = video.duration; + if (keystroke == "rightArrow") { + newval = oldval + 15; + } else { + newval = oldval + maxtime / 10; + } + video.currentTime = newval <= maxtime ? newval : maxtime; + break; + case "home" /* Seek to beginning */: + if (!this.isKeyEnabled(KEYBOARD_CONTROLS.SEEK)) { + return; + } + if (!isVideoStreaming) { + video.currentTime = 0; + } + break; + case "end" /* Seek to end */: + if (!this.isKeyEnabled(KEYBOARD_CONTROLS.SEEK)) { + return; + } + if (!isVideoStreaming && video.currentTime != video.duration) { + video.currentTime = video.duration; + } + break; + default: + } + } catch (e) { + /* ignore any exception from setting video.currentTime */ + } + } +} diff --git a/toolkit/actors/PopupBlockingChild.jsm b/toolkit/actors/PopupBlockingChild.jsm new file mode 100644 index 0000000000..0d4b465229 --- /dev/null +++ b/toolkit/actors/PopupBlockingChild.jsm @@ -0,0 +1,159 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* 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/. */ + +/* eslint no-unused-vars: ["error", {args: "none"}] */ + +var EXPORTED_SYMBOLS = ["PopupBlockingChild"]; + +// The maximum number of popup information we'll send to the parent. +const MAX_SENT_POPUPS = 15; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +class PopupBlockingChild extends JSWindowActorChild { + constructor() { + super(); + this.weakDocStates = new WeakMap(); + } + + actorCreated() { + this.contentWindow.addEventListener("pageshow", this); + } + + didDestroy() { + this.contentWindow.removeEventListener("pageshow", this); + } + + /** + * Returns the state for the current document referred to via + * this.document. If no such state exists, creates it, stores it + * and returns it. + */ + get docState() { + let state = this.weakDocStates.get(this.document); + if (!state) { + state = { + popupData: [], + }; + this.weakDocStates.set(this.document, state); + } + + return state; + } + + receiveMessage(msg) { + switch (msg.name) { + case "UnblockPopup": { + let i = msg.data.index; + let state = this.docState; + let popupData = state.popupData[i]; + if (popupData) { + let dwi = popupData.requestingWindow; + + // If we have a requesting window and the requesting document is + // still the current document, open the popup. + if (dwi && dwi.document == popupData.requestingDocument) { + dwi.open( + popupData.popupWindowURISpec, + popupData.popupWindowName, + popupData.popupWindowFeatures + ); + } + } + break; + } + + case "GetBlockedPopupList": { + let state = this.docState; + let length = Math.min(state.popupData.length, MAX_SENT_POPUPS); + + let result = []; + + for (let i = 0; i < length; ++i) { + let popup = state.popupData[i]; + + let popupWindowURISpec = popup.popupWindowURISpec; + + if (this.contentWindow.location.href == popupWindowURISpec) { + popupWindowURISpec = "<self>"; + } else { + // Limit 500 chars to be sent because the URI will be cropped + // by the UI anyway, and data: URIs can be significantly larger. + popupWindowURISpec = popupWindowURISpec.substring(0, 500); + } + + result.push({ + popupWindowURISpec, + }); + } + + return result; + } + } + + return null; + } + + handleEvent(event) { + switch (event.type) { + case "DOMPopupBlocked": + this.onPopupBlocked(event); + break; + case "pageshow": { + this.onPageShow(event); + break; + } + } + } + + onPopupBlocked(event) { + if (event.target != this.document) { + return; + } + + let state = this.docState; + + // Avoid spamming the parent process with too many blocked popups. + if (state.popupData.length >= PopupBlockingChild.maxReportedPopups) { + return; + } + + let popup = { + popupWindowURISpec: event.popupWindowURI + ? event.popupWindowURI.spec + : "about:blank", + popupWindowFeatures: event.popupWindowFeatures, + popupWindowName: event.popupWindowName, + requestingWindow: event.requestingWindow, + requestingDocument: event.requestingWindow.document, + }; + + state.popupData.push(popup); + this.updateBlockedPopups(true); + } + + onPageShow(event) { + if (event.target != this.document) { + return; + } + + this.updateBlockedPopups(false); + } + + updateBlockedPopups(shouldNotify) { + this.sendAsyncMessage("UpdateBlockedPopups", { + shouldNotify, + count: this.docState.popupData.length, + }); + } +} + +XPCOMUtils.defineLazyPreferenceGetter( + PopupBlockingChild, + "maxReportedPopups", + "privacy.popups.maxReported" +); diff --git a/toolkit/actors/PopupBlockingParent.jsm b/toolkit/actors/PopupBlockingParent.jsm new file mode 100644 index 0000000000..8c7dc4ab33 --- /dev/null +++ b/toolkit/actors/PopupBlockingParent.jsm @@ -0,0 +1,271 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["PopupBlocker", "PopupBlockingParent"]; + +/** + * This class manages all popup blocking operations on a <xul:browser>, including + * notifying the UI about updates to the blocked popups, and allowing popups to + * be unblocked. + */ +class PopupBlocker { + constructor(browser) { + this._browser = browser; + this._allBlockedPopupCounts = new WeakMap(); + this._shouldShowNotification = false; + } + + /** + * Returns whether or not there are new blocked popups for the associated + * <xul:browser> that the user might need to be notified about. + */ + get shouldShowNotification() { + return this._shouldShowNotification; + } + + /** + * Should be called by the UI when the user has been notified about blocked + * popups for the associated <xul:browser>. + */ + didShowNotification() { + this._shouldShowNotification = false; + } + + /** + * Synchronously returns the most recent count of blocked popups for + * the associated <xul:browser>. + * + * @return {Number} + * The total number of blocked popups for this <xul:browser>. + */ + getBlockedPopupCount() { + let totalBlockedPopups = 0; + + let contextsToVisit = [this._browser.browsingContext]; + while (contextsToVisit.length) { + let currentBC = contextsToVisit.pop(); + let windowGlobal = currentBC.currentWindowGlobal; + + if (!windowGlobal) { + continue; + } + + let popupCountForGlobal = + this._allBlockedPopupCounts.get(windowGlobal) || 0; + totalBlockedPopups += popupCountForGlobal; + contextsToVisit.push(...currentBC.children); + } + + return totalBlockedPopups; + } + + /** + * Asynchronously retrieve information about the popups that have + * been blocked for the associated <xul:browser>. This information + * can be used to unblock those popups. + * + * @return {Promise} + * @resolves {Array} + * When the blocked popup information has been gathered, + * resolves with an Array of Objects with the following properties: + * + * browsingContext {BrowsingContext} + * The BrowsingContext that the popup was blocked for. + * + * innerWindowId {Number} + * The inner window ID for the blocked popup. This is used to differentiate + * popups that were blocked from one page load to the next. + * + * popupWindowURISpec {String} + * A string representing part or all of the URI that tried to be opened in a + * popup. + */ + async getBlockedPopups() { + let contextsToVisit = [this._browser.browsingContext]; + let result = []; + while (contextsToVisit.length) { + let currentBC = contextsToVisit.pop(); + let windowGlobal = currentBC.currentWindowGlobal; + + if (!windowGlobal) { + continue; + } + + let popupCountForGlobal = + this._allBlockedPopupCounts.get(windowGlobal) || 0; + if (popupCountForGlobal) { + let actor = windowGlobal.getActor("PopupBlocking"); + let popups = await actor.sendQuery("GetBlockedPopupList"); + + for (let popup of popups) { + if (!popup.popupWindowURISpec) { + continue; + } + + result.push({ + browsingContext: currentBC, + innerWindowId: windowGlobal.innerWindowId, + popupWindowURISpec: popup.popupWindowURISpec, + }); + } + } + + contextsToVisit.push(...currentBC.children); + } + + return result; + } + + /** + * Unblocks a popup that had been blocked. The information passed should + * come from the list of blocked popups returned via getBlockedPopups(). + * + * Unblocking a popup causes that popup to open. + * + * @param browsingContext {BrowsingContext} + * The BrowsingContext that the popup was blocked for. + * + * @param innerWindowId {Number} + * The inner window ID for the blocked popup. This is used to differentiate popups + * that were blocked from one page load to the next. + * + * @param popupIndex {Number} + * The index of the entry in the Array returned by getBlockedPopups(). + */ + unblockPopup(browsingContext, innerWindowId, popupIndex) { + let popupFrame = browsingContext.top.embedderElement; + let popupBrowser = popupFrame.outerBrowser + ? popupFrame.outerBrowser + : popupFrame; + + if (this._browser != popupBrowser) { + throw new Error( + "Attempting to unblock popup in a BrowsingContext no longer hosted in this browser." + ); + } + + let windowGlobal = browsingContext.currentWindowGlobal; + + if (!windowGlobal || windowGlobal.innerWindowId != innerWindowId) { + // The inner window has moved on since the user clicked on + // the blocked popups dropdown, so we'll just exit silently. + return; + } + + let actor = browsingContext.currentWindowGlobal.getActor("PopupBlocking"); + actor.sendAsyncMessage("UnblockPopup", { index: popupIndex }); + } + + /** + * Goes through the most recent list of blocked popups for the associated + * <xul:browser> and unblocks all of them. Unblocking a popup causes the popup + * to open. + */ + async unblockAllPopups() { + let popups = await this.getBlockedPopups(); + for (let i = 0; i < popups.length; ++i) { + let popup = popups[i]; + this.unblockPopup(popup.browsingContext, popup.innerWindowId, i); + } + } + + /** + * Fires a DOMUpdateBlockedPopups chrome-only event so that the UI can + * update itself to represent the current state of popup blocking for + * the associated <xul:browser>. + */ + updateBlockedPopupsUI() { + let event = this._browser.ownerDocument.createEvent("Events"); + event.initEvent("DOMUpdateBlockedPopups", true, true); + this._browser.dispatchEvent(event); + } + + /** Private methods **/ + + /** + * Updates the current popup count for a particular BrowsingContext based + * on messages from the underlying process. + * + * This should only be called by a PopupBlockingParent instance. + * + * @param browsingContext {BrowsingContext} + * The BrowsingContext to update the internal blocked popup count for. + * + * @param blockedPopupData {Object} + * An Object representing information about how many popups are blocked + * for the BrowsingContext. The Object has the following properties: + * + * count {Number} + * The total number of blocked popups for the BrowsingContext. + * + * shouldNotify {Boolean} + * Whether or not the list of blocked popups has changed in such a way that + * the UI should be updated about it. + */ + _updateBlockedPopupEntries(browsingContext, blockedPopupData) { + let windowGlobal = browsingContext.currentWindowGlobal; + let { count, shouldNotify } = blockedPopupData; + + if (!this.shouldShowNotification && shouldNotify) { + this._shouldShowNotification = true; + } + + if (windowGlobal) { + this._allBlockedPopupCounts.set(windowGlobal, count); + } + + this.updateBlockedPopupsUI(); + } +} + +/** + * To keep things properly encapsulated, these should only be instantiated via + * the PopupBlocker class for a particular <xul:browser>. + * + * Instantiated for a WindowGlobalParent for a BrowsingContext in one of two cases: + * + * 1. One or more popups have been blocked for the underlying frame represented + * by the WindowGlobalParent. + * + * 2. Something in the parent process is querying a frame for information about + * any popups that may have been blocked inside of it. + */ +class PopupBlockingParent extends JSWindowActorParent { + didDestroy() { + this.updatePopupCountForBrowser({ count: 0, shouldNotify: false }); + } + + receiveMessage(message) { + if (message.name == "UpdateBlockedPopups") { + this.updatePopupCountForBrowser({ + count: message.data.count, + shouldNotify: message.data.shouldNotify, + }); + } + } + + /** + * Updates the PopupBlocker for the <xul:browser> associated with this + * PopupBlockingParent with the most recent count of blocked popups. + * + * @param data {Object} + * An Object with the following properties: + * + * count {Number}: + * The number of blocked popups for the underlying document. + * + * shouldNotify {Boolean}: + * Whether or not the list of blocked popups has changed in such a way that + * the UI should be updated about it. + */ + updatePopupCountForBrowser(data) { + let browser = this.browsingContext.top.embedderElement; + if (!browser) { + return; + } + + browser.popupBlocker._updateBlockedPopupEntries(this.browsingContext, data); + } +} diff --git a/toolkit/actors/PrintingChild.jsm b/toolkit/actors/PrintingChild.jsm new file mode 100644 index 0000000000..e03aacc053 --- /dev/null +++ b/toolkit/actors/PrintingChild.jsm @@ -0,0 +1,498 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["PrintingChild"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +ChromeUtils.defineModuleGetter( + this, + "setTimeout", + "resource://gre/modules/Timer.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "ReaderMode", + "resource://gre/modules/ReaderMode.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "DeferredTask", + "resource://gre/modules/DeferredTask.jsm" +); + +let gPrintPreviewInitializingInfo = null; + +let gPendingPreviewsMap = new Map(); + +class PrintingChild extends JSWindowActorChild { + actorCreated() { + // When the print preview page is loaded, the actor will change, so update + // the state/progress listener to the new actor. + let listener = gPendingPreviewsMap.get(this.browsingContext.id); + if (listener) { + listener.actor = this; + } + this.contentWindow.addEventListener("scroll", this); + } + + didDestroy() { + this._scrollTask?.disarm(); + this.contentWindow?.removeEventListener("scroll", this); + } + + // Bug 1088061: nsPrintJob's DoCommonPrint currently expects the + // progress listener passed to it to QI to an nsIPrintingPromptService + // in order to know that a printing progress dialog has been shown. That's + // really all the interface is used for, hence the fact that I don't actually + // implement the interface here. Bug 1088061 has been filed to remove + // this hackery. + + get shouldSavePrintSettings() { + return Services.prefs.getBoolPref("print.save_print_settings"); + } + + handleEvent(event) { + switch (event.type) { + case "PrintingError": { + let win = event.target.defaultView; + let wbp = win.getInterface(Ci.nsIWebBrowserPrint); + let nsresult = event.detail; + this.sendAsyncMessage("Printing:Error", { + isPrinting: wbp.doingPrint, + nsresult, + }); + break; + } + + case "printPreviewUpdate": { + let info = gPrintPreviewInitializingInfo; + if (!info) { + // If there is no gPrintPreviewInitializingInfo then we did not + // initiate the preview so ignore this event. + return; + } + + // Only send Printing:Preview:Entered message on first update, indicated + // by gPrintPreviewInitializingInfo.entered not being set. + if (!info.entered) { + gPendingPreviewsMap.delete(this.browsingContext.id); + + info.entered = true; + this.sendAsyncMessage("Printing:Preview:Entered", { + failed: false, + changingBrowsers: info.changingBrowsers, + }); + + // If we have another request waiting, dispatch it now. + if (info.nextRequest) { + Services.tm.dispatchToMainThread(info.nextRequest); + } + } + + // Always send page count update. + this.updatePageCount(); + break; + } + + case "scroll": + if (!this._scrollTask) { + this._scrollTask = new DeferredTask( + () => this.updateCurrentPage(), + 16, + 16 + ); + } + this._scrollTask.arm(); + break; + } + } + + receiveMessage(message) { + let data = message.data; + switch (message.name) { + case "Printing:Preview:Enter": { + this.enterPrintPreview( + BrowsingContext.get(data.browsingContextId), + data.simplifiedMode, + data.changingBrowsers, + data.lastUsedPrinterName + ); + break; + } + + case "Printing:Preview:Exit": { + this.exitPrintPreview(); + break; + } + + case "Printing:Preview:Navigate": { + this.navigate(data.navType, data.pageNum); + break; + } + + case "Printing:Preview:ParseDocument": { + return this.parseDocument( + data.URL, + Services.wm.getOuterWindowWithId(data.windowID) + ); + } + } + + return undefined; + } + + getPrintSettings(lastUsedPrinterName) { + try { + let PSSVC = Cc["@mozilla.org/gfx/printsettings-service;1"].getService( + Ci.nsIPrintSettingsService + ); + + let printSettings = PSSVC.newPrintSettings; + if (!printSettings.printerName) { + printSettings.printerName = lastUsedPrinterName; + } + // First get any defaults from the printer + PSSVC.initPrintSettingsFromPrinter( + printSettings.printerName, + printSettings + ); + // now augment them with any values from last time + PSSVC.initPrintSettingsFromPrefs( + printSettings, + true, + printSettings.kInitSaveAll + ); + + return printSettings; + } catch (e) { + Cu.reportError(e); + } + + return null; + } + + async parseDocument(URL, contentWindow) { + // The document in 'contentWindow' will be simplified and the resulting nodes + // will be inserted into this.contentWindow. + let thisWindow = this.contentWindow; + + // By using ReaderMode primitives, we parse given document and place the + // resulting JS object into the DOM of current browser. + let article; + try { + article = await ReaderMode.parseDocument(contentWindow.document); + } catch (ex) { + Cu.reportError(ex); + } + + // We make use of a web progress listener in order to know when the content we inject + // into the DOM has finished rendering. If our layout engine is still painting, we + // will wait for MozAfterPaint event to be fired. + let actor = thisWindow.windowGlobalChild.getActor("Printing"); + let webProgressListener = { + onStateChange(webProgress, req, flags, status) { + if (flags & Ci.nsIWebProgressListener.STATE_STOP) { + webProgress.removeProgressListener(webProgressListener); + let domUtils = contentWindow.windowUtils; + // Here we tell the parent that we have parsed the document successfully + // using ReaderMode primitives and we are able to enter on preview mode. + if (domUtils.isMozAfterPaintPending) { + let onPaint = function() { + contentWindow.removeEventListener("MozAfterPaint", onPaint); + actor.sendAsyncMessage("Printing:Preview:ReaderModeReady"); + }; + contentWindow.addEventListener("MozAfterPaint", onPaint); + // This timer is needed for when display list invalidation doesn't invalidate. + setTimeout(() => { + contentWindow.removeEventListener("MozAfterPaint", onPaint); + actor.sendAsyncMessage("Printing:Preview:ReaderModeReady"); + }, 100); + } else { + actor.sendAsyncMessage("Printing:Preview:ReaderModeReady"); + } + } + }, + + QueryInterface: ChromeUtils.generateQI([ + "nsIWebProgressListener", + "nsISupportsWeakReference", + "nsIObserver", + ]), + }; + + // Here we QI the docShell into a nsIWebProgress passing our web progress listener in. + let webProgress = thisWindow.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + webProgress.addProgressListener( + webProgressListener, + Ci.nsIWebProgress.NOTIFY_STATE_REQUEST + ); + + let document = thisWindow.document; + document.head.innerHTML = ""; + + // Set base URI of document. Print preview code will read this value to + // populate the URL field in print settings so that it doesn't show + // "about:blank" as its URI. + let headBaseElement = document.createElement("base"); + headBaseElement.setAttribute("href", URL); + document.head.appendChild(headBaseElement); + + // Create link element referencing aboutReader.css and append it to head + let headStyleElement = document.createElement("link"); + headStyleElement.setAttribute("rel", "stylesheet"); + headStyleElement.setAttribute( + "href", + "chrome://global/skin/aboutReader.css" + ); + headStyleElement.setAttribute("type", "text/css"); + document.head.appendChild(headStyleElement); + + // Create link element referencing simplifyMode.css and append it to head + headStyleElement = document.createElement("link"); + headStyleElement.setAttribute("rel", "stylesheet"); + headStyleElement.setAttribute( + "href", + "chrome://global/content/simplifyMode.css" + ); + headStyleElement.setAttribute("type", "text/css"); + document.head.appendChild(headStyleElement); + + document.body.innerHTML = ""; + + // Create container div (main element) and append it to body + let containerElement = document.createElement("div"); + containerElement.setAttribute("id", "container"); + document.body.appendChild(containerElement); + + // Reader Mode might return null if there's a failure when parsing the document. + // We'll render the error message for the Simplify Page document when that happens. + if (article) { + // Set title of document + document.title = article.title; + + // Create header div and append it to container + let headerElement = document.createElement("div"); + headerElement.setAttribute("id", "reader-header"); + headerElement.setAttribute("class", "header"); + containerElement.appendChild(headerElement); + + // Jam the article's title and byline into header div + let titleElement = document.createElement("h1"); + titleElement.setAttribute("id", "reader-title"); + titleElement.textContent = article.title; + headerElement.appendChild(titleElement); + + let bylineElement = document.createElement("div"); + bylineElement.setAttribute("id", "reader-credits"); + bylineElement.setAttribute("class", "credits"); + bylineElement.textContent = article.byline; + headerElement.appendChild(bylineElement); + + // Display header element + headerElement.style.display = "block"; + + // Create content div and append it to container + let contentElement = document.createElement("div"); + contentElement.setAttribute("class", "content"); + containerElement.appendChild(contentElement); + + // Jam the article's content into content div + let readerContent = document.createElement("div"); + readerContent.setAttribute("id", "moz-reader-content"); + contentElement.appendChild(readerContent); + + let articleUri = Services.io.newURI(article.url); + let parserUtils = Cc["@mozilla.org/parserutils;1"].getService( + Ci.nsIParserUtils + ); + let contentFragment = parserUtils.parseFragment( + article.content, + Ci.nsIParserUtils.SanitizerDropForms | + Ci.nsIParserUtils.SanitizerAllowStyle, + false, + articleUri, + readerContent + ); + + readerContent.appendChild(contentFragment); + + // Display reader content element + readerContent.style.display = "block"; + } else { + let aboutReaderStrings = Services.strings.createBundle( + "chrome://global/locale/aboutReader.properties" + ); + let errorMessage = aboutReaderStrings.GetStringFromName( + "aboutReader.loadError" + ); + + document.title = errorMessage; + + // Create reader message div and append it to body + let readerMessageElement = document.createElement("div"); + readerMessageElement.setAttribute("class", "reader-message"); + readerMessageElement.textContent = errorMessage; + containerElement.appendChild(readerMessageElement); + + // Display reader message element + readerMessageElement.style.display = "block"; + } + } + + enterPrintPreview( + browsingContext, + simplifiedMode, + changingBrowsers, + lastUsedPrinterName + ) { + const { docShell } = this; + + try { + let contentWindow = browsingContext.window; + let printSettings = this.getPrintSettings(lastUsedPrinterName); + + // Disable the progress dialog for generating previews. + printSettings.showPrintProgress = !Services.prefs.getBoolPref( + "print.tab_modal.enabled", + false + ); + + // If we happen to be on simplified mode, we need to set docURL in order + // to generate header/footer content correctly, since simplified tab has + // "about:blank" as its URI. + if (printSettings && simplifiedMode) { + printSettings.docURL = contentWindow.document.baseURI; + } + + // Get this early in case the actor goes away during print preview. + let browserContextId = this.browsingContext.id; + + // The print preview docshell will be in a different TabGroup, so + // printPreviewInitialize must be run in a separate runnable to avoid + // touching a different TabGroup in our own runnable. + let printPreviewInitialize = () => { + // During dispatching this function to the main-thread, the docshell + // might be destroyed, for example the print preview window gets closed + // soon after it's opened, in such case we should just simply bail out. + if (docShell.isBeingDestroyed()) { + this.sendAsyncMessage("Printing:Preview:Entered", { + failed: true, + }); + return; + } + + try { + let listener = new PrintingListener(this); + gPendingPreviewsMap.set(browserContextId, listener); + + gPrintPreviewInitializingInfo = { changingBrowsers }; + + contentWindow.printPreview(printSettings, listener, docShell); + } catch (error) { + // This might fail if we, for example, attempt to print a XUL document. + // In that case, we inform the parent to bail out of print preview. + Cu.reportError(error); + gPrintPreviewInitializingInfo = null; + this.sendAsyncMessage("Printing:Preview:Entered", { + failed: true, + }); + } + }; + + // If gPrintPreviewInitializingInfo.entered is not set we are still in the + // initial setup of a previous preview request. We delay this one until + // that has finished because running them at the same time will almost + // certainly cause failures. + if ( + gPrintPreviewInitializingInfo && + !gPrintPreviewInitializingInfo.entered + ) { + gPrintPreviewInitializingInfo.nextRequest = printPreviewInitialize; + } else { + Services.tm.dispatchToMainThread(printPreviewInitialize); + } + } catch (error) { + // This might fail if we, for example, attempt to print a XUL document. + // In that case, we inform the parent to bail out of print preview. + Cu.reportError(error); + this.sendAsyncMessage("Printing:Preview:Entered", { + failed: true, + }); + } + } + + exitPrintPreview() { + gPrintPreviewInitializingInfo = null; + this.docShell.exitPrintPreview(); + } + + updatePageCount() { + let cv = this.docShell.contentViewer; + cv.QueryInterface(Ci.nsIWebBrowserPrint); + this.sendAsyncMessage("Printing:Preview:UpdatePageCount", { + numPages: cv.printPreviewNumPages, + totalPages: cv.rawNumPages, + }); + } + + updateCurrentPage() { + let cv = this.docShell.contentViewer; + cv.QueryInterface(Ci.nsIWebBrowserPrint); + this.sendAsyncMessage("Printing:Preview:CurrentPage", { + currentPage: cv.printPreviewCurrentPageNumber, + }); + } + + navigate(navType, pageNum) { + let cv = this.docShell.contentViewer; + cv.QueryInterface(Ci.nsIWebBrowserPrint); + cv.printPreviewScrollToPage(navType, pageNum); + } +} + +PrintingChild.prototype.QueryInterface = ChromeUtils.generateQI([ + "nsIPrintingPromptService", +]); + +function PrintingListener(actor) { + this.actor = actor; +} +PrintingListener.prototype = { + QueryInterface: ChromeUtils.generateQI(["nsIWebProgressListener"]), + + onStateChange(aWebProgress, aRequest, aStateFlags, aStatus) { + this.actor.sendAsyncMessage("Printing:Preview:StateChange", { + stateFlags: aStateFlags, + status: aStatus, + }); + }, + + onProgressChange( + aWebProgress, + aRequest, + aCurSelfProgress, + aMaxSelfProgress, + aCurTotalProgress, + aMaxTotalProgress + ) { + this.actor.sendAsyncMessage("Printing:Preview:ProgressChange", { + curSelfProgress: aCurSelfProgress, + maxSelfProgress: aMaxSelfProgress, + curTotalProgress: aCurTotalProgress, + maxTotalProgress: aMaxTotalProgress, + }); + }, + + onLocationChange(aWebProgress, aRequest, aLocation, aFlags) {}, + onStatusChange(aWebProgress, aRequest, aStatus, aMessage) {}, + onSecurityChange(aWebProgress, aRequest, aState) {}, + onContentBlockingEvent(aWebProgress, aRequest, aEvent) {}, +}; diff --git a/toolkit/actors/PrintingParent.jsm b/toolkit/actors/PrintingParent.jsm new file mode 100644 index 0000000000..00baca696c --- /dev/null +++ b/toolkit/actors/PrintingParent.jsm @@ -0,0 +1,113 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["PrintingParent"]; + +let gTestListener = null; + +class PrintingParent extends JSWindowActorParent { + static setTestListener(listener) { + gTestListener = listener; + } + + getPrintPreviewToolbar(browser) { + return browser.ownerDocument.getElementById("print-preview-toolbar"); + } + + receiveMessage(message) { + let browser = this.browsingContext.top.embedderElement; + let PrintUtils = browser.ownerGlobal.PrintUtils; + + if (message.name == "Printing:Error") { + PrintUtils._displayPrintingError( + message.data.nsresult, + message.data.isPrinting + ); + return undefined; + } + + if (this.ignoreListeners) { + return undefined; + } + + let listener = PrintUtils._webProgressPP?.value; + let data = message.data; + + switch (message.name) { + case "Printing:Preview:CurrentPage": { + browser.setAttribute("current-page", message.data.currentPage); + break; + } + + case "Printing:Preview:Entered": { + // This message is sent by the content process once it has completed + // putting the content into print preview mode. We must wait for that to + // to complete before switching the chrome UI to print preview mode, + // otherwise we have layout issues. + + if (gTestListener) { + gTestListener(browser); + } + + PrintUtils.printPreviewEntered(browser, message.data); + break; + } + + case "Printing:Preview:ReaderModeReady": { + PrintUtils.readerModeReady(browser); + break; + } + + case "Printing:Preview:UpdatePageCount": { + let toolbar = this.getPrintPreviewToolbar(browser); + toolbar.updatePageCount(message.data.totalPages); + break; + } + + case "Printing:Preview:ProgressChange": { + if (!PrintUtils._webProgressPP.value) { + // We somehow didn't get a nsIWebProgressListener to be updated... + // I guess there's nothing to do. + return undefined; + } + + return listener.onProgressChange( + null, + null, + data.curSelfProgress, + data.maxSelfProgress, + data.curTotalProgress, + data.maxTotalProgress + ); + } + + case "Printing:Preview:StateChange": { + if (!PrintUtils._webProgressPP.value) { + // We somehow didn't get a nsIWebProgressListener to be updated... + // I guess there's nothing to do. + return undefined; + } + + if (data.stateFlags & Ci.nsIWebProgressListener.STATE_STOP) { + // Strangely, the printing engine sends 2 STATE_STOP messages when + // print preview is finishing. One has the STATE_IS_DOCUMENT flag, + // the other has the STATE_IS_NETWORK flag. However, the webProgressPP + // listener stops listening once the first STATE_STOP is sent. + // Any subsequent messages result in NS_ERROR_FAILURE errors getting + // thrown. This should all get torn out once bug 1088061 is fixed. + + // Enable toobar elements that we disabled during update. + let printPreviewTB = this.getPrintPreviewToolbar(browser); + printPreviewTB.disableUpdateTriggers(false); + } + + return listener.onStateChange(null, null, data.stateFlags, data.status); + } + } + + return undefined; + } +} diff --git a/toolkit/actors/PrintingSelectionChild.jsm b/toolkit/actors/PrintingSelectionChild.jsm new file mode 100644 index 0000000000..b13ced53ad --- /dev/null +++ b/toolkit/actors/PrintingSelectionChild.jsm @@ -0,0 +1,30 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["PrintingSelectionChild"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +class PrintingSelectionChild extends JSWindowActorChild { + receiveMessage(message) { + switch (message.name) { + case "PrintingSelection:HasSelection": + return this.hasSelection(); + } + + return undefined; + } + + hasSelection() { + let focusedWindow = Services.focus.focusedWindow; + if (focusedWindow) { + let selection = focusedWindow.getSelection(); + return selection.type == "Range"; + } + + return false; + } +} diff --git a/toolkit/actors/PurgeSessionHistoryChild.jsm b/toolkit/actors/PurgeSessionHistoryChild.jsm new file mode 100644 index 0000000000..da9badfb95 --- /dev/null +++ b/toolkit/actors/PurgeSessionHistoryChild.jsm @@ -0,0 +1,37 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["PurgeSessionHistoryChild"]; + +class PurgeSessionHistoryChild extends JSWindowActorChild { + receiveMessage(message) { + if (message.name != "Browser:PurgeSessionHistory") { + return; + } + let sessionHistory = this.docShell.QueryInterface(Ci.nsIWebNavigation) + .sessionHistory; + if (!sessionHistory) { + return; + } + + // place the entry at current index at the end of the history list, so it won't get removed + if (sessionHistory.index < sessionHistory.count - 1) { + let legacy = sessionHistory.legacySHistory; + let indexEntry = legacy.getEntryAtIndex(sessionHistory.index); + indexEntry.QueryInterface(Ci.nsISHEntry); + legacy.addEntry(indexEntry, true); + } + + let purge = sessionHistory.count; + if (this.document.location.href != "about:blank") { + --purge; // Don't remove the page the user's staring at from shistory + } + + if (purge > 0) { + sessionHistory.legacySHistory.purgeHistory(purge); + } + } +} diff --git a/toolkit/actors/RemotePageChild.jsm b/toolkit/actors/RemotePageChild.jsm new file mode 100644 index 0000000000..c5a7357a7d --- /dev/null +++ b/toolkit/actors/RemotePageChild.jsm @@ -0,0 +1,228 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ + +"use strict"; + +var EXPORTED_SYMBOLS = ["RemotePageChild"]; + +/** + * RemotePageChild is a base class for an unprivileged internal page, typically + * an about: page. A specific implementation should subclass the RemotePageChild + * actor with a more specific actor for that page. Typically, the child is not + * needed, but the parent actor will respond to messages and provide results + * directly to the page. + */ + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +ChromeUtils.defineModuleGetter( + this, + "AsyncPrefs", + "resource://gre/modules/AsyncPrefs.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "PrivateBrowsingUtils", + "resource://gre/modules/PrivateBrowsingUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "RemotePageAccessManager", + "resource://gre/modules/RemotePageAccessManager.jsm" +); + +class RemotePageChild extends JSWindowActorChild { + actorCreated() { + this.listeners = new Map(); + this.exportBaseFunctions(); + } + + exportBaseFunctions() { + const exportableFunctions = [ + "RPMSendAsyncMessage", + "RPMSendQuery", + "RPMAddMessageListener", + "RPMRemoveMessageListener", + "RPMGetIntPref", + "RPMGetStringPref", + "RPMGetBoolPref", + "RPMSetBoolPref", + "RPMGetFormatURLPref", + "RPMIsWindowPrivate", + ]; + + this.exportFunctions(exportableFunctions); + } + + /** + * Exports a list of functions to be accessible by the privileged page. + * Subclasses may call this function to add functions that are specific + * to a page. When the page calls a function, a function with the same + * name is called within the child actor. + * + * Only functions that appear in the whitelist in the + * RemotePageAccessManager for that page will be exported. + * + * @param array of function names. + */ + exportFunctions(functions) { + let document = this.document; + let principal = document.nodePrincipal; + + // If there is no content principal, don't export any functions. + if (!principal) { + return; + } + + let window = this.contentWindow; + + for (let fnname of functions) { + let allowAccess = RemotePageAccessManager.checkAllowAccessToFeature( + principal, + fnname, + document + ); + + if (allowAccess) { + // Wrap each function in an access checking function. + function accessCheckedFn(...args) { + this.checkAllowAccess(fnname, args[0]); + return this[fnname](...args); + } + + Cu.exportFunction(accessCheckedFn.bind(this), window, { + defineAs: fnname, + }); + } + } + } + + handleEvent() { + // Do nothing. The DOMWindowCreated event is just used to create + // the actor. + } + + receiveMessage(messagedata) { + let message = { + name: messagedata.name, + data: messagedata.data, + }; + + let listeners = this.listeners.get(message.name); + if (!listeners) { + return; + } + + let clonedMessage = Cu.cloneInto(message, this.contentWindow); + for (let listener of listeners.values()) { + try { + listener(clonedMessage); + } catch (e) { + Cu.reportError(e); + } + } + } + + wrapPromise(promise) { + return new this.contentWindow.Promise((resolve, reject) => + promise.then(resolve, reject) + ); + } + + /** + * Returns true if a feature cannot be accessed by the current page. + * Throws an exception if the feature may not be accessed. + + * @param aDocument child process document to call from + * @param aFeature to feature to check access to + * @param aValue value that must be included with that feature's whitelist + * @returns true if access is allowed or throws an exception otherwise + */ + checkAllowAccess(aFeature, aValue) { + let doc = this.document; + if (!RemotePageAccessManager.checkAllowAccess(doc, aFeature, aValue)) { + throw new Error( + "RemotePageAccessManager does not allow access to " + aFeature + ); + } + + return true; + } + + // Implementation of functions that are exported into the page. + + RPMSendAsyncMessage(aName, aData = null) { + this.sendAsyncMessage(aName, aData); + } + + RPMSendQuery(aName, aData = null) { + return this.wrapPromise( + new Promise(resolve => { + this.sendQuery(aName, aData).then(result => { + resolve(Cu.cloneInto(result, this.contentWindow)); + }); + }) + ); + } + + /** + * Adds a listener for messages. Many callbacks can be registered for the + * same message if necessary. An attempt to register the same callback for the + * same message twice will be ignored. When called the callback is passed an + * object with these properties: + * name: The message name + * data: Any data sent with the message + */ + RPMAddMessageListener(aName, aCallback) { + if (!this.listeners.has(aName)) { + this.listeners.set(aName, new Set([aCallback])); + } else { + this.listeners.get(aName).add(aCallback); + } + } + + /** + * Removes a listener for messages. + */ + RPMRemoveMessageListener(aName, aCallback) { + if (!this.listeners.has(aName)) { + return; + } + + this.listeners.get(aName).delete(aCallback); + } + + RPMGetIntPref(aPref, defaultValue) { + // Only call with a default value if it's defined, to be able to throw + // errors for non-existent prefs. + if (defaultValue !== undefined) { + return Services.prefs.getIntPref(aPref, defaultValue); + } + return Services.prefs.getIntPref(aPref); + } + + RPMGetStringPref(aPref) { + return Services.prefs.getStringPref(aPref); + } + + RPMGetBoolPref(aPref, defaultValue) { + // Only call with a default value if it's defined, to be able to throw + // errors for non-existent prefs. + if (defaultValue !== undefined) { + return Services.prefs.getBoolPref(aPref, defaultValue); + } + return Services.prefs.getBoolPref(aPref); + } + + RPMSetBoolPref(aPref, aVal) { + return this.wrapPromise(AsyncPrefs.set(aPref, aVal)); + } + + RPMGetFormatURLPref(aFormatURL) { + return Services.urlFormatter.formatURLPref(aFormatURL); + } + + RPMIsWindowPrivate() { + return PrivateBrowsingUtils.isContentWindowPrivate(this.contentWindow); + } +} diff --git a/toolkit/actors/SelectChild.jsm b/toolkit/actors/SelectChild.jsm new file mode 100644 index 0000000000..54a60d178b --- /dev/null +++ b/toolkit/actors/SelectChild.jsm @@ -0,0 +1,474 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["SelectChild"]; + +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +ChromeUtils.defineModuleGetter( + this, + "BrowserUtils", + "resource://gre/modules/BrowserUtils.jsm" +); +ChromeUtils.defineModuleGetter( + this, + "DeferredTask", + "resource://gre/modules/DeferredTask.jsm" +); + +XPCOMUtils.defineLazyGlobalGetters(this, ["InspectorUtils"]); + +const kStateActive = 0x00000001; // NS_EVENT_STATE_ACTIVE +const kStateHover = 0x00000004; // NS_EVENT_STATE_HOVER + +// Duplicated in SelectParent.jsm +// Please keep these lists in sync. +const SUPPORTED_OPTION_OPTGROUP_PROPERTIES = [ + "direction", + "color", + "background-color", + "text-shadow", + "font-family", + "font-weight", + "font-size", + "font-style", +]; + +const SUPPORTED_SELECT_PROPERTIES = [ + ...SUPPORTED_OPTION_OPTGROUP_PROPERTIES, + "scrollbar-width", + "scrollbar-color", +]; + +// A process global state for whether or not content thinks +// that a <select> dropdown is open or not. This is managed +// entirely within this module, and is read-only accessible +// via SelectContentHelper.open. +var gOpen = false; + +var SelectContentHelper = function(aElement, aOptions, aActor) { + this.element = aElement; + this.initialSelection = aElement[aElement.selectedIndex] || null; + this.actor = aActor; + this.closedWithClickOn = false; + this.isOpenedViaTouch = aOptions.isOpenedViaTouch; + this._closeAfterBlur = true; + this._pseudoStylesSetup = false; + this._lockedDescendants = null; + this.init(); + this.showDropDown(); + this._updateTimer = new DeferredTask(this._update.bind(this), 0); +}; + +Object.defineProperty(SelectContentHelper, "open", { + get() { + return gOpen; + }, +}); + +SelectContentHelper.prototype = { + init() { + let win = this.element.ownerGlobal; + win.addEventListener("pagehide", this, { mozSystemGroup: true }); + this.element.addEventListener("blur", this, { mozSystemGroup: true }); + this.element.addEventListener("transitionend", this, { + mozSystemGroup: true, + }); + let MutationObserver = this.element.ownerGlobal.MutationObserver; + this.mut = new MutationObserver(mutations => { + // Something changed the <select> while it was open, so + // we'll poke a DeferredTask to update the parent sometime + // in the very near future. + this._updateTimer.arm(); + }); + this.mut.observe(this.element, { + childList: true, + subtree: true, + attributes: true, + }); + + XPCOMUtils.defineLazyPreferenceGetter( + this, + "disablePopupAutohide", + "ui.popup.disable_autohide", + false + ); + }, + + uninit() { + this.element.openInParentProcess = false; + let win = this.element.ownerGlobal; + win.removeEventListener("pagehide", this, { mozSystemGroup: true }); + this.element.removeEventListener("blur", this, { mozSystemGroup: true }); + this.element.removeEventListener("transitionend", this, { + mozSystemGroup: true, + }); + this.element = null; + this.actor = null; + this.mut.disconnect(); + this._updateTimer.disarm(); + this._updateTimer = null; + gOpen = false; + }, + + showDropDown() { + this.element.openInParentProcess = true; + this._setupPseudoClassStyles(); + let rect = this._getBoundingContentRect(); + let computedStyles = getComputedStyles(this.element); + let options = this._buildOptionList(); + let defaultStyles = this.element.ownerGlobal.getDefaultComputedStyle( + this.element + ); + this.actor.sendAsyncMessage("Forms:ShowDropDown", { + isOpenedViaTouch: this.isOpenedViaTouch, + options, + rect, + selectedIndex: this.element.selectedIndex, + style: supportedStyles(computedStyles, SUPPORTED_SELECT_PROPERTIES), + defaultStyle: supportedStyles(defaultStyles, SUPPORTED_SELECT_PROPERTIES), + }); + this._clearPseudoClassStyles(); + gOpen = true; + }, + + _setupPseudoClassStyles() { + if (this._pseudoStylesSetup) { + throw new Error("pseudo styles must not be set up yet"); + } + // Do all of the things that change style at once, before we read + // any styles. + this._pseudoStylesSetup = true; + InspectorUtils.addPseudoClassLock(this.element, ":focus"); + let lockedDescendants = (this._lockedDescendants = this.element.querySelectorAll( + ":checked" + )); + for (let child of lockedDescendants) { + // Selected options have the :checked pseudo-class, which + // we want to disable before calculating the computed + // styles since the user agent styles alter the styling + // based on :checked. + InspectorUtils.addPseudoClassLock(child, ":checked", false); + } + }, + + _clearPseudoClassStyles() { + if (!this._pseudoStylesSetup) { + throw new Error("pseudo styles must be set up already"); + } + // Undo all of the things that change style at once, after we're + // done reading styles. + InspectorUtils.clearPseudoClassLocks(this.element); + let lockedDescendants = this._lockedDescendants; + for (let child of lockedDescendants) { + InspectorUtils.clearPseudoClassLocks(child); + } + this._lockedDescendants = null; + this._pseudoStylesSetup = false; + }, + + _getBoundingContentRect() { + return BrowserUtils.getElementBoundingScreenRect(this.element); + }, + + _buildOptionList() { + if (!this._pseudoStylesSetup) { + throw new Error("pseudo styles must be set up"); + } + let uniqueStyles = []; + let options = buildOptionListForChildren(this.element, uniqueStyles); + return { options, uniqueStyles }; + }, + + _update() { + // The <select> was updated while the dropdown was open. + // Let's send up a new list of options. + // Technically we might not need to set this pseudo-class + // during _update() since the element should organically + // have :focus, though it is here for belt-and-suspenders. + this._setupPseudoClassStyles(); + let computedStyles = getComputedStyles(this.element); + let defaultStyles = this.element.ownerGlobal.getDefaultComputedStyle( + this.element + ); + this.actor.sendAsyncMessage("Forms:UpdateDropDown", { + options: this._buildOptionList(), + selectedIndex: this.element.selectedIndex, + style: supportedStyles(computedStyles, SUPPORTED_SELECT_PROPERTIES), + defaultStyle: supportedStyles(defaultStyles, SUPPORTED_SELECT_PROPERTIES), + }); + this._clearPseudoClassStyles(); + }, + + dispatchMouseEvent(win, target, eventName) { + let mouseEvent = new win.MouseEvent(eventName, { + view: win, + bubbles: true, + cancelable: true, + composed: true, + }); + target.dispatchEvent(mouseEvent); + }, + + receiveMessage(message) { + switch (message.name) { + case "Forms:SelectDropDownItem": + this.element.selectedIndex = message.data.value; + this.closedWithClickOn = !message.data.closedWithEnter; + break; + + case "Forms:DismissedDropDown": { + if (!this.element) { + return; + } + + let win = this.element.ownerGlobal; + + // Running arbitrary script below (dispatching events for example) can + // close us, but we should still send events consistently. + let element = this.element; + + let selectedOption = element.item(element.selectedIndex); + + // For ordering of events, we're using non-e10s as our guide here, + // since the spec isn't exactly clear. In non-e10s: + // - If the user clicks on an element in the dropdown, we fire + // mousedown, mouseup, input, change, and click events. + // - If the user uses the keyboard to select an element in the + // dropdown, we only fire input and change events. + // - If the user pressed ESC key or clicks outside the dropdown, + // we fire nothing as the selected option is unchanged. + if (this.closedWithClickOn) { + this.dispatchMouseEvent(win, selectedOption, "mousedown"); + this.dispatchMouseEvent(win, selectedOption, "mouseup"); + } + + // Clear active document no matter user selects via keyboard or mouse + InspectorUtils.removeContentState( + element, + kStateActive, + /* aClearActiveDocument */ true + ); + + // Fire input and change events when selected option changes + if (this.initialSelection !== selectedOption) { + let inputEvent = new win.Event("input", { + bubbles: true, + }); + + let changeEvent = new win.Event("change", { + bubbles: true, + }); + + let handlingUserInput = win.windowUtils.setHandlingUserInput(true); + try { + element.dispatchEvent(inputEvent); + element.dispatchEvent(changeEvent); + } finally { + handlingUserInput.destruct(); + } + } + + // Fire click event + if (this.closedWithClickOn) { + this.dispatchMouseEvent(win, selectedOption, "click"); + } + + this.uninit(); + break; + } + + case "Forms:MouseOver": + InspectorUtils.setContentState(this.element, kStateHover); + break; + + case "Forms:MouseOut": + InspectorUtils.removeContentState(this.element, kStateHover); + break; + + case "Forms:MouseUp": + let win = this.element.ownerGlobal; + if (message.data.onAnchor) { + this.dispatchMouseEvent(win, this.element, "mouseup"); + } + InspectorUtils.removeContentState(this.element, kStateActive); + if (message.data.onAnchor) { + this.dispatchMouseEvent(win, this.element, "click"); + } + break; + + case "Forms:SearchFocused": + this._closeAfterBlur = false; + break; + + case "Forms:BlurDropDown-Pong": + if (!this._closeAfterBlur || !gOpen) { + return; + } + this.actor.sendAsyncMessage("Forms:HideDropDown", {}); + this.uninit(); + break; + } + }, + + handleEvent(event) { + switch (event.type) { + case "pagehide": + if (this.element.ownerDocument === event.target) { + this.actor.sendAsyncMessage("Forms:HideDropDown", {}); + this.uninit(); + } + break; + case "blur": { + if (this.element !== event.target || this.disablePopupAutohide) { + break; + } + this._closeAfterBlur = true; + // Send a ping-pong message to make sure that we wait for + // enough cycles to pass from the potential focusing of the + // search box to disable closing-after-blur. + this.actor.sendAsyncMessage("Forms:BlurDropDown-Ping", {}); + break; + } + case "mozhidedropdown": + if (this.element === event.target) { + this.actor.sendAsyncMessage("Forms:HideDropDown", {}); + this.uninit(); + } + break; + case "transitionend": + if (SUPPORTED_SELECT_PROPERTIES.includes(event.propertyName)) { + this._updateTimer.arm(); + } + break; + } + }, +}; + +function getComputedStyles(element) { + return element.ownerGlobal.getComputedStyle(element); +} + +function supportedStyles(cs, supportedProps) { + let styles = {}; + for (let property of supportedProps) { + styles[property] = cs.getPropertyValue(property); + } + return styles; +} + +function supportedStylesEqual(styles, otherStyles) { + for (let property in styles) { + if (styles[property] !== otherStyles[property]) { + return false; + } + } + return true; +} + +function uniqueStylesIndex(cs, uniqueStyles) { + let styles = supportedStyles(cs, SUPPORTED_OPTION_OPTGROUP_PROPERTIES); + for (let i = uniqueStyles.length; i--; ) { + if (supportedStylesEqual(uniqueStyles[i], styles)) { + return i; + } + } + uniqueStyles.push(styles); + return uniqueStyles.length - 1; +} + +function buildOptionListForChildren(node, uniqueStyles) { + let result = []; + + for (let child of node.children) { + let tagName = child.tagName.toUpperCase(); + + if (tagName == "OPTION" || tagName == "OPTGROUP") { + if (child.hidden) { + continue; + } + + // The option code-path should match HTMLOptionElement::GetRenderedLabel. + let textContent = + tagName == "OPTGROUP" + ? child.getAttribute("label") + : child.label || child.text; + if (textContent == null) { + textContent = ""; + } + + let cs = getComputedStyles(child); + let info = { + index: child.index, + tagName, + textContent, + disabled: child.disabled, + display: cs.display, + tooltip: child.title, + children: + tagName == "OPTGROUP" + ? buildOptionListForChildren(child, uniqueStyles) + : [], + // Most options have the same style. In order to reduce the size of the + // IPC message, coalesce them in uniqueStyles. + styleIndex: uniqueStylesIndex(cs, uniqueStyles), + }; + + result.push(info); + } + } + return result; +} + +// Hold the instance of SelectContentHelper created +// when the dropdown list is opened. This variable helps +// re-route the received message from SelectChild to SelectContentHelper object. +let currentSelectContentHelper = new WeakMap(); + +class SelectChild extends JSWindowActorChild { + handleEvent(event) { + if (SelectContentHelper.open) { + // The SelectContentHelper object handles captured + // events when the <select> popup is open. + let contentHelper = currentSelectContentHelper.get(this); + if (contentHelper) { + contentHelper.handleEvent(event); + } + return; + } + + switch (event.type) { + case "mozshowdropdown": { + let contentHelper = new SelectContentHelper( + event.target, + { isOpenedViaTouch: false }, + this + ); + currentSelectContentHelper.set(this, contentHelper); + break; + } + + case "mozshowdropdown-sourcetouch": { + let contentHelper = new SelectContentHelper( + event.target, + { isOpenedViaTouch: true }, + this + ); + currentSelectContentHelper.set(this, contentHelper); + break; + } + } + } + + receiveMessage(message) { + let contentHelper = currentSelectContentHelper.get(this); + if (contentHelper) { + contentHelper.receiveMessage(message); + } + } +} diff --git a/toolkit/actors/SelectParent.jsm b/toolkit/actors/SelectParent.jsm new file mode 100644 index 0000000000..045052e013 --- /dev/null +++ b/toolkit/actors/SelectParent.jsm @@ -0,0 +1,771 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["SelectParent", "SelectParentHelper"]; + +const { AppConstants } = ChromeUtils.import( + "resource://gre/modules/AppConstants.jsm" +); +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +// Maximum number of rows to display in the select dropdown. +const MAX_ROWS = 20; + +// Minimum elements required to show select search +const SEARCH_MINIMUM_ELEMENTS = 40; + +// The properties that we should respect only when the item is not active. +const PROPERTIES_RESET_WHEN_ACTIVE = [ + "color", + "background-color", + "text-shadow", +]; + +// Duplicated in SelectChild.jsm +// Please keep these lists in sync. +const SUPPORTED_OPTION_OPTGROUP_PROPERTIES = [ + "direction", + "color", + "background-color", + "text-shadow", + "font-family", + "font-weight", + "font-size", + "font-style", +]; + +const SUPPORTED_SELECT_PROPERTIES = [ + ...SUPPORTED_OPTION_OPTGROUP_PROPERTIES, + "scrollbar-width", + "scrollbar-color", +]; + +const customStylingEnabled = Services.prefs.getBoolPref( + "dom.forms.select.customstyling" +); + +var SelectParentHelper = { + /** + * `populate` takes the `menulist` element and a list of `items` and generates + * a popup list of options. + * + * If `customStylingEnabled` is set to `true`, the function will also + * style the select and its popup trying to prevent the text + * and background to end up in the same color. + * + * All `ua*` variables represent the color values for the default colors + * for their respective form elements used by the user agent. + * The `select*` variables represent the color values defined for the + * particular <select> element. + * + * The `customoptionstyling` attribute controls the application of + * `-moz-appearance` on the elements and is disabled if the element is + * defining its own background-color. + * + * @param {Element} menulist + * @param {Array<Element>} items + * @param {Array<Object>} uniqueItemStyles + * @param {Number} selectedIndex + * @param {Number} zoom + * @param {Object} uaStyle + * @param {Object} selectStyle + * + * FIXME(emilio): injecting a stylesheet is a somewhat inefficient way to do + * this, can we use more style attributes? + * + * FIXME(emilio, bug 1530709): At the very least we should use CSSOM to avoid + * trusting the IPC message too much. + */ + populate( + menulist, + items, + uniqueItemStyles, + selectedIndex, + zoom, + uaStyle, + selectStyle + ) { + // Clear the current contents of the popup + menulist.menupopup.textContent = ""; + let stylesheet = menulist.querySelector("#ContentSelectDropdownStylesheet"); + if (stylesheet) { + stylesheet.remove(); + } + + let doc = menulist.ownerDocument; + let sheet; + if (customStylingEnabled) { + stylesheet = doc.createElementNS("http://www.w3.org/1999/xhtml", "style"); + stylesheet.setAttribute("id", "ContentSelectDropdownStylesheet"); + stylesheet.hidden = true; + stylesheet = menulist.appendChild(stylesheet); + sheet = stylesheet.sheet; + } else { + selectStyle = uaStyle; + } + + let selectBackgroundSet = false; + + if (selectStyle["background-color"] == "rgba(0, 0, 0, 0)") { + selectStyle["background-color"] = uaStyle["background-color"]; + } + + if (selectStyle.color == selectStyle["background-color"]) { + selectStyle.color = uaStyle.color; + } + + if (customStylingEnabled) { + if (selectStyle["text-shadow"] != "none") { + sheet.insertRule( + `#ContentSelectDropdown > menupopup > [_moz-menuactive="true"] { + text-shadow: none; + }`, + 0 + ); + } + + let addedRule = false; + for (let property of SUPPORTED_SELECT_PROPERTIES) { + if (property == "direction") { + continue; + } // Handled above, or before. + if ( + !selectStyle[property] || + selectStyle[property] == uaStyle[property] + ) { + continue; + } + if (!addedRule) { + sheet.insertRule("#ContentSelectDropdown > menupopup {}", 0); + addedRule = true; + } + let value = selectStyle[property]; + if (property == "scrollbar-width") { + // This needs to actually apply to the relevant scrollbox, because + // scrollbar-width doesn't inherit. + property = "--content-select-scrollbar-width"; + } + sheet.cssRules[0].style.setProperty(property, value); + } + // Some webpages set the <select> backgroundColor to transparent, + // but they don't intend to change the popup to transparent. + // So we remove the backgroundColor and turn it into an image instead. + if ( + customStylingEnabled && + selectStyle["background-color"] != uaStyle["background-color"] + ) { + // We intentionally use the parsed color to prevent color + // values like `url(..)` being injected into the + // `background-image` property. + let parsedColor = sheet.cssRules[0].style["background-color"]; + sheet.cssRules[0].style["background-color"] = ""; + sheet.cssRules[0].style[ + "background-image" + ] = `linear-gradient(${parsedColor}, ${parsedColor})`; + selectBackgroundSet = true; + } + if (addedRule) { + sheet.insertRule( + `#ContentSelectDropdown > menupopup > :not([_moz-menuactive="true"]) { + color: inherit; + }`, + 0 + ); + } + } + + // We only set the `customoptionstyling` if the background has been + // manually set. This prevents the overlap between moz-appearance and + // background-color. `color` and `text-shadow` do not interfere with it. + if (selectBackgroundSet) { + menulist.menupopup.setAttribute("customoptionstyling", "true"); + } else { + menulist.menupopup.removeAttribute("customoptionstyling"); + } + + this._currentZoom = zoom; + this._currentMenulist = menulist; + this.populateChildren( + menulist, + items, + uniqueItemStyles, + selectedIndex, + zoom, + selectStyle, + selectBackgroundSet, + sheet + ); + }, + + open(browser, menulist, rect, isOpenedViaTouch, selectParentActor) { + this._actor = selectParentActor; + menulist.hidden = false; + this._currentBrowser = browser; + this._closedWithEnter = false; + this._selectRect = rect; + this._registerListeners(browser, menulist.menupopup); + + let win = browser.ownerGlobal; + + // Set the maximum height to show exactly MAX_ROWS items. + let menupopup = menulist.menupopup; + let firstItem = menupopup.firstElementChild; + while (firstItem && firstItem.hidden) { + firstItem = firstItem.nextElementSibling; + } + + if (firstItem) { + let itemHeight = firstItem.getBoundingClientRect().height; + + // Include the padding and border on the popup. + let cs = win.getComputedStyle(menupopup); + let bpHeight = + parseFloat(cs.borderTopWidth) + + parseFloat(cs.borderBottomWidth) + + parseFloat(cs.paddingTop) + + parseFloat(cs.paddingBottom); + menupopup.style.maxHeight = itemHeight * MAX_ROWS + bpHeight + "px"; + } + + menupopup.classList.toggle("isOpenedViaTouch", isOpenedViaTouch); + + if (browser.getAttribute("selectmenuconstrained") != "false") { + let constraintRect = browser.getBoundingClientRect(); + constraintRect = new win.DOMRect( + constraintRect.left + win.mozInnerScreenX, + constraintRect.top + win.mozInnerScreenY, + constraintRect.width, + constraintRect.height + ); + menupopup.setConstraintRect(constraintRect); + } else { + menupopup.setConstraintRect(new win.DOMRect(0, 0, 0, 0)); + } + menupopup.openPopupAtScreenRect( + AppConstants.platform == "macosx" ? "selection" : "after_start", + rect.left, + rect.top, + rect.width, + rect.height, + false, + false + ); + }, + + hide(menulist, browser) { + if (this._currentBrowser == browser) { + menulist.menupopup.hidePopup(); + } + }, + + handleEvent(event) { + switch (event.type) { + case "mouseup": + function inRect(rect, x, y) { + return ( + x >= rect.left && + x <= rect.left + rect.width && + y >= rect.top && + y <= rect.top + rect.height + ); + } + + let x = event.screenX, + y = event.screenY; + let onAnchor = + !inRect(this._currentMenulist.menupopup.getOuterScreenRect(), x, y) && + inRect(this._selectRect, x, y) && + this._currentMenulist.menupopup.state == "open"; + this._actor.sendAsyncMessage("Forms:MouseUp", { onAnchor }); + break; + + case "mouseover": + this._actor.sendAsyncMessage("Forms:MouseOver", {}); + + break; + + case "mouseout": + this._actor.sendAsyncMessage("Forms:MouseOut", {}); + break; + + case "keydown": + if (event.keyCode == event.DOM_VK_RETURN) { + this._closedWithEnter = true; + } + break; + + case "command": + if (event.target.hasAttribute("value")) { + this._actor.sendAsyncMessage("Forms:SelectDropDownItem", { + value: event.target.value, + closedWithEnter: this._closedWithEnter, + }); + } + break; + + case "fullscreen": + if (this._currentMenulist) { + this._currentMenulist.menupopup.hidePopup(); + } + break; + + case "popuphidden": + this._actor.sendAsyncMessage("Forms:DismissedDropDown", {}); + let popup = event.target; + this._unregisterListeners(this._currentBrowser, popup); + popup.parentNode.hidden = true; + this._currentBrowser = null; + this._currentMenulist = null; + this._selectRect = null; + this._currentZoom = 1; + this._actor = null; + break; + } + }, + + receiveMessage(msg) { + if (!this._currentBrowser) { + return; + } + + if (msg.name == "Forms:UpdateDropDown") { + // Sanity check - we'd better know what the currently + // opened menulist is, and what browser it belongs to... + if (!this._currentMenulist) { + return; + } + + let scrollBox = this._currentMenulist.menupopup.scrollBox.scrollbox; + let scrollTop = scrollBox.scrollTop; + + let options = msg.data.options; + let selectedIndex = msg.data.selectedIndex; + this.populate( + this._currentMenulist, + options.options, + options.uniqueStyles, + selectedIndex, + this._currentZoom, + msg.data.defaultStyle, + msg.data.style + ); + + // Restore scroll position to what it was prior to the update. + scrollBox.scrollTop = scrollTop; + } else if (msg.name == "Forms:BlurDropDown-Ping") { + this._actor.sendAsyncMessage("Forms:BlurDropDown-Pong", {}); + } + }, + + _registerListeners(browser, popup) { + popup.addEventListener("command", this); + popup.addEventListener("popuphidden", this); + popup.addEventListener("mouseover", this); + popup.addEventListener("mouseout", this); + browser.ownerGlobal.addEventListener("mouseup", this, true); + browser.ownerGlobal.addEventListener("keydown", this, true); + browser.ownerGlobal.addEventListener("fullscreen", this, true); + }, + + _unregisterListeners(browser, popup) { + popup.removeEventListener("command", this); + popup.removeEventListener("popuphidden", this); + popup.removeEventListener("mouseover", this); + popup.removeEventListener("mouseout", this); + browser.ownerGlobal.removeEventListener("mouseup", this, true); + browser.ownerGlobal.removeEventListener("keydown", this, true); + browser.ownerGlobal.removeEventListener("fullscreen", this, true); + }, + + /** + * `populateChildren` creates all <menuitem> elements for the popup menu + * based on the list of <option> elements from the <select> element. + * + * It attempts to intelligently add per-item CSS rules if the single + * item values differ from the parent menu values and attempting to avoid + * ending up with the same color of text and background. + * + * @param {Element} menulist + * @param {Array<Element>} options + * @param {Array<Object>} uniqueOptionStyles + * @param {Number} selectedIndex + * @param {Number} zoom + * @param {Object} selectStyle + * @param {Boolean} selectBackgroundSet + * @param {CSSStyleSheet} sheet + * @param {Element} parentElement + * @param {Boolean} isGroupDisabled + * @param {Boolean} addSearch + * @param {Number} nthChildIndex + * @returns {Number} + * + * FIXME(emilio): Again, using a stylesheet + :nth-child is not really efficient. + */ + populateChildren( + menulist, + options, + uniqueOptionStyles, + selectedIndex, + zoom, + selectStyle, + selectBackgroundSet, + sheet, + parentElement = null, + isGroupDisabled = false, + addSearch = true, + nthChildIndex = 1 + ) { + let element = menulist.menupopup; + + let ariaOwns = ""; + for (let option of options) { + let isOptGroup = option.tagName == "OPTGROUP"; + let item = element.ownerDocument.createXULElement( + isOptGroup ? "menucaption" : "menuitem" + ); + if (isOptGroup) { + item.setAttribute("role", "group"); + } + let style = uniqueOptionStyles[option.styleIndex]; + + item.setAttribute("label", option.textContent); + item.style.direction = style.direction; + item.style.fontSize = zoom * parseFloat(style["font-size"], 10) + "px"; + item.hidden = + option.display == "none" || (parentElement && parentElement.hidden); + // Keep track of which options are hidden by page content, so we can avoid showing + // them on search input + item.hiddenByContent = item.hidden; + item.setAttribute("tooltiptext", option.tooltip); + + if (style["background-color"] == "rgba(0, 0, 0, 0)") { + style["background-color"] = selectStyle["background-color"]; + } + + let optionBackgroundSet = + style["background-color"] != selectStyle["background-color"]; + + if (style.color == style["background-color"]) { + style.color = selectStyle.color; + } + + if (customStylingEnabled) { + let addedRule = false; + for (const property of SUPPORTED_OPTION_OPTGROUP_PROPERTIES) { + if (property == "direction" || property == "font-size") { + continue; + } // handled above + if (!style[property] || style[property] == selectStyle[property]) { + continue; + } + if (PROPERTIES_RESET_WHEN_ACTIVE.includes(property)) { + if (!addedRule) { + sheet.insertRule( + `#ContentSelectDropdown > menupopup > :nth-child(${nthChildIndex}):not([_moz-menuactive="true"]) { + }`, + 0 + ); + addedRule = true; + } + sheet.cssRules[0].style[property] = style[property]; + } else { + item.style.setProperty(property, style[property]); + } + } + + if (addedRule) { + if ( + style["text-shadow"] != "none" && + style["text-shadow"] != selectStyle["text-shadow"] + ) { + // Need to explicitly disable the possibly inherited + // text-shadow rule when _moz-menuactive=true since + // _moz-menuactive=true disables custom option styling. + sheet.insertRule( + `#ContentSelectDropdown > menupopup > :nth-child(${nthChildIndex})[_moz-menuactive="true"] { + text-shadow: none; + }`, + 0 + ); + } + } + } + + if ( + customStylingEnabled && + (optionBackgroundSet || selectBackgroundSet) + ) { + item.setAttribute("customoptionstyling", "true"); + } else { + item.removeAttribute("customoptionstyling"); + } + + if (parentElement) { + // In the menupopup, the optgroup is a sibling of its contained options. + // For accessibility, we want to preserve the hierarchy such that the + // options are inside the optgroup. We do this using aria-owns on the + // parent. + item.id = "ContentSelectDropdownOption" + nthChildIndex; + item.setAttribute("aria-level", "2"); + ariaOwns += item.id + " "; + } + + element.appendChild(item); + nthChildIndex++; + + // A disabled optgroup disables all of its child options. + let isDisabled = isGroupDisabled || option.disabled; + if (isDisabled) { + item.setAttribute("disabled", "true"); + } + + if (isOptGroup) { + nthChildIndex = this.populateChildren( + menulist, + option.children, + uniqueOptionStyles, + selectedIndex, + zoom, + selectStyle, + selectBackgroundSet, + sheet, + item, + isDisabled, + false, + nthChildIndex + ); + } else { + if (option.index == selectedIndex) { + // We expect the parent element of the popup to be a <xul:menulist> that + // has the popuponly attribute set to "true". This is necessary in order + // for a <xul:menupopup> to act like a proper <html:select> dropdown, as + // the <xul:menulist> does things like remember state and set the + // _moz-menuactive attribute on the selected <xul:menuitem>. + menulist.selectedItem = item; + + // It's hack time. In the event that we've re-populated the menulist due + // to a mutation in the <select> in content, that means that the -moz_activemenu + // may have been removed from the selected item. Since that's normally only + // set for the initially selected on popupshowing for the menulist, and we + // don't want to close and re-open the popup, we manually set it here. + menulist.activeChild = item; + } + + item.setAttribute("value", option.index); + + if (parentElement) { + item.classList.add("contentSelectDropdown-ingroup"); + } + } + } + + if (parentElement && ariaOwns) { + parentElement.setAttribute("aria-owns", ariaOwns); + } + + // Check if search pref is enabled, if this is the first time iterating through + // the dropdown, and if the list is long enough for a search element to be added. + if ( + Services.prefs.getBoolPref("dom.forms.selectSearch") && + addSearch && + element.childElementCount > SEARCH_MINIMUM_ELEMENTS + ) { + // Add a search text field as the first element of the dropdown + let searchbox = element.ownerDocument.createXULElement("search-textbox"); + searchbox.className = "contentSelectDropdown-searchbox"; + searchbox.addEventListener("input", this.onSearchInput); + searchbox.addEventListener("focus", this.onSearchFocus.bind(this)); + searchbox.addEventListener("blur", this.onSearchBlur); + searchbox.addEventListener("command", this.onSearchInput); + + // Handle special keys for exiting search + searchbox.addEventListener( + "keydown", + event => { + this.onSearchKeydown(event, menulist); + }, + true + ); + + element.insertBefore(searchbox, element.children[0]); + } + + return nthChildIndex; + }, + + onSearchKeydown(event, menulist) { + if (event.defaultPrevented) { + return; + } + + let searchbox = event.currentTarget; + switch (event.key) { + case "Escape": + searchbox.parentElement.hidePopup(); + break; + case "ArrowDown": + case "Enter": + case "Tab": + searchbox.blur(); + if ( + searchbox.nextElementSibling.localName == "menuitem" && + !searchbox.nextElementSibling.hidden + ) { + menulist.activeChild = searchbox.nextElementSibling; + } else { + let currentOption = searchbox.nextElementSibling; + while ( + currentOption && + (currentOption.localName != "menuitem" || currentOption.hidden) + ) { + currentOption = currentOption.nextElementSibling; + } + if (currentOption) { + menulist.activeChild = currentOption; + } else { + searchbox.focus(); + } + } + break; + default: + return; + } + event.preventDefault(); + }, + + onSearchInput(event) { + let searchObj = event.currentTarget; + + // Get input from search field, set to all lower case for comparison + let input = searchObj.value.toLowerCase(); + // Get all items in dropdown (could be options or optgroups) + let menupopup = searchObj.parentElement; + let menuItems = menupopup.querySelectorAll("menuitem, menucaption"); + + // Flag used to detect any group headers with no visible options. + // These group headers should be hidden. + let allHidden = true; + // Keep a reference to the previous group header (menucaption) to go back + // and set to hidden if all options within are hidden. + let prevCaption = null; + + for (let currentItem of menuItems) { + // Make sure we don't show any options that were hidden by page content + if (!currentItem.hiddenByContent) { + // Get label and tooltip (title) from option and change to + // lower case for comparison + let itemLabel = currentItem.getAttribute("label").toLowerCase(); + let itemTooltip = currentItem.getAttribute("title").toLowerCase(); + + // If search input is empty, all options should be shown + if (!input) { + currentItem.hidden = false; + } else if (currentItem.localName == "menucaption") { + if (prevCaption != null) { + prevCaption.hidden = allHidden; + } + prevCaption = currentItem; + allHidden = true; + } else { + if ( + !currentItem.classList.contains("contentSelectDropdown-ingroup") && + currentItem.previousElementSibling.classList.contains( + "contentSelectDropdown-ingroup" + ) + ) { + if (prevCaption != null) { + prevCaption.hidden = allHidden; + } + prevCaption = null; + allHidden = true; + } + if (itemLabel.includes(input) || itemTooltip.includes(input)) { + currentItem.hidden = false; + allHidden = false; + } else { + currentItem.hidden = true; + } + } + if (prevCaption != null) { + prevCaption.hidden = allHidden; + } + } + } + }, + + onSearchFocus(event) { + let menupopup = event.target.closest("menupopup"); + menupopup.parentElement.activeChild = null; + menupopup.setAttribute("ignorekeys", "true"); + this._actor.sendAsyncMessage("Forms:SearchFocused", {}); + }, + + onSearchBlur(event) { + let menupopup = event.target.closest("menupopup"); + menupopup.setAttribute("ignorekeys", "false"); + }, +}; + +class SelectParent extends JSWindowActorParent { + receiveMessage(message) { + switch (message.name) { + case "Forms:ShowDropDown": { + let topBrowsingContext = this.manager.browsingContext.top; + let browser = topBrowsingContext.embedderElement; + + if (!browser.hasAttribute("selectmenulist")) { + return; + } + + let document = browser.ownerDocument; + let menulist = document.getElementById( + browser.getAttribute("selectmenulist") + ); + + if (!this._menulist) { + // Cache the menulist to have access to it + // when the document is gone (eg: Tab closed) + this._menulist = menulist; + } + + let data = message.data; + menulist.menupopup.style.direction = data.style.direction; + + let { ZoomManager } = topBrowsingContext.topChromeWindow; + SelectParentHelper.populate( + menulist, + data.options.options, + data.options.uniqueStyles, + data.selectedIndex, + // We only want to apply the full zoom. The text zoom is already + // applied in the font-size. + ZoomManager.getFullZoomForBrowser(browser), + data.defaultStyle, + data.style + ); + SelectParentHelper.open( + browser, + menulist, + data.rect, + data.isOpenedViaTouch, + this + ); + break; + } + + case "Forms:HideDropDown": { + let topBrowsingContext = this.manager.browsingContext.top; + let browser = topBrowsingContext.embedderElement; + + SelectParentHelper.hide(this._menulist, browser); + break; + } + + default: + SelectParentHelper.receiveMessage(message); + } + } +} diff --git a/toolkit/actors/TestProcessActorChild.jsm b/toolkit/actors/TestProcessActorChild.jsm new file mode 100644 index 0000000000..1d621b7dc8 --- /dev/null +++ b/toolkit/actors/TestProcessActorChild.jsm @@ -0,0 +1,61 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +var EXPORTED_SYMBOLS = ["TestProcessActorChild"]; + +class TestProcessActorChild extends JSProcessActorChild { + constructor() { + super(); + this.sawActorCreated = false; + } + + actorCreated() { + this.sawActorCreated = true; + } + + receiveMessage(aMessage) { + switch (aMessage.name) { + case "toChild": + aMessage.data.toChild = true; + this.sendAsyncMessage("toParent", aMessage.data); + break; + case "asyncAdd": + let { a, b } = aMessage.data; + return new Promise(resolve => { + resolve({ result: a + b }); + }); + case "error": + return Promise.reject(new SyntaxError(aMessage.data.message)); + case "exception": + return Promise.reject( + Components.Exception(aMessage.data.message, aMessage.data.result) + ); + case "done": + this.done(aMessage.data); + break; + } + + return undefined; + } + + observe(subject, topic, data) { + this.lastObserved = { subject, topic, data }; + } + + show() { + return "TestProcessActorChild"; + } + + didDestroy() { + Services.obs.notifyObservers( + this, + "test-js-content-actor-diddestroy", + true + ); + } +} diff --git a/toolkit/actors/TestProcessActorParent.jsm b/toolkit/actors/TestProcessActorParent.jsm new file mode 100644 index 0000000000..dfa1fc1df0 --- /dev/null +++ b/toolkit/actors/TestProcessActorParent.jsm @@ -0,0 +1,41 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["TestProcessActorParent"]; + +class TestProcessActorParent extends JSProcessActorParent { + constructor() { + super(); + this.wrappedJSObject = this; + this.sawActorCreated = false; + } + + actorCreated() { + this.sawActorCreated = true; + } + + receiveMessage(aMessage) { + switch (aMessage.name) { + case "init": + aMessage.data.initial = true; + this.sendAsyncMessage("toChild", aMessage.data); + break; + case "toParent": + aMessage.data.toParent = true; + this.sendAsyncMessage("done", aMessage.data); + break; + case "asyncMul": + let { a, b } = aMessage.data; + return { result: a * b }; + } + + return undefined; + } + + show() { + return "TestProcessActorParent"; + } +} diff --git a/toolkit/actors/TestWindowChild.jsm b/toolkit/actors/TestWindowChild.jsm new file mode 100644 index 0000000000..b233dfd9bb --- /dev/null +++ b/toolkit/actors/TestWindowChild.jsm @@ -0,0 +1,104 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +var EXPORTED_SYMBOLS = ["TestWindowChild"]; + +var docShellThunks = new Map(); + +class TestWindowChild extends JSWindowActorChild { + constructor() { + super(); + this.sawActorCreated = false; + + try { + void this.contentWindow; + } catch (e) { + this.uninitializedGetterError = e; + } + } + + actorCreated() { + this.sawActorCreated = true; + } + + receiveMessage(aMessage) { + switch (aMessage.name) { + case "toChild": + aMessage.data.toChild = true; + this.sendAsyncMessage("toParent", aMessage.data); + break; + case "asyncAdd": + let { a, b } = aMessage.data; + return new Promise(resolve => { + resolve({ result: a + b }); + }); + case "error": + return Promise.reject(new SyntaxError(aMessage.data.message)); + case "exception": + return Promise.reject( + Components.Exception(aMessage.data.message, aMessage.data.result) + ); + case "done": + this.done(aMessage.data); + break; + case "noncloneReply": + // Return a value which is non-cloneable, like a WindowProxy. + return this.contentWindow; + case "storeActor": + docShellThunks.set(this.docShell, this); + break; + case "checkActor": { + let actor = docShellThunks.get(this.docShell); + docShellThunks.delete(this.docShell); + + let contentWindow; + let error; + try { + contentWindow = actor.contentWindow; + } catch (e) { + error = e; + } + if (error) { + return { + status: "error", + errorType: error.name, + }; + } + return { + status: "success", + valueIsNull: contentWindow === null, + }; + } + } + + return undefined; + } + + handleEvent(aEvent) { + this.sendAsyncMessage("event", { type: aEvent.type }); + } + + observe(subject, topic, data) { + switch (topic) { + case "audio-playback": + this.done({ subject, topic, data }); + break; + default: + this.lastObserved = { subject, topic, data }; + break; + } + } + + show() { + return "TestWindowChild"; + } + + didDestroy() { + Services.obs.notifyObservers(this, "test-js-window-actor-diddestroy", true); + } +} diff --git a/toolkit/actors/TestWindowParent.jsm b/toolkit/actors/TestWindowParent.jsm new file mode 100644 index 0000000000..ee5dcab469 --- /dev/null +++ b/toolkit/actors/TestWindowParent.jsm @@ -0,0 +1,51 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +var EXPORTED_SYMBOLS = ["TestWindowParent"]; + +class TestWindowParent extends JSWindowActorParent { + constructor() { + super(); + this.wrappedJSObject = this; + this.sawActorCreated = false; + } + + actorCreated() { + this.sawActorCreated = true; + } + + receiveMessage(aMessage) { + switch (aMessage.name) { + case "init": + aMessage.data.initial = true; + this.sendAsyncMessage("toChild", aMessage.data); + break; + case "toParent": + aMessage.data.toParent = true; + this.sendAsyncMessage("done", aMessage.data); + break; + case "asyncMul": + let { a, b } = aMessage.data; + return { result: a * b }; + + case "event": + Services.obs.notifyObservers( + this, + "test-js-window-actor-parent-event", + aMessage.data.type + ); + break; + } + + return undefined; + } + + show() { + return "TestWindowParent"; + } +} diff --git a/toolkit/actors/ThumbnailsChild.jsm b/toolkit/actors/ThumbnailsChild.jsm new file mode 100644 index 0000000000..d54a217840 --- /dev/null +++ b/toolkit/actors/ThumbnailsChild.jsm @@ -0,0 +1,62 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["ThumbnailsChild"]; +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +ChromeUtils.defineModuleGetter( + this, + "PageThumbUtils", + "resource://gre/modules/PageThumbUtils.jsm" +); + +class ThumbnailsChild extends JSWindowActorChild { + receiveMessage(message) { + switch (message.name) { + case "Browser:Thumbnail:ContentInfo": { + let [width, height] = PageThumbUtils.getContentSize(this.contentWindow); + return { width, height }; + } + case "Browser:Thumbnail:CheckState": { + /** + * Remote isSafeForCapture request handler for PageThumbs. + */ + return new Promise(resolve => + Services.tm.idleDispatchToMainThread(() => { + if (!this.manager) { + // If we have no manager, our actor has been destroyed, which + // means we can't respond, and trying to touch + // `this.contentWindow` or `this.browsingContext` will throw. + // The `sendQuery` call in the parent will already have been + // rejected when the actor was destroyed, so there's no need to + // reject our promise or log an additional error. + return; + } + + let result = PageThumbUtils.shouldStoreContentThumbnail( + this.contentWindow, + this.browsingContext.docShell + ); + resolve(result); + }) + ); + } + case "Browser:Thumbnail:GetOriginalURL": { + /** + * Remote GetOriginalURL request handler for PageThumbs. + */ + let channel = this.browsingContext.docShell.currentDocumentChannel; + let channelError = PageThumbUtils.isChannelErrorResponse(channel); + let originalURL; + try { + originalURL = channel.originalURI.spec; + } catch (ex) {} + return { channelError, originalURL }; + } + } + return undefined; + } +} diff --git a/toolkit/actors/UAWidgetsChild.jsm b/toolkit/actors/UAWidgetsChild.jsm new file mode 100644 index 0000000000..7dfa773ed4 --- /dev/null +++ b/toolkit/actors/UAWidgetsChild.jsm @@ -0,0 +1,241 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["UAWidgetsChild"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +class UAWidgetsChild extends JSWindowActorChild { + constructor() { + super(); + + this.widgets = new WeakMap(); + this.prefsCache = new Map(); + this.observedPrefs = []; + + // Bug 1570744 - JSWindowActorChild's cannot be used as nsIObserver's + // directly, so we create a new function here instead to act as our + // nsIObserver, which forwards the notification to the observe method. + this.observerFunction = (subject, topic, data) => { + this.observe(subject, topic, data); + }; + } + + didDestroy() { + for (let pref in this.observedPrefs) { + Services.prefs.removeObserver(pref, this.observerFunction); + } + } + + unwrap(obj) { + return Cu.isXrayWrapper(obj) ? obj.wrappedJSObject : obj; + } + + handleEvent(aEvent) { + switch (aEvent.type) { + case "UAWidgetSetupOrChange": + this.setupOrNotifyWidget(aEvent.target); + break; + case "UAWidgetTeardown": + this.teardownWidget(aEvent.target); + break; + } + } + + setupOrNotifyWidget(aElement) { + if (!this.widgets.has(aElement)) { + this.setupWidget(aElement); + return; + } + + let { widget } = this.widgets.get(aElement); + + if (typeof widget.onchange == "function") { + if ( + this.unwrap(aElement.openOrClosedShadowRoot) != + this.unwrap(widget.shadowRoot) + ) { + Cu.reportError( + "Getting a UAWidgetSetupOrChange event without the ShadowRoot. " + + "Torn down already?" + ); + return; + } + try { + widget.onchange(); + } catch (ex) { + Cu.reportError(ex); + } + } + } + + setupWidget(aElement) { + let uri; + let widgetName; + // Use prefKeys to optionally send a list of preferences to forward to + // the UAWidget. The UAWidget will receive those preferences as key-value + // pairs as the second argument to its constructor. Updates to those prefs + // can be observed by implementing an optional onPrefChange method for the + // UAWidget that receives the changed pref name as the first argument, and + // the updated value as the second. + let prefKeys = []; + switch (aElement.localName) { + case "video": + case "audio": + uri = "chrome://global/content/elements/videocontrols.js"; + widgetName = "VideoControlsWidget"; + prefKeys = [ + "media.videocontrols.picture-in-picture.video-toggle.enabled", + "media.videocontrols.picture-in-picture.video-toggle.always-show", + "media.videocontrols.picture-in-picture.video-toggle.min-video-secs", + "media.videocontrols.picture-in-picture.video-toggle.position", + "media.videocontrols.picture-in-picture.video-toggle.has-used", + "media.videocontrols.keyboard-tab-to-all-controls", + ]; + break; + case "input": + uri = "chrome://global/content/elements/datetimebox.js"; + widgetName = "DateTimeBoxWidget"; + break; + case "embed": + case "object": + uri = "chrome://global/content/elements/pluginProblem.js"; + widgetName = "PluginProblemWidget"; + break; + case "marquee": + uri = "chrome://global/content/elements/marquee.js"; + widgetName = "MarqueeWidget"; + break; + } + + if (!uri || !widgetName) { + Cu.reportError( + "Getting a UAWidgetSetupOrChange event on undefined element." + ); + return; + } + + let shadowRoot = aElement.openOrClosedShadowRoot; + if (!shadowRoot) { + Cu.reportError( + "Getting a UAWidgetSetupOrChange event without the Shadow Root. " + + "Torn down already?" + ); + return; + } + + let isSystemPrincipal = aElement.nodePrincipal.isSystemPrincipal; + let sandbox = isSystemPrincipal + ? Object.create(null) + : Cu.getUAWidgetScope(aElement.nodePrincipal); + + if (!sandbox[widgetName]) { + Services.scriptloader.loadSubScript(uri, sandbox); + } + + let prefs = Cu.cloneInto( + this.getPrefsForUAWidget(widgetName, prefKeys), + sandbox + ); + + let widget = new sandbox[widgetName](shadowRoot, prefs); + if (!isSystemPrincipal) { + widget = widget.wrappedJSObject; + } + if (this.unwrap(widget.shadowRoot) != this.unwrap(shadowRoot)) { + Cu.reportError("Widgets should expose their shadow root."); + } + this.widgets.set(aElement, { widget, widgetName }); + try { + widget.onsetup(); + } catch (ex) { + Cu.reportError(ex); + } + } + + teardownWidget(aElement) { + if (!this.widgets.has(aElement)) { + return; + } + let { widget } = this.widgets.get(aElement); + if (typeof widget.destructor == "function") { + try { + widget.destructor(); + } catch (ex) { + Cu.reportError(ex); + } + } + this.widgets.delete(aElement); + } + + getPrefsForUAWidget(aWidgetName, aPrefKeys) { + let result = this.prefsCache.get(aWidgetName); + if (result) { + return result; + } + + result = {}; + for (let key of aPrefKeys) { + result[key] = this.getPref(key); + this.observePref(key); + } + + this.prefsCache.set(aWidgetName, result); + return result; + } + + observePref(prefKey) { + Services.prefs.addObserver(prefKey, this.observerFunction); + this.observedPrefs.push(prefKey); + } + + getPref(prefKey) { + switch (Services.prefs.getPrefType(prefKey)) { + case Ci.nsIPrefBranch.PREF_BOOL: { + return Services.prefs.getBoolPref(prefKey); + } + case Ci.nsIPrefBranch.PREF_INT: { + return Services.prefs.getIntPref(prefKey); + } + case Ci.nsIPrefBranch.PREF_STRING: { + return Services.prefs.getStringPref(prefKey); + } + } + + return undefined; + } + + observe(subject, topic, data) { + if (topic == "nsPref:changed") { + for (let [widgetName, prefCache] of this.prefsCache) { + if (prefCache.hasOwnProperty(data)) { + let newValue = this.getPref(data); + prefCache[data] = newValue; + + this.notifyWidgetsOnPrefChange(widgetName, data, newValue); + } + } + } + } + + notifyWidgetsOnPrefChange(nameOfWidgetToNotify, prefKey, newValue) { + let elements = ChromeUtils.nondeterministicGetWeakMapKeys(this.widgets); + for (let element of elements) { + if (!Cu.isDeadWrapper(element) && element.isConnected) { + let { widgetName, widget } = this.widgets.get(element); + if (widgetName == nameOfWidgetToNotify) { + if (typeof widget.onPrefChange == "function") { + try { + widget.onPrefChange(prefKey, newValue); + } catch (ex) { + Cu.reportError(ex); + } + } + } + } + } + } +} diff --git a/toolkit/actors/UnselectedTabHoverChild.jsm b/toolkit/actors/UnselectedTabHoverChild.jsm new file mode 100644 index 0000000000..1c3076462b --- /dev/null +++ b/toolkit/actors/UnselectedTabHoverChild.jsm @@ -0,0 +1,25 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["UnselectedTabHoverChild"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +class UnselectedTabHoverChild extends JSWindowActorChild { + receiveMessage(message) { + Services.obs.notifyObservers( + this.contentWindow, + "unselected-tab-hover", + message.data.hovered + ); + } + + handleEvent(event) { + this.sendAsyncMessage("UnselectedTabHover:Toggle", { + enable: event.type == "UnselectedTabHover:Enable", + }); + } +} diff --git a/toolkit/actors/UnselectedTabHoverParent.jsm b/toolkit/actors/UnselectedTabHoverParent.jsm new file mode 100644 index 0000000000..96e836b5d6 --- /dev/null +++ b/toolkit/actors/UnselectedTabHoverParent.jsm @@ -0,0 +1,18 @@ +/* vim: set ts=2 sw=2 sts=2 et tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this + * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ +"use strict"; + +var EXPORTED_SYMBOLS = ["UnselectedTabHoverParent"]; + +class UnselectedTabHoverParent extends JSWindowActorParent { + receiveMessage(message) { + const topBrowsingContext = this.manager.browsingContext.top; + const browser = topBrowsingContext.embedderElement; + if (!browser) { + return; + } + browser.shouldHandleUnselectedTabHover = message.data.enable; + } +} diff --git a/toolkit/actors/ViewSourceChild.jsm b/toolkit/actors/ViewSourceChild.jsm new file mode 100644 index 0000000000..471d5929b3 --- /dev/null +++ b/toolkit/actors/ViewSourceChild.jsm @@ -0,0 +1,353 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* 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/. */ + +var EXPORTED_SYMBOLS = ["ViewSourceChild"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +ChromeUtils.defineModuleGetter( + this, + "ViewSourcePageChild", + "resource://gre/actors/ViewSourcePageChild.jsm" +); + +class ViewSourceChild extends JSWindowActorChild { + receiveMessage(message) { + let data = message.data; + switch (message.name) { + case "ViewSource:LoadSource": + this.viewSource(data.URL, data.outerWindowID, data.lineNumber); + break; + case "ViewSource:LoadSourceWithSelection": + this.viewSourceWithSelection( + data.URL, + data.drawSelection, + data.baseURI + ); + break; + case "ViewSource:GetSelection": + let selectionDetails; + try { + selectionDetails = this.getSelection(this.document.ownerGlobal); + } catch (e) {} + return selectionDetails; + } + + return undefined; + } + + /** + * Called when the parent sends a message to view some source code. + * + * @param URL (required) + * The URL string of the source to be shown. + * @param outerWindowID (optional) + * The outerWindowID of the content window that has hosted + * the document, in case we want to retrieve it from the network + * cache. + * @param lineNumber (optional) + * The line number to focus as soon as the source has finished + * loading. + */ + viewSource(URL, outerWindowID, lineNumber) { + let otherDocShell, forcedCharSet; + + if (outerWindowID) { + let contentWindow = Services.wm.getOuterWindowWithId(outerWindowID); + if (contentWindow) { + otherDocShell = contentWindow.docShell; + + let utils = contentWindow.windowUtils; + let doc = contentWindow.document; + forcedCharSet = utils.docCharsetIsForced ? doc.characterSet : null; + } + } + + this.loadSource(URL, otherDocShell, lineNumber, forcedCharSet); + } + + /** + * Loads a view source selection showing the given view-source url and + * highlight the selection. + * + * @param uri view-source uri to show + * @param drawSelection true to highlight the selection + * @param baseURI base URI of the original document + */ + viewSourceWithSelection(uri, drawSelection, baseURI) { + // This isn't ideal, but set a global in the view source page actor + // that indicates that a selection should be drawn. It will be read + // when by the page's pageshow listener. This should work as the + // view source page is always loaded in the same process. + ViewSourcePageChild.setNeedsDrawSelection(drawSelection); + + // all our content is held by the data:URI and URIs are internally stored as utf-8 (see nsIURI.idl) + let loadFlags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE; + let webNav = this.docShell.QueryInterface(Ci.nsIWebNavigation); + let loadURIOptions = { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + loadFlags, + baseURI: Services.io.newURI(baseURI), + }; + webNav.loadURI(uri, loadURIOptions); + } + + /** + * Common utility function used by both the current and deprecated APIs + * for loading source. + * + * @param URL (required) + * The URL string of the source to be shown. + * @param otherDocShell (optional) + * The docshell of the content window that is hosting the document. + * @param lineNumber (optional) + * The line number to focus as soon as the source has finished + * loading. + * @param forcedCharSet (optional) + * The document character set to use instead of the default one. + */ + loadSource(URL, otherDocShell, lineNumber, forcedCharSet) { + const viewSrcURL = "view-source:" + URL; + + if (forcedCharSet) { + try { + this.docShell.charset = forcedCharSet; + } catch (e) { + /* invalid charset */ + } + } + + ViewSourcePageChild.setInitialLineNumber(lineNumber); + + if (!otherDocShell) { + this.loadSourceFromURL(viewSrcURL); + return; + } + + try { + let pageLoader = this.docShell.QueryInterface(Ci.nsIWebPageDescriptor); + pageLoader.loadPageAsViewSource(otherDocShell, viewSrcURL); + } catch (e) { + // We were not able to load the source from the network cache. + this.loadSourceFromURL(viewSrcURL); + } + } + + /** + * Load some URL in the browser. + * + * @param URL + * The URL string to load. + */ + loadSourceFromURL(URL) { + let loadFlags = Ci.nsIWebNavigation.LOAD_FLAGS_NONE; + let webNav = this.docShell.QueryInterface(Ci.nsIWebNavigation); + let loadURIOptions = { + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + loadFlags, + }; + webNav.loadURI(URL, loadURIOptions); + } + + /** + * A helper to get a path like FIXptr, but with an array instead of the + * "tumbler" notation. + * See FIXptr: http://lists.w3.org/Archives/Public/www-xml-linking-comments/2001AprJun/att-0074/01-NOTE-FIXptr-20010425.htm + */ + getPath(ancestor, node) { + var n = node; + var p = n.parentNode; + if (n == ancestor || !p) { + return null; + } + var path = []; + if (!path) { + return null; + } + do { + for (var i = 0; i < p.childNodes.length; i++) { + if (p.childNodes.item(i) == n) { + path.push(i); + break; + } + } + n = p; + p = n.parentNode; + } while (n != ancestor && p); + return path; + } + + getSelection(global) { + const { content } = global; + + // These are markers used to delimit the selection during processing. They + // are removed from the final rendering. + // We use noncharacter Unicode codepoints to minimize the risk of clashing + // with anything that might legitimately be present in the document. + // U+FDD0..FDEF <noncharacters> + const MARK_SELECTION_START = "\uFDD0"; + const MARK_SELECTION_END = "\uFDEF"; + + var focusedWindow = Services.focus.focusedWindow || content; + var selection = focusedWindow.getSelection(); + + var range = selection.getRangeAt(0); + var ancestorContainer = range.commonAncestorContainer; + var doc = ancestorContainer.ownerDocument; + + var startContainer = range.startContainer; + var endContainer = range.endContainer; + var startOffset = range.startOffset; + var endOffset = range.endOffset; + + // let the ancestor be an element + var Node = doc.defaultView.Node; + if ( + ancestorContainer.nodeType == Node.TEXT_NODE || + ancestorContainer.nodeType == Node.CDATA_SECTION_NODE + ) { + ancestorContainer = ancestorContainer.parentNode; + } + + // for selectAll, let's use the entire document, including <html>...</html> + // @see nsDocumentViewer::SelectAll() for how selectAll is implemented + try { + if (ancestorContainer == doc.body) { + ancestorContainer = doc.documentElement; + } + } catch (e) {} + + // each path is a "child sequence" (a.k.a. "tumbler") that + // descends from the ancestor down to the boundary point + var startPath = this.getPath(ancestorContainer, startContainer); + var endPath = this.getPath(ancestorContainer, endContainer); + + // clone the fragment of interest and reset everything to be relative to it + // note: it is with the clone that we operate/munge from now on. Also note + // that we clone into a data document to prevent images in the fragment from + // loading and the like. The use of importNode here, as opposed to adoptNode, + // is _very_ important. + // XXXbz wish there were a less hacky way to create an untrusted document here + var isHTML = doc.createElement("div").tagName == "DIV"; + var dataDoc = isHTML + ? ancestorContainer.ownerDocument.implementation.createHTMLDocument("") + : ancestorContainer.ownerDocument.implementation.createDocument( + "", + "", + null + ); + ancestorContainer = dataDoc.importNode(ancestorContainer, true); + startContainer = ancestorContainer; + endContainer = ancestorContainer; + + // Only bother with the selection if it can be remapped. Don't mess with + // leaf elements (such as <isindex>) that secretly use anynomous content + // for their display appearance. + var canDrawSelection = ancestorContainer.hasChildNodes(); + var tmpNode; + if (canDrawSelection) { + var i; + for (i = startPath ? startPath.length - 1 : -1; i >= 0; i--) { + startContainer = startContainer.childNodes.item(startPath[i]); + } + for (i = endPath ? endPath.length - 1 : -1; i >= 0; i--) { + endContainer = endContainer.childNodes.item(endPath[i]); + } + + // add special markers to record the extent of the selection + // note: |startOffset| and |endOffset| are interpreted either as + // offsets in the text data or as child indices (see the Range spec) + // (here, munging the end point first to keep the start point safe...) + if ( + endContainer.nodeType == Node.TEXT_NODE || + endContainer.nodeType == Node.CDATA_SECTION_NODE + ) { + // do some extra tweaks to try to avoid the view-source output to look like + // ...<tag>]... or ...]</tag>... (where ']' marks the end of the selection). + // To get a neat output, the idea here is to remap the end point from: + // 1. ...<tag>]... to ...]<tag>... + // 2. ...]</tag>... to ...</tag>]... + if ( + (endOffset > 0 && endOffset < endContainer.data.length) || + !endContainer.parentNode || + !endContainer.parentNode.parentNode + ) { + endContainer.insertData(endOffset, MARK_SELECTION_END); + } else { + tmpNode = dataDoc.createTextNode(MARK_SELECTION_END); + endContainer = endContainer.parentNode; + if (endOffset === 0) { + endContainer.parentNode.insertBefore(tmpNode, endContainer); + } else { + endContainer.parentNode.insertBefore( + tmpNode, + endContainer.nextSibling + ); + } + } + } else { + tmpNode = dataDoc.createTextNode(MARK_SELECTION_END); + endContainer.insertBefore( + tmpNode, + endContainer.childNodes.item(endOffset) + ); + } + + if ( + startContainer.nodeType == Node.TEXT_NODE || + startContainer.nodeType == Node.CDATA_SECTION_NODE + ) { + // do some extra tweaks to try to avoid the view-source output to look like + // ...<tag>[... or ...[</tag>... (where '[' marks the start of the selection). + // To get a neat output, the idea here is to remap the start point from: + // 1. ...<tag>[... to ...[<tag>... + // 2. ...[</tag>... to ...</tag>[... + if ( + (startOffset > 0 && startOffset < startContainer.data.length) || + !startContainer.parentNode || + !startContainer.parentNode.parentNode || + startContainer != startContainer.parentNode.lastChild + ) { + startContainer.insertData(startOffset, MARK_SELECTION_START); + } else { + tmpNode = dataDoc.createTextNode(MARK_SELECTION_START); + startContainer = startContainer.parentNode; + if (startOffset === 0) { + startContainer.parentNode.insertBefore(tmpNode, startContainer); + } else { + startContainer.parentNode.insertBefore( + tmpNode, + startContainer.nextSibling + ); + } + } + } else { + tmpNode = dataDoc.createTextNode(MARK_SELECTION_START); + startContainer.insertBefore( + tmpNode, + startContainer.childNodes.item(startOffset) + ); + } + } + + // now extract and display the syntax highlighted source + tmpNode = dataDoc.createElementNS("http://www.w3.org/1999/xhtml", "div"); + tmpNode.appendChild(ancestorContainer); + + return { + URL: + (isHTML + ? "view-source:data:text/html;charset=utf-8," + : "view-source:data:application/xml;charset=utf-8,") + + encodeURIComponent(tmpNode.innerHTML), + drawSelection: canDrawSelection, + baseURI: doc.baseURI, + }; + } + + get wrapLongLines() { + return Services.prefs.getBoolPref("view_source.wrap_long_lines"); + } +} diff --git a/toolkit/actors/ViewSourcePageChild.jsm b/toolkit/actors/ViewSourcePageChild.jsm new file mode 100644 index 0000000000..b96a5431d1 --- /dev/null +++ b/toolkit/actors/ViewSourcePageChild.jsm @@ -0,0 +1,555 @@ +/* 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/. */ + +/* eslint-env mozilla/frame-script */ + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); + +var EXPORTED_SYMBOLS = ["ViewSourcePageChild"]; + +XPCOMUtils.defineLazyGlobalGetters(this, ["NodeFilter"]); + +const NS_XHTML = "http://www.w3.org/1999/xhtml"; +const BUNDLE_URL = "chrome://global/locale/viewSource.properties"; + +// These are markers used to delimit the selection during processing. They +// are removed from the final rendering. +// We use noncharacter Unicode codepoints to minimize the risk of clashing +// with anything that might legitimately be present in the document. +// U+FDD0..FDEF <noncharacters> +const MARK_SELECTION_START = "\uFDD0"; +const MARK_SELECTION_END = "\uFDEF"; + +/** + * When showing selection source, chrome will construct a page fragment to + * show, and then instruct content to draw a selection after load. This is + * set true when there is a pending request to draw selection. + */ +let gNeedsDrawSelection = false; + +/** + * Start at a specific line number. + */ +let gInitialLineNumber = -1; + +/** + * In-page context menu items that are injected after page load. + */ +let gContextMenuItems = [ + { + id: "goToLine", + accesskey: true, + handler(actor) { + actor.sendAsyncMessage("ViewSource:PromptAndGoToLine"); + }, + }, + { + id: "wrapLongLines", + get checked() { + return Services.prefs.getBoolPref("view_source.wrap_long_lines"); + }, + handler(actor) { + actor.toggleWrapping(); + }, + }, + { + id: "highlightSyntax", + get checked() { + return Services.prefs.getBoolPref("view_source.syntax_highlight"); + }, + handler(actor) { + actor.toggleSyntaxHighlighting(); + }, + }, +]; + +class ViewSourcePageChild extends JSWindowActorChild { + constructor() { + super(); + + XPCOMUtils.defineLazyGetter(this, "bundle", function() { + return Services.strings.createBundle(BUNDLE_URL); + }); + } + + static setNeedsDrawSelection(value) { + gNeedsDrawSelection = value; + } + + static setInitialLineNumber(value) { + gInitialLineNumber = value; + } + + receiveMessage(msg) { + if (msg.name == "ViewSource:GoToLine") { + this.goToLine(msg.data.lineNumber); + } + } + + /** + * Any events should get handled here, and should get dispatched to + * a specific function for the event type. + */ + handleEvent(event) { + switch (event.type) { + case "pageshow": + this.onPageShow(event); + break; + case "click": + this.onClick(event); + break; + } + } + + /** + * A shortcut to the nsISelectionController for the content. + */ + get selectionController() { + return this.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsISelectionDisplay) + .QueryInterface(Ci.nsISelectionController); + } + + /** + * A shortcut to the nsIWebBrowserFind for the content. + */ + get webBrowserFind() { + return this.docShell + .QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebBrowserFind); + } + + /** + * This handler is for click events from: + * * error page content, which can show up if the user attempts to view the + * source of an attack page. + * * in-page context menu actions + */ + onClick(event) { + let target = event.originalTarget; + // Check for content menu actions + if (target.id) { + gContextMenuItems.forEach(itemSpec => { + if (itemSpec.id !== target.id) { + return; + } + itemSpec.handler(this); + event.stopPropagation(); + }); + } + + // Don't trust synthetic events + if (!event.isTrusted || event.target.localName != "button") { + return; + } + + let errorDoc = target.ownerDocument; + + if (/^about:blocked/.test(errorDoc.documentURI)) { + // The event came from a button on a malware/phishing block page + + if (target == errorDoc.getElementById("goBackButton")) { + // Instead of loading some safe page, just close the window + this.sendAsyncMessage("ViewSource:Close"); + } + } + } + + /** + * Handler for the pageshow event. + * + * @param event + * The pageshow event being handled. + */ + onPageShow(event) { + // If we need to draw the selection, wait until an actual view source page + // has loaded, instead of about:blank. + if ( + gNeedsDrawSelection && + this.document.documentURI.startsWith("view-source:") + ) { + gNeedsDrawSelection = false; + this.drawSelection(); + } + + if (gInitialLineNumber >= 0) { + this.goToLine(gInitialLineNumber); + gInitialLineNumber = -1; + } + + if (this.document.body) { + this.injectContextMenu(); + } + } + + /** + * Attempts to go to a particular line in the source code being + * shown. If it succeeds in finding the line, it will fire a + * "ViewSource:GoToLine:Success" message, passing up an object + * with the lineNumber we just went to. If it cannot find the line, + * it will fire a "ViewSource:GoToLine:Failed" message. + * + * @param lineNumber + * The line number to attempt to go to. + */ + goToLine(lineNumber) { + let body = this.document.body; + + // The source document is made up of a number of pre elements with + // id attributes in the format <pre id="line123">, meaning that + // the first line in the pre element is number 123. + // Do binary search to find the pre element containing the line. + // However, in the plain text case, we have only one pre without an + // attribute, so assume it begins on line 1. + let pre; + for (let lbound = 0, ubound = body.childNodes.length; ; ) { + let middle = (lbound + ubound) >> 1; + pre = body.childNodes[middle]; + + let firstLine = pre.id ? parseInt(pre.id.substring(4)) : 1; + + if (lbound == ubound - 1) { + break; + } + + if (lineNumber >= firstLine) { + lbound = middle; + } else { + ubound = middle; + } + } + + let result = {}; + let found = this.findLocation(pre, lineNumber, null, -1, false, result); + + if (!found) { + this.sendAsyncMessage("ViewSource:GoToLine:Failed"); + return; + } + + let selection = this.document.defaultView.getSelection(); + selection.removeAllRanges(); + + // In our case, the range's startOffset is after "\n" on the previous line. + // Tune the selection at the beginning of the next line and do some tweaking + // to position the focusNode and the caret at the beginning of the line. + selection.interlinePosition = true; + + selection.addRange(result.range); + + if (!selection.isCollapsed) { + selection.collapseToEnd(); + + let offset = result.range.startOffset; + let node = result.range.startContainer; + if (offset < node.data.length) { + // The same text node spans across the "\n", just focus where we were. + selection.extend(node, offset); + } else { + // There is another tag just after the "\n", hook there. We need + // to focus a safe point because there are edgy cases such as + // <span>...\n</span><span>...</span> vs. + // <span>...\n<span>...</span></span><span>...</span> + node = node.nextSibling + ? node.nextSibling + : node.parentNode.nextSibling; + selection.extend(node, 0); + } + } + + let selCon = this.selectionController; + selCon.setDisplaySelection(Ci.nsISelectionController.SELECTION_ON); + selCon.setCaretVisibilityDuringSelection(true); + + // Scroll the beginning of the line into view. + selCon.scrollSelectionIntoView( + Ci.nsISelectionController.SELECTION_NORMAL, + Ci.nsISelectionController.SELECTION_FOCUS_REGION, + true + ); + + this.sendAsyncMessage("ViewSource:GoToLine:Success", { lineNumber }); + } + + /** + * Some old code from the original view source implementation. Original + * documentation follows: + * + * "Loops through the text lines in the pre element. The arguments are either + * (pre, line) or (node, offset, interlinePosition). result is an out + * argument. If (pre, line) are specified (and node == null), result.range is + * a range spanning the specified line. If the (node, offset, + * interlinePosition) are specified, result.line and result.col are the line + * and column number of the specified offset in the specified node relative to + * the whole file." + */ + findLocation(pre, lineNumber, node, offset, interlinePosition, result) { + if (node && !pre) { + // Look upwards to find the current pre element. + // eslint-disable-next-line no-empty + for (pre = node; pre.nodeName != "PRE"; pre = pre.parentNode) {} + } + + // The source document is made up of a number of pre elements with + // id attributes in the format <pre id="line123">, meaning that + // the first line in the pre element is number 123. + // However, in the plain text case, there is only one <pre> without an id, + // so assume line 1. + let curLine = pre.id ? parseInt(pre.id.substring(4)) : 1; + + // Walk through each of the text nodes and count newlines. + let treewalker = this.document.createTreeWalker( + pre, + NodeFilter.SHOW_TEXT, + null + ); + + // The column number of the first character in the current text node. + let firstCol = 1; + + let found = false; + for ( + let textNode = treewalker.firstChild(); + textNode && !found; + textNode = treewalker.nextNode() + ) { + // \r is not a valid character in the DOM, so we only check for \n. + let lineArray = textNode.data.split(/\n/); + let lastLineInNode = curLine + lineArray.length - 1; + + // Check if we can skip the text node without further inspection. + if (node ? textNode != node : lastLineInNode < lineNumber) { + if (lineArray.length > 1) { + firstCol = 1; + } + firstCol += lineArray[lineArray.length - 1].length; + curLine = lastLineInNode; + continue; + } + + // curPos is the offset within the current text node of the first + // character in the current line. + for ( + var i = 0, curPos = 0; + i < lineArray.length; + curPos += lineArray[i++].length + 1 + ) { + if (i > 0) { + curLine++; + } + + if (node) { + if (offset >= curPos && offset <= curPos + lineArray[i].length) { + // If we are right after the \n of a line and interlinePosition is + // false, the caret looks as if it were at the end of the previous + // line, so we display that line and column instead. + + if (i > 0 && offset == curPos && !interlinePosition) { + result.line = curLine - 1; + var prevPos = curPos - lineArray[i - 1].length; + result.col = (i == 1 ? firstCol : 1) + offset - prevPos; + } else { + result.line = curLine; + result.col = (i == 0 ? firstCol : 1) + offset - curPos; + } + found = true; + + break; + } + } else if (curLine == lineNumber && !("range" in result)) { + result.range = this.document.createRange(); + result.range.setStart(textNode, curPos); + + // This will always be overridden later, except when we look for + // the very last line in the file (this is the only line that does + // not end with \n). + result.range.setEndAfter(pre.lastChild); + } else if (curLine == lineNumber + 1) { + result.range.setEnd(textNode, curPos - 1); + found = true; + break; + } + } + } + + return found || "range" in result; + } + + /** + * Toggles the "wrap" class on the document body, which sets whether + * or not long lines are wrapped. Notifies parent to update the pref. + */ + toggleWrapping() { + let body = this.document.body; + let state = body.classList.toggle("wrap"); + this.sendAsyncMessage("ViewSource:StoreWrapping", { state }); + } + + /** + * Toggles the "highlight" class on the document body, which sets whether + * or not syntax highlighting is displayed. Notifies parent to update the + * pref. + */ + toggleSyntaxHighlighting() { + let body = this.document.body; + let state = body.classList.toggle("highlight"); + this.sendAsyncMessage("ViewSource:StoreSyntaxHighlighting", { state }); + } + + /** + * Using special markers left in the serialized source, this helper makes the + * underlying markup of the selected fragment to automatically appear as + * selected on the inflated view-source DOM. + */ + drawSelection() { + this.document.title = this.bundle.GetStringFromName( + "viewSelectionSourceTitle" + ); + + // find the special selection markers that we added earlier, and + // draw the selection between the two... + var findService = null; + try { + // get the find service which stores the global find state + findService = Cc["@mozilla.org/find/find_service;1"].getService( + Ci.nsIFindService + ); + } catch (e) {} + if (!findService) { + return; + } + + // cache the current global find state + var matchCase = findService.matchCase; + var entireWord = findService.entireWord; + var wrapFind = findService.wrapFind; + var findBackwards = findService.findBackwards; + var searchString = findService.searchString; + var replaceString = findService.replaceString; + + // setup our find instance + var findInst = this.webBrowserFind; + findInst.matchCase = true; + findInst.entireWord = false; + findInst.wrapFind = true; + findInst.findBackwards = false; + + // ...lookup the start mark + findInst.searchString = MARK_SELECTION_START; + var startLength = MARK_SELECTION_START.length; + findInst.findNext(); + + var selection = this.document.defaultView.getSelection(); + if (!selection.rangeCount) { + return; + } + + var range = selection.getRangeAt(0); + + var startContainer = range.startContainer; + var startOffset = range.startOffset; + + // ...lookup the end mark + findInst.searchString = MARK_SELECTION_END; + var endLength = MARK_SELECTION_END.length; + findInst.findNext(); + + var endContainer = selection.anchorNode; + var endOffset = selection.anchorOffset; + + // reset the selection that find has left + selection.removeAllRanges(); + + // delete the special markers now... + endContainer.deleteData(endOffset, endLength); + startContainer.deleteData(startOffset, startLength); + if (startContainer == endContainer) { + endOffset -= startLength; + } // has shrunk if on same text node... + range.setEnd(endContainer, endOffset); + + // show the selection and scroll it into view + selection.addRange(range); + // the default behavior of the selection is to scroll at the end of + // the selection, whereas in this situation, it is more user-friendly + // to scroll at the beginning. So we override the default behavior here + try { + this.selectionController.scrollSelectionIntoView( + Ci.nsISelectionController.SELECTION_NORMAL, + Ci.nsISelectionController.SELECTION_ANCHOR_REGION, + true + ); + } catch (e) {} + + // restore the current find state + findService.matchCase = matchCase; + findService.entireWord = entireWord; + findService.wrapFind = wrapFind; + findService.findBackwards = findBackwards; + findService.searchString = searchString; + findService.replaceString = replaceString; + + findInst.matchCase = matchCase; + findInst.entireWord = entireWord; + findInst.wrapFind = wrapFind; + findInst.findBackwards = findBackwards; + findInst.searchString = searchString; + } + + /** + * Add context menu items for view source specific actions. + */ + injectContextMenu() { + let doc = this.document; + + let menu = doc.createElementNS(NS_XHTML, "menu"); + menu.setAttribute("type", "context"); + menu.setAttribute("id", "actions"); + doc.body.appendChild(menu); + doc.body.setAttribute("contextmenu", "actions"); + + gContextMenuItems.forEach(itemSpec => { + let item = doc.createElementNS(NS_XHTML, "menuitem"); + item.setAttribute("id", itemSpec.id); + let labelName = `context_${itemSpec.id}_label`; + let label = this.bundle.GetStringFromName(labelName); + item.setAttribute("label", label); + if ("checked" in itemSpec) { + item.setAttribute("type", "checkbox"); + } + if (itemSpec.accesskey) { + let accesskeyName = `context_${itemSpec.id}_accesskey`; + item.setAttribute( + "accesskey", + this.bundle.GetStringFromName(accesskeyName) + ); + } + menu.appendChild(item); + }); + + this.updateContextMenu(); + } + + /** + * Update state of checkbox-style context menu items. + */ + updateContextMenu() { + let doc = this.document; + gContextMenuItems.forEach(itemSpec => { + if (!("checked" in itemSpec)) { + return; + } + let item = doc.getElementById(itemSpec.id); + if (itemSpec.checked) { + item.setAttribute("checked", true); + } else { + item.removeAttribute("checked"); + } + }); + } +} diff --git a/toolkit/actors/ViewSourcePageParent.jsm b/toolkit/actors/ViewSourcePageParent.jsm new file mode 100644 index 0000000000..4070431a92 --- /dev/null +++ b/toolkit/actors/ViewSourcePageParent.jsm @@ -0,0 +1,159 @@ +// -*- indent-tabs-mode: nil; js-indent-level: 2 -*- + +/* 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/. */ + +ChromeUtils.defineModuleGetter( + this, + "Services", + "resource://gre/modules/Services.jsm" +); + +const BUNDLE_URL = "chrome://global/locale/viewSource.properties"; + +var EXPORTED_SYMBOLS = ["ViewSourcePageParent"]; + +/** + * ViewSourcePageParent manages the view source <browser> from the chrome side. + */ +class ViewSourcePageParent extends JSWindowActorParent { + constructor() { + super(); + + /** + * Holds the value of the last line found via the "Go to line" + * command, to pre-populate the prompt the next time it is + * opened. + */ + this.lastLineFound = null; + } + + /** + * Anything added to the messages array will get handled here, and should + * get dispatched to a specific function for the message name. + */ + receiveMessage(message) { + let data = message.data; + + switch (message.name) { + case "ViewSource:PromptAndGoToLine": + this.promptAndGoToLine(); + break; + case "ViewSource:GoToLine:Success": + this.onGoToLineSuccess(data.lineNumber); + break; + case "ViewSource:GoToLine:Failed": + this.onGoToLineFailed(); + break; + case "ViewSource:StoreWrapping": + this.storeWrapping(data.state); + break; + case "ViewSource:StoreSyntaxHighlighting": + this.storeSyntaxHighlighting(data.state); + break; + } + } + + /** + * A getter for the view source string bundle. + */ + get bundle() { + if (this._bundle) { + return this._bundle; + } + return (this._bundle = Services.strings.createBundle(BUNDLE_URL)); + } + + /** + * Opens the "Go to line" prompt for a user to hop to a particular line + * of the source code they're viewing. This will keep prompting until the + * user either cancels out of the prompt, or enters a valid line number. + */ + promptAndGoToLine() { + let input = { value: this.lastLineFound }; + let window = Services.wm.getMostRecentWindow(null); + + let ok = Services.prompt.prompt( + window, + this.bundle.GetStringFromName("goToLineTitle"), + this.bundle.GetStringFromName("goToLineText"), + input, + null, + { value: 0 } + ); + + if (!ok) { + return; + } + + let line = parseInt(input.value, 10); + + if (!(line > 0)) { + Services.prompt.alert( + window, + this.bundle.GetStringFromName("invalidInputTitle"), + this.bundle.GetStringFromName("invalidInputText") + ); + this.promptAndGoToLine(); + } else { + this.goToLine(line); + } + } + + /** + * Go to a particular line of the source code. This act is asynchronous. + * + * @param lineNumber + * The line number to try to go to to. + */ + goToLine(lineNumber) { + this.sendAsyncMessage("ViewSource:GoToLine", { lineNumber }); + } + + /** + * Called when the frame script reports that a line was successfully gotten + * to. + * + * @param lineNumber + * The line number that we successfully got to. + */ + onGoToLineSuccess(lineNumber) { + // We'll pre-populate the "Go to line" prompt with this value the next + // time it comes up. + this.lastLineFound = lineNumber; + } + + /** + * Called when the child reports that we failed to go to a particular + * line. This informs the user that their selection was likely out of range, + * and then reprompts the user to try again. + */ + onGoToLineFailed() { + let window = Services.wm.getMostRecentWindow(null); + Services.prompt.alert( + window, + this.bundle.GetStringFromName("outOfRangeTitle"), + this.bundle.GetStringFromName("outOfRangeText") + ); + this.promptAndGoToLine(); + } + + /** + * Update the wrapping pref based on the child's current state. + * @param state + * Whether wrapping is currently enabled in the child. + */ + storeWrapping(state) { + Services.prefs.setBoolPref("view_source.wrap_long_lines", state); + } + + /** + * Update the syntax highlighting pref based on the child's current state. + * @param state + * Whether syntax highlighting is currently enabled in the child. + */ + storeSyntaxHighlighting(state) { + Services.prefs.setBoolPref("view_source.syntax_highlight", state); + } +} diff --git a/toolkit/actors/WebChannelChild.jsm b/toolkit/actors/WebChannelChild.jsm new file mode 100644 index 0000000000..ccdab327a1 --- /dev/null +++ b/toolkit/actors/WebChannelChild.jsm @@ -0,0 +1,140 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* 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/. */ + +/* eslint no-unused-vars: ["error", {args: "none"}] */ + +var EXPORTED_SYMBOLS = ["WebChannelChild"]; + +const { Services } = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { XPCOMUtils } = ChromeUtils.import( + "resource://gre/modules/XPCOMUtils.jsm" +); +const { ContentDOMReference } = ChromeUtils.import( + "resource://gre/modules/ContentDOMReference.jsm" +); + +// Preference containing the list (space separated) of origins that are +// allowed to send non-string values through a WebChannel, mainly for +// backwards compatability. See bug 1238128 for more information. +const URL_WHITELIST_PREF = "webchannel.allowObject.urlWhitelist"; + +let _cachedWhitelist = null; + +const CACHED_PREFS = {}; +XPCOMUtils.defineLazyPreferenceGetter( + CACHED_PREFS, + "URL_WHITELIST", + URL_WHITELIST_PREF, + "", + // Null this out so we update it. + () => (_cachedWhitelist = null) +); + +class WebChannelChild extends JSWindowActorChild { + handleEvent(event) { + if (event.type === "WebChannelMessageToChrome") { + return this._onMessageToChrome(event); + } + return undefined; + } + + receiveMessage(msg) { + if (msg.name === "WebChannelMessageToContent") { + return this._onMessageToContent(msg); + } + return undefined; + } + + _getWhitelistedPrincipals() { + if (!_cachedWhitelist) { + let urls = CACHED_PREFS.URL_WHITELIST.split(/\s+/); + _cachedWhitelist = urls.map(origin => + Services.scriptSecurityManager.createContentPrincipalFromOrigin(origin) + ); + } + return _cachedWhitelist; + } + + _onMessageToChrome(e) { + // If target is window then we want the document principal, otherwise fallback to target itself. + let principal = e.target.nodePrincipal + ? e.target.nodePrincipal + : e.target.document.nodePrincipal; + + if (e.detail) { + if (typeof e.detail != "string") { + // Check if the principal is one of the ones that's allowed to send + // non-string values for e.detail. They're whitelisted by site origin, + // so we compare on originNoSuffix in order to avoid other origin attributes + // that are not relevant here, such as containers or private browsing. + let objectsAllowed = this._getWhitelistedPrincipals().some( + whitelisted => principal.originNoSuffix == whitelisted.originNoSuffix + ); + if (!objectsAllowed) { + Cu.reportError( + "WebChannelMessageToChrome sent with an object from a non-whitelisted principal" + ); + return; + } + } + + let eventTarget = + e.target instanceof Ci.nsIDOMWindow + ? null + : ContentDOMReference.get(e.target); + this.sendAsyncMessage("WebChannelMessageToChrome", { + contentData: e.detail, + eventTarget, + principal, + }); + } else { + Cu.reportError("WebChannel message failed. No message detail."); + } + } + + _onMessageToContent(msg) { + if (msg.data && this.contentWindow) { + // msg.objects.eventTarget will be defined if sending a response to + // a WebChannelMessageToChrome event. An unsolicited send + // may not have an eventTarget defined, in this case send to the + // main content window. + let { eventTarget, principal } = msg.data; + if (!eventTarget) { + eventTarget = this.contentWindow; + } else { + eventTarget = ContentDOMReference.resolve(eventTarget); + } + if (!eventTarget) { + Cu.reportError("WebChannel message failed. No target."); + return; + } + + // Use nodePrincipal if available, otherwise fallback to document principal. + let targetPrincipal = + eventTarget instanceof Ci.nsIDOMWindow + ? eventTarget.document.nodePrincipal + : eventTarget.nodePrincipal; + + if (principal.subsumes(targetPrincipal)) { + let targetWindow = this.contentWindow; + eventTarget.dispatchEvent( + new targetWindow.CustomEvent("WebChannelMessageToContent", { + detail: Cu.cloneInto( + { + id: msg.data.id, + message: msg.data.message, + }, + targetWindow + ), + }) + ); + } else { + Cu.reportError("WebChannel message failed. Principal mismatch."); + } + } else { + Cu.reportError("WebChannel message failed. No message data."); + } + } +} diff --git a/toolkit/actors/WebChannelParent.jsm b/toolkit/actors/WebChannelParent.jsm new file mode 100644 index 0000000000..7fdeeaf490 --- /dev/null +++ b/toolkit/actors/WebChannelParent.jsm @@ -0,0 +1,96 @@ +/* -*- indent-tabs-mode: nil; js-indent-level: 2 -*- */ +/* 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/. */ + +var EXPORTED_SYMBOLS = ["WebChannelParent"]; + +const { WebChannelBroker } = ChromeUtils.import( + "resource://gre/modules/WebChannel.jsm" +); + +const ERRNO_MISSING_PRINCIPAL = 1; +const ERRNO_NO_SUCH_CHANNEL = 2; + +class WebChannelParent extends JSWindowActorParent { + receiveMessage(msg) { + let data = msg.data.contentData; + let sendingContext = { + browsingContext: this.browsingContext, + browser: this.browsingContext.top.embedderElement, + eventTarget: msg.data.eventTarget, + principal: msg.data.principal, + }; + // data must be a string except for a few legacy origins allowed by browser-content.js. + if (typeof data == "string") { + try { + data = JSON.parse(data); + } catch (e) { + Cu.reportError("Failed to parse WebChannel data as a JSON object"); + return; + } + } + + if (data && data.id) { + if (!msg.data.principal) { + this._sendErrorEventToContent( + data.id, + sendingContext, + ERRNO_MISSING_PRINCIPAL, + "Message principal missing" + ); + } else { + let validChannelFound = WebChannelBroker.tryToDeliver( + data, + sendingContext + ); + + // if no valid origins send an event that there is no such valid channel + if (!validChannelFound) { + this._sendErrorEventToContent( + data.id, + sendingContext, + ERRNO_NO_SUCH_CHANNEL, + "No Such Channel" + ); + } + } + } else { + Cu.reportError("WebChannel channel id missing"); + } + } + + /** + * + * @param id {String} + * The WebChannel id to include in the message + * @param sendingContext {Object} + * Message sending context + * @param [errorMsg] {String} + * Error message + * @private + */ + _sendErrorEventToContent(id, sendingContext, errorNo, errorMsg) { + let { eventTarget, principal } = sendingContext; + + errorMsg = errorMsg || "Web Channel Parent error"; + + let { currentWindowGlobal = null } = this.browsingContext; + if (currentWindowGlobal) { + currentWindowGlobal + .getActor("WebChannel") + .sendAsyncMessage("WebChannelMessageToContent", { + id, + message: { + errno: errorNo, + error: errorMsg, + }, + eventTarget, + principal, + }); + } else { + Cu.reportError("Failed to send a WebChannel error. Target invalid."); + } + Cu.reportError(id.toString() + " error message. " + errorMsg); + } +} diff --git a/toolkit/actors/moz.build b/toolkit/actors/moz.build new file mode 100644 index 0000000000..40729d7210 --- /dev/null +++ b/toolkit/actors/moz.build @@ -0,0 +1,70 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# 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/. + +with Files("**"): + BUG_COMPONENT = ("Toolkit", "General") + +with Files("AutoScroll*.jsm"): + BUG_COMPONENT = ("Core", "Panning and Zooming") + +with Files("Finder*.jsm"): + BUG_COMPONENT = ("Toolkit", "Find Toolbar") + +with Files("KeyPressEventModelCheckerChild.jsm"): + BUG_COMPONENT = ("Core", "DOM: Events") + +TESTING_JS_MODULES += [ + "TestProcessActorChild.jsm", + "TestProcessActorParent.jsm", + "TestWindowChild.jsm", + "TestWindowParent.jsm", +] + +FINAL_TARGET_FILES.actors += [ + "AboutHttpsOnlyErrorChild.jsm", + "AboutHttpsOnlyErrorParent.jsm", + "AudioPlaybackChild.jsm", + "AudioPlaybackParent.jsm", + "AutoCompleteChild.jsm", + "AutoCompleteParent.jsm", + "AutoplayChild.jsm", + "AutoplayParent.jsm", + "AutoScrollChild.jsm", + "AutoScrollParent.jsm", + "BackgroundThumbnailsChild.jsm", + "BrowserElementChild.jsm", + "BrowserElementParent.jsm", + "ControllersChild.jsm", + "ControllersParent.jsm", + "DateTimePickerChild.jsm", + "DateTimePickerParent.jsm", + "ExtFindChild.jsm", + "FindBarChild.jsm", + "FindBarParent.jsm", + "FinderChild.jsm", + "InlineSpellCheckerChild.jsm", + "InlineSpellCheckerParent.jsm", + "KeyPressEventModelCheckerChild.jsm", + "PictureInPictureChild.jsm", + "PopupBlockingChild.jsm", + "PopupBlockingParent.jsm", + "PrintingChild.jsm", + "PrintingParent.jsm", + "PrintingSelectionChild.jsm", + "PurgeSessionHistoryChild.jsm", + "RemotePageChild.jsm", + "SelectChild.jsm", + "SelectParent.jsm", + "ThumbnailsChild.jsm", + "UAWidgetsChild.jsm", + "UnselectedTabHoverChild.jsm", + "UnselectedTabHoverParent.jsm", + "ViewSourceChild.jsm", + "ViewSourcePageChild.jsm", + "ViewSourcePageParent.jsm", + "WebChannelChild.jsm", + "WebChannelParent.jsm", +] |