@capgo/capacitor-updater 5.9.4 → 5.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CapgoCapacitorUpdater.podspec +7 -5
  2. package/Package.swift +37 -0
  3. package/README.md +1030 -212
  4. package/android/build.gradle +28 -11
  5. package/android/proguard-rules.pro +22 -5
  6. package/android/src/main/AndroidManifest.xml +0 -1
  7. package/android/src/main/java/ee/forgr/capacitor_updater/BundleInfo.java +171 -195
  8. package/android/src/main/java/ee/forgr/capacitor_updater/BundleStatus.java +23 -23
  9. package/android/src/main/java/ee/forgr/capacitor_updater/Callback.java +2 -2
  10. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +2111 -1538
  11. package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +1551 -0
  12. package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipher.java +229 -111
  13. package/android/src/main/java/ee/forgr/capacitor_updater/DataManager.java +28 -0
  14. package/android/src/main/java/ee/forgr/capacitor_updater/DelayCondition.java +42 -49
  15. package/android/src/main/java/ee/forgr/capacitor_updater/DelayUntilNext.java +4 -4
  16. package/android/src/main/java/ee/forgr/capacitor_updater/DelayUpdateUtils.java +260 -0
  17. package/android/src/main/java/ee/forgr/capacitor_updater/DeviceIdHelper.java +221 -0
  18. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +795 -124
  19. package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +156 -0
  20. package/android/src/main/java/ee/forgr/capacitor_updater/InternalUtils.java +19 -28
  21. package/android/src/main/java/ee/forgr/capacitor_updater/Logger.java +338 -0
  22. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeDetector.java +72 -0
  23. package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +169 -0
  24. package/dist/docs.json +1072 -162
  25. package/dist/esm/definitions.d.ts +899 -118
  26. package/dist/esm/definitions.js.map +1 -1
  27. package/dist/esm/history.d.ts +1 -0
  28. package/dist/esm/history.js +283 -0
  29. package/dist/esm/history.js.map +1 -0
  30. package/dist/esm/index.d.ts +3 -2
  31. package/dist/esm/index.js +5 -4
  32. package/dist/esm/index.js.map +1 -1
  33. package/dist/esm/web.d.ts +16 -2
  34. package/dist/esm/web.js +79 -40
  35. package/dist/esm/web.js.map +1 -1
  36. package/dist/plugin.cjs.js +361 -40
  37. package/dist/plugin.cjs.js.map +1 -1
  38. package/dist/plugin.js +361 -40
  39. package/dist/plugin.js.map +1 -1
  40. package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +69 -0
  41. package/ios/Sources/CapacitorUpdaterPlugin/BigInt.swift +55 -0
  42. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/BundleStatus.swift +1 -1
  43. package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +1582 -0
  44. package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +1513 -0
  45. package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +187 -0
  46. package/ios/Sources/CapacitorUpdaterPlugin/DelayUpdateUtils.swift +220 -0
  47. package/ios/Sources/CapacitorUpdaterPlugin/DeviceIdHelper.swift +120 -0
  48. package/ios/Sources/CapacitorUpdaterPlugin/InternalUtils.swift +307 -0
  49. package/ios/Sources/CapacitorUpdaterPlugin/Logger.swift +310 -0
  50. package/ios/Sources/CapacitorUpdaterPlugin/RSA.swift +274 -0
  51. package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +112 -0
  52. package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/UserDefaultsExtension.swift +0 -2
  53. package/package.json +33 -28
  54. package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdater.java +0 -1187
  55. package/ios/Plugin/CapacitorUpdater.swift +0 -1032
  56. package/ios/Plugin/CapacitorUpdaterPlugin.h +0 -10
  57. package/ios/Plugin/CapacitorUpdaterPlugin.m +0 -31
  58. package/ios/Plugin/CapacitorUpdaterPlugin.swift +0 -843
  59. package/ios/Plugin/CryptoCipher.swift +0 -246
  60. /package/{LICENCE → LICENSE} +0 -0
  61. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/BundleInfo.swift +0 -0
  62. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/DelayCondition.swift +0 -0
  63. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/DelayUntilNext.swift +0 -0
  64. /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/Info.plist +0 -0
@@ -0,0 +1,187 @@
1
+ /*
2
+ * This Source Code Form is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5
+ */
6
+
7
+ import Foundation
8
+ import CryptoKit
9
+ import BigInt
10
+
11
+ public struct CryptoCipher {
12
+ private static var logger: Logger!
13
+
14
+ public static func setLogger(_ logger: Logger) {
15
+ self.logger = logger
16
+ }
17
+
18
+ public static func decryptChecksum(checksum: String, publicKey: String) throws -> String {
19
+ if publicKey.isEmpty {
20
+ logger.info("No encryption set (public key) ignored")
21
+ return checksum
22
+ }
23
+ do {
24
+ guard let checksumBytes = Data(base64Encoded: checksum) else {
25
+ logger.error("Cannot decode checksum as base64: \(checksum)")
26
+ throw CustomError.cannotDecode
27
+ }
28
+
29
+ if checksumBytes.isEmpty {
30
+ logger.error("Decoded checksum is empty")
31
+ throw CustomError.cannotDecode
32
+ }
33
+
34
+ guard let rsaPublicKey = RSAPublicKey.load(rsaPublicKey: publicKey) else {
35
+ logger.error("The public key is not a valid RSA Public key")
36
+ throw CustomError.cannotDecode
37
+ }
38
+
39
+ guard let decryptedChecksum = rsaPublicKey.decrypt(data: checksumBytes) else {
40
+ logger.error("decryptChecksum fail")
41
+ throw NSError(domain: "Failed to decrypt session key data", code: 2, userInfo: nil)
42
+ }
43
+
44
+ return decryptedChecksum.base64EncodedString()
45
+ } catch {
46
+ logger.error("decryptChecksum fail: \(error.localizedDescription)")
47
+ throw CustomError.cannotDecode
48
+ }
49
+ }
50
+ public static func calcChecksum(filePath: URL) -> String {
51
+ let bufferSize = 1024 * 1024 * 5 // 5 MB
52
+ var sha256 = SHA256()
53
+
54
+ do {
55
+ let fileHandle: FileHandle
56
+ do {
57
+ fileHandle = try FileHandle(forReadingFrom: filePath)
58
+ } catch {
59
+ logger.error("Cannot open file for checksum: \(filePath.path) \(error)")
60
+ return ""
61
+ }
62
+
63
+ defer {
64
+ do {
65
+ try fileHandle.close()
66
+ } catch {
67
+ logger.error("Error closing file: \(error)")
68
+ }
69
+ }
70
+
71
+ while autoreleasepool(invoking: {
72
+ let fileData: Data
73
+ do {
74
+ if #available(iOS 13.4, *) {
75
+ fileData = try fileHandle.read(upToCount: bufferSize) ?? Data()
76
+ } else {
77
+ fileData = fileHandle.readData(ofLength: bufferSize)
78
+ }
79
+ } catch {
80
+ logger.error("Error reading file: \(error)")
81
+ return false
82
+ }
83
+
84
+ if fileData.count > 0 {
85
+ sha256.update(data: fileData)
86
+ return true // Continue
87
+ } else {
88
+ return false // End of file
89
+ }
90
+ }) {}
91
+
92
+ let digest = sha256.finalize()
93
+ return digest.compactMap { String(format: "%02x", $0) }.joined()
94
+ } catch {
95
+ logger.error("Cannot get checksum: \(filePath.path) \(error)")
96
+ return ""
97
+ }
98
+ }
99
+
100
+ public static func decryptFile(filePath: URL, publicKey: String, sessionKey: String, version: String) throws {
101
+ if publicKey.isEmpty || sessionKey.isEmpty || sessionKey.components(separatedBy: ":").count != 2 {
102
+ logger.info("Encryption not set, no public key or session, ignored")
103
+ return
104
+ }
105
+
106
+ if !publicKey.hasPrefix("-----BEGIN RSA PUBLIC KEY-----") {
107
+ logger.error("The public key is not a valid RSA Public key")
108
+ return
109
+ }
110
+
111
+ do {
112
+ guard let rsaPublicKey = RSAPublicKey.load(rsaPublicKey: publicKey) else {
113
+ logger.error("The public key is not a valid RSA Public key")
114
+ throw CustomError.cannotDecode
115
+ }
116
+
117
+ let sessionKeyComponents = sessionKey.components(separatedBy: ":")
118
+ let ivBase64 = sessionKeyComponents[0]
119
+ let encryptedKeyBase64 = sessionKeyComponents[1]
120
+
121
+ guard let ivData = Data(base64Encoded: ivBase64) else {
122
+ logger.error("Cannot decode sessionKey IV \(ivBase64)")
123
+ throw CustomError.cannotDecode
124
+ }
125
+
126
+ if ivData.count != 16 {
127
+ logger.error("IV data has invalid length: \(ivData.count), expected 16")
128
+ throw CustomError.cannotDecode
129
+ }
130
+
131
+ guard let sessionKeyDataEncrypted = Data(base64Encoded: encryptedKeyBase64) else {
132
+ logger.error("Cannot decode sessionKey data \(encryptedKeyBase64)")
133
+ throw NSError(domain: "Invalid session key data", code: 1, userInfo: nil)
134
+ }
135
+
136
+ guard let sessionKeyDataDecrypted = rsaPublicKey.decrypt(data: sessionKeyDataEncrypted) else {
137
+ logger.error("Failed to decrypt session key data")
138
+ throw NSError(domain: "Failed to decrypt session key data", code: 2, userInfo: nil)
139
+ }
140
+
141
+ if sessionKeyDataDecrypted.count != 16 {
142
+ logger.error("Decrypted session key has invalid length: \(sessionKeyDataDecrypted.count), expected 16")
143
+ throw NSError(domain: "Invalid decrypted session key", code: 5, userInfo: nil)
144
+ }
145
+
146
+ let aesPrivateKey = AES128Key(iv: ivData, aes128Key: sessionKeyDataDecrypted, logger: logger)
147
+
148
+ let encryptedData: Data
149
+ do {
150
+ encryptedData = try Data(contentsOf: filePath)
151
+ } catch {
152
+ logger.error("Failed to read encrypted data: \(error)")
153
+ throw NSError(domain: "Failed to read encrypted data", code: 3, userInfo: nil)
154
+ }
155
+
156
+ if encryptedData.isEmpty {
157
+ logger.error("Encrypted file data is empty")
158
+ throw NSError(domain: "Empty encrypted data", code: 6, userInfo: nil)
159
+ }
160
+
161
+ guard let decryptedData = aesPrivateKey.decrypt(data: encryptedData) else {
162
+ logger.error("Failed to decrypt data")
163
+ throw NSError(domain: "Failed to decrypt data", code: 4, userInfo: nil)
164
+ }
165
+
166
+ if decryptedData.isEmpty {
167
+ logger.error("Decrypted data is empty")
168
+ throw NSError(domain: "Empty decrypted data", code: 7, userInfo: nil)
169
+ }
170
+
171
+ do {
172
+ try decryptedData.write(to: filePath, options: .atomic)
173
+ if !FileManager.default.fileExists(atPath: filePath.path) {
174
+ logger.error("File was not created after write")
175
+ throw NSError(domain: "File write failed", code: 8, userInfo: nil)
176
+ }
177
+ } catch {
178
+ logger.error("Error writing decrypted file: \(error)")
179
+ throw error
180
+ }
181
+
182
+ } catch {
183
+ logger.error("decryptFile fail")
184
+ throw CustomError.cannotDecode
185
+ }
186
+ }
187
+ }
@@ -0,0 +1,220 @@
1
+ /*
2
+ * This Source Code Form is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5
+ */
6
+
7
+ //
8
+ // DelayUpdateUtils.swift
9
+ // Plugin
10
+ //
11
+ // Created by Auto-generated based on Android implementation
12
+ // Copyright © 2024 Capgo. All rights reserved.
13
+ //
14
+
15
+ import Foundation
16
+ import Version
17
+
18
+ public class DelayUpdateUtils {
19
+
20
+ static let DELAY_CONDITION_PREFERENCES = "DELAY_CONDITION_PREFERENCES_CAPGO"
21
+ static let BACKGROUND_TIMESTAMP_KEY = "BACKGROUND_TIMESTAMP_KEY_CAPGO"
22
+ private let logger: Logger
23
+
24
+ private let currentVersionNative: Version
25
+
26
+ public enum CancelDelaySource {
27
+ case killed
28
+ case background
29
+ case foreground
30
+
31
+ var description: String {
32
+ switch self {
33
+ case .killed: return "KILLED"
34
+ case .background: return "BACKGROUND"
35
+ case .foreground: return "FOREGROUND"
36
+ }
37
+ }
38
+ }
39
+
40
+ public init(currentVersionNative: Version, logger: Logger) {
41
+ self.currentVersionNative = currentVersionNative
42
+ self.logger = logger
43
+ }
44
+
45
+ public func checkCancelDelay(source: CancelDelaySource) {
46
+ let delayUpdatePreferences = UserDefaults.standard.string(forKey: DelayUpdateUtils.DELAY_CONDITION_PREFERENCES) ?? "[]"
47
+ let delayConditionList: [DelayCondition] = fromJsonArr(json: delayUpdatePreferences).map { obj -> DelayCondition in
48
+ let kind: String = obj.value(forKey: "kind") as! String
49
+ let value: String? = obj.value(forKey: "value") as? String
50
+ return DelayCondition(kind: kind, value: value)
51
+ }
52
+
53
+ var delayConditionListToKeep: [DelayCondition] = []
54
+ var index = 0
55
+
56
+ for condition in delayConditionList {
57
+ let kind = condition.getKind()
58
+ let value = condition.getValue()
59
+
60
+ switch kind {
61
+ case "background":
62
+ if source == .foreground {
63
+ let backgroundedAt = getBackgroundTimestamp()
64
+ let now = Int64(Date().timeIntervalSince1970 * 1000) // Convert to milliseconds
65
+ let delta = max(0, now - backgroundedAt)
66
+
67
+ var longValue: Int64 = 0
68
+ if let value = value, !value.isEmpty {
69
+ longValue = Int64(value) ?? 0
70
+ }
71
+
72
+ if delta > longValue {
73
+ logger.info("Background condition (value: \(value ?? "")) deleted at index \(index). Delta: \(delta), longValue: \(longValue)")
74
+ } else {
75
+ delayConditionListToKeep.append(condition)
76
+ logger.info("Background delay (value: \(value ?? "")) condition kept at index \(index) (source: \(source.description))")
77
+ }
78
+ } else {
79
+ delayConditionListToKeep.append(condition)
80
+ logger.info("Background delay (value: \(value ?? "")) condition kept at index \(index) (source: \(source.description))")
81
+ }
82
+
83
+ case "kill":
84
+ if source == .killed {
85
+ logger.info("Kill delay (value: \(value ?? "")) condition removed at index \(index) after app kill")
86
+ } else {
87
+ delayConditionListToKeep.append(condition)
88
+ logger.info("Kill delay (value: \(value ?? "")) condition kept at index \(index) (source: \(source.description))")
89
+ }
90
+
91
+ case "date":
92
+ if let value = value, !value.isEmpty {
93
+ do {
94
+ let dateFormatter = ISO8601DateFormatter()
95
+ dateFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
96
+
97
+ if let date = dateFormatter.date(from: value) {
98
+ if Date() > date {
99
+ logger.info("Date delay (value: \(value)) condition removed due to expired date at index \(index)")
100
+ } else {
101
+ delayConditionListToKeep.append(condition)
102
+ logger.info("Date delay (value: \(value)) condition kept at index \(index)")
103
+ }
104
+ } else {
105
+ logger.error("Date delay (value: \(value)) condition removed due to parsing issue at index \(index)")
106
+ }
107
+ } catch {
108
+ logger.error("Date delay (value: \(value)) condition removed due to parsing issue at index \(index): \(error)")
109
+ }
110
+ } else {
111
+ logger.error("Date delay (value: \(value ?? "")) condition removed due to empty value at index \(index)")
112
+ }
113
+
114
+ case "nativeVersion":
115
+ if let value = value, !value.isEmpty {
116
+ do {
117
+ let versionLimit = try Version(value)
118
+ if currentVersionNative >= versionLimit {
119
+ logger.info("Native version delay (value: \(value)) condition removed due to above limit at index \(index)")
120
+ } else {
121
+ delayConditionListToKeep.append(condition)
122
+ logger.info("Native version delay (value: \(value)) condition kept at index \(index)")
123
+ }
124
+ } catch {
125
+ logger.error("Native version delay (value: \(value)) condition removed due to parsing issue at index \(index): \(error)")
126
+ }
127
+ } else {
128
+ logger.error("Native version delay (value: \(value ?? "")) condition removed due to empty value at index \(index)")
129
+ }
130
+
131
+ default:
132
+ logger.error("Unknown delay condition kind: \(kind) at index \(index)")
133
+ }
134
+
135
+ index += 1
136
+ }
137
+
138
+ if !delayConditionListToKeep.isEmpty {
139
+ let json = toJson(object: delayConditionListToKeep.map { $0.toJSON() })
140
+ _ = setMultiDelay(delayConditions: json)
141
+ } else {
142
+ // Clear all delay conditions if none are left to keep
143
+ _ = cancelDelay(source: "checkCancelDelay")
144
+ }
145
+ }
146
+
147
+ public func setMultiDelay(delayConditions: String) -> Bool {
148
+ do {
149
+ UserDefaults.standard.set(delayConditions, forKey: DelayUpdateUtils.DELAY_CONDITION_PREFERENCES)
150
+ UserDefaults.standard.synchronize()
151
+ logger.info("Delay update saved")
152
+ return true
153
+ } catch {
154
+ logger.error("Failed to delay update, [Error calling 'setMultiDelay()']: \(error)")
155
+ return false
156
+ }
157
+ }
158
+
159
+ public func setBackgroundTimestamp(_ backgroundTimestamp: Int64) {
160
+ do {
161
+ UserDefaults.standard.set(backgroundTimestamp, forKey: DelayUpdateUtils.BACKGROUND_TIMESTAMP_KEY)
162
+ UserDefaults.standard.synchronize()
163
+ logger.info("Background timestamp saved")
164
+ } catch {
165
+ logger.error("Failed to save background timestamp, [Error calling 'setBackgroundTimestamp()']: \(error)")
166
+ }
167
+ }
168
+
169
+ public func unsetBackgroundTimestamp() {
170
+ do {
171
+ UserDefaults.standard.removeObject(forKey: DelayUpdateUtils.BACKGROUND_TIMESTAMP_KEY)
172
+ UserDefaults.standard.synchronize()
173
+ logger.info("Background timestamp removed")
174
+ } catch {
175
+ logger.error("Failed to remove background timestamp, [Error calling 'unsetBackgroundTimestamp()']: \(error)")
176
+ }
177
+ }
178
+
179
+ private func getBackgroundTimestamp() -> Int64 {
180
+ do {
181
+ let timestamp = UserDefaults.standard.object(forKey: DelayUpdateUtils.BACKGROUND_TIMESTAMP_KEY) as? Int64 ?? 0
182
+ return timestamp
183
+ } catch {
184
+ logger.error("Failed to get background timestamp, [Error calling 'getBackgroundTimestamp()']: \(error)")
185
+ return 0
186
+ }
187
+ }
188
+
189
+ public func cancelDelay(source: String) -> Bool {
190
+ do {
191
+ UserDefaults.standard.removeObject(forKey: DelayUpdateUtils.DELAY_CONDITION_PREFERENCES)
192
+ UserDefaults.standard.synchronize()
193
+ logger.info("All delays canceled from \(source)")
194
+ return true
195
+ } catch {
196
+ logger.error("Failed to cancel update delay: \(error)")
197
+ return false
198
+ }
199
+ }
200
+
201
+ // MARK: - Helper methods
202
+
203
+ private func toJson(object: Any) -> String {
204
+ guard let data = try? JSONSerialization.data(withJSONObject: object, options: []) else {
205
+ return ""
206
+ }
207
+ return String(data: data, encoding: String.Encoding.utf8) ?? ""
208
+ }
209
+
210
+ private func fromJsonArr(json: String) -> [NSObject] {
211
+ guard let jsonData = json.data(using: .utf8) else {
212
+ return []
213
+ }
214
+ let object = try? JSONSerialization.jsonObject(
215
+ with: jsonData,
216
+ options: .mutableContainers
217
+ ) as? [NSObject]
218
+ return object ?? []
219
+ }
220
+ }
@@ -0,0 +1,120 @@
1
+ /*
2
+ * This Source Code Form is subject to the terms of the Mozilla Public
3
+ * License, v. 2.0. If a copy of the MPL was not distributed with this
4
+ * file, You can obtain one at https://mozilla.org/MPL/2.0/.
5
+ */
6
+
7
+ import Foundation
8
+ import Security
9
+
10
+ /**
11
+ * Helper class to manage device ID persistence across app installations.
12
+ * Uses iOS Keychain to persist the device ID.
13
+ */
14
+ class DeviceIdHelper {
15
+ private static let keychainService = "app.capgo.updater"
16
+ private static let keychainAccount = "deviceId"
17
+ private static let legacyDefaultsKey = "appUUID"
18
+
19
+ /**
20
+ * Gets or creates a device ID that persists across reinstalls.
21
+ *
22
+ * This method:
23
+ * 1. First checks for an existing ID in Keychain (persists across reinstalls)
24
+ * 2. Falls back to UserDefaults (for migration from older versions)
25
+ * 3. Generates a new UUID if neither exists
26
+ * 4. Stores the ID in Keychain for future use
27
+ *
28
+ * @return Device ID as a lowercase UUID string
29
+ */
30
+ static func getOrCreateDeviceId() -> String {
31
+ // Try to get device ID from Keychain first
32
+ if let keychainDeviceId = getDeviceIdFromKeychain() {
33
+ return keychainDeviceId.lowercased()
34
+ }
35
+
36
+ // Migration: Check UserDefaults for existing device ID
37
+ var deviceId = UserDefaults.standard.string(forKey: legacyDefaultsKey)
38
+
39
+ if deviceId == nil || deviceId!.isEmpty {
40
+ // Generate new device ID if none exists
41
+ deviceId = UUID().uuidString
42
+ }
43
+
44
+ // Ensure lowercase for consistency
45
+ deviceId = deviceId!.lowercased()
46
+
47
+ // Save to Keychain for persistence across reinstalls
48
+ saveDeviceIdToKeychain(deviceId: deviceId!)
49
+
50
+ return deviceId!
51
+ }
52
+
53
+ /**
54
+ * Retrieves the device ID from iOS Keychain.
55
+ *
56
+ * @return Device ID string or nil if not found
57
+ */
58
+ private static func getDeviceIdFromKeychain() -> String? {
59
+ let query: [String: Any] = [
60
+ kSecClass as String: kSecClassGenericPassword,
61
+ kSecAttrService as String: keychainService,
62
+ kSecAttrAccount as String: keychainAccount,
63
+ kSecReturnData as String: true,
64
+ kSecMatchLimit as String: kSecMatchLimitOne
65
+ ]
66
+
67
+ var result: AnyObject?
68
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
69
+
70
+ guard status == errSecSuccess,
71
+ let data = result as? Data,
72
+ let deviceId = String(data: data, encoding: .utf8) else {
73
+ return nil
74
+ }
75
+
76
+ return deviceId
77
+ }
78
+
79
+ /**
80
+ * Saves the device ID to iOS Keychain with appropriate accessibility settings.
81
+ *
82
+ * Uses kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly:
83
+ * - Data persists across reinstalls
84
+ * - Data is NOT synced to iCloud
85
+ * - Data is accessible after first device unlock
86
+ * - Data stays on this device only (privacy-friendly)
87
+ *
88
+ * @param deviceId The device ID to save
89
+ */
90
+ private static func saveDeviceIdToKeychain(deviceId: String) {
91
+ guard let data = deviceId.data(using: .utf8) else {
92
+ return
93
+ }
94
+
95
+ // Delete any existing entry first
96
+ let deleteQuery: [String: Any] = [
97
+ kSecClass as String: kSecClassGenericPassword,
98
+ kSecAttrService as String: keychainService,
99
+ kSecAttrAccount as String: keychainAccount
100
+ ]
101
+ SecItemDelete(deleteQuery as CFDictionary)
102
+
103
+ // Add new entry with appropriate accessibility
104
+ let addQuery: [String: Any] = [
105
+ kSecClass as String: kSecClassGenericPassword,
106
+ kSecAttrService as String: keychainService,
107
+ kSecAttrAccount as String: keychainAccount,
108
+ kSecValueData as String: data,
109
+ // This ensures data persists across reinstalls but stays on device (not synced)
110
+ kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly
111
+ ]
112
+
113
+ let status = SecItemAdd(addQuery as CFDictionary, nil)
114
+
115
+ if status != errSecSuccess {
116
+ // Log error but don't crash - we'll fall back to UserDefaults on next launch
117
+ print("Failed to save device ID to Keychain: \(status)")
118
+ }
119
+ }
120
+ }