Files

104 lines
4.7 KiB
Swift

import Foundation
public struct DiagnosticLogEntry: Codable, Identifiable, Sendable {
public enum Level: String, Codable, CaseIterable, Sendable { case info, warning, error }
public var id: UUID
public var date: Date
public var level: Level
public var category: String
public var message: String
}
public final class DiagnosticLog: @unchecked Sendable {
public static let shared = DiagnosticLog()
private let lock = NSLock()
private let fileURL: URL
private let maximumEntries: Int
private let isEnabled: @Sendable () -> Bool
private var entries: [DiagnosticLogEntry]
public init(directory: URL? = nil, maximumEntries: Int = 500,
isEnabled: @escaping @Sendable () -> Bool = { ApplicationSettingsStore.shared.settings.recordLog }) {
fileURL = (directory ?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("Legado/Logs", isDirectory: true)).appendingPathComponent("diagnostics.json")
self.maximumEntries = max(1, maximumEntries)
self.isEnabled = isEnabled
entries = (try? JSONDecoder().decode([DiagnosticLogEntry].self, from: Data(contentsOf: fileURL))) ?? []
entries = Array(entries.suffix(self.maximumEntries))
}
public func record(_ level: DiagnosticLogEntry.Level = .info, category: String, message: String) {
guard isEnabled() else { return }
lock.lock()
defer { lock.unlock() }
entries.append(.init(id: UUID(), date: Date(), level: level,
category: String(category.prefix(80)), message: Self.redact(String(message.prefix(2048)))))
entries = Array(entries.suffix(maximumEntries))
try? persist()
}
public func snapshot() -> [DiagnosticLogEntry] {
lock.lock()
defer { lock.unlock() }
return entries.reversed()
}
public func clear() throws {
lock.lock()
defer { lock.unlock() }
if FileManager.default.fileExists(atPath: fileURL.path) { try FileManager.default.removeItem(at: fileURL) }
entries.removeAll()
}
public func export() -> String {
let formatter = ISO8601DateFormatter()
return snapshot().reversed().map {
"\(formatter.string(from: $0.date)) [\($0.level.rawValue.uppercased())] [\($0.category)] \($0.message)"
}.joined(separator: "\n")
}
public func network(_ request: URLRequest, status: Int? = nil, error: Error? = nil, startedAt: Date) {
// URL query strings, credentials and fragments can contain access tokens.
let url = request.url.map(Self.safeURL) ?? "未知地址"
let duration = Int(Date().timeIntervalSince(startedAt) * 1000)
let result: String
if let error, case HTTPClientError.statusResponse(let response) = error {
result = "HTTP \(response.statusCode)"
} else {
result = error.map { "失败 (\(($0 as NSError).domain):\(($0 as NSError).code))" } ?? "HTTP \(status ?? 0)"
}
record(error != nil || (status ?? 0) >= 400 ? .error : .info, category: "网络",
message: "\(request.httpMethod ?? "GET") \(url) · \(result) · \(duration) ms")
}
public static func safeURL(_ url: URL) -> String {
guard var parts = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return "[URL]" }
parts.user = nil; parts.password = nil; parts.query = nil; parts.fragment = nil
return parts.string ?? "[URL]"
}
public static func redact(_ text: String) -> String {
var value = text
if let regex = try? NSRegularExpression(pattern: #"https?://[^\s\"<>]+"#) {
for match in regex.matches(in: value, range: NSRange(value.startIndex..., in: value)).reversed() {
guard let range = Range(match.range, in: value), let url = URL(string: String(value[range])) else { continue }
value.replaceSubrange(range, with: safeURL(url))
}
}
for pattern in [
#"(?im)(authorization|cookie)["']?(\s*[=:]\s*)[^\r\n]+"#,
#"(?i)["']?(password|passwd|token|secret)["']?(\s*[=:]\s*)(?:"[^"]*"|'[^']*'|[^\s,;]+)"#
] {
if let regex = try? NSRegularExpression(pattern: pattern) {
value = regex.stringByReplacingMatches(in: value, range: NSRange(value.startIndex..., in: value), withTemplate: "$1$2[已隐藏]")
}
}
return value
}
private func persist() throws {
try FileManager.default.createDirectory(at: fileURL.deletingLastPathComponent(), withIntermediateDirectories: true)
try JSONEncoder().encode(entries).write(to: fileURL, options: .atomic)
}
}