@onramper/onramper-react-native 1.2.2-1 → 1.3.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 +193 -0
- package/README.md +45 -3
- package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64/OnramperSDK.framework/Modules/OnramperSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
- package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64/OnramperSDK.framework/Modules/OnramperSDK.swiftmodule/arm64-apple-ios.swiftinterface +107 -4
- package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64/OnramperSDK.framework/OnramperSDK +0 -0
- package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64/dSYMs/OnramperSDK.framework.dSYM/Contents/Resources/DWARF/OnramperSDK +0 -0
- package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64/dSYMs/OnramperSDK.framework.dSYM/Contents/Resources/Relocations/aarch64/OnramperSDK.yml +3560 -2684
- package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64_x86_64-simulator/OnramperSDK.framework/Modules/OnramperSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
- package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64_x86_64-simulator/OnramperSDK.framework/Modules/OnramperSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +107 -4
- package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64_x86_64-simulator/OnramperSDK.framework/Modules/OnramperSDK.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
- package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64_x86_64-simulator/OnramperSDK.framework/Modules/OnramperSDK.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +107 -4
- package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64_x86_64-simulator/OnramperSDK.framework/OnramperSDK +0 -0
- package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64_x86_64-simulator/dSYMs/OnramperSDK.framework.dSYM/Contents/Resources/DWARF/OnramperSDK +0 -0
- package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64_x86_64-simulator/dSYMs/OnramperSDK.framework.dSYM/Contents/Resources/Relocations/aarch64/OnramperSDK.yml +3560 -2684
- package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64_x86_64-simulator/dSYMs/OnramperSDK.framework.dSYM/Contents/Resources/Relocations/x86_64/OnramperSDK.yml +3523 -2651
- package/ios/HybridOnramperCheckoutButton.swift +1 -1
- package/ios/HybridOnramperNitro.swift +140 -78
- package/ios/OnramperSDKBridge.swift +19 -2
- package/ios/PreparedIntentRegistry.swift +124 -6
- package/ios/SDKLogRelay.swift +41 -0
- package/lib/commonjs/OnramperClient.js +66 -5
- package/lib/commonjs/OnramperClient.js.map +1 -1
- package/lib/commonjs/errors.js +19 -5
- package/lib/commonjs/errors.js.map +1 -1
- package/lib/module/OnramperClient.js +66 -5
- package/lib/module/OnramperClient.js.map +1 -1
- package/lib/module/errors.js +19 -5
- package/lib/module/errors.js.map +1 -1
- package/lib/typescript/commonjs/src/OnramperClient.d.ts +5 -1
- package/lib/typescript/commonjs/src/OnramperClient.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/errors.d.ts +6 -1
- package/lib/typescript/commonjs/src/errors.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/events.d.ts +10 -0
- package/lib/typescript/commonjs/src/events.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/index.d.ts +1 -1
- package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/specs/OnramperNitro.nitro.d.ts +3 -0
- package/lib/typescript/commonjs/src/specs/OnramperNitro.nitro.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/types.d.ts +32 -3
- package/lib/typescript/commonjs/src/types.d.ts.map +1 -1
- package/lib/typescript/module/src/OnramperClient.d.ts +5 -1
- package/lib/typescript/module/src/OnramperClient.d.ts.map +1 -1
- package/lib/typescript/module/src/errors.d.ts +6 -1
- package/lib/typescript/module/src/errors.d.ts.map +1 -1
- package/lib/typescript/module/src/events.d.ts +10 -0
- package/lib/typescript/module/src/events.d.ts.map +1 -1
- package/lib/typescript/module/src/index.d.ts +1 -1
- package/lib/typescript/module/src/index.d.ts.map +1 -1
- package/lib/typescript/module/src/specs/OnramperNitro.nitro.d.ts +3 -0
- package/lib/typescript/module/src/specs/OnramperNitro.nitro.d.ts.map +1 -1
- package/lib/typescript/module/src/types.d.ts +32 -3
- package/lib/typescript/module/src/types.d.ts.map +1 -1
- package/nitrogen/generated/ios/OnramperReactNative-Swift-Cxx-Bridge.cpp +3 -3
- package/nitrogen/generated/ios/OnramperReactNative-Swift-Cxx-Bridge.hpp +7 -7
- package/nitrogen/generated/ios/c++/HybridOnramperNitroSpecSwift.hpp +6 -0
- package/nitrogen/generated/ios/swift/Func_void_std__string.swift +5 -5
- package/nitrogen/generated/ios/swift/HybridOnramperNitroSpec.swift +1 -0
- package/nitrogen/generated/ios/swift/HybridOnramperNitroSpec_cxx.swift +27 -11
- package/nitrogen/generated/ios/swift/OnramperNitroConfig.swift +16 -6
- package/nitrogen/generated/shared/c++/HybridOnramperNitroSpec.cpp +1 -0
- package/nitrogen/generated/shared/c++/HybridOnramperNitroSpec.hpp +1 -0
- package/nitrogen/generated/shared/c++/OnramperNitroConfig.hpp +10 -2
- package/package.json +3 -2
- package/src/OnramperClient.ts +67 -4
- package/src/errors.ts +29 -6
- package/src/events.ts +5 -0
- package/src/index.ts +3 -0
- package/src/specs/OnramperNitro.nitro.ts +3 -0
- package/src/types.ts +36 -3
|
@@ -39,7 +39,7 @@ final class HybridOnramperCheckoutButton: HybridOnramperCheckoutButtonSpec {
|
|
|
39
39
|
let entry = await PreparedIntentRegistry.shared.consume(handle)
|
|
40
40
|
// A newer handle arrived while this one was being consumed; it owns the view.
|
|
41
41
|
guard self.mountedHandle == handle else { return }
|
|
42
|
-
guard let entry else {
|
|
42
|
+
guard let entry, entry.isValid else {
|
|
43
43
|
// Handle was invalidated or already consumed. Remove whatever an earlier
|
|
44
44
|
// handle hosted, so a stale Buy button can't stay on screen.
|
|
45
45
|
self.container.clear()
|
|
@@ -9,51 +9,73 @@ import SwiftUI
|
|
|
9
9
|
/// callbacks, and feeds the SDK's `sessionExpirationHandler` from a stored
|
|
10
10
|
/// async JS callback. The public typed JS API lives in `OnramperClient.ts`.
|
|
11
11
|
final class HybridOnramperNitro: HybridOnramperNitroSpec {
|
|
12
|
-
private
|
|
13
|
-
private var
|
|
14
|
-
private var
|
|
15
|
-
private var
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
private
|
|
19
|
-
private
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
12
|
+
private let logRelay = SDKLogRelay()
|
|
13
|
+
@MainActor private var client: OnramperClient?
|
|
14
|
+
@MainActor private var stateObservation: AnyCancellable?
|
|
15
|
+
@MainActor private var transactionIdObservation: AnyCancellable?
|
|
16
|
+
@MainActor private var eventTask: Task<Void, Never>?
|
|
17
|
+
|
|
18
|
+
private let lifetime = PreparedIntentLifetime()
|
|
19
|
+
private let callbackLock = NSLock()
|
|
20
|
+
private struct Callbacks {
|
|
21
|
+
var onState: ((String) -> Void)?
|
|
22
|
+
var onEvent: ((String) -> Void)?
|
|
23
|
+
var onTransactionId: ((String?) -> Void)?
|
|
24
|
+
// Nitro wraps the async JS callback's own returned Promise.
|
|
25
|
+
var sessionHandler: (() -> Promise<Promise<NitroSessionCredentials>>)?
|
|
26
|
+
}
|
|
27
|
+
private var callbacks = Callbacks()
|
|
28
|
+
|
|
29
|
+
private func withCallbacks<Value>(_ body: (inout Callbacks) -> Value) -> Value {
|
|
30
|
+
callbackLock.lock()
|
|
31
|
+
defer { callbackLock.unlock() }
|
|
32
|
+
return body(&callbacks)
|
|
33
|
+
}
|
|
27
34
|
|
|
28
35
|
// MARK: - Listener registration (synchronous)
|
|
29
36
|
|
|
37
|
+
func setLogListener(onLog: @escaping (String) -> Void) {
|
|
38
|
+
logRelay.setListener(onLog)
|
|
39
|
+
}
|
|
40
|
+
|
|
30
41
|
func setStateListener(onState: @escaping (String) -> Void) {
|
|
31
|
-
|
|
42
|
+
withCallbacks { $0.onState = onState }
|
|
32
43
|
}
|
|
33
44
|
|
|
34
45
|
func setEventListener(onEvent: @escaping (String) -> Void) {
|
|
35
|
-
|
|
46
|
+
withCallbacks { $0.onEvent = onEvent }
|
|
36
47
|
}
|
|
37
48
|
|
|
38
49
|
func setTransactionIdListener(onTransactionId: @escaping (String?) -> Void) {
|
|
39
|
-
|
|
50
|
+
withCallbacks { $0.onTransactionId = onTransactionId }
|
|
40
51
|
}
|
|
41
52
|
|
|
42
53
|
func setSessionExpirationHandler(handler: @escaping () -> Promise<Promise<NitroSessionCredentials>>) {
|
|
43
|
-
|
|
54
|
+
withCallbacks { $0.sessionHandler = handler }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/// Also used by reset/signOut: invalidate synchronously before scheduling
|
|
58
|
+
/// asynchronous SDK work, so old results cannot become usable buttons.
|
|
59
|
+
private func beginPreparation() throws -> PreparedIntentLifetime.Ticket {
|
|
60
|
+
do { return try lifetime.begin() } catch { throw bridgeSDKError(error) }
|
|
44
61
|
}
|
|
45
62
|
|
|
46
63
|
// MARK: - Lifecycle
|
|
47
64
|
|
|
48
65
|
func configure(config: OnramperNitroConfig) throws -> Promise<Void> {
|
|
49
66
|
return Promise.async { [weak self] in
|
|
50
|
-
|
|
51
|
-
|
|
67
|
+
do {
|
|
68
|
+
guard let self else { throw BridgeLifecycleError.clientDestroyed }
|
|
69
|
+
try await self.performConfigure(config)
|
|
70
|
+
} catch {
|
|
71
|
+
throw bridgeSDKError(error)
|
|
72
|
+
}
|
|
52
73
|
}
|
|
53
74
|
}
|
|
54
75
|
|
|
55
76
|
@MainActor
|
|
56
77
|
private func performConfigure(_ config: OnramperNitroConfig) async throws {
|
|
78
|
+
try lifetime.checkActive()
|
|
57
79
|
// Idempotent: a second configure() with the SDK already built is a no-op.
|
|
58
80
|
if client != nil { return }
|
|
59
81
|
|
|
@@ -75,24 +97,34 @@ final class HybridOnramperNitro: HybridOnramperNitroSpec {
|
|
|
75
97
|
}
|
|
76
98
|
}()
|
|
77
99
|
|
|
100
|
+
let relay = logRelay
|
|
78
101
|
let swiftConfig = OnramperConfiguration(
|
|
79
102
|
apiKey: config.apiKey,
|
|
80
103
|
clientId: config.clientId,
|
|
81
104
|
environment: environment,
|
|
82
105
|
theme: theme,
|
|
83
106
|
logLevel: logLevel,
|
|
107
|
+
logHandler: { record in
|
|
108
|
+
OnramperLog.systemHandler(record)
|
|
109
|
+
relay.emit(record)
|
|
110
|
+
},
|
|
111
|
+
isEventLoggingEnabled: config.isEventLoggingEnabled,
|
|
112
|
+
isEventConsoleLoggingEnabled: config.isEventConsoleLoggingEnabled,
|
|
84
113
|
sessionExpirationHandler: { [weak self] in
|
|
85
|
-
guard let self, let handler = self.sessionHandler else {
|
|
114
|
+
guard let self, let handler = self.withCallbacks({ $0.sessionHandler }) else {
|
|
86
115
|
throw OnramperError.userTokenRefreshFailed("no session expiration handler registered")
|
|
87
116
|
}
|
|
88
117
|
// Await twice: outer Promise = the callback invocation, inner Promise =
|
|
89
118
|
// the JS async function's own returned Promise. Then map to the SDK type.
|
|
119
|
+
try self.lifetime.checkActive()
|
|
90
120
|
let creds = try await handler().await().await()
|
|
121
|
+
try self.lifetime.checkActive()
|
|
91
122
|
return SessionCredentials(sessionId: creds.sessionId, sessionToken: creds.sessionToken)
|
|
92
123
|
}
|
|
93
124
|
)
|
|
94
125
|
|
|
95
126
|
let newClient = OnramperClient(configuration: swiftConfig)
|
|
127
|
+
try lifetime.checkActive()
|
|
96
128
|
client = newClient
|
|
97
129
|
attachObservers(to: newClient)
|
|
98
130
|
}
|
|
@@ -100,10 +132,12 @@ final class HybridOnramperNitro: HybridOnramperNitroSpec {
|
|
|
100
132
|
@MainActor
|
|
101
133
|
private func attachObservers(to client: OnramperClient) {
|
|
102
134
|
stateObservation = client.$state.sink { [weak self] state in
|
|
103
|
-
self
|
|
135
|
+
guard let self, (try? self.lifetime.checkActive()) != nil else { return }
|
|
136
|
+
self.withCallbacks { $0.onState }?(jsonString(state.toJSDict()))
|
|
104
137
|
}
|
|
105
138
|
transactionIdObservation = client.$currentTransactionId.sink { [weak self] transactionId in
|
|
106
|
-
self
|
|
139
|
+
guard let self, (try? self.lifetime.checkActive()) != nil else { return }
|
|
140
|
+
self.withCallbacks { $0.onTransactionId }?(transactionId)
|
|
107
141
|
}
|
|
108
142
|
// `events` is a computed AsyncStream that captures a single continuation, so
|
|
109
143
|
// access it exactly once here. CheckoutEvent is Sendable → safe to iterate
|
|
@@ -111,38 +145,59 @@ final class HybridOnramperNitro: HybridOnramperNitroSpec {
|
|
|
111
145
|
let stream = client.events
|
|
112
146
|
eventTask = Task { [weak self] in
|
|
113
147
|
for await event in stream {
|
|
114
|
-
self
|
|
148
|
+
guard let self, !Task.isCancelled, (try? self.lifetime.checkActive()) != nil else { break }
|
|
149
|
+
self.withCallbacks { $0.onEvent }?(jsonString(event.toJSDict()))
|
|
115
150
|
}
|
|
116
151
|
}
|
|
117
152
|
}
|
|
118
153
|
|
|
119
154
|
@MainActor
|
|
120
155
|
private func requireClient() throws -> OnramperClient {
|
|
156
|
+
try lifetime.checkActive()
|
|
121
157
|
guard let client else { throw OnramperError.notInitialized }
|
|
122
158
|
return client
|
|
123
159
|
}
|
|
124
160
|
|
|
125
161
|
func initialize(sessionId: String, sessionToken: String) throws -> Promise<Void> {
|
|
126
162
|
return Promise.async { [weak self] in
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
163
|
+
do {
|
|
164
|
+
guard let self else { throw BridgeLifecycleError.clientDestroyed }
|
|
165
|
+
let client = try await self.requireClient()
|
|
166
|
+
try await client.initialize(sessionId: sessionId, sessionToken: sessionToken)
|
|
167
|
+
try self.lifetime.checkActive()
|
|
168
|
+
} catch {
|
|
169
|
+
throw bridgeSDKError(error)
|
|
170
|
+
}
|
|
130
171
|
}
|
|
131
172
|
}
|
|
132
173
|
|
|
133
174
|
func reset() throws -> Promise<Void> {
|
|
175
|
+
_ = try beginPreparation()
|
|
134
176
|
return Promise.async { [weak self] in
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
177
|
+
do {
|
|
178
|
+
guard let self else { throw BridgeLifecycleError.clientDestroyed }
|
|
179
|
+
let client = try await self.requireClient()
|
|
180
|
+
await PreparedIntentRegistry.shared.dropInvalidated(for: self.lifetime)
|
|
181
|
+
await client.reset()
|
|
182
|
+
try self.lifetime.checkActive()
|
|
183
|
+
} catch {
|
|
184
|
+
throw bridgeSDKError(error)
|
|
185
|
+
}
|
|
138
186
|
}
|
|
139
187
|
}
|
|
140
188
|
|
|
141
189
|
func signOut() throws -> Promise<Void> {
|
|
190
|
+
_ = try beginPreparation()
|
|
142
191
|
return Promise.async { [weak self] in
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
192
|
+
do {
|
|
193
|
+
guard let self else { throw BridgeLifecycleError.clientDestroyed }
|
|
194
|
+
let client = try await self.requireClient()
|
|
195
|
+
await PreparedIntentRegistry.shared.dropInvalidated(for: self.lifetime)
|
|
196
|
+
await client.signOut()
|
|
197
|
+
try self.lifetime.checkActive()
|
|
198
|
+
} catch {
|
|
199
|
+
throw bridgeSDKError(error)
|
|
200
|
+
}
|
|
146
201
|
}
|
|
147
202
|
}
|
|
148
203
|
|
|
@@ -153,13 +208,19 @@ final class HybridOnramperNitro: HybridOnramperNitroSpec {
|
|
|
153
208
|
prefillJson: String,
|
|
154
209
|
styleJson: String
|
|
155
210
|
) throws -> Promise<PreparedIntentResult> {
|
|
211
|
+
let ticket = try beginPreparation()
|
|
156
212
|
return Promise.async { [weak self] in
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
213
|
+
do {
|
|
214
|
+
guard let self else { throw BridgeLifecycleError.clientDestroyed }
|
|
215
|
+
return try await self.performGetCheckoutRequirements(
|
|
216
|
+
requestJson: requestJson,
|
|
217
|
+
prefillJson: prefillJson,
|
|
218
|
+
styleJson: styleJson,
|
|
219
|
+
ticket: ticket
|
|
220
|
+
)
|
|
221
|
+
} catch {
|
|
222
|
+
throw bridgeSDKError(error)
|
|
223
|
+
}
|
|
163
224
|
}
|
|
164
225
|
}
|
|
165
226
|
|
|
@@ -167,37 +228,35 @@ final class HybridOnramperNitro: HybridOnramperNitroSpec {
|
|
|
167
228
|
private func performGetCheckoutRequirements(
|
|
168
229
|
requestJson: String,
|
|
169
230
|
prefillJson: String,
|
|
170
|
-
styleJson: String
|
|
231
|
+
styleJson: String,
|
|
232
|
+
ticket: PreparedIntentLifetime.Ticket
|
|
171
233
|
) async throws -> PreparedIntentResult {
|
|
172
|
-
let
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
if let prev = lastPreparedHandle {
|
|
184
|
-
await PreparedIntentRegistry.shared.drop(prev)
|
|
234
|
+
let prepared = try await PreparedIntentRegistry.shared.prepare(ticket: ticket) { [self] in
|
|
235
|
+
try ticket.check()
|
|
236
|
+
let client = try requireClient()
|
|
237
|
+
let request = try decodeCheckoutRequest(requestJson)
|
|
238
|
+
// Best-effort; prefill values are never logged.
|
|
239
|
+
let prefill = decodeUserPrefill(prefillJson)
|
|
240
|
+
let style = decodeCheckoutButtonStyle(styleJson)
|
|
241
|
+
let result = try await client.getCheckoutRequirements(request, prefill: prefill, buttonStyle: style)
|
|
242
|
+
let entry = PreparedIntentRegistry.PreparedIntent(button: AnyView(result.button), createdAt: Date())
|
|
243
|
+
let quoteDict = (codableToJSValue(result.quote) as? [String: Any]) ?? [:]
|
|
244
|
+
return (entry, jsonString(quoteDict))
|
|
185
245
|
}
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
let entry = PreparedIntentRegistry.PreparedIntent(button: AnyView(result.button), createdAt: Date())
|
|
189
|
-
let handle = await PreparedIntentRegistry.shared.store(entry)
|
|
190
|
-
lastPreparedHandle = handle
|
|
191
|
-
let quoteDict = (codableToJSValue(result.quote) as? [String: Any]) ?? [:]
|
|
192
|
-
return PreparedIntentResult(intentHandle: handle, quoteJson: jsonString(quoteDict))
|
|
246
|
+
try await PreparedIntentRegistry.shared.validateDelivery(of: prepared.handle, ticket: ticket)
|
|
247
|
+
return PreparedIntentResult(intentHandle: prepared.handle, quoteJson: prepared.value)
|
|
193
248
|
}
|
|
194
249
|
|
|
195
250
|
func cancelPreparedIntent(intentHandle: String) throws -> Promise<Void> {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
251
|
+
return Promise.async { [weak self] in
|
|
252
|
+
do {
|
|
253
|
+
guard let self else { throw BridgeLifecycleError.clientDestroyed }
|
|
254
|
+
try self.lifetime.checkActive()
|
|
255
|
+
await PreparedIntentRegistry.shared.drop(intentHandle, owner: self.lifetime)
|
|
256
|
+
try self.lifetime.checkActive()
|
|
257
|
+
} catch {
|
|
258
|
+
throw bridgeSDKError(error)
|
|
259
|
+
}
|
|
201
260
|
}
|
|
202
261
|
}
|
|
203
262
|
|
|
@@ -206,17 +265,20 @@ final class HybridOnramperNitro: HybridOnramperNitroSpec {
|
|
|
206
265
|
/// Releases the stored JS callbacks (Nitro holds strong refs to them) and the
|
|
207
266
|
/// SDK client. Called from `OnramperClient.destroy()` in JS.
|
|
208
267
|
func dispose() {
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
268
|
+
guard lifetime.destroy() else { return }
|
|
269
|
+
logRelay.setListener(nil)
|
|
270
|
+
withCallbacks { $0 = Callbacks() }
|
|
271
|
+
// SDK ownership and Combine subscriptions belong to the main actor.
|
|
272
|
+
// Tickets are already invalid even if this cleanup has not run yet.
|
|
273
|
+
Task { @MainActor [self] in
|
|
274
|
+
stateObservation?.cancel()
|
|
275
|
+
stateObservation = nil
|
|
276
|
+
transactionIdObservation?.cancel()
|
|
277
|
+
transactionIdObservation = nil
|
|
278
|
+
eventTask?.cancel()
|
|
279
|
+
eventTask = nil
|
|
280
|
+
client = nil
|
|
281
|
+
await PreparedIntentRegistry.shared.dropInvalidated(for: lifetime)
|
|
282
|
+
}
|
|
221
283
|
}
|
|
222
284
|
}
|
|
@@ -19,6 +19,9 @@ extension OnramperError {
|
|
|
19
19
|
case .networkError: return "networkError"
|
|
20
20
|
case .decodingError: return "decodingError"
|
|
21
21
|
case .timeout: return "timeout"
|
|
22
|
+
case .paymentFailed: return "paymentFailed"
|
|
23
|
+
case .providerFailed: return "providerFailed"
|
|
24
|
+
case .applePayNotConfigured: return "applePayNotConfigured"
|
|
22
25
|
case .requirementNotSatisfied: return "requirementNotSatisfied"
|
|
23
26
|
case .amountOutOfRange: return "amountOutOfRange"
|
|
24
27
|
case .oidcFlowCancelled: return "oidcFlowCancelled"
|
|
@@ -73,9 +76,14 @@ extension OnramperError {
|
|
|
73
76
|
}
|
|
74
77
|
}
|
|
75
78
|
|
|
76
|
-
/// A JS-friendly error dict
|
|
79
|
+
/// A JS-friendly error dict, including customer-facing copy and support code.
|
|
77
80
|
func toJSDict() -> [String: Any] {
|
|
78
|
-
var dict: [String: Any] = [
|
|
81
|
+
var dict: [String: Any] = [
|
|
82
|
+
"code": jsCode,
|
|
83
|
+
"message": errorDescription ?? "Unknown error",
|
|
84
|
+
"userMessage": userMessage,
|
|
85
|
+
"supportCode": supportCode,
|
|
86
|
+
]
|
|
79
87
|
if let info = jsInfo { dict["info"] = info }
|
|
80
88
|
return dict
|
|
81
89
|
}
|
|
@@ -149,6 +157,7 @@ extension OnramperState {
|
|
|
149
157
|
"renderType": ctx.renderType.jsValue,
|
|
150
158
|
"paymentType": ctx.paymentType.jsValue,
|
|
151
159
|
]
|
|
160
|
+
case .paymentPending: return ["kind": "paymentPending"]
|
|
152
161
|
case .completed: return ["kind": "completed"]
|
|
153
162
|
case .failed(let error):
|
|
154
163
|
return ["kind": "failed", "error": error.toJSDict()]
|
|
@@ -188,6 +197,14 @@ extension CheckoutEvent {
|
|
|
188
197
|
return ["type": "paymentAuthorized"]
|
|
189
198
|
case .paymentProcessing:
|
|
190
199
|
return ["type": "paymentProcessing"]
|
|
200
|
+
case .paymentPending:
|
|
201
|
+
return ["type": "paymentPending"]
|
|
202
|
+
case .challengeStarted:
|
|
203
|
+
return ["type": "challengeStarted"]
|
|
204
|
+
case .challengeCompleted:
|
|
205
|
+
return ["type": "challengeCompleted"]
|
|
206
|
+
case .customerOnboardingRequired:
|
|
207
|
+
return ["type": "customerOnboardingRequired"]
|
|
191
208
|
case .paymentCancelled:
|
|
192
209
|
return ["type": "paymentCancelled"]
|
|
193
210
|
case .providerError(let reason):
|
|
@@ -2,6 +2,65 @@ import Foundation
|
|
|
2
2
|
import Security
|
|
3
3
|
import SwiftUI
|
|
4
4
|
|
|
5
|
+
/// Issued synchronously at the Nitro boundary, so request order does not depend
|
|
6
|
+
/// on when Promise.async happens to reach the main actor. Disposal can also
|
|
7
|
+
/// invalidate tickets immediately from the JS thread.
|
|
8
|
+
final class PreparedIntentLifetime: @unchecked Sendable {
|
|
9
|
+
private let lock = NSLock()
|
|
10
|
+
private var generation = UUID()
|
|
11
|
+
private var destroyed = false
|
|
12
|
+
|
|
13
|
+
struct Ticket {
|
|
14
|
+
fileprivate let owner: PreparedIntentLifetime
|
|
15
|
+
fileprivate let generation: UUID
|
|
16
|
+
|
|
17
|
+
func check() throws { try owner.check(generation: generation) }
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
func begin() throws -> Ticket {
|
|
21
|
+
lock.lock()
|
|
22
|
+
defer { lock.unlock() }
|
|
23
|
+
guard !destroyed else { throw BridgeLifecycleError.clientDestroyed }
|
|
24
|
+
generation = UUID()
|
|
25
|
+
return Ticket(owner: self, generation: generation)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
func checkActive() throws { try check(generation: nil) }
|
|
29
|
+
|
|
30
|
+
private func check(generation expected: UUID?) throws {
|
|
31
|
+
lock.lock()
|
|
32
|
+
defer { lock.unlock() }
|
|
33
|
+
guard !destroyed else { throw BridgeLifecycleError.clientDestroyed }
|
|
34
|
+
if let expected, expected != generation { throw BridgeLifecycleError.intentInvalidated }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
func destroy() -> Bool {
|
|
38
|
+
lock.lock()
|
|
39
|
+
defer { lock.unlock() }
|
|
40
|
+
guard !destroyed else { return false }
|
|
41
|
+
destroyed = true
|
|
42
|
+
return true
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
enum BridgeLifecycleError: String, Error {
|
|
47
|
+
case clientDestroyed
|
|
48
|
+
case intentInvalidated
|
|
49
|
+
|
|
50
|
+
var payload: [String: Any] {
|
|
51
|
+
switch self {
|
|
52
|
+
case .clientDestroyed:
|
|
53
|
+
return ["code": rawValue, "supportCode": rawValue,
|
|
54
|
+
"message": "This OnramperClient has been destroyed. Create a new client before making SDK calls.",
|
|
55
|
+
"userMessage": "This checkout session has ended. Please reopen checkout."]
|
|
56
|
+
case .intentInvalidated:
|
|
57
|
+
return ["code": rawValue, "supportCode": rawValue,
|
|
58
|
+
"message": "This checkout preparation was superseded or cleared. Use the most recent request.",
|
|
59
|
+
"userMessage": "Your checkout details have changed. Please use the latest quote."]
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
5
64
|
actor PreparedIntentRegistry {
|
|
6
65
|
/// Process-wide registry. `getCheckoutRequirements` (on HybridOnramperNitro)
|
|
7
66
|
/// stores the SwiftUI button under a ULID handle; the separate
|
|
@@ -12,10 +71,23 @@ actor PreparedIntentRegistry {
|
|
|
12
71
|
struct PreparedIntent {
|
|
13
72
|
let button: AnyView
|
|
14
73
|
let createdAt: Date
|
|
15
|
-
var
|
|
74
|
+
fileprivate var ticket: PreparedIntentLifetime.Ticket?
|
|
75
|
+
|
|
76
|
+
init(button: AnyView, createdAt: Date) {
|
|
77
|
+
self.button = button
|
|
78
|
+
self.createdAt = createdAt
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// The main-actor view rechecks after the hop from the registry actor.
|
|
82
|
+
var isValid: Bool {
|
|
83
|
+
guard let ticket else { return true }
|
|
84
|
+
do { try ticket.check(); return true } catch { return false }
|
|
85
|
+
}
|
|
16
86
|
}
|
|
17
87
|
|
|
18
88
|
private var entries: [String: PreparedIntent] = [:]
|
|
89
|
+
// Internal diagnostics used by the standalone native regression suite.
|
|
90
|
+
var count: Int { entries.count }
|
|
19
91
|
private let ttl: TimeInterval
|
|
20
92
|
|
|
21
93
|
init(ttl: TimeInterval = 300) {
|
|
@@ -29,17 +101,63 @@ actor PreparedIntentRegistry {
|
|
|
29
101
|
return handle
|
|
30
102
|
}
|
|
31
103
|
|
|
104
|
+
/// The bridge uses this path so stale successes AND stale errors are
|
|
105
|
+
/// rejected consistently, including SDK cancellation errors. The operation
|
|
106
|
+
/// may still finish on the server; this only controls local button ownership.
|
|
107
|
+
func prepare<Value>(
|
|
108
|
+
ticket: PreparedIntentLifetime.Ticket,
|
|
109
|
+
operation: @MainActor () async throws -> (PreparedIntent, Value)
|
|
110
|
+
) async throws -> (handle: String, value: Value) {
|
|
111
|
+
dropInvalidated(for: ticket.owner)
|
|
112
|
+
try ticket.check()
|
|
113
|
+
do {
|
|
114
|
+
let (entry, value) = try await operation()
|
|
115
|
+
try ticket.check()
|
|
116
|
+
sweepExpired()
|
|
117
|
+
let handle = Self.makeULID()
|
|
118
|
+
var ownedEntry = entry
|
|
119
|
+
ownedEntry.ticket = ticket
|
|
120
|
+
entries[handle] = ownedEntry
|
|
121
|
+
return (handle, value)
|
|
122
|
+
} catch {
|
|
123
|
+
try ticket.check()
|
|
124
|
+
throw error
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
32
128
|
func consume(_ handle: String) -> PreparedIntent? {
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
129
|
+
// Transfer ownership to the mounted view instead of retaining consumed
|
|
130
|
+
// SwiftUI views until some future quote request triggers a sweep.
|
|
131
|
+
guard let entry = entries.removeValue(forKey: handle),
|
|
132
|
+
Date().timeIntervalSince(entry.createdAt) < ttl else { return nil }
|
|
133
|
+
return entry.isValid ? entry : nil
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/// Validate after the registry-to-main-actor hop, immediately before the
|
|
137
|
+
/// bridge returns the handle. A rejected result never reaches JavaScript,
|
|
138
|
+
/// so it must release its stored view without waiting for another sweep.
|
|
139
|
+
@MainActor
|
|
140
|
+
func validateDelivery(of handle: String, ticket: PreparedIntentLifetime.Ticket) async throws {
|
|
141
|
+
do {
|
|
142
|
+
try ticket.check()
|
|
143
|
+
} catch {
|
|
144
|
+
await drop(handle, owner: ticket.owner)
|
|
145
|
+
throw error
|
|
146
|
+
}
|
|
37
147
|
}
|
|
38
148
|
|
|
39
|
-
func drop(_ handle: String) {
|
|
149
|
+
func drop(_ handle: String, owner: PreparedIntentLifetime? = nil) {
|
|
150
|
+
if let owner, entries[handle]?.ticket?.owner !== owner { return }
|
|
40
151
|
entries.removeValue(forKey: handle)
|
|
41
152
|
}
|
|
42
153
|
|
|
154
|
+
func dropInvalidated(for owner: PreparedIntentLifetime) {
|
|
155
|
+
entries = entries.filter { _, entry in
|
|
156
|
+
guard let ticket = entry.ticket, ticket.owner === owner else { return true }
|
|
157
|
+
do { try ticket.check(); return true } catch { return false }
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
43
161
|
func invalidateAll() {
|
|
44
162
|
entries.removeAll()
|
|
45
163
|
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import NitroModules
|
|
3
|
+
import OnramperSDK
|
|
4
|
+
|
|
5
|
+
/// The SDK can emit records from any thread. Keep callback registration and
|
|
6
|
+
/// teardown synchronized; invoke outside the lock (Nitro schedules delivery).
|
|
7
|
+
final class SDKLogRelay: @unchecked Sendable {
|
|
8
|
+
private let lock = NSLock()
|
|
9
|
+
private var listener: ((String) -> Void)?
|
|
10
|
+
|
|
11
|
+
func setListener(_ listener: ((String) -> Void)?) {
|
|
12
|
+
lock.lock()
|
|
13
|
+
self.listener = listener
|
|
14
|
+
lock.unlock()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
func emit(_ record: OnramperLogRecord) {
|
|
18
|
+
lock.lock()
|
|
19
|
+
let callback = listener
|
|
20
|
+
lock.unlock()
|
|
21
|
+
guard let callback else { return }
|
|
22
|
+
callback(jsonString([
|
|
23
|
+
"timestamp": record.timestamp.timeIntervalSince1970 * 1000,
|
|
24
|
+
"level": String(describing: record.level),
|
|
25
|
+
"category": record.category,
|
|
26
|
+
"event": record.event,
|
|
27
|
+
"context": record.context,
|
|
28
|
+
"message": record.message,
|
|
29
|
+
]))
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/// Nitro otherwise converts a Swift error to a message-only JavaScript Error.
|
|
34
|
+
/// Both failed events and rejected operations must carry the SDK's safe copy.
|
|
35
|
+
func bridgeSDKError(_ error: Error) -> Error {
|
|
36
|
+
if let lifecycleError = error as? BridgeLifecycleError {
|
|
37
|
+
return RuntimeError.error(withMessage: "ONRAMPER_ERROR:" + jsonString(lifecycleError.payload))
|
|
38
|
+
}
|
|
39
|
+
guard let sdkError = error as? OnramperError else { return error }
|
|
40
|
+
return RuntimeError.error(withMessage: "ONRAMPER_ERROR:" + jsonString(sdkError.toJSDict()))
|
|
41
|
+
}
|