| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194 |
- // LoopFollow
- // LogManager.swift
- import Foundation
- import UIKit
- class LogManager {
- static let shared = LogManager()
- private let fileManager = FileManager.default
- private let logDirectory: URL
- private let dateFormatter: DateFormatter
- private let consoleQueue = DispatchQueue(label: "com.loopfollow.log.console", qos: .background)
- private let rateLimitQueue = DispatchQueue(label: "com.loopfollow.log.ratelimit")
- private var lastLoggedTimestamps: [String: Date] = [:]
- private var shouldLogVersionHeader: Bool = true
- enum Category: String, CaseIterable {
- case bluetooth = "Bluetooth"
- case nightscout = "Nightscout"
- case apns = "APNS"
- case general = "General"
- case contact = "Contact"
- case taskScheduler = "Task Scheduler"
- case dexcom = "Dexcom"
- case alarm = "Alarm"
- case volumeButtonSnooze = "Volume Button Snooze"
- case calendar = "Calendar"
- case deviceStatus = "Device Status"
- case remote = "Remote"
- case telemetry = "Telemetry"
- }
- init() {
- let documentsDirectory = fileManager.urls(for: .documentDirectory, in: .userDomainMask).first!
- logDirectory = documentsDirectory.appendingPathComponent("Logs")
- if !fileManager.fileExists(atPath: logDirectory.path) {
- try? fileManager.createDirectory(at: logDirectory, withIntermediateDirectories: true, attributes: nil)
- }
- dateFormatter = DateFormatter()
- dateFormatter.dateFormat = "yyyy-MM-dd"
- }
- private func formattedLogMessage(for category: Category, message: String) -> String {
- let timestamp = DateFormatter.localizedString(from: Date(), dateStyle: .none, timeStyle: .medium)
- return "[\(timestamp)] [\(category.rawValue)] \(message)"
- }
- /// Logs a message with an optional rate limit.
- ///
- /// - Parameters:
- /// - category: The log category.
- /// - message: The message to log.
- /// - isDebug: Indicates if this is a debug log.
- /// - limitIdentifier: Optional key to rate-limit similar log messages.
- /// - limitInterval: Time interval (in seconds) to wait before logging the same type again.
- func log(category: Category, message: String, isDebug: Bool = false, limitIdentifier: String? = nil, limitInterval: TimeInterval = 300) {
- let safeMessage = LogRedactor.sweep(message)
- let logMessage = formattedLogMessage(for: category, message: safeMessage)
- consoleQueue.async {
- print(logMessage)
- }
- if category == .taskScheduler && isDebug {
- return
- }
- if let key = limitIdentifier, !Storage.shared.debugLogLevel.value {
- let shouldLog: Bool = rateLimitQueue.sync {
- if let lastLogged = lastLoggedTimestamps[key] {
- let interval = Date().timeIntervalSince(lastLogged)
- if interval < limitInterval {
- return false
- }
- }
- lastLoggedTimestamps[key] = Date()
- return true
- }
- if !shouldLog {
- return
- }
- }
- if !isDebug || Storage.shared.debugLogLevel.value {
- let logFileURL = currentLogFileURL
- writeVersionHeaderIfNeeded(for: logFileURL)
- append(logMessage + "\n", to: logFileURL)
- }
- }
- /// Helper method: checks if the log file is empty.
- private func isLogFileEmpty(at fileURL: URL) -> Bool {
- if !fileManager.fileExists(atPath: fileURL.path) { return true }
- if let attributes = try? fileManager.attributesOfItem(atPath: fileURL.path),
- let fileSize = attributes[.size] as? UInt64
- {
- return fileSize == 0
- }
- return false
- }
- /// Helper method: writes the version header if needed.
- private func writeVersionHeaderIfNeeded(for fileURL: URL) {
- if shouldLogVersionHeader || isLogFileEmpty(at: fileURL) {
- let versionManager = AppVersionManager()
- let version = versionManager.version()
- // Retrieve build details
- let buildDetails = BuildDetails.default
- let formattedBuildDate = dateTimeUtils.formattedDate(from: buildDetails.buildDate())
- let branchAndSha = buildDetails.branchAndSha
- let expiration = dateTimeUtils.formattedDate(from: buildDetails.calculateExpirationDate())
- let expirationHeaderString = buildDetails.expirationHeaderString
- let isMacApp = buildDetails.isMacApp()
- let isSimulatorBuild = buildDetails.isSimulatorBuild()
- let osLabel: String
- let osVersion: String
- if isMacApp {
- osLabel = "macOS"
- let v = ProcessInfo.processInfo.operatingSystemVersion
- osVersion = "\(v.majorVersion).\(v.minorVersion).\(v.patchVersion)"
- } else {
- osLabel = "iOS"
- osVersion = UIDevice.current.systemVersion
- }
- // Assemble header information
- var headerLines = [String]()
- headerLines.append("LoopFollow Version: \(version)")
- if !isMacApp, !isSimulatorBuild {
- headerLines.append("\(expirationHeaderString): \(expiration)")
- }
- headerLines.append("Built: \(formattedBuildDate)")
- headerLines.append("Branch: \(branchAndSha)")
- headerLines.append("\(osLabel): \(osVersion)")
- let headerMessage = headerLines.joined(separator: ", ") + "\n"
- let logMessage = formattedLogMessage(for: .general, message: headerMessage)
- append(logMessage, to: fileURL)
- shouldLogVersionHeader = false
- }
- }
- func cleanupOldLogs() {
- let today = dateFormatter.string(from: Date())
- let yesterday = dateFormatter.string(from: Calendar.current.date(byAdding: .day, value: -1, to: Date())!)
- do {
- let logFiles = try fileManager.contentsOfDirectory(at: logDirectory, includingPropertiesForKeys: nil)
- for logFile in logFiles {
- let filename = logFile.lastPathComponent
- if !filename.contains(today), !filename.contains(yesterday) {
- try fileManager.removeItem(at: logFile)
- }
- }
- } catch {
- print("Failed to clean up old logs: \(error)")
- }
- }
- func logFileURL(for date: Date) -> URL {
- let dateString = dateFormatter.string(from: date)
- return logDirectory.appendingPathComponent("LoopFollow \(dateString).log")
- }
- func logFilesForTodayAndYesterday() -> [URL] {
- let today = logFileURL(for: Date())
- let yesterday = logFileURL(for: Calendar.current.date(byAdding: .day, value: -1, to: Date())!)
- return [today, yesterday].filter { fileManager.fileExists(atPath: $0.path) }
- }
- var currentLogFileURL: URL {
- return logFileURL(for: Date())
- }
- private func append(_ message: String, to fileURL: URL) {
- if !fileManager.fileExists(atPath: fileURL.path) {
- fileManager.createFile(atPath: fileURL.path, contents: nil, attributes: nil)
- }
- if let fileHandle = try? FileHandle(forWritingTo: fileURL) {
- defer { fileHandle.closeFile() }
- fileHandle.seekToEndOfFile()
- if let data = message.data(using: .utf8) {
- fileHandle.write(data)
- }
- } else {
- print("Failed to open log file at \(fileURL.path)")
- }
- }
- }
|