Add support for inline images, attribution text, image crop hint, audio files, custom icon path in Windows

This commit is contained in:
Pat Hartl 2026-04-03 00:17:37 -05:00
parent e1166c474e
commit a24e3cd46d
7 changed files with 411 additions and 45 deletions

View file

@ -20,6 +20,9 @@
#define WINTOASTWRAPPER_EXPORTS
#define NOMINMAX
#include <Windows.h>
#include <shlobj.h> // SHGetFolderPathW, IShellLinkW, CLSID_ShellLink
#include <objbase.h> // CoCreateInstance
#include <strsafe.h> // StringCchCatW
#include <string>
#include <memory>
#include <unordered_map>
@ -195,7 +198,7 @@ NOTIFYAPI BOOL WNT_IsCompatible(void)
return WinToast::isCompatible() ? TRUE : FALSE;
}
NOTIFYAPI BOOL WNT_Initialize(const wchar_t* appName, const wchar_t* appUserModelId)
NOTIFYAPI BOOL WNT_Initialize(const wchar_t* appName, const wchar_t* appUserModelId, const wchar_t* appIconPath)
{
if (!WinToast::isCompatible())
return FALSE;
@ -213,6 +216,41 @@ NOTIFYAPI BOOL WNT_Initialize(const wchar_t* appName, const wchar_t* appUserMode
if (!instance->initialize(&error))
return FALSE;
// If a custom app icon was requested, stamp it onto the Start-Menu shortcut
// that WinToastLib just created/verified. This icon appears in the top-left
// corner of every toast notification from this app.
if (appIconPath && appIconPath[0] != L'\0')
{
// Build the same shortcut path WinToastLib uses: %APPDATA%\Microsoft\Windows\Start Menu\Programs\{appName}.lnk
WCHAR linkPath[MAX_PATH] = {};
if (SUCCEEDED(SHGetFolderPathW(NULL, CSIDL_APPDATA, NULL, 0, linkPath)))
{
if (SUCCEEDED(StringCchCatW(linkPath, MAX_PATH, L"\\Microsoft\\Windows\\Start Menu\\Programs\\")) &&
SUCCEEDED(StringCchCatW(linkPath, MAX_PATH, appName)) &&
SUCCEEDED(StringCchCatW(linkPath, MAX_PATH, L".lnk")))
{
IShellLinkW* shellLink = nullptr;
if (SUCCEEDED(CoCreateInstance(CLSID_ShellLink, nullptr, CLSCTX_INPROC_SERVER,
IID_IShellLinkW, reinterpret_cast<void**>(&shellLink))))
{
IPersistFile* persistFile = nullptr;
if (SUCCEEDED(shellLink->QueryInterface(IID_IPersistFile,
reinterpret_cast<void**>(&persistFile))))
{
if (SUCCEEDED(persistFile->Load(linkPath, STGM_READWRITE)))
{
shellLink->SetIconLocation(appIconPath, 0);
persistFile->Save(linkPath, TRUE);
}
persistFile->Release();
}
shellLink->Release();
}
}
}
// Icon update is best-effort — do not fail initialization if it doesn't succeed.
}
{
std::lock_guard<std::mutex> lock(g_mutex);
// Clear lookup table from a previous Initialize/Uninitialize cycle.
@ -237,9 +275,10 @@ NOTIFYAPI INT64 WNT_ShowToast(
if (!descriptor || !handler)
return static_cast<INT64>(WinToast::WinToastError::InvalidParameters);
bool hasBody = descriptor->body != nullptr && descriptor->body[0] != L'\0';
bool hasImage = descriptor->imagePath != nullptr && descriptor->imagePath[0] != L'\0';
bool hasHeroImage = descriptor->heroImagePath != nullptr && descriptor->heroImagePath[0] != L'\0';
bool hasBody = descriptor->body != nullptr && descriptor->body[0] != L'\0';
bool hasImage = descriptor->imagePath != nullptr && descriptor->imagePath[0] != L'\0';
bool hasHeroImage = descriptor->heroImagePath != nullptr && descriptor->heroImagePath[0] != L'\0';
bool hasInlineImage = descriptor->inlineImagePath != nullptr && descriptor->inlineImagePath[0] != L'\0';
WinToastTemplate tmpl(SelectTemplateType(hasImage, hasBody));
@ -248,10 +287,21 @@ NOTIFYAPI INT64 WNT_ShowToast(
tmpl.setTextField(descriptor->body, WinToastTemplate::SecondLine);
if (hasImage)
tmpl.setImagePath(descriptor->imagePath);
{
WinToastTemplate::CropHint cropHint = (descriptor->cropHint == WNT_CROP_HINT_CIRCLE)
? WinToastTemplate::CropHint::Circle
: WinToastTemplate::CropHint::Square;
tmpl.setImagePath(descriptor->imagePath, cropHint);
}
if (hasHeroImage)
tmpl.setHeroImagePath(descriptor->heroImagePath);
// Inline image takes precedence over hero image; only one can be set at a time.
if (hasInlineImage)
tmpl.setHeroImagePath(descriptor->inlineImagePath, true /* inline */);
else if (hasHeroImage)
tmpl.setHeroImagePath(descriptor->heroImagePath, false /* banner */);
if (descriptor->attributionText != nullptr && descriptor->attributionText[0] != L'\0')
tmpl.setAttributionText(descriptor->attributionText);
for (int i = 0; i < descriptor->buttonCount; ++i)
{
@ -262,6 +312,16 @@ NOTIFYAPI INT64 WNT_ShowToast(
if (descriptor->expirationMs > 0)
tmpl.setExpiration(descriptor->expirationMs);
// Audio: custom path overrides the system-sound enum; both are independent of AudioOption.
if (descriptor->customAudioPath != nullptr && descriptor->customAudioPath[0] != L'\0')
{
tmpl.setAudioPath(descriptor->customAudioPath);
}
else if (descriptor->audioFile >= 0)
{
tmpl.setAudioPath(static_cast<WinToastTemplate::AudioSystemFile>(descriptor->audioFile));
}
switch (descriptor->audioOption)
{
case WNT_AUDIO_SILENT:

View file

@ -62,21 +62,79 @@ typedef enum _WNT_AudioOption {
WNT_AUDIO_LOOP = 2
} WNT_AudioOption;
/**
* Selects which Windows system notification sound to play.
* WNT_AUDIO_FILE_NONE (-1) means no specific sound file override (use AudioOption behaviour).
* Values 0-25 map directly to WinToastTemplate::AudioSystemFile.
*/
typedef enum _WNT_AudioFile {
WNT_AUDIO_FILE_NONE = -1,
WNT_AUDIO_FILE_DEFAULT = 0,
WNT_AUDIO_FILE_IM = 1,
WNT_AUDIO_FILE_MAIL = 2,
WNT_AUDIO_FILE_REMINDER = 3,
WNT_AUDIO_FILE_SMS = 4,
WNT_AUDIO_FILE_ALARM = 5,
WNT_AUDIO_FILE_ALARM2 = 6,
WNT_AUDIO_FILE_ALARM3 = 7,
WNT_AUDIO_FILE_ALARM4 = 8,
WNT_AUDIO_FILE_ALARM5 = 9,
WNT_AUDIO_FILE_ALARM6 = 10,
WNT_AUDIO_FILE_ALARM7 = 11,
WNT_AUDIO_FILE_ALARM8 = 12,
WNT_AUDIO_FILE_ALARM9 = 13,
WNT_AUDIO_FILE_ALARM10 = 14,
WNT_AUDIO_FILE_CALL = 15,
WNT_AUDIO_FILE_CALL1 = 16,
WNT_AUDIO_FILE_CALL2 = 17,
WNT_AUDIO_FILE_CALL3 = 18,
WNT_AUDIO_FILE_CALL4 = 19,
WNT_AUDIO_FILE_CALL5 = 20,
WNT_AUDIO_FILE_CALL6 = 21,
WNT_AUDIO_FILE_CALL7 = 22,
WNT_AUDIO_FILE_CALL8 = 23,
WNT_AUDIO_FILE_CALL9 = 24,
WNT_AUDIO_FILE_CALL10 = 25
} WNT_AudioFile;
/** Controls how the app-logo image is cropped. */
typedef enum _WNT_CropHint {
WNT_CROP_HINT_SQUARE = 0,
WNT_CROP_HINT_CIRCLE = 1
} WNT_CropHint;
/**
* Describes the notification to display.
* All pointer fields may be NULL where noted.
* Callers must keep pointed-to memory valid for the duration of WNT_ShowToast.
*
* Field layout (x64, no explicit packing):
* offsets 0..39 five pointers (title, body, imagePath, heroImagePath, buttonLabels)
* offset 40 buttonCount (int, 4 bytes) + 4 bytes natural padding
* offset 48 expirationMs (long long, 8 bytes)
* offset 56 scenario (int, 4 bytes)
* offset 60 audioOption (int, 4 bytes)
* offsets 64..79 three new pointers (inlineImagePath, attributionText, customAudioPath)
* offset 88 cropHint (int, 4 bytes)
* offset 92 audioFile (int, 4 bytes)
* Total: 96 bytes
*/
typedef struct _WNT_ToastDescriptor {
const wchar_t* title; /* required */
const wchar_t* body; /* nullable */
const wchar_t* imagePath; /* nullable — absolute path; displayed as a square thumbnail */
const wchar_t* heroImagePath; /* nullable — absolute path; displayed full-width, aspect ratio preserved */
const wchar_t** buttonLabels; /* nullable — array of buttonCount wchar_t* */
const wchar_t* title; /* required */
const wchar_t* body; /* nullable */
const wchar_t* imagePath; /* nullable — absolute path; app logo override in generic templates */
const wchar_t* heroImagePath; /* nullable — absolute path; full-width banner above the notification */
const wchar_t** buttonLabels; /* nullable — array of buttonCount wchar_t* */
int buttonCount;
long long expirationMs; /* 0 = platform default */
long long expirationMs; /* 0 = platform default */
WNT_Scenario scenario;
WNT_AudioOption audioOption;
/* Extended fields (added in v2): */
const wchar_t* inlineImagePath; /* nullable — image displayed inline inside the notification body */
const wchar_t* attributionText; /* nullable — small text shown at the bottom of the notification */
const wchar_t* customAudioPath; /* nullable — ms-winsoundevent: URI or ms-appx:/// path; overrides audioFile */
int cropHint; /* WNT_CropHint — how imagePath is cropped (Square or Circle) */
int audioFile; /* WNT_AudioFile — system sound to play; -1 = not overridden */
} WNT_ToastDescriptor;
/**
@ -102,9 +160,14 @@ typedef struct _WNT_Handler {
* @param appUserModelId AppUserModelId (AUMI). The wrapper creates a Start-Menu
* shortcut carrying this AUMI automatically if one does not
* already exist.
* @param appIconPath Optional absolute path to an .ico (or .exe/.dll) file whose
* first icon is stamped onto the Start-Menu shortcut. This is
* the small icon shown in the top-left corner of every toast
* notification from this app. Pass NULL to use the default
* (the host executable's icon).
* @return TRUE on success.
*/
NOTIFYAPI BOOL WNT_Initialize(const wchar_t* appName, const wchar_t* appUserModelId);
NOTIFYAPI BOOL WNT_Initialize(const wchar_t* appName, const wchar_t* appUserModelId, const wchar_t* appIconPath);
/**
* Releases all WinToastLib resources. Call from the same STA thread as WNT_Initialize.

View file

@ -16,15 +16,56 @@ namespace Notify.NET.Abstractions
/// <summary>Optional body text shown beneath the title.</summary>
public string? Body { get; }
/// <summary>Absolute path to an image file displayed as a square thumbnail.</summary>
/// <summary>
/// Absolute path to an image used as the app logo override — the small icon shown
/// alongside the notification content. In generic toast templates (i.e. when a hero
/// or inline image is also present, or when <see cref="ImageCropHint"/> is
/// <see cref="NotificationImageCropHint.Circle"/>) this image replaces the default
/// app icon. Windows only — ignored on Linux and macOS.
/// </summary>
public string? ImagePath { get; }
/// <summary>
/// Absolute path to an image file displayed full-width above the title, preserving aspect ratio.
/// Mutually exclusive with <see cref="InlineImagePath"/> — if both are set, the inline image takes precedence.
/// Windows only — ignored on Linux and macOS.
/// </summary>
public string? HeroImagePath { get; }
/// <summary>
/// Absolute path to an image file displayed inline inside the notification body.
/// Takes precedence over <see cref="HeroImagePath"/> when both are set.
/// Windows only — ignored on Linux and macOS.
/// </summary>
public string? InlineImagePath { get; }
/// <summary>
/// Small attribution text shown at the bottom of the notification (e.g. a source name).
/// Windows only — ignored on Linux and macOS.
/// </summary>
public string? AttributionText { get; }
/// <summary>
/// Controls how <see cref="ImagePath"/> is cropped. Defaults to <see cref="NotificationImageCropHint.Square"/>.
/// Windows only — ignored on Linux and macOS.
/// </summary>
public NotificationImageCropHint ImageCropHint { get; }
/// <summary>
/// A specific Windows system notification sound to play, independent of
/// <see cref="Audio"/>. When set, overrides the default sound selection.
/// Ignored if <see cref="CustomAudioPath"/> is also set.
/// Windows only — ignored on Linux and macOS.
/// </summary>
public NotificationAudioFile? AudioFile { get; }
/// <summary>
/// A custom audio URI (e.g. <c>ms-appx:///sounds/alert.mp3</c> or a
/// <c>ms-winsoundevent:</c> URI). When set, takes precedence over <see cref="AudioFile"/>.
/// Windows only — ignored on Linux and macOS.
/// </summary>
public string? CustomAudioPath { get; }
/// <summary>Action buttons to display. Maximum platform limits apply (typically 5 on Windows, varies on Linux).</summary>
public IReadOnlyList<NotificationButton> Buttons { get; }
@ -45,6 +86,11 @@ namespace Notify.NET.Abstractions
string? body,
string? imagePath,
string? heroImagePath,
string? inlineImagePath,
string? attributionText,
NotificationImageCropHint imageCropHint,
NotificationAudioFile? audioFile,
string? customAudioPath,
IReadOnlyList<NotificationButton> buttons,
INotificationHandler? handler,
TimeSpan? expiration,
@ -58,6 +104,11 @@ namespace Notify.NET.Abstractions
Body = body;
ImagePath = imagePath;
HeroImagePath = heroImagePath;
InlineImagePath = inlineImagePath;
AttributionText = attributionText;
ImageCropHint = imageCropHint;
AudioFile = audioFile;
CustomAudioPath = customAudioPath;
Buttons = buttons;
Handler = handler;
Expiration = expiration;
@ -66,6 +117,79 @@ namespace Notify.NET.Abstractions
}
}
/// <summary>
/// Controls how <see cref="NotificationRequest.ImagePath"/> is cropped when displayed as the app logo override.
/// Windows only.
/// </summary>
public enum NotificationImageCropHint
{
/// <summary>Display the image uncropped (square).</summary>
Square = 0,
/// <summary>Crop the image into a circle.</summary>
Circle = 1
}
/// <summary>
/// Selects a Windows system notification sound.
/// Set on <see cref="NotificationRequest.AudioFile"/> independently of
/// <see cref="NotificationAudio"/> (which controls looping/silence behaviour).
/// </summary>
public enum NotificationAudioFile
{
/// <summary>The generic default notification sound.</summary>
Default = 0,
/// <summary>Instant message sound.</summary>
IM = 1,
/// <summary>New mail sound.</summary>
Mail = 2,
/// <summary>Reminder sound.</summary>
Reminder = 3,
/// <summary>SMS / text message sound.</summary>
SMS = 4,
/// <summary>Looping alarm sound (variant 1).</summary>
Alarm = 5,
/// <summary>Looping alarm sound (variant 2).</summary>
Alarm2 = 6,
/// <summary>Looping alarm sound (variant 3).</summary>
Alarm3 = 7,
/// <summary>Looping alarm sound (variant 4).</summary>
Alarm4 = 8,
/// <summary>Looping alarm sound (variant 5).</summary>
Alarm5 = 9,
/// <summary>Looping alarm sound (variant 6).</summary>
Alarm6 = 10,
/// <summary>Looping alarm sound (variant 7).</summary>
Alarm7 = 11,
/// <summary>Looping alarm sound (variant 8).</summary>
Alarm8 = 12,
/// <summary>Looping alarm sound (variant 9).</summary>
Alarm9 = 13,
/// <summary>Looping alarm sound (variant 10).</summary>
Alarm10 = 14,
/// <summary>Looping incoming-call sound (variant 1).</summary>
Call = 15,
/// <summary>Looping incoming-call sound (variant 2).</summary>
Call1 = 16,
/// <summary>Looping incoming-call sound (variant 3).</summary>
Call2 = 17,
/// <summary>Looping incoming-call sound (variant 4).</summary>
Call3 = 18,
/// <summary>Looping incoming-call sound (variant 5).</summary>
Call4 = 19,
/// <summary>Looping incoming-call sound (variant 6).</summary>
Call5 = 20,
/// <summary>Looping incoming-call sound (variant 7).</summary>
Call6 = 21,
/// <summary>Looping incoming-call sound (variant 8).</summary>
Call7 = 22,
/// <summary>Looping incoming-call sound (variant 9).</summary>
Call8 = 23,
/// <summary>Looping incoming-call sound (variant 10).</summary>
Call9 = 24,
/// <summary>Looping incoming-call sound (variant 11).</summary>
Call10 = 25
}
/// <summary>Controls the audio played when the notification is shown (Windows only; Linux ignores this).</summary>
public enum NotificationAudio
{

View file

@ -27,6 +27,11 @@ namespace Notify.NET.Builder
private string? _body;
private string? _imagePath;
private string? _heroImagePath;
private string? _inlineImagePath;
private string? _attributionText;
private NotificationImageCropHint _imageCropHint = NotificationImageCropHint.Square;
private NotificationAudioFile? _audioFile;
private string? _customAudioPath;
private readonly List<NotificationButton> _buttons = new List<NotificationButton>();
private INotificationHandler? _handler;
private TimeSpan? _expiration;
@ -59,7 +64,12 @@ namespace Notify.NET.Builder
return this;
}
/// <summary>Sets the absolute path of an image to display as a square thumbnail.</summary>
/// <summary>
/// Sets the absolute path of an image used as the app logo override — the small icon
/// displayed alongside the notification content. In generic toast templates (when a hero
/// or inline image is present, or when crop hint is <see cref="NotificationImageCropHint.Circle"/>)
/// this replaces the default app icon. Windows only — ignored on Linux and macOS.
/// </summary>
public NotificationBuilder WithImage(string imagePath)
{
_imagePath = imagePath;
@ -68,7 +78,9 @@ namespace Notify.NET.Builder
/// <summary>
/// Sets the absolute path of an image to display full-width above the notification title,
/// preserving the image's aspect ratio. Windows only — ignored on Linux and macOS.
/// preserving the image's aspect ratio.
/// Mutually exclusive with <see cref="WithInlineImage"/>; if both are set the inline image takes precedence.
/// Windows only — ignored on Linux and macOS.
/// </summary>
public NotificationBuilder WithHeroImage(string imagePath)
{
@ -76,6 +88,62 @@ namespace Notify.NET.Builder
return this;
}
/// <summary>
/// Sets the absolute path of an image displayed inline inside the notification body.
/// Takes precedence over <see cref="WithHeroImage"/> when both are set.
/// Windows only — ignored on Linux and macOS.
/// </summary>
public NotificationBuilder WithInlineImage(string imagePath)
{
_inlineImagePath = imagePath;
return this;
}
/// <summary>
/// Sets small attribution text shown at the bottom of the notification (e.g. a source name or URL).
/// Windows only — ignored on Linux and macOS.
/// </summary>
public NotificationBuilder WithAttributionText(string text)
{
_attributionText = text;
return this;
}
/// <summary>
/// Controls how <see cref="WithImage"/> is cropped when displayed as the app logo override.
/// <see cref="NotificationImageCropHint.Circle"/> also forces the toast into generic template
/// mode, which enables hero/inline images and attribution text.
/// Windows only — ignored on Linux and macOS.
/// </summary>
public NotificationBuilder WithImageCropHint(NotificationImageCropHint cropHint)
{
_imageCropHint = cropHint;
return this;
}
/// <summary>
/// Selects a specific Windows system notification sound. Overrides the default sound
/// selection while still respecting the <see cref="WithAudio"/> loop/silence setting.
/// Ignored when <see cref="WithCustomAudioPath"/> is also set.
/// Windows only — ignored on Linux and macOS.
/// </summary>
public NotificationBuilder WithAudioFile(NotificationAudioFile audioFile)
{
_audioFile = audioFile;
return this;
}
/// <summary>
/// Sets a custom audio URI (e.g. <c>ms-appx:///sounds/alert.mp3</c> or a
/// <c>ms-winsoundevent:</c> URI). Takes precedence over <see cref="WithAudioFile"/>.
/// Windows only — ignored on Linux and macOS.
/// </summary>
public NotificationBuilder WithCustomAudioPath(string audioPath)
{
_customAudioPath = audioPath;
return this;
}
/// <summary>Adds an action button with an optional click callback.</summary>
/// <param name="label">Text shown on the button.</param>
/// <param name="callback">Called with the notification ID when the button is clicked.</param>
@ -178,6 +246,11 @@ namespace Notify.NET.Builder
body: _body,
imagePath: _imagePath,
heroImagePath: _heroImagePath,
inlineImagePath: _inlineImagePath,
attributionText: _attributionText,
imageCropHint: _imageCropHint,
audioFile: _audioFile,
customAudioPath: _customAudioPath,
buttons: _buttons.AsReadOnly(),
handler: handler,
expiration: _expiration,

View file

@ -62,7 +62,7 @@ namespace Notify.NET.Extensions
private static INotificationService CreateService(NotificationOptions opts)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
return new WindowsNotificationService(opts.AppName, opts.AppUserModelId);
return new WindowsNotificationService(opts.AppName, opts.AppUserModelId, opts.AppIconPath);
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
return new LinuxNotificationService(opts.AppName);
@ -95,6 +95,14 @@ namespace Notify.NET.Extensions
/// </summary>
public string AppUserModelId { get; set; } =
System.Diagnostics.Process.GetCurrentProcess().ProcessName;
/// <summary>
/// Optional absolute path to an .ico (or .exe/.dll) file whose first icon is stamped onto
/// the Start-Menu shortcut and shown as the small icon in the top-left corner of every toast
/// notification from this app. Set once at startup; null uses the host executable's icon.
/// Windows only — ignored on Linux and macOS.
/// </summary>
public string? AppIconPath { get; set; }
}
/// <summary>

View file

@ -43,15 +43,21 @@ namespace Notify.NET.Platform.Windows
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
internal struct WNT_ToastDescriptor
{
public IntPtr title; // wchar_t*
public IntPtr body; // wchar_t* (may be IntPtr.Zero)
public IntPtr imagePath; // wchar_t* (may be IntPtr.Zero) — square thumbnail
public IntPtr heroImagePath; // wchar_t* (may be IntPtr.Zero) — full-width, aspect ratio preserved
public IntPtr buttonLabels; // wchar_t** (array of pointers, may be IntPtr.Zero)
public IntPtr title; // wchar_t*
public IntPtr body; // wchar_t* (may be IntPtr.Zero)
public IntPtr imagePath; // wchar_t* (may be IntPtr.Zero) — app logo override in generic templates
public IntPtr heroImagePath; // wchar_t* (may be IntPtr.Zero) — full-width banner above notification
public IntPtr buttonLabels; // wchar_t** (array of pointers, may be IntPtr.Zero)
public int buttonCount;
public long expirationMs; // 0 = platform default
public int scenario; // WNT_Scenario enum value
public int audioOption; // WNT_AudioOption enum value
public long expirationMs; // 0 = platform default
public int scenario; // WNT_Scenario enum value
public int audioOption; // WNT_AudioOption enum value
// Extended fields (v2):
public IntPtr inlineImagePath; // wchar_t* (may be IntPtr.Zero) — image shown inline in notification body
public IntPtr attributionText; // wchar_t* (may be IntPtr.Zero) — small text at the bottom
public IntPtr customAudioPath; // wchar_t* (may be IntPtr.Zero) — ms-winsoundevent: or file URI; overrides audioFile
public int cropHint; // WNT_CropHint (0 = Square, 1 = Circle)
public int audioFile; // WNT_AudioFile (-1 = not set)
}
/// <summary>
@ -68,9 +74,9 @@ namespace Notify.NET.Platform.Windows
}
// WNT_Scenario values (must match enum in WinToastWrapper.h)
internal const int WNT_SCENARIO_DEFAULT = 0;
internal const int WNT_SCENARIO_ALARM = 1;
internal const int WNT_SCENARIO_REMINDER = 2;
internal const int WNT_SCENARIO_DEFAULT = 0;
internal const int WNT_SCENARIO_ALARM = 1;
internal const int WNT_SCENARIO_REMINDER = 2;
internal const int WNT_SCENARIO_INCOMING_CALL = 3;
// WNT_AudioOption values (must match enum in WinToastWrapper.h)
@ -78,6 +84,19 @@ namespace Notify.NET.Platform.Windows
internal const int WNT_AUDIO_SILENT = 1;
internal const int WNT_AUDIO_LOOP = 2;
// WNT_CropHint values (must match enum in WinToastWrapper.h)
internal const int WNT_CROP_HINT_SQUARE = 0;
internal const int WNT_CROP_HINT_CIRCLE = 1;
// WNT_AudioFile values (must match enum in WinToastWrapper.h)
internal const int WNT_AUDIO_FILE_NONE = -1;
internal const int WNT_AUDIO_FILE_DEFAULT = 0;
internal const int WNT_AUDIO_FILE_IM = 1;
internal const int WNT_AUDIO_FILE_MAIL = 2;
internal const int WNT_AUDIO_FILE_REMINDER = 3;
internal const int WNT_AUDIO_FILE_SMS = 4;
internal const int WNT_AUDIO_FILE_ALARM = 5;
// -------------------------------------------------------------------------
// Exported functions
// -------------------------------------------------------------------------
@ -93,7 +112,7 @@ namespace Notify.NET.Platform.Windows
/// <returns>true on success.</returns>
[DllImport(DllName, EntryPoint = "WNT_Initialize", CharSet = CharSet.Unicode, SetLastError = false)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool WNT_Initialize(string appName, string appUserModelId);
internal static extern bool WNT_Initialize(string appName, string appUserModelId, string? appIconPath);
/// <summary>Uninitialises WinToastLib and releases all internal resources.</summary>
[DllImport(DllName, EntryPoint = "WNT_Uninitialize")]

View file

@ -23,6 +23,7 @@ namespace Notify.NET.Platform.Windows
{
private readonly string _appName;
private readonly string _appUserModelId;
private readonly string? _appIconPath;
private readonly Thread _staThread;
private readonly BlockingCollection<Action> _workQueue = new BlockingCollection<Action>();
@ -40,10 +41,16 @@ namespace Notify.NET.Platform.Windows
/// A Start-Menu shortcut carrying this AUMI is required for notifications to persist in
/// the Action Centre. The native wrapper creates the shortcut automatically when missing.
/// </param>
public WindowsNotificationService(string appName, string appUserModelId)
/// <param name="appIconPath">
/// Optional absolute path to an .ico (or .exe/.dll) file whose first icon is used as the
/// small icon in the top-left corner of every toast notification from this app.
/// Pass null to use the host executable's default icon.
/// </param>
public WindowsNotificationService(string appName, string appUserModelId, string? appIconPath = null)
{
_appName = appName ?? throw new ArgumentNullException(nameof(appName));
_appUserModelId = appUserModelId ?? throw new ArgumentNullException(nameof(appUserModelId));
_appIconPath = appIconPath;
_staThread = new Thread(StaThreadProc)
{
@ -171,7 +178,7 @@ namespace Notify.NET.Platform.Windows
return;
}
bool ok = WinToastNative.WNT_Initialize(_appName, _appUserModelId);
bool ok = WinToastNative.WNT_Initialize(_appName, _appUserModelId, _appIconPath);
if (!ok)
{
_initException = new NotificationException("WNT_Initialize returned false.");
@ -214,10 +221,13 @@ namespace Notify.NET.Platform.Windows
// Pin managed strings as unmanaged UTF-16 memory for the duration of the call.
// button label pointers are pinned in the IntPtr[] and that array is pinned too.
using var titlePin = new PinnedString(request.Title);
using var bodyPin = new PinnedString(request.Body);
using var imagePin = new PinnedString(ResolveImagePath(request.ImagePath));
using var heroImagePin = new PinnedString(ResolveImagePath(request.HeroImagePath));
using var titlePin = new PinnedString(request.Title);
using var bodyPin = new PinnedString(request.Body);
using var imagePin = new PinnedString(ResolveImagePath(request.ImagePath));
using var heroImagePin = new PinnedString(ResolveImagePath(request.HeroImagePath));
using var inlineImagePin = new PinnedString(ResolveImagePath(request.InlineImagePath));
using var attributionPin = new PinnedString(request.AttributionText);
using var customAudioPin = new PinnedString(request.CustomAudioPath);
// Build array of pinned button label pointers.
var buttonPins = new PinnedString[request.Buttons.Count];
@ -242,15 +252,24 @@ namespace Notify.NET.Platform.Windows
var descriptor = new WinToastNative.WNT_ToastDescriptor
{
title = titlePin.Pointer,
body = bodyPin.Pointer,
imagePath = imagePin.Pointer,
heroImagePath = heroImagePin.Pointer,
buttonLabels = buttonArrayPtr,
buttonCount = request.Buttons.Count,
expirationMs = request.Expiration.HasValue ? (long)request.Expiration.Value.TotalMilliseconds : 0L,
scenario = MapScenario(request.Urgency),
audioOption = MapAudio(request.Audio)
title = titlePin.Pointer,
body = bodyPin.Pointer,
imagePath = imagePin.Pointer,
heroImagePath = heroImagePin.Pointer,
buttonLabels = buttonArrayPtr,
buttonCount = request.Buttons.Count,
expirationMs = request.Expiration.HasValue ? (long)request.Expiration.Value.TotalMilliseconds : 0L,
scenario = MapScenario(request.Urgency),
audioOption = MapAudio(request.Audio),
inlineImagePath = inlineImagePin.Pointer,
attributionText = attributionPin.Pointer,
customAudioPath = customAudioPin.Pointer,
cropHint = request.ImageCropHint == NotificationImageCropHint.Circle
? WinToastNative.WNT_CROP_HINT_CIRCLE
: WinToastNative.WNT_CROP_HINT_SQUARE,
audioFile = request.AudioFile.HasValue
? (int)request.AudioFile.Value
: WinToastNative.WNT_AUDIO_FILE_NONE
};
var handler = new WinToastNative.WNT_Handler