diff --git a/Package.swift b/Package.swift index 088b576..47a0456 100644 --- a/Package.swift +++ b/Package.swift @@ -5,8 +5,8 @@ import PackageDescription let package = Package( name: "AsyncMonitor", platforms: [ - .iOS(.v18), - .macOS(.v15), + .iOS(.v17), + .macOS(.v14), ], products: [ .library( diff --git a/Sources/AsyncMonitor/AsyncMonitor.swift b/Sources/AsyncMonitor/AsyncMonitor.swift index c4a839d..5729a24 100644 --- a/Sources/AsyncMonitor/AsyncMonitor.swift +++ b/Sources/AsyncMonitor/AsyncMonitor.swift @@ -20,6 +20,7 @@ public final class AsyncMonitor: Hashable, AsyncCancellable { /// Defaults to `#isolation`, preserving the caller's actor isolation. /// - sequence: The asynchronous sequence of elements to observe. /// - block: A closure to execute for each element yielded by the sequence. + @available(iOS 18, *) public init( isolation: isolated (any Actor)? = #isolation, sequence: any AsyncSequence, @@ -34,6 +35,29 @@ public final class AsyncMonitor: Hashable, AsyncCancellable { } } + /// Creates an ``AsyncMonitor`` that observes the provided asynchronous sequence. + /// + /// - Parameters: + /// - sequence: The asynchronous sequence of elements to observe. + /// - block: A closure to execute for each element yielded by the sequence. + @available(iOS, introduced: 17, obsoleted: 18) + public init( + sequence: sending Sequence, + @_inheritActorContext performing block: @escaping @Sendable (Element) async -> Void + ) where Sequence: AsyncSequence, Element == Sequence.Element { + self.task = Task { + do { + for try await element in sequence { + await block(element) + } + } catch { + guard !Task.isCancelled else { return } + + print("Error iterating over sequence: \(error)") + } + } + } + deinit { cancel() } diff --git a/Sources/AsyncMonitor/AsyncSequence+Extensions.swift b/Sources/AsyncMonitor/AsyncSequence+Extensions.swift index a2a83ea..cd6d280 100644 --- a/Sources/AsyncMonitor/AsyncSequence+Extensions.swift +++ b/Sources/AsyncMonitor/AsyncSequence+Extensions.swift @@ -1,3 +1,4 @@ +@available(iOS 18, *) public extension AsyncSequence where Element: Sendable, Failure == Never { /// Observes the elements yielded by this sequence and executes the given closure with each element. /// @@ -33,3 +34,32 @@ public extension AsyncSequence where Element: Sendable, Failure == Never { } } } + +@available(iOS, introduced: 17, obsoleted: 18) +public extension AsyncSequence where Self: Sendable, Element: Sendable { + /// Observes the elements yielded by this sequence and executes the given closure with each element. + /// + /// - Parameters: + /// - block: A closure that's executed with each yielded element. + func monitor( + _ block: @escaping @Sendable (Element) async -> Void + ) -> AsyncMonitor { + AsyncMonitor(sequence: self, performing: block) + } + + /// Observes the elements yielded by this sequence and executes the given closure with each element the weakly-captured + /// context object. + /// + /// - Parameters: + /// - context: The object to capture weakly for use within the closure. + /// - block: A closure that's executed with each yielded element, and the `context`. + func monitor( + context: Context, + _ block: @escaping @Sendable (Context, Element) async -> Void + ) -> AsyncMonitor { + AsyncMonitor(sequence: self) { [weak context] element in + guard let context else { return } + await block(context, element) + } + } +} diff --git a/Sources/AsyncMonitor/NSObject+AsyncKVO.swift b/Sources/AsyncMonitor/NSObject+AsyncKVO.swift index 203528f..acce324 100644 --- a/Sources/AsyncMonitor/NSObject+AsyncKVO.swift +++ b/Sources/AsyncMonitor/NSObject+AsyncKVO.swift @@ -2,6 +2,30 @@ public import Foundation extension KeyPath: @unchecked @retroactive Sendable where Value: Sendable {} +public extension NSObjectProtocol where Self: NSObject { + /// Returns an `AsyncSequence` of `Value`s for all changes to the given key path on this object. + /// + /// - Parameters: + /// - keyPath: The key path to observe on this object. The value must be `Sendable`. + /// - options: KVO options to use for observation. Defaults to an empty set. + func values( + for keyPath: KeyPath, + options: NSKeyValueObservingOptions = [] + ) -> AsyncStream { + let (stream, continuation) = AsyncStream.makeStream() + let token: NSKeyValueObservation? = self.observe(keyPath, options: options) { object, _ in + continuation.yield(object[keyPath: keyPath]) + } + // A nice side-effect of this is that the stream retains the token automatically. + let locker = ValueLocker(value: token) + continuation.onTermination = { _ in + locker.modify { $0 = nil } + } + return stream + } +} + +@available(iOS 18, *) public extension NSObjectProtocol where Self: NSObject { /// Observes changes to the specified key path on the object and asynchronously yields each value. Values must be `Sendable`. /// @@ -17,25 +41,22 @@ public extension NSObjectProtocol where Self: NSObject { values(for: keyPath, options: options) .monitor(changeHandler) } +} - /// Returns an `AsyncSequence` of `Value`s for all changes to the given key path on this object. +@available(iOS, introduced: 17, obsoleted: 18) +public extension NSObjectProtocol where Self: NSObject { + /// Observes changes to the specified key path on the object and asynchronously yields each value. Values must be `Sendable`. /// /// - Parameters: /// - keyPath: The key path to observe on this object. The value must be `Sendable`. /// - options: KVO options to use for observation. Defaults to an empty set. - func values( + /// - changeHandler: A closure that's executed with each new value. + func monitorValues( for keyPath: KeyPath, - options: NSKeyValueObservingOptions = [] - ) -> some AsyncSequence { - let (stream, continuation) = AsyncStream.makeStream() - let token: NSKeyValueObservation? = self.observe(keyPath, options: options) { object, _ in - continuation.yield(object[keyPath: keyPath]) - } - // A nice side-effect of this is that the stream retains the token automatically. - let locker = ValueLocker(value: token) - continuation.onTermination = { _ in - locker.modify { $0 = nil } - } - return stream + options: NSKeyValueObservingOptions = [], + changeHandler: @escaping @Sendable (Value) -> Void + ) -> any AsyncCancellable { + values(for: keyPath, options: options) + .monitor(changeHandler) } } diff --git a/Tests/AsyncMonitorTests/NSObject+AsyncKVOTests.swift b/Tests/AsyncMonitorTests/NSObject+AsyncKVOTests.swift index cca9eb8..f8b7333 100644 --- a/Tests/AsyncMonitorTests/NSObject+AsyncKVOTests.swift +++ b/Tests/AsyncMonitorTests/NSObject+AsyncKVOTests.swift @@ -27,7 +27,7 @@ class AsyncKVOTests { #expect(values.count == total) } - // It's important that the test or the progress-observing task are not on the same actor, so + // It's important that the test and the progress-observing task are not on the same actor, so // we make the test @MainActor and observe progress values on another actor. Otherwise it's a // deadlock. @Test(.timeLimit(.minutes(1)))