From c3786b606c8d76ceae55268ffd77f6877b3ccc11 Mon Sep 17 00:00:00 2001 From: Revanth Date: Thu, 10 Sep 2026 11:15:06 +0530 Subject: [PATCH] Swizzle configuration factories so custom sessions are captured Co-Authored-By: Claude Opus 5 (1M context) --- .../InspectorURLProtocol.swift | 4 ++ .../URLSessionConfigurationSwizzle.swift | 64 +++++++++++++------ .../NetworkInspectorTests.swift | 23 ++++++- 3 files changed, 69 insertions(+), 22 deletions(-) diff --git a/Sources/NetworkInspector/InspectorURLProtocol.swift b/Sources/NetworkInspector/InspectorURLProtocol.swift index b87abaa..f2216e0 100644 --- a/Sources/NetworkInspector/InspectorURLProtocol.swift +++ b/Sources/NetworkInspector/InspectorURLProtocol.swift @@ -18,6 +18,10 @@ final class InspectorURLProtocol: URLProtocol { private lazy var session: URLSession = { let config = URLSessionConfiguration.default + // `default` is swizzled to seed InspectorURLProtocol; leaving it in place here would point + // this forwarding session back at ourselves. The handled-request flag already breaks that + // loop, but keeping it off the replay session means we never rely on it. + config.protocolClasses = [] return URLSession(configuration: config, delegate: self, delegateQueue: nil) }() diff --git a/Sources/NetworkInspector/URLSessionConfigurationSwizzle.swift b/Sources/NetworkInspector/URLSessionConfigurationSwizzle.swift index be6ab36..5d510b6 100644 --- a/Sources/NetworkInspector/URLSessionConfigurationSwizzle.swift +++ b/Sources/NetworkInspector/URLSessionConfigurationSwizzle.swift @@ -11,31 +11,55 @@ import ObjectiveC extension URLSessionConfiguration { - private static var isSwizzled = false - - @objc dynamic var inspector_protocolClasses: [AnyClass]? { - var classes = self.inspector_protocolClasses ?? [] - if !classes.contains(where: { $0 == InspectorURLProtocol.self }) { - classes.insert(InspectorURLProtocol.self, at: 0) - } - return classes + /// `URLProtocol.registerClass` is only consulted by `NSURLConnection` and `URLSession.shared`. + /// A session built from its own configuration — Ktor's Darwin engine, Alamofire, most embedded + /// SDKs — ignores that registry entirely and only reads its own `protocolClasses`. + /// + /// Swizzling the instance `protocolClasses` getter does not work here: `URLSessionConfiguration` + /// is a class cluster, so `default` returns a private `__NSCFURLSessionConfiguration` whose own + /// override wins over anything exchanged on this class. The factory class methods are the one + /// place every such configuration is guaranteed to pass through. + static func installInspectorSwizzle() { + _ = installOnce } - static func installInspectorSwizzle() { - guard !isSwizzled else { return } + /// A `static let` is initialised exactly once and is thread-safe, so concurrent callers block + /// until the exchange has actually landed. A plain bool flag would let a second caller return + /// early while the first was still mid-swizzle. + private static let installOnce: Void = { + exchangeClassMethod( + #selector(getter: URLSessionConfiguration.default), + with: #selector(URLSessionConfiguration.inspector_defaultSessionConfiguration) + ) + exchangeClassMethod( + #selector(getter: URLSessionConfiguration.ephemeral), + with: #selector(URLSessionConfiguration.inspector_ephemeralSessionConfiguration) + ) + }() + private static func exchangeClassMethod(_ original: Selector, with replacement: Selector) { guard - let original = class_getInstanceMethod( - URLSessionConfiguration.self, - #selector(getter: URLSessionConfiguration.protocolClasses) - ), - let replacement = class_getInstanceMethod( - URLSessionConfiguration.self, - #selector(getter: URLSessionConfiguration.inspector_protocolClasses) - ) + let originalMethod = class_getClassMethod(URLSessionConfiguration.self, original), + let replacementMethod = class_getClassMethod(URLSessionConfiguration.self, replacement) else { return } - method_exchangeImplementations(original, replacement) - isSwizzled = true + method_exchangeImplementations(originalMethod, replacementMethod) + } + + // The implementations are exchanged, so these calls land on the originals. + @objc private class func inspector_defaultSessionConfiguration() -> URLSessionConfiguration { + inspector_defaultSessionConfiguration().addingInspectorProtocol() + } + + @objc private class func inspector_ephemeralSessionConfiguration() -> URLSessionConfiguration { + inspector_ephemeralSessionConfiguration().addingInspectorProtocol() + } + + private func addingInspectorProtocol() -> URLSessionConfiguration { + var classes = protocolClasses ?? [] + guard !classes.contains(where: { $0 == InspectorURLProtocol.self }) else { return self } + classes.insert(InspectorURLProtocol.self, at: 0) + protocolClasses = classes + return self } } diff --git a/Tests/NetworkInspectorTests/NetworkInspectorTests.swift b/Tests/NetworkInspectorTests/NetworkInspectorTests.swift index 4de4be1..ad80d01 100644 --- a/Tests/NetworkInspectorTests/NetworkInspectorTests.swift +++ b/Tests/NetworkInspectorTests/NetworkInspectorTests.swift @@ -1,6 +1,25 @@ +import Foundation import Testing @testable import NetworkInspector -@Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. +/// Sessions that own their configuration never consult `URLProtocol.registerClass`, so the only +/// proof the inspector can see them is the protocol landing in a freshly built configuration. +@Test func injectsProtocolIntoConfigurationsCustomSessionsAreBuiltFrom() async throws { + URLSessionConfiguration.installInspectorSwizzle() + + // `default` is what Ktor's Darwin engine builds its NSURLSession from. + let defaultClasses = URLSessionConfiguration.default.protocolClasses ?? [] + #expect(defaultClasses.contains { $0 == InspectorURLProtocol.self }) + + let ephemeralClasses = URLSessionConfiguration.ephemeral.protocolClasses ?? [] + #expect(ephemeralClasses.contains { $0 == InspectorURLProtocol.self }) +} + +@Test func installIsIdempotent() async throws { + URLSessionConfiguration.installInspectorSwizzle() + URLSessionConfiguration.installInspectorSwizzle() + + let classes = URLSessionConfiguration.default.protocolClasses ?? [] + let occurrences = classes.filter { $0 == InspectorURLProtocol.self }.count + #expect(occurrences == 1) }