NightscoutSocketManager.swift 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. // LoopFollow
  2. // NightscoutSocketManager.swift
  3. import Foundation
  4. import SocketIO
  5. class NightscoutSocketManager {
  6. static let shared = NightscoutSocketManager()
  7. enum ConnectionState: String {
  8. case disconnected
  9. case connecting
  10. case connected
  11. case authenticated
  12. case error
  13. }
  14. private(set) var connectionState: ConnectionState = .disconnected {
  15. didSet {
  16. DispatchQueue.main.async {
  17. NotificationCenter.default.post(name: .nightscoutSocketStateChanged, object: nil)
  18. }
  19. }
  20. }
  21. private var manager: SocketManager?
  22. private var socket: SocketIOClient?
  23. private var currentURL: String = ""
  24. private var currentToken: String = ""
  25. var onDataUpdate: (([String: Any]) -> Void)?
  26. private init() {}
  27. // MARK: - Public API
  28. func connectIfNeeded() {
  29. guard Storage.shared.webSocketEnabled.value else {
  30. disconnect()
  31. return
  32. }
  33. // Sanitize defensively: values saved before this fix may still hold a stray
  34. // whitespace/control char that crashes Socket.IO's URL builder on iOS 26.
  35. let url = NightscoutUtils.sanitizeConnectionInput(Storage.shared.url.value)
  36. let token = NightscoutUtils.sanitizeConnectionInput(Storage.shared.token.value)
  37. guard !url.isEmpty else {
  38. disconnect()
  39. return
  40. }
  41. // Already connected to the same URL
  42. if connectionState == .authenticated || connectionState == .connecting || connectionState == .connected {
  43. if url == currentURL, token == currentToken {
  44. return
  45. }
  46. // URL or token changed, reconnect
  47. disconnect()
  48. }
  49. currentURL = url
  50. currentToken = token
  51. connect()
  52. }
  53. func disconnect() {
  54. let wasAuthenticated = connectionState == .authenticated
  55. socket?.removeAllHandlers()
  56. socket?.disconnect()
  57. manager?.disconnect()
  58. manager = nil
  59. socket = nil
  60. connectionState = .disconnected
  61. currentURL = ""
  62. currentToken = ""
  63. // While WS was delivering, each Nightscout poll was rescheduled with a multiplier on
  64. // the assumption WS would publish before the next REST tick. With WS gone, those
  65. // long delays would leave the user on stale data for ~10–15 minutes. Fire each poll
  66. // now; their actions will reschedule on the normal (un-multiplied) cadence going
  67. // forward, since connectionState is no longer .authenticated.
  68. //
  69. // Only catch up if we were actually authenticated — skip the no-op disconnect paths
  70. // (already-disconnected, never-connected) and the connectIfNeeded() reconnect dance,
  71. // where polls were already on a sensible schedule.
  72. if wasAuthenticated {
  73. let now = Date()
  74. TaskScheduler.shared.rescheduleTask(id: .fetchBG, to: now)
  75. TaskScheduler.shared.rescheduleTask(id: .deviceStatus, to: now)
  76. TaskScheduler.shared.rescheduleTask(id: .treatments, to: now)
  77. TaskScheduler.shared.rescheduleTask(id: .profile, to: now)
  78. }
  79. }
  80. // MARK: - Private
  81. private func connect() {
  82. guard let url = URL(string: currentURL) else {
  83. LogManager.shared.log(category: .websocket, message: "Invalid Nightscout URL for WebSocket")
  84. connectionState = .error
  85. return
  86. }
  87. connectionState = .connecting
  88. var config: SocketIOClientConfiguration = [
  89. .log(false),
  90. .compress,
  91. .forceWebsockets(false),
  92. .reconnects(true),
  93. .reconnectWait(5),
  94. .reconnectWaitMax(30),
  95. ]
  96. if !currentToken.isEmpty {
  97. config.insert(.connectParams(["token": currentToken]))
  98. }
  99. manager = SocketManager(socketURL: url, config: config)
  100. guard let mgr = manager else { return }
  101. socket = mgr.defaultSocket
  102. setupEventHandlers()
  103. socket?.connect()
  104. LogManager.shared.log(category: .websocket, message: "Connecting to Nightscout WebSocket at \(currentURL)")
  105. }
  106. private func setupEventHandlers() {
  107. guard let socket = socket else { return }
  108. socket.on(clientEvent: .connect) { [weak self] _, _ in
  109. guard let self = self else { return }
  110. LogManager.shared.log(category: .websocket, message: "Socket connected, authorizing...")
  111. self.connectionState = .connected
  112. self.authorize()
  113. }
  114. socket.on(clientEvent: .disconnect) { [weak self] data, _ in
  115. guard let self = self else { return }
  116. let reason = (data.first as? String) ?? "unknown"
  117. LogManager.shared.log(category: .websocket, message: "Socket disconnected: \(reason)")
  118. self.connectionState = .disconnected
  119. // Immediately restore normal polling intervals
  120. NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil)
  121. }
  122. socket.on(clientEvent: .reconnect) { _, _ in
  123. LogManager.shared.log(category: .websocket, message: "Socket reconnecting...")
  124. }
  125. socket.on(clientEvent: .error) { [weak self] data, _ in
  126. let errorMsg = (data.first as? String) ?? "unknown error"
  127. LogManager.shared.log(category: .websocket, message: "Socket error: \(errorMsg)")
  128. self?.connectionState = .error
  129. }
  130. socket.on("connected") { [weak self] _, _ in
  131. guard let self = self else { return }
  132. LogManager.shared.log(category: .websocket, message: "Authorized and receiving data")
  133. self.connectionState = .authenticated
  134. }
  135. socket.on("dataUpdate") { [weak self] data, _ in
  136. guard let self = self,
  137. let payload = data.first as? [String: Any]
  138. else { return }
  139. LogManager.shared.log(category: .websocket, message: "Received dataUpdate (delta: \(payload["delta"] as? Bool ?? false))", isDebug: true)
  140. DispatchQueue.main.async {
  141. self.onDataUpdate?(payload)
  142. }
  143. }
  144. }
  145. private func authorize() {
  146. var authPayload: [String: Any] = [
  147. "client": "LoopFollow",
  148. "history": 1,
  149. ]
  150. // Nightscout's authorization.resolve() expects:
  151. // - "token" field for JWT tokens (verified via verifyJWT)
  152. // - "secret" field for access tokens (checked via doesAccessTokenExist)
  153. // LoopFollow uses access tokens (e.g. "readable-xxxx"), so pass as "secret".
  154. if !currentToken.isEmpty {
  155. authPayload["secret"] = currentToken
  156. }
  157. socket?.emit("authorize", authPayload)
  158. }
  159. }
  160. extension Notification.Name {
  161. static let nightscoutSocketStateChanged = Notification.Name("nightscoutSocketStateChanged")
  162. }