1 contributor
// MeterNameStore.swift
// USB Meter
//
// Created by Codex on 2026.
//
import Foundation
final class MeterNameStore {
struct Record: Identifiable {
let macAddress: String
let customName: String?
let temperatureUnit: String?
var id: String {
macAddress
}
}
enum CloudAvailability: Equatable {
case unknown
case available
case noAccount
case error(String)
var helpTitle: String {
switch self {
case .unknown:
return "Cloud Sync Status Unknown"
case .available:
return "Cloud Sync Ready"
case .noAccount:
return "Enable iCloud Drive"
case .error:
return "Cloud Sync Error"
}
}
var helpMessage: String {
switch self {
case .unknown:
return "The app is still checking whether iCloud sync is available on this device."
case .available:
return "iCloud sync is available for meter names and TC66 temperature preferences."
case .noAccount:
return "Meter names and TC66 temperature preferences sync through iCloud Drive. The app keeps a local copy too, but cross-device sync stays off until iCloud Drive is available."
case .error(let description):
return "The app keeps local values, but iCloud sync reported an error: \(description)"
}
}
}
static let shared = MeterNameStore()
private enum Keys {
static let localMeterNames = "MeterNameStore.localMeterNames"
static let localTemperatureUnits = "MeterNameStore.localTemperatureUnits"
static let cloudMeterNames = "MeterNameStore.cloudMeterNames"
static let cloudTemperatureUnits = "MeterNameStore.cloudTemperatureUnits"
}
private let defaults = UserDefaults.standard
private let ubiquitousStore = NSUbiquitousKeyValueStore.default
private let workQueue = DispatchQueue(label: "MeterNameStore.Queue")
private var cloudAvailability: CloudAvailability = .unknown
private var ubiquitousObserver: NSObjectProtocol?
private var ubiquityIdentityObserver: NSObjectProtocol?
private init() {
ubiquitousObserver = NotificationCenter.default.addObserver(
forName: NSUbiquitousKeyValueStore.didChangeExternallyNotification,
object: ubiquitousStore,
queue: nil
) { [weak self] notification in
self?.handleUbiquitousStoreChange(notification)
}
ubiquityIdentityObserver = NotificationCenter.default.addObserver(
forName: NSNotification.Name.NSUbiquityIdentityDidChange,
object: nil,
queue: nil
) { [weak self] _ in
self?.refreshCloudAvailability(reason: "identity-changed")
self?.syncLocalValuesToCloudIfPossible(reason: "identity-changed")
}
refreshCloudAvailability(reason: "startup")
ubiquitousStore.synchronize()
syncLocalValuesToCloudIfPossible(reason: "startup")
}
var currentCloudAvailability: CloudAvailability {
workQueue.sync {
cloudAvailability
}
}
func name(for macAddress: String) -> String? {
let normalizedMAC = normalizedMACAddress(macAddress)
guard !normalizedMAC.isEmpty else { return nil }
return mergedDictionary(localKey: Keys.localMeterNames, cloudKey: Keys.cloudMeterNames)[normalizedMAC]
}
func temperatureUnitRawValue(for macAddress: String) -> String? {
let normalizedMAC = normalizedMACAddress(macAddress)
guard !normalizedMAC.isEmpty else { return nil }
return mergedDictionary(localKey: Keys.localTemperatureUnits, cloudKey: Keys.cloudTemperatureUnits)[normalizedMAC]
}
func upsert(macAddress: String, name: String?, temperatureUnitRawValue: String?) {
let normalizedMAC = normalizedMACAddress(macAddress)
guard !normalizedMAC.isEmpty else {
track("MeterNameStore ignored upsert with invalid MAC '\(macAddress)'")
return
}
var didChange = false
if let name {
didChange = updateDictionaryValue(
for: normalizedMAC,
value: normalizedName(name),
localKey: Keys.localMeterNames,
cloudKey: Keys.cloudMeterNames
) || didChange
}
if let temperatureUnitRawValue {
didChange = updateDictionaryValue(
for: normalizedMAC,
value: normalizedTemperatureUnit(temperatureUnitRawValue),
localKey: Keys.localTemperatureUnits,
cloudKey: Keys.cloudTemperatureUnits
) || didChange
}
if didChange {
notifyChange()
}
}
func allRecords() -> [Record] {
let names = mergedDictionary(localKey: Keys.localMeterNames, cloudKey: Keys.cloudMeterNames)
let temperatureUnits = mergedDictionary(localKey: Keys.localTemperatureUnits, cloudKey: Keys.cloudTemperatureUnits)
let macAddresses = Set(names.keys).union(temperatureUnits.keys)
return macAddresses.sorted().map { macAddress in
Record(
macAddress: macAddress,
customName: names[macAddress],
temperatureUnit: temperatureUnits[macAddress]
)
}
}
private func normalizedMACAddress(_ macAddress: String) -> String {
macAddress.trimmingCharacters(in: .whitespacesAndNewlines).uppercased()
}
private func normalizedName(_ name: String?) -> String? {
guard let trimmed = name?.trimmingCharacters(in: .whitespacesAndNewlines),
!trimmed.isEmpty else {
return nil
}
return trimmed
}
private func normalizedTemperatureUnit(_ value: String?) -> String? {
guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines),
!trimmed.isEmpty else {
return nil
}
return trimmed
}
private func dictionary(for key: String, store: KeyValueReading) -> [String: String] {
(store.object(forKey: key) as? [String: String]) ?? [:]
}
private func mergedDictionary(localKey: String, cloudKey: String) -> [String: String] {
let localValues = dictionary(for: localKey, store: defaults)
let cloudValues = dictionary(for: cloudKey, store: ubiquitousStore)
return localValues.merging(cloudValues) { _, cloudValue in
cloudValue
}
}
@discardableResult
private func updateDictionaryValue(
for macAddress: String,
value: String?,
localKey: String,
cloudKey: String
) -> Bool {
var localValues = dictionary(for: localKey, store: defaults)
let didChangeLocal = setDictionaryValue(&localValues, for: macAddress, value: value)
if didChangeLocal {
defaults.set(localValues, forKey: localKey)
}
var didChangeCloud = false
if isICloudDriveAvailable {
var cloudValues = dictionary(for: cloudKey, store: ubiquitousStore)
didChangeCloud = setDictionaryValue(&cloudValues, for: macAddress, value: value)
if didChangeCloud {
ubiquitousStore.set(cloudValues, forKey: cloudKey)
ubiquitousStore.synchronize()
}
}
return didChangeLocal || didChangeCloud
}
@discardableResult
private func setDictionaryValue(
_ dictionary: inout [String: String],
for macAddress: String,
value: String?
) -> Bool {
let currentValue = dictionary[macAddress]
guard currentValue != value else { return false }
if let value {
dictionary[macAddress] = value
} else {
dictionary.removeValue(forKey: macAddress)
}
return true
}
private var isICloudDriveAvailable: Bool {
FileManager.default.ubiquityIdentityToken != nil
}
private func refreshCloudAvailability(reason: String) {
let newAvailability: CloudAvailability = isICloudDriveAvailable ? .available : .noAccount
var shouldNotify = false
workQueue.sync {
guard cloudAvailability != newAvailability else { return }
cloudAvailability = newAvailability
shouldNotify = true
}
guard shouldNotify else { return }
track("MeterNameStore iCloud availability (\(reason)): \(newAvailability)")
DispatchQueue.main.async {
NotificationCenter.default.post(name: .meterNameStoreCloudStatusDidChange, object: nil)
}
}
private func handleUbiquitousStoreChange(_ notification: Notification) {
refreshCloudAvailability(reason: "ubiquitous-store-change")
if let changedKeys = notification.userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? [String], !changedKeys.isEmpty {
track("MeterNameStore received ubiquitous changes for keys: \(changedKeys.joined(separator: ", "))")
}
notifyChange()
}
private func syncLocalValuesToCloudIfPossible(reason: String) {
guard isICloudDriveAvailable else {
refreshCloudAvailability(reason: reason)
return
}
let localNames = dictionary(for: Keys.localMeterNames, store: defaults)
let localTemperatureUnits = dictionary(for: Keys.localTemperatureUnits, store: defaults)
var cloudNames = dictionary(for: Keys.cloudMeterNames, store: ubiquitousStore)
var cloudTemperatureUnits = dictionary(for: Keys.cloudTemperatureUnits, store: ubiquitousStore)
let mergedNames = cloudNames.merging(localNames) { cloudValue, _ in
cloudValue
}
let mergedTemperatureUnits = cloudTemperatureUnits.merging(localTemperatureUnits) { cloudValue, _ in
cloudValue
}
var didChange = false
if cloudNames != mergedNames {
cloudNames = mergedNames
ubiquitousStore.set(cloudNames, forKey: Keys.cloudMeterNames)
didChange = true
}
if cloudTemperatureUnits != mergedTemperatureUnits {
cloudTemperatureUnits = mergedTemperatureUnits
ubiquitousStore.set(cloudTemperatureUnits, forKey: Keys.cloudTemperatureUnits)
didChange = true
}
refreshCloudAvailability(reason: reason)
if didChange {
ubiquitousStore.synchronize()
track("MeterNameStore pushed local fallback values into iCloud KVS (\(reason)).")
notifyChange()
}
}
private func notifyChange() {
DispatchQueue.main.async {
NotificationCenter.default.post(name: .meterNameStoreDidChange, object: nil)
}
}
deinit {
if let observer = ubiquitousObserver {
NotificationCenter.default.removeObserver(observer)
}
if let observer = ubiquityIdentityObserver {
NotificationCenter.default.removeObserver(observer)
}
}
}
private protocol KeyValueReading {
func object(forKey defaultName: String) -> Any?
}
extension UserDefaults: KeyValueReading {}
extension NSUbiquitousKeyValueStore: KeyValueReading {}
extension Notification.Name {
static let meterNameStoreDidChange = Notification.Name("MeterNameStoreDidChange")
static let meterNameStoreCloudStatusDidChange = Notification.Name("MeterNameStoreCloudStatusDidChange")
}