/* -*- Mode: C++; tab-width: 4; indent-tabs-mode: nil; c-basic-offset: 2 -*- */ /* vim:set ts=2 sts=2 sw=2 et cin: */ /* 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 "ToastNotificationHandler.h" #include #include "gfxUtils.h" #include "gfxPlatform.h" #include "imgIContainer.h" #include "imgIRequest.h" #include "json/json.h" #include "mozilla/gfx/2D.h" #ifdef MOZ_BACKGROUNDTASKS # include "mozilla/BackgroundTasks.h" #endif #include "mozilla/HashFunctions.h" #include "mozilla/JSONStringWriteFuncs.h" #include "mozilla/Result.h" #include "mozilla/Logging.h" #include "mozilla/Tokenizer.h" #include "mozilla/Unused.h" #include "mozilla/WindowsVersion.h" #include "mozilla/intl/Localization.h" #include "nsAppDirectoryServiceDefs.h" #include "nsAppRunner.h" #include "nsDirectoryServiceDefs.h" #include "nsDirectoryServiceUtils.h" #include "nsIDUtils.h" #include "nsIStringBundle.h" #include "nsIToolkitProfile.h" #include "nsIToolkitProfileService.h" #include "nsIURI.h" #include "nsIWidget.h" #include "nsIWindowMediator.h" #include "nsNetUtil.h" #include "nsPIDOMWindow.h" #include "nsProxyRelease.h" #include "nsXREDirProvider.h" #include "ToastNotificationHeaderOnlyUtils.h" #include "WidgetUtils.h" #include "WinUtils.h" #include "ToastNotification.h" namespace mozilla { namespace widget { extern LazyLogModule sWASLog; using namespace ABI::Windows::Data::Xml::Dom; using namespace ABI::Windows::Foundation; using namespace ABI::Windows::UI::Notifications; using namespace Microsoft::WRL; using namespace Microsoft::WRL::Wrappers; using namespace toastnotification; // Needed to disambiguate internal and Windows `ToastNotification` classes. using WinToastNotification = ABI::Windows::UI::Notifications::ToastNotification; using ToastActivationHandler = ITypedEventHandler; using ToastDismissedHandler = ITypedEventHandler; using ToastFailedHandler = ITypedEventHandler; using IVectorView_ToastNotification = Collections::IVectorView; NS_IMPL_ISUPPORTS(ToastNotificationHandler, nsIAlertNotificationImageListener) static bool SetNodeValueString(const nsString& aString, IXmlNode* node, IXmlDocument* xml) { ComPtr inputText; HRESULT hr; hr = xml->CreateTextNode(HStringReference(aString.get()).Get(), &inputText); NS_ENSURE_TRUE(SUCCEEDED(hr), false); ComPtr inputTextNode; hr = inputText.As(&inputTextNode); NS_ENSURE_TRUE(SUCCEEDED(hr), false); ComPtr appendedChild; hr = node->AppendChild(inputTextNode.Get(), &appendedChild); NS_ENSURE_TRUE(SUCCEEDED(hr), false); return true; } static bool SetAttribute(ComPtr& element, const HStringReference& name, const nsAString& value) { HString valueStr; valueStr.Set(PromiseFlatString(value).get()); HRESULT hr = element->SetAttribute(name.Get(), valueStr.Get()); NS_ENSURE_TRUE(SUCCEEDED(hr), false); return true; } static bool AddActionNode(ComPtr& toastXml, ComPtr& actionsNode, const nsAString& actionTitle, const nsAString& launchArg, const nsAString& actionArgs, const nsAString& actionPlacement = u""_ns, const nsAString& activationType = u""_ns) { ComPtr action; HRESULT hr = toastXml->CreateElement(HStringReference(L"action").Get(), &action); NS_ENSURE_TRUE(SUCCEEDED(hr), false); bool success = SetAttribute(action, HStringReference(L"content"), actionTitle); NS_ENSURE_TRUE(success, false); // Action arguments overwrite the toast's launch arguments, so we need to // prepend the launch arguments necessary for the Notification Server to // reconstruct the toast's origin. // // Web Notification actions are arbitrary strings; to prevent breaking launch // argument parsing the action argument must be last. All delimiters after // `action` are part of the action arugment. nsAutoString args = launchArg + u"\n"_ns + nsDependentString(kLaunchArgAction) + u"\n"_ns + actionArgs; success = SetAttribute(action, HStringReference(L"arguments"), args); NS_ENSURE_TRUE(success, false); if (!actionPlacement.IsEmpty()) { success = SetAttribute(action, HStringReference(L"placement"), actionPlacement); NS_ENSURE_TRUE(success, false); } if (!activationType.IsEmpty()) { success = SetAttribute(action, HStringReference(L"activationType"), activationType); NS_ENSURE_TRUE(success, false); // No special argument handling: when `activationType="system"`, `arguments` // should be a Windows-specific keyword, namely "dismiss" or "snooze", which // are supposed to make a system handled dismiss/snooze buttons. // https://learn.microsoft.com/en-us/windows/apps/design/shell/tiles-and-notifications/adaptive-interactive-toasts?tabs=xml#snoozedismiss // // Note that while using it prevents calling our notification COM server, // it somehow still calls OnActivate instead of OnDismiss. Thus, we still // need to handle such callbacks manually by checking `arguments`. success = SetAttribute(action, HStringReference(L"arguments"), actionArgs); NS_ENSURE_TRUE(success, false); } // Add to ComPtr actionNode; hr = action.As(&actionNode); NS_ENSURE_TRUE(SUCCEEDED(hr), false); ComPtr appendedChild; hr = actionsNode->AppendChild(actionNode.Get(), &appendedChild); NS_ENSURE_TRUE(SUCCEEDED(hr), false); return true; } nsresult ToastNotificationHandler::GetWindowsTag(nsAString& aWindowsTag) { aWindowsTag.Assign(mWindowsTag); return NS_OK; } nsresult ToastNotificationHandler::SetWindowsTag(const nsAString& aWindowsTag) { mWindowsTag.Assign(aWindowsTag); return NS_OK; } // clang - format off /* Populate the launch argument so the COM server can reconstruct the toast * origin. * * program * {MOZ_APP_NAME} * profile * {path to profile} */ // clang-format on Result ToastNotificationHandler::GetLaunchArgument() { nsString launchArg; // When the preference is false, the COM notification server will be invoked, // discover that there is no `program`, and exit (successfully), after which // Windows will invoke the in-product Windows 8-style callbacks. When true, // the COM notification server will launch Firefox with sufficient arguments // for Firefox to handle the notification. if (!Preferences::GetBool( "alerts.useSystemBackend.windows.notificationserver.enabled", false)) { // Include dummy key/value so that newline appended arguments aren't off by // one line. launchArg += u"invalid key\ninvalid value"_ns; return launchArg; } // `program` argument. launchArg += nsDependentString(kLaunchArgProgram) + u"\n"_ns MOZ_APP_NAME; // `profile` argument. nsCOMPtr profDir; bool wantCurrentProfile = true; #ifdef MOZ_BACKGROUNDTASKS if (BackgroundTasks::IsBackgroundTaskMode()) { // Notifications popped from a background task want to invoke Firefox with a // different profile -- the default browsing profile. We'd prefer to not // specify a profile, so that the Firefox invoked by the notification server // chooses its default profile, but this might pop the profile chooser in // some configurations. wantCurrentProfile = false; nsCOMPtr profileSvc = do_GetService(NS_PROFILESERVICE_CONTRACTID); if (profileSvc) { nsCOMPtr defaultProfile; nsresult rv = profileSvc->GetDefaultProfile(getter_AddRefs(defaultProfile)); if (NS_SUCCEEDED(rv) && defaultProfile) { // Not all installations have a default profile. But if one is set, // then it should have a profile directory. MOZ_TRY(defaultProfile->GetRootDir(getter_AddRefs(profDir))); } } } #endif if (wantCurrentProfile) { MOZ_TRY(NS_GetSpecialDirectory(NS_APP_USER_PROFILE_50_DIR, getter_AddRefs(profDir))); } if (profDir) { nsAutoString profilePath; MOZ_TRY(profDir->GetPath(profilePath)); launchArg += u"\n"_ns + nsDependentString(kLaunchArgProfile) + u"\n"_ns + profilePath; } // `windowsTag` argument. launchArg += u"\n"_ns + nsDependentString(kLaunchArgTag) + u"\n"_ns + mWindowsTag; // `logging` argument. if (Preferences::GetBool( "alerts.useSystemBackend.windows.notificationserver.verbose", false)) { // Signal notification to log verbose messages. launchArg += u"\n"_ns + nsDependentString(kLaunchArgLogging) + u"\nverbose"_ns; } return launchArg; } static ComPtr GetToastNotificationManagerStatics() { ComPtr toastNotificationManagerStatics; HRESULT hr = GetActivationFactory( HStringReference( RuntimeClass_Windows_UI_Notifications_ToastNotificationManager) .Get(), &toastNotificationManagerStatics); NS_ENSURE_TRUE(SUCCEEDED(hr), nullptr); return toastNotificationManagerStatics; } ToastNotificationHandler::~ToastNotificationHandler() { if (mImageRequest) { mImageRequest->Cancel(NS_BINDING_ABORTED); mImageRequest = nullptr; } if (mHasImage && mImageFile) { DebugOnly rv = mImageFile->Remove(false); NS_ASSERTION(NS_SUCCEEDED(rv), "Cannot remove temporary image file"); } UnregisterHandler(); } void ToastNotificationHandler::UnregisterHandler() { if (mNotification) { mNotification->remove_Dismissed(mDismissedToken); mNotification->remove_Activated(mActivatedToken); mNotification->remove_Failed(mFailedToken); } mNotification = nullptr; mNotifier = nullptr; SendFinished(); } nsresult ToastNotificationHandler::InitAlertAsync( nsIAlertNotification* aAlert) { MOZ_TRY(InitWindowsTag()); #ifdef MOZ_BACKGROUNDTASKS nsAutoString imageUrl; if (BackgroundTasks::IsBackgroundTaskMode() && NS_SUCCEEDED(aAlert->GetImageURL(imageUrl)) && !imageUrl.IsEmpty()) { // Bug 1870750: Image decoding relies on gfx and runs on a thread pool, // which expects to have been initialized early and on the main thread. // Since background tasks run headless this never occurs. In this case we // force gfx initialization. Unused << NS_WARN_IF(!gfxPlatform::GetPlatform()); } #endif return aAlert->LoadImage(/* aTimeout = */ 0, this, /* aUserData = */ nullptr, getter_AddRefs(mImageRequest)); } // Uniquely identify this toast to Windows. Existing names and cookies are not // suitable: we want something generated and unique. This is needed to check if // toast is still present in the Windows Action Center when we receive a dismiss // timeout. // // Local testing reveals that the space of tags is not global but instead is per // AUMID. Since an installation uses a unique AUMID incorporating the install // directory hash, it should not witness another installation's tag. nsresult ToastNotificationHandler::InitWindowsTag() { mWindowsTag.Truncate(); nsAutoString tag; // Multiple profiles might overwrite each other's toast messages when a // common name is used for a given host port. We prevent this by including // the profile directory as part of the toast hash. nsCOMPtr profDir; MOZ_TRY(NS_GetSpecialDirectory(NS_APP_USER_PROFILE_50_DIR, getter_AddRefs(profDir))); MOZ_TRY(profDir->GetPath(tag)); if (!mHostPort.IsEmpty()) { // Notification originated from a web notification. // `mName` will be in the form `{mHostPort}#tag:{tag}` if the notification // was created with a tag and `{mHostPort}#notag:{uuid}` otherwise. tag += mName; } else { // Notification originated from the browser chrome. if (!mName.IsEmpty()) { tag += u"chrome#tag:"_ns; // Browser chrome notifications don't follow any convention for naming. tag += mName; } else { // No associated name, append a UUID to prevent reuse of the same tag. nsIDToCString uuidString(nsID::GenerateUUID()); size_t len = strlen(uuidString.get()); MOZ_ASSERT(len == NSID_LENGTH - 1); nsAutoString uuid; CopyASCIItoUTF16(nsDependentCSubstring(uuidString.get(), len), uuid); tag += u"chrome#notag:"_ns; tag += uuid; } } // Windows notification tags are limited to 16 characters, or 64 characters // after the Creators Update; therefore we hash the tag to fit the minimum // range. HashNumber hash = HashString(tag); mWindowsTag.AppendPrintf("%010u", hash); return NS_OK; } nsString ToastNotificationHandler::ActionArgsJSONString( const nsString& aAction, const nsString& aOpaqueRelaunchData = u""_ns) { nsAutoCString actionArgsData; JSONStringRefWriteFunc js(actionArgsData); JSONWriter w(js, JSONWriter::SingleLineStyle); w.Start(); w.StringProperty("action", NS_ConvertUTF16toUTF8(aAction)); if (mIsSystemPrincipal) { // Privileged/chrome alerts (not activated by Windows) can have custom // relaunch data. if (!aOpaqueRelaunchData.IsEmpty()) { w.StringProperty("opaqueRelaunchData", NS_ConvertUTF16toUTF8(aOpaqueRelaunchData)); } // Privileged alerts include any provided name for metrics. if (!mName.IsEmpty()) { w.StringProperty("privilegedName", NS_ConvertUTF16toUTF8(mName)); } } else { if (!mHostPort.IsEmpty()) { w.StringProperty("launchUrl", NS_ConvertUTF16toUTF8(mHostPort)); } } w.End(); return NS_ConvertUTF8toUTF16(actionArgsData); } ComPtr ToastNotificationHandler::CreateToastXmlDocument() { ComPtr toastNotificationManagerStatics = GetToastNotificationManagerStatics(); NS_ENSURE_TRUE(toastNotificationManagerStatics, nullptr); ToastTemplateType toastTemplate; if (mHostPort.IsEmpty()) { toastTemplate = mHasImage ? ToastTemplateType::ToastTemplateType_ToastImageAndText03 : ToastTemplateType::ToastTemplateType_ToastText03; } else { toastTemplate = mHasImage ? ToastTemplateType::ToastTemplateType_ToastImageAndText04 : ToastTemplateType::ToastTemplateType_ToastText04; } ComPtr toastXml; toastNotificationManagerStatics->GetTemplateContent(toastTemplate, &toastXml); if (!toastXml) { return nullptr; } nsresult ns; HRESULT hr; bool success; if (mHasImage) { ComPtr toastImageElements; hr = toastXml->GetElementsByTagName(HStringReference(L"image").Get(), &toastImageElements); NS_ENSURE_TRUE(SUCCEEDED(hr), nullptr); ComPtr imageNode; hr = toastImageElements->Item(0, &imageNode); NS_ENSURE_TRUE(SUCCEEDED(hr), nullptr); ComPtr image; hr = imageNode.As(&image); NS_ENSURE_TRUE(SUCCEEDED(hr), nullptr); success = SetAttribute(image, HStringReference(L"src"), mImageUri); NS_ENSURE_TRUE(success, nullptr); switch (mImagePlacement) { case ImagePlacement::eHero: success = SetAttribute(image, HStringReference(L"placement"), u"hero"_ns); NS_ENSURE_TRUE(success, nullptr); break; case ImagePlacement::eIcon: success = SetAttribute(image, HStringReference(L"placement"), u"appLogoOverride"_ns); NS_ENSURE_TRUE(success, nullptr); break; case ImagePlacement::eInline: // No attribute placement attribute for inline images. break; } } ComPtr toastTextElements; hr = toastXml->GetElementsByTagName(HStringReference(L"text").Get(), &toastTextElements); NS_ENSURE_TRUE(SUCCEEDED(hr), nullptr); ComPtr titleTextNodeRoot; hr = toastTextElements->Item(0, &titleTextNodeRoot); NS_ENSURE_TRUE(SUCCEEDED(hr), nullptr); ComPtr msgTextNodeRoot; hr = toastTextElements->Item(1, &msgTextNodeRoot); NS_ENSURE_TRUE(SUCCEEDED(hr), nullptr); success = SetNodeValueString(mTitle, titleTextNodeRoot.Get(), toastXml.Get()); NS_ENSURE_TRUE(success, nullptr); success = SetNodeValueString(mMsg, msgTextNodeRoot.Get(), toastXml.Get()); NS_ENSURE_TRUE(success, nullptr); ComPtr toastElements; hr = toastXml->GetElementsByTagName(HStringReference(L"toast").Get(), &toastElements); NS_ENSURE_TRUE(SUCCEEDED(hr), nullptr); ComPtr toastNodeRoot; hr = toastElements->Item(0, &toastNodeRoot); NS_ENSURE_TRUE(SUCCEEDED(hr), nullptr); ComPtr toastElement; hr = toastNodeRoot.As(&toastElement); NS_ENSURE_TRUE(SUCCEEDED(hr), nullptr); if (mRequireInteraction) { success = SetAttribute(toastElement, HStringReference(L"scenario"), u"reminder"_ns); NS_ENSURE_TRUE(success, nullptr); } auto maybeLaunchArg = GetLaunchArgument(); NS_ENSURE_TRUE(maybeLaunchArg.isOk(), nullptr); nsString launchArg = maybeLaunchArg.unwrap(); nsString launchArgWithoutAction = launchArg; if (!mIsSystemPrincipal) { // Unprivileged/content alerts can't have custom relaunch data. NS_WARNING_ASSERTION(mOpaqueRelaunchData.IsEmpty(), "unprivileged/content alert " "should have trivial `mOpaqueRelaunchData`"); } launchArg += u"\n"_ns + nsDependentString(kLaunchArgAction) + u"\n"_ns + ActionArgsJSONString(u""_ns, mOpaqueRelaunchData); success = SetAttribute(toastElement, HStringReference(L"launch"), launchArg); NS_ENSURE_TRUE(success, nullptr); MOZ_LOG(sWASLog, LogLevel::Debug, ("launchArg: '%s'", NS_ConvertUTF16toUTF8(launchArg).get())); // Use newer toast layout for system (chrome-privileged) toasts. This gains us // UI elements such as new image placement options (default image placement is // larger and inline) and buttons. if (mIsSystemPrincipal) { ComPtr bindingElements; hr = toastXml->GetElementsByTagName(HStringReference(L"binding").Get(), &bindingElements); NS_ENSURE_TRUE(SUCCEEDED(hr), nullptr); ComPtr bindingNodeRoot; hr = bindingElements->Item(0, &bindingNodeRoot); NS_ENSURE_TRUE(SUCCEEDED(hr), nullptr); ComPtr bindingElement; hr = bindingNodeRoot.As(&bindingElement); NS_ENSURE_TRUE(SUCCEEDED(hr), nullptr); success = SetAttribute(bindingElement, HStringReference(L"template"), u"ToastGeneric"_ns); NS_ENSURE_TRUE(success, nullptr); } ComPtr actions; hr = toastXml->CreateElement(HStringReference(L"actions").Get(), &actions); NS_ENSURE_TRUE(SUCCEEDED(hr), nullptr); ComPtr actionsNode; hr = actions.As(&actionsNode); NS_ENSURE_TRUE(SUCCEEDED(hr), nullptr); nsCOMPtr sbs = do_GetService(NS_STRINGBUNDLE_CONTRACTID); NS_ENSURE_TRUE(sbs, nullptr); nsCOMPtr bundle; sbs->CreateBundle("chrome://alerts/locale/alert.properties", getter_AddRefs(bundle)); NS_ENSURE_TRUE(bundle, nullptr); if (!mHostPort.IsEmpty()) { AutoTArray formatStrings = {mHostPort}; ComPtr urlTextNodeRoot; hr = toastTextElements->Item(2, &urlTextNodeRoot); NS_ENSURE_TRUE(SUCCEEDED(hr), nullptr); nsAutoString urlReference; bundle->FormatStringFromName("source.label", formatStrings, urlReference); success = SetNodeValueString(urlReference, urlTextNodeRoot.Get(), toastXml.Get()); NS_ENSURE_TRUE(success, nullptr); if (IsWin10AnniversaryUpdateOrLater()) { ComPtr placementText; hr = urlTextNodeRoot.As(&placementText); if (SUCCEEDED(hr)) { // placement is supported on Windows 10 Anniversary Update or later SetAttribute(placementText, HStringReference(L"placement"), u"attribution"_ns); } } nsAutoString disableButtonTitle; ns = bundle->FormatStringFromName("webActions.disableForOrigin.label", formatStrings, disableButtonTitle); NS_ENSURE_SUCCESS(ns, nullptr); AddActionNode(toastXml, actionsNode, disableButtonTitle, // TODO: launch into `about:preferences`? launchArgWithoutAction, ActionArgsJSONString(u"snooze"_ns), u"contextmenu"_ns); } bool wantSettings = true; #ifdef MOZ_BACKGROUNDTASKS if (BackgroundTasks::IsBackgroundTaskMode()) { // Notifications popped from a background task want to invoke Firefox with a // different profile -- the default browsing profile. Don't link to Firefox // settings in some different profile: the relevant Firefox settings won't // take effect. wantSettings = false; } #endif if (MOZ_LIKELY(wantSettings)) { nsAutoString settingsButtonTitle; bundle->GetStringFromName("webActions.settings.label", settingsButtonTitle); success = AddActionNode( toastXml, actionsNode, settingsButtonTitle, launchArgWithoutAction, // TODO: launch into `about:preferences`? ActionArgsJSONString(u"settings"_ns), u"contextmenu"_ns); NS_ENSURE_TRUE(success, nullptr); } for (const auto& action : mActions) { // Bug 1778596: include per-action icon from image URL. nsString title; ns = action->GetTitle(title); NS_ENSURE_SUCCESS(ns, nullptr); nsString actionString; ns = action->GetAction(actionString); NS_ENSURE_SUCCESS(ns, nullptr); nsString opaqueRelaunchData; ns = action->GetOpaqueRelaunchData(opaqueRelaunchData); NS_ENSURE_SUCCESS(ns, nullptr); MOZ_LOG(sWASLog, LogLevel::Debug, ("launchArgWithoutAction for '%s': '%s'", NS_ConvertUTF16toUTF8(actionString).get(), NS_ConvertUTF16toUTF8(launchArgWithoutAction).get())); // Privileged/chrome alerts can have actions that are activated by Windows. // Recognize these actions and enable these activations. bool activationType(false); ns = action->GetWindowsSystemActivationType(&activationType); NS_ENSURE_SUCCESS(ns, nullptr); nsString activationTypeString( (mIsSystemPrincipal && activationType) ? u"system"_ns : u""_ns); nsString actionArgs; if (mIsSystemPrincipal && activationType) { // Privileged/chrome alerts that are activated by Windows can't have // custom relaunch data. actionArgs = actionString; NS_WARNING_ASSERTION(opaqueRelaunchData.IsEmpty(), "action with `windowsSystemActivationType=true` " "should have trivial `opaqueRelaunchData`"); } else { actionArgs = ActionArgsJSONString(actionString, opaqueRelaunchData); } success = AddActionNode(toastXml, actionsNode, title, /* launchArg */ launchArgWithoutAction, /* actionArgs */ actionArgs, /* actionPlacement */ u""_ns, /* activationType */ activationTypeString); NS_ENSURE_TRUE(success, nullptr); } // Windows ignores scenario=reminder added by mRequiredInteraction if // there's no non-contextmenu action. if (mRequireInteraction && !mActions.Length()) { // `activationType="system" arguments="dismiss" content=""` provides // localized text from Windows, but we support more locales than Windows // does, so let's have our own. nsTArray resIds = { "toolkit/global/alert.ftl"_ns, }; RefPtr l10n = intl::Localization::Create(resIds, true); IgnoredErrorResult rv; nsAutoCString closeTitle; l10n->FormatValueSync("notification-default-dismiss"_ns, {}, closeTitle, rv); NS_ENSURE_TRUE(!rv.Failed(), nullptr); NS_ENSURE_TRUE( AddActionNode(toastXml, actionsNode, NS_ConvertUTF8toUTF16(closeTitle), u""_ns, u"dismiss"_ns, u""_ns, u"system"_ns), nullptr); } ComPtr appendedChild; hr = toastNodeRoot->AppendChild(actionsNode.Get(), &appendedChild); NS_ENSURE_TRUE(SUCCEEDED(hr), nullptr); if (mIsSilent) { ComPtr audioNode; // Create