151 lines
5.0 KiB
Swift
151 lines
5.0 KiB
Swift
import Foundation
|
|
import Logging
|
|
|
|
final class FileLogSink: @unchecked Sendable {
|
|
static let shared = FileLogSink()
|
|
static let defaultURL: URL = {
|
|
let base = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first
|
|
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library", isDirectory: true)
|
|
return base
|
|
.appendingPathComponent("Logs", isDirectory: true)
|
|
.appendingPathComponent("CodexBar", isDirectory: true)
|
|
.appendingPathComponent("CodexBar.log")
|
|
}()
|
|
|
|
private let queue = DispatchQueue(label: "com.steipete.codexbar.filelog", qos: .utility)
|
|
private let fileManager: FileManager
|
|
private var isEnabled = false
|
|
private var fileHandle: FileHandle?
|
|
private var fileURL: URL = FileLogSink.defaultURL
|
|
private let maxBytes: Int64 = 10 * 1024 * 1024
|
|
|
|
init(fileManager: FileManager = .default) {
|
|
self.fileManager = fileManager
|
|
}
|
|
|
|
func setEnabled(_ enabled: Bool, fileURL: URL = FileLogSink.defaultURL) {
|
|
self.queue.async {
|
|
self.isEnabled = enabled
|
|
self.fileURL = fileURL
|
|
if !enabled {
|
|
self.closeHandle()
|
|
return
|
|
}
|
|
_ = self.openHandleIfNeeded()
|
|
}
|
|
}
|
|
|
|
func currentURL() -> URL {
|
|
self.queue.sync { self.fileURL }
|
|
}
|
|
|
|
func isWriteEnabled() -> Bool {
|
|
self.queue.sync { self.isEnabled }
|
|
}
|
|
|
|
func write(_ text: String) {
|
|
self.queue.async {
|
|
guard self.isEnabled else { return }
|
|
guard let data = text.data(using: .utf8) else { return }
|
|
guard let handle = self.openHandleIfNeeded() else { return }
|
|
handle.write(data)
|
|
}
|
|
}
|
|
|
|
private func openHandleIfNeeded() -> FileHandle? {
|
|
if let handle = self.fileHandle { return handle }
|
|
do {
|
|
try self.prepareFile(at: self.fileURL)
|
|
let handle = try FileHandle(forWritingTo: self.fileURL)
|
|
handle.seekToEndOfFile()
|
|
self.fileHandle = handle
|
|
return handle
|
|
} catch {
|
|
return nil
|
|
}
|
|
}
|
|
|
|
private func prepareFile(at url: URL) throws {
|
|
let directory = url.deletingLastPathComponent()
|
|
if !self.fileManager.fileExists(atPath: directory.path) {
|
|
try self.fileManager.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
}
|
|
if self.fileManager.fileExists(atPath: url.path) {
|
|
let attributes = try self.fileManager.attributesOfItem(atPath: url.path)
|
|
let size = (attributes[.size] as? NSNumber)?.int64Value ?? 0
|
|
if size > self.maxBytes {
|
|
try self.fileManager.removeItem(at: url)
|
|
}
|
|
}
|
|
if !self.fileManager.fileExists(atPath: url.path) {
|
|
_ = self.fileManager.createFile(atPath: url.path, contents: nil)
|
|
}
|
|
}
|
|
|
|
private func closeHandle() {
|
|
if let handle = self.fileHandle {
|
|
try? handle.close()
|
|
}
|
|
self.fileHandle = nil
|
|
}
|
|
}
|
|
|
|
struct FileLogHandler: LogHandler {
|
|
var metadata: Logger.Metadata = [:]
|
|
var logLevel: Logger.Level = .info
|
|
|
|
private let label: String
|
|
private let sink: FileLogSink
|
|
|
|
init(label: String, sink: FileLogSink = .shared) {
|
|
self.label = label
|
|
self.sink = sink
|
|
}
|
|
|
|
subscript(metadataKey metadataKey: String) -> Logger.Metadata.Value? {
|
|
get { self.metadata[metadataKey] }
|
|
set { self.metadata[metadataKey] = newValue }
|
|
}
|
|
|
|
func log(event: LogEvent) {
|
|
guard self.sink.isWriteEnabled() else { return }
|
|
let ts = Self.timestamp()
|
|
var combined = self.metadata
|
|
if let metadata = event.metadata { combined.merge(metadata, uniquingKeysWith: { _, new in new }) }
|
|
var metaText = ""
|
|
if !combined.isEmpty {
|
|
let pairs = combined
|
|
.sorted(by: { $0.key < $1.key })
|
|
.map { key, value in
|
|
let rendered = Self.renderMetadataValue(value)
|
|
let safeValue = LogRedactor.redact(rendered)
|
|
return "\(key)=\(safeValue)"
|
|
}
|
|
.joined(separator: " ")
|
|
metaText = " \(pairs)"
|
|
}
|
|
let safeMessage = LogRedactor.redact("\(event.message)")
|
|
let lineText = "[\(ts)] [\(event.level.rawValue.uppercased())] \(self.label): \(safeMessage)\(metaText)\n"
|
|
_ = event.source
|
|
_ = event.file
|
|
_ = event.function
|
|
_ = event.line
|
|
self.sink.write(lineText)
|
|
}
|
|
|
|
private static func timestamp() -> String {
|
|
let formatter = ISO8601DateFormatter()
|
|
formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
return formatter.string(from: Date())
|
|
}
|
|
|
|
private static func renderMetadataValue(_ value: Logger.Metadata.Value) -> String {
|
|
switch value {
|
|
case let .string(text):
|
|
text
|
|
default:
|
|
String(describing: value)
|
|
}
|
|
}
|
|
}
|