Write a post about AsyncMonitor and NotificationSmuggler

This commit is contained in:
Sami Samhuri 2025-06-06 14:26:43 -07:00
parent eba6e2c12f
commit 46a355cfba
No known key found for this signature in database

View file

@ -0,0 +1,144 @@
---
Author: Sami Samhuri
Title: Type-safe notifications and async stream monitoring with Swift 6
Date: 6th June, 2025
Timestamp: 2025-06-06T14:27:11-07:00
Tags: Swift, iOS, notifications, async, concurrency, AsyncMonitor, NotificationSmuggler
---
Swift 6 concurrency checking made handling notifications without warnings kinda tedious. The old Combine approach doesn't work with `@Sendable` closures and manually managing tasks gets repetitive. I made a couple of tiny Swift packages to help out with the situation: [AsyncMonitor](https://github.com/samsonjs/AsyncMonitor) which wraps task management, and [NotificationSmuggler](https://github.com/samsonjs/NotificationSmuggler) which adds a type-safe interface on top of `Notification` and `NotificationCenter`.
## The manual approach
Here's what I was writing over and over:
```swift
// Causes Sendable warnings in Swift 6
let task = Task { [weak self] in
for await notification in NotificationCenter.default.notifications(named: .NSCalendarDayChanged) {
guard let self else { return }
await self.handleDayChange()
}
}
// Store it somewhere, hope you remember to cancel it...
```
And when you have a bunch of these then you wind up with lots of properties to track them and other boilerplate.
## AsyncMonitor
[AsyncMonitor](https://github.com/samsonjs/AsyncMonitor) wraps the task lifecycle. Instead of managing tasks manually you use streams like Combine publishers:
```swift
import AsyncMonitor
let cancellable = NotificationCenter.default
.notifications(named: .NSCalendarDayChanged)
.map(\.name)
.monitor { _ in
print("The date is now \(Date.now)")
}
```
The `monitor` method creates a `Task` internally and handles cleanup when the cancellable goes out of scope. The closure is async so you can await in it.
You're free to do the usual `[weak self]` and `guard let self else { return }` dance, but there's a variant that accepts a context parameter that's automatically weakified. Your closure receives a strong reference:
```swift
NotificationCenter.default
.notifications(named: .NSCalendarDayChanged)
.map(\.name)
.monitor(context: self) { _self, _ in
_self.dayChanged()
}.store(in: &cancellables)
```
The monitor finishes automatically after finding a nil context.
### KVO
Sometimes you need KVO and the old ways are best. There's a KVO extension that bridges to async sequences:
```swift
// AVPlayer's Combine publisher for rate doesn't publish all the values
player.monitorValues(for: \.rate) { rate in
print("Player rate: \(rate)")
}.store(in: &cancellables)
```
### What about Combine?
Combine works but doesn't mesh well with Swift 6 concurrency. The `@Sendable` requirements make it annoying. You can write the Task code manually but it gets repetitive when you have a lot of observers.
## NotificationSmuggler
[NotificationSmuggler](https://github.com/samsonjs/NotificationSmuggler) solves a different problem: type-safe notifications. No more dumpster diving in `userInfo`.
Define your contraband:
```swift
import NotificationSmuggler
struct ProjectExportComplete: Smuggled, Sendable {
let projectID: String
let outputURL: URL
}
```
The `Smuggled` protocol generates a unique notification name and userInfo key from your type name.
Smuggle your illicit goods like so:
```swift
NotificationCenter.default.smuggle(ProjectExportComplete(projectID: "project-123", outputURL: exportURL))
// which is short for
NotificationCenter.default.post(.smuggle(ProjectExportComplete(projectID: "project-123", outputURL: exportURL)))
```
And on the other side it's as easy as any other notification using an extension on `NotificationCenter`:
```swift
for await notification in NotificationCenter.default.notifications(for: ProjectExportComplete.self) {
print("Project \(notification.projectID) exported to \(notification.outputURL)")
}
```
## Using them together
They work well together. Here's a more full example:
```swift
import AsyncMonitor
import NotificationSmuggler
struct BackupCompleteNotification: Smuggled, Sendable {
let success: Bool
let totalSize: Int64
}
NotificationCenter.default
.notifications(for: BackupCompleteNotification.self)
.monitor(context: self) { _self, notification in
_self.updateBackupStatus(success: notification.success)
}.store(in: &cancellables)
// elsewhere
NotificationCenter.default.smuggle(BackupCompleteNotification(success: true, totalSize: 42))
```
## That's it
Both libraries are small and focused. AsyncMonitor is about 100 lines, NotificationSmuggler is smaller. Zero dependencies.
AsyncMonitor requires iOS 17. It supports both iOS 17 and iOS 18 with different initializers due to changes in inheriting actor isolation.
- [AsyncMonitor on GitHub](https://github.com/samsonjs/AsyncMonitor)
- [AsyncMonitor on Swift Package Index](https://swiftpackageindex.com/samsonjs/AsyncMonitor)
NotificationSmuggler requires iOS 17.
- [NotificationSmuggler on GitHub](https://github.com/samsonjs/NotificationSmuggler)
- [NotificationSmuggler on Swift Package Index](https://swiftpackageindex.com/samsonjs/NotificationSmuggler)