Skip to content
This repository was archived by the owner on Jun 11, 2026. It is now read-only.

Commit d9aea26

Browse files
steipeteezoushen
andcommitted
feat: support weak object interposers
Co-authored-by: ezoushen <ezoushen@gmail.com>
1 parent 2492331 commit d9aea26

7 files changed

Lines changed: 170 additions & 23 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66

77
##### Enhancements
88

9-
* None.
9+
* Support weakly referenced object interposers, avoiding retain cycles when an interposer is associated with its target. Thanks to [@ezoushen](https://github.com/ezoushen).
1010

1111
##### Bug Fixes
1212

README.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,22 @@ InterposeKit can hook classes and object. Class hooking is similar to swizzling,
8787

8888
Caveat: Hooking will fail with an error if the object uses KVO or is backed by Core Foundation, such as `NSURL`. These objects rely on runtime behavior that is incompatible with InterposeKit's dynamic subclass. Using KVO after a hook was created is supported and will not cause issues.
8989

90+
Object interposers retain their target by default. Use a weak reference when associating an interposer back to its target, avoiding a retain cycle:
91+
92+
```swift
93+
let interposer = try Interpose(.weak(testObj)) {
94+
try $0.prepareHook(
95+
#selector(TestClass.sayHi),
96+
methodSignature: (@convention(c) (AnyObject, Selector) -> String).self,
97+
hookSignature: (@convention(block) (AnyObject) -> String).self
98+
) { store in
99+
{ object in store.original(object, store.selector) + "weakly held" }
100+
}
101+
}
102+
```
103+
104+
`interposer.object` becomes `nil` after a weakly referenced target deallocates. Operations that require the target then throw `InterposeError.objectDeallocated`.
105+
90106
## Various ways to define the signature
91107

92108
Next to using `methodSignature` and `hookSignature`, following variants to define the signature are also possible:

Sources/InterposeKit/InterposeError.swift

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ public enum InterposeError: LocalizedError {
3030
/// Core Foundation-backed objects do not support isa-swizzling from Swift.
3131
case coreFoundationObjectDetected(AnyObject)
3232

33+
/// A weakly referenced object deallocated before an operation that requires it.
34+
case objectDeallocated
35+
3336
/// Object is lying about it's actual class metadata.
3437
/// This usually happens when other swizzling libraries (like Aspects) also interfere with a class.
3538
/// While this might just work, it's not worth risking a crash, so similar to KVO this case is rejected.
@@ -73,6 +76,8 @@ extension InterposeError: Equatable {
7376
return "Unable to hook object that uses Key Value Observing: \(obj)"
7477
case .coreFoundationObjectDetected(let obj):
7578
return "Unable to hook Core Foundation-backed object: \(obj)"
79+
case .objectDeallocated:
80+
return "Unable to hook an object that has been deallocated"
7681
case .objectPosingAsDifferentClass(let obj, let actualClass):
7782
return "Unable to hook \(type(of: obj)) posing as \(NSStringFromClass(actualClass))/"
7883
case .invalidState(let expectedState):

Sources/InterposeKit/InterposeKit.swift

Lines changed: 67 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,25 @@ import CoreFoundation
33
#endif
44
import Foundation
55

6+
private final class InterposeObjectReferenceStorage {
7+
private let strongObject: AnyObject?
8+
private weak var weakObject: AnyObject?
9+
10+
init(strong object: AnyObject) {
11+
strongObject = object
12+
weakObject = nil
13+
}
14+
15+
init(weak object: AnyObject) {
16+
strongObject = nil
17+
weakObject = object
18+
}
19+
20+
var object: AnyObject? {
21+
strongObject ?? weakObject
22+
}
23+
}
24+
625
extension NSObject {
726
/// Hook an `@objc dynamic` instance method via selector on the current object or class..
827
@discardableResult public func hook<MethodSignature, HookSignature> (
@@ -34,13 +53,45 @@ extension NSObject {
3453
/// Methods are hooked via replacing the implementation, instead of the usual exchange.
3554
/// Supports both swizzling classes and individual objects.
3655
final public class Interpose {
56+
/// Controls whether an object-based interposer retains its target.
57+
public struct ObjectReference {
58+
private let storage: InterposeObjectReferenceStorage
59+
60+
init(strong object: AnyObject) {
61+
storage = InterposeObjectReferenceStorage(strong: object)
62+
}
63+
64+
init(weak object: AnyObject) {
65+
storage = InterposeObjectReferenceStorage(weak: object)
66+
}
67+
68+
/// Creates a reference that retains the target object.
69+
public static func strong(_ object: NSObject) -> ObjectReference {
70+
ObjectReference(strong: object)
71+
}
72+
73+
/// Creates a reference that allows the target object to deallocate.
74+
public static func weak(_ object: NSObject) -> ObjectReference {
75+
ObjectReference(weak: object)
76+
}
77+
78+
/// The target object, or `nil` after a weakly referenced target deallocates.
79+
public var object: AnyObject? {
80+
storage.object
81+
}
82+
}
83+
3784
/// Stores swizzle hooks and executes them at once.
3885
public let `class`: AnyClass
3986
/// Lists all hooks for the current interpose class object.
4087
public private(set) var hooks: [AnyHook] = []
4188

4289
/// If Interposing is object-based, this is set.
43-
public let object: AnyObject?
90+
public var object: AnyObject? {
91+
objectReference?.object
92+
}
93+
94+
private let objectReference: ObjectReference?
4495

4596
// Checks if a object is posing as a different class
4697
// via implementing 'class' and returning something else.
@@ -87,7 +138,7 @@ final public class Interpose {
87138
/// If `builder` is present, `apply()` is automatically called.
88139
public init(_ `class`: AnyClass, builder: ((Interpose) throws -> Void)? = nil) throws {
89140
self.class = `class`
90-
self.object = nil
141+
self.objectReference = nil
91142

92143
// Only apply if a builder is present
93144
if let builder = builder {
@@ -96,10 +147,18 @@ final public class Interpose {
96147
}
97148

98149
/// Initialize with a single object to interpose.
99-
public init(_ object: NSObject, builder: ((Interpose) throws -> Void)? = nil) throws {
100-
self.object = object
101-
self.class = type(of: object)
150+
public convenience init(_ object: NSObject, builder: ((Interpose) throws -> Void)? = nil) throws {
151+
try self.init(.strong(object), builder: builder)
152+
}
102153

154+
/// Initialize with a strong or weak reference to a single object.
155+
public init(_ objectReference: ObjectReference, builder: ((Interpose) throws -> Void)? = nil) throws {
156+
guard let object = objectReference.object else {
157+
throw InterposeError.objectDeallocated
158+
}
159+
160+
self.objectReference = objectReference
161+
self.class = type(of: object)
103162
try Self.validateObjectForHooking(object)
104163

105164
// Only apply if a builder is present
@@ -145,8 +204,9 @@ final public class Interpose {
145204
_ implementation: (TypedHook<MethodSignature, HookSignature>) -> HookSignature?)
146205
throws -> TypedHook<MethodSignature, HookSignature> {
147206
var hook: TypedHook<MethodSignature, HookSignature>
148-
if let object = self.object {
149-
hook = try ObjectHook(object: object, selector: selector, implementation: implementation)
207+
if let objectReference = self.objectReference {
208+
hook = try ObjectHook(
209+
objectReference: objectReference, selector: selector, implementation: implementation)
150210
} else {
151211
hook = try ClassHook(class: `class`, selector: selector, implementation: implementation)
152212
}

Sources/InterposeKit/InterposeSubclass.swift

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,6 @@ class InterposeSubclass {
1818
}
1919
}
2020

21-
/// The object that is being hooked.
22-
let object: AnyObject
23-
2421
/// Subclass that we create on the fly
2522
private(set) var dynamicClass: AnyClass
2623

@@ -30,12 +27,11 @@ class InterposeSubclass {
3027
/// Making KVO and Object-based hooking work at the same time is difficult.
3128
/// If we make a dynamic subclass over KVO, invalidating the token crashes in cache_getImp.
3229
init(object: AnyObject) throws {
33-
self.object = object
3430
dynamicClass = type(of: object) // satisfy set to something
35-
dynamicClass = try getExistingSubclass() ?? createSubclass()
31+
dynamicClass = try Self.getExistingSubclass(for: object) ?? Self.createSubclass(for: object)
3632
}
3733

38-
private func createSubclass() throws -> AnyClass {
34+
private static func createSubclass(for object: AnyObject) throws -> AnyClass {
3935
let perceivedClass: AnyClass = type(of: object)
4036
let actualClass: AnyClass = object_getClass(object)!
4137

@@ -50,7 +46,7 @@ class InterposeSubclass {
5046
return existingClass
5147
} else {
5248
guard let subclass: AnyClass = objc_allocateClassPair(actualClass, cString, 0) else { return nil }
53-
replaceGetClass(in: subclass, decoy: perceivedClass)
49+
Self.replaceGetClass(in: subclass, decoy: perceivedClass)
5450
objc_registerClassPair(subclass)
5551
return subclass
5652
}
@@ -67,7 +63,7 @@ class InterposeSubclass {
6763
}
6864

6965
/// We need to reuse a dynamic subclass if the object already has one.
70-
private func getExistingSubclass() -> AnyClass? {
66+
private static func getExistingSubclass(for object: AnyObject) -> AnyClass? {
7167
let actualClass: AnyClass = object_getClass(object)!
7268
if Self.isInterposeSubclass(actualClass) {
7369
return actualClass
@@ -80,7 +76,7 @@ class InterposeSubclass {
8076
}
8177

8278
#if !os(Linux)
83-
private func replaceGetClass(in class: AnyClass, decoy perceivedClass: AnyClass) {
79+
private static func replaceGetClass(in class: AnyClass, decoy perceivedClass: AnyClass) {
8480
// crashes on linux
8581
let getClass: @convention(block) (AnyObject) -> AnyClass = { _ in
8682
perceivedClass
@@ -112,6 +108,6 @@ class InterposeSubclass {
112108
#else
113109
func addSuperTrampoline(selector: Selector) { }
114110
class var supportsSuperTrampolines: Bool { return false }
115-
private func replaceGetClass(in class: AnyClass, decoy perceivedClass: AnyClass) {}
111+
private static func replaceGetClass(in class: AnyClass, decoy perceivedClass: AnyClass) {}
116112
#endif
117113
}

Sources/InterposeKit/ObjectHook.swift

Lines changed: 27 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,14 @@ extension Interpose {
77
final public class ObjectHook<MethodSignature, HookSignature>: TypedHook<MethodSignature, HookSignature> {
88

99
/// The object that is being hooked.
10-
public let object: AnyObject
10+
public var object: AnyObject {
11+
guard let object = objectReference.object else {
12+
preconditionFailure("The interposed object has been deallocated")
13+
}
14+
return object
15+
}
16+
17+
private let objectReference: Interpose.ObjectReference
1118

1219
/// Subclass that we create on the fly
1320
var interposeSubclass: InterposeSubclass?
@@ -16,10 +23,21 @@ extension Interpose {
1623
let generatesSuperIMP = InterposeSubclass.supportsSuperTrampolines
1724

1825
/// Initialize a new hook to interpose an instance method.
19-
public init(object: AnyObject, selector: Selector,
20-
implementation: (ObjectHook<MethodSignature, HookSignature>) -> HookSignature?) throws {
26+
public convenience init(object: AnyObject, selector: Selector,
27+
implementation: (ObjectHook<MethodSignature, HookSignature>) -> HookSignature?) throws {
28+
try self.init(
29+
objectReference: Interpose.ObjectReference(strong: object),
30+
selector: selector,
31+
implementation: implementation)
32+
}
33+
34+
init(objectReference: Interpose.ObjectReference, selector: Selector,
35+
implementation: (ObjectHook<MethodSignature, HookSignature>) -> HookSignature?) throws {
36+
guard let object = objectReference.object else {
37+
throw InterposeError.objectDeallocated
38+
}
2139
try Interpose.validateObjectForHooking(object)
22-
self.object = object
40+
self.objectReference = objectReference
2341
try super.init(class: type(of: object), selector: selector)
2442
let block = implementation(self) as AnyObject
2543
try validateImplementationBlock(block)
@@ -85,6 +103,9 @@ extension Interpose {
85103

86104
override func replaceImplementation() throws {
87105
let method = try validate()
106+
guard let object = objectReference.object else {
107+
throw InterposeError.objectDeallocated
108+
}
88109

89110
// Check if there's an existing subclass we can reuse.
90111
// Create one at runtime if there is none.
@@ -180,7 +201,8 @@ extension Interpose {
180201
#if DEBUG
181202
extension Interpose.ObjectHook: CustomDebugStringConvertible {
182203
public var debugDescription: String {
183-
return "\(selector) of \(object) -> \(String(describing: original))"
204+
let objectDescription = objectReference.object.map(String.init(describing:)) ?? "<deallocated>"
205+
return "\(selector) of \(objectDescription) -> \(String(describing: original))"
184206
}
185207
}
186208
#endif

Tests/InterposeKitTests/ObjectInterposeTests.swift

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,54 @@ import XCTest
44

55
final class ObjectInterposeTests: InterposeKitTestCase {
66

7+
#if !os(Linux)
8+
func testWeakInterposeBreaksAssociatedObjectCycle() throws {
9+
weak var weakObject: TestClass?
10+
var associationKey: UInt8 = 0
11+
12+
try autoreleasepool {
13+
let object = TestClass()
14+
weakObject = object
15+
let interpose = try Interpose(.weak(object)) {
16+
try $0.prepareHook(
17+
#selector(TestClass.sayHi),
18+
methodSignature: (@convention(c) (AnyObject, Selector) -> String).self,
19+
hookSignature: (@convention(block) (AnyObject) -> String).self
20+
) { store in
21+
{ object in store.original(object, store.selector) }
22+
}
23+
}
24+
objc_setAssociatedObject(object, &associationKey, interpose, .OBJC_ASSOCIATION_RETAIN)
25+
26+
XCTAssertNotNil(interpose.object)
27+
XCTAssertEqual(object.sayHi(), testClassHi)
28+
}
29+
30+
XCTAssertNil(weakObject)
31+
}
32+
33+
func testWeakInterposeReportsDeallocatedObject() throws {
34+
var interpose: Interpose?
35+
36+
autoreleasepool {
37+
let object = TestClass()
38+
interpose = try? Interpose(.weak(object))
39+
XCTAssertNotNil(interpose?.object)
40+
}
41+
42+
XCTAssertNil(interpose?.object)
43+
XCTAssertThrowsError(try interpose?.prepareHook(
44+
#selector(TestClass.sayHi),
45+
methodSignature: (@convention(c) (AnyObject, Selector) -> String).self,
46+
hookSignature: (@convention(block) (AnyObject) -> String).self
47+
) { _ in
48+
{ _ in testClassHi }
49+
}) { error in
50+
XCTAssertEqual(error as? InterposeError, .objectDeallocated)
51+
}
52+
}
53+
#endif
54+
755
func testRejectsMismatchedHookReturnType() {
856
let testObj = TestClass()
957

0 commit comments

Comments
 (0)