AirAcceptLogger.swift 4.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
  1. import Foundation
  2. import OSLog
  3. public struct AirAcceptEvent: Codable, Equatable, Sendable {
  4. public var timestamp: Date
  5. public var hostProcess: String
  6. public var summary: String
  7. public var deviceName: String?
  8. public var action: String
  9. public var reason: String?
  10. public init(
  11. timestamp: Date = Date(),
  12. hostProcess: String,
  13. summary: String,
  14. deviceName: String?,
  15. action: String,
  16. reason: String?
  17. ) {
  18. self.timestamp = timestamp
  19. self.hostProcess = hostProcess
  20. self.summary = summary
  21. self.deviceName = deviceName
  22. self.action = action
  23. self.reason = reason
  24. }
  25. }
  26. public final class AirAcceptLogger {
  27. public let logURL: URL
  28. private let logger = Logger(subsystem: "com.airaccept.AirAccept", category: "AirAccept")
  29. private let fileManager: FileManager
  30. private let encoder: JSONEncoder
  31. private let maxLogSizeBytes = 1_000_000
  32. public init(
  33. logURL: URL = AirAcceptLogger.defaultLogURL(),
  34. fileManager: FileManager = .default
  35. ) {
  36. self.logURL = logURL
  37. self.fileManager = fileManager
  38. self.encoder = JSONEncoder()
  39. self.encoder.dateEncodingStrategy = .iso8601
  40. self.encoder.outputFormatting = [.sortedKeys]
  41. }
  42. public static func defaultLogURL() -> URL {
  43. FileManager.default.homeDirectoryForCurrentUser
  44. .appendingPathComponent("Library/Logs/AirAccept", isDirectory: true)
  45. .appendingPathComponent("airaccept.log", isDirectory: false)
  46. }
  47. public func info(_ message: String) {
  48. logger.info("\(message, privacy: .public)")
  49. appendLine("[info] \(isoTimestamp()) \(message)")
  50. }
  51. public func error(_ message: String) {
  52. logger.error("\(message, privacy: .public)")
  53. appendLine("[error] \(isoTimestamp()) \(message)")
  54. }
  55. public func event(_ event: AirAcceptEvent) {
  56. var event = event
  57. event.summary = sanitize(event.summary, limit: 600)
  58. event.reason = event.reason.map { sanitize($0, limit: 240) }
  59. logger.info(
  60. "host=\(event.hostProcess, privacy: .public) action=\(event.action, privacy: .public) device=\(event.deviceName ?? "unknown", privacy: .public) reason=\(event.reason ?? "", privacy: .public)"
  61. )
  62. do {
  63. let data = try encoder.encode(event)
  64. let line = String(decoding: data, as: UTF8.self)
  65. appendLine(line)
  66. } catch {
  67. appendLine("[error] \(isoTimestamp()) failed to encode event: \(error.localizedDescription)")
  68. }
  69. }
  70. private func appendLine(_ line: String) {
  71. do {
  72. try fileManager.createDirectory(
  73. at: logURL.deletingLastPathComponent(),
  74. withIntermediateDirectories: true
  75. )
  76. try rotateLogIfNeeded()
  77. let data = Data((line + "\n").utf8)
  78. if fileManager.fileExists(atPath: logURL.path) {
  79. let handle = try FileHandle(forWritingTo: logURL)
  80. try handle.seekToEnd()
  81. try handle.write(contentsOf: data)
  82. try handle.close()
  83. } else {
  84. try data.write(to: logURL, options: [.atomic])
  85. }
  86. } catch {
  87. logger.error("failed to append log file: \(error.localizedDescription, privacy: .public)")
  88. }
  89. }
  90. private func rotateLogIfNeeded() throws {
  91. guard
  92. fileManager.fileExists(atPath: logURL.path),
  93. let size = try fileManager.attributesOfItem(atPath: logURL.path)[.size] as? NSNumber,
  94. size.intValue >= maxLogSizeBytes
  95. else {
  96. return
  97. }
  98. let rotatedLogURL = logURL.deletingPathExtension().appendingPathExtension("log.1")
  99. if fileManager.fileExists(atPath: rotatedLogURL.path) {
  100. try fileManager.removeItem(at: rotatedLogURL)
  101. }
  102. try fileManager.moveItem(at: logURL, to: rotatedLogURL)
  103. }
  104. private func isoTimestamp() -> String {
  105. ISO8601DateFormatter().string(from: Date())
  106. }
  107. private func sanitize(_ value: String, limit: Int) -> String {
  108. var result = value
  109. .replacingOccurrences(of: "\n", with: " ")
  110. .replacingOccurrences(of: "\r", with: " ")
  111. .replacingOccurrences(of: "\t", with: " ")
  112. while result.contains(" ") {
  113. result = result.replacingOccurrences(of: " ", with: " ")
  114. }
  115. if result.count > limit {
  116. return String(result.prefix(limit - 3)) + "..."
  117. }
  118. return result
  119. }
  120. }