@hot-updater/react-native 0.28.0 → 0.29.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +156 -7
- package/android/src/main/java/com/hotupdater/CohortService.kt +73 -0
- package/android/src/main/java/com/hotupdater/DecompressService.kt +28 -22
- package/android/src/main/java/com/hotupdater/HotUpdaterException.kt +1 -1
- package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +12 -0
- package/android/src/main/java/com/hotupdater/ReactNativeValueConverters.kt +55 -0
- package/android/src/main/java/com/hotupdater/TarBrDecompressionStrategy.kt +19 -7
- package/android/src/newarch/HotUpdaterModule.kt +16 -19
- package/android/src/oldarch/HotUpdaterModule.kt +20 -20
- package/android/src/oldarch/HotUpdaterSpec.kt +12 -2
- package/ios/HotUpdater/Internal/BundleFileStorageService.swift +153 -31
- package/ios/HotUpdater/Internal/CohortService.swift +63 -0
- package/ios/HotUpdater/Internal/DecompressService.swift +53 -30
- package/ios/HotUpdater/Internal/HotUpdater.mm +111 -59
- package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +28 -0
- package/ios/HotUpdater/Internal/TarBrDecompressionStrategy.swift +24 -8
- package/lib/commonjs/DefaultResolver.js +3 -5
- package/lib/commonjs/DefaultResolver.js.map +1 -1
- package/lib/commonjs/checkForUpdate.js +2 -0
- package/lib/commonjs/checkForUpdate.js.map +1 -1
- package/lib/commonjs/index.js +13 -0
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/native.js +193 -18
- package/lib/commonjs/native.js.map +1 -1
- package/lib/commonjs/native.spec.js +361 -4
- package/lib/commonjs/native.spec.js.map +1 -1
- package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
- package/lib/commonjs/types.js.map +1 -1
- package/lib/module/DefaultResolver.js +3 -5
- package/lib/module/DefaultResolver.js.map +1 -1
- package/lib/module/checkForUpdate.js +3 -1
- package/lib/module/checkForUpdate.js.map +1 -1
- package/lib/module/index.js +14 -1
- package/lib/module/index.js.map +1 -1
- package/lib/module/native.js +187 -14
- package/lib/module/native.js.map +1 -1
- package/lib/module/native.spec.js +361 -4
- package/lib/module/native.spec.js.map +1 -1
- package/lib/module/specs/NativeHotUpdater.js.map +1 -1
- package/lib/module/types.js.map +1 -1
- package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +14 -1
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/native.d.ts +39 -8
- package/lib/typescript/commonjs/native.d.ts.map +1 -1
- package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +28 -0
- package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
- package/lib/typescript/commonjs/types.d.ts +4 -0
- package/lib/typescript/commonjs/types.d.ts.map +1 -1
- package/lib/typescript/commonjs/wrap.d.ts +1 -1
- package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +14 -1
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/native.d.ts +39 -8
- package/lib/typescript/module/native.d.ts.map +1 -1
- package/lib/typescript/module/specs/NativeHotUpdater.d.ts +28 -0
- package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
- package/lib/typescript/module/types.d.ts +4 -0
- package/lib/typescript/module/types.d.ts.map +1 -1
- package/lib/typescript/module/wrap.d.ts +1 -1
- package/package.json +6 -6
- package/src/DefaultResolver.ts +4 -4
- package/src/checkForUpdate.ts +4 -0
- package/src/index.ts +21 -0
- package/src/native.spec.ts +400 -4
- package/src/native.ts +265 -20
- package/src/specs/NativeHotUpdater.ts +32 -0
- package/src/types.ts +5 -0
- package/src/wrap.tsx +1 -1
- package/lib/typescript/commonjs/native.spec.d.ts +0 -2
- package/lib/typescript/commonjs/native.spec.d.ts.map +0 -1
- package/lib/typescript/module/native.spec.d.ts +0 -2
- 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] = "
|
|
59
|
+
userInfo[NSLocalizedDescriptionKey] = "The downloaded bundle file is not a valid compressed archive"
|
|
58
60
|
userInfo[NSUnderlyingErrorKey] = underlyingError
|
|
59
|
-
userInfo[NSLocalizedRecoverySuggestionErrorKey] = "The
|
|
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
|
|
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
|
-
///
|
|
9
|
-
private let
|
|
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)
|
|
13
|
-
|
|
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
|
|
35
|
-
for strategy in
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
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
|
|
79
|
+
for strategy in signatureStrategies {
|
|
82
80
|
if strategy.isValid(file: file) {
|
|
83
81
|
return true
|
|
84
82
|
}
|
|
85
83
|
}
|
|
86
|
-
NSLog("[DecompressService] No
|
|
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
|
-
|
|
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
|
-
|
|
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];
|