Skip to content

Commit a24e3cd

Browse files
committed
Add support for inline images, attribution text, image crop hint, audio files, custom icon path in Windows
1 parent e1166c4 commit a24e3cd

7 files changed

Lines changed: 411 additions & 45 deletions

File tree

native/WinToastWrapper/WinToastWrapper.cpp

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020
#define WINTOASTWRAPPER_EXPORTS
2121
#define NOMINMAX
2222
#include <Windows.h>
23+
#include <shlobj.h> // SHGetFolderPathW, IShellLinkW, CLSID_ShellLink
24+
#include <objbase.h> // CoCreateInstance
25+
#include <strsafe.h> // StringCchCatW
2326
#include <string>
2427
#include <memory>
2528
#include <unordered_map>
@@ -195,7 +198,7 @@ NOTIFYAPI BOOL WNT_IsCompatible(void)
195198
return WinToast::isCompatible() ? TRUE : FALSE;
196199
}
197200

198-
NOTIFYAPI BOOL WNT_Initialize(const wchar_t* appName, const wchar_t* appUserModelId)
201+
NOTIFYAPI BOOL WNT_Initialize(const wchar_t* appName, const wchar_t* appUserModelId, const wchar_t* appIconPath)
199202
{
200203
if (!WinToast::isCompatible())
201204
return FALSE;
@@ -213,6 +216,41 @@ NOTIFYAPI BOOL WNT_Initialize(const wchar_t* appName, const wchar_t* appUserMode
213216
if (!instance->initialize(&error))
214217
return FALSE;
215218

219+
// If a custom app icon was requested, stamp it onto the Start-Menu shortcut
220+
// that WinToastLib just created/verified. This icon appears in the top-left
221+
// corner of every toast notification from this app.
222+
if (appIconPath && appIconPath[0] != L'\0')
223+
{
224+
// Build the same shortcut path WinToastLib uses: %APPDATA%\Microsoft\Windows\Start Menu\Programs\{appName}.lnk
225+
WCHAR linkPath[MAX_PATH] = {};
226+
if (SUCCEEDED(SHGetFolderPathW(NULL, CSIDL_APPDATA, NULL, 0, linkPath)))
227+
{
228+
if (SUCCEEDED(StringCchCatW(linkPath, MAX_PATH, L"\\Microsoft\\Windows\\Start Menu\\Programs\\")) &&
229+
SUCCEEDED(StringCchCatW(linkPath, MAX_PATH, appName)) &&
230+
SUCCEEDED(StringCchCatW(linkPath, MAX_PATH, L".lnk")))
231+
{
232+
IShellLinkW* shellLink = nullptr;
233+
if (SUCCEEDED(CoCreateInstance(CLSID_ShellLink, nullptr, CLSCTX_INPROC_SERVER,
234+
IID_IShellLinkW, reinterpret_cast<void**>(&shellLink))))
235+
{
236+
IPersistFile* persistFile = nullptr;
237+
if (SUCCEEDED(shellLink->QueryInterface(IID_IPersistFile,
238+
reinterpret_cast<void**>(&persistFile))))
239+
{
240+
if (SUCCEEDED(persistFile->Load(linkPath, STGM_READWRITE)))
241+
{
242+
shellLink->SetIconLocation(appIconPath, 0);
243+
persistFile->Save(linkPath, TRUE);
244+
}
245+
persistFile->Release();
246+
}
247+
shellLink->Release();
248+
}
249+
}
250+
}
251+
// Icon update is best-effort — do not fail initialization if it doesn't succeed.
252+
}
253+
216254
{
217255
std::lock_guard<std::mutex> lock(g_mutex);
218256
// Clear lookup table from a previous Initialize/Uninitialize cycle.
@@ -237,9 +275,10 @@ NOTIFYAPI INT64 WNT_ShowToast(
237275
if (!descriptor || !handler)
238276
return static_cast<INT64>(WinToast::WinToastError::InvalidParameters);
239277

240-
bool hasBody = descriptor->body != nullptr && descriptor->body[0] != L'\0';
241-
bool hasImage = descriptor->imagePath != nullptr && descriptor->imagePath[0] != L'\0';
242-
bool hasHeroImage = descriptor->heroImagePath != nullptr && descriptor->heroImagePath[0] != L'\0';
278+
bool hasBody = descriptor->body != nullptr && descriptor->body[0] != L'\0';
279+
bool hasImage = descriptor->imagePath != nullptr && descriptor->imagePath[0] != L'\0';
280+
bool hasHeroImage = descriptor->heroImagePath != nullptr && descriptor->heroImagePath[0] != L'\0';
281+
bool hasInlineImage = descriptor->inlineImagePath != nullptr && descriptor->inlineImagePath[0] != L'\0';
243282

244283
WinToastTemplate tmpl(SelectTemplateType(hasImage, hasBody));
245284

@@ -248,10 +287,21 @@ NOTIFYAPI INT64 WNT_ShowToast(
248287
tmpl.setTextField(descriptor->body, WinToastTemplate::SecondLine);
249288

250289
if (hasImage)
251-
tmpl.setImagePath(descriptor->imagePath);
290+
{
291+
WinToastTemplate::CropHint cropHint = (descriptor->cropHint == WNT_CROP_HINT_CIRCLE)
292+
? WinToastTemplate::CropHint::Circle
293+
: WinToastTemplate::CropHint::Square;
294+
tmpl.setImagePath(descriptor->imagePath, cropHint);
295+
}
296+
297+
// Inline image takes precedence over hero image; only one can be set at a time.
298+
if (hasInlineImage)
299+
tmpl.setHeroImagePath(descriptor->inlineImagePath, true /* inline */);
300+
else if (hasHeroImage)
301+
tmpl.setHeroImagePath(descriptor->heroImagePath, false /* banner */);
252302

253-
if (hasHeroImage)
254-
tmpl.setHeroImagePath(descriptor->heroImagePath);
303+
if (descriptor->attributionText != nullptr && descriptor->attributionText[0] != L'\0')
304+
tmpl.setAttributionText(descriptor->attributionText);
255305

256306
for (int i = 0; i < descriptor->buttonCount; ++i)
257307
{
@@ -262,6 +312,16 @@ NOTIFYAPI INT64 WNT_ShowToast(
262312
if (descriptor->expirationMs > 0)
263313
tmpl.setExpiration(descriptor->expirationMs);
264314

315+
// Audio: custom path overrides the system-sound enum; both are independent of AudioOption.
316+
if (descriptor->customAudioPath != nullptr && descriptor->customAudioPath[0] != L'\0')
317+
{
318+
tmpl.setAudioPath(descriptor->customAudioPath);
319+
}
320+
else if (descriptor->audioFile >= 0)
321+
{
322+
tmpl.setAudioPath(static_cast<WinToastTemplate::AudioSystemFile>(descriptor->audioFile));
323+
}
324+
265325
switch (descriptor->audioOption)
266326
{
267327
case WNT_AUDIO_SILENT:

native/WinToastWrapper/WinToastWrapper.h

Lines changed: 70 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -62,21 +62,79 @@ typedef enum _WNT_AudioOption {
6262
WNT_AUDIO_LOOP = 2
6363
} WNT_AudioOption;
6464

65+
/**
66+
* Selects which Windows system notification sound to play.
67+
* WNT_AUDIO_FILE_NONE (-1) means no specific sound file override (use AudioOption behaviour).
68+
* Values 0-25 map directly to WinToastTemplate::AudioSystemFile.
69+
*/
70+
typedef enum _WNT_AudioFile {
71+
WNT_AUDIO_FILE_NONE = -1,
72+
WNT_AUDIO_FILE_DEFAULT = 0,
73+
WNT_AUDIO_FILE_IM = 1,
74+
WNT_AUDIO_FILE_MAIL = 2,
75+
WNT_AUDIO_FILE_REMINDER = 3,
76+
WNT_AUDIO_FILE_SMS = 4,
77+
WNT_AUDIO_FILE_ALARM = 5,
78+
WNT_AUDIO_FILE_ALARM2 = 6,
79+
WNT_AUDIO_FILE_ALARM3 = 7,
80+
WNT_AUDIO_FILE_ALARM4 = 8,
81+
WNT_AUDIO_FILE_ALARM5 = 9,
82+
WNT_AUDIO_FILE_ALARM6 = 10,
83+
WNT_AUDIO_FILE_ALARM7 = 11,
84+
WNT_AUDIO_FILE_ALARM8 = 12,
85+
WNT_AUDIO_FILE_ALARM9 = 13,
86+
WNT_AUDIO_FILE_ALARM10 = 14,
87+
WNT_AUDIO_FILE_CALL = 15,
88+
WNT_AUDIO_FILE_CALL1 = 16,
89+
WNT_AUDIO_FILE_CALL2 = 17,
90+
WNT_AUDIO_FILE_CALL3 = 18,
91+
WNT_AUDIO_FILE_CALL4 = 19,
92+
WNT_AUDIO_FILE_CALL5 = 20,
93+
WNT_AUDIO_FILE_CALL6 = 21,
94+
WNT_AUDIO_FILE_CALL7 = 22,
95+
WNT_AUDIO_FILE_CALL8 = 23,
96+
WNT_AUDIO_FILE_CALL9 = 24,
97+
WNT_AUDIO_FILE_CALL10 = 25
98+
} WNT_AudioFile;
99+
100+
/** Controls how the app-logo image is cropped. */
101+
typedef enum _WNT_CropHint {
102+
WNT_CROP_HINT_SQUARE = 0,
103+
WNT_CROP_HINT_CIRCLE = 1
104+
} WNT_CropHint;
105+
65106
/**
66107
* Describes the notification to display.
67108
* All pointer fields may be NULL where noted.
68109
* Callers must keep pointed-to memory valid for the duration of WNT_ShowToast.
110+
*
111+
* Field layout (x64, no explicit packing):
112+
* offsets 0..39 — five pointers (title, body, imagePath, heroImagePath, buttonLabels)
113+
* offset 40 — buttonCount (int, 4 bytes) + 4 bytes natural padding
114+
* offset 48 — expirationMs (long long, 8 bytes)
115+
* offset 56 — scenario (int, 4 bytes)
116+
* offset 60 — audioOption (int, 4 bytes)
117+
* offsets 64..79 — three new pointers (inlineImagePath, attributionText, customAudioPath)
118+
* offset 88 — cropHint (int, 4 bytes)
119+
* offset 92 — audioFile (int, 4 bytes)
120+
* Total: 96 bytes
69121
*/
70122
typedef struct _WNT_ToastDescriptor {
71-
const wchar_t* title; /* required */
72-
const wchar_t* body; /* nullable */
73-
const wchar_t* imagePath; /* nullable — absolute path; displayed as a square thumbnail */
74-
const wchar_t* heroImagePath; /* nullable — absolute path; displayed full-width, aspect ratio preserved */
75-
const wchar_t** buttonLabels; /* nullable — array of buttonCount wchar_t* */
123+
const wchar_t* title; /* required */
124+
const wchar_t* body; /* nullable */
125+
const wchar_t* imagePath; /* nullable — absolute path; app logo override in generic templates */
126+
const wchar_t* heroImagePath; /* nullable — absolute path; full-width banner above the notification */
127+
const wchar_t** buttonLabels; /* nullable — array of buttonCount wchar_t* */
76128
int buttonCount;
77-
long long expirationMs; /* 0 = platform default */
129+
long long expirationMs; /* 0 = platform default */
78130
WNT_Scenario scenario;
79131
WNT_AudioOption audioOption;
132+
/* Extended fields (added in v2): */
133+
const wchar_t* inlineImagePath; /* nullable — image displayed inline inside the notification body */
134+
const wchar_t* attributionText; /* nullable — small text shown at the bottom of the notification */
135+
const wchar_t* customAudioPath; /* nullable — ms-winsoundevent: URI or ms-appx:/// path; overrides audioFile */
136+
int cropHint; /* WNT_CropHint — how imagePath is cropped (Square or Circle) */
137+
int audioFile; /* WNT_AudioFile — system sound to play; -1 = not overridden */
80138
} WNT_ToastDescriptor;
81139

82140
/**
@@ -102,9 +160,14 @@ typedef struct _WNT_Handler {
102160
* @param appUserModelId AppUserModelId (AUMI). The wrapper creates a Start-Menu
103161
* shortcut carrying this AUMI automatically if one does not
104162
* already exist.
163+
* @param appIconPath Optional absolute path to an .ico (or .exe/.dll) file whose
164+
* first icon is stamped onto the Start-Menu shortcut. This is
165+
* the small icon shown in the top-left corner of every toast
166+
* notification from this app. Pass NULL to use the default
167+
* (the host executable's icon).
105168
* @return TRUE on success.
106169
*/
107-
NOTIFYAPI BOOL WNT_Initialize(const wchar_t* appName, const wchar_t* appUserModelId);
170+
NOTIFYAPI BOOL WNT_Initialize(const wchar_t* appName, const wchar_t* appUserModelId, const wchar_t* appIconPath);
108171

109172
/**
110173
* Releases all WinToastLib resources. Call from the same STA thread as WNT_Initialize.

src/Notify.NET/Abstractions/NotificationRequest.cs

Lines changed: 125 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,15 +16,56 @@ public sealed class NotificationRequest
1616
/// <summary>Optional body text shown beneath the title.</summary>
1717
public string? Body { get; }
1818

19-
/// <summary>Absolute path to an image file displayed as a square thumbnail.</summary>
19+
/// <summary>
20+
/// Absolute path to an image used as the app logo override — the small icon shown
21+
/// alongside the notification content. In generic toast templates (i.e. when a hero
22+
/// or inline image is also present, or when <see cref="ImageCropHint"/> is
23+
/// <see cref="NotificationImageCropHint.Circle"/>) this image replaces the default
24+
/// app icon. Windows only — ignored on Linux and macOS.
25+
/// </summary>
2026
public string? ImagePath { get; }
2127

2228
/// <summary>
2329
/// Absolute path to an image file displayed full-width above the title, preserving aspect ratio.
30+
/// Mutually exclusive with <see cref="InlineImagePath"/> — if both are set, the inline image takes precedence.
2431
/// Windows only — ignored on Linux and macOS.
2532
/// </summary>
2633
public string? HeroImagePath { get; }
2734

35+
/// <summary>
36+
/// Absolute path to an image file displayed inline inside the notification body.
37+
/// Takes precedence over <see cref="HeroImagePath"/> when both are set.
38+
/// Windows only — ignored on Linux and macOS.
39+
/// </summary>
40+
public string? InlineImagePath { get; }
41+
42+
/// <summary>
43+
/// Small attribution text shown at the bottom of the notification (e.g. a source name).
44+
/// Windows only — ignored on Linux and macOS.
45+
/// </summary>
46+
public string? AttributionText { get; }
47+
48+
/// <summary>
49+
/// Controls how <see cref="ImagePath"/> is cropped. Defaults to <see cref="NotificationImageCropHint.Square"/>.
50+
/// Windows only — ignored on Linux and macOS.
51+
/// </summary>
52+
public NotificationImageCropHint ImageCropHint { get; }
53+
54+
/// <summary>
55+
/// A specific Windows system notification sound to play, independent of
56+
/// <see cref="Audio"/>. When set, overrides the default sound selection.
57+
/// Ignored if <see cref="CustomAudioPath"/> is also set.
58+
/// Windows only — ignored on Linux and macOS.
59+
/// </summary>
60+
public NotificationAudioFile? AudioFile { get; }
61+
62+
/// <summary>
63+
/// A custom audio URI (e.g. <c>ms-appx:///sounds/alert.mp3</c> or a
64+
/// <c>ms-winsoundevent:</c> URI). When set, takes precedence over <see cref="AudioFile"/>.
65+
/// Windows only — ignored on Linux and macOS.
66+
/// </summary>
67+
public string? CustomAudioPath { get; }
68+
2869
/// <summary>Action buttons to display. Maximum platform limits apply (typically 5 on Windows, varies on Linux).</summary>
2970
public IReadOnlyList<NotificationButton> Buttons { get; }
3071

@@ -45,6 +86,11 @@ internal NotificationRequest(
4586
string? body,
4687
string? imagePath,
4788
string? heroImagePath,
89+
string? inlineImagePath,
90+
string? attributionText,
91+
NotificationImageCropHint imageCropHint,
92+
NotificationAudioFile? audioFile,
93+
string? customAudioPath,
4894
IReadOnlyList<NotificationButton> buttons,
4995
INotificationHandler? handler,
5096
TimeSpan? expiration,
@@ -58,6 +104,11 @@ internal NotificationRequest(
58104
Body = body;
59105
ImagePath = imagePath;
60106
HeroImagePath = heroImagePath;
107+
InlineImagePath = inlineImagePath;
108+
AttributionText = attributionText;
109+
ImageCropHint = imageCropHint;
110+
AudioFile = audioFile;
111+
CustomAudioPath = customAudioPath;
61112
Buttons = buttons;
62113
Handler = handler;
63114
Expiration = expiration;
@@ -66,6 +117,79 @@ internal NotificationRequest(
66117
}
67118
}
68119

120+
/// <summary>
121+
/// Controls how <see cref="NotificationRequest.ImagePath"/> is cropped when displayed as the app logo override.
122+
/// Windows only.
123+
/// </summary>
124+
public enum NotificationImageCropHint
125+
{
126+
/// <summary>Display the image uncropped (square).</summary>
127+
Square = 0,
128+
/// <summary>Crop the image into a circle.</summary>
129+
Circle = 1
130+
}
131+
132+
/// <summary>
133+
/// Selects a Windows system notification sound.
134+
/// Set on <see cref="NotificationRequest.AudioFile"/> independently of
135+
/// <see cref="NotificationAudio"/> (which controls looping/silence behaviour).
136+
/// </summary>
137+
public enum NotificationAudioFile
138+
{
139+
/// <summary>The generic default notification sound.</summary>
140+
Default = 0,
141+
/// <summary>Instant message sound.</summary>
142+
IM = 1,
143+
/// <summary>New mail sound.</summary>
144+
Mail = 2,
145+
/// <summary>Reminder sound.</summary>
146+
Reminder = 3,
147+
/// <summary>SMS / text message sound.</summary>
148+
SMS = 4,
149+
/// <summary>Looping alarm sound (variant 1).</summary>
150+
Alarm = 5,
151+
/// <summary>Looping alarm sound (variant 2).</summary>
152+
Alarm2 = 6,
153+
/// <summary>Looping alarm sound (variant 3).</summary>
154+
Alarm3 = 7,
155+
/// <summary>Looping alarm sound (variant 4).</summary>
156+
Alarm4 = 8,
157+
/// <summary>Looping alarm sound (variant 5).</summary>
158+
Alarm5 = 9,
159+
/// <summary>Looping alarm sound (variant 6).</summary>
160+
Alarm6 = 10,
161+
/// <summary>Looping alarm sound (variant 7).</summary>
162+
Alarm7 = 11,
163+
/// <summary>Looping alarm sound (variant 8).</summary>
164+
Alarm8 = 12,
165+
/// <summary>Looping alarm sound (variant 9).</summary>
166+
Alarm9 = 13,
167+
/// <summary>Looping alarm sound (variant 10).</summary>
168+
Alarm10 = 14,
169+
/// <summary>Looping incoming-call sound (variant 1).</summary>
170+
Call = 15,
171+
/// <summary>Looping incoming-call sound (variant 2).</summary>
172+
Call1 = 16,
173+
/// <summary>Looping incoming-call sound (variant 3).</summary>
174+
Call2 = 17,
175+
/// <summary>Looping incoming-call sound (variant 4).</summary>
176+
Call3 = 18,
177+
/// <summary>Looping incoming-call sound (variant 5).</summary>
178+
Call4 = 19,
179+
/// <summary>Looping incoming-call sound (variant 6).</summary>
180+
Call5 = 20,
181+
/// <summary>Looping incoming-call sound (variant 7).</summary>
182+
Call6 = 21,
183+
/// <summary>Looping incoming-call sound (variant 8).</summary>
184+
Call7 = 22,
185+
/// <summary>Looping incoming-call sound (variant 9).</summary>
186+
Call8 = 23,
187+
/// <summary>Looping incoming-call sound (variant 10).</summary>
188+
Call9 = 24,
189+
/// <summary>Looping incoming-call sound (variant 11).</summary>
190+
Call10 = 25
191+
}
192+
69193
/// <summary>Controls the audio played when the notification is shown (Windows only; Linux ignores this).</summary>
70194
public enum NotificationAudio
71195
{

0 commit comments

Comments
 (0)