LogManager.swift 7.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. // LoopFollow
  2. // LogManager.swift
  3. import Foundation
  4. import UIKit
  5. class LogManager {
  6. static let shared = LogManager()
  7. private let fileManager = FileManager.default
  8. private let logDirectory: URL
  9. private let dateFormatter: DateFormatter
  10. private let consoleQueue = DispatchQueue(label: "com.loopfollow.log.console", qos: .background)
  11. private let rateLimitQueue = DispatchQueue(label: "com.loopfollow.log.ratelimit")
  12. private var lastLoggedTimestamps: [String: Date] = [:]
  13. private var shouldLogVersionHeader: Bool = true
  14. enum Category: String, CaseIterable {
  15. case bluetooth = "Bluetooth"
  16. case nightscout = "Nightscout"
  17. case apns = "APNS"
  18. case general = "General"
  19. case contact = "Contact"
  20. case taskScheduler = "Task Scheduler"
  21. case dexcom = "Dexcom"
  22. case alarm = "Alarm"
  23. case volumeButtonSnooze = "Volume Button Snooze"
  24. case calendar = "Calendar"
  25. case deviceStatus = "Device Status"
  26. case remote = "Remote"
  27. case websocket = "WebSocket"
  28. case telemetry = "Telemetry"
  29. }
  30. init() {
  31. let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
  32. logDirectory = documentsDirectory.appendingPathComponent("Logs")
  33. if !fileManager.fileExists(atPath: logDirectory.path) {
  34. try? fileManager.createDirectory(at: logDirectory, withIntermediateDirectories: true, attributes: nil)
  35. }
  36. dateFormatter = DateFormatter()
  37. dateFormatter.dateFormat = "yyyy-MM-dd"
  38. }
  39. private func formattedLogMessage(for category: Category, message: String) -> String {
  40. let timestamp = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium)
  41. return "[\(timestamp)] [\(category.rawValue)] \(message)"
  42. }
  43. /// Logs a message with an optional rate limit.
  44. ///
  45. /// - Parameters:
  46. /// - category: The log category.
  47. /// - message: The message to log.
  48. /// - isDebug: Indicates if this is a debug log.
  49. /// - limitIdentifier: Optional key to rate-limit similar log messages.
  50. /// - limitInterval: Time interval (in seconds) to wait before logging the same type again.
  51. func log(category: Category, message: String, isDebug: Bool = false, limitIdentifier: String? = nil, limitInterval: TimeInterval = 300) {
  52. let safeMessage = LogRedactor.sweep(message)
  53. let logMessage = formattedLogMessage(for: category, message: safeMessage)
  54. consoleQueue.async {
  55. print(logMessage)
  56. }
  57. if category == .taskScheduler && isDebug {
  58. return
  59. }
  60. if let key = limitIdentifier, !Storage.shared.debugLogLevel.value {
  61. let shouldLog: Bool = rateLimitQueue.sync {
  62. if let lastLogged = lastLoggedTimestamps[key] {
  63. let interval = Date().timeIntervalSince(lastLogged)
  64. if interval < limitInterval {
  65. return false
  66. }
  67. }
  68. lastLoggedTimestamps[key] = Date()
  69. return true
  70. }
  71. if !shouldLog {
  72. return
  73. }
  74. }
  75. if !isDebug || Storage.shared.debugLogLevel.value {
  76. let logFileURL = currentLogFileURL
  77. writeVersionHeaderIfNeeded(for: logFileURL)
  78. append(logMessage + "\n", to: logFileURL)
  79. }
  80. }
  81. /// Helper method: checks if the log file is empty.
  82. private func isLogFileEmpty(at fileURL: URL) -> Bool {
  83. if !fileManager.fileExists(atPath: fileURL.path) { return true }
  84. if let attributes = try? fileManager.attributesOfItem(atPath: fileURL.path),
  85. let fileSize = attributes[.size] as? UInt64
  86. {
  87. return fileSize == 0
  88. }
  89. return false
  90. }
  91. /// Helper method: writes the version header if needed.
  92. private func writeVersionHeaderIfNeeded(for fileURL: URL) {
  93. if shouldLogVersionHeader || isLogFileEmpty(at: fileURL) {
  94. let versionManager = AppVersionManager()
  95. let version = versionManager.version()
  96. // Retrieve build details
  97. let buildDetails = BuildDetails.default
  98. let formattedBuildDate = dateTimeUtils.formattedDate(from: buildDetails.buildDate())
  99. let branchAndSha = buildDetails.branchAndSha
  100. let expiration = dateTimeUtils.formattedDate(from: buildDetails.calculateExpirationDate())
  101. let expirationHeaderString = buildDetails.expirationHeaderString
  102. let isMacApp = buildDetails.isMacApp()
  103. let isSimulatorBuild = buildDetails.isSimulatorBuild()
  104. let osLabel: String
  105. let osVersion: String
  106. if isMacApp {
  107. osLabel = "macOS"
  108. let v = ProcessInfo.processInfo.operatingSystemVersion
  109. osVersion = "\(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
  110. } else {
  111. osLabel = "iOS"
  112. osVersion = UIDevice.current.systemVersion
  113. }
  114. // Assemble header information
  115. var headerLines = [String]()
  116. headerLines.append("LoopFollow Version: \(version)")
  117. if !isMacApp, !isSimulatorBuild {
  118. headerLines.append("\(expirationHeaderString): \(expiration)")
  119. }
  120. headerLines.append("Built: \(formattedBuildDate)")
  121. headerLines.append("Branch: \(branchAndSha)")
  122. headerLines.append("\(osLabel): \(osVersion)")
  123. let headerMessage = headerLines.joined(separator: ", ") + "\n"
  124. let logMessage = formattedLogMessage(for: .general, message: headerMessage)
  125. append(logMessage, to: fileURL)
  126. shouldLogVersionHeader = false
  127. }
  128. }
  129. func cleanupOldLogs() {
  130. let today = dateFormatter.string(from: Date())
  131. let yesterday = dateFormatter.string(from: Calendar.current.date(byAdding: .day, value: -1, to: Date())!)
  132. do {
  133. let logFiles = try fileManager.contentsOfDirectory(at: logDirectory, includingPropertiesForKeys: nil)
  134. for logFile in logFiles {
  135. let filename = logFile.lastPathComponent
  136. if !filename.contains(today), !filename.contains(yesterday) {
  137. try fileManager.removeItem(at: logFile)
  138. }
  139. }
  140. } catch {
  141. print("Failed to clean up old logs: \(error)")
  142. }
  143. }
  144. func logFileURL(for date: Date) -> URL {
  145. let dateString = dateFormatter.string(from: date)
  146. return logDirectory.appendingPathComponent("LoopFollow \(dateString).log")
  147. }
  148. func logFilesForTodayAndYesterday() -> [URL] {
  149. let today = logFileURL(for: Date())
  150. let yesterday = logFileURL(for: Calendar.current.date(byAdding: .day, value: -1, to: Date())!)
  151. return [today, yesterday].filter { fileManager.fileExists(atPath: $0.path) }
  152. }
  153. var currentLogFileURL: URL {
  154. return logFileURL(for: Date())
  155. }
  156. private func append(_ message: String, to fileURL: URL) {
  157. if !fileManager.fileExists(atPath: fileURL.path) {
  158. fileManager.createFile(atPath: fileURL.path, contents: nil, attributes: nil)
  159. }
  160. if let fileHandle = try? FileHandle(forWritingTo: fileURL) {
  161. defer { fileHandle.closeFile() }
  162. fileHandle.seekToEndOfFile()
  163. if let data = message.data(using: .utf8) {
  164. fileHandle.write(data)
  165. }
  166. } else {
  167. print("Failed to open log file at \(fileURL.path)")
  168. }
  169. }
  170. }