diff --git a/README.md b/README.md index ac27ae6..cf562ab 100644 --- a/README.md +++ b/README.md @@ -1,186 +1,239 @@ # InterposeKit -[![CI](https://github.com/structuredpath/InterposeKit/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/structuredpath/InterposeKit/actions/workflows/ci.yml) -![Xcode 15+](https://img.shields.io/badge/Xcode-15%2B-blue.svg) -![Swift 5.9+](https://img.shields.io/badge/Swift-5.9%2B-orange.svg) +[![CI](https://github.com/structuredpath/InterposeKit/actions/workflows/ci.yml/badge.svg?branch=main)](https://github.com/structuredpath/InterposeKit/actions/workflows/ci.yml) ![Swift 5.9+](https://img.shields.io/badge/Swift-5.9%2B-orange.svg) ![Xcode 15+](https://img.shields.io/badge/Xcode-15%2B-blue.svg) -InterposeKit is a modern library to swizzle elegantly in Swift, supporting hooks on classes and individual objects. It is [well-documented](http://interposekit.com/), [tested](https://github.com/steipete/InterposeKit/actions?query=workflow%3ASwiftPM), written in "pure" Swift and works on `@objc dynamic` Swift functions or Objective-C instance methods. The Inspiration for InterposeKit was [a race condition in Mac Catalyst](https://steipete.com/posts/mac-catalyst-crash-hunt/), which required tricky swizzling to fix, I also wrote up [implementation thoughts on my blog](https://steipete.com/posts/interposekit/). +**InterposeKit** is a modern library for hooking Objective-C methods in Swift, also known as method swizzling. It supports both class-based and object-based hooks, and it provides a clean, block-based, Swift-friendly API. -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/). +This is a continuation and modernization of [Peter Steinberger’s original implementation](https://github.com/steipete/InterposeKit). If you’re migrating, check out [what’s changed](#what-has-changed). -You can call the original implementation and add code before, instead or after a method call. -This is similar to the [Aspects library](https://github.com/steipete/Aspects), but doesn't yet do dynamic subclassing. +## Key Features + +- Swift-friendly, modern, and minimal API. +- Block-based hooks using direct `Method` implementation replacement under the hood rather than less-safe [selector-based swizzling](https://pspdfkit.com/blog/2019/swizzling-in-swift/). +- Ability to target both classes and individual objects. +- Support for both instance and class methods. +- Object hooks are safely isolated using runtime subclassing, similar to the KVO mechanism. +- Hooks get access to the original method implementation via a proxy. +- Hooks can be applied immediately or prepared and applied later, and safely reverted at any time[^1]. +- Typed signatures must be explicitly provided for both the original method and the hook block. This adds some boilerplate but ensures a clean API and better performance compared to using `NSInvocation`[^2]. +- Written almost entirely in Swift on top of the Objective-C runtime[^3]. + +## Requirements + +- Swift 5.9 or later +- Xcode 15 or later +- Apple platforms only (macOS, iOS, tvOS, watchOS) +- `arm64` or `x86_64` architectures + +## Installation + +You can add InterposeKit to your project using the Swift Package Manager. -Compare: [Swizzling a property without helper and with InterposeKit](https://gist.github.com/steipete/f955aaa0742021af15add0133d8482b9) +In Xcode, open your project settings, select the *Package Dependencies* tab, click the *+* button, and enter the URL `https://github.com/structuredpath/InterposeKit`. Then select the latest version and add the package to your desired target. + +If you’re adding InterposeKit using a `Package.swift` manifest, include it in your `dependencies` like this: + +``` +dependencies: [ + .package(url: "https://github.com/structuredpath/InterposeKit", from: "1.0.0") +] +``` + +Then add the product to any target that needs it: + +``` +.target( + name: "YourTarget", + dependencies: [ + .product(name: "InterposeKit", package: "InterposeKit") + ] +) +``` ## Usage -Let's say you want to amend `sayHi` from `TestClass`: +### Class Hook on Instance Method ```swift -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 👋" +class MyClass: NSObject { + @objc dynamic func getValue() -> Int { + return 42 } } -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" - } +let object = MyClass() +print(object.getValue()) // => 42 + +let hook = try Interpose.applyHook( + on: MyClass.self, + for: #selector(MyClass.getValue), + methodSignature: (@convention(c) (MyClass, Selector) -> Int).self, + hookSignature: (@convention(block) (MyClass) -> Int).self +) { hook in + return { `self` in + // Retrieve the original result and add 1 to it. This can be skipped. + return hook.original(`self`, hook.selector) + 1 } } -// Don't need the hook anymore? Undo is built-in! -interposer.revert() +print(object.getValue()) // => 43 + +try hook.revert() +print(object.getValue()) // => 42 ``` -Want to hook just a single instance? No problem! +### Class Hook on Class Method ```swift -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" - } +class MyClass: NSObject { + @objc dynamic class func getStaticValue() -> Int { + return 42 + } } -``` -Here's what we get when calling `print(TestClass().sayHi())` +print(MyClass.getStaticValue()) // => 42 + +let hook = try Interpose.applyHook( + on: MyClass.self, + for: #selector(MyClass.getStaticValue), + methodKind: .class, + methodSignature: (@convention(c) (MyClass.Type, Selector) -> Int).self, + hookSignature: (@convention(block) (MyClass.Type) -> Int).self +) { hook in + return { `class` in + // Retrieve the original result and add 1 to it. This can be skipped. + return hook.original(`class`, hook.selector) + 1 + } +} + +print(MyClass.getStaticValue()) // => 43 + +try hook.revert() +print(MyClass.getStaticValue()) // => 42 ``` -[Interposer] Swizzled -[TestClass.sayHi] IMP: 0x000000010d9f4430 -> 0x000000010db36020 -Before Interposing -Calling sayHi -After Interposing -Hi there 👋 and Interpose + +### Object Hook + +```swift +class MyClass: NSObject { + @objc dynamic func getValue() -> Int { + return 42 + } +} + +let object1 = MyClass() +let object2 = MyClass() + +print(object1.getValue()) // => 42 +print(object2.getValue()) // => 42 + +let hook = try Interpose.applyHook( + on: object1, + for: #selector(MyClass.getValue), + methodSignature: (@convention(c) (MyClass, Selector) -> Int).self, + hookSignature: (@convention(block) (MyClass) -> Int).self +) { hook in + return { `self` in + // Retrieve the original result and add 1 to it. This can be skipped. + return hook.original(`self`, hook.selector) + 1 + } +} + +print(object1.getValue()) // => 43 +print(object2.getValue()) // => 42 + +try hook.revert() + +print(object1.getValue()) // => 42 +print(object2.getValue()) // => 42 ``` -## Key Features +> [!IMPORTANT] +> If the object is already being observed via KVO when you apply or revert the hook, the operation will fail safely by throwing `InterposeError.kvoDetected(object:)`. Using KVO after the hook is installed is fully supported. -- Interpose directly modifies the implementation of a `Method`, which is [safer than selector-based swizzling]((https://pspdfkit.com/blog/2019/swizzling-in-swift/)). -- 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](https://steipete.com/posts/mac-catalyst-crash-hunt/). +### More Examples -## Object Hooking +You can check out the extensive test suite to see more advanced examples. The repository also comes with an example Xcode project, which showcases more real-life examples of tweaking AppKit classes. -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. +

What’s Changed

-## Various ways to define the signature +Compared to the [original implementation](https://github.com/steipete/InterposeKit), this fork introduces several API and internal changes. Here is a summary of key differences with migration hints. -Next to using `methodSignature` and `hookSignature`, following variants to define the signature are also possible: +### Environment -### 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 } -} -``` +- Switched the library to Swift Package Manager only. Carthage and CocoaPods support was removed. +- Raised minimum Swift version to 5.9. +- Limited to Apple platforms with `arm64` and `x86_64` architectures. Support for Linux was removed. -### 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 - } - } -} -``` +### API Changes -## Delayed Hooking +- Class hooks now support class methods. +- Removed the builder-based API `Interpose(…) { … }` for applying hooks in batches. Each hook must now be individually prepared, applied, and reverted. +- Signature types must now be specified via parameters, improving clarity and streamlining the API. +- Renamed `hook(…)` methods to `applyHook(…)`. +- Removed fluent-style `Hook` API, meaning that methods no longer return `self`. +- Introduced `HookProxy`, which is passed into the hook builder. It still provides access to the original method implementation and selector, but hides other irrelevant APIs like `revert()`. +- Hook types now use composition instead of inheritance. The public `Hook` class delegates to internal strategies conforming to `HookStrategy`. +- Object hooks now use a global counter instead of UUIDs for dynamic subclass names. +- Dynamic subclasses created at runtime are now cleaned up when the last hook is reverted on an object. +- Class hooks must now target the exact class that actually implements the method to ensure the revert functionality works correctly. +- Added initial Swift 6 support with basic concurrency checks. Should be thread-safe but most usage is still expected to be single-threaded. +- Removed support for [delayed hooking](https://github.com/steipete/InterposeKit?tab=readme-ov-file#delayed-hooking) via `Interpose.whenAvailable(…)` to keep the library laser-focused. +- …and heavily refactored the Swift part of the codebase: cleaner use of Objective-C runtime APIs, a revamped `InterposeError` enum, and new supporting types like `HookScope` or `HookState`. -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. +### Fixes -```swift -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}) -} -``` +- Fixed a crash where `IKTAddSuperImplementationToClass` was stripped in release builds per [steipete/InterposeKit#29](https://github.com/steipete/InterposeKit/issues/29) by using the fix from [steipete/InterposeKit#30](https://github.com/steipete/InterposeKit/issues/30) submitted by [@Thomvis](https://github.com/Thomvis), which replaces a call via dynamic library with a direct Swift call to `IKTSuperBuilder.addSuperInstanceMethod(to:selector:)`. +- Fixed floating-point register handling on arm64 using the patch from [steipete/InterposeKit#37](https://github.com/steipete/InterposeKit/issues/37) submitted by [@ishutinvv](https://github.com/ishutinvv), which resolves an issue affecting swizzled methods with `CGFloat` parameters or structs like `CGPoint` and `CGRect` due to floating-point registers not being restored in the correct order after the trampoline call. +## Q&A -## FAQ +### Why is it called InterposeKit? -### Why didn't you call it Interpose? "Kit" feels so old-school. -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. +Peter originally wanted to go with _Interpose_, but [Swift had (and still has) a bug](https://github.com/swiftlang/swift/issues/43510) where using the same name for a module and a type can break things in certain scenarios. -### 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](https://github.com/apple/swift/pull/20333) aka `@_dynamicReplacement` for details.) +### Why another Objective-C swizzling library? -### 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](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)". +UIKit, AppKit, and other system frameworks written in Objective-C won’t go away and sometimes you still need to swizzle to fix bugs or tweak internal behavior. InterposeKit is meant as a rarely needed tool for these cases, providing a simple, Swift-friendly API. -### 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. +### What the fork? -## Installation +This version of InterposeKit started as a fork of [Peter Steinberger’s original library](https://github.com/steipete/InterposeKit) but has since evolved into a *significantly reworked and modernized* version. The core ideas and underlying runtime techniques remain, but large parts of the Swift codebase were restructured and rewritten. -Building InterposeKit requires Xcode 15+ or a Swift 5.9+ toolchain with the Swift Package Manager. +### Can I hook Swift methods? And what about pure C functions? -### Swift Package Manager +No. Peter had plans to experiment with [Swift method hooking](https://github.com/rodionovd/SWRoute), [Swift dynamic function replacement](https://github.com/swiftlang/swift/pull/20333), and hooking C functions via [`dyld_dynamic_interpose`](https://twitter.com/steipete/status/1258482647933870080), but none of these made it into InterposeKit, and frankly, they wouldn’t really fit the scope of this library anyway. -Add `.package(url: "https://github.com/steipete/InterposeKit.git", from: "0.0.1")` to your -`Package.swift` file's `dependencies`. +### Can I ship this? -### Carthage +Modifying the internal behavior of system frameworks always carries risks. You should know what you’re doing, use defensive programming techniques, and assume that future OS updates might break your hooks. -Add `github "steipete/InterposeKit"` to your `Cartfile`. +That said, InterposeKit is designed to be safe for production use. It includes guardrails that verify the method state before applying or reverting hooks, helping catch unexpected conditions early. The focus is on simplicity and predictability, avoiding clever tricks that could introduce hidden side effects. ## Improvement Ideas -- Write proposal to allow to [convert the calling convention of existing types](https://twitter.com/steipete/status/1266799174563041282?s=21). -- 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. -- 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](https://twitter.com/steipete/status/1258482647933870080) to hook pure C functions -- Combine Promise-API for `Interpose.whenAvailable` for better error bubbling. -- Experiment with [Swift function hooking](https://github.com/rodionovd/SWRoute/wiki/Function-hooking-in-Swift)? ⚡️ -- 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](https://github.com/steipete/InterposeKit/pull/15#discussion_r439871752). -- I'm sure there's more - Pull Requests or [comments](https://twitter.com/steipete) very welcome! - -Make this happen: -[![Carthage compatible](https://img.shields.io/badge/Carthage-compatible-4BC51D.svg?style=flat)](https://github.com/Carthage/Carthage) -![CocoaPods](https://img.shields.io/cocoapods/v/SwiftyJSON.svg) +- Support for hooking KVO-enabled objects ([#19](https://github.com/structuredpath/InterposeKit/issues/19)) +- Correct super lookup when injecting a trampoline into classes with overridden methods ([#21](https://github.com/structuredpath/InterposeKit/issues/21)) +- Signature type checking at hook construction ([#20](https://github.com/structuredpath/InterposeKit/issues/20)) +- A way for retrieving all hooks on an object/class +- Support for reverting multiple hooks on a class in an arbitrary order ([#12](https://github.com/structuredpath/InterposeKit/issues/12)) -## Thanks +## References -Special thanks to [JP Simard](https://github.com/jpsim/Yams) who did such a great job in setting up [Yams](https://github.com/jpsim/Yams) with GitHub Actions - this was extremely helpful to build CI here fast. +- [Peter’s original implementation](https://github.com/steipete/InterposeKit) +- [Peter’s introductory blog post](https://steipete.me/posts/interposekit/) +- [Peter’s blog post on swizzling in Swift](https://www.nutrient.io/blog/swizzling-in-swift/) +- [Peter’s blog post on calling super at runtime](https://steipete.me/posts/calling-super-at-runtime/) +- [Aspects - Objective-C predecessor to InterposeKit](https://github.com/steipete/Aspects) +- [NSHipster on method swizzling](https://nshipster.com/method-swizzling/) +- [Stack Overflow: How do I remove instance methods at runtime in Objective-C 2.0?](https://stackoverflow.com/questions/1315169/how-do-i-remove-instance-methods-at-runtime-in-objective-c-2-0) ## License -InterposeKit is MIT Licensed. +This library is released under the MIT license. See [LICENSE](LICENSE) for details. + +[^1]: Both applying and reverting a hook include safety checks. InterposeKit detects if the method was modified externally—such as by KVO or other swizzling—and prevents the operation if it would lead to inconsistent behavior. +[^2]: There’s currently no runtime type checking. If the specified types don’t match, it will cause a runtime crash. +[^3]: The most advanced part of this library is `ITKSuperBuilder`, a component for constructing method implementations that simply call `super`, which is [surprisingly hard to do](https://steipete.me/posts/calling-super-at-runtime/). It’s written in Objective-C and assembly, lives in its own SPM target, and is invoked from Swift. All credit goes to Peter, who originally came up with this masterpiece!