Initial commit

This commit is contained in:
Pat Hartl 2026-03-29 13:56:50 -05:00
commit 099012cfd3
36 changed files with 6675 additions and 0 deletions

166
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,166 @@
# .github/workflows/release.yml
#
# Triggered by a semver tag (e.g. v1.2.3).
#
# Jobs:
# build-windows — compiles WinToastWrapper.dll for win-x64 / win-x86 / win-arm64
# using MSBuild on a Windows runner.
# build-macos — compiles libMacNotifyWrapper.dylib for osx-arm64 and osx-x64
# using clang cross-compilation on a macOS runner.
# pack — downloads all native artifacts, packs the NuGet package with the
# tag version, creates a GitHub Release, and optionally pushes to
# NuGet.org (requires the NUGET_API_KEY repository secret).
name: Release
on:
push:
tags:
- 'v[0-9]+.[0-9]+.[0-9]+'
permissions:
contents: write # required to create GitHub Releases
# ---------------------------------------------------------------------------
# Windows native build — three architectures in parallel
# ---------------------------------------------------------------------------
jobs:
build-windows:
name: Build WinToastWrapper (${{ matrix.rid }})
runs-on: windows-latest
strategy:
fail-fast: false
matrix:
include:
- { platform: x64, rid: win-x64 }
- { platform: Win32, rid: win-x86 }
- { platform: ARM64, rid: win-arm64 }
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Add MSBuild to PATH
uses: microsoft/setup-msbuild@v2
- name: Build WinToastWrapper (${{ matrix.platform }})
run: |
msbuild native\WinToastWrapper\WinToastWrapper.vcxproj `
/p:Configuration=Release `
/p:Platform=${{ matrix.platform }} `
/m `
/nologo
# The vcxproj OutDir already targets runtimes/<rid>/native/ relative to the
# repo root, so the DLL lands in the right place after a successful build.
- name: Upload native artifact
uses: actions/upload-artifact@v4
with:
name: native-${{ matrix.rid }}
path: runtimes/${{ matrix.rid }}/native/WinToastWrapper.dll
if-no-files-found: error
# ---------------------------------------------------------------------------
# macOS native build — both architectures on one runner via clang cross-compile
# ---------------------------------------------------------------------------
build-macos:
name: Build MacNotifyWrapper (osx-arm64 + osx-x64)
runs-on: macos-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# The Makefile targets arm64-apple-macos11.0 and x86_64-apple-macos10.14
# using -arch flags; cross-compilation works on both Intel and Apple Silicon
# runners because Xcode ships cross-compilers for both targets.
- name: Build dylibs and install to runtimes/
run: make -C native/MacNotifyWrapper install
- name: Upload osx-arm64 artifact
uses: actions/upload-artifact@v4
with:
name: native-osx-arm64
path: runtimes/osx-arm64/native/libMacNotifyWrapper.dylib
if-no-files-found: error
- name: Upload osx-x64 artifact
uses: actions/upload-artifact@v4
with:
name: native-osx-x64
path: runtimes/osx-x64/native/libMacNotifyWrapper.dylib
if-no-files-found: error
# ---------------------------------------------------------------------------
# NuGet pack and GitHub Release
# ---------------------------------------------------------------------------
pack:
name: Pack NuGet and publish release
needs: [build-windows, build-macos]
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# Strip the leading 'v' from the tag (v1.2.3 → 1.2.3) for use as the
# NuGet package version and .NET assembly version.
- name: Extract version from tag
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"
# Download every native-* artifact into artifact-staging/<artifact-name>/
# Each artifact contains a single file at its root (no subdirectory).
- name: Download native artifacts
uses: actions/download-artifact@v4
with:
pattern: native-*
path: artifact-staging/
# Reconstruct the runtimes/ tree that the .csproj Content items reference.
# The Condition="Exists(...)" guards in the .csproj will only bundle a file
# if it is present here, so all five binaries must be staged before packing.
- name: Stage native binaries into runtimes/
run: |
mkdir -p \
runtimes/win-x64/native \
runtimes/win-x86/native \
runtimes/win-arm64/native \
runtimes/osx-x64/native \
runtimes/osx-arm64/native
cp artifact-staging/native-win-x64/WinToastWrapper.dll runtimes/win-x64/native/
cp artifact-staging/native-win-x86/WinToastWrapper.dll runtimes/win-x86/native/
cp artifact-staging/native-win-arm64/WinToastWrapper.dll runtimes/win-arm64/native/
cp artifact-staging/native-osx-x64/libMacNotifyWrapper.dylib runtimes/osx-x64/native/
cp artifact-staging/native-osx-arm64/libMacNotifyWrapper.dylib runtimes/osx-arm64/native/
- name: Setup .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.x'
# -p:Version overrides the hardcoded version in the .csproj for this build.
# --no-restore is safe here because we only need the managed code compiled;
# the native binaries are staged directly without a restore step.
- name: Pack NuGet package
run: |
dotnet pack src/Notify.NET/Notify.NET.csproj \
--configuration Release \
-p:Version=${{ env.VERSION }} \
--output ./nupkg
- name: Create GitHub Release
uses: softprops/action-gh-release@v2
with:
files: ./nupkg/*.nupkg
generate_release_notes: true
fail_on_unmatched_files: true
# Requires the NUGET_API_KEY repository secret to be configured.
# Skip this step silently if the secret is absent.
- name: Push to NuGet.org
if: ${{ secrets.NUGET_API_KEY != '' }}
run: |
dotnet nuget push ./nupkg/*.nupkg \
--api-key ${{ secrets.NUGET_API_KEY }} \
--source https://api.nuget.org/v3/index.json \
--skip-duplicate

171
.gitignore vendored Normal file
View file

@ -0,0 +1,171 @@
### C++
# Object files
*.o
*.obj
# Precompiled Headers
*.gch
*.pch
# Dynamic libraries
*.so
*.dylib
*.dll
# Static libraries
*.a
*.lib
# Executables
*.exe
*.out
# Linker output
*.ilk
*.map
*.exp
# Debug files
*.dSYM/
*.idb
*.pdb
### C#
# Build results
[Bb]in/
[Oo]bj/
# Visual Studio cache/options directory
.vs/
# User-specific files
*.suo
*.user
# Debug symbols
*.pdb
# NuGet Packages
*.nupkg
**/[Pp]ackages/*
!**/[Pp]ackages/build/
# .NET build artifacts
artifacts/
# Publish output
publish/
### Objective-C
# Xcode user settings
xcuserdata/
# Xcode build data
DerivedData/
# Obj-C/Swift specific
*.hmap
# App packaging
*.ipa
*.dSYM.zip
*.dSYM
# Playgrounds
timeline.xctimeline
playground.xcworkspace
### macOS
# Finder metadata
.DS_Store
# Thumbnails
._*
# Custom folder icons
Icon
# Volume root files
.DocumentRevisions-V100
.fseventsd
.Spotlight-V100
.TemporaryItems
.Trashes
.VolumeIcon.icns
.com.apple.timemachine.donotpresent
### Windows
# Windows thumbnail cache files
Thumbs.db
# Folder config file
[Dd]esktop.ini
# Recycle Bin used on file shares
$RECYCLE.BIN/
# Windows shortcuts
*.lnk
### Linux
# Backup files
*~
# Temporary files from deleted open files
.fuse_hidden*
# KDE directory preferences
.directory
# Linux trash folder
.Trash-*
# NFS temporary files
.nfs*
### JetBrains
# JetBrains IDE directory
.idea/
# CMake build directories
cmake-build-*/
# File-based project format
*.iws
*.iml
# IntelliJ build output
out/
### VisualStudio
# User-specific files
*.suo
*.user
# Build results
[Dd]ebug/
[Rr]elease/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
[Bb]in/
[Oo]bj/
# Visual Studio cache/options directory
.vs/
# MSTest test Results
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NuGet
*.nupkg
**/[Pp]ackages/*
!**/[Pp]ackages/build/
*.nuget.props
*.nuget.targets
# Publish Web Output
*.[Pp]ublish.xml

25
Notify.NET.sln Normal file
View file

@ -0,0 +1,25 @@
Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Notify.NET", "src\Notify.NET\Notify.NET.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Notify.NET.Sample", "samples\Notify.NET.Sample\Notify.NET.Sample.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
EndGlobal

381
README.md Normal file
View file

@ -0,0 +1,381 @@
# Notify.NET
A cross-platform .NET Standard library for displaying OS notifications. Provides a single
fluent API that dispatches to the native notification system on each supported platform.
| Platform | Backend | Minimum OS |
|----------|---------|------------|
| Windows | [WinToastLib](https://github.com/mohabouje/WinToast) via a thin C++ wrapper DLL | Windows 8 |
| Linux | libnotify via P/Invoke | Any distribution with a D-Bus notification daemon |
| macOS | UNUserNotificationCenter via a thin Objective-C wrapper dylib | macOS 10.14 (Mojave) |
---
## Installation
```
dotnet add package Notify.NET
```
The NuGet package includes the pre-compiled native libraries for all supported platforms.
No separate native installation is required.
---
## Quick start
```csharp
using Notify.NET.Builder;
using Notify.NET.Extensions;
using var service = ServiceCollectionExtensions.CreateNotificationService(opts =>
{
opts.AppName = "My App";
opts.AppUserModelId = "MyCompany.MyApp"; // Windows only; ignored on other platforms
});
if (service.IsSupported)
{
long id = await service.ShowAsync(
NotificationBuilder.Create("Hello")
.WithBody("Notify.NET is working.")
.Build());
}
```
---
## Creating the service
### Without a DI container
```csharp
using var service = ServiceCollectionExtensions.CreateNotificationService(opts =>
{
opts.AppName = "My App";
opts.AppUserModelId = "MyCompany.MyApp";
});
```
`CreateNotificationService` selects the correct implementation for the current OS
automatically. On unsupported platforms it returns a no-op service where `IsSupported`
is `false`.
### With Microsoft.Extensions.DependencyInjection
```csharp
services.AddNotifications(opts =>
{
opts.AppName = "My App";
opts.AppUserModelId = "MyCompany.MyApp";
});
```
This registers `INotificationService` as a singleton. Resolve it normally:
```csharp
var service = provider.GetRequiredService<INotificationService>();
```
### Checking platform support
Always check `IsSupported` before calling `ShowAsync`. On unsupported platforms or when
initialisation fails (e.g. the user denied notification permission on macOS), `ShowAsync`
throws `PlatformNotSupportedException`.
```csharp
if (!service.IsSupported)
{
Console.WriteLine("Notifications are not available on this platform.");
return;
}
```
---
## Builder API
All notification configuration is done through `NotificationBuilder`. The `Build()` call
produces an immutable `NotificationRequest` that can be passed to `ShowAsync`.
### Title and body
```csharp
var request = NotificationBuilder.Create("Title goes here")
.WithBody("Optional body text goes here.")
.Build();
```
### Image
Pass an absolute path to an image file. Relative paths are resolved to absolute paths
at call time; if the file does not exist the notification is shown without an image
rather than failing.
```csharp
var request = NotificationBuilder.Create("New photo")
.WithBody("A photo has arrived.")
.WithImage("/home/user/photos/latest.jpg")
.Build();
```
Supported formats depend on the platform (PNG and JPEG work on all three).
### Action buttons
Up to five action buttons can be added. Each button has a label and an optional click
callback that receives the notification ID.
```csharp
var request = NotificationBuilder.Create("Update available")
.WithBody("Version 2.0 is ready.")
.AddButton("Install now", id => Installer.Run())
.AddButton("Remind me later", id => ScheduleReminder())
.AddButton("Skip this version", null)
.Build();
```
### Lifecycle callbacks
Register delegates for specific notification events:
```csharp
var request = NotificationBuilder.Create("Download complete")
.WithBody("report.pdf has been saved.")
.OnActivated(id => OpenFile("report.pdf"))
.OnDismissed((id, reason) =>
{
if (reason == DismissReason.UserCancelled)
Console.WriteLine("User dismissed the notification.");
})
.OnFailed(id => Console.WriteLine($"Notification {id} could not be displayed."))
.Build();
```
For more complex cases, implement `INotificationHandler` and attach it with `WithHandler`:
```csharp
public sealed class DownloadHandler : INotificationHandler
{
public void OnActivated(long id) => OpenDownloadFolder();
public void OnButtonActivated(long id, int i) => HandleButton(i);
public void OnDismissed(long id, DismissReason r) { }
public void OnFailed(long id) => LogError(id);
}
var request = NotificationBuilder.Create("Download complete")
.WithHandler(new DownloadHandler())
.Build();
```
`WithHandler` takes precedence over any delegate callbacks registered on the same builder.
### Urgency
```csharp
// Low priority — the platform may suppress or delay it
NotificationBuilder.Create("FYI").WithUrgency(NotificationUrgency.Low)
// Critical — bypasses Do Not Disturb where the platform supports it
NotificationBuilder.Create("Disk full").WithUrgency(NotificationUrgency.Critical)
// Windows-specific scenarios
NotificationBuilder.Create("Meeting in 5 minutes").WithUrgency(NotificationUrgency.Reminder)
NotificationBuilder.Create("Incoming call").WithUrgency(NotificationUrgency.Alarm)
```
| Value | Windows | Linux | macOS |
|-------|---------|-------|-------|
| `Normal` | Default scenario | Normal urgency | Active interruption level |
| `Low` | Default scenario | Low urgency | Passive interruption level |
| `Critical` | Default scenario | Critical urgency | Critical interruption level (macOS 12+) |
| `Alarm` | Alarm scenario | Critical urgency | Critical interruption level (macOS 12+) |
| `Reminder` | Reminder scenario | Normal urgency | Active interruption level |
### Audio
```csharp
NotificationBuilder.Create("Alert").WithAudio(NotificationAudio.Silent)
NotificationBuilder.Create("Alarm").WithAudio(NotificationAudio.Loop) // Windows only
```
| Value | Windows | Linux | macOS |
|-------|---------|-------|-------|
| `Default` | System notification sound | Controlled by daemon | System notification sound |
| `Silent` | No sound | No sound | No sound |
| `Loop` | Sound loops until dismissed | Treated as default | Treated as default |
### Expiration
Sets how long the notification is visible before it auto-dismisses. Pass `null` or omit
the call to use the platform default.
```csharp
NotificationBuilder.Create("Reminder")
.WithExpiration(TimeSpan.FromSeconds(10))
.Build();
```
Note: `UNUserNotificationCenter` on macOS does not expose a per-notification timeout API;
this value is stored in the request but has no effect on macOS.
---
## Showing and hiding notifications
`ShowAsync` returns a `long` identifier for the notification. Pass this to `HideAsync`
to remove it programmatically before the user interacts with it.
```csharp
long id = await service.ShowAsync(request);
// Remove it after two seconds
await Task.Delay(TimeSpan.FromSeconds(2));
await service.HideAsync(id);
```
---
## Callback threading
Callbacks are fired on a background thread:
- **Windows** — callbacks arrive on a WinRT thread-pool thread.
- **Linux** — callbacks arrive on the GLib main loop thread.
- **macOS** — callbacks arrive on a background GCD thread managed by
`UNUserNotificationCenter`.
If you need to update UI elements from a callback, marshal the call to your UI thread
(e.g. `Dispatcher.InvokeAsync` on WPF, `Control.Invoke` on WinForms, or
`MainThread.BeginInvokeOnMainThread` on MAUI).
---
## INotificationService interface
```csharp
public interface INotificationService : IDisposable
{
// False if the platform is unsupported or initialisation failed.
bool IsSupported { get; }
// Shows a notification. Returns the notification ID on success.
// Throws PlatformNotSupportedException if IsSupported is false.
Task<long> ShowAsync(NotificationRequest request,
CancellationToken cancellationToken = default);
// Removes the notification from the notification centre.
Task HideAsync(long notificationId,
CancellationToken cancellationToken = default);
}
```
Dispose the service when your application exits to release the native resources
(WinToastLib STA thread on Windows, GLib main loop on Linux, UNUserNotificationCenter
cleanup on macOS).
---
## Platform notes
### Windows
- Requires Windows 8 or later. `IsSupported` returns `false` on earlier versions.
- The `AppUserModelId` must match an application shortcut in the Start Menu. The native
wrapper creates this shortcut automatically on first run when it does not already exist,
but the shortcut creation requires that the process can write to the user's
`%APPDATA%\Microsoft\Windows\Start Menu\Programs` folder.
- `WinToastWrapper.dll` is loaded at runtime from `runtimes/win-<arch>/native/` relative
to the entry assembly. For published single-file applications, ensure the DLL is
published alongside the executable.
- Toast callbacks are delivered on a WinRT thread-pool thread, not the STA thread. The
library handles this internally.
### Linux
`libnotify` must be installed at runtime. Install it via your package manager:
```
# Debian / Ubuntu
sudo apt install libnotify4
# Fedora / RHEL
sudo dnf install libnotify
# Arch
sudo pacman -S libnotify
```
A running D-Bus notification daemon is required (GNOME Shell, KDE Plasma, and most other
desktop environments provide one). Notifications will be silently dropped if no daemon is
running. `IsSupported` reflects whether `notify_init()` succeeded, not whether a daemon
is present.
Image support via `gdk-pixbuf` requires `libgdk-pixbuf-2.0` to be installed, which is
typically included as a dependency of `libnotify4`.
### macOS
- Requires macOS 10.14 (Mojave) or later.
- On first use, macOS presents an authorisation dialog asking the user to allow
notifications. `MNW_Initialize` (called from the `MacOSNotificationService` constructor)
blocks until the user responds. `IsSupported` is `false` if permission was denied.
- The user can revoke permission at any time in System Settings > Notifications. Subsequent
calls to `ShowAsync` will fail silently (the OS will not display the notification and no
callback fires) until permission is re-granted.
- Action button callbacks and dismiss callbacks require the process to remain running after
the notification is shown, because `UNUserNotificationCenterDelegate` delivers responses
to the live process. If the process exits before the user interacts, the callbacks are
never fired.
- Non-bundled processes (bare `dotnet` CLI applications) can display banners but may not
always receive action callbacks on all OS versions. Wrap the application as an `.app`
bundle or sign it with an appropriate entitlement for reliable callback delivery in
production.
- The notification body-tap and button-tap events on macOS are terminal events — the
`OnDismissed` callback is not fired after the user activates a notification or clicks
a button (unlike Windows, where WinToastLib always fires the dismissed event after any
interaction).
---
## Building the native libraries from source
Pre-built native binaries are included in the NuGet package. You only need to build from
source if you are making changes to the native wrapper code.
### Windows (WinToastWrapper.dll)
Requires Visual Studio 2022 with the "Desktop development with C++" workload.
```
msbuild native\WinToastWrapper\WinToastWrapper.vcxproj /p:Configuration=Release /p:Platform=x64
msbuild native\WinToastWrapper\WinToastWrapper.vcxproj /p:Configuration=Release /p:Platform=Win32
msbuild native\WinToastWrapper\WinToastWrapper.vcxproj /p:Configuration=Release /p:Platform=ARM64
```
Output is written to `runtimes\win-<arch>\native\WinToastWrapper.dll`.
### macOS (libMacNotifyWrapper.dylib)
Requires Xcode command-line tools (`xcode-select --install`).
```
make -C native/MacNotifyWrapper install
```
This cross-compiles both an `arm64` (Apple Silicon, macOS 11+) and an `x86_64` (Intel,
macOS 10.14+) slice and copies them to `runtimes/osx-arm64/native/` and
`runtimes/osx-x64/native/` respectively.
To also produce a universal binary:
```
make -C native/MacNotifyWrapper universal
```
---
## License
MIT. See [LICENSE](LICENSE) for details.
WinToastLib is copyright (c) mohabouje, also distributed under the MIT License.

View file

@ -0,0 +1,127 @@
/**
* MacNotifyWrapper.h
*
* Flat C API for macOS User Notifications (UNUserNotificationCenter).
* Consumed by the Notify.NET managed library via P/Invoke.
*
* All strings are UTF-8, null-terminated.
* Callbacks fire on a background GCD thread managed by UNUserNotificationCenter.
* The caller must not free any memory passed to MNW_ShowNotification before it returns;
* the implementation copies all fields before returning.
*/
#pragma once
#include <stdint.h>
#include <stdbool.h>
#ifdef __cplusplus
extern "C" {
#endif
#ifdef MACNOTIFYWRAPPER_EXPORTS
# define MACNOTIFYAPI __attribute__((visibility("default")))
#else
# define MACNOTIFYAPI
#endif
/* -------------------------------------------------------------------------
* Callback types fired on a background thread from UNUserNotificationCenter.
* None of the callbacks should call back into MNW_* synchronously.
* ------------------------------------------------------------------------- */
typedef void (*MNW_ActivatedCallback) (int64_t notifId);
typedef void (*MNW_ButtonActivatedCallback)(int64_t notifId, int buttonIndex);
typedef void (*MNW_DismissedCallback) (int64_t notifId, int reason);
typedef void (*MNW_FailedCallback) (int64_t notifId);
/* -------------------------------------------------------------------------
* Dismiss reasons (passed to MNW_DismissedCallback)
* ------------------------------------------------------------------------- */
#define MNW_DISMISS_EXPIRED 0 /* Notification auto-expired (note: macOS does not fire this) */
#define MNW_DISMISS_USER 1 /* User swiped or clicked "Close" */
#define MNW_DISMISS_APP_REMOVED 2 /* Removed programmatically via MNW_HideNotification */
/* -------------------------------------------------------------------------
* Audio options
* ------------------------------------------------------------------------- */
#define MNW_AUDIO_DEFAULT 0
#define MNW_AUDIO_SILENT 1
/* -------------------------------------------------------------------------
* Interruption level (macOS 12+; silently ignored on earlier versions)
* ------------------------------------------------------------------------- */
#define MNW_INTERRUPTION_ACTIVE 0
#define MNW_INTERRUPTION_PASSIVE 1
#define MNW_INTERRUPTION_TIME_SENSITIVE 2
#define MNW_INTERRUPTION_CRITICAL 3
/* -------------------------------------------------------------------------
* Handler bundle of four callback function pointers, copied by value.
* Any pointer may be NULL to opt out of that event.
* ------------------------------------------------------------------------- */
typedef struct {
MNW_ActivatedCallback onActivated;
MNW_ButtonActivatedCallback onButtonActivated;
MNW_DismissedCallback onDismissed;
MNW_FailedCallback onFailed;
} MNW_Handler;
/* -------------------------------------------------------------------------
* Notification descriptor.
* All pointer fields may be NULL / empty string where documented.
* The caller must keep all pointed-to memory valid until MNW_ShowNotification returns;
* the implementation deep-copies every string before returning.
* ------------------------------------------------------------------------- */
typedef struct {
const char* title; /* Required, non-empty UTF-8 string */
const char* body; /* Optional body text; NULL or "" → omitted */
const char* imagePath; /* Optional absolute path to an image file */
const char** buttonLabels; /* Optional array of buttonCount UTF-8 strings */
int buttonCount; /* 05 */
int64_t expirationMs; /* Reserved — UNUserNotificationCenter has no per-notification timeout API */
int audioOption; /* MNW_AUDIO_* */
int interruptionLevel; /* MNW_INTERRUPTION_* */
} MNW_NotificationDescriptor;
/* -------------------------------------------------------------------------
* API
* ------------------------------------------------------------------------- */
/**
* Returns true if UNUserNotificationCenter is available (macOS 10.14+).
* Safe to call before MNW_Initialize.
*/
MACNOTIFYAPI bool MNW_IsSupported(void);
/**
* Initialises the notification centre and requests authorisation (alert + sound + badge).
* Blocks until the user grants or denies the authorisation prompt (up to 30 s).
* Returns true if authorisation was granted; false if denied or unavailable.
* Safe to call multiple times; subsequent calls only re-check authorisation status.
*/
MACNOTIFYAPI bool MNW_Initialize(const char* appName);
/**
* Removes all pending and delivered notifications posted by this process and frees
* all internal state. Call once before the process exits.
*/
MACNOTIFYAPI void MNW_Uninitialize(void);
/**
* Posts a notification. Returns a positive opaque int64 identifier on success,
* or a negative value if the descriptor is invalid or the library is not initialised.
* The MNW_Handler callbacks will fire asynchronously from a background thread.
*/
MACNOTIFYAPI int64_t MNW_ShowNotification(
const MNW_NotificationDescriptor* descriptor,
const MNW_Handler* handler);
/**
* Removes a pending or delivered notification by its ID.
* Fires onDismissed(MNW_DISMISS_APP_REMOVED) synchronously before returning.
* Returns true if the notification was found and removed.
*/
MACNOTIFYAPI bool MNW_HideNotification(int64_t notifId);
#ifdef __cplusplus
}
#endif

View file

@ -0,0 +1,444 @@
/**
* MacNotifyWrapper.m
*
* Objective-C implementation of the flat C API declared in MacNotifyWrapper.h.
* Wraps UNUserNotificationCenter (macOS 10.14+).
*
* Compilation requirements:
* clang -fobjc-arc -fvisibility=hidden
* Frameworks: Foundation, UserNotifications
* Minimum deployment target: macOS 10.14 (for x86_64), macOS 11.0 (for arm64)
*
* Threading model:
* MNW_Initialize: blocks on a semaphore waiting for the authorisation dialog.
* MNW_ShowNotification / MNW_HideNotification: thread-safe; UNUserNotificationCenter
* internally serialises requests.
* Callbacks (onActivated, onButtonActivated, onDismissed, onFailed): invoked on a
* background GCD thread managed by UNUserNotificationCenter. Callers must not
* call back into MNW_* synchronously from these callbacks.
*/
#define MACNOTIFYWRAPPER_EXPORTS
#import <Foundation/Foundation.h>
#import <UserNotifications/UserNotifications.h>
#include <stdatomic.h>
#include <stdlib.h>
#include "MacNotifyWrapper.h"
/* -------------------------------------------------------------------------
* Per-notification heap state
* ------------------------------------------------------------------------- */
typedef struct {
int64_t notifId;
MNW_Handler handler;
} NotifState;
/* -------------------------------------------------------------------------
* Delegate
* ------------------------------------------------------------------------- */
@interface MNWDelegate : NSObject <UNUserNotificationCenterDelegate>
@end
/* -------------------------------------------------------------------------
* Process-lifetime globals
* ------------------------------------------------------------------------- */
static MNWDelegate* g_delegate = nil;
static NSLock* g_lock = nil;
/* strId NSValue wrapping NotifState* (heap-allocated) */
static NSMutableDictionary<NSString*, NSValue*>* g_entries = nil;
/* int64 NSNumber strId NSString */
static NSMutableDictionary<NSNumber*, NSString*>* g_idMap = nil;
/* category identifiers we have registered */
static NSMutableSet<NSString*>* g_categoryIds = nil;
/* UNNotificationCategory objects corresponding to the above */
static NSMutableSet<UNNotificationCategory*>* g_categoryObjs = nil;
static _Atomic(int64_t) g_counter = 1;
static bool g_initialized = false;
/* -------------------------------------------------------------------------
* Private helpers
* ------------------------------------------------------------------------- */
static NSString* StringIdFor(int64_t notifId)
{
return [NSString stringWithFormat:@"mnw_%lld", (long long)notifId];
}
/* Stores a newly allocated NotifState in both lookup tables. */
static void StoreEntry(NSString* strId, int64_t notifId, const MNW_Handler* handler)
{
NotifState* s = (NotifState*)malloc(sizeof(NotifState));
s->notifId = notifId;
s->handler = *handler;
NSValue* boxed = [NSValue valueWithPointer:s];
[g_lock lock];
g_entries[strId] = boxed;
g_idMap[@(notifId)] = strId;
[g_lock unlock];
}
/*
* Atomically removes the entry and returns a copy of its state.
* Returns NO if no entry exists (already removed by a prior event).
*/
static BOOL TakeEntry(NSString* strId, NotifState* outState)
{
[g_lock lock];
NSValue* val = g_entries[strId];
if (!val) { [g_lock unlock]; return NO; }
NotifState* s = (NotifState*)[val pointerValue];
*outState = *s;
free(s);
[g_entries removeObjectForKey:strId];
[g_idMap removeObjectForKey:@(outState->notifId)];
[g_lock unlock];
return YES;
}
/*
* Builds a stable, reproducible category identifier from a set of button labels.
* The identifier must survive app restarts so that previously delivered notifications
* whose category was registered in an earlier session are still actionable.
*/
static NSString* BuildCategoryId(const char** labels, int count)
{
if (count == 0) return @"mnw_default";
NSMutableString* s = [NSMutableString stringWithString:@"mnw"];
for (int i = 0; i < count; i++)
[s appendFormat:@"_%s", labels[i]];
/* Hash long identifiers to keep the string short. */
if (s.length > 80)
return [NSString stringWithFormat:@"mnw_%lu", (unsigned long)s.hash];
return [s copy];
}
/*
* Ensures a UNNotificationCategory with the given identifier is registered.
* No-op if the category was already registered this session.
* Thread-safe uses g_lock.
*/
static NSString* EnsureCategory(const char** labels, int count)
{
NSString* catId = BuildCategoryId(labels, count);
[g_lock lock];
BOOL known = [g_categoryIds containsObject:catId];
[g_lock unlock];
if (known) return catId;
/* Build the UNNotificationAction array. */
NSMutableArray<UNNotificationAction*>* actions = [NSMutableArray array];
for (int i = 0; i < count; i++) {
NSString* actionId = [NSString stringWithFormat:@"btn_%d", i];
NSString* title = [NSString stringWithUTF8String:labels[i]];
[actions addObject:[UNNotificationAction
actionWithIdentifier:actionId
title:title
options:UNNotificationActionOptionNone]];
}
/* CustomDismissAction makes the delegate receive dismiss events. */
UNNotificationCategory* category = [UNNotificationCategory
categoryWithIdentifier:catId
actions:actions
intentIdentifiers:@[]
options:UNNotificationCategoryOptionCustomDismissAction];
[g_lock lock];
if (![g_categoryIds containsObject:catId]) {
[g_categoryIds addObject:catId];
[g_categoryObjs addObject:category];
/* setNotificationCategories replaces the full set; pass our accumulated set. */
NSSet* snapshot = [g_categoryObjs copy];
[[UNUserNotificationCenter currentNotificationCenter]
setNotificationCategories:snapshot];
}
[g_lock unlock];
return catId;
}
/* -------------------------------------------------------------------------
* Delegate implementation
* ------------------------------------------------------------------------- */
@implementation MNWDelegate
/*
* Called when a notification arrives while the app is in the foreground.
* Show the banner and play the sound so that console/background processes
* still see the notification visually.
*/
- (void)userNotificationCenter:(UNUserNotificationCenter*)center
willPresentNotification:(UNNotification*)notification
withCompletionHandler:(void(^)(UNNotificationPresentationOptions))completionHandler
{
if (@available(macOS 12.0, *)) {
completionHandler(UNNotificationPresentationOptionBanner
| UNNotificationPresentationOptionSound
| UNNotificationPresentationOptionList);
} else {
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
completionHandler(UNNotificationPresentationOptionAlert
| UNNotificationPresentationOptionSound);
#pragma clang diagnostic pop
}
}
/*
* Called when the user interacts with (or dismisses) a notification.
* This is the single delivery point for all notification responses;
* we route to the appropriate managed callback and then clean up.
*
* Note: on macOS, a body-tap or button-tap IS the terminal event.
* UNUserNotificationCenter does NOT subsequently fire a dismiss event
* after an action response. We therefore release after every interaction.
*/
- (void)userNotificationCenter:(UNUserNotificationCenter*)center
didReceiveNotificationResponse:(UNNotificationResponse*)response
withCompletionHandler:(void(^)(void))completionHandler
{
NSString* strId = response.notification.request.identifier;
NotifState state = {0};
if (!TakeEntry(strId, &state)) {
/* Already handled (e.g. MNW_HideNotification was called first). */
completionHandler();
return;
}
NSString* actionId = response.actionIdentifier;
if ([actionId isEqualToString:UNNotificationDefaultActionIdentifier]) {
/* User tapped the notification body. */
if (state.handler.onActivated)
state.handler.onActivated(state.notifId);
} else if ([actionId isEqualToString:UNNotificationDismissActionIdentifier]) {
/* User dismissed (swipe/close). Requires CustomDismissAction on category. */
if (state.handler.onDismissed)
state.handler.onDismissed(state.notifId, MNW_DISMISS_USER);
} else if ([actionId hasPrefix:@"btn_"]) {
/* User tapped an action button. */
int idx = (int)[[actionId substringFromIndex:4] integerValue];
if (state.handler.onButtonActivated)
state.handler.onButtonActivated(state.notifId, idx);
} else {
/* Unknown action treat as activation. */
if (state.handler.onActivated)
state.handler.onActivated(state.notifId);
}
completionHandler();
}
@end
/* -------------------------------------------------------------------------
* API implementation
* ------------------------------------------------------------------------- */
bool MNW_IsSupported(void)
{
if (@available(macOS 10.14, *)) return true;
return false;
}
bool MNW_Initialize(const char* appName)
{
(void)appName; /* appName is informational; the bundle identifier governs delivery. */
if (!MNW_IsSupported()) return false;
/* One-time setup of global state. */
static dispatch_once_t once;
dispatch_once(&once, ^{
g_lock = [[NSLock alloc] init];
g_entries = [NSMutableDictionary dictionary];
g_idMap = [NSMutableDictionary dictionary];
g_categoryIds = [NSMutableSet set];
g_categoryObjs = [NSMutableSet set];
g_delegate = [[MNWDelegate alloc] init];
[[UNUserNotificationCenter currentNotificationCenter] setDelegate:g_delegate];
/* Pre-register the default (no-button) category. */
EnsureCategory(NULL, 0);
});
if (g_initialized) {
/* On re-initialisation just re-check authorisation status. */
__block bool ok = false;
dispatch_semaphore_t sem = dispatch_semaphore_create(0);
[[UNUserNotificationCenter currentNotificationCenter]
getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings* s) {
ok = (s.authorizationStatus == UNAuthorizationStatusAuthorized
|| s.authorizationStatus == UNAuthorizationStatusProvisional);
dispatch_semaphore_signal(sem);
}];
dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC));
return ok;
}
/* Request authorisation. Blocks until the user responds (or times out). */
__block bool granted = false;
dispatch_semaphore_t sem = dispatch_semaphore_create(0);
[[UNUserNotificationCenter currentNotificationCenter]
requestAuthorizationWithOptions:(UNAuthorizationOptionAlert
| UNAuthorizationOptionSound
| UNAuthorizationOptionBadge)
completionHandler:^(BOOL g, NSError* __unused err) {
granted = (bool)g;
dispatch_semaphore_signal(sem);
}];
dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, 30 * NSEC_PER_SEC));
g_initialized = granted;
return granted;
}
void MNW_Uninitialize(void)
{
if (!g_lock) return;
/* Remove all notifications posted by this process. */
UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter];
[center removeAllPendingNotificationRequests];
[center removeAllDeliveredNotifications];
/* Free all live state without firing callbacks. */
[g_lock lock];
for (NSValue* val in g_entries.allValues) {
NotifState* s = (NotifState*)[val pointerValue];
free(s);
}
[g_entries removeAllObjects];
[g_idMap removeAllObjects];
[g_lock unlock];
g_initialized = false;
}
int64_t MNW_ShowNotification(
const MNW_NotificationDescriptor* descriptor,
const MNW_Handler* handler)
{
if (!descriptor || !handler) return -1;
if (!descriptor->title || descriptor->title[0]=='\0') return -2;
if (!g_lock) return -3; /* Not initialised */
int64_t notifId = atomic_fetch_add_explicit(&g_counter, 1, memory_order_relaxed);
NSString* strId = StringIdFor(notifId);
/* --- Content --------------------------------------------------------- */
UNMutableNotificationContent* content = [[UNMutableNotificationContent alloc] init];
content.title = [NSString stringWithUTF8String:descriptor->title];
if (descriptor->body && descriptor->body[0] != '\0')
content.body = [NSString stringWithUTF8String:descriptor->body];
/* --- Sound ----------------------------------------------------------- */
content.sound = (descriptor->audioOption == MNW_AUDIO_SILENT)
? nil
: [UNNotificationSound defaultSound];
/* --- Interruption level (macOS 12+) ---------------------------------- */
if (@available(macOS 12.0, *)) {
switch (descriptor->interruptionLevel) {
case MNW_INTERRUPTION_PASSIVE:
content.interruptionLevel = UNNotificationInterruptionLevelPassive;
break;
case MNW_INTERRUPTION_TIME_SENSITIVE:
content.interruptionLevel = UNNotificationInterruptionLevelTimeSensitive;
break;
case MNW_INTERRUPTION_CRITICAL:
content.interruptionLevel = UNNotificationInterruptionLevelCritical;
break;
default:
content.interruptionLevel = UNNotificationInterruptionLevelActive;
break;
}
}
/* --- Image attachment ------------------------------------------------ */
if (descriptor->imagePath && descriptor->imagePath[0] != '\0') {
NSString* path = [NSString stringWithUTF8String:descriptor->imagePath];
NSURL* url = [NSURL fileURLWithPath:path];
NSError* err = nil;
UNNotificationAttachment* att = [UNNotificationAttachment
attachmentWithIdentifier:@"image" URL:url options:nil error:&err];
if (att)
content.attachments = @[att];
/* If attachment fails (file missing, unsupported format), continue without it. */
}
/* --- Category / buttons --------------------------------------------- */
content.categoryIdentifier = EnsureCategory(descriptor->buttonLabels,
descriptor->buttonCount);
/* --- Trigger --------------------------------------------------------- */
/*
* UNTimeIntervalNotificationTrigger requires timeInterval > 0.
* Use 0.1 s for "fire immediately" imperceptible to the user.
*/
UNTimeIntervalNotificationTrigger* trigger =
[UNTimeIntervalNotificationTrigger triggerWithTimeInterval:0.1 repeats:NO];
/* --- Register state BEFORE scheduling so no callback is missed ------- */
StoreEntry(strId, notifId, handler);
/* --- Schedule -------------------------------------------------------- */
UNNotificationRequest* req = [UNNotificationRequest
requestWithIdentifier:strId
content:content
trigger:trigger];
[[UNUserNotificationCenter currentNotificationCenter]
addNotificationRequest:req
withCompletionHandler:^(NSError* error) {
if (!error) return;
/* Delivery failed fire onFailed and clean up. */
NotifState state = {0};
if (TakeEntry(strId, &state)) {
if (state.handler.onFailed)
state.handler.onFailed(state.notifId);
}
}];
return notifId;
}
bool MNW_HideNotification(int64_t notifId)
{
if (!g_lock) return false;
[g_lock lock];
NSString* strId = g_idMap[@(notifId)];
[g_lock unlock];
if (!strId) return false;
NSArray<NSString*>* ids = @[strId];
UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter];
[center removePendingNotificationRequestsWithIdentifiers:ids];
[center removeDeliveredNotificationsWithIdentifiers:ids];
/* Fire dismissed callback synchronously before returning. */
NotifState state = {0};
if (TakeEntry(strId, &state)) {
if (state.handler.onDismissed)
state.handler.onDismissed(state.notifId, MNW_DISMISS_APP_REMOVED);
}
return true;
}

View file

@ -0,0 +1,57 @@
# Makefile for MacNotifyWrapper
#
# Builds a thin Objective-C dylib wrapping UNUserNotificationCenter.
# Produces separate arm64 and x86_64 slices, plus an optional universal binary.
#
# Usage:
# make # build all slices
# make install # copy dylibs into ../../runtimes/
# make universal # also build universal binary
# make clean
SDK := $(shell xcrun --show-sdk-path)
CC := clang
SRC := MacNotifyWrapper.m
CFLAGS := -fobjc-arc -fvisibility=hidden -O2 -Wall -Wextra \
-isysroot $(SDK)
LDFLAGS := -dynamiclib \
-framework Foundation \
-framework UserNotifications \
-install_name @rpath/libMacNotifyWrapper.dylib
ARM64_TARGET := arm64-apple-macos11.0
X64_TARGET := x86_64-apple-macos10.14
OUT_ARM64 := osx-arm64/libMacNotifyWrapper.dylib
OUT_X64 := osx-x64/libMacNotifyWrapper.dylib
OUT_UNIVERSAL := osx-universal/libMacNotifyWrapper.dylib
RUNTIME_ARM64 := ../../runtimes/osx-arm64/native
RUNTIME_X64 := ../../runtimes/osx-x64/native
.PHONY: all universal install clean
all: $(OUT_ARM64) $(OUT_X64)
universal: $(OUT_UNIVERSAL)
$(OUT_ARM64): $(SRC) MacNotifyWrapper.h
mkdir -p osx-arm64
$(CC) $(CFLAGS) -arch arm64 -target $(ARM64_TARGET) $(LDFLAGS) -o $@ $<
$(OUT_X64): $(SRC) MacNotifyWrapper.h
mkdir -p osx-x64
$(CC) $(CFLAGS) -arch x86_64 -target $(X64_TARGET) $(LDFLAGS) -o $@ $<
$(OUT_UNIVERSAL): $(OUT_ARM64) $(OUT_X64)
mkdir -p osx-universal
lipo -create -output $@ $(OUT_ARM64) $(OUT_X64)
install: $(OUT_ARM64) $(OUT_X64)
mkdir -p $(RUNTIME_ARM64) $(RUNTIME_X64)
cp $(OUT_ARM64) $(RUNTIME_ARM64)/
cp $(OUT_X64) $(RUNTIME_X64)/
@echo "Installed to runtimes/"
clean:
rm -rf osx-arm64 osx-x64 osx-universal

View file

@ -0,0 +1,332 @@
/**
* WinToastWrapper.cpp
*
* Implementation of the flat C API declared in WinToastWrapper.h.
* Links against WinToastLib (wintoastlib.h / wintoastlib.cpp from mohabouje/WinToast).
*
* Compilation requirements:
* - MSVC 2019 or later, C++17
* - /W3 /EHsc /MT (static CRT to avoid runtime dependency)
* - Link: Ole32.lib Shlwapi.lib Shell32.lib
* - _WIN32_WINNT >= 0x0602 (Windows 8)
* - WINTOASTWRAPPER_EXPORTS defined in the DLL project
*
* Threading model:
* WNT_Initialize and WNT_ShowToast MUST be called from an STA thread.
* Callbacks fire on a WinRT thread-pool thread they must not call back into
* WNT_ShowToast or WNT_HideToast directly; the .NET side re-queues on the STA.
*/
#define WINTOASTWRAPPER_EXPORTS
#define NOMINMAX
#include <Windows.h>
#include <string>
#include <memory>
#include <unordered_map>
#include <mutex>
#include "WinToastWrapper.h"
#include "vendor/wintoastlib.h"
// WinToast is a class inside the WinToastLib namespace — not a nested namespace.
// "using namespace WinToastLib" brings WinToast, WinToastTemplate, IWinToastHandler,
// WinToastError, etc. into the global scope.
using namespace WinToastLib;
/* -------------------------------------------------------------------------
* Handler implementation
* -------------------------------------------------------------------------
* WinToastLib wraps the IWinToastHandler* in a std::shared_ptr<IWinToastHandler>
* at the start of showToast (wintoastlib.cpp line ~721), adopting ownership.
* WinToastLib will delete the handler when the shared_ptr is destroyed.
* We must NEVER delete a handler ourselves doing so would cause a double-free.
*
* g_handlers is kept only for lookup (e.g. WNT_HideToast needs the handler for
* the toast-ID-to-handler mapping that WinToastLib itself tracks). Entries are
* removed from g_handlers when a toast ends, but the pointed-to object is never
* freed here.
* ------------------------------------------------------------------------- */
class WinToastHandlerImpl : public IWinToastHandler
{
public:
INT64 m_toastId;
WNT_Handler m_handler; // copied by value
WinToastHandlerImpl(INT64 toastId, const WNT_Handler& handler)
: m_toastId(toastId), m_handler(handler) {}
// WinToastDismissalReason is a nested enum of IWinToastHandler.
// It is accessible by unqualified name within this derived class.
void toastActivated() const override
{
if (m_handler.onActivated)
m_handler.onActivated(m_toastId);
}
void toastActivated(int actionIndex) const override
{
if (m_handler.onButtonActivated)
m_handler.onButtonActivated(m_toastId, actionIndex);
}
// Input response (text box) — treat as a plain activation for our purposes.
void toastActivated(std::wstring /*response*/) const override
{
if (m_handler.onActivated)
m_handler.onActivated(m_toastId);
}
void toastDismissed(WinToastDismissalReason state) const override
{
if (m_handler.onDismissed)
{
int reason = 0;
switch (state)
{
case WinToastDismissalReason::UserCanceled: reason = 0; break;
case WinToastDismissalReason::ApplicationHidden: reason = 1; break;
case WinToastDismissalReason::TimedOut: reason = 2; break;
}
m_handler.onDismissed(m_toastId, reason);
}
ScheduleDelete(m_toastId);
}
void toastFailed() const override
{
if (m_handler.onFailed)
m_handler.onFailed(m_toastId);
ScheduleDelete(m_toastId);
}
private:
// Removes this toast from the lookup table when its lifecycle ends.
// Does NOT delete the handler — WinToastLib owns it via shared_ptr.
static void ScheduleDelete(INT64 toastId);
};
/* -------------------------------------------------------------------------
* Global state
* ------------------------------------------------------------------------- */
static std::mutex g_mutex;
static std::unordered_map<INT64, WinToastHandlerImpl*> g_handlers; // live toasts (no ownership)
// Removes the entry for toastId from g_handlers.
// Does NOT delete the handler — WinToastLib owns it via shared_ptr.
void WinToastHandlerImpl::ScheduleDelete(INT64 toastId)
{
std::lock_guard<std::mutex> lock(g_mutex);
g_handlers.erase(toastId);
}
/* -------------------------------------------------------------------------
* Helpers
* ------------------------------------------------------------------------- */
static WinToastTemplate::WinToastTemplateType SelectTemplateType(
bool hasImage, bool hasBody)
{
if (hasImage)
return hasBody
? WinToastTemplate::ImageAndText02 // image + title + body
: WinToastTemplate::ImageAndText01; // image + title only
return hasBody
? WinToastTemplate::Text02 // title + body
: WinToastTemplate::Text01; // title only
}
// ---------------------------------------------------------------------------
// SEH-safe wrappers for WinToastLib calls that can trigger AVs from WinRT.
//
// MSVC rule: __try cannot appear in a function that has local objects with
// destructors (WinToastTemplate, std::lock_guard, std::wstring, etc.).
// These helpers contain ONLY plain scalars and raw pointers so __try is legal.
// Any structured exception (AV, invalid handle, COM fault) is caught here and
// converted to an error-code return, preventing it from ever reaching the CLR
// where a corrupted-state exception would terminate the process.
// ---------------------------------------------------------------------------
// Calls WinToast::showToast with full SEH protection.
// tmpl is passed as a const pointer to avoid any destructor in this frame.
static INT64 SafeShowToast(
const WinToastTemplate* tmpl,
IWinToastHandler* handler,
WinToast::WinToastError* error)
{
INT64 result = static_cast<INT64>(WinToast::WinToastError::UnknownError);
__try
{
result = WinToast::instance()->showToast(*tmpl, handler, error);
}
__except (EXCEPTION_EXECUTE_HANDLER)
{
// Structured exception (AV, etc.) from WinRT internals — return failure.
result = static_cast<INT64>(WinToast::WinToastError::UnknownError);
}
return result;
}
// Calls WinToast::hideToast with full SEH protection.
static BOOL SafeHideToast(INT64 toastId)
{
BOOL result = FALSE;
__try
{
result = WinToast::instance()->hideToast(toastId) ? TRUE : FALSE;
}
__except (EXCEPTION_EXECUTE_HANDLER)
{
result = FALSE;
}
return result;
}
/* -------------------------------------------------------------------------
* Exported API
* ------------------------------------------------------------------------- */
extern "C" {
NOTIFYAPI BOOL WNT_IsCompatible(void)
{
return WinToast::isCompatible() ? TRUE : FALSE;
}
NOTIFYAPI BOOL WNT_Initialize(const wchar_t* appName, const wchar_t* appUserModelId)
{
if (!WinToast::isCompatible())
return FALSE;
WinToast* instance = WinToast::instance();
instance->setAppName(appName);
instance->setAppUserModelId(appUserModelId);
// SHORTCUT_POLICY_REQUIRE_CREATE: create a Start-Menu shortcut with this AUMI
// automatically if one does not already exist. Required for unpackaged (Win32)
// apps so that toasts persist in the Action Centre between sessions.
instance->setShortcutPolicy(WinToast::SHORTCUT_POLICY_REQUIRE_CREATE);
WinToast::WinToastError error = WinToast::WinToastError::NoError;
if (!instance->initialize(&error))
return FALSE;
{
std::lock_guard<std::mutex> lock(g_mutex);
// Clear lookup table from a previous Initialize/Uninitialize cycle.
// Do NOT delete handlers — WinToastLib owns them via shared_ptr.
g_handlers.clear();
}
return TRUE;
}
NOTIFYAPI void WNT_Uninitialize(void)
{
std::lock_guard<std::mutex> lock(g_mutex);
// Do NOT delete handlers — WinToastLib owns them via shared_ptr.
g_handlers.clear();
}
NOTIFYAPI INT64 WNT_ShowToast(
const WNT_ToastDescriptor* descriptor,
const WNT_Handler* handler)
{
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';
WinToastTemplate tmpl(SelectTemplateType(hasImage, hasBody));
tmpl.setTextField(descriptor->title, WinToastTemplate::FirstLine);
if (hasBody)
tmpl.setTextField(descriptor->body, WinToastTemplate::SecondLine);
if (hasImage)
tmpl.setImagePath(descriptor->imagePath);
for (int i = 0; i < descriptor->buttonCount; ++i)
{
if (descriptor->buttonLabels && descriptor->buttonLabels[i])
tmpl.addAction(descriptor->buttonLabels[i]);
}
if (descriptor->expirationMs > 0)
tmpl.setExpiration(descriptor->expirationMs);
switch (descriptor->audioOption)
{
case WNT_AUDIO_SILENT:
tmpl.setAudioOption(WinToastTemplate::AudioOption::Silent);
break;
case WNT_AUDIO_LOOP:
tmpl.setAudioOption(WinToastTemplate::AudioOption::Loop);
break;
default:
tmpl.setAudioOption(WinToastTemplate::AudioOption::Default);
break;
}
switch (descriptor->scenario)
{
case WNT_SCENARIO_ALARM:
tmpl.setScenario(WinToastTemplate::Scenario::Alarm);
break;
case WNT_SCENARIO_REMINDER:
tmpl.setScenario(WinToastTemplate::Scenario::Reminder);
break;
case WNT_SCENARIO_INCOMING_CALL:
tmpl.setScenario(WinToastTemplate::Scenario::IncomingCall);
break;
default:
tmpl.setScenario(WinToastTemplate::Scenario::Default);
break;
}
// Allocate the handler. Use nothrow so a failed allocation returns an error
// code rather than throwing std::bad_alloc through the extern "C" boundary.
auto* handlerImpl = new (std::nothrow) WinToastHandlerImpl(0 /* filled in below */, *handler);
if (!handlerImpl)
return static_cast<INT64>(WinToast::WinToastError::UnknownError);
WinToast::WinToastError error = WinToast::WinToastError::NoError;
INT64 toastId = SafeShowToast(&tmpl, handlerImpl, &error);
if (toastId < 0)
{
// showToast failed — WinToastLib created a shared_ptr internally which
// will delete handlerImpl when it destructs. Do NOT delete it here.
return toastId; // already the error code cast from WinToastError
}
handlerImpl->m_toastId = toastId;
{
std::lock_guard<std::mutex> lock(g_mutex);
g_handlers[toastId] = handlerImpl;
}
return toastId;
}
NOTIFYAPI BOOL WNT_HideToast(INT64 toastId)
{
BOOL ok = SafeHideToast(toastId);
if (ok)
{
// hideToast may fire the ApplicationHidden dismissal callback synchronously,
// which calls ScheduleDelete and removes from g_handlers. Erase here too in
// case the callback was skipped. Do NOT delete — WinToastLib owns the handler.
std::lock_guard<std::mutex> lock(g_mutex);
g_handlers.erase(toastId);
}
return ok ? TRUE : FALSE;
}
} /* extern "C" */

View file

@ -0,0 +1,142 @@
/**
* WinToastWrapper.h
*
* Flat C API that wraps WinToastLib (https://github.com/mohabouje/WinToast).
* This header is the sole interface between the managed P/Invoke layer and
* the C++ WinToastLib implementation.
*
* ABI contract (must stay in sync with WinToastNative.cs):
* - All strings are wchar_t* (UTF-16LE, null-terminated).
* - Callbacks are called on a WinRT thread-pool thread, NOT the calling STA thread.
* - WNT_ShowToast must be called from the STA thread that called WNT_Initialize.
* - WNT_HideToast must be called from the same STA thread.
*/
#pragma once
#include <Windows.h>
#ifdef WINTOASTWRAPPER_EXPORTS
#define NOTIFYAPI __declspec(dllexport)
#else
#define NOTIFYAPI __declspec(dllimport)
#endif
#ifdef __cplusplus
extern "C" {
#endif
/* -------------------------------------------------------------------------
* Callback function pointer types
* ------------------------------------------------------------------------- */
/** Called when the user clicks the notification body (no button). */
typedef void (CALLBACK* WNT_ActivatedCallback)(INT64 toastId);
/** Called when the user clicks one of the action buttons. */
typedef void (CALLBACK* WNT_ButtonActivatedCallback)(INT64 toastId, int buttonIndex);
/**
* Called when the notification is dismissed.
* reason: 0 = UserCancelled, 1 = ApplicationHidden, 2 = TimedOut
*/
typedef void (CALLBACK* WNT_DismissedCallback)(INT64 toastId, int reason);
/** Called when the notification fails to display. */
typedef void (CALLBACK* WNT_FailedCallback)(INT64 toastId);
/* -------------------------------------------------------------------------
* Structs (must match StructLayout in WinToastNative.cs exactly)
* ------------------------------------------------------------------------- */
typedef enum _WNT_Scenario {
WNT_SCENARIO_DEFAULT = 0,
WNT_SCENARIO_ALARM = 1,
WNT_SCENARIO_REMINDER = 2,
WNT_SCENARIO_INCOMING_CALL = 3
} WNT_Scenario;
typedef enum _WNT_AudioOption {
WNT_AUDIO_DEFAULT = 0,
WNT_AUDIO_SILENT = 1,
WNT_AUDIO_LOOP = 2
} WNT_AudioOption;
/**
* 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.
*/
typedef struct _WNT_ToastDescriptor {
const wchar_t* title; /* required */
const wchar_t* body; /* nullable */
const wchar_t* imagePath; /* nullable — absolute path to an image file */
const wchar_t** buttonLabels; /* nullable — array of buttonCount wchar_t* */
int buttonCount;
long long expirationMs; /* 0 = platform default */
WNT_Scenario scenario;
WNT_AudioOption audioOption;
} WNT_ToastDescriptor;
/**
* Bundle of callback function pointers for one notification.
* The struct is copied by value inside WNT_ShowToast; callers need not keep it alive.
* Individual function pointers may be NULL to skip that event.
*/
typedef struct _WNT_Handler {
WNT_ActivatedCallback onActivated;
WNT_ButtonActivatedCallback onButtonActivated;
WNT_DismissedCallback onDismissed;
WNT_FailedCallback onFailed;
} WNT_Handler;
/* -------------------------------------------------------------------------
* API functions
* ------------------------------------------------------------------------- */
/**
* Initialises WinToastLib. Must be called once from an STA thread.
*
* @param appName Human-readable name shown in the Action Centre.
* @param appUserModelId AppUserModelId (AUMI). The wrapper creates a Start-Menu
* shortcut carrying this AUMI automatically if one does not
* already exist.
* @return TRUE on success.
*/
NOTIFYAPI BOOL WNT_Initialize(const wchar_t* appName, const wchar_t* appUserModelId);
/**
* Releases all WinToastLib resources. Call from the same STA thread as WNT_Initialize.
*/
NOTIFYAPI void WNT_Uninitialize(void);
/**
* Returns TRUE if WinToast is supported on the current Windows version (requires Win 8+).
* Check this before calling WNT_Initialize.
*/
NOTIFYAPI BOOL WNT_IsCompatible(void);
/**
* Displays a toast notification.
*
* Must be called from the STA thread that called WNT_Initialize.
*
* @param descriptor Pointer to a WNT_ToastDescriptor (read-only during the call).
* @param handler Pointer to a WNT_Handler with callback function pointers.
* @return A positive INT64 toast ID on success, or a negative WinToastError code on failure.
*/
NOTIFYAPI INT64 WNT_ShowToast(const WNT_ToastDescriptor* descriptor, const WNT_Handler* handler);
/**
* Programmatically dismisses a previously shown toast.
*
* Must be called from the same STA thread as WNT_ShowToast.
*
* @param toastId The ID returned by WNT_ShowToast.
* @return TRUE if the toast was successfully hidden.
*/
NOTIFYAPI BOOL WNT_HideToast(INT64 toastId);
#ifdef __cplusplus
} /* extern "C" */
#endif

View file

@ -0,0 +1,92 @@
<?xml version="1.0" encoding="utf-8"?>
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
<ItemGroup Label="ProjectConfigurations">
<ProjectConfiguration Include="Release|Win32">
<Configuration>Release</Configuration>
<Platform>Win32</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|x64">
<Configuration>Release</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Release|ARM64">
<Configuration>Release</Configuration>
<Platform>ARM64</Platform>
</ProjectConfiguration>
<ProjectConfiguration Include="Debug|x64">
<Configuration>Debug</Configuration>
<Platform>x64</Platform>
</ProjectConfiguration>
</ItemGroup>
<PropertyGroup Label="Globals">
<VCProjectVersion>17.0</VCProjectVersion>
<ProjectGuid>{C1D2E3F4-A5B6-7890-CDEF-012345678901}</ProjectGuid>
<RootNamespace>WinToastWrapper</RootNamespace>
<WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
<PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration">
<ConfigurationType>DynamicLibrary</ConfigurationType>
<UseDebugLibraries>false</UseDebugLibraries>
<PlatformToolset>v143</PlatformToolset>
<WholeProgramOptimization>true</WholeProgramOptimization>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration">
<ConfigurationType>DynamicLibrary</ConfigurationType>
<UseDebugLibraries>true</UseDebugLibraries>
<PlatformToolset>v143</PlatformToolset>
<CharacterSet>Unicode</CharacterSet>
</PropertyGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
<PropertyGroup>
<!-- Output directly to the runtimes folder consumed by the .NET project -->
<OutDir Condition="'$(Platform)'=='x64'">..\..\runtimes\win-x64\native\</OutDir>
<OutDir Condition="'$(Platform)'=='Win32'">..\..\runtimes\win-x86\native\</OutDir>
<OutDir Condition="'$(Platform)'=='ARM64'">..\..\runtimes\win-arm64\native\</OutDir>
<TargetName>WinToastWrapper</TargetName>
</PropertyGroup>
<ItemDefinitionGroup>
<ClCompile>
<WarningLevel>Level3</WarningLevel>
<LanguageStandard>stdcpp17</LanguageStandard>
<PreprocessorDefinitions>
WINTOASTWRAPPER_EXPORTS;
_WINDOWS;
_WIN32_WINNT=0x0602;
WIN32_LEAN_AND_MEAN;
NOMINMAX;
%(PreprocessorDefinitions)
</PreprocessorDefinitions>
<RuntimeLibrary Condition="'$(Configuration)'=='Release'">MultiThreaded</RuntimeLibrary>
<RuntimeLibrary Condition="'$(Configuration)'=='Debug'">MultiThreadedDebug</RuntimeLibrary>
<ExceptionHandling>Sync</ExceptionHandling>
<AdditionalIncludeDirectories>$(ProjectDir)vendor;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
</ClCompile>
<Link>
<SubSystem>Windows</SubSystem>
<AdditionalDependencies>Ole32.lib;Shlwapi.lib;Shell32.lib;%(AdditionalDependencies)</AdditionalDependencies>
</Link>
</ItemDefinitionGroup>
<ItemGroup>
<ClInclude Include="WinToastWrapper.h" />
<ClInclude Include="vendor\wintoastlib.h" />
</ItemGroup>
<ItemGroup>
<ClCompile Include="WinToastWrapper.cpp" />
<ClCompile Include="vendor\wintoastlib.cpp" />
</ItemGroup>
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
</Project>

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,318 @@
/**
* MIT License
*
* Copyright (C) 2016-2023 WinToast v1.3.0 - Mohammed Boujemaoui <mohabouje@gmail.com>
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
* the Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
#ifndef WINTOASTLIB_H
#define WINTOASTLIB_H
#include <Windows.h>
#include <sdkddkver.h>
#include <WinUser.h>
#include <ShObjIdl.h>
#include <wrl/implements.h>
#include <wrl/event.h>
#include <windows.ui.notifications.h>
#include <strsafe.h>
#include <Psapi.h>
#include <ShlObj.h>
#include <roapi.h>
#include <propvarutil.h>
#include <functiondiscoverykeys.h>
#include <iostream>
#include <winstring.h>
#include <string.h>
#include <vector>
#include <map>
#include <memory>
using namespace Microsoft::WRL;
using namespace ABI::Windows::Data::Xml::Dom;
using namespace ABI::Windows::Foundation;
using namespace ABI::Windows::UI::Notifications;
using namespace Windows::Foundation;
namespace WinToastLib {
void setDebugOutputEnabled(bool enabled);
class IWinToastHandler {
public:
enum WinToastDismissalReason {
UserCanceled = ToastDismissalReason::ToastDismissalReason_UserCanceled,
ApplicationHidden = ToastDismissalReason::ToastDismissalReason_ApplicationHidden,
TimedOut = ToastDismissalReason::ToastDismissalReason_TimedOut
};
virtual ~IWinToastHandler() = default;
virtual void toastActivated() const = 0;
virtual void toastActivated(int actionIndex) const = 0;
virtual void toastActivated(std::wstring response) const = 0;
virtual void toastDismissed(WinToastDismissalReason state) const = 0;
virtual void toastFailed() const = 0;
};
class WinToastTemplate {
public:
enum class Scenario { Default, Alarm, IncomingCall, Reminder };
enum Duration { System, Short, Long };
enum AudioOption { Default = 0, Silent, Loop };
enum TextField { FirstLine = 0, SecondLine, ThirdLine };
enum WinToastTemplateType {
ImageAndText01 = ToastTemplateType::ToastTemplateType_ToastImageAndText01,
ImageAndText02 = ToastTemplateType::ToastTemplateType_ToastImageAndText02,
ImageAndText03 = ToastTemplateType::ToastTemplateType_ToastImageAndText03,
ImageAndText04 = ToastTemplateType::ToastTemplateType_ToastImageAndText04,
Text01 = ToastTemplateType::ToastTemplateType_ToastText01,
Text02 = ToastTemplateType::ToastTemplateType_ToastText02,
Text03 = ToastTemplateType::ToastTemplateType_ToastText03,
Text04 = ToastTemplateType::ToastTemplateType_ToastText04
};
enum AudioSystemFile {
DefaultSound,
IM,
Mail,
Reminder,
SMS,
Alarm,
Alarm2,
Alarm3,
Alarm4,
Alarm5,
Alarm6,
Alarm7,
Alarm8,
Alarm9,
Alarm10,
Call,
Call1,
Call2,
Call3,
Call4,
Call5,
Call6,
Call7,
Call8,
Call9,
Call10,
};
enum CropHint {
Square,
Circle,
};
WinToastTemplate(_In_ WinToastTemplateType type = WinToastTemplateType::ImageAndText02);
~WinToastTemplate();
void setFirstLine(_In_ std::wstring const& text);
void setSecondLine(_In_ std::wstring const& text);
void setThirdLine(_In_ std::wstring const& text);
void setTextField(_In_ std::wstring const& txt, _In_ TextField pos);
void setAttributionText(_In_ std::wstring const& attributionText);
void setImagePath(_In_ std::wstring const& imgPath, _In_ CropHint cropHint = CropHint::Square);
void setHeroImagePath(_In_ std::wstring const& imgPath, _In_ bool inlineImage = false);
void setAudioPath(_In_ WinToastTemplate::AudioSystemFile audio);
void setAudioPath(_In_ std::wstring const& audioPath);
void setAudioOption(_In_ WinToastTemplate::AudioOption audioOption);
void setDuration(_In_ Duration duration);
void setExpiration(_In_ INT64 millisecondsFromNow);
void setScenario(_In_ Scenario scenario);
void addAction(_In_ std::wstring const& label);
void addInput();
std::size_t textFieldsCount() const;
std::size_t actionsCount() const;
bool hasImage() const;
bool hasHeroImage() const;
std::vector<std::wstring> const& textFields() const;
std::wstring const& textField(_In_ TextField pos) const;
std::wstring const& actionLabel(_In_ std::size_t pos) const;
std::wstring const& imagePath() const;
std::wstring const& heroImagePath() const;
std::wstring const& audioPath() const;
std::wstring const& attributionText() const;
std::wstring const& scenario() const;
INT64 expiration() const;
WinToastTemplateType type() const;
WinToastTemplate::AudioOption audioOption() const;
Duration duration() const;
bool isToastGeneric() const;
bool isInlineHeroImage() const;
bool isCropHintCircle() const;
bool isInput() const;
private:
bool _hasInput{false};
std::vector<std::wstring> _textFields{};
std::vector<std::wstring> _actions{};
std::wstring _imagePath{};
std::wstring _heroImagePath{};
bool _inlineHeroImage{false};
std::wstring _audioPath{};
std::wstring _attributionText{};
std::wstring _scenario{L"Default"};
INT64 _expiration{0};
AudioOption _audioOption{WinToastTemplate::AudioOption::Default};
WinToastTemplateType _type{WinToastTemplateType::Text01};
Duration _duration{Duration::System};
CropHint _cropHint{CropHint::Square};
};
class WinToast {
public:
enum WinToastError {
NoError = 0,
NotInitialized,
SystemNotSupported,
ShellLinkNotCreated,
InvalidAppUserModelID,
InvalidParameters,
InvalidHandler,
NotDisplayed,
UnknownError
};
enum ShortcutResult {
SHORTCUT_UNCHANGED = 0,
SHORTCUT_WAS_CHANGED = 1,
SHORTCUT_WAS_CREATED = 2,
SHORTCUT_MISSING_PARAMETERS = -1,
SHORTCUT_INCOMPATIBLE_OS = -2,
SHORTCUT_COM_INIT_FAILURE = -3,
SHORTCUT_CREATE_FAILED = -4
};
enum ShortcutPolicy {
/* Don't check, create, or modify a shortcut. */
SHORTCUT_POLICY_IGNORE = 0,
/* Require a shortcut with matching AUMI, don't create or modify an existing one. */
SHORTCUT_POLICY_REQUIRE_NO_CREATE = 1,
/* Require a shortcut with matching AUMI, create if missing, modify if not matching. This is the default. */
SHORTCUT_POLICY_REQUIRE_CREATE = 2,
};
WinToast(void);
virtual ~WinToast();
static WinToast* instance();
static bool isCompatible();
static bool isSupportingModernFeatures();
static bool isWin10AnniversaryOrHigher();
static std::wstring configureAUMI(_In_ std::wstring const& companyName, _In_ std::wstring const& productName,
_In_ std::wstring const& subProduct = std::wstring(),
_In_ std::wstring const& versionInformation = std::wstring());
static std::wstring const& strerror(_In_ WinToastError error);
virtual bool initialize(_Out_opt_ WinToastError* error = nullptr);
virtual bool isInitialized() const;
virtual bool hideToast(_In_ INT64 id);
virtual INT64 showToast(_In_ WinToastTemplate const& toast, _In_ IWinToastHandler* eventHandler,
_Out_opt_ WinToastError* error = nullptr);
virtual void clear();
virtual enum ShortcutResult createShortcut();
std::wstring const& appName() const;
std::wstring const& appUserModelId() const;
void setAppUserModelId(_In_ std::wstring const& aumi);
void setAppName(_In_ std::wstring const& appName);
void setShortcutPolicy(_In_ ShortcutPolicy policy);
protected:
struct NotifyData {
NotifyData(){};
NotifyData(_In_ ComPtr<IToastNotification> notify, _In_ EventRegistrationToken activatedToken,
_In_ EventRegistrationToken dismissedToken, _In_ EventRegistrationToken failedToken) :
_notify(notify), _activatedToken(activatedToken), _dismissedToken(dismissedToken), _failedToken(failedToken) {}
~NotifyData() {
RemoveTokens();
}
void RemoveTokens() {
if (!_readyForDeletion) {
return;
}
if (_previouslyTokenRemoved) {
return;
}
if (!_notify.Get()) {
return;
}
_notify->remove_Activated(_activatedToken);
_notify->remove_Dismissed(_dismissedToken);
_notify->remove_Failed(_failedToken);
_previouslyTokenRemoved = true;
}
void markAsReadyForDeletion() {
_readyForDeletion = true;
}
bool isReadyForDeletion() const {
return _readyForDeletion;
}
IToastNotification* notification() {
return _notify.Get();
}
private:
ComPtr<IToastNotification> _notify{nullptr};
EventRegistrationToken _activatedToken{};
EventRegistrationToken _dismissedToken{};
EventRegistrationToken _failedToken{};
bool _readyForDeletion{false};
bool _previouslyTokenRemoved{false};
};
bool _isInitialized{false};
bool _hasCoInitialized{false};
ShortcutPolicy _shortcutPolicy{SHORTCUT_POLICY_REQUIRE_CREATE};
std::wstring _appName{};
std::wstring _aumi{};
std::map<INT64, NotifyData> _buffer{};
void markAsReadyForDeletion(_In_ INT64 id);
HRESULT validateShellLinkHelper(_Out_ bool& wasChanged);
HRESULT createShellLinkHelper();
HRESULT setImageFieldHelper(_In_ IXmlDocument* xml, _In_ std::wstring const& path, _In_ bool isToastGeneric, bool isCropHintCircle);
HRESULT setHeroImageHelper(_In_ IXmlDocument* xml, _In_ std::wstring const& path, _In_ bool isInlineImage);
HRESULT setBindToastGenericHelper(_In_ IXmlDocument* xml);
HRESULT
setAudioFieldHelper(_In_ IXmlDocument* xml, _In_ std::wstring const& path,
_In_opt_ WinToastTemplate::AudioOption option = WinToastTemplate::AudioOption::Default);
HRESULT setTextFieldHelper(_In_ IXmlDocument* xml, _In_ std::wstring const& text, _In_ UINT32 pos);
HRESULT setAttributionTextFieldHelper(_In_ IXmlDocument* xml, _In_ std::wstring const& text);
HRESULT addActionHelper(_In_ IXmlDocument* xml, _In_ std::wstring const& action, _In_ std::wstring const& arguments);
HRESULT addDurationHelper(_In_ IXmlDocument* xml, _In_ std::wstring const& duration);
HRESULT addScenarioHelper(_In_ IXmlDocument* xml, _In_ std::wstring const& scenario);
HRESULT addInputHelper(_In_ IXmlDocument* xml);
ComPtr<IToastNotifier> notifier(_In_ bool* succeded) const;
void setError(_Out_opt_ WinToastError* error, _In_ WinToastError value);
};
} // namespace WinToastLib
#endif // WINTOASTLIB_H

View file

@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<!-- Target net6.0 so the sample can run on modern .NET while consuming the netstandard2.0 library -->
<TargetFramework>net6.0</TargetFramework>
<LangVersion>10</LangVersion>
<Nullable>enable</Nullable>
<RootNamespace>Notify.NET.Sample</RootNamespace>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Notify.NET\Notify.NET.csproj" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
</ItemGroup>
</Project>

View file

@ -0,0 +1,153 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Notify.NET.Abstractions;
using Notify.NET.Builder;
using Notify.NET.Extensions;
// ============================================================================
// Notify.NET Sample — demonstrates fluent builder + DI registration
// ============================================================================
// --- Option A: use the factory helper directly (no DI container) ---
using var service = ServiceCollectionExtensions.CreateNotificationService(opts =>
{
opts.AppName = "Notify.NET Sample";
opts.AppUserModelId = "NotifyNET.Sample";
});
Console.WriteLine($"Notification service supported: {service.IsSupported}");
if (!service.IsSupported)
{
Console.WriteLine("Native notifications are not available on this platform. Exiting.");
return;
}
var done = new ManualResetEventSlim(false);
// ------ 1. Simple notification (title + body) --------------------------------
Console.WriteLine("\n[1] Showing a simple notification...");
long id1 = await service.ShowAsync(
NotificationBuilder.Create("Hello from Notify.NET")
.WithBody("This is a simple cross-platform OS notification.")
.OnActivated(id => Console.WriteLine($" [1] Notification {id} activated"))
.OnDismissed((id, reason) => Console.WriteLine($" [1] Notification {id} dismissed: {reason}"))
.Build());
Console.WriteLine($" Shown with id={id1}");
await Task.Delay(3000);
// ------ 2. Notification with buttons ----------------------------------------
Console.WriteLine("\n[2] Showing a notification with action buttons...");
var buttonDone = new ManualResetEventSlim(false);
long id2 = await service.ShowAsync(
NotificationBuilder.Create("Update Available")
.WithBody("Version 2.0 is ready to install.")
.AddButton("Install Now", id => { Console.WriteLine($" [2] Install Now clicked (id={id})"); buttonDone.Set(); })
.AddButton("Remind Me", id => { Console.WriteLine($" [2] Remind Me clicked (id={id})"); buttonDone.Set(); })
.AddButton("Skip Version", id => { Console.WriteLine($" [2] Skip Version clicked (id={id})"); buttonDone.Set(); })
.OnDismissed((id, reason) => { Console.WriteLine($" [2] Dismissed: {reason}"); buttonDone.Set(); })
.OnFailed(id => { Console.WriteLine($" [2] Failed for id={id}"); buttonDone.Set(); })
.Build());
Console.WriteLine($" Shown with id={id2}. Waiting up to 15 s for interaction...");
buttonDone.Wait(TimeSpan.FromSeconds(15));
// ------ 3. Notification with an image ----------------------------------------
Console.WriteLine("\n[3] Showing a notification with an image...");
// Provide any PNG/JPG path; the sample gracefully handles a missing file
// because the native service falls back to no image when gdk_pixbuf_new_from_file fails.
string imagePath = "image.jpg";
long id3 = -1;
try
{
id3 = await service.ShowAsync(
NotificationBuilder.Create("Picture Notification")
.WithBody("This notification includes an image.")
.WithImage(imagePath)
.WithUrgency(NotificationUrgency.Low)
.OnActivated(id => Console.WriteLine($" [3] Activated id={id}"))
.Build());
Console.WriteLine($" Shown with id={id3}");
}
catch (Exception ex)
{
Console.WriteLine($" [3] Failed: {ex.GetType().Name}: {ex.Message}");
}
await Task.Delay(3000);
// ------ 4. Programmatic dismiss ----------------------------------------------
Console.WriteLine("\n[4] Showing a notification and dismissing it programmatically after 2 s...");
long id4 = await service.ShowAsync(
NotificationBuilder.Create("I will disappear in 2 seconds")
.WithBody("Programmatically dismissed.")
.OnDismissed((id, reason) => Console.WriteLine($" [4] Dismissed: {reason}"))
.Build());
Console.WriteLine($" Shown with id={id4}. Hiding in 2 s...");
await Task.Delay(2000);
await service.HideAsync(id4);
Console.WriteLine(" Hidden.");
// ------ 5. Interface-based handler -------------------------------------------
Console.WriteLine("\n[5] Showing a notification using INotificationHandler...");
long id5 = await service.ShowAsync(
NotificationBuilder.Create("Handler-based Notification")
.WithBody("Uses a custom INotificationHandler implementation.")
.AddButton("Acknowledge", null)
.WithHandler(new SampleHandler())
.Build());
Console.WriteLine($" Shown with id={id5}. Waiting 10 s...");
await Task.Delay(10_000);
// ------ 6. DI container usage ------------------------------------------------
Console.WriteLine("\n[6] Demonstrating DI container registration...");
var services = new ServiceCollection();
services.AddNotifications(opts =>
{
opts.AppName = "Notify.NET Sample (DI)";
opts.AppUserModelId = "NotifyNET.Sample.DI";
});
await using var provider = services.BuildServiceProvider();
var diService = provider.GetRequiredService<INotificationService>();
long id6 = await diService.ShowAsync(
NotificationBuilder.Create("DI-registered Service")
.WithBody("This notification was shown via an IServiceProvider-resolved service.")
.Build());
Console.WriteLine($" Shown with id={id6}");
await Task.Delay(3000);
Console.WriteLine("\nAll done.");
// ============================================================================
// Sample INotificationHandler implementation
// ============================================================================
sealed class SampleHandler : INotificationHandler
{
public void OnActivated(long id)
=> Console.WriteLine($" [5] SampleHandler.OnActivated(id={id})");
public void OnButtonActivated(long id, int buttonIndex)
=> Console.WriteLine($" [5] SampleHandler.OnButtonActivated(id={id}, button={buttonIndex})");
public void OnDismissed(long id, DismissReason reason)
=> Console.WriteLine($" [5] SampleHandler.OnDismissed(id={id}, reason={reason})");
public void OnFailed(long id)
=> Console.WriteLine($" [5] SampleHandler.OnFailed(id={id})");
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 164 KiB

View file

@ -0,0 +1,26 @@
using Notify.NET.Builder;
namespace Notify.NET.Abstractions
{
/// <summary>
/// Receives lifecycle events for a single notification.
/// Implement this interface or use the delegate-based callbacks on
/// <see cref="NotificationBuilder"/> to respond to user interactions.
/// </summary>
public interface INotificationHandler
{
/// <summary>Called when the user clicks the notification body (not a button).</summary>
void OnActivated(long notificationId);
/// <summary>Called when the user clicks one of the action buttons.</summary>
/// <param name="notificationId">The notification's platform ID.</param>
/// <param name="buttonIndex">Zero-based index matching the order buttons were added via the builder.</param>
void OnButtonActivated(long notificationId, int buttonIndex);
/// <summary>Called when the notification is dismissed (by the user, system, or expiration).</summary>
void OnDismissed(long notificationId, DismissReason reason);
/// <summary>Called when the platform fails to display the notification.</summary>
void OnFailed(long notificationId);
}
}

View file

@ -0,0 +1,33 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Notify.NET.Abstractions
{
/// <summary>
/// Dispatches OS notifications to the native notification subsystem for the current platform.
/// </summary>
public interface INotificationService : IDisposable
{
/// <summary>
/// Whether the native notification subsystem is available and initialised on this platform.
/// If false, <see cref="ShowAsync"/> will throw <see cref="Exceptions.PlatformNotSupportedException"/>.
/// </summary>
bool IsSupported { get; }
/// <summary>
/// Displays a notification and returns a platform-specific ID that can be used to hide it later.
/// </summary>
/// <param name="request">The notification to display, constructed via <see cref="Builder.NotificationBuilder"/>.</param>
/// <param name="cancellationToken">Optional cancellation token.</param>
/// <returns>A non-negative notification ID on success.</returns>
Task<long> ShowAsync(NotificationRequest request, CancellationToken cancellationToken = default);
/// <summary>
/// Programmatically dismisses a previously shown notification.
/// </summary>
/// <param name="notificationId">The ID returned by <see cref="ShowAsync"/>.</param>
/// <param name="cancellationToken">Optional cancellation token.</param>
Task HideAsync(long notificationId, CancellationToken cancellationToken = default);
}
}

View file

@ -0,0 +1,99 @@
using System;
using System.Collections.Generic;
using Notify.NET.Builder;
namespace Notify.NET.Abstractions
{
/// <summary>
/// Immutable description of a notification to be displayed.
/// Construct instances via <see cref="NotificationBuilder"/>.
/// </summary>
public sealed class NotificationRequest
{
/// <summary>The primary heading of the notification.</summary>
public string Title { get; }
/// <summary>Optional body text shown beneath the title.</summary>
public string? Body { get; }
/// <summary>Absolute path to an image file to display in the notification.</summary>
public string? ImagePath { get; }
/// <summary>Action buttons to display. Maximum platform limits apply (typically 5 on Windows, varies on Linux).</summary>
public IReadOnlyList<NotificationButton> Buttons { get; }
/// <summary>Optional interface-based handler for notification lifecycle events.</summary>
public INotificationHandler? Handler { get; }
/// <summary>How long to display the notification before it expires automatically. Null means use the platform default.</summary>
public TimeSpan? Expiration { get; }
/// <summary>Audio behaviour when the notification appears.</summary>
public NotificationAudio Audio { get; }
/// <summary>The urgency/scenario of the notification, which may affect how the platform presents it.</summary>
public NotificationUrgency Urgency { get; }
internal NotificationRequest(
string title,
string? body,
string? imagePath,
IReadOnlyList<NotificationButton> buttons,
INotificationHandler? handler,
TimeSpan? expiration,
NotificationAudio audio,
NotificationUrgency urgency)
{
if (string.IsNullOrWhiteSpace(title))
throw new ArgumentException("Notification title must not be empty.", nameof(title));
Title = title;
Body = body;
ImagePath = imagePath;
Buttons = buttons;
Handler = handler;
Expiration = expiration;
Audio = audio;
Urgency = urgency;
}
}
/// <summary>Controls the audio played when the notification is shown (Windows only; Linux ignores this).</summary>
public enum NotificationAudio
{
/// <summary>Play the platform default notification sound.</summary>
Default,
/// <summary>Display silently with no sound.</summary>
Silent,
/// <summary>Loop the notification sound until the notification is dismissed.</summary>
Loop
}
/// <summary>Maps to the notification urgency/scenario on each platform.</summary>
public enum NotificationUrgency
{
/// <summary>Standard informational notification.</summary>
Normal,
/// <summary>Low-priority; the platform may suppress or delay it.</summary>
Low,
/// <summary>High-priority; may bypass Do Not Disturb on some platforms.</summary>
Critical,
/// <summary>Alarm scenario (Windows) — may produce a full-screen interrupt.</summary>
Alarm,
/// <summary>Reminder scenario (Windows).</summary>
Reminder
}
/// <summary>Reason a notification was dismissed.</summary>
public enum DismissReason
{
/// <summary>The user explicitly dismissed the notification.</summary>
UserCancelled,
/// <summary>The notification timed out / expired.</summary>
TimedOut,
/// <summary>The application programmatically hid the notification.</summary>
ApplicationHidden,
/// <summary>Dismissed for an unspecified or platform-specific reason.</summary>
Unknown
}
}

View file

@ -0,0 +1,202 @@
using System;
using System.Collections.Generic;
using Notify.NET.Abstractions;
namespace Notify.NET.Builder
{
/// <summary>
/// Fluent builder for constructing a <see cref="NotificationRequest"/>.
/// </summary>
/// <example>
/// <code>
/// var request = NotificationBuilder.Create("Update available")
/// .WithBody("Version 2.0 is ready to install.")
/// .WithImage("/usr/share/icons/my-app.png")
/// .AddButton("Install now", id => Installer.Run())
/// .AddButton("Remind me later", id => Snooze(id))
/// .OnActivated(id => Console.WriteLine($"Notification {id} clicked"))
/// .OnDismissed((id, reason) => Console.WriteLine($"Dismissed: {reason}"))
/// .Build();
///
/// long id = await notificationService.ShowAsync(request);
/// </code>
/// </example>
public sealed class NotificationBuilder
{
private string _title = string.Empty;
private string? _body;
private string? _imagePath;
private readonly List<NotificationButton> _buttons = new List<NotificationButton>();
private INotificationHandler? _handler;
private TimeSpan? _expiration;
private NotificationAudio _audio = NotificationAudio.Default;
private NotificationUrgency _urgency = NotificationUrgency.Normal;
// Delegate-based callbacks (converted to INotificationHandler in Build())
private Action<long>? _onActivated;
private Action<long, int>? _onButtonActivated;
private Action<long, DismissReason>? _onDismissed;
private Action<long>? _onFailed;
private NotificationBuilder() { }
/// <summary>Creates a new builder with the specified notification title.</summary>
public static NotificationBuilder Create(string title)
=> new NotificationBuilder { _title = title };
/// <summary>Sets the notification title.</summary>
public NotificationBuilder WithTitle(string title)
{
_title = title ?? throw new ArgumentNullException(nameof(title));
return this;
}
/// <summary>Sets the notification body text.</summary>
public NotificationBuilder WithBody(string body)
{
_body = body;
return this;
}
/// <summary>Sets the absolute path of an image to display in the notification.</summary>
public NotificationBuilder WithImage(string imagePath)
{
_imagePath = imagePath;
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>
/// <param name="actionId">Optional machine-readable action identifier.</param>
public NotificationBuilder AddButton(string label, Action<long>? callback = null, string? actionId = null)
{
_buttons.Add(new NotificationButton(label, callback, actionId));
return this;
}
/// <summary>Adds a pre-constructed button.</summary>
public NotificationBuilder AddButton(NotificationButton button)
{
_buttons.Add(button ?? throw new ArgumentNullException(nameof(button)));
return this;
}
/// <summary>
/// Attaches an interface-based handler for all notification lifecycle events.
/// This takes priority over any delegate-based callbacks registered via
/// <see cref="OnActivated"/>, <see cref="OnDismissed"/>, or <see cref="OnFailed"/>.
/// </summary>
public NotificationBuilder WithHandler(INotificationHandler handler)
{
_handler = handler;
return this;
}
/// <summary>Registers a callback for when the notification body is clicked.</summary>
public NotificationBuilder OnActivated(Action<long> callback)
{
_onActivated = callback;
return this;
}
/// <summary>Registers a callback for when a specific action button is clicked.</summary>
public NotificationBuilder OnButtonActivated(Action<long, int> callback)
{
_onButtonActivated = callback;
return this;
}
/// <summary>Registers a callback for when the notification is dismissed.</summary>
public NotificationBuilder OnDismissed(Action<long, DismissReason> callback)
{
_onDismissed = callback;
return this;
}
/// <summary>Registers a callback for when the notification fails to display.</summary>
public NotificationBuilder OnFailed(Action<long> callback)
{
_onFailed = callback;
return this;
}
/// <summary>
/// Sets how long the notification remains visible before auto-dismissal.
/// Pass <see cref="TimeSpan.Zero"/> or null to use the platform default.
/// </summary>
public NotificationBuilder WithExpiration(TimeSpan expiration)
{
_expiration = expiration == TimeSpan.Zero ? (TimeSpan?)null : expiration;
return this;
}
/// <summary>Controls the sound played when the notification appears (Windows only).</summary>
public NotificationBuilder WithAudio(NotificationAudio audio)
{
_audio = audio;
return this;
}
/// <summary>Sets the urgency/scenario which may affect how the platform presents the notification.</summary>
public NotificationBuilder WithUrgency(NotificationUrgency urgency)
{
_urgency = urgency;
return this;
}
/// <summary>
/// Constructs the immutable <see cref="NotificationRequest"/>.
/// Throws <see cref="InvalidOperationException"/> if <see cref="WithTitle"/> has not been set.
/// </summary>
public NotificationRequest Build()
{
if (string.IsNullOrWhiteSpace(_title))
throw new InvalidOperationException("Notification title must be set before calling Build().");
// If an explicit INotificationHandler was provided, use it directly.
// Otherwise, if any delegate callbacks were registered, wrap them.
INotificationHandler? handler = _handler;
if (handler == null && (_onActivated != null || _onButtonActivated != null || _onDismissed != null || _onFailed != null))
{
handler = new DelegateNotificationHandler(_onActivated, _onButtonActivated, _onDismissed, _onFailed);
}
return new NotificationRequest(
title: _title,
body: _body,
imagePath: _imagePath,
buttons: _buttons.AsReadOnly(),
handler: handler,
expiration: _expiration,
audio: _audio,
urgency: _urgency);
}
// Internal adapter that bridges the delegate callbacks to INotificationHandler.
private sealed class DelegateNotificationHandler : INotificationHandler
{
private readonly Action<long>? _activated;
private readonly Action<long, int>? _buttonActivated;
private readonly Action<long, DismissReason>? _dismissed;
private readonly Action<long>? _failed;
public DelegateNotificationHandler(
Action<long>? activated,
Action<long, int>? buttonActivated,
Action<long, DismissReason>? dismissed,
Action<long>? failed)
{
_activated = activated;
_buttonActivated = buttonActivated;
_dismissed = dismissed;
_failed = failed;
}
public void OnActivated(long id) => _activated?.Invoke(id);
public void OnButtonActivated(long id, int idx) => _buttonActivated?.Invoke(id, idx);
public void OnDismissed(long id, DismissReason reason) => _dismissed?.Invoke(id, reason);
public void OnFailed(long id) => _failed?.Invoke(id);
}
}
}

View file

@ -0,0 +1,36 @@
using System;
namespace Notify.NET.Builder
{
/// <summary>
/// An action button displayed inside a notification.
/// </summary>
public sealed class NotificationButton
{
/// <summary>The label shown on the button.</summary>
public string Label { get; }
/// <summary>
/// Callback invoked when the user clicks this button.
/// The argument is the platform notification ID.
/// The callback is invoked on a background thread; marshal to the UI thread if required.
/// </summary>
public Action<long>? Callback { get; }
/// <summary>
/// An optional machine-readable identifier for this action (used internally by libnotify).
/// Defaults to a sanitised version of <see cref="Label"/> when not specified.
/// </summary>
public string ActionId { get; }
public NotificationButton(string label, Action<long>? callback = null, string? actionId = null)
{
if (string.IsNullOrWhiteSpace(label))
throw new ArgumentException("Button label must not be empty.", nameof(label));
Label = label;
Callback = callback;
ActionId = actionId ?? label.ToLowerInvariant().Replace(' ', '-');
}
}
}

View file

@ -0,0 +1,22 @@
using System;
namespace Notify.NET.Exceptions
{
/// <summary>Thrown when the native notification subsystem returns an error.</summary>
public sealed class NotificationException : Exception
{
/// <summary>Platform-specific error code, if available.</summary>
public int? NativeErrorCode { get; }
public NotificationException(string message) : base(message) { }
public NotificationException(string message, int nativeErrorCode)
: base(message)
{
NativeErrorCode = nativeErrorCode;
}
public NotificationException(string message, Exception innerException)
: base(message, innerException) { }
}
}

View file

@ -0,0 +1,20 @@
using System;
namespace Notify.NET.Exceptions
{
/// <summary>
/// Thrown when <see cref="Abstractions.INotificationService.ShowAsync"/> is called on a platform
/// where the native notification subsystem is unavailable or could not be initialised.
/// Check <see cref="Abstractions.INotificationService.IsSupported"/> before calling Show.
/// </summary>
public sealed class PlatformNotSupportedException : Exception
{
public PlatformNotSupportedException()
: base("Native notifications are not supported or could not be initialised on this platform.") { }
public PlatformNotSupportedException(string message) : base(message) { }
public PlatformNotSupportedException(string message, Exception innerException)
: base(message, innerException) { }
}
}

View file

@ -0,0 +1,117 @@
using System;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Notify.NET.Abstractions;
using Notify.NET.Platform.Windows;
using Notify.NET.Platform.Linux;
using Notify.NET.Platform.MacOS;
namespace Notify.NET.Extensions
{
/// <summary>
/// Extension methods for registering <see cref="INotificationService"/> with an
/// <see cref="IServiceCollection"/>. The correct platform implementation is selected
/// automatically at runtime.
/// </summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers <see cref="INotificationService"/> as a singleton, using the
/// platform-appropriate backend:
/// <list type="bullet">
/// <item><description>Windows → <see cref="WindowsNotificationService"/> (WinToastLib)</description></item>
/// <item><description>Linux → <see cref="LinuxNotificationService"/> (libnotify)</description></item>
/// <item><description>macOS → <see cref="MacOSNotificationService"/> (UNUserNotificationCenter)</description></item>
/// <item><description>Other → <see cref="NullNotificationService"/> (<see cref="INotificationService.IsSupported"/> = false)</description></item>
/// </list>
/// </summary>
/// <param name="services">The service collection to add to.</param>
/// <param name="configure">Optional delegate to configure <see cref="NotificationOptions"/>.</param>
public static IServiceCollection AddNotifications(
this IServiceCollection services,
Action<NotificationOptions>? configure = null)
{
var options = new NotificationOptions();
configure?.Invoke(options);
services.AddSingleton(options);
services.AddSingleton<INotificationService>(sp =>
{
var opts = sp.GetRequiredService<NotificationOptions>();
return CreateService(opts);
});
return services;
}
/// <summary>
/// Creates the platform-appropriate <see cref="INotificationService"/> directly
/// (without a DI container), for use in simple console applications.
/// </summary>
public static INotificationService CreateNotificationService(
Action<NotificationOptions>? configure = null)
{
var opts = new NotificationOptions();
configure?.Invoke(opts);
return CreateService(opts);
}
private static INotificationService CreateService(NotificationOptions opts)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
return new WindowsNotificationService(opts.AppName, opts.AppUserModelId);
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
return new LinuxNotificationService(opts.AppName);
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
return new MacOSNotificationService(opts.AppName);
return new NullNotificationService();
}
}
/// <summary>
/// Configuration options for the notification service.
/// Pass to <see cref="ServiceCollectionExtensions.AddNotifications"/> via the configure delegate.
/// </summary>
public sealed class NotificationOptions
{
/// <summary>
/// Human-readable application name shown in the notification and Action Centre.
/// Defaults to the process name.
/// </summary>
public string AppName { get; set; } =
System.Diagnostics.Process.GetCurrentProcess().ProcessName;
/// <summary>
/// Windows AppUserModelId (AUMI), e.g. <c>"MyCompany.MyApp"</c>.
/// Required for notifications to persist in the Windows Action Centre.
/// The native wrapper creates a Start-Menu shortcut with this AUMI automatically.
/// Ignored on non-Windows platforms.
/// </summary>
public string AppUserModelId { get; set; } =
System.Diagnostics.Process.GetCurrentProcess().ProcessName;
}
/// <summary>
/// No-op implementation used when the current platform has no supported notification backend.
/// <see cref="IsSupported"/> is always false; calling <see cref="ShowAsync"/> throws
/// <see cref="Exceptions.PlatformNotSupportedException"/>.
/// </summary>
internal sealed class NullNotificationService : INotificationService
{
public bool IsSupported => false;
public Task<long> ShowAsync(NotificationRequest request, CancellationToken cancellationToken = default)
=> throw new Exceptions.PlatformNotSupportedException();
public Task HideAsync(long notificationId, CancellationToken cancellationToken = default)
=> throw new Exceptions.PlatformNotSupportedException();
public void Dispose() { }
}
}

View file

@ -0,0 +1,49 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<LangVersion>9.0</LangVersion>
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
<Nullable>enable</Nullable>
<AssemblyName>Notify.NET</AssemblyName>
<RootNamespace>Notify.NET</RootNamespace>
<PackageId>Notify.NET</PackageId>
<Version>1.0.0</Version>
<Description>Cross-platform OS notification library for .NET Standard with WinToast (Windows), libnotify (Linux), and UNUserNotificationCenter (macOS) backends.</Description>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="2.1.0" />
<PackageReference Include="Microsoft.Extensions.Options" Version="2.1.0" />
<PackageReference Include="System.Collections.Concurrent" Version="4.3.0" />
</ItemGroup>
<!-- Bundle pre-compiled native libraries for Windows -->
<ItemGroup>
<Content Include="runtimes\win-x64\native\WinToastWrapper.dll" Condition="Exists('runtimes\win-x64\native\WinToastWrapper.dll')">
<PackagePath>runtimes/win-x64/native/WinToastWrapper.dll</PackagePath>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="runtimes\win-x86\native\WinToastWrapper.dll" Condition="Exists('runtimes\win-x86\native\WinToastWrapper.dll')">
<PackagePath>runtimes/win-x86/native/WinToastWrapper.dll</PackagePath>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="runtimes\win-arm64\native\WinToastWrapper.dll" Condition="Exists('runtimes\win-arm64\native\WinToastWrapper.dll')">
<PackagePath>runtimes/win-arm64/native/WinToastWrapper.dll</PackagePath>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
<!-- Bundle pre-compiled native dylibs for macOS -->
<ItemGroup>
<Content Include="runtimes/osx-x64/native/libMacNotifyWrapper.dylib" Condition="Exists('runtimes/osx-x64/native/libMacNotifyWrapper.dylib')">
<PackagePath>runtimes/osx-x64/native/libMacNotifyWrapper.dylib</PackagePath>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
<Content Include="runtimes/osx-arm64/native/libMacNotifyWrapper.dylib" Condition="Exists('runtimes/osx-arm64/native/libMacNotifyWrapper.dylib')">
<PackagePath>runtimes/osx-arm64/native/libMacNotifyWrapper.dylib</PackagePath>
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>

View file

@ -0,0 +1,125 @@
using System;
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using System.Threading;
namespace Notify.NET.Platform.Linux
{
/// <summary>
/// Owns and manages a GLib GMainLoop on a dedicated background thread.
///
/// libnotify delivers notification signals (action-invoked, closed) through the GLib
/// event system and requires a running GMainLoop to dispatch them. Console applications
/// and ASP.NET hosts do not have one by default, so this class creates and owns one.
///
/// All calls to libnotify that create/show/close notifications MUST be executed on the
/// GMainLoop thread to ensure proper GObject signal wiring. Use <see cref="InvokeAsync"/>
/// to marshal work onto that thread.
/// </summary>
internal sealed class GLibMainLoopRunner : IDisposable
{
private readonly Thread _loopThread;
private readonly ManualResetEventSlim _started = new ManualResetEventSlim(false);
// Pinned GSourceFunc delegate — static, never collected.
private static readonly LibNotifyNative.GSourceFunc _dispatchSourceFunc = DispatchSourceFuncStatic;
private static readonly IntPtr _dispatchFuncPtr =
Marshal.GetFunctionPointerForDelegate(_dispatchSourceFunc);
// Work items posted from external threads via g_main_context_invoke.
// We use a ConcurrentQueue keyed by a GCHandle to the WorkItem so we can pass a
// single IntPtr through the GLib userData parameter.
private IntPtr _mainLoop;
private bool _disposed;
internal GLibMainLoopRunner()
{
_loopThread = new Thread(LoopThreadProc)
{
Name = "Notify.NET GMainLoop",
IsBackground = true
};
_loopThread.Start();
_started.Wait();
}
// ------------------------------------------------------------------
// Public API
// ------------------------------------------------------------------
/// <summary>
/// Posts <paramref name="action"/> to be executed on the GMainLoop thread and
/// returns a task that completes when the action finishes.
/// </summary>
internal System.Threading.Tasks.Task InvokeAsync(Action action)
{
var tcs = new System.Threading.Tasks.TaskCompletionSource<bool>();
var item = new WorkItem(action, tcs);
// Allocate a GCHandle to keep the WorkItem alive from unmanaged code.
GCHandle handle = GCHandle.Alloc(item, GCHandleType.Normal);
// g_main_context_invoke(null) posts to the default context, which is owned
// by our GMainLoop thread.
LibNotifyNative.g_main_context_invoke(IntPtr.Zero, _dispatchFuncPtr, GCHandle.ToIntPtr(handle));
return tcs.Task;
}
// ------------------------------------------------------------------
// IDisposable
// ------------------------------------------------------------------
public void Dispose()
{
if (_disposed) return;
_disposed = true;
if (_mainLoop != IntPtr.Zero)
{
LibNotifyNative.g_main_loop_quit(_mainLoop);
if (_loopThread.IsAlive)
_loopThread.Join(TimeSpan.FromSeconds(5));
LibNotifyNative.g_main_loop_unref(_mainLoop);
_mainLoop = IntPtr.Zero;
}
_started.Dispose();
}
// ------------------------------------------------------------------
// Private
// ------------------------------------------------------------------
private void LoopThreadProc()
{
_mainLoop = LibNotifyNative.g_main_loop_new(IntPtr.Zero, false);
_started.Set();
LibNotifyNative.g_main_loop_run(_mainLoop);
// g_main_loop_unref is called in Dispose, not here, to avoid double-unref.
}
// Static GSourceFunc — invoked on the GMainLoop thread by GLib.
// Returns false so GLib removes the source after one invocation.
private static bool DispatchSourceFuncStatic(IntPtr userData)
{
GCHandle handle = GCHandle.FromIntPtr(userData);
var item = (WorkItem)handle.Target!;
handle.Free();
try { item.Action(); item.Tcs.TrySetResult(true); }
catch (Exception ex) { item.Tcs.TrySetException(ex); }
return false; // one-shot
}
private sealed class WorkItem
{
internal readonly Action Action;
internal readonly System.Threading.Tasks.TaskCompletionSource<bool> Tcs;
internal WorkItem(Action action, System.Threading.Tasks.TaskCompletionSource<bool> tcs)
{ Action = action; Tcs = tcs; }
}
}
}

View file

@ -0,0 +1,152 @@
using System;
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using Notify.NET.Abstractions;
namespace Notify.NET.Platform.Linux
{
/// <summary>
/// Bridges the unmanaged libnotify GObject signal callbacks back to the managed
/// <see cref="INotificationHandler"/> for each in-flight notification.
///
/// Threading: GLib delivers both the action and closed signals on the GMainLoop thread.
/// Handler invocations therefore happen on the GMainLoop background thread;
/// consumers are responsible for marshalling to a UI thread if required.
///
/// Lifetime rules (mirror the Windows bridge):
/// 1. Static delegates are pinned permanently — their function pointers are valid forever.
/// 2. Per-notification bridges are held in a static ConcurrentDictionary keyed by the
/// native NotifyNotification* pointer (cast to long).
/// 3. The bridge's GCHandle prevents GC collection until the "closed" signal fires.
/// 4. Release() is called once from the closed callback; it frees the GCHandle,
/// unrefs the GObject, and removes the dictionary entry.
/// </summary>
internal sealed class LibNotifyCallbackBridge
{
// ------------------------------------------------------------------
// Static callbacks — one instance shared across all notifications
// ------------------------------------------------------------------
private static readonly LibNotifyNative.NotifyActionCallback _staticAction;
private static readonly LibNotifyNative.NotifyClosedCallback _staticClosed;
internal static readonly IntPtr PtrAction;
internal static readonly IntPtr PtrClosed;
// ------------------------------------------------------------------
// Live bridge registry
// ------------------------------------------------------------------
private static readonly ConcurrentDictionary<long, LibNotifyCallbackBridge> _live
= new ConcurrentDictionary<long, LibNotifyCallbackBridge>();
// ------------------------------------------------------------------
// Per-instance state
// ------------------------------------------------------------------
private readonly INotificationHandler? _handler;
private readonly System.Collections.Generic.IReadOnlyList<Builder.NotificationButton> _buttons;
private GCHandle _gcHandle;
static LibNotifyCallbackBridge()
{
_staticAction = OnActionStatic;
_staticClosed = OnClosedStatic;
PtrAction = Marshal.GetFunctionPointerForDelegate(_staticAction);
PtrClosed = Marshal.GetFunctionPointerForDelegate(_staticClosed);
}
private LibNotifyCallbackBridge(
INotificationHandler? handler,
System.Collections.Generic.IReadOnlyList<Builder.NotificationButton> buttons)
{
_handler = handler;
_buttons = buttons;
_gcHandle = GCHandle.Alloc(this, GCHandleType.Normal);
}
/// <summary>
/// Registers a bridge for the native notification pointer.
/// Must be called on the GMainLoop thread BEFORE connecting GObject signals,
/// so the dictionary entry exists before any signal can fire.
/// </summary>
internal static LibNotifyCallbackBridge Register(
IntPtr notificationPtr,
INotificationHandler? handler,
System.Collections.Generic.IReadOnlyList<Builder.NotificationButton> buttons)
{
var bridge = new LibNotifyCallbackBridge(handler, buttons);
_live[(long)notificationPtr] = bridge;
return bridge;
}
/// <summary>
/// Removes the bridge and releases all resources.
/// Called from the "closed" signal handler — do not call from application code.
/// </summary>
internal static void Release(IntPtr notificationPtr)
{
long key = (long)notificationPtr;
if (_live.TryRemove(key, out var bridge))
{
if (bridge._gcHandle.IsAllocated)
bridge._gcHandle.Free();
// Release the libnotify GObject reference.
LibNotifyNative.g_object_unref(notificationPtr);
}
}
// ------------------------------------------------------------------
// Static GLib signal handlers — called on the GMainLoop thread
// ------------------------------------------------------------------
private static void OnActionStatic(IntPtr notification, string action, IntPtr userData)
{
try
{
long key = (long)notification;
if (!_live.TryGetValue(key, out var bridge)) return;
for (int i = 0; i < bridge._buttons.Count; i++)
{
if (string.Equals(bridge._buttons[i].ActionId, action, StringComparison.Ordinal))
{
bridge._buttons[i].Callback?.Invoke(key);
bridge._handler?.OnButtonActivated(key, i);
return;
}
}
bridge._handler?.OnActivated(key);
}
catch { }
}
private static void OnClosedStatic(IntPtr notification, IntPtr userData)
{
try
{
long key = (long)notification;
if (_live.TryGetValue(key, out var bridge))
{
int reason = LibNotifyNative.notify_notification_get_closed_reason(notification);
bridge._handler?.OnDismissed(key, MapCloseReason(reason));
}
}
catch { }
finally { Release(notification); }
}
private static DismissReason MapCloseReason(int reason)
{
// freedesktop.org notification spec close reasons:
// 1 = expired, 2 = dismissed by user, 3 = closed by app, 4 = undefined
switch (reason)
{
case 1: return DismissReason.TimedOut;
case 2: return DismissReason.UserCancelled;
case 3: return DismissReason.ApplicationHidden;
default: return DismissReason.Unknown;
}
}
}
}

View file

@ -0,0 +1,172 @@
using System;
using System.Runtime.InteropServices;
namespace Notify.NET.Platform.Linux
{
/// <summary>
/// P/Invoke declarations for libnotify.so.4 and the GLib/GObject functions
/// needed to manage signals and the main event loop.
///
/// All GLib string parameters use ANSI (UTF-8) encoding, which matches GLib's
/// internal string convention on Linux.
/// </summary>
internal static class LibNotifyNative
{
private const string LibNotify = "libnotify.so.4";
private const string LibGLib = "libglib-2.0.so.0";
private const string LibGObj = "libgobject-2.0.so.0";
private const string LibGdkPB = "libgdk_pixbuf-2.0.so.0";
// -------------------------------------------------------------------------
// Unmanaged callback delegate types
// -------------------------------------------------------------------------
/// <summary>Callback fired when the user clicks an action button on the notification.</summary>
[UnmanagedFunctionPointer(CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
internal delegate void NotifyActionCallback(IntPtr notification, string action, IntPtr userData);
/// <summary>Callback fired when the notification is closed (any reason).</summary>
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
internal delegate void NotifyClosedCallback(IntPtr notification, IntPtr userData);
/// <summary>Function posted to the GMainContext via g_main_context_invoke.</summary>
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
internal delegate bool GSourceFunc(IntPtr userData);
// -------------------------------------------------------------------------
// libnotify
// -------------------------------------------------------------------------
/// <summary>Initialises libnotify. Must be called before any other notify_ function.</summary>
[DllImport(LibNotify, EntryPoint = "notify_init", CharSet = CharSet.Ansi)]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool notify_init(string appName);
/// <summary>Returns true if notify_init() has been called successfully.</summary>
[DllImport(LibNotify, EntryPoint = "notify_is_initted")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool notify_is_initted();
/// <summary>Releases all libnotify resources.</summary>
[DllImport(LibNotify, EntryPoint = "notify_uninit")]
internal static extern void notify_uninit();
/// <summary>
/// Creates a new notification object. The returned pointer is a GObject reference
/// with a ref-count of 1. Callers must eventually call g_object_unref.
/// </summary>
[DllImport(LibNotify, EntryPoint = "notify_notification_new", CharSet = CharSet.Ansi)]
internal static extern IntPtr notify_notification_new(string summary, string? body, string? icon);
/// <summary>Shows the notification. Returns false and sets <paramref name="error"/> on failure.</summary>
[DllImport(LibNotify, EntryPoint = "notify_notification_show")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool notify_notification_show(IntPtr notification, ref IntPtr error);
/// <summary>Programmatically closes the notification.</summary>
[DllImport(LibNotify, EntryPoint = "notify_notification_close")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool notify_notification_close(IntPtr notification, ref IntPtr error);
/// <summary>
/// Adds an action button to the notification.
/// <paramref name="callback"/> must be a pinned function pointer; see <see cref="LibNotifyCallbackBridge"/>.
/// </summary>
[DllImport(LibNotify, EntryPoint = "notify_notification_add_action", CharSet = CharSet.Ansi)]
internal static extern void notify_notification_add_action(
IntPtr notification,
string action, // machine-readable action ID
string label, // human-readable label
IntPtr callback, // NotifyActionCallback function pointer
IntPtr userData,
IntPtr freeFunc); // GFreeFunc, pass IntPtr.Zero
/// <summary>Sets a display hint on the notification (e.g., urgency level).</summary>
[DllImport(LibNotify, EntryPoint = "notify_notification_set_hint", CharSet = CharSet.Ansi)]
internal static extern void notify_notification_set_hint(
IntPtr notification, string key, IntPtr value /* GVariant* */);
/// <summary>Sets the notification's image from a GdkPixbuf.</summary>
[DllImport(LibNotify, EntryPoint = "notify_notification_set_image_from_pixbuf")]
internal static extern void notify_notification_set_image_from_pixbuf(
IntPtr notification, IntPtr pixbuf /* GdkPixbuf* */);
/// <summary>Returns the reason the notification was closed (call after the "closed" signal).</summary>
[DllImport(LibNotify, EntryPoint = "notify_notification_get_closed_reason")]
internal static extern int notify_notification_get_closed_reason(IntPtr notification);
// -------------------------------------------------------------------------
// GLib / GObject
// -------------------------------------------------------------------------
/// <summary>Creates a new GMainLoop.</summary>
[DllImport(LibGLib, EntryPoint = "g_main_loop_new")]
internal static extern IntPtr g_main_loop_new(IntPtr context /* null = default */, bool isRunning);
/// <summary>Runs the GMainLoop, blocking until g_main_loop_quit is called.</summary>
[DllImport(LibGLib, EntryPoint = "g_main_loop_run")]
internal static extern void g_main_loop_run(IntPtr loop);
/// <summary>Signals the GMainLoop to stop its run() and return.</summary>
[DllImport(LibGLib, EntryPoint = "g_main_loop_quit")]
internal static extern void g_main_loop_quit(IntPtr loop);
/// <summary>Releases a GMainLoop reference.</summary>
[DllImport(LibGLib, EntryPoint = "g_main_loop_unref")]
internal static extern void g_main_loop_unref(IntPtr loop);
/// <summary>
/// Posts a function to be called on the default GMainContext from any thread.
/// The function is invoked on the GMainLoop thread.
/// </summary>
[DllImport(LibGLib, EntryPoint = "g_main_context_invoke")]
internal static extern void g_main_context_invoke(IntPtr context, IntPtr func, IntPtr userData);
/// <summary>
/// Connects a callback to a GObject signal.
/// Returns the handler ID (used to disconnect later if needed).
/// </summary>
[DllImport(LibGObj, EntryPoint = "g_signal_connect_data", CharSet = CharSet.Ansi)]
internal static extern ulong g_signal_connect_data(
IntPtr instance,
string detailedSignal,
IntPtr cHandler,
IntPtr data,
IntPtr destroyData,
int connectFlags);
/// <summary>Releases one reference on a GObject. The object is destroyed when the ref-count reaches 0.</summary>
[DllImport(LibGObj, EntryPoint = "g_object_unref")]
internal static extern void g_object_unref(IntPtr obj);
/// <summary>Frees a GError and sets the pointer to null.</summary>
[DllImport(LibGLib, EntryPoint = "g_error_free")]
internal static extern void g_error_free(IntPtr error);
// -------------------------------------------------------------------------
// GLib GVariant helpers (needed for urgency hints)
// -------------------------------------------------------------------------
/// <summary>Creates a GVariant holding a byte value (used for the urgency hint).</summary>
[DllImport(LibGLib, EntryPoint = "g_variant_new_byte")]
internal static extern IntPtr g_variant_new_byte(byte value);
// -------------------------------------------------------------------------
// GdkPixbuf (optional — for loading images from disk paths)
// -------------------------------------------------------------------------
/// <summary>
/// Loads an image from disk into a GdkPixbuf.
/// Returns IntPtr.Zero on failure; callers should fall back gracefully.
/// </summary>
[DllImport(LibGdkPB, EntryPoint = "gdk_pixbuf_new_from_file", CharSet = CharSet.Ansi)]
internal static extern IntPtr gdk_pixbuf_new_from_file(string filename, ref IntPtr error);
// -------------------------------------------------------------------------
// Urgency level constants (freedesktop.org spec)
// -------------------------------------------------------------------------
internal const byte URGENCY_LOW = 0;
internal const byte URGENCY_NORMAL = 1;
internal const byte URGENCY_CRITICAL = 2;
}
}

View file

@ -0,0 +1,274 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using Notify.NET.Abstractions;
using Notify.NET.Exceptions;
namespace Notify.NET.Platform.Linux
{
/// <summary>
/// <see cref="INotificationService"/> implementation backed by libnotify.
///
/// Threading model:
/// All libnotify calls must be made from the GLib GMainLoop thread to ensure correct
/// signal wiring. <see cref="GLibMainLoopRunner.InvokeAsync"/> marshals work onto
/// that thread. Callbacks (action-invoked, closed) are delivered on the same thread.
/// </summary>
public sealed class LinuxNotificationService : INotificationService
{
private readonly string _appName;
private readonly GLibMainLoopRunner _loopRunner;
private volatile bool _disposed;
/// <inheritdoc/>
public bool IsSupported { get; private set; }
/// <param name="appName">Application name passed to notify_init().</param>
public LinuxNotificationService(string appName)
{
_appName = appName ?? throw new ArgumentNullException(nameof(appName));
_loopRunner = new GLibMainLoopRunner();
// Initialise libnotify on the GMainLoop thread.
// Detect a missing libnotify gracefully so IsSupported is false rather than throwing.
try
{
_loopRunner.InvokeAsync(() =>
{
if (!LibNotifyNative.notify_is_initted())
{
bool ok = LibNotifyNative.notify_init(_appName);
IsSupported = ok;
}
else
{
IsSupported = true;
}
}).GetAwaiter().GetResult();
}
catch (DllNotFoundException)
{
IsSupported = false;
}
}
// ------------------------------------------------------------------
// INotificationService
// ------------------------------------------------------------------
/// <inheritdoc/>
public async Task<long> ShowAsync(NotificationRequest request, CancellationToken cancellationToken = default)
{
if (request == null) throw new ArgumentNullException(nameof(request));
ThrowIfDisposedOrUnsupported();
cancellationToken.ThrowIfCancellationRequested();
long notificationId = 0;
await _loopRunner.InvokeAsync(() =>
{
notificationId = ShowOnLoopThread(request);
}).ConfigureAwait(false);
return notificationId;
}
/// <inheritdoc/>
public async Task HideAsync(long notificationId, CancellationToken cancellationToken = default)
{
ThrowIfDisposedOrUnsupported();
cancellationToken.ThrowIfCancellationRequested();
await _loopRunner.InvokeAsync(() =>
{
var ptr = (IntPtr)notificationId;
IntPtr error = IntPtr.Zero;
bool ok = LibNotifyNative.notify_notification_close(ptr, ref error);
if (!ok)
{
string msg = MarshalGError(ref error);
throw new NotificationException($"notify_notification_close failed: {msg}");
}
// Release is normally triggered by the "closed" signal, but call it here
// too in case the signal doesn't fire (some notification daemons omit it).
LibNotifyCallbackBridge.Release(ptr);
}).ConfigureAwait(false);
}
// ------------------------------------------------------------------
// IDisposable
// ------------------------------------------------------------------
/// <inheritdoc/>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
try
{
_loopRunner.InvokeAsync(() =>
{
if (LibNotifyNative.notify_is_initted())
LibNotifyNative.notify_uninit();
}).GetAwaiter().GetResult();
}
catch { /* best-effort cleanup */ }
_loopRunner.Dispose();
}
// ------------------------------------------------------------------
// Private helpers
// ------------------------------------------------------------------
private void ThrowIfDisposedOrUnsupported()
{
if (_disposed) throw new ObjectDisposedException(nameof(LinuxNotificationService));
if (!IsSupported) throw new Exceptions.PlatformNotSupportedException();
}
/// <summary>Called on the GMainLoop thread to create and show a notification.</summary>
private long ShowOnLoopThread(NotificationRequest request)
{
IntPtr notification = LibNotifyNative.notify_notification_new(
request.Title,
request.Body,
null /* icon — we set it from imagePath below if provided */);
if (notification == IntPtr.Zero)
throw new NotificationException("notify_notification_new returned null.");
// --- Image ---
string? resolvedImage = ResolveImagePath(request.ImagePath);
if (resolvedImage != null)
ApplyImage(notification, resolvedImage);
// --- Urgency hint ---
byte urgency = MapUrgency(request.Urgency);
IntPtr urgencyVariant = LibNotifyNative.g_variant_new_byte(urgency);
LibNotifyNative.notify_notification_set_hint(notification, "urgency", urgencyVariant);
// --- Expiration ---
if (request.Expiration.HasValue)
{
int ms = (int)request.Expiration.Value.TotalMilliseconds;
// notify_notification_set_timeout is available in newer libnotify versions;
// set as a hint for compatibility with older versions too.
LibNotifyNative.notify_notification_set_hint(
notification, "x-canonical-snap-decisions-timeout",
LibNotifyNative.g_variant_new_byte((byte)Math.Clamp(ms / 1000, 0, 255)));
}
// --- Register bridge BEFORE connecting signals ---
LibNotifyCallbackBridge.Register(notification, request.Handler, request.Buttons);
// --- Action buttons ---
for (int i = 0; i < request.Buttons.Count; i++)
{
var btn = request.Buttons[i];
LibNotifyNative.notify_notification_add_action(
notification,
btn.ActionId,
btn.Label,
LibNotifyCallbackBridge.PtrAction,
notification, // userData = the notification pointer (our lookup key)
IntPtr.Zero);
}
// --- "closed" signal ---
LibNotifyNative.g_signal_connect_data(
notification,
"closed",
LibNotifyCallbackBridge.PtrClosed,
notification, // data = lookup key
IntPtr.Zero,
0);
// --- Show ---
IntPtr error = IntPtr.Zero;
bool ok = LibNotifyNative.notify_notification_show(notification, ref error);
if (!ok)
{
string msg = MarshalGError(ref error);
LibNotifyCallbackBridge.Release(notification);
throw new NotificationException($"notify_notification_show failed: {msg}");
}
return (long)notification;
}
private static string? ResolveImagePath(string? path)
{
if (string.IsNullOrEmpty(path)) return null;
try
{
string absolute = Path.IsPathRooted(path) ? path : Path.GetFullPath(path);
return File.Exists(absolute) ? absolute : null;
}
catch (Exception) { return null; }
}
private static void ApplyImage(IntPtr notification, string imagePath)
{
IntPtr pixbufError = IntPtr.Zero;
IntPtr pixbuf = IntPtr.Zero;
try
{
pixbuf = LibNotifyNative.gdk_pixbuf_new_from_file(imagePath, ref pixbufError);
if (pixbuf != IntPtr.Zero)
{
LibNotifyNative.notify_notification_set_image_from_pixbuf(notification, pixbuf);
}
else
{
// Image loading failed — clear the error and continue without an image.
if (pixbufError != IntPtr.Zero)
{
LibNotifyNative.g_error_free(pixbufError);
pixbufError = IntPtr.Zero;
}
}
}
finally
{
// Unref the pixbuf — the notification holds its own reference after set_image.
if (pixbuf != IntPtr.Zero)
LibNotifyNative.g_object_unref(pixbuf);
if (pixbufError != IntPtr.Zero)
LibNotifyNative.g_error_free(pixbufError);
}
}
private static string MarshalGError(ref IntPtr error)
{
if (error == IntPtr.Zero) return "(unknown error)";
// GError layout: domain (uint32) | code (int32) | message (char*)
// On 64-bit Linux: domain at 0, code at 4, message pointer at 8.
IntPtr messagePtr = Marshal.ReadIntPtr(error, 8);
string message = Marshal.PtrToStringAnsi(messagePtr) ?? "(null)";
LibNotifyNative.g_error_free(error);
error = IntPtr.Zero;
return message;
}
private static byte MapUrgency(NotificationUrgency urgency)
{
switch (urgency)
{
case NotificationUrgency.Low: return LibNotifyNative.URGENCY_LOW;
case NotificationUrgency.Critical:
case NotificationUrgency.Alarm: return LibNotifyNative.URGENCY_CRITICAL;
default: return LibNotifyNative.URGENCY_NORMAL;
}
}
}
}

View file

@ -0,0 +1,153 @@
using System;
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using Notify.NET.Abstractions;
namespace Notify.NET.Platform.MacOS
{
/// <summary>
/// Bridges unmanaged callbacks from <c>libMacNotifyWrapper.dylib</c> back to the
/// managed <see cref="INotificationHandler"/> for each in-flight notification.
///
/// Design rules — identical to the Windows and Linux bridges:
///
/// 1. The four static delegates are stored in <c>static readonly</c> fields and
/// their function pointers obtained once; they are permanently valid.
///
/// 2. Per-notification state is held in <see cref="MacNotifyCallbackBridge"/> instances
/// tracked in <see cref="_live"/>. A <see cref="GCHandle"/> prevents GC collection.
///
/// 3. <see cref="Release"/> is called from <em>every</em> terminal callback.
/// On macOS, body-tap, button-tap, dismiss and failure are all terminal:
/// <c>UNUserNotificationCenter</c> fires exactly one response per notification
/// and does NOT separately fire a dismiss event after an action response.
/// </summary>
internal sealed class MacNotifyCallbackBridge
{
// ------------------------------------------------------------------
// Static delegates — one set for the entire process lifetime
// ------------------------------------------------------------------
private static readonly MacNotifyNative.ActivatedCallback _staticActivated;
private static readonly MacNotifyNative.ButtonActivatedCallback _staticButtonActivated;
private static readonly MacNotifyNative.DismissedCallback _staticDismissed;
private static readonly MacNotifyNative.FailedCallback _staticFailed;
internal static readonly IntPtr PtrActivated;
internal static readonly IntPtr PtrButtonActivated;
internal static readonly IntPtr PtrDismissed;
internal static readonly IntPtr PtrFailed;
// ------------------------------------------------------------------
// Live bridge registry: notifId → bridge
// ------------------------------------------------------------------
private static readonly ConcurrentDictionary<long, MacNotifyCallbackBridge> _live
= new ConcurrentDictionary<long, MacNotifyCallbackBridge>();
// ------------------------------------------------------------------
// Per-instance state
// ------------------------------------------------------------------
private readonly INotificationHandler? _handler;
private GCHandle _gcHandle;
static MacNotifyCallbackBridge()
{
_staticActivated = OnActivatedStatic;
_staticButtonActivated = OnButtonActivatedStatic;
_staticDismissed = OnDismissedStatic;
_staticFailed = OnFailedStatic;
PtrActivated = Marshal.GetFunctionPointerForDelegate(_staticActivated);
PtrButtonActivated = Marshal.GetFunctionPointerForDelegate(_staticButtonActivated);
PtrDismissed = Marshal.GetFunctionPointerForDelegate(_staticDismissed);
PtrFailed = Marshal.GetFunctionPointerForDelegate(_staticFailed);
}
private MacNotifyCallbackBridge(INotificationHandler? handler)
{
_handler = handler;
_gcHandle = GCHandle.Alloc(this, GCHandleType.Normal);
}
/// <summary>
/// Registers a bridge for <paramref name="notifId"/>.
/// Call immediately after <see cref="MacNotifyNative.MNW_ShowNotification"/> returns
/// a positive ID.
/// </summary>
internal static MacNotifyCallbackBridge Register(long notifId, INotificationHandler? handler)
{
var bridge = new MacNotifyCallbackBridge(handler);
_live[notifId] = bridge;
return bridge;
}
/// <summary>
/// Removes the bridge and frees its <see cref="GCHandle"/>.
/// Safe to call multiple times; subsequent calls are no-ops.
/// </summary>
internal static void Release(long notifId)
{
if (_live.TryRemove(notifId, out var bridge) && bridge._gcHandle.IsAllocated)
bridge._gcHandle.Free();
}
// ------------------------------------------------------------------
// Static routing callbacks — invoked on a background GCD thread
// ------------------------------------------------------------------
private static void OnActivatedStatic(long notifId)
{
try
{
if (_live.TryGetValue(notifId, out var bridge))
bridge._handler?.OnActivated(notifId);
}
catch { /* must not propagate into native code */ }
finally { Release(notifId); }
}
private static void OnButtonActivatedStatic(long notifId, int buttonIndex)
{
try
{
if (_live.TryGetValue(notifId, out var bridge))
bridge._handler?.OnButtonActivated(notifId, buttonIndex);
}
catch { }
finally { Release(notifId); }
}
private static void OnDismissedStatic(long notifId, int reason)
{
try
{
if (_live.TryGetValue(notifId, out var bridge))
bridge._handler?.OnDismissed(notifId, MapDismissReason(reason));
}
catch { }
finally { Release(notifId); }
}
private static void OnFailedStatic(long notifId)
{
try
{
if (_live.TryGetValue(notifId, out var bridge))
bridge._handler?.OnFailed(notifId);
}
catch { }
finally { Release(notifId); }
}
private static DismissReason MapDismissReason(int reason)
{
// MNW_DISMISS_EXPIRED = 0, MNW_DISMISS_USER = 1, MNW_DISMISS_APP_REMOVED = 2
switch (reason)
{
case 0: return DismissReason.TimedOut;
case 1: return DismissReason.UserCancelled;
case 2: return DismissReason.ApplicationHidden;
default: return DismissReason.Unknown;
}
}
}
}

View file

@ -0,0 +1,91 @@
using System;
using System.Runtime.InteropServices;
namespace Notify.NET.Platform.MacOS
{
/// <summary>
/// P/Invoke declarations for <c>libMacNotifyWrapper.dylib</c>.
///
/// All strings in structs are marshalled as UTF-8 via <see cref="IntPtr"/> and
/// <see cref="Marshal.StringToHGlobalAnsi"/>. On macOS the ANSI code page is UTF-8,
/// so this is a faithful UTF-8 round-trip.
///
/// Every exported function uses the C calling convention (cdecl), which is the
/// platform default for all architectures on macOS.
/// </summary>
internal static class MacNotifyNative
{
internal const string LibName = "MacNotifyWrapper";
// ------------------------------------------------------------------
// Unmanaged callback delegate types
// ------------------------------------------------------------------
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
internal delegate void ActivatedCallback(long notifId);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
internal delegate void ButtonActivatedCallback(long notifId, int buttonIndex);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
internal delegate void DismissedCallback(long notifId, int reason);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
internal delegate void FailedCallback(long notifId);
// ------------------------------------------------------------------
// MNW_Handler — bundle of four function pointers
// ------------------------------------------------------------------
[StructLayout(LayoutKind.Sequential)]
internal struct MNW_Handler
{
public IntPtr onActivated;
public IntPtr onButtonActivated;
public IntPtr onDismissed;
public IntPtr onFailed;
}
// ------------------------------------------------------------------
// MNW_NotificationDescriptor — all string fields are raw pointers
// ------------------------------------------------------------------
[StructLayout(LayoutKind.Sequential)]
internal struct MNW_NotificationDescriptor
{
public IntPtr title; // const char* UTF-8, required
public IntPtr body; // const char* UTF-8, may be Zero
public IntPtr imagePath; // const char* UTF-8, may be Zero
public IntPtr buttonLabels; // const char** array, may be Zero
public int buttonCount;
public long expirationMs; // reserved — not used by UNUserNotificationCenter
public int audioOption;
public int interruptionLevel;
}
// ------------------------------------------------------------------
// Exported functions
// ------------------------------------------------------------------
[DllImport(LibName, CallingConvention = CallingConvention.Cdecl)]
[return: MarshalAs(UnmanagedType.I1)]
internal static extern bool MNW_IsSupported();
[DllImport(LibName, CallingConvention = CallingConvention.Cdecl)]
[return: MarshalAs(UnmanagedType.I1)]
internal static extern bool MNW_Initialize(
[MarshalAs(UnmanagedType.LPStr)] string appName);
[DllImport(LibName, CallingConvention = CallingConvention.Cdecl)]
internal static extern void MNW_Uninitialize();
[DllImport(LibName, CallingConvention = CallingConvention.Cdecl)]
internal static extern long MNW_ShowNotification(
ref MNW_NotificationDescriptor descriptor,
ref MNW_Handler handler);
[DllImport(LibName, CallingConvention = CallingConvention.Cdecl)]
[return: MarshalAs(UnmanagedType.I1)]
internal static extern bool MNW_HideNotification(long notifId);
}
}

View file

@ -0,0 +1,94 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
using Notify.NET.Exceptions;
namespace Notify.NET.Platform.MacOS
{
/// <summary>
/// Ensures <c>libMacNotifyWrapper.dylib</c> is loaded before the first P/Invoke call.
///
/// Resolution order:
/// 1. Alongside the executing assembly (typical for published apps).
/// 2. NuGet <c>runtimes/&lt;rid&gt;/native/</c> layout relative to the executing assembly.
/// 3. NuGet layout relative to the entry assembly.
/// </summary>
internal static class MacOSNativeLibraryLoader
{
private const string DylibName = "libMacNotifyWrapper.dylib";
private static volatile bool _loaded;
private static readonly object _lock = new object();
/// <summary>
/// Loads the dylib if it has not been loaded yet.
/// Throws <see cref="DllNotFoundException"/> if the file cannot be found or opened.
/// </summary>
internal static void EnsureLoaded()
{
if (_loaded) return;
lock (_lock)
{
if (_loaded) return;
LoadDylib();
_loaded = true;
}
}
private static void LoadDylib()
{
string rid = GetRuntimeIdentifier();
string relativeSubPath = Path.Combine("runtimes", rid, "native", DylibName);
string? assemblyDir = Path.GetDirectoryName(
typeof(MacOSNativeLibraryLoader).Assembly.Location);
// Search locations in priority order.
string[] candidates = assemblyDir != null
? new[]
{
Path.Combine(assemblyDir, DylibName),
Path.Combine(assemblyDir, relativeSubPath),
Path.Combine(AppContext.BaseDirectory, DylibName),
Path.Combine(AppContext.BaseDirectory, relativeSubPath),
}
: new[]
{
Path.Combine(AppContext.BaseDirectory, DylibName),
Path.Combine(AppContext.BaseDirectory, relativeSubPath),
};
foreach (string candidate in candidates)
{
if (!File.Exists(candidate)) continue;
IntPtr handle = dlopen(candidate, RTLD_NOW | RTLD_GLOBAL);
if (handle != IntPtr.Zero) return;
}
throw new DllNotFoundException(
$"Could not load {DylibName}. " +
$"Ensure the macOS native dylib is present in the output directory or " +
$"runtimes/{rid}/native/. " +
$"Build with: cd native/MacNotifyWrapper && make install");
}
private static string GetRuntimeIdentifier()
{
switch (RuntimeInformation.ProcessArchitecture)
{
case Architecture.X64: return "osx-x64";
case Architecture.Arm64: return "osx-arm64";
default:
throw new Exceptions.PlatformNotSupportedException(
$"Unsupported macOS architecture: {RuntimeInformation.ProcessArchitecture}");
}
}
// RTLD_NOW=2, RTLD_GLOBAL=8 on macOS.
private const int RTLD_NOW = 2;
private const int RTLD_GLOBAL = 8;
[DllImport("libSystem.B.dylib")]
private static extern IntPtr dlopen(string path, int mode);
}
}

View file

@ -0,0 +1,302 @@
using System;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using Notify.NET.Abstractions;
using Notify.NET.Exceptions;
namespace Notify.NET.Platform.MacOS
{
/// <summary>
/// <see cref="INotificationService"/> implementation backed by macOS
/// <c>UNUserNotificationCenter</c> (macOS 10.14+) via a thin native Objective-C
/// wrapper (<c>libMacNotifyWrapper.dylib</c>).
///
/// Threading model:
/// <c>UNUserNotificationCenter</c> is internally thread-safe; all P/Invoke calls
/// may be made from any thread. Callbacks arrive on a background GCD thread managed
/// by the framework; consumers are responsible for marshalling to a UI thread if
/// required.
///
/// macOS-specific behaviour:
/// - A user authorisation prompt is shown on the first call (Alert + Sound + Badge).
/// - Body-tap and button-tap are terminal events; <c>onDismissed</c> is NOT fired
/// after an action response (unlike Windows where WinToastLib always fires it).
/// - Auto-expiry (timed out) does not produce a callback — macOS does not expose
/// this event to <c>UNUserNotificationCenterDelegate</c>.
/// - Non-bundled processes (e.g. bare dotnet CLI) receive the banner but may not
/// receive action callbacks depending on the OS version and app entitlements.
/// </summary>
public sealed class MacOSNotificationService : INotificationService
{
private volatile bool _disposed;
/// <inheritdoc/>
public bool IsSupported { get; private set; }
/// <param name="appName">
/// Application name used for logging. The OS uses the bundle identifier for
/// notification attribution; pass a descriptive name for diagnostic purposes.
/// </param>
public MacOSNotificationService(string appName)
{
if (appName == null) throw new ArgumentNullException(nameof(appName));
try
{
MacOSNativeLibraryLoader.EnsureLoaded();
if (!MacNotifyNative.MNW_IsSupported())
{
IsSupported = false;
return;
}
IsSupported = MacNotifyNative.MNW_Initialize(appName);
}
catch (DllNotFoundException)
{
IsSupported = false;
}
}
// ------------------------------------------------------------------
// INotificationService
// ------------------------------------------------------------------
/// <inheritdoc/>
public Task<long> ShowAsync(NotificationRequest request,
CancellationToken cancellationToken = default)
{
if (request == null) throw new ArgumentNullException(nameof(request));
ThrowIfDisposedOrUnsupported();
cancellationToken.ThrowIfCancellationRequested();
long notifId = ShowNative(request);
return Task.FromResult(notifId);
}
/// <inheritdoc/>
public Task HideAsync(long notificationId,
CancellationToken cancellationToken = default)
{
ThrowIfDisposedOrUnsupported();
cancellationToken.ThrowIfCancellationRequested();
bool ok = MacNotifyNative.MNW_HideNotification(notificationId);
// MNW_HideNotification fires onDismissed synchronously via the native
// layer; release the managed bridge entry too.
MacNotifyCallbackBridge.Release(notificationId);
if (!ok)
throw new NotificationException(
$"MNW_HideNotification failed for id {notificationId}.");
return Task.CompletedTask;
}
// ------------------------------------------------------------------
// IDisposable
// ------------------------------------------------------------------
/// <inheritdoc/>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
if (IsSupported)
MacNotifyNative.MNW_Uninitialize();
}
// ------------------------------------------------------------------
// Private helpers
// ------------------------------------------------------------------
private void ThrowIfDisposedOrUnsupported()
{
if (_disposed) throw new ObjectDisposedException(nameof(MacOSNotificationService));
if (!IsSupported) throw new Exceptions.PlatformNotSupportedException();
}
private static long ShowNative(NotificationRequest request)
{
// Marshal all strings to unmanaged UTF-8 memory for the duration of the call.
// Marshal.StringToHGlobalAnsi uses the system ANSI encoding; on macOS that is UTF-8.
using var titlePin = new PinnedStringAnsi(request.Title);
using var bodyPin = new PinnedStringAnsi(request.Body);
using var imagePin = new PinnedStringAnsi(ResolveImagePath(request.ImagePath));
// Build array of pinned button label pointers.
int btnCount = request.Buttons.Count;
var btnPins = new PinnedStringAnsi[btnCount];
var btnPtrs = new IntPtr[btnCount];
for (int i = 0; i < btnCount; i++)
{
btnPins[i] = new PinnedStringAnsi(request.Buttons[i].Label);
btnPtrs[i] = btnPins[i].Pointer;
}
try
{
// Pin the button pointer array so its address is stable during the call.
GCHandle btnArrayHandle = default;
IntPtr btnArrayPtr = IntPtr.Zero;
if (btnCount > 0)
{
btnArrayHandle = GCHandle.Alloc(btnPtrs, GCHandleType.Pinned);
btnArrayPtr = btnArrayHandle.AddrOfPinnedObject();
}
var descriptor = new MacNotifyNative.MNW_NotificationDescriptor
{
title = titlePin.Pointer,
body = bodyPin.Pointer,
imagePath = imagePin.Pointer,
buttonLabels = btnArrayPtr,
buttonCount = btnCount,
expirationMs = request.Expiration.HasValue
? (long)request.Expiration.Value.TotalMilliseconds
: 0L,
audioOption = MapAudio(request.Audio),
interruptionLevel = MapInterruptionLevel(request.Urgency)
};
var handler = new MacNotifyNative.MNW_Handler
{
onActivated = MacNotifyCallbackBridge.PtrActivated,
onButtonActivated = MacNotifyCallbackBridge.PtrButtonActivated,
onDismissed = MacNotifyCallbackBridge.PtrDismissed,
onFailed = MacNotifyCallbackBridge.PtrFailed
};
long notifId = MacNotifyNative.MNW_ShowNotification(ref descriptor, ref handler);
if (btnArrayHandle.IsAllocated)
btnArrayHandle.Free();
if (notifId < 0)
throw new NotificationException(
$"MNW_ShowNotification failed with code {notifId}.");
// Register managed bridge before any callback can fire.
MacNotifyCallbackBridge.Register(notifId, BuildCompositeHandler(request));
return notifId;
}
finally
{
foreach (var pin in btnPins)
pin.Dispose();
}
}
private static INotificationHandler? BuildCompositeHandler(NotificationRequest request)
{
bool hasButtonCallbacks = false;
foreach (var btn in request.Buttons)
if (btn.Callback != null) { hasButtonCallbacks = true; break; }
if (!hasButtonCallbacks)
return request.Handler;
return new CompositeHandler(request.Handler, request.Buttons);
}
private static int MapAudio(NotificationAudio audio)
{
return audio == NotificationAudio.Silent
? 1 /* MNW_AUDIO_SILENT */
: 0 /* MNW_AUDIO_DEFAULT */;
}
private static int MapInterruptionLevel(NotificationUrgency urgency)
{
switch (urgency)
{
case NotificationUrgency.Low: return 1; // MNW_INTERRUPTION_PASSIVE
case NotificationUrgency.Critical:
case NotificationUrgency.Alarm: return 3; // MNW_INTERRUPTION_CRITICAL
default: return 0; // MNW_INTERRUPTION_ACTIVE
}
}
/// <summary>
/// Resolves a (possibly relative) image path to an absolute path and verifies the file
/// exists. Returns null if the path is empty, unresolvable, or missing so that the
/// native layer skips the attachment rather than failing the whole notification.
/// </summary>
private static string? ResolveImagePath(string? path)
{
if (string.IsNullOrEmpty(path)) return null;
try
{
string absolute = Path.IsPathRooted(path) ? path : Path.GetFullPath(path);
return File.Exists(absolute) ? absolute : null;
}
catch (Exception) { return null; }
}
// ------------------------------------------------------------------
// Inner helpers
// ------------------------------------------------------------------
/// <summary>
/// Copies a .NET string to unmanaged ANSI (UTF-8 on macOS) memory.
/// The allocation is freed on <see cref="Dispose"/>.
/// </summary>
private sealed class PinnedStringAnsi : IDisposable
{
public IntPtr Pointer { get; }
public PinnedStringAnsi(string? value)
{
Pointer = value != null
? Marshal.StringToHGlobalAnsi(value)
: IntPtr.Zero;
}
public void Dispose()
{
if (Pointer != IntPtr.Zero)
Marshal.FreeHGlobal(Pointer);
}
}
/// <summary>
/// Combines a top-level <see cref="INotificationHandler"/> with per-button callbacks
/// stored in <see cref="Builder.NotificationButton.Callback"/>.
/// </summary>
private sealed class CompositeHandler : INotificationHandler
{
private readonly INotificationHandler? _inner;
private readonly System.Collections.Generic.IReadOnlyList<Builder.NotificationButton> _buttons;
public CompositeHandler(
INotificationHandler? inner,
System.Collections.Generic.IReadOnlyList<Builder.NotificationButton> buttons)
{
_inner = inner;
_buttons = buttons;
}
public void OnActivated(long id) => _inner?.OnActivated(id);
public void OnButtonActivated(long id, int index)
{
if (index >= 0 && index < _buttons.Count)
_buttons[index].Callback?.Invoke(id);
_inner?.OnButtonActivated(id, index);
}
public void OnDismissed(long id, DismissReason reason) =>
_inner?.OnDismissed(id, reason);
public void OnFailed(long id) => _inner?.OnFailed(id);
}
}
}

View file

@ -0,0 +1,93 @@
using System;
using System.IO;
using System.Reflection;
using System.Runtime.InteropServices;
namespace Notify.NET.Platform.Windows
{
/// <summary>
/// Loads WinToastWrapper.dll from the correct runtime-identifier sub-folder before
/// the first P/Invoke call is made. This ensures the x64/x86/arm64 variant that
/// matches the current process architecture is used.
///
/// Once LoadLibraryW succeeds, subsequent DllImport("WinToastWrapper") resolutions
/// find the already-loaded module in the process module list automatically.
/// </summary>
internal static class NativeLibraryLoader
{
private static volatile bool _loaded;
private static readonly object _lock = new object();
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern IntPtr LoadLibraryW(string lpLibFileName);
internal static void EnsureLoaded()
{
if (_loaded) return;
lock (_lock)
{
if (_loaded) return;
string rid = GetRuntimeIdentifier();
string dllPath = ResolveNativePath(rid);
IntPtr handle = LoadLibraryW(dllPath);
if (handle == IntPtr.Zero)
{
int err = Marshal.GetLastWin32Error();
throw new DllNotFoundException(
$"Failed to load WinToastWrapper.dll from '{dllPath}' (Win32 error {err}). " +
"Ensure the native DLL for your platform architecture is present in the " +
$"runtimes/{rid}/native/ directory relative to the assembly.");
}
_loaded = true;
}
}
private static string GetRuntimeIdentifier()
{
switch (RuntimeInformation.ProcessArchitecture)
{
case Architecture.X64: return "win-x64";
case Architecture.X86: return "win-x86";
case Architecture.Arm64: return "win-arm64";
default:
throw new PlatformNotSupportedException(
$"No WinToastWrapper.dll is available for architecture {RuntimeInformation.ProcessArchitecture}.");
}
}
private static string ResolveNativePath(string rid)
{
// Search order:
// 1. Alongside the executing assembly (output directory, typical for app projects)
// 2. Relative to the assembly's location using the NuGet runtimes layout
string assemblyDir = Path.GetDirectoryName(
new Uri(typeof(NativeLibraryLoader).Assembly.CodeBase!).LocalPath)!;
// Typical publish output: <outdir>/WinToastWrapper.dll (copied by MSBuild)
string flat = Path.Combine(assemblyDir, "WinToastWrapper.dll");
if (File.Exists(flat)) return flat;
// NuGet runtimes layout: <assemblyDir>/runtimes/<rid>/native/WinToastWrapper.dll
string runtimePath = Path.Combine(assemblyDir, "runtimes", rid, "native", "WinToastWrapper.dll");
if (File.Exists(runtimePath)) return runtimePath;
// Fallback: relative to the entry assembly location
string? entryDir = Path.GetDirectoryName(Assembly.GetEntryAssembly()?.Location);
if (entryDir != null)
{
string entryRuntime = Path.Combine(entryDir, "runtimes", rid, "native", "WinToastWrapper.dll");
if (File.Exists(entryRuntime)) return entryRuntime;
string entryFlat = Path.Combine(entryDir, "WinToastWrapper.dll");
if (File.Exists(entryFlat)) return entryFlat;
}
// Return the NuGet path even if not found — LoadLibraryW will fail with a useful error
return Path.Combine(assemblyDir, "runtimes", rid, "native", "WinToastWrapper.dll");
}
}
}

View file

@ -0,0 +1,158 @@
using System;
using System.Collections.Concurrent;
using System.Runtime.InteropServices;
using Notify.NET.Abstractions;
namespace Notify.NET.Platform.Windows
{
/// <summary>
/// Bridges the unmanaged WinToastWrapper callbacks back to the managed
/// <see cref="INotificationHandler"/> for each in-flight toast.
///
/// Design rules that MUST be maintained to avoid memory-safety bugs:
///
/// 1. The four static delegates (<see cref="_staticActivated"/> etc.) are kept alive
/// for the process lifetime because they are stored in static fields. Their function
/// pointers are therefore permanently valid for unmanaged code to call.
///
/// 2. Per-notification state is held in <see cref="WinToastHandlerBridge"/> instances
/// tracked in the static <see cref="_live"/> dictionary. Each instance's GCHandle
/// prevents the GC from collecting it while the toast is alive.
///
/// 3. Routing works by passing the toast ID (long) back through the static callbacks,
/// which look up the matching bridge instance in <see cref="_live"/>.
///
/// 4. <see cref="Release"/> is called exactly once, from whichever callback fires last
/// (dismissed or failed). It removes the entry and frees the GCHandle.
/// </summary>
internal sealed class WinToastHandlerBridge
{
// ------------------------------------------------------------------
// Static callback function pointers — allocated once, never collected
// ------------------------------------------------------------------
private static readonly WinToastNative.ActivatedCallback _staticActivated;
private static readonly WinToastNative.ButtonActivatedCallback _staticButtonActivated;
private static readonly WinToastNative.DismissedCallback _staticDismissed;
private static readonly WinToastNative.FailedCallback _staticFailed;
// Pointer-sized function pointers stored in the WNT_Handler struct
internal static readonly IntPtr PtrActivated;
internal static readonly IntPtr PtrButtonActivated;
internal static readonly IntPtr PtrDismissed;
internal static readonly IntPtr PtrFailed;
// ------------------------------------------------------------------
// Static dictionary: toastId → live bridge instance
// ------------------------------------------------------------------
private static readonly ConcurrentDictionary<long, WinToastHandlerBridge> _live
= new ConcurrentDictionary<long, WinToastHandlerBridge>();
// ------------------------------------------------------------------
// Per-instance state
// ------------------------------------------------------------------
private readonly INotificationHandler? _handler;
private GCHandle _gcHandle; // keeps this bridge alive from unmanaged side
static WinToastHandlerBridge()
{
// Create static delegates and pin their function pointers permanently.
_staticActivated = OnActivatedStatic;
_staticButtonActivated = OnButtonActivatedStatic;
_staticDismissed = OnDismissedStatic;
_staticFailed = OnFailedStatic;
PtrActivated = Marshal.GetFunctionPointerForDelegate(_staticActivated);
PtrButtonActivated = Marshal.GetFunctionPointerForDelegate(_staticButtonActivated);
PtrDismissed = Marshal.GetFunctionPointerForDelegate(_staticDismissed);
PtrFailed = Marshal.GetFunctionPointerForDelegate(_staticFailed);
}
private WinToastHandlerBridge(INotificationHandler? handler)
{
_handler = handler;
// Allocate a GCHandle so the GC cannot collect this instance.
_gcHandle = GCHandle.Alloc(this, GCHandleType.Normal);
}
/// <summary>
/// Creates a bridge and registers it under <paramref name="toastId"/>.
/// Call this immediately after <see cref="WinToastNative.WNT_ShowToast"/> returns a positive ID.
/// </summary>
internal static WinToastHandlerBridge Register(long toastId, INotificationHandler? handler)
{
var bridge = new WinToastHandlerBridge(handler);
_live[toastId] = bridge;
return bridge;
}
/// <summary>
/// Removes the bridge for <paramref name="toastId"/> and releases its GCHandle.
/// Safe to call multiple times; subsequent calls are no-ops.
/// </summary>
internal static void Release(long toastId)
{
if (_live.TryRemove(toastId, out var bridge) && bridge._gcHandle.IsAllocated)
bridge._gcHandle.Free();
}
// ------------------------------------------------------------------
// Static routing callbacks — invoked by unmanaged code on a WinRT thread
// ------------------------------------------------------------------
private static void OnActivatedStatic(long toastId)
{
// Must not let exceptions escape to native code — unhandled exceptions
// on WinRT callback threads crash the process with no useful error.
try
{
if (_live.TryGetValue(toastId, out var bridge))
bridge._handler?.OnActivated(toastId);
}
catch { /* swallow — caller cannot handle managed exceptions */ }
}
private static void OnButtonActivatedStatic(long toastId, int buttonIndex)
{
try
{
if (_live.TryGetValue(toastId, out var bridge))
bridge._handler?.OnButtonActivated(toastId, buttonIndex);
}
catch { }
}
private static void OnDismissedStatic(long toastId, int reason)
{
try
{
if (_live.TryGetValue(toastId, out var bridge))
bridge._handler?.OnDismissed(toastId, MapDismissReason(reason));
}
catch { }
finally { Release(toastId); }
}
private static void OnFailedStatic(long toastId)
{
try
{
if (_live.TryGetValue(toastId, out var bridge))
bridge._handler?.OnFailed(toastId);
}
catch { }
finally { Release(toastId); }
}
private static DismissReason MapDismissReason(int native)
{
// WinToastDismissalReason: 0=UserCancelled, 1=ApplicationHidden, 2=TimedOut
switch (native)
{
case 0: return DismissReason.UserCancelled;
case 1: return DismissReason.ApplicationHidden;
case 2: return DismissReason.TimedOut;
default: return DismissReason.Unknown;
}
}
}
}

View file

@ -0,0 +1,120 @@
using System;
using System.Runtime.InteropServices;
namespace Notify.NET.Platform.Windows
{
/// <summary>
/// P/Invoke declarations for WinToastWrapper.dll — the native C DLL that wraps WinToastLib.
/// All strings are UTF-16 (Unicode) to match the wchar_t* ABI of the wrapper.
/// The DLL must be loaded via <see cref="NativeLibraryLoader"/> before these are called.
/// </summary>
internal static class WinToastNative
{
private const string DllName = "WinToastWrapper";
// -------------------------------------------------------------------------
// Unmanaged callback delegate types.
// IMPORTANT: These must be static fields — never pass instance delegates as
// unmanaged function pointers. The GC does not see unmanaged references and
// will collect instance delegates, producing an AccessViolationException.
// -------------------------------------------------------------------------
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
internal delegate void ActivatedCallback(long toastId);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
internal delegate void ButtonActivatedCallback(long toastId, int buttonIndex);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
internal delegate void DismissedCallback(long toastId, int reason);
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
internal delegate void FailedCallback(long toastId);
// -------------------------------------------------------------------------
// Structs matching the C ABI of WinToastWrapper.h
// -------------------------------------------------------------------------
/// <summary>
/// Plain-data descriptor passed to <see cref="WNT_ShowToast"/>.
/// String fields are pointers into pinned managed memory — callers must
/// keep the pinned handles alive for the duration of the call.
/// </summary>
[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)
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
}
/// <summary>
/// Struct of four function pointers passed to <see cref="WNT_ShowToast"/>.
/// Must be pinned for the lifetime of the toast (until dismissed or failed).
/// </summary>
[StructLayout(LayoutKind.Sequential)]
internal struct WNT_Handler
{
public IntPtr onActivated; // ActivatedCallback
public IntPtr onButtonActivated; // ButtonActivatedCallback
public IntPtr onDismissed; // DismissedCallback
public IntPtr onFailed; // FailedCallback
}
// 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_INCOMING_CALL = 3;
// WNT_AudioOption values (must match enum in WinToastWrapper.h)
internal const int WNT_AUDIO_DEFAULT = 0;
internal const int WNT_AUDIO_SILENT = 1;
internal const int WNT_AUDIO_LOOP = 2;
// -------------------------------------------------------------------------
// Exported functions
// -------------------------------------------------------------------------
/// <summary>
/// Initialises WinToastLib. Must be called once from an STA thread before any other function.
/// </summary>
/// <param name="appName">Human-readable application name shown in the Action Centre.</param>
/// <param name="appUserModelId">
/// The AppUserModelId (AUMI) — must match the shortcut in the Start Menu.
/// The wrapper creates the shortcut automatically if it doesn't exist.
/// </param>
/// <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);
/// <summary>Uninitialises WinToastLib and releases all internal resources.</summary>
[DllImport(DllName, EntryPoint = "WNT_Uninitialize")]
internal static extern void WNT_Uninitialize();
/// <summary>Returns true if WinToast is supported on this version of Windows (requires Win 8+).</summary>
[DllImport(DllName, EntryPoint = "WNT_IsCompatible")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool WNT_IsCompatible();
/// <summary>
/// Shows a toast notification. Must be called from the STA thread.
/// </summary>
/// <param name="descriptor">Pointer to a <see cref="WNT_ToastDescriptor"/> with notification data.</param>
/// <param name="handler">Pointer to a <see cref="WNT_Handler"/> with callback function pointers.</param>
/// <returns>A positive toast ID on success, or a negative error code on failure.</returns>
[DllImport(DllName, EntryPoint = "WNT_ShowToast")]
internal static extern long WNT_ShowToast(ref WNT_ToastDescriptor descriptor, ref WNT_Handler handler);
/// <summary>Programmatically dismisses a previously shown toast. Must be called from the STA thread.</summary>
[DllImport(DllName, EntryPoint = "WNT_HideToast")]
[return: MarshalAs(UnmanagedType.Bool)]
internal static extern bool WNT_HideToast(long toastId);
}
}

View file

@ -0,0 +1,417 @@
using System;
using System.Collections.Concurrent;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using Notify.NET.Abstractions;
using Notify.NET.Exceptions;
namespace Notify.NET.Platform.Windows
{
/// <summary>
/// <see cref="INotificationService"/> implementation backed by WinToastLib via a thin
/// native C wrapper DLL (<c>WinToastWrapper.dll</c>).
///
/// Threading model:
/// WinRT toast APIs require a Single-Threaded Apartment (STA). This service owns a
/// dedicated STA background thread that runs a Win32 message pump. All P/Invoke calls
/// are marshalled onto that thread via a work-item queue. Callbacks from WinToast arrive
/// on a WinRT thread-pool thread (NOT the STA thread) and are safe to dispatch directly.
/// </summary>
public sealed class WindowsNotificationService : INotificationService
{
private readonly string _appName;
private readonly string _appUserModelId;
private readonly Thread _staThread;
private readonly BlockingCollection<Action> _workQueue = new BlockingCollection<Action>();
private readonly ManualResetEventSlim _initialised = new ManualResetEventSlim(false);
private volatile bool _isSupported;
private volatile bool _disposed;
private Exception? _initException;
/// <inheritdoc/>
public bool IsSupported => _isSupported;
/// <param name="appName">Human-readable application name (shown in Action Centre).</param>
/// <param name="appUserModelId">
/// Your application's AppUserModelId, e.g. <c>"MyCompany.MyApp"</c>.
/// 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)
{
_appName = appName ?? throw new ArgumentNullException(nameof(appName));
_appUserModelId = appUserModelId ?? throw new ArgumentNullException(nameof(appUserModelId));
_staThread = new Thread(StaThreadProc)
{
Name = "Notify.NET STA",
IsBackground = true
};
_staThread.SetApartmentState(ApartmentState.STA);
_staThread.Start();
// Block until the STA thread has finished initialising (or failed).
_initialised.Wait();
if (_initException != null)
throw new NotificationException("WinToastLib initialisation failed.", _initException);
}
// ------------------------------------------------------------------
// INotificationService
// ------------------------------------------------------------------
/// <inheritdoc/>
public Task<long> ShowAsync(NotificationRequest request, CancellationToken cancellationToken = default)
{
if (request == null) throw new ArgumentNullException(nameof(request));
ThrowIfDisposedOrUnsupported();
var tcs = new TaskCompletionSource<long>();
cancellationToken.Register(() => tcs.TrySetCanceled());
EnqueueOnSta(() =>
{
try
{
long id = ShowOnSta(request);
tcs.TrySetResult(id);
}
catch (Exception ex)
{
tcs.TrySetException(ex);
}
});
return tcs.Task;
}
/// <inheritdoc/>
public Task HideAsync(long notificationId, CancellationToken cancellationToken = default)
{
ThrowIfDisposedOrUnsupported();
var tcs = new TaskCompletionSource<bool>();
cancellationToken.Register(() => tcs.TrySetCanceled());
EnqueueOnSta(() =>
{
try
{
bool ok = WinToastNative.WNT_HideToast(notificationId);
if (!ok)
tcs.TrySetException(new NotificationException($"WNT_HideToast failed for id {notificationId}."));
else
{
WinToastHandlerBridge.Release(notificationId);
tcs.TrySetResult(true);
}
}
catch (Exception ex)
{
tcs.TrySetException(ex);
}
});
return tcs.Task;
}
// ------------------------------------------------------------------
// IDisposable
// ------------------------------------------------------------------
/// <inheritdoc/>
public void Dispose()
{
if (_disposed) return;
_disposed = true;
// Signal the STA thread to shut down by completing the queue.
_workQueue.CompleteAdding();
// Wait for the STA thread to finish its message pump and uninitialise.
if (_staThread.IsAlive)
_staThread.Join(TimeSpan.FromSeconds(5));
_workQueue.Dispose();
_initialised.Dispose();
}
// ------------------------------------------------------------------
// Private helpers
// ------------------------------------------------------------------
private void ThrowIfDisposedOrUnsupported()
{
if (_disposed) throw new ObjectDisposedException(nameof(WindowsNotificationService));
if (!_isSupported) throw new Exceptions.PlatformNotSupportedException();
}
private void EnqueueOnSta(Action action)
{
try { _workQueue.Add(action); }
catch (InvalidOperationException) { /* queue completed — service is disposed */ }
}
/// <summary>
/// The STA thread entry point. Runs a simple work-item loop as the message pump.
/// </summary>
private void StaThreadProc()
{
try
{
NativeLibraryLoader.EnsureLoaded();
if (!WinToastNative.WNT_IsCompatible())
{
_isSupported = false;
_initialised.Set();
return;
}
bool ok = WinToastNative.WNT_Initialize(_appName, _appUserModelId);
if (!ok)
{
_initException = new NotificationException("WNT_Initialize returned false.");
_isSupported = false;
_initialised.Set();
return;
}
_isSupported = true;
_initialised.Set();
// Process work items until Dispose() calls CompleteAdding().
foreach (Action work in _workQueue.GetConsumingEnumerable())
{
// Pump pending Windows messages between work items so WinRT callbacks
// can be delivered to the STA message queue.
PumpMessages();
work();
PumpMessages();
}
}
catch (Exception ex)
{
_initException = ex;
_isSupported = false;
_initialised.Set();
}
finally
{
// Drain any remaining messages before uninitialising.
PumpMessages();
if (_isSupported)
WinToastNative.WNT_Uninitialize();
}
}
private long ShowOnSta(NotificationRequest request)
{
// We need to pass wchar_t* pointers to the native layer.
// 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));
// Build array of pinned button label pointers.
var buttonPins = new PinnedString[request.Buttons.Count];
var buttonPtrs = new IntPtr[request.Buttons.Count];
for (int i = 0; i < request.Buttons.Count; i++)
{
buttonPins[i] = new PinnedString(request.Buttons[i].Label);
buttonPtrs[i] = buttonPins[i].Pointer;
}
try
{
// Pin the button pointer array itself.
GCHandle buttonArrayHandle = default;
IntPtr buttonArrayPtr = IntPtr.Zero;
if (buttonPtrs.Length > 0)
{
buttonArrayHandle = GCHandle.Alloc(buttonPtrs, GCHandleType.Pinned);
buttonArrayPtr = buttonArrayHandle.AddrOfPinnedObject();
}
var descriptor = new WinToastNative.WNT_ToastDescriptor
{
title = titlePin.Pointer,
body = bodyPin.Pointer,
imagePath = imagePin.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)
};
var handler = new WinToastNative.WNT_Handler
{
onActivated = WinToastHandlerBridge.PtrActivated,
onButtonActivated = WinToastHandlerBridge.PtrButtonActivated,
onDismissed = WinToastHandlerBridge.PtrDismissed,
onFailed = WinToastHandlerBridge.PtrFailed
};
long toastId = WinToastNative.WNT_ShowToast(ref descriptor, ref handler);
if (buttonArrayHandle.IsAllocated)
buttonArrayHandle.Free();
if (toastId < 0)
throw new NotificationException($"WNT_ShowToast failed with error code {toastId}.", (int)toastId);
// Register the per-notification bridge BEFORE any callback can fire.
WinToastHandlerBridge.Register(toastId, BuildCompositeHandler(request));
return toastId;
}
finally
{
foreach (var pin in buttonPins)
pin.Dispose();
}
}
/// <summary>
/// Builds an <see cref="INotificationHandler"/> that combines the request-level handler
/// with per-button callbacks defined on each <see cref="Builder.NotificationButton"/>.
/// </summary>
private static INotificationHandler? BuildCompositeHandler(NotificationRequest request)
{
bool hasButtonCallbacks = false;
foreach (var btn in request.Buttons)
if (btn.Callback != null) { hasButtonCallbacks = true; break; }
if (!hasButtonCallbacks)
return request.Handler;
return new CompositeHandler(request.Handler, request.Buttons);
}
private static int MapScenario(NotificationUrgency urgency)
{
switch (urgency)
{
case NotificationUrgency.Alarm: return WinToastNative.WNT_SCENARIO_ALARM;
case NotificationUrgency.Reminder: return WinToastNative.WNT_SCENARIO_REMINDER;
default: return WinToastNative.WNT_SCENARIO_DEFAULT;
}
}
private static int MapAudio(NotificationAudio audio)
{
switch (audio)
{
case NotificationAudio.Silent: return WinToastNative.WNT_AUDIO_SILENT;
case NotificationAudio.Loop: return WinToastNative.WNT_AUDIO_LOOP;
default: return WinToastNative.WNT_AUDIO_DEFAULT;
}
}
/// <summary>Pumps pending Win32/WinRT messages on the STA thread.</summary>
private static void PumpMessages()
{
NativeMessage msg;
while (PeekMessageW(out msg, IntPtr.Zero, 0, 0, 0x0001 /* PM_REMOVE */))
{
TranslateMessage(ref msg);
DispatchMessageW(ref msg);
}
}
[DllImport("user32.dll")] private static extern bool PeekMessageW(out NativeMessage msg, IntPtr hwnd, uint min, uint max, uint remove);
[DllImport("user32.dll")] private static extern bool TranslateMessage(ref NativeMessage msg);
[DllImport("user32.dll")] private static extern IntPtr DispatchMessageW(ref NativeMessage msg);
[StructLayout(LayoutKind.Sequential)]
private struct NativeMessage
{
public IntPtr hwnd;
public uint msg;
public IntPtr wParam;
public IntPtr lParam;
public uint time;
public int ptX;
public int ptY;
}
// ------------------------------------------------------------------
// Inner helpers
// ------------------------------------------------------------------
/// <summary>
/// Resolves a (possibly relative) image path to an absolute path and verifies
/// the file exists. Returns null if the path is empty, unresolvable, or the
/// file is not found — causing the native layer to skip the image rather than crash.
/// WinToastLib embeds the path verbatim into toast XML; it cannot handle
/// relative paths or missing files gracefully.
/// </summary>
private static string? ResolveImagePath(string? path)
{
if (string.IsNullOrEmpty(path)) return null;
try
{
string absolute = Path.IsPathRooted(path) ? path : Path.GetFullPath(path);
return File.Exists(absolute) ? absolute : null;
}
catch (Exception) { return null; }
}
/// <summary>
/// Copies a .NET string into unmanaged UTF-16 memory so it can be passed as
/// a wchar_t* to native code. The memory is freed on Dispose.
/// </summary>
private sealed class PinnedString : IDisposable
{
public IntPtr Pointer { get; }
public PinnedString(string? value)
{
Pointer = value != null
? Marshal.StringToHGlobalUni(value)
: IntPtr.Zero;
}
public void Dispose()
{
if (Pointer != IntPtr.Zero)
Marshal.FreeHGlobal(Pointer);
}
}
/// <summary>
/// Combines a top-level <see cref="INotificationHandler"/> with per-button callbacks.
/// </summary>
private sealed class CompositeHandler : INotificationHandler
{
private readonly INotificationHandler? _inner;
private readonly System.Collections.Generic.IReadOnlyList<Builder.NotificationButton> _buttons;
public CompositeHandler(INotificationHandler? inner,
System.Collections.Generic.IReadOnlyList<Builder.NotificationButton> buttons)
{
_inner = inner;
_buttons = buttons;
}
public void OnActivated(long id) => _inner?.OnActivated(id);
public void OnButtonActivated(long id, int index)
{
if (index >= 0 && index < _buttons.Count)
_buttons[index].Callback?.Invoke(id);
_inner?.OnButtonActivated(id, index);
}
public void OnDismissed(long id, DismissReason reason) => _inner?.OnDismissed(id, reason);
public void OnFailed(long id) => _inner?.OnFailed(id);
}
}
}