@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,634 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
// A port of @hot-updater/protocol's release catalog format: the catalog types,
|
|
4
|
+
// the wire parser, scope and channel keys, and app version canonicalization.
|
|
5
|
+
// Release selection reads these in ReleaseSelection.swift, and the shared
|
|
6
|
+
// conformance fixture in @hot-updater/test-utils keeps both in step with the
|
|
7
|
+
// protocol.
|
|
8
|
+
//
|
|
9
|
+
// Strings compare as JavaScript compares them, by UTF-16 code units: Swift's
|
|
10
|
+
// `==` and `<` treat canonically equivalent strings as equal, which the
|
|
11
|
+
// protocol does not.
|
|
12
|
+
|
|
13
|
+
// MARK: - Constants
|
|
14
|
+
|
|
15
|
+
enum ReleaseCatalogLimits {
|
|
16
|
+
static let schemaVersion = 1
|
|
17
|
+
static let fallbackPolicy = "BUILTIN_IF_ACTIVE_INELIGIBLE"
|
|
18
|
+
static let maxCrashedBundles = 10
|
|
19
|
+
static let maxTargetCohortsPerRelease = 100
|
|
20
|
+
static let maxDistinctTargetCohortsPerScope = 512
|
|
21
|
+
static let maxCompiledCatalogBytes = 256 * 1024
|
|
22
|
+
/// The largest release catalog response body a client accepts, in UTF-8 bytes.
|
|
23
|
+
static let maxReleaseCatalogWireBytes = maxCompiledCatalogBytes * 2 + 4 * 1024
|
|
24
|
+
static let numericCohortSize = 1000
|
|
25
|
+
static let maxCohortLength = 64
|
|
26
|
+
static let maxChannelCodePoints = 255
|
|
27
|
+
static let maxCatalogSegmentLength = 255
|
|
28
|
+
static let maxSafeInteger: Int64 = 9_007_199_254_740_991
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// MARK: - Types
|
|
32
|
+
|
|
33
|
+
enum ReleasePlatform: String {
|
|
34
|
+
case ios
|
|
35
|
+
case android
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
enum ReleaseStrategy: String {
|
|
39
|
+
case appVersion = "APP_VERSION"
|
|
40
|
+
case fingerprint = "FINGERPRINT"
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
enum SelectionKind: String {
|
|
44
|
+
case bundle = "BUNDLE"
|
|
45
|
+
case builtin = "BUILTIN"
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
struct ReleaseCatalogDescriptor {
|
|
49
|
+
let releaseId: String
|
|
50
|
+
let kind: SelectionKind
|
|
51
|
+
let bundleId: String?
|
|
52
|
+
let rolloutCohortCount: Int
|
|
53
|
+
/// The target cohorts' text, an unpaired surrogate as U+FFFD.
|
|
54
|
+
let targetCohorts: [String]
|
|
55
|
+
let shouldForceUpdate: Bool
|
|
56
|
+
let message: String?
|
|
57
|
+
/// The target cohorts a device cohort can equal, when not all of
|
|
58
|
+
/// `targetCohorts`: one with an unpaired surrogate equals no Swift
|
|
59
|
+
/// string, as `JSON.parse` keeps it apart from every valid cohort.
|
|
60
|
+
var matchableTargetCohorts: [String]? = nil
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
struct ReleaseCatalog {
|
|
64
|
+
let schemaVersion: Int
|
|
65
|
+
let catalogId: String
|
|
66
|
+
let scopeKey: String
|
|
67
|
+
let generation: Int64
|
|
68
|
+
let catalogHash: String
|
|
69
|
+
let fallbackPolicy: String
|
|
70
|
+
/// Release descriptors ordered newest first.
|
|
71
|
+
let releases: [ReleaseCatalogDescriptor]
|
|
72
|
+
/// Enabled compatible Releases ordered newest first, where a device keeps
|
|
73
|
+
/// its active Release or rolls back to the newest Release before it.
|
|
74
|
+
let rollbackReleases: [ReleaseCatalogDescriptor]
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/// The scope a catalog belongs to, and the scope a client asks for.
|
|
78
|
+
struct ReleaseCatalogScope {
|
|
79
|
+
let platform: ReleasePlatform
|
|
80
|
+
let channelKey: String
|
|
81
|
+
let strategy: ReleaseStrategy
|
|
82
|
+
/// Set only for the fingerprint strategy.
|
|
83
|
+
let fingerprintHash: String?
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
struct ReleaseCatalogError: Error {
|
|
87
|
+
let message: String
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// MARK: - JavaScript string semantics
|
|
91
|
+
|
|
92
|
+
extension String {
|
|
93
|
+
/// Whether both strings hold the same UTF-16 code units, as JavaScript's `===`.
|
|
94
|
+
func jsEquals(_ other: String) -> Bool {
|
|
95
|
+
utf16.elementsEqual(other.utf16)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/// Orders strings by UTF-16 code units, as JavaScript's `<` and `sort()`.
|
|
99
|
+
func jsLess(_ other: String) -> Bool {
|
|
100
|
+
utf16.lexicographicallyPrecedes(other.utf16)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/// JavaScript's `String.prototype.trim()`.
|
|
104
|
+
var jsTrimmed: String {
|
|
105
|
+
let scalars = unicodeScalars
|
|
106
|
+
guard let start = scalars.firstIndex(where: { !$0.isJSWhitespace }) else {
|
|
107
|
+
return ""
|
|
108
|
+
}
|
|
109
|
+
let end = scalars.lastIndex(where: { !$0.isJSWhitespace })!
|
|
110
|
+
return String(String.UnicodeScalarView(scalars[start...end]))
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/// JavaScript's locale-independent `String.prototype.toLowerCase()`,
|
|
114
|
+
/// including the final sigma rule.
|
|
115
|
+
var jsLowercased: String {
|
|
116
|
+
let scalars = Array(unicodeScalars)
|
|
117
|
+
var result = String.UnicodeScalarView()
|
|
118
|
+
for (index, scalar) in scalars.enumerated() {
|
|
119
|
+
if scalar.value == 0x03A3 {
|
|
120
|
+
result.append(isFinalSigma(scalars, at: index) ? "\u{03C2}" : "\u{03C3}")
|
|
121
|
+
} else {
|
|
122
|
+
result.append(contentsOf: scalar.properties.lowercaseMapping.unicodeScalars)
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return String(result)
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
extension Optional where Wrapped == String {
|
|
130
|
+
func jsEquals(_ other: String?) -> Bool {
|
|
131
|
+
switch (self, other) {
|
|
132
|
+
case (nil, nil): return true
|
|
133
|
+
case let (left?, right?): return left.jsEquals(right)
|
|
134
|
+
default: return false
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private extension Unicode.Scalar {
|
|
140
|
+
var isJSWhitespace: Bool {
|
|
141
|
+
switch value {
|
|
142
|
+
case 0x09...0x0D, 0x20, 0xA0, 0x1680, 0x2000...0x200A,
|
|
143
|
+
0x2028, 0x2029, 0x202F, 0x205F, 0x3000, 0xFEFF:
|
|
144
|
+
return true
|
|
145
|
+
default:
|
|
146
|
+
return false
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/// Unicode's Final_Sigma condition: a cased letter comes before the sigma and
|
|
152
|
+
/// none comes after it, skipping case-ignorable characters on both sides.
|
|
153
|
+
private func isFinalSigma(_ scalars: [Unicode.Scalar], at index: Int) -> Bool {
|
|
154
|
+
var before = index - 1
|
|
155
|
+
while before >= 0, scalars[before].properties.isCaseIgnorable {
|
|
156
|
+
before -= 1
|
|
157
|
+
}
|
|
158
|
+
guard before >= 0, scalars[before].properties.isCased else {
|
|
159
|
+
return false
|
|
160
|
+
}
|
|
161
|
+
var after = index + 1
|
|
162
|
+
while after < scalars.count, scalars[after].properties.isCaseIgnorable {
|
|
163
|
+
after += 1
|
|
164
|
+
}
|
|
165
|
+
return after >= scalars.count || !scalars[after].properties.isCased
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
private func isASCIIDigit(_ unit: UInt16) -> Bool {
|
|
169
|
+
unit >= 0x30 && unit <= 0x39
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private func isLowercaseHex(_ unit: UInt16) -> Bool {
|
|
173
|
+
isASCIIDigit(unit) || (unit >= 0x61 && unit <= 0x66)
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// MARK: - App version
|
|
177
|
+
|
|
178
|
+
/// The form of an app version that the device and the server compare:
|
|
179
|
+
/// `major.minor.patch` of the first version in `appVersion`, such as `1.4.0`
|
|
180
|
+
/// for `v1.4`, or nil when it holds none. Prerelease and build parts are
|
|
181
|
+
/// dropped.
|
|
182
|
+
func canonicalizeAppVersion(_ appVersion: String) -> String? {
|
|
183
|
+
let units = Array(appVersion.utf16)
|
|
184
|
+
|
|
185
|
+
// The digit run starting at `start`, when it is 1-16 ASCII digits long.
|
|
186
|
+
func digitRun(at start: Int) -> String? {
|
|
187
|
+
var end = start
|
|
188
|
+
while end < units.count, isASCIIDigit(units[end]) {
|
|
189
|
+
end += 1
|
|
190
|
+
}
|
|
191
|
+
let length = end - start
|
|
192
|
+
guard length >= 1, length <= 16 else {
|
|
193
|
+
return nil
|
|
194
|
+
}
|
|
195
|
+
return String(decoding: units[start..<end], as: UTF16.self)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
var index = 0
|
|
199
|
+
var major: String?
|
|
200
|
+
while index < units.count {
|
|
201
|
+
guard isASCIIDigit(units[index]) else {
|
|
202
|
+
index += 1
|
|
203
|
+
continue
|
|
204
|
+
}
|
|
205
|
+
var end = index
|
|
206
|
+
while end < units.count, isASCIIDigit(units[end]) {
|
|
207
|
+
end += 1
|
|
208
|
+
}
|
|
209
|
+
if let run = digitRun(at: index) {
|
|
210
|
+
major = run
|
|
211
|
+
index = end
|
|
212
|
+
break
|
|
213
|
+
}
|
|
214
|
+
index = end
|
|
215
|
+
}
|
|
216
|
+
guard let major else {
|
|
217
|
+
return nil
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
var parts = [major]
|
|
221
|
+
for _ in 0..<2 {
|
|
222
|
+
guard index < units.count, units[index] == 0x2E,
|
|
223
|
+
let run = digitRun(at: index + 1) else {
|
|
224
|
+
break
|
|
225
|
+
}
|
|
226
|
+
parts.append(run)
|
|
227
|
+
index += 1 + run.utf16.count
|
|
228
|
+
}
|
|
229
|
+
while parts.count < 3 {
|
|
230
|
+
parts.append("0")
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
for part in parts {
|
|
234
|
+
if part.utf16.count > 1, part.utf16.first == 0x30 {
|
|
235
|
+
return nil
|
|
236
|
+
}
|
|
237
|
+
guard let value = UInt64(part), value <= UInt64(ReleaseCatalogLimits.maxSafeInteger) else {
|
|
238
|
+
return nil
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
return parts.joined(separator: ".")
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// MARK: - Channel keys
|
|
245
|
+
|
|
246
|
+
/// The channel name a channel key encodes: trimmed and NFC-normalized.
|
|
247
|
+
func normalizeChannelName(_ channel: String) -> String {
|
|
248
|
+
channel.jsTrimmed.precomposedStringWithCanonicalMapping
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
func assertCanonicalChannelName(_ channel: String) throws {
|
|
252
|
+
let normalized = normalizeChannelName(channel)
|
|
253
|
+
if normalized.utf16.isEmpty || !normalized.jsEquals(channel) {
|
|
254
|
+
throw ReleaseCatalogError(message: "Channel name must be non-empty, trimmed, and NFC-normalized")
|
|
255
|
+
}
|
|
256
|
+
if normalized.unicodeScalars.count > ReleaseCatalogLimits.maxChannelCodePoints {
|
|
257
|
+
throw ReleaseCatalogError(message: "Channel name must not exceed 255 characters")
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/// The unpadded base64url form of the channel's UTF-8 bytes.
|
|
262
|
+
func encodeChannelKey(_ channel: String) throws -> String {
|
|
263
|
+
try assertCanonicalChannelName(channel)
|
|
264
|
+
return Data(channel.utf8).base64EncodedString()
|
|
265
|
+
.replacingOccurrences(of: "+", with: "-")
|
|
266
|
+
.replacingOccurrences(of: "/", with: "_")
|
|
267
|
+
.replacingOccurrences(of: "=", with: "")
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
func decodeChannelKey(_ channelKey: String) throws -> String {
|
|
271
|
+
let invalid = ReleaseCatalogError(message: "Invalid channel key")
|
|
272
|
+
let alphabet = Array("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_".utf16)
|
|
273
|
+
var lookup = [UInt16: Int]()
|
|
274
|
+
for (index, unit) in alphabet.enumerated() {
|
|
275
|
+
lookup[unit] = index
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
let units = Array(channelKey.utf16)
|
|
279
|
+
guard units.count % 4 != 1 else {
|
|
280
|
+
throw invalid
|
|
281
|
+
}
|
|
282
|
+
var bytes = [UInt8]()
|
|
283
|
+
var index = 0
|
|
284
|
+
while index < units.count {
|
|
285
|
+
let chunk = units[index..<min(index + 4, units.count)]
|
|
286
|
+
var bits = 0
|
|
287
|
+
for (offset, unit) in chunk.enumerated() {
|
|
288
|
+
guard let sextet = lookup[unit] else {
|
|
289
|
+
throw invalid
|
|
290
|
+
}
|
|
291
|
+
bits |= sextet << (18 - offset * 6)
|
|
292
|
+
}
|
|
293
|
+
bytes.append(UInt8((bits >> 16) & 0xFF))
|
|
294
|
+
if chunk.count >= 3 { bytes.append(UInt8((bits >> 8) & 0xFF)) }
|
|
295
|
+
if chunk.count == 4 { bytes.append(UInt8(bits & 0xFF)) }
|
|
296
|
+
index += 4
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
let channel = try decodeStrictUTF8(bytes)
|
|
300
|
+
try assertCanonicalChannelName(channel)
|
|
301
|
+
guard try encodeChannelKey(channel).jsEquals(channelKey) else {
|
|
302
|
+
throw ReleaseCatalogError(message: "Channel key is not canonically encoded")
|
|
303
|
+
}
|
|
304
|
+
return channel
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
private func decodeStrictUTF8(_ bytes: [UInt8]) throws -> String {
|
|
308
|
+
let invalid = ReleaseCatalogError(message: "Invalid UTF-8 channel key")
|
|
309
|
+
var scalars = String.UnicodeScalarView()
|
|
310
|
+
var index = 0
|
|
311
|
+
while index < bytes.count {
|
|
312
|
+
let first = UInt32(bytes[index])
|
|
313
|
+
index += 1
|
|
314
|
+
var codePoint: UInt32
|
|
315
|
+
let continuationCount: Int
|
|
316
|
+
if first <= 0x7F {
|
|
317
|
+
codePoint = first
|
|
318
|
+
continuationCount = 0
|
|
319
|
+
} else if first & 0xE0 == 0xC0 {
|
|
320
|
+
codePoint = first & 0x1F
|
|
321
|
+
continuationCount = 1
|
|
322
|
+
} else if first & 0xF0 == 0xE0 {
|
|
323
|
+
codePoint = first & 0x0F
|
|
324
|
+
continuationCount = 2
|
|
325
|
+
} else if first & 0xF8 == 0xF0 {
|
|
326
|
+
codePoint = first & 0x07
|
|
327
|
+
continuationCount = 3
|
|
328
|
+
} else {
|
|
329
|
+
throw invalid
|
|
330
|
+
}
|
|
331
|
+
for _ in 0..<continuationCount {
|
|
332
|
+
guard index < bytes.count, bytes[index] & 0xC0 == 0x80 else {
|
|
333
|
+
throw invalid
|
|
334
|
+
}
|
|
335
|
+
codePoint = (codePoint << 6) | UInt32(bytes[index] & 0x3F)
|
|
336
|
+
index += 1
|
|
337
|
+
}
|
|
338
|
+
if (continuationCount == 1 && codePoint < 0x80)
|
|
339
|
+
|| (continuationCount == 2 && codePoint < 0x800)
|
|
340
|
+
|| (continuationCount == 3 && codePoint < 0x10000) {
|
|
341
|
+
throw invalid
|
|
342
|
+
}
|
|
343
|
+
guard let scalar = Unicode.Scalar(codePoint) else {
|
|
344
|
+
throw invalid
|
|
345
|
+
}
|
|
346
|
+
scalars.append(scalar)
|
|
347
|
+
}
|
|
348
|
+
return String(scalars)
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// MARK: - Scope keys
|
|
352
|
+
|
|
353
|
+
private func assertCatalogSegment(_ value: String, name: String) throws {
|
|
354
|
+
let units = value.utf16
|
|
355
|
+
let isURLSafe = units.allSatisfy { unit in
|
|
356
|
+
switch unit {
|
|
357
|
+
case 0x30...0x39, 0x41...0x5A, 0x61...0x7A, 0x2E, 0x5F, 0x7E, 0x2D:
|
|
358
|
+
return true
|
|
359
|
+
default:
|
|
360
|
+
return false
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
if units.isEmpty || units.count > ReleaseCatalogLimits.maxCatalogSegmentLength || !isURLSafe {
|
|
364
|
+
throw ReleaseCatalogError(message: "\(name) must be 1-255 URL-safe ASCII characters")
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
func createReleaseCatalogScopeKey(_ scope: ReleaseCatalogScope) throws -> String {
|
|
369
|
+
_ = try decodeChannelKey(scope.channelKey)
|
|
370
|
+
|
|
371
|
+
switch scope.strategy {
|
|
372
|
+
case .appVersion:
|
|
373
|
+
return "v1:app-version:\(scope.platform.rawValue):\(scope.channelKey)"
|
|
374
|
+
case .fingerprint:
|
|
375
|
+
let fingerprintHash = scope.fingerprintHash ?? ""
|
|
376
|
+
try assertCatalogSegment(fingerprintHash, name: "Fingerprint hash")
|
|
377
|
+
return "v1:fingerprint:\(scope.platform.rawValue):\(scope.channelKey):\(fingerprintHash)"
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
func parseReleaseCatalogScopeKey(_ scopeKey: String) throws -> ReleaseCatalogScope {
|
|
382
|
+
let invalid = ReleaseCatalogError(message: "Invalid release catalog scope key")
|
|
383
|
+
let segments = scopeKey.unicodeScalars
|
|
384
|
+
.split(separator: ":", omittingEmptySubsequences: false)
|
|
385
|
+
.map { String(String.UnicodeScalarView($0)) }
|
|
386
|
+
guard segments.count >= 4, segments[0] == "v1",
|
|
387
|
+
let platform = ReleasePlatform(rawValue: segments[2]) else {
|
|
388
|
+
throw invalid
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
let scope: ReleaseCatalogScope
|
|
392
|
+
if segments[1] == "app-version", segments.count == 4 {
|
|
393
|
+
scope = ReleaseCatalogScope(platform: platform, channelKey: segments[3], strategy: .appVersion, fingerprintHash: nil)
|
|
394
|
+
} else if segments[1] == "fingerprint", segments.count == 5 {
|
|
395
|
+
scope = ReleaseCatalogScope(platform: platform, channelKey: segments[3], strategy: .fingerprint, fingerprintHash: segments[4])
|
|
396
|
+
} else {
|
|
397
|
+
throw invalid
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
guard try createReleaseCatalogScopeKey(scope).jsEquals(scopeKey) else {
|
|
401
|
+
throw ReleaseCatalogError(message: "Release catalog scope key is not canonical")
|
|
402
|
+
}
|
|
403
|
+
return scope
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/// Whether the catalog's scope key names the expected scope.
|
|
407
|
+
func hasExpectedReleaseCatalogScope(scopeKey: String, expected: ReleaseCatalogScope) -> Bool {
|
|
408
|
+
guard let parsed = try? parseReleaseCatalogScopeKey(scopeKey) else {
|
|
409
|
+
return false
|
|
410
|
+
}
|
|
411
|
+
return parsed.channelKey.jsEquals(expected.channelKey)
|
|
412
|
+
&& parsed.platform == expected.platform
|
|
413
|
+
&& parsed.strategy == expected.strategy
|
|
414
|
+
&& (parsed.strategy == .appVersion || parsed.fingerprintHash.jsEquals(expected.fingerprintHash))
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// MARK: - Wire format
|
|
418
|
+
|
|
419
|
+
/// The length of `value` in UTF-8 bytes, counted as the protocol counts it.
|
|
420
|
+
func getUtf8ByteLength(_ value: String) -> Int {
|
|
421
|
+
let units = Array(value.utf16)
|
|
422
|
+
var bytes = 0
|
|
423
|
+
var index = 0
|
|
424
|
+
while index < units.count {
|
|
425
|
+
let code = units[index]
|
|
426
|
+
if code <= 0x7F {
|
|
427
|
+
bytes += 1
|
|
428
|
+
} else if code <= 0x7FF {
|
|
429
|
+
bytes += 2
|
|
430
|
+
} else if code >= 0xD800, code <= 0xDBFF, index + 1 < units.count,
|
|
431
|
+
units[index + 1] >= 0xDC00, units[index + 1] <= 0xDFFF {
|
|
432
|
+
bytes += 4
|
|
433
|
+
index += 1
|
|
434
|
+
} else {
|
|
435
|
+
bytes += 3
|
|
436
|
+
}
|
|
437
|
+
index += 1
|
|
438
|
+
}
|
|
439
|
+
return bytes
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/// Parses a release catalog response body as a client accepts it: within the
|
|
443
|
+
/// wire limit, in the schema's shape, for the expected scope, and within the
|
|
444
|
+
/// cohort limits. Any other body is nil, and unknown fields are dropped.
|
|
445
|
+
func parseReleaseCatalog(_ body: String, expectedScope: ReleaseCatalogScope) -> ReleaseCatalog? {
|
|
446
|
+
guard getUtf8ByteLength(body) <= ReleaseCatalogLimits.maxReleaseCatalogWireBytes,
|
|
447
|
+
// As JSON.parse reads it; JSONSerialization differs on edge cases.
|
|
448
|
+
let catalog = StrictJson.parse(body) as? [String: Any] else {
|
|
449
|
+
return nil
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
guard let schemaVersion = jsonSafeInteger(catalog["schemaVersion"]),
|
|
453
|
+
schemaVersion == Int64(ReleaseCatalogLimits.schemaVersion),
|
|
454
|
+
let catalogId = jsonString(catalog["catalogId"]), !catalogId.utf16.isEmpty,
|
|
455
|
+
let scopeKey = jsonString(catalog["scopeKey"]),
|
|
456
|
+
let generation = jsonSafeInteger(catalog["generation"]), generation >= 1,
|
|
457
|
+
let catalogHash = jsonString(catalog["catalogHash"]), isCatalogHash(catalogHash),
|
|
458
|
+
let fallbackPolicy = jsonString(catalog["fallbackPolicy"]),
|
|
459
|
+
fallbackPolicy.jsEquals(ReleaseCatalogLimits.fallbackPolicy),
|
|
460
|
+
let releases = parseReleaseDescriptors(catalog["releases"]),
|
|
461
|
+
let rollbackReleases = parseReleaseDescriptors(catalog["rollbackReleases"]),
|
|
462
|
+
hasExpectedReleaseCatalogScope(scopeKey: scopeKey, expected: expectedScope) else {
|
|
463
|
+
return nil
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
guard distinctTargetCohortCount(catalog) <= ReleaseCatalogLimits.maxDistinctTargetCohortsPerScope else {
|
|
467
|
+
return nil
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
return ReleaseCatalog(
|
|
471
|
+
schemaVersion: ReleaseCatalogLimits.schemaVersion,
|
|
472
|
+
catalogId: catalogId,
|
|
473
|
+
scopeKey: scopeKey,
|
|
474
|
+
generation: generation,
|
|
475
|
+
catalogHash: catalogHash,
|
|
476
|
+
fallbackPolicy: fallbackPolicy,
|
|
477
|
+
releases: releases,
|
|
478
|
+
rollbackReleases: rollbackReleases
|
|
479
|
+
)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
private func parseReleaseDescriptors(_ value: Any?) -> [ReleaseCatalogDescriptor]? {
|
|
483
|
+
guard let entries = value as? [Any] else {
|
|
484
|
+
return nil
|
|
485
|
+
}
|
|
486
|
+
var descriptors = [ReleaseCatalogDescriptor]()
|
|
487
|
+
for entry in entries {
|
|
488
|
+
guard let descriptor = parseReleaseDescriptor(entry) else {
|
|
489
|
+
return nil
|
|
490
|
+
}
|
|
491
|
+
descriptors.append(descriptor)
|
|
492
|
+
}
|
|
493
|
+
return descriptors
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
private func parseReleaseDescriptor(_ value: Any) -> ReleaseCatalogDescriptor? {
|
|
497
|
+
guard let descriptor = value as? [String: Any],
|
|
498
|
+
let releaseId = jsonString(descriptor["releaseId"]), isUUIDv7(releaseId),
|
|
499
|
+
let kind = jsonString(descriptor["kind"]), kind.jsEquals(SelectionKind.bundle.rawValue),
|
|
500
|
+
let bundleId = jsonString(descriptor["bundleId"]),
|
|
501
|
+
let rolloutCohortCount = jsonSafeInteger(descriptor["rolloutCohortCount"]),
|
|
502
|
+
rolloutCohortCount >= 0,
|
|
503
|
+
rolloutCohortCount <= Int64(ReleaseCatalogLimits.numericCohortSize),
|
|
504
|
+
let cohortEntries = descriptor["targetCohorts"] as? [Any],
|
|
505
|
+
cohortEntries.count <= ReleaseCatalogLimits.maxTargetCohortsPerRelease,
|
|
506
|
+
let shouldForceUpdate = jsonBoolean(descriptor["shouldForceUpdate"]) else {
|
|
507
|
+
return nil
|
|
508
|
+
}
|
|
509
|
+
var targetCohorts = [String]()
|
|
510
|
+
var matchableTargetCohorts = [String]()
|
|
511
|
+
for entry in cohortEntries {
|
|
512
|
+
guard let cohort = jsonString(entry) else {
|
|
513
|
+
return nil
|
|
514
|
+
}
|
|
515
|
+
targetCohorts.append(cohort)
|
|
516
|
+
if !(entry is StrictJson.UnpairedString) {
|
|
517
|
+
matchableTargetCohorts.append(cohort)
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
let message: String?
|
|
521
|
+
switch descriptor["message"] {
|
|
522
|
+
case is NSNull:
|
|
523
|
+
message = nil
|
|
524
|
+
case let value:
|
|
525
|
+
guard let text = jsonString(value) else {
|
|
526
|
+
return nil
|
|
527
|
+
}
|
|
528
|
+
message = text
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
return ReleaseCatalogDescriptor(
|
|
532
|
+
releaseId: releaseId,
|
|
533
|
+
kind: .bundle,
|
|
534
|
+
bundleId: bundleId,
|
|
535
|
+
rolloutCohortCount: Int(rolloutCohortCount),
|
|
536
|
+
targetCohorts: targetCohorts,
|
|
537
|
+
shouldForceUpdate: shouldForceUpdate,
|
|
538
|
+
message: message,
|
|
539
|
+
matchableTargetCohorts: matchableTargetCohorts.count == targetCohorts.count ? nil : matchableTargetCohorts
|
|
540
|
+
)
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
/// Whether `value` is a canonical UUIDv7 in lowercase hex.
|
|
544
|
+
func isUUIDv7(_ value: String) -> Bool {
|
|
545
|
+
let units = Array(value.utf16)
|
|
546
|
+
guard units.count == 36 else {
|
|
547
|
+
return false
|
|
548
|
+
}
|
|
549
|
+
for (index, unit) in units.enumerated() {
|
|
550
|
+
switch index {
|
|
551
|
+
case 8, 13, 18, 23:
|
|
552
|
+
guard unit == 0x2D else { return false }
|
|
553
|
+
case 14:
|
|
554
|
+
guard unit == 0x37 else { return false }
|
|
555
|
+
case 19:
|
|
556
|
+
guard unit == 0x38 || unit == 0x39 || unit == 0x61 || unit == 0x62 else { return false }
|
|
557
|
+
default:
|
|
558
|
+
guard isLowercaseHex(unit) else { return false }
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
return true
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private func isCatalogHash(_ value: String) -> Bool {
|
|
565
|
+
let units = Array(value.utf16)
|
|
566
|
+
let prefix = Array("sha256:".utf16)
|
|
567
|
+
return units.count == prefix.count + 64
|
|
568
|
+
&& units.starts(with: prefix)
|
|
569
|
+
&& units.dropFirst(prefix.count).allSatisfy(isLowercaseHex)
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// JSONSerialization returns booleans and numbers as NSNumber; the CF type tells
|
|
573
|
+
// them apart as JavaScript's typeof does.
|
|
574
|
+
|
|
575
|
+
private func isJSONBoolean(_ number: NSNumber) -> Bool {
|
|
576
|
+
CFGetTypeID(number) == CFBooleanGetTypeID()
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/// The distinct target cohorts of a parsed catalog's releases and rollback
|
|
580
|
+
/// releases, told apart by code units as JavaScript does, including cohorts
|
|
581
|
+
/// with unpaired surrogates that `targetCohorts` keeps as U+FFFD.
|
|
582
|
+
func distinctTargetCohortCount(_ catalog: [String: Any]) -> Int {
|
|
583
|
+
var distinct = Set<[UInt16]>()
|
|
584
|
+
for key in ["releases", "rollbackReleases"] {
|
|
585
|
+
for descriptor in catalog[key] as? [[String: Any]] ?? [] {
|
|
586
|
+
for cohort in descriptor["targetCohorts"] as? [Any] ?? [] {
|
|
587
|
+
if let units = jsonStringUnits(cohort) {
|
|
588
|
+
distinct.insert(units)
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
return distinct.count
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
/// A string value's text; one with an unpaired surrogate has it as U+FFFD.
|
|
597
|
+
private func jsonString(_ value: Any?) -> String? {
|
|
598
|
+
if let unpaired = value as? StrictJson.UnpairedString {
|
|
599
|
+
return unpaired.text
|
|
600
|
+
}
|
|
601
|
+
guard let value, !(value is NSNumber) else {
|
|
602
|
+
return nil
|
|
603
|
+
}
|
|
604
|
+
return value as? String
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
/// A string value's UTF-16 code units, as JavaScript compares strings.
|
|
608
|
+
private func jsonStringUnits(_ value: Any?) -> [UInt16]? {
|
|
609
|
+
if let unpaired = value as? StrictJson.UnpairedString {
|
|
610
|
+
return unpaired.units
|
|
611
|
+
}
|
|
612
|
+
return jsonString(value).map { Array($0.utf16) }
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
private func jsonBoolean(_ value: Any?) -> Bool? {
|
|
616
|
+
guard let number = value as? NSNumber, isJSONBoolean(number) else {
|
|
617
|
+
return nil
|
|
618
|
+
}
|
|
619
|
+
return number.boolValue
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/// The value as JavaScript's `Number.isSafeInteger` accepts it, so `3.0` and
|
|
623
|
+
/// `3e0` count and booleans do not.
|
|
624
|
+
private func jsonSafeInteger(_ value: Any?) -> Int64? {
|
|
625
|
+
guard let number = value as? NSNumber, !isJSONBoolean(number) else {
|
|
626
|
+
return nil
|
|
627
|
+
}
|
|
628
|
+
let double = number.doubleValue
|
|
629
|
+
guard double.isFinite, double.rounded(.towardZero) == double,
|
|
630
|
+
abs(double) <= Double(ReleaseCatalogLimits.maxSafeInteger) else {
|
|
631
|
+
return nil
|
|
632
|
+
}
|
|
633
|
+
return Int64(double)
|
|
634
|
+
}
|
|
@@ -201,7 +201,7 @@ final class ReleaseCatalogCacheService {
|
|
|
201
201
|
}
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
-
|
|
204
|
+
static func defaultRootDirectory() -> URL {
|
|
205
205
|
let base = FileManager.default.urls(
|
|
206
206
|
for: .applicationSupportDirectory,
|
|
207
207
|
in: .userDomainMask
|