@capgo/capacitor-updater 8.0.0 → 8.1.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.
- package/CapgoCapacitorUpdater.podspec +7 -5
- package/Package.swift +37 -0
- package/README.md +1461 -231
- package/android/build.gradle +29 -12
- package/android/proguard-rules.pro +45 -0
- package/android/src/main/AndroidManifest.xml +0 -1
- package/android/src/main/java/ee/forgr/capacitor_updater/BundleInfo.java +223 -195
- package/android/src/main/java/ee/forgr/capacitor_updater/BundleStatus.java +23 -23
- package/android/src/main/java/ee/forgr/capacitor_updater/Callback.java +13 -0
- package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdaterPlugin.java +2159 -1234
- package/android/src/main/java/ee/forgr/capacitor_updater/CapgoUpdater.java +1507 -0
- package/android/src/main/java/ee/forgr/capacitor_updater/CryptoCipher.java +330 -121
- package/android/src/main/java/ee/forgr/capacitor_updater/DataManager.java +28 -0
- package/android/src/main/java/ee/forgr/capacitor_updater/DelayCondition.java +43 -49
- package/android/src/main/java/ee/forgr/capacitor_updater/DelayUntilNext.java +4 -4
- package/android/src/main/java/ee/forgr/capacitor_updater/DelayUpdateUtils.java +260 -0
- package/android/src/main/java/ee/forgr/capacitor_updater/DeviceIdHelper.java +221 -0
- package/android/src/main/java/ee/forgr/capacitor_updater/DownloadService.java +808 -117
- package/android/src/main/java/ee/forgr/capacitor_updater/DownloadWorkerManager.java +156 -0
- package/android/src/main/java/ee/forgr/capacitor_updater/InternalUtils.java +32 -0
- package/android/src/main/java/ee/forgr/capacitor_updater/Logger.java +338 -0
- package/android/src/main/java/ee/forgr/capacitor_updater/ShakeDetector.java +72 -0
- package/android/src/main/java/ee/forgr/capacitor_updater/ShakeMenu.java +169 -0
- package/dist/docs.json +2187 -625
- package/dist/esm/definitions.d.ts +1286 -249
- package/dist/esm/definitions.js.map +1 -1
- package/dist/esm/history.d.ts +1 -0
- package/dist/esm/history.js +283 -0
- package/dist/esm/history.js.map +1 -0
- package/dist/esm/index.d.ts +3 -2
- package/dist/esm/index.js +5 -4
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/web.d.ts +36 -41
- package/dist/esm/web.js +94 -35
- package/dist/esm/web.js.map +1 -1
- package/dist/plugin.cjs.js +376 -35
- package/dist/plugin.cjs.js.map +1 -1
- package/dist/plugin.js +376 -35
- package/dist/plugin.js.map +1 -1
- package/ios/Sources/CapacitorUpdaterPlugin/AES.swift +69 -0
- package/ios/Sources/CapacitorUpdaterPlugin/BigInt.swift +55 -0
- package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/BundleInfo.swift +37 -10
- package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/BundleStatus.swift +1 -1
- package/ios/Sources/CapacitorUpdaterPlugin/CapacitorUpdaterPlugin.swift +1605 -0
- package/ios/Sources/CapacitorUpdaterPlugin/CapgoUpdater.swift +1526 -0
- package/ios/Sources/CapacitorUpdaterPlugin/CryptoCipher.swift +267 -0
- package/ios/Sources/CapacitorUpdaterPlugin/DelayUpdateUtils.swift +220 -0
- package/ios/Sources/CapacitorUpdaterPlugin/DeviceIdHelper.swift +120 -0
- package/ios/Sources/CapacitorUpdaterPlugin/InternalUtils.swift +311 -0
- package/ios/Sources/CapacitorUpdaterPlugin/Logger.swift +310 -0
- package/ios/Sources/CapacitorUpdaterPlugin/RSA.swift +274 -0
- package/ios/Sources/CapacitorUpdaterPlugin/ShakeMenu.swift +112 -0
- package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/UserDefaultsExtension.swift +0 -2
- package/package.json +41 -35
- package/android/src/main/java/ee/forgr/capacitor_updater/CapacitorUpdater.java +0 -1130
- package/ios/Plugin/CapacitorUpdater.swift +0 -858
- package/ios/Plugin/CapacitorUpdaterPlugin.h +0 -10
- package/ios/Plugin/CapacitorUpdaterPlugin.m +0 -27
- package/ios/Plugin/CapacitorUpdaterPlugin.swift +0 -675
- package/ios/Plugin/CryptoCipher.swift +0 -240
- /package/{LICENCE → LICENSE} +0 -0
- /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/DelayCondition.swift +0 -0
- /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/DelayUntilNext.swift +0 -0
- /package/ios/{Plugin → Sources/CapacitorUpdaterPlugin}/Info.plist +0 -0
|
@@ -0,0 +1,267 @@
|
|
|
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
|
+
private static func hexStringToData(_ hex: String) -> Data? {
|
|
19
|
+
var data = Data()
|
|
20
|
+
var hexIterator = hex.makeIterator()
|
|
21
|
+
while let c1 = hexIterator.next(), let c2 = hexIterator.next() {
|
|
22
|
+
guard let byte = UInt8(String([c1, c2]), radix: 16) else {
|
|
23
|
+
return nil
|
|
24
|
+
}
|
|
25
|
+
data.append(byte)
|
|
26
|
+
}
|
|
27
|
+
return data
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private static func isHexString(_ str: String) -> Bool {
|
|
31
|
+
let hexCharacterSet = CharacterSet(charactersIn: "0123456789abcdefABCDEF")
|
|
32
|
+
return str.unicodeScalars.allSatisfy { hexCharacterSet.contains($0) }
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
public static func decryptChecksum(checksum: String, publicKey: String) throws -> String {
|
|
36
|
+
if publicKey.isEmpty {
|
|
37
|
+
logger.info("No encryption set (public key) ignored")
|
|
38
|
+
return checksum
|
|
39
|
+
}
|
|
40
|
+
do {
|
|
41
|
+
// Determine if input is hex or base64 encoded
|
|
42
|
+
// Hex strings only contain 0-9 and a-f, while base64 contains other characters
|
|
43
|
+
let checksumBytes: Data
|
|
44
|
+
let detectedFormat: String
|
|
45
|
+
if isHexString(checksum) {
|
|
46
|
+
// Hex encoded (new format from CLI for plugin versions >= 5.30.0, 6.30.0, 7.30.0)
|
|
47
|
+
guard let hexData = hexStringToData(checksum) else {
|
|
48
|
+
logger.error("Cannot decode checksum as hex: \(checksum)")
|
|
49
|
+
throw CustomError.cannotDecode
|
|
50
|
+
}
|
|
51
|
+
checksumBytes = hexData
|
|
52
|
+
detectedFormat = "hex"
|
|
53
|
+
} else {
|
|
54
|
+
// TODO: remove backwards compatibility
|
|
55
|
+
// Base64 encoded (old format for backwards compatibility)
|
|
56
|
+
guard let base64Data = Data(base64Encoded: checksum) else {
|
|
57
|
+
logger.error("Cannot decode checksum as base64: \(checksum)")
|
|
58
|
+
throw CustomError.cannotDecode
|
|
59
|
+
}
|
|
60
|
+
checksumBytes = base64Data
|
|
61
|
+
detectedFormat = "base64"
|
|
62
|
+
}
|
|
63
|
+
logger.debug("Received encrypted checksum format: \(detectedFormat) (length: \(checksum.count) chars, \(checksumBytes.count) bytes)")
|
|
64
|
+
|
|
65
|
+
if checksumBytes.isEmpty {
|
|
66
|
+
logger.error("Decoded checksum is empty")
|
|
67
|
+
throw CustomError.cannotDecode
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
guard let rsaPublicKey = RSAPublicKey.load(rsaPublicKey: publicKey) else {
|
|
71
|
+
logger.error("The public key is not a valid RSA Public key")
|
|
72
|
+
throw CustomError.cannotDecode
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
guard let decryptedChecksum = rsaPublicKey.decrypt(data: checksumBytes) else {
|
|
76
|
+
logger.error("decryptChecksum fail")
|
|
77
|
+
throw NSError(domain: "Failed to decrypt session key data", code: 2, userInfo: nil)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Return as hex string to match calcChecksum output format
|
|
81
|
+
let result = decryptedChecksum.map { String(format: "%02x", $0) }.joined()
|
|
82
|
+
|
|
83
|
+
// Detect checksum algorithm based on length
|
|
84
|
+
let detectedAlgorithm: String
|
|
85
|
+
if decryptedChecksum.count == 32 {
|
|
86
|
+
detectedAlgorithm = "SHA-256"
|
|
87
|
+
} else if decryptedChecksum.count == 4 {
|
|
88
|
+
detectedAlgorithm = "CRC32 (deprecated)"
|
|
89
|
+
logger.error("CRC32 checksum detected. This algorithm is deprecated and no longer supported. Please update your CLI to use SHA-256 checksums.")
|
|
90
|
+
} else {
|
|
91
|
+
detectedAlgorithm = "unknown (\(decryptedChecksum.count) bytes)"
|
|
92
|
+
logger.error("Unknown checksum algorithm detected with \(decryptedChecksum.count) bytes. Expected SHA-256 (32 bytes).")
|
|
93
|
+
}
|
|
94
|
+
logger.debug("Decrypted checksum: \(detectedAlgorithm) hex format (length: \(result.count) chars, \(decryptedChecksum.count) bytes)")
|
|
95
|
+
return result
|
|
96
|
+
} catch {
|
|
97
|
+
logger.error("decryptChecksum fail: \(error.localizedDescription)")
|
|
98
|
+
throw CustomError.cannotDecode
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/// Detect checksum algorithm based on hex string length.
|
|
103
|
+
/// SHA-256 = 64 hex chars (32 bytes)
|
|
104
|
+
/// CRC32 = 8 hex chars (4 bytes)
|
|
105
|
+
public static func detectChecksumAlgorithm(_ hexChecksum: String) -> String {
|
|
106
|
+
if hexChecksum.isEmpty {
|
|
107
|
+
return "empty"
|
|
108
|
+
}
|
|
109
|
+
let len = hexChecksum.count
|
|
110
|
+
if len == 64 {
|
|
111
|
+
return "SHA-256"
|
|
112
|
+
} else if len == 8 {
|
|
113
|
+
return "CRC32 (deprecated)"
|
|
114
|
+
} else {
|
|
115
|
+
return "unknown (\(len) hex chars)"
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/// Log checksum info and warn if deprecated algorithm detected.
|
|
120
|
+
public static func logChecksumInfo(label: String, hexChecksum: String) {
|
|
121
|
+
let algorithm = detectChecksumAlgorithm(hexChecksum)
|
|
122
|
+
logger.debug("\(label): \(algorithm) hex format (length: \(hexChecksum.count) chars)")
|
|
123
|
+
if algorithm.contains("CRC32") {
|
|
124
|
+
logger.error("CRC32 checksum detected. This algorithm is deprecated and no longer supported. Please update your CLI to use SHA-256 checksums.")
|
|
125
|
+
} else if algorithm.contains("unknown") {
|
|
126
|
+
logger.error("Unknown checksum algorithm detected. Expected SHA-256 (64 hex chars) but got \(hexChecksum.count) chars.")
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
public static func calcChecksum(filePath: URL) -> String {
|
|
131
|
+
let bufferSize = 1024 * 1024 * 5 // 5 MB
|
|
132
|
+
var sha256 = SHA256()
|
|
133
|
+
|
|
134
|
+
do {
|
|
135
|
+
let fileHandle: FileHandle
|
|
136
|
+
do {
|
|
137
|
+
fileHandle = try FileHandle(forReadingFrom: filePath)
|
|
138
|
+
} catch {
|
|
139
|
+
logger.error("Cannot open file for checksum: \(filePath.path) \(error)")
|
|
140
|
+
return ""
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
defer {
|
|
144
|
+
do {
|
|
145
|
+
try fileHandle.close()
|
|
146
|
+
} catch {
|
|
147
|
+
logger.error("Error closing file: \(error)")
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
while autoreleasepool(invoking: {
|
|
152
|
+
let fileData: Data
|
|
153
|
+
do {
|
|
154
|
+
if #available(iOS 13.4, *) {
|
|
155
|
+
fileData = try fileHandle.read(upToCount: bufferSize) ?? Data()
|
|
156
|
+
} else {
|
|
157
|
+
fileData = fileHandle.readData(ofLength: bufferSize)
|
|
158
|
+
}
|
|
159
|
+
} catch {
|
|
160
|
+
logger.error("Error reading file: \(error)")
|
|
161
|
+
return false
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if fileData.count > 0 {
|
|
165
|
+
sha256.update(data: fileData)
|
|
166
|
+
return true // Continue
|
|
167
|
+
} else {
|
|
168
|
+
return false // End of file
|
|
169
|
+
}
|
|
170
|
+
}) {}
|
|
171
|
+
|
|
172
|
+
let digest = sha256.finalize()
|
|
173
|
+
return digest.compactMap { String(format: "%02x", $0) }.joined()
|
|
174
|
+
} catch {
|
|
175
|
+
logger.error("Cannot get checksum: \(filePath.path) \(error)")
|
|
176
|
+
return ""
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
public static func decryptFile(filePath: URL, publicKey: String, sessionKey: String, version: String) throws {
|
|
181
|
+
if publicKey.isEmpty || sessionKey.isEmpty || sessionKey.components(separatedBy: ":").count != 2 {
|
|
182
|
+
logger.info("Encryption not set, no public key or session, ignored")
|
|
183
|
+
return
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if !publicKey.hasPrefix("-----BEGIN RSA PUBLIC KEY-----") {
|
|
187
|
+
logger.error("The public key is not a valid RSA Public key")
|
|
188
|
+
return
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
do {
|
|
192
|
+
guard let rsaPublicKey = RSAPublicKey.load(rsaPublicKey: publicKey) else {
|
|
193
|
+
logger.error("The public key is not a valid RSA Public key")
|
|
194
|
+
throw CustomError.cannotDecode
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
let sessionKeyComponents = sessionKey.components(separatedBy: ":")
|
|
198
|
+
let ivBase64 = sessionKeyComponents[0]
|
|
199
|
+
let encryptedKeyBase64 = sessionKeyComponents[1]
|
|
200
|
+
|
|
201
|
+
guard let ivData = Data(base64Encoded: ivBase64) else {
|
|
202
|
+
logger.error("Cannot decode sessionKey IV \(ivBase64)")
|
|
203
|
+
throw CustomError.cannotDecode
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if ivData.count != 16 {
|
|
207
|
+
logger.error("IV data has invalid length: \(ivData.count), expected 16")
|
|
208
|
+
throw CustomError.cannotDecode
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
guard let sessionKeyDataEncrypted = Data(base64Encoded: encryptedKeyBase64) else {
|
|
212
|
+
logger.error("Cannot decode sessionKey data \(encryptedKeyBase64)")
|
|
213
|
+
throw NSError(domain: "Invalid session key data", code: 1, userInfo: nil)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
guard let sessionKeyDataDecrypted = rsaPublicKey.decrypt(data: sessionKeyDataEncrypted) else {
|
|
217
|
+
logger.error("Failed to decrypt session key data")
|
|
218
|
+
throw NSError(domain: "Failed to decrypt session key data", code: 2, userInfo: nil)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if sessionKeyDataDecrypted.count != 16 {
|
|
222
|
+
logger.error("Decrypted session key has invalid length: \(sessionKeyDataDecrypted.count), expected 16")
|
|
223
|
+
throw NSError(domain: "Invalid decrypted session key", code: 5, userInfo: nil)
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
let aesPrivateKey = AES128Key(iv: ivData, aes128Key: sessionKeyDataDecrypted, logger: logger)
|
|
227
|
+
|
|
228
|
+
let encryptedData: Data
|
|
229
|
+
do {
|
|
230
|
+
encryptedData = try Data(contentsOf: filePath)
|
|
231
|
+
} catch {
|
|
232
|
+
logger.error("Failed to read encrypted data: \(error)")
|
|
233
|
+
throw NSError(domain: "Failed to read encrypted data", code: 3, userInfo: nil)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if encryptedData.isEmpty {
|
|
237
|
+
logger.error("Encrypted file data is empty")
|
|
238
|
+
throw NSError(domain: "Empty encrypted data", code: 6, userInfo: nil)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
guard let decryptedData = aesPrivateKey.decrypt(data: encryptedData) else {
|
|
242
|
+
logger.error("Failed to decrypt data")
|
|
243
|
+
throw NSError(domain: "Failed to decrypt data", code: 4, userInfo: nil)
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if decryptedData.isEmpty {
|
|
247
|
+
logger.error("Decrypted data is empty")
|
|
248
|
+
throw NSError(domain: "Empty decrypted data", code: 7, userInfo: nil)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
do {
|
|
252
|
+
try decryptedData.write(to: filePath, options: .atomic)
|
|
253
|
+
if !FileManager.default.fileExists(atPath: filePath.path) {
|
|
254
|
+
logger.error("File was not created after write")
|
|
255
|
+
throw NSError(domain: "File write failed", code: 8, userInfo: nil)
|
|
256
|
+
}
|
|
257
|
+
} catch {
|
|
258
|
+
logger.error("Error writing decrypted file: \(error)")
|
|
259
|
+
throw error
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
} catch {
|
|
263
|
+
logger.error("decryptFile fail")
|
|
264
|
+
throw CustomError.cannotDecode
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -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
|
+
}
|