Files

539 lines
23 KiB
Swift
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// The MIT License (MIT)
//
// Copyright (c) 2020-2026 Alexander Grebenyuk (github.com/kean).
import Foundation
import Pulse
import CoreData
import SwiftUI
#if os(iOS) || os(visionOS)
import PDFKit
#endif
/// Low-level attributed string creation API.
package final class TextRenderer {
package struct Options {
package var color: ColorMode
package init(color: ColorMode = .full) {
self.color = color
}
package static let sharing = Options(color: .automatic)
}
package enum ColorMode: String, RawRepresentable {
case monochrome
case automatic
case full
}
private let options: Options
let helper: TextHelper
/// LoggerBlobHandleEntity.objectID: string
var renderedBodies: [NSManagedObjectID: NSAttributedString] = [:]
private var string = NSMutableAttributedString()
package init(options: Options = .init()) {
self.options = options
self.helper = TextHelper()
}
package func make(_ render: (TextRenderer) -> Void) -> NSAttributedString {
render(self)
return make()
}
package func make() -> NSMutableAttributedString {
defer { string = NSMutableAttributedString() }
return string
}
package func addSpacer() {
string.append(spacer())
}
package func spacer() -> NSAttributedString {
NSAttributedString(string: "\n", attributes: helper.spacerAttributes)
}
package func render(_ message: LoggerMessageEntity) {
string.append(ConsoleFormatter.subheadline(for: message) + "\n", helper.attributes(role: .subheadline, style: .monospacedDigital, width: .condensed, color: .secondaryLabel))
string.append(message.text + "\n", helper.attributes(role: .body2, color: textColor(for: message.logLevel)))
}
package func renderCompact(_ message: LoggerMessageEntity) {
var details = ConsoleFormatter.time(for: message.createdAt)
if let label = ConsoleFormatter.label(for: message) {
details += "\(ConsoleFormatter.separator)\(label)\(ConsoleFormatter.separator)"
}
let detailsColor: UXColor
switch message.logLevel {
case .critical, .error, .warning:
detailsColor = textColor(for: message.logLevel)
default:
detailsColor = .secondaryLabel
}
string.append(details, helper.attributes(role: .body2, style: .monospacedDigital, width: .condensed, color: detailsColor))
string.append(message.text + "\n", helper.attributes(role: .body2, color: textColor(for: message.logLevel)))
}
private func textColor(for level: LoggerStore.Level) -> UXColor {
if options.color == .monochrome {
return level == .trace ? .secondaryLabel : .label
} else {
return .textColor(for: level)
}
}
package func render(_ task: NetworkTaskEntity, content: NetworkContent, store: LoggerStoreProtocol) {
if content.contains(.largeHeader) {
renderLargeHeader(for: task, store: store)
} else if content.contains(.header) {
renderHeader(for: task, store: store)
}
if content.contains(.taskDetails) {
append(section: .makeTaskDetails(for: task))
}
func append(section: KeyValueSectionViewModel?, count: Bool) {
let isCountDisplayed = count && section?.items.isEmpty == false
let details = isCountDisplayed ? section?.items.count.description : nil
append(section: section, details: details)
}
func append(section: KeyValueSectionViewModel?, details: String? = nil) {
guard let section = section else { return }
string.append(render(section, details: details))
addSpacer()
}
if content.contains(.errorDetails) {
append(section: .makeErrorDetails(for: task))
}
if content.contains(.requestComponents), let url = task.url.flatMap(URL.init) {
append(section: .makeComponents(for: url))
}
if content.contains(.requestQueryItems), let url = task.url.flatMap(URL.init) {
append(section: .makeQueryItems(for: url))
}
if content.contains(.requestOptions), let request = task.originalRequest {
append(section: .makeParameters(for: request))
}
if content.contains(.originalRequestHeaders) && content.contains(.currentRequestHeaders),
let originalRequest = task.originalRequest, let currentRequest = task.currentRequest {
append(section: .makeHeaders(title: "Original Request Headers", headers: originalRequest.headers), count: true)
append(section: .makeHeaders(title: "Current Request Headers", headers: currentRequest.headers), count: true)
} else if content.contains(.originalRequestHeaders), let originalRequest = task.originalRequest {
append(section: .makeHeaders(title: "Request Headers", headers: originalRequest.headers), count: true)
} else if content.contains(.currentRequestHeaders), let currentRequest = task.currentRequest {
append(section: .makeHeaders(title: "Request Headers", headers: currentRequest.headers), count: true)
}
if content.contains(.requestBody) {
let details = ByteCountFormatter.string(fromBodySize: task.requestBodySize).map { " (\($0))" } ?? ""
string.append(render(subheadline: "Request Body" + details))
string.append(renderRequestBody(for: task))
string.append("\n")
addSpacer()
}
if content.contains(.responseHeaders), let response = task.response {
append(section: .makeHeaders(title: "Response Headers", headers: response.headers), count: true)
}
if content.contains(.responseBody) {
let details = ByteCountFormatter.string(fromBodySize: task.responseBodySize).map { " (\($0))" } ?? ""
string.append(render(subheadline: "Response Body" + details))
string.append(renderResponseBody(for: task))
string.append("\n")
addSpacer()
}
string.deleteCharacters(in: NSRange(location: string.length - 1, length: 1))
}
private func renderLargeHeader(for task: NetworkTaskEntity, store: LoggerStoreProtocol) {
let status = NetworkRequestStatusCellModel(task: task, store: store)
let suffix = status.isMock ? " (mock)" : ""
string.append(render(status.status.title + suffix + "\n", role: .title, weight: .semibold, color: UXColor(status.status.tint)))
string.append(self.spacer())
var urlAttributes = helper.attributes(role: .body2, weight: .regular)
urlAttributes[.underlineColor] = UXColor.clear
string.append((task.httpMethod ?? "GET") + "\n", helper.attributes(role: .body, weight: .semibold))
string.append((task.url ?? "–") + "\n", urlAttributes)
addSpacer()
}
private func renderHeader(for task: NetworkTaskEntity, store: LoggerStoreProtocol) {
let isTitleColored = task.state == .failure && options.color != .monochrome
let titleColor = isTitleColored ? UXColor.systemRed : UXColor.secondaryLabel
let detailsColor = isTitleColored ? UXColor.systemRed : UXColor.label
let title = ConsoleFormatter.subheadline(for: task, store: store)
string.append(title + "\n", helper.attributes(role: .subheadline, style: .monospacedDigital, width: .condensed, color: titleColor))
var urlAttributes = helper.attributes(role: .body2, weight: .medium, color: detailsColor)
urlAttributes[.underlineColor] = UXColor.clear
string.append((task.url ?? "–") + "\n", urlAttributes)
addSpacer()
}
package func renderCompact(_ task: NetworkTaskEntity, store: LoggerStoreProtocol) {
let isTitleColored = task.state == .failure && options.color != .monochrome
let titleColor = isTitleColored ? UXColor.systemRed : UXColor.secondaryLabel
let detailsColor = isTitleColored ? UXColor.systemRed : UXColor.label
let time = ConsoleFormatter.time(for: task.createdAt)
let status = StatusLabelViewModel(task: task, store: store).title
string.append("\(time)\(ConsoleFormatter.separator)\(status)\(ConsoleFormatter.separator)", helper.attributes(role: .body2, style: .monospacedDigital, width: .condensed, color: titleColor))
var urlAttributes = helper.attributes(role: .body2, weight: .medium, color: detailsColor)
urlAttributes[.link] = task.objectID.uriRepresentation()
urlAttributes[.underlineColor] = titleColor.withAlphaComponent(0.5)
urlAttributes[.underlineStyle] = 1
#if os(macOS)
urlAttributes[.cursor] = NSCursor.pointingHand
#endif
string.append((task.httpMethod ?? "GET") + " " + (task.url ?? "–") + "\n", urlAttributes)
}
package func render(_ transaction: NetworkTransactionMetricsEntity) {
do {
let status = StatusLabelViewModel(transaction: transaction)
let method = transaction.request.httpMethod ?? "GET"
string.append(render(status.title + "\n", role: .title, weight: .semibold, color: UXColor(status.tint)))
string.append(self.spacer())
var urlAttributes = helper.attributes(role: .body2, weight: .regular)
urlAttributes[.underlineColor] = UXColor.clear
string.append(method + "\n", helper.attributes(role: .body, weight: .semibold))
string.append((transaction.request.url ?? "–") + "\n", urlAttributes)
string.append(spacer())
}
func append(section: KeyValueSectionViewModel?, count: Bool) {
let isCountDisplayed = count && section?.items.isEmpty == false
let details = isCountDisplayed ? section?.items.count.description : nil
append(section: section, details: details)
}
func append(section: KeyValueSectionViewModel?, details: String? = nil) {
guard let section = section else { return }
string.append(render(section, details: details))
addSpacer()
}
if let url = URL(string: transaction.request.url ?? "–") {
append(section: .makeComponents(for: url))
}
append(section: .makeHeaders(title: "Request Headers", headers: transaction.request.headers), count: true)
if let response = transaction.response {
append(section: .makeHeaders(title: "Response Headers", headers: response.headers), count: true)
}
string.deleteCharacters(in: NSRange(location: string.length - 1, length: 1))
}
package func render(subheadline: String) -> NSAttributedString {
render(subheadline + "\n", role: .subheadline, color: .secondaryLabel)
}
private func renderRequestBody(for task: NetworkTaskEntity) -> NSAttributedString {
if let body = task.requestBody, let string = renderedBodies[body.objectID] {
return string
}
if let blob = task.requestBody, let data = blob.data, !data.isEmpty {
return render(blob, data, contentType: blob.contentType, error: nil)
} else if task.type == .uploadTask, task.requestBodySize > 0 {
return _render(dataSize: task.requestBodySize, contentType: task.originalRequest?.contentType)
} else {
return render("–", role: .body2)
}
}
private func renderResponseBody(for task: NetworkTaskEntity) -> NSAttributedString {
if let body = task.responseBody, let string = renderedBodies[body.objectID] {
return string
}
if let blob = task.responseBody, let data = blob.data, !data.isEmpty {
return render(blob, data, contentType: blob.contentType, error: task.decodingError)
} else if task.type == .downloadTask, task.responseBodySize > 0 {
return _render(dataSize: task.responseBodySize, contentType: task.response?.contentType)
} else {
return render("–", role: .body2)
}
}
package func render(json: Any, error: NetworkLogger.DecodingError? = nil) -> NSAttributedString {
TextRendererJSON(json: json, error: error, options: options).render()
}
package func render(_ blob: LoggerBlobHandleEntity, _ data: Data, contentType: NetworkLogger.ContentType?, error: NetworkLogger.DecodingError?) -> NSAttributedString {
let string = render(data, contentType: contentType, error: error)
renderedBodies[blob.objectID] = string
return string
}
package func render(_ data: Data, contentType: NetworkLogger.ContentType?, error: NetworkLogger.DecodingError?) -> NSAttributedString {
if let json = try? JSONSerialization.jsonObject(with: data, options: []) {
return render(json: json, error: error)
}
if let string = String(data: data, encoding: .utf8) {
if contentType?.isEncodedForm ?? false, let section = decodeQueryParameters(form: string) {
return render(section, style: .monospaced)
} else if contentType?.isHTML ?? false {
return TextRendererHTML(html: string, options: options).render()
}
return preformatted(string)
} else {
return _render(dataSize: Int64(data.count), contentType: contentType)
}
}
private func _render(dataSize: Int64, contentType: NetworkLogger.ContentType?) -> NSAttributedString {
let string = [
ByteCountFormatter.string(fromByteCount: max(0, dataSize)),
contentType.map { "(\($0.rawValue))" }
].compactMap { $0 }.joined(separator: " ")
return render(string, role: .body2)
}
private func decodeQueryParameters(form string: String) -> KeyValueSectionViewModel? {
let string = "https://placeholder.com/path?" + string
guard let components = URLComponents(string: string),
let queryItems = components.queryItems,
!queryItems.isEmpty else {
return nil
}
return KeyValueSectionViewModel.makeQueryItems(for: queryItems)
}
package func render(_ sections: [KeyValueSectionViewModel]) {
for (index, section) in sections.enumerated() {
string.append(render(section))
if index != sections.endIndex - 1 {
addSpacer()
}
}
}
package func render(_ section: KeyValueSectionViewModel, details: String? = nil, style: TextFontStyle = .monospaced) -> NSAttributedString {
let details = details.map { "(\($0))" }
let title = [section.title, details].compactMap { $0 }.joined(separator: " ")
let titleColor: UXColor
#if os(macOS)
titleColor = .label
#else
titleColor = .secondaryLabel
#endif
let string = NSMutableAttributedString(string: title + "\n", attributes: helper.attributes(role: .subheadline, color: titleColor))
string.append(render(section.items, color: section.color, style: style))
return string
}
package func render(_ values: [(String, String?)]?, color: Color, style: TextFontStyle = .monospaced) -> NSAttributedString {
guard let values = values, !values.isEmpty else {
return NSAttributedString(string: "–\n", attributes: helper.attributes(role: .body2, style: style))
}
var index = 0
var keys: [NSRange] = []
var separators: [NSRange] = []
var string = ""
@discardableResult func append(_ value: String) -> NSRange {
let length = value.utf16.count
let range = NSRange(location: index, length: length)
index += length
string.append(value)
return range
}
for (key, value) in values {
keys.append(append(key))
#if os(watchOS)
separators.append(append(":\n"))
#else
separators.append(append(": "))
#endif
append("\(value ?? "–")\n")
}
let output = NSMutableAttributedString(string: string, attributes: helper.attributes(role: .body2, style: style))
let keyWeight: UXFont.Weight
#if os(macOS)
keyWeight = .regular
#else
keyWeight = options.color == .full ? .medium : .semibold
#endif
let keyFont = helper.font(style: .init(role: .body2, style: style, weight: keyWeight))
for range in keys {
output.addAttribute(.font, value: keyFont, range: range)
#if os(macOS)
output.addAttribute(.foregroundColor, value: UXColor.secondaryLabel, range: range)
#else
if options.color == .full {
output.addAttribute(.foregroundColor, value: UXColor(color), range: range)
}
#endif
}
for range in separators {
output.addAttribute(.foregroundColor, value: UXColor.secondaryLabel, range: range)
}
return output
}
package func preformatted(_ string: String, color: UXColor? = nil) -> NSAttributedString {
render(string, role: .body2, style: .monospaced, color: color ?? .label)
}
package func append(_ string: NSAttributedString) {
self.string.append(string)
}
package func render(
_ string: String,
role: TextRole,
style: TextFontStyle = .proportional,
weight: UXFont.Weight = .regular,
width: TextWidth = .standard,
color: UXColor = .label
) -> NSAttributedString {
let attributes = helper.attributes(role: role, style: style, weight: weight, width: width, color: color)
return NSAttributedString(string: string, attributes: attributes)
}
}
extension NSAttributedString.Key {
package static let objectId = NSAttributedString.Key("pulse-object-id-key")
package static let isTechnical = NSAttributedString.Key("pulse-technical-substring-key")
package static let subheadline = NSAttributedString.Key("pulse-subheadline-key")
}
// MARK: - Previews
#if DEBUG && !os(macOS)
private let _previewTask = LoggerStore.preview.entity(for: .login)
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
#Preview("Task") {
RichTextView(viewModel: .init(string: TextRenderer(options: .sharing).make {
$0.render(_previewTask, content: .sharing, store: LoggerStore.mock)
}))
}
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
#Preview("Task (Share)") {
RichTextView(viewModel: .init(string: TextRenderer(options: .sharing).make { $0.render(_previewTask, content: .sharing, store: LoggerStore.mock) }))
}
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
#Preview("Task (Color)") {
RichTextView(viewModel: .init(string: TextRenderer(options: .init(color: .full)).make {
$0.render(_previewTask, content: .all, store: LoggerStore.mock)
}))
}
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
#Preview("Task (Plain)") {
RichTextView(viewModel: .init(string: NSAttributedString(string: ShareStoreTask(entities: try! LoggerStore.mock.messages(), store: .mock, output: .plainText, completion: { _ in }).share().items[0] as! String)))
}
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
#Preview("Transaction") {
RichTextView(viewModel: .init(string: TextRenderer(options: .sharing).make { $0.render(_previewTask.orderedTransactions[0]) }))
}
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
#Preview("HTML (Raw)", traits: .fixedLayout(width: 1160, height: 2000)) {
let html = try! TextUtilities.html(from: TextRenderer(options: .sharing).make {
$0.render(_previewTask, content: .sharing, store: LoggerStore.mock)
})
RichTextView(viewModel: .init(string: TextRendererHTML(html: String(data: html, encoding: .utf8)!).render()))
}
#if os(iOS)
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
#Preview("HTML") {
let html = try! TextUtilities.html(from: TextRenderer(options: .sharing).make {
$0.render(_previewTask, content: .sharing, store: LoggerStore.mock)
})
WebView(data: html, contentType: "application/html")
.edgesIgnoringSafeArea([.bottom])
}
@available(iOS 18, tvOS 18, macOS 15, watchOS 11, visionOS 1, *)
#Preview("PDF") {
let string = TextRenderer(options: .sharing).make {
$0.render(_previewTask, content: .sharing, store: LoggerStore.mock)
}
PDFKitRepresentedView(document: PDFDocument(data: try! TextUtilities.pdf(from: string))!)
.edgesIgnoringSafeArea([.all])
}
#endif
#endif
package struct NetworkContent: OptionSet {
package let rawValue: Int16
package init(rawValue: Int16) { self.rawValue = rawValue }
package static let header = NetworkContent(rawValue: 1 << 0)
package static let largeHeader = NetworkContent(rawValue: 1 << 1)
package static let taskDetails = NetworkContent(rawValue: 1 << 2)
package static let requestComponents = NetworkContent(rawValue: 1 << 3)
package static let requestQueryItems = NetworkContent(rawValue: 1 << 4)
package static let errorDetails = NetworkContent(rawValue: 1 << 5)
package static let originalRequestHeaders = NetworkContent(rawValue: 1 << 6)
package static let currentRequestHeaders = NetworkContent(rawValue: 1 << 7)
package static let requestOptions = NetworkContent(rawValue: 1 << 8)
package static let requestBody = NetworkContent(rawValue: 1 << 9)
package static let responseHeaders = NetworkContent(rawValue: 1 << 10)
package static let responseBody = NetworkContent(rawValue: 1 << 11)
package static let sharing: NetworkContent = [
largeHeader, taskDetails, errorDetails, currentRequestHeaders, requestBody, responseHeaders, responseBody
]
package static let preview: NetworkContent = [
largeHeader, taskDetails, errorDetails, responseBody
]
package static let summary: NetworkContent = [
largeHeader, taskDetails, errorDetails, requestComponents, requestQueryItems, errorDetails, originalRequestHeaders, currentRequestHeaders, requestOptions, responseHeaders
]
package static let all: NetworkContent = [
largeHeader, taskDetails, errorDetails, requestComponents, requestQueryItems, errorDetails, originalRequestHeaders, currentRequestHeaders, requestOptions, requestBody, responseHeaders, responseBody
]
}
/// Uncomment to run performance tests in the release mode.
// public enum TextRendererTests {
// public static func share(_ entities: [NSManagedObject], store: LoggerStore, _ completion: @escaping () -> Void) {
// // TODO: rewrite using new convenience API for testing
// ShareStoreTask(entities: entities, store: store, output: .plainText) { _ in
// completion()
// }.start()
// }
//
// public static func plainText(from string: NSAttributedString) -> String {
// TextUtilities.plainText(from: string)
// }
//
// public static func html(from string: NSAttributedString) throws -> Data {
// try TextUtilities.html(from: string)
// }
//
// #if os(iOS)
// public static func pdf(from string: NSAttributedString) throws -> Data {
// try TextUtilities.pdf(from: string)
// }
// #endif
// }