From b07f1db4df68c4a6e49e0203351e7c98b53c5108 Mon Sep 17 00:00:00 2001 From: Kyle Date: Wed, 26 Aug 2026 23:49:40 +0800 Subject: [PATCH] Add KeyValueObservingPublisher support --- RemainingFoundationInterface.swift | 39 ---- .../Publishers+KeyValueObserving.swift | 199 ++++++++++++++++++ .../KeyValueObservingPublisherTests.swift | 185 ++++++++++++++++ 3 files changed, 384 insertions(+), 39 deletions(-) delete mode 100644 RemainingFoundationInterface.swift create mode 100644 Sources/OpenCombineFoundation/Publishers+KeyValueObserving.swift create mode 100644 Tests/OpenCombineTests/FoundationTests/KeyValueObservingPublisherTests.swift diff --git a/RemainingFoundationInterface.swift b/RemainingFoundationInterface.swift deleted file mode 100644 index 0ac5fe7f6..000000000 --- a/RemainingFoundationInterface.swift +++ /dev/null @@ -1,39 +0,0 @@ -// From /Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk/usr/lib/swift/Foundation.swiftmodule/x86_64.swiftinterface -// swift-interface-format-version: 1.0 -// swift-compiler-version: Apple Swift version 5.1.1 (swiftlang-1100.8.275.1 clang-1100.0.32.1) -// swift-module-flags: -target x86_64-apple-macosx10.15 -enable-objc-interop -autolink-force-load -enable-library-evolution -module-link-name swiftFoundation -swift-version 5 -O -enforce-exclusivity=unchecked -module-name Foundation - -public typealias Published = Combine.Published - -public typealias ObservableObject = Combine.ObservableObject - -public protocol _KeyValueCodingAndObservingPublishing { -} - -extension NSObject : Foundation._KeyValueCodingAndObservingPublishing { -} - -extension _KeyValueCodingAndObservingPublishing where Self : ObjectiveC.NSObject { - public func publisher(for keyPath: Swift.KeyPath, options: Foundation.NSKeyValueObservingOptions = [.initial, .new]) -> ObjectiveC.NSObject.KeyValueObservingPublisher -} - -extension NSObject.KeyValueObservingPublisher { - public func didChange() -> Combine.Publishers.Map, Swift.Void> -} - -extension NSObject { - public struct KeyValueObservingPublisher : Swift.Equatable where Subject : ObjectiveC.NSObject { - public let object: Subject - public let keyPath: Swift.KeyPath - public let options: Foundation.NSKeyValueObservingOptions - public init(object: Subject, keyPath: Swift.KeyPath, options: Foundation.NSKeyValueObservingOptions) - public static func == (lhs: ObjectiveC.NSObject.KeyValueObservingPublisher, rhs: ObjectiveC.NSObject.KeyValueObservingPublisher) -> Swift.Bool - } -} - -extension NSObject.KeyValueObservingPublisher : Combine.Publisher { - public typealias Output = Value - public typealias Failure = Swift.Never - public func receive(subscriber: S) where Value == S.Input, S : Combine.Subscriber, S.Failure == ObjectiveC.NSObject.KeyValueObservingPublisher.Failure -} - diff --git a/Sources/OpenCombineFoundation/Publishers+KeyValueObserving.swift b/Sources/OpenCombineFoundation/Publishers+KeyValueObserving.swift new file mode 100644 index 000000000..ab2718704 --- /dev/null +++ b/Sources/OpenCombineFoundation/Publishers+KeyValueObserving.swift @@ -0,0 +1,199 @@ +// ===----------------------------------------------------------------------===// +// +// This source file is part of the Swift.org open source project +// +// Copyright (c) 2014 - 2020 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// +// See https://swift.org/LICENSE.txt for license information +// See https://swift.org/CONTRIBUTORS.txt for the list of Swift project authors +// +// ===----------------------------------------------------------------------===// + +// This implementation is adapted for OpenCombine from the last publicly +// available Swift.org Darwin Foundation overlay: +// https://github.com/swiftlang/swift/blob/e7cd5ab17f5ce92315f0e23b269628ac2b9369ea/stdlib/public/Darwin/Foundation/Publishers%2BKeyValueObserving.swift +// +// Swift removed the Darwin overlays while adding Xcode 13 support: +// https://github.com/swiftlang/swift/commit/23c3b15f5f2676dd2cfeaabdaf62ae9b49d6faf1 +// The API is now supplied by the system Foundation module. Its current Darwin +// implementation is not present in swift-corelibs-foundation or +// swift-foundation; objc4 only exposes runtime facilities used by Foundation's +// KVO implementation. + +import Foundation +import OpenCombine + +public typealias Published = OpenCombine.Published + +public typealias ObservableObject = OpenCombine.ObservableObject + +#if canImport(ObjectiveC) + +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +extension NSObject.KeyValueObservingPublisher: OpenCombine.Publisher { + + public typealias Output = Value + + public typealias Failure = Never + + public func receive(subscriber: Downstream) + where Downstream.Input == Value, Downstream.Failure == Never + { + let subscription = NSObject.KVOSubscription( + object, + keyPath, + options, + subscriber + ) + subscriber.receive(subscription: subscription) + } +} + +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +extension NSObject.KeyValueObservingPublisher { + + /// Returns a publisher that emits a signal when a KVO-compliant property changes. + public func didChange() + -> OpenCombine.Publishers.Map< + NSObject.KeyValueObservingPublisher, + Void + > + { + return map { _ in () } + } +} + +extension NSObject { + + private final class KVOSubscription: Subscription, + CustomStringConvertible, + CustomReflectable, + CustomPlaygroundDisplayConvertible + { + private struct PendingInitial { + var value: Value + } + + private var observation: NSKeyValueObservation? + private var demand = OpenCombine.Subscribers.Demand.none + private var receivedInitial = false + private var pendingInitial: PendingInitial? + private var downstream: OpenCombine.AnySubscriber? + + private let lock = UnfairLock.allocate() + private let downstreamLock = UnfairRecursiveLock.allocate() + + fileprivate init( + _ object: Subject, + _ keyPath: KeyPath, + _ options: NSKeyValueObservingOptions, + _ downstream: Downstream + ) where Downstream.Input == Value, Downstream.Failure == Never { + self.downstream = OpenCombine.AnySubscriber(downstream) + observation = object.observe( + keyPath, + options: options + ) { [weak self] object, _ in + self?.didChange( + to: object[keyPath: keyPath], + cachingInitial: options.contains(.initial) + ) + } + } + + deinit { + lock.deallocate() + downstreamLock.deallocate() + } + + fileprivate var description: String { + return "KVOSubscription" + } + + fileprivate var customMirror: Mirror { + lock.lock() + defer { lock.unlock() } + return Mirror(self, children: [ + "observation": observation as Any, + "demand": demand, + ]) + } + + fileprivate var playgroundDescription: Any { + return description + } + + fileprivate func request(_ demand: OpenCombine.Subscribers.Demand) { + demand.assertNonZero() + lock.lock() + guard let downstream = self.downstream else { + lock.unlock() + return + } + self.demand += demand + guard self.demand > .none, let initial = pendingInitial.take() else { + self.demand -= 1 + pendingInitial = nil + lock.unlock() + return + } + self.demand -= 1 + lock.unlock() + + downstreamLock.lock() + let additionalDemand = downstream.receive(initial.value) + downstreamLock.unlock() + + guard additionalDemand > .none else { + return + } + lock.lock() + if self.downstream != nil { + self.demand += additionalDemand + } + lock.unlock() + } + + fileprivate func cancel() { + lock.lock() + let observation = self.observation.take() + downstream = nil + pendingInitial = nil + lock.unlock() + observation?.invalidate() + } + + private func didChange(to value: Value, cachingInitial: Bool) { + lock.lock() + let isInitial = cachingInitial && !receivedInitial + if isInitial { + receivedInitial = true + } + guard demand > .none, let downstream = self.downstream else { + if isInitial { + pendingInitial = PendingInitial(value: value) + } + lock.unlock() + return + } + demand -= 1 + lock.unlock() + + downstreamLock.lock() + let additionalDemand = downstream.receive(value) + downstreamLock.unlock() + + guard additionalDemand > .none else { + return + } + lock.lock() + if self.downstream != nil { + demand += additionalDemand + } + lock.unlock() + } + } +} + +#endif diff --git a/Tests/OpenCombineTests/FoundationTests/KeyValueObservingPublisherTests.swift b/Tests/OpenCombineTests/FoundationTests/KeyValueObservingPublisherTests.swift new file mode 100644 index 000000000..169ded68e --- /dev/null +++ b/Tests/OpenCombineTests/FoundationTests/KeyValueObservingPublisherTests.swift @@ -0,0 +1,185 @@ +// +// KeyValueObservingPublisherTests.swift +// + +#if canImport(ObjectiveC) // TEST_DISCOVERY_CONDITION + +import Foundation +import XCTest + +#if OPENCOMBINE_COMPATIBILITY_TEST +import Combine +#else +import OpenCombine +import OpenCombineFoundation +#endif + +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +final class KeyValueObservingPublisherTests: XCTestCase { + + func testPublisherInitializer() { + let object = TestObject() + let publisher = NSObject.KeyValueObservingPublisher( + object: object, + keyPath: \TestObject.value, + options: [.new] + ) + + XCTAssertTrue(publisher.object === object) + XCTAssertEqual(publisher.keyPath, \TestObject.value) + XCTAssertEqual(publisher.options, [.new]) + } + + func testPublisherPropertiesAndEquality() { + let object = TestObject() + let otherObject = TestObject() + let publisher = makePublisher(object, for: \.value) + + XCTAssertTrue(publisher.object === object) + XCTAssertEqual(publisher.keyPath, \.value) + XCTAssertEqual(publisher.options, [.initial, .new]) + XCTAssertEqual(publisher, makePublisher(object, for: \.value)) + XCTAssertNotEqual(publisher, makePublisher(otherObject, for: \.value)) + XCTAssertNotEqual( + publisher, + makePublisher(object, for: \.value, options: [.new]) + ) + } + + func testInitialValueWaitsForDemandAndLaterValuesAreDroppedWithoutDemand() { + let object = TestObject() + let publisher = makePublisher(object, for: \.value) + var subscription: Subscription? + let tracking = TrackingSubscriberBase( + receiveSubscription: { subscription = $0 } + ) + + publisher.receive(subscriber: tracking) + + XCTAssertEqual(tracking.subscriptions.count, 1) + XCTAssertEqual(Array(tracking.inputs), []) + + subscription?.request(.max(1)) + XCTAssertEqual(Array(tracking.inputs), [0]) + + object.value = 1 + XCTAssertEqual(Array(tracking.inputs), [0]) + + subscription?.request(.max(2)) + object.value = 2 + XCTAssertEqual(Array(tracking.inputs), [0, 2]) + + subscription?.cancel() + object.value = 3 + XCTAssertEqual(Array(tracking.inputs), [0, 2]) + } + + func testAdditionalDemandReturnedByDownstream() { + let object = TestObject() + let publisher = makePublisher(object, for: \.value) + let tracking = TrackingSubscriberBase( + receiveSubscription: { $0.request(.max(1)) }, + receiveValue: { _ in .max(1) } + ) + + publisher.receive(subscriber: tracking) + object.value = 1 + object.value = 2 + + XCTAssertEqual(Array(tracking.inputs), [0, 1, 2]) + } + + func testPublisherWithoutInitialOptionUsesDemandForFirstChange() { + let object = TestObject() + let publisher = makePublisher(object, for: \.value, options: [.new]) + let tracking = TrackingSubscriberBase( + receiveSubscription: { $0.request(.max(2)) } + ) + + publisher.receive(subscriber: tracking) + XCTAssertEqual(Array(tracking.inputs), []) + + object.value = 1 + XCTAssertEqual(Array(tracking.inputs), [1]) + } + + func testOptionalNilInitialValueIsCached() { + let object = TestObject() + let publisher = makePublisher(object, for: \.optionalValue) + var subscription: Subscription? + let tracking = TrackingSubscriberBase( + receiveSubscription: { subscription = $0 } + ) + + publisher.receive(subscriber: tracking) + XCTAssertEqual(tracking.history.count, 1) + + subscription?.request(.max(1)) + XCTAssertEqual(tracking.history.count, 2) + guard case .value(nil) = tracking.history[1] else { + return XCTFail("Expected the cached nil initial value") + } + } + + func testDidChange() { + let object = TestObject() + let publisher = makePublisher(object, for: \.value, options: [.new]) + + #if OPENCOMBINE_COMPATIBILITY_TEST + let didChange: Combine.Publishers.Map< + NSObject.KeyValueObservingPublisher, + Void + > = publisher.didChange() + #else + let didChange: OpenCombine.Publishers.Map< + NSObject.KeyValueObservingPublisher, + Void + > = publisher.didChange() + #endif + + let tracking = TrackingSubscriberBase( + receiveSubscription: { $0.request(.unlimited) } + ) + didChange.receive(subscriber: tracking) + + XCTAssertEqual(tracking.inputs.count, 0) + + object.value = 1 + object.value = 2 + + XCTAssertEqual(tracking.inputs.count, 2) + } + + #if !OPENCOMBINE_COMPATIBILITY_TEST + func testFoundationTypealiases() { + let object = AliasObject() + let observableObject: any OpenCombine.ObservableObject = object + + XCTAssertTrue(observableObject is AliasObject) + XCTAssertEqual(object.value, 0) + } + #endif + + private func makePublisher( + _ object: TestObject, + for keyPath: KeyPath, + options: NSKeyValueObservingOptions = [.initial, .new] + ) -> NSObject.KeyValueObservingPublisher { + return object.publisher(for: keyPath, options: options) + } +} + +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +private final class TestObject: NSObject { + @objc dynamic var value = 0 + @objc dynamic var optionalValue: String? +} + +#if !OPENCOMBINE_COMPATIBILITY_TEST +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +private final class AliasObject: OpenCombineFoundation.ObservableObject { + @OpenCombineFoundation.Published var value = 0 +} +#endif + +#endif