tor-browser

The Tor Browser
git clone https://git.dasho.dev/tor-browser.git
Log | Files | Refs | README | LICENSE

commit 1d7618633a0ed89ba69cc4666b16581e56f2c5da
parent 9525f21bd79b881116d440c89cdc3c8cc1a2c09c
Author: Vincent Hilla <vhilla@mozilla.com>
Date:   Tue, 16 Dec 2025 18:12:05 +0000

Bug 1858562 - Part 1: Implement Document Picture-in-Picture Spec. r=edgar,smaug,dom-core,webidl,firefox-style-system-reviewers,layout-reviewers,emilio

Differential Revision: https://phabricator.services.mozilla.com/D265290

Diffstat:
Mdocshell/base/BrowsingContext.cpp | 30++++++++++++++++++++++++++++++
Mdocshell/base/BrowsingContext.h | 17++++++++++++++++-
Mdocshell/base/WindowContext.cpp | 46++++++++++++++++++++++++++++++++++++++++++++--
Mdocshell/base/nsDocShell.cpp | 13+++++++++++++
Mdom/base/Document.cpp | 100+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Mdom/base/Document.h | 3+++
Mdom/base/nsGlobalWindowInner.cpp | 11+++++++++++
Mdom/base/nsGlobalWindowInner.h | 9+++++++++
Mdom/base/nsGlobalWindowOuter.cpp | 63++++++++++++++++++++++++++++++++++++++++++++++++++++++++++-----
Mdom/base/nsGlobalWindowOuter.h | 3++-
Mdom/base/nsPIDOMWindow.h | 4++++
Mdom/chrome-webidl/BrowsingContext.webidl | 1+
Adom/documentpip/DocumentPictureInPicture.cpp | 362+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adom/documentpip/DocumentPictureInPicture.h | 69+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Adom/documentpip/moz.build | 18++++++++++++++++++
Adom/documentpip/tests/browser/browser.toml | 5+++++
Adom/documentpip/tests/browser/browser_pip_closes_once.js | 26++++++++++++++++++++++++++
Adom/documentpip/tests/browser/head.js | 22++++++++++++++++++++++
Mdom/locales/en-US/chrome/dom/dom.properties | 2++
Mdom/moz.build | 1+
Adom/webidl/DocumentPictureInPicture.webidl | 23+++++++++++++++++++++++
Adom/webidl/DocumentPictureInPictureEvent.webidl | 18++++++++++++++++++
Mdom/webidl/Window.webidl | 6++++++
Mdom/webidl/moz.build | 3+++
Mlayout/base/nsDocumentViewer.cpp | 5+++++
Mlayout/style/nsMediaFeatures.cpp | 21++++++++++++---------
Mlayout/style/test/test_media_queries.html | 6++++++
Mmodules/libpref/init/StaticPrefList.yaml | 10++++++++++
Mservo/components/style/gecko/media_features.rs | 7+++++++
Mtesting/web-platform/meta/css/mediaqueries/display-mode.html.ini | 5+++--
Mtesting/web-platform/meta/document-picture-in-picture/__dir__.ini | 5+++--
Dtesting/web-platform/meta/document-picture-in-picture/base-uri.https.html.ini | 3---
Dtesting/web-platform/meta/document-picture-in-picture/beforeunload-is-disabled.https.html.ini | 3---
Dtesting/web-platform/meta/document-picture-in-picture/clears-session-on-close.https.html.ini | 3---
Mtesting/web-platform/meta/document-picture-in-picture/closes-on-navigation-or-destroy.https.html.ini | 18+-----------------
Dtesting/web-platform/meta/document-picture-in-picture/copy-document-mode-quirks.https.html.ini | 3---
Dtesting/web-platform/meta/document-picture-in-picture/copy-document-mode.https.html.ini | 3---
Dtesting/web-platform/meta/document-picture-in-picture/display-mode.https.html.ini | 3---
Dtesting/web-platform/meta/document-picture-in-picture/enter-event.https.html.ini | 3---
Dtesting/web-platform/meta/document-picture-in-picture/focus-opener.https.html.ini | 3---
Dtesting/web-platform/meta/document-picture-in-picture/iframe-document-pip.https.html.ini | 3---
Dtesting/web-platform/meta/document-picture-in-picture/open-pip-window-from-pip-window.https.html.ini | 3---
Dtesting/web-platform/meta/document-picture-in-picture/pip-fullscreen.tentative.https.html.ini | 6------
Dtesting/web-platform/meta/document-picture-in-picture/pip-move.tentative.https.html.ini | 3---
Mtesting/web-platform/meta/document-picture-in-picture/pip-receives-focus.https.html.ini | 3++-
Dtesting/web-platform/meta/document-picture-in-picture/pip-resize.https.html.ini | 6------
Dtesting/web-platform/meta/document-picture-in-picture/pip-size.optional.https.html.ini | 9---------
Dtesting/web-platform/meta/document-picture-in-picture/propagate-user-activation-from-opener.https.html.ini | 9---------
Dtesting/web-platform/meta/document-picture-in-picture/propagate-user-activation-to-opener.https.html.ini | 12------------
Dtesting/web-platform/meta/document-picture-in-picture/requires-user-gesture.https.html.ini | 3---
Dtesting/web-platform/meta/document-picture-in-picture/requires-width-and-height-to-both-be-specified.https.html.ini | 6------
Dtesting/web-platform/meta/document-picture-in-picture/returns-window-with-document.https.html.ini | 9---------
Mtesting/web-platform/tests/document-picture-in-picture/base-uri.https.html | 8--------
Mtesting/web-platform/tests/document-picture-in-picture/display-mode.https.html | 7-------
Mtesting/web-platform/tests/document-picture-in-picture/focus-opener.https.html | 17++++++++++-------
Mtesting/web-platform/tests/document-picture-in-picture/pip-fullscreen.tentative.https.html | 7-------
Mtesting/web-platform/tests/document-picture-in-picture/pip-resize.https.html | 4++--
Mtesting/web-platform/tests/document-picture-in-picture/propagate-user-activation-from-opener.https.html | 6------
Mtesting/web-platform/tests/document-picture-in-picture/propagate-user-activation-to-opener.https.html | 35+++--------------------------------
Mtesting/web-platform/tests/document-picture-in-picture/returns-window-with-document.https.html | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Mtesting/web-platform/tests/document-picture-in-picture/support/popup-opens-pip.html | 5-----
Mtoolkit/components/browser/nsIWebBrowserChrome.idl | 16++++++++++++++++
Mtoolkit/components/windowwatcher/nsWindowWatcher.cpp | 24+++++++++++++++++++-----
Mtoolkit/components/windowwatcher/nsWindowWatcher.h | 2+-
64 files changed, 993 insertions(+), 215 deletions(-)

diff --git a/docshell/base/BrowsingContext.cpp b/docshell/base/BrowsingContext.cpp @@ -30,6 +30,7 @@ #include "mozilla/dom/ContentChild.h" #include "mozilla/dom/ContentParent.h" #include "mozilla/dom/Document.h" +#include "mozilla/dom/DocumentPictureInPicture.h" #include "mozilla/dom/Element.h" #include "mozilla/dom/Geolocation.h" #include "mozilla/dom/HTMLEmbedElement.h" @@ -2680,6 +2681,28 @@ void BrowsingContext::IncrementHistoryEntryCountForBrowsingContext() { (void)SetHistoryEntryCount(GetHistoryEntryCount() + 1); } +// https://wicg.github.io/document-picture-in-picture/#focusing-the-opener-window +static bool ConsumePiPWindowTransientActivation(nsPIDOMWindowOuter* outer) { + NS_ENSURE_TRUE(outer, false); + + nsPIDOMWindowInner* inner = outer->GetCurrentInnerWindow(); + NS_ENSURE_TRUE(inner, false); + + DocumentPictureInPicture* dpip = inner->GetExtantDocumentPictureInPicture(); + if (!dpip) { + return false; + } + nsGlobalWindowInner* pipWindow = dpip->GetWindow(); + if (!pipWindow) { + return false; + } + + WindowContext* wc = pipWindow->GetWindowContext(); + NS_ENSURE_TRUE(wc, false); + + return wc->ConsumeTransientUserGestureActivation(); +} + std::tuple<bool, bool> BrowsingContext::CanFocusCheck(CallerType aCallerType) { nsFocusManager* fm = nsFocusManager::GetFocusManager(); if (!fm) { @@ -2703,6 +2726,13 @@ std::tuple<bool, bool> BrowsingContext::CanFocusCheck(CallerType aCallerType) { PopupBlocker::openBlocked; } + // Allow the opener to get system focus if the PIP window has transient + // activation + if (!canFocus && IsTopContent() && + ConsumePiPWindowTransientActivation(GetDOMWindow())) { + canFocus = true; + } + bool isActive = false; if (XRE_IsParentProcess()) { CanonicalBrowsingContext* chromeTop = Canonical()->TopCrossChromeBoundary(); diff --git a/docshell/base/BrowsingContext.h b/docshell/base/BrowsingContext.h @@ -293,7 +293,10 @@ struct EmbedderColorSchemes { FIELD(IPAddressSpace, nsILoadInfo::IPAddressSpace) \ /* This is true if we should redirect to an error page when inserting * \ * meta tags flagging adult content into our documents */ \ - FIELD(ParentalControlsEnabled, bool) + FIELD(ParentalControlsEnabled, bool) \ + /* If true, this traversable is a Document Picture-in-Picture and \ + is subject to certain restrictions */ \ + FIELD(IsDocumentPiP, bool) // BrowsingContext, in this context, is the cross process replicated // environment in which information about documents is stored. In @@ -1485,6 +1488,10 @@ class BrowsingContext : public nsILoadContext, public nsWrapperCache { return XRE_IsParentProcess(); } + bool CanSet(FieldIndex<IDX_IsDocumentPiP>, bool, ContentParent*) { + return IsTop(); + } + // Overload `DidSet` to get notifications for a particular field being set. // // You can also overload the variant that gets the old value if you need it. @@ -1506,6 +1513,14 @@ class BrowsingContext : public nsILoadContext, public nsWrapperCache { void DidSet(FieldIndex<IDX_ForceOffline>, bool aOldValue); + void DidSet(FieldIndex<IDX_IsDocumentPiP>, bool aWasPiP) { + if (GetIsDocumentPiP() && !aWasPiP) { + SetDisplayMode(DisplayMode::Picture_in_picture, IgnoreErrors()); + } else if (!GetIsDocumentPiP() && aWasPiP) { + MOZ_ASSERT_UNREACHABLE("BrowsingContext should never leave PiP mode"); + } + } + // Allow if the process attemping to set field is the same as the owning // process. Deprecated. New code that might use this should generally be moved // to WindowContext or be settable only by the parent process. diff --git a/docshell/base/WindowContext.cpp b/docshell/base/WindowContext.cpp @@ -12,6 +12,7 @@ #include "mozilla/dom/BrowsingContext.h" #include "mozilla/dom/CloseWatcherManager.h" #include "mozilla/dom/Document.h" +#include "mozilla/dom/DocumentPictureInPicture.h" #include "mozilla/dom/UserActivationIPCUtils.h" #include "mozilla/dom/WorkerCommon.h" #include "mozilla/PermissionDelegateIPCUtils.h" @@ -614,6 +615,44 @@ bool WindowContext::HasValidTransientUserGestureActivation() { (TimeStamp::Now() - mLastActivationTimestamp) <= timeout; } +template <typename F> +static void ConsumeUserGestureActivationBetweenPiP(BrowsingContext* aTop, + F&& aCallback) { + // https://wicg.github.io/document-picture-in-picture/#user-activation-propagation + // Monkey patch to consume user activation + if (aTop->GetIsDocumentPiP()) { + // 4. If top is a PIP window, then extend navigables with the opener + // window's inclusive decendant navigables + RefPtr<BrowsingContext> opener = aTop->GetOpener(); + if (!opener) { + return; + } + opener->GetBrowsingContext()->PreOrderWalk(aCallback); + } else { + // 5. Get top-level navigable's last opened PiP window + nsPIDOMWindowOuter* outer = aTop->GetDOMWindow(); + NS_ENSURE_TRUE_VOID(outer); + nsPIDOMWindowInner* inner = outer->GetCurrentInnerWindow(); + NS_ENSURE_TRUE_VOID(inner); + DocumentPictureInPicture* dpip = inner->GetExtantDocumentPictureInPicture(); + if (!dpip) { + return; + } + nsGlobalWindowInner* pip = dpip->GetWindow(); + if (!pip) { + return; + } + + // 6. Extend navigables with the inclusive descendant navigables of the PIP + // window. + BrowsingContext* pipBC = pip->GetBrowsingContext(); + NS_ENSURE_TRUE_VOID(pipBC); + WindowContext* pipWC = pipBC->GetCurrentWindowContext(); + NS_ENSURE_TRUE_VOID(pipWC); + pipBC->PreOrderWalk(aCallback); + } +} + // https://html.spec.whatwg.org/#consume-user-activation bool WindowContext::ConsumeTransientUserGestureActivation() { MOZ_ASSERT(IsInProcess()); @@ -631,7 +670,7 @@ bool WindowContext::ConsumeTransientUserGestureActivation() { // 3. Let navigables be the inclusive descendant navigables of top's active // document. - top->PreOrderWalk([&](BrowsingContext* aBrowsingContext) { + auto callback = [&](BrowsingContext* aBrowsingContext) { // 4. Let windows be the list of Window objects constructed by taking the // active window of each item in navigables. WindowContext* windowContext = aBrowsingContext->GetCurrentWindowContext(); @@ -650,7 +689,10 @@ bool WindowContext::ConsumeTransientUserGestureActivation() { (void)windowContext->SetUserActivationStateAndModifiers( stateAndModifiers.GetRawData()); } - }); + }; + top->PreOrderWalk(callback); + + ConsumeUserGestureActivationBetweenPiP(top, callback); return true; } diff --git a/docshell/base/nsDocShell.cpp b/docshell/base/nsDocShell.cpp @@ -10929,6 +10929,19 @@ nsresult nsDocShell::DoURILoad(nsDocShellLoadState* aLoadState, aLoadState->PrincipalToInherit()) && !shouldSkipSyncLoadForSHRestore(); + if (!isAboutBlankLoadOntoInitialAboutBlank) { + // https://wicg.github.io/document-picture-in-picture/#close-on-navigate + if (Document* doc = GetExtantDocument()) { + NS_DispatchToMainThread(NS_NewRunnableFunction( + "Close PIP window on navigate", [doc = RefPtr(doc)]() { + doc->CloseAnyAssociatedDocumentPiPWindows(); + })); + } + if (GetBrowsingContext()->GetIsDocumentPiP()) { + return NS_OK; + } + } + // FIXME We still have a ton of codepaths that don't pass through // DocumentLoadListener, so probably need to create session history info // in more places. diff --git a/dom/base/Document.cpp b/dom/base/Document.cpp @@ -162,6 +162,7 @@ #include "mozilla/dom/DocumentFragment.h" #include "mozilla/dom/DocumentInlines.h" #include "mozilla/dom/DocumentL10n.h" +#include "mozilla/dom/DocumentPictureInPicture.h" #include "mozilla/dom/DocumentTimeline.h" #include "mozilla/dom/DocumentType.h" #include "mozilla/dom/ElementBinding.h" @@ -12269,6 +12270,35 @@ bool Document::CanSavePresentation(nsIRequest* aNewRequest, return ret; } +// https://wicg.github.io/document-picture-in-picture/#close-any-associated-document-picture-in-picture-windows +void Document::CloseAnyAssociatedDocumentPiPWindows() { + BrowsingContext* bc = GetBrowsingContext(); + if (!bc || !bc->IsTop()) { + return; + } + + // 3. Close us if we're a PIP window + // Note that this method is called when the opener or pip document is + // destroyed, which might mean the PiP is already closing. + if (bc->GetIsDocumentPiP() && !bc->GetClosed()) { + if (IsUncommittedInitialDocument()) { + // Don't close us if we're just doing the initial about:blank load. + return; + } + return bc->Close(CallerType::System, IgnoreErrors()); + } + + // 4,5. Close a PIP window opened by us + if (nsPIDOMWindowInner* inner = GetInnerWindow()) { + if (DocumentPictureInPicture* dpip = + inner->GetExtantDocumentPictureInPicture()) { + if (RefPtr<nsGlobalWindowInner> pipWindow = dpip->GetWindow()) { + pipWindow->Close(); + } + } + } +} + void Document::Destroy() { // The DocumentViewer wants to release the document now. So, tell our content // to drop any references to the document so that it can be destroyed. @@ -12679,6 +12709,9 @@ void Document::OnPageHide(bool aPersisted, EventTarget* aDispatchStartTarget, mVisible = false; } + // https://wicg.github.io/document-picture-in-picture/#close-on-destroy + CloseAnyAssociatedDocumentPiPWindows(); + PointerLockManager::Unlock("Document::OnPageHide", this); if (!mIsBeingUsedAsImage) { @@ -16450,6 +16483,12 @@ const char* Document::GetFullscreenError(CallerType aCallerType) { return "FullscreenDeniedDisabled"; } + BrowsingContext* bc = GetBrowsingContext(); + // https://github.com/WICG/document-picture-in-picture/issues/133 + if (!bc || bc->Top()->GetIsDocumentPiP()) { + return "FullscreenDeniedPiP"; + } + if (aCallerType == CallerType::System) { // Chrome code can always use the fullscreen API, provided it's not // explicitly disabled. @@ -16466,8 +16505,7 @@ const char* Document::GetFullscreenError(CallerType aCallerType) { // Ensure that all containing elements are <iframe> and have allowfullscreen // attribute set. - BrowsingContext* bc = GetBrowsingContext(); - if (!bc || !bc->FullscreenAllowed()) { + if (!bc->FullscreenAllowed()) { return "FullscreenDeniedContainerNotAllowed"; } @@ -18369,6 +18407,62 @@ BrowsingContext* Document::GetBrowsingContext() const { : nullptr; } +static void PropagateUserGestureActivationBetweenPiP( + BrowsingContext* currentBC, UserActivation::Modifiers aModifiers) { + // https://wicg.github.io/document-picture-in-picture/#user-activation-propagation + // Monkey patch to activation notification + if (currentBC->Top()->GetIsDocumentPiP()) { + // 5. If we are in a PIP window, give transient activation to the opener + // window + // This means activation in a cross-origin subframe in the PIP window + // will cause the opener to get activation. + RefPtr<BrowsingContext> opener = currentBC->Top()->GetOpener(); + if (!opener) { + return; + } + WindowContext* wc = opener->GetCurrentWindowContext(); + NS_ENSURE_TRUE_VOID(wc); + wc->NotifyUserGestureActivation(aModifiers); + } else { + // 6. Get top-level navigable's last opened PiP window + // this means activation in a cross-origin subframe in the opener will + // cause the PIP window to get activation. + nsPIDOMWindowOuter* outer = currentBC->Top()->GetDOMWindow(); + NS_ENSURE_TRUE_VOID(outer); + nsPIDOMWindowInner* inner = outer->GetCurrentInnerWindow(); + NS_ENSURE_TRUE_VOID(inner); + DocumentPictureInPicture* dpip = inner->GetExtantDocumentPictureInPicture(); + if (!dpip) { + return; + } + nsGlobalWindowInner* pip = dpip->GetWindow(); + if (!pip) { + return; + } + + // 7. Give transient activation to the pip window and it's same origin + // descendants + BrowsingContext* pipBC = pip->GetBrowsingContext(); + NS_ENSURE_TRUE_VOID(pipBC); + WindowContext* pipWC = pipBC->GetCurrentWindowContext(); + NS_ENSURE_TRUE_VOID(pipWC); + pipBC->PreOrderWalk([&](BrowsingContext* bc) { + WindowContext* wc = bc->GetCurrentWindowContext(); + if (!wc) { + return; + } + + // Check same-origin as current document + WindowGlobalChild* wgc = wc->GetWindowGlobalChild(); + if (!wgc || !wgc->IsSameOriginWith(pipWC)) { + return; + } + + wc->NotifyUserGestureActivation(aModifiers); + }); + } +} + void Document::NotifyUserGestureActivation( UserActivation::Modifiers aModifiers /* = UserActivation::Modifiers::None() */) { @@ -18415,6 +18509,8 @@ void Document::NotifyUserGestureActivation( wc->NotifyUserGestureActivation(aModifiers); }); + PropagateUserGestureActivationBetweenPiP(currentBC, aModifiers); + // If there has been a user activation, mark the current session history entry // as having been interacted with. SetSHEntryHasUserInteraction(true); diff --git a/dom/base/Document.h b/dom/base/Document.h @@ -2506,6 +2506,9 @@ class Document : public nsINode, */ virtual void Destroy(); + // https://wicg.github.io/document-picture-in-picture/#close-on-destroy + void CloseAnyAssociatedDocumentPiPWindows(); + /** * Notify the document that its associated DocumentViewer is no longer * the current viewer for the docshell. The document might still diff --git a/dom/base/nsGlobalWindowInner.cpp b/dom/base/nsGlobalWindowInner.cpp @@ -119,6 +119,7 @@ #include "mozilla/dom/DocGroup.h" #include "mozilla/dom/Document.h" #include "mozilla/dom/DocumentInlines.h" +#include "mozilla/dom/DocumentPictureInPicture.h" #include "mozilla/dom/Element.h" #include "mozilla/dom/Event.h" #include "mozilla/dom/EventTarget.h" @@ -1273,6 +1274,7 @@ void nsGlobalWindowInner::FreeInnerObjects() { mConsole = nullptr; mCookieStore = nullptr; + mDocumentPiP = nullptr; mPaintWorklet = nullptr; @@ -1468,6 +1470,7 @@ NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN_INTERNAL(nsGlobalWindowInner) NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mCrypto) NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mConsole) NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mCookieStore) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mDocumentPiP) NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mPaintWorklet) NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mExternal) NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mIntlUtils) @@ -7403,6 +7406,14 @@ already_AddRefed<CookieStore> nsGlobalWindowInner::CookieStore() { return do_AddRef(mCookieStore); } +DocumentPictureInPicture* nsGlobalWindowInner::DocumentPictureInPicture() { + if (!mDocumentPiP) { + mDocumentPiP = MakeRefPtr<class DocumentPictureInPicture>(this); + } + + return mDocumentPiP; +} + bool nsGlobalWindowInner::IsSecureContext() const { JS::Realm* realm = js::GetNonCCWObjectRealm(GetWrapperPreserveColor()); return JS::GetIsSecureContext(realm); diff --git a/dom/base/nsGlobalWindowInner.h b/dom/base/nsGlobalWindowInner.h @@ -110,6 +110,7 @@ class Crypto; class CustomElementRegistry; class DataTransfer; class DocGroup; +class DocumentPictureInPicture; class External; class FunctionOrTrustedScriptOrString; class Gamepad; @@ -668,6 +669,13 @@ class nsGlobalWindowInner final : public mozilla::dom::EventTarget, already_AddRefed<mozilla::dom::CookieStore> CookieStore(); + mozilla::dom::DocumentPictureInPicture* GetExtantDocumentPictureInPicture() + override { + return mDocumentPiP; + } + + mozilla::dom::DocumentPictureInPicture* DocumentPictureInPicture(); + // https://w3c.github.io/webappsec-secure-contexts/#dom-window-issecurecontext bool IsSecureContext() const; @@ -1395,6 +1403,7 @@ class nsGlobalWindowInner final : public mozilla::dom::EventTarget, RefPtr<mozilla::dom::cache::CacheStorage> mCacheStorage; RefPtr<mozilla::dom::Console> mConsole; RefPtr<mozilla::dom::CookieStore> mCookieStore; + RefPtr<mozilla::dom::DocumentPictureInPicture> mDocumentPiP; RefPtr<mozilla::dom::Worklet> mPaintWorklet; RefPtr<mozilla::dom::External> mExternal; diff --git a/dom/base/nsGlobalWindowOuter.cpp b/dom/base/nsGlobalWindowOuter.cpp @@ -29,6 +29,7 @@ #include "mozilla/dom/ContentChild.h" #include "mozilla/dom/ContentFrameMessageManager.h" #include "mozilla/dom/DocumentInlines.h" +#include "mozilla/dom/DocumentPictureInPicture.h" #include "mozilla/dom/EventTarget.h" #include "mozilla/dom/HTMLIFrameElement.h" #include "mozilla/dom/LSObject.h" @@ -4581,7 +4582,13 @@ void nsGlobalWindowOuter::MakeMessageWithPrincipal( } } -bool nsGlobalWindowOuter::CanMoveResizeWindows(CallerType aCallerType) { +bool nsGlobalWindowOuter::CanMoveResizeWindows(CallerType aCallerType, + bool aIsMove, + ErrorResult& aError) { + if (mBrowsingContext->IsSubframe()) { + return false; + } + // When called from chrome, we can avoid the following checks. if (aCallerType != CallerType::System) { // Don't allow scripts to move or resize windows that were not opened by a @@ -4606,6 +4613,25 @@ bool nsGlobalWindowOuter::CanMoveResizeWindows(CallerType aCallerType) { if (NS_SUCCEEDED(rv) && !allow) return false; } + if (mBrowsingContext->GetIsDocumentPiP()) { + // https://wicg.github.io/document-picture-in-picture/#positioning + if (aIsMove) { + nsLiteralString errorMsg( + u"Picture-in-Picture windows cannot be moved by script."); + nsContentUtils::ReportToConsoleNonLocalized( + errorMsg, nsIScriptError::warningFlag, "Window"_ns, GetDocument()); + return false; + } + + // https://wicg.github.io/document-picture-in-picture/#resizing-the-pip-window + WindowContext* wc = mInnerWindow->GetWindowContext(); + if (!wc || !wc->ConsumeTransientUserGestureActivation()) { + aError.ThrowNotAllowedError( + "Resizing a Picture-in-Picture window requires transient activation"); + return false; + } + } + if (nsGlobalWindowInner::sMouseDown && !nsGlobalWindowInner::sDragServiceDisabled) { nsCOMPtr<nsIDragService> ds = @@ -5223,7 +5249,7 @@ void nsGlobalWindowOuter::MoveToOuter(int32_t aXPos, int32_t aYPos, * prevent window.moveTo() by exiting early */ - if (!CanMoveResizeWindows(aCallerType) || mBrowsingContext->IsSubframe()) { + if (!CanMoveResizeWindows(aCallerType, true, aError)) { return; } @@ -5259,7 +5285,7 @@ void nsGlobalWindowOuter::MoveByOuter(int32_t aXDif, int32_t aYDif, * prevent window.moveBy() by exiting early */ - if (!CanMoveResizeWindows(aCallerType) || mBrowsingContext->IsSubframe()) { + if (!CanMoveResizeWindows(aCallerType, true, aError)) { return; } @@ -5308,7 +5334,7 @@ void nsGlobalWindowOuter::ResizeToOuter(int32_t aWidth, int32_t aHeight, * prevent window.resizeTo() by exiting early */ - if (!CanMoveResizeWindows(aCallerType) || mBrowsingContext->IsSubframe()) { + if (!CanMoveResizeWindows(aCallerType, false, aError)) { return; } @@ -5319,6 +5345,20 @@ void nsGlobalWindowOuter::ResizeToOuter(int32_t aWidth, int32_t aHeight, } CSSIntSize cssSize(aWidth, aHeight); + + if (mBrowsingContext->GetIsDocumentPiP()) { + if (Maybe<CSSIntRect> screen = + DocumentPictureInPicture::GetScreenRect(this)) { + CSSIntSize maxSize = + DocumentPictureInPicture::CalcMaxDimensions(screen.value()); + cssSize.width = std::min(cssSize.width, maxSize.width); + cssSize.height = std::min(cssSize.height, maxSize.height); + } else { + aError.Throw(NS_ERROR_FAILURE); + return; + } + } + CheckSecurityWidthAndHeight(&cssSize.width, &cssSize.height, aCallerType); LayoutDeviceIntSize devSize = @@ -5336,7 +5376,7 @@ void nsGlobalWindowOuter::ResizeByOuter(int32_t aWidthDif, int32_t aHeightDif, * prevent window.resizeBy() by exiting early */ - if (!CanMoveResizeWindows(aCallerType) || mBrowsingContext->IsSubframe()) { + if (!CanMoveResizeWindows(aCallerType, false, aError)) { return; } @@ -5358,6 +5398,19 @@ void nsGlobalWindowOuter::ResizeByOuter(int32_t aWidthDif, int32_t aHeightDif, cssSize.width += aWidthDif; cssSize.height += aHeightDif; + if (mBrowsingContext->GetIsDocumentPiP()) { + if (Maybe<CSSIntRect> screen = + DocumentPictureInPicture::GetScreenRect(this)) { + CSSIntSize maxSize = + DocumentPictureInPicture::CalcMaxDimensions(screen.value()); + cssSize.width = std::min(cssSize.width, maxSize.width); + cssSize.height = std::min(cssSize.height, maxSize.height); + } else { + aError.Throw(NS_ERROR_FAILURE); + return; + } + } + CheckSecurityWidthAndHeight(&cssSize.width, &cssSize.height, aCallerType); LayoutDeviceIntSize newDevSize = RoundedToInt(cssSize * scale); diff --git a/dom/base/nsGlobalWindowOuter.h b/dom/base/nsGlobalWindowOuter.h @@ -781,7 +781,8 @@ class nsGlobalWindowOuter final : public mozilla::dom::EventTarget, // Outer windows only. MOZ_CAN_RUN_SCRIPT_BOUNDARY - bool CanMoveResizeWindows(mozilla::dom::CallerType aCallerType); + bool CanMoveResizeWindows(mozilla::dom::CallerType aCallerType, bool aIsMove, + mozilla::ErrorResult& aError); // If aDoFlush is true, we'll flush our own layout; otherwise we'll try to // just flush our parent and only flush ourselves if we think we need to. diff --git a/dom/base/nsPIDOMWindow.h b/dom/base/nsPIDOMWindow.h @@ -72,6 +72,7 @@ class WebIdentityHandler; class WindowContext; class WindowGlobalChild; class CustomElementRegistry; +class DocumentPictureInPicture; enum class CallerType : uint32_t; } // namespace mozilla::dom @@ -629,6 +630,9 @@ class nsPIDOMWindowInner : public mozIDOMWindow { // Called when a CloseWatcher is removed from the manager void NotifyCloseWatcherRemoved(); + virtual mozilla::dom::DocumentPictureInPicture* + GetExtantDocumentPictureInPicture() = 0; + protected: void CreatePerformanceObjectIfNeeded(); diff --git a/dom/chrome-webidl/BrowsingContext.webidl b/dom/chrome-webidl/BrowsingContext.webidl @@ -43,6 +43,7 @@ enum DisplayMode { "minimal-ui", "standalone", "fullscreen", + "picture-in-picture" }; /** diff --git a/dom/documentpip/DocumentPictureInPicture.cpp b/dom/documentpip/DocumentPictureInPicture.cpp @@ -0,0 +1,362 @@ +/* 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 https://mozilla.org/MPL/2.0/. */ + +#include "mozilla/dom/DocumentPictureInPicture.h" + +#include "mozilla/AsyncEventDispatcher.h" +#include "mozilla/WidgetUtils.h" +#include "mozilla/dom/BrowserChild.h" +#include "mozilla/dom/Document.h" +#include "mozilla/dom/DocumentPictureInPictureEvent.h" +#include "mozilla/dom/WindowContext.h" +#include "mozilla/widget/Screen.h" +#include "nsDocShell.h" +#include "nsDocShellLoadState.h" +#include "nsIWindowWatcher.h" +#include "nsNetUtil.h" +#include "nsPIWindowWatcher.h" +#include "nsServiceManagerUtils.h" +#include "nsWindowWatcher.h" + +namespace mozilla::dom { + +static mozilla::LazyLogModule gDPIPLog("DocumentPIP"); + +NS_IMPL_CYCLE_COLLECTION_CLASS(DocumentPictureInPicture) + +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN_INHERITED(DocumentPictureInPicture, + DOMEventTargetHelper) + NS_IMPL_CYCLE_COLLECTION_TRAVERSE(mLastOpenedWindow) +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END + +NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN_INHERITED(DocumentPictureInPicture, + DOMEventTargetHelper) + NS_IMPL_CYCLE_COLLECTION_UNLINK(mLastOpenedWindow) +NS_IMPL_CYCLE_COLLECTION_UNLINK_END + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(DocumentPictureInPicture) + NS_INTERFACE_MAP_ENTRY(nsIObserver) + NS_INTERFACE_MAP_ENTRY(nsIDOMEventListener) +NS_INTERFACE_MAP_END_INHERITING(DOMEventTargetHelper) + +NS_IMPL_ADDREF_INHERITED(DocumentPictureInPicture, DOMEventTargetHelper) +NS_IMPL_RELEASE_INHERITED(DocumentPictureInPicture, DOMEventTargetHelper) + +JSObject* DocumentPictureInPicture::WrapObject( + JSContext* cx, JS::Handle<JSObject*> aGivenProto) { + return DocumentPictureInPicture_Binding::Wrap(cx, this, aGivenProto); +} + +DocumentPictureInPicture::DocumentPictureInPicture(nsPIDOMWindowInner* aWindow) + : DOMEventTargetHelper(aWindow) { + nsCOMPtr<nsIObserverService> os = mozilla::services::GetObserverService(); + NS_ENSURE_TRUE_VOID(os); + DebugOnly<nsresult> rv = os->AddObserver(this, "domwindowclosed", false); + MOZ_ASSERT(NS_SUCCEEDED(rv)); +} + +DocumentPictureInPicture::~DocumentPictureInPicture() { + nsCOMPtr<nsIObserverService> os = mozilla::services::GetObserverService(); + NS_ENSURE_TRUE_VOID(os); + DebugOnly<nsresult> rv = os->RemoveObserver(this, "domwindowclosed"); + MOZ_ASSERT(NS_SUCCEEDED(rv)); +} + +void DocumentPictureInPicture::OnPiPResized() { + if (!mLastOpenedWindow) { + return; + } + + RefPtr<nsGlobalWindowInner> innerWindow = + nsGlobalWindowInner::Cast(mLastOpenedWindow); + + int x = innerWindow->GetScreenLeft(CallerType::System, IgnoreErrors()); + int y = innerWindow->GetScreenTop(CallerType::System, IgnoreErrors()); + int width = static_cast<int>(innerWindow->GetInnerWidth(IgnoreErrors())); + int height = static_cast<int>(innerWindow->GetInnerHeight(IgnoreErrors())); + + mPreviousExtent = Some(CSSIntRect(x, y, width, height)); + + MOZ_LOG(gDPIPLog, LogLevel::Debug, + ("PiP was resized, remembering position %s", + ToString(mPreviousExtent).c_str())); +} + +void DocumentPictureInPicture::OnPiPClosed() { + if (!mLastOpenedWindow) { + return; + } + + RefPtr<nsGlobalWindowInner> pipInnerWindow = + nsGlobalWindowInner::Cast(mLastOpenedWindow); + pipInnerWindow->RemoveSystemEventListener(u"resize"_ns, this, true); + + MOZ_LOG(gDPIPLog, LogLevel::Debug, ("PiP was closed")); + + mLastOpenedWindow = nullptr; +} + +nsGlobalWindowInner* DocumentPictureInPicture::GetWindow() { + if (mLastOpenedWindow && mLastOpenedWindow->GetOuterWindow() && + !mLastOpenedWindow->GetOuterWindow()->Closed()) { + return nsGlobalWindowInner::Cast(mLastOpenedWindow); + } + return nullptr; +} + +// Some sane default. Maybe we should come up with an heuristic based on screen +// size. +const CSSIntSize DocumentPictureInPicture::sDefaultSize = {700, 650}; +const CSSIntSize DocumentPictureInPicture::sMinSize = {240, 50}; + +static nsresult OpenPiPWindowUtility(nsPIDOMWindowOuter* aParent, + const CSSIntRect& aExtent, bool aPrivate, + mozilla::dom::BrowsingContext** aRet) { + MOZ_DIAGNOSTIC_ASSERT(aParent); + + nsresult rv = NS_OK; + nsCOMPtr<nsIWindowWatcher> ww = + do_GetService(NS_WINDOWWATCHER_CONTRACTID, &rv); + NS_ENSURE_SUCCESS(rv, rv); + + nsCOMPtr<nsPIWindowWatcher> pww(do_QueryInterface(ww)); + NS_ENSURE_TRUE(pww, NS_ERROR_FAILURE); + + nsCOMPtr<nsIURI> uri; + rv = NS_NewURI(getter_AddRefs(uri), "about:blank"_ns, nullptr); + NS_ENSURE_SUCCESS(rv, rv); + + RefPtr<nsDocShellLoadState> loadState = + nsWindowWatcher::CreateLoadState(uri, aParent); + + // pictureinpicture is a non-standard window feature not available from JS + nsPrintfCString features("pictureinpicture,top=%d,left=%d,width=%d,height=%d", + aExtent.y, aExtent.x, aExtent.width, aExtent.height); + + rv = pww->OpenWindow2(aParent, uri, "_blank"_ns, features, + mozilla::dom::UserActivation::Modifiers::None(), false, + false, true, nullptr, false, false, false, + nsPIWindowWatcher::PrintKind::PRINT_NONE, loadState, + aRet); + NS_ENSURE_SUCCESS(rv, rv); + NS_ENSURE_TRUE(aRet, NS_ERROR_FAILURE); + return NS_OK; +} + +/* static */ +Maybe<CSSIntRect> DocumentPictureInPicture::GetScreenRect( + nsPIDOMWindowOuter* aWindow) { + nsCOMPtr<nsIWidget> widget = widget::WidgetUtils::DOMWindowToWidget(aWindow); + NS_ENSURE_TRUE(widget, Nothing()); + RefPtr<widget::Screen> screen = widget->GetWidgetScreen(); + NS_ENSURE_TRUE(screen, Nothing()); + LayoutDeviceIntRect rect = screen->GetRect(); + + nsGlobalWindowOuter* outerWindow = nsGlobalWindowOuter::Cast(aWindow); + NS_ENSURE_TRUE(outerWindow, Nothing()); + nsCOMPtr<nsIBaseWindow> treeOwnerAsWin = outerWindow->GetTreeOwnerWindow(); + NS_ENSURE_TRUE(treeOwnerAsWin, Nothing()); + auto scale = outerWindow->CSSToDevScaleForBaseWindow(treeOwnerAsWin); + + return Some(RoundedToInt(rect / scale)); +} + +// Place window in the bottom right of the opener window's screen +static CSSIntPoint CalcInitialPos(const CSSIntRect& screen, + const CSSIntSize& aSize) { + // aSize is the inner size not including browser UI. But we need the outer + // size for calculating where the top left corner of the PiP should be + // initially. For now use a guess of ~80px for the browser UI? + return {std::max(screen.X(), screen.XMost() - aSize.width - 100), + std::max(screen.Y(), screen.YMost() - aSize.height - 100 - 80)}; +} + +/* static */ +CSSIntSize DocumentPictureInPicture::CalcMaxDimensions( + const CSSIntRect& screen) { + // Limit PIP size to 80% (arbitrary number) of screen size + // https://wicg.github.io/document-picture-in-picture/#maximum-size + CSSIntSize size = + RoundedToInt(screen.Size() * gfx::ScaleFactor<CSSPixel, CSSPixel>(0.8)); + size.width = std::max(size.width, sMinSize.width); + size.height = std::max(size.height, sMinSize.height); + return size; +} + +CSSIntRect DocumentPictureInPicture::DetermineExtent( + bool aPreferInitialWindowPlacement, int aRequestedWidth, + int aRequestedHeight, const CSSIntRect& screen) { + // If we remembered an extent, don't preferInitialWindowPlacement, and the + // requested size didn't change, then restore the remembered extent. + const bool shouldUseInitialPlacement = + !mPreviousExtent.isSome() || aPreferInitialWindowPlacement || + (mLastRequestedSize.isSome() && + (mLastRequestedSize->Width() != aRequestedWidth || + mLastRequestedSize->Height() != aRequestedHeight)); + + CSSIntRect extent; + if (shouldUseInitialPlacement) { + CSSIntSize size = sDefaultSize; + if (aRequestedWidth > 0 && aRequestedHeight > 0) { + size = CSSIntSize(aRequestedWidth, aRequestedHeight); + } + CSSIntPoint initialPos = CalcInitialPos(screen, size); + extent = CSSIntRect(initialPos, size); + + MOZ_LOG(gDPIPLog, LogLevel::Debug, + ("Calculated initial PiP rect %s", ToString(extent).c_str())); + } else { + extent = mPreviousExtent.value(); + } + + // https://wicg.github.io/document-picture-in-picture/#maximum-size + CSSIntSize maxSize = CalcMaxDimensions(screen); + extent.width = std::clamp(extent.width, sMinSize.width, maxSize.width); + extent.height = std::clamp(extent.height, sMinSize.height, maxSize.height); + + return extent; +} + +already_AddRefed<Promise> DocumentPictureInPicture::RequestWindow( + const DocumentPictureInPictureOptions& aOptions, ErrorResult& aRv) { + // Not part of the spec, but check the document is active + RefPtr<nsPIDOMWindowInner> ownerWin = GetOwnerWindow(); + if (!ownerWin || !ownerWin->IsFullyActive()) { + aRv.ThrowNotAllowedError("Document is not fully active"); + return nullptr; + } + + // 2. Throw if not top-level + BrowsingContext* bc = ownerWin->GetBrowsingContext(); + if (!bc || !bc->IsTop()) { + aRv.ThrowNotAllowedError( + "Document Picture-in-Picture is only available in top-level contexts"); + return nullptr; + } + + // 3. Throw if already in a Document PIP window + if (bc->GetIsDocumentPiP()) { + aRv.ThrowNotAllowedError( + "Cannot open a Picture-in-Picture window from inside one"); + return nullptr; + } + + // 4, 7. Require transient activation + WindowContext* wc = ownerWin->GetWindowContext(); + if (!wc || !wc->ConsumeTransientUserGestureActivation()) { + aRv.ThrowNotAllowedError( + "Document Picture-in-Picture requires user activation"); + return nullptr; + } + + // 5-6. If width or height is given, both must be specified + if ((aOptions.mWidth > 0) != (aOptions.mHeight > 0)) { + aRv.ThrowRangeError( + "requestWindow: width and height must be specified together"); + return nullptr; + } + + // 8. Possibly close last opened window + if (RefPtr<nsPIDOMWindowInner> lastOpenedWindow = mLastOpenedWindow) { + lastOpenedWindow->Close(); + } + + CSSIntRect screen; + if (Maybe<CSSIntRect> maybeScreen = + GetScreenRect(ownerWin->GetOuterWindow())) { + screen = maybeScreen.value(); + } else { + aRv.ThrowRangeError("Could not determine screen for window"); + return nullptr; + } + + // 13-15. Determine PiP extent + const int requestedWidth = SaturatingCast<int>(aOptions.mWidth), + requestedHeight = SaturatingCast<int>(aOptions.mHeight); + CSSIntRect extent = DetermineExtent(aOptions.mPreferInitialWindowPlacement, + requestedWidth, requestedHeight, screen); + mLastRequestedSize = Some(CSSIntSize(requestedWidth, requestedHeight)); + + MOZ_LOG(gDPIPLog, LogLevel::Debug, + ("Will place PiP at rect %s", ToString(extent).c_str())); + + // 9. Optionally, close any existing PIP windows + // I think it's useful to have multiple PiP windows from different top pages. + + // 15. aOptions.mDisallowReturnToOpener + // I think this button is redundant with close and the webpage won't know + // whether close or return was pressed. So let's not have that button at all. + + // 10. Create a new top-level traversable for target _blank + // 16. Configure PIP to float on top via window features + RefPtr<BrowsingContext> pipTraversable; + nsresult rv = OpenPiPWindowUtility(ownerWin->GetOuterWindow(), extent, + bc->UsePrivateBrowsing(), + getter_AddRefs(pipTraversable)); + if (NS_FAILED(rv)) { + aRv.ThrowUnknownError("Failed to create PIP window"); + return nullptr; + } + + // 11. Set PIP's active document's mode to this's document's mode + pipTraversable->GetDocument()->SetCompatibilityMode( + ownerWin->GetDoc()->GetCompatibilityMode()); + + // 12. Set PIP's IsDocumentPIP flag + rv = pipTraversable->SetIsDocumentPiP(true); + MOZ_ASSERT(NS_SUCCEEDED(rv)); + + // 16. Set mLastOpenedWindow + mLastOpenedWindow = pipTraversable->GetDOMWindow()->GetCurrentInnerWindow(); + MOZ_ASSERT(mLastOpenedWindow); + + // Keep track of resizes to update mPreviousExtent + RefPtr<nsGlobalWindowInner> pipInnerWindow = + nsGlobalWindowInner::Cast(mLastOpenedWindow); + pipInnerWindow->AddSystemEventListener(u"resize"_ns, this, true, false); + + // 17. Queue a task to fire a DocumentPictureInPictureEvent named "enter" on + // this with pipTraversable as it's window attribute + DocumentPictureInPictureEventInit eventInit; + eventInit.mWindow = pipInnerWindow; + RefPtr<Event> event = + DocumentPictureInPictureEvent::Constructor(this, u"enter"_ns, eventInit); + RefPtr<AsyncEventDispatcher> asyncDispatcher = + new AsyncEventDispatcher(this, event.forget()); + asyncDispatcher->PostDOMEvent(); + + // 18. Return pipTraversable + RefPtr<Promise> promise = Promise::CreateInfallible(GetOwnerGlobal()); + promise->MaybeResolve(pipInnerWindow); + return promise.forget(); +} + +NS_IMETHODIMP +DocumentPictureInPicture::HandleEvent(Event* aEvent) { + nsAutoString type; + aEvent->GetType(type); + + if (type.EqualsLiteral("resize")) { + OnPiPResized(); + return NS_OK; + } + + return NS_OK; +} + +NS_IMETHODIMP DocumentPictureInPicture::Observe(nsISupports* aSubject, + const char* aTopic, + const char16_t* aData) { + if (nsCRT::strcmp(aTopic, "domwindowclosed") == 0) { + nsCOMPtr<nsPIDOMWindowOuter> subjectWin = do_QueryInterface(aSubject); + NS_ENSURE_TRUE(!!subjectWin, NS_OK); + + if (subjectWin->GetCurrentInnerWindow() == mLastOpenedWindow) { + OnPiPClosed(); + } + } + return NS_OK; +} + +} // namespace mozilla::dom diff --git a/dom/documentpip/DocumentPictureInPicture.h b/dom/documentpip/DocumentPictureInPicture.h @@ -0,0 +1,69 @@ +/* -*- Mode: C++; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* vim: set ts=8 sts=2 et sw=2 tw=80: */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +#ifndef mozilla_dom_documentpip_DocumentPictureInPicture_h +#define mozilla_dom_documentpip_DocumentPictureInPicture_h + +#include "mozilla/DOMEventTargetHelper.h" +#include "mozilla/dom/DocumentPictureInPictureBinding.h" +#include "nsIDOMEventListener.h" + +namespace mozilla::dom { + +class DocumentPictureInPicture final : public DOMEventTargetHelper, + public nsIObserver, + public nsIDOMEventListener { + public: + NS_DECL_NSIDOMEVENTLISTENER + NS_DECL_NSIOBSERVER + + NS_DECL_ISUPPORTS_INHERITED + NS_DECL_CYCLE_COLLECTION_CLASS_INHERITED(DocumentPictureInPicture, + DOMEventTargetHelper) + + explicit DocumentPictureInPicture(nsPIDOMWindowInner* aWindow); + + JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + already_AddRefed<Promise> RequestWindow( + const DocumentPictureInPictureOptions& aOptions, ErrorResult& aRv); + + // Get the current PiP window, exposed as webidl property + nsGlobalWindowInner* GetWindow(); + + IMPL_EVENT_HANDLER(enter); + + static Maybe<CSSIntRect> GetScreenRect(nsPIDOMWindowOuter* aWindow); + + static CSSIntSize CalcMaxDimensions(const CSSIntRect& screen); + + CSSIntRect DetermineExtent(bool aPreferInitialWindowPlacement, + int aRequestedWidth, int aRequestedHeight, + const CSSIntRect& screen); + + private: + ~DocumentPictureInPicture(); + + MOZ_CAN_RUN_SCRIPT void OnPiPResized(); + + void OnPiPClosed(); + + static const CSSIntSize sDefaultSize, sMinSize; + + // The extent of the most recently closed PiP + Maybe<CSSIntRect> mPreviousExtent; + + // The size with which the most recent PiP was requested + Maybe<CSSIntSize> mLastRequestedSize; + + // The currently open PiP (if any) + RefPtr<nsPIDOMWindowInner> mLastOpenedWindow; +}; + +} // namespace mozilla::dom + +#endif // mozilla_dom_documentpip_DocumentPictureInPicture_h diff --git a/dom/documentpip/moz.build b/dom/documentpip/moz.build @@ -0,0 +1,18 @@ +# -*- Mode: python; indent-tabs-mode: nil; tab-width: 40 -*- +# vim: set filetype=python: +# This Source Code Form is subject to the terms of the Mozilla Public +# License, v. 2.0. If a copy of the MPL was not distributed with this +# file, You can obtain one at http://mozilla.org/MPL/2.0/. + +with Files("**"): + BUG_COMPONENT = ("Core", "DOM: Core & HTML") + +EXPORTS.mozilla.dom += ["DocumentPictureInPicture.h"] + +UNIFIED_SOURCES += [ + "DocumentPictureInPicture.cpp", +] + +include("/ipc/chromium/chromium-config.mozbuild") + +FINAL_LIBRARY = "xul" diff --git a/dom/documentpip/tests/browser/browser.toml b/dom/documentpip/tests/browser/browser.toml @@ -0,0 +1,5 @@ +[DEFAULT] +support-files = ["head.js"] +prefs = ["dom.documentpip.enabled=true"] + +["browser_pip_closes_once.js"] diff --git a/dom/documentpip/tests/browser/browser_pip_closes_once.js b/dom/documentpip/tests/browser/browser_pip_closes_once.js @@ -0,0 +1,26 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +"use strict"; + +/** + * See https://wicg.github.io/document-picture-in-picture/ + * When the PiP document is destroyed, we should run + * #close-any-associated-document-picture-in-picture-windows. + * But that usually means the PiP is going away, so if we aren't careful + * we might dispatch DOMWindowClose twice. + */ +add_task(async function closing_pip_sends_exactly_one_DOMWindowClosed() { + const [tab, chromePiP] = await newTabWithPiP(); + + let closeCount = 0; + chromePiP.addEventListener("DOMWindowClose", () => closeCount++); + + await SpecialPowers.spawn(tab.linkedBrowser, [], () => { + content.documentPictureInPicture.window.close(); + }); + await BrowserTestUtils.windowClosed(chromePiP); + + is(closeCount, 1, "Received a single DOMWindowClosed"); + BrowserTestUtils.removeTab(tab); +}); diff --git a/dom/documentpip/tests/browser/head.js b/dom/documentpip/tests/browser/head.js @@ -0,0 +1,22 @@ +/* Any copyright is dedicated to the Public Domain. + http://creativecommons.org/publicdomain/zero/1.0/ */ + +async function newTabWithPiP() { + // Create a tab that can use the API (secure context) + const tab = await BrowserTestUtils.openNewForegroundTab({ + gBrowser, + opening: "https://example.com", + waitForLoad: true, + }); + const browser = tab.linkedBrowser; + + // Open a document PiP window + const chromePiPPromise = BrowserTestUtils.waitForNewWindow(); + await SpecialPowers.spawn(browser, [], async () => { + content.document.notifyUserGestureActivation(); + await content.documentPictureInPicture.requestWindow(); + }); + const chromePiP = await chromePiPPromise; + + return [tab, chromePiP]; +} diff --git a/dom/locales/en-US/chrome/dom/dom.properties b/dom/locales/en-US/chrome/dom/dom.properties @@ -67,6 +67,8 @@ FormValidationStepMismatchOneValue=Please select a valid value. The nearest vali FormValidationTimeReversedRangeUnderflowAndOverflow=Please select a value between %1$S and %2$S. FormValidationBadInputNumber=Please enter a number. FullscreenDeniedDisabled=Request for fullscreen was denied because Fullscreen API is disabled by user preference. +# LOCALIZATION NOTE (FullscreenDeniedPiP): Document Picture-in-Picture is the name of a web API that provides a popup-like always-on-top document. +FullscreenDeniedPiP=Request for fullscreen was denied because the element is in a Document Picture-in-Picture window. FullscreenDeniedHidden=Request for fullscreen was denied because the document is no longer visible. FullscreenDeniedHTMLDialog=Request for fullscreen was denied because requesting element is a <dialog> element. FullscreenDeniedContainerNotAllowed=Request for fullscreen was denied because at least one of the document’s containing elements is not an iframe or does not have an “allowfullscreen” attribute. diff --git a/dom/moz.build b/dom/moz.build @@ -41,6 +41,7 @@ DIRS += [ "credentialmanagement", "crypto", "debugger", + "documentpip", "encoding", "events", "fetch", diff --git a/dom/webidl/DocumentPictureInPicture.webidl b/dom/webidl/DocumentPictureInPicture.webidl @@ -0,0 +1,23 @@ +/* -*- Mode: IDL; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. + * + * The origin of this IDL file is + * https://wicg.github.io/document-picture-in-picture/#api + */ + +[Exposed=(Window), SecureContext, Pref="dom.documentpip.enabled"] +interface DocumentPictureInPicture : EventTarget { + [NewObject] Promise<Window> requestWindow( + optional DocumentPictureInPictureOptions options = {}); + readonly attribute Window? window; + attribute EventHandler onenter; +}; + +dictionary DocumentPictureInPictureOptions { + [EnforceRange] unsigned long long width = 0; + [EnforceRange] unsigned long long height = 0; + boolean disallowReturnToOpener = false; + boolean preferInitialWindowPlacement = false; +}; diff --git a/dom/webidl/DocumentPictureInPictureEvent.webidl b/dom/webidl/DocumentPictureInPictureEvent.webidl @@ -0,0 +1,18 @@ +/* -*- Mode: IDL; tab-width: 2; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. + * + * The origin of this IDL file is + * https://wicg.github.io/document-picture-in-picture/#api + */ + +[Exposed=(Window), SecureContext, Pref="dom.documentpip.enabled"] +interface DocumentPictureInPictureEvent : Event { + constructor(DOMString type, DocumentPictureInPictureEventInit eventInitDict); + [SameObject] readonly attribute Window window; +}; + +dictionary DocumentPictureInPictureEventInit : EventInit { + required Window window; +}; diff --git a/dom/webidl/Window.webidl b/dom/webidl/Window.webidl @@ -915,3 +915,9 @@ partial interface Window { partial interface Window { [Pref="dom.origin_agent_cluster.enabled"] readonly attribute boolean originAgentCluster; }; + +// https://wicg.github.io/document-picture-in-picture/#api +partial interface Window { + [SameObject, SecureContext, Pref="dom.documentpip.enabled"] + readonly attribute DocumentPictureInPicture documentPictureInPicture; +}; diff --git a/dom/webidl/moz.build b/dom/webidl/moz.build @@ -567,6 +567,8 @@ WEBIDL_FILES = [ "Document.webidl", "DocumentFragment.webidl", "DocumentOrShadowRoot.webidl", + "DocumentPictureInPicture.webidl", + "DocumentPictureInPictureEvent.webidl", "DocumentTimeline.webidl", "DocumentType.webidl", "DOMException.webidl", @@ -1220,6 +1222,7 @@ GENERATED_EVENTS_WEBIDL_FILES = [ "ContentVisibilityAutoStateChangeEvent.webidl", "DeviceLightEvent.webidl", "DeviceOrientationEvent.webidl", + "DocumentPictureInPictureEvent.webidl", "ErrorEvent.webidl", "FontFaceSetLoadEvent.webidl", "FormDataEvent.webidl", diff --git a/layout/base/nsDocumentViewer.cpp b/layout/base/nsDocumentViewer.cpp @@ -1149,6 +1149,11 @@ nsDocumentViewer::PermitUnload(PermitUnloadAction aAction, return NS_OK; } + if (bc->GetIsDocumentPiP()) { + // https://wicg.github.io/document-picture-in-picture/#close-document-pip-window + return NS_OK; + } + // Per spec, we need to increase the ignore-opens-during-unload counter while // dispatching the "beforeunload" event on both the document we're currently // dispatching the event to and the document that we explicitly asked to diff --git a/layout/style/nsMediaFeatures.cpp b/layout/style/nsMediaFeatures.cpp @@ -230,15 +230,18 @@ StyleDisplayMode Gecko_MediaFeatures_GetDisplayMode(const Document* aDocument) { } } - static_assert(static_cast<int32_t>(DisplayMode::Browser) == - static_cast<int32_t>(StyleDisplayMode::Browser) && - static_cast<int32_t>(DisplayMode::Minimal_ui) == - static_cast<int32_t>(StyleDisplayMode::MinimalUi) && - static_cast<int32_t>(DisplayMode::Standalone) == - static_cast<int32_t>(StyleDisplayMode::Standalone) && - static_cast<int32_t>(DisplayMode::Fullscreen) == - static_cast<int32_t>(StyleDisplayMode::Fullscreen), - "DisplayMode must mach nsStyleConsts.h"); + static_assert( + static_cast<int32_t>(DisplayMode::Browser) == + static_cast<int32_t>(StyleDisplayMode::Browser) && + static_cast<int32_t>(DisplayMode::Minimal_ui) == + static_cast<int32_t>(StyleDisplayMode::MinimalUi) && + static_cast<int32_t>(DisplayMode::Standalone) == + static_cast<int32_t>(StyleDisplayMode::Standalone) && + static_cast<int32_t>(DisplayMode::Fullscreen) == + static_cast<int32_t>(StyleDisplayMode::Fullscreen) && + static_cast<int32_t>(DisplayMode::Picture_in_picture) == + static_cast<int32_t>(StyleDisplayMode::PictureInPicture), + "DisplayMode must mach nsStyleConsts.h"); dom::BrowsingContext* browsingContext = aDocument->GetBrowsingContext(); if (!browsingContext) { diff --git a/layout/style/test/test_media_queries.html b/layout/style/test/test_media_queries.html @@ -282,6 +282,12 @@ function run() { expression_should_be_known("display-mode: " + type); }); + if (SpecialPowers.getBoolPref("dom.documentpip.enabled")) { + expression_should_be_known("display-mode: picture-in-picture"); + } else { + expression_should_not_be_known("display-mode: picture-in-picture"); + } + expression_should_not_be_known("display-mode: invalid") var content_div = document.getElementById("content"); diff --git a/modules/libpref/init/StaticPrefList.yaml b/modules/libpref/init/StaticPrefList.yaml @@ -3050,6 +3050,16 @@ value: true mirror: always +# Whether the Document PictureInPicture API is enabled +# https://wicg.github.io/document-picture-in-picture/#api +# Always disable on android (unsupported) so that webpages aren't +# confused by the webidl bindings being exposed +- name: dom.documentpip.enabled + type: RelaxedAtomicBool + value: false + mirror: always + rust: true + # Allow the content process to create a File from a path. This is allowed just # on parent process, on 'file' Content process, or for testing. - name: dom.file.createInChild diff --git a/servo/components/style/gecko/media_features.rs b/servo/components/style/gecko/media_features.rs @@ -8,6 +8,7 @@ use crate::derives::*; use crate::gecko_bindings::bindings; use crate::gecko_bindings::structs; use crate::media_queries::{Device, MediaType}; +use crate::parser::ParserContext; use crate::queries::feature::{AllowsRanges, Evaluator, FeatureFlags, QueryFeatureDescription}; use crate::queries::values::{Orientation, PrefersColorScheme}; use crate::values::computed::{CSSPixelLength, Context, Ratio, Resolution}; @@ -77,6 +78,10 @@ fn eval_device_orientation(context: &Context, value: Option<Orientation>) -> boo Orientation::eval(device_size(context.device()), value) } +fn document_picture_in_picture_enabled(_: &ParserContext) -> bool { + static_prefs::pref!("dom.documentpip.enabled") +} + /// Values for the display-mode media feature. #[derive(Clone, Copy, Debug, FromPrimitive, Parse, PartialEq, ToCss)] #[repr(u8)] @@ -86,6 +91,8 @@ pub enum DisplayMode { MinimalUi, Standalone, Fullscreen, + #[parse(condition = "document_picture_in_picture_enabled")] + PictureInPicture, } /// https://w3c.github.io/manifest/#the-display-mode-media-feature diff --git a/testing/web-platform/meta/css/mediaqueries/display-mode.html.ini b/testing/web-platform/meta/css/mediaqueries/display-mode.html.ini @@ -1,3 +1,3 @@ [display-mode.html] - [Should be known: '(display-mode: picture-in-picture)'] - expected: FAIL + prefs: [dom.documentpip.enabled:true] + +\ No newline at end of file diff --git a/testing/web-platform/meta/document-picture-in-picture/__dir__.ini b/testing/web-platform/meta/document-picture-in-picture/__dir__.ini @@ -1,2 +1,3 @@ -# https://bugzilla.mozilla.org/show_bug.cgi?id=1676069 -implementation-status: not-implementing +prefs: [dom.documentpip.enabled:true] +disabled: + if os == "android": not supported diff --git a/testing/web-platform/meta/document-picture-in-picture/base-uri.https.html.ini b/testing/web-platform/meta/document-picture-in-picture/base-uri.https.html.ini @@ -1,3 +0,0 @@ -[base-uri.https.html] - [Test that a document picture-in-picture window has the base URI of the initiator] - expected: FAIL diff --git a/testing/web-platform/meta/document-picture-in-picture/beforeunload-is-disabled.https.html.ini b/testing/web-platform/meta/document-picture-in-picture/beforeunload-is-disabled.https.html.ini @@ -1,3 +0,0 @@ -[beforeunload-is-disabled.https.html] - [Test that onbeforeunload is disabled for document picture in picture] - expected: FAIL diff --git a/testing/web-platform/meta/document-picture-in-picture/clears-session-on-close.https.html.ini b/testing/web-platform/meta/document-picture-in-picture/clears-session-on-close.https.html.ini @@ -1,3 +0,0 @@ -[clears-session-on-close.https.html] - [Test that documentPictureInPicture.window\n is cleared when the PiP window in closed.] - expected: FAIL diff --git a/testing/web-platform/meta/document-picture-in-picture/closes-on-navigation-or-destroy.https.html.ini b/testing/web-platform/meta/document-picture-in-picture/closes-on-navigation-or-destroy.https.html.ini @@ -1,19 +1,3 @@ [closes-on-navigation-or-destroy.https.html] - expected: ERROR - [PIP window can be closed] - expected: TIMEOUT - - [PIP window closes when opener closes] - expected: NOTRUN - [window.closed becomes true after pagehide if not window.close() initiated] - expected: NOTRUN - - [PIP window closes when navigated] - expected: NOTRUN - - [PIP window closes when navigated by name] - expected: NOTRUN - - [PIP window closes when opener navigates] - expected: NOTRUN + expected: FAIL diff --git a/testing/web-platform/meta/document-picture-in-picture/copy-document-mode-quirks.https.html.ini b/testing/web-platform/meta/document-picture-in-picture/copy-document-mode-quirks.https.html.ini @@ -1,3 +0,0 @@ -[copy-document-mode-quirks.https.html] - [Test document picture-in-picture copies Document mode when it's quirks mode] - expected: FAIL diff --git a/testing/web-platform/meta/document-picture-in-picture/copy-document-mode.https.html.ini b/testing/web-platform/meta/document-picture-in-picture/copy-document-mode.https.html.ini @@ -1,3 +0,0 @@ -[copy-document-mode.https.html] - [Test document picture-in-picture copies Document mode] - expected: FAIL diff --git a/testing/web-platform/meta/document-picture-in-picture/display-mode.https.html.ini b/testing/web-platform/meta/document-picture-in-picture/display-mode.https.html.ini @@ -1,3 +0,0 @@ -[display-mode.https.html] - [Test picture-in-picture display mode] - expected: FAIL diff --git a/testing/web-platform/meta/document-picture-in-picture/enter-event.https.html.ini b/testing/web-platform/meta/document-picture-in-picture/enter-event.https.html.ini @@ -1,3 +0,0 @@ -[enter-event.https.html] - [Test that enter event is fired at documentPictureInPicture\n when the PiP window in opened.] - expected: FAIL diff --git a/testing/web-platform/meta/document-picture-in-picture/focus-opener.https.html.ini b/testing/web-platform/meta/document-picture-in-picture/focus-opener.https.html.ini @@ -1,3 +0,0 @@ -[focus-opener.https.html] - [Test that a document picture-in-picture window can use Window's focus()\n API to focus its opener window] - expected: FAIL diff --git a/testing/web-platform/meta/document-picture-in-picture/iframe-document-pip.https.html.ini b/testing/web-platform/meta/document-picture-in-picture/iframe-document-pip.https.html.ini @@ -1,3 +0,0 @@ -[iframe-document-pip.https.html] - [Test that document pip is not allowed in iframes.] - expected: FAIL diff --git a/testing/web-platform/meta/document-picture-in-picture/open-pip-window-from-pip-window.https.html.ini b/testing/web-platform/meta/document-picture-in-picture/open-pip-window-from-pip-window.https.html.ini @@ -1,3 +0,0 @@ -[open-pip-window-from-pip-window.https.html] - [Test that documentPictureInPicture.requestWindow()\n rejects from a PiP window] - expected: FAIL diff --git a/testing/web-platform/meta/document-picture-in-picture/pip-fullscreen.tentative.https.html.ini b/testing/web-platform/meta/document-picture-in-picture/pip-fullscreen.tentative.https.html.ini @@ -1,6 +0,0 @@ -[pip-fullscreen.tentative.https.html] - [A pip window cannot be fullscreened] - expected: FAIL - - [A pip window can fullscreen it's opener] - expected: FAIL diff --git a/testing/web-platform/meta/document-picture-in-picture/pip-move.tentative.https.html.ini b/testing/web-platform/meta/document-picture-in-picture/pip-move.tentative.https.html.ini @@ -1,3 +0,0 @@ -[pip-move.tentative.https.html] - [Test that a moveTo and moveBy are disabled for a document picture-in-picture window] - expected: FAIL diff --git a/testing/web-platform/meta/document-picture-in-picture/pip-receives-focus.https.html.ini b/testing/web-platform/meta/document-picture-in-picture/pip-receives-focus.https.html.ini @@ -1,3 +1,4 @@ [pip-receives-focus.https.html] + expected: [TIMEOUT, OK] [PiP recieves system focus after being opened] - expected: FAIL + expected: [TIMEOUT, PASS] diff --git a/testing/web-platform/meta/document-picture-in-picture/pip-resize.https.html.ini b/testing/web-platform/meta/document-picture-in-picture/pip-resize.https.html.ini @@ -1,6 +0,0 @@ -[pip-resize.https.html] - [Test resizeTo PiP] - expected: FAIL - - [Test resizeBy PiP] - expected: FAIL diff --git a/testing/web-platform/meta/document-picture-in-picture/pip-size.optional.https.html.ini b/testing/web-platform/meta/document-picture-in-picture/pip-size.optional.https.html.ini @@ -1,9 +0,0 @@ -[pip-size.optional.https.html] - [Requesting PIP with width and height] - expected: FAIL - - [Test maximum size is restricted] - expected: FAIL - - [PiP remembers size] - expected: FAIL diff --git a/testing/web-platform/meta/document-picture-in-picture/propagate-user-activation-from-opener.https.html.ini b/testing/web-platform/meta/document-picture-in-picture/propagate-user-activation-from-opener.https.html.ini @@ -1,9 +0,0 @@ -[propagate-user-activation-from-opener.https.html] - [user activation propagates from opener to PiP] - expected: FAIL - - [user activation propagates from cross-origin iframe in opener to PiP] - expected: FAIL - - [user activation propagates from opener to iframe PiP] - expected: FAIL diff --git a/testing/web-platform/meta/document-picture-in-picture/propagate-user-activation-to-opener.https.html.ini b/testing/web-platform/meta/document-picture-in-picture/propagate-user-activation-to-opener.https.html.ini @@ -1,12 +0,0 @@ -[propagate-user-activation-to-opener.https.html] - [user activation propagates from PiP to opener] - expected: FAIL - - [user activation propagates from cross-origin iframe in PiP to opener] - expected: FAIL - - [user activation does not propagate from PiP to iframe in opener] - expected: FAIL - - [Consuming activation in PiP also consumes activation in iframes in opener] - expected: FAIL diff --git a/testing/web-platform/meta/document-picture-in-picture/requires-user-gesture.https.html.ini b/testing/web-platform/meta/document-picture-in-picture/requires-user-gesture.https.html.ini @@ -1,3 +0,0 @@ -[requires-user-gesture.https.html] - [requestWindow should fail without a user gesture] - expected: FAIL diff --git a/testing/web-platform/meta/document-picture-in-picture/requires-width-and-height-to-both-be-specified.https.html.ini b/testing/web-platform/meta/document-picture-in-picture/requires-width-and-height-to-both-be-specified.https.html.ini @@ -1,6 +0,0 @@ -[requires-width-and-height-to-both-be-specified.https.html] - [requestWindow should fail when width is specified without height] - expected: FAIL - - [requestWindow should fail when height is specified without width] - expected: FAIL diff --git a/testing/web-platform/meta/document-picture-in-picture/returns-window-with-document.https.html.ini b/testing/web-platform/meta/document-picture-in-picture/returns-window-with-document.https.html.ini @@ -1,9 +0,0 @@ -[returns-window-with-document.https.html] - [requestWindow resolves with the PiP window] - expected: FAIL - - [Elements can be moved from opener to PiP document] - expected: FAIL - - [PiP document is fully loaded] - expected: FAIL diff --git a/testing/web-platform/tests/document-picture-in-picture/base-uri.https.html b/testing/web-platform/tests/document-picture-in-picture/base-uri.https.html @@ -9,14 +9,6 @@ promise_test(async (t) => { await test_driver.bless('request PiP window from top window'); const pipWindow = await documentPictureInPicture.requestWindow(); - - if (pipWindow.document.readyState != "complete") { - // about:blank should load synchronous, but Gecko is still working on that... - // The initial placeholder is in many ways not initialized, e.g. doesn't have the right base - assert_true(true, "Waiting for pip window to load"); - await new Promise(res => pipWindow.addEventListener("load", res, { once: true })); - } - assert_equals(pipWindow.document.baseURI, document.baseURI, "Base URIs match"); }); </script> diff --git a/testing/web-platform/tests/document-picture-in-picture/display-mode.https.html b/testing/web-platform/tests/document-picture-in-picture/display-mode.https.html @@ -10,13 +10,6 @@ promise_test(async (t) => { await test_driver.bless('request PiP window'); const pipWindow = await documentPictureInPicture.requestWindow(); - if (pipWindow.document.readyState != "complete") { - // about:blank should load synchronous, but Gecko is still working on that... - // Ensure the async load doesn't blow away the iframe later on. - assert_true(true, "Waiting for pip window to load"); - await new Promise(res => pipWindow.addEventListener("load", res, { once: true })); - } - await new Promise(requestAnimationFrame); assert_true( !!pipWindow.matchMedia('(display-mode: picture-in-picture)'.matches), diff --git a/testing/web-platform/tests/document-picture-in-picture/focus-opener.https.html b/testing/web-platform/tests/document-picture-in-picture/focus-opener.https.html @@ -9,16 +9,19 @@ <iframe></iframe> <script> promise_test(async (t) => { - await test_driver.bless('request PiP window from top window'); - const pipWindow = await documentPictureInPicture.requestWindow(); - - // Blur this window by focusing the iframe. This will allow us to detect that - // the document picture-in-picture window has focused us. + // Ensure this window is blurred so that we can detect that the + // document PiP window has focused us. const blurPromise = new Promise(async (resolve) => { window.addEventListener('blur', resolve, { once: true }); - await test_driver.bless('focus inner iframe to blur window'); - document.getElementsByTagName('iframe')[0].focus(); }); + + await test_driver.bless('request PiP window from top window'); + const pipWindow = await documentPictureInPicture.requestWindow(); + + // The PiP might be focused automatically. + // But to be safe that this window is blured, focus the iframe. + await test_driver.bless('focus inner iframe to blur window'); + document.getElementsByTagName('iframe')[0].focus(); await blurPromise; // Now focus this window from the document picture-in-picture window's diff --git a/testing/web-platform/tests/document-picture-in-picture/pip-fullscreen.tentative.https.html b/testing/web-platform/tests/document-picture-in-picture/pip-fullscreen.tentative.https.html @@ -28,13 +28,6 @@ promise_test(async (t) => { await test_driver.bless('request PiP window from top window'); const pipWindow = await documentPictureInPicture.requestWindow(); - if (pipWindow.document.readyState != "complete") { - // about:blank should load synchronous, but Gecko is still working on that... - // The initial placeholder is in many ways not initialized, e.g. doesn't have the right base - assert_true(true, "Waiting for pip window to load"); - await new Promise(res => pipWindow.addEventListener("load", res, { once: true })); - } - const fsResult = new Promise((res, rej) => { document.body.addEventListener('fullscreenchange', res); document.body.addEventListener('fullscreenerror', rej); diff --git a/testing/web-platform/tests/document-picture-in-picture/pip-resize.https.html b/testing/web-platform/tests/document-picture-in-picture/pip-resize.https.html @@ -17,7 +17,7 @@ promise_test(async (t) => { await assert_throws_dom('NotAllowedError', pipWindow.DOMException, () => pipWindow.resizeTo(pipWindow.outerWidth, pipWindow.outerHeight + 100) - , 'resizeTo requires user acivation'); + , 'resizeTo requires user activation'); await test_driver.bless('resize window'); let resized = new Promise(res => pipWindow.addEventListener("resize", res, { once: true })); @@ -38,7 +38,7 @@ promise_test(async (t) => { await assert_throws_dom('NotAllowedError', pipWindow.DOMException, () => pipWindow.resizeBy(100, 0) - , 'resizeBy requires user acivation'); + , 'resizeBy requires user activation'); await test_driver.bless('resize window'); let resized = new Promise(res => pipWindow.addEventListener("resize", res, { once: true })); diff --git a/testing/web-platform/tests/document-picture-in-picture/propagate-user-activation-from-opener.https.html b/testing/web-platform/tests/document-picture-in-picture/propagate-user-activation-from-opener.https.html @@ -49,12 +49,6 @@ promise_test(async (t) => { await test_driver.bless('request PiP window'); const pipWindow = await documentPictureInPicture.requestWindow(); - if (pipWindow.document.readyState != "complete") { - // about:blank should load synchronous, but Gecko is still working on that... - assert_true(true, "Waiting for pip window to load"); - await new Promise(res => pipWindow.addEventListener("load", res, { once: true })); - } - const ifr = pipWindow.document.createElement("iframe"); pipWindow.document.body.append(ifr); diff --git a/testing/web-platform/tests/document-picture-in-picture/propagate-user-activation-to-opener.https.html b/testing/web-platform/tests/document-picture-in-picture/propagate-user-activation-to-opener.https.html @@ -4,6 +4,7 @@ <script src="/resources/testharnessreport.js"></script> <script src="/resources/testdriver.js"></script> <script src="/resources/testdriver-vendor.js"></script> +<script src="/common/get-host-info.sub.js"></script> <iframe id="same-origin-iframe" src="/common/blank.html"></iframe> <body> <script> @@ -11,13 +12,6 @@ promise_test(async (t) => { await test_driver.bless('request PiP window'); const pipWindow = await documentPictureInPicture.requestWindow(); - if (pipWindow.document.readyState != "complete") { - // about:blank should load synchronous, but Gecko is still working on that... - // The async load could blow away our document while blessing causing an error - assert_true(true, "Waiting for pip window to load"); - await new Promise(res => pipWindow.addEventListener("load", res, { once: true })); - } - assert_false(navigator.userActivation.isActive, 'the opener should initially not have user activation'); assert_false(pipWindow.navigator.userActivation.isActive, 'the PiP window should initially not have user activation'); @@ -36,22 +30,13 @@ promise_test(async (t) => { await test_driver.bless('request PiP window'); const pipWindow = await documentPictureInPicture.requestWindow(); - if (pipWindow.document.readyState != "complete") { - // about:blank should load synchronous, but Gecko is still working on that... - // The async load could blow away our document while blessing causing an error - assert_true(true, "Waiting for pip window to load"); - await new Promise(res => pipWindow.addEventListener("load", res, { once: true })); - } - const ifr = pipWindow.document.createElement("iframe"); - ifr.src = 'https://{{hosts[alt][www]}}:{{ports[https][0]}}/common/blank.html'; pipWindow.document.body.append(ifr); - await new Promise(res => ifr.addEventListener('load', res, { once: true })); assert_false(navigator.userActivation.isActive, 'opener initially not active'); assert_false(pipWindow.navigator.userActivation.isActive, 'PiP initially not active'); - await test_driver.bless('activate cross-origin iframe', null, ifr.contentWindow); + await test_driver.bless('activate iframe', null, ifr.contentWindow); assert_true(navigator.userActivation.isActive, 'activation propagated to opener'); @@ -59,19 +44,12 @@ promise_test(async (t) => { assert_false(navigator.userActivation.isActive, 'activation was consumed in opener'); assert_false(pipWindow.navigator.userActivation.isActive, 'activation was consumed in PiP'); -}, 'user activation propagates from cross-origin iframe in PiP to opener'); +}, 'user activation propagates from iframe in PiP to opener'); promise_test(async (t) => { await test_driver.bless('request PiP window'); const pipWindow = await documentPictureInPicture.requestWindow(); - if (pipWindow.document.readyState != "complete") { - // about:blank should load synchronous, but Gecko is still working on that... - // The async load could blow away our document while blessing causing an error - assert_true(true, "Waiting for pip window to load"); - await new Promise(res => pipWindow.addEventListener("load", res, { once: true })); - } - const ifr = document.getElementById("same-origin-iframe"); assert_false(navigator.userActivation.isActive, 'opener initially not active'); @@ -89,13 +67,6 @@ promise_test(async (t) => { await test_driver.bless('request PiP window'); const pipWindow = await documentPictureInPicture.requestWindow(); - if (pipWindow.document.readyState != "complete") { - // about:blank should load synchronous, but Gecko is still working on that... - // The async load could blow away our document while blessing causing an error - assert_true(true, "Waiting for pip window to load"); - await new Promise(res => pipWindow.addEventListener("load", res, { once: true })); - } - const ifr = document.getElementById("same-origin-iframe"); assert_false(navigator.userActivation.isActive, 'opener initially not active'); diff --git a/testing/web-platform/tests/document-picture-in-picture/returns-window-with-document.https.html b/testing/web-platform/tests/document-picture-in-picture/returns-window-with-document.https.html @@ -9,7 +9,15 @@ <div id="div"></div> <script> +function waitForEnter() { + return new Promise(resolve => + documentPictureInPicture.addEventListener("enter", resolve, { once: true }) + ); +} + promise_test(async (t) => { + const enter = waitForEnter(); + await test_driver.bless('request PiP window'); const pipWindow = await documentPictureInPicture.requestWindow(); @@ -17,9 +25,13 @@ promise_test(async (t) => { 'Window should contain a document'); assert_true(documentPictureInPicture.window === pipWindow, 'DocumentPictureInPicture.window should match the current window'); + + await enter; // avoid issues in the next subtest }, 'requestWindow resolves with the PiP window'); promise_test(async (t) => { + const enter = waitForEnter(); + await test_driver.bless('request PiP window'); const pipWindow = await documentPictureInPicture.requestWindow(); const div = document.getElementById('div'); @@ -34,12 +46,49 @@ promise_test(async (t) => { 'The div should have moved away from the original document'); assert_true(pipWindow.document.body.contains(div), 'The div should have moved to the PiP document'); + + await enter; // avoid issues in the next subtest }, 'Elements can be moved from opener to PiP document'); promise_test(async (t) => { + const enter = waitForEnter(); + await test_driver.bless('request PiP window'); const pipWindow = await documentPictureInPicture.requestWindow(); assert_equals(pipWindow.document.readyState, "complete", "PiP document ready state is complete"); + + await enter; // avoid issues in the next subtest }, 'PiP document is fully loaded'); + +promise_test(async (t) => { + let events = []; + + const enter = new Promise(resolve => { + documentPictureInPicture.onenter = () => { + events.push("enter"); + resolve(); + } + }); + + await test_driver.bless('request PiP window'); + const request = documentPictureInPicture.requestWindow() + .then(() => { + events.push("requestWindow"); + }); + + // Microtask to resolve request should already be queued + const microtask = Promise.resolve().then(() => events.push("microtask")); + + assert_true(!!documentPictureInPicture.window, "window property should be set synchronous"); + + await Promise.all([enter, request, microtask]); + + assert_equals( + events.join(), + "requestWindow,microtask,enter", + "Got the expected order of actions" + ); + +}, "requestWindow timing"); </script> </body> diff --git a/testing/web-platform/tests/document-picture-in-picture/support/popup-opens-pip.html b/testing/web-platform/tests/document-picture-in-picture/support/popup-opens-pip.html @@ -33,11 +33,6 @@ async function action(data) { if (data.type == 'request-pip') { pipWindow = await documentPictureInPicture.requestWindow(); - if (pipWindow.document.readyState != "complete") { - // about:blank should load synchronous, but Gecko is still working on that... - await new Promise(res => pipWindow.addEventListener("load", res, { once: true })); - } - const script = pipWindow.document.createElement('script'); script.innerHTML = get_pip_code(data.channelName); pipWindow.document.body.append(script); diff --git a/toolkit/components/browser/nsIWebBrowserChrome.idl b/toolkit/components/browser/nsIWebBrowserChrome.idl @@ -32,10 +32,20 @@ interface nsIWebBrowserChrome : nsISupports const unsigned long CHROME_WINDOW_BORDERS = 1 << 1; const unsigned long CHROME_WINDOW_CLOSE = 1 << 2; const unsigned long CHROME_WINDOW_RESIZE = 1 << 3; + + // toolbar 'toolbar-menubar' accessible by pressing alt const unsigned long CHROME_MENUBAR = 1 << 4; + + // Various toolbarbutton elements like reload, back/forward... const unsigned long CHROME_TOOLBAR = 1 << 5; + + // Whole toolbar including locationbar const unsigned long CHROME_LOCATIONBAR = 1 << 6; + + // XXX This hides .chromeclass-status which doesn't exist anymore const unsigned long CHROME_STATUSBAR = 1 << 7; + + // toolbar 'PersonalToolbar' containing bookmarks const unsigned long CHROME_PERSONAL_TOOLBAR = 1 << 8; const unsigned long CHROME_SCROLLBARS = 1 << 9; const unsigned long CHROME_TITLEBAR = 1 << 10; @@ -48,6 +58,7 @@ interface nsIWebBrowserChrome : nsISupports CHROME_STATUSBAR | CHROME_PERSONAL_TOOLBAR | CHROME_SCROLLBARS | CHROME_TITLEBAR | CHROME_EXTRA; + // Only applies to dialog windows, all others always have a minimize button const unsigned long CHROME_WINDOW_MINIMIZE = 1 << 14; const unsigned long CHROME_ALERT = 1 << 15; @@ -98,6 +109,11 @@ interface nsIWebBrowserChrome : nsISupports CHROME_WINDOW_MINIMIZE | CHROME_LOCATIONBAR | CHROME_STATUSBAR | CHROME_SCROLLBARS | CHROME_TITLEBAR; + const unsigned long CHROME_DOCUMENT_PICTURE_IN_PICTURE = + CHROME_WINDOW_BORDERS | CHROME_WINDOW_CLOSE | CHROME_WINDOW_RESIZE | + CHROME_WINDOW_MINIMIZE | CHROME_LOCATIONBAR | CHROME_STATUSBAR | + CHROME_SCROLLBARS | CHROME_TITLEBAR | CHROME_ALWAYS_ON_TOP; + /** * The chrome flags for this browser chrome. The implementation should * reflect the value of this attribute by hiding or showing its chrome diff --git a/toolkit/components/windowwatcher/nsWindowWatcher.cpp b/toolkit/components/windowwatcher/nsWindowWatcher.cpp @@ -553,8 +553,8 @@ nsWindowWatcher::OpenWindowWithRemoteTab( // don't need to propagate isPopupRequested out-parameter to the resulting // browsing context. bool unused = false; - uint32_t chromeFlags = - CalculateChromeFlagsForContent(aFeatures, aModifiers, &unused); + uint32_t chromeFlags = CalculateChromeFlagsForContent(aFeatures, aModifiers, + aCalledFromJS, &unused); if (isPrivateBrowsingWindow) { chromeFlags |= nsIWebBrowserChrome::CHROME_PRIVATE_WINDOW; @@ -804,8 +804,8 @@ nsresult nsWindowWatcher::OpenWindowInternal( } else { MOZ_DIAGNOSTIC_ASSERT(parentBC && parentBC->IsContent(), "content caller must provide content parent"); - chromeFlags = - CalculateChromeFlagsForContent(features, aModifiers, &isPopupRequested); + chromeFlags = CalculateChromeFlagsForContent( + features, aModifiers, aCalledFromJS, &isPopupRequested); if (aDialog) { MOZ_ASSERT(XRE_IsParentProcess()); @@ -1885,6 +1885,9 @@ bool nsWindowWatcher::ShouldOpenPopup(const WindowFeatures& aFeatures) { * from a child process. The feature string can only control whether to open a * new tab or a new popup. * @param aFeatures a string containing a list of named features + * @param aCalledFromJS a bool indicating whether the features were provided by + content JS. If not, we can expose non-standard, more powerful features to + content callers. * @param aIsPopupRequested an out parameter that indicates whether a popup * is requested by aFeatures * @return the chrome bitmask @@ -1893,7 +1896,12 @@ bool nsWindowWatcher::ShouldOpenPopup(const WindowFeatures& aFeatures) { uint32_t nsWindowWatcher::CalculateChromeFlagsForContent( const WindowFeatures& aFeatures, const mozilla::dom::UserActivation::Modifiers& aModifiers, - bool* aIsPopupRequested) { + bool aCalledFromJS, bool* aIsPopupRequested) { + if (!aCalledFromJS && + aFeatures.GetBoolWithDefault("pictureinpicture", false)) { + return nsIWebBrowserChrome::CHROME_DOCUMENT_PICTURE_IN_PICTURE; + } + if (aFeatures.IsEmpty() || !ShouldOpenPopup(aFeatures)) { // Open the current/new tab in the current/new window // (depends on browser.link.open_newwindow). @@ -2662,6 +2670,12 @@ int32_t nsWindowWatcher::GetWindowOpenLocation( } } } + + if ((aChromeFlags & + nsIWebBrowserChrome::CHROME_DOCUMENT_PICTURE_IN_PICTURE) == + nsIWebBrowserChrome::CHROME_DOCUMENT_PICTURE_IN_PICTURE) { + return nsIBrowserDOMWindow::OPEN_NEWWINDOW; + } #endif return containerPref; diff --git a/toolkit/components/windowwatcher/nsWindowWatcher.h b/toolkit/components/windowwatcher/nsWindowWatcher.h @@ -115,7 +115,7 @@ class nsWindowWatcher : public nsIWindowWatcher, static uint32_t CalculateChromeFlagsForContent( const mozilla::dom::WindowFeatures& aFeatures, const mozilla::dom::UserActivation::Modifiers& aModifiers, - bool* aIsPopupRequested); + bool aCalledFromJS, bool* aIsPopupRequested); static uint32_t CalculateChromeFlagsForSystem( const mozilla::dom::WindowFeatures& aFeatures, bool aDialog,