/* 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/. */ #include "PointerLockManager.h" #include "mozilla/AsyncEventDispatcher.h" #include "mozilla/EventStateManager.h" #include "mozilla/Logging.h" #include "mozilla/PresShell.h" #include "mozilla/ScopeExit.h" #include "mozilla/StaticPrefs_full_screen_api.h" #include "mozilla/dom/BindingDeclarations.h" #include "mozilla/dom/BrowserChild.h" #include "mozilla/dom/BrowserParent.h" #include "mozilla/dom/BrowsingContext.h" #include "mozilla/dom/CanonicalBrowsingContext.h" #include "mozilla/dom/Document.h" #include "mozilla/dom/DocumentInlines.h" #include "mozilla/dom/Element.h" #include "mozilla/dom/ElementBinding.h" #include "mozilla/dom/PointerEventHandler.h" #include "mozilla/dom/Promise.h" #include "mozilla/dom/WindowContext.h" #include "nsCOMPtr.h" #include "nsIWidget.h" #include "nsMenuPopupFrame.h" #include "nsSandboxFlags.h" mozilla::LazyLogModule gPointerLockLog("PointerLock"); #define MOZ_POINTERLOCK_LOG(...) \ MOZ_LOG(gPointerLockLog, mozilla::LogLevel::Debug, (__VA_ARGS__)) namespace mozilla { using mozilla::dom::BrowserChild; using mozilla::dom::BrowserParent; using mozilla::dom::BrowsingContext; using mozilla::dom::CallerType; using mozilla::dom::CanonicalBrowsingContext; using mozilla::dom::Document; using mozilla::dom::Element; using mozilla::dom::PointerLockOptions; using mozilla::dom::Promise; using mozilla::dom::WindowContext; // Reference to the pointer locked element. constinit static nsWeakPtr sLockedElement; // Reference to the document which requested pointer lock. constinit static nsWeakPtr sLockedDoc; // Reference to the BrowserParent requested pointer lock. static BrowserParent* sLockedRemoteTarget = nullptr; /* static */ bool PointerLockManager::sIsLocked = false; /* static */ bool PointerLockManager::sIsLockUnadjustedMovement = false; // Map a Gecko-internal pointer-lock error string to the DOMException defined in // the spec, https://w3c.github.io/pointerlock/#dom-element-requestpointerlock. static void RejectPromiseForError(Promise* aPromise, const char* aError) { MOZ_ASSERT(aPromise); MOZ_ASSERT(aError); if (!strcmp(aError, "PointerLockDeniedDisabled")) { aPromise->MaybeRejectWithNotSupportedError("Pointer Lock API is disabled."); return; } if (!strcmp(aError, "PointerLockDeniedNotInDocument")) { aPromise->MaybeRejectWithInvalidStateError( "The requesting element is not in a document."); return; } if (!strcmp(aError, "PointerLockDeniedSandboxed")) { aPromise->MaybeRejectWithSecurityError( "Pointer Lock API is restricted via sandbox."); return; } // Other browsers allow the hidden document to lock the pointer, but there is // a spec issue for that, https://github.com/w3c/pointerlock/issues/93. if (!strcmp(aError, "PointerLockDeniedHidden")) { aPromise->MaybeRejectWithWrongDocumentError("The document is not visible."); return; } if (!strcmp(aError, "PointerLockDeniedNotFocused")) { aPromise->MaybeRejectWithWrongDocumentError("The document is not focused."); return; } if (!strcmp(aError, "PointerLockDeniedFailedToLock")) { aPromise->MaybeRejectWithNotSupportedError( "The browser failed to lock the pointer."); return; } if (!strcmp(aError, "PointerLockDeniedInUse")) { aPromise->MaybeRejectWithInvalidStateError( "The pointer is currently locked by a different document."); return; } if (!strcmp(aError, "PointerLockDeniedNotInputDriven")) { aPromise->MaybeRejectWithNotAllowedError( "Element.requestPointerLock() was not called from inside a short " "running user-generated event handler, and the document is not in full " "screen."); return; } if (!strcmp(aError, "PointerLockDeniedMovedDocument")) { aPromise->MaybeRejectWithInvalidStateError( "The requesting element has moved to a different document"); return; } MOZ_ASSERT_UNREACHABLE("Unknown pointer lock error"); aPromise->MaybeRejectWithInvalidStateError("Unknown error."); } /* static */ already_AddRefed PointerLockManager::GetLockedElement() { nsCOMPtr element = do_QueryReferent(sLockedElement); return element.forget(); } /* static */ already_AddRefed PointerLockManager::GetLockedDocument() { nsCOMPtr document = do_QueryReferent(sLockedDoc); return document.forget(); } /* static */ BrowserParent* PointerLockManager::GetLockedRemoteTarget() { MOZ_ASSERT(XRE_IsParentProcess()); return sLockedRemoteTarget; } static void DispatchPointerLockChange(Document* aTarget) { if (!aTarget) { return; } MOZ_POINTERLOCK_LOG("Dispatch pointerlockchange event [document=0x%p]", aTarget); RefPtr asyncDispatcher = new AsyncEventDispatcher(aTarget, u"pointerlockchange"_ns, CanBubble::eYes, ChromeOnlyDispatch::eNo); asyncDispatcher->PostDOMEvent(); } static void DispatchPointerLockError(Document* aTarget, const char* aMessage) { MOZ_ASSERT(aMessage); if (!aTarget) { return; } MOZ_POINTERLOCK_LOG( "Dispatch pointerlockerror event [document=0x%p, message=%s]", aTarget, aMessage); RefPtr asyncDispatcher = new AsyncEventDispatcher(aTarget, u"pointerlockerror"_ns, CanBubble::eYes, ChromeOnlyDispatch::eNo); asyncDispatcher->PostDOMEvent(); nsContentUtils::ReportToConsole(nsIScriptError::warningFlag, "DOM"_ns, aTarget, PropertiesFile::DOM_PROPERTIES, aMessage); } // Combined error path: fires pointerlockerror for backwards compatibility AND // rejects the spec-mandated Promise with the appropriate DOMException. static void FailWith(Document* aTarget, Promise* aPromise, const char* aError) { DispatchPointerLockError(aTarget, aError); RejectPromiseForError(aPromise, aError); } static bool IsPopupOpened() { // Check if any popup is open. nsXULPopupManager* pm = nsXULPopupManager::GetInstance(); if (!pm) { return false; } nsTArray popups; pm->GetVisiblePopups(popups, true); for (nsMenuPopupFrame* popup : popups) { if (popup->GetPopupType() != widget::PopupType::Tooltip) { return true; } } return false; } static const char* GetPointerLockError(Element* aElement, Element* aCurrentLock, bool aNoFocusCheck = false) { // Check if pointer lock pref is enabled if (!StaticPrefs::full_screen_api_pointer_lock_enabled()) { return "PointerLockDeniedDisabled"; } nsCOMPtr ownerDoc = aElement->OwnerDoc(); if (aCurrentLock && aCurrentLock->OwnerDoc() != ownerDoc) { return "PointerLockDeniedInUse"; } if (!aElement->IsInComposedDoc()) { return "PointerLockDeniedNotInDocument"; } if (ownerDoc->GetSandboxFlags() & SANDBOXED_POINTER_LOCK) { return "PointerLockDeniedSandboxed"; } // Check if the element is in a document with a docshell. if (!ownerDoc->GetContainer()) { return "PointerLockDeniedHidden"; } nsCOMPtr ownerWindow = ownerDoc->GetWindow(); if (!ownerWindow) { return "PointerLockDeniedHidden"; } nsCOMPtr ownerInnerWindow = ownerDoc->GetInnerWindow(); if (!ownerInnerWindow) { return "PointerLockDeniedHidden"; } if (ownerWindow->GetCurrentInnerWindow() != ownerInnerWindow) { return "PointerLockDeniedHidden"; } BrowsingContext* bc = ownerDoc->GetBrowsingContext(); BrowsingContext* topBC = bc ? bc->Top() : nullptr; WindowContext* topWC = ownerDoc->GetTopLevelWindowContext(); if (!topBC || !topBC->IsActive() || !topWC || topWC != topBC->GetCurrentWindowContext()) { return "PointerLockDeniedHidden"; } if (!aNoFocusCheck) { if (!IsInActiveTab(ownerDoc)) { return "PointerLockDeniedNotFocused"; } } if (IsPopupOpened()) { return "PointerLockDeniedFailedToLock"; } return nullptr; } /* static */ void PointerLockManager::RequestLock(Element* aElement, const PointerLockOptions& aOptions, CallerType aCallerType, Promise* aPromise) { MOZ_ASSERT(aPromise); NS_ASSERTION(aElement, "Must pass non-null element to PointerLockManager::RequestLock"); RefPtr doc = aElement->OwnerDoc(); nsCOMPtr pointerLockedElement = GetLockedElement(); MOZ_POINTERLOCK_LOG( "Request lock on element 0x%p [document=0x%p, " "unadjustedMovement=%s]", aElement, doc.get(), aOptions.mUnadjustedMovement ? "true" : "false"); // XXX: https://bugzilla.mozilla.org/show_bug.cgi?id=2037874. // Spec does this check in queued task instead, see step 6-2 of // https://w3c.github.io/pointerlock/#dom-element-requestpointerlock. if (aElement == pointerLockedElement && sIsLockUnadjustedMovement == aOptions.mUnadjustedMovement) { DispatchPointerLockChange(doc); aPromise->MaybeResolveWithUndefined(); return; } if (const char* msg = GetPointerLockError(aElement, pointerLockedElement)) { FailWith(doc, aPromise, msg); return; } bool userInputOrSystemCaller = doc->HasValidTransientUserGestureActivation() || aCallerType == CallerType::System; nsCOMPtr request = new PointerLockRequest(aElement, userInputOrSystemCaller, aOptions.mUnadjustedMovement, aPromise); doc->Dispatch(request.forget()); } /* static */ void PointerLockManager::Unlock(const char* aReason, Document* aDoc) { if (sLockedRemoteTarget) { MOZ_ASSERT(XRE_IsParentProcess()); MOZ_ASSERT(!sIsLocked); if (aDoc) { CanonicalBrowsingContext* lockedBc = sLockedRemoteTarget->GetBrowsingContext(); if (lockedBc && lockedBc->TopCrossChromeBoundary()->GetExtantDocument() != aDoc) { return; } } MOZ_POINTERLOCK_LOG( "Unlock document 0x%p [sLockedRemoteTarget=0x%p, reason=%s]", aDoc, sLockedRemoteTarget, aReason); (void)sLockedRemoteTarget->SendReleasePointerLock(); PointerLockManager::ReleaseLockedRemoteTarget(sLockedRemoteTarget); return; } if (!sIsLocked) { return; } nsCOMPtr pointerLockedDoc = GetLockedDocument(); MOZ_POINTERLOCK_LOG("Unlock document 0x%p [LockedDocument=0x%p, reason=%s]", aDoc, pointerLockedDoc.get(), aReason); if (!pointerLockedDoc || (aDoc && aDoc != pointerLockedDoc)) { return; } if (!SetPointerLock(nullptr, pointerLockedDoc, StyleCursorKind::Auto, /* aUnadjustedMovement */ false)) { return; } nsCOMPtr pointerLockedElement = GetLockedElement(); ChangePointerLockedElement(nullptr, pointerLockedDoc, pointerLockedElement); if (BrowserChild* browserChild = BrowserChild::GetFrom(pointerLockedDoc->GetDocShell())) { browserChild->SendReleasePointerLock(); } AsyncEventDispatcher::RunDOMEventWhenSafe( *pointerLockedElement, u"MozDOMPointerLock:Exited"_ns, CanBubble::eYes, ChromeOnlyDispatch::eYes); } /* static */ void PointerLockManager::ChangePointerLockedElement( Element* aElement, Document* aDocument, Element* aPointerLockedElement) { // aDocument here is not really necessary, as it is the uncomposed // document of both aElement and aPointerLockedElement as far as one // is not nullptr, and they wouldn't both be nullptr in any case. // But since the caller of this function should have known what the // document is, we just don't try to figure out what it should be. MOZ_ASSERT(aDocument); MOZ_ASSERT(aElement != aPointerLockedElement); MOZ_POINTERLOCK_LOG("Change locked element from 0x%p to 0x%p [document=0x%p]", aPointerLockedElement, aElement, aDocument); if (aPointerLockedElement) { MOZ_ASSERT(aPointerLockedElement->GetComposedDoc() == aDocument); aPointerLockedElement->ClearPointerLock(); } if (aElement) { MOZ_ASSERT(aElement->GetComposedDoc() == aDocument); aElement->SetPointerLock(); sLockedElement = do_GetWeakReference(aElement); sLockedDoc = do_GetWeakReference(aDocument); NS_ASSERTION(sLockedElement && sLockedDoc, "aElement and this should support weak references!"); } else { sLockedElement = nullptr; sLockedDoc = nullptr; } // Retarget all events to aElement via capture or // stop retargeting if aElement is nullptr. PresShell::SetCapturingContent(aElement, CaptureFlags::PointerLock); DispatchPointerLockChange(aDocument); } /* static */ bool PointerLockManager::StartSetPointerLock(Element* aElement, Document* aDocument, bool aUnadjustedMovement) { if (!SetPointerLock(aElement, aDocument, StyleCursorKind::None, aUnadjustedMovement)) { DispatchPointerLockError(aDocument, "PointerLockDeniedFailedToLock"); return false; } ChangePointerLockedElement(aElement, aDocument, nullptr); nsContentUtils::DispatchEventOnlyToChrome( aDocument, aElement, u"MozDOMPointerLock:Entered"_ns, CanBubble::eYes, Cancelable::eNo, /* DefaultAction */ nullptr); return true; } /* static */ bool PointerLockManager::SetPointerLock(Element* aElement, Document* aDocument, StyleCursorKind aCursorStyle, bool aUnadjustedMovement) { MOZ_ASSERT(!aElement || aElement->OwnerDoc() == aDocument, "We should be either unlocking pointer (aElement is nullptr), " "or locking pointer to an element in this document"); #ifdef DEBUG if (!aElement) { nsCOMPtr pointerLockedDoc = GetLockedDocument(); MOZ_ASSERT(pointerLockedDoc == aDocument); } #endif PresShell* presShell = aDocument->GetPresShell(); if (!presShell) { NS_WARNING("SetPointerLock(): No PresShell"); if (!aElement) { sIsLocked = false; sIsLockUnadjustedMovement = false; // If we are unlocking pointer lock, but for some reason the doc // has already detached from the presshell, just ask the event // state manager to release the pointer. EventStateManager::SetPointerLock(nullptr, nullptr, /* aUnadjustedMovement */ false); return true; } return false; } RefPtr presContext = presShell->GetPresContext(); if (!presContext) { NS_WARNING("SetPointerLock(): Unable to get PresContext"); return false; } nsCOMPtr widget; nsIFrame* rootFrame = presShell->GetRootFrame(); if (!NS_WARN_IF(!rootFrame)) { widget = rootFrame->GetNearestWidget(); NS_WARNING_ASSERTION(widget, "SetPointerLock(): Unable to find widget in " "presShell->GetRootFrame()->GetNearestWidget();"); } if (aElement && !widget) { NS_WARNING("SetPointerLock(): No Widget while requesting pointer lock"); return false; } sIsLocked = !!aElement; sIsLockUnadjustedMovement = !!aElement && aUnadjustedMovement; // Hide the cursor and set pointer lock for future mouse events RefPtr esm = presContext->EventStateManager(); esm->SetCursor(aCursorStyle, nullptr, {}, Nothing(), widget, true); EventStateManager::SetPointerLock(widget, presContext, sIsLockUnadjustedMovement); return true; } /* static */ bool PointerLockManager::IsInLockContext(BrowsingContext* aContext) { if (!aContext) { return false; } nsCOMPtr pointerLockedDoc = GetLockedDocument(); if (!pointerLockedDoc || !pointerLockedDoc->GetBrowsingContext()) { return false; } BrowsingContext* lockTop = pointerLockedDoc->GetBrowsingContext()->Top(); BrowsingContext* top = aContext->Top(); return top == lockTop; } /* static */ void PointerLockManager::SetLockedRemoteTarget(BrowserParent* aBrowserParent, const bool& aUnadjustedMovement, nsACString& aError) { MOZ_ASSERT(XRE_IsParentProcess()); MOZ_ASSERT(aBrowserParent); if (sLockedRemoteTarget) { if (sLockedRemoteTarget != aBrowserParent) { aError = "PointerLockDeniedInUse"_ns; } return; } // Check if any popup is open. if (IsPopupOpened()) { aError = "PointerLockDeniedFailedToLock"_ns; return; } RefPtr element = aBrowserParent->TopLevelBrowserParent()->GetOwnerElement(); if (NS_WARN_IF(!element)) { aError = "PointerLockDeniedFailedToLock"_ns; return; } nsPresContext* presContext = element->OwnerDoc()->GetPresContext(); if (NS_WARN_IF(!presContext)) { aError = "PointerLockDeniedFailedToLock"_ns; return; } nsIWidget* widget = nsContentUtils::WidgetForContent(element); if (NS_WARN_IF(!widget)) { aError = "PointerLockDeniedFailedToLock"_ns; return; } MOZ_POINTERLOCK_LOG("Set locked remote target to 0x%p", aBrowserParent); sLockedRemoteTarget = aBrowserParent; PointerEventHandler::ReleaseAllPointerCaptureRemoteTarget(); if (StaticPrefs::dom_pointer_lock_reset_to_center_from_parent_enabled()) { // Capture pointer to the top level browser element, so that mouse event can // be always retargeted to the content when pointer lock is active, even if // the pointer is out of the content area. // XXX Now that we also capture the pointer in the parent process for // pointer lock, we could potentially rely on the captured content for // cross-process event dispatching instead of tracking the locked remote // target separately. PresShell::SetCapturingContent(element, CaptureFlags::PointerLock); EventStateManager::RequestLockPointer(widget, presContext, aUnadjustedMovement); } } /* static */ void PointerLockManager::ReleaseLockedRemoteTarget( BrowserParent* aBrowserParent) { MOZ_ASSERT(XRE_IsParentProcess()); if (sLockedRemoteTarget == aBrowserParent) { MOZ_POINTERLOCK_LOG("Release locked remote target 0x%p", sLockedRemoteTarget); sLockedRemoteTarget = nullptr; PresShell::SetCapturingContent(nullptr, CaptureFlags::PointerLock); nsCOMPtr widget = aBrowserParent->GetTopLevelWidget(); EventStateManager::ReleaseLockedPointer(widget); } } /* static */ bool PointerLockManager::ShouldResetPointer() { if (!StaticPrefs::dom_pointer_lock_reset_to_center_from_parent_enabled()) { return IsLocked(); } // If the pref is enabled, we should reset pointer position to center of the // widget in parent process. return XRE_IsParentProcess() && (GetLockedRemoteTarget() || IsLocked()); } static nsIWidget* GetWidgetForDocument(Document* aDocument) { if (!aDocument) { return nullptr; } PresShell* presShell = aDocument->GetPresShell(); if (!presShell) { return nullptr; } return presShell->GetRootWidget(); } PointerLockManager::PointerLockRequest::PointerLockRequest( Element* aElement, bool aUserInputOrChromeCaller, bool aUnadjustedMovement, Promise* aPromise) : mozilla::Runnable("PointerLockRequest"), mElement(do_GetWeakReference(aElement)), mDocument(do_GetWeakReference(aElement->OwnerDoc())), mUserInputOrChromeCaller(aUserInputOrChromeCaller), mUnadjustedMovement(aUnadjustedMovement), mPromise(aPromise) {} NS_IMETHODIMP PointerLockManager::PointerLockRequest::Run() { nsCOMPtr element = do_QueryReferent(mElement); nsCOMPtr document = do_QueryReferent(mDocument); RefPtr promise = std::move(mPromise); const char* error = nullptr; if (!element || !document || !element->GetComposedDoc()) { error = "PointerLockDeniedNotInDocument"; } else if (element->GetComposedDoc() != document) { error = "PointerLockDeniedMovedDocument"; } else if (mUnadjustedMovement) { nsCOMPtr widget = GetWidgetForDocument(document); if (!widget || !widget->SupportsUnadjustedMovement()) { // XXX Reuse the existing error code for now, we should have a more // specific error code for this case. error = "PointerLockDeniedFailedToLock"; } } if (!error) { nsCOMPtr pointerLockedElement = do_QueryReferent(sLockedElement); // XXX The steps are not exactly the same as in the spec, but they should // result in the same behavior. if (element == pointerLockedElement && sIsLockUnadjustedMovement == mUnadjustedMovement) { DispatchPointerLockChange(document); promise->MaybeResolveWithUndefined(); return NS_OK; } // Note, we must bypass focus change, so pass true as the last parameter! error = GetPointerLockError(element, pointerLockedElement, true); // Another element in the same document is requesting pointer lock, // just grant it without user input check. if (!error && pointerLockedElement) { // Apply new options on the existing lock. if (sIsLockUnadjustedMovement != mUnadjustedMovement) { nsCOMPtr widget = GetWidgetForDocument(document); if (NS_WARN_IF(!widget)) { FailWith(document, promise, "PointerLockDeniedFailedToLock"); return NS_OK; } MOZ_ASSERT(widget->SupportsUnadjustedMovement()); widget->SetNativePointerLockMode( mUnadjustedMovement ? nsIWidget::NativePointerLockMode::Unadjusted : nsIWidget::NativePointerLockMode::Regular); sIsLockUnadjustedMovement = mUnadjustedMovement; } if (element != pointerLockedElement) { ChangePointerLockedElement(element, document, pointerLockedElement); } else { DispatchPointerLockChange(document); } promise->MaybeResolveWithUndefined(); return NS_OK; } } // If it is neither user input initiated, nor requested in fullscreen, // it should be rejected. if (!error && !mUserInputOrChromeCaller && !document->Fullscreen()) { error = "PointerLockDeniedNotInputDriven"; } if (error) { FailWith(document, promise, error); return NS_OK; } if (BrowserChild* browserChild = BrowserChild::GetFrom(document->GetDocShell())) { nsWeakPtr e = do_GetWeakReference(element); nsWeakPtr doc = do_GetWeakReference(element->OwnerDoc()); nsWeakPtr bc = do_GetWeakReference(browserChild); browserChild->SendRequestPointerLock( mUnadjustedMovement, [e, doc, bc, promise, unadjustedMovement = mUnadjustedMovement](const nsCString& aError) { nsCOMPtr document = do_QueryReferent(doc); if (!aError.IsEmpty()) { FailWith(document, promise, aError.get()); return; } const char* error = nullptr; auto autoCleanup = MakeScopeExit([&] { if (error) { FailWith(document, promise, error); // If we are failed to set pointer lock, notify parent to stop // redirect mouse event to this process. if (nsCOMPtr browserChild = do_QueryReferent(bc)) { static_cast(browserChild.get()) ->SendReleasePointerLock(); } } }); nsCOMPtr element = do_QueryReferent(e); if (!element || !document || !element->GetComposedDoc()) { error = "PointerLockDeniedNotInDocument"; return; } if (element->GetComposedDoc() != document) { error = "PointerLockDeniedMovedDocument"; return; } nsCOMPtr pointerLockedElement = GetLockedElement(); error = GetPointerLockError(element, pointerLockedElement, true); if (error) { return; } if (!StartSetPointerLock(element, document, unadjustedMovement)) { error = "PointerLockDeniedFailedToLock"; return; } promise->MaybeResolveWithUndefined(); }, [doc, promise](mozilla::ipc::ResponseRejectReason) { // IPC layer error nsCOMPtr document = do_QueryReferent(doc); FailWith(document, promise, "PointerLockDeniedFailedToLock"); }); } else { if (StartSetPointerLock(element, document, mUnadjustedMovement)) { promise->MaybeResolveWithUndefined(); } else { FailWith(document, promise, "PointerLockDeniedFailedToLock"); } } return NS_OK; } } // namespace mozilla