@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.
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 +493 -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,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
- private static func defaultRootDirectory() -> URL {
204
+ static func defaultRootDirectory() -> URL {
205
205
  let base = FileManager.default.urls(
206
206
  for: .applicationSupportDirectory,
207
207
  in: .userDomainMask