@hot-updater/react-native 1.0.8 → 1.1.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/HotUpdater.podspec +14 -2
- package/Package.swift +24 -3
- package/android/build.gradle +2 -6
- package/android/hot-updater-fingerprint.gradle +135 -0
- package/android/src/main/cpp/HotUpdaterRecovery.cpp +291 -21
- package/android/src/main/java/com/hotupdater/ActivityStartGate.kt +176 -15
- package/android/src/main/java/com/hotupdater/ArtifactFileResolver.kt +20 -8
- package/android/src/main/java/com/hotupdater/BundleFileStorageService.kt +145 -31
- package/android/src/main/java/com/hotupdater/CohortService.kt +10 -2
- package/android/src/main/java/com/hotupdater/FileManagerService.kt +3 -1
- package/android/src/main/java/com/hotupdater/HotUpdaterConfig.kt +249 -21
- package/android/src/main/java/com/hotupdater/HotUpdaterException.kt +8 -4
- package/android/src/main/java/com/hotupdater/HotUpdaterHeldContent.kt +92 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterImpl.kt +319 -113
- package/android/src/main/java/com/hotupdater/HotUpdaterInstance.kt +87 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterInstanceEnvironment.kt +323 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterMainThread.kt +46 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterProcess.kt +30 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryAttribution.kt +493 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryManager.kt +1541 -159
- package/android/src/main/java/com/hotupdater/HotUpdaterRecoverySlots.kt +137 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterRecoveryState.kt +4 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterRuntimeInstance.kt +53 -0
- package/android/src/main/java/com/hotupdater/HotUpdaterUpdateCheck.kt +149 -0
- package/android/src/main/java/com/hotupdater/InstallIdentityService.kt +12 -2
- package/android/src/main/java/com/hotupdater/NativeConfigUtils.kt +5 -6
- package/android/src/main/java/com/hotupdater/NativeUpdateCheck.kt +172 -0
- package/android/src/main/java/com/hotupdater/NativeUpdateChecker.kt +1007 -0
- package/android/src/main/java/com/hotupdater/NativeUpdateEventQueue.kt +111 -0
- package/android/src/main/java/com/hotupdater/OkHttpUpdateCheckTransport.kt +88 -0
- package/android/src/main/java/com/hotupdater/ReleaseCatalog.kt +491 -0
- package/android/src/main/java/com/hotupdater/ReleaseCatalogClient.kt +405 -0
- package/android/src/main/java/com/hotupdater/ReleaseSelection.kt +474 -0
- package/android/src/main/java/com/hotupdater/SignatureVerifier.kt +32 -10
- package/android/src/main/java/com/hotupdater/StrictJson.kt +392 -0
- package/android/src/main/java/com/hotupdater/VersionedPreferencesService.kt +24 -9
- package/android/src/main/java/com/hotupdater/WeakIdentitySet.kt +76 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BitReader.java +314 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliError.java +47 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliInputStream.java +174 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/BrotliRuntimeException.java +21 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Context.java +58 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Decode.java +1574 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Decoder.java +72 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Dictionary.java +103 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/DictionaryData.java +82 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Huffman.java +143 -0
- package/{LICENSE → android/src/main/java/com/hotupdater/vendor/brotli/dec/LICENSE} +6 -20
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/README.md +39 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/State.java +100 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Transform.java +240 -0
- package/android/src/main/java/com/hotupdater/vendor/brotli/dec/Utils.java +163 -0
- package/android/src/main/resources/META-INF/LICENSE-brotli +19 -0
- package/android/src/newarch/HotUpdater.kt +53 -15
- package/android/src/newarch/HotUpdaterModule.kt +42 -17
- package/android/src/newarch/JavaScriptExceptionHooks.kt +45 -8
- package/android/src/oldarch/HotUpdater.kt +51 -12
- package/android/src/oldarch/HotUpdaterModule.kt +43 -17
- package/android/src/oldarch/HotUpdaterSpec.kt +2 -0
- package/android/src/oldarch/JavaScriptExceptionHooks.kt +25 -16
- package/ios/HotUpdater/Interface/HotUpdaterInterface.h +13 -0
- package/ios/HotUpdater/Internal/ArtifactFileResolver.swift +38 -7
- package/ios/HotUpdater/Internal/BuiltInAssetResolver.swift +10 -4
- package/ios/HotUpdater/Internal/BundleFileStorageService.swift +186 -48
- package/ios/HotUpdater/Internal/CohortService.swift +8 -0
- package/ios/HotUpdater/Internal/HotUpdater.mm +821 -71
- package/ios/HotUpdater/Internal/HotUpdaterAsync.swift +7 -0
- package/ios/HotUpdater/Internal/HotUpdaterConfig.swift +208 -25
- package/ios/HotUpdater/Internal/HotUpdaterFingerprintHash.m +10 -0
- package/ios/HotUpdater/Internal/HotUpdaterImpl.swift +940 -261
- package/ios/HotUpdater/Internal/HotUpdaterInstanceEnvironment.swift +492 -0
- package/ios/HotUpdater/Internal/HotUpdaterInstanceRouter.swift +83 -0
- package/ios/HotUpdater/Internal/HotUpdaterInterface.swift +28 -1
- package/ios/HotUpdater/Internal/HotUpdaterLaunchRecovery.swift +1553 -0
- package/ios/HotUpdater/Internal/HotUpdaterNativeUpdates.swift +353 -0
- package/ios/HotUpdater/Internal/LaunchPreparationLock.swift +54 -0
- package/ios/HotUpdater/Internal/NativeUpdateCheck.swift +2051 -0
- package/ios/HotUpdater/Internal/ReleaseCatalog.swift +634 -0
- package/ios/HotUpdater/Internal/ReleaseCatalogCacheService.swift +1 -1
- package/ios/HotUpdater/Internal/ReleaseSelection.swift +482 -0
- package/ios/HotUpdater/Internal/SignatureVerifier.swift +27 -10
- package/ios/HotUpdater/Internal/StrictJson.swift +363 -0
- package/ios/HotUpdater/Internal/URLSessionDownloadService.swift +4 -2
- package/ios/HotUpdater/Internal/UpdateFailure.swift +2 -0
- package/ios/HotUpdater/Package.swift +11 -0
- package/ios/HotUpdater/Public/HotUpdater/HotUpdater.h +4 -0
- package/ios/HotUpdater/Public/HotUpdater.h +21 -2
- package/ios/HotUpdater/Recovery/HotUpdaterRecoveryHooks.mm +66 -0
- package/ios/Plugins/HotUpdaterFingerprintPlugin/HotUpdaterFingerprintPlugin.swift +45 -0
- package/ios/scripts/generate-fingerprint-hash.zsh +85 -0
- package/lib/commonjs/checkForUpdate.js +3 -1
- package/lib/commonjs/checkForUpdate.js.map +1 -1
- package/lib/commonjs/index.js +9 -12
- package/lib/commonjs/index.js.map +1 -1
- package/lib/commonjs/native.js +24 -79
- package/lib/commonjs/native.js.map +1 -1
- package/lib/commonjs/nativeUpdateEvents.js +66 -0
- package/lib/commonjs/nativeUpdateEvents.js.map +1 -0
- package/lib/commonjs/sdkVersion.js +1 -1
- package/lib/commonjs/specs/NativeHotUpdater.js.map +1 -1
- package/lib/module/checkForUpdate.js +3 -1
- package/lib/module/checkForUpdate.js.map +1 -1
- package/lib/module/index.js +10 -13
- package/lib/module/index.js.map +1 -1
- package/lib/module/native.js +19 -73
- package/lib/module/native.js.map +1 -1
- package/lib/module/nativeUpdateEvents.js +61 -0
- package/lib/module/nativeUpdateEvents.js.map +1 -0
- package/lib/module/sdkVersion.js +1 -1
- package/lib/module/specs/NativeHotUpdater.js.map +1 -1
- package/lib/typescript/commonjs/checkForUpdate.d.ts.map +1 -1
- package/lib/typescript/commonjs/index.d.ts +4 -14
- package/lib/typescript/commonjs/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/native.d.ts +9 -46
- package/lib/typescript/commonjs/native.d.ts.map +1 -1
- package/lib/typescript/commonjs/nativeUpdateEvents.d.ts +12 -0
- package/lib/typescript/commonjs/nativeUpdateEvents.d.ts.map +1 -0
- package/lib/typescript/commonjs/sdkVersion.d.ts +1 -1
- package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts +19 -3
- package/lib/typescript/commonjs/specs/NativeHotUpdater.d.ts.map +1 -1
- package/lib/typescript/module/checkForUpdate.d.ts.map +1 -1
- package/lib/typescript/module/index.d.ts +4 -14
- package/lib/typescript/module/index.d.ts.map +1 -1
- package/lib/typescript/module/native.d.ts +9 -46
- package/lib/typescript/module/native.d.ts.map +1 -1
- package/lib/typescript/module/nativeUpdateEvents.d.ts +12 -0
- package/lib/typescript/module/nativeUpdateEvents.d.ts.map +1 -0
- package/lib/typescript/module/sdkVersion.d.ts +1 -1
- package/lib/typescript/module/specs/NativeHotUpdater.d.ts +19 -3
- package/lib/typescript/module/specs/NativeHotUpdater.d.ts.map +1 -1
- package/package.json +4 -4
- package/src/checkForUpdate.ts +7 -1
- package/src/index.ts +9 -17
- package/src/native.ts +19 -91
- package/src/nativeUpdateEvents.ts +77 -0
- package/src/sdkVersion.ts +1 -1
- package/src/specs/NativeHotUpdater.ts +20 -3
- package/android/libs/README.md +0 -50
- package/android/libs/hot-updater-brotli-dec-1.2.0.jar +0 -0
- package/android/src/bridge/BridgeReactInstance.kt +0 -38
- package/android/src/bridgeless/BridgeReactInstance.kt +0 -24
- package/android/src/main/java/com/hotupdater/ContentAppearedLog.kt +0 -31
- package/android/src/main/java/com/hotupdater/ReactIntegrationManagerBase.kt +0 -34
- package/android/src/newarch/ReactIntegrationManager.kt +0 -212
- 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
|
|
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
|
-
|
|
109
|
-
if let override =
|
|
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 =
|
|
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()
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|