Compare commits

..

No commits in common. "main" and "0.1.0" have entirely different histories.
main ... 0.1.0

7 changed files with 28 additions and 421 deletions

View file

@ -1,58 +0,0 @@
# Changelog
## [Unreleased]
- Your change here.
[Unreleased]: https://github.com/samsonjs/NotificationSmuggler/compare/0.2.1...HEAD
## [0.2.1] - 2025-06-06
[Compare with 0.2.0](https://github.com/samsonjs/NotificationSmuggler/compare/0.2.0...0.2.1)
### Added
- Support for optional object parameter in notification posting
- Comprehensive DocC documentation with examples and best practices
- Enhanced API documentation with usage examples
### Changed
- Improved documentation throughout the codebase
- Enhanced test coverage for new functionality
## [0.2.0] - 2025-06-06
[Compare with 0.1.2](https://github.com/samsonjs/NotificationSmuggler/compare/0.1.2...0.2.0)
### Added
- [#1](https://github.com/samsonjs/NotificationSmuggler/pull/1): `NotificationCenter.smuggle` extension method for improved ergonomics - [@samsonjs](https://github.com/samsonjs).
- Better API for posting notifications directly from NotificationCenter
### Changed
- Improved logging using `os.log` instead of `NSLog`
- Enhanced overall package documentation
## [0.1.2] - 2025-04-29
[Compare with 0.1.1](https://github.com/samsonjs/NotificationSmuggler/compare/0.1.1...0.1.2)
### Changed
- Updated documentation and version references
## [0.1.1] - 2025-04-29
[Compare with 0.1.0](https://github.com/samsonjs/NotificationSmuggler/compare/0.1.0...0.1.1)
### Changed
- Fixed deployment targets for iOS 18.0+ and macOS 15.0+
- Updated README with comprehensive usage examples and documentation
## [0.1.0] - 2025-04-29
### Added
- Initial release of NotificationSmuggler
- `Smuggled` protocol for type-safe notification handling
- `Notification` and `NotificationCenter` extensions for smuggling notifications
- Support for async/await and Combine notification observation
- Swift 6 concurrency support with `Sendable` conformance
- Comprehensive test suite using Swift Testing framework
- iOS 18.0+ and macOS 15.0+ platform support

View file

@ -6,8 +6,8 @@ import PackageDescription
let package = Package( let package = Package(
name: "NotificationSmuggler", name: "NotificationSmuggler",
platforms: [ platforms: [
.iOS(.v18), .iOS(.v16),
.macOS(.v15), .macOS(.v12),
], ],
products: [ products: [
// Products define the executables and libraries a package produces, making them visible to other packages. // Products define the executables and libraries a package produces, making them visible to other packages.

View file

@ -8,12 +8,10 @@
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:object:)` and `NotificationCenter.publisher(for:object:)` 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:)` and `NotificationCenter.publisher(for:)` 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.
This package pairs nicely with [AsyncMonitor](https://github.com/samsonjs/AsyncMonitor) for a complete notification handling system in the Swift 6 concurrency world.
## Usage ## Usage
### Define a smuggled payload ### Define a smuggled payload
@ -30,17 +28,11 @@ The `Smuggled` protocol provides static `notificationName` and `userInfoKey` pro
### Post a notification ### Post a notification
```swift
NotificationCenter.default.smuggle(SomeNotification(answer: 42))
```
or
```swift ```swift
NotificationCenter.default.post(.smuggle(SomeNotification(answer: 42))) NotificationCenter.default.post(.smuggle(SomeNotification(answer: 42)))
``` ```
Both automatically set the `.name`, `userInfo`, and optionally `.object` for the notification. This automatically sets the `.name`, `userInfo`, and optionally `.object` for the notification.
### Observe and extract contraband ### Observe and extract contraband
@ -61,7 +53,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 18.0+ and macOS 15.0+. This package is supported on iOS 16.0+ and macOS 12.0+.
### Xcode ### Xcode
@ -72,7 +64,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.1")) .package(url: "https://github.com/samsonjs/NotificationSmuggler.git", .upToNextMajor(from: "0.1.0"))
``` ```
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

@ -1,100 +0,0 @@
# 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,51 +1,14 @@
import Foundation import Foundation
/// A marker protocol for types that represent notifications with associated data. /// A marker protocol for types that represent notifications with associated data. Conforming types gain a default notification name and
/// 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.
/// ///
/// Conforming types automatically gain a default notification name and user-info key to facilitate /// If you want to extract the contraband manually you can use the extension method ``Notification.smuggled()``.
/// type-safe notification handling. The protocol enables strongly-typed notification posting and
/// observation without manual `userInfo` dictionary manipulation.
/// ///
/// ## Basic Usage /// When smuggling notifications you can use ``Notification.smuggle(:object:)`` to build up a notification with the correct
/// /// user info automatically.
/// 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

@ -1,35 +1,13 @@
import Combine import Combine
import Foundation import Foundation
import os.log
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 embed in the notification. /// - contraband: The `Smuggled` value to send.
/// - object: An optional sender object to associate with the notification. /// - object: An optional sender object.
/// - 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
@ -43,36 +21,14 @@ 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`.
/// ///
/// This method performs type-safe extraction by looking for the value using the type's /// - Returns: The extracted `Smuggled` value when found, otherwise `nil`.
/// ``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)") NSLog("[\(Contraband.self)] Value not found in userInfo[\"\(Contraband.userInfoKey)\"]")
return nil return nil
} }
guard let contraband = instance as? Contraband else { guard let contraband = instance as? Contraband else {
log.error("Failed to cast \(String(describing: instance)) as \(Contraband.notificationName.rawValue)") NSLog("[\(Contraband.self)] Failed to cast \(instance) as \(Contraband.self)")
return nil return nil
} }
@ -83,105 +39,27 @@ public extension Notification {
// MARK: - // MARK: -
public extension NotificationCenter { public extension NotificationCenter {
/// Posts a notification that smuggles the given `Smuggled` value.
///
/// This is a convenience method that combines ``Notification.smuggle(_:object:)``
/// and `post(_:)` into a single operation.
///
/// ## 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.
/// ///
/// This method provides async/await-compatible observation of notifications. It automatically /// Each element of the sequence is a `Smuggled` value.
/// filters for the specified type and extracts the values from `userInfo`, yielding only
/// successfully extracted values.
/// ///
/// ## Example /// - Parameter contrabandType: The `Smuggled` type to observe..
/// /// - 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, object: object) notifications(named: Contraband.notificationName)
.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.
/// ///
/// This method provides Combine-compatible observation of notifications from a specific sender. /// - Parameter contrabandType: The `Smuggled` type to observe.
/// 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, object: object) publisher(for: contrabandType.notificationName)
.compactMap { $0.smuggled() } .compactMap { $0.smuggled() }
.eraseToAnyPublisher() .eraseToAnyPublisher()
} }

View file

@ -42,22 +42,6 @@ struct SmugglingTests {
// MARK: NotificationCenter extensions // MARK: NotificationCenter extensions
@Test(.timeLimit(.minutes(1)))
@MainActor func notificationCenterSmuggle() async {
let center = NotificationCenter()
let task = Task {
for await contraband in center.notifications(for: HitchhikersNotification.self) {
#expect(contraband.answer == 42)
return
}
}
await Task.yield()
let contraband = HitchhikersNotification(answer: 42)
center.smuggle(contraband)
await task.value
}
// It's important that the tests and the notification-observing tasks are not on the same actor, // It's important that the tests and the notification-observing tasks are not on the same actor,
// so we make the tests @MainActor and observe notifications on another actor. Otherwise it's // so we make the tests @MainActor and observe notifications on another actor. Otherwise it's
// a deadlock. // a deadlock.
@ -76,7 +60,7 @@ struct SmugglingTests {
await Task.yield() await Task.yield()
let contraband = HitchhikersNotification(answer: 42) let contraband = HitchhikersNotification(answer: 42)
center.smuggle(contraband) center.post(.smuggle(contraband))
while !received { await Task.yield() } while !received { await Task.yield() }
} }
@ -117,7 +101,7 @@ struct SmugglingTests {
await Task.yield() await Task.yield()
let contraband = HitchhikersNotification(answer: 42) let contraband = HitchhikersNotification(answer: 42)
center.smuggle(contraband) center.post(.smuggle(contraband))
while !received { await Task.yield() } while !received { await Task.yield() }
} }
@ -144,56 +128,4 @@ 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() }
}
} }