NightscoutSettingsViewModel.swift 6.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201
  1. // LoopFollow
  2. // NightscoutSettingsViewModel.swift
  3. import Combine
  4. import Foundation
  5. import SwiftUI
  6. class NightscoutSettingsViewModel: ObservableObject {
  7. private var initialURL: String
  8. private var initialToken: String
  9. /// Whether the Nightscout connection is successfully verified
  10. @Published var isConnected: Bool = false
  11. /// Whether this is a fresh setup (URL was empty when view appeared)
  12. private(set) var isFreshSetup: Bool = false
  13. @Published var nightscoutURL: String = Storage.shared.url.value {
  14. willSet {
  15. if newValue != nightscoutURL {
  16. Storage.shared.url.value = newValue
  17. triggerCheckStatus()
  18. }
  19. }
  20. }
  21. @Published var nightscoutToken: String = Storage.shared.token.value {
  22. willSet {
  23. if newValue != nightscoutToken {
  24. Storage.shared.token.value = NightscoutUtils.sanitizeConnectionInput(newValue)
  25. triggerCheckStatus()
  26. }
  27. }
  28. }
  29. @Published var nightscoutStatus: String = "Checking..."
  30. @Published var webSocketEnabled: Bool = Storage.shared.webSocketEnabled.value {
  31. didSet {
  32. Storage.shared.webSocketEnabled.value = webSocketEnabled
  33. if webSocketEnabled {
  34. NightscoutSocketManager.shared.connectIfNeeded()
  35. } else {
  36. NightscoutSocketManager.shared.disconnect()
  37. triggerRefresh()
  38. }
  39. }
  40. }
  41. @Published var webSocketStatus: String = "Disconnected"
  42. var webSocketStatusColor: Color {
  43. switch NightscoutSocketManager.shared.connectionState {
  44. case .authenticated: return .green
  45. case .connecting, .connected: return .orange
  46. case .disconnected: return .secondary
  47. case .error: return .red
  48. }
  49. }
  50. private var cancellables = Set<AnyCancellable>()
  51. private var checkStatusSubject = PassthroughSubject<Void, Never>()
  52. private var checkStatusWorkItem: DispatchWorkItem?
  53. init() {
  54. initialURL = Storage.shared.url.value
  55. initialToken = Storage.shared.token.value
  56. isFreshSetup = initialURL.isEmpty
  57. setupDebounce()
  58. checkNightscoutStatus()
  59. observeWebSocketState()
  60. }
  61. private func setupDebounce() {
  62. checkStatusSubject
  63. .debounce(for: .seconds(2), scheduler: DispatchQueue.main)
  64. .sink { [weak self] in
  65. self?.checkNightscoutStatus()
  66. }
  67. .store(in: &cancellables)
  68. }
  69. private func triggerCheckStatus() {
  70. checkStatusWorkItem?.cancel()
  71. nightscoutStatus = "Checking..."
  72. checkStatusWorkItem = DispatchWorkItem {
  73. self.checkStatusSubject.send()
  74. }
  75. DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: checkStatusWorkItem!)
  76. }
  77. func processURL(_ value: String) {
  78. // Strip whitespace/newlines/control chars first. A URL can't legally contain
  79. // them, and a stray one (e.g. a trailing newline from a paste) otherwise makes
  80. // URLComponents parsing fail and falls through to the lossy fallback below,
  81. // mangling a URL-with-embedded-token into an invalid address.
  82. let value = NightscoutUtils.sanitizeConnectionInput(value)
  83. var useTokenUrl = false
  84. if let urlComponents = URLComponents(string: value), let queryItems = urlComponents.queryItems {
  85. if let tokenItem = queryItems.first(where: { $0.name.lowercased() == "token" }) {
  86. let tokenPattern = "^[^-\\s]+-[0-9a-fA-F]{16}$"
  87. if let token = tokenItem.value, let _ = token.range(of: tokenPattern, options: .regularExpression) {
  88. var baseComponents = urlComponents
  89. baseComponents.queryItems = nil
  90. if let baseURL = baseComponents.string {
  91. nightscoutToken = token
  92. nightscoutURL = baseURL
  93. useTokenUrl = true
  94. }
  95. }
  96. }
  97. }
  98. if !useTokenUrl {
  99. let filtered = value.replacingOccurrences(of: "[^A-Za-z0-9:/._-]", with: "", options: .regularExpression).lowercased()
  100. var cleanURL = filtered
  101. while cleanURL.count > 8, cleanURL.last == "/" {
  102. cleanURL = String(cleanURL.dropLast())
  103. }
  104. nightscoutURL = cleanURL
  105. }
  106. }
  107. func checkNightscoutStatus() {
  108. NightscoutUtils.verifyURLAndToken { error, _, nsWriteAuth, nsAdminAuth in
  109. DispatchQueue.main.async {
  110. Storage.shared.nsWriteAuth.value = nsWriteAuth
  111. Storage.shared.nsAdminAuth.value = nsAdminAuth
  112. self.updateStatusLabel(error: error)
  113. }
  114. }
  115. }
  116. func updateStatusLabel(error: NightscoutUtils.NightscoutError?) {
  117. if let error = error {
  118. isConnected = false
  119. switch error {
  120. case .invalidURL:
  121. nightscoutStatus = "Invalid URL"
  122. case .networkError:
  123. nightscoutStatus = "Network Error"
  124. case .invalidToken:
  125. nightscoutStatus = "Invalid Token"
  126. case .tokenRequired:
  127. nightscoutStatus = "Token Required"
  128. case .siteNotFound:
  129. nightscoutStatus = "Site Not Found"
  130. case .unknown:
  131. nightscoutStatus = "Unknown Error"
  132. case .emptyAddress:
  133. nightscoutStatus = "Address Empty"
  134. }
  135. NightscoutSocketManager.shared.disconnect()
  136. } else {
  137. isConnected = true
  138. let authStatus: String
  139. if Storage.shared.nsAdminAuth.value {
  140. authStatus = "Admin"
  141. } else {
  142. authStatus = "Read" + (Storage.shared.nsWriteAuth.value ? " & Write" : "")
  143. }
  144. nightscoutStatus = "OK (\(authStatus))"
  145. if nightscoutURL != initialURL || nightscoutToken != initialToken {
  146. NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil)
  147. }
  148. }
  149. }
  150. private func triggerRefresh() {
  151. NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil)
  152. }
  153. private func observeWebSocketState() {
  154. updateWebSocketStatus()
  155. NotificationCenter.default.publisher(for: .nightscoutSocketStateChanged)
  156. .receive(on: DispatchQueue.main)
  157. .sink { [weak self] _ in
  158. self?.updateWebSocketStatus()
  159. }
  160. .store(in: &cancellables)
  161. }
  162. private func updateWebSocketStatus() {
  163. switch NightscoutSocketManager.shared.connectionState {
  164. case .disconnected: webSocketStatus = "Disconnected"
  165. case .connecting: webSocketStatus = "Connecting..."
  166. case .connected: webSocketStatus = "Connected"
  167. case .authenticated: webSocketStatus = "Connected"
  168. case .error: webSocketStatus = "Error"
  169. }
  170. }
  171. }