From fbd17178244a7b7c4edbb9260d1b3d132a007d29 Mon Sep 17 00:00:00 2001 From: playwright-devops Date: Mon, 23 Mar 2020 12:29:24 -0700 Subject: [PATCH] chore: bootstrap build #1051 --- accessible/base/NotificationController.h | 2 + .../interfaces/nsIAccessibleDocument.idl | 5 + accessible/xpcom/xpcAccessibleDocument.cpp | 9 + accessible/xpcom/xpcAccessibleDocument.h | 2 + browser/installer/allowed-dupes.mn | 5 + browser/installer/package-manifest.in | 5 + devtools/server/socket/websocket-server.js | 13 +- docshell/base/nsDocShell.cpp | 115 +++ docshell/base/nsDocShell.h | 17 + docshell/base/nsIDocShell.idl | 16 + dom/base/Document.cpp | 8 + dom/base/Navigator.cpp | 20 +- dom/base/Navigator.h | 2 +- dom/geolocation/Geolocation.cpp | 21 +- dom/geolocation/Geolocation.h | 4 +- dom/html/HTMLInputElement.cpp | 7 + dom/ipc/BrowserChild.cpp | 7 + dom/script/ScriptSettings.cpp | 26 +- dom/security/nsCSPUtils.cpp | 5 + dom/workers/RuntimeService.cpp | 5 +- .../permissions/nsPermissionManager.cpp | 6 +- juggler/BrowserContextManager.js | 235 +++++ juggler/Helper.js | 102 ++ juggler/NetworkObserver.js | 726 ++++++++++++++ juggler/SimpleChannel.js | 130 +++ juggler/TargetRegistry.js | 277 ++++++ juggler/components/juggler.js | 116 +++ juggler/components/juggler.manifest | 3 + juggler/components/moz.build | 9 + juggler/content/FrameTree.js | 411 ++++++++ juggler/content/NetworkMonitor.js | 62 ++ juggler/content/PageAgent.js | 921 ++++++++++++++++++ juggler/content/RuntimeAgent.js | 559 +++++++++++ juggler/content/ScrollbarManager.js | 85 ++ juggler/content/WorkerMain.js | 29 + juggler/content/floating-scrollbars.css | 47 + juggler/content/hidden-scrollbars.css | 13 + juggler/content/main.js | 178 ++++ juggler/jar.mn | 29 + juggler/moz.build | 15 + juggler/protocol/AccessibilityHandler.js | 17 + juggler/protocol/BrowserHandler.js | 178 ++++ juggler/protocol/Dispatcher.js | 194 ++++ juggler/protocol/NetworkHandler.js | 160 +++ juggler/protocol/PageHandler.js | 348 +++++++ juggler/protocol/PrimitiveTypes.js | 143 +++ juggler/protocol/Protocol.js | 778 +++++++++++++++ juggler/protocol/RuntimeHandler.js | 52 + parser/html/nsHtml5TreeOpExecutor.cpp | 5 +- .../manager/ssl/nsCertOverrideService.cpp | 2 +- services/settings/Utils.jsm | 2 +- .../statusfilter/nsBrowserStatusFilter.cpp | 12 +- toolkit/mozapps/update/UpdateService.jsm | 2 +- toolkit/toolkit.mozbuild | 1 + uriloader/base/nsDocLoader.cpp | 25 + uriloader/base/nsDocLoader.h | 5 + uriloader/base/nsIWebProgress.idl | 7 +- uriloader/base/nsIWebProgressListener2.idl | 23 + 58 files changed, 6167 insertions(+), 34 deletions(-) create mode 100644 juggler/BrowserContextManager.js create mode 100644 juggler/Helper.js create mode 100644 juggler/NetworkObserver.js create mode 100644 juggler/SimpleChannel.js create mode 100644 juggler/TargetRegistry.js create mode 100644 juggler/components/juggler.js create mode 100644 juggler/components/juggler.manifest create mode 100644 juggler/components/moz.build create mode 100644 juggler/content/FrameTree.js create mode 100644 juggler/content/NetworkMonitor.js create mode 100644 juggler/content/PageAgent.js create mode 100644 juggler/content/RuntimeAgent.js create mode 100644 juggler/content/ScrollbarManager.js create mode 100644 juggler/content/WorkerMain.js create mode 100644 juggler/content/floating-scrollbars.css create mode 100644 juggler/content/hidden-scrollbars.css create mode 100644 juggler/content/main.js create mode 100644 juggler/jar.mn create mode 100644 juggler/moz.build create mode 100644 juggler/protocol/AccessibilityHandler.js create mode 100644 juggler/protocol/BrowserHandler.js create mode 100644 juggler/protocol/Dispatcher.js create mode 100644 juggler/protocol/NetworkHandler.js create mode 100644 juggler/protocol/PageHandler.js create mode 100644 juggler/protocol/PrimitiveTypes.js create mode 100644 juggler/protocol/Protocol.js create mode 100644 juggler/protocol/RuntimeHandler.js diff --git a/accessible/base/NotificationController.h b/accessible/base/NotificationController.h index c6aa1cf44c8ba..cfe64bdda54d4 100644 --- a/accessible/base/NotificationController.h +++ b/accessible/base/NotificationController.h @@ -270,6 +270,8 @@ class NotificationController final : public EventQueue, } #endif + bool IsUpdatePendingForJugglerAccessibility() { return IsUpdatePending(); } + protected: virtual ~NotificationController(); diff --git a/accessible/interfaces/nsIAccessibleDocument.idl b/accessible/interfaces/nsIAccessibleDocument.idl index a91df31c96afd..ee777c1746284 100644 --- a/accessible/interfaces/nsIAccessibleDocument.idl +++ b/accessible/interfaces/nsIAccessibleDocument.idl @@ -72,4 +72,9 @@ interface nsIAccessibleDocument : nsISupports * Return the child document accessible at the given index. */ nsIAccessibleDocument getChildDocumentAt(in unsigned long index); + + /** + * Return whether it is updating. + */ + readonly attribute boolean isUpdatePendingForJugglerAccessibility; }; diff --git a/accessible/xpcom/xpcAccessibleDocument.cpp b/accessible/xpcom/xpcAccessibleDocument.cpp index e3dbe73f22252..87f50fe3df7cc 100644 --- a/accessible/xpcom/xpcAccessibleDocument.cpp +++ b/accessible/xpcom/xpcAccessibleDocument.cpp @@ -143,6 +143,15 @@ xpcAccessibleDocument::GetVirtualCursor(nsIAccessiblePivot** aVirtualCursor) { return NS_OK; } + +NS_IMETHODIMP +xpcAccessibleDocument::GetIsUpdatePendingForJugglerAccessibility(bool* updating) { + NS_ENSURE_ARG_POINTER(updating); + *updating = Intl()->Controller()->IsUpdatePendingForJugglerAccessibility(); + return NS_OK; +} + + //////////////////////////////////////////////////////////////////////////////// // xpcAccessibleDocument diff --git a/accessible/xpcom/xpcAccessibleDocument.h b/accessible/xpcom/xpcAccessibleDocument.h index f042cc1081850..65bcff9b41b94 100644 --- a/accessible/xpcom/xpcAccessibleDocument.h +++ b/accessible/xpcom/xpcAccessibleDocument.h @@ -48,6 +48,8 @@ class xpcAccessibleDocument : public xpcAccessibleHyperText, nsIAccessibleDocument** aDocument) final; NS_IMETHOD GetVirtualCursor(nsIAccessiblePivot** aVirtualCursor) final; + NS_IMETHOD GetIsUpdatePendingForJugglerAccessibility(bool* aUpdating) final; + /** * Return XPCOM wrapper for the internal accessible. */ diff --git a/browser/installer/allowed-dupes.mn b/browser/installer/allowed-dupes.mn index 25b1dc48f42fa..7da6b76cfbee3 100644 --- a/browser/installer/allowed-dupes.mn +++ b/browser/installer/allowed-dupes.mn @@ -139,6 +139,11 @@ browser/chrome/browser/res/payments/formautofill/autofillEditForms.js # Bug 1451050 - Remote settings empty dumps (will be populated with data eventually) browser/defaults/settings/pinning/pins.json browser/defaults/settings/main/example.json +# Juggler/marionette files +chrome/juggler/content/content/floating-scrollbars.css +browser/chrome/devtools/skin/floating-scrollbars-responsive-design.css +chrome/juggler/content/server/stream-utils.js +chrome/marionette/content/stream-utils.js #ifdef MOZ_EME_WIN32_ARTIFACT gmp-clearkey/0.1/manifest.json i686/gmp-clearkey/0.1/manifest.json diff --git a/browser/installer/package-manifest.in b/browser/installer/package-manifest.in index 7054749357ec1..c9064880ecf7e 100644 --- a/browser/installer/package-manifest.in +++ b/browser/installer/package-manifest.in @@ -211,6 +211,11 @@ @RESPATH@/components/marionette.js #endif +@RESPATH@/chrome/juggler@JAREXT@ +@RESPATH@/chrome/juggler.manifest +@RESPATH@/components/juggler.manifest +@RESPATH@/components/juggler.js + #if defined(ENABLE_TESTS) && defined(MOZ_DEBUG) @RESPATH@/components/TestInterfaceJS.js @RESPATH@/components/TestInterfaceJS.manifest diff --git a/devtools/server/socket/websocket-server.js b/devtools/server/socket/websocket-server.js index 040c7b124dec6..b4e6b8132786a 100644 --- a/devtools/server/socket/websocket-server.js +++ b/devtools/server/socket/websocket-server.js @@ -133,13 +133,12 @@ function writeHttpResponse(output, response) { * Process the WebSocket handshake headers and return the key to be sent in * Sec-WebSocket-Accept response header. */ -function processRequest({ requestLine, headers }) { +function processRequest({ requestLine, headers }, expectedPath) { const [method, path] = requestLine.split(" "); if (method !== "GET") { throw new Error("The handshake request must use GET method"); } - - if (path !== "/") { + if (path !== expectedPath) { throw new Error("The handshake request has unknown path"); } @@ -189,13 +188,13 @@ function computeKey(key) { /** * Perform the server part of a WebSocket opening handshake on an incoming connection. */ -const serverHandshake = async function(input, output) { +const serverHandshake = async function(input, output, expectedPath) { // Read the request const request = await readHttpRequest(input); try { // Check and extract info from the request - const { acceptKey } = processRequest(request); + const { acceptKey } = processRequest(request, expectedPath); // Send response headers await writeHttpResponse(output, [ @@ -217,8 +216,8 @@ const serverHandshake = async function(input, output) { * Performs the WebSocket handshake and waits for the WebSocket to open. * Returns Promise with a WebSocket ready to send and receive messages. */ -const accept = async function(transport, input, output) { - await serverHandshake(input, output); +const accept = async function(transport, input, output, expectedPath) { + await serverHandshake(input, output, expectedPath || "/"); const transportProvider = { setListener(upgradeListener) { diff --git a/docshell/base/nsDocShell.cpp b/docshell/base/nsDocShell.cpp index 344bd4a9f5246..38acc7e3636f3 100644 --- a/docshell/base/nsDocShell.cpp +++ b/docshell/base/nsDocShell.cpp @@ -53,6 +53,7 @@ #include "mozilla/dom/ContentFrameMessageManager.h" #include "mozilla/dom/DocGroup.h" #include "mozilla/dom/Element.h" +#include "mozilla/dom/Geolocation.h" #include "mozilla/dom/HTMLAnchorElement.h" #include "mozilla/dom/PerformanceNavigation.h" #include "mozilla/dom/PermissionMessageUtils.h" @@ -96,6 +97,7 @@ #include "nsIDocShellTreeItem.h" #include "nsIDocShellTreeOwner.h" #include "mozilla/dom/Document.h" +#include "mozilla/dom/Element.h" #include "nsIDocumentLoaderFactory.h" #include "nsIDOMWindow.h" #include "nsIEditingSession.h" @@ -351,6 +353,9 @@ nsDocShell::nsDocShell(BrowsingContext* aBrowsingContext, mUseStrictSecurityChecks(false), mObserveErrorPages(true), mCSSErrorReportingEnabled(false), + mFileInputInterceptionEnabled(false), + mBypassCSPEnabled(false), + mOnlineOverride(nsIDocShell::ONLINE_OVERRIDE_NONE), mAllowAuth(mItemType == typeContent), mAllowKeywordFixup(false), mIsOffScreenBrowser(false), @@ -1223,6 +1228,7 @@ bool nsDocShell::SetCurrentURI(nsIURI* aURI, nsIRequest* aRequest, isSubFrame = mLSHE->GetIsSubFrame(); } + FireOnFrameLocationChange(this, aRequest, aURI, aLocationFlags); if (!isSubFrame && !isRoot) { /* * We don't want to send OnLocationChange notifications when @@ -3363,6 +3369,109 @@ nsDocShell::GetMessageManager(ContentFrameMessageManager** aMessageManager) { return NS_OK; } +// =============== Juggler Begin ======================= + +nsDocShell* nsDocShell::GetRootDocShell() { + nsCOMPtr rootAsItem; + GetInProcessSameTypeRootTreeItem(getter_AddRefs(rootAsItem)); + nsCOMPtr rootShell = do_QueryInterface(rootAsItem); + return nsDocShell::Cast(rootShell); +} + +NS_IMETHODIMP +nsDocShell::GetBypassCSPEnabled(bool* aEnabled) { + MOZ_ASSERT(aEnabled); + *aEnabled = mBypassCSPEnabled; + return NS_OK; +} + +NS_IMETHODIMP +nsDocShell::SetBypassCSPEnabled(bool aEnabled) { + mBypassCSPEnabled = aEnabled; + return NS_OK; +} + +bool nsDocShell::IsBypassCSPEnabled() { + return GetRootDocShell()->mBypassCSPEnabled; +} + +NS_IMETHODIMP +nsDocShell::GetLanguageOverride(nsAString& aLanguageOverride) { + MOZ_ASSERT(aEnabled); + aLanguageOverride = GetRootDocShell()->mLanguageOverride; + return NS_OK; +} + +NS_IMETHODIMP +nsDocShell::SetLanguageOverride(const nsAString& aLanguageOverride) { + mLanguageOverride = aLanguageOverride; + return NS_OK; +} + +NS_IMETHODIMP +nsDocShell::GetFileInputInterceptionEnabled(bool* aEnabled) { + MOZ_ASSERT(aEnabled); + *aEnabled = GetRootDocShell()->mFileInputInterceptionEnabled; + return NS_OK; +} + +NS_IMETHODIMP +nsDocShell::SetFileInputInterceptionEnabled(bool aEnabled) { + mFileInputInterceptionEnabled = aEnabled; + return NS_OK; +} + +bool nsDocShell::IsFileInputInterceptionEnabled() { + return GetRootDocShell()->mFileInputInterceptionEnabled; +} + +void nsDocShell::FilePickerShown(mozilla::dom::Element* element) { + nsCOMPtr observerService = + mozilla::services::GetObserverService(); + observerService->NotifyObservers( + ToSupports(element), "juggler-file-picker-shown", nullptr); +} + +RefPtr nsDocShell::GetGeolocationServiceOverride() { + return GetRootDocShell()->mGeolocationServiceOverride; +} + +NS_IMETHODIMP +nsDocShell::SetGeolocationOverride(nsIDOMGeoPosition* aGeolocationOverride) { + if (aGeolocationOverride) { + if (!mGeolocationServiceOverride) { + mGeolocationServiceOverride = new nsGeolocationService(); + mGeolocationServiceOverride->Init(); + } + mGeolocationServiceOverride->Update(aGeolocationOverride); + } else { + mGeolocationServiceOverride = nullptr; + } + return NS_OK; +} + +NS_IMETHODIMP +nsDocShell::GetOnlineOverride(OnlineOverride* aOnlineOverride) { + *aOnlineOverride = GetRootDocShell()->mOnlineOverride; + return NS_OK; +} + +NS_IMETHODIMP +nsDocShell::SetOnlineOverride(OnlineOverride aOnlineOverride) { + // We don't have a way to verify this coming from Javascript, so this check is + // still needed. + if (!(aOnlineOverride == ONLINE_OVERRIDE_NONE || + aOnlineOverride == ONLINE_OVERRIDE_ONLINE || + aOnlineOverride == ONLINE_OVERRIDE_OFFLINE)) { + return NS_ERROR_INVALID_ARG; + } + + mOnlineOverride = aOnlineOverride; + return NS_OK; +} + +// =============== Juggler End ======================= + NS_IMETHODIMP nsDocShell::GetIsNavigating(bool* aOut) { *aOut = mIsNavigating; @@ -12138,6 +12247,9 @@ class OnLinkClickEvent : public Runnable { mNoOpenerImplied, nullptr, nullptr, mIsUserTriggered, mTriggeringPrincipal, mCsp); } + nsCOMPtr observerService = mozilla::services::GetObserverService(); + observerService->NotifyObservers(ToSupports(mContent), "juggler-link-click-sync", nullptr); + return NS_OK; } @@ -12227,6 +12339,9 @@ nsresult nsDocShell::OnLinkClick( this, aContent, aURI, target, aFileName, aPostDataStream, aHeadersDataStream, noOpenerImplied, aIsUserTriggered, aIsTrusted, aTriggeringPrincipal, aCsp); + + nsCOMPtr observerService = mozilla::services::GetObserverService(); + observerService->NotifyObservers(ToSupports(aContent), "juggler-link-click", nullptr); return DispatchToTabGroup(TaskCategory::UI, ev.forget()); } diff --git a/docshell/base/nsDocShell.h b/docshell/base/nsDocShell.h index bd8327aae45f1..073b11500232c 100644 --- a/docshell/base/nsDocShell.h +++ b/docshell/base/nsDocShell.h @@ -13,6 +13,7 @@ #include "Units.h" #include "jsapi.h" #include "mozilla/BasePrincipal.h" +#include "mozilla/dom/Geolocation.h" #include "mozilla/HalScreenConfiguration.h" #include "mozilla/LinkedList.h" #include "mozilla/Maybe.h" @@ -25,6 +26,7 @@ #include "mozilla/UniquePtr.h" #include "mozilla/WeakPtr.h" #include "mozilla/dom/BrowsingContext.h" +#include "mozilla/dom/Element.h" #include "mozilla/dom/ChildSHistory.h" #include "mozilla/dom/ProfileTimelineMarkerBinding.h" #include "mozilla/dom/WindowProxyHolder.h" @@ -481,6 +483,13 @@ class nsDocShell final : public nsDocLoader, mSkipBrowsingContextDetachOnDestroy = true; } + bool IsFileInputInterceptionEnabled(); + void FilePickerShown(mozilla::dom::Element* element); + + bool IsBypassCSPEnabled(); + + RefPtr GetGeolocationServiceOverride(); + // Create a content viewer within this nsDocShell for the given // `WindowGlobalChild` actor. nsresult CreateContentViewerForActor( @@ -1040,6 +1049,8 @@ class nsDocShell final : public nsDocLoader, bool CSSErrorReportingEnabled() const { return mCSSErrorReportingEnabled; } + nsDocShell* GetRootDocShell(); + // Handles retrieval of subframe session history for nsDocShell::LoadURI. If a // load is requested in a subframe of the current DocShell, the subframe // loadType may need to reflect the loadType of the parent document, or in @@ -1296,6 +1307,12 @@ class nsDocShell final : public nsDocLoader, bool mUseStrictSecurityChecks : 1; bool mObserveErrorPages : 1; bool mCSSErrorReportingEnabled : 1; + bool mFileInputInterceptionEnabled: 1; + bool mBypassCSPEnabled : 1; + nsString mLanguageOverride; + RefPtr mGeolocationServiceOverride; + OnlineOverride mOnlineOverride; + bool mAllowAuth : 1; bool mAllowKeywordFixup : 1; bool mIsOffScreenBrowser : 1; diff --git a/docshell/base/nsIDocShell.idl b/docshell/base/nsIDocShell.idl index db95b181388e8..23030bc98a517 100644 --- a/docshell/base/nsIDocShell.idl +++ b/docshell/base/nsIDocShell.idl @@ -44,6 +44,7 @@ interface nsIURI; interface nsIChannel; interface nsIContentViewer; interface nsIContentSecurityPolicy; +interface nsIDOMGeoPosition; interface nsIDocShellLoadInfo; interface nsIEditor; interface nsIEditingSession; @@ -1132,4 +1133,19 @@ interface nsIDocShell : nsIDocShellTreeItem * @see nsISHEntry synchronizeLayoutHistoryState(). */ void synchronizeLayoutHistoryState(); + + attribute boolean fileInputInterceptionEnabled; + + attribute boolean bypassCSPEnabled; + + attribute AString languageOverride; + + cenum OnlineOverride: 8 { + ONLINE_OVERRIDE_NONE = 0, + ONLINE_OVERRIDE_ONLINE = 1, + ONLINE_OVERRIDE_OFFLINE = 2, + }; + [infallible] attribute nsIDocShell_OnlineOverride onlineOverride; + + void setGeolocationOverride(in nsIDOMGeoPosition position); }; diff --git a/dom/base/Document.cpp b/dom/base/Document.cpp index 394004780db40..941ef9f97a485 100644 --- a/dom/base/Document.cpp +++ b/dom/base/Document.cpp @@ -3278,6 +3278,9 @@ void Document::SendToConsole(nsCOMArray& aMessages) { } void Document::ApplySettingsFromCSP(bool aSpeculative) { + if (mDocumentContainer && mDocumentContainer->IsBypassCSPEnabled()) + return; + nsresult rv = NS_OK; if (!aSpeculative) { // 1) apply settings from regular CSP @@ -3327,6 +3330,11 @@ nsresult Document::InitCSP(nsIChannel* aChannel) { return NS_OK; } + nsCOMPtr shell(mDocumentContainer); + if (shell && nsDocShell::Cast(shell)->IsBypassCSPEnabled()) { + return NS_OK; + } + // If this is a data document - no need to set CSP. if (mLoadedAsData) { return NS_OK; diff --git a/dom/base/Navigator.cpp b/dom/base/Navigator.cpp index 061f19c31fa39..cb04aed0e46e9 100644 --- a/dom/base/Navigator.cpp +++ b/dom/base/Navigator.cpp @@ -313,14 +313,18 @@ void Navigator::GetAppName(nsAString& aAppName, CallerType aCallerType) const { * An empty array will be returned if there is no valid languages. */ /* static */ -void Navigator::GetAcceptLanguages(nsTArray& aLanguages) { +void Navigator::GetAcceptLanguages(const nsString* aLanguageOverride, nsTArray& aLanguages) { MOZ_ASSERT(NS_IsMainThread()); aLanguages.Clear(); // E.g. "de-de, en-us,en". nsAutoString acceptLang; - Preferences::GetLocalizedString("intl.accept_languages", acceptLang); + if (aLanguageOverride && aLanguageOverride->Length()) + acceptLang = *aLanguageOverride; + else + Preferences::GetLocalizedString("intl.accept_languages", acceptLang); + // Split values on commas. nsCharSeparatedTokenizer langTokenizer(acceptLang, ','); @@ -376,7 +380,9 @@ void Navigator::GetLanguage(nsAString& aLanguage) { } void Navigator::GetLanguages(nsTArray& aLanguages) { - GetAcceptLanguages(aLanguages); + nsString languageOverride; + mWindow->GetDocShell()->GetLanguageOverride(languageOverride); + GetAcceptLanguages(&languageOverride, aLanguages); // The returned value is cached by the binding code. The window listen to the // accept languages change and will clear the cache when needed. It has to @@ -530,7 +536,13 @@ bool Navigator::CookieEnabled() { return granted; } -bool Navigator::OnLine() { return !NS_IsOffline(); } +bool Navigator::OnLine() { + nsDocShell* docShell = static_cast(GetDocShell()); + nsIDocShell::OnlineOverride onlineOverride; + if (!docShell || docShell->GetOnlineOverride(&onlineOverride) != NS_OK || onlineOverride == nsIDocShell::ONLINE_OVERRIDE_NONE) + return !NS_IsOffline(); + return onlineOverride == nsIDocShell::ONLINE_OVERRIDE_ONLINE; +} void Navigator::GetBuildID(nsAString& aBuildID, CallerType aCallerType, ErrorResult& aRv) const { diff --git a/dom/base/Navigator.h b/dom/base/Navigator.h index 1ad4f2a680177..a867a49b16475 100644 --- a/dom/base/Navigator.h +++ b/dom/base/Navigator.h @@ -218,7 +218,7 @@ class Navigator final : public nsISupports, public nsWrapperCache { StorageManager* Storage(); - static void GetAcceptLanguages(nsTArray& aLanguages); + static void GetAcceptLanguages(const nsString* aLanguageOverride, nsTArray& aLanguages); dom::MediaCapabilities* MediaCapabilities(); dom::MediaSession* MediaSession(); diff --git a/dom/geolocation/Geolocation.cpp b/dom/geolocation/Geolocation.cpp index f2bb0d880f179..acf9cd6eddfa5 100644 --- a/dom/geolocation/Geolocation.cpp +++ b/dom/geolocation/Geolocation.cpp @@ -294,10 +294,8 @@ nsGeolocationRequest::Allow(JS::HandleValue aChoices) { return NS_OK; } - RefPtr gs = - nsGeolocationService::GetGeolocationService(); - - bool canUseCache = false; + nsGeolocationService* gs = mLocator->GetGeolocationService(); + bool canUseCache = gs != nsGeolocationService::sService.get(); CachedPositionAndAccuracy lastPosition = gs->GetCachedPosition(); if (lastPosition.position) { DOMTimeStamp cachedPositionTime_ms; @@ -467,8 +465,7 @@ void nsGeolocationRequest::Shutdown() { // If there are no other high accuracy requests, the geolocation service will // notify the provider to switch to the default accuracy. if (mOptions && mOptions->mEnableHighAccuracy) { - RefPtr gs = - nsGeolocationService::GetGeolocationService(); + nsGeolocationService* gs = mLocator ? mLocator->GetGeolocationService() : nullptr; if (gs) { gs->UpdateAccuracy(); } @@ -745,8 +742,14 @@ void nsGeolocationService::StopDevice() { StaticRefPtr nsGeolocationService::sService; already_AddRefed -nsGeolocationService::GetGeolocationService() { +nsGeolocationService::GetGeolocationService(nsDocShell* docShell) { RefPtr result; + if (docShell) { + result = docShell->GetGeolocationServiceOverride(); + if (result) + return result.forget(); + } + if (nsGeolocationService::sService) { result = nsGeolocationService::sService; @@ -838,7 +841,9 @@ nsresult Geolocation::Init(nsPIDOMWindowInner* aContentDom) { // If no aContentDom was passed into us, we are being used // by chrome/c++ and have no mOwner, no mPrincipal, and no need // to prompt. - mService = nsGeolocationService::GetGeolocationService(); + nsCOMPtr doc = aContentDom ? aContentDom->GetDoc() : nullptr; + mService = nsGeolocationService::GetGeolocationService( + doc ? static_cast(doc->GetDocShell()) : nullptr); if (mService) { mService->AddLocator(this); } diff --git a/dom/geolocation/Geolocation.h b/dom/geolocation/Geolocation.h index d92bd1c738016..a4c1f0ca97447 100644 --- a/dom/geolocation/Geolocation.h +++ b/dom/geolocation/Geolocation.h @@ -57,7 +57,7 @@ struct CachedPositionAndAccuracy { class nsGeolocationService final : public nsIGeolocationUpdate, public nsIObserver { public: - static already_AddRefed GetGeolocationService(); + static already_AddRefed GetGeolocationService(nsDocShell* docShell = nullptr); static mozilla::StaticRefPtr sService; NS_DECL_THREADSAFE_ISUPPORTS @@ -182,6 +182,8 @@ class Geolocation final : public nsIGeolocationUpdate, public nsWrapperCache { // null. static already_AddRefed NonWindowSingleton(); + nsGeolocationService* GetGeolocationService() { return mService; }; + private: ~Geolocation(); diff --git a/dom/html/HTMLInputElement.cpp b/dom/html/HTMLInputElement.cpp index f495214f53e65..9b09feb9425f9 100644 --- a/dom/html/HTMLInputElement.cpp +++ b/dom/html/HTMLInputElement.cpp @@ -44,6 +44,7 @@ #include "nsMappedAttributes.h" #include "nsIFormControl.h" #include "mozilla/dom/Document.h" +#include "nsDocShell.h" #include "nsIFormControlFrame.h" #include "nsITextControlFrame.h" #include "nsIFrame.h" @@ -728,6 +729,12 @@ nsresult HTMLInputElement::InitFilePicker(FilePickerType aType) { return NS_ERROR_FAILURE; } + nsDocShell* docShell = static_cast(win->GetDocShell()); + if (docShell && docShell->IsFileInputInterceptionEnabled()) { + docShell->FilePickerShown(this); + return NS_OK; + } + if (IsPopupBlocked()) { return NS_OK; } diff --git a/dom/ipc/BrowserChild.cpp b/dom/ipc/BrowserChild.cpp index 0c3a3386c7b41..17ee78af1aabf 100644 --- a/dom/ipc/BrowserChild.cpp +++ b/dom/ipc/BrowserChild.cpp @@ -3605,6 +3605,13 @@ NS_IMETHODIMP BrowserChild::OnStateChange(nsIWebProgress* aWebProgress, return NS_OK; } +NS_IMETHODIMP BrowserChild::OnFrameLocationChange(nsIWebProgress *aWebProgress, + nsIRequest *aRequest, + nsIURI *aLocation, + uint32_t aFlags) { + return NS_OK; +} + NS_IMETHODIMP BrowserChild::OnProgressChange(nsIWebProgress* aWebProgress, nsIRequest* aRequest, int32_t aCurSelfProgress, diff --git a/dom/script/ScriptSettings.cpp b/dom/script/ScriptSettings.cpp index 9e3c1a56d1039..8c661de349d6c 100644 --- a/dom/script/ScriptSettings.cpp +++ b/dom/script/ScriptSettings.cpp @@ -140,6 +140,30 @@ ScriptSettingsStackEntry::~ScriptSettingsStackEntry() { MOZ_ASSERT_IF(mGlobalObject, mGlobalObject->HasJSGlobal()); } +static nsIGlobalObject* UnwrapSandboxGlobal(nsIGlobalObject* global) { + if (!global) + return global; + JSObject* globalObject = global->GetGlobalJSObject(); + if (!globalObject) + return global; + JSContext* cx = nsContentUtils::GetCurrentJSContext(); + if (!cx) + return global; + JS::Rooted proto(cx); + JS::RootedObject rootedGlobal(cx, globalObject); + if (!JS_GetPrototype(cx, rootedGlobal, &proto)) + return global; + if (!proto || !xpc::IsSandboxPrototypeProxy(proto)) + return global; + // If this is a sandbox associated with a DOMWindow via a + // sandboxPrototype, use that DOMWindow. This supports GreaseMonkey + // and JetPack content scripts. + proto = js::CheckedUnwrapDynamic(proto, cx, /* stopAtWindowProxy = */ false); + if (!proto) + return global; + return xpc::WindowGlobalOrNull(proto); +} + // If the entry or incumbent global ends up being something that the subject // principal doesn't subsume, we don't want to use it. This never happens on // the web, but can happen with asymmetric privilege relationships (i.e. @@ -167,7 +191,7 @@ static nsIGlobalObject* ClampToSubject(nsIGlobalObject* aGlobalOrNull) { NS_ENSURE_TRUE(globalPrin, GetCurrentGlobal()); if (!nsContentUtils::SubjectPrincipalOrSystemIfNativeCaller() ->SubsumesConsideringDomain(globalPrin)) { - return GetCurrentGlobal(); + return UnwrapSandboxGlobal(GetCurrentGlobal()); } return aGlobalOrNull; diff --git a/dom/security/nsCSPUtils.cpp b/dom/security/nsCSPUtils.cpp index f0c28cfdae1c9..a38ab106e37db 100644 --- a/dom/security/nsCSPUtils.cpp +++ b/dom/security/nsCSPUtils.cpp @@ -121,6 +121,11 @@ void CSP_ApplyMetaCSPToDoc(mozilla::dom::Document& aDoc, return; } + if (aDoc.GetDocShell() && + nsDocShell::Cast(aDoc.GetDocShell())->IsBypassCSPEnabled()) { + return; + } + nsAutoString policyStr( nsContentUtils::TrimWhitespace( aPolicyStr)); diff --git a/dom/workers/RuntimeService.cpp b/dom/workers/RuntimeService.cpp index ddc2aac728a77..4a9fd6d8a437c 100644 --- a/dom/workers/RuntimeService.cpp +++ b/dom/workers/RuntimeService.cpp @@ -1065,7 +1065,7 @@ void PrefLanguagesChanged(const char* /* aPrefName */, void* /* aClosure */) { AssertIsOnMainThread(); nsTArray languages; - Navigator::GetAcceptLanguages(languages); + Navigator::GetAcceptLanguages(nullptr, languages); RuntimeService* runtime = RuntimeService::GetService(); if (runtime) { @@ -1269,8 +1269,7 @@ bool RuntimeService::RegisterWorker(WorkerPrivate* aWorkerPrivate) { } // The navigator overridden properties should have already been read. - - Navigator::GetAcceptLanguages(mNavigatorProperties.mLanguages); + Navigator::GetAcceptLanguages(nullptr, mNavigatorProperties.mLanguages); mNavigatorPropertiesLoaded = true; } diff --git a/extensions/permissions/nsPermissionManager.cpp b/extensions/permissions/nsPermissionManager.cpp index 5de630a1db847..0268bc2bdfb3b 100644 --- a/extensions/permissions/nsPermissionManager.cpp +++ b/extensions/permissions/nsPermissionManager.cpp @@ -167,7 +167,7 @@ void MaybeStripOAs(bool aForceStrip, OriginAttributes& aOriginAttributes) { } if (flags != 0) { - aOriginAttributes.StripAttributes(flags); + // aOriginAttributes.StripAttributes(flags); } } @@ -200,6 +200,8 @@ nsresult GetOriginFromPrincipal(nsIPrincipal* aPrincipal, bool aForceStripOA, OriginAppendOASuffix(attrs, aForceStripOA, aOrigin); + // Disable userContext for permissions. + // attrs.StripAttributes(mozilla::OriginAttributes::STRIP_USER_CONTEXT_ID); return NS_OK; } @@ -318,7 +320,7 @@ already_AddRefed GetNextSubDomainPrincipal( if (!StaticPrefs::permissions_isolateBy_userContext()) { // Disable userContext for permissions. - attrs.StripAttributes(mozilla::OriginAttributes::STRIP_USER_CONTEXT_ID); + // attrs.StripAttributes(mozilla::OriginAttributes::STRIP_USER_CONTEXT_ID); } nsCOMPtr principal = diff --git a/juggler/BrowserContextManager.js b/juggler/BrowserContextManager.js new file mode 100644 index 0000000000000..bd57d338c279f --- /dev/null +++ b/juggler/BrowserContextManager.js @@ -0,0 +1,235 @@ +"use strict"; + +const {ContextualIdentityService} = ChromeUtils.import("resource://gre/modules/ContextualIdentityService.jsm"); +const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const {NetUtil} = ChromeUtils.import('resource://gre/modules/NetUtil.jsm'); +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); +const {EventEmitter} = ChromeUtils.import('resource://gre/modules/EventEmitter.jsm'); +const helper = new Helper(); + +const IDENTITY_NAME = 'JUGGLER '; +const HUNDRED_YEARS = 60 * 60 * 24 * 365 * 100; + +const ALL_PERMISSIONS = [ + 'geo', + 'microphone', + 'camera', + 'desktop-notifications', +]; + +class BrowserContextManager { + static instance() { + return BrowserContextManager._instance || null; + } + + static initialize() { + if (BrowserContextManager._instance) + return; + BrowserContextManager._instance = new BrowserContextManager(); + } + + constructor() { + this._browserContextIdToBrowserContext = new Map(); + this._userContextIdToBrowserContext = new Map(); + + // Cleanup containers from previous runs (if any) + for (const identity of ContextualIdentityService.getPublicIdentities()) { + if (identity.name && identity.name.startsWith(IDENTITY_NAME)) { + ContextualIdentityService.remove(identity.userContextId); + ContextualIdentityService.closeContainerTabs(identity.userContextId); + } + } + + this._defaultContext = new BrowserContext(this, undefined, undefined); + } + + defaultContext() { + return this._defaultContext; + } + + createBrowserContext(options) { + return new BrowserContext(this, helper.generateId(), options); + } + + browserContextForId(browserContextId) { + return this._browserContextIdToBrowserContext.get(browserContextId); + } + + browserContextForUserContextId(userContextId) { + return this._userContextIdToBrowserContext.get(userContextId); + } + + getBrowserContexts() { + return Array.from(this._browserContextIdToBrowserContext.values()); + } +} + +class BrowserContext { + constructor(manager, browserContextId, options) { + EventEmitter.decorate(this); + + this._manager = manager; + this.browserContextId = browserContextId; + // Default context has userContextId === 0, but we pass undefined to many APIs just in case. + this.userContextId = 0; + if (browserContextId !== undefined) { + const identity = ContextualIdentityService.create(IDENTITY_NAME + browserContextId); + this.userContextId = identity.userContextId; + } + this._principals = []; + // Maps origins to the permission lists. + this._permissions = new Map(); + this._manager._browserContextIdToBrowserContext.set(this.browserContextId, this); + this._manager._userContextIdToBrowserContext.set(this.userContextId, this); + this.options = options || {}; + this.options.scriptsToEvaluateOnNewDocument = []; + this.options.bindings = []; + this.pages = new Set(); + } + + destroy() { + if (this.userContextId !== 0) { + ContextualIdentityService.remove(this.userContextId); + ContextualIdentityService.closeContainerTabs(this.userContextId); + } + this._manager._browserContextIdToBrowserContext.delete(this.browserContextId); + this._manager._userContextIdToBrowserContext.delete(this.userContextId); + } + + async addScriptToEvaluateOnNewDocument(script) { + this.options.scriptsToEvaluateOnNewDocument.push(script); + await Promise.all(Array.from(this.pages).map(page => page.addScriptToEvaluateOnNewDocument(script))); + } + + async addBinding(name, script) { + this.options.bindings.push({ name, script }); + await Promise.all(Array.from(this.pages).map(page => page.addBinding(name, script))); + } + + async setGeolocationOverride(geolocation) { + this.options.geolocation = geolocation; + await Promise.all(Array.from(this.pages).map(page => page.setGeolocationOverride(geolocation))); + } + + async setOnlineOverride(override) { + this.options.onlineOverride = override; + await Promise.all(Array.from(this.pages).map(page => page.setOnlineOverride(override))); + } + + async grantPermissions(origin, permissions) { + this._permissions.set(origin, permissions); + const promises = []; + for (const page of this.pages) { + if (origin === '*' || page._url.startsWith(origin)) { + this.grantPermissionsToOrigin(page._url); + promises.push(page.ensurePermissions(permissions)); + } + } + await Promise.all(promises); + } + + resetPermissions() { + for (const principal of this._principals) { + for (const permission of ALL_PERMISSIONS) + Services.perms.removeFromPrincipal(principal, permission); + } + this._principals = []; + this._permissions.clear(); + } + + grantPermissionsToOrigin(url) { + let origin = Array.from(this._permissions.keys()).find(key => url.startsWith(key)); + if (!origin) + origin = '*'; + + const permissions = this._permissions.get(origin); + if (!permissions) + return; + + const attrs = { userContextId: this.userContextId || undefined }; + const principal = Services.scriptSecurityManager.createContentPrincipal(NetUtil.newURI(url), attrs); + this._principals.push(principal); + for (const permission of ALL_PERMISSIONS) { + const action = permissions.includes(permission) ? Ci.nsIPermissionManager.ALLOW_ACTION : Ci.nsIPermissionManager.DENY_ACTION; + Services.perms.addFromPrincipal(principal, permission, action, Ci.nsIPermissionManager.EXPIRE_NEVER, 0 /* expireTime */); + } + } + + setCookies(cookies) { + const protocolToSameSite = { + [undefined]: Ci.nsICookie.SAMESITE_NONE, + 'Lax': Ci.nsICookie.SAMESITE_LAX, + 'Strict': Ci.nsICookie.SAMESITE_STRICT, + }; + for (const cookie of cookies) { + const uri = cookie.url ? NetUtil.newURI(cookie.url) : null; + let domain = cookie.domain; + if (!domain) { + if (!uri) + throw new Error('At least one of the url and domain needs to be specified'); + domain = uri.host; + } + let path = cookie.path; + if (!path) + path = uri ? dirPath(uri.filePath) : '/'; + let secure = false; + if (cookie.secure !== undefined) + secure = cookie.secure; + else if (uri && uri.scheme === 'https') + secure = true; + Services.cookies.add( + domain, + path, + cookie.name, + cookie.value, + secure, + cookie.httpOnly || false, + cookie.expires === undefined || cookie.expires === -1 /* isSession */, + cookie.expires === undefined ? Date.now() + HUNDRED_YEARS : cookie.expires, + { userContextId: this.userContextId || undefined } /* originAttributes */, + protocolToSameSite[cookie.sameSite], + ); + } + } + + clearCookies() { + Services.cookies.removeCookiesWithOriginAttributes(JSON.stringify({ userContextId: this.userContextId || undefined })); + } + + getCookies() { + const result = []; + const sameSiteToProtocol = { + [Ci.nsICookie.SAMESITE_NONE]: 'None', + [Ci.nsICookie.SAMESITE_LAX]: 'Lax', + [Ci.nsICookie.SAMESITE_STRICT]: 'Strict', + }; + for (let cookie of Services.cookies.cookies) { + if (cookie.originAttributes.userContextId !== this.userContextId) + continue; + if (cookie.host === 'addons.mozilla.org') + continue; + result.push({ + name: cookie.name, + value: cookie.value, + domain: cookie.host, + path: cookie.path, + expires: cookie.isSession ? -1 : cookie.expiry, + size: cookie.name.length + cookie.value.length, + httpOnly: cookie.isHttpOnly, + secure: cookie.isSecure, + session: cookie.isSession, + sameSite: sameSiteToProtocol[cookie.sameSite], + }); + } + return result; + } +} + +function dirPath(path) { + return path.substring(0, path.lastIndexOf('/') + 1); +} + +var EXPORTED_SYMBOLS = ['BrowserContextManager', 'BrowserContext']; +this.BrowserContextManager = BrowserContextManager; +this.BrowserContext = BrowserContext; + diff --git a/juggler/Helper.js b/juggler/Helper.js new file mode 100644 index 0000000000000..862c680198bbb --- /dev/null +++ b/juggler/Helper.js @@ -0,0 +1,102 @@ +const uuidGen = Cc["@mozilla.org/uuid-generator;1"].getService(Ci.nsIUUIDGenerator); +const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +class Helper { + addObserver(handler, topic) { + Services.obs.addObserver(handler, topic); + return () => Services.obs.removeObserver(handler, topic); + } + + addMessageListener(receiver, eventName, handler) { + receiver.addMessageListener(eventName, handler); + return () => receiver.removeMessageListener(eventName, handler); + } + + addEventListener(receiver, eventName, handler) { + receiver.addEventListener(eventName, handler); + return () => receiver.removeEventListener(eventName, handler); + } + + on(receiver, eventName, handler) { + // The toolkit/modules/EventEmitter.jsm dispatches event name as a first argument. + // Fire event listeners without it for convenience. + const handlerWrapper = (_, ...args) => handler(...args); + receiver.on(eventName, handlerWrapper); + return () => receiver.off(eventName, handlerWrapper); + } + + addProgressListener(progress, listener, flags) { + progress.addProgressListener(listener, flags); + return () => progress.removeProgressListener(listener); + } + + removeListeners(listeners) { + for (const tearDown of listeners) + tearDown.call(null); + listeners.splice(0, listeners.length); + } + + generateId() { + const string = uuidGen.generateUUID().toString(); + return string.substring(1, string.length - 1); + } + + getNetworkErrorStatusText(status) { + if (!status) + return null; + for (const key of Object.keys(Cr)) { + if (Cr[key] === status) + return key; + } + // Security module. The following is taken from + // https://developer.mozilla.org/en-US/docs/Web/API/XMLHttpRequest/How_to_check_the_secruity_state_of_an_XMLHTTPRequest_over_SSL + if ((status & 0xff0000) === 0x5a0000) { + // NSS_SEC errors (happen below the base value because of negative vals) + if ((status & 0xffff) < Math.abs(Ci.nsINSSErrorsService.NSS_SEC_ERROR_BASE)) { + // The bases are actually negative, so in our positive numeric space, we + // need to subtract the base off our value. + const nssErr = Math.abs(Ci.nsINSSErrorsService.NSS_SEC_ERROR_BASE) - (status & 0xffff); + switch (nssErr) { + case 11: + return 'SEC_ERROR_EXPIRED_CERTIFICATE'; + case 12: + return 'SEC_ERROR_REVOKED_CERTIFICATE'; + case 13: + return 'SEC_ERROR_UNKNOWN_ISSUER'; + case 20: + return 'SEC_ERROR_UNTRUSTED_ISSUER'; + case 21: + return 'SEC_ERROR_UNTRUSTED_CERT'; + case 36: + return 'SEC_ERROR_CA_CERT_INVALID'; + case 90: + return 'SEC_ERROR_INADEQUATE_KEY_USAGE'; + case 176: + return 'SEC_ERROR_CERT_SIGNATURE_ALGORITHM_DISABLED'; + default: + return 'SEC_ERROR_UNKNOWN'; + } + } + const sslErr = Math.abs(Ci.nsINSSErrorsService.NSS_SSL_ERROR_BASE) - (status & 0xffff); + switch (sslErr) { + case 3: + return 'SSL_ERROR_NO_CERTIFICATE'; + case 4: + return 'SSL_ERROR_BAD_CERTIFICATE'; + case 8: + return 'SSL_ERROR_UNSUPPORTED_CERTIFICATE_TYPE'; + case 9: + return 'SSL_ERROR_UNSUPPORTED_VERSION'; + case 12: + return 'SSL_ERROR_BAD_CERT_DOMAIN'; + default: + return 'SSL_ERROR_UNKNOWN'; + } + } + return ''; + } +} + +var EXPORTED_SYMBOLS = [ "Helper" ]; +this.Helper = Helper; + diff --git a/juggler/NetworkObserver.js b/juggler/NetworkObserver.js new file mode 100644 index 0000000000000..347a2b24dc4c9 --- /dev/null +++ b/juggler/NetworkObserver.js @@ -0,0 +1,726 @@ +"use strict"; + +const {EventEmitter} = ChromeUtils.import('resource://gre/modules/EventEmitter.jsm'); +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); +const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const {NetUtil} = ChromeUtils.import('resource://gre/modules/NetUtil.jsm'); +const {CommonUtils} = ChromeUtils.import("resource://services-common/utils.js"); +const {TargetRegistry} = ChromeUtils.import('chrome://juggler/content/TargetRegistry.js'); +const {BrowserContextManager} = ChromeUtils.import('chrome://juggler/content/BrowserContextManager.js'); + + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; +const Cr = Components.results; +const Cm = Components.manager; +const CC = Components.Constructor; +const helper = new Helper(); + +const BinaryInputStream = CC('@mozilla.org/binaryinputstream;1', 'nsIBinaryInputStream', 'setInputStream'); +const BinaryOutputStream = CC('@mozilla.org/binaryoutputstream;1', 'nsIBinaryOutputStream', 'setOutputStream'); +const StorageStream = CC('@mozilla.org/storagestream;1', 'nsIStorageStream', 'init'); + +// Cap response storage with 100Mb per tracked tab. +const MAX_RESPONSE_STORAGE_SIZE = 100 * 1024 * 1024; + +/** + * This is a nsIChannelEventSink implementation that monitors channel redirects. + */ +const SINK_CLASS_DESCRIPTION = "Juggler NetworkMonitor Channel Event Sink"; +const SINK_CLASS_ID = Components.ID("{c2b4c83e-607a-405a-beab-0ef5dbfb7617}"); +const SINK_CONTRACT_ID = "@mozilla.org/network/monitor/channeleventsink;1"; +const SINK_CATEGORY_NAME = "net-channel-event-sinks"; + +class NetworkObserver { + static instance() { + return NetworkObserver._instance || null; + } + + static initialize() { + if (NetworkObserver._instance) + return; + NetworkObserver._instance = new NetworkObserver(); + } + + constructor() { + EventEmitter.decorate(this); + this._browserSessionCount = new Map(); + this._activityDistributor = Cc["@mozilla.org/network/http-activity-distributor;1"].getService(Ci.nsIHttpActivityDistributor); + this._activityDistributor.addObserver(this); + + this._redirectMap = new Map(); // oldId => newId + this._resumedRequestIdToHeaders = new Map(); // requestId => { headers } + this._postResumeChannelIdToRequestId = new Map(); // post-resume channel id => pre-resume request id + + this._channelSink = { + QueryInterface: ChromeUtils.generateQI([Ci.nsIChannelEventSink]), + asyncOnChannelRedirect: (oldChannel, newChannel, flags, callback) => { + this._onRedirect(oldChannel, newChannel, flags); + callback.onRedirectVerifyCallback(Cr.NS_OK); + }, + }; + this._channelSinkFactory = { + QueryInterface: ChromeUtils.generateQI([Ci.nsIFactory]), + createInstance: (aOuter, aIID) => this._channelSink.QueryInterface(aIID), + }; + // Register self as ChannelEventSink to track redirects. + const registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar); + registrar.registerFactory(SINK_CLASS_ID, SINK_CLASS_DESCRIPTION, SINK_CONTRACT_ID, this._channelSinkFactory); + Services.catMan.addCategoryEntry(SINK_CATEGORY_NAME, SINK_CONTRACT_ID, SINK_CONTRACT_ID, false, true); + + this._browsersWithEnabledInterception = new Set(); + this._browserInterceptors = new Map(); // Browser => (requestId => interceptor). + this._extraHTTPHeaders = new Map(); + this._browserResponseStorages = new Map(); + + this._eventListeners = [ + helper.addObserver(this._onRequest.bind(this), 'http-on-modify-request'), + helper.addObserver(this._onResponse.bind(this, false /* fromCache */), 'http-on-examine-response'), + helper.addObserver(this._onResponse.bind(this, true /* fromCache */), 'http-on-examine-cached-response'), + helper.addObserver(this._onResponse.bind(this, true /* fromCache */), 'http-on-examine-merged-response'), + ]; + } + + setExtraHTTPHeaders(browser, headers) { + if (!headers) + this._extraHTTPHeaders.delete(browser); + else + this._extraHTTPHeaders.set(browser, headers); + } + + enableRequestInterception(browser) { + this._browsersWithEnabledInterception.add(browser); + } + + disableRequestInterception(browser) { + this._browsersWithEnabledInterception.delete(browser); + const interceptors = this._browserInterceptors.get(browser); + if (!interceptors) + return; + this._browserInterceptors.delete(browser); + for (const interceptor of interceptors.values()) + interceptor._resume(); + } + + _takeInterceptor(browser, requestId) { + const interceptors = this._browserInterceptors.get(browser); + if (!interceptors) + throw new Error(`Request interception is not enabled`); + const interceptor = interceptors.get(requestId); + if (!interceptor) + throw new Error(`Cannot find request "${requestId}"`); + interceptors.delete(requestId); + return interceptor; + } + + resumeInterceptedRequest(browser, requestId, method, headers, postData) { + this._takeInterceptor(browser, requestId)._resume(method, headers, postData); + } + + getResponseBody(browser, requestId) { + const responseStorage = this._browserResponseStorages.get(browser); + if (!responseStorage) + throw new Error('Responses are not tracked for the given browser'); + return responseStorage.getBase64EncodedResponse(requestId); + } + + fulfillInterceptedRequest(browser, requestId, status, statusText, headers, base64body) { + this._takeInterceptor(browser, requestId)._fulfill(status, statusText, headers, base64body); + } + + abortInterceptedRequest(browser, requestId, errorCode) { + this._takeInterceptor(browser, requestId)._abort(errorCode); + } + + _requestId(httpChannel) { + const id = httpChannel.channelId + ''; + return this._postResumeChannelIdToRequestId.get(id) || id; + } + + _onRedirect(oldChannel, newChannel, flags) { + if (!(oldChannel instanceof Ci.nsIHttpChannel) || !(newChannel instanceof Ci.nsIHttpChannel)) + return; + const oldHttpChannel = oldChannel.QueryInterface(Ci.nsIHttpChannel); + const newHttpChannel = newChannel.QueryInterface(Ci.nsIHttpChannel); + const browser = this._getBrowserForChannel(oldHttpChannel); + if (!browser) + return; + const oldRequestId = this._requestId(oldHttpChannel); + const newRequestId = this._requestId(newHttpChannel); + if (this._resumedRequestIdToHeaders.has(oldRequestId)) { + // When we call resetInterception on a request, we get a new "redirected" request for it. + const { method, headers, postData } = this._resumedRequestIdToHeaders.get(oldRequestId); + if (headers) { + // Apply new request headers from interception resume. + for (const header of requestHeaders(newChannel)) + newChannel.setRequestHeader(header.name, '', false /* merge */); + for (const header of headers) + newChannel.setRequestHeader(header.name, header.value, false /* merge */); + } + if (method) + newChannel.requestMethod = method; + if (postData && newChannel instanceof Ci.nsIUploadChannel) { + const synthesized = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(Ci.nsIStringInputStream); + synthesized.data = atob(postData); + newChannel.setUploadStream(synthesized, 'application/octet-stream', -1); + } + // Use the old request id for the new "redirected" request for protocol consistency. + this._resumedRequestIdToHeaders.delete(oldRequestId); + this._postResumeChannelIdToRequestId.set(newRequestId, oldRequestId); + } else if (!(flags & Ci.nsIChannelEventSink.REDIRECT_INTERNAL)) { + // Regular (non-internal) redirect. + this._redirectMap.set(newRequestId, oldRequestId); + } + } + + observeActivity(channel, activityType, activitySubtype, timestamp, extraSizeData, extraStringData) { + if (activityType !== Ci.nsIHttpActivityObserver.ACTIVITY_TYPE_HTTP_TRANSACTION) + return; + if (!(channel instanceof Ci.nsIHttpChannel)) + return; + const httpChannel = channel.QueryInterface(Ci.nsIHttpChannel); + const browser = this._getBrowserForChannel(httpChannel); + if (!browser) + return; + if (activitySubtype !== Ci.nsIHttpActivityObserver.ACTIVITY_SUBTYPE_TRANSACTION_CLOSE) + return; + if (this._isResumedChannel(httpChannel)) + return; + this._sendOnRequestFinished(httpChannel); + } + + _getBrowserForChannel(httpChannel) { + let loadContext = null; + try { + if (httpChannel.notificationCallbacks) + loadContext = httpChannel.notificationCallbacks.getInterface(Ci.nsILoadContext); + } catch (e) {} + try { + if (!loadContext && httpChannel.loadGroup) + loadContext = httpChannel.loadGroup.notificationCallbacks.getInterface(Ci.nsILoadContext); + } catch (e) { } + if (!loadContext || !this._browserSessionCount.has(loadContext.topFrameElement)) + return; + return loadContext.topFrameElement; + } + + _isResumedChannel(httpChannel) { + return this._postResumeChannelIdToRequestId.has(httpChannel.channelId + ''); + } + + _onRequest(channel, topic) { + if (!(channel instanceof Ci.nsIHttpChannel)) + return; + const httpChannel = channel.QueryInterface(Ci.nsIHttpChannel); + const browser = this._getBrowserForChannel(httpChannel); + if (!browser) + return; + if (this._isResumedChannel(httpChannel)) { + // Ignore onRequest for resumed requests, but listen to their response. + new ResponseBodyListener(this, browser, httpChannel); + return; + } + const browserContext = TargetRegistry.instance().browserContextForBrowser(browser); + if (browserContext) + this._appendExtraHTTPHeaders(httpChannel, browserContext.options.extraHTTPHeaders); + this._appendExtraHTTPHeaders(httpChannel, this._extraHTTPHeaders.get(browser)); + const requestId = this._requestId(httpChannel); + const isRedirect = this._redirectMap.has(requestId); + const interceptionEnabled = this._isInterceptionEnabledForBrowser(browser); + if (!interceptionEnabled) { + new NotificationCallbacks(this, browser, httpChannel, false); + this._sendOnRequest(httpChannel, false); + new ResponseBodyListener(this, browser, httpChannel); + } else if (isRedirect) { + // We pretend that redirect is interceptable in the protocol, although it's actually not + // and therefore we do not instantiate the interceptor. + // TODO: look into REDIRECT_MODE_MANUAL. + const interceptors = this._ensureInterceptors(browser); + interceptors.set(requestId, { + _resume: () => {}, + _abort: () => {}, + _fulfill: () => {}, + }); + new NotificationCallbacks(this, browser, httpChannel, false); + this._sendOnRequest(httpChannel, true); + new ResponseBodyListener(this, browser, httpChannel); + } else { + const previousCallbacks = httpChannel.notificationCallbacks; + if (previousCallbacks instanceof Ci.nsIInterfaceRequestor) { + const interceptor = previousCallbacks.getInterface(Ci.nsINetworkInterceptController); + // We assume that interceptor is a service worker if there is one. + if (interceptor && interceptor.shouldPrepareForIntercept(httpChannel.URI, httpChannel)) { + new NotificationCallbacks(this, browser, httpChannel, false); + this._sendOnRequest(httpChannel, false); + new ResponseBodyListener(this, browser, httpChannel); + } else { + // We'll issue onRequest once it's intercepted. + new NotificationCallbacks(this, browser, httpChannel, true); + } + } else { + // We'll issue onRequest once it's intercepted. + new NotificationCallbacks(this, browser, httpChannel, true); + } + } + } + + _isInterceptionEnabledForBrowser(browser) { + if (this._browsersWithEnabledInterception.has(browser)) + return true; + const browserContext = TargetRegistry.instance().browserContextForBrowser(browser); + if (browserContext && browserContext.options.requestInterceptionEnabled) + return true; + if (browserContext && browserContext.options.onlineOverride === 'offline') + return true; + return false; + } + + _ensureInterceptors(browser) { + let interceptors = this._browserInterceptors.get(browser); + if (!interceptors) { + interceptors = new Map(); + this._browserInterceptors.set(browser, interceptors); + } + return interceptors; + } + + _appendExtraHTTPHeaders(httpChannel, headers) { + if (!headers) + return; + for (const header of headers) + httpChannel.setRequestHeader(header.name, header.value, false /* merge */); + } + + _onIntercepted(httpChannel, interceptor) { + const browser = this._getBrowserForChannel(httpChannel); + if (!browser) { + interceptor._resume(); + return; + } + + const browserContext = TargetRegistry.instance().browserContextForBrowser(browser); + if (browserContext && browserContext.options.onlineOverride === 'offline') { + interceptor._abort(Cr.NS_ERROR_OFFLINE); + return; + } + + const interceptionEnabled = this._isInterceptionEnabledForBrowser(browser); + this._sendOnRequest(httpChannel, !!interceptionEnabled); + if (interceptionEnabled) + this._ensureInterceptors(browser).set(this._requestId(httpChannel), interceptor); + else + interceptor._resume(); + } + + _sendOnRequest(httpChannel, isIntercepted) { + const browser = this._getBrowserForChannel(httpChannel); + if (!browser) + return; + const causeType = httpChannel.loadInfo ? httpChannel.loadInfo.externalContentPolicyType : Ci.nsIContentPolicy.TYPE_OTHER; + const requestId = this._requestId(httpChannel); + const redirectedFrom = this._redirectMap.get(requestId); + this._redirectMap.delete(requestId); + this.emit('request', httpChannel, { + url: httpChannel.URI.spec, + isIntercepted, + requestId, + redirectedFrom, + postData: readRequestPostData(httpChannel), + headers: requestHeaders(httpChannel), + method: httpChannel.requestMethod, + navigationId: httpChannel.isMainDocumentChannel ? this._requestId(httpChannel) : undefined, + cause: causeTypeToString(causeType), + }); + } + + _sendOnRequestFinished(httpChannel) { + this.emit('requestfinished', httpChannel, { + requestId: this._requestId(httpChannel), + }); + this._postResumeChannelIdToRequestId.delete(httpChannel.channelId + ''); + } + + _sendOnRequestFailed(httpChannel, error) { + this.emit('requestfailed', httpChannel, { + requestId: this._requestId(httpChannel), + errorCode: helper.getNetworkErrorStatusText(error), + }); + this._postResumeChannelIdToRequestId.delete(httpChannel.channelId + ''); + } + + _onResponse(fromCache, httpChannel, topic) { + const browser = this._getBrowserForChannel(httpChannel); + if (!browser) + return; + httpChannel.QueryInterface(Ci.nsIHttpChannelInternal); + const headers = []; + httpChannel.visitResponseHeaders({ + visitHeader: (name, value) => headers.push({name, value}), + }); + + let remoteIPAddress = undefined; + let remotePort = undefined; + try { + remoteIPAddress = httpChannel.remoteAddress; + remotePort = httpChannel.remotePort; + } catch (e) { + // remoteAddress is not defined for cached requests. + } + this.emit('response', httpChannel, { + requestId: this._requestId(httpChannel), + securityDetails: getSecurityDetails(httpChannel), + fromCache, + headers, + remoteIPAddress, + remotePort, + status: httpChannel.responseStatus, + statusText: httpChannel.responseStatusText, + }); + } + + _onResponseFinished(browser, httpChannel, body) { + const responseStorage = this._browserResponseStorages.get(browser); + if (!responseStorage) + return; + responseStorage.addResponseBody(httpChannel, body); + this._sendOnRequestFinished(httpChannel); + } + + startTrackingBrowserNetwork(browser) { + const value = this._browserSessionCount.get(browser) || 0; + this._browserSessionCount.set(browser, value + 1); + if (value === 0) + this._browserResponseStorages.set(browser, new ResponseStorage(this, MAX_RESPONSE_STORAGE_SIZE, MAX_RESPONSE_STORAGE_SIZE / 10)); + return () => this.stopTrackingBrowserNetwork(browser); + } + + stopTrackingBrowserNetwork(browser) { + const value = this._browserSessionCount.get(browser); + if (value) { + this._browserSessionCount.set(browser, value - 1); + } else { + this._browserSessionCount.delete(browser); + this._browserResponseStorages.delete(browser); + this._browsersWithEnabledInterception.delete(browser); + this._browserInterceptors.delete(browser); + } + } + + dispose() { + this._activityDistributor.removeObserver(this); + const registrar = Cm.QueryInterface(Ci.nsIComponentRegistrar); + registrar.unregisterFactory(SINK_CLASS_ID, this._channelSinkFactory); + Services.catMan.deleteCategoryEntry(SINK_CATEGORY_NAME, SINK_CONTRACT_ID, false); + helper.removeListeners(this._eventListeners); + } +} + +const protocolVersionNames = { + [Ci.nsITransportSecurityInfo.TLS_VERSION_1]: 'TLS 1', + [Ci.nsITransportSecurityInfo.TLS_VERSION_1_1]: 'TLS 1.1', + [Ci.nsITransportSecurityInfo.TLS_VERSION_1_2]: 'TLS 1.2', + [Ci.nsITransportSecurityInfo.TLS_VERSION_1_3]: 'TLS 1.3', +}; + +function getSecurityDetails(httpChannel) { + const securityInfo = httpChannel.securityInfo; + if (!securityInfo) + return null; + securityInfo.QueryInterface(Ci.nsITransportSecurityInfo); + if (!securityInfo.serverCert) + return null; + return { + protocol: protocolVersionNames[securityInfo.protocolVersion] || '', + subjectName: securityInfo.serverCert.commonName, + issuer: securityInfo.serverCert.issuerCommonName, + // Convert to seconds. + validFrom: securityInfo.serverCert.validity.notBefore / 1000 / 1000, + validTo: securityInfo.serverCert.validity.notAfter / 1000 / 1000, + }; +} + +function readRequestPostData(httpChannel) { + if (!(httpChannel instanceof Ci.nsIUploadChannel)) + return undefined; + const iStream = httpChannel.uploadStream; + if (!iStream) + return undefined; + const isSeekableStream = iStream instanceof Ci.nsISeekableStream; + + let prevOffset; + if (isSeekableStream) { + prevOffset = iStream.tell(); + iStream.seek(Ci.nsISeekableStream.NS_SEEK_SET, 0); + } + + // Read data from the stream. + let text = undefined; + try { + text = NetUtil.readInputStreamToString(iStream, iStream.available()); + const converter = Cc['@mozilla.org/intl/scriptableunicodeconverter'] + .createInstance(Ci.nsIScriptableUnicodeConverter); + converter.charset = 'UTF-8'; + text = converter.ConvertToUnicode(text); + } catch (err) { + text = undefined; + } + + // Seek locks the file, so seek to the beginning only if necko hasn't + // read it yet, since necko doesn't seek to 0 before reading (at lest + // not till 459384 is fixed). + if (isSeekableStream && prevOffset == 0) + iStream.seek(Ci.nsISeekableStream.NS_SEEK_SET, 0); + return text; +} + +function requestHeaders(httpChannel) { + const headers = []; + httpChannel.visitRequestHeaders({ + visitHeader: (name, value) => headers.push({name, value}), + }); + return headers; +} + +function causeTypeToString(causeType) { + for (let key in Ci.nsIContentPolicy) { + if (Ci.nsIContentPolicy[key] === causeType) + return key; + } + return 'TYPE_OTHER'; +} + +class ResponseStorage { + constructor(networkObserver, maxTotalSize, maxResponseSize) { + this._networkObserver = networkObserver; + this._totalSize = 0; + this._maxResponseSize = maxResponseSize; + this._maxTotalSize = maxTotalSize; + this._responses = new Map(); + } + + addResponseBody(httpChannel, body) { + if (body.length > this._maxResponseSize) { + this._responses.set(requestId, { + evicted: true, + body: '', + }); + return; + } + let encodings = []; + if ((httpChannel instanceof Ci.nsIEncodedChannel) && httpChannel.contentEncodings && !httpChannel.applyConversion) { + const encodingHeader = httpChannel.getResponseHeader("Content-Encoding"); + encodings = encodingHeader.split(/\s*\t*,\s*\t*/); + } + this._responses.set(this._networkObserver._requestId(httpChannel), {body, encodings}); + this._totalSize += body.length; + if (this._totalSize > this._maxTotalSize) { + for (let [requestId, response] of this._responses) { + this._totalSize -= response.body.length; + response.body = ''; + response.evicted = true; + if (this._totalSize < this._maxTotalSize) + break; + } + } + } + + getBase64EncodedResponse(requestId) { + const response = this._responses.get(requestId); + if (!response) + throw new Error(`Request "${requestId}" is not found`); + if (response.evicted) + return {base64body: '', evicted: true}; + let result = response.body; + if (response.encodings && response.encodings.length) { + for (const encoding of response.encodings) + result = CommonUtils.convertString(result, encoding, 'uncompressed'); + } + return {base64body: btoa(result)}; + } +} + +class ResponseBodyListener { + constructor(networkObserver, browser, httpChannel) { + this._networkObserver = networkObserver; + this._browser = browser; + this._httpChannel = httpChannel; + this._chunks = []; + this.QueryInterface = ChromeUtils.generateQI([Ci.nsIStreamListener]); + httpChannel.QueryInterface(Ci.nsITraceableChannel); + this.originalListener = httpChannel.setNewListener(this); + } + + onDataAvailable(aRequest, aInputStream, aOffset, aCount) { + const iStream = new BinaryInputStream(aInputStream); + const sStream = new StorageStream(8192, aCount, null); + const oStream = new BinaryOutputStream(sStream.getOutputStream(0)); + + // Copy received data as they come. + const data = iStream.readBytes(aCount); + this._chunks.push(data); + + oStream.writeBytes(data, aCount); + this.originalListener.onDataAvailable(aRequest, sStream.newInputStream(0), aOffset, aCount); + } + + onStartRequest(aRequest) { + this.originalListener.onStartRequest(aRequest); + } + + onStopRequest(aRequest, aStatusCode) { + this.originalListener.onStopRequest(aRequest, aStatusCode); + const body = this._chunks.join(''); + delete this._chunks; + this._networkObserver._onResponseFinished(this._browser, this._httpChannel, body); + } +} + +class NotificationCallbacks { + constructor(networkObserver, browser, httpChannel, shouldIntercept) { + this._networkObserver = networkObserver; + this._browser = browser; + this._shouldIntercept = shouldIntercept; + this._httpChannel = httpChannel; + this._previousCallbacks = httpChannel.notificationCallbacks; + httpChannel.notificationCallbacks = this; + + const qis = [ + Ci.nsIAuthPrompt2, + Ci.nsIAuthPromptProvider, + Ci.nsIInterfaceRequestor, + ]; + if (shouldIntercept) + qis.push(Ci.nsINetworkInterceptController); + this.QueryInterface = ChromeUtils.generateQI(qis); + } + + getInterface(iid) { + if (iid.equals(Ci.nsIAuthPrompt2) || iid.equals(Ci.nsIAuthPromptProvider)) + return this; + if (this._shouldIntercept && iid.equals(Ci.nsINetworkInterceptController)) + return this; + if (iid.equals(Ci.nsIAuthPrompt)) // Block nsIAuthPrompt - we want nsIAuthPrompt2 to be used instead. + throw Cr.NS_ERROR_NO_INTERFACE; + if (this._previousCallbacks) + return this._previousCallbacks.getInterface(iid); + throw Cr.NS_ERROR_NO_INTERFACE; + } + + _forward(iid, method, args) { + if (!this._previousCallbacks) + return; + try { + const impl = this._previousCallbacks.getInterface(iid); + impl[method].apply(impl, args); + } catch (e) { + if (e.result != Cr.NS_ERROR_NO_INTERFACE) + throw e; + } + } + + // nsIAuthPromptProvider + getAuthPrompt(aPromptReason, iid) { + return this; + } + + // nsIAuthPrompt2 + asyncPromptAuth(aChannel, aCallback, aContext, level, authInfo) { + let canceled = false; + Promise.resolve().then(() => { + if (canceled) + return; + const hasAuth = this.promptAuth(aChannel, level, authInfo); + if (hasAuth) + aCallback.onAuthAvailable(aContext, authInfo); + else + aCallback.onAuthCancelled(aContext, true); + }); + return { + QueryInterface: ChromeUtils.generateQI([Ci.nsICancelable]), + cancel: () => { + aCallback.onAuthCancelled(aContext, false); + canceled = true; + } + }; + } + + // nsIAuthPrompt2 + promptAuth(aChannel, level, authInfo) { + if (authInfo.flags & Ci.nsIAuthInformation.PREVIOUS_FAILED) + return false; + const browserContext = TargetRegistry.instance().browserContextForBrowser(this._browser); + const credentials = browserContext ? browserContext.options.httpCredentials : undefined; + if (!credentials) + return false; + authInfo.username = credentials.username; + authInfo.password = credentials.password; + return true; + } + + // nsINetworkInterceptController + shouldPrepareForIntercept(aURI, channel) { + if (!(channel instanceof Ci.nsIHttpChannel)) + return false; + const httpChannel = channel.QueryInterface(Ci.nsIHttpChannel); + return httpChannel.channelId === this._httpChannel.channelId; + } + + // nsINetworkInterceptController + channelIntercepted(intercepted) { + this._intercepted = intercepted.QueryInterface(Ci.nsIInterceptedChannel); + const httpChannel = this._intercepted.channel.QueryInterface(Ci.nsIHttpChannel); + this._networkObserver._onIntercepted(httpChannel, this); + } + + _resume(method, headers, postData) { + this._networkObserver._resumedRequestIdToHeaders.set(this._networkObserver._requestId(this._httpChannel), { method, headers, postData }); + this._intercepted.resetInterception(); + } + + _fulfill(status, statusText, headers, base64body) { + this._intercepted.synthesizeStatus(status, statusText); + for (const header of headers) + this._intercepted.synthesizeHeader(header.name, header.value); + const synthesized = Cc["@mozilla.org/io/string-input-stream;1"].createInstance(Ci.nsIStringInputStream); + const body = base64body ? atob(base64body) : ''; + synthesized.data = body; + this._intercepted.startSynthesizedResponse(synthesized, null, null, '', false); + this._intercepted.finishSynthesizedResponse(); + this._networkObserver.emit('response', this._httpChannel, { + requestId: this._networkObserver._requestId(this._httpChannel), + securityDetails: null, + fromCache: false, + headers, + status, + statusText, + }); + this._networkObserver._onResponseFinished(this._browser, this._httpChannel, body); + } + + _abort(errorCode) { + const error = errorMap[errorCode] || Cr.NS_ERROR_FAILURE; + this._intercepted.cancelInterception(error); + this._networkObserver._sendOnRequestFailed(this._httpChannel, error); + } +} + +const errorMap = { + 'aborted': Cr.NS_ERROR_ABORT, + 'accessdenied': Cr.NS_ERROR_PORT_ACCESS_NOT_ALLOWED, + 'addressunreachable': Cr.NS_ERROR_UNKNOWN_HOST, + 'blockedbyclient': Cr.NS_ERROR_FAILURE, + 'blockedbyresponse': Cr.NS_ERROR_FAILURE, + 'connectionaborted': Cr.NS_ERROR_NET_INTERRUPT, + 'connectionclosed': Cr.NS_ERROR_FAILURE, + 'connectionfailed': Cr.NS_ERROR_FAILURE, + 'connectionrefused': Cr.NS_ERROR_CONNECTION_REFUSED, + 'connectionreset': Cr.NS_ERROR_NET_RESET, + 'internetdisconnected': Cr.NS_ERROR_OFFLINE, + 'namenotresolved': Cr.NS_ERROR_UNKNOWN_HOST, + 'timedout': Cr.NS_ERROR_NET_TIMEOUT, + 'failed': Cr.NS_ERROR_FAILURE, +}; + +var EXPORTED_SYMBOLS = ['NetworkObserver']; +this.NetworkObserver = NetworkObserver; diff --git a/juggler/SimpleChannel.js b/juggler/SimpleChannel.js new file mode 100644 index 0000000000000..ba34976ad05e7 --- /dev/null +++ b/juggler/SimpleChannel.js @@ -0,0 +1,130 @@ +"use strict"; +// Note: this file should be loadabale with eval() into worker environment. +// Avoid Components.*, ChromeUtils and global const variables. + +const SIMPLE_CHANNEL_MESSAGE_NAME = 'juggler:simplechannel'; + +class SimpleChannel { + static createForMessageManager(name, mm) { + const channel = new SimpleChannel(name); + + const messageListener = { + receiveMessage: message => channel._onMessage(message.data) + }; + mm.addMessageListener(SIMPLE_CHANNEL_MESSAGE_NAME, messageListener); + + channel.transport.sendMessage = obj => mm.sendAsyncMessage(SIMPLE_CHANNEL_MESSAGE_NAME, obj); + channel.transport.dispose = () => { + mm.removeMessageListener(SIMPLE_CHANNEL_MESSAGE_NAME, messageListener); + }; + return channel; + } + + constructor(name) { + this._name = name; + this._messageId = 0; + this._connectorId = 0; + this._pendingMessages = new Map(); + this._handlers = new Map(); + this.transport = { + sendMessage: null, + dispose: null, + }; + this._disposed = false; + } + + dispose() { + if (this._disposed) + return; + this._disposed = true; + for (const {resolve, reject, methodName} of this._pendingMessages.values()) + reject(new Error(`Failed "${methodName}": ${this._name} is disposed.`)); + this._pendingMessages.clear(); + this._handlers.clear(); + this.transport.dispose(); + } + + _rejectCallbacksFromConnector(connectorId) { + for (const [messageId, callback] of this._pendingMessages) { + if (callback.connectorId === connectorId) { + callback.reject(new Error(`Failed "${callback.methodName}": connector for namespace "${callback.namespace}" in channel "${this._name}" is disposed.`)); + this._pendingMessages.delete(messageId); + } + } + } + + connect(namespace) { + const connectorId = ++this._connectorId; + return { + send: (...args) => this._send(namespace, connectorId, ...args), + emit: (...args) => void this._send(namespace, connectorId, ...args).catch(e => {}), + dispose: () => this._rejectCallbacksFromConnector(connectorId), + }; + } + + register(namespace, handler) { + if (this._handlers.has(namespace)) + throw new Error('ERROR: double-register for namespace ' + namespace); + this._handlers.set(namespace, handler); + return () => this.unregister(namespace); + } + + unregister(namespace) { + this._handlers.delete(namespace); + } + + /** + * @param {string} namespace + * @param {number} connectorId + * @param {string} methodName + * @param {...*} params + * @return {!Promise<*>} + */ + async _send(namespace, connectorId, methodName, ...params) { + if (this._disposed) + throw new Error(`ERROR: channel ${this._name} is already disposed! Cannot send "${methodName}" to "${namespace}"`); + const id = ++this._messageId; + const promise = new Promise((resolve, reject) => { + this._pendingMessages.set(id, {connectorId, resolve, reject, methodName, namespace}); + }); + this.transport.sendMessage({requestId: id, methodName, params, namespace}); + return promise; + } + + async _onMessage(data) { + if (data.responseId) { + const {resolve, reject} = this._pendingMessages.get(data.responseId); + this._pendingMessages.delete(data.responseId); + if (data.error) + reject(new Error(data.error)); + else + resolve(data.result); + } else if (data.requestId) { + const namespace = data.namespace; + const handler = this._handlers.get(namespace); + if (!handler) { + this.transport.sendMessage({responseId: data.requestId, error: `error in channel "${this._name}": No handler for namespace "${namespace}"`}); + return; + } + const method = handler[data.methodName]; + if (!method) { + this.transport.sendMessage({responseId: data.requestId, error: `error in channel "${this._name}": No method "${data.methodName}" in namespace "${namespace}"`}); + return; + } + try { + const result = await method.call(handler, ...data.params); + this.transport.sendMessage({responseId: data.requestId, result}); + } catch (error) { + this.transport.sendMessage({responseId: data.requestId, error: `error in channel "${this._name}": exception while running method "${data.methodName}" in namespace "${namespace}": ${error.message} ${error.stack}`}); + return; + } + } else { + dump(` + ERROR: unknown message in channel "${this._name}": ${JSON.stringify(data)} + `); + } + } +} + +var EXPORTED_SYMBOLS = ['SimpleChannel']; +this.SimpleChannel = SimpleChannel; diff --git a/juggler/TargetRegistry.js b/juggler/TargetRegistry.js new file mode 100644 index 0000000000000..e624e3c21a20d --- /dev/null +++ b/juggler/TargetRegistry.js @@ -0,0 +1,277 @@ +const {EventEmitter} = ChromeUtils.import('resource://gre/modules/EventEmitter.jsm'); +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); +const {SimpleChannel} = ChromeUtils.import('chrome://juggler/content/SimpleChannel.js'); +const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; + +const helper = new Helper(); + +class TargetRegistry { + static instance() { + return TargetRegistry._instance || null; + } + + static initialize(mainWindow, contextManager) { + if (TargetRegistry._instance) + return; + TargetRegistry._instance = new TargetRegistry(mainWindow, contextManager); + } + + constructor(mainWindow, contextManager) { + EventEmitter.decorate(this); + + this._mainWindow = mainWindow; + this._contextManager = contextManager; + this._targets = new Map(); + + this._browserTarget = new BrowserTarget(); + this._targets.set(this._browserTarget.id(), this._browserTarget); + this._tabToTarget = new Map(); + + for (const tab of this._mainWindow.gBrowser.tabs) + this._createTargetForTab(tab); + this._mainWindow.gBrowser.tabContainer.addEventListener('TabOpen', event => { + const target = this._createTargetForTab(event.target); + // If we come here, content will have juggler script from the start, + // and we should wait for initial navigation. + target._waitForInitialNavigation = true; + // For pages created before we attach to them, we don't wait for initial + // navigation (target._waitForInitialNavigation is false by default). + }); + this._mainWindow.gBrowser.tabContainer.addEventListener('TabClose', event => { + const tab = event.target; + const target = this._tabToTarget.get(tab); + if (!target) + return; + this._targets.delete(target.id()); + this._tabToTarget.delete(tab); + target.dispose(); + this.emit(TargetRegistry.Events.TargetDestroyed, target); + }); + Services.obs.addObserver(this, 'oop-frameloader-crashed'); + } + + pageTargets(browserContextId) { + const browserContext = this._contextManager.browserContextForId(browserContextId); + const pageTargets = [...this._targets.values()].filter(target => target instanceof PageTarget); + return pageTargets.filter(target => target._browserContext === browserContext); + } + + async newPage({browserContextId}) { + const browserContext = this._contextManager.browserContextForId(browserContextId); + const tab = this._mainWindow.gBrowser.addTab('about:blank', { + userContextId: browserContext.userContextId, + triggeringPrincipal: Services.scriptSecurityManager.getSystemPrincipal(), + }); + this._mainWindow.gBrowser.selectedTab = tab; + const target = this._tabToTarget.get(tab); + await target._contentReadyPromise; + return target.id(); + } + + async closePage(targetId, runBeforeUnload = false) { + const tab = this.tabForTarget(targetId); + await this._mainWindow.gBrowser.removeTab(tab, { + skipPermitUnload: !runBeforeUnload, + }); + } + + targets() { + return Array.from(this._targets.values()); + } + + targetInfo(targetId) { + const target = this._targets.get(targetId); + return target ? target.info() : null; + } + + browserTargetInfo() { + return this._browserTarget.info(); + } + + tabForTarget(targetId) { + const target = this._targets.get(targetId); + if (!target) + throw new Error(`Target "${targetId}" does not exist!`); + if (!(target instanceof PageTarget)) + throw new Error(`Target "${targetId}" is not a page!`); + return target._tab; + } + + contentChannelForTarget(targetId) { + const target = this._targets.get(targetId); + if (!target) + throw new Error(`Target "${targetId}" does not exist!`); + if (!(target instanceof PageTarget)) + throw new Error(`Target "${targetId}" is not a page!`); + return target._channel; + } + + targetForId(targetId) { + return this._targets.get(targetId); + } + + targetForBrowser(browser) { + const tab = this._mainWindow.gBrowser.getTabForBrowser(browser); + return tab ? this._tabToTarget.get(tab) : undefined; + } + + browserContextForBrowser(browser) { + const tab = this._mainWindow.gBrowser.getTabForBrowser(browser); + return tab ? this._contextManager.browserContextForUserContextId(tab.userContextId) : undefined; + } + + _createTargetForTab(tab) { + if (this._tabToTarget.has(tab)) + throw new Error(`Internal error: two targets per tab`); + const openerTarget = tab.openerTab ? this._tabToTarget.get(tab.openerTab) : null; + const target = new PageTarget(this, tab, this._contextManager.browserContextForUserContextId(tab.userContextId), openerTarget); + this._targets.set(target.id(), target); + this._tabToTarget.set(tab, target); + this.emit(TargetRegistry.Events.TargetCreated, target); + return target; + } + + observe(subject, topic, data) { + if (topic === 'oop-frameloader-crashed') { + const browser = subject.ownerElement; + if (!browser) + return; + const target = this.targetForBrowser(browser); + if (!target) + return; + this.emit(TargetRegistry.Events.TargetCrashed, target.id()); + return; + } + } +} + +class PageTarget { + constructor(registry, tab, browserContext, opener) { + this._targetId = helper.generateId(); + this._registry = registry; + this._tab = tab; + this._browserContext = browserContext; + this._url = ''; + this._openerId = opener ? opener.id() : undefined; + this._channel = SimpleChannel.createForMessageManager(`browser::page[${this._targetId}]`, tab.linkedBrowser.messageManager); + + const navigationListener = { + QueryInterface: ChromeUtils.generateQI([ Ci.nsIWebProgressListener]), + onLocationChange: (aWebProgress, aRequest, aLocation) => this._onNavigated(aLocation), + }; + this._eventListeners = [ + helper.addProgressListener(tab.linkedBrowser, navigationListener, Ci.nsIWebProgress.NOTIFY_LOCATION), + helper.addMessageListener(tab.linkedBrowser.messageManager, 'juggler:content-ready', { + receiveMessage: () => this._onContentReady() + }), + ]; + + this._contentReadyPromise = new Promise(f => this._contentReadyCallback = f); + this._waitForInitialNavigation = false; + + if (browserContext) + browserContext.pages.add(this); + if (browserContext && browserContext.options.viewport) + this.setViewportSize(browserContext.options.viewport.viewportSize); + } + + setViewportSize(viewportSize) { + if (viewportSize) { + const {width, height} = viewportSize; + this._tab.linkedBrowser.style.setProperty('min-width', width + 'px'); + this._tab.linkedBrowser.style.setProperty('min-height', height + 'px'); + this._tab.linkedBrowser.style.setProperty('max-width', width + 'px'); + this._tab.linkedBrowser.style.setProperty('max-height', height + 'px'); + } else { + this._tab.linkedBrowser.style.removeProperty('min-width'); + this._tab.linkedBrowser.style.removeProperty('min-height'); + this._tab.linkedBrowser.style.removeProperty('max-width'); + this._tab.linkedBrowser.style.removeProperty('max-height'); + } + const rect = this._tab.linkedBrowser.getBoundingClientRect(); + return { width: rect.width, height: rect.height }; + } + + _onContentReady() { + const sessionIds = []; + const data = { sessionIds, target: this }; + this._registry.emit(TargetRegistry.Events.PageTargetReady, data); + this._contentReadyCallback(); + return { + browserContextOptions: this._browserContext ? this._browserContext.options : {}, + waitForInitialNavigation: this._waitForInitialNavigation, + sessionIds + }; + } + + id() { + return this._targetId; + } + + info() { + return { + targetId: this.id(), + type: 'page', + browserContextId: this._browserContext ? this._browserContext.browserContextId : undefined, + openerId: this._openerId, + }; + } + + _onNavigated(aLocation) { + this._url = aLocation.spec; + this._browserContext.grantPermissionsToOrigin(this._url); + } + + async ensurePermissions(permissions) { + await this._channel.connect('').send('ensurePermissions', permissions).catch(e => void e); + } + + async addScriptToEvaluateOnNewDocument(script) { + await this._channel.connect('').send('addScriptToEvaluateOnNewDocument', script).catch(e => void e); + } + + async addBinding(name, script) { + await this._channel.connect('').send('addBinding', { name, script }).catch(e => void e); + } + + async setGeolocationOverride(geolocation) { + await this._channel.connect('').send('setGeolocationOverride', geolocation).catch(e => void e); + } + + async setOnlineOverride(override) { + await this._channel.connect('').send('setOnlineOverride', override).catch(e => void e); + } + + dispose() { + if (this._browserContext) + this._browserContext.pages.delete(this); + helper.removeListeners(this._eventListeners); + } +} + +class BrowserTarget { + id() { + return 'target-browser'; + } + + info() { + return { + targetId: this.id(), + type: 'browser', + } + } +} + +TargetRegistry.Events = { + TargetCreated: Symbol('TargetRegistry.Events.TargetCreated'), + TargetDestroyed: Symbol('TargetRegistry.Events.TargetDestroyed'), + TargetCrashed: Symbol('TargetRegistry.Events.TargetCrashed'), + PageTargetReady: Symbol('TargetRegistry.Events.PageTargetReady'), +}; + +var EXPORTED_SYMBOLS = ['TargetRegistry']; +this.TargetRegistry = TargetRegistry; diff --git a/juggler/components/juggler.js b/juggler/components/juggler.js new file mode 100644 index 0000000000000..055b032beff4b --- /dev/null +++ b/juggler/components/juggler.js @@ -0,0 +1,116 @@ +const {XPCOMUtils} = ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm"); +const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const {Dispatcher} = ChromeUtils.import("chrome://juggler/content/protocol/Dispatcher.js"); +const {BrowserContextManager} = ChromeUtils.import("chrome://juggler/content/BrowserContextManager.js"); +const {NetworkObserver} = ChromeUtils.import("chrome://juggler/content/NetworkObserver.js"); +const {TargetRegistry} = ChromeUtils.import("chrome://juggler/content/TargetRegistry.js"); +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); +const helper = new Helper(); + +const Cc = Components.classes; +const Ci = Components.interfaces; + +const FRAME_SCRIPT = "chrome://juggler/content/content/main.js"; + +// Command Line Handler +function CommandLineHandler() { + this._port = -1; +}; + +CommandLineHandler.prototype = { + classDescription: "Sample command-line handler", + classID: Components.ID('{f7a74a33-e2ab-422d-b022-4fb213dd2639}'), + contractID: "@mozilla.org/remote/juggler;1", + _xpcom_categories: [{ + category: "command-line-handler", + entry: "m-juggler" + }], + + /* nsICommandLineHandler */ + handle: async function(cmdLine) { + const jugglerFlag = cmdLine.handleFlagWithParam("juggler", false); + if (!jugglerFlag || isNaN(jugglerFlag)) + return; + this._port = parseInt(jugglerFlag, 10); + Services.obs.addObserver(this, 'sessionstore-windows-restored'); + }, + + observe: async function(subject, topic) { + Services.obs.removeObserver(this, 'sessionstore-windows-restored'); + + const win = await waitForBrowserWindow(); + BrowserContextManager.initialize(); + NetworkObserver.initialize(); + TargetRegistry.initialize(win, BrowserContextManager.instance()); + + const { require } = ChromeUtils.import("resource://devtools/shared/Loader.jsm"); + const WebSocketServer = require('devtools/server/socket/websocket-server'); + this._server = Cc["@mozilla.org/network/server-socket;1"].createInstance(Ci.nsIServerSocket); + this._server.initSpecialConnection(this._port, Ci.nsIServerSocket.KeepWhenOffline | Ci.nsIServerSocket.LoopbackOnly, 4); + + const token = helper.generateId(); + + this._server.asyncListen({ + onSocketAccepted: async(socket, transport) => { + const input = transport.openInputStream(0, 0, 0); + const output = transport.openOutputStream(0, 0, 0); + const webSocket = await WebSocketServer.accept(transport, input, output, "/" + token); + new Dispatcher(webSocket); + } + }); + + Services.mm.loadFrameScript(FRAME_SCRIPT, true /* aAllowDelayedLoad */); + dump(`Juggler listening on ws://127.0.0.1:${this._server.port}/${token}\n`); + }, + + QueryInterface: ChromeUtils.generateQI([ Ci.nsICommandLineHandler ]), + + // CHANGEME: change the help info as appropriate, but + // follow the guidelines in nsICommandLineHandler.idl + // specifically, flag descriptions should start at + // character 24, and lines should be wrapped at + // 72 characters with embedded newlines, + // and finally, the string should end with a newline + helpInfo : " --juggler Enable Juggler automation\n" +}; + +var NSGetFactory = XPCOMUtils.generateNSGetFactory([CommandLineHandler]); + +/** + * @return {!Promise} + */ +async function waitForBrowserWindow() { + const windowsIt = Services.wm.getEnumerator('navigator:browser'); + if (windowsIt.hasMoreElements()) + return waitForWindowLoaded(windowsIt.getNext()); + + let fulfill; + let promise = new Promise(x => fulfill = x); + + const listener = { + onOpenWindow: window => { + if (window instanceof Ci.nsIDOMChromeWindow) { + Services.wm.removeListener(listener); + fulfill(waitForWindowLoaded(window)); + } + }, + onCloseWindow: () => {} + }; + Services.wm.addListener(listener); + return promise; + + /** + * @param {!Ci.nsIDOMChromeWindow} window + * @return {!Promise} + */ + function waitForWindowLoaded(window) { + if (window.document.readyState === 'complete') + return window; + return new Promise(fulfill => { + window.addEventListener('load', function listener() { + window.removeEventListener('load', listener); + fulfill(window); + }); + }); + } +} diff --git a/juggler/components/juggler.manifest b/juggler/components/juggler.manifest new file mode 100644 index 0000000000000..50f8930207563 --- /dev/null +++ b/juggler/components/juggler.manifest @@ -0,0 +1,3 @@ +component {f7a74a33-e2ab-422d-b022-4fb213dd2639} juggler.js +contract @mozilla.org/remote/juggler;1 {f7a74a33-e2ab-422d-b022-4fb213dd2639} +category command-line-handler m-juggler @mozilla.org/remote/juggler;1 diff --git a/juggler/components/moz.build b/juggler/components/moz.build new file mode 100644 index 0000000000000..268fbc361d805 --- /dev/null +++ b/juggler/components/moz.build @@ -0,0 +1,9 @@ +# 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/. + +EXTRA_COMPONENTS += [ + "juggler.js", + "juggler.manifest", +] + diff --git a/juggler/content/FrameTree.js b/juggler/content/FrameTree.js new file mode 100644 index 0000000000000..679b5851c4270 --- /dev/null +++ b/juggler/content/FrameTree.js @@ -0,0 +1,411 @@ +"use strict"; +const Ci = Components.interfaces; +const Cr = Components.results; +const Cu = Components.utils; + +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); +const {SimpleChannel} = ChromeUtils.import('chrome://juggler/content/SimpleChannel.js'); +const {EventEmitter} = ChromeUtils.import('resource://gre/modules/EventEmitter.jsm'); + +const helper = new Helper(); + +class FrameTree { + constructor(rootDocShell, waitForInitialNavigation) { + EventEmitter.decorate(this); + + this._browsingContextGroup = rootDocShell.browsingContext.group; + if (!this._browsingContextGroup.__jugglerFrameTrees) + this._browsingContextGroup.__jugglerFrameTrees = new Set(); + this._browsingContextGroup.__jugglerFrameTrees.add(this); + + this._bindings = new Map(); + this._workers = new Map(); + this._docShellToFrame = new Map(); + this._frameIdToFrame = new Map(); + this._pageReady = !waitForInitialNavigation; + this._mainFrame = this._createFrame(rootDocShell); + const webProgress = rootDocShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + this.QueryInterface = ChromeUtils.generateQI([ + Ci.nsIWebProgressListener, + Ci.nsIWebProgressListener2, + Ci.nsISupportsWeakReference, + ]); + this._scriptsToEvaluateOnNewDocument = []; + + this._wdm = Cc["@mozilla.org/dom/workers/workerdebuggermanager;1"].createInstance(Ci.nsIWorkerDebuggerManager); + this._wdmListener = { + QueryInterface: ChromeUtils.generateQI([Ci.nsIWorkerDebuggerManagerListener]), + onRegister: this._onWorkerCreated.bind(this), + onUnregister: this._onWorkerDestroyed.bind(this), + }; + this._wdm.addListener(this._wdmListener); + for (const workerDebugger of this._wdm.getWorkerDebuggerEnumerator()) + this._onWorkerCreated(workerDebugger); + + + const flags = Ci.nsIWebProgress.NOTIFY_STATE_DOCUMENT | + Ci.nsIWebProgress.NOTIFY_FRAME_LOCATION; + this._eventListeners = [ + helper.addObserver(subject => this._onDocShellCreated(subject.QueryInterface(Ci.nsIDocShell)), 'webnavigation-create'), + helper.addObserver(subject => this._onDocShellDestroyed(subject.QueryInterface(Ci.nsIDocShell)), 'webnavigation-destroy'), + helper.addObserver(window => this._onDOMWindowCreated(window), 'content-document-global-created'), + helper.addProgressListener(webProgress, this, flags), + ]; + } + + workers() { + return [...this._workers.values()]; + } + + _frameForWorker(workerDebugger) { + if (workerDebugger.type !== Ci.nsIWorkerDebugger.TYPE_DEDICATED) + return null; + const docShell = workerDebugger.window.docShell; + return this._docShellToFrame.get(docShell) || null; + } + + _onWorkerCreated(workerDebugger) { + // Note: we do not interoperate with firefox devtools. + if (workerDebugger.isInitialized) + return; + const frame = this._frameForWorker(workerDebugger); + if (!frame) + return; + const worker = new Worker(frame, workerDebugger); + this._workers.set(workerDebugger, worker); + this.emit(FrameTree.Events.WorkerCreated, worker); + } + + _onWorkerDestroyed(workerDebugger) { + const worker = this._workers.get(workerDebugger); + if (!worker) + return; + worker.dispose(); + this._workers.delete(workerDebugger); + this.emit(FrameTree.Events.WorkerDestroyed, worker); + } + + allFramesInBrowsingContextGroup(group) { + const frames = []; + for (const frameTree of (group.__jugglerFrameTrees || [])) + frames.push(...frameTree.frames()); + return frames; + } + + isPageReady() { + return this._pageReady; + } + + forcePageReady() { + if (this._pageReady) + return false; + this._pageReady = true; + this.emit(FrameTree.Events.PageReady); + return true; + } + + addScriptToEvaluateOnNewDocument(script) { + this._scriptsToEvaluateOnNewDocument.push(script); + } + + scriptsToEvaluateOnNewDocument() { + return this._scriptsToEvaluateOnNewDocument; + } + + addBinding(name, script) { + this._bindings.set(name, script); + for (const frame of this.frames()) + this._addBindingToFrame(frame, name, script); + } + + _addBindingToFrame(frame, name, script) { + Cu.exportFunction((...args) => { + this.emit(FrameTree.Events.BindingCalled, { + frame, + name, + payload: args[0] + }); + }, frame.domWindow(), { + defineAs: name, + }); + frame.domWindow().eval(script); + } + + frameForDocShell(docShell) { + return this._docShellToFrame.get(docShell) || null; + } + + frame(frameId) { + return this._frameIdToFrame.get(frameId) || null; + } + + frames() { + let result = []; + collect(this._mainFrame); + return result; + + function collect(frame) { + result.push(frame); + for (const subframe of frame._children) + collect(subframe); + } + } + + mainFrame() { + return this._mainFrame; + } + + dispose() { + this._browsingContextGroup.__jugglerFrameTrees.delete(this); + this._wdm.removeListener(this._wdmListener); + helper.removeListeners(this._eventListeners); + } + + onStateChange(progress, request, flag, status) { + if (!(request instanceof Ci.nsIChannel)) + return; + const channel = request.QueryInterface(Ci.nsIChannel); + const docShell = progress.DOMWindow.docShell; + const frame = this._docShellToFrame.get(docShell); + if (!frame) { + dump(`ERROR: got a state changed event for un-tracked docshell!\n`); + return; + } + + const isStart = flag & Ci.nsIWebProgressListener.STATE_START; + const isTransferring = flag & Ci.nsIWebProgressListener.STATE_TRANSFERRING; + const isStop = flag & Ci.nsIWebProgressListener.STATE_STOP; + + if (isStart) { + // Starting a new navigation. + frame._pendingNavigationId = this._channelId(channel); + frame._pendingNavigationURL = channel.URI.spec; + this.emit(FrameTree.Events.NavigationStarted, frame); + } else if (isTransferring || (isStop && frame._pendingNavigationId && !status)) { + // Navigation is committed. + for (const subframe of frame._children) + this._detachFrame(subframe); + const navigationId = frame._pendingNavigationId; + frame._pendingNavigationId = null; + frame._pendingNavigationURL = null; + frame._lastCommittedNavigationId = navigationId; + frame._url = channel.URI.spec; + this.emit(FrameTree.Events.NavigationCommitted, frame); + if (frame === this._mainFrame) + this.forcePageReady(); + } else if (isStop && frame._pendingNavigationId && status) { + // Navigation is aborted. + const navigationId = frame._pendingNavigationId; + frame._pendingNavigationId = null; + frame._pendingNavigationURL = null; + this.emit(FrameTree.Events.NavigationAborted, frame, navigationId, helper.getNetworkErrorStatusText(status)); + } + } + + onFrameLocationChange(progress, request, location, flags) { + const docShell = progress.DOMWindow.docShell; + const frame = this._docShellToFrame.get(docShell); + const sameDocumentNavigation = !!(flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT); + if (frame && sameDocumentNavigation) { + frame._url = location.spec; + this.emit(FrameTree.Events.SameDocumentNavigation, frame); + } + } + + _channelId(channel) { + if (channel instanceof Ci.nsIHttpChannel) { + const httpChannel = channel.QueryInterface(Ci.nsIHttpChannel); + return String(httpChannel.channelId); + } + return helper.generateId(); + } + + _onDocShellCreated(docShell) { + // Bug 1142752: sometimes, the docshell appears to be immediately + // destroyed, bailout early to prevent random exceptions. + if (docShell.isBeingDestroyed()) + return; + // If this docShell doesn't belong to our frame tree - do nothing. + let root = docShell; + while (root.parent) + root = root.parent; + if (root === this._mainFrame._docShell) + this._createFrame(docShell); + } + + _createFrame(docShell) { + const parentFrame = this._docShellToFrame.get(docShell.parent) || null; + const frame = new Frame(this, docShell, parentFrame); + this._docShellToFrame.set(docShell, frame); + this._frameIdToFrame.set(frame.id(), frame); + for (const [name, script] of this._bindings) + this._addBindingToFrame(frame, name, script); + this.emit(FrameTree.Events.FrameAttached, frame); + return frame; + } + + _onDocShellDestroyed(docShell) { + const frame = this._docShellToFrame.get(docShell); + if (frame) + this._detachFrame(frame); + } + + _onDOMWindowCreated(window) { + const docShell = window.docShell; + const frame = this.frameForDocShell(docShell); + if (!frame) + return; + for (const [name, script] of this._bindings) + this._addBindingToFrame(frame, name, script); + this.emit(FrameTree.Events.GlobalObjectCreated, { frame, window }); + } + + _detachFrame(frame) { + // Detach all children first + for (const subframe of frame._children) + this._detachFrame(subframe); + this._docShellToFrame.delete(frame._docShell); + this._frameIdToFrame.delete(frame.id()); + if (frame._parentFrame) + frame._parentFrame._children.delete(frame); + frame._parentFrame = null; + this.emit(FrameTree.Events.FrameDetached, frame); + } +} + +FrameTree.Events = { + BindingCalled: 'bindingcalled', + FrameAttached: 'frameattached', + FrameDetached: 'framedetached', + GlobalObjectCreated: 'globalobjectcreated', + WorkerCreated: 'workercreated', + WorkerDestroyed: 'workerdestroyed', + NavigationStarted: 'navigationstarted', + NavigationCommitted: 'navigationcommitted', + NavigationAborted: 'navigationaborted', + SameDocumentNavigation: 'samedocumentnavigation', + PageReady: 'pageready', +}; + +class Frame { + constructor(frameTree, docShell, parentFrame) { + this._frameTree = frameTree; + this._docShell = docShell; + this._children = new Set(); + this._frameId = helper.generateId(); + this._parentFrame = null; + this._url = ''; + if (docShell.domWindow && docShell.domWindow.location) + this._url = docShell.domWindow.location.href; + if (parentFrame) { + this._parentFrame = parentFrame; + parentFrame._children.add(this); + } + + this._lastCommittedNavigationId = null; + this._pendingNavigationId = null; + this._pendingNavigationURL = null; + + this._textInputProcessor = null; + } + + textInputProcessor() { + if (!this._textInputProcessor) { + this._textInputProcessor = Cc["@mozilla.org/text-input-processor;1"].createInstance(Ci.nsITextInputProcessor); + this._textInputProcessor.beginInputTransactionForTests(this._docShell.DOMWindow); + } + return this._textInputProcessor; + } + + pendingNavigationId() { + return this._pendingNavigationId; + } + + pendingNavigationURL() { + return this._pendingNavigationURL; + } + + lastCommittedNavigationId() { + return this._lastCommittedNavigationId; + } + + docShell() { + return this._docShell; + } + + domWindow() { + return this._docShell.domWindow; + } + + name() { + const frameElement = this._docShell.domWindow.frameElement; + let name = ''; + if (frameElement) + name = frameElement.getAttribute('name') || frameElement.getAttribute('id') || ''; + return name; + } + + parentFrame() { + return this._parentFrame; + } + + id() { + return this._frameId; + } + + url() { + return this._url; + } + +} + +class Worker { + constructor(frame, workerDebugger) { + this._frame = frame; + this._workerId = helper.generateId(); + this._workerDebugger = workerDebugger; + + workerDebugger.initialize('chrome://juggler/content/content/WorkerMain.js'); + + this._channel = new SimpleChannel(`content::worker[${this._workerId}]`); + this._channel.transport = { + sendMessage: obj => workerDebugger.postMessage(JSON.stringify(obj)), + dispose: () => {}, + }; + this._workerDebuggerListener = { + QueryInterface: ChromeUtils.generateQI([Ci.nsIWorkerDebuggerListener]), + onMessage: msg => void this._channel._onMessage(JSON.parse(msg)), + onClose: () => void this._channel.dispose(), + onError: (filename, lineno, message) => { + dump(`Error in worker: ${message} @${filename}:${lineno}\n`); + }, + }; + workerDebugger.addListener(this._workerDebuggerListener); + } + + channel() { + return this._channel; + } + + frame() { + return this._frame; + } + + id() { + return this._workerId; + } + + url() { + return this._workerDebugger.url; + } + + dispose() { + this._channel.dispose(); + this._workerDebugger.removeListener(this._workerDebuggerListener); + } +} + +var EXPORTED_SYMBOLS = ['FrameTree']; +this.FrameTree = FrameTree; + diff --git a/juggler/content/NetworkMonitor.js b/juggler/content/NetworkMonitor.js new file mode 100644 index 0000000000000..be70ea364f953 --- /dev/null +++ b/juggler/content/NetworkMonitor.js @@ -0,0 +1,62 @@ +"use strict"; +const Ci = Components.interfaces; +const Cr = Components.results; +const Cu = Components.utils; + +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); + +const helper = new Helper(); + +class NetworkMonitor { + constructor(rootDocShell, frameTree) { + this._frameTree = frameTree; + this._requestDetails = new Map(); + + this._eventListeners = [ + helper.addObserver(this._onRequest.bind(this), 'http-on-opening-request'), + ]; + } + + _onRequest(channel) { + if (!(channel instanceof Ci.nsIHttpChannel)) + return; + const httpChannel = channel.QueryInterface(Ci.nsIHttpChannel); + const loadContext = getLoadContext(httpChannel); + if (!loadContext) + return; + const window = loadContext.associatedWindow; + const frame = this._frameTree.frameForDocShell(window.docShell); + if (!frame) + return; + this._requestDetails.set(httpChannel.channelId, { + frameId: frame.id(), + }); + } + + requestDetails(channelId) { + return this._requestDetails.get(channelId) || null; + } + + dispose() { + this._requestDetails.clear(); + helper.removeListeners(this._eventListeners); + } +} + +function getLoadContext(httpChannel) { + let loadContext = null; + try { + if (httpChannel.notificationCallbacks) + loadContext = httpChannel.notificationCallbacks.getInterface(Ci.nsILoadContext); + } catch (e) {} + try { + if (!loadContext && httpChannel.loadGroup) + loadContext = httpChannel.loadGroup.notificationCallbacks.getInterface(Ci.nsILoadContext); + } catch (e) { } + return loadContext; +} + + +var EXPORTED_SYMBOLS = ['NetworkMonitor']; +this.NetworkMonitor = NetworkMonitor; + diff --git a/juggler/content/PageAgent.js b/juggler/content/PageAgent.js new file mode 100644 index 0000000000000..6a001d9f51c81 --- /dev/null +++ b/juggler/content/PageAgent.js @@ -0,0 +1,921 @@ +"use strict"; +const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const Ci = Components.interfaces; +const Cr = Components.results; +const Cu = Components.utils; + +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); +const {NetUtil} = ChromeUtils.import('resource://gre/modules/NetUtil.jsm'); + +const helper = new Helper(); + +class WorkerData { + constructor(pageAgent, browserChannel, sessionId, worker) { + this._workerRuntime = worker.channel().connect(sessionId + 'runtime'); + this._browserWorker = browserChannel.connect(sessionId + worker.id()); + this._worker = worker; + this._sessionId = sessionId; + const emit = name => { + return (...args) => this._browserWorker.emit(name, ...args); + }; + this._eventListeners = [ + worker.channel().register(sessionId + 'runtime', { + runtimeConsole: emit('runtimeConsole'), + runtimeExecutionContextCreated: emit('runtimeExecutionContextCreated'), + runtimeExecutionContextDestroyed: emit('runtimeExecutionContextDestroyed'), + workerConsoleMessage: (hash) => pageAgent._runtime.filterConsoleMessage(hash), + }), + browserChannel.register(sessionId + worker.id(), { + evaluate: (options) => this._workerRuntime.send('evaluate', options), + callFunction: (options) => this._workerRuntime.send('callFunction', options), + getObjectProperties: (options) => this._workerRuntime.send('getObjectProperties', options), + disposeObject: (options) =>this._workerRuntime.send('disposeObject', options), + }), + ]; + worker.channel().connect('').emit('attach', {sessionId}); + } + + dispose() { + this._worker.channel().connect('').emit('detach', {sessionId: this._sessionId}); + this._workerRuntime.dispose(); + this._browserWorker.dispose(); + helper.removeListeners(this._eventListeners); + } +} + +class FrameData { + constructor(agent, frame) { + this._agent = agent; + this._frame = frame; + this._isolatedWorlds = new Map(); + this.reset(); + } + + reset() { + if (this.mainContext) + this._agent._runtime.destroyExecutionContext(this.mainContext); + for (const world of this._isolatedWorlds.values()) + this._agent._runtime.destroyExecutionContext(world); + this._isolatedWorlds.clear(); + + this.mainContext = this._agent._runtime.createExecutionContext(this._frame.domWindow(), this._frame.domWindow(), { + frameId: this._frame.id(), + name: '', + }); + + for (const script of this._agent._frameTree.scriptsToEvaluateOnNewDocument()) { + // TODO: this should actually be handled in FrameTree, but first we have to move + // execution contexts there. + try { + let result = this.mainContext.evaluateScript(script); + if (result && result.objectId) + this.mainContext.disposeObject(result.objectId); + } catch (e) { + } + } + for (const {script, worldName} of this._agent._scriptsToEvaluateOnNewDocument.values()) { + const context = worldName ? this.createIsolatedWorld(worldName) : this.mainContext; + try { + let result = context.evaluateScript(script); + if (result && result.objectId) + context.disposeObject(result.objectId); + } catch (e) { + } + } + } + + createIsolatedWorld(name) { + const principal = [this._frame.domWindow()]; // extended principal + const sandbox = Cu.Sandbox(principal, { + sandboxPrototype: this._frame.domWindow(), + wantComponents: false, + wantExportHelpers: false, + wantXrays: true, + }); + const world = this._agent._runtime.createExecutionContext(this._frame.domWindow(), sandbox, { + frameId: this._frame.id(), + name, + }); + this._isolatedWorlds.set(world.id(), world); + return world; + } + + unsafeObject(objectId) { + if (this.mainContext) { + const result = this.mainContext.unsafeObject(objectId); + if (result) + return result.object; + } + for (const world of this._isolatedWorlds.values()) { + const result = world.unsafeObject(objectId); + if (result) + return result.object; + } + throw new Error('Cannot find object with id = ' + objectId); + } + + dispose() {} +} + +class PageAgent { + constructor(messageManager, browserChannel, sessionId, runtimeAgent, frameTree, networkMonitor) { + this._messageManager = messageManager; + this._browserChannel = browserChannel; + this._sessionId = sessionId; + this._browserPage = browserChannel.connect(sessionId + 'page'); + this._runtime = runtimeAgent; + this._frameTree = frameTree; + this._networkMonitor = networkMonitor; + + this._frameData = new Map(); + this._workerData = new Map(); + this._scriptsToEvaluateOnNewDocument = new Map(); + + this._eventListeners = [ + browserChannel.register(sessionId + 'page', { + addBinding: ({ name, script }) => this._frameTree.addBinding(name, script), + addScriptToEvaluateOnNewDocument: this._addScriptToEvaluateOnNewDocument.bind(this), + adoptNode: this._adoptNode.bind(this), + awaitViewportDimensions: this._awaitViewportDimensions.bind(this), + crash: this._crash.bind(this), + describeNode: this._describeNode.bind(this), + dispatchKeyEvent: this._dispatchKeyEvent.bind(this), + dispatchMouseEvent: this._dispatchMouseEvent.bind(this), + dispatchTouchEvent: this._dispatchTouchEvent.bind(this), + getBoundingBox: this._getBoundingBox.bind(this), + getContentQuads: this._getContentQuads.bind(this), + getFullAXTree: this._getFullAXTree.bind(this), + goBack: this._goBack.bind(this), + goForward: this._goForward.bind(this), + insertText: this._insertText.bind(this), + navigate: this._navigate.bind(this), + reload: this._reload.bind(this), + removeScriptToEvaluateOnNewDocument: this._removeScriptToEvaluateOnNewDocument.bind(this), + requestDetails: this._requestDetails.bind(this), + screenshot: this._screenshot.bind(this), + scrollIntoViewIfNeeded: this._scrollIntoViewIfNeeded.bind(this), + setCacheDisabled: this._setCacheDisabled.bind(this), + setEmulatedMedia: this._setEmulatedMedia.bind(this), + setFileInputFiles: this._setFileInputFiles.bind(this), + setInterceptFileChooserDialog: this._setInterceptFileChooserDialog.bind(this), + }), + ]; + this._enabled = false; + + const docShell = frameTree.mainFrame().docShell(); + this._docShell = docShell; + this._initialDPPX = docShell.contentViewer.overrideDPPX; + this._customScrollbars = null; + + this._runtime.setOnErrorFromWorker((domWindow, message, stack) => { + const frame = this._frameTree.frameForDocShell(domWindow.docShell); + if (!frame) + return; + this._browserPage.emit('pageUncaughtError', { + frameId: frame.id(), + message, + stack, + }); + }); + } + + async _awaitViewportDimensions({width, height}) { + const win = this._frameTree.mainFrame().domWindow(); + if (win.innerWidth === width && win.innerHeight === height) + return; + await new Promise(resolve => { + const listener = helper.addEventListener(win, 'resize', () => { + if (win.innerWidth === width && win.innerHeight === height) { + helper.removeListeners([listener]); + resolve(); + } + }); + }); + } + + _requestDetails({channelId}) { + return this._networkMonitor.requestDetails(channelId); + } + + async _setEmulatedMedia({type, colorScheme}) { + const docShell = this._frameTree.mainFrame().docShell(); + const cv = docShell.contentViewer; + if (type === '') + cv.stopEmulatingMedium(); + else if (type) + cv.emulateMedium(type); + switch (colorScheme) { + case 'light': cv.emulatePrefersColorScheme(cv.PREFERS_COLOR_SCHEME_LIGHT); break; + case 'dark': cv.emulatePrefersColorScheme(cv.PREFERS_COLOR_SCHEME_DARK); break; + case 'no-preference': cv.emulatePrefersColorScheme(cv.PREFERS_COLOR_SCHEME_NO_PREFERENCE); break; + } + } + + _addScriptToEvaluateOnNewDocument({script, worldName}) { + const scriptId = helper.generateId(); + this._scriptsToEvaluateOnNewDocument.set(scriptId, {script, worldName}); + if (worldName) { + for (const frameData of this._frameData.values()) + frameData.createIsolatedWorld(worldName); + } + return {scriptId}; + } + + _removeScriptToEvaluateOnNewDocument({scriptId}) { + this._scriptsToEvaluateOnNewDocument.delete(scriptId); + } + + _setCacheDisabled({cacheDisabled}) { + const enable = Ci.nsIRequest.LOAD_NORMAL; + const disable = Ci.nsIRequest.LOAD_BYPASS_CACHE | + Ci.nsIRequest.INHIBIT_CACHING; + + const docShell = this._frameTree.mainFrame().docShell(); + docShell.defaultLoadFlags = cacheDisabled ? disable : enable; + } + + enable() { + if (this._enabled) + return; + + this._enabled = true; + // Dispatch frameAttached events for all initial frames + for (const frame of this._frameTree.frames()) { + this._onFrameAttached(frame); + if (frame.url()) + this._onNavigationCommitted(frame); + if (frame.pendingNavigationId()) + this._onNavigationStarted(frame); + } + + for (const worker of this._frameTree.workers()) + this._onWorkerCreated(worker); + + this._eventListeners.push(...[ + helper.addObserver(this._linkClicked.bind(this, false), 'juggler-link-click'), + helper.addObserver(this._linkClicked.bind(this, true), 'juggler-link-click-sync'), + helper.addObserver(this._filePickerShown.bind(this), 'juggler-file-picker-shown'), + helper.addEventListener(this._messageManager, 'DOMContentLoaded', this._onDOMContentLoaded.bind(this)), + helper.addEventListener(this._messageManager, 'pageshow', this._onLoad.bind(this)), + helper.addObserver(this._onDocumentOpenLoad.bind(this), 'juggler-document-open-loaded'), + helper.addEventListener(this._messageManager, 'error', this._onError.bind(this)), + helper.on(this._frameTree, 'bindingcalled', this._onBindingCalled.bind(this)), + helper.on(this._frameTree, 'frameattached', this._onFrameAttached.bind(this)), + helper.on(this._frameTree, 'framedetached', this._onFrameDetached.bind(this)), + helper.on(this._frameTree, 'globalobjectcreated', this._onGlobalObjectCreated.bind(this)), + helper.on(this._frameTree, 'navigationstarted', this._onNavigationStarted.bind(this)), + helper.on(this._frameTree, 'navigationcommitted', this._onNavigationCommitted.bind(this)), + helper.on(this._frameTree, 'navigationaborted', this._onNavigationAborted.bind(this)), + helper.on(this._frameTree, 'samedocumentnavigation', this._onSameDocumentNavigation.bind(this)), + helper.on(this._frameTree, 'pageready', () => this._browserPage.emit('pageReady', {})), + helper.on(this._frameTree, 'workercreated', this._onWorkerCreated.bind(this)), + helper.on(this._frameTree, 'workerdestroyed', this._onWorkerDestroyed.bind(this)), + helper.addObserver(this._onWindowOpen.bind(this), 'webNavigation-createdNavigationTarget-from-js'), + ]); + + if (this._frameTree.isPageReady()) + this._browserPage.emit('pageReady', {}); + } + + _onWorkerCreated(worker) { + const workerData = new WorkerData(this, this._browserChannel, this._sessionId, worker); + this._workerData.set(worker.id(), workerData); + this._browserPage.emit('pageWorkerCreated', { + workerId: worker.id(), + frameId: worker.frame().id(), + url: worker.url(), + }); + } + + _onWorkerDestroyed(worker) { + const workerData = this._workerData.get(worker.id()); + if (!workerData) + return; + this._workerData.delete(worker.id()); + workerData.dispose(); + this._browserPage.emit('pageWorkerDestroyed', { + workerId: worker.id(), + }); + } + + _onWindowOpen(subject) { + if (!(subject instanceof Ci.nsIPropertyBag2)) + return; + const props = subject.QueryInterface(Ci.nsIPropertyBag2); + const hasUrl = props.hasKey('url'); + const createdDocShell = props.getPropertyAsInterface('createdTabDocShell', Ci.nsIDocShell); + if (!hasUrl && createdDocShell === this._docShell && this._frameTree.forcePageReady()) { + this._browserPage.emit('pageEventFired', { + frameId: this._frameTree.mainFrame().id(), + name: 'DOMContentLoaded', + }); + this._browserPage.emit('pageEventFired', { + frameId: this._frameTree.mainFrame().id(), + name: 'load', + }); + } + } + + _setInterceptFileChooserDialog({enabled}) { + this._docShell.fileInputInterceptionEnabled = !!enabled; + } + + _linkClicked(sync, anchorElement) { + if (anchorElement.ownerGlobal.docShell !== this._docShell) + return; + this._browserPage.emit('pageLinkClicked', { phase: sync ? 'after' : 'before' }); + } + + _filePickerShown(inputElement) { + if (inputElement.ownerGlobal.docShell !== this._docShell) + return; + const frameData = this._findFrameForNode(inputElement); + this._browserPage.emit('pageFileChooserOpened', { + executionContextId: frameData.mainContext.id(), + element: frameData.mainContext.rawValueToRemoteObject(inputElement) + }); + } + + _findFrameForNode(node) { + return Array.from(this._frameData.values()).find(data => { + const doc = data._frame.domWindow().document; + return node === doc || node.ownerDocument === doc; + }); + } + + _onDOMContentLoaded(event) { + const docShell = event.target.ownerGlobal.docShell; + const frame = this._frameTree.frameForDocShell(docShell); + if (!frame) + return; + this._browserPage.emit('pageEventFired', { + frameId: frame.id(), + name: 'DOMContentLoaded', + }); + } + + _onError(errorEvent) { + const docShell = errorEvent.target.ownerGlobal.docShell; + const frame = this._frameTree.frameForDocShell(docShell); + if (!frame) + return; + this._browserPage.emit('pageUncaughtError', { + frameId: frame.id(), + message: errorEvent.message, + stack: errorEvent.error ? errorEvent.error.stack : '', + }); + } + + _onDocumentOpenLoad(document) { + const docShell = document.ownerGlobal.docShell; + const frame = this._frameTree.frameForDocShell(docShell); + if (!frame) + return; + this._browserPage.emit('pageEventFired', { + frameId: frame.id(), + name: 'load' + }); + } + + _onLoad(event) { + const docShell = event.target.ownerGlobal.docShell; + const frame = this._frameTree.frameForDocShell(docShell); + if (!frame) + return; + this._browserPage.emit('pageEventFired', { + frameId: frame.id(), + name: 'load' + }); + } + + _onNavigationStarted(frame) { + this._browserPage.emit('pageNavigationStarted', { + frameId: frame.id(), + navigationId: frame.pendingNavigationId(), + url: frame.pendingNavigationURL(), + }); + } + + _onNavigationAborted(frame, navigationId, errorText) { + this._browserPage.emit('pageNavigationAborted', { + frameId: frame.id(), + navigationId, + errorText, + }); + } + + _onSameDocumentNavigation(frame) { + this._browserPage.emit('pageSameDocumentNavigation', { + frameId: frame.id(), + url: frame.url(), + }); + } + + _onNavigationCommitted(frame) { + this._browserPage.emit('pageNavigationCommitted', { + frameId: frame.id(), + navigationId: frame.lastCommittedNavigationId() || undefined, + url: frame.url(), + name: frame.name(), + }); + } + + _onGlobalObjectCreated({ frame }) { + this._frameData.get(frame).reset(); + } + + _onFrameAttached(frame) { + this._browserPage.emit('pageFrameAttached', { + frameId: frame.id(), + parentFrameId: frame.parentFrame() ? frame.parentFrame().id() : undefined, + }); + this._frameData.set(frame, new FrameData(this, frame)); + } + + _onFrameDetached(frame) { + this._frameData.delete(frame); + this._browserPage.emit('pageFrameDetached', { + frameId: frame.id(), + }); + } + + _onBindingCalled({frame, name, payload}) { + const frameData = this._frameData.get(frame); + this._browserPage.emit('pageBindingCalled', { + executionContextId: frameData.mainContext.id(), + name, + payload + }); + } + + dispose() { + for (const workerData of this._workerData.values()) + workerData.dispose(); + this._workerData.clear(); + for (const frameData of this._frameData.values()) + frameData.dispose(); + this._frameData.clear(); + helper.removeListeners(this._eventListeners); + } + + async _navigate({frameId, url, referer}) { + try { + const uri = NetUtil.newURI(url); + } catch (e) { + throw new Error(`Invalid url: "${url}"`); + } + let referrerURI = null; + let referrerInfo = null; + if (referer) { + try { + referrerURI = NetUtil.newURI(referer); + const ReferrerInfo = Components.Constructor( + '@mozilla.org/referrer-info;1', + 'nsIReferrerInfo', + 'init' + ); + referrerInfo = new ReferrerInfo(Ci.nsIHttpChannel.REFERRER_POLICY_UNSET, true, referrerURI); + } catch (e) { + throw new Error(`Invalid referer: "${referer}"`); + } + } + const frame = this._frameTree.frame(frameId); + const docShell = frame.docShell().QueryInterface(Ci.nsIWebNavigation); + docShell.loadURI(url, { + flags: Ci.nsIWebNavigation.LOAD_FLAGS_NONE, + referrerInfo, + postData: null, + headers: null, + }); + return {navigationId: frame.pendingNavigationId(), navigationURL: frame.pendingNavigationURL()}; + } + + async _reload({frameId, url}) { + const frame = this._frameTree.frame(frameId); + const docShell = frame.docShell().QueryInterface(Ci.nsIWebNavigation); + docShell.reload(Ci.nsIWebNavigation.LOAD_FLAGS_NONE); + return {navigationId: frame.pendingNavigationId(), navigationURL: frame.pendingNavigationURL()}; + } + + async _goBack({frameId, url}) { + const frame = this._frameTree.frame(frameId); + const docShell = frame.docShell(); + if (!docShell.canGoBack) + return {navigationId: null, navigationURL: null}; + docShell.goBack(); + return {navigationId: frame.pendingNavigationId(), navigationURL: frame.pendingNavigationURL()}; + } + + async _goForward({frameId, url}) { + const frame = this._frameTree.frame(frameId); + const docShell = frame.docShell(); + if (!docShell.canGoForward) + return {navigationId: null, navigationURL: null}; + docShell.goForward(); + return {navigationId: frame.pendingNavigationId(), navigationURL: frame.pendingNavigationURL()}; + } + + async _adoptNode({frameId, objectId, executionContextId}) { + const frame = this._frameTree.frame(frameId); + if (!frame) + throw new Error('Failed to find frame with id = ' + frameId); + const unsafeObject = this._frameData.get(frame).unsafeObject(objectId); + const context = this._runtime.findExecutionContext(executionContextId); + const fromPrincipal = unsafeObject.nodePrincipal; + const toFrame = this._frameTree.frame(context.auxData().frameId); + const toPrincipal = toFrame.domWindow().document.nodePrincipal; + if (!toPrincipal.subsumes(fromPrincipal)) + return { remoteObject: null }; + return { remoteObject: context.rawValueToRemoteObject(unsafeObject) }; + } + + async _setFileInputFiles({objectId, frameId, files}) { + const frame = this._frameTree.frame(frameId); + if (!frame) + throw new Error('Failed to find frame with id = ' + frameId); + const unsafeObject = this._frameData.get(frame).unsafeObject(objectId); + if (!unsafeObject) + throw new Error('Object is not input!'); + const nsFiles = await Promise.all(files.map(filePath => File.createFromFileName(filePath))); + unsafeObject.mozSetFileArray(nsFiles); + } + + _getContentQuads({objectId, frameId}) { + const frame = this._frameTree.frame(frameId); + if (!frame) + throw new Error('Failed to find frame with id = ' + frameId); + const unsafeObject = this._frameData.get(frame).unsafeObject(objectId); + if (!unsafeObject.getBoxQuads) + throw new Error('RemoteObject is not a node'); + const quads = unsafeObject.getBoxQuads({relativeTo: this._frameTree.mainFrame().domWindow().document}).map(quad => { + return { + p1: {x: quad.p1.x, y: quad.p1.y}, + p2: {x: quad.p2.x, y: quad.p2.y}, + p3: {x: quad.p3.x, y: quad.p3.y}, + p4: {x: quad.p4.x, y: quad.p4.y}, + }; + }); + return {quads}; + } + + _describeNode({objectId, frameId}) { + const frame = this._frameTree.frame(frameId); + if (!frame) + throw new Error('Failed to find frame with id = ' + frameId); + const unsafeObject = this._frameData.get(frame).unsafeObject(objectId); + const browsingContextGroup = frame.docShell().browsingContext.group; + const frames = this._frameTree.allFramesInBrowsingContextGroup(browsingContextGroup); + let contentFrame; + let ownerFrame; + for (const frame of frames) { + if (unsafeObject.contentWindow && frame.docShell() === unsafeObject.contentWindow.docShell) + contentFrame = frame; + const document = frame.domWindow().document; + if (unsafeObject === document || unsafeObject.ownerDocument === document) + ownerFrame = frame; + } + return { + contentFrameId: contentFrame ? contentFrame.id() : undefined, + ownerFrameId: ownerFrame ? ownerFrame.id() : undefined, + }; + } + + async _scrollIntoViewIfNeeded({objectId, frameId, rect}) { + const frame = this._frameTree.frame(frameId); + if (!frame) + throw new Error('Failed to find frame with id = ' + frameId); + const unsafeObject = this._frameData.get(frame).unsafeObject(objectId); + if (!unsafeObject.isConnected) + throw new Error('Node is detached from document'); + await this._scrollNodeIntoViewIfNeeded(unsafeObject); + const box = this._getNodeBoundingBox(unsafeObject); + if (rect) { + box.x += rect.x; + box.y += rect.y; + box.width = rect.width; + box.height = rect.height; + } + this._scrollRectIntoViewIfNeeded(unsafeObject, box); + } + + async _scrollNodeIntoViewIfNeeded(node) { + if (node.nodeType !== 1) + node = node.parentElement; + if (!node.ownerDocument || !node.ownerDocument.defaultView) + return; + const global = node.ownerDocument.defaultView; + const visibleRatio = await new Promise(resolve => { + const observer = new global.IntersectionObserver(entries => { + resolve(entries[0].intersectionRatio); + observer.disconnect(); + }); + observer.observe(node); + // Firefox doesn't call IntersectionObserver callback unless + // there are rafs. + global.requestAnimationFrame(() => {}); + }); + if (visibleRatio !== 1.0) + node.scrollIntoView({block: 'center', inline: 'center', behavior: 'instant'}); + } + + _scrollRectIntoViewIfNeeded(node, rect) { + // TODO: implement. + } + + _getNodeBoundingBox(unsafeObject) { + if (!unsafeObject.getBoxQuads) + throw new Error('RemoteObject is not a node'); + const quads = unsafeObject.getBoxQuads({relativeTo: this._frameTree.mainFrame().domWindow().document}); + if (!quads.length) + return; + let x1 = Infinity; + let y1 = Infinity; + let x2 = -Infinity; + let y2 = -Infinity; + for (const quad of quads) { + const boundingBox = quad.getBounds(); + x1 = Math.min(boundingBox.x, x1); + y1 = Math.min(boundingBox.y, y1); + x2 = Math.max(boundingBox.x + boundingBox.width, x2); + y2 = Math.max(boundingBox.y + boundingBox.height, y2); + } + return {x: x1, y: y1, width: x2 - x1, height: y2 - y1}; + } + + async _getBoundingBox({frameId, objectId}) { + const frame = this._frameTree.frame(frameId); + if (!frame) + throw new Error('Failed to find frame with id = ' + frameId); + const unsafeObject = this._frameData.get(frame).unsafeObject(objectId); + const box = this._getNodeBoundingBox(unsafeObject); + if (!box) + return {boundingBox: null}; + return {boundingBox: {x: box.x + frame.domWindow().scrollX, y: box.y + frame.domWindow().scrollY, width: box.width, height: box.height}}; + } + + async _screenshot({mimeType, fullPage, clip}) { + const content = this._messageManager.content; + if (clip) { + const data = takeScreenshot(content, clip.x, clip.y, clip.width, clip.height, mimeType); + return {data}; + } + if (fullPage) { + const rect = content.document.documentElement.getBoundingClientRect(); + const width = content.innerWidth + content.scrollMaxX - content.scrollMinX; + const height = content.innerHeight + content.scrollMaxY - content.scrollMinY; + const data = takeScreenshot(content, 0, 0, width, height, mimeType); + return {data}; + } + const data = takeScreenshot(content, content.scrollX, content.scrollY, content.innerWidth, content.innerHeight, mimeType); + return {data}; + } + + async _dispatchKeyEvent({type, keyCode, code, key, repeat, location, text}) { + const frame = this._frameTree.mainFrame(); + const tip = frame.textInputProcessor(); + if (key === 'Meta' && Services.appinfo.OS !== 'Darwin') + key = 'OS'; + else if (key === 'OS' && Services.appinfo.OS === 'Darwin') + key = 'Meta'; + let keyEvent = new (frame.domWindow().KeyboardEvent)("", { + key, + code, + location, + repeat, + keyCode + }); + if (type === 'keydown') { + if (text && text !== key) { + tip.commitCompositionWith(text, keyEvent); + } else { + const flags = 0; + tip.keydown(keyEvent, flags); + } + } else if (type === 'keyup') { + if (text) + throw new Error(`keyup does not support text option`); + const flags = 0; + tip.keyup(keyEvent, flags); + } else { + throw new Error(`Unknown type ${type}`); + } + } + + async _dispatchTouchEvent({type, touchPoints, modifiers}) { + const frame = this._frameTree.mainFrame(); + const defaultPrevented = frame.domWindow().windowUtils.sendTouchEvent( + type.toLowerCase(), + touchPoints.map((point, id) => id), + touchPoints.map(point => point.x), + touchPoints.map(point => point.y), + touchPoints.map(point => point.radiusX === undefined ? 1.0 : point.radiusX), + touchPoints.map(point => point.radiusY === undefined ? 1.0 : point.radiusY), + touchPoints.map(point => point.rotationAngle === undefined ? 0.0 : point.rotationAngle), + touchPoints.map(point => point.force === undefined ? 1.0 : point.force), + touchPoints.length, + modifiers); + return {defaultPrevented}; + } + + async _dispatchMouseEvent({type, x, y, button, clickCount, modifiers, buttons}) { + const frame = this._frameTree.mainFrame(); + frame.domWindow().windowUtils.sendMouseEvent( + type, + x, + y, + button, + clickCount, + modifiers, + false /*aIgnoreRootScrollFrame*/, + undefined /*pressure*/, + undefined /*inputSource*/, + undefined /*isDOMEventSynthesized*/, + undefined /*isWidgetEventSynthesized*/, + buttons); + if (type === 'mousedown' && button === 2) { + frame.domWindow().windowUtils.sendMouseEvent( + 'contextmenu', + x, + y, + button, + clickCount, + modifiers, + false /*aIgnoreRootScrollFrame*/, + undefined /*pressure*/, + undefined /*inputSource*/, + undefined /*isDOMEventSynthesized*/, + undefined /*isWidgetEventSynthesized*/, + buttons); + } + } + + async _insertText({text}) { + const frame = this._frameTree.mainFrame(); + frame.textInputProcessor().commitCompositionWith(text); + } + + async _crash() { + dump(`Crashing intentionally\n`); + // This is to intentionally crash the frame. + // We crash by using js-ctypes and dereferencing + // a bad pointer. The crash should happen immediately + // upon loading this frame script. + const { ctypes } = ChromeUtils.import('resource://gre/modules/ctypes.jsm'); + ChromeUtils.privateNoteIntentionalCrash(); + const zero = new ctypes.intptr_t(8); + const badptr = ctypes.cast(zero, ctypes.PointerType(ctypes.int32_t)); + badptr.contents; + } + + async _getFullAXTree({objectId}) { + let unsafeObject = null; + if (objectId) { + unsafeObject = this._frameData.get(this._frameTree.mainFrame()).unsafeObject(objectId); + if (!unsafeObject) + throw new Error(`No object found for id "${objectId}"`); + } + + const service = Cc["@mozilla.org/accessibilityService;1"] + .getService(Ci.nsIAccessibilityService); + const document = this._frameTree.mainFrame().domWindow().document; + const docAcc = service.getAccessibleFor(document); + + while (docAcc.document.isUpdatePendingForJugglerAccessibility) + await new Promise(x => this._frameTree.mainFrame().domWindow().requestAnimationFrame(x)); + + async function waitForQuiet() { + let state = {}; + docAcc.getState(state, {}); + if ((state.value & Ci.nsIAccessibleStates.STATE_BUSY) == 0) + return; + let resolve, reject; + const promise = new Promise((x, y) => {resolve = x, reject = y}); + let eventObserver = { + observe(subject, topic) { + if (topic !== "accessible-event") { + return; + } + + // If event type does not match expected type, skip the event. + let event = subject.QueryInterface(Ci.nsIAccessibleEvent); + if (event.eventType !== Ci.nsIAccessibleEvent.EVENT_STATE_CHANGE) { + return; + } + + // If event's accessible does not match expected accessible, + // skip the event. + if (event.accessible !== docAcc) { + return; + } + + Services.obs.removeObserver(this, "accessible-event"); + resolve(); + }, + }; + Services.obs.addObserver(eventObserver, "accessible-event"); + return promise; + } + function buildNode(accElement) { + let a = {}, b = {}; + accElement.getState(a, b); + const tree = { + role: service.getStringRole(accElement.role), + name: accElement.name || '', + }; + if (unsafeObject && unsafeObject === accElement.DOMNode) + tree.foundObject = true; + for (const userStringProperty of [ + 'value', + 'description' + ]) { + tree[userStringProperty] = accElement[userStringProperty] || undefined; + } + + const states = {}; + for (const name of service.getStringStates(a.value, b.value)) + states[name] = true; + for (const name of ['selected', + 'focused', + 'pressed', + 'focusable', + 'haspopup', + 'required', + 'invalid', + 'modal', + 'editable', + 'busy', + 'checked', + 'multiselectable']) { + if (states[name]) + tree[name] = true; + } + + if (states['multi line']) + tree['multiline'] = true; + if (states['editable'] && states['readonly']) + tree['readonly'] = true; + if (states['checked']) + tree['checked'] = true; + if (states['mixed']) + tree['checked'] = 'mixed'; + if (states['expanded']) + tree['expanded'] = true; + else if (states['collapsed']) + tree['expanded'] = false; + if (!states['enabled']) + tree['disabled'] = true; + + const attributes = {}; + if (accElement.attributes) { + for (const { key, value } of accElement.attributes.enumerate()) { + attributes[key] = value; + } + } + for (const numericalProperty of ['level']) { + if (numericalProperty in attributes) + tree[numericalProperty] = parseFloat(attributes[numericalProperty]); + } + for (const stringProperty of ['tag', 'roledescription', 'valuetext', 'orientation', 'autocomplete', 'keyshortcuts']) { + if (stringProperty in attributes) + tree[stringProperty] = attributes[stringProperty]; + } + const children = []; + + for (let child = accElement.firstChild; child; child = child.nextSibling) { + children.push(buildNode(child)); + } + if (children.length) + tree.children = children; + return tree; + } + await waitForQuiet(); + return { + tree: buildNode(docAcc) + }; + } +} + +function takeScreenshot(win, left, top, width, height, mimeType) { + const MAX_SKIA_DIMENSIONS = 32767; + + const scale = win.devicePixelRatio; + const canvasWidth = width * scale; + const canvasHeight = height * scale; + + if (canvasWidth > MAX_SKIA_DIMENSIONS || canvasHeight > MAX_SKIA_DIMENSIONS) + throw new Error('Cannot take screenshot larger than ' + MAX_SKIA_DIMENSIONS); + + const canvas = win.document.createElementNS('http://www.w3.org/1999/xhtml', 'canvas'); + canvas.width = canvasWidth; + canvas.height = canvasHeight; + + let ctx = canvas.getContext('2d'); + ctx.scale(scale, scale); + ctx.drawWindow(win, left, top, width, height, 'rgb(255,255,255)', ctx.DRAWWINDOW_DRAW_CARET); + const dataURL = canvas.toDataURL(mimeType); + return dataURL.substring(dataURL.indexOf(',') + 1); +}; + +var EXPORTED_SYMBOLS = ['PageAgent']; +this.PageAgent = PageAgent; + diff --git a/juggler/content/RuntimeAgent.js b/juggler/content/RuntimeAgent.js new file mode 100644 index 0000000000000..b10cc8d29bbff --- /dev/null +++ b/juggler/content/RuntimeAgent.js @@ -0,0 +1,559 @@ +"use strict"; +// Note: this file should be loadabale with eval() into worker environment. +// Avoid Components.*, ChromeUtils and global const variables. + +if (!this.Debugger) { + // Worker has a Debugger defined already. + const {addDebuggerToGlobal} = ChromeUtils.import("resource://gre/modules/jsdebugger.jsm", {}); + addDebuggerToGlobal(Components.utils.getGlobalForObject(this)); +} + +let lastId = 0; +function generateId() { + return 'id-' + (++lastId); +} + +const consoleLevelToProtocolType = { + 'dir': 'dir', + 'log': 'log', + 'debug': 'debug', + 'info': 'info', + 'error': 'error', + 'warn': 'warning', + 'dirxml': 'dirxml', + 'table': 'table', + 'trace': 'trace', + 'clear': 'clear', + 'group': 'startGroup', + 'groupCollapsed': 'startGroupCollapsed', + 'groupEnd': 'endGroup', + 'assert': 'assert', + 'profile': 'profile', + 'profileEnd': 'profileEnd', + 'count': 'count', + 'countReset': 'countReset', + 'time': null, + 'timeLog': 'timeLog', + 'timeEnd': 'timeEnd', + 'timeStamp': 'timeStamp', +}; + +const disallowedMessageCategories = new Set([ + 'XPConnect JavaScript', + 'component javascript', + 'chrome javascript', + 'chrome registration', + 'XBL', + 'XBL Prototype Handler', + 'XBL Content Sink', + 'xbl javascript', +]); + +class RuntimeAgent { + constructor(channel, sessionId, isWorker = false) { + this._debugger = new Debugger(); + this._pendingPromises = new Map(); + this._executionContexts = new Map(); + this._windowToExecutionContext = new Map(); + this._session = channel.connect(sessionId + 'runtime'); + this._eventListeners = [ + channel.register(sessionId + 'runtime', { + evaluate: this._evaluate.bind(this), + callFunction: this._callFunction.bind(this), + getObjectProperties: this._getObjectProperties.bind(this), + disposeObject: this._disposeObject.bind(this), + }), + ]; + this._enabled = false; + this._filteredConsoleMessageHashes = new Set(); + this._onErrorFromWorker = null; + this._isWorker = isWorker; + } + + enable() { + if (this._enabled) + return; + this._enabled = true; + for (const executionContext of this._executionContexts.values()) + this._notifyExecutionContextCreated(executionContext); + + if (this._isWorker) { + this._registerConsoleEventHandler(); + } else { + const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); + this._registerConsoleServiceListener(Services); + this._registerConsoleObserver(Services); + } + } + + _registerConsoleServiceListener(Services) { + const Ci = Components.interfaces; + const consoleServiceListener = { + QueryInterface: ChromeUtils.generateQI([Ci.nsIConsoleListener]), + + observe: message => { + if (!(message instanceof Ci.nsIScriptError) || !message.outerWindowID || + !message.category || disallowedMessageCategories.has(message.category)) { + return; + } + const errorWindow = Services.wm.getOuterWindowWithId(message.outerWindowID); + if (message.category === 'Web Worker' && (message.flags & Ci.nsIScriptError.exceptionFlag)) { + if (this._onErrorFromWorker) + this._onErrorFromWorker(errorWindow, message.message, '' + message.stack); + return; + } + const executionContext = this._windowToExecutionContext.get(errorWindow); + if (!executionContext) + return; + const typeNames = { + [Ci.nsIConsoleMessage.debug]: 'debug', + [Ci.nsIConsoleMessage.info]: 'info', + [Ci.nsIConsoleMessage.warn]: 'warn', + [Ci.nsIConsoleMessage.error]: 'error', + }; + this._session.emit('runtimeConsole', { + args: [{ + value: message.message, + }], + type: typeNames[message.logLevel], + executionContextId: executionContext.id(), + location: { + lineNumber: message.lineNumber, + columnNumber: message.columnNumber, + url: message.sourceName, + }, + }); + }, + }; + Services.console.registerListener(consoleServiceListener); + this._eventListeners.push(() => Services.console.unregisterListener(consoleServiceListener)); + } + + _registerConsoleObserver(Services) { + const consoleObserver = ({wrappedJSObject}, topic, data) => { + const hash = this._consoleMessageHash(wrappedJSObject); + if (this._filteredConsoleMessageHashes.has(hash)) { + this._filteredConsoleMessageHashes.delete(hash); + return; + } + const executionContext = Array.from(this._executionContexts.values()).find(context => { + const domWindow = context._domWindow; + return domWindow && domWindow.windowUtils.currentInnerWindowID === wrappedJSObject.innerID; + }); + if (!executionContext) + return; + this._onConsoleMessage(executionContext, wrappedJSObject); + }; + Services.obs.addObserver(consoleObserver, "console-api-log-event"); + this._eventListeners.push(() => Services.obs.removeObserver(consoleObserver, "console-api-log-event")); + } + + _registerConsoleEventHandler() { + setConsoleEventHandler(message => { + this._session.emit('workerConsoleMessage', this._consoleMessageHash(message)); + const executionContext = Array.from(this._executionContexts.values())[0]; + this._onConsoleMessage(executionContext, message); + }); + this._eventListeners.push(() => setConsoleEventHandler(null)); + } + + filterConsoleMessage(messageHash) { + this._filteredConsoleMessageHashes.add(messageHash); + } + + setOnErrorFromWorker(onErrorFromWorker) { + this._onErrorFromWorker = onErrorFromWorker; + } + + _consoleMessageHash(message) { + return `${message.timeStamp}/${message.filename}/${message.lineNumber}/${message.columnNumber}/${message.sourceId}/${message.level}`; + } + + _onConsoleMessage(executionContext, message) { + const type = consoleLevelToProtocolType[message.level]; + if (!type) + return; + const args = message.arguments.map(arg => executionContext.rawValueToRemoteObject(arg)); + this._session.emit('runtimeConsole', { + args, + type, + executionContextId: executionContext.id(), + location: { + lineNumber: message.lineNumber - 1, + columnNumber: message.columnNumber - 1, + url: message.filename, + }, + }); + } + + _notifyExecutionContextCreated(executionContext) { + if (!this._enabled) + return; + this._session.emit('runtimeExecutionContextCreated', { + executionContextId: executionContext._id, + auxData: executionContext._auxData, + }); + } + + _notifyExecutionContextDestroyed(executionContext) { + if (!this._enabled) + return; + this._session.emit('runtimeExecutionContextDestroyed', { + executionContextId: executionContext._id, + }); + } + + dispose() { + this._session.dispose(); + for (const tearDown of this._eventListeners) + tearDown.call(null); + this._eventListeners = []; + } + + async _awaitPromise(executionContext, obj, exceptionDetails = {}) { + if (obj.promiseState === 'fulfilled') + return {success: true, obj: obj.promiseValue}; + if (obj.promiseState === 'rejected') { + const global = executionContext._global; + exceptionDetails.text = global.executeInGlobalWithBindings('e.message', {e: obj.promiseReason}).return; + exceptionDetails.stack = global.executeInGlobalWithBindings('e.stack', {e: obj.promiseReason}).return; + return {success: false, obj: null}; + } + let resolve, reject; + const promise = new Promise((a, b) => { + resolve = a; + reject = b; + }); + this._pendingPromises.set(obj.promiseID, {resolve, reject, executionContext, exceptionDetails}); + if (this._pendingPromises.size === 1) + this._debugger.onPromiseSettled = this._onPromiseSettled.bind(this); + return await promise; + } + + _onPromiseSettled(obj) { + const pendingPromise = this._pendingPromises.get(obj.promiseID); + if (!pendingPromise) + return; + this._pendingPromises.delete(obj.promiseID); + if (!this._pendingPromises.size) + this._debugger.onPromiseSettled = undefined; + + if (obj.promiseState === 'fulfilled') { + pendingPromise.resolve({success: true, obj: obj.promiseValue}); + return; + }; + const global = pendingPromise.executionContext._global; + pendingPromise.exceptionDetails.text = global.executeInGlobalWithBindings('e.message', {e: obj.promiseReason}).return; + pendingPromise.exceptionDetails.stack = global.executeInGlobalWithBindings('e.stack', {e: obj.promiseReason}).return; + pendingPromise.resolve({success: false, obj: null}); + } + + createExecutionContext(domWindow, contextGlobal, auxData) { + // Note: domWindow is null for workers. + const context = new ExecutionContext(this, domWindow, contextGlobal, this._debugger.addDebuggee(contextGlobal), auxData); + this._executionContexts.set(context._id, context); + if (domWindow) + this._windowToExecutionContext.set(domWindow, context); + this._notifyExecutionContextCreated(context); + return context; + } + + findExecutionContext(executionContextId) { + const executionContext = this._executionContexts.get(executionContextId); + if (!executionContext) + throw new Error('Failed to find execution context with id = ' + executionContextId); + return executionContext; + } + + destroyExecutionContext(destroyedContext) { + for (const [promiseID, {reject, executionContext}] of this._pendingPromises) { + if (executionContext === destroyedContext) { + reject(new Error('Execution context was destroyed!')); + this._pendingPromises.delete(promiseID); + } + } + if (!this._pendingPromises.size) + this._debugger.onPromiseSettled = undefined; + this._debugger.removeDebuggee(destroyedContext._contextGlobal); + this._executionContexts.delete(destroyedContext._id); + if (destroyedContext._domWindow) + this._windowToExecutionContext.delete(destroyedContext._domWindow); + this._notifyExecutionContextDestroyed(destroyedContext); + } + + async _evaluate({executionContextId, expression, returnByValue}) { + const executionContext = this._executionContexts.get(executionContextId); + if (!executionContext) + throw new Error('Failed to find execution context with id = ' + executionContextId); + const exceptionDetails = {}; + let result = await executionContext.evaluateScript(expression, exceptionDetails); + if (!result) + return {exceptionDetails}; + if (returnByValue) + result = executionContext.ensureSerializedToValue(result); + return {result}; + } + + async _callFunction({executionContextId, functionDeclaration, args, returnByValue}) { + const executionContext = this._executionContexts.get(executionContextId); + if (!executionContext) + throw new Error('Failed to find execution context with id = ' + executionContextId); + const exceptionDetails = {}; + let result = await executionContext.evaluateFunction(functionDeclaration, args, exceptionDetails); + if (!result) + return {exceptionDetails}; + if (returnByValue) + result = executionContext.ensureSerializedToValue(result); + return {result}; + } + + async _getObjectProperties({executionContextId, objectId}) { + const executionContext = this._executionContexts.get(executionContextId); + if (!executionContext) + throw new Error('Failed to find execution context with id = ' + executionContextId); + return {properties: executionContext.getObjectProperties(objectId)}; + } + + async _disposeObject({executionContextId, objectId}) { + const executionContext = this._executionContexts.get(executionContextId); + if (!executionContext) + throw new Error('Failed to find execution context with id = ' + executionContextId); + return executionContext.disposeObject(objectId); + } +} + +class ExecutionContext { + constructor(runtime, domWindow, contextGlobal, global, auxData) { + this._runtime = runtime; + this._domWindow = domWindow; + this._contextGlobal = contextGlobal; + this._global = global; + this._remoteObjects = new Map(); + this._id = generateId(); + this._auxData = auxData; + this._jsonStringifyObject = this._global.executeInGlobal(`((stringify, dateProto, object) => { + const oldToJson = dateProto.toJSON; + dateProto.toJSON = undefined; + let hasSymbol = false; + const result = stringify(object, (key, value) => { + if (typeof value === 'symbol') + hasSymbol = true; + return value; + }); + dateProto.toJSON = oldToJson; + return hasSymbol ? undefined : result; + }).bind(null, JSON.stringify.bind(JSON), Date.prototype)`).return; + } + + id() { + return this._id; + } + + auxData() { + return this._auxData; + } + + async evaluateScript(script, exceptionDetails = {}) { + const userInputHelper = this._domWindow ? this._domWindow.windowUtils.setHandlingUserInput(true) : null; + if (this._domWindow && this._domWindow.document) + this._domWindow.document.notifyUserGestureActivation(); + + let {success, obj} = this._getResult(this._global.executeInGlobal(script), exceptionDetails); + userInputHelper && userInputHelper.destruct(); + if (!success) + return null; + if (obj && obj.isPromise) { + const awaitResult = await this._runtime._awaitPromise(this, obj, exceptionDetails); + if (!awaitResult.success) + return null; + obj = awaitResult.obj; + } + return this._createRemoteObject(obj); + } + + async evaluateFunction(functionText, args, exceptionDetails = {}) { + const funEvaluation = this._getResult(this._global.executeInGlobal('(' + functionText + ')'), exceptionDetails); + if (!funEvaluation.success) + return null; + if (!funEvaluation.obj.callable) + throw new Error('functionText does not evaluate to a function!'); + args = args.map(arg => { + if (arg.objectId) { + if (!this._remoteObjects.has(arg.objectId)) + throw new Error('Cannot find object with id = ' + arg.objectId); + return this._remoteObjects.get(arg.objectId); + } + switch (arg.unserializableValue) { + case 'Infinity': return Infinity; + case '-Infinity': return -Infinity; + case '-0': return -0; + case 'NaN': return NaN; + default: return this._toDebugger(arg.value); + } + }); + const userInputHelper = this._domWindow ? this._domWindow.windowUtils.setHandlingUserInput(true) : null; + let {success, obj} = this._getResult(funEvaluation.obj.apply(null, args), exceptionDetails); + userInputHelper && userInputHelper.destruct(); + if (!success) + return null; + if (obj && obj.isPromise) { + const awaitResult = await this._runtime._awaitPromise(this, obj, exceptionDetails); + if (!awaitResult.success) + return null; + obj = awaitResult.obj; + } + return this._createRemoteObject(obj); + } + + unsafeObject(objectId) { + if (!this._remoteObjects.has(objectId)) + return; + return { object: this._remoteObjects.get(objectId).unsafeDereference() }; + } + + rawValueToRemoteObject(rawValue) { + const debuggerObj = this._global.makeDebuggeeValue(rawValue); + return this._createRemoteObject(debuggerObj); + } + + _instanceOf(debuggerObj, rawObj, className) { + if (this._domWindow) + return rawObj instanceof this._domWindow[className]; + return this._global.executeInGlobalWithBindings('o instanceof this[className]', {o: debuggerObj, className: this._global.makeDebuggeeValue(className)}).return; + } + + _createRemoteObject(debuggerObj) { + if (debuggerObj instanceof Debugger.Object) { + const objectId = generateId(); + this._remoteObjects.set(objectId, debuggerObj); + const rawObj = debuggerObj.unsafeDereference(); + const type = typeof rawObj; + let subtype = undefined; + if (debuggerObj.isProxy) + subtype = 'proxy'; + else if (Array.isArray(rawObj)) + subtype = 'array'; + else if (Object.is(rawObj, null)) + subtype = 'null'; + else if (this._instanceOf(debuggerObj, rawObj, 'Node')) + subtype = 'node'; + else if (this._instanceOf(debuggerObj, rawObj, 'RegExp')) + subtype = 'regexp'; + else if (this._instanceOf(debuggerObj, rawObj, 'Date')) + subtype = 'date'; + else if (this._instanceOf(debuggerObj, rawObj, 'Map')) + subtype = 'map'; + else if (this._instanceOf(debuggerObj, rawObj, 'Set')) + subtype = 'set'; + else if (this._instanceOf(debuggerObj, rawObj, 'WeakMap')) + subtype = 'weakmap'; + else if (this._instanceOf(debuggerObj, rawObj, 'WeakSet')) + subtype = 'weakset'; + else if (this._instanceOf(debuggerObj, rawObj, 'Error')) + subtype = 'error'; + else if (this._instanceOf(debuggerObj, rawObj, 'Promise')) + subtype = 'promise'; + else if ((this._instanceOf(debuggerObj, rawObj, 'Int8Array')) || (this._instanceOf(debuggerObj, rawObj, 'Uint8Array')) || + (this._instanceOf(debuggerObj, rawObj, 'Uint8ClampedArray')) || (this._instanceOf(debuggerObj, rawObj, 'Int16Array')) || + (this._instanceOf(debuggerObj, rawObj, 'Uint16Array')) || (this._instanceOf(debuggerObj, rawObj, 'Int32Array')) || + (this._instanceOf(debuggerObj, rawObj, 'Uint32Array')) || (this._instanceOf(debuggerObj, rawObj, 'Float32Array')) || + (this._instanceOf(debuggerObj, rawObj, 'Float64Array'))) { + subtype = 'typedarray'; + } + return {objectId, type, subtype}; + } + if (typeof debuggerObj === 'symbol') { + const objectId = generateId(); + this._remoteObjects.set(objectId, debuggerObj); + return {objectId, type: 'symbol'}; + } + + let unserializableValue = undefined; + if (Object.is(debuggerObj, NaN)) + unserializableValue = 'NaN'; + else if (Object.is(debuggerObj, -0)) + unserializableValue = '-0'; + else if (Object.is(debuggerObj, Infinity)) + unserializableValue = 'Infinity'; + else if (Object.is(debuggerObj, -Infinity)) + unserializableValue = '-Infinity'; + return unserializableValue ? {unserializableValue} : {value: debuggerObj}; + } + + ensureSerializedToValue(protocolObject) { + if (!protocolObject.objectId) + return protocolObject; + const obj = this._remoteObjects.get(protocolObject.objectId); + this._remoteObjects.delete(protocolObject.objectId); + return {value: this._serialize(obj)}; + } + + _toDebugger(obj) { + if (typeof obj !== 'object') + return obj; + if (obj === null) + return obj; + const properties = {}; + for (let [key, value] of Object.entries(obj)) { + properties[key] = { + writable: true, + enumerable: true, + value: this._toDebugger(value), + }; + } + const baseObject = Array.isArray(obj) ? '([])' : '({})'; + const debuggerObj = this._global.executeInGlobal(baseObject).return; + debuggerObj.defineProperties(properties); + return debuggerObj; + } + + _serialize(obj) { + const result = this._global.executeInGlobalWithBindings('stringify(e)', {e: obj, stringify: this._jsonStringifyObject}); + if (result.throw) + throw new Error('Object is not serializable'); + return result.return === undefined ? undefined : JSON.parse(result.return); + } + + disposeObject(objectId) { + this._remoteObjects.delete(objectId); + } + + getObjectProperties(objectId) { + if (!this._remoteObjects.has(objectId)) + throw new Error('Cannot find object with id = ' + arg.objectId); + const result = []; + for (let obj = this._remoteObjects.get(objectId); obj; obj = obj.proto) { + for (const propertyName of obj.getOwnPropertyNames()) { + const descriptor = obj.getOwnPropertyDescriptor(propertyName); + if (!descriptor.enumerable) + continue; + result.push({ + name: propertyName, + value: this._createRemoteObject(descriptor.value), + }); + } + } + return result; + } + + _getResult(completionValue, exceptionDetails = {}) { + if (!completionValue) { + exceptionDetails.text = 'Evaluation terminated!'; + exceptionDetails.stack = ''; + return {success: false, obj: null}; + } + if (completionValue.throw) { + if (this._global.executeInGlobalWithBindings('e instanceof Error', {e: completionValue.throw}).return) { + exceptionDetails.text = this._global.executeInGlobalWithBindings('e.message', {e: completionValue.throw}).return; + exceptionDetails.stack = this._global.executeInGlobalWithBindings('e.stack', {e: completionValue.throw}).return; + } else { + exceptionDetails.value = this._serialize(completionValue.throw); + } + return {success: false, obj: null}; + } + return {success: true, obj: completionValue.return}; + } +} + +var EXPORTED_SYMBOLS = ['RuntimeAgent']; +this.RuntimeAgent = RuntimeAgent; diff --git a/juggler/content/ScrollbarManager.js b/juggler/content/ScrollbarManager.js new file mode 100644 index 0000000000000..caee4df323d0a --- /dev/null +++ b/juggler/content/ScrollbarManager.js @@ -0,0 +1,85 @@ +const Ci = Components.interfaces; +const Cr = Components.results; +const Cu = Components.utils; +const Cc = Components.classes; + +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); +const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +const HIDDEN_SCROLLBARS = Services.io.newURI('chrome://juggler/content/content/hidden-scrollbars.css'); +const FLOATING_SCROLLBARS = Services.io.newURI('chrome://juggler/content/content/floating-scrollbars.css'); + +const isHeadless = Cc["@mozilla.org/gfx/info;1"].getService(Ci.nsIGfxInfo).isHeadless; +const helper = new Helper(); + +class ScrollbarManager { + constructor(docShell) { + this._docShell = docShell; + this._customScrollbars = null; + this._contentViewerScrollBars = new Map(); + + if (isHeadless) + this._setCustomScrollbars(HIDDEN_SCROLLBARS); + + const webProgress = this._docShell.QueryInterface(Ci.nsIInterfaceRequestor) + .getInterface(Ci.nsIWebProgress); + + this.QueryInterface = ChromeUtils.generateQI(['nsIWebProgressListener', 'nsISupportsWeakReference']); + this._eventListeners = [ + helper.addProgressListener(webProgress, this, Ci.nsIWebProgress.NOTIFY_ALL), + ]; + } + + onLocationChange(webProgress, request, URI, flags) { + if (flags & Ci.nsIWebProgressListener.LOCATION_CHANGE_SAME_DOCUMENT) + return; + this._updateAllDocShells(); + } + + setFloatingScrollbars(enabled) { + if (this._customScrollbars === HIDDEN_SCROLLBARS) + return; + this._setCustomScrollbars(enabled ? FLOATING_SCROLLBARS : null); + } + + _setCustomScrollbars(customScrollbars) { + if (this._customScrollbars === customScrollbars) + return; + this._customScrollbars = customScrollbars; + this._updateAllDocShells(); + } + + _updateAllDocShells() { + const allDocShells = [this._docShell]; + for (let i = 0; i < this._docShell.childCount; i++) + allDocShells.push(this._docShell.getChildAt(i).QueryInterface(Ci.nsIDocShell)); + // At this point, a content viewer might not be loaded for certain docShells. + // Scrollbars will be updated in onLocationChange. + const contentViewers = allDocShells.map(docShell => docShell.contentViewer).filter(contentViewer => !!contentViewer); + + // Update scrollbar stylesheets. + for (const contentViewer of contentViewers) { + const oldScrollbars = this._contentViewerScrollBars.get(contentViewer); + if (oldScrollbars === this._customScrollbars) + continue; + const winUtils = contentViewer.DOMDocument.defaultView.windowUtils; + if (oldScrollbars) + winUtils.removeSheet(oldScrollbars, winUtils.AGENT_SHEET); + if (this._customScrollbars) + winUtils.loadSheet(this._customScrollbars, winUtils.AGENT_SHEET); + } + // Update state for all *existing* docShells. + this._contentViewerScrollBars.clear(); + for (const contentViewer of contentViewers) + this._contentViewerScrollBars.set(contentViewer, this._customScrollbars); + } + + dispose() { + this._setCustomScrollbars(null); + helper.removeListeners(this._eventListeners); + } +} + +var EXPORTED_SYMBOLS = ['ScrollbarManager']; +this.ScrollbarManager = ScrollbarManager; + diff --git a/juggler/content/WorkerMain.js b/juggler/content/WorkerMain.js new file mode 100644 index 0000000000000..b1a33558c6028 --- /dev/null +++ b/juggler/content/WorkerMain.js @@ -0,0 +1,29 @@ +"use strict"; +loadSubScript('chrome://juggler/content/content/RuntimeAgent.js'); +loadSubScript('chrome://juggler/content/SimpleChannel.js'); + +const runtimeAgents = new Map(); + +const channel = new SimpleChannel('worker::worker'); +const eventListener = event => channel._onMessage(JSON.parse(event.data)); +this.addEventListener('message', eventListener); +channel.transport = { + sendMessage: msg => postMessage(JSON.stringify(msg)), + dispose: () => this.removeEventListener('message', eventListener), +}; + +channel.register('', { + attach: ({sessionId}) => { + const runtimeAgent = new RuntimeAgent(channel, sessionId, true /* isWorker */); + runtimeAgents.set(sessionId, runtimeAgent); + runtimeAgent.createExecutionContext(null /* domWindow */, global, {}); + runtimeAgent.enable(); + }, + + detach: ({sessionId}) => { + const runtimeAgent = runtimeAgents.get(sessionId); + runtimeAgents.delete(sessionId); + runtimeAgent.dispose(); + }, +}); + diff --git a/juggler/content/floating-scrollbars.css b/juggler/content/floating-scrollbars.css new file mode 100644 index 0000000000000..7709bdd34c650 --- /dev/null +++ b/juggler/content/floating-scrollbars.css @@ -0,0 +1,47 @@ +@namespace url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"); +@namespace html url("http://www.w3.org/1999/xhtml"); + +/* Restrict all styles to `*|*:not(html|select) > scrollbar` so that scrollbars + inside a . */ +*|*:not(html|select) > scrollbar { + -moz-appearance: none !important; + position: relative; + background-color: transparent; + background-image: none; + z-index: 2147483647; + padding: 2px; + border: none; +} + +/* Scrollbar code will reset the margin to the correct side depending on + where layout actually puts the scrollbar */ +*|*:not(html|select) > scrollbar[orient="vertical"] { + margin-left: -10px; + min-width: 10px; + max-width: 10px; +} + +*|*:not(html|select) > scrollbar[orient="horizontal"] { + margin-top: -10px; + min-height: 10px; + max-height: 10px; +} + +*|*:not(html|select) > scrollbar slider { + -moz-appearance: none !important; +} + +*|*:not(html|select) > scrollbar thumb { + -moz-appearance: none !important; + background-color: rgba(0,0,0,0.2); + border-width: 0px !important; + border-radius: 3px !important; +} + +*|*:not(html|select) > scrollbar scrollbarbutton, +*|*:not(html|select) > scrollbar gripper { + display: none; +} diff --git a/juggler/content/hidden-scrollbars.css b/juggler/content/hidden-scrollbars.css new file mode 100644 index 0000000000000..3a386425d3796 --- /dev/null +++ b/juggler/content/hidden-scrollbars.css @@ -0,0 +1,13 @@ +@namespace url("http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"); +@namespace html url("http://www.w3.org/1999/xhtml"); + +/* Restrict all styles to `*|*:not(html|select) > scrollbar` so that scrollbars + inside a . */ +*|*:not(html|select) > scrollbar { + -moz-appearance: none !important; + display: none; +} + diff --git a/juggler/content/main.js b/juggler/content/main.js new file mode 100644 index 0000000000000..9bb5c2bff8eb3 --- /dev/null +++ b/juggler/content/main.js @@ -0,0 +1,178 @@ +const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); +const {FrameTree} = ChromeUtils.import('chrome://juggler/content/content/FrameTree.js'); +const {NetworkMonitor} = ChromeUtils.import('chrome://juggler/content/content/NetworkMonitor.js'); +const {ScrollbarManager} = ChromeUtils.import('chrome://juggler/content/content/ScrollbarManager.js'); +const {SimpleChannel} = ChromeUtils.import('chrome://juggler/content/SimpleChannel.js'); +const {RuntimeAgent} = ChromeUtils.import('chrome://juggler/content/content/RuntimeAgent.js'); +const {PageAgent} = ChromeUtils.import('chrome://juggler/content/content/PageAgent.js'); + +const ALL_PERMISSIONS = [ + 'geo', + 'microphone', + 'camera', + 'desktop-notification', +]; + +const scrollbarManager = new ScrollbarManager(docShell); +let frameTree; +let networkMonitor; +const helper = new Helper(); +const messageManager = this; + +const sessions = new Map(); + +function createContentSession(channel, sessionId) { + const runtimeAgent = new RuntimeAgent(channel, sessionId); + const pageAgent = new PageAgent(messageManager, channel, sessionId, runtimeAgent, frameTree, networkMonitor); + sessions.set(sessionId, [runtimeAgent, pageAgent]); + + runtimeAgent.enable(); + pageAgent.enable(); +} + +function disposeContentSession(sessionId) { + const handlers = sessions.get(sessionId); + sessions.delete(sessionId); + for (const handler of handlers) + handler.dispose(); +} + +function setGeolocationOverrideInDocShell(geolocation) { + if (geolocation) { + docShell.setGeolocationOverride({ + coords: { + latitude: geolocation.latitude, + longitude: geolocation.longitude, + accuracy: geolocation.accuracy, + altitude: NaN, + altitudeAccuracy: NaN, + heading: NaN, + speed: NaN, + }, + address: null, + timestamp: Date.now() + }); + } else { + docShell.setGeolocationOverride(null); + } +} + +function setOnlineOverrideInDocShell(override) { + if (!override) { + docShell.onlineOverride = Ci.nsIDocShell.ONLINE_OVERRIDE_NONE; + return; + } + docShell.onlineOverride = override === 'online' ? + Ci.nsIDocShell.ONLINE_OVERRIDE_ONLINE : Ci.nsIDocShell.ONLINE_OVERRIDE_OFFLINE; +} + +function initialize() { + let response = sendSyncMessage('juggler:content-ready', {})[0]; + if (!response) + response = { sessionIds: [], browserContextOptions: {}, waitForInitialNavigation: false }; + + const { sessionIds, browserContextOptions, waitForInitialNavigation } = response; + const { userAgent, bypassCSP, javaScriptDisabled, viewport, scriptsToEvaluateOnNewDocument, bindings, locale, geolocation, onlineOverride } = browserContextOptions; + + if (userAgent !== undefined) + docShell.customUserAgent = userAgent; + if (bypassCSP !== undefined) + docShell.bypassCSPEnabled = bypassCSP; + if (javaScriptDisabled !== undefined) + docShell.allowJavascript = !javaScriptDisabled; + if (locale !== undefined) + docShell.languageOverride = locale; + if (geolocation !== undefined) + setGeolocationOverrideInDocShell(geolocation); + if (onlineOverride !== undefined) + setOnlineOverrideInDocShell(onlineOverride); + if (viewport !== undefined) { + docShell.contentViewer.overrideDPPX = viewport.deviceScaleFactor || this._initialDPPX; + docShell.deviceSizeIsPageSize = viewport.isMobile; + docShell.touchEventsOverride = viewport.hasTouch ? Ci.nsIDocShell.TOUCHEVENTS_OVERRIDE_ENABLED : Ci.nsIDocShell.TOUCHEVENTS_OVERRIDE_NONE; + scrollbarManager.setFloatingScrollbars(viewport.isMobile); + } + + frameTree = new FrameTree(docShell, waitForInitialNavigation); + for (const script of scriptsToEvaluateOnNewDocument || []) + frameTree.addScriptToEvaluateOnNewDocument(script); + for (const { name, script } of bindings || []) + frameTree.addBinding(name, script); + networkMonitor = new NetworkMonitor(docShell, frameTree); + + const channel = SimpleChannel.createForMessageManager('content::page', messageManager); + + for (const sessionId of sessionIds) + createContentSession(channel, sessionId); + + channel.register('', { + attach({sessionId}) { + createContentSession(channel, sessionId); + }, + + detach({sessionId}) { + disposeContentSession(sessionId); + }, + + addScriptToEvaluateOnNewDocument(script) { + frameTree.addScriptToEvaluateOnNewDocument(script); + }, + + addBinding(name, script) { + frameTree.addBinding(name, script); + }, + + setGeolocationOverride(geolocation) { + setGeolocationOverrideInDocShell(geolocation); + }, + + setOnlineOverride(override) { + setOnlineOverrideInDocShell(override); + }, + + async ensurePermissions(permissions) { + const checkPermissions = () => { + for (const permission of ALL_PERMISSIONS) { + const actual = Services.perms.testExactPermissionFromPrincipal(this._docShell.domWindow.document.nodePrincipal, permission); + const expected = permissions.include(permission) ? Ci.nsIPermissionManager.ALLOW_ACTION : Ci.nsIPermissionManager.DENY_ACTION; + if (actual !== expected) + return false; + } + return true; + } + + if (checkPermissions()) + return; + + // Track all 'perm-changed' events and wait until permissions are expected. + await new Promise(resolve => { + const listeners = [helper.addObserver(() => { + if (!checkPermission()) + return; + helper.removeListeners(listeners); + resolve(); + }, 'perm-changed')]; + }); + }, + + dispose() { + }, + }); + + const gListeners = [ + helper.addEventListener(messageManager, 'unload', msg => { + helper.removeListeners(gListeners); + channel.dispose(); + + for (const sessionId of sessions.keys()) + disposeContentSession(sessionId); + + scrollbarManager.dispose(); + networkMonitor.dispose(); + frameTree.dispose(); + }), + ]; +} + +initialize(); diff --git a/juggler/jar.mn b/juggler/jar.mn new file mode 100644 index 0000000000000..e8a057109be8b --- /dev/null +++ b/juggler/jar.mn @@ -0,0 +1,29 @@ +# 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/. + +juggler.jar: +% content juggler %content/ + content/Helper.js (Helper.js) + content/NetworkObserver.js (NetworkObserver.js) + content/BrowserContextManager.js (BrowserContextManager.js) + content/TargetRegistry.js (TargetRegistry.js) + content/SimpleChannel.js (SimpleChannel.js) + content/protocol/PrimitiveTypes.js (protocol/PrimitiveTypes.js) + content/protocol/Protocol.js (protocol/Protocol.js) + content/protocol/Dispatcher.js (protocol/Dispatcher.js) + content/protocol/PageHandler.js (protocol/PageHandler.js) + content/protocol/RuntimeHandler.js (protocol/RuntimeHandler.js) + content/protocol/NetworkHandler.js (protocol/NetworkHandler.js) + content/protocol/BrowserHandler.js (protocol/BrowserHandler.js) + content/protocol/AccessibilityHandler.js (protocol/AccessibilityHandler.js) + content/content/main.js (content/main.js) + content/content/FrameTree.js (content/FrameTree.js) + content/content/NetworkMonitor.js (content/NetworkMonitor.js) + content/content/PageAgent.js (content/PageAgent.js) + content/content/RuntimeAgent.js (content/RuntimeAgent.js) + content/content/WorkerMain.js (content/WorkerMain.js) + content/content/ScrollbarManager.js (content/ScrollbarManager.js) + content/content/floating-scrollbars.css (content/floating-scrollbars.css) + content/content/hidden-scrollbars.css (content/hidden-scrollbars.css) + diff --git a/juggler/moz.build b/juggler/moz.build new file mode 100644 index 0000000000000..1a0a3130bf950 --- /dev/null +++ b/juggler/moz.build @@ -0,0 +1,15 @@ +# 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/. + +DIRS += ["components"] + +JAR_MANIFESTS += ["jar.mn"] +#JS_PREFERENCE_FILES += ["prefs/marionette.js"] + +#MARIONETTE_UNIT_MANIFESTS += ["harness/marionette_harness/tests/unit/unit-tests.ini"] +#XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.ini"] + +with Files("**"): + BUG_COMPONENT = ("Testing", "Juggler") + diff --git a/juggler/protocol/AccessibilityHandler.js b/juggler/protocol/AccessibilityHandler.js new file mode 100644 index 0000000000000..2f2b7ca247f6b --- /dev/null +++ b/juggler/protocol/AccessibilityHandler.js @@ -0,0 +1,17 @@ +class AccessibilityHandler { + constructor(chromeSession, sessionId, contentChannel) { + this._chromeSession = chromeSession; + this._contentPage = contentChannel.connect(sessionId + 'page'); + } + + async getFullAXTree(params) { + return await this._contentPage.send('getFullAXTree', params); + } + + dispose() { + this._contentPage.dispose(); + } +} + +var EXPORTED_SYMBOLS = ['AccessibilityHandler']; +this.AccessibilityHandler = AccessibilityHandler; diff --git a/juggler/protocol/BrowserHandler.js b/juggler/protocol/BrowserHandler.js new file mode 100644 index 0000000000000..e225fc81c62bb --- /dev/null +++ b/juggler/protocol/BrowserHandler.js @@ -0,0 +1,178 @@ +"use strict"; + +const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); +const { allowAllCerts } = ChromeUtils.import( + "chrome://marionette/content/cert.js" +); +const {BrowserContextManager} = ChromeUtils.import("chrome://juggler/content/BrowserContextManager.js"); +const {TargetRegistry} = ChromeUtils.import("chrome://juggler/content/TargetRegistry.js"); +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); + +const helper = new Helper(); + +class BrowserHandler { + /** + * @param {ChromeSession} session + */ + constructor(session) { + this._session = session; + this._contextManager = BrowserContextManager.instance(); + this._targetRegistry = TargetRegistry.instance(); + this._enabled = false; + this._attachToDefaultContext = false; + this._eventListeners = []; + this._createdBrowserContextIds = new Set(); + } + + async enable({attachToDefaultContext}) { + if (this._enabled) + return; + this._enabled = true; + this._attachToDefaultContext = attachToDefaultContext; + + for (const target of this._targetRegistry.targets()) { + if (!this._shouldAttachToTarget(target)) + continue; + const sessionId = this._session.dispatcher().createSession(target.id(), true /* shouldConnect */); + this._session.emitEvent('Browser.attachedToTarget', { + sessionId, + targetInfo: target.info() + }); + } + + this._eventListeners = [ + helper.on(this._targetRegistry, TargetRegistry.Events.PageTargetReady, this._onPageTargetReady.bind(this)), + ]; + } + + async createBrowserContext(options) { + if (!this._enabled) + throw new Error('Browser domain is not enabled'); + const browserContext = this._contextManager.createBrowserContext(options); + this._createdBrowserContextIds.add(browserContext.browserContextId); + return {browserContextId: browserContext.browserContextId}; + } + + async removeBrowserContext({browserContextId}) { + if (!this._enabled) + throw new Error('Browser domain is not enabled'); + this._createdBrowserContextIds.delete(browserContextId); + this._contextManager.browserContextForId(browserContextId).destroy(); + } + + dispose() { + helper.removeListeners(this._eventListeners); + for (const browserContextId of this._createdBrowserContextIds) { + const browserContext = this._contextManager.browserContextForId(browserContextId); + if (browserContext.options.removeOnDetach) + browserContext.destroy(); + } + this._createdBrowserContextIds.clear(); + } + + _shouldAttachToTarget(target) { + if (!target._browserContext) + return false; + if (this._createdBrowserContextIds.has(target._browserContext.browserContextId)) + return true; + return this._attachToDefaultContext && target._browserContext === this._contextManager.defaultContext(); + } + + _onPageTargetReady({sessionIds, target}) { + if (!this._shouldAttachToTarget(target)) + return; + const sessionId = this._session.dispatcher().createSession(target.id(), false /* shouldConnect */); + sessionIds.push(sessionId); + this._session.emitEvent('Browser.attachedToTarget', { + sessionId, + targetInfo: target.info() + }); + } + + async newPage({browserContextId}) { + const targetId = await this._targetRegistry.newPage({browserContextId}); + return {targetId}; + } + + async close() { + let browserWindow = Services.wm.getMostRecentWindow( + "navigator:browser" + ); + if (browserWindow && browserWindow.gBrowserInit) { + await browserWindow.gBrowserInit.idleTasksFinishedPromise; + } + Services.startup.quit(Ci.nsIAppStartup.eForceQuit); + } + + async setIgnoreHTTPSErrors({enabled}) { + if (!enabled) { + allowAllCerts.disable() + Services.prefs.setBoolPref('security.mixed_content.block_active_content', true); + } else { + allowAllCerts.enable() + Services.prefs.setBoolPref('security.mixed_content.block_active_content', false); + } + } + + async grantPermissions({browserContextId, origin, permissions}) { + await this._contextManager.browserContextForId(browserContextId).grantPermissions(origin, permissions); + } + + resetPermissions({browserContextId}) { + this._contextManager.browserContextForId(browserContextId).resetPermissions(); + } + + setExtraHTTPHeaders({browserContextId, headers}) { + this._contextManager.browserContextForId(browserContextId).options.extraHTTPHeaders = headers; + } + + setHTTPCredentials({browserContextId, credentials}) { + this._contextManager.browserContextForId(browserContextId).options.httpCredentials = credentials; + } + + setRequestInterception({browserContextId, enabled}) { + this._contextManager.browserContextForId(browserContextId).options.requestInterceptionEnabled = enabled; + } + + async setGeolocationOverride({browserContextId, geolocation}) { + await this._contextManager.browserContextForId(browserContextId).setGeolocationOverride(geolocation); + } + + async setOnlineOverride({browserContextId, override}) { + await this._contextManager.browserContextForId(browserContextId).setOnlineOverride(override); + } + + async addScriptToEvaluateOnNewDocument({browserContextId, script}) { + await this._contextManager.browserContextForId(browserContextId).addScriptToEvaluateOnNewDocument(script); + } + + async addBinding({browserContextId, name, script}) { + await this._contextManager.browserContextForId(browserContextId).addBinding(name, script); + } + + setCookies({browserContextId, cookies}) { + this._contextManager.browserContextForId(browserContextId).setCookies(cookies); + } + + clearCookies({browserContextId}) { + this._contextManager.browserContextForId(browserContextId).clearCookies(); + } + + getCookies({browserContextId}) { + const cookies = this._contextManager.browserContextForId(browserContextId).getCookies(); + return {cookies}; + } + + async getInfo() { + const version = Components.classes["@mozilla.org/xre/app-info;1"] + .getService(Components.interfaces.nsIXULAppInfo) + .version; + const userAgent = Components.classes["@mozilla.org/network/protocol;1?name=http"] + .getService(Components.interfaces.nsIHttpProtocolHandler) + .userAgent; + return {version: 'Firefox/' + version, userAgent}; + } +} + +var EXPORTED_SYMBOLS = ['BrowserHandler']; +this.BrowserHandler = BrowserHandler; diff --git a/juggler/protocol/Dispatcher.js b/juggler/protocol/Dispatcher.js new file mode 100644 index 0000000000000..b75f20324cb58 --- /dev/null +++ b/juggler/protocol/Dispatcher.js @@ -0,0 +1,194 @@ +const {TargetRegistry} = ChromeUtils.import("chrome://juggler/content/TargetRegistry.js"); +const {protocol, checkScheme} = ChromeUtils.import("chrome://juggler/content/protocol/Protocol.js"); +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); +const helper = new Helper(); +const {SimpleChannel} = ChromeUtils.import('chrome://juggler/content/SimpleChannel.js'); + +const PROTOCOL_HANDLERS = { + Page: ChromeUtils.import("chrome://juggler/content/protocol/PageHandler.js").PageHandler, + Network: ChromeUtils.import("chrome://juggler/content/protocol/NetworkHandler.js").NetworkHandler, + Browser: ChromeUtils.import("chrome://juggler/content/protocol/BrowserHandler.js").BrowserHandler, + Runtime: ChromeUtils.import("chrome://juggler/content/protocol/RuntimeHandler.js").RuntimeHandler, + Accessibility: ChromeUtils.import("chrome://juggler/content/protocol/AccessibilityHandler.js").AccessibilityHandler, +}; + +class Dispatcher { + /** + * @param {Connection} connection + */ + constructor(connection) { + this._connection = connection; + this._connection.onmessage = this._dispatch.bind(this); + this._connection.onclose = this._dispose.bind(this); + + this._targetSessions = new Map(); + this._sessions = new Map(); + this._rootSession = new ChromeSession(this, undefined, null /* contentChannel */, TargetRegistry.instance().browserTargetInfo()); + + this._eventListeners = [ + helper.on(TargetRegistry.instance(), TargetRegistry.Events.TargetDestroyed, this._onTargetDestroyed.bind(this)), + ]; + } + + createSession(targetId, shouldConnect) { + const targetInfo = TargetRegistry.instance().targetInfo(targetId); + if (!targetInfo) + throw new Error(`Target "${targetId}" is not found`); + let targetSessions = this._targetSessions.get(targetId); + if (!targetSessions) { + targetSessions = new Map(); + this._targetSessions.set(targetId, targetSessions); + } + + const sessionId = helper.generateId(); + const contentChannel = targetInfo.type === 'page' ? TargetRegistry.instance().contentChannelForTarget(targetInfo.targetId) : null; + if (shouldConnect && contentChannel) + contentChannel.connect('').send('attach', {sessionId}); + const chromeSession = new ChromeSession(this, sessionId, contentChannel, targetInfo); + targetSessions.set(sessionId, chromeSession); + this._sessions.set(sessionId, chromeSession); + return sessionId; + } + + _dispose() { + helper.removeListeners(this._eventListeners); + this._connection.onmessage = null; + this._connection.onclose = null; + this._rootSession.dispose(); + this._rootSession = null; + for (const session of this._sessions.values()) + session.dispose(); + this._sessions.clear(); + this._targetSessions.clear(); + } + + _onTargetDestroyed(target) { + const targetId = target.id(); + const sessions = this._targetSessions.get(targetId); + if (!sessions) + return; + this._targetSessions.delete(targetId); + for (const [sessionId, session] of sessions) { + session.dispose(); + this._sessions.delete(sessionId); + } + } + + async _dispatch(event) { + const data = JSON.parse(event.data); + const id = data.id; + const sessionId = data.sessionId; + delete data.sessionId; + try { + const session = sessionId ? this._sessions.get(sessionId) : this._rootSession; + if (!session) + throw new Error(`ERROR: cannot find session with id "${sessionId}"`); + const method = data.method; + const params = data.params || {}; + if (!id) + throw new Error(`ERROR: every message must have an 'id' parameter`); + if (!method) + throw new Error(`ERROR: every message must have a 'method' parameter`); + + const [domain, methodName] = method.split('.'); + const descriptor = protocol.domains[domain] ? protocol.domains[domain].methods[methodName] : null; + if (!descriptor) + throw new Error(`ERROR: method '${method}' is not supported`); + let details = {}; + if (!checkScheme(descriptor.params || {}, params, details)) + throw new Error(`ERROR: failed to call method '${method}' with parameters ${JSON.stringify(params, null, 2)}\n${details.error}`); + + const result = await session.dispatch(method, params); + + details = {}; + if ((descriptor.returns || result) && !checkScheme(descriptor.returns, result, details)) + throw new Error(`ERROR: failed to dispatch method '${method}' result ${JSON.stringify(result, null, 2)}\n${details.error}`); + + this._connection.send(JSON.stringify({id, sessionId, result})); + } catch (e) { + this._connection.send(JSON.stringify({id, sessionId, error: { + message: e.message, + data: e.stack + }})); + } + } + + _emitEvent(sessionId, eventName, params) { + const [domain, eName] = eventName.split('.'); + const scheme = protocol.domains[domain] ? protocol.domains[domain].events[eName] : null; + if (!scheme) + throw new Error(`ERROR: event '${eventName}' is not supported`); + const details = {}; + if (!checkScheme(scheme, params || {}, details)) + throw new Error(`ERROR: failed to emit event '${eventName}' ${JSON.stringify(params, null, 2)}\n${details.error}`); + this._connection.send(JSON.stringify({method: eventName, params, sessionId})); + } +} + +class ChromeSession { + /** + * @param {Connection} connection + */ + constructor(dispatcher, sessionId, contentChannel, targetInfo) { + this._dispatcher = dispatcher; + this._sessionId = sessionId; + this._contentChannel = contentChannel; + this._targetInfo = targetInfo; + + this._handlers = {}; + for (const [domainName, handlerFactory] of Object.entries(PROTOCOL_HANDLERS)) { + if (protocol.domains[domainName].targets.includes(targetInfo.type)) + this._handlers[domainName] = new handlerFactory(this, sessionId, contentChannel); + } + const pageHandler = this._handlers['Page']; + if (pageHandler) + pageHandler.enable(); + const networkHandler = this._handlers['Network']; + if (networkHandler) + networkHandler.enable(); + } + + dispatcher() { + return this._dispatcher; + } + + targetId() { + return this._targetInfo.targetId; + } + + dispose() { + if (this._contentChannel) + this._contentChannel.connect('').emit('detach', {sessionId: this._sessionId}); + this._contentChannel = null; + for (const [domainName, handler] of Object.entries(this._handlers)) { + if (!handler.dispose) + throw new Error(`Handler for "${domainName}" domain does not define |dispose| method!`); + handler.dispose(); + delete this._handlers[domainName]; + } + // Root session don't have sessionId and don't emit detachedFromTarget. + if (this._sessionId) { + this._dispatcher._emitEvent(this._dispatcher._rootSession._sessionId, 'Browser.detachedFromTarget', { + sessionId: this._sessionId, + targetId: this.targetId(), + }); + } + } + + emitEvent(eventName, params) { + this._dispatcher._emitEvent(this._sessionId, eventName, params); + } + + async dispatch(method, params) { + const [domainName, methodName] = method.split('.'); + if (!this._handlers[domainName]) + throw new Error(`Domain "${domainName}" does not exist`); + if (!this._handlers[domainName][methodName]) + throw new Error(`Handler for domain "${domainName}" does not implement method "${methodName}"`); + return await this._handlers[domainName][methodName](params); + } +} + +this.EXPORTED_SYMBOLS = ['Dispatcher']; +this.Dispatcher = Dispatcher; + diff --git a/juggler/protocol/NetworkHandler.js b/juggler/protocol/NetworkHandler.js new file mode 100644 index 0000000000000..e1f1e21a20768 --- /dev/null +++ b/juggler/protocol/NetworkHandler.js @@ -0,0 +1,160 @@ +"use strict"; + +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); +const {NetworkObserver} = ChromeUtils.import('chrome://juggler/content/NetworkObserver.js'); +const {TargetRegistry} = ChromeUtils.import("chrome://juggler/content/TargetRegistry.js"); + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; +const XUL_NS = 'http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul'; +const helper = new Helper(); + +class NetworkHandler { + constructor(chromeSession, sessionId, contentChannel) { + this._chromeSession = chromeSession; + this._contentPage = contentChannel.connect(sessionId + 'page'); + this._networkObserver = NetworkObserver.instance(); + this._httpActivity = new Map(); + this._enabled = false; + this._browser = TargetRegistry.instance().tabForTarget(this._chromeSession.targetId()).linkedBrowser; + this._requestInterception = false; + this._eventListeners = []; + this._pendingRequstWillBeSentEvents = new Set(); + this._requestIdToFrameId = new Map(); + } + + async enable() { + if (this._enabled) + return; + this._enabled = true; + this._eventListeners = [ + helper.on(this._networkObserver, 'request', this._onRequest.bind(this)), + helper.on(this._networkObserver, 'response', this._onResponse.bind(this)), + helper.on(this._networkObserver, 'requestfinished', this._onRequestFinished.bind(this)), + helper.on(this._networkObserver, 'requestfailed', this._onRequestFailed.bind(this)), + this._networkObserver.startTrackingBrowserNetwork(this._browser), + ]; + } + + async getResponseBody({requestId}) { + return this._networkObserver.getResponseBody(this._browser, requestId); + } + + async setExtraHTTPHeaders({headers}) { + this._networkObserver.setExtraHTTPHeaders(this._browser, headers); + } + + async setRequestInterception({enabled}) { + if (enabled) + this._networkObserver.enableRequestInterception(this._browser); + else + this._networkObserver.disableRequestInterception(this._browser); + // Right after we enable/disable request interception we need to await all pending + // requestWillBeSent events before successfully returning from the method. + await Promise.all(Array.from(this._pendingRequstWillBeSentEvents)); + } + + async resumeInterceptedRequest({requestId, method, headers, postData}) { + this._networkObserver.resumeInterceptedRequest(this._browser, requestId, method, headers, postData); + } + + async abortInterceptedRequest({requestId, errorCode}) { + this._networkObserver.abortInterceptedRequest(this._browser, requestId, errorCode); + } + + async fulfillInterceptedRequest({requestId, status, statusText, headers, base64body}) { + this._networkObserver.fulfillInterceptedRequest(this._browser, requestId, status, statusText, headers, base64body); + } + + dispose() { + this._contentPage.dispose(); + helper.removeListeners(this._eventListeners); + } + + _ensureHTTPActivity(requestId) { + let activity = this._httpActivity.get(requestId); + if (!activity) { + activity = { + _id: requestId, + _lastSentEvent: null, + request: null, + response: null, + complete: null, + failed: null, + }; + this._httpActivity.set(requestId, activity); + } + return activity; + } + + _reportHTTPAcitivityEvents(activity) { + // State machine - sending network events. + if (!activity._lastSentEvent && activity.request) { + this._chromeSession.emitEvent('Network.requestWillBeSent', activity.request); + activity._lastSentEvent = 'requestWillBeSent'; + } + if (activity._lastSentEvent === 'requestWillBeSent' && activity.response) { + this._chromeSession.emitEvent('Network.responseReceived', activity.response); + activity._lastSentEvent = 'responseReceived'; + } + if (activity._lastSentEvent === 'responseReceived' && activity.complete) { + this._chromeSession.emitEvent('Network.requestFinished', activity.complete); + activity._lastSentEvent = 'requestFinished'; + } + if (activity._lastSentEvent && activity.failed) { + this._chromeSession.emitEvent('Network.requestFailed', activity.failed); + activity._lastSentEvent = 'requestFailed'; + } + + // Clean up if request lifecycle is over. + if (activity._lastSentEvent === 'requestFinished' || activity._lastSentEvent === 'requestFailed') + this._httpActivity.delete(activity._id); + } + + async _onRequest(httpChannel, eventDetails) { + let pendingRequestCallback; + let pendingRequestPromise = new Promise(x => pendingRequestCallback = x); + this._pendingRequstWillBeSentEvents.add(pendingRequestPromise); + let details = null; + try { + details = await this._contentPage.send('requestDetails', {channelId: httpChannel.channelId}); + } catch (e) { + pendingRequestCallback(); + this._pendingRequstWillBeSentEvents.delete(pendingRequestPromise); + return; + } + // Inherit frameId for redirects when details are not available. + const frameId = details ? details.frameId : (eventDetails.redirectedFrom ? this._requestIdToFrameId.get(eventDetails.redirectedFrom) : undefined); + this._requestIdToFrameId.set(eventDetails.requestId, frameId); + const activity = this._ensureHTTPActivity(eventDetails.requestId); + activity.request = { + frameId, + ...eventDetails, + }; + this._reportHTTPAcitivityEvents(activity); + pendingRequestCallback(); + this._pendingRequstWillBeSentEvents.delete(pendingRequestPromise); + } + + async _onResponse(httpChannel, eventDetails) { + const activity = this._ensureHTTPActivity(eventDetails.requestId); + activity.response = eventDetails; + this._reportHTTPAcitivityEvents(activity); + } + + async _onRequestFinished(httpChannel, eventDetails) { + const activity = this._ensureHTTPActivity(eventDetails.requestId); + activity.complete = eventDetails; + this._reportHTTPAcitivityEvents(activity); + } + + async _onRequestFailed(httpChannel, eventDetails) { + const activity = this._ensureHTTPActivity(eventDetails.requestId); + activity.failed = eventDetails; + this._reportHTTPAcitivityEvents(activity); + } +} + +var EXPORTED_SYMBOLS = ['NetworkHandler']; +this.NetworkHandler = NetworkHandler; diff --git a/juggler/protocol/PageHandler.js b/juggler/protocol/PageHandler.js new file mode 100644 index 0000000000000..11f9567d81630 --- /dev/null +++ b/juggler/protocol/PageHandler.js @@ -0,0 +1,348 @@ +"use strict"; + +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); +const {TargetRegistry} = ChromeUtils.import("chrome://juggler/content/TargetRegistry.js"); +const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; +const XUL_NS = 'http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul'; +const helper = new Helper(); + +class WorkerHandler { + constructor(chromeSession, contentChannel, sessionId, workerId) { + this._chromeSession = chromeSession; + this._sessionId = sessionId; + this._contentWorker = contentChannel.connect(sessionId + workerId); + this._workerId = workerId; + + const emitWrappedProtocolEvent = eventName => { + return params => { + this._chromeSession.emitEvent('Page.dispatchMessageFromWorker', { + workerId, + message: JSON.stringify({method: eventName, params}), + }); + } + } + + this._eventListeners = [ + contentChannel.register(sessionId + workerId, { + runtimeConsole: emitWrappedProtocolEvent('Runtime.console'), + runtimeExecutionContextCreated: emitWrappedProtocolEvent('Runtime.executionContextCreated'), + runtimeExecutionContextDestroyed: emitWrappedProtocolEvent('Runtime.executionContextDestroyed'), + }), + ]; + } + + async sendMessage(message) { + const [domain, method] = message.method.split('.'); + if (domain !== 'Runtime') + throw new Error('ERROR: can only dispatch to Runtime domain inside worker'); + const result = await this._contentWorker.send(method, message.params); + this._chromeSession.emitEvent('Page.dispatchMessageFromWorker', { + workerId: this._workerId, + message: JSON.stringify({result, id: message.id}), + }); + } + + dispose() { + this._contentWorker.dispose(); + helper.removeListeners(this._eventListeners); + } +} + +class PageHandler { + constructor(chromeSession, sessionId, contentChannel) { + this._chromeSession = chromeSession; + this._contentChannel = contentChannel; + this._sessionId = sessionId; + this._contentPage = contentChannel.connect(sessionId + 'page'); + this._workers = new Map(); + + const emitProtocolEvent = eventName => { + return (...args) => this._chromeSession.emitEvent(eventName, ...args); + } + + this._eventListeners = [ + contentChannel.register(sessionId + 'page', { + pageBindingCalled: emitProtocolEvent('Page.bindingCalled'), + pageDispatchMessageFromWorker: emitProtocolEvent('Page.dispatchMessageFromWorker'), + pageEventFired: emitProtocolEvent('Page.eventFired'), + pageFileChooserOpened: emitProtocolEvent('Page.fileChooserOpened'), + pageFrameAttached: emitProtocolEvent('Page.frameAttached'), + pageFrameDetached: emitProtocolEvent('Page.frameDetached'), + pageLinkClicked: emitProtocolEvent('Page.linkClicked'), + pageNavigationAborted: emitProtocolEvent('Page.navigationAborted'), + pageNavigationCommitted: emitProtocolEvent('Page.navigationCommitted'), + pageNavigationStarted: emitProtocolEvent('Page.navigationStarted'), + pageReady: emitProtocolEvent('Page.ready'), + pageSameDocumentNavigation: emitProtocolEvent('Page.sameDocumentNavigation'), + pageUncaughtError: emitProtocolEvent('Page.uncaughtError'), + pageWorkerCreated: this._onWorkerCreated.bind(this), + pageWorkerDestroyed: this._onWorkerDestroyed.bind(this), + }), + ]; + this._pageTarget = TargetRegistry.instance().targetForId(chromeSession.targetId()); + this._browser = TargetRegistry.instance().tabForTarget(chromeSession.targetId()).linkedBrowser; + this._dialogs = new Map(); + + this._enabled = false; + } + + _onWorkerCreated({workerId, frameId, url}) { + const worker = new WorkerHandler(this._chromeSession, this._contentChannel, this._sessionId, workerId); + this._workers.set(workerId, worker); + this._chromeSession.emitEvent('Page.workerCreated', {workerId, frameId, url}); + } + + _onWorkerDestroyed({workerId}) { + const worker = this._workers.get(workerId); + if (!worker) + return; + this._workers.delete(workerId); + worker.dispose(); + this._chromeSession.emitEvent('Page.workerDestroyed', {workerId}); + } + + async close({runBeforeUnload}) { + // Postpone target close to deliver response in session. + Services.tm.dispatchToMainThread(() => { + TargetRegistry.instance().closePage(this._chromeSession.targetId(), runBeforeUnload); + }); + } + + async enable() { + if (this._enabled) + return; + this._enabled = true; + this._updateModalDialogs(); + + this._eventListeners.push(...[ + helper.addEventListener(this._browser, 'DOMWillOpenModalDialog', async (event) => { + // wait for the dialog to be actually added to DOM. + await Promise.resolve(); + this._updateModalDialogs(); + }), + helper.addEventListener(this._browser, 'DOMModalDialogClosed', event => this._updateModalDialogs()), + helper.on(TargetRegistry.instance(), TargetRegistry.Events.TargetCrashed, targetId => { + if (targetId === this._chromeSession.targetId()) + this._chromeSession.emitEvent('Page.crashed', {}); + }), + ]); + } + + dispose() { + this._contentPage.dispose(); + helper.removeListeners(this._eventListeners); + } + + async setViewportSize({viewportSize}) { + const size = this._pageTarget.setViewportSize(viewportSize); + await this._contentPage.send('awaitViewportDimensions', { + width: size.width, + height: size.height + }); + } + + _updateModalDialogs() { + const prompts = new Set(this._browser.tabModalPromptBox ? this._browser.tabModalPromptBox.listPrompts() : []); + for (const dialog of this._dialogs.values()) { + if (!prompts.has(dialog.prompt())) { + this._dialogs.delete(dialog.id()); + this._chromeSession.emitEvent('Page.dialogClosed', { + dialogId: dialog.id(), + }); + } else { + prompts.delete(dialog.prompt()); + } + } + for (const prompt of prompts) { + const dialog = Dialog.createIfSupported(prompt); + if (!dialog) + continue; + this._dialogs.set(dialog.id(), dialog); + this._chromeSession.emitEvent('Page.dialogOpened', { + dialogId: dialog.id(), + type: dialog.type(), + message: dialog.message(), + defaultValue: dialog.defaultValue(), + }); + } + } + + async setFileInputFiles(options) { + return await this._contentPage.send('setFileInputFiles', options); + } + + async setEmulatedMedia(options) { + return await this._contentPage.send('setEmulatedMedia', options); + } + + async setCacheDisabled(options) { + return await this._contentPage.send('setCacheDisabled', options); + } + + async addBinding(options) { + return await this._contentPage.send('addBinding', options); + } + + async adoptNode(options) { + return await this._contentPage.send('adoptNode', options); + } + + async screenshot(options) { + return await this._contentPage.send('screenshot', options); + } + + async getBoundingBox(options) { + return await this._contentPage.send('getBoundingBox', options); + } + + async getContentQuads(options) { + return await this._contentPage.send('getContentQuads', options); + } + + /** + * @param {{frameId: string, url: string}} options + */ + async navigate(options) { + return await this._contentPage.send('navigate', options); + } + + /** + * @param {{frameId: string, url: string}} options + */ + async goBack(options) { + return await this._contentPage.send('goBack', options); + } + + /** + * @param {{frameId: string, url: string}} options + */ + async goForward(options) { + return await this._contentPage.send('goForward', options); + } + + /** + * @param {{frameId: string, url: string}} options + */ + async reload(options) { + return await this._contentPage.send('reload', options); + } + + async describeNode(options) { + return await this._contentPage.send('describeNode', options); + } + + async scrollIntoViewIfNeeded(options) { + return await this._contentPage.send('scrollIntoViewIfNeeded', options); + } + + async addScriptToEvaluateOnNewDocument(options) { + return await this._contentPage.send('addScriptToEvaluateOnNewDocument', options); + } + + async removeScriptToEvaluateOnNewDocument(options) { + return await this._contentPage.send('removeScriptToEvaluateOnNewDocument', options); + } + + async dispatchKeyEvent(options) { + return await this._contentPage.send('dispatchKeyEvent', options); + } + + async dispatchTouchEvent(options) { + return await this._contentPage.send('dispatchTouchEvent', options); + } + + async dispatchMouseEvent(options) { + return await this._contentPage.send('dispatchMouseEvent', options); + } + + async insertText(options) { + return await this._contentPage.send('insertText', options); + } + + async crash(options) { + return await this._contentPage.send('crash', options); + } + + async handleDialog({dialogId, accept, promptText}) { + const dialog = this._dialogs.get(dialogId); + if (!dialog) + throw new Error('Failed to find dialog with id = ' + dialogId); + if (accept) + dialog.accept(promptText); + else + dialog.dismiss(); + } + + async setInterceptFileChooserDialog(options) { + return await this._contentPage.send('setInterceptFileChooserDialog', options); + } + + async sendMessageToWorker({workerId, message}) { + const worker = this._workers.get(workerId); + if (!worker) + throw new Error('ERROR: cannot find worker with id ' + workerId); + return await worker.sendMessage(JSON.parse(message)); + } +} + +class Dialog { + static createIfSupported(prompt) { + const type = prompt.args.promptType; + switch (type) { + case 'alert': + case 'prompt': + case 'confirm': + return new Dialog(prompt, type); + case 'confirmEx': + return new Dialog(prompt, 'beforeunload'); + default: + return null; + }; + } + + constructor(prompt, type) { + this._id = helper.generateId(); + this._type = type; + this._prompt = prompt; + } + + id() { + return this._id; + } + + message() { + return this._prompt.ui.infoBody.textContent; + } + + type() { + return this._type; + } + + prompt() { + return this._prompt; + } + + dismiss() { + if (this._prompt.ui.button1) + this._prompt.ui.button1.click(); + else + this._prompt.ui.button0.click(); + } + + defaultValue() { + return this._prompt.ui.loginTextbox.value; + } + + accept(promptValue) { + if (typeof promptValue === 'string' && this._type === 'prompt') + this._prompt.ui.loginTextbox.value = promptValue; + this._prompt.ui.button0.click(); + } +} + +var EXPORTED_SYMBOLS = ['PageHandler']; +this.PageHandler = PageHandler; diff --git a/juggler/protocol/PrimitiveTypes.js b/juggler/protocol/PrimitiveTypes.js new file mode 100644 index 0000000000000..78b6601b91d0b --- /dev/null +++ b/juggler/protocol/PrimitiveTypes.js @@ -0,0 +1,143 @@ +const t = {}; + +t.String = function(x, details = {}, path = ['']) { + if (typeof x === 'string' || typeof x === 'String') + return true; + details.error = `Expected "${path.join('.')}" to be |string|; found |${typeof x}| \`${JSON.stringify(x)}\` instead.`; + return false; +} + +t.Number = function(x, details = {}, path = ['']) { + if (typeof x === 'number') + return true; + details.error = `Expected "${path.join('.')}" to be |number|; found |${typeof x}| \`${JSON.stringify(x)}\` instead.`; + return false; +} + +t.Boolean = function(x, details = {}, path = ['']) { + if (typeof x === 'boolean') + return true; + details.error = `Expected "${path.join('.')}" to be |boolean|; found |${typeof x}| \`${JSON.stringify(x)}\` instead.`; + return false; +} + +t.Null = function(x, details = {}, path = ['']) { + if (Object.is(x, null)) + return true; + details.error = `Expected "${path.join('.')}" to be \`null\`; found \`${JSON.stringify(x)}\` instead.`; + return false; +} + +t.Undefined = function(x, details = {}, path = ['']) { + if (Object.is(x, undefined)) + return true; + details.error = `Expected "${path.join('.')}" to be \`undefined\`; found \`${JSON.stringify(x)}\` instead.`; + return false; +} + +t.Any = x => true, + +t.Enum = function(values) { + return function(x, details = {}, path = ['']) { + if (values.indexOf(x) !== -1) + return true; + details.error = `Expected "${path.join('.')}" to be one of [${values.join(', ')}]; found \`${JSON.stringify(x)}\` (${typeof x}) instead.`; + return false; + } +} + +t.Nullable = function(scheme) { + return function(x, details = {}, path = ['']) { + if (Object.is(x, null)) + return true; + return checkScheme(scheme, x, details, path); + } +} + +t.Optional = function(scheme) { + return function(x, details = {}, path = ['']) { + if (Object.is(x, undefined)) + return true; + return checkScheme(scheme, x, details, path); + } +} + +t.Array = function(scheme) { + return function(x, details = {}, path = ['']) { + if (!Array.isArray(x)) { + details.error = `Expected "${path.join('.')}" to be an array; found \`${JSON.stringify(x)}\` (${typeof x}) instead.`; + return false; + } + const lastPathElement = path[path.length - 1]; + for (let i = 0; i < x.length; ++i) { + path[path.length - 1] = lastPathElement + `[${i}]`; + if (!checkScheme(scheme, x[i], details, path)) + return false; + } + path[path.length - 1] = lastPathElement; + return true; + } +} + +t.Recursive = function(types, schemeName) { + return function(x, details = {}, path = ['']) { + const scheme = types[schemeName]; + return checkScheme(scheme, x, details, path); + } +} + +function beauty(path, obj) { + if (path.length === 1) + return `object ${JSON.stringify(obj, null, 2)}`; + return `property "${path.join('.')}" - ${JSON.stringify(obj, null, 2)}`; +} + +function checkScheme(scheme, x, details = {}, path = ['']) { + if (!scheme) + throw new Error(`ILLDEFINED SCHEME: ${path.join('.')}`); + if (typeof scheme === 'object') { + if (!x) { + details.error = `Object "${path.join('.')}" is undefined, but has some scheme`; + return false; + } + for (const [propertyName, aScheme] of Object.entries(scheme)) { + path.push(propertyName); + const result = checkScheme(aScheme, x[propertyName], details, path); + path.pop(); + if (!result) + return false; + } + for (const propertyName of Object.keys(x)) { + if (!scheme[propertyName]) { + path.push(propertyName); + details.error = `Found ${beauty(path, x[propertyName])} which is not described in this scheme`; + return false; + } + } + return true; + } + return scheme(x, details, path); +} + +/* + +function test(scheme, obj) { + const details = {}; + if (!checkScheme(scheme, obj, details)) { + dump(`FAILED: ${JSON.stringify(obj)} + details.error: ${details.error} + `); + } else { + dump(`SUCCESS: ${JSON.stringify(obj)} +`); + } +} + +test(t.Array(t.String), ['a', 'b', 2, 'c']); +test(t.Either(t.String, t.Number), {}); + +*/ + +this.t = t; +this.checkScheme = checkScheme; +this.EXPORTED_SYMBOLS = ['t', 'checkScheme']; diff --git a/juggler/protocol/Protocol.js b/juggler/protocol/Protocol.js new file mode 100644 index 0000000000000..67df4d5592d66 --- /dev/null +++ b/juggler/protocol/Protocol.js @@ -0,0 +1,778 @@ +const {t, checkScheme} = ChromeUtils.import('chrome://juggler/content/protocol/PrimitiveTypes.js'); + +// Protocol-specific types. +const browserTypes = {}; + +browserTypes.TargetInfo = { + type: t.Enum(['page']), + targetId: t.String, + browserContextId: t.Optional(t.String), + // PageId of parent tab, if any. + openerId: t.Optional(t.String), +}; + +browserTypes.CookieOptions = { + name: t.String, + value: t.String, + url: t.Optional(t.String), + domain: t.Optional(t.String), + path: t.Optional(t.String), + secure: t.Optional(t.Boolean), + httpOnly: t.Optional(t.Boolean), + sameSite: t.Optional(t.Enum(['Strict', 'Lax', 'None'])), + expires: t.Optional(t.Number), +}; + +browserTypes.Cookie = { + name: t.String, + domain: t.String, + path: t.String, + value: t.String, + expires: t.Number, + size: t.Number, + httpOnly: t.Boolean, + secure: t.Boolean, + session: t.Boolean, + sameSite: t.Enum(['Strict', 'Lax', 'None']), +}; + +browserTypes.Geolocation = { + latitude: t.Number, + longitude: t.Number, + accuracy: t.Optional(t.Number), +}; + +const pageTypes = {}; +pageTypes.DOMPoint = { + x: t.Number, + y: t.Number, +}; + +pageTypes.Rect = { + x: t.Number, + y: t.Number, + width: t.Number, + height: t.Number, +}; + +pageTypes.Size = { + width: t.Number, + height: t.Number, +}; + +pageTypes.Viewport = { + viewportSize: pageTypes.Size, + deviceScaleFactor: t.Number, + isMobile: t.Boolean, + hasTouch: t.Boolean, +}; + +pageTypes.DOMQuad = { + p1: pageTypes.DOMPoint, + p2: pageTypes.DOMPoint, + p3: pageTypes.DOMPoint, + p4: pageTypes.DOMPoint, +}; + +pageTypes.TouchPoint = { + x: t.Number, + y: t.Number, + radiusX: t.Optional(t.Number), + radiusY: t.Optional(t.Number), + rotationAngle: t.Optional(t.Number), + force: t.Optional(t.Number), +}; + +pageTypes.Clip = { + x: t.Number, + y: t.Number, + width: t.Number, + height: t.Number, +}; + + +const runtimeTypes = {}; +runtimeTypes.RemoteObject = { + type: t.Optional(t.Enum(['object', 'function', 'undefined', 'string', 'number', 'boolean', 'symbol', 'bigint'])), + subtype: t.Optional(t.Enum(['array', 'null', 'node', 'regexp', 'date', 'map', 'set', 'weakmap', 'weakset', 'error', 'proxy', 'promise', 'typedarray'])), + objectId: t.Optional(t.String), + unserializableValue: t.Optional(t.Enum(['Infinity', '-Infinity', '-0', 'NaN'])), + value: t.Any +}; + +runtimeTypes.ObjectProperty = { + name: t.String, + value: runtimeTypes.RemoteObject, +}; + +runtimeTypes.ScriptLocation = { + columnNumber: t.Number, + lineNumber: t.Number, + url: t.String, +}; + +runtimeTypes.ExceptionDetails = { + text: t.Optional(t.String), + stack: t.Optional(t.String), + value: t.Optional(t.Any), +}; + +runtimeTypes.CallFunctionArgument = { + objectId: t.Optional(t.String), + unserializableValue: t.Optional(t.Enum(['Infinity', '-Infinity', '-0', 'NaN'])), + value: t.Any, +}; + +const axTypes = {}; +axTypes.AXTree = { + role: t.String, + name: t.String, + children: t.Optional(t.Array(t.Recursive(axTypes, 'AXTree'))), + + selected: t.Optional(t.Boolean), + focused: t.Optional(t.Boolean), + pressed: t.Optional(t.Boolean), + focusable: t.Optional(t.Boolean), + haspopup: t.Optional(t.Boolean), + required: t.Optional(t.Boolean), + invalid: t.Optional(t.Boolean), + modal: t.Optional(t.Boolean), + editable: t.Optional(t.Boolean), + busy: t.Optional(t.Boolean), + multiline: t.Optional(t.Boolean), + readonly: t.Optional(t.Boolean), + checked: t.Optional(t.Enum(['mixed', true])), + expanded: t.Optional(t.Boolean), + disabled: t.Optional(t.Boolean), + multiselectable: t.Optional(t.Boolean), + + value: t.Optional(t.String), + description: t.Optional(t.String), + + value: t.Optional(t.String), + roledescription: t.Optional(t.String), + valuetext: t.Optional(t.String), + orientation: t.Optional(t.String), + autocomplete: t.Optional(t.String), + keyshortcuts: t.Optional(t.String), + + level: t.Optional(t.Number), + + tag: t.Optional(t.String), + + foundObject: t.Optional(t.Boolean), +} + +const networkTypes = {}; + +networkTypes.HTTPHeader = { + name: t.String, + value: t.String, +}; + +networkTypes.HTTPCredentials = { + username: t.String, + password: t.String, +}; + +networkTypes.SecurityDetails = { + protocol: t.String, + subjectName: t.String, + issuer: t.String, + validFrom: t.Number, + validTo: t.Number, +}; + +const Browser = { + targets: ['browser'], + + types: browserTypes, + + events: { + 'attachedToTarget': { + sessionId: t.String, + targetInfo: browserTypes.TargetInfo, + }, + 'detachedFromTarget': { + sessionId: t.String, + targetId: t.String, + }, + }, + + methods: { + 'enable': { + params: { + attachToDefaultContext: t.Boolean, + }, + }, + 'createBrowserContext': { + params: { + removeOnDetach: t.Optional(t.Boolean), + userAgent: t.Optional(t.String), + bypassCSP: t.Optional(t.Boolean), + javaScriptDisabled: t.Optional(t.Boolean), + viewport: t.Optional(pageTypes.Viewport), + locale: t.Optional(t.String), + }, + returns: { + browserContextId: t.String, + }, + }, + 'removeBrowserContext': { + params: { + browserContextId: t.String, + }, + }, + 'newPage': { + params: { + browserContextId: t.Optional(t.String), + }, + returns: { + targetId: t.String, + } + }, + 'close': {}, + 'getInfo': { + returns: { + userAgent: t.String, + version: t.String, + }, + }, + 'setIgnoreHTTPSErrors': { + params: { + enabled: t.Boolean, + }, + }, + 'setExtraHTTPHeaders': { + params: { + browserContextId: t.Optional(t.String), + headers: t.Array(networkTypes.HTTPHeader), + }, + }, + 'setHTTPCredentials': { + params: { + browserContextId: t.Optional(t.String), + credentials: t.Nullable(networkTypes.HTTPCredentials), + }, + }, + 'setRequestInterception': { + params: { + browserContextId: t.Optional(t.String), + enabled: t.Boolean, + }, + }, + 'setGeolocationOverride': { + params: { + browserContextId: t.Optional(t.String), + geolocation: t.Nullable(browserTypes.Geolocation), + } + }, + 'addScriptToEvaluateOnNewDocument': { + params: { + browserContextId: t.Optional(t.String), + script: t.String, + } + }, + 'addBinding': { + params: { + browserContextId: t.Optional(t.String), + name: t.String, + script: t.String, + }, + }, + 'grantPermissions': { + params: { + origin: t.String, + browserContextId: t.Optional(t.String), + permissions: t.Array(t.String), + }, + }, + 'resetPermissions': { + params: { + browserContextId: t.Optional(t.String), + } + }, + 'setCookies': { + params: { + browserContextId: t.Optional(t.String), + cookies: t.Array(browserTypes.CookieOptions), + } + }, + 'clearCookies': { + params: { + browserContextId: t.Optional(t.String), + } + }, + 'getCookies': { + params: { + browserContextId: t.Optional(t.String) + }, + returns: { + cookies: t.Array(browserTypes.Cookie), + }, + }, + 'setOnlineOverride': { + params: { + browserContextId: t.Optional(t.String), + override: t.Optional(t.Enum(['online', 'offline'])), + } + }, + }, +}; + +const Network = { + targets: ['page'], + types: networkTypes, + events: { + 'requestWillBeSent': { + // frameId may be absent for redirected requests. + frameId: t.Optional(t.String), + requestId: t.String, + // RequestID of redirected request. + redirectedFrom: t.Optional(t.String), + postData: t.Optional(t.String), + headers: t.Array(networkTypes.HTTPHeader), + isIntercepted: t.Boolean, + url: t.String, + method: t.String, + navigationId: t.Optional(t.String), + cause: t.String, + }, + 'responseReceived': { + securityDetails: t.Nullable(networkTypes.SecurityDetails), + requestId: t.String, + fromCache: t.Boolean, + remoteIPAddress: t.Optional(t.String), + remotePort: t.Optional(t.Number), + status: t.Number, + statusText: t.String, + headers: t.Array(networkTypes.HTTPHeader), + }, + 'requestFinished': { + requestId: t.String, + }, + 'requestFailed': { + requestId: t.String, + errorCode: t.String, + }, + }, + methods: { + 'setRequestInterception': { + params: { + enabled: t.Boolean, + }, + }, + 'setExtraHTTPHeaders': { + params: { + headers: t.Array(networkTypes.HTTPHeader), + }, + }, + 'abortInterceptedRequest': { + params: { + requestId: t.String, + errorCode: t.String, + }, + }, + 'resumeInterceptedRequest': { + params: { + requestId: t.String, + method: t.Optional(t.String), + headers: t.Optional(t.Array(networkTypes.HTTPHeader)), + postData: t.Optional(t.String), + }, + }, + 'fulfillInterceptedRequest': { + params: { + requestId: t.String, + status: t.Number, + statusText: t.String, + headers: t.Array(networkTypes.HTTPHeader), + base64body: t.Optional(t.String), // base64-encoded + }, + }, + 'getResponseBody': { + params: { + requestId: t.String, + }, + returns: { + base64body: t.String, + evicted: t.Optional(t.Boolean), + }, + }, + }, +}; + +const Runtime = { + targets: ['page'], + types: runtimeTypes, + events: { + 'executionContextCreated': { + executionContextId: t.String, + auxData: t.Any, + }, + 'executionContextDestroyed': { + executionContextId: t.String, + }, + 'console': { + executionContextId: t.String, + args: t.Array(runtimeTypes.RemoteObject), + type: t.String, + location: runtimeTypes.ScriptLocation, + }, + }, + methods: { + 'evaluate': { + params: { + // Pass frameId here. + executionContextId: t.String, + expression: t.String, + returnByValue: t.Optional(t.Boolean), + }, + + returns: { + result: t.Optional(runtimeTypes.RemoteObject), + exceptionDetails: t.Optional(runtimeTypes.ExceptionDetails), + } + }, + 'callFunction': { + params: { + // Pass frameId here. + executionContextId: t.String, + functionDeclaration: t.String, + returnByValue: t.Optional(t.Boolean), + args: t.Array(runtimeTypes.CallFunctionArgument), + }, + + returns: { + result: t.Optional(runtimeTypes.RemoteObject), + exceptionDetails: t.Optional(runtimeTypes.ExceptionDetails), + } + }, + 'disposeObject': { + params: { + executionContextId: t.String, + objectId: t.String, + }, + }, + + 'getObjectProperties': { + params: { + executionContextId: t.String, + objectId: t.String, + }, + + returns: { + properties: t.Array(runtimeTypes.ObjectProperty), + } + }, + }, +}; + +const Page = { + targets: ['page'], + + types: pageTypes, + events: { + 'ready': { + }, + 'crashed': { + }, + 'eventFired': { + frameId: t.String, + name: t.Enum(['load', 'DOMContentLoaded']), + }, + 'uncaughtError': { + frameId: t.String, + message: t.String, + stack: t.String, + }, + 'frameAttached': { + frameId: t.String, + parentFrameId: t.Optional(t.String), + }, + 'frameDetached': { + frameId: t.String, + }, + 'navigationStarted': { + frameId: t.String, + navigationId: t.String, + url: t.String, + }, + 'navigationCommitted': { + frameId: t.String, + // |navigationId| can only be null in response to enable. + navigationId: t.Optional(t.String), + url: t.String, + // frame.id or frame.name + name: t.String, + }, + 'navigationAborted': { + frameId: t.String, + navigationId: t.String, + errorText: t.String, + }, + 'sameDocumentNavigation': { + frameId: t.String, + url: t.String, + }, + 'dialogOpened': { + dialogId: t.String, + type: t.Enum(['prompt', 'alert', 'confirm', 'beforeunload']), + message: t.String, + defaultValue: t.Optional(t.String), + }, + 'dialogClosed': { + dialogId: t.String, + }, + 'bindingCalled': { + executionContextId: t.String, + name: t.String, + payload: t.Any, + }, + 'linkClicked': { + phase: t.Enum(['before', 'after']), + }, + 'fileChooserOpened': { + executionContextId: t.String, + element: runtimeTypes.RemoteObject + }, + 'workerCreated': { + workerId: t.String, + frameId: t.String, + url: t.String, + }, + 'workerDestroyed': { + workerId: t.String, + }, + 'dispatchMessageFromWorker': { + workerId: t.String, + message: t.String, + }, + }, + + methods: { + 'close': { + params: { + runBeforeUnload: t.Optional(t.Boolean), + }, + }, + 'setFileInputFiles': { + params: { + frameId: t.String, + objectId: t.String, + files: t.Array(t.String), + }, + }, + 'addBinding': { + params: { + name: t.String, + script: t.String, + }, + }, + 'setViewportSize': { + params: { + viewportSize: t.Nullable(pageTypes.Size), + }, + }, + 'setEmulatedMedia': { + params: { + type: t.Optional(t.Enum(['screen', 'print', ''])), + colorScheme: t.Optional(t.Enum(['dark', 'light', 'no-preference'])), + }, + }, + 'setCacheDisabled': { + params: { + cacheDisabled: t.Boolean, + }, + }, + 'describeNode': { + params: { + frameId: t.String, + objectId: t.String, + }, + returns: { + contentFrameId: t.Optional(t.String), + ownerFrameId: t.Optional(t.String), + }, + }, + 'scrollIntoViewIfNeeded': { + params: { + frameId: t.String, + objectId: t.String, + rect: t.Optional(pageTypes.Rect), + }, + }, + 'addScriptToEvaluateOnNewDocument': { + params: { + script: t.String, + worldName: t.Optional(t.String), + }, + returns: { + scriptId: t.String, + } + }, + 'removeScriptToEvaluateOnNewDocument': { + params: { + scriptId: t.String, + }, + }, + 'navigate': { + params: { + frameId: t.String, + url: t.String, + referer: t.Optional(t.String), + }, + returns: { + navigationId: t.Nullable(t.String), + navigationURL: t.Nullable(t.String), + } + }, + 'goBack': { + params: { + frameId: t.String, + }, + returns: { + navigationId: t.Nullable(t.String), + navigationURL: t.Nullable(t.String), + } + }, + 'goForward': { + params: { + frameId: t.String, + }, + returns: { + navigationId: t.Nullable(t.String), + navigationURL: t.Nullable(t.String), + } + }, + 'reload': { + params: { + frameId: t.String, + }, + returns: { + navigationId: t.String, + navigationURL: t.String, + } + }, + 'getBoundingBox': { + params: { + frameId: t.String, + objectId: t.String, + }, + returns: { + boundingBox: t.Nullable(pageTypes.Rect), + }, + }, + 'adoptNode': { + params: { + frameId: t.String, + objectId: t.String, + executionContextId: t.String, + }, + returns: { + remoteObject: t.Nullable(runtimeTypes.RemoteObject), + }, + }, + 'screenshot': { + params: { + mimeType: t.Enum(['image/png', 'image/jpeg']), + fullPage: t.Optional(t.Boolean), + clip: t.Optional(pageTypes.Clip), + }, + returns: { + data: t.String, + } + }, + 'getContentQuads': { + params: { + frameId: t.String, + objectId: t.String, + }, + returns: { + quads: t.Array(pageTypes.DOMQuad), + }, + }, + 'dispatchKeyEvent': { + params: { + type: t.String, + key: t.String, + keyCode: t.Number, + location: t.Number, + code: t.String, + repeat: t.Boolean, + text: t.Optional(t.String), + } + }, + 'dispatchTouchEvent': { + params: { + type: t.Enum(['touchStart', 'touchEnd', 'touchMove', 'touchCancel']), + touchPoints: t.Array(pageTypes.TouchPoint), + modifiers: t.Number, + }, + returns: { + defaultPrevented: t.Boolean, + } + }, + 'dispatchMouseEvent': { + params: { + type: t.String, + button: t.Number, + x: t.Number, + y: t.Number, + modifiers: t.Number, + clickCount: t.Optional(t.Number), + buttons: t.Number, + } + }, + 'insertText': { + params: { + text: t.String, + } + }, + 'crash': { + params: {} + }, + 'handleDialog': { + params: { + dialogId: t.String, + accept: t.Boolean, + promptText: t.Optional(t.String), + }, + }, + 'setInterceptFileChooserDialog': { + params: { + enabled: t.Boolean, + }, + }, + 'sendMessageToWorker': { + params: { + frameId: t.String, + workerId: t.String, + message: t.String, + }, + }, + }, +}; + + +const Accessibility = { + targets: ['page'], + types: axTypes, + events: {}, + methods: { + 'getFullAXTree': { + params: { + objectId: t.Optional(t.String), + }, + returns: { + tree: axTypes.AXTree + }, + } + } +} + +this.protocol = { + domains: {Browser, Page, Runtime, Network, Accessibility}, +}; +this.checkScheme = checkScheme; +this.EXPORTED_SYMBOLS = ['protocol', 'checkScheme']; diff --git a/juggler/protocol/RuntimeHandler.js b/juggler/protocol/RuntimeHandler.js new file mode 100644 index 0000000000000..5cc68241bdb42 --- /dev/null +++ b/juggler/protocol/RuntimeHandler.js @@ -0,0 +1,52 @@ +"use strict"; + +const {Helper} = ChromeUtils.import('chrome://juggler/content/Helper.js'); +const {Services} = ChromeUtils.import("resource://gre/modules/Services.jsm"); + +const Cc = Components.classes; +const Ci = Components.interfaces; +const Cu = Components.utils; +const helper = new Helper(); + +class RuntimeHandler { + constructor(chromeSession, sessionId, contentChannel) { + this._chromeSession = chromeSession; + this._contentRuntime = contentChannel.connect(sessionId + 'runtime'); + + const emitProtocolEvent = eventName => { + return (...args) => this._chromeSession.emitEvent(eventName, ...args); + } + + this._eventListeners = [ + contentChannel.register(sessionId + 'runtime', { + runtimeConsole: emitProtocolEvent('Runtime.console'), + runtimeExecutionContextCreated: emitProtocolEvent('Runtime.executionContextCreated'), + runtimeExecutionContextDestroyed: emitProtocolEvent('Runtime.executionContextDestroyed'), + }), + ]; + } + + async evaluate(options) { + return await this._contentRuntime.send('evaluate', options); + } + + async callFunction(options) { + return await this._contentRuntime.send('callFunction', options); + } + + async getObjectProperties(options) { + return await this._contentRuntime.send('getObjectProperties', options); + } + + async disposeObject(options) { + return await this._contentRuntime.send('disposeObject', options); + } + + dispose() { + this._contentRuntime.dispose(); + helper.removeListeners(this._eventListeners); + } +} + +var EXPORTED_SYMBOLS = ['RuntimeHandler']; +this.RuntimeHandler = RuntimeHandler; diff --git a/parser/html/nsHtml5TreeOpExecutor.cpp b/parser/html/nsHtml5TreeOpExecutor.cpp index d99e7f91503e8..b63e9d9621942 100644 --- a/parser/html/nsHtml5TreeOpExecutor.cpp +++ b/parser/html/nsHtml5TreeOpExecutor.cpp @@ -1065,9 +1065,12 @@ void nsHtml5TreeOpExecutor::AddSpeculationCSP(const nsAString& aCSP) { if (!StaticPrefs::security_csp_enable()) { return; } - NS_ASSERTION(NS_IsMainThread(), "Wrong thread!"); + if (mDocShell && static_cast(mDocShell.get())->IsBypassCSPEnabled()) { + return; + } + nsresult rv = NS_OK; nsCOMPtr preloadCsp = mDocument->GetPreloadCsp(); if (!preloadCsp) { diff --git a/security/manager/ssl/nsCertOverrideService.cpp b/security/manager/ssl/nsCertOverrideService.cpp index 6dca2b78830ed..fbe89c9682834 100644 --- a/security/manager/ssl/nsCertOverrideService.cpp +++ b/security/manager/ssl/nsCertOverrideService.cpp @@ -634,7 +634,7 @@ static bool IsDebugger() { NS_IMETHODIMP nsCertOverrideService:: SetDisableAllSecurityChecksAndLetAttackersInterceptMyData(bool aDisable) { - if (!(PR_GetEnv("XPCSHELL_TEST_PROFILE_DIR") || IsDebugger())) { + if (false /* juggler hacks */ && !(PR_GetEnv("XPCSHELL_TEST_PROFILE_DIR") || IsDebugger())) { return NS_ERROR_NOT_AVAILABLE; } diff --git a/services/settings/Utils.jsm b/services/settings/Utils.jsm index 36da62856100a..b29d93917386d 100644 --- a/services/settings/Utils.jsm +++ b/services/settings/Utils.jsm @@ -42,7 +42,7 @@ var Utils = { Ci.nsIEnvironment ); const isXpcshell = env.exists("XPCSHELL_TEST_PROFILE_DIR"); - return AppConstants.RELEASE_OR_BETA && !Cu.isInAutomation && !isXpcshell + return false && !Cu.isInAutomation && !isXpcshell ? "https://firefox.settings.services.mozilla.com/v1" : gServerURL; }, diff --git a/toolkit/components/statusfilter/nsBrowserStatusFilter.cpp b/toolkit/components/statusfilter/nsBrowserStatusFilter.cpp index 7e10920c90067..8c0f31c01bce5 100644 --- a/toolkit/components/statusfilter/nsBrowserStatusFilter.cpp +++ b/toolkit/components/statusfilter/nsBrowserStatusFilter.cpp @@ -177,8 +177,16 @@ nsBrowserStatusFilter::OnStateChange(nsIWebProgress* aWebProgress, } NS_IMETHODIMP -nsBrowserStatusFilter::OnProgressChange(nsIWebProgress* aWebProgress, - nsIRequest* aRequest, +nsBrowserStatusFilter::OnFrameLocationChange(nsIWebProgress *aWebProgress, + nsIRequest *aRequest, + nsIURI *aLocation, + uint32_t aFlags) { + return NS_OK; +} + +NS_IMETHODIMP +nsBrowserStatusFilter::OnProgressChange(nsIWebProgress *aWebProgress, + nsIRequest *aRequest, int32_t aCurSelfProgress, int32_t aMaxSelfProgress, int32_t aCurTotalProgress, diff --git a/toolkit/mozapps/update/UpdateService.jsm b/toolkit/mozapps/update/UpdateService.jsm index 33caae56237e1..1538c49f64b36 100644 --- a/toolkit/mozapps/update/UpdateService.jsm +++ b/toolkit/mozapps/update/UpdateService.jsm @@ -3097,7 +3097,7 @@ UpdateService.prototype = { ).running; } - return ( + return true || ( (Cu.isInAutomation || marionetteRunning) && Services.prefs.getBoolPref(PREF_APP_UPDATE_DISABLEDFORTESTING, false) ); diff --git a/toolkit/toolkit.mozbuild b/toolkit/toolkit.mozbuild index 299230cb3bde5..09f4ef6977621 100644 --- a/toolkit/toolkit.mozbuild +++ b/toolkit/toolkit.mozbuild @@ -168,6 +168,7 @@ if CONFIG['ENABLE_MARIONETTE']: DIRS += [ '/testing/firefox-ui', '/testing/marionette', + '/juggler', '/toolkit/components/telemetry/tests/marionette', ] diff --git a/uriloader/base/nsDocLoader.cpp b/uriloader/base/nsDocLoader.cpp index 05dcc11514277..96b0ee7b09672 100644 --- a/uriloader/base/nsDocLoader.cpp +++ b/uriloader/base/nsDocLoader.cpp @@ -758,6 +758,13 @@ void nsDocLoader::DocLoaderIsEmpty(bool aFlushLayout) { ("DocLoader:%p: Firing load event for document.open\n", this)); + nsCOMPtr os = mozilla::services::GetObserverService(); + if (os) { + nsIPrincipal* principal = doc->NodePrincipal(); + if (!principal->IsSystemPrincipal()) + os->NotifyObservers(ToSupports(doc), "juggler-document-open-loaded", nullptr); + } + // This is a very cut-down version of // nsDocumentViewer::LoadComplete that doesn't do various things // that are not relevant here because this wasn't an actual @@ -1365,6 +1372,24 @@ void nsDocLoader::FireOnLocationChange(nsIWebProgress* aWebProgress, } } +void nsDocLoader::FireOnFrameLocationChange(nsIWebProgress* aWebProgress, + nsIRequest* aRequest, + nsIURI *aUri, + uint32_t aFlags) { + NOTIFY_LISTENERS(nsIWebProgress::NOTIFY_FRAME_LOCATION, + nsCOMPtr listener2 = + do_QueryReferent(info.mWeakListener); + if (!listener2) + continue; + listener2->OnFrameLocationChange(aWebProgress, aRequest, aUri, aFlags); + ); + + // Pass the notification up to the parent... + if (mParent) { + mParent->FireOnFrameLocationChange(aWebProgress, aRequest, aUri, aFlags); + } +} + void nsDocLoader::FireOnStatusChange(nsIWebProgress* aWebProgress, nsIRequest* aRequest, nsresult aStatus, const char16_t* aMessage) { diff --git a/uriloader/base/nsDocLoader.h b/uriloader/base/nsDocLoader.h index 684cf732b6c7c..d76f46c006dd7 100644 --- a/uriloader/base/nsDocLoader.h +++ b/uriloader/base/nsDocLoader.h @@ -204,6 +204,11 @@ class nsDocLoader : public nsIDocumentLoader, void FireOnLocationChange(nsIWebProgress* aWebProgress, nsIRequest* aRequest, nsIURI* aUri, uint32_t aFlags); + void FireOnFrameLocationChange(nsIWebProgress* aWebProgress, + nsIRequest* aRequest, + nsIURI *aUri, + uint32_t aFlags); + MOZ_MUST_USE bool RefreshAttempted(nsIWebProgress* aWebProgress, nsIURI* aURI, int32_t aDelay, bool aSameURI); diff --git a/uriloader/base/nsIWebProgress.idl b/uriloader/base/nsIWebProgress.idl index b0cde5026dc7c..09ebb0ef6799c 100644 --- a/uriloader/base/nsIWebProgress.idl +++ b/uriloader/base/nsIWebProgress.idl @@ -87,6 +87,10 @@ interface nsIWebProgress : nsISupports * NOTIFY_REFRESH * Receive onRefreshAttempted events. * This is defined on nsIWebProgressListener2. + * + * NOTIFY_FRAME_LOCATION + * Receive onFrameLocationChange events. + * This is defined on nsIWebProgressListener2. */ const unsigned long NOTIFY_PROGRESS = 0x00000010; const unsigned long NOTIFY_STATUS = 0x00000020; @@ -94,11 +98,12 @@ interface nsIWebProgress : nsISupports const unsigned long NOTIFY_LOCATION = 0x00000080; const unsigned long NOTIFY_REFRESH = 0x00000100; const unsigned long NOTIFY_CONTENT_BLOCKING = 0x00000200; + const unsigned long NOTIFY_FRAME_LOCATION = 0x00000400; /** * This flag enables all notifications. */ - const unsigned long NOTIFY_ALL = 0x000003ff; + const unsigned long NOTIFY_ALL = 0x000007ff; /** * Registers a listener to receive web progress events. diff --git a/uriloader/base/nsIWebProgressListener2.idl b/uriloader/base/nsIWebProgressListener2.idl index 87701f8d2cfee..ae1aa85c019cb 100644 --- a/uriloader/base/nsIWebProgressListener2.idl +++ b/uriloader/base/nsIWebProgressListener2.idl @@ -66,4 +66,27 @@ interface nsIWebProgressListener2 : nsIWebProgressListener { in nsIURI aRefreshURI, in long aMillis, in boolean aSameURI); + + /** + * Called when the location of the window or its subframes changes. This is not + * when a load is requested, but rather once it is verified that the load is + * going to occur in the given window. For instance, a load that starts in a + * window might send progress and status messages for the new site, but it + * will not send the onLocationChange until we are sure that we are loading + * this new page here. + * + * @param aWebProgress + * The nsIWebProgress instance that fired the notification. + * @param aRequest + * The associated nsIRequest. This may be null in some cases. + * @param aLocation + * The URI of the location that is being loaded. + * @param aFlags + * This is a value which explains the situation or the reason why + * the location has changed. + */ + void onFrameLocationChange(in nsIWebProgress aWebProgress, + in nsIRequest aRequest, + in nsIURI aLocation, + [optional] in unsigned long aFlags); };