Skip to content

Commit 7d1e456

Browse files
committed
Flesh out the readme and add more tests
1 parent bc05e17 commit 7d1e456

13 files changed

+280
-59
lines changed

Readme.md

Lines changed: 72 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -12,57 +12,33 @@ It uses a Swift `Task` to ensure that all resources are properly cleaned up when
1212

1313
That's it. It's pretty trivial. I just got tired of writing it over and over, mainly for notifications. You still have to map your `Notification`s to something sendable.
1414

15-
## Installation
16-
17-
The only way to install this package is with Swift Package Manager (SPM). Please [file a new issue][] or submit a pull-request if you want to use something else.
18-
19-
[file a new issue]: https://github.com/samsonjs/AsyncMonitor/issues/new
20-
21-
### Supported Platforms
22-
23-
This package is supported on iOS 18.0+ and macOS 15.0+.
24-
25-
### Xcode
26-
27-
When you're integrating this into an app with Xcode then go to your project's Package Dependencies and enter the URL `https://github.com/samsonjs/AsyncMonitor` and then go through the usual flow for adding packages.
28-
29-
### Swift Package Manager (SPM)
30-
31-
When you're integrating this using SPM on its own then add this to the list of dependencies your Package.swift file:
32-
33-
```swift
34-
.package(url: "https://github.com/samsonjs/AsyncMonitor.git", .upToNextMajor(from: "0.1.1"))
35-
```
36-
37-
and then add `"AsyncMonitor"` to the list of dependencies in your target as well.
38-
3915
## Usage
4016

41-
The simplest example uses a closure that receives the notification:
17+
The simplest example uses a closure that receives the notification. The closure is async so you can await in there if you need to.
4218

4319
```swift
4420
import AsyncMonitor
4521

4622
class SimplestVersion {
4723
let cancellable = NotificationCenter.default
48-
.notifications(named: .NSCalendarDayChanged).map(\.name)
24+
.notifications(named: .NSCalendarDayChanged)
25+
.map(\.name)
4926
.monitor { _ in
5027
print("The date is now \(Date.now)")
5128
}
5229
}
5330
```
5431

55-
This example uses the context parameter to avoid reference cycles with `self`:
32+
This example uses the context parameter to avoid reference cycles with `self`.
5633

5734
```swift
58-
import AsyncMonitor
59-
6035
class WithContext {
6136
var cancellables = Set<AnyAsyncCancellable>()
6237

6338
init() {
6439
NotificationCenter.default
65-
.notifications(named: .NSCalendarDayChanged).map(\.name)
40+
.notifications(named: .NSCalendarDayChanged)
41+
.map(\.name)
6642
.monitor(context: self) { _self, _ in
6743
_self.dayChanged()
6844
}.store(in: &cancellables)
@@ -74,7 +50,72 @@ class WithContext {
7450
}
7551
```
7652

77-
The closure is async so you can await in there if you need to.
53+
### Combine
54+
55+
Working with Combine publishers is trivial thanks to [`AnyPublisher.values`][values].
56+
57+
```swift
58+
import Combine
59+
60+
class CombineExample {
61+
var cancellables = Set<AnyAsyncCancellable>()
62+
63+
init() {
64+
Timer.publish(every: 1.0, on: .main, in: .common)
65+
.autoconnect()
66+
.values
67+
.monitor { date in
68+
print("Timer fired at \(date)")
69+
}
70+
.store(in: &cancellables)
71+
}
72+
}
73+
```
74+
75+
[values]: https://developer.apple.com/documentation/combine/anypublisher/values-3s2uy
76+
77+
### Key-Value Observing (KVO) extension
78+
79+
When you need to observe an object that uses [KVO][] there's an extension method you can use to monitor it:
80+
81+
```swift
82+
class KVOExample {
83+
var cancellables = Set<AnyAsyncCancellable>()
84+
85+
init() {
86+
let progress = Progress(totalUnitCount: 42)
87+
progress.values(for: \.fractionCompleted) { fraction in
88+
print("Progress is \(fraction.formatted(.percent))%")
89+
}.store(in: &cancellables)
90+
}
91+
}
92+
```
93+
94+
[KVO]: https://developer.apple.com/library/archive/documentation/General/Conceptual/DevPedia-CocoaCore/KVO.html
95+
96+
## Installation
97+
98+
The only way to install this package is with Swift Package Manager (SPM). Please [file a new issue][] or submit a pull-request if you want to use something else.
99+
100+
[file a new issue]: https://github.com/samsonjs/AsyncMonitor/issues/new
101+
102+
### Supported Platforms
103+
104+
This package is supported on iOS 18.0+ and macOS 15.0+.
105+
106+
### Xcode
107+
108+
When you're integrating this into an app with Xcode then go to your project's Package Dependencies and enter the URL `https://github.com/samsonjs/AsyncMonitor` and then go through the usual flow for adding packages.
109+
110+
### Swift Package Manager (SPM)
111+
112+
When you're integrating this using SPM on its own then add this to the list of dependencies your Package.swift file:
113+
114+
```swift
115+
.package(url: "https://github.com/samsonjs/AsyncMonitor.git", .upToNextMajor(from: "0.1.1"))
116+
```
117+
118+
and then add `"AsyncMonitor"` to the list of dependencies in your target as well.
78119

79120
## License
80121

Sources/AsyncMonitor/AnyAsyncCancellable.swift

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
/// Type-erasing wrapper for ``AsyncCancellable`` that ties its instance lifetime to cancellation. In other words, when you release
22
/// an instance of ``AnyAsyncCancellable`` and it's deallocated then it automatically cancels its given ``AsyncCancellable``.
33
public class AnyAsyncCancellable: AsyncCancellable {
4+
lazy var id = ObjectIdentifier(self)
5+
46
let canceller: () -> Void
57

68
public init<AC: AsyncCancellable>(cancellable: AC) {
@@ -16,4 +18,14 @@ public class AnyAsyncCancellable: AsyncCancellable {
1618
public func cancel() {
1719
canceller()
1820
}
21+
22+
// MARK: Hashable conformance
23+
24+
public static func == (lhs: AnyAsyncCancellable, rhs: AnyAsyncCancellable) -> Bool {
25+
lhs.id == rhs.id
26+
}
27+
28+
public func hash(into hasher: inout Hasher) {
29+
hasher.combine(id)
30+
}
1931
}

Sources/AsyncMonitor/AsyncCancellable.swift

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,15 +15,3 @@ public extension AsyncCancellable {
1515
set.insert(AnyAsyncCancellable(cancellable: self))
1616
}
1717
}
18-
19-
// MARK: Hashable conformance
20-
21-
public extension AsyncCancellable {
22-
static func == (lhs: Self, rhs: Self) -> Bool {
23-
ObjectIdentifier(lhs) == ObjectIdentifier(rhs)
24-
}
25-
26-
func hash(into hasher: inout Hasher) {
27-
hasher.combine(ObjectIdentifier(self))
28-
}
29-
}

Sources/AsyncMonitor/AsyncMonitor.swift

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
///
66
/// ```
77
/// NotificationCenter.default
8-
/// .notifications(named: .NSCalendarDayChanged).map(\.name)
8+
/// .notifications(named: .NSCalendarDayChanged)
9+
/// .map(\.name)
910
/// .monitor { _ in whatever() }
1011
/// .store(in: &cancellables)
1112
/// ```
@@ -43,4 +44,14 @@ public final class AsyncMonitor: Hashable, AsyncCancellable {
4344
public func cancel() {
4445
task.cancel()
4546
}
47+
48+
// MARK: Hashable conformance
49+
50+
public static func == (lhs: AsyncMonitor, rhs: AsyncMonitor) -> Bool {
51+
lhs.task == rhs.task
52+
}
53+
54+
public func hash(into hasher: inout Hasher) {
55+
hasher.combine(task)
56+
}
4657
}

Sources/AsyncMonitor/NSObject+AsyncKVO.swift

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,12 @@ public extension NSObjectProtocol where Self: NSObject {
1818
let token = self.observe(keyPath, options: options) { object, _ in
1919
continuation.yield(object[keyPath: keyPath])
2020
}
21+
let locker = TokenLocker(token: token)
22+
continuation.onTermination = { _ in
23+
locker.clear()
24+
}
2125
return stream.monitor { value in
22-
_ = token // keep this alive
26+
_ = locker // keep this alive
2327
changeHandler(value)
2428
}
2529
}
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import Foundation
2+
3+
final class TokenLocker: @unchecked Sendable {
4+
private let lock = NSLock()
5+
private var unsafeToken: NSKeyValueObservation?
6+
7+
init(token: NSKeyValueObservation) {
8+
unsafeToken = token
9+
}
10+
11+
func clear() {
12+
lock.withLock {
13+
unsafeToken = nil
14+
}
15+
}
16+
}
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
@testable import AsyncMonitor
2+
import Testing
3+
4+
@MainActor class AnyAsyncCancellableTests {
5+
var subject: AnyAsyncCancellable!
6+
7+
@Test func cancelsWhenReleased() {
8+
let cancellable = TestCancellable()
9+
subject = AnyAsyncCancellable(cancellable: cancellable)
10+
#expect(!cancellable.isCancelled)
11+
subject = nil
12+
#expect(cancellable.isCancelled)
13+
}
14+
15+
@Test func cancelsWhenCancelled() {
16+
let cancellable = TestCancellable()
17+
subject = AnyAsyncCancellable(cancellable: cancellable)
18+
#expect(!cancellable.isCancelled)
19+
subject.cancel()
20+
#expect(cancellable.isCancelled)
21+
}
22+
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
@testable import AsyncMonitor
2+
import Testing
3+
4+
@MainActor class AsyncCancellableTests {
5+
var cancellables = Set<AnyAsyncCancellable>()
6+
7+
@Test func storeInsertsIntoSetAndKeepsSubjectAlive() throws {
8+
var subject: TestCancellable? = TestCancellable()
9+
weak var weakSubject: TestCancellable? = subject
10+
try #require(subject).store(in: &cancellables)
11+
#expect(cancellables.count == 1)
12+
subject = nil
13+
#expect(weakSubject != nil)
14+
cancellables.removeAll()
15+
#expect(weakSubject == nil)
16+
}
17+
}

Tests/AsyncMonitorTests/AsyncMonitorTests.swift

Lines changed: 37 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,30 +10,36 @@ class AsyncMonitorTests {
1010

1111
@Test func callsBlockWhenNotificationsArePosted() async throws {
1212
await withCheckedContinuation { [center, name] continuation in
13-
subject = center.notifications(named: name).map(\.name).monitor { receivedName in
14-
#expect(name == receivedName)
15-
continuation.resume()
16-
}
13+
subject = center.notifications(named: name)
14+
.map(\.name)
15+
.monitor { receivedName in
16+
#expect(name == receivedName)
17+
continuation.resume()
18+
}
1719
Task {
1820
center.post(name: name, object: nil)
1921
}
2022
}
2123
}
2224

2325
@Test func doesNotCallBlockWhenOtherNotificationsArePosted() async throws {
24-
subject = center.notifications(named: name).map(\.name).monitor { receivedName in
25-
Issue.record("Called for irrelevant notification \(receivedName)")
26-
}
26+
subject = center.notifications(named: name)
27+
.map(\.name)
28+
.monitor { receivedName in
29+
Issue.record("Called for irrelevant notification \(receivedName)")
30+
}
2731
Task { [center] in
2832
center.post(name: Notification.Name("something else"), object: nil)
2933
}
3034
try await Task.sleep(for: .milliseconds(10))
3135
}
3236

3337
@Test @MainActor func stopsCallingBlockWhenDeallocated() async throws {
34-
subject = center.notifications(named: name).map(\.name).monitor { _ in
35-
Issue.record("Called after deallocation")
36-
}
38+
subject = center.notifications(named: name)
39+
.map(\.name)
40+
.monitor { _ in
41+
Issue.record("Called after deallocation")
42+
}
3743

3844
Task { @MainActor in
3945
subject = nil
@@ -51,7 +57,8 @@ class AsyncMonitorTests {
5157
init(center: NotificationCenter, deinitHook: @escaping () -> Void) {
5258
self.deinitHook = deinitHook
5359
let name = Notification.Name("irrelevant name")
54-
cancellable = center.notifications(named: name).map(\.name)
60+
cancellable = center.notifications(named: name)
61+
.map(\.name)
5562
.monitor(context: self) { _, _ in }
5663
}
5764

@@ -73,7 +80,8 @@ class AsyncMonitorTests {
7380

7481
@Test func stopsCallingBlockWhenContextIsDeallocated() async throws {
7582
var context: NSObject? = NSObject()
76-
subject = center.notifications(named: name).map(\.name)
83+
subject = center.notifications(named: name)
84+
.map(\.name)
7785
.monitor(context: context!) { context, receivedName in
7886
Issue.record("Called after context was deallocated")
7987
}
@@ -83,4 +91,21 @@ class AsyncMonitorTests {
8391
}
8492
try await Task.sleep(for: .milliseconds(10))
8593
}
94+
95+
@Test func equatable() throws {
96+
let subject = AsyncMonitor(sequence: AsyncStream.just(42)) { _ in }
97+
#expect(subject == subject)
98+
#expect(subject != AsyncMonitor(sequence: AsyncStream.just(42)) { _ in })
99+
}
100+
101+
@Test func hashable() throws {
102+
let subjects = (1...100).map { _ in
103+
AsyncMonitor(sequence: AsyncStream.just(42)) { _ in }
104+
}
105+
var hashValues: Set<Int> = []
106+
for subject in subjects {
107+
hashValues.insert(subject.hashValue)
108+
}
109+
#expect(hashValues.count == subjects.count)
110+
}
86111
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import Foundation
2+
3+
extension AsyncSequence where Element: Sendable {
4+
static func just(_ value: Element) -> AsyncStream<Element> {
5+
AsyncStream { continuation in
6+
continuation.yield(value)
7+
}
8+
}
9+
}

0 commit comments

Comments
 (0)