| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201 |
- // LoopFollow
- // NightscoutSettingsViewModel.swift
- import Combine
- import Foundation
- import SwiftUI
- class NightscoutSettingsViewModel: ObservableObject {
- private var initialURL: String
- private var initialToken: String
- /// Whether the Nightscout connection is successfully verified
- @Published var isConnected: Bool = false
- /// Whether this is a fresh setup (URL was empty when view appeared)
- private(set) var isFreshSetup: Bool = false
- @Published var nightscoutURL: String = Storage.shared.url.value {
- willSet {
- if newValue != nightscoutURL {
- Storage.shared.url.value = newValue
- triggerCheckStatus()
- }
- }
- }
- @Published var nightscoutToken: String = Storage.shared.token.value {
- willSet {
- if newValue != nightscoutToken {
- Storage.shared.token.value = NightscoutUtils.sanitizeConnectionInput(newValue)
- triggerCheckStatus()
- }
- }
- }
- @Published var nightscoutStatus: String = "Checking..."
- @Published var webSocketEnabled: Bool = Storage.shared.webSocketEnabled.value {
- didSet {
- Storage.shared.webSocketEnabled.value = webSocketEnabled
- if webSocketEnabled {
- NightscoutSocketManager.shared.connectIfNeeded()
- } else {
- NightscoutSocketManager.shared.disconnect()
- triggerRefresh()
- }
- }
- }
- @Published var webSocketStatus: String = "Disconnected"
- var webSocketStatusColor: Color {
- switch NightscoutSocketManager.shared.connectionState {
- case .authenticated: return .green
- case .connecting, .connected: return .orange
- case .disconnected: return .secondary
- case .error: return .red
- }
- }
- private var cancellables = Set<AnyCancellable>()
- private var checkStatusSubject = PassthroughSubject<Void, Never>()
- private var checkStatusWorkItem: DispatchWorkItem?
- init() {
- initialURL = Storage.shared.url.value
- initialToken = Storage.shared.token.value
- isFreshSetup = initialURL.isEmpty
- setupDebounce()
- checkNightscoutStatus()
- observeWebSocketState()
- }
- private func setupDebounce() {
- checkStatusSubject
- .debounce(for: .seconds(2), scheduler: DispatchQueue.main)
- .sink { [weak self] in
- self?.checkNightscoutStatus()
- }
- .store(in: &cancellables)
- }
- private func triggerCheckStatus() {
- checkStatusWorkItem?.cancel()
- nightscoutStatus = "Checking..."
- checkStatusWorkItem = DispatchWorkItem {
- self.checkStatusSubject.send()
- }
- DispatchQueue.main.asyncAfter(deadline: .now() + 0.1, execute: checkStatusWorkItem!)
- }
- func processURL(_ value: String) {
- // Strip whitespace/newlines/control chars first. A URL can't legally contain
- // them, and a stray one (e.g. a trailing newline from a paste) otherwise makes
- // URLComponents parsing fail and falls through to the lossy fallback below,
- // mangling a URL-with-embedded-token into an invalid address.
- let value = NightscoutUtils.sanitizeConnectionInput(value)
- var useTokenUrl = false
- if let urlComponents = URLComponents(string: value), let queryItems = urlComponents.queryItems {
- if let tokenItem = queryItems.first(where: { $0.name.lowercased() == "token" }) {
- let tokenPattern = "^[^-\\s]+-[0-9a-fA-F]{16}$"
- if let token = tokenItem.value, let _ = token.range(of: tokenPattern, options: .regularExpression) {
- var baseComponents = urlComponents
- baseComponents.queryItems = nil
- if let baseURL = baseComponents.string {
- nightscoutToken = token
- nightscoutURL = baseURL
- useTokenUrl = true
- }
- }
- }
- }
- if !useTokenUrl {
- let filtered = value.replacingOccurrences(of: "[^A-Za-z0-9:/._-]", with: "", options: .regularExpression).lowercased()
- var cleanURL = filtered
- while cleanURL.count > 8, cleanURL.last == "/" {
- cleanURL = String(cleanURL.dropLast())
- }
- nightscoutURL = cleanURL
- }
- }
- func checkNightscoutStatus() {
- NightscoutUtils.verifyURLAndToken { error, _, nsWriteAuth, nsAdminAuth in
- DispatchQueue.main.async {
- Storage.shared.nsWriteAuth.value = nsWriteAuth
- Storage.shared.nsAdminAuth.value = nsAdminAuth
- self.updateStatusLabel(error: error)
- }
- }
- }
- func updateStatusLabel(error: NightscoutUtils.NightscoutError?) {
- if let error = error {
- isConnected = false
- switch error {
- case .invalidURL:
- nightscoutStatus = "Invalid URL"
- case .networkError:
- nightscoutStatus = "Network Error"
- case .invalidToken:
- nightscoutStatus = "Invalid Token"
- case .tokenRequired:
- nightscoutStatus = "Token Required"
- case .siteNotFound:
- nightscoutStatus = "Site Not Found"
- case .unknown:
- nightscoutStatus = "Unknown Error"
- case .emptyAddress:
- nightscoutStatus = "Address Empty"
- }
- NightscoutSocketManager.shared.disconnect()
- } else {
- isConnected = true
- let authStatus: String
- if Storage.shared.nsAdminAuth.value {
- authStatus = "Admin"
- } else {
- authStatus = "Read" + (Storage.shared.nsWriteAuth.value ? " & Write" : "")
- }
- nightscoutStatus = "OK (\(authStatus))"
- if nightscoutURL != initialURL || nightscoutToken != initialToken {
- NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil)
- }
- }
- }
- private func triggerRefresh() {
- NotificationCenter.default.post(name: NSNotification.Name("refresh"), object: nil)
- }
- private func observeWebSocketState() {
- updateWebSocketStatus()
- NotificationCenter.default.publisher(for: .nightscoutSocketStateChanged)
- .receive(on: DispatchQueue.main)
- .sink { [weak self] _ in
- self?.updateWebSocketStatus()
- }
- .store(in: &cancellables)
- }
- private func updateWebSocketStatus() {
- switch NightscoutSocketManager.shared.connectionState {
- case .disconnected: webSocketStatus = "Disconnected"
- case .connecting: webSocketStatus = "Connecting..."
- case .connected: webSocketStatus = "Connected"
- case .authenticated: webSocketStatus = "Connected"
- case .error: webSocketStatus = "Error"
- }
- }
- }
|