diff --git a/.github/package.xcworkspace/contents.xcworkspacedata b/.github/package.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..0fd0bc3 --- /dev/null +++ b/.github/package.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/.github/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/.github/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist new file mode 100644 index 0000000..18d9810 --- /dev/null +++ b/.github/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist @@ -0,0 +1,8 @@ + + + + + IDEDidComputeMac32BitWarning + + + diff --git a/.github/package.xcworkspace/xcshareddata/swiftpm/Package.resolved b/.github/package.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..0368460 --- /dev/null +++ b/.github/package.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,41 @@ +{ + "pins" : [ + { + "identity" : "swift-macro-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-macro-testing.git", + "state" : { + "revision" : "10dcef36314ddfea6f60442169b0b320204cbd35", + "version" : "0.2.2" + } + }, + { + "identity" : "swift-macro-toolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/stackotter/swift-macro-toolkit.git", + "state" : { + "revision" : "106daeb38eb3f52b1540aed981fc63fa22274576", + "version" : "0.3.1" + } + }, + { + "identity" : "swift-snapshot-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing", + "state" : { + "revision" : "8e68404f641300bfd0e37d478683bb275926760c", + "version" : "1.15.2" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "revision" : "74203046135342e4a4a627476dd6caf8b28fe11b", + "version" : "509.0.0" + } + } + ], + "version" : 2 +} diff --git a/.github/package.xcworkspace/xcshareddata/xcschemes/Interception.xcscheme b/.github/package.xcworkspace/xcshareddata/xcschemes/Interception.xcscheme new file mode 100644 index 0000000..56e193c --- /dev/null +++ b/.github/package.xcworkspace/xcshareddata/xcschemes/Interception.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.github/package.xcworkspace/xcshareddata/xcschemes/InterceptionTests.xcscheme b/.github/package.xcworkspace/xcshareddata/xcschemes/InterceptionTests.xcscheme new file mode 100644 index 0000000..2c211d8 --- /dev/null +++ b/.github/package.xcworkspace/xcshareddata/xcschemes/InterceptionTests.xcscheme @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/.github/package.xcworkspace/xcshareddata/xcschemes/_SwiftInterceptionCustomSelectors.xcscheme b/.github/package.xcworkspace/xcshareddata/xcschemes/_SwiftInterceptionCustomSelectors.xcscheme new file mode 100644 index 0000000..1695afb --- /dev/null +++ b/.github/package.xcworkspace/xcshareddata/xcschemes/_SwiftInterceptionCustomSelectors.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.github/package.xcworkspace/xcshareddata/xcschemes/_SwiftInterceptionCustomSelectorsMacrosTests.xcscheme b/.github/package.xcworkspace/xcshareddata/xcschemes/_SwiftInterceptionCustomSelectorsMacrosTests.xcscheme new file mode 100644 index 0000000..352ae6c --- /dev/null +++ b/.github/package.xcworkspace/xcshareddata/xcschemes/_SwiftInterceptionCustomSelectorsMacrosTests.xcscheme @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/.github/package.xcworkspace/xcshareddata/xcschemes/_SwiftInterceptionUtils.xcscheme b/.github/package.xcworkspace/xcshareddata/xcschemes/_SwiftInterceptionUtils.xcscheme new file mode 100644 index 0000000..2127e55 --- /dev/null +++ b/.github/package.xcworkspace/xcshareddata/xcschemes/_SwiftInterceptionUtils.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..c54d8cb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,34 @@ +name: CI + +on: + push: + branches: + - main + pull_request: + branches: + - '*' + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + library-swift-latest: + name: Library + if: | + !contains(github.event.head_commit.message, '[ci skip]') && + !contains(github.event.head_commit.message, '[ci skip test]') && + !contains(github.event.head_commit.message, '[ci skip library-swift-latest]') + runs-on: macos-13 + timeout-minutes: 30 + strategy: + matrix: + config: + - debug + - release + steps: + - uses: actions/checkout@v4 + - name: Select Xcode 15.1 + run: sudo xcode-select -s /Applications/Xcode_15.1.app + - name: Run tests + run: make CONFIG=debug test-library diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/.spi.yml b/.spi.yml new file mode 100644 index 0000000..ef0bb96 --- /dev/null +++ b/.spi.yml @@ -0,0 +1,5 @@ +version: 1 +builder: + configs: + - documentation_targets: [Interception] + swift_version: 5.9 diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/Interception.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/Interception.xcscheme new file mode 100644 index 0000000..56e193c --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/Interception.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/InterceptionTests.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/InterceptionTests.xcscheme new file mode 100644 index 0000000..2c211d8 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/InterceptionTests.xcscheme @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/_SwiftInterceptionCustomSelectors.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/_SwiftInterceptionCustomSelectors.xcscheme new file mode 100644 index 0000000..1695afb --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/_SwiftInterceptionCustomSelectors.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/_SwiftInterceptionCustomSelectorsMacrosTests.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/_SwiftInterceptionCustomSelectorsMacrosTests.xcscheme new file mode 100644 index 0000000..352ae6c --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/_SwiftInterceptionCustomSelectorsMacrosTests.xcscheme @@ -0,0 +1,53 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/.swiftpm/xcode/xcshareddata/xcschemes/_SwiftInterceptionUtils.xcscheme b/.swiftpm/xcode/xcshareddata/xcschemes/_SwiftInterceptionUtils.xcscheme new file mode 100644 index 0000000..2127e55 --- /dev/null +++ b/.swiftpm/xcode/xcshareddata/xcschemes/_SwiftInterceptionUtils.xcscheme @@ -0,0 +1,66 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ACKNOWLEDGMENTS b/ACKNOWLEDGMENTS new file mode 100644 index 0000000..7c348e2 --- /dev/null +++ b/ACKNOWLEDGMENTS @@ -0,0 +1,83 @@ +Inspiration and some implementations are taken from: + +–––––––––––––––––––––––––––––– +https://github.com/ReactiveCocoa/ReactiveSwift + +LICENCE +------------------------------ +Copyright (c) 2012 - 2016, GitHub, Inc. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +–––––––––––––––––––––––––––––– + +–––––––––––––––––––––––––––––– +https://github.com/ReactiveCocoa/ReactiveCocoa + +LICENCE +------------------------------ +Copyright (c) 2012 - 2016, GitHub, Inc. All rights reserved. + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. +–––––––––––––––––––––––––––––– + +–––––––––––––––––––––––––––––– +https://github.com/CombineCommunity/CombineExt + +LICENCE +------------------------------ +Copyright (c) 2020 Combine Community, and/or Shai Mishali + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in +all copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN +THE SOFTWARE. +–––––––––––––––––––––––––––––– + +–––––––––––––––––––––––––––––– +https://github.com/CombineCommunity/CombineCocoa + +LICENCE +------------------------------ +MIT License + +Copyright (c) 2019 Shai Mishali + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +–––––––––––––––––––––––––––––– diff --git a/LICENCE.md b/LICENCE.md new file mode 100644 index 0000000..929aa26 --- /dev/null +++ b/LICENCE.md @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 CaptureContext + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..ab85d0e --- /dev/null +++ b/Makefile @@ -0,0 +1,42 @@ +CONFIG = debug +PLATFORM_IOS = iOS Simulator,id=$(call udid_for,iOS 17,iPhone \d\+ Pro [^M]) +PLATFORM_MACOS = macOS +PLATFORM_MAC_CATALYST = macOS,variant=Mac Catalyst +PLATFORM_TVOS = tvOS Simulator,id=$(call udid_for,tvOS 17,TV) +PLATFORM_WATCHOS = watchOS Simulator,id=$(call udid_for,watchOS 10,Watch) + +default: test + +test: + $(MAKE) CONFIG=debug test-library + $(MAKE) test-docs + +test-library: + for platform in "$(PLATFORM_IOS)" "$(PLATFORM_MACOS)" "$(PLATFORM_MAC_CATALYST)" "$(PLATFORM_TVOS)" "$(PLATFORM_WATCHOS)"; do \ + echo "\nTesting Library on $$platform\n" && \ + (xcodebuild test \ + -skipMacroValidation \ + -configuration $(CONFIG) \ + -workspace .github/package.xcworkspace \ + -scheme InterceptionTests \ + -destination platform="$$platform" | xcpretty && exit 0 \ + ) \ + || exit 1; \ + done; + +DOC_WARNINGS = $(shell xcodebuild clean docbuild \ + -scheme Interception \ + -destination platform="$(PLATFORM_IOS)" \ + -quiet \ + 2>&1 \ + | grep "couldn't be resolved to known documentation" \ + | sed 's|$(PWD)|.|g' \ + | tr '\n' '\1') +test-docs: + @test "$(DOC_WARNINGS)" = "" \ + || (echo "xcodebuild docbuild failed:\n\n$(DOC_WARNINGS)" | tr '\1' '\n' \ + && exit 1) + +define udid_for +$(shell xcrun simctl list devices available '$(1)' | grep '$(2)' | sort -r | head -1 | awk -F '[()]' '{ print $$(NF-3) }') +endef diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..0368460 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,41 @@ +{ + "pins" : [ + { + "identity" : "swift-macro-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-macro-testing.git", + "state" : { + "revision" : "10dcef36314ddfea6f60442169b0b320204cbd35", + "version" : "0.2.2" + } + }, + { + "identity" : "swift-macro-toolkit", + "kind" : "remoteSourceControl", + "location" : "https://github.com/stackotter/swift-macro-toolkit.git", + "state" : { + "revision" : "106daeb38eb3f52b1540aed981fc63fa22274576", + "version" : "0.3.1" + } + }, + { + "identity" : "swift-snapshot-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-snapshot-testing", + "state" : { + "revision" : "8e68404f641300bfd0e37d478683bb275926760c", + "version" : "1.15.2" + } + }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-syntax.git", + "state" : { + "revision" : "74203046135342e4a4a627476dd6caf8b28fe11b", + "version" : "509.0.0" + } + } + ], + "version" : 2 +} diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..abe52cf --- /dev/null +++ b/Package.swift @@ -0,0 +1,88 @@ +// swift-tools-version: 5.9 + +import PackageDescription +import CompilerPluginSupport + +let package = Package( + name: "swift-interception", + platforms: [ + .iOS(.v13), + .macOS(.v10_15), + .tvOS(.v13), + .macCatalyst(.v13), + .watchOS(.v6) + ], + products: [ + .library( + name: "_SwiftInterceptionCustomSelectors", + type: .static, + targets: ["_SwiftInterceptionCustomSelectors"] + ), + .library( + name: "_SwiftInterceptionUtils", + type: .static, + targets: ["_SwiftInterceptionUtils"] + ), + .library( + name: "Interception", + type: .static, + targets: ["Interception"] + ) + ], + dependencies: [ + .package( + url: "https://github.com/stackotter/swift-macro-toolkit.git", + .upToNextMinor(from: "0.3.0") + ), + .package( + url: "https://github.com/pointfreeco/swift-macro-testing.git", + .upToNextMinor(from: "0.2.2") + ) + ], + targets: [ + .macro( + name: "_SwiftInterceptionCustomSelectorsMacros", + dependencies: [ + .product( + name: "MacroToolkit", + package: "swift-macro-toolkit" + ) + ] + ), + + .target( + name: "_SwiftInterceptionCustomSelectors", + dependencies: [ + .target(name: "_SwiftInterceptionCustomSelectorsMacros"), + ] + ), + .target(name: "_SwiftInterceptionUtilsObjc"), + .target( + name: "_SwiftInterceptionUtils", + dependencies: [ + .target(name: "_SwiftInterceptionUtilsObjc"), + ] + ), + .target( + name: "Interception", + dependencies: [ + .target(name: "_SwiftInterceptionCustomSelectors"), + .target(name: "_SwiftInterceptionUtils"), + ] + ), + + .testTarget( + name: "_SwiftInterceptionCustomSelectorsMacrosTests", + dependencies: [ + .target(name: "_SwiftInterceptionCustomSelectorsMacros"), + .product(name: "MacroTesting", package: "swift-macro-testing"), + ] + ), + .testTarget( + name: "InterceptionTests", + dependencies: [ + .target(name: "Interception"), + ] + ), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..4251945 --- /dev/null +++ b/README.md @@ -0,0 +1,92 @@ +# swift-interception + +[![SwiftPM 5.9](https://img.shields.io/badge/swiftpm-5.9-ED523F.svg?style=flat)](https://swift.org/download/) ![Platforms](https://img.shields.io/badge/Platforms-iOS_13_|_macOS_10.15_|_Catalyst_13_|_tvOS_13_|_watchOS_7-ED523F.svg?style=flat) [![@capturecontext](https://img.shields.io/badge/contact-@capturecontext-1DA1F2.svg?style=flat&logo=twitter)](https://twitter.com/capture_context) + +Package for interception of objc selectors in Swift. + +## Usage + +### Basic + +Observe any selectors on NSObject instances + +```swift +import Interception + +navigationController.setInterceptionHandler( + for: #methodSelector(UINavigationController.popViewController) +) { result in + print(result.args) // `animated` flag + print(result.output) // popped `UIViewController?`` +} +``` + +You can set up multiple interception handlers as well, just make sure that you use different keys for each handler + +```swift +import Interception + +object.setInterceptionHandler( + for: #methodSelector(MyObject.someMethod(arg1:arg2)), + key: "argumentsPrinter" +) { result in + // In case of multiple arguments + // you can access them as a tuple + print(result.args.0) + print(result.args.1) +} +``` + +### Library development + +If you use it to create a library it may be a good idea to export custom selectors implicitly + +```swift +// Exports.swift +@_exported import _SwiftInterceptionCustomSelectors +``` + +Also you may find some `@_spi` methods and Utils helpful + +```swift +@_spi(Internals) import Interception +import _SwiftInterceptionUtils // Is not shown in the autocomplete +``` + + + +## Installation + +### Basic + +You can add CombineInterception to an Xcode project by adding it as a package dependency. + +1. From the **File** menu, select **Swift Packages › Add Package Dependency…** +2. Enter [`"https://github.com/capturecontext/swift-interception.git"`](https://github.com/capturecontext/swift-interception.git) into the package repository URL text field +3. Choose products you need to link them to your project. + +### Recommended + +If you use SwiftPM for your project, you can add CombineInterception to your package file. + +```swift +.package( + url: "https://github.com/capturecontext/swift-interception.git", + branch: "main" +) +``` + +Do not forget about target dependencies: + +```swift +.product( + name: "Interception", + package: "swift-interception" +) +``` + +## License + +This library is released under the MIT license. See [LICENCE](LICENCE) for details. + +See [ACKNOWLEDGMENTS](ACKNOWLEDGMENTS) for inspiration references and their licences. diff --git a/Sources/Interception/Exports.swift b/Sources/Interception/Exports.swift new file mode 100644 index 0000000..0a97b7c --- /dev/null +++ b/Sources/Interception/Exports.swift @@ -0,0 +1 @@ +@_exported import _SwiftInterceptionCustomSelectors diff --git a/Sources/Interception/InterceptionResult.swift b/Sources/Interception/InterceptionResult.swift new file mode 100644 index 0000000..9379ca3 --- /dev/null +++ b/Sources/Interception/InterceptionResult.swift @@ -0,0 +1,60 @@ +public struct InterceptionResult { + public var args: Args + public var output: Output + + @_spi(Internals) + public init(args: Args, output: Output) { + self.args = args + self.output = output + } +} + +extension InterceptionResult where Args == [Any?], Output == Any? { + @_spi(Internals) + public func unsafeCast( + args: (repeat each Arg).Type = (repeat each Arg).self, + output: T.Type = T.self + ) -> InterceptionResult<(repeat each Arg), T> { + return .init( + args: self.args.unsafeCastToTuple(of: args), + output: self.output as! T + ) + } +} + +extension Array { + @_spi(Internals) + public func unsafeCastToTuple( + of type: (repeat each T).Type = (repeat each T).self + ) -> (repeat each T) { + guard (repeat each T).self != Void.self else { + return unsafeBitCast((), to: (repeat each T).self) + } + + guard self.count > 1 else { + return forceCast(self.first!, to: (repeat each T).self) + } + + var copy = self + var tuple: Any = copy.removeLast() + for _ in 1..(_ value: From, to type: Any.Type) -> To { + let forceCastBox: (Any) -> To = { unboxedValue in + guard let castedValue = unboxedValue as? To else { + fatalError("Could not cast \(From.self) to \(To.self)") + } + + return castedValue + } + + let box = unsafeBitCast(forceCastBox, to: ((From) -> To).self) + + return box(value) +} diff --git a/Sources/Interception/NSObject+Interception.swift b/Sources/Interception/NSObject+Interception.swift new file mode 100644 index 0000000..a6bf64c --- /dev/null +++ b/Sources/Interception/NSObject+Interception.swift @@ -0,0 +1,533 @@ +#if canImport(Combine) +import Foundation +import _SwiftInterceptionUtils +import _SwiftInterceptionCustomSelectors + +/// Whether the runtime subclass has already been prepared for method +/// interception. +private let interceptedKey = AssociationKey(default: false) + +/// Holds the method signature cache of the runtime subclass. +private let signatureCacheKey = AssociationKey() + +/// Holds the method selector cache of the runtime subclass. +private let selectorCacheKey = AssociationKey() + +internal let noImplementation: IMP = unsafeBitCast(Int(0), to: IMP.self) + +extension NSObject { + /// Sets interception handler, which accepts ``InterceptionResult`` containing a tuple + /// of bridged arguments and bridged output, at the end of every invocation of `selector` on the object. + /// + /// > ⚠️ Handlers should not call the method specified by the selector. + /// + /// - Parameters: + /// - selector: The selector to observe. + /// - key: Registration key for the interception handler + /// - action: Action to perform when `selector` was triggered + public func setInterceptionHandler( + for selector: Selector, + key: AnyHashable = "__default", + action: ((InterceptionResult<[Any?], Any?>) -> Void)? + ) { + let handler = _intercept(selector) + handler.register(action, for: key) + } + + /// Sets interception handler, which accepts ``InterceptionResult`` containing a tuple + /// of casted arguments and casted output, at the end of every invocation of `selector` on the object. + /// + /// > ⚠️ Handlers should not call the method specified by the selector. + /// + /// - Parameters: + /// - selector: The selector to observe. + /// - key: Registration key for the interception handler + /// - action: Action to perform when `selector` was triggered + public func setInterceptionHandler( + for selector: _MethodSelector, + key: AnyHashable = "__default", + action: ((InterceptionResult) -> Void)? + ) { + let handler = _intercept(selector.wrappedValue) + + guard let action else { + handler.register(nil, for: key) + return + } + + handler.register({ action($0.unsafeCast()) }, for: key) + } + + /// Setup the method interception. + @nonobjc fileprivate func _intercept(_ selector: Selector) -> InterceptionHanlders { + guard let method = class_getInstanceMethod(objcClass, selector) else { + fatalError( + "Selector `\(selector)` does not exist in class `\(String(describing: objcClass))`." + ) + } + + let typeEncoding = method_getTypeEncoding(method)! + assert(checkTypeEncoding(typeEncoding)) + + return synchronized(self) { + let alias = selector.alias + let handlerKey = AssociationKey(alias) + let interopAlias = selector.interopAlias + + if let handler = associations.value(forKey: handlerKey) { + return handler + } + + let subclass: AnyClass = swizzleClass(self) + let subclassAssociations = Associations(subclass as AnyObject) + + synchronized(subclass) { + let isSwizzled = subclassAssociations.value(forKey: interceptedKey) + + let signatureCache: SignatureCache + let selectorCache: SelectorCache + + if isSwizzled { + signatureCache = subclassAssociations.value(forKey: signatureCacheKey) + selectorCache = subclassAssociations.value(forKey: selectorCacheKey) + } + else { + signatureCache = SignatureCache() + selectorCache = SelectorCache() + + subclassAssociations.setValue(signatureCache, forKey: signatureCacheKey) + subclassAssociations.setValue(selectorCache, forKey: selectorCacheKey) + subclassAssociations.setValue(true, forKey: interceptedKey) + + enableMessageForwarding(subclass, selectorCache) + setupMethodSignatureCaching(subclass, signatureCache) + } + + selectorCache.cache(selector) + + if signatureCache[selector] == nil { + let signature = NSMethodSignature.objcSignature(withObjCTypes: typeEncoding) + signatureCache[selector] = signature + } + + // If an immediate implementation of the selector is found in the + // runtime subclass the first time the selector is intercepted, + // preserve the implementation. + // + // Example: KVO setters if the instance is swizzled by KVO before combine-interception + // does. + if !class_respondsToSelector(subclass, interopAlias) { + let immediateImpl = class_getImmediateMethod(subclass, selector) + .flatMap(method_getImplementation) + .flatMap { $0 != _swiftInterceptionMsgForward ? $0 : nil } + + if let impl = immediateImpl { + let succeeds = class_addMethod(subclass, interopAlias, impl, typeEncoding) + precondition( + succeeds, + "combine-interception attempts to swizzle a selector that has message forwarding enabled with a runtime injected implementation. This is unsupported in the current version." + ) + } + } + } + + let handler = InterceptionHanlders() + associations.setValue(handler, forKey: handlerKey) + + // Start forwarding the messages of the selector. + _ = class_replaceMethod(subclass, selector, _swiftInterceptionMsgForward, typeEncoding) + + return handler + } + } +} + +/// Swizzle `realClass` to enable message forwarding for method interception. +/// +/// - parameters: +/// - realClass: The runtime subclass to be swizzled. +private func enableMessageForwarding(_ realClass: AnyClass, _ selectorCache: SelectorCache) { + let perceivedClass: AnyClass = class_getSuperclass(realClass)! + + typealias ForwardInvocationImpl = @convention(block) (Unmanaged, AnyObject) -> Void + let newForwardInvocation: ForwardInvocationImpl = { objectRef, invocation in + let selector = invocation.selector! + let alias = selectorCache.alias(for: selector) + let interopAlias = selectorCache.interopAlias(for: selector) + + defer { + let handlerKey = AssociationKey(alias) + if let handler = objectRef.takeUnretainedValue().associations.value(forKey: handlerKey) { + handler(invocation) + } + } + + let method = class_getInstanceMethod(perceivedClass, selector) + let typeEncoding: String + + if let runtimeTypeEncoding = method.flatMap(method_getTypeEncoding) { + typeEncoding = String(cString: runtimeTypeEncoding) + } + else { + let methodSignature = (objectRef.takeUnretainedValue() as AnyObject) + .objcMethodSignature(for: selector) + let encodings = (0.., Selector, AnyObject) -> + Void + let forwardInvocationImpl = class_getMethodImplementation( + perceivedClass, + ObjCSelector.forwardInvocation + ) + let forwardInvocation = unsafeBitCast(forwardInvocationImpl, to: SuperForwardInvocation.self) + forwardInvocation(objectRef, ObjCSelector.forwardInvocation, invocation) + } + + _ = class_replaceMethod( + realClass, + ObjCSelector.forwardInvocation, + imp_implementationWithBlock(newForwardInvocation as Any), + ObjCMethodEncoding.forwardInvocation + ) +} + +/// Swizzle `realClass` to accelerate the method signature retrieval, using a +/// signature cache that covers all known intercepted selectors of `realClass`. +/// +/// - parameters: +/// - realClass: The runtime subclass to be swizzled. +/// - signatureCache: The method signature cache. +private func setupMethodSignatureCaching(_ realClass: AnyClass, _ signatureCache: SignatureCache) { + let perceivedClass: AnyClass = class_getSuperclass(realClass)! + + let newMethodSignatureForSelector: + @convention(block) (Unmanaged, Selector) -> AnyObject? = { objectRef, selector in + if let signature = signatureCache[selector] { + return signature + } + + typealias SuperMethodSignatureForSelector = @convention(c) ( + Unmanaged, Selector, Selector + ) -> AnyObject? + let impl = class_getMethodImplementation( + perceivedClass, + ObjCSelector.methodSignatureForSelector + ) + let methodSignatureForSelector = unsafeBitCast(impl, to: SuperMethodSignatureForSelector.self) + return methodSignatureForSelector( + objectRef, + ObjCSelector.methodSignatureForSelector, + selector + ) + } + + _ = class_replaceMethod( + realClass, + ObjCSelector.methodSignatureForSelector, + imp_implementationWithBlock(newMethodSignatureForSelector as Any), + ObjCMethodEncoding.methodSignatureForSelector + ) +} + +/// The state of an intercepted method specific to an instance. +private final class InterceptionHanlders { + typealias Action = (InterceptionResult<[Any?], Any?>) -> Void + private var handlers: [AnyHashable: Action] = [:] + + func register(_ action: Action?, for key: AnyHashable) { + handlers[key] = action + } + + func callAsFunction(_ invocation: AnyObject) { + let unpackedInvocation = unpackInvocation(invocation) + handlers.values.forEach { $0(unpackedInvocation) } + } +} + +private final class SelectorCache { + private var map: [Selector: (main: Selector, interop: Selector)] = [:] + + init() {} + + /// Cache the aliases of the specified selector in the cache. + /// + /// - warning: Any invocation of this method must be synchronized against the + /// runtime subclass. + @discardableResult + func cache(_ selector: Selector) -> (main: Selector, interop: Selector) { + if let pair = map[selector] { + return pair + } + + let aliases = (selector.alias, selector.interopAlias) + map[selector] = aliases + + return aliases + } + + /// Get the alias of the specified selector. + /// + /// - parameters: + /// - selector: The selector alias. + func alias(for selector: Selector) -> Selector { + if let (main, _) = map[selector] { + return main + } + + return selector.alias + } + + /// Get the secondary alias of the specified selector. + /// + /// - parameters: + /// - selector: The selector alias. + func interopAlias(for selector: Selector) -> Selector { + if let (_, interop) = map[selector] { + return interop + } + + return selector.interopAlias + } +} + +// The signature cache for classes that have been swizzled for method +// interception. +// +// Read-copy-update is used here, since the cache has multiple readers but only +// one writer. +private final class SignatureCache { + // `Dictionary` takes 8 bytes for the reference to its storage and does CoW. + // So it should not encounter any corrupted, partially updated state. + private var map: [Selector: AnyObject] = [:] + + init() {} + + /// Get or set the signature for the specified selector. + /// + /// - warning: Any invocation of the setter must be synchronized against the + /// runtime subclass. + /// + /// - parameters: + /// - selector: The method signature. + subscript(selector: Selector) -> AnyObject? { + get { + return map[selector] + } + set { + if map[selector] == nil { + map[selector] = newValue + } + } + } +} + +/// Assert that the method does not contain types that cannot be intercepted. +/// +/// - parameters: +/// - types: The type encoding C string of the method. +/// +/// - returns: `true`. +private func checkTypeEncoding(_ types: UnsafePointer) -> Bool { + // Some types, including vector types, are not encoded. In these cases the + // signature starts with the size of the argument frame. + assert( + types.pointee < Int8(UInt8(ascii: "1")) || types.pointee > Int8(UInt8(ascii: "9")), + "unknown method return type not supported in type encoding: \(String(cString: types))" + ) + + assert(types.pointee != Int8(UInt8(ascii: "(")), "union method return type not supported") + assert(types.pointee != Int8(UInt8(ascii: "{")), "struct method return type not supported") + assert(types.pointee != Int8(UInt8(ascii: "[")), "array method return type not supported") + + assert(types.pointee != Int8(UInt8(ascii: "j")), "complex method return type not supported") + + return true +} + +/// Extract the arguments of an `NSInvocation` as an array of objects. +/// +/// - parameters: +/// - invocation: The `NSInvocation` to unpack. +/// +/// - returns: An array of objects. +private func unpackInvocation(_ invocation: AnyObject) -> InterceptionResult<[Any?], Any?> { + let invocation = invocation as AnyObject + let methodSignature = invocation.objcMethodSignature! + let count = methodSignature.objcNumberOfArguments! + + var args = [Any?]() + args.reserveCapacity(Int(count - 2)) + + // Ignore `self` and `_cmd` at index 0 and 1. + for position in 2.., + value extractValueToBuffer: (UnsafeMutableRawPointer) -> Void +) -> Any? { + let encoding = ObjCTypeEncoding(rawValue: rawEncoding.pointee) ?? .undefined + let value: Any? + + func extract(_ type: T.Type) -> T { + let pointer = UnsafeMutableRawPointer.allocate( + byteCount: MemoryLayout.size, + alignment: MemoryLayout.alignment + ) + + defer { + pointer.deallocate() + } + + extractValueToBuffer(pointer) + return pointer.assumingMemoryBound(to: type).pointee + } + + switch encoding { + case .char: + value = NSNumber(value: extract(CChar.self)) + case .int: + value = NSNumber(value: extract(CInt.self)) + case .short: + value = NSNumber(value: extract(CShort.self)) + case .long: + value = NSNumber(value: extract(CLong.self)) + case .longLong: + value = NSNumber(value: extract(CLongLong.self)) + case .unsignedChar: + value = NSNumber(value: extract(CUnsignedChar.self)) + case .unsignedInt: + value = NSNumber(value: extract(CUnsignedInt.self)) + case .unsignedShort: + value = NSNumber(value: extract(CUnsignedShort.self)) + case .unsignedLong: + value = NSNumber(value: extract(CUnsignedLong.self)) + case .unsignedLongLong: + value = NSNumber(value: extract(CUnsignedLongLong.self)) + case .float: + value = NSNumber(value: extract(CFloat.self)) + case .double: + value = NSNumber(value: extract(CDouble.self)) + case .bool: + value = NSNumber(value: extract(CBool.self)) + case .object: + value = extract((AnyObject?).self) + case .type: + value = extract((AnyClass?).self) + case .selector: + value = extract((Selector?).self) + case .void: + value = () + case .undefined: + var size = 0 + var alignment = 0 + NSGetSizeAndAlignment(rawEncoding, &size, &alignment) + let buffer = UnsafeMutableRawPointer.allocate(byteCount: size, alignment: alignment) + defer { buffer.deallocate() } + + extractValueToBuffer(buffer) + value = NSValue(bytes: buffer, objCType: rawEncoding) + } + + return value +} +#endif diff --git a/Sources/_SwiftInterceptionCustomSelectors/KeyPath+.swift b/Sources/_SwiftInterceptionCustomSelectors/KeyPath+.swift new file mode 100644 index 0000000..88dd4bb --- /dev/null +++ b/Sources/_SwiftInterceptionCustomSelectors/KeyPath+.swift @@ -0,0 +1,15 @@ +import Foundation + +extension KeyPath { + public var getterSelector: Selector? { + guard let property = _kvcKeyPathString else { return nil } + return Selector(property) + } +} + +extension WritableKeyPath { + public var setterSelector: Selector? { + guard let property = _kvcKeyPathString else { return nil } + return Selector("set" + property.first!.uppercased() + String(property.dropFirst()) + ":") + } +} diff --git a/Sources/_SwiftInterceptionCustomSelectors/Macros.swift b/Sources/_SwiftInterceptionCustomSelectors/Macros.swift new file mode 100644 index 0000000..da7934c --- /dev/null +++ b/Sources/_SwiftInterceptionCustomSelectors/Macros.swift @@ -0,0 +1,72 @@ +import _SwiftInterceptionCustomSelectorsMacros +import Foundation + +// MARK: - PropertySelectors + +@freestanding(expression) +public macro propertySelector( + _: KeyPath +) -> _ReadonlyPropertySelector = +#externalMacro(module: "_SwiftInterceptionCustomSelectorsMacros", type: "PropertySelectorMacro") + +@freestanding(expression) +public macro propertySelector( + _: WritableKeyPath +) -> _MutablePropertySelector = +#externalMacro(module: "_SwiftInterceptionCustomSelectorsMacros", type: "PropertySelectorMacro") + +// MARK: - MethodSelectors + +@freestanding(expression) +public macro methodSelector( + _: (() -> Output)? +) -> _MethodSelector = +#externalMacro(module: "_SwiftInterceptionCustomSelectorsMacros", type: "MethodSelectorMacro") + +@freestanding(expression) +public macro methodSelector( + _: ((Arg) -> Output)? +) -> _MethodSelector = +#externalMacro(module: "_SwiftInterceptionCustomSelectorsMacros", type: "MethodSelectorMacro") + +@freestanding(expression) +public macro methodSelector( + _: ((repeat each Arg) -> Output)? +) -> _MethodSelector<(repeat each Arg), Output> = +#externalMacro(module: "_SwiftInterceptionCustomSelectorsMacros", type: "MethodSelectorMacro") + +@freestanding(expression) +public macro methodSelector( + _: (Object) -> (() -> Output)? +) -> _MethodSelector = +#externalMacro(module: "_SwiftInterceptionCustomSelectorsMacros", type: "MethodSelectorMacro") + +@freestanding(expression) +public macro methodSelector( + _: (Object) -> ((Arg) -> Output)? +) -> _MethodSelector = +#externalMacro(module: "_SwiftInterceptionCustomSelectorsMacros", type: "MethodSelectorMacro") + +@freestanding(expression) +public macro methodSelector( + _: (Object) -> ((repeat each Arg) -> Output)? +) -> _MethodSelector<(repeat each Arg), Output> = +#externalMacro(module: "_SwiftInterceptionCustomSelectorsMacros", type: "MethodSelectorMacro") + +@freestanding(expression) +public macro methodSelector( + _: (Object) -> () -> Output +) -> _MethodSelector = +#externalMacro(module: "_SwiftInterceptionCustomSelectorsMacros", type: "MethodSelectorMacro") + +@freestanding(expression) +public macro methodSelector( + _: (Object) -> (Arg) -> Output +) -> _MethodSelector = +#externalMacro(module: "_SwiftInterceptionCustomSelectorsMacros", type: "MethodSelectorMacro") + +@freestanding(expression) +public macro methodSelector( + _: (Object) -> (repeat each Arg) -> Output +) -> _MethodSelector<(repeat each Arg), Output> = +#externalMacro(module: "_SwiftInterceptionCustomSelectorsMacros", type: "MethodSelectorMacro") diff --git a/Sources/_SwiftInterceptionCustomSelectors/_MethodSelector.swift b/Sources/_SwiftInterceptionCustomSelectors/_MethodSelector.swift new file mode 100644 index 0000000..54839ba --- /dev/null +++ b/Sources/_SwiftInterceptionCustomSelectors/_MethodSelector.swift @@ -0,0 +1,85 @@ +import Foundation + +public protocol _MethodSelectorProtocol { + associatedtype Args + associatedtype Output + var wrappedValue: Selector { get } +} + +public struct _MethodSelector: _MethodSelectorProtocol { + public var wrappedValue: Selector + + fileprivate init(_ selector: Selector) { + self.wrappedValue = selector + } + + public static func _unchecked(_ selector: Selector) -> _MethodSelector { + return .init(selector) + } +} + +public func _makeMethodSelector( + selector: Selector, + signature: (() -> Output)? +) -> _MethodSelector { + return ._unchecked(selector) +} + +public func _makeMethodSelector( + selector: Selector, + signature: ((Arg) -> Output)? +) -> _MethodSelector<(Arg), Output> { + return ._unchecked(selector) +} + +@_disfavoredOverload +public func _makeMethodSelector( + selector: Selector, + signature: ((repeat each Arg) -> Output)? +) -> _MethodSelector<(repeat each Arg), Output> { + return ._unchecked(selector) +} + +public func _makeMethodSelector( + selector: Selector, + signature: (Object) -> (() -> Output)? +) -> _MethodSelector { + return ._unchecked(selector) +} + +public func _makeMethodSelector( + selector: Selector, + signature: (Object) -> ((Arg) -> Output)? +) -> _MethodSelector<(Arg), Output> { + return ._unchecked(selector) +} + +@_disfavoredOverload +public func _makeMethodSelector( + selector: Selector, + signature: (Object) -> ((repeat each Arg) -> Output)? +) -> _MethodSelector<(repeat each Arg), Output> { + return ._unchecked(selector) +} + +public func _makeMethodSelector( + selector: Selector, + signature: (Object) -> () -> Output +) -> _MethodSelector { + return ._unchecked(selector) +} + +public func _makeMethodSelector( + selector: Selector, + signature: (Object) -> (Arg) -> Output +) -> _MethodSelector<(Arg), Output> { + return ._unchecked(selector) +} + +@_disfavoredOverload +public func _makeMethodSelector( + selector: Selector, + signature: (Object) -> (repeat each Arg) -> Output +) -> _MethodSelector<(repeat each Arg), Output> { + return ._unchecked(selector) +} diff --git a/Sources/_SwiftInterceptionCustomSelectors/_MutablePropertySelector.swift b/Sources/_SwiftInterceptionCustomSelectors/_MutablePropertySelector.swift new file mode 100644 index 0000000..d489b3d --- /dev/null +++ b/Sources/_SwiftInterceptionCustomSelectors/_MutablePropertySelector.swift @@ -0,0 +1,45 @@ +import Foundation + +public protocol _MutablePropertySelectorProtocol: _PropertySelectorProtocol { + var setter: Selector { get } +} + +public struct _MutablePropertySelector: _MutablePropertySelectorProtocol { + public var getter: Selector + public var setter: Selector + + private init( + getter: Selector, + setter: Selector + ) { + self.getter = getter + self.setter = setter + } + + public init?(_ keyPath: WritableKeyPath) { + guard + let getter = keyPath.getterSelector, + let setter = keyPath.setterSelector + else { return nil } + self.init( + getter: getter, + setter: setter + ) + } + + public static func _unchecked( + getter: Selector, + setter: Selector + ) -> _MutablePropertySelector { + return .init( + getter: getter, + setter: setter + ) + } +} + +public func _unsafeMakePropertySelector( + _ keyPath: WritableKeyPath +) -> _MutablePropertySelector { + return .init(keyPath)! +} diff --git a/Sources/_SwiftInterceptionCustomSelectors/_PropertySelector.swift b/Sources/_SwiftInterceptionCustomSelectors/_PropertySelector.swift new file mode 100644 index 0000000..0051ed9 --- /dev/null +++ b/Sources/_SwiftInterceptionCustomSelectors/_PropertySelector.swift @@ -0,0 +1,29 @@ +import Foundation + +public protocol _PropertySelectorProtocol { + associatedtype Value + var getter: Selector { get } +} + +public struct _ReadonlyPropertySelector: _PropertySelectorProtocol { + public var getter: Selector + + private init(getter: Selector) { + self.getter = getter + } + + public init?(_ keyPath: KeyPath) { + guard let getter = keyPath.getterSelector else { return nil } + self.init(getter: getter) + } + + public static func _unchecked(_ selector: Selector) -> _ReadonlyPropertySelector { + return .init(getter: selector) + } +} + +public func _unsafeMakePropertySelector( + _ keyPath: KeyPath +) -> _ReadonlyPropertySelector { + return .init(keyPath)! +} diff --git a/Sources/_SwiftInterceptionCustomSelectorsMacros/Macros/MethodSelectorMacro.swift b/Sources/_SwiftInterceptionCustomSelectorsMacros/Macros/MethodSelectorMacro.swift new file mode 100644 index 0000000..0fc687b --- /dev/null +++ b/Sources/_SwiftInterceptionCustomSelectorsMacros/Macros/MethodSelectorMacro.swift @@ -0,0 +1,24 @@ +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxMacros +import Foundation + +public struct MethodSelectorMacro: ExpressionMacro { + public static func expansion< + Node: FreestandingMacroExpansionSyntax, + Context: MacroExpansionContext + >( + of node: Node, + in context: Context + ) -> ExprSyntax { + guard let arg = node.argumentList.first.map(\.expression) + else { fatalError("compiler bug: the macro does not have any arguments") } + + return """ + _makeMethodSelector( + selector: #selector(\(arg)), + signature: \(arg) + ) + """ + } +} diff --git a/Sources/_SwiftInterceptionCustomSelectorsMacros/Macros/PropertySelectorMacro.swift b/Sources/_SwiftInterceptionCustomSelectorsMacros/Macros/PropertySelectorMacro.swift new file mode 100644 index 0000000..e9ce530 --- /dev/null +++ b/Sources/_SwiftInterceptionCustomSelectorsMacros/Macros/PropertySelectorMacro.swift @@ -0,0 +1,20 @@ +import SwiftDiagnostics +import SwiftSyntax +import SwiftSyntaxMacros + +public struct PropertySelectorMacro: ExpressionMacro { + public static func expansion< + Node: FreestandingMacroExpansionSyntax, + Context: MacroExpansionContext + >( + of node: Node, + in context: Context + ) -> ExprSyntax { + guard let arg = node.argumentList.first.map(\.expression) + else { fatalError("compiler bug: the macro does not have any arguments") } + + return """ + _unsafeMakePropertySelector(\(arg)) + """ + } +} diff --git a/Sources/_SwiftInterceptionCustomSelectorsMacros/Plugin.swift b/Sources/_SwiftInterceptionCustomSelectorsMacros/Plugin.swift new file mode 100644 index 0000000..5599cdf --- /dev/null +++ b/Sources/_SwiftInterceptionCustomSelectorsMacros/Plugin.swift @@ -0,0 +1,11 @@ +import SwiftCompilerPlugin +import SwiftSyntaxMacros + +@main +struct Plugin: CompilerPlugin { + let providingMacros: [Macro.Type] = [ + MethodSelectorMacro.self, + PropertySelectorMacro.self + ] +} + diff --git a/Sources/_SwiftInterceptionUtils/Exports.swift b/Sources/_SwiftInterceptionUtils/Exports.swift new file mode 100644 index 0000000..07b0cdb --- /dev/null +++ b/Sources/_SwiftInterceptionUtils/Exports.swift @@ -0,0 +1,3 @@ +#if canImport(_SwiftInterceptionUtilsObjc) +@_exported import _SwiftInterceptionUtilsObjc +#endif diff --git a/Sources/_SwiftInterceptionUtils/NSObject+Association.swift b/Sources/_SwiftInterceptionUtils/NSObject+Association.swift new file mode 100644 index 0000000..0c79c6f --- /dev/null +++ b/Sources/_SwiftInterceptionUtils/NSObject+Association.swift @@ -0,0 +1,159 @@ +import Foundation +import _SwiftInterceptionUtilsObjc + +public struct AssociationKey { + fileprivate let address: UnsafeRawPointer + fileprivate let `default`: Value! + + /// Create an ObjC association key. + /// + /// - warning: The key must be uniqued. + /// + /// - parameters: + /// - default: The default value, or `nil` to trap on undefined value. It is + /// ignored if `Value` is an optional. + public init(default: Value? = nil) { + self.address = UnsafeRawPointer( + UnsafeMutablePointer.allocate(capacity: 1) + ) + self.default = `default` + } + + /// Create an ObjC association key from a `StaticString`. + /// + /// - precondition: `key` has a pointer representation. + /// + /// - parameters: + /// - default: The default value, or `nil` to trap on undefined value. It is + /// ignored if `Value` is an optional. + public init(_ key: StaticString, default: Value? = nil) { + assert(key.hasPointerRepresentation) + self.address = UnsafeRawPointer(key.utf8Start) + self.default = `default` + } + + /// Create an ObjC association key from a `Selector`. + /// + /// - parameters: + /// - default: The default value, or `nil` to trap on undefined value. It is + /// ignored if `Value` is an optional. + public init(_ key: Selector, default: Value? = nil) { + self.address = UnsafeRawPointer(key.utf8Start) + self.default = `default` + } +} + +public struct Associations { + fileprivate let base: Base + + public init(_ base: Base) { + self.base = base + } +} + +extension NSObjectProtocol { + /// Retrieve the associated value for the specified key. If the value does not + /// exist, `initial` would be called and the returned value would be + /// associated subsequently. + /// + /// - parameters: + /// - key: An optional key to differentiate different values. + /// - initial: The action that supples an initial value. + /// + /// - returns: The associated value for the specified key. + public func associatedValue( + forKey key: StaticString = #function, + initial: (Self) -> T + ) -> T { + let key = AssociationKey(key) + + if let value = associations.value(forKey: key) { + return value + } + + let value = initial(self) + associations.setValue(value, forKey: key) + + return value + } +} + +extension NSObjectProtocol { + @nonobjc public var associations: Associations { + return Associations(self) + } +} + +extension Associations { + /// Retrieve the associated value for the specified key. + /// + /// - parameters: + /// - key: The key. + /// + /// - returns: The associated value, or the default value if no value has been + /// associated with the key. + public func value( + forKey key: AssociationKey + ) -> Value { + return (objc_getAssociatedObject(base, key.address) as! Value?) ?? key.default + } + + /// Retrieve the associated value for the specified key. + /// + /// - parameters: + /// - key: The key. + /// + /// - returns: The associated value, or `nil` if no value is associated with + /// the key. + public func value( + forKey key: AssociationKey + ) -> Value? { + return objc_getAssociatedObject(base, key.address) as! Value? + } + + /// Set the associated value for the specified key. + /// + /// - parameters: + /// - value: The value to be associated. + /// - key: The key. + public func setValue( + _ value: Value, + forKey key: AssociationKey, + policy: objc_AssociationPolicy = .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) { + objc_setAssociatedObject(base, key.address, value, policy) + } + + /// Set the associated value for the specified key. + /// + /// - parameters: + /// - value: The value to be associated. + /// - key: The key. + public func setValue( + _ value: Value?, + forKey key: AssociationKey, + policy: objc_AssociationPolicy = .OBJC_ASSOCIATION_RETAIN_NONATOMIC + ) { + objc_setAssociatedObject(base, key.address, value, policy) + } +} + +/// Set the associated value for the specified key. +/// +/// - parameters: +/// - value: The value to be associated. +/// - key: The key. +/// - address: The address of the object. +public func unsafeSetAssociatedValue( + _ value: Value?, + forKey key: AssociationKey, + forObjectAt address: UnsafeRawPointer, + policy: objc_AssociationPolicy = .OBJC_ASSOCIATION_RETAIN_NONATOMIC +) { + _swiftInterceptionSetAssociatedObject( + address, + key.address, + value, + policy + ) +} diff --git a/Sources/_SwiftInterceptionUtils/NSObject+ObjCRuntime.swift b/Sources/_SwiftInterceptionUtils/NSObject+ObjCRuntime.swift new file mode 100644 index 0000000..4434283 --- /dev/null +++ b/Sources/_SwiftInterceptionUtils/NSObject+ObjCRuntime.swift @@ -0,0 +1,11 @@ +import Foundation + +extension NSObject { + /// The class of the instance reported by the ObjC `-class:` message. + /// + /// - note: `type(of:)` might return the runtime subclass, while this property + /// always returns the original class. + @nonobjc public var objcClass: AnyClass { + return (self as AnyObject).objcClass + } +} diff --git a/Sources/_SwiftInterceptionUtils/ObjC+Constants.swift b/Sources/_SwiftInterceptionUtils/ObjC+Constants.swift new file mode 100644 index 0000000..bff2775 --- /dev/null +++ b/Sources/_SwiftInterceptionUtils/ObjC+Constants.swift @@ -0,0 +1,49 @@ +import Foundation + +// Unavailable selectors in Swift. +public enum ObjCSelector { + public static let forwardInvocation = Selector((("forwardInvocation:"))) + public static let methodSignatureForSelector = Selector((("methodSignatureForSelector:"))) + public static let getClass = Selector((("class"))) +} + +// Method encoding of the unavailable selectors. +public enum ObjCMethodEncoding { + public static let forwardInvocation = extract("v@:@") + public static let methodSignatureForSelector = extract("v@::") + public static let getClass = extract("#@:") + + private static func extract(_ string: StaticString) -> UnsafePointer { + return UnsafeRawPointer(string.utf8Start).assumingMemoryBound(to: CChar.self) + } +} + +/// Objective-C type encoding. +/// +/// The enum does not cover all options, but only those that are expressive in +/// Swift. +public enum ObjCTypeEncoding: Int8 { + case char = 99 + case int = 105 + case short = 115 + case long = 108 + case longLong = 113 + + case unsignedChar = 67 + case unsignedInt = 73 + case unsignedShort = 83 + case unsignedLong = 76 + case unsignedLongLong = 81 + + case float = 102 + case double = 100 + + case bool = 66 + + case object = 64 + case type = 35 + case selector = 58 + + case void = 118 + case undefined = -1 +} diff --git a/Sources/_SwiftInterceptionUtils/ObjC+Messages.swift b/Sources/_SwiftInterceptionUtils/ObjC+Messages.swift new file mode 100644 index 0000000..26dc23f --- /dev/null +++ b/Sources/_SwiftInterceptionUtils/ObjC+Messages.swift @@ -0,0 +1,90 @@ +// Unavailable classes like `NSInvocation` can still be passed into Swift as +// `AnyClass` and `AnyObject`, and receive messages as `AnyClass` and +// `AnyObject` existentials. +// +// These `@objc` protocols host the method signatures so that they can be used +// with `AnyObject`. + +import Foundation + +public let NSInvocation: AnyClass = NSClassFromString("NSInvocation")! +public let NSMethodSignature: AnyClass = NSClassFromString("NSMethodSignature")! + +// Signatures defined in `@objc` protocols would be available for ObjC message +// sending via `AnyObject`. +@objc public protocol ObjCClassReporting { + // An alias for `-class`, which is unavailable in Swift. + @objc(class) + var objcClass: AnyClass! { get } + + @objc(methodSignatureForSelector:) + func objcMethodSignature(for selector: Selector) -> AnyObject +} + +// Methods of `NSInvocation`. +@objc public protocol ObjCInvocation { + @objc(setSelector:) + func objcSetSelector(_ selector: Selector) + + @objc(methodSignature) + var objcMethodSignature: AnyObject { get } + + @objc(getArgument:atIndex:) + func objcCopy(to buffer: UnsafeMutableRawPointer?, forArgumentAt index: Int) + + @objc(invoke) + func objcInvoke() + + @objc(getReturnValue:) + func objcCopyReturnValue(to buffer: UnsafeMutableRawPointer?) + + @objc(invocationWithMethodSignature:) + static func objcInvocation(withMethodSignature signature: AnyObject) -> AnyObject +} + +// Methods of `NSMethodSignature`. +@objc public protocol ObjCMethodSignature { + @objc(numberOfArguments) + var objcNumberOfArguments: UInt { get } + + @objc(methodReturnType) + var objcMethodReturnType: UnsafePointer { get } + + @objc(getArgumentTypeAtIndex:) + func objcArgumentType(at index: UInt) -> UnsafePointer + + @objc(signatureWithObjCTypes:) + static func objcSignature(withObjCTypes typeEncoding: UnsafePointer) -> AnyObject +} + +public func copyArgument( + of type: T.Type = T.self, + at position: Int, + from invocation: AnyObject +) -> T { + let pointer = UnsafeMutableRawPointer.allocate( + byteCount: MemoryLayout.size, + alignment: MemoryLayout.alignment + ) + + defer { + pointer.deallocate() + } + + invocation.objcCopy(to: pointer, forArgumentAt: position) + return pointer.assumingMemoryBound(to: type).pointee +} + +public func copyReturnValue(of type: T.Type = T.self, from invocation: AnyObject) -> T { + let pointer = UnsafeMutableRawPointer.allocate( + byteCount: MemoryLayout.size, + alignment: MemoryLayout.alignment + ) + + defer { + pointer.deallocate() + } + + invocation.objcCopyReturnValue(to: pointer) + return pointer.assumingMemoryBound(to: type).pointee +} diff --git a/Sources/_SwiftInterceptionUtils/ObjC+Runtime.swift b/Sources/_SwiftInterceptionUtils/ObjC+Runtime.swift new file mode 100644 index 0000000..ca37b4c --- /dev/null +++ b/Sources/_SwiftInterceptionUtils/ObjC+Runtime.swift @@ -0,0 +1,27 @@ +import Foundation + +/// Search in `class` for any method that matches the supplied selector without +/// propagating to the ancestors. +/// +/// - parameters: +/// - class: The class to search the method in. +/// - selector: The selector of the method. +/// +/// - returns: The matching method, or `nil` if none is found. +public func class_getImmediateMethod(_ `class`: AnyClass, _ selector: Selector) -> Method? { + var total: UInt32 = 0 + + if let methods = class_copyMethodList(`class`, &total) { + defer { free(methods) } + + for index in 0..(default: nil) + +extension NSObject { + /// Swizzle the given selectors. + /// + /// - warning: The swizzling **does not** apply on a per-instance basis. In + /// other words, repetitive swizzling of the same selector would + /// overwrite previous swizzling attempts, despite a different + /// instance being supplied. + /// + /// - parameters: + /// - pairs: Tuples of selectors and the respective implementions to be + /// swapped in. + /// - key: An association key which determines if the swizzling has already + /// been performed. + public func swizzle(_ pairs: (Selector, Any)..., key hasSwizzledKey: AssociationKey) { + let subclass: AnyClass = swizzleClass(self) + + synchronized(subclass) { + let subclassAssociations = Associations(subclass as AnyObject) + + if !subclassAssociations.value(forKey: hasSwizzledKey) { + subclassAssociations.setValue(true, forKey: hasSwizzledKey) + + for (selector, body) in pairs { + let method = class_getInstanceMethod(subclass, selector)! + let typeEncoding = method_getTypeEncoding(method)! + + if method_getImplementation(method) == _swiftInterceptionMsgForward { + let succeeds = class_addMethod( + subclass, + selector.interopAlias, + imp_implementationWithBlock(body), + typeEncoding + ) + precondition( + succeeds, + "combine-interception attempts to swizzle a selector that has message forwarding enabled with a runtime injected implementation. This is unsupported in the current version." + ) + } + else { + let succeeds = class_addMethod( + subclass, + selector, + imp_implementationWithBlock(body), + typeEncoding + ) + precondition( + succeeds, + "combine-interception attempts to swizzle a selector that has already a runtime injected implementation. This is unsupported in the current version." + ) + } + } + } + } + } +} + +/// ISA-swizzle the class of the supplied instance. +/// +/// - note: If the instance has already been isa-swizzled, the swizzling happens +/// in place in the runtime subclass created by external parties. +/// +/// - warning: The swizzling **does not** apply on a per-instance basis. In +/// other words, repetitive swizzling of the same selector would +/// overwrite previous swizzling attempts, despite a different +/// instance being supplied. +/// +/// - parameters: +/// - instance: The instance to be swizzled. +/// +/// - returns: The runtime subclass of the perceived class of the instance. +public func swizzleClass(_ instance: NSObject) -> AnyClass { + if let knownSubclass = instance.associations.value(forKey: knownRuntimeSubclassKey) { + return knownSubclass + } + + let perceivedClass: AnyClass = instance.objcClass + let realClass: AnyClass = object_getClass(instance)! + let realClassAssociations = Associations(realClass as AnyObject) + + if perceivedClass != realClass { + // If the class is already lying about what it is, it's probably a KVO + // dynamic subclass or something else that we shouldn't subclass at runtime. + synchronized(realClass) { + let isSwizzled = realClassAssociations.value(forKey: runtimeSubclassedKey) + if !isSwizzled { + replaceGetClass(in: realClass, decoy: perceivedClass) + realClassAssociations.setValue(true, forKey: runtimeSubclassedKey) + } + } + + return realClass + } + else { + let name = subclassName(of: perceivedClass) + let subclass: AnyClass = name.withCString { cString in + if let existingClass = objc_getClass(cString) as! AnyClass? { + return existingClass + } + else { + let subclass: AnyClass = objc_allocateClassPair(perceivedClass, cString, 0)! + replaceGetClass(in: subclass, decoy: perceivedClass) + objc_registerClassPair(subclass) + return subclass + } + } + + object_setClass(instance, subclass) + instance.associations.setValue(subclass, forKey: knownRuntimeSubclassKey) + return subclass + } +} + +private func subclassName(of class: AnyClass) -> String { + return String(cString: class_getName(`class`)).appending("_combine-interceptionSwift") +} + +/// Swizzle the `-class` and `+class` methods. +/// +/// - parameters: +/// - class: The class to swizzle. +/// - perceivedClass: The class to be reported by the methods. +private func replaceGetClass(in class: AnyClass, decoy perceivedClass: AnyClass) { + let getClass: @convention(block) (UnsafeRawPointer?) -> AnyClass = { _ in + return 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 + ) +} +#endif diff --git a/Sources/_SwiftInterceptionUtils/ObjC+Selector.swift b/Sources/_SwiftInterceptionUtils/ObjC+Selector.swift new file mode 100644 index 0000000..0769cab --- /dev/null +++ b/Sources/_SwiftInterceptionUtils/ObjC+Selector.swift @@ -0,0 +1,45 @@ +import Foundation + +extension Selector { + /// `self` as a pointer. It is uniqued across instances, similar to + /// `StaticString`. + public var utf8Start: UnsafePointer { + return unsafeBitCast(self, to: UnsafePointer.self) + } + + /// An alias of `self`, used in method interception. + public var alias: Selector { + return prefixing("combine_interception_0_") + } + + /// An alias of `self`, used in method interception specifically for + /// preserving (if found) an immediate implementation of `self` in the + /// runtimae subclass. + public var interopAlias: Selector { + return prefixing("combine_interception_1_") + } + + /// An alias of `self`, used for delegate proxies. + public var delegateProxyAlias: Selector { + return prefixing("delegate_proxy_") + } + + public func prefixing(_ prefix: StaticString) -> Selector { + let length = Int(strlen(utf8Start)) + let prefixedLength = length + prefix.utf8CodeUnitCount + + let asciiPrefix = UnsafeRawPointer(prefix.utf8Start).assumingMemoryBound(to: Int8.self) + + let cString = UnsafeMutablePointer.allocate(capacity: prefixedLength + 1) + defer { + cString.deinitialize(count: prefixedLength + 1) + cString.deallocate() + } + + cString.initialize(from: asciiPrefix, count: prefix.utf8CodeUnitCount) + (cString + prefix.utf8CodeUnitCount).initialize(from: utf8Start, count: length) + (cString + prefixedLength).initialize(to: Int8(UInt8(ascii: "\0"))) + + return sel_registerName(cString) + } +} diff --git a/Sources/_SwiftInterceptionUtils/Synchronizing.swift b/Sources/_SwiftInterceptionUtils/Synchronizing.swift new file mode 100644 index 0000000..1f56553 --- /dev/null +++ b/Sources/_SwiftInterceptionUtils/Synchronizing.swift @@ -0,0 +1,10 @@ +import Foundation + +public func synchronized( + _ token: AnyObject, + execute: () throws -> Result +) rethrows -> Result { + objc_sync_enter(token) + defer { objc_sync_exit(token) } + return try execute() +} diff --git a/Sources/_SwiftInterceptionUtilsObjc/ObjcRuntimeAliases.m b/Sources/_SwiftInterceptionUtilsObjc/ObjcRuntimeAliases.m new file mode 100644 index 0000000..f75f9e1 --- /dev/null +++ b/Sources/_SwiftInterceptionUtilsObjc/ObjcRuntimeAliases.m @@ -0,0 +1,14 @@ +#import +#import + +const IMP _swiftInterceptionMsgForward = _objc_msgForward; + +void _swiftInterceptionSetAssociatedObject( + const void* object, + const void* key, + id value, + objc_AssociationPolicy policy +) { + __unsafe_unretained id obj = (__bridge typeof(obj)) object; + objc_setAssociatedObject(obj, key, value, policy); +} diff --git a/Sources/_SwiftInterceptionUtilsObjc/include/ObjcRuntimeAliases.h b/Sources/_SwiftInterceptionUtilsObjc/include/ObjcRuntimeAliases.h new file mode 100644 index 0000000..2772c77 --- /dev/null +++ b/Sources/_SwiftInterceptionUtilsObjc/include/ObjcRuntimeAliases.h @@ -0,0 +1,18 @@ +#import +#import +#import + +NS_ASSUME_NONNULL_BEGIN + +extern const IMP _swiftInterceptionMsgForward; + +/// A trampoline of `objc_setAssociatedObject` that is made to circumvent the +/// reference counting calls in the imported version in Swift. +void _swiftInterceptionSetAssociatedObject( + const void* object, + const void* key, + id _Nullable value, + objc_AssociationPolicy policy +); + +NS_ASSUME_NONNULL_END diff --git a/Sources/_SwiftInterceptionUtilsObjc/include/module.modulemap b/Sources/_SwiftInterceptionUtilsObjc/include/module.modulemap new file mode 100644 index 0000000..b745ff7 --- /dev/null +++ b/Sources/_SwiftInterceptionUtilsObjc/include/module.modulemap @@ -0,0 +1,4 @@ +module _SwiftInterceptionUtilsObjc { + umbrella header "ObjcRuntimeAliases.h" + export * +} diff --git a/Tests/InterceptionTests/InterceptionTests.swift b/Tests/InterceptionTests/InterceptionTests.swift new file mode 100644 index 0000000..54e0193 --- /dev/null +++ b/Tests/InterceptionTests/InterceptionTests.swift @@ -0,0 +1,173 @@ +import XCTest +@testable import Interception + +final class InterceptionTests: XCTestCase { + func testNoInputWithOutput() { + let object = Object() + var _args: [Void] = [] + var _outputs: [Int] = [] + var _expectedOutputs: [Int] = [] + var _count = 0 + + object.setInterceptionHandler( + for: #methodSelector(Object.zero) + ) { result in + _args.append(result.args) + _outputs.append(result.output) + _count += 1 + } + + let runs = 3 + for _ in 0.. Int { 0 } + + @objc dynamic + func discard(_ value: Int) {} + + @discardableResult + @objc dynamic + func booleanForInteger(_ value: Int) -> Bool { + value != 0 + } + + @objc dynamic + func booleanAndForTwoIntegers(_ first: Int, _ second: Int) -> Bool { + booleanForInteger(first) && booleanForInteger(second) + } + + @objc dynamic + func toString(_ first: Int, _ second: Bool) -> String { + String(describing: (first, second)) + } +} + +fileprivate struct Pair: Equatable { + var left: Left + var right: Right + + init(_ pair: (Left, Right)) { + self.left = pair.0 + self.right = pair.1 + } +} diff --git a/Tests/_SwiftInterceptionCustomSelectorsMacrosTests/MethodSelectorTests.swift b/Tests/_SwiftInterceptionCustomSelectorsMacrosTests/MethodSelectorTests.swift new file mode 100644 index 0000000..6eee673 --- /dev/null +++ b/Tests/_SwiftInterceptionCustomSelectorsMacrosTests/MethodSelectorTests.swift @@ -0,0 +1,31 @@ +import XCTest +import MacroTesting +import _SwiftInterceptionCustomSelectorsMacros + +final class MethodSelectorTests: XCTestCase { + override func invokeTest() { + withMacroTesting( + isRecording: false, + macros: [ + "methodSelector": MethodSelectorMacro.self + ] + ) { + super.invokeTest() + } + } + + func testApplication() { + assertMacro { + """ + #methodSelector(Object.someFunc) + """ + } expansion: { + """ + _makeMethodSelector( + selector: #selector(Object.someFunc), + signature: Object.someFunc + ) + """ + } + } +} diff --git a/Tests/_SwiftInterceptionCustomSelectorsMacrosTests/PropertySelectorTests.swift b/Tests/_SwiftInterceptionCustomSelectorsMacrosTests/PropertySelectorTests.swift new file mode 100644 index 0000000..8963af4 --- /dev/null +++ b/Tests/_SwiftInterceptionCustomSelectorsMacrosTests/PropertySelectorTests.swift @@ -0,0 +1,28 @@ +import XCTest +import MacroTesting +import _SwiftInterceptionCustomSelectorsMacros + +final class PropertySelectorTests: XCTestCase { + override func invokeTest() { + withMacroTesting( + isRecording: false, + macros: [ + "propertySelector": PropertySelectorMacro.self + ] + ) { + super.invokeTest() + } + } + + func testApplication() { + assertMacro { + #""" + #propertySelector(\Object.someProperty) + """# + } expansion: { + #""" + _unsafeMakePropertySelector(\Object.someProperty) + """# + } + } +}