From a24e3cd46d03634c08122ef080629b41b73b9472 Mon Sep 17 00:00:00 2001 From: Pat Hartl Date: Fri, 3 Apr 2026 00:17:37 -0500 Subject: [PATCH] Add support for inline images, attribution text, image crop hint, audio files, custom icon path in Windows --- native/WinToastWrapper/WinToastWrapper.cpp | 74 +++++++++- native/WinToastWrapper/WinToastWrapper.h | 77 ++++++++++- .../Abstractions/NotificationRequest.cs | 126 +++++++++++++++++- src/Notify.NET/Builder/NotificationBuilder.cs | 77 ++++++++++- .../Extensions/ServiceCollectionExtensions.cs | 10 +- .../Platform/Windows/WinToastNative.cs | 43 ++++-- .../Windows/WindowsNotificationService.cs | 49 ++++--- 7 files changed, 411 insertions(+), 45 deletions(-) diff --git a/native/WinToastWrapper/WinToastWrapper.cpp b/native/WinToastWrapper/WinToastWrapper.cpp index a82b04b..1ef7d77 100644 --- a/native/WinToastWrapper/WinToastWrapper.cpp +++ b/native/WinToastWrapper/WinToastWrapper.cpp @@ -20,6 +20,9 @@ #define WINTOASTWRAPPER_EXPORTS #define NOMINMAX #include +#include // SHGetFolderPathW, IShellLinkW, CLSID_ShellLink +#include // CoCreateInstance +#include // StringCchCatW #include #include #include @@ -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(&shellLink)))) + { + IPersistFile* persistFile = nullptr; + if (SUCCEEDED(shellLink->QueryInterface(IID_IPersistFile, + reinterpret_cast(&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 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(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(descriptor->audioFile)); + } + switch (descriptor->audioOption) { case WNT_AUDIO_SILENT: diff --git a/native/WinToastWrapper/WinToastWrapper.h b/native/WinToastWrapper/WinToastWrapper.h index 3e58c32..768d9af 100644 --- a/native/WinToastWrapper/WinToastWrapper.h +++ b/native/WinToastWrapper/WinToastWrapper.h @@ -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. diff --git a/src/Notify.NET/Abstractions/NotificationRequest.cs b/src/Notify.NET/Abstractions/NotificationRequest.cs index 7db9303..dfe083e 100644 --- a/src/Notify.NET/Abstractions/NotificationRequest.cs +++ b/src/Notify.NET/Abstractions/NotificationRequest.cs @@ -16,15 +16,56 @@ namespace Notify.NET.Abstractions /// Optional body text shown beneath the title. public string? Body { get; } - /// Absolute path to an image file displayed as a square thumbnail. + /// + /// 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 is + /// ) this image replaces the default + /// app icon. Windows only — ignored on Linux and macOS. + /// public string? ImagePath { get; } /// /// Absolute path to an image file displayed full-width above the title, preserving aspect ratio. + /// Mutually exclusive with — if both are set, the inline image takes precedence. /// Windows only — ignored on Linux and macOS. /// public string? HeroImagePath { get; } + /// + /// Absolute path to an image file displayed inline inside the notification body. + /// Takes precedence over when both are set. + /// Windows only — ignored on Linux and macOS. + /// + public string? InlineImagePath { get; } + + /// + /// Small attribution text shown at the bottom of the notification (e.g. a source name). + /// Windows only — ignored on Linux and macOS. + /// + public string? AttributionText { get; } + + /// + /// Controls how is cropped. Defaults to . + /// Windows only — ignored on Linux and macOS. + /// + public NotificationImageCropHint ImageCropHint { get; } + + /// + /// A specific Windows system notification sound to play, independent of + /// . When set, overrides the default sound selection. + /// Ignored if is also set. + /// Windows only — ignored on Linux and macOS. + /// + public NotificationAudioFile? AudioFile { get; } + + /// + /// A custom audio URI (e.g. ms-appx:///sounds/alert.mp3 or a + /// ms-winsoundevent: URI). When set, takes precedence over . + /// Windows only — ignored on Linux and macOS. + /// + public string? CustomAudioPath { get; } + /// Action buttons to display. Maximum platform limits apply (typically 5 on Windows, varies on Linux). public IReadOnlyList 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 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 } } + /// + /// Controls how is cropped when displayed as the app logo override. + /// Windows only. + /// + public enum NotificationImageCropHint + { + /// Display the image uncropped (square). + Square = 0, + /// Crop the image into a circle. + Circle = 1 + } + + /// + /// Selects a Windows system notification sound. + /// Set on independently of + /// (which controls looping/silence behaviour). + /// + public enum NotificationAudioFile + { + /// The generic default notification sound. + Default = 0, + /// Instant message sound. + IM = 1, + /// New mail sound. + Mail = 2, + /// Reminder sound. + Reminder = 3, + /// SMS / text message sound. + SMS = 4, + /// Looping alarm sound (variant 1). + Alarm = 5, + /// Looping alarm sound (variant 2). + Alarm2 = 6, + /// Looping alarm sound (variant 3). + Alarm3 = 7, + /// Looping alarm sound (variant 4). + Alarm4 = 8, + /// Looping alarm sound (variant 5). + Alarm5 = 9, + /// Looping alarm sound (variant 6). + Alarm6 = 10, + /// Looping alarm sound (variant 7). + Alarm7 = 11, + /// Looping alarm sound (variant 8). + Alarm8 = 12, + /// Looping alarm sound (variant 9). + Alarm9 = 13, + /// Looping alarm sound (variant 10). + Alarm10 = 14, + /// Looping incoming-call sound (variant 1). + Call = 15, + /// Looping incoming-call sound (variant 2). + Call1 = 16, + /// Looping incoming-call sound (variant 3). + Call2 = 17, + /// Looping incoming-call sound (variant 4). + Call3 = 18, + /// Looping incoming-call sound (variant 5). + Call4 = 19, + /// Looping incoming-call sound (variant 6). + Call5 = 20, + /// Looping incoming-call sound (variant 7). + Call6 = 21, + /// Looping incoming-call sound (variant 8). + Call7 = 22, + /// Looping incoming-call sound (variant 9). + Call8 = 23, + /// Looping incoming-call sound (variant 10). + Call9 = 24, + /// Looping incoming-call sound (variant 11). + Call10 = 25 + } + /// Controls the audio played when the notification is shown (Windows only; Linux ignores this). public enum NotificationAudio { diff --git a/src/Notify.NET/Builder/NotificationBuilder.cs b/src/Notify.NET/Builder/NotificationBuilder.cs index 75b964d..1127168 100644 --- a/src/Notify.NET/Builder/NotificationBuilder.cs +++ b/src/Notify.NET/Builder/NotificationBuilder.cs @@ -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 _buttons = new List(); private INotificationHandler? _handler; private TimeSpan? _expiration; @@ -59,7 +64,12 @@ namespace Notify.NET.Builder return this; } - /// Sets the absolute path of an image to display as a square thumbnail. + /// + /// 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 ) + /// this replaces the default app icon. Windows only — ignored on Linux and macOS. + /// public NotificationBuilder WithImage(string imagePath) { _imagePath = imagePath; @@ -68,7 +78,9 @@ namespace Notify.NET.Builder /// /// 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 ; if both are set the inline image takes precedence. + /// Windows only — ignored on Linux and macOS. /// public NotificationBuilder WithHeroImage(string imagePath) { @@ -76,6 +88,62 @@ namespace Notify.NET.Builder return this; } + /// + /// Sets the absolute path of an image displayed inline inside the notification body. + /// Takes precedence over when both are set. + /// Windows only — ignored on Linux and macOS. + /// + public NotificationBuilder WithInlineImage(string imagePath) + { + _inlineImagePath = imagePath; + return this; + } + + /// + /// 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. + /// + public NotificationBuilder WithAttributionText(string text) + { + _attributionText = text; + return this; + } + + /// + /// Controls how is cropped when displayed as the app logo override. + /// also forces the toast into generic template + /// mode, which enables hero/inline images and attribution text. + /// Windows only — ignored on Linux and macOS. + /// + public NotificationBuilder WithImageCropHint(NotificationImageCropHint cropHint) + { + _imageCropHint = cropHint; + return this; + } + + /// + /// Selects a specific Windows system notification sound. Overrides the default sound + /// selection while still respecting the loop/silence setting. + /// Ignored when is also set. + /// Windows only — ignored on Linux and macOS. + /// + public NotificationBuilder WithAudioFile(NotificationAudioFile audioFile) + { + _audioFile = audioFile; + return this; + } + + /// + /// Sets a custom audio URI (e.g. ms-appx:///sounds/alert.mp3 or a + /// ms-winsoundevent: URI). Takes precedence over . + /// Windows only — ignored on Linux and macOS. + /// + public NotificationBuilder WithCustomAudioPath(string audioPath) + { + _customAudioPath = audioPath; + return this; + } + /// Adds an action button with an optional click callback. /// Text shown on the button. /// Called with the notification ID when the button is clicked. @@ -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, diff --git a/src/Notify.NET/Extensions/ServiceCollectionExtensions.cs b/src/Notify.NET/Extensions/ServiceCollectionExtensions.cs index 6bb705f..20ead8e 100644 --- a/src/Notify.NET/Extensions/ServiceCollectionExtensions.cs +++ b/src/Notify.NET/Extensions/ServiceCollectionExtensions.cs @@ -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 /// public string AppUserModelId { get; set; } = System.Diagnostics.Process.GetCurrentProcess().ProcessName; + + /// + /// 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. + /// + public string? AppIconPath { get; set; } } /// diff --git a/src/Notify.NET/Platform/Windows/WinToastNative.cs b/src/Notify.NET/Platform/Windows/WinToastNative.cs index c28d3df..ea1b455 100644 --- a/src/Notify.NET/Platform/Windows/WinToastNative.cs +++ b/src/Notify.NET/Platform/Windows/WinToastNative.cs @@ -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) } /// @@ -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 /// true on success. [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); /// Uninitialises WinToastLib and releases all internal resources. [DllImport(DllName, EntryPoint = "WNT_Uninitialize")] diff --git a/src/Notify.NET/Platform/Windows/WindowsNotificationService.cs b/src/Notify.NET/Platform/Windows/WindowsNotificationService.cs index a16a321..bff56f9 100644 --- a/src/Notify.NET/Platform/Windows/WindowsNotificationService.cs +++ b/src/Notify.NET/Platform/Windows/WindowsNotificationService.cs @@ -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 _workQueue = new BlockingCollection(); @@ -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. /// - public WindowsNotificationService(string appName, string appUserModelId) + /// + /// 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. + /// + 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