@hot-updater/react-native 0.28.0 → 0.29.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.
Files changed (73) hide show
  1. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +156 -7
  2. package/android/src/main/java/com/hotupdater/CohortService.kt +73 -0
  3. package/android/src/main/java/com/hotupdater/DecompressService.kt +28 -22
  4. package/android/src/main/java/com/hotupdater/HotUpdaterException.kt +1 -1
  5. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +12 -0
  6. package/android/src/main/java/com/hotupdater/ReactNativeValueConverters.kt +55 -0
  7. package/android/src/main/java/com/hotupdater/TarBrDecompressionStrategy.kt +19 -7
  8. package/android/src/newarch/HotUpdaterModule.kt +16 -19
  9. package/android/src/oldarch/HotUpdaterModule.kt +20 -20
  10. package/android/src/oldarch/HotUpdaterSpec.kt +12 -2
  11. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +153 -31
  12. package/ios/HotUpdater/Internal/CohortService.swift +63 -0
  13. package/ios/HotUpdater/Internal/DecompressService.swift +53 -30
  14. package/ios/HotUpdater/Internal/HotUpdater.mm +111 -59
  15. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +28 -0
  16. package/ios/HotUpdater/Internal/TarBrDecompressionStrategy.swift +24 -8
  17. package/lib/commonjs/DefaultResolver.js +3 -5
  18. package/lib/commonjs/DefaultResolver.js.map +1 -1
  19. package/lib/commonjs/checkForUpdate.js +2 -0
  20. package/lib/commonjs/checkForUpdate.js.map +1 -1
  21. package/lib/commonjs/index.js +13 -0
  22. package/lib/commonjs/index.js.map +1 -1
  23. package/lib/commonjs/native.js +193 -18
  24. package/lib/commonjs/native.js.map +1 -1
  25. package/lib/commonjs/native.spec.js +361 -4
  26. package/lib/commonjs/native.spec.js.map +1 -1
  27. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  28. package/lib/commonjs/types.js.map +1 -1
  29. package/lib/module/DefaultResolver.js +3 -5
  30. package/lib/module/DefaultResolver.js.map +1 -1
  31. package/lib/module/checkForUpdate.js +3 -1
  32. package/lib/module/checkForUpdate.js.map +1 -1
  33. package/lib/module/index.js +14 -1
  34. package/lib/module/index.js.map +1 -1
  35. package/lib/module/native.js +187 -14
  36. package/lib/module/native.js.map +1 -1
  37. package/lib/module/native.spec.js +361 -4
  38. package/lib/module/native.spec.js.map +1 -1
  39. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  40. package/lib/module/types.js.map +1 -1
  41. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  42. package/lib/typescript/commonjs/index.d.ts +14 -1
  43. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  44. package/lib/typescript/commonjs/native.d.ts +39 -8
  45. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  46. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +28 -0
  47. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  48. package/lib/typescript/commonjs/types.d.ts +4 -0
  49. package/lib/typescript/commonjs/types.d.ts.map +1 -1
  50. package/lib/typescript/commonjs/wrap.d.ts +1 -1
  51. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  52. package/lib/typescript/module/index.d.ts +14 -1
  53. package/lib/typescript/module/index.d.ts.map +1 -1
  54. package/lib/typescript/module/native.d.ts +39 -8
  55. package/lib/typescript/module/native.d.ts.map +1 -1
  56. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +28 -0
  57. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  58. package/lib/typescript/module/types.d.ts +4 -0
  59. package/lib/typescript/module/types.d.ts.map +1 -1
  60. package/lib/typescript/module/wrap.d.ts +1 -1
  61. package/package.json +6 -6
  62. package/src/DefaultResolver.ts +4 -4
  63. package/src/checkForUpdate.ts +4 -0
  64. package/src/index.ts +21 -0
  65. package/src/native.spec.ts +400 -4
  66. package/src/native.ts +265 -20
  67. package/src/specs/NativeHotUpdater.ts +32 -0
  68. package/src/types.ts +5 -0
  69. package/src/wrap.tsx +1 -1
  70. package/lib/typescript/commonjs/native.spec.d.ts +0 -2
  71. package/lib/typescript/commonjs/native.spec.d.ts.map +0 -1
  72. package/lib/typescript/module/native.spec.d.ts +0 -2
  73. package/lib/typescript/module/native.spec.d.ts.map +0 -1
@@ -1,5 +1,7 @@
1
1
  import Foundation
2
2
 
3
+ public typealias ManifestAssets = [String: Any]
4
+
3
5
  public enum BundleStorageError: Error, CustomNSError {
4
6
  case directoryCreationFailed
5
7
  case downloadFailed(Error)
@@ -54,9 +56,9 @@ public enum BundleStorageError: Error, CustomNSError {
54
56
  userInfo[NSLocalizedRecoverySuggestionErrorKey] = "The download was interrupted. Check network connection and try again"
55
57
 
56
58
  case .extractionFormatError(let underlyingError):
57
- userInfo[NSLocalizedDescriptionKey] = "Invalid or corrupted bundle archive format"
59
+ userInfo[NSLocalizedDescriptionKey] = "The downloaded bundle file is not a valid compressed archive"
58
60
  userInfo[NSUnderlyingErrorKey] = underlyingError
59
- userInfo[NSLocalizedRecoverySuggestionErrorKey] = "The bundle archive may be corrupted or in an unsupported format. Try downloading again"
61
+ userInfo[NSLocalizedRecoverySuggestionErrorKey] = "The downloaded file is not a supported bundle archive. Try downloading again"
60
62
 
61
63
  case .invalidBundle:
62
64
  userInfo[NSLocalizedDescriptionKey] = "Bundle missing required platform files (index.ios.bundle or main.jsbundle)"
@@ -120,6 +122,18 @@ public protocol BundleStorageService {
120
122
  */
121
123
  func getBaseURL() -> String
122
124
 
125
+ /**
126
+ * Gets the current active bundle ID from bundle storage.
127
+ * Reads manifest.json first and falls back to older metadata when needed.
128
+ */
129
+ func getBundleId() -> String?
130
+
131
+ /**
132
+ * Gets the current manifest from bundle storage.
133
+ * Returns an empty object when manifest.json is missing or invalid.
134
+ */
135
+ func getManifest() -> ManifestAssets
136
+
123
137
  /**
124
138
  * Restores the original bundle and clears downloaded bundle state.
125
139
  */
@@ -127,6 +141,12 @@ public protocol BundleStorageService {
127
141
  }
128
142
 
129
143
  class BundleFileStorageService: BundleStorageService {
144
+ private struct ActiveBundleMetadataSnapshot {
145
+ let activeBundleId: String
146
+ let bundleId: String?
147
+ let manifest: ManifestAssets
148
+ }
149
+
130
150
  private let fileSystem: FileSystemService
131
151
  private let downloadService: DownloadService
132
152
  private let decompressService: DecompressService
@@ -141,6 +161,8 @@ class BundleFileStorageService: BundleStorageService {
141
161
  private var activeTasks: [URLSessionTask] = []
142
162
 
143
163
  private var currentLaunchReport: LaunchReport?
164
+ private let activeBundleMetadataLock = NSLock()
165
+ private var activeBundleMetadataSnapshot: ActiveBundleMetadataSnapshot?
144
166
 
145
167
  public init(fileSystem: FileSystemService,
146
168
  downloadService: DownloadService,
@@ -252,6 +274,125 @@ class BundleFileStorageService: BundleStorageService {
252
274
  return metadata.stableBundleId
253
275
  }
254
276
 
277
+ private func getActiveBundleId() -> String? {
278
+ let metadata = loadMetadataOrNull()
279
+
280
+ if let stagingBundleId = metadata?.stagingBundleId {
281
+ return stagingBundleId
282
+ }
283
+
284
+ if let stableBundleId = metadata?.stableBundleId {
285
+ return stableBundleId
286
+ }
287
+
288
+ return getCachedBundleURL()?.deletingLastPathComponent().lastPathComponent
289
+ }
290
+
291
+ private func withActiveBundleMetadataLock<T>(_ body: () -> T) -> T {
292
+ activeBundleMetadataLock.lock()
293
+ defer { activeBundleMetadataLock.unlock() }
294
+ return body()
295
+ }
296
+
297
+ private func clearActiveBundleMetadataSnapshot() {
298
+ withActiveBundleMetadataLock {
299
+ activeBundleMetadataSnapshot = nil
300
+ }
301
+ }
302
+
303
+ private func getActiveBundleMetadataSnapshot() -> ActiveBundleMetadataSnapshot? {
304
+ guard let activeBundleId = getActiveBundleId(),
305
+ case .success(let storeDir) = bundleStoreDir() else {
306
+ clearActiveBundleMetadataSnapshot()
307
+ return nil
308
+ }
309
+
310
+ if let snapshot = withActiveBundleMetadataLock({
311
+ activeBundleMetadataSnapshot?.activeBundleId == activeBundleId
312
+ ? activeBundleMetadataSnapshot
313
+ : nil
314
+ }) {
315
+ return snapshot
316
+ }
317
+
318
+ let bundleDir = (storeDir as NSString).appendingPathComponent(activeBundleId)
319
+ guard fileSystem.fileExists(atPath: bundleDir) else {
320
+ clearActiveBundleMetadataSnapshot()
321
+ return nil
322
+ }
323
+
324
+ let snapshot = resolveActiveBundleMetadataSnapshot(
325
+ activeBundleId: activeBundleId,
326
+ bundleDirectory: bundleDir
327
+ )
328
+ return withActiveBundleMetadataLock {
329
+ activeBundleMetadataSnapshot = snapshot
330
+ return snapshot
331
+ }
332
+ }
333
+
334
+ private func resolveActiveBundleMetadataSnapshot(
335
+ activeBundleId: String,
336
+ bundleDirectory: String
337
+ ) -> ActiveBundleMetadataSnapshot {
338
+ let manifest = readManifest(in: bundleDirectory) ?? [:]
339
+ let manifestBundleId =
340
+ (manifest["bundleId"] as? String)?
341
+ .trimmingCharacters(in: .whitespacesAndNewlines)
342
+ let resolvedBundleId =
343
+ (manifestBundleId?.isEmpty == false ? manifestBundleId : nil) ??
344
+ readCompatibilityBundleId(in: bundleDirectory)
345
+
346
+ return ActiveBundleMetadataSnapshot(
347
+ activeBundleId: activeBundleId,
348
+ bundleId: resolvedBundleId,
349
+ manifest: manifest
350
+ )
351
+ }
352
+
353
+ private func readCompatibilityBundleId(in bundleDirectory: String) -> String? {
354
+ let compatibilityBundleIdPath = (bundleDirectory as NSString)
355
+ .appendingPathComponent(compatibilityBundleIdFilename())
356
+ if fileSystem.fileExists(atPath: compatibilityBundleIdPath) {
357
+ do {
358
+ let compatibilityBundleId = try String(
359
+ contentsOfFile: compatibilityBundleIdPath,
360
+ encoding: .utf8
361
+ )
362
+ .trimmingCharacters(in: .whitespacesAndNewlines)
363
+
364
+ if !compatibilityBundleId.isEmpty {
365
+ return compatibilityBundleId
366
+ }
367
+ } catch {
368
+ NSLog(
369
+ "[BundleStorage] Failed to read compatibility bundle metadata at \(compatibilityBundleIdPath): \(error.localizedDescription)"
370
+ )
371
+ }
372
+ }
373
+
374
+ return nil
375
+ }
376
+
377
+ private func compatibilityBundleIdFilename() -> String {
378
+ "BUNDLE_ID"
379
+ }
380
+
381
+ private func readManifest(in bundleDirectory: String) -> [String: Any]? {
382
+ let manifestPath = (bundleDirectory as NSString).appendingPathComponent("manifest.json")
383
+ guard fileSystem.fileExists(atPath: manifestPath) else {
384
+ return nil
385
+ }
386
+
387
+ do {
388
+ let manifestData = try Data(contentsOf: URL(fileURLWithPath: manifestPath))
389
+ return try JSONSerialization.jsonObject(with: manifestData) as? [String: Any]
390
+ } catch {
391
+ NSLog("[BundleStorage] Failed to read manifest at \(manifestPath): \(error.localizedDescription)")
392
+ return nil
393
+ }
394
+ }
395
+
255
396
  /**
256
397
  * Checks if isolationKey has changed and cleans up old bundles if needed.
257
398
  * This handles migration when isolationKey format changes.
@@ -598,6 +739,7 @@ class BundleFileStorageService: BundleStorageService {
598
739
  do {
599
740
  NSLog("[BundleStorage] Setting bundle URL to: \(localPath ?? "nil")")
600
741
  try self.preferences.setItem(localPath, forKey: "HotUpdaterBundleURL")
742
+ clearActiveBundleMetadataSnapshot()
601
743
  return .success(())
602
744
  } catch let error {
603
745
  return .failure(error)
@@ -1171,35 +1313,7 @@ class BundleFileStorageService: BundleStorageService {
1171
1313
  */
1172
1314
  func getBaseURL() -> String {
1173
1315
  do {
1174
- let metadata = loadMetadataOrNull()
1175
- let activeBundleId: String?
1176
-
1177
- // Prefer the current staging bundle regardless of verification state.
1178
- if let staging = metadata?.stagingBundleId {
1179
- activeBundleId = staging
1180
- } else if let stable = metadata?.stableBundleId {
1181
- activeBundleId = stable
1182
- } else {
1183
- // Fall back to current bundle ID from preferences
1184
- if let savedURL = try preferences.getItem(forKey: "HotUpdaterBundleURL") {
1185
- // Extract bundle ID from path like "bundle-store/abc123/index.ios.bundle"
1186
- if let range = savedURL.range(of: "bundle-store/([^/]+)/", options: .regularExpression) {
1187
- let match = savedURL[range]
1188
- let components = match.split(separator: "/")
1189
- if components.count >= 2 {
1190
- activeBundleId = String(components[1])
1191
- } else {
1192
- activeBundleId = nil
1193
- }
1194
- } else {
1195
- activeBundleId = nil
1196
- }
1197
- } else {
1198
- activeBundleId = nil
1199
- }
1200
- }
1201
-
1202
- if let bundleId = activeBundleId {
1316
+ if let bundleId = getActiveBundleId() {
1203
1317
  if case .success(let storeDir) = bundleStoreDir() {
1204
1318
  let bundleDir = (storeDir as NSString).appendingPathComponent(bundleId)
1205
1319
  if fileSystem.fileExists(atPath: bundleDir) {
@@ -1215,6 +1329,14 @@ class BundleFileStorageService: BundleStorageService {
1215
1329
  }
1216
1330
  }
1217
1331
 
1332
+ func getBundleId() -> String? {
1333
+ return getActiveBundleMetadataSnapshot()?.bundleId
1334
+ }
1335
+
1336
+ func getManifest() -> ManifestAssets {
1337
+ return getActiveBundleMetadataSnapshot()?.manifest ?? [:]
1338
+ }
1339
+
1218
1340
  func resetChannel() -> Result<Bool, Error> {
1219
1341
  guard case .success = setBundleURL(localPath: nil) else {
1220
1342
  return .failure(BundleStorageError.unknown(nil))
@@ -0,0 +1,63 @@
1
+ import Foundation
2
+ import UIKit
3
+
4
+ final class CohortService {
5
+ private let userDefaults: UserDefaults
6
+
7
+ // Keep the legacy key so existing custom cohorts continue to work.
8
+ private let cohortKey = "HotUpdater_CustomCohort"
9
+ private let fallbackIdentifierKey = "HotUpdater_FallbackCohortIdentifier"
10
+
11
+ init(userDefaults: UserDefaults = .standard) {
12
+ self.userDefaults = userDefaults
13
+ }
14
+
15
+ private func hashString(_ value: String) -> Int32 {
16
+ var hash: Int32 = 0
17
+
18
+ for scalar in value.unicodeScalars {
19
+ hash = (hash &* 31) &+ Int32(scalar.value)
20
+ }
21
+
22
+ return hash
23
+ }
24
+
25
+ private func defaultNumericCohort(for identifier: String) -> String {
26
+ let hash = Int64(hashString(identifier))
27
+ let normalized = Int((hash % 1000 + 1000) % 1000) + 1
28
+ return String(normalized)
29
+ }
30
+
31
+ private func fallbackIdentifier() -> String {
32
+ if let fallbackId = userDefaults.string(forKey: fallbackIdentifierKey), !fallbackId.isEmpty {
33
+ return fallbackId
34
+ }
35
+
36
+ let fallbackId = UUID().uuidString
37
+ userDefaults.set(fallbackId, forKey: fallbackIdentifierKey)
38
+ return fallbackId
39
+ }
40
+
41
+ func setCohort(_ cohort: String) {
42
+ if cohort.isEmpty {
43
+ return
44
+ }
45
+ userDefaults.set(cohort, forKey: cohortKey)
46
+ }
47
+
48
+ func getCohort() -> String {
49
+ if let cohort = userDefaults.string(forKey: cohortKey), !cohort.isEmpty {
50
+ return cohort
51
+ }
52
+
53
+ let initialCohort: String
54
+ if let idfv = UIDevice.current.identifierForVendor?.uuidString, !idfv.isEmpty {
55
+ initialCohort = defaultNumericCohort(for: idfv)
56
+ } else {
57
+ initialCohort = defaultNumericCohort(for: fallbackIdentifier())
58
+ }
59
+
60
+ userDefaults.set(initialCohort, forKey: cohortKey)
61
+ return initialCohort
62
+ }
63
+ }
@@ -5,16 +5,19 @@ import Foundation
5
5
  * Automatically detects format by trying each strategy's validation and delegates to appropriate decompression strategy.
6
6
  */
7
7
  class DecompressService {
8
- /// Array of available strategies in order of detection priority
9
- private let strategies: [DecompressionStrategy]
8
+ /// Strategies with reliable file signatures that can be validated cheaply.
9
+ private let signatureStrategies: [DecompressionStrategy]
10
+ /// TAR.BR has no reliable magic bytes, so it is attempted as the final fallback.
11
+ private let tarBrStrategy: DecompressionStrategy
10
12
 
11
13
  init() {
12
- // Order matters: Try ZIP first (clear magic bytes), then TAR.GZ (GZIP magic bytes), then TAR.BR (fallback)
13
- self.strategies = [
14
+ // Order matters: Try ZIP first (clear magic bytes), then TAR.GZ (GZIP magic bytes).
15
+ // TAR.BR is attempted only after signature-based formats are ruled out.
16
+ self.signatureStrategies = [
14
17
  ZipDecompressionStrategy(),
15
- TarGzDecompressionStrategy(),
16
- TarBrDecompressionStrategy()
18
+ TarGzDecompressionStrategy()
17
19
  ]
20
+ self.tarBrStrategy = TarBrDecompressionStrategy()
18
21
  }
19
22
 
20
23
  /**
@@ -31,8 +34,8 @@ class DecompressService {
31
34
  let fileName = fileURL.lastPathComponent
32
35
  let fileSize = (try? FileManager.default.attributesOfItem(atPath: file)[.size] as? UInt64) ?? 0
33
36
 
34
- // Try each strategy's validation
35
- for strategy in strategies {
37
+ // Try each signature-based strategy first.
38
+ for strategy in signatureStrategies {
36
39
  if strategy.isValid(file: file) {
37
40
  NSLog("[DecompressService] Using strategy for \(fileName)")
38
41
  try strategy.decompress(file: file, to: destination, progressHandler: progressHandler)
@@ -40,26 +43,21 @@ class DecompressService {
40
43
  }
41
44
  }
42
45
 
43
- // No valid strategy found - provide detailed error message
44
- let errorMessage = """
45
- Failed to decompress file: \(fileName) (\(fileSize) bytes)
46
-
47
- Tried strategies: ZIP (magic bytes 0x504B0304), TAR.GZ (magic bytes 0x1F8B), TAR.BR (file extension)
48
-
49
- Supported formats:
50
- - ZIP archives (.zip)
51
- - GZIP compressed TAR archives (.tar.gz)
52
- - Brotli compressed TAR archives (.tar.br)
46
+ NSLog("[DecompressService] No ZIP/TAR.GZ signature matched for \(fileName), trying TAR.BR fallback")
53
47
 
54
- Please verify the file is not corrupted and matches one of the supported formats.
55
- """
56
-
57
- NSLog("[DecompressService] \(errorMessage)")
58
- throw NSError(
59
- domain: "DecompressService",
60
- code: 1,
61
- userInfo: [NSLocalizedDescriptionKey: errorMessage]
62
- )
48
+ do {
49
+ try tarBrStrategy.decompress(file: file, to: destination, progressHandler: progressHandler)
50
+ NSLog("[DecompressService] Using TAR.BR fallback for \(fileName)")
51
+ return
52
+ } catch {
53
+ let invalidArchiveError = createInvalidArchiveError(
54
+ fileName: fileName,
55
+ fileSize: fileSize,
56
+ underlyingError: error
57
+ )
58
+ NSLog("[DecompressService] \(invalidArchiveError.localizedDescription)")
59
+ throw invalidArchiveError
60
+ }
63
61
  }
64
62
 
65
63
  /**
@@ -73,17 +71,42 @@ Please verify the file is not corrupted and matches one of the supported formats
73
71
  }
74
72
 
75
73
  /**
76
- * Validates if a file is a valid compressed archive.
74
+ * Validates if a file matches one of the signature-based archive formats.
77
75
  * @param file Path to the file to validate
78
76
  * @return true if the file is valid for any strategy
79
77
  */
80
78
  func isValid(file: String) -> Bool {
81
- for strategy in strategies {
79
+ for strategy in signatureStrategies {
82
80
  if strategy.isValid(file: file) {
83
81
  return true
84
82
  }
85
83
  }
86
- NSLog("[DecompressService] No valid strategy found for file: \(file)")
84
+ NSLog("[DecompressService] No ZIP/TAR.GZ signature matched for file: \(file). TAR.BR is handled during extraction fallback.")
87
85
  return false
88
86
  }
87
+
88
+ private func createInvalidArchiveError(fileName: String, fileSize: UInt64, underlyingError: Error? = nil) -> NSError {
89
+ let errorMessage = """
90
+ The downloaded bundle file is not a valid compressed archive: \(fileName) (\(fileSize) bytes)
91
+
92
+ Supported formats:
93
+ - ZIP archives (.zip)
94
+ - GZIP compressed TAR archives (.tar.gz)
95
+ - Brotli compressed TAR archives (.tar.br)
96
+ """
97
+
98
+ var userInfo: [String: Any] = [
99
+ NSLocalizedDescriptionKey: errorMessage
100
+ ]
101
+
102
+ if let underlyingError {
103
+ userInfo[NSUnderlyingErrorKey] = underlyingError
104
+ }
105
+
106
+ return NSError(
107
+ domain: "DecompressService",
108
+ code: 1,
109
+ userInfo: userInfo
110
+ )
111
+ }
89
112
  }
@@ -17,6 +17,7 @@
17
17
 
18
18
  @interface HotUpdater (InternalSharedImpl)
19
19
  + (HotUpdaterImpl *)sharedImpl;
20
+ + (NSString *)generateUUIDv7FromTimestamp:(uint64_t)timestampMs;
20
21
  @end
21
22
 
22
23
  namespace {
@@ -171,6 +172,68 @@ extern "C" BOOL HotUpdaterPerformRecoveryReload(void)
171
172
  return didTriggerReload;
172
173
  }
173
174
 
175
+ extern "C" NSString *HotUpdaterGetMinBundleId(void)
176
+ {
177
+ static NSString *uuid = nil;
178
+ static dispatch_once_t onceToken;
179
+ dispatch_once(&onceToken, ^{
180
+ #if DEBUG
181
+ uuid = @"00000000-0000-0000-0000-000000000000";
182
+ #else
183
+ // Step 1: Try to read HOT_UPDATER_BUILD_TIMESTAMP from Info.plist
184
+ NSDictionary *infoDictionary = [[NSBundle mainBundle] infoDictionary];
185
+ NSString *customValue = infoDictionary[@"HOT_UPDATER_BUILD_TIMESTAMP"];
186
+
187
+ // Step 2: If custom value exists and is not empty
188
+ if (customValue && customValue.length > 0 && ![customValue isEqualToString:@"$(HOT_UPDATER_BUILD_TIMESTAMP)"]) {
189
+ // Check if it's a timestamp (pure digits) or UUID
190
+ NSCharacterSet *nonDigits = [[NSCharacterSet decimalDigitCharacterSet] invertedSet];
191
+ BOOL isTimestamp = ([customValue rangeOfCharacterFromSet:nonDigits].location == NSNotFound);
192
+
193
+ if (isTimestamp) {
194
+ // Convert timestamp (milliseconds) to UUID v7
195
+ uint64_t timestampMs = [customValue longLongValue];
196
+ uuid = [HotUpdater generateUUIDv7FromTimestamp:timestampMs];
197
+ RCTLogInfo(@"[HotUpdater.mm] Using timestamp %@ as MIN_BUNDLE_ID: %@", customValue, uuid);
198
+ } else {
199
+ // Use as UUID directly
200
+ uuid = customValue;
201
+ RCTLogInfo(@"[HotUpdater.mm] Using custom MIN_BUNDLE_ID from Info.plist: %@", uuid);
202
+ }
203
+ return;
204
+ }
205
+
206
+ // Step 3: Fallback to default logic (26-hour subtraction)
207
+ RCTLogInfo(@"[HotUpdater.mm] No custom MIN_BUNDLE_ID found, using default calculation");
208
+
209
+ NSString *compileDateStr = [NSString stringWithFormat:@"%s %s", __DATE__, __TIME__];
210
+ NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
211
+
212
+ // Parse __DATE__ __TIME__ as UTC to ensure consistent timezone handling across all build environments
213
+ [formatter setTimeZone:[NSTimeZone timeZoneWithName:@"UTC"]];
214
+ [formatter setLocale:[[NSLocale alloc] initWithLocaleIdentifier:@"en_US_POSIX"]];
215
+ [formatter setDateFormat:@"MMM d yyyy HH:mm:ss"];
216
+ NSDate *buildDate = [formatter dateFromString:compileDateStr];
217
+ if (!buildDate) {
218
+ RCTLogWarn(@"[HotUpdater.mm] Could not parse build date: %@", compileDateStr);
219
+ uuid = @"00000000-0000-0000-0000-000000000000";
220
+ return;
221
+ }
222
+
223
+ // Subtract 26 hours (93600 seconds) to ensure MIN_BUNDLE_ID is always in the past
224
+ // This guarantees that uuidv7-based bundleIds (generated at runtime) will always be newer than MIN_BUNDLE_ID
225
+ // Why 26 hours? Global timezone range spans from UTC-12 to UTC+14 (total 26 hours)
226
+ // By subtracting 26 hours, MIN_BUNDLE_ID becomes a safe "past timestamp" regardless of build timezone
227
+ // Example: Build at 15:00 in any timezone -> parse as 15:00 UTC -> subtract 26h -> 13:00 UTC (previous day)
228
+ NSTimeInterval adjustedTimestamp = [buildDate timeIntervalSince1970] - 93600.0;
229
+ uint64_t buildTimestampMs = (uint64_t)(adjustedTimestamp * 1000.0);
230
+
231
+ uuid = [HotUpdater generateUUIDv7FromTimestamp:buildTimestampMs];
232
+ #endif
233
+ });
234
+ return uuid;
235
+ }
236
+
174
237
 
175
238
  // Define Notification names used for observing Swift Core
176
239
  NSNotificationName const HotUpdaterDownloadProgressUpdateNotification = @"HotUpdaterDownloadProgressUpdate";
@@ -326,68 +389,11 @@ RCT_EXPORT_MODULE();
326
389
 
327
390
  // Returns the minimum bundle ID string, either from Info.plist or generated from build timestamp
328
391
  - (NSString *)getMinBundleId {
329
- static NSString *uuid = nil;
330
- static dispatch_once_t onceToken;
331
- dispatch_once(&onceToken, ^{
332
- #if DEBUG
333
- uuid = @"00000000-0000-0000-0000-000000000000";
334
- #else
335
- // Step 1: Try to read HOT_UPDATER_BUILD_TIMESTAMP from Info.plist
336
- NSDictionary *infoDictionary = [[NSBundle mainBundle] infoDictionary];
337
- NSString *customValue = infoDictionary[@"HOT_UPDATER_BUILD_TIMESTAMP"];
338
-
339
- // Step 2: If custom value exists and is not empty
340
- if (customValue && customValue.length > 0 && ![customValue isEqualToString:@"$(HOT_UPDATER_BUILD_TIMESTAMP)"]) {
341
- // Check if it's a timestamp (pure digits) or UUID
342
- NSCharacterSet *nonDigits = [[NSCharacterSet decimalDigitCharacterSet] invertedSet];
343
- BOOL isTimestamp = ([customValue rangeOfCharacterFromSet:nonDigits].location == NSNotFound);
344
-
345
- if (isTimestamp) {
346
- // Convert timestamp (milliseconds) to UUID v7
347
- uint64_t timestampMs = [customValue longLongValue];
348
- uuid = [self generateUUIDv7FromTimestamp:timestampMs];
349
- RCTLogInfo(@"[HotUpdater.mm] Using timestamp %@ as MIN_BUNDLE_ID: %@", customValue, uuid);
350
- } else {
351
- // Use as UUID directly
352
- uuid = customValue;
353
- RCTLogInfo(@"[HotUpdater.mm] Using custom MIN_BUNDLE_ID from Info.plist: %@", uuid);
354
- }
355
- return;
356
- }
357
-
358
- // Step 3: Fallback to default logic (26-hour subtraction)
359
- RCTLogInfo(@"[HotUpdater.mm] No custom MIN_BUNDLE_ID found, using default calculation");
360
-
361
- NSString *compileDateStr = [NSString stringWithFormat:@"%s %s", __DATE__, __TIME__];
362
- NSDateFormatter *formatter = [[NSDateFormatter alloc] init];
363
-
364
- // Parse __DATE__ __TIME__ as UTC to ensure consistent timezone handling across all build environments
365
- [formatter setTimeZone:[NSTimeZone timeZoneWithName:@"UTC"]];
366
- [formatter setLocale:[[NSLocale alloc] initWithLocaleIdentifier:@"en_US_POSIX"]];
367
- [formatter setDateFormat:@"MMM d yyyy HH:mm:ss"]; // Correct format for __DATE__ __TIME__
368
- NSDate *buildDate = [formatter dateFromString:compileDateStr];
369
- if (!buildDate) {
370
- RCTLogWarn(@"[HotUpdater.mm] Could not parse build date: %@", compileDateStr);
371
- uuid = @"00000000-0000-0000-0000-000000000000";
372
- return;
373
- }
374
-
375
- // Subtract 26 hours (93600 seconds) to ensure MIN_BUNDLE_ID is always in the past
376
- // This guarantees that uuidv7-based bundleIds (generated at runtime) will always be newer than MIN_BUNDLE_ID
377
- // Why 26 hours? Global timezone range spans from UTC-12 to UTC+14 (total 26 hours)
378
- // By subtracting 26 hours, MIN_BUNDLE_ID becomes a safe "past timestamp" regardless of build timezone
379
- // Example: Build at 15:00 in any timezone → parse as 15:00 UTC → subtract 26h → 13:00 UTC (previous day)
380
- NSTimeInterval adjustedTimestamp = [buildDate timeIntervalSince1970] - 93600.0;
381
- uint64_t buildTimestampMs = (uint64_t)(adjustedTimestamp * 1000.0);
382
-
383
- uuid = [self generateUUIDv7FromTimestamp:buildTimestampMs];
384
- #endif
385
- });
386
- return uuid;
392
+ return HotUpdaterGetMinBundleId();
387
393
  }
388
394
 
389
395
  // Helper method: Generate UUID v7 from timestamp (milliseconds)
390
- - (NSString *)generateUUIDv7FromTimestamp:(uint64_t)timestampMs {
396
+ + (NSString *)generateUUIDv7FromTimestamp:(uint64_t)timestampMs {
391
397
  unsigned char bytes[16];
392
398
 
393
399
  // UUID v7 format: timestamp_ms (48 bits) + ver (4 bits) + random (12 bits) + variant (2 bits) + random (62 bits)
@@ -582,6 +588,29 @@ RCT_EXPORT_MODULE();
582
588
  return baseURL ?: @"";
583
589
  }
584
590
 
591
+ - (void)setCohort:(NSString *)customId {
592
+ HotUpdaterImpl *impl = [HotUpdater sharedImpl];
593
+ [impl setCohort:customId];
594
+ }
595
+
596
+ - (NSString *)getCohort {
597
+ HotUpdaterImpl *impl = [HotUpdater sharedImpl];
598
+ return [impl getCohort];
599
+ }
600
+
601
+ - (NSString * _Nullable)getBundleId {
602
+ NSLog(@"[HotUpdater.mm] getBundleId called");
603
+ HotUpdaterImpl *impl = [HotUpdater sharedImpl];
604
+ return [impl getBundleId];
605
+ }
606
+
607
+ - (NSDictionary<NSString *, id> *)getManifest {
608
+ NSLog(@"[HotUpdater.mm] getManifest called");
609
+ HotUpdaterImpl *impl = [HotUpdater sharedImpl];
610
+ NSDictionary<NSString *, id> *manifest = [impl getManifest];
611
+ return manifest ?: @{};
612
+ }
613
+
585
614
  - (void)resetChannel:(RCTPromiseResolveBlock)resolve
586
615
  reject:(RCTPromiseRejectBlock)reject {
587
616
  HotUpdaterImpl *impl = [HotUpdater sharedImpl];
@@ -673,6 +702,29 @@ RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(getBaseURL) {
673
702
  return baseURL ?: @"";
674
703
  }
675
704
 
705
+ RCT_EXPORT_METHOD(setCohort:(NSString *)customId) {
706
+ HotUpdaterImpl *impl = [HotUpdater sharedImpl];
707
+ [impl setCohort:customId];
708
+ }
709
+
710
+ RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(getCohort) {
711
+ HotUpdaterImpl *impl = [HotUpdater sharedImpl];
712
+ return [impl getCohort];
713
+ }
714
+
715
+ RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(getBundleId) {
716
+ NSLog(@"[HotUpdater.mm] getBundleId called");
717
+ HotUpdaterImpl *impl = [HotUpdater sharedImpl];
718
+ return [impl getBundleId];
719
+ }
720
+
721
+ RCT_EXPORT_BLOCKING_SYNCHRONOUS_METHOD(getManifest) {
722
+ NSLog(@"[HotUpdater.mm] getManifest called");
723
+ HotUpdaterImpl *impl = [HotUpdater sharedImpl];
724
+ NSDictionary<NSString *, id> *manifest = [impl getManifest];
725
+ return manifest ?: @{};
726
+ }
727
+
676
728
  RCT_EXPORT_METHOD(resetChannel:(RCTPromiseResolveBlock)resolve
677
729
  reject:(RCTPromiseRejectBlock)reject) {
678
730
  HotUpdaterImpl *impl = [HotUpdater sharedImpl];