@latchway/react-native 0.0.0-bootstrap.0 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +122 -0
- package/LatchwayReactNative.podspec +33 -0
- package/NOTICE +7 -0
- package/README.md +293 -3
- package/SECURITY.md +69 -0
- package/android/build.gradle.kts +62 -0
- package/android/consumer-rules.pro +2 -0
- package/android/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/android/gradle/wrapper/gradle-wrapper.properties +9 -0
- package/android/gradle.properties +4 -0
- package/android/gradlew +251 -0
- package/android/gradlew.bat +94 -0
- package/android/settings.gradle.kts +33 -0
- package/android/src/main/AndroidManifest.xml +3 -0
- package/android/src/main/java/dev/latchway/reactnative/LatchwayReactNativePackage.kt +25 -0
- package/android/src/main/java/dev/latchway/reactnative/NativeLatchwayModule.kt +964 -0
- package/android/src/test/java/dev/latchway/reactnative/NativeLatchwayModuleTest.kt +521 -0
- package/babel.cjs +22 -0
- package/babel.d.cts +7 -0
- package/contract.lock +7 -0
- package/docs/architecture.md +145 -0
- package/docs/conformance.md +68 -0
- package/docs/langchain.md +135 -0
- package/docs/native-installation.md +172 -0
- package/docs/physical-device-evidence.md +559 -0
- package/docs/releasing.md +360 -0
- package/docs/security.md +55 -0
- package/ios/LatchwayNativeBridge.swift +1551 -0
- package/ios/RCTNativeLatchway.h +9 -0
- package/ios/RCTNativeLatchway.mm +175 -0
- package/lib/client.d.ts +29 -0
- package/lib/client.d.ts.map +1 -0
- package/lib/client.js +939 -0
- package/lib/client.js.map +1 -0
- package/lib/component-client.d.ts +15 -0
- package/lib/component-client.d.ts.map +1 -0
- package/lib/component-client.js +141 -0
- package/lib/component-client.js.map +1 -0
- package/lib/config.d.ts +25 -0
- package/lib/config.d.ts.map +1 -0
- package/lib/config.js +261 -0
- package/lib/config.js.map +1 -0
- package/lib/coordinator.d.ts +19 -0
- package/lib/coordinator.d.ts.map +1 -0
- package/lib/coordinator.js +167 -0
- package/lib/coordinator.js.map +1 -0
- package/lib/errors.d.ts +5 -0
- package/lib/errors.d.ts.map +1 -0
- package/lib/errors.js +201 -0
- package/lib/errors.js.map +1 -0
- package/lib/index.d.ts +8 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +13 -0
- package/lib/index.js.map +1 -0
- package/lib/native/NativeLatchway.d.ts +25 -0
- package/lib/native/NativeLatchway.d.ts.map +1 -0
- package/lib/native/NativeLatchway.js +3 -0
- package/lib/native/NativeLatchway.js.map +1 -0
- package/lib/native/bridge.d.ts +5 -0
- package/lib/native/bridge.d.ts.map +1 -0
- package/lib/native/bridge.js +17 -0
- package/lib/native/bridge.js.map +1 -0
- package/lib/native-output.d.ts +3 -0
- package/lib/native-output.d.ts.map +1 -0
- package/lib/native-output.js +43 -0
- package/lib/native-output.js.map +1 -0
- package/lib/polyfills.d.ts +8 -0
- package/lib/polyfills.d.ts.map +1 -0
- package/lib/polyfills.js +56 -0
- package/lib/polyfills.js.map +1 -0
- package/lib/request-id.d.ts +2 -0
- package/lib/request-id.d.ts.map +1 -0
- package/lib/request-id.js +5 -0
- package/lib/request-id.js.map +1 -0
- package/lib/runtime-symbols.d.ts +2 -0
- package/lib/runtime-symbols.d.ts.map +1 -0
- package/lib/runtime-symbols.js +9 -0
- package/lib/runtime-symbols.js.map +1 -0
- package/lib/testing.d.ts +7 -0
- package/lib/testing.d.ts.map +1 -0
- package/lib/testing.js +9 -0
- package/lib/testing.js.map +1 -0
- package/lib/types.d.ts +200 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +2 -0
- package/lib/types.js.map +1 -0
- package/lib/version.d.ts +8 -0
- package/lib/version.d.ts.map +1 -0
- package/lib/version.js +8 -0
- package/lib/version.js.map +1 -0
- package/package.json +152 -6
- package/react-native.config.cjs +7 -0
- package/release-compatibility.json +64 -0
- package/src/client.ts +1022 -0
- package/src/component-client.ts +158 -0
- package/src/config.ts +368 -0
- package/src/coordinator.ts +195 -0
- package/src/errors.ts +225 -0
- package/src/index.ts +53 -0
- package/src/native/NativeLatchway.ts +75 -0
- package/src/native/bridge.ts +23 -0
- package/src/native-output.ts +43 -0
- package/src/polyfills.ts +50 -0
- package/src/request-id.ts +5 -0
- package/src/runtime-symbols.ts +9 -0
- package/src/testing.ts +11 -0
- package/src/types.ts +241 -0
- 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
|
+
}
|