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,1269 @@
|
|
|
1
|
+
//
|
|
2
|
+
// BundleFileStorageService.swift
|
|
3
|
+
// InstantpayCodePush
|
|
4
|
+
//
|
|
5
|
+
// Created by Dhananjay kumar on 06/02/26.
|
|
6
|
+
//
|
|
7
|
+
|
|
8
|
+
import Foundation
|
|
9
|
+
|
|
10
|
+
public enum BundleStorageError: Error, CustomNSError {
|
|
11
|
+
case directoryCreationFailed
|
|
12
|
+
case downloadFailed(Error)
|
|
13
|
+
case incompleteDownload(expected: Int64, actual: Int64)
|
|
14
|
+
case extractionFormatError(Error)
|
|
15
|
+
case invalidBundle
|
|
16
|
+
case insufficientDiskSpace
|
|
17
|
+
case signatureVerificationFailed(SignatureVerificationError)
|
|
18
|
+
case moveOperationFailed(Error)
|
|
19
|
+
case bundleInCrashedHistory(String)
|
|
20
|
+
case unknown(Error?)
|
|
21
|
+
|
|
22
|
+
// CustomNSError protocol implementation
|
|
23
|
+
public static var errorDomain: String {
|
|
24
|
+
return "IpayCodePush"
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
public var errorCode: Int {
|
|
28
|
+
return 0
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public var errorCodeString: String {
|
|
32
|
+
switch self {
|
|
33
|
+
case .directoryCreationFailed: return "DIRECTORY_CREATION_FAILED"
|
|
34
|
+
case .downloadFailed: return "DOWNLOAD_FAILED"
|
|
35
|
+
case .incompleteDownload: return "INCOMPLETE_DOWNLOAD"
|
|
36
|
+
case .extractionFormatError: return "EXTRACTION_FORMAT_ERROR"
|
|
37
|
+
case .invalidBundle: return "INVALID_BUNDLE"
|
|
38
|
+
case .insufficientDiskSpace: return "INSUFFICIENT_DISK_SPACE"
|
|
39
|
+
case .signatureVerificationFailed: return "SIGNATURE_VERIFICATION_FAILED"
|
|
40
|
+
case .moveOperationFailed: return "MOVE_OPERATION_FAILED"
|
|
41
|
+
case .bundleInCrashedHistory: return "BUNDLE_IN_CRASHED_HISTORY"
|
|
42
|
+
case .unknown: return "UNKNOWN_ERROR"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
public var errorUserInfo: [String: Any] {
|
|
47
|
+
var userInfo: [String: Any] = [:]
|
|
48
|
+
|
|
49
|
+
switch self {
|
|
50
|
+
case .directoryCreationFailed:
|
|
51
|
+
userInfo[NSLocalizedDescriptionKey] = "Failed to create required directory for bundle storage"
|
|
52
|
+
userInfo[NSLocalizedRecoverySuggestionErrorKey] = "Check app permissions and available disk space"
|
|
53
|
+
|
|
54
|
+
case .downloadFailed(let underlyingError):
|
|
55
|
+
userInfo[NSLocalizedDescriptionKey] = "Failed to download bundle from server"
|
|
56
|
+
userInfo[NSUnderlyingErrorKey] = underlyingError
|
|
57
|
+
userInfo[NSLocalizedRecoverySuggestionErrorKey] = "Check network connection and try again"
|
|
58
|
+
|
|
59
|
+
case .incompleteDownload(let expected, let actual):
|
|
60
|
+
userInfo[NSLocalizedDescriptionKey] = "Download incomplete: received \(actual) bytes, expected \(expected) bytes"
|
|
61
|
+
userInfo[NSLocalizedRecoverySuggestionErrorKey] = "The download was interrupted. Check network connection and try again"
|
|
62
|
+
|
|
63
|
+
case .extractionFormatError(let underlyingError):
|
|
64
|
+
userInfo[NSLocalizedDescriptionKey] = "Invalid or corrupted bundle archive format"
|
|
65
|
+
userInfo[NSUnderlyingErrorKey] = underlyingError
|
|
66
|
+
userInfo[NSLocalizedRecoverySuggestionErrorKey] = "The bundle archive may be corrupted or in an unsupported format. Try downloading again"
|
|
67
|
+
|
|
68
|
+
case .invalidBundle:
|
|
69
|
+
userInfo[NSLocalizedDescriptionKey] = "Bundle missing required platform files (index.ios.bundle or main.jsbundle)"
|
|
70
|
+
userInfo[NSLocalizedRecoverySuggestionErrorKey] = "Verify the bundle was built correctly with metro bundler"
|
|
71
|
+
|
|
72
|
+
case .insufficientDiskSpace:
|
|
73
|
+
userInfo[NSLocalizedDescriptionKey] = "Insufficient disk space to download and extract bundle"
|
|
74
|
+
userInfo[NSLocalizedRecoverySuggestionErrorKey] = "Free up device storage and try again"
|
|
75
|
+
|
|
76
|
+
case .signatureVerificationFailed(let underlyingError):
|
|
77
|
+
userInfo[NSLocalizedDescriptionKey] = "Bundle signature verification failed"
|
|
78
|
+
userInfo[NSUnderlyingErrorKey] = underlyingError
|
|
79
|
+
userInfo[NSLocalizedRecoverySuggestionErrorKey] = "The bundle signature is invalid. Update rejected for security"
|
|
80
|
+
|
|
81
|
+
case .moveOperationFailed(let underlyingError):
|
|
82
|
+
userInfo[NSLocalizedDescriptionKey] = "Failed to move bundle to final location"
|
|
83
|
+
userInfo[NSUnderlyingErrorKey] = underlyingError
|
|
84
|
+
userInfo[NSLocalizedRecoverySuggestionErrorKey] = "Check file system permissions"
|
|
85
|
+
|
|
86
|
+
case .bundleInCrashedHistory(let bundleId):
|
|
87
|
+
userInfo[NSLocalizedDescriptionKey] = "Bundle '\(bundleId)' is in crashed history and cannot be applied"
|
|
88
|
+
userInfo[NSLocalizedRecoverySuggestionErrorKey] = "This bundle previously caused a crash and was blocked for safety"
|
|
89
|
+
|
|
90
|
+
case .unknown(let underlyingError):
|
|
91
|
+
userInfo[NSLocalizedDescriptionKey] = "An unknown error occurred during bundle update"
|
|
92
|
+
if let error = underlyingError {
|
|
93
|
+
userInfo[NSUnderlyingErrorKey] = error
|
|
94
|
+
}
|
|
95
|
+
userInfo[NSLocalizedRecoverySuggestionErrorKey] = "Please try again or contact support with error details"
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
return userInfo
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Protocol for interacting with bundle storage system.
|
|
104
|
+
* `updateBundle` operates asynchronously using a completion handler.
|
|
105
|
+
* Other operations are synchronous.
|
|
106
|
+
*/
|
|
107
|
+
public protocol BundleStorageService {
|
|
108
|
+
|
|
109
|
+
// Bundle URL operations
|
|
110
|
+
func setBundleURL(localPath: String?) -> Result<Void, Error>
|
|
111
|
+
func getCachedBundleURL() -> URL?
|
|
112
|
+
func getFallbackBundleURL(bundle: Bundle) -> URL? // Synchronous as it's lightweight
|
|
113
|
+
func getBundleURL(bundle: Bundle) -> URL?
|
|
114
|
+
|
|
115
|
+
// Bundle update
|
|
116
|
+
func updateBundle(bundleId: String, fileUrl: URL?, fileHash: String?, progressHandler: @escaping (Double) -> Void, completion: @escaping (Result<Bool, Error>) -> Void)
|
|
117
|
+
|
|
118
|
+
// Rollback support
|
|
119
|
+
func notifyAppReady(bundleId: String) -> [String: Any]
|
|
120
|
+
func getCrashHistory() -> CrashedHistory
|
|
121
|
+
func clearCrashHistory() -> Bool
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Gets the base URL for the current active bundle directory
|
|
125
|
+
* @return Base URL string (e.g., "file:///data/.../bundle-store/abc123") or empty string
|
|
126
|
+
*/
|
|
127
|
+
func getBaseURL() -> String
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
class BundleFileStorageService: BundleStorageService {
|
|
131
|
+
|
|
132
|
+
private let fileSystem: FileSystemService
|
|
133
|
+
private let downloadService: DownloadService
|
|
134
|
+
private let decompressService: DecompressService
|
|
135
|
+
private let preferences: PreferencesService
|
|
136
|
+
private let isolationKey: String
|
|
137
|
+
private let CLASS_TAG = "*BundleStorage"
|
|
138
|
+
|
|
139
|
+
private let id = Int.random(in: 1..<100)
|
|
140
|
+
|
|
141
|
+
// Queue for potentially long-running sequences within updateBundle or for explicit background tasks.
|
|
142
|
+
private let fileOperationQueue: DispatchQueue
|
|
143
|
+
|
|
144
|
+
private var activeTasks: [URLSessionTask] = []
|
|
145
|
+
|
|
146
|
+
// Session-only rollback tracking (in-memory)
|
|
147
|
+
private var sessionRollbackBundleId: String?
|
|
148
|
+
|
|
149
|
+
public init(fileSystem: FileSystemService,
|
|
150
|
+
downloadService: DownloadService,
|
|
151
|
+
decompressService: DecompressService,
|
|
152
|
+
preferences: PreferencesService,
|
|
153
|
+
isolationKey: String) {
|
|
154
|
+
|
|
155
|
+
self.fileSystem = fileSystem
|
|
156
|
+
self.downloadService = downloadService
|
|
157
|
+
self.decompressService = decompressService
|
|
158
|
+
self.preferences = preferences
|
|
159
|
+
self.isolationKey = isolationKey
|
|
160
|
+
|
|
161
|
+
// Create queue for file operations
|
|
162
|
+
self.fileOperationQueue = DispatchQueue(label: "in.instantpaycodepush.fileoperations",
|
|
163
|
+
qos: .utility,
|
|
164
|
+
attributes: .concurrent)
|
|
165
|
+
|
|
166
|
+
// Ensure bundle store directory exists
|
|
167
|
+
_ = bundleStoreDir()
|
|
168
|
+
|
|
169
|
+
// Clean up old bundles if isolationKey format changed
|
|
170
|
+
checkAndCleanupIfIsolationKeyChanged()
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// MARK: - Metadata File Paths
|
|
174
|
+
|
|
175
|
+
private func metadataFileURL() -> URL? {
|
|
176
|
+
guard case .success(let storeDir) = bundleStoreDir() else {
|
|
177
|
+
return nil
|
|
178
|
+
}
|
|
179
|
+
return URL(fileURLWithPath: storeDir).appendingPathComponent(BundleMetadata.metadataFilename)
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
private func crashedHistoryFileURL() -> URL? {
|
|
183
|
+
guard case .success(let storeDir) = bundleStoreDir() else {
|
|
184
|
+
return nil
|
|
185
|
+
}
|
|
186
|
+
return URL(fileURLWithPath: storeDir).appendingPathComponent(CrashedHistory.crashedHistoryFilename)
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// MARK: - Metadata Operations
|
|
190
|
+
|
|
191
|
+
private func loadMetadataOrNull() -> BundleMetadata? {
|
|
192
|
+
guard let file = metadataFileURL() else {
|
|
193
|
+
return nil
|
|
194
|
+
}
|
|
195
|
+
return BundleMetadata.load(from: file, expectedIsolationKey: isolationKey)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private func saveMetadata(_ metadata: BundleMetadata) -> Bool {
|
|
199
|
+
guard let file = metadataFileURL() else {
|
|
200
|
+
return false
|
|
201
|
+
}
|
|
202
|
+
var updatedMetadata = metadata
|
|
203
|
+
updatedMetadata.isolationKey = isolationKey
|
|
204
|
+
return updatedMetadata.save(to: file)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Checks if isolationKey has changed and cleans up old bundles if needed.
|
|
209
|
+
* This handles migration when isolationKey format changes.
|
|
210
|
+
*/
|
|
211
|
+
private func checkAndCleanupIfIsolationKeyChanged() {
|
|
212
|
+
guard let metadataURL = metadataFileURL() else {
|
|
213
|
+
return
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
let metadataPath = metadataURL.path
|
|
217
|
+
|
|
218
|
+
guard fileSystem.fileExists(atPath: metadataPath) else {
|
|
219
|
+
// First launch - no cleanup needed
|
|
220
|
+
return
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
do {
|
|
224
|
+
let jsonString = try String(contentsOf: metadataURL, encoding: .utf8)
|
|
225
|
+
if let jsonData = jsonString.data(using: .utf8),
|
|
226
|
+
let json = try JSONSerialization.jsonObject(with: jsonData) as? [String: Any],
|
|
227
|
+
let storedKey = json["isolationKey"] as? String {
|
|
228
|
+
|
|
229
|
+
if storedKey != isolationKey {
|
|
230
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "isolationKey changed: \(storedKey) -> \(isolationKey)")
|
|
231
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Cleaning up old bundles for migration")
|
|
232
|
+
cleanupAllBundlesForMigration()
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
} catch {
|
|
236
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Error checking isolationKey: \(error.localizedDescription)")
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Removes all bundle directories during migration.
|
|
242
|
+
* Called when isolationKey format changes.
|
|
243
|
+
*/
|
|
244
|
+
private func cleanupAllBundlesForMigration() {
|
|
245
|
+
guard case .success(let storeDir) = bundleStoreDir() else {
|
|
246
|
+
return
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
do {
|
|
250
|
+
let contents = try fileSystem.contentsOfDirectory(atPath: storeDir)
|
|
251
|
+
var cleanedCount = 0
|
|
252
|
+
|
|
253
|
+
for item in contents {
|
|
254
|
+
let fullPath = (storeDir as NSString).appendingPathComponent(item)
|
|
255
|
+
|
|
256
|
+
// Skip metadata files
|
|
257
|
+
if item == "metadata.json" || item == "crashed-history.json" {
|
|
258
|
+
continue
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if fileSystem.fileExists(atPath: fullPath) {
|
|
262
|
+
try fileSystem.removeItem(atPath: fullPath)
|
|
263
|
+
cleanedCount += 1
|
|
264
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Migration: removed old bundle \(item)")
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Migration cleanup complete: removed \(cleanedCount) bundles")
|
|
269
|
+
} catch {
|
|
270
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Error during migration cleanup: \(error.localizedDescription)")
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// MARK: - Crashed History Operations
|
|
275
|
+
|
|
276
|
+
private func loadCrashedHistory() -> CrashedHistory {
|
|
277
|
+
guard let file = crashedHistoryFileURL() else {
|
|
278
|
+
return CrashedHistory()
|
|
279
|
+
}
|
|
280
|
+
return CrashedHistory.load(from: file)
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
private func saveCrashedHistory(_ history: CrashedHistory) -> Bool {
|
|
284
|
+
guard let file = crashedHistoryFileURL() else {
|
|
285
|
+
return false
|
|
286
|
+
}
|
|
287
|
+
return history.save(to: file)
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// MARK: - State Machine Methods
|
|
291
|
+
|
|
292
|
+
private func isVerificationPending(_ metadata: BundleMetadata) -> Bool {
|
|
293
|
+
return metadata.verificationPending && metadata.stagingBundleId != nil
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
private func wasVerificationAttempted(_ metadata: BundleMetadata) -> Bool {
|
|
297
|
+
return metadata.verificationAttemptedAt != nil
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private func markVerificationAttempted() {
|
|
301
|
+
guard var metadata = loadMetadataOrNull() else {
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
metadata.verificationAttemptedAt = Date().timeIntervalSince1970 * 1000
|
|
305
|
+
let _ = saveMetadata(metadata)
|
|
306
|
+
|
|
307
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Marked verification attempted for staging bundle: \(metadata.stagingBundleId ?? "nil")")
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
private func incrementStagingExecutionCount() {
|
|
311
|
+
guard var metadata = loadMetadataOrNull() else {
|
|
312
|
+
return
|
|
313
|
+
}
|
|
314
|
+
metadata.stagingExecutionCount = (metadata.stagingExecutionCount ?? 0) + 1
|
|
315
|
+
metadata.updatedAt = Date().timeIntervalSince1970 * 1000
|
|
316
|
+
let _ = saveMetadata(metadata)
|
|
317
|
+
|
|
318
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Incremented staging execution count to: \(metadata.stagingExecutionCount ?? 0)")
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private func promoteStagingToStable() {
|
|
322
|
+
guard var metadata = loadMetadataOrNull() else {
|
|
323
|
+
return
|
|
324
|
+
}
|
|
325
|
+
guard let stagingId = metadata.stagingBundleId else {
|
|
326
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "No staging bundle to promote")
|
|
327
|
+
return
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
let oldStableId = metadata.stableBundleId
|
|
331
|
+
metadata.stableBundleId = stagingId
|
|
332
|
+
metadata.stagingBundleId = nil
|
|
333
|
+
metadata.verificationPending = false
|
|
334
|
+
metadata.verificationAttemptedAt = nil
|
|
335
|
+
metadata.stagingExecutionCount = nil
|
|
336
|
+
metadata.updatedAt = Date().timeIntervalSince1970 * 1000
|
|
337
|
+
|
|
338
|
+
if saveMetadata(metadata) {
|
|
339
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Promoted staging '\(stagingId)' to stable (old stable: \(oldStableId ?? "nil"))")
|
|
340
|
+
|
|
341
|
+
// Clean up old stable bundle
|
|
342
|
+
if let oldId = oldStableId, oldId != stagingId {
|
|
343
|
+
let _ = cleanupOldBundles(currentBundleId: stagingId, bundleId: nil)
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
private func rollbackToStable() {
|
|
349
|
+
guard var metadata = loadMetadataOrNull() else {
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
guard let stagingId = metadata.stagingBundleId else {
|
|
353
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "No staging bundle to rollback from")
|
|
354
|
+
return
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Add crashed bundle to history
|
|
358
|
+
var crashedHistory = loadCrashedHistory()
|
|
359
|
+
crashedHistory.addEntry(stagingId)
|
|
360
|
+
let _ = saveCrashedHistory(crashedHistory)
|
|
361
|
+
|
|
362
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "[BundleStorage('\(id)')] Added bundle '\(stagingId)' to crashed history")
|
|
363
|
+
|
|
364
|
+
// Save rollback info to session variable (memory only)
|
|
365
|
+
self.sessionRollbackBundleId = stagingId
|
|
366
|
+
|
|
367
|
+
// Clear staging
|
|
368
|
+
metadata.stagingBundleId = nil
|
|
369
|
+
metadata.verificationPending = false
|
|
370
|
+
metadata.verificationAttemptedAt = nil
|
|
371
|
+
metadata.stagingExecutionCount = nil
|
|
372
|
+
metadata.updatedAt = Date().timeIntervalSince1970 * 1000
|
|
373
|
+
|
|
374
|
+
if saveMetadata(metadata) {
|
|
375
|
+
|
|
376
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Rolled back to stable bundle: \(metadata.stableBundleId ?? "fallback")")
|
|
377
|
+
|
|
378
|
+
// Update IpayCodePushBundleURL to point to stable bundle
|
|
379
|
+
if let stableId = metadata.stableBundleId {
|
|
380
|
+
if case .success(let storeDir) = bundleStoreDir() {
|
|
381
|
+
let stableBundleDir = (storeDir as NSString).appendingPathComponent(stableId)
|
|
382
|
+
if case .success(let bundlePath) = findBundleFile(in: stableBundleDir), let path = bundlePath {
|
|
383
|
+
let _ = setBundleURL(localPath: path)
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
} else {
|
|
387
|
+
// Reset to fallback
|
|
388
|
+
let _ = setBundleURL(localPath: nil)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Clean up failed staging bundle
|
|
392
|
+
let _ = cleanupOldBundles(currentBundleId: metadata.stableBundleId, bundleId: nil)
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
// MARK: - Directory Management
|
|
397
|
+
|
|
398
|
+
/**
|
|
399
|
+
* Ensures a directory exists at the specified path. Creates it if necessary.
|
|
400
|
+
* Executes synchronously on the calling thread.
|
|
401
|
+
* @param path The path where directory should exist
|
|
402
|
+
* @return Result with the path or an error
|
|
403
|
+
*/
|
|
404
|
+
private func ensureDirectoryExists(path: String) -> Result<String, Error> {
|
|
405
|
+
if !self.fileSystem.fileExists(atPath: path) {
|
|
406
|
+
if !self.fileSystem.createDirectory(atPath: path) {
|
|
407
|
+
return .failure(BundleStorageError.directoryCreationFailed)
|
|
408
|
+
}
|
|
409
|
+
}
|
|
410
|
+
return .success(path)
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Gets the path to the bundle store directory.
|
|
415
|
+
* Executes synchronously on the calling thread.
|
|
416
|
+
* @return Result with the directory path or error
|
|
417
|
+
*/
|
|
418
|
+
func bundleStoreDir() -> Result<String, Error> {
|
|
419
|
+
let path = (fileSystem.documentsPath() as NSString).appendingPathComponent("bundle-store")
|
|
420
|
+
return ensureDirectoryExists(path: path)
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Gets the path to the temporary directory.
|
|
425
|
+
* Executes synchronously on the calling thread.
|
|
426
|
+
* @return Result with the directory path or error
|
|
427
|
+
*/
|
|
428
|
+
func tempDir() -> Result<String, Error> {
|
|
429
|
+
let path = (fileSystem.documentsPath() as NSString).appendingPathComponent("bundle-temp")
|
|
430
|
+
return ensureDirectoryExists(path: path)
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Cleans up temporary files safely. Executes synchronously on the calling thread.
|
|
435
|
+
* @param paths Array of file/directory paths to clean up
|
|
436
|
+
*/
|
|
437
|
+
private func cleanupTemporaryFiles(_ paths: [String]) {
|
|
438
|
+
let workItem = DispatchWorkItem {
|
|
439
|
+
for path in paths {
|
|
440
|
+
do {
|
|
441
|
+
if self.fileSystem.fileExists(atPath: path) {
|
|
442
|
+
try self.fileSystem.removeItem(atPath: path)
|
|
443
|
+
IpayCodePushHelper.logPrint(classTag: self.CLASS_TAG, log: "Cleaned up temporary file: \(path)")
|
|
444
|
+
}
|
|
445
|
+
} catch {
|
|
446
|
+
IpayCodePushHelper.logPrint(classTag: self.CLASS_TAG, log: "Failed to clean up temporary file \(path): \(error.localizedDescription)")
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
DispatchQueue.global(qos: .background).async(execute: workItem)
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// MARK: - Bundle File Operations
|
|
454
|
+
|
|
455
|
+
/**
|
|
456
|
+
* Finds the bundle file within a directory by checking direct paths.
|
|
457
|
+
* Executes synchronously on the calling thread.
|
|
458
|
+
* @param directoryPath Directory to search in
|
|
459
|
+
* @return Result with path to bundle file or error
|
|
460
|
+
*/
|
|
461
|
+
func findBundleFile(in directoryPath: String) -> Result<String?, Error> {
|
|
462
|
+
|
|
463
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Searching for bundle file in directory: \(directoryPath)")
|
|
464
|
+
|
|
465
|
+
// Check directory contents
|
|
466
|
+
do {
|
|
467
|
+
let contents = try self.fileSystem.contentsOfDirectory(atPath: directoryPath)
|
|
468
|
+
|
|
469
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Directory contents: \(contents)")
|
|
470
|
+
|
|
471
|
+
// Check for iOS bundle file directly
|
|
472
|
+
let iosBundlePath = (directoryPath as NSString).appendingPathComponent("index.ios.bundle")
|
|
473
|
+
if self.fileSystem.fileExists(atPath: iosBundlePath) {
|
|
474
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Found iOS bundle atPath: \(iosBundlePath)")
|
|
475
|
+
return .success(iosBundlePath)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Check for main bundle file
|
|
479
|
+
let mainBundlePath = (directoryPath as NSString).appendingPathComponent("main.jsbundle")
|
|
480
|
+
if self.fileSystem.fileExists(atPath: mainBundlePath) {
|
|
481
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Found main bundle atPath: \(mainBundlePath)")
|
|
482
|
+
return .success(mainBundlePath)
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Additional search: check all .bundle files
|
|
486
|
+
for file in contents {
|
|
487
|
+
if file.hasSuffix(".bundle") {
|
|
488
|
+
let bundlePath = (directoryPath as NSString).appendingPathComponent(file)
|
|
489
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Found alternative bundle atPath: \(bundlePath)")
|
|
490
|
+
return .success(bundlePath)
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "No bundle file found in directory: \(directoryPath)")
|
|
495
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Available files: \(contents)")
|
|
496
|
+
return .success(nil)
|
|
497
|
+
} catch let error {
|
|
498
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Error reading directory contents: \(error.localizedDescription)")
|
|
499
|
+
return .failure(error)
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Cleans up old bundles, keeping only the current and new bundles.
|
|
506
|
+
* Executes synchronously on the calling thread.
|
|
507
|
+
* @param currentBundleId ID of the current active bundle (optional)
|
|
508
|
+
* @param bundleId ID of the new bundle to keep (optional)
|
|
509
|
+
* @return Result of operation
|
|
510
|
+
*/
|
|
511
|
+
func cleanupOldBundles(currentBundleId: String?, bundleId: String?) -> Result<Void, Error> {
|
|
512
|
+
let storeDirResult = bundleStoreDir()
|
|
513
|
+
|
|
514
|
+
guard case .success(let storeDir) = storeDirResult else {
|
|
515
|
+
return .failure(storeDirResult.failureError ?? BundleStorageError.unknown(nil))
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// List only directories that are not .tmp
|
|
519
|
+
let contents: [String]
|
|
520
|
+
do {
|
|
521
|
+
contents = try self.fileSystem.contentsOfDirectory(atPath: storeDir)
|
|
522
|
+
} catch let error {
|
|
523
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Failed to list contents of bundle store directory: \(storeDir)")
|
|
524
|
+
return .failure(BundleStorageError.unknown(error))
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
let bundles = contents.compactMap { item -> String? in
|
|
528
|
+
let fullPath = (storeDir as NSString).appendingPathComponent(item)
|
|
529
|
+
|
|
530
|
+
// Skip metadata files - DO NOT delete
|
|
531
|
+
if item == "metadata.json" || item == "crashed-history.json" {
|
|
532
|
+
return nil
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
return (!item.hasSuffix(".tmp") && self.fileSystem.fileExists(atPath: fullPath)) ? fullPath : nil
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// Keep only the specified bundle IDs
|
|
539
|
+
let bundleIdsToKeep = Set([currentBundleId, bundleId].compactMap { $0 })
|
|
540
|
+
|
|
541
|
+
bundles.forEach { bundlePath in
|
|
542
|
+
let bundleName = (bundlePath as NSString).lastPathComponent
|
|
543
|
+
|
|
544
|
+
if !bundleIdsToKeep.contains(bundleName) {
|
|
545
|
+
do {
|
|
546
|
+
try self.fileSystem.removeItem(atPath: bundlePath)
|
|
547
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Removing old bundle: \(bundleName)")
|
|
548
|
+
} catch {
|
|
549
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Failed to remove old bundle at \(bundlePath): \(error)")
|
|
550
|
+
}
|
|
551
|
+
} else {
|
|
552
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Keeping bundle: \(bundleName)")
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
// Remove any leftover .tmp directories
|
|
557
|
+
contents.forEach { item in
|
|
558
|
+
if item.hasSuffix(".tmp") {
|
|
559
|
+
let fullPath = (storeDir as NSString).appendingPathComponent(item)
|
|
560
|
+
do {
|
|
561
|
+
try self.fileSystem.removeItem(atPath: fullPath)
|
|
562
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Removing stale tmp directory: \(item)")
|
|
563
|
+
} catch {
|
|
564
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Failed to remove stale tmp directory \(fullPath): \(error)")
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
return .success(())
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/**
|
|
573
|
+
* Sets the current bundle URL in preferences.
|
|
574
|
+
* Executes synchronously on the calling thread.
|
|
575
|
+
* @param localPath Path to the bundle file (or nil to reset)
|
|
576
|
+
* @return Result of operation
|
|
577
|
+
*/
|
|
578
|
+
func setBundleURL(localPath: String?) -> Result<Void, Error> {
|
|
579
|
+
do {
|
|
580
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Setting bundle URL to: \(localPath ?? "nil")")
|
|
581
|
+
try self.preferences.setItem(localPath, forKey: "IpayCodePushBundleURL")
|
|
582
|
+
return .success(())
|
|
583
|
+
} catch let error {
|
|
584
|
+
return .failure(error)
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
/**
|
|
589
|
+
* Gets the URL to the cached bundle file if it exists.
|
|
590
|
+
*/
|
|
591
|
+
func getCachedBundleURL() -> URL? {
|
|
592
|
+
do {
|
|
593
|
+
guard let savedURLString = try self.preferences.getItem(forKey: "IpayCodePushBundleURL"),
|
|
594
|
+
let bundleURL = URL(string: savedURLString),
|
|
595
|
+
self.fileSystem.fileExists(atPath: bundleURL.path) else {
|
|
596
|
+
return nil
|
|
597
|
+
}
|
|
598
|
+
return bundleURL
|
|
599
|
+
} catch {
|
|
600
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Error getting cached bundle URL: \(error.localizedDescription)")
|
|
601
|
+
return nil
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Gets the URL to the fallback bundle included in the app.
|
|
607
|
+
* @param bundle instance to lookup the JavaScript bundle resource.
|
|
608
|
+
* @return URL to the fallback bundle or nil if not found
|
|
609
|
+
*/
|
|
610
|
+
func getFallbackBundleURL(bundle: Bundle) -> URL? {
|
|
611
|
+
return bundle.url(forResource: "main", withExtension: "jsbundle")
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
public func getBundleURL(bundle: Bundle) -> URL? {
|
|
615
|
+
// Try to load metadata
|
|
616
|
+
let metadata = loadMetadataOrNull()
|
|
617
|
+
|
|
618
|
+
// If no metadata exists, use legacy behavior (backwards compatible)
|
|
619
|
+
guard let metadata = metadata else {
|
|
620
|
+
let cached = getCachedBundleURL()
|
|
621
|
+
return cached ?? getFallbackBundleURL(bundle: bundle)
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Check if we need to handle crash recovery
|
|
625
|
+
if isVerificationPending(metadata) {
|
|
626
|
+
let executionCount = metadata.stagingExecutionCount ?? 0
|
|
627
|
+
|
|
628
|
+
if executionCount == 0 {
|
|
629
|
+
// First execution - give staging bundle a chance
|
|
630
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "First execution of staging bundle, incrementing counter")
|
|
631
|
+
incrementStagingExecutionCount()
|
|
632
|
+
// Don't mark verificationAttempted yet!
|
|
633
|
+
} else if wasVerificationAttempted(metadata) {
|
|
634
|
+
// Already executed once and verificationAttempted is set → crash!
|
|
635
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Crash detected: staging bundle executed but didn't call notifyAppReady")
|
|
636
|
+
rollbackToStable()
|
|
637
|
+
} else {
|
|
638
|
+
// Second execution - now mark verification attempted
|
|
639
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Second execution of staging bundle, marking verification attempted")
|
|
640
|
+
markVerificationAttempted()
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// Reload metadata after potential rollback
|
|
645
|
+
guard let currentMetadata = loadMetadataOrNull() else {
|
|
646
|
+
return getCachedBundleURL() ?? getFallbackBundleURL(bundle: bundle)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// If verification is pending, return staging bundle URL
|
|
650
|
+
if isVerificationPending(currentMetadata), let stagingId = currentMetadata.stagingBundleId {
|
|
651
|
+
if case .success(let storeDir) = bundleStoreDir() {
|
|
652
|
+
let stagingBundleDir = (storeDir as NSString).appendingPathComponent(stagingId)
|
|
653
|
+
if case .success(let bundlePath) = findBundleFile(in: stagingBundleDir), let path = bundlePath {
|
|
654
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Returning staging bundle URL: \(path)")
|
|
655
|
+
return URL(fileURLWithPath: path)
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
// Return stable bundle URL
|
|
661
|
+
if let stableId = currentMetadata.stableBundleId {
|
|
662
|
+
if case .success(let storeDir) = bundleStoreDir() {
|
|
663
|
+
let stableBundleDir = (storeDir as NSString).appendingPathComponent(stableId)
|
|
664
|
+
if case .success(let bundlePath) = findBundleFile(in: stableBundleDir), let path = bundlePath {
|
|
665
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Returning stable bundle URL: \(path)")
|
|
666
|
+
return URL(fileURLWithPath: path)
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
// Fallback to app bundle
|
|
672
|
+
return getFallbackBundleURL(bundle: bundle)
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// MARK: - Bundle Update
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Updates the bundle from the specified URL. This operation is asynchronous.
|
|
679
|
+
* @param bundleId ID of the bundle to update
|
|
680
|
+
* @param fileUrl URL of the bundle file to download (or nil to reset)
|
|
681
|
+
* @param fileHash Combined hash string for verification (sig:<signature> or <hex_hash>)
|
|
682
|
+
* @param progressHandler Callback for download and extraction progress (0.0 to 1.0)
|
|
683
|
+
* @param completion Callback with result of the operation
|
|
684
|
+
*/
|
|
685
|
+
func updateBundle(bundleId: String, fileUrl: URL?, fileHash: String?, progressHandler: @escaping (Double) -> Void, completion: @escaping (Result<Bool, Error>) -> Void) {
|
|
686
|
+
// Check if bundle is in crashed history
|
|
687
|
+
let crashedHistory = loadCrashedHistory()
|
|
688
|
+
if crashedHistory.contains(bundleId) {
|
|
689
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Bundle '\(bundleId)' is in crashed history, rejecting update")
|
|
690
|
+
completion(.failure(BundleStorageError.bundleInCrashedHistory(bundleId)))
|
|
691
|
+
return
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Get the current bundle ID from the cached bundle URL (exclude fallback bundles)
|
|
695
|
+
let currentBundleId = self.getCachedBundleURL()?.deletingLastPathComponent().lastPathComponent
|
|
696
|
+
|
|
697
|
+
guard let validFileUrl = fileUrl else {
|
|
698
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "fileUrl is nil, resetting bundle URL.")
|
|
699
|
+
// Dispatch the sequence to the file operation queue to ensure completion is called asynchronously
|
|
700
|
+
// and to keep file operations off the calling thread if it's the main thread.
|
|
701
|
+
fileOperationQueue.async {
|
|
702
|
+
let setResult = self.setBundleURL(localPath: nil)
|
|
703
|
+
switch setResult {
|
|
704
|
+
case .success:
|
|
705
|
+
let cleanupResult = self.cleanupOldBundles(currentBundleId: currentBundleId, bundleId: bundleId)
|
|
706
|
+
switch cleanupResult {
|
|
707
|
+
case .success:
|
|
708
|
+
completion(.success(true))
|
|
709
|
+
case .failure(let error):
|
|
710
|
+
IpayCodePushHelper.logPrint(classTag: self.CLASS_TAG, log: "Error during cleanup after reset: \(error)")
|
|
711
|
+
completion(.failure(error))
|
|
712
|
+
}
|
|
713
|
+
case .failure(let error):
|
|
714
|
+
IpayCodePushHelper.logPrint(classTag: self.CLASS_TAG, log: "Error resetting bundle URL: \(error)")
|
|
715
|
+
completion(.failure(error))
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
return
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Start the bundle update process on a background queue
|
|
722
|
+
fileOperationQueue.async {
|
|
723
|
+
|
|
724
|
+
let storeDirResult = self.bundleStoreDir()
|
|
725
|
+
guard case .success(let storeDir) = storeDirResult else {
|
|
726
|
+
completion(.failure(storeDirResult.failureError ?? BundleStorageError.unknown(nil)))
|
|
727
|
+
return
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
let finalBundleDir = (storeDir as NSString).appendingPathComponent(bundleId)
|
|
731
|
+
|
|
732
|
+
if self.fileSystem.fileExists(atPath: finalBundleDir) {
|
|
733
|
+
let findResult = self.findBundleFile(in: finalBundleDir)
|
|
734
|
+
switch findResult {
|
|
735
|
+
case .success(let existingBundlePath):
|
|
736
|
+
if let bundlePath = existingBundlePath {
|
|
737
|
+
IpayCodePushHelper.logPrint(classTag: self.CLASS_TAG, log: "Using cached bundle at path: \(bundlePath)")
|
|
738
|
+
let setResult = self.setBundleURL(localPath: bundlePath)
|
|
739
|
+
switch setResult {
|
|
740
|
+
case .success:
|
|
741
|
+
// Set staging metadata for rollback support
|
|
742
|
+
var metadata = self.loadMetadataOrNull() ?? BundleMetadata()
|
|
743
|
+
metadata.stagingBundleId = bundleId
|
|
744
|
+
metadata.verificationPending = true
|
|
745
|
+
metadata.verificationAttemptedAt = nil
|
|
746
|
+
metadata.stagingExecutionCount = 0
|
|
747
|
+
metadata.updatedAt = Date().timeIntervalSince1970 * 1000
|
|
748
|
+
let _ = self.saveMetadata(metadata)
|
|
749
|
+
IpayCodePushHelper.logPrint(classTag: self.CLASS_TAG, log: "Set staging bundle (cached): \(bundleId), verificationPending: true")
|
|
750
|
+
|
|
751
|
+
// Clean up old bundles, preserving stable and new staging
|
|
752
|
+
let stableId = metadata.stableBundleId
|
|
753
|
+
let bundleIdsToKeep = [stableId, bundleId].compactMap { $0 }
|
|
754
|
+
if bundleIdsToKeep.count > 0 {
|
|
755
|
+
let _ = self.cleanupOldBundles(currentBundleId: bundleIdsToKeep.first, bundleId: bundleIdsToKeep.count > 1 ? bundleIdsToKeep[1] : nil)
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
completion(.success(true))
|
|
759
|
+
case .failure(let error):
|
|
760
|
+
completion(.failure(error))
|
|
761
|
+
}
|
|
762
|
+
return
|
|
763
|
+
} else {
|
|
764
|
+
IpayCodePushHelper.logPrint(classTag: self.CLASS_TAG, log: "Cached directory exists but invalid, removing: \(finalBundleDir)")
|
|
765
|
+
do {
|
|
766
|
+
try self.fileSystem.removeItem(atPath: finalBundleDir)
|
|
767
|
+
// Continue with download process on success
|
|
768
|
+
self.prepareAndDownloadBundle(bundleId: bundleId, fileUrl: validFileUrl, fileHash: fileHash, storeDir: storeDir, progressHandler: progressHandler, completion: completion)
|
|
769
|
+
} catch let error {
|
|
770
|
+
IpayCodePushHelper.logPrint(classTag: self.CLASS_TAG, log: "Failed to remove invalid bundle dir: \(error.localizedDescription)")
|
|
771
|
+
completion(.failure(BundleStorageError.unknown(error)))
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
case .failure(let error):
|
|
775
|
+
completion(.failure(error))
|
|
776
|
+
}
|
|
777
|
+
} else {
|
|
778
|
+
self.prepareAndDownloadBundle(bundleId: bundleId, fileUrl: validFileUrl, fileHash: fileHash, storeDir: storeDir, progressHandler: progressHandler, completion: completion)
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* Prepares directories and starts the download process.
|
|
785
|
+
* This method is part of the asynchronous `updateBundle` flow.
|
|
786
|
+
* @param bundleId ID of the bundle to update
|
|
787
|
+
* @param fileUrl URL of the bundle file to download
|
|
788
|
+
* @param fileHash Combined hash string for verification (sig:<signature> or <hex_hash>)
|
|
789
|
+
* @param storeDir Path to the bundle-store directory
|
|
790
|
+
* @param progressHandler Callback for download and extraction progress
|
|
791
|
+
* @param completion Callback with result of the operation
|
|
792
|
+
*/
|
|
793
|
+
private func prepareAndDownloadBundle(
|
|
794
|
+
bundleId: String,
|
|
795
|
+
fileUrl: URL,
|
|
796
|
+
fileHash: String?,
|
|
797
|
+
storeDir: String,
|
|
798
|
+
progressHandler: @escaping (Double) -> Void,
|
|
799
|
+
completion: @escaping (Result<Bool, Error>) -> Void
|
|
800
|
+
) {
|
|
801
|
+
// 1) Prepare temp directory for download
|
|
802
|
+
let tempDirResult = tempDir()
|
|
803
|
+
guard case .success(let tempDirectory) = tempDirResult else {
|
|
804
|
+
completion(.failure(tempDirResult.failureError ?? BundleStorageError.unknown(nil)))
|
|
805
|
+
return
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
// 2) Clean up any previous temp dir
|
|
809
|
+
try? self.fileSystem.removeItem(atPath: tempDirectory)
|
|
810
|
+
|
|
811
|
+
// 3) Create temp dir
|
|
812
|
+
if !self.fileSystem.createDirectory(atPath: tempDirectory) {
|
|
813
|
+
completion(.failure(BundleStorageError.directoryCreationFailed))
|
|
814
|
+
return
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// 4) Determine bundle filename from URL
|
|
818
|
+
let bundleFileName = fileUrl.lastPathComponent.isEmpty ? "bundle.zip" : fileUrl.lastPathComponent
|
|
819
|
+
let tempBundleFile = (tempDirectory as NSString).appendingPathComponent(bundleFileName)
|
|
820
|
+
|
|
821
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Starting download from \(fileUrl)")
|
|
822
|
+
|
|
823
|
+
// Download with integrated disk space check
|
|
824
|
+
var diskSpaceError: BundleStorageError? = nil
|
|
825
|
+
|
|
826
|
+
_ = self.downloadService.downloadFile(
|
|
827
|
+
from: fileUrl,
|
|
828
|
+
to: tempBundleFile,
|
|
829
|
+
fileSizeHandler: { [weak self] fileSize in
|
|
830
|
+
// This will be called when Content-Length is received
|
|
831
|
+
guard let self = self else { return }
|
|
832
|
+
|
|
833
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "File size received: \(fileSize) bytes")
|
|
834
|
+
|
|
835
|
+
// Check available disk space
|
|
836
|
+
do {
|
|
837
|
+
let attributes = try FileManager.default.attributesOfFileSystem(forPath: NSHomeDirectory())
|
|
838
|
+
if let freeSize = attributes[.systemFreeSize] as? Int64 {
|
|
839
|
+
let requiredSpace = fileSize * 2 // ZIP + extracted files
|
|
840
|
+
|
|
841
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Available: \(freeSize) bytes, Required: \(requiredSpace) bytes")
|
|
842
|
+
|
|
843
|
+
if freeSize < requiredSpace {
|
|
844
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Insufficient disk space detected: need \(requiredSpace) bytes, available \(freeSize) bytes")
|
|
845
|
+
// Store error to be returned in completion handler
|
|
846
|
+
diskSpaceError = .insufficientDiskSpace
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
} catch {
|
|
850
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Failed to check disk space: \(error.localizedDescription)")
|
|
851
|
+
}
|
|
852
|
+
},
|
|
853
|
+
progressHandler: { downloadProgress in
|
|
854
|
+
// Map download progress to 0.0 - 0.8
|
|
855
|
+
progressHandler(downloadProgress * 0.8)
|
|
856
|
+
},
|
|
857
|
+
completion: { [weak self] result in
|
|
858
|
+
guard let self = self else {
|
|
859
|
+
let error = NSError(domain: "IpayCodePushError", code: 998,
|
|
860
|
+
userInfo: [NSLocalizedDescriptionKey: "Self deallocated during download"])
|
|
861
|
+
completion(.failure(error))
|
|
862
|
+
return
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
// Check for disk space error first before processing download result
|
|
866
|
+
if let diskError = diskSpaceError {
|
|
867
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Throwing disk space error")
|
|
868
|
+
self.cleanupTemporaryFiles([tempDirectory])
|
|
869
|
+
completion(.failure(diskError))
|
|
870
|
+
return
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// Dispatch the processing of the downloaded file to the file operation queue
|
|
874
|
+
let workItem = DispatchWorkItem {
|
|
875
|
+
switch result {
|
|
876
|
+
case .success(let location):
|
|
877
|
+
self.processDownloadedFileWithTmp(location: location,
|
|
878
|
+
tempBundleFile: tempBundleFile,
|
|
879
|
+
fileHash: fileHash,
|
|
880
|
+
storeDir: storeDir,
|
|
881
|
+
bundleId: bundleId,
|
|
882
|
+
tempDirectory: tempDirectory,
|
|
883
|
+
progressHandler: progressHandler,
|
|
884
|
+
completion: completion)
|
|
885
|
+
case .failure(let error):
|
|
886
|
+
IpayCodePushHelper.logPrint(classTag: self.CLASS_TAG, log: "Download failed: \(error.localizedDescription)")
|
|
887
|
+
self.cleanupTemporaryFiles([tempDirectory]) // Sync cleanup
|
|
888
|
+
|
|
889
|
+
// Map DownloadError.incompleteDownload to BundleStorageError.incompleteDownload
|
|
890
|
+
if let downloadError = error as? DownloadError,
|
|
891
|
+
case .incompleteDownload(let expected, let actual) = downloadError {
|
|
892
|
+
completion(.failure(BundleStorageError.incompleteDownload(expected: expected, actual: actual)))
|
|
893
|
+
} else {
|
|
894
|
+
completion(.failure(BundleStorageError.downloadFailed(error)))
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
self.fileOperationQueue.async(execute: workItem)
|
|
899
|
+
}
|
|
900
|
+
)
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
/**
|
|
904
|
+
* Logs detailed diagnostic information about a file system path.
|
|
905
|
+
* @param path The path to diagnose
|
|
906
|
+
* @param context Additional context for logging
|
|
907
|
+
*/
|
|
908
|
+
private func logFileSystemDiagnostics(path: String, context: String) {
|
|
909
|
+
let fileManager = FileManager.default
|
|
910
|
+
|
|
911
|
+
// Check if path exists
|
|
912
|
+
let exists = fileManager.fileExists(atPath: path)
|
|
913
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "[\(context)] Path exists: \(exists) - \(path)")
|
|
914
|
+
|
|
915
|
+
if exists {
|
|
916
|
+
do {
|
|
917
|
+
let attributes = try fileManager.attributesOfItem(atPath: path)
|
|
918
|
+
let size = attributes[.size] as? Int64 ?? 0
|
|
919
|
+
let permissions = attributes[.posixPermissions] as? Int ?? 0
|
|
920
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "[\(context)] Size: \(size) bytes, Permissions: \(String(permissions, radix: 8))")
|
|
921
|
+
} catch {
|
|
922
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "[\(context)] Failed to get attributes: \(error.localizedDescription)")
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
// Check parent directory
|
|
927
|
+
let parentPath = (path as NSString).deletingLastPathComponent
|
|
928
|
+
let parentExists = fileManager.fileExists(atPath: parentPath)
|
|
929
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "[\(context)] Parent directory exists: \(parentExists) - \(parentPath)")
|
|
930
|
+
}
|
|
931
|
+
|
|
932
|
+
/**
|
|
933
|
+
* Processes a downloaded bundle file using the "tmp" rename approach.
|
|
934
|
+
* This method is part of the asynchronous `updateBundle` flow and is expected to run on a background thread.
|
|
935
|
+
* @param location URL of the downloaded file
|
|
936
|
+
* @param tempBundleFile Path to store the downloaded bundle file
|
|
937
|
+
* @param fileHash Combined hash string for verification (sig:<signature> or <hex_hash>)
|
|
938
|
+
* @param storeDir Path to the bundle-store directory
|
|
939
|
+
* @param bundleId ID of the bundle being processed
|
|
940
|
+
* @param tempDirectory Temporary directory for processing
|
|
941
|
+
* @param progressHandler Callback for extraction progress (0.8 to 1.0)
|
|
942
|
+
* @param completion Callback with result of the operation
|
|
943
|
+
*/
|
|
944
|
+
private func processDownloadedFileWithTmp(
|
|
945
|
+
location: URL,
|
|
946
|
+
tempBundleFile: String,
|
|
947
|
+
fileHash: String?,
|
|
948
|
+
storeDir: String,
|
|
949
|
+
bundleId: String,
|
|
950
|
+
tempDirectory: String,
|
|
951
|
+
progressHandler: @escaping (Double) -> Void,
|
|
952
|
+
completion: @escaping (Result<Bool, Error>) -> Void
|
|
953
|
+
) {
|
|
954
|
+
let currentBundleId = self.getCachedBundleURL()?.deletingLastPathComponent().lastPathComponent
|
|
955
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Processing downloaded file atPath: \(location.path)")
|
|
956
|
+
|
|
957
|
+
// 1) Ensure the bundle file exists
|
|
958
|
+
guard self.fileSystem.fileExists(atPath: location.path) else {
|
|
959
|
+
logFileSystemDiagnostics(path: location.path, context: "Download Location Missing")
|
|
960
|
+
self.cleanupTemporaryFiles([tempDirectory])
|
|
961
|
+
completion(.failure(BundleStorageError.downloadFailed(NSError(
|
|
962
|
+
domain: "IpayCodePushError",
|
|
963
|
+
code: 1,
|
|
964
|
+
userInfo: [NSLocalizedDescriptionKey: "Downloaded file does not exist atPath: \(location.path)"]
|
|
965
|
+
))))
|
|
966
|
+
return
|
|
967
|
+
}
|
|
968
|
+
|
|
969
|
+
// 2) Define tmpDir and realDir
|
|
970
|
+
let tmpDir = (storeDir as NSString).appendingPathComponent("\(bundleId).tmp")
|
|
971
|
+
let realDir = (storeDir as NSString).appendingPathComponent(bundleId)
|
|
972
|
+
|
|
973
|
+
do {
|
|
974
|
+
// 3) Remove any existing tmpDir
|
|
975
|
+
if self.fileSystem.fileExists(atPath: tmpDir) {
|
|
976
|
+
try self.fileSystem.removeItem(atPath: tmpDir)
|
|
977
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Removed existing tmpDir: \(tmpDir)")
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
// 4) Create tmpDir
|
|
981
|
+
try self.fileSystem.createDirectory(atPath: tmpDir)
|
|
982
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Created tmpDir: \(tmpDir)")
|
|
983
|
+
logFileSystemDiagnostics(path: tmpDir, context: "TmpDir Created")
|
|
984
|
+
|
|
985
|
+
// 5) Verify bundle integrity (hash or signature based on fileHash format)
|
|
986
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Verifying bundle integrity...")
|
|
987
|
+
let tempBundleURL = URL(fileURLWithPath: tempBundleFile)
|
|
988
|
+
let verificationResult = SignatureVerifier.verifyBundle(fileURL: tempBundleURL, fileHash: fileHash)
|
|
989
|
+
switch verificationResult {
|
|
990
|
+
case .success:
|
|
991
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Bundle verification completed successfully")
|
|
992
|
+
case .failure(let error):
|
|
993
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Bundle verification failed: \(error)")
|
|
994
|
+
try? self.fileSystem.removeItem(atPath: tmpDir)
|
|
995
|
+
self.cleanupTemporaryFiles([tempDirectory])
|
|
996
|
+
completion(.failure(BundleStorageError.signatureVerificationFailed(error)))
|
|
997
|
+
return
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// 6) Unzip directly into tmpDir with progress tracking (0.8 - 1.0)
|
|
1001
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Extracting \(tempBundleFile) → \(tmpDir)")
|
|
1002
|
+
logFileSystemDiagnostics(path: tempBundleFile, context: "Before Extraction")
|
|
1003
|
+
do {
|
|
1004
|
+
try self.decompressService.unzip(file: tempBundleFile, to: tmpDir, progressHandler: { unzipProgress in
|
|
1005
|
+
// Map unzip progress (0.0 - 1.0) to overall progress (0.8 - 1.0)
|
|
1006
|
+
progressHandler(0.8 + (unzipProgress * 0.2))
|
|
1007
|
+
})
|
|
1008
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Extraction complete at \(tmpDir)")
|
|
1009
|
+
logFileSystemDiagnostics(path: tmpDir, context: "After Extraction")
|
|
1010
|
+
} catch {
|
|
1011
|
+
let nsError = error as NSError
|
|
1012
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Extraction failed - Domain: \(nsError.domain), Code: \(nsError.code), Description: \(nsError.localizedDescription)")
|
|
1013
|
+
logFileSystemDiagnostics(path: tmpDir, context: "Extraction Failed")
|
|
1014
|
+
try? self.fileSystem.removeItem(atPath: tmpDir)
|
|
1015
|
+
self.cleanupTemporaryFiles([tempDirectory])
|
|
1016
|
+
completion(.failure(BundleStorageError.extractionFormatError(error)))
|
|
1017
|
+
return
|
|
1018
|
+
}
|
|
1019
|
+
|
|
1020
|
+
// 7) Remove the downloaded bundle file
|
|
1021
|
+
try? self.fileSystem.removeItem(atPath: tempBundleFile)
|
|
1022
|
+
|
|
1023
|
+
// 8) Verify that a valid bundle file exists inside tmpDir
|
|
1024
|
+
switch self.findBundleFile(in: tmpDir) {
|
|
1025
|
+
case .success(let maybeBundlePath):
|
|
1026
|
+
if let bundlePathInTmp = maybeBundlePath {
|
|
1027
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Found valid bundle in tmpDir: \(bundlePathInTmp)")
|
|
1028
|
+
logFileSystemDiagnostics(path: bundlePathInTmp, context: "Bundle Found")
|
|
1029
|
+
|
|
1030
|
+
// 9) Remove any existing realDir
|
|
1031
|
+
if self.fileSystem.fileExists(atPath: realDir) {
|
|
1032
|
+
try self.fileSystem.removeItem(atPath: realDir)
|
|
1033
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Removed existing realDir: \(realDir)")
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// 10) Rename (move) tmpDir → realDir
|
|
1037
|
+
do {
|
|
1038
|
+
try self.fileSystem.moveItem(atPath: tmpDir, toPath: realDir)
|
|
1039
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Renamed tmpDir to realDir: \(realDir)")
|
|
1040
|
+
logFileSystemDiagnostics(path: realDir, context: "After Move")
|
|
1041
|
+
} catch {
|
|
1042
|
+
let nsError = error as NSError
|
|
1043
|
+
|
|
1044
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Move operation failed - Domain: \(nsError.domain), Code: \(nsError.code), Description: \(nsError.localizedDescription)")
|
|
1045
|
+
|
|
1046
|
+
logFileSystemDiagnostics(path: tmpDir, context: "Move Failed - Source")
|
|
1047
|
+
logFileSystemDiagnostics(path: realDir, context: "Move Failed - Destination")
|
|
1048
|
+
throw BundleStorageError.moveOperationFailed(error)
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// 11) Construct final bundlePath for preferences
|
|
1052
|
+
let finalBundlePath = (realDir as NSString).appendingPathComponent((bundlePathInTmp as NSString).lastPathComponent)
|
|
1053
|
+
|
|
1054
|
+
// 12) Set the bundle URL in preferences (for backwards compatibility)
|
|
1055
|
+
let setResult = self.setBundleURL(localPath: finalBundlePath)
|
|
1056
|
+
switch setResult {
|
|
1057
|
+
case .success:
|
|
1058
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Successfully set bundle URL: \(finalBundlePath)")
|
|
1059
|
+
|
|
1060
|
+
// 13) Set staging metadata for rollback support
|
|
1061
|
+
var metadata = self.loadMetadataOrNull() ?? BundleMetadata()
|
|
1062
|
+
metadata.stagingBundleId = bundleId
|
|
1063
|
+
metadata.verificationPending = true
|
|
1064
|
+
metadata.verificationAttemptedAt = nil
|
|
1065
|
+
metadata.stagingExecutionCount = 0
|
|
1066
|
+
metadata.updatedAt = Date().timeIntervalSince1970 * 1000
|
|
1067
|
+
let _ = self.saveMetadata(metadata)
|
|
1068
|
+
|
|
1069
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Set staging bundle: \(bundleId), verificationPending: true")
|
|
1070
|
+
|
|
1071
|
+
// 14) Clean up the temporary directory
|
|
1072
|
+
self.cleanupTemporaryFiles([tempDirectory])
|
|
1073
|
+
|
|
1074
|
+
// 15) Clean up old bundles, preserving current, stable, and new staging
|
|
1075
|
+
let stableId = metadata.stableBundleId
|
|
1076
|
+
let bundleIdsToKeep = [stableId, bundleId].compactMap { $0 }
|
|
1077
|
+
if bundleIdsToKeep.count > 0 {
|
|
1078
|
+
let _ = self.cleanupOldBundles(currentBundleId: bundleIdsToKeep.first, bundleId: bundleIdsToKeep.count > 1 ? bundleIdsToKeep[1] : nil)
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
// 16) Complete with success
|
|
1082
|
+
completion(.success(true))
|
|
1083
|
+
case .failure(let err):
|
|
1084
|
+
let nsError = err as NSError
|
|
1085
|
+
|
|
1086
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Failed to set bundle URL - Domain: \(nsError.domain), Code: \(nsError.code), Description: \(nsError.localizedDescription)")
|
|
1087
|
+
|
|
1088
|
+
// Preferences save failed → remove realDir and clean up
|
|
1089
|
+
try? self.fileSystem.removeItem(atPath: realDir)
|
|
1090
|
+
self.cleanupTemporaryFiles([tempDirectory])
|
|
1091
|
+
completion(.failure(err))
|
|
1092
|
+
}
|
|
1093
|
+
} else {
|
|
1094
|
+
// No valid .jsbundle found → delete tmpDir and fail
|
|
1095
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "No valid bundle file found in tmpDir")
|
|
1096
|
+
|
|
1097
|
+
logFileSystemDiagnostics(path: tmpDir, context: "Invalid Bundle")
|
|
1098
|
+
try? self.fileSystem.removeItem(atPath: tmpDir)
|
|
1099
|
+
self.cleanupTemporaryFiles([tempDirectory])
|
|
1100
|
+
completion(.failure(BundleStorageError.invalidBundle))
|
|
1101
|
+
}
|
|
1102
|
+
case .failure(let findError):
|
|
1103
|
+
let nsError = findError as NSError
|
|
1104
|
+
|
|
1105
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Error finding bundle file - Domain: \(nsError.domain), Code: \(nsError.code), Description: \(nsError.localizedDescription)")
|
|
1106
|
+
|
|
1107
|
+
// Error scanning tmpDir → delete tmpDir and fail
|
|
1108
|
+
try? self.fileSystem.removeItem(atPath: tmpDir)
|
|
1109
|
+
self.cleanupTemporaryFiles([tempDirectory])
|
|
1110
|
+
completion(.failure(findError))
|
|
1111
|
+
}
|
|
1112
|
+
} catch let error {
|
|
1113
|
+
// Any failure during unzip or rename → clean tmpDir and fail
|
|
1114
|
+
let nsError = error as NSError
|
|
1115
|
+
|
|
1116
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Error during tmpDir processing - Domain: \(nsError.domain), Code: \(nsError.code), Description: \(nsError.localizedDescription)")
|
|
1117
|
+
|
|
1118
|
+
logFileSystemDiagnostics(path: tmpDir, context: "Processing Error")
|
|
1119
|
+
try? self.fileSystem.removeItem(atPath: tmpDir)
|
|
1120
|
+
self.cleanupTemporaryFiles([tempDirectory])
|
|
1121
|
+
|
|
1122
|
+
// Re-throw specific BundleStorageError if it is one, otherwise wrap as unknown
|
|
1123
|
+
if let bundleError = error as? BundleStorageError {
|
|
1124
|
+
completion(.failure(bundleError))
|
|
1125
|
+
} else {
|
|
1126
|
+
completion(.failure(BundleStorageError.unknown(error)))
|
|
1127
|
+
}
|
|
1128
|
+
}
|
|
1129
|
+
}
|
|
1130
|
+
|
|
1131
|
+
// MARK: - Rollback Support
|
|
1132
|
+
|
|
1133
|
+
/**
|
|
1134
|
+
* Notifies the system that the app has successfully started with the given bundle.
|
|
1135
|
+
* If the bundle matches the staging bundle, promotes it to stable.
|
|
1136
|
+
* @param bundleId The ID of the currently running bundle
|
|
1137
|
+
* @return true if promotion was successful or no action was needed
|
|
1138
|
+
*/
|
|
1139
|
+
func notifyAppReady(bundleId: String) -> [String: Any] {
|
|
1140
|
+
|
|
1141
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "notifyAppReady: Called with bundleId '\(bundleId)'")
|
|
1142
|
+
|
|
1143
|
+
guard var metadata = loadMetadataOrNull() else {
|
|
1144
|
+
// No metadata exists - legacy mode, nothing to do
|
|
1145
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "notifyAppReady: No metadata exists (legacy mode)")
|
|
1146
|
+
return ["status": "STABLE"]
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// Check if there was a recent rollback (session variable)
|
|
1150
|
+
if let crashedBundleId = self.sessionRollbackBundleId {
|
|
1151
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "notifyAppReady: Detected rollback recovery from '\(crashedBundleId)'")
|
|
1152
|
+
|
|
1153
|
+
// Clear rollback info (one-time read)
|
|
1154
|
+
self.sessionRollbackBundleId = nil
|
|
1155
|
+
|
|
1156
|
+
return [
|
|
1157
|
+
"status": "RECOVERED",
|
|
1158
|
+
"crashedBundleId": crashedBundleId
|
|
1159
|
+
]
|
|
1160
|
+
}
|
|
1161
|
+
|
|
1162
|
+
// Check if the bundle matches the staging bundle (promotion case)
|
|
1163
|
+
if let stagingId = metadata.stagingBundleId, stagingId == bundleId, metadata.verificationPending {
|
|
1164
|
+
|
|
1165
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "notifyAppReady: Bundle '\(bundleId)' matches staging, promoting to stable")
|
|
1166
|
+
|
|
1167
|
+
promoteStagingToStable()
|
|
1168
|
+
return ["status": "PROMOTED"]
|
|
1169
|
+
}
|
|
1170
|
+
|
|
1171
|
+
// Check if the bundle matches the stable bundle
|
|
1172
|
+
if let stableId = metadata.stableBundleId, stableId == bundleId {
|
|
1173
|
+
// Already stable, clear any pending verification state
|
|
1174
|
+
if metadata.verificationPending {
|
|
1175
|
+
metadata.verificationPending = false
|
|
1176
|
+
metadata.verificationAttemptedAt = nil
|
|
1177
|
+
metadata.updatedAt = Date().timeIntervalSince1970 * 1000
|
|
1178
|
+
let _ = saveMetadata(metadata)
|
|
1179
|
+
|
|
1180
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "notifyAppReady: Bundle '\(bundleId)' is stable, cleared pending verification")
|
|
1181
|
+
|
|
1182
|
+
} else {
|
|
1183
|
+
|
|
1184
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "notifyAppReady: Bundle '\(bundleId)' is already stable")
|
|
1185
|
+
}
|
|
1186
|
+
return ["status": "STABLE"]
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
// Bundle doesn't match staging or stable - might be fallback or unknown
|
|
1190
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "notifyAppReady: Bundle '\(bundleId)' doesn't match staging or stable")
|
|
1191
|
+
return ["status": "STABLE"]
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1194
|
+
/**
|
|
1195
|
+
* Returns the crashed bundle history.
|
|
1196
|
+
* @return The crashed history object
|
|
1197
|
+
*/
|
|
1198
|
+
func getCrashHistory() -> CrashedHistory {
|
|
1199
|
+
return loadCrashedHistory()
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
/**
|
|
1203
|
+
* Clears the crashed bundle history.
|
|
1204
|
+
* @return true if clearing was successful
|
|
1205
|
+
*/
|
|
1206
|
+
func clearCrashHistory() -> Bool {
|
|
1207
|
+
var history = loadCrashedHistory()
|
|
1208
|
+
history.clear()
|
|
1209
|
+
return saveCrashedHistory(history)
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
/**
|
|
1213
|
+
* Gets the base URL for the current active bundle directory
|
|
1214
|
+
* Returns the file:// URL to the bundle directory without trailing slash
|
|
1215
|
+
*/
|
|
1216
|
+
func getBaseURL() -> String {
|
|
1217
|
+
do {
|
|
1218
|
+
let metadata = loadMetadataOrNull()
|
|
1219
|
+
let activeBundleId: String?
|
|
1220
|
+
|
|
1221
|
+
// Prefer staging bundle if verification is pending
|
|
1222
|
+
if let meta = metadata, meta.verificationPending, let staging = meta.stagingBundleId {
|
|
1223
|
+
activeBundleId = staging
|
|
1224
|
+
} else if let stable = metadata?.stableBundleId {
|
|
1225
|
+
activeBundleId = stable
|
|
1226
|
+
} else {
|
|
1227
|
+
// Fall back to current bundle ID from preferences
|
|
1228
|
+
if let savedURL = try preferences.getItem(forKey: "IpayCodePushBundleURL") {
|
|
1229
|
+
// Extract bundle ID from path like "bundle-store/abc123/index.ios.bundle"
|
|
1230
|
+
if let range = savedURL.range(of: "bundle-store/([^/]+)/", options: .regularExpression) {
|
|
1231
|
+
let match = savedURL[range]
|
|
1232
|
+
let components = match.split(separator: "/")
|
|
1233
|
+
if components.count >= 2 {
|
|
1234
|
+
activeBundleId = String(components[1])
|
|
1235
|
+
} else {
|
|
1236
|
+
activeBundleId = nil
|
|
1237
|
+
}
|
|
1238
|
+
} else {
|
|
1239
|
+
activeBundleId = nil
|
|
1240
|
+
}
|
|
1241
|
+
} else {
|
|
1242
|
+
activeBundleId = nil
|
|
1243
|
+
}
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
if let bundleId = activeBundleId {
|
|
1247
|
+
if case .success(let storeDir) = bundleStoreDir() {
|
|
1248
|
+
let bundleDir = (storeDir as NSString).appendingPathComponent(bundleId)
|
|
1249
|
+
if fileSystem.fileExists(atPath: bundleDir) {
|
|
1250
|
+
return "file://\(bundleDir)"
|
|
1251
|
+
}
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
return ""
|
|
1256
|
+
} catch {
|
|
1257
|
+
IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Error getting base URL: \(error)")
|
|
1258
|
+
return ""
|
|
1259
|
+
}
|
|
1260
|
+
}
|
|
1261
|
+
}
|
|
1262
|
+
|
|
1263
|
+
// Helper to get the associated error from a Result, if it's a failure
|
|
1264
|
+
extension Result {
|
|
1265
|
+
var failureError: Failure? {
|
|
1266
|
+
guard case .failure(let error) = self else { return nil }
|
|
1267
|
+
return error
|
|
1268
|
+
}
|
|
1269
|
+
}
|