mirror of
https://github.com/Aminark/legado-ios.git
synced 2026-10-23 20:31:22 +08:00
feat: open-source 小浣熊阅读 for iOS
This commit is contained in:
@@ -0,0 +1,103 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user