From bb0316664555059d6fdc6f1ff88a772839ff9971 Mon Sep 17 00:00:00 2001 From: Jota Uribe Date: Tue, 6 Feb 2024 21:18:46 -0500 Subject: [PATCH 1/3] Implement preliminar logic to support exporting of HAR files --- Pulse.xcodeproj/project.pbxproj | 6 +- Sources/PulseUI/Helpers/HARDocument.swift | 282 ++++++++++++++++++ Sources/PulseUI/Helpers/ShareItems.swift | 10 +- Sources/PulseUI/Helpers/ShareStoreTask.swift | 40 ++- Sources/PulseUI/Helpers/TextUtilities.swift | 4 + Sources/PulseUI/Views/ShareStoreView.swift | 1 + .../PulseUI/Views/ShareStoreViewModel.swift | 2 + 7 files changed, 328 insertions(+), 17 deletions(-) create mode 100644 Sources/PulseUI/Helpers/HARDocument.swift diff --git a/Pulse.xcodeproj/project.pbxproj b/Pulse.xcodeproj/project.pbxproj index de11c9e11..2ef4220c6 100644 --- a/Pulse.xcodeproj/project.pbxproj +++ b/Pulse.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 53; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -271,6 +271,7 @@ 0CFF79DD29EB7A8E00BE767B /* SessionListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CFF79DB29EB7A8B00BE767B /* SessionListView.swift */; }; 0CFF79DF29EB7B4300BE767B /* SessionPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CFF79DE29EB7B4300BE767B /* SessionPickerView.swift */; }; 0CFF79E229EC1FA200BE767B /* ImageProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CFF79E029EC1F7800BE767B /* ImageProcessor.swift */; }; + E95D6C562B7314E6004D28E4 /* HARDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = E95D6C552B7314E6004D28E4 /* HARDocument.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -727,6 +728,7 @@ 0CFF79E029EC1F7800BE767B /* ImageProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageProcessor.swift; sourceTree = ""; }; 49E82A8526D107A00070244F /* AlamofireIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlamofireIntegration.swift; sourceTree = ""; }; 49E82A8826D1083D0070244F /* MoyaIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MoyaIntegration.swift; sourceTree = ""; }; + E95D6C552B7314E6004D28E4 /* HARDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HARDocument.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -1186,6 +1188,7 @@ 0CB17F192978ABBA004E33F4 /* ManagedObjectsCountObserver.swift */, 0C3002B22986CEF30055F6C2 /* LoggerStoreIndex.swift */, 0C1050E429E1FAC1006AFDAD /* Version.swift */, + E95D6C552B7314E6004D28E4 /* HARDocument.swift */, ); path = Helpers; sourceTree = ""; @@ -2012,6 +2015,7 @@ 0C7A0E00297C51CE00B4B69D /* ConsoleListOptions.swift in Sources */, 0CF0D668296F189600EED9D4 /* NetworkRequestStatusCell.swift in Sources */, 0C9751562A32CBFB00DC46FF /* RemoteLoggerSelectedDeviceView.swift in Sources */, + E95D6C562B7314E6004D28E4 /* HARDocument.swift in Sources */, 0CB63A2F2975C43D00525165 /* ConsoleSearchScope.swift in Sources */, 0CF0D640296F189600EED9D4 /* ConsoleFilterViewModel.swift in Sources */, 0CF0D65A296F189600EED9D4 /* StoreDetailsView.swift in Sources */, diff --git a/Sources/PulseUI/Helpers/HARDocument.swift b/Sources/PulseUI/Helpers/HARDocument.swift new file mode 100644 index 000000000..b616aaa71 --- /dev/null +++ b/Sources/PulseUI/Helpers/HARDocument.swift @@ -0,0 +1,282 @@ +// +// HARDocument.swift +// PulseUI +// +// Created by Jota Uribe on 6/02/24. +// Copyright © 2024 kean. All rights reserved. +// + +import Foundation +import Pulse + +fileprivate enum HARFileDateFormatter { + static var formatter: ISO8601DateFormatter = { + let formatter = ISO8601DateFormatter() + formatter.formatOptions = [.withFullDate, .withFullTime] + return formatter + }() +} + +struct HARDocument: Encodable { + private let log: Log + init(store: LoggerStore) throws { + var entries: [Entry] = [] + var pages: [Page] = [] + try Dictionary(grouping: store.allTasks(), by: \.url).values.forEach { networkTasks in + let pageId = "page_\(pages.count)" + pages.append( + .init( + id: pageId, + startedDateTime: HARFileDateFormatter.formatter.string(from: networkTasks[.zero].createdAt), + title: networkTasks[.zero].url ?? "" + ) + ) + entries.append(contentsOf: networkTasks.map { .init(entity: $0, pageId: pageId) }) + } + try store.allMessages().forEach { message in + if let task = message.task { + entries.append(.init(entity: task, pageId: "page_\(pages.count)")) + } + } + log = .init( + version: "1.2", + creator: ["name": "Pulse HAR generation tool", "version": "0.1"], + pages: pages, + entries: entries + ) + } +} + +extension HARDocument { + struct Log: Encodable { + let version: String + let creator: [String: String] + var pages: [Page] + var entries: [Entry] + } + + struct Page: Encodable { + let id: String + let pageTimings: PageTimings + let startedDateTime: String? + let title: String + + init( + id: String, + pageTimings: PageTimings = .init(), + startedDateTime: String, + title: String + ) { + self.id = id + self.pageTimings = pageTimings + self.startedDateTime = startedDateTime + self.title = title + } + } + + struct Entry: Encodable { + let cache: Cache + let connection: String + let pageref: String + let request: Request + let response: Response? + let serverIPAddress: String + let startedDateTime: String + let time: Double + let timings: Timings? + + init(entity: NetworkTaskEntity, pageId: String) { + cache = .init() + connection = "\(entity.orderedTransactions.first?.remotePort ?? .zero)" + pageref = pageId + request = .init( + cookies: [], + headers: entity.originalRequest?.headers.compactMap { ["name": $0.key, "value": $0.value] } ?? [], + httpVersion: "HTTP/2", + method: entity.httpMethod, + queryString: [], + url: entity.url + ) + + response = .init(entity) + + serverIPAddress = entity.orderedTransactions.first?.remoteAddress ?? "" + startedDateTime = HARFileDateFormatter.formatter.string(from: entity.createdAt) + time = entity.duration * 1000 + timings = .init(entity.orderedTransactions.last?.timing) + } + } + + struct Timings: Encodable { + let blocked: Double + let connect: Int + let dns: Int + let receive: Double + let send: Double + let ssl: Int + let wait: Double + + init?(_ timing: NetworkLogger.TransactionTimingInfo?) { + if let timing { + blocked = -1 + connect = Self.millisecondsBetween( + startDate: timing.fetchStartDate, + endDate: timing.connectEndDate + ) + + dns = Self.millisecondsBetween( + startDate: timing.domainLookupStartDate, + endDate: timing.domainLookupEndDate + ) + + receive = Self.intervalBetween( + startDate: timing.responseStartDate, + endDate: timing.responseEndDate + ) + + send = Self.intervalBetween( + startDate: timing.requestStartDate, + endDate: timing.requestEndDate + ) + + ssl = Self.millisecondsBetween( + startDate: timing.secureConnectionStartDate, + endDate: timing.secureConnectionEndDate + ) + + wait = timing.duration ?? .zero + } else { + return nil + } + } + } + + struct PageTimings: Encodable { + let onContentLoad: Int + let onLoad: Int + + init( + onContentLoad: Int = -1, + onLoad: Int = -1 + ) { + self.onContentLoad = onContentLoad + self.onLoad = onLoad + } + } +} + +extension HARDocument.Entry { + struct Request: Encodable { + var bodySize: Int = -1 + let cookies: [[String: String]] + let headers: [[String: String]] + let httpVersion: String + let method: String? + let queryString: [[String: String]] + let url: String? + } + + struct Response: Encodable { + let bodySize: Int + let content: Content? + let cookies: [[String: String]] + let headers: [[String: String]] + let headersSize: Int + let httpVersion: String + let redirectURL: String + let status: Int + var statusText: String + + init?(_ entity: NetworkTaskEntity?) { + if let entity { + bodySize = Int(entity.responseBody?.size ?? -1) + content = .init(entity.responseBody) + cookies = [] + headers = entity.response?.headers.compactMap { ["name": $0.key, "value": $0.value] } ?? [] + headersSize = -1 + httpVersion = "HTTP/2" + redirectURL = "" + status = Int(entity.statusCode) + statusText = "" + } else { + return nil + } + } + } + + struct Content: Encodable { + let compression: Int + let encoding: String? + let mimeType: String + let size: Int + var text: String = "" + + init?(_ entity: LoggerBlobHandleEntity?) { + if let entity { + compression = Int(entity.size - entity.decompressedSize) + encoding = "" + mimeType = entity.contentType?.rawValue ?? "" + size = entity.data?.count ?? .zero + if let data = entity.data { + text = String(decoding: data, as: UTF8.self) + } + } else { + return nil + } + } + } + + struct Cache: Encodable { + let afterRequest: Item? + let beforeRequest: Item? + + init( + afterRequest: Item? = nil, + beforeRequest: Item? = nil + ) { + self.afterRequest = afterRequest + self.beforeRequest = beforeRequest + } + } +} + +extension HARDocument.Entry.Cache { + struct Item: Encodable { + let eTag: String + let expires: String + let hitCount: Int + let lastAccess: String + + init( + eTag: String = "", + expires: String = "", + hitCount: Int = .zero, + lastAccess: String = "" + ) { + self.eTag = eTag + self.expires = expires + self.hitCount = hitCount + self.lastAccess = lastAccess + } + } +} + +// MARK: - Helper Methods + +extension HARDocument.Timings { + fileprivate static func millisecondsBetween(startDate: Date?, endDate: Date?) -> Int { + let timeInterval = intervalBetween(startDate: startDate, endDate: endDate) + guard timeInterval != .zero else { + // return -1 if value can not be determined as indicated on HAR document specs. + return -1 + } + return Int(timeInterval * 1000) + } + + fileprivate static func intervalBetween(startDate: Date?, endDate: Date?) -> TimeInterval { + guard let startDate, let endDate else { + return .zero + } + return endDate.timeIntervalSince(startDate) + } +} diff --git a/Sources/PulseUI/Helpers/ShareItems.swift b/Sources/PulseUI/Helpers/ShareItems.swift index 788e0dbfc..9b6fb403f 100644 --- a/Sources/PulseUI/Helpers/ShareItems.swift +++ b/Sources/PulseUI/Helpers/ShareItems.swift @@ -7,13 +7,14 @@ import Pulse import CoreData public enum ShareStoreOutput: String, RawRepresentable { - case store, package, text, html + case store, package, text, html, har var fileExtension: String { switch self { case .store, .package: return "pulse" case .text: return "txt" case .html: return "html" + case .har: return "har" } } } @@ -76,6 +77,11 @@ enum ShareService { #else return ShareItems(["Sharing as PDF is not supported on this platform"]) #endif + case .har: + let har = TextUtilities.har(from: string) + let directory = TemporaryDirectory() + let fileURL = directory.write(data: har, extension: "har") + return ShareItems([fileURL], size: Int64(har.count), cleanup: directory.remove) } } @@ -110,12 +116,14 @@ enum ShareOutput { case plainText case html case pdf + case har var title: String { switch self { case .plainText: return "Text" case .html: return "HTML" case .pdf: return "PDF" + case .har: return "HAR" } } } diff --git a/Sources/PulseUI/Helpers/ShareStoreTask.swift b/Sources/PulseUI/Helpers/ShareStoreTask.swift index b095abe80..836fd8f62 100644 --- a/Sources/PulseUI/Helpers/ShareStoreTask.swift +++ b/Sources/PulseUI/Helpers/ShareStoreTask.swift @@ -64,24 +64,34 @@ final class ShareStoreTask { } private func renderAsAttributedString() -> NSAttributedString { - prerenderResponseBodies() - - let content = contentForSharing(count: objectIDs.count) - for index in objectIDs.indices { - guard let entity = try? context.existingObject(with: objectIDs[index]) else { - continue - } - switch LoggerEntity(entity) { - case .message(let message): - renderer.render(message) - case .task(let task): - renderer.render(task, content: content, store: store) + switch output { + case .har: + guard let file = try? HARDocument(store: store), + let encoded = try? JSONEncoder().encode(file), + let string = String(data: encoded, encoding: .utf8) else { + return NSAttributedString(string: "") } - if index < objectIDs.endIndex - 1 { - renderer.addSpacer() + return NSAttributedString(string: string) + default: + prerenderResponseBodies() + + let content = contentForSharing(count: objectIDs.count) + for index in objectIDs.indices { + guard let entity = try? context.existingObject(with: objectIDs[index]) else { + continue + } + switch LoggerEntity(entity) { + case .message(let message): + renderer.render(message) + case .task(let task): + renderer.render(task, content: content, store: store) + } + if index < objectIDs.endIndex - 1 { + renderer.addSpacer() + } } + return renderer.make() } - return renderer.make() } // Unlike the rest of the processing it's easy to parallelize and it diff --git a/Sources/PulseUI/Helpers/TextUtilities.swift b/Sources/PulseUI/Helpers/TextUtilities.swift index 1bdfb365c..b31fc9313 100644 --- a/Sources/PulseUI/Helpers/TextUtilities.swift +++ b/Sources/PulseUI/Helpers/TextUtilities.swift @@ -118,6 +118,10 @@ enum TextUtilities { return data as Data } #endif + + static func har(from string: NSAttributedString) -> Data { + string.string.data(using: .utf8) ?? Data() + } } private var isDarkMode: Bool { diff --git a/Sources/PulseUI/Views/ShareStoreView.swift b/Sources/PulseUI/Views/ShareStoreView.swift index ee2d93923..ba9ea80e4 100644 --- a/Sources/PulseUI/Views/ShareStoreView.swift +++ b/Sources/PulseUI/Views/ShareStoreView.swift @@ -98,6 +98,7 @@ struct ShareStoreView: View { Text("Pulse").tag(ShareStoreOutput.store) Text("Plain Text").tag(ShareStoreOutput.text) Text("HTML").tag(ShareStoreOutput.html) + Text("HAR").tag(ShareStoreOutput.har) Divider() Text("Pulse (Package)").tag(ShareStoreOutput.package) } diff --git a/Sources/PulseUI/Views/ShareStoreViewModel.swift b/Sources/PulseUI/Views/ShareStoreViewModel.swift index e31aa2038..013f270ac 100644 --- a/Sources/PulseUI/Views/ShareStoreViewModel.swift +++ b/Sources/PulseUI/Views/ShareStoreViewModel.swift @@ -91,6 +91,8 @@ import Combine case .text, .html: let output: ShareOutput = output == .text ? .plainText : .html return try await prepareForSharing(store: store, output: output, options: options) + case .har: + return try await prepareForSharing(store: store, output: .har, options: options) } } From 4cd347669c1c9ad7f6b894777d1392fd9a0b4e64 Mon Sep 17 00:00:00 2001 From: Jota Uribe Date: Tue, 6 Feb 2024 21:44:44 -0500 Subject: [PATCH 2/3] Rename date formatter --- Sources/PulseUI/Helpers/HARDocument.swift | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Sources/PulseUI/Helpers/HARDocument.swift b/Sources/PulseUI/Helpers/HARDocument.swift index b616aaa71..e0debfaa9 100644 --- a/Sources/PulseUI/Helpers/HARDocument.swift +++ b/Sources/PulseUI/Helpers/HARDocument.swift @@ -9,7 +9,7 @@ import Foundation import Pulse -fileprivate enum HARFileDateFormatter { +fileprivate enum HARDateFormatter { static var formatter: ISO8601DateFormatter = { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withFullDate, .withFullTime] @@ -27,7 +27,7 @@ struct HARDocument: Encodable { pages.append( .init( id: pageId, - startedDateTime: HARFileDateFormatter.formatter.string(from: networkTasks[.zero].createdAt), + startedDateTime: HARDateFormatter.formatter.string(from: networkTasks[.zero].createdAt), title: networkTasks[.zero].url ?? "" ) ) @@ -101,7 +101,7 @@ extension HARDocument { response = .init(entity) serverIPAddress = entity.orderedTransactions.first?.remoteAddress ?? "" - startedDateTime = HARFileDateFormatter.formatter.string(from: entity.createdAt) + startedDateTime = HARDateFormatter.formatter.string(from: entity.createdAt) time = entity.duration * 1000 timings = .init(entity.orderedTransactions.last?.timing) } From c96d0ca685d70dde339b2a4357e751d70e2121a7 Mon Sep 17 00:00:00 2001 From: Jota Uribe Date: Wed, 7 Feb 2024 07:56:22 -0500 Subject: [PATCH 3/3] Simplify renderAsAttributedString logic for HAR --- Sources/PulseUI/Helpers/ShareStoreTask.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Sources/PulseUI/Helpers/ShareStoreTask.swift b/Sources/PulseUI/Helpers/ShareStoreTask.swift index 836fd8f62..ee4154e9c 100644 --- a/Sources/PulseUI/Helpers/ShareStoreTask.swift +++ b/Sources/PulseUI/Helpers/ShareStoreTask.swift @@ -68,10 +68,10 @@ final class ShareStoreTask { case .har: guard let file = try? HARDocument(store: store), let encoded = try? JSONEncoder().encode(file), - let string = String(data: encoded, encoding: .utf8) else { + let string = try? NSAttributedString(data: encoded, documentAttributes: nil) else { return NSAttributedString(string: "") } - return NSAttributedString(string: string) + return string default: prerenderResponseBodies()