// // DeviceUUID.swift // // // Created Nut.Tech on 02.08.2022. // #if os(iOS) import UIKit #elseif os(macOS) import AppKit #endif import KeyChainAccess /// Class allow retrive UUID with different life cycle. final public class DeviceUUID { private enum Constants { enum Keys { static let installationUUIDKey = "installationUUIDKey" static let deviceUUIDKey = "deviceUUIDKey" static let devicesUUIDsKey = "devicesUUIDsKey" static let devicesUUIDsToggleKey = "devicesUUIDsToggleKey" static let devicesUUIDs = "devicesUUIDs" } enum Locals { static let DevicesUUIDsDidChangeNotification = NSNotification.Name(rawValue: "DevicesUUIDsDidChangeNotification") } } /// Shared instance for class static public var shared = DeviceUUID() /// Changes each time the app gets launched (persistent to session). lazy private(set) public var session: String = { self.generateUuid() }() /// Changes each time the app gets installed (persistent to installation). lazy private(set) public var installation: String = { self.getValue(forKey: Constants.Keys.installationUUIDKey, defaultValue: nil, keychain: false, synchronizable: false) }() /// Changes each time all the apps of the same vendor are uninstalled (this works exactly as identifierForVendor). lazy private(set) public var vendor: String? = { #if os(iOS) UIDevice.current .identifierForVendor? .uuidString .lowercased() .replacingOccurrences(of: "-", with: "") #elseif os(macOS) return nil #endif }() /// Changes only on system reset, this is the best replacement to the good old udid (persistent to device) lazy private(set) public var device: String = { self.getValue(forKey: Constants.Keys.deviceUUIDKey, defaultValue: nil, synchronizable: false) }() /// List of all uuidForDevice of the same user. /// In this way it's possible manage guest accounts across multiple devices easily lazy private(set) public var devices: [String] = { let devicesString = self.getValue(forKey: Constants.Keys.devicesUUIDsKey, defaultValue: self.device) return devicesString.components(separatedBy: "|") }() /// Changes each time (no persistent), but allows to keep in memory more temporary uuids. public func uuid(forKey key: String) -> String { guard let uuid = self.uuids[key] else { let value = self.generateUuid() self.uuids[key] = value return value } return uuid } /// Changes each time (no persistent) public func generateUuid() -> String { let uuidRef = CFUUIDCreate(nil) let uuidStringRef = CFUUIDCreateString(nil, uuidRef) return ((uuidStringRef as? String) ?? "") .lowercased() .replacingOccurrences(of: "-", with: "") } // MARK: - Private private var uuids = [String: String]() private var isCloudAvailable = false // MARK: - Init init() { self.initCloudUUIDsDevices() } // MARK: - Internal func getValue(forKey key: String, defaultValue: String? = nil, userDefaults: Bool = true, keychain: Bool = true, service: String? = nil, accessGroup: String? = nil, synchronizable: Bool = true) -> String { if let newValue = Self.getValue(forKey: key, userDefaults: userDefaults, keychain: keychain, service: service, accessGroup: accessGroup) { return newValue } else { let value = defaultValue ?? self.generateUuid() Self.setValue(value, forKey: key, userDefaults: userDefaults, keychain: keychain, service: service, accessGroup: accessGroup, synchronizable: synchronizable) return value } } public func uuidForDeviceMigratingValue(forKey key: String, service: String? = nil, accessGroup: String? = nil, commitMigration: Bool) -> String? { if let uuidToMigrate = Self.getValue(forKey: key, service: service, accessGroup: accessGroup) { return self.uuid(forDeviceMigratingValue: uuidToMigrate, commitMigration: commitMigration) } return nil } func updateUUIDDevice(value: String) { self.device = value Self.setValue(value, forKey: Constants.Keys.deviceUUIDKey, synchronizable: false) } func uuid(forDeviceMigratingValue value: String, commitMigration: Bool) -> String? { if self.isValidUUID(value) { let oldValue = self.device let newValue = value guard oldValue != newValue else { return oldValue } if commitMigration { self.updateUUIDDevice(value: newValue) let deviceSet = NSMutableOrderedSet(array: self.devices) deviceSet.add(newValue) deviceSet.remove(oldValue) if let uuidsArray = deviceSet.array as? [String] { self.updateUUIDsDevices(with: uuidsArray) } self.syncCloudUUIDsDevices() return self.device } else { return oldValue } } else { let exception = NSException(name: NSExceptionName(rawValue: "Invalid uuid to migrate"), reason: "uuid value should be a string of 32 or 36 characters.") exception.raise() return nil } } private func initCloudUUIDsDevices() { self.isCloudAvailable = false guard FileManager.default.ubiquityIdentityToken != nil else { return } self.isCloudAvailable = true NotificationCenter.default.addObserver(self, selector: #selector(self.changesCloudUUIDsDevicesNotification), name: NSUbiquitousKeyValueStore.didChangeExternallyNotification, object: nil) self.syncCloudUUIDsDevices() } private func syncCloudUUIDsDevices() { if self.isCloudAvailable { let iCloud = NSUbiquitousKeyValueStore.default //if keychain contains more device identifiers than icloud, maybe that icloud has been empty, so re-write these identifiers to iCloud for uuidOfUserDevice in self.devices { let uuidOfUserDeviceAsKey = "\(Constants.Keys.deviceUUIDKey)_\(uuidOfUserDevice)" if iCloud.string(forKey: uuidOfUserDeviceAsKey) != uuidOfUserDevice { iCloud.set(uuidOfUserDevice, forKey: uuidOfUserDeviceAsKey) } } //toggle a boolean value to force notification on other devices, useful for debug let uuidsOfUserDevicesToggler = !iCloud.bool(forKey: Constants.Keys.devicesUUIDsToggleKey) iCloud.set(uuidsOfUserDevicesToggler, forKey: Constants.Keys.devicesUUIDsToggleKey) iCloud.synchronize() } } private func updateUUIDsDevices(with value: [String]) { self.devices = value Self.setValue(value.joined(separator: "|"), forKey: Constants.Keys.devicesUUIDsKey) } private func isValidUUID(_ value: String) -> Bool { let pattern = "^[0-9a-f]{32}|[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$" guard let regExp = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) else { return false } let uuidValueRange = NSRange(location: 0, length: value.count) let matchRange = regExp.rangeOfFirstMatch(in: value, options: [], range: uuidValueRange) var matchValue: String? if !NSEqualRanges(matchRange, NSRange(location: NSNotFound, length: 0)) { matchValue = (value as NSString).substring(with: matchRange) return matchValue == value ? true : false } else { return false } } @objc private func changesCloudUUIDsDevicesNotification(_ notification: Notification?) { if self.isCloudAvailable { let uuidsSet = NSMutableOrderedSet(array: self.devices) let uuidsCount = uuidsSet.count let iCloud = NSUbiquitousKeyValueStore.default let iCloudDict = iCloud.dictionaryRepresentation as NSDictionary iCloudDict.enumerateKeysAndObjects { key, obj, stop in let uuidKey = key as? NSString if uuidKey?.range(of: Constants.Keys.deviceUUIDKey).location == 0 { if let uuidValue = obj as? String { if uuidKey?.range(of: uuidValue).location != NSNotFound, self.isValidUUID(uuidValue) { uuidsSet.add(uuidValue) } else { print("invalid uuid") } } } } if uuidsSet.count > uuidsCount, let uuidsArray = uuidsSet.array as? [String] { self.updateUUIDsDevices(with: uuidsArray) let userInfo = [Constants.Keys.devicesUUIDs: self.devices] NotificationCenter.default.post(name: Constants.Locals.DevicesUUIDsDidChangeNotification, object: self, userInfo: userInfo) } } } // MARK: - Static static private func getValue(forKey key: String, userDefaults: Bool = true, keychain: Bool = true, service: String? = nil, accessGroup: String? = nil) -> String? { let keychainStore = Self.makeKeychain(service: service, accessGroup: accessGroup) var value = try? keychainStore.getString(key) if userDefaults, !value.isExist { value = UserDefaults.standard.string(forKey: key) } return value } @discardableResult static private func setValue(_ value: String?, forKey key: String, userDefaults: Bool = true, keychain: Bool = true, service: String? = nil, accessGroup: String? = nil, synchronizable: Bool = true) -> Error? { if let value = value, userDefaults { UserDefaults.standard.set(value, forKey: key) UserDefaults.standard.synchronize() } if let value = value, keychain { let keychainStore = Self.makeKeychain(service: service, accessGroup: accessGroup).synchronizable(synchronizable) do { try keychainStore.set(value, key: key) } catch { return error } } return nil } static private func makeKeychain(service: String? = nil, accessGroup: String? = nil) -> Keychain { if let service = service { if let accessGroup = accessGroup { return Keychain(service: service, accessGroup: accessGroup) } else { return Keychain(service: service) } } else if let accessGroup = accessGroup { return Keychain(accessGroup: accessGroup) } return Keychain() } }