diff --git a/CombineCocoa.xcodeproj/xcshareddata/xcschemes/CombineCocoa-Package.xcscheme b/CombineCocoa.xcodeproj/xcshareddata/xcschemes/CombineCocoa-Package.xcscheme index 52033b0..5b8c1fa 100644 --- a/CombineCocoa.xcodeproj/xcshareddata/xcschemes/CombineCocoa-Package.xcscheme +++ b/CombineCocoa.xcodeproj/xcshareddata/xcschemes/CombineCocoa-Package.xcscheme @@ -1,32 +1,73 @@ - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Sources/CombineCocoa/Interception/NSObject+Association.swift b/Sources/CombineCocoa/Interception/NSObject+Association.swift new file mode 100644 index 0000000..7b78d15 --- /dev/null +++ b/Sources/CombineCocoa/Interception/NSObject+Association.swift @@ -0,0 +1,167 @@ +// +// NSObject+Association.swift +// CombineCocoa +// +// Created by Maxim Krouk on 22.06.21. +// Copyright © 2020 Combine Community. All rights reserved. +// + +import Foundation + +#if canImport(Runtime) + import Runtime +#endif + +internal struct AssociationKey { + fileprivate let address: UnsafeRawPointer + fileprivate let `default`: Value! + + /// Create an ObjC association key. + /// + /// - warning: The key must be uniqued. + /// + /// - parameters: + /// - default: The default value, or `nil` to trap on undefined value. It is + /// ignored if `Value` is an optional. + init(default: Value? = nil) { + self.address = UnsafeRawPointer( + UnsafeMutablePointer.allocate(capacity: 1) + ) + self.default = `default` + } + + /// Create an ObjC association key from a `StaticString`. + /// + /// - precondition: `key` has a pointer representation. + /// + /// - parameters: + /// - default: The default value, or `nil` to trap on undefined value. It is + /// ignored if `Value` is an optional. + init(_ key: StaticString, default: Value? = nil) { + assert(key.hasPointerRepresentation) + self.address = UnsafeRawPointer(key.utf8Start) + self.default = `default` + } + + /// Create an ObjC association key from a `Selector`. + /// + /// - parameters: + /// - default: The default value, or `nil` to trap on undefined value. It is + /// ignored if `Value` is an optional. + init(_ key: Selector, default: Value? = nil) { + self.address = UnsafeRawPointer(key.utf8Start) + self.default = `default` + } +} + +internal struct Associations { + fileprivate let base: Base + + init(_ base: Base) { + self.base = base + } +} + +extension NSObjectProtocol { + /// Retrieve the associated value for the specified key. If the value does not + /// exist, `initial` would be called and the returned value would be + /// associated subsequently. + /// + /// - parameters: + /// - key: An optional key to differentiate different values. + /// - initial: The action that supples an initial value. + /// + /// - returns: The associated value for the specified key. + internal func associatedValue( + forKey key: StaticString = #function, + initial: (Self) -> T + ) -> T { + let key = AssociationKey(key) + + if let value = associations.value(forKey: key) { + return value + } + + let value = initial(self) + associations.setValue(value, forKey: key) + + return value + } +} + +extension NSObjectProtocol { + @nonobjc internal var associations: Associations { + return Associations(self) + } +} + +extension Associations { + /// Retrieve the associated value for the specified key. + /// + /// - parameters: + /// - key: The key. + /// + /// - returns: The associated value, or the default value if no value has been + /// associated with the key. + internal func value( + forKey key: AssociationKey + ) -> Value { + return (objc_getAssociatedObject(base, key.address) as! Value?) ?? key.default + } + + /// Retrieve the associated value for the specified key. + /// + /// - parameters: + /// - key: The key. + /// + /// - returns: The associated value, or `nil` if no value is associated with + /// the key. + internal func value( + forKey key: AssociationKey + ) -> Value? { + return objc_getAssociatedObject(base, key.address) as! Value? + } + + /// Set the associated value for the specified key. + /// + /// - parameters: + /// - value: The value to be associated. + /// - key: The key. + internal func setValue( + _ value: Value, + forKey key: AssociationKey + ) { + objc_setAssociatedObject(base, key.address, value, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } + + /// Set the associated value for the specified key. + /// + /// - parameters: + /// - value: The value to be associated. + /// - key: The key. + internal func setValue( + _ value: Value?, + forKey key: AssociationKey + ) { + objc_setAssociatedObject(base, key.address, value, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) + } +} + +/// Set the associated value for the specified key. +/// +/// - parameters: +/// - value: The value to be associated. +/// - key: The key. +/// - address: The address of the object. +internal func unsafeSetAssociatedValue( + _ value: Value?, + forKey key: AssociationKey, + forObjectAt address: UnsafeRawPointer +) { + _combinecocoa_objc_setAssociatedObject( + address, + key.address, + value, + .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) +} diff --git a/Sources/CombineCocoa/Interception/NSObject+Interseption.swift b/Sources/CombineCocoa/Interception/NSObject+Interseption.swift new file mode 100644 index 0000000..9648c78 --- /dev/null +++ b/Sources/CombineCocoa/Interception/NSObject+Interseption.swift @@ -0,0 +1,518 @@ +// +// NSObject+Interseption.swift +// CombineCocoa +// +// Created by Maxim Krouk on 22.06.21. +// Copyright © 2020 Combine Community. All rights reserved. +// + +#if canImport(Combine) +import Combine +import Foundation + +#if canImport(Runtime) + import Runtime +#endif + +/// Whether the runtime subclass has already been prepared for method +/// interception. +private let interceptedKey = AssociationKey(default: false) + +/// Holds the method signature cache of the runtime subclass. +private let signatureCacheKey = AssociationKey() + +/// Holds the method selector cache of the runtime subclass. +private let selectorCacheKey = AssociationKey() + +internal let noImplementation: IMP = unsafeBitCast(Int(0), to: IMP.self) + +extension NSObject { + /// Create a publisher which sends a `next` event at the end of every + /// invocation of `selector` on the object. + /// + /// It completes when the object deinitializes. + /// + /// - note: Observers to the resulting publisher should not call the method + /// specified by the selector. + /// + /// - parameters: + /// - selector: The selector to observe. + /// + /// - returns: A trigger publisher. + @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) + public func publisher(for selector: Selector) -> AnyPublisher<(), Never> { + return intercept(selector).map { (_: AnyObject) in }.eraseToAnyPublisher() + } + + /// Create a publisher which sends a `next` event, containing an array of + /// bridged arguments, at the end of every invocation of `selector` on the + /// object. + /// + /// It completes when the object deinitializes. + /// + /// - note: Observers to the resulting publisher should not call the method + /// specified by the selector. + /// + /// - parameters: + /// - selector: The selector to observe. + /// + /// - returns: A publisher that sends an array of bridged arguments. + @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) + public func intercept(_ selector: Selector) -> AnyPublisher<[Any?], Never> { + return intercept(selector).map(unpackInvocation).eraseToAnyPublisher() + } + + /// Setup the method interception. + /// + /// - parameters: + /// - object: The object to be intercepted. + /// - selector: The selector of the method to be intercepted. + /// + /// - returns: A publisher that sends the corresponding `NSInvocation` after + /// every invocation of the method. + @available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) + @nonobjc fileprivate func intercept(_ selector: Selector) -> AnyPublisher { + guard let method = class_getInstanceMethod(objcClass, selector) else { + fatalError( + "Selector `\(selector)` does not exist in class `\(String(describing: objcClass))`." + ) + } + + let typeEncoding = method_getTypeEncoding(method)! + assert(checkTypeEncoding(typeEncoding)) + + return synchronized(self) { + let alias = selector.alias + let stateKey = AssociationKey(alias) + let interopAlias = selector.interopAlias + + if let state = associations.value(forKey: stateKey) { + return state.subject.eraseToAnyPublisher() + } + + let subclass: AnyClass = swizzleClass(self) + let subclassAssociations = Associations(subclass as AnyObject) + + synchronized(subclass) { + let isSwizzled = subclassAssociations.value(forKey: interceptedKey) + + let signatureCache: SignatureCache + let selectorCache: SelectorCache + + if isSwizzled { + signatureCache = subclassAssociations.value(forKey: signatureCacheKey) + selectorCache = subclassAssociations.value(forKey: selectorCacheKey) + } + else { + signatureCache = SignatureCache() + selectorCache = SelectorCache() + + subclassAssociations.setValue(signatureCache, forKey: signatureCacheKey) + subclassAssociations.setValue(selectorCache, forKey: selectorCacheKey) + subclassAssociations.setValue(true, forKey: interceptedKey) + + enableMessageForwarding(subclass, selectorCache) + setupMethodSignatureCaching(subclass, signatureCache) + } + + selectorCache.cache(selector) + + if signatureCache[selector] == nil { + let signature = NSMethodSignature.objcSignature(withObjCTypes: typeEncoding) + signatureCache[selector] = signature + } + + // If an immediate implementation of the selector is found in the + // runtime subclass the first time the selector is intercepted, + // preserve the implementation. + // + // Example: KVO setters if the instance is swizzled by KVO before RAC + // does. + if !class_respondsToSelector(subclass, interopAlias) { + let immediateImpl = class_getImmediateMethod(subclass, selector) + .flatMap(method_getImplementation) + .flatMap { $0 != _combinecocoa_objc_msgForward ? $0 : nil } + + if let impl = immediateImpl { + let succeeds = class_addMethod(subclass, interopAlias, impl, typeEncoding) + precondition( + succeeds, + "RAC attempts to swizzle a selector that has message forwarding enabled with a runtime injected implementation. This is unsupported in the current version." + ) + } + } + } + + let state = InterceptingState() + associations.setValue(state, forKey: stateKey) + + // Start forwarding the messages of the selector. + _ = class_replaceMethod(subclass, selector, _combinecocoa_objc_msgForward, typeEncoding) + + return state.subject.eraseToAnyPublisher() + } + } +} + +/// Swizzle `realClass` to enable message forwarding for method interception. +/// +/// - parameters: +/// - realClass: The runtime subclass to be swizzled. +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +private func enableMessageForwarding(_ realClass: AnyClass, _ selectorCache: SelectorCache) { + let perceivedClass: AnyClass = class_getSuperclass(realClass)! + + typealias ForwardInvocationImpl = @convention(block) (Unmanaged, AnyObject) -> Void + let newForwardInvocation: ForwardInvocationImpl = { objectRef, invocation in + let selector = invocation.selector! + let alias = selectorCache.alias(for: selector) + let interopAlias = selectorCache.interopAlias(for: selector) + + defer { + let stateKey = AssociationKey(alias) + if let state = objectRef.takeUnretainedValue().associations.value(forKey: stateKey) { + state.subject.send(invocation) + } + } + + let method = class_getInstanceMethod(perceivedClass, selector) + let typeEncoding: String + + if let runtimeTypeEncoding = method.flatMap(method_getTypeEncoding) { + typeEncoding = String(cString: runtimeTypeEncoding) + } + else { + let methodSignature = (objectRef.takeUnretainedValue() as AnyObject) + .objcMethodSignature(for: selector) + let encodings = (0.., Selector, AnyObject) -> + Void + let forwardInvocationImpl = class_getMethodImplementation( + perceivedClass, + ObjCSelector.forwardInvocation + ) + let forwardInvocation = unsafeBitCast(forwardInvocationImpl, to: SuperForwardInvocation.self) + forwardInvocation(objectRef, ObjCSelector.forwardInvocation, invocation) + } + + _ = class_replaceMethod( + realClass, + ObjCSelector.forwardInvocation, + imp_implementationWithBlock(newForwardInvocation as Any), + ObjCMethodEncoding.forwardInvocation + ) +} + +/// Swizzle `realClass` to accelerate the method signature retrieval, using a +/// signature cache that covers all known intercepted selectors of `realClass`. +/// +/// - parameters: +/// - realClass: The runtime subclass to be swizzled. +/// - signatureCache: The method signature cache. +private func setupMethodSignatureCaching(_ realClass: AnyClass, _ signatureCache: SignatureCache) { + let perceivedClass: AnyClass = class_getSuperclass(realClass)! + + let newMethodSignatureForSelector: + @convention(block) (Unmanaged, Selector) -> AnyObject? = { objectRef, selector in + if let signature = signatureCache[selector] { + return signature + } + + typealias SuperMethodSignatureForSelector = @convention(c) ( + Unmanaged, Selector, Selector + ) -> AnyObject? + let impl = class_getMethodImplementation( + perceivedClass, + ObjCSelector.methodSignatureForSelector + ) + let methodSignatureForSelector = unsafeBitCast(impl, to: SuperMethodSignatureForSelector.self) + return methodSignatureForSelector( + objectRef, + ObjCSelector.methodSignatureForSelector, + selector + ) + } + + _ = class_replaceMethod( + realClass, + ObjCSelector.methodSignatureForSelector, + imp_implementationWithBlock(newMethodSignatureForSelector as Any), + ObjCMethodEncoding.methodSignatureForSelector + ) +} + +/// The state of an intercepted method specific to an instance. +@available(macOS 10.15, iOS 13.0, tvOS 13.0, watchOS 6.0, *) +private final class InterceptingState { + let subject = PassthroughSubject() +} + +private final class SelectorCache { + private var map: [Selector: (main: Selector, interop: Selector)] = [:] + + init() {} + + /// Cache the aliases of the specified selector in the cache. + /// + /// - warning: Any invocation of this method must be synchronized against the + /// runtime subclass. + @discardableResult + func cache(_ selector: Selector) -> (main: Selector, interop: Selector) { + if let pair = map[selector] { + return pair + } + + let aliases = (selector.alias, selector.interopAlias) + map[selector] = aliases + + return aliases + } + + /// Get the alias of the specified selector. + /// + /// - parameters: + /// - selector: The selector alias. + func alias(for selector: Selector) -> Selector { + if let (main, _) = map[selector] { + return main + } + + return selector.alias + } + + /// Get the secondary alias of the specified selector. + /// + /// - parameters: + /// - selector: The selector alias. + func interopAlias(for selector: Selector) -> Selector { + if let (_, interop) = map[selector] { + return interop + } + + return selector.interopAlias + } +} + +// The signature cache for classes that have been swizzled for method +// interception. +// +// Read-copy-update is used here, since the cache has multiple readers but only +// one writer. +private final class SignatureCache { + // `Dictionary` takes 8 bytes for the reference to its storage and does CoW. + // So it should not encounter any corrupted, partially updated state. + private var map: [Selector: AnyObject] = [:] + + init() {} + + /// Get or set the signature for the specified selector. + /// + /// - warning: Any invocation of the setter must be synchronized against the + /// runtime subclass. + /// + /// - parameters: + /// - selector: The method signature. + subscript(selector: Selector) -> AnyObject? { + get { + return map[selector] + } + set { + if map[selector] == nil { + map[selector] = newValue + } + } + } +} + +/// Assert that the method does not contain types that cannot be intercepted. +/// +/// - parameters: +/// - types: The type encoding C string of the method. +/// +/// - returns: `true`. +private func checkTypeEncoding(_ types: UnsafePointer) -> Bool { + // Some types, including vector types, are not encoded. In these cases the + // signature starts with the size of the argument frame. + assert( + types.pointee < Int8(UInt8(ascii: "1")) || types.pointee > Int8(UInt8(ascii: "9")), + "unknown method return type not supported in type encoding: \(String(cString: types))" + ) + + assert(types.pointee != Int8(UInt8(ascii: "(")), "union method return type not supported") + assert(types.pointee != Int8(UInt8(ascii: "{")), "struct method return type not supported") + assert(types.pointee != Int8(UInt8(ascii: "[")), "array method return type not supported") + + assert(types.pointee != Int8(UInt8(ascii: "j")), "complex method return type not supported") + + return true +} + +/// Extract the arguments of an `NSInvocation` as an array of objects. +/// +/// - parameters: +/// - invocation: The `NSInvocation` to unpack. +/// +/// - returns: An array of objects. +private func unpackInvocation(_ invocation: AnyObject) -> [Any?] { + let invocation = invocation as AnyObject + let methodSignature = invocation.objcMethodSignature! + let count = methodSignature.objcNumberOfArguments! + + var bridged = [Any?]() + bridged.reserveCapacity(Int(count - 2)) + + // Ignore `self` and `_cmd` at index 0 and 1. + for position in 2..(_ type: U.Type) -> U { + let pointer = UnsafeMutableRawPointer.allocate( + byteCount: MemoryLayout.size, + alignment: MemoryLayout.alignment + ) + defer { + pointer.deallocate() + } + + invocation.objcCopy(to: pointer, forArgumentAt: Int(position)) + return pointer.assumingMemoryBound(to: type).pointee + } + + let value: Any? + + switch encoding { + case .char: + value = NSNumber(value: extract(CChar.self)) + case .int: + value = NSNumber(value: extract(CInt.self)) + case .short: + value = NSNumber(value: extract(CShort.self)) + case .long: + value = NSNumber(value: extract(CLong.self)) + case .longLong: + value = NSNumber(value: extract(CLongLong.self)) + case .unsignedChar: + value = NSNumber(value: extract(CUnsignedChar.self)) + case .unsignedInt: + value = NSNumber(value: extract(CUnsignedInt.self)) + case .unsignedShort: + value = NSNumber(value: extract(CUnsignedShort.self)) + case .unsignedLong: + value = NSNumber(value: extract(CUnsignedLong.self)) + case .unsignedLongLong: + value = NSNumber(value: extract(CUnsignedLongLong.self)) + case .float: + value = NSNumber(value: extract(CFloat.self)) + case .double: + value = NSNumber(value: extract(CDouble.self)) + case .bool: + value = NSNumber(value: extract(CBool.self)) + case .object: + value = extract((AnyObject?).self) + case .type: + value = extract((AnyClass?).self) + case .selector: + value = extract((Selector?).self) + case .undefined: + var size = 0 + var alignment = 0 + NSGetSizeAndAlignment(rawEncoding, &size, &alignment) + let buffer = UnsafeMutableRawPointer.allocate(byteCount: size, alignment: alignment) + defer { buffer.deallocate() } + + invocation.objcCopy(to: buffer, forArgumentAt: Int(position)) + value = NSValue(bytes: buffer, objCType: rawEncoding) + } + + bridged.append(value) + } + + return bridged +} +#endif diff --git a/Sources/CombineCocoa/Interception/NSObject+ObjCRuntime.swift b/Sources/CombineCocoa/Interception/NSObject+ObjCRuntime.swift new file mode 100644 index 0000000..fbc4a3a --- /dev/null +++ b/Sources/CombineCocoa/Interception/NSObject+ObjCRuntime.swift @@ -0,0 +1,19 @@ +// +// NSObject+ObjCRuntime.swift +// CombineCocoa +// +// Created by Maxim Krouk on 22.06.21. +// Copyright © 2020 Combine Community. All rights reserved. +// + +import Foundation + +extension NSObject { + /// The class of the instance reported by the ObjC `-class:` message. + /// + /// - note: `type(of:)` might return the runtime subclass, while this property + /// always returns the original class. + @nonobjc internal var objcClass: AnyClass { + return (self as AnyObject).objcClass + } +} diff --git a/Sources/CombineCocoa/Interception/ObjC+Constants.swift b/Sources/CombineCocoa/Interception/ObjC+Constants.swift new file mode 100644 index 0000000..d761421 --- /dev/null +++ b/Sources/CombineCocoa/Interception/ObjC+Constants.swift @@ -0,0 +1,56 @@ +// +// ObjC+Constants.swift +// CombineCocoa +// +// Created by Maxim Krouk on 22.06.21. +// Copyright © 2020 Combine Community. All rights reserved. +// + +import Foundation + +// Unavailable selectors in Swift. +internal enum ObjCSelector { + static let forwardInvocation = Selector((("forwardInvocation:"))) + static let methodSignatureForSelector = Selector((("methodSignatureForSelector:"))) + static let getClass = Selector((("class"))) +} + +// Method encoding of the unavailable selectors. +internal enum ObjCMethodEncoding { + static let forwardInvocation = extract("v@:@") + static let methodSignatureForSelector = extract("v@::") + static let getClass = extract("#@:") + + private static func extract(_ string: StaticString) -> UnsafePointer { + return UnsafeRawPointer(string.utf8Start).assumingMemoryBound(to: CChar.self) + } +} + +/// Objective-C type encoding. +/// +/// The enum does not cover all options, but only those that are expressive in +/// Swift. +internal enum ObjCTypeEncoding: Int8 { + case char = 99 + case int = 105 + case short = 115 + case long = 108 + case longLong = 113 + + case unsignedChar = 67 + case unsignedInt = 73 + case unsignedShort = 83 + case unsignedLong = 76 + case unsignedLongLong = 81 + + case float = 102 + case double = 100 + + case bool = 66 + + case object = 64 + case type = 35 + case selector = 58 + + case undefined = -1 +} diff --git a/Sources/CombineCocoa/Interception/ObjC+Messages.swift b/Sources/CombineCocoa/Interception/ObjC+Messages.swift new file mode 100644 index 0000000..253d495 --- /dev/null +++ b/Sources/CombineCocoa/Interception/ObjC+Messages.swift @@ -0,0 +1,60 @@ +// +// ObjC+Messages.swift +// CombineCocoa +// +// Created by Maxim Krouk on 22.06.21. +// Copyright © 2020 Combine Community. All rights reserved. +// + +// Unavailable classes like `NSInvocation` can still be passed into Swift as +// `AnyClass` and `AnyObject`, and receive messages as `AnyClass` and +// `AnyObject` existentials. +// +// These `@objc` protocols host the method signatures so that they can be used +// with `AnyObject`. + +import Foundation + +internal let NSInvocation: AnyClass = NSClassFromString("NSInvocation")! +internal let NSMethodSignature: AnyClass = NSClassFromString("NSMethodSignature")! + +// Signatures defined in `@objc` protocols would be available for ObjC message +// sending via `AnyObject`. +@objc internal protocol ObjCClassReporting { + // An alias for `-class`, which is unavailable in Swift. + @objc(class) + var objcClass: AnyClass! { get } + + @objc(methodSignatureForSelector:) + func objcMethodSignature(for selector: Selector) -> AnyObject +} + +// Methods of `NSInvocation`. +@objc internal protocol ObjCInvocation { + @objc(setSelector:) + func objcSetSelector(_ selector: Selector) + + @objc(methodSignature) + var objcMethodSignature: AnyObject { get } + + @objc(getArgument:atIndex:) + func objcCopy(to buffer: UnsafeMutableRawPointer?, forArgumentAt index: Int) + + @objc(invoke) + func objcInvoke() + + @objc(invocationWithMethodSignature:) + static func objcInvocation(withMethodSignature signature: AnyObject) -> AnyObject +} + +// Methods of `NSMethodSignature`. +@objc internal protocol ObjCMethodSignature { + @objc(numberOfArguments) + var objcNumberOfArguments: UInt { get } + + @objc(getArgumentTypeAtIndex:) + func objcArgumentType(at index: UInt) -> UnsafePointer + + @objc(signatureWithObjCTypes:) + static func objcSignature(withObjCTypes typeEncoding: UnsafePointer) -> AnyObject +} diff --git a/Sources/CombineCocoa/Interception/ObjC+Runtime.swift b/Sources/CombineCocoa/Interception/ObjC+Runtime.swift new file mode 100644 index 0000000..acd30f5 --- /dev/null +++ b/Sources/CombineCocoa/Interception/ObjC+Runtime.swift @@ -0,0 +1,35 @@ +// +// ObjC+Runtime.swift +// CombineCocoa +// +// Created by Maxim Krouk on 22.06.21. +// Copyright © 2020 Combine Community. All rights reserved. +// + +import Foundation + +/// Search in `class` for any method that matches the supplied selector without +/// propagating to the ancestors. +/// +/// - parameters: +/// - class: The class to search the method in. +/// - selector: The selector of the method. +/// +/// - returns: The matching method, or `nil` if none is found. +internal func class_getImmediateMethod(_ `class`: AnyClass, _ selector: Selector) -> Method? { + var total: UInt32 = 0 + + if let methods = class_copyMethodList(`class`, &total) { + defer { free(methods) } + + for index in 0..(default: nil) + +extension NSObject { + /// Swizzle the given selectors. + /// + /// - warning: The swizzling **does not** apply on a per-instance basis. In + /// other words, repetitive swizzling of the same selector would + /// overwrite previous swizzling attempts, despite a different + /// instance being supplied. + /// + /// - parameters: + /// - pairs: Tuples of selectors and the respective implementions to be + /// swapped in. + /// - key: An association key which determines if the swizzling has already + /// been performed. + internal func swizzle(_ pairs: (Selector, Any)..., key hasSwizzledKey: AssociationKey) { + let subclass: AnyClass = swizzleClass(self) + + synchronized(subclass) { + let subclassAssociations = Associations(subclass as AnyObject) + + if !subclassAssociations.value(forKey: hasSwizzledKey) { + subclassAssociations.setValue(true, forKey: hasSwizzledKey) + + for (selector, body) in pairs { + let method = class_getInstanceMethod(subclass, selector)! + let typeEncoding = method_getTypeEncoding(method)! + + if method_getImplementation(method) == _combinecocoa_objc_msgForward { + let succeeds = class_addMethod( + subclass, + selector.interopAlias, + imp_implementationWithBlock(body), + typeEncoding + ) + precondition( + succeeds, + "RAC attempts to swizzle a selector that has message forwarding enabled with a runtime injected implementation. This is unsupported in the current version." + ) + } + else { + let succeeds = class_addMethod( + subclass, + selector, + imp_implementationWithBlock(body), + typeEncoding + ) + precondition( + succeeds, + "RAC attempts to swizzle a selector that has already a runtime injected implementation. This is unsupported in the current version." + ) + } + } + } + } + } +} + +/// ISA-swizzle the class of the supplied instance. +/// +/// - note: If the instance has already been isa-swizzled, the swizzling happens +/// in place in the runtime subclass created by external parties. +/// +/// - warning: The swizzling **does not** apply on a per-instance basis. In +/// other words, repetitive swizzling of the same selector would +/// overwrite previous swizzling attempts, despite a different +/// instance being supplied. +/// +/// - parameters: +/// - instance: The instance to be swizzled. +/// +/// - returns: The runtime subclass of the perceived class of the instance. +internal func swizzleClass(_ instance: NSObject) -> AnyClass { + if let knownSubclass = instance.associations.value(forKey: knownRuntimeSubclassKey) { + return knownSubclass + } + + let perceivedClass: AnyClass = instance.objcClass + let realClass: AnyClass = object_getClass(instance)! + let realClassAssociations = Associations(realClass as AnyObject) + + if perceivedClass != realClass { + // If the class is already lying about what it is, it's probably a KVO + // dynamic subclass or something else that we shouldn't subclass at runtime. + synchronized(realClass) { + let isSwizzled = realClassAssociations.value(forKey: runtimeSubclassedKey) + if !isSwizzled { + replaceGetClass(in: realClass, decoy: perceivedClass) + realClassAssociations.setValue(true, forKey: runtimeSubclassedKey) + } + } + + return realClass + } + else { + let name = subclassName(of: perceivedClass) + let subclass: AnyClass = name.withCString { cString in + if let existingClass = objc_getClass(cString) as! AnyClass? { + return existingClass + } + else { + let subclass: AnyClass = objc_allocateClassPair(perceivedClass, cString, 0)! + replaceGetClass(in: subclass, decoy: perceivedClass) + objc_registerClassPair(subclass) + return subclass + } + } + + object_setClass(instance, subclass) + instance.associations.setValue(subclass, forKey: knownRuntimeSubclassKey) + return subclass + } +} + +private func subclassName(of class: AnyClass) -> String { + return String(cString: class_getName(`class`)).appending("_RACSwift") +} + +/// Swizzle the `-class` and `+class` methods. +/// +/// - parameters: +/// - class: The class to swizzle. +/// - perceivedClass: The class to be reported by the methods. +private func replaceGetClass(in class: AnyClass, decoy perceivedClass: AnyClass) { + let getClass: @convention(block) (UnsafeRawPointer?) -> AnyClass = { _ in + return perceivedClass + } + + let impl = imp_implementationWithBlock(getClass as Any) + + _ = class_replaceMethod( + `class`, + ObjCSelector.getClass, + impl, + ObjCMethodEncoding.getClass + ) + + _ = class_replaceMethod( + object_getClass(`class`), + ObjCSelector.getClass, + impl, + ObjCMethodEncoding.getClass + ) +} +#endif diff --git a/Sources/CombineCocoa/Interception/ObjC+Selector.swift b/Sources/CombineCocoa/Interception/ObjC+Selector.swift new file mode 100644 index 0000000..4ee73c3 --- /dev/null +++ b/Sources/CombineCocoa/Interception/ObjC+Selector.swift @@ -0,0 +1,53 @@ +// +// ObjC+Selector.swift +// CombineCocoa +// +// Created by Maxim Krouk on 22.06.21. +// Copyright © 2020 Combine Community. All rights reserved. +// + +import Foundation + +extension Selector { + /// `self` as a pointer. It is uniqued across instances, similar to + /// `StaticString`. + internal var utf8Start: UnsafePointer { + return unsafeBitCast(self, to: UnsafePointer.self) + } + + /// An alias of `self`, used in method interception. + internal var alias: Selector { + return prefixing("rac0_") + } + + /// An alias of `self`, used in method interception specifically for + /// preserving (if found) an immediate implementation of `self` in the + /// runtime subclass. + internal var interopAlias: Selector { + return prefixing("rac1_") + } + + /// An alias of `self`, used for delegate proxies. + internal var delegateProxyAlias: Selector { + return prefixing("rac2_") + } + + internal func prefixing(_ prefix: StaticString) -> Selector { + let length = Int(strlen(utf8Start)) + let prefixedLength = length + prefix.utf8CodeUnitCount + + let asciiPrefix = UnsafeRawPointer(prefix.utf8Start).assumingMemoryBound(to: Int8.self) + + let cString = UnsafeMutablePointer.allocate(capacity: prefixedLength + 1) + defer { + cString.deinitialize(count: prefixedLength + 1) + cString.deallocate() + } + + cString.initialize(from: asciiPrefix, count: prefix.utf8CodeUnitCount) + (cString + prefix.utf8CodeUnitCount).initialize(from: utf8Start, count: length) + (cString + prefixedLength).initialize(to: Int8(UInt8(ascii: "\0"))) + + return sel_registerName(cString) + } +} diff --git a/Sources/CombineCocoa/Interception/Synchronizing.swift b/Sources/CombineCocoa/Interception/Synchronizing.swift new file mode 100644 index 0000000..72d3d5f --- /dev/null +++ b/Sources/CombineCocoa/Interception/Synchronizing.swift @@ -0,0 +1,17 @@ +// +// Synchronizing.swift +// CombineCocoa +// +// Created by Maxim Krouk on 22.06.21. +// Copyright © 2020 Combine Community. All rights reserved. +// + +import Foundation + +internal func synchronized(_ token: AnyObject, execute: () throws -> Result) rethrows + -> Result +{ + objc_sync_enter(token) + defer { objc_sync_exit(token) } + return try execute() +} diff --git a/Sources/Runtime/ObjcRuntimeAliases.m b/Sources/Runtime/ObjcRuntimeAliases.m new file mode 100644 index 0000000..6c5dd98 --- /dev/null +++ b/Sources/Runtime/ObjcRuntimeAliases.m @@ -0,0 +1,21 @@ +// +// ObjcRuntimeAliases.m +// +// +// Created by Benjamin Deckys on 2023/07/27. +// + +#import +#import + +const IMP _combinecocoa_objc_msgForward = _objc_msgForward; + +void _combinecocoa_objc_setAssociatedObject( + const void* object, + const void* key, + id value, + objc_AssociationPolicy policy + ) { + __unsafe_unretained id obj = (__bridge typeof(obj)) object; + objc_setAssociatedObject(obj, key, value, policy); +} diff --git a/Sources/Runtime/include/ObjcDelegateProxy.h b/Sources/Runtime/include/ObjcDelegateProxy.h index a7d7d2f..14a75c3 100644 --- a/Sources/Runtime/include/ObjcDelegateProxy.h +++ b/Sources/Runtime/include/ObjcDelegateProxy.h @@ -7,6 +7,7 @@ // #import +#import "ObjcRuntimeAliases.h" @interface ObjcDelegateProxy: NSObject diff --git a/Sources/Runtime/include/ObjcRuntimeAliases.h b/Sources/Runtime/include/ObjcRuntimeAliases.h new file mode 100644 index 0000000..93aa744 --- /dev/null +++ b/Sources/Runtime/include/ObjcRuntimeAliases.h @@ -0,0 +1,17 @@ +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +extern const IMP _combinecocoa_objc_msgForward; + +/// A trampoline of `objc_setAssociatedObject` that is made to circumvent the +/// reference counting calls in the imported version in Swift. +void _combinecocoa_objc_setAssociatedObject( + const void* object, + const void* key, + id _Nullable value, + objc_AssociationPolicy policy +); + +NS_ASSUME_NONNULL_END