Add support for optional object param and add more documentation

This commit is contained in:
Sami Samhuri 2025-06-06 17:13:23 -07:00
parent 94845eb55c
commit 17a78eb8d0
No known key found for this signature in database
5 changed files with 325 additions and 24 deletions

View file

@ -8,7 +8,7 @@
NotificationSmuggler is a tiny Swift package that makes it easy to embed strongly-typed values in `Notification`s, and extract them out on the receiving end as well. Nothing elaborate, it sneaks the contraband in the `userInfo` dictionary. NotificationSmuggler is a tiny Swift package that makes it easy to embed strongly-typed values in `Notification`s, and extract them out on the receiving end as well. Nothing elaborate, it sneaks the contraband in the `userInfo` dictionary.
Declare a type conforming to `Smuggled` and then use the static method `Notification.smuggle(_:object:)` when posting the notification. On the receiving side of things you can use the extension methods `NotificationCenter.notifications(for:)` and `NotificationCenter.publisher(for:)` to observe the strongly-typed values without manually mapping them yourself. Declare a type conforming to `Smuggled` and then use the static method `Notification.smuggle(_:object:)` when posting the notification. On the receiving side of things you can use the extension methods `NotificationCenter.notifications(for:object:)` and `NotificationCenter.publisher(for:object:)` to observe the strongly-typed values without manually mapping them yourself.
If you have `Sendable` contraband then all of this will work nicely with Swift 6 and complete concurrency checking. If you have `Sendable` contraband then all of this will work nicely with Swift 6 and complete concurrency checking.
@ -61,7 +61,7 @@ The only way to install this package is with Swift Package Manager (SPM). Please
### Supported Platforms ### Supported Platforms
This package is supported on iOS 16.0+ and macOS 12.0+. This package is supported on iOS 18.0+ and macOS 15.0+.
### Xcode ### Xcode
@ -72,7 +72,7 @@ When you're integrating this into an app with Xcode then go to your project's Pa
When you're integrating this using SPM on its own then add this to the list of dependencies your Package.swift file: When you're integrating this using SPM on its own then add this to the list of dependencies your Package.swift file:
```swift ```swift
.package(url: "https://github.com/samsonjs/NotificationSmuggler.git", .upToNextMajor(from: "0.2.0")) .package(url: "https://github.com/samsonjs/NotificationSmuggler.git", .upToNextMajor(from: "0.2.1"))
``` ```
and then add `"NotificationSmuggler"` to the list of dependencies in your target as well. and then add `"NotificationSmuggler"` to the list of dependencies in your target as well.

View file

@ -0,0 +1,100 @@
# NotificationSmuggler
A Swift 6 package for type-safe notification handling with strongly-typed values with strict concurrency, using async/await or Combine publishers.
Never touch a `userInfo` dictionary again for your own notifications.
## Overview
NotificationSmuggler makes it easy to embed strongly-typed values in `Notification`s and extract them on the receiving end. It "smuggles" your contraband payload through the `userInfo` dictionary while providing a clean, type-safe API.
Each conforming type gets its own unique notification name and userInfo key, automatically generated from the type name. There are convenience methods for posting and observing your contraband. Structs are recommended because you may want these to be `Sendable`, and inheritance isn't supported so hierarchies may pose pitfalls if you try to get fancy.
## Getting Started
### Define your contraband
Create a type that conforms to the ``Smuggled`` protocol:
```swift
struct AccountAuthenticated: Smuggled, Sendable {
let accountID: String
let timestamp: Date
}
```
### Post Notifications
Use the convenience method to post directly:
```swift
NotificationCenter.default.smuggle(AccountAuthenticatedNotification(
accountID: "abc123",
timestamp: .now
))
```
Or create a notification first if that's more convenient:
```swift
let notification = Notification.smuggle(AccountAuthenticatedNotification(
accountID: "abc123",
timestamp: .now
))
NotificationCenter.default.post(notification)
```
### Observe Notifications
With async/await (recommended for Sendable types):
```swift
Task {
for await authenticated in NotificationCenter.default.notifications(for: AccountAuthenticatedNotification.self) {
print("Account \(authenticated.accountID) authenticated at \(authenticated.timestamp)")
}
}
```
With Combine:
```swift
NotificationCenter.default.publisher(for: AccountAuthenticatedNotification.self)
.sink { authenticated in
print("Account \(authenticated.accountID) authenticated")
}
.store(in: &cancellables)
```
### Object-Specific Observation
Filter notifications by sender object:
```swift
let document = AwesomeDocument()
// Only receive notifications from this specific document
for await saved in NotificationCenter.default.notifications(for: DocumentSavedNotification.self, object: document) {
print("Document saved: \(saved.filename)")
}
```
## Topics
### Essential Types
- ``Smuggled``
### Notification Creation
- ``Foundation/Notification/smuggle(_:object:)``
- ``Foundation/NotificationCenter/smuggle(_:object:)``
### Notification Observation
- ``Foundation/NotificationCenter/notifications(for:object:)``
- ``Foundation/NotificationCenter/publisher(for:object:)``
### Extracting Values
- ``Foundation/Notification/smuggled()``

View file

@ -1,14 +1,51 @@
import Foundation import Foundation
/// A marker protocol for types that represent notifications with associated data. Conforming types gain a default notification name and /// A marker protocol for types that represent notifications with associated data.
/// user info key to facilitate smuggling. They can be used with extension methods like
/// ``NotificationCenter.notifications(for:)`` and ``NotificationCenter.publisher(for:)`` which
/// automatically extract and cast this type from user info.
/// ///
/// If you want to extract the contraband manually you can use the extension method ``Notification.smuggled()``. /// Conforming types automatically gain a default notification name and user-info key to facilitate
/// type-safe notification handling. The protocol enables strongly-typed notification posting and
/// observation without manual `userInfo` dictionary manipulation.
/// ///
/// When smuggling notifications you can use ``Notification.smuggle(:object:)`` to build up a notification with the correct /// ## Basic Usage
/// user info automatically, or use ``NotificationCenter.smuggle(_:)`` as a convenience method that posts the notification directly. ///
/// Define a notification type:
///
/// ```swift
/// struct AccountAuthenticatedNotification: Smuggled, Sendable {
/// let accountID: String
/// let timestamp: Date
/// }
/// ```
///
/// Post the notification:
///
/// ```swift
/// NotificationCenter.default.smuggle(AccountAuthenticatedNotification(
/// accountID: "abc123",
/// timestamp: .now
/// ))
/// ```
///
/// Observe notifications:
///
/// ```swift
/// for await authentication in NotificationCenter.default.notifications(for: AccountAuthenticatedNotification.self) {
/// print("Account \(authentication.accountID) authenticated")
/// }
/// ```
///
/// ## Sendable Considerations
///
/// For Swift 6 concurrency, consider making your notification types `Sendable` when they might
/// cross actor boundaries. Value types with `Sendable` properties are automatically `Sendable`.
///
/// ## Available Methods
///
/// - ``NotificationCenter.smuggle(_:object:)`` - Post notification directly
/// - ``Notification.smuggle(_:object:)`` - Create notification instance
/// - ``NotificationCenter.notifications(for:object:)`` - Async observation
/// - ``NotificationCenter.publisher(for:object:)`` - Combine observation
/// - ``Notification.smuggled()`` - Manual extraction
public protocol Smuggled {} public protocol Smuggled {}
public extension Smuggled { public extension Smuggled {

View file

@ -7,10 +7,29 @@ private let log = Logger(subsystem: "NotificationSmuggler", category: "smuggling
public extension Notification { public extension Notification {
/// Creates a `Notification` instance that smuggles the given `Smuggled` value. /// Creates a `Notification` instance that smuggles the given `Smuggled` value.
/// ///
/// This method automatically configures the notification's `name` using the type's
/// ``Smuggled/notificationName`` and embeds the value in `userInfo` using the type's
/// ``Smuggled/userInfoKey``.
///
/// ## Example
///
/// ```swift
/// struct CoolEvent: Smuggled {
/// let message: String
/// }
///
/// let notification = Notification.smuggle(CoolEvent(message: "Hello"))
/// NotificationCenter.default.post(notification)
/// ```
///
/// - Parameters: /// - Parameters:
/// - contraband: The `Smuggled` value to send. /// - contraband: The `Smuggled` value to embed in the notification.
/// - object: An optional sender object. /// - object: An optional sender object to associate with the notification.
/// - Returns: A configured `Notification` with `name`, `object`, and `userInfo`. /// - Returns: A configured `Notification` with `name`, `object`, and `userInfo`.
///
/// ## See Also
///
/// - ``NotificationCenter.smuggle(_:object:)``
static func smuggle<Contraband: Smuggled>( static func smuggle<Contraband: Smuggled>(
_ contraband: Contraband, _ contraband: Contraband,
object: Any? = nil object: Any? = nil
@ -24,7 +43,29 @@ public extension Notification {
/// Extracts a `Smuggled` value (contraband) of the specified type from this `Notification`'s `userInfo`. /// Extracts a `Smuggled` value (contraband) of the specified type from this `Notification`'s `userInfo`.
/// ///
/// - Returns: The extracted `Smuggled` value when found, otherwise `nil`. /// This method performs type-safe extraction by looking for the value using the type's
/// ``Smuggled/userInfoKey`` and attempting to cast it to the expected type.
///
/// ## Example
///
/// ```swift
/// func handleNotification(_ notification: Notification) {
/// if let event: MyEvent = notification.smuggled() {
/// print("Received: \(event.message)")
/// }
/// }
/// ```
///
/// ## Error Handling
///
/// - Missing values and type-casting failures are logged at `error` level since that means you tried something fancy and messed it up. No offence, them's the facts.
///
/// - Returns: The extracted `Smuggled` value when found and properly typed, otherwise `nil`.
///
/// ## See Also
///
/// - ``NotificationCenter.notifications(for:object:)``
/// - ``NotificationCenter.publisher(for:object:)``
func smuggled<Contraband: Smuggled>() -> Contraband? { func smuggled<Contraband: Smuggled>() -> Contraband? {
guard let instance = userInfo?[Contraband.userInfoKey] else { guard let instance = userInfo?[Contraband.userInfoKey] else {
log.error("Value not found in userInfo[\"\(Contraband.userInfoKey)\"] for \(Contraband.notificationName.rawValue)") log.error("Value not found in userInfo[\"\(Contraband.userInfoKey)\"] for \(Contraband.notificationName.rawValue)")
@ -44,32 +85,103 @@ public extension Notification {
public extension NotificationCenter { public extension NotificationCenter {
/// Posts a notification that smuggles the given `Smuggled` value. /// Posts a notification that smuggles the given `Smuggled` value.
/// ///
/// - Parameter contraband: The `Smuggled` value to send. /// This is a convenience method that combines ``Notification.smuggle(_:object:)``
func smuggle<Contraband: Smuggled>(_ contraband: Contraband) { /// and `post(_:)` into a single operation.
post(.smuggle(contraband)) ///
/// ## Example
///
/// ```swift
/// struct AccountAuthenticated: Smuggled {
/// let accountID: String
/// }
///
/// // Instead of:
/// // NotificationCenter.default.post(.smuggle(AccountAuthenticated(accountID: "abc123")))
///
/// // You can write:
/// NotificationCenter.default.smuggle(AccountAuthenticated(accountID: "abc123"))
/// ```
///
/// - Parameters:
/// - contraband: The `Smuggled` value to send.
/// - object: An optional sender object to associate with the notification.
///
/// ## See Also
///
/// - ``Notification.smuggle(_:object:)``
func smuggle<Contraband: Smuggled>(_ contraband: Contraband, object: Any? = nil) {
post(.smuggle(contraband, object: object))
} }
/// Returns an `AsyncSequence` of notifications of a specific `Smuggled` type. /// Returns an `AsyncSequence` of notifications of a specific `Smuggled` type.
/// ///
/// Each element of the sequence is a `Smuggled` value. /// This method provides async/await-compatible observation of notifications. It automatically
/// filters for the specified type and extracts the values from `userInfo`, yielding only
/// successfully extracted values.
/// ///
/// - Parameter contrabandType: The `Smuggled` type to observe.. /// ## Example
/// - Returns: An `AsyncSequence` of `NotificationSmuggler` values. ///
/// ```swift
/// struct NetworkStatusChanged: Smuggled, Sendable {
/// let isOnline: Bool
/// }
///
/// Task {
/// for await status in NotificationCenter.default.notifications(for: NetworkStatusChanged.self) {
/// print("Network is \(status.isOnline ? "online" : "offline")")
/// }
/// }
/// ```
///
/// - Parameter contrabandType: The `Smuggled` type to observe.
/// - Returns: An `AsyncSequence` that yields extracted notification values.
///
/// ## See Also
///
/// - ``publisher(for:object:)``
func notifications<Contraband: Smuggled>( func notifications<Contraband: Smuggled>(
for contrabandType: Contraband.Type for contrabandType: Contraband.Type,
object: (AnyObject & Sendable)? = nil
) -> any AsyncSequence<Contraband, Never> { ) -> any AsyncSequence<Contraband, Never> {
notifications(named: Contraband.notificationName) notifications(named: Contraband.notificationName, object: object)
.compactMap { $0.smuggled() } .compactMap { $0.smuggled() }
} }
/// Returns a Combine publisher that emits `Smuggled` values of the given type. /// Returns a Combine publisher that emits `Smuggled` values of the given type.
/// ///
/// - Parameter contrabandType: The `Smuggled` type to observe. /// This method provides Combine-compatible observation of notifications from a specific sender.
/// It automatically filters for the specified type and object, extracting values from `userInfo`.
///
/// ## Example
///
/// ```swift
/// struct DocumentSavedNotification: Smuggled {
/// let filename: String
/// }
///
/// let document = AwesomeDocument()
/// var cancellables = Set<AnyCancellable>()
///
/// NotificationCenter.default.publisher(for: DocumentSavedNotification.self, object: document)
/// .sink { saved in
/// print("Document \(saved.filename) was saved")
/// }
/// .store(in: &cancellables)
/// ```
///
/// - Parameters:
/// - contrabandType: The `Smuggled` type to observe.
/// - object: The optional object whose notifications you want to receive. Must be a class instance.
/// - Returns: A publisher emitting `Smuggled` values. /// - Returns: A publisher emitting `Smuggled` values.
///
/// ## See Also
///
/// - ``notifications(for:object:)``
func publisher<Contraband: Smuggled>( func publisher<Contraband: Smuggled>(
for contrabandType: Contraband.Type for contrabandType: Contraband.Type,
object: AnyObject? = nil
) -> AnyPublisher<Contraband, Never> { ) -> AnyPublisher<Contraband, Never> {
publisher(for: contrabandType.notificationName) publisher(for: contrabandType.notificationName, object: object)
.compactMap { $0.smuggled() } .compactMap { $0.smuggled() }
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }

View file

@ -144,4 +144,56 @@ struct SmugglingTests {
try await Task.sleep(for: .milliseconds(10)) try await Task.sleep(for: .milliseconds(10))
#expect(!received) #expect(!received)
} }
// MARK: Object-specific notifications
final class SenderObject: NSObject, Sendable {}
@Test(.timeLimit(.minutes(1)))
@MainActor func notificationCenterAsyncSequenceWithObject() async throws {
let center = NotificationCenter()
let senderObject = SenderObject()
let decoyObject = NSObject()
let task = Task {
for await contraband in center.notifications(for: HitchhikersNotification.self, object: senderObject) {
#expect(contraband.answer == 42)
return
}
}
await Task.yield()
// Post from decay object, should be ignored
center.post(.smuggle(HitchhikersNotification(answer: 99), object: decoyObject))
try await Task.sleep(for: .milliseconds(10))
// Post from sender object, should be received
center.post(.smuggle(HitchhikersNotification(answer: 42), object: senderObject))
await task.value
}
@Test(.timeLimit(.minutes(1)))
@MainActor func notificationCenterPublisherWithObject() async throws {
var cancellables = Set<AnyCancellable>()
let center = NotificationCenter()
let senderObject = SenderObject()
let decoyObject = NSObject()
nonisolated(unsafe) var received = false
center.publisher(for: HitchhikersNotification.self, object: senderObject)
.sink { contraband in
#expect(contraband.answer == 42)
received = true
}.store(in: &cancellables)
await Task.yield()
// Post from decoy object, should be ignored
center.post(.smuggle(HitchhikersNotification(answer: 99), object: decoyObject))
try await Task.sleep(for: .milliseconds(10))
#expect(!received)
// Post from sender object, should be received
center.post(.smuggle(HitchhikersNotification(answer: 42), object: senderObject))
while !received { await Task.yield() }
}
} }