|
| 1 | +Instead of [adding new methods and exchanging implementations](https://nshipster.com/method-swizzling/) based on [`method_exchangeImplementations`](https://developer.apple.com/documentation/objectivec/1418769-method_exchangeimplementations), this library replaces the implementation directly using [`class_replaceMethod`](https://developer.apple.com/documentation/objectivec/1418677-class_replacemethod). This avoids some of [the usual problems with swizzling](https://pspdfkit.com/blog/2019/swizzling-in-swift/). |
| 2 | + |
| 3 | +## Usage |
| 4 | + |
| 5 | +Let's say you want to amend `sayHi` from `TestClass`: |
| 6 | + |
| 7 | +```swift |
| 8 | +class TestClass: NSObject { |
| 9 | + // Functions need to be marked as `@objc dynamic` or written in Objective-C. |
| 10 | + @objc dynamic func sayHi() -> String { |
| 11 | + print("Calling sayHi") |
| 12 | + return "Hi there 👋" |
| 13 | + } |
| 14 | +} |
| 15 | + |
| 16 | +let interposer = try Interpose(TestClass.self) { |
| 17 | + try $0.prepareHook( |
| 18 | + #selector(TestClass.sayHi), |
| 19 | + methodSignature: (@convention(c) (AnyObject, Selector) -> String).self, |
| 20 | + hookSignature: (@convention(block) (AnyObject) -> String).self) { |
| 21 | + store in { `self` in |
| 22 | + print("Before Interposing \(`self`)") |
| 23 | + let string = store.original(`self`, store.selector) // free to skip |
| 24 | + print("After Interposing \(`self`)") |
| 25 | + return string + "and Interpose" |
| 26 | + } |
| 27 | + } |
| 28 | +} |
| 29 | + |
| 30 | +// Don't need the hook anymore? Undo is built-in! |
| 31 | +interposer.revert() |
| 32 | +``` |
| 33 | + |
| 34 | +Want to hook just a single instance? No problem! |
| 35 | + |
| 36 | +```swift |
| 37 | +let hook = try testObj.hook( |
| 38 | + #selector(TestClass.sayHi), |
| 39 | + methodSignature: (@convention(c) (AnyObject, Selector) -> String).self, |
| 40 | + hookSignature: (@convention(block) (AnyObject) -> String).self) { store in { `self` in |
| 41 | + return store.original(`self`, store.selector) + "just this instance" |
| 42 | + } |
| 43 | +} |
| 44 | +``` |
| 45 | + |
| 46 | +Here's what we get when calling `print(TestClass().sayHi())` |
| 47 | +``` |
| 48 | +[Interposer] Swizzled -[TestClass.sayHi] IMP: 0x000000010d9f4430 -> 0x000000010db36020 |
| 49 | +Before Interposing <InterposeTests.TestClass: 0x7fa0b160c1e0> |
| 50 | +Calling sayHi |
| 51 | +After Interposing <InterposeTests.TestClass: 0x7fa0b160c1e0> |
| 52 | +Hi there 👋 and Interpose |
| 53 | +``` |
| 54 | + |
| 55 | +## Object Hooking |
| 56 | + |
| 57 | +InterposeKit can hook classes and object. Class hooking is similar to swizzling, but object-based hooking offers a variety of new ways to set hooks. This is achieved via creating a dynamic subclass at runtime. |
| 58 | + |
| 59 | +Caveat: Hooking will fail with an error if the object uses KVO. The KVO machinery is fragile and it's to easy to cause a crash. Using KVO after a hook was created is supported and will not cause issues. |
| 60 | + |
| 61 | +## Various ways to define the signature |
| 62 | + |
| 63 | +Next to using `methodSignature` and `hookSignature`, following variants to define the signature are also possible: |
| 64 | + |
| 65 | +### methodSignature + casted block |
| 66 | +``` |
| 67 | +let interposer = try Interpose(testObj) { |
| 68 | + try $0.hook( |
| 69 | + #selector(TestClass.sayHi), |
| 70 | + methodSignature: (@convention(c) (AnyObject, Selector) -> String).self) { store in { `self` in |
| 71 | + let string = store.original(`self`, store.selector) |
| 72 | + return string + testString |
| 73 | + } as @convention(block) (AnyObject) -> String } |
| 74 | +} |
| 75 | +``` |
| 76 | + |
| 77 | +### Define type via store object |
| 78 | +``` |
| 79 | +// Functions need to be `@objc dynamic` to be hookable. |
| 80 | +let interposer = try Interpose(testObj) { |
| 81 | + try $0.hook(#selector(TestClass.returnInt)) { (store: TypedHook<@convention(c) (AnyObject, Selector) -> Int, @convention(block) (AnyObject) -> Int>) in { |
| 82 | +
|
| 83 | + // You're free to skip calling the original implementation. |
| 84 | + let int = store.original($0, store.selector) |
| 85 | + return int + returnIntOverrideOffset |
| 86 | + } |
| 87 | + } |
| 88 | +} |
| 89 | +``` |
| 90 | + |
| 91 | +## FAQ |
| 92 | + |
| 93 | +### Why didn't you call it Interpose? "Kit" feels so old-school. |
| 94 | +Naming it Interpose was the plan, but then [SR-898](https://bugs.swift.org/browse/SR-898) came. While having a class with the same name as the module works [in most cases](https://forums.swift.org/t/frameworkname-is-not-a-member-type-of-frameworkname-errors-inside-swiftinterface/28962), [this breaks](https://twitter.com/BalestraPatrick/status/1260928023357878273) when you enable build-for-distribution. There's some [discussion](https://forums.swift.org/t/pitch-fully-qualified-name-syntax/28482/81) to get that fixed, but this will be more towards end of 2020, if even. |
| 95 | + |
| 96 | +### I want to hook into Swift! You made another ObjC swizzle thingy, why? |
| 97 | +UIKit and AppKit won't go away, and the bugs won't go away either. I see this as a rarely-needed instrument to fix system-level issues. There are ways to do some of that in Swift, but that's a separate (and much more difficult!) project. (See [Dynamic function replacement #20333](https://github.com/apple/swift/pull/20333) aka `@_dynamicReplacement` for details.) |
| 98 | + |
| 99 | +### Can I ship this? |
| 100 | +Yes, absolutely. The goal for this one project is a simple library that doesn't try to be too smart. I did this in [Aspects](https://github.com/steipete/Aspects) and while I loved this to no end, it's problematic and can cause side-effects with other code that tries to be clever. InterposeKit is boring, so you don't have to worry about conditions like "We added New Relic to our app and now [your thing crashes](https://github.com/steipete/Aspects/issues/21)". |
| 101 | + |
| 102 | +### It does not do X! |
| 103 | +Pull Requests welcome! You might wanna open a draft before to lay out what you plan, I want to keep the feature-set minimal so it stays simple and no-magic. |
| 104 | + |
| 105 | +## Installation |
| 106 | + |
| 107 | +Building InterposeKit requires Xcode 15+ or a Swift 5.9+ toolchain with the Swift Package Manager. |
| 108 | + |
| 109 | +### Swift Package Manager |
| 110 | + |
| 111 | +Add `.package(url: "https://github.com/steipete/InterposeKit.git", from: "0.0.1")` to your |
| 112 | +`Package.swift` file's `dependencies`. |
| 113 | + |
| 114 | +## Improvement Ideas |
| 115 | + |
| 116 | +- Write proposal to allow to [convert the calling convention of existing types](https://twitter.com/steipete/status/1266799174563041282?s=21). |
| 117 | +- Use the C block struct to perform type checking between Method type and C type (I do that in [Aspects library](https://github.com/steipete/Aspects)), it's still a runtime crash but could be at hook time, not when we call it. |
| 118 | +- Add a way to get all current hooks from an object/class. |
| 119 | +- Add a way to revert hooks without super helper. |
| 120 | +- Add a way to apply multiple hooks to classes |
| 121 | +- Enable hooking of class methods. |
| 122 | +- Add [dyld_dynamic_interpose](https://twitter.com/steipete/status/1258482647933870080) to hook pure C functions |
| 123 | +- Combine Promise-API for `Interpose.whenAvailable` for better error bubbling. |
| 124 | +- Experiment with [Swift function hooking](https://github.com/rodionovd/SWRoute/wiki/Function-hooking-in-Swift)? ⚡️ |
| 125 | +- Test against Swift Nightly as Cron Job |
| 126 | +- Switch to Trampolines to manage cases where other code overrides super, so we end up with a super call that's [not on top of the class hierarchy](https://github.com/steipete/InterposeKit/pull/15#discussion_r439871752). |
0 commit comments