├── .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 | [](#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 |
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 |
--------------------------------------------------------------------------------