From 3946944f32c9757c9f4786c6f156541b94baa360 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Tue, 25 Mar 2025 16:35:06 +0100 Subject: [PATCH 01/70] Reorganized source files --- Sources/InterposeKit/{ => Hooks}/AnyHook.swift | 0 Sources/InterposeKit/{ => Hooks}/ClassHook.swift | 0 Sources/InterposeKit/{ => Hooks}/ObjectHook.swift | 0 Sources/InterposeKit/{ => Internal}/HookFinder.swift | 0 Sources/InterposeKit/{ => Internal}/InterposeSubclass.swift | 0 Sources/InterposeKit/{Watcher.swift => Interpose+Watcher.swift} | 0 Sources/InterposeKit/{InterposeKit.swift => Interpose.swift} | 0 .../{NSObject+InterposeKit.swift => NSObject+Interpose.swift} | 0 8 files changed, 0 insertions(+), 0 deletions(-) rename Sources/InterposeKit/{ => Hooks}/AnyHook.swift (100%) rename Sources/InterposeKit/{ => Hooks}/ClassHook.swift (100%) rename Sources/InterposeKit/{ => Hooks}/ObjectHook.swift (100%) rename Sources/InterposeKit/{ => Internal}/HookFinder.swift (100%) rename Sources/InterposeKit/{ => Internal}/InterposeSubclass.swift (100%) rename Sources/InterposeKit/{Watcher.swift => Interpose+Watcher.swift} (100%) rename Sources/InterposeKit/{InterposeKit.swift => Interpose.swift} (100%) rename Sources/InterposeKit/{NSObject+InterposeKit.swift => NSObject+Interpose.swift} (100%) diff --git a/Sources/InterposeKit/AnyHook.swift b/Sources/InterposeKit/Hooks/AnyHook.swift similarity index 100% rename from Sources/InterposeKit/AnyHook.swift rename to Sources/InterposeKit/Hooks/AnyHook.swift diff --git a/Sources/InterposeKit/ClassHook.swift b/Sources/InterposeKit/Hooks/ClassHook.swift similarity index 100% rename from Sources/InterposeKit/ClassHook.swift rename to Sources/InterposeKit/Hooks/ClassHook.swift diff --git a/Sources/InterposeKit/ObjectHook.swift b/Sources/InterposeKit/Hooks/ObjectHook.swift similarity index 100% rename from Sources/InterposeKit/ObjectHook.swift rename to Sources/InterposeKit/Hooks/ObjectHook.swift diff --git a/Sources/InterposeKit/HookFinder.swift b/Sources/InterposeKit/Internal/HookFinder.swift similarity index 100% rename from Sources/InterposeKit/HookFinder.swift rename to Sources/InterposeKit/Internal/HookFinder.swift diff --git a/Sources/InterposeKit/InterposeSubclass.swift b/Sources/InterposeKit/Internal/InterposeSubclass.swift similarity index 100% rename from Sources/InterposeKit/InterposeSubclass.swift rename to Sources/InterposeKit/Internal/InterposeSubclass.swift diff --git a/Sources/InterposeKit/Watcher.swift b/Sources/InterposeKit/Interpose+Watcher.swift similarity index 100% rename from Sources/InterposeKit/Watcher.swift rename to Sources/InterposeKit/Interpose+Watcher.swift diff --git a/Sources/InterposeKit/InterposeKit.swift b/Sources/InterposeKit/Interpose.swift similarity index 100% rename from Sources/InterposeKit/InterposeKit.swift rename to Sources/InterposeKit/Interpose.swift diff --git a/Sources/InterposeKit/NSObject+InterposeKit.swift b/Sources/InterposeKit/NSObject+Interpose.swift similarity index 100% rename from Sources/InterposeKit/NSObject+InterposeKit.swift rename to Sources/InterposeKit/NSObject+Interpose.swift From 09e19eefcb15add2ea2016ebdde71cadf718acbd Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Tue, 25 Mar 2025 17:48:34 +0100 Subject: [PATCH 02/70] Replaced XCTest assertion for throwing errors --- .../Helpers/XCTest+Errors.swift | 22 ++++++++++++++ .../InterposeKitTestCase.swift | 29 ------------------- 2 files changed, 22 insertions(+), 29 deletions(-) create mode 100644 Tests/InterposeKitTests/Helpers/XCTest+Errors.swift 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/InterposeKitTestCase.swift b/Tests/InterposeKitTests/InterposeKitTestCase.swift index 674fc7f..b1e26cd 100644 --- a/Tests/InterposeKitTests/InterposeKitTestCase.swift +++ b/Tests/InterposeKitTests/InterposeKitTestCase.swift @@ -6,32 +6,3 @@ class InterposeKitTestCase: XCTestCase { 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 - ) - } -} From d25ea1d446ac4d8932ef47a48cf2d44c78235871 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Tue, 25 Mar 2025 17:51:24 +0100 Subject: [PATCH 03/70] Reworked HookState --- .../InterposeKit/Deprecated/Deprecated.swift | 15 +++++ Sources/InterposeKit/Hooks/AnyHook.swift | 36 +++++------- Sources/InterposeKit/Hooks/ClassHook.swift | 2 +- Sources/InterposeKit/Hooks/Hook.swift | 12 ++++ Sources/InterposeKit/Hooks/ObjectHook.swift | 2 +- Sources/InterposeKit/Interpose.swift | 4 +- Tests/InterposeKitTests/HookTests.swift | 56 +++++++++++++++++++ 7 files changed, 101 insertions(+), 26 deletions(-) create mode 100644 Sources/InterposeKit/Deprecated/Deprecated.swift create mode 100644 Sources/InterposeKit/Hooks/Hook.swift create mode 100644 Tests/InterposeKitTests/HookTests.swift diff --git a/Sources/InterposeKit/Deprecated/Deprecated.swift b/Sources/InterposeKit/Deprecated/Deprecated.swift new file mode 100644 index 0000000..7e6c1d0 --- /dev/null +++ b/Sources/InterposeKit/Deprecated/Deprecated.swift @@ -0,0 +1,15 @@ +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/Hooks/AnyHook.swift b/Sources/InterposeKit/Hooks/AnyHook.swift index 1a7abe8..4ee3909 100644 --- a/Sources/InterposeKit/Hooks/AnyHook.swift +++ b/Sources/InterposeKit/Hooks/AnyHook.swift @@ -9,7 +9,7 @@ public class AnyHook { public let selector: Selector /// The current state of the hook. - public internal(set) var state = State.prepared + public internal(set) var state = State.pending // else we validate init order var replacementIMP: IMP! @@ -17,18 +17,8 @@ public class AnyHook { // 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) - } - + public typealias State = HookState + init(`class`: AnyClass, selector: Selector) throws { self.selector = selector self.class = `class` @@ -47,22 +37,24 @@ public class AnyHook { /// Apply the interpose hook. @discardableResult public func apply() throws -> AnyHook { - try execute(newState: .interposed) { try replaceImplementation() } + try execute(newState: .active) { try replaceImplementation() } return self } /// Revert the interpose hook. @discardableResult public func revert() throws -> AnyHook { - try execute(newState: .prepared) { try resetImplementation() } + try execute(newState: .pending) { try resetImplementation() } return self } /// Validate that the selector exists on the active class. - @discardableResult func validate(expectedState: State = .prepared) throws -> Method { + @discardableResult func validate(expectedState: State = .pending) throws -> Method { guard let method = class_getInstanceMethod(`class`, selector) else { throw InterposeError.methodNotFound(`class`, selector) } - guard state == expectedState else { throw InterposeError.invalidState(expectedState: expectedState) } + guard state == expectedState else { + throw InterposeError.invalidState(expectedState: expectedState) + } return method } @@ -71,7 +63,7 @@ public class AnyHook { try task() state = newState } catch let error as InterposeError { - state = .error(error) + state = .failed throw error } } @@ -79,13 +71,13 @@ public class AnyHook { /// Release the hook block if possible. public func cleanup() { switch state { - case .prepared: + case .pending: Interpose.log("Releasing -[\(`class`).\(selector)] IMP: \(replacementIMP!)") imp_removeBlock(replacementIMP) - case .interposed: + case .active: Interpose.log("Keeping -[\(`class`).\(selector)] IMP: \(replacementIMP!)") - case let .error(error): - Interpose.log("Leaking -[\(`class`).\(selector)] IMP: \(replacementIMP!) due to error: \(error)") + case .failed: + Interpose.log("Leaking -[\(`class`).\(selector)] IMP: \(replacementIMP!)") } } } diff --git a/Sources/InterposeKit/Hooks/ClassHook.swift b/Sources/InterposeKit/Hooks/ClassHook.swift index 6070068..85a8dd9 100644 --- a/Sources/InterposeKit/Hooks/ClassHook.swift +++ b/Sources/InterposeKit/Hooks/ClassHook.swift @@ -21,7 +21,7 @@ extension Interpose { } override func resetImplementation() throws { - let method = try validate(expectedState: .interposed) + let method = try validate(expectedState: .active) precondition(origIMP != nil) let previousIMP = class_replaceMethod(`class`, selector, origIMP!, method_getTypeEncoding(method)) guard previousIMP == replacementIMP else { diff --git a/Sources/InterposeKit/Hooks/Hook.swift b/Sources/InterposeKit/Hooks/Hook.swift new file mode 100644 index 0000000..16cd98a --- /dev/null +++ b/Sources/InterposeKit/Hooks/Hook.swift @@ -0,0 +1,12 @@ +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 + +} diff --git a/Sources/InterposeKit/Hooks/ObjectHook.swift b/Sources/InterposeKit/Hooks/ObjectHook.swift index e4caad3..2fd6ab8 100644 --- a/Sources/InterposeKit/Hooks/ObjectHook.swift +++ b/Sources/InterposeKit/Hooks/ObjectHook.swift @@ -136,7 +136,7 @@ extension Interpose { } override func resetImplementation() throws { - let method = try validate(expectedState: .interposed) + let method = try validate(expectedState: .active) guard super.origIMP != nil else { // Removing methods at runtime is not supported. diff --git a/Sources/InterposeKit/Interpose.swift b/Sources/InterposeKit/Interpose.swift index ea518ce..f3d7874 100644 --- a/Sources/InterposeKit/Interpose.swift +++ b/Sources/InterposeKit/Interpose.swift @@ -112,11 +112,11 @@ final public class Interpose { /// Revert all stored hooks. @discardableResult public func revert(_ hook: ((Interpose) throws -> Void)? = nil) throws -> Interpose { - try execute(hook, expectedState: .interposed) { try $0.revert() } + try execute(hook, expectedState: .active) { try $0.revert() } } private func execute(_ task: ((Interpose) throws -> Void)? = nil, - expectedState: AnyHook.State = .prepared, + expectedState: AnyHook.State = .pending, executor: ((AnyHook) throws -> Void)) throws -> Interpose { // Run pre-apply code first if let task = task { diff --git a/Tests/InterposeKitTests/HookTests.swift b/Tests/InterposeKitTests/HookTests.swift new file mode 100644 index 0000000..a8748a4 --- /dev/null +++ b/Tests/InterposeKitTests/HookTests.swift @@ -0,0 +1,56 @@ +@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 interposer = try Interpose(ExampleClass.self) + + let hook = try interposer.prepareHook( + #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`. + let interposer = try Interpose(ExampleSubclass.self) + + // We can prepare a hook, as the method is accessible from the subclass. + let hook = try interposer.prepareHook( + #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) + } + +} From 5b3457695fc87a164f6b18f5b9b3490aceabe984 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Tue, 25 Mar 2025 19:52:06 +0100 Subject: [PATCH 04/70] Centralized deprecated code --- ...Deprecated.swift => Hook+Deprecated.swift} | 0 .../Deprecated/NSObject+Deprecated.swift | 45 +++++++++++++++++++ Sources/InterposeKit/NSObject+Interpose.swift | 44 ------------------ 3 files changed, 45 insertions(+), 44 deletions(-) rename Sources/InterposeKit/Deprecated/{Deprecated.swift => Hook+Deprecated.swift} (100%) create mode 100644 Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift diff --git a/Sources/InterposeKit/Deprecated/Deprecated.swift b/Sources/InterposeKit/Deprecated/Hook+Deprecated.swift similarity index 100% rename from Sources/InterposeKit/Deprecated/Deprecated.swift rename to Sources/InterposeKit/Deprecated/Hook+Deprecated.swift diff --git a/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift b/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift new file mode 100644 index 0000000..0038dc9 --- /dev/null +++ b/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift @@ -0,0 +1,45 @@ +import ObjectiveC + +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/NSObject+Interpose.swift b/Sources/InterposeKit/NSObject+Interpose.swift index 7e6c952..9d21f83 100644 --- a/Sources/InterposeKit/NSObject+Interpose.swift +++ b/Sources/InterposeKit/NSObject+Interpose.swift @@ -54,47 +54,3 @@ extension NSObject { } } - -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() - } - -} From 8d4e170b7fec8f237bb8238be1d32e7616ea0833 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Tue, 25 Mar 2025 17:56:51 +0100 Subject: [PATCH 05/70] Marked `AnyHook.State` as deprecated --- Sources/InterposeKit/Deprecated/Hook+Deprecated.swift | 7 +++++++ Sources/InterposeKit/Hooks/AnyHook.swift | 8 +++----- Sources/InterposeKit/Interpose.swift | 2 +- Sources/InterposeKit/InterposeError.swift | 2 +- 4 files changed, 12 insertions(+), 7 deletions(-) diff --git a/Sources/InterposeKit/Deprecated/Hook+Deprecated.swift b/Sources/InterposeKit/Deprecated/Hook+Deprecated.swift index 7e6c1d0..d2ae275 100644 --- a/Sources/InterposeKit/Deprecated/Hook+Deprecated.swift +++ b/Sources/InterposeKit/Deprecated/Hook+Deprecated.swift @@ -1,3 +1,10 @@ +extension AnyHook { + + @available(*, deprecated, renamed: "HookState", message: "Use top-level 'HookState' instead of 'AnyHook.State'.") + public typealias State = HookState + +} + extension HookState { @available(*, deprecated, renamed: "pending", message: "Use 'pending' instead.") diff --git a/Sources/InterposeKit/Hooks/AnyHook.swift b/Sources/InterposeKit/Hooks/AnyHook.swift index 4ee3909..38218a0 100644 --- a/Sources/InterposeKit/Hooks/AnyHook.swift +++ b/Sources/InterposeKit/Hooks/AnyHook.swift @@ -9,7 +9,7 @@ public class AnyHook { public let selector: Selector /// The current state of the hook. - public internal(set) var state = State.pending + public internal(set) var state = HookState.pending // else we validate init order var replacementIMP: IMP! @@ -17,8 +17,6 @@ public class AnyHook { // fetched at apply time, changes late, thus class requirement var origIMP: IMP? - public typealias State = HookState - init(`class`: AnyClass, selector: Selector) throws { self.selector = selector self.class = `class` @@ -48,7 +46,7 @@ public class AnyHook { } /// Validate that the selector exists on the active class. - @discardableResult func validate(expectedState: State = .pending) throws -> Method { + @discardableResult func validate(expectedState: HookState = .pending) throws -> Method { guard let method = class_getInstanceMethod(`class`, selector) else { throw InterposeError.methodNotFound(`class`, selector) } @@ -58,7 +56,7 @@ public class AnyHook { return method } - private func execute(newState: State, task: () throws -> Void) throws { + private func execute(newState: HookState, task: () throws -> Void) throws { do { try task() state = newState diff --git a/Sources/InterposeKit/Interpose.swift b/Sources/InterposeKit/Interpose.swift index f3d7874..bc93876 100644 --- a/Sources/InterposeKit/Interpose.swift +++ b/Sources/InterposeKit/Interpose.swift @@ -116,7 +116,7 @@ final public class Interpose { } private func execute(_ task: ((Interpose) throws -> Void)? = nil, - expectedState: AnyHook.State = .pending, + expectedState: HookState = .pending, executor: ((AnyHook) throws -> Void)) throws -> Interpose { // Run pre-apply code first if let task = task { diff --git a/Sources/InterposeKit/InterposeError.swift b/Sources/InterposeKit/InterposeError.swift index e19a372..92e6a8e 100644 --- a/Sources/InterposeKit/InterposeError.swift +++ b/Sources/InterposeKit/InterposeError.swift @@ -32,7 +32,7 @@ public enum InterposeError: LocalizedError { case objectPosingAsDifferentClass(AnyObject, actualClass: AnyClass) /// Can't revert or apply if already done so. - case invalidState(expectedState: AnyHook.State) + case invalidState(expectedState: HookState) /// Unable to remove hook. case resetUnsupported(_ reason: String) From 0c3f4c14fad072a74442414be7ed52838642b23a Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Tue, 25 Mar 2025 20:06:32 +0100 Subject: [PATCH 06/70] Deprecated fluent overloads of `apply()` and `revert()` --- .../Deprecated/Hook+Deprecated.swift | 16 +++++++++++++++- Sources/InterposeKit/Hooks/AnyHook.swift | 8 +++----- Sources/InterposeKit/NSObject+Interpose.swift | 11 ++++++----- 3 files changed, 24 insertions(+), 11 deletions(-) diff --git a/Sources/InterposeKit/Deprecated/Hook+Deprecated.swift b/Sources/InterposeKit/Deprecated/Hook+Deprecated.swift index d2ae275..c49cb1d 100644 --- a/Sources/InterposeKit/Deprecated/Hook+Deprecated.swift +++ b/Sources/InterposeKit/Deprecated/Hook+Deprecated.swift @@ -1,8 +1,22 @@ extension AnyHook { - @available(*, deprecated, renamed: "HookState", message: "Use top-level 'HookState' instead of 'AnyHook.State'.") + @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 { diff --git a/Sources/InterposeKit/Hooks/AnyHook.swift b/Sources/InterposeKit/Hooks/AnyHook.swift index 38218a0..3f6184d 100644 --- a/Sources/InterposeKit/Hooks/AnyHook.swift +++ b/Sources/InterposeKit/Hooks/AnyHook.swift @@ -34,17 +34,15 @@ public class AnyHook { } /// Apply the interpose hook. - @discardableResult public func apply() throws -> AnyHook { + public func apply() throws { try execute(newState: .active) { try replaceImplementation() } - return self } /// Revert the interpose hook. - @discardableResult public func revert() throws -> AnyHook { + public func revert() throws { try execute(newState: .pending) { try resetImplementation() } - return self } - + /// Validate that the selector exists on the active class. @discardableResult func validate(expectedState: HookState = .pending) throws -> Method { guard let method = class_getInstanceMethod(`class`, selector) else { diff --git a/Sources/InterposeKit/NSObject+Interpose.swift b/Sources/InterposeKit/NSObject+Interpose.swift index 9d21f83..9646547 100644 --- a/Sources/InterposeKit/NSObject+Interpose.swift +++ b/Sources/InterposeKit/NSObject+Interpose.swift @@ -46,11 +46,12 @@ extension NSObject { hookSignature: HookSignature.Type, implementation: (TypedHook) -> HookSignature ) throws -> AnyHook { - try Interpose.ObjectHook( - object: self, - selector: selector, - implementation: implementation - ).apply() + return try Interpose(self).hook( + selector, + methodSignature: methodSignature, + hookSignature: hookSignature, + implementation + ) } } From 5ff24623ac705fa651502bf3a289ef41a4db158a Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Tue, 25 Mar 2025 20:20:52 +0100 Subject: [PATCH 07/70] Introduced Hook protocol --- .../Deprecated/NSObject+Deprecated.swift | 10 +++++---- Sources/InterposeKit/Hooks/AnyHook.swift | 2 +- Sources/InterposeKit/Hooks/Hook.swift | 22 +++++++++++++++++++ Sources/InterposeKit/Interpose.swift | 6 ++--- Sources/InterposeKit/NSObject+Interpose.swift | 13 ++++++----- 5 files changed, 39 insertions(+), 14 deletions(-) diff --git a/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift b/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift index 0038dc9..3366521 100644 --- a/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift +++ b/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift @@ -9,7 +9,7 @@ extension NSObject { methodSignature: MethodSignature.Type = MethodSignature.self, hookSignature: HookSignature.Type = HookSignature.self, _ implementation: (TypedHook) -> HookSignature - ) throws -> AnyHook { + ) throws -> some Hook { precondition( !(self is AnyClass), "There should not be a way to cast an NSObject to AnyClass." @@ -34,12 +34,14 @@ extension NSObject { methodSignature: MethodSignature.Type = MethodSignature.self, hookSignature: HookSignature.Type = HookSignature.self, _ implementation: (TypedHook) -> HookSignature - ) throws -> AnyHook { - return try Interpose.ClassHook( + ) throws -> some Hook { + let hook = try Interpose.ClassHook( class: self as AnyClass, selector: selector, implementation: implementation - ).apply() + ) + try hook.apply() + return hook } } diff --git a/Sources/InterposeKit/Hooks/AnyHook.swift b/Sources/InterposeKit/Hooks/AnyHook.swift index 3f6184d..2abbc54 100644 --- a/Sources/InterposeKit/Hooks/AnyHook.swift +++ b/Sources/InterposeKit/Hooks/AnyHook.swift @@ -1,7 +1,7 @@ import Foundation /// Base class, represents a hook to exactly one method. -public class AnyHook { +public class AnyHook: Hook { /// The class this hook is based on. public let `class`: AnyClass diff --git a/Sources/InterposeKit/Hooks/Hook.swift b/Sources/InterposeKit/Hooks/Hook.swift index 16cd98a..0636dc5 100644 --- a/Sources/InterposeKit/Hooks/Hook.swift +++ b/Sources/InterposeKit/Hooks/Hook.swift @@ -1,3 +1,25 @@ +import ObjectiveC + +/// A runtime hook that interposes a single instance method on a class or object. +public protocol Hook: AnyObject { + + /// The class whose instance method is being interposed. + var `class`: AnyClass { get } + + /// The selector identifying the instance method being interposed. + var selector: Selector { get } + + /// The current state of the hook. + var state: HookState { get } + + /// Applies the hook by interposing the method implementation. + func apply() throws + + /// Reverts the hook, restoring the original method implementation. + func revert() throws + +} + public enum HookState: Equatable { /// The hook is ready to be applied. diff --git a/Sources/InterposeKit/Interpose.swift b/Sources/InterposeKit/Interpose.swift index bc93876..bccbd53 100644 --- a/Sources/InterposeKit/Interpose.swift +++ b/Sources/InterposeKit/Interpose.swift @@ -70,7 +70,7 @@ final public class Interpose { methodSignature: MethodSignature.Type = MethodSignature.self, hookSignature: HookSignature.Type = HookSignature.self, _ implementation: (TypedHook) -> HookSignature - ) throws -> TypedHook { + ) throws -> some Hook { try hook(NSSelectorFromString(selName), methodSignature: methodSignature, hookSignature: hookSignature, implementation) } @@ -81,7 +81,7 @@ final public class Interpose { methodSignature: MethodSignature.Type = MethodSignature.self, hookSignature: HookSignature.Type = HookSignature.self, _ implementation: (TypedHook) -> HookSignature - ) throws -> TypedHook { + ) throws -> some Hook { let hook = try prepareHook(selector, methodSignature: methodSignature, hookSignature: hookSignature, implementation) try hook.apply() @@ -94,7 +94,7 @@ final public class Interpose { methodSignature: MethodSignature.Type = MethodSignature.self, hookSignature: HookSignature.Type = HookSignature.self, _ implementation: (TypedHook) -> HookSignature - ) throws -> TypedHook { + ) throws -> some Hook { var hook: TypedHook if let object = self.object { hook = try ObjectHook(object: object, selector: selector, implementation: implementation) diff --git a/Sources/InterposeKit/NSObject+Interpose.swift b/Sources/InterposeKit/NSObject+Interpose.swift index 9646547..e5048fd 100644 --- a/Sources/InterposeKit/NSObject+Interpose.swift +++ b/Sources/InterposeKit/NSObject+Interpose.swift @@ -45,13 +45,14 @@ extension NSObject { methodSignature: MethodSignature.Type, hookSignature: HookSignature.Type, implementation: (TypedHook) -> HookSignature - ) throws -> AnyHook { - return try Interpose(self).hook( - selector, - methodSignature: methodSignature, - hookSignature: hookSignature, - implementation + ) throws -> some Hook { + let hook = try Interpose.ObjectHook( + object: self, + selector: selector, + implementation: implementation ) + try hook.apply() + return hook } } From 7b0e94ddb13c6cf2e7034d790eb7b83eee304c2e Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Tue, 25 Mar 2025 22:06:22 +0100 Subject: [PATCH 08/70] Initial step towards strategy-based approach --- Sources/InterposeKit/Hooks/AnyHook.swift | 18 +++++----- Sources/InterposeKit/Hooks/ClassHook.swift | 19 +++++++--- Sources/InterposeKit/Hooks/HookStrategy.swift | 11 ++++++ Sources/InterposeKit/Hooks/ObjectHook.swift | 36 ++++++++++++------- 4 files changed, 58 insertions(+), 26 deletions(-) create mode 100644 Sources/InterposeKit/Hooks/HookStrategy.swift diff --git a/Sources/InterposeKit/Hooks/AnyHook.swift b/Sources/InterposeKit/Hooks/AnyHook.swift index 2abbc54..0fba793 100644 --- a/Sources/InterposeKit/Hooks/AnyHook.swift +++ b/Sources/InterposeKit/Hooks/AnyHook.swift @@ -11,18 +11,20 @@ public class AnyHook: Hook { /// The current state of the hook. public internal(set) var state = HookState.pending - // else we validate init order - var replacementIMP: IMP! - + private var _strategy: AnyHookStrategy! + var strategy: AnyHookStrategy { _strategy } + // fetched at apply time, changes late, thus class requirement var origIMP: IMP? - init(`class`: AnyClass, selector: Selector) throws { + init(`class`: AnyClass, selector: Selector, strategyProvider: (AnyHook) -> AnyHookStrategy) throws { self.selector = selector self.class = `class` // Check if method exists try validate() + + self._strategy = strategyProvider(self) } func replaceImplementation() throws { @@ -68,12 +70,12 @@ public class AnyHook: Hook { public func cleanup() { switch state { case .pending: - Interpose.log("Releasing -[\(`class`).\(selector)] IMP: \(replacementIMP!)") - imp_removeBlock(replacementIMP) + Interpose.log("Releasing -[\(`class`).\(selector)] IMP: \(self.strategy.replacementIMP)") + imp_removeBlock(strategy.replacementIMP) case .active: - Interpose.log("Keeping -[\(`class`).\(selector)] IMP: \(replacementIMP!)") + Interpose.log("Keeping -[\(`class`).\(selector)] IMP: \(self.strategy.replacementIMP)") case .failed: - Interpose.log("Leaking -[\(`class`).\(selector)] IMP: \(replacementIMP!)") + Interpose.log("Leaking -[\(`class`).\(selector)] IMP: \(self.strategy.replacementIMP)") } } } diff --git a/Sources/InterposeKit/Hooks/ClassHook.swift b/Sources/InterposeKit/Hooks/ClassHook.swift index 85a8dd9..e97b702 100644 --- a/Sources/InterposeKit/Hooks/ClassHook.swift +++ b/Sources/InterposeKit/Hooks/ClassHook.swift @@ -9,22 +9,31 @@ extension Interpose { selector: Selector, implementation: (ClassHook) -> HookSignature ) throws { - try super.init(class: `class`, selector: selector) - replacementIMP = imp_implementationWithBlock(implementation(self)) + let strategyProvider: (AnyHook) -> AnyHookStrategy = { hook in + let hook = hook as! Self + let replacementIMP = imp_implementationWithBlock(implementation(hook)) + return AnyHookStrategy(replacementIMP: replacementIMP) + } + + try super.init( + class: `class`, + selector: selector, + strategyProvider: strategyProvider + ) } override func replaceImplementation() throws { let method = try validate() - origIMP = class_replaceMethod(`class`, selector, replacementIMP, method_getTypeEncoding(method)) + origIMP = class_replaceMethod(`class`, selector, self.strategy.replacementIMP, method_getTypeEncoding(method)) guard origIMP != nil else { throw InterposeError.nonExistingImplementation(`class`, selector) } - Interpose.log("Swizzled -[\(`class`).\(selector)] IMP: \(origIMP!) -> \(replacementIMP!)") + Interpose.log("Swizzled -[\(`class`).\(selector)] IMP: \(origIMP!) -> \(self.strategy.replacementIMP)") } override func resetImplementation() throws { let method = try validate(expectedState: .active) precondition(origIMP != nil) let previousIMP = class_replaceMethod(`class`, selector, origIMP!, method_getTypeEncoding(method)) - guard previousIMP == replacementIMP else { + guard previousIMP == self.strategy.replacementIMP else { throw InterposeError.unexpectedImplementation(`class`, selector, previousIMP) } Interpose.log("Restored -[\(`class`).\(selector)] IMP: \(origIMP!)") diff --git a/Sources/InterposeKit/Hooks/HookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy.swift new file mode 100644 index 0000000..972b6b8 --- /dev/null +++ b/Sources/InterposeKit/Hooks/HookStrategy.swift @@ -0,0 +1,11 @@ +import ObjectiveC + +final class AnyHookStrategy { + + init(replacementIMP: IMP) { + self.replacementIMP = replacementIMP + } + + let replacementIMP: IMP + +} diff --git a/Sources/InterposeKit/Hooks/ObjectHook.swift b/Sources/InterposeKit/Hooks/ObjectHook.swift index 2fd6ab8..2980d93 100644 --- a/Sources/InterposeKit/Hooks/ObjectHook.swift +++ b/Sources/InterposeKit/Hooks/ObjectHook.swift @@ -22,15 +22,23 @@ extension Interpose { 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?") + + let strategyProvider: (AnyHook) -> AnyHookStrategy = { hook in + let hook = hook as! Self + let block = implementation(hook) as AnyObject + let replacementIMP = imp_implementationWithBlock(block) + + // Weakly store reference to hook inside the block of the IMP. + Interpose.storeHook(hook: hook, to: block) + + return AnyHookStrategy(replacementIMP: replacementIMP) } - - // Weakly store reference to hook inside the block of the IMP. - Interpose.storeHook(hook: self, to: block) + + try super.init( + class: type(of: object), + selector: selector, + strategyProvider: strategyProvider + ) } // /// Release the hook block if possible. @@ -99,6 +107,7 @@ extension Interpose { // This function searches superclasses for implementations let hasExistingMethod = exactClassImplementsSelector(dynamicSubclass, selector) let encoding = method_getTypeEncoding(method) + let replacementIMP = self.strategy.replacementIMP if self.generatesSuperIMP { // If the subclass is empty, we create a super trampoline first. @@ -112,23 +121,23 @@ extension Interpose { guard origIMP != nil else { throw InterposeError.nonExistingImplementation(dynamicSubclass, selector) } - Interpose.log("Added -[\(`class`).\(selector)] IMP: \(origIMP!) -> \(replacementIMP!)") + 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") + Interpose.log("Added -[\(`class`).\(selector)] IMP: \(replacementIMP) via replacement") } else { - Interpose.log("Unable to replace: -[\(`class`).\(selector)] IMP: \(replacementIMP!)") + 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!)") + Interpose.log("Added -[\(`class`).\(selector)] IMP: \(replacementIMP)") } else { - Interpose.log("Unable to add: -[\(`class`).\(selector)] IMP: \(replacementIMP!)") + Interpose.log("Unable to add: -[\(`class`).\(selector)] IMP: \(replacementIMP)") throw InterposeError.unableToAddMethod(`class`, selector) } } @@ -155,6 +164,7 @@ extension Interpose { } // We are the topmost hook, replace method. + let replacementIMP = self.strategy.replacementIMP if currentIMP == replacementIMP { let previousIMP = class_replaceMethod( dynamicSubclass, selector, origIMP!, method_getTypeEncoding(method)) From 86c05aabb8e432c587dfce5a0e623212eaae9d36 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Tue, 25 Mar 2025 22:12:50 +0100 Subject: [PATCH 09/70] Moved originalIMP to AnyHookStrategy --- Sources/InterposeKit/Hooks/AnyHook.swift | 3 --- Sources/InterposeKit/Hooks/ClassHook.swift | 16 +++++++-------- Sources/InterposeKit/Hooks/HookStrategy.swift | 4 ++++ Sources/InterposeKit/Hooks/ObjectHook.swift | 20 +++++++++---------- .../InterposeKit/Internal/HookFinder.swift | 2 +- 5 files changed, 23 insertions(+), 22 deletions(-) diff --git a/Sources/InterposeKit/Hooks/AnyHook.swift b/Sources/InterposeKit/Hooks/AnyHook.swift index 0fba793..04ab8eb 100644 --- a/Sources/InterposeKit/Hooks/AnyHook.swift +++ b/Sources/InterposeKit/Hooks/AnyHook.swift @@ -13,9 +13,6 @@ public class AnyHook: Hook { private var _strategy: AnyHookStrategy! var strategy: AnyHookStrategy { _strategy } - - // fetched at apply time, changes late, thus class requirement - var origIMP: IMP? init(`class`: AnyClass, selector: Selector, strategyProvider: (AnyHook) -> AnyHookStrategy) throws { self.selector = selector diff --git a/Sources/InterposeKit/Hooks/ClassHook.swift b/Sources/InterposeKit/Hooks/ClassHook.swift index e97b702..b1f850b 100644 --- a/Sources/InterposeKit/Hooks/ClassHook.swift +++ b/Sources/InterposeKit/Hooks/ClassHook.swift @@ -24,24 +24,24 @@ extension Interpose { override func replaceImplementation() throws { let method = try validate() - origIMP = class_replaceMethod(`class`, selector, self.strategy.replacementIMP, method_getTypeEncoding(method)) - guard origIMP != nil else { throw InterposeError.nonExistingImplementation(`class`, selector) } - Interpose.log("Swizzled -[\(`class`).\(selector)] IMP: \(origIMP!) -> \(self.strategy.replacementIMP)") + self.strategy.originalIMP = class_replaceMethod(`class`, selector, self.strategy.replacementIMP, method_getTypeEncoding(method)) + guard self.strategy.originalIMP != nil else { throw InterposeError.nonExistingImplementation(`class`, selector) } + Interpose.log("Swizzled -[\(`class`).\(selector)] IMP: \(self.strategy.originalIMP!) -> \(self.strategy.replacementIMP)") } override func resetImplementation() throws { let method = try validate(expectedState: .active) - precondition(origIMP != nil) - let previousIMP = class_replaceMethod(`class`, selector, origIMP!, method_getTypeEncoding(method)) + precondition(self.strategy.originalIMP != nil) + let previousIMP = class_replaceMethod(`class`, selector, self.strategy.originalIMP!, method_getTypeEncoding(method)) guard previousIMP == self.strategy.replacementIMP else { throw InterposeError.unexpectedImplementation(`class`, selector, previousIMP) } - Interpose.log("Restored -[\(`class`).\(selector)] IMP: \(origIMP!)") + Interpose.log("Restored -[\(`class`).\(selector)] IMP: \(self.strategy.originalIMP!)") } /// The original implementation is cached at hook time. public override var original: MethodSignature { - unsafeBitCast(origIMP, to: MethodSignature.self) + unsafeBitCast(self.strategy.originalIMP, to: MethodSignature.self) } } } @@ -49,7 +49,7 @@ extension Interpose { #if DEBUG extension Interpose.ClassHook: CustomDebugStringConvertible { public var debugDescription: String { - return "\(selector) -> \(String(describing: origIMP))" + return "\(selector) -> \(String(describing: self.strategy.originalIMP))" } } #endif diff --git a/Sources/InterposeKit/Hooks/HookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy.swift index 972b6b8..c8485a2 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy.swift @@ -6,6 +6,10 @@ final class AnyHookStrategy { self.replacementIMP = replacementIMP } + /// The replacement implementation used to interpose the method, created during hook setup. let replacementIMP: IMP + /// The original method implementation, captured when the hook is applied. + var originalIMP: IMP? + } diff --git a/Sources/InterposeKit/Hooks/ObjectHook.swift b/Sources/InterposeKit/Hooks/ObjectHook.swift index 2980d93..c7b2d86 100644 --- a/Sources/InterposeKit/Hooks/ObjectHook.swift +++ b/Sources/InterposeKit/Hooks/ObjectHook.swift @@ -50,7 +50,7 @@ extension Interpose { /// 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 { + if let savedOrigIMP = self.strategy.originalIMP { return unsafeBitCast(savedOrigIMP, to: MethodSignature.self) } // Else, perform a dynamic lookup @@ -117,16 +117,16 @@ extension Interpose { } // Replace IMP (by now we guarantee that it exists) - origIMP = class_replaceMethod(dynamicSubclass, selector, replacementIMP, encoding) - guard origIMP != nil else { + self.strategy.originalIMP = class_replaceMethod(dynamicSubclass, selector, replacementIMP, encoding) + guard self.strategy.originalIMP != nil else { throw InterposeError.nonExistingImplementation(dynamicSubclass, selector) } - Interpose.log("Added -[\(`class`).\(selector)] IMP: \(origIMP!) -> \(replacementIMP)") + Interpose.log("Added -[\(`class`).\(selector)] IMP: \(self.strategy.originalIMP!) -> \(replacementIMP)") } else { // Could potentially be unified in the code paths if hasExistingMethod { - origIMP = class_replaceMethod(dynamicSubclass, selector, replacementIMP, encoding) - if origIMP != nil { + self.strategy.originalIMP = class_replaceMethod(dynamicSubclass, selector, replacementIMP, encoding) + if self.strategy.originalIMP != nil { Interpose.log("Added -[\(`class`).\(selector)] IMP: \(replacementIMP) via replacement") } else { Interpose.log("Unable to replace: -[\(`class`).\(selector)] IMP: \(replacementIMP)") @@ -147,7 +147,7 @@ extension Interpose { override func resetImplementation() throws { let method = try validate(expectedState: .active) - guard super.origIMP != nil else { + guard self.strategy.originalIMP != 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 @@ -167,15 +167,15 @@ extension Interpose { let replacementIMP = self.strategy.replacementIMP if currentIMP == replacementIMP { let previousIMP = class_replaceMethod( - dynamicSubclass, selector, origIMP!, method_getTypeEncoding(method)) + dynamicSubclass, selector, self.strategy.originalIMP!, method_getTypeEncoding(method)) guard previousIMP == replacementIMP else { throw InterposeError.unexpectedImplementation(dynamicSubclass, selector, previousIMP) } - Interpose.log("Restored -[\(`class`).\(selector)] IMP: \(origIMP!)") + Interpose.log("Restored -[\(`class`).\(selector)] IMP: \(self.strategy.originalIMP!)") } else { let nextHook = Interpose.findNextHook(selfHook: self, topmostIMP: currentIMP) // Replace next's original IMP - nextHook?.origIMP = self.origIMP + nextHook?.strategy.originalIMP = self.strategy.originalIMP } // FUTURE: remove class pair! diff --git a/Sources/InterposeKit/Internal/HookFinder.swift b/Sources/InterposeKit/Internal/HookFinder.swift index fccf06f..6ce1544 100644 --- a/Sources/InterposeKit/Internal/HookFinder.swift +++ b/Sources/InterposeKit/Internal/HookFinder.swift @@ -46,7 +46,7 @@ extension Interpose { } // crawl down the chain until we find ourselves currentHook = hook - impl = hook?.origIMP + impl = hook?.strategy.originalIMP } while impl != nil return nil } From 1fbf4327199ef5936afcc42a3610be9ca80d9065 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Tue, 25 Mar 2025 22:31:43 +0100 Subject: [PATCH 10/70] First take on ClassHookStrategy --- Sources/InterposeKit/Hooks/AnyHook.swift | 6 +- Sources/InterposeKit/Hooks/ClassHook.swift | 91 ++++++++++++++++--- Sources/InterposeKit/Hooks/HookStrategy.swift | 15 ++- 3 files changed, 91 insertions(+), 21 deletions(-) diff --git a/Sources/InterposeKit/Hooks/AnyHook.swift b/Sources/InterposeKit/Hooks/AnyHook.swift index 04ab8eb..88612b9 100644 --- a/Sources/InterposeKit/Hooks/AnyHook.swift +++ b/Sources/InterposeKit/Hooks/AnyHook.swift @@ -11,10 +11,10 @@ public class AnyHook: Hook { /// The current state of the hook. public internal(set) var state = HookState.pending - private var _strategy: AnyHookStrategy! - var strategy: AnyHookStrategy { _strategy } + private var _strategy: HookStrategy! + var strategy: HookStrategy { _strategy } - init(`class`: AnyClass, selector: Selector, strategyProvider: (AnyHook) -> AnyHookStrategy) throws { + init(`class`: AnyClass, selector: Selector, strategyProvider: (AnyHook) -> HookStrategy) throws { self.selector = selector self.class = `class` diff --git a/Sources/InterposeKit/Hooks/ClassHook.swift b/Sources/InterposeKit/Hooks/ClassHook.swift index b1f850b..02acbd9 100644 --- a/Sources/InterposeKit/Hooks/ClassHook.swift +++ b/Sources/InterposeKit/Hooks/ClassHook.swift @@ -9,10 +9,15 @@ extension Interpose { selector: Selector, implementation: (ClassHook) -> HookSignature ) throws { - let strategyProvider: (AnyHook) -> AnyHookStrategy = { hook in + let strategyProvider: (AnyHook) -> HookStrategy = { hook in let hook = hook as! Self let replacementIMP = imp_implementationWithBlock(implementation(hook)) - return AnyHookStrategy(replacementIMP: replacementIMP) + + return ClassHookStrategy( + class: `class`, + selector: selector, + replacementIMP: replacementIMP + ) } try super.init( @@ -23,27 +28,85 @@ extension Interpose { } override func replaceImplementation() throws { - let method = try validate() - self.strategy.originalIMP = class_replaceMethod(`class`, selector, self.strategy.replacementIMP, method_getTypeEncoding(method)) - guard self.strategy.originalIMP != nil else { throw InterposeError.nonExistingImplementation(`class`, selector) } - Interpose.log("Swizzled -[\(`class`).\(selector)] IMP: \(self.strategy.originalIMP!) -> \(self.strategy.replacementIMP)") + try (self.strategy as! ClassHookStrategy).replaceImplementation() } override func resetImplementation() throws { - let method = try validate(expectedState: .active) - precondition(self.strategy.originalIMP != nil) - let previousIMP = class_replaceMethod(`class`, selector, self.strategy.originalIMP!, method_getTypeEncoding(method)) - guard previousIMP == self.strategy.replacementIMP else { - throw InterposeError.unexpectedImplementation(`class`, selector, previousIMP) - } - Interpose.log("Restored -[\(`class`).\(selector)] IMP: \(self.strategy.originalIMP!)") + try (self.strategy as! ClassHookStrategy).resetImplementation() } /// The original implementation is cached at hook time. public override var original: MethodSignature { - unsafeBitCast(self.strategy.originalIMP, to: MethodSignature.self) + (self.strategy as! ClassHookStrategy).original + } + + } +} + +final class ClassHookStrategy: HookStrategy { + + init( + `class`: AnyClass, + selector: Selector, + replacementIMP: IMP + ) { + self.class = `class` + self.selector = selector + self.replacementIMP = replacementIMP + } + + let `class`: AnyClass + let selector: Selector + let replacementIMP: IMP + var originalIMP: IMP? + + func replaceImplementation() throws { + 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, + self.replacementIMP, + method_getTypeEncoding(method) + ) else { + throw InterposeError.nonExistingImplementation(self.class, self.selector) + } + + self.originalIMP = originalIMP + + Interpose.log("Swizzled -[\(self.class).\(self.selector)] IMP: \(originalIMP) -> \(self.replacementIMP)") + } + + func resetImplementation() throws { + guard let method = class_getInstanceMethod(self.class, self.selector) else { + throw InterposeError.methodNotFound(self.class, self.selector) + } + + guard let originalIMP = self.originalIMP else { + // Ignore? Throw error? + fatalError("The original implementation should be loaded when resetting") } + + let previousIMP = class_replaceMethod( + self.class, + self.selector, + originalIMP, + method_getTypeEncoding(method) + ) + + guard previousIMP == self.replacementIMP else { + throw InterposeError.unexpectedImplementation(self.class, self.selector, previousIMP) + } + + Interpose.log("Restored -[\(self.class).\(self.selector)] IMP: \(originalIMP)") + } + + var original: MethodSignature { + unsafeBitCast(self.originalIMP, to: MethodSignature.self) } + } #if DEBUG diff --git a/Sources/InterposeKit/Hooks/HookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy.swift index c8485a2..5acf206 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy.swift @@ -1,15 +1,22 @@ import ObjectiveC -final class AnyHookStrategy { +protocol HookStrategy: AnyObject { + + /// The replacement implementation used to interpose the method, created during hook setup. + var replacementIMP: IMP { get } + + /// The original method implementation, captured when the hook is applied. + var originalIMP: IMP? { get set } + +} + +final class AnyHookStrategy: HookStrategy { init(replacementIMP: IMP) { self.replacementIMP = replacementIMP } - /// The replacement implementation used to interpose the method, created during hook setup. let replacementIMP: IMP - - /// The original method implementation, captured when the hook is applied. var originalIMP: IMP? } From 93d756b007460fd3ed44074b282fb8ddb79997e4 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Tue, 25 Mar 2025 22:38:35 +0100 Subject: [PATCH 11/70] More tweaks to HookStrategy types --- Sources/InterposeKit/Hooks/AnyHook.swift | 6 +++--- Sources/InterposeKit/Hooks/ClassHook.swift | 2 +- Sources/InterposeKit/Hooks/HookStrategy.swift | 12 +++++++++--- Sources/InterposeKit/Hooks/ObjectHook.swift | 10 +++++----- 4 files changed, 18 insertions(+), 12 deletions(-) diff --git a/Sources/InterposeKit/Hooks/AnyHook.swift b/Sources/InterposeKit/Hooks/AnyHook.swift index 88612b9..5c402b9 100644 --- a/Sources/InterposeKit/Hooks/AnyHook.swift +++ b/Sources/InterposeKit/Hooks/AnyHook.swift @@ -11,10 +11,10 @@ public class AnyHook: Hook { /// The current state of the hook. public internal(set) var state = HookState.pending - private var _strategy: HookStrategy! - var strategy: HookStrategy { _strategy } + private var _strategy: _HookStrategy! + var strategy: _HookStrategy { _strategy } - init(`class`: AnyClass, selector: Selector, strategyProvider: (AnyHook) -> HookStrategy) throws { + init(`class`: AnyClass, selector: Selector, strategyProvider: (AnyHook) -> _HookStrategy) throws { self.selector = selector self.class = `class` diff --git a/Sources/InterposeKit/Hooks/ClassHook.swift b/Sources/InterposeKit/Hooks/ClassHook.swift index 02acbd9..0b0b6de 100644 --- a/Sources/InterposeKit/Hooks/ClassHook.swift +++ b/Sources/InterposeKit/Hooks/ClassHook.swift @@ -9,7 +9,7 @@ extension Interpose { selector: Selector, implementation: (ClassHook) -> HookSignature ) throws { - let strategyProvider: (AnyHook) -> HookStrategy = { hook in + let strategyProvider: (AnyHook) -> _HookStrategy = { hook in let hook = hook as! Self let replacementIMP = imp_implementationWithBlock(implementation(hook)) diff --git a/Sources/InterposeKit/Hooks/HookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy.swift index 5acf206..080e2a0 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy.swift @@ -1,16 +1,22 @@ import ObjectiveC -protocol HookStrategy: AnyObject { +protocol _HookStrategy: AnyObject { /// The replacement implementation used to interpose the method, created during hook setup. var replacementIMP: IMP { get } /// The original method implementation, captured when the hook is applied. - var originalIMP: IMP? { get set } + var originalIMP: IMP? { get } } -final class AnyHookStrategy: HookStrategy { +protocol HookStrategy: _HookStrategy { + + associatedtype MethodSignature + +} + +final class DummyHookStrategy: HookStrategy { init(replacementIMP: IMP) { self.replacementIMP = replacementIMP diff --git a/Sources/InterposeKit/Hooks/ObjectHook.swift b/Sources/InterposeKit/Hooks/ObjectHook.swift index c7b2d86..e525fe2 100644 --- a/Sources/InterposeKit/Hooks/ObjectHook.swift +++ b/Sources/InterposeKit/Hooks/ObjectHook.swift @@ -23,7 +23,7 @@ extension Interpose { ) throws { self.object = object - let strategyProvider: (AnyHook) -> AnyHookStrategy = { hook in + let strategyProvider: (AnyHook) -> any _HookStrategy = { hook in let hook = hook as! Self let block = implementation(hook) as AnyObject let replacementIMP = imp_implementationWithBlock(block) @@ -31,7 +31,7 @@ extension Interpose { // Weakly store reference to hook inside the block of the IMP. Interpose.storeHook(hook: hook, to: block) - return AnyHookStrategy(replacementIMP: replacementIMP) + return DummyHookStrategy(replacementIMP: replacementIMP) } try super.init( @@ -117,7 +117,7 @@ extension Interpose { } // Replace IMP (by now we guarantee that it exists) - self.strategy.originalIMP = class_replaceMethod(dynamicSubclass, selector, replacementIMP, encoding) + (self.strategy as! DummyHookStrategy).originalIMP = class_replaceMethod(dynamicSubclass, selector, replacementIMP, encoding) guard self.strategy.originalIMP != nil else { throw InterposeError.nonExistingImplementation(dynamicSubclass, selector) } @@ -125,7 +125,7 @@ extension Interpose { } else { // Could potentially be unified in the code paths if hasExistingMethod { - self.strategy.originalIMP = class_replaceMethod(dynamicSubclass, selector, replacementIMP, encoding) + (self.strategy as! DummyHookStrategy).originalIMP = class_replaceMethod(dynamicSubclass, selector, replacementIMP, encoding) if self.strategy.originalIMP != nil { Interpose.log("Added -[\(`class`).\(selector)] IMP: \(replacementIMP) via replacement") } else { @@ -175,7 +175,7 @@ extension Interpose { } else { let nextHook = Interpose.findNextHook(selfHook: self, topmostIMP: currentIMP) // Replace next's original IMP - nextHook?.strategy.originalIMP = self.strategy.originalIMP + (nextHook?.strategy as? DummyHookStrategy)?.originalIMP = self.strategy.originalIMP } // FUTURE: remove class pair! From dee3e0cb60a3ac47ff607abff67564e4a2ec1566 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Tue, 25 Mar 2025 23:01:40 +0100 Subject: [PATCH 12/70] Minor tweaks --- Sources/InterposeKit/Hooks/Hook.swift | 3 +++ Sources/InterposeKit/NSObject+Interpose.swift | 3 +-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Sources/InterposeKit/Hooks/Hook.swift b/Sources/InterposeKit/Hooks/Hook.swift index 0636dc5..1209020 100644 --- a/Sources/InterposeKit/Hooks/Hook.swift +++ b/Sources/InterposeKit/Hooks/Hook.swift @@ -18,6 +18,9 @@ public protocol Hook: AnyObject { /// Reverts the hook, restoring the original method implementation. func revert() throws + // TODO: Rename to `cleanUp()` + func cleanup() + } public enum HookState: Equatable { diff --git a/Sources/InterposeKit/NSObject+Interpose.swift b/Sources/InterposeKit/NSObject+Interpose.swift index e5048fd..c6cc07c 100644 --- a/Sources/InterposeKit/NSObject+Interpose.swift +++ b/Sources/InterposeKit/NSObject+Interpose.swift @@ -18,8 +18,7 @@ extension NSObject { /// - 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()`. + /// - Returns: 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. From d635810d6979b65680c358a4b54cceb761e7e5e4 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Tue, 25 Mar 2025 23:01:57 +0100 Subject: [PATCH 13/70] Removed guts from ClassHook --- Sources/InterposeKit/Hooks/AnyHook.swift | 24 +++++++++++++++++++++- Sources/InterposeKit/Hooks/ClassHook.swift | 13 ------------ 2 files changed, 23 insertions(+), 14 deletions(-) diff --git a/Sources/InterposeKit/Hooks/AnyHook.swift b/Sources/InterposeKit/Hooks/AnyHook.swift index 5c402b9..c595133 100644 --- a/Sources/InterposeKit/Hooks/AnyHook.swift +++ b/Sources/InterposeKit/Hooks/AnyHook.swift @@ -79,8 +79,30 @@ public class AnyHook: Hook { /// Hook baseclass with generic signatures. public class TypedHook: AnyHook { + + override func replaceImplementation() throws { + if let strategy = self.strategy as? ClassHookStrategy { + return try strategy.replaceImplementation() + } else { + preconditionFailure("Not implemented") + } + } + + override func resetImplementation() throws { + if let strategy = self.strategy as? ClassHookStrategy { + return try strategy.resetImplementation() + } else { + preconditionFailure("Not implemented") + } + } + /// The original implementation of the hook. Might be looked up at runtime. Do not cache this. public var original: MethodSignature { - preconditionFailure("Always override") + if let strategy = self.strategy as? ClassHookStrategy { + return strategy.original + } else { + preconditionFailure("Not implemented") + } } + } diff --git a/Sources/InterposeKit/Hooks/ClassHook.swift b/Sources/InterposeKit/Hooks/ClassHook.swift index 0b0b6de..4daadcb 100644 --- a/Sources/InterposeKit/Hooks/ClassHook.swift +++ b/Sources/InterposeKit/Hooks/ClassHook.swift @@ -26,19 +26,6 @@ extension Interpose { strategyProvider: strategyProvider ) } - - override func replaceImplementation() throws { - try (self.strategy as! ClassHookStrategy).replaceImplementation() - } - - override func resetImplementation() throws { - try (self.strategy as! ClassHookStrategy).resetImplementation() - } - - /// The original implementation is cached at hook time. - public override var original: MethodSignature { - (self.strategy as! ClassHookStrategy).original - } } } From a68d49de0499bacc9e4598533472cfc868f68bc3 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Tue, 25 Mar 2025 23:05:25 +0100 Subject: [PATCH 14/70] Fixed typos in tests --- Tests/InterposeKitTests/HookTests.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Tests/InterposeKitTests/HookTests.swift b/Tests/InterposeKitTests/HookTests.swift index a8748a4..8b7b160 100644 --- a/Tests/InterposeKitTests/HookTests.swift +++ b/Tests/InterposeKitTests/HookTests.swift @@ -15,7 +15,7 @@ final class HookTests: InterposeKitTestCase { let hook = try interposer.prepareHook( #selector(ExampleClass.foo), methodSignature: (@convention(c) (NSObject, Selector) -> Void).self, - hookSignature: (@convention(block) (NSObject) -> Void).self, + hookSignature: (@convention(block) (NSObject) -> Void).self ) { hook in return { `self` in } } @@ -36,7 +36,7 @@ final class HookTests: InterposeKitTestCase { let hook = try interposer.prepareHook( #selector(ExampleClass.foo), methodSignature: (@convention(c) (NSObject, Selector) -> Void).self, - hookSignature: (@convention(block) (NSObject) -> Void).self, + hookSignature: (@convention(block) (NSObject) -> Void).self ) { hook in return { `self` in } } From 86414463b02a5726f99efe16198fb84484f878e8 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Tue, 25 Mar 2025 23:08:28 +0100 Subject: [PATCH 15/70] Raised minimum supported OS versions --- Package.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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( From 953daaf85d2b67df619ceb24c7d65073a191826a Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 26 Mar 2025 07:34:59 +0100 Subject: [PATCH 16/70] Introduced HookImplementationBuilder --- Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift | 4 ++-- Sources/InterposeKit/Hooks/ClassHook.swift | 2 +- Sources/InterposeKit/Hooks/Hook.swift | 2 ++ Sources/InterposeKit/Hooks/ObjectHook.swift | 2 +- Sources/InterposeKit/Interpose.swift | 6 +++--- Sources/InterposeKit/NSObject+Interpose.swift | 5 ++++- 6 files changed, 13 insertions(+), 8 deletions(-) diff --git a/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift b/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift index 3366521..78854df 100644 --- a/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift +++ b/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift @@ -8,7 +8,7 @@ extension NSObject { _ selector: Selector, methodSignature: MethodSignature.Type = MethodSignature.self, hookSignature: HookSignature.Type = HookSignature.self, - _ implementation: (TypedHook) -> HookSignature + _ implementation: HookImplementationBuilder ) throws -> some Hook { precondition( !(self is AnyClass), @@ -33,7 +33,7 @@ extension NSObject { _ selector: Selector, methodSignature: MethodSignature.Type = MethodSignature.self, hookSignature: HookSignature.Type = HookSignature.self, - _ implementation: (TypedHook) -> HookSignature + _ implementation: HookImplementationBuilder ) throws -> some Hook { let hook = try Interpose.ClassHook( class: self as AnyClass, diff --git a/Sources/InterposeKit/Hooks/ClassHook.swift b/Sources/InterposeKit/Hooks/ClassHook.swift index 4daadcb..92889d4 100644 --- a/Sources/InterposeKit/Hooks/ClassHook.swift +++ b/Sources/InterposeKit/Hooks/ClassHook.swift @@ -7,7 +7,7 @@ extension Interpose { public init( `class`: AnyClass, selector: Selector, - implementation: (ClassHook) -> HookSignature + implementation: HookImplementationBuilder ) throws { let strategyProvider: (AnyHook) -> _HookStrategy = { hook in let hook = hook as! Self diff --git a/Sources/InterposeKit/Hooks/Hook.swift b/Sources/InterposeKit/Hooks/Hook.swift index 1209020..0b15628 100644 --- a/Sources/InterposeKit/Hooks/Hook.swift +++ b/Sources/InterposeKit/Hooks/Hook.swift @@ -1,5 +1,7 @@ import ObjectiveC +public typealias HookImplementationBuilder = (TypedHook) -> HookSignature + /// A runtime hook that interposes a single instance method on a class or object. public protocol Hook: AnyObject { diff --git a/Sources/InterposeKit/Hooks/ObjectHook.swift b/Sources/InterposeKit/Hooks/ObjectHook.swift index e525fe2..72bda7e 100644 --- a/Sources/InterposeKit/Hooks/ObjectHook.swift +++ b/Sources/InterposeKit/Hooks/ObjectHook.swift @@ -19,7 +19,7 @@ extension Interpose { public init( object: AnyObject, selector: Selector, - implementation: (ObjectHook) -> HookSignature + implementation: HookImplementationBuilder ) throws { self.object = object diff --git a/Sources/InterposeKit/Interpose.swift b/Sources/InterposeKit/Interpose.swift index bccbd53..46faf9f 100644 --- a/Sources/InterposeKit/Interpose.swift +++ b/Sources/InterposeKit/Interpose.swift @@ -69,7 +69,7 @@ final public class Interpose { _ selName: String, methodSignature: MethodSignature.Type = MethodSignature.self, hookSignature: HookSignature.Type = HookSignature.self, - _ implementation: (TypedHook) -> HookSignature + _ implementation: HookImplementationBuilder ) throws -> some Hook { try hook(NSSelectorFromString(selName), methodSignature: methodSignature, hookSignature: hookSignature, implementation) @@ -80,7 +80,7 @@ final public class Interpose { _ selector: Selector, methodSignature: MethodSignature.Type = MethodSignature.self, hookSignature: HookSignature.Type = HookSignature.self, - _ implementation: (TypedHook) -> HookSignature + _ implementation: HookImplementationBuilder ) throws -> some Hook { let hook = try prepareHook(selector, methodSignature: methodSignature, hookSignature: hookSignature, implementation) @@ -93,7 +93,7 @@ final public class Interpose { _ selector: Selector, methodSignature: MethodSignature.Type = MethodSignature.self, hookSignature: HookSignature.Type = HookSignature.self, - _ implementation: (TypedHook) -> HookSignature + _ implementation: HookImplementationBuilder ) throws -> some Hook { var hook: TypedHook if let object = self.object { diff --git a/Sources/InterposeKit/NSObject+Interpose.swift b/Sources/InterposeKit/NSObject+Interpose.swift index c6cc07c..139e3a4 100644 --- a/Sources/InterposeKit/NSObject+Interpose.swift +++ b/Sources/InterposeKit/NSObject+Interpose.swift @@ -1,5 +1,8 @@ import ObjectiveC +// TODO: Rename `addHook(…)` to `applyHook(…)` +// TODO: Revise documentation (implementation builder) + extension NSObject { /// Installs a hook for the specified Objective-C selector on this object instance. @@ -43,7 +46,7 @@ extension NSObject { for selector: Selector, methodSignature: MethodSignature.Type, hookSignature: HookSignature.Type, - implementation: (TypedHook) -> HookSignature + implementation: HookImplementationBuilder ) throws -> some Hook { let hook = try Interpose.ObjectHook( object: self, From 23eb321225ba4e7f3351547ff8c1750812147fd8 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 26 Mar 2025 07:47:45 +0100 Subject: [PATCH 17/70] Breaking: method and hook signatures have to be explicitly specified --- .../Deprecated/Interpose+Deprecated.swift | 21 ++++++++++++++++ .../Deprecated/NSObject+Deprecated.swift | 8 +++--- Sources/InterposeKit/Interpose.swift | 25 ++++++------------- .../ObjectInterposeTests.swift | 11 +++++--- 4 files changed, 40 insertions(+), 25 deletions(-) create mode 100644 Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift diff --git a/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift b/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift new file mode 100644 index 0000000..79d72e7 --- /dev/null +++ b/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift @@ -0,0 +1,21 @@ +import Foundation + +extension Interpose { + + @available(*, deprecated, message: "Use 'hook(_:methodSignature:hookSignature:_:)' instead and pass materialized selector.") + @discardableResult + public func hook( + _ selectorName: String, + methodSignature: MethodSignature.Type, + hookSignature: HookSignature.Type, + _ implementation: HookImplementationBuilder + ) throws -> some Hook { + try self.hook( + NSSelectorFromString(selectorName), + methodSignature: methodSignature, + hookSignature: hookSignature, + implementation + ) + } + +} diff --git a/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift b/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift index 78854df..5c3119a 100644 --- a/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift +++ b/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift @@ -6,8 +6,8 @@ extension NSObject { @discardableResult public func hook ( _ selector: Selector, - methodSignature: MethodSignature.Type = MethodSignature.self, - hookSignature: HookSignature.Type = HookSignature.self, + methodSignature: MethodSignature.Type, + hookSignature: HookSignature.Type, _ implementation: HookImplementationBuilder ) throws -> some Hook { precondition( @@ -31,8 +31,8 @@ extension NSObject { @discardableResult public class func hook ( _ selector: Selector, - methodSignature: MethodSignature.Type = MethodSignature.self, - hookSignature: HookSignature.Type = HookSignature.self, + methodSignature: MethodSignature.Type, + hookSignature: HookSignature.Type, _ implementation: HookImplementationBuilder ) throws -> some Hook { let hook = try Interpose.ClassHook( diff --git a/Sources/InterposeKit/Interpose.swift b/Sources/InterposeKit/Interpose.swift index 46faf9f..01bb414 100644 --- a/Sources/InterposeKit/Interpose.swift +++ b/Sources/InterposeKit/Interpose.swift @@ -64,22 +64,12 @@ final public class Interpose { 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: HookImplementationBuilder - ) throws -> some Hook { - 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 ( + @discardableResult + public func hook ( _ selector: Selector, - methodSignature: MethodSignature.Type = MethodSignature.self, - hookSignature: HookSignature.Type = HookSignature.self, + methodSignature: MethodSignature.Type, + hookSignature: HookSignature.Type, _ implementation: HookImplementationBuilder ) throws -> some Hook { let hook = try prepareHook(selector, methodSignature: methodSignature, @@ -89,10 +79,11 @@ final public class Interpose { } /// Prepares a hook, but does not call apply immediately. - @discardableResult public func prepareHook ( + @discardableResult + public func prepareHook ( _ selector: Selector, - methodSignature: MethodSignature.Type = MethodSignature.self, - hookSignature: HookSignature.Type = HookSignature.self, + methodSignature: MethodSignature.Type, + hookSignature: HookSignature.Type, _ implementation: HookImplementationBuilder ) throws -> some Hook { var hook: TypedHook diff --git a/Tests/InterposeKitTests/ObjectInterposeTests.swift b/Tests/InterposeKitTests/ObjectInterposeTests.swift index f9d8afc..489230e 100644 --- a/Tests/InterposeKitTests/ObjectInterposeTests.swift +++ b/Tests/InterposeKitTests/ObjectInterposeTests.swift @@ -85,10 +85,13 @@ final class ObjectInterposeTests: InterposeKitTestCase { // 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 + try $0.prepareHook( + #selector(TestClass.returnInt), + methodSignature: (@convention(c) (AnyObject, Selector) -> Int).self, + hookSignature: (@convention(block) (AnyObject) -> Int).self + ) { hook in + return { + hook.original($0, hook.selector) * returnIntClassMultiplier } } } From 8fa936ef227d4e18018a226bd95f541475a3fc2e Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 26 Mar 2025 07:49:36 +0100 Subject: [PATCH 18/70] Removed HookSignature generic parameter from TypedHook --- Sources/InterposeKit/Hooks/AnyHook.swift | 2 +- Sources/InterposeKit/Hooks/ClassHook.swift | 4 ++-- Sources/InterposeKit/Hooks/Hook.swift | 2 +- Sources/InterposeKit/Hooks/ObjectHook.swift | 4 ++-- Sources/InterposeKit/Interpose.swift | 2 +- 5 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Sources/InterposeKit/Hooks/AnyHook.swift b/Sources/InterposeKit/Hooks/AnyHook.swift index c595133..31d03d3 100644 --- a/Sources/InterposeKit/Hooks/AnyHook.swift +++ b/Sources/InterposeKit/Hooks/AnyHook.swift @@ -78,7 +78,7 @@ public class AnyHook: Hook { } /// Hook baseclass with generic signatures. -public class TypedHook: AnyHook { +public class TypedHook: AnyHook { override func replaceImplementation() throws { if let strategy = self.strategy as? ClassHookStrategy { diff --git a/Sources/InterposeKit/Hooks/ClassHook.swift b/Sources/InterposeKit/Hooks/ClassHook.swift index 92889d4..e1e0218 100644 --- a/Sources/InterposeKit/Hooks/ClassHook.swift +++ b/Sources/InterposeKit/Hooks/ClassHook.swift @@ -2,9 +2,9 @@ import Foundation extension Interpose { /// A hook to an instance method and stores both the original and new implementation. - final public class ClassHook: TypedHook { + final public class ClassHook: TypedHook { - public init( + public init( `class`: AnyClass, selector: Selector, implementation: HookImplementationBuilder diff --git a/Sources/InterposeKit/Hooks/Hook.swift b/Sources/InterposeKit/Hooks/Hook.swift index 0b15628..77ab8d7 100644 --- a/Sources/InterposeKit/Hooks/Hook.swift +++ b/Sources/InterposeKit/Hooks/Hook.swift @@ -1,6 +1,6 @@ import ObjectiveC -public typealias HookImplementationBuilder = (TypedHook) -> HookSignature +public typealias HookImplementationBuilder = (TypedHook) -> HookSignature /// A runtime hook that interposes a single instance method on a class or object. public protocol Hook: AnyObject { diff --git a/Sources/InterposeKit/Hooks/ObjectHook.swift b/Sources/InterposeKit/Hooks/ObjectHook.swift index 72bda7e..eb671fd 100644 --- a/Sources/InterposeKit/Hooks/ObjectHook.swift +++ b/Sources/InterposeKit/Hooks/ObjectHook.swift @@ -4,7 +4,7 @@ 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 { + final public class ObjectHook: TypedHook { /// The object that is being hooked. public let object: AnyObject @@ -16,7 +16,7 @@ extension Interpose { let generatesSuperIMP = InterposeSubclass.supportsSuperTrampolines /// Initialize a new hook to interpose an instance method. - public init( + public init( object: AnyObject, selector: Selector, implementation: HookImplementationBuilder diff --git a/Sources/InterposeKit/Interpose.swift b/Sources/InterposeKit/Interpose.swift index 01bb414..0e25afd 100644 --- a/Sources/InterposeKit/Interpose.swift +++ b/Sources/InterposeKit/Interpose.swift @@ -86,7 +86,7 @@ final public class Interpose { hookSignature: HookSignature.Type, _ implementation: HookImplementationBuilder ) throws -> some Hook { - var hook: TypedHook + var hook: TypedHook if let object = self.object { hook = try ObjectHook(object: object, selector: selector, implementation: implementation) } else { From da0ea371e399199a10d9f0d0238c95c65e6bf53a Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 26 Mar 2025 07:53:28 +0100 Subject: [PATCH 19/70] Introduced HookProxy --- Sources/InterposeKit/Hooks/ClassHook.swift | 8 +++++++- Sources/InterposeKit/Hooks/Hook.swift | 20 +++++++++++++++++++- Sources/InterposeKit/Hooks/ObjectHook.swift | 8 +++++++- 3 files changed, 33 insertions(+), 3 deletions(-) diff --git a/Sources/InterposeKit/Hooks/ClassHook.swift b/Sources/InterposeKit/Hooks/ClassHook.swift index e1e0218..c5c71de 100644 --- a/Sources/InterposeKit/Hooks/ClassHook.swift +++ b/Sources/InterposeKit/Hooks/ClassHook.swift @@ -11,7 +11,13 @@ extension Interpose { ) throws { let strategyProvider: (AnyHook) -> _HookStrategy = { hook in let hook = hook as! Self - let replacementIMP = imp_implementationWithBlock(implementation(hook)) + + let hookProxy = HookProxy( + selector: selector, + originalProvider: { hook.original } + ) + + let replacementIMP = imp_implementationWithBlock(implementation(hookProxy)) return ClassHookStrategy( class: `class`, diff --git a/Sources/InterposeKit/Hooks/Hook.swift b/Sources/InterposeKit/Hooks/Hook.swift index 77ab8d7..dee7f04 100644 --- a/Sources/InterposeKit/Hooks/Hook.swift +++ b/Sources/InterposeKit/Hooks/Hook.swift @@ -1,6 +1,24 @@ import ObjectiveC -public typealias HookImplementationBuilder = (TypedHook) -> HookSignature +public typealias HookImplementationBuilder = (HookProxy) -> HookSignature + +public final class HookProxy { + + internal init( + selector: Selector, + originalProvider: @escaping () -> MethodSignature + ) { + self.selector = selector + self.originalProvider = originalProvider + } + + public let selector: Selector + + private let originalProvider: () -> MethodSignature + + public var original: MethodSignature { self.originalProvider() } + +} /// A runtime hook that interposes a single instance method on a class or object. public protocol Hook: AnyObject { diff --git a/Sources/InterposeKit/Hooks/ObjectHook.swift b/Sources/InterposeKit/Hooks/ObjectHook.swift index eb671fd..945262c 100644 --- a/Sources/InterposeKit/Hooks/ObjectHook.swift +++ b/Sources/InterposeKit/Hooks/ObjectHook.swift @@ -25,7 +25,13 @@ extension Interpose { let strategyProvider: (AnyHook) -> any _HookStrategy = { hook in let hook = hook as! Self - let block = implementation(hook) as AnyObject + + let hookProxy = HookProxy( + selector: selector, + originalProvider: { hook.original } + ) + + let block = implementation(hookProxy) as AnyObject let replacementIMP = imp_implementationWithBlock(block) // Weakly store reference to hook inside the block of the IMP. From 1700ad9d327521e87e053fd3f4614638bb7472a5 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 26 Mar 2025 08:07:14 +0100 Subject: [PATCH 20/70] Removed MethodSignature generic parameter from TypedHook --- Sources/InterposeKit/Hooks/AnyHook.swift | 19 ++++++++++--------- Sources/InterposeKit/Hooks/ClassHook.swift | 18 ++++++++---------- Sources/InterposeKit/Hooks/HookStrategy.swift | 8 +------- Sources/InterposeKit/Hooks/ObjectHook.swift | 6 +++--- Sources/InterposeKit/Interpose.swift | 2 +- 5 files changed, 23 insertions(+), 30 deletions(-) diff --git a/Sources/InterposeKit/Hooks/AnyHook.swift b/Sources/InterposeKit/Hooks/AnyHook.swift index 31d03d3..0213b26 100644 --- a/Sources/InterposeKit/Hooks/AnyHook.swift +++ b/Sources/InterposeKit/Hooks/AnyHook.swift @@ -11,10 +11,10 @@ public class AnyHook: Hook { /// The current state of the hook. public internal(set) var state = HookState.pending - private var _strategy: _HookStrategy! - var strategy: _HookStrategy { _strategy } + private var _strategy: HookStrategy! + var strategy: HookStrategy { _strategy } - init(`class`: AnyClass, selector: Selector, strategyProvider: (AnyHook) -> _HookStrategy) throws { + init(`class`: AnyClass, selector: Selector, strategyProvider: (AnyHook) -> HookStrategy) throws { self.selector = selector self.class = `class` @@ -78,10 +78,10 @@ public class AnyHook: Hook { } /// Hook baseclass with generic signatures. -public class TypedHook: AnyHook { +public class TypedHook: AnyHook { override func replaceImplementation() throws { - if let strategy = self.strategy as? ClassHookStrategy { + if let strategy = self.strategy as? ClassHookStrategy { return try strategy.replaceImplementation() } else { preconditionFailure("Not implemented") @@ -89,7 +89,7 @@ public class TypedHook: AnyHook { } override func resetImplementation() throws { - if let strategy = self.strategy as? ClassHookStrategy { + if let strategy = self.strategy as? ClassHookStrategy { return try strategy.resetImplementation() } else { preconditionFailure("Not implemented") @@ -97,9 +97,10 @@ public class TypedHook: AnyHook { } /// The original implementation of the hook. Might be looked up at runtime. Do not cache this. - public var original: MethodSignature { - if let strategy = self.strategy as? ClassHookStrategy { - return strategy.original + + public var originalIMP: IMP? { + if let strategy = self.strategy as? ClassHookStrategy { + return strategy.originalIMP } else { preconditionFailure("Not implemented") } diff --git a/Sources/InterposeKit/Hooks/ClassHook.swift b/Sources/InterposeKit/Hooks/ClassHook.swift index c5c71de..a238779 100644 --- a/Sources/InterposeKit/Hooks/ClassHook.swift +++ b/Sources/InterposeKit/Hooks/ClassHook.swift @@ -2,24 +2,26 @@ import Foundation extension Interpose { /// A hook to an instance method and stores both the original and new implementation. - final public class ClassHook: TypedHook { + final public class ClassHook: TypedHook { - public init( + public init( `class`: AnyClass, selector: Selector, implementation: HookImplementationBuilder ) throws { - let strategyProvider: (AnyHook) -> _HookStrategy = { hook in + let strategyProvider: (AnyHook) -> HookStrategy = { hook in let hook = hook as! Self let hookProxy = HookProxy( selector: selector, - originalProvider: { hook.original } + originalProvider: { + unsafeBitCast(hook.strategy.originalIMP, to: MethodSignature.self) + } ) let replacementIMP = imp_implementationWithBlock(implementation(hookProxy)) - return ClassHookStrategy( + return ClassHookStrategy( class: `class`, selector: selector, replacementIMP: replacementIMP @@ -36,7 +38,7 @@ extension Interpose { } } -final class ClassHookStrategy: HookStrategy { +final class ClassHookStrategy: HookStrategy { init( `class`: AnyClass, @@ -96,10 +98,6 @@ final class ClassHookStrategy: HookStrategy { Interpose.log("Restored -[\(self.class).\(self.selector)] IMP: \(originalIMP)") } - var original: MethodSignature { - unsafeBitCast(self.originalIMP, to: MethodSignature.self) - } - } #if DEBUG diff --git a/Sources/InterposeKit/Hooks/HookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy.swift index 080e2a0..f7fbf2a 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy.swift @@ -1,6 +1,6 @@ import ObjectiveC -protocol _HookStrategy: AnyObject { +protocol HookStrategy: AnyObject { /// The replacement implementation used to interpose the method, created during hook setup. var replacementIMP: IMP { get } @@ -10,12 +10,6 @@ protocol _HookStrategy: AnyObject { } -protocol HookStrategy: _HookStrategy { - - associatedtype MethodSignature - -} - final class DummyHookStrategy: HookStrategy { init(replacementIMP: IMP) { diff --git a/Sources/InterposeKit/Hooks/ObjectHook.swift b/Sources/InterposeKit/Hooks/ObjectHook.swift index 945262c..e42e411 100644 --- a/Sources/InterposeKit/Hooks/ObjectHook.swift +++ b/Sources/InterposeKit/Hooks/ObjectHook.swift @@ -4,7 +4,7 @@ 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 { + final public class ObjectHook: TypedHook { /// The object that is being hooked. public let object: AnyObject @@ -23,7 +23,7 @@ extension Interpose { ) throws { self.object = object - let strategyProvider: (AnyHook) -> any _HookStrategy = { hook in + let strategyProvider: (AnyHook) -> any HookStrategy = { hook in let hook = hook as! Self let hookProxy = HookProxy( @@ -54,7 +54,7 @@ extension Interpose { // } /// The original implementation of the hook. Might be looked up at runtime. Do not cache this. - public override var original: MethodSignature { + public var original: MethodSignature { // If we switched implementations, return stored. if let savedOrigIMP = self.strategy.originalIMP { return unsafeBitCast(savedOrigIMP, to: MethodSignature.self) diff --git a/Sources/InterposeKit/Interpose.swift b/Sources/InterposeKit/Interpose.swift index 0e25afd..3a28199 100644 --- a/Sources/InterposeKit/Interpose.swift +++ b/Sources/InterposeKit/Interpose.swift @@ -86,7 +86,7 @@ final public class Interpose { hookSignature: HookSignature.Type, _ implementation: HookImplementationBuilder ) throws -> some Hook { - var hook: TypedHook + var hook: TypedHook if let object = self.object { hook = try ObjectHook(object: object, selector: selector, implementation: implementation) } else { From f3ae5aa3f2c9728061fce35658b4b3fe1a1c0299 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 26 Mar 2025 08:08:43 +0100 Subject: [PATCH 21/70] Merged TypedHook into AnyHook --- Sources/InterposeKit/Hooks/AnyHook.swift | 54 ++++++++------------- Sources/InterposeKit/Hooks/ClassHook.swift | 2 +- Sources/InterposeKit/Hooks/ObjectHook.swift | 2 +- Sources/InterposeKit/Interpose.swift | 2 +- 4 files changed, 23 insertions(+), 37 deletions(-) diff --git a/Sources/InterposeKit/Hooks/AnyHook.swift b/Sources/InterposeKit/Hooks/AnyHook.swift index 0213b26..8df1c7c 100644 --- a/Sources/InterposeKit/Hooks/AnyHook.swift +++ b/Sources/InterposeKit/Hooks/AnyHook.swift @@ -25,11 +25,28 @@ public class AnyHook: Hook { } func replaceImplementation() throws { - preconditionFailure("Not implemented") + if let strategy = self.strategy as? ClassHookStrategy { + return try strategy.replaceImplementation() + } else { + preconditionFailure("Not implemented") + } } - + func resetImplementation() throws { - preconditionFailure("Not implemented") + if let strategy = self.strategy as? ClassHookStrategy { + return try strategy.resetImplementation() + } else { + preconditionFailure("Not implemented") + } + } + + /// The original implementation of the hook. Might be looked up at runtime. Do not cache this. + var originalIMP: IMP? { + if let strategy = self.strategy as? ClassHookStrategy { + return strategy.originalIMP + } else { + preconditionFailure("Not implemented") + } } /// Apply the interpose hook. @@ -76,34 +93,3 @@ public class AnyHook: Hook { } } } - -/// Hook baseclass with generic signatures. -public class TypedHook: AnyHook { - - override func replaceImplementation() throws { - if let strategy = self.strategy as? ClassHookStrategy { - return try strategy.replaceImplementation() - } else { - preconditionFailure("Not implemented") - } - } - - override func resetImplementation() throws { - if let strategy = self.strategy as? ClassHookStrategy { - return try strategy.resetImplementation() - } else { - preconditionFailure("Not implemented") - } - } - - /// The original implementation of the hook. Might be looked up at runtime. Do not cache this. - - public var originalIMP: IMP? { - if let strategy = self.strategy as? ClassHookStrategy { - return strategy.originalIMP - } else { - preconditionFailure("Not implemented") - } - } - -} diff --git a/Sources/InterposeKit/Hooks/ClassHook.swift b/Sources/InterposeKit/Hooks/ClassHook.swift index a238779..5637ced 100644 --- a/Sources/InterposeKit/Hooks/ClassHook.swift +++ b/Sources/InterposeKit/Hooks/ClassHook.swift @@ -2,7 +2,7 @@ import Foundation extension Interpose { /// A hook to an instance method and stores both the original and new implementation. - final public class ClassHook: TypedHook { + final public class ClassHook: AnyHook { public init( `class`: AnyClass, diff --git a/Sources/InterposeKit/Hooks/ObjectHook.swift b/Sources/InterposeKit/Hooks/ObjectHook.swift index e42e411..18436c5 100644 --- a/Sources/InterposeKit/Hooks/ObjectHook.swift +++ b/Sources/InterposeKit/Hooks/ObjectHook.swift @@ -4,7 +4,7 @@ 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 { + final public class ObjectHook: AnyHook { /// The object that is being hooked. public let object: AnyObject diff --git a/Sources/InterposeKit/Interpose.swift b/Sources/InterposeKit/Interpose.swift index 3a28199..186dcbc 100644 --- a/Sources/InterposeKit/Interpose.swift +++ b/Sources/InterposeKit/Interpose.swift @@ -86,7 +86,7 @@ final public class Interpose { hookSignature: HookSignature.Type, _ implementation: HookImplementationBuilder ) throws -> some Hook { - var hook: TypedHook + var hook: AnyHook if let object = self.object { hook = try ObjectHook(object: object, selector: selector, implementation: implementation) } else { From 38a2df10a9a70ff7a2dc7b982ecf02c9b79be0f8 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 26 Mar 2025 08:12:39 +0100 Subject: [PATCH 22/70] HookStrategy now conforms to CustomDebugStringConvertible --- Sources/InterposeKit/Hooks/ClassHook.swift | 8 +++----- Sources/InterposeKit/Hooks/HookStrategy.swift | 6 +++++- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Sources/InterposeKit/Hooks/ClassHook.swift b/Sources/InterposeKit/Hooks/ClassHook.swift index 5637ced..c0b5c75 100644 --- a/Sources/InterposeKit/Hooks/ClassHook.swift +++ b/Sources/InterposeKit/Hooks/ClassHook.swift @@ -100,10 +100,8 @@ final class ClassHookStrategy: HookStrategy { } -#if DEBUG -extension Interpose.ClassHook: CustomDebugStringConvertible { - public var debugDescription: String { - return "\(selector) -> \(String(describing: self.strategy.originalIMP))" +extension ClassHookStrategy: CustomDebugStringConvertible { + var debugDescription: String { + "\(self.selector) → \(String(describing: self.originalIMP))" } } -#endif diff --git a/Sources/InterposeKit/Hooks/HookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy.swift index f7fbf2a..12ced68 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy.swift @@ -1,6 +1,6 @@ import ObjectiveC -protocol HookStrategy: AnyObject { +protocol HookStrategy: AnyObject, CustomDebugStringConvertible { /// The replacement implementation used to interpose the method, created during hook setup. var replacementIMP: IMP { get } @@ -20,3 +20,7 @@ final class DummyHookStrategy: HookStrategy { var originalIMP: IMP? } + +extension DummyHookStrategy: CustomDebugStringConvertible { + var debugDescription: String { "" } +} From 59cdbef80fafadc5ab6161642bb0a7874a3a1178 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 26 Mar 2025 08:18:25 +0100 Subject: [PATCH 23/70] Merged AnyHook and Hook --- .../Deprecated/Hook+Deprecated.swift | 2 +- .../Deprecated/Interpose+Deprecated.swift | 2 +- .../Deprecated/NSObject+Deprecated.swift | 4 +- Sources/InterposeKit/Hooks/AnyHook.swift | 95 ------------- Sources/InterposeKit/Hooks/ClassHook.swift | 4 +- Sources/InterposeKit/Hooks/Hook+.swift | 34 +++++ Sources/InterposeKit/Hooks/Hook.swift | 128 ++++++++++++------ Sources/InterposeKit/Hooks/ObjectHook.swift | 4 +- .../InterposeKit/Internal/HookFinder.swift | 6 +- Sources/InterposeKit/Interpose.swift | 10 +- Sources/InterposeKit/NSObject+Interpose.swift | 2 +- 11 files changed, 136 insertions(+), 155 deletions(-) delete mode 100644 Sources/InterposeKit/Hooks/AnyHook.swift create mode 100644 Sources/InterposeKit/Hooks/Hook+.swift diff --git a/Sources/InterposeKit/Deprecated/Hook+Deprecated.swift b/Sources/InterposeKit/Deprecated/Hook+Deprecated.swift index c49cb1d..22368df 100644 --- a/Sources/InterposeKit/Deprecated/Hook+Deprecated.swift +++ b/Sources/InterposeKit/Deprecated/Hook+Deprecated.swift @@ -1,4 +1,4 @@ -extension AnyHook { +extension Hook { @available(*, deprecated, renamed: "HookState", message: "Use top-level 'HookState'.") public typealias State = HookState diff --git a/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift b/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift index 79d72e7..8a8e1d0 100644 --- a/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift +++ b/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift @@ -9,7 +9,7 @@ extension Interpose { methodSignature: MethodSignature.Type, hookSignature: HookSignature.Type, _ implementation: HookImplementationBuilder - ) throws -> some Hook { + ) throws -> Hook { try self.hook( NSSelectorFromString(selectorName), methodSignature: methodSignature, diff --git a/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift b/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift index 5c3119a..b94655e 100644 --- a/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift +++ b/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift @@ -9,7 +9,7 @@ extension NSObject { methodSignature: MethodSignature.Type, hookSignature: HookSignature.Type, _ implementation: HookImplementationBuilder - ) throws -> some Hook { + ) throws -> Hook { precondition( !(self is AnyClass), "There should not be a way to cast an NSObject to AnyClass." @@ -34,7 +34,7 @@ extension NSObject { methodSignature: MethodSignature.Type, hookSignature: HookSignature.Type, _ implementation: HookImplementationBuilder - ) throws -> some Hook { + ) throws -> Hook { let hook = try Interpose.ClassHook( class: self as AnyClass, selector: selector, diff --git a/Sources/InterposeKit/Hooks/AnyHook.swift b/Sources/InterposeKit/Hooks/AnyHook.swift deleted file mode 100644 index 8df1c7c..0000000 --- a/Sources/InterposeKit/Hooks/AnyHook.swift +++ /dev/null @@ -1,95 +0,0 @@ -import Foundation - -/// Base class, represents a hook to exactly one method. -public class AnyHook: Hook { - /// 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 = HookState.pending - - private var _strategy: HookStrategy! - var strategy: HookStrategy { _strategy } - - init(`class`: AnyClass, selector: Selector, strategyProvider: (AnyHook) -> HookStrategy) throws { - self.selector = selector - self.class = `class` - - // Check if method exists - try validate() - - self._strategy = strategyProvider(self) - } - - func replaceImplementation() throws { - if let strategy = self.strategy as? ClassHookStrategy { - return try strategy.replaceImplementation() - } else { - preconditionFailure("Not implemented") - } - } - - func resetImplementation() throws { - if let strategy = self.strategy as? ClassHookStrategy { - return try strategy.resetImplementation() - } else { - preconditionFailure("Not implemented") - } - } - - /// The original implementation of the hook. Might be looked up at runtime. Do not cache this. - var originalIMP: IMP? { - if let strategy = self.strategy as? ClassHookStrategy { - return strategy.originalIMP - } else { - preconditionFailure("Not implemented") - } - } - - /// Apply the interpose hook. - public func apply() throws { - try execute(newState: .active) { try replaceImplementation() } - } - - /// Revert the interpose hook. - public func revert() throws { - try execute(newState: .pending) { try resetImplementation() } - } - - /// Validate that the selector exists on the active class. - @discardableResult func validate(expectedState: HookState = .pending) 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: HookState, task: () throws -> Void) throws { - do { - try task() - state = newState - } catch let error as InterposeError { - state = .failed - throw error - } - } - - /// Release the hook block if possible. - public func cleanup() { - switch state { - case .pending: - Interpose.log("Releasing -[\(`class`).\(selector)] IMP: \(self.strategy.replacementIMP)") - imp_removeBlock(strategy.replacementIMP) - case .active: - Interpose.log("Keeping -[\(`class`).\(selector)] IMP: \(self.strategy.replacementIMP)") - case .failed: - Interpose.log("Leaking -[\(`class`).\(selector)] IMP: \(self.strategy.replacementIMP)") - } - } -} diff --git a/Sources/InterposeKit/Hooks/ClassHook.swift b/Sources/InterposeKit/Hooks/ClassHook.swift index c0b5c75..c457cbb 100644 --- a/Sources/InterposeKit/Hooks/ClassHook.swift +++ b/Sources/InterposeKit/Hooks/ClassHook.swift @@ -2,14 +2,14 @@ import Foundation extension Interpose { /// A hook to an instance method and stores both the original and new implementation. - final public class ClassHook: AnyHook { + final public class ClassHook: Hook { public init( `class`: AnyClass, selector: Selector, implementation: HookImplementationBuilder ) throws { - let strategyProvider: (AnyHook) -> HookStrategy = { hook in + let strategyProvider: (Hook) -> HookStrategy = { hook in let hook = hook as! Self let hookProxy = HookProxy( diff --git a/Sources/InterposeKit/Hooks/Hook+.swift b/Sources/InterposeKit/Hooks/Hook+.swift new file mode 100644 index 0000000..e853067 --- /dev/null +++ b/Sources/InterposeKit/Hooks/Hook+.swift @@ -0,0 +1,34 @@ +import ObjectiveC + +public typealias HookImplementationBuilder = (HookProxy) -> HookSignature + +public final class HookProxy { + + internal init( + selector: Selector, + originalProvider: @escaping () -> MethodSignature + ) { + self.selector = selector + self.originalProvider = originalProvider + } + + public let selector: Selector + + private let originalProvider: () -> MethodSignature + + public var original: MethodSignature { self.originalProvider() } + +} + +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 + +} diff --git a/Sources/InterposeKit/Hooks/Hook.swift b/Sources/InterposeKit/Hooks/Hook.swift index dee7f04..79e3ceb 100644 --- a/Sources/InterposeKit/Hooks/Hook.swift +++ b/Sources/InterposeKit/Hooks/Hook.swift @@ -1,57 +1,99 @@ -import ObjectiveC +import Foundation -public typealias HookImplementationBuilder = (HookProxy) -> HookSignature - -public final class HookProxy { - - internal init( - selector: Selector, - originalProvider: @escaping () -> MethodSignature - ) { - self.selector = selector - self.originalProvider = originalProvider - } - - public let selector: Selector - - private let originalProvider: () -> MethodSignature - - public var original: MethodSignature { self.originalProvider() } - -} +// TODO: Make final /// A runtime hook that interposes a single instance method on a class or object. -public protocol Hook: AnyObject { +public class Hook { /// The class whose instance method is being interposed. - var `class`: AnyClass { get } - + public let `class`: AnyClass + /// The selector identifying the instance method being interposed. - var selector: Selector { get } - + public let selector: Selector + /// The current state of the hook. - var state: HookState { get } - - /// Applies the hook by interposing the method implementation. - func apply() throws - - /// Reverts the hook, restoring the original method implementation. - func revert() throws - - // TODO: Rename to `cleanUp()` - func cleanup() + public internal(set) var state = HookState.pending -} + private var _strategy: HookStrategy! + var strategy: HookStrategy { _strategy } + + init(`class`: AnyClass, selector: Selector, strategyProvider: (Hook) -> HookStrategy) throws { + self.selector = selector + self.class = `class` + + // Check if method exists + try validate() + + self._strategy = strategyProvider(self) + } -public enum HookState: Equatable { + func replaceImplementation() throws { + if let strategy = self.strategy as? ClassHookStrategy { + return try strategy.replaceImplementation() + } else { + preconditionFailure("Not implemented") + } + } - /// The hook is ready to be applied. - case pending + func resetImplementation() throws { + if let strategy = self.strategy as? ClassHookStrategy { + return try strategy.resetImplementation() + } else { + preconditionFailure("Not implemented") + } + } - /// The hook has been successfully applied. - case active + /// The original implementation of the hook. Might be looked up at runtime. Do not cache this. + var originalIMP: IMP? { + if let strategy = self.strategy as? ClassHookStrategy { + return strategy.originalIMP + } else { + preconditionFailure("Not implemented") + } + } + + /// Applies the hook by interposing the method implementation. + public func apply() throws { + try execute(newState: .active) { try replaceImplementation() } + } + + /// Reverts the hook, restoring the original method implementation. + public func revert() throws { + try execute(newState: .pending) { try resetImplementation() } + } - /// The hook failed to apply. - case failed + /// Validate that the selector exists on the active class. + @discardableResult func validate(expectedState: HookState = .pending) 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: HookState, task: () throws -> Void) throws { + do { + try task() + state = newState + } catch let error as InterposeError { + state = .failed + throw error + } + } + + // TODO: Rename to `cleanUp()` + public func cleanup() { + switch state { + case .pending: + Interpose.log("Releasing -[\(`class`).\(selector)] IMP: \(self.strategy.replacementIMP)") + imp_removeBlock(strategy.replacementIMP) + case .active: + Interpose.log("Keeping -[\(`class`).\(selector)] IMP: \(self.strategy.replacementIMP)") + case .failed: + Interpose.log("Leaking -[\(`class`).\(selector)] IMP: \(self.strategy.replacementIMP)") + } + } } diff --git a/Sources/InterposeKit/Hooks/ObjectHook.swift b/Sources/InterposeKit/Hooks/ObjectHook.swift index 18436c5..c36c237 100644 --- a/Sources/InterposeKit/Hooks/ObjectHook.swift +++ b/Sources/InterposeKit/Hooks/ObjectHook.swift @@ -4,7 +4,7 @@ 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: AnyHook { + final public class ObjectHook: Hook { /// The object that is being hooked. public let object: AnyObject @@ -23,7 +23,7 @@ extension Interpose { ) throws { self.object = object - let strategyProvider: (AnyHook) -> any HookStrategy = { hook in + let strategyProvider: (Hook) -> any HookStrategy = { hook in let hook = hook as! Self let hookProxy = HookProxy( diff --git a/Sources/InterposeKit/Internal/HookFinder.swift b/Sources/InterposeKit/Internal/HookFinder.swift index 6ce1544..60466bf 100644 --- a/Sources/InterposeKit/Internal/HookFinder.swift +++ b/Sources/InterposeKit/Internal/HookFinder.swift @@ -17,7 +17,7 @@ extension Interpose { } } - static func storeHook(hook: HookType, to block: AnyObject) { + 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) @@ -25,7 +25,7 @@ extension Interpose { } // Finds the hook to a given implementation. - static func hookForIMP(_ imp: IMP) -> HookType? { + 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 @@ -33,7 +33,7 @@ extension Interpose { } // Find the hook above us (not necessarily topmost) - static func findNextHook(selfHook: HookType, topmostIMP: IMP) -> HookType? { + 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? diff --git a/Sources/InterposeKit/Interpose.swift b/Sources/InterposeKit/Interpose.swift index 186dcbc..9f0d6d5 100644 --- a/Sources/InterposeKit/Interpose.swift +++ b/Sources/InterposeKit/Interpose.swift @@ -8,7 +8,7 @@ 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] = [] + public private(set) var hooks: [Hook] = [] /// If Interposing is object-based, this is set. public let object: AnyObject? @@ -71,7 +71,7 @@ final public class Interpose { methodSignature: MethodSignature.Type, hookSignature: HookSignature.Type, _ implementation: HookImplementationBuilder - ) throws -> some Hook { + ) throws -> Hook { let hook = try prepareHook(selector, methodSignature: methodSignature, hookSignature: hookSignature, implementation) try hook.apply() @@ -85,8 +85,8 @@ final public class Interpose { methodSignature: MethodSignature.Type, hookSignature: HookSignature.Type, _ implementation: HookImplementationBuilder - ) throws -> some Hook { - var hook: AnyHook + ) throws -> Hook { + var hook: Hook if let object = self.object { hook = try ObjectHook(object: object, selector: selector, implementation: implementation) } else { @@ -108,7 +108,7 @@ final public class Interpose { private func execute(_ task: ((Interpose) throws -> Void)? = nil, expectedState: HookState = .pending, - executor: ((AnyHook) throws -> Void)) throws -> Interpose { + executor: ((Hook) throws -> Void)) throws -> Interpose { // Run pre-apply code first if let task = task { try task(self) diff --git a/Sources/InterposeKit/NSObject+Interpose.swift b/Sources/InterposeKit/NSObject+Interpose.swift index 139e3a4..f33c70b 100644 --- a/Sources/InterposeKit/NSObject+Interpose.swift +++ b/Sources/InterposeKit/NSObject+Interpose.swift @@ -47,7 +47,7 @@ extension NSObject { methodSignature: MethodSignature.Type, hookSignature: HookSignature.Type, implementation: HookImplementationBuilder - ) throws -> some Hook { + ) throws -> Hook { let hook = try Interpose.ObjectHook( object: self, selector: selector, From 4127d26065a88b1fcb70e11fc8a76788e3267249 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 26 Mar 2025 08:35:23 +0100 Subject: [PATCH 24/70] Merged remainings of ClassHook into Hook --- .../Deprecated/NSObject+Deprecated.swift | 2 +- ...lassHook.swift => ClassHookStrategy.swift} | 38 ----------- Sources/InterposeKit/Hooks/Hook.swift | 66 ++++++++++++++++--- Sources/InterposeKit/Interpose.swift | 2 +- 4 files changed, 59 insertions(+), 49 deletions(-) rename Sources/InterposeKit/Hooks/{ClassHook.swift => ClassHookStrategy.swift} (62%) diff --git a/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift b/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift index b94655e..85b81e6 100644 --- a/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift +++ b/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift @@ -35,7 +35,7 @@ extension NSObject { hookSignature: HookSignature.Type, _ implementation: HookImplementationBuilder ) throws -> Hook { - let hook = try Interpose.ClassHook( + let hook = try Hook( class: self as AnyClass, selector: selector, implementation: implementation diff --git a/Sources/InterposeKit/Hooks/ClassHook.swift b/Sources/InterposeKit/Hooks/ClassHookStrategy.swift similarity index 62% rename from Sources/InterposeKit/Hooks/ClassHook.swift rename to Sources/InterposeKit/Hooks/ClassHookStrategy.swift index c457cbb..1deacb8 100644 --- a/Sources/InterposeKit/Hooks/ClassHook.swift +++ b/Sources/InterposeKit/Hooks/ClassHookStrategy.swift @@ -1,43 +1,5 @@ import Foundation -extension Interpose { - /// A hook to an instance method and stores both the original and new implementation. - final public class ClassHook: Hook { - - public init( - `class`: AnyClass, - selector: Selector, - implementation: HookImplementationBuilder - ) throws { - let strategyProvider: (Hook) -> HookStrategy = { hook in - let hook = hook as! Self - - let hookProxy = HookProxy( - selector: selector, - originalProvider: { - unsafeBitCast(hook.strategy.originalIMP, to: MethodSignature.self) - } - ) - - let replacementIMP = imp_implementationWithBlock(implementation(hookProxy)) - - return ClassHookStrategy( - class: `class`, - selector: selector, - replacementIMP: replacementIMP - ) - } - - try super.init( - class: `class`, - selector: selector, - strategyProvider: strategyProvider - ) - } - - } -} - final class ClassHookStrategy: HookStrategy { init( diff --git a/Sources/InterposeKit/Hooks/Hook.swift b/Sources/InterposeKit/Hooks/Hook.swift index 79e3ceb..c256211 100644 --- a/Sources/InterposeKit/Hooks/Hook.swift +++ b/Sources/InterposeKit/Hooks/Hook.swift @@ -1,10 +1,66 @@ import Foundation // TODO: Make final +// TODO: Make strategy private +// TODO: Make main init private /// A runtime hook that interposes a single instance method on a class or object. public class Hook { + // ============================================================================ // + // MARK: Initialization + // ============================================================================ // + + internal convenience init( + `class`: AnyClass, + selector: Selector, + implementation: HookImplementationBuilder + ) throws { + let strategyProvider: (Hook) -> HookStrategy = { hook in + let hookProxy = HookProxy( + selector: selector, + originalProvider: { + unsafeBitCast( + hook.strategy.originalIMP, + to: MethodSignature.self + ) + } + ) + + let replacementIMP = imp_implementationWithBlock(implementation(hookProxy)) + + return ClassHookStrategy( + class: `class`, + selector: selector, + replacementIMP: replacementIMP + ) + } + + try self.init( + class: `class`, + selector: selector, + strategyProvider: strategyProvider + ) + } + + init( + `class`: AnyClass, + selector: Selector, + strategyProvider: (Hook) -> HookStrategy + ) throws { + self.selector = selector + self.class = `class` + + // Check if method exists + try validate() + + self._strategy = strategyProvider(self) + } + + // ============================================================================ // + // MARK: ... + // ============================================================================ // + /// The class whose instance method is being interposed. public let `class`: AnyClass @@ -17,15 +73,7 @@ public class Hook { private var _strategy: HookStrategy! var strategy: HookStrategy { _strategy } - init(`class`: AnyClass, selector: Selector, strategyProvider: (Hook) -> HookStrategy) throws { - self.selector = selector - self.class = `class` - - // Check if method exists - try validate() - - self._strategy = strategyProvider(self) - } + func replaceImplementation() throws { if let strategy = self.strategy as? ClassHookStrategy { diff --git a/Sources/InterposeKit/Interpose.swift b/Sources/InterposeKit/Interpose.swift index 9f0d6d5..e6bc914 100644 --- a/Sources/InterposeKit/Interpose.swift +++ b/Sources/InterposeKit/Interpose.swift @@ -90,7 +90,7 @@ final public class Interpose { if let object = self.object { hook = try ObjectHook(object: object, selector: selector, implementation: implementation) } else { - hook = try ClassHook(class: `class`, selector: selector, implementation: implementation) + hook = try Hook(class: `class`, selector: selector, implementation: implementation) } hooks.append(hook) return hook From 3f7513c9b7f7972de0b3f3bc6a178bb087703421 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 26 Mar 2025 10:33:31 +0100 Subject: [PATCH 25/70] =?UTF-8?q?Refactored=20hook=20builder=20&=20addHook?= =?UTF-8?q?=20=E2=86=92=20applyHook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Deprecated/Interpose+Deprecated.swift | 4 +-- .../Deprecated/NSObject+Deprecated.swift | 26 +++++++++++++++---- Sources/InterposeKit/Hooks/Hook+.swift | 2 +- Sources/InterposeKit/Hooks/Hook.swift | 4 +-- Sources/InterposeKit/Hooks/ObjectHook.swift | 4 +-- Sources/InterposeKit/Interpose.swift | 10 +++---- Sources/InterposeKit/NSObject+Interpose.swift | 6 ++--- .../MultipleInterposing.swift | 2 +- .../ObjectInterposeTests.swift | 16 ++++++------ 9 files changed, 45 insertions(+), 29 deletions(-) diff --git a/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift b/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift index 8a8e1d0..840c7bb 100644 --- a/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift +++ b/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift @@ -8,13 +8,13 @@ extension Interpose { _ selectorName: String, methodSignature: MethodSignature.Type, hookSignature: HookSignature.Type, - _ implementation: HookImplementationBuilder + _ build: HookBuilder ) throws -> Hook { try self.hook( NSSelectorFromString(selectorName), methodSignature: methodSignature, hookSignature: hookSignature, - implementation + build ) } diff --git a/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift b/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift index 85b81e6..3447864 100644 --- a/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift +++ b/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift @@ -2,13 +2,29 @@ import ObjectiveC extension NSObject { - @available(*, deprecated, renamed: "addHook(for:methodSignature:hookSignature:implementation:)") + @available(*, deprecated, renamed: "applyHook(for:methodSignature:hookSignature:build:)") + @discardableResult + public func addHook( + for selector: Selector, + methodSignature: MethodSignature.Type, + hookSignature: HookSignature.Type, + implementation: HookBuilder + ) throws -> Hook { + return try self.applyHook( + for: selector, + methodSignature: methodSignature, + hookSignature: hookSignature, + build: implementation + ) + } + + @available(*, deprecated, renamed: "addHook(for:methodSignature:hookSignature:build:)") @discardableResult public func hook ( _ selector: Selector, methodSignature: MethodSignature.Type, hookSignature: HookSignature.Type, - _ implementation: HookImplementationBuilder + _ build: HookBuilder ) throws -> Hook { precondition( !(self is AnyClass), @@ -19,7 +35,7 @@ extension NSObject { for: selector, methodSignature: methodSignature, hookSignature: hookSignature, - implementation: implementation + implementation: build ) } @@ -33,12 +49,12 @@ extension NSObject { _ selector: Selector, methodSignature: MethodSignature.Type, hookSignature: HookSignature.Type, - _ implementation: HookImplementationBuilder + _ build: HookBuilder ) throws -> Hook { let hook = try Hook( class: self as AnyClass, selector: selector, - implementation: implementation + build: build ) try hook.apply() return hook diff --git a/Sources/InterposeKit/Hooks/Hook+.swift b/Sources/InterposeKit/Hooks/Hook+.swift index e853067..d070e18 100644 --- a/Sources/InterposeKit/Hooks/Hook+.swift +++ b/Sources/InterposeKit/Hooks/Hook+.swift @@ -1,6 +1,6 @@ import ObjectiveC -public typealias HookImplementationBuilder = (HookProxy) -> HookSignature +public typealias HookBuilder = (HookProxy) -> HookSignature public final class HookProxy { diff --git a/Sources/InterposeKit/Hooks/Hook.swift b/Sources/InterposeKit/Hooks/Hook.swift index c256211..94baa59 100644 --- a/Sources/InterposeKit/Hooks/Hook.swift +++ b/Sources/InterposeKit/Hooks/Hook.swift @@ -14,7 +14,7 @@ public class Hook { internal convenience init( `class`: AnyClass, selector: Selector, - implementation: HookImplementationBuilder + build: HookBuilder ) throws { let strategyProvider: (Hook) -> HookStrategy = { hook in let hookProxy = HookProxy( @@ -27,7 +27,7 @@ public class Hook { } ) - let replacementIMP = imp_implementationWithBlock(implementation(hookProxy)) + let replacementIMP = imp_implementationWithBlock(build(hookProxy)) return ClassHookStrategy( class: `class`, diff --git a/Sources/InterposeKit/Hooks/ObjectHook.swift b/Sources/InterposeKit/Hooks/ObjectHook.swift index c36c237..da801a5 100644 --- a/Sources/InterposeKit/Hooks/ObjectHook.swift +++ b/Sources/InterposeKit/Hooks/ObjectHook.swift @@ -19,7 +19,7 @@ extension Interpose { public init( object: AnyObject, selector: Selector, - implementation: HookImplementationBuilder + build: HookBuilder ) throws { self.object = object @@ -31,7 +31,7 @@ extension Interpose { originalProvider: { hook.original } ) - let block = implementation(hookProxy) as AnyObject + let block = build(hookProxy) as AnyObject let replacementIMP = imp_implementationWithBlock(block) // Weakly store reference to hook inside the block of the IMP. diff --git a/Sources/InterposeKit/Interpose.swift b/Sources/InterposeKit/Interpose.swift index e6bc914..18b87c3 100644 --- a/Sources/InterposeKit/Interpose.swift +++ b/Sources/InterposeKit/Interpose.swift @@ -70,10 +70,10 @@ final public class Interpose { _ selector: Selector, methodSignature: MethodSignature.Type, hookSignature: HookSignature.Type, - _ implementation: HookImplementationBuilder + _ build: HookBuilder ) throws -> Hook { let hook = try prepareHook(selector, methodSignature: methodSignature, - hookSignature: hookSignature, implementation) + hookSignature: hookSignature, build) try hook.apply() return hook } @@ -84,13 +84,13 @@ final public class Interpose { _ selector: Selector, methodSignature: MethodSignature.Type, hookSignature: HookSignature.Type, - _ implementation: HookImplementationBuilder + _ build: HookBuilder ) throws -> Hook { var hook: Hook if let object = self.object { - hook = try ObjectHook(object: object, selector: selector, implementation: implementation) + hook = try ObjectHook(object: object, selector: selector, build: build) } else { - hook = try Hook(class: `class`, selector: selector, implementation: implementation) + hook = try Hook(class: `class`, selector: selector, build: build) } hooks.append(hook) return hook diff --git a/Sources/InterposeKit/NSObject+Interpose.swift b/Sources/InterposeKit/NSObject+Interpose.swift index f33c70b..ea77c66 100644 --- a/Sources/InterposeKit/NSObject+Interpose.swift +++ b/Sources/InterposeKit/NSObject+Interpose.swift @@ -42,16 +42,16 @@ extension NSObject { /// hook.revert() /// ``` @discardableResult - public func addHook( + public func applyHook( for selector: Selector, methodSignature: MethodSignature.Type, hookSignature: HookSignature.Type, - implementation: HookImplementationBuilder + build: HookBuilder ) throws -> Hook { let hook = try Interpose.ObjectHook( object: self, selector: selector, - implementation: implementation + build: build ) try hook.apply() return hook diff --git a/Tests/InterposeKitTests/MultipleInterposing.swift b/Tests/InterposeKitTests/MultipleInterposing.swift index 19594fd..b36ddfb 100644 --- a/Tests/InterposeKitTests/MultipleInterposing.swift +++ b/Tests/InterposeKitTests/MultipleInterposing.swift @@ -25,7 +25,7 @@ final class MultipleInterposingTests: InterposeKitTestCase { XCTAssertEqual(testObj.sayHi(), testClassHi + testString) XCTAssertEqual(testObj2.sayHi(), testClassHi) - try testObj.addHook( + try testObj.applyHook( for: #selector(TestClass.sayHi), methodSignature: (@convention(c) (AnyObject, Selector) -> String).self, hookSignature: (@convention(block) (AnyObject) -> String).self diff --git a/Tests/InterposeKitTests/ObjectInterposeTests.swift b/Tests/InterposeKitTests/ObjectInterposeTests.swift index 489230e..9f16a75 100644 --- a/Tests/InterposeKitTests/ObjectInterposeTests.swift +++ b/Tests/InterposeKitTests/ObjectInterposeTests.swift @@ -11,7 +11,7 @@ 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 @@ -40,7 +40,7 @@ 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 @@ -71,7 +71,7 @@ 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 @@ -113,7 +113,7 @@ 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 @@ -134,7 +134,7 @@ 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 @@ -156,7 +156,7 @@ 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 @@ -174,7 +174,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 @@ -201,7 +201,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 From 50ce7d443fb8aa716f5f5869c215faee753d4728 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 26 Mar 2025 10:40:03 +0100 Subject: [PATCH 26/70] Preps for ObjectHookStrategy --- .../Hooks/ClassHookStrategy.swift | 2 +- Sources/InterposeKit/Hooks/HookStrategy.swift | 15 ------- Sources/InterposeKit/Hooks/ObjectHook.swift | 39 +++++++++++++++++-- 3 files changed, 36 insertions(+), 20 deletions(-) diff --git a/Sources/InterposeKit/Hooks/ClassHookStrategy.swift b/Sources/InterposeKit/Hooks/ClassHookStrategy.swift index 1deacb8..38b6f19 100644 --- a/Sources/InterposeKit/Hooks/ClassHookStrategy.swift +++ b/Sources/InterposeKit/Hooks/ClassHookStrategy.swift @@ -15,7 +15,7 @@ final class ClassHookStrategy: HookStrategy { let `class`: AnyClass let selector: Selector let replacementIMP: IMP - var originalIMP: IMP? + private(set) var originalIMP: IMP? func replaceImplementation() throws { guard let method = class_getInstanceMethod(self.class, self.selector) else { diff --git a/Sources/InterposeKit/Hooks/HookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy.swift index 12ced68..c6eb6fc 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy.swift @@ -9,18 +9,3 @@ protocol HookStrategy: AnyObject, CustomDebugStringConvertible { var originalIMP: IMP? { get } } - -final class DummyHookStrategy: HookStrategy { - - init(replacementIMP: IMP) { - self.replacementIMP = replacementIMP - } - - let replacementIMP: IMP - var originalIMP: IMP? - -} - -extension DummyHookStrategy: CustomDebugStringConvertible { - var debugDescription: String { "" } -} diff --git a/Sources/InterposeKit/Hooks/ObjectHook.swift b/Sources/InterposeKit/Hooks/ObjectHook.swift index da801a5..1be7f2e 100644 --- a/Sources/InterposeKit/Hooks/ObjectHook.swift +++ b/Sources/InterposeKit/Hooks/ObjectHook.swift @@ -37,7 +37,11 @@ extension Interpose { // Weakly store reference to hook inside the block of the IMP. Interpose.storeHook(hook: hook, to: block) - return DummyHookStrategy(replacementIMP: replacementIMP) + return ObjectHookStrategy( + object: object, + selector: selector, + replacementIMP: replacementIMP + ) } try super.init( @@ -123,7 +127,7 @@ extension Interpose { } // Replace IMP (by now we guarantee that it exists) - (self.strategy as! DummyHookStrategy).originalIMP = class_replaceMethod(dynamicSubclass, selector, replacementIMP, encoding) + (self.strategy as! ObjectHookStrategy).originalIMP = class_replaceMethod(dynamicSubclass, selector, replacementIMP, encoding) guard self.strategy.originalIMP != nil else { throw InterposeError.nonExistingImplementation(dynamicSubclass, selector) } @@ -131,7 +135,7 @@ extension Interpose { } else { // Could potentially be unified in the code paths if hasExistingMethod { - (self.strategy as! DummyHookStrategy).originalIMP = class_replaceMethod(dynamicSubclass, selector, replacementIMP, encoding) + (self.strategy as! ObjectHookStrategy).originalIMP = class_replaceMethod(dynamicSubclass, selector, replacementIMP, encoding) if self.strategy.originalIMP != nil { Interpose.log("Added -[\(`class`).\(selector)] IMP: \(replacementIMP) via replacement") } else { @@ -181,7 +185,7 @@ extension Interpose { } else { let nextHook = Interpose.findNextHook(selfHook: self, topmostIMP: currentIMP) // Replace next's original IMP - (nextHook?.strategy as? DummyHookStrategy)?.originalIMP = self.strategy.originalIMP + (nextHook?.strategy as? ObjectHookStrategy)?.originalIMP = self.strategy.originalIMP } // FUTURE: remove class pair! @@ -201,3 +205,30 @@ extension Interpose.ObjectHook: CustomDebugStringConvertible { } } #endif + +final class ObjectHookStrategy: HookStrategy { + + init( + object: AnyObject, + selector: Selector, + replacementIMP: IMP + ) { + self.object = object + self.class = type(of: object) + self.selector = selector + self.replacementIMP = replacementIMP + } + + let object: AnyObject + let `class`: AnyClass + let selector: Selector + let replacementIMP: IMP + var originalIMP: IMP? + +} + +extension ObjectHookStrategy: CustomDebugStringConvertible { + var debugDescription: String { + "" + } +} From 5510600bebe965e5eaffe1fd6004b1fd0ced3bbe Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 26 Mar 2025 10:50:14 +0100 Subject: [PATCH 27/70] Preps for ObjectHookStrategy 2/ --- Sources/InterposeKit/Hooks/ObjectHook.swift | 65 ++++++++++----------- 1 file changed, 32 insertions(+), 33 deletions(-) diff --git a/Sources/InterposeKit/Hooks/ObjectHook.swift b/Sources/InterposeKit/Hooks/ObjectHook.swift index 1be7f2e..15ab003 100644 --- a/Sources/InterposeKit/Hooks/ObjectHook.swift +++ b/Sources/InterposeKit/Hooks/ObjectHook.swift @@ -6,23 +6,12 @@ extension Interpose { /// Think about: Multiple hooks for one object final public class ObjectHook: Hook { - /// 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, build: HookBuilder ) throws { - self.object = object - let strategyProvider: (Hook) -> any HookStrategy = { hook in let hook = hook as! Self @@ -98,16 +87,14 @@ extension Interpose { return false } - var dynamicSubclass: AnyClass { - interposeSubclass!.dynamicClass - } - override func replaceImplementation() throws { + let strategy = self.strategy as! ObjectHookStrategy + 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) + strategy.interposeSubclass = try InterposeSubclass(object: strategy.object) // The implementation of the call that is hooked must exist. guard lookupOrigIMP != nil else { @@ -115,27 +102,27 @@ extension Interpose { } // This function searches superclasses for implementations - let hasExistingMethod = exactClassImplementsSelector(dynamicSubclass, selector) + let hasExistingMethod = exactClassImplementsSelector(strategy.dynamicSubclass, selector) let encoding = method_getTypeEncoding(method) let replacementIMP = self.strategy.replacementIMP - if self.generatesSuperIMP { + if strategy.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) + strategy.interposeSubclass!.addSuperTrampoline(selector: selector) } // Replace IMP (by now we guarantee that it exists) - (self.strategy as! ObjectHookStrategy).originalIMP = class_replaceMethod(dynamicSubclass, selector, replacementIMP, encoding) + (self.strategy as! ObjectHookStrategy).originalIMP = class_replaceMethod(strategy.dynamicSubclass, selector, replacementIMP, encoding) guard self.strategy.originalIMP != nil else { - throw InterposeError.nonExistingImplementation(dynamicSubclass, selector) + throw InterposeError.nonExistingImplementation(strategy.dynamicSubclass, selector) } Interpose.log("Added -[\(`class`).\(selector)] IMP: \(self.strategy.originalIMP!) -> \(replacementIMP)") } else { // Could potentially be unified in the code paths if hasExistingMethod { - (self.strategy as! ObjectHookStrategy).originalIMP = class_replaceMethod(dynamicSubclass, selector, replacementIMP, encoding) + (self.strategy as! ObjectHookStrategy).originalIMP = class_replaceMethod(strategy.dynamicSubclass, selector, replacementIMP, encoding) if self.strategy.originalIMP != nil { Interpose.log("Added -[\(`class`).\(selector)] IMP: \(replacementIMP) via replacement") } else { @@ -143,7 +130,7 @@ extension Interpose { throw InterposeError.unableToAddMethod(`class`, selector) } } else { - let didAddMethod = class_addMethod(dynamicSubclass, selector, replacementIMP, encoding) + let didAddMethod = class_addMethod(strategy.dynamicSubclass, selector, replacementIMP, encoding) if didAddMethod { Interpose.log("Added -[\(`class`).\(selector)] IMP: \(replacementIMP)") } else { @@ -155,6 +142,8 @@ extension Interpose { } override func resetImplementation() throws { + let strategy = self.strategy as! ObjectHookStrategy + let method = try validate(expectedState: .active) guard self.strategy.originalIMP != nil else { @@ -169,7 +158,7 @@ extension Interpose { throw InterposeError.resetUnsupported("No Original IMP found. SuperBuilder missing?") } - guard let currentIMP = class_getMethodImplementation(dynamicSubclass, selector) else { + guard let currentIMP = class_getMethodImplementation(strategy.dynamicSubclass, selector) else { throw InterposeError.unknownError("No Implementation found") } @@ -177,9 +166,9 @@ extension Interpose { let replacementIMP = self.strategy.replacementIMP if currentIMP == replacementIMP { let previousIMP = class_replaceMethod( - dynamicSubclass, selector, self.strategy.originalIMP!, method_getTypeEncoding(method)) + strategy.dynamicSubclass, selector, self.strategy.originalIMP!, method_getTypeEncoding(method)) guard previousIMP == replacementIMP else { - throw InterposeError.unexpectedImplementation(dynamicSubclass, selector, previousIMP) + throw InterposeError.unexpectedImplementation(strategy.dynamicSubclass, selector, previousIMP) } Interpose.log("Restored -[\(`class`).\(selector)] IMP: \(self.strategy.originalIMP!)") } else { @@ -198,13 +187,13 @@ extension Interpose { } } -#if DEBUG -extension Interpose.ObjectHook: CustomDebugStringConvertible { - public var debugDescription: String { - return "\(selector) of \(object) -> \(String(describing: original))" - } -} -#endif +//#if DEBUG +//extension Interpose.ObjectHook: CustomDebugStringConvertible { +// public var debugDescription: String { +// return "\(selector) of \(object) -> \(String(describing: original))" +// } +//} +//#endif final class ObjectHookStrategy: HookStrategy { @@ -225,6 +214,16 @@ final class ObjectHookStrategy: HookStrategy { let replacementIMP: IMP var originalIMP: IMP? + /// 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 + } + } extension ObjectHookStrategy: CustomDebugStringConvertible { From b85ef8e3f6409d83d8f6c6fa00819e8f70e267c6 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 26 Mar 2025 11:18:14 +0100 Subject: [PATCH 28/70] Removed generic parameter from ObjectHook --- Sources/InterposeKit/Hooks/Hook.swift | 11 +-- Sources/InterposeKit/Hooks/ObjectHook.swift | 77 ++++++++++--------- .../HookDynamicLookupTests.swift | 42 ++++++++++ 3 files changed, 88 insertions(+), 42 deletions(-) create mode 100644 Tests/InterposeKitTests/HookDynamicLookupTests.swift diff --git a/Sources/InterposeKit/Hooks/Hook.swift b/Sources/InterposeKit/Hooks/Hook.swift index 94baa59..fe16c83 100644 --- a/Sources/InterposeKit/Hooks/Hook.swift +++ b/Sources/InterposeKit/Hooks/Hook.swift @@ -21,7 +21,7 @@ public class Hook { selector: selector, originalProvider: { unsafeBitCast( - hook.strategy.originalIMP, + hook.originalIMP, to: MethodSignature.self ) } @@ -91,13 +91,10 @@ public class Hook { } } - /// The original implementation of the hook. Might be looked up at runtime. Do not cache this. + /// The effective original implementation of the hook. Might be looked up at runtime. + /// Do not cache this. var originalIMP: IMP? { - if let strategy = self.strategy as? ClassHookStrategy { - return strategy.originalIMP - } else { - preconditionFailure("Not implemented") - } + self.strategy.originalIMP } /// Applies the hook by interposing the method implementation. diff --git a/Sources/InterposeKit/Hooks/ObjectHook.swift b/Sources/InterposeKit/Hooks/ObjectHook.swift index 15ab003..6cb6969 100644 --- a/Sources/InterposeKit/Hooks/ObjectHook.swift +++ b/Sources/InterposeKit/Hooks/ObjectHook.swift @@ -4,10 +4,10 @@ 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: Hook { + final public class ObjectHook: Hook { /// Initialize a new hook to interpose an instance method. - public init( + public init( object: AnyObject, selector: Selector, build: HookBuilder @@ -17,7 +17,12 @@ extension Interpose { let hookProxy = HookProxy( selector: selector, - originalProvider: { hook.original } + originalProvider: { + unsafeBitCast( + hook.originalIMP, + to: MethodSignature.self + ) + } ) let block = build(hookProxy) as AnyObject @@ -46,33 +51,6 @@ extension Interpose { // super.cleanup() // } - /// The original implementation of the hook. Might be looked up at runtime. Do not cache this. - public var original: MethodSignature { - // If we switched implementations, return stored. - if let savedOrigIMP = self.strategy.originalIMP { - 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 @@ -97,7 +75,7 @@ extension Interpose { strategy.interposeSubclass = try InterposeSubclass(object: strategy.object) // The implementation of the call that is hooked must exist. - guard lookupOrigIMP != nil else { + guard strategy.lookupOrigIMP != nil else { throw InterposeError.nonExistingImplementation(`class`, selector).log() } @@ -114,7 +92,7 @@ extension Interpose { } // Replace IMP (by now we guarantee that it exists) - (self.strategy as! ObjectHookStrategy).originalIMP = class_replaceMethod(strategy.dynamicSubclass, selector, replacementIMP, encoding) + (self.strategy as! ObjectHookStrategy).storedOriginalIMP = class_replaceMethod(strategy.dynamicSubclass, selector, replacementIMP, encoding) guard self.strategy.originalIMP != nil else { throw InterposeError.nonExistingImplementation(strategy.dynamicSubclass, selector) } @@ -122,7 +100,7 @@ extension Interpose { } else { // Could potentially be unified in the code paths if hasExistingMethod { - (self.strategy as! ObjectHookStrategy).originalIMP = class_replaceMethod(strategy.dynamicSubclass, selector, replacementIMP, encoding) + (self.strategy as! ObjectHookStrategy).storedOriginalIMP = class_replaceMethod(strategy.dynamicSubclass, selector, replacementIMP, encoding) if self.strategy.originalIMP != nil { Interpose.log("Added -[\(`class`).\(selector)] IMP: \(replacementIMP) via replacement") } else { @@ -174,7 +152,7 @@ extension Interpose { } else { let nextHook = Interpose.findNextHook(selfHook: self, topmostIMP: currentIMP) // Replace next's original IMP - (nextHook?.strategy as? ObjectHookStrategy)?.originalIMP = self.strategy.originalIMP + (nextHook?.strategy as? ObjectHookStrategy)?.storedOriginalIMP = self.strategy.originalIMP } // FUTURE: remove class pair! @@ -212,7 +190,22 @@ final class ObjectHookStrategy: HookStrategy { let `class`: AnyClass let selector: Selector let replacementIMP: IMP - var originalIMP: IMP? + var storedOriginalIMP: IMP? + + /// The original implementation of the hook. Might be looked up at runtime. Do not cache this. + /// Actually not optional… + var originalIMP: IMP? { + // If we switched implementations, return stored. + if let storedOrigIMP = self.storedOriginalIMP { + return storedOrigIMP + } + // Else, perform a dynamic lookup + guard let origIMP = self.lookupOrigIMP else { + InterposeError.nonExistingImplementation(`class`, selector).log() + preconditionFailure("IMP must be found for call") + } + return origIMP + } /// Subclass that we create on the fly var interposeSubclass: InterposeSubclass? @@ -224,6 +217,20 @@ final class ObjectHookStrategy: HookStrategy { interposeSubclass!.dynamicClass } + /// We look for the parent IMP dynamically, so later modifications to the class are no problem. + var lookupOrigIMP: IMP? { + var currentClass: AnyClass? = self.class + repeat { + if let currentClass = currentClass, + let method = class_getInstanceMethod(currentClass, self.selector) { + let origIMP = method_getImplementation(method) + return origIMP + } + currentClass = class_getSuperclass(currentClass) + } while currentClass != nil + return nil + } + } extension ObjectHookStrategy: CustomDebugStringConvertible { diff --git a/Tests/InterposeKitTests/HookDynamicLookupTests.swift b/Tests/InterposeKitTests/HookDynamicLookupTests.swift new file mode 100644 index 0000000..0b82dcb --- /dev/null +++ b/Tests/InterposeKitTests/HookDynamicLookupTests.swift @@ -0,0 +1,42 @@ +@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 obj = 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 Interpose.ObjectHook( + object: obj, + selector: #selector(ExampleClass.greet(name:)), + build: { (hook: HookProxy) -> HookSignature in + // Build a replacement block that calls the original implementation via the hook proxy. + 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 `lookupOrigIMP` 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(obj, #selector(ExampleClass.greet(name:)), "World") + XCTAssertEqual(result, "Hello, World!") + } +} From 5d781f74eef5b5e7aa34a66d7a83baa0133605d2 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 26 Mar 2025 11:36:57 +0100 Subject: [PATCH 29/70] Moved more code to ObjectHookStrategy --- Sources/InterposeKit/Hooks/ObjectHook.swift | 232 +++++++++--------- .../InterposeKit/Internal/HookFinder.swift | 12 +- 2 files changed, 124 insertions(+), 120 deletions(-) diff --git a/Sources/InterposeKit/Hooks/ObjectHook.swift b/Sources/InterposeKit/Hooks/ObjectHook.swift index 6cb6969..27fb73b 100644 --- a/Sources/InterposeKit/Hooks/ObjectHook.swift +++ b/Sources/InterposeKit/Hooks/ObjectHook.swift @@ -13,8 +13,6 @@ extension Interpose { build: HookBuilder ) throws { let strategyProvider: (Hook) -> any HookStrategy = { hook in - let hook = hook as! Self - let hookProxy = HookProxy( selector: selector, originalProvider: { @@ -45,122 +43,12 @@ extension Interpose { ) } - // /// Release the hook block if possible. - // public override func cleanup() { - // // remove subclass! - // super.cleanup() - // } - - /// 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 - } - override func replaceImplementation() throws { - let strategy = self.strategy as! ObjectHookStrategy - - let method = try validate() - - // Check if there's an existing subclass we can reuse. - // Create one at runtime if there is none. - strategy.interposeSubclass = try InterposeSubclass(object: strategy.object) - - // The implementation of the call that is hooked must exist. - guard strategy.lookupOrigIMP != nil else { - throw InterposeError.nonExistingImplementation(`class`, selector).log() - } - - // This function searches superclasses for implementations - let hasExistingMethod = exactClassImplementsSelector(strategy.dynamicSubclass, selector) - let encoding = method_getTypeEncoding(method) - let replacementIMP = self.strategy.replacementIMP - - if strategy.generatesSuperIMP { - // If the subclass is empty, we create a super trampoline first. - // If a hook already exists, we must skip this. - if !hasExistingMethod { - strategy.interposeSubclass!.addSuperTrampoline(selector: selector) - } - - // Replace IMP (by now we guarantee that it exists) - (self.strategy as! ObjectHookStrategy).storedOriginalIMP = class_replaceMethod(strategy.dynamicSubclass, selector, replacementIMP, encoding) - guard self.strategy.originalIMP != nil else { - throw InterposeError.nonExistingImplementation(strategy.dynamicSubclass, selector) - } - Interpose.log("Added -[\(`class`).\(selector)] IMP: \(self.strategy.originalIMP!) -> \(replacementIMP)") - } else { - // Could potentially be unified in the code paths - if hasExistingMethod { - (self.strategy as! ObjectHookStrategy).storedOriginalIMP = class_replaceMethod(strategy.dynamicSubclass, selector, replacementIMP, encoding) - if self.strategy.originalIMP != 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(strategy.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) - } - } - } + try (self.strategy as! ObjectHookStrategy).replaceImplementation() } override func resetImplementation() throws { - let strategy = self.strategy as! ObjectHookStrategy - - let method = try validate(expectedState: .active) - - guard self.strategy.originalIMP != 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(strategy.dynamicSubclass, selector) else { - throw InterposeError.unknownError("No Implementation found") - } - - // We are the topmost hook, replace method. - let replacementIMP = self.strategy.replacementIMP - if currentIMP == replacementIMP { - let previousIMP = class_replaceMethod( - strategy.dynamicSubclass, selector, self.strategy.originalIMP!, method_getTypeEncoding(method)) - guard previousIMP == replacementIMP else { - throw InterposeError.unexpectedImplementation(strategy.dynamicSubclass, selector, previousIMP) - } - Interpose.log("Restored -[\(`class`).\(selector)] IMP: \(self.strategy.originalIMP!)") - } else { - let nextHook = Interpose.findNextHook(selfHook: self, topmostIMP: currentIMP) - // Replace next's original IMP - (nextHook?.strategy as? ObjectHookStrategy)?.storedOriginalIMP = self.strategy.originalIMP - } - - // 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 + try (self.strategy as! ObjectHookStrategy).resetImplementation(hook: self) } } } @@ -231,6 +119,122 @@ final class ObjectHookStrategy: HookStrategy { return nil } + func replaceImplementation() throws { + 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.lookupOrigIMP != 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) + let replacementIMP = self.replacementIMP + + 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, replacementIMP, encoding) + guard self.storedOriginalIMP != nil else { + throw InterposeError.nonExistingImplementation(self.dynamicSubclass, self.selector) + } + Interpose.log("Added -[\(self.class).\(self.selector)] IMP: \(self.storedOriginalIMP!) -> \(replacementIMP)") + } else { + // Could potentially be unified in the code paths + if hasExistingMethod { + self.storedOriginalIMP = class_replaceMethod(self.dynamicSubclass, self.selector, replacementIMP, encoding) + if self.storedOriginalIMP != nil { + Interpose.log("Added -[\(self.class).\(self.selector)] IMP: \(replacementIMP) via replacement") + } else { + Interpose.log("Unable to replace: -[\(self.class).\(self.selector)] IMP: \(replacementIMP)") + throw InterposeError.unableToAddMethod(self.class, self.selector) + } + } else { + let didAddMethod = class_addMethod(self.dynamicSubclass, self.selector, replacementIMP, encoding) + if didAddMethod { + Interpose.log("Added -[\(self.class).\(self.selector)] IMP: \(replacementIMP)") + } else { + Interpose.log("Unable to add: -[\(self.class).\(self.selector)] IMP: \(replacementIMP)") + 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 resetImplementation(hook: Hook) throws { + 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. + let replacementIMP = self.replacementIMP + if currentIMP == replacementIMP { + let previousIMP = class_replaceMethod(self.dynamicSubclass, self.selector, self.storedOriginalIMP!, method_getTypeEncoding(method)) + guard previousIMP == replacementIMP else { + throw InterposeError.unexpectedImplementation(self.dynamicSubclass, selector, previousIMP) + } + Interpose.log("Restored -[\(self.class).\(self.selector)] IMP: \(self.storedOriginalIMP!)") + } else { + let nextHook = Interpose.findNextHook(selfHook: hook, topmostIMP: currentIMP) + // Replace next's original IMP + (nextHook?.strategy as? ObjectHookStrategy)?.storedOriginalIMP = 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 + } + + // /// Release the hook block if possible. + // public override func cleanup() { + // // remove subclass! + // super.cleanup() + // } + } extension ObjectHookStrategy: CustomDebugStringConvertible { diff --git a/Sources/InterposeKit/Internal/HookFinder.swift b/Sources/InterposeKit/Internal/HookFinder.swift index 60466bf..504961a 100644 --- a/Sources/InterposeKit/Internal/HookFinder.swift +++ b/Sources/InterposeKit/Internal/HookFinder.swift @@ -17,7 +17,7 @@ extension Interpose { } } - static func storeHook(hook: HookType, to block: AnyObject) { + static func storeHook(hook: Hook, 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) @@ -25,21 +25,21 @@ extension Interpose { } // Finds the hook to a given implementation. - static func hookForIMP(_ imp: IMP) -> HookType? { + static func hookForIMP(_ imp: IMP) -> Hook? { // 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 + 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? { + static func findNextHook(selfHook: Hook, topmostIMP: IMP) -> Hook? { // We are not topmost hook, so find the hook above us! var impl: IMP? = topmostIMP - var currentHook: HookType? + var currentHook: Hook? repeat { // get topmost hook - let hook: HookType? = Interpose.hookForIMP(impl!) + let hook: Hook? = Interpose.hookForIMP(impl!) if hook === selfHook { // return parent return currentHook From 9d1b88bcde34cd9124dc81ca451939589bc29f5c Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 26 Mar 2025 11:49:10 +0100 Subject: [PATCH 30/70] =?UTF-8?q?`storeHook(=E2=80=A6)`=20now=20works=20wi?= =?UTF-8?q?th=20IMP=20rather=20than=20block?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/InterposeKit/Hooks/ObjectHook.swift | 2 +- Sources/InterposeKit/Internal/HookFinder.swift | 12 +++++++++--- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/Sources/InterposeKit/Hooks/ObjectHook.swift b/Sources/InterposeKit/Hooks/ObjectHook.swift index 27fb73b..dc694a3 100644 --- a/Sources/InterposeKit/Hooks/ObjectHook.swift +++ b/Sources/InterposeKit/Hooks/ObjectHook.swift @@ -27,7 +27,7 @@ extension Interpose { let replacementIMP = imp_implementationWithBlock(block) // Weakly store reference to hook inside the block of the IMP. - Interpose.storeHook(hook: hook, to: block) + Interpose.storeHook(hook: hook, to: replacementIMP) return ObjectHookStrategy( object: object, diff --git a/Sources/InterposeKit/Internal/HookFinder.swift b/Sources/InterposeKit/Internal/HookFinder.swift index 504961a..02c669c 100644 --- a/Sources/InterposeKit/Internal/HookFinder.swift +++ b/Sources/InterposeKit/Internal/HookFinder.swift @@ -17,10 +17,16 @@ extension Interpose { } } - static func storeHook(hook: Hook, to block: AnyObject) { + static func storeHook(hook: Hook, to imp: IMP) { // Weakly store reference to hook inside the block of the IMP. - objc_setAssociatedObject(block, &AssociatedKeys.hookForBlock, - WeakObjectContainer(with: hook), .OBJC_ASSOCIATION_RETAIN) + guard let block = imp_getBlock(imp) else { fatalError() } + + objc_setAssociatedObject( + block, + &AssociatedKeys.hookForBlock, + WeakObjectContainer(with: hook), + .OBJC_ASSOCIATION_RETAIN + ) } From 75a498a6b091ea5064d145b0ec212de3d94efeea Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 26 Mar 2025 11:53:45 +0100 Subject: [PATCH 31/70] =?UTF-8?q?replacementIMP=20=E2=86=92=20hookIMP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../Hooks/ClassHookStrategy.swift | 12 +++---- Sources/InterposeKit/Hooks/Hook.swift | 13 +++---- Sources/InterposeKit/Hooks/HookStrategy.swift | 10 ++++-- Sources/InterposeKit/Hooks/ObjectHook.swift | 36 +++++++++---------- 4 files changed, 37 insertions(+), 34 deletions(-) diff --git a/Sources/InterposeKit/Hooks/ClassHookStrategy.swift b/Sources/InterposeKit/Hooks/ClassHookStrategy.swift index 38b6f19..4961b89 100644 --- a/Sources/InterposeKit/Hooks/ClassHookStrategy.swift +++ b/Sources/InterposeKit/Hooks/ClassHookStrategy.swift @@ -5,16 +5,16 @@ final class ClassHookStrategy: HookStrategy { init( `class`: AnyClass, selector: Selector, - replacementIMP: IMP + hookIMP: IMP ) { self.class = `class` self.selector = selector - self.replacementIMP = replacementIMP + self.hookIMP = hookIMP } let `class`: AnyClass let selector: Selector - let replacementIMP: IMP + let hookIMP: IMP private(set) var originalIMP: IMP? func replaceImplementation() throws { @@ -25,7 +25,7 @@ final class ClassHookStrategy: HookStrategy { guard let originalIMP = class_replaceMethod( self.class, self.selector, - self.replacementIMP, + self.hookIMP, method_getTypeEncoding(method) ) else { throw InterposeError.nonExistingImplementation(self.class, self.selector) @@ -33,7 +33,7 @@ final class ClassHookStrategy: HookStrategy { self.originalIMP = originalIMP - Interpose.log("Swizzled -[\(self.class).\(self.selector)] IMP: \(originalIMP) -> \(self.replacementIMP)") + Interpose.log("Swizzled -[\(self.class).\(self.selector)] IMP: \(originalIMP) -> \(self.hookIMP)") } func resetImplementation() throws { @@ -53,7 +53,7 @@ final class ClassHookStrategy: HookStrategy { method_getTypeEncoding(method) ) - guard previousIMP == self.replacementIMP else { + guard previousIMP == self.hookIMP else { throw InterposeError.unexpectedImplementation(self.class, self.selector, previousIMP) } diff --git a/Sources/InterposeKit/Hooks/Hook.swift b/Sources/InterposeKit/Hooks/Hook.swift index fe16c83..7be9db8 100644 --- a/Sources/InterposeKit/Hooks/Hook.swift +++ b/Sources/InterposeKit/Hooks/Hook.swift @@ -27,12 +27,13 @@ public class Hook { } ) - let replacementIMP = imp_implementationWithBlock(build(hookProxy)) + let hookBlock = build(hookProxy) + let hookIMP = imp_implementationWithBlock(hookBlock) return ClassHookStrategy( class: `class`, selector: selector, - replacementIMP: replacementIMP + hookIMP: hookIMP ) } @@ -132,12 +133,12 @@ public class Hook { public func cleanup() { switch state { case .pending: - Interpose.log("Releasing -[\(`class`).\(selector)] IMP: \(self.strategy.replacementIMP)") - imp_removeBlock(strategy.replacementIMP) + Interpose.log("Releasing -[\(`class`).\(selector)] IMP: \(self.strategy.hookIMP)") + imp_removeBlock(strategy.hookIMP) case .active: - Interpose.log("Keeping -[\(`class`).\(selector)] IMP: \(self.strategy.replacementIMP)") + Interpose.log("Keeping -[\(`class`).\(selector)] IMP: \(self.strategy.hookIMP)") case .failed: - Interpose.log("Leaking -[\(`class`).\(selector)] IMP: \(self.strategy.replacementIMP)") + Interpose.log("Leaking -[\(`class`).\(selector)] IMP: \(self.strategy.hookIMP)") } } diff --git a/Sources/InterposeKit/Hooks/HookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy.swift index c6eb6fc..932a463 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy.swift @@ -1,11 +1,15 @@ import ObjectiveC +// TODO: Make originalIMP non-optional + protocol HookStrategy: AnyObject, CustomDebugStringConvertible { - /// The replacement implementation used to interpose the method, created during hook setup. - var replacementIMP: IMP { get } + /// The implementation used to interpose the method, created during hook setup and used + /// to replace the original implementation while the hook is applied. + var hookIMP: IMP { get } - /// The original method implementation, captured when the hook is applied. + /// The original method implementation active before the hook is applied, restored when + /// the hook is reverted. var originalIMP: IMP? { get } } diff --git a/Sources/InterposeKit/Hooks/ObjectHook.swift b/Sources/InterposeKit/Hooks/ObjectHook.swift index dc694a3..a875b2e 100644 --- a/Sources/InterposeKit/Hooks/ObjectHook.swift +++ b/Sources/InterposeKit/Hooks/ObjectHook.swift @@ -23,16 +23,16 @@ extension Interpose { } ) - let block = build(hookProxy) as AnyObject - let replacementIMP = imp_implementationWithBlock(block) + let hookBlock = build(hookProxy) + let hookIMP = imp_implementationWithBlock(hookBlock) // Weakly store reference to hook inside the block of the IMP. - Interpose.storeHook(hook: hook, to: replacementIMP) + Interpose.storeHook(hook: hook, to: hookIMP) return ObjectHookStrategy( object: object, selector: selector, - replacementIMP: replacementIMP + hookIMP: hookIMP ) } @@ -66,18 +66,18 @@ final class ObjectHookStrategy: HookStrategy { init( object: AnyObject, selector: Selector, - replacementIMP: IMP + hookIMP: IMP ) { self.object = object self.class = type(of: object) self.selector = selector - self.replacementIMP = replacementIMP + self.hookIMP = hookIMP } let object: AnyObject let `class`: AnyClass let selector: Selector - let replacementIMP: IMP + let hookIMP: IMP var storedOriginalIMP: IMP? /// The original implementation of the hook. Might be looked up at runtime. Do not cache this. @@ -136,7 +136,6 @@ final class ObjectHookStrategy: HookStrategy { // This function searches superclasses for implementations let hasExistingMethod = self.exactClassImplementsSelector(self.dynamicSubclass, self.selector) let encoding = method_getTypeEncoding(method) - let replacementIMP = self.replacementIMP if self.generatesSuperIMP { // If the subclass is empty, we create a super trampoline first. @@ -146,27 +145,27 @@ final class ObjectHookStrategy: HookStrategy { } // Replace IMP (by now we guarantee that it exists) - self.storedOriginalIMP = class_replaceMethod(self.dynamicSubclass, self.selector, replacementIMP, encoding) + self.storedOriginalIMP = class_replaceMethod(self.dynamicSubclass, self.selector, self.hookIMP, encoding) guard self.storedOriginalIMP != nil else { throw InterposeError.nonExistingImplementation(self.dynamicSubclass, self.selector) } - Interpose.log("Added -[\(self.class).\(self.selector)] IMP: \(self.storedOriginalIMP!) -> \(replacementIMP)") + Interpose.log("Added -[\(self.class).\(self.selector)] IMP: \(self.storedOriginalIMP!) -> \(self.hookIMP)") } else { // Could potentially be unified in the code paths if hasExistingMethod { - self.storedOriginalIMP = class_replaceMethod(self.dynamicSubclass, self.selector, replacementIMP, encoding) + self.storedOriginalIMP = class_replaceMethod(self.dynamicSubclass, self.selector, self.hookIMP, encoding) if self.storedOriginalIMP != nil { - Interpose.log("Added -[\(self.class).\(self.selector)] IMP: \(replacementIMP) via replacement") + Interpose.log("Added -[\(self.class).\(self.selector)] IMP: \(self.hookIMP) via replacement") } else { - Interpose.log("Unable to replace: -[\(self.class).\(self.selector)] IMP: \(replacementIMP)") + Interpose.log("Unable to replace: -[\(self.class).\(self.selector)] IMP: \(self.hookIMP)") throw InterposeError.unableToAddMethod(self.class, self.selector) } } else { - let didAddMethod = class_addMethod(self.dynamicSubclass, self.selector, replacementIMP, encoding) + let didAddMethod = class_addMethod(self.dynamicSubclass, self.selector, self.hookIMP, encoding) if didAddMethod { - Interpose.log("Added -[\(self.class).\(self.selector)] IMP: \(replacementIMP)") + Interpose.log("Added -[\(self.class).\(self.selector)] IMP: \(self.hookIMP)") } else { - Interpose.log("Unable to add: -[\(self.class).\(self.selector)] IMP: \(replacementIMP)") + Interpose.log("Unable to add: -[\(self.class).\(self.selector)] IMP: \(self.hookIMP)") throw InterposeError.unableToAddMethod(self.class, self.selector) } } @@ -208,10 +207,9 @@ final class ObjectHookStrategy: HookStrategy { } // We are the topmost hook, replace method. - let replacementIMP = self.replacementIMP - if currentIMP == replacementIMP { + if currentIMP == self.hookIMP { let previousIMP = class_replaceMethod(self.dynamicSubclass, self.selector, self.storedOriginalIMP!, method_getTypeEncoding(method)) - guard previousIMP == replacementIMP else { + guard previousIMP == self.hookIMP else { throw InterposeError.unexpectedImplementation(self.dynamicSubclass, selector, previousIMP) } Interpose.log("Restored -[\(self.class).\(self.selector)] IMP: \(self.storedOriginalIMP!)") From 01307adc4755f5cfcbfe9a4afef436fe4f154aa0 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 26 Mar 2025 12:28:25 +0100 Subject: [PATCH 32/70] First take on ObjectHookLink --- Sources/InterposeKit/Hooks/ObjectHook.swift | 88 +++++++++++++++++-- .../InterposeKit/Internal/HookFinder.swift | 59 ------------- 2 files changed, 80 insertions(+), 67 deletions(-) delete mode 100644 Sources/InterposeKit/Internal/HookFinder.swift diff --git a/Sources/InterposeKit/Hooks/ObjectHook.swift b/Sources/InterposeKit/Hooks/ObjectHook.swift index a875b2e..e3ba25a 100644 --- a/Sources/InterposeKit/Hooks/ObjectHook.swift +++ b/Sources/InterposeKit/Hooks/ObjectHook.swift @@ -26,9 +26,6 @@ extension Interpose { let hookBlock = build(hookProxy) let hookIMP = imp_implementationWithBlock(hookBlock) - // Weakly store reference to hook inside the block of the IMP. - Interpose.storeHook(hook: hook, to: hookIMP) - return ObjectHookStrategy( object: object, selector: selector, @@ -48,7 +45,7 @@ extension Interpose { } override func resetImplementation() throws { - try (self.strategy as! ObjectHookStrategy).resetImplementation(hook: self) + try (self.strategy as! ObjectHookStrategy).resetImplementation() } } } @@ -72,13 +69,22 @@ final class ObjectHookStrategy: HookStrategy { self.class = type(of: object) self.selector = selector self.hookIMP = hookIMP + + // Weakly store reference to hook inside the block of the IMP. + Interpose.storeHook(hook: self.node, to: hookIMP) } let object: AnyObject let `class`: AnyClass let selector: Selector let hookIMP: IMP - var storedOriginalIMP: IMP? + + var storedOriginalIMP: IMP? { + get { self.node.originalIMP } + set { self.node.originalIMP = newValue } + } + + private lazy var node = ObjectHookLink() /// The original implementation of the hook. Might be looked up at runtime. Do not cache this. /// Actually not optional… @@ -186,7 +192,7 @@ final class ObjectHookStrategy: HookStrategy { return false } - func resetImplementation(hook: Hook) throws { + func resetImplementation() throws { guard let method = class_getInstanceMethod(self.class, self.selector) else { throw InterposeError.methodNotFound(self.class, self.selector) } @@ -214,9 +220,9 @@ final class ObjectHookStrategy: HookStrategy { } Interpose.log("Restored -[\(self.class).\(self.selector)] IMP: \(self.storedOriginalIMP!)") } else { - let nextHook = Interpose.findNextHook(selfHook: hook, topmostIMP: currentIMP) + let nextHook = Interpose.findNextHook(selfHook: self.node, topmostIMP: currentIMP) // Replace next's original IMP - (nextHook?.strategy as? ObjectHookStrategy)?.storedOriginalIMP = self.storedOriginalIMP + nextHook?.originalIMP = self.storedOriginalIMP } // FUTURE: remove class pair! @@ -235,6 +241,72 @@ final class ObjectHookStrategy: HookStrategy { } +final class ObjectHookLink { + + init() {} + + var originalIMP: IMP? + +} + +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: ObjectHookLink, to imp: IMP) { + // Weakly store reference to hook inside the block of the IMP. + guard let block = imp_getBlock(imp) else { fatalError() } + + objc_setAssociatedObject( + block, + &AssociatedKeys.hookForBlock, + WeakObjectContainer(with: hook), + .OBJC_ASSOCIATION_RETAIN + ) + + } + + // Finds the hook to a given implementation. + static func hookForIMP(_ imp: IMP) -> ObjectHookLink? { + // 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: ObjectHookLink, topmostIMP: IMP) -> ObjectHookLink? { + // We are not topmost hook, so find the hook above us! + var impl: IMP? = topmostIMP + var currentHook: ObjectHookLink? + repeat { + // get topmost hook + let hook: ObjectHookLink? = Interpose.hookForIMP(impl!) + if hook === selfHook { + // return parent + return currentHook + } + // crawl down the chain until we find ourselves + currentHook = hook + impl = hook?.originalIMP + } while impl != nil + return nil + } +} + extension ObjectHookStrategy: CustomDebugStringConvertible { var debugDescription: String { "" diff --git a/Sources/InterposeKit/Internal/HookFinder.swift b/Sources/InterposeKit/Internal/HookFinder.swift deleted file mode 100644 index 02c669c..0000000 --- a/Sources/InterposeKit/Internal/HookFinder.swift +++ /dev/null @@ -1,59 +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: Hook, to imp: IMP) { - // Weakly store reference to hook inside the block of the IMP. - guard let block = imp_getBlock(imp) else { fatalError() } - - objc_setAssociatedObject( - block, - &AssociatedKeys.hookForBlock, - WeakObjectContainer(with: hook), - .OBJC_ASSOCIATION_RETAIN - ) - - } - - // Finds the hook to a given implementation. - static func hookForIMP(_ imp: IMP) -> Hook? { - // 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: Hook, topmostIMP: IMP) -> Hook? { - // We are not topmost hook, so find the hook above us! - var impl: IMP? = topmostIMP - var currentHook: Hook? - repeat { - // get topmost hook - let hook: Hook? = Interpose.hookForIMP(impl!) - if hook === selfHook { - // return parent - return currentHook - } - // crawl down the chain until we find ourselves - currentHook = hook - impl = hook?.strategy.originalIMP - } while impl != nil - return nil - } -} From fb634bd369ac445cc38832931b374af875420fff Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 26 Mar 2025 12:32:35 +0100 Subject: [PATCH 33/70] Moved replace/restore methods to HookStrategy --- .../Hooks/ClassHookStrategy.swift | 2 +- Sources/InterposeKit/Hooks/Hook.swift | 26 +++++-------------- Sources/InterposeKit/Hooks/HookStrategy.swift | 4 +++ Sources/InterposeKit/Hooks/ObjectHook.swift | 11 +------- 4 files changed, 12 insertions(+), 31 deletions(-) diff --git a/Sources/InterposeKit/Hooks/ClassHookStrategy.swift b/Sources/InterposeKit/Hooks/ClassHookStrategy.swift index 4961b89..b957ae1 100644 --- a/Sources/InterposeKit/Hooks/ClassHookStrategy.swift +++ b/Sources/InterposeKit/Hooks/ClassHookStrategy.swift @@ -36,7 +36,7 @@ final class ClassHookStrategy: HookStrategy { Interpose.log("Swizzled -[\(self.class).\(self.selector)] IMP: \(originalIMP) -> \(self.hookIMP)") } - func resetImplementation() throws { + func restoreImplementation() throws { guard let method = class_getInstanceMethod(self.class, self.selector) else { throw InterposeError.methodNotFound(self.class, self.selector) } diff --git a/Sources/InterposeKit/Hooks/Hook.swift b/Sources/InterposeKit/Hooks/Hook.swift index 7be9db8..50fbf0c 100644 --- a/Sources/InterposeKit/Hooks/Hook.swift +++ b/Sources/InterposeKit/Hooks/Hook.swift @@ -74,24 +74,6 @@ public class Hook { private var _strategy: HookStrategy! var strategy: HookStrategy { _strategy } - - - func replaceImplementation() throws { - if let strategy = self.strategy as? ClassHookStrategy { - return try strategy.replaceImplementation() - } else { - preconditionFailure("Not implemented") - } - } - - func resetImplementation() throws { - if let strategy = self.strategy as? ClassHookStrategy { - return try strategy.resetImplementation() - } else { - preconditionFailure("Not implemented") - } - } - /// The effective original implementation of the hook. Might be looked up at runtime. /// Do not cache this. var originalIMP: IMP? { @@ -100,12 +82,16 @@ public class Hook { /// Applies the hook by interposing the method implementation. public func apply() throws { - try execute(newState: .active) { try replaceImplementation() } + try execute(newState: .active) { + try self.strategy.replaceImplementation() + } } /// Reverts the hook, restoring the original method implementation. public func revert() throws { - try execute(newState: .pending) { try resetImplementation() } + try execute(newState: .pending) { + try self.strategy.restoreImplementation() + } } /// Validate that the selector exists on the active class. diff --git a/Sources/InterposeKit/Hooks/HookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy.swift index 932a463..80e6f39 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy.swift @@ -12,4 +12,8 @@ protocol HookStrategy: AnyObject, CustomDebugStringConvertible { /// the hook is reverted. var originalIMP: IMP? { get } + func replaceImplementation() throws + + func restoreImplementation() throws + } diff --git a/Sources/InterposeKit/Hooks/ObjectHook.swift b/Sources/InterposeKit/Hooks/ObjectHook.swift index e3ba25a..1f38d11 100644 --- a/Sources/InterposeKit/Hooks/ObjectHook.swift +++ b/Sources/InterposeKit/Hooks/ObjectHook.swift @@ -5,7 +5,6 @@ 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: Hook { - /// Initialize a new hook to interpose an instance method. public init( object: AnyObject, @@ -39,14 +38,6 @@ extension Interpose { strategyProvider: strategyProvider ) } - - override func replaceImplementation() throws { - try (self.strategy as! ObjectHookStrategy).replaceImplementation() - } - - override func resetImplementation() throws { - try (self.strategy as! ObjectHookStrategy).resetImplementation() - } } } @@ -192,7 +183,7 @@ final class ObjectHookStrategy: HookStrategy { return false } - func resetImplementation() throws { + func restoreImplementation() throws { guard let method = class_getInstanceMethod(self.class, self.selector) else { throw InterposeError.methodNotFound(self.class, self.selector) } From b31d282a47e87ab0afcae3a60eb9e3963165409e Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 26 Mar 2025 12:35:40 +0100 Subject: [PATCH 34/70] Removed ObjectHook --- Sources/InterposeKit/Hooks/Hook.swift | 33 +++++++++ ...ectHook.swift => ObjectHookStrategy.swift} | 69 ++++--------------- Sources/InterposeKit/Interpose.swift | 2 +- Sources/InterposeKit/NSObject+Interpose.swift | 2 +- .../HookDynamicLookupTests.swift | 8 +-- 5 files changed, 53 insertions(+), 61 deletions(-) rename Sources/InterposeKit/Hooks/{ObjectHook.swift => ObjectHookStrategy.swift} (87%) diff --git a/Sources/InterposeKit/Hooks/Hook.swift b/Sources/InterposeKit/Hooks/Hook.swift index 50fbf0c..dfb8271 100644 --- a/Sources/InterposeKit/Hooks/Hook.swift +++ b/Sources/InterposeKit/Hooks/Hook.swift @@ -44,6 +44,39 @@ public class Hook { ) } + internal convenience init( + object: AnyObject, + selector: Selector, + build: HookBuilder + ) throws { + let strategyProvider: (Hook) -> any HookStrategy = { hook in + let hookProxy = HookProxy( + selector: selector, + originalProvider: { + unsafeBitCast( + hook.originalIMP, + to: MethodSignature.self + ) + } + ) + + let hookBlock = build(hookProxy) + let hookIMP = imp_implementationWithBlock(hookBlock) + + return ObjectHookStrategy( + object: object, + selector: selector, + hookIMP: hookIMP + ) + } + + try self.init( + class: type(of: object), + selector: selector, + strategyProvider: strategyProvider + ) + } + init( `class`: AnyClass, selector: Selector, diff --git a/Sources/InterposeKit/Hooks/ObjectHook.swift b/Sources/InterposeKit/Hooks/ObjectHookStrategy.swift similarity index 87% rename from Sources/InterposeKit/Hooks/ObjectHook.swift rename to Sources/InterposeKit/Hooks/ObjectHookStrategy.swift index 1f38d11..bc3dd5c 100644 --- a/Sources/InterposeKit/Hooks/ObjectHook.swift +++ b/Sources/InterposeKit/Hooks/ObjectHookStrategy.swift @@ -1,54 +1,5 @@ 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: Hook { - /// Initialize a new hook to interpose an instance method. - public init( - object: AnyObject, - selector: Selector, - build: HookBuilder - ) throws { - let strategyProvider: (Hook) -> any HookStrategy = { hook in - let hookProxy = HookProxy( - selector: selector, - originalProvider: { - unsafeBitCast( - hook.originalIMP, - to: MethodSignature.self - ) - } - ) - - let hookBlock = build(hookProxy) - let hookIMP = imp_implementationWithBlock(hookBlock) - - return ObjectHookStrategy( - object: object, - selector: selector, - hookIMP: hookIMP - ) - } - - try super.init( - class: type(of: object), - selector: selector, - strategyProvider: strategyProvider - ) - } - } -} - -//#if DEBUG -//extension Interpose.ObjectHook: CustomDebugStringConvertible { -// public var debugDescription: String { -// return "\(selector) of \(object) -> \(String(describing: original))" -// } -//} -//#endif - final class ObjectHookStrategy: HookStrategy { init( @@ -232,6 +183,20 @@ final class ObjectHookStrategy: HookStrategy { } +extension ObjectHookStrategy: CustomDebugStringConvertible { + var debugDescription: String { + "" + } +} + +//#if DEBUG +//extension Interpose.ObjectHook: CustomDebugStringConvertible { +// public var debugDescription: String { +// return "\(selector) of \(object) -> \(String(describing: original))" +// } +//} +//#endif + final class ObjectHookLink { init() {} @@ -297,9 +262,3 @@ extension Interpose { return nil } } - -extension ObjectHookStrategy: CustomDebugStringConvertible { - var debugDescription: String { - "" - } -} diff --git a/Sources/InterposeKit/Interpose.swift b/Sources/InterposeKit/Interpose.swift index 18b87c3..1727efc 100644 --- a/Sources/InterposeKit/Interpose.swift +++ b/Sources/InterposeKit/Interpose.swift @@ -88,7 +88,7 @@ final public class Interpose { ) throws -> Hook { var hook: Hook if let object = self.object { - hook = try ObjectHook(object: object, selector: selector, build: build) + hook = try Hook(object: object, selector: selector, build: build) } else { hook = try Hook(class: `class`, selector: selector, build: build) } diff --git a/Sources/InterposeKit/NSObject+Interpose.swift b/Sources/InterposeKit/NSObject+Interpose.swift index ea77c66..2dc8313 100644 --- a/Sources/InterposeKit/NSObject+Interpose.swift +++ b/Sources/InterposeKit/NSObject+Interpose.swift @@ -48,7 +48,7 @@ extension NSObject { hookSignature: HookSignature.Type, build: HookBuilder ) throws -> Hook { - let hook = try Interpose.ObjectHook( + let hook = try Hook( object: self, selector: selector, build: build diff --git a/Tests/InterposeKitTests/HookDynamicLookupTests.swift b/Tests/InterposeKitTests/HookDynamicLookupTests.swift index 0b82dcb..3f13036 100644 --- a/Tests/InterposeKitTests/HookDynamicLookupTests.swift +++ b/Tests/InterposeKitTests/HookDynamicLookupTests.swift @@ -13,12 +13,12 @@ class HookDynamicLookupTests: XCTestCase { typealias MethodSignature = @convention(c) (ExampleClass, Selector, String) -> String typealias HookSignature = @convention(block) (ExampleClass, String) -> String - let obj = ExampleClass() + 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 Interpose.ObjectHook( - object: obj, + let hook = try Hook( + object: object, selector: #selector(ExampleClass.greet(name:)), build: { (hook: HookProxy) -> HookSignature in // Build a replacement block that calls the original implementation via the hook proxy. @@ -36,7 +36,7 @@ class HookDynamicLookupTests: XCTestCase { ) // Call the original implementation via the looked-up IMP. - let result = original(obj, #selector(ExampleClass.greet(name:)), "World") + let result = original(object, #selector(ExampleClass.greet(name:)), "World") XCTAssertEqual(result, "Hello, World!") } } From 99ee08ec87446dedf4d172fb36eedc175f34c73d Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 26 Mar 2025 12:36:46 +0100 Subject: [PATCH 35/70] Tweaks to Hook --- Sources/InterposeKit/Hooks/Hook.swift | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/Sources/InterposeKit/Hooks/Hook.swift b/Sources/InterposeKit/Hooks/Hook.swift index dfb8271..29306ea 100644 --- a/Sources/InterposeKit/Hooks/Hook.swift +++ b/Sources/InterposeKit/Hooks/Hook.swift @@ -1,11 +1,7 @@ import Foundation -// TODO: Make final -// TODO: Make strategy private -// TODO: Make main init private - /// A runtime hook that interposes a single instance method on a class or object. -public class Hook { +public final class Hook { // ============================================================================ // // MARK: Initialization @@ -77,7 +73,7 @@ public class Hook { ) } - init( + private init( `class`: AnyClass, selector: Selector, strategyProvider: (Hook) -> HookStrategy @@ -105,7 +101,8 @@ public class Hook { public internal(set) var state = HookState.pending private var _strategy: HookStrategy! - var strategy: HookStrategy { _strategy } + + private var strategy: HookStrategy { _strategy } /// The effective original implementation of the hook. Might be looked up at runtime. /// Do not cache this. From 1d6b83e4b5b8b7e0983daabe543efc1a777c636f Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 26 Mar 2025 14:04:55 +0100 Subject: [PATCH 36/70] ObjectHookRegistry & ObjectHookHandle --- .../ClassHookStrategy.swift | 0 .../{ => HookStrategy}/HookStrategy.swift | 0 .../ObjectHookStrategy/ObjectHookHandle.swift | 26 +++++ .../ObjectHookRegistry.swift | 51 +++++++++ .../ObjectHookStrategy.swift | 100 +++++------------- 5 files changed, 103 insertions(+), 74 deletions(-) rename Sources/InterposeKit/Hooks/{ => HookStrategy}/ClassHookStrategy.swift (100%) rename Sources/InterposeKit/Hooks/{ => HookStrategy}/HookStrategy.swift (100%) create mode 100644 Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookHandle.swift create mode 100644 Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookRegistry.swift rename Sources/InterposeKit/Hooks/{ => HookStrategy/ObjectHookStrategy}/ObjectHookStrategy.swift (81%) diff --git a/Sources/InterposeKit/Hooks/ClassHookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy.swift similarity index 100% rename from Sources/InterposeKit/Hooks/ClassHookStrategy.swift rename to Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy.swift diff --git a/Sources/InterposeKit/Hooks/HookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/HookStrategy.swift similarity index 100% rename from Sources/InterposeKit/Hooks/HookStrategy.swift rename to Sources/InterposeKit/Hooks/HookStrategy/HookStrategy.swift 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..901a32c --- /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 { + fatalError("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/ObjectHookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift similarity index 81% rename from Sources/InterposeKit/Hooks/ObjectHookStrategy.swift rename to Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift index bc3dd5c..06e0745 100644 --- a/Sources/InterposeKit/Hooks/ObjectHookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift @@ -12,8 +12,7 @@ final class ObjectHookStrategy: HookStrategy { self.selector = selector self.hookIMP = hookIMP - // Weakly store reference to hook inside the block of the IMP. - Interpose.storeHook(hook: self.node, to: hookIMP) + ObjectHookRegistry.register(self.handle, for: hookIMP) } let object: AnyObject @@ -21,12 +20,12 @@ final class ObjectHookStrategy: HookStrategy { let selector: Selector let hookIMP: IMP - var storedOriginalIMP: IMP? { - get { self.node.originalIMP } - set { self.node.originalIMP = newValue } - } + var storedOriginalIMP: IMP? - private lazy var node = ObjectHookLink() + private lazy var handle = ObjectHookHandle( + getOriginalIMP: { self.storedOriginalIMP }, + setOriginalIMP: { self.storedOriginalIMP = $0 } + ) /// The original implementation of the hook. Might be looked up at runtime. Do not cache this. /// Actually not optional… @@ -162,7 +161,7 @@ final class ObjectHookStrategy: HookStrategy { } Interpose.log("Restored -[\(self.class).\(self.selector)] IMP: \(self.storedOriginalIMP!)") } else { - let nextHook = Interpose.findNextHook(selfHook: self.node, topmostIMP: currentIMP) + let nextHook = self.findNextHook(selfHook: self.handle, topmostIMP: currentIMP) // Replace next's original IMP nextHook?.originalIMP = self.storedOriginalIMP } @@ -175,6 +174,25 @@ final class ObjectHookStrategy: HookStrategy { // self.dynamicSubclass = nil } + // Find the hook above us (not necessarily topmost) + private func findNextHook(selfHook: ObjectHookHandle, topmostIMP: IMP) -> ObjectHookHandle? { + // We are not topmost hook, so find the hook above us! + var impl: IMP? = topmostIMP + var currentHook: ObjectHookHandle? + repeat { + // get topmost hook + let hook: ObjectHookHandle? = ObjectHookRegistry.handle(for: impl!) + if hook === selfHook { + // return parent + return currentHook + } + // crawl down the chain until we find ourselves + currentHook = hook + impl = hook?.originalIMP + } while impl != nil + return nil + } + // /// Release the hook block if possible. // public override func cleanup() { // // remove subclass! @@ -196,69 +214,3 @@ extension ObjectHookStrategy: CustomDebugStringConvertible { // } //} //#endif - -final class ObjectHookLink { - - init() {} - - var originalIMP: IMP? - -} - -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: ObjectHookLink, to imp: IMP) { - // Weakly store reference to hook inside the block of the IMP. - guard let block = imp_getBlock(imp) else { fatalError() } - - objc_setAssociatedObject( - block, - &AssociatedKeys.hookForBlock, - WeakObjectContainer(with: hook), - .OBJC_ASSOCIATION_RETAIN - ) - - } - - // Finds the hook to a given implementation. - static func hookForIMP(_ imp: IMP) -> ObjectHookLink? { - // 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: ObjectHookLink, topmostIMP: IMP) -> ObjectHookLink? { - // We are not topmost hook, so find the hook above us! - var impl: IMP? = topmostIMP - var currentHook: ObjectHookLink? - repeat { - // get topmost hook - let hook: ObjectHookLink? = Interpose.hookForIMP(impl!) - if hook === selfHook { - // return parent - return currentHook - } - // crawl down the chain until we find ourselves - currentHook = hook - impl = hook?.originalIMP - } while impl != nil - return nil - } -} From 4c19c511efc879385dc4d17ff7ff5c25b7aa8c78 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 26 Mar 2025 14:08:11 +0100 Subject: [PATCH 37/70] Cleaned up debug descriptions --- Sources/InterposeKit/Hooks/Hook.swift | 6 ++++++ .../Hooks/HookStrategy/ClassHookStrategy.swift | 2 +- .../ObjectHookStrategy/ObjectHookStrategy.swift | 12 ++---------- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/Sources/InterposeKit/Hooks/Hook.swift b/Sources/InterposeKit/Hooks/Hook.swift index 29306ea..0a564f1 100644 --- a/Sources/InterposeKit/Hooks/Hook.swift +++ b/Sources/InterposeKit/Hooks/Hook.swift @@ -159,3 +159,9 @@ public final class Hook { } } + +extension Hook: CustomDebugStringConvertible { + public var debugDescription: String { + self.strategy.debugDescription + } +} diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy.swift index b957ae1..49d5f75 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy.swift @@ -63,7 +63,7 @@ final class ClassHookStrategy: HookStrategy { } extension ClassHookStrategy: CustomDebugStringConvertible { - var debugDescription: String { + internal var debugDescription: String { "\(self.selector) → \(String(describing: self.originalIMP))" } } diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift index 06e0745..b0795e9 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift @@ -202,15 +202,7 @@ final class ObjectHookStrategy: HookStrategy { } extension ObjectHookStrategy: CustomDebugStringConvertible { - var debugDescription: String { - "" + internal var debugDescription: String { + "\(self.selector) of \(self.object) → \(String(describing: self.originalIMP))" } } - -//#if DEBUG -//extension Interpose.ObjectHook: CustomDebugStringConvertible { -// public var debugDescription: String { -// return "\(selector) of \(object) -> \(String(describing: original))" -// } -//} -//#endif From 0d9646de2acdf9c3e20fb44f2b6d5550211a284d Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 26 Mar 2025 14:32:31 +0100 Subject: [PATCH 38/70] =?UTF-8?q?Refactored=20`ObjectHookStrategy.=5FfindP?= =?UTF-8?q?arentHook(=E2=80=A6)`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ObjectHookStrategy.swift | 47 ++++++++++--------- 1 file changed, 24 insertions(+), 23 deletions(-) diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift index b0795e9..29c962b 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift @@ -161,7 +161,7 @@ final class ObjectHookStrategy: HookStrategy { } Interpose.log("Restored -[\(self.class).\(self.selector)] IMP: \(self.storedOriginalIMP!)") } else { - let nextHook = self.findNextHook(selfHook: self.handle, topmostIMP: currentIMP) + let nextHook = self._findParentHook(from: currentIMP) // Replace next's original IMP nextHook?.originalIMP = self.storedOriginalIMP } @@ -174,31 +174,32 @@ final class ObjectHookStrategy: HookStrategy { // self.dynamicSubclass = nil } - // Find the hook above us (not necessarily topmost) - private func findNextHook(selfHook: ObjectHookHandle, topmostIMP: IMP) -> ObjectHookHandle? { - // We are not topmost hook, so find the hook above us! - var impl: IMP? = topmostIMP - var currentHook: ObjectHookHandle? - repeat { - // get topmost hook - let hook: ObjectHookHandle? = ObjectHookRegistry.handle(for: impl!) - if hook === selfHook { - // return parent - return currentHook - } - // crawl down the chain until we find ourselves - currentHook = hook - impl = hook?.originalIMP - } while impl != 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 } - // /// Release the hook block if possible. - // public override func cleanup() { - // // remove subclass! - // super.cleanup() - // } - } extension ObjectHookStrategy: CustomDebugStringConvertible { From 183178cd8f2c763785a9b10a64de5b8cc813e35b Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 26 Mar 2025 19:07:01 +0100 Subject: [PATCH 39/70] `HookStrategy.lookUpIMP()` --- .../Hooks/HookStrategy/HookStrategy.swift | 28 ++++++++++++++++++- .../ObjectHookStrategy.swift | 18 ++---------- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/Sources/InterposeKit/Hooks/HookStrategy/HookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/HookStrategy.swift index 80e6f39..8ebf5df 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/HookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/HookStrategy.swift @@ -2,7 +2,13 @@ import ObjectiveC // TODO: Make originalIMP non-optional -protocol HookStrategy: AnyObject, CustomDebugStringConvertible { +internal protocol HookStrategy: AnyObject, CustomDebugStringConvertible { + + /// The class whose instance method is being interposed. + var `class`: AnyClass { get } + + /// /// The selector identifying the instance method being interposed. + var selector: Selector { get } /// The implementation used to interpose the method, created during hook setup and used /// to replace the original implementation while the hook is applied. @@ -17,3 +23,23 @@ protocol HookStrategy: AnyObject, CustomDebugStringConvertible { func restoreImplementation() throws } + +extension HookStrategy { + + /// Dynamically resolves the current IMP of the hooked method by walking the class hierarchy. + /// This may return either the original or a hook IMP, depending on the state of the hook. + 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/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift index 29c962b..c95fbeb 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift @@ -35,7 +35,7 @@ final class ObjectHookStrategy: HookStrategy { return storedOrigIMP } // Else, perform a dynamic lookup - guard let origIMP = self.lookupOrigIMP else { + guard let origIMP = self.lookUpIMP() else { InterposeError.nonExistingImplementation(`class`, selector).log() preconditionFailure("IMP must be found for call") } @@ -52,20 +52,6 @@ final class ObjectHookStrategy: HookStrategy { interposeSubclass!.dynamicClass } - /// We look for the parent IMP dynamically, so later modifications to the class are no problem. - var lookupOrigIMP: IMP? { - var currentClass: AnyClass? = self.class - repeat { - if let currentClass = currentClass, - let method = class_getInstanceMethod(currentClass, self.selector) { - let origIMP = method_getImplementation(method) - return origIMP - } - currentClass = class_getSuperclass(currentClass) - } while currentClass != nil - return nil - } - func replaceImplementation() throws { guard let method = class_getInstanceMethod(self.class, self.selector) else { throw InterposeError.methodNotFound(self.class, self.selector) @@ -76,7 +62,7 @@ final class ObjectHookStrategy: HookStrategy { self.interposeSubclass = try InterposeSubclass(object: self.object) // The implementation of the call that is hooked must exist. - guard self.lookupOrigIMP != nil else { + guard self.lookUpIMP() != nil else { throw InterposeError.nonExistingImplementation(self.class, self.selector).log() } From 44be65f3dcedcc921926af6aa1711a63bec1c1a6 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 26 Mar 2025 19:39:37 +0100 Subject: [PATCH 40/70] Validation in HookStrategy & HookScope --- Sources/InterposeKit/Hooks/Hook+.swift | 10 ++++ Sources/InterposeKit/Hooks/Hook.swift | 54 ++++++++----------- .../HookStrategy/ClassHookStrategy.swift | 5 +- .../Hooks/HookStrategy/HookStrategy.swift | 16 ++++-- .../ObjectHookStrategy.swift | 11 ++-- 5 files changed, 55 insertions(+), 41 deletions(-) diff --git a/Sources/InterposeKit/Hooks/Hook+.swift b/Sources/InterposeKit/Hooks/Hook+.swift index d070e18..c3ddc92 100644 --- a/Sources/InterposeKit/Hooks/Hook+.swift +++ b/Sources/InterposeKit/Hooks/Hook+.swift @@ -20,6 +20,16 @@ public final class HookProxy { } +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(AnyObject) + +} + public enum HookState: Equatable { /// The hook is ready to be applied. diff --git a/Sources/InterposeKit/Hooks/Hook.swift b/Sources/InterposeKit/Hooks/Hook.swift index 0a564f1..724dbef 100644 --- a/Sources/InterposeKit/Hooks/Hook.swift +++ b/Sources/InterposeKit/Hooks/Hook.swift @@ -1,5 +1,7 @@ import Foundation +// TODO: Make originalIMP private + /// A runtime hook that interposes a single instance method on a class or object. public final class Hook { @@ -12,7 +14,7 @@ public final class Hook { selector: Selector, build: HookBuilder ) throws { - let strategyProvider: (Hook) -> HookStrategy = { hook in + try self.init { hook in let hookProxy = HookProxy( selector: selector, originalProvider: { @@ -26,18 +28,12 @@ public final class Hook { let hookBlock = build(hookProxy) let hookIMP = imp_implementationWithBlock(hookBlock) - return ClassHookStrategy( + return try ClassHookStrategy( class: `class`, selector: selector, hookIMP: hookIMP ) } - - try self.init( - class: `class`, - selector: selector, - strategyProvider: strategyProvider - ) } internal convenience init( @@ -45,7 +41,7 @@ public final class Hook { selector: Selector, build: HookBuilder ) throws { - let strategyProvider: (Hook) -> any HookStrategy = { hook in + try self.init { hook in let hookProxy = HookProxy( selector: selector, originalProvider: { @@ -59,32 +55,16 @@ public final class Hook { let hookBlock = build(hookProxy) let hookIMP = imp_implementationWithBlock(hookBlock) - return ObjectHookStrategy( + return try ObjectHookStrategy( object: object, selector: selector, hookIMP: hookIMP ) } - - try self.init( - class: type(of: object), - selector: selector, - strategyProvider: strategyProvider - ) } - private init( - `class`: AnyClass, - selector: Selector, - strategyProvider: (Hook) -> HookStrategy - ) throws { - self.selector = selector - self.class = `class` - - // Check if method exists - try validate() - - self._strategy = strategyProvider(self) + private init(strategyProvider: (Hook) throws -> HookStrategy) rethrows { + self._strategy = try strategyProvider(self) } // ============================================================================ // @@ -92,21 +72,31 @@ public final class Hook { // ============================================================================ // /// The class whose instance method is being interposed. - public let `class`: AnyClass + public var `class`: AnyClass { + self.strategy.class + } + + public var scope: HookScope { + self.strategy.scope + } /// The selector identifying the instance method being interposed. - public let selector: Selector + public var selector: Selector { + self.strategy.selector + } /// The current state of the hook. public internal(set) var state = HookState.pending private var _strategy: HookStrategy! - private var strategy: HookStrategy { _strategy } + private var strategy: HookStrategy { + self._strategy + } /// The effective original implementation of the hook. Might be looked up at runtime. /// Do not cache this. - var originalIMP: IMP? { + internal var originalIMP: IMP? { self.strategy.originalIMP } diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy.swift index 49d5f75..2528cf3 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy.swift @@ -6,13 +6,16 @@ final class ClassHookStrategy: HookStrategy { `class`: AnyClass, selector: Selector, hookIMP: IMP - ) { + ) throws { self.class = `class` self.selector = selector self.hookIMP = hookIMP + + try self.validate() } let `class`: AnyClass + var scope: HookScope { .class } let selector: Selector let hookIMP: IMP private(set) var originalIMP: IMP? diff --git a/Sources/InterposeKit/Hooks/HookStrategy/HookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/HookStrategy.swift index 8ebf5df..60fe45d 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/HookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/HookStrategy.swift @@ -4,10 +4,8 @@ import ObjectiveC internal protocol HookStrategy: AnyObject, CustomDebugStringConvertible { - /// The class whose instance method is being interposed. var `class`: AnyClass { get } - - /// /// The selector identifying the instance method being interposed. + var scope: HookScope { get } var selector: Selector { get } /// The implementation used to interpose the method, created during hook setup and used @@ -19,11 +17,21 @@ internal protocol HookStrategy: AnyObject, CustomDebugStringConvertible { var originalIMP: IMP? { get } func replaceImplementation() throws - func restoreImplementation() throws } +extension HookStrategy { + + /// Validates that the target method exists on the class, throwing if not found. + internal func validate() throws { + guard class_getInstanceMethod(self.class, self.selector) != nil else { + throw InterposeError.methodNotFound(self.class, self.selector) + } + } + +} + extension HookStrategy { /// Dynamically resolves the current IMP of the hooked method by walking the class hierarchy. diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift index c95fbeb..44478fe 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift @@ -6,20 +6,23 @@ final class ObjectHookStrategy: HookStrategy { object: AnyObject, selector: Selector, hookIMP: IMP - ) { - self.object = object + ) throws { self.class = type(of: object) + self.object = object self.selector = selector self.hookIMP = hookIMP + + try self.validate() ObjectHookRegistry.register(self.handle, for: hookIMP) } - let object: AnyObject let `class`: AnyClass + let object: AnyObject + var scope: HookScope { .object(self.object) } let selector: Selector - let hookIMP: IMP + let hookIMP: IMP var storedOriginalIMP: IMP? private lazy var handle = ObjectHookHandle( From 8ae9fade84214422038f8caa03c98157f6564104 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 26 Mar 2025 19:48:04 +0100 Subject: [PATCH 41/70] Improve initializers in Hook --- Sources/InterposeKit/Hooks/Hook.swift | 71 +++++++++++++++------------ 1 file changed, 40 insertions(+), 31 deletions(-) diff --git a/Sources/InterposeKit/Hooks/Hook.swift b/Sources/InterposeKit/Hooks/Hook.swift index 724dbef..6424f7a 100644 --- a/Sources/InterposeKit/Hooks/Hook.swift +++ b/Sources/InterposeKit/Hooks/Hook.swift @@ -14,26 +14,11 @@ public final class Hook { selector: Selector, build: HookBuilder ) throws { - try self.init { hook in - let hookProxy = HookProxy( - selector: selector, - originalProvider: { - unsafeBitCast( - hook.originalIMP, - to: MethodSignature.self - ) - } - ) - - let hookBlock = build(hookProxy) - let hookIMP = imp_implementationWithBlock(hookBlock) - - return try ClassHookStrategy( - class: `class`, - selector: selector, - hookIMP: hookIMP - ) - } + try self.init( + target: .class(`class`), + selector: selector, + build: build + ) } internal convenience init( @@ -41,12 +26,24 @@ public final class Hook { selector: Selector, build: HookBuilder ) throws { - try self.init { hook in + try self.init( + target: .object(object), + selector: selector, + build: build + ) + } + + private init( + target: HookTarget, + selector: Selector, + build: HookBuilder + ) throws { + func makeStrategy(_ hook: Hook) throws -> HookStrategy { let hookProxy = HookProxy( selector: selector, originalProvider: { unsafeBitCast( - hook.originalIMP, + self.originalIMP, to: MethodSignature.self ) } @@ -55,16 +52,23 @@ public final class Hook { let hookBlock = build(hookProxy) let hookIMP = imp_implementationWithBlock(hookBlock) - return try ObjectHookStrategy( - object: object, - selector: selector, - hookIMP: hookIMP - ) + switch target { + case .class(let `class`): + return try ClassHookStrategy( + class: `class`, + selector: selector, + hookIMP: hookIMP + ) + case .object(let object): + return try ObjectHookStrategy( + object: object, + selector: selector, + hookIMP: hookIMP + ) + } } - } - - private init(strategyProvider: (Hook) throws -> HookStrategy) rethrows { - self._strategy = try strategyProvider(self) + + self._strategy = try makeStrategy(self) } // ============================================================================ // @@ -155,3 +159,8 @@ extension Hook: CustomDebugStringConvertible { self.strategy.debugDescription } } + +fileprivate enum HookTarget { + case `class`(AnyClass) + case object(AnyObject) +} From 7b3272a26ec0fddd15d727b3307746a20eed6592 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 26 Mar 2025 20:05:18 +0100 Subject: [PATCH 42/70] `HookStrategy.originalIMP` is no longer optional --- .../HookStrategy/ClassHookStrategy.swift | 8 ++-- .../Hooks/HookStrategy/HookStrategy.swift | 37 +++++++++++++++---- .../ObjectHookStrategy.swift | 17 +-------- 3 files changed, 35 insertions(+), 27 deletions(-) diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy.swift index 2528cf3..3602ef3 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy.swift @@ -18,7 +18,7 @@ final class ClassHookStrategy: HookStrategy { var scope: HookScope { .class } let selector: Selector let hookIMP: IMP - private(set) var originalIMP: IMP? + private(set) var storedOriginalIMP: IMP? func replaceImplementation() throws { guard let method = class_getInstanceMethod(self.class, self.selector) else { @@ -34,7 +34,7 @@ final class ClassHookStrategy: HookStrategy { throw InterposeError.nonExistingImplementation(self.class, self.selector) } - self.originalIMP = originalIMP + self.storedOriginalIMP = originalIMP Interpose.log("Swizzled -[\(self.class).\(self.selector)] IMP: \(originalIMP) -> \(self.hookIMP)") } @@ -44,7 +44,7 @@ final class ClassHookStrategy: HookStrategy { throw InterposeError.methodNotFound(self.class, self.selector) } - guard let originalIMP = self.originalIMP else { + guard let originalIMP = self.storedOriginalIMP else { // Ignore? Throw error? fatalError("The original implementation should be loaded when resetting") } @@ -67,6 +67,6 @@ final class ClassHookStrategy: HookStrategy { extension ClassHookStrategy: CustomDebugStringConvertible { internal var debugDescription: String { - "\(self.selector) → \(String(describing: self.originalIMP))" + "\(self.selector) → \(String(describing: self.storedOriginalIMP))" } } diff --git a/Sources/InterposeKit/Hooks/HookStrategy/HookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/HookStrategy.swift index 60fe45d..9c5750c 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/HookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/HookStrategy.swift @@ -1,7 +1,5 @@ import ObjectiveC -// TODO: Make originalIMP non-optional - internal protocol HookStrategy: AnyObject, CustomDebugStringConvertible { var `class`: AnyClass { get } @@ -12,9 +10,9 @@ internal protocol HookStrategy: AnyObject, CustomDebugStringConvertible { /// to replace the original implementation while the hook is applied. var hookIMP: IMP { get } - /// The original method implementation active before the hook is applied, restored when - /// the hook is reverted. - var originalIMP: IMP? { get } + /// The original implementation captured when the hook is applied, restored when the hook + /// is reverted. + var storedOriginalIMP: IMP? { get } func replaceImplementation() throws func restoreImplementation() throws @@ -34,8 +32,33 @@ extension HookStrategy { extension HookStrategy { - /// Dynamically resolves the current IMP of the hooked method by walking the class hierarchy. - /// This may return either the original or a hook IMP, depending on the state of the hook. + /// 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 + } + + fatalError() + } + + /// 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 diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift index 44478fe..ff90b2e 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift @@ -23,28 +23,13 @@ final class ObjectHookStrategy: HookStrategy { let selector: Selector let hookIMP: IMP - var storedOriginalIMP: IMP? + private(set) var storedOriginalIMP: IMP? private lazy var handle = ObjectHookHandle( getOriginalIMP: { self.storedOriginalIMP }, setOriginalIMP: { self.storedOriginalIMP = $0 } ) - /// The original implementation of the hook. Might be looked up at runtime. Do not cache this. - /// Actually not optional… - var originalIMP: IMP? { - // If we switched implementations, return stored. - if let storedOrigIMP = self.storedOriginalIMP { - return storedOrigIMP - } - // Else, perform a dynamic lookup - guard let origIMP = self.lookUpIMP() else { - InterposeError.nonExistingImplementation(`class`, selector).log() - preconditionFailure("IMP must be found for call") - } - return origIMP - } - /// Subclass that we create on the fly var interposeSubclass: InterposeSubclass? From e2b1422892612d7feb10e56d528a5218fc9a6018 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 26 Mar 2025 20:08:37 +0100 Subject: [PATCH 43/70] =?UTF-8?q?Eliminated=20obsolete=20`Hook.validate(?= =?UTF-8?q?=E2=80=A6)`=20method?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Sources/InterposeKit/Hooks/Hook.swift | 11 ----------- Sources/InterposeKit/Interpose.swift | 10 ++++++---- 2 files changed, 6 insertions(+), 15 deletions(-) diff --git a/Sources/InterposeKit/Hooks/Hook.swift b/Sources/InterposeKit/Hooks/Hook.swift index 6424f7a..20e23a6 100644 --- a/Sources/InterposeKit/Hooks/Hook.swift +++ b/Sources/InterposeKit/Hooks/Hook.swift @@ -118,17 +118,6 @@ public final class Hook { } } - /// Validate that the selector exists on the active class. - @discardableResult func validate(expectedState: HookState = .pending) 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: HookState, task: () throws -> Void) throws { do { try task() diff --git a/Sources/InterposeKit/Interpose.swift b/Sources/InterposeKit/Interpose.swift index 1727efc..8fedd22 100644 --- a/Sources/InterposeKit/Interpose.swift +++ b/Sources/InterposeKit/Interpose.swift @@ -113,12 +113,14 @@ final public class Interpose { 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) + for hook in self.hooks { + if hook.state != expectedState { + throw InterposeError.invalidState(expectedState: expectedState) + } } + // Execute all tasks try hooks.forEach(executor) return self From 68369496d0a8060d566200ae0e52790fd5df4a1a Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 26 Mar 2025 20:19:01 +0100 Subject: [PATCH 44/70] Hook: Improved file structure --- Sources/InterposeKit/Hooks/Hook.swift | 84 +++++++++++++++++---------- 1 file changed, 52 insertions(+), 32 deletions(-) diff --git a/Sources/InterposeKit/Hooks/Hook.swift b/Sources/InterposeKit/Hooks/Hook.swift index 20e23a6..af3641a 100644 --- a/Sources/InterposeKit/Hooks/Hook.swift +++ b/Sources/InterposeKit/Hooks/Hook.swift @@ -1,7 +1,5 @@ import Foundation -// TODO: Make originalIMP private - /// A runtime hook that interposes a single instance method on a class or object. public final class Hook { @@ -72,7 +70,7 @@ public final class Hook { } // ============================================================================ // - // MARK: ... + // MARK: Target Info // ============================================================================ // /// The class whose instance method is being interposed. @@ -88,47 +86,76 @@ public final class Hook { public var selector: Selector { self.strategy.selector } + + // ============================================================================ // + // MARK: State + // ============================================================================ // /// The current state of the hook. public internal(set) var state = HookState.pending - private var _strategy: HookStrategy! - - private var strategy: HookStrategy { - self._strategy - } - - /// The effective original implementation of the hook. Might be looked up at runtime. - /// Do not cache this. - internal var originalIMP: IMP? { - self.strategy.originalIMP - } + // ============================================================================ // + // MARK: Applying & Reverting + // ============================================================================ // /// Applies the hook by interposing the method implementation. public func apply() throws { - try execute(newState: .active) { + 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 { - try execute(newState: .pending) { + guard self.state == .active else { return } + + do { try self.strategy.restoreImplementation() + self.state = .pending + } catch { + self.state = .failed + throw error } } + + // ============================================================================ // + // MARK: Original Implementation + // ============================================================================ // - private func execute(newState: HookState, task: () throws -> Void) throws { - do { - try task() - state = newState - } catch let error as InterposeError { - state = .failed - throw error - } + // TODO: Make originalIMP private + + /// The effective original implementation of the hook. Might be looked up at runtime. + /// Do not cache this. + internal var originalIMP: IMP? { + self.strategy.originalIMP } + + // ============================================================================ // + // MARK: Underlying Strategy + // ============================================================================ // + + private var _strategy: HookStrategy! + + private var strategy: HookStrategy { + self._strategy + } + +} + +extension Hook: CustomDebugStringConvertible { + public var debugDescription: String { + self.strategy.debugDescription + } +} - // TODO: Rename to `cleanUp()` +// TODO: Try to make clean-up automatic in deinit +extension Hook { public func cleanup() { switch state { case .pending: @@ -140,13 +167,6 @@ public final class Hook { Interpose.log("Leaking -[\(`class`).\(selector)] IMP: \(self.strategy.hookIMP)") } } - -} - -extension Hook: CustomDebugStringConvertible { - public var debugDescription: String { - self.strategy.debugDescription - } } fileprivate enum HookTarget { From f7956cc8ec4c4d9f10150fd3e1971afe254c48df Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 26 Mar 2025 20:24:19 +0100 Subject: [PATCH 45/70] Tweaks strategy storage --- Sources/InterposeKit/Hooks/Hook.swift | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Sources/InterposeKit/Hooks/Hook.swift b/Sources/InterposeKit/Hooks/Hook.swift index af3641a..3b022d8 100644 --- a/Sources/InterposeKit/Hooks/Hook.swift +++ b/Sources/InterposeKit/Hooks/Hook.swift @@ -66,7 +66,7 @@ public final class Hook { } } - self._strategy = try makeStrategy(self) + self.strategy = try makeStrategy(self) } // ============================================================================ // @@ -139,12 +139,12 @@ public final class Hook { // ============================================================================ // // MARK: Underlying Strategy // ============================================================================ // - - private var _strategy: HookStrategy! - - private var strategy: HookStrategy { - self._strategy - } + + /// The active strategy used to interpose and manage the method implementation. + /// + /// This is an implicitly unwrapped optional, assigned immediately after initialization, + /// as constructing the strategy requires `self` to build the hook proxy. + private var strategy: HookStrategy! } From 12b79386e5cb5c786346c3f8f52f2aee0d3a0d5e Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 26 Mar 2025 20:26:38 +0100 Subject: [PATCH 46/70] Moved hook-related types to Hook.swift --- Sources/InterposeKit/Hooks/Hook+.swift | 44 ----------------------- Sources/InterposeKit/Hooks/Hook.swift | 49 ++++++++++++++++++++++++-- 2 files changed, 47 insertions(+), 46 deletions(-) delete mode 100644 Sources/InterposeKit/Hooks/Hook+.swift diff --git a/Sources/InterposeKit/Hooks/Hook+.swift b/Sources/InterposeKit/Hooks/Hook+.swift deleted file mode 100644 index c3ddc92..0000000 --- a/Sources/InterposeKit/Hooks/Hook+.swift +++ /dev/null @@ -1,44 +0,0 @@ -import ObjectiveC - -public typealias HookBuilder = (HookProxy) -> HookSignature - -public final class HookProxy { - - internal init( - selector: Selector, - originalProvider: @escaping () -> MethodSignature - ) { - self.selector = selector - self.originalProvider = originalProvider - } - - public let selector: Selector - - private let originalProvider: () -> MethodSignature - - public var original: MethodSignature { self.originalProvider() } - -} - -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(AnyObject) - -} - -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 - -} diff --git a/Sources/InterposeKit/Hooks/Hook.swift b/Sources/InterposeKit/Hooks/Hook.swift index 3b022d8..2a05f51 100644 --- a/Sources/InterposeKit/Hooks/Hook.swift +++ b/Sources/InterposeKit/Hooks/Hook.swift @@ -1,4 +1,4 @@ -import Foundation +import ObjectiveC /// A runtime hook that interposes a single instance method on a class or object. public final class Hook { @@ -39,7 +39,7 @@ public final class Hook { func makeStrategy(_ hook: Hook) throws -> HookStrategy { let hookProxy = HookProxy( selector: selector, - originalProvider: { + getOriginal: { unsafeBitCast( self.originalIMP, to: MethodSignature.self @@ -169,6 +169,51 @@ extension Hook { } } +public typealias HookBuilder = (HookProxy) -> HookSignature + +public final class HookProxy { + + internal init( + selector: Selector, + getOriginal: @escaping () -> MethodSignature + ) { + self.selector = selector + self._getOriginal = getOriginal + } + + public let selector: Selector + + private let _getOriginal: () -> MethodSignature + + public var original: MethodSignature { + self._getOriginal() + } + +} + +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(AnyObject) + +} + +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 + +} + fileprivate enum HookTarget { case `class`(AnyClass) case object(AnyObject) From 56a082a7973e625c8b45f3952d7a6bacc0ce8403 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Wed, 26 Mar 2025 20:30:24 +0100 Subject: [PATCH 47/70] Initial refactoring in InterposeSubclass --- Sources/InterposeKit/Internal/InterposeSubclass.swift | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Sources/InterposeKit/Internal/InterposeSubclass.swift b/Sources/InterposeKit/Internal/InterposeSubclass.swift index 3f2264a..9bfa064 100644 --- a/Sources/InterposeKit/Internal/InterposeSubclass.swift +++ b/Sources/InterposeKit/Internal/InterposeSubclass.swift @@ -32,11 +32,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)! @@ -68,7 +67,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,7 +75,7 @@ class InterposeSubclass { return nil } - private func replaceGetClass(in class: AnyClass, decoy perceivedClass: AnyClass) { + private static func replaceGetClass(in class: AnyClass, decoy perceivedClass: AnyClass) { let getClass: @convention(block) (AnyObject) -> AnyClass = { _ in perceivedClass } From 57d2a7a73d1303bda9eed1706eca325d033522e8 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Thu, 27 Mar 2025 08:43:57 +0100 Subject: [PATCH 48/70] Improved docs --- .../Deprecated/NSObject+Deprecated.swift | 2 +- Sources/InterposeKit/Hooks/Hook.swift | 18 +++++++++++-- Sources/InterposeKit/NSObject+Interpose.swift | 27 ++++++++----------- 3 files changed, 28 insertions(+), 19 deletions(-) diff --git a/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift b/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift index 3447864..a90c3b1 100644 --- a/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift +++ b/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift @@ -18,7 +18,7 @@ extension NSObject { ) } - @available(*, deprecated, renamed: "addHook(for:methodSignature:hookSignature:build:)") + @available(*, deprecated, renamed: "applyHook(for:methodSignature:hookSignature:build:)") @discardableResult public func hook ( _ selector: Selector, diff --git a/Sources/InterposeKit/Hooks/Hook.swift b/Sources/InterposeKit/Hooks/Hook.swift index 2a05f51..c1c4aa7 100644 --- a/Sources/InterposeKit/Hooks/Hook.swift +++ b/Sources/InterposeKit/Hooks/Hook.swift @@ -169,8 +169,20 @@ extension Hook { } } +/// 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) (AnyObject, Selector, Params…) -> ReturnValue).self`. +/// +/// `HookSignature` is the block type used as the replacement, typically in the form: +/// `(@convention(block) (AnyObject, 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( @@ -181,14 +193,16 @@ public final class HookProxy { self._getOriginal = getOriginal } + /// The selector of the method being hooked. public let selector: Selector - private let _getOriginal: () -> MethodSignature - + /// 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 { diff --git a/Sources/InterposeKit/NSObject+Interpose.swift b/Sources/InterposeKit/NSObject+Interpose.swift index 2dc8313..5d983aa 100644 --- a/Sources/InterposeKit/NSObject+Interpose.swift +++ b/Sources/InterposeKit/NSObject+Interpose.swift @@ -1,15 +1,11 @@ import ObjectiveC -// TODO: Rename `addHook(…)` to `applyHook(…)` -// TODO: Revise documentation (implementation builder) - extension NSObject { - /// Installs a hook for the specified Objective-C selector on this object instance. + /// Installs a hook for the specified 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. + /// 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`. @@ -17,19 +13,18 @@ extension NSObject { /// - 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. + /// - 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, allowing to remove the hook later by calling `hook.revert()`. + /// - Returns: The installed hook, which can later be reverted by calling `try 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. + /// - 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.addHook( + /// let hook = try object.applyHook( /// for: #selector(MyClass.someMethod), /// methodSignature: (@convention(c) (NSObject, Selector, Int) -> Void).self, /// hookSignature: (@convention(block) (NSObject, Int) -> Void).self @@ -39,7 +34,7 @@ extension NSObject { /// } /// } /// - /// hook.revert() + /// try hook.revert() /// ``` @discardableResult public func applyHook( From b7d3e57211ffcb7cac61e4b973870547d8e362e7 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Thu, 27 Mar 2025 07:45:51 +0100 Subject: [PATCH 49/70] Moved files around --- .../HookStrategy/{ => ClassHookStrategy}/ClassHookStrategy.swift | 0 .../HookStrategy/ObjectHookStrategy}/InterposeSubclass.swift | 0 2 files changed, 0 insertions(+), 0 deletions(-) rename Sources/InterposeKit/Hooks/HookStrategy/{ => ClassHookStrategy}/ClassHookStrategy.swift (100%) rename Sources/InterposeKit/{Internal => Hooks/HookStrategy/ObjectHookStrategy}/InterposeSubclass.swift (100%) diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy/ClassHookStrategy.swift similarity index 100% rename from Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy.swift rename to Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy/ClassHookStrategy.swift diff --git a/Sources/InterposeKit/Internal/InterposeSubclass.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/InterposeSubclass.swift similarity index 100% rename from Sources/InterposeKit/Internal/InterposeSubclass.swift rename to Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/InterposeSubclass.swift From b870a224702ff5c0d049250420a3110be2bb0d2b Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Thu, 27 Mar 2025 08:20:27 +0100 Subject: [PATCH 50/70] `class_setPerceivedClass(for:to:)` --- .../Deprecated/Interpose+Deprecated.swift | 4 +-- .../InterposeSubclass.swift | 23 +------------- .../Utilities/class_setPerceivedClass.swift | 30 +++++++++++++++++++ .../ClassSetPerceivedClassTests.swift | 25 ++++++++++++++++ 4 files changed, 58 insertions(+), 24 deletions(-) create mode 100644 Sources/InterposeKit/Utilities/class_setPerceivedClass.swift create mode 100644 Tests/InterposeKitTests/ClassSetPerceivedClassTests.swift diff --git a/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift b/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift index 840c7bb..5e1af52 100644 --- a/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift +++ b/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift @@ -1,4 +1,4 @@ -import Foundation +import ObjectiveC extension Interpose { @@ -11,7 +11,7 @@ extension Interpose { _ build: HookBuilder ) throws -> Hook { try self.hook( - NSSelectorFromString(selectorName), + Selector(selectorName), methodSignature: methodSignature, hookSignature: hookSignature, build diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/InterposeSubclass.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/InterposeSubclass.swift index 9bfa064..95facf8 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/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 @@ -50,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 } @@ -75,15 +63,6 @@ class InterposeSubclass { return nil } - private static 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/Utilities/class_setPerceivedClass.swift b/Sources/InterposeKit/Utilities/class_setPerceivedClass.swift new file mode 100644 index 0000000..5df7c99 --- /dev/null +++ b/Sources/InterposeKit/Utilities/class_setPerceivedClass.swift @@ -0,0 +1,30 @@ +import ObjectiveC + +/// Replaces the `class` method on a class and its metaclass to return a perceived class. +/// +/// This causes both instance-level and class-level calls to `[object class]` or `[Class class]` +/// to return the given `perceivedClass`, effectively making the target class pose as another. +/// +/// - 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/Tests/InterposeKitTests/ClassSetPerceivedClassTests.swift b/Tests/InterposeKitTests/ClassSetPerceivedClassTests.swift new file mode 100644 index 0000000..adbb2b1 --- /dev/null +++ b/Tests/InterposeKitTests/ClassSetPerceivedClassTests.swift @@ -0,0 +1,25 @@ +@testable import InterposeKit +import XCTest + +fileprivate class RealClass: NSObject {} +fileprivate class FakeClass: NSObject {} + +final class ClassSetPerceivedClassTests: XCTestCase { + + func test() { + let object = RealClass() + + XCTAssertTrue(object.perform(Selector((("class"))))?.takeUnretainedValue() === RealClass.self) + XCTAssertTrue(object_getClass(object) === RealClass.self) + + XCTAssertTrue(RealClass.perform(Selector((("class"))))?.takeUnretainedValue() === RealClass.self) + + class_setPerceivedClass(for: RealClass.self, to: FakeClass.self) + + XCTAssertTrue(object.perform(Selector((("class"))))?.takeUnretainedValue() === FakeClass.self) + XCTAssertTrue(object_getClass(object) === RealClass.self) + + XCTAssertTrue(RealClass.perform(Selector((("class"))))?.takeUnretainedValue() === FakeClass.self) + } + +} From 88ffe001891948c0271e3a0a5c8703bf8fd5451c Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Thu, 27 Mar 2025 09:52:21 +0100 Subject: [PATCH 51/70] Tweaks to `Hook.originalImp` --- Sources/InterposeKit/Hooks/Hook.swift | 13 ++++++++----- .../Hooks/HookStrategy/HookStrategy.swift | 7 ++++++- .../InterposeKitTests/HookDynamicLookupTests.swift | 13 +++++++++++-- 3 files changed, 25 insertions(+), 8 deletions(-) diff --git a/Sources/InterposeKit/Hooks/Hook.swift b/Sources/InterposeKit/Hooks/Hook.swift index c1c4aa7..4901a37 100644 --- a/Sources/InterposeKit/Hooks/Hook.swift +++ b/Sources/InterposeKit/Hooks/Hook.swift @@ -128,11 +128,14 @@ public final class Hook { // MARK: Original Implementation // ============================================================================ // - // TODO: Make originalIMP private - - /// The effective original implementation of the hook. Might be looked up at runtime. - /// Do not cache this. - internal var originalIMP: IMP? { + /// 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 } diff --git a/Sources/InterposeKit/Hooks/HookStrategy/HookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/HookStrategy.swift index 9c5750c..60a632c 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/HookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/HookStrategy.swift @@ -49,7 +49,12 @@ extension HookStrategy { return originalIMP } - fatalError() + fatalError( + """ + 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 diff --git a/Tests/InterposeKitTests/HookDynamicLookupTests.swift b/Tests/InterposeKitTests/HookDynamicLookupTests.swift index 3f13036..bfaaee2 100644 --- a/Tests/InterposeKitTests/HookDynamicLookupTests.swift +++ b/Tests/InterposeKitTests/HookDynamicLookupTests.swift @@ -21,7 +21,6 @@ class HookDynamicLookupTests: XCTestCase { object: object, selector: #selector(ExampleClass.greet(name:)), build: { (hook: HookProxy) -> HookSignature in - // Build a replacement block that calls the original implementation via the hook proxy. return { `self`, name in return hook.original(self, hook.selector, name) } @@ -29,7 +28,7 @@ class HookDynamicLookupTests: XCTestCase { ) // Force the dynamic lookup path by ensuring no original IMP has been cached. - // The following call will use `lookupOrigIMP` to find the method implementation. + // 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 @@ -38,5 +37,15 @@ class HookDynamicLookupTests: XCTestCase { // 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!") } } From af3e3aceda6ac715523ad10060ec9b14834e8cf7 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Thu, 27 Mar 2025 10:13:31 +0100 Subject: [PATCH 52/70] Initial change to KVO detection --- Sources/InterposeKit/Interpose.swift | 7 +- .../InterposeKit/Utilities/NSObject+KVO.swift | 7 + Tests/InterposeKitTests/KVOTests.swift | 120 +++++++++--------- 3 files changed, 68 insertions(+), 66 deletions(-) create mode 100644 Sources/InterposeKit/Utilities/NSObject+KVO.swift diff --git a/Sources/InterposeKit/Interpose.swift b/Sources/InterposeKit/Interpose.swift index 8fedd22..7c125ff 100644 --- a/Sources/InterposeKit/Interpose.swift +++ b/Sources/InterposeKit/Interpose.swift @@ -24,11 +24,6 @@ final public class Interpose { 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 { @@ -47,7 +42,7 @@ final public class Interpose { self.class = type(of: object) if let actualClass = checkObjectPosingAsDifferentClass(object) { - if isKVORuntimeGeneratedClass(actualClass) { + if object.isKeyValueObserved && NSStringFromClass(actualClass).contains("NSKVONotifying") { throw InterposeError.keyValueObservationDetected(object) } else { throw InterposeError.objectPosingAsDifferentClass(object, actualClass: actualClass) diff --git a/Sources/InterposeKit/Utilities/NSObject+KVO.swift b/Sources/InterposeKit/Utilities/NSObject+KVO.swift new file mode 100644 index 0000000..3fa4dd2 --- /dev/null +++ b/Sources/InterposeKit/Utilities/NSObject+KVO.swift @@ -0,0 +1,7 @@ +import ObjectiveC + +extension NSObject { + internal var isKeyValueObserved: Bool { + self.value(forKey: "_isKVOA") as? Bool ?? false + } +} diff --git a/Tests/InterposeKitTests/KVOTests.swift b/Tests/InterposeKitTests/KVOTests.swift index 7cb9cdb..f8fbc9e 100644 --- a/Tests/InterposeKitTests/KVOTests.swift +++ b/Tests/InterposeKitTests/KVOTests.swift @@ -2,63 +2,63 @@ 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) -// } -// } -//} +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(testObj), expected: 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) + } + } +} From 2e113380698353668caa026b6329040862f097fe Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Fri, 28 Mar 2025 11:06:03 +0100 Subject: [PATCH 53/70] =?UTF-8?q?Tweaked=20docs=20for=20`class=5FsetPercei?= =?UTF-8?q?vedClass(=E2=80=A6)`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../InterposeKit/Utilities/class_setPerceivedClass.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Sources/InterposeKit/Utilities/class_setPerceivedClass.swift b/Sources/InterposeKit/Utilities/class_setPerceivedClass.swift index 5df7c99..8f7aa02 100644 --- a/Sources/InterposeKit/Utilities/class_setPerceivedClass.swift +++ b/Sources/InterposeKit/Utilities/class_setPerceivedClass.swift @@ -1,9 +1,12 @@ import ObjectiveC -/// Replaces the `class` method on a class and its metaclass to return a perceived class. +/// Overrides the `class` method on a class and its metaclass to return a perceived class. /// -/// This causes both instance-level and class-level calls to `[object class]` or `[Class class]` -/// to return the given `perceivedClass`, effectively making the target class pose as another. +/// 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. From 014c7edb0a725f56f371e930a26518550e574146 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Fri, 28 Mar 2025 12:24:46 +0100 Subject: [PATCH 54/70] Formalized KVO check --- Sources/InterposeKit/Interpose.swift | 2 +- .../InterposeKit/Utilities/NSObject+KVO.swift | 7 ------ .../Utilities/object_isKVOActive.swift | 24 +++++++++++++++++++ 3 files changed, 25 insertions(+), 8 deletions(-) delete mode 100644 Sources/InterposeKit/Utilities/NSObject+KVO.swift create mode 100644 Sources/InterposeKit/Utilities/object_isKVOActive.swift diff --git a/Sources/InterposeKit/Interpose.swift b/Sources/InterposeKit/Interpose.swift index 7c125ff..e40716f 100644 --- a/Sources/InterposeKit/Interpose.swift +++ b/Sources/InterposeKit/Interpose.swift @@ -42,7 +42,7 @@ final public class Interpose { self.class = type(of: object) if let actualClass = checkObjectPosingAsDifferentClass(object) { - if object.isKeyValueObserved && NSStringFromClass(actualClass).contains("NSKVONotifying") { + if object_isKVOActive(object) { throw InterposeError.keyValueObservationDetected(object) } else { throw InterposeError.objectPosingAsDifferentClass(object, actualClass: actualClass) diff --git a/Sources/InterposeKit/Utilities/NSObject+KVO.swift b/Sources/InterposeKit/Utilities/NSObject+KVO.swift deleted file mode 100644 index 3fa4dd2..0000000 --- a/Sources/InterposeKit/Utilities/NSObject+KVO.swift +++ /dev/null @@ -1,7 +0,0 @@ -import ObjectiveC - -extension NSObject { - internal var isKeyValueObserved: Bool { - self.value(forKey: "_isKVOA") as? Bool ?? false - } -} 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 +} From 8f603b586cc0ac0f1de46475488d7408eb60d583 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Fri, 28 Mar 2025 12:31:55 +0100 Subject: [PATCH 55/70] Marked inherited tests for future polishing --- .../ClassSetPerceivedClassTests.swift | 25 ------------- .../ClassMethodInterposeTests.swift | 0 .../HookDynamicLookupTests.swift | 0 .../InterposeKitTestCase.swift | 0 .../InterposeKitTests.swift | 0 .../{ => ToBePolished}/KVOTests.swift | 12 +++---- .../MultipleInterposing.swift | 0 .../ObjectInterposeTests.swift | 0 .../{ => ToBePolished}/TestClass.swift | 0 Tests/InterposeKitTests/UtilitiesTests.swift | 35 +++++++++++++++++++ 10 files changed, 41 insertions(+), 31 deletions(-) delete mode 100644 Tests/InterposeKitTests/ClassSetPerceivedClassTests.swift rename Tests/InterposeKitTests/{ => ToBePolished}/ClassMethodInterposeTests.swift (100%) rename Tests/InterposeKitTests/{ => ToBePolished}/HookDynamicLookupTests.swift (100%) rename Tests/InterposeKitTests/{ => ToBePolished}/InterposeKitTestCase.swift (100%) rename Tests/InterposeKitTests/{ => ToBePolished}/InterposeKitTests.swift (100%) rename Tests/InterposeKitTests/{ => ToBePolished}/KVOTests.swift (92%) rename Tests/InterposeKitTests/{ => ToBePolished}/MultipleInterposing.swift (100%) rename Tests/InterposeKitTests/{ => ToBePolished}/ObjectInterposeTests.swift (100%) rename Tests/InterposeKitTests/{ => ToBePolished}/TestClass.swift (100%) create mode 100644 Tests/InterposeKitTests/UtilitiesTests.swift diff --git a/Tests/InterposeKitTests/ClassSetPerceivedClassTests.swift b/Tests/InterposeKitTests/ClassSetPerceivedClassTests.swift deleted file mode 100644 index adbb2b1..0000000 --- a/Tests/InterposeKitTests/ClassSetPerceivedClassTests.swift +++ /dev/null @@ -1,25 +0,0 @@ -@testable import InterposeKit -import XCTest - -fileprivate class RealClass: NSObject {} -fileprivate class FakeClass: NSObject {} - -final class ClassSetPerceivedClassTests: XCTestCase { - - func test() { - let object = RealClass() - - XCTAssertTrue(object.perform(Selector((("class"))))?.takeUnretainedValue() === RealClass.self) - XCTAssertTrue(object_getClass(object) === RealClass.self) - - XCTAssertTrue(RealClass.perform(Selector((("class"))))?.takeUnretainedValue() === RealClass.self) - - class_setPerceivedClass(for: RealClass.self, to: FakeClass.self) - - XCTAssertTrue(object.perform(Selector((("class"))))?.takeUnretainedValue() === FakeClass.self) - XCTAssertTrue(object_getClass(object) === RealClass.self) - - XCTAssertTrue(RealClass.perform(Selector((("class"))))?.takeUnretainedValue() === FakeClass.self) - } - -} diff --git a/Tests/InterposeKitTests/ClassMethodInterposeTests.swift b/Tests/InterposeKitTests/ToBePolished/ClassMethodInterposeTests.swift similarity index 100% rename from Tests/InterposeKitTests/ClassMethodInterposeTests.swift rename to Tests/InterposeKitTests/ToBePolished/ClassMethodInterposeTests.swift diff --git a/Tests/InterposeKitTests/HookDynamicLookupTests.swift b/Tests/InterposeKitTests/ToBePolished/HookDynamicLookupTests.swift similarity index 100% rename from Tests/InterposeKitTests/HookDynamicLookupTests.swift rename to Tests/InterposeKitTests/ToBePolished/HookDynamicLookupTests.swift diff --git a/Tests/InterposeKitTests/InterposeKitTestCase.swift b/Tests/InterposeKitTests/ToBePolished/InterposeKitTestCase.swift similarity index 100% rename from Tests/InterposeKitTests/InterposeKitTestCase.swift rename to Tests/InterposeKitTests/ToBePolished/InterposeKitTestCase.swift diff --git a/Tests/InterposeKitTests/InterposeKitTests.swift b/Tests/InterposeKitTests/ToBePolished/InterposeKitTests.swift similarity index 100% rename from Tests/InterposeKitTests/InterposeKitTests.swift rename to Tests/InterposeKitTests/ToBePolished/InterposeKitTests.swift diff --git a/Tests/InterposeKitTests/KVOTests.swift b/Tests/InterposeKitTests/ToBePolished/KVOTests.swift similarity index 92% rename from Tests/InterposeKitTests/KVOTests.swift rename to Tests/InterposeKitTests/ToBePolished/KVOTests.swift index f8fbc9e..1cd5875 100644 --- a/Tests/InterposeKitTests/KVOTests.swift +++ b/Tests/InterposeKitTests/ToBePolished/KVOTests.swift @@ -1,6 +1,6 @@ +@testable import InterposeKit import Foundation import XCTest -@testable import InterposeKit final class KVOTests: InterposeKitTestCase { @@ -37,12 +37,12 @@ final class KVOTests: InterposeKitTestCase { } // Hook without KVO! - let hook = try testObj.hook( - #selector(getter: TestClass.age), + let hook = try testObj.applyHook( + for: #selector(getter: TestClass.age), methodSignature: (@convention(c) (AnyObject, Selector) -> Int).self, - hookSignature: (@convention(block) (AnyObject) -> Int).self) { _ in { _ in - return 3 - } + hookSignature: (@convention(block) (AnyObject) -> Int).self + ) { _ in + return { _ in 3 } } XCTAssertEqual(testObj.age, 3) try hook.revert() diff --git a/Tests/InterposeKitTests/MultipleInterposing.swift b/Tests/InterposeKitTests/ToBePolished/MultipleInterposing.swift similarity index 100% rename from Tests/InterposeKitTests/MultipleInterposing.swift rename to Tests/InterposeKitTests/ToBePolished/MultipleInterposing.swift diff --git a/Tests/InterposeKitTests/ObjectInterposeTests.swift b/Tests/InterposeKitTests/ToBePolished/ObjectInterposeTests.swift similarity index 100% rename from Tests/InterposeKitTests/ObjectInterposeTests.swift rename to Tests/InterposeKitTests/ToBePolished/ObjectInterposeTests.swift 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..cee371a --- /dev/null +++ b/Tests/InterposeKitTests/UtilitiesTests.swift @@ -0,0 +1,35 @@ +@testable import InterposeKit +import XCTest + +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) + } + +} From e2a025aae05d7847f30d3c0cf4881def6a60c5ff Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Fri, 28 Mar 2025 12:39:10 +0100 Subject: [PATCH 56/70] =?UTF-8?q?Added=20test=20for=20`object=5FisKVOActiv?= =?UTF-8?q?e(=E2=80=A6)`?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Tests/InterposeKitTests/UtilitiesTests.swift | 23 ++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/Tests/InterposeKitTests/UtilitiesTests.swift b/Tests/InterposeKitTests/UtilitiesTests.swift index cee371a..3459f66 100644 --- a/Tests/InterposeKitTests/UtilitiesTests.swift +++ b/Tests/InterposeKitTests/UtilitiesTests.swift @@ -1,6 +1,10 @@ @testable import InterposeKit import XCTest +fileprivate class ExampleClass: NSObject { + @objc dynamic var value = 0 +} + fileprivate class RealClass: NSObject {} fileprivate class FakeClass: NSObject {} @@ -32,4 +36,23 @@ final class UtilitiesTests: XCTestCase { 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)) + } + } From 17afb9d273dce35a3e9453e37fc286ea6d0a8b10 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Fri, 28 Mar 2025 14:39:19 +0100 Subject: [PATCH 57/70] Sketched out new Interpose facade class --- .../Deprecated/NSObject+Deprecated.swift | 2 +- Sources/InterposeKit/Hooks/Hook.swift | 28 +-------- Sources/InterposeKit/Interpose.swift | 4 +- Sources/InterposeKit/NSObject+Interpose.swift | 2 +- Sources/InterposeKit/__Interpose.swift | 59 +++++++++++++++++++ 5 files changed, 65 insertions(+), 30 deletions(-) create mode 100644 Sources/InterposeKit/__Interpose.swift diff --git a/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift b/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift index a90c3b1..f63281e 100644 --- a/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift +++ b/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift @@ -52,7 +52,7 @@ extension NSObject { _ build: HookBuilder ) throws -> Hook { let hook = try Hook( - class: self as AnyClass, + target: .class(self), selector: selector, build: build ) diff --git a/Sources/InterposeKit/Hooks/Hook.swift b/Sources/InterposeKit/Hooks/Hook.swift index 4901a37..1a83bb5 100644 --- a/Sources/InterposeKit/Hooks/Hook.swift +++ b/Sources/InterposeKit/Hooks/Hook.swift @@ -7,31 +7,7 @@ public final class Hook { // MARK: Initialization // ============================================================================ // - internal convenience init( - `class`: AnyClass, - selector: Selector, - build: HookBuilder - ) throws { - try self.init( - target: .class(`class`), - selector: selector, - build: build - ) - } - - internal convenience init( - object: AnyObject, - selector: Selector, - build: HookBuilder - ) throws { - try self.init( - target: .object(object), - selector: selector, - build: build - ) - } - - private init( + internal init( target: HookTarget, selector: Selector, build: HookBuilder @@ -231,7 +207,7 @@ public enum HookState: Equatable { } -fileprivate enum HookTarget { +internal enum HookTarget { case `class`(AnyClass) case object(AnyObject) } diff --git a/Sources/InterposeKit/Interpose.swift b/Sources/InterposeKit/Interpose.swift index e40716f..046caf8 100644 --- a/Sources/InterposeKit/Interpose.swift +++ b/Sources/InterposeKit/Interpose.swift @@ -83,9 +83,9 @@ final public class Interpose { ) throws -> Hook { var hook: Hook if let object = self.object { - hook = try Hook(object: object, selector: selector, build: build) + hook = try Hook(target: .object(object), selector: selector, build: build) } else { - hook = try Hook(class: `class`, selector: selector, build: build) + hook = try Hook(target: .class(`class`), selector: selector, build: build) } hooks.append(hook) return hook diff --git a/Sources/InterposeKit/NSObject+Interpose.swift b/Sources/InterposeKit/NSObject+Interpose.swift index 5d983aa..127508f 100644 --- a/Sources/InterposeKit/NSObject+Interpose.swift +++ b/Sources/InterposeKit/NSObject+Interpose.swift @@ -44,7 +44,7 @@ extension NSObject { build: HookBuilder ) throws -> Hook { let hook = try Hook( - object: self, + target: .object(self), selector: selector, build: build ) diff --git a/Sources/InterposeKit/__Interpose.swift b/Sources/InterposeKit/__Interpose.swift new file mode 100644 index 0000000..f255533 --- /dev/null +++ b/Sources/InterposeKit/__Interpose.swift @@ -0,0 +1,59 @@ +import ObjectiveC + +public final class __Interpose { + + // ============================================================================ // + // MARK: Initialization + // ============================================================================ // + + public init(class: AnyClass) { + self.target = .class(`class`) + } + + public init(object: AnyObject) { + self.target = .object(object) + } + + // ============================================================================ // + // MARK: Configuration + // ============================================================================ // + + /// The target of the hooks created via this factory. + private let target: HookTarget + + // ============================================================================ // + // MARK: Hook Creation + // ============================================================================ // + + /// Creates and returns a hook in pending state. + public func prepareHook( + for selector: Selector, + methodSignature: MethodSignature.Type, + hookSignature: HookSignature.Type, + build: HookBuilder + ) throws -> Hook { + return try Hook( + target: self.target, + selector: selector, + build: build + ) + } + + /// Creates a hook, applies it, and returns it in one go. + @discardableResult + public func applyHook( + for selector: Selector, + methodSignature: MethodSignature.Type, + hookSignature: HookSignature.Type, + build: HookBuilder + ) throws -> Hook { + let hook = try Hook( + target: self.target, + selector: selector, + build: build + ) + try hook.apply() + return hook + } + +} From 8b8978d4e84a8beab2131e2d9ff4e253e6f29c4a Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Fri, 28 Mar 2025 14:55:10 +0100 Subject: [PATCH 58/70] Sketched out new Interpose facade class --- .../Deprecated/Interpose+Deprecated.swift | 12 +++ Sources/InterposeKit/Interpose+Watcher.swift | 3 +- Sources/InterposeKit/Interpose.swift | 8 +- .../ToBePolished/HookDynamicLookupTests.swift | 2 +- .../ToBePolished/InterposeKitTests.swift | 82 +++++++++---------- .../ToBePolished/MultipleInterposing.swift | 49 ++++++----- 6 files changed, 88 insertions(+), 68 deletions(-) diff --git a/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift b/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift index 5e1af52..de91d07 100644 --- a/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift +++ b/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift @@ -18,4 +18,16 @@ extension Interpose { ) } + @available(*, unavailable, message: "'apply()' is no longer supported. Use 'applyHook(...)' instead to apply individual hooks.") + @discardableResult + public func apply(_ builder: ((Interpose) throws -> Void)? = nil) throws -> Interpose { + fatalError("Interpose.apply() is unavailable.") + } + + @available(*, unavailable, message: "'revert()' is no longer supported. Keep a reference to the individual hooks and call 'revert()' on them.") + @discardableResult + public func revert(_ builder: ((Interpose) throws -> Void)? = nil) throws -> Interpose { + fatalError("Interpose.revert() is unavailable.") + } + } diff --git a/Sources/InterposeKit/Interpose+Watcher.swift b/Sources/InterposeKit/Interpose+Watcher.swift index 20da0fc..ed1e395 100644 --- a/Sources/InterposeKit/Interpose+Watcher.swift +++ b/Sources/InterposeKit/Interpose+Watcher.swift @@ -54,7 +54,8 @@ extension Interpose { func tryExecute() throws -> Bool { guard let `class` = NSClassFromString(className), let builder = self.builder else { return false } - try Interpose(`class`).apply(builder) + let interposer = try Interpose(`class`) + try builder(interposer) if let completion = self.completion { completion() } diff --git a/Sources/InterposeKit/Interpose.swift b/Sources/InterposeKit/Interpose.swift index 046caf8..0f58623 100644 --- a/Sources/InterposeKit/Interpose.swift +++ b/Sources/InterposeKit/Interpose.swift @@ -32,7 +32,7 @@ final public class Interpose { // Only apply if a builder is present if let builder = builder { - try apply(builder) + try _apply(builder) } } @@ -51,7 +51,7 @@ final public class Interpose { // Only apply if a builder is present if let builder = builder { - try apply(builder) + try _apply(builder) } } @@ -92,12 +92,12 @@ final public class Interpose { } /// Apply all stored hooks. - @discardableResult public func apply(_ hook: ((Interpose) throws -> Void)? = nil) throws -> Interpose { + @discardableResult private 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 { + @discardableResult private func _revert(_ hook: ((Interpose) throws -> Void)? = nil) throws -> Interpose { try execute(hook, expectedState: .active) { try $0.revert() } } diff --git a/Tests/InterposeKitTests/ToBePolished/HookDynamicLookupTests.swift b/Tests/InterposeKitTests/ToBePolished/HookDynamicLookupTests.swift index bfaaee2..a041321 100644 --- a/Tests/InterposeKitTests/ToBePolished/HookDynamicLookupTests.swift +++ b/Tests/InterposeKitTests/ToBePolished/HookDynamicLookupTests.swift @@ -18,7 +18,7 @@ class HookDynamicLookupTests: XCTestCase { // 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( - object: object, + target: .object(object), selector: #selector(ExampleClass.greet(name:)), build: { (hook: HookProxy) -> HookSignature in return { `self`, name in diff --git a/Tests/InterposeKitTests/ToBePolished/InterposeKitTests.swift b/Tests/InterposeKitTests/ToBePolished/InterposeKitTests.swift index 528c1f6..feb58e5 100644 --- a/Tests/InterposeKitTests/ToBePolished/InterposeKitTests.swift +++ b/Tests/InterposeKitTests/ToBePolished/InterposeKitTests.swift @@ -12,34 +12,33 @@ final class InterposeKitTests: InterposeKitTestCase { 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 - } + let interposer = try Interpose(TestClass.self) + let hook = try interposer.hook( + #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() + try hook.revert() XCTAssertEqual(testObj.sayHi(), testClassHi) - try interposer.apply() + try hook.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() + 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) } @@ -48,20 +47,19 @@ final class InterposeKitTests: InterposeKitTestCase { 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 - } + let interposer = try Interpose(TestClass.self) + let hook = try interposer.hook( + #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() + try hook.revert() XCTAssertEqual(testObj.sayHi(), testClassHi + testSubclass) - try interposed.apply() + try hook.apply() XCTAssertEqual(testObj.sayHi(), testClassHi + testString + testSubclass) // Swizzle subclass, automatically applys @@ -74,7 +72,7 @@ final class InterposeKitTests: InterposeKitTestCase { } XCTAssertEqual(testObj.sayHi(), testClassHi + testString + testSubclass + testString) - try interposed.revert() + try hook.revert() XCTAssertEqual(testObj.sayHi(), testClassHi + testSubclass + testString) try interposedSubclass.revert() XCTAssertEqual(testObj.sayHi(), testClassHi + testSubclass) @@ -115,17 +113,17 @@ final class InterposeKitTests: InterposeKitTestCase { } // 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) - } + let interposer = try Interpose(TestClass.self) + let hook = try interposer.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() + } + + try hook.revert() } // Verify that the block was deallocated diff --git a/Tests/InterposeKitTests/ToBePolished/MultipleInterposing.swift b/Tests/InterposeKitTests/ToBePolished/MultipleInterposing.swift index b36ddfb..a25e342 100644 --- a/Tests/InterposeKitTests/ToBePolished/MultipleInterposing.swift +++ b/Tests/InterposeKitTests/ToBePolished/MultipleInterposing.swift @@ -12,13 +12,14 @@ final class MultipleInterposingTests: InterposeKitTestCase { 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 - } + let interposer = try Interpose(testObj) + let hook = try interposer.hook( + #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 } } @@ -36,7 +37,7 @@ final class MultipleInterposingTests: InterposeKitTestCase { } XCTAssertEqual(testObj.sayHi(), testClassHi + testString + testString2) - try interposer.revert() + try hook.revert() XCTAssertEqual(testObj.sayHi(), testClassHi + testString2) } @@ -44,24 +45,32 @@ final class MultipleInterposingTests: InterposeKitTestCase { 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 - } + let interpose = try Interpose(testObj) + let hook1 = try interpose.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 interpose.hook(#selector(getter: TestClass.age), - methodSignature: (@convention(c) (AnyObject, Selector) -> Int).self, - hookSignature: (@convention(block) (AnyObject) -> Int).self) { _ in { _ in - return 5 - } + let hook2 = 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() + try hook2.revert() + + XCTAssertEqual(testObj.age, 3) + try hook1.revert() + XCTAssertEqual(testObj.age, 1) } } From c5f4ed98adb8386bbb9ea37e4eadbb055b8220be Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Fri, 28 Mar 2025 14:51:51 +0100 Subject: [PATCH 59/70] Interpose no longer allows apply/revert --- .../ToBePolished/ObjectInterposeTests.swift | 19 +++++++++---------- 1 file changed, 9 insertions(+), 10 deletions(-) diff --git a/Tests/InterposeKitTests/ToBePolished/ObjectInterposeTests.swift b/Tests/InterposeKitTests/ToBePolished/ObjectInterposeTests.swift index 9f16a75..4642485 100644 --- a/Tests/InterposeKitTests/ToBePolished/ObjectInterposeTests.swift +++ b/Tests/InterposeKitTests/ToBePolished/ObjectInterposeTests.swift @@ -84,15 +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), - methodSignature: (@convention(c) (AnyObject, Selector) -> Int).self, - hookSignature: (@convention(block) (AnyObject) -> Int).self - ) { hook in - return { - hook.original($0, hook.selector) * returnIntClassMultiplier - } + let classInterposer = try Interpose(TestClass.self) + let classHook = try classInterposer.hook( + #selector(TestClass.returnInt), + methodSignature: (@convention(c) (AnyObject, Selector) -> Int).self, + hookSignature: (@convention(block) (AnyObject) -> Int).self + ) { hook in + return { + hook.original($0, hook.selector) * returnIntClassMultiplier } } @@ -104,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) } From 106e13952dc8336178e17cb40e4223019e9b1db8 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Fri, 28 Mar 2025 15:06:54 +0100 Subject: [PATCH 60/70] Moved object validation from Interpose to ObjectHookStrategy --- Sources/InterposeKit/Hooks/Hook.swift | 2 +- .../ObjectHookStrategy.swift | 20 +++++++++++++++++- Sources/InterposeKit/Interpose.swift | 21 +------------------ Sources/InterposeKit/__Interpose.swift | 2 +- .../ToBePolished/KVOTests.swift | 11 +++++++++- 5 files changed, 32 insertions(+), 24 deletions(-) diff --git a/Sources/InterposeKit/Hooks/Hook.swift b/Sources/InterposeKit/Hooks/Hook.swift index 1a83bb5..9fb3cb1 100644 --- a/Sources/InterposeKit/Hooks/Hook.swift +++ b/Sources/InterposeKit/Hooks/Hook.swift @@ -209,5 +209,5 @@ public enum HookState: Equatable { internal enum HookTarget { case `class`(AnyClass) - case object(AnyObject) + case object(NSObject) } diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift index ff90b2e..c2b7af1 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift @@ -3,7 +3,7 @@ import Foundation final class ObjectHookStrategy: HookStrategy { init( - object: AnyObject, + object: NSObject, selector: Selector, hookIMP: IMP ) throws { @@ -14,6 +14,13 @@ final class ObjectHookStrategy: HookStrategy { try self.validate() + if let _ = checkObjectPosingAsDifferentClass(object) { + if object_isKVOActive(object) { + throw InterposeError.keyValueObservationDetected(object) + } + // TODO: Handle the case where the object is posing as different class but not the interpose subclass + } + ObjectHookRegistry.register(self.handle, for: hookIMP) } @@ -148,6 +155,17 @@ final class ObjectHookStrategy: HookStrategy { // 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. /// diff --git a/Sources/InterposeKit/Interpose.swift b/Sources/InterposeKit/Interpose.swift index 0f58623..c646c19 100644 --- a/Sources/InterposeKit/Interpose.swift +++ b/Sources/InterposeKit/Interpose.swift @@ -11,18 +11,7 @@ final public class Interpose { public private(set) var hooks: [Hook] = [] /// 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 - } + public let object: NSObject? /// Initializes an instance of Interpose for a specific class. /// If `builder` is present, `apply()` is automatically called. @@ -41,14 +30,6 @@ final public class Interpose { self.object = object self.class = type(of: object) - if let actualClass = checkObjectPosingAsDifferentClass(object) { - if object_isKVOActive(object) { - 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) diff --git a/Sources/InterposeKit/__Interpose.swift b/Sources/InterposeKit/__Interpose.swift index f255533..e9f12da 100644 --- a/Sources/InterposeKit/__Interpose.swift +++ b/Sources/InterposeKit/__Interpose.swift @@ -10,7 +10,7 @@ public final class __Interpose { self.target = .class(`class`) } - public init(object: AnyObject) { + public init(object: NSObject) { self.target = .object(object) } diff --git a/Tests/InterposeKitTests/ToBePolished/KVOTests.swift b/Tests/InterposeKitTests/ToBePolished/KVOTests.swift index 1cd5875..23f8b7a 100644 --- a/Tests/InterposeKitTests/ToBePolished/KVOTests.swift +++ b/Tests/InterposeKitTests/ToBePolished/KVOTests.swift @@ -32,7 +32,16 @@ final class KVOTests: InterposeKitTestCase { testObj.age = 2 XCTAssertEqual(testObj.age, 2) // Hooking is expected to fail - XCTAssertThrowsError(try Interpose(testObj), expected: InterposeError.keyValueObservationDetected(testObj)) + XCTAssertThrowsError( + try Interpose(testObj).prepareHook( + #selector(getter: TestClass.age), + methodSignature: (@convention(c) (AnyObject, Selector) -> Int).self, + hookSignature: (@convention(block) (AnyObject) -> Int).self + ) { _ in + return { _ in 3 } + }, + expected: InterposeError.keyValueObservationDetected(testObj) + ) XCTAssertEqual(testObj.age, 2) } From 209cd30491e41282d058855f14adce5318d5aa29 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Fri, 28 Mar 2025 15:13:55 +0100 Subject: [PATCH 61/70] Deprecated Interpose.init(_:builder:) for classes --- .../Deprecated/Interpose+Deprecated.swift | 10 +++++++- Sources/InterposeKit/Interpose+Watcher.swift | 2 +- Sources/InterposeKit/Interpose.swift | 7 +----- Tests/InterposeKitTests/HookTests.swift | 4 ++-- .../ClassMethodInterposeTests.swift | 24 +++++++++---------- .../ToBePolished/InterposeKitTests.swift | 6 ++--- .../ToBePolished/ObjectInterposeTests.swift | 2 +- 7 files changed, 28 insertions(+), 27 deletions(-) diff --git a/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift b/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift index de91d07..33033cd 100644 --- a/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift +++ b/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift @@ -2,6 +2,14 @@ import ObjectiveC extension Interpose { + @available(*, unavailable, message: "Use 'init(_ class: AnyClass)' followed by 'applyHook(…)' instead.") + public convenience init( + _ class: AnyClass, + builder: (Interpose) throws -> Void + ) throws { + fatalError("Interpose(class:builder:) is unavailable.") + } + @available(*, deprecated, message: "Use 'hook(_:methodSignature:hookSignature:_:)' instead and pass materialized selector.") @discardableResult public func hook( @@ -18,7 +26,7 @@ extension Interpose { ) } - @available(*, unavailable, message: "'apply()' is no longer supported. Use 'applyHook(...)' instead to apply individual hooks.") + @available(*, unavailable, message: "'apply()' is no longer supported. Use 'applyHook(…)' instead to apply individual hooks.") @discardableResult public func apply(_ builder: ((Interpose) throws -> Void)? = nil) throws -> Interpose { fatalError("Interpose.apply() is unavailable.") diff --git a/Sources/InterposeKit/Interpose+Watcher.swift b/Sources/InterposeKit/Interpose+Watcher.swift index ed1e395..4dc9645 100644 --- a/Sources/InterposeKit/Interpose+Watcher.swift +++ b/Sources/InterposeKit/Interpose+Watcher.swift @@ -54,7 +54,7 @@ extension Interpose { func tryExecute() throws -> Bool { guard let `class` = NSClassFromString(className), let builder = self.builder else { return false } - let interposer = try Interpose(`class`) + let interposer = Interpose(`class`) try builder(interposer) if let completion = self.completion { completion() diff --git a/Sources/InterposeKit/Interpose.swift b/Sources/InterposeKit/Interpose.swift index c646c19..3da96bd 100644 --- a/Sources/InterposeKit/Interpose.swift +++ b/Sources/InterposeKit/Interpose.swift @@ -15,14 +15,9 @@ final public class Interpose { /// 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 { + public init(_ `class`: AnyClass) { 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. diff --git a/Tests/InterposeKitTests/HookTests.swift b/Tests/InterposeKitTests/HookTests.swift index 8b7b160..18a3cc0 100644 --- a/Tests/InterposeKitTests/HookTests.swift +++ b/Tests/InterposeKitTests/HookTests.swift @@ -10,7 +10,7 @@ fileprivate class ExampleSubclass: ExampleClass {} final class HookTests: InterposeKitTestCase { func testStates_success() throws { - let interposer = try Interpose(ExampleClass.self) + let interposer = Interpose(ExampleClass.self) let hook = try interposer.prepareHook( #selector(ExampleClass.foo), @@ -30,7 +30,7 @@ final class HookTests: InterposeKitTestCase { func testStates_failure() throws { // Interpose on a subclass that inherits but does not implement `foo`. - let interposer = try Interpose(ExampleSubclass.self) + let interposer = Interpose(ExampleSubclass.self) // We can prepare a hook, as the method is accessible from the subclass. let hook = try interposer.prepareHook( diff --git a/Tests/InterposeKitTests/ToBePolished/ClassMethodInterposeTests.swift b/Tests/InterposeKitTests/ToBePolished/ClassMethodInterposeTests.swift index aad6310..085d6b5 100644 --- a/Tests/InterposeKitTests/ToBePolished/ClassMethodInterposeTests.swift +++ b/Tests/InterposeKitTests/ToBePolished/ClassMethodInterposeTests.swift @@ -4,20 +4,18 @@ import XCTest final class ClassMethodInterposeTests: InterposeKitTestCase { func testClassMethod() { + let interposer = Interpose(TestClass.self) + 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))) - } + try interposer.prepareHook( + #selector(getter: TestClass.staticInt), + methodSignature: (@convention(c) (AnyObject, Selector) -> Int).self, + hookSignature: (@convention(block) (AnyObject) -> Int).self + ) { hook in + return { _ in 73 } + }, + expected: InterposeError.methodNotFound(TestClass.self, #selector(getter: TestClass.staticInt)) + ) } } diff --git a/Tests/InterposeKitTests/ToBePolished/InterposeKitTests.swift b/Tests/InterposeKitTests/ToBePolished/InterposeKitTests.swift index feb58e5..ef0c961 100644 --- a/Tests/InterposeKitTests/ToBePolished/InterposeKitTests.swift +++ b/Tests/InterposeKitTests/ToBePolished/InterposeKitTests.swift @@ -12,7 +12,7 @@ final class InterposeKitTests: InterposeKitTestCase { XCTAssertEqual(testObj.sayHi(), testClassHi) // Functions need to be `@objc dynamic` to be hookable. - let interposer = try Interpose(TestClass.self) + let interposer = Interpose(TestClass.self) let hook = try interposer.hook( #selector(TestClass.sayHi), methodSignature: (@convention(c) (AnyObject, Selector) -> String).self, @@ -47,7 +47,7 @@ final class InterposeKitTests: InterposeKitTestCase { XCTAssertEqual(testObj.sayHi(), testClassHi + testSubclass) // Swizzle test class - let interposer = try Interpose(TestClass.self) + let interposer = Interpose(TestClass.self) let hook = try interposer.hook( #selector(TestClass.sayHi), methodSignature: (@convention(c) (AnyObject, Selector) -> String).self, @@ -113,7 +113,7 @@ final class InterposeKitTests: InterposeKitTestCase { } // Swizzle test class - let interposer = try Interpose(TestClass.self) + let interposer = Interpose(TestClass.self) let hook = try interposer.prepareHook( #selector(TestClass.doNothing), methodSignature: (@convention(c) (AnyObject, Selector) -> Void).self, diff --git a/Tests/InterposeKitTests/ToBePolished/ObjectInterposeTests.swift b/Tests/InterposeKitTests/ToBePolished/ObjectInterposeTests.swift index 4642485..e1720f9 100644 --- a/Tests/InterposeKitTests/ToBePolished/ObjectInterposeTests.swift +++ b/Tests/InterposeKitTests/ToBePolished/ObjectInterposeTests.swift @@ -84,7 +84,7 @@ final class ObjectInterposeTests: InterposeKitTestCase { XCTAssertEqual(testObj.returnInt(), returnIntDefault + returnIntOverrideOffset) // Interpose on TestClass itself! - let classInterposer = try Interpose(TestClass.self) + let classInterposer = Interpose(TestClass.self) let classHook = try classInterposer.hook( #selector(TestClass.returnInt), methodSignature: (@convention(c) (AnyObject, Selector) -> Int).self, From d5661175890bfeccad7690e7571de8c4e46fe10f Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Fri, 28 Mar 2025 15:21:27 +0100 Subject: [PATCH 62/70] Deprecated Interpose.init(_:builder:) for objects --- .../Deprecated/Interpose+Deprecated.swift | 10 ++++- Sources/InterposeKit/Interpose.swift | 38 +------------------ Sources/InterposeKit/InterposeError.swift | 5 --- 3 files changed, 10 insertions(+), 43 deletions(-) diff --git a/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift b/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift index 33033cd..a63a0d5 100644 --- a/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift +++ b/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift @@ -7,7 +7,15 @@ extension Interpose { _ class: AnyClass, builder: (Interpose) throws -> Void ) throws { - fatalError("Interpose(class:builder:) is unavailable.") + fatalError("Interpose(_:builder:) is unavailable.") + } + + @available(*, unavailable, message: "Use 'init(_ object: NSObject)' followed by 'applyHook(…)' instead.") + public convenience init( + _ object: NSObject, + builder: (Interpose) throws -> Void + ) throws { + fatalError("Interpose(_:builder:) is unavailable.") } @available(*, deprecated, message: "Use 'hook(_:methodSignature:hookSignature:_:)' instead and pass materialized selector.") diff --git a/Sources/InterposeKit/Interpose.swift b/Sources/InterposeKit/Interpose.swift index 3da96bd..b4ecd3e 100644 --- a/Sources/InterposeKit/Interpose.swift +++ b/Sources/InterposeKit/Interpose.swift @@ -14,21 +14,15 @@ final public class Interpose { public let object: NSObject? /// Initializes an instance of Interpose for a specific class. - /// If `builder` is present, `apply()` is automatically called. public init(_ `class`: AnyClass) { self.class = `class` self.object = nil } /// Initialize with a single object to interpose. - public init(_ object: NSObject, builder: ((Interpose) throws -> Void)? = nil) throws { + public init(_ object: NSObject) throws { self.object = object self.class = type(of: object) - - // Only apply if a builder is present - if let builder = builder { - try _apply(builder) - } } deinit { @@ -66,36 +60,6 @@ final public class Interpose { hooks.append(hook) return hook } - - /// Apply all stored hooks. - @discardableResult private func _apply(_ hook: ((Interpose) throws -> Void)? = nil) throws -> Interpose { - try execute(hook) { try $0.apply() } - } - - /// Revert all stored hooks. - @discardableResult private func _revert(_ hook: ((Interpose) throws -> Void)? = nil) throws -> Interpose { - try execute(hook, expectedState: .active) { try $0.revert() } - } - - private func execute(_ task: ((Interpose) throws -> Void)? = nil, - expectedState: HookState = .pending, - executor: ((Hook) 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 - for hook in self.hooks { - if hook.state != expectedState { - throw InterposeError.invalidState(expectedState: expectedState) - } - } - - // Execute all tasks - try hooks.forEach(executor) - return self - } } // MARK: Logging diff --git a/Sources/InterposeKit/InterposeError.swift b/Sources/InterposeKit/InterposeError.swift index 92e6a8e..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: HookState) - /// 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): From 7901b40555e06b79a561a8d04f67890911a5e6a2 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Fri, 28 Mar 2025 15:33:32 +0100 Subject: [PATCH 63/70] Renamed hook creation methods on Interpose --- .../Deprecated/Interpose+Deprecated.swift | 80 +++++++++++++++++-- Sources/InterposeKit/Interpose.swift | 14 ++-- Tests/InterposeKitTests/HookTests.swift | 4 +- .../ClassMethodInterposeTests.swift | 2 +- .../ToBePolished/InterposeKitTests.swift | 18 ++--- .../ToBePolished/KVOTests.swift | 2 +- .../ToBePolished/MultipleInterposing.swift | 10 +-- .../ToBePolished/ObjectInterposeTests.swift | 4 +- 8 files changed, 104 insertions(+), 30 deletions(-) diff --git a/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift b/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift index a63a0d5..0678f61 100644 --- a/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift +++ b/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift @@ -2,7 +2,11 @@ import ObjectiveC extension Interpose { - @available(*, unavailable, message: "Use 'init(_ class: AnyClass)' followed by 'applyHook(…)' instead.") + @available( + *, + unavailable, + message: "Use 'init(_ class: AnyClass)' followed by 'applyHook(…)' instead." + ) public convenience init( _ class: AnyClass, builder: (Interpose) throws -> Void @@ -10,7 +14,11 @@ extension Interpose { fatalError("Interpose(_:builder:) is unavailable.") } - @available(*, unavailable, message: "Use 'init(_ object: NSObject)' followed by 'applyHook(…)' instead.") + @available( + *, + unavailable, + message: "Use 'init(_ object: NSObject)' followed by 'applyHook(…)' instead." + ) public convenience init( _ object: NSObject, builder: (Interpose) throws -> Void @@ -18,7 +26,14 @@ extension Interpose { fatalError("Interpose(_:builder:) is unavailable.") } - @available(*, deprecated, message: "Use 'hook(_:methodSignature:hookSignature:_:)' instead and pass materialized selector.") + @available( + *, + deprecated, + message: """ + Use 'applyHook(for:methodSignature:hookSignature:_:)' instead and pass a materialized + selector. + """ + ) @discardableResult public func hook( _ selectorName: String, @@ -34,13 +49,68 @@ extension Interpose { ) } - @available(*, unavailable, message: "'apply()' is no longer supported. Use 'applyHook(…)' instead to apply individual hooks.") + @available( + *, + deprecated, + renamed: "applyHook(for:methodSignature:hookSignature:_:)", + message: "Use 'applyHook(for:methodSignature:hookSignature:_:)' instead." + ) + @discardableResult + public func hook ( + _ selector: Selector, + methodSignature: MethodSignature.Type, + hookSignature: HookSignature.Type, + _ build: HookBuilder + ) throws -> Hook { + try self.applyHook( + for: selector, + methodSignature: methodSignature, + hookSignature: hookSignature, + build + ) + } + + @available( + *, + deprecated, + renamed: "prepareHook(for:methodSignature:hookSignature:_:)", + message: "Use 'prepareHook(for:methodSignature:hookSignature:_:)' instead." + ) + @discardableResult + public func prepareHook ( + _ selector: Selector, + methodSignature: MethodSignature.Type, + hookSignature: HookSignature.Type, + _ build: HookBuilder + ) throws -> Hook { + try self.prepareHook( + for: selector, + methodSignature: methodSignature, + hookSignature: hookSignature, + build + ) + } + + @available( + *, + unavailable, + message: """ + 'apply()' is no longer supported. Use 'applyHook(…)' instead to apply individual hooks. + """ + ) @discardableResult public func apply(_ builder: ((Interpose) throws -> Void)? = nil) throws -> Interpose { fatalError("Interpose.apply() is unavailable.") } - @available(*, unavailable, message: "'revert()' is no longer supported. Keep a reference to the individual hooks and call 'revert()' on them.") + @available( + *, + unavailable, + message: """ + 'revert()' is no longer supported. Keep a reference to the individual hooks and call + 'revert()' on them. + """ + ) @discardableResult public func revert(_ builder: ((Interpose) throws -> Void)? = nil) throws -> Interpose { fatalError("Interpose.revert() is unavailable.") diff --git a/Sources/InterposeKit/Interpose.swift b/Sources/InterposeKit/Interpose.swift index b4ecd3e..1b5caee 100644 --- a/Sources/InterposeKit/Interpose.swift +++ b/Sources/InterposeKit/Interpose.swift @@ -31,14 +31,18 @@ final public class Interpose { /// Hook an `@objc dynamic` instance method via selector on the current class. @discardableResult - public func hook ( - _ selector: Selector, + public func applyHook ( + for selector: Selector, methodSignature: MethodSignature.Type, hookSignature: HookSignature.Type, _ build: HookBuilder ) throws -> Hook { - let hook = try prepareHook(selector, methodSignature: methodSignature, - hookSignature: hookSignature, build) + let hook = try self.prepareHook( + for: selector, + methodSignature: methodSignature, + hookSignature: hookSignature, + build + ) try hook.apply() return hook } @@ -46,7 +50,7 @@ final public class Interpose { /// Prepares a hook, but does not call apply immediately. @discardableResult public func prepareHook ( - _ selector: Selector, + for selector: Selector, methodSignature: MethodSignature.Type, hookSignature: HookSignature.Type, _ build: HookBuilder diff --git a/Tests/InterposeKitTests/HookTests.swift b/Tests/InterposeKitTests/HookTests.swift index 18a3cc0..8dff124 100644 --- a/Tests/InterposeKitTests/HookTests.swift +++ b/Tests/InterposeKitTests/HookTests.swift @@ -13,7 +13,7 @@ final class HookTests: InterposeKitTestCase { let interposer = Interpose(ExampleClass.self) let hook = try interposer.prepareHook( - #selector(ExampleClass.foo), + for: #selector(ExampleClass.foo), methodSignature: (@convention(c) (NSObject, Selector) -> Void).self, hookSignature: (@convention(block) (NSObject) -> Void).self ) { hook in @@ -34,7 +34,7 @@ final class HookTests: InterposeKitTestCase { // We can prepare a hook, as the method is accessible from the subclass. let hook = try interposer.prepareHook( - #selector(ExampleClass.foo), + for: #selector(ExampleClass.foo), methodSignature: (@convention(c) (NSObject, Selector) -> Void).self, hookSignature: (@convention(block) (NSObject) -> Void).self ) { hook in diff --git a/Tests/InterposeKitTests/ToBePolished/ClassMethodInterposeTests.swift b/Tests/InterposeKitTests/ToBePolished/ClassMethodInterposeTests.swift index 085d6b5..1e9b2b1 100644 --- a/Tests/InterposeKitTests/ToBePolished/ClassMethodInterposeTests.swift +++ b/Tests/InterposeKitTests/ToBePolished/ClassMethodInterposeTests.swift @@ -8,7 +8,7 @@ final class ClassMethodInterposeTests: InterposeKitTestCase { XCTAssertThrowsError( try interposer.prepareHook( - #selector(getter: TestClass.staticInt), + for: #selector(getter: TestClass.staticInt), methodSignature: (@convention(c) (AnyObject, Selector) -> Int).self, hookSignature: (@convention(block) (AnyObject) -> Int).self ) { hook in diff --git a/Tests/InterposeKitTests/ToBePolished/InterposeKitTests.swift b/Tests/InterposeKitTests/ToBePolished/InterposeKitTests.swift index ef0c961..c427169 100644 --- a/Tests/InterposeKitTests/ToBePolished/InterposeKitTests.swift +++ b/Tests/InterposeKitTests/ToBePolished/InterposeKitTests.swift @@ -13,8 +13,8 @@ final class InterposeKitTests: InterposeKitTestCase { // Functions need to be `@objc dynamic` to be hookable. let interposer = Interpose(TestClass.self) - let hook = try interposer.hook( - #selector(TestClass.sayHi), + let hook = try interposer.applyHook( + for: #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. @@ -48,8 +48,8 @@ final class InterposeKitTests: InterposeKitTestCase { // Swizzle test class let interposer = Interpose(TestClass.self) - let hook = try interposer.hook( - #selector(TestClass.sayHi), + let hook = try interposer.applyHook( + for: #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 @@ -63,8 +63,8 @@ final class InterposeKitTests: InterposeKitTestCase { XCTAssertEqual(testObj.sayHi(), testClassHi + testString + testSubclass) // Swizzle subclass, automatically applys - let interposedSubclass = try Interpose(TestSubclass.self).hook( - #selector(TestSubclass.sayHi), + let interposedSubclass = try Interpose(TestSubclass.self).applyHook( + for: #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 @@ -87,8 +87,8 @@ final class InterposeKitTests: InterposeKitTestCase { } // Swizzle test class - let interposer = try Interpose(TestClass.self).hook( - #selector(TestClass.doNothing), + let interposer = try Interpose(TestClass.self).applyHook( + for: #selector(TestClass.doNothing), methodSignature: (@convention(c) (AnyObject, Selector) -> Void).self, hookSignature: (@convention(block) (AnyObject) -> Void).self) { store in { bSelf in tracker.keep() @@ -115,7 +115,7 @@ final class InterposeKitTests: InterposeKitTestCase { // Swizzle test class let interposer = Interpose(TestClass.self) let hook = try interposer.prepareHook( - #selector(TestClass.doNothing), + for: #selector(TestClass.doNothing), methodSignature: (@convention(c) (AnyObject, Selector) -> Void).self, hookSignature: (@convention(block) (AnyObject) -> Void).self) { store in { bSelf in tracker.keep() diff --git a/Tests/InterposeKitTests/ToBePolished/KVOTests.swift b/Tests/InterposeKitTests/ToBePolished/KVOTests.swift index 23f8b7a..2da0290 100644 --- a/Tests/InterposeKitTests/ToBePolished/KVOTests.swift +++ b/Tests/InterposeKitTests/ToBePolished/KVOTests.swift @@ -34,7 +34,7 @@ final class KVOTests: InterposeKitTestCase { // Hooking is expected to fail XCTAssertThrowsError( try Interpose(testObj).prepareHook( - #selector(getter: TestClass.age), + for: #selector(getter: TestClass.age), methodSignature: (@convention(c) (AnyObject, Selector) -> Int).self, hookSignature: (@convention(block) (AnyObject) -> Int).self ) { _ in diff --git a/Tests/InterposeKitTests/ToBePolished/MultipleInterposing.swift b/Tests/InterposeKitTests/ToBePolished/MultipleInterposing.swift index a25e342..213cee5 100644 --- a/Tests/InterposeKitTests/ToBePolished/MultipleInterposing.swift +++ b/Tests/InterposeKitTests/ToBePolished/MultipleInterposing.swift @@ -13,8 +13,8 @@ final class MultipleInterposingTests: InterposeKitTestCase { // Functions need to be `@objc dynamic` to be hookable. let interposer = try Interpose(testObj) - let hook = try interposer.hook( - #selector(TestClass.sayHi), + let hook = try interposer.applyHook( + for: #selector(TestClass.sayHi), methodSignature: (@convention(c) (AnyObject, Selector) -> String).self, hookSignature: (@convention(block) (AnyObject) -> String).self ) { store in @@ -46,8 +46,8 @@ final class MultipleInterposingTests: InterposeKitTestCase { XCTAssertEqual(testObj.age, 1) let interpose = try Interpose(testObj) - let hook1 = try interpose.hook( - #selector(getter: TestClass.age), + let hook1 = try interpose.applyHook( + for: #selector(getter: TestClass.age), methodSignature: (@convention(c) (AnyObject, Selector) -> Int).self, hookSignature: (@convention(block) (AnyObject) -> Int).self ) { _ in @@ -58,7 +58,7 @@ final class MultipleInterposingTests: InterposeKitTestCase { XCTAssertEqual(testObj.age, 3) - let hook2 = try interpose.hook(#selector(getter: TestClass.age), + let hook2 = try interpose.applyHook(for: #selector(getter: TestClass.age), methodSignature: (@convention(c) (AnyObject, Selector) -> Int).self, hookSignature: (@convention(block) (AnyObject) -> Int).self) { _ in { _ in return 5 diff --git a/Tests/InterposeKitTests/ToBePolished/ObjectInterposeTests.swift b/Tests/InterposeKitTests/ToBePolished/ObjectInterposeTests.swift index e1720f9..d54bb18 100644 --- a/Tests/InterposeKitTests/ToBePolished/ObjectInterposeTests.swift +++ b/Tests/InterposeKitTests/ToBePolished/ObjectInterposeTests.swift @@ -85,8 +85,8 @@ final class ObjectInterposeTests: InterposeKitTestCase { // Interpose on TestClass itself! let classInterposer = Interpose(TestClass.self) - let classHook = try classInterposer.hook( - #selector(TestClass.returnInt), + let classHook = try classInterposer.applyHook( + for: #selector(TestClass.returnInt), methodSignature: (@convention(c) (AnyObject, Selector) -> Int).self, hookSignature: (@convention(block) (AnyObject) -> Int).self ) { hook in From a126a31bfaa114b8881fd8f4bebff870ba4da6a0 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Sat, 29 Mar 2025 10:38:39 +0100 Subject: [PATCH 64/70] Reworked life cycle handling across the library MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Hook now stores a closure for lazily creating its strategy, replacing the unsafe approach with an implicitly unwrapped optional. - Hook implementation is now created lazily at the point of applying a hook and released when a hook is reverted, managed in concrete hook strategies. - Validation in HookStrategy is now performed in a separate step after initialization via `HookStrategy.validate()`. - ObjectHookStrategy is now referenced weakly from its handle to prevent retain cycles. - Removed hooks storage in legacy Interpose facade. - AnyObject → NSObject --- .../Deprecated/Interpose+Deprecated.swift | 6 +- .../Deprecated/NSObject+Deprecated.swift | 6 +- Sources/InterposeKit/Hooks/Hook.swift | 107 +++++++++++------- .../ClassHookStrategy/ClassHookStrategy.swift | 33 ++++-- .../Hooks/HookStrategy/HookStrategy.swift | 26 ++--- .../ObjectHookStrategy.swift | 69 ++++++----- Sources/InterposeKit/Interpose.swift | 14 +-- Sources/InterposeKit/NSObject+Interpose.swift | 2 +- Sources/InterposeKit/__Interpose.swift | 4 +- .../ClassMethodInterposeTests.swift | 4 +- .../ToBePolished/InterposeKitTests.swift | 60 +++++++--- .../ToBePolished/KVOTests.swift | 8 +- .../ToBePolished/MultipleInterposing.swift | 16 +-- .../ToBePolished/ObjectInterposeTests.swift | 32 +++--- 14 files changed, 230 insertions(+), 157 deletions(-) diff --git a/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift b/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift index 0678f61..dac11f4 100644 --- a/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift +++ b/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift @@ -39,7 +39,7 @@ extension Interpose { _ selectorName: String, methodSignature: MethodSignature.Type, hookSignature: HookSignature.Type, - _ build: HookBuilder + _ build: @escaping HookBuilder ) throws -> Hook { try self.hook( Selector(selectorName), @@ -60,7 +60,7 @@ extension Interpose { _ selector: Selector, methodSignature: MethodSignature.Type, hookSignature: HookSignature.Type, - _ build: HookBuilder + _ build: @escaping HookBuilder ) throws -> Hook { try self.applyHook( for: selector, @@ -81,7 +81,7 @@ extension Interpose { _ selector: Selector, methodSignature: MethodSignature.Type, hookSignature: HookSignature.Type, - _ build: HookBuilder + _ build: @escaping HookBuilder ) throws -> Hook { try self.prepareHook( for: selector, diff --git a/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift b/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift index f63281e..9da407d 100644 --- a/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift +++ b/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift @@ -8,7 +8,7 @@ extension NSObject { for selector: Selector, methodSignature: MethodSignature.Type, hookSignature: HookSignature.Type, - implementation: HookBuilder + implementation: @escaping HookBuilder ) throws -> Hook { return try self.applyHook( for: selector, @@ -24,7 +24,7 @@ extension NSObject { _ selector: Selector, methodSignature: MethodSignature.Type, hookSignature: HookSignature.Type, - _ build: HookBuilder + _ build: @escaping HookBuilder ) throws -> Hook { precondition( !(self is AnyClass), @@ -49,7 +49,7 @@ extension NSObject { _ selector: Selector, methodSignature: MethodSignature.Type, hookSignature: HookSignature.Type, - _ build: HookBuilder + _ build: @escaping HookBuilder ) throws -> Hook { let hook = try Hook( target: .class(self), diff --git a/Sources/InterposeKit/Hooks/Hook.swift b/Sources/InterposeKit/Hooks/Hook.swift index 9fb3cb1..35e218f 100644 --- a/Sources/InterposeKit/Hooks/Hook.swift +++ b/Sources/InterposeKit/Hooks/Hook.swift @@ -10,39 +10,55 @@ public final class Hook { internal init( target: HookTarget, selector: Selector, - build: HookBuilder + build: @escaping HookBuilder ) throws { - func makeStrategy(_ hook: Hook) throws -> HookStrategy { - let hookProxy = HookProxy( - selector: selector, - getOriginal: { - unsafeBitCast( - self.originalIMP, - to: MethodSignature.self + 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 { + fatalError( + """ + [InterposeKit] Internal inconsistency: Hook instance was deallocated \ + before the hook implementation could be created. + """ ) } - ) - - let hookBlock = build(hookProxy) - let hookIMP = imp_implementationWithBlock(hookBlock) + + 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 try ClassHookStrategy( + return ClassHookStrategy( class: `class`, selector: selector, - hookIMP: hookIMP + makeHookIMP: makeHookIMP ) case .object(let object): - return try ObjectHookStrategy( + return ObjectHookStrategy( object: object, selector: selector, - hookIMP: hookIMP + makeHookIMP: makeHookIMP ) } } - self.strategy = try makeStrategy(self) + try self.strategy.validate() } // ============================================================================ // @@ -119,11 +135,39 @@ public final class Hook { // MARK: Underlying Strategy // ============================================================================ // - /// The active strategy used to interpose and manage the method implementation. + /// The strategy responsible for interposing and managing the method implementation. /// - /// This is an implicitly unwrapped optional, assigned immediately after initialization, - /// as constructing the strategy requires `self` to build the hook proxy. - private var strategy: HookStrategy! + /// 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: " ")) + } } @@ -133,31 +177,16 @@ extension Hook: CustomDebugStringConvertible { } } -// TODO: Try to make clean-up automatic in deinit -extension Hook { - public func cleanup() { - switch state { - case .pending: - Interpose.log("Releasing -[\(`class`).\(selector)] IMP: \(self.strategy.hookIMP)") - imp_removeBlock(strategy.hookIMP) - case .active: - Interpose.log("Keeping -[\(`class`).\(selector)] IMP: \(self.strategy.hookIMP)") - case .failed: - Interpose.log("Leaking -[\(`class`).\(selector)] IMP: \(self.strategy.hookIMP)") - } - } -} - /// 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) (AnyObject, Selector, Params…) -> ReturnValue).self`. +/// 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) (AnyObject, Params…) -> ReturnValue).self`. +/// `(@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 @@ -190,7 +219,7 @@ public enum HookScope { case `class` /// The scope that targets a specific instance of the class. - case object(AnyObject) + case object(NSObject) } diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy/ClassHookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy/ClassHookStrategy.swift index 3602ef3..d29bf16 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy/ClassHookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy/ClassHookStrategy.swift @@ -5,22 +5,30 @@ final class ClassHookStrategy: HookStrategy { init( `class`: AnyClass, selector: Selector, - hookIMP: IMP - ) throws { + makeHookIMP: @escaping () -> IMP + ) { self.class = `class` self.selector = selector - self.hookIMP = hookIMP - - try self.validate() + self.makeHookIMP = makeHookIMP } let `class`: AnyClass var scope: HookScope { .class } let selector: Selector - let hookIMP: IMP + 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) } @@ -28,7 +36,7 @@ final class ClassHookStrategy: HookStrategy { guard let originalIMP = class_replaceMethod( self.class, self.selector, - self.hookIMP, + hookIMP, method_getTypeEncoding(method) ) else { throw InterposeError.nonExistingImplementation(self.class, self.selector) @@ -36,10 +44,17 @@ final class ClassHookStrategy: HookStrategy { self.storedOriginalIMP = originalIMP - Interpose.log("Swizzled -[\(self.class).\(self.selector)] IMP: \(originalIMP) -> \(self.hookIMP)") + 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) } @@ -56,7 +71,7 @@ final class ClassHookStrategy: HookStrategy { method_getTypeEncoding(method) ) - guard previousIMP == self.hookIMP else { + guard previousIMP == hookIMP else { throw InterposeError.unexpectedImplementation(self.class, self.selector, previousIMP) } diff --git a/Sources/InterposeKit/Hooks/HookStrategy/HookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/HookStrategy.swift index 60a632c..fabff68 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/HookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/HookStrategy.swift @@ -5,28 +5,20 @@ internal protocol HookStrategy: AnyObject, CustomDebugStringConvertible { var `class`: AnyClass { get } var scope: HookScope { get } var selector: Selector { get } - - /// The implementation used to interpose the method, created during hook setup and used - /// to replace the original implementation while the hook is applied. - var hookIMP: IMP { get } - - /// The original implementation captured when the hook is applied, restored when the hook - /// is reverted. - var storedOriginalIMP: IMP? { get } + + /// Validates the target and throws if invalid. + func validate() throws func replaceImplementation() throws func restoreImplementation() throws -} - -extension HookStrategy { + /// 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 } - /// Validates that the target method exists on the class, throwing if not found. - internal func validate() throws { - guard class_getInstanceMethod(self.class, self.selector) != nil else { - throw InterposeError.methodNotFound(self.class, self.selector) - } - } + /// The original implementation captured when the hook is applied, restored when the hook + /// is reverted. + var storedOriginalIMP: IMP? { get } } diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift index c2b7af1..9fd8175 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookStrategy.swift @@ -5,36 +5,26 @@ final class ObjectHookStrategy: HookStrategy { init( object: NSObject, selector: Selector, - hookIMP: IMP - ) throws { + makeHookIMP: @escaping () -> IMP + ) { self.class = type(of: object) self.object = object self.selector = selector - self.hookIMP = hookIMP - - try self.validate() - - if let _ = checkObjectPosingAsDifferentClass(object) { - if object_isKVOActive(object) { - throw InterposeError.keyValueObservationDetected(object) - } - // TODO: Handle the case where the object is posing as different class but not the interpose subclass - } - - ObjectHookRegistry.register(self.handle, for: hookIMP) + self.makeHookIMP = makeHookIMP } let `class`: AnyClass - let object: AnyObject + let object: NSObject var scope: HookScope { .object(self.object) } let selector: Selector - let hookIMP: IMP + private let makeHookIMP: () -> IMP + private(set) var appliedHookIMP: IMP? private(set) var storedOriginalIMP: IMP? private lazy var handle = ObjectHookHandle( - getOriginalIMP: { self.storedOriginalIMP }, - setOriginalIMP: { self.storedOriginalIMP = $0 } + getOriginalIMP: { [weak self] in self?.storedOriginalIMP }, + setOriginalIMP: { [weak self] in self?.storedOriginalIMP = $0 } ) /// Subclass that we create on the fly @@ -47,7 +37,24 @@ final class ObjectHookStrategy: HookStrategy { 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) } @@ -73,27 +80,27 @@ final class ObjectHookStrategy: HookStrategy { } // Replace IMP (by now we guarantee that it exists) - self.storedOriginalIMP = class_replaceMethod(self.dynamicSubclass, self.selector, self.hookIMP, encoding) + 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!) -> \(self.hookIMP)") + 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, self.hookIMP, encoding) + self.storedOriginalIMP = class_replaceMethod(self.dynamicSubclass, self.selector, hookIMP, encoding) if self.storedOriginalIMP != nil { - Interpose.log("Added -[\(self.class).\(self.selector)] IMP: \(self.hookIMP) via replacement") + Interpose.log("Added -[\(self.class).\(self.selector)] IMP: \(hookIMP) via replacement") } else { - Interpose.log("Unable to replace: -[\(self.class).\(self.selector)] IMP: \(self.hookIMP)") + 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, self.hookIMP, encoding) + let didAddMethod = class_addMethod(self.dynamicSubclass, self.selector, hookIMP, encoding) if didAddMethod { - Interpose.log("Added -[\(self.class).\(self.selector)] IMP: \(self.hookIMP)") + Interpose.log("Added -[\(self.class).\(self.selector)] IMP: \(hookIMP)") } else { - Interpose.log("Unable to add: -[\(self.class).\(self.selector)] IMP: \(self.hookIMP)") + Interpose.log("Unable to add: -[\(self.class).\(self.selector)] IMP: \(hookIMP)") throw InterposeError.unableToAddMethod(self.class, self.selector) } } @@ -115,6 +122,12 @@ final class ObjectHookStrategy: HookStrategy { } 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) } @@ -135,9 +148,9 @@ final class ObjectHookStrategy: HookStrategy { } // We are the topmost hook, replace method. - if currentIMP == self.hookIMP { + if currentIMP == hookIMP { let previousIMP = class_replaceMethod(self.dynamicSubclass, self.selector, self.storedOriginalIMP!, method_getTypeEncoding(method)) - guard previousIMP == self.hookIMP else { + guard previousIMP == hookIMP else { throw InterposeError.unexpectedImplementation(self.dynamicSubclass, selector, previousIMP) } Interpose.log("Restored -[\(self.class).\(self.selector)] IMP: \(self.storedOriginalIMP!)") diff --git a/Sources/InterposeKit/Interpose.swift b/Sources/InterposeKit/Interpose.swift index 1b5caee..7b614a1 100644 --- a/Sources/InterposeKit/Interpose.swift +++ b/Sources/InterposeKit/Interpose.swift @@ -7,8 +7,6 @@ import Foundation 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: [Hook] = [] /// If Interposing is object-based, this is set. public let object: NSObject? @@ -25,17 +23,13 @@ final public class Interpose { self.class = type(of: object) } - deinit { - hooks.forEach({ $0.cleanup() }) - } - /// Hook an `@objc dynamic` instance method via selector on the current class. @discardableResult public func applyHook ( for selector: Selector, methodSignature: MethodSignature.Type, hookSignature: HookSignature.Type, - _ build: HookBuilder + _ build: @escaping HookBuilder ) throws -> Hook { let hook = try self.prepareHook( for: selector, @@ -53,7 +47,7 @@ final public class Interpose { for selector: Selector, methodSignature: MethodSignature.Type, hookSignature: HookSignature.Type, - _ build: HookBuilder + _ build: @escaping HookBuilder ) throws -> Hook { var hook: Hook if let object = self.object { @@ -61,9 +55,9 @@ final public class Interpose { } else { hook = try Hook(target: .class(`class`), selector: selector, build: build) } - hooks.append(hook) return hook } + } // MARK: Logging @@ -75,7 +69,7 @@ extension Interpose { /// Simple log wrapper for print. class func log(_ object: Any) { if isLoggingEnabled { - print("[Interposer] \(object)") + print("[InterposeKit] \(object)") } } } diff --git a/Sources/InterposeKit/NSObject+Interpose.swift b/Sources/InterposeKit/NSObject+Interpose.swift index 127508f..efd9b37 100644 --- a/Sources/InterposeKit/NSObject+Interpose.swift +++ b/Sources/InterposeKit/NSObject+Interpose.swift @@ -41,7 +41,7 @@ extension NSObject { for selector: Selector, methodSignature: MethodSignature.Type, hookSignature: HookSignature.Type, - build: HookBuilder + build: @escaping HookBuilder ) throws -> Hook { let hook = try Hook( target: .object(self), diff --git a/Sources/InterposeKit/__Interpose.swift b/Sources/InterposeKit/__Interpose.swift index e9f12da..d6f47d1 100644 --- a/Sources/InterposeKit/__Interpose.swift +++ b/Sources/InterposeKit/__Interpose.swift @@ -30,7 +30,7 @@ public final class __Interpose { for selector: Selector, methodSignature: MethodSignature.Type, hookSignature: HookSignature.Type, - build: HookBuilder + build: @escaping HookBuilder ) throws -> Hook { return try Hook( target: self.target, @@ -45,7 +45,7 @@ public final class __Interpose { for selector: Selector, methodSignature: MethodSignature.Type, hookSignature: HookSignature.Type, - build: HookBuilder + build: @escaping HookBuilder ) throws -> Hook { let hook = try Hook( target: self.target, diff --git a/Tests/InterposeKitTests/ToBePolished/ClassMethodInterposeTests.swift b/Tests/InterposeKitTests/ToBePolished/ClassMethodInterposeTests.swift index 1e9b2b1..848f4ae 100644 --- a/Tests/InterposeKitTests/ToBePolished/ClassMethodInterposeTests.swift +++ b/Tests/InterposeKitTests/ToBePolished/ClassMethodInterposeTests.swift @@ -9,8 +9,8 @@ final class ClassMethodInterposeTests: InterposeKitTestCase { XCTAssertThrowsError( try interposer.prepareHook( for: #selector(getter: TestClass.staticInt), - 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 { _ in 73 } }, diff --git a/Tests/InterposeKitTests/ToBePolished/InterposeKitTests.swift b/Tests/InterposeKitTests/ToBePolished/InterposeKitTests.swift index c427169..422bb0e 100644 --- a/Tests/InterposeKitTests/ToBePolished/InterposeKitTests.swift +++ b/Tests/InterposeKitTests/ToBePolished/InterposeKitTests.swift @@ -15,8 +15,8 @@ final class InterposeKitTests: InterposeKitTestCase { let interposer = Interpose(TestClass.self) let hook = try interposer.applyHook( for: #selector(TestClass.sayHi), - methodSignature: (@convention(c) (AnyObject, Selector) -> String).self, - hookSignature: (@convention(block) (AnyObject) -> String).self) { store in { bSelf in + 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) @@ -50,8 +50,8 @@ final class InterposeKitTests: InterposeKitTestCase { let interposer = Interpose(TestClass.self) let hook = try interposer.applyHook( for: #selector(TestClass.sayHi), - methodSignature: (@convention(c) (AnyObject, Selector) -> String).self, - hookSignature: (@convention(block) (AnyObject) -> String).self) { store in { bSelf in + methodSignature: (@convention(c) (NSObject, Selector) -> String).self, + hookSignature: (@convention(block) (NSObject) -> String).self) { store in { bSelf in return store.original(bSelf, store.selector) + testString } } @@ -65,8 +65,8 @@ final class InterposeKitTests: InterposeKitTestCase { // Swizzle subclass, automatically applys let interposedSubclass = try Interpose(TestSubclass.self).applyHook( for: #selector(TestSubclass.sayHi), - methodSignature: (@convention(c) (AnyObject, Selector) -> String).self, - hookSignature: (@convention(block) (AnyObject) -> String).self) { store in { bSelf in + methodSignature: (@convention(c) (NSObject, Selector) -> String).self, + hookSignature: (@convention(block) (NSObject) -> String).self) { store in { bSelf in return store.original(bSelf, store.selector) + testString } } @@ -89,8 +89,8 @@ final class InterposeKitTests: InterposeKitTestCase { // Swizzle test class let interposer = try Interpose(TestClass.self).applyHook( for: #selector(TestClass.doNothing), - methodSignature: (@convention(c) (AnyObject, Selector) -> Void).self, - hookSignature: (@convention(block) (AnyObject) -> Void).self) { store in { bSelf in + 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) } @@ -104,7 +104,7 @@ final class InterposeKitTests: InterposeKitTestCase { XCTAssertFalse(deallocated) } - func testRevertedCleanup() throws { + func testRevertedCleanup_class() throws { var deallocated = false try autoreleasepool { @@ -114,14 +114,16 @@ final class InterposeKitTests: InterposeKitTestCase { // Swizzle test class let interposer = Interpose(TestClass.self) - let hook = try interposer.prepareHook( + let hook = try interposer.applyHook( for: #selector(TestClass.doNothing), - methodSignature: (@convention(c) (AnyObject, Selector) -> Void).self, - hookSignature: (@convention(block) (AnyObject) -> Void).self) { store in { bSelf in + methodSignature: (@convention(c) (NSObject, Selector) -> Void).self, + hookSignature: (@convention(block) (NSObject) -> Void).self + ) { hook in + return { `self` in tracker.keep() - return store.original(bSelf, store.selector) - } + return hook.original(self, hook.selector) } + } try hook.revert() } @@ -129,6 +131,34 @@ final class InterposeKitTests: InterposeKitTestCase { // 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 interposer = try Interpose(object) + let hook = try interposer.applyHook( + 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 @@ -138,7 +168,7 @@ final class InterposeKitTests: InterposeKitTestCase { deallocated = true } - let block: @convention(block) (AnyObject) -> Void = { _ in + let block: @convention(block) (NSObject) -> Void = { _ in // retain `tracker` inside a block tracker.keep() } diff --git a/Tests/InterposeKitTests/ToBePolished/KVOTests.swift b/Tests/InterposeKitTests/ToBePolished/KVOTests.swift index 2da0290..2946d46 100644 --- a/Tests/InterposeKitTests/ToBePolished/KVOTests.swift +++ b/Tests/InterposeKitTests/ToBePolished/KVOTests.swift @@ -35,8 +35,8 @@ final class KVOTests: InterposeKitTestCase { XCTAssertThrowsError( try Interpose(testObj).prepareHook( for: #selector(getter: TestClass.age), - 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 ) { _ in return { _ in 3 } }, @@ -48,8 +48,8 @@ final class KVOTests: InterposeKitTestCase { // Hook without KVO! let hook = try testObj.applyHook( for: #selector(getter: TestClass.age), - 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 ) { _ in return { _ in 3 } } diff --git a/Tests/InterposeKitTests/ToBePolished/MultipleInterposing.swift b/Tests/InterposeKitTests/ToBePolished/MultipleInterposing.swift index 213cee5..cbe4f2b 100644 --- a/Tests/InterposeKitTests/ToBePolished/MultipleInterposing.swift +++ b/Tests/InterposeKitTests/ToBePolished/MultipleInterposing.swift @@ -15,8 +15,8 @@ final class MultipleInterposingTests: InterposeKitTestCase { let interposer = try Interpose(testObj) let hook = try interposer.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 ) { store in { bSelf in return store.original(bSelf, store.selector) + testString @@ -28,8 +28,8 @@ final class MultipleInterposingTests: InterposeKitTestCase { 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 return hook.original(self, hook.selector) + testString2 @@ -48,8 +48,8 @@ final class MultipleInterposingTests: InterposeKitTestCase { let interpose = try Interpose(testObj) let hook1 = try interpose.applyHook( for: #selector(getter: TestClass.age), - 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 ) { _ in { _ in return 3 @@ -59,8 +59,8 @@ final class MultipleInterposingTests: InterposeKitTestCase { XCTAssertEqual(testObj.age, 3) let hook2 = try interpose.applyHook(for: #selector(getter: TestClass.age), - methodSignature: (@convention(c) (AnyObject, Selector) -> Int).self, - hookSignature: (@convention(block) (AnyObject) -> Int).self) { _ in { _ in + methodSignature: (@convention(c) (NSObject, Selector) -> Int).self, + hookSignature: (@convention(block) (NSObject) -> Int).self) { _ in { _ in return 5 } } diff --git a/Tests/InterposeKitTests/ToBePolished/ObjectInterposeTests.swift b/Tests/InterposeKitTests/ToBePolished/ObjectInterposeTests.swift index d54bb18..6c62c0e 100644 --- a/Tests/InterposeKitTests/ToBePolished/ObjectInterposeTests.swift +++ b/Tests/InterposeKitTests/ToBePolished/ObjectInterposeTests.swift @@ -13,8 +13,8 @@ final class ObjectInterposeTests: InterposeKitTestCase { 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)") @@ -42,8 +42,8 @@ final class ObjectInterposeTests: InterposeKitTestCase { 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) @@ -73,8 +73,8 @@ final class ObjectInterposeTests: InterposeKitTestCase { // Functions need to be `@objc dynamic` to be hookable. 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. @@ -87,8 +87,8 @@ final class ObjectInterposeTests: InterposeKitTestCase { let classInterposer = Interpose(TestClass.self) let classHook = try classInterposer.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 { hook.original($0, hook.selector) * returnIntClassMultiplier @@ -114,8 +114,8 @@ final class ObjectInterposeTests: InterposeKitTestCase { // Functions need to be `@objc dynamic` to be hookable. 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) @@ -135,8 +135,8 @@ final class ObjectInterposeTests: InterposeKitTestCase { // Functions need to be `@objc dynamic` to be hookable. 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. @@ -157,8 +157,8 @@ final class ObjectInterposeTests: InterposeKitTestCase { // Functions need to be `@objc dynamic` to be hookable. 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 @@ -237,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) // } From 1a50325903b078eb70dde7b2ca75d48ca433f7c5 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Sat, 29 Mar 2025 10:38:49 +0100 Subject: [PATCH 65/70] Fixed multiline strings --- Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift | 4 ++-- Sources/InterposeKit/Hooks/HookStrategy/HookStrategy.swift | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift b/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift index dac11f4..28c2439 100644 --- a/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift +++ b/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift @@ -30,7 +30,7 @@ extension Interpose { *, deprecated, message: """ - Use 'applyHook(for:methodSignature:hookSignature:_:)' instead and pass a materialized + Use 'applyHook(for:methodSignature:hookSignature:_:)' instead and pass a materialized \ selector. """ ) @@ -107,7 +107,7 @@ extension Interpose { *, unavailable, message: """ - 'revert()' is no longer supported. Keep a reference to the individual hooks and call + 'revert()' is no longer supported. Keep a reference to the individual hooks and call \ 'revert()' on them. """ ) diff --git a/Sources/InterposeKit/Hooks/HookStrategy/HookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/HookStrategy.swift index fabff68..ac2d2ff 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/HookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/HookStrategy.swift @@ -43,7 +43,7 @@ extension HookStrategy { fatalError( """ - No original implementation found for selector \(self.selector) on \(self.class). + No original implementation found for selector \(self.selector) on \(self.class). \ This likely indicates a corrupted or misconfigured class. """ ) From c5abecd3684b08140934ed9344a1d3ec7e4f08ae Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Sat, 29 Mar 2025 10:41:56 +0100 Subject: [PATCH 66/70] Interpose.fail(_:) --- .../InterposeKit/Deprecated/Interpose+Deprecated.swift | 8 ++++---- Sources/InterposeKit/Hooks/Hook.swift | 6 +++--- .../ClassHookStrategy/ClassHookStrategy.swift | 2 +- .../InterposeKit/Hooks/HookStrategy/HookStrategy.swift | 2 +- .../ObjectHookStrategy/ObjectHookRegistry.swift | 2 +- Sources/InterposeKit/Interpose+Watcher.swift | 2 +- Sources/InterposeKit/Interpose.swift | 8 +++++--- 7 files changed, 16 insertions(+), 14 deletions(-) diff --git a/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift b/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift index 28c2439..d21530c 100644 --- a/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift +++ b/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift @@ -11,7 +11,7 @@ extension Interpose { _ class: AnyClass, builder: (Interpose) throws -> Void ) throws { - fatalError("Interpose(_:builder:) is unavailable.") + Interpose.fail("Interpose(_:builder:) is unavailable.") } @available( @@ -23,7 +23,7 @@ extension Interpose { _ object: NSObject, builder: (Interpose) throws -> Void ) throws { - fatalError("Interpose(_:builder:) is unavailable.") + Interpose.fail("Interpose(_:builder:) is unavailable.") } @available( @@ -100,7 +100,7 @@ extension Interpose { ) @discardableResult public func apply(_ builder: ((Interpose) throws -> Void)? = nil) throws -> Interpose { - fatalError("Interpose.apply() is unavailable.") + Interpose.fail("Interpose.apply() is unavailable.") } @available( @@ -113,7 +113,7 @@ extension Interpose { ) @discardableResult public func revert(_ builder: ((Interpose) throws -> Void)? = nil) throws -> Interpose { - fatalError("Interpose.revert() is unavailable.") + Interpose.fail("Interpose.revert() is unavailable.") } } diff --git a/Sources/InterposeKit/Hooks/Hook.swift b/Sources/InterposeKit/Hooks/Hook.swift index 35e218f..1d46361 100644 --- a/Sources/InterposeKit/Hooks/Hook.swift +++ b/Sources/InterposeKit/Hooks/Hook.swift @@ -19,10 +19,10 @@ public final class Hook { // happens when installing implementation from within the strategy, which is // triggered from a live hook instance. guard let hook else { - fatalError( + Interpose.fail( """ - [InterposeKit] Internal inconsistency: Hook instance was deallocated \ - before the hook implementation could be created. + Internal inconsistency: Hook instance was deallocated before the hook \ + implementation could be created. """ ) } diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy/ClassHookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy/ClassHookStrategy.swift index d29bf16..e165fb6 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy/ClassHookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ClassHookStrategy/ClassHookStrategy.swift @@ -61,7 +61,7 @@ final class ClassHookStrategy: HookStrategy { guard let originalIMP = self.storedOriginalIMP else { // Ignore? Throw error? - fatalError("The original implementation should be loaded when resetting") + Interpose.fail("The original implementation should be loaded when resetting") } let previousIMP = class_replaceMethod( diff --git a/Sources/InterposeKit/Hooks/HookStrategy/HookStrategy.swift b/Sources/InterposeKit/Hooks/HookStrategy/HookStrategy.swift index ac2d2ff..8d78136 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/HookStrategy.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/HookStrategy.swift @@ -41,7 +41,7 @@ extension HookStrategy { return originalIMP } - fatalError( + Interpose.fail( """ No original implementation found for selector \(self.selector) on \(self.class). \ This likely indicates a corrupted or misconfigured class. diff --git a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookRegistry.swift b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookRegistry.swift index 901a32c..e32176c 100644 --- a/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookRegistry.swift +++ b/Sources/InterposeKit/Hooks/HookStrategy/ObjectHookStrategy/ObjectHookRegistry.swift @@ -8,7 +8,7 @@ internal enum ObjectHookRegistry { for imp: IMP ) { guard let block = imp_getBlock(imp) else { - fatalError("IMP does not point to a block.") + Interpose.fail("IMP does not point to a block.") } objc_setAssociatedObject( diff --git a/Sources/InterposeKit/Interpose+Watcher.swift b/Sources/InterposeKit/Interpose+Watcher.swift index 4dc9645..447751f 100644 --- a/Sources/InterposeKit/Interpose+Watcher.swift +++ b/Sources/InterposeKit/Interpose+Watcher.swift @@ -97,7 +97,7 @@ private struct InterposeWatcher { // 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).") + Interpose.fail("Error while executing task: \(error).") #endif } return false diff --git a/Sources/InterposeKit/Interpose.swift b/Sources/InterposeKit/Interpose.swift index 7b614a1..224a54b 100644 --- a/Sources/InterposeKit/Interpose.swift +++ b/Sources/InterposeKit/Interpose.swift @@ -63,13 +63,15 @@ final public class Interpose { // 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) { + static func log(_ object: Any) { if isLoggingEnabled { print("[InterposeKit] \(object)") } } + + static func fail(_ message: String) -> Never { + fatalError("[InterposeKit] \(message)") + } } From 334cc6d102bb6f7ef332804171157a35ab90bb8c Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Sat, 29 Mar 2025 11:10:10 +0100 Subject: [PATCH 67/70] Switched Interpose to static methods --- .../Deprecated/Interpose+Deprecated.swift | 93 +++++++++---------- Sources/InterposeKit/Interpose+Watcher.swift | 4 +- Sources/InterposeKit/Interpose.swift | 54 +---------- Sources/InterposeKit/__Interpose.swift | 68 +++++++++----- Tests/InterposeKitTests/HookTests.swift | 10 +- .../ClassMethodInterposeTests.swift | 5 +- .../ToBePolished/InterposeKitTests.swift | 22 +++-- .../ToBePolished/KVOTests.swift | 3 +- .../ToBePolished/MultipleInterposing.swift | 26 +++--- .../ToBePolished/ObjectInterposeTests.swift | 4 +- 10 files changed, 128 insertions(+), 161 deletions(-) diff --git a/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift b/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift index d21530c..a50255c 100644 --- a/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift +++ b/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift @@ -4,34 +4,42 @@ extension Interpose { @available( *, - unavailable, - message: "Use 'init(_ class: AnyClass)' followed by 'applyHook(…)' instead." + 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 convenience init( _ class: AnyClass, builder: (Interpose) throws -> Void ) throws { - Interpose.fail("Interpose(_:builder:) is unavailable.") + Interpose.fail("Unavailable API") } @available( *, - unavailable, - message: "Use 'init(_ object: NSObject)' followed by 'applyHook(…)' instead." + 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 convenience init( _ object: NSObject, - builder: (Interpose) throws -> Void + builder: ((Interpose) throws -> Void)? = nil ) throws { - Interpose.fail("Interpose(_:builder:) is unavailable.") + Interpose.fail("Unavailable API") } @available( *, - deprecated, - message: """ - Use 'applyHook(for:methodSignature:hookSignature:_:)' instead and pass a materialized \ - selector. + unavailable, + message: """ + Instance method 'hook(_:methodSignature:hookSignature:_:)' is no longer supported. \ + Use 'Interpose.applyHook(on:for:methodSignature:hookSignature:build:)' instead. """ ) @discardableResult @@ -41,79 +49,68 @@ extension Interpose { hookSignature: HookSignature.Type, _ build: @escaping HookBuilder ) throws -> Hook { - try self.hook( - Selector(selectorName), - methodSignature: methodSignature, - hookSignature: hookSignature, - build - ) + Interpose.fail("Unavailable API") } @available( *, - deprecated, - renamed: "applyHook(for:methodSignature:hookSignature:_:)", - message: "Use 'applyHook(for:methodSignature:hookSignature:_:)' instead." + unavailable, + message: """ + Instance method 'hook(_:methodSignature:hookSignature:_:)' is no longer supported. \ + Use 'Interpose.applyHook(on:for:methodSignature:hookSignature:build:)' instead. + """ ) @discardableResult - public func hook ( + public func hook( _ selector: Selector, methodSignature: MethodSignature.Type, hookSignature: HookSignature.Type, _ build: @escaping HookBuilder ) throws -> Hook { - try self.applyHook( - for: selector, - methodSignature: methodSignature, - hookSignature: hookSignature, - build - ) + Interpose.fail("Unavailable API") } @available( *, - deprecated, - renamed: "prepareHook(for:methodSignature:hookSignature:_:)", - message: "Use 'prepareHook(for:methodSignature:hookSignature:_:)' instead." + unavailable, + message: """ + Instance method 'prepareHook(_:methodSignature:hookSignature:_:)' is no longer supported. \ + Use 'Interpose.prepareHook(on:for:methodSignature:hookSignature:build:)' instead. + """ ) - @discardableResult - public func prepareHook ( + public func prepareHook( _ selector: Selector, methodSignature: MethodSignature.Type, hookSignature: HookSignature.Type, _ build: @escaping HookBuilder ) throws -> Hook { - try self.prepareHook( - for: selector, - methodSignature: methodSignature, - hookSignature: hookSignature, - build - ) + Interpose.fail("Unavailable API") } @available( *, - unavailable, - message: """ - 'apply()' is no longer supported. Use 'applyHook(…)' instead to apply individual hooks. + unavailable, + message: """ + 'apply()' is no longer supported. Use 'Interpose.applyHook(…)' to apply individual hooks \ + directly using the new static API. """ ) @discardableResult public func apply(_ builder: ((Interpose) throws -> Void)? = nil) throws -> Interpose { - Interpose.fail("Interpose.apply() is unavailable.") + Interpose.fail("Unavailable API") } @available( *, - unavailable, - message: """ - 'revert()' is no longer supported. Keep a reference to the individual hooks and call \ - 'revert()' on them. + unavailable, + message: """ + 'revert()' is no longer supported. Keep a reference to each individual hook and call \ + 'revert()' on them directly. """ ) @discardableResult public func revert(_ builder: ((Interpose) throws -> Void)? = nil) throws -> Interpose { - Interpose.fail("Interpose.revert() is unavailable.") + Interpose.fail("Unavailable API") } - + } diff --git a/Sources/InterposeKit/Interpose+Watcher.swift b/Sources/InterposeKit/Interpose+Watcher.swift index 447751f..19b635f 100644 --- a/Sources/InterposeKit/Interpose+Watcher.swift +++ b/Sources/InterposeKit/Interpose+Watcher.swift @@ -53,8 +53,8 @@ extension Interpose { } func tryExecute() throws -> Bool { - guard let `class` = NSClassFromString(className), let builder = self.builder else { return false } - let interposer = Interpose(`class`) + guard let builder = self.builder else { return false } + let interposer = Interpose() try builder(interposer) if let completion = self.completion { completion() diff --git a/Sources/InterposeKit/Interpose.swift b/Sources/InterposeKit/Interpose.swift index 224a54b..b3778e6 100644 --- a/Sources/InterposeKit/Interpose.swift +++ b/Sources/InterposeKit/Interpose.swift @@ -5,58 +5,8 @@ import Foundation /// 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 - - /// If Interposing is object-based, this is set. - public let object: NSObject? - - /// Initializes an instance of Interpose for a specific class. - public init(_ `class`: AnyClass) { - self.class = `class` - self.object = nil - } - - /// Initialize with a single object to interpose. - public init(_ object: NSObject) throws { - self.object = object - self.class = type(of: object) - } - - /// Hook an `@objc dynamic` instance method via selector on the current class. - @discardableResult - public func applyHook ( - for selector: Selector, - methodSignature: MethodSignature.Type, - hookSignature: HookSignature.Type, - _ build: @escaping HookBuilder - ) throws -> Hook { - let hook = try self.prepareHook( - for: selector, - methodSignature: methodSignature, - hookSignature: hookSignature, - build - ) - try hook.apply() - return hook - } - - /// Prepares a hook, but does not call apply immediately. - @discardableResult - public func prepareHook ( - for selector: Selector, - methodSignature: MethodSignature.Type, - hookSignature: HookSignature.Type, - _ build: @escaping HookBuilder - ) throws -> Hook { - var hook: Hook - if let object = self.object { - hook = try Hook(target: .object(object), selector: selector, build: build) - } else { - hook = try Hook(target: .class(`class`), selector: selector, build: build) - } - return hook - } + + public init() {} } diff --git a/Sources/InterposeKit/__Interpose.swift b/Sources/InterposeKit/__Interpose.swift index d6f47d1..81149e4 100644 --- a/Sources/InterposeKit/__Interpose.swift +++ b/Sources/InterposeKit/__Interpose.swift @@ -1,55 +1,75 @@ import ObjectiveC -public final class __Interpose { - +extension Interpose { + // ============================================================================ // - // MARK: Initialization + // MARK: Class Hooks // ============================================================================ // - public init(class: AnyClass) { - self.target = .class(`class`) + 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 + ) } - public init(object: NSObject) { - self.target = .object(object) + @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: Configuration - // ============================================================================ // - - /// The target of the hooks created via this factory. - private let target: HookTarget - - // ============================================================================ // - // MARK: Hook Creation + // MARK: Object Hooks // ============================================================================ // - /// Creates and returns a hook in pending state. - public func prepareHook( + public static func prepareHook( + on object: NSObject, for selector: Selector, methodSignature: MethodSignature.Type, hookSignature: HookSignature.Type, build: @escaping HookBuilder ) throws -> Hook { - return try Hook( - target: self.target, + try Hook( + target: .object(object), selector: selector, build: build ) } - /// Creates a hook, applies it, and returns it in one go. @discardableResult - public func applyHook( + public static func applyHook( + on object: NSObject, for selector: Selector, methodSignature: MethodSignature.Type, hookSignature: HookSignature.Type, build: @escaping HookBuilder ) throws -> Hook { - let hook = try Hook( - target: self.target, - selector: selector, + let hook = try prepareHook( + on: object, + for: selector, + methodSignature: methodSignature, + hookSignature: hookSignature, build: build ) try hook.apply() diff --git a/Tests/InterposeKitTests/HookTests.swift b/Tests/InterposeKitTests/HookTests.swift index 8dff124..15c167d 100644 --- a/Tests/InterposeKitTests/HookTests.swift +++ b/Tests/InterposeKitTests/HookTests.swift @@ -10,9 +10,8 @@ fileprivate class ExampleSubclass: ExampleClass {} final class HookTests: InterposeKitTestCase { func testStates_success() throws { - let interposer = Interpose(ExampleClass.self) - - let hook = try interposer.prepareHook( + 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 @@ -30,10 +29,9 @@ final class HookTests: InterposeKitTestCase { func testStates_failure() throws { // Interpose on a subclass that inherits but does not implement `foo`. - let interposer = Interpose(ExampleSubclass.self) - // We can prepare a hook, as the method is accessible from the subclass. - let hook = try interposer.prepareHook( + 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 diff --git a/Tests/InterposeKitTests/ToBePolished/ClassMethodInterposeTests.swift b/Tests/InterposeKitTests/ToBePolished/ClassMethodInterposeTests.swift index 848f4ae..a81abc2 100644 --- a/Tests/InterposeKitTests/ToBePolished/ClassMethodInterposeTests.swift +++ b/Tests/InterposeKitTests/ToBePolished/ClassMethodInterposeTests.swift @@ -4,10 +4,9 @@ import XCTest final class ClassMethodInterposeTests: InterposeKitTestCase { func testClassMethod() { - let interposer = Interpose(TestClass.self) - XCTAssertThrowsError( - try interposer.prepareHook( + try Interpose.prepareHook( + on: TestClass.self, for: #selector(getter: TestClass.staticInt), methodSignature: (@convention(c) (NSObject, Selector) -> Int).self, hookSignature: (@convention(block) (NSObject) -> Int).self diff --git a/Tests/InterposeKitTests/ToBePolished/InterposeKitTests.swift b/Tests/InterposeKitTests/ToBePolished/InterposeKitTests.swift index 422bb0e..b750b05 100644 --- a/Tests/InterposeKitTests/ToBePolished/InterposeKitTests.swift +++ b/Tests/InterposeKitTests/ToBePolished/InterposeKitTests.swift @@ -12,8 +12,8 @@ final class InterposeKitTests: InterposeKitTestCase { XCTAssertEqual(testObj.sayHi(), testClassHi) // Functions need to be `@objc dynamic` to be hookable. - let interposer = Interpose(TestClass.self) - let hook = try interposer.applyHook( + 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 @@ -47,8 +47,8 @@ final class InterposeKitTests: InterposeKitTestCase { XCTAssertEqual(testObj.sayHi(), testClassHi + testSubclass) // Swizzle test class - let interposer = Interpose(TestClass.self) - let hook = try interposer.applyHook( + 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 @@ -63,7 +63,8 @@ final class InterposeKitTests: InterposeKitTestCase { XCTAssertEqual(testObj.sayHi(), testClassHi + testString + testSubclass) // Swizzle subclass, automatically applys - let interposedSubclass = try Interpose(TestSubclass.self).applyHook( + 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 @@ -87,7 +88,8 @@ final class InterposeKitTests: InterposeKitTestCase { } // Swizzle test class - let interposer = try Interpose(TestClass.self).applyHook( + 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 @@ -113,8 +115,8 @@ final class InterposeKitTests: InterposeKitTestCase { } // Swizzle test class - let interposer = Interpose(TestClass.self) - let hook = try interposer.applyHook( + 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 @@ -141,8 +143,8 @@ final class InterposeKitTests: InterposeKitTestCase { } let object = TestClass() - let interposer = try Interpose(object) - let hook = try interposer.applyHook( + let hook = try Interpose.applyHook( + on: object, for: #selector(TestClass.doNothing), methodSignature: (@convention(c) (NSObject, Selector) -> Void).self, hookSignature: (@convention(block) (NSObject) -> Void).self diff --git a/Tests/InterposeKitTests/ToBePolished/KVOTests.swift b/Tests/InterposeKitTests/ToBePolished/KVOTests.swift index 2946d46..fbc36d1 100644 --- a/Tests/InterposeKitTests/ToBePolished/KVOTests.swift +++ b/Tests/InterposeKitTests/ToBePolished/KVOTests.swift @@ -33,7 +33,8 @@ final class KVOTests: InterposeKitTestCase { XCTAssertEqual(testObj.age, 2) // Hooking is expected to fail XCTAssertThrowsError( - try Interpose(testObj).prepareHook( + try Interpose.prepareHook( + on: testObj, for: #selector(getter: TestClass.age), methodSignature: (@convention(c) (NSObject, Selector) -> Int).self, hookSignature: (@convention(block) (NSObject) -> Int).self diff --git a/Tests/InterposeKitTests/ToBePolished/MultipleInterposing.swift b/Tests/InterposeKitTests/ToBePolished/MultipleInterposing.swift index cbe4f2b..6e0326a 100644 --- a/Tests/InterposeKitTests/ToBePolished/MultipleInterposing.swift +++ b/Tests/InterposeKitTests/ToBePolished/MultipleInterposing.swift @@ -12,8 +12,8 @@ final class MultipleInterposingTests: InterposeKitTestCase { XCTAssertEqual(testObj2.sayHi(), testClassHi) // Functions need to be `@objc dynamic` to be hookable. - let interposer = try Interpose(testObj) - let hook = try interposer.applyHook( + let hook = try Interpose.applyHook( + on: testObj, for: #selector(TestClass.sayHi), methodSignature: (@convention(c) (NSObject, Selector) -> String).self, hookSignature: (@convention(block) (NSObject) -> String).self @@ -45,24 +45,24 @@ final class MultipleInterposingTests: InterposeKitTestCase { let testObj = TestClass() XCTAssertEqual(testObj.age, 1) - let interpose = try Interpose(testObj) - let hook1 = try interpose.applyHook( + 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 - { _ in - return 3 - } + return { _ in 3 } } XCTAssertEqual(testObj.age, 3) - - let hook2 = try interpose.applyHook(for: #selector(getter: TestClass.age), - methodSignature: (@convention(c) (NSObject, Selector) -> Int).self, - hookSignature: (@convention(block) (NSObject) -> Int).self) { _ in { _ in - return 5 - } + + 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) diff --git a/Tests/InterposeKitTests/ToBePolished/ObjectInterposeTests.swift b/Tests/InterposeKitTests/ToBePolished/ObjectInterposeTests.swift index 6c62c0e..822abd9 100644 --- a/Tests/InterposeKitTests/ToBePolished/ObjectInterposeTests.swift +++ b/Tests/InterposeKitTests/ToBePolished/ObjectInterposeTests.swift @@ -84,8 +84,8 @@ final class ObjectInterposeTests: InterposeKitTestCase { XCTAssertEqual(testObj.returnInt(), returnIntDefault + returnIntOverrideOffset) // Interpose on TestClass itself! - let classInterposer = Interpose(TestClass.self) - let classHook = try classInterposer.applyHook( + 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 From e8340613b2b69db4436b80a320b1b502a6a9f4d0 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Sat, 29 Mar 2025 11:13:21 +0100 Subject: [PATCH 68/70] Commented out Interpose+Watcher.swift --- Sources/InterposeKit/Interpose+Watcher.swift | 108 ------------------ .../Legacy/Interpose+Watcher.swift | 108 ++++++++++++++++++ 2 files changed, 108 insertions(+), 108 deletions(-) delete mode 100644 Sources/InterposeKit/Interpose+Watcher.swift create mode 100644 Sources/InterposeKit/Legacy/Interpose+Watcher.swift diff --git a/Sources/InterposeKit/Interpose+Watcher.swift b/Sources/InterposeKit/Interpose+Watcher.swift deleted file mode 100644 index 19b635f..0000000 --- a/Sources/InterposeKit/Interpose+Watcher.swift +++ /dev/null @@ -1,108 +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 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/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 +// } +// } +// } +// } +//} From 5afa11f396d2011658be4dfb91ae5a0c699a4a19 Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Sat, 29 Mar 2025 11:16:32 +0100 Subject: [PATCH 69/70] Final polish to Interpose facade --- .../Deprecated/Interpose+Deprecated.swift | 6 +- Sources/InterposeKit/Interpose.swift | 102 +++++++++++++++--- Sources/InterposeKit/__Interpose.swift | 79 -------------- 3 files changed, 88 insertions(+), 99 deletions(-) delete mode 100644 Sources/InterposeKit/__Interpose.swift diff --git a/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift b/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift index a50255c..c3ae362 100644 --- a/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift +++ b/Sources/InterposeKit/Deprecated/Interpose+Deprecated.swift @@ -11,7 +11,7 @@ extension Interpose { installation, or 'Interpose.prepareHook(…)' for manual control. """ ) - public convenience init( + public init( _ class: AnyClass, builder: (Interpose) throws -> Void ) throws { @@ -27,7 +27,7 @@ extension Interpose { installation, or 'Interpose.prepareHook(…)' for manual control. """ ) - public convenience init( + public init( _ object: NSObject, builder: ((Interpose) throws -> Void)? = nil ) throws { @@ -95,7 +95,6 @@ extension Interpose { directly using the new static API. """ ) - @discardableResult public func apply(_ builder: ((Interpose) throws -> Void)? = nil) throws -> Interpose { Interpose.fail("Unavailable API") } @@ -108,7 +107,6 @@ extension Interpose { 'revert()' on them directly. """ ) - @discardableResult public func revert(_ builder: ((Interpose) throws -> Void)? = nil) throws -> Interpose { Interpose.fail("Unavailable API") } diff --git a/Sources/InterposeKit/Interpose.swift b/Sources/InterposeKit/Interpose.swift index b3778e6..ca02b73 100644 --- a/Sources/InterposeKit/Interpose.swift +++ b/Sources/InterposeKit/Interpose.swift @@ -1,27 +1,97 @@ -import Foundation +import ObjectiveC /// 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 { +public enum Interpose { - public init() {} + // ============================================================================ // + // MARK: Class Hooks + // ============================================================================ // -} - -// MARK: Logging - -extension Interpose { + 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 - - static func log(_ object: Any) { - if isLoggingEnabled { - print("[InterposeKit] \(object)") + + internal static func log(_ message: String) { + if self.isLoggingEnabled { + print("[InterposeKit] \(message)") } } - static func fail(_ message: String) -> Never { + internal static func fail(_ message: String) -> Never { fatalError("[InterposeKit] \(message)") } + } diff --git a/Sources/InterposeKit/__Interpose.swift b/Sources/InterposeKit/__Interpose.swift deleted file mode 100644 index 81149e4..0000000 --- a/Sources/InterposeKit/__Interpose.swift +++ /dev/null @@ -1,79 +0,0 @@ -import ObjectiveC - -extension 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 - } - -} From 11cfcb03147b2c90db720bb21d8e28621e621e3a Mon Sep 17 00:00:00 2001 From: Lukas Kubanek Date: Sat, 29 Mar 2025 11:19:16 +0100 Subject: [PATCH 70/70] Removed irrelevant deprecation --- .../Deprecated/NSObject+Deprecated.swift | 20 ++----------------- 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift b/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift index 9da407d..35842eb 100644 --- a/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift +++ b/Sources/InterposeKit/Deprecated/NSObject+Deprecated.swift @@ -2,22 +2,6 @@ import ObjectiveC extension NSObject { - @available(*, deprecated, renamed: "applyHook(for:methodSignature:hookSignature:build:)") - @discardableResult - public func addHook( - for selector: Selector, - methodSignature: MethodSignature.Type, - hookSignature: HookSignature.Type, - implementation: @escaping HookBuilder - ) throws -> Hook { - return try self.applyHook( - for: selector, - methodSignature: methodSignature, - hookSignature: hookSignature, - build: implementation - ) - } - @available(*, deprecated, renamed: "applyHook(for:methodSignature:hookSignature:build:)") @discardableResult public func hook ( @@ -31,11 +15,11 @@ extension NSObject { "There should not be a way to cast an NSObject to AnyClass." ) - return try self.addHook( + return try self.applyHook( for: selector, methodSignature: methodSignature, hookSignature: hookSignature, - implementation: build + build: build ) }