Skip to content

Commit e4e3f08

Browse files
Add toast notification infrastructure (#20010)
## Summary of the Pull Request Adds the infrastructure for toast notifications. Breakdown: - `DesktopNotification`: - `DesktopNotificationArgs` includes the struct to group all notification-related data together. - `SendNotification()` actually sends it - `AppCommandlineArgs.cpp`: added a check for the `--from-toast` no-op sentinel; ensures no new window is created - Most of the other changes are just bubbling up the notification from the `TerminalPaneContent` to `TerminalPage` - `TabManagement.cpp`: `_SendDesktopNotification()` does the final packaging of the notification before calling the `DesktopNotification` API This supports finding the right tab when it's been reordered or even moved to a new window! This also has expanded to support finding the right pane, which is resilient to pane swaps/closing too. When the pane can't be found, we just fallback to the tab. If the pane is already focused, we don't send a notification. This simply adds the infrastructure! Looks like nothing can actually take advantage of it yet, but it's been tested with the changes in #20011. Heavily based on #19935 Co-authored by @zadjii-msft
1 parent 84e807c commit e4e3f08

21 files changed

Lines changed: 418 additions & 1 deletion

.github/actions/spelling/expect/expect.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1104,6 +1104,7 @@ NOSIZE
11041104
NOSNAPSHOT
11051105
NOTHOUSANDS
11061106
NOTICKS
1107+
notif
11071108
NOTIMEOUTIFNOTHUNG
11081109
NOTIMPL
11091110
NOTOPMOST

src/cascadia/TerminalApp/AppCommandlineArgs.cpp

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1068,6 +1068,15 @@ int AppCommandlineArgs::ParseArgs(winrt::array_view<const winrt::hstring> args)
10681068
return 0;
10691069
}
10701070

1071+
// When a toast notification is clicked, Windows may launch a new instance
1072+
// with "--from-toast" as the argument. This is a no-op sentinel — the
1073+
// in-process Activated handler on the toast already handled activation.
1074+
// See DesktopNotification.cpp for more details.
1075+
if (args.size() == 2 && args[1] == L"--from-toast")
1076+
{
1077+
return 0;
1078+
}
1079+
10711080
auto commands = ::TerminalApp::AppCommandlineArgs::BuildCommands(args);
10721081

10731082
for (auto& cmdBlob : commands)

src/cascadia/TerminalApp/BasicPaneEvents.h

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ namespace winrt::TerminalApp::implementation
1515
til::typed_event<IPaneContent> TaskbarProgressChanged;
1616
til::typed_event<IPaneContent> ReadOnlyChanged;
1717
til::typed_event<IPaneContent> FocusRequested;
18+
til::typed_event<IPaneContent, winrt::TerminalApp::NotificationEventArgs> NotificationRequested;
1819

1920
til::typed_event<winrt::Windows::Foundation::IInspectable, Microsoft::Terminal::Settings::Model::Command> DispatchCommandRequested;
2021
};
Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Copyright (c) Microsoft Corporation.
2+
// Licensed under the MIT license.
3+
4+
#include "pch.h"
5+
#include "DesktopNotification.h"
6+
7+
#include <WtExeUtils.h>
8+
9+
using namespace winrt::Windows::UI::Notifications;
10+
using namespace winrt::Windows::Data::Xml::Dom;
11+
12+
namespace winrt::TerminalApp::implementation
13+
{
14+
std::atomic<uint64_t> DesktopNotification::_lastNotificationTime{ 0 };
15+
16+
// Method Description:
17+
// - Rate-limits toast notifications so we don't spam the user.
18+
// Return Value:
19+
// - Returns true if a notification is allowed, false if too recent.
20+
bool DesktopNotification::ShouldSendNotification()
21+
{
22+
const auto now = GetTickCount64();
23+
auto last = _lastNotificationTime.load(std::memory_order_relaxed);
24+
25+
// Subtraction wraps cleanly modulo 2^64, so the delta is correct even
26+
// across the (~584 million year) GetTickCount64 rollover.
27+
if (now - last < MinNotificationIntervalMs)
28+
{
29+
return false;
30+
}
31+
32+
// Attempt to update; if another thread beat us, that's fine — we'll skip this one.
33+
return _lastNotificationTime.compare_exchange_strong(last, now, std::memory_order_relaxed);
34+
}
35+
36+
// Method Description:
37+
// - Sends a toast notification with the given title and message.
38+
// - When the user clicks the toast, the `Activated` callback fires
39+
// with the tabIndex that was passed in, so the caller can switch
40+
// to the correct tab and summon the window.
41+
// Arguments:
42+
// - args: The title, message, and tab index to include in the notification.
43+
// - activated: A callback invoked on the background thread when the
44+
// toast is clicked. The uint32_t parameter is the tab index.
45+
void DesktopNotification::SendNotification(const DesktopNotificationArgs& args, std::function<void()> activatedFunc)
46+
{
47+
try
48+
{
49+
if (!ShouldSendNotification())
50+
{
51+
return;
52+
}
53+
54+
// Build the toast XML. We use a simple template with a title and body text.
55+
//
56+
// <toast launch="--from-toast">
57+
// <visual>
58+
// <binding template="ToastGeneric">
59+
// <text>Title</text>
60+
// <text>Message</text>
61+
// </binding>
62+
// </visual>
63+
// </toast>
64+
auto toastXml = ToastNotificationManager::GetTemplateContent(ToastTemplateType::ToastText02);
65+
auto textNodes = toastXml.GetElementsByTagName(L"text");
66+
67+
// First <text> is the title
68+
textNodes.Item(0).InnerText(args.Title);
69+
// Second <text> is the body
70+
textNodes.Item(1).InnerText(args.Message);
71+
72+
auto toastElement = toastXml.DocumentElement();
73+
74+
// When a toast is clicked, Windows launches a new instance of the app
75+
// with the "launch" attribute as command-line arguments. We handle
76+
// toast activation in-process via the Activated event below, so the
77+
// new instance should do nothing. "--from-toast" is recognized by
78+
// AppCommandlineArgs::ParseArgs as a no-op sentinel.
79+
toastElement.SetAttribute(L"launch", L"--from-toast");
80+
81+
toastElement.SetAttribute(L"scenario", L"default");
82+
83+
auto toast = ToastNotification{ toastXml };
84+
85+
// Set the tag and group to enable notification replacement.
86+
// Repeated notifications with the same tag replace the previous one
87+
// rather than stacking in the notification center.
88+
toast.Tag(args.Tag);
89+
toast.Group(L"WindowsTerminal");
90+
91+
// When the user activates (clicks) the toast, fire the callback.
92+
if (activatedFunc)
93+
{
94+
toast.Activated([activatedFunc](const auto& /*sender*/, const auto& /*eventArgs*/) {
95+
activatedFunc();
96+
});
97+
}
98+
99+
// For packaged apps, CreateToastNotifier() uses the package identity automatically.
100+
// For unpackaged apps, we must pass the explicit AUMID that was registered
101+
// at startup via SetCurrentProcessExplicitAppUserModelID.
102+
winrt::Windows::UI::Notifications::ToastNotifier notifier{ nullptr };
103+
if (IsPackaged())
104+
{
105+
notifier = ToastNotificationManager::CreateToastNotifier();
106+
}
107+
else
108+
{
109+
// Retrieve the AUMID that was set by WindowEmperor at startup.
110+
wil::unique_cotaskmem_string aumid;
111+
if (SUCCEEDED(GetCurrentProcessExplicitAppUserModelID(&aumid)))
112+
{
113+
notifier = ToastNotificationManager::CreateToastNotifier(aumid.get());
114+
}
115+
}
116+
if (notifier)
117+
{
118+
notifier.Show(toast);
119+
}
120+
}
121+
catch (...)
122+
{
123+
// Toast notification is a best-effort feature. If it fails (e.g., notifications
124+
// are disabled, or the app is unpackaged without proper AUMID setup), we silently
125+
// ignore the error.
126+
LOG_CAUGHT_EXCEPTION();
127+
}
128+
}
129+
}
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
/*++
2+
Copyright (c) Microsoft Corporation
3+
Licensed under the MIT license.
4+
5+
Module Name:
6+
- DesktopNotification.h
7+
8+
Module Description:
9+
- Helper for sending Windows desktop toast notifications. Used to surface
10+
terminal activity events to the user via the Windows notification center.
11+
--*/
12+
13+
#pragma once
14+
#include "pch.h"
15+
16+
namespace winrt::TerminalApp::implementation
17+
{
18+
struct DesktopNotificationArgs
19+
{
20+
winrt::hstring Title;
21+
winrt::hstring Message;
22+
winrt::hstring Tag;
23+
};
24+
25+
class DesktopNotification
26+
{
27+
public:
28+
static bool ShouldSendNotification();
29+
static void SendNotification(const DesktopNotificationArgs& args, std::function<void()> activatedFunc);
30+
31+
private:
32+
static std::atomic<uint64_t> _lastNotificationTime;
33+
34+
// Minimum interval between notifications, in milliseconds (GetTickCount64 units).
35+
static constexpr uint64_t MinNotificationIntervalMs = 5'000;
36+
};
37+
}

src/cascadia/TerminalApp/IPaneContent.idl

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,12 @@ namespace TerminalApp
1616
Boolean FlashTaskbar { get; };
1717
};
1818

19+
runtimeclass NotificationEventArgs
20+
{
21+
String Title { get; };
22+
String Body { get; };
23+
};
24+
1925
interface IPaneContent
2026
{
2127
Windows.UI.Xaml.FrameworkElement GetRoot();
@@ -46,6 +52,7 @@ namespace TerminalApp
4652
event Windows.Foundation.TypedEventHandler<IPaneContent, Object> TaskbarProgressChanged;
4753
event Windows.Foundation.TypedEventHandler<IPaneContent, Object> ReadOnlyChanged;
4854
event Windows.Foundation.TypedEventHandler<IPaneContent, Object> FocusRequested;
55+
event Windows.Foundation.TypedEventHandler<IPaneContent, NotificationEventArgs> NotificationRequested;
4956
};
5057

5158

src/cascadia/TerminalApp/Resources/en-US/Resources.resw

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -745,6 +745,14 @@
745745
<value>Windows</value>
746746
<comment>This is displayed as a label for the context menu item that holds the submenu of available windows.</comment>
747747
</data>
748+
<data name="NotificationMessage_TabActivity" xml:space="preserve">
749+
<value>Activity in tab "{0}"</value>
750+
<comment>{0} is the tab title. Shown as the body of a desktop notification when tab activity is detected.</comment>
751+
</data>
752+
<data name="NotificationMessage_TabActivityInWindow" xml:space="preserve">
753+
<value>Activity in tab "{0}" (window "{1}")</value>
754+
<comment>{0} is the tab title, {1} is the window name. Shown as the body of a desktop notification when tab activity is detected and the window has a name.</comment>
755+
</data>
748756
<data name="DropPathTabRun.Text" xml:space="preserve">
749757
<value>Open a new tab in given starting directory</value>
750758
</data>

src/cascadia/TerminalApp/Tab.cpp

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1166,6 +1166,18 @@ namespace winrt::TerminalApp::implementation
11661166
events.RestartTerminalRequested = terminal.RestartTerminalRequested(winrt::auto_revoke, { get_weak(), &Tab::_bubbleRestartTerminalRequested });
11671167
}
11681168

1169+
events.NotificationRequested = content.NotificationRequested(
1170+
winrt::auto_revoke,
1171+
[dispatcher, weakThis](TerminalApp::IPaneContent sender, auto notifArgs) -> safe_void_coroutine {
1172+
const auto weakThisCopy = weakThis;
1173+
co_await wil::resume_foreground(dispatcher);
1174+
if (const auto tab{ weakThisCopy.get() })
1175+
{
1176+
const auto title = notifArgs.Title().empty() ? tab->Title() : notifArgs.Title();
1177+
tab->TabToastNotificationRequested.raise(title, notifArgs.Body(), sender);
1178+
}
1179+
});
1180+
11691181
if (_tabStatus.IsInputBroadcastActive())
11701182
{
11711183
if (const auto& termContent{ content.try_as<TerminalApp::TerminalPaneContent>() })

src/cascadia/TerminalApp/Tab.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ namespace winrt::TerminalApp::implementation
121121

122122
til::typed_event<TerminalApp::Tab, IInspectable> ActivePaneChanged;
123123
til::event<winrt::delegate<>> TabRaiseVisualBell;
124+
til::event<winrt::delegate<winrt::hstring /*title*/, winrt::hstring /*body*/, winrt::TerminalApp::IPaneContent /*content*/>> TabToastNotificationRequested;
124125
til::typed_event<IInspectable, IInspectable> TaskbarProgressChanged;
125126

126127
// The TabViewIndex is the index this Tab object resides in TerminalPage's _tabs vector.
@@ -185,6 +186,7 @@ namespace winrt::TerminalApp::implementation
185186
winrt::TerminalApp::IPaneContent::ConnectionStateChanged_revoker ConnectionStateChanged;
186187
winrt::TerminalApp::IPaneContent::ReadOnlyChanged_revoker ReadOnlyChanged;
187188
winrt::TerminalApp::IPaneContent::FocusRequested_revoker FocusRequested;
189+
winrt::TerminalApp::IPaneContent::NotificationRequested_revoker NotificationRequested;
188190

189191
// These events literally only apply if the content is a TermControl.
190192
winrt::Microsoft::Terminal::Control::TermControl::KeySent_revoker KeySent;

0 commit comments

Comments
 (0)