| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201 |
- // LoopFollow
- // NightscoutSocketManager.swift
- import Foundation
- import SocketIO
- class NightscoutSocketManager {
- static let shared = NightscoutSocketManager()
- enum ConnectionState: String {
- case disconnected
- case connecting
- case connected
- case authenticated
- case error
- }
- private(set) var connectionState: ConnectionState = .disconnected {
- didSet {
- DispatchQueue.main.async {
- NotificationCenter.default.post(name: .nightscoutSocketStateChanged, object: nil)
- }
- }
- }
- private var manager: SocketManager?
- private var socket: SocketIOClient?
- private var currentURL: String = ""
- private var currentToken: String = ""
- var onDataUpdate: (([String: Any]) -> Void)?
- private init() {}
- // MARK: - Public API
- func connectIfNeeded() {
- guard Storage.shared.webSocketEnabled.value else {
- disconnect()
- return
- }
- // Sanitize defensively: values saved before this fix may still hold a stray
- // whitespace/control char that crashes Socket.IO's URL builder on iOS 26.
- let url = NightscoutUtils.sanitizeConnectionInput(Storage.shared.url.value)
- let token = NightscoutUtils.sanitizeConnectionInput(Storage.shared.token.value)
- guard !url.isEmpty else {
- disconnect()
- return
- }
- // Already connected to the same URL
- if connectionState == .authenticated || connectionState == .connecting || connectionState == .connected {
- if url == currentURL, token == currentToken {
- return
- }
- // URL or token changed, reconnect
- disconnect()
- }
- currentURL = url
- currentToken = token
- connect()
- }
- func disconnect() {
- let wasAuthenticated = connectionState == .authenticated
- socket?.removeAllHandlers()
- socket?.disconnect()
- manager?.disconnect()
- manager = nil
- socket = nil
- connectionState = .disconnected
- currentURL = ""
- currentToken = ""
- // While WS was delivering, each Nightscout poll was rescheduled with a multiplier on
- // the assumption WS would publish before the next REST tick. With WS gone, those
- // long delays would leave the user on stale data for ~10–15 minutes. Fire each poll
- // now; their actions will reschedule on the normal (un-multiplied) cadence going
- // forward, since connectionState is no longer .authenticated.
- //
- // Only catch up if we were actually authenticated — skip the no-op disconnect paths
- // (already-disconnected, never-connected) and the connectIfNeeded() reconnect dance,
- // where polls were already on a sensible schedule.
- if wasAuthenticated {
- let now = Date()
- TaskScheduler.shared.rescheduleTask(id: .fetchBG, to: now)
- TaskScheduler.shared.rescheduleTask(id: .deviceStatus, to: now)
- TaskScheduler.shared.rescheduleTask(id: .treatments, to: now)
- TaskScheduler.shared.rescheduleTask(id: .profile, to: now)
- }
- }
- // MARK: - Private
- private func connect() {
- guard let url = URL(string: currentURL) else {
- LogManager.shared.log(category: .websocket, message: "Invalid Nightscout URL for WebSocket")
- connectionState = .error
- return
- }
- connectionState = .connecting
- var config: SocketIOClientConfiguration = [
- .log(false),
- .compress,
- .forceWebsockets(false),
- .reconnects(true),
- .reconnectWait(5),
- .reconnectWaitMax(30),
- ]
- if !currentToken.isEmpty {
- config.insert(.connectParams(["token": currentToken]))
- }
- manager = SocketManager(socketURL: url, config: config)
- guard let mgr = manager else { return }
- socket = mgr.defaultSocket
- setupEventHandlers()
- socket?.connect()
- LogManager.shared.log(category: .websocket, message: "Connecting to Nightscout WebSocket at \(currentURL)")
- }
- private func setupEventHandlers() {
- guard let socket = socket else { return }
- socket.on(clientEvent: .connect) { [weak self] _, _ in
- guard let self = self else { return }
- LogManager.shared.log(category: .websocket, message: "Socket connected, authorizing...")
- self.connectionState = .connected
- self.authorize()
- }
- socket.on(clientEvent: .disconnect) { [weak self] data, _ in
- guard let self = self else { return }
- let reason = (data.first as? String) ?? "unknown"
- LogManager.shared.log(category: .websocket, message: "Socket disconnected: \(reason)")
- self.connectionState = .disconnected
- // Immediately restore normal polling intervals
- NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil)
- }
- socket.on(clientEvent: .reconnect) { _, _ in
- LogManager.shared.log(category: .websocket, message: "Socket reconnecting...")
- }
- socket.on(clientEvent: .error) { [weak self] data, _ in
- let errorMsg = (data.first as? String) ?? "unknown error"
- LogManager.shared.log(category: .websocket, message: "Socket error: \(errorMsg)")
- self?.connectionState = .error
- }
- socket.on("connected") { [weak self] _, _ in
- guard let self = self else { return }
- LogManager.shared.log(category: .websocket, message: "Authorized and receiving data")
- self.connectionState = .authenticated
- }
- socket.on("dataUpdate") { [weak self] data, _ in
- guard let self = self,
- let payload = data.first as? [String: Any]
- else { return }
- LogManager.shared.log(category: .websocket, message: "Received dataUpdate (delta: \(payload["delta"] as? Bool ?? false))", isDebug: true)
- DispatchQueue.main.async {
- self.onDataUpdate?(payload)
- }
- }
- }
- private func authorize() {
- var authPayload: [String: Any] = [
- "client": "LoopFollow",
- "history": 1,
- ]
- // Nightscout's authorization.resolve() expects:
- // - "token" field for JWT tokens (verified via verifyJWT)
- // - "secret" field for access tokens (checked via doesAccessTokenExist)
- // LoopFollow uses access tokens (e.g. "readable-xxxx"), so pass as "secret".
- if !currentToken.isEmpty {
- authPayload["secret"] = currentToken
- }
- socket?.emit("authorize", authPayload)
- }
- }
- extension Notification.Name {
- static let nightscoutSocketStateChanged = Notification.Name("nightscoutSocketStateChanged")
- }
|