diff options
Diffstat (limited to 'dom/notification')
29 files changed, 5529 insertions, 0 deletions
diff --git a/dom/notification/Notification.cpp b/dom/notification/Notification.cpp new file mode 100644 index 0000000000..39ba7b23ff --- /dev/null +++ b/dom/notification/Notification.cpp @@ -0,0 +1,2333 @@ +/* -*- Mode: C++; tab-width: 8; 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/. */ + +#include "mozilla/dom/Notification.h" + +#include <utility> + +#include "mozilla/BasePrincipal.h" +#include "mozilla/Components.h" +#include "mozilla/Encoding.h" +#include "mozilla/EventStateManager.h" +#include "mozilla/HoldDropJSObjects.h" +#include "mozilla/JSONStringWriteFuncs.h" +#include "mozilla/OwningNonNull.h" +#include "mozilla/Preferences.h" +#include "mozilla/StaticPrefs_dom.h" +#include "mozilla/Unused.h" +#include "mozilla/dom/AppNotificationServiceOptionsBinding.h" +#include "mozilla/dom/BindingUtils.h" +#include "mozilla/dom/ContentChild.h" +#include "mozilla/dom/Document.h" +#include "mozilla/dom/Promise.h" +#include "mozilla/dom/PromiseWorkerProxy.h" +#include "mozilla/dom/QMResult.h" +#include "mozilla/dom/RootedDictionary.h" +#include "mozilla/dom/ServiceWorkerGlobalScopeBinding.h" +#include "mozilla/dom/ServiceWorkerUtils.h" +#include "mozilla/dom/WorkerRunnable.h" +#include "mozilla/dom/WorkerScope.h" +#include "mozilla/dom/quota/ResultExtensions.h" +#include "Navigator.h" +#include "nsComponentManagerUtils.h" +#include "nsContentPermissionHelper.h" +#include "nsContentUtils.h" +#include "nsFocusManager.h" +#include "nsIAlertsService.h" +#include "nsIContentPermissionPrompt.h" +#include "nsILoadContext.h" +#include "nsINotificationStorage.h" +#include "nsIPermission.h" +#include "nsIPermissionManager.h" +#include "nsIPushService.h" +#include "nsIScriptError.h" +#include "nsIServiceWorkerManager.h" +#include "nsIUUIDGenerator.h" +#include "nsNetUtil.h" +#include "nsProxyRelease.h" +#include "nsServiceManagerUtils.h" +#include "nsStructuredCloneContainer.h" +#include "nsThreadUtils.h" +#include "nsXULAppAPI.h" + +namespace mozilla::dom { + +struct NotificationStrings { + const nsString mID; + const nsString mTitle; + const nsString mDir; + const nsString mLang; + const nsString mBody; + const nsString mTag; + const nsString mIcon; + const nsString mData; + const nsString mBehavior; + const nsString mServiceWorkerRegistrationScope; +}; + +class ScopeCheckingGetCallback : public nsINotificationStorageCallback { + const nsString mScope; + + public: + explicit ScopeCheckingGetCallback(const nsAString& aScope) : mScope(aScope) {} + + NS_IMETHOD Handle(const nsAString& aID, const nsAString& aTitle, + const nsAString& aDir, const nsAString& aLang, + const nsAString& aBody, const nsAString& aTag, + const nsAString& aIcon, const nsAString& aData, + const nsAString& aBehavior, + const nsAString& aServiceWorkerRegistrationScope) final { + AssertIsOnMainThread(); + MOZ_ASSERT(!aID.IsEmpty()); + + // Skip scopes that don't match when called from getNotifications(). + if (!mScope.IsEmpty() && !mScope.Equals(aServiceWorkerRegistrationScope)) { + return NS_OK; + } + + NotificationStrings strings = { + nsString(aID), nsString(aTitle), + nsString(aDir), nsString(aLang), + nsString(aBody), nsString(aTag), + nsString(aIcon), nsString(aData), + nsString(aBehavior), nsString(aServiceWorkerRegistrationScope), + }; + + mStrings.AppendElement(std::move(strings)); + return NS_OK; + } + + NS_IMETHOD Done() override = 0; + + protected: + virtual ~ScopeCheckingGetCallback() = default; + + nsTArray<NotificationStrings> mStrings; +}; + +class NotificationStorageCallback final : public ScopeCheckingGetCallback { + public: + NS_DECL_CYCLE_COLLECTING_ISUPPORTS + NS_DECL_CYCLE_COLLECTION_CLASS(NotificationStorageCallback) + + NotificationStorageCallback(nsIGlobalObject* aWindow, const nsAString& aScope, + Promise* aPromise) + : ScopeCheckingGetCallback(aScope), mWindow(aWindow), mPromise(aPromise) { + AssertIsOnMainThread(); + MOZ_ASSERT(aWindow); + MOZ_ASSERT(aPromise); + } + + NS_IMETHOD Done() final { + AutoTArray<RefPtr<Notification>, 5> notifications; + + for (uint32_t i = 0; i < mStrings.Length(); ++i) { + auto result = Notification::ConstructFromFields( + mWindow, mStrings[i].mID, mStrings[i].mTitle, mStrings[i].mDir, + mStrings[i].mLang, mStrings[i].mBody, mStrings[i].mTag, + mStrings[i].mIcon, mStrings[i].mData, + /* mStrings[i].mBehavior, not + * supported */ + mStrings[i].mServiceWorkerRegistrationScope); + if (result.isErr()) { + continue; + } + RefPtr<Notification> n = result.unwrap(); + n->SetStoredState(true); + notifications.AppendElement(n.forget()); + } + + mPromise->MaybeResolve(notifications); + return NS_OK; + } + + private: + virtual ~NotificationStorageCallback() = default; + + nsCOMPtr<nsIGlobalObject> mWindow; + RefPtr<Promise> mPromise; + const nsString mScope; +}; + +NS_IMPL_CYCLE_COLLECTING_ADDREF(NotificationStorageCallback) +NS_IMPL_CYCLE_COLLECTING_RELEASE(NotificationStorageCallback) +NS_IMPL_CYCLE_COLLECTION(NotificationStorageCallback, mWindow, mPromise); + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(NotificationStorageCallback) + NS_INTERFACE_MAP_ENTRY(nsINotificationStorageCallback) + NS_INTERFACE_MAP_ENTRY(nsISupports) +NS_INTERFACE_MAP_END + +class NotificationGetRunnable final : public Runnable { + const nsString mOrigin; + const nsString mTag; + nsCOMPtr<nsINotificationStorageCallback> mCallback; + + public: + NotificationGetRunnable(const nsAString& aOrigin, const nsAString& aTag, + nsINotificationStorageCallback* aCallback) + : Runnable("NotificationGetRunnable"), + mOrigin(aOrigin), + mTag(aTag), + mCallback(aCallback) {} + + NS_IMETHOD + Run() override { + nsresult rv; + nsCOMPtr<nsINotificationStorage> notificationStorage = + do_GetService(NS_NOTIFICATION_STORAGE_CONTRACTID, &rv); + if (NS_WARN_IF(NS_FAILED(rv))) { + return rv; + } + + rv = notificationStorage->Get(mOrigin, mTag, mCallback); + // XXXnsm Is it guaranteed mCallback will be called in case of failure? + Unused << NS_WARN_IF(NS_FAILED(rv)); + return rv; + } +}; + +class NotificationPermissionRequest : public ContentPermissionRequestBase, + public nsIRunnable, + public nsINamed { + public: + NS_DECL_NSIRUNNABLE + NS_DECL_ISUPPORTS_INHERITED + NS_DECL_CYCLE_COLLECTION_CLASS_INHERITED(NotificationPermissionRequest, + ContentPermissionRequestBase) + + // nsIContentPermissionRequest + NS_IMETHOD Cancel(void) override; + NS_IMETHOD Allow(JS::Handle<JS::Value> choices) override; + + NotificationPermissionRequest(nsIPrincipal* aPrincipal, + nsPIDOMWindowInner* aWindow, Promise* aPromise, + NotificationPermissionCallback* aCallback) + : ContentPermissionRequestBase(aPrincipal, aWindow, "notification"_ns, + "desktop-notification"_ns), + mPermission(NotificationPermission::Default), + mPromise(aPromise), + mCallback(aCallback) { + MOZ_ASSERT(aPromise); + } + + NS_IMETHOD GetName(nsACString& aName) override { + aName.AssignLiteral("NotificationPermissionRequest"); + return NS_OK; + } + + protected: + ~NotificationPermissionRequest() = default; + + MOZ_CAN_RUN_SCRIPT nsresult ResolvePromise(); + nsresult DispatchResolvePromise(); + NotificationPermission mPermission; + RefPtr<Promise> mPromise; + RefPtr<NotificationPermissionCallback> mCallback; +}; + +namespace { +class ReleaseNotificationControlRunnable final + : public MainThreadWorkerControlRunnable { + Notification* mNotification; + + public: + explicit ReleaseNotificationControlRunnable(Notification* aNotification) + : MainThreadWorkerControlRunnable(aNotification->mWorkerPrivate), + mNotification(aNotification) {} + + bool WorkerRun(JSContext* aCx, WorkerPrivate* aWorkerPrivate) override { + mNotification->ReleaseObject(); + return true; + } +}; + +class GetPermissionRunnable final : public WorkerMainThreadRunnable { + NotificationPermission mPermission; + + public: + explicit GetPermissionRunnable(WorkerPrivate* aWorker) + : WorkerMainThreadRunnable(aWorker, "Notification :: Get Permission"_ns), + mPermission(NotificationPermission::Denied) {} + + bool MainThreadRun() override { + ErrorResult result; + mPermission = Notification::GetPermissionInternal( + mWorkerPrivate->GetPrincipal(), result); + return true; + } + + NotificationPermission GetPermission() { return mPermission; } +}; + +class FocusWindowRunnable final : public Runnable { + nsMainThreadPtrHandle<nsPIDOMWindowInner> mWindow; + + public: + explicit FocusWindowRunnable( + const nsMainThreadPtrHandle<nsPIDOMWindowInner>& aWindow) + : Runnable("FocusWindowRunnable"), mWindow(aWindow) {} + + // MOZ_CAN_RUN_SCRIPT_BOUNDARY until Runnable::Run is MOZ_CAN_RUN_SCRIPT. See + // bug 1535398. + MOZ_CAN_RUN_SCRIPT_BOUNDARY NS_IMETHOD Run() override { + AssertIsOnMainThread(); + if (!mWindow->IsCurrentInnerWindow()) { + // Window has been closed, this observer is not valid anymore + return NS_OK; + } + + nsCOMPtr<nsPIDOMWindowOuter> outerWindow = mWindow->GetOuterWindow(); + nsFocusManager::FocusWindow(outerWindow, CallerType::System); + return NS_OK; + } +}; + +nsresult CheckScope(nsIPrincipal* aPrincipal, const nsACString& aScope, + uint64_t aWindowID) { + AssertIsOnMainThread(); + MOZ_ASSERT(aPrincipal); + + nsCOMPtr<nsIURI> scopeURI; + nsresult rv = NS_NewURI(getter_AddRefs(scopeURI), aScope); + if (NS_WARN_IF(NS_FAILED(rv))) { + return rv; + } + + return aPrincipal->CheckMayLoadWithReporting( + scopeURI, + /* allowIfInheritsPrincipal = */ false, aWindowID); +} +} // anonymous namespace + +// Subclass that can be directly dispatched to child workers from the main +// thread. +class NotificationWorkerRunnable : public MainThreadWorkerRunnable { + protected: + explicit NotificationWorkerRunnable( + WorkerPrivate* aWorkerPrivate, + const char* aName = "NotificationWorkerRunnable") + : MainThreadWorkerRunnable(aWorkerPrivate, aName) {} + + bool WorkerRun(JSContext* aCx, WorkerPrivate* aWorkerPrivate) override { + aWorkerPrivate->AssertIsOnWorkerThread(); + // WorkerScope might start dying at the moment. And WorkerRunInternal() + // should not be executed once WorkerScope is dying, since + // WorkerRunInternal() might access resources which already been freed + // during WorkerRef::Notify(). + if (aWorkerPrivate->GlobalScope() && + !aWorkerPrivate->GlobalScope()->IsDying()) { + WorkerRunInternal(aWorkerPrivate); + } + return true; + } + + virtual void WorkerRunInternal(WorkerPrivate* aWorkerPrivate) = 0; +}; + +// Overrides dispatch and run handlers so we can directly dispatch from main +// thread to child workers. +class NotificationEventWorkerRunnable final + : public NotificationWorkerRunnable { + Notification* mNotification; + const nsString mEventName; + + public: + NotificationEventWorkerRunnable(Notification* aNotification, + const nsString& aEventName) + : NotificationWorkerRunnable(aNotification->mWorkerPrivate, + "NotificationEventWorkerRunnable"), + mNotification(aNotification), + mEventName(aEventName) {} + + void WorkerRunInternal(WorkerPrivate* aWorkerPrivate) override { + mNotification->DispatchTrustedEvent(mEventName); + } +}; + +class ReleaseNotificationRunnable final : public NotificationWorkerRunnable { + Notification* mNotification; + + public: + explicit ReleaseNotificationRunnable(Notification* aNotification) + : NotificationWorkerRunnable(aNotification->mWorkerPrivate, + "ReleaseNotificationRunnable"), + mNotification(aNotification) {} + + bool WorkerRun(JSContext* aCx, WorkerPrivate* aWorkerPrivate) override { + aWorkerPrivate->AssertIsOnWorkerThread(); + // ReleaseNotificationRunnable is only used in StrongWorkerRef's shutdown + // callback. At the moment, it is supposed to executing + // mNotification->ReleaseObject() safely even though the corresponding + // WorkerScope::IsDying() is true. It is unlike other + // NotificationWorkerRunnable. + WorkerRunInternal(aWorkerPrivate); + return true; + } + + void WorkerRunInternal(WorkerPrivate* aWorkerPrivate) override { + mNotification->ReleaseObject(); + } + + nsresult Cancel() override { + mNotification->ReleaseObject(); + return NS_OK; + } +}; + +// Create one whenever you require ownership of the notification. Use with +// UniquePtr<>. See Notification.h for details. +class NotificationRef final { + friend class WorkerNotificationObserver; + + private: + Notification* mNotification; + bool mInited; + + // Only useful for workers. + void Forget() { mNotification = nullptr; } + + public: + explicit NotificationRef(Notification* aNotification) + : mNotification(aNotification) { + MOZ_ASSERT(mNotification); + if (mNotification->mWorkerPrivate) { + mNotification->mWorkerPrivate->AssertIsOnWorkerThread(); + } else { + AssertIsOnMainThread(); + } + + mInited = mNotification->AddRefObject(); + } + + // This is only required because Gecko runs script in a worker's onclose + // handler (non-standard, Bug 790919) where calls to HoldWorker() will + // fail. Due to non-standardness and added complications if we decide to + // support this, attempts to create a Notification in onclose just throw + // exceptions. + bool Initialized() { return mInited; } + + ~NotificationRef() { + if (Initialized() && mNotification) { + Notification* notification = mNotification; + mNotification = nullptr; + if (notification->mWorkerPrivate && NS_IsMainThread()) { + // Try to pass ownership back to the worker. If the dispatch succeeds we + // are guaranteed this runnable will run, and that it will run after + // queued event runnables, so event runnables will have a safe pointer + // to the Notification. + // + // If the dispatch fails, the worker isn't running anymore and the event + // runnables have already run or been canceled. We can use a control + // runnable to release the reference. + RefPtr<ReleaseNotificationRunnable> r = + new ReleaseNotificationRunnable(notification); + + if (!r->Dispatch()) { + RefPtr<ReleaseNotificationControlRunnable> r = + new ReleaseNotificationControlRunnable(notification); + MOZ_ALWAYS_TRUE(r->Dispatch()); + } + } else { + notification->AssertIsOnTargetThread(); + notification->ReleaseObject(); + } + } + } + + // XXXnsm, is it worth having some sort of WeakPtr like wrapper instead of + // a rawptr that the NotificationRef can invalidate? + Notification* GetNotification() { + MOZ_ASSERT(Initialized()); + return mNotification; + } +}; + +class NotificationTask : public Runnable { + public: + enum NotificationAction { eShow, eClose }; + + NotificationTask(const char* aName, UniquePtr<NotificationRef> aRef, + NotificationAction aAction) + : Runnable(aName), mNotificationRef(std::move(aRef)), mAction(aAction) {} + + NS_IMETHOD + Run() override; + + protected: + virtual ~NotificationTask() = default; + + UniquePtr<NotificationRef> mNotificationRef; + NotificationAction mAction; +}; + +uint32_t Notification::sCount = 0; + +NS_IMPL_CYCLE_COLLECTION_INHERITED(NotificationPermissionRequest, + ContentPermissionRequestBase, mCallback) +NS_IMPL_ADDREF_INHERITED(NotificationPermissionRequest, + ContentPermissionRequestBase) +NS_IMPL_RELEASE_INHERITED(NotificationPermissionRequest, + ContentPermissionRequestBase) + +NS_IMPL_QUERY_INTERFACE_CYCLE_COLLECTION_INHERITED( + NotificationPermissionRequest, ContentPermissionRequestBase, nsIRunnable, + nsINamed) + +NS_IMETHODIMP +NotificationPermissionRequest::Run() { + bool isSystem = mPrincipal->IsSystemPrincipal(); + bool blocked = false; + if (isSystem) { + mPermission = NotificationPermission::Granted; + } else if ( + mPrincipal->GetPrivateBrowsingId() != 0 && + !StaticPrefs:: + dom_webnotifications_privateBrowsing_enableDespiteLimitations()) { + mPermission = NotificationPermission::Denied; + blocked = true; + } else { + // File are automatically granted permission. + + if (mPrincipal->SchemeIs("file")) { + mPermission = NotificationPermission::Granted; + } else if (!mWindow->IsSecureContext()) { + mPermission = NotificationPermission::Denied; + blocked = true; + nsCOMPtr<Document> doc = mWindow->GetExtantDoc(); + if (doc) { + nsContentUtils::ReportToConsole( + nsIScriptError::errorFlag, "DOM"_ns, doc, + nsContentUtils::eDOM_PROPERTIES, + "NotificationsInsecureRequestIsForbidden"); + } + } + } + + // We can't call ShowPrompt() directly here since our logic for determining + // whether to display a prompt depends on the checks above as well as the + // result of CheckPromptPrefs(). So we have to manually check the prompt + // prefs and decide what to do based on that. + PromptResult pr = CheckPromptPrefs(); + switch (pr) { + case PromptResult::Granted: + mPermission = NotificationPermission::Granted; + break; + case PromptResult::Denied: + mPermission = NotificationPermission::Denied; + break; + default: + // ignore + break; + } + + if (!mHasValidTransientUserGestureActivation && + !StaticPrefs::dom_webnotifications_requireuserinteraction()) { + nsCOMPtr<Document> doc = mWindow->GetExtantDoc(); + if (doc) { + doc->WarnOnceAbout(Document::eNotificationsRequireUserGestureDeprecation); + } + } + + // Check this after checking the prompt prefs to make sure this pref overrides + // those. We rely on this for testing purposes. + if (!isSystem && !blocked && + !StaticPrefs::dom_webnotifications_allowcrossoriginiframe() && + !mPrincipal->Subsumes(mTopLevelPrincipal)) { + mPermission = NotificationPermission::Denied; + blocked = true; + nsCOMPtr<Document> doc = mWindow->GetExtantDoc(); + if (doc) { + nsContentUtils::ReportToConsole( + nsIScriptError::errorFlag, "DOM"_ns, doc, + nsContentUtils::eDOM_PROPERTIES, + "NotificationsCrossOriginIframeRequestIsForbidden"); + } + } + + if (mPermission != NotificationPermission::Default) { + return DispatchResolvePromise(); + } + + return nsContentPermissionUtils::AskPermission(this, mWindow); +} + +NS_IMETHODIMP +NotificationPermissionRequest::Cancel() { + // `Cancel` is called if the user denied permission or dismissed the + // permission request. To distinguish between the two, we set the + // permission to "default" and query the permission manager in + // `ResolvePromise`. + mPermission = NotificationPermission::Default; + return DispatchResolvePromise(); +} + +NS_IMETHODIMP +NotificationPermissionRequest::Allow(JS::Handle<JS::Value> aChoices) { + MOZ_ASSERT(aChoices.isUndefined()); + + mPermission = NotificationPermission::Granted; + return DispatchResolvePromise(); +} + +inline nsresult NotificationPermissionRequest::DispatchResolvePromise() { + nsCOMPtr<nsIRunnable> resolver = + NewRunnableMethod("NotificationPermissionRequest::DispatchResolvePromise", + this, &NotificationPermissionRequest::ResolvePromise); + return nsGlobalWindowInner::Cast(mWindow.get())->Dispatch(resolver.forget()); +} + +nsresult NotificationPermissionRequest::ResolvePromise() { + nsresult rv = NS_OK; + // This will still be "default" if the user dismissed the doorhanger, + // or "denied" otherwise. + if (mPermission == NotificationPermission::Default) { + // When the front-end has decided to deny the permission request + // automatically and we are not handling user input, then log a + // warning in the current document that this happened because + // Notifications require a user gesture. + if (!mHasValidTransientUserGestureActivation && + StaticPrefs::dom_webnotifications_requireuserinteraction()) { + nsCOMPtr<Document> doc = mWindow->GetExtantDoc(); + if (doc) { + nsContentUtils::ReportToConsole(nsIScriptError::errorFlag, "DOM"_ns, + doc, nsContentUtils::eDOM_PROPERTIES, + "NotificationsRequireUserGesture"); + } + } + + mPermission = Notification::TestPermission(mPrincipal); + } + if (mCallback) { + ErrorResult error; + RefPtr<NotificationPermissionCallback> callback(mCallback); + callback->Call(mPermission, error); + rv = error.StealNSResult(); + } + mPromise->MaybeResolve(mPermission); + return rv; +} + +// Observer that the alert service calls to do common tasks and/or dispatch to +// the specific observer for the context e.g. main thread, worker, or service +// worker. +class NotificationObserver final : public nsIObserver { + public: + nsCOMPtr<nsIObserver> mObserver; + nsCOMPtr<nsIPrincipal> mPrincipal; + bool mInPrivateBrowsing; + NS_DECL_ISUPPORTS + NS_DECL_NSIOBSERVER + + NotificationObserver(nsIObserver* aObserver, nsIPrincipal* aPrincipal, + bool aInPrivateBrowsing) + : mObserver(aObserver), + mPrincipal(aPrincipal), + mInPrivateBrowsing(aInPrivateBrowsing) { + AssertIsOnMainThread(); + MOZ_ASSERT(mObserver); + MOZ_ASSERT(mPrincipal); + } + + protected: + virtual ~NotificationObserver() { AssertIsOnMainThread(); } + + nsresult AdjustPushQuota(const char* aTopic); +}; + +NS_IMPL_ISUPPORTS(NotificationObserver, nsIObserver) + +class MainThreadNotificationObserver : public nsIObserver { + public: + UniquePtr<NotificationRef> mNotificationRef; + NS_DECL_ISUPPORTS + NS_DECL_NSIOBSERVER + + explicit MainThreadNotificationObserver(UniquePtr<NotificationRef> aRef) + : mNotificationRef(std::move(aRef)) { + AssertIsOnMainThread(); + } + + protected: + virtual ~MainThreadNotificationObserver() { AssertIsOnMainThread(); } +}; + +NS_IMPL_ISUPPORTS(MainThreadNotificationObserver, nsIObserver) + +NS_IMETHODIMP +NotificationTask::Run() { + AssertIsOnMainThread(); + + // Get a pointer to notification before the notification takes ownership of + // the ref (it owns itself temporarily, with ShowInternal() and + // CloseInternal() passing on the ownership appropriately.) + Notification* notif = mNotificationRef->GetNotification(); + notif->mTempRef.swap(mNotificationRef); + if (mAction == eShow) { + notif->ShowInternal(); + } else if (mAction == eClose) { + notif->CloseInternal(); + } else { + MOZ_CRASH("Invalid action"); + } + + MOZ_ASSERT(!mNotificationRef); + return NS_OK; +} + +// static +bool Notification::PrefEnabled(JSContext* aCx, JSObject* aObj) { + return StaticPrefs::dom_webnotifications_enabled(); +} + +Notification::Notification(nsIGlobalObject* aGlobal, const nsAString& aID, + const nsAString& aTitle, const nsAString& aBody, + NotificationDirection aDir, const nsAString& aLang, + const nsAString& aTag, const nsAString& aIconUrl, + bool aRequireInteraction, bool aSilent, + nsTArray<uint32_t>&& aVibrate, + const NotificationBehavior& aBehavior) + : DOMEventTargetHelper(aGlobal), + mWorkerPrivate(nullptr), + mObserver(nullptr), + mID(aID), + mTitle(aTitle), + mBody(aBody), + mDir(aDir), + mLang(aLang), + mTag(aTag), + mIconUrl(aIconUrl), + mRequireInteraction(aRequireInteraction), + mSilent(aSilent), + mVibrate(std::move(aVibrate)), + mBehavior(aBehavior), + mData(JS::NullValue()), + mIsClosed(false), + mIsStored(false), + mTaskCount(0) { + if (!NS_IsMainThread()) { + mWorkerPrivate = GetCurrentThreadWorkerPrivate(); + MOZ_ASSERT(mWorkerPrivate); + } +} + +nsresult Notification::Init() { + if (!mWorkerPrivate) { + nsCOMPtr<nsIObserverService> obs = mozilla::services::GetObserverService(); + NS_ENSURE_TRUE(obs, NS_ERROR_FAILURE); + + nsresult rv = obs->AddObserver(this, DOM_WINDOW_DESTROYED_TOPIC, true); + NS_ENSURE_SUCCESS(rv, rv); + + rv = obs->AddObserver(this, DOM_WINDOW_FROZEN_TOPIC, true); + NS_ENSURE_SUCCESS(rv, rv); + } + + return NS_OK; +} + +void Notification::SetAlertName() { + AssertIsOnMainThread(); + if (!mAlertName.IsEmpty()) { + return; + } + + nsAutoString alertName; + nsresult rv = GetOrigin(GetPrincipal(), alertName); + if (NS_WARN_IF(NS_FAILED(rv))) { + return; + } + + // Get the notification name that is unique per origin + tag/ID. + // The name of the alert is of the form origin#tag/ID. + alertName.Append('#'); + if (!mTag.IsEmpty()) { + alertName.AppendLiteral("tag:"); + alertName.Append(mTag); + } else { + alertName.AppendLiteral("notag:"); + alertName.Append(mID); + } + + mAlertName = alertName; +} + +// May be called on any thread. +// static +already_AddRefed<Notification> Notification::Constructor( + const GlobalObject& aGlobal, const nsAString& aTitle, + const NotificationOptions& aOptions, ErrorResult& aRv) { + // FIXME(nsm): If the sticky flag is set, throw an error. + RefPtr<ServiceWorkerGlobalScope> scope; + UNWRAP_OBJECT(ServiceWorkerGlobalScope, aGlobal.Get(), scope); + if (scope) { + aRv.ThrowTypeError( + "Notification constructor cannot be used in ServiceWorkerGlobalScope. " + "Use registration.showNotification() instead."); + return nullptr; + } + + nsCOMPtr<nsIGlobalObject> global = do_QueryInterface(aGlobal.GetAsSupports()); + RefPtr<Notification> notification = + CreateAndShow(aGlobal.Context(), global, aTitle, aOptions, u""_ns, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return nullptr; + } + + // This is be ok since we are on the worker thread where this function will + // run to completion before the Notification has a chance to go away. + return notification.forget(); +} + +// static +Result<already_AddRefed<Notification>, QMResult> +Notification::ConstructFromFields( + nsIGlobalObject* aGlobal, const nsAString& aID, const nsAString& aTitle, + const nsAString& aDir, const nsAString& aLang, const nsAString& aBody, + const nsAString& aTag, const nsAString& aIcon, const nsAString& aData, + const nsAString& aServiceWorkerRegistrationScope) { + MOZ_ASSERT(aGlobal); + + RootedDictionary<NotificationOptions> options(RootingCx()); + options.mDir = Notification::StringToDirection(nsString(aDir)); + options.mLang = aLang; + options.mBody = aBody; + options.mTag = aTag; + options.mIcon = aIcon; + IgnoredErrorResult rv; + RefPtr<Notification> notification = + CreateInternal(aGlobal, aID, aTitle, options, rv); + if (NS_WARN_IF(rv.Failed())) { + return Err(ToQMResult(NS_ERROR_FAILURE)); + } + + QM_TRY(notification->InitFromBase64(aData)); + + notification->SetScope(aServiceWorkerRegistrationScope); + + return notification.forget(); +} + +nsresult Notification::PersistNotification() { + AssertIsOnMainThread(); + nsresult rv; + nsCOMPtr<nsINotificationStorage> notificationStorage = + do_GetService(NS_NOTIFICATION_STORAGE_CONTRACTID, &rv); + if (NS_FAILED(rv)) { + return rv; + } + + nsString origin; + rv = GetOrigin(GetPrincipal(), origin); + if (NS_WARN_IF(NS_FAILED(rv))) { + return rv; + } + + nsString id; + GetID(id); + + nsString alertName; + GetAlertName(alertName); + + nsAutoString behavior; + if (!mBehavior.ToJSON(behavior)) { + return NS_ERROR_FAILURE; + } + + rv = notificationStorage->Put(origin, id, mTitle, DirectionToString(mDir), + mLang, mBody, mTag, mIconUrl, alertName, + mDataAsBase64, behavior, mScope); + + if (NS_FAILED(rv)) { + return rv; + } + + SetStoredState(true); + return NS_OK; +} + +void Notification::UnpersistNotification() { + AssertIsOnMainThread(); + if (IsStored()) { + nsCOMPtr<nsINotificationStorage> notificationStorage = + do_GetService(NS_NOTIFICATION_STORAGE_CONTRACTID); + if (notificationStorage) { + nsString origin; + nsresult rv = GetOrigin(GetPrincipal(), origin); + if (NS_SUCCEEDED(rv)) { + notificationStorage->Delete(origin, mID); + } + } + SetStoredState(false); + } +} + +already_AddRefed<Notification> Notification::CreateInternal( + nsIGlobalObject* aGlobal, const nsAString& aID, const nsAString& aTitle, + const NotificationOptions& aOptions, ErrorResult& aRv) { + nsresult rv; + nsString id; + if (!aID.IsEmpty()) { + id = aID; + } else { + nsCOMPtr<nsIUUIDGenerator> uuidgen = + do_GetService("@mozilla.org/uuid-generator;1"); + NS_ENSURE_TRUE(uuidgen, nullptr); + nsID uuid; + rv = uuidgen->GenerateUUIDInPlace(&uuid); + NS_ENSURE_SUCCESS(rv, nullptr); + + char buffer[NSID_LENGTH]; + uuid.ToProvidedString(buffer); + NS_ConvertASCIItoUTF16 convertedID(buffer); + id = convertedID; + } + + bool silent = false; + if (StaticPrefs::dom_webnotifications_silent_enabled()) { + silent = aOptions.mSilent; + } + + nsTArray<uint32_t> vibrate; + if (StaticPrefs::dom_webnotifications_vibrate_enabled() && + aOptions.mVibrate.WasPassed()) { + if (silent) { + aRv.ThrowTypeError( + "Silent notifications must not specify vibration patterns."); + return nullptr; + } + + const OwningUnsignedLongOrUnsignedLongSequence& value = + aOptions.mVibrate.Value(); + if (value.IsUnsignedLong()) { + AutoTArray<uint32_t, 1> array; + array.AppendElement(value.GetAsUnsignedLong()); + vibrate = SanitizeVibratePattern(array); + } else { + vibrate = SanitizeVibratePattern(value.GetAsUnsignedLongSequence()); + } + } + + RefPtr<Notification> notification = new Notification( + aGlobal, id, aTitle, aOptions.mBody, aOptions.mDir, aOptions.mLang, + aOptions.mTag, aOptions.mIcon, aOptions.mRequireInteraction, silent, + std::move(vibrate), aOptions.mMozbehavior); + rv = notification->Init(); + NS_ENSURE_SUCCESS(rv, nullptr); + return notification.forget(); +} + +Notification::~Notification() { + mozilla::DropJSObjects(this); + AssertIsOnTargetThread(); + MOZ_ASSERT(!mWorkerRef); + MOZ_ASSERT(!mTempRef); +} + +NS_IMPL_CYCLE_COLLECTION_CLASS(Notification) +NS_IMPL_CYCLE_COLLECTION_UNLINK_BEGIN_INHERITED(Notification, + DOMEventTargetHelper) + tmp->mData.setUndefined(); + NS_IMPL_CYCLE_COLLECTION_UNLINK_WEAK_REFERENCE +NS_IMPL_CYCLE_COLLECTION_UNLINK_END + +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_BEGIN_INHERITED(Notification, + DOMEventTargetHelper) +NS_IMPL_CYCLE_COLLECTION_TRAVERSE_END + +NS_IMPL_CYCLE_COLLECTION_TRACE_BEGIN_INHERITED(Notification, + DOMEventTargetHelper) + NS_IMPL_CYCLE_COLLECTION_TRACE_JS_MEMBER_CALLBACK(mData) +NS_IMPL_CYCLE_COLLECTION_TRACE_END + +NS_IMPL_ADDREF_INHERITED(Notification, DOMEventTargetHelper) +NS_IMPL_RELEASE_INHERITED(Notification, DOMEventTargetHelper) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(Notification) + NS_INTERFACE_MAP_ENTRY(nsIObserver) + NS_INTERFACE_MAP_ENTRY(nsISupportsWeakReference) +NS_INTERFACE_MAP_END_INHERITING(DOMEventTargetHelper) + +nsIPrincipal* Notification::GetPrincipal() { + AssertIsOnMainThread(); + if (mWorkerPrivate) { + return mWorkerPrivate->GetPrincipal(); + } else { + nsCOMPtr<nsIScriptObjectPrincipal> sop = do_QueryInterface(GetOwner()); + NS_ENSURE_TRUE(sop, nullptr); + return sop->GetPrincipal(); + } +} + +class WorkerNotificationObserver final : public MainThreadNotificationObserver { + public: + NS_INLINE_DECL_REFCOUNTING_INHERITED(WorkerNotificationObserver, + MainThreadNotificationObserver) + NS_DECL_NSIOBSERVER + + explicit WorkerNotificationObserver(UniquePtr<NotificationRef> aRef) + : MainThreadNotificationObserver(std::move(aRef)) { + AssertIsOnMainThread(); + MOZ_ASSERT(mNotificationRef->GetNotification()->mWorkerPrivate); + } + + void ForgetNotification() { + AssertIsOnMainThread(); + mNotificationRef->Forget(); + } + + protected: + virtual ~WorkerNotificationObserver() { + AssertIsOnMainThread(); + + MOZ_ASSERT(mNotificationRef); + Notification* notification = mNotificationRef->GetNotification(); + if (notification) { + notification->mObserver = nullptr; + } + } +}; + +class ServiceWorkerNotificationObserver final : public nsIObserver { + public: + NS_DECL_ISUPPORTS + NS_DECL_NSIOBSERVER + + ServiceWorkerNotificationObserver( + const nsAString& aScope, nsIPrincipal* aPrincipal, const nsAString& aID, + const nsAString& aTitle, const nsAString& aDir, const nsAString& aLang, + const nsAString& aBody, const nsAString& aTag, const nsAString& aIcon, + const nsAString& aData, const nsAString& aBehavior) + : mScope(aScope), + mID(aID), + mPrincipal(aPrincipal), + mTitle(aTitle), + mDir(aDir), + mLang(aLang), + mBody(aBody), + mTag(aTag), + mIcon(aIcon), + mData(aData), + mBehavior(aBehavior) { + AssertIsOnMainThread(); + MOZ_ASSERT(aPrincipal); + } + + private: + ~ServiceWorkerNotificationObserver() = default; + + const nsString mScope; + const nsString mID; + nsCOMPtr<nsIPrincipal> mPrincipal; + const nsString mTitle; + const nsString mDir; + const nsString mLang; + const nsString mBody; + const nsString mTag; + const nsString mIcon; + const nsString mData; + const nsString mBehavior; +}; + +NS_IMPL_ISUPPORTS(ServiceWorkerNotificationObserver, nsIObserver) + +bool Notification::DispatchClickEvent() { + AssertIsOnTargetThread(); + RefPtr<Event> event = NS_NewDOMEvent(this, nullptr, nullptr); + event->InitEvent(u"click"_ns, false, true); + event->SetTrusted(true); + WantsPopupControlCheck popupControlCheck(event); + return DispatchEvent(*event, CallerType::System, IgnoreErrors()); +} + +// Overrides dispatch and run handlers so we can directly dispatch from main +// thread to child workers. +class NotificationClickWorkerRunnable final + : public NotificationWorkerRunnable { + Notification* mNotification; + // Optional window that gets focused if click event is not + // preventDefault()ed. + nsMainThreadPtrHandle<nsPIDOMWindowInner> mWindow; + + public: + NotificationClickWorkerRunnable( + Notification* aNotification, + const nsMainThreadPtrHandle<nsPIDOMWindowInner>& aWindow) + : NotificationWorkerRunnable(aNotification->mWorkerPrivate, + "NotificationClickWorkerRunnable"), + mNotification(aNotification), + mWindow(aWindow) { + MOZ_ASSERT_IF(mWorkerPrivate->IsServiceWorker(), !mWindow); + } + + void WorkerRunInternal(WorkerPrivate* aWorkerPrivate) override { + bool doDefaultAction = mNotification->DispatchClickEvent(); + MOZ_ASSERT_IF(mWorkerPrivate->IsServiceWorker(), !doDefaultAction); + if (doDefaultAction) { + RefPtr<FocusWindowRunnable> r = new FocusWindowRunnable(mWindow); + mWorkerPrivate->DispatchToMainThread(r.forget()); + } + } +}; + +NS_IMETHODIMP +NotificationObserver::Observe(nsISupports* aSubject, const char* aTopic, + const char16_t* aData) { + AssertIsOnMainThread(); + + if (!strcmp("alertdisablecallback", aTopic)) { + if (XRE_IsParentProcess()) { + return Notification::RemovePermission(mPrincipal); + } + // Permissions can't be removed from the content process. Send a message + // to the parent; `ContentParent::RecvDisableNotifications` will call + // `RemovePermission`. + ContentChild::GetSingleton()->SendDisableNotifications(mPrincipal); + return NS_OK; + } else if (!strcmp("alertsettingscallback", aTopic)) { + if (XRE_IsParentProcess()) { + return Notification::OpenSettings(mPrincipal); + } + // `ContentParent::RecvOpenNotificationSettings` notifies observers in the + // parent process. + ContentChild::GetSingleton()->SendOpenNotificationSettings(mPrincipal); + return NS_OK; + } else if (!strcmp("alertshow", aTopic) || !strcmp("alertfinished", aTopic)) { + Unused << NS_WARN_IF(NS_FAILED(AdjustPushQuota(aTopic))); + } + + return mObserver->Observe(aSubject, aTopic, aData); +} + +nsresult NotificationObserver::AdjustPushQuota(const char* aTopic) { + nsCOMPtr<nsIPushQuotaManager> pushQuotaManager = + do_GetService("@mozilla.org/push/Service;1"); + if (!pushQuotaManager) { + return NS_ERROR_FAILURE; + } + + nsAutoCString origin; + nsresult rv = mPrincipal->GetOrigin(origin); + if (NS_FAILED(rv)) { + return rv; + } + + if (!strcmp("alertshow", aTopic)) { + return pushQuotaManager->NotificationForOriginShown(origin.get()); + } + return pushQuotaManager->NotificationForOriginClosed(origin.get()); +} + +// MOZ_CAN_RUN_SCRIPT_BOUNDARY until Runnable::Run is MOZ_CAN_RUN_SCRIPT. See +// bug 1539845. +MOZ_CAN_RUN_SCRIPT_BOUNDARY NS_IMETHODIMP +MainThreadNotificationObserver::Observe(nsISupports* aSubject, + const char* aTopic, + const char16_t* aData) { + AssertIsOnMainThread(); + MOZ_ASSERT(mNotificationRef); + Notification* notification = mNotificationRef->GetNotification(); + MOZ_ASSERT(notification); + if (!strcmp("alertclickcallback", aTopic)) { + nsCOMPtr<nsPIDOMWindowInner> window = notification->GetOwner(); + if (NS_WARN_IF(!window || !window->IsCurrentInnerWindow())) { + // Window has been closed, this observer is not valid anymore + return NS_ERROR_FAILURE; + } + + bool doDefaultAction = notification->DispatchClickEvent(); + if (doDefaultAction) { + nsCOMPtr<nsPIDOMWindowOuter> outerWindow = window->GetOuterWindow(); + nsFocusManager::FocusWindow(outerWindow, CallerType::System); + } + } else if (!strcmp("alertfinished", aTopic)) { + notification->UnpersistNotification(); + notification->mIsClosed = true; + notification->DispatchTrustedEvent(u"close"_ns); + } else if (!strcmp("alertshow", aTopic)) { + notification->DispatchTrustedEvent(u"show"_ns); + } + return NS_OK; +} + +NS_IMETHODIMP +WorkerNotificationObserver::Observe(nsISupports* aSubject, const char* aTopic, + const char16_t* aData) { + AssertIsOnMainThread(); + MOZ_ASSERT(mNotificationRef); + // For an explanation of why it is OK to pass this rawptr to the event + // runnables, see the Notification class comment. + Notification* notification = mNotificationRef->GetNotification(); + // We can't assert notification here since the feature could've unset it. + if (NS_WARN_IF(!notification)) { + return NS_ERROR_FAILURE; + } + + MOZ_ASSERT(notification->mWorkerPrivate); + + RefPtr<WorkerRunnable> r; + if (!strcmp("alertclickcallback", aTopic)) { + nsPIDOMWindowInner* window = nullptr; + if (!notification->mWorkerPrivate->IsServiceWorker()) { + WorkerPrivate* top = notification->mWorkerPrivate; + while (top->GetParent()) { + top = top->GetParent(); + } + + window = top->GetWindow(); + if (NS_WARN_IF(!window || !window->IsCurrentInnerWindow())) { + // Window has been closed, this observer is not valid anymore + return NS_ERROR_FAILURE; + } + } + + // Instead of bothering with adding features and other worker lifecycle + // management, we simply hold strongrefs to the window and document. + nsMainThreadPtrHandle<nsPIDOMWindowInner> windowHandle( + new nsMainThreadPtrHolder<nsPIDOMWindowInner>( + "WorkerNotificationObserver::Observe::nsPIDOMWindowInner", window)); + + r = new NotificationClickWorkerRunnable(notification, windowHandle); + } else if (!strcmp("alertfinished", aTopic)) { + notification->UnpersistNotification(); + notification->mIsClosed = true; + r = new NotificationEventWorkerRunnable(notification, u"close"_ns); + } else if (!strcmp("alertshow", aTopic)) { + r = new NotificationEventWorkerRunnable(notification, u"show"_ns); + } + + MOZ_ASSERT(r); + if (!r->Dispatch()) { + NS_WARNING("Could not dispatch event to worker notification"); + } + return NS_OK; +} + +NS_IMETHODIMP +ServiceWorkerNotificationObserver::Observe(nsISupports* aSubject, + const char* aTopic, + const char16_t* aData) { + AssertIsOnMainThread(); + + nsAutoCString originSuffix; + nsresult rv = mPrincipal->GetOriginSuffix(originSuffix); + if (NS_WARN_IF(NS_FAILED(rv))) { + return rv; + } + + if (!strcmp("alertclickcallback", aTopic)) { + if (XRE_IsParentProcess()) { + nsCOMPtr<nsIServiceWorkerManager> swm = + mozilla::components::ServiceWorkerManager::Service(); + if (NS_WARN_IF(!swm)) { + return NS_ERROR_FAILURE; + } + + rv = swm->SendNotificationClickEvent( + originSuffix, NS_ConvertUTF16toUTF8(mScope), mID, mTitle, mDir, mLang, + mBody, mTag, mIcon, mData, mBehavior); + Unused << NS_WARN_IF(NS_FAILED(rv)); + } else { + auto* cc = ContentChild::GetSingleton(); + NotificationEventData data(originSuffix, NS_ConvertUTF16toUTF8(mScope), + mID, mTitle, mDir, mLang, mBody, mTag, mIcon, + mData, mBehavior); + Unused << cc->SendNotificationEvent(u"click"_ns, data); + } + return NS_OK; + } + + if (!strcmp("alertfinished", aTopic)) { + nsString origin; + nsresult rv = Notification::GetOrigin(mPrincipal, origin); + if (NS_WARN_IF(NS_FAILED(rv))) { + return rv; + } + + // Remove closed or dismissed persistent notifications. + nsCOMPtr<nsINotificationStorage> notificationStorage = + do_GetService(NS_NOTIFICATION_STORAGE_CONTRACTID); + if (notificationStorage) { + notificationStorage->Delete(origin, mID); + } + + if (XRE_IsParentProcess()) { + nsCOMPtr<nsIServiceWorkerManager> swm = + mozilla::components::ServiceWorkerManager::Service(); + if (NS_WARN_IF(!swm)) { + return NS_ERROR_FAILURE; + } + + rv = swm->SendNotificationCloseEvent( + originSuffix, NS_ConvertUTF16toUTF8(mScope), mID, mTitle, mDir, mLang, + mBody, mTag, mIcon, mData, mBehavior); + Unused << NS_WARN_IF(NS_FAILED(rv)); + } else { + auto* cc = ContentChild::GetSingleton(); + NotificationEventData data(originSuffix, NS_ConvertUTF16toUTF8(mScope), + mID, mTitle, mDir, mLang, mBody, mTag, mIcon, + mData, mBehavior); + Unused << cc->SendNotificationEvent(u"close"_ns, data); + } + return NS_OK; + } + + return NS_OK; +} + +bool Notification::IsInPrivateBrowsing() { + AssertIsOnMainThread(); + + Document* doc = nullptr; + + if (mWorkerPrivate) { + doc = mWorkerPrivate->GetDocument(); + } else if (GetOwner()) { + doc = GetOwner()->GetExtantDoc(); + } + + if (doc) { + nsCOMPtr<nsILoadContext> loadContext = doc->GetLoadContext(); + return loadContext && loadContext->UsePrivateBrowsing(); + } + + if (mWorkerPrivate) { + // Not all workers may have a document, but with Bug 1107516 fixed, they + // should all have a loadcontext. + nsCOMPtr<nsILoadGroup> loadGroup = mWorkerPrivate->GetLoadGroup(); + nsCOMPtr<nsILoadContext> loadContext; + NS_QueryNotificationCallbacks(nullptr, loadGroup, + NS_GET_IID(nsILoadContext), + getter_AddRefs(loadContext)); + return loadContext && loadContext->UsePrivateBrowsing(); + } + + // XXXnsm Should this default to true? + return false; +} + +void Notification::ShowInternal() { + AssertIsOnMainThread(); + MOZ_ASSERT(mTempRef, + "Notification should take ownership of itself before" + "calling ShowInternal!"); + // A notification can only have one observer and one call to ShowInternal. + MOZ_ASSERT(!mObserver); + + // Transfer ownership to local scope so we can either release it at the end + // of this function or transfer it to the observer. + UniquePtr<NotificationRef> ownership; + std::swap(ownership, mTempRef); + MOZ_ASSERT(ownership->GetNotification() == this); + + nsresult rv = PersistNotification(); + if (NS_FAILED(rv)) { + NS_WARNING("Could not persist Notification"); + } + + nsCOMPtr<nsIAlertsService> alertService = components::Alerts::Service(); + + ErrorResult result; + NotificationPermission permission = NotificationPermission::Denied; + if (mWorkerPrivate) { + permission = GetPermissionInternal(mWorkerPrivate->GetPrincipal(), result); + } else { + permission = GetPermissionInternal(GetOwner(), result); + } + // We rely on GetPermissionInternal returning Denied on all failure codepaths. + MOZ_ASSERT_IF(result.Failed(), permission == NotificationPermission::Denied); + result.SuppressException(); + if (permission != NotificationPermission::Granted || !alertService) { + if (mWorkerPrivate) { + RefPtr<NotificationEventWorkerRunnable> r = + new NotificationEventWorkerRunnable(this, u"error"_ns); + if (!r->Dispatch()) { + NS_WARNING("Could not dispatch event to worker notification"); + } + } else { + DispatchTrustedEvent(u"error"_ns); + } + return; + } + + nsAutoString iconUrl; + nsAutoString soundUrl; + ResolveIconAndSoundURL(iconUrl, soundUrl); + + bool isPersistent = false; + nsCOMPtr<nsIObserver> observer; + if (mScope.IsEmpty()) { + // Ownership passed to observer. + if (mWorkerPrivate) { + // Scope better be set on ServiceWorker initiated requests. + MOZ_ASSERT(!mWorkerPrivate->IsServiceWorker()); + // Keep a pointer so that the feature can tell the observer not to release + // the notification. + mObserver = new WorkerNotificationObserver(std::move(ownership)); + observer = mObserver; + } else { + observer = new MainThreadNotificationObserver(std::move(ownership)); + } + } else { + isPersistent = true; + // This observer does not care about the Notification. It will be released + // at the end of this function. + // + // The observer is wholly owned by the NotificationObserver passed to the + // alert service. + nsAutoString behavior; + if (NS_WARN_IF(!mBehavior.ToJSON(behavior))) { + behavior.Truncate(); + } + observer = new ServiceWorkerNotificationObserver( + mScope, GetPrincipal(), mID, mTitle, DirectionToString(mDir), mLang, + mBody, mTag, iconUrl, mDataAsBase64, behavior); + } + MOZ_ASSERT(observer); + nsCOMPtr<nsIObserver> alertObserver = + new NotificationObserver(observer, GetPrincipal(), IsInPrivateBrowsing()); + + // In the case of IPC, the parent process uses the cookie to map to + // nsIObserver. Thus the cookie must be unique to differentiate observers. + nsString uniqueCookie = u"notification:"_ns; + uniqueCookie.AppendInt(sCount++); + bool inPrivateBrowsing = IsInPrivateBrowsing(); + + bool requireInteraction = mRequireInteraction; + if (!StaticPrefs::dom_webnotifications_requireinteraction_enabled()) { + requireInteraction = false; + } + + nsAutoString alertName; + GetAlertName(alertName); + nsCOMPtr<nsIAlertNotification> alert = + do_CreateInstance(ALERT_NOTIFICATION_CONTRACTID); + NS_ENSURE_TRUE_VOID(alert); + nsIPrincipal* principal = GetPrincipal(); + rv = + alert->Init(alertName, iconUrl, mTitle, mBody, true, uniqueCookie, + DirectionToString(mDir), mLang, mDataAsBase64, GetPrincipal(), + inPrivateBrowsing, requireInteraction, mSilent, mVibrate); + NS_ENSURE_SUCCESS_VOID(rv); + + if (isPersistent) { + JSONStringWriteFunc<nsAutoCString> persistentData; + JSONWriter w(persistentData); + w.Start(); + + nsAutoString origin; + Notification::GetOrigin(principal, origin); + w.StringProperty("origin", NS_ConvertUTF16toUTF8(origin)); + + w.StringProperty("id", NS_ConvertUTF16toUTF8(mID)); + + nsAutoCString originSuffix; + principal->GetOriginSuffix(originSuffix); + w.StringProperty("originSuffix", originSuffix); + + w.End(); + + alertService->ShowPersistentNotification( + NS_ConvertUTF8toUTF16(persistentData.StringCRef()), alert, + alertObserver); + } else { + alertService->ShowAlert(alert, alertObserver); + } +} + +/* static */ +bool Notification::RequestPermissionEnabledForScope(JSContext* aCx, + JSObject* /* unused */) { + // requestPermission() is not allowed on workers. The calling page should ask + // for permission on the worker's behalf. This is to prevent 'which window + // should show the browser pop-up'. See discussion: + // http://lists.whatwg.org/pipermail/whatwg-whatwg.org/2013-October/041272.html + return NS_IsMainThread(); +} + +// static +already_AddRefed<Promise> Notification::RequestPermission( + const GlobalObject& aGlobal, + const Optional<OwningNonNull<NotificationPermissionCallback> >& aCallback, + ErrorResult& aRv) { + AssertIsOnMainThread(); + + // Get principal from global to make permission request for notifications. + nsCOMPtr<nsPIDOMWindowInner> window = + do_QueryInterface(aGlobal.GetAsSupports()); + nsCOMPtr<nsIScriptObjectPrincipal> sop = + do_QueryInterface(aGlobal.GetAsSupports()); + if (!sop || !window) { + aRv.Throw(NS_ERROR_UNEXPECTED); + return nullptr; + } + + nsCOMPtr<nsIPrincipal> principal = sop->GetPrincipal(); + if (!principal) { + aRv.Throw(NS_ERROR_UNEXPECTED); + return nullptr; + } + + RefPtr<Promise> promise = Promise::Create(window->AsGlobal(), aRv); + if (aRv.Failed()) { + return nullptr; + } + NotificationPermissionCallback* permissionCallback = nullptr; + if (aCallback.WasPassed()) { + permissionCallback = &aCallback.Value(); + } + nsCOMPtr<nsIRunnable> request = new NotificationPermissionRequest( + principal, window, promise, permissionCallback); + + window->AsGlobal()->Dispatch(request.forget()); + + return promise.forget(); +} + +// static +NotificationPermission Notification::GetPermission(const GlobalObject& aGlobal, + ErrorResult& aRv) { + nsCOMPtr<nsIGlobalObject> global = do_QueryInterface(aGlobal.GetAsSupports()); + return GetPermission(global, aRv); +} + +// static +NotificationPermission Notification::GetPermission(nsIGlobalObject* aGlobal, + ErrorResult& aRv) { + if (NS_IsMainThread()) { + return GetPermissionInternal(aGlobal->GetAsInnerWindow(), aRv); + } else { + WorkerPrivate* worker = GetCurrentThreadWorkerPrivate(); + MOZ_ASSERT(worker); + RefPtr<GetPermissionRunnable> r = new GetPermissionRunnable(worker); + r->Dispatch(Canceling, aRv); + if (aRv.Failed()) { + return NotificationPermission::Denied; + } + + return r->GetPermission(); + } +} + +/* static */ +NotificationPermission Notification::GetPermissionInternal( + nsPIDOMWindowInner* aWindow, ErrorResult& aRv) { + // Get principal from global to check permission for notifications. + nsCOMPtr<nsIScriptObjectPrincipal> sop = do_QueryInterface(aWindow); + if (!sop) { + aRv.Throw(NS_ERROR_UNEXPECTED); + return NotificationPermission::Denied; + } + + nsCOMPtr<nsIPrincipal> principal = sop->GetPrincipal(); + if (!principal) { + aRv.Throw(NS_ERROR_UNEXPECTED); + return NotificationPermission::Denied; + } + + if (principal->GetPrivateBrowsingId() != 0 && + !StaticPrefs:: + dom_webnotifications_privateBrowsing_enableDespiteLimitations()) { + return NotificationPermission::Denied; + } + // Disallow showing notification if our origin is not the same origin as the + // toplevel one, see https://github.com/whatwg/notifications/issues/177. + if (!StaticPrefs::dom_webnotifications_allowcrossoriginiframe()) { + nsCOMPtr<nsIScriptObjectPrincipal> topSop = + do_QueryInterface(aWindow->GetBrowsingContext()->Top()->GetDOMWindow()); + nsIPrincipal* topPrincipal = topSop ? topSop->GetPrincipal() : nullptr; + if (!topPrincipal || !principal->Subsumes(topPrincipal)) { + return NotificationPermission::Denied; + } + } + + return GetPermissionInternal(principal, aRv); +} + +/* static */ +NotificationPermission Notification::GetPermissionInternal( + nsIPrincipal* aPrincipal, ErrorResult& aRv) { + AssertIsOnMainThread(); + MOZ_ASSERT(aPrincipal); + + if (aPrincipal->IsSystemPrincipal()) { + return NotificationPermission::Granted; + } else { + // Allow files to show notifications by default. + if (aPrincipal->SchemeIs("file")) { + return NotificationPermission::Granted; + } + } + + // We also allow notifications is they are pref'ed on. + if (Preferences::GetBool("notification.prompt.testing", false)) { + if (Preferences::GetBool("notification.prompt.testing.allow", true)) { + return NotificationPermission::Granted; + } else { + return NotificationPermission::Denied; + } + } + + return TestPermission(aPrincipal); +} + +/* static */ +NotificationPermission Notification::TestPermission(nsIPrincipal* aPrincipal) { + AssertIsOnMainThread(); + + uint32_t permission = nsIPermissionManager::UNKNOWN_ACTION; + + nsCOMPtr<nsIPermissionManager> permissionManager = + components::PermissionManager::Service(); + if (!permissionManager) { + return NotificationPermission::Default; + } + + permissionManager->TestExactPermissionFromPrincipal( + aPrincipal, "desktop-notification"_ns, &permission); + + // Convert the result to one of the enum types. + switch (permission) { + case nsIPermissionManager::ALLOW_ACTION: + return NotificationPermission::Granted; + case nsIPermissionManager::DENY_ACTION: + return NotificationPermission::Denied; + default: + return NotificationPermission::Default; + } +} + +nsresult Notification::ResolveIconAndSoundURL(nsString& iconUrl, + nsString& soundUrl) { + AssertIsOnMainThread(); + nsresult rv = NS_OK; + + nsIURI* baseUri = nullptr; + + // XXXnsm If I understand correctly, the character encoding for resolving + // URIs in new specs is dictated by the URL spec, which states that unless + // the URL parser is passed an override encoding, the charset to be used is + // UTF-8. The new Notification icon/sound specification just says to use the + // Fetch API, where the Request constructor defers to URL parsing specifying + // the API base URL and no override encoding. So we've to use UTF-8 on + // workers, but for backwards compat keeping it document charset on main + // thread. + auto encoding = UTF_8_ENCODING; + + if (mWorkerPrivate) { + baseUri = mWorkerPrivate->GetBaseURI(); + } else { + Document* doc = GetOwner() ? GetOwner()->GetExtantDoc() : nullptr; + if (doc) { + baseUri = doc->GetBaseURI(); + encoding = doc->GetDocumentCharacterSet(); + } else { + NS_WARNING("No document found for main thread notification!"); + return NS_ERROR_FAILURE; + } + } + + if (baseUri) { + if (mIconUrl.Length() > 0) { + nsCOMPtr<nsIURI> srcUri; + rv = NS_NewURI(getter_AddRefs(srcUri), mIconUrl, encoding, baseUri); + if (NS_SUCCEEDED(rv)) { + nsAutoCString src; + srcUri->GetSpec(src); + CopyUTF8toUTF16(src, iconUrl); + } + } + if (mBehavior.mSoundFile.Length() > 0) { + nsCOMPtr<nsIURI> srcUri; + rv = NS_NewURI(getter_AddRefs(srcUri), mBehavior.mSoundFile, encoding, + baseUri); + if (NS_SUCCEEDED(rv)) { + nsAutoCString src; + srcUri->GetSpec(src); + CopyUTF8toUTF16(src, soundUrl); + } + } + } + + return rv; +} + +already_AddRefed<Promise> Notification::Get( + nsPIDOMWindowInner* aWindow, const GetNotificationOptions& aFilter, + const nsAString& aScope, ErrorResult& aRv) { + AssertIsOnMainThread(); + MOZ_ASSERT(aWindow); + + nsCOMPtr<Document> doc = aWindow->GetExtantDoc(); + if (!doc) { + aRv.Throw(NS_ERROR_UNEXPECTED); + return nullptr; + } + + nsString origin; + aRv = GetOrigin(doc->NodePrincipal(), origin); + if (aRv.Failed()) { + return nullptr; + } + + RefPtr<Promise> promise = Promise::Create(aWindow->AsGlobal(), aRv); + if (aRv.Failed()) { + return nullptr; + } + + nsCOMPtr<nsINotificationStorageCallback> callback = + new NotificationStorageCallback(aWindow->AsGlobal(), aScope, promise); + + RefPtr<NotificationGetRunnable> r = + new NotificationGetRunnable(origin, aFilter.mTag, callback); + + aRv = aWindow->AsGlobal()->Dispatch(r.forget()); + if (NS_WARN_IF(aRv.Failed())) { + return nullptr; + } + + return promise.forget(); +} + +class WorkerGetResultRunnable final : public NotificationWorkerRunnable { + RefPtr<PromiseWorkerProxy> mPromiseProxy; + const nsTArray<NotificationStrings> mStrings; + + public: + WorkerGetResultRunnable(WorkerPrivate* aWorkerPrivate, + PromiseWorkerProxy* aPromiseProxy, + nsTArray<NotificationStrings>&& aStrings) + : NotificationWorkerRunnable(aWorkerPrivate, "WorkerGetResultRunnable"), + mPromiseProxy(aPromiseProxy), + mStrings(std::move(aStrings)) {} + + void WorkerRunInternal(WorkerPrivate* aWorkerPrivate) override { + RefPtr<Promise> workerPromise = mPromiseProxy->GetWorkerPromise(); + // Once Worker had already started shutdown, workerPromise would be nullptr + if (!workerPromise) { + return; + } + + AutoTArray<RefPtr<Notification>, 5> notifications; + for (uint32_t i = 0; i < mStrings.Length(); ++i) { + auto result = Notification::ConstructFromFields( + aWorkerPrivate->GlobalScope(), mStrings[i].mID, mStrings[i].mTitle, + mStrings[i].mDir, mStrings[i].mLang, mStrings[i].mBody, + mStrings[i].mTag, mStrings[i].mIcon, mStrings[i].mData, + /* mStrings[i].mBehavior, not + * supported */ + mStrings[i].mServiceWorkerRegistrationScope); + if (result.isErr()) { + continue; + } + RefPtr<Notification> n = result.unwrap(); + n->SetStoredState(true); + notifications.AppendElement(n.forget()); + } + + workerPromise->MaybeResolve(notifications); + mPromiseProxy->CleanUp(); + } +}; + +class WorkerGetCallback final : public ScopeCheckingGetCallback { + RefPtr<PromiseWorkerProxy> mPromiseProxy; + + public: + NS_DECL_ISUPPORTS + + WorkerGetCallback(PromiseWorkerProxy* aProxy, const nsAString& aScope) + : ScopeCheckingGetCallback(aScope), mPromiseProxy(aProxy) { + AssertIsOnMainThread(); + MOZ_ASSERT(aProxy); + } + + NS_IMETHOD Done() final { + AssertIsOnMainThread(); + MOZ_ASSERT(mPromiseProxy, "Was Done() called twice?"); + + RefPtr<PromiseWorkerProxy> proxy = std::move(mPromiseProxy); + MutexAutoLock lock(proxy->Lock()); + if (proxy->CleanedUp()) { + return NS_OK; + } + + RefPtr<WorkerGetResultRunnable> r = new WorkerGetResultRunnable( + proxy->GetWorkerPrivate(), proxy, std::move(mStrings)); + + r->Dispatch(); + return NS_OK; + } + + private: + ~WorkerGetCallback() = default; +}; + +NS_IMPL_ISUPPORTS(WorkerGetCallback, nsINotificationStorageCallback) + +class WorkerGetRunnable final : public Runnable { + RefPtr<PromiseWorkerProxy> mPromiseProxy; + const nsString mTag; + const nsString mScope; + + public: + WorkerGetRunnable(PromiseWorkerProxy* aProxy, const nsAString& aTag, + const nsAString& aScope) + : Runnable("WorkerGetRunnable"), + mPromiseProxy(aProxy), + mTag(aTag), + mScope(aScope) { + MOZ_ASSERT(mPromiseProxy); + } + + NS_IMETHOD + Run() override { + AssertIsOnMainThread(); + nsCOMPtr<nsINotificationStorageCallback> callback = + new WorkerGetCallback(mPromiseProxy, mScope); + + nsresult rv; + nsCOMPtr<nsINotificationStorage> notificationStorage = + do_GetService(NS_NOTIFICATION_STORAGE_CONTRACTID, &rv); + if (NS_WARN_IF(NS_FAILED(rv))) { + callback->Done(); + return rv; + } + + MutexAutoLock lock(mPromiseProxy->Lock()); + if (mPromiseProxy->CleanedUp()) { + return NS_OK; + } + + nsString origin; + rv = Notification::GetOrigin( + mPromiseProxy->GetWorkerPrivate()->GetPrincipal(), origin); + if (NS_WARN_IF(NS_FAILED(rv))) { + callback->Done(); + return rv; + } + + rv = notificationStorage->Get(origin, mTag, callback); + if (NS_WARN_IF(NS_FAILED(rv))) { + callback->Done(); + return rv; + } + + return NS_OK; + } + + private: + ~WorkerGetRunnable() = default; +}; + +// static +already_AddRefed<Promise> Notification::WorkerGet( + WorkerPrivate* aWorkerPrivate, const GetNotificationOptions& aFilter, + const nsAString& aScope, ErrorResult& aRv) { + MOZ_ASSERT(aWorkerPrivate); + aWorkerPrivate->AssertIsOnWorkerThread(); + RefPtr<Promise> p = Promise::Create(aWorkerPrivate->GlobalScope(), aRv); + if (NS_WARN_IF(aRv.Failed())) { + return nullptr; + } + + RefPtr<PromiseWorkerProxy> proxy = + PromiseWorkerProxy::Create(aWorkerPrivate, p); + if (!proxy) { + aRv.Throw(NS_ERROR_DOM_ABORT_ERR); + return nullptr; + } + + RefPtr<WorkerGetRunnable> r = + new WorkerGetRunnable(proxy, aFilter.mTag, aScope); + // Since this is called from script via + // ServiceWorkerRegistration::GetNotifications, we can assert dispatch. + MOZ_ALWAYS_SUCCEEDS(aWorkerPrivate->DispatchToMainThread(r.forget())); + return p.forget(); +} + +JSObject* Notification::WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) { + return mozilla::dom::Notification_Binding::Wrap(aCx, this, aGivenProto); +} + +void Notification::Close() { + AssertIsOnTargetThread(); + auto ref = MakeUnique<NotificationRef>(this); + if (!ref->Initialized()) { + return; + } + + nsCOMPtr<nsIRunnable> closeNotificationTask = new NotificationTask( + "Notification::Close", std::move(ref), NotificationTask::eClose); + nsresult rv = DispatchToMainThread(closeNotificationTask.forget()); + + if (NS_FAILED(rv)) { + DispatchTrustedEvent(u"error"_ns); + // If dispatch fails, NotificationTask will release the ref when it goes + // out of scope at the end of this function. + } +} + +void Notification::CloseInternal(bool aContextClosed) { + AssertIsOnMainThread(); + // Transfer ownership (if any) to local scope so we can release it at the end + // of this function. This is relevant when the call is from + // NotificationTask::Run(). + UniquePtr<NotificationRef> ownership; + std::swap(ownership, mTempRef); + + SetAlertName(); + UnpersistNotification(); + if (!mIsClosed) { + nsCOMPtr<nsIAlertsService> alertService = components::Alerts::Service(); + if (alertService) { + nsAutoString alertName; + GetAlertName(alertName); + alertService->CloseAlert(alertName, aContextClosed); + } + } +} + +nsresult Notification::GetOrigin(nsIPrincipal* aPrincipal, nsString& aOrigin) { + if (!aPrincipal) { + return NS_ERROR_FAILURE; + } + + nsresult rv = + nsContentUtils::GetWebExposedOriginSerialization(aPrincipal, aOrigin); + NS_ENSURE_SUCCESS(rv, rv); + + return NS_OK; +} + +bool Notification::RequireInteraction() const { return mRequireInteraction; } + +bool Notification::Silent() const { return mSilent; } + +void Notification::GetVibrate(nsTArray<uint32_t>& aRetval) const { + aRetval = mVibrate.Clone(); +} + +void Notification::GetData(JSContext* aCx, + JS::MutableHandle<JS::Value> aRetval) { + if (mData.isNull() && !mDataAsBase64.IsEmpty()) { + nsresult rv; + RefPtr<nsStructuredCloneContainer> container = + new nsStructuredCloneContainer(); + rv = container->InitFromBase64(mDataAsBase64, JS_STRUCTURED_CLONE_VERSION); + if (NS_WARN_IF(NS_FAILED(rv))) { + aRetval.setNull(); + return; + } + + JS::Rooted<JS::Value> data(aCx); + rv = container->DeserializeToJsval(aCx, &data); + if (NS_WARN_IF(NS_FAILED(rv))) { + aRetval.setNull(); + return; + } + + if (data.isGCThing()) { + mozilla::HoldJSObjects(this); + } + mData = data; + } + if (mData.isNull()) { + aRetval.setNull(); + return; + } + + aRetval.set(mData); +} + +void Notification::InitFromJSVal(JSContext* aCx, JS::Handle<JS::Value> aData, + ErrorResult& aRv) { + if (!mDataAsBase64.IsEmpty() || aData.isNull()) { + return; + } + RefPtr<nsStructuredCloneContainer> dataObjectContainer = + new nsStructuredCloneContainer(); + aRv = dataObjectContainer->InitFromJSVal(aData, aCx); + if (NS_WARN_IF(aRv.Failed())) { + return; + } + + aRv = dataObjectContainer->GetDataAsBase64(mDataAsBase64); + if (NS_WARN_IF(aRv.Failed())) { + return; + } +} + +Result<Ok, QMResult> Notification::InitFromBase64(const nsAString& aData) { + MOZ_ASSERT(mDataAsBase64.IsEmpty()); + if (aData.IsEmpty()) { + // No data; skipping + return Ok(); + } + + // To and fro to ensure it is valid base64. + RefPtr<nsStructuredCloneContainer> container = + new nsStructuredCloneContainer(); + QM_TRY(QM_TO_RESULT( + container->InitFromBase64(aData, JS_STRUCTURED_CLONE_VERSION))); + QM_TRY(QM_TO_RESULT(container->GetDataAsBase64(mDataAsBase64))); + + return Ok(); +} + +bool Notification::AddRefObject() { + AssertIsOnTargetThread(); + MOZ_ASSERT_IF(mWorkerPrivate && !mWorkerRef, mTaskCount == 0); + MOZ_ASSERT_IF(mWorkerPrivate && mWorkerRef, mTaskCount > 0); + if (mWorkerPrivate && !mWorkerRef) { + if (!CreateWorkerRef()) { + return false; + } + } + AddRef(); + ++mTaskCount; + return true; +} + +void Notification::ReleaseObject() { + AssertIsOnTargetThread(); + MOZ_ASSERT(mTaskCount > 0); + MOZ_ASSERT_IF(mWorkerPrivate, mWorkerRef); + + --mTaskCount; + if (mWorkerPrivate && mTaskCount == 0) { + MOZ_ASSERT(mWorkerRef); + mWorkerRef = nullptr; + } + Release(); +} + +/* + * Called from the worker, runs on main thread, blocks worker. + * + * We can freely access mNotification here because the feature supplied it and + * the Notification owns the feature. + */ +class CloseNotificationRunnable final : public WorkerMainThreadRunnable { + Notification* mNotification; + bool mHadObserver; + + public: + explicit CloseNotificationRunnable(Notification* aNotification) + : WorkerMainThreadRunnable(aNotification->mWorkerPrivate, + "Notification :: Close Notification"_ns), + mNotification(aNotification), + mHadObserver(false) {} + + bool MainThreadRun() override { + if (mNotification->mObserver) { + // The Notify() take's responsibility of releasing the Notification. + mNotification->mObserver->ForgetNotification(); + mNotification->mObserver = nullptr; + mHadObserver = true; + } + mNotification->CloseInternal(); + return true; + } + + bool HadObserver() { return mHadObserver; } +}; + +bool Notification::CreateWorkerRef() { + MOZ_ASSERT(mWorkerPrivate); + mWorkerPrivate->AssertIsOnWorkerThread(); + MOZ_ASSERT(!mWorkerRef); + + RefPtr<Notification> self = this; + mWorkerRef = + StrongWorkerRef::Create(mWorkerPrivate, "Notification", [self]() { + // CloseNotificationRunnable blocks the worker by pushing a sync event + // loop on the stack. Meanwhile, WorkerControlRunnables dispatched to + // the worker can still continue running. One of these is + // ReleaseNotificationControlRunnable that releases the notification, + // invalidating the notification and this feature. We hold this + // reference to keep the notification valid until we are done with it. + // + // An example of when the control runnable could get dispatched to the + // worker is if a Notification is created and the worker is immediately + // closed, but there is no permission to show it so that the main thread + // immediately drops the NotificationRef. In this case, this function + // blocks on the main thread, but the main thread dispatches the control + // runnable, invalidating mNotification. + + // Dispatched to main thread, blocks on closing the Notification. + RefPtr<CloseNotificationRunnable> r = + new CloseNotificationRunnable(self); + ErrorResult rv; + r->Dispatch(Killing, rv); + // XXXbz I'm told throwing and returning false from here is pointless + // (and also that doing sync stuff from here is really weird), so I + // guess we just suppress the exception on rv, if any. + rv.SuppressException(); + + // Only call ReleaseObject() to match the observer's NotificationRef + // ownership (since CloseNotificationRunnable asked the observer to drop + // the reference to the notification). + if (r->HadObserver()) { + self->ReleaseObject(); + } + + // From this point we cannot touch properties of this feature because + // ReleaseObject() may have led to the notification going away and the + // notification owns this feature! + }); + + if (NS_WARN_IF(!mWorkerRef)) { + return false; + } + + return true; +} + +/* + * Checks: + * 1) Is aWorker allowed to show a notification for scope? + * 2) Is aWorker an active worker? + * + * If it is not an active worker, Result() will be NS_ERROR_NOT_AVAILABLE. + */ +class CheckLoadRunnable final : public WorkerMainThreadRunnable { + nsresult mRv; + nsCString mScope; + ServiceWorkerRegistrationDescriptor mDescriptor; + + public: + explicit CheckLoadRunnable( + WorkerPrivate* aWorker, const nsACString& aScope, + const ServiceWorkerRegistrationDescriptor& aDescriptor) + : WorkerMainThreadRunnable(aWorker, "Notification :: Check Load"_ns), + mRv(NS_ERROR_DOM_SECURITY_ERR), + mScope(aScope), + mDescriptor(aDescriptor) {} + + bool MainThreadRun() override { + nsIPrincipal* principal = mWorkerPrivate->GetPrincipal(); + mRv = CheckScope(principal, mScope, mWorkerPrivate->WindowID()); + + if (NS_FAILED(mRv)) { + return true; + } + + auto activeWorker = mDescriptor.GetActive(); + + if (!activeWorker || + activeWorker.ref().Id() != mWorkerPrivate->ServiceWorkerID()) { + mRv = NS_ERROR_NOT_AVAILABLE; + } + + return true; + } + + nsresult Result() { return mRv; } +}; + +// Step 2, 5, 6 of +// https://notifications.spec.whatwg.org/#dom-serviceworkerregistration-shownotification +/* static */ +already_AddRefed<Promise> Notification::ShowPersistentNotification( + JSContext* aCx, nsIGlobalObject* aGlobal, const nsAString& aScope, + const nsAString& aTitle, const NotificationOptions& aOptions, + const ServiceWorkerRegistrationDescriptor& aDescriptor, ErrorResult& aRv) { + MOZ_ASSERT(aGlobal); + + // Validate scope. + // XXXnsm: This may be slow due to blocking the worker and waiting on the main + // thread. On calls from content, we can be sure the scope is valid since + // ServiceWorkerRegistrations have their scope set correctly. Can this be made + // debug only? The problem is that there would be different semantics in + // debug and non-debug builds in such a case. + if (NS_IsMainThread()) { + nsCOMPtr<nsIScriptObjectPrincipal> sop = do_QueryInterface(aGlobal); + if (NS_WARN_IF(!sop)) { + aRv.Throw(NS_ERROR_UNEXPECTED); + return nullptr; + } + + nsIPrincipal* principal = sop->GetPrincipal(); + if (NS_WARN_IF(!principal)) { + aRv.Throw(NS_ERROR_UNEXPECTED); + return nullptr; + } + + uint64_t windowID = 0; + nsCOMPtr<nsPIDOMWindowInner> win = do_QueryInterface(aGlobal); + if (win) { + windowID = win->WindowID(); + } + + aRv = CheckScope(principal, NS_ConvertUTF16toUTF8(aScope), windowID); + if (NS_WARN_IF(aRv.Failed())) { + aRv.Throw(NS_ERROR_DOM_SECURITY_ERR); + return nullptr; + } + } else { + WorkerPrivate* worker = GetCurrentThreadWorkerPrivate(); + MOZ_ASSERT(worker); + worker->AssertIsOnWorkerThread(); + + RefPtr<CheckLoadRunnable> loadChecker = new CheckLoadRunnable( + worker, NS_ConvertUTF16toUTF8(aScope), aDescriptor); + loadChecker->Dispatch(Canceling, aRv); + if (aRv.Failed()) { + return nullptr; + } + + if (NS_WARN_IF(NS_FAILED(loadChecker->Result()))) { + if (loadChecker->Result() == NS_ERROR_NOT_AVAILABLE) { + aRv.ThrowTypeError<MSG_NO_ACTIVE_WORKER>(NS_ConvertUTF16toUTF8(aScope)); + } else { + aRv.Throw(NS_ERROR_DOM_SECURITY_ERR); + } + return nullptr; + } + } + + // Step 2: Let promise be a new promise in this’s relevant Realm. + RefPtr<Promise> p = Promise::Create(aGlobal, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return nullptr; + } + + // We check permission here rather than pass the Promise to NotificationTask + // which leads to uglier code. + // XXX: GetPermission is a synchronous blocking function on workers. + NotificationPermission permission = GetPermission(aGlobal, aRv); + + // Step 6.1: If the result of getting the notifications permission state is + // not "granted", then queue a global task on the DOM manipulation task source + // given global to reject promise with a TypeError, and abort these steps. + if (NS_WARN_IF(aRv.Failed()) || + permission != NotificationPermission::Granted) { + p->MaybeRejectWithTypeError("Permission to show Notification denied."); + return p.forget(); + } + + // "Otherwise, resolve promise with undefined." + // The Notification may still not be shown due to other errors, but the spec + // is not concerned with those. + p->MaybeResolveWithUndefined(); + + // Step 5: Let notification be the result of creating a notification given + // title, options, this’s relevant settings object, and + // serviceWorkerRegistration. If this threw an exception, then reject promise + // with that exception and return promise. + // + // XXX: This should happen before the permission check per the spec, as this + // can throw errors too. This should be split into create and show. + RefPtr<Notification> notification = + CreateAndShow(aCx, aGlobal, aTitle, aOptions, aScope, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return nullptr; + } + + return p.forget(); +} + +/* static */ +already_AddRefed<Notification> Notification::CreateAndShow( + JSContext* aCx, nsIGlobalObject* aGlobal, const nsAString& aTitle, + const NotificationOptions& aOptions, const nsAString& aScope, + ErrorResult& aRv) { + MOZ_ASSERT(aGlobal); + + RefPtr<Notification> notification = + CreateInternal(aGlobal, u""_ns, aTitle, aOptions, aRv); + if (aRv.Failed()) { + return nullptr; + } + + // Make a structured clone of the aOptions.mData object + JS::Rooted<JS::Value> data(aCx, aOptions.mData); + notification->InitFromJSVal(aCx, data, aRv); + if (NS_WARN_IF(aRv.Failed())) { + return nullptr; + } + + notification->SetScope(aScope); + + auto ref = MakeUnique<NotificationRef>(notification); + if (NS_WARN_IF(!ref->Initialized())) { + aRv.Throw(NS_ERROR_DOM_ABORT_ERR); + return nullptr; + } + + // Queue a task to show the notification. + nsCOMPtr<nsIRunnable> showNotificationTask = new NotificationTask( + "Notification::CreateAndShow", std::move(ref), NotificationTask::eShow); + + nsresult rv = + notification->DispatchToMainThread(showNotificationTask.forget()); + + if (NS_WARN_IF(NS_FAILED(rv))) { + notification->DispatchTrustedEvent(u"error"_ns); + } + + return notification.forget(); +} + +/* static */ +nsresult Notification::RemovePermission(nsIPrincipal* aPrincipal) { + MOZ_ASSERT(XRE_IsParentProcess()); + nsCOMPtr<nsIPermissionManager> permissionManager = + mozilla::components::PermissionManager::Service(); + if (!permissionManager) { + return NS_ERROR_FAILURE; + } + permissionManager->RemoveFromPrincipal(aPrincipal, "desktop-notification"_ns); + return NS_OK; +} + +/* static */ +nsresult Notification::OpenSettings(nsIPrincipal* aPrincipal) { + MOZ_ASSERT(XRE_IsParentProcess()); + nsCOMPtr<nsIObserverService> obs = mozilla::services::GetObserverService(); + if (!obs) { + return NS_ERROR_FAILURE; + } + // Notify other observers so they can show settings UI. + obs->NotifyObservers(aPrincipal, "notifications-open-settings", nullptr); + return NS_OK; +} + +NS_IMETHODIMP +Notification::Observe(nsISupports* aSubject, const char* aTopic, + const char16_t* aData) { + AssertIsOnMainThread(); + + if (!strcmp(aTopic, DOM_WINDOW_DESTROYED_TOPIC) || + !strcmp(aTopic, DOM_WINDOW_FROZEN_TOPIC)) { + nsCOMPtr<nsPIDOMWindowInner> window = GetOwner(); + if (SameCOMIdentity(aSubject, window)) { + nsCOMPtr<nsIObserverService> obs = + mozilla::services::GetObserverService(); + if (obs) { + obs->RemoveObserver(this, DOM_WINDOW_DESTROYED_TOPIC); + obs->RemoveObserver(this, DOM_WINDOW_FROZEN_TOPIC); + } + + CloseInternal(true); + } + } + + return NS_OK; +} + +nsresult Notification::DispatchToMainThread( + already_AddRefed<nsIRunnable>&& aRunnable) { + if (mWorkerPrivate) { + return mWorkerPrivate->DispatchToMainThread(std::move(aRunnable)); + } + AssertIsOnMainThread(); + return NS_DispatchToCurrentThread(std::move(aRunnable)); +} + +} // namespace mozilla::dom diff --git a/dom/notification/Notification.h b/dom/notification/Notification.h new file mode 100644 index 0000000000..4ffa69cf49 --- /dev/null +++ b/dom/notification/Notification.h @@ -0,0 +1,360 @@ +/* -*- Mode: C++; tab-width: 8; 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_notification_h__ +#define mozilla_dom_notification_h__ + +#include "mozilla/DOMEventTargetHelper.h" +#include "mozilla/UniquePtr.h" +#include "mozilla/dom/NotificationBinding.h" +#include "mozilla/dom/WorkerPrivate.h" +#include "mozilla/dom/quota/QuotaCommon.h" + +#include "nsIObserver.h" +#include "nsISupports.h" + +#include "nsCycleCollectionParticipant.h" +#include "nsWeakReference.h" + +class nsIPrincipal; +class nsIVariant; + +namespace mozilla::dom { + +class NotificationRef; +class WorkerNotificationObserver; +class Promise; +class StrongWorkerRef; + +/* + * Notifications on workers introduce some lifetime issues. The property we + * are trying to satisfy is: + * Whenever a task is dispatched to the main thread to operate on + * a Notification, the Notification should be addrefed on the worker thread + * and a feature should be added to observe the worker lifetime. This main + * thread owner should ensure it properly releases the reference to the + * Notification, additionally removing the feature if necessary. + * + * To enforce the correct addref and release, along with managing the feature, + * we introduce a NotificationRef. Only one object may ever own + * a NotificationRef, so UniquePtr<> is used throughout. The NotificationRef + * constructor calls AddRefObject(). When it is destroyed (on any thread) it + * releases the Notification on the correct thread. + * + * Code should only access the underlying Notification object when it can + * guarantee that it retains ownership of the NotificationRef while doing so. + * + * The one kink in this mechanism is that the worker feature may be Notify()ed + * if the worker stops running script, even if the Notification's corresponding + * UI is still visible to the user. We handle this case with the following + * steps: + * a) Close the notification. This is done by blocking the worker on the main + * thread. This ensures that there are no main thread holders when the worker + * resumes. This also deals with the case where Notify() runs on the worker + * before the observer has been created on the main thread. Even in such + * a situation, the CloseNotificationRunnable() will only run after the + * Show task that was previously queued. Since the show task is only queued + * once when the Notification is created, we can be sure that no new tasks + * will follow the Notify(). + * + * b) Ask the observer to let go of its NotificationRef's underlying + * Notification without proper cleanup since the feature will handle the + * release. This is only OK because every notification has only one + * associated observer. The NotificationRef itself is still owned by the + * observer and deleted by the UniquePtr, but it doesn't do anything since + * the underlying Notification is null. + * + * To unify code-paths, we use the same NotificationRef in the main + * thread implementation too. + * + * Note that the Notification's JS wrapper does it's standard + * AddRef()/Release() and is not affected by any of this. + * + * Since the worker event queue can have runnables that will dispatch events on + * the Notification, the NotificationRef destructor will first try to release + * the Notification by dispatching a normal runnable to the worker so that it is + * queued after any event runnables. If that dispatch fails, it means the worker + * is no longer running and queued WorkerRunnables will be canceled, so we + * dispatch a control runnable instead. + * + */ +class Notification : public DOMEventTargetHelper, + public nsIObserver, + public nsSupportsWeakReference { + friend class CloseNotificationRunnable; + friend class NotificationTask; + friend class NotificationPermissionRequest; + friend class MainThreadNotificationObserver; + friend class NotificationStorageCallback; + friend class ServiceWorkerNotificationObserver; + friend class WorkerGetRunnable; + friend class WorkerNotificationObserver; + + public: + IMPL_EVENT_HANDLER(click) + IMPL_EVENT_HANDLER(show) + IMPL_EVENT_HANDLER(error) + IMPL_EVENT_HANDLER(close) + + NS_DECL_ISUPPORTS_INHERITED + NS_DECL_CYCLE_COLLECTION_SCRIPT_HOLDER_CLASS_INHERITED(Notification, + DOMEventTargetHelper) + NS_DECL_NSIOBSERVER + + static bool PrefEnabled(JSContext* aCx, JSObject* aObj); + + static already_AddRefed<Notification> Constructor( + const GlobalObject& aGlobal, const nsAString& aTitle, + const NotificationOptions& aOption, ErrorResult& aRv); + + /** + * Used when dispatching the ServiceWorkerEvent. + * + * Does not initialize the Notification's behavior. + * This is because: + * 1) The Notification is not shown to the user and so the behavior + * parameters don't matter. + * 2) The default binding requires main thread for parsing the JSON from the + * string behavior. + */ + static Result<already_AddRefed<Notification>, QMResult> ConstructFromFields( + nsIGlobalObject* aGlobal, const nsAString& aID, const nsAString& aTitle, + const nsAString& aDir, const nsAString& aLang, const nsAString& aBody, + const nsAString& aTag, const nsAString& aIcon, const nsAString& aData, + const nsAString& aServiceWorkerRegistrationScope); + + void GetID(nsAString& aRetval) { aRetval = mID; } + + void GetTitle(nsAString& aRetval) { aRetval = mTitle; } + + NotificationDirection Dir() { return mDir; } + + void GetLang(nsAString& aRetval) { aRetval = mLang; } + + void GetBody(nsAString& aRetval) { aRetval = mBody; } + + void GetTag(nsAString& aRetval) { aRetval = mTag; } + + void GetIcon(nsAString& aRetval) { aRetval = mIconUrl; } + + void SetStoredState(bool val) { mIsStored = val; } + + bool IsStored() { return mIsStored; } + + static bool RequestPermissionEnabledForScope(JSContext* aCx, + JSObject* /* unused */); + + static already_AddRefed<Promise> RequestPermission( + const GlobalObject& aGlobal, + const Optional<OwningNonNull<NotificationPermissionCallback> >& aCallback, + ErrorResult& aRv); + + static NotificationPermission GetPermission(const GlobalObject& aGlobal, + ErrorResult& aRv); + + static already_AddRefed<Promise> Get(nsPIDOMWindowInner* aWindow, + const GetNotificationOptions& aFilter, + const nsAString& aScope, + ErrorResult& aRv); + + static already_AddRefed<Promise> WorkerGet( + WorkerPrivate* aWorkerPrivate, const GetNotificationOptions& aFilter, + const nsAString& aScope, ErrorResult& aRv); + + // Notification implementation of + // ServiceWorkerRegistration.showNotification. + // + // + // Note that aCx may not be in the compartment of aGlobal, but aOptions will + // have its JS things in the compartment of aCx. + static already_AddRefed<Promise> ShowPersistentNotification( + JSContext* aCx, nsIGlobalObject* aGlobal, const nsAString& aScope, + const nsAString& aTitle, const NotificationOptions& aOptions, + const ServiceWorkerRegistrationDescriptor& aDescriptor, ErrorResult& aRv); + + void Close(); + + nsPIDOMWindowInner* GetParentObject() { return GetOwner(); } + + virtual JSObject* WrapObject(JSContext* aCx, + JS::Handle<JSObject*> aGivenProto) override; + + bool RequireInteraction() const; + + bool Silent() const; + + void GetVibrate(nsTArray<uint32_t>& aRetval) const; + + void GetData(JSContext* aCx, JS::MutableHandle<JS::Value> aRetval); + + void InitFromJSVal(JSContext* aCx, JS::Handle<JS::Value> aData, + ErrorResult& aRv); + + Result<Ok, QMResult> InitFromBase64(const nsAString& aData); + + void AssertIsOnTargetThread() const { MOZ_ASSERT(IsTargetThread()); } + + // Initialized on the worker thread, never unset, and always used in + // a read-only capacity. Used on any thread. + CheckedUnsafePtr<WorkerPrivate> mWorkerPrivate; + + // Main thread only. + WorkerNotificationObserver* mObserver; + + // The NotificationTask calls ShowInternal()/CloseInternal() on the + // Notification. At this point the task has ownership of the Notification. It + // passes this on to the Notification itself via mTempRef so that + // ShowInternal()/CloseInternal() may pass it along appropriately (or release + // it). + // + // Main thread only. + UniquePtr<NotificationRef> mTempRef; + + // Returns true if addref succeeded. + bool AddRefObject(); + void ReleaseObject(); + + static NotificationPermission GetPermission(nsIGlobalObject* aGlobal, + ErrorResult& aRv); + + static NotificationPermission GetPermissionInternal(nsIPrincipal* aPrincipal, + ErrorResult& rv); + + static NotificationPermission TestPermission(nsIPrincipal* aPrincipal); + + bool DispatchClickEvent(); + + static nsresult RemovePermission(nsIPrincipal* aPrincipal); + static nsresult OpenSettings(nsIPrincipal* aPrincipal); + + nsresult DispatchToMainThread(already_AddRefed<nsIRunnable>&& aRunnable); + + protected: + Notification(nsIGlobalObject* aGlobal, const nsAString& aID, + const nsAString& aTitle, const nsAString& aBody, + NotificationDirection aDir, const nsAString& aLang, + const nsAString& aTag, const nsAString& aIconUrl, + bool aRequireInteraction, bool aSilent, + nsTArray<uint32_t>&& aVibrate, + const NotificationBehavior& aBehavior); + + static already_AddRefed<Notification> CreateInternal( + nsIGlobalObject* aGlobal, const nsAString& aID, const nsAString& aTitle, + const NotificationOptions& aOptions, ErrorResult& aRv); + + nsresult Init(); + bool IsInPrivateBrowsing(); + void ShowInternal(); + void CloseInternal(bool aContextClosed = false); + + static NotificationPermission GetPermissionInternal( + nsPIDOMWindowInner* aWindow, ErrorResult& rv); + + static const nsString DirectionToString(NotificationDirection aDirection) { + switch (aDirection) { + case NotificationDirection::Ltr: + return u"ltr"_ns; + case NotificationDirection::Rtl: + return u"rtl"_ns; + default: + return u"auto"_ns; + } + } + + static NotificationDirection StringToDirection(const nsAString& aDirection) { + if (aDirection.EqualsLiteral("ltr")) { + return NotificationDirection::Ltr; + } + if (aDirection.EqualsLiteral("rtl")) { + return NotificationDirection::Rtl; + } + return NotificationDirection::Auto; + } + + static nsresult GetOrigin(nsIPrincipal* aPrincipal, nsString& aOrigin); + + void GetAlertName(nsAString& aRetval) { + AssertIsOnMainThread(); + if (mAlertName.IsEmpty()) { + SetAlertName(); + } + aRetval = mAlertName; + } + + void GetScope(nsAString& aScope) { aScope = mScope; } + + void SetScope(const nsAString& aScope) { + MOZ_ASSERT(mScope.IsEmpty()); + mScope = aScope; + } + + const nsString mID; + const nsString mTitle; + const nsString mBody; + const NotificationDirection mDir; + const nsString mLang; + const nsString mTag; + const nsString mIconUrl; + const bool mRequireInteraction; + const bool mSilent; + nsTArray<uint32_t> mVibrate; + nsString mDataAsBase64; + const NotificationBehavior mBehavior; + + // It's null until GetData is first called + JS::Heap<JS::Value> mData; + + nsString mAlertName; + nsString mScope; + + // Main thread only. + bool mIsClosed; + + // We need to make a distinction between the notification being closed i.e. + // removed from any pending or active lists, and the notification being + // removed from the database. NotificationDB might fail when trying to remove + // the notification. + bool mIsStored; + + static uint32_t sCount; + + private: + virtual ~Notification(); + + // Creates a Notification and shows it. Returns a reference to the + // Notification if result is NS_OK. The lifetime of this Notification is tied + // to an underlying NotificationRef. Do not hold a non-stack raw pointer to + // it. Be careful about thread safety if acquiring a strong reference. + // + // Note that aCx may not be in the compartment of aGlobal, but aOptions will + // have its JS things in the compartment of aCx. + static already_AddRefed<Notification> CreateAndShow( + JSContext* aCx, nsIGlobalObject* aGlobal, const nsAString& aTitle, + const NotificationOptions& aOptions, const nsAString& aScope, + ErrorResult& aRv); + + nsIPrincipal* GetPrincipal(); + + nsresult PersistNotification(); + void UnpersistNotification(); + + void SetAlertName(); + + bool IsTargetThread() const { return NS_IsMainThread() == !mWorkerPrivate; } + + bool CreateWorkerRef(); + + nsresult ResolveIconAndSoundURL(nsString&, nsString&); + + // Only used for Notifications on Workers, worker thread only. + RefPtr<StrongWorkerRef> mWorkerRef; + // Target thread only. + uint32_t mTaskCount; +}; + +} // namespace mozilla::dom + +#endif // mozilla_dom_notification_h__ diff --git a/dom/notification/NotificationEvent.cpp b/dom/notification/NotificationEvent.cpp new file mode 100644 index 0000000000..df9800c46a --- /dev/null +++ b/dom/notification/NotificationEvent.cpp @@ -0,0 +1,21 @@ +/* -*- Mode: C++; tab-width: 8; 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/. */ + +#include "NotificationEvent.h" + +using namespace mozilla::dom; + +NotificationEvent::NotificationEvent(EventTarget* aOwner) + : ExtendableEvent(aOwner) {} + +NS_IMPL_ADDREF_INHERITED(NotificationEvent, ExtendableEvent) +NS_IMPL_RELEASE_INHERITED(NotificationEvent, ExtendableEvent) + +NS_INTERFACE_MAP_BEGIN_CYCLE_COLLECTION(NotificationEvent) +NS_INTERFACE_MAP_END_INHERITING(ExtendableEvent) + +NS_IMPL_CYCLE_COLLECTION_INHERITED(NotificationEvent, ExtendableEvent, + mNotification) diff --git a/dom/notification/NotificationEvent.h b/dom/notification/NotificationEvent.h new file mode 100644 index 0000000000..744c216798 --- /dev/null +++ b/dom/notification/NotificationEvent.h @@ -0,0 +1,65 @@ +/* -*- Mode: C++; tab-width: 8; 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_workers_notificationevent_h__ +#define mozilla_dom_workers_notificationevent_h__ + +#include "mozilla/dom/Event.h" +#include "mozilla/dom/NotificationEventBinding.h" +#include "mozilla/dom/ServiceWorkerEvents.h" +#include "mozilla/dom/WorkerCommon.h" + +namespace mozilla::dom { + +class ServiceWorker; +class ServiceWorkerClient; + +class NotificationEvent final : public ExtendableEvent { + protected: + explicit NotificationEvent(EventTarget* aOwner); + ~NotificationEvent() = default; + + public: + NS_DECL_ISUPPORTS_INHERITED + NS_DECL_CYCLE_COLLECTION_CLASS_INHERITED(NotificationEvent, ExtendableEvent) + + virtual JSObject* WrapObjectInternal( + JSContext* aCx, JS::Handle<JSObject*> aGivenProto) override { + return NotificationEvent_Binding::Wrap(aCx, this, aGivenProto); + } + + static already_AddRefed<NotificationEvent> Constructor( + mozilla::dom::EventTarget* aOwner, const nsAString& aType, + const NotificationEventInit& aOptions) { + RefPtr<NotificationEvent> e = new NotificationEvent(aOwner); + bool trusted = e->Init(aOwner); + e->InitEvent(aType, aOptions.mBubbles, aOptions.mCancelable); + e->SetTrusted(trusted); + e->SetComposed(aOptions.mComposed); + e->mNotification = aOptions.mNotification; + e->SetWantsPopupControlCheck(e->IsTrusted()); + return e.forget(); + } + + static already_AddRefed<NotificationEvent> Constructor( + const GlobalObject& aGlobal, const nsAString& aType, + const NotificationEventInit& aOptions) { + nsCOMPtr<EventTarget> owner = do_QueryInterface(aGlobal.GetAsSupports()); + return Constructor(owner, aType, aOptions); + } + + already_AddRefed<Notification> Notification_() { + RefPtr<Notification> n = mNotification; + return n.forget(); + } + + private: + RefPtr<Notification> mNotification; +}; + +} // namespace mozilla::dom + +#endif /* mozilla_dom_workers_notificationevent_h__ */ diff --git a/dom/notification/NotificationStorage.sys.mjs b/dom/notification/NotificationStorage.sys.mjs new file mode 100644 index 0000000000..132771e088 --- /dev/null +++ b/dom/notification/NotificationStorage.sys.mjs @@ -0,0 +1,205 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const DEBUG = false; +function debug(s) { + dump("-*- NotificationStorage.js: " + s + "\n"); +} + +const kMessageNotificationGetAllOk = "Notification:GetAll:Return:OK"; +const kMessageNotificationGetAllKo = "Notification:GetAll:Return:KO"; +const kMessageNotificationSaveKo = "Notification:Save:Return:KO"; +const kMessageNotificationDeleteKo = "Notification:Delete:Return:KO"; + +const kMessages = [ + kMessageNotificationGetAllOk, + kMessageNotificationGetAllKo, + kMessageNotificationSaveKo, + kMessageNotificationDeleteKo, +]; + +export function NotificationStorage() { + this._requests = {}; + this._requestCount = 0; + + Services.obs.addObserver(this, "xpcom-shutdown"); + + // Register for message listeners. + this.registerListeners(); +} + +NotificationStorage.prototype = { + registerListeners() { + for (let message of kMessages) { + Services.cpmm.addMessageListener(message, this); + } + }, + + unregisterListeners() { + for (let message of kMessages) { + Services.cpmm.removeMessageListener(message, this); + } + }, + + observe(aSubject, aTopic, aData) { + if (DEBUG) { + debug("Topic: " + aTopic); + } + if (aTopic === "xpcom-shutdown") { + Services.obs.removeObserver(this, "xpcom-shutdown"); + this.unregisterListeners(); + } + }, + + put( + origin, + id, + title, + dir, + lang, + body, + tag, + icon, + alertName, + data, + behavior, + serviceWorkerRegistrationScope + ) { + if (DEBUG) { + debug("PUT: " + origin + " " + id + ": " + title); + } + var notification = { + id, + title, + dir, + lang, + body, + tag, + icon, + alertName, + timestamp: new Date().getTime(), + origin, + data, + mozbehavior: behavior, + serviceWorkerRegistrationScope, + }; + + Services.cpmm.sendAsyncMessage("Notification:Save", { + origin, + notification, + }); + }, + + get(origin, tag, callback) { + if (DEBUG) { + debug("GET: " + origin + " " + tag); + } + this._fetchFromDB(origin, tag, callback); + }, + + delete(origin, id) { + if (DEBUG) { + debug("DELETE: " + id); + } + Services.cpmm.sendAsyncMessage("Notification:Delete", { + origin, + id, + }); + }, + + receiveMessage(message) { + var request = this._requests[message.data.requestID]; + + switch (message.name) { + case kMessageNotificationGetAllOk: + delete this._requests[message.data.requestID]; + this._returnNotifications( + message.data.notifications, + request.origin, + request.tag, + request.callback + ); + break; + + case kMessageNotificationGetAllKo: + delete this._requests[message.data.requestID]; + try { + request.callback.done(); + } catch (e) { + debug("Error calling callback done: " + e); + } + break; + case kMessageNotificationSaveKo: + case kMessageNotificationDeleteKo: + if (DEBUG) { + debug( + "Error received when treating: '" + + message.name + + "': " + + message.data.errorMsg + ); + } + break; + + default: + if (DEBUG) { + debug("Unrecognized message: " + message.name); + } + break; + } + }, + + _fetchFromDB(origin, tag, callback) { + var request = { + origin, + tag, + callback, + }; + var requestID = this._requestCount++; + this._requests[requestID] = request; + Services.cpmm.sendAsyncMessage("Notification:GetAll", { + origin, + tag, + requestID, + }); + }, + + _returnNotifications(notifications, origin, tag, callback) { + // Pass each notification back separately. + // The callback is called asynchronously to match the behaviour when + // fetching from the database. + notifications.forEach(function (notification) { + try { + Services.tm.dispatchToMainThread( + callback.handle.bind( + callback, + notification.id, + notification.title, + notification.dir, + notification.lang, + notification.body, + notification.tag, + notification.icon, + notification.data, + notification.mozbehavior, + notification.serviceWorkerRegistrationScope + ) + ); + } catch (e) { + if (DEBUG) { + debug("Error calling callback handle: " + e); + } + } + }); + try { + Services.tm.dispatchToMainThread(callback.done); + } catch (e) { + if (DEBUG) { + debug("Error calling callback done: " + e); + } + } + }, + + QueryInterface: ChromeUtils.generateQI(["nsINotificationStorage"]), +}; diff --git a/dom/notification/components.conf b/dom/notification/components.conf new file mode 100644 index 0000000000..2cb6bdb33a --- /dev/null +++ b/dom/notification/components.conf @@ -0,0 +1,14 @@ +# -*- 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/. + +Classes = [ + { + 'cid': '{37f819b0-0b5c-11e3-8ffd-0800200c9a66}', + 'contract_ids': ['@mozilla.org/notificationStorage;1'], + 'esModule': 'resource://gre/modules/NotificationStorage.sys.mjs', + 'constructor': 'NotificationStorage', + }, +] diff --git a/dom/notification/moz.build b/dom/notification/moz.build new file mode 100644 index 0000000000..b42e7a13ab --- /dev/null +++ b/dom/notification/moz.build @@ -0,0 +1,48 @@ +# -*- 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: Notifications") + +EXTRA_JS_MODULES += [ + "NotificationStorage.sys.mjs", +] + +XPCOM_MANIFESTS += [ + "components.conf", +] + +EXPORTS.mozilla.dom += [ + "Notification.h", + "NotificationEvent.h", +] + +UNIFIED_SOURCES += [ + "Notification.cpp", + "NotificationEvent.cpp", +] + +include("/ipc/chromium/chromium-config.mozbuild") + +FINAL_LIBRARY = "xul" +LOCAL_INCLUDES += [ + "/dom/base", + "/dom/ipc", +] + +BROWSER_CHROME_MANIFESTS += ["test/browser/browser.toml"] +XPCSHELL_TESTS_MANIFESTS += ["test/unit/xpcshell.toml"] +MOCHITEST_MANIFESTS += ["test/mochitest/mochitest.toml"] +MOCHITEST_CHROME_MANIFESTS += ["test/chrome/chrome.toml"] + +if CONFIG["MOZ_NEW_NOTIFICATION_STORE"]: + EXTRA_JS_MODULES += [ + "new/NotificationDB.sys.mjs", + ] +else: + EXTRA_JS_MODULES += [ + "old/NotificationDB.sys.mjs", + ] diff --git a/dom/notification/new/NotificationDB.sys.mjs b/dom/notification/new/NotificationDB.sys.mjs new file mode 100644 index 0000000000..b607fdd234 --- /dev/null +++ b/dom/notification/new/NotificationDB.sys.mjs @@ -0,0 +1,375 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const DEBUG = false; +function debug(s) { + dump("-*- NotificationDB component: " + s + "\n"); +} + +const lazy = {}; + +ChromeUtils.defineESModuleGetters(lazy, { + KeyValueService: "resource://gre/modules/kvstore.sys.mjs", +}); + +const kMessages = [ + "Notification:Save", + "Notification:Delete", + "Notification:GetAll", +]; + +// Given its origin and ID, produce the key that uniquely identifies +// a notification. +function makeKey(origin, id) { + return origin.concat("\t", id); +} + +var NotificationDB = { + // Ensure we won't call init() while xpcom-shutdown is performed + _shutdownInProgress: false, + + // A handle to the kvstore, retrieved lazily when we load the data. + _store: null, + + // A promise that resolves once the store has been loaded. + // The promise doesn't resolve to a value; it merely captures the state + // of the load via its resolution. + _loadPromise: null, + + init() { + if (this._shutdownInProgress) { + return; + } + + this.tasks = []; // read/write operation queue + this.runningTask = null; + + Services.obs.addObserver(this, "xpcom-shutdown"); + this.registerListeners(); + }, + + registerListeners() { + for (let message of kMessages) { + Services.ppmm.addMessageListener(message, this); + } + }, + + unregisterListeners() { + for (let message of kMessages) { + Services.ppmm.removeMessageListener(message, this); + } + }, + + observe(aSubject, aTopic, aData) { + if (DEBUG) { + debug("Topic: " + aTopic); + } + if (aTopic == "xpcom-shutdown") { + this._shutdownInProgress = true; + Services.obs.removeObserver(this, "xpcom-shutdown"); + this.unregisterListeners(); + } + }, + + filterNonAppNotifications(notifications) { + for (let origin in notifications) { + let persistentNotificationCount = 0; + for (let id in notifications[origin]) { + if (notifications[origin][id].serviceWorkerRegistrationScope) { + persistentNotificationCount++; + } else { + delete notifications[origin][id]; + } + } + if (persistentNotificationCount == 0) { + if (DEBUG) { + debug( + "Origin " + origin + " is not linked to an app manifest, deleting." + ); + } + delete notifications[origin]; + } + } + + return notifications; + }, + + async maybeMigrateData() { + const oldStore = PathUtils.join( + Services.dirsvc.get("ProfD", Ci.nsIFile).path, + "notificationstore.json" + ); + + if (!(await IOUtils.exists(oldStore))) { + if (DEBUG) { + debug("Old store doesn't exist; not migrating data."); + } + return; + } + + let data; + try { + data = await IOUtils.readUTF8(oldStore); + } catch (ex) { + // If read failed, we assume we have no notifications to migrate. + if (DEBUG) { + debug("Failed to read old store; not migrating data."); + } + return; + } finally { + // Finally, delete the old file so we don't try to migrate it again. + await IOUtils.remove(oldStore); + } + + if (data.length) { + // Preprocessing phase intends to cleanly separate any migration-related + // tasks. + // + // NB: This code existed before we migrated the data to a kvstore, + // and the "migration-related tasks" it references are from an earlier + // migration. We used to do it every time we read the JSON file; + // now we do it once, when migrating the JSON file to the kvstore. + const notifications = this.filterNonAppNotifications(JSON.parse(data)); + + // Copy the data from the JSON file to the kvstore. + // TODO: use a transaction to improve the performance of these operations + // once the kvstore API supports it (bug 1515096). + for (const origin in notifications) { + for (const id in notifications[origin]) { + await this._store.put( + makeKey(origin, id), + JSON.stringify(notifications[origin][id]) + ); + } + } + } + }, + + // Attempt to read notification file, if it's not there we will create it. + async load() { + // Get and cache a handle to the kvstore. + const dir = PathUtils.join(PathUtils.profileDir, "notificationstore"); + await IOUtils.makeDirectory(dir, { ignoreExisting: true }); + this._store = await lazy.KeyValueService.getOrCreate(dir, "notifications"); + + // Migrate data from the old JSON file to the new kvstore if the old file + // is present in the user's profile directory. + await this.maybeMigrateData(); + }, + + // Helper function: promise will be resolved once file exists and/or is loaded. + ensureLoaded() { + if (!this._loadPromise) { + this._loadPromise = this.load(); + } + return this._loadPromise; + }, + + receiveMessage(message) { + if (DEBUG) { + debug("Received message:" + message.name); + } + + // sendAsyncMessage can fail if the child process exits during a + // notification storage operation, so always wrap it in a try/catch. + function returnMessage(name, data) { + try { + message.target.sendAsyncMessage(name, data); + } catch (e) { + if (DEBUG) { + debug("Return message failed, " + name); + } + } + } + + switch (message.name) { + case "Notification:GetAll": + this.queueTask("getall", message.data) + .then(function (notifications) { + returnMessage("Notification:GetAll:Return:OK", { + requestID: message.data.requestID, + origin: message.data.origin, + notifications, + }); + }) + .catch(function (error) { + returnMessage("Notification:GetAll:Return:KO", { + requestID: message.data.requestID, + origin: message.data.origin, + errorMsg: error, + }); + }); + break; + + case "Notification:Save": + this.queueTask("save", message.data) + .then(function () { + returnMessage("Notification:Save:Return:OK", { + requestID: message.data.requestID, + }); + }) + .catch(function (error) { + returnMessage("Notification:Save:Return:KO", { + requestID: message.data.requestID, + errorMsg: error, + }); + }); + break; + + case "Notification:Delete": + this.queueTask("delete", message.data) + .then(function () { + returnMessage("Notification:Delete:Return:OK", { + requestID: message.data.requestID, + }); + }) + .catch(function (error) { + returnMessage("Notification:Delete:Return:KO", { + requestID: message.data.requestID, + errorMsg: error, + }); + }); + break; + + default: + if (DEBUG) { + debug("Invalid message name" + message.name); + } + } + }, + + // We need to make sure any read/write operations are atomic, + // so use a queue to run each operation sequentially. + queueTask(operation, data) { + if (DEBUG) { + debug("Queueing task: " + operation); + } + + var defer = {}; + + this.tasks.push({ operation, data, defer }); + + var promise = new Promise(function (resolve, reject) { + defer.resolve = resolve; + defer.reject = reject; + }); + + // Only run immediately if we aren't currently running another task. + if (!this.runningTask) { + if (DEBUG) { + debug("Task queue was not running, starting now..."); + } + this.runNextTask(); + } + + return promise; + }, + + runNextTask() { + if (this.tasks.length === 0) { + if (DEBUG) { + debug("No more tasks to run, queue depleted"); + } + this.runningTask = null; + return; + } + this.runningTask = this.tasks.shift(); + + // Always make sure we are loaded before performing any read/write tasks. + this.ensureLoaded() + .then(() => { + var task = this.runningTask; + + switch (task.operation) { + case "getall": + return this.taskGetAll(task.data); + + case "save": + return this.taskSave(task.data); + + case "delete": + return this.taskDelete(task.data); + } + + throw new Error(`Unknown task operation: ${task.operation}`); + }) + .then(payload => { + if (DEBUG) { + debug("Finishing task: " + this.runningTask.operation); + } + this.runningTask.defer.resolve(payload); + }) + .catch(err => { + if (DEBUG) { + debug( + "Error while running " + this.runningTask.operation + ": " + err + ); + } + this.runningTask.defer.reject(err); + }) + .then(() => { + this.runNextTask(); + }); + }, + + enumerate(origin) { + // The "from" and "to" key parameters to nsIKeyValueStore.enumerate() + // are inclusive and exclusive, respectively, and keys are tuples + // of origin and ID joined by a tab (\t), which is character code 9; + // so enumerating ["origin", "origin\n"), where the line feed (\n) + // is character code 10, enumerates all pairs with the given origin. + return this._store.enumerate(origin, `${origin}\n`); + }, + + async taskGetAll(data) { + if (DEBUG) { + debug("Task, getting all"); + } + var origin = data.origin; + var notifications = []; + + for (const { value } of await this.enumerate(origin)) { + notifications.push(JSON.parse(value)); + } + + if (data.tag) { + notifications = notifications.filter(n => n.tag === data.tag); + } + + return notifications; + }, + + async taskSave(data) { + if (DEBUG) { + debug("Task, saving"); + } + var origin = data.origin; + var notification = data.notification; + + // We might have existing notification with this tag, + // if so we need to remove it before saving the new one. + if (notification.tag) { + for (const { key, value } of await this.enumerate(origin)) { + const oldNotification = JSON.parse(value); + if (oldNotification.tag === notification.tag) { + await this._store.delete(key); + } + } + } + + await this._store.put( + makeKey(origin, notification.id), + JSON.stringify(notification) + ); + }, + + async taskDelete(data) { + if (DEBUG) { + debug("Task, deleting"); + } + await this._store.delete(makeKey(data.origin, data.id)); + }, +}; + +NotificationDB.init(); diff --git a/dom/notification/old/NotificationDB.sys.mjs b/dom/notification/old/NotificationDB.sys.mjs new file mode 100644 index 0000000000..79a9965628 --- /dev/null +++ b/dom/notification/old/NotificationDB.sys.mjs @@ -0,0 +1,386 @@ +/* This Source Code Form is subject to the terms of the Mozilla Public + * License, v. 2.0. If a copy of the MPL was not distributed with this file, + * You can obtain one at http://mozilla.org/MPL/2.0/. */ + +const DEBUG = false; +function debug(s) { + dump("-*- NotificationDB component: " + s + "\n"); +} + +const NOTIFICATION_STORE_DIR = PathUtils.profileDir; +const NOTIFICATION_STORE_PATH = PathUtils.join( + NOTIFICATION_STORE_DIR, + "notificationstore.json" +); + +const kMessages = [ + "Notification:Save", + "Notification:Delete", + "Notification:GetAll", +]; + +var NotificationDB = { + // Ensure we won't call init() while xpcom-shutdown is performed + _shutdownInProgress: false, + + init() { + if (this._shutdownInProgress) { + return; + } + + this.notifications = Object.create(null); + this.byTag = Object.create(null); + this.loaded = false; + + this.tasks = []; // read/write operation queue + this.runningTask = null; + + Services.obs.addObserver(this, "xpcom-shutdown"); + this.registerListeners(); + }, + + registerListeners() { + for (let message of kMessages) { + Services.ppmm.addMessageListener(message, this); + } + }, + + unregisterListeners() { + for (let message of kMessages) { + Services.ppmm.removeMessageListener(message, this); + } + }, + + observe(aSubject, aTopic, aData) { + if (DEBUG) { + debug("Topic: " + aTopic); + } + if (aTopic == "xpcom-shutdown") { + this._shutdownInProgress = true; + Services.obs.removeObserver(this, "xpcom-shutdown"); + this.unregisterListeners(); + } + }, + + filterNonAppNotifications(notifications) { + let result = Object.create(null); + for (let origin in notifications) { + result[origin] = Object.create(null); + let persistentNotificationCount = 0; + for (let id in notifications[origin]) { + if (notifications[origin][id].serviceWorkerRegistrationScope) { + persistentNotificationCount++; + result[origin][id] = notifications[origin][id]; + } + } + if (persistentNotificationCount == 0) { + if (DEBUG) { + debug( + "Origin " + origin + " is not linked to an app manifest, deleting." + ); + } + delete result[origin]; + } + } + + return result; + }, + + // Attempt to read notification file, if it's not there we will create it. + load() { + var promise = IOUtils.readUTF8(NOTIFICATION_STORE_PATH); + return promise.then( + data => { + if (data.length) { + // Preprocessing phase intends to cleanly separate any migration-related + // tasks. + this.notifications = this.filterNonAppNotifications(JSON.parse(data)); + } + + // populate the list of notifications by tag + if (this.notifications) { + for (var origin in this.notifications) { + this.byTag[origin] = Object.create(null); + for (var id in this.notifications[origin]) { + var curNotification = this.notifications[origin][id]; + if (curNotification.tag) { + this.byTag[origin][curNotification.tag] = curNotification; + } + } + } + } + + this.loaded = true; + }, + + // If read failed, we assume we have no notifications to load. + reason => { + this.loaded = true; + return this.createStore(); + } + ); + }, + + // Creates the notification directory. + createStore() { + var promise = IOUtils.makeDirectory(NOTIFICATION_STORE_DIR, { + ignoreExisting: true, + }); + return promise.then(this.createFile.bind(this)); + }, + + // Creates the notification file once the directory is created. + createFile() { + return IOUtils.writeUTF8(NOTIFICATION_STORE_PATH, "", { + tmpPath: NOTIFICATION_STORE_PATH + ".tmp", + }); + }, + + // Save current notifications to the file. + save() { + var data = JSON.stringify(this.notifications); + return IOUtils.writeUTF8(NOTIFICATION_STORE_PATH, data, { + tmpPath: NOTIFICATION_STORE_PATH + ".tmp", + }); + }, + + // Helper function: promise will be resolved once file exists and/or is loaded. + ensureLoaded() { + if (!this.loaded) { + return this.load(); + } + return Promise.resolve(); + }, + + receiveMessage(message) { + if (DEBUG) { + debug("Received message:" + message.name); + } + + // sendAsyncMessage can fail if the child process exits during a + // notification storage operation, so always wrap it in a try/catch. + function returnMessage(name, data) { + try { + message.target.sendAsyncMessage(name, data); + } catch (e) { + if (DEBUG) { + debug("Return message failed, " + name); + } + } + } + + switch (message.name) { + case "Notification:GetAll": + this.queueTask("getall", message.data) + .then(function (notifications) { + returnMessage("Notification:GetAll:Return:OK", { + requestID: message.data.requestID, + origin: message.data.origin, + notifications, + }); + }) + .catch(function (error) { + returnMessage("Notification:GetAll:Return:KO", { + requestID: message.data.requestID, + origin: message.data.origin, + errorMsg: error, + }); + }); + break; + + case "Notification:Save": + this.queueTask("save", message.data) + .then(function () { + returnMessage("Notification:Save:Return:OK", { + requestID: message.data.requestID, + }); + }) + .catch(function (error) { + returnMessage("Notification:Save:Return:KO", { + requestID: message.data.requestID, + errorMsg: error, + }); + }); + break; + + case "Notification:Delete": + this.queueTask("delete", message.data) + .then(function () { + returnMessage("Notification:Delete:Return:OK", { + requestID: message.data.requestID, + }); + }) + .catch(function (error) { + returnMessage("Notification:Delete:Return:KO", { + requestID: message.data.requestID, + errorMsg: error, + }); + }); + break; + + default: + if (DEBUG) { + debug("Invalid message name" + message.name); + } + } + }, + + // We need to make sure any read/write operations are atomic, + // so use a queue to run each operation sequentially. + queueTask(operation, data) { + if (DEBUG) { + debug("Queueing task: " + operation); + } + + var defer = {}; + + this.tasks.push({ + operation, + data, + defer, + }); + + var promise = new Promise(function (resolve, reject) { + defer.resolve = resolve; + defer.reject = reject; + }); + + // Only run immediately if we aren't currently running another task. + if (!this.runningTask) { + if (DEBUG) { + debug("Task queue was not running, starting now..."); + } + this.runNextTask(); + } + + return promise; + }, + + runNextTask() { + if (this.tasks.length === 0) { + if (DEBUG) { + debug("No more tasks to run, queue depleted"); + } + this.runningTask = null; + return; + } + this.runningTask = this.tasks.shift(); + + // Always make sure we are loaded before performing any read/write tasks. + this.ensureLoaded() + .then(() => { + var task = this.runningTask; + + switch (task.operation) { + case "getall": + return this.taskGetAll(task.data); + + case "save": + return this.taskSave(task.data); + + case "delete": + return this.taskDelete(task.data); + + default: + return Promise.reject( + new Error(`Found a task with unknown operation ${task.operation}`) + ); + } + }) + .then(payload => { + if (DEBUG) { + debug("Finishing task: " + this.runningTask.operation); + } + this.runningTask.defer.resolve(payload); + }) + .catch(err => { + if (DEBUG) { + debug( + "Error while running " + this.runningTask.operation + ": " + err + ); + } + this.runningTask.defer.reject(err); + }) + .then(() => { + this.runNextTask(); + }); + }, + + taskGetAll(data) { + if (DEBUG) { + debug("Task, getting all"); + } + var origin = data.origin; + var notifications = []; + // Grab only the notifications for specified origin. + if (this.notifications[origin]) { + if (data.tag) { + let n; + if ((n = this.byTag[origin][data.tag])) { + notifications.push(n); + } + } else { + for (var i in this.notifications[origin]) { + notifications.push(this.notifications[origin][i]); + } + } + } + return Promise.resolve(notifications); + }, + + taskSave(data) { + if (DEBUG) { + debug("Task, saving"); + } + var origin = data.origin; + var notification = data.notification; + if (!this.notifications[origin]) { + this.notifications[origin] = Object.create(null); + this.byTag[origin] = Object.create(null); + } + + // We might have existing notification with this tag, + // if so we need to remove it before saving the new one. + if (notification.tag) { + var oldNotification = this.byTag[origin][notification.tag]; + if (oldNotification) { + delete this.notifications[origin][oldNotification.id]; + } + this.byTag[origin][notification.tag] = notification; + } + + this.notifications[origin][notification.id] = notification; + return this.save(); + }, + + taskDelete(data) { + if (DEBUG) { + debug("Task, deleting"); + } + var origin = data.origin; + var id = data.id; + if (!this.notifications[origin]) { + if (DEBUG) { + debug("No notifications found for origin: " + origin); + } + return Promise.resolve(); + } + + // Make sure we can find the notification to delete. + var oldNotification = this.notifications[origin][id]; + if (!oldNotification) { + if (DEBUG) { + debug("No notification found with id: " + id); + } + return Promise.resolve(); + } + + if (oldNotification.tag) { + delete this.byTag[origin][oldNotification.tag]; + } + delete this.notifications[origin][id]; + return this.save(); + }, +}; + +NotificationDB.init(); diff --git a/dom/notification/test/browser/browser.toml b/dom/notification/test/browser/browser.toml new file mode 100644 index 0000000000..426a79b940 --- /dev/null +++ b/dom/notification/test/browser/browser.toml @@ -0,0 +1,4 @@ +[DEFAULT] + +["browser_permission_dismiss.js"] +support-files = ["notification.html"] diff --git a/dom/notification/test/browser/browser_permission_dismiss.js b/dom/notification/test/browser/browser_permission_dismiss.js new file mode 100644 index 0000000000..72b2db2827 --- /dev/null +++ b/dom/notification/test/browser/browser_permission_dismiss.js @@ -0,0 +1,231 @@ +"use strict"; + +const { PermissionTestUtils } = ChromeUtils.importESModule( + "resource://testing-common/PermissionTestUtils.sys.mjs" +); + +const ORIGIN_URI = Services.io.newURI("https://example.com"); +const PERMISSION_NAME = "desktop-notification"; +const PROMPT_ALLOW_BUTTON = -1; +const PROMPT_NOT_NOW_BUTTON = 0; +const PROMPT_NEVER_BUTTON = 1; +const TEST_URL = + "https://example.com/browser/dom/notification/test/browser/notification.html"; + +/** + * Clicks the specified web-notifications prompt button. + * + * @param {Number} aButtonIndex Number indicating which button to click. + * See the constants in this file. + * @note modified from toolkit/components/passwordmgr/test/browser/head.js + */ +function clickDoorhangerButton(aButtonIndex, browser) { + let popup = PopupNotifications.getNotification("web-notifications", browser); + let notifications = popup.owner.panel.childNodes; + ok(notifications.length, "at least one notification displayed"); + ok(true, notifications.length + " notification(s)"); + let notification = notifications[0]; + + if (aButtonIndex == PROMPT_ALLOW_BUTTON) { + ok(true, "Triggering main action (allow the permission)"); + notification.button.doCommand(); + } else if (aButtonIndex == PROMPT_NEVER_BUTTON) { + ok(true, "Triggering secondary action (deny the permission permanently)"); + notification.menupopup.querySelector("menuitem").doCommand(); + } else { + ok(true, "Triggering secondary action (deny the permission temporarily)"); + notification.secondaryButton.doCommand(); + } +} + +/** + * Opens a tab which calls `Notification.requestPermission()` with a callback + * argument, calls the `task` function while the permission prompt is open, + * and verifies that the expected permission is set. + * + * @param {Function} task Task function to run to interact with the prompt. + * @param {String} permission Expected permission value. + * @return {Promise} resolving when the task function is done and the tab + * closes. + */ +function tabWithRequest( + task, + permission, + browser = window.gBrowser, + privateWindow = false +) { + clearPermission(ORIGIN_URI, PERMISSION_NAME, privateWindow); + + return BrowserTestUtils.withNewTab( + { + gBrowser: browser, + url: TEST_URL, + }, + async function (linkedBrowser) { + let requestPromise = SpecialPowers.spawn( + linkedBrowser, + [ + { + permission, + }, + ], + async function ({ permission }) { + function requestCallback(perm) { + is( + perm, + permission, + "Should call the legacy callback with the permission state" + ); + } + let perm = await content.window.Notification.requestPermission( + requestCallback + ); + is( + perm, + permission, + "Should resolve the promise with the permission state" + ); + } + ); + + await task(linkedBrowser); + await requestPromise; + } + ); +} + +function clearPermission(origin, permissionName, isPrivate) { + let principal = Services.scriptSecurityManager.createContentPrincipal( + origin, + isPrivate ? { privateBrowsingId: 1 } : {} /* attrs */ + ); + PermissionTestUtils.remove(principal, permissionName); +} + +add_setup(async function () { + Services.prefs.setBoolPref( + "dom.webnotifications.requireuserinteraction", + false + ); + Services.prefs.setBoolPref( + "permissions.desktop-notification.notNow.enabled", + true + ); + SimpleTest.registerCleanupFunction(() => { + Services.prefs.clearUserPref("dom.webnotifications.requireuserinteraction"); + Services.prefs.clearUserPref( + "permissions.desktop-notification.notNow.enabled" + ); + + clearPermission(ORIGIN_URI, PERMISSION_NAME, false /* private origin */); + clearPermission(ORIGIN_URI, PERMISSION_NAME, true /* private origin */); + }); +}); + +add_task(async function test_requestPermission_granted() { + await tabWithRequest(async function (linkedBrowser) { + await BrowserTestUtils.waitForEvent(PopupNotifications.panel, "popupshown"); + clickDoorhangerButton(PROMPT_ALLOW_BUTTON, linkedBrowser); + }, "granted"); + + ok( + !PopupNotifications.getNotification("web-notifications"), + "Should remove the doorhanger notification icon if granted" + ); + + is( + PermissionTestUtils.testPermission(ORIGIN_URI, PERMISSION_NAME), + Services.perms.ALLOW_ACTION, + "Check permission in perm. manager" + ); +}); + +add_task(async function test_requestPermission_denied_temporarily() { + await tabWithRequest(async function (linkedBrowser) { + await BrowserTestUtils.waitForEvent(PopupNotifications.panel, "popupshown"); + clickDoorhangerButton(PROMPT_NOT_NOW_BUTTON, linkedBrowser); + }, "default"); + + ok( + !PopupNotifications.getNotification("web-notifications"), + "Should remove the doorhanger notification icon if denied" + ); + + is( + PermissionTestUtils.testPermission(ORIGIN_URI, PERMISSION_NAME), + Services.perms.UNKNOWN_ACTION, + "Check permission in perm. manager" + ); +}); + +add_task(async function test_requestPermission_denied_permanently() { + await tabWithRequest(async function (linkedBrowser) { + await BrowserTestUtils.waitForEvent(PopupNotifications.panel, "popupshown"); + clickDoorhangerButton(PROMPT_NEVER_BUTTON, linkedBrowser); + }, "denied"); + + ok( + !PopupNotifications.getNotification("web-notifications"), + "Should remove the doorhanger notification icon if denied" + ); + + is( + PermissionTestUtils.testPermission(ORIGIN_URI, PERMISSION_NAME), + Services.perms.DENY_ACTION, + "Check permission in perm. manager" + ); +}); + +add_task( + async function test_requestPermission_defaultPrivateNotificationsPref() { + ok( + !SpecialPowers.getBoolPref( + "dom.webnotifications.privateBrowsing.enableDespiteLimitations" + ), + "Pref should be default disabled" + ); + } +); + +add_task(async function test_requestPermission_privateNotifications() { + async function run(perm) { + let privateWindow = await BrowserTestUtils.openNewBrowserWindow({ + private: true, + }); + + await tabWithRequest( + async linkedBrowser => { + if (perm != Services.perms.UNKNOWN_ACTION) { + await BrowserTestUtils.waitForEvent( + privateWindow.PopupNotifications.panel, + "popupshown" + ); + + clickDoorhangerButton(PROMPT_ALLOW_BUTTON, linkedBrowser); + } + }, + perm == Services.perms.ALLOW_ACTION ? "granted" : "denied", + privateWindow.gBrowser, + true /* privateWindow */ + ); + + ok( + !PopupNotifications.getNotification( + "web-notifications", + privateWindow.gBrowser + ), + "doorhanger should have been removed in all cases by now" + ); + + await BrowserTestUtils.closeWindow(privateWindow); + } + + await run(Services.perms.UNKNOWN_ACTION); + + await SpecialPowers.pushPrefEnv({ + set: [ + ["dom.webnotifications.privateBrowsing.enableDespiteLimitations", true], + ], + }); + await run(Services.perms.ALLOW_ACTION); +}); diff --git a/dom/notification/test/browser/notification.html b/dom/notification/test/browser/notification.html new file mode 100644 index 0000000000..0ceeb8ea46 --- /dev/null +++ b/dom/notification/test/browser/notification.html @@ -0,0 +1,11 @@ +<!DOCTYPE html> +<html> + <head> + <meta charset="utf-8"> + <title>Notifications test</title> + </head> + + <body> + + </body> +</html> diff --git a/dom/notification/test/chrome/chrome.toml b/dom/notification/test/chrome/chrome.toml new file mode 100644 index 0000000000..d4027438d2 --- /dev/null +++ b/dom/notification/test/chrome/chrome.toml @@ -0,0 +1,3 @@ +[DEFAULT] + +["test_notification_system_principal.xhtml"] diff --git a/dom/notification/test/chrome/test_notification_system_principal.xhtml b/dom/notification/test/chrome/test_notification_system_principal.xhtml new file mode 100644 index 0000000000..0700d59338 --- /dev/null +++ b/dom/notification/test/chrome/test_notification_system_principal.xhtml @@ -0,0 +1,76 @@ +<?xml version="1.0"?> +<?xml-stylesheet type="text/css" href="chrome://global/skin"?> +<?xml-stylesheet type="text/css" href="chrome://mochikit/content/tests/SimpleTest/test.css"?> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=874090 +--> +<window title="Mozilla Bug 874090" onload="runTests()" + xmlns="http://www.mozilla.org/keymaster/gatekeeper/there.is.only.xul"> + <script src="chrome://mochikit/content/tests/SimpleTest/SimpleTest.js"/> + <script src="chrome://mochikit/content/tests/SimpleTest/EventUtils.js"/> + + <!-- test results are displayed in the html:body --> + <body xmlns="http://www.w3.org/1999/xhtml"> + <a href="https://bugzilla.mozilla.org/show_bug.cgi?id=874090" + target="_blank">Mozilla Bug 874090</a> + </body> + + <!-- test code goes here --> + <script type="application/javascript"> + <![CDATA[ + /** Test for Bug 874090 **/ + const MOCK_CID = Components.ID("{2a0f83c4-8818-4914-a184-f1172b4eaaa7}"); + const ALERTS_SERVICE_CONTRACT_ID = "@mozilla.org/alerts-service;1"; + + var mockAlertsService = { + showAlert(alert, alertListener) { + ok(true, "System principal was granted permission and is able to call showAlert."); + unregisterMock(); + SimpleTest.finish(); + }, + + showAlertNotification(imageUrl, title, text, textClickable, + cookie, alertListener, name, dir, lang, data) { + this.showAlert(); + }, + + QueryInterface: ChromeUtils.generateQI(["nsIAlertsService"]), + + createInstance(aIID) { + return this.QueryInterface(aIID); + } + }; + + function registerMock() { + Components.manager.QueryInterface(Ci.nsIComponentRegistrar). + registerFactory(MOCK_CID, "alerts service", ALERTS_SERVICE_CONTRACT_ID, mockAlertsService); + } + + function unregisterMock() { + Components.manager.QueryInterface(Ci.nsIComponentRegistrar). + unregisterFactory(MOCK_CID, mockAlertsService); + } + + function runTests() { + registerMock(); + + is(Notification.permission, "granted", "System principal should be automatically granted permission."); + + Notification.requestPermission(function(permission) { + is(permission, "granted", "System principal should be granted permission when calling requestPermission."); + + if (permission == "granted") { + // Create a notification and make sure that it is able to call into + // the mock alert service to show the notification. + new Notification("Hello"); + } else { + unregisterMock(); + SimpleTest.finish(); + } + }); + } + + SimpleTest.waitForExplicitFinish(); + ]]> + </script> +</window> diff --git a/dom/notification/test/mochitest/MockServices.js b/dom/notification/test/mochitest/MockServices.js new file mode 100644 index 0000000000..4b2526c26a --- /dev/null +++ b/dom/notification/test/mochitest/MockServices.js @@ -0,0 +1,176 @@ +var MockServices = (function () { + "use strict"; + + const MOCK_ALERTS_CID = SpecialPowers.wrap(SpecialPowers.Components).ID( + "{48068bc2-40ab-4904-8afd-4cdfb3a385f3}" + ); + const ALERTS_SERVICE_CONTRACT_ID = "@mozilla.org/alerts-service;1"; + + const MOCK_SYSTEM_ALERTS_CID = SpecialPowers.wrap( + SpecialPowers.Components + ).ID("{e86d888c-e41b-4b78-9104-2f2742a532de}"); + const SYSTEM_ALERTS_SERVICE_CONTRACT_ID = + "@mozilla.org/system-alerts-service;1"; + + var registrar = SpecialPowers.wrap( + SpecialPowers.Components + ).manager.QueryInterface(SpecialPowers.Ci.nsIComponentRegistrar); + + var activeAlertNotifications = Object.create(null); + + var activeAppNotifications = Object.create(null); + + window.addEventListener("mock-notification-close-event", function (e) { + for (var alertName in activeAlertNotifications) { + var notif = activeAlertNotifications[alertName]; + if (notif.title === e.detail.title) { + notif.listener.observe(null, "alertfinished", null); + delete activeAlertNotifications[alertName]; + delete activeAppNotifications[alertName]; + return; + } + } + }); + + var mockAlertsService = { + showPersistentNotification(persistentData, alert, alertListener) { + this.showAlert(alert, alertListener); + }, + + showAlert(alert, alertListener) { + var listener = SpecialPowers.wrap(alertListener); + activeAlertNotifications[alert.name] = { + listener, + cookie: alert.cookie, + title: alert.title, + }; + + // fake async alert show event + if (listener) { + setTimeout(function () { + listener.observe(null, "alertshow", alert.cookie); + }, 100); + setTimeout(function () { + listener.observe(null, "alertclickcallback", alert.cookie); + }, 100); + } + }, + + showAlertNotification( + imageUrl, + title, + text, + textClickable, + cookie, + alertListener, + name + ) { + this.showAlert( + { + name, + cookie, + title, + }, + alertListener + ); + }, + + closeAlert(name) { + var alertNotification = activeAlertNotifications[name]; + if (alertNotification) { + if (alertNotification.listener) { + alertNotification.listener.observe( + null, + "alertfinished", + alertNotification.cookie + ); + } + delete activeAlertNotifications[name]; + } + + var appNotification = activeAppNotifications[name]; + if (appNotification) { + delete activeAppNotifications[name]; + } + }, + + QueryInterface(aIID) { + if ( + SpecialPowers.wrap(aIID).equals(SpecialPowers.Ci.nsISupports) || + SpecialPowers.wrap(aIID).equals(SpecialPowers.Ci.nsIAlertsService) + ) { + return this; + } + throw SpecialPowers.Components.results.NS_ERROR_NO_INTERFACE; + }, + + createInstance(aIID) { + return this.QueryInterface(aIID); + }, + }; + mockAlertsService = SpecialPowers.wrapCallbackObject(mockAlertsService); + + // MockServices API + return { + register() { + try { + this.originalAlertsCID = registrar.contractIDToCID( + ALERTS_SERVICE_CONTRACT_ID + ); + } catch (ex) { + this.originalAlertsCID = null; + } + try { + this.originalSystemAlertsCID = registrar.contractIDToCID( + SYSTEM_ALERTS_SERVICE_CONTRACT_ID + ); + } catch (ex) { + this.originalSystemAlertsCID = null; + } + + registrar.registerFactory( + MOCK_ALERTS_CID, + "alerts service", + ALERTS_SERVICE_CONTRACT_ID, + mockAlertsService + ); + + registrar.registerFactory( + MOCK_SYSTEM_ALERTS_CID, + "system alerts service", + SYSTEM_ALERTS_SERVICE_CONTRACT_ID, + mockAlertsService + ); + }, + + unregister() { + registrar.unregisterFactory(MOCK_ALERTS_CID, mockAlertsService); + registrar.unregisterFactory(MOCK_SYSTEM_ALERTS_CID, mockAlertsService); + + // Passing `null` for the factory re-maps the contract ID to the + // entry for its original CID. + + if (this.originalAlertsCID) { + registrar.registerFactory( + this.originalAlertsCID, + "alerts service", + ALERTS_SERVICE_CONTRACT_ID, + null + ); + } + + if (this.originalSystemAlertsCID) { + registrar.registerFactory( + this.originalSystemAlertsCID, + "system alerts service", + SYSTEM_ALERTS_SERVICE_CONTRACT_ID, + null + ); + } + }, + + activeAlertNotifications, + + activeAppNotifications, + }; +})(); diff --git a/dom/notification/test/mochitest/NotificationTest.js b/dom/notification/test/mochitest/NotificationTest.js new file mode 100644 index 0000000000..400ff56253 --- /dev/null +++ b/dom/notification/test/mochitest/NotificationTest.js @@ -0,0 +1,102 @@ +var NotificationTest = (function () { + "use strict"; + + function info(msg, name) { + SimpleTest.info("::Notification Tests::" + (name || ""), msg); + } + + function setup_testing_env() { + SimpleTest.waitForExplicitFinish(); + // turn on testing pref (used by notification.cpp, and mock the alerts + return SpecialPowers.setBoolPref("notification.prompt.testing", true); + } + + async function teardown_testing_env() { + await SpecialPowers.clearUserPref("notification.prompt.testing"); + await SpecialPowers.clearUserPref("notification.prompt.testing.allow"); + + SimpleTest.finish(); + } + + function executeTests(tests, callback) { + // context is `this` object in test functions + // it can be used to track data between tests + var context = {}; + + (function executeRemainingTests(remainingTests) { + if (!remainingTests.length) { + callback(); + return; + } + + var nextTest = remainingTests.shift(); + var finishTest = executeRemainingTests.bind(null, remainingTests); + var startTest = nextTest.call.bind(nextTest, context, finishTest); + + try { + startTest(); + // if no callback was defined for test function, + // we must manually invoke finish to continue + if (nextTest.length === 0) { + finishTest(); + } + } catch (e) { + ok(false, "Test threw exception!"); + finishTest(); + } + })(tests); + } + + // NotificationTest API + return { + run(tests, callback) { + let ready = setup_testing_env(); + + addLoadEvent(async function () { + await ready; + executeTests(tests, function () { + teardown_testing_env(); + callback && callback(); + }); + }); + }, + + allowNotifications() { + return SpecialPowers.setBoolPref( + "notification.prompt.testing.allow", + true + ); + }, + + denyNotifications() { + return SpecialPowers.setBoolPref( + "notification.prompt.testing.allow", + false + ); + }, + + clickNotification(notification) { + // TODO: how?? + }, + + fireCloseEvent(title) { + window.dispatchEvent( + new CustomEvent("mock-notification-close-event", { + detail: { + title, + }, + }) + ); + }, + + info, + + payload: { + body: "Body", + tag: "fakeTag", + icon: "icon.jpg", + lang: "en-US", + dir: "ltr", + }, + }; +})(); diff --git a/dom/notification/test/mochitest/blank.html b/dom/notification/test/mochitest/blank.html new file mode 100644 index 0000000000..1f9324523a --- /dev/null +++ b/dom/notification/test/mochitest/blank.html @@ -0,0 +1,4 @@ +<!DOCTYPE html> +<html> + <body></body> +</html> diff --git a/dom/notification/test/mochitest/create_notification.html b/dom/notification/test/mochitest/create_notification.html new file mode 100644 index 0000000000..b0387e4ffb --- /dev/null +++ b/dom/notification/test/mochitest/create_notification.html @@ -0,0 +1,16 @@ +<!DOCTYPE html> +<html> +<head><meta charset=utf-8> + <title>Create a notification</title> +</head> +<body> +<script> + +var notification = new Notification("This is a title", { + body: "This is a notification body", + tag: "sometag", + }); + +</script> +</body> +</html> diff --git a/dom/notification/test/mochitest/mochitest.toml b/dom/notification/test/mochitest/mochitest.toml new file mode 100644 index 0000000000..19320326fc --- /dev/null +++ b/dom/notification/test/mochitest/mochitest.toml @@ -0,0 +1,30 @@ +[DEFAULT] + +support-files = [ + "blank.html", + "create_notification.html", + "MockServices.js", + "NotificationTest.js", +] + +["test_notification_basics.html"] +skip-if = ["xorigin"] # Bug 1792790 + +["test_notification_crossorigin_iframe.html"] +scheme = "https" +# This test needs to be run on HTTP (not HTTPS). + +["test_notification_insecure_context.html"] +skip-if = [ + "http3", + "http2", +] + +["test_notification_permissions.html"] +scheme = "https" + +["test_notification_tag.html"] +skip-if = [ + "http3", + "http2", +] diff --git a/dom/notification/test/mochitest/test_notification_basics.html b/dom/notification/test/mochitest/test_notification_basics.html new file mode 100644 index 0000000000..3dde839a96 --- /dev/null +++ b/dom/notification/test/mochitest/test_notification_basics.html @@ -0,0 +1,125 @@ +<!DOCTYPE HTML> +<html> +<head> + <title>Notification Basics</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <script type="text/javascript" src="MockServices.js"></script> + <script type="text/javascript" src="NotificationTest.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<p id="display"></p> +<div id="content" style="display: none"></div> +<pre id="test"></pre> +<script type="text/javascript"> + + var info = NotificationTest.info; + var options; + + SimpleTest.requestFlakyTimeout("untriaged"); + + var steps = [ + function() { + info("Test notification spec"); + ok(Notification, "Notification constructor exists"); + ok(Notification.permission, "Notification.permission exists"); + ok(Notification.requestPermission, "Notification.requestPermission exists"); + }, + + function() { + info("Test requestPermission without callback"); + Notification.requestPermission(); + }, + + async function(done) { + info("Test requestPermission deny"); + function assertPermissionDenied(perm) { + is(perm, "denied", "Permission should be denied."); + is(Notification.permission, "denied", "Permission should be denied."); + } + await NotificationTest.denyNotifications(); + Notification.requestPermission() + .then(assertPermissionDenied) + .then(_ => Notification.requestPermission(assertPermissionDenied)) + .catch(err => { + ok(!err, "requestPermission should not reject promise"); + }) + .then(done); + }, + + async function(done) { + info("Test requestPermission grant"); + function assertPermissionGranted(perm) { + is(perm, "granted", "Permission should be granted."); + is(Notification.permission, "granted", "Permission should be granted"); + } + await NotificationTest.allowNotifications(); + Notification.requestPermission() + .then(assertPermissionGranted) + .then(_ => Notification.requestPermission(assertPermissionGranted)) + .catch(err => { + ok(!err, "requestPermission should not reject promise"); + }) + .then(done); + }, + + function(done) { + info("Test invalid requestPermission"); + Notification.requestPermission({}) + .then(_ => { + ok(false, "Non callable arg to requestPermission should reject promise"); + }, err => { + ok(true, "Non callable arg to requestPermission should reject promise"); + }) + .then(done); + }, + + function(done) { + info("Test create notification"); + + options = NotificationTest.payload; + + var notification = new Notification("This is a title", options); + + ok(notification, "Notification exists"); + is(notification.onclick, null, "onclick() should be null"); + is(notification.onshow, null, "onshow() should be null"); + is(notification.onerror, null, "onerror() should be null"); + is(notification.onclose, null, "onclose() should be null"); + is(typeof notification.close, "function", "close() should exist"); + + is(notification.dir, options.dir, "auto should get set"); + is(notification.lang, options.lang, "lang should get set"); + is(notification.body, options.body, "body should get set"); + is(notification.tag, options.tag, "tag should get set"); + is(notification.icon, options.icon, "icon should get set"); + + // store notification in test context + this.notification = notification; + + notification.onshow = function() { + ok(true, "onshow handler should be called"); + done(); + }; + }, + + function(done) { + info("Test closing a notification"); + var notification = this.notification; + + notification.onclose = function() { + ok(true, "onclose handler should be called"); + done(); + }; + + notification.close(); + }, + ]; + + MockServices.register(); + NotificationTest.run(steps, function() { + MockServices.unregister(); + }); +</script> +</body> +</html> diff --git a/dom/notification/test/mochitest/test_notification_crossorigin_iframe.html b/dom/notification/test/mochitest/test_notification_crossorigin_iframe.html new file mode 100644 index 0000000000..0abfce3722 --- /dev/null +++ b/dom/notification/test/mochitest/test_notification_crossorigin_iframe.html @@ -0,0 +1,67 @@ +<!DOCTYPE HTML> +<html> +<!-- +Tests that Notification permissions are denied in cross-origin iframes. +https://bugzilla.mozilla.org/show_bug.cgi?id=1560741 +--> +<head> + <title>Notification permission in cross-origin iframes</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> + <p id="display"></p> + <div id="content" style="display: none"> + </div> + <pre id="test"> + <script class="testbody" type="text/javascript"> + SimpleTest.waitForExplicitFinish(); + + const kBlankURL = "https://example.org/tests/dom/notification/test/mochitest/blank.html"; + + (async function runTest() { + await SpecialPowers.pushPrefEnv({"set": [ + ["notification.prompt.testing", true], + ["notification.prompt.testing.allow", true], + ]}); + + let iframe = document.createElement("iframe"); + iframe.src = kBlankURL; + document.body.appendChild(iframe); + await new Promise(resolve => { + iframe.onload = resolve; + }); + + let checkRequest = async (expectedResponse, msg) => { + let response = await this.content.Notification.requestPermission(); + Assert.equal(response, expectedResponse, msg); + }; + + await SpecialPowers.spawn(iframe, + ["denied", "Denied permission in cross-origin iframe"], + checkRequest); + + let checkPermission = async (expectedPermission, msg) => { + let permission = this.content.Notification.permission; + Assert.equal(permission, expectedPermission, msg); + }; + + await SpecialPowers.spawn(iframe, + ["denied", "Permission is denied in cross-origin iframe"], + checkPermission); + + await SpecialPowers.pushPrefEnv({"set": [["dom.webnotifications.allowcrossoriginiframe", true]]}); + + await SpecialPowers.spawn(iframe, + ["granted", "Granted permission in cross-origin iframe with pref set"], + checkRequest); + await SpecialPowers.spawn(iframe, + ["granted", "Permission is granted in cross-origin iframe with pref set"], + checkPermission); + + SimpleTest.finish(); + })(); + </script> + </pre> +</body> +</html> diff --git a/dom/notification/test/mochitest/test_notification_insecure_context.html b/dom/notification/test/mochitest/test_notification_insecure_context.html new file mode 100644 index 0000000000..bb64db64a9 --- /dev/null +++ b/dom/notification/test/mochitest/test_notification_insecure_context.html @@ -0,0 +1,42 @@ +<!DOCTYPE HTML> +<html> +<!-- +Tests that Notification permissions are denied in insecure context. +https://bugzilla.mozilla.org/show_bug.cgi?id=1429432 +--> +<head> + <title>Notification permission in insecure context</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> + <p id="display"></p> + <div id="content" style="display: none"> + </div> + <pre id="test"> + <script class="testbody" type="text/javascript"> + SimpleTest.waitForExplicitFinish(); + + // Add an allow permission for the mochitest origin to test this. + let script = SpecialPowers.loadChromeScript(function() { + /* eslint-env mozilla/chrome-script */ + let principal = Services.scriptSecurityManager.createContentPrincipalFromOrigin("http://mochi.test:8888"); + Services.perms.addFromPrincipal(principal, "desktop-notification", Services.perms.ALLOW_ACTION); + addMessageListener("destroy", function() { + Services.perms.removeFromPrincipal(principal, "desktop-notification"); + }); + }); + + (async function runTest() { + let response = await Notification.requestPermission(); + is(response, "denied", "Denied permission in insecure context"); + + script.sendAsyncMessage("destroy"); + script.destroy(); + + SimpleTest.finish(); + })(); + </script> + </pre> +</body> +</html> diff --git a/dom/notification/test/mochitest/test_notification_permissions.html b/dom/notification/test/mochitest/test_notification_permissions.html new file mode 100644 index 0000000000..0290fb9c87 --- /dev/null +++ b/dom/notification/test/mochitest/test_notification_permissions.html @@ -0,0 +1,68 @@ +<!DOCTYPE HTML> +<html> +<!-- +Tests that the Notification.requestPermission and navigator.permissions.query +return values are consistent with the stored permission. +https://bugzilla.mozilla.org/show_bug.cgi?id=1589754 +--> +<head> + <title>Notification permissions and permissions API</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css"> +</head> +<body> + <p id="display"></p> + <div id="content" style="display: none"> + </div> + <pre id="test"> +<script class="testbody"> + +add_task(async function test_notifications_permission() { + await SpecialPowers.clearUserPref("notification.prompt.testing"); + await SpecialPowers.pushPrefEnv({ + set: [ + // Automatically dismiss the permission request when it appears. + ["dom.webnotifications.requireuserinteraction", true], + ], + }); + + async function testPermissionInWindow(win) { + async function checkPermission(perm, expectedResult, expectedPermission) { + await SpecialPowers.pushPermissions([ + { + type: "desktop-notification", + allow: SpecialPowers.Ci.nsIPermissionManager[perm], + context: document, + }, + ]); + is( + await win.Notification.requestPermission(), + expectedResult, + `expected requestPermission() result for permission ${perm}` + ); + + let result = + await win.navigator.permissions.query({ name: "notifications" }); + is( + result.state, + expectedPermission, + `expected permissions API result for permission ${perm}` + ); + } + + await checkPermission("UNKNOWN_ACTION", "default", "prompt"); + await checkPermission("ALLOW_ACTION", "granted", "granted"); + await checkPermission("DENY_ACTION", "denied", "denied"); + await checkPermission("PROMPT_ACTION", "default", "prompt"); + } + + var win = window.open("blank.html"); + await new Promise(resolve => { win.onload = resolve; }); + await testPermissionInWindow(win); + win.close(); +}); + +</script> + </pre> +</body> +</html> diff --git a/dom/notification/test/mochitest/test_notification_tag.html b/dom/notification/test/mochitest/test_notification_tag.html new file mode 100644 index 0000000000..f4fc72bbe3 --- /dev/null +++ b/dom/notification/test/mochitest/test_notification_tag.html @@ -0,0 +1,169 @@ +<!DOCTYPE HTML> +<html> +<!-- +https://bugzilla.mozilla.org/show_bug.cgi?id=782211 +--> +<head> + <title>Bug 782211</title> + <script src="/tests/SimpleTest/SimpleTest.js"></script> + <link rel="stylesheet" type="text/css" href="/tests/SimpleTest/test.css" /> +</head> +<body> +<a target="_blank" href="https://bugzilla.mozilla.org/show_bug.cgi?id=782211">Bug 782211</a> +<p id="display"></p> +<div id="content" style="display: none"> +</div> +<pre id="test"> +</pre> +<script type="text/javascript"> + /* eslint-disable mozilla/use-chromeutils-generateqi */ + + // The mock is not a general purpose mock, but is specific for this test. + // It is always registered in the parent process using LoadChromeScript by + // the MockAlertsService below, to allow this to work regardless of whether + // the frames from different origins live in the same process or in different + // processes (with Fission), since the default content-process alerts service + // relays messages to the parent process. + function mockServicesChromeScript() { + /* eslint-env mozilla/chrome-script */ + const MOCK_CID = Components.ID("{dbe37e64-d9a3-402c-8d8a-0826c619f7ad}"); + const ALERTS_SERVICE_CONTRACT_ID = "@mozilla.org/alerts-service;1"; + + var notificationsCreated = []; + + const mockAlertsService = { + showAlert(alert, alertListener) { + notificationsCreated.push(alert.name); + if (notificationsCreated.length == 3) { + // notifications created by the test1 origin + var test1notifications = []; + // notifications created by the test2 origin + var test2notifications = []; + for (var i = 0; i < notificationsCreated.length; i++) { + var notificationName = notificationsCreated[i]; + if (notificationName.includes("test1")) { + test1notifications.push(notificationsCreated[i]); + } else if (notificationName.includes("test2")) { + test2notifications.push(notificationsCreated[i]); + } + } + + is( + test1notifications.length, + 2, + "2 notifications should be created by test1.example.org:80 origin." + ); + is( + test1notifications[0], + test1notifications[1], + "notification names should be identical." + ); + is( + test2notifications.length, + 1, + "1 notification should be created by test2.example.org:80 origin." + ); + + // Register original alerts service. + registrar.unregisterFactory(MOCK_CID, this); + + sendAsyncMessage("mock-alert-service:unregistered"); + } + }, + + showAlertNotification( + imageUrl, + title, + text, + textClickable, + cookie, + alertListener, + name, + dir, + lang, + data + ) { + this.showAlert({ name }); + }, + + QueryInterface(aIID) { + if (aIID.equals(Ci.nsISupports) || aIID.equals(Ci.nsIAlertsService)) { + return this; + } + throw Components.Exception("", Cr.NS_ERROR_NO_INTERFACE); + }, + + createInstance(aIID) { + return this.QueryInterface(aIID); + }, + }; + + const registrar = Components.manager.QueryInterface(Ci.nsIComponentRegistrar); + + registrar.registerFactory( + MOCK_CID, + "alerts service", + ALERTS_SERVICE_CONTRACT_ID, + mockAlertsService + ); + + const { sendAsyncMessage } = this; + + sendAsyncMessage("mock-alert-service:registered"); + } + + const MockAlertsService = { + async register() { + if (this._chromeScript) { + throw new Error("MockAlertsService already registered"); + } + this._chromeScript = SpecialPowers.loadChromeScript( + mockServicesChromeScript + ); + await this._chromeScript.promiseOneMessage("mock-alert-service:registered"); + }, + async unregistered() { + await this._chromeScript.promiseOneMessage( + "mock-alert-service:unregistered" + ); + }, + }; + + if (window.Notification) { + SimpleTest.waitForExplicitFinish(); + + async function showNotifications() { + await MockAlertsService.register(); + + // Load two frames with the same origin that create notification with the same tag. + // Both pages should generate notifications with the same name, and thus the second + // notification should replace the first. + let sameDomain = window.open("http://test1.example.org:80/tests/dom/notification/test/mochitest/create_notification.html"); + let anotherSameDomain = window.open("http://test1.example.org:80/tests/dom/notification/test/mochitest/create_notification.html"); + // Load a frame with a different origin that creates a notification with the same tag. + // The notification name should be different and thus no notifications should be replaced. + let crossDomain = window.open("http://test2.example.org:80/tests/dom/notification/test/mochitest/create_notification.html"); + + await MockAlertsService.unregistered(); + + sameDomain.close(); + anotherSameDomain.close(); + crossDomain.close(); + SimpleTest.finish(); + } + + SpecialPowers.pushPrefEnv( + { + set: [ + ["notification.prompt.testing", true], + ["notification.prompt.testing.allow", true], + ], + }, + showNotifications + ); + } else { + ok(true, "Notifications are not enabled on the platform."); + } +</script> +</body> +</html> diff --git a/dom/notification/test/unit/head_notificationdb.js b/dom/notification/test/unit/head_notificationdb.js new file mode 100644 index 0000000000..1b23d88729 --- /dev/null +++ b/dom/notification/test/unit/head_notificationdb.js @@ -0,0 +1,61 @@ +"use strict"; + +var { XPCOMUtils } = ChromeUtils.importESModule( + "resource://gre/modules/XPCOMUtils.sys.mjs" +); + +function getNotificationObject(app, id, tag, includeScope) { + const origin = `https://${app}.gaiamobile.org/`; + return { + origin, + id, + title: app + "Notification:" + Date.now(), + dir: "auto", + lang: "", + body: app + " notification body", + tag: tag || "", + icon: "icon.png", + serviceWorkerRegistrationScope: includeScope ? origin : undefined, + }; +} + +var systemNotification = getNotificationObject( + "system", + "{2bc883bf-2809-4432-b0f4-f54e10372764}" +); + +var calendarNotification = getNotificationObject( + "calendar", + "{d8d11299-a58e-429b-9a9a-57c562982fbf}" +); + +// Helper to start the NotificationDB +function startNotificationDB() { + ChromeUtils.importESModule("resource://gre/modules/NotificationDB.sys.mjs"); +} + +// Helper function to add a listener, send message and treat the reply +function addAndSend(msg, reply, callback, payload, runNext = true) { + let handler = { + receiveMessage(message) { + if (message.name === reply) { + Services.cpmm.removeMessageListener(reply, handler); + callback(message); + if (runNext) { + run_next_test(); + } + } + }, + }; + Services.cpmm.addMessageListener(reply, handler); + Services.cpmm.sendAsyncMessage(msg, payload); +} + +// helper fonction, comparing two notifications +function compareNotification(notif1, notif2) { + // retrieved notification should be the second one sent + for (let prop in notif1) { + // compare each property + Assert.equal(notif1[prop], notif2[prop]); + } +} diff --git a/dom/notification/test/unit/test_notificationdb.js b/dom/notification/test/unit/test_notificationdb.js new file mode 100644 index 0000000000..33397f87f3 --- /dev/null +++ b/dom/notification/test/unit/test_notificationdb.js @@ -0,0 +1,340 @@ +"use strict"; + +function run_test() { + do_get_profile(); + startNotificationDB(); + run_next_test(); +} + +// Get one notification, none exists +add_test(function test_get_none() { + let requestID = 0; + let msgReply = "Notification:GetAll:Return:OK"; + let msgHandler = function (message) { + Assert.equal(requestID, message.data.requestID); + Assert.equal(0, message.data.notifications.length); + }; + + addAndSend("Notification:GetAll", msgReply, msgHandler, { + origin: systemNotification.origin, + requestID, + }); +}); + +// Store one notification +add_test(function test_send_one() { + let requestID = 1; + let msgReply = "Notification:Save:Return:OK"; + let msgHandler = function (message) { + Assert.equal(requestID, message.data.requestID); + }; + + addAndSend("Notification:Save", msgReply, msgHandler, { + origin: systemNotification.origin, + notification: systemNotification, + requestID, + }); +}); + +// Get one notification, one exists +add_test(function test_get_one() { + let requestID = 2; + let msgReply = "Notification:GetAll:Return:OK"; + let msgHandler = function (message) { + Assert.equal(requestID, message.data.requestID); + Assert.equal(1, message.data.notifications.length); + // compare the content + compareNotification(systemNotification, message.data.notifications[0]); + }; + + addAndSend("Notification:GetAll", msgReply, msgHandler, { + origin: systemNotification.origin, + requestID, + }); +}); + +// Delete one notification +add_test(function test_delete_one() { + let requestID = 3; + let msgReply = "Notification:Delete:Return:OK"; + let msgHandler = function (message) { + Assert.equal(requestID, message.data.requestID); + }; + + addAndSend("Notification:Delete", msgReply, msgHandler, { + origin: systemNotification.origin, + id: systemNotification.id, + requestID, + }); +}); + +// Get one notification, none exists +add_test(function test_get_none_again() { + let requestID = 4; + let msgReply = "Notification:GetAll:Return:OK"; + let msgHandler = function (message) { + Assert.equal(requestID, message.data.requestID); + Assert.equal(0, message.data.notifications.length); + }; + + addAndSend("Notification:GetAll", msgReply, msgHandler, { + origin: systemNotification.origin, + requestID, + }); +}); + +// Delete one notification that do not exists anymore +add_test(function test_delete_one_nonexistent() { + let requestID = 5; + let msgReply = "Notification:Delete:Return:OK"; + let msgHandler = function (message) { + Assert.equal(requestID, message.data.requestID); + }; + + addAndSend("Notification:Delete", msgReply, msgHandler, { + origin: systemNotification.origin, + id: systemNotification.id, + requestID, + }); +}); + +// Store two notifications with the same id +add_test(function test_send_two_get_one() { + let requestID = 6; + let calls = 0; + + let msgGetReply = "Notification:GetAll:Return:OK"; + let msgGetHandler = function (message) { + Assert.equal(requestID + 2, message.data.requestID); + Assert.equal(1, message.data.notifications.length); + // compare the content + compareNotification(systemNotification, message.data.notifications[0]); + }; + + let msgSaveReply = "Notification:Save:Return:OK"; + let msgSaveHandler = function (message) { + calls += 1; + if (calls === 2) { + addAndSend("Notification:GetAll", msgGetReply, msgGetHandler, { + origin: systemNotification.origin, + requestID: requestID + 2, + }); + } + }; + + addAndSend( + "Notification:Save", + msgSaveReply, + msgSaveHandler, + { + origin: systemNotification.origin, + notification: systemNotification, + requestID, + }, + false + ); + + addAndSend( + "Notification:Save", + msgSaveReply, + msgSaveHandler, + { + origin: systemNotification.origin, + notification: systemNotification, + requestID: requestID + 1, + }, + false + ); +}); + +// Delete previous notification +add_test(function test_delete_previous() { + let requestID = 8; + let msgReply = "Notification:Delete:Return:OK"; + let msgHandler = function (message) { + Assert.equal(requestID, message.data.requestID); + }; + + addAndSend("Notification:Delete", msgReply, msgHandler, { + origin: systemNotification.origin, + id: systemNotification.id, + requestID, + }); +}); + +// Store two notifications from same origin with the same tag +add_test(function test_send_two_get_one() { + let requestID = 10; + let tag = "voicemail"; + + let systemNotification1 = getNotificationObject( + "system", + "{f271f9ee-3955-4c10-b1f2-af552fb270ee}", + tag + ); + let systemNotification2 = getNotificationObject( + "system", + "{8ef9a628-f0f4-44b4-820d-c117573c33e3}", + tag + ); + + let msgGetReply = "Notification:GetAll:Return:OK"; + let msgGetNotifHandler = { + receiveMessage(message) { + if (message.name === msgGetReply) { + Services.cpmm.removeMessageListener(msgGetReply, msgGetNotifHandler); + let notifications = message.data.notifications; + // same tag, so replaced + Assert.equal(1, notifications.length); + // compare the content + compareNotification(systemNotification2, notifications[0]); + run_next_test(); + } + }, + }; + + Services.cpmm.addMessageListener(msgGetReply, msgGetNotifHandler); + + let msgSaveReply = "Notification:Save:Return:OK"; + let msgSaveCalls = 0; + let msgSaveHandler = function (message) { + msgSaveCalls++; + // Once both request have been sent, trigger getall + if (msgSaveCalls === 2) { + Services.cpmm.sendAsyncMessage("Notification:GetAll", { + origin: systemNotification1.origin, + requestID: message.data.requestID + 2, // 12, 13 + }); + } + }; + + addAndSend( + "Notification:Save", + msgSaveReply, + msgSaveHandler, + { + origin: systemNotification1.origin, + notification: systemNotification1, + requestID, // 10 + }, + false + ); + + addAndSend( + "Notification:Save", + msgSaveReply, + msgSaveHandler, + { + origin: systemNotification2.origin, + notification: systemNotification2, + requestID: requestID + 1, // 11 + }, + false + ); +}); + +// Delete previous notification +add_test(function test_delete_previous() { + let requestID = 15; + let msgReply = "Notification:Delete:Return:OK"; + let msgHandler = function (message) { + Assert.equal(requestID, message.data.requestID); + }; + + addAndSend("Notification:Delete", msgReply, msgHandler, { + origin: systemNotification.origin, + id: "{8ef9a628-f0f4-44b4-820d-c117573c33e3}", + requestID, + }); +}); + +// Store two notifications from two origins with the same tag +add_test(function test_send_two_get_two() { + let requestID = 20; + let tag = "voicemail"; + + let systemNotification1 = systemNotification; + systemNotification1.tag = tag; + + let calendarNotification2 = calendarNotification; + calendarNotification2.tag = tag; + + let msgGetReply = "Notification:GetAll:Return:OK"; + let msgGetCalls = 0; + let msgGetHandler = { + receiveMessage(message) { + if (message.name === msgGetReply) { + msgGetCalls++; + let notifications = message.data.notifications; + + // one notification per origin + Assert.equal(1, notifications.length); + + // first call should be system notification + if (msgGetCalls === 1) { + compareNotification(systemNotification1, notifications[0]); + } + + // second and last call should be calendar notification + if (msgGetCalls === 2) { + Services.cpmm.removeMessageListener(msgGetReply, msgGetHandler); + compareNotification(calendarNotification2, notifications[0]); + run_next_test(); + } + } + }, + }; + Services.cpmm.addMessageListener(msgGetReply, msgGetHandler); + + let msgSaveReply = "Notification:Save:Return:OK"; + let msgSaveCalls = 0; + let msgSaveHandler = { + receiveMessage(message) { + if (message.name === msgSaveReply) { + msgSaveCalls++; + if (msgSaveCalls === 2) { + Services.cpmm.removeMessageListener(msgSaveReply, msgSaveHandler); + + // Trigger getall for each origin + Services.cpmm.sendAsyncMessage("Notification:GetAll", { + origin: systemNotification1.origin, + requestID: message.data.requestID + 1, // 22 + }); + + Services.cpmm.sendAsyncMessage("Notification:GetAll", { + origin: calendarNotification2.origin, + requestID: message.data.requestID + 2, // 23 + }); + } + } + }, + }; + Services.cpmm.addMessageListener(msgSaveReply, msgSaveHandler); + + Services.cpmm.sendAsyncMessage("Notification:Save", { + origin: systemNotification1.origin, + notification: systemNotification1, + requestID, // 20 + }); + + Services.cpmm.sendAsyncMessage("Notification:Save", { + origin: calendarNotification2.origin, + notification: calendarNotification2, + requestID: requestID + 1, // 21 + }); +}); + +// Cleanup previous notification +add_test(function test_delete_previous() { + let requestID = 25; + let msgReply = "Notification:Delete:Return:OK"; + let msgHandler = function (message) { + Assert.equal(requestID, message.data.requestID); + }; + + addAndSend("Notification:Delete", msgReply, msgHandler, { + origin: systemNotification.origin, + id: "{2bc883bf-2809-4432-b0f4-f54e10372764}", + requestID, + }); +}); diff --git a/dom/notification/test/unit/test_notificationdb_bug1024090.js b/dom/notification/test/unit/test_notificationdb_bug1024090.js new file mode 100644 index 0000000000..37dee0a639 --- /dev/null +++ b/dom/notification/test/unit/test_notificationdb_bug1024090.js @@ -0,0 +1,59 @@ +"use strict"; + +function run_test() { + do_get_profile(); + run_next_test(); +} + +// For bug 1024090: test edge case of notificationstore.json +add_test(function test_bug1024090_purge() { + const NOTIFICATION_STORE_PATH = PathUtils.join( + PathUtils.profileDir, + "notificationstore" + ); + let cleanup = IOUtils.remove(NOTIFICATION_STORE_PATH, { recursive: true }); + cleanup + .then( + function onSuccess() { + ok(true, "Notification database cleaned."); + }, + function onError(reason) { + ok(false, "Notification database error when cleaning: " + reason); + } + ) + .then(function next() { + info("Cleanup steps completed: " + NOTIFICATION_STORE_PATH); + startNotificationDB(); + run_next_test(); + }); +}); + +// Store one notification +add_test(function test_bug1024090_send_one() { + let requestID = 1; + let msgReply = "Notification:Save:Return:OK"; + let msgHandler = function (message) { + equal(requestID, message.data.requestID, "Checking requestID"); + }; + + addAndSend("Notification:Save", msgReply, msgHandler, { + origin: systemNotification.origin, + notification: systemNotification, + requestID, + }); +}); + +// Get one notification, one exists +add_test(function test_bug1024090_get_one() { + let requestID = 2; + let msgReply = "Notification:GetAll:Return:OK"; + let msgHandler = function (message) { + equal(requestID, message.data.requestID, "Checking requestID"); + equal(1, message.data.notifications.length, "One notification stored"); + }; + + addAndSend("Notification:GetAll", msgReply, msgHandler, { + origin: systemNotification.origin, + requestID, + }); +}); diff --git a/dom/notification/test/unit/test_notificationdb_migration.js b/dom/notification/test/unit/test_notificationdb_migration.js new file mode 100644 index 0000000000..2b38e8d43a --- /dev/null +++ b/dom/notification/test/unit/test_notificationdb_migration.js @@ -0,0 +1,129 @@ +"use strict"; + +const { AppConstants } = ChromeUtils.importESModule( + "resource://gre/modules/AppConstants.sys.mjs" +); + +const fooNotification = getNotificationObject( + "foo", + "a4f1d54a-98b7-4231-9120-5afc26545bad", + null, + true +); +const barNotification = getNotificationObject( + "bar", + "a4f1d54a-98b7-4231-9120-5afc26545bad", + "baz", + true +); +const msg = "Notification:GetAll"; +const msgReply = "Notification:GetAll:Return:OK"; + +do_get_profile(); +const OLD_STORE_PATH = PathUtils.join( + PathUtils.profileDir, + "notificationstore.json" +); + +let nextRequestID = 0; + +// Create the old datastore and populate it with data before we initialize +// the notification database so it has data to migrate. This is a setup step, +// not a test, but it seems like we need to do it in a test function +// rather than in run_test() because the test runner doesn't handle async steps +// in run_test(). +add_task( + { + skip_if: () => !AppConstants.MOZ_NEW_NOTIFICATION_STORE, + }, + async function test_create_old_datastore() { + const notifications = { + [fooNotification.origin]: { + [fooNotification.id]: fooNotification, + }, + [barNotification.origin]: { + [barNotification.id]: barNotification, + }, + }; + + await IOUtils.writeJSON(OLD_STORE_PATH, notifications); + + startNotificationDB(); + } +); + +add_test( + { + skip_if: () => !AppConstants.MOZ_NEW_NOTIFICATION_STORE, + }, + function test_get_system_notification() { + const requestID = nextRequestID++; + const msgHandler = function (message) { + Assert.equal(requestID, message.data.requestID); + Assert.equal(0, message.data.notifications.length); + }; + + addAndSend(msg, msgReply, msgHandler, { + origin: systemNotification.origin, + requestID, + }); + } +); + +add_test( + { + skip_if: () => !AppConstants.MOZ_NEW_NOTIFICATION_STORE, + }, + function test_get_foo_notification() { + const requestID = nextRequestID++; + const msgHandler = function (message) { + Assert.equal(requestID, message.data.requestID); + Assert.equal(1, message.data.notifications.length); + Assert.deepEqual( + fooNotification, + message.data.notifications[0], + "Notification data migrated" + ); + }; + + addAndSend(msg, msgReply, msgHandler, { + origin: fooNotification.origin, + requestID, + }); + } +); + +add_test( + { + skip_if: () => !AppConstants.MOZ_NEW_NOTIFICATION_STORE, + }, + function test_get_bar_notification() { + const requestID = nextRequestID++; + const msgHandler = function (message) { + Assert.equal(requestID, message.data.requestID); + Assert.equal(1, message.data.notifications.length); + Assert.deepEqual( + barNotification, + message.data.notifications[0], + "Notification data migrated" + ); + }; + + addAndSend(msg, msgReply, msgHandler, { + origin: barNotification.origin, + requestID, + }); + } +); + +add_task( + { + skip_if: () => !AppConstants.MOZ_NEW_NOTIFICATION_STORE, + }, + async function test_old_datastore_deleted() { + Assert.ok( + !(await IOUtils.exists(OLD_STORE_PATH)), + "old datastore no longer exists" + ); + } +); diff --git a/dom/notification/test/unit/xpcshell.toml b/dom/notification/test/unit/xpcshell.toml new file mode 100644 index 0000000000..178af95b1f --- /dev/null +++ b/dom/notification/test/unit/xpcshell.toml @@ -0,0 +1,9 @@ +[DEFAULT] +head = "head_notificationdb.js" +skip-if = ["os == 'android'"] + +["test_notificationdb.js"] + +["test_notificationdb_bug1024090.js"] + +["test_notificationdb_migration.js"] |