commit 099012cfd3e1f4985c433336204f3625aa49a240 Author: Pat Hartl Date: Sun Mar 29 13:56:50 2026 -0500 Initial commit diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..43da5aa --- /dev/null +++ b/.github/workflows/release.yml @@ -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//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// + # 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 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..20886d6 --- /dev/null +++ b/.gitignore @@ -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 \ No newline at end of file diff --git a/Notify.NET.sln b/Notify.NET.sln new file mode 100644 index 0000000..b409878 --- /dev/null +++ b/Notify.NET.sln @@ -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 diff --git a/README.md b/README.md new file mode 100644 index 0000000..1600a4f --- /dev/null +++ b/README.md @@ -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(); +``` + +### 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 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-/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-\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. diff --git a/native/MacNotifyWrapper/MacNotifyWrapper.h b/native/MacNotifyWrapper/MacNotifyWrapper.h new file mode 100644 index 0000000..ec59e44 --- /dev/null +++ b/native/MacNotifyWrapper/MacNotifyWrapper.h @@ -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 +#include + +#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; /* 0–5 */ + 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 diff --git a/native/MacNotifyWrapper/MacNotifyWrapper.m b/native/MacNotifyWrapper/MacNotifyWrapper.m new file mode 100644 index 0000000..8fa09e9 --- /dev/null +++ b/native/MacNotifyWrapper/MacNotifyWrapper.m @@ -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 +#import +#include +#include +#include "MacNotifyWrapper.h" + +/* ------------------------------------------------------------------------- + * Per-notification heap state + * ------------------------------------------------------------------------- */ + +typedef struct { + int64_t notifId; + MNW_Handler handler; +} NotifState; + +/* ------------------------------------------------------------------------- + * Delegate + * ------------------------------------------------------------------------- */ + +@interface MNWDelegate : NSObject +@end + +/* ------------------------------------------------------------------------- + * Process-lifetime globals + * ------------------------------------------------------------------------- */ + +static MNWDelegate* g_delegate = nil; +static NSLock* g_lock = nil; +/* strId → NSValue wrapping NotifState* (heap-allocated) */ +static NSMutableDictionary* g_entries = nil; +/* int64 NSNumber → strId NSString */ +static NSMutableDictionary* g_idMap = nil; +/* category identifiers we have registered */ +static NSMutableSet* g_categoryIds = nil; +/* UNNotificationCategory objects corresponding to the above */ +static NSMutableSet* 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* 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* 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; +} diff --git a/native/MacNotifyWrapper/Makefile b/native/MacNotifyWrapper/Makefile new file mode 100644 index 0000000..f7f922e --- /dev/null +++ b/native/MacNotifyWrapper/Makefile @@ -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 diff --git a/native/WinToastWrapper/WinToastWrapper.cpp b/native/WinToastWrapper/WinToastWrapper.cpp new file mode 100644 index 0000000..dcf0097 --- /dev/null +++ b/native/WinToastWrapper/WinToastWrapper.cpp @@ -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 +#include +#include +#include +#include +#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 + * 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 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 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(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(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 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 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(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(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 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 lock(g_mutex); + g_handlers.erase(toastId); + } + + return ok ? TRUE : FALSE; +} + +} /* extern "C" */ diff --git a/native/WinToastWrapper/WinToastWrapper.h b/native/WinToastWrapper/WinToastWrapper.h new file mode 100644 index 0000000..034d77c --- /dev/null +++ b/native/WinToastWrapper/WinToastWrapper.h @@ -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 + +#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 diff --git a/native/WinToastWrapper/WinToastWrapper.vcxproj b/native/WinToastWrapper/WinToastWrapper.vcxproj new file mode 100644 index 0000000..3ca25a0 --- /dev/null +++ b/native/WinToastWrapper/WinToastWrapper.vcxproj @@ -0,0 +1,92 @@ + + + + + + Release + Win32 + + + Release + x64 + + + Release + ARM64 + + + Debug + x64 + + + + + 17.0 + {C1D2E3F4-A5B6-7890-CDEF-012345678901} + WinToastWrapper + 10.0 + + + + + + DynamicLibrary + false + v143 + true + Unicode + + + + DynamicLibrary + true + v143 + Unicode + + + + + + + ..\..\runtimes\win-x64\native\ + ..\..\runtimes\win-x86\native\ + ..\..\runtimes\win-arm64\native\ + WinToastWrapper + + + + + Level3 + stdcpp17 + + WINTOASTWRAPPER_EXPORTS; + _WINDOWS; + _WIN32_WINNT=0x0602; + WIN32_LEAN_AND_MEAN; + NOMINMAX; + %(PreprocessorDefinitions) + + MultiThreaded + MultiThreadedDebug + Sync + $(ProjectDir)vendor;%(AdditionalIncludeDirectories) + + + Windows + Ole32.lib;Shlwapi.lib;Shell32.lib;%(AdditionalDependencies) + + + + + + + + + + + + + + + + diff --git a/native/WinToastWrapper/vendor/wintoastlib.cpp b/native/WinToastWrapper/vendor/wintoastlib.cpp new file mode 100644 index 0000000..4317740 --- /dev/null +++ b/native/WinToastWrapper/vendor/wintoastlib.cpp @@ -0,0 +1,1492 @@ +/** + * MIT License + * + * Copyright (C) 2016-2023 WinToast v1.3.0 - Mohammed Boujemaoui + * + * 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. +*/ + +#include "wintoastlib.h" + +#include +#include +#include +#include +#include + +#pragma comment(lib, "shlwapi") +#pragma comment(lib, "user32") + +#define DEFAULT_SHELL_LINKS_PATH L"\\Microsoft\\Windows\\Start Menu\\Programs\\" +#define DEFAULT_LINK_FORMAT L".lnk" +#define STATUS_SUCCESS (0x00000000) + +#ifdef NDEBUG +static bool DebugOutputEnabled = false; +#else +static bool DebugOutputEnabled = true; +#endif + +#define DEBUG_MSG(str) \ + do { \ + if (DebugOutputEnabled) { \ + std::wcout << str << std::endl; \ + } \ + } while (false) + +// Quickstart: Handling toast activations from Win32 apps in Windows 10 +// https://blogs.msdn.microsoft.com/tiles_and_toasts/2015/10/16/quickstart-handling-toast-activations-from-win32-apps-in-windows-10/ +using namespace WinToastLib; + +void WinToastLib::setDebugOutputEnabled(bool enabled) { + DebugOutputEnabled = enabled; +} + +namespace DllImporter { + + // Function load a function from library + template + HRESULT loadFunctionFromLibrary(HINSTANCE library, LPCSTR name, Function& func) { + if (!library) { + return E_INVALIDARG; + } + func = reinterpret_cast(GetProcAddress(library, name)); + return (func != nullptr) ? S_OK : E_FAIL; + } + + typedef HRESULT(FAR STDAPICALLTYPE* f_SetCurrentProcessExplicitAppUserModelID)(__in PCWSTR AppID); + typedef HRESULT(FAR STDAPICALLTYPE* f_PropVariantToString)(_In_ REFPROPVARIANT propvar, _Out_writes_(cch) PWSTR psz, _In_ UINT cch); + typedef HRESULT(FAR STDAPICALLTYPE* f_RoGetActivationFactory)(_In_ HSTRING activatableClassId, _In_ REFIID iid, + _COM_Outptr_ void** factory); + typedef HRESULT(FAR STDAPICALLTYPE* f_WindowsCreateStringReference)(_In_reads_opt_(length + 1) PCWSTR sourceString, UINT32 length, + _Out_ HSTRING_HEADER* hstringHeader, + _Outptr_result_maybenull_ _Result_nullonfailure_ HSTRING* string); + typedef PCWSTR(FAR STDAPICALLTYPE* f_WindowsGetStringRawBuffer)(_In_ HSTRING string, _Out_opt_ UINT32* length); + typedef HRESULT(FAR STDAPICALLTYPE* f_WindowsDeleteString)(_In_opt_ HSTRING string); + + static f_SetCurrentProcessExplicitAppUserModelID SetCurrentProcessExplicitAppUserModelID; + static f_PropVariantToString PropVariantToString; + static f_RoGetActivationFactory RoGetActivationFactory; + static f_WindowsCreateStringReference WindowsCreateStringReference; + static f_WindowsGetStringRawBuffer WindowsGetStringRawBuffer; + static f_WindowsDeleteString WindowsDeleteString; + + template + __inline _Check_return_ HRESULT _1_GetActivationFactory(_In_ HSTRING activatableClassId, _COM_Outptr_ T** factory) { + return RoGetActivationFactory(activatableClassId, IID_INS_ARGS(factory)); + } + + template + inline HRESULT Wrap_GetActivationFactory(_In_ HSTRING activatableClassId, _Inout_ Details::ComPtrRef factory) noexcept { + return _1_GetActivationFactory(activatableClassId, factory.ReleaseAndGetAddressOf()); + } + + inline HRESULT initialize() { + HINSTANCE LibShell32 = LoadLibraryW(L"SHELL32.DLL"); + HRESULT hr = + loadFunctionFromLibrary(LibShell32, "SetCurrentProcessExplicitAppUserModelID", SetCurrentProcessExplicitAppUserModelID); + if (SUCCEEDED(hr)) { + HINSTANCE LibPropSys = LoadLibraryW(L"PROPSYS.DLL"); + hr = loadFunctionFromLibrary(LibPropSys, "PropVariantToString", PropVariantToString); + if (SUCCEEDED(hr)) { + HINSTANCE LibComBase = LoadLibraryW(L"COMBASE.DLL"); + bool const succeded = + SUCCEEDED(loadFunctionFromLibrary(LibComBase, "RoGetActivationFactory", RoGetActivationFactory)) && + SUCCEEDED(loadFunctionFromLibrary(LibComBase, "WindowsCreateStringReference", WindowsCreateStringReference)) && + SUCCEEDED(loadFunctionFromLibrary(LibComBase, "WindowsGetStringRawBuffer", WindowsGetStringRawBuffer)) && + SUCCEEDED(loadFunctionFromLibrary(LibComBase, "WindowsDeleteString", WindowsDeleteString)); + return succeded ? S_OK : E_FAIL; + } + } + return hr; + } +} // namespace DllImporter + +class WinToastStringWrapper { +public: + WinToastStringWrapper(_In_reads_(length) PCWSTR stringRef, _In_ UINT32 length) noexcept { + HRESULT hr = DllImporter::WindowsCreateStringReference(stringRef, length, &_header, &_hstring); + if (!SUCCEEDED(hr)) { + RaiseException(static_cast(STATUS_INVALID_PARAMETER), EXCEPTION_NONCONTINUABLE, 0, nullptr); + } + } + + WinToastStringWrapper(_In_ std::wstring const& stringRef) noexcept { + HRESULT hr = + DllImporter::WindowsCreateStringReference(stringRef.c_str(), static_cast(stringRef.length()), &_header, &_hstring); + if (FAILED(hr)) { + RaiseException(static_cast(STATUS_INVALID_PARAMETER), EXCEPTION_NONCONTINUABLE, 0, nullptr); + } + } + + ~WinToastStringWrapper() { + DllImporter::WindowsDeleteString(_hstring); + } + + inline HSTRING Get() const noexcept { + return _hstring; + } + +private: + HSTRING _hstring; + HSTRING_HEADER _header; +}; + +class InternalDateTime : public IReference { +public: + static INT64 Now() { + FILETIME now; + GetSystemTimeAsFileTime(&now); + return ((((INT64) now.dwHighDateTime) << 32) | now.dwLowDateTime); + } + + InternalDateTime(DateTime dateTime) : _dateTime(dateTime) {} + + InternalDateTime(INT64 millisecondsFromNow) { + _dateTime.UniversalTime = Now() + millisecondsFromNow * 10000; + } + + virtual ~InternalDateTime() = default; + + operator INT64() { + return _dateTime.UniversalTime; + } + + HRESULT STDMETHODCALLTYPE get_Value(DateTime* dateTime) { + *dateTime = _dateTime; + return S_OK; + } + + HRESULT STDMETHODCALLTYPE QueryInterface(const IID& riid, void** ppvObject) { + if (!ppvObject) { + return E_POINTER; + } + if (riid == __uuidof(IUnknown) || riid == __uuidof(IReference)) { + *ppvObject = static_cast(static_cast*>(this)); + return S_OK; + } + return E_NOINTERFACE; + } + + ULONG STDMETHODCALLTYPE Release() { + return 1; + } + + ULONG STDMETHODCALLTYPE AddRef() { + return 2; + } + + HRESULT STDMETHODCALLTYPE GetIids(ULONG*, IID**) { + return E_NOTIMPL; + } + + HRESULT STDMETHODCALLTYPE GetRuntimeClassName(HSTRING*) { + return E_NOTIMPL; + } + + HRESULT STDMETHODCALLTYPE GetTrustLevel(TrustLevel*) { + return E_NOTIMPL; + } + +protected: + DateTime _dateTime; +}; + +namespace Util { + + typedef LONG NTSTATUS, *PNTSTATUS; + typedef NTSTATUS(WINAPI* RtlGetVersionPtr)(PRTL_OSVERSIONINFOW); + inline RTL_OSVERSIONINFOW getRealOSVersion() { + HMODULE hMod = ::GetModuleHandleW(L"ntdll.dll"); + if (hMod) { + RtlGetVersionPtr fxPtr = (RtlGetVersionPtr)::GetProcAddress(hMod, "RtlGetVersion"); + if (fxPtr != nullptr) { + RTL_OSVERSIONINFOW rovi = {0}; + rovi.dwOSVersionInfoSize = sizeof(rovi); + if (STATUS_SUCCESS == fxPtr(&rovi)) { + return rovi; + } + } + } + RTL_OSVERSIONINFOW rovi = {0}; + return rovi; + } + + inline HRESULT defaultExecutablePath(_In_ WCHAR* path, _In_ DWORD nSize = MAX_PATH) { + DWORD written = GetModuleFileNameExW(GetCurrentProcess(), nullptr, path, nSize); + DEBUG_MSG("Default executable path: " << path); + return (written > 0) ? S_OK : E_FAIL; + } + + inline HRESULT defaultShellLinksDirectory(_In_ WCHAR* path, _In_ DWORD nSize = MAX_PATH) { + DWORD written = GetEnvironmentVariableW(L"APPDATA", path, nSize); + HRESULT hr = written > 0 ? S_OK : E_INVALIDARG; + if (SUCCEEDED(hr)) { + errno_t result = wcscat_s(path, nSize, DEFAULT_SHELL_LINKS_PATH); + hr = (result == 0) ? S_OK : E_INVALIDARG; + DEBUG_MSG("Default shell link path: " << path); + } + return hr; + } + + inline HRESULT defaultShellLinkPath(_In_ std::wstring const& appname, _In_ WCHAR* path, _In_ DWORD nSize = MAX_PATH) { + HRESULT hr = defaultShellLinksDirectory(path, nSize); + if (SUCCEEDED(hr)) { + const std::wstring appLink(appname + DEFAULT_LINK_FORMAT); + errno_t result = wcscat_s(path, nSize, appLink.c_str()); + hr = (result == 0) ? S_OK : E_INVALIDARG; + DEBUG_MSG("Default shell link file path: " << path); + } + return hr; + } + + inline std::wstring parentDirectory(WCHAR* path, DWORD size) { + size_t lastSeparator = 0; + for (size_t i = 0; i < size; i++) { + if (path[i] == L'\\' || path[i] == L'/') { + lastSeparator = i; + } + } + return {path, lastSeparator}; + } + + inline PCWSTR AsString(_In_ ComPtr& xmlDocument) { + HSTRING xml; + ComPtr ser; + HRESULT hr = xmlDocument.As(&ser); + hr = ser->GetXml(&xml); + if (SUCCEEDED(hr)) { + return DllImporter::WindowsGetStringRawBuffer(xml, nullptr); + } + return nullptr; + } + + inline PCWSTR AsString(_In_ HSTRING hstring) { + return DllImporter::WindowsGetStringRawBuffer(hstring, nullptr); + } + + inline HRESULT setNodeStringValue(_In_ std::wstring const& string, _Out_opt_ IXmlNode* node, _Out_ IXmlDocument* xml) { + ComPtr textNode; + HRESULT hr = xml->CreateTextNode(WinToastStringWrapper(string).Get(), &textNode); + if (SUCCEEDED(hr)) { + ComPtr stringNode; + hr = textNode.As(&stringNode); + if (SUCCEEDED(hr)) { + ComPtr appendedChild; + hr = node->AppendChild(stringNode.Get(), &appendedChild); + } + } + return hr; + } + + template + inline HRESULT setEventHandlers(_In_ IToastNotification* notification, _In_ std::shared_ptr eventHandler, + _In_ INT64 expirationTime, _Out_ EventRegistrationToken& activatedToken, + _Out_ EventRegistrationToken& dismissedToken, _Out_ EventRegistrationToken& failedToken, + _In_ FunctorT&& markAsReadyForDeletionFunc) { + HRESULT hr = notification->add_Activated( + Callback, ITypedEventHandler>>( + [eventHandler, markAsReadyForDeletionFunc](IToastNotification* notify, IInspectable* inspectable) + { + ComPtr activatedEventArgs; + HRESULT hr = inspectable->QueryInterface(activatedEventArgs.GetAddressOf()); + if (SUCCEEDED(hr)) { + HSTRING argumentsHandle; + hr = activatedEventArgs->get_Arguments(&argumentsHandle); + if (SUCCEEDED(hr)) { + PCWSTR arguments = Util::AsString(argumentsHandle); + + if(wcscmp(arguments, L"action=reply") == 0) + { + ComPtr inputBoxActivatedEventArgs; + HRESULT hr2 = inspectable->QueryInterface(inputBoxActivatedEventArgs.GetAddressOf()); + + if(SUCCEEDED(hr2)) + { + ComPtr replyHandle; + inputBoxActivatedEventArgs->get_UserInput(&replyHandle); + + ComPtr<__FIMap_2_HSTRING_IInspectable> replyMap; + hr = replyHandle.As(&replyMap); + + if(SUCCEEDED(hr)) + { + IInspectable* propertySet; + hr = replyMap.Get()->Lookup(WinToastStringWrapper(L"textBox").Get(), &propertySet); + if (SUCCEEDED(hr)) + { + ComPtr propertyValue; + hr = propertySet->QueryInterface(IID_PPV_ARGS(&propertyValue)); + + if (SUCCEEDED(hr)) + { + // Successfully queried IPropertyValue, now extract the value + HSTRING userInput; + hr = propertyValue->GetString(&userInput); + + // Convert the HSTRING to a wide string + PCWSTR strValue = AsString(userInput); + + if (SUCCEEDED(hr)) + { + eventHandler->toastActivated(std::wstring(strValue)); + return S_OK; + } + } + } + } + } + } + + if (arguments && *arguments) { + eventHandler->toastActivated(static_cast(wcstol(arguments, nullptr, 10))); + DllImporter::WindowsDeleteString(argumentsHandle); + markAsReadyForDeletionFunc(); + return S_OK; + } + DllImporter::WindowsDeleteString(argumentsHandle); + } + } + eventHandler->toastActivated(); + markAsReadyForDeletionFunc(); + return S_OK; + }) + .Get(), + &activatedToken); + + if (SUCCEEDED(hr)) { + hr = notification->add_Dismissed( + Callback, ITypedEventHandler>>( + [eventHandler, expirationTime, markAsReadyForDeletionFunc](IToastNotification* notify, IToastDismissedEventArgs* e) { + ToastDismissalReason reason; + if (SUCCEEDED(e->get_Reason(&reason))) { + if (reason == ToastDismissalReason_UserCanceled && expirationTime && + InternalDateTime::Now() >= expirationTime) { + reason = ToastDismissalReason_TimedOut; + } + eventHandler->toastDismissed(static_cast(reason)); + } + markAsReadyForDeletionFunc(); + return S_OK; + }) + .Get(), + &dismissedToken); + if (SUCCEEDED(hr)) { + hr = notification->add_Failed( + Callback, ITypedEventHandler>>( + [eventHandler, markAsReadyForDeletionFunc](IToastNotification* notify, IToastFailedEventArgs* e) { + eventHandler->toastFailed(); + markAsReadyForDeletionFunc(); + return S_OK; + }) + .Get(), + &failedToken); + } + } + return hr; + } + + inline HRESULT addAttribute(_In_ IXmlDocument* xml, std::wstring const& name, IXmlNamedNodeMap* attributeMap) { + ComPtr srcAttribute; + HRESULT hr = xml->CreateAttribute(WinToastStringWrapper(name).Get(), &srcAttribute); + if (SUCCEEDED(hr)) { + ComPtr node; + hr = srcAttribute.As(&node); + if (SUCCEEDED(hr)) { + ComPtr pNode; + hr = attributeMap->SetNamedItem(node.Get(), &pNode); + } + } + return hr; + } + + inline HRESULT createElement(_In_ IXmlDocument* xml, _In_ std::wstring const& root_node, _In_ std::wstring const& element_name, + _In_ std::vector const& attribute_names) { + ComPtr rootList; + HRESULT hr = xml->GetElementsByTagName(WinToastStringWrapper(root_node).Get(), &rootList); + if (SUCCEEDED(hr)) { + ComPtr root; + hr = rootList->Item(0, &root); + if (SUCCEEDED(hr)) { + ComPtr audioElement; + hr = xml->CreateElement(WinToastStringWrapper(element_name).Get(), &audioElement); + if (SUCCEEDED(hr)) { + ComPtr audioNodeTmp; + hr = audioElement.As(&audioNodeTmp); + if (SUCCEEDED(hr)) { + ComPtr audioNode; + hr = root->AppendChild(audioNodeTmp.Get(), &audioNode); + if (SUCCEEDED(hr)) { + ComPtr attributes; + hr = audioNode->get_Attributes(&attributes); + if (SUCCEEDED(hr)) { + for (auto const& it : attribute_names) { + hr = addAttribute(xml, it, attributes.Get()); + } + } + } + } + } + } + } + return hr; + } +} // namespace Util + +WinToast* WinToast::instance() { + thread_local static WinToast instance; + return &instance; +} + +WinToast::WinToast() : _isInitialized(false), _hasCoInitialized(false) { + if (!isCompatible()) { + DEBUG_MSG(L"Warning: Your system is not compatible with this library "); + } +} + +WinToast::~WinToast() { + clear(); + + if (_hasCoInitialized) { + CoUninitialize(); + } +} + +void WinToast::setAppName(_In_ std::wstring const& appName) { + _appName = appName; +} + +void WinToast::setAppUserModelId(_In_ std::wstring const& aumi) { + _aumi = aumi; + DEBUG_MSG(L"Default App User Model Id: " << _aumi.c_str()); +} + +void WinToast::setShortcutPolicy(_In_ ShortcutPolicy shortcutPolicy) { + _shortcutPolicy = shortcutPolicy; +} + +bool WinToast::isCompatible() { + DllImporter::initialize(); + return !((DllImporter::SetCurrentProcessExplicitAppUserModelID == nullptr) || (DllImporter::PropVariantToString == nullptr) || + (DllImporter::RoGetActivationFactory == nullptr) || (DllImporter::WindowsCreateStringReference == nullptr) || + (DllImporter::WindowsDeleteString == nullptr)); +} + +bool WinToastLib::WinToast::isSupportingModernFeatures() { + constexpr auto MinimumSupportedVersion = 6; + return Util::getRealOSVersion().dwMajorVersion > MinimumSupportedVersion; +} + +bool WinToastLib::WinToast::isWin10AnniversaryOrHigher() { + return Util::getRealOSVersion().dwBuildNumber >= 14393; +} + +std::wstring WinToast::configureAUMI(_In_ std::wstring const& companyName, _In_ std::wstring const& productName, + _In_ std::wstring const& subProduct, _In_ std::wstring const& versionInformation) { + std::wstring aumi = companyName; + aumi += L"." + productName; + if (subProduct.length() > 0) { + aumi += L"." + subProduct; + if (versionInformation.length() > 0) { + aumi += L"." + versionInformation; + } + } + + if (aumi.length() > SCHAR_MAX) { + DEBUG_MSG("Error: max size allowed for AUMI: 128 characters."); + } + return aumi; +} + +std::wstring const& WinToast::strerror(WinToastError error) { + static const std::unordered_map Labels = { + {WinToastError::NoError, L"No error. The process was executed correctly" }, + {WinToastError::NotInitialized, L"The library has not been initialized" }, + {WinToastError::SystemNotSupported, L"The OS does not support WinToast" }, + {WinToastError::ShellLinkNotCreated, L"The library was not able to create a Shell Link for the app" }, + {WinToastError::InvalidAppUserModelID, L"The AUMI is not a valid one" }, + {WinToastError::InvalidParameters, L"Invalid parameters, please double-check the AUMI or App Name" }, + {WinToastError::NotDisplayed, L"The toast was created correctly but WinToast was not able to display the toast"}, + {WinToastError::UnknownError, L"Unknown error" } + }; + + auto const iter = Labels.find(error); + assert(iter != Labels.end()); + return iter->second; +} + +enum WinToast::ShortcutResult WinToast::createShortcut() { + if (_aumi.empty() || _appName.empty()) { + DEBUG_MSG(L"Error: App User Model Id or Appname is empty!"); + return SHORTCUT_MISSING_PARAMETERS; + } + + if (!isCompatible()) { + DEBUG_MSG(L"Your OS is not compatible with this library! =("); + return SHORTCUT_INCOMPATIBLE_OS; + } + + if (!_hasCoInitialized) { + HRESULT initHr = CoInitializeEx(nullptr, COINIT::COINIT_MULTITHREADED); + if (initHr != RPC_E_CHANGED_MODE) { + if (FAILED(initHr) && initHr != S_FALSE) { + DEBUG_MSG(L"Error on COM library initialization!"); + return SHORTCUT_COM_INIT_FAILURE; + } else { + _hasCoInitialized = true; + } + } + } + + bool wasChanged; + HRESULT hr = validateShellLinkHelper(wasChanged); + if (SUCCEEDED(hr)) { + return wasChanged ? SHORTCUT_WAS_CHANGED : SHORTCUT_UNCHANGED; + } + + hr = createShellLinkHelper(); + return SUCCEEDED(hr) ? SHORTCUT_WAS_CREATED : SHORTCUT_CREATE_FAILED; +} + +bool WinToast::initialize(_Out_opt_ WinToastError* error) { + _isInitialized = false; + setError(error, WinToastError::NoError); + + if (!isCompatible()) { + setError(error, WinToastError::SystemNotSupported); + DEBUG_MSG(L"Error: system not supported."); + return false; + } + + if (_aumi.empty() || _appName.empty()) { + setError(error, WinToastError::InvalidParameters); + DEBUG_MSG(L"Error while initializing, did you set up a valid AUMI and App name?"); + return false; + } + + if (_shortcutPolicy != SHORTCUT_POLICY_IGNORE) { + if (createShortcut() < 0) { + setError(error, WinToastError::ShellLinkNotCreated); + DEBUG_MSG(L"Error while attaching the AUMI to the current proccess =("); + return false; + } + } + + if (FAILED(DllImporter::SetCurrentProcessExplicitAppUserModelID(_aumi.c_str()))) { + setError(error, WinToastError::InvalidAppUserModelID); + DEBUG_MSG(L"Error while attaching the AUMI to the current proccess =("); + return false; + } + + _isInitialized = true; + return _isInitialized; +} + +bool WinToast::isInitialized() const { + return _isInitialized; +} + +std::wstring const& WinToast::appName() const { + return _appName; +} + +std::wstring const& WinToast::appUserModelId() const { + return _aumi; +} + +HRESULT WinToast::validateShellLinkHelper(_Out_ bool& wasChanged) { + WCHAR path[MAX_PATH] = {L'\0'}; + Util::defaultShellLinkPath(_appName, path); + // Check if the file exist + DWORD attr = GetFileAttributesW(path); + if (attr >= 0xFFFFFFF) { + DEBUG_MSG("Error, shell link not found. Try to create a new one in: " << path); + return E_FAIL; + } + + // Let's load the file as shell link to validate. + // - Create a shell link + // - Create a persistant file + // - Load the path as data for the persistant file + // - Read the property AUMI and validate with the current + // - Review if AUMI is equal. + ComPtr shellLink; + HRESULT hr = CoCreateInstance(CLSID_ShellLink, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&shellLink)); + if (SUCCEEDED(hr)) { + ComPtr persistFile; + hr = shellLink.As(&persistFile); + if (SUCCEEDED(hr)) { + hr = persistFile->Load(path, STGM_READWRITE); + if (SUCCEEDED(hr)) { + ComPtr propertyStore; + hr = shellLink.As(&propertyStore); + if (SUCCEEDED(hr)) { + PROPVARIANT appIdPropVar; + hr = propertyStore->GetValue(PKEY_AppUserModel_ID, &appIdPropVar); + if (SUCCEEDED(hr)) { + WCHAR AUMI[MAX_PATH]; + hr = DllImporter::PropVariantToString(appIdPropVar, AUMI, MAX_PATH); + wasChanged = false; + if (FAILED(hr) || _aumi != AUMI) { + if (_shortcutPolicy == SHORTCUT_POLICY_REQUIRE_CREATE) { + // AUMI Changed for the same app, let's update the current value! =) + wasChanged = true; + PropVariantClear(&appIdPropVar); + hr = InitPropVariantFromString(_aumi.c_str(), &appIdPropVar); + if (SUCCEEDED(hr)) { + hr = propertyStore->SetValue(PKEY_AppUserModel_ID, appIdPropVar); + if (SUCCEEDED(hr)) { + hr = propertyStore->Commit(); + if (SUCCEEDED(hr) && SUCCEEDED(persistFile->IsDirty())) { + hr = persistFile->Save(path, TRUE); + } + } + } + } else { + // Not allowed to touch the shortcut to fix the AUMI + hr = E_FAIL; + } + } + PropVariantClear(&appIdPropVar); + } + } + } + } + } + return hr; +} + +HRESULT WinToast::createShellLinkHelper() { + if (_shortcutPolicy != SHORTCUT_POLICY_REQUIRE_CREATE) { + return E_FAIL; + } + + WCHAR exePath[MAX_PATH]{L'\0'}; + WCHAR slPath[MAX_PATH]{L'\0'}; + Util::defaultShellLinkPath(_appName, slPath); + Util::defaultExecutablePath(exePath); + std::wstring exeDir = Util::parentDirectory(exePath, sizeof(exePath) / sizeof(exePath[0])); + ComPtr shellLink; + HRESULT hr = CoCreateInstance(CLSID_ShellLink, nullptr, CLSCTX_INPROC_SERVER, IID_PPV_ARGS(&shellLink)); + if (SUCCEEDED(hr)) { + hr = shellLink->SetPath(exePath); + if (SUCCEEDED(hr)) { + hr = shellLink->SetArguments(L""); + if (SUCCEEDED(hr)) { + hr = shellLink->SetWorkingDirectory(exeDir.c_str()); + if (SUCCEEDED(hr)) { + ComPtr propertyStore; + hr = shellLink.As(&propertyStore); + if (SUCCEEDED(hr)) { + PROPVARIANT appIdPropVar; + hr = InitPropVariantFromString(_aumi.c_str(), &appIdPropVar); + if (SUCCEEDED(hr)) { + hr = propertyStore->SetValue(PKEY_AppUserModel_ID, appIdPropVar); + if (SUCCEEDED(hr)) { + hr = propertyStore->Commit(); + if (SUCCEEDED(hr)) { + ComPtr persistFile; + hr = shellLink.As(&persistFile); + if (SUCCEEDED(hr)) { + hr = persistFile->Save(slPath, TRUE); + } + } + } + PropVariantClear(&appIdPropVar); + } + } + } + } + } + } + return hr; +} + +INT64 WinToast::showToast(_In_ WinToastTemplate const& toast, _In_ IWinToastHandler* eventHandler, _Out_ WinToastError* error) { + std::shared_ptr handler(eventHandler); + setError(error, WinToastError::NoError); + INT64 id = -1; + if (!isInitialized()) { + setError(error, WinToastError::NotInitialized); + DEBUG_MSG("Error when launching the toast. WinToast is not initialized."); + return id; + } + if (!handler) { + setError(error, WinToastError::InvalidHandler); + DEBUG_MSG("Error when launching the toast. Handler cannot be nullptr."); + return id; + } + + ComPtr notificationManager; + HRESULT hr = DllImporter::Wrap_GetActivationFactory( + WinToastStringWrapper(RuntimeClass_Windows_UI_Notifications_ToastNotificationManager).Get(), ¬ificationManager); + if (SUCCEEDED(hr)) { + ComPtr notifier; + hr = notificationManager->CreateToastNotifierWithId(WinToastStringWrapper(_aumi).Get(), ¬ifier); + if (SUCCEEDED(hr)) { + ComPtr notificationFactory; + hr = DllImporter::Wrap_GetActivationFactory( + WinToastStringWrapper(RuntimeClass_Windows_UI_Notifications_ToastNotification).Get(), ¬ificationFactory); + if (SUCCEEDED(hr)) { + ComPtr xmlDocument; + hr = notificationManager->GetTemplateContent(ToastTemplateType(toast.type()), &xmlDocument); + if (SUCCEEDED(hr) && toast.isToastGeneric()) { + hr = setBindToastGenericHelper(xmlDocument.Get()); + } + if (SUCCEEDED(hr)) { + for (UINT32 i = 0, fieldsCount = static_cast(toast.textFieldsCount()); i < fieldsCount && SUCCEEDED(hr); i++) { + hr = setTextFieldHelper(xmlDocument.Get(), toast.textField(WinToastTemplate::TextField(i)), i); + } + + // Modern feature are supported Windows > Windows 10 + if (SUCCEEDED(hr) && isSupportingModernFeatures()) { + // Note that we do this *after* using toast.textFieldsCount() to + // iterate/fill the template's text fields, since we're adding yet another text field. + if (SUCCEEDED(hr) && !toast.attributionText().empty()) { + hr = setAttributionTextFieldHelper(xmlDocument.Get(), toast.attributionText()); + } + + std::array buf; + for (std::size_t i = 0, actionsCount = toast.actionsCount(); i < actionsCount && SUCCEEDED(hr); i++) { + _snwprintf_s(buf.data(), buf.size(), _TRUNCATE, L"%zd", i); + hr = addActionHelper(xmlDocument.Get(), toast.actionLabel(i), buf.data()); + } + + if (SUCCEEDED(hr)) { + hr = (toast.audioPath().empty() && toast.audioOption() == WinToastTemplate::AudioOption::Default) + ? hr + : setAudioFieldHelper(xmlDocument.Get(), toast.audioPath(), toast.audioOption()); + } + + if (SUCCEEDED(hr) && toast.duration() != WinToastTemplate::Duration::System) { + hr = addDurationHelper(xmlDocument.Get(), + (toast.duration() == WinToastTemplate::Duration::Short) ? L"short" : L"long"); + } + + if(SUCCEEDED(hr) && toast.isInput()) { + hr = addInputHelper(xmlDocument.Get()); + } + + if (SUCCEEDED(hr)) { + hr = addScenarioHelper(xmlDocument.Get(), toast.scenario()); + } + + } else { + DEBUG_MSG("Modern features (Actions/Sounds/Attributes) not supported in this os version"); + } + + if (SUCCEEDED(hr)) { + bool isWin10AnniversaryOrAbove = WinToast::isWin10AnniversaryOrHigher(); + bool isCircleCropHint = isWin10AnniversaryOrAbove ? toast.isCropHintCircle() : false; + hr = toast.hasImage() + ? setImageFieldHelper(xmlDocument.Get(), toast.imagePath(), toast.isToastGeneric(), isCircleCropHint) + : hr; + if (SUCCEEDED(hr) && isWin10AnniversaryOrAbove && toast.hasHeroImage()) { + hr = setHeroImageHelper(xmlDocument.Get(), toast.heroImagePath(), toast.isInlineHeroImage()); + } + if (SUCCEEDED(hr)) { + ComPtr notification; + hr = notificationFactory->CreateToastNotification(xmlDocument.Get(), ¬ification); + if (SUCCEEDED(hr)) { + INT64 expiration = 0, relativeExpiration = toast.expiration(); + if (relativeExpiration > 0) { + InternalDateTime expirationDateTime(relativeExpiration); + expiration = expirationDateTime; + hr = notification->put_ExpirationTime(&expirationDateTime); + } + + EventRegistrationToken activatedToken, dismissedToken, failedToken; + + GUID guid; + HRESULT hrGuid = CoCreateGuid(&guid); + id = guid.Data1; + if (SUCCEEDED(hr) && SUCCEEDED(hrGuid)) { + hr = Util::setEventHandlers(notification.Get(), handler, expiration, activatedToken, dismissedToken, + failedToken, [this, id]() { markAsReadyForDeletion(id); }); + if (FAILED(hr)) { + setError(error, WinToastError::InvalidHandler); + } + } + + if (SUCCEEDED(hr)) { + if (SUCCEEDED(hr)) { + _buffer.emplace(id, NotifyData(notification, activatedToken, dismissedToken, failedToken)); + DEBUG_MSG("xml: " << Util::AsString(xmlDocument)); + hr = notifier->Show(notification.Get()); + if (FAILED(hr)) { + setError(error, WinToastError::NotDisplayed); + } + } + } + } + } + } + } + } + } + } + return FAILED(hr) ? -1 : id; +} + +ComPtr WinToast::notifier(_In_ bool* succeded) const { + ComPtr notificationManager; + ComPtr notifier; + HRESULT hr = DllImporter::Wrap_GetActivationFactory( + WinToastStringWrapper(RuntimeClass_Windows_UI_Notifications_ToastNotificationManager).Get(), ¬ificationManager); + if (SUCCEEDED(hr)) { + hr = notificationManager->CreateToastNotifierWithId(WinToastStringWrapper(_aumi).Get(), ¬ifier); + } + *succeded = SUCCEEDED(hr); + return notifier; +} + +void WinToast::markAsReadyForDeletion(_In_ INT64 id) { + // Flush the buffer by removing all the toasts that are ready for deletion + for (auto it = _buffer.begin(); it != _buffer.end();) { + if (it->second.isReadyForDeletion()) { + it->second.RemoveTokens(); + it = _buffer.erase(it); + } else { + ++it; + } + } + + // Mark the toast as ready for deletion (if it exists) so that it will be removed from the buffer in the next iteration + auto const iter = _buffer.find(id); + if (iter != _buffer.end()) { + _buffer[id].markAsReadyForDeletion(); + } +} + +bool WinToast::hideToast(_In_ INT64 id) { + if (!isInitialized()) { + DEBUG_MSG("Error when hiding the toast. WinToast is not initialized."); + return false; + } + + auto iter = _buffer.find(id); + if (iter == _buffer.end()) { + return false; + } + + auto succeded = false; + auto notify = notifier(&succeded); + if (!succeded) { + return false; + } + + auto& notifyData = iter->second; + auto result = notify->Hide(notifyData.notification()); + if (FAILED(result)) { + DEBUG_MSG("Error when hiding the toast. Error code: " << result); + return false; + } + + notifyData.RemoveTokens(); + _buffer.erase(iter); + return SUCCEEDED(result); +} + +void WinToast::clear() { + auto succeded = false; + auto notify = notifier(&succeded); + if (!succeded) { + return; + } + + auto safeCopy = _buffer; + for (auto& data : safeCopy) { + auto& notifyData = data.second; + notify->Hide(notifyData.notification()); + notifyData.RemoveTokens(); + } + _buffer.clear(); +} + +// +// Available as of Windows 10 Anniversary Update +// Ref: https://docs.microsoft.com/en-us/windows/uwp/design/shell/tiles-and-notifications/adaptive-interactive-toasts +// +// NOTE: This will add a new text field, so be aware when iterating over +// the toast's text fields or getting a count of them. +// +HRESULT WinToast::setAttributionTextFieldHelper(_In_ IXmlDocument* xml, _In_ std::wstring const& text) { + Util::createElement(xml, L"binding", L"text", {L"placement"}); + ComPtr nodeList; + HRESULT hr = xml->GetElementsByTagName(WinToastStringWrapper(L"text").Get(), &nodeList); + if (SUCCEEDED(hr)) { + UINT32 nodeListLength; + hr = nodeList->get_Length(&nodeListLength); + if (SUCCEEDED(hr)) { + for (UINT32 i = 0; i < nodeListLength; i++) { + ComPtr textNode; + hr = nodeList->Item(i, &textNode); + if (SUCCEEDED(hr)) { + ComPtr attributes; + hr = textNode->get_Attributes(&attributes); + if (SUCCEEDED(hr)) { + ComPtr editedNode; + if (SUCCEEDED(hr)) { + hr = attributes->GetNamedItem(WinToastStringWrapper(L"placement").Get(), &editedNode); + if (FAILED(hr) || !editedNode) { + continue; + } + hr = Util::setNodeStringValue(L"attribution", editedNode.Get(), xml); + if (SUCCEEDED(hr)) { + return setTextFieldHelper(xml, text, i); + } + } + } + } + } + } + } + return hr; +} + +HRESULT WinToast::addDurationHelper(_In_ IXmlDocument* xml, _In_ std::wstring const& duration) { + ComPtr nodeList; + HRESULT hr = xml->GetElementsByTagName(WinToastStringWrapper(L"toast").Get(), &nodeList); + if (SUCCEEDED(hr)) { + UINT32 length; + hr = nodeList->get_Length(&length); + if (SUCCEEDED(hr)) { + ComPtr toastNode; + hr = nodeList->Item(0, &toastNode); + if (SUCCEEDED(hr)) { + ComPtr toastElement; + hr = toastNode.As(&toastElement); + if (SUCCEEDED(hr)) { + hr = toastElement->SetAttribute(WinToastStringWrapper(L"duration").Get(), WinToastStringWrapper(duration).Get()); + } + } + } + } + return hr; +} + +HRESULT WinToast::addScenarioHelper(_In_ IXmlDocument* xml, _In_ std::wstring const& scenario) { + ComPtr nodeList; + HRESULT hr = xml->GetElementsByTagName(WinToastStringWrapper(L"toast").Get(), &nodeList); + if (SUCCEEDED(hr)) { + UINT32 length; + hr = nodeList->get_Length(&length); + if (SUCCEEDED(hr)) { + ComPtr toastNode; + hr = nodeList->Item(0, &toastNode); + if (SUCCEEDED(hr)) { + ComPtr toastElement; + hr = toastNode.As(&toastElement); + if (SUCCEEDED(hr)) { + hr = toastElement->SetAttribute(WinToastStringWrapper(L"scenario").Get(), WinToastStringWrapper(scenario).Get()); + } + } + } + } + return hr; +} + +HRESULT WinToast::addInputHelper(_In_ IXmlDocument* xml) +{ + std::vector attrbs; + attrbs.push_back(L"id"); + attrbs.push_back(L"type"); + attrbs.push_back(L"placeHolderContent"); + + std::vector attrbs2; + attrbs2.push_back(L"content"); + attrbs2.push_back(L"arguments"); + + Util::createElement(xml, L"toast", L"actions", {}); + + Util::createElement(xml, L"actions", L"input",attrbs); + Util::createElement(xml, L"actions", L"action",attrbs2); + + ComPtr nodeList; + HRESULT hr = xml->GetElementsByTagName(WinToastStringWrapper(L"input").Get(), &nodeList); + if (SUCCEEDED(hr)) + { + ComPtr inputNode; + hr = nodeList->Item(0, &inputNode); + if (SUCCEEDED(hr)) + { + ComPtr toastElement; + hr = inputNode.As(&toastElement); + if(SUCCEEDED(hr)){ + toastElement->SetAttribute(WinToastStringWrapper(L"id").Get(), WinToastStringWrapper(L"textBox").Get()); + toastElement->SetAttribute(WinToastStringWrapper(L"type").Get(), WinToastStringWrapper(L"text").Get()); + hr = toastElement->SetAttribute(WinToastStringWrapper(L"placeHolderContent").Get(), WinToastStringWrapper(L"...").Get()); + } + } + } + + ComPtr nodeList2; + hr = xml->GetElementsByTagName(WinToastStringWrapper(L"action").Get(), &nodeList2); + if (SUCCEEDED(hr)) + { + ComPtr actionNode; + hr = nodeList2->Item(0, &actionNode); + if (SUCCEEDED(hr)) + { + ComPtr actionElement; + hr = actionNode.As(&actionElement); + if(SUCCEEDED(hr)){ + actionElement->SetAttribute(WinToastStringWrapper(L"content").Get(), WinToastStringWrapper(L"Reply").Get()); + actionElement->SetAttribute(WinToastStringWrapper(L"arguments").Get(), WinToastStringWrapper(L"action=reply").Get()); + actionElement->SetAttribute(WinToastStringWrapper(L"hint-inputId").Get(), WinToastStringWrapper(L"textBox").Get()); + } + } + } + return hr; +} + +HRESULT WinToast::setTextFieldHelper(_In_ IXmlDocument* xml, _In_ std::wstring const& text, _In_ UINT32 pos) { + ComPtr nodeList; + HRESULT hr = xml->GetElementsByTagName(WinToastStringWrapper(L"text").Get(), &nodeList); + if (SUCCEEDED(hr)) { + ComPtr node; + hr = nodeList->Item(pos, &node); + if (SUCCEEDED(hr)) { + hr = Util::setNodeStringValue(text, node.Get(), xml); + } + } + return hr; +} + +HRESULT WinToast::setBindToastGenericHelper(_In_ IXmlDocument* xml) { + ComPtr nodeList; + HRESULT hr = xml->GetElementsByTagName(WinToastStringWrapper(L"binding").Get(), &nodeList); + if (SUCCEEDED(hr)) { + UINT32 length; + hr = nodeList->get_Length(&length); + if (SUCCEEDED(hr)) { + ComPtr toastNode; + hr = nodeList->Item(0, &toastNode); + if (SUCCEEDED(hr)) { + ComPtr toastElement; + hr = toastNode.As(&toastElement); + if (SUCCEEDED(hr)) { + hr = toastElement->SetAttribute(WinToastStringWrapper(L"template").Get(), WinToastStringWrapper(L"ToastGeneric").Get()); + } + } + } + } + return hr; +} + +HRESULT WinToast::setImageFieldHelper(_In_ IXmlDocument* xml, _In_ std::wstring const& path, _In_ bool isToastGeneric, + _In_ bool isCropHintCircle) { + assert(path.size() < MAX_PATH); + + wchar_t imagePath[MAX_PATH] = L"file:///"; + HRESULT hr = StringCchCatW(imagePath, MAX_PATH, path.c_str()); + if (SUCCEEDED(hr)) { + ComPtr nodeList; + HRESULT hr = xml->GetElementsByTagName(WinToastStringWrapper(L"image").Get(), &nodeList); + if (SUCCEEDED(hr)) { + ComPtr node; + hr = nodeList->Item(0, &node); + + ComPtr imageElement; + HRESULT hrImage = node.As(&imageElement); + if (SUCCEEDED(hr) && SUCCEEDED(hrImage) && isToastGeneric) { + hr = imageElement->SetAttribute(WinToastStringWrapper(L"placement").Get(), WinToastStringWrapper(L"appLogoOverride").Get()); + if (SUCCEEDED(hr) && isCropHintCircle) { + hr = imageElement->SetAttribute(WinToastStringWrapper(L"hint-crop").Get(), WinToastStringWrapper(L"circle").Get()); + } + } + if (SUCCEEDED(hr)) { + ComPtr attributes; + hr = node->get_Attributes(&attributes); + if (SUCCEEDED(hr)) { + ComPtr editedNode; + hr = attributes->GetNamedItem(WinToastStringWrapper(L"src").Get(), &editedNode); + if (SUCCEEDED(hr)) { + Util::setNodeStringValue(imagePath, editedNode.Get(), xml); + } + } + } + } + } + return hr; +} + +HRESULT WinToast::setAudioFieldHelper(_In_ IXmlDocument* xml, _In_ std::wstring const& path, + _In_opt_ WinToastTemplate::AudioOption option) { + std::vector attrs; + if (!path.empty()) { + attrs.push_back(L"src"); + } + if (option == WinToastTemplate::AudioOption::Loop) { + attrs.push_back(L"loop"); + } + if (option == WinToastTemplate::AudioOption::Silent) { + attrs.push_back(L"silent"); + } + Util::createElement(xml, L"toast", L"audio", attrs); + + ComPtr nodeList; + HRESULT hr = xml->GetElementsByTagName(WinToastStringWrapper(L"audio").Get(), &nodeList); + if (SUCCEEDED(hr)) { + ComPtr node; + hr = nodeList->Item(0, &node); + if (SUCCEEDED(hr)) { + ComPtr attributes; + hr = node->get_Attributes(&attributes); + if (SUCCEEDED(hr)) { + ComPtr editedNode; + if (!path.empty()) { + if (SUCCEEDED(hr)) { + hr = attributes->GetNamedItem(WinToastStringWrapper(L"src").Get(), &editedNode); + if (SUCCEEDED(hr)) { + hr = Util::setNodeStringValue(path, editedNode.Get(), xml); + } + } + } + + if (SUCCEEDED(hr)) { + switch (option) { + case WinToastTemplate::AudioOption::Loop: + hr = attributes->GetNamedItem(WinToastStringWrapper(L"loop").Get(), &editedNode); + if (SUCCEEDED(hr)) { + hr = Util::setNodeStringValue(L"true", editedNode.Get(), xml); + } + break; + case WinToastTemplate::AudioOption::Silent: + hr = attributes->GetNamedItem(WinToastStringWrapper(L"silent").Get(), &editedNode); + if (SUCCEEDED(hr)) { + hr = Util::setNodeStringValue(L"true", editedNode.Get(), xml); + } + default: + break; + } + } + } + } + } + return hr; +} + +HRESULT WinToast::addActionHelper(_In_ IXmlDocument* xml, _In_ std::wstring const& content, _In_ std::wstring const& arguments) { + ComPtr nodeList; + HRESULT hr = xml->GetElementsByTagName(WinToastStringWrapper(L"actions").Get(), &nodeList); + if (SUCCEEDED(hr)) { + UINT32 length; + hr = nodeList->get_Length(&length); + if (SUCCEEDED(hr)) { + ComPtr actionsNode; + if (length > 0) { + hr = nodeList->Item(0, &actionsNode); + } else { + hr = xml->GetElementsByTagName(WinToastStringWrapper(L"toast").Get(), &nodeList); + if (SUCCEEDED(hr)) { + hr = nodeList->get_Length(&length); + if (SUCCEEDED(hr)) { + ComPtr toastNode; + hr = nodeList->Item(0, &toastNode); + if (SUCCEEDED(hr)) { + ComPtr toastElement; + hr = toastNode.As(&toastElement); + if (SUCCEEDED(hr)) { + hr = toastElement->SetAttribute(WinToastStringWrapper(L"template").Get(), + WinToastStringWrapper(L"ToastGeneric").Get()); + } + if (SUCCEEDED(hr)) { + hr = toastElement->SetAttribute(WinToastStringWrapper(L"duration").Get(), + WinToastStringWrapper(L"long").Get()); + } + if (SUCCEEDED(hr)) { + ComPtr actionsElement; + hr = xml->CreateElement(WinToastStringWrapper(L"actions").Get(), &actionsElement); + if (SUCCEEDED(hr)) { + hr = actionsElement.As(&actionsNode); + if (SUCCEEDED(hr)) { + ComPtr appendedChild; + hr = toastNode->AppendChild(actionsNode.Get(), &appendedChild); + } + } + } + } + } + } + } + if (SUCCEEDED(hr)) { + ComPtr actionElement; + hr = xml->CreateElement(WinToastStringWrapper(L"action").Get(), &actionElement); + if (SUCCEEDED(hr)) { + hr = actionElement->SetAttribute(WinToastStringWrapper(L"content").Get(), WinToastStringWrapper(content).Get()); + } + if (SUCCEEDED(hr)) { + hr = actionElement->SetAttribute(WinToastStringWrapper(L"arguments").Get(), WinToastStringWrapper(arguments).Get()); + } + if (SUCCEEDED(hr)) { + ComPtr actionNode; + hr = actionElement.As(&actionNode); + if (SUCCEEDED(hr)) { + ComPtr appendedChild; + hr = actionsNode->AppendChild(actionNode.Get(), &appendedChild); + } + } + } + } + } + return hr; +} + +HRESULT WinToast::setHeroImageHelper(_In_ IXmlDocument* xml, _In_ std::wstring const& path, _In_ bool isInlineImage) { + ComPtr nodeList; + HRESULT hr = xml->GetElementsByTagName(WinToastStringWrapper(L"binding").Get(), &nodeList); + if (SUCCEEDED(hr)) { + UINT32 length; + hr = nodeList->get_Length(&length); + if (SUCCEEDED(hr)) { + ComPtr bindingNode; + if (length > 0) { + hr = nodeList->Item(0, &bindingNode); + } + if (SUCCEEDED(hr)) { + ComPtr imageElement; + hr = xml->CreateElement(WinToastStringWrapper(L"image").Get(), &imageElement); + if (SUCCEEDED(hr) && isInlineImage == false) { + hr = imageElement->SetAttribute(WinToastStringWrapper(L"placement").Get(), WinToastStringWrapper(L"hero").Get()); + } + if (SUCCEEDED(hr)) { + hr = imageElement->SetAttribute(WinToastStringWrapper(L"src").Get(), WinToastStringWrapper(path).Get()); + } + if (SUCCEEDED(hr)) { + ComPtr actionNode; + hr = imageElement.As(&actionNode); + if (SUCCEEDED(hr)) { + ComPtr appendedChild; + hr = bindingNode->AppendChild(actionNode.Get(), &appendedChild); + } + } + } + } + } + return hr; +} + +void WinToast::setError(_Out_opt_ WinToastError* error, _In_ WinToastError value) { + if (error) { + *error = value; + } +} + +WinToastTemplate::WinToastTemplate(_In_ WinToastTemplateType type) : _type(type) { + constexpr static std::size_t TextFieldsCount[] = {1, 2, 2, 3, 1, 2, 2, 3}; + _textFields = std::vector(TextFieldsCount[type], L""); +} + +WinToastTemplate::~WinToastTemplate() { + _textFields.clear(); +} + +void WinToastTemplate::setTextField(_In_ std::wstring const& txt, _In_ WinToastTemplate::TextField pos) { + auto const position = static_cast(pos); + if (position >= _textFields.size()) { + DEBUG_MSG("The selected template type supports only " << _textFields.size() << " text lines"); + return; + } + _textFields[position] = txt; +} + +void WinToastTemplate::setImagePath(_In_ std::wstring const& imgPath, _In_ CropHint cropHint) { + _imagePath = imgPath; + _cropHint = cropHint; +} + +void WinToastTemplate::setHeroImagePath(_In_ std::wstring const& imgPath, _In_ bool inlineImage) { + _heroImagePath = imgPath; + _inlineHeroImage = inlineImage; +} + +void WinToastTemplate::setAudioPath(_In_ std::wstring const& audioPath) { + _audioPath = audioPath; +} + +void WinToastTemplate::setAudioPath(_In_ AudioSystemFile file) { + static const std::unordered_map Files = { + {AudioSystemFile::DefaultSound, L"ms-winsoundevent:Notification.Default" }, + {AudioSystemFile::IM, L"ms-winsoundevent:Notification.IM" }, + {AudioSystemFile::Mail, L"ms-winsoundevent:Notification.Mail" }, + {AudioSystemFile::Reminder, L"ms-winsoundevent:Notification.Reminder" }, + {AudioSystemFile::SMS, L"ms-winsoundevent:Notification.SMS" }, + {AudioSystemFile::Alarm, L"ms-winsoundevent:Notification.Looping.Alarm" }, + {AudioSystemFile::Alarm2, L"ms-winsoundevent:Notification.Looping.Alarm2" }, + {AudioSystemFile::Alarm3, L"ms-winsoundevent:Notification.Looping.Alarm3" }, + {AudioSystemFile::Alarm4, L"ms-winsoundevent:Notification.Looping.Alarm4" }, + {AudioSystemFile::Alarm5, L"ms-winsoundevent:Notification.Looping.Alarm5" }, + {AudioSystemFile::Alarm6, L"ms-winsoundevent:Notification.Looping.Alarm6" }, + {AudioSystemFile::Alarm7, L"ms-winsoundevent:Notification.Looping.Alarm7" }, + {AudioSystemFile::Alarm8, L"ms-winsoundevent:Notification.Looping.Alarm8" }, + {AudioSystemFile::Alarm9, L"ms-winsoundevent:Notification.Looping.Alarm9" }, + {AudioSystemFile::Alarm10, L"ms-winsoundevent:Notification.Looping.Alarm10"}, + {AudioSystemFile::Call, L"ms-winsoundevent:Notification.Looping.Call" }, + {AudioSystemFile::Call1, L"ms-winsoundevent:Notification.Looping.Call1" }, + {AudioSystemFile::Call2, L"ms-winsoundevent:Notification.Looping.Call2" }, + {AudioSystemFile::Call3, L"ms-winsoundevent:Notification.Looping.Call3" }, + {AudioSystemFile::Call4, L"ms-winsoundevent:Notification.Looping.Call4" }, + {AudioSystemFile::Call5, L"ms-winsoundevent:Notification.Looping.Call5" }, + {AudioSystemFile::Call6, L"ms-winsoundevent:Notification.Looping.Call6" }, + {AudioSystemFile::Call7, L"ms-winsoundevent:Notification.Looping.Call7" }, + {AudioSystemFile::Call8, L"ms-winsoundevent:Notification.Looping.Call8" }, + {AudioSystemFile::Call9, L"ms-winsoundevent:Notification.Looping.Call9" }, + {AudioSystemFile::Call10, L"ms-winsoundevent:Notification.Looping.Call10" }, + }; + auto const iter = Files.find(file); + assert(iter != Files.end()); + _audioPath = iter->second; +} + +void WinToastTemplate::setAudioOption(_In_ WinToastTemplate::AudioOption audioOption) { + _audioOption = audioOption; +} + +void WinToastTemplate::setFirstLine(_In_ std::wstring const& text) { + setTextField(text, WinToastTemplate::FirstLine); +} + +void WinToastTemplate::setSecondLine(_In_ std::wstring const& text) { + setTextField(text, WinToastTemplate::SecondLine); +} + +void WinToastTemplate::setThirdLine(_In_ std::wstring const& text) { + setTextField(text, WinToastTemplate::ThirdLine); +} + +void WinToastTemplate::setDuration(_In_ Duration duration) { + _duration = duration; +} + +void WinToastTemplate::setExpiration(_In_ INT64 millisecondsFromNow) { + _expiration = millisecondsFromNow; +} + +void WinToastLib::WinToastTemplate::setScenario(_In_ Scenario scenario) { + switch (scenario) { + case Scenario::Default: + _scenario = L"Default"; + break; + case Scenario::Alarm: + _scenario = L"Alarm"; + break; + case Scenario::IncomingCall: + _scenario = L"IncomingCall"; + break; + case Scenario::Reminder: + _scenario = L"Reminder"; + break; + } +} + +void WinToastTemplate::setAttributionText(_In_ std::wstring const& attributionText) { + _attributionText = attributionText; +} + +void WinToastTemplate::addAction(_In_ std::wstring const& label) { + _actions.push_back(label); +} + +void WinToastTemplate::addInput() +{ + _hasInput = true; +} + +std::size_t WinToastTemplate::textFieldsCount() const { + return _textFields.size(); +} + +std::size_t WinToastTemplate::actionsCount() const { + return _actions.size(); +} + +bool WinToastTemplate::hasImage() const { + return _type < WinToastTemplateType::Text01; +} + +bool WinToastTemplate::hasHeroImage() const { + return hasImage() && !_heroImagePath.empty(); +} + +std::vector const& WinToastTemplate::textFields() const { + return _textFields; +} + +std::wstring const& WinToastTemplate::textField(_In_ TextField pos) const { + auto const position = static_cast(pos); + assert(position < _textFields.size()); + return _textFields[position]; +} + +std::wstring const& WinToastTemplate::actionLabel(_In_ std::size_t position) const { + assert(position < _actions.size()); + return _actions[position]; +} + +std::wstring const& WinToastTemplate::imagePath() const { + return _imagePath; +} + +std::wstring const& WinToastTemplate::heroImagePath() const { + return _heroImagePath; +} + +std::wstring const& WinToastTemplate::audioPath() const { + return _audioPath; +} + +std::wstring const& WinToastTemplate::attributionText() const { + return _attributionText; +} + +std::wstring const& WinToastLib::WinToastTemplate::scenario() const { + return _scenario; +} + +INT64 WinToastTemplate::expiration() const { + return _expiration; +} + +WinToastTemplate::WinToastTemplateType WinToastTemplate::type() const { + return _type; +} + +WinToastTemplate::AudioOption WinToastTemplate::audioOption() const { + return _audioOption; +} + +WinToastTemplate::Duration WinToastTemplate::duration() const { + return _duration; +} + +bool WinToastTemplate::isToastGeneric() const { + return hasHeroImage() || _cropHint == WinToastTemplate::Circle; +} + +bool WinToastTemplate::isInlineHeroImage() const { + return _inlineHeroImage; +} + +bool WinToastTemplate::isCropHintCircle() const { + return _cropHint == CropHint::Circle; +} + +bool WinToastTemplate::isInput() const{ + return _hasInput; +} diff --git a/native/WinToastWrapper/vendor/wintoastlib.h b/native/WinToastWrapper/vendor/wintoastlib.h new file mode 100644 index 0000000..475c5b3 --- /dev/null +++ b/native/WinToastWrapper/vendor/wintoastlib.h @@ -0,0 +1,318 @@ +/** + * MIT License + * + * Copyright (C) 2016-2023 WinToast v1.3.0 - Mohammed Boujemaoui + * + * 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 +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +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 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 _textFields{}; + std::vector _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 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 _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 _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 notifier(_In_ bool* succeded) const; + void setError(_Out_opt_ WinToastError* error, _In_ WinToastError value); + }; +} // namespace WinToastLib +#endif // WINTOASTLIB_H diff --git a/samples/Notify.NET.Sample/Notify.NET.Sample.csproj b/samples/Notify.NET.Sample/Notify.NET.Sample.csproj new file mode 100644 index 0000000..2961686 --- /dev/null +++ b/samples/Notify.NET.Sample/Notify.NET.Sample.csproj @@ -0,0 +1,20 @@ + + + + Exe + + net6.0 + 10 + enable + Notify.NET.Sample + + + + + + + + + + + diff --git a/samples/Notify.NET.Sample/Program.cs b/samples/Notify.NET.Sample/Program.cs new file mode 100644 index 0000000..221d705 --- /dev/null +++ b/samples/Notify.NET.Sample/Program.cs @@ -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(); + +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})"); +} diff --git a/samples/Notify.NET.Sample/image.jpg b/samples/Notify.NET.Sample/image.jpg new file mode 100644 index 0000000..5bfee8f Binary files /dev/null and b/samples/Notify.NET.Sample/image.jpg differ diff --git a/src/Notify.NET/Abstractions/INotificationHandler.cs b/src/Notify.NET/Abstractions/INotificationHandler.cs new file mode 100644 index 0000000..f5df178 --- /dev/null +++ b/src/Notify.NET/Abstractions/INotificationHandler.cs @@ -0,0 +1,26 @@ +using Notify.NET.Builder; + +namespace Notify.NET.Abstractions +{ + /// + /// Receives lifecycle events for a single notification. + /// Implement this interface or use the delegate-based callbacks on + /// to respond to user interactions. + /// + public interface INotificationHandler + { + /// Called when the user clicks the notification body (not a button). + void OnActivated(long notificationId); + + /// Called when the user clicks one of the action buttons. + /// The notification's platform ID. + /// Zero-based index matching the order buttons were added via the builder. + void OnButtonActivated(long notificationId, int buttonIndex); + + /// Called when the notification is dismissed (by the user, system, or expiration). + void OnDismissed(long notificationId, DismissReason reason); + + /// Called when the platform fails to display the notification. + void OnFailed(long notificationId); + } +} diff --git a/src/Notify.NET/Abstractions/INotificationService.cs b/src/Notify.NET/Abstractions/INotificationService.cs new file mode 100644 index 0000000..10f4e19 --- /dev/null +++ b/src/Notify.NET/Abstractions/INotificationService.cs @@ -0,0 +1,33 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace Notify.NET.Abstractions +{ + /// + /// Dispatches OS notifications to the native notification subsystem for the current platform. + /// + public interface INotificationService : IDisposable + { + /// + /// Whether the native notification subsystem is available and initialised on this platform. + /// If false, will throw . + /// + bool IsSupported { get; } + + /// + /// Displays a notification and returns a platform-specific ID that can be used to hide it later. + /// + /// The notification to display, constructed via . + /// Optional cancellation token. + /// A non-negative notification ID on success. + Task ShowAsync(NotificationRequest request, CancellationToken cancellationToken = default); + + /// + /// Programmatically dismisses a previously shown notification. + /// + /// The ID returned by . + /// Optional cancellation token. + Task HideAsync(long notificationId, CancellationToken cancellationToken = default); + } +} diff --git a/src/Notify.NET/Abstractions/NotificationRequest.cs b/src/Notify.NET/Abstractions/NotificationRequest.cs new file mode 100644 index 0000000..9cce3fa --- /dev/null +++ b/src/Notify.NET/Abstractions/NotificationRequest.cs @@ -0,0 +1,99 @@ +using System; +using System.Collections.Generic; +using Notify.NET.Builder; + +namespace Notify.NET.Abstractions +{ + /// + /// Immutable description of a notification to be displayed. + /// Construct instances via . + /// + public sealed class NotificationRequest + { + /// The primary heading of the notification. + public string Title { get; } + + /// Optional body text shown beneath the title. + public string? Body { get; } + + /// Absolute path to an image file to display in the notification. + public string? ImagePath { get; } + + /// Action buttons to display. Maximum platform limits apply (typically 5 on Windows, varies on Linux). + public IReadOnlyList Buttons { get; } + + /// Optional interface-based handler for notification lifecycle events. + public INotificationHandler? Handler { get; } + + /// How long to display the notification before it expires automatically. Null means use the platform default. + public TimeSpan? Expiration { get; } + + /// Audio behaviour when the notification appears. + public NotificationAudio Audio { get; } + + /// The urgency/scenario of the notification, which may affect how the platform presents it. + public NotificationUrgency Urgency { get; } + + internal NotificationRequest( + string title, + string? body, + string? imagePath, + IReadOnlyList 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; + } + } + + /// Controls the audio played when the notification is shown (Windows only; Linux ignores this). + public enum NotificationAudio + { + /// Play the platform default notification sound. + Default, + /// Display silently with no sound. + Silent, + /// Loop the notification sound until the notification is dismissed. + Loop + } + + /// Maps to the notification urgency/scenario on each platform. + public enum NotificationUrgency + { + /// Standard informational notification. + Normal, + /// Low-priority; the platform may suppress or delay it. + Low, + /// High-priority; may bypass Do Not Disturb on some platforms. + Critical, + /// Alarm scenario (Windows) — may produce a full-screen interrupt. + Alarm, + /// Reminder scenario (Windows). + Reminder + } + + /// Reason a notification was dismissed. + public enum DismissReason + { + /// The user explicitly dismissed the notification. + UserCancelled, + /// The notification timed out / expired. + TimedOut, + /// The application programmatically hid the notification. + ApplicationHidden, + /// Dismissed for an unspecified or platform-specific reason. + Unknown + } +} diff --git a/src/Notify.NET/Builder/NotificationBuilder.cs b/src/Notify.NET/Builder/NotificationBuilder.cs new file mode 100644 index 0000000..6117fc6 --- /dev/null +++ b/src/Notify.NET/Builder/NotificationBuilder.cs @@ -0,0 +1,202 @@ +using System; +using System.Collections.Generic; +using Notify.NET.Abstractions; + +namespace Notify.NET.Builder +{ + /// + /// Fluent builder for constructing a . + /// + /// + /// + /// 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); + /// + /// + public sealed class NotificationBuilder + { + private string _title = string.Empty; + private string? _body; + private string? _imagePath; + private readonly List _buttons = new List(); + 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? _onActivated; + private Action? _onButtonActivated; + private Action? _onDismissed; + private Action? _onFailed; + + private NotificationBuilder() { } + + /// Creates a new builder with the specified notification title. + public static NotificationBuilder Create(string title) + => new NotificationBuilder { _title = title }; + + /// Sets the notification title. + public NotificationBuilder WithTitle(string title) + { + _title = title ?? throw new ArgumentNullException(nameof(title)); + return this; + } + + /// Sets the notification body text. + public NotificationBuilder WithBody(string body) + { + _body = body; + return this; + } + + /// Sets the absolute path of an image to display in the notification. + public NotificationBuilder WithImage(string imagePath) + { + _imagePath = imagePath; + return this; + } + + /// Adds an action button with an optional click callback. + /// Text shown on the button. + /// Called with the notification ID when the button is clicked. + /// Optional machine-readable action identifier. + public NotificationBuilder AddButton(string label, Action? callback = null, string? actionId = null) + { + _buttons.Add(new NotificationButton(label, callback, actionId)); + return this; + } + + /// Adds a pre-constructed button. + public NotificationBuilder AddButton(NotificationButton button) + { + _buttons.Add(button ?? throw new ArgumentNullException(nameof(button))); + return this; + } + + /// + /// Attaches an interface-based handler for all notification lifecycle events. + /// This takes priority over any delegate-based callbacks registered via + /// , , or . + /// + public NotificationBuilder WithHandler(INotificationHandler handler) + { + _handler = handler; + return this; + } + + /// Registers a callback for when the notification body is clicked. + public NotificationBuilder OnActivated(Action callback) + { + _onActivated = callback; + return this; + } + + /// Registers a callback for when a specific action button is clicked. + public NotificationBuilder OnButtonActivated(Action callback) + { + _onButtonActivated = callback; + return this; + } + + /// Registers a callback for when the notification is dismissed. + public NotificationBuilder OnDismissed(Action callback) + { + _onDismissed = callback; + return this; + } + + /// Registers a callback for when the notification fails to display. + public NotificationBuilder OnFailed(Action callback) + { + _onFailed = callback; + return this; + } + + /// + /// Sets how long the notification remains visible before auto-dismissal. + /// Pass or null to use the platform default. + /// + public NotificationBuilder WithExpiration(TimeSpan expiration) + { + _expiration = expiration == TimeSpan.Zero ? (TimeSpan?)null : expiration; + return this; + } + + /// Controls the sound played when the notification appears (Windows only). + public NotificationBuilder WithAudio(NotificationAudio audio) + { + _audio = audio; + return this; + } + + /// Sets the urgency/scenario which may affect how the platform presents the notification. + public NotificationBuilder WithUrgency(NotificationUrgency urgency) + { + _urgency = urgency; + return this; + } + + /// + /// Constructs the immutable . + /// Throws if has not been set. + /// + 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? _activated; + private readonly Action? _buttonActivated; + private readonly Action? _dismissed; + private readonly Action? _failed; + + public DelegateNotificationHandler( + Action? activated, + Action? buttonActivated, + Action? dismissed, + Action? 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); + } + } +} diff --git a/src/Notify.NET/Builder/NotificationButton.cs b/src/Notify.NET/Builder/NotificationButton.cs new file mode 100644 index 0000000..27acc2c --- /dev/null +++ b/src/Notify.NET/Builder/NotificationButton.cs @@ -0,0 +1,36 @@ +using System; + +namespace Notify.NET.Builder +{ + /// + /// An action button displayed inside a notification. + /// + public sealed class NotificationButton + { + /// The label shown on the button. + public string Label { get; } + + /// + /// 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. + /// + public Action? Callback { get; } + + /// + /// An optional machine-readable identifier for this action (used internally by libnotify). + /// Defaults to a sanitised version of when not specified. + /// + public string ActionId { get; } + + public NotificationButton(string label, Action? 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(' ', '-'); + } + } +} diff --git a/src/Notify.NET/Exceptions/NotificationException.cs b/src/Notify.NET/Exceptions/NotificationException.cs new file mode 100644 index 0000000..64f3ab0 --- /dev/null +++ b/src/Notify.NET/Exceptions/NotificationException.cs @@ -0,0 +1,22 @@ +using System; + +namespace Notify.NET.Exceptions +{ + /// Thrown when the native notification subsystem returns an error. + public sealed class NotificationException : Exception + { + /// Platform-specific error code, if available. + 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) { } + } +} diff --git a/src/Notify.NET/Exceptions/PlatformNotSupportedException.cs b/src/Notify.NET/Exceptions/PlatformNotSupportedException.cs new file mode 100644 index 0000000..0e89897 --- /dev/null +++ b/src/Notify.NET/Exceptions/PlatformNotSupportedException.cs @@ -0,0 +1,20 @@ +using System; + +namespace Notify.NET.Exceptions +{ + /// + /// Thrown when is called on a platform + /// where the native notification subsystem is unavailable or could not be initialised. + /// Check before calling Show. + /// + 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) { } + } +} diff --git a/src/Notify.NET/Extensions/ServiceCollectionExtensions.cs b/src/Notify.NET/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..6bb705f --- /dev/null +++ b/src/Notify.NET/Extensions/ServiceCollectionExtensions.cs @@ -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 +{ + /// + /// Extension methods for registering with an + /// . The correct platform implementation is selected + /// automatically at runtime. + /// + public static class ServiceCollectionExtensions + { + /// + /// Registers as a singleton, using the + /// platform-appropriate backend: + /// + /// Windows → (WinToastLib) + /// Linux → (libnotify) + /// macOS → (UNUserNotificationCenter) + /// Other → ( = false) + /// + /// + /// The service collection to add to. + /// Optional delegate to configure . + public static IServiceCollection AddNotifications( + this IServiceCollection services, + Action? configure = null) + { + var options = new NotificationOptions(); + configure?.Invoke(options); + + services.AddSingleton(options); + + services.AddSingleton(sp => + { + var opts = sp.GetRequiredService(); + return CreateService(opts); + }); + + return services; + } + + /// + /// Creates the platform-appropriate directly + /// (without a DI container), for use in simple console applications. + /// + public static INotificationService CreateNotificationService( + Action? 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(); + } + } + + /// + /// Configuration options for the notification service. + /// Pass to via the configure delegate. + /// + public sealed class NotificationOptions + { + /// + /// Human-readable application name shown in the notification and Action Centre. + /// Defaults to the process name. + /// + public string AppName { get; set; } = + System.Diagnostics.Process.GetCurrentProcess().ProcessName; + + /// + /// Windows AppUserModelId (AUMI), e.g. "MyCompany.MyApp". + /// 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. + /// + public string AppUserModelId { get; set; } = + System.Diagnostics.Process.GetCurrentProcess().ProcessName; + } + + /// + /// No-op implementation used when the current platform has no supported notification backend. + /// is always false; calling throws + /// . + /// + internal sealed class NullNotificationService : INotificationService + { + public bool IsSupported => false; + + public Task 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() { } + } +} diff --git a/src/Notify.NET/Notify.NET.csproj b/src/Notify.NET/Notify.NET.csproj new file mode 100644 index 0000000..5b4a755 --- /dev/null +++ b/src/Notify.NET/Notify.NET.csproj @@ -0,0 +1,49 @@ + + + + netstandard2.1 + 9.0 + true + enable + Notify.NET + Notify.NET + Notify.NET + 1.0.0 + Cross-platform OS notification library for .NET Standard with WinToast (Windows), libnotify (Linux), and UNUserNotificationCenter (macOS) backends. + + + + + + + + + + + + runtimes/win-x64/native/WinToastWrapper.dll + PreserveNewest + + + runtimes/win-x86/native/WinToastWrapper.dll + PreserveNewest + + + runtimes/win-arm64/native/WinToastWrapper.dll + PreserveNewest + + + + + + + runtimes/osx-x64/native/libMacNotifyWrapper.dylib + PreserveNewest + + + runtimes/osx-arm64/native/libMacNotifyWrapper.dylib + PreserveNewest + + + + diff --git a/src/Notify.NET/Platform/Linux/GLibMainLoopRunner.cs b/src/Notify.NET/Platform/Linux/GLibMainLoopRunner.cs new file mode 100644 index 0000000..c7de570 --- /dev/null +++ b/src/Notify.NET/Platform/Linux/GLibMainLoopRunner.cs @@ -0,0 +1,125 @@ +using System; +using System.Collections.Concurrent; +using System.Runtime.InteropServices; +using System.Threading; + +namespace Notify.NET.Platform.Linux +{ + /// + /// 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 + /// to marshal work onto that thread. + /// + 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 + // ------------------------------------------------------------------ + + /// + /// Posts to be executed on the GMainLoop thread and + /// returns a task that completes when the action finishes. + /// + internal System.Threading.Tasks.Task InvokeAsync(Action action) + { + var tcs = new System.Threading.Tasks.TaskCompletionSource(); + 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 Tcs; + internal WorkItem(Action action, System.Threading.Tasks.TaskCompletionSource tcs) + { Action = action; Tcs = tcs; } + } + } +} diff --git a/src/Notify.NET/Platform/Linux/LibNotifyCallbackBridge.cs b/src/Notify.NET/Platform/Linux/LibNotifyCallbackBridge.cs new file mode 100644 index 0000000..1620de3 --- /dev/null +++ b/src/Notify.NET/Platform/Linux/LibNotifyCallbackBridge.cs @@ -0,0 +1,152 @@ +using System; +using System.Collections.Concurrent; +using System.Runtime.InteropServices; +using Notify.NET.Abstractions; + +namespace Notify.NET.Platform.Linux +{ + /// + /// Bridges the unmanaged libnotify GObject signal callbacks back to the managed + /// 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. + /// + 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 _live + = new ConcurrentDictionary(); + + // ------------------------------------------------------------------ + // Per-instance state + // ------------------------------------------------------------------ + private readonly INotificationHandler? _handler; + private readonly System.Collections.Generic.IReadOnlyList _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 buttons) + { + _handler = handler; + _buttons = buttons; + _gcHandle = GCHandle.Alloc(this, GCHandleType.Normal); + } + + /// + /// 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. + /// + internal static LibNotifyCallbackBridge Register( + IntPtr notificationPtr, + INotificationHandler? handler, + System.Collections.Generic.IReadOnlyList buttons) + { + var bridge = new LibNotifyCallbackBridge(handler, buttons); + _live[(long)notificationPtr] = bridge; + return bridge; + } + + /// + /// Removes the bridge and releases all resources. + /// Called from the "closed" signal handler — do not call from application code. + /// + 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; + } + } + } +} diff --git a/src/Notify.NET/Platform/Linux/LibNotifyNative.cs b/src/Notify.NET/Platform/Linux/LibNotifyNative.cs new file mode 100644 index 0000000..2565768 --- /dev/null +++ b/src/Notify.NET/Platform/Linux/LibNotifyNative.cs @@ -0,0 +1,172 @@ +using System; +using System.Runtime.InteropServices; + +namespace Notify.NET.Platform.Linux +{ + /// + /// 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. + /// + 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 + // ------------------------------------------------------------------------- + + /// Callback fired when the user clicks an action button on the notification. + [UnmanagedFunctionPointer(CallingConvention.Cdecl, CharSet = CharSet.Ansi)] + internal delegate void NotifyActionCallback(IntPtr notification, string action, IntPtr userData); + + /// Callback fired when the notification is closed (any reason). + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + internal delegate void NotifyClosedCallback(IntPtr notification, IntPtr userData); + + /// Function posted to the GMainContext via g_main_context_invoke. + [UnmanagedFunctionPointer(CallingConvention.Cdecl)] + internal delegate bool GSourceFunc(IntPtr userData); + + // ------------------------------------------------------------------------- + // libnotify + // ------------------------------------------------------------------------- + + /// Initialises libnotify. Must be called before any other notify_ function. + [DllImport(LibNotify, EntryPoint = "notify_init", CharSet = CharSet.Ansi)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool notify_init(string appName); + + /// Returns true if notify_init() has been called successfully. + [DllImport(LibNotify, EntryPoint = "notify_is_initted")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool notify_is_initted(); + + /// Releases all libnotify resources. + [DllImport(LibNotify, EntryPoint = "notify_uninit")] + internal static extern void notify_uninit(); + + /// + /// 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. + /// + [DllImport(LibNotify, EntryPoint = "notify_notification_new", CharSet = CharSet.Ansi)] + internal static extern IntPtr notify_notification_new(string summary, string? body, string? icon); + + /// Shows the notification. Returns false and sets on failure. + [DllImport(LibNotify, EntryPoint = "notify_notification_show")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool notify_notification_show(IntPtr notification, ref IntPtr error); + + /// Programmatically closes the notification. + [DllImport(LibNotify, EntryPoint = "notify_notification_close")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool notify_notification_close(IntPtr notification, ref IntPtr error); + + /// + /// Adds an action button to the notification. + /// must be a pinned function pointer; see . + /// + [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 + + /// Sets a display hint on the notification (e.g., urgency level). + [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* */); + + /// Sets the notification's image from a GdkPixbuf. + [DllImport(LibNotify, EntryPoint = "notify_notification_set_image_from_pixbuf")] + internal static extern void notify_notification_set_image_from_pixbuf( + IntPtr notification, IntPtr pixbuf /* GdkPixbuf* */); + + /// Returns the reason the notification was closed (call after the "closed" signal). + [DllImport(LibNotify, EntryPoint = "notify_notification_get_closed_reason")] + internal static extern int notify_notification_get_closed_reason(IntPtr notification); + + // ------------------------------------------------------------------------- + // GLib / GObject + // ------------------------------------------------------------------------- + + /// Creates a new GMainLoop. + [DllImport(LibGLib, EntryPoint = "g_main_loop_new")] + internal static extern IntPtr g_main_loop_new(IntPtr context /* null = default */, bool isRunning); + + /// Runs the GMainLoop, blocking until g_main_loop_quit is called. + [DllImport(LibGLib, EntryPoint = "g_main_loop_run")] + internal static extern void g_main_loop_run(IntPtr loop); + + /// Signals the GMainLoop to stop its run() and return. + [DllImport(LibGLib, EntryPoint = "g_main_loop_quit")] + internal static extern void g_main_loop_quit(IntPtr loop); + + /// Releases a GMainLoop reference. + [DllImport(LibGLib, EntryPoint = "g_main_loop_unref")] + internal static extern void g_main_loop_unref(IntPtr loop); + + /// + /// Posts a function to be called on the default GMainContext from any thread. + /// The function is invoked on the GMainLoop thread. + /// + [DllImport(LibGLib, EntryPoint = "g_main_context_invoke")] + internal static extern void g_main_context_invoke(IntPtr context, IntPtr func, IntPtr userData); + + /// + /// Connects a callback to a GObject signal. + /// Returns the handler ID (used to disconnect later if needed). + /// + [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); + + /// Releases one reference on a GObject. The object is destroyed when the ref-count reaches 0. + [DllImport(LibGObj, EntryPoint = "g_object_unref")] + internal static extern void g_object_unref(IntPtr obj); + + /// Frees a GError and sets the pointer to null. + [DllImport(LibGLib, EntryPoint = "g_error_free")] + internal static extern void g_error_free(IntPtr error); + + // ------------------------------------------------------------------------- + // GLib GVariant helpers (needed for urgency hints) + // ------------------------------------------------------------------------- + + /// Creates a GVariant holding a byte value (used for the urgency hint). + [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) + // ------------------------------------------------------------------------- + + /// + /// Loads an image from disk into a GdkPixbuf. + /// Returns IntPtr.Zero on failure; callers should fall back gracefully. + /// + [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; + } +} diff --git a/src/Notify.NET/Platform/Linux/LinuxNotificationService.cs b/src/Notify.NET/Platform/Linux/LinuxNotificationService.cs new file mode 100644 index 0000000..331a50a --- /dev/null +++ b/src/Notify.NET/Platform/Linux/LinuxNotificationService.cs @@ -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 +{ + /// + /// implementation backed by libnotify. + /// + /// Threading model: + /// All libnotify calls must be made from the GLib GMainLoop thread to ensure correct + /// signal wiring. marshals work onto + /// that thread. Callbacks (action-invoked, closed) are delivered on the same thread. + /// + public sealed class LinuxNotificationService : INotificationService + { + private readonly string _appName; + private readonly GLibMainLoopRunner _loopRunner; + private volatile bool _disposed; + + /// + public bool IsSupported { get; private set; } + + /// Application name passed to notify_init(). + 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 + // ------------------------------------------------------------------ + + /// + public async Task 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; + } + + /// + 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 + // ------------------------------------------------------------------ + + /// + 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(); + } + + /// Called on the GMainLoop thread to create and show a notification. + 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; + } + } + } +} diff --git a/src/Notify.NET/Platform/MacOS/MacNotifyCallbackBridge.cs b/src/Notify.NET/Platform/MacOS/MacNotifyCallbackBridge.cs new file mode 100644 index 0000000..582cfc0 --- /dev/null +++ b/src/Notify.NET/Platform/MacOS/MacNotifyCallbackBridge.cs @@ -0,0 +1,153 @@ +using System; +using System.Collections.Concurrent; +using System.Runtime.InteropServices; +using Notify.NET.Abstractions; + +namespace Notify.NET.Platform.MacOS +{ + /// + /// Bridges unmanaged callbacks from libMacNotifyWrapper.dylib back to the + /// managed for each in-flight notification. + /// + /// Design rules — identical to the Windows and Linux bridges: + /// + /// 1. The four static delegates are stored in static readonly fields and + /// their function pointers obtained once; they are permanently valid. + /// + /// 2. Per-notification state is held in instances + /// tracked in . A prevents GC collection. + /// + /// 3. is called from every terminal callback. + /// On macOS, body-tap, button-tap, dismiss and failure are all terminal: + /// UNUserNotificationCenter fires exactly one response per notification + /// and does NOT separately fire a dismiss event after an action response. + /// + 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 _live + = new ConcurrentDictionary(); + + // ------------------------------------------------------------------ + // 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); + } + + /// + /// Registers a bridge for . + /// Call immediately after returns + /// a positive ID. + /// + internal static MacNotifyCallbackBridge Register(long notifId, INotificationHandler? handler) + { + var bridge = new MacNotifyCallbackBridge(handler); + _live[notifId] = bridge; + return bridge; + } + + /// + /// Removes the bridge and frees its . + /// Safe to call multiple times; subsequent calls are no-ops. + /// + 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; + } + } + } +} diff --git a/src/Notify.NET/Platform/MacOS/MacNotifyNative.cs b/src/Notify.NET/Platform/MacOS/MacNotifyNative.cs new file mode 100644 index 0000000..4308157 --- /dev/null +++ b/src/Notify.NET/Platform/MacOS/MacNotifyNative.cs @@ -0,0 +1,91 @@ +using System; +using System.Runtime.InteropServices; + +namespace Notify.NET.Platform.MacOS +{ + /// + /// P/Invoke declarations for libMacNotifyWrapper.dylib. + /// + /// All strings in structs are marshalled as UTF-8 via and + /// . 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. + /// + 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); + } +} diff --git a/src/Notify.NET/Platform/MacOS/MacOSNativeLibraryLoader.cs b/src/Notify.NET/Platform/MacOS/MacOSNativeLibraryLoader.cs new file mode 100644 index 0000000..9d80775 --- /dev/null +++ b/src/Notify.NET/Platform/MacOS/MacOSNativeLibraryLoader.cs @@ -0,0 +1,94 @@ +using System; +using System.IO; +using System.Runtime.InteropServices; +using Notify.NET.Exceptions; + +namespace Notify.NET.Platform.MacOS +{ + /// + /// Ensures libMacNotifyWrapper.dylib is loaded before the first P/Invoke call. + /// + /// Resolution order: + /// 1. Alongside the executing assembly (typical for published apps). + /// 2. NuGet runtimes/<rid>/native/ layout relative to the executing assembly. + /// 3. NuGet layout relative to the entry assembly. + /// + internal static class MacOSNativeLibraryLoader + { + private const string DylibName = "libMacNotifyWrapper.dylib"; + + private static volatile bool _loaded; + private static readonly object _lock = new object(); + + /// + /// Loads the dylib if it has not been loaded yet. + /// Throws if the file cannot be found or opened. + /// + 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); + } +} diff --git a/src/Notify.NET/Platform/MacOS/MacOSNotificationService.cs b/src/Notify.NET/Platform/MacOS/MacOSNotificationService.cs new file mode 100644 index 0000000..b79f440 --- /dev/null +++ b/src/Notify.NET/Platform/MacOS/MacOSNotificationService.cs @@ -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 +{ + /// + /// implementation backed by macOS + /// UNUserNotificationCenter (macOS 10.14+) via a thin native Objective-C + /// wrapper (libMacNotifyWrapper.dylib). + /// + /// Threading model: + /// UNUserNotificationCenter 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; onDismissed 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 UNUserNotificationCenterDelegate. + /// - 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. + /// + public sealed class MacOSNotificationService : INotificationService + { + private volatile bool _disposed; + + /// + public bool IsSupported { get; private set; } + + /// + /// Application name used for logging. The OS uses the bundle identifier for + /// notification attribution; pass a descriptive name for diagnostic purposes. + /// + 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 + // ------------------------------------------------------------------ + + /// + public Task 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); + } + + /// + 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 + // ------------------------------------------------------------------ + + /// + 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 + } + } + + /// + /// 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. + /// + 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 + // ------------------------------------------------------------------ + + /// + /// Copies a .NET string to unmanaged ANSI (UTF-8 on macOS) memory. + /// The allocation is freed on . + /// + 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); + } + } + + /// + /// Combines a top-level with per-button callbacks + /// stored in . + /// + private sealed class CompositeHandler : INotificationHandler + { + private readonly INotificationHandler? _inner; + private readonly System.Collections.Generic.IReadOnlyList _buttons; + + public CompositeHandler( + INotificationHandler? inner, + System.Collections.Generic.IReadOnlyList 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); + } + } +} diff --git a/src/Notify.NET/Platform/Windows/NativeLibraryLoader.cs b/src/Notify.NET/Platform/Windows/NativeLibraryLoader.cs new file mode 100644 index 0000000..faac169 --- /dev/null +++ b/src/Notify.NET/Platform/Windows/NativeLibraryLoader.cs @@ -0,0 +1,93 @@ +using System; +using System.IO; +using System.Reflection; +using System.Runtime.InteropServices; + +namespace Notify.NET.Platform.Windows +{ + /// + /// 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. + /// + 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: /WinToastWrapper.dll (copied by MSBuild) + string flat = Path.Combine(assemblyDir, "WinToastWrapper.dll"); + if (File.Exists(flat)) return flat; + + // NuGet runtimes layout: /runtimes//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"); + } + } +} diff --git a/src/Notify.NET/Platform/Windows/WinToastHandlerBridge.cs b/src/Notify.NET/Platform/Windows/WinToastHandlerBridge.cs new file mode 100644 index 0000000..deeba81 --- /dev/null +++ b/src/Notify.NET/Platform/Windows/WinToastHandlerBridge.cs @@ -0,0 +1,158 @@ +using System; +using System.Collections.Concurrent; +using System.Runtime.InteropServices; +using Notify.NET.Abstractions; + +namespace Notify.NET.Platform.Windows +{ + /// + /// Bridges the unmanaged WinToastWrapper callbacks back to the managed + /// for each in-flight toast. + /// + /// Design rules that MUST be maintained to avoid memory-safety bugs: + /// + /// 1. The four static delegates ( 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 instances + /// tracked in the static 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 . + /// + /// 4. is called exactly once, from whichever callback fires last + /// (dismissed or failed). It removes the entry and frees the GCHandle. + /// + 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 _live + = new ConcurrentDictionary(); + + // ------------------------------------------------------------------ + // 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); + } + + /// + /// Creates a bridge and registers it under . + /// Call this immediately after returns a positive ID. + /// + internal static WinToastHandlerBridge Register(long toastId, INotificationHandler? handler) + { + var bridge = new WinToastHandlerBridge(handler); + _live[toastId] = bridge; + return bridge; + } + + /// + /// Removes the bridge for and releases its GCHandle. + /// Safe to call multiple times; subsequent calls are no-ops. + /// + 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; + } + } + } +} diff --git a/src/Notify.NET/Platform/Windows/WinToastNative.cs b/src/Notify.NET/Platform/Windows/WinToastNative.cs new file mode 100644 index 0000000..e78be98 --- /dev/null +++ b/src/Notify.NET/Platform/Windows/WinToastNative.cs @@ -0,0 +1,120 @@ +using System; +using System.Runtime.InteropServices; + +namespace Notify.NET.Platform.Windows +{ + /// + /// 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 before these are called. + /// + 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 + // ------------------------------------------------------------------------- + + /// + /// Plain-data descriptor passed to . + /// String fields are pointers into pinned managed memory — callers must + /// keep the pinned handles alive for the duration of the call. + /// + [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 + } + + /// + /// Struct of four function pointers passed to . + /// Must be pinned for the lifetime of the toast (until dismissed or failed). + /// + [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 + // ------------------------------------------------------------------------- + + /// + /// Initialises WinToastLib. Must be called once from an STA thread before any other function. + /// + /// Human-readable application name shown in the Action Centre. + /// + /// The AppUserModelId (AUMI) — must match the shortcut in the Start Menu. + /// The wrapper creates the shortcut automatically if it doesn't exist. + /// + /// true on success. + [DllImport(DllName, EntryPoint = "WNT_Initialize", CharSet = CharSet.Unicode, SetLastError = false)] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool WNT_Initialize(string appName, string appUserModelId); + + /// Uninitialises WinToastLib and releases all internal resources. + [DllImport(DllName, EntryPoint = "WNT_Uninitialize")] + internal static extern void WNT_Uninitialize(); + + /// Returns true if WinToast is supported on this version of Windows (requires Win 8+). + [DllImport(DllName, EntryPoint = "WNT_IsCompatible")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool WNT_IsCompatible(); + + /// + /// Shows a toast notification. Must be called from the STA thread. + /// + /// Pointer to a with notification data. + /// Pointer to a with callback function pointers. + /// A positive toast ID on success, or a negative error code on failure. + [DllImport(DllName, EntryPoint = "WNT_ShowToast")] + internal static extern long WNT_ShowToast(ref WNT_ToastDescriptor descriptor, ref WNT_Handler handler); + + /// Programmatically dismisses a previously shown toast. Must be called from the STA thread. + [DllImport(DllName, EntryPoint = "WNT_HideToast")] + [return: MarshalAs(UnmanagedType.Bool)] + internal static extern bool WNT_HideToast(long toastId); + } +} diff --git a/src/Notify.NET/Platform/Windows/WindowsNotificationService.cs b/src/Notify.NET/Platform/Windows/WindowsNotificationService.cs new file mode 100644 index 0000000..a3e3a5d --- /dev/null +++ b/src/Notify.NET/Platform/Windows/WindowsNotificationService.cs @@ -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 +{ + /// + /// implementation backed by WinToastLib via a thin + /// native C wrapper DLL (WinToastWrapper.dll). + /// + /// 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. + /// + public sealed class WindowsNotificationService : INotificationService + { + private readonly string _appName; + private readonly string _appUserModelId; + + private readonly Thread _staThread; + private readonly BlockingCollection _workQueue = new BlockingCollection(); + private readonly ManualResetEventSlim _initialised = new ManualResetEventSlim(false); + private volatile bool _isSupported; + private volatile bool _disposed; + private Exception? _initException; + + /// + public bool IsSupported => _isSupported; + + /// Human-readable application name (shown in Action Centre). + /// + /// Your application's AppUserModelId, e.g. "MyCompany.MyApp". + /// A Start-Menu shortcut carrying this AUMI is required for notifications to persist in + /// the Action Centre. The native wrapper creates the shortcut automatically when missing. + /// + public WindowsNotificationService(string appName, string appUserModelId) + { + _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 + // ------------------------------------------------------------------ + + /// + public Task ShowAsync(NotificationRequest request, CancellationToken cancellationToken = default) + { + if (request == null) throw new ArgumentNullException(nameof(request)); + ThrowIfDisposedOrUnsupported(); + + var tcs = new TaskCompletionSource(); + cancellationToken.Register(() => tcs.TrySetCanceled()); + + EnqueueOnSta(() => + { + try + { + long id = ShowOnSta(request); + tcs.TrySetResult(id); + } + catch (Exception ex) + { + tcs.TrySetException(ex); + } + }); + + return tcs.Task; + } + + /// + public Task HideAsync(long notificationId, CancellationToken cancellationToken = default) + { + ThrowIfDisposedOrUnsupported(); + + var tcs = new TaskCompletionSource(); + 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 + // ------------------------------------------------------------------ + + /// + 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 */ } + } + + /// + /// The STA thread entry point. Runs a simple work-item loop as the message pump. + /// + 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(); + } + } + + /// + /// Builds an that combines the request-level handler + /// with per-button callbacks defined on each . + /// + 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; + } + } + + /// Pumps pending Win32/WinRT messages on the STA thread. + 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 + // ------------------------------------------------------------------ + + /// + /// 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. + /// + 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; } + } + + /// + /// 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. + /// + 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); + } + } + + /// + /// Combines a top-level with per-button callbacks. + /// + private sealed class CompositeHandler : INotificationHandler + { + private readonly INotificationHandler? _inner; + private readonly System.Collections.Generic.IReadOnlyList _buttons; + + public CompositeHandler(INotificationHandler? inner, + System.Collections.Generic.IReadOnlyList 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); + } + } +}