Files
steipete--codexbar/Sources/CodexBarCore/Logging/FileLogHandler.swift
T
2026-07-13 12:22:33 +08:00

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)
}
}
}