@latchway/react-native 0.0.0-bootstrap.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/CHANGELOG.md +97 -0
  2. package/LatchwayReactNative.podspec +33 -0
  3. package/NOTICE +7 -0
  4. package/README.md +286 -3
  5. package/SECURITY.md +69 -0
  6. package/android/build.gradle.kts +62 -0
  7. package/android/consumer-rules.pro +2 -0
  8. package/android/gradle/wrapper/gradle-wrapper.jar +0 -0
  9. package/android/gradle/wrapper/gradle-wrapper.properties +9 -0
  10. package/android/gradle.properties +4 -0
  11. package/android/gradlew +251 -0
  12. package/android/gradlew.bat +94 -0
  13. package/android/settings.gradle.kts +33 -0
  14. package/android/src/main/AndroidManifest.xml +3 -0
  15. package/android/src/main/java/dev/latchway/reactnative/LatchwayReactNativePackage.kt +25 -0
  16. package/android/src/main/java/dev/latchway/reactnative/NativeLatchwayModule.kt +964 -0
  17. package/android/src/test/java/dev/latchway/reactnative/NativeLatchwayModuleTest.kt +521 -0
  18. package/contract.lock +7 -0
  19. package/docs/architecture.md +145 -0
  20. package/docs/conformance.md +68 -0
  21. package/docs/native-installation.md +172 -0
  22. package/docs/physical-device-evidence.md +559 -0
  23. package/docs/releasing.md +360 -0
  24. package/docs/security.md +55 -0
  25. package/ios/LatchwayNativeBridge.swift +1551 -0
  26. package/ios/RCTNativeLatchway.h +9 -0
  27. package/ios/RCTNativeLatchway.mm +175 -0
  28. package/lib/client.d.ts +29 -0
  29. package/lib/client.d.ts.map +1 -0
  30. package/lib/client.js +939 -0
  31. package/lib/client.js.map +1 -0
  32. package/lib/component-client.d.ts +15 -0
  33. package/lib/component-client.d.ts.map +1 -0
  34. package/lib/component-client.js +141 -0
  35. package/lib/component-client.js.map +1 -0
  36. package/lib/config.d.ts +25 -0
  37. package/lib/config.d.ts.map +1 -0
  38. package/lib/config.js +261 -0
  39. package/lib/config.js.map +1 -0
  40. package/lib/coordinator.d.ts +19 -0
  41. package/lib/coordinator.d.ts.map +1 -0
  42. package/lib/coordinator.js +167 -0
  43. package/lib/coordinator.js.map +1 -0
  44. package/lib/errors.d.ts +5 -0
  45. package/lib/errors.d.ts.map +1 -0
  46. package/lib/errors.js +201 -0
  47. package/lib/errors.js.map +1 -0
  48. package/lib/index.d.ts +8 -0
  49. package/lib/index.d.ts.map +1 -0
  50. package/lib/index.js +13 -0
  51. package/lib/index.js.map +1 -0
  52. package/lib/native/NativeLatchway.d.ts +25 -0
  53. package/lib/native/NativeLatchway.d.ts.map +1 -0
  54. package/lib/native/NativeLatchway.js +3 -0
  55. package/lib/native/NativeLatchway.js.map +1 -0
  56. package/lib/native/bridge.d.ts +5 -0
  57. package/lib/native/bridge.d.ts.map +1 -0
  58. package/lib/native/bridge.js +17 -0
  59. package/lib/native/bridge.js.map +1 -0
  60. package/lib/native-output.d.ts +3 -0
  61. package/lib/native-output.d.ts.map +1 -0
  62. package/lib/native-output.js +43 -0
  63. package/lib/native-output.js.map +1 -0
  64. package/lib/request-id.d.ts +2 -0
  65. package/lib/request-id.d.ts.map +1 -0
  66. package/lib/request-id.js +5 -0
  67. package/lib/request-id.js.map +1 -0
  68. package/lib/testing.d.ts +7 -0
  69. package/lib/testing.d.ts.map +1 -0
  70. package/lib/testing.js +9 -0
  71. package/lib/testing.js.map +1 -0
  72. package/lib/types.d.ts +200 -0
  73. package/lib/types.d.ts.map +1 -0
  74. package/lib/types.js +2 -0
  75. package/lib/types.js.map +1 -0
  76. package/lib/version.d.ts +8 -0
  77. package/lib/version.d.ts.map +1 -0
  78. package/lib/version.js +8 -0
  79. package/lib/version.js.map +1 -0
  80. package/package.json +130 -6
  81. package/react-native.config.cjs +7 -0
  82. package/release-compatibility.json +64 -0
  83. package/src/client.ts +1022 -0
  84. package/src/component-client.ts +158 -0
  85. package/src/config.ts +368 -0
  86. package/src/coordinator.ts +195 -0
  87. package/src/errors.ts +225 -0
  88. package/src/index.ts +53 -0
  89. package/src/native/NativeLatchway.ts +75 -0
  90. package/src/native/bridge.ts +23 -0
  91. package/src/native-output.ts +43 -0
  92. package/src/request-id.ts +5 -0
  93. package/src/testing.ts +11 -0
  94. package/src/types.ts +241 -0
  95. package/src/version.ts +7 -0
@@ -0,0 +1,1551 @@
1
+ @preconcurrency import Foundation
2
+ import Latchway
3
+ #if canImport(LatchwayAppAttest)
4
+ import LatchwayAppAttest
5
+ #endif
6
+
7
+ public typealias LatchwayResolveString = @Sendable (String) -> Void
8
+ public typealias LatchwayResolveVoid = @Sendable () -> Void
9
+ public typealias LatchwayReject = @Sendable (String, String, NSError?) -> Void
10
+
11
+ protocol NativeClientOperating: Sendable {
12
+ func startRequest(identityToken: String, encoded: String) async throws -> String
13
+ func readResponseChunk(responseID: String, maximumBytes: Double) async throws -> String
14
+ func closeResponse(responseID: String) async
15
+ func close() async
16
+ func quota(identityToken: String, feature: String) async throws -> String
17
+ func diagnostics(identityToken: String) async throws -> String
18
+ func refresh(identityToken: String) async throws
19
+ func prepareComponents(identityToken: String, encoded: String) async throws -> String
20
+ func revokeComponent(identityToken: String, encoded: String) async throws
21
+ func replaceComponent(identityToken: String, encoded: String) async throws -> String
22
+ func componentDiagnostics(encoded: String) async throws -> String
23
+ func revokeCurrentInstallation(identityToken: String) async throws
24
+ func revokeCurrentInstallationFamily(identityToken: String) async throws
25
+ func revokeFamily(identityToken: String, encoded: String) async throws
26
+ }
27
+
28
+ typealias NativeClientFactory = @Sendable (NativeConfiguration) throws -> any NativeClientOperating
29
+
30
+ @objc(LatchwayNativeBridge)
31
+ public final class LatchwayNativeBridge: NSObject, @unchecked Sendable {
32
+ private let store: LatchwayBridgeStore
33
+
34
+ public override init() {
35
+ store = LatchwayBridgeStore { try NativeClientContext(configuration: $0) }
36
+ super.init()
37
+ }
38
+
39
+ init(makeClient: @escaping NativeClientFactory) {
40
+ store = LatchwayBridgeStore(makeClient: makeClient)
41
+ super.init()
42
+ }
43
+
44
+ @objc(configureWithClientID:configurationJSON:resolve:reject:)
45
+ public func configure(
46
+ clientID: String,
47
+ configurationJSON: String,
48
+ resolve: @escaping LatchwayResolveString,
49
+ reject: @escaping LatchwayReject
50
+ ) {
51
+ Task {
52
+ do { resolve(try await store.configure(clientID: clientID, encoded: configurationJSON)) }
53
+ catch { Self.reject(error, with: reject) }
54
+ }
55
+ }
56
+
57
+ @objc(configureComponentWithClientID:configurationJSON:componentJSON:resolve:reject:)
58
+ public func configureComponent(
59
+ clientID: String,
60
+ configurationJSON: String,
61
+ componentJSON: String,
62
+ resolve: @escaping LatchwayResolveString,
63
+ reject: @escaping LatchwayReject
64
+ ) {
65
+ Task {
66
+ do {
67
+ resolve(try await store.configureComponent(
68
+ clientID: clientID,
69
+ encodedConfiguration: configurationJSON,
70
+ encodedComponent: componentJSON
71
+ ))
72
+ } catch { Self.reject(error, with: reject) }
73
+ }
74
+ }
75
+
76
+ @objc(startRequestWithClientID:operationID:identityToken:requestJSON:resolve:reject:)
77
+ public func startRequest(
78
+ clientID: String,
79
+ operationID: String,
80
+ identityToken: String,
81
+ requestJSON: String,
82
+ resolve: @escaping LatchwayResolveString,
83
+ reject: @escaping LatchwayReject
84
+ ) {
85
+ Task {
86
+ do {
87
+ resolve(try await store.run(clientID: clientID, operationID: operationID) { context in
88
+ try await context.startRequest(identityToken: identityToken, encoded: requestJSON)
89
+ })
90
+ } catch { Self.reject(error, with: reject) }
91
+ }
92
+ }
93
+
94
+ @objc(readResponseChunkWithClientID:operationID:responseID:maximumBytes:resolve:reject:)
95
+ public func readResponseChunk(
96
+ clientID: String,
97
+ operationID: String,
98
+ responseID: String,
99
+ maximumBytes: Double,
100
+ resolve: @escaping LatchwayResolveString,
101
+ reject: @escaping LatchwayReject
102
+ ) {
103
+ Task {
104
+ do {
105
+ resolve(try await store.run(clientID: clientID, operationID: operationID) { context in
106
+ try await context.readResponseChunk(responseID: responseID, maximumBytes: maximumBytes)
107
+ })
108
+ } catch { Self.reject(error, with: reject) }
109
+ }
110
+ }
111
+
112
+ @objc(closeResponseWithClientID:responseID:resolve:reject:)
113
+ public func closeResponse(
114
+ clientID: String,
115
+ responseID: String,
116
+ resolve: @escaping LatchwayResolveVoid,
117
+ reject: @escaping LatchwayReject
118
+ ) {
119
+ Task {
120
+ do {
121
+ try await store.closeResponse(clientID: clientID, responseID: responseID)
122
+ resolve()
123
+ } catch { Self.reject(error, with: reject) }
124
+ }
125
+ }
126
+
127
+ @objc(refreshWithClientID:operationID:identityToken:resolve:reject:)
128
+ public func refresh(
129
+ clientID: String,
130
+ operationID: String,
131
+ identityToken: String,
132
+ resolve: @escaping LatchwayResolveVoid,
133
+ reject: @escaping LatchwayReject
134
+ ) {
135
+ runVoid(clientID: clientID, operationID: operationID, identityToken: identityToken, resolve: resolve, reject: reject) {
136
+ try await $0.refresh(identityToken: $1)
137
+ }
138
+ }
139
+
140
+ @objc(quotaWithClientID:operationID:identityToken:feature:resolve:reject:)
141
+ public func quota(
142
+ clientID: String,
143
+ operationID: String,
144
+ identityToken: String,
145
+ feature: String,
146
+ resolve: @escaping LatchwayResolveString,
147
+ reject: @escaping LatchwayReject
148
+ ) {
149
+ Task {
150
+ do {
151
+ resolve(try await store.run(clientID: clientID, operationID: operationID) { context in
152
+ try await context.quota(identityToken: identityToken, feature: feature)
153
+ })
154
+ } catch { Self.reject(error, with: reject) }
155
+ }
156
+ }
157
+
158
+ @objc(diagnosticsWithClientID:operationID:identityToken:resolve:reject:)
159
+ public func diagnostics(
160
+ clientID: String,
161
+ operationID: String,
162
+ identityToken: String,
163
+ resolve: @escaping LatchwayResolveString,
164
+ reject: @escaping LatchwayReject
165
+ ) {
166
+ Task {
167
+ do {
168
+ resolve(try await store.run(clientID: clientID, operationID: operationID) { context in
169
+ try await context.diagnostics(identityToken: identityToken)
170
+ })
171
+ } catch { Self.reject(error, with: reject) }
172
+ }
173
+ }
174
+
175
+ @objc(establishDirectAttestationWithClientID:operationID:resolve:reject:)
176
+ public func establishDirectAttestation(
177
+ clientID: String,
178
+ operationID: String,
179
+ resolve: @escaping LatchwayResolveVoid,
180
+ reject: @escaping LatchwayReject
181
+ ) {
182
+ Task {
183
+ do {
184
+ _ = try await store.runComponent(clientID: clientID, operationID: operationID) { context in
185
+ try await context.establishDirectAttestation()
186
+ return true
187
+ }
188
+ resolve()
189
+ } catch { Self.reject(error, with: reject) }
190
+ }
191
+ }
192
+
193
+ @objc(componentDiagnosticsWithClientID:operationID:resolve:reject:)
194
+ public func componentDiagnostics(
195
+ clientID: String,
196
+ operationID: String,
197
+ resolve: @escaping LatchwayResolveString,
198
+ reject: @escaping LatchwayReject
199
+ ) {
200
+ Task {
201
+ do {
202
+ resolve(try await store.runComponent(clientID: clientID, operationID: operationID) { context in
203
+ try await context.diagnostics()
204
+ })
205
+ } catch { Self.reject(error, with: reject) }
206
+ }
207
+ }
208
+
209
+ @objc(prepareComponentsWithClientID:operationID:identityToken:componentsJSON:resolve:reject:)
210
+ public func prepareComponents(
211
+ clientID: String,
212
+ operationID: String,
213
+ identityToken: String,
214
+ componentsJSON: String,
215
+ resolve: @escaping LatchwayResolveString,
216
+ reject: @escaping LatchwayReject
217
+ ) {
218
+ Task {
219
+ do {
220
+ resolve(try await store.run(clientID: clientID, operationID: operationID) { context in
221
+ try await context.prepareComponents(identityToken: identityToken, encoded: componentsJSON)
222
+ })
223
+ } catch { Self.reject(error, with: reject) }
224
+ }
225
+ }
226
+
227
+ @objc(replaceComponentWithClientID:operationID:identityToken:componentJSON:resolve:reject:)
228
+ public func replaceComponent(
229
+ clientID: String,
230
+ operationID: String,
231
+ identityToken: String,
232
+ componentJSON: String,
233
+ resolve: @escaping LatchwayResolveString,
234
+ reject: @escaping LatchwayReject
235
+ ) {
236
+ Task {
237
+ do {
238
+ resolve(try await store.run(clientID: clientID, operationID: operationID) { context in
239
+ try await context.replaceComponent(identityToken: identityToken, encoded: componentJSON)
240
+ })
241
+ } catch { Self.reject(error, with: reject) }
242
+ }
243
+ }
244
+
245
+ @objc(rootComponentDiagnosticsWithClientID:operationID:componentJSON:resolve:reject:)
246
+ public func rootComponentDiagnostics(
247
+ clientID: String,
248
+ operationID: String,
249
+ componentJSON: String,
250
+ resolve: @escaping LatchwayResolveString,
251
+ reject: @escaping LatchwayReject
252
+ ) {
253
+ Task {
254
+ do {
255
+ resolve(try await store.run(clientID: clientID, operationID: operationID) { context in
256
+ try await context.componentDiagnostics(encoded: componentJSON)
257
+ })
258
+ } catch { Self.reject(error, with: reject) }
259
+ }
260
+ }
261
+
262
+ @objc(revokeComponentWithClientID:operationID:identityToken:componentJSON:resolve:reject:)
263
+ public func revokeComponent(
264
+ clientID: String,
265
+ operationID: String,
266
+ identityToken: String,
267
+ componentJSON: String,
268
+ resolve: @escaping LatchwayResolveVoid,
269
+ reject: @escaping LatchwayReject
270
+ ) {
271
+ Task {
272
+ do {
273
+ _ = try await store.run(clientID: clientID, operationID: operationID) { context in
274
+ try await context.revokeComponent(identityToken: identityToken, encoded: componentJSON)
275
+ return true
276
+ }
277
+ resolve()
278
+ } catch { Self.reject(error, with: reject) }
279
+ }
280
+ }
281
+
282
+ @objc(revokeWithClientID:operationID:identityToken:resolve:reject:)
283
+ public func revoke(
284
+ clientID: String,
285
+ operationID: String,
286
+ identityToken: String,
287
+ resolve: @escaping LatchwayResolveVoid,
288
+ reject: @escaping LatchwayReject
289
+ ) {
290
+ runVoid(clientID: clientID, operationID: operationID, identityToken: identityToken, resolve: resolve, reject: reject) {
291
+ try await $0.revokeCurrentInstallation(identityToken: $1)
292
+ }
293
+ }
294
+
295
+ @objc(revokeFamilyWithClientID:operationID:identityToken:resolve:reject:)
296
+ public func revokeFamily(
297
+ clientID: String,
298
+ operationID: String,
299
+ identityToken: String,
300
+ resolve: @escaping LatchwayResolveVoid,
301
+ reject: @escaping LatchwayReject
302
+ ) {
303
+ runVoid(clientID: clientID, operationID: operationID, identityToken: identityToken, resolve: resolve, reject: reject) {
304
+ try await $0.revokeCurrentInstallationFamily(identityToken: $1)
305
+ }
306
+ }
307
+
308
+ @objc(revokeFamilyWithComponentsWithClientID:operationID:identityToken:componentsJSON:resolve:reject:)
309
+ public func revokeFamilyWithComponents(
310
+ clientID: String,
311
+ operationID: String,
312
+ identityToken: String,
313
+ componentsJSON: String,
314
+ resolve: @escaping LatchwayResolveVoid,
315
+ reject: @escaping LatchwayReject
316
+ ) {
317
+ Task {
318
+ do {
319
+ _ = try await store.run(clientID: clientID, operationID: operationID) { context in
320
+ try await context.revokeFamily(identityToken: identityToken, encoded: componentsJSON)
321
+ return true
322
+ }
323
+ resolve()
324
+ } catch { Self.reject(error, with: reject) }
325
+ }
326
+ }
327
+
328
+ @objc(cancelWithClientID:operationID:)
329
+ public func cancel(clientID: String, operationID: String) {
330
+ Task { await store.cancel(clientID: clientID, operationID: operationID) }
331
+ }
332
+
333
+ @objc(disposeWithClientID:resolve:reject:)
334
+ public func dispose(
335
+ clientID: String,
336
+ resolve: @escaping LatchwayResolveVoid,
337
+ reject _: @escaping LatchwayReject
338
+ ) {
339
+ Task {
340
+ await store.dispose(clientID: clientID)
341
+ resolve()
342
+ }
343
+ }
344
+
345
+ private func runVoid(
346
+ clientID: String,
347
+ operationID: String,
348
+ identityToken: String,
349
+ resolve: @escaping LatchwayResolveVoid,
350
+ reject: @escaping LatchwayReject,
351
+ operation: @escaping @Sendable (any NativeClientOperating, String) async throws -> Void
352
+ ) {
353
+ Task {
354
+ do {
355
+ _ = try await store.run(clientID: clientID, operationID: operationID) { context in
356
+ try await operation(context, identityToken)
357
+ return true
358
+ }
359
+ resolve()
360
+ } catch { Self.reject(error, with: reject) }
361
+ }
362
+ }
363
+
364
+ private static func reject(_ error: Error, with reject: LatchwayReject) {
365
+ let failure = NativeFailure(error)
366
+ reject(failure.code, failure.message, NSError(
367
+ domain: "dev.latchway.react-native",
368
+ code: failure.status ?? 0,
369
+ userInfo: compact([
370
+ "code": failure.code,
371
+ "documentationURL": failure.documentationURL,
372
+ "requestID": failure.requestID as Any,
373
+ "operationID": failure.operationID as Any,
374
+ "status": failure.status as Any,
375
+ "retryable": failure.retryable,
376
+ ])
377
+ ))
378
+ }
379
+ }
380
+
381
+ private actor LatchwayBridgeStore {
382
+ private let makeClient: NativeClientFactory
383
+ private var clients: [String: any NativeClientOperating] = [:]
384
+ private var componentClients: [String: NativeComponentContext] = [:]
385
+ private var cancellations: [String: @Sendable () -> Void] = [:]
386
+
387
+ init(makeClient: @escaping NativeClientFactory) {
388
+ self.makeClient = makeClient
389
+ }
390
+
391
+ func configure(clientID: String, encoded: String) throws -> String {
392
+ guard clients[clientID] == nil, componentClients[clientID] == nil else {
393
+ throw LatchwayError.invalidConfiguration("client identifier is already configured")
394
+ }
395
+ let configuration = try NativeConfiguration.decode(encoded)
396
+ let context = try makeClient(configuration)
397
+ clients[clientID] = context
398
+ return try jsonString([
399
+ "platform": "react_native_ios",
400
+ "nativeSDKVersion": LatchwayVersion.sdk,
401
+ "contractVersion": LatchwayVersion.contract,
402
+ "protocolVersion": LatchwayVersion.protocolVersion,
403
+ ])
404
+ }
405
+
406
+ func configureComponent(
407
+ clientID: String,
408
+ encodedConfiguration: String,
409
+ encodedComponent: String
410
+ ) throws -> String {
411
+ guard clients[clientID] == nil, componentClients[clientID] == nil else {
412
+ throw LatchwayError.invalidConfiguration("client identifier is already configured")
413
+ }
414
+ let configuration = try NativeComponentConfiguration.decode(encodedConfiguration)
415
+ let component = try NativeComponentInput.decode(encodedComponent)
416
+ guard configuration.apple.legacySharedKeychainAccessGroups.contains(
417
+ component.keychainAccessGroup
418
+ ) else {
419
+ throw LatchwayError.invalidConfiguration(
420
+ "component Keychain access group is not shared by the containing root application"
421
+ )
422
+ }
423
+ componentClients[clientID] = try NativeComponentContext(
424
+ configuration: configuration,
425
+ component: component
426
+ )
427
+ return try jsonString([
428
+ "platform": "react_native_ios",
429
+ "nativeSDKVersion": LatchwayVersion.sdk,
430
+ "contractVersion": LatchwayVersion.contract,
431
+ "protocolVersion": LatchwayVersion.protocolVersion,
432
+ ])
433
+ }
434
+
435
+ func run<T: Sendable>(
436
+ clientID: String,
437
+ operationID: String,
438
+ operation: @escaping @Sendable (any NativeClientOperating) async throws -> T
439
+ ) async throws -> T {
440
+ guard let context = clients[clientID] else { throw LatchwayError.invalidConfiguration("client is not configured") }
441
+ let key = "\(clientID)|\(operationID)"
442
+ guard cancellations[key] == nil else { throw LatchwayError.invalidRequest("operation identifier is already active") }
443
+ let task = Task { try await operation(context) }
444
+ cancellations[key] = { task.cancel() }
445
+ defer { cancellations.removeValue(forKey: key) }
446
+ return try await task.value
447
+ }
448
+
449
+ func runComponent<T: Sendable>(
450
+ clientID: String,
451
+ operationID: String,
452
+ operation: @escaping @Sendable (NativeComponentContext) async throws -> T
453
+ ) async throws -> T {
454
+ guard let context = componentClients[clientID] else {
455
+ throw LatchwayError.invalidConfiguration("component client is not configured")
456
+ }
457
+ let key = "\(clientID)|\(operationID)"
458
+ guard cancellations[key] == nil else {
459
+ throw LatchwayError.invalidRequest("operation identifier is already active")
460
+ }
461
+ let task = Task { try await operation(context) }
462
+ cancellations[key] = { task.cancel() }
463
+ defer { cancellations.removeValue(forKey: key) }
464
+ return try await task.value
465
+ }
466
+
467
+ func cancel(clientID: String, operationID: String) {
468
+ cancellations["\(clientID)|\(operationID)"]?()
469
+ }
470
+
471
+ func closeResponse(clientID: String, responseID: String) async throws {
472
+ guard let context = clients[clientID] else {
473
+ throw LatchwayError.invalidConfiguration("client is not configured")
474
+ }
475
+ await context.closeResponse(responseID: responseID)
476
+ }
477
+
478
+ func dispose(clientID: String) async {
479
+ let context = clients.removeValue(forKey: clientID)
480
+ componentClients.removeValue(forKey: clientID)
481
+ let prefix = "\(clientID)|"
482
+ for key in cancellations.keys.filter({ $0.hasPrefix(prefix) }) {
483
+ cancellations.removeValue(forKey: key)?()
484
+ }
485
+ await context?.close()
486
+ }
487
+ }
488
+
489
+ private final class NativeClientContext: NativeClientOperating, @unchecked Sendable {
490
+ private let identity = TransientIdentityTokenProvider()
491
+ private let operationLock = NativeOperationLock()
492
+ private let client: LatchwayClient
493
+ private let baseURL: URL
494
+ private let frameworkVersion: String
495
+ private let responses = NativeResponseRegistry()
496
+ private let sharedKeychainAccessGroups: Set<String>
497
+
498
+ init(configuration: NativeConfiguration) throws {
499
+ let baseURL = try validatedNativeBaseURL(
500
+ configuration.baseURL,
501
+ allowInsecureLoopback: configuration.allowInsecureLoopback
502
+ )
503
+ self.baseURL = baseURL
504
+ frameworkVersion = configuration.frameworkVersion
505
+ sharedKeychainAccessGroups = Set(configuration.apple.legacySharedKeychainAccessGroups)
506
+ let attestation: (any LatchwayAttestationProvider)?
507
+ if configuration.apple.appAttestEnabled {
508
+ if let namespace = configuration.apple.storageNamespace {
509
+ attestation = LatchwayAppAttestProvider(
510
+ rootKeychainAccessGroup: configuration.apple.rootKeychainAccessGroup,
511
+ legacySharedKeychainAccessGroups: configuration.apple.legacySharedKeychainAccessGroups,
512
+ storageNamespace: "\(namespace).react_native_ios"
513
+ )
514
+ } else {
515
+ attestation = LatchwayAppAttestProvider(
516
+ applicationID: configuration.applicationID,
517
+ environment: configuration.environment,
518
+ rootKeychainAccessGroup: configuration.apple.rootKeychainAccessGroup,
519
+ legacySharedKeychainAccessGroups: configuration.apple.legacySharedKeychainAccessGroups,
520
+ clientRuntime: .reactNativeIOS
521
+ )
522
+ }
523
+ } else {
524
+ attestation = nil
525
+ }
526
+ let fallback: LatchwaySoftwareKeyFallbackPolicy = configuration.apple.softwareKeyFallbackPolicy == "allow"
527
+ ? .allowWhenSecureEnclaveUnavailable
528
+ : .disallow
529
+ let native = LatchwayConfiguration(
530
+ baseURL: baseURL,
531
+ applicationID: configuration.applicationID,
532
+ environment: configuration.environment,
533
+ rootKeychainAccessGroup: configuration.apple.rootKeychainAccessGroup,
534
+ legacySharedKeychainAccessGroups: configuration.apple.legacySharedKeychainAccessGroups,
535
+ identityProvider: configuration.identityProvider,
536
+ clientRuntime: .reactNativeIOS,
537
+ clientSDKVersion: configuration.sdkVersion,
538
+ appVersion: configuration.appVersion,
539
+ softwareKeyFallbackPolicy: fallback,
540
+ attestationProvider: attestation
541
+ )
542
+ client = LatchwayClient(configuration: native, identityTokenProvider: identity)
543
+ }
544
+
545
+ func startRequest(identityToken: String, encoded: String) async throws -> String {
546
+ let input = try NativeRequestInput.decode(encoded)
547
+ guard let url = URL(string: input.url) else {
548
+ throw LatchwayError.invalidRequest("request URL is invalid")
549
+ }
550
+ try validateTarget(baseURL: baseURL, target: url, method: input.method, feature: input.feature)
551
+ var request = URLRequest(url: url)
552
+ request.httpMethod = input.method
553
+ request.httpBody = input.body
554
+ for pair in input.headers {
555
+ request.setValue(pair[1], forHTTPHeaderField: pair[0])
556
+ }
557
+ let preparedRequest = request
558
+ return try await withIdentityToken(identityToken) { client in
559
+ let stream = try await client.transport(
560
+ feature: input.feature,
561
+ framework: .reactNativeFetch(version: self.frameworkVersion)
562
+ ).bytes(for: preparedRequest)
563
+ do {
564
+ try Task.checkCancellation()
565
+ let response = stream.response
566
+ guard (200 ... 599).contains(response.statusCode),
567
+ response.url.map({
568
+ targetHasSameAllowedOriginAndPath(
569
+ baseURL: self.baseURL,
570
+ target: $0,
571
+ method: input.method,
572
+ feature: input.feature
573
+ )
574
+ }) == true
575
+ else {
576
+ throw LatchwayError.invalidServerResponse
577
+ }
578
+ let responseID = "rsp_\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))"
579
+ let handle = NativeResponseHandle(stream: stream)
580
+ await self.responses.insert(handle, responseID: responseID)
581
+ do {
582
+ return try responseMetadata(responseID: responseID, response: response)
583
+ } catch {
584
+ await self.responses.close(responseID: responseID)
585
+ throw error
586
+ }
587
+ } catch let error as LatchwayError {
588
+ stream.cancel()
589
+ throw error
590
+ } catch is CancellationError {
591
+ stream.cancel()
592
+ throw LatchwayError.cancelled
593
+ } catch let error as URLError where error.code == .cancelled && Task.isCancelled {
594
+ stream.cancel()
595
+ throw LatchwayError.cancelled
596
+ } catch {
597
+ stream.cancel()
598
+ throw LatchwayError.transportFailure
599
+ }
600
+ }
601
+ }
602
+
603
+ func readResponseChunk(responseID: String, maximumBytes: Double) async throws -> String {
604
+ guard maximumBytes.isFinite,
605
+ maximumBytes.rounded(.towardZero) == maximumBytes,
606
+ (1 ... Double(maximumResponseChunkBytes)).contains(maximumBytes)
607
+ else { throw LatchwayError.invalidRequest("response chunk limit is invalid") }
608
+ guard let data = try await responses.read(responseID: responseID, maximumBytes: Int(maximumBytes)) else {
609
+ return try jsonString(["done": true])
610
+ }
611
+ return try jsonString([
612
+ "done": false,
613
+ "chunk": data.base64EncodedString(),
614
+ ])
615
+ }
616
+
617
+ func closeResponse(responseID: String) async {
618
+ await responses.close(responseID: responseID)
619
+ }
620
+
621
+ func close() async {
622
+ await responses.closeAll()
623
+ }
624
+
625
+ func quota(identityToken: String, feature: String) async throws -> String {
626
+ try await withIdentityToken(identityToken) { client in
627
+ let encoder = JSONEncoder()
628
+ encoder.dateEncodingStrategy = .iso8601
629
+ return String(decoding: try encoder.encode(try await client.quota(feature: feature)), as: UTF8.self)
630
+ }
631
+ }
632
+
633
+ func diagnostics(identityToken: String) async throws -> String {
634
+ try await withIdentityToken(identityToken) { client in
635
+ let diagnostics = await client.diagnostics()
636
+ return try jsonString([
637
+ "contractVersion": diagnostics.contractVersion,
638
+ "protocolVersion": diagnostics.protocolVersion,
639
+ "keyStorage": diagnostics.keyStorage.rawValue,
640
+ "attestation": compact([
641
+ "support": diagnostics.attestation.support.rawValue,
642
+ "provider": diagnostics.trustProvider as Any,
643
+ "trustLevel": diagnostics.trustLevel as Any,
644
+ "lastOperation": diagnostics.attestation.lastOperation as Any,
645
+ ]),
646
+ "session": compact([
647
+ "state": diagnostics.sessionState.rawValue,
648
+ "expiresAt": diagnostics.sessionExpiresAt.map(iso8601) as Any,
649
+ "refreshAvailable": diagnostics.sessionExpiresAt != nil,
650
+ ]),
651
+ "installation": compact([
652
+ "id": diagnostics.installationID as Any,
653
+ "status": diagnostics.sessionState == .revoked ? "revoked" : "active",
654
+ ]),
655
+ "server": compact([
656
+ "version": diagnostics.serverVersion as Any,
657
+ "lastRequestID": diagnostics.lastRequestID as Any,
658
+ ]),
659
+ "lastErrorCode": diagnostics.lastErrorCode as Any,
660
+ ])
661
+ }
662
+ }
663
+
664
+ func refresh(identityToken: String) async throws {
665
+ try await withIdentityToken(identityToken) { try await $0.refresh() }
666
+ }
667
+
668
+ func prepareComponents(identityToken: String, encoded: String) async throws -> String {
669
+ let components = try NativeHostComponentInput.decodeMany(
670
+ encoded,
671
+ sharedKeychainAccessGroups: sharedKeychainAccessGroups
672
+ )
673
+ let diagnostics = try await withIdentityToken(identityToken) { client in
674
+ try await client.prepareComponents(components.map(\.configuration))
675
+ }
676
+ return try jsonString([
677
+ "components": diagnostics.map(componentDiagnosticsDictionary),
678
+ ])
679
+ }
680
+
681
+ func revokeComponent(identityToken: String, encoded: String) async throws {
682
+ let component = try NativeHostComponentInput.decodeOne(
683
+ encoded,
684
+ sharedKeychainAccessGroups: sharedKeychainAccessGroups
685
+ )
686
+ try await withIdentityToken(identityToken) { client in
687
+ try await client.revokeComponent(component.configuration)
688
+ }
689
+ }
690
+
691
+ func replaceComponent(identityToken: String, encoded: String) async throws -> String {
692
+ let component = try NativeHostComponentInput.decodeOne(
693
+ encoded,
694
+ sharedKeychainAccessGroups: sharedKeychainAccessGroups
695
+ )
696
+ let diagnostics = try await withIdentityToken(identityToken) { client in
697
+ try await client.replaceComponent(component.configuration)
698
+ }
699
+ return try jsonString(componentDiagnosticsDictionary(diagnostics))
700
+ }
701
+
702
+ func componentDiagnostics(encoded: String) async throws -> String {
703
+ let component = try NativeHostComponentInput.decodeOne(
704
+ encoded,
705
+ sharedKeychainAccessGroups: sharedKeychainAccessGroups
706
+ )
707
+ let diagnostics = await client.componentDiagnostics(component.configuration)
708
+ return try jsonString(componentDiagnosticsDictionary(diagnostics))
709
+ }
710
+
711
+ func revokeCurrentInstallation(identityToken: String) async throws {
712
+ try await withIdentityToken(identityToken) { try await $0.revokeCurrentInstallation() }
713
+ }
714
+
715
+ func revokeCurrentInstallationFamily(identityToken: String) async throws {
716
+ // The native SDK discovers every prepared descriptor from its
717
+ // root-private durable registry; JavaScript does not need to replay it.
718
+ try await withIdentityToken(identityToken) { try await $0.revokeCurrentInstallationFamily() }
719
+ }
720
+
721
+ func revokeFamily(identityToken: String, encoded: String) async throws {
722
+ let components = try NativeHostComponentInput.decodeMany(
723
+ encoded,
724
+ sharedKeychainAccessGroups: sharedKeychainAccessGroups
725
+ )
726
+ try await withIdentityToken(identityToken) { client in
727
+ try await client.revokeCurrentInstallationFamily(retiring: components.map(\.configuration))
728
+ }
729
+ }
730
+
731
+ func withIdentityToken<T: Sendable>(
732
+ _ token: String,
733
+ operation: @escaping @Sendable (LatchwayClient) async throws -> T
734
+ ) async throws -> T {
735
+ guard !token.isEmpty, token.utf8.count <= 65_536,
736
+ !token.unicodeScalars.contains(where: CharacterSet.controlCharacters.contains)
737
+ else { throw LatchwayError.invalidRequest("identity token is invalid") }
738
+ await operationLock.acquire()
739
+ do {
740
+ try Task.checkCancellation()
741
+ await identity.set(token)
742
+ let result = try await operation(client)
743
+ await identity.clear()
744
+ await operationLock.release()
745
+ return result
746
+ } catch {
747
+ await identity.clear()
748
+ await operationLock.release()
749
+ throw error
750
+ }
751
+ }
752
+ }
753
+
754
+ private final class NativeComponentContext: @unchecked Sendable {
755
+ private let client: LatchwayExtensionClient
756
+
757
+ init(configuration: NativeComponentConfiguration, component input: NativeComponentInput) throws {
758
+ guard isApplicationExtensionProcess() else {
759
+ throw LatchwayComponentError.invalidConfiguration(
760
+ "The React Native component client must run inside the signed iOS extension process"
761
+ )
762
+ }
763
+ let baseURL = try validatedNativeBaseURL(
764
+ configuration.baseURL,
765
+ allowInsecureLoopback: configuration.allowInsecureLoopback
766
+ )
767
+ let fallback: LatchwaySoftwareKeyFallbackPolicy = configuration.apple.softwareKeyFallbackPolicy == "allow"
768
+ ? .allowWhenSecureEnclaveUnavailable
769
+ : .disallow
770
+ let native = LatchwayConfiguration(
771
+ baseURL: baseURL,
772
+ applicationID: configuration.applicationID,
773
+ environment: configuration.environment,
774
+ rootKeychainAccessGroup: configuration.apple.rootKeychainAccessGroup,
775
+ legacySharedKeychainAccessGroups: configuration.apple.legacySharedKeychainAccessGroups,
776
+ identityProvider: "custom_jwt",
777
+ clientRuntime: .reactNativeIOS,
778
+ clientSDKVersion: configuration.sdkVersion,
779
+ appVersion: configuration.appVersion,
780
+ softwareKeyFallbackPolicy: fallback
781
+ )
782
+ let component = LatchwayComponentConfiguration(
783
+ definitionID: input.definitionID,
784
+ kind: input.kind,
785
+ keychainAccessGroup: input.keychainAccessGroup,
786
+ requestedFeatures: input.requestedFeatures
787
+ )
788
+ // DCAppAttestService.generateKey is unavailable to iOS application
789
+ // extensions. Use the delegated-only public initializer so the .appex
790
+ // cannot construct a direct App Attest provider or let its containing
791
+ // application attest on its behalf.
792
+ client = try LatchwayExtensionClient(
793
+ configuration: native,
794
+ component: component
795
+ )
796
+ }
797
+
798
+ func establishDirectAttestation() async throws {
799
+ try await client.establishDirectAttestation()
800
+ }
801
+
802
+ func diagnostics() async throws -> String {
803
+ let diagnostics = await client.diagnostics()
804
+ return try jsonString(componentDiagnosticsDictionary(diagnostics))
805
+ }
806
+ }
807
+
808
+ private func isApplicationExtensionProcess(bundle: Bundle = .main) -> Bool {
809
+ bundle.bundleURL.pathExtension == "appex"
810
+ && bundle.object(forInfoDictionaryKey: "NSExtension") != nil
811
+ }
812
+
813
+ private actor TransientIdentityTokenProvider: LatchwayIdentityTokenProvider {
814
+ private var token: String?
815
+
816
+ func set(_ value: String) { token = value }
817
+ func clear() { token = nil }
818
+
819
+ func identityToken() async throws -> String {
820
+ guard let token else { throw LatchwayError.sessionUnavailable }
821
+ return token
822
+ }
823
+ }
824
+
825
+ private actor NativeOperationLock {
826
+ private var held = false
827
+ private var waiters: [CheckedContinuation<Void, Never>] = []
828
+
829
+ func acquire() async {
830
+ if !held {
831
+ held = true
832
+ return
833
+ }
834
+ await withCheckedContinuation { waiters.append($0) }
835
+ }
836
+
837
+ func release() {
838
+ if waiters.isEmpty {
839
+ held = false
840
+ } else {
841
+ waiters.removeFirst().resume()
842
+ }
843
+ }
844
+ }
845
+
846
+ private actor NativeResponseRegistry {
847
+ private var responses: [String: NativeResponseHandle] = [:]
848
+
849
+ func insert(_ response: NativeResponseHandle, responseID: String) {
850
+ responses[responseID] = response
851
+ }
852
+
853
+ func read(responseID: String, maximumBytes: Int) async throws -> Data? {
854
+ guard let response = responses[responseID] else {
855
+ throw LatchwayError.invalidRequest("native response handle is unavailable")
856
+ }
857
+ let data = try await response.read(maximumBytes: maximumBytes)
858
+ if data == nil {
859
+ responses.removeValue(forKey: responseID)
860
+ await response.close()
861
+ }
862
+ return data
863
+ }
864
+
865
+ func close(responseID: String) async {
866
+ guard let response = responses.removeValue(forKey: responseID) else { return }
867
+ await response.close()
868
+ }
869
+
870
+ func closeAll() async {
871
+ let active = Array(responses.values)
872
+ responses.removeAll()
873
+ for response in active { await response.close() }
874
+ }
875
+ }
876
+
877
+ private actor NativeResponseHandle {
878
+ private let stream: LatchwayStreamingResponse
879
+ private var iterator: LatchwayAsyncBytes.AsyncIterator?
880
+ private var exhausted = false
881
+ private var closed = false
882
+
883
+ init(stream: LatchwayStreamingResponse) {
884
+ self.stream = stream
885
+ iterator = stream.bytes.makeAsyncIterator()
886
+ }
887
+
888
+ func read(maximumBytes: Int) async throws -> Data? {
889
+ if exhausted { return nil }
890
+ guard !closed, var activeIterator = iterator else {
891
+ throw LatchwayError.invalidRequest("native response handle is unavailable")
892
+ }
893
+ iterator = nil
894
+ var result = Data()
895
+ result.reserveCapacity(maximumBytes)
896
+ do {
897
+ while result.count < maximumBytes {
898
+ try Task.checkCancellation()
899
+ guard let byte = try await activeIterator.next() else {
900
+ exhausted = true
901
+ closed = true
902
+ iterator = nil
903
+ stream.finish()
904
+ return result.isEmpty ? nil : result
905
+ }
906
+ guard !closed else { throw LatchwayError.cancelled }
907
+ result.append(byte)
908
+ // A line boundary keeps SSE and NDJSON delivery incremental even
909
+ // when the bridge requests a larger maximum chunk.
910
+ if byte == 0x0A { break }
911
+ }
912
+ guard !closed else { throw LatchwayError.cancelled }
913
+ iterator = activeIterator
914
+ return result
915
+ } catch let error as LatchwayError {
916
+ closeNow()
917
+ throw error
918
+ } catch is CancellationError {
919
+ closeNow()
920
+ throw LatchwayError.cancelled
921
+ } catch let error as URLError where error.code == .cancelled {
922
+ closeNow()
923
+ throw LatchwayError.cancelled
924
+ } catch {
925
+ closeNow()
926
+ throw LatchwayError.transportFailure
927
+ }
928
+ }
929
+
930
+ func close() {
931
+ closeNow()
932
+ }
933
+
934
+ private func closeNow() {
935
+ guard !closed else { return }
936
+ closed = true
937
+ iterator = nil
938
+ stream.cancel()
939
+ }
940
+ }
941
+
942
+ struct NativeConfiguration: Decodable {
943
+ struct Apple: Decodable {
944
+ let appAttestEnabled: Bool
945
+ let rootKeychainAccessGroup: String
946
+ let legacySharedKeychainAccessGroups: [String]
947
+ let storageNamespace: String?
948
+ let softwareKeyFallbackPolicy: String
949
+ }
950
+
951
+ let baseURL: String
952
+ let applicationID: String
953
+ let environment: String
954
+ let identityProvider: String
955
+ let appVersion: String
956
+ let sdkVersion: String
957
+ let frameworkID: String
958
+ let frameworkVersion: String
959
+ let contractVersion: String
960
+ let protocolVersion: Int
961
+ let allowInsecureLoopback: Bool
962
+ let apple: Apple
963
+
964
+ static func decode(_ encoded: String) throws -> Self {
965
+ guard let data = encoded.data(using: .utf8), data.count <= 65_536,
966
+ let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
967
+ Set(object.keys) == nativeConfigurationKeys,
968
+ let apple = object["apple"] as? [String: Any],
969
+ nativeAppleConfigurationRequiredKeys.isSubset(of: apple.keys),
970
+ Set(apple.keys).isSubset(of: nativeAppleConfigurationKeys),
971
+ let android = object["android"] as? [String: Any],
972
+ nativeAndroidConfigurationRequiredKeys.isSubset(of: android.keys),
973
+ Set(android.keys).isSubset(of: nativeAndroidConfigurationKeys)
974
+ else { throw LatchwayError.invalidConfiguration("native configuration is invalid") }
975
+ let value = try decodeStrict(Self.self, encoded: encoded)
976
+ guard value.contractVersion == LatchwayVersion.contract,
977
+ value.protocolVersion == LatchwayVersion.protocolVersion,
978
+ value.frameworkID == reactNativeFrameworkID,
979
+ value.frameworkVersion == reactNativeFrameworkVersion,
980
+ value.apple.softwareKeyFallbackPolicy == "allow"
981
+ || value.apple.softwareKeyFallbackPolicy == "disallow"
982
+ else { throw LatchwayError.invalidConfiguration("contract version is incompatible") }
983
+ try LatchwayRootKeychainPreflight.validateAccessGroups(
984
+ rootKeychainAccessGroup: value.apple.rootKeychainAccessGroup,
985
+ legacySharedKeychainAccessGroups: value.apple.legacySharedKeychainAccessGroups
986
+ )
987
+ return value
988
+ }
989
+ }
990
+
991
+ private struct NativeComponentConfiguration: Decodable {
992
+ struct Apple: Decodable {
993
+ let rootKeychainAccessGroup: String
994
+ let legacySharedKeychainAccessGroups: [String]
995
+ let storageNamespace: String?
996
+ let softwareKeyFallbackPolicy: String
997
+ }
998
+
999
+ let baseURL: String
1000
+ let applicationID: String
1001
+ let environment: String
1002
+ let appVersion: String
1003
+ let sdkVersion: String
1004
+ let contractVersion: String
1005
+ let protocolVersion: Int
1006
+ let allowInsecureLoopback: Bool
1007
+ let apple: Apple
1008
+
1009
+ static func decode(_ encoded: String) throws -> Self {
1010
+ guard let data = encoded.data(using: .utf8), data.count <= 65_536,
1011
+ let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
1012
+ Set(object.keys) == nativeComponentConfigurationKeys,
1013
+ let apple = object["apple"] as? [String: Any],
1014
+ nativeComponentAppleConfigurationRequiredKeys.isSubset(of: apple.keys),
1015
+ Set(apple.keys).isSubset(of: nativeComponentAppleConfigurationKeys)
1016
+ else { throw LatchwayError.invalidConfiguration("native component configuration is invalid") }
1017
+ let value = try decodeStrict(Self.self, encoded: encoded)
1018
+ guard value.contractVersion == LatchwayVersion.contract,
1019
+ value.protocolVersion == LatchwayVersion.protocolVersion,
1020
+ value.apple.softwareKeyFallbackPolicy == "allow"
1021
+ || value.apple.softwareKeyFallbackPolicy == "disallow"
1022
+ else { throw LatchwayError.invalidConfiguration("component contract version is incompatible") }
1023
+ try LatchwayRootKeychainPreflight.validateAccessGroups(
1024
+ rootKeychainAccessGroup: value.apple.rootKeychainAccessGroup,
1025
+ legacySharedKeychainAccessGroups: value.apple.legacySharedKeychainAccessGroups
1026
+ )
1027
+ return value
1028
+ }
1029
+ }
1030
+
1031
+ private struct NativeComponentInput: Decodable, Equatable {
1032
+ let definitionID: String
1033
+ let kind: String
1034
+ let keychainAccessGroup: String
1035
+ let requestedFeatures: [String]
1036
+
1037
+ static func decode(_ encoded: String) throws -> Self {
1038
+ guard let data = encoded.data(using: .utf8), data.count <= 65_536,
1039
+ let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
1040
+ Set(object.keys) == Set(["definitionID", "kind", "keychainAccessGroup", "requestedFeatures"])
1041
+ else { throw LatchwayError.invalidRequest("native component descriptor is invalid") }
1042
+ let value = try decodeStrict(Self.self, encoded: encoded)
1043
+ guard matches(value.definitionID, pattern: "^[a-z][a-z0-9_-]{0,62}$"),
1044
+ reactNativeDirectAttestationComponentKinds.contains(value.kind),
1045
+ !value.requestedFeatures.isEmpty,
1046
+ value.requestedFeatures.count <= 256,
1047
+ Set(value.requestedFeatures).count == value.requestedFeatures.count,
1048
+ value.requestedFeatures.allSatisfy(validFeature)
1049
+ else { throw LatchwayError.invalidRequest("native component descriptor is invalid") }
1050
+ try LatchwayRootKeychainPreflight.validateAccessGroups(
1051
+ rootKeychainAccessGroup: value.keychainAccessGroup
1052
+ )
1053
+ return value
1054
+ }
1055
+ }
1056
+
1057
+ private struct NativeHostComponentInput: Decodable, Equatable {
1058
+ let definitionID: String
1059
+ let kind: String
1060
+ let keychainAccessGroup: String
1061
+ let requestedFeatures: [String]
1062
+
1063
+ var configuration: LatchwayComponentConfiguration {
1064
+ LatchwayComponentConfiguration(
1065
+ definitionID: definitionID,
1066
+ kind: kind,
1067
+ keychainAccessGroup: keychainAccessGroup,
1068
+ requestedFeatures: requestedFeatures
1069
+ )
1070
+ }
1071
+
1072
+ static func decodeOne(
1073
+ _ encoded: String,
1074
+ sharedKeychainAccessGroups: Set<String>
1075
+ ) throws -> Self {
1076
+ guard let data = encoded.data(using: .utf8), data.count <= 65_536,
1077
+ let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
1078
+ else { throw LatchwayError.invalidRequest("native component descriptor is invalid") }
1079
+ try validateKeys(object)
1080
+ let value = try decodeStrict(Self.self, encoded: encoded)
1081
+ try validate(value, sharedKeychainAccessGroups: sharedKeychainAccessGroups)
1082
+ return value
1083
+ }
1084
+
1085
+ static func decodeMany(
1086
+ _ encoded: String,
1087
+ sharedKeychainAccessGroups: Set<String>
1088
+ ) throws -> [Self] {
1089
+ guard let data = encoded.data(using: .utf8), data.count <= 65_536,
1090
+ let objects = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]],
1091
+ !objects.isEmpty, objects.count <= 256
1092
+ else { throw LatchwayError.invalidRequest("native component descriptors are invalid") }
1093
+ try objects.forEach(validateKeys)
1094
+ let values = try decodeStrict([Self].self, encoded: encoded)
1095
+ try values.forEach { try validate($0, sharedKeychainAccessGroups: sharedKeychainAccessGroups) }
1096
+ guard Set(values.map(\.definitionID)).count == values.count else {
1097
+ throw LatchwayError.invalidRequest("native component definition IDs must be unique")
1098
+ }
1099
+ return values
1100
+ }
1101
+
1102
+ private static func validateKeys(_ object: [String: Any]) throws {
1103
+ guard Set(object.keys) == Set(["definitionID", "kind", "keychainAccessGroup", "requestedFeatures"])
1104
+ else { throw LatchwayError.invalidRequest("native component descriptor is invalid") }
1105
+ }
1106
+
1107
+ private static func validate(
1108
+ _ value: Self,
1109
+ sharedKeychainAccessGroups: Set<String>
1110
+ ) throws {
1111
+ guard matches(value.definitionID, pattern: "^[a-z][a-z0-9_-]{0,62}$"),
1112
+ nativeIOSComponentKinds.contains(value.kind),
1113
+ sharedKeychainAccessGroups.contains(value.keychainAccessGroup),
1114
+ !value.requestedFeatures.isEmpty,
1115
+ value.requestedFeatures.count <= 256,
1116
+ Set(value.requestedFeatures).count == value.requestedFeatures.count,
1117
+ value.requestedFeatures.allSatisfy(validFeature)
1118
+ else { throw LatchwayError.invalidRequest("native component descriptor is invalid") }
1119
+ try LatchwayRootKeychainPreflight.validateAccessGroups(
1120
+ rootKeychainAccessGroup: value.keychainAccessGroup
1121
+ )
1122
+ }
1123
+ }
1124
+
1125
+ private struct NativeRequestInput: Decodable {
1126
+ let url: String
1127
+ let method: String
1128
+ let feature: String
1129
+ let headers: [[String]]
1130
+ let bodyBase64: String?
1131
+
1132
+ var body: Data? {
1133
+ guard let bodyBase64 else { return nil }
1134
+ return Data(base64Encoded: bodyBase64)
1135
+ }
1136
+
1137
+ static func decode(_ encoded: String) throws -> Self {
1138
+ guard let data = encoded.data(using: .utf8), data.count <= maximumNativeRequestBytes,
1139
+ let object = try? JSONSerialization.jsonObject(with: data) as? [String: Any]
1140
+ else { throw LatchwayError.invalidRequest("native request is invalid") }
1141
+ let required = Set(["url", "method", "feature", "headers"])
1142
+ let allowed = required.union(["bodyBase64"])
1143
+ guard required.isSubset(of: object.keys), Set(object.keys).isSubset(of: allowed) else {
1144
+ throw LatchwayError.invalidRequest("native request is invalid")
1145
+ }
1146
+ let value = try decodeStrict(Self.self, encoded: encoded, maximumBytes: maximumNativeRequestBytes)
1147
+ guard validMethod(value.method), validFeature(value.feature), value.headers.count <= maximumHeaders else {
1148
+ throw LatchwayError.invalidRequest("native request is invalid")
1149
+ }
1150
+ var headerBytes = 0
1151
+ for pair in value.headers {
1152
+ guard pair.count == 2 else { throw LatchwayError.invalidRequest("native request header is invalid") }
1153
+ let name = pair[0].lowercased()
1154
+ let headerValue = pair[1]
1155
+ guard validHeaderName(name), validHeaderValue(headerValue), !isForbiddenCredentialName(name) else {
1156
+ throw LatchwayError.invalidRequest("native request header is invalid")
1157
+ }
1158
+ headerBytes += name.utf8.count + headerValue.utf8.count
1159
+ guard headerBytes <= maximumHeaderBytes else {
1160
+ throw LatchwayError.invalidRequest("native request headers are too large")
1161
+ }
1162
+ }
1163
+ if let encodedBody = value.bodyBase64 {
1164
+ guard let body = Data(base64Encoded: encodedBody), body.count <= maximumRequestBodyBytes,
1165
+ body.base64EncodedString() == encodedBody
1166
+ else { throw LatchwayError.invalidRequest("native request body is invalid") }
1167
+ }
1168
+ return value
1169
+ }
1170
+ }
1171
+
1172
+ private func responseMetadata(responseID: String, response: HTTPURLResponse) throws -> String {
1173
+ var headers: [[String]] = []
1174
+ var headerBytes = 0
1175
+ for (rawName, rawValue) in response.allHeaderFields {
1176
+ guard let name = (rawName as? String)?.lowercased(), safeResponseHeaders.contains(name),
1177
+ let value = rawValue as? String
1178
+ else { continue }
1179
+ guard validHeaderValue(value) else { throw LatchwayError.invalidServerResponse }
1180
+ headerBytes += name.utf8.count + value.utf8.count
1181
+ guard headers.count < maximumHeaders, headerBytes <= maximumHeaderBytes else {
1182
+ throw LatchwayError.invalidServerResponse
1183
+ }
1184
+ headers.append([name, value])
1185
+ }
1186
+ return try jsonString([
1187
+ "responseID": responseID,
1188
+ "status": response.statusCode,
1189
+ "statusText": "",
1190
+ "headers": headers,
1191
+ ])
1192
+ }
1193
+
1194
+ private func validatedNativeBaseURL(_ encoded: String, allowInsecureLoopback: Bool) throws -> URL {
1195
+ guard let url = URL(string: encoded),
1196
+ let components = URLComponents(url: url, resolvingAgainstBaseURL: false),
1197
+ components.user == nil, components.password == nil,
1198
+ components.percentEncodedPath == "/", components.percentEncodedQuery == nil,
1199
+ components.fragment == nil, let scheme = components.scheme?.lowercased(),
1200
+ let host = components.host?.lowercased()
1201
+ else { throw LatchwayError.invalidConfiguration("base URL is invalid") }
1202
+ let loopback = host == "localhost" || host == "127.0.0.1" || host == "::1"
1203
+ guard scheme == "https" || (allowInsecureLoopback && loopback && scheme == "http") else {
1204
+ throw LatchwayError.invalidConfiguration("base URL transport is invalid")
1205
+ }
1206
+ return url
1207
+ }
1208
+
1209
+ private func validateTarget(baseURL: URL, target: URL, method: String, feature: String) throws {
1210
+ guard targetHasSameAllowedOriginAndPath(
1211
+ baseURL: baseURL,
1212
+ target: target,
1213
+ method: method,
1214
+ feature: feature
1215
+ ) else {
1216
+ throw LatchwayError.invalidRequest("request destination is not an allowed Latchway data-plane URL")
1217
+ }
1218
+ guard let query = URLComponents(url: target, resolvingAgainstBaseURL: false)?.percentEncodedQuery else { return }
1219
+ for component in query.split(separator: "&", omittingEmptySubsequences: false) {
1220
+ let name = component.split(separator: "=", maxSplits: 1, omittingEmptySubsequences: false).first.map(String.init) ?? ""
1221
+ if isForbiddenCredentialName(decodedCredentialName(name)) {
1222
+ throw LatchwayError.invalidRequest("upstream provider credentials must not be supplied in the request URL")
1223
+ }
1224
+ }
1225
+ }
1226
+
1227
+ func targetHasSameAllowedOriginAndPath(
1228
+ baseURL: URL,
1229
+ target: URL,
1230
+ method: String,
1231
+ feature: String
1232
+ ) -> Bool {
1233
+ guard let base = URLComponents(url: baseURL, resolvingAgainstBaseURL: false),
1234
+ let candidate = URLComponents(url: target, resolvingAgainstBaseURL: false),
1235
+ let baseScheme = base.scheme?.lowercased(), let candidateScheme = candidate.scheme?.lowercased(),
1236
+ let baseHost = base.host?.lowercased(), let candidateHost = candidate.host?.lowercased(),
1237
+ base.user == nil, base.password == nil, base.fragment == nil,
1238
+ candidate.user == nil, candidate.password == nil, candidate.fragment == nil,
1239
+ baseScheme == candidateScheme, baseHost == candidateHost,
1240
+ effectivePort(base) == effectivePort(candidate)
1241
+ else { return false }
1242
+ let normalizedMethod = method.uppercased()
1243
+ if normalizedMethod == "POST", allowedDataPlanePaths.contains(candidate.percentEncodedPath) {
1244
+ return true
1245
+ }
1246
+ let prefix = "/proxy/\(feature)/"
1247
+ guard opaqueDataPlaneMethods.contains(normalizedMethod), candidate.percentEncodedQuery == nil,
1248
+ candidate.percentEncodedPath.hasPrefix(prefix)
1249
+ else { return false }
1250
+ let remaining = String(candidate.percentEncodedPath.dropFirst(prefix.count))
1251
+ let lowerRemaining = remaining.lowercased()
1252
+ return (1 ... 2_048).contains(remaining.utf8.count)
1253
+ && remaining.split(separator: "/", omittingEmptySubsequences: false).allSatisfy {
1254
+ !$0.isEmpty && $0 != "." && $0 != ".."
1255
+ }
1256
+ && !lowerRemaining.contains("%2e")
1257
+ && !lowerRemaining.contains("%2f")
1258
+ && !lowerRemaining.contains("%5c")
1259
+ && !remaining.contains("\\")
1260
+ && !lowerRemaining.hasPrefix("http:")
1261
+ && !lowerRemaining.hasPrefix("https:")
1262
+ }
1263
+
1264
+ private func effectivePort(_ components: URLComponents) -> Int? {
1265
+ if let port = components.port { return port }
1266
+ switch components.scheme?.lowercased() {
1267
+ case "https": return 443
1268
+ case "http": return 80
1269
+ default: return nil
1270
+ }
1271
+ }
1272
+
1273
+ private func decodedCredentialName(_ value: String) -> String {
1274
+ var decoded = value
1275
+ for _ in 0 ..< 4 {
1276
+ guard let next = decoded.removingPercentEncoding, next != decoded else { break }
1277
+ decoded = next
1278
+ }
1279
+ if decoded.range(of: #"%[0-9A-Fa-f]{2}"#, options: .regularExpression) != nil {
1280
+ return "credential-encoded-name"
1281
+ }
1282
+ return decoded.lowercased()
1283
+ }
1284
+
1285
+ private func isForbiddenCredentialName(_ value: String) -> Bool {
1286
+ let normalized = value.lowercased()
1287
+ if forbiddenRequestHeaders.contains(normalized) || forbiddenCredentialQueryNames.contains(normalized) { return true }
1288
+ let compact = normalized.replacingOccurrences(
1289
+ of: #"[^a-z0-9]"#,
1290
+ with: "",
1291
+ options: .regularExpression
1292
+ )
1293
+ if ["key", "token", "secret", "bearer", "cookie", "password", "passwd"].contains(compact) { return true }
1294
+ return forbiddenCredentialNameFragments.contains { compact.contains($0) }
1295
+ }
1296
+
1297
+ private func validMethod(_ value: String) -> Bool {
1298
+ matches(value, pattern: #"^[A-Z][A-Z0-9!#$%&'*+.^_`|~-]{0,31}$"#)
1299
+ && !forbiddenMethods.contains(value)
1300
+ }
1301
+
1302
+ private func validFeature(_ value: String) -> Bool {
1303
+ matches(value, pattern: #"^[a-z][a-z0-9_-]{0,62}$"#)
1304
+ }
1305
+
1306
+ private func validHeaderName(_ value: String) -> Bool {
1307
+ matches(value, pattern: #"^[!#$%&'*+.^_`|~0-9A-Za-z-]{1,128}$"#)
1308
+ }
1309
+
1310
+ private func validHeaderValue(_ value: String) -> Bool {
1311
+ guard value.utf8.count <= maximumHeaderValueBytes else { return false }
1312
+ return !value.unicodeScalars.contains { scalar in
1313
+ (0x00 ... 0x08).contains(scalar.value)
1314
+ || (0x0A ... 0x1F).contains(scalar.value)
1315
+ || scalar.value == 0x7F
1316
+ }
1317
+ }
1318
+
1319
+ private func matches(_ value: String, pattern: String) -> Bool {
1320
+ value.range(of: pattern, options: .regularExpression) != nil
1321
+ }
1322
+
1323
+ private let maximumRequestBodyBytes = 8 * 1024 * 1024
1324
+ private let maximumNativeRequestBytes = 12 * 1024 * 1024
1325
+ private let maximumResponseChunkBytes = 32 * 1024
1326
+ private let maximumHeaders = 128
1327
+ private let maximumHeaderBytes = 128 * 1024
1328
+ private let maximumHeaderValueBytes = 8 * 1024
1329
+ private let nativeConfigurationKeys = Set([
1330
+ "baseURL", "applicationID", "environment", "identityProvider", "appVersion", "sdkVersion",
1331
+ "frameworkID", "frameworkVersion", "contractVersion", "protocolVersion",
1332
+ "allowInsecureLoopback", "apple", "android",
1333
+ ])
1334
+ private let reactNativeFrameworkID = "react-native-fetch"
1335
+ private let reactNativeFrameworkVersion = "0.82.0"
1336
+ private let nativeAppleConfigurationRequiredKeys = Set([
1337
+ "appAttestEnabled", "rootKeychainAccessGroup", "legacySharedKeychainAccessGroups",
1338
+ "softwareKeyFallbackPolicy",
1339
+ ])
1340
+ private let nativeAppleConfigurationKeys = nativeAppleConfigurationRequiredKeys.union(["storageNamespace"])
1341
+ private let nativeAndroidConfigurationRequiredKeys = Set(["keyPolicy"])
1342
+ private let nativeAndroidConfigurationKeys = nativeAndroidConfigurationRequiredKeys.union([
1343
+ "playIntegrityCloudProjectNumber",
1344
+ ])
1345
+ private let nativeComponentConfigurationKeys = Set([
1346
+ "baseURL", "applicationID", "environment", "appVersion", "sdkVersion", "contractVersion",
1347
+ "protocolVersion", "allowInsecureLoopback", "apple",
1348
+ ])
1349
+ private let nativeComponentAppleConfigurationRequiredKeys = Set([
1350
+ "rootKeychainAccessGroup", "legacySharedKeychainAccessGroups", "softwareKeyFallbackPolicy",
1351
+ ])
1352
+ private let nativeComponentAppleConfigurationKeys = nativeComponentAppleConfigurationRequiredKeys.union([
1353
+ "storageNamespace",
1354
+ ])
1355
+ private let forbiddenMethods = Set(["CONNECT", "TRACE", "TRACK"])
1356
+ private let allowedDataPlanePaths = Set([
1357
+ "/v1/responses",
1358
+ "/v1/chat/completions",
1359
+ "/v1/embeddings",
1360
+ "/v1/messages",
1361
+ ])
1362
+ private let opaqueDataPlaneMethods = Set(["GET", "POST", "PUT", "PATCH", "DELETE"])
1363
+ private let reactNativeDirectAttestationComponentKinds = Set(["action_extension", "sso_extension"])
1364
+ private let nativeIOSComponentKinds = Set([
1365
+ "widget", "share_extension", "app_intent_extension", "notification_service_extension",
1366
+ "action_extension", "sso_extension",
1367
+ ])
1368
+ private let forbiddenRequestHeaders = Set([
1369
+ "authorization", "proxy-authorization", "api-key", "api_key", "apikey", "x-api-key",
1370
+ "openai-api-key", "openai_api_key", "x-openai-api-key", "anthropic-api-key", "anthropic_api_key",
1371
+ "x-goog-api-key", "x-goog_api_key", "access_token", "auth_token", "x-auth-token", "cookie", "connection",
1372
+ "content-length", "expect", "host", "key", "proxy-connection", "te", "trailer", "transfer-encoding",
1373
+ "token", "upgrade", "x-amz-credential", "x-amz-security-token", "x-amz-signature", "x-goog-credential",
1374
+ "x-goog-signature", "dpop", "dpop-nonce", "x-latchway-feature", "x-latchway-framework",
1375
+ "x-latchway-framework-version", "x-latchway-protocol-version", "x-latchway-request-id", "x-latchway-sdk",
1376
+ "x-latchway-sdk-version",
1377
+ ])
1378
+ private let forbiddenCredentialQueryNames = forbiddenRequestHeaders.union([
1379
+ "refresh_token", "identity_token", "private_key", "client_data_hash", "request_hash", "integrity_token",
1380
+ ])
1381
+ private let forbiddenCredentialNameFragments = [
1382
+ "authorization", "dpop", "apikey", "accesstoken", "authtoken", "refreshtoken", "identitytoken",
1383
+ "integritytoken", "sessiontoken", "privatekey", "clientsecret", "credential", "attestationevidence",
1384
+ "clientdatahash", "requesthash", "xamzsignature", "xgoogsignature",
1385
+ ]
1386
+ private let safeResponseHeaders = Set([
1387
+ "accept-ranges", "age", "cache-control", "content-encoding", "content-language", "content-length",
1388
+ "content-range", "content-type", "date", "etag", "expires", "last-modified", "request-id", "retry-after",
1389
+ "server-timing", "vary", "x-request-id", "x-latchway-request-id", "x-latchway-server-version",
1390
+ "x-latchway-operation-id",
1391
+ ])
1392
+
1393
+ private struct NativeFailure {
1394
+ let code: String
1395
+ let documentationURL: String
1396
+ let message: String
1397
+ let requestID: String?
1398
+ let operationID: String?
1399
+ let status: Int?
1400
+ let retryable: Bool
1401
+
1402
+ init(_ error: Error) {
1403
+ if let error = error as? LatchwayComponentError {
1404
+ switch error {
1405
+ case .containingAppSetupRequired:
1406
+ code = "containing_app_setup_required"; message = "The containing application must prepare this component."
1407
+ requestID = nil; operationID = nil; status = nil; retryable = false
1408
+ case .componentNotProvisioned:
1409
+ code = "component_not_provisioned"; message = "The component has not been provisioned."
1410
+ requestID = nil; operationID = nil; status = nil; retryable = false
1411
+ case .componentGrantExpired:
1412
+ code = "component_delegation_expired"; message = "The component delegation has expired."
1413
+ requestID = nil; operationID = nil; status = nil; retryable = false
1414
+ case .componentRevoked:
1415
+ code = "component_revoked"; message = "The component has been revoked."
1416
+ requestID = nil; operationID = nil; status = nil; retryable = false
1417
+ case .installationFamilyRevoked:
1418
+ code = "installation_family_revoked"; message = "The installation family has been revoked."
1419
+ requestID = nil; operationID = nil; status = nil; retryable = false
1420
+ case .parentTrustExpired:
1421
+ code = "component_parent_trust_expired"; message = "The component parent trust has expired."
1422
+ requestID = nil; operationID = nil; status = nil; retryable = false
1423
+ case .featureNotDelegated:
1424
+ code = "component_feature_not_granted"; message = "The feature was not delegated to this component."
1425
+ requestID = nil; operationID = nil; status = nil; retryable = false
1426
+ case .keychainAccessGroupUnavailable:
1427
+ code = "secure_state_unavailable"; message = "The component Keychain access group is unavailable."
1428
+ requestID = nil; operationID = nil; status = nil; retryable = false
1429
+ case .componentKeyUnavailable:
1430
+ code = "key_unavailable"; message = "The component key is unavailable."
1431
+ requestID = nil; operationID = nil; status = nil; retryable = false
1432
+ case .identityChanged:
1433
+ code = "identity_reauthentication_required"; message = "The application identity must be reauthenticated."
1434
+ requestID = nil; operationID = nil; status = nil; retryable = false
1435
+ case .directAttestationRequired:
1436
+ code = "component_direct_attestation_required"; message = "Direct component attestation is required."
1437
+ requestID = nil; operationID = nil; status = nil; retryable = false
1438
+ case .invalidConfiguration:
1439
+ code = "invalid_configuration"; message = "The native component configuration is invalid."
1440
+ requestID = nil; operationID = nil; status = nil; retryable = false
1441
+ case let .latchway(underlying):
1442
+ let failure = NativeFailure(underlying)
1443
+ code = failure.code; message = failure.message
1444
+ requestID = failure.requestID; operationID = failure.operationID
1445
+ status = failure.status; retryable = failure.retryable
1446
+ }
1447
+ } else if let error = error as? LatchwayError {
1448
+ switch error {
1449
+ case let .server(problem):
1450
+ code = problem.code.description
1451
+ message = "The Latchway gateway rejected the request."
1452
+ requestID = problem.requestID
1453
+ operationID = problem.operationID
1454
+ status = problem.status
1455
+ retryable = problem.retryable
1456
+ case .invalidConfiguration:
1457
+ code = "invalid_configuration"; message = "Latchway native configuration is invalid."
1458
+ requestID = nil; operationID = nil; status = nil; retryable = false
1459
+ case .invalidRequest:
1460
+ code = "request_invalid"; message = "The native Latchway request is invalid."
1461
+ requestID = nil; operationID = nil; status = nil; retryable = false
1462
+ case .secureEnclaveUnavailable:
1463
+ code = "key_unavailable"; message = "Required hardware-backed key storage is unavailable."
1464
+ requestID = nil; operationID = nil; status = nil; retryable = false
1465
+ case .keyStorageFailure:
1466
+ code = "secure_state_unavailable"; message = "Secure Latchway state is unavailable."
1467
+ requestID = nil; operationID = nil; status = nil; retryable = false
1468
+ case .rootKeychainMigrationRequired:
1469
+ code = "secure_state_unavailable"; message = "Legacy root Keychain state requires explicit migration."
1470
+ requestID = nil; operationID = nil; status = nil; retryable = false
1471
+ case .attestationUnavailable:
1472
+ code = "attestation_unsupported"; message = "Required application attestation is unavailable."
1473
+ requestID = nil; operationID = nil; status = nil; retryable = false
1474
+ case .invalidAttestationBinding:
1475
+ code = "attestation_invalid"; message = "The attestation challenge binding is invalid."
1476
+ requestID = nil; operationID = nil; status = nil; retryable = false
1477
+ case .sessionUnavailable:
1478
+ code = "session_unavailable"; message = "A Latchway session is unavailable."
1479
+ requestID = nil; operationID = nil; status = nil; retryable = true
1480
+ case .transportFailure:
1481
+ code = "network_unavailable"; message = "The Latchway control request failed."
1482
+ requestID = nil; operationID = nil; status = nil; retryable = true
1483
+ case .invalidServerResponse:
1484
+ code = "response_invalid"; message = "Latchway returned an invalid native response."
1485
+ requestID = nil; operationID = nil; status = nil; retryable = false
1486
+ case .cancelled:
1487
+ code = "cancelled"; message = "The Latchway native operation was cancelled."
1488
+ requestID = nil; operationID = nil; status = nil; retryable = false
1489
+ }
1490
+ } else if error is CancellationError {
1491
+ code = "cancelled"; message = "The Latchway native operation was cancelled."
1492
+ requestID = nil; operationID = nil; status = nil; retryable = false
1493
+ } else {
1494
+ code = "internal_error"; message = "The Latchway native operation failed."
1495
+ requestID = nil; operationID = nil; status = nil; retryable = false
1496
+ }
1497
+ documentationURL = "https://docs.latchway.dev/errors/\(code.replacingOccurrences(of: "_", with: "-"))"
1498
+ }
1499
+ }
1500
+
1501
+ private func decodeStrict<T: Decodable>(
1502
+ _ type: T.Type,
1503
+ encoded: String,
1504
+ maximumBytes: Int = 65_536
1505
+ ) throws -> T {
1506
+ guard let data = encoded.data(using: .utf8), data.count <= maximumBytes else {
1507
+ throw LatchwayError.invalidRequest("native input is invalid")
1508
+ }
1509
+ do { return try JSONDecoder().decode(type, from: data) }
1510
+ catch { throw LatchwayError.invalidRequest("native input is invalid") }
1511
+ }
1512
+
1513
+ private func jsonString(_ value: [String: Any]) throws -> String {
1514
+ let compacted = compact(value)
1515
+ guard JSONSerialization.isValidJSONObject(compacted) else { throw LatchwayError.invalidServerResponse }
1516
+ return String(decoding: try JSONSerialization.data(withJSONObject: compacted, options: [.sortedKeys]), as: UTF8.self)
1517
+ }
1518
+
1519
+ private func componentDiagnosticsDictionary(
1520
+ _ diagnostics: LatchwayComponentDiagnostics
1521
+ ) -> [String: Any] {
1522
+ compact([
1523
+ "familyID": diagnostics.familyID as Any,
1524
+ "componentID": diagnostics.componentID as Any,
1525
+ "definitionID": diagnostics.definitionID,
1526
+ "keychainAccessGroup": diagnostics.keychainAccessGroup,
1527
+ "keyAvailable": diagnostics.keyAvailable,
1528
+ "keyStorage": diagnostics.keyStorage.rawValue,
1529
+ "grantAvailable": diagnostics.grantAvailable,
1530
+ "sessionAvailable": diagnostics.sessionAvailable,
1531
+ "trustSource": diagnostics.trustSource?.rawValue as Any,
1532
+ "trustExpiresAt": diagnostics.trustExpiresAt.map(iso8601) as Any,
1533
+ "containingAppActionRequired": diagnostics.containingAppActionRequired,
1534
+ ])
1535
+ }
1536
+
1537
+ private func compact(_ value: [String: Any]) -> [String: Any] {
1538
+ value.compactMapValues { item in
1539
+ if let optional = item as? OptionalProtocol { return optional.wrapped }
1540
+ return item
1541
+ }
1542
+ }
1543
+
1544
+ private protocol OptionalProtocol { var wrapped: Any? { get } }
1545
+ extension Optional: OptionalProtocol { fileprivate var wrapped: Any? { self } }
1546
+
1547
+ private func iso8601(_ date: Date) -> String {
1548
+ let formatter = ISO8601DateFormatter()
1549
+ formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
1550
+ return formatter.string(from: date)
1551
+ }