314 lines
12 KiB
Swift
314 lines
12 KiB
Swift
// SPDX-FileCopyrightText: 2026 Kaede Akino
|
|
// SPDX-License-Identifier: GPL-3.0-only
|
|
|
|
import Foundation
|
|
import Logging
|
|
import TermindApplication
|
|
|
|
public nonisolated enum LaunchFileLogError: LocalizedError, Sendable {
|
|
case invalidFile
|
|
case missingFile
|
|
|
|
public var errorDescription: String? {
|
|
switch self {
|
|
case .invalidFile: String(localized: "The selected logging file is invalid.")
|
|
case .missingFile: String(localized: "The selected logging file no longer exists.")
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A process-wide SwiftLog backend that writes every label into one file for the current launch.
|
|
/// The file is created lazily and only while saving is enabled.
|
|
public nonisolated final class LaunchFileLogService: ApplicationLogManaging, @unchecked Sendable {
|
|
private let core: LaunchFileLogCore
|
|
|
|
public init(
|
|
directory: URL? = nil,
|
|
defaults: UserDefaults = .standard,
|
|
launchDate: Date = .now,
|
|
launchID: UUID = UUID()
|
|
) {
|
|
let baseDirectory =
|
|
directory
|
|
?? FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
|
|
.appendingPathComponent("Logging", isDirectory: true)
|
|
core = LaunchFileLogCore(
|
|
directory: baseDirectory,
|
|
defaults: defaults,
|
|
launchDate: launchDate,
|
|
launchID: launchID
|
|
)
|
|
}
|
|
|
|
public var isSaving: Bool { core.isSaving }
|
|
|
|
public func makeLogger(label: String) -> Logger {
|
|
Logger(label: label) { [core] label in
|
|
LaunchFileLogHandler(label: label, core: core)
|
|
}
|
|
}
|
|
|
|
public func setSaving(_ enabled: Bool) throws { try core.setSaving(enabled) }
|
|
public func setLevel(_ level: ApplicationLogLevel) { core.setLevel(level) }
|
|
public func files() throws -> [ApplicationLogFile] { try core.files() }
|
|
public func exportURL(for id: ApplicationLogFile.ID) throws -> URL { try core.exportURL(for: id) }
|
|
public func delete(_ id: ApplicationLogFile.ID) throws { try core.delete(id) }
|
|
public func deleteAll() throws { try core.deleteAll() }
|
|
}
|
|
|
|
private nonisolated struct LaunchFileLogHandler: LogHandler {
|
|
let label: String
|
|
let core: LaunchFileLogCore
|
|
var metadataProvider: Logger.MetadataProvider?
|
|
var metadata: Logger.Metadata = [:]
|
|
|
|
init(label: String, core: LaunchFileLogCore) {
|
|
self.label = label
|
|
self.core = core
|
|
}
|
|
|
|
var logLevel: Logger.Level {
|
|
get { core.loggerLevel }
|
|
set { core.setLevel(ApplicationLogLevel(loggerLevel: newValue)) }
|
|
}
|
|
|
|
subscript(metadataKey key: String) -> Logger.Metadata.Value? {
|
|
get { metadata[key] }
|
|
set { metadata[key] = newValue }
|
|
}
|
|
|
|
func log(event: LogEvent) {
|
|
var combined = metadata
|
|
if let provided = metadataProvider?.get() {
|
|
combined.merge(provided) { _, provided in provided }
|
|
}
|
|
if let explicit = event.metadata {
|
|
combined.merge(explicit) { _, explicit in explicit }
|
|
}
|
|
core.emit(label: label, event: event, metadata: combined)
|
|
}
|
|
}
|
|
|
|
private nonisolated final class LaunchFileLogCore: @unchecked Sendable {
|
|
private static let metricsPayloadKey = "metrics_payload"
|
|
private static let metricsPayloadEvents: Set<String> = [
|
|
"metrics.sample.incomplete", "metrics.sample.oversized",
|
|
]
|
|
private static let maximumMetricsPayloadBytes = 8 * 1_024
|
|
|
|
private let lock = NSLock()
|
|
private let directory: URL
|
|
private let defaults: UserDefaults
|
|
private let currentID: String
|
|
private let launchDate: Date
|
|
private let timestampFormatter: ISO8601DateFormatter
|
|
private var saving: Bool
|
|
private var configuredLevel: ApplicationLogLevel
|
|
private var writer: FileHandle?
|
|
|
|
init(directory: URL, defaults: UserDefaults, launchDate: Date, launchID: UUID) {
|
|
self.directory = directory
|
|
self.defaults = defaults
|
|
self.launchDate = launchDate
|
|
saving = defaults.object(forKey: SettingsKey.loggingSavingEnabled) as? Bool ?? false
|
|
configuredLevel =
|
|
defaults.string(forKey: SettingsKey.loggingLevel).flatMap(ApplicationLogLevel.init(rawValue:)) ?? .info
|
|
|
|
let filenameFormatter = DateFormatter()
|
|
filenameFormatter.calendar = Calendar(identifier: .iso8601)
|
|
filenameFormatter.locale = Locale(identifier: "en_US_POSIX")
|
|
filenameFormatter.timeZone = TimeZone(secondsFromGMT: 0)
|
|
filenameFormatter.dateFormat = "yyyyMMdd'T'HHmmss.SSS'Z'"
|
|
currentID = "termind-\(filenameFormatter.string(from: launchDate))-\(launchID.uuidString.lowercased()).log"
|
|
|
|
timestampFormatter = ISO8601DateFormatter()
|
|
timestampFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
|
}
|
|
|
|
deinit { try? writer?.close() }
|
|
|
|
var isSaving: Bool { lock.withLock { saving } }
|
|
var level: ApplicationLogLevel { lock.withLock { configuredLevel } }
|
|
var loggerLevel: Logger.Level { level.loggerLevel }
|
|
|
|
func setSaving(_ enabled: Bool) throws {
|
|
try lock.withLock {
|
|
guard saving != enabled else { return }
|
|
if enabled {
|
|
try openWriterLocked()
|
|
saving = true
|
|
} else {
|
|
saving = false
|
|
try closeWriterLocked()
|
|
}
|
|
defaults.set(enabled, forKey: SettingsKey.loggingSavingEnabled)
|
|
}
|
|
}
|
|
|
|
func setLevel(_ level: ApplicationLogLevel) {
|
|
lock.withLock { configuredLevel = level }
|
|
defaults.set(level.rawValue, forKey: SettingsKey.loggingLevel)
|
|
}
|
|
|
|
func emit(label: String, event: LogEvent, metadata: Logger.Metadata) {
|
|
let data: Data? = lock.withLock {
|
|
guard event.level >= configuredLevel.loggerLevel else { return nil }
|
|
let line = formattedLineLocked(label: label, event: event, metadata: metadata)
|
|
guard let data = line.data(using: .utf8) else { return nil }
|
|
if saving {
|
|
do {
|
|
try openWriterLocked()
|
|
try writer?.write(contentsOf: data)
|
|
} catch {
|
|
try? closeWriterLocked()
|
|
}
|
|
}
|
|
return data
|
|
}
|
|
#if DEBUG
|
|
if let data { try? FileHandle.standardError.write(contentsOf: data) }
|
|
#endif
|
|
}
|
|
|
|
func files() throws -> [ApplicationLogFile] {
|
|
try lock.withLock {
|
|
try synchronizeLocked()
|
|
guard FileManager.default.fileExists(atPath: directory.path) else { return [] }
|
|
let keys: Set<URLResourceKey> = [.creationDateKey, .contentModificationDateKey, .fileSizeKey]
|
|
return try FileManager.default.contentsOfDirectory(
|
|
at: directory,
|
|
includingPropertiesForKeys: Array(keys),
|
|
options: [.skipsHiddenFiles]
|
|
).compactMap { url in
|
|
guard url.pathExtension == "log", Self.isValidID(url.lastPathComponent) else { return nil }
|
|
let values = try url.resourceValues(forKeys: keys)
|
|
return ApplicationLogFile(
|
|
id: url.lastPathComponent,
|
|
createdAt: values.creationDate ?? values.contentModificationDate ?? .distantPast,
|
|
byteCount: Int64(values.fileSize ?? 0),
|
|
isCurrent: url.lastPathComponent == currentID
|
|
)
|
|
}.sorted {
|
|
if $0.createdAt != $1.createdAt { return $0.createdAt > $1.createdAt }
|
|
return $0.id > $1.id
|
|
}
|
|
}
|
|
}
|
|
|
|
func exportURL(for id: ApplicationLogFile.ID) throws -> URL {
|
|
try lock.withLock {
|
|
let url = try fileURLLocked(for: id)
|
|
try synchronizeLocked()
|
|
guard FileManager.default.fileExists(atPath: url.path) else { throw LaunchFileLogError.missingFile }
|
|
return url
|
|
}
|
|
}
|
|
|
|
func delete(_ id: ApplicationLogFile.ID) throws {
|
|
try lock.withLock {
|
|
let url = try fileURLLocked(for: id)
|
|
if id == currentID { try closeWriterLocked() }
|
|
if FileManager.default.fileExists(atPath: url.path) { try FileManager.default.removeItem(at: url) }
|
|
if id == currentID, saving { try openWriterLocked() }
|
|
}
|
|
}
|
|
|
|
func deleteAll() throws {
|
|
try lock.withLock {
|
|
try closeWriterLocked()
|
|
if FileManager.default.fileExists(atPath: directory.path) {
|
|
try FileManager.default.removeItem(at: directory)
|
|
}
|
|
if saving { try openWriterLocked() }
|
|
}
|
|
}
|
|
|
|
private func fileURLLocked(for id: String) throws -> URL {
|
|
guard Self.isValidID(id) else { throw LaunchFileLogError.invalidFile }
|
|
return directory.appendingPathComponent(id, isDirectory: false)
|
|
}
|
|
|
|
private func openWriterLocked() throws {
|
|
guard writer == nil else { return }
|
|
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
|
|
let url = directory.appendingPathComponent(currentID, isDirectory: false)
|
|
if !FileManager.default.fileExists(atPath: url.path) {
|
|
guard FileManager.default.createFile(atPath: url.path, contents: nil) else {
|
|
throw CocoaError(.fileWriteUnknown)
|
|
}
|
|
try FileManager.default.setAttributes([.creationDate: launchDate], ofItemAtPath: url.path)
|
|
}
|
|
let handle = try FileHandle(forWritingTo: url)
|
|
try handle.seekToEnd()
|
|
writer = handle
|
|
}
|
|
|
|
private func synchronizeLocked() throws { try writer?.synchronize() }
|
|
|
|
private func closeWriterLocked() throws {
|
|
guard let writer else { return }
|
|
self.writer = nil
|
|
try writer.synchronize()
|
|
try writer.close()
|
|
}
|
|
|
|
private func formattedLineLocked(label: String, event: LogEvent, metadata: Logger.Metadata) -> String {
|
|
let timestamp = timestampFormatter.string(from: .now)
|
|
let safeLabel = Self.safeToken(label, fallback: "termind")
|
|
let safeEvent = Self.safeToken(event.message.description, fallback: "event.redacted")
|
|
var fields = metadata.keys.sorted().compactMap { key -> String? in
|
|
guard key != Self.metricsPayloadKey, Self.safeToken(key, fallback: "") == key,
|
|
let value = metadata[key]
|
|
else { return nil }
|
|
return "\(key)=\(Self.safeToken(value.description, fallback: "redacted"))"
|
|
}
|
|
if Self.metricsPayloadEvents.contains(event.message.description),
|
|
let payload = metadata[Self.metricsPayloadKey]
|
|
{
|
|
let (escaped, truncated) = Self.escapedMetricsPayload(payload.description)
|
|
fields.append("metrics_payload=\"\(escaped)\"")
|
|
if truncated, metadata["metrics_payload_truncated"] == nil {
|
|
fields.append("metrics_payload_truncated=true")
|
|
}
|
|
}
|
|
let suffix = fields.isEmpty ? "" : " " + fields.joined(separator: " ")
|
|
return "\(timestamp) \(event.level.rawValue.uppercased()) \(safeLabel) \(safeEvent)\(suffix)\n"
|
|
}
|
|
|
|
private static func isValidID(_ id: String) -> Bool {
|
|
id == URL(fileURLWithPath: id).lastPathComponent && !id.contains("/") && id.hasSuffix(".log")
|
|
}
|
|
|
|
private static func safeToken(_ value: String, fallback: String) -> String {
|
|
guard !value.isEmpty, value.utf8.count <= 96,
|
|
value.unicodeScalars.allSatisfy({ scalar in
|
|
switch scalar.value {
|
|
case 45, 46, 48...57, 65...90, 95, 97...122: true
|
|
default: false
|
|
}
|
|
})
|
|
else { return fallback }
|
|
return value
|
|
}
|
|
|
|
private static func escapedMetricsPayload(_ value: String) -> (String, Bool) {
|
|
let bytes = Array(value.utf8)
|
|
let truncated = bytes.count > maximumMetricsPayloadBytes
|
|
let text = String(decoding: bytes.prefix(maximumMetricsPayloadBytes), as: UTF8.self)
|
|
var escaped = ""
|
|
escaped.reserveCapacity(text.utf8.count)
|
|
for scalar in text.unicodeScalars {
|
|
switch scalar.value {
|
|
case 0x22: escaped += "\\\""
|
|
case 0x5C: escaped += "\\\\"
|
|
case 0x0A: escaped += "\\n"
|
|
case 0x0D: escaped += "\\r"
|
|
case 0x09: escaped += "\\t"
|
|
case 0x00...0x1F, 0x7F: escaped += String(format: "\\u{%02X}", scalar.value)
|
|
default: escaped.unicodeScalars.append(scalar)
|
|
}
|
|
}
|
|
return (escaped, truncated)
|
|
}
|
|
}
|