From 46a355cfbaa3f19b751015d8f7c22ec653d189ca Mon Sep 17 00:00:00 2001 From: Sami Samhuri Date: Fri, 6 Jun 2025 14:26:43 -0700 Subject: [PATCH] Write a post about AsyncMonitor and NotificationSmuggler --- ...nd-async-stream-monitoring-with-swift-6.md | 144 ++++++++++++++++++ 1 file changed, 144 insertions(+) create mode 100644 posts/2025/06/type-safe-notifications-and-async-stream-monitoring-with-swift-6.md diff --git a/posts/2025/06/type-safe-notifications-and-async-stream-monitoring-with-swift-6.md b/posts/2025/06/type-safe-notifications-and-async-stream-monitoring-with-swift-6.md new file mode 100644 index 0000000..5876881 --- /dev/null +++ b/posts/2025/06/type-safe-notifications-and-async-stream-monitoring-with-swift-6.md @@ -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)