react-native-instantpay-code-push 1.1.8 → 1.2.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/InstantpayCodePush.podspec +5 -1
- package/ios/BundleFileStorageService.swift +1269 -0
- package/ios/BundleMetadata.swift +208 -0
- package/ios/DecompressService.swift +116 -0
- package/ios/FileManagerService.swift +104 -0
- package/ios/HashUtils.swift +73 -0
- package/ios/InstantpayCodePush-Bridging-Header.h +16 -0
- package/ios/InstantpayCodePush.h +39 -1
- package/ios/InstantpayCodePush.mm +332 -4
- package/ios/IpayCodePushHelper.swift +57 -0
- package/ios/IpayCodePushImpl.swift +297 -0
- package/ios/NotificationExtension.swift +13 -0
- package/ios/SignatureVerifier.swift +358 -0
- package/ios/URLSessionDownloadService.swift +251 -0
- package/ios/VersionedPreferencesService.swift +93 -0
- package/ios/ZipDecompressionStrategy.swift +175 -0
- package/package.json +1 -1
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
//
|
|
2
|
+
// NotificationExtension.swift
|
|
3
|
+
// InstantpayCodePush
|
|
4
|
+
//
|
|
5
|
+
// Created by Dhananjay kumar on 09/02/26.
|
|
6
|
+
//
|
|
7
|
+
|
|
8
|
+
import Foundation
|
|
9
|
+
|
|
10
|
+
extension Notification.Name {
|
|
11
|
+
static let downloadProgressUpdate = Notification.Name("IpayCodePushDownloadProgressUpdate")
|
|
12
|
+
static let downloadDidFinish = Notification.Name("IpayCodePushDownloadDidFinish")
|
|
13
|
+
}
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
//
|
|
2
|
+
// SignatureVerifier.swift
|
|
3
|
+
// InstantpayCodePush
|
|
4
|
+
//
|
|
5
|
+
// Created by Dhananjay kumar on 06/02/26.
|
|
6
|
+
//
|
|
7
|
+
|
|
8
|
+
import Foundation
|
|
9
|
+
import Security
|
|
10
|
+
|
|
11
|
+
/// Prefix for signed file hash format.
|
|
12
|
+
private let SIGNED_HASH_PREFIX = "sig:"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
/// Error types for signature verification failures.
|
|
16
|
+
public enum SignatureVerificationError: Error, CustomNSError {
|
|
17
|
+
case publicKeyNotConfigured
|
|
18
|
+
case invalidPublicKeyFormat
|
|
19
|
+
case missingFileHash
|
|
20
|
+
case invalidSignatureFormat
|
|
21
|
+
case signatureVerificationFailed
|
|
22
|
+
case fileHashMismatch
|
|
23
|
+
case fileReadFailed
|
|
24
|
+
case unsignedNotAllowed
|
|
25
|
+
case securityFrameworkError(OSStatus)
|
|
26
|
+
|
|
27
|
+
// CustomNSError protocol implementation
|
|
28
|
+
public static var errorDomain: String {
|
|
29
|
+
return "IpayCodePush.SignatureVerificationError"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
public var errorCode: Int {
|
|
33
|
+
return 0
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
public var errorCodeString: String {
|
|
37
|
+
switch self {
|
|
38
|
+
case .publicKeyNotConfigured: return "PUBLIC_KEY_NOT_CONFIGURED"
|
|
39
|
+
case .invalidPublicKeyFormat: return "INVALID_PUBLIC_KEY_FORMAT"
|
|
40
|
+
case .missingFileHash: return "MISSING_FILE_HASH"
|
|
41
|
+
case .invalidSignatureFormat: return "INVALID_SIGNATURE_FORMAT"
|
|
42
|
+
case .signatureVerificationFailed: return "SIGNATURE_VERIFICATION_FAILED"
|
|
43
|
+
case .fileHashMismatch: return "FILE_HASH_MISMATCH"
|
|
44
|
+
case .fileReadFailed: return "FILE_READ_FAILED"
|
|
45
|
+
case .unsignedNotAllowed: return "UNSIGNED_NOT_ALLOWED"
|
|
46
|
+
case .securityFrameworkError: return "SECURITY_FRAMEWORK_ERROR"
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
public var errorUserInfo: [String: Any] {
|
|
51
|
+
var userInfo: [String: Any] = [:]
|
|
52
|
+
|
|
53
|
+
switch self {
|
|
54
|
+
case .publicKeyNotConfigured:
|
|
55
|
+
userInfo[NSLocalizedDescriptionKey] = "Public key not configured for signature verification"
|
|
56
|
+
userInfo[NSLocalizedRecoverySuggestionErrorKey] = "Add IPAY_CODE_PUSH_PUBLIC_KEY to Info.plist with your RSA public key"
|
|
57
|
+
|
|
58
|
+
case .invalidPublicKeyFormat:
|
|
59
|
+
userInfo[NSLocalizedDescriptionKey] = "Public key format is invalid"
|
|
60
|
+
userInfo[NSLocalizedRecoverySuggestionErrorKey] = "Ensure the public key is in PEM format (BEGIN PUBLIC KEY)"
|
|
61
|
+
|
|
62
|
+
case .missingFileHash:
|
|
63
|
+
userInfo[NSLocalizedDescriptionKey] = "File hash is missing or empty"
|
|
64
|
+
userInfo[NSLocalizedRecoverySuggestionErrorKey] = "Ensure the bundle update includes a valid file hash"
|
|
65
|
+
|
|
66
|
+
case .invalidSignatureFormat:
|
|
67
|
+
userInfo[NSLocalizedDescriptionKey] = "Signature format is invalid or corrupted"
|
|
68
|
+
userInfo[NSLocalizedRecoverySuggestionErrorKey] = "The signature data is malformed or cannot be decoded. Bundle may be corrupted"
|
|
69
|
+
|
|
70
|
+
case .signatureVerificationFailed:
|
|
71
|
+
userInfo[NSLocalizedDescriptionKey] = "Bundle signature verification failed"
|
|
72
|
+
userInfo[NSLocalizedRecoverySuggestionErrorKey] = "The bundle may be corrupted or tampered with. Rejecting update for security"
|
|
73
|
+
|
|
74
|
+
case .fileHashMismatch:
|
|
75
|
+
userInfo[NSLocalizedDescriptionKey] = "File hash verification failed"
|
|
76
|
+
userInfo[NSLocalizedRecoverySuggestionErrorKey] = "The bundle file hash does not match the expected value. File may be corrupted"
|
|
77
|
+
|
|
78
|
+
case .fileReadFailed:
|
|
79
|
+
userInfo[NSLocalizedDescriptionKey] = "Failed to read file for verification"
|
|
80
|
+
userInfo[NSLocalizedRecoverySuggestionErrorKey] = "Could not read file for hash verification"
|
|
81
|
+
|
|
82
|
+
case .unsignedNotAllowed:
|
|
83
|
+
userInfo[NSLocalizedDescriptionKey] = "Unsigned bundle not allowed when signing is enabled"
|
|
84
|
+
userInfo[NSLocalizedRecoverySuggestionErrorKey] = "Public key is configured but bundle is not signed. Rejecting update"
|
|
85
|
+
|
|
86
|
+
case .securityFrameworkError(let status):
|
|
87
|
+
userInfo[NSLocalizedDescriptionKey] = "Security framework error during verification (OSStatus: \(status))"
|
|
88
|
+
userInfo[NSLocalizedRecoverySuggestionErrorKey] = "Check public key format and signature data"
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return userInfo
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Service for verifying bundle integrity through hash or RSA-SHA256 signature verification.
|
|
97
|
+
* Uses iOS Security framework for cryptographic operations.
|
|
98
|
+
*
|
|
99
|
+
* fileHash format:
|
|
100
|
+
* - Signed: `sig:<base64_signature>` - Verify signature (implicitly verifies hash)
|
|
101
|
+
* - Unsigned: `<hex_hash>` - Verify SHA256 hash only
|
|
102
|
+
*
|
|
103
|
+
* Security rules:
|
|
104
|
+
* - null/empty fileHash → REJECT
|
|
105
|
+
* - sig:... + public key configured → verify signature → Install/REJECT
|
|
106
|
+
* - sig:... + public key NOT configured → REJECT (can't verify)
|
|
107
|
+
* - <hash> + public key configured → REJECT (unsigned not allowed)
|
|
108
|
+
* - <hash> + public key NOT configured → verify hash → Install/REJECT
|
|
109
|
+
*/
|
|
110
|
+
public class SignatureVerifier {
|
|
111
|
+
|
|
112
|
+
public static let CLASS_TAG: String = "*SignatureVerifier"
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Reads public key from Info.plist configuration.
|
|
116
|
+
* @return Public key PEM string or nil if not configured
|
|
117
|
+
*/
|
|
118
|
+
private static func getPublicKeyFromConfig() -> String? {
|
|
119
|
+
guard let publicKeyPEM = Bundle.main.object(forInfoDictionaryKey: "IPAY_CODE_PUSH_PUBLIC_KEY") as? String else {
|
|
120
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "IPAY_CODE_PUSH_PUBLIC_KEY not found in Info.plist")
|
|
121
|
+
return nil
|
|
122
|
+
}
|
|
123
|
+
return publicKeyPEM
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Checks if signing is enabled (public key is configured).
|
|
128
|
+
* @return true if public key is configured
|
|
129
|
+
*/
|
|
130
|
+
public static func isSigningEnabled() -> Bool {
|
|
131
|
+
return getPublicKeyFromConfig() != nil
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/**
|
|
135
|
+
* Checks if fileHash is in signed format (starts with "sig:").
|
|
136
|
+
* @param fileHash The file hash string to check
|
|
137
|
+
* @return true if signed format
|
|
138
|
+
*/
|
|
139
|
+
public static func isSignedFormat(_ fileHash: String?) -> Bool {
|
|
140
|
+
guard let hash = fileHash else { return false }
|
|
141
|
+
return hash.hasPrefix(SIGNED_HASH_PREFIX)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Extracts signature from signed format fileHash.
|
|
146
|
+
* @param fileHash The signed file hash (sig:<signature>)
|
|
147
|
+
* @return Base64-encoded signature or nil if not signed format
|
|
148
|
+
*/
|
|
149
|
+
public static func extractSignature(_ fileHash: String?) -> String? {
|
|
150
|
+
guard let hash = fileHash, isSignedFormat(hash) else { return nil }
|
|
151
|
+
return String(hash.dropFirst(SIGNED_HASH_PREFIX.count))
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Verifies bundle integrity based on fileHash format.
|
|
156
|
+
* Determines verification mode by checking for "sig:" prefix.
|
|
157
|
+
*
|
|
158
|
+
* @param fileURL URL of the bundle file to verify
|
|
159
|
+
* @param fileHash Combined hash string (sig:<signature> or <hex_hash>)
|
|
160
|
+
* @return Result indicating verification success or failure with error
|
|
161
|
+
*/
|
|
162
|
+
public static func verifyBundle(fileURL: URL, fileHash: String?) -> Result<Void, SignatureVerificationError> {
|
|
163
|
+
let signingEnabled = isSigningEnabled()
|
|
164
|
+
|
|
165
|
+
// Rule: null/empty fileHash → REJECT
|
|
166
|
+
guard let hash = fileHash, !hash.isEmpty else {
|
|
167
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "[SignatureVerifier] fileHash is null or empty. Rejecting update.")
|
|
168
|
+
return .failure(.missingFileHash)
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if isSignedFormat(hash) {
|
|
172
|
+
// Signed format: sig:<signature>
|
|
173
|
+
guard let signature = extractSignature(hash) else {
|
|
174
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "[SignatureVerifier] Failed to extract signature from fileHash")
|
|
175
|
+
return .failure(.invalidSignatureFormat)
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Rule: sig:... + public key NOT configured → REJECT
|
|
179
|
+
guard signingEnabled else {
|
|
180
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "[SignatureVerifier] Signed bundle but public key not configured. Cannot verify.")
|
|
181
|
+
return .failure(.publicKeyNotConfigured)
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Rule: sig:... + public key configured → verify signature
|
|
185
|
+
return verifySignature(fileURL: fileURL, signatureBase64: signature)
|
|
186
|
+
} else {
|
|
187
|
+
// Unsigned format: <hex_hash>
|
|
188
|
+
|
|
189
|
+
// Rule: <hash> + public key configured → REJECT
|
|
190
|
+
if signingEnabled {
|
|
191
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "[SignatureVerifier] Unsigned bundle not allowed when signing is enabled. Rejecting.")
|
|
192
|
+
return .failure(.unsignedNotAllowed)
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Rule: <hash> + public key NOT configured → verify hash
|
|
196
|
+
return verifyHash(fileURL: fileURL, expectedHash: hash)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Verifies SHA256 hash of a file.
|
|
202
|
+
* @param fileURL URL of the file to verify
|
|
203
|
+
* @param expectedHash Expected SHA256 hash (hex string)
|
|
204
|
+
* @return Result indicating verification success or failure
|
|
205
|
+
*/
|
|
206
|
+
public static func verifyHash(fileURL: URL, expectedHash: String) -> Result<Void, SignatureVerificationError> {
|
|
207
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Verifying hash for file: \(fileURL.lastPathComponent)")
|
|
208
|
+
|
|
209
|
+
guard HashUtils.verifyHash(fileURL: fileURL, expectedHash: expectedHash) else {
|
|
210
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Hash mismatch!")
|
|
211
|
+
return .failure(.fileHashMismatch)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "✅ Hash verified successfully")
|
|
215
|
+
return .success(())
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Verifies RSA-SHA256 signature of a file.
|
|
220
|
+
* Calculates the file hash internally and verifies the signature.
|
|
221
|
+
*
|
|
222
|
+
* @param fileURL URL of the file to verify
|
|
223
|
+
* @param signatureBase64 Base64-encoded RSA-SHA256 signature
|
|
224
|
+
* @return Result indicating verification success or failure with error
|
|
225
|
+
*/
|
|
226
|
+
public static func verifySignature(fileURL: URL, signatureBase64: String) -> Result<Void, SignatureVerificationError> {
|
|
227
|
+
|
|
228
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Verifying signature for file: \(fileURL.lastPathComponent)")
|
|
229
|
+
|
|
230
|
+
// Get public key from config
|
|
231
|
+
guard let publicKeyPEM = getPublicKeyFromConfig() else {
|
|
232
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Cannot verify signature: public key not configured in Info.plist")
|
|
233
|
+
return .failure(.publicKeyNotConfigured)
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Convert PEM to SecKey
|
|
237
|
+
let publicKeyResult = createPublicKey(from: publicKeyPEM)
|
|
238
|
+
guard case .success(let publicKey) = publicKeyResult else {
|
|
239
|
+
if case .failure(let error) = publicKeyResult {
|
|
240
|
+
return .failure(error)
|
|
241
|
+
}
|
|
242
|
+
return .failure(.invalidPublicKeyFormat)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Calculate file hash
|
|
246
|
+
guard let fileHashHex = HashUtils.calculateSHA256(fileURL: fileURL) else {
|
|
247
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Failed to calculate file hash")
|
|
248
|
+
return .failure(.fileReadFailed)
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Calculated file hash: \(fileHashHex)")
|
|
252
|
+
|
|
253
|
+
// Decode signature from base64
|
|
254
|
+
guard let signatureData = Data(base64Encoded: signatureBase64) else {
|
|
255
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Failed to decode signature from base64")
|
|
256
|
+
return .failure(.invalidSignatureFormat)
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Convert hex fileHash to data
|
|
260
|
+
guard let fileHashData = dataFromHexString(fileHashHex) else {
|
|
261
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Failed to convert fileHash from hex")
|
|
262
|
+
return .failure(.invalidSignatureFormat)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Verify signature
|
|
266
|
+
let algorithm: SecKeyAlgorithm = .rsaSignatureMessagePKCS1v15SHA256
|
|
267
|
+
|
|
268
|
+
guard SecKeyIsAlgorithmSupported(publicKey, .verify, algorithm) else {
|
|
269
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "RSA-SHA256 algorithm not supported")
|
|
270
|
+
return .failure(.securityFrameworkError(-1))
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
var error: Unmanaged<CFError>?
|
|
274
|
+
let verified = SecKeyVerifySignature(
|
|
275
|
+
publicKey,
|
|
276
|
+
algorithm,
|
|
277
|
+
fileHashData as CFData,
|
|
278
|
+
signatureData as CFData,
|
|
279
|
+
&error
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
if let err = error?.takeRetainedValue() {
|
|
283
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Verification failed: \(err)")
|
|
284
|
+
return .failure(.signatureVerificationFailed)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if verified {
|
|
288
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "✅ Signature verified successfully")
|
|
289
|
+
return .success(())
|
|
290
|
+
} else {
|
|
291
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "❌ Signature verification failed")
|
|
292
|
+
return .failure(.signatureVerificationFailed)
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* Converts PEM-formatted public key to SecKey.
|
|
298
|
+
* @param publicKeyPEM Public key in PEM format
|
|
299
|
+
* @return SecKey or error
|
|
300
|
+
*/
|
|
301
|
+
private static func createPublicKey(from publicKeyPEM: String) -> Result<SecKey, SignatureVerificationError> {
|
|
302
|
+
// Remove PEM headers/footers and whitespace
|
|
303
|
+
var keyString = publicKeyPEM
|
|
304
|
+
.replacingOccurrences(of: "-----BEGIN PUBLIC KEY-----", with: "")
|
|
305
|
+
.replacingOccurrences(of: "-----END PUBLIC KEY-----", with: "")
|
|
306
|
+
.replacingOccurrences(of: "\\n", with: "")
|
|
307
|
+
.replacingOccurrences(of: "\n", with: "")
|
|
308
|
+
.replacingOccurrences(of: " ", with: "")
|
|
309
|
+
|
|
310
|
+
// Decode base64
|
|
311
|
+
guard let keyData = Data(base64Encoded: keyString) else {
|
|
312
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Failed to decode public key from base64")
|
|
313
|
+
return .failure(.invalidPublicKeyFormat)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// SecKeyCreateWithData auto-detects key size from SPKI-formatted key data.
|
|
317
|
+
// This supports any valid RSA key size (2048, 3072, 4096-bit, etc.)
|
|
318
|
+
let attributes: [String: Any] = [
|
|
319
|
+
kSecAttrKeyType as String: kSecAttrKeyTypeRSA,
|
|
320
|
+
kSecAttrKeyClass as String: kSecAttrKeyClassPublic
|
|
321
|
+
]
|
|
322
|
+
|
|
323
|
+
var error: Unmanaged<CFError>?
|
|
324
|
+
guard let secKey = SecKeyCreateWithData(keyData as CFData, attributes as CFDictionary, &error) else {
|
|
325
|
+
if let err = error?.takeRetainedValue() {
|
|
326
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "SecKeyCreateWithData failed: \(err)")
|
|
327
|
+
}
|
|
328
|
+
return .failure(.invalidPublicKeyFormat)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return .success(secKey)
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Converts hex string to Data.
|
|
336
|
+
* @param hexString Hex-encoded string
|
|
337
|
+
* @return Data or nil if invalid format
|
|
338
|
+
*/
|
|
339
|
+
private static func dataFromHexString(_ hexString: String) -> Data? {
|
|
340
|
+
var data = Data(capacity: hexString.count / 2)
|
|
341
|
+
|
|
342
|
+
var index = hexString.startIndex
|
|
343
|
+
while index < hexString.endIndex {
|
|
344
|
+
let nextIndex = hexString.index(index, offsetBy: 2)
|
|
345
|
+
guard nextIndex <= hexString.endIndex else { return nil }
|
|
346
|
+
|
|
347
|
+
let byteString = hexString[index..<nextIndex]
|
|
348
|
+
guard let byte = UInt8(byteString, radix: 16) else { return nil }
|
|
349
|
+
|
|
350
|
+
data.append(byte)
|
|
351
|
+
index = nextIndex
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
return data
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
//
|
|
2
|
+
// URLSessionDownloadService.swift
|
|
3
|
+
// InstantpayCodePush
|
|
4
|
+
//
|
|
5
|
+
// Created by Dhananjay kumar on 09/02/26.
|
|
6
|
+
//
|
|
7
|
+
|
|
8
|
+
import Foundation
|
|
9
|
+
#if !os(macOS)
|
|
10
|
+
import UIKit
|
|
11
|
+
#endif
|
|
12
|
+
|
|
13
|
+
protocol DownloadService {
|
|
14
|
+
/**
|
|
15
|
+
* Downloads a file from a URL.
|
|
16
|
+
* @param url The URL to download from
|
|
17
|
+
* @param destination The local path to save to
|
|
18
|
+
* @param fileSizeHandler Optional callback called when file size is known
|
|
19
|
+
* @param progressHandler Callback for download progress updates
|
|
20
|
+
* @param completion Callback with downloaded file URL or error
|
|
21
|
+
* @return The download task (optional)
|
|
22
|
+
*/
|
|
23
|
+
func downloadFile(from url: URL, to destination: String, fileSizeHandler: ((Int64) -> Void)?, progressHandler: @escaping (Double) -> Void, completion: @escaping (Result<URL, Error>) -> Void) -> URLSessionDownloadTask?
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
enum DownloadError: Error {
|
|
27
|
+
case incompleteDownload(expected: Int64, actual: Int64)
|
|
28
|
+
case invalidContentLength
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Task state for persistence and recovery
|
|
32
|
+
struct TaskState: Codable {
|
|
33
|
+
let taskIdentifier: Int
|
|
34
|
+
let destination: String
|
|
35
|
+
let bundleId: String
|
|
36
|
+
let startedAt: TimeInterval
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
class URLSessionDownloadService: NSObject, DownloadService {
|
|
40
|
+
|
|
41
|
+
private var session: URLSession!
|
|
42
|
+
private var backgroundSession: URLSession!
|
|
43
|
+
private var progressHandlers: [URLSessionTask: (Double) -> Void] = [:]
|
|
44
|
+
private var completionHandlers: [URLSessionTask: (Result<URL, Error>) -> Void] = [:]
|
|
45
|
+
private var destinations: [URLSessionTask: String] = [:]
|
|
46
|
+
private var fileSizeHandlers: [URLSessionTask: (Int64) -> Void] = [:]
|
|
47
|
+
private var taskStates: [Int: TaskState] = [:]
|
|
48
|
+
|
|
49
|
+
override init() {
|
|
50
|
+
super.init()
|
|
51
|
+
|
|
52
|
+
// Foreground session (existing behavior)
|
|
53
|
+
let defaultConfig = URLSessionConfiguration.default
|
|
54
|
+
session = URLSession(configuration: defaultConfig, delegate: self, delegateQueue: nil)
|
|
55
|
+
|
|
56
|
+
// Background session for persistent downloads
|
|
57
|
+
let backgroundConfig = URLSessionConfiguration.background(
|
|
58
|
+
withIdentifier: "com.ipaycodepush.background.download"
|
|
59
|
+
)
|
|
60
|
+
backgroundConfig.isDiscretionary = false
|
|
61
|
+
backgroundConfig.sessionSendsLaunchEvents = true
|
|
62
|
+
backgroundSession = URLSession(configuration: backgroundConfig, delegate: self, delegateQueue: nil)
|
|
63
|
+
|
|
64
|
+
// Load persisted task states
|
|
65
|
+
taskStates = loadTaskStates()
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// MARK: - State Persistence
|
|
69
|
+
|
|
70
|
+
private var stateFileURL: URL {
|
|
71
|
+
let documentsPath = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
|
|
72
|
+
return documentsPath.appendingPathComponent("download-state.json")
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
private func saveTaskState(_ state: TaskState) {
|
|
76
|
+
taskStates[state.taskIdentifier] = state
|
|
77
|
+
|
|
78
|
+
if let data = try? JSONEncoder().encode(taskStates) {
|
|
79
|
+
try? data.write(to: stateFileURL)
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private func loadTaskStates() -> [Int: TaskState] {
|
|
84
|
+
guard let data = try? Data(contentsOf: stateFileURL),
|
|
85
|
+
let states = try? JSONDecoder().decode([Int: TaskState].self, from: data) else {
|
|
86
|
+
return [:]
|
|
87
|
+
}
|
|
88
|
+
return states
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private func removeTaskState(_ taskIdentifier: Int) {
|
|
92
|
+
taskStates.removeValue(forKey: taskIdentifier)
|
|
93
|
+
|
|
94
|
+
if let data = try? JSONEncoder().encode(taskStates) {
|
|
95
|
+
try? data.write(to: stateFileURL)
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
func downloadFile(from url: URL, to destination: String, fileSizeHandler: ((Int64) -> Void)?, progressHandler: @escaping (Double) -> Void, completion: @escaping (Result<URL, Error>) -> Void) -> URLSessionDownloadTask? {
|
|
100
|
+
// Determine if we should use background session
|
|
101
|
+
#if !os(macOS)
|
|
102
|
+
let appState = UIApplication.shared.applicationState
|
|
103
|
+
let useBackgroundSession = (appState == .background || appState == .inactive)
|
|
104
|
+
#else
|
|
105
|
+
let useBackgroundSession = false
|
|
106
|
+
#endif
|
|
107
|
+
|
|
108
|
+
let selectedSession = useBackgroundSession ? backgroundSession : session
|
|
109
|
+
let task = selectedSession?.downloadTask(with: url)
|
|
110
|
+
|
|
111
|
+
guard let task = task else {
|
|
112
|
+
return nil
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
progressHandlers[task] = progressHandler
|
|
116
|
+
completionHandlers[task] = completion
|
|
117
|
+
destinations[task] = destination
|
|
118
|
+
if let handler = fileSizeHandler {
|
|
119
|
+
fileSizeHandlers[task] = handler
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Extract bundleId from destination path (e.g., "bundle-store/{bundleId}/bundle.zip")
|
|
123
|
+
let bundleId = (destination as NSString).pathComponents
|
|
124
|
+
.dropFirst()
|
|
125
|
+
.first(where: { $0 != "bundle-store" }) ?? "unknown"
|
|
126
|
+
|
|
127
|
+
// Save task metadata for background recovery
|
|
128
|
+
let taskState = TaskState(
|
|
129
|
+
taskIdentifier: task.taskIdentifier,
|
|
130
|
+
destination: destination,
|
|
131
|
+
bundleId: bundleId,
|
|
132
|
+
startedAt: Date().timeIntervalSince1970
|
|
133
|
+
)
|
|
134
|
+
saveTaskState(taskState)
|
|
135
|
+
|
|
136
|
+
task.resume()
|
|
137
|
+
return task
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
extension URLSessionDownloadService: URLSessionDownloadDelegate {
|
|
142
|
+
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didFinishDownloadingTo location: URL) {
|
|
143
|
+
let completion = completionHandlers[downloadTask]
|
|
144
|
+
let destination = destinations[downloadTask]
|
|
145
|
+
|
|
146
|
+
defer {
|
|
147
|
+
progressHandlers.removeValue(forKey: downloadTask)
|
|
148
|
+
completionHandlers.removeValue(forKey: downloadTask)
|
|
149
|
+
destinations.removeValue(forKey: downloadTask)
|
|
150
|
+
fileSizeHandlers.removeValue(forKey: downloadTask)
|
|
151
|
+
removeTaskState(downloadTask.taskIdentifier)
|
|
152
|
+
|
|
153
|
+
// 다운로드 완료 알림
|
|
154
|
+
NotificationCenter.default.post(name: .downloadDidFinish, object: downloadTask)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
guard let destination = destination else {
|
|
158
|
+
completion?(.failure(NSError(domain: "IpayCodePushError", code: 1, userInfo: [NSLocalizedDescriptionKey: "Destination path not found"])))
|
|
159
|
+
return
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Verify file size
|
|
163
|
+
let expectedSize = downloadTask.response?.expectedContentLength ?? -1
|
|
164
|
+
let actualSize: Int64?
|
|
165
|
+
do {
|
|
166
|
+
let attributes = try FileManager.default.attributesOfItem(atPath: location.path)
|
|
167
|
+
actualSize = attributes[.size] as? Int64
|
|
168
|
+
} catch {
|
|
169
|
+
IpayCodePushHelper.logPrint(classTag: "DownloadService", log: "Failed to get file attributes: \(error.localizedDescription)")
|
|
170
|
+
actualSize = nil
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if expectedSize > 0, let actualSize = actualSize, actualSize != expectedSize {
|
|
174
|
+
IpayCodePushHelper.logPrint(classTag: "DownloadService", log: "Download incomplete: \(actualSize) / \(expectedSize) bytes")
|
|
175
|
+
// Delete incomplete file
|
|
176
|
+
try? FileManager.default.removeItem(at: location)
|
|
177
|
+
completion?(.failure(DownloadError.incompleteDownload(expected: expectedSize, actual: actualSize)))
|
|
178
|
+
return
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
do {
|
|
182
|
+
let destinationURL = URL(fileURLWithPath: destination)
|
|
183
|
+
|
|
184
|
+
// Delete existing file if needed
|
|
185
|
+
if FileManager.default.fileExists(atPath: destination) {
|
|
186
|
+
try FileManager.default.removeItem(at: destinationURL)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
try FileManager.default.copyItem(at: location, to: destinationURL)
|
|
190
|
+
|
|
191
|
+
IpayCodePushHelper.logPrint(classTag: "DownloadService", log: "Download completed successfully: \(actualSize ?? 0) bytes")
|
|
192
|
+
|
|
193
|
+
completion?(.success(destinationURL))
|
|
194
|
+
} catch {
|
|
195
|
+
|
|
196
|
+
IpayCodePushHelper.logPrint(classTag: "DownloadService", log: "Failed to copy downloaded file: \(error.localizedDescription)")
|
|
197
|
+
|
|
198
|
+
completion?(.failure(error))
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
func urlSession(_ session: URLSession, task: URLSessionTask, didCompleteWithError error: Error?) {
|
|
203
|
+
let completion = completionHandlers[task]
|
|
204
|
+
defer {
|
|
205
|
+
progressHandlers.removeValue(forKey: task)
|
|
206
|
+
completionHandlers.removeValue(forKey: task)
|
|
207
|
+
destinations.removeValue(forKey: task)
|
|
208
|
+
fileSizeHandlers.removeValue(forKey: task)
|
|
209
|
+
removeTaskState(task.taskIdentifier)
|
|
210
|
+
|
|
211
|
+
NotificationCenter.default.post(name: .downloadDidFinish, object: task)
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
if let error = error {
|
|
215
|
+
completion?(.failure(error))
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
func urlSession(_ session: URLSession, downloadTask: URLSessionDownloadTask, didWriteData bytesWritten: Int64, totalBytesWritten: Int64, totalBytesExpectedToWrite: Int64) {
|
|
220
|
+
let progressHandler = progressHandlers[downloadTask]
|
|
221
|
+
|
|
222
|
+
// Call file size handler on first callback when size is known
|
|
223
|
+
if totalBytesWritten == bytesWritten && bytesWritten > 0 {
|
|
224
|
+
if let fileSizeHandler = fileSizeHandlers[downloadTask] {
|
|
225
|
+
if totalBytesExpectedToWrite > 0 {
|
|
226
|
+
fileSizeHandler(totalBytesExpectedToWrite)
|
|
227
|
+
} else {
|
|
228
|
+
|
|
229
|
+
IpayCodePushHelper.logPrint(classTag: "DownloadService", log: "Content-Length not available, proceeding without disk space check")
|
|
230
|
+
}
|
|
231
|
+
fileSizeHandlers.removeValue(forKey: downloadTask)
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
if totalBytesExpectedToWrite > 0 {
|
|
236
|
+
let progress = Double(totalBytesWritten) / Double(totalBytesExpectedToWrite)
|
|
237
|
+
progressHandler?(progress)
|
|
238
|
+
|
|
239
|
+
let progressInfo: [String: Any] = [
|
|
240
|
+
"progress": progress,
|
|
241
|
+
"totalBytesReceived": totalBytesWritten,
|
|
242
|
+
"totalBytesExpected": totalBytesExpectedToWrite
|
|
243
|
+
]
|
|
244
|
+
NotificationCenter.default.post(name: .downloadProgressUpdate, object: downloadTask, userInfo: progressInfo)
|
|
245
|
+
} else {
|
|
246
|
+
progressHandler?(0)
|
|
247
|
+
|
|
248
|
+
NotificationCenter.default.post(name: .downloadProgressUpdate, object: downloadTask, userInfo: ["progress": 0.0, "totalBytesReceived": 0, "totalBytesExpected": 0])
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
}
|