diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7fab20ba..b601b2d77 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -92,6 +92,7 @@ jobs: - uses: actions/checkout@v2 - name: Build Demo run: | + cd Demo set -o pipefail xcodebuild build -scheme "Pulse Demo iOS" -destination "OS=17.4,name=iPhone 15 Pro" | xcpretty build-demo-tvos: @@ -103,6 +104,7 @@ jobs: - uses: actions/checkout@v2 - name: Build Demo run: | + cd Demo set -o pipefail xcodebuild build -scheme "Pulse Demo tvOS" -destination "OS=17.4,name=Apple TV" | xcpretty build-integration-examples-ios: @@ -114,5 +116,6 @@ jobs: - uses: actions/checkout@v2 - name: Build Integration Tests run: | + cd Demo set -o pipefail xcodebuild build -scheme "Pulse Integration Examples iOS" -destination "OS=17.4,name=iPhone 15 Pro" | xcpretty diff --git a/Demo/Pulse.xcodeproj/project.pbxproj b/Demo/Pulse.xcodeproj/project.pbxproj index 829a90b39..305ec2fb2 100644 --- a/Demo/Pulse.xcodeproj/project.pbxproj +++ b/Demo/Pulse.xcodeproj/project.pbxproj @@ -40,6 +40,7 @@ 0C9F04F92884F34A0035239F /* Pulse_Demo_macOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C9F04F82884F34A0035239F /* Pulse_Demo_macOSApp.swift */; }; 0C9F04FD2884F34A0035239F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0C9F04FC2884F34A0035239F /* Assets.xcassets */; }; 0C9F05002884F34A0035239F /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 0C9F04FF2884F34A0035239F /* Preview Assets.xcassets */; }; + 0CA245732C85E87A00B432DA /* PulseProxy in Frameworks */ = {isa = PBXBuildFile; productRef = 0CA245722C85E87A00B432DA /* PulseProxy */; }; 0CDACDE529EC6607007C15CD /* repos.json in Resources */ = {isa = PBXBuildFile; fileRef = 0CDACDE129EC6607007C15CD /* repos.json */; }; 0CDACDE629EC6607007C15CD /* repos.json in Resources */ = {isa = PBXBuildFile; fileRef = 0CDACDE129EC6607007C15CD /* repos.json */; }; /* End PBXBuildFile section */ @@ -152,6 +153,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 0CA245732C85E87A00B432DA /* PulseProxy in Frameworks */, 0C8FCB522C45F05400C4FD84 /* PulseUI in Frameworks */, 0C8FCB502C45F05400C4FD84 /* Pulse in Frameworks */, ); @@ -382,6 +384,7 @@ packageProductDependencies = ( 0C8FCB4F2C45F05400C4FD84 /* Pulse */, 0C8FCB512C45F05400C4FD84 /* PulseUI */, + 0CA245722C85E87A00B432DA /* PulseProxy */, ); productName = "Pulse Demo iOS"; productReference = 0C70EA732A3F611B000B1071 /* Pulse Demo iOS.app */; @@ -791,7 +794,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -835,7 +838,7 @@ INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 14.0; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1033,6 +1036,10 @@ isa = XCSwiftPackageProductDependency; productName = PulseUI; }; + 0CA245722C85E87A00B432DA /* PulseProxy */ = { + isa = XCSwiftPackageProductDependency; + productName = PulseProxy; + }; /* End XCSwiftPackageProductDependency section */ }; rootObject = 0C57AD45245F0EFB005B3400 /* Project object */; diff --git a/Demo/Sources/iOS/Pulse_Demo_iOSApp.swift b/Demo/Sources/iOS/Pulse_Demo_iOSApp.swift index 3c0477bdd..b8fdf6f78 100644 --- a/Demo/Sources/iOS/Pulse_Demo_iOSApp.swift +++ b/Demo/Sources/iOS/Pulse_Demo_iOSApp.swift @@ -5,7 +5,7 @@ import SwiftUI import Pulse import PulseUI -import OSLog +import PulseProxy @main struct PulseDemo_iOS: App { @@ -21,27 +21,52 @@ struct PulseDemo_iOS: App { } private final class AppViewModel: ObservableObject { - let log = OSLog(subsystem: "app", category: "AppViewModel") - init() { -// URLSessionProxyDelegate.enableAutomaticRegistration() -// URLSessionProxy.enable() + // NetworkLogger.enableProxy() + DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(3)) { sendRequest() } -// DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(6)) { -// sendRequest() -// } -// DispatchQueue.main.asyncAfter(deadline: .now() + .seconds(9)) { -// sendRequest() -// } } } + private func sendRequest() { - let session = URLSession(configuration: .default, delegate: DemoSessionDelegate(), delegateQueue: nil) - let task = session.dataTask(with: URLRequest(url: URL(string: "https://github.com/kean/Nuke/archive/refs/tags/11.0.0.zip")!)) + testSwiftConcurrency() + +// let task = session.dataTask(with: URLRequest(url: URL(string: "https://github.com/kean/Nuke/archive/refs/tags/11.0.0.zip")!)) +// task.resume() +} + +private func testClosures() { + let session = URLSessionProxy(configuration: .default) + let task = session.dataTask(with: URLRequest(url: URL(string: "https://api.github.com/repos/octocat/Spoon-Knife/issues?per_page=2")!)) { data, _, _ in + NSLog("didFinish: \(data?.count ?? 0)") + } task.resume() } -private final class DemoSessionDelegate: NSObject, URLSessionDelegate {} +private func testSwiftConcurrency() { + Task { + let demoDelegate = DemoSessionDelegate() + let session = URLSessionProxy(configuration: .default, delegate: demoDelegate, delegateQueue: nil) +// let session = URLSession(configuration: .default) + + let (data, _) = try await session.data(from: URL(string: "https://api.github.com/repos/octocat/Spoon-Knife/issues?per_page=2")!) //, delegate: demoDelegate) + NSLog("didFinish: \(data.count)") + } +} + +private final class DemoSessionDelegate: NSObject, URLSessionDelegate, URLSessionDataDelegate { + func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + NSLog("[\(dataTask.taskIdentifier)] didReceive: \(data.count)") + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { + NSLog("[\(task.taskIdentifier)] didFinishCollectingMetrics: \(metrics)") + } + + func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: (any Error)?) { + NSLog("[\(task.taskIdentifier)] didCompleteWithError: \(String(describing: error))") + } +} diff --git a/Package.swift b/Package.swift index d2ec6605d..2154c7911 100644 --- a/Package.swift +++ b/Package.swift @@ -1,20 +1,22 @@ -// swift-tools-version:5.9 +// swift-tools-version:5.10 import PackageDescription let package = Package( name: "Pulse", platforms: [ - .iOS(.v14), + .iOS(.v15), .tvOS(.v15), .macOS(.v12), .watchOS(.v8) ], products: [ .library(name: "Pulse", targets: ["Pulse"]), + .library(name: "PulseProxy", targets: ["PulseProxy"]), .library(name: "PulseUI", targets: ["PulseUI"]) ], targets: [ .target(name: "Pulse"), + .target(name: "PulseProxy", dependencies: ["Pulse"]), .target(name: "PulseUI", dependencies: ["Pulse"]), ], swiftLanguageVersions: [ diff --git a/Sources/Pulse/Helpers/PulseDocument.swift b/Sources/Pulse/Helpers/PulseDocument.swift index c17c58bd8..ded762896 100644 --- a/Sources/Pulse/Helpers/PulseDocument.swift +++ b/Sources/Pulse/Helpers/PulseDocument.swift @@ -27,6 +27,7 @@ final class PulseDocument { } } + /// - warning: Model has to be loaded only once. static let model: NSManagedObjectModel = { let model = NSManagedObjectModel() let blob = NSEntityDescription(class: PulseBlobEntity.self) diff --git a/Sources/Pulse/Helpers/Regex.swift b/Sources/Pulse/Helpers/Regex.swift index e00c72773..797ddc1a4 100644 --- a/Sources/Pulse/Helpers/Regex.swift +++ b/Sources/Pulse/Helpers/Regex.swift @@ -4,7 +4,7 @@ import Foundation -final class Regex { +final class Regex: @unchecked Sendable { private let regex: NSRegularExpression struct Options: OptionSet { diff --git a/Sources/Pulse/Helpers/Version.swift b/Sources/Pulse/Helpers/Version.swift deleted file mode 100644 index 3b9920d8b..000000000 --- a/Sources/Pulse/Helpers/Version.swift +++ /dev/null @@ -1,68 +0,0 @@ -// The MIT License (MIT) -// -// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). - -public struct Version: Comparable, LosslessStringConvertible, Codable, Sendable { - public let major: Int - public let minor: Int - public let patch: Int - - public init(_ major: Int, _ minor: Int, _ patch: Int) { - precondition(major >= 0 && minor >= 0 && patch >= 0, "Negative versioning is invalid.") - self.major = major - self.minor = minor - self.patch = patch - } - - // MARK: Comparable - - public static func == (lhs: Version, rhs: Version) -> Bool { - !(lhs < rhs) && !(lhs > rhs) - } - - public static func < (lhs: Version, rhs: Version) -> Bool { - (lhs.major, lhs.minor, lhs.patch) < (rhs.major, rhs.minor, rhs.patch) - } - - public init(string: String) throws { - guard let version = Version(string) else { - throw LoggerStore.Error.unknownError // Should never happen - } - self = version - } - - // MARK: LosslessStringConvertible - - public init?(_ string: String) { - guard string.allSatisfy(\.isASCII) else { return nil } - let components = string.split(separator: ".", omittingEmptySubsequences: false) - guard components.count == 3, - let major = Int(components[0]), - let minor = Int(components[1]), - let patch = Int(components[2]) else { - return nil - } - self.major = major - self.minor = minor - self.patch = patch - } - - public var description: String { - "\(major).\(minor).\(patch)" - } - - // MARK: Codable - - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - guard let version = Version(try container.decode(String.self)) else { - throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid version number format") - } - self = version - } - - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - try container.encode(self.description) - } -} diff --git a/Sources/Pulse/LoggerStore/LoggerStore+Configuration.swift b/Sources/Pulse/LoggerStore/LoggerStore+Configuration.swift index 17a61ad19..180e40bd8 100644 --- a/Sources/Pulse/LoggerStore/LoggerStore+Configuration.swift +++ b/Sources/Pulse/LoggerStore/LoggerStore+Configuration.swift @@ -50,7 +50,7 @@ extension LoggerStore { /// The store configuration. public struct Configuration: @unchecked Sendable { - /// Size limit in bytes. `128 MB` by default. + /// Size limit in bytes. `256 MB` by default. public var sizeLimit: Int64 var blobSizeLimit: Int64 { @@ -75,8 +75,8 @@ extension LoggerStore { public var isStoringOnlyImageThumbnails = true /// Limit the maximum response size stored by the logger. The default - /// value is `5 Mb`. The same limit applies to requests. - public var responseBodySizeLimit: Int = 5 * 1048576 + /// value is `8 MB`. The same limit applies to requests. + public var responseBodySizeLimit: Int = 8 * 1048576 var inlineLimit = 16384 // 16 KB @@ -87,9 +87,6 @@ extension LoggerStore { /// ``LoggerStore/Options-swift.struct/sweep`` option. The default store supports sweeps. public var maxAge: TimeInterval = 14 * 86400 - /// For testing purposes. - var makeCurrentDate: () -> Date = { Date() } - /// Gets called when the store receives an event. You can use it to /// modify the event before it is stored in order, for example, filter /// out some sensitive information. If you return `nil`, the event @@ -102,8 +99,8 @@ extension LoggerStore { /// /// - parameters: /// - sizeLimit: The approximate limit of the logger store, including - /// both the database and the blobs. `128 Mb` by default. - public init(sizeLimit: Int64 = 128 * 1_000_000) { + /// both the database and the blobs. `256 Mb` by default. + public init(sizeLimit: Int64 = 256 * 1_000_000) { self.sizeLimit = sizeLimit } } diff --git a/Sources/Pulse/LoggerStore/LoggerStore+Info.swift b/Sources/Pulse/LoggerStore/LoggerStore+Info.swift index db13204a0..1ac7118b0 100644 --- a/Sources/Pulse/LoggerStore/LoggerStore+Info.swift +++ b/Sources/Pulse/LoggerStore/LoggerStore+Info.swift @@ -104,12 +104,14 @@ private func getAppIcon() -> Data? { #if os(iOS) || os(tvOS) || os(visionOS) import UIKit +@MainActor func getDeviceId() -> UUID? { UIDevice.current.identifierForVendor } extension LoggerStore.Info.DeviceInfo { - static func make() -> LoggerStore.Info.DeviceInfo { + @MainActor +static func make() -> LoggerStore.Info.DeviceInfo { let device = UIDevice.current return LoggerStore.Info.DeviceInfo( name: device.name, @@ -123,12 +125,14 @@ extension LoggerStore.Info.DeviceInfo { #elseif os(watchOS) import WatchKit +@MainActor func getDeviceId() -> UUID? { WKInterfaceDevice.current().identifierForVendor } extension LoggerStore.Info.DeviceInfo { - static func make() -> LoggerStore.Info.DeviceInfo { + @MainActor +static func make() -> LoggerStore.Info.DeviceInfo { let device = WKInterfaceDevice.current() return LoggerStore.Info.DeviceInfo( name: device.name, @@ -143,7 +147,8 @@ extension LoggerStore.Info.DeviceInfo { import AppKit extension LoggerStore.Info.DeviceInfo { - static func make() -> LoggerStore.Info.DeviceInfo { + @MainActor +static func make() -> LoggerStore.Info.DeviceInfo { return LoggerStore.Info.DeviceInfo( name: Host.current().name ?? "unknown", model: "unknown", @@ -154,6 +159,7 @@ extension LoggerStore.Info.DeviceInfo { } } +@MainActor func getDeviceId() -> UUID? { return nil } diff --git a/Sources/Pulse/LoggerStore/LoggerStore+Model.swift b/Sources/Pulse/LoggerStore/LoggerStore+Model.swift index ea5cf0374..7a44d6ae0 100644 --- a/Sources/Pulse/LoggerStore/LoggerStore+Model.swift +++ b/Sources/Pulse/LoggerStore/LoggerStore+Model.swift @@ -6,6 +6,8 @@ import CoreData extension LoggerStore { /// Returns Core Data model used by the store. + /// + /// - warning: Model has to be loaded only once. static let model: NSManagedObjectModel = { typealias Entity = NSEntityDescription typealias Attribute = NSAttributeDescription diff --git a/Sources/Pulse/LoggerStore/LoggerStore+Version.swift b/Sources/Pulse/LoggerStore/LoggerStore+Version.swift new file mode 100644 index 000000000..c5f9a1dfd --- /dev/null +++ b/Sources/Pulse/LoggerStore/LoggerStore+Version.swift @@ -0,0 +1,73 @@ +// The MIT License (MIT) +// +// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). + +extension LoggerStore { + /// A semantic version. + public struct Version: Comparable, LosslessStringConvertible, Codable, Sendable { + public let major: Int + public let minor: Int + public let patch: Int + + public init(_ major: Int, _ minor: Int, _ patch: Int) { + precondition(major >= 0 && minor >= 0 && patch >= 0, "Negative versioning is invalid.") + self.major = major + self.minor = minor + self.patch = patch + } + + // MARK: Comparable + + public static func == (lhs: Version, rhs: Version) -> Bool { + !(lhs < rhs) && !(lhs > rhs) + } + + public static func < (lhs: Version, rhs: Version) -> Bool { + (lhs.major, lhs.minor, lhs.patch) < (rhs.major, rhs.minor, rhs.patch) + } + + public init(string: String) throws { + guard let version = Version(string) else { + throw LoggerStore.Error.unknownError // Should never happen + } + self = version + } + + // MARK: LosslessStringConvertible + + public init?(_ string: String) { + guard string.allSatisfy(\.isASCII) else { return nil } + let components = string.split(separator: ".", omittingEmptySubsequences: false) + guard components.count == 3, + let major = Int(components[0]), + let minor = Int(components[1]), + let patch = Int(components[2]) else { + return nil + } + self.major = major + self.minor = minor + self.patch = patch + } + + public var description: String { + "\(major).\(minor).\(patch)" + } + + // MARK: Codable + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + guard let version = Version(try container.decode(String.self)) else { + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid version number format") + } + self = version + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(self.description) + } + } +} + +typealias Version = LoggerStore.Version diff --git a/Sources/Pulse/LoggerStore/LoggerStore.swift b/Sources/Pulse/LoggerStore/LoggerStore.swift index ca7cfbbb7..2850ebeff 100644 --- a/Sources/Pulse/LoggerStore/LoggerStore.swift +++ b/Sources/Pulse/LoggerStore/LoggerStore.swift @@ -17,7 +17,10 @@ public final class LoggerStore: @unchecked Sendable, Identifiable { public let options: Options /// The configuration with which the store was initialized with. - public let configuration: Configuration + /// + /// - warning: This property is not thread-safe. Make sure to change it at + /// the app launch before sending any logs. + public var configuration: Configuration /// Current session or the latest session in case of an archive. private(set) public var session: Session = .current @@ -54,9 +57,12 @@ public final class LoggerStore: @unchecked Sendable, Identifiable { private var requestsCache: [NetworkLogger.Request: NetworkRequestEntity] = [:] private var responsesCache: [NetworkLogger.Response: NetworkResponseEntity] = [:] + /// For testing purposes. + var makeCurrentDate: () -> Date = { Date() } + // MARK: Shared - /// Returns a shared store. + /// Returns the shared store. /// /// You can replace the default store with a custom one. If you replace the /// shared store, it automatically gets registered as the default store @@ -101,15 +107,16 @@ public final class LoggerStore: @unchecked Sendable, Identifiable { /// Initializes the store with the given URL. The store needs to be /// /// The ``LoggerStore/shared`` store is a package optimized for writing. When - /// you are ready to share the store, create a Pulse document using ``copy(to:predicate:)`` + /// you are ready to share the store, create a Pulse document using ``export(to:options:)`` /// method. The document format is optimized to use the least amount of space possible. /// /// - parameters: /// - storeURL: The store URL that points to a package (directory) /// with a Pulse database. - /// - options: By default, empty. To create a store, use ``Options-swift.struct/create``. + /// - options: By default, contains ``LoggerStore/Options-swift.struct/create`` + /// and ``LoggerStore/Options-swift.struct/sweep`` options. /// - configuration: The store configuration specifying size limit, etc. - public init(storeURL: URL, options: Options = [], configuration: Configuration = .init()) throws { + public init(storeURL: URL, options: Options = [.create, .sweep], configuration: Configuration = .init()) throws { var isDirectory: ObjCBool = ObjCBool(false) let fileExists = Files.fileExists(atPath: storeURL.path, isDirectory: &isDirectory) guard (fileExists && isDirectory.boolValue) || options.contains(.create) else { @@ -279,7 +286,7 @@ extension LoggerStore { line: UInt = #line ) { handle(.messageStored(.init( - createdAt: createdAt ?? configuration.makeCurrentDate(), + createdAt: createdAt ?? makeCurrentDate(), label: label, level: level, message: message, @@ -306,7 +313,7 @@ extension LoggerStore { handle(.networkTaskCompleted(.init( taskId: UUID(), taskType: .dataTask, - createdAt: configuration.makeCurrentDate(), + createdAt: makeCurrentDate(), originalRequest: NetworkLogger.Request(request), currentRequest: NetworkLogger.Request(request), response: response.map(NetworkLogger.Response.init), @@ -320,7 +327,7 @@ extension LoggerStore { } /// Handles event created by the current store and dispatches it to observers. - public func handle(_ event: Event) { + func handle(_ event: Event) { guard let event = configuration.willHandleEvent(event) else { return } @@ -442,9 +449,9 @@ extension LoggerStore { } var currentRequest = event.currentRequest - if currentRequest?.headers?[RemoteLoggerURLProtocol.requestMockedHeaderName] != nil { + if currentRequest?.headers?[MockingURLProtocol.requestMockedHeaderName] != nil { entity.isMocked = true - currentRequest?.headers?[RemoteLoggerURLProtocol.requestMockedHeaderName] = nil + currentRequest?.headers?[MockingURLProtocol.requestMockedHeaderName] = nil } else { entity.isMocked = false } @@ -842,26 +849,20 @@ extension LoggerStore { /// - targetURL: The destination directory must already exist. If the /// file at the destination URL already exists, throws an error. /// - options: The other sharing options. - /// - /// - returns: The information about the created store. - @discardableResult - public func export(to targetURL: URL, options: ExportOptions = .init()) async throws -> Info { - try await Task.detached(priority: .userInitiated) { - try self._export(to: targetURL, options: options) - }.value + public func export(to targetURL: URL, options: ExportOptions = .init()) async throws { + try await _export(to: targetURL, options: options) } - @discardableResult - private func _export(to targetURL: URL, options: ExportOptions) throws -> Info { + private func _export(to targetURL: URL, options: ExportOptions) async throws { guard !FileManager.default.fileExists(atPath: targetURL.path) else { throw LoggerStore.Error.fileAlreadyExists } - return try _exportAsArchive(to: targetURL, options: options) + try await _exportAsArchive(to: targetURL, options: options) } // MARK: Export as Package - private func _exportAsPackage(to targetURL: URL, options: ExportOptions) throws -> Info { + private func _exportAsPackage(to targetURL: URL, options: ExportOptions) async throws { let temporary = TemporaryDirectory() defer { temporary.remove() } @@ -887,11 +888,8 @@ extension LoggerStore { try? _exportBlobs(to: target) } - let info = try target.info() try target.close() // important: has to be called before `move`. - try Files.moveItem(at: temporary.url, to: targetURL) - return info } /// Removes any content that doesn't match the given options. @@ -927,24 +925,24 @@ extension LoggerStore { // MARK: Export as Archive - private func _exportAsArchive(to targetURL: URL, options: ExportOptions) throws -> Info { + private func _exportAsArchive(to targetURL: URL, options: ExportOptions) async throws { if options.predicate != nil || options.sessions != nil { let temporary = TemporaryDirectory() defer { temporary.remove() } let tempStoreURL = temporary.url.appending(filename: "temp.pulse") - _ = try _exportAsPackage(to: tempStoreURL, options: options) + _ = try await _exportAsPackage(to: tempStoreURL, options: options) let target = try LoggerStore(storeURL: tempStoreURL, options: .readonly) defer { try? target.close() } - return try target._exportPackageAsArchive(to: targetURL) + return try await target._exportPackageAsArchive(to: targetURL) } else { - return try _exportPackageAsArchive(to: targetURL) + return try await _exportPackageAsArchive(to: targetURL) } } - private func _exportPackageAsArchive(to targetURL: URL) throws -> Info { + private func _exportPackageAsArchive(to targetURL: URL) async throws { let temporary = TemporaryDirectory() defer { temporary.remove() } @@ -952,7 +950,7 @@ extension LoggerStore { let databaseURL = temporary.url.appending(filename: databaseFilename) try container.persistentStoreCoordinator.createCopyOfStore(at: databaseURL) - var info = try self.info() + var info = try await self.info() let document = try PulseDocument(documentURL: targetURL) var totalSize: Int64 = 0 @@ -988,7 +986,7 @@ extension LoggerStore { // The output file is also going to be about 10-20% larger because of // the unused pages in the sqlite database. info.totalStoreSize = totalSize + 500 // info is roughly 500 bytes - info.creationDate = configuration.makeCurrentDate() + info.creationDate = makeCurrentDate() info.modifiedDate = info.creationDate let infoBlob = PulseBlobEntity(context: document.context) @@ -997,15 +995,8 @@ extension LoggerStore { try document.context.save() try? document.close() - - return info } } - - @available(*, deprecated, message: "Deprecated") // 3.6 - public func copy(to targetURL: URL, predicate: NSPredicate? = nil) throws -> Info { - try _export(to: targetURL, options: .init(predicate: predicate)) - } } // MARK: - LoggerStore (Sweep) @@ -1040,7 +1031,7 @@ extension LoggerStore { } private func removeExpiredMessages() throws { - let cutoffDate = configuration.makeCurrentDate().addingTimeInterval(-configuration.maxAge) + let cutoffDate = makeCurrentDate().addingTimeInterval(-configuration.maxAge) let sessionIDs = try backgroundContext.fetch(LoggerSessionEntity.self) { $0.predicate = NSPredicate(format: "createdAt < %@", cutoffDate as NSDate) }.map(\.id) @@ -1140,16 +1131,19 @@ extension LoggerStore { /// Returns the current store's info. /// /// - important Thread-safe. But must NOT be called inside the `backgroundContext` queue. - public func info() throws -> Info { - try backgroundContext.performAndReturn { try self._info() } + public func info() async throws -> Info { + let deviceInfo = await LoggerStore.Info.DeviceInfo.make() + return try await container.performBackgroundTask { context in + return try self._info(in: context, deviceInfo: deviceInfo) + } } - private func _info() throws -> Info { + private func _info(in context: NSManagedObjectContext, deviceInfo: LoggerStore.Info.DeviceInfo) throws -> Info { let databaseAttributes = try Files.attributesOfItem(atPath: databaseURL.path) - let messageCount = try backgroundContext.count(for: LoggerMessageEntity.self) - let taskCount = try backgroundContext.count(for: NetworkTaskEntity.self) - let blobCount = try backgroundContext.count(for: LoggerBlobHandleEntity.self) + let messageCount = try context.count(for: LoggerMessageEntity.self) + let taskCount = try context.count(for: NetworkTaskEntity.self) + let blobCount = try context.count(for: LoggerBlobHandleEntity.self) return Info( storeId: manifest.storeId, @@ -1163,7 +1157,7 @@ extension LoggerStore { blobsSize: try getBlobsSize(), blobsDecompressedSize: try getBlobsSize(isDecompressed: true), appInfo: .make(), - deviceInfo: .make() + deviceInfo: deviceInfo ) } } diff --git a/Sources/Pulse/RemoteLogger/RemoteLoggerURLProtocol.swift b/Sources/Pulse/NetworkDebugger/MockingURLProtocol.swift similarity index 54% rename from Sources/Pulse/RemoteLogger/RemoteLoggerURLProtocol.swift rename to Sources/Pulse/NetworkDebugger/MockingURLProtocol.swift index 3dfec8ccc..5874e189a 100644 --- a/Sources/Pulse/RemoteLogger/RemoteLoggerURLProtocol.swift +++ b/Sources/Pulse/NetworkDebugger/MockingURLProtocol.swift @@ -4,11 +4,11 @@ import Foundation -/// A custom `URLProtocol` that enables Pulse network debugger features such -/// as mocking, request rewriting, breakpoints, and more. -public final class RemoteLoggerURLProtocol: URLProtocol { +/// A custom `URLProtocol` that enables Pulse network debugging features such +/// as mocking of the network responses. +public final class MockingURLProtocol: URLProtocol, @unchecked Sendable { public override func startLoading() { - guard let mock = RemoteDebugger.shared.getMock(for: request) else { + guard let mock = NetworkDebugger.shared.getMock(for: request) else { client?.urlProtocol(self, didFailWithError: URLError(.unknown)) // Should never happen return } @@ -43,7 +43,7 @@ public final class RemoteLoggerURLProtocol: URLProtocol { public override class func canonicalRequest(for request: URLRequest) -> URLRequest { var request = request - request.addValue("true", forHTTPHeaderField: RemoteLoggerURLProtocol.requestMockedHeaderName) + request.addValue("true", forHTTPHeaderField: MockingURLProtocol.requestMockedHeaderName) return request } @@ -51,8 +51,32 @@ public final class RemoteLoggerURLProtocol: URLProtocol { guard RemoteLogger.latestConnectionState.value == .connected else { return false } - return RemoteDebugger.shared.shouldMock(request) + return NetworkDebugger.shared.shouldMock(request) } static let requestMockedHeaderName = "X-PulseRequestMocked" } + + +// MARK: - MockingURLProtocol (Automatic Registration) + +extension MockingURLProtocol { + /// Inject the protocol in every `URLSession` instance created by the app. + @MainActor + public static func enableAutomaticRegistration() { + if let lhs = class_getClassMethod(URLSession.self, #selector(URLSession.init(configuration:delegate:delegateQueue:))), + let rhs = class_getClassMethod(URLSession.self, #selector(URLSession.pulse_init2(configuration:delegate:delegateQueue:))) { + method_exchangeImplementations(lhs, rhs) + } + } +} + +private extension URLSession { + @objc class func pulse_init2(configuration: URLSessionConfiguration, delegate: URLSessionDelegate?, delegateQueue: OperationQueue?) -> URLSession { + guard isConfiguringSessionSafe(delegate: delegate) else { + return self.pulse_init2(configuration: configuration, delegate: delegate, delegateQueue: delegateQueue) + } + configuration.protocolClasses = [MockingURLProtocol.self] + (configuration.protocolClasses ?? []) + return self.pulse_init2(configuration: configuration, delegate: delegate, delegateQueue: delegateQueue) + } +} diff --git a/Sources/Pulse/RemoteLogger/RemoteDebugger.swift b/Sources/Pulse/NetworkDebugger/NetworkDebugger.swift similarity index 94% rename from Sources/Pulse/RemoteLogger/RemoteDebugger.swift rename to Sources/Pulse/NetworkDebugger/NetworkDebugger.swift index ae07908a2..b88927912 100644 --- a/Sources/Pulse/RemoteLogger/RemoteDebugger.swift +++ b/Sources/Pulse/NetworkDebugger/NetworkDebugger.swift @@ -4,7 +4,7 @@ import Foundation -final class RemoteDebugger: @unchecked Sendable { +final class NetworkDebugger: @unchecked Sendable { private var mocks: [UUID: URLSessionMock] = [:] // Number of handled requests per mock. @@ -13,7 +13,7 @@ final class RemoteDebugger: @unchecked Sendable { private let lock = NSLock() - static let shared = RemoteDebugger() + static let shared = NetworkDebugger() func getMock(for request: URLRequest) -> URLSessionMock? { lock.lock() diff --git a/Sources/Pulse/NetworkLogger/NetworkLogger.swift b/Sources/Pulse/NetworkLogger/NetworkLogger.swift index dbcf43cf5..aa6e59b8a 100644 --- a/Sources/Pulse/NetworkLogger/NetworkLogger.swift +++ b/Sources/Pulse/NetworkLogger/NetworkLogger.swift @@ -10,7 +10,8 @@ import Foundation /// should generally not be used directly. public final class NetworkLogger: @unchecked Sendable { private let configuration: Configuration - private let store: LoggerStore + private var store: LoggerStore { _store ?? .shared } + private let _store: LoggerStore? private var includedHosts: [Regex] = [] private var includedURLs: [Regex] = [] @@ -24,6 +25,24 @@ public final class NetworkLogger: @unchecked Sendable { private var isFilteringNeeded = false private let lock = NSLock() + /// A shared network logger. + /// + /// You can configure a logger by creating a new instance and setting it as + /// a shared logger: + /// + /// ```swift + /// NetworkLogger.shared = NetworkLogger { + /// $0.excludedHosts = ["github.com"] + /// } + /// ``` + /// + /// The best place to do it is at the app launch. + public static var shared: NetworkLogger { + get { _shared.value } + set { _shared.value = newValue } + } + private static let _shared = Mutex(NetworkLogger()) + /// The logger configuration. public struct Configuration: Sendable { /// A custom label to associated with stored messages. @@ -93,18 +112,18 @@ public final class NetworkLogger: @unchecked Sendable { /// - parameters: /// - store: The target store for network requests. /// - configuration: The store configuration. - public init(store: LoggerStore = .shared, configuration: Configuration = .init()) { - self.store = store + public init(store: LoggerStore? = nil, configuration: Configuration = .init()) { + self._store = store self.configuration = configuration self.processPatterns() } - /// Initializes and configures the network logger. - public convenience init(store: LoggerStore = .shared, _ configure: (inout Configuration) -> Void) { - var configuration = Configuration() - configure(&configuration) - self.init(store: store, configuration: configuration) - } +// /// Initializes and configures the network logger. +// public convenience init(store: LoggerStore? = nil, _ configure: (inout Configuration) -> Void) { +// var configuration = Configuration() +// configure(&configuration) +// self.init(store: store, configuration: configuration) +// } // MARK: Patterns @@ -141,6 +160,9 @@ public final class NetworkLogger: @unchecked Sendable { /// Logs the task creation (optional). public func logTaskCreated(_ task: URLSessionTask) { lock.lock() + guard tasks[TaskKey(task: task)] == nil else { + return // Already registered + } let context = context(for: task) lock.unlock() diff --git a/Sources/Pulse/Pulse.docc/Articles/GettingStarted.md b/Sources/Pulse/Pulse.docc/Articles/GettingStarted.md index b974a677a..251887cf5 100644 --- a/Sources/Pulse/Pulse.docc/Articles/GettingStarted.md +++ b/Sources/Pulse/Pulse.docc/Articles/GettingStarted.md @@ -2,99 +2,101 @@ Learn how to integrate Pulse. -## Overview +## 1. Add Frameworks -Pulse is a framework that provides complete access to the underlying data, and there are many ways to use it. This guide describes the basic integration steps. - -## 1. Add Pulse and PulseUI Frameworks to Your App - -There are two main installation options: - -- Add Pulse Swift package to your project using SPM +- **Option 1 (Recommended)**. Add package to your project using SwiftPM. ``` https://github.com/kean/Pulse ``` -- Use precompiled binary frameworks from the [latest release](https://github.com/kean/Pulse/releases) +Add **Pulse** and **PulseUI** libraries to your app. -> info: If you'd like to create binary frameworks using a specific Xcode version, consider using [swift-create-xcframework](https://github.com/marketplace/actions/swift-create-xcframework). +- **Option 2**. Use precompiled binary frameworks from the [latest release](https://github.com/kean/Pulse/releases). ## 2. Integrate Pulse Framework -To start collecting logs, use [Pulse](https://kean-docs.github.io/pulse/documentation/pulse/) framework. +**Pulse** framework contains APIs for logging, capturing, and mocking network requests, as well as connecting to the Pulse Pro apps. -### 2.1. Collecting Regular Messages +### 2.1. Capture Network Requests -To store regular log messages, use [LoggerStore](https://kean-docs.github.io/pulse/documentation/pulse/loggerstore). +- **Option 1 (Recommended)**. Use ``URLSessionProxy``, a thin wrapper on top of `URLSession`. ```swift -LoggerStore.shared.storeMessage( - label: "auth", - level: .debug, - message: "Will login user", - metadata: ["userId": .string("uid-1")] -) +import Pulse + +#if DEBUG +let session: URLSessionProtocol = URLSessionProxy(configuration: .default) +#else +let session: URLSessionProtocol = URLSession(configuration: .default) +#endif ``` -> info: As an alternative to using `LoggerStore` directly, you can use Pulse as a SwiftLog backend using [PersistentLogHandler](https://kean-docs.github.io/pulseloghandler/documentation/pulseloghandler/persistentloghandler) struct from [PulseLogHandler](https://kean-docs.github.io/pulseloghandler/documentation/pulseloghandler) which is a [Swift package distributed separately](https://github.com/kean/PulseLogHandler). This way you can have more than one logger at once. +> tip: See for more information about how to configure network logging if your app does not use `URLSession` directly, how to further customize it, how to capture and display decoding errors, and more. Pulse is modular and will accommodate almost any system. + +- **Option 2 (Quickest)**. If you are evaluating the framework, the quickest way to get started is with a proxy from the **PulseProxy** module. -### 2.2. Collecting Network Requests +```swift +import PulseProxy + +#if DEBUG +NetworkLogger.enableProxy() +#endif +``` -The recommended option is to use ``URLSessionProxyDelegate`` which sits between [`URLSession`](https://developer.apple.com/documentation/foundation/urlsession) and your actual [`URLSessionDelegate`](https://developer.apple.com/documentation/foundation/urlsessiondelegate). +> important: **PulseProxy** uses swizzling and private APIs and it is not recommended that you include it in the production builds of your app. -You can enable ``URLSessionProxyDelegate`` for all `URLSession` instances created by the app by using ``URLSessionProxyDelegate/enableAutomaticRegistration(logger:)``. +### 2.2. Collect Logs + +To store regular log messages, use [LoggerStore](https://kean-docs.github.io/pulse/documentation/pulse/loggerstore). ```swift -// Call it anywhere in your code before instantiating a `URLSession` -URLSessionProxyDelegate.enableAutomaticRegistration() - -// Instantiate `URLSession` as usual -let session = URLSession( - configuration: .default, - delegate: YourURLSessionDelegate(), - delegateQueue: nil +LoggerStore.shared.storeMessage( + label: "auth", + level: .debug, + message: "Will login user", + metadata: ["userId": .string("uid-1")] ) ``` -> Important: This option works only with delegate-based sessions, which includes [Alamofire](https://github.com/Alamofire/Alamofire) and [Get](https://github.com/kean/Get). It will **not** work with `URLSession.shared`. For other options, see the dedicated [guide](https://kean-docs.github.io/pulse/documentation/pulse/networklogging-article). - -Logs are stored persistently and the store automatically removes old messages and limits the overall size (configurable). It uses a number of space [optimizations techniques](https://kean.blog/post/pulse-2#space-savings), including fast [lzfse](https://developer.apple.com/documentation/compression/algorithm/lzfse) compression. - -> Tip: To get the most out of the network logger, follow the guide. For example, starting with Pulse 2.0, you can record and view [decoding errors](https://kean.blog/post/pulse-2#decoding-errors) which makes it much easier to see why decoding is failing. +> tip: Alternatively, you can use it as a SwiftLog backend using [PersistentLogHandler](https://kean-docs.github.io/pulseloghandler/documentation/pulseloghandler/persistentloghandler) from a [PulseLogHandler](https://github.com/kean/PulseLogHandler) package. ## 3. Integrate PulseUI Framework -To view logs and network requests from your app, use [PulseUI](https://kean-docs.github.io/pulseui/documentation/pulseui/) framework. The framework is centered around a single screen: `ConsoleView`. On iOS, you can push it into the existing navigation stack or present it modally. +[**PulseUI**](https://kean-docs.github.io/pulseui/documentation/pulseui/) allows you to view logs and network requests directly from your app. The framework is centered around a single screen: `ConsoleView`. On iOS, you can push it into the existing navigation stack or present it modally. ```swift +import PulseUI + NavigationLink(destination: ConsoleView()) { Text("Console") } ``` -> Note: There are some additional steps required for some platforms. For more information see the PulseUI [documentation](https://kean-docs.github.io/pulseui/documentation/pulseui/). +> tip: For more information, see the PulseUI [documentation](https://kean-docs.github.io/pulseui/documentation/pulseui/). + +![Pulse Console](pulse-console.png) -## 4. Configure Remote Logging with Pulse Pro +## 4. Get Pulse Apps -In addition to the frameworks and the on-device view, Pulse also provides a separate professional macOS app called [Pulse Pro](https://kean.blog/pulse/pro) that you can use for viewing the previously shared logs or even viewing the logs from the device remotely in real-time. +Pulse also provides separate indispensable [macOS and iOS apps](https://pulselogger.com) that you can use to view logs collected by the Pulse SDK and even debug your apps in real-time with features like response mocking. The app are [available on the App Store](https://apps.apple.com/us/app/pulse-network-logger/id6661031747). -To start using remote logging, there are a couple of extra setup steps: +The apps require two more simple configuration steps. -### 4.1. Configure the App +### 4.1. Update Info.plist -Add the following to the app's plist file to allow it to use local networking: +Add the following to your app's plist file: ```swift NSLocalNetworkUsageDescription -Network usage required for debugging purposes +Network usage required only for development purposes NSBonjourServices _pulse._tcp ``` -> Note: There will be no user prompts unless you enable remote logging from settings. +> important: Pulse will **not show** any prompts unless you enable remote logging from the Pulse settings screen. ### 4.2. Enable Remote Logging @@ -102,4 +104,10 @@ Open the Pulse console from the app, go to Settings, enable "Remote Logging", an ![Enabling remote logging](remote-logging.png) -Once the connection is established, open Pulse Pro and select the device in the sidebar. The next time you launch the app, the connection will happen automatically. +Once the connection is established, open the Pulse app on your Mac and select the device in the sidebar. The next time you launch the app, the connection will happen automatically. + +![Pulse Pro](pulse-pro.png) + +## Next Steps + +Learn how to configure Pulse to best suit your app needs in and explore additional networking debugging techniques in . diff --git a/Sources/Pulse/Pulse.docc/Articles/NetworkLogging-Article.md b/Sources/Pulse/Pulse.docc/Articles/NetworkLogging-Article.md index f16f5c1b7..811d86630 100644 --- a/Sources/Pulse/Pulse.docc/Articles/NetworkLogging-Article.md +++ b/Sources/Pulse/Pulse.docc/Articles/NetworkLogging-Article.md @@ -1,47 +1,70 @@ -# Logging Network Requests +# Network Logging & Debugging -Learn how to enable network logging. +Learn how to enable and configure network logging and debugging. ## Overview -Pulse works on the `URLSession` level and it needs access to its callbacks to log network requests and capture network metrics. There are multiple ways to do that and they are all covered in this article. +Pulse works on the `URLSession` level, and it needs access to its callbacks to log network requests and capture network metrics. The framework is modular and provides multiple options that can accommodate almost any system. By the end of this article, you will have a system that: -## Proxy Delegate +- Captures network requests and metrics +- Supports debugging features powered by Pulse Pro, such as mocking -The recommended option is to use ``URLSessionProxyDelegate`` which sits between [`URLSession`](https://developer.apple.com/documentation/foundation/urlsession) and your actual [`URLSessionDelegate`](https://developer.apple.com/documentation/foundation/urlsessiondelegate). +## Capture Network Requests -You can enable ``URLSessionProxyDelegate`` for all `URLSession` instances created by the app by using ``URLSessionProxyDelegate/enableAutomaticRegistration(logger:)`` (note that it uses Objective-C runtime to achieve that): +The first step is to capture network traffic. + +### Option 1 (Recommended) + +Use ``URLSessionProxy``, a thin wrapper on top of `URLSession`. + +```swift +import Pulse + +#if DEBUG +let session: URLSessionProtocol = URLSessionProxy(configuration: .default) +#else +let session: URLSessionProtocol = URLSession(configuration: .default) +#endif +``` + +``URLSessionProxy`` is the best way to integrate Pulse because it supports all `URLSession` APIs, including the new Async/Await methods. It also makes it easy to remove it conditionally. + +### Option 2 (Quickest) + +If you are evaluating the framework, the quickest way to get started is with a proxy from the **PulseProxy** module. ```swift -// Call it anywhere in your code prior to instantiating a `URLSession` -URLSessionProxyDelegate.enableAutomaticRegistration() +import PulseProxy -// Instantiate `URLSession` as usual -let session = URLSession(configuration: .default, delegate: YourURLSessionDelegate(), delegateQueue: nil) +#if DEBUG +NetworkLogger.enableProxy() +#endif ``` -And if you want to enable logging just for specific sessions, use ``URLSessionProxyDelegate`` directly: +> important: **PulseProxy** uses method swizzling and private APIs, and it is not recommended that you include it in the production builds of your app. It is also not guaranteed to continue working with new versions of the system SDKs. + +### Option 3 + +If you use a delegate-based `URLSession` that doesn't rely on any of its convenience APIs, such as [Alamofire](https://github.com/Alamofire/Alamofire), you can record its traffic using ``URLSessionProxyDelegate``. ```swift -let delegate = URLSessionProxyDelegate(delegate: YourURLSessionDelegate()) +let delegate = URLSessionProxyDelegate(delegate: <#YourSessionDelegate#>) let session = URLSession(configuration: .default, delegate: delegate, delegateQueue: nil) ``` -> important: Both these options work only with sessions that use a delegate-based approach and won't work with `URLSession.shared`. In that can you can either log the requests manually, which is covered in the next section or try ``Experimental/URLSessionProxy``. +> important: This method supports a limited subset of scenarios and doesn't work with `URLSession` Async/Await APIs. -## Manual Logging +### Option 4 (Manual) -Another option for capturing network requests is by using ``NetworkLogger`` directly. For example, here can you can use it with Alamofire's `EventMonitor`: +If none of the convenience APIs described earlier work for you, you can use the underlying ``NetworkLogger`` directly. For example, here is how you can use it with Alamofire's `EventMonitor`: ```swift import Alamofire -// Don't forget to bootstrap the logging system first. - -let session = Alamofire.Session(eventMonitors: [NetworkLoggerEventMonitor(logger: logger)]) +let session = Alamofire.Session(eventMonitors: [NetworkLoggerEventMonitor()]) struct NetworkLoggerEventMonitor: EventMonitor { - let logger: NetworkLogger + var logger: NetworkLogger = .shared func request(_ request: Request, didCreateTask task: URLSessionTask) { logger.logTaskCreated(task) @@ -61,75 +84,76 @@ struct NetworkLoggerEventMonitor: EventMonitor { } ``` -> tip: Make sure to capture [`URLSessionTaskMetrics`](https://developer.apple.com/documentation/foundation/urlsessiontaskmetrics) as Pulse makes a great use of them and many of the features won't work without them. - -## Session Proxy (Experimental) - -To capture _all_ network traffic from _all_ session, including `URLSession.shared`, you can try using ``Experimental/URLSessionProxy``. +Alternatively, if you don't have access to `URLSessionTask`, you can store the request/response directly in ``LoggerStore``: ```swift -Experimental.URLSessionProxy.shared.isEnabled = true +LoggerStore.shared.storeRequest(urlRequest, response: urlResponse, ...) ``` -> warning: As clearly communicate by its namespace, it's an experimental feature and it might negatively affect your networking. The way it works is by registering a custom [URLProtocol](https://developer.apple.com/documentation/foundation/urlprotocol) and using a secondary URLSession instance in it, but it can be a useful tool. +## Configure Logging -> note: Alternatively, you can give the following swizzle-based [approach](https://gist.github.com/kean/3154a5bde8e0c5e9dc3322f21ba86757) a try that is less intrusive but requires more swizzling and can't be shipped with the production code. +### Record Decoding Errors -## Recoding Decoding Errors - -The network requests usually can only be considered successful when the app was able to decode the response data. With Pulse, you can do just that and when you open the response body, it'll even highlight the part of the response that's causing the decoding error. +The network requests can only be considered successful when the app decodes the response data. With Pulse, you can do just that, and when you open the response body, it'll even highlight the part of the response causing the decoding error. ```swift // Initial setup -let logger = NetworkLogger(configuration: .init(isWaitingForDecoding: true)) -let delegate = URLSessionProxyDelegate(logger: logger, delegate: YourURLSessionDelegate())) -// ... create session +var configuration = NetworkLogger.Configuration() +configuration.isWaitingForDecoding = true -// Somewhere else in the app where decoding is done. -logger.logTask(task, didFinishDecodingWithError: decodingError) -``` +let logger = NetworkLogger(configuration: configuration) -## Exclude Information From Logs +let session = NetworkLogger.URLSession(configuration: .default, logger: logger) -There is usually some sensitive information in network requests, such as passwords, access tokens, and more. It's important to keep it safe. +// Add this to the code that performs decoding of the responses. +logger.logTask(task, didFinishDecodingWithError: decodingError) +``` -> tip: It's recommended to use Pulse _only_ in the debug mode. +### Exclude Information From Logs -``NetworkLogger`` captures data safely in a local database and it never leaves your device. Logs are never written to the system's logging system. But of course, logs are meant to be viewed and shared, which is why PulseUI provides sharing options. In case the logs do leave your device, it's best to redact any sensitive information. +``NetworkLogger`` captures data safely in a local database, and it never leaves your device. Logs are never written to the system's logging system. But, of course, logs are meant to be viewed and shared, which is why PulseUI provides sharing options. In case the logs do leave your device, it's best to redact any sensitive information.  ``NetworkLogger/Configuration`` has a set of convenience APIs for managing what information is included or excluded from the logs. ```swift -let logger = NetworkLogger { - // Includes only requests with the given domain. - $0.includedHosts = ["*.example.com"] +var configuration = NetworkLogger.Configuration() - // Exclude some subdomains. - $0.excludedHosts = ["logging.example.com"] +// Includes only requests with the given domain. +configuration.includedHosts = ["*.example.com"] - // Exclude specific URLs. - $0.excludedURLs = ["*/log/event"] +// Exclude some subdomains. +configuration.excludedHosts = ["logging.example.com"] - // Replaces values for the given HTTP headers with "" - $0.sensitiveHeaders = ["Authorization", "Access-Token"] +// Exclude specific URLs. +configuration.excludedURLs = ["*/log/event"] - // Redacts sensitive query items. - $0.sensitiveQueryItems = ["password"] +// Replaces values for the given HTTP headers with "" +configuration.sensitiveHeaders = ["Authorization", "Access-Token"] - // Replaces values for the given response and request JSON fields with "" - $0.sensitiveDataFields = ["password"] -} +// Redacts sensitive query items. +configuration.sensitiveQueryItems = ["password"] + +// Replaces values for the given response and request JSON fields with "" +configuration.sensitiveDataFields = ["password"] + +let logger = NetworkLogger(configuration: configuration) ``` -> tip: "Include" and "exclude" patterns support basic wildcards (`*`), but you can also turns them into full-featured regex patterns using ``NetworkLogger/Configuration/isRegexEnabled``. +You can then replace the default decoder with your custom instance: -If the built-in configuration options don't cover all of your use-cases, you can set ``NetworkLogger/Configuration/willHandleEvent`` closure that provides you complete control for filtering out and updating the events. +```swift +NetworkLogger.shared = logger +``` + +> Tip: "Include" and "exclude" patterns support basic wildcards (`*`), but you can also turn them into full-featured regex patterns using ``NetworkLogger/Configuration/isRegexEnabled``.  -> important: If you redact information manually from requests or responses, make sure to also update ``NetworkLogger/Metrics`` because individual transactions within metrics contain recorded request and response pairs. +If the built-in configuration options don't cover all of your use cases, you can set the ``NetworkLogger/Configuration/willHandleEvent`` closure that provides you complete control for filtering out and updating the events. -## Trace in Xcode Console +> important: If you redact information manually from requests or responses, also update ``NetworkLogger/Metrics`` because individual transactions within metrics contain recorded request and response pairs. -While Pulse doesn't print anything in the Xcode Console by default, it's easy to enable such logging for network requests. ``LoggerStore`` re-translates all of the log events that it processes using ``LoggerStore/events`` publisher that you can leverage to log some of the recoded information in the Xcode Console. +### Trace in Xcode Console + +Pulse doesn't print anything in the Xcode Console by default, but it's easy to enable logging for network requests. ``LoggerStore`` re-translates all of the log events that it processes using ``LoggerStore/events`` publisher that you can leverage. ```swift func register(store: LoggerStore) { @@ -147,3 +171,18 @@ private func process(event: LoggerStore.Event) { } } ``` + +## Network Debugging + +In addition to logging, Pulse provides network debugging features, such as logging. If you use the recommended ``URLSessionProxy``, these features are enabled automatically, and you don't need to do anything. In other cases, make sure to inject ``MockingURLProtocol`` in the set of URL protocols used by your `URLSession`: + +```swift +let configuration = URLSesionConfiguration.default +configuration.protocolClasses = [MockingURLProtocol.self] + (configuration.protocolClasses ?? []) +``` + +Alternatively, you can use automatic registration. + +```swift +MockingURLProtocol.enableAutomaticRegistration() +``` diff --git a/Sources/Pulse/Pulse.docc/Articles/NextSteps.md b/Sources/Pulse/Pulse.docc/Articles/NextSteps.md new file mode 100644 index 000000000..99d5944b7 --- /dev/null +++ b/Sources/Pulse/Pulse.docc/Articles/NextSteps.md @@ -0,0 +1,59 @@ +# Next Steps + +Learn how to configure Pulse to best suit your app needs. + +## Logger + +### Configure Store + +``LoggerStore`` is the primary way to configure how logs are stored. It uses a database to record logs in an efficient binary format and employes a number of space [optimizations techniques](https://kean.blog/post/pulse-2#space-savings), including fast [lzfse](https://developer.apple.com/documentation/compression/algorithm/lzfse) compression. The store automatically limits how much spaces it takes and also removed old logs. + +```swift +LoggerStore.shared.configuration.sizeLimit = 512 * 1_000_000 +``` + +> important: Make sure to change it at the app launch before sending any logs. + +### Exporting Logs + +If you want to provide additional ways to share the logs recorded by the store, use ``LoggerStore/export(to:options:)``. + +```swift +try await store.export(to: <#targetURL#>) +``` + +Export can be configured with a predicate to limit what gets exported: + +```swift +var options = LoggerStore.ExportOptions( + predicate: <#predicate#>, + sessions: [<#sessionID#>] +) +``` + +> note: The exported store is in a Pulse document format (`.pulse` extension) + +### Accessing Logs + +``LoggerStore`` uses Core Data and provides full access to its underlying entities, which you can use to access any previously stored logs, export them, or create custom views for your logs. + +```swift +struct AnalyticsLogsView: View { + @FetchRequest( + sortDescriptors: [NSSortDescriptor(keyPath: \LoggerMessageEntity.createdAt, ascending: true)], + predicate: NSPredicate(format: "label == %@", "analytics") + ) var messages: FetchedResults + + var body: some View { + List(messages, id: \.objectID) { message in + <#view#> + } + } +} +``` + +> important: In the current schema, the alogger creates an associated ``LoggerMessageEntity`` entity for every ``NetworkTaskEntity``, but it will likely change in the future. + +## Network Logging & Debugging + +See for more information about how to configure network logging if your app does not use `URLSession` directly, how to further customize it, how to capture and display decoding errors, and more. diff --git a/Sources/Pulse/Pulse.docc/Extensions/LoggerStore-Extension.md b/Sources/Pulse/Pulse.docc/Extensions/LoggerStore-Extension.md index c9ee794df..dc27f55b8 100644 --- a/Sources/Pulse/Pulse.docc/Extensions/LoggerStore-Extension.md +++ b/Sources/Pulse/Pulse.docc/Extensions/LoggerStore-Extension.md @@ -2,74 +2,62 @@ ## Topics -### Getting a Store - -- ``shared`` - ### Initializers +- ``shared`` - ``init(storeURL:options:configuration:)`` - ``Options-swift.struct`` - ``Configuration-swift.struct`` -### Instance Properties - -- ``storeURL`` -- ``version`` -- ``options-swift.property`` -- ``session-swift.property`` -- ``isArchive`` -- ``configuration-swift.property`` - ### Storing Logs - ``storeMessage(createdAt:label:level:message:metadata:file:function:line:)`` -- ``storeRequest(_:response:error:data:metrics:label:)`` +- ``storeRequest(_:response:error:data:metrics:label:taskDescription:)`` ### Accessing Logs - ``allMessages()`` - ``allTasks()`` -- ``getBlobData(forKey:)`` -### Export +### Exporting Logs -- ``export(to:as:options:)`` +- ``export(to:options:)`` - ``ExportOptions`` -- ``DocumentType`` -- ``copy(to:predicate:)`` ### Managing the Store +- ``info()`` - ``removeAll()`` +- ``removeSessions(withIDs:)`` - ``close()`` - ``destroy()`` - -### Getting Store Info - -- ``info()`` -- ``Info`` - -### Managing Pins - -- ``pins-swift.property`` -- ``Pins-swift.class`` - -### Receiving and Filtering Events - -- ``events`` -- ``Event`` +- ``getBlobData(forKey:)`` ### Direct Database Access - ``container`` - ``viewContext`` - ``backgroundContext`` +- ``newBackgroundContext()`` + +### Core Data Entities + +- ``LoggerMessageEntity`` +- ``LoggerBlobHandleEntity`` +- ``LoggerSessionEntity`` +- ``NetworkTaskEntity`` +- ``NetworkTaskProgressEntity`` +- ``NetworkTransactionMetricsEntity`` +- ``NetworkRequestEntity`` +- ``NetworkResponseEntity`` -### Nested +### Nested Types +- ``Error`` +- ``Event`` +- ``Info`` - ``Level`` -- ``Metadata`` - ``MetadataValue`` -- ``Error`` +- ``Metadata`` - ``Session-swift.struct`` +- ``Version-swift.struct`` diff --git a/Sources/Pulse/Pulse.docc/Extensions/LoggerStoreInfo-Extension.md b/Sources/Pulse/Pulse.docc/Extensions/LoggerStoreInfo-Extension.md deleted file mode 100644 index cc64057e5..000000000 --- a/Sources/Pulse/Pulse.docc/Extensions/LoggerStoreInfo-Extension.md +++ /dev/null @@ -1,32 +0,0 @@ -# ``Pulse/LoggerStore/Info-swift.struct`` - -## Topics - -### Store Info - -- ``storeId`` -- ``storeVersion`` - -### Creation and Update Dates - -- ``creationDate`` -- ``modifiedDate`` - -### Usage Statistics - -- ``messageCount`` -- ``taskCount`` -- ``blobCount`` -- ``totalStoreSize`` -- ``blobsSize`` - -### App and Device Info - -- ``appInfo-swift.property`` -- ``AppInfo-swift.struct`` -- ``deviceInfo-swift.property`` -- ``DeviceInfo-swift.struct`` - -### Reading Archive Info - -- ``make(storeURL:)`` diff --git a/Sources/Pulse/Pulse.docc/Extensions/NetworkLogger-Extension.md b/Sources/Pulse/Pulse.docc/Extensions/NetworkLogger-Extension.md deleted file mode 100644 index 2d9e3bc54..000000000 --- a/Sources/Pulse/Pulse.docc/Extensions/NetworkLogger-Extension.md +++ /dev/null @@ -1,31 +0,0 @@ -# ``Pulse/NetworkLogger`` - -## Topics - -### Initializers - -- ``init(store:configuration:)`` -- ``Configuration`` - -### Logging Events - -- ``logTaskCreated(_:)`` -- ``logDataTask(_:didReceive:)`` -- ``logTask(_:didUpdateProgress:)`` -- ``logTask(_:didFinishCollecting:)-xcb1`` -- ``logTask(_:didFinishCollecting:)-vuf`` -- ``logTask(_:didCompleteWithError:)`` -- ``logTask(_:didFinishDecodingWithError:)`` - -### Network Entities - -- ``Request`` -- ``Response`` -- ``ResponseError`` -- ``Metrics`` -- ``TransactionMetrics`` -- ``TransferSizeInfo`` -- ``TransactionTimingInfo`` -- ``ContentType`` -- ``DecodingError`` -- ``TaskType`` diff --git a/Sources/Pulse/Pulse.docc/Extensions/NetworkLoggerConfiguration-extension.md b/Sources/Pulse/Pulse.docc/Extensions/NetworkLoggerConfiguration-extension.md deleted file mode 100644 index 77c0246bc..000000000 --- a/Sources/Pulse/Pulse.docc/Extensions/NetworkLoggerConfiguration-extension.md +++ /dev/null @@ -1,29 +0,0 @@ -# ``Pulse/NetworkLogger/Configuration`` - -## Topics - -### Initializers - -- ``init()`` - -### Exclude Requests From Logs - -- ``includedHosts`` -- ``excludedHosts`` -- ``includedURLs`` -- ``excludedURLs`` - -### Redact Sensitive Information - -- ``sensitiveHeaders`` -- ``sensitiveQueryItems`` -- ``sensitiveDataFields`` - -### Observe and Filter Events - -- ``willHandleEvent`` - -### Misc - -- ``label`` -- ``isWaitingForDecoding`` diff --git a/Sources/Pulse/Pulse.docc/Extensions/RemoteLogger-Extension.md b/Sources/Pulse/Pulse.docc/Extensions/RemoteLogger-Extension.md deleted file mode 100644 index 740ab7a25..000000000 --- a/Sources/Pulse/Pulse.docc/Extensions/RemoteLogger-Extension.md +++ /dev/null @@ -1,28 +0,0 @@ -# ``Pulse/RemoteLogger`` - - -## Topics - -### Accessing Shared Instance - -- ``shared`` -- ``initialize(store:)`` -- ``store`` - -### Managing Remote Logging - -- ``isEnabled`` -- ``enable()`` -- ``disable()`` - -### Managing Available Servers - -- ``servers`` -- ``selectedServerName`` -- ``isSelected(_:)`` - -### Connection - -- ``connectionState-swift.property`` -- ``ConnectionState-swift.enum`` -- ``connect(to:passcode:_:)`` diff --git a/Sources/Pulse/Pulse.docc/Pulse.md b/Sources/Pulse/Pulse.docc/Pulse.md index e97f0e296..776342dac 100644 --- a/Sources/Pulse/Pulse.docc/Pulse.md +++ b/Sources/Pulse/Pulse.docc/Pulse.md @@ -7,26 +7,22 @@ Logger and network inspector for Apple platforms. ### Essentials - +- - ``LoggerStore`` -### Network Logging +### Network Logging & Debugging - - ``NetworkLogger`` +- ``URLSessionProxy`` +- ``URLSessionProtocol`` - ``URLSessionProxyDelegate`` -- ``Experimental`` +- ``MockingURLProtocol`` ### Remote Logging - ``RemoteLogger`` -### Core Data Entities +### Deprecated -- ``LoggerMessageEntity`` -- ``LoggerBlobHandleEntity`` -- ``LoggerSessionEntity`` -- ``NetworkTaskEntity`` -- ``NetworkTaskProgressEntity`` -- ``NetworkTransactionMetricsEntity`` -- ``NetworkRequestEntity`` -- ``NetworkResponseEntity`` +- ``Experimental`` diff --git a/Sources/Pulse/Pulse.docc/Resources/pulse-console.png b/Sources/Pulse/Pulse.docc/Resources/pulse-console.png new file mode 100644 index 000000000..75f29dfc8 Binary files /dev/null and b/Sources/Pulse/Pulse.docc/Resources/pulse-console.png differ diff --git a/Sources/Pulse/Pulse.docc/Resources/pulse-pro.png b/Sources/Pulse/Pulse.docc/Resources/pulse-pro.png new file mode 100644 index 000000000..c4c78465d Binary files /dev/null and b/Sources/Pulse/Pulse.docc/Resources/pulse-pro.png differ diff --git a/Sources/Pulse/Pulse.docc/Resources/remote-logging.png b/Sources/Pulse/Pulse.docc/Resources/remote-logging.png index a63cca351..f8f49e66d 100644 Binary files a/Sources/Pulse/Pulse.docc/Resources/remote-logging.png and b/Sources/Pulse/Pulse.docc/Resources/remote-logging.png differ diff --git a/Sources/Pulse/RemoteLogger/RemoteLogger-Connection.swift b/Sources/Pulse/RemoteLogger/RemoteLogger-Connection.swift index 602857916..fa457cf0f 100644 --- a/Sources/Pulse/RemoteLogger/RemoteLogger-Connection.swift +++ b/Sources/Pulse/RemoteLogger/RemoteLogger-Connection.swift @@ -7,13 +7,14 @@ import Network import CryptoKit import OSLog +@MainActor protocol RemoteLoggerConnectionDelegate: AnyObject { func connection(_ connection: RemoteLogger.Connection, didChangeState newState: NWConnection.State) func connection(_ connection: RemoteLogger.Connection, didReceiveEvent event: RemoteLogger.Connection.Event) } extension RemoteLogger { - final class Connection { + final class Connection: @unchecked Sendable { var endpoint: NWEndpoint { connection.endpoint } private let connection: NWConnection private var buffer = Data() @@ -23,6 +24,8 @@ extension RemoteLogger { weak var delegate: RemoteLoggerConnectionDelegate? + private let queue = DispatchQueue(label: "com.github.kean.pulse.remote-logger-connection") + convenience init(endpoint: NWEndpoint, using parameters: NWParameters) { self.init(NWConnection(to: endpoint, using: parameters)) } @@ -35,9 +38,9 @@ extension RemoteLogger { self.log = isLogEnabled ? OSLog(subsystem: "com.github.kean.pulse", category: "RemoteLogger") : .disabled } - func start(on queue: DispatchQueue) { + func start() { connection.stateUpdateHandler = { [weak self] state in - guard let self = self else { return } + guard let self else { return } DispatchQueue.main.async { self.delegate?.connection(self, didChangeState: state) } @@ -73,7 +76,7 @@ extension RemoteLogger { } } - private func process(data freshData: Data) { + private nonisolated func process(data freshData: Data) { guard !freshData.isEmpty else { return } var freshData = freshData @@ -130,10 +133,9 @@ extension RemoteLogger { func send(code: UInt8, data: Data) { do { let data = try encode(code: code, body: data) - let log = self.log - connection.send(content: data, completion: .contentProcessed({ error in + connection.send(content: data, completion: .contentProcessed({ [weak self] error in if let error { - os_log("Failed to send data: %{public}@", log: log, type: .error, "\(error)") + self?.logSendDataError(error) } })) } catch { @@ -141,6 +143,10 @@ extension RemoteLogger { } } + private func logSendDataError(_ error: Error) { + os_log("Failed to send data: %{public}@", log: log, type: .error, "\(error)") + } + func send(code: UInt8, entity: T) { do { let data = try JSONEncoder().encode(entity) diff --git a/Sources/Pulse/RemoteLogger/RemoteLogger.swift b/Sources/Pulse/RemoteLogger/RemoteLogger.swift index 51487dfb5..cf85d5751 100644 --- a/Sources/Pulse/RemoteLogger/RemoteLogger.swift +++ b/Sources/Pulse/RemoteLogger/RemoteLogger.swift @@ -114,12 +114,12 @@ public final class RemoteLogger: ObservableObject, RemoteLoggerConnectionDelegat guard self.store !== store else { return } + self.store = store if isInitialized { cancel() } isInitialized = true - if isEnabled { startBrowser() } @@ -369,7 +369,7 @@ public final class RemoteLogger: ObservableObject, RemoteLoggerConnectionDelegat self.connectionState = .connecting self.connection = connection - connection.start(on: DispatchQueue.main) + connection.start() } // MARK: RemoteLoggerConnectionDelegate @@ -471,7 +471,9 @@ public final class RemoteLogger: ObservableObject, RemoteLoggerConnectionDelegat isLoggingPaused = true case .resume: isLoggingPaused = false - buffer?.forEach(send) + for event in (buffer ?? []) { + send(event: event) + } case .ping: scheduleAutomaticDisconnect() case .message: @@ -481,7 +483,7 @@ public final class RemoteLogger: ObservableObject, RemoteLoggerConnectionDelegat switch message.path { case .updateMocks: let mocks = try JSONDecoder().decode([URLSessionMock].self, from: message.data) - RemoteDebugger.shared.update(mocks) + NetworkDebugger.shared.update(mocks) case .getMockedResponse, .openMessageDetails, .openTaskDetails: break // Server specific (should never happen) } diff --git a/Sources/Pulse/URLSessionProxy/URLSessionProtocol.swift b/Sources/Pulse/URLSessionProxy/URLSessionProtocol.swift new file mode 100644 index 000000000..c6b90db89 --- /dev/null +++ b/Sources/Pulse/URLSessionProxy/URLSessionProtocol.swift @@ -0,0 +1,105 @@ +// The MIT License (MIT) +// +// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +public protocol URLSessionProtocol { + // MARK: - Core + + func dataTask(with request: URLRequest) -> URLSessionDataTask + + func dataTask(with url: URL) -> URLSessionDataTask + + func uploadTask(with request: URLRequest, fromFile fileURL: URL) -> URLSessionUploadTask + + func uploadTask(with request: URLRequest, from bodyData: Data) -> URLSessionUploadTask + + @available(iOS 17, tvOS 17, macOS 14, watchOS 9, *) + func uploadTask(withResumeData resumeData: Data) -> URLSessionUploadTask + + func uploadTask(withStreamedRequest request: URLRequest) -> URLSessionUploadTask + + func downloadTask(with request: URLRequest) -> URLSessionDownloadTask + + func downloadTask(with url: URL) -> URLSessionDownloadTask + + func downloadTask(withResumeData resumeData: Data) -> URLSessionDownloadTask + + func streamTask(withHostName hostname: String, port: Int) -> URLSessionStreamTask + + func webSocketTask(with url: URL) -> URLSessionWebSocketTask + + func webSocketTask(with url: URL, protocols: [String]) -> URLSessionWebSocketTask + + func webSocketTask(with request: URLRequest) -> URLSessionWebSocketTask + + // MARK: - Closures + + func dataTask(with request: URLRequest, completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) -> URLSessionDataTask + + func dataTask(with url: URL, completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) -> URLSessionDataTask + + func uploadTask(with request: URLRequest, fromFile fileURL: URL, completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) -> URLSessionUploadTask + + func uploadTask(with request: URLRequest, from bodyData: Data?, completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) -> URLSessionUploadTask + + @available(iOS 17, tvOS 17, macOS 14, watchOS 9, *) + func uploadTask(withResumeData resumeData: Data, completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) -> URLSessionUploadTask + + func downloadTask(with request: URLRequest, completionHandler: @escaping @Sendable (URL?, URLResponse?, (any Error)?) -> Void) -> URLSessionDownloadTask + + func downloadTask(with url: URL, completionHandler: @escaping @Sendable (URL?, URLResponse?, (any Error)?) -> Void) -> URLSessionDownloadTask + + func downloadTask(withResumeData resumeData: Data, completionHandler: @escaping @Sendable (URL?, URLResponse?, (any Error)?) -> Void) -> URLSessionDownloadTask + + // MARK: - Combine + + func dataTaskPublisher(for url: URL) -> URLSession.DataTaskPublisher + + func dataTaskPublisher(for request: URLRequest) -> URLSession.DataTaskPublisher + + // MARK: - Swift Concurrency + + func data(for request: URLRequest, delegate: (any URLSessionTaskDelegate)?) async throws -> (Data, URLResponse) + + func data(from url: URL, delegate: (any URLSessionTaskDelegate)?) async throws -> (Data, URLResponse) + + func upload(for request: URLRequest, fromFile fileURL: URL, delegate: (any URLSessionTaskDelegate)?) async throws -> (Data, URLResponse) + + func upload(for request: URLRequest, from bodyData: Data, delegate: (any URLSessionTaskDelegate)?) async throws -> (Data, URLResponse) + + func download(for request: URLRequest, delegate: (any URLSessionTaskDelegate)?) async throws -> (URL, URLResponse) + + func download(from url: URL, delegate: (any URLSessionTaskDelegate)?) async throws -> (URL, URLResponse) + + func download(resumeFrom resumeData: Data, delegate: (any URLSessionTaskDelegate)?) async throws -> (URL, URLResponse) + + func bytes(for request: URLRequest, delegate: (any URLSessionTaskDelegate)?) async throws -> (URLSession.AsyncBytes, URLResponse) + + func bytes(from url: URL, delegate: (any URLSessionTaskDelegate)?) async throws -> (URLSession.AsyncBytes, URLResponse) +} + +public extension URLSessionProtocol { + func data(for request: URLRequest) async throws -> (Data, URLResponse) { + try await data(for: request, delegate: nil) + } + + func data(from url: URL) async throws -> (Data, URLResponse) { + try await data(from: url, delegate: nil) + } + + func upload(for request: URLRequest, fromFile fileURL: URL) async throws -> (Data, URLResponse) { + try await upload(for: request, fromFile: fileURL, delegate: nil) + } + + func upload(for request: URLRequest, from bodyData: Data) async throws -> (Data, URLResponse) { + try await upload(for: request, from: bodyData, delegate: nil) + } + + func bytes(from url: URL) async throws -> (URLSession.AsyncBytes, URLResponse) { + try await bytes(from: url, delegate: nil) + } +} + +extension URLSession: URLSessionProtocol {} diff --git a/Sources/Pulse/URLSessionProxy/URLSessionProxy.swift b/Sources/Pulse/URLSessionProxy/URLSessionProxy.swift new file mode 100644 index 000000000..4eeb10935 --- /dev/null +++ b/Sources/Pulse/URLSessionProxy/URLSessionProxy.swift @@ -0,0 +1,242 @@ +// The MIT License (MIT) +// +// Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). + +import Foundation + +/// A thin wrapper on top of `URLSession` that simplifies logging of network +/// requests and enables other Pulse features. +public final class URLSessionProxy: URLSessionProtocol, @unchecked Sendable { + /// A configuration object that defines session behavior. + public struct Options: Sendable { + /// If enabled, registers ``MockingURLProtocol``. + public var isMockingEnabled = true + + /// Creates default options. + public init() {} + } + + /// The underlying `URLSession`. + public let session: Foundation.URLSession + var logger: NetworkLogger { _logger ?? .shared } + private let _logger: NetworkLogger? + private let options: Options + + /// - parameter logger: A custom logger to use instead of ``NetworkLogger/shared``. + public convenience init( + configuration: URLSessionConfiguration, + logger: NetworkLogger? = nil, + options: Options = .init() + ) { + self.init(configuration: configuration, delegate: nil, delegateQueue: nil, options: options) + } + + /// - parameter logger: A custom logger to use instead of ``NetworkLogger/shared``. + public init( + configuration: URLSessionConfiguration, + delegate: (any URLSessionDelegate)?, + delegateQueue: OperationQueue? = nil, + logger: NetworkLogger? = nil, + options: Options = .init() + ) { + if options.isMockingEnabled { + configuration.protocolClasses = [MockingURLProtocol.self] + (configuration.protocolClasses ?? []) + } + self.session = Foundation.URLSession( + configuration: configuration, + delegate: URLSessionProxyDelegate(logger: logger, delegate: delegate), + delegateQueue: delegateQueue + ) + self.options = options + self._logger = logger + } + + // MARK: - URLSessionProtocol (Core) + + // These APIs work out of the box thanks to `URLSessionProxyDelegate`. + + public func dataTask(with request: URLRequest) -> URLSessionDataTask { + session.dataTask(with: request) + } + + public func dataTask(with url: URL) -> URLSessionDataTask { + session.dataTask(with: url) + } + + public func uploadTask(with request: URLRequest, from bodyData: Data) -> URLSessionUploadTask { + session.uploadTask(with: request, from: bodyData) + } + + public func uploadTask(with request: URLRequest, fromFile fileURL: URL) -> URLSessionUploadTask { + session.uploadTask(with: request, fromFile: fileURL) + } + + @available(iOS 17, tvOS 17, macOS 14, watchOS 9, *) + public func uploadTask(withResumeData resumeData: Data) -> URLSessionUploadTask { + session.uploadTask(withResumeData: resumeData) + } + + public func uploadTask(withStreamedRequest request: URLRequest) -> URLSessionUploadTask { + session.uploadTask(withStreamedRequest: request) + } + + public func downloadTask(with request: URLRequest) -> URLSessionDownloadTask { + session.downloadTask(with: request) + } + + public func downloadTask(with url: URL) -> URLSessionDownloadTask { + session.downloadTask(with: url) + } + + public func downloadTask(withResumeData resumeData: Data) -> URLSessionDownloadTask { + session.downloadTask(withResumeData: resumeData) + } + + public func streamTask(withHostName hostname: String, port: Int) -> URLSessionStreamTask { + session.streamTask(withHostName: hostname, port: port) + } + + public func webSocketTask(with url: URL) -> URLSessionWebSocketTask { + session.webSocketTask(with: url) + } + + public func webSocketTask(with url: URL, protocols: [String]) -> URLSessionWebSocketTask { + session.webSocketTask(with: url, protocols: protocols) + } + + public func webSocketTask(with request: URLRequest) -> URLSessionWebSocketTask { + session.webSocketTask(with: request) + } + + // MARK: - URLSessionProtocol (Closures) + + public func dataTask(with request: URLRequest, completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) -> URLSessionDataTask { + let box = Mutex(nil) + let task = session.dataTask(with: request) { [logger] data, response, error in + if let task = box.value { + if let data { + logger.logDataTask(task, didReceive: data) + } + logger.logTask(task, didCompleteWithError: error) + } + completionHandler(data, response, error) + } + box.value = task + return task + } + + public func dataTask(with url: URL, completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) -> URLSessionDataTask { + dataTask(with: URLRequest(url: url), completionHandler: completionHandler) + } + + public func uploadTask(with request: URLRequest, fromFile fileURL: URL, completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) -> URLSessionUploadTask { + fatalError("Not implemented") + } + + public func uploadTask(with request: URLRequest, from bodyData: Data?, completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) -> URLSessionUploadTask { + fatalError("Not implemented") + } + + public func uploadTask(withResumeData resumeData: Data, completionHandler: @escaping @Sendable (Data?, URLResponse?, (any Error)?) -> Void) -> URLSessionUploadTask { + fatalError("Not implemented") + } + + public func downloadTask(with request: URLRequest, completionHandler: @escaping @Sendable (URL?, URLResponse?, (any Error)?) -> Void) -> URLSessionDownloadTask { + fatalError("Not implemented") + } + + public func downloadTask(with url: URL, completionHandler: @escaping @Sendable (URL?, URLResponse?, (any Error)?) -> Void) -> URLSessionDownloadTask { + fatalError("Not implemented") + } + + public func downloadTask(withResumeData resumeData: Data, completionHandler: @escaping @Sendable (URL?, URLResponse?, (any Error)?) -> Void) -> URLSessionDownloadTask { + fatalError("Not implemented") + } + + // MARK: - URLSessionProtocol (Combine) + + public func dataTaskPublisher(for url: URL) -> URLSession.DataTaskPublisher { + session.dataTaskPublisher(for: url) + } + + public func dataTaskPublisher(for request: URLRequest) -> URLSession.DataTaskPublisher { + session.dataTaskPublisher(for: request) + } + + // MARK: - URLSessionProtocol (Swift Concurrency) + + public func data(for request: URLRequest, delegate: (any URLSessionTaskDelegate)?) async throws -> (Data, URLResponse) { + let delegate = URLSessionProxyDelegate(logger: logger, delegate: delegate) + do { + let (data, response) = try await session.data(for: request, delegate: delegate) + if let task = delegate.createdTask.value as? URLSessionDataTask { + logger.logDataTask(task, didReceive: data) + logger.logTask(task, didCompleteWithError: nil) + } + return (data, response) + } catch { + if let task = delegate.createdTask.value { + logger.logTask(task, didCompleteWithError: error) + } + throw error + } + } + + public func data(from url: URL, delegate: (any URLSessionTaskDelegate)?) async throws -> (Data, URLResponse) { + try await data(for: URLRequest(url: url), delegate: delegate) + } + + public func upload(for request: URLRequest, fromFile fileURL: URL, delegate: (any URLSessionTaskDelegate)?) async throws -> (Data, URLResponse) { + let delegate = URLSessionProxyDelegate(logger: logger, delegate: delegate) + do { + let (data, response) = try await session.upload(for: request, fromFile: fileURL) + if let task = delegate.createdTask.value as? URLSessionUploadTask { + logger.logDataTask(task, didReceive: data) + logger.logTask(task, didCompleteWithError: nil) + } + return (data, response) + } catch { + if let task = delegate.createdTask.value { + logger.logTask(task, didCompleteWithError: error) + } + throw error + } + } + + public func upload(for request: URLRequest, from bodyData: Data, delegate: (any URLSessionTaskDelegate)?) async throws -> (Data, URLResponse) { + let delegate = URLSessionProxyDelegate(logger: logger, delegate: delegate) + do { + let (data, response) = try await session.upload(for: request, from: bodyData) + if let task = delegate.createdTask.value as? URLSessionUploadTask { + logger.logDataTask(task, didReceive: data) + logger.logTask(task, didCompleteWithError: nil) + } + return (data, response) + } catch { + if let task = delegate.createdTask.value { + logger.logTask(task, didCompleteWithError: error) + } + throw error + } + } + + public func download(for request: URLRequest, delegate: (any URLSessionTaskDelegate)?) async throws -> (URL, URLResponse) { + fatalError("Not implemented") + } + + public func download(from url: URL, delegate: (any URLSessionTaskDelegate)?) async throws -> (URL, URLResponse) { + fatalError("Not implemented") + } + + public func download(resumeFrom resumeData: Data, delegate: (any URLSessionTaskDelegate)?) async throws -> (URL, URLResponse) { + fatalError("Not implemented") + } + + public func bytes(for request: URLRequest, delegate: (any URLSessionTaskDelegate)?) async throws -> (URLSession.AsyncBytes, URLResponse) { + fatalError("Not implemented") + } + + public func bytes(from url: URL, delegate: (any URLSessionTaskDelegate)?) async throws -> (URLSession.AsyncBytes, URLResponse) { + fatalError("Not implemented") + } +} diff --git a/Sources/Pulse/NetworkLogger/URLSessionProxyDelegate+AutomaticRegistration.swift b/Sources/Pulse/URLSessionProxy/URLSessionProxyDelegate+AutomaticRegistration.swift similarity index 63% rename from Sources/Pulse/NetworkLogger/URLSessionProxyDelegate+AutomaticRegistration.swift rename to Sources/Pulse/URLSessionProxy/URLSessionProxyDelegate+AutomaticRegistration.swift index d6955bee0..4681623ec 100644 --- a/Sources/Pulse/NetworkLogger/URLSessionProxyDelegate+AutomaticRegistration.swift +++ b/Sources/Pulse/URLSessionProxy/URLSessionProxyDelegate+AutomaticRegistration.swift @@ -13,12 +13,22 @@ extension URLSessionProxyDelegate { /// `RemoteLoggerURLProtocol` to the list of session protocol classes. /// /// - warning: This logging method works only with delegate-based `URLSession` - /// instances. If it doesn't work for you, consider using ``URLSessionProxy`` - /// for automatic logging or manually logging the requests using ``NetworkLogger``. + /// instances. /// - /// - parameter logger: The network logger to be used for recording the requests. + /// - parameter logger: The network logger to be used for recording the requests. By default, uses shared logger. + /// + /// - warning: This method is soft-deprecated in Pulse 5.0. + public static func enableAutomaticRegistration(logger: NetworkLogger? = nil) { + guard Thread.isMainThread else { + return DispatchQueue.main.async { _enableAutomaticRegistration(logger: logger) } + } + MainActor.assumeIsolated { + _enableAutomaticRegistration(logger: logger) + } + } + @MainActor - public static func enableAutomaticRegistration(logger: NetworkLogger = .init()) { + static func _enableAutomaticRegistration(logger: NetworkLogger?) { guard !isAutomaticNetworkLoggingEnabled else { return } sharedNetworkLogger = logger @@ -33,10 +43,6 @@ extension URLSessionProxyDelegate { /// existing mechanisms provided by Pulse. @MainActor var isAutomaticNetworkLoggingEnabled: Bool { - guard URLSessionProxy.proxy == nil else { - NSLog("Error: Pulse.URLSessionProxy already enabled") - return true - } guard sharedNetworkLogger == nil else { NSLog("Error: Pulse network request logging is already enabled") return true @@ -62,12 +68,30 @@ private extension URLSession { guard isConfiguringSessionSafe(delegate: delegate) else { return self.pulse_init(configuration: configuration, delegate: delegate, delegateQueue: delegateQueue) } - configuration.protocolClasses = [RemoteLoggerURLProtocol.self] + (configuration.protocolClasses ?? []) - guard let sharedNetworkLogger else { - assertionFailure("Shared logger is missing") - return self.pulse_init(configuration: configuration, delegate: delegate, delegateQueue: delegateQueue) - } + configuration.protocolClasses = [MockingURLProtocol.self] + (configuration.protocolClasses ?? []) let delegate = URLSessionProxyDelegate(logger: sharedNetworkLogger, delegate: delegate) return self.pulse_init(configuration: configuration, delegate: delegate, delegateQueue: delegateQueue) } } + +// MARK: - Experimental (Deprecated) + +@available(*, deprecated, message: "Experimental.URLSessionProxy is replaced with NetworkLogger.enableProxy() from the PulseProxy target") +public enum Experimental {} + +@available(*, deprecated, message: "Experimental.URLSessionProxy is replaced with NetworkLogger.enableProxy() from the PulseProxy target") +public extension Experimental { + @MainActor + final class URLSessionProxy { + public static let shared = URLSessionProxy() + public var logger: NetworkLogger = .init() + public var configuration: URLSessionConfiguration = .default + public var ignoredHosts = Set() + + public var isEnabled: Bool = false { + didSet { + NSLog("Pulse.URLSessionProxu can't be disabled at runtime") + } + } + } +} diff --git a/Sources/Pulse/NetworkLogger/URLSessionProxyDelegate.swift b/Sources/Pulse/URLSessionProxy/URLSessionProxyDelegate.swift similarity index 74% rename from Sources/Pulse/NetworkLogger/URLSessionProxyDelegate.swift rename to Sources/Pulse/URLSessionProxy/URLSessionProxyDelegate.swift index 5195de421..c859ae71f 100644 --- a/Sources/Pulse/NetworkLogger/URLSessionProxyDelegate.swift +++ b/Sources/Pulse/URLSessionProxy/URLSessionProxyDelegate.swift @@ -15,14 +15,16 @@ public final class URLSessionProxyDelegate: NSObject, URLSessionTaskDelegate, UR private let actualDelegate: URLSessionDelegate? private let taskDelegate: URLSessionTaskDelegate? private let interceptedSelectors: Set - private let logger: NetworkLogger + private var logger: NetworkLogger { _logger ?? .shared } + private let _logger: NetworkLogger? - /// - parameter logger: By default, creates a logger with `LoggerStore.shared`. + /// - parameter logger: By default, uses a shared logger /// - parameter delegate: The "actual" session delegate, strongly retained. - public init(logger: NetworkLogger = .init(), delegate: URLSessionDelegate? = nil) { + public init(logger: NetworkLogger? = nil, delegate: URLSessionDelegate? = nil) { self.actualDelegate = delegate self.taskDelegate = delegate as? URLSessionTaskDelegate - self.logger = logger + self._logger = logger + var interceptedSelectors: Set = [ #selector(URLSessionDataDelegate.urlSession(_:dataTask:didReceive:)), #selector(URLSessionTaskDelegate.urlSession(_:task:didCompleteWithError:)), @@ -39,24 +41,27 @@ public final class URLSessionProxyDelegate: NSObject, URLSessionTaskDelegate, UR // MARK: URLSessionTaskDelegate - public func urlSession(_ session: URLSession, didCreateTask task: URLSessionTask) { + let createdTask = Mutex(nil) + + public func urlSession(_ session: Foundation.URLSession, didCreateTask task: URLSessionTask) { + createdTask.value = task logger.logTaskCreated(task) if #available(iOS 16.0, tvOS 16.0, macOS 13.0, watchOS 9.0, *) { taskDelegate?.urlSession?(session, didCreateTask: task) } } - public func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { + public func urlSession(_ session: Foundation.URLSession, task: URLSessionTask, didCompleteWithError error: Error?) { logger.logTask(task, didCompleteWithError: error) taskDelegate?.urlSession?(session, task: task, didCompleteWithError: error) } - public func urlSession(_ session: URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { + public func urlSession(_ session: Foundation.URLSession, task: URLSessionTask, didFinishCollecting metrics: URLSessionTaskMetrics) { logger.logTask(task, didFinishCollecting: metrics) taskDelegate?.urlSession?(session, task: task, didFinishCollecting: metrics) } - public func urlSession(_ session: URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) { + public func urlSession(_ session: Foundation.URLSession, task: URLSessionTask, didSendBodyData bytesSent: Int64, totalBytesSent: Int64, totalBytesExpectedToSend: Int64) { if task is URLSessionUploadTask { logger.logTask(task, didUpdateProgress: (completed: totalBytesSent, total: totalBytesExpectedToSend)) } @@ -65,18 +70,18 @@ public final class URLSessionProxyDelegate: NSObject, URLSessionTaskDelegate, UR // MARK: URLSessionDataDelegate - public func urlSession(_ session: URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { + public func urlSession(_ session: Foundation.URLSession, dataTask: URLSessionDataTask, didReceive data: Data) { logger.logDataTask(dataTask, didReceive: data) (actualDelegate as? URLSessionDataDelegate)?.urlSession?(session, dataTask: dataTask, didReceive: data) } // MARK: URLSessionDownloadDelegate - public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { + public func urlSession(_ session: Foundation.URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) { (actualDelegate as? URLSessionDownloadDelegate)?.urlSession(session, downloadTask: downloadTask, didFinishDownloadingTo: location) } - public func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { + public func urlSession(_ session: Foundation.URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) { logger.logTask(downloadTask, didUpdateProgress: (completed: totalBytesWritten, total: totalBytesExpectedToWrite)) (actualDelegate as? URLSessionDownloadDelegate)?.urlSession?(session, downloadTask: downloadTask, didWriteData: bytesWritten, totalBytesWritten: totalBytesWritten, totalBytesExpectedToWrite: totalBytesExpectedToWrite) } diff --git a/Sources/Pulse/NetworkLogger/URLSessionProxy.swift b/Sources/PulseProxy/URLSessionSwizzler.swift similarity index 63% rename from Sources/Pulse/NetworkLogger/URLSessionProxy.swift rename to Sources/PulseProxy/URLSessionSwizzler.swift index 324c07afb..31d57cd33 100644 --- a/Sources/Pulse/NetworkLogger/URLSessionProxy.swift +++ b/Sources/PulseProxy/URLSessionSwizzler.swift @@ -3,17 +3,9 @@ // Copyright (c) 2020-2024 Alexander Grebenyuk (github.com/kean). import Foundation +import Pulse -@MainActor -public final class URLSessionProxy { - static var proxy: URLSessionProxy? - - let logger: NetworkLogger - - init(logger: NetworkLogger) { - self.logger = logger - } - +extension NetworkLogger { /// Enables automatic logging and remote debugging of network requests. /// /// - warning: This method of logging relies heavily on swizzling and might @@ -21,15 +13,38 @@ public final class URLSessionProxy { /// for a more stable solution, consider using ``URLSessionProxyDelegate`` or /// manually logging the requests using ``NetworkLogger``. /// - /// - parameter logger: The network logger to be used for recording the requests. - public static func enable(with logger: NetworkLogger = .init()) { - guard !isAutomaticNetworkLoggingEnabled else { return } + /// - parameter logger: The network logger to be used for recording the requests. By default, uses shared logger. + public static func enableProxy(logger: NetworkLogger? = nil) { + URLSessionSwizzler.enable(logger: logger) + } +} - let proxy = URLSessionProxy(logger: logger) - proxy.enable() - URLSessionProxy.proxy = proxy +final class URLSessionSwizzler { + static var shared: URLSessionSwizzler? + + private var logger: NetworkLogger { _logger ?? .shared } + private let _logger: NetworkLogger? - RemoteLoggerURLProtocol.enableAutomaticRegistration() + init(logger: NetworkLogger?) { + self._logger = logger + } + + static let lock = NSLock() + static var isEnabled = false + + static func enable(logger: NetworkLogger?) { + lock.lock() + if isEnabled { + lock.unlock() + NSLog("Error: Pulse proxy is already enabled") + return + } + isEnabled = true + lock.unlock() + + let proxy = URLSessionSwizzler(logger: logger) + proxy.enable() + URLSessionSwizzler.shared = proxy } func enable() { @@ -39,7 +54,7 @@ public final class URLSessionProxy { swizzleDataTaskDidReceiveData(baseClass: sessionClass) swizzleDataDataDidCompleteWithError(baseClass: sessionClass) } else { - NSLog("Pulse.URLSessionProxy failed to initialize. Please report at https://github.com/kean/Pulse/issues.") + NSLog("Pulse.URLSessionSwizzler failed to initialize. Please report at https://github.com/kean/Pulse/issues.") } } @@ -59,6 +74,7 @@ public final class URLSessionProxy { var originalImplementation: IMP? let block: @convention(block) (URLSessionTask) -> Void = { [weak self] task in self?.logger.logTaskCreated(task) + guard task.currentRequest != nil else { return } let key = String(method.hashValue) objc_setAssociatedObject(task, key, true, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) @@ -76,7 +92,7 @@ public final class URLSessionProxy { // "_didFinishWithError:" let selector = NSSelectorFromString(["_", "didFinish", "With", "Error", ":"].joined()) guard let method = class_getInstanceMethod(baseClass, selector), - baseClass.instancesRespond(to: selector) else { + baseClass.instancesRespond(to: selector) else { return } typealias MethodSignature = @convention(c) (AnyObject, Selector, AnyObject?) -> Void @@ -90,8 +106,15 @@ public final class URLSessionProxy { if let metrics = task.value(forKey: ["_", "incomplete", "Task", "Metrics"].joined()) as? URLSessionTaskMetrics { self?.logger.logTask(task, didFinishCollecting: metrics) } - let error = error as? Error - self?.logger.logTask(task, didCompleteWithError: error) + if var error = error as? NSError { + if error.domain == "kCFErrorDomainCFNetwork" { + // Satisfy LogggerStore (needs refactoring) + error = NSError(domain: URLError.errorDomain, code: error.code, userInfo: error.userInfo) + } + self?.logger.logTask(task, didCompleteWithError: error) + } else { + self?.logger.logTask(task, didCompleteWithError: error as? Error) + } } } method_setImplementation(method, imp_implementationWithBlock(closure)) @@ -102,7 +125,7 @@ public final class URLSessionProxy { // "_didReceiveData" let selector = NSSelectorFromString(["_", "did", "Receive", "Data", ":"].joined()) guard let method = class_getInstanceMethod(baseClass, selector), - baseClass.instancesRespond(to: selector) else { + baseClass.instancesRespond(to: selector) else { return } @@ -120,51 +143,3 @@ public final class URLSessionProxy { method_setImplementation(method, imp_implementationWithBlock(closure)) } } - -// MARK: - RemoteLoggerURLProtocol (Automatic Regisration) - -extension RemoteLoggerURLProtocol { - @MainActor - static func enableAutomaticRegistration() { - if let lhs = class_getClassMethod(URLSession.self, #selector(URLSession.init(configuration:delegate:delegateQueue:))), - let rhs = class_getClassMethod(URLSession.self, #selector(URLSession.pulse_init2(configuration:delegate:delegateQueue:))) { - method_exchangeImplementations(lhs, rhs) - } - } -} - -private extension URLSession { - @objc class func pulse_init2(configuration: URLSessionConfiguration, delegate: URLSessionDelegate?, delegateQueue: OperationQueue?) -> URLSession { - guard isConfiguringSessionSafe(delegate: delegate) else { - return self.pulse_init2(configuration: configuration, delegate: delegate, delegateQueue: delegateQueue) - } - configuration.protocolClasses = [RemoteLoggerURLProtocol.self] + (configuration.protocolClasses ?? []) - return self.pulse_init2(configuration: configuration, delegate: delegate, delegateQueue: delegateQueue) - } -} - -// MARK: - Experimental (Deprecated) - -@available(*, deprecated, message: "Experimental.URLSessionProxy is replaced with a reworked URLSessionProxy") -public enum Experimental {} - -@available(*, deprecated, message: "Experimental.URLSessionProxy is replaced with a reworked URLSessionProxy") -public extension Experimental { - @MainActor - final class URLSessionProxy { - public static let shared = URLSessionProxy() - public var logger: NetworkLogger = .init() - public var configuration: URLSessionConfiguration = .default - public var ignoredHosts = Set() - - public var isEnabled: Bool = false { - didSet { - if isEnabled { - Pulse.URLSessionProxy.enable(with: logger) - } else { - NSLog("Pulse.URLSessionProxy can't be disabled at runtime") - } - } - } - } -} diff --git a/Sources/PulseUI/Features/Console/Views/ConsoleMessageCell.swift b/Sources/PulseUI/Features/Console/Views/ConsoleMessageCell.swift index f78dad7e0..a63c308ea 100644 --- a/Sources/PulseUI/Features/Console/Views/ConsoleMessageCell.swift +++ b/Sources/PulseUI/Features/Console/Views/ConsoleMessageCell.swift @@ -130,8 +130,8 @@ struct ConsoleConstants { static let fontInfo = Font.system(size: 14) static let fontBody = Font.system(size: 15) #elseif os(macOS) - static let fontTitle = Font.caption - static let fontInfo = Font.caption + static let fontTitle = Font.subheadline + static let fontInfo = Font.subheadline static let fontBody = Font.body #elseif os(iOS) || os(visionOS) static let fontTitle = Font.subheadline.monospacedDigit() diff --git a/Sources/PulseUI/Features/Inspector/Cells/NetworkRequestStatusCell.swift b/Sources/PulseUI/Features/Inspector/Cells/NetworkRequestStatusCell.swift index 0c86bf9f3..70b185ad4 100644 --- a/Sources/PulseUI/Features/Inspector/Cells/NetworkRequestStatusCell.swift +++ b/Sources/PulseUI/Features/Inspector/Cells/NetworkRequestStatusCell.swift @@ -36,6 +36,8 @@ struct NetworkRequestStatusCell: View { .padding(.bottom, 16) .listRowInsets(EdgeInsets(top: 0, leading: 0, bottom: 0, trailing: 0)) .listRowBackground(Color.clear) +#elseif os(macOS) + .font(.title3.weight(.medium)) #else .font(.headline) #endif @@ -48,7 +50,9 @@ struct NetworkRequestStatusCell: View { if viewModel.isMock { MockBadgeView() } else { +#if !os(macOS) viewModel.duration.map(DurationLabel.init) +#endif } } } diff --git a/Sources/PulseUI/Features/Inspector/NetworkInspectorResponseBodyView.swift b/Sources/PulseUI/Features/Inspector/NetworkInspectorResponseBodyView.swift index 2d395df7b..46b232b09 100644 --- a/Sources/PulseUI/Features/Inspector/NetworkInspectorResponseBodyView.swift +++ b/Sources/PulseUI/Features/Inspector/NetworkInspectorResponseBodyView.swift @@ -29,7 +29,7 @@ struct NetworkInspectorResponseBodyView: View { return title }()) } else if viewModel.task.responseBodySize > 0 { - PlaceholderView(imageName: "exclamationmark.circle", title: "Unavailable", subtitle: "The response body was deleted from the store to reduce its size") + PlaceholderView(imageName: "exclamationmark.circle", title: "Unavailable", subtitle: "The response body was deleted from the store to reduce its size. Increase `responseBodySizeLimit` of the store.") } else { PlaceholderView(imageName: "nosign", title: "Empty Response") } diff --git a/Sources/PulseUI/Features/Sessions/SessionsView.swift b/Sources/PulseUI/Features/Sessions/SessionsView.swift index e43807d04..f2a807804 100644 --- a/Sources/PulseUI/Features/Sessions/SessionsView.swift +++ b/Sources/PulseUI/Features/Sessions/SessionsView.swift @@ -25,7 +25,7 @@ struct SessionsView: View { @Environment(\.router) private var router var body: some View { - if store.version < Version(3, 6, 0) { + if store.version < LoggerStore.Version(3, 6, 0) { PlaceholderView(imageName: "questionmark.app", title: "Unsupported", subtitle: "This feature requires a store created by Pulse version 3.6.0 or higher").padding() } else { content diff --git a/Sources/PulseUI/Features/Settings/SettingsView-macos.swift b/Sources/PulseUI/Features/Settings/SettingsView-macos.swift index 62ed22421..e5eed0d24 100644 --- a/Sources/PulseUI/Features/Settings/SettingsView-macos.swift +++ b/Sources/PulseUI/Features/Settings/SettingsView-macos.swift @@ -26,9 +26,10 @@ struct SettingsView: View { } } Section { - if #available(macOS 13, *), let info = try? store.info() { - LoggerStoreSizeChart(info: info, sizeLimit: store.configuration.sizeLimit) - } + // TODO: load this info async +// if #available(macOS 13, *), let info = try? store.info() { +// LoggerStoreSizeChart(info: info, sizeLimit: store.configuration.sizeLimit) +// } } header: { PlainListSectionHeaderSeparator(title: "Store") } diff --git a/Sources/PulseUI/Features/Settings/StoreDetailsView.swift b/Sources/PulseUI/Features/Settings/StoreDetailsView.swift index a59b6f49f..92b2c189b 100644 --- a/Sources/PulseUI/Features/Settings/StoreDetailsView.swift +++ b/Sources/PulseUI/Features/Settings/StoreDetailsView.swift @@ -104,15 +104,17 @@ final class StoreDetailsViewModel: ObservableObject { func load(from source: StoreDetailsView.Source) { switch source { case .store(let store): - loadInfo(for: store) + Task { + await loadInfo(for: store) + } case .info(let value): display(value) } } - private func loadInfo(for store: LoggerStore) { + private func loadInfo(for store: LoggerStore) async { do { - let info = try store.info() + let info = try await store.info() if store === LoggerStore.shared { self.storeSizeLimit = store.configuration.sizeLimit } diff --git a/Sources/PulseUI/Helpers/ShareItems.swift b/Sources/PulseUI/Helpers/ShareItems.swift index 4ce4ea8a8..42bf84a7a 100644 --- a/Sources/PulseUI/Helpers/ShareItems.swift +++ b/Sources/PulseUI/Helpers/ShareItems.swift @@ -28,11 +28,11 @@ public enum ShareStoreOutput: String, RawRepresentable, Codable, CaseIterable { } } -struct ShareItems: Identifiable { - let id = UUID() - let items: [Any] - let size: Int64? - let cleanup: () -> Void +public struct ShareItems: Identifiable { + public let id = UUID() + public let items: [Any] + public let size: Int64? + public let cleanup: () -> Void init(_ items: [Any], size: Int64? = nil, cleanup: @escaping () -> Void = { }) { self.items = items @@ -121,7 +121,7 @@ enum ShareService { } } -enum ShareOutput { +public enum ShareOutput { case plainText case html case pdf diff --git a/Sources/PulseUI/Mocks/MockStore.swift b/Sources/PulseUI/Mocks/MockStore.swift index e7187cc2f..ba86826d8 100644 --- a/Sources/PulseUI/Mocks/MockStore.swift +++ b/Sources/PulseUI/Mocks/MockStore.swift @@ -80,9 +80,11 @@ private func _asyncPopulateStore(_ store: LoggerStore) async { Logger(label: named, store: store) } - let networkLogger = NetworkLogger(store: store) { - $0.isWaitingForDecoding = true - } + let networkLogger = NetworkLogger(store: store, configuration: { + var configuration = NetworkLogger.Configuration() + configuration.isWaitingForDecoding = true + return configuration + }()) let urlSession = URLSession(configuration: .default) @@ -149,9 +151,11 @@ private func _syncPopulateStore(_ store: LoggerStore) { Logger(label: named, store: store) } - let networkLogger = NetworkLogger(store: store) { - $0.isWaitingForDecoding = true - } + let networkLogger = NetworkLogger(store: store, configuration: { + var configuration = NetworkLogger.Configuration() + configuration.isWaitingForDecoding = true + return configuration + }()) let urlSession = URLSession(configuration: .default) diff --git a/Sources/PulseUI/Views/ImageViewer.swift b/Sources/PulseUI/Views/ImageViewer.swift index fb9b4310b..c0109c6bd 100644 --- a/Sources/PulseUI/Views/ImageViewer.swift +++ b/Sources/PulseUI/Views/ImageViewer.swift @@ -17,7 +17,7 @@ struct ImageViewer: View { .border(Color.separator, width: 0.5) HStack { - TextView(string: viewModel.info) + TextView(string: TextRenderer().render(viewModel.info)) Spacer() } @@ -28,7 +28,7 @@ struct ImageViewer: View { struct ImagePreviewViewModel { let image: UXImage - let info: NSAttributedString + let info: KeyValueSectionViewModel init(image: UXImage, data: Data, context: FileViewerViewModelContext) { func intValue(for key: String) -> Int? { @@ -66,7 +66,6 @@ struct ImagePreviewViewModel { } self.image = image - let section = KeyValueSectionViewModel(title: "Image", color: .pink, items: info) - self.info = TextRenderer().render(section) + self.info = KeyValueSectionViewModel(title: "Image", color: .pink, items: info) } } diff --git a/Sources/PulseUI/Views/LoggerStoreSizeChart.swift b/Sources/PulseUI/Views/LoggerStoreSizeChart.swift index 143774d95..ef4aedbf1 100644 --- a/Sources/PulseUI/Views/LoggerStoreSizeChart.swift +++ b/Sources/PulseUI/Views/LoggerStoreSizeChart.swift @@ -78,12 +78,12 @@ private struct Series: Identifiable { } #if DEBUG -@available(iOS 16.0, tvOS 16.0, macOS 13.0, watchOS 9.0, visionOS 1.0, *) -struct LoggerStoreSizeChart_Previews: PreviewProvider { - static var previews: some View { - LoggerStoreSizeChart(info: try! LoggerStore.mock.info(), sizeLimit: 512 * 1024) - .padding() - .previewLayout(.sizeThatFits) - } -} +//@available(iOS 16.0, tvOS 16.0, macOS 13.0, watchOS 9.0, visionOS 1.0, *) +//struct LoggerStoreSizeChart_Previews: PreviewProvider { +// static var previews: some View { +// LoggerStoreSizeChart(info: try! LoggerStore.mock.info(), sizeLimit: 512 * 1024) +// .padding() +// .previewLayout(.sizeThatFits) +// } +//} #endif diff --git a/Sources/PulseUI/Views/Metrics/NetworkInspectorTransferInfoView.swift b/Sources/PulseUI/Views/Metrics/NetworkInspectorTransferInfoView.swift index 3680f3680..2d752a58f 100644 --- a/Sources/PulseUI/Views/Metrics/NetworkInspectorTransferInfoView.swift +++ b/Sources/PulseUI/Views/Metrics/NetworkInspectorTransferInfoView.swift @@ -25,14 +25,6 @@ struct NetworkInspectorTransferInfoView: View { } .frame(maxWidth: .infinity) } -#elseif os(macOS) - var body: some View { - HStack { - bytesSent - Spacer() - bytesReceived - } - } #else var body: some View { HStack { @@ -75,9 +67,14 @@ struct NetworkInspectorTransferInfoView: View { Image(systemName: imageName) .font(.largeTitle) VStack(alignment: .leading, spacing: 0) { - Text(title).font(.headline) - Text(total).font(.headline) + Text(title) + Text(total) } +#if os(macOS) + .font(.title3.weight(.medium)) +#else + .font(.headline) +#endif } .fixedSize() .padding(2) @@ -85,16 +82,16 @@ struct NetworkInspectorTransferInfoView: View { VStack(alignment: .trailing) { Text("Headers:") .foregroundColor(.secondary) - .font(.footnote) + .font(valueFont) Text("Body:") .foregroundColor(.secondary) - .font(.footnote) + .font(valueFont) } VStack(alignment: .leading) { Text(headers) - .font(.footnote) + .font(valueFont) Text(body) - .font(.footnote) + .font(valueFont) } } .fixedSize() @@ -102,6 +99,12 @@ struct NetworkInspectorTransferInfoView: View { } } +#if os(macOS) +private let valueFont: Font = .callout +#else +private let valueFont: Font = .footnote +#endif + #if os(tvOS) private let spacing: CGFloat = 20 #else diff --git a/Sources/PulseUI/Views/TimingView.swift b/Sources/PulseUI/Views/TimingView.swift index 69d8e67b3..2fb8f5fbd 100644 --- a/Sources/PulseUI/Views/TimingView.swift +++ b/Sources/PulseUI/Views/TimingView.swift @@ -36,7 +36,11 @@ private struct TimingSectionView: View { VStack(spacing: 6) { HStack { Text(viewModel.title) +#if os(macOS) + .font(.headline) +#else .font(.subheadline) +#endif .lineLimit(1) .foregroundColor(viewModel.isHeader ? .secondary : .primary) Spacer() @@ -100,14 +104,22 @@ private struct TimingRowView: View { private func makeTitle(_ text: String) -> some View { Text(text) +#if os(macOS) + .font(.subheadline) +#else .font(.footnote) +#endif .lineLimit(1) .foregroundColor(.secondary) } private func makeValue(_ text: String) -> some View { Text(text) +#if os(macOS) + .font(.system(.subheadline, design: .monospaced)) +#else .font(.system(.caption, design: .monospaced)) +#endif .lineLimit(1) .foregroundColor(.secondary) }