diff --git a/Package.swift b/Package.swift index 3c3ea75..f0f4e94 100644 --- a/Package.swift +++ b/Package.swift @@ -5,10 +5,10 @@ import PackageDescription let package = Package( name: "InterposeKit", platforms: [ - .iOS(.v12), - .macOS(.v10_13), - .tvOS(.v12), - .watchOS(.v5) + .iOS(.v13), + .macOS(.v10_15), + .tvOS(.v13), + .watchOS(.v6) ], products: [ .library( diff --git a/Sources/InterposeKit/AnyHook.swift b/Sources/InterposeKit/AnyHook.swift deleted file mode 100644 index 1a7abe8..0000000 --- a/Sources/InterposeKit/AnyHook.swift +++ /dev/null @@ -1,99 +0,0 @@ -import Foundation - -/// Base class, represents a hook to exactly one method. -public class AnyHook { - /// The class this hook is based on. - public let `class`: AnyClass - - /// The selector this hook interposes. - public let selector: Selector - - /// The current state of the hook. - public internal(set) var state = State.prepared - - // else we validate init order - var replacementIMP: IMP! - - // fetched at apply time, changes late, thus class requirement - var origIMP: IMP? - - /// The possible task states - public enum State: Equatable { - /// The task is prepared to be interposed. - case prepared - - /// The method has been successfully interposed. - case interposed - - /// An error happened while interposing a method. - indirect case error(InterposeError) - } - - init(`class`: AnyClass, selector: Selector) throws { - self.selector = selector - self.class = `class` - - // Check if method exists - try validate() - } - - func replaceImplementation() throws { - preconditionFailure("Not implemented") - } - - func resetImplementation() throws { - preconditionFailure("Not implemented") - } - - /// Apply the interpose hook. - @discardableResult public func apply() throws -> AnyHook { - try execute(newState: .interposed) { try replaceImplementation() } - return self - } - - /// Revert the interpose hook. - @discardableResult public func revert() throws -> AnyHook { - try execute(newState: .prepared) { try resetImplementation() } - return self - } - - /// Validate that the selector exists on the active class. - @discardableResult func validate(expectedState: State = .prepared) throws -> Method { - guard let method = class_getInstanceMethod(`class`, selector) else { - throw InterposeError.methodNotFound(`class`, selector) - } - guard state == expectedState else { throw InterposeError.invalidState(expectedState: expectedState) } - return method - } - - private func execute(newState: State, task: () throws -> Void) throws { - do { - try task() - state = newState - } catch let error as InterposeError { - state = .error(error) - throw error - } - } - - /// Release the hook block if possible. - public func cleanup() { - switch state { - case .prepared: - Interpose.log("Releasing -[\(`class`).\(selector)] IMP: \(replacementIMP!)") - imp_removeBlock(replacementIMP) - case .interposed: - Interpose.log("Keeping -[\(`class`).\(selector)] IMP: \(replacementIMP!)") - case let .error(error): - Interpose.log("Leaking -[\(`class`).\(selector)] IMP: \(replacementIMP!) due to error: \(error)") - } - } -} - -/// Hook baseclass with generic signatures. -public class TypedHook: AnyHook { - /// The original implementation of the hook. Might be looked up at runtime. Do not cache this. - public var original: MethodSignature { - preconditionFailure("Always override") - } -} diff --git a/Sources/InterposeKit/ClassHook.swift b/Sources/InterposeKit/ClassHook.swift deleted file mode 100644 index 6070068..0000000 --- a/Sources/InterposeKit/ClassHook.swift +++ /dev/null @@ -1,46 +0,0 @@ -import Foundation - -extension Interpose { - /// A hook to an instance method and stores both the original and new implementation. - final public class ClassHook: TypedHook { - - public init( - `class`: AnyClass, - selector: Selector, - implementation: (ClassHook) -> HookSignature - ) throws { - try super.init(class: `class`, selector: selector) - replacementIMP = imp_implementationWithBlock(implementation(self)) - } - - override func replaceImplementation() throws { - let method = try validate() - origIMP = class_replaceMethod(`class`, selector, replacementIMP, method_getTypeEncoding(method)) - guard origIMP != nil else { throw InterposeError.nonExistingImplementation(`class`, selector) } - Interpose.log("Swizzled -[\(`class`).\(selector)] IMP: \(origIMP!) -> \(replacementIMP!)") - } - - override func resetImplementation() throws { - let method = try validate(expectedState: .interposed) - precondition(origIMP != nil) - let previousIMP = class_replaceMethod(`class`, selector, origIMP!, method_getTypeEncoding(method)) - guard previousIMP == replacementIMP else { - throw InterposeError.unexpectedImplementation(`class`, selector, previousIMP) - } - Interpose.log("Restored -[\(`class`).\(selector)] IMP: \(origIMP!)") - } - - /// The original implementation is cached at hook time. - public override var original: MethodSignature { - unsafeBitCast(origIMP, to: MethodSignature.self) - } - } -} - -#if DEBUG -extension Interpose.ClassHook: CustomDebugStringConvertible { - public var debugDescription: String { - return "\(selector) -> \(String(describing: origIMP))" - } -} -#endif diff --git a/Sources/InterposeKit/Deprecated/Hook+Deprecated.swift b/Sources/InterposeKit/Deprecated/Hook+Deprecated.swift new file mode 100644 index 0000000..22368df --- /dev/null +++ b/Sources/InterposeKit/Deprecated/Hook+Deprecated.swift @@ -0,0 +1,36 @@ +extension Hook { + + @available(*, deprecated, renamed: "HookState", message: "Use top-level 'HookState'.") + public typealias State = HookState + + @available(*, deprecated, message: "Use 'apply()' returning Void. The overload returning 'Self' has been removed.") + @_disfavoredOverload + public func apply() throws -> Self { + try self.apply() + return self + } + + @available(*, deprecated, message: "Use 'revert()' returning Void. The overload returning 'Self' has been removed.") + @_disfavoredOverload + public func revert() throws -> Self { + try revert() + return self + } + +} + +extension HookState { + + @available(*, deprecated, renamed: "pending", message: "Use 'pending' instead.") + public static var prepared: Self { .pending } + + @available(*, deprecated, renamed: "active", message: "Use 'active' instead.") + public static var interposed: Self { .active } + + @available(*, deprecated, renamed: "failed", message: """ + Use 'failed' instead. The state no longer carries an associated error—handle errors where + the hook is applied. + """) + public static var error: Self { .failed } + +} diff --git a/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift b/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift new file mode 100644 index 0000000..c3ae362 --- /dev/null +++ b/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift @@ -0,0 +1,114 @@ +import ObjectiveC + +extension Interpose { + + @available( + *, + unavailable, + message: """ + The builder-based initializer pattern is no longer supported. Use the static method \ + 'Interpose.applyHook(on:for:methodSignature:hookSignature:build:)' for immediate \ + installation, or 'Interpose.prepareHook(…)' for manual control. + """ + ) + public init( + _ class: AnyClass, + builder: (Interpose) throws -> Void + ) throws { + Interpose.fail("Unavailable API") + } + + @available( + *, + unavailable, + message: """ + The builder-based initializer pattern is no longer supported. Use the static method \ + 'Interpose.applyHook(on:for:methodSignature:hookSignature:build:)' for immediate \ + installation, or 'Interpose.prepareHook(…)' for manual control. + """ + ) + public init( + _ object: NSObject, + builder: ((Interpose) throws -> Void)? = nil + ) throws { + Interpose.fail("Unavailable API") + } + + @available( + *, + unavailable, + message: """ + Instance method 'hook(_:methodSignature:hookSignature:_:)' is no longer supported. \ + Use 'Interpose.applyHook(on:for:methodSignature:hookSignature:build:)' instead. + """ + ) + @discardableResult + public func hook( + _ selectorName: String, + methodSignature: MethodSignature.Type, + hookSignature: HookSignature.Type, + _ build: @escaping HookBuilder + ) throws -> Hook { + Interpose.fail("Unavailable API") + } + + @available( + *, + unavailable, + message: """ + Instance method 'hook(_:methodSignature:hookSignature:_:)' is no longer supported. \ + Use 'Interpose.applyHook(on:for:methodSignature:hookSignature:build:)' instead. + """ + ) + @discardableResult + public func hook( + _ selector: Selector, + methodSignature: MethodSignature.Type, + hookSignature: HookSignature.Type, + _ build: @escaping HookBuilder + ) throws -> Hook { + Interpose.fail("Unavailable API") + } + + @available( + *, + unavailable, + message: """ + Instance method 'prepareHook(_:methodSignature:hookSignature:_:)' is no longer supported. \ + Use 'Interpose.prepareHook(on:for:methodSignature:hookSignature:build:)' instead. + """ + ) + public func prepareHook( + _ selector: Selector, + methodSignature: MethodSignature.Type, + hookSignature: HookSignature.Type, + _ build: @escaping HookBuilder + ) throws -> Hook { + Interpose.fail("Unavailable API") + } + + @available( + *, + unavailable, + message: """ + 'apply()' is no longer supported. Use 'Interpose.applyHook(…)' to apply individual hooks \ + directly using the new static API. + """ + ) + public func apply(_ builder: ((Interpose) throws -> Void)? = nil) throws -> Interpose { + Interpose.fail("Unavailable API") + } + + @available( + *, + unavailable, + message: """ + 'revert()' is no longer supported. Keep a reference to each individual hook and call \ + 'revert()' on them directly. + """ + ) + public func revert(_ builder: ((Interpose) throws -> Void)? = nil) throws -> Interpose { + Interpose.fail("Unavailable API") + } + +} diff --git a/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift b/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift new file mode 100644 index 0000000..35842eb --- /dev/null +++ b/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift @@ -0,0 +1,47 @@ +import ObjectiveC + +extension NSObject { + + @available(*, deprecated, renamed: "applyHook(for:methodSignature:hookSignature:build:)") + @discardableResult + public func hook ( + _ selector: Selector, + methodSignature: MethodSignature.Type, + hookSignature: HookSignature.Type, + _ build: @escaping HookBuilder + ) throws -> Hook { + precondition( + !(self is AnyClass), + "There should not be a way to cast an NSObject to AnyClass." + ) + + return try self.applyHook( + for: selector, + methodSignature: methodSignature, + hookSignature: hookSignature, + build: build + ) + } + + @available(*, deprecated, message: """ + Deprecated to avoid confusion: this hooks instance methods on classes, but can be mistaken \ + for hooking class methods, which is not supported. Use `Interpose(Class.self)` with \ + `prepareHook(…)` to make the intent explicit. + """) + @discardableResult + public class func hook ( + _ selector: Selector, + methodSignature: MethodSignature.Type, + hookSignature: HookSignature.Type, + _ build: @escaping HookBuilder + ) throws -> Hook { + let hook = try Hook( + target: .class(self), + selector: selector, + build: build + ) + try hook.apply() + return hook + } + +} diff --git a/Sources/InterposeKit/HookFinder.swift b/Sources/InterposeKit/HookFinder.swift deleted file mode 100644 index fccf06f..0000000 --- a/Sources/InterposeKit/HookFinder.swift +++ /dev/null @@ -1,53 +0,0 @@ -import Foundation - -extension Interpose { - - private struct AssociatedKeys { - static var hookForBlock: UInt8 = 0 - } - - private class WeakObjectContainer: NSObject { - private weak var _object: T? - - var object: T? { - return _object - } - init(with object: T?) { - _object = object - } - } - - static func storeHook(hook: HookType, to block: AnyObject) { - // Weakly store reference to hook inside the block of the IMP. - objc_setAssociatedObject(block, &AssociatedKeys.hookForBlock, - WeakObjectContainer(with: hook), .OBJC_ASSOCIATION_RETAIN) - - } - - // Finds the hook to a given implementation. - static func hookForIMP(_ imp: IMP) -> HookType? { - // Get the block that backs our IMP replacement - guard let block = imp_getBlock(imp) else { return nil } - let container = objc_getAssociatedObject(block, &AssociatedKeys.hookForBlock) as? WeakObjectContainer - return container?.object - } - - // Find the hook above us (not necessarily topmost) - static func findNextHook(selfHook: HookType, topmostIMP: IMP) -> HookType? { - // We are not topmost hook, so find the hook above us! - var impl: IMP? = topmostIMP - var currentHook: HookType? - repeat { - // get topmost hook - let hook: HookType? = Interpose.hookForIMP(impl!) - if hook === selfHook { - // return parent - return currentHook - } - // crawl down the chain until we find ourselves - currentHook = hook - impl = hook?.origIMP - } while impl != nil - return nil - } -} diff --git a/Sources/InterposeKit/Hooks/Hook.swift b/Sources/InterposeKit/Hooks/Hook.swift new file mode 100644 index 0000000..1d46361 --- /dev/null +++ b/Sources/InterposeKit/Hooks/Hook.swift @@ -0,0 +1,242 @@ +import ObjectiveC + +/// A runtime hook that interposes a single instance method on a class or object. +public final class Hook { + + // ============================================================================ // + // MARK: Initialization + // ============================================================================ // + + internal init( + target: HookTarget, + selector: Selector, + build: @escaping HookBuilder + ) throws { + self.makeStrategy = { hook in + let makeHookIMP: () -> IMP = { [weak hook] in + + // Hook should never be deallocated when invoking `makeHookIMP()`, as this only + // happens when installing implementation from within the strategy, which is + // triggered from a live hook instance. + guard let hook else { + Interpose.fail( + """ + Internal inconsistency: Hook instance was deallocated before the hook \ + implementation could be created. + """ + ) + } + + let hookProxy = HookProxy( + selector: selector, + getOriginal: { + unsafeBitCast( + hook.originalIMP, + to: MethodSignature.self + ) + } + ) + + let hookBlock = build(hookProxy) + let hookIMP = imp_implementationWithBlock(hookBlock) + return hookIMP + } + + switch target { + case .class(let `class`): + return ClassHookStrategy( + class: `class`, + selector: selector, + makeHookIMP: makeHookIMP + ) + case .object(let object): + return ObjectHookStrategy( + object: object, + selector: selector, + makeHookIMP: makeHookIMP + ) + } + } + + try self.strategy.validate() + } + + // ============================================================================ // + // MARK: Target Info + // ============================================================================ // + + /// The class whose instance method is being interposed. + public var `class`: AnyClass { + self.strategy.class + } + + public var scope: HookScope { + self.strategy.scope + } + + /// The selector identifying the instance method being interposed. + public var selector: Selector { + self.strategy.selector + } + + // ============================================================================ // + // MARK: State + // ============================================================================ // + + /// The current state of the hook. + public internal(set) var state = HookState.pending + + // ============================================================================ // + // MARK: Applying & Reverting + // ============================================================================ // + + /// Applies the hook by interposing the method implementation. + public func apply() throws { + guard self.state == .pending else { return } + + do { + try self.strategy.replaceImplementation() + self.state = .active + } catch { + self.state = .failed + throw error + } + } + + /// Reverts the hook, restoring the original method implementation. + public func revert() throws { + guard self.state == .active else { return } + + do { + try self.strategy.restoreImplementation() + self.state = .pending + } catch { + self.state = .failed + throw error + } + } + + // ============================================================================ // + // MARK: Original Implementation + // ============================================================================ // + + /// The effective original implementation of the method being hooked. + /// + /// Resolved via the active strategy. If the hook has been applied, it returns a stored + /// original implementation. Otherwise, it performs a dynamic lookup at runtime. + /// + /// Provided to the hook builder via a proxy to enable calls to the original implementation. + /// This value is dynamic and must not be cached. + internal var originalIMP: IMP { + self.strategy.originalIMP + } + + // ============================================================================ // + // MARK: Underlying Strategy + // ============================================================================ // + + /// The strategy responsible for interposing and managing the method implementation. + /// + /// Lazily initialized because strategy construction requires `self` to be passed into + /// the hook proxy when building the hook implementation. + private lazy var strategy: HookStrategy = { self.makeStrategy(self) }() + + /// A closure that creates the strategy powering the hook. + private let makeStrategy: (Hook) -> HookStrategy + + // ============================================================================ // + // MARK: Deinitialization + // ============================================================================ // + + deinit { + var logComponents = [String]() + + switch self.state { + case .pending: + logComponents.append("Releasing") + case .active: + logComponents.append("Keeping") + case .failed: + logComponents.append("Leaking") + } + + logComponents.append("-[\(self.class) \(self.selector)]") + + if let hookIMP = self.strategy.appliedHookIMP { + logComponents.append("IMP: \(hookIMP)") + } + + Interpose.log(logComponents.joined(separator: " ")) + } + +} + +extension Hook: CustomDebugStringConvertible { + public var debugDescription: String { + self.strategy.debugDescription + } +} + +/// A closure that builds a hook implementation block for a method. +/// +/// Receives a proxy to the hook, which provides access to the selector and the original +/// implementation, and returns a block to be installed when the hook is applied. +/// +/// `MethodSignature` is the C function type of the original method implementation, typically +/// in the form: `(@convention(c) (NSObject, Selector, Params…) -> ReturnValue).self`. +/// +/// `HookSignature` is the block type used as the replacement, typically in the form: +/// `(@convention(block) (NSObject, Params…) -> ReturnValue).self`. +public typealias HookBuilder = (HookProxy) -> HookSignature + +/// A lightweight proxy passed to a `HookBuilder`, providing access to the selector and original +/// implementation of the hooked method. +public final class HookProxy { + + internal init( + selector: Selector, + getOriginal: @escaping () -> MethodSignature + ) { + self.selector = selector + self._getOriginal = getOriginal + } + + /// The selector of the method being hooked. + public let selector: Selector + + /// The original method implementation, safe to call from within the hook block. + public var original: MethodSignature { + self._getOriginal() + } + + private let _getOriginal: () -> MethodSignature + +} + +public enum HookScope { + + /// The scope that targets all instances of the class. + case `class` + + /// The scope that targets a specific instance of the class. + case object(NSObject) + +} + +public enum HookState: Equatable { + + /// The hook is ready to be applied. + case pending + + /// The hook has been successfully applied. + case active + + /// The hook failed to apply. + case failed + +} + +internal enum HookTarget { + case `class`(AnyClass) + case object(NSObject) +} diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy/ClassHookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy/ClassHookStrategy.swift new file mode 100644 index 0000000..e165fb6 --- /dev/null +++ b/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy/ClassHookStrategy.swift @@ -0,0 +1,87 @@ +import Foundation + +final class ClassHookStrategy: HookStrategy { + + init( + `class`: AnyClass, + selector: Selector, + makeHookIMP: @escaping () -> IMP + ) { + self.class = `class` + self.selector = selector + self.makeHookIMP = makeHookIMP + } + + let `class`: AnyClass + var scope: HookScope { .class } + let selector: Selector + private let makeHookIMP: () -> IMP + private(set) var appliedHookIMP: IMP? + private(set) var storedOriginalIMP: IMP? + + func validate() throws { + guard class_getInstanceMethod(self.class, self.selector) != nil else { + throw InterposeError.methodNotFound(self.class, self.selector) + } + } + + func replaceImplementation() throws { + let hookIMP = self.makeHookIMP() + self.appliedHookIMP = hookIMP + + guard let method = class_getInstanceMethod(self.class, self.selector) else { + throw InterposeError.methodNotFound(self.class, self.selector) + } + + guard let originalIMP = class_replaceMethod( + self.class, + self.selector, + hookIMP, + method_getTypeEncoding(method) + ) else { + throw InterposeError.nonExistingImplementation(self.class, self.selector) + } + + self.storedOriginalIMP = originalIMP + + Interpose.log("Swizzled -[\(self.class).\(self.selector)] IMP: \(originalIMP) -> \(hookIMP)") + } + + func restoreImplementation() throws { + guard let hookIMP = self.appliedHookIMP else { return } + + defer { + imp_removeBlock(hookIMP) + self.appliedHookIMP = nil + } + + guard let method = class_getInstanceMethod(self.class, self.selector) else { + throw InterposeError.methodNotFound(self.class, self.selector) + } + + guard let originalIMP = self.storedOriginalIMP else { + // Ignore? Throw error? + Interpose.fail("The original implementation should be loaded when resetting") + } + + let previousIMP = class_replaceMethod( + self.class, + self.selector, + originalIMP, + method_getTypeEncoding(method) + ) + + guard previousIMP == hookIMP else { + throw InterposeError.unexpectedImplementation(self.class, self.selector, previousIMP) + } + + Interpose.log("Restored -[\(self.class).\(self.selector)] IMP: \(originalIMP)") + } + +} + +extension ClassHookStrategy: CustomDebugStringConvertible { + internal var debugDescription: String { + "\(self.selector) → \(String(describing: self.storedOriginalIMP))" + } +} diff --git a/Sources/InterposeKit/Hooks/HookStrategy/HookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/HookStrategy.swift new file mode 100644 index 0000000..8d78136 --- /dev/null +++ b/Sources/InterposeKit/Hooks/HookStrategy/HookStrategy.swift @@ -0,0 +1,73 @@ +import ObjectiveC + +internal protocol HookStrategy: AnyObject, CustomDebugStringConvertible { + + var `class`: AnyClass { get } + var scope: HookScope { get } + var selector: Selector { get } + + /// Validates the target and throws if invalid. + func validate() throws + + func replaceImplementation() throws + func restoreImplementation() throws + + /// The current implementation used to interpose the method, created lazily when applying + /// the hook and removed when the hook is reverted. + var appliedHookIMP: IMP? { get } + + /// The original implementation captured when the hook is applied, restored when the hook + /// is reverted. + var storedOriginalIMP: IMP? { get } + +} + +extension HookStrategy { + + /// Returns the original implementation of the hooked method. + /// + /// If the hook has been applied, the stored original implementation is returned. + /// Otherwise, a dynamic lookup of the original implementation is performed using + /// `lookUpIMP()`. + /// + /// Crashes if no implementation can be found, which should only occur if the class + /// is in a funky state. + internal var originalIMP: IMP { + if let storedOriginalIMP = self.storedOriginalIMP { + return storedOriginalIMP + } + + if let originalIMP = self.lookUpIMP() { + return originalIMP + } + + Interpose.fail( + """ + No original implementation found for selector \(self.selector) on \(self.class). \ + This likely indicates a corrupted or misconfigured class. + """ + ) + } + + /// Dynamically resolves the current implementation of the hooked method by walking the class + /// hierarchy. + /// + /// This may return either the original or a hook implementation, depending on the state + /// of the hook. + /// + /// Use `originalIMP` if you explicitly need the original implementation. + internal func lookUpIMP() -> IMP? { + var currentClass: AnyClass? = self.class + + while let `class` = currentClass { + if let method = class_getInstanceMethod(`class`, self.selector) { + return method_getImplementation(method) + } else { + currentClass = class_getSuperclass(currentClass) + } + } + + return nil + } + +} diff --git a/Sources/InterposeKit/InterposeSubclass.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/InterposeSubclass.swift similarity index 71% rename from Sources/InterposeKit/InterposeSubclass.swift rename to Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/InterposeSubclass.swift index 3f2264a..95facf8 100644 --- a/Sources/InterposeKit/InterposeSubclass.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/InterposeSubclass.swift @@ -7,18 +7,6 @@ class InterposeSubclass { static let subclassSuffix = "InterposeKit_" } - enum ObjCSelector { - static let getClass = Selector((("class"))) - } - - enum ObjCMethodEncoding { - static let getClass = extract("#@:") - - private static func extract(_ string: StaticString) -> UnsafePointer { - return UnsafeRawPointer(string.utf8Start).assumingMemoryBound(to: CChar.self) - } - } - /// The object that is being hooked. let object: AnyObject @@ -32,11 +20,10 @@ class InterposeSubclass { /// If we make a dynamic subclass over KVO, invalidating the token crashes in cache_getImp. init(object: AnyObject) throws { self.object = object - dynamicClass = type(of: object) // satisfy set to something - dynamicClass = try getExistingSubclass() ?? createSubclass() + self.dynamicClass = try Self.getExistingSubclass(object: object) ?? Self.createSubclass(object: object) } - private func createSubclass() throws -> AnyClass { + private static func createSubclass(object: AnyObject) throws -> AnyClass { let perceivedClass: AnyClass = type(of: object) let actualClass: AnyClass = object_getClass(object)! @@ -51,7 +38,7 @@ class InterposeSubclass { return existingClass } else { guard let subclass: AnyClass = objc_allocateClassPair(actualClass, cString, 0) else { return nil } - replaceGetClass(in: subclass, decoy: perceivedClass) + class_setPerceivedClass(for: subclass, to: perceivedClass) objc_registerClassPair(subclass) return subclass } @@ -68,7 +55,7 @@ class InterposeSubclass { } /// We need to reuse a dynamic subclass if the object already has one. - private func getExistingSubclass() -> AnyClass? { + private static func getExistingSubclass(object: AnyObject) -> AnyClass? { let actualClass: AnyClass = object_getClass(object)! if NSStringFromClass(actualClass).hasPrefix(Constants.subclassSuffix) { return actualClass @@ -76,15 +63,6 @@ class InterposeSubclass { return nil } - private func replaceGetClass(in class: AnyClass, decoy perceivedClass: AnyClass) { - let getClass: @convention(block) (AnyObject) -> AnyClass = { _ in - 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) - } - class var supportsSuperTrampolines: Bool { ITKSuperBuilder.isSupportedArchitecture } diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookHandle.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookHandle.swift new file mode 100644 index 0000000..4bd62f2 --- /dev/null +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookHandle.swift @@ -0,0 +1,26 @@ +import ObjectiveC + +/// A lightweight handle for an object hook, providing access to the stored original IMP. +/// +/// Used internally to manage hook chaining and rewiring. The handle delegates all reads +/// and writes to the `ObjectHookStrategy` through the provided closures. +internal final class ObjectHookHandle { + + internal init( + getOriginalIMP: @escaping () -> IMP?, + setOriginalIMP: @escaping (IMP?) -> Void + ) { + self._getOriginalIMP = getOriginalIMP + self._setOriginalIMP = setOriginalIMP + } + + private let _getOriginalIMP: () -> IMP? + private let _setOriginalIMP: (IMP?) -> Void + + /// The original IMP stored for the object hook referenced by this handle. + internal var originalIMP: IMP? { + get { self._getOriginalIMP() } + set { self._setOriginalIMP(newValue) } + } + +} diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookRegistry.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookRegistry.swift new file mode 100644 index 0000000..e32176c --- /dev/null +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookRegistry.swift @@ -0,0 +1,51 @@ +import ObjectiveC + +internal enum ObjectHookRegistry { + + /// Associates the given object hook handle with the block-based IMP in a weak fashion. + internal static func register( + _ handle: ObjectHookHandle, + for imp: IMP + ) { + guard let block = imp_getBlock(imp) else { + Interpose.fail("IMP does not point to a block.") + } + + objc_setAssociatedObject( + block, + &self.associatedKey, + WeakReference(handle), + .OBJC_ASSOCIATION_RETAIN + ) + } + + /// Returns the object hook handle previously associated with the given block-based IMP, + /// if still alive. + internal static func handle(for imp: IMP) -> ObjectHookHandle? { + guard let block = imp_getBlock(imp) else { return nil } + + guard let reference = objc_getAssociatedObject( + block, + &self.associatedKey + ) as? WeakReference else { return nil } + + return reference.object + } + + private static var associatedKey: UInt8 = 0 + +} + +fileprivate class WeakReference: NSObject { + + fileprivate init(_ object: T?) { + self._object = object + } + + private weak var _object: T? + + fileprivate var object: T? { + return self._object + } + +} diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift new file mode 100644 index 0000000..9fd8175 --- /dev/null +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift @@ -0,0 +1,214 @@ +import Foundation + +final class ObjectHookStrategy: HookStrategy { + + init( + object: NSObject, + selector: Selector, + makeHookIMP: @escaping () -> IMP + ) { + self.class = type(of: object) + self.object = object + self.selector = selector + self.makeHookIMP = makeHookIMP + } + + let `class`: AnyClass + let object: NSObject + var scope: HookScope { .object(self.object) } + let selector: Selector + + private let makeHookIMP: () -> IMP + private(set) var appliedHookIMP: IMP? + private(set) var storedOriginalIMP: IMP? + + private lazy var handle = ObjectHookHandle( + getOriginalIMP: { [weak self] in self?.storedOriginalIMP }, + setOriginalIMP: { [weak self] in self?.storedOriginalIMP = $0 } + ) + + /// Subclass that we create on the fly + var interposeSubclass: InterposeSubclass? + + // Logic switch to use super builder + let generatesSuperIMP = InterposeSubclass.supportsSuperTrampolines + + var dynamicSubclass: AnyClass { + interposeSubclass!.dynamicClass + } + + func validate() throws { + guard class_getInstanceMethod(self.class, self.selector) != nil else { + throw InterposeError.methodNotFound(self.class, self.selector) + } + + if let _ = checkObjectPosingAsDifferentClass(self.object) { + if object_isKVOActive(self.object) { + throw InterposeError.keyValueObservationDetected(object) + } + // TODO: Handle the case where the object is posing as different class but not the interpose subclass + } + } + + func replaceImplementation() throws { + let hookIMP = self.makeHookIMP() + self.appliedHookIMP = hookIMP + ObjectHookRegistry.register(self.handle, for: hookIMP) + + guard let method = class_getInstanceMethod(self.class, self.selector) else { + throw InterposeError.methodNotFound(self.class, self.selector) + } + + // Check if there's an existing subclass we can reuse. + // Create one at runtime if there is none. + self.interposeSubclass = try InterposeSubclass(object: self.object) + + // The implementation of the call that is hooked must exist. + guard self.lookUpIMP() != nil else { + throw InterposeError.nonExistingImplementation(self.class, self.selector).log() + } + + // This function searches superclasses for implementations + let hasExistingMethod = self.exactClassImplementsSelector(self.dynamicSubclass, self.selector) + let encoding = method_getTypeEncoding(method) + + if self.generatesSuperIMP { + // If the subclass is empty, we create a super trampoline first. + // If a hook already exists, we must skip this. + if !hasExistingMethod { + self.interposeSubclass!.addSuperTrampoline(selector: self.selector) + } + + // Replace IMP (by now we guarantee that it exists) + self.storedOriginalIMP = class_replaceMethod(self.dynamicSubclass, self.selector, hookIMP, encoding) + guard self.storedOriginalIMP != nil else { + throw InterposeError.nonExistingImplementation(self.dynamicSubclass, self.selector) + } + Interpose.log("Added -[\(self.class).\(self.selector)] IMP: \(self.storedOriginalIMP!) -> \(hookIMP)") + } else { + // Could potentially be unified in the code paths + if hasExistingMethod { + self.storedOriginalIMP = class_replaceMethod(self.dynamicSubclass, self.selector, hookIMP, encoding) + if self.storedOriginalIMP != nil { + Interpose.log("Added -[\(self.class).\(self.selector)] IMP: \(hookIMP) via replacement") + } else { + Interpose.log("Unable to replace: -[\(self.class).\(self.selector)] IMP: \(hookIMP)") + throw InterposeError.unableToAddMethod(self.class, self.selector) + } + } else { + let didAddMethod = class_addMethod(self.dynamicSubclass, self.selector, hookIMP, encoding) + if didAddMethod { + Interpose.log("Added -[\(self.class).\(self.selector)] IMP: \(hookIMP)") + } else { + Interpose.log("Unable to add: -[\(self.class).\(self.selector)] IMP: \(hookIMP)") + throw InterposeError.unableToAddMethod(self.class, self.selector) + } + } + } + } + + /// Looks for an instance method in the exact class, without looking up the hierarchy. + private func exactClassImplementsSelector(_ klass: AnyClass, _ selector: Selector) -> Bool { + var methodCount: CUnsignedInt = 0 + guard let methodsInAClass = class_copyMethodList(klass, &methodCount) else { return false } + defer { free(methodsInAClass) } + for index in 0 ..< Int(methodCount) { + let method = methodsInAClass[index] + if method_getName(method) == selector { + return true + } + } + return false + } + + func restoreImplementation() throws { + guard let hookIMP = self.appliedHookIMP else { return } + defer { + imp_removeBlock(hookIMP) + self.appliedHookIMP = nil + } + + guard let method = class_getInstanceMethod(self.class, self.selector) else { + throw InterposeError.methodNotFound(self.class, self.selector) + } + + guard self.storedOriginalIMP != nil else { + // Removing methods at runtime is not supported. + // https://stackoverflow.com/questions/1315169/how-do-i-remove-instance-methods-at-runtime-in-objective-c-2-0 + // + // This codepath will be hit if the super helper is missing. + // We could recreate the whole class at runtime and rebuild all hooks, + // but that seems excessive when we have a trampoline at our disposal. + Interpose.log("Reset of -[\(self.class).\(self.selector)] not supported. No IMP") + throw InterposeError.resetUnsupported("No Original IMP found. SuperBuilder missing?") + } + + guard let currentIMP = class_getMethodImplementation(self.dynamicSubclass, self.selector) else { + throw InterposeError.unknownError("No Implementation found") + } + + // We are the topmost hook, replace method. + if currentIMP == hookIMP { + let previousIMP = class_replaceMethod(self.dynamicSubclass, self.selector, self.storedOriginalIMP!, method_getTypeEncoding(method)) + guard previousIMP == hookIMP else { + throw InterposeError.unexpectedImplementation(self.dynamicSubclass, selector, previousIMP) + } + Interpose.log("Restored -[\(self.class).\(self.selector)] IMP: \(self.storedOriginalIMP!)") + } else { + let nextHook = self._findParentHook(from: currentIMP) + // Replace next's original IMP + nextHook?.originalIMP = self.storedOriginalIMP + } + + // FUTURE: remove class pair! + // This might fail if we get KVO observed. + // objc_disposeClassPair does not return a bool but logs if it fails. + // + // objc_disposeClassPair(dynamicSubclass) + // self.dynamicSubclass = nil + } + + // Checks if a object is posing as a different class + // via implementing 'class' and returning something else. + private func checkObjectPosingAsDifferentClass(_ object: AnyObject) -> AnyClass? { + let perceivedClass: AnyClass = type(of: object) + let actualClass: AnyClass = object_getClass(object)! + if actualClass != perceivedClass { + return actualClass + } + return nil + } + + /// Traverses the object hook chain to find the handle to the parent of this hook, starting + /// from the topmost IMP for the hooked method. + /// + /// This is used when removing an object hook to rewire the parent hook’s original IMP + /// back to this hook’s original IMP, effectively unlinking it from the chain. + /// + /// - Parameter topmostIMP: The IMP of the topmost installed hook. + /// - Returns: The handle to the parent hook in the chain, or `nil` if topmost. + private func _findParentHook(from topmostIMP: IMP) -> ObjectHookHandle? { + var currentIMP: IMP? = topmostIMP + var previousHandle: ObjectHookHandle? + + while let imp = currentIMP { + // Get the handle for the current IMP and stop if not found. + guard let currentHandle = ObjectHookRegistry.handle(for: imp) else { break } + + // If we’ve reached this hook, the previous one is its parent. + if currentHandle === self.handle { return previousHandle } + + previousHandle = currentHandle + currentIMP = currentHandle.originalIMP + } + + return nil + } + +} + +extension ObjectHookStrategy: CustomDebugStringConvertible { + internal var debugDescription: String { + "\(self.selector) of \(self.object) → \(String(describing: self.originalIMP))" + } +} diff --git a/Sources/InterposeKit/Interpose.swift b/Sources/InterposeKit/Interpose.swift new file mode 100644 index 0000000..ca02b73 --- /dev/null +++ b/Sources/InterposeKit/Interpose.swift @@ -0,0 +1,97 @@ +import ObjectiveC + +/// Interpose is a modern library to swizzle elegantly in Swift. +public enum Interpose { + + // ============================================================================ // + // MARK: Class Hooks + // ============================================================================ // + + public static func prepareHook( + on `class`: AnyClass, + for selector: Selector, + methodSignature: MethodSignature.Type, + hookSignature: HookSignature.Type, + build: @escaping HookBuilder + ) throws -> Hook { + try Hook( + target: .class(`class`), + selector: selector, + build: build + ) + } + + @discardableResult + public static func applyHook( + on `class`: AnyClass, + for selector: Selector, + methodSignature: MethodSignature.Type, + hookSignature: HookSignature.Type, + build: @escaping HookBuilder + ) throws -> Hook { + let hook = try prepareHook( + on: `class`, + for: selector, + methodSignature: methodSignature, + hookSignature: hookSignature, + build: build + ) + try hook.apply() + return hook + } + + // ============================================================================ // + // MARK: Object Hooks + // ============================================================================ // + + public static func prepareHook( + on object: NSObject, + for selector: Selector, + methodSignature: MethodSignature.Type, + hookSignature: HookSignature.Type, + build: @escaping HookBuilder + ) throws -> Hook { + try Hook( + target: .object(object), + selector: selector, + build: build + ) + } + + @discardableResult + public static func applyHook( + on object: NSObject, + for selector: Selector, + methodSignature: MethodSignature.Type, + hookSignature: HookSignature.Type, + build: @escaping HookBuilder + ) throws -> Hook { + let hook = try prepareHook( + on: object, + for: selector, + methodSignature: methodSignature, + hookSignature: hookSignature, + build: build + ) + try hook.apply() + return hook + } + + // ============================================================================ // + // MARK: Logging + // ============================================================================ // + + /// The flag indicating whether logging is enabled. + public static var isLoggingEnabled = false + + internal static func log(_ message: String) { + if self.isLoggingEnabled { + print("[InterposeKit] \(message)") + } + } + + internal static func fail(_ message: String) -> Never { + fatalError("[InterposeKit] \(message)") + } + +} diff --git a/Sources/InterposeKit/InterposeError.swift b/Sources/InterposeKit/InterposeError.swift index e19a372..6b4298a 100644 --- a/Sources/InterposeKit/InterposeError.swift +++ b/Sources/InterposeKit/InterposeError.swift @@ -31,9 +31,6 @@ public enum InterposeError: LocalizedError { /// Use `NSClassFromString` to get the correct name. case objectPosingAsDifferentClass(AnyObject, actualClass: AnyClass) - /// Can't revert or apply if already done so. - case invalidState(expectedState: AnyHook.State) - /// Unable to remove hook. case resetUnsupported(_ reason: String) @@ -63,8 +60,6 @@ extension InterposeError: Equatable { return "Unable to hook object that uses Key Value Observing: \(obj)" case .objectPosingAsDifferentClass(let obj, let actualClass): return "Unable to hook \(type(of: obj)) posing as \(NSStringFromClass(actualClass))/" - case .invalidState(let expectedState): - return "Invalid State. Expected: \(expectedState)" case .resetUnsupported(let reason): return "Reset Unsupported: \(reason)" case .unknownError(let reason): diff --git a/Sources/InterposeKit/InterposeKit.swift b/Sources/InterposeKit/InterposeKit.swift deleted file mode 100644 index ea518ce..0000000 --- a/Sources/InterposeKit/InterposeKit.swift +++ /dev/null @@ -1,149 +0,0 @@ -import Foundation - -/// Interpose is a modern library to swizzle elegantly in Swift. -/// -/// Methods are hooked via replacing the implementation, instead of the usual exchange. -/// Supports both swizzling classes and individual objects. -final public class Interpose { - /// Stores swizzle hooks and executes them at once. - public let `class`: AnyClass - /// Lists all hooks for the current interpose class object. - public private(set) var hooks: [AnyHook] = [] - - /// If Interposing is object-based, this is set. - public let object: AnyObject? - - // Checks if a object is posing as a different class - // via implementing 'class' and returning something else. - private func checkObjectPosingAsDifferentClass(_ object: AnyObject) -> AnyClass? { - let perceivedClass: AnyClass = type(of: object) - let actualClass: AnyClass = object_getClass(object)! - if actualClass != perceivedClass { - return actualClass - } - return nil - } - - // This is based on observation, there is no documented way - private func isKVORuntimeGeneratedClass(_ klass: AnyClass) -> Bool { - NSStringFromClass(klass).hasPrefix("NSKVO") - } - - /// Initializes an instance of Interpose for a specific class. - /// If `builder` is present, `apply()` is automatically called. - public init(_ `class`: AnyClass, builder: ((Interpose) throws -> Void)? = nil) throws { - self.class = `class` - self.object = nil - - // Only apply if a builder is present - if let builder = builder { - try apply(builder) - } - } - - /// Initialize with a single object to interpose. - public init(_ object: NSObject, builder: ((Interpose) throws -> Void)? = nil) throws { - self.object = object - self.class = type(of: object) - - if let actualClass = checkObjectPosingAsDifferentClass(object) { - if isKVORuntimeGeneratedClass(actualClass) { - throw InterposeError.keyValueObservationDetected(object) - } else { - throw InterposeError.objectPosingAsDifferentClass(object, actualClass: actualClass) - } - } - - // Only apply if a builder is present - if let builder = builder { - try apply(builder) - } - } - - deinit { - hooks.forEach({ $0.cleanup() }) - } - - /// Hook an `@objc dynamic` instance method via selector name on the current class. - @discardableResult public func hook( - _ selName: String, - methodSignature: MethodSignature.Type = MethodSignature.self, - hookSignature: HookSignature.Type = HookSignature.self, - _ implementation: (TypedHook) -> HookSignature - ) throws -> TypedHook { - try hook(NSSelectorFromString(selName), - methodSignature: methodSignature, hookSignature: hookSignature, implementation) - } - - /// Hook an `@objc dynamic` instance method via selector on the current class. - @discardableResult public func hook ( - _ selector: Selector, - methodSignature: MethodSignature.Type = MethodSignature.self, - hookSignature: HookSignature.Type = HookSignature.self, - _ implementation: (TypedHook) -> HookSignature - ) throws -> TypedHook { - let hook = try prepareHook(selector, methodSignature: methodSignature, - hookSignature: hookSignature, implementation) - try hook.apply() - return hook - } - - /// Prepares a hook, but does not call apply immediately. - @discardableResult public func prepareHook ( - _ selector: Selector, - methodSignature: MethodSignature.Type = MethodSignature.self, - hookSignature: HookSignature.Type = HookSignature.self, - _ implementation: (TypedHook) -> HookSignature - ) throws -> TypedHook { - var hook: TypedHook - if let object = self.object { - hook = try ObjectHook(object: object, selector: selector, implementation: implementation) - } else { - hook = try ClassHook(class: `class`, selector: selector, implementation: implementation) - } - hooks.append(hook) - return hook - } - - /// Apply all stored hooks. - @discardableResult public func apply(_ hook: ((Interpose) throws -> Void)? = nil) throws -> Interpose { - try execute(hook) { try $0.apply() } - } - - /// Revert all stored hooks. - @discardableResult public func revert(_ hook: ((Interpose) throws -> Void)? = nil) throws -> Interpose { - try execute(hook, expectedState: .interposed) { try $0.revert() } - } - - private func execute(_ task: ((Interpose) throws -> Void)? = nil, - expectedState: AnyHook.State = .prepared, - executor: ((AnyHook) throws -> Void)) throws -> Interpose { - // Run pre-apply code first - if let task = task { - try task(self) - } - // Validate all tasks, stop if anything is not valid - guard hooks.allSatisfy({ - (try? $0.validate(expectedState: expectedState)) != nil - }) else { - throw InterposeError.invalidState(expectedState: expectedState) - } - // Execute all tasks - try hooks.forEach(executor) - return self - } -} - -// MARK: Logging - -extension Interpose { - /// Logging uses print and is minimal. - public static var isLoggingEnabled = false - - /// Simple log wrapper for print. - class func log(_ object: Any) { - if isLoggingEnabled { - print("[Interposer] \(object)") - } - } -} diff --git a/Sources/InterposeKit/Legacy/Interpose+Watcher.swift b/Sources/InterposeKit/Legacy/Interpose+Watcher.swift new file mode 100644 index 0000000..2ce7d5d --- /dev/null +++ b/Sources/InterposeKit/Legacy/Interpose+Watcher.swift @@ -0,0 +1,108 @@ +//import Foundation +//import MachO.dyld +// +//// MARK: Interpose Class Load Watcher +// +//extension Interpose { +// // Separate definitions to have more relevant calling syntax when completion is not needed. +// +// /// Interpose a class once available. Class is passed via `classParts` string array. +// @discardableResult public class func whenAvailable(_ classParts: [String], +// builder: @escaping (Interpose) throws -> Void) throws -> Waiter { +// try whenAvailable(classParts, builder: builder, completion: nil) +// } +// +// /// Interpose a class once available. Class is passed via `classParts` string array, with completion handler. +// @discardableResult public class func whenAvailable(_ classParts: [String], +// builder: @escaping (Interpose) throws -> Void, +// completion: (() -> Void)? = nil) throws -> Waiter { +// try whenAvailable(classParts.joined(), builder: builder, completion: completion) +// } +// +// /// Interpose a class once available. Class is passed via `className` string. +// @discardableResult public class func whenAvailable(_ className: String, +// builder: @escaping (Interpose) throws -> Void) throws -> Waiter { +// try whenAvailable(className, builder: builder, completion: nil) +// } +// +// /// Interpose a class once available. Class is passed via `className` string, with completion handler. +// @discardableResult public class func whenAvailable(_ className: String, +// builder: @escaping (Interpose) throws -> Void, +// completion: (() -> Void)? = nil) throws -> Waiter { +// try Waiter(className: className, builder: builder, completion: completion) +// } +// +// /// Helper that stores hooks to a specific class and executes them once the class becomes available. +// public struct Waiter { +// fileprivate let className: String +// private var builder: ((Interpose) throws -> Void)? +// private var completion: (() -> Void)? +// +// /// Initialize waiter object. +// @discardableResult init(className: String, +// builder: @escaping (Interpose) throws -> Void, +// completion: (() -> Void)? = nil) throws { +// self.className = className +// self.builder = builder +// self.completion = completion +// +// // Immediately try to execute task. If not there, install waiter. +// if try tryExecute() == false { +// InterposeWatcher.append(waiter: self) +// } +// } +// +// func tryExecute() throws -> Bool { +// guard let builder = self.builder else { return false } +// let interposer = Interpose() +// try builder(interposer) +// if let completion = self.completion { +// completion() +// } +// return true +// } +// } +//} +// +//// dyld C function cannot capture class context so we pack it in a static struct. +//private struct InterposeWatcher { +// // Global list of waiters; can be multiple per class +// private static var globalWatchers: [Interpose.Waiter] = { +// // Register after Swift global registers to not deadlock +// DispatchQueue.main.async { InterposeWatcher.installGlobalImageLoadWatcher() } +// return [] +// }() +// +// fileprivate static func append(waiter: Interpose.Waiter) { +// InterposeWatcher.globalWatcherQueue.sync { +// globalWatchers.append(waiter) +// } +// } +// +// // Register hook when dyld loads an image +// private static let globalWatcherQueue = DispatchQueue(label: "com.steipete.global-image-watcher") +// private static func installGlobalImageLoadWatcher() { +// _dyld_register_func_for_add_image { _, _ in +// InterposeWatcher.globalWatcherQueue.sync { +// // this is called on the thread the image is loaded. +// InterposeWatcher.globalWatchers = InterposeWatcher.globalWatchers.filter { waiter -> Bool in +// do { +// if try waiter.tryExecute() == false { +// return true // only collect if this fails because class is not there yet +// } else { +// Interpose.log("\(waiter.className) was successful.") +// } +// } catch { +// Interpose.log("Error while executing task: \(error).") +// // We can't bubble up the throw into the C context. +// #if DEBUG +// // Instead of silently eating, it's better to crash in DEBUG. +// Interpose.fail("Error while executing task: \(error).") +// #endif +// } +// return false +// } +// } +// } +// } +//} diff --git a/Sources/InterposeKit/NSObject+Interpose.swift b/Sources/InterposeKit/NSObject+Interpose.swift new file mode 100644 index 0000000..efd9b37 --- /dev/null +++ b/Sources/InterposeKit/NSObject+Interpose.swift @@ -0,0 +1,55 @@ +import ObjectiveC + +extension NSObject { + + /// Installs a hook for the specified selector on this object instance. + /// + /// Replaces the implementation of an instance method with a block-based hook, while providing + /// access to the original implementation through a proxy. + /// + /// To be hookable, the method must be exposed to the Objective-C runtime. When written + /// in Swift, it must be marked `@objc dynamic`. + /// + /// - Parameters: + /// - selector: The selector of the instance method to hook. + /// - methodSignature: The expected C function type of the original method implementation. + /// - hookSignature: The type of the hook block. + /// - build: A hook builder closure that receives a proxy to the hook (enabling access + /// to the original implementation) and returns the hook block. + /// + /// - Returns: The installed hook, which can later be reverted by calling `try hook.revert()`. + /// + /// - Throws: An error if the hook could not be applied—for example, if the method + /// does not exist or is not exposed to the Objective-C runtime. + /// + /// ### Example + /// ```swift + /// let hook = try object.applyHook( + /// for: #selector(MyClass.someMethod), + /// methodSignature: (@convention(c) (NSObject, Selector, Int) -> Void).self, + /// hookSignature: (@convention(block) (NSObject, Int) -> Void).self + /// ) { hook in + /// return { `self`, parameter in + /// hook.original(self, hook.selector, parameter) + /// } + /// } + /// + /// try hook.revert() + /// ``` + @discardableResult + public func applyHook( + for selector: Selector, + methodSignature: MethodSignature.Type, + hookSignature: HookSignature.Type, + build: @escaping HookBuilder + ) throws -> Hook { + let hook = try Hook( + target: .object(self), + selector: selector, + build: build + ) + try hook.apply() + return hook + } + +} diff --git a/Sources/InterposeKit/NSObject+InterposeKit.swift b/Sources/InterposeKit/NSObject+InterposeKit.swift deleted file mode 100644 index 7e6c952..0000000 --- a/Sources/InterposeKit/NSObject+InterposeKit.swift +++ /dev/null @@ -1,100 +0,0 @@ -import ObjectiveC - -extension NSObject { - - /// Installs a hook for the specified Objective-C selector on this object instance. - /// - /// This method replaces the implementation of an Objective-C instance method with - /// a block-based implementation, while optionally allowing access to the original - /// implementation. - /// - /// To be hookable, the method must be exposed to the Objective-C runtime. When written - /// in Swift, it must be marked `@objc dynamic`. - /// - /// - Parameters: - /// - selector: The selector of the instance method to hook. - /// - methodSignature: The expected C function type of the original method implementation. - /// - hookSignature: The type of the replacement block. - /// - implementation: A closure that receives a `TypedHook` and returns the replacement - /// implementation block. - /// - /// - Returns: An `AnyHook` representing the installed hook, allowing to remove the hook later - /// by calling `hook.revert()`. - /// - /// - Throws: An error if the hook could not be applied, such as if the method does not exist - /// or is not implemented in Objective-C. - /// - /// ### Example - /// - /// ```swift - /// let hook = try object.addHook( - /// for: #selector(MyClass.someMethod), - /// methodSignature: (@convention(c) (NSObject, Selector, Int) -> Void).self, - /// hookSignature: (@convention(block) (NSObject, Int) -> Void).self - /// ) { hook in - /// return { `self`, parameter in - /// hook.original(self, hook.selector, parameter) - /// } - /// } - /// - /// hook.revert() - /// ``` - @discardableResult - public func addHook( - for selector: Selector, - methodSignature: MethodSignature.Type, - hookSignature: HookSignature.Type, - implementation: (TypedHook) -> HookSignature - ) throws -> AnyHook { - try Interpose.ObjectHook( - object: self, - selector: selector, - implementation: implementation - ).apply() - } - -} - -extension NSObject { - - @available(*, deprecated, renamed: "addHook(for:methodSignature:hookSignature:implementation:)") - @discardableResult - public func hook ( - _ selector: Selector, - methodSignature: MethodSignature.Type = MethodSignature.self, - hookSignature: HookSignature.Type = HookSignature.self, - _ implementation: (TypedHook) -> HookSignature - ) throws -> AnyHook { - precondition( - !(self is AnyClass), - "There should not be a way to cast an NSObject to AnyClass." - ) - - return try self.addHook( - for: selector, - methodSignature: methodSignature, - hookSignature: hookSignature, - implementation: implementation - ) - } - - @available(*, deprecated, message: """ - Deprecated to avoid confusion: this hooks instance methods on classes, but can be mistaken \ - for hooking class methods, which is not supported. Use `Interpose(Class.self)` with \ - `prepareHook(…)` to make the intent explicit. - """) - @discardableResult - public class func hook ( - _ selector: Selector, - methodSignature: MethodSignature.Type = MethodSignature.self, - hookSignature: HookSignature.Type = HookSignature.self, - _ implementation: (TypedHook) -> HookSignature - ) throws -> AnyHook { - return try Interpose.ClassHook( - class: self as AnyClass, - selector: selector, - implementation: implementation - ).apply() - } - -} diff --git a/Sources/InterposeKit/ObjectHook.swift b/Sources/InterposeKit/ObjectHook.swift deleted file mode 100644 index e4caad3..0000000 --- a/Sources/InterposeKit/ObjectHook.swift +++ /dev/null @@ -1,187 +0,0 @@ -import Foundation - -extension Interpose { - - /// A hook to an instance method of a single object, stores both the original and new implementation. - /// Think about: Multiple hooks for one object - final public class ObjectHook: TypedHook { - - /// The object that is being hooked. - public let object: AnyObject - - /// Subclass that we create on the fly - var interposeSubclass: InterposeSubclass? - - // Logic switch to use super builder - let generatesSuperIMP = InterposeSubclass.supportsSuperTrampolines - - /// Initialize a new hook to interpose an instance method. - public init( - object: AnyObject, - selector: Selector, - implementation: (ObjectHook) -> HookSignature - ) throws { - self.object = object - try super.init(class: type(of: object), selector: selector) - let block = implementation(self) as AnyObject - replacementIMP = imp_implementationWithBlock(block) - guard replacementIMP != nil else { - throw InterposeError.unknownError("imp_implementationWithBlock failed for \(block) - slots exceeded?") - } - - // Weakly store reference to hook inside the block of the IMP. - Interpose.storeHook(hook: self, to: block) - } - - // /// Release the hook block if possible. - // public override func cleanup() { - // // remove subclass! - // super.cleanup() - // } - - /// The original implementation of the hook. Might be looked up at runtime. Do not cache this. - public override var original: MethodSignature { - // If we switched implementations, return stored. - if let savedOrigIMP = origIMP { - return unsafeBitCast(savedOrigIMP, to: MethodSignature.self) - } - // Else, perform a dynamic lookup - guard let origIMP = lookupOrigIMP else { InterposeError.nonExistingImplementation(`class`, selector).log() - preconditionFailure("IMP must be found for call") - } - return origIMP - } - - /// We look for the parent IMP dynamically, so later modifications to the class are no problem. - private var lookupOrigIMP: MethodSignature? { - var currentClass: AnyClass? = self.class - repeat { - if let currentClass = currentClass, - let method = class_getInstanceMethod(currentClass, self.selector) { - let origIMP = method_getImplementation(method) - return unsafeBitCast(origIMP, to: MethodSignature.self) - } - currentClass = class_getSuperclass(currentClass) - } while currentClass != nil - return nil - } - - /// Looks for an instance method in the exact class, without looking up the hierarchy. - func exactClassImplementsSelector(_ klass: AnyClass, _ selector: Selector) -> Bool { - var methodCount: CUnsignedInt = 0 - guard let methodsInAClass = class_copyMethodList(klass, &methodCount) else { return false } - defer { free(methodsInAClass) } - for index in 0 ..< Int(methodCount) { - let method = methodsInAClass[index] - if method_getName(method) == selector { - return true - } - } - return false - } - - var dynamicSubclass: AnyClass { - interposeSubclass!.dynamicClass - } - - override func replaceImplementation() throws { - let method = try validate() - - // Check if there's an existing subclass we can reuse. - // Create one at runtime if there is none. - interposeSubclass = try InterposeSubclass(object: object) - - // The implementation of the call that is hooked must exist. - guard lookupOrigIMP != nil else { - throw InterposeError.nonExistingImplementation(`class`, selector).log() - } - - // This function searches superclasses for implementations - let hasExistingMethod = exactClassImplementsSelector(dynamicSubclass, selector) - let encoding = method_getTypeEncoding(method) - - if self.generatesSuperIMP { - // If the subclass is empty, we create a super trampoline first. - // If a hook already exists, we must skip this. - if !hasExistingMethod { - interposeSubclass!.addSuperTrampoline(selector: selector) - } - - // Replace IMP (by now we guarantee that it exists) - origIMP = class_replaceMethod(dynamicSubclass, selector, replacementIMP, encoding) - guard origIMP != nil else { - throw InterposeError.nonExistingImplementation(dynamicSubclass, selector) - } - Interpose.log("Added -[\(`class`).\(selector)] IMP: \(origIMP!) -> \(replacementIMP!)") - } else { - // Could potentially be unified in the code paths - if hasExistingMethod { - origIMP = class_replaceMethod(dynamicSubclass, selector, replacementIMP, encoding) - if origIMP != nil { - Interpose.log("Added -[\(`class`).\(selector)] IMP: \(replacementIMP!) via replacement") - } else { - Interpose.log("Unable to replace: -[\(`class`).\(selector)] IMP: \(replacementIMP!)") - throw InterposeError.unableToAddMethod(`class`, selector) - } - } else { - let didAddMethod = class_addMethod(dynamicSubclass, selector, replacementIMP, encoding) - if didAddMethod { - Interpose.log("Added -[\(`class`).\(selector)] IMP: \(replacementIMP!)") - } else { - Interpose.log("Unable to add: -[\(`class`).\(selector)] IMP: \(replacementIMP!)") - throw InterposeError.unableToAddMethod(`class`, selector) - } - } - } - } - - override func resetImplementation() throws { - let method = try validate(expectedState: .interposed) - - guard super.origIMP != nil else { - // Removing methods at runtime is not supported. - // https://stackoverflow.com/questions/1315169/ - // how-do-i-remove-instance-methods-at-runtime-in-objective-c-2-0 - // - // This codepath will be hit if the super helper is missing. - // We could recreate the whole class at runtime and rebuild all hooks, - // but that seems excessive when we have a trampoline at our disposal. - Interpose.log("Reset of -[\(`class`).\(selector)] not supported. No IMP") - throw InterposeError.resetUnsupported("No Original IMP found. SuperBuilder missing?") - } - - guard let currentIMP = class_getMethodImplementation(dynamicSubclass, selector) else { - throw InterposeError.unknownError("No Implementation found") - } - - // We are the topmost hook, replace method. - if currentIMP == replacementIMP { - let previousIMP = class_replaceMethod( - dynamicSubclass, selector, origIMP!, method_getTypeEncoding(method)) - guard previousIMP == replacementIMP else { - throw InterposeError.unexpectedImplementation(dynamicSubclass, selector, previousIMP) - } - Interpose.log("Restored -[\(`class`).\(selector)] IMP: \(origIMP!)") - } else { - let nextHook = Interpose.findNextHook(selfHook: self, topmostIMP: currentIMP) - // Replace next's original IMP - nextHook?.origIMP = self.origIMP - } - - // FUTURE: remove class pair! - // This might fail if we get KVO observed. - // objc_disposeClassPair does not return a bool but logs if it fails. - // - // objc_disposeClassPair(dynamicSubclass) - // self.dynamicSubclass = nil - } - } -} - -#if DEBUG -extension Interpose.ObjectHook: CustomDebugStringConvertible { - public var debugDescription: String { - return "\(selector) of \(object) -> \(String(describing: original))" - } -} -#endif diff --git a/Sources/InterposeKit/Utilities/class_setPerceivedClass.swift b/Sources/InterposeKit/Utilities/class_setPerceivedClass.swift new file mode 100644 index 0000000..8f7aa02 --- /dev/null +++ b/Sources/InterposeKit/Utilities/class_setPerceivedClass.swift @@ -0,0 +1,33 @@ +import ObjectiveC + +/// Overrides the `class` method on a class and its metaclass to return a perceived class. +/// +/// This affects both instance-level and class-level calls to `[object class]` or `[Class class]`, +/// making the object appear as if it is an instance of `perceivedClass`. +/// +/// This does **not** change the actual `isa` pointer or affect dynamic dispatch. It simply +/// overrides how the object and class **report** their type. +/// +/// - Parameters: +/// - targetClass: The class whose `class` method should be overridden. +/// - perceivedClass: The class it should appear to be. +@inline(__always) +internal func class_setPerceivedClass( + for targetClass: AnyClass, + to perceivedClass: AnyClass +) { + let selector = Selector((("class"))) + + let impBlock: @convention(block) (AnyObject) -> AnyClass = { _ in perceivedClass } + let imp = imp_implementationWithBlock(impBlock) + + // Objective-C type encoding: "#@:" + // - # → return type is Class + // - @ → first parameter is 'self' (id) + // - : → second parameter is '_cmd' (SEL) + let encoding = UnsafeRawPointer(("#@:" as StaticString).utf8Start) + .assumingMemoryBound(to: CChar.self) + + _ = class_replaceMethod(targetClass, selector, imp, encoding) + _ = class_replaceMethod(object_getClass(targetClass), selector, imp, encoding) +} diff --git a/Sources/InterposeKit/Utilities/object_isKVOActive.swift b/Sources/InterposeKit/Utilities/object_isKVOActive.swift new file mode 100644 index 0000000..ea26d3d --- /dev/null +++ b/Sources/InterposeKit/Utilities/object_isKVOActive.swift @@ -0,0 +1,24 @@ +import ObjectiveC + +/// Returns whether the given object has KVO active and thus a runtime subclass installed. +/// +/// This calls `-[NSObject isKVOA]` via key-value coding, a private (but seemingly stable) API +/// used internally by the Objective-C runtime to indicate whether KVO has installed a dynamic +/// subclass on the object. +/// +/// The typical alternative to this approach is to inspect the object’s class name for the prefix +/// `"NSKVONotifying_"`. However, when the observed class is defined in Swift, we’ve observed +/// the runtime-generated subclass being prefixed with `".."`, resulting in names like +/// `"..NSKVONotifying_MyApp.MyClass"`. +/// +/// In practice, calling `-[NSObject isKVOA]` is a more robust and consistent check. +/// +/// - Parameter object: The object to check. +/// - Returns: `true` if KVO is active and the object has been subclassed at runtime; otherwise, +/// `false`. +@inline(__always) +internal func object_isKVOActive( + _ object: NSObject +) -> Bool { + return object.value(forKey: "_" + "i" + "s" + "K" + "V" + "O" + "A") as? Bool ?? false +} diff --git a/Sources/InterposeKit/Watcher.swift b/Sources/InterposeKit/Watcher.swift deleted file mode 100644 index 20da0fc..0000000 --- a/Sources/InterposeKit/Watcher.swift +++ /dev/null @@ -1,107 +0,0 @@ -import Foundation -import MachO.dyld - -// MARK: Interpose Class Load Watcher - -extension Interpose { - // Separate definitions to have more relevant calling syntax when completion is not needed. - - /// Interpose a class once available. Class is passed via `classParts` string array. - @discardableResult public class func whenAvailable(_ classParts: [String], - builder: @escaping (Interpose) throws -> Void) throws -> Waiter { - try whenAvailable(classParts, builder: builder, completion: nil) - } - - /// Interpose a class once available. Class is passed via `classParts` string array, with completion handler. - @discardableResult public class func whenAvailable(_ classParts: [String], - builder: @escaping (Interpose) throws -> Void, - completion: (() -> Void)? = nil) throws -> Waiter { - try whenAvailable(classParts.joined(), builder: builder, completion: completion) - } - - /// Interpose a class once available. Class is passed via `className` string. - @discardableResult public class func whenAvailable(_ className: String, - builder: @escaping (Interpose) throws -> Void) throws -> Waiter { - try whenAvailable(className, builder: builder, completion: nil) - } - - /// Interpose a class once available. Class is passed via `className` string, with completion handler. - @discardableResult public class func whenAvailable(_ className: String, - builder: @escaping (Interpose) throws -> Void, - completion: (() -> Void)? = nil) throws -> Waiter { - try Waiter(className: className, builder: builder, completion: completion) - } - - /// Helper that stores hooks to a specific class and executes them once the class becomes available. - public struct Waiter { - fileprivate let className: String - private var builder: ((Interpose) throws -> Void)? - private var completion: (() -> Void)? - - /// Initialize waiter object. - @discardableResult init(className: String, - builder: @escaping (Interpose) throws -> Void, - completion: (() -> Void)? = nil) throws { - self.className = className - self.builder = builder - self.completion = completion - - // Immediately try to execute task. If not there, install waiter. - if try tryExecute() == false { - InterposeWatcher.append(waiter: self) - } - } - - func tryExecute() throws -> Bool { - guard let `class` = NSClassFromString(className), let builder = self.builder else { return false } - try Interpose(`class`).apply(builder) - if let completion = self.completion { - completion() - } - return true - } - } -} - -// dyld C function cannot capture class context so we pack it in a static struct. -private struct InterposeWatcher { - // Global list of waiters; can be multiple per class - private static var globalWatchers: [Interpose.Waiter] = { - // Register after Swift global registers to not deadlock - DispatchQueue.main.async { InterposeWatcher.installGlobalImageLoadWatcher() } - return [] - }() - - fileprivate static func append(waiter: Interpose.Waiter) { - InterposeWatcher.globalWatcherQueue.sync { - globalWatchers.append(waiter) - } - } - - // Register hook when dyld loads an image - private static let globalWatcherQueue = DispatchQueue(label: "com.steipete.global-image-watcher") - private static func installGlobalImageLoadWatcher() { - _dyld_register_func_for_add_image { _, _ in - InterposeWatcher.globalWatcherQueue.sync { - // this is called on the thread the image is loaded. - InterposeWatcher.globalWatchers = InterposeWatcher.globalWatchers.filter { waiter -> Bool in - do { - if try waiter.tryExecute() == false { - return true // only collect if this fails because class is not there yet - } else { - Interpose.log("\(waiter.className) was successful.") - } - } catch { - Interpose.log("Error while executing task: \(error).") - // We can't bubble up the throw into the C context. - #if DEBUG - // Instead of silently eating, it's better to crash in DEBUG. - fatalError("Error while executing task: \(error).") - #endif - } - return false - } - } - } - } -} diff --git a/Tests/InterposeKitTests/ClassMethodInterposeTests.swift b/Tests/InterposeKitTests/ClassMethodInterposeTests.swift deleted file mode 100644 index aad6310..0000000 --- a/Tests/InterposeKitTests/ClassMethodInterposeTests.swift +++ /dev/null @@ -1,23 +0,0 @@ -@testable import InterposeKit -import XCTest - -final class ClassMethodInterposeTests: InterposeKitTestCase { - - func testClassMethod() { - XCTAssertThrowsError( - try Interpose(TestClass.self) { - try $0.prepareHook( - #selector(getter: TestClass.staticInt), - methodSignature: (@convention(c) (AnyObject, Selector) -> Int).self, - hookSignature: (@convention(block) (AnyObject) -> Int).self - ) { hook in - return { _ in 73 } - } - } - ) { error in - let typedError = error as! InterposeError - XCTAssertEqual(typedError, .methodNotFound(TestClass.self, #selector(getter: TestClass.staticInt))) - } - } - -} diff --git a/Tests/InterposeKitTests/Helpers/XCTest+Errors.swift b/Tests/InterposeKitTests/Helpers/XCTest+Errors.swift new file mode 100644 index 0000000..c1641d6 --- /dev/null +++ b/Tests/InterposeKitTests/Helpers/XCTest+Errors.swift @@ -0,0 +1,22 @@ +import XCTest + +// https://medium.com/@hybridcattt/how-to-test-throwing-code-in-swift-c70a95535ee5 +public func XCTAssertThrowsError( + _ expression: @autoclosure () throws -> T, + expected expectedError: E, + _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, + line: UInt = #line +) { + XCTAssertThrowsError(try expression(), message(), file: file, line: line) { error in + if let error = error as? E { + XCTAssertEqual(error, expectedError, file: file, line: line) + } else { + XCTFail( + "The type of the thrown error \(type(of: error)) does not match the type of the expected one \(type(of: expectedError)).", + file: file, + line: line + ) + } + } +} diff --git a/Tests/InterposeKitTests/HookTests.swift b/Tests/InterposeKitTests/HookTests.swift new file mode 100644 index 0000000..15c167d --- /dev/null +++ b/Tests/InterposeKitTests/HookTests.swift @@ -0,0 +1,54 @@ +@testable import InterposeKit +import XCTest + +fileprivate class ExampleClass: NSObject { + @objc dynamic func foo() {} +} + +fileprivate class ExampleSubclass: ExampleClass {} + +final class HookTests: InterposeKitTestCase { + + func testStates_success() throws { + let hook = try Interpose.prepareHook( + on: ExampleClass.self, + for: #selector(ExampleClass.foo), + methodSignature: (@convention(c) (NSObject, Selector) -> Void).self, + hookSignature: (@convention(block) (NSObject) -> Void).self + ) { hook in + return { `self` in } + } + XCTAssertEqual(hook.state, .pending) + + try hook.apply() + XCTAssertEqual(hook.state, .active) + + try hook.revert() + XCTAssertEqual(hook.state, .pending) + } + + func testStates_failure() throws { + // Interpose on a subclass that inherits but does not implement `foo`. + // We can prepare a hook, as the method is accessible from the subclass. + let hook = try Interpose.prepareHook( + on: ExampleSubclass.self, + for: #selector(ExampleClass.foo), + methodSignature: (@convention(c) (NSObject, Selector) -> Void).self, + hookSignature: (@convention(block) (NSObject) -> Void).self + ) { hook in + return { `self` in } + } + XCTAssertEqual(hook.state, .pending) + + // But applying the hook fails because the subclass has no implementation. + XCTAssertThrowsError( + try hook.apply(), + expected: InterposeError.nonExistingImplementation( + ExampleSubclass.self, + #selector(ExampleClass.foo) + ) + ) + XCTAssertEqual(hook.state, .failed) + } + +} diff --git a/Tests/InterposeKitTests/InterposeKitTestCase.swift b/Tests/InterposeKitTests/InterposeKitTestCase.swift deleted file mode 100644 index 674fc7f..0000000 --- a/Tests/InterposeKitTests/InterposeKitTestCase.swift +++ /dev/null @@ -1,37 +0,0 @@ -import XCTest -@testable import InterposeKit - -class InterposeKitTestCase: XCTestCase { - override func setUpWithError() throws { - Interpose.isLoggingEnabled = true - } -} - -extension InterposeKitTestCase { - /// Assert that a specific error is thrown. - func assert( - _ expression: @autoclosure () throws -> T, - throws error: E, - in file: StaticString = #file, - line: UInt = #line - ) { - // https://www.swiftbysundell.com/articles/testing-error-code-paths-in-swift/ - var thrownError: Error? - - XCTAssertThrowsError(try expression(), - file: file, line: line) { - thrownError = $0 - } - - XCTAssertTrue( - thrownError is E, - "Unexpected error type: \(type(of: thrownError))", - file: file, line: line - ) - - XCTAssertEqual( - thrownError as? E, error, - file: file, line: line - ) - } -} diff --git a/Tests/InterposeKitTests/InterposeKitTests.swift b/Tests/InterposeKitTests/InterposeKitTests.swift deleted file mode 100644 index 528c1f6..0000000 --- a/Tests/InterposeKitTests/InterposeKitTests.swift +++ /dev/null @@ -1,175 +0,0 @@ -import XCTest -@testable import InterposeKit - -final class InterposeKitTests: InterposeKitTestCase { - - override func setUpWithError() throws { - Interpose.isLoggingEnabled = true - } - - func testClassOverrideAndRevert() throws { - let testObj = TestClass() - XCTAssertEqual(testObj.sayHi(), testClassHi) - - // Functions need to be `@objc dynamic` to be hookable. - let interposer = try Interpose(TestClass.self) { - try $0.prepareHook( - #selector(TestClass.sayHi), - methodSignature: (@convention(c) (AnyObject, Selector) -> String).self, - hookSignature: (@convention(block) (AnyObject) -> String).self) { store in { bSelf in - // You're free to skip calling the original implementation. - print("Before Interposing \(bSelf)") - let string = store.original(bSelf, store.selector) - print("After Interposing \(bSelf)") - - return string + testString - } - } - } - print(TestClass().sayHi()) - - // Test various apply/revert's - XCTAssertEqual(testObj.sayHi(), testClassHi + testString) - try interposer.revert() - XCTAssertEqual(testObj.sayHi(), testClassHi) - try interposer.apply() - XCTAssertEqual(testObj.sayHi(), testClassHi + testString) - XCTAssertThrowsError(try interposer.apply()) - XCTAssertThrowsError(try interposer.apply()) - try interposer.revert() - XCTAssertThrowsError(try interposer.revert()) - try interposer.apply() - try interposer.revert() - XCTAssertEqual(testObj.sayHi(), testClassHi) - } - - func testSubclassOverride() throws { - let testObj = TestSubclass() - XCTAssertEqual(testObj.sayHi(), testClassHi + testSubclass) - - // Swizzle test class - let interposed = try Interpose(TestClass.self) { - try $0.prepareHook( - #selector(TestClass.sayHi), - methodSignature: (@convention(c) (AnyObject, Selector) -> String).self, - hookSignature: (@convention(block) (AnyObject) -> String).self) { store in { bSelf in - return store.original(bSelf, store.selector) + testString - } - } - } - - XCTAssertEqual(testObj.sayHi(), testClassHi + testString + testSubclass) - try interposed.revert() - XCTAssertEqual(testObj.sayHi(), testClassHi + testSubclass) - try interposed.apply() - XCTAssertEqual(testObj.sayHi(), testClassHi + testString + testSubclass) - - // Swizzle subclass, automatically applys - let interposedSubclass = try Interpose(TestSubclass.self).hook( - #selector(TestSubclass.sayHi), - methodSignature: (@convention(c) (AnyObject, Selector) -> String).self, - hookSignature: (@convention(block) (AnyObject) -> String).self) { store in { bSelf in - return store.original(bSelf, store.selector) + testString - } - } - - XCTAssertEqual(testObj.sayHi(), testClassHi + testString + testSubclass + testString) - try interposed.revert() - XCTAssertEqual(testObj.sayHi(), testClassHi + testSubclass + testString) - try interposedSubclass.revert() - XCTAssertEqual(testObj.sayHi(), testClassHi + testSubclass) - } - - func testInterposedCleanup() throws { - var deallocated = false - - try autoreleasepool { - let tracker = LifetimeTracker { - deallocated = true - } - - // Swizzle test class - let interposer = try Interpose(TestClass.self).hook( - #selector(TestClass.doNothing), - methodSignature: (@convention(c) (AnyObject, Selector) -> Void).self, - hookSignature: (@convention(block) (AnyObject) -> Void).self) { store in { bSelf in - tracker.keep() - return store.original(bSelf, store.selector) - } - } - - // Dealloc interposer without removing hooks - _ = interposer - } - - // Unreverted block should not be deallocated - XCTAssertFalse(deallocated) - } - - func testRevertedCleanup() throws { - var deallocated = false - - try autoreleasepool { - let tracker = LifetimeTracker { - deallocated = true - } - - // Swizzle test class - let interposer = try Interpose(TestClass.self) { - try $0.prepareHook( - #selector(TestClass.doNothing), - methodSignature: (@convention(c) (AnyObject, Selector) -> Void).self, - hookSignature: (@convention(block) (AnyObject) -> Void).self) { store in { bSelf in - tracker.keep() - return store.original(bSelf, store.selector) - } - } - } - try interposer.revert() - } - - // Verify that the block was deallocated - XCTAssertTrue(deallocated) - } - - func testImpRemoveBlockWorks() { - var deallocated = false - - let imp: IMP = autoreleasepool { - let tracker = LifetimeTracker { - deallocated = true - } - - let block: @convention(block) (AnyObject) -> Void = { _ in - // retain `tracker` inside a block - tracker.keep() - } - - return imp_implementationWithBlock(block) - } - - // `imp` retains `block` which retains `tracker` - XCTAssertFalse(deallocated) - - // Detach `block` from `imp` - imp_removeBlock(imp) - - // `block` and `tracker` should be deallocated now - XCTAssertTrue(deallocated) - } - - class LifetimeTracker { - let deinitCalled: () -> Void - - init(deinitCalled: @escaping () -> Void) { - self.deinitCalled = deinitCalled - } - - deinit { - deinitCalled() - } - - func keep() { } - } - -} diff --git a/Tests/InterposeKitTests/KVOTests.swift b/Tests/InterposeKitTests/KVOTests.swift deleted file mode 100644 index 7cb9cdb..0000000 --- a/Tests/InterposeKitTests/KVOTests.swift +++ /dev/null @@ -1,64 +0,0 @@ -import Foundation -import XCTest -@testable import InterposeKit - -//final class KVOTests: InterposeKitTestCase { -// -// // Helper observer that wraps a token and removes it on deinit. -// class TestClassObserver { -// var kvoToken: NSKeyValueObservation? -// var didCallObserver = false -// -// func observe(obj: TestClass) { -// kvoToken = obj.observe(\.age, options: .new) { [weak self] _, change in -// guard let age = change.newValue else { return } -// print("New age is: \(age)") -// self?.didCallObserver = true -// } -// } -// -// deinit { -// kvoToken?.invalidate() -// } -// } -// -// func testBasicKVO() throws { -// let testObj = TestClass() -// -// // KVO before hooking works, but hooking will fail -// try withExtendedLifetime(TestClassObserver()) { observer in -// observer.observe(obj: testObj) -// XCTAssertEqual(testObj.age, 1) -// testObj.age = 2 -// XCTAssertEqual(testObj.age, 2) -// // Hooking is expected to fail -// assert(try Interpose(testObj), throws: InterposeError.keyValueObservationDetected(testObj)) -// XCTAssertEqual(testObj.age, 2) -// } -// -// // Hook without KVO! -// let hook = try testObj.hook( -// #selector(getter: TestClass.age), -// methodSignature: (@convention(c) (AnyObject, Selector) -> Int).self, -// hookSignature: (@convention(block) (AnyObject) -> Int).self) { _ in { _ in -// return 3 -// } -// } -// XCTAssertEqual(testObj.age, 3) -// try hook.revert() -// XCTAssertEqual(testObj.age, 2) -// try hook.apply() -// XCTAssertEqual(testObj.age, 3) -// -// // Now we KVO after hooking! -// withExtendedLifetime(TestClassObserver()) { observer in -// observer.observe(obj: testObj) -// XCTAssertEqual(testObj.age, 3) -// // Setter is fine but won't change outcome -// XCTAssertFalse(observer.didCallObserver) -// testObj.age = 4 -// XCTAssertTrue(observer.didCallObserver) -// XCTAssertEqual(testObj.age, 3) -// } -// } -//} diff --git a/Tests/InterposeKitTests/MultipleInterposing.swift b/Tests/InterposeKitTests/MultipleInterposing.swift deleted file mode 100644 index 19594fd..0000000 --- a/Tests/InterposeKitTests/MultipleInterposing.swift +++ /dev/null @@ -1,67 +0,0 @@ -import Foundation -import XCTest -@testable import InterposeKit - -final class MultipleInterposingTests: InterposeKitTestCase { - - func testInterposeSingleObjectMultipleTimes() throws { - let testObj = TestClass() - let testObj2 = TestClass() - - XCTAssertEqual(testObj.sayHi(), testClassHi) - XCTAssertEqual(testObj2.sayHi(), testClassHi) - - // Functions need to be `@objc dynamic` to be hookable. - let interposer = try Interpose(testObj) { - try $0.prepareHook( - #selector(TestClass.sayHi), - methodSignature: (@convention(c) (AnyObject, Selector) -> String).self, - hookSignature: (@convention(block) (AnyObject) -> String).self) { store in { bSelf in - return store.original(bSelf, store.selector) + testString - } - } - } - - XCTAssertEqual(testObj.sayHi(), testClassHi + testString) - XCTAssertEqual(testObj2.sayHi(), testClassHi) - - try testObj.addHook( - for: #selector(TestClass.sayHi), - methodSignature: (@convention(c) (AnyObject, Selector) -> String).self, - hookSignature: (@convention(block) (AnyObject) -> String).self - ) { hook in - return { `self` in - return hook.original(self, hook.selector) + testString2 - } - } - - XCTAssertEqual(testObj.sayHi(), testClassHi + testString + testString2) - try interposer.revert() - XCTAssertEqual(testObj.sayHi(), testClassHi + testString2) - } - - func testInterposeAgeAndRevert() throws { - let testObj = TestClass() - XCTAssertEqual(testObj.age, 1) - - let interpose = try Interpose(testObj) { - try $0.prepareHook(#selector(getter: TestClass.age), - methodSignature: (@convention(c) (AnyObject, Selector) -> Int).self, - hookSignature: (@convention(block) (AnyObject) -> Int).self) { _ in { _ in - return 3 - } - } - } - XCTAssertEqual(testObj.age, 3) - - try interpose.hook(#selector(getter: TestClass.age), - methodSignature: (@convention(c) (AnyObject, Selector) -> Int).self, - hookSignature: (@convention(block) (AnyObject) -> Int).self) { _ in { _ in - return 5 - } - } - XCTAssertEqual(testObj.age, 5) - try interpose.revert() - XCTAssertEqual(testObj.age, 1) - } -} diff --git a/Tests/InterposeKitTests/ToBePolished/ClassMethodInterposeTests.swift b/Tests/InterposeKitTests/ToBePolished/ClassMethodInterposeTests.swift new file mode 100644 index 0000000..a81abc2 --- /dev/null +++ b/Tests/InterposeKitTests/ToBePolished/ClassMethodInterposeTests.swift @@ -0,0 +1,20 @@ +@testable import InterposeKit +import XCTest + +final class ClassMethodInterposeTests: InterposeKitTestCase { + + func testClassMethod() { + XCTAssertThrowsError( + try Interpose.prepareHook( + on: TestClass.self, + for: #selector(getter: TestClass.staticInt), + methodSignature: (@convention(c) (NSObject, Selector) -> Int).self, + hookSignature: (@convention(block) (NSObject) -> Int).self + ) { hook in + return { _ in 73 } + }, + expected: InterposeError.methodNotFound(TestClass.self, #selector(getter: TestClass.staticInt)) + ) + } + +} diff --git a/Tests/InterposeKitTests/ToBePolished/HookDynamicLookupTests.swift b/Tests/InterposeKitTests/ToBePolished/HookDynamicLookupTests.swift new file mode 100644 index 0000000..a041321 --- /dev/null +++ b/Tests/InterposeKitTests/ToBePolished/HookDynamicLookupTests.swift @@ -0,0 +1,51 @@ +@testable import InterposeKit +import XCTest +import Foundation + +fileprivate class ExampleClass: NSObject { + @objc dynamic func greet(name: String) -> String { + return "Hello, \(name)!" + } +} + +class HookDynamicLookupTests: XCTestCase { + func test() throws { + typealias MethodSignature = @convention(c) (ExampleClass, Selector, String) -> String + typealias HookSignature = @convention(block) (ExampleClass, String) -> String + + let object = ExampleClass() + + // Create an ObjectHook for the 'greet(name:)' method. + // Note: We don't explicitly set strategy.originalIMP, so the dynamic lookup path will be used. + let hook = try Hook( + target: .object(object), + selector: #selector(ExampleClass.greet(name:)), + build: { (hook: HookProxy) -> HookSignature in + return { `self`, name in + return hook.original(self, hook.selector, name) + } + } + ) + + // Force the dynamic lookup path by ensuring no original IMP has been cached. + // The following call will use `strategy.lookUpIMP()` to find the method implementation. + let original = unsafeBitCast( + hook.originalIMP, + to: (@convention(c) (ExampleClass, Selector, String) -> String).self + ) + + // Call the original implementation via the looked-up IMP. + let result = original(object, #selector(ExampleClass.greet(name:)), "World") + XCTAssertEqual(result, "Hello, World!") + + try hook.apply() + + let original2 = unsafeBitCast( + hook.originalIMP, + to: (@convention(c) (ExampleClass, Selector, String) -> String).self + ) + + let result2 = original2(object, #selector(ExampleClass.greet(name:)), "World") + XCTAssertEqual(result2, "Hello, World!") + } +} diff --git a/Tests/InterposeKitTests/ToBePolished/InterposeKitTestCase.swift b/Tests/InterposeKitTests/ToBePolished/InterposeKitTestCase.swift new file mode 100644 index 0000000..b1e26cd --- /dev/null +++ b/Tests/InterposeKitTests/ToBePolished/InterposeKitTestCase.swift @@ -0,0 +1,8 @@ +import XCTest +@testable import InterposeKit + +class InterposeKitTestCase: XCTestCase { + override func setUpWithError() throws { + Interpose.isLoggingEnabled = true + } +} diff --git a/Tests/InterposeKitTests/ToBePolished/InterposeKitTests.swift b/Tests/InterposeKitTests/ToBePolished/InterposeKitTests.swift new file mode 100644 index 0000000..b750b05 --- /dev/null +++ b/Tests/InterposeKitTests/ToBePolished/InterposeKitTests.swift @@ -0,0 +1,205 @@ +import XCTest +@testable import InterposeKit + +final class InterposeKitTests: InterposeKitTestCase { + + override func setUpWithError() throws { + Interpose.isLoggingEnabled = true + } + + func testClassOverrideAndRevert() throws { + let testObj = TestClass() + XCTAssertEqual(testObj.sayHi(), testClassHi) + + // Functions need to be `@objc dynamic` to be hookable. + let hook = try Interpose.applyHook( + on: TestClass.self, + for: #selector(TestClass.sayHi), + methodSignature: (@convention(c) (NSObject, Selector) -> String).self, + hookSignature: (@convention(block) (NSObject) -> String).self) { store in { bSelf in + // You're free to skip calling the original implementation. + print("Before Interposing \(bSelf)") + let string = store.original(bSelf, store.selector) + print("After Interposing \(bSelf)") + + return string + testString + } + } + print(TestClass().sayHi()) + + // Test various apply/revert's + XCTAssertEqual(testObj.sayHi(), testClassHi + testString) + try hook.revert() + XCTAssertEqual(testObj.sayHi(), testClassHi) + try hook.apply() + XCTAssertEqual(testObj.sayHi(), testClassHi + testString) + try hook.apply() // noop + try hook.apply() // noop + try hook.revert() + try hook.revert() // noop + try hook.apply() + try hook.revert() + XCTAssertEqual(testObj.sayHi(), testClassHi) + } + + func testSubclassOverride() throws { + let testObj = TestSubclass() + XCTAssertEqual(testObj.sayHi(), testClassHi + testSubclass) + + // Swizzle test class + let hook = try Interpose.applyHook( + on: TestClass.self, + for: #selector(TestClass.sayHi), + methodSignature: (@convention(c) (NSObject, Selector) -> String).self, + hookSignature: (@convention(block) (NSObject) -> String).self) { store in { bSelf in + return store.original(bSelf, store.selector) + testString + } + } + + XCTAssertEqual(testObj.sayHi(), testClassHi + testString + testSubclass) + try hook.revert() + XCTAssertEqual(testObj.sayHi(), testClassHi + testSubclass) + try hook.apply() + XCTAssertEqual(testObj.sayHi(), testClassHi + testString + testSubclass) + + // Swizzle subclass, automatically applys + let interposedSubclass = try Interpose.applyHook( + on: TestSubclass.self, + for: #selector(TestSubclass.sayHi), + methodSignature: (@convention(c) (NSObject, Selector) -> String).self, + hookSignature: (@convention(block) (NSObject) -> String).self) { store in { bSelf in + return store.original(bSelf, store.selector) + testString + } + } + + XCTAssertEqual(testObj.sayHi(), testClassHi + testString + testSubclass + testString) + try hook.revert() + XCTAssertEqual(testObj.sayHi(), testClassHi + testSubclass + testString) + try interposedSubclass.revert() + XCTAssertEqual(testObj.sayHi(), testClassHi + testSubclass) + } + + func testInterposedCleanup() throws { + var deallocated = false + + try autoreleasepool { + let tracker = LifetimeTracker { + deallocated = true + } + + // Swizzle test class + let interposer = try Interpose.applyHook( + on: TestClass.self, + for: #selector(TestClass.doNothing), + methodSignature: (@convention(c) (NSObject, Selector) -> Void).self, + hookSignature: (@convention(block) (NSObject) -> Void).self) { store in { bSelf in + tracker.keep() + return store.original(bSelf, store.selector) + } + } + + // Dealloc interposer without removing hooks + _ = interposer + } + + // Unreverted block should not be deallocated + XCTAssertFalse(deallocated) + } + + func testRevertedCleanup_class() throws { + var deallocated = false + + try autoreleasepool { + let tracker = LifetimeTracker { + deallocated = true + } + + // Swizzle test class + let hook = try Interpose.applyHook( + on: TestClass.self, + for: #selector(TestClass.doNothing), + methodSignature: (@convention(c) (NSObject, Selector) -> Void).self, + hookSignature: (@convention(block) (NSObject) -> Void).self + ) { hook in + return { `self` in + tracker.keep() + return hook.original(self, hook.selector) + } + } + + try hook.revert() + } + + // Verify that the block was deallocated + XCTAssertTrue(deallocated) + } + + func testRevertedCleanup_object() throws { + var deallocated = false + + try autoreleasepool { + let tracker = LifetimeTracker { + deallocated = true + } + + let object = TestClass() + let hook = try Interpose.applyHook( + on: object, + for: #selector(TestClass.doNothing), + methodSignature: (@convention(c) (NSObject, Selector) -> Void).self, + hookSignature: (@convention(block) (NSObject) -> Void).self + ) { hook in + return { `self` in + tracker.keep() + return hook.original(self, hook.selector) + } + } + + try hook.revert() + } + + // Verify that the block was deallocated + XCTAssertTrue(deallocated) + } + + func testImpRemoveBlockWorks() { + var deallocated = false + + let imp: IMP = autoreleasepool { + let tracker = LifetimeTracker { + deallocated = true + } + + let block: @convention(block) (NSObject) -> Void = { _ in + // retain `tracker` inside a block + tracker.keep() + } + + return imp_implementationWithBlock(block) + } + + // `imp` retains `block` which retains `tracker` + XCTAssertFalse(deallocated) + + // Detach `block` from `imp` + imp_removeBlock(imp) + + // `block` and `tracker` should be deallocated now + XCTAssertTrue(deallocated) + } + + class LifetimeTracker { + let deinitCalled: () -> Void + + init(deinitCalled: @escaping () -> Void) { + self.deinitCalled = deinitCalled + } + + deinit { + deinitCalled() + } + + func keep() { } + } + +} diff --git a/Tests/InterposeKitTests/ToBePolished/KVOTests.swift b/Tests/InterposeKitTests/ToBePolished/KVOTests.swift new file mode 100644 index 0000000..fbc36d1 --- /dev/null +++ b/Tests/InterposeKitTests/ToBePolished/KVOTests.swift @@ -0,0 +1,74 @@ +@testable import InterposeKit +import Foundation +import XCTest + +final class KVOTests: InterposeKitTestCase { + + // Helper observer that wraps a token and removes it on deinit. + class TestClassObserver { + var kvoToken: NSKeyValueObservation? + var didCallObserver = false + + func observe(obj: TestClass) { + kvoToken = obj.observe(\.age, options: .new) { [weak self] _, change in + guard let age = change.newValue else { return } + print("New age is: \(age)") + self?.didCallObserver = true + } + } + + deinit { + kvoToken?.invalidate() + } + } + + func testBasicKVO() throws { + let testObj = TestClass() + + // KVO before hooking works, but hooking will fail + try withExtendedLifetime(TestClassObserver()) { observer in + observer.observe(obj: testObj) + XCTAssertEqual(testObj.age, 1) + testObj.age = 2 + XCTAssertEqual(testObj.age, 2) + // Hooking is expected to fail + XCTAssertThrowsError( + try Interpose.prepareHook( + on: testObj, + for: #selector(getter: TestClass.age), + methodSignature: (@convention(c) (NSObject, Selector) -> Int).self, + hookSignature: (@convention(block) (NSObject) -> Int).self + ) { _ in + return { _ in 3 } + }, + expected: InterposeError.keyValueObservationDetected(testObj) + ) + XCTAssertEqual(testObj.age, 2) + } + + // Hook without KVO! + let hook = try testObj.applyHook( + for: #selector(getter: TestClass.age), + methodSignature: (@convention(c) (NSObject, Selector) -> Int).self, + hookSignature: (@convention(block) (NSObject) -> Int).self + ) { _ in + return { _ in 3 } + } + XCTAssertEqual(testObj.age, 3) + try hook.revert() + XCTAssertEqual(testObj.age, 2) + try hook.apply() + XCTAssertEqual(testObj.age, 3) + + // Now we KVO after hooking! + withExtendedLifetime(TestClassObserver()) { observer in + observer.observe(obj: testObj) + XCTAssertEqual(testObj.age, 3) + // Setter is fine but won't change outcome + XCTAssertFalse(observer.didCallObserver) + testObj.age = 4 + XCTAssertTrue(observer.didCallObserver) + XCTAssertEqual(testObj.age, 3) + } + } +} diff --git a/Tests/InterposeKitTests/ToBePolished/MultipleInterposing.swift b/Tests/InterposeKitTests/ToBePolished/MultipleInterposing.swift new file mode 100644 index 0000000..6e0326a --- /dev/null +++ b/Tests/InterposeKitTests/ToBePolished/MultipleInterposing.swift @@ -0,0 +1,76 @@ +import Foundation +import XCTest +@testable import InterposeKit + +final class MultipleInterposingTests: InterposeKitTestCase { + + func testInterposeSingleObjectMultipleTimes() throws { + let testObj = TestClass() + let testObj2 = TestClass() + + XCTAssertEqual(testObj.sayHi(), testClassHi) + XCTAssertEqual(testObj2.sayHi(), testClassHi) + + // Functions need to be `@objc dynamic` to be hookable. + let hook = try Interpose.applyHook( + on: testObj, + for: #selector(TestClass.sayHi), + methodSignature: (@convention(c) (NSObject, Selector) -> String).self, + hookSignature: (@convention(block) (NSObject) -> String).self + ) { store in + { bSelf in + return store.original(bSelf, store.selector) + testString + } + } + + XCTAssertEqual(testObj.sayHi(), testClassHi + testString) + XCTAssertEqual(testObj2.sayHi(), testClassHi) + + try testObj.applyHook( + for: #selector(TestClass.sayHi), + methodSignature: (@convention(c) (NSObject, Selector) -> String).self, + hookSignature: (@convention(block) (NSObject) -> String).self + ) { hook in + return { `self` in + return hook.original(self, hook.selector) + testString2 + } + } + + XCTAssertEqual(testObj.sayHi(), testClassHi + testString + testString2) + try hook.revert() + XCTAssertEqual(testObj.sayHi(), testClassHi + testString2) + } + + func testInterposeAgeAndRevert() throws { + let testObj = TestClass() + XCTAssertEqual(testObj.age, 1) + + let hook1 = try Interpose.applyHook( + on: testObj, + for: #selector(getter: TestClass.age), + methodSignature: (@convention(c) (NSObject, Selector) -> Int).self, + hookSignature: (@convention(block) (NSObject) -> Int).self + ) { _ in + return { _ in 3 } + } + + XCTAssertEqual(testObj.age, 3) + + let hook2 = try Interpose.applyHook( + on: testObj, + for: #selector(getter: TestClass.age), + methodSignature: (@convention(c) (NSObject, Selector) -> Int).self, + hookSignature: (@convention(block) (NSObject) -> Int).self + ) { _ in + return { _ in 5 } + } + + XCTAssertEqual(testObj.age, 5) + try hook2.revert() + + XCTAssertEqual(testObj.age, 3) + try hook1.revert() + + XCTAssertEqual(testObj.age, 1) + } +} diff --git a/Tests/InterposeKitTests/ObjectInterposeTests.swift b/Tests/InterposeKitTests/ToBePolished/ObjectInterposeTests.swift similarity index 80% rename from Tests/InterposeKitTests/ObjectInterposeTests.swift rename to Tests/InterposeKitTests/ToBePolished/ObjectInterposeTests.swift index f9d8afc..822abd9 100644 --- a/Tests/InterposeKitTests/ObjectInterposeTests.swift +++ b/Tests/InterposeKitTests/ToBePolished/ObjectInterposeTests.swift @@ -11,10 +11,10 @@ final class ObjectInterposeTests: InterposeKitTestCase { XCTAssertEqual(testObj.sayHi(), testClassHi) XCTAssertEqual(testObj2.sayHi(), testClassHi) - let hook = try testObj.addHook( + let hook = try testObj.applyHook( for: #selector(TestClass.sayHi), - methodSignature: (@convention(c) (AnyObject, Selector) -> String).self, - hookSignature: (@convention(block) (AnyObject) -> String).self + methodSignature: (@convention(c) (NSObject, Selector) -> String).self, + hookSignature: (@convention(block) (NSObject) -> String).self ) { hook in return { `self` in print("Before Interposing \(self)") @@ -40,10 +40,10 @@ final class ObjectInterposeTests: InterposeKitTestCase { let returnIntOverrideOffset = 2 XCTAssertEqual(testObj.returnInt(), returnIntDefault) - let hook = try testObj.addHook( + let hook = try testObj.applyHook( for: #selector(TestClass.returnInt), - methodSignature: (@convention(c) (AnyObject, Selector) -> Int).self, - hookSignature: (@convention(block) (AnyObject) -> Int).self + methodSignature: (@convention(c) (NSObject, Selector) -> Int).self, + hookSignature: (@convention(block) (NSObject) -> Int).self ) { hook in return { `self` in let int = hook.original(self, hook.selector) @@ -71,10 +71,10 @@ final class ObjectInterposeTests: InterposeKitTestCase { XCTAssertEqual(testObj.returnInt(), returnIntDefault) // Functions need to be `@objc dynamic` to be hookable. - let hook = try testObj.addHook( + let hook = try testObj.applyHook( for: #selector(TestClass.returnInt), - methodSignature: (@convention(c) (AnyObject, Selector) -> Int).self, - hookSignature: (@convention(block) (AnyObject) -> Int).self + methodSignature: (@convention(c) (NSObject, Selector) -> Int).self, + hookSignature: (@convention(block) (NSObject) -> Int).self ) { hook in return { `self` in // You're free to skip calling the original implementation. @@ -84,12 +84,14 @@ final class ObjectInterposeTests: InterposeKitTestCase { XCTAssertEqual(testObj.returnInt(), returnIntDefault + returnIntOverrideOffset) // Interpose on TestClass itself! - let classInterposer = try Interpose(TestClass.self) { - try $0.prepareHook(#selector(TestClass.returnInt)) { (store: TypedHook - <@convention(c) (AnyObject, Selector) -> Int, - @convention(block) (AnyObject) -> Int>) in { - store.original($0, store.selector) * returnIntClassMultiplier - } + let classHook = try Interpose.applyHook( + on: TestClass.self, + for: #selector(TestClass.returnInt), + methodSignature: (@convention(c) (NSObject, Selector) -> Int).self, + hookSignature: (@convention(block) (NSObject) -> Int).self + ) { hook in + return { + hook.original($0, hook.selector) * returnIntClassMultiplier } } @@ -101,7 +103,7 @@ final class ObjectInterposeTests: InterposeKitTestCase { try hook.revert() XCTAssertEqual(testObj.returnInt(), returnIntDefault * returnIntClassMultiplier) - try classInterposer.revert() + try classHook.revert() XCTAssertEqual(testObj.returnInt(), returnIntDefault) } @@ -110,10 +112,10 @@ final class ObjectInterposeTests: InterposeKitTestCase { XCTAssertEqual(testObj.calculate(var1: 1, var2: 2, var3: 3), 1 + 2 + 3) // Functions need to be `@objc dynamic` to be hookable. - let hook = try testObj.addHook( + let hook = try testObj.applyHook( for: #selector(TestClass.calculate), - methodSignature: (@convention(c) (AnyObject, Selector, Int, Int, Int) -> Int).self, - hookSignature: (@convention(block) (AnyObject, Int, Int, Int) -> Int).self + methodSignature: (@convention(c) (NSObject, Selector, Int, Int, Int) -> Int).self, + hookSignature: (@convention(block) (NSObject, Int, Int, Int) -> Int).self ) { hook in return { let orig = hook.original($0, hook.selector, $1, $2, $3) @@ -131,10 +133,10 @@ final class ObjectInterposeTests: InterposeKitTestCase { var4: 4, var5: 5, var6: 6), 1 + 2 + 3 + 4 + 5 + 6) // Functions need to be `@objc dynamic` to be hookable. - let hook = try testObj.addHook( + let hook = try testObj.applyHook( for: #selector(TestClass.calculate2), - methodSignature: (@convention(c) (AnyObject, Selector, Int, Int, Int, Int, Int, Int) -> Int).self, - hookSignature: (@convention(block) (AnyObject, Int, Int, Int, Int, Int, Int) -> Int).self + methodSignature: (@convention(c) (NSObject, Selector, Int, Int, Int, Int, Int, Int) -> Int).self, + hookSignature: (@convention(block) (NSObject, Int, Int, Int, Int, Int, Int) -> Int).self ) { hook in return { // You're free to skip calling the original implementation. @@ -153,10 +155,10 @@ final class ObjectInterposeTests: InterposeKitTestCase { XCTAssertEqual(testObj.doubleString(string: str), str + str) // Functions need to be `@objc dynamic` to be hookable. - let hook = try testObj.addHook( + let hook = try testObj.applyHook( for: #selector(TestClass.doubleString), - methodSignature: (@convention(c) (AnyObject, Selector, String) -> String).self, - hookSignature: (@convention(block) (AnyObject, String) -> String).self + methodSignature: (@convention(c) (NSObject, Selector, String) -> String).self, + hookSignature: (@convention(block) (NSObject, String) -> String).self ) { hook in return { `self`, parameter in hook.original(self, hook.selector, parameter) + str @@ -171,7 +173,7 @@ final class ObjectInterposeTests: InterposeKitTestCase { let object = TestClass() XCTAssertEqual(object.getPoint(), CGPoint(x: -1, y: 1)) - let hook = try object.addHook( + let hook = try object.applyHook( for: #selector(TestClass.getPoint), methodSignature: (@convention(c) (NSObject, Selector) -> CGPoint).self, hookSignature: (@convention(block) (NSObject) -> CGPoint).self @@ -198,7 +200,7 @@ final class ObjectInterposeTests: InterposeKitTestCase { CGPoint(x: 1, y: 1) ) - let hook = try object.addHook( + let hook = try object.applyHook( for: #selector(TestClass.passthroughPoint(_:)), methodSignature: (@convention(c) (NSObject, Selector, CGPoint) -> CGPoint).self, hookSignature: (@convention(block) (NSObject, CGPoint) -> CGPoint).self @@ -235,8 +237,8 @@ final class ObjectInterposeTests: InterposeKitTestCase { // // // Functions need to be `@objc dynamic` to be hookable. // let hook = try testObj.hook(#selector(TestClass.invert3DTransform)) { (store: TypedHook -// <@convention(c)(AnyObject, Selector, CATransform3D) -> CATransform3D, -// @convention(block) (AnyObject, CATransform3D) -> CATransform3D>) in { +// <@convention(c)(NSObject, Selector, CATransform3D) -> CATransform3D, +// @convention(block) (NSObject, CATransform3D) -> CATransform3D>) in { // let matrix = store.original($0, store.selector, $1) // return transformMatrix(matrix) // } diff --git a/Tests/InterposeKitTests/TestClass.swift b/Tests/InterposeKitTests/ToBePolished/TestClass.swift similarity index 100% rename from Tests/InterposeKitTests/TestClass.swift rename to Tests/InterposeKitTests/ToBePolished/TestClass.swift diff --git a/Tests/InterposeKitTests/UtilitiesTests.swift b/Tests/InterposeKitTests/UtilitiesTests.swift new file mode 100644 index 0000000..3459f66 --- /dev/null +++ b/Tests/InterposeKitTests/UtilitiesTests.swift @@ -0,0 +1,58 @@ +@testable import InterposeKit +import XCTest + +fileprivate class ExampleClass: NSObject { + @objc dynamic var value = 0 +} + +fileprivate class RealClass: NSObject {} +fileprivate class FakeClass: NSObject {} + +extension NSObject { + fileprivate var objcClass: AnyClass { + self.perform(Selector((("class"))))?.takeUnretainedValue() as! AnyClass + } + + fileprivate static var objcClass: AnyClass { + self.perform(Selector((("class"))))?.takeUnretainedValue() as! AnyClass + } +} + +final class UtilitiesTests: XCTestCase { + + func test_setPerceivedClass() { + let object = RealClass() + + XCTAssertTrue(object.objcClass === RealClass.self) + XCTAssertTrue(object_getClass(object) === RealClass.self) + + XCTAssertTrue(RealClass.objcClass === RealClass.self) + + class_setPerceivedClass(for: RealClass.self, to: FakeClass.self) + + XCTAssertTrue(object.objcClass === FakeClass.self) + XCTAssertTrue(object_getClass(object) === RealClass.self) + + XCTAssertTrue(RealClass.objcClass === FakeClass.self) + } + + func test_isObjectKVOActive() { + let object = ExampleClass() + XCTAssertFalse(object_isKVOActive(object)) + + var token1: NSKeyValueObservation? = object.observe(\.value, options: []) { _, _ in } + XCTAssertTrue(object_isKVOActive(object)) + + var token2: NSKeyValueObservation? = object.observe(\.value, options: []) { _, _ in } + XCTAssertTrue(object_isKVOActive(object)) + + _ = token1 + token1 = nil + XCTAssertTrue(object_isKVOActive(object)) + + _ = token2 + token2 = nil + XCTAssertFalse(object_isKVOActive(object)) + } + +}