InterposeKit is a modern library to swizzle elegantly in Swift, supporting hooks on classes and individual objects. It is well-documented, tested, written in "pure" Swift 5.2 and works on @objc dynamic
Swift functions or Objective-C instance methods. The Inspiration for InterposeKit was a race condition in Mac Catalyst, which required tricky swizzling to fix, I also wrote up implementation thoughts on my blog.
Instead of adding new methods and exchanging implementations based on method_exchangeImplementations
, this library replaces the implementation directly using class_replaceMethod
. This avoids some of the usual problems with swizzling.
You can call the original implementation and add code before, instead or after a method call.
This is similar to the Aspects library, but doesn't yet do dynamic subclassing.
Compare: Swizzling a property without helper and with InterposeKit
Usage
Let's say you want to amend sayHi
from TestClass
:
class TestClass: NSObject {
// Functions need to be marked as `@objc dynamic` or written in Objective-C.
@objc dynamic func sayHi() -> String {
print("Calling sayHi")
return "Hi there 👋"
}
}
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 { `self` in
print("Before Interposing \(`self`)")
let string = store.original(`self`, store.selector) // free to skip
print("After Interposing \(`self`)")
return string + "and Interpose"
}
}
}
// Don't need the hook anymore? Undo is built-in!
interposer.revert()
Want to hook just a single instance? No problem!
let hook = try testObj.hook(
#selector(TestClass.sayHi),
methodSignature: (@convention(c) (AnyObject, Selector) -> String).self,
hookSignature: (@convention(block) (AnyObject) -> String).self) { store in { `self` in
return store.original(`self`, store.selector) + "just this instance"
}
}
Here's what we get when calling print(TestClass().sayHi())
[Interposer] Swizzled -[TestClass.sayHi] IMP: 0x000000010d9f4430 -> 0x000000010db36020
Before Interposing <InterposeTests.TestClass: 0x7fa0b160c1e0>
Calling sayHi
After Interposing <InterposeTests.TestClass: 0x7fa0b160c1e0>
Hi there 👋 and Interpose
Key Features
- Interpose directly modifies the implementation of a
Method
, which is safer than selector-based swizzling. - Interpose works on classes and individual objects.
- Hooks can easily be undone via calling
revert()
. This also checks and errors if someone else changed stuff in between. - Mostly Swift, no
NSInvocation
, which requires boxing and can be slow. - No Type checking. If you have a typo or forget a
convention
part, this will crash at runtime. - Yes, you have to type the resulting type twice This is a tradeoff, else we need
NSInvocation
. - Delayed Interposing helps when a class is loaded at runtime. This is useful for Mac Catalyst.
Object Hooking
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.
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.
Various ways to define the signature
Next to using methodSignature
and hookSignature
, following variants to define the signature are also possible:
methodSignature + casted block
let interposer = try Interpose(testObj) {
try $0.hook(
#selector(TestClass.sayHi),
methodSignature: (@convention(c) (AnyObject, Selector) -> String).self) { store in { `self` in
let string = store.original(`self`, store.selector)
return string + testString
} as @convention(block) (AnyObject) -> String }
}
Define type via store object
// Functions need to be `@objc dynamic` to be hookable.
let interposer = try Interpose(testObj) {
try $0.hook(#selector(TestClass.returnInt)) { (store: TypedHook<@convention(c) (AnyObject, Selector) -> Int, @convention(block) (AnyObject) -> Int>) in {
// You're free to skip calling the original implementation.
let int = store.original($0, store.selector)
return int + returnIntOverrideOffset
}
}
}
Delayed Hooking
Sometimes it can be necessary to hook a class deep in a system framework, which is loaded at a later time. Interpose has a solution for this and uses a hook in the dynamic linker to be notified whenever new classes are loaded.
try Interpose.whenAvailable(["RTIInput", "SystemSession"]) {
let lock = DispatchQueue(label: "com.steipete.document-state-hack")
try $0.hook("documentState", { store in { `self` in
lock.sync {
store((@convention(c) (AnyObject, Selector) -> AnyObject).self)(`self`, store.selector)
}} as @convention(block) (AnyObject) -> AnyObject})
try $0.hook("setDocumentState:", { store in { `self`, newValue in
lock.sync {
store((@convention(c) (AnyObject, Selector, AnyObject) -> Void).self)(`self`, store.selector, newValue)
}} as @convention(block) (AnyObject, AnyObject) -> Void})
}
FAQ
Why didn't you call it Interpose? "Kit" feels so old-school.
Naming it Interpose was the plan, but then SR-898 came. While having a class with the same name as the module works in most cases, this breaks when you enable build-for-distribution. There's some discussion to get that fixed, but this will be more towards end of 2020, if even.
I want to hook into Swift! You made another ObjC swizzle thingy, why?
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 aka @_dynamicReplacement
for details.)
Can I ship this?
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 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".
It does not do X!
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.
Installation
Building InterposeKit requires Xcode 11.4+ or a Swift 5.2+ toolchain with the Swift Package Manager.
Swift Package Manager
Add .package(url: "https://github.com/steipete/InterposeKit.git", from: "0.0.1")
to your
Package.swift
file's dependencies
.
CocoaPods
InterposeKit is on CocoaPods. Add pod 'InterposeKit'
to your Podfile
.
Carthage
Add github "steipete/InterposeKit"
to your Cartfile
.
Improvement Ideas
- Write proposal to allow to convert the calling convention of existing types.
- Use the C block struct to perform type checking between Method type and C type (I do that in Aspects library), it's still a runtime crash but could be at hook time, not when we call it.
- Add a way to get all current hooks from an object/class.
- Add a way to revert hooks without super helper.
- Add a way to apply multiple hooks to classes
- Enable hooking of class methods.
- Add dyld_dynamic_interpose to hook pure C functions
- Combine Promise-API for
Interpose.whenAvailable
for better error bubbling. - Experiment with Swift function hooking? ⚡️
- Test against Swift Nightly as Cron Job
- 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.
- I'm sure there's more - Pull Requests or comments very welcome!
Thanks
Special thanks to JP Simard who did such a great job in setting up Yams with GitHub Actions - this was extremely helpful to build CI here fast.
License
InterposeKit is MIT Licensed.