Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Sources/NetworkInspector/InspectorURLProtocol.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = []
Comment on lines +21 to +24
return URLSession(configuration: config, delegate: self, delegateQueue: nil)
}()

Expand Down
64 changes: 44 additions & 20 deletions Sources/NetworkInspector/URLSessionConfigurationSwizzle.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}
23 changes: 21 additions & 2 deletions Tests/NetworkInspectorTests/NetworkInspectorTests.swift
Original file line number Diff line number Diff line change
@@ -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)
}