@hot-updater/react-native 1.0.8 → 1.1.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 (145) hide show
  1. package/HotUpdater.podspec +14 -2
  2. package/Package.swift +24 -3
  3. package/android/build.gradle +2 -6
  4. package/android/hot-updater-fingerprint.gradle +135 -0
  5. package/android/src/main/cpp/HotUpdaterRecovery.cpp +291 -21
  6. package/android/src/main/java/com/hotupdater/ActivityStartGate.kt +176 -15
  7. package/android/src/main/java/com/hotupdater/ArtifactFileResolver.kt +20 -8
  8. package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +145 -31
  9. package/android/src/main/java/com/hotupdater/CohortService.kt +10 -2
  10. package/android/src/main/java/com/hotupdater/FileManagerService.kt +3 -1
  11. package/android/src/main/java/com/hotupdater/HotUpdaterConfig.kt +249 -21
  12. package/android/src/main/java/com/hotupdater/HotUpdaterException.kt +8 -4
  13. package/android/src/main/java/com/hotupdater/HotUpdaterHeldContent.kt +92 -0
  14. package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +319 -113
  15. package/android/src/main/java/com/hotupdater/HotUpdaterInstance.kt +87 -0
  16. package/android/src/main/java/com/hotupdater/HotUpdaterInstanceEnvironment.kt +323 -0
  17. package/android/src/main/java/com/hotupdater/HotUpdaterMainThread.kt +46 -0
  18. package/android/src/main/java/com/hotupdater/HotUpdaterProcess.kt +30 -0
  19. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryAttribution.kt +492 -0
  20. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryManager.kt +1541 -159
  21. package/android/src/main/java/com/hotupdater/HotUpdaterRecoverySlots.kt +137 -0
  22. package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryState.kt +4 -0
  23. package/android/src/main/java/com/hotupdater/HotUpdaterRuntimeInstance.kt +53 -0
  24. package/android/src/main/java/com/hotupdater/HotUpdaterUpdateCheck.kt +149 -0
  25. package/android/src/main/java/com/hotupdater/InstallIdentityService.kt +12 -2
  26. package/android/src/main/java/com/hotupdater/NativeConfigUtils.kt +5 -6
  27. package/android/src/main/java/com/hotupdater/NativeUpdateCheck.kt +172 -0
  28. package/android/src/main/java/com/hotupdater/NativeUpdateChecker.kt +1007 -0
  29. package/android/src/main/java/com/hotupdater/NativeUpdateEventQueue.kt +111 -0
  30. package/android/src/main/java/com/hotupdater/OkHttpUpdateCheckTransport.kt +88 -0
  31. package/android/src/main/java/com/hotupdater/ReleaseCatalog.kt +491 -0
  32. package/android/src/main/java/com/hotupdater/ReleaseCatalogClient.kt +405 -0
  33. package/android/src/main/java/com/hotupdater/ReleaseSelection.kt +474 -0
  34. package/android/src/main/java/com/hotupdater/SignatureVerifier.kt +32 -10
  35. package/android/src/main/java/com/hotupdater/StrictJson.kt +392 -0
  36. package/android/src/main/java/com/hotupdater/VersionedPreferencesService.kt +24 -9
  37. package/android/src/main/java/com/hotupdater/WeakIdentitySet.kt +76 -0
  38. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BitReader.java +314 -0
  39. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliError.java +47 -0
  40. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliInputStream.java +174 -0
  41. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliRuntimeException.java +21 -0
  42. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Context.java +58 -0
  43. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Decode.java +1574 -0
  44. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Decoder.java +72 -0
  45. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Dictionary.java +103 -0
  46. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/DictionaryData.java +82 -0
  47. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Huffman.java +143 -0
  48. package/{LICENSE → android/src/main/java/com/hotupdater/vendor/brotli/dec/LICENSE} +6 -20
  49. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/README.md +39 -0
  50. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/State.java +100 -0
  51. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Transform.java +240 -0
  52. package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Utils.java +163 -0
  53. package/android/src/main/resources/META-INF/LICENSE-brotli +19 -0
  54. package/android/src/newarch/HotUpdater.kt +53 -15
  55. package/android/src/newarch/HotUpdaterModule.kt +42 -17
  56. package/android/src/newarch/JavaScriptExceptionHooks.kt +45 -8
  57. package/android/src/oldarch/HotUpdater.kt +51 -12
  58. package/android/src/oldarch/HotUpdaterModule.kt +43 -17
  59. package/android/src/oldarch/HotUpdaterSpec.kt +2 -0
  60. package/android/src/oldarch/JavaScriptExceptionHooks.kt +25 -16
  61. package/ios/HotUpdater/Interface/HotUpdaterInterface.h +13 -0
  62. package/ios/HotUpdater/Internal/ArtifactFileResolver.swift +38 -7
  63. package/ios/HotUpdater/Internal/BuiltInAssetResolver.swift +10 -4
  64. package/ios/HotUpdater/Internal/BundleFileStorageService.swift +186 -48
  65. package/ios/HotUpdater/Internal/CohortService.swift +8 -0
  66. package/ios/HotUpdater/Internal/HotUpdater.mm +821 -71
  67. package/ios/HotUpdater/Internal/HotUpdaterAsync.swift +7 -0
  68. package/ios/HotUpdater/Internal/HotUpdaterConfig.swift +208 -25
  69. package/ios/HotUpdater/Internal/HotUpdaterFingerprintHash.m +10 -0
  70. package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +940 -261
  71. package/ios/HotUpdater/Internal/HotUpdaterInstanceEnvironment.swift +492 -0
  72. package/ios/HotUpdater/Internal/HotUpdaterInstanceRouter.swift +83 -0
  73. package/ios/HotUpdater/Internal/HotUpdaterInterface.swift +28 -1
  74. package/ios/HotUpdater/Internal/HotUpdaterLaunchRecovery.swift +1553 -0
  75. package/ios/HotUpdater/Internal/HotUpdaterNativeUpdates.swift +353 -0
  76. package/ios/HotUpdater/Internal/LaunchPreparationLock.swift +54 -0
  77. package/ios/HotUpdater/Internal/NativeUpdateCheck.swift +2051 -0
  78. package/ios/HotUpdater/Internal/ReleaseCatalog.swift +634 -0
  79. package/ios/HotUpdater/Internal/ReleaseCatalogCacheService.swift +1 -1
  80. package/ios/HotUpdater/Internal/ReleaseSelection.swift +482 -0
  81. package/ios/HotUpdater/Internal/SignatureVerifier.swift +27 -10
  82. package/ios/HotUpdater/Internal/StrictJson.swift +363 -0
  83. package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +4 -2
  84. package/ios/HotUpdater/Internal/UpdateFailure.swift +2 -0
  85. package/ios/HotUpdater/Package.swift +11 -0
  86. package/ios/HotUpdater/Public/HotUpdater/HotUpdater.h +4 -0
  87. package/ios/HotUpdater/Public/HotUpdater.h +21 -2
  88. package/ios/HotUpdater/Recovery/HotUpdaterRecoveryHooks.mm +66 -0
  89. package/ios/Plugins/HotUpdaterFingerprintPlugin/HotUpdaterFingerprintPlugin.swift +45 -0
  90. package/ios/scripts/generate-fingerprint-hash.zsh +85 -0
  91. package/lib/commonjs/checkForUpdate.js +3 -1
  92. package/lib/commonjs/checkForUpdate.js.map +1 -1
  93. package/lib/commonjs/index.js +9 -12
  94. package/lib/commonjs/index.js.map +1 -1
  95. package/lib/commonjs/native.js +24 -79
  96. package/lib/commonjs/native.js.map +1 -1
  97. package/lib/commonjs/nativeUpdateEvents.js +66 -0
  98. package/lib/commonjs/nativeUpdateEvents.js.map +1 -0
  99. package/lib/commonjs/sdkVersion.js +1 -1
  100. package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
  101. package/lib/module/checkForUpdate.js +3 -1
  102. package/lib/module/checkForUpdate.js.map +1 -1
  103. package/lib/module/index.js +10 -13
  104. package/lib/module/index.js.map +1 -1
  105. package/lib/module/native.js +19 -73
  106. package/lib/module/native.js.map +1 -1
  107. package/lib/module/nativeUpdateEvents.js +61 -0
  108. package/lib/module/nativeUpdateEvents.js.map +1 -0
  109. package/lib/module/sdkVersion.js +1 -1
  110. package/lib/module/specs/NativeHotUpdater.js.map +1 -1
  111. package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
  112. package/lib/typescript/commonjs/index.d.ts +4 -14
  113. package/lib/typescript/commonjs/index.d.ts.map +1 -1
  114. package/lib/typescript/commonjs/native.d.ts +9 -46
  115. package/lib/typescript/commonjs/native.d.ts.map +1 -1
  116. package/lib/typescript/commonjs/nativeUpdateEvents.d.ts +12 -0
  117. package/lib/typescript/commonjs/nativeUpdateEvents.d.ts.map +1 -0
  118. package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
  119. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +19 -3
  120. package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
  121. package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
  122. package/lib/typescript/module/index.d.ts +4 -14
  123. package/lib/typescript/module/index.d.ts.map +1 -1
  124. package/lib/typescript/module/native.d.ts +9 -46
  125. package/lib/typescript/module/native.d.ts.map +1 -1
  126. package/lib/typescript/module/nativeUpdateEvents.d.ts +12 -0
  127. package/lib/typescript/module/nativeUpdateEvents.d.ts.map +1 -0
  128. package/lib/typescript/module/sdkVersion.d.ts +1 -1
  129. package/lib/typescript/module/specs/NativeHotUpdater.d.ts +19 -3
  130. package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
  131. package/package.json +4 -4
  132. package/src/checkForUpdate.ts +7 -1
  133. package/src/index.ts +9 -17
  134. package/src/native.ts +19 -91
  135. package/src/nativeUpdateEvents.ts +77 -0
  136. package/src/sdkVersion.ts +1 -1
  137. package/src/specs/NativeHotUpdater.ts +20 -3
  138. package/android/libs/README.md +0 -50
  139. package/android/libs/hot-updater-brotli-dec-1.2.0.jar +0 -0
  140. package/android/src/bridge/BridgeReactInstance.kt +0 -38
  141. package/android/src/bridgeless/BridgeReactInstance.kt +0 -24
  142. package/android/src/main/java/com/hotupdater/ContentAppearedLog.kt +0 -31
  143. package/android/src/main/java/com/hotupdater/ReactIntegrationManagerBase.kt +0 -34
  144. package/android/src/newarch/ReactIntegrationManager.kt +0 -212
  145. package/android/src/oldarch/ReactIntegrationManager.kt +0 -120
@@ -0,0 +1,482 @@
1
+ import Foundation
2
+
3
+ // A port of @hot-updater/protocol's release selection: cohort rollout, the
4
+ // selection context hash, the desired Release, and transition authorization.
5
+ // The shared conformance fixture in @hot-updater/test-utils keeps it in step
6
+ // with the protocol.
7
+
8
+ // MARK: - Cohorts
9
+
10
+ private func parseNumericCohortValue(_ cohort: String) -> Int? {
11
+ let units = cohort.utf16
12
+ guard !units.isEmpty, units.allSatisfy({ $0 >= 0x30 && $0 <= 0x39 }) else {
13
+ return nil
14
+ }
15
+ // Leading zeros are dropped first, so a long run of them stays in range.
16
+ let significant = units.drop(while: { $0 == 0x30 })
17
+ guard !significant.isEmpty, significant.count <= 4,
18
+ let parsed = Int(String(decoding: Array(significant), as: UTF16.self)),
19
+ parsed <= ReleaseCatalogLimits.numericCohortSize else {
20
+ return nil
21
+ }
22
+ return parsed
23
+ }
24
+
25
+ /// A cohort as the server stores it: trimmed and lowercased, with numeric
26
+ /// cohorts in canonical form, such as `1` for `001`.
27
+ func normalizeCohortValue(_ cohort: String) -> String {
28
+ let normalized = cohort.jsTrimmed.jsLowercased
29
+ if let numericCohort = parseNumericCohortValue(normalized) {
30
+ return String(numericCohort)
31
+ }
32
+ return normalized
33
+ }
34
+
35
+ private func numericCohortValue(_ cohort: String) -> Int? {
36
+ parseNumericCohortValue(normalizeCohortValue(cohort))
37
+ }
38
+
39
+ func isCustomCohort(_ cohort: String) -> Bool {
40
+ let normalized = normalizeCohortValue(cohort).utf16
41
+ let isSlug = normalized.allSatisfy { unit in
42
+ (unit >= 0x61 && unit <= 0x7A) || (unit >= 0x30 && unit <= 0x39) || unit == 0x2D
43
+ }
44
+ let isDigits = normalized.allSatisfy { $0 >= 0x30 && $0 <= 0x39 }
45
+ return !normalized.isEmpty
46
+ && normalized.count <= ReleaseCatalogLimits.maxCohortLength
47
+ && !isDigits
48
+ && isSlug
49
+ }
50
+
51
+ func normalizeRolloutCohortCount(_ rolloutCohortCount: Double?) -> Int {
52
+ guard let rolloutCohortCount else {
53
+ return ReleaseCatalogLimits.numericCohortSize
54
+ }
55
+ // NaN rolls out to no one, as every comparison with it fails in JavaScript.
56
+ if rolloutCohortCount.isNaN || rolloutCohortCount <= 0 {
57
+ return 0
58
+ }
59
+ if rolloutCohortCount >= Double(ReleaseCatalogLimits.numericCohortSize) {
60
+ return ReleaseCatalogLimits.numericCohortSize
61
+ }
62
+ return Int(rolloutCohortCount.rounded(.down))
63
+ }
64
+
65
+ private func positiveMod(_ value: Int, _ modulus: Int) -> Int {
66
+ ((value % modulus) + modulus) % modulus
67
+ }
68
+
69
+ /// The protocol's 32-bit string hash over UTF-16 code units.
70
+ private func hashString(_ value: String) -> Int {
71
+ var hash: Int32 = 0
72
+ for unit in value.utf16 {
73
+ hash = (hash &<< 5) &- hash &+ Int32(unit)
74
+ }
75
+ return Int(hash)
76
+ }
77
+
78
+ private func gcd(_ a: Int, _ b: Int) -> Int {
79
+ var x = abs(a)
80
+ var y = abs(b)
81
+ while y != 0 {
82
+ (x, y) = (y, x % y)
83
+ }
84
+ return x
85
+ }
86
+
87
+ private func modularInverse(_ value: Int, _ modulus: Int) -> Int {
88
+ var t = 0
89
+ var newT = 1
90
+ var r = modulus
91
+ var newR = positiveMod(value, modulus)
92
+ while newR != 0 {
93
+ let quotient = r / newR
94
+ (t, newT) = (newT, t - quotient * newT)
95
+ (r, newR) = (newR, r - quotient * newR)
96
+ }
97
+ // The multiplier is coprime with the cohort size, so r is 1 here.
98
+ return positiveMod(t, modulus)
99
+ }
100
+
101
+ private func rolloutShuffleParameters(_ bundleId: String) -> (offset: Int, inverseMultiplier: Int) {
102
+ let size = ReleaseCatalogLimits.numericCohortSize
103
+ var multiplier = positiveMod(hashString("\(bundleId):multiplier"), 997)
104
+ if multiplier == 0 {
105
+ multiplier = 1
106
+ }
107
+ while gcd(multiplier, size) != 1 {
108
+ multiplier = positiveMod(multiplier + 1, size)
109
+ if multiplier == 0 {
110
+ multiplier = 1
111
+ }
112
+ }
113
+ let offset = positiveMod(hashString("\(bundleId):offset"), size)
114
+ return (offset, modularInverse(multiplier, size))
115
+ }
116
+
117
+ private func numericCohortRolloutPosition(_ bundleId: String, _ cohortValue: Int) -> Int {
118
+ let (offset, inverseMultiplier) = rolloutShuffleParameters(bundleId)
119
+ return positiveMod(
120
+ inverseMultiplier * (cohortValue - 1 - offset),
121
+ ReleaseCatalogLimits.numericCohortSize
122
+ )
123
+ }
124
+
125
+ func isCohortEligibleForUpdate(
126
+ bundleId: String,
127
+ cohort: String?,
128
+ rolloutCohortCount: Double?,
129
+ targetCohorts: [String]?
130
+ ) -> Bool {
131
+ let normalizedCohort = cohort.map(normalizeCohortValue)
132
+ let normalizedTargetCohorts = (targetCohorts ?? []).map(normalizeCohortValue)
133
+
134
+ if let normalizedCohort,
135
+ normalizedTargetCohorts.contains(where: { $0.jsEquals(normalizedCohort) }) {
136
+ return true
137
+ }
138
+
139
+ let normalizedRolloutCount = normalizeRolloutCohortCount(rolloutCohortCount)
140
+ if normalizedRolloutCount <= 0 {
141
+ return false
142
+ }
143
+ guard let normalizedCohort else {
144
+ return normalizedRolloutCount >= ReleaseCatalogLimits.numericCohortSize
145
+ }
146
+ guard let numericCohort = numericCohortValue(normalizedCohort) else {
147
+ return false
148
+ }
149
+ if normalizedRolloutCount >= ReleaseCatalogLimits.numericCohortSize {
150
+ return true
151
+ }
152
+ return numericCohortRolloutPosition(bundleId, numericCohort) < normalizedRolloutCount
153
+ }
154
+
155
+ /// Whether a device in `cohort` may take the Release: it is targeted, rolled
156
+ /// out to the cohort, or rolled out fully to a custom cohort.
157
+ func isReleaseEligibleForCohort(
158
+ releaseId: String,
159
+ rolloutCohortCount: Double,
160
+ targetCohorts: [String],
161
+ cohort: String?
162
+ ) -> Bool {
163
+ if isCohortEligibleForUpdate(
164
+ bundleId: releaseId,
165
+ cohort: cohort,
166
+ rolloutCohortCount: rolloutCohortCount,
167
+ targetCohorts: targetCohorts
168
+ ) {
169
+ return true
170
+ }
171
+ guard let cohort else {
172
+ return false
173
+ }
174
+ return isCustomCohort(normalizeCohortValue(cohort))
175
+ && normalizeRolloutCohortCount(rolloutCohortCount) == ReleaseCatalogLimits.numericCohortSize
176
+ }
177
+
178
+ func isReleaseEligibleForCohort(_ release: ReleaseCatalogDescriptor, cohort: String?) -> Bool {
179
+ isReleaseEligibleForCohort(
180
+ releaseId: release.releaseId,
181
+ rolloutCohortCount: Double(release.rolloutCohortCount),
182
+ targetCohorts: release.matchableTargetCohorts ?? release.targetCohorts,
183
+ cohort: cohort
184
+ )
185
+ }
186
+
187
+ // MARK: - Selection context hash
188
+
189
+ struct ReleaseSelectionContext {
190
+ let activeBundleId: String?
191
+ let activeReleaseId: String?
192
+ let cohort: String?
193
+ let minimumReleaseId: String
194
+ let strategy: ReleaseStrategy
195
+ let strategyValue: String
196
+ let crashedBundleIds: [String]
197
+ }
198
+
199
+ /// Hashes what selection read, so a device reselects when its context changes
200
+ /// under the same catalog generation.
201
+ func createReleaseSelectionContextHash(_ context: ReleaseSelectionContext) -> String {
202
+ let sorted = context.crashedBundleIds.sorted { $0.jsLess($1) }
203
+ var crashedBundleIds = [String]()
204
+ for bundleId in sorted where !(crashedBundleIds.last?.jsEquals(bundleId) ?? false) {
205
+ crashedBundleIds.append(bundleId)
206
+ }
207
+
208
+ // JSON.stringify of the context, with the keys in this order.
209
+ var json = CanonicalJSON()
210
+ json.raw("{\"activeBundleId\":")
211
+ json.nullableString(context.activeBundleId)
212
+ json.raw(",\"activeReleaseId\":")
213
+ json.nullableString(context.activeReleaseId)
214
+ json.raw(",\"cohort\":")
215
+ json.string(normalizeCohortValue(context.cohort ?? ""))
216
+ json.raw(",\"crashedBundleIds\":[")
217
+ for (index, bundleId) in crashedBundleIds.prefix(ReleaseCatalogLimits.maxCrashedBundles).enumerated() {
218
+ if index > 0 {
219
+ json.raw(",")
220
+ }
221
+ json.string(bundleId)
222
+ }
223
+ json.raw("],\"minimumReleaseId\":")
224
+ json.string(context.minimumReleaseId)
225
+ json.raw(",\"selectorSchemaVersion\":\(ReleaseCatalogLimits.schemaVersion),\"strategy\":")
226
+ json.string(context.strategy.rawValue)
227
+ json.raw(",\"strategyValue\":")
228
+ json.string(context.strategyValue)
229
+ json.raw("}")
230
+
231
+ var first: UInt32 = 0x811C9DC5
232
+ var second: UInt32 = 0x9E3779B9
233
+ for code in json.units {
234
+ first = (first ^ UInt32(code)) &* 0x01000193
235
+ second = (second ^ UInt32(code)) &* 0x85EBCA6B
236
+ }
237
+ return "v1:" + hex8(first) + hex8(second)
238
+ }
239
+
240
+ private func hex8(_ value: UInt32) -> String {
241
+ let digits = String(value, radix: 16)
242
+ return String(repeating: "0", count: 8 - digits.count) + digits
243
+ }
244
+
245
+ /// UTF-16 code units of JSON written as JavaScript's JSON.stringify writes it.
246
+ private struct CanonicalJSON {
247
+ private(set) var units = [UInt16]()
248
+
249
+ mutating func raw(_ text: String) {
250
+ units.append(contentsOf: text.utf16)
251
+ }
252
+
253
+ mutating func nullableString(_ value: String?) {
254
+ if let value {
255
+ string(value)
256
+ } else {
257
+ raw("null")
258
+ }
259
+ }
260
+
261
+ mutating func string(_ value: String) {
262
+ units.append(0x22)
263
+ for unit in value.utf16 {
264
+ switch unit {
265
+ case 0x22: raw("\\\"")
266
+ case 0x5C: raw("\\\\")
267
+ case 0x08: raw("\\b")
268
+ case 0x0C: raw("\\f")
269
+ case 0x0A: raw("\\n")
270
+ case 0x0D: raw("\\r")
271
+ case 0x09: raw("\\t")
272
+ case 0x00..<0x20:
273
+ let digits = String(unit, radix: 16)
274
+ raw("\\u" + String(repeating: "0", count: 4 - digits.count) + digits)
275
+ default:
276
+ // A Swift string holds no lone surrogates, which JSON.stringify
277
+ // would escape, so every other code unit is written as is.
278
+ units.append(unit)
279
+ }
280
+ }
281
+ units.append(0x22)
282
+ }
283
+ }
284
+
285
+ // MARK: - Desired Release
286
+
287
+ struct ReleaseSelectionInput {
288
+ let builtInBundleId: String
289
+ let currentBundleId: String
290
+ let activeReleaseId: String?
291
+ /// The Release of the bundle the app is running, which differs from
292
+ /// `activeReleaseId` while a newer bundle is staged for the next launch.
293
+ /// `nil` defaults to `activeReleaseId`; `.some(nil)` compares by
294
+ /// `currentBundleId`.
295
+ let runningReleaseId: String??
296
+ let minimumReleaseId: String
297
+ let cohort: String?
298
+ let crashedBundleIds: [String]
299
+ }
300
+
301
+ enum DesiredReleaseStatus: String {
302
+ case update = "UPDATE"
303
+ case rollback = "ROLLBACK"
304
+ }
305
+
306
+ struct DesiredRelease {
307
+ let kind: SelectionKind
308
+ let releaseId: String?
309
+ let bundleId: String
310
+ let release: ReleaseCatalogDescriptor?
311
+ let status: DesiredReleaseStatus
312
+ /// Whether the device must apply this transition immediately: every
313
+ /// rollback, and an update when a forced Release the device's cohort was
314
+ /// eligible for lies after the running bundle's Release, up to and
315
+ /// including the selected Release.
316
+ let shouldForceUpdate: Bool
317
+ }
318
+
319
+ /// The Release the device should run under `catalog`, the built-in bundle when
320
+ /// the catalog leaves it none, or nil when it should keep what it runs.
321
+ func selectDesiredRelease(_ catalog: ReleaseCatalog, input: ReleaseSelectionInput) -> DesiredRelease? {
322
+ let crashedBundleIds = Set(input.crashedBundleIds.map { Array($0.utf16) })
323
+ let activeReleaseId = input.activeReleaseId
324
+ let hasActiveBundle = !input.currentBundleId.jsEquals(input.builtInBundleId)
325
+ let runningReleaseId: String? = input.runningReleaseId ?? activeReleaseId
326
+
327
+ func safeBundleId(_ release: ReleaseCatalogDescriptor) -> String? {
328
+ guard let bundleId = release.bundleId,
329
+ !crashedBundleIds.contains(Array(bundleId.utf16)),
330
+ !bundleId.jsLess(input.minimumReleaseId) else {
331
+ return nil
332
+ }
333
+ return bundleId
334
+ }
335
+
336
+ func isNewerThanRunning(_ release: ReleaseCatalogDescriptor) -> Bool {
337
+ if let runningReleaseId {
338
+ return runningReleaseId.jsLess(release.releaseId)
339
+ }
340
+ guard let bundleId = release.bundleId else {
341
+ return false
342
+ }
343
+ return input.currentBundleId.jsLess(bundleId)
344
+ }
345
+
346
+ // `releases` keeps only the newest few Bundles, so the forced Releases an
347
+ // update carries the device past are read from the complete spine. A
348
+ // forced Release of the running bundle is already satisfied, and one below
349
+ // the native floor is older than what the binary ships.
350
+ func passesForcedRelease(_ selected: ReleaseCatalogDescriptor) -> Bool {
351
+ ([selected] + catalog.rollbackReleases).contains { passed in
352
+ guard passed.shouldForceUpdate, let bundleId = passed.bundleId else {
353
+ return false
354
+ }
355
+ return !bundleId.jsLess(input.minimumReleaseId)
356
+ && !bundleId.jsEquals(input.currentBundleId)
357
+ && !selected.releaseId.jsLess(passed.releaseId)
358
+ && isNewerThanRunning(passed)
359
+ && isReleaseEligibleForCohort(passed, cohort: input.cohort)
360
+ }
361
+ }
362
+
363
+ func desiredBundle(_ release: ReleaseCatalogDescriptor, bundleId: String, status: DesiredReleaseStatus) -> DesiredRelease {
364
+ DesiredRelease(
365
+ kind: .bundle,
366
+ releaseId: release.releaseId,
367
+ bundleId: bundleId,
368
+ release: release,
369
+ status: status,
370
+ shouldForceUpdate: status == .rollback || passesForcedRelease(release)
371
+ )
372
+ }
373
+
374
+ for release in catalog.releases {
375
+ guard isReleaseEligibleForCohort(release, cohort: input.cohort) else {
376
+ continue
377
+ }
378
+ if let activeReleaseId {
379
+ if !activeReleaseId.jsLess(release.releaseId) {
380
+ continue
381
+ }
382
+ } else if hasActiveBundle {
383
+ guard let bundleId = release.bundleId, input.currentBundleId.jsLess(bundleId) else {
384
+ continue
385
+ }
386
+ }
387
+ if let bundleId = safeBundleId(release) {
388
+ return desiredBundle(release, bundleId: bundleId, status: .update)
389
+ }
390
+ }
391
+
392
+ if activeReleaseId != nil || hasActiveBundle {
393
+ let rollbackReleases = catalog.rollbackReleases
394
+ let current = rollbackReleases.first { release in
395
+ if let activeReleaseId {
396
+ return release.releaseId.jsEquals(activeReleaseId)
397
+ }
398
+ return release.bundleId.jsEquals(input.currentBundleId)
399
+ }
400
+ if let current, let bundleId = safeBundleId(current),
401
+ isReleaseEligibleForCohort(current, cohort: input.cohort) {
402
+ return desiredBundle(current, bundleId: bundleId, status: .update)
403
+ }
404
+
405
+ for release in rollbackReleases {
406
+ guard let bundleId = safeBundleId(release) else {
407
+ continue
408
+ }
409
+ let isOlder = activeReleaseId.map { release.releaseId.jsLess($0) }
410
+ ?? bundleId.jsLess(input.currentBundleId)
411
+ if isOlder {
412
+ return desiredBundle(release, bundleId: bundleId, status: .rollback)
413
+ }
414
+ }
415
+
416
+ if !input.minimumReleaseId.jsLess(input.currentBundleId) {
417
+ return nil
418
+ }
419
+ }
420
+
421
+ guard catalog.fallbackPolicy.jsEquals(ReleaseCatalogLimits.fallbackPolicy) else {
422
+ return nil
423
+ }
424
+ return DesiredRelease(
425
+ kind: .builtin,
426
+ releaseId: nil,
427
+ bundleId: input.builtInBundleId,
428
+ release: nil,
429
+ status: .rollback,
430
+ shouldForceUpdate: true
431
+ )
432
+ }
433
+
434
+ // MARK: - Transition authorization
435
+
436
+ enum ReleaseTransitionReason: String {
437
+ case firstAuthenticatedSelection = "FIRST_AUTHENTICATED_SELECTION"
438
+ case newerPolicy = "NEWER_POLICY"
439
+ case contextReselection = "CONTEXT_RESELECTION"
440
+ case explicitScopeSwitch = "EXPLICIT_SCOPE_SWITCH"
441
+ case backwardNotAuthorized = "BACKWARD_NOT_AUTHORIZED"
442
+ case emptyTargetScope = "EMPTY_TARGET_SCOPE"
443
+ case staleGeneration = "STALE_GENERATION"
444
+ case unsolicitedScope = "UNSOLICITED_SCOPE"
445
+ }
446
+
447
+ struct ReleaseTransitionAuthorization: Equatable {
448
+ let authorized: Bool
449
+ let reason: ReleaseTransitionReason
450
+ }
451
+
452
+ /// Whether the device may move from its active selection to `desired`.
453
+ func authorizeReleaseTransition(
454
+ active: PersistedSelection?,
455
+ desired: PersistedSelection,
456
+ explicitScopeSwitch: Bool
457
+ ) -> ReleaseTransitionAuthorization {
458
+ guard let active, active.scopeKey != nil, let activeGeneration = active.generation else {
459
+ return ReleaseTransitionAuthorization(authorized: true, reason: .firstAuthenticatedSelection)
460
+ }
461
+
462
+ if !desired.catalogId.jsEquals(active.catalogId) || !desired.scopeKey.jsEquals(active.scopeKey) {
463
+ if !explicitScopeSwitch {
464
+ return ReleaseTransitionAuthorization(authorized: false, reason: .unsolicitedScope)
465
+ }
466
+ if desired.releaseId == nil || desired.kind.jsEquals(SelectionKind.builtin.rawValue) {
467
+ return ReleaseTransitionAuthorization(authorized: false, reason: .emptyTargetScope)
468
+ }
469
+ return ReleaseTransitionAuthorization(authorized: true, reason: .explicitScopeSwitch)
470
+ }
471
+
472
+ guard let desiredGeneration = desired.generation, desiredGeneration >= activeGeneration else {
473
+ return ReleaseTransitionAuthorization(authorized: false, reason: .staleGeneration)
474
+ }
475
+ if desiredGeneration > activeGeneration {
476
+ return ReleaseTransitionAuthorization(authorized: true, reason: .newerPolicy)
477
+ }
478
+ if !desired.selectionContextHash.jsEquals(active.selectionContextHash) {
479
+ return ReleaseTransitionAuthorization(authorized: true, reason: .contextReselection)
480
+ }
481
+ return ReleaseTransitionAuthorization(authorized: false, reason: .backwardNotAuthorized)
482
+ }
@@ -102,14 +102,15 @@ public enum SignatureVerificationError: Error, CustomNSError {
102
102
  public class SignatureVerifier {
103
103
 
104
104
  /**
105
- * Reads public key from Info.plist configuration.
105
+ * Reads main's public key: `HotUpdaterConfig.shared`, then Info.plist.
106
+ * A named instance passes its own key to the `publicKey:` variants.
106
107
  * @return Public key PEM string or nil if not configured
107
108
  */
108
- private static func getPublicKeyFromConfig() -> String? {
109
- if let override = HotUpdaterConfig.shared.publicKey, !override.isEmpty {
109
+ static func getPublicKeyFromConfig(_ config: HotUpdaterConfig = .shared) -> String? {
110
+ if let override = config.overrides.publicKey, !override.isEmpty {
110
111
  return override
111
112
  }
112
- guard let publicKeyPEM = Bundle.main.object(forInfoDictionaryKey: "HOT_UPDATER_PUBLIC_KEY") as? String else {
113
+ guard let publicKeyPEM = config.infoValue(forKey: "HOT_UPDATER_PUBLIC_KEY") else {
113
114
  NSLog("[SignatureVerifier] HOT_UPDATER_PUBLIC_KEY not found in Info.plist")
114
115
  return nil
115
116
  }
@@ -121,7 +122,11 @@ public class SignatureVerifier {
121
122
  * @return true if public key is configured
122
123
  */
123
124
  public static func isSigningEnabled() -> Bool {
124
- return getPublicKeyFromConfig() != nil
125
+ return isSigningEnabled(publicKey: getPublicKeyFromConfig())
126
+ }
127
+
128
+ static func isSigningEnabled(publicKey: String?) -> Bool {
129
+ return publicKey != nil
125
130
  }
126
131
 
127
132
  /**
@@ -153,7 +158,12 @@ public class SignatureVerifier {
153
158
  * @return Result indicating verification success or failure with error
154
159
  */
155
160
  public static func verifyBundle(fileURL: URL, fileHash: String?) -> Result<Void, SignatureVerificationError> {
156
- let signingEnabled = isSigningEnabled()
161
+ return verifyBundle(fileURL: fileURL, fileHash: fileHash, publicKey: getPublicKeyFromConfig())
162
+ }
163
+
164
+ /// `verifyBundle` with an instance's public key, or nil when it has none.
165
+ static func verifyBundle(fileURL: URL, fileHash: String?, publicKey: String?) -> Result<Void, SignatureVerificationError> {
166
+ let signingEnabled = isSigningEnabled(publicKey: publicKey)
157
167
 
158
168
  // Rule: null/empty fileHash → REJECT
159
169
  guard let hash = fileHash, !hash.isEmpty else {
@@ -175,7 +185,7 @@ public class SignatureVerifier {
175
185
  }
176
186
 
177
187
  // Rule: sig:... + public key configured → verify signature
178
- return verifySignature(fileURL: fileURL, signatureBase64: signature)
188
+ return verifySignature(fileURL: fileURL, signatureBase64: signature, publicKey: publicKey)
179
189
  } else {
180
190
  // Unsigned format: <hex_hash>
181
191
 
@@ -217,10 +227,13 @@ public class SignatureVerifier {
217
227
  * @return Result indicating verification success or failure with error
218
228
  */
219
229
  public static func verifySignature(fileURL: URL, signatureBase64: String) -> Result<Void, SignatureVerificationError> {
230
+ return verifySignature(fileURL: fileURL, signatureBase64: signatureBase64, publicKey: getPublicKeyFromConfig())
231
+ }
232
+
233
+ static func verifySignature(fileURL: URL, signatureBase64: String, publicKey: String?) -> Result<Void, SignatureVerificationError> {
220
234
  NSLog("[SignatureVerifier] Verifying signature for file: \(fileURL.lastPathComponent)")
221
235
 
222
- // Get public key from config
223
- guard let publicKeyPEM = getPublicKeyFromConfig() else {
236
+ guard let publicKeyPEM = publicKey else {
224
237
  NSLog("[SignatureVerifier] Cannot verify signature: public key not configured in Info.plist")
225
238
  return .failure(.publicKeyNotConfigured)
226
239
  }
@@ -249,7 +262,11 @@ public class SignatureVerifier {
249
262
  * Verifies an RSA-SHA256 signature for an explicit SHA256 file hash.
250
263
  */
251
264
  public static func verifyHashSignature(fileHash: String, signatureBase64: String) -> Result<Void, SignatureVerificationError> {
252
- guard let publicKeyPEM = getPublicKeyFromConfig() else {
265
+ return verifyHashSignature(fileHash: fileHash, signatureBase64: signatureBase64, publicKey: getPublicKeyFromConfig())
266
+ }
267
+
268
+ static func verifyHashSignature(fileHash: String, signatureBase64: String, publicKey: String?) -> Result<Void, SignatureVerificationError> {
269
+ guard let publicKeyPEM = publicKey else {
253
270
  NSLog("[SignatureVerifier] Cannot verify signature: public key not configured in Info.plist")
254
271
  return .failure(.publicKeyNotConfigured)
255
272
  }