WatchLogger.swift 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160
  1. import Foundation
  2. import WatchConnectivity
  3. actor WatchLogger {
  4. static let shared = WatchLogger()
  5. private var logs: [String] = []
  6. private let maxEntries = 3000
  7. private let flushInterval: TimeInterval = 3 * 60
  8. private let flushSizeThreshold = 1000
  9. private var lastFlush = Date()
  10. private let session = WCSession.default
  11. private var timerTask: Task<Void, Never>?
  12. private init() {
  13. Task {
  14. await startFlushTimer()
  15. }
  16. }
  17. private var dateFormatter: DateFormatter {
  18. let formatter = DateFormatter()
  19. formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ssZ"
  20. return formatter
  21. }
  22. private func startFlushTimer() async {
  23. timerTask = Task {
  24. while true {
  25. try? await Task.sleep(nanoseconds: UInt64(flushInterval * 1_000_000_000))
  26. await flushIfNeeded(force: false)
  27. }
  28. }
  29. }
  30. func log(
  31. _ message: String,
  32. force: Bool = false,
  33. function: String = #function,
  34. file: String = #fileID,
  35. line: Int = #line
  36. ) async {
  37. let shortFile = (file as NSString).lastPathComponent
  38. let timestamp = dateFormatter.string(from: Date())
  39. let entry = "[\(timestamp)] [\(shortFile):\(line)] \(function) → \(message)"
  40. logs.append(entry)
  41. if logs.count > maxEntries {
  42. logs.removeFirst(logs.count - maxEntries)
  43. }
  44. print(entry)
  45. await flushIfNeeded(force: force)
  46. }
  47. func flushIfNeeded(force: Bool = false) async {
  48. let now = Date()
  49. let shouldFlush = force || now.timeIntervalSince(lastFlush) >= flushInterval || logs.count >= flushSizeThreshold
  50. if shouldFlush {
  51. await flushToPhone()
  52. } else {
  53. await log("⌚️ Skipping flush — force: \(force), logs: \(logs.count), interval: \(now.timeIntervalSince(lastFlush))")
  54. }
  55. }
  56. private func flushToPhone() async {
  57. guard !logs.isEmpty else {
  58. await log("⌚️ No logs to flush")
  59. return
  60. }
  61. let payload: [String: Any] = ["watchLogs": logs.joined(separator: "\n")]
  62. if session.activationState != .activated {
  63. session.activate()
  64. }
  65. if session.isReachable {
  66. await log("⌚️ Sending logs to phone: \(logs.count) entries")
  67. session.sendMessage(payload, replyHandler: nil) { error in
  68. Task {
  69. await self.log("⌚️ Failed to flush logs to phone: \(error.localizedDescription)")
  70. await self.persistLogsLocally()
  71. }
  72. }
  73. } else {
  74. await log("⌚️ Phone not reachable, persisting logs locally")
  75. await persistLogsLocally()
  76. }
  77. lastFlush = Date()
  78. logs.removeAll()
  79. }
  80. func persistLogsLocally() async {
  81. let logDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
  82. .appendingPathComponent("logs", isDirectory: true)
  83. try? FileManager.default.createDirectory(at: logDir, withIntermediateDirectories: true)
  84. let logFile = logDir.appendingPathComponent("watch_log.txt")
  85. let previousLogFile = logDir.appendingPathComponent("watch_log_prev.txt")
  86. let startOfDay = Calendar.current.startOfDay(for: Date())
  87. if let attributes = try? FileManager.default.attributesOfItem(atPath: logFile.path),
  88. let creationDate = attributes[.creationDate] as? Date,
  89. creationDate < startOfDay
  90. {
  91. try? FileManager.default.removeItem(at: previousLogFile)
  92. try? FileManager.default.moveItem(at: logFile, to: previousLogFile)
  93. FileManager.default.createFile(atPath: logFile.path, contents: nil, attributes: [.creationDate: startOfDay])
  94. }
  95. let fullLog = logs.joined(separator: "\n") + "\n"
  96. if let data = fullLog.data(using: .utf8) {
  97. if let handle = try? FileHandle(forWritingTo: logFile) {
  98. _ = try? handle.seekToEnd()
  99. handle.write(data)
  100. try? handle.close()
  101. } else {
  102. try? data.write(to: logFile)
  103. }
  104. }
  105. await log("⌚️ Persisted \(logs.count) logs locally to \(logFile.lastPathComponent)")
  106. }
  107. func flushPersistedLogs() async {
  108. let logDir = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first!
  109. .appendingPathComponent("logs", isDirectory: true)
  110. let logFile = logDir.appendingPathComponent("watch_log.txt")
  111. guard let data = try? Data(contentsOf: logFile),
  112. let logString = String(data: data, encoding: .utf8),
  113. !logString.isEmpty
  114. else { return }
  115. let payload: [String: Any] = ["watchLogs": logString]
  116. if session.activationState != .activated {
  117. session.activate()
  118. }
  119. if session.isReachable {
  120. await log("⌚️ Sending persisted logs to phone")
  121. session.sendMessage(payload, replyHandler: nil) { error in
  122. Task {
  123. await self.log("⌚️ Failed to resend persisted logs: \(error.localizedDescription)")
  124. }
  125. }
  126. try? FileManager.default.removeItem(at: logFile)
  127. } else {
  128. await log("⌚️ Transferring persisted logs via userInfo")
  129. _ = session.transferUserInfo(payload)
  130. try? FileManager.default.removeItem(at: logFile)
  131. }
  132. }
  133. }