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