mirror of
https://github.com/LANCommander/Notify.NET.git
synced 2026-08-31 08:23:05 -04:00
Initial commit
This commit is contained in:
commit
099012cfd3
36 changed files with 6675 additions and 0 deletions
166
.github/workflows/release.yml
vendored
Normal file
166
.github/workflows/release.yml
vendored
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
# .github/workflows/release.yml
|
||||
#
|
||||
# Triggered by a semver tag (e.g. v1.2.3).
|
||||
#
|
||||
# Jobs:
|
||||
# build-windows — compiles WinToastWrapper.dll for win-x64 / win-x86 / win-arm64
|
||||
# using MSBuild on a Windows runner.
|
||||
# build-macos — compiles libMacNotifyWrapper.dylib for osx-arm64 and osx-x64
|
||||
# using clang cross-compilation on a macOS runner.
|
||||
# pack — downloads all native artifacts, packs the NuGet package with the
|
||||
# tag version, creates a GitHub Release, and optionally pushes to
|
||||
# NuGet.org (requires the NUGET_API_KEY repository secret).
|
||||
|
||||
name: Release
|
||||
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'v[0-9]+.[0-9]+.[0-9]+'
|
||||
|
||||
permissions:
|
||||
contents: write # required to create GitHub Releases
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Windows native build — three architectures in parallel
|
||||
# ---------------------------------------------------------------------------
|
||||
jobs:
|
||||
build-windows:
|
||||
name: Build WinToastWrapper (${{ matrix.rid }})
|
||||
runs-on: windows-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- { platform: x64, rid: win-x64 }
|
||||
- { platform: Win32, rid: win-x86 }
|
||||
- { platform: ARM64, rid: win-arm64 }
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Add MSBuild to PATH
|
||||
uses: microsoft/setup-msbuild@v2
|
||||
|
||||
- name: Build WinToastWrapper (${{ matrix.platform }})
|
||||
run: |
|
||||
msbuild native\WinToastWrapper\WinToastWrapper.vcxproj `
|
||||
/p:Configuration=Release `
|
||||
/p:Platform=${{ matrix.platform }} `
|
||||
/m `
|
||||
/nologo
|
||||
|
||||
# The vcxproj OutDir already targets runtimes/<rid>/native/ relative to the
|
||||
# repo root, so the DLL lands in the right place after a successful build.
|
||||
- name: Upload native artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: native-${{ matrix.rid }}
|
||||
path: runtimes/${{ matrix.rid }}/native/WinToastWrapper.dll
|
||||
if-no-files-found: error
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# macOS native build — both architectures on one runner via clang cross-compile
|
||||
# ---------------------------------------------------------------------------
|
||||
build-macos:
|
||||
name: Build MacNotifyWrapper (osx-arm64 + osx-x64)
|
||||
runs-on: macos-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# The Makefile targets arm64-apple-macos11.0 and x86_64-apple-macos10.14
|
||||
# using -arch flags; cross-compilation works on both Intel and Apple Silicon
|
||||
# runners because Xcode ships cross-compilers for both targets.
|
||||
- name: Build dylibs and install to runtimes/
|
||||
run: make -C native/MacNotifyWrapper install
|
||||
|
||||
- name: Upload osx-arm64 artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: native-osx-arm64
|
||||
path: runtimes/osx-arm64/native/libMacNotifyWrapper.dylib
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Upload osx-x64 artifact
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: native-osx-x64
|
||||
path: runtimes/osx-x64/native/libMacNotifyWrapper.dylib
|
||||
if-no-files-found: error
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# NuGet pack and GitHub Release
|
||||
# ---------------------------------------------------------------------------
|
||||
pack:
|
||||
name: Pack NuGet and publish release
|
||||
needs: [build-windows, build-macos]
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
# Strip the leading 'v' from the tag (v1.2.3 → 1.2.3) for use as the
|
||||
# NuGet package version and .NET assembly version.
|
||||
- name: Extract version from tag
|
||||
run: echo "VERSION=${GITHUB_REF_NAME#v}" >> "$GITHUB_ENV"
|
||||
|
||||
# Download every native-* artifact into artifact-staging/<artifact-name>/
|
||||
# Each artifact contains a single file at its root (no subdirectory).
|
||||
- name: Download native artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
pattern: native-*
|
||||
path: artifact-staging/
|
||||
|
||||
# Reconstruct the runtimes/ tree that the .csproj Content items reference.
|
||||
# The Condition="Exists(...)" guards in the .csproj will only bundle a file
|
||||
# if it is present here, so all five binaries must be staged before packing.
|
||||
- name: Stage native binaries into runtimes/
|
||||
run: |
|
||||
mkdir -p \
|
||||
runtimes/win-x64/native \
|
||||
runtimes/win-x86/native \
|
||||
runtimes/win-arm64/native \
|
||||
runtimes/osx-x64/native \
|
||||
runtimes/osx-arm64/native
|
||||
cp artifact-staging/native-win-x64/WinToastWrapper.dll runtimes/win-x64/native/
|
||||
cp artifact-staging/native-win-x86/WinToastWrapper.dll runtimes/win-x86/native/
|
||||
cp artifact-staging/native-win-arm64/WinToastWrapper.dll runtimes/win-arm64/native/
|
||||
cp artifact-staging/native-osx-x64/libMacNotifyWrapper.dylib runtimes/osx-x64/native/
|
||||
cp artifact-staging/native-osx-arm64/libMacNotifyWrapper.dylib runtimes/osx-arm64/native/
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@v4
|
||||
with:
|
||||
dotnet-version: '8.x'
|
||||
|
||||
# -p:Version overrides the hardcoded version in the .csproj for this build.
|
||||
# --no-restore is safe here because we only need the managed code compiled;
|
||||
# the native binaries are staged directly without a restore step.
|
||||
- name: Pack NuGet package
|
||||
run: |
|
||||
dotnet pack src/Notify.NET/Notify.NET.csproj \
|
||||
--configuration Release \
|
||||
-p:Version=${{ env.VERSION }} \
|
||||
--output ./nupkg
|
||||
|
||||
- name: Create GitHub Release
|
||||
uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: ./nupkg/*.nupkg
|
||||
generate_release_notes: true
|
||||
fail_on_unmatched_files: true
|
||||
|
||||
# Requires the NUGET_API_KEY repository secret to be configured.
|
||||
# Skip this step silently if the secret is absent.
|
||||
- name: Push to NuGet.org
|
||||
if: ${{ secrets.NUGET_API_KEY != '' }}
|
||||
run: |
|
||||
dotnet nuget push ./nupkg/*.nupkg \
|
||||
--api-key ${{ secrets.NUGET_API_KEY }} \
|
||||
--source https://api.nuget.org/v3/index.json \
|
||||
--skip-duplicate
|
||||
171
.gitignore
vendored
Normal file
171
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
### C++
|
||||
# Object files
|
||||
*.o
|
||||
*.obj
|
||||
|
||||
# Precompiled Headers
|
||||
*.gch
|
||||
*.pch
|
||||
|
||||
# Dynamic libraries
|
||||
*.so
|
||||
*.dylib
|
||||
*.dll
|
||||
|
||||
# Static libraries
|
||||
*.a
|
||||
*.lib
|
||||
|
||||
# Executables
|
||||
*.exe
|
||||
*.out
|
||||
|
||||
# Linker output
|
||||
*.ilk
|
||||
*.map
|
||||
*.exp
|
||||
|
||||
# Debug files
|
||||
*.dSYM/
|
||||
*.idb
|
||||
*.pdb
|
||||
|
||||
### C#
|
||||
# Build results
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
|
||||
# Visual Studio cache/options directory
|
||||
.vs/
|
||||
|
||||
# User-specific files
|
||||
*.suo
|
||||
*.user
|
||||
|
||||
# Debug symbols
|
||||
*.pdb
|
||||
|
||||
# NuGet Packages
|
||||
*.nupkg
|
||||
**/[Pp]ackages/*
|
||||
!**/[Pp]ackages/build/
|
||||
|
||||
# .NET build artifacts
|
||||
artifacts/
|
||||
|
||||
# Publish output
|
||||
publish/
|
||||
|
||||
### Objective-C
|
||||
# Xcode user settings
|
||||
xcuserdata/
|
||||
|
||||
# Xcode build data
|
||||
DerivedData/
|
||||
|
||||
# Obj-C/Swift specific
|
||||
*.hmap
|
||||
|
||||
# App packaging
|
||||
*.ipa
|
||||
*.dSYM.zip
|
||||
*.dSYM
|
||||
|
||||
# Playgrounds
|
||||
timeline.xctimeline
|
||||
playground.xcworkspace
|
||||
|
||||
### macOS
|
||||
# Finder metadata
|
||||
.DS_Store
|
||||
|
||||
# Thumbnails
|
||||
._*
|
||||
|
||||
# Custom folder icons
|
||||
Icon
|
||||
|
||||
# Volume root files
|
||||
.DocumentRevisions-V100
|
||||
.fseventsd
|
||||
.Spotlight-V100
|
||||
.TemporaryItems
|
||||
.Trashes
|
||||
.VolumeIcon.icns
|
||||
.com.apple.timemachine.donotpresent
|
||||
|
||||
### Windows
|
||||
# Windows thumbnail cache files
|
||||
Thumbs.db
|
||||
|
||||
# Folder config file
|
||||
[Dd]esktop.ini
|
||||
|
||||
# Recycle Bin used on file shares
|
||||
$RECYCLE.BIN/
|
||||
|
||||
# Windows shortcuts
|
||||
*.lnk
|
||||
|
||||
### Linux
|
||||
# Backup files
|
||||
*~
|
||||
|
||||
# Temporary files from deleted open files
|
||||
.fuse_hidden*
|
||||
|
||||
# KDE directory preferences
|
||||
.directory
|
||||
|
||||
# Linux trash folder
|
||||
.Trash-*
|
||||
|
||||
# NFS temporary files
|
||||
.nfs*
|
||||
|
||||
### JetBrains
|
||||
# JetBrains IDE directory
|
||||
.idea/
|
||||
|
||||
# CMake build directories
|
||||
cmake-build-*/
|
||||
|
||||
# File-based project format
|
||||
*.iws
|
||||
*.iml
|
||||
|
||||
# IntelliJ build output
|
||||
out/
|
||||
|
||||
### VisualStudio
|
||||
# User-specific files
|
||||
*.suo
|
||||
*.user
|
||||
|
||||
# Build results
|
||||
[Dd]ebug/
|
||||
[Rr]elease/
|
||||
x64/
|
||||
x86/
|
||||
[Ww][Ii][Nn]32/
|
||||
[Aa][Rr][Mm]/
|
||||
[Aa][Rr][Mm]64/
|
||||
[Bb]in/
|
||||
[Oo]bj/
|
||||
|
||||
# Visual Studio cache/options directory
|
||||
.vs/
|
||||
|
||||
# MSTest test Results
|
||||
[Tt]est[Rr]esult*/
|
||||
[Bb]uild[Ll]og.*
|
||||
|
||||
# NuGet
|
||||
*.nupkg
|
||||
**/[Pp]ackages/*
|
||||
!**/[Pp]ackages/build/
|
||||
*.nuget.props
|
||||
*.nuget.targets
|
||||
|
||||
# Publish Web Output
|
||||
*.[Pp]ublish.xml
|
||||
25
Notify.NET.sln
Normal file
25
Notify.NET.sln
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Notify.NET", "src\Notify.NET\Notify.NET.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Notify.NET.Sample", "samples\Notify.NET.Sample\Notify.NET.Sample.csproj", "{B2C3D4E5-F6A7-8901-BCDE-F12345678901}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{B2C3D4E5-F6A7-8901-BCDE-F12345678901}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
381
README.md
Normal file
381
README.md
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
# Notify.NET
|
||||
|
||||
A cross-platform .NET Standard library for displaying OS notifications. Provides a single
|
||||
fluent API that dispatches to the native notification system on each supported platform.
|
||||
|
||||
| Platform | Backend | Minimum OS |
|
||||
|----------|---------|------------|
|
||||
| Windows | [WinToastLib](https://github.com/mohabouje/WinToast) via a thin C++ wrapper DLL | Windows 8 |
|
||||
| Linux | libnotify via P/Invoke | Any distribution with a D-Bus notification daemon |
|
||||
| macOS | UNUserNotificationCenter via a thin Objective-C wrapper dylib | macOS 10.14 (Mojave) |
|
||||
|
||||
---
|
||||
|
||||
## Installation
|
||||
|
||||
```
|
||||
dotnet add package Notify.NET
|
||||
```
|
||||
|
||||
The NuGet package includes the pre-compiled native libraries for all supported platforms.
|
||||
No separate native installation is required.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
```csharp
|
||||
using Notify.NET.Builder;
|
||||
using Notify.NET.Extensions;
|
||||
|
||||
using var service = ServiceCollectionExtensions.CreateNotificationService(opts =>
|
||||
{
|
||||
opts.AppName = "My App";
|
||||
opts.AppUserModelId = "MyCompany.MyApp"; // Windows only; ignored on other platforms
|
||||
});
|
||||
|
||||
if (service.IsSupported)
|
||||
{
|
||||
long id = await service.ShowAsync(
|
||||
NotificationBuilder.Create("Hello")
|
||||
.WithBody("Notify.NET is working.")
|
||||
.Build());
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Creating the service
|
||||
|
||||
### Without a DI container
|
||||
|
||||
```csharp
|
||||
using var service = ServiceCollectionExtensions.CreateNotificationService(opts =>
|
||||
{
|
||||
opts.AppName = "My App";
|
||||
opts.AppUserModelId = "MyCompany.MyApp";
|
||||
});
|
||||
```
|
||||
|
||||
`CreateNotificationService` selects the correct implementation for the current OS
|
||||
automatically. On unsupported platforms it returns a no-op service where `IsSupported`
|
||||
is `false`.
|
||||
|
||||
### With Microsoft.Extensions.DependencyInjection
|
||||
|
||||
```csharp
|
||||
services.AddNotifications(opts =>
|
||||
{
|
||||
opts.AppName = "My App";
|
||||
opts.AppUserModelId = "MyCompany.MyApp";
|
||||
});
|
||||
```
|
||||
|
||||
This registers `INotificationService` as a singleton. Resolve it normally:
|
||||
|
||||
```csharp
|
||||
var service = provider.GetRequiredService<INotificationService>();
|
||||
```
|
||||
|
||||
### Checking platform support
|
||||
|
||||
Always check `IsSupported` before calling `ShowAsync`. On unsupported platforms or when
|
||||
initialisation fails (e.g. the user denied notification permission on macOS), `ShowAsync`
|
||||
throws `PlatformNotSupportedException`.
|
||||
|
||||
```csharp
|
||||
if (!service.IsSupported)
|
||||
{
|
||||
Console.WriteLine("Notifications are not available on this platform.");
|
||||
return;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Builder API
|
||||
|
||||
All notification configuration is done through `NotificationBuilder`. The `Build()` call
|
||||
produces an immutable `NotificationRequest` that can be passed to `ShowAsync`.
|
||||
|
||||
### Title and body
|
||||
|
||||
```csharp
|
||||
var request = NotificationBuilder.Create("Title goes here")
|
||||
.WithBody("Optional body text goes here.")
|
||||
.Build();
|
||||
```
|
||||
|
||||
### Image
|
||||
|
||||
Pass an absolute path to an image file. Relative paths are resolved to absolute paths
|
||||
at call time; if the file does not exist the notification is shown without an image
|
||||
rather than failing.
|
||||
|
||||
```csharp
|
||||
var request = NotificationBuilder.Create("New photo")
|
||||
.WithBody("A photo has arrived.")
|
||||
.WithImage("/home/user/photos/latest.jpg")
|
||||
.Build();
|
||||
```
|
||||
|
||||
Supported formats depend on the platform (PNG and JPEG work on all three).
|
||||
|
||||
### Action buttons
|
||||
|
||||
Up to five action buttons can be added. Each button has a label and an optional click
|
||||
callback that receives the notification ID.
|
||||
|
||||
```csharp
|
||||
var request = NotificationBuilder.Create("Update available")
|
||||
.WithBody("Version 2.0 is ready.")
|
||||
.AddButton("Install now", id => Installer.Run())
|
||||
.AddButton("Remind me later", id => ScheduleReminder())
|
||||
.AddButton("Skip this version", null)
|
||||
.Build();
|
||||
```
|
||||
|
||||
### Lifecycle callbacks
|
||||
|
||||
Register delegates for specific notification events:
|
||||
|
||||
```csharp
|
||||
var request = NotificationBuilder.Create("Download complete")
|
||||
.WithBody("report.pdf has been saved.")
|
||||
.OnActivated(id => OpenFile("report.pdf"))
|
||||
.OnDismissed((id, reason) =>
|
||||
{
|
||||
if (reason == DismissReason.UserCancelled)
|
||||
Console.WriteLine("User dismissed the notification.");
|
||||
})
|
||||
.OnFailed(id => Console.WriteLine($"Notification {id} could not be displayed."))
|
||||
.Build();
|
||||
```
|
||||
|
||||
For more complex cases, implement `INotificationHandler` and attach it with `WithHandler`:
|
||||
|
||||
```csharp
|
||||
public sealed class DownloadHandler : INotificationHandler
|
||||
{
|
||||
public void OnActivated(long id) => OpenDownloadFolder();
|
||||
public void OnButtonActivated(long id, int i) => HandleButton(i);
|
||||
public void OnDismissed(long id, DismissReason r) { }
|
||||
public void OnFailed(long id) => LogError(id);
|
||||
}
|
||||
|
||||
var request = NotificationBuilder.Create("Download complete")
|
||||
.WithHandler(new DownloadHandler())
|
||||
.Build();
|
||||
```
|
||||
|
||||
`WithHandler` takes precedence over any delegate callbacks registered on the same builder.
|
||||
|
||||
### Urgency
|
||||
|
||||
```csharp
|
||||
// Low priority — the platform may suppress or delay it
|
||||
NotificationBuilder.Create("FYI").WithUrgency(NotificationUrgency.Low)
|
||||
|
||||
// Critical — bypasses Do Not Disturb where the platform supports it
|
||||
NotificationBuilder.Create("Disk full").WithUrgency(NotificationUrgency.Critical)
|
||||
|
||||
// Windows-specific scenarios
|
||||
NotificationBuilder.Create("Meeting in 5 minutes").WithUrgency(NotificationUrgency.Reminder)
|
||||
NotificationBuilder.Create("Incoming call").WithUrgency(NotificationUrgency.Alarm)
|
||||
```
|
||||
|
||||
| Value | Windows | Linux | macOS |
|
||||
|-------|---------|-------|-------|
|
||||
| `Normal` | Default scenario | Normal urgency | Active interruption level |
|
||||
| `Low` | Default scenario | Low urgency | Passive interruption level |
|
||||
| `Critical` | Default scenario | Critical urgency | Critical interruption level (macOS 12+) |
|
||||
| `Alarm` | Alarm scenario | Critical urgency | Critical interruption level (macOS 12+) |
|
||||
| `Reminder` | Reminder scenario | Normal urgency | Active interruption level |
|
||||
|
||||
### Audio
|
||||
|
||||
```csharp
|
||||
NotificationBuilder.Create("Alert").WithAudio(NotificationAudio.Silent)
|
||||
NotificationBuilder.Create("Alarm").WithAudio(NotificationAudio.Loop) // Windows only
|
||||
```
|
||||
|
||||
| Value | Windows | Linux | macOS |
|
||||
|-------|---------|-------|-------|
|
||||
| `Default` | System notification sound | Controlled by daemon | System notification sound |
|
||||
| `Silent` | No sound | No sound | No sound |
|
||||
| `Loop` | Sound loops until dismissed | Treated as default | Treated as default |
|
||||
|
||||
### Expiration
|
||||
|
||||
Sets how long the notification is visible before it auto-dismisses. Pass `null` or omit
|
||||
the call to use the platform default.
|
||||
|
||||
```csharp
|
||||
NotificationBuilder.Create("Reminder")
|
||||
.WithExpiration(TimeSpan.FromSeconds(10))
|
||||
.Build();
|
||||
```
|
||||
|
||||
Note: `UNUserNotificationCenter` on macOS does not expose a per-notification timeout API;
|
||||
this value is stored in the request but has no effect on macOS.
|
||||
|
||||
---
|
||||
|
||||
## Showing and hiding notifications
|
||||
|
||||
`ShowAsync` returns a `long` identifier for the notification. Pass this to `HideAsync`
|
||||
to remove it programmatically before the user interacts with it.
|
||||
|
||||
```csharp
|
||||
long id = await service.ShowAsync(request);
|
||||
|
||||
// Remove it after two seconds
|
||||
await Task.Delay(TimeSpan.FromSeconds(2));
|
||||
await service.HideAsync(id);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Callback threading
|
||||
|
||||
Callbacks are fired on a background thread:
|
||||
|
||||
- **Windows** — callbacks arrive on a WinRT thread-pool thread.
|
||||
- **Linux** — callbacks arrive on the GLib main loop thread.
|
||||
- **macOS** — callbacks arrive on a background GCD thread managed by
|
||||
`UNUserNotificationCenter`.
|
||||
|
||||
If you need to update UI elements from a callback, marshal the call to your UI thread
|
||||
(e.g. `Dispatcher.InvokeAsync` on WPF, `Control.Invoke` on WinForms, or
|
||||
`MainThread.BeginInvokeOnMainThread` on MAUI).
|
||||
|
||||
---
|
||||
|
||||
## INotificationService interface
|
||||
|
||||
```csharp
|
||||
public interface INotificationService : IDisposable
|
||||
{
|
||||
// False if the platform is unsupported or initialisation failed.
|
||||
bool IsSupported { get; }
|
||||
|
||||
// Shows a notification. Returns the notification ID on success.
|
||||
// Throws PlatformNotSupportedException if IsSupported is false.
|
||||
Task<long> ShowAsync(NotificationRequest request,
|
||||
CancellationToken cancellationToken = default);
|
||||
|
||||
// Removes the notification from the notification centre.
|
||||
Task HideAsync(long notificationId,
|
||||
CancellationToken cancellationToken = default);
|
||||
}
|
||||
```
|
||||
|
||||
Dispose the service when your application exits to release the native resources
|
||||
(WinToastLib STA thread on Windows, GLib main loop on Linux, UNUserNotificationCenter
|
||||
cleanup on macOS).
|
||||
|
||||
---
|
||||
|
||||
## Platform notes
|
||||
|
||||
### Windows
|
||||
|
||||
- Requires Windows 8 or later. `IsSupported` returns `false` on earlier versions.
|
||||
- The `AppUserModelId` must match an application shortcut in the Start Menu. The native
|
||||
wrapper creates this shortcut automatically on first run when it does not already exist,
|
||||
but the shortcut creation requires that the process can write to the user's
|
||||
`%APPDATA%\Microsoft\Windows\Start Menu\Programs` folder.
|
||||
- `WinToastWrapper.dll` is loaded at runtime from `runtimes/win-<arch>/native/` relative
|
||||
to the entry assembly. For published single-file applications, ensure the DLL is
|
||||
published alongside the executable.
|
||||
- Toast callbacks are delivered on a WinRT thread-pool thread, not the STA thread. The
|
||||
library handles this internally.
|
||||
|
||||
### Linux
|
||||
|
||||
`libnotify` must be installed at runtime. Install it via your package manager:
|
||||
|
||||
```
|
||||
# Debian / Ubuntu
|
||||
sudo apt install libnotify4
|
||||
|
||||
# Fedora / RHEL
|
||||
sudo dnf install libnotify
|
||||
|
||||
# Arch
|
||||
sudo pacman -S libnotify
|
||||
```
|
||||
|
||||
A running D-Bus notification daemon is required (GNOME Shell, KDE Plasma, and most other
|
||||
desktop environments provide one). Notifications will be silently dropped if no daemon is
|
||||
running. `IsSupported` reflects whether `notify_init()` succeeded, not whether a daemon
|
||||
is present.
|
||||
|
||||
Image support via `gdk-pixbuf` requires `libgdk-pixbuf-2.0` to be installed, which is
|
||||
typically included as a dependency of `libnotify4`.
|
||||
|
||||
### macOS
|
||||
|
||||
- Requires macOS 10.14 (Mojave) or later.
|
||||
- On first use, macOS presents an authorisation dialog asking the user to allow
|
||||
notifications. `MNW_Initialize` (called from the `MacOSNotificationService` constructor)
|
||||
blocks until the user responds. `IsSupported` is `false` if permission was denied.
|
||||
- The user can revoke permission at any time in System Settings > Notifications. Subsequent
|
||||
calls to `ShowAsync` will fail silently (the OS will not display the notification and no
|
||||
callback fires) until permission is re-granted.
|
||||
- Action button callbacks and dismiss callbacks require the process to remain running after
|
||||
the notification is shown, because `UNUserNotificationCenterDelegate` delivers responses
|
||||
to the live process. If the process exits before the user interacts, the callbacks are
|
||||
never fired.
|
||||
- Non-bundled processes (bare `dotnet` CLI applications) can display banners but may not
|
||||
always receive action callbacks on all OS versions. Wrap the application as an `.app`
|
||||
bundle or sign it with an appropriate entitlement for reliable callback delivery in
|
||||
production.
|
||||
- The notification body-tap and button-tap events on macOS are terminal events — the
|
||||
`OnDismissed` callback is not fired after the user activates a notification or clicks
|
||||
a button (unlike Windows, where WinToastLib always fires the dismissed event after any
|
||||
interaction).
|
||||
|
||||
---
|
||||
|
||||
## Building the native libraries from source
|
||||
|
||||
Pre-built native binaries are included in the NuGet package. You only need to build from
|
||||
source if you are making changes to the native wrapper code.
|
||||
|
||||
### Windows (WinToastWrapper.dll)
|
||||
|
||||
Requires Visual Studio 2022 with the "Desktop development with C++" workload.
|
||||
|
||||
```
|
||||
msbuild native\WinToastWrapper\WinToastWrapper.vcxproj /p:Configuration=Release /p:Platform=x64
|
||||
msbuild native\WinToastWrapper\WinToastWrapper.vcxproj /p:Configuration=Release /p:Platform=Win32
|
||||
msbuild native\WinToastWrapper\WinToastWrapper.vcxproj /p:Configuration=Release /p:Platform=ARM64
|
||||
```
|
||||
|
||||
Output is written to `runtimes\win-<arch>\native\WinToastWrapper.dll`.
|
||||
|
||||
### macOS (libMacNotifyWrapper.dylib)
|
||||
|
||||
Requires Xcode command-line tools (`xcode-select --install`).
|
||||
|
||||
```
|
||||
make -C native/MacNotifyWrapper install
|
||||
```
|
||||
|
||||
This cross-compiles both an `arm64` (Apple Silicon, macOS 11+) and an `x86_64` (Intel,
|
||||
macOS 10.14+) slice and copies them to `runtimes/osx-arm64/native/` and
|
||||
`runtimes/osx-x64/native/` respectively.
|
||||
|
||||
To also produce a universal binary:
|
||||
|
||||
```
|
||||
make -C native/MacNotifyWrapper universal
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## License
|
||||
|
||||
MIT. See [LICENSE](LICENSE) for details.
|
||||
WinToastLib is copyright (c) mohabouje, also distributed under the MIT License.
|
||||
127
native/MacNotifyWrapper/MacNotifyWrapper.h
Normal file
127
native/MacNotifyWrapper/MacNotifyWrapper.h
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
/**
|
||||
* MacNotifyWrapper.h
|
||||
*
|
||||
* Flat C API for macOS User Notifications (UNUserNotificationCenter).
|
||||
* Consumed by the Notify.NET managed library via P/Invoke.
|
||||
*
|
||||
* All strings are UTF-8, null-terminated.
|
||||
* Callbacks fire on a background GCD thread managed by UNUserNotificationCenter.
|
||||
* The caller must not free any memory passed to MNW_ShowNotification before it returns;
|
||||
* the implementation copies all fields before returning.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
#include <stdint.h>
|
||||
#include <stdbool.h>
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
#ifdef MACNOTIFYWRAPPER_EXPORTS
|
||||
# define MACNOTIFYAPI __attribute__((visibility("default")))
|
||||
#else
|
||||
# define MACNOTIFYAPI
|
||||
#endif
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Callback types — fired on a background thread from UNUserNotificationCenter.
|
||||
* None of the callbacks should call back into MNW_* synchronously.
|
||||
* ------------------------------------------------------------------------- */
|
||||
typedef void (*MNW_ActivatedCallback) (int64_t notifId);
|
||||
typedef void (*MNW_ButtonActivatedCallback)(int64_t notifId, int buttonIndex);
|
||||
typedef void (*MNW_DismissedCallback) (int64_t notifId, int reason);
|
||||
typedef void (*MNW_FailedCallback) (int64_t notifId);
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Dismiss reasons (passed to MNW_DismissedCallback)
|
||||
* ------------------------------------------------------------------------- */
|
||||
#define MNW_DISMISS_EXPIRED 0 /* Notification auto-expired (note: macOS does not fire this) */
|
||||
#define MNW_DISMISS_USER 1 /* User swiped or clicked "Close" */
|
||||
#define MNW_DISMISS_APP_REMOVED 2 /* Removed programmatically via MNW_HideNotification */
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Audio options
|
||||
* ------------------------------------------------------------------------- */
|
||||
#define MNW_AUDIO_DEFAULT 0
|
||||
#define MNW_AUDIO_SILENT 1
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Interruption level (macOS 12+; silently ignored on earlier versions)
|
||||
* ------------------------------------------------------------------------- */
|
||||
#define MNW_INTERRUPTION_ACTIVE 0
|
||||
#define MNW_INTERRUPTION_PASSIVE 1
|
||||
#define MNW_INTERRUPTION_TIME_SENSITIVE 2
|
||||
#define MNW_INTERRUPTION_CRITICAL 3
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Handler — bundle of four callback function pointers, copied by value.
|
||||
* Any pointer may be NULL to opt out of that event.
|
||||
* ------------------------------------------------------------------------- */
|
||||
typedef struct {
|
||||
MNW_ActivatedCallback onActivated;
|
||||
MNW_ButtonActivatedCallback onButtonActivated;
|
||||
MNW_DismissedCallback onDismissed;
|
||||
MNW_FailedCallback onFailed;
|
||||
} MNW_Handler;
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Notification descriptor.
|
||||
* All pointer fields may be NULL / empty string where documented.
|
||||
* The caller must keep all pointed-to memory valid until MNW_ShowNotification returns;
|
||||
* the implementation deep-copies every string before returning.
|
||||
* ------------------------------------------------------------------------- */
|
||||
typedef struct {
|
||||
const char* title; /* Required, non-empty UTF-8 string */
|
||||
const char* body; /* Optional body text; NULL or "" → omitted */
|
||||
const char* imagePath; /* Optional absolute path to an image file */
|
||||
const char** buttonLabels; /* Optional array of buttonCount UTF-8 strings */
|
||||
int buttonCount; /* 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
|
||||
444
native/MacNotifyWrapper/MacNotifyWrapper.m
Normal file
444
native/MacNotifyWrapper/MacNotifyWrapper.m
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
/**
|
||||
* MacNotifyWrapper.m
|
||||
*
|
||||
* Objective-C implementation of the flat C API declared in MacNotifyWrapper.h.
|
||||
* Wraps UNUserNotificationCenter (macOS 10.14+).
|
||||
*
|
||||
* Compilation requirements:
|
||||
* clang -fobjc-arc -fvisibility=hidden
|
||||
* Frameworks: Foundation, UserNotifications
|
||||
* Minimum deployment target: macOS 10.14 (for x86_64), macOS 11.0 (for arm64)
|
||||
*
|
||||
* Threading model:
|
||||
* MNW_Initialize: blocks on a semaphore waiting for the authorisation dialog.
|
||||
* MNW_ShowNotification / MNW_HideNotification: thread-safe; UNUserNotificationCenter
|
||||
* internally serialises requests.
|
||||
* Callbacks (onActivated, onButtonActivated, onDismissed, onFailed): invoked on a
|
||||
* background GCD thread managed by UNUserNotificationCenter. Callers must not
|
||||
* call back into MNW_* synchronously from these callbacks.
|
||||
*/
|
||||
|
||||
#define MACNOTIFYWRAPPER_EXPORTS
|
||||
#import <Foundation/Foundation.h>
|
||||
#import <UserNotifications/UserNotifications.h>
|
||||
#include <stdatomic.h>
|
||||
#include <stdlib.h>
|
||||
#include "MacNotifyWrapper.h"
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Per-notification heap state
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
typedef struct {
|
||||
int64_t notifId;
|
||||
MNW_Handler handler;
|
||||
} NotifState;
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Delegate
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
@interface MNWDelegate : NSObject <UNUserNotificationCenterDelegate>
|
||||
@end
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Process-lifetime globals
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
static MNWDelegate* g_delegate = nil;
|
||||
static NSLock* g_lock = nil;
|
||||
/* strId → NSValue wrapping NotifState* (heap-allocated) */
|
||||
static NSMutableDictionary<NSString*, NSValue*>* g_entries = nil;
|
||||
/* int64 NSNumber → strId NSString */
|
||||
static NSMutableDictionary<NSNumber*, NSString*>* g_idMap = nil;
|
||||
/* category identifiers we have registered */
|
||||
static NSMutableSet<NSString*>* g_categoryIds = nil;
|
||||
/* UNNotificationCategory objects corresponding to the above */
|
||||
static NSMutableSet<UNNotificationCategory*>* g_categoryObjs = nil;
|
||||
static _Atomic(int64_t) g_counter = 1;
|
||||
static bool g_initialized = false;
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Private helpers
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
static NSString* StringIdFor(int64_t notifId)
|
||||
{
|
||||
return [NSString stringWithFormat:@"mnw_%lld", (long long)notifId];
|
||||
}
|
||||
|
||||
/* Stores a newly allocated NotifState in both lookup tables. */
|
||||
static void StoreEntry(NSString* strId, int64_t notifId, const MNW_Handler* handler)
|
||||
{
|
||||
NotifState* s = (NotifState*)malloc(sizeof(NotifState));
|
||||
s->notifId = notifId;
|
||||
s->handler = *handler;
|
||||
NSValue* boxed = [NSValue valueWithPointer:s];
|
||||
[g_lock lock];
|
||||
g_entries[strId] = boxed;
|
||||
g_idMap[@(notifId)] = strId;
|
||||
[g_lock unlock];
|
||||
}
|
||||
|
||||
/*
|
||||
* Atomically removes the entry and returns a copy of its state.
|
||||
* Returns NO if no entry exists (already removed by a prior event).
|
||||
*/
|
||||
static BOOL TakeEntry(NSString* strId, NotifState* outState)
|
||||
{
|
||||
[g_lock lock];
|
||||
NSValue* val = g_entries[strId];
|
||||
if (!val) { [g_lock unlock]; return NO; }
|
||||
|
||||
NotifState* s = (NotifState*)[val pointerValue];
|
||||
*outState = *s;
|
||||
free(s);
|
||||
[g_entries removeObjectForKey:strId];
|
||||
[g_idMap removeObjectForKey:@(outState->notifId)];
|
||||
[g_lock unlock];
|
||||
return YES;
|
||||
}
|
||||
|
||||
/*
|
||||
* Builds a stable, reproducible category identifier from a set of button labels.
|
||||
* The identifier must survive app restarts so that previously delivered notifications
|
||||
* whose category was registered in an earlier session are still actionable.
|
||||
*/
|
||||
static NSString* BuildCategoryId(const char** labels, int count)
|
||||
{
|
||||
if (count == 0) return @"mnw_default";
|
||||
|
||||
NSMutableString* s = [NSMutableString stringWithString:@"mnw"];
|
||||
for (int i = 0; i < count; i++)
|
||||
[s appendFormat:@"_%s", labels[i]];
|
||||
|
||||
/* Hash long identifiers to keep the string short. */
|
||||
if (s.length > 80)
|
||||
return [NSString stringWithFormat:@"mnw_%lu", (unsigned long)s.hash];
|
||||
|
||||
return [s copy];
|
||||
}
|
||||
|
||||
/*
|
||||
* Ensures a UNNotificationCategory with the given identifier is registered.
|
||||
* No-op if the category was already registered this session.
|
||||
* Thread-safe — uses g_lock.
|
||||
*/
|
||||
static NSString* EnsureCategory(const char** labels, int count)
|
||||
{
|
||||
NSString* catId = BuildCategoryId(labels, count);
|
||||
|
||||
[g_lock lock];
|
||||
BOOL known = [g_categoryIds containsObject:catId];
|
||||
[g_lock unlock];
|
||||
|
||||
if (known) return catId;
|
||||
|
||||
/* Build the UNNotificationAction array. */
|
||||
NSMutableArray<UNNotificationAction*>* actions = [NSMutableArray array];
|
||||
for (int i = 0; i < count; i++) {
|
||||
NSString* actionId = [NSString stringWithFormat:@"btn_%d", i];
|
||||
NSString* title = [NSString stringWithUTF8String:labels[i]];
|
||||
[actions addObject:[UNNotificationAction
|
||||
actionWithIdentifier:actionId
|
||||
title:title
|
||||
options:UNNotificationActionOptionNone]];
|
||||
}
|
||||
|
||||
/* CustomDismissAction makes the delegate receive dismiss events. */
|
||||
UNNotificationCategory* category = [UNNotificationCategory
|
||||
categoryWithIdentifier:catId
|
||||
actions:actions
|
||||
intentIdentifiers:@[]
|
||||
options:UNNotificationCategoryOptionCustomDismissAction];
|
||||
|
||||
[g_lock lock];
|
||||
if (![g_categoryIds containsObject:catId]) {
|
||||
[g_categoryIds addObject:catId];
|
||||
[g_categoryObjs addObject:category];
|
||||
/* setNotificationCategories replaces the full set; pass our accumulated set. */
|
||||
NSSet* snapshot = [g_categoryObjs copy];
|
||||
[[UNUserNotificationCenter currentNotificationCenter]
|
||||
setNotificationCategories:snapshot];
|
||||
}
|
||||
[g_lock unlock];
|
||||
|
||||
return catId;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Delegate implementation
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
@implementation MNWDelegate
|
||||
|
||||
/*
|
||||
* Called when a notification arrives while the app is in the foreground.
|
||||
* Show the banner and play the sound so that console/background processes
|
||||
* still see the notification visually.
|
||||
*/
|
||||
- (void)userNotificationCenter:(UNUserNotificationCenter*)center
|
||||
willPresentNotification:(UNNotification*)notification
|
||||
withCompletionHandler:(void(^)(UNNotificationPresentationOptions))completionHandler
|
||||
{
|
||||
if (@available(macOS 12.0, *)) {
|
||||
completionHandler(UNNotificationPresentationOptionBanner
|
||||
| UNNotificationPresentationOptionSound
|
||||
| UNNotificationPresentationOptionList);
|
||||
} else {
|
||||
#pragma clang diagnostic push
|
||||
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
|
||||
completionHandler(UNNotificationPresentationOptionAlert
|
||||
| UNNotificationPresentationOptionSound);
|
||||
#pragma clang diagnostic pop
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Called when the user interacts with (or dismisses) a notification.
|
||||
* This is the single delivery point for all notification responses;
|
||||
* we route to the appropriate managed callback and then clean up.
|
||||
*
|
||||
* Note: on macOS, a body-tap or button-tap IS the terminal event.
|
||||
* UNUserNotificationCenter does NOT subsequently fire a dismiss event
|
||||
* after an action response. We therefore release after every interaction.
|
||||
*/
|
||||
- (void)userNotificationCenter:(UNUserNotificationCenter*)center
|
||||
didReceiveNotificationResponse:(UNNotificationResponse*)response
|
||||
withCompletionHandler:(void(^)(void))completionHandler
|
||||
{
|
||||
NSString* strId = response.notification.request.identifier;
|
||||
|
||||
NotifState state = {0};
|
||||
if (!TakeEntry(strId, &state)) {
|
||||
/* Already handled (e.g. MNW_HideNotification was called first). */
|
||||
completionHandler();
|
||||
return;
|
||||
}
|
||||
|
||||
NSString* actionId = response.actionIdentifier;
|
||||
|
||||
if ([actionId isEqualToString:UNNotificationDefaultActionIdentifier]) {
|
||||
/* User tapped the notification body. */
|
||||
if (state.handler.onActivated)
|
||||
state.handler.onActivated(state.notifId);
|
||||
|
||||
} else if ([actionId isEqualToString:UNNotificationDismissActionIdentifier]) {
|
||||
/* User dismissed (swipe/close). Requires CustomDismissAction on category. */
|
||||
if (state.handler.onDismissed)
|
||||
state.handler.onDismissed(state.notifId, MNW_DISMISS_USER);
|
||||
|
||||
} else if ([actionId hasPrefix:@"btn_"]) {
|
||||
/* User tapped an action button. */
|
||||
int idx = (int)[[actionId substringFromIndex:4] integerValue];
|
||||
if (state.handler.onButtonActivated)
|
||||
state.handler.onButtonActivated(state.notifId, idx);
|
||||
|
||||
} else {
|
||||
/* Unknown action — treat as activation. */
|
||||
if (state.handler.onActivated)
|
||||
state.handler.onActivated(state.notifId);
|
||||
}
|
||||
|
||||
completionHandler();
|
||||
}
|
||||
|
||||
@end
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* API implementation
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
bool MNW_IsSupported(void)
|
||||
{
|
||||
if (@available(macOS 10.14, *)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
bool MNW_Initialize(const char* appName)
|
||||
{
|
||||
(void)appName; /* appName is informational; the bundle identifier governs delivery. */
|
||||
|
||||
if (!MNW_IsSupported()) return false;
|
||||
|
||||
/* One-time setup of global state. */
|
||||
static dispatch_once_t once;
|
||||
dispatch_once(&once, ^{
|
||||
g_lock = [[NSLock alloc] init];
|
||||
g_entries = [NSMutableDictionary dictionary];
|
||||
g_idMap = [NSMutableDictionary dictionary];
|
||||
g_categoryIds = [NSMutableSet set];
|
||||
g_categoryObjs = [NSMutableSet set];
|
||||
g_delegate = [[MNWDelegate alloc] init];
|
||||
[[UNUserNotificationCenter currentNotificationCenter] setDelegate:g_delegate];
|
||||
|
||||
/* Pre-register the default (no-button) category. */
|
||||
EnsureCategory(NULL, 0);
|
||||
});
|
||||
|
||||
if (g_initialized) {
|
||||
/* On re-initialisation just re-check authorisation status. */
|
||||
__block bool ok = false;
|
||||
dispatch_semaphore_t sem = dispatch_semaphore_create(0);
|
||||
[[UNUserNotificationCenter currentNotificationCenter]
|
||||
getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings* s) {
|
||||
ok = (s.authorizationStatus == UNAuthorizationStatusAuthorized
|
||||
|| s.authorizationStatus == UNAuthorizationStatusProvisional);
|
||||
dispatch_semaphore_signal(sem);
|
||||
}];
|
||||
dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, 5 * NSEC_PER_SEC));
|
||||
return ok;
|
||||
}
|
||||
|
||||
/* Request authorisation. Blocks until the user responds (or times out). */
|
||||
__block bool granted = false;
|
||||
dispatch_semaphore_t sem = dispatch_semaphore_create(0);
|
||||
[[UNUserNotificationCenter currentNotificationCenter]
|
||||
requestAuthorizationWithOptions:(UNAuthorizationOptionAlert
|
||||
| UNAuthorizationOptionSound
|
||||
| UNAuthorizationOptionBadge)
|
||||
completionHandler:^(BOOL g, NSError* __unused err) {
|
||||
granted = (bool)g;
|
||||
dispatch_semaphore_signal(sem);
|
||||
}];
|
||||
dispatch_semaphore_wait(sem, dispatch_time(DISPATCH_TIME_NOW, 30 * NSEC_PER_SEC));
|
||||
|
||||
g_initialized = granted;
|
||||
return granted;
|
||||
}
|
||||
|
||||
void MNW_Uninitialize(void)
|
||||
{
|
||||
if (!g_lock) return;
|
||||
|
||||
/* Remove all notifications posted by this process. */
|
||||
UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter];
|
||||
[center removeAllPendingNotificationRequests];
|
||||
[center removeAllDeliveredNotifications];
|
||||
|
||||
/* Free all live state without firing callbacks. */
|
||||
[g_lock lock];
|
||||
for (NSValue* val in g_entries.allValues) {
|
||||
NotifState* s = (NotifState*)[val pointerValue];
|
||||
free(s);
|
||||
}
|
||||
[g_entries removeAllObjects];
|
||||
[g_idMap removeAllObjects];
|
||||
[g_lock unlock];
|
||||
|
||||
g_initialized = false;
|
||||
}
|
||||
|
||||
int64_t MNW_ShowNotification(
|
||||
const MNW_NotificationDescriptor* descriptor,
|
||||
const MNW_Handler* handler)
|
||||
{
|
||||
if (!descriptor || !handler) return -1;
|
||||
if (!descriptor->title || descriptor->title[0]=='\0') return -2;
|
||||
if (!g_lock) return -3; /* Not initialised */
|
||||
|
||||
int64_t notifId = atomic_fetch_add_explicit(&g_counter, 1, memory_order_relaxed);
|
||||
NSString* strId = StringIdFor(notifId);
|
||||
|
||||
/* --- Content --------------------------------------------------------- */
|
||||
UNMutableNotificationContent* content = [[UNMutableNotificationContent alloc] init];
|
||||
content.title = [NSString stringWithUTF8String:descriptor->title];
|
||||
|
||||
if (descriptor->body && descriptor->body[0] != '\0')
|
||||
content.body = [NSString stringWithUTF8String:descriptor->body];
|
||||
|
||||
/* --- Sound ----------------------------------------------------------- */
|
||||
content.sound = (descriptor->audioOption == MNW_AUDIO_SILENT)
|
||||
? nil
|
||||
: [UNNotificationSound defaultSound];
|
||||
|
||||
/* --- Interruption level (macOS 12+) ---------------------------------- */
|
||||
if (@available(macOS 12.0, *)) {
|
||||
switch (descriptor->interruptionLevel) {
|
||||
case MNW_INTERRUPTION_PASSIVE:
|
||||
content.interruptionLevel = UNNotificationInterruptionLevelPassive;
|
||||
break;
|
||||
case MNW_INTERRUPTION_TIME_SENSITIVE:
|
||||
content.interruptionLevel = UNNotificationInterruptionLevelTimeSensitive;
|
||||
break;
|
||||
case MNW_INTERRUPTION_CRITICAL:
|
||||
content.interruptionLevel = UNNotificationInterruptionLevelCritical;
|
||||
break;
|
||||
default:
|
||||
content.interruptionLevel = UNNotificationInterruptionLevelActive;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Image attachment ------------------------------------------------ */
|
||||
if (descriptor->imagePath && descriptor->imagePath[0] != '\0') {
|
||||
NSString* path = [NSString stringWithUTF8String:descriptor->imagePath];
|
||||
NSURL* url = [NSURL fileURLWithPath:path];
|
||||
NSError* err = nil;
|
||||
UNNotificationAttachment* att = [UNNotificationAttachment
|
||||
attachmentWithIdentifier:@"image" URL:url options:nil error:&err];
|
||||
if (att)
|
||||
content.attachments = @[att];
|
||||
/* If attachment fails (file missing, unsupported format), continue without it. */
|
||||
}
|
||||
|
||||
/* --- Category / buttons --------------------------------------------- */
|
||||
content.categoryIdentifier = EnsureCategory(descriptor->buttonLabels,
|
||||
descriptor->buttonCount);
|
||||
|
||||
/* --- Trigger --------------------------------------------------------- */
|
||||
/*
|
||||
* UNTimeIntervalNotificationTrigger requires timeInterval > 0.
|
||||
* Use 0.1 s for "fire immediately" — imperceptible to the user.
|
||||
*/
|
||||
UNTimeIntervalNotificationTrigger* trigger =
|
||||
[UNTimeIntervalNotificationTrigger triggerWithTimeInterval:0.1 repeats:NO];
|
||||
|
||||
/* --- Register state BEFORE scheduling so no callback is missed ------- */
|
||||
StoreEntry(strId, notifId, handler);
|
||||
|
||||
/* --- Schedule -------------------------------------------------------- */
|
||||
UNNotificationRequest* req = [UNNotificationRequest
|
||||
requestWithIdentifier:strId
|
||||
content:content
|
||||
trigger:trigger];
|
||||
|
||||
[[UNUserNotificationCenter currentNotificationCenter]
|
||||
addNotificationRequest:req
|
||||
withCompletionHandler:^(NSError* error) {
|
||||
if (!error) return;
|
||||
/* Delivery failed — fire onFailed and clean up. */
|
||||
NotifState state = {0};
|
||||
if (TakeEntry(strId, &state)) {
|
||||
if (state.handler.onFailed)
|
||||
state.handler.onFailed(state.notifId);
|
||||
}
|
||||
}];
|
||||
|
||||
return notifId;
|
||||
}
|
||||
|
||||
bool MNW_HideNotification(int64_t notifId)
|
||||
{
|
||||
if (!g_lock) return false;
|
||||
|
||||
[g_lock lock];
|
||||
NSString* strId = g_idMap[@(notifId)];
|
||||
[g_lock unlock];
|
||||
|
||||
if (!strId) return false;
|
||||
|
||||
NSArray<NSString*>* ids = @[strId];
|
||||
UNUserNotificationCenter* center = [UNUserNotificationCenter currentNotificationCenter];
|
||||
[center removePendingNotificationRequestsWithIdentifiers:ids];
|
||||
[center removeDeliveredNotificationsWithIdentifiers:ids];
|
||||
|
||||
/* Fire dismissed callback synchronously before returning. */
|
||||
NotifState state = {0};
|
||||
if (TakeEntry(strId, &state)) {
|
||||
if (state.handler.onDismissed)
|
||||
state.handler.onDismissed(state.notifId, MNW_DISMISS_APP_REMOVED);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
57
native/MacNotifyWrapper/Makefile
Normal file
57
native/MacNotifyWrapper/Makefile
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
# Makefile for MacNotifyWrapper
|
||||
#
|
||||
# Builds a thin Objective-C dylib wrapping UNUserNotificationCenter.
|
||||
# Produces separate arm64 and x86_64 slices, plus an optional universal binary.
|
||||
#
|
||||
# Usage:
|
||||
# make # build all slices
|
||||
# make install # copy dylibs into ../../runtimes/
|
||||
# make universal # also build universal binary
|
||||
# make clean
|
||||
|
||||
SDK := $(shell xcrun --show-sdk-path)
|
||||
CC := clang
|
||||
SRC := MacNotifyWrapper.m
|
||||
CFLAGS := -fobjc-arc -fvisibility=hidden -O2 -Wall -Wextra \
|
||||
-isysroot $(SDK)
|
||||
LDFLAGS := -dynamiclib \
|
||||
-framework Foundation \
|
||||
-framework UserNotifications \
|
||||
-install_name @rpath/libMacNotifyWrapper.dylib
|
||||
|
||||
ARM64_TARGET := arm64-apple-macos11.0
|
||||
X64_TARGET := x86_64-apple-macos10.14
|
||||
|
||||
OUT_ARM64 := osx-arm64/libMacNotifyWrapper.dylib
|
||||
OUT_X64 := osx-x64/libMacNotifyWrapper.dylib
|
||||
OUT_UNIVERSAL := osx-universal/libMacNotifyWrapper.dylib
|
||||
|
||||
RUNTIME_ARM64 := ../../runtimes/osx-arm64/native
|
||||
RUNTIME_X64 := ../../runtimes/osx-x64/native
|
||||
|
||||
.PHONY: all universal install clean
|
||||
|
||||
all: $(OUT_ARM64) $(OUT_X64)
|
||||
|
||||
universal: $(OUT_UNIVERSAL)
|
||||
|
||||
$(OUT_ARM64): $(SRC) MacNotifyWrapper.h
|
||||
mkdir -p osx-arm64
|
||||
$(CC) $(CFLAGS) -arch arm64 -target $(ARM64_TARGET) $(LDFLAGS) -o $@ $<
|
||||
|
||||
$(OUT_X64): $(SRC) MacNotifyWrapper.h
|
||||
mkdir -p osx-x64
|
||||
$(CC) $(CFLAGS) -arch x86_64 -target $(X64_TARGET) $(LDFLAGS) -o $@ $<
|
||||
|
||||
$(OUT_UNIVERSAL): $(OUT_ARM64) $(OUT_X64)
|
||||
mkdir -p osx-universal
|
||||
lipo -create -output $@ $(OUT_ARM64) $(OUT_X64)
|
||||
|
||||
install: $(OUT_ARM64) $(OUT_X64)
|
||||
mkdir -p $(RUNTIME_ARM64) $(RUNTIME_X64)
|
||||
cp $(OUT_ARM64) $(RUNTIME_ARM64)/
|
||||
cp $(OUT_X64) $(RUNTIME_X64)/
|
||||
@echo "Installed to runtimes/"
|
||||
|
||||
clean:
|
||||
rm -rf osx-arm64 osx-x64 osx-universal
|
||||
332
native/WinToastWrapper/WinToastWrapper.cpp
Normal file
332
native/WinToastWrapper/WinToastWrapper.cpp
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
/**
|
||||
* WinToastWrapper.cpp
|
||||
*
|
||||
* Implementation of the flat C API declared in WinToastWrapper.h.
|
||||
* Links against WinToastLib (wintoastlib.h / wintoastlib.cpp from mohabouje/WinToast).
|
||||
*
|
||||
* Compilation requirements:
|
||||
* - MSVC 2019 or later, C++17
|
||||
* - /W3 /EHsc /MT (static CRT to avoid runtime dependency)
|
||||
* - Link: Ole32.lib Shlwapi.lib Shell32.lib
|
||||
* - _WIN32_WINNT >= 0x0602 (Windows 8)
|
||||
* - WINTOASTWRAPPER_EXPORTS defined in the DLL project
|
||||
*
|
||||
* Threading model:
|
||||
* WNT_Initialize and WNT_ShowToast MUST be called from an STA thread.
|
||||
* Callbacks fire on a WinRT thread-pool thread — they must not call back into
|
||||
* WNT_ShowToast or WNT_HideToast directly; the .NET side re-queues on the STA.
|
||||
*/
|
||||
|
||||
#define WINTOASTWRAPPER_EXPORTS
|
||||
#define NOMINMAX
|
||||
#include <Windows.h>
|
||||
#include <string>
|
||||
#include <memory>
|
||||
#include <unordered_map>
|
||||
#include <mutex>
|
||||
#include "WinToastWrapper.h"
|
||||
#include "vendor/wintoastlib.h"
|
||||
|
||||
// WinToast is a class inside the WinToastLib namespace — not a nested namespace.
|
||||
// "using namespace WinToastLib" brings WinToast, WinToastTemplate, IWinToastHandler,
|
||||
// WinToastError, etc. into the global scope.
|
||||
using namespace WinToastLib;
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Handler implementation
|
||||
* -------------------------------------------------------------------------
|
||||
* WinToastLib wraps the IWinToastHandler* in a std::shared_ptr<IWinToastHandler>
|
||||
* at the start of showToast (wintoastlib.cpp line ~721), adopting ownership.
|
||||
* WinToastLib will delete the handler when the shared_ptr is destroyed.
|
||||
* We must NEVER delete a handler ourselves — doing so would cause a double-free.
|
||||
*
|
||||
* g_handlers is kept only for lookup (e.g. WNT_HideToast needs the handler for
|
||||
* the toast-ID-to-handler mapping that WinToastLib itself tracks). Entries are
|
||||
* removed from g_handlers when a toast ends, but the pointed-to object is never
|
||||
* freed here.
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
class WinToastHandlerImpl : public IWinToastHandler
|
||||
{
|
||||
public:
|
||||
INT64 m_toastId;
|
||||
WNT_Handler m_handler; // copied by value
|
||||
|
||||
WinToastHandlerImpl(INT64 toastId, const WNT_Handler& handler)
|
||||
: m_toastId(toastId), m_handler(handler) {}
|
||||
|
||||
// WinToastDismissalReason is a nested enum of IWinToastHandler.
|
||||
// It is accessible by unqualified name within this derived class.
|
||||
|
||||
void toastActivated() const override
|
||||
{
|
||||
if (m_handler.onActivated)
|
||||
m_handler.onActivated(m_toastId);
|
||||
}
|
||||
|
||||
void toastActivated(int actionIndex) const override
|
||||
{
|
||||
if (m_handler.onButtonActivated)
|
||||
m_handler.onButtonActivated(m_toastId, actionIndex);
|
||||
}
|
||||
|
||||
// Input response (text box) — treat as a plain activation for our purposes.
|
||||
void toastActivated(std::wstring /*response*/) const override
|
||||
{
|
||||
if (m_handler.onActivated)
|
||||
m_handler.onActivated(m_toastId);
|
||||
}
|
||||
|
||||
void toastDismissed(WinToastDismissalReason state) const override
|
||||
{
|
||||
if (m_handler.onDismissed)
|
||||
{
|
||||
int reason = 0;
|
||||
switch (state)
|
||||
{
|
||||
case WinToastDismissalReason::UserCanceled: reason = 0; break;
|
||||
case WinToastDismissalReason::ApplicationHidden: reason = 1; break;
|
||||
case WinToastDismissalReason::TimedOut: reason = 2; break;
|
||||
}
|
||||
m_handler.onDismissed(m_toastId, reason);
|
||||
}
|
||||
ScheduleDelete(m_toastId);
|
||||
}
|
||||
|
||||
void toastFailed() const override
|
||||
{
|
||||
if (m_handler.onFailed)
|
||||
m_handler.onFailed(m_toastId);
|
||||
ScheduleDelete(m_toastId);
|
||||
}
|
||||
|
||||
private:
|
||||
// Removes this toast from the lookup table when its lifecycle ends.
|
||||
// Does NOT delete the handler — WinToastLib owns it via shared_ptr.
|
||||
static void ScheduleDelete(INT64 toastId);
|
||||
};
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Global state
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
static std::mutex g_mutex;
|
||||
static std::unordered_map<INT64, WinToastHandlerImpl*> g_handlers; // live toasts (no ownership)
|
||||
|
||||
// Removes the entry for toastId from g_handlers.
|
||||
// Does NOT delete the handler — WinToastLib owns it via shared_ptr.
|
||||
void WinToastHandlerImpl::ScheduleDelete(INT64 toastId)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_mutex);
|
||||
g_handlers.erase(toastId);
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Helpers
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
static WinToastTemplate::WinToastTemplateType SelectTemplateType(
|
||||
bool hasImage, bool hasBody)
|
||||
{
|
||||
if (hasImage)
|
||||
return hasBody
|
||||
? WinToastTemplate::ImageAndText02 // image + title + body
|
||||
: WinToastTemplate::ImageAndText01; // image + title only
|
||||
|
||||
return hasBody
|
||||
? WinToastTemplate::Text02 // title + body
|
||||
: WinToastTemplate::Text01; // title only
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SEH-safe wrappers for WinToastLib calls that can trigger AVs from WinRT.
|
||||
//
|
||||
// MSVC rule: __try cannot appear in a function that has local objects with
|
||||
// destructors (WinToastTemplate, std::lock_guard, std::wstring, etc.).
|
||||
// These helpers contain ONLY plain scalars and raw pointers so __try is legal.
|
||||
// Any structured exception (AV, invalid handle, COM fault) is caught here and
|
||||
// converted to an error-code return, preventing it from ever reaching the CLR
|
||||
// where a corrupted-state exception would terminate the process.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Calls WinToast::showToast with full SEH protection.
|
||||
// tmpl is passed as a const pointer to avoid any destructor in this frame.
|
||||
static INT64 SafeShowToast(
|
||||
const WinToastTemplate* tmpl,
|
||||
IWinToastHandler* handler,
|
||||
WinToast::WinToastError* error)
|
||||
{
|
||||
INT64 result = static_cast<INT64>(WinToast::WinToastError::UnknownError);
|
||||
__try
|
||||
{
|
||||
result = WinToast::instance()->showToast(*tmpl, handler, error);
|
||||
}
|
||||
__except (EXCEPTION_EXECUTE_HANDLER)
|
||||
{
|
||||
// Structured exception (AV, etc.) from WinRT internals — return failure.
|
||||
result = static_cast<INT64>(WinToast::WinToastError::UnknownError);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
// Calls WinToast::hideToast with full SEH protection.
|
||||
static BOOL SafeHideToast(INT64 toastId)
|
||||
{
|
||||
BOOL result = FALSE;
|
||||
__try
|
||||
{
|
||||
result = WinToast::instance()->hideToast(toastId) ? TRUE : FALSE;
|
||||
}
|
||||
__except (EXCEPTION_EXECUTE_HANDLER)
|
||||
{
|
||||
result = FALSE;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Exported API
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
extern "C" {
|
||||
|
||||
NOTIFYAPI BOOL WNT_IsCompatible(void)
|
||||
{
|
||||
return WinToast::isCompatible() ? TRUE : FALSE;
|
||||
}
|
||||
|
||||
NOTIFYAPI BOOL WNT_Initialize(const wchar_t* appName, const wchar_t* appUserModelId)
|
||||
{
|
||||
if (!WinToast::isCompatible())
|
||||
return FALSE;
|
||||
|
||||
WinToast* instance = WinToast::instance();
|
||||
instance->setAppName(appName);
|
||||
instance->setAppUserModelId(appUserModelId);
|
||||
|
||||
// SHORTCUT_POLICY_REQUIRE_CREATE: create a Start-Menu shortcut with this AUMI
|
||||
// automatically if one does not already exist. Required for unpackaged (Win32)
|
||||
// apps so that toasts persist in the Action Centre between sessions.
|
||||
instance->setShortcutPolicy(WinToast::SHORTCUT_POLICY_REQUIRE_CREATE);
|
||||
|
||||
WinToast::WinToastError error = WinToast::WinToastError::NoError;
|
||||
if (!instance->initialize(&error))
|
||||
return FALSE;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_mutex);
|
||||
// Clear lookup table from a previous Initialize/Uninitialize cycle.
|
||||
// Do NOT delete handlers — WinToastLib owns them via shared_ptr.
|
||||
g_handlers.clear();
|
||||
}
|
||||
|
||||
return TRUE;
|
||||
}
|
||||
|
||||
NOTIFYAPI void WNT_Uninitialize(void)
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_mutex);
|
||||
// Do NOT delete handlers — WinToastLib owns them via shared_ptr.
|
||||
g_handlers.clear();
|
||||
}
|
||||
|
||||
NOTIFYAPI INT64 WNT_ShowToast(
|
||||
const WNT_ToastDescriptor* descriptor,
|
||||
const WNT_Handler* handler)
|
||||
{
|
||||
if (!descriptor || !handler)
|
||||
return static_cast<INT64>(WinToast::WinToastError::InvalidParameters);
|
||||
|
||||
bool hasBody = descriptor->body != nullptr && descriptor->body[0] != L'\0';
|
||||
bool hasImage = descriptor->imagePath != nullptr && descriptor->imagePath[0] != L'\0';
|
||||
|
||||
WinToastTemplate tmpl(SelectTemplateType(hasImage, hasBody));
|
||||
|
||||
tmpl.setTextField(descriptor->title, WinToastTemplate::FirstLine);
|
||||
if (hasBody)
|
||||
tmpl.setTextField(descriptor->body, WinToastTemplate::SecondLine);
|
||||
|
||||
if (hasImage)
|
||||
tmpl.setImagePath(descriptor->imagePath);
|
||||
|
||||
for (int i = 0; i < descriptor->buttonCount; ++i)
|
||||
{
|
||||
if (descriptor->buttonLabels && descriptor->buttonLabels[i])
|
||||
tmpl.addAction(descriptor->buttonLabels[i]);
|
||||
}
|
||||
|
||||
if (descriptor->expirationMs > 0)
|
||||
tmpl.setExpiration(descriptor->expirationMs);
|
||||
|
||||
switch (descriptor->audioOption)
|
||||
{
|
||||
case WNT_AUDIO_SILENT:
|
||||
tmpl.setAudioOption(WinToastTemplate::AudioOption::Silent);
|
||||
break;
|
||||
case WNT_AUDIO_LOOP:
|
||||
tmpl.setAudioOption(WinToastTemplate::AudioOption::Loop);
|
||||
break;
|
||||
default:
|
||||
tmpl.setAudioOption(WinToastTemplate::AudioOption::Default);
|
||||
break;
|
||||
}
|
||||
|
||||
switch (descriptor->scenario)
|
||||
{
|
||||
case WNT_SCENARIO_ALARM:
|
||||
tmpl.setScenario(WinToastTemplate::Scenario::Alarm);
|
||||
break;
|
||||
case WNT_SCENARIO_REMINDER:
|
||||
tmpl.setScenario(WinToastTemplate::Scenario::Reminder);
|
||||
break;
|
||||
case WNT_SCENARIO_INCOMING_CALL:
|
||||
tmpl.setScenario(WinToastTemplate::Scenario::IncomingCall);
|
||||
break;
|
||||
default:
|
||||
tmpl.setScenario(WinToastTemplate::Scenario::Default);
|
||||
break;
|
||||
}
|
||||
|
||||
// Allocate the handler. Use nothrow so a failed allocation returns an error
|
||||
// code rather than throwing std::bad_alloc through the extern "C" boundary.
|
||||
auto* handlerImpl = new (std::nothrow) WinToastHandlerImpl(0 /* filled in below */, *handler);
|
||||
if (!handlerImpl)
|
||||
return static_cast<INT64>(WinToast::WinToastError::UnknownError);
|
||||
|
||||
WinToast::WinToastError error = WinToast::WinToastError::NoError;
|
||||
INT64 toastId = SafeShowToast(&tmpl, handlerImpl, &error);
|
||||
|
||||
if (toastId < 0)
|
||||
{
|
||||
// showToast failed — WinToastLib created a shared_ptr internally which
|
||||
// will delete handlerImpl when it destructs. Do NOT delete it here.
|
||||
return toastId; // already the error code cast from WinToastError
|
||||
}
|
||||
|
||||
handlerImpl->m_toastId = toastId;
|
||||
|
||||
{
|
||||
std::lock_guard<std::mutex> lock(g_mutex);
|
||||
g_handlers[toastId] = handlerImpl;
|
||||
}
|
||||
|
||||
return toastId;
|
||||
}
|
||||
|
||||
NOTIFYAPI BOOL WNT_HideToast(INT64 toastId)
|
||||
{
|
||||
BOOL ok = SafeHideToast(toastId);
|
||||
|
||||
if (ok)
|
||||
{
|
||||
// hideToast may fire the ApplicationHidden dismissal callback synchronously,
|
||||
// which calls ScheduleDelete and removes from g_handlers. Erase here too in
|
||||
// case the callback was skipped. Do NOT delete — WinToastLib owns the handler.
|
||||
std::lock_guard<std::mutex> lock(g_mutex);
|
||||
g_handlers.erase(toastId);
|
||||
}
|
||||
|
||||
return ok ? TRUE : FALSE;
|
||||
}
|
||||
|
||||
} /* extern "C" */
|
||||
142
native/WinToastWrapper/WinToastWrapper.h
Normal file
142
native/WinToastWrapper/WinToastWrapper.h
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
/**
|
||||
* WinToastWrapper.h
|
||||
*
|
||||
* Flat C API that wraps WinToastLib (https://github.com/mohabouje/WinToast).
|
||||
* This header is the sole interface between the managed P/Invoke layer and
|
||||
* the C++ WinToastLib implementation.
|
||||
*
|
||||
* ABI contract (must stay in sync with WinToastNative.cs):
|
||||
* - All strings are wchar_t* (UTF-16LE, null-terminated).
|
||||
* - Callbacks are called on a WinRT thread-pool thread, NOT the calling STA thread.
|
||||
* - WNT_ShowToast must be called from the STA thread that called WNT_Initialize.
|
||||
* - WNT_HideToast must be called from the same STA thread.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <Windows.h>
|
||||
|
||||
#ifdef WINTOASTWRAPPER_EXPORTS
|
||||
#define NOTIFYAPI __declspec(dllexport)
|
||||
#else
|
||||
#define NOTIFYAPI __declspec(dllimport)
|
||||
#endif
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C" {
|
||||
#endif
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Callback function pointer types
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/** Called when the user clicks the notification body (no button). */
|
||||
typedef void (CALLBACK* WNT_ActivatedCallback)(INT64 toastId);
|
||||
|
||||
/** Called when the user clicks one of the action buttons. */
|
||||
typedef void (CALLBACK* WNT_ButtonActivatedCallback)(INT64 toastId, int buttonIndex);
|
||||
|
||||
/**
|
||||
* Called when the notification is dismissed.
|
||||
* reason: 0 = UserCancelled, 1 = ApplicationHidden, 2 = TimedOut
|
||||
*/
|
||||
typedef void (CALLBACK* WNT_DismissedCallback)(INT64 toastId, int reason);
|
||||
|
||||
/** Called when the notification fails to display. */
|
||||
typedef void (CALLBACK* WNT_FailedCallback)(INT64 toastId);
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* Structs (must match StructLayout in WinToastNative.cs exactly)
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
typedef enum _WNT_Scenario {
|
||||
WNT_SCENARIO_DEFAULT = 0,
|
||||
WNT_SCENARIO_ALARM = 1,
|
||||
WNT_SCENARIO_REMINDER = 2,
|
||||
WNT_SCENARIO_INCOMING_CALL = 3
|
||||
} WNT_Scenario;
|
||||
|
||||
typedef enum _WNT_AudioOption {
|
||||
WNT_AUDIO_DEFAULT = 0,
|
||||
WNT_AUDIO_SILENT = 1,
|
||||
WNT_AUDIO_LOOP = 2
|
||||
} WNT_AudioOption;
|
||||
|
||||
/**
|
||||
* Describes the notification to display.
|
||||
* All pointer fields may be NULL where noted.
|
||||
* Callers must keep pointed-to memory valid for the duration of WNT_ShowToast.
|
||||
*/
|
||||
typedef struct _WNT_ToastDescriptor {
|
||||
const wchar_t* title; /* required */
|
||||
const wchar_t* body; /* nullable */
|
||||
const wchar_t* imagePath; /* nullable — absolute path to an image file */
|
||||
const wchar_t** buttonLabels; /* nullable — array of buttonCount wchar_t* */
|
||||
int buttonCount;
|
||||
long long expirationMs; /* 0 = platform default */
|
||||
WNT_Scenario scenario;
|
||||
WNT_AudioOption audioOption;
|
||||
} WNT_ToastDescriptor;
|
||||
|
||||
/**
|
||||
* Bundle of callback function pointers for one notification.
|
||||
* The struct is copied by value inside WNT_ShowToast; callers need not keep it alive.
|
||||
* Individual function pointers may be NULL to skip that event.
|
||||
*/
|
||||
typedef struct _WNT_Handler {
|
||||
WNT_ActivatedCallback onActivated;
|
||||
WNT_ButtonActivatedCallback onButtonActivated;
|
||||
WNT_DismissedCallback onDismissed;
|
||||
WNT_FailedCallback onFailed;
|
||||
} WNT_Handler;
|
||||
|
||||
/* -------------------------------------------------------------------------
|
||||
* API functions
|
||||
* ------------------------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Initialises WinToastLib. Must be called once from an STA thread.
|
||||
*
|
||||
* @param appName Human-readable name shown in the Action Centre.
|
||||
* @param appUserModelId AppUserModelId (AUMI). The wrapper creates a Start-Menu
|
||||
* shortcut carrying this AUMI automatically if one does not
|
||||
* already exist.
|
||||
* @return TRUE on success.
|
||||
*/
|
||||
NOTIFYAPI BOOL WNT_Initialize(const wchar_t* appName, const wchar_t* appUserModelId);
|
||||
|
||||
/**
|
||||
* Releases all WinToastLib resources. Call from the same STA thread as WNT_Initialize.
|
||||
*/
|
||||
NOTIFYAPI void WNT_Uninitialize(void);
|
||||
|
||||
/**
|
||||
* Returns TRUE if WinToast is supported on the current Windows version (requires Win 8+).
|
||||
* Check this before calling WNT_Initialize.
|
||||
*/
|
||||
NOTIFYAPI BOOL WNT_IsCompatible(void);
|
||||
|
||||
/**
|
||||
* Displays a toast notification.
|
||||
*
|
||||
* Must be called from the STA thread that called WNT_Initialize.
|
||||
*
|
||||
* @param descriptor Pointer to a WNT_ToastDescriptor (read-only during the call).
|
||||
* @param handler Pointer to a WNT_Handler with callback function pointers.
|
||||
* @return A positive INT64 toast ID on success, or a negative WinToastError code on failure.
|
||||
*/
|
||||
NOTIFYAPI INT64 WNT_ShowToast(const WNT_ToastDescriptor* descriptor, const WNT_Handler* handler);
|
||||
|
||||
/**
|
||||
* Programmatically dismisses a previously shown toast.
|
||||
*
|
||||
* Must be called from the same STA thread as WNT_ShowToast.
|
||||
*
|
||||
* @param toastId The ID returned by WNT_ShowToast.
|
||||
* @return TRUE if the toast was successfully hidden.
|
||||
*/
|
||||
NOTIFYAPI BOOL WNT_HideToast(INT64 toastId);
|
||||
|
||||
#ifdef __cplusplus
|
||||
} /* extern "C" */
|
||||
#endif
|
||||
92
native/WinToastWrapper/WinToastWrapper.vcxproj
Normal file
92
native/WinToastWrapper/WinToastWrapper.vcxproj
Normal file
|
|
@ -0,0 +1,92 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project DefaultTargets="Build" xmlns="http://schemas.microsoft.com/developer/msbuild/2003">
|
||||
|
||||
<ItemGroup Label="ProjectConfigurations">
|
||||
<ProjectConfiguration Include="Release|Win32">
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>Win32</Platform>
|
||||
</ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Release|x64">
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>x64</Platform>
|
||||
</ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Release|ARM64">
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>ARM64</Platform>
|
||||
</ProjectConfiguration>
|
||||
<ProjectConfiguration Include="Debug|x64">
|
||||
<Configuration>Debug</Configuration>
|
||||
<Platform>x64</Platform>
|
||||
</ProjectConfiguration>
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Label="Globals">
|
||||
<VCProjectVersion>17.0</VCProjectVersion>
|
||||
<ProjectGuid>{C1D2E3F4-A5B6-7890-CDEF-012345678901}</ProjectGuid>
|
||||
<RootNamespace>WinToastWrapper</RootNamespace>
|
||||
<WindowsTargetPlatformVersion>10.0</WindowsTargetPlatformVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.Default.props" />
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)'=='Release'" Label="Configuration">
|
||||
<ConfigurationType>DynamicLibrary</ConfigurationType>
|
||||
<UseDebugLibraries>false</UseDebugLibraries>
|
||||
<PlatformToolset>v143</PlatformToolset>
|
||||
<WholeProgramOptimization>true</WholeProgramOptimization>
|
||||
<CharacterSet>Unicode</CharacterSet>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)'=='Debug'" Label="Configuration">
|
||||
<ConfigurationType>DynamicLibrary</ConfigurationType>
|
||||
<UseDebugLibraries>true</UseDebugLibraries>
|
||||
<PlatformToolset>v143</PlatformToolset>
|
||||
<CharacterSet>Unicode</CharacterSet>
|
||||
</PropertyGroup>
|
||||
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.props" />
|
||||
|
||||
<PropertyGroup>
|
||||
<!-- Output directly to the runtimes folder consumed by the .NET project -->
|
||||
<OutDir Condition="'$(Platform)'=='x64'">..\..\runtimes\win-x64\native\</OutDir>
|
||||
<OutDir Condition="'$(Platform)'=='Win32'">..\..\runtimes\win-x86\native\</OutDir>
|
||||
<OutDir Condition="'$(Platform)'=='ARM64'">..\..\runtimes\win-arm64\native\</OutDir>
|
||||
<TargetName>WinToastWrapper</TargetName>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemDefinitionGroup>
|
||||
<ClCompile>
|
||||
<WarningLevel>Level3</WarningLevel>
|
||||
<LanguageStandard>stdcpp17</LanguageStandard>
|
||||
<PreprocessorDefinitions>
|
||||
WINTOASTWRAPPER_EXPORTS;
|
||||
_WINDOWS;
|
||||
_WIN32_WINNT=0x0602;
|
||||
WIN32_LEAN_AND_MEAN;
|
||||
NOMINMAX;
|
||||
%(PreprocessorDefinitions)
|
||||
</PreprocessorDefinitions>
|
||||
<RuntimeLibrary Condition="'$(Configuration)'=='Release'">MultiThreaded</RuntimeLibrary>
|
||||
<RuntimeLibrary Condition="'$(Configuration)'=='Debug'">MultiThreadedDebug</RuntimeLibrary>
|
||||
<ExceptionHandling>Sync</ExceptionHandling>
|
||||
<AdditionalIncludeDirectories>$(ProjectDir)vendor;%(AdditionalIncludeDirectories)</AdditionalIncludeDirectories>
|
||||
</ClCompile>
|
||||
<Link>
|
||||
<SubSystem>Windows</SubSystem>
|
||||
<AdditionalDependencies>Ole32.lib;Shlwapi.lib;Shell32.lib;%(AdditionalDependencies)</AdditionalDependencies>
|
||||
</Link>
|
||||
</ItemDefinitionGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ClInclude Include="WinToastWrapper.h" />
|
||||
<ClInclude Include="vendor\wintoastlib.h" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ClCompile Include="WinToastWrapper.cpp" />
|
||||
<ClCompile Include="vendor\wintoastlib.cpp" />
|
||||
</ItemGroup>
|
||||
|
||||
<Import Project="$(VCTargetsPath)\Microsoft.Cpp.targets" />
|
||||
|
||||
</Project>
|
||||
1492
native/WinToastWrapper/vendor/wintoastlib.cpp
vendored
Normal file
1492
native/WinToastWrapper/vendor/wintoastlib.cpp
vendored
Normal file
File diff suppressed because it is too large
Load diff
318
native/WinToastWrapper/vendor/wintoastlib.h
vendored
Normal file
318
native/WinToastWrapper/vendor/wintoastlib.h
vendored
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
/**
|
||||
* MIT License
|
||||
*
|
||||
* Copyright (C) 2016-2023 WinToast v1.3.0 - Mohammed Boujemaoui <mohabouje@gmail.com>
|
||||
*
|
||||
* Permission is hereby granted, free of charge, to any person obtaining a copy of
|
||||
* this software and associated documentation files (the "Software"), to deal in
|
||||
* the Software without restriction, including without limitation the rights to
|
||||
* use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
|
||||
* the Software, and to permit persons to whom the Software is furnished to do so,
|
||||
* subject to the following conditions:
|
||||
*
|
||||
* The above copyright notice and this permission notice shall be included in all
|
||||
* copies or substantial portions of the Software.
|
||||
*
|
||||
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
|
||||
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
||||
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
* IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
|
||||
* CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
*/
|
||||
|
||||
#ifndef WINTOASTLIB_H
|
||||
#define WINTOASTLIB_H
|
||||
|
||||
#include <Windows.h>
|
||||
#include <sdkddkver.h>
|
||||
#include <WinUser.h>
|
||||
#include <ShObjIdl.h>
|
||||
#include <wrl/implements.h>
|
||||
#include <wrl/event.h>
|
||||
#include <windows.ui.notifications.h>
|
||||
#include <strsafe.h>
|
||||
#include <Psapi.h>
|
||||
#include <ShlObj.h>
|
||||
#include <roapi.h>
|
||||
#include <propvarutil.h>
|
||||
#include <functiondiscoverykeys.h>
|
||||
#include <iostream>
|
||||
#include <winstring.h>
|
||||
#include <string.h>
|
||||
#include <vector>
|
||||
#include <map>
|
||||
#include <memory>
|
||||
|
||||
using namespace Microsoft::WRL;
|
||||
using namespace ABI::Windows::Data::Xml::Dom;
|
||||
using namespace ABI::Windows::Foundation;
|
||||
using namespace ABI::Windows::UI::Notifications;
|
||||
using namespace Windows::Foundation;
|
||||
|
||||
namespace WinToastLib {
|
||||
|
||||
void setDebugOutputEnabled(bool enabled);
|
||||
|
||||
class IWinToastHandler {
|
||||
public:
|
||||
enum WinToastDismissalReason {
|
||||
UserCanceled = ToastDismissalReason::ToastDismissalReason_UserCanceled,
|
||||
ApplicationHidden = ToastDismissalReason::ToastDismissalReason_ApplicationHidden,
|
||||
TimedOut = ToastDismissalReason::ToastDismissalReason_TimedOut
|
||||
};
|
||||
|
||||
virtual ~IWinToastHandler() = default;
|
||||
virtual void toastActivated() const = 0;
|
||||
virtual void toastActivated(int actionIndex) const = 0;
|
||||
virtual void toastActivated(std::wstring response) const = 0;
|
||||
virtual void toastDismissed(WinToastDismissalReason state) const = 0;
|
||||
virtual void toastFailed() const = 0;
|
||||
};
|
||||
|
||||
class WinToastTemplate {
|
||||
public:
|
||||
enum class Scenario { Default, Alarm, IncomingCall, Reminder };
|
||||
enum Duration { System, Short, Long };
|
||||
enum AudioOption { Default = 0, Silent, Loop };
|
||||
enum TextField { FirstLine = 0, SecondLine, ThirdLine };
|
||||
|
||||
enum WinToastTemplateType {
|
||||
ImageAndText01 = ToastTemplateType::ToastTemplateType_ToastImageAndText01,
|
||||
ImageAndText02 = ToastTemplateType::ToastTemplateType_ToastImageAndText02,
|
||||
ImageAndText03 = ToastTemplateType::ToastTemplateType_ToastImageAndText03,
|
||||
ImageAndText04 = ToastTemplateType::ToastTemplateType_ToastImageAndText04,
|
||||
Text01 = ToastTemplateType::ToastTemplateType_ToastText01,
|
||||
Text02 = ToastTemplateType::ToastTemplateType_ToastText02,
|
||||
Text03 = ToastTemplateType::ToastTemplateType_ToastText03,
|
||||
Text04 = ToastTemplateType::ToastTemplateType_ToastText04
|
||||
};
|
||||
|
||||
enum AudioSystemFile {
|
||||
DefaultSound,
|
||||
IM,
|
||||
Mail,
|
||||
Reminder,
|
||||
SMS,
|
||||
Alarm,
|
||||
Alarm2,
|
||||
Alarm3,
|
||||
Alarm4,
|
||||
Alarm5,
|
||||
Alarm6,
|
||||
Alarm7,
|
||||
Alarm8,
|
||||
Alarm9,
|
||||
Alarm10,
|
||||
Call,
|
||||
Call1,
|
||||
Call2,
|
||||
Call3,
|
||||
Call4,
|
||||
Call5,
|
||||
Call6,
|
||||
Call7,
|
||||
Call8,
|
||||
Call9,
|
||||
Call10,
|
||||
};
|
||||
|
||||
enum CropHint {
|
||||
Square,
|
||||
Circle,
|
||||
};
|
||||
|
||||
WinToastTemplate(_In_ WinToastTemplateType type = WinToastTemplateType::ImageAndText02);
|
||||
~WinToastTemplate();
|
||||
|
||||
void setFirstLine(_In_ std::wstring const& text);
|
||||
void setSecondLine(_In_ std::wstring const& text);
|
||||
void setThirdLine(_In_ std::wstring const& text);
|
||||
void setTextField(_In_ std::wstring const& txt, _In_ TextField pos);
|
||||
void setAttributionText(_In_ std::wstring const& attributionText);
|
||||
void setImagePath(_In_ std::wstring const& imgPath, _In_ CropHint cropHint = CropHint::Square);
|
||||
void setHeroImagePath(_In_ std::wstring const& imgPath, _In_ bool inlineImage = false);
|
||||
void setAudioPath(_In_ WinToastTemplate::AudioSystemFile audio);
|
||||
void setAudioPath(_In_ std::wstring const& audioPath);
|
||||
void setAudioOption(_In_ WinToastTemplate::AudioOption audioOption);
|
||||
void setDuration(_In_ Duration duration);
|
||||
void setExpiration(_In_ INT64 millisecondsFromNow);
|
||||
void setScenario(_In_ Scenario scenario);
|
||||
void addAction(_In_ std::wstring const& label);
|
||||
void addInput();
|
||||
|
||||
std::size_t textFieldsCount() const;
|
||||
std::size_t actionsCount() const;
|
||||
bool hasImage() const;
|
||||
bool hasHeroImage() const;
|
||||
std::vector<std::wstring> const& textFields() const;
|
||||
std::wstring const& textField(_In_ TextField pos) const;
|
||||
std::wstring const& actionLabel(_In_ std::size_t pos) const;
|
||||
std::wstring const& imagePath() const;
|
||||
std::wstring const& heroImagePath() const;
|
||||
std::wstring const& audioPath() const;
|
||||
std::wstring const& attributionText() const;
|
||||
std::wstring const& scenario() const;
|
||||
INT64 expiration() const;
|
||||
WinToastTemplateType type() const;
|
||||
WinToastTemplate::AudioOption audioOption() const;
|
||||
Duration duration() const;
|
||||
bool isToastGeneric() const;
|
||||
bool isInlineHeroImage() const;
|
||||
bool isCropHintCircle() const;
|
||||
bool isInput() const;
|
||||
|
||||
private:
|
||||
bool _hasInput{false};
|
||||
|
||||
std::vector<std::wstring> _textFields{};
|
||||
std::vector<std::wstring> _actions{};
|
||||
std::wstring _imagePath{};
|
||||
std::wstring _heroImagePath{};
|
||||
bool _inlineHeroImage{false};
|
||||
std::wstring _audioPath{};
|
||||
std::wstring _attributionText{};
|
||||
std::wstring _scenario{L"Default"};
|
||||
INT64 _expiration{0};
|
||||
AudioOption _audioOption{WinToastTemplate::AudioOption::Default};
|
||||
WinToastTemplateType _type{WinToastTemplateType::Text01};
|
||||
Duration _duration{Duration::System};
|
||||
CropHint _cropHint{CropHint::Square};
|
||||
};
|
||||
|
||||
class WinToast {
|
||||
public:
|
||||
enum WinToastError {
|
||||
NoError = 0,
|
||||
NotInitialized,
|
||||
SystemNotSupported,
|
||||
ShellLinkNotCreated,
|
||||
InvalidAppUserModelID,
|
||||
InvalidParameters,
|
||||
InvalidHandler,
|
||||
NotDisplayed,
|
||||
UnknownError
|
||||
};
|
||||
|
||||
enum ShortcutResult {
|
||||
SHORTCUT_UNCHANGED = 0,
|
||||
SHORTCUT_WAS_CHANGED = 1,
|
||||
SHORTCUT_WAS_CREATED = 2,
|
||||
|
||||
SHORTCUT_MISSING_PARAMETERS = -1,
|
||||
SHORTCUT_INCOMPATIBLE_OS = -2,
|
||||
SHORTCUT_COM_INIT_FAILURE = -3,
|
||||
SHORTCUT_CREATE_FAILED = -4
|
||||
};
|
||||
|
||||
enum ShortcutPolicy {
|
||||
/* Don't check, create, or modify a shortcut. */
|
||||
SHORTCUT_POLICY_IGNORE = 0,
|
||||
/* Require a shortcut with matching AUMI, don't create or modify an existing one. */
|
||||
SHORTCUT_POLICY_REQUIRE_NO_CREATE = 1,
|
||||
/* Require a shortcut with matching AUMI, create if missing, modify if not matching. This is the default. */
|
||||
SHORTCUT_POLICY_REQUIRE_CREATE = 2,
|
||||
};
|
||||
|
||||
WinToast(void);
|
||||
virtual ~WinToast();
|
||||
static WinToast* instance();
|
||||
static bool isCompatible();
|
||||
static bool isSupportingModernFeatures();
|
||||
static bool isWin10AnniversaryOrHigher();
|
||||
static std::wstring configureAUMI(_In_ std::wstring const& companyName, _In_ std::wstring const& productName,
|
||||
_In_ std::wstring const& subProduct = std::wstring(),
|
||||
_In_ std::wstring const& versionInformation = std::wstring());
|
||||
static std::wstring const& strerror(_In_ WinToastError error);
|
||||
virtual bool initialize(_Out_opt_ WinToastError* error = nullptr);
|
||||
virtual bool isInitialized() const;
|
||||
virtual bool hideToast(_In_ INT64 id);
|
||||
virtual INT64 showToast(_In_ WinToastTemplate const& toast, _In_ IWinToastHandler* eventHandler,
|
||||
_Out_opt_ WinToastError* error = nullptr);
|
||||
virtual void clear();
|
||||
virtual enum ShortcutResult createShortcut();
|
||||
|
||||
std::wstring const& appName() const;
|
||||
std::wstring const& appUserModelId() const;
|
||||
void setAppUserModelId(_In_ std::wstring const& aumi);
|
||||
void setAppName(_In_ std::wstring const& appName);
|
||||
void setShortcutPolicy(_In_ ShortcutPolicy policy);
|
||||
|
||||
protected:
|
||||
struct NotifyData {
|
||||
NotifyData(){};
|
||||
NotifyData(_In_ ComPtr<IToastNotification> notify, _In_ EventRegistrationToken activatedToken,
|
||||
_In_ EventRegistrationToken dismissedToken, _In_ EventRegistrationToken failedToken) :
|
||||
_notify(notify), _activatedToken(activatedToken), _dismissedToken(dismissedToken), _failedToken(failedToken) {}
|
||||
|
||||
~NotifyData() {
|
||||
RemoveTokens();
|
||||
}
|
||||
|
||||
void RemoveTokens() {
|
||||
if (!_readyForDeletion) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (_previouslyTokenRemoved) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_notify.Get()) {
|
||||
return;
|
||||
}
|
||||
|
||||
_notify->remove_Activated(_activatedToken);
|
||||
_notify->remove_Dismissed(_dismissedToken);
|
||||
_notify->remove_Failed(_failedToken);
|
||||
_previouslyTokenRemoved = true;
|
||||
}
|
||||
|
||||
void markAsReadyForDeletion() {
|
||||
_readyForDeletion = true;
|
||||
}
|
||||
|
||||
bool isReadyForDeletion() const {
|
||||
return _readyForDeletion;
|
||||
}
|
||||
|
||||
IToastNotification* notification() {
|
||||
return _notify.Get();
|
||||
}
|
||||
|
||||
private:
|
||||
ComPtr<IToastNotification> _notify{nullptr};
|
||||
EventRegistrationToken _activatedToken{};
|
||||
EventRegistrationToken _dismissedToken{};
|
||||
EventRegistrationToken _failedToken{};
|
||||
bool _readyForDeletion{false};
|
||||
bool _previouslyTokenRemoved{false};
|
||||
};
|
||||
|
||||
bool _isInitialized{false};
|
||||
bool _hasCoInitialized{false};
|
||||
ShortcutPolicy _shortcutPolicy{SHORTCUT_POLICY_REQUIRE_CREATE};
|
||||
std::wstring _appName{};
|
||||
std::wstring _aumi{};
|
||||
std::map<INT64, NotifyData> _buffer{};
|
||||
|
||||
void markAsReadyForDeletion(_In_ INT64 id);
|
||||
HRESULT validateShellLinkHelper(_Out_ bool& wasChanged);
|
||||
HRESULT createShellLinkHelper();
|
||||
HRESULT setImageFieldHelper(_In_ IXmlDocument* xml, _In_ std::wstring const& path, _In_ bool isToastGeneric, bool isCropHintCircle);
|
||||
HRESULT setHeroImageHelper(_In_ IXmlDocument* xml, _In_ std::wstring const& path, _In_ bool isInlineImage);
|
||||
HRESULT setBindToastGenericHelper(_In_ IXmlDocument* xml);
|
||||
HRESULT
|
||||
setAudioFieldHelper(_In_ IXmlDocument* xml, _In_ std::wstring const& path,
|
||||
_In_opt_ WinToastTemplate::AudioOption option = WinToastTemplate::AudioOption::Default);
|
||||
HRESULT setTextFieldHelper(_In_ IXmlDocument* xml, _In_ std::wstring const& text, _In_ UINT32 pos);
|
||||
HRESULT setAttributionTextFieldHelper(_In_ IXmlDocument* xml, _In_ std::wstring const& text);
|
||||
HRESULT addActionHelper(_In_ IXmlDocument* xml, _In_ std::wstring const& action, _In_ std::wstring const& arguments);
|
||||
HRESULT addDurationHelper(_In_ IXmlDocument* xml, _In_ std::wstring const& duration);
|
||||
HRESULT addScenarioHelper(_In_ IXmlDocument* xml, _In_ std::wstring const& scenario);
|
||||
HRESULT addInputHelper(_In_ IXmlDocument* xml);
|
||||
ComPtr<IToastNotifier> notifier(_In_ bool* succeded) const;
|
||||
void setError(_Out_opt_ WinToastError* error, _In_ WinToastError value);
|
||||
};
|
||||
} // namespace WinToastLib
|
||||
#endif // WINTOASTLIB_H
|
||||
20
samples/Notify.NET.Sample/Notify.NET.Sample.csproj
Normal file
20
samples/Notify.NET.Sample/Notify.NET.Sample.csproj
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>Exe</OutputType>
|
||||
<!-- Target net6.0 so the sample can run on modern .NET while consuming the netstandard2.0 library -->
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<LangVersion>10</LangVersion>
|
||||
<Nullable>enable</Nullable>
|
||||
<RootNamespace>Notify.NET.Sample</RootNamespace>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Notify.NET\Notify.NET.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
153
samples/Notify.NET.Sample/Program.cs
Normal file
153
samples/Notify.NET.Sample/Program.cs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Notify.NET.Abstractions;
|
||||
using Notify.NET.Builder;
|
||||
using Notify.NET.Extensions;
|
||||
|
||||
// ============================================================================
|
||||
// Notify.NET Sample — demonstrates fluent builder + DI registration
|
||||
// ============================================================================
|
||||
|
||||
// --- Option A: use the factory helper directly (no DI container) ---
|
||||
using var service = ServiceCollectionExtensions.CreateNotificationService(opts =>
|
||||
{
|
||||
opts.AppName = "Notify.NET Sample";
|
||||
opts.AppUserModelId = "NotifyNET.Sample";
|
||||
});
|
||||
|
||||
Console.WriteLine($"Notification service supported: {service.IsSupported}");
|
||||
|
||||
if (!service.IsSupported)
|
||||
{
|
||||
Console.WriteLine("Native notifications are not available on this platform. Exiting.");
|
||||
return;
|
||||
}
|
||||
|
||||
var done = new ManualResetEventSlim(false);
|
||||
|
||||
// ------ 1. Simple notification (title + body) --------------------------------
|
||||
Console.WriteLine("\n[1] Showing a simple notification...");
|
||||
|
||||
long id1 = await service.ShowAsync(
|
||||
NotificationBuilder.Create("Hello from Notify.NET")
|
||||
.WithBody("This is a simple cross-platform OS notification.")
|
||||
.OnActivated(id => Console.WriteLine($" [1] Notification {id} activated"))
|
||||
.OnDismissed((id, reason) => Console.WriteLine($" [1] Notification {id} dismissed: {reason}"))
|
||||
.Build());
|
||||
|
||||
Console.WriteLine($" Shown with id={id1}");
|
||||
await Task.Delay(3000);
|
||||
|
||||
// ------ 2. Notification with buttons ----------------------------------------
|
||||
Console.WriteLine("\n[2] Showing a notification with action buttons...");
|
||||
|
||||
var buttonDone = new ManualResetEventSlim(false);
|
||||
|
||||
long id2 = await service.ShowAsync(
|
||||
NotificationBuilder.Create("Update Available")
|
||||
.WithBody("Version 2.0 is ready to install.")
|
||||
.AddButton("Install Now", id => { Console.WriteLine($" [2] Install Now clicked (id={id})"); buttonDone.Set(); })
|
||||
.AddButton("Remind Me", id => { Console.WriteLine($" [2] Remind Me clicked (id={id})"); buttonDone.Set(); })
|
||||
.AddButton("Skip Version", id => { Console.WriteLine($" [2] Skip Version clicked (id={id})"); buttonDone.Set(); })
|
||||
.OnDismissed((id, reason) => { Console.WriteLine($" [2] Dismissed: {reason}"); buttonDone.Set(); })
|
||||
.OnFailed(id => { Console.WriteLine($" [2] Failed for id={id}"); buttonDone.Set(); })
|
||||
.Build());
|
||||
|
||||
Console.WriteLine($" Shown with id={id2}. Waiting up to 15 s for interaction...");
|
||||
buttonDone.Wait(TimeSpan.FromSeconds(15));
|
||||
|
||||
// ------ 3. Notification with an image ----------------------------------------
|
||||
Console.WriteLine("\n[3] Showing a notification with an image...");
|
||||
|
||||
// Provide any PNG/JPG path; the sample gracefully handles a missing file
|
||||
// because the native service falls back to no image when gdk_pixbuf_new_from_file fails.
|
||||
string imagePath = "image.jpg";
|
||||
|
||||
long id3 = -1;
|
||||
try
|
||||
{
|
||||
id3 = await service.ShowAsync(
|
||||
NotificationBuilder.Create("Picture Notification")
|
||||
.WithBody("This notification includes an image.")
|
||||
.WithImage(imagePath)
|
||||
.WithUrgency(NotificationUrgency.Low)
|
||||
.OnActivated(id => Console.WriteLine($" [3] Activated id={id}"))
|
||||
.Build());
|
||||
Console.WriteLine($" Shown with id={id3}");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($" [3] Failed: {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
await Task.Delay(3000);
|
||||
|
||||
// ------ 4. Programmatic dismiss ----------------------------------------------
|
||||
Console.WriteLine("\n[4] Showing a notification and dismissing it programmatically after 2 s...");
|
||||
|
||||
long id4 = await service.ShowAsync(
|
||||
NotificationBuilder.Create("I will disappear in 2 seconds")
|
||||
.WithBody("Programmatically dismissed.")
|
||||
.OnDismissed((id, reason) => Console.WriteLine($" [4] Dismissed: {reason}"))
|
||||
.Build());
|
||||
|
||||
Console.WriteLine($" Shown with id={id4}. Hiding in 2 s...");
|
||||
await Task.Delay(2000);
|
||||
await service.HideAsync(id4);
|
||||
Console.WriteLine(" Hidden.");
|
||||
|
||||
// ------ 5. Interface-based handler -------------------------------------------
|
||||
Console.WriteLine("\n[5] Showing a notification using INotificationHandler...");
|
||||
|
||||
long id5 = await service.ShowAsync(
|
||||
NotificationBuilder.Create("Handler-based Notification")
|
||||
.WithBody("Uses a custom INotificationHandler implementation.")
|
||||
.AddButton("Acknowledge", null)
|
||||
.WithHandler(new SampleHandler())
|
||||
.Build());
|
||||
|
||||
Console.WriteLine($" Shown with id={id5}. Waiting 10 s...");
|
||||
await Task.Delay(10_000);
|
||||
|
||||
// ------ 6. DI container usage ------------------------------------------------
|
||||
Console.WriteLine("\n[6] Demonstrating DI container registration...");
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddNotifications(opts =>
|
||||
{
|
||||
opts.AppName = "Notify.NET Sample (DI)";
|
||||
opts.AppUserModelId = "NotifyNET.Sample.DI";
|
||||
});
|
||||
|
||||
await using var provider = services.BuildServiceProvider();
|
||||
var diService = provider.GetRequiredService<INotificationService>();
|
||||
|
||||
long id6 = await diService.ShowAsync(
|
||||
NotificationBuilder.Create("DI-registered Service")
|
||||
.WithBody("This notification was shown via an IServiceProvider-resolved service.")
|
||||
.Build());
|
||||
|
||||
Console.WriteLine($" Shown with id={id6}");
|
||||
await Task.Delay(3000);
|
||||
|
||||
Console.WriteLine("\nAll done.");
|
||||
|
||||
// ============================================================================
|
||||
// Sample INotificationHandler implementation
|
||||
// ============================================================================
|
||||
|
||||
sealed class SampleHandler : INotificationHandler
|
||||
{
|
||||
public void OnActivated(long id)
|
||||
=> Console.WriteLine($" [5] SampleHandler.OnActivated(id={id})");
|
||||
|
||||
public void OnButtonActivated(long id, int buttonIndex)
|
||||
=> Console.WriteLine($" [5] SampleHandler.OnButtonActivated(id={id}, button={buttonIndex})");
|
||||
|
||||
public void OnDismissed(long id, DismissReason reason)
|
||||
=> Console.WriteLine($" [5] SampleHandler.OnDismissed(id={id}, reason={reason})");
|
||||
|
||||
public void OnFailed(long id)
|
||||
=> Console.WriteLine($" [5] SampleHandler.OnFailed(id={id})");
|
||||
}
|
||||
BIN
samples/Notify.NET.Sample/image.jpg
Normal file
BIN
samples/Notify.NET.Sample/image.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 164 KiB |
26
src/Notify.NET/Abstractions/INotificationHandler.cs
Normal file
26
src/Notify.NET/Abstractions/INotificationHandler.cs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
using Notify.NET.Builder;
|
||||
|
||||
namespace Notify.NET.Abstractions
|
||||
{
|
||||
/// <summary>
|
||||
/// Receives lifecycle events for a single notification.
|
||||
/// Implement this interface or use the delegate-based callbacks on
|
||||
/// <see cref="NotificationBuilder"/> to respond to user interactions.
|
||||
/// </summary>
|
||||
public interface INotificationHandler
|
||||
{
|
||||
/// <summary>Called when the user clicks the notification body (not a button).</summary>
|
||||
void OnActivated(long notificationId);
|
||||
|
||||
/// <summary>Called when the user clicks one of the action buttons.</summary>
|
||||
/// <param name="notificationId">The notification's platform ID.</param>
|
||||
/// <param name="buttonIndex">Zero-based index matching the order buttons were added via the builder.</param>
|
||||
void OnButtonActivated(long notificationId, int buttonIndex);
|
||||
|
||||
/// <summary>Called when the notification is dismissed (by the user, system, or expiration).</summary>
|
||||
void OnDismissed(long notificationId, DismissReason reason);
|
||||
|
||||
/// <summary>Called when the platform fails to display the notification.</summary>
|
||||
void OnFailed(long notificationId);
|
||||
}
|
||||
}
|
||||
33
src/Notify.NET/Abstractions/INotificationService.cs
Normal file
33
src/Notify.NET/Abstractions/INotificationService.cs
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace Notify.NET.Abstractions
|
||||
{
|
||||
/// <summary>
|
||||
/// Dispatches OS notifications to the native notification subsystem for the current platform.
|
||||
/// </summary>
|
||||
public interface INotificationService : IDisposable
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether the native notification subsystem is available and initialised on this platform.
|
||||
/// If false, <see cref="ShowAsync"/> will throw <see cref="Exceptions.PlatformNotSupportedException"/>.
|
||||
/// </summary>
|
||||
bool IsSupported { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Displays a notification and returns a platform-specific ID that can be used to hide it later.
|
||||
/// </summary>
|
||||
/// <param name="request">The notification to display, constructed via <see cref="Builder.NotificationBuilder"/>.</param>
|
||||
/// <param name="cancellationToken">Optional cancellation token.</param>
|
||||
/// <returns>A non-negative notification ID on success.</returns>
|
||||
Task<long> ShowAsync(NotificationRequest request, CancellationToken cancellationToken = default);
|
||||
|
||||
/// <summary>
|
||||
/// Programmatically dismisses a previously shown notification.
|
||||
/// </summary>
|
||||
/// <param name="notificationId">The ID returned by <see cref="ShowAsync"/>.</param>
|
||||
/// <param name="cancellationToken">Optional cancellation token.</param>
|
||||
Task HideAsync(long notificationId, CancellationToken cancellationToken = default);
|
||||
}
|
||||
}
|
||||
99
src/Notify.NET/Abstractions/NotificationRequest.cs
Normal file
99
src/Notify.NET/Abstractions/NotificationRequest.cs
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Notify.NET.Builder;
|
||||
|
||||
namespace Notify.NET.Abstractions
|
||||
{
|
||||
/// <summary>
|
||||
/// Immutable description of a notification to be displayed.
|
||||
/// Construct instances via <see cref="NotificationBuilder"/>.
|
||||
/// </summary>
|
||||
public sealed class NotificationRequest
|
||||
{
|
||||
/// <summary>The primary heading of the notification.</summary>
|
||||
public string Title { get; }
|
||||
|
||||
/// <summary>Optional body text shown beneath the title.</summary>
|
||||
public string? Body { get; }
|
||||
|
||||
/// <summary>Absolute path to an image file to display in the notification.</summary>
|
||||
public string? ImagePath { get; }
|
||||
|
||||
/// <summary>Action buttons to display. Maximum platform limits apply (typically 5 on Windows, varies on Linux).</summary>
|
||||
public IReadOnlyList<NotificationButton> Buttons { get; }
|
||||
|
||||
/// <summary>Optional interface-based handler for notification lifecycle events.</summary>
|
||||
public INotificationHandler? Handler { get; }
|
||||
|
||||
/// <summary>How long to display the notification before it expires automatically. Null means use the platform default.</summary>
|
||||
public TimeSpan? Expiration { get; }
|
||||
|
||||
/// <summary>Audio behaviour when the notification appears.</summary>
|
||||
public NotificationAudio Audio { get; }
|
||||
|
||||
/// <summary>The urgency/scenario of the notification, which may affect how the platform presents it.</summary>
|
||||
public NotificationUrgency Urgency { get; }
|
||||
|
||||
internal NotificationRequest(
|
||||
string title,
|
||||
string? body,
|
||||
string? imagePath,
|
||||
IReadOnlyList<NotificationButton> buttons,
|
||||
INotificationHandler? handler,
|
||||
TimeSpan? expiration,
|
||||
NotificationAudio audio,
|
||||
NotificationUrgency urgency)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
throw new ArgumentException("Notification title must not be empty.", nameof(title));
|
||||
|
||||
Title = title;
|
||||
Body = body;
|
||||
ImagePath = imagePath;
|
||||
Buttons = buttons;
|
||||
Handler = handler;
|
||||
Expiration = expiration;
|
||||
Audio = audio;
|
||||
Urgency = urgency;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Controls the audio played when the notification is shown (Windows only; Linux ignores this).</summary>
|
||||
public enum NotificationAudio
|
||||
{
|
||||
/// <summary>Play the platform default notification sound.</summary>
|
||||
Default,
|
||||
/// <summary>Display silently with no sound.</summary>
|
||||
Silent,
|
||||
/// <summary>Loop the notification sound until the notification is dismissed.</summary>
|
||||
Loop
|
||||
}
|
||||
|
||||
/// <summary>Maps to the notification urgency/scenario on each platform.</summary>
|
||||
public enum NotificationUrgency
|
||||
{
|
||||
/// <summary>Standard informational notification.</summary>
|
||||
Normal,
|
||||
/// <summary>Low-priority; the platform may suppress or delay it.</summary>
|
||||
Low,
|
||||
/// <summary>High-priority; may bypass Do Not Disturb on some platforms.</summary>
|
||||
Critical,
|
||||
/// <summary>Alarm scenario (Windows) — may produce a full-screen interrupt.</summary>
|
||||
Alarm,
|
||||
/// <summary>Reminder scenario (Windows).</summary>
|
||||
Reminder
|
||||
}
|
||||
|
||||
/// <summary>Reason a notification was dismissed.</summary>
|
||||
public enum DismissReason
|
||||
{
|
||||
/// <summary>The user explicitly dismissed the notification.</summary>
|
||||
UserCancelled,
|
||||
/// <summary>The notification timed out / expired.</summary>
|
||||
TimedOut,
|
||||
/// <summary>The application programmatically hid the notification.</summary>
|
||||
ApplicationHidden,
|
||||
/// <summary>Dismissed for an unspecified or platform-specific reason.</summary>
|
||||
Unknown
|
||||
}
|
||||
}
|
||||
202
src/Notify.NET/Builder/NotificationBuilder.cs
Normal file
202
src/Notify.NET/Builder/NotificationBuilder.cs
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using Notify.NET.Abstractions;
|
||||
|
||||
namespace Notify.NET.Builder
|
||||
{
|
||||
/// <summary>
|
||||
/// Fluent builder for constructing a <see cref="NotificationRequest"/>.
|
||||
/// </summary>
|
||||
/// <example>
|
||||
/// <code>
|
||||
/// var request = NotificationBuilder.Create("Update available")
|
||||
/// .WithBody("Version 2.0 is ready to install.")
|
||||
/// .WithImage("/usr/share/icons/my-app.png")
|
||||
/// .AddButton("Install now", id => Installer.Run())
|
||||
/// .AddButton("Remind me later", id => Snooze(id))
|
||||
/// .OnActivated(id => Console.WriteLine($"Notification {id} clicked"))
|
||||
/// .OnDismissed((id, reason) => Console.WriteLine($"Dismissed: {reason}"))
|
||||
/// .Build();
|
||||
///
|
||||
/// long id = await notificationService.ShowAsync(request);
|
||||
/// </code>
|
||||
/// </example>
|
||||
public sealed class NotificationBuilder
|
||||
{
|
||||
private string _title = string.Empty;
|
||||
private string? _body;
|
||||
private string? _imagePath;
|
||||
private readonly List<NotificationButton> _buttons = new List<NotificationButton>();
|
||||
private INotificationHandler? _handler;
|
||||
private TimeSpan? _expiration;
|
||||
private NotificationAudio _audio = NotificationAudio.Default;
|
||||
private NotificationUrgency _urgency = NotificationUrgency.Normal;
|
||||
|
||||
// Delegate-based callbacks (converted to INotificationHandler in Build())
|
||||
private Action<long>? _onActivated;
|
||||
private Action<long, int>? _onButtonActivated;
|
||||
private Action<long, DismissReason>? _onDismissed;
|
||||
private Action<long>? _onFailed;
|
||||
|
||||
private NotificationBuilder() { }
|
||||
|
||||
/// <summary>Creates a new builder with the specified notification title.</summary>
|
||||
public static NotificationBuilder Create(string title)
|
||||
=> new NotificationBuilder { _title = title };
|
||||
|
||||
/// <summary>Sets the notification title.</summary>
|
||||
public NotificationBuilder WithTitle(string title)
|
||||
{
|
||||
_title = title ?? throw new ArgumentNullException(nameof(title));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Sets the notification body text.</summary>
|
||||
public NotificationBuilder WithBody(string body)
|
||||
{
|
||||
_body = body;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Sets the absolute path of an image to display in the notification.</summary>
|
||||
public NotificationBuilder WithImage(string imagePath)
|
||||
{
|
||||
_imagePath = imagePath;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Adds an action button with an optional click callback.</summary>
|
||||
/// <param name="label">Text shown on the button.</param>
|
||||
/// <param name="callback">Called with the notification ID when the button is clicked.</param>
|
||||
/// <param name="actionId">Optional machine-readable action identifier.</param>
|
||||
public NotificationBuilder AddButton(string label, Action<long>? callback = null, string? actionId = null)
|
||||
{
|
||||
_buttons.Add(new NotificationButton(label, callback, actionId));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Adds a pre-constructed button.</summary>
|
||||
public NotificationBuilder AddButton(NotificationButton button)
|
||||
{
|
||||
_buttons.Add(button ?? throw new ArgumentNullException(nameof(button)));
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches an interface-based handler for all notification lifecycle events.
|
||||
/// This takes priority over any delegate-based callbacks registered via
|
||||
/// <see cref="OnActivated"/>, <see cref="OnDismissed"/>, or <see cref="OnFailed"/>.
|
||||
/// </summary>
|
||||
public NotificationBuilder WithHandler(INotificationHandler handler)
|
||||
{
|
||||
_handler = handler;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Registers a callback for when the notification body is clicked.</summary>
|
||||
public NotificationBuilder OnActivated(Action<long> callback)
|
||||
{
|
||||
_onActivated = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Registers a callback for when a specific action button is clicked.</summary>
|
||||
public NotificationBuilder OnButtonActivated(Action<long, int> callback)
|
||||
{
|
||||
_onButtonActivated = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Registers a callback for when the notification is dismissed.</summary>
|
||||
public NotificationBuilder OnDismissed(Action<long, DismissReason> callback)
|
||||
{
|
||||
_onDismissed = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Registers a callback for when the notification fails to display.</summary>
|
||||
public NotificationBuilder OnFailed(Action<long> callback)
|
||||
{
|
||||
_onFailed = callback;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets how long the notification remains visible before auto-dismissal.
|
||||
/// Pass <see cref="TimeSpan.Zero"/> or null to use the platform default.
|
||||
/// </summary>
|
||||
public NotificationBuilder WithExpiration(TimeSpan expiration)
|
||||
{
|
||||
_expiration = expiration == TimeSpan.Zero ? (TimeSpan?)null : expiration;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Controls the sound played when the notification appears (Windows only).</summary>
|
||||
public NotificationBuilder WithAudio(NotificationAudio audio)
|
||||
{
|
||||
_audio = audio;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>Sets the urgency/scenario which may affect how the platform presents the notification.</summary>
|
||||
public NotificationBuilder WithUrgency(NotificationUrgency urgency)
|
||||
{
|
||||
_urgency = urgency;
|
||||
return this;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Constructs the immutable <see cref="NotificationRequest"/>.
|
||||
/// Throws <see cref="InvalidOperationException"/> if <see cref="WithTitle"/> has not been set.
|
||||
/// </summary>
|
||||
public NotificationRequest Build()
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(_title))
|
||||
throw new InvalidOperationException("Notification title must be set before calling Build().");
|
||||
|
||||
// If an explicit INotificationHandler was provided, use it directly.
|
||||
// Otherwise, if any delegate callbacks were registered, wrap them.
|
||||
INotificationHandler? handler = _handler;
|
||||
if (handler == null && (_onActivated != null || _onButtonActivated != null || _onDismissed != null || _onFailed != null))
|
||||
{
|
||||
handler = new DelegateNotificationHandler(_onActivated, _onButtonActivated, _onDismissed, _onFailed);
|
||||
}
|
||||
|
||||
return new NotificationRequest(
|
||||
title: _title,
|
||||
body: _body,
|
||||
imagePath: _imagePath,
|
||||
buttons: _buttons.AsReadOnly(),
|
||||
handler: handler,
|
||||
expiration: _expiration,
|
||||
audio: _audio,
|
||||
urgency: _urgency);
|
||||
}
|
||||
|
||||
// Internal adapter that bridges the delegate callbacks to INotificationHandler.
|
||||
private sealed class DelegateNotificationHandler : INotificationHandler
|
||||
{
|
||||
private readonly Action<long>? _activated;
|
||||
private readonly Action<long, int>? _buttonActivated;
|
||||
private readonly Action<long, DismissReason>? _dismissed;
|
||||
private readonly Action<long>? _failed;
|
||||
|
||||
public DelegateNotificationHandler(
|
||||
Action<long>? activated,
|
||||
Action<long, int>? buttonActivated,
|
||||
Action<long, DismissReason>? dismissed,
|
||||
Action<long>? failed)
|
||||
{
|
||||
_activated = activated;
|
||||
_buttonActivated = buttonActivated;
|
||||
_dismissed = dismissed;
|
||||
_failed = failed;
|
||||
}
|
||||
|
||||
public void OnActivated(long id) => _activated?.Invoke(id);
|
||||
public void OnButtonActivated(long id, int idx) => _buttonActivated?.Invoke(id, idx);
|
||||
public void OnDismissed(long id, DismissReason reason) => _dismissed?.Invoke(id, reason);
|
||||
public void OnFailed(long id) => _failed?.Invoke(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/Notify.NET/Builder/NotificationButton.cs
Normal file
36
src/Notify.NET/Builder/NotificationButton.cs
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
using System;
|
||||
|
||||
namespace Notify.NET.Builder
|
||||
{
|
||||
/// <summary>
|
||||
/// An action button displayed inside a notification.
|
||||
/// </summary>
|
||||
public sealed class NotificationButton
|
||||
{
|
||||
/// <summary>The label shown on the button.</summary>
|
||||
public string Label { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Callback invoked when the user clicks this button.
|
||||
/// The argument is the platform notification ID.
|
||||
/// The callback is invoked on a background thread; marshal to the UI thread if required.
|
||||
/// </summary>
|
||||
public Action<long>? Callback { get; }
|
||||
|
||||
/// <summary>
|
||||
/// An optional machine-readable identifier for this action (used internally by libnotify).
|
||||
/// Defaults to a sanitised version of <see cref="Label"/> when not specified.
|
||||
/// </summary>
|
||||
public string ActionId { get; }
|
||||
|
||||
public NotificationButton(string label, Action<long>? callback = null, string? actionId = null)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(label))
|
||||
throw new ArgumentException("Button label must not be empty.", nameof(label));
|
||||
|
||||
Label = label;
|
||||
Callback = callback;
|
||||
ActionId = actionId ?? label.ToLowerInvariant().Replace(' ', '-');
|
||||
}
|
||||
}
|
||||
}
|
||||
22
src/Notify.NET/Exceptions/NotificationException.cs
Normal file
22
src/Notify.NET/Exceptions/NotificationException.cs
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
using System;
|
||||
|
||||
namespace Notify.NET.Exceptions
|
||||
{
|
||||
/// <summary>Thrown when the native notification subsystem returns an error.</summary>
|
||||
public sealed class NotificationException : Exception
|
||||
{
|
||||
/// <summary>Platform-specific error code, if available.</summary>
|
||||
public int? NativeErrorCode { get; }
|
||||
|
||||
public NotificationException(string message) : base(message) { }
|
||||
|
||||
public NotificationException(string message, int nativeErrorCode)
|
||||
: base(message)
|
||||
{
|
||||
NativeErrorCode = nativeErrorCode;
|
||||
}
|
||||
|
||||
public NotificationException(string message, Exception innerException)
|
||||
: base(message, innerException) { }
|
||||
}
|
||||
}
|
||||
20
src/Notify.NET/Exceptions/PlatformNotSupportedException.cs
Normal file
20
src/Notify.NET/Exceptions/PlatformNotSupportedException.cs
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
using System;
|
||||
|
||||
namespace Notify.NET.Exceptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Thrown when <see cref="Abstractions.INotificationService.ShowAsync"/> is called on a platform
|
||||
/// where the native notification subsystem is unavailable or could not be initialised.
|
||||
/// Check <see cref="Abstractions.INotificationService.IsSupported"/> before calling Show.
|
||||
/// </summary>
|
||||
public sealed class PlatformNotSupportedException : Exception
|
||||
{
|
||||
public PlatformNotSupportedException()
|
||||
: base("Native notifications are not supported or could not be initialised on this platform.") { }
|
||||
|
||||
public PlatformNotSupportedException(string message) : base(message) { }
|
||||
|
||||
public PlatformNotSupportedException(string message, Exception innerException)
|
||||
: base(message, innerException) { }
|
||||
}
|
||||
}
|
||||
117
src/Notify.NET/Extensions/ServiceCollectionExtensions.cs
Normal file
117
src/Notify.NET/Extensions/ServiceCollectionExtensions.cs
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Notify.NET.Abstractions;
|
||||
using Notify.NET.Platform.Windows;
|
||||
using Notify.NET.Platform.Linux;
|
||||
using Notify.NET.Platform.MacOS;
|
||||
|
||||
namespace Notify.NET.Extensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Extension methods for registering <see cref="INotificationService"/> with an
|
||||
/// <see cref="IServiceCollection"/>. The correct platform implementation is selected
|
||||
/// automatically at runtime.
|
||||
/// </summary>
|
||||
public static class ServiceCollectionExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Registers <see cref="INotificationService"/> as a singleton, using the
|
||||
/// platform-appropriate backend:
|
||||
/// <list type="bullet">
|
||||
/// <item><description>Windows → <see cref="WindowsNotificationService"/> (WinToastLib)</description></item>
|
||||
/// <item><description>Linux → <see cref="LinuxNotificationService"/> (libnotify)</description></item>
|
||||
/// <item><description>macOS → <see cref="MacOSNotificationService"/> (UNUserNotificationCenter)</description></item>
|
||||
/// <item><description>Other → <see cref="NullNotificationService"/> (<see cref="INotificationService.IsSupported"/> = false)</description></item>
|
||||
/// </list>
|
||||
/// </summary>
|
||||
/// <param name="services">The service collection to add to.</param>
|
||||
/// <param name="configure">Optional delegate to configure <see cref="NotificationOptions"/>.</param>
|
||||
public static IServiceCollection AddNotifications(
|
||||
this IServiceCollection services,
|
||||
Action<NotificationOptions>? configure = null)
|
||||
{
|
||||
var options = new NotificationOptions();
|
||||
configure?.Invoke(options);
|
||||
|
||||
services.AddSingleton(options);
|
||||
|
||||
services.AddSingleton<INotificationService>(sp =>
|
||||
{
|
||||
var opts = sp.GetRequiredService<NotificationOptions>();
|
||||
return CreateService(opts);
|
||||
});
|
||||
|
||||
return services;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates the platform-appropriate <see cref="INotificationService"/> directly
|
||||
/// (without a DI container), for use in simple console applications.
|
||||
/// </summary>
|
||||
public static INotificationService CreateNotificationService(
|
||||
Action<NotificationOptions>? configure = null)
|
||||
{
|
||||
var opts = new NotificationOptions();
|
||||
configure?.Invoke(opts);
|
||||
return CreateService(opts);
|
||||
}
|
||||
|
||||
private static INotificationService CreateService(NotificationOptions opts)
|
||||
{
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
return new WindowsNotificationService(opts.AppName, opts.AppUserModelId);
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux))
|
||||
return new LinuxNotificationService(opts.AppName);
|
||||
|
||||
if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX))
|
||||
return new MacOSNotificationService(opts.AppName);
|
||||
|
||||
return new NullNotificationService();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configuration options for the notification service.
|
||||
/// Pass to <see cref="ServiceCollectionExtensions.AddNotifications"/> via the configure delegate.
|
||||
/// </summary>
|
||||
public sealed class NotificationOptions
|
||||
{
|
||||
/// <summary>
|
||||
/// Human-readable application name shown in the notification and Action Centre.
|
||||
/// Defaults to the process name.
|
||||
/// </summary>
|
||||
public string AppName { get; set; } =
|
||||
System.Diagnostics.Process.GetCurrentProcess().ProcessName;
|
||||
|
||||
/// <summary>
|
||||
/// Windows AppUserModelId (AUMI), e.g. <c>"MyCompany.MyApp"</c>.
|
||||
/// Required for notifications to persist in the Windows Action Centre.
|
||||
/// The native wrapper creates a Start-Menu shortcut with this AUMI automatically.
|
||||
/// Ignored on non-Windows platforms.
|
||||
/// </summary>
|
||||
public string AppUserModelId { get; set; } =
|
||||
System.Diagnostics.Process.GetCurrentProcess().ProcessName;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// No-op implementation used when the current platform has no supported notification backend.
|
||||
/// <see cref="IsSupported"/> is always false; calling <see cref="ShowAsync"/> throws
|
||||
/// <see cref="Exceptions.PlatformNotSupportedException"/>.
|
||||
/// </summary>
|
||||
internal sealed class NullNotificationService : INotificationService
|
||||
{
|
||||
public bool IsSupported => false;
|
||||
|
||||
public Task<long> ShowAsync(NotificationRequest request, CancellationToken cancellationToken = default)
|
||||
=> throw new Exceptions.PlatformNotSupportedException();
|
||||
|
||||
public Task HideAsync(long notificationId, CancellationToken cancellationToken = default)
|
||||
=> throw new Exceptions.PlatformNotSupportedException();
|
||||
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
49
src/Notify.NET/Notify.NET.csproj
Normal file
49
src/Notify.NET/Notify.NET.csproj
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>netstandard2.1</TargetFramework>
|
||||
<LangVersion>9.0</LangVersion>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>Notify.NET</AssemblyName>
|
||||
<RootNamespace>Notify.NET</RootNamespace>
|
||||
<PackageId>Notify.NET</PackageId>
|
||||
<Version>1.0.0</Version>
|
||||
<Description>Cross-platform OS notification library for .NET Standard with WinToast (Windows), libnotify (Linux), and UNUserNotificationCenter (macOS) backends.</Description>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="2.1.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Options" Version="2.1.0" />
|
||||
<PackageReference Include="System.Collections.Concurrent" Version="4.3.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Bundle pre-compiled native libraries for Windows -->
|
||||
<ItemGroup>
|
||||
<Content Include="runtimes\win-x64\native\WinToastWrapper.dll" Condition="Exists('runtimes\win-x64\native\WinToastWrapper.dll')">
|
||||
<PackagePath>runtimes/win-x64/native/WinToastWrapper.dll</PackagePath>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="runtimes\win-x86\native\WinToastWrapper.dll" Condition="Exists('runtimes\win-x86\native\WinToastWrapper.dll')">
|
||||
<PackagePath>runtimes/win-x86/native/WinToastWrapper.dll</PackagePath>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="runtimes\win-arm64\native\WinToastWrapper.dll" Condition="Exists('runtimes\win-arm64\native\WinToastWrapper.dll')">
|
||||
<PackagePath>runtimes/win-arm64/native/WinToastWrapper.dll</PackagePath>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Bundle pre-compiled native dylibs for macOS -->
|
||||
<ItemGroup>
|
||||
<Content Include="runtimes/osx-x64/native/libMacNotifyWrapper.dylib" Condition="Exists('runtimes/osx-x64/native/libMacNotifyWrapper.dylib')">
|
||||
<PackagePath>runtimes/osx-x64/native/libMacNotifyWrapper.dylib</PackagePath>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
<Content Include="runtimes/osx-arm64/native/libMacNotifyWrapper.dylib" Condition="Exists('runtimes/osx-arm64/native/libMacNotifyWrapper.dylib')">
|
||||
<PackagePath>runtimes/osx-arm64/native/libMacNotifyWrapper.dylib</PackagePath>
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
</Content>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
125
src/Notify.NET/Platform/Linux/GLibMainLoopRunner.cs
Normal file
125
src/Notify.NET/Platform/Linux/GLibMainLoopRunner.cs
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
|
||||
namespace Notify.NET.Platform.Linux
|
||||
{
|
||||
/// <summary>
|
||||
/// Owns and manages a GLib GMainLoop on a dedicated background thread.
|
||||
///
|
||||
/// libnotify delivers notification signals (action-invoked, closed) through the GLib
|
||||
/// event system and requires a running GMainLoop to dispatch them. Console applications
|
||||
/// and ASP.NET hosts do not have one by default, so this class creates and owns one.
|
||||
///
|
||||
/// All calls to libnotify that create/show/close notifications MUST be executed on the
|
||||
/// GMainLoop thread to ensure proper GObject signal wiring. Use <see cref="InvokeAsync"/>
|
||||
/// to marshal work onto that thread.
|
||||
/// </summary>
|
||||
internal sealed class GLibMainLoopRunner : IDisposable
|
||||
{
|
||||
private readonly Thread _loopThread;
|
||||
private readonly ManualResetEventSlim _started = new ManualResetEventSlim(false);
|
||||
|
||||
// Pinned GSourceFunc delegate — static, never collected.
|
||||
private static readonly LibNotifyNative.GSourceFunc _dispatchSourceFunc = DispatchSourceFuncStatic;
|
||||
private static readonly IntPtr _dispatchFuncPtr =
|
||||
Marshal.GetFunctionPointerForDelegate(_dispatchSourceFunc);
|
||||
|
||||
// Work items posted from external threads via g_main_context_invoke.
|
||||
// We use a ConcurrentQueue keyed by a GCHandle to the WorkItem so we can pass a
|
||||
// single IntPtr through the GLib userData parameter.
|
||||
private IntPtr _mainLoop;
|
||||
private bool _disposed;
|
||||
|
||||
internal GLibMainLoopRunner()
|
||||
{
|
||||
_loopThread = new Thread(LoopThreadProc)
|
||||
{
|
||||
Name = "Notify.NET GMainLoop",
|
||||
IsBackground = true
|
||||
};
|
||||
_loopThread.Start();
|
||||
_started.Wait();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Public API
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Posts <paramref name="action"/> to be executed on the GMainLoop thread and
|
||||
/// returns a task that completes when the action finishes.
|
||||
/// </summary>
|
||||
internal System.Threading.Tasks.Task InvokeAsync(Action action)
|
||||
{
|
||||
var tcs = new System.Threading.Tasks.TaskCompletionSource<bool>();
|
||||
var item = new WorkItem(action, tcs);
|
||||
|
||||
// Allocate a GCHandle to keep the WorkItem alive from unmanaged code.
|
||||
GCHandle handle = GCHandle.Alloc(item, GCHandleType.Normal);
|
||||
|
||||
// g_main_context_invoke(null) posts to the default context, which is owned
|
||||
// by our GMainLoop thread.
|
||||
LibNotifyNative.g_main_context_invoke(IntPtr.Zero, _dispatchFuncPtr, GCHandle.ToIntPtr(handle));
|
||||
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// IDisposable
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
if (_mainLoop != IntPtr.Zero)
|
||||
{
|
||||
LibNotifyNative.g_main_loop_quit(_mainLoop);
|
||||
if (_loopThread.IsAlive)
|
||||
_loopThread.Join(TimeSpan.FromSeconds(5));
|
||||
|
||||
LibNotifyNative.g_main_loop_unref(_mainLoop);
|
||||
_mainLoop = IntPtr.Zero;
|
||||
}
|
||||
|
||||
_started.Dispose();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Private
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private void LoopThreadProc()
|
||||
{
|
||||
_mainLoop = LibNotifyNative.g_main_loop_new(IntPtr.Zero, false);
|
||||
_started.Set();
|
||||
LibNotifyNative.g_main_loop_run(_mainLoop);
|
||||
// g_main_loop_unref is called in Dispose, not here, to avoid double-unref.
|
||||
}
|
||||
|
||||
// Static GSourceFunc — invoked on the GMainLoop thread by GLib.
|
||||
// Returns false so GLib removes the source after one invocation.
|
||||
private static bool DispatchSourceFuncStatic(IntPtr userData)
|
||||
{
|
||||
GCHandle handle = GCHandle.FromIntPtr(userData);
|
||||
var item = (WorkItem)handle.Target!;
|
||||
handle.Free();
|
||||
|
||||
try { item.Action(); item.Tcs.TrySetResult(true); }
|
||||
catch (Exception ex) { item.Tcs.TrySetException(ex); }
|
||||
|
||||
return false; // one-shot
|
||||
}
|
||||
|
||||
private sealed class WorkItem
|
||||
{
|
||||
internal readonly Action Action;
|
||||
internal readonly System.Threading.Tasks.TaskCompletionSource<bool> Tcs;
|
||||
internal WorkItem(Action action, System.Threading.Tasks.TaskCompletionSource<bool> tcs)
|
||||
{ Action = action; Tcs = tcs; }
|
||||
}
|
||||
}
|
||||
}
|
||||
152
src/Notify.NET/Platform/Linux/LibNotifyCallbackBridge.cs
Normal file
152
src/Notify.NET/Platform/Linux/LibNotifyCallbackBridge.cs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.InteropServices;
|
||||
using Notify.NET.Abstractions;
|
||||
|
||||
namespace Notify.NET.Platform.Linux
|
||||
{
|
||||
/// <summary>
|
||||
/// Bridges the unmanaged libnotify GObject signal callbacks back to the managed
|
||||
/// <see cref="INotificationHandler"/> for each in-flight notification.
|
||||
///
|
||||
/// Threading: GLib delivers both the action and closed signals on the GMainLoop thread.
|
||||
/// Handler invocations therefore happen on the GMainLoop background thread;
|
||||
/// consumers are responsible for marshalling to a UI thread if required.
|
||||
///
|
||||
/// Lifetime rules (mirror the Windows bridge):
|
||||
/// 1. Static delegates are pinned permanently — their function pointers are valid forever.
|
||||
/// 2. Per-notification bridges are held in a static ConcurrentDictionary keyed by the
|
||||
/// native NotifyNotification* pointer (cast to long).
|
||||
/// 3. The bridge's GCHandle prevents GC collection until the "closed" signal fires.
|
||||
/// 4. Release() is called once from the closed callback; it frees the GCHandle,
|
||||
/// unrefs the GObject, and removes the dictionary entry.
|
||||
/// </summary>
|
||||
internal sealed class LibNotifyCallbackBridge
|
||||
{
|
||||
// ------------------------------------------------------------------
|
||||
// Static callbacks — one instance shared across all notifications
|
||||
// ------------------------------------------------------------------
|
||||
private static readonly LibNotifyNative.NotifyActionCallback _staticAction;
|
||||
private static readonly LibNotifyNative.NotifyClosedCallback _staticClosed;
|
||||
|
||||
internal static readonly IntPtr PtrAction;
|
||||
internal static readonly IntPtr PtrClosed;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Live bridge registry
|
||||
// ------------------------------------------------------------------
|
||||
private static readonly ConcurrentDictionary<long, LibNotifyCallbackBridge> _live
|
||||
= new ConcurrentDictionary<long, LibNotifyCallbackBridge>();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Per-instance state
|
||||
// ------------------------------------------------------------------
|
||||
private readonly INotificationHandler? _handler;
|
||||
private readonly System.Collections.Generic.IReadOnlyList<Builder.NotificationButton> _buttons;
|
||||
private GCHandle _gcHandle;
|
||||
|
||||
static LibNotifyCallbackBridge()
|
||||
{
|
||||
_staticAction = OnActionStatic;
|
||||
_staticClosed = OnClosedStatic;
|
||||
|
||||
PtrAction = Marshal.GetFunctionPointerForDelegate(_staticAction);
|
||||
PtrClosed = Marshal.GetFunctionPointerForDelegate(_staticClosed);
|
||||
}
|
||||
|
||||
private LibNotifyCallbackBridge(
|
||||
INotificationHandler? handler,
|
||||
System.Collections.Generic.IReadOnlyList<Builder.NotificationButton> buttons)
|
||||
{
|
||||
_handler = handler;
|
||||
_buttons = buttons;
|
||||
_gcHandle = GCHandle.Alloc(this, GCHandleType.Normal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a bridge for the native notification pointer.
|
||||
/// Must be called on the GMainLoop thread BEFORE connecting GObject signals,
|
||||
/// so the dictionary entry exists before any signal can fire.
|
||||
/// </summary>
|
||||
internal static LibNotifyCallbackBridge Register(
|
||||
IntPtr notificationPtr,
|
||||
INotificationHandler? handler,
|
||||
System.Collections.Generic.IReadOnlyList<Builder.NotificationButton> buttons)
|
||||
{
|
||||
var bridge = new LibNotifyCallbackBridge(handler, buttons);
|
||||
_live[(long)notificationPtr] = bridge;
|
||||
return bridge;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the bridge and releases all resources.
|
||||
/// Called from the "closed" signal handler — do not call from application code.
|
||||
/// </summary>
|
||||
internal static void Release(IntPtr notificationPtr)
|
||||
{
|
||||
long key = (long)notificationPtr;
|
||||
if (_live.TryRemove(key, out var bridge))
|
||||
{
|
||||
if (bridge._gcHandle.IsAllocated)
|
||||
bridge._gcHandle.Free();
|
||||
|
||||
// Release the libnotify GObject reference.
|
||||
LibNotifyNative.g_object_unref(notificationPtr);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Static GLib signal handlers — called on the GMainLoop thread
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static void OnActionStatic(IntPtr notification, string action, IntPtr userData)
|
||||
{
|
||||
try
|
||||
{
|
||||
long key = (long)notification;
|
||||
if (!_live.TryGetValue(key, out var bridge)) return;
|
||||
|
||||
for (int i = 0; i < bridge._buttons.Count; i++)
|
||||
{
|
||||
if (string.Equals(bridge._buttons[i].ActionId, action, StringComparison.Ordinal))
|
||||
{
|
||||
bridge._buttons[i].Callback?.Invoke(key);
|
||||
bridge._handler?.OnButtonActivated(key, i);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
bridge._handler?.OnActivated(key);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private static void OnClosedStatic(IntPtr notification, IntPtr userData)
|
||||
{
|
||||
try
|
||||
{
|
||||
long key = (long)notification;
|
||||
if (_live.TryGetValue(key, out var bridge))
|
||||
{
|
||||
int reason = LibNotifyNative.notify_notification_get_closed_reason(notification);
|
||||
bridge._handler?.OnDismissed(key, MapCloseReason(reason));
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
finally { Release(notification); }
|
||||
}
|
||||
|
||||
private static DismissReason MapCloseReason(int reason)
|
||||
{
|
||||
// freedesktop.org notification spec close reasons:
|
||||
// 1 = expired, 2 = dismissed by user, 3 = closed by app, 4 = undefined
|
||||
switch (reason)
|
||||
{
|
||||
case 1: return DismissReason.TimedOut;
|
||||
case 2: return DismissReason.UserCancelled;
|
||||
case 3: return DismissReason.ApplicationHidden;
|
||||
default: return DismissReason.Unknown;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
172
src/Notify.NET/Platform/Linux/LibNotifyNative.cs
Normal file
172
src/Notify.NET/Platform/Linux/LibNotifyNative.cs
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Notify.NET.Platform.Linux
|
||||
{
|
||||
/// <summary>
|
||||
/// P/Invoke declarations for libnotify.so.4 and the GLib/GObject functions
|
||||
/// needed to manage signals and the main event loop.
|
||||
///
|
||||
/// All GLib string parameters use ANSI (UTF-8) encoding, which matches GLib's
|
||||
/// internal string convention on Linux.
|
||||
/// </summary>
|
||||
internal static class LibNotifyNative
|
||||
{
|
||||
private const string LibNotify = "libnotify.so.4";
|
||||
private const string LibGLib = "libglib-2.0.so.0";
|
||||
private const string LibGObj = "libgobject-2.0.so.0";
|
||||
private const string LibGdkPB = "libgdk_pixbuf-2.0.so.0";
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Unmanaged callback delegate types
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Callback fired when the user clicks an action button on the notification.</summary>
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl, CharSet = CharSet.Ansi)]
|
||||
internal delegate void NotifyActionCallback(IntPtr notification, string action, IntPtr userData);
|
||||
|
||||
/// <summary>Callback fired when the notification is closed (any reason).</summary>
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
internal delegate void NotifyClosedCallback(IntPtr notification, IntPtr userData);
|
||||
|
||||
/// <summary>Function posted to the GMainContext via g_main_context_invoke.</summary>
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
internal delegate bool GSourceFunc(IntPtr userData);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// libnotify
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Initialises libnotify. Must be called before any other notify_ function.</summary>
|
||||
[DllImport(LibNotify, EntryPoint = "notify_init", CharSet = CharSet.Ansi)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static extern bool notify_init(string appName);
|
||||
|
||||
/// <summary>Returns true if notify_init() has been called successfully.</summary>
|
||||
[DllImport(LibNotify, EntryPoint = "notify_is_initted")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static extern bool notify_is_initted();
|
||||
|
||||
/// <summary>Releases all libnotify resources.</summary>
|
||||
[DllImport(LibNotify, EntryPoint = "notify_uninit")]
|
||||
internal static extern void notify_uninit();
|
||||
|
||||
/// <summary>
|
||||
/// Creates a new notification object. The returned pointer is a GObject reference
|
||||
/// with a ref-count of 1. Callers must eventually call g_object_unref.
|
||||
/// </summary>
|
||||
[DllImport(LibNotify, EntryPoint = "notify_notification_new", CharSet = CharSet.Ansi)]
|
||||
internal static extern IntPtr notify_notification_new(string summary, string? body, string? icon);
|
||||
|
||||
/// <summary>Shows the notification. Returns false and sets <paramref name="error"/> on failure.</summary>
|
||||
[DllImport(LibNotify, EntryPoint = "notify_notification_show")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static extern bool notify_notification_show(IntPtr notification, ref IntPtr error);
|
||||
|
||||
/// <summary>Programmatically closes the notification.</summary>
|
||||
[DllImport(LibNotify, EntryPoint = "notify_notification_close")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static extern bool notify_notification_close(IntPtr notification, ref IntPtr error);
|
||||
|
||||
/// <summary>
|
||||
/// Adds an action button to the notification.
|
||||
/// <paramref name="callback"/> must be a pinned function pointer; see <see cref="LibNotifyCallbackBridge"/>.
|
||||
/// </summary>
|
||||
[DllImport(LibNotify, EntryPoint = "notify_notification_add_action", CharSet = CharSet.Ansi)]
|
||||
internal static extern void notify_notification_add_action(
|
||||
IntPtr notification,
|
||||
string action, // machine-readable action ID
|
||||
string label, // human-readable label
|
||||
IntPtr callback, // NotifyActionCallback function pointer
|
||||
IntPtr userData,
|
||||
IntPtr freeFunc); // GFreeFunc, pass IntPtr.Zero
|
||||
|
||||
/// <summary>Sets a display hint on the notification (e.g., urgency level).</summary>
|
||||
[DllImport(LibNotify, EntryPoint = "notify_notification_set_hint", CharSet = CharSet.Ansi)]
|
||||
internal static extern void notify_notification_set_hint(
|
||||
IntPtr notification, string key, IntPtr value /* GVariant* */);
|
||||
|
||||
/// <summary>Sets the notification's image from a GdkPixbuf.</summary>
|
||||
[DllImport(LibNotify, EntryPoint = "notify_notification_set_image_from_pixbuf")]
|
||||
internal static extern void notify_notification_set_image_from_pixbuf(
|
||||
IntPtr notification, IntPtr pixbuf /* GdkPixbuf* */);
|
||||
|
||||
/// <summary>Returns the reason the notification was closed (call after the "closed" signal).</summary>
|
||||
[DllImport(LibNotify, EntryPoint = "notify_notification_get_closed_reason")]
|
||||
internal static extern int notify_notification_get_closed_reason(IntPtr notification);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GLib / GObject
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Creates a new GMainLoop.</summary>
|
||||
[DllImport(LibGLib, EntryPoint = "g_main_loop_new")]
|
||||
internal static extern IntPtr g_main_loop_new(IntPtr context /* null = default */, bool isRunning);
|
||||
|
||||
/// <summary>Runs the GMainLoop, blocking until g_main_loop_quit is called.</summary>
|
||||
[DllImport(LibGLib, EntryPoint = "g_main_loop_run")]
|
||||
internal static extern void g_main_loop_run(IntPtr loop);
|
||||
|
||||
/// <summary>Signals the GMainLoop to stop its run() and return.</summary>
|
||||
[DllImport(LibGLib, EntryPoint = "g_main_loop_quit")]
|
||||
internal static extern void g_main_loop_quit(IntPtr loop);
|
||||
|
||||
/// <summary>Releases a GMainLoop reference.</summary>
|
||||
[DllImport(LibGLib, EntryPoint = "g_main_loop_unref")]
|
||||
internal static extern void g_main_loop_unref(IntPtr loop);
|
||||
|
||||
/// <summary>
|
||||
/// Posts a function to be called on the default GMainContext from any thread.
|
||||
/// The function is invoked on the GMainLoop thread.
|
||||
/// </summary>
|
||||
[DllImport(LibGLib, EntryPoint = "g_main_context_invoke")]
|
||||
internal static extern void g_main_context_invoke(IntPtr context, IntPtr func, IntPtr userData);
|
||||
|
||||
/// <summary>
|
||||
/// Connects a callback to a GObject signal.
|
||||
/// Returns the handler ID (used to disconnect later if needed).
|
||||
/// </summary>
|
||||
[DllImport(LibGObj, EntryPoint = "g_signal_connect_data", CharSet = CharSet.Ansi)]
|
||||
internal static extern ulong g_signal_connect_data(
|
||||
IntPtr instance,
|
||||
string detailedSignal,
|
||||
IntPtr cHandler,
|
||||
IntPtr data,
|
||||
IntPtr destroyData,
|
||||
int connectFlags);
|
||||
|
||||
/// <summary>Releases one reference on a GObject. The object is destroyed when the ref-count reaches 0.</summary>
|
||||
[DllImport(LibGObj, EntryPoint = "g_object_unref")]
|
||||
internal static extern void g_object_unref(IntPtr obj);
|
||||
|
||||
/// <summary>Frees a GError and sets the pointer to null.</summary>
|
||||
[DllImport(LibGLib, EntryPoint = "g_error_free")]
|
||||
internal static extern void g_error_free(IntPtr error);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GLib GVariant helpers (needed for urgency hints)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>Creates a GVariant holding a byte value (used for the urgency hint).</summary>
|
||||
[DllImport(LibGLib, EntryPoint = "g_variant_new_byte")]
|
||||
internal static extern IntPtr g_variant_new_byte(byte value);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// GdkPixbuf (optional — for loading images from disk paths)
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Loads an image from disk into a GdkPixbuf.
|
||||
/// Returns IntPtr.Zero on failure; callers should fall back gracefully.
|
||||
/// </summary>
|
||||
[DllImport(LibGdkPB, EntryPoint = "gdk_pixbuf_new_from_file", CharSet = CharSet.Ansi)]
|
||||
internal static extern IntPtr gdk_pixbuf_new_from_file(string filename, ref IntPtr error);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Urgency level constants (freedesktop.org spec)
|
||||
// -------------------------------------------------------------------------
|
||||
internal const byte URGENCY_LOW = 0;
|
||||
internal const byte URGENCY_NORMAL = 1;
|
||||
internal const byte URGENCY_CRITICAL = 2;
|
||||
}
|
||||
}
|
||||
274
src/Notify.NET/Platform/Linux/LinuxNotificationService.cs
Normal file
274
src/Notify.NET/Platform/Linux/LinuxNotificationService.cs
Normal file
|
|
@ -0,0 +1,274 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Notify.NET.Abstractions;
|
||||
using Notify.NET.Exceptions;
|
||||
|
||||
namespace Notify.NET.Platform.Linux
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="INotificationService"/> implementation backed by libnotify.
|
||||
///
|
||||
/// Threading model:
|
||||
/// All libnotify calls must be made from the GLib GMainLoop thread to ensure correct
|
||||
/// signal wiring. <see cref="GLibMainLoopRunner.InvokeAsync"/> marshals work onto
|
||||
/// that thread. Callbacks (action-invoked, closed) are delivered on the same thread.
|
||||
/// </summary>
|
||||
public sealed class LinuxNotificationService : INotificationService
|
||||
{
|
||||
private readonly string _appName;
|
||||
private readonly GLibMainLoopRunner _loopRunner;
|
||||
private volatile bool _disposed;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsSupported { get; private set; }
|
||||
|
||||
/// <param name="appName">Application name passed to notify_init().</param>
|
||||
public LinuxNotificationService(string appName)
|
||||
{
|
||||
_appName = appName ?? throw new ArgumentNullException(nameof(appName));
|
||||
_loopRunner = new GLibMainLoopRunner();
|
||||
|
||||
// Initialise libnotify on the GMainLoop thread.
|
||||
// Detect a missing libnotify gracefully so IsSupported is false rather than throwing.
|
||||
try
|
||||
{
|
||||
_loopRunner.InvokeAsync(() =>
|
||||
{
|
||||
if (!LibNotifyNative.notify_is_initted())
|
||||
{
|
||||
bool ok = LibNotifyNative.notify_init(_appName);
|
||||
IsSupported = ok;
|
||||
}
|
||||
else
|
||||
{
|
||||
IsSupported = true;
|
||||
}
|
||||
}).GetAwaiter().GetResult();
|
||||
}
|
||||
catch (DllNotFoundException)
|
||||
{
|
||||
IsSupported = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// INotificationService
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task<long> ShowAsync(NotificationRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request == null) throw new ArgumentNullException(nameof(request));
|
||||
ThrowIfDisposedOrUnsupported();
|
||||
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
long notificationId = 0;
|
||||
|
||||
await _loopRunner.InvokeAsync(() =>
|
||||
{
|
||||
notificationId = ShowOnLoopThread(request);
|
||||
}).ConfigureAwait(false);
|
||||
|
||||
return notificationId;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public async Task HideAsync(long notificationId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposedOrUnsupported();
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
await _loopRunner.InvokeAsync(() =>
|
||||
{
|
||||
var ptr = (IntPtr)notificationId;
|
||||
IntPtr error = IntPtr.Zero;
|
||||
bool ok = LibNotifyNative.notify_notification_close(ptr, ref error);
|
||||
|
||||
if (!ok)
|
||||
{
|
||||
string msg = MarshalGError(ref error);
|
||||
throw new NotificationException($"notify_notification_close failed: {msg}");
|
||||
}
|
||||
|
||||
// Release is normally triggered by the "closed" signal, but call it here
|
||||
// too in case the signal doesn't fire (some notification daemons omit it).
|
||||
LibNotifyCallbackBridge.Release(ptr);
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// IDisposable
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
try
|
||||
{
|
||||
_loopRunner.InvokeAsync(() =>
|
||||
{
|
||||
if (LibNotifyNative.notify_is_initted())
|
||||
LibNotifyNative.notify_uninit();
|
||||
}).GetAwaiter().GetResult();
|
||||
}
|
||||
catch { /* best-effort cleanup */ }
|
||||
|
||||
_loopRunner.Dispose();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private void ThrowIfDisposedOrUnsupported()
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(LinuxNotificationService));
|
||||
if (!IsSupported) throw new Exceptions.PlatformNotSupportedException();
|
||||
}
|
||||
|
||||
/// <summary>Called on the GMainLoop thread to create and show a notification.</summary>
|
||||
private long ShowOnLoopThread(NotificationRequest request)
|
||||
{
|
||||
IntPtr notification = LibNotifyNative.notify_notification_new(
|
||||
request.Title,
|
||||
request.Body,
|
||||
null /* icon — we set it from imagePath below if provided */);
|
||||
|
||||
if (notification == IntPtr.Zero)
|
||||
throw new NotificationException("notify_notification_new returned null.");
|
||||
|
||||
// --- Image ---
|
||||
string? resolvedImage = ResolveImagePath(request.ImagePath);
|
||||
if (resolvedImage != null)
|
||||
ApplyImage(notification, resolvedImage);
|
||||
|
||||
// --- Urgency hint ---
|
||||
byte urgency = MapUrgency(request.Urgency);
|
||||
IntPtr urgencyVariant = LibNotifyNative.g_variant_new_byte(urgency);
|
||||
LibNotifyNative.notify_notification_set_hint(notification, "urgency", urgencyVariant);
|
||||
|
||||
// --- Expiration ---
|
||||
if (request.Expiration.HasValue)
|
||||
{
|
||||
int ms = (int)request.Expiration.Value.TotalMilliseconds;
|
||||
// notify_notification_set_timeout is available in newer libnotify versions;
|
||||
// set as a hint for compatibility with older versions too.
|
||||
LibNotifyNative.notify_notification_set_hint(
|
||||
notification, "x-canonical-snap-decisions-timeout",
|
||||
LibNotifyNative.g_variant_new_byte((byte)Math.Clamp(ms / 1000, 0, 255)));
|
||||
}
|
||||
|
||||
// --- Register bridge BEFORE connecting signals ---
|
||||
LibNotifyCallbackBridge.Register(notification, request.Handler, request.Buttons);
|
||||
|
||||
// --- Action buttons ---
|
||||
for (int i = 0; i < request.Buttons.Count; i++)
|
||||
{
|
||||
var btn = request.Buttons[i];
|
||||
LibNotifyNative.notify_notification_add_action(
|
||||
notification,
|
||||
btn.ActionId,
|
||||
btn.Label,
|
||||
LibNotifyCallbackBridge.PtrAction,
|
||||
notification, // userData = the notification pointer (our lookup key)
|
||||
IntPtr.Zero);
|
||||
}
|
||||
|
||||
// --- "closed" signal ---
|
||||
LibNotifyNative.g_signal_connect_data(
|
||||
notification,
|
||||
"closed",
|
||||
LibNotifyCallbackBridge.PtrClosed,
|
||||
notification, // data = lookup key
|
||||
IntPtr.Zero,
|
||||
0);
|
||||
|
||||
// --- Show ---
|
||||
IntPtr error = IntPtr.Zero;
|
||||
bool ok = LibNotifyNative.notify_notification_show(notification, ref error);
|
||||
if (!ok)
|
||||
{
|
||||
string msg = MarshalGError(ref error);
|
||||
LibNotifyCallbackBridge.Release(notification);
|
||||
throw new NotificationException($"notify_notification_show failed: {msg}");
|
||||
}
|
||||
|
||||
return (long)notification;
|
||||
}
|
||||
|
||||
private static string? ResolveImagePath(string? path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) return null;
|
||||
try
|
||||
{
|
||||
string absolute = Path.IsPathRooted(path) ? path : Path.GetFullPath(path);
|
||||
return File.Exists(absolute) ? absolute : null;
|
||||
}
|
||||
catch (Exception) { return null; }
|
||||
}
|
||||
|
||||
private static void ApplyImage(IntPtr notification, string imagePath)
|
||||
{
|
||||
IntPtr pixbufError = IntPtr.Zero;
|
||||
IntPtr pixbuf = IntPtr.Zero;
|
||||
|
||||
try
|
||||
{
|
||||
pixbuf = LibNotifyNative.gdk_pixbuf_new_from_file(imagePath, ref pixbufError);
|
||||
if (pixbuf != IntPtr.Zero)
|
||||
{
|
||||
LibNotifyNative.notify_notification_set_image_from_pixbuf(notification, pixbuf);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Image loading failed — clear the error and continue without an image.
|
||||
if (pixbufError != IntPtr.Zero)
|
||||
{
|
||||
LibNotifyNative.g_error_free(pixbufError);
|
||||
pixbufError = IntPtr.Zero;
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Unref the pixbuf — the notification holds its own reference after set_image.
|
||||
if (pixbuf != IntPtr.Zero)
|
||||
LibNotifyNative.g_object_unref(pixbuf);
|
||||
if (pixbufError != IntPtr.Zero)
|
||||
LibNotifyNative.g_error_free(pixbufError);
|
||||
}
|
||||
}
|
||||
|
||||
private static string MarshalGError(ref IntPtr error)
|
||||
{
|
||||
if (error == IntPtr.Zero) return "(unknown error)";
|
||||
|
||||
// GError layout: domain (uint32) | code (int32) | message (char*)
|
||||
// On 64-bit Linux: domain at 0, code at 4, message pointer at 8.
|
||||
IntPtr messagePtr = Marshal.ReadIntPtr(error, 8);
|
||||
string message = Marshal.PtrToStringAnsi(messagePtr) ?? "(null)";
|
||||
|
||||
LibNotifyNative.g_error_free(error);
|
||||
error = IntPtr.Zero;
|
||||
return message;
|
||||
}
|
||||
|
||||
private static byte MapUrgency(NotificationUrgency urgency)
|
||||
{
|
||||
switch (urgency)
|
||||
{
|
||||
case NotificationUrgency.Low: return LibNotifyNative.URGENCY_LOW;
|
||||
case NotificationUrgency.Critical:
|
||||
case NotificationUrgency.Alarm: return LibNotifyNative.URGENCY_CRITICAL;
|
||||
default: return LibNotifyNative.URGENCY_NORMAL;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
153
src/Notify.NET/Platform/MacOS/MacNotifyCallbackBridge.cs
Normal file
153
src/Notify.NET/Platform/MacOS/MacNotifyCallbackBridge.cs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.InteropServices;
|
||||
using Notify.NET.Abstractions;
|
||||
|
||||
namespace Notify.NET.Platform.MacOS
|
||||
{
|
||||
/// <summary>
|
||||
/// Bridges unmanaged callbacks from <c>libMacNotifyWrapper.dylib</c> back to the
|
||||
/// managed <see cref="INotificationHandler"/> for each in-flight notification.
|
||||
///
|
||||
/// Design rules — identical to the Windows and Linux bridges:
|
||||
///
|
||||
/// 1. The four static delegates are stored in <c>static readonly</c> fields and
|
||||
/// their function pointers obtained once; they are permanently valid.
|
||||
///
|
||||
/// 2. Per-notification state is held in <see cref="MacNotifyCallbackBridge"/> instances
|
||||
/// tracked in <see cref="_live"/>. A <see cref="GCHandle"/> prevents GC collection.
|
||||
///
|
||||
/// 3. <see cref="Release"/> is called from <em>every</em> terminal callback.
|
||||
/// On macOS, body-tap, button-tap, dismiss and failure are all terminal:
|
||||
/// <c>UNUserNotificationCenter</c> fires exactly one response per notification
|
||||
/// and does NOT separately fire a dismiss event after an action response.
|
||||
/// </summary>
|
||||
internal sealed class MacNotifyCallbackBridge
|
||||
{
|
||||
// ------------------------------------------------------------------
|
||||
// Static delegates — one set for the entire process lifetime
|
||||
// ------------------------------------------------------------------
|
||||
private static readonly MacNotifyNative.ActivatedCallback _staticActivated;
|
||||
private static readonly MacNotifyNative.ButtonActivatedCallback _staticButtonActivated;
|
||||
private static readonly MacNotifyNative.DismissedCallback _staticDismissed;
|
||||
private static readonly MacNotifyNative.FailedCallback _staticFailed;
|
||||
|
||||
internal static readonly IntPtr PtrActivated;
|
||||
internal static readonly IntPtr PtrButtonActivated;
|
||||
internal static readonly IntPtr PtrDismissed;
|
||||
internal static readonly IntPtr PtrFailed;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Live bridge registry: notifId → bridge
|
||||
// ------------------------------------------------------------------
|
||||
private static readonly ConcurrentDictionary<long, MacNotifyCallbackBridge> _live
|
||||
= new ConcurrentDictionary<long, MacNotifyCallbackBridge>();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Per-instance state
|
||||
// ------------------------------------------------------------------
|
||||
private readonly INotificationHandler? _handler;
|
||||
private GCHandle _gcHandle;
|
||||
|
||||
static MacNotifyCallbackBridge()
|
||||
{
|
||||
_staticActivated = OnActivatedStatic;
|
||||
_staticButtonActivated = OnButtonActivatedStatic;
|
||||
_staticDismissed = OnDismissedStatic;
|
||||
_staticFailed = OnFailedStatic;
|
||||
|
||||
PtrActivated = Marshal.GetFunctionPointerForDelegate(_staticActivated);
|
||||
PtrButtonActivated = Marshal.GetFunctionPointerForDelegate(_staticButtonActivated);
|
||||
PtrDismissed = Marshal.GetFunctionPointerForDelegate(_staticDismissed);
|
||||
PtrFailed = Marshal.GetFunctionPointerForDelegate(_staticFailed);
|
||||
}
|
||||
|
||||
private MacNotifyCallbackBridge(INotificationHandler? handler)
|
||||
{
|
||||
_handler = handler;
|
||||
_gcHandle = GCHandle.Alloc(this, GCHandleType.Normal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Registers a bridge for <paramref name="notifId"/>.
|
||||
/// Call immediately after <see cref="MacNotifyNative.MNW_ShowNotification"/> returns
|
||||
/// a positive ID.
|
||||
/// </summary>
|
||||
internal static MacNotifyCallbackBridge Register(long notifId, INotificationHandler? handler)
|
||||
{
|
||||
var bridge = new MacNotifyCallbackBridge(handler);
|
||||
_live[notifId] = bridge;
|
||||
return bridge;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the bridge and frees its <see cref="GCHandle"/>.
|
||||
/// Safe to call multiple times; subsequent calls are no-ops.
|
||||
/// </summary>
|
||||
internal static void Release(long notifId)
|
||||
{
|
||||
if (_live.TryRemove(notifId, out var bridge) && bridge._gcHandle.IsAllocated)
|
||||
bridge._gcHandle.Free();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Static routing callbacks — invoked on a background GCD thread
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static void OnActivatedStatic(long notifId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_live.TryGetValue(notifId, out var bridge))
|
||||
bridge._handler?.OnActivated(notifId);
|
||||
}
|
||||
catch { /* must not propagate into native code */ }
|
||||
finally { Release(notifId); }
|
||||
}
|
||||
|
||||
private static void OnButtonActivatedStatic(long notifId, int buttonIndex)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_live.TryGetValue(notifId, out var bridge))
|
||||
bridge._handler?.OnButtonActivated(notifId, buttonIndex);
|
||||
}
|
||||
catch { }
|
||||
finally { Release(notifId); }
|
||||
}
|
||||
|
||||
private static void OnDismissedStatic(long notifId, int reason)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_live.TryGetValue(notifId, out var bridge))
|
||||
bridge._handler?.OnDismissed(notifId, MapDismissReason(reason));
|
||||
}
|
||||
catch { }
|
||||
finally { Release(notifId); }
|
||||
}
|
||||
|
||||
private static void OnFailedStatic(long notifId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_live.TryGetValue(notifId, out var bridge))
|
||||
bridge._handler?.OnFailed(notifId);
|
||||
}
|
||||
catch { }
|
||||
finally { Release(notifId); }
|
||||
}
|
||||
|
||||
private static DismissReason MapDismissReason(int reason)
|
||||
{
|
||||
// MNW_DISMISS_EXPIRED = 0, MNW_DISMISS_USER = 1, MNW_DISMISS_APP_REMOVED = 2
|
||||
switch (reason)
|
||||
{
|
||||
case 0: return DismissReason.TimedOut;
|
||||
case 1: return DismissReason.UserCancelled;
|
||||
case 2: return DismissReason.ApplicationHidden;
|
||||
default: return DismissReason.Unknown;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
91
src/Notify.NET/Platform/MacOS/MacNotifyNative.cs
Normal file
91
src/Notify.NET/Platform/MacOS/MacNotifyNative.cs
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Notify.NET.Platform.MacOS
|
||||
{
|
||||
/// <summary>
|
||||
/// P/Invoke declarations for <c>libMacNotifyWrapper.dylib</c>.
|
||||
///
|
||||
/// All strings in structs are marshalled as UTF-8 via <see cref="IntPtr"/> and
|
||||
/// <see cref="Marshal.StringToHGlobalAnsi"/>. On macOS the ANSI code page is UTF-8,
|
||||
/// so this is a faithful UTF-8 round-trip.
|
||||
///
|
||||
/// Every exported function uses the C calling convention (cdecl), which is the
|
||||
/// platform default for all architectures on macOS.
|
||||
/// </summary>
|
||||
internal static class MacNotifyNative
|
||||
{
|
||||
internal const string LibName = "MacNotifyWrapper";
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Unmanaged callback delegate types
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
internal delegate void ActivatedCallback(long notifId);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
internal delegate void ButtonActivatedCallback(long notifId, int buttonIndex);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
internal delegate void DismissedCallback(long notifId, int reason);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
internal delegate void FailedCallback(long notifId);
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// MNW_Handler — bundle of four function pointers
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct MNW_Handler
|
||||
{
|
||||
public IntPtr onActivated;
|
||||
public IntPtr onButtonActivated;
|
||||
public IntPtr onDismissed;
|
||||
public IntPtr onFailed;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// MNW_NotificationDescriptor — all string fields are raw pointers
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct MNW_NotificationDescriptor
|
||||
{
|
||||
public IntPtr title; // const char* UTF-8, required
|
||||
public IntPtr body; // const char* UTF-8, may be Zero
|
||||
public IntPtr imagePath; // const char* UTF-8, may be Zero
|
||||
public IntPtr buttonLabels; // const char** array, may be Zero
|
||||
public int buttonCount;
|
||||
public long expirationMs; // reserved — not used by UNUserNotificationCenter
|
||||
public int audioOption;
|
||||
public int interruptionLevel;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Exported functions
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
[DllImport(LibName, CallingConvention = CallingConvention.Cdecl)]
|
||||
[return: MarshalAs(UnmanagedType.I1)]
|
||||
internal static extern bool MNW_IsSupported();
|
||||
|
||||
[DllImport(LibName, CallingConvention = CallingConvention.Cdecl)]
|
||||
[return: MarshalAs(UnmanagedType.I1)]
|
||||
internal static extern bool MNW_Initialize(
|
||||
[MarshalAs(UnmanagedType.LPStr)] string appName);
|
||||
|
||||
[DllImport(LibName, CallingConvention = CallingConvention.Cdecl)]
|
||||
internal static extern void MNW_Uninitialize();
|
||||
|
||||
[DllImport(LibName, CallingConvention = CallingConvention.Cdecl)]
|
||||
internal static extern long MNW_ShowNotification(
|
||||
ref MNW_NotificationDescriptor descriptor,
|
||||
ref MNW_Handler handler);
|
||||
|
||||
[DllImport(LibName, CallingConvention = CallingConvention.Cdecl)]
|
||||
[return: MarshalAs(UnmanagedType.I1)]
|
||||
internal static extern bool MNW_HideNotification(long notifId);
|
||||
}
|
||||
}
|
||||
94
src/Notify.NET/Platform/MacOS/MacOSNativeLibraryLoader.cs
Normal file
94
src/Notify.NET/Platform/MacOS/MacOSNativeLibraryLoader.cs
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using Notify.NET.Exceptions;
|
||||
|
||||
namespace Notify.NET.Platform.MacOS
|
||||
{
|
||||
/// <summary>
|
||||
/// Ensures <c>libMacNotifyWrapper.dylib</c> is loaded before the first P/Invoke call.
|
||||
///
|
||||
/// Resolution order:
|
||||
/// 1. Alongside the executing assembly (typical for published apps).
|
||||
/// 2. NuGet <c>runtimes/<rid>/native/</c> layout relative to the executing assembly.
|
||||
/// 3. NuGet layout relative to the entry assembly.
|
||||
/// </summary>
|
||||
internal static class MacOSNativeLibraryLoader
|
||||
{
|
||||
private const string DylibName = "libMacNotifyWrapper.dylib";
|
||||
|
||||
private static volatile bool _loaded;
|
||||
private static readonly object _lock = new object();
|
||||
|
||||
/// <summary>
|
||||
/// Loads the dylib if it has not been loaded yet.
|
||||
/// Throws <see cref="DllNotFoundException"/> if the file cannot be found or opened.
|
||||
/// </summary>
|
||||
internal static void EnsureLoaded()
|
||||
{
|
||||
if (_loaded) return;
|
||||
lock (_lock)
|
||||
{
|
||||
if (_loaded) return;
|
||||
LoadDylib();
|
||||
_loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static void LoadDylib()
|
||||
{
|
||||
string rid = GetRuntimeIdentifier();
|
||||
string relativeSubPath = Path.Combine("runtimes", rid, "native", DylibName);
|
||||
|
||||
string? assemblyDir = Path.GetDirectoryName(
|
||||
typeof(MacOSNativeLibraryLoader).Assembly.Location);
|
||||
|
||||
// Search locations in priority order.
|
||||
string[] candidates = assemblyDir != null
|
||||
? new[]
|
||||
{
|
||||
Path.Combine(assemblyDir, DylibName),
|
||||
Path.Combine(assemblyDir, relativeSubPath),
|
||||
Path.Combine(AppContext.BaseDirectory, DylibName),
|
||||
Path.Combine(AppContext.BaseDirectory, relativeSubPath),
|
||||
}
|
||||
: new[]
|
||||
{
|
||||
Path.Combine(AppContext.BaseDirectory, DylibName),
|
||||
Path.Combine(AppContext.BaseDirectory, relativeSubPath),
|
||||
};
|
||||
|
||||
foreach (string candidate in candidates)
|
||||
{
|
||||
if (!File.Exists(candidate)) continue;
|
||||
IntPtr handle = dlopen(candidate, RTLD_NOW | RTLD_GLOBAL);
|
||||
if (handle != IntPtr.Zero) return;
|
||||
}
|
||||
|
||||
throw new DllNotFoundException(
|
||||
$"Could not load {DylibName}. " +
|
||||
$"Ensure the macOS native dylib is present in the output directory or " +
|
||||
$"runtimes/{rid}/native/. " +
|
||||
$"Build with: cd native/MacNotifyWrapper && make install");
|
||||
}
|
||||
|
||||
private static string GetRuntimeIdentifier()
|
||||
{
|
||||
switch (RuntimeInformation.ProcessArchitecture)
|
||||
{
|
||||
case Architecture.X64: return "osx-x64";
|
||||
case Architecture.Arm64: return "osx-arm64";
|
||||
default:
|
||||
throw new Exceptions.PlatformNotSupportedException(
|
||||
$"Unsupported macOS architecture: {RuntimeInformation.ProcessArchitecture}");
|
||||
}
|
||||
}
|
||||
|
||||
// RTLD_NOW=2, RTLD_GLOBAL=8 on macOS.
|
||||
private const int RTLD_NOW = 2;
|
||||
private const int RTLD_GLOBAL = 8;
|
||||
|
||||
[DllImport("libSystem.B.dylib")]
|
||||
private static extern IntPtr dlopen(string path, int mode);
|
||||
}
|
||||
}
|
||||
302
src/Notify.NET/Platform/MacOS/MacOSNotificationService.cs
Normal file
302
src/Notify.NET/Platform/MacOS/MacOSNotificationService.cs
Normal file
|
|
@ -0,0 +1,302 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Notify.NET.Abstractions;
|
||||
using Notify.NET.Exceptions;
|
||||
|
||||
namespace Notify.NET.Platform.MacOS
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="INotificationService"/> implementation backed by macOS
|
||||
/// <c>UNUserNotificationCenter</c> (macOS 10.14+) via a thin native Objective-C
|
||||
/// wrapper (<c>libMacNotifyWrapper.dylib</c>).
|
||||
///
|
||||
/// Threading model:
|
||||
/// <c>UNUserNotificationCenter</c> is internally thread-safe; all P/Invoke calls
|
||||
/// may be made from any thread. Callbacks arrive on a background GCD thread managed
|
||||
/// by the framework; consumers are responsible for marshalling to a UI thread if
|
||||
/// required.
|
||||
///
|
||||
/// macOS-specific behaviour:
|
||||
/// - A user authorisation prompt is shown on the first call (Alert + Sound + Badge).
|
||||
/// - Body-tap and button-tap are terminal events; <c>onDismissed</c> is NOT fired
|
||||
/// after an action response (unlike Windows where WinToastLib always fires it).
|
||||
/// - Auto-expiry (timed out) does not produce a callback — macOS does not expose
|
||||
/// this event to <c>UNUserNotificationCenterDelegate</c>.
|
||||
/// - Non-bundled processes (e.g. bare dotnet CLI) receive the banner but may not
|
||||
/// receive action callbacks depending on the OS version and app entitlements.
|
||||
/// </summary>
|
||||
public sealed class MacOSNotificationService : INotificationService
|
||||
{
|
||||
private volatile bool _disposed;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsSupported { get; private set; }
|
||||
|
||||
/// <param name="appName">
|
||||
/// Application name used for logging. The OS uses the bundle identifier for
|
||||
/// notification attribution; pass a descriptive name for diagnostic purposes.
|
||||
/// </param>
|
||||
public MacOSNotificationService(string appName)
|
||||
{
|
||||
if (appName == null) throw new ArgumentNullException(nameof(appName));
|
||||
|
||||
try
|
||||
{
|
||||
MacOSNativeLibraryLoader.EnsureLoaded();
|
||||
|
||||
if (!MacNotifyNative.MNW_IsSupported())
|
||||
{
|
||||
IsSupported = false;
|
||||
return;
|
||||
}
|
||||
|
||||
IsSupported = MacNotifyNative.MNW_Initialize(appName);
|
||||
}
|
||||
catch (DllNotFoundException)
|
||||
{
|
||||
IsSupported = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// INotificationService
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<long> ShowAsync(NotificationRequest request,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request == null) throw new ArgumentNullException(nameof(request));
|
||||
ThrowIfDisposedOrUnsupported();
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
long notifId = ShowNative(request);
|
||||
return Task.FromResult(notifId);
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task HideAsync(long notificationId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposedOrUnsupported();
|
||||
cancellationToken.ThrowIfCancellationRequested();
|
||||
|
||||
bool ok = MacNotifyNative.MNW_HideNotification(notificationId);
|
||||
|
||||
// MNW_HideNotification fires onDismissed synchronously via the native
|
||||
// layer; release the managed bridge entry too.
|
||||
MacNotifyCallbackBridge.Release(notificationId);
|
||||
|
||||
if (!ok)
|
||||
throw new NotificationException(
|
||||
$"MNW_HideNotification failed for id {notificationId}.");
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// IDisposable
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
if (IsSupported)
|
||||
MacNotifyNative.MNW_Uninitialize();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private void ThrowIfDisposedOrUnsupported()
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(MacOSNotificationService));
|
||||
if (!IsSupported) throw new Exceptions.PlatformNotSupportedException();
|
||||
}
|
||||
|
||||
private static long ShowNative(NotificationRequest request)
|
||||
{
|
||||
// Marshal all strings to unmanaged UTF-8 memory for the duration of the call.
|
||||
// Marshal.StringToHGlobalAnsi uses the system ANSI encoding; on macOS that is UTF-8.
|
||||
using var titlePin = new PinnedStringAnsi(request.Title);
|
||||
using var bodyPin = new PinnedStringAnsi(request.Body);
|
||||
using var imagePin = new PinnedStringAnsi(ResolveImagePath(request.ImagePath));
|
||||
|
||||
// Build array of pinned button label pointers.
|
||||
int btnCount = request.Buttons.Count;
|
||||
var btnPins = new PinnedStringAnsi[btnCount];
|
||||
var btnPtrs = new IntPtr[btnCount];
|
||||
|
||||
for (int i = 0; i < btnCount; i++)
|
||||
{
|
||||
btnPins[i] = new PinnedStringAnsi(request.Buttons[i].Label);
|
||||
btnPtrs[i] = btnPins[i].Pointer;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Pin the button pointer array so its address is stable during the call.
|
||||
GCHandle btnArrayHandle = default;
|
||||
IntPtr btnArrayPtr = IntPtr.Zero;
|
||||
|
||||
if (btnCount > 0)
|
||||
{
|
||||
btnArrayHandle = GCHandle.Alloc(btnPtrs, GCHandleType.Pinned);
|
||||
btnArrayPtr = btnArrayHandle.AddrOfPinnedObject();
|
||||
}
|
||||
|
||||
var descriptor = new MacNotifyNative.MNW_NotificationDescriptor
|
||||
{
|
||||
title = titlePin.Pointer,
|
||||
body = bodyPin.Pointer,
|
||||
imagePath = imagePin.Pointer,
|
||||
buttonLabels = btnArrayPtr,
|
||||
buttonCount = btnCount,
|
||||
expirationMs = request.Expiration.HasValue
|
||||
? (long)request.Expiration.Value.TotalMilliseconds
|
||||
: 0L,
|
||||
audioOption = MapAudio(request.Audio),
|
||||
interruptionLevel = MapInterruptionLevel(request.Urgency)
|
||||
};
|
||||
|
||||
var handler = new MacNotifyNative.MNW_Handler
|
||||
{
|
||||
onActivated = MacNotifyCallbackBridge.PtrActivated,
|
||||
onButtonActivated = MacNotifyCallbackBridge.PtrButtonActivated,
|
||||
onDismissed = MacNotifyCallbackBridge.PtrDismissed,
|
||||
onFailed = MacNotifyCallbackBridge.PtrFailed
|
||||
};
|
||||
|
||||
long notifId = MacNotifyNative.MNW_ShowNotification(ref descriptor, ref handler);
|
||||
|
||||
if (btnArrayHandle.IsAllocated)
|
||||
btnArrayHandle.Free();
|
||||
|
||||
if (notifId < 0)
|
||||
throw new NotificationException(
|
||||
$"MNW_ShowNotification failed with code {notifId}.");
|
||||
|
||||
// Register managed bridge before any callback can fire.
|
||||
MacNotifyCallbackBridge.Register(notifId, BuildCompositeHandler(request));
|
||||
|
||||
return notifId;
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var pin in btnPins)
|
||||
pin.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
private static INotificationHandler? BuildCompositeHandler(NotificationRequest request)
|
||||
{
|
||||
bool hasButtonCallbacks = false;
|
||||
foreach (var btn in request.Buttons)
|
||||
if (btn.Callback != null) { hasButtonCallbacks = true; break; }
|
||||
|
||||
if (!hasButtonCallbacks)
|
||||
return request.Handler;
|
||||
|
||||
return new CompositeHandler(request.Handler, request.Buttons);
|
||||
}
|
||||
|
||||
private static int MapAudio(NotificationAudio audio)
|
||||
{
|
||||
return audio == NotificationAudio.Silent
|
||||
? 1 /* MNW_AUDIO_SILENT */
|
||||
: 0 /* MNW_AUDIO_DEFAULT */;
|
||||
}
|
||||
|
||||
private static int MapInterruptionLevel(NotificationUrgency urgency)
|
||||
{
|
||||
switch (urgency)
|
||||
{
|
||||
case NotificationUrgency.Low: return 1; // MNW_INTERRUPTION_PASSIVE
|
||||
case NotificationUrgency.Critical:
|
||||
case NotificationUrgency.Alarm: return 3; // MNW_INTERRUPTION_CRITICAL
|
||||
default: return 0; // MNW_INTERRUPTION_ACTIVE
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a (possibly relative) image path to an absolute path and verifies the file
|
||||
/// exists. Returns null if the path is empty, unresolvable, or missing so that the
|
||||
/// native layer skips the attachment rather than failing the whole notification.
|
||||
/// </summary>
|
||||
private static string? ResolveImagePath(string? path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) return null;
|
||||
try
|
||||
{
|
||||
string absolute = Path.IsPathRooted(path) ? path : Path.GetFullPath(path);
|
||||
return File.Exists(absolute) ? absolute : null;
|
||||
}
|
||||
catch (Exception) { return null; }
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Inner helpers
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Copies a .NET string to unmanaged ANSI (UTF-8 on macOS) memory.
|
||||
/// The allocation is freed on <see cref="Dispose"/>.
|
||||
/// </summary>
|
||||
private sealed class PinnedStringAnsi : IDisposable
|
||||
{
|
||||
public IntPtr Pointer { get; }
|
||||
|
||||
public PinnedStringAnsi(string? value)
|
||||
{
|
||||
Pointer = value != null
|
||||
? Marshal.StringToHGlobalAnsi(value)
|
||||
: IntPtr.Zero;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Pointer != IntPtr.Zero)
|
||||
Marshal.FreeHGlobal(Pointer);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Combines a top-level <see cref="INotificationHandler"/> with per-button callbacks
|
||||
/// stored in <see cref="Builder.NotificationButton.Callback"/>.
|
||||
/// </summary>
|
||||
private sealed class CompositeHandler : INotificationHandler
|
||||
{
|
||||
private readonly INotificationHandler? _inner;
|
||||
private readonly System.Collections.Generic.IReadOnlyList<Builder.NotificationButton> _buttons;
|
||||
|
||||
public CompositeHandler(
|
||||
INotificationHandler? inner,
|
||||
System.Collections.Generic.IReadOnlyList<Builder.NotificationButton> buttons)
|
||||
{
|
||||
_inner = inner;
|
||||
_buttons = buttons;
|
||||
}
|
||||
|
||||
public void OnActivated(long id) => _inner?.OnActivated(id);
|
||||
|
||||
public void OnButtonActivated(long id, int index)
|
||||
{
|
||||
if (index >= 0 && index < _buttons.Count)
|
||||
_buttons[index].Callback?.Invoke(id);
|
||||
_inner?.OnButtonActivated(id, index);
|
||||
}
|
||||
|
||||
public void OnDismissed(long id, DismissReason reason) =>
|
||||
_inner?.OnDismissed(id, reason);
|
||||
|
||||
public void OnFailed(long id) => _inner?.OnFailed(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
93
src/Notify.NET/Platform/Windows/NativeLibraryLoader.cs
Normal file
93
src/Notify.NET/Platform/Windows/NativeLibraryLoader.cs
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Reflection;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Notify.NET.Platform.Windows
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads WinToastWrapper.dll from the correct runtime-identifier sub-folder before
|
||||
/// the first P/Invoke call is made. This ensures the x64/x86/arm64 variant that
|
||||
/// matches the current process architecture is used.
|
||||
///
|
||||
/// Once LoadLibraryW succeeds, subsequent DllImport("WinToastWrapper") resolutions
|
||||
/// find the already-loaded module in the process module list automatically.
|
||||
/// </summary>
|
||||
internal static class NativeLibraryLoader
|
||||
{
|
||||
private static volatile bool _loaded;
|
||||
private static readonly object _lock = new object();
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
private static extern IntPtr LoadLibraryW(string lpLibFileName);
|
||||
|
||||
internal static void EnsureLoaded()
|
||||
{
|
||||
if (_loaded) return;
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_loaded) return;
|
||||
|
||||
string rid = GetRuntimeIdentifier();
|
||||
string dllPath = ResolveNativePath(rid);
|
||||
|
||||
IntPtr handle = LoadLibraryW(dllPath);
|
||||
if (handle == IntPtr.Zero)
|
||||
{
|
||||
int err = Marshal.GetLastWin32Error();
|
||||
throw new DllNotFoundException(
|
||||
$"Failed to load WinToastWrapper.dll from '{dllPath}' (Win32 error {err}). " +
|
||||
"Ensure the native DLL for your platform architecture is present in the " +
|
||||
$"runtimes/{rid}/native/ directory relative to the assembly.");
|
||||
}
|
||||
|
||||
_loaded = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static string GetRuntimeIdentifier()
|
||||
{
|
||||
switch (RuntimeInformation.ProcessArchitecture)
|
||||
{
|
||||
case Architecture.X64: return "win-x64";
|
||||
case Architecture.X86: return "win-x86";
|
||||
case Architecture.Arm64: return "win-arm64";
|
||||
default:
|
||||
throw new PlatformNotSupportedException(
|
||||
$"No WinToastWrapper.dll is available for architecture {RuntimeInformation.ProcessArchitecture}.");
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveNativePath(string rid)
|
||||
{
|
||||
// Search order:
|
||||
// 1. Alongside the executing assembly (output directory, typical for app projects)
|
||||
// 2. Relative to the assembly's location using the NuGet runtimes layout
|
||||
string assemblyDir = Path.GetDirectoryName(
|
||||
new Uri(typeof(NativeLibraryLoader).Assembly.CodeBase!).LocalPath)!;
|
||||
|
||||
// Typical publish output: <outdir>/WinToastWrapper.dll (copied by MSBuild)
|
||||
string flat = Path.Combine(assemblyDir, "WinToastWrapper.dll");
|
||||
if (File.Exists(flat)) return flat;
|
||||
|
||||
// NuGet runtimes layout: <assemblyDir>/runtimes/<rid>/native/WinToastWrapper.dll
|
||||
string runtimePath = Path.Combine(assemblyDir, "runtimes", rid, "native", "WinToastWrapper.dll");
|
||||
if (File.Exists(runtimePath)) return runtimePath;
|
||||
|
||||
// Fallback: relative to the entry assembly location
|
||||
string? entryDir = Path.GetDirectoryName(Assembly.GetEntryAssembly()?.Location);
|
||||
if (entryDir != null)
|
||||
{
|
||||
string entryRuntime = Path.Combine(entryDir, "runtimes", rid, "native", "WinToastWrapper.dll");
|
||||
if (File.Exists(entryRuntime)) return entryRuntime;
|
||||
|
||||
string entryFlat = Path.Combine(entryDir, "WinToastWrapper.dll");
|
||||
if (File.Exists(entryFlat)) return entryFlat;
|
||||
}
|
||||
|
||||
// Return the NuGet path even if not found — LoadLibraryW will fail with a useful error
|
||||
return Path.Combine(assemblyDir, "runtimes", rid, "native", "WinToastWrapper.dll");
|
||||
}
|
||||
}
|
||||
}
|
||||
158
src/Notify.NET/Platform/Windows/WinToastHandlerBridge.cs
Normal file
158
src/Notify.NET/Platform/Windows/WinToastHandlerBridge.cs
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Runtime.InteropServices;
|
||||
using Notify.NET.Abstractions;
|
||||
|
||||
namespace Notify.NET.Platform.Windows
|
||||
{
|
||||
/// <summary>
|
||||
/// Bridges the unmanaged WinToastWrapper callbacks back to the managed
|
||||
/// <see cref="INotificationHandler"/> for each in-flight toast.
|
||||
///
|
||||
/// Design rules that MUST be maintained to avoid memory-safety bugs:
|
||||
///
|
||||
/// 1. The four static delegates (<see cref="_staticActivated"/> etc.) are kept alive
|
||||
/// for the process lifetime because they are stored in static fields. Their function
|
||||
/// pointers are therefore permanently valid for unmanaged code to call.
|
||||
///
|
||||
/// 2. Per-notification state is held in <see cref="WinToastHandlerBridge"/> instances
|
||||
/// tracked in the static <see cref="_live"/> dictionary. Each instance's GCHandle
|
||||
/// prevents the GC from collecting it while the toast is alive.
|
||||
///
|
||||
/// 3. Routing works by passing the toast ID (long) back through the static callbacks,
|
||||
/// which look up the matching bridge instance in <see cref="_live"/>.
|
||||
///
|
||||
/// 4. <see cref="Release"/> is called exactly once, from whichever callback fires last
|
||||
/// (dismissed or failed). It removes the entry and frees the GCHandle.
|
||||
/// </summary>
|
||||
internal sealed class WinToastHandlerBridge
|
||||
{
|
||||
// ------------------------------------------------------------------
|
||||
// Static callback function pointers — allocated once, never collected
|
||||
// ------------------------------------------------------------------
|
||||
private static readonly WinToastNative.ActivatedCallback _staticActivated;
|
||||
private static readonly WinToastNative.ButtonActivatedCallback _staticButtonActivated;
|
||||
private static readonly WinToastNative.DismissedCallback _staticDismissed;
|
||||
private static readonly WinToastNative.FailedCallback _staticFailed;
|
||||
|
||||
// Pointer-sized function pointers stored in the WNT_Handler struct
|
||||
internal static readonly IntPtr PtrActivated;
|
||||
internal static readonly IntPtr PtrButtonActivated;
|
||||
internal static readonly IntPtr PtrDismissed;
|
||||
internal static readonly IntPtr PtrFailed;
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Static dictionary: toastId → live bridge instance
|
||||
// ------------------------------------------------------------------
|
||||
private static readonly ConcurrentDictionary<long, WinToastHandlerBridge> _live
|
||||
= new ConcurrentDictionary<long, WinToastHandlerBridge>();
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Per-instance state
|
||||
// ------------------------------------------------------------------
|
||||
private readonly INotificationHandler? _handler;
|
||||
private GCHandle _gcHandle; // keeps this bridge alive from unmanaged side
|
||||
|
||||
static WinToastHandlerBridge()
|
||||
{
|
||||
// Create static delegates and pin their function pointers permanently.
|
||||
_staticActivated = OnActivatedStatic;
|
||||
_staticButtonActivated = OnButtonActivatedStatic;
|
||||
_staticDismissed = OnDismissedStatic;
|
||||
_staticFailed = OnFailedStatic;
|
||||
|
||||
PtrActivated = Marshal.GetFunctionPointerForDelegate(_staticActivated);
|
||||
PtrButtonActivated = Marshal.GetFunctionPointerForDelegate(_staticButtonActivated);
|
||||
PtrDismissed = Marshal.GetFunctionPointerForDelegate(_staticDismissed);
|
||||
PtrFailed = Marshal.GetFunctionPointerForDelegate(_staticFailed);
|
||||
}
|
||||
|
||||
private WinToastHandlerBridge(INotificationHandler? handler)
|
||||
{
|
||||
_handler = handler;
|
||||
// Allocate a GCHandle so the GC cannot collect this instance.
|
||||
_gcHandle = GCHandle.Alloc(this, GCHandleType.Normal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates a bridge and registers it under <paramref name="toastId"/>.
|
||||
/// Call this immediately after <see cref="WinToastNative.WNT_ShowToast"/> returns a positive ID.
|
||||
/// </summary>
|
||||
internal static WinToastHandlerBridge Register(long toastId, INotificationHandler? handler)
|
||||
{
|
||||
var bridge = new WinToastHandlerBridge(handler);
|
||||
_live[toastId] = bridge;
|
||||
return bridge;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Removes the bridge for <paramref name="toastId"/> and releases its GCHandle.
|
||||
/// Safe to call multiple times; subsequent calls are no-ops.
|
||||
/// </summary>
|
||||
internal static void Release(long toastId)
|
||||
{
|
||||
if (_live.TryRemove(toastId, out var bridge) && bridge._gcHandle.IsAllocated)
|
||||
bridge._gcHandle.Free();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Static routing callbacks — invoked by unmanaged code on a WinRT thread
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private static void OnActivatedStatic(long toastId)
|
||||
{
|
||||
// Must not let exceptions escape to native code — unhandled exceptions
|
||||
// on WinRT callback threads crash the process with no useful error.
|
||||
try
|
||||
{
|
||||
if (_live.TryGetValue(toastId, out var bridge))
|
||||
bridge._handler?.OnActivated(toastId);
|
||||
}
|
||||
catch { /* swallow — caller cannot handle managed exceptions */ }
|
||||
}
|
||||
|
||||
private static void OnButtonActivatedStatic(long toastId, int buttonIndex)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_live.TryGetValue(toastId, out var bridge))
|
||||
bridge._handler?.OnButtonActivated(toastId, buttonIndex);
|
||||
}
|
||||
catch { }
|
||||
}
|
||||
|
||||
private static void OnDismissedStatic(long toastId, int reason)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_live.TryGetValue(toastId, out var bridge))
|
||||
bridge._handler?.OnDismissed(toastId, MapDismissReason(reason));
|
||||
}
|
||||
catch { }
|
||||
finally { Release(toastId); }
|
||||
}
|
||||
|
||||
private static void OnFailedStatic(long toastId)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (_live.TryGetValue(toastId, out var bridge))
|
||||
bridge._handler?.OnFailed(toastId);
|
||||
}
|
||||
catch { }
|
||||
finally { Release(toastId); }
|
||||
}
|
||||
|
||||
private static DismissReason MapDismissReason(int native)
|
||||
{
|
||||
// WinToastDismissalReason: 0=UserCancelled, 1=ApplicationHidden, 2=TimedOut
|
||||
switch (native)
|
||||
{
|
||||
case 0: return DismissReason.UserCancelled;
|
||||
case 1: return DismissReason.ApplicationHidden;
|
||||
case 2: return DismissReason.TimedOut;
|
||||
default: return DismissReason.Unknown;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
120
src/Notify.NET/Platform/Windows/WinToastNative.cs
Normal file
120
src/Notify.NET/Platform/Windows/WinToastNative.cs
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace Notify.NET.Platform.Windows
|
||||
{
|
||||
/// <summary>
|
||||
/// P/Invoke declarations for WinToastWrapper.dll — the native C DLL that wraps WinToastLib.
|
||||
/// All strings are UTF-16 (Unicode) to match the wchar_t* ABI of the wrapper.
|
||||
/// The DLL must be loaded via <see cref="NativeLibraryLoader"/> before these are called.
|
||||
/// </summary>
|
||||
internal static class WinToastNative
|
||||
{
|
||||
private const string DllName = "WinToastWrapper";
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Unmanaged callback delegate types.
|
||||
// IMPORTANT: These must be static fields — never pass instance delegates as
|
||||
// unmanaged function pointers. The GC does not see unmanaged references and
|
||||
// will collect instance delegates, producing an AccessViolationException.
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
internal delegate void ActivatedCallback(long toastId);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
internal delegate void ButtonActivatedCallback(long toastId, int buttonIndex);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
internal delegate void DismissedCallback(long toastId, int reason);
|
||||
|
||||
[UnmanagedFunctionPointer(CallingConvention.Cdecl)]
|
||||
internal delegate void FailedCallback(long toastId);
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Structs matching the C ABI of WinToastWrapper.h
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Plain-data descriptor passed to <see cref="WNT_ShowToast"/>.
|
||||
/// String fields are pointers into pinned managed memory — callers must
|
||||
/// keep the pinned handles alive for the duration of the call.
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential, CharSet = CharSet.Unicode)]
|
||||
internal struct WNT_ToastDescriptor
|
||||
{
|
||||
public IntPtr title; // wchar_t*
|
||||
public IntPtr body; // wchar_t* (may be IntPtr.Zero)
|
||||
public IntPtr imagePath; // wchar_t* (may be IntPtr.Zero)
|
||||
public IntPtr buttonLabels; // wchar_t** (array of pointers, may be IntPtr.Zero)
|
||||
public int buttonCount;
|
||||
public long expirationMs; // 0 = platform default
|
||||
public int scenario; // WNT_Scenario enum value
|
||||
public int audioOption; // WNT_AudioOption enum value
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Struct of four function pointers passed to <see cref="WNT_ShowToast"/>.
|
||||
/// Must be pinned for the lifetime of the toast (until dismissed or failed).
|
||||
/// </summary>
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
internal struct WNT_Handler
|
||||
{
|
||||
public IntPtr onActivated; // ActivatedCallback
|
||||
public IntPtr onButtonActivated; // ButtonActivatedCallback
|
||||
public IntPtr onDismissed; // DismissedCallback
|
||||
public IntPtr onFailed; // FailedCallback
|
||||
}
|
||||
|
||||
// WNT_Scenario values (must match enum in WinToastWrapper.h)
|
||||
internal const int WNT_SCENARIO_DEFAULT = 0;
|
||||
internal const int WNT_SCENARIO_ALARM = 1;
|
||||
internal const int WNT_SCENARIO_REMINDER = 2;
|
||||
internal const int WNT_SCENARIO_INCOMING_CALL = 3;
|
||||
|
||||
// WNT_AudioOption values (must match enum in WinToastWrapper.h)
|
||||
internal const int WNT_AUDIO_DEFAULT = 0;
|
||||
internal const int WNT_AUDIO_SILENT = 1;
|
||||
internal const int WNT_AUDIO_LOOP = 2;
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Exported functions
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Initialises WinToastLib. Must be called once from an STA thread before any other function.
|
||||
/// </summary>
|
||||
/// <param name="appName">Human-readable application name shown in the Action Centre.</param>
|
||||
/// <param name="appUserModelId">
|
||||
/// The AppUserModelId (AUMI) — must match the shortcut in the Start Menu.
|
||||
/// The wrapper creates the shortcut automatically if it doesn't exist.
|
||||
/// </param>
|
||||
/// <returns>true on success.</returns>
|
||||
[DllImport(DllName, EntryPoint = "WNT_Initialize", CharSet = CharSet.Unicode, SetLastError = false)]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static extern bool WNT_Initialize(string appName, string appUserModelId);
|
||||
|
||||
/// <summary>Uninitialises WinToastLib and releases all internal resources.</summary>
|
||||
[DllImport(DllName, EntryPoint = "WNT_Uninitialize")]
|
||||
internal static extern void WNT_Uninitialize();
|
||||
|
||||
/// <summary>Returns true if WinToast is supported on this version of Windows (requires Win 8+).</summary>
|
||||
[DllImport(DllName, EntryPoint = "WNT_IsCompatible")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static extern bool WNT_IsCompatible();
|
||||
|
||||
/// <summary>
|
||||
/// Shows a toast notification. Must be called from the STA thread.
|
||||
/// </summary>
|
||||
/// <param name="descriptor">Pointer to a <see cref="WNT_ToastDescriptor"/> with notification data.</param>
|
||||
/// <param name="handler">Pointer to a <see cref="WNT_Handler"/> with callback function pointers.</param>
|
||||
/// <returns>A positive toast ID on success, or a negative error code on failure.</returns>
|
||||
[DllImport(DllName, EntryPoint = "WNT_ShowToast")]
|
||||
internal static extern long WNT_ShowToast(ref WNT_ToastDescriptor descriptor, ref WNT_Handler handler);
|
||||
|
||||
/// <summary>Programmatically dismisses a previously shown toast. Must be called from the STA thread.</summary>
|
||||
[DllImport(DllName, EntryPoint = "WNT_HideToast")]
|
||||
[return: MarshalAs(UnmanagedType.Bool)]
|
||||
internal static extern bool WNT_HideToast(long toastId);
|
||||
}
|
||||
}
|
||||
417
src/Notify.NET/Platform/Windows/WindowsNotificationService.cs
Normal file
417
src/Notify.NET/Platform/Windows/WindowsNotificationService.cs
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Notify.NET.Abstractions;
|
||||
using Notify.NET.Exceptions;
|
||||
|
||||
namespace Notify.NET.Platform.Windows
|
||||
{
|
||||
/// <summary>
|
||||
/// <see cref="INotificationService"/> implementation backed by WinToastLib via a thin
|
||||
/// native C wrapper DLL (<c>WinToastWrapper.dll</c>).
|
||||
///
|
||||
/// Threading model:
|
||||
/// WinRT toast APIs require a Single-Threaded Apartment (STA). This service owns a
|
||||
/// dedicated STA background thread that runs a Win32 message pump. All P/Invoke calls
|
||||
/// are marshalled onto that thread via a work-item queue. Callbacks from WinToast arrive
|
||||
/// on a WinRT thread-pool thread (NOT the STA thread) and are safe to dispatch directly.
|
||||
/// </summary>
|
||||
public sealed class WindowsNotificationService : INotificationService
|
||||
{
|
||||
private readonly string _appName;
|
||||
private readonly string _appUserModelId;
|
||||
|
||||
private readonly Thread _staThread;
|
||||
private readonly BlockingCollection<Action> _workQueue = new BlockingCollection<Action>();
|
||||
private readonly ManualResetEventSlim _initialised = new ManualResetEventSlim(false);
|
||||
private volatile bool _isSupported;
|
||||
private volatile bool _disposed;
|
||||
private Exception? _initException;
|
||||
|
||||
/// <inheritdoc/>
|
||||
public bool IsSupported => _isSupported;
|
||||
|
||||
/// <param name="appName">Human-readable application name (shown in Action Centre).</param>
|
||||
/// <param name="appUserModelId">
|
||||
/// Your application's AppUserModelId, e.g. <c>"MyCompany.MyApp"</c>.
|
||||
/// A Start-Menu shortcut carrying this AUMI is required for notifications to persist in
|
||||
/// the Action Centre. The native wrapper creates the shortcut automatically when missing.
|
||||
/// </param>
|
||||
public WindowsNotificationService(string appName, string appUserModelId)
|
||||
{
|
||||
_appName = appName ?? throw new ArgumentNullException(nameof(appName));
|
||||
_appUserModelId = appUserModelId ?? throw new ArgumentNullException(nameof(appUserModelId));
|
||||
|
||||
_staThread = new Thread(StaThreadProc)
|
||||
{
|
||||
Name = "Notify.NET STA",
|
||||
IsBackground = true
|
||||
};
|
||||
_staThread.SetApartmentState(ApartmentState.STA);
|
||||
_staThread.Start();
|
||||
|
||||
// Block until the STA thread has finished initialising (or failed).
|
||||
_initialised.Wait();
|
||||
if (_initException != null)
|
||||
throw new NotificationException("WinToastLib initialisation failed.", _initException);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// INotificationService
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task<long> ShowAsync(NotificationRequest request, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (request == null) throw new ArgumentNullException(nameof(request));
|
||||
ThrowIfDisposedOrUnsupported();
|
||||
|
||||
var tcs = new TaskCompletionSource<long>();
|
||||
cancellationToken.Register(() => tcs.TrySetCanceled());
|
||||
|
||||
EnqueueOnSta(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
long id = ShowOnSta(request);
|
||||
tcs.TrySetResult(id);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
tcs.TrySetException(ex);
|
||||
}
|
||||
});
|
||||
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
/// <inheritdoc/>
|
||||
public Task HideAsync(long notificationId, CancellationToken cancellationToken = default)
|
||||
{
|
||||
ThrowIfDisposedOrUnsupported();
|
||||
|
||||
var tcs = new TaskCompletionSource<bool>();
|
||||
cancellationToken.Register(() => tcs.TrySetCanceled());
|
||||
|
||||
EnqueueOnSta(() =>
|
||||
{
|
||||
try
|
||||
{
|
||||
bool ok = WinToastNative.WNT_HideToast(notificationId);
|
||||
if (!ok)
|
||||
tcs.TrySetException(new NotificationException($"WNT_HideToast failed for id {notificationId}."));
|
||||
else
|
||||
{
|
||||
WinToastHandlerBridge.Release(notificationId);
|
||||
tcs.TrySetResult(true);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
tcs.TrySetException(ex);
|
||||
}
|
||||
});
|
||||
|
||||
return tcs.Task;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// IDisposable
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <inheritdoc/>
|
||||
public void Dispose()
|
||||
{
|
||||
if (_disposed) return;
|
||||
_disposed = true;
|
||||
|
||||
// Signal the STA thread to shut down by completing the queue.
|
||||
_workQueue.CompleteAdding();
|
||||
|
||||
// Wait for the STA thread to finish its message pump and uninitialise.
|
||||
if (_staThread.IsAlive)
|
||||
_staThread.Join(TimeSpan.FromSeconds(5));
|
||||
|
||||
_workQueue.Dispose();
|
||||
_initialised.Dispose();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Private helpers
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
private void ThrowIfDisposedOrUnsupported()
|
||||
{
|
||||
if (_disposed) throw new ObjectDisposedException(nameof(WindowsNotificationService));
|
||||
if (!_isSupported) throw new Exceptions.PlatformNotSupportedException();
|
||||
}
|
||||
|
||||
private void EnqueueOnSta(Action action)
|
||||
{
|
||||
try { _workQueue.Add(action); }
|
||||
catch (InvalidOperationException) { /* queue completed — service is disposed */ }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The STA thread entry point. Runs a simple work-item loop as the message pump.
|
||||
/// </summary>
|
||||
private void StaThreadProc()
|
||||
{
|
||||
try
|
||||
{
|
||||
NativeLibraryLoader.EnsureLoaded();
|
||||
|
||||
if (!WinToastNative.WNT_IsCompatible())
|
||||
{
|
||||
_isSupported = false;
|
||||
_initialised.Set();
|
||||
return;
|
||||
}
|
||||
|
||||
bool ok = WinToastNative.WNT_Initialize(_appName, _appUserModelId);
|
||||
if (!ok)
|
||||
{
|
||||
_initException = new NotificationException("WNT_Initialize returned false.");
|
||||
_isSupported = false;
|
||||
_initialised.Set();
|
||||
return;
|
||||
}
|
||||
|
||||
_isSupported = true;
|
||||
_initialised.Set();
|
||||
|
||||
// Process work items until Dispose() calls CompleteAdding().
|
||||
foreach (Action work in _workQueue.GetConsumingEnumerable())
|
||||
{
|
||||
// Pump pending Windows messages between work items so WinRT callbacks
|
||||
// can be delivered to the STA message queue.
|
||||
PumpMessages();
|
||||
work();
|
||||
PumpMessages();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
_initException = ex;
|
||||
_isSupported = false;
|
||||
_initialised.Set();
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Drain any remaining messages before uninitialising.
|
||||
PumpMessages();
|
||||
if (_isSupported)
|
||||
WinToastNative.WNT_Uninitialize();
|
||||
}
|
||||
}
|
||||
|
||||
private long ShowOnSta(NotificationRequest request)
|
||||
{
|
||||
// We need to pass wchar_t* pointers to the native layer.
|
||||
// Pin managed strings as unmanaged UTF-16 memory for the duration of the call.
|
||||
// button label pointers are pinned in the IntPtr[] and that array is pinned too.
|
||||
|
||||
using var titlePin = new PinnedString(request.Title);
|
||||
using var bodyPin = new PinnedString(request.Body);
|
||||
using var imagePin = new PinnedString(ResolveImagePath(request.ImagePath));
|
||||
|
||||
// Build array of pinned button label pointers.
|
||||
var buttonPins = new PinnedString[request.Buttons.Count];
|
||||
var buttonPtrs = new IntPtr[request.Buttons.Count];
|
||||
for (int i = 0; i < request.Buttons.Count; i++)
|
||||
{
|
||||
buttonPins[i] = new PinnedString(request.Buttons[i].Label);
|
||||
buttonPtrs[i] = buttonPins[i].Pointer;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Pin the button pointer array itself.
|
||||
GCHandle buttonArrayHandle = default;
|
||||
IntPtr buttonArrayPtr = IntPtr.Zero;
|
||||
|
||||
if (buttonPtrs.Length > 0)
|
||||
{
|
||||
buttonArrayHandle = GCHandle.Alloc(buttonPtrs, GCHandleType.Pinned);
|
||||
buttonArrayPtr = buttonArrayHandle.AddrOfPinnedObject();
|
||||
}
|
||||
|
||||
var descriptor = new WinToastNative.WNT_ToastDescriptor
|
||||
{
|
||||
title = titlePin.Pointer,
|
||||
body = bodyPin.Pointer,
|
||||
imagePath = imagePin.Pointer,
|
||||
buttonLabels = buttonArrayPtr,
|
||||
buttonCount = request.Buttons.Count,
|
||||
expirationMs = request.Expiration.HasValue ? (long)request.Expiration.Value.TotalMilliseconds : 0L,
|
||||
scenario = MapScenario(request.Urgency),
|
||||
audioOption = MapAudio(request.Audio)
|
||||
};
|
||||
|
||||
var handler = new WinToastNative.WNT_Handler
|
||||
{
|
||||
onActivated = WinToastHandlerBridge.PtrActivated,
|
||||
onButtonActivated = WinToastHandlerBridge.PtrButtonActivated,
|
||||
onDismissed = WinToastHandlerBridge.PtrDismissed,
|
||||
onFailed = WinToastHandlerBridge.PtrFailed
|
||||
};
|
||||
|
||||
long toastId = WinToastNative.WNT_ShowToast(ref descriptor, ref handler);
|
||||
|
||||
if (buttonArrayHandle.IsAllocated)
|
||||
buttonArrayHandle.Free();
|
||||
|
||||
if (toastId < 0)
|
||||
throw new NotificationException($"WNT_ShowToast failed with error code {toastId}.", (int)toastId);
|
||||
|
||||
// Register the per-notification bridge BEFORE any callback can fire.
|
||||
WinToastHandlerBridge.Register(toastId, BuildCompositeHandler(request));
|
||||
|
||||
return toastId;
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var pin in buttonPins)
|
||||
pin.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds an <see cref="INotificationHandler"/> that combines the request-level handler
|
||||
/// with per-button callbacks defined on each <see cref="Builder.NotificationButton"/>.
|
||||
/// </summary>
|
||||
private static INotificationHandler? BuildCompositeHandler(NotificationRequest request)
|
||||
{
|
||||
bool hasButtonCallbacks = false;
|
||||
foreach (var btn in request.Buttons)
|
||||
if (btn.Callback != null) { hasButtonCallbacks = true; break; }
|
||||
|
||||
if (!hasButtonCallbacks)
|
||||
return request.Handler;
|
||||
|
||||
return new CompositeHandler(request.Handler, request.Buttons);
|
||||
}
|
||||
|
||||
private static int MapScenario(NotificationUrgency urgency)
|
||||
{
|
||||
switch (urgency)
|
||||
{
|
||||
case NotificationUrgency.Alarm: return WinToastNative.WNT_SCENARIO_ALARM;
|
||||
case NotificationUrgency.Reminder: return WinToastNative.WNT_SCENARIO_REMINDER;
|
||||
default: return WinToastNative.WNT_SCENARIO_DEFAULT;
|
||||
}
|
||||
}
|
||||
|
||||
private static int MapAudio(NotificationAudio audio)
|
||||
{
|
||||
switch (audio)
|
||||
{
|
||||
case NotificationAudio.Silent: return WinToastNative.WNT_AUDIO_SILENT;
|
||||
case NotificationAudio.Loop: return WinToastNative.WNT_AUDIO_LOOP;
|
||||
default: return WinToastNative.WNT_AUDIO_DEFAULT;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Pumps pending Win32/WinRT messages on the STA thread.</summary>
|
||||
private static void PumpMessages()
|
||||
{
|
||||
NativeMessage msg;
|
||||
while (PeekMessageW(out msg, IntPtr.Zero, 0, 0, 0x0001 /* PM_REMOVE */))
|
||||
{
|
||||
TranslateMessage(ref msg);
|
||||
DispatchMessageW(ref msg);
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("user32.dll")] private static extern bool PeekMessageW(out NativeMessage msg, IntPtr hwnd, uint min, uint max, uint remove);
|
||||
[DllImport("user32.dll")] private static extern bool TranslateMessage(ref NativeMessage msg);
|
||||
[DllImport("user32.dll")] private static extern IntPtr DispatchMessageW(ref NativeMessage msg);
|
||||
|
||||
[StructLayout(LayoutKind.Sequential)]
|
||||
private struct NativeMessage
|
||||
{
|
||||
public IntPtr hwnd;
|
||||
public uint msg;
|
||||
public IntPtr wParam;
|
||||
public IntPtr lParam;
|
||||
public uint time;
|
||||
public int ptX;
|
||||
public int ptY;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Inner helpers
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a (possibly relative) image path to an absolute path and verifies
|
||||
/// the file exists. Returns null if the path is empty, unresolvable, or the
|
||||
/// file is not found — causing the native layer to skip the image rather than crash.
|
||||
/// WinToastLib embeds the path verbatim into toast XML; it cannot handle
|
||||
/// relative paths or missing files gracefully.
|
||||
/// </summary>
|
||||
private static string? ResolveImagePath(string? path)
|
||||
{
|
||||
if (string.IsNullOrEmpty(path)) return null;
|
||||
try
|
||||
{
|
||||
string absolute = Path.IsPathRooted(path) ? path : Path.GetFullPath(path);
|
||||
return File.Exists(absolute) ? absolute : null;
|
||||
}
|
||||
catch (Exception) { return null; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies a .NET string into unmanaged UTF-16 memory so it can be passed as
|
||||
/// a wchar_t* to native code. The memory is freed on Dispose.
|
||||
/// </summary>
|
||||
private sealed class PinnedString : IDisposable
|
||||
{
|
||||
public IntPtr Pointer { get; }
|
||||
|
||||
public PinnedString(string? value)
|
||||
{
|
||||
Pointer = value != null
|
||||
? Marshal.StringToHGlobalUni(value)
|
||||
: IntPtr.Zero;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (Pointer != IntPtr.Zero)
|
||||
Marshal.FreeHGlobal(Pointer);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Combines a top-level <see cref="INotificationHandler"/> with per-button callbacks.
|
||||
/// </summary>
|
||||
private sealed class CompositeHandler : INotificationHandler
|
||||
{
|
||||
private readonly INotificationHandler? _inner;
|
||||
private readonly System.Collections.Generic.IReadOnlyList<Builder.NotificationButton> _buttons;
|
||||
|
||||
public CompositeHandler(INotificationHandler? inner,
|
||||
System.Collections.Generic.IReadOnlyList<Builder.NotificationButton> buttons)
|
||||
{
|
||||
_inner = inner;
|
||||
_buttons = buttons;
|
||||
}
|
||||
|
||||
public void OnActivated(long id) => _inner?.OnActivated(id);
|
||||
|
||||
public void OnButtonActivated(long id, int index)
|
||||
{
|
||||
if (index >= 0 && index < _buttons.Count)
|
||||
_buttons[index].Callback?.Invoke(id);
|
||||
_inner?.OnButtonActivated(id, index);
|
||||
}
|
||||
|
||||
public void OnDismissed(long id, DismissReason reason) => _inner?.OnDismissed(id, reason);
|
||||
public void OnFailed(long id) => _inner?.OnFailed(id);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue