Files
2022-08-31 14:21:25 +03:00

347 lines
13 KiB
Swift

//
// 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()
}
}