NightscoutSettingsViewModel.swift 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437
  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. /// The most recent verification error, kept so the onboarding address page can
  31. /// tell "reachable Nightscout that needs a token" apart from "can't reach it".
  32. @Published var lastError: NightscoutUtils.NightscoutError?
  33. /// True when the most recent error means the site is a reachable Nightscout
  34. /// that simply needs (a different) token.
  35. private var errorIsTokenRelated: Bool {
  36. switch lastError {
  37. case .tokenRequired, .invalidToken: return true
  38. default: return false
  39. }
  40. }
  41. /// The site responded as a Nightscout instance, even if it needs a token.
  42. var addressReachable: Bool {
  43. isConnected || errorIsTokenRelated
  44. }
  45. /// The site is reachable but requires a token we don't have yet.
  46. var addressNeedsToken: Bool {
  47. !isConnected && errorIsTokenRelated
  48. }
  49. @Published var webSocketEnabled: Bool = Storage.shared.webSocketEnabled.value {
  50. didSet {
  51. Storage.shared.webSocketEnabled.value = webSocketEnabled
  52. if webSocketEnabled {
  53. NightscoutSocketManager.shared.connectIfNeeded()
  54. } else {
  55. NightscoutSocketManager.shared.disconnect()
  56. triggerRefresh()
  57. }
  58. }
  59. }
  60. @Published var webSocketStatus: String = "Disconnected"
  61. var webSocketStatusColor: Color {
  62. switch NightscoutSocketManager.shared.connectionState {
  63. case .authenticated: return .green
  64. case .connecting, .connected: return .orange
  65. case .disconnected: return .secondary
  66. case .error: return .red
  67. }
  68. }
  69. private var cancellables = Set<AnyCancellable>()
  70. private var checkStatusSubject = PassthroughSubject<Void, Never>()
  71. private var checkStatusWorkItem: DispatchWorkItem?
  72. /// While confirming a freshly provisioned token, the retry loop owns the
  73. /// status label, so the ordinary debounced check is suppressed to avoid
  74. /// flickering "Invalid Token" before the server has caught up.
  75. private var isConfirmingProvisionedToken = false
  76. /// Set when a token we just created is correct (the create call returned its
  77. /// id, so the secret was valid and the token is a deterministic function of
  78. /// it) but the site hasn't started accepting it yet. Some hosts only reload
  79. /// their auth cache on a restart, which can take minutes — far longer than we
  80. /// can spin during onboarding — so this is treated as a success-pending state
  81. /// the user can proceed from, not an error.
  82. @Published private(set) var provisionedTokenPending = false
  83. /// True while a read-only token is being created from the API secret.
  84. @Published var isProvisioningToken = false
  85. /// The most recent token-provisioning failure, for inline display.
  86. @Published var tokenProvisionError: String?
  87. init() {
  88. initialURL = Storage.shared.url.value
  89. initialToken = Storage.shared.token.value
  90. isFreshSetup = initialURL.isEmpty
  91. setupDebounce()
  92. checkNightscoutStatus()
  93. observeWebSocketState()
  94. }
  95. private func setupDebounce() {
  96. checkStatusSubject
  97. .debounce(for: .seconds(2), scheduler: DispatchQueue.main)
  98. .sink { [weak self] in
  99. self?.checkNightscoutStatus()
  100. }
  101. .store(in: &cancellables)
  102. }
  103. private func triggerCheckStatus() {
  104. checkStatusWorkItem?.cancel()
  105. // Any manual edit invalidates a pending-provisioned state and any earlier
  106. // "this is your API secret" verdict.
  107. provisionedTokenPending = false
  108. tokenIsVerifiedSecret = false
  109. nightscoutStatus = "Checking..."
  110. checkStatusWorkItem = DispatchWorkItem {
  111. self.checkStatusSubject.send()
  112. }
  113. DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: checkStatusWorkItem!)
  114. }
  115. func processURL(_ value: String) {
  116. // Strip whitespace/newlines/control chars first. A URL can't legally contain
  117. // them, and a stray one (e.g. a trailing newline from a paste) otherwise makes
  118. // URLComponents parsing fail and falls through to the lossy fallback below,
  119. // mangling a URL-with-embedded-token into an invalid address.
  120. let value = NightscoutUtils.sanitizeConnectionInput(value)
  121. var useTokenUrl = false
  122. if let urlComponents = URLComponents(string: value), let queryItems = urlComponents.queryItems {
  123. if let tokenItem = queryItems.first(where: { $0.name.lowercased() == "token" }) {
  124. let tokenPattern = "^[^-\\s]+-[0-9a-fA-F]{16}$"
  125. if let token = tokenItem.value, let _ = token.range(of: tokenPattern, options: .regularExpression) {
  126. var baseComponents = urlComponents
  127. baseComponents.queryItems = nil
  128. if let baseURL = baseComponents.string {
  129. nightscoutToken = token
  130. nightscoutURL = baseURL
  131. useTokenUrl = true
  132. }
  133. }
  134. }
  135. }
  136. if !useTokenUrl {
  137. let filtered = value.replacingOccurrences(of: "[^A-Za-z0-9:/._-]", with: "", options: .regularExpression).lowercased()
  138. var cleanURL = filtered
  139. while cleanURL.count > 8, cleanURL.last == "/" {
  140. cleanURL = String(cleanURL.dropLast())
  141. }
  142. nightscoutURL = cleanURL
  143. }
  144. }
  145. func checkNightscoutStatus() {
  146. if isConfirmingProvisionedToken { return }
  147. NightscoutUtils.verifyURLAndToken { error, _, nsWriteAuth, nsAdminAuth in
  148. DispatchQueue.main.async {
  149. Storage.shared.nsWriteAuth.value = nsWriteAuth
  150. Storage.shared.nsAdminAuth.value = nsAdminAuth
  151. self.updateStatusLabel(error: error)
  152. }
  153. }
  154. }
  155. /// Applies a token that LoopFollow just created and confirms it works.
  156. ///
  157. /// A freshly created subject isn't always recognized immediately: each
  158. /// Nightscout server instance only reloads its in-memory subject cache on a
  159. /// write, and multi-instance deployments don't share that cache — so the
  160. /// first validation can be routed to an instance that hasn't caught up yet.
  161. /// Rather than fail (and make the user tap again), we poll for a few seconds
  162. /// with a reassuring status before surfacing any error.
  163. func confirmProvisionedToken(_ token: String) {
  164. isConfirmingProvisionedToken = true
  165. provisionedTokenPending = false
  166. isConnected = false
  167. nightscoutStatus = "Finishing connection…"
  168. nightscoutToken = token
  169. verifyProvisionedTokenLoop(attempt: 0)
  170. }
  171. private func verifyProvisionedTokenLoop(attempt: Int) {
  172. let maxAttempts = 8
  173. NightscoutUtils.verifyURLAndToken { [weak self] error, _, nsWriteAuth, nsAdminAuth in
  174. DispatchQueue.main.async {
  175. guard let self else { return }
  176. if error == nil {
  177. self.isConfirmingProvisionedToken = false
  178. self.provisionedTokenPending = false
  179. Storage.shared.nsWriteAuth.value = nsWriteAuth
  180. Storage.shared.nsAdminAuth.value = nsAdminAuth
  181. self.updateStatusLabel(error: nil)
  182. } else if attempt + 1 < maxAttempts {
  183. self.nightscoutStatus = "Finishing connection…"
  184. let delay = min(0.5 + Double(attempt) * 0.25, 2.0)
  185. DispatchQueue.main.asyncAfter(deadline: .now() + delay) {
  186. self.verifyProvisionedTokenLoop(attempt: attempt + 1)
  187. }
  188. } else {
  189. // The token is correct but the site hasn't started accepting
  190. // it yet. Surface a calm "pending" state the user can proceed
  191. // from rather than a red error.
  192. self.isConfirmingProvisionedToken = false
  193. self.provisionedTokenPending = true
  194. self.lastError = nil
  195. }
  196. }
  197. }
  198. }
  199. // MARK: - Token provisioning from API secret
  200. /// Nightscout access tokens look like `name-<16 hex>`; anything else the user
  201. /// puts in the token field — most often their API secret — won't match.
  202. private static let tokenFormat = "^[^-\\s]+-[0-9a-fA-F]{16}$"
  203. private func looksLikeToken(_ value: String) -> Bool {
  204. value.range(of: Self.tokenFormat, options: .regularExpression) != nil
  205. }
  206. /// Set once the value in the token field has been confirmed to authenticate as
  207. /// the site's API secret. Drives the "create a token from this" suggestion.
  208. /// This is a verified result (an actual auth probe), not a format guess, so it
  209. /// only appears when creating a token will genuinely work.
  210. @Published private(set) var tokenIsVerifiedSecret = false
  211. /// Bumped on every edit so a slow verification for an old value can't land on a
  212. /// newer one.
  213. private var secretCheckGeneration = 0
  214. /// After a failed token check, find out whether what the user pasted is in fact
  215. /// the API secret — verified against the server, not guessed from its shape.
  216. private func verifyTokenIsSecret() {
  217. let candidate = nightscoutToken
  218. guard !candidate.isEmpty, !looksLikeToken(candidate) else {
  219. tokenIsVerifiedSecret = false
  220. return
  221. }
  222. secretCheckGeneration += 1
  223. let generation = secretCheckGeneration
  224. let url = nightscoutURL
  225. Task {
  226. let isSecret = await NightscoutUtils.verifyAPISecret(url: url, secret: candidate)
  227. await MainActor.run {
  228. guard generation == self.secretCheckGeneration,
  229. self.nightscoutToken == candidate else { return }
  230. self.tokenIsVerifiedSecret = isSecret
  231. }
  232. }
  233. }
  234. /// Creates (or reuses) a read-only token from the given API secret and applies
  235. /// it. The secret authorizes the create call only and is never stored.
  236. func createReadOnlyToken(fromSecret secret: String) {
  237. let trimmed = secret.trimmingCharacters(in: .whitespacesAndNewlines)
  238. guard !trimmed.isEmpty else { return }
  239. tokenProvisionError = nil
  240. isProvisioningToken = true
  241. let url = nightscoutURL
  242. Task {
  243. do {
  244. let token = try await NightscoutUtils.provisionReadOnlyToken(url: url, secret: trimmed)
  245. await MainActor.run {
  246. self.isProvisioningToken = false
  247. self.confirmProvisionedToken(token)
  248. }
  249. } catch {
  250. await MainActor.run {
  251. self.isProvisioningToken = false
  252. self.tokenProvisionError = NightscoutSettingsViewModel.provisioningMessage(for: error)
  253. }
  254. }
  255. }
  256. }
  257. static func provisioningMessage(for error: Error) -> String {
  258. guard let nsError = error as? NightscoutUtils.NightscoutError else {
  259. return "Could not create a token. Please try again."
  260. }
  261. switch nsError {
  262. case .invalidToken:
  263. return "That API secret was rejected. Check it and try again."
  264. case .invalidURL, .emptyAddress:
  265. return "Please enter a valid site URL first."
  266. case .siteNotFound:
  267. return "Couldn't reach that site. Check the URL."
  268. case .networkError:
  269. return "Network error. Check your connection and try again."
  270. case .tokenRequired, .unknown:
  271. return "Could not create a token. Please try again."
  272. }
  273. }
  274. func updateStatusLabel(error: NightscoutUtils.NightscoutError?) {
  275. lastError = error
  276. if let error = error {
  277. isConnected = false
  278. switch error {
  279. case .invalidURL:
  280. nightscoutStatus = "Invalid URL"
  281. case .networkError:
  282. nightscoutStatus = "Network Error"
  283. case .invalidToken:
  284. nightscoutStatus = "Invalid Token"
  285. case .tokenRequired:
  286. nightscoutStatus = "Token Required"
  287. case .siteNotFound:
  288. nightscoutStatus = "Site Not Found"
  289. case .unknown:
  290. nightscoutStatus = "Unknown Error"
  291. case .emptyAddress:
  292. nightscoutStatus = "Address Empty"
  293. }
  294. NightscoutSocketManager.shared.disconnect()
  295. // A site that's reachable but rejects the value as a token is the one
  296. // case where the user may have pasted their API secret — verify it so
  297. // we can offer to turn it into a token.
  298. switch error {
  299. case .invalidToken, .tokenRequired:
  300. verifyTokenIsSecret()
  301. default:
  302. tokenIsVerifiedSecret = false
  303. }
  304. } else {
  305. isConnected = true
  306. tokenIsVerifiedSecret = false
  307. let authStatus: String
  308. if Storage.shared.nsAdminAuth.value {
  309. authStatus = "Admin"
  310. } else {
  311. authStatus = "Read" + (Storage.shared.nsWriteAuth.value ? " & Write" : "")
  312. }
  313. nightscoutStatus = "OK (\(authStatus))"
  314. if nightscoutURL != initialURL || nightscoutToken != initialToken {
  315. NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil)
  316. }
  317. }
  318. }
  319. private func triggerRefresh() {
  320. NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil)
  321. }
  322. // MARK: - Adaptive status (onboarding)
  323. enum ConnectionStatusKind {
  324. case idle
  325. case checking
  326. case needsToken
  327. case pending
  328. case connected
  329. case error
  330. }
  331. /// A coarse status used to drive the onboarding status pill's color and icon.
  332. var statusKind: ConnectionStatusKind {
  333. if isConfirmingProvisionedToken { return .checking }
  334. if nightscoutURL.isEmpty { return .idle }
  335. if isConnected { return .connected }
  336. // Token created and correct, just not accepted by the site yet.
  337. if provisionedTokenPending { return .pending }
  338. if nightscoutStatus == "Checking..." { return .checking }
  339. // The site is reachable and simply needs a token — that's an expected
  340. // step, not an error, so it's shown positively rather than red.
  341. if addressNeedsToken { return .needsToken }
  342. return .error
  343. }
  344. /// A friendly, contextual status line that updates as the user fills fields,
  345. /// rather than a fixed "Status" label that can read as stale.
  346. var friendlyStatus: String {
  347. switch statusKind {
  348. case .idle:
  349. return "Enter your site address to connect."
  350. case .checking:
  351. return isConfirmingProvisionedToken ? "Finishing connection…" : "Checking your connection…"
  352. case .needsToken:
  353. return "Site found — it needs a token."
  354. case .pending:
  355. return "Token created. Your site can take a few minutes to start accepting it — you can continue."
  356. case .connected:
  357. if Storage.shared.nsAdminAuth.value { return "Connected — admin access" }
  358. if Storage.shared.nsWriteAuth.value { return "Connected — read & write" }
  359. return "Connected — read-only"
  360. case .error:
  361. return nightscoutStatus
  362. }
  363. }
  364. private func observeWebSocketState() {
  365. updateWebSocketStatus()
  366. NotificationCenter.default.publisher(for: .nightscoutSocketStateChanged)
  367. .receive(on: DispatchQueue.main)
  368. .sink { [weak self] _ in
  369. self?.updateWebSocketStatus()
  370. }
  371. .store(in: &cancellables)
  372. }
  373. private func updateWebSocketStatus() {
  374. switch NightscoutSocketManager.shared.connectionState {
  375. case .disconnected: webSocketStatus = "Disconnected"
  376. case .connecting: webSocketStatus = "Connecting..."
  377. case .connected: webSocketStatus = "Connected"
  378. case .authenticated: webSocketStatus = "Connected"
  379. case .error: webSocketStatus = "Error"
  380. }
  381. }
  382. }