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.
@@ -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
+ }