├── .github └── workflows │ └── swift.yml ├── .gitignore ├── .swiftpm └── xcode │ └── package.xcworkspace │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── LICENSE.md ├── Package.swift ├── README.md ├── Sources └── StableID │ ├── Delegate │ └── Delegate.swift │ ├── Generators │ └── IDGenerator.swift │ ├── Misc │ ├── Constants.swift │ └── Logger.swift │ └── StableID.swift └── Tests └── StableIDTests └── StableIDTests.swift /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | # This workflow will build a Swift project 2 | # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-swift 3 | 4 | name: Swift 5 | 6 | on: 7 | push: 8 | branches: [ "main" ] 9 | pull_request: 10 | branches: [ "main" ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: macos-13 16 | 17 | steps: 18 | - uses: actions/checkout@v3 19 | - name: Select Xcode 15.2 20 | run: sudo xcode-select -s /Applications/Xcode_15.2.app/Contents/Developer 21 | 22 | - name: Show eligible build destinations for StableID 23 | run: xcodebuild -showdestinations -scheme StableID 24 | 25 | - name: Build and test ( macOS 13) 26 | run: xcodebuild test -scheme StableID -destination 'platform=macOS,arch=x86_64,id=4203018E-580F-C1B5-9525-B745CECA79EB' 27 | # - name: Build and test ( iOS 17) 28 | # run: xcodebuild test -scheme StableID -destination 'platform=iOS Simulator,OS=17.2,name=iPhone 15 Pro' 29 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | xcuserdata/ 5 | DerivedData/ 6 | .swiftpm/configuration/registries.json 7 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 8 | .netrc 9 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | 2 | Copyright 2024 Cody Kerns 3 | 4 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 5 | 6 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 7 | 8 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 9 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.9 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "StableID", 8 | platforms: [ 9 | .iOS(.v14), 10 | .macOS(.v11), 11 | .tvOS(.v14), 12 | .visionOS(.v1), 13 | .watchOS(.v7) 14 | ], 15 | products: [ 16 | // Products define the executables and libraries a package produces, making them visible to other packages. 17 | .library( 18 | name: "StableID", 19 | targets: ["StableID"]), 20 | ], 21 | targets: [ 22 | // Targets are the basic building blocks of a package, defining a module or a test suite. 23 | // Targets can depend on other targets in this package and products from dependencies. 24 | .target( 25 | name: "StableID"), 26 | .testTarget( 27 | name: "StableIDTests", 28 | dependencies: ["StableID"]), 29 | ] 30 | ) 31 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ### A simple, stable user identifier across devices 2 | 3 | [![SwiftPM compatible](https://img.shields.io/badge/SwiftPM-compatible-orange.svg)](#Installation) 4 | 5 | StableID is a simple package that helps you keep a stable user identifier across devices by leveraging [iCloud Key Value Store](https://developer.apple.com/documentation/foundation/nsubiquitouskeyvaluestore)). 6 | 7 | It's useful for services like [RevenueCat](https://github.com/RevenueCat/purchases-ios), where you may want to maintain a consistent user identifier to allow users to access their purchases across their devices, but you _don't_ want to have a complete account system or use anonymous identifiers. 8 | 9 | StableID persists across all devices of a user's iCloud account. 10 | 11 | ## 📦 Installation 12 | 13 | Add this repository as a Swift package. 14 | 15 | ```plaintext 16 | https://github.com/codykerns/StableID 17 | ``` 18 | 19 | ## ℹ️ Before using StableID 20 | 21 | In order to use StableID, you'll need to add the iCloud capability to your target and enable `Key-value storage`: 22 | 23 | Screenshot 2024-02-17 at 1 12 04 AM 24 | 25 | ## 🛠️ Configuration 26 | 27 | Initialize StableID: 28 | 29 | ```swift 30 | StableID.configure() 31 | ``` 32 | 33 | By default, StableID will look for any other StableID identifier in iCloud or local user defaults - otherwise, it will generate a new identifier. 34 | 35 | If you want to provide a custom identifier to force the client to be set to a specific identifier and update iCloud: 36 | 37 | ```swift 38 | StableID.configure(id: ) 39 | ``` 40 | 41 | Call `StableID.isConfigured` to see if StableID has already been configured. 42 | 43 | ### Changing identifiers 44 | 45 | To change identifiers, call: 46 | 47 | ```swift 48 | StableID.identify(id: ) 49 | ``` 50 | 51 | ### Receiving updates 52 | 53 | To receive updates when a user identifier changes (for example from detecting a change from another iCloud device), configure a delegate: 54 | 55 | ```swift 56 | // call after configuring StableID 57 | StableID.set(delegate: MyClass()) 58 | 59 | class MyClass: StableIDDelegate { 60 | func willChangeID(currentID: String, candidateID: String) -> String? { 61 | // called before StableID changes IDs, it gives you the option to return the proper ID 62 | } 63 | 64 | func didChangeID(newID: String) { 65 | // called once the ID changes 66 | } 67 | } 68 | ``` 69 | 70 | ### Custom ID Generators 71 | 72 | By default, StableID uses a standard `IDGenerator` that generates simple UUIDs. 73 | 74 | If you want any generated identifiers to follow a certain pattern, you can implement a custom ID generator by conforming to `IDGenerator` and implementing `generateID()`: 75 | 76 | ```swift 77 | struct MyCustomIDGenerator: IDGenerator { 78 | func generateID() -> String { 79 | // do something custom 80 | return myGeneratedID 81 | } 82 | } 83 | ``` 84 | 85 | Then pass the generator as part of the `configure` method: 86 | 87 | ```swift 88 | StableID.configure(idGenerator: MyCustomIDGenerator()) 89 | ``` 90 | 91 | **Built-in generators** 92 | - `StableID.StandardGenerator`: Standard UUIDs 93 | - `StableID.ShortIDGenerator`: 8-character alphanumeric IDs 94 | 95 | ## 📚 Examples 96 | 97 | _Coming soon_ 98 | 99 | ## 📙 License 100 | 101 | MIT 102 | -------------------------------------------------------------------------------- /Sources/StableID/Delegate/Delegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Delegate.swift 3 | // 4 | // 5 | // Created by Cody Kerns on 2/13/24. 6 | // 7 | 8 | import Foundation 9 | 10 | public protocol StableIDDelegate { 11 | /// Called when StableID is about to change the identified user ID. 12 | /// Return `nil` to prevent the change. 13 | func willChangeID(currentID: String, candidateID: String) -> String? 14 | 15 | /// Called after StableID changes the identified user ID. 16 | func didChangeID(newID: String) 17 | } 18 | -------------------------------------------------------------------------------- /Sources/StableID/Generators/IDGenerator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // IDGenerator.swift 3 | // 4 | // 5 | // Created by Cody Kerns on 2/17/24. 6 | // 7 | 8 | import Foundation 9 | 10 | public protocol IDGenerator { 11 | func generateID() -> String 12 | } 13 | 14 | extension StableID { 15 | public class StandardGenerator: IDGenerator { 16 | public init() { } 17 | 18 | public func generateID() -> String { 19 | return UUID().uuidString 20 | } 21 | } 22 | 23 | public class ShortIDGenerator: IDGenerator { 24 | public init() { } 25 | 26 | public func generateID() -> String { 27 | let letters = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 28 | 29 | return String((0..<8).compactMap { _ in 30 | letters.randomElement() 31 | }) 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/StableID/Misc/Constants.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Constants.swift 3 | // 4 | // 5 | // Created by Cody Kerns on 2/10/24. 6 | // 7 | 8 | import Foundation 9 | 10 | struct Constants { 11 | static let StableID_Key_DefaultsSuiteName = "_StableID_DefaultsSuiteName" 12 | static let StableID_Key_Identifier = "_StableID_Identifier" 13 | } 14 | -------------------------------------------------------------------------------- /Sources/StableID/Misc/Logger.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Logger.swift 3 | // 4 | // 5 | // Created by Cody Kerns on 2/11/24. 6 | // 7 | 8 | import Foundation 9 | 10 | internal struct StableIDLogger { 11 | enum LogType { 12 | case info, warning, error 13 | 14 | var title: String { 15 | switch self { 16 | case .info: 17 | return "ℹ️ INFO:" 18 | case .warning: 19 | return "⚠️ WARNING:" 20 | case .error: 21 | return "🚨 ERROR:" 22 | } 23 | } 24 | } 25 | 26 | func log(type: LogType, message: String) { 27 | let message: String = "[StableID] - \(type.title) \(message)" 28 | print(message) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/StableID/StableID.swift: -------------------------------------------------------------------------------- 1 | // 2 | // StableID.swift 3 | // 4 | // 5 | // Created by Cody Kerns on 2/10/24. 6 | // 7 | 8 | import Foundation 9 | 10 | public class StableID { 11 | internal static var _stableID: StableID? = nil 12 | 13 | private static var shared: StableID { 14 | guard let _stableID else { 15 | fatalError("StableID not configured.") 16 | } 17 | return _stableID 18 | } 19 | 20 | private init(_id: String, _idGenerator: IDGenerator) { 21 | self._id = _id 22 | self._idGenerator = _idGenerator 23 | } 24 | 25 | private static var _remoteStore = NSUbiquitousKeyValueStore.default 26 | private static var _localStore = UserDefaults(suiteName: Constants.StableID_Key_DefaultsSuiteName) 27 | 28 | public static func configure(id: String? = nil, idGenerator: IDGenerator = StandardGenerator()) { 29 | guard isConfigured == false else { 30 | self.logger.log(type: .error, message: "StableID has already been configured! Call `identify` to change the identifier.") 31 | return 32 | } 33 | 34 | self.logger.log(type: .info, message: "Configuring StableID...") 35 | 36 | // By default, generate a new anonymous identifier 37 | var identifier = idGenerator.generateID() 38 | 39 | if let id { 40 | // if an identifier is provided in the configure method, identify with it 41 | identifier = id 42 | self.logger.log(type: .info, message: "Identifying with configured ID: \(id)") 43 | } else { 44 | self.logger.log(type: .info, message: "No ID passed to `configure`. Checking iCloud store...") 45 | 46 | if let remoteID = Self._remoteStore.string(forKey: Constants.StableID_Key_Identifier) { 47 | // if an identifier exists in iCloud, use that 48 | identifier = remoteID 49 | self.logger.log(type: .info, message: "Configuring with iCloud ID: \(remoteID)") 50 | } else { 51 | self.logger.log(type: .info, message: "No ID available in iCloud. Checking local defaults...") 52 | 53 | if let localID = Self._localStore?.string(forKey: Constants.StableID_Key_Identifier) { 54 | // if an identifier only exists locally, use that 55 | identifier = localID 56 | self.logger.log(type: .info, message: "Configuring with local ID: \(localID)") 57 | } else { 58 | self.logger.log(type: .info, message: "No available identifier. Generating new unique user identifier...") 59 | } 60 | } 61 | } 62 | 63 | _stableID = StableID(_id: identifier, _idGenerator: idGenerator) 64 | 65 | self.logger.log(type: .info, message: "Configured StableID. Current user ID: \(identifier)") 66 | 67 | NotificationCenter.default.addObserver(Self.shared, 68 | selector: #selector(didChangeExternally), 69 | name: NSUbiquitousKeyValueStore.didChangeExternallyNotification, 70 | object: NSUbiquitousKeyValueStore.default) 71 | } 72 | 73 | private static let logger = StableIDLogger() 74 | 75 | private var _idGenerator: any IDGenerator 76 | 77 | private var _id: String 78 | 79 | private var delegate: (any StableIDDelegate)? 80 | 81 | private func setIdentity(value: String) { 82 | if value != _id { 83 | 84 | var adjustedId = value 85 | 86 | if let delegateId = self.delegate?.willChangeID(currentID: self._id, candidateID: value) { 87 | adjustedId = delegateId 88 | } 89 | 90 | Self.logger.log(type: .info, message: "Setting StableID to \(adjustedId)") 91 | 92 | Self.shared._id = adjustedId 93 | self.setLocal(key: Constants.StableID_Key_Identifier, value: adjustedId) 94 | self.setRemote(key: Constants.StableID_Key_Identifier, value: adjustedId) 95 | 96 | self.delegate?.didChangeID(newID: adjustedId) 97 | } 98 | } 99 | 100 | private func generateID() { 101 | Self.logger.log(type: .info, message: "Generating new StableID.") 102 | 103 | let newID = self._idGenerator.generateID() 104 | self.setIdentity(value: newID) 105 | } 106 | 107 | private func setLocal(key: String, value: String) { 108 | Self._localStore?.set(value, forKey: key) 109 | } 110 | 111 | private func setRemote(key: String, value: String) { 112 | Self._remoteStore.set(value, forKey: key) 113 | Self._remoteStore.synchronize() 114 | } 115 | 116 | @objc 117 | private func didChangeExternally(_ notification: Notification) { 118 | if let newId = Self._remoteStore.string(forKey: Constants.StableID_Key_Identifier) { 119 | if newId != _id { 120 | Self.logger.log(type: .info, message: "Detected new StableID: \(newId)") 121 | 122 | self.setIdentity(value: newId) 123 | } else { 124 | // the identifier was updated remotely, but it's the same identifier 125 | Self.logger.log(type: .info, message: "No change to StableID.") 126 | } 127 | 128 | } else { 129 | Self.logger.log(type: .warning, message: "StableID removed from iCloud. Reverting to local value: \(_id)") 130 | 131 | // The store was updated, but the id is empty. Reset back to configured identifier 132 | self.setIdentity(value: _id) 133 | } 134 | 135 | } 136 | } 137 | 138 | 139 | /// Public methods 140 | extension StableID { 141 | public static var isConfigured: Bool { _stableID != nil } 142 | 143 | public static var id: String { return Self.shared._id } 144 | 145 | public static func identify(id: String) { 146 | Self.shared.setIdentity(value: id) 147 | } 148 | 149 | public static func generateNewID() { 150 | Self.shared.generateID() 151 | } 152 | 153 | public static func set(delegate: any StableIDDelegate) { 154 | Self.shared.delegate = delegate 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /Tests/StableIDTests/StableIDTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import StableID 3 | 4 | final class StableIDTests: XCTestCase { 5 | func testExample() throws { 6 | // XCTest Documentation 7 | // https://developer.apple.com/documentation/xctest 8 | 9 | // Defining Test Cases and Test Methods 10 | // https://developer.apple.com/documentation/xctest/defining_test_cases_and_test_methods 11 | } 12 | 13 | func clearDefaults() { 14 | guard let defaults = UserDefaults(suiteName: Constants.StableID_Key_DefaultsSuiteName) else { return } 15 | 16 | let dictionary = defaults.dictionaryRepresentation() 17 | dictionary.keys.forEach { key in 18 | print(key) 19 | defaults.removeObject(forKey: key) 20 | } 21 | } 22 | 23 | override func setUp() { 24 | super.setUp() 25 | 26 | clearDefaults() 27 | } 28 | 29 | override func tearDown() { 30 | super.tearDown() 31 | 32 | StableID._stableID = nil 33 | } 34 | 35 | func testConfiguring() { 36 | StableID.configure() 37 | XCTAssert(StableID.isConfigured == true) 38 | } 39 | 40 | func testIdentifying() { 41 | StableID.configure() 42 | 43 | let uuid = UUID().uuidString 44 | StableID.identify(id: uuid) 45 | 46 | XCTAssert(StableID.id == uuid) 47 | } 48 | 49 | func testGenerateNewID() { 50 | StableID.configure() 51 | let originalID = StableID.id 52 | 53 | StableID.generateNewID() 54 | let newID = StableID.id 55 | 56 | XCTAssert(originalID != newID) 57 | } 58 | 59 | func testShortIDLength() { 60 | StableID.configure(idGenerator: StableID.ShortIDGenerator()) 61 | 62 | XCTAssert(StableID.id.count == 8) 63 | } 64 | } 65 | --------------------------------------------------------------------------------