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.
@@ -1,12 +1,340 @@
1
1
  #import "InstantpayCodePush.h"
2
+ #import <React/RCTReloadCommand.h>
3
+ #import <React/RCTLog.h>
2
4
 
3
- @implementation InstantpayCodePush
4
- - (NSNumber *)multiply:(double)a b:(double)b {
5
- NSNumber *result = @(a * b);
5
+ #if __has_include("InstantpayCodePush/InstantpayCodePush-Swift.h")
6
+ #import "InstantpayCodePush/InstantpayCodePush-Swift.h"
7
+ #else
8
+ #import "InstantpayCodePush-Swift.h"
9
+ #endif
6
10
 
7
- return result;
11
+ // Define Notification names used for observing Swift Core
12
+ NSNotificationName const IpayCodePushDownloadProgressUpdateNotification = @"IpayCodePushDownloadProgressUpdate";
13
+ NSNotificationName const IpayCodePushDownloadDidFinishNotification = @"IpayCodePushDownloadDidFinish";
14
+
15
+ @implementation InstantpayCodePush {
16
+ bool hasListeners;
17
+ // Keep track of tasks ONLY for removing observers when this ObjC instance is invalidated
18
+ NSMutableSet<NSURLSessionTask *> *observedTasks; // Changed to NSURLSessionTask for broader compatibility if needed
19
+ }
20
+
21
+ + (BOOL)requiresMainQueueSetup {
22
+ return YES;
23
+ }
24
+
25
+ - (instancetype)init {
26
+ self = [super init];
27
+ if (self) {
28
+ observedTasks = [NSMutableSet set];
29
+
30
+ [[NSNotificationCenter defaultCenter] addObserver:self
31
+ selector:@selector(handleDownloadProgress:)
32
+ name:IpayCodePushDownloadProgressUpdateNotification
33
+ object:nil];
34
+ [[NSNotificationCenter defaultCenter] addObserver:self
35
+ selector:@selector(handleDownloadCompletion:)
36
+ name:IpayCodePushDownloadDidFinishNotification
37
+ object:nil];
38
+
39
+ _lastUpdateTime = 0;
40
+ }
41
+ return self;
8
42
  }
9
43
 
44
+ // Clean up observers when module is invalidated or deallocated
45
+ - (void)invalidate {
46
+ RCTLogInfo(@"[InstantpayCodePush.mm] invalidate called, removing observers.");
47
+ [[NSNotificationCenter defaultCenter] removeObserver:self];
48
+ // Swift side should handle KVO observer removal for its tasks
49
+ [super invalidate];
50
+ }
51
+
52
+ - (void)dealloc {
53
+ RCTLogInfo(@"[InstantpayCodePush.mm] dealloc called, removing observers.");
54
+ [[NSNotificationCenter defaultCenter] removeObserver:self];
55
+ }
56
+
57
+ //RCT_EXPORT_MODULE();
58
+
59
+ #pragma mark - Singleton Instance
60
+
61
+ // Static singleton IpayCodePushImpl getter
62
+ + (IpayCodePushImpl *)sharedImpl {
63
+ static IpayCodePushImpl *_sharedImpl = nil;
64
+ static dispatch_once_t onceToken;
65
+ dispatch_once(&onceToken, ^{
66
+ _sharedImpl = [[IpayCodePushImpl alloc] init];
67
+ });
68
+ return _sharedImpl;
69
+ }
70
+
71
+ #pragma mark - React Native Constants (Keep getMinBundleId, delegate others)
72
+
73
+
74
+ // Returns the minimum bundle ID string, either from Info.plist or generated from build timestamp
75
+
76
+ - (NSString *)getMinBundleId {
77
+ static NSString *uuid = nil;
78
+ static dispatch_once_t onceToken;
79
+
80
+ dispatch_once(&onceToken, ^{
81
+ #if DEBUG
82
+ uuid = @"00000000-0000-0000-0000-000000000000";
83
+ #else
84
+ // Step 1: Try to read IPAY_CODE_PUSH_BUILD_TIMESTAMP from Info.plist
85
+ NSDictionary *infoDictionary = [[NSBundle mainBundle] infoDictionary];
86
+ NSString *customValue = infoDictionary[@"IPAY_CODE_PUSH_BUILD_TIMESTAMP"];
87
+
88
+ // Step 2: If custom value exists and is not empty
89
+ if (customValue && customValue.length > 0 && ![customValue isEqualToString:@"$(IPAY_CODE_PUSH_BUILD_TIMESTAMP)"]) {
90
+ // Check if it's a timestamp (pure digits) or UUID
91
+ NSCharacterSet *nonDigits = [[NSCharacterSet decimalDigitCharacterSet] invertedSet];
92
+ BOOL isTimestamp = ([customValue rangeOfCharacterFromSet:nonDigits].location == NSNotFound);
93
+
94
+ if (isTimestamp) {
95
+ // Convert timestamp (milliseconds) to UUID v7
96
+ uint64_t timestampMs = [customValue longLongValue];
97
+ uuid = [self generateUUIDv7FromTimestamp:timestampMs];
98
+ RCTLogInfo(@"[InstantpayCodePush.mm] Using timestamp %@ as MIN_BUNDLE_ID: %@", customValue, uuid);
99
+ } else {
100
+ // Use as UUID directly
101
+ uuid = customValue;
102
+ RCTLogInfo(@"[InstantpayCodePush.mm] Using custom MIN_BUNDLE_ID from Info.plist: %@", uuid);
103
+ }
104
+ return;
105
+ }
106
+
107
+ // Step 3: Fallback to default logic (26-hour subtraction)
108
+ RCTLogInfo(@"[InstantpayCodePush.mm] No custom MIN_BUNDLE_ID found, using default calculation");
109
+
110
+ NSString *compileDateStr = [NSString stringWithFormat:@"%s %s", __DATE__, __TIME__];
111
+ NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
112
+
113
+ // Parse __DATE__ __TIME__ as UTC to ensure consistent timezone handling across all build environments
114
+ [formatter setTimeZone:[NSTimeZone timeZoneWithName:@"UTC"]];
115
+ [formatter setLocale:[[NSLocale alloc] initWithLocaleIdentifier:@"en_US_POSIX"]];
116
+ [formatter setDateFormat:@"MMM d yyyy HH:mm:ss"]; // Correct format for __DATE__ __TIME__
117
+ NSDate *buildDate = [formatter dateFromString:compileDateStr];
118
+
119
+ if (!buildDate) {
120
+ RCTLogWarn(@"[InstantpayCodePush.mm] Could not parse build date: %@", compileDateStr);
121
+ uuid = @"00000000-0000-0000-0000-000000000000";
122
+ return;
123
+ }
124
+
125
+ // Subtract 26 hours (93600 seconds) to ensure MIN_BUNDLE_ID is always in the past
126
+ // This guarantees that uuidv7-based bundleIds (generated at runtime) will always be newer than MIN_BUNDLE_ID
127
+ // Why 26 hours? Global timezone range spans from UTC-12 to UTC+14 (total 26 hours)
128
+ // By subtracting 26 hours, MIN_BUNDLE_ID becomes a safe "past timestamp" regardless of build timezone
129
+ // Example: Build at 15:00 in any timezone → parse as 15:00 UTC → subtract 26h → 13:00 UTC (previous day)
130
+
131
+ NSTimeInterval adjustedTimestamp = [buildDate timeIntervalSince1970] - 93600.0;
132
+ uint64_t buildTimestampMs = (uint64_t)(adjustedTimestamp * 1000.0);
133
+
134
+ uuid = [self generateUUIDv7FromTimestamp:buildTimestampMs];
135
+
136
+ #endif
137
+ });
138
+ return uuid;
139
+ }
140
+
141
+ // Helper method: Generate UUID v7 from timestamp (milliseconds)
142
+ - (NSString *)generateUUIDv7FromTimestamp:(uint64_t)timestampMs {
143
+ unsigned char bytes[16];
144
+
145
+ // UUID v7 format: timestamp_ms (48 bits) + ver (4 bits) + random (12 bits) + variant (2 bits) + random (62 bits)
146
+ bytes[0] = (timestampMs >> 40) & 0xFF;
147
+ bytes[1] = (timestampMs >> 32) & 0xFF;
148
+ bytes[2] = (timestampMs >> 24) & 0xFF;
149
+ bytes[3] = (timestampMs >> 16) & 0xFF;
150
+ bytes[4] = (timestampMs >> 8) & 0xFF;
151
+ bytes[5] = timestampMs & 0xFF;
152
+
153
+ // Version 7
154
+ bytes[6] = 0x70;
155
+ bytes[7] = 0x00;
156
+
157
+ // Variant bits (10xxxxxx)
158
+ bytes[8] = 0x80;
159
+ bytes[9] = 0x00;
160
+
161
+ // Remaining bytes (zeros for deterministic MIN_BUNDLE_ID)
162
+ for (int i = 10; i < 16; i++) {
163
+ bytes[i] = 0x00;
164
+ }
165
+
166
+ return [NSString stringWithFormat:
167
+ @"%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x",
168
+ bytes[0], bytes[1], bytes[2], bytes[3], bytes[4], bytes[5], bytes[6], bytes[7],
169
+ bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], bytes[15]];
170
+ }
171
+
172
+ - (NSDictionary *)_buildConstantsDictionary {
173
+ return @{
174
+ @"MIN_BUNDLE_ID": [self getMinBundleId] ?: [NSNull null],
175
+ @"APP_VERSION": [IpayCodePushImpl appVersion] ?: [NSNull null],
176
+ @"CHANNEL": [[InstantpayCodePush sharedImpl] getChannel] ?: [NSNull null],
177
+ @"FINGERPRINT_HASH": [[InstantpayCodePush sharedImpl] getFingerprintHash] ?: [NSNull null]
178
+ };
179
+ }
180
+
181
+ // Get bundleURL with default bundle using singleton
182
+ + (NSURL *)bundleURL {
183
+ return [[InstantpayCodePush sharedImpl] bundleURLWithBundle:[NSBundle mainBundle]];
184
+ }
185
+
186
+ // Get bundleURL with specific bundle using singleton
187
+ + (NSURL *)bundleURLWithBundle:(NSBundle *)bundle {
188
+ return [[InstantpayCodePush sharedImpl] bundleURLWithBundle:bundle];
189
+ }
190
+
191
+ // Get bundleURL with default bundle using instance impl
192
+ - (NSURL *)bundleURL {
193
+ return [[InstantpayCodePush sharedImpl] bundleURLWithBundle:[NSBundle mainBundle]];
194
+ }
195
+
196
+ // Get bundleURL with specific bundle using instance impl
197
+ - (NSURL *)bundleURLWithBundle:(NSBundle *)bundle {
198
+ return [[InstantpayCodePush sharedImpl] bundleURLWithBundle:bundle];
199
+ }
200
+
201
+ #pragma mark - Progress Updates & Event Emitting (Keep in ObjC Wrapper)
202
+
203
+ - (void)handleDownloadProgress:(NSNotification *)notification {
204
+ if (!hasListeners) return;
205
+
206
+ NSDictionary *userInfo = notification.userInfo;
207
+ NSNumber *progressNum = userInfo[@"progress"];
208
+
209
+ if (progressNum) {
210
+ double progress = [progressNum doubleValue];
211
+ NSTimeInterval currentTime = [[NSDate date] timeIntervalSince1970] * 1000;
212
+ if ((currentTime - self.lastUpdateTime) >= 100 || progress >= 1.0) {
213
+ self.lastUpdateTime = currentTime;
214
+ [self sendEvent:@"onProgress" body:@{@"progress": @(progress)}];
215
+ }
216
+ }
217
+ }
218
+
219
+ - (void)handleDownloadCompletion:(NSNotification *)notification {
220
+ NSURLSessionTask *task = notification.object; // Task that finished
221
+ RCTLogInfo(@"[InstantpayCodePush.mm] Received download completion notification for task: %@", task.originalRequest.URL);
222
+ // Swift side handles KVO observer removal internally now when task finishes.
223
+ // No specific cleanup needed here based on this notification anymore.
224
+ }
225
+
226
+ #pragma mark - React Native Events (Keep as is)
227
+
228
+ - (NSArray<NSString *> *)supportedEvents {
229
+ return @[@"onProgress"];
230
+ }
231
+
232
+ - (void)startObserving {
233
+ hasListeners = YES;
234
+ }
235
+
236
+ - (void)stopObserving {
237
+ hasListeners = NO;
238
+ }
239
+
240
+ - (void)sendEvent:(NSString *)name body:(id)body {
241
+ if (hasListeners) {
242
+ [self sendEventWithName:name body:body];
243
+ }
244
+ }
245
+
246
+ #pragma mark - React Native Exports
247
+
248
+ - (void)reload:(RCTPromiseResolveBlock)resolve
249
+ reject:(RCTPromiseRejectBlock)reject {
250
+ RCTLogInfo(@"[InstantpayCodePush.mm] IpayCodePush requested a reload");
251
+ dispatch_async(dispatch_get_main_queue(), ^{
252
+ @try {
253
+ IpayCodePushImpl *impl = [InstantpayCodePush sharedImpl];
254
+ NSURL *bundleURL = [impl bundleURLWithBundle:[NSBundle mainBundle]];
255
+ RCTLogInfo(@"[InstantpayCodePush.mm] Reloading with bundle URL: %@", bundleURL);
256
+ if (bundleURL && super.bridge) {
257
+ [super.bridge setValue:bundleURL forKey:@"bundleURL"];
258
+ } else if (!super.bridge) {
259
+ RCTLogWarn(@"[InstantpayCodePush.mm] Bridge is nil, cannot set bundleURL for reload.");
260
+ }
261
+ RCTTriggerReloadCommandListeners(@"IpayCodePush requested a reload");
262
+ resolve(nil);
263
+ } @catch (NSError *error) {
264
+ RCTLogError(@"[InstantpayCodePush.mm] Failed to reload: %@", error);
265
+ reject(@"RELOAD_ERROR", error.description, error);
266
+ }
267
+ });
268
+ }
269
+
270
+ - (void)updateBundle:(JS::NativeInstantpayCodePush::UpdateBundleParams &)params
271
+ resolve:(RCTPromiseResolveBlock)resolve
272
+ reject:(RCTPromiseRejectBlock)reject {
273
+ NSLog(@"[InstantpayCodePush.mm] updateBundle called.");
274
+ NSMutableDictionary *paramDict = [NSMutableDictionary dictionary];
275
+ if (params.bundleId()) {
276
+ paramDict[@"bundleId"] = params.bundleId();
277
+ }
278
+ if (params.fileUrl()) {
279
+ paramDict[@"fileUrl"] = params.fileUrl();
280
+ }
281
+ if (params.fileHash()) {
282
+ paramDict[@"fileHash"] = params.fileHash();
283
+ }
284
+
285
+ IpayCodePushImpl *impl = [InstantpayCodePush sharedImpl];
286
+ [impl updateBundle:paramDict resolver:resolve rejecter:reject];
287
+ }
288
+
289
+ - (NSDictionary *)notifyAppReady:(JS::NativeInstantpayCodePush::SpecNotifyAppReadyParams &)params {
290
+ NSString *bundleId = nil;
291
+ if (params.bundleId()) {
292
+ bundleId = params.bundleId();
293
+ }
294
+ NSLog(@"[InstantpayCodePush.mm] notifyAppReady called with bundleId: %@", bundleId);
295
+ IpayCodePushImpl *impl = [InstantpayCodePush sharedImpl];
296
+ return [impl notifyAppReadyWithBundleId:bundleId];
297
+ }
298
+
299
+ - (NSArray<NSString *> *)getCrashHistory {
300
+ NSLog(@"[InstantpayCodePush.mm] getCrashHistory called");
301
+ IpayCodePushImpl *impl = [InstantpayCodePush sharedImpl];
302
+ NSArray<NSString *> *crashHistory = [impl getCrashHistory];
303
+ return crashHistory ?: @[];
304
+ }
305
+
306
+ - (NSNumber *)clearCrashHistory {
307
+ NSLog(@"[InstantpayCodePush.mm] clearCrashHistory called");
308
+ IpayCodePushImpl *impl = [InstantpayCodePush sharedImpl];
309
+ BOOL result = [impl clearCrashHistory];
310
+ return @(result);
311
+ }
312
+
313
+ - (NSString *)getBaseURL {
314
+ NSLog(@"[InstantpayCodePush.mm] getBaseURL called");
315
+ IpayCodePushImpl *impl = [InstantpayCodePush sharedImpl];
316
+ NSString *baseURL = [impl getBaseURL];
317
+ return baseURL ?: @"";
318
+ }
319
+
320
+ - (facebook::react::ModuleConstants<JS::NativeInstantpayCodePush::Constants::Builder>)constantsToExport {
321
+ return [self getConstants];
322
+ }
323
+
324
+ - (facebook::react::ModuleConstants<JS::NativeInstantpayCodePush::Constants::Builder>)getConstants {
325
+ IpayCodePushImpl *impl = [InstantpayCodePush sharedImpl];
326
+ return facebook::react::typedConstants<JS::NativeInstantpayCodePush::Constants::Builder>({
327
+ .MIN_BUNDLE_ID = [self getMinBundleId],
328
+ .APP_VERSION = [IpayCodePushImpl appVersion],
329
+ .CHANNEL = [impl getChannel],
330
+ .FINGERPRINT_HASH = [impl getFingerprintHash],
331
+ .KEYSTORE_PUBLIC_KEY = nil
332
+ });
333
+ }
334
+
335
+ #pragma mark - Turbo Module Support (Keep as is)
336
+
337
+
10
338
  - (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
11
339
  (const facebook::react::ObjCTurboModule::InitParams &)params
12
340
  {
@@ -0,0 +1,57 @@
1
+ //
2
+ // IpayCodePushHelper.swift
3
+ // InstantpayCodePush
4
+ //
5
+ // Created by Dhananjay kumar on 06/02/26.
6
+ //
7
+ import os
8
+
9
+ @objcMembers public class IpayCodePushHelper {
10
+
11
+ private init() {}
12
+
13
+ static let MAIN_LOG_TAG: String = "*IpayCodePush -> ";
14
+
15
+ let WARNING_LOG = "WARNING_LOG"
16
+
17
+ let ERROR_LOG = "ERROR_LOG"
18
+
19
+ static func logPrint(classTag: String, log: String?) -> Void {
20
+ if(log == nil){
21
+ return
22
+ }
23
+
24
+ let fullTagName = "\(MAIN_LOG_TAG) \(classTag)"
25
+
26
+ if let isEnableLog: Bool = Bundle.main.object(forInfoDictionaryKey: "IpayCodePush_Log") as! Bool? {
27
+ if(isEnableLog){
28
+ let logger = Logger(subsystem: "com.instantpay.ipayCodePush", category: "IpayCodePushHelper")
29
+ logger.info("\(fullTagName)")
30
+ }
31
+ }
32
+ }
33
+
34
+ static func logPrint(logType: String, classTag: String, log: String?){
35
+ if(log == nil){
36
+ return
37
+ }
38
+
39
+ let fullTagName = "\(MAIN_LOG_TAG) \(classTag)"
40
+
41
+ if let isEnableLog: Bool = Bundle.main.object(forInfoDictionaryKey: "IpayCodePush_Log") as! Bool? {
42
+ if(isEnableLog){
43
+ let logger = Logger(subsystem: "com.instantpay.ipayCodePush", category: "IpayCodePushHelper")
44
+ if(logType == "WARNING_LOG"){
45
+ logger.warning("\(fullTagName)")
46
+ }
47
+ else if(logType == "ERROR_LOG"){
48
+ logger.error("\(fullTagName)")
49
+ }
50
+ else{
51
+ logger.info("\(fullTagName)")
52
+ }
53
+ }
54
+ }
55
+ }
56
+
57
+ }
@@ -0,0 +1,297 @@
1
+ //
2
+ // IpayCodePushImpl.swift
3
+ // InstantpayCodePush
4
+ //
5
+ // Created by Dhananjay kumar on 06/02/26.
6
+ //
7
+
8
+ import Foundation
9
+ import React
10
+
11
+ @objcMembers public class IpayCodePushImpl: NSObject {
12
+
13
+ private let bundleStorage: BundleStorageService
14
+ private let preferences: PreferencesService
15
+
16
+ let CLASS_TAG = "*IpayCodePushImpl"
17
+
18
+ private static let DEFAULT_CHANNEL = "production"
19
+
20
+ // MARK: - Initialization
21
+
22
+ /**
23
+ * Convenience initializer that creates and configures all dependencies.
24
+ */
25
+ public convenience override init() {
26
+ let fileSystem = FileManagerService()
27
+ let isolationKey = IpayCodePushImpl.getIsolationKey()
28
+ let preferences = VersionedPreferencesService()
29
+ let downloadService = URLSessionDownloadService()
30
+ let decompressService = DecompressService()
31
+
32
+ let bundleStorage = BundleFileStorageService(
33
+ fileSystem: fileSystem,
34
+ downloadService: downloadService,
35
+ decompressService: decompressService,
36
+ preferences: preferences,
37
+ isolationKey: isolationKey
38
+ )
39
+
40
+ self.init(bundleStorage: bundleStorage, preferences: preferences)
41
+ }
42
+
43
+ /**
44
+ * Primary initializer with dependency injection.
45
+ * @param bundleStorage Service for bundle storage operations
46
+ * @param preferences Service for preference storage
47
+ */
48
+ internal init(bundleStorage: BundleStorageService, preferences: PreferencesService) {
49
+ self.bundleStorage = bundleStorage
50
+ self.preferences = preferences
51
+ super.init()
52
+
53
+ // Configure preferences with isolation key
54
+ let isolationKey = IpayCodePushImpl.getIsolationKey()
55
+ (preferences as? VersionedPreferencesService)?.configure(isolationKey: isolationKey)
56
+ }
57
+
58
+ // MARK: - Static Properties
59
+
60
+ /**
61
+ * Returns the app version from main bundle info.
62
+ */
63
+ public static var appVersion: String? {
64
+ return Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String
65
+ }
66
+
67
+ /**
68
+ * Returns the app version from main bundle info.
69
+ */
70
+ public static var appChannel: String {
71
+ return Bundle.main.object(forInfoDictionaryKey: "IPAY_CODE_PUSH_CHANNEL") as? String ?? Self.DEFAULT_CHANNEL
72
+ }
73
+
74
+ /**
75
+ * Gets the complete isolation key for preferences storage.
76
+ * @return The isolation key in format: ipaycodepush_{fingerprintOrVersion}_{channel}_
77
+ */
78
+ public static func getIsolationKey() -> String {
79
+ // Get fingerprint hash from Info.plist
80
+ let fingerprintHash = Bundle.main.object(forInfoDictionaryKey: "IPAY_CODE_PUSH_FINGERPRINT_HASH") as? String
81
+
82
+ // Get app version and channel
83
+ let appVersion = self.appVersion ?? "unknown"
84
+ let appChannel = self.appChannel
85
+
86
+ // Include both fingerprint hash and app version for complete isolation
87
+ let baseKey: String
88
+ if let hash = fingerprintHash, !hash.isEmpty {
89
+ baseKey = "\(hash)_\(appVersion)"
90
+ } else {
91
+ baseKey = appVersion
92
+ }
93
+
94
+ return "ipaycodepush_\(baseKey)_\(appChannel)_"
95
+ }
96
+
97
+ // MARK: - Channel Management
98
+
99
+ /**
100
+ * Gets the current update channel.
101
+ * @return The channel name or nil if not set
102
+ */
103
+ public func getChannel() -> String {
104
+ return Bundle.main.object(forInfoDictionaryKey: "IPAY_CODE_PUSH_CHANNEL") as? String ?? Self.DEFAULT_CHANNEL
105
+ }
106
+
107
+ /**
108
+ * Gets the current fingerprint hash.
109
+ * @return The fingerprint hash or nil if not set
110
+ */
111
+ public func getFingerprintHash() -> String? {
112
+ return Bundle.main.object(forInfoDictionaryKey: "IPAY_CODE_PUSH_FINGERPRINT_HASH") as? String
113
+ }
114
+
115
+ // MARK: - Bundle URL Management
116
+
117
+ /**
118
+ * Gets the URL to the bundle file.
119
+ * @param bundle instance to lookup the JavaScript bundle resource. Defaults to Bundle.main.
120
+ * @return URL to the bundle or nil
121
+ */
122
+ public func bundleURL(bundle: Bundle = Bundle.main) -> URL? {
123
+ return bundleStorage.getBundleURL(bundle: bundle)
124
+ }
125
+
126
+ // MARK: - Bundle Update
127
+
128
+ /**
129
+ * Updates the bundle from JavaScript bridge.
130
+ * This method acts as the primary error boundary for all bundle operations.
131
+ * @param params Dictionary with bundleId and fileUrl parameters
132
+ * @param resolve Promise resolve callback
133
+ * @param reject Promise reject callback
134
+ */
135
+ public func updateBundle(
136
+ _ params: NSDictionary?,
137
+ resolver resolve: @escaping RCTPromiseResolveBlock,
138
+ rejecter reject: @escaping RCTPromiseRejectBlock
139
+ ) {
140
+
141
+ do {
142
+ // Validate parameters (this runs on calling thread - typically JS thread)
143
+ guard let data = params else {
144
+ let error = NSError(domain: "IpayCodePush", code: 0,
145
+ userInfo: [NSLocalizedDescriptionKey: "Missing or invalid parameters for updateBundle"])
146
+ reject("UNKNOWN_ERROR", error.localizedDescription, error)
147
+ return
148
+ }
149
+
150
+ guard let bundleId = data["bundleId"] as? String, !bundleId.isEmpty else {
151
+ let error = NSError(domain: "IpayCodePush", code: 0,
152
+ userInfo: [NSLocalizedDescriptionKey: "Missing or empty 'bundleId'"])
153
+ reject("MISSING_BUNDLE_ID", error.localizedDescription, error)
154
+ return
155
+ }
156
+
157
+ let fileUrlString = data["fileUrl"] as? String ?? ""
158
+
159
+ var fileUrl: URL? = nil
160
+ if !fileUrlString.isEmpty {
161
+ guard let url = URL(string: fileUrlString) else {
162
+ let error = NSError(domain: "IpayCodePush", code: 0,
163
+ userInfo: [NSLocalizedDescriptionKey: "Invalid 'fileUrl' provided: \(fileUrlString)"])
164
+ reject("INVALID_FILE_URL", error.localizedDescription, error)
165
+ return
166
+ }
167
+ fileUrl = url
168
+ }
169
+
170
+ // Extract fileHash if provided
171
+ let fileHash = data["fileHash"] as? String
172
+
173
+ // Extract progress callback if provided
174
+ let progressCallback = data["progressCallback"] as? RCTResponseSenderBlock
175
+
176
+ IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "updateBundle called with bundleId: \(bundleId), fileUrl: \(fileUrl?.absoluteString ?? "nil"), fileHash: \(fileHash ?? "nil")")
177
+
178
+ // Heavy work is delegated to bundle storage service with safe error handling
179
+ bundleStorage.updateBundle(bundleId: bundleId, fileUrl: fileUrl, fileHash: fileHash, progressHandler: { progress in
180
+ // Call JS progress callback if provided
181
+ if let callback = progressCallback {
182
+ DispatchQueue.main.async {
183
+ callback([progress])
184
+ }
185
+ }
186
+ }) { [weak self] result in
187
+ guard self != nil else {
188
+ let error = NSError(domain: "IpayCodePush", code: 0,
189
+ userInfo: [NSLocalizedDescriptionKey: "Internal error: self deallocated during update"])
190
+ DispatchQueue.main.async {
191
+ reject("SELF_DEALLOCATED", error.localizedDescription, error)
192
+ }
193
+ return
194
+ }
195
+ // Return results on main thread for React Native bridge
196
+ DispatchQueue.main.async {
197
+ switch result {
198
+ case .success:
199
+ IpayCodePushHelper.logPrint(classTag: self!.CLASS_TAG, log: "Update successful for \(bundleId). Resolving promise.")
200
+ resolve(true)
201
+ case .failure(let error):
202
+ IpayCodePushHelper.logPrint(classTag: self!.CLASS_TAG, log: "Update failed for \(bundleId) - Error: \(error)")
203
+
204
+ let normalizedCode = IpayCodePushImpl.normalizeErrorCode(from: error)
205
+ let nsError = error as NSError
206
+ reject(normalizedCode, nsError.localizedDescription, nsError)
207
+ }
208
+ }
209
+ }
210
+ } catch let error {
211
+ // Main error boundary - catch and convert all errors to JS rejection
212
+ let nsError = error as NSError
213
+ IpayCodePushHelper.logPrint(classTag: CLASS_TAG, log: "Error in updateBundleFromJS - Domain: \(nsError.domain), Code: \(nsError.code), Description: \(nsError.localizedDescription)")
214
+
215
+ reject("UNKNOWN_ERROR", nsError.localizedDescription, nsError)
216
+ }
217
+ }
218
+
219
+ /**
220
+ * Normalizes native errors to a small, predictable set of JS-facing error codes.
221
+ * Rare or platform-specific codes are collapsed to UNKNOWN_ERROR to reduce surface area.
222
+ */
223
+ private static func normalizeErrorCode(from error: Error) -> String {
224
+ let baseCode: String
225
+
226
+ if let storageError = error as? BundleStorageError {
227
+ // Collapse signature sub-errors into a single public code
228
+ if case .signatureVerificationFailed = storageError {
229
+ baseCode = "SIGNATURE_VERIFICATION_FAILED"
230
+ } else {
231
+ baseCode = storageError.errorCodeString
232
+ }
233
+ } else if error is SignatureVerificationError {
234
+ baseCode = "SIGNATURE_VERIFICATION_FAILED"
235
+ } else {
236
+ baseCode = "UNKNOWN_ERROR"
237
+ }
238
+
239
+ return userFacingErrorCodes.contains(baseCode) ? baseCode : "UNKNOWN_ERROR"
240
+ }
241
+
242
+ // Error codes we intentionally expose to JS callers.
243
+ private static let userFacingErrorCodes: Set<String> = [
244
+ "MISSING_BUNDLE_ID",
245
+ "INVALID_FILE_URL",
246
+ "DIRECTORY_CREATION_FAILED",
247
+ "DOWNLOAD_FAILED",
248
+ "INCOMPLETE_DOWNLOAD",
249
+ "EXTRACTION_FORMAT_ERROR",
250
+ "INVALID_BUNDLE",
251
+ "INSUFFICIENT_DISK_SPACE",
252
+ "SIGNATURE_VERIFICATION_FAILED",
253
+ "MOVE_OPERATION_FAILED",
254
+ "BUNDLE_IN_CRASHED_HISTORY",
255
+ "SELF_DEALLOCATED",
256
+ "UNKNOWN_ERROR",
257
+ ]
258
+
259
+ // MARK: - Rollback Support
260
+
261
+ /**
262
+ * Notifies the system that the app has successfully started with the given bundle.
263
+ * If the bundle matches the staging bundle, it promotes to stable.
264
+ * @param bundleId The ID of the currently running bundle
265
+ * @return true if promotion was successful or no action was needed
266
+ */
267
+ public func notifyAppReady(bundleId: String) -> [String: Any] {
268
+ return bundleStorage.notifyAppReady(bundleId: bundleId)
269
+ }
270
+
271
+ /**
272
+ * Gets the crashed bundle history.
273
+ * @return Array of crashed bundle IDs
274
+ */
275
+ public func getCrashHistory() -> [String] {
276
+ return bundleStorage.getCrashHistory().bundles.map { $0.bundleId }
277
+ }
278
+
279
+ /**
280
+ * Clears the crashed bundle history.
281
+ * @return true if clearing was successful
282
+ */
283
+ public func clearCrashHistory() -> Bool {
284
+ return bundleStorage.clearCrashHistory()
285
+ }
286
+
287
+ /**
288
+ * Gets the base URL for the current active bundle directory.
289
+ * Returns the file:// URL to the bundle directory with trailing slash.
290
+ * This is used for Expo DOM components to construct full asset paths.
291
+ * @return Base URL string (e.g., "file:///var/.../bundle-store/abc123/") or empty string
292
+ */
293
+ public func getBaseURL() -> String {
294
+ return bundleStorage.getBaseURL()
295
+ }
296
+
297
+ }