@onramper/onramper-react-native 1.2.2 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +249 -0
  2. package/README.md +64 -4
  3. package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64/OnramperSDK.framework/Modules/OnramperSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  4. package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64/OnramperSDK.framework/Modules/OnramperSDK.swiftmodule/arm64-apple-ios.swiftinterface +110 -4
  5. package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64/OnramperSDK.framework/OnramperSDK +0 -0
  6. package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64/dSYMs/OnramperSDK.framework.dSYM/Contents/Resources/DWARF/OnramperSDK +0 -0
  7. package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64/dSYMs/OnramperSDK.framework.dSYM/Contents/Resources/Relocations/aarch64/OnramperSDK.yml +3562 -2684
  8. package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64_x86_64-simulator/OnramperSDK.framework/Modules/OnramperSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  9. package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64_x86_64-simulator/OnramperSDK.framework/Modules/OnramperSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +110 -4
  10. package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64_x86_64-simulator/OnramperSDK.framework/Modules/OnramperSDK.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
  11. package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64_x86_64-simulator/OnramperSDK.framework/Modules/OnramperSDK.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +110 -4
  12. package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64_x86_64-simulator/OnramperSDK.framework/OnramperSDK +0 -0
  13. package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64_x86_64-simulator/dSYMs/OnramperSDK.framework.dSYM/Contents/Resources/DWARF/OnramperSDK +0 -0
  14. package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64_x86_64-simulator/dSYMs/OnramperSDK.framework.dSYM/Contents/Resources/Relocations/aarch64/OnramperSDK.yml +3562 -2684
  15. package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64_x86_64-simulator/dSYMs/OnramperSDK.framework.dSYM/Contents/Resources/Relocations/x86_64/OnramperSDK.yml +3525 -2651
  16. package/ios/HybridOnramperCheckoutButton.swift +77 -3
  17. package/ios/HybridOnramperNitro.swift +142 -78
  18. package/ios/OnramperSDKBridge.swift +19 -2
  19. package/ios/PreparedIntentRegistry.swift +124 -6
  20. package/ios/SDKLogRelay.swift +41 -0
  21. package/lib/commonjs/CheckoutButton.js +34 -0
  22. package/lib/commonjs/CheckoutButton.js.map +1 -0
  23. package/lib/commonjs/OnramperClient.js +81 -12
  24. package/lib/commonjs/OnramperClient.js.map +1 -1
  25. package/lib/commonjs/errors.js +19 -5
  26. package/lib/commonjs/errors.js.map +1 -1
  27. package/lib/commonjs/generated/OnramperCheckoutButtonConfig.json +1 -0
  28. package/lib/module/CheckoutButton.js +30 -0
  29. package/lib/module/CheckoutButton.js.map +1 -0
  30. package/lib/module/OnramperClient.js +81 -12
  31. package/lib/module/OnramperClient.js.map +1 -1
  32. package/lib/module/errors.js +19 -5
  33. package/lib/module/errors.js.map +1 -1
  34. package/lib/module/generated/OnramperCheckoutButtonConfig.json +1 -0
  35. package/lib/typescript/commonjs/src/CheckoutButton.d.ts +20 -0
  36. package/lib/typescript/commonjs/src/CheckoutButton.d.ts.map +1 -0
  37. package/lib/typescript/commonjs/src/OnramperClient.d.ts +10 -1
  38. package/lib/typescript/commonjs/src/OnramperClient.d.ts.map +1 -1
  39. package/lib/typescript/commonjs/src/errors.d.ts +6 -1
  40. package/lib/typescript/commonjs/src/errors.d.ts.map +1 -1
  41. package/lib/typescript/commonjs/src/events.d.ts +10 -0
  42. package/lib/typescript/commonjs/src/events.d.ts.map +1 -1
  43. package/lib/typescript/commonjs/src/index.d.ts +1 -1
  44. package/lib/typescript/commonjs/src/index.d.ts.map +1 -1
  45. package/lib/typescript/commonjs/src/specs/OnramperCheckoutButton.nitro.d.ts +6 -0
  46. package/lib/typescript/commonjs/src/specs/OnramperCheckoutButton.nitro.d.ts.map +1 -1
  47. package/lib/typescript/commonjs/src/specs/OnramperNitro.nitro.d.ts +4 -0
  48. package/lib/typescript/commonjs/src/specs/OnramperNitro.nitro.d.ts.map +1 -1
  49. package/lib/typescript/commonjs/src/types.d.ts +32 -3
  50. package/lib/typescript/commonjs/src/types.d.ts.map +1 -1
  51. package/lib/typescript/module/src/CheckoutButton.d.ts +20 -0
  52. package/lib/typescript/module/src/CheckoutButton.d.ts.map +1 -0
  53. package/lib/typescript/module/src/OnramperClient.d.ts +10 -1
  54. package/lib/typescript/module/src/OnramperClient.d.ts.map +1 -1
  55. package/lib/typescript/module/src/errors.d.ts +6 -1
  56. package/lib/typescript/module/src/errors.d.ts.map +1 -1
  57. package/lib/typescript/module/src/events.d.ts +10 -0
  58. package/lib/typescript/module/src/events.d.ts.map +1 -1
  59. package/lib/typescript/module/src/index.d.ts +1 -1
  60. package/lib/typescript/module/src/index.d.ts.map +1 -1
  61. package/lib/typescript/module/src/specs/OnramperCheckoutButton.nitro.d.ts +6 -0
  62. package/lib/typescript/module/src/specs/OnramperCheckoutButton.nitro.d.ts.map +1 -1
  63. package/lib/typescript/module/src/specs/OnramperNitro.nitro.d.ts +4 -0
  64. package/lib/typescript/module/src/specs/OnramperNitro.nitro.d.ts.map +1 -1
  65. package/lib/typescript/module/src/types.d.ts +32 -3
  66. package/lib/typescript/module/src/types.d.ts.map +1 -1
  67. package/nitrogen/generated/ios/OnramperReactNative-Swift-Cxx-Bridge.cpp +11 -3
  68. package/nitrogen/generated/ios/OnramperReactNative-Swift-Cxx-Bridge.hpp +44 -7
  69. package/nitrogen/generated/ios/c++/HybridOnramperCheckoutButtonSpecSwift.hpp +9 -0
  70. package/nitrogen/generated/ios/c++/HybridOnramperNitroSpecSwift.hpp +11 -2
  71. package/nitrogen/generated/ios/c++/views/HybridOnramperCheckoutButtonComponent.mm +5 -0
  72. package/nitrogen/generated/ios/swift/Func_void_double.swift +46 -0
  73. package/nitrogen/generated/ios/swift/Func_void_std__string.swift +5 -5
  74. package/nitrogen/generated/ios/swift/HybridOnramperCheckoutButtonSpec.swift +1 -0
  75. package/nitrogen/generated/ios/swift/HybridOnramperCheckoutButtonSpec_cxx.swift +32 -0
  76. package/nitrogen/generated/ios/swift/HybridOnramperNitroSpec.swift +2 -1
  77. package/nitrogen/generated/ios/swift/HybridOnramperNitroSpec_cxx.swift +32 -11
  78. package/nitrogen/generated/ios/swift/OnramperNitroConfig.swift +16 -6
  79. package/nitrogen/generated/shared/c++/HybridOnramperCheckoutButtonSpec.cpp +2 -0
  80. package/nitrogen/generated/shared/c++/HybridOnramperCheckoutButtonSpec.hpp +4 -0
  81. package/nitrogen/generated/shared/c++/HybridOnramperNitroSpec.cpp +2 -0
  82. package/nitrogen/generated/shared/c++/HybridOnramperNitroSpec.hpp +3 -2
  83. package/nitrogen/generated/shared/c++/OnramperNitroConfig.hpp +10 -2
  84. package/nitrogen/generated/shared/c++/views/HybridOnramperCheckoutButtonComponent.cpp +11 -0
  85. package/nitrogen/generated/shared/c++/views/HybridOnramperCheckoutButtonComponent.hpp +3 -2
  86. package/nitrogen/generated/shared/json/OnramperCheckoutButtonConfig.json +1 -0
  87. package/package.json +5 -2
  88. package/src/CheckoutButton.ts +28 -0
  89. package/src/OnramperClient.ts +82 -9
  90. package/src/errors.ts +29 -6
  91. package/src/events.ts +5 -0
  92. package/src/generated/OnramperCheckoutButtonConfig.json +1 -0
  93. package/src/index.ts +3 -0
  94. package/src/specs/OnramperCheckoutButton.nitro.ts +6 -0
  95. package/src/specs/OnramperNitro.nitro.ts +5 -0
  96. package/src/types.ts +36 -3
@@ -12,6 +12,14 @@ import UIKit
12
12
  final class HybridOnramperCheckoutButton: HybridOnramperCheckoutButtonSpec {
13
13
  private let container = CheckoutContainerView()
14
14
  private var mountedHandle: String?
15
+ private var lastReportedHeight: CGFloat = 0
16
+ /// Latest height SwiftUI measured, kept so a callback attached after the
17
+ /// measurement (or replaced later) still receives it.
18
+ private var measuredHeight: CGFloat = 0
19
+
20
+ var onContentHeightChange: ((_ height: Double) -> Void)? {
21
+ didSet { flushContentHeight() }
22
+ }
15
23
 
16
24
  var intentHandle: String = "" {
17
25
  didSet {
@@ -24,22 +32,65 @@ final class HybridOnramperCheckoutButton: HybridOnramperCheckoutButtonSpec {
24
32
 
25
33
  private func mount(handle: String) {
26
34
  mountedHandle = handle
35
+ lastReportedHeight = 0
36
+ measuredHeight = 0
27
37
  Task { @MainActor [weak self] in
28
38
  guard let self else { return }
29
- guard let entry = await PreparedIntentRegistry.shared.consume(handle) else {
30
- // Handle was invalidated or already consumed — leave the view empty.
39
+ let entry = await PreparedIntentRegistry.shared.consume(handle)
40
+ // A newer handle arrived while this one was being consumed; it owns the view.
41
+ guard self.mountedHandle == handle else { return }
42
+ guard let entry, entry.isValid else {
43
+ // Handle was invalidated or already consumed. Remove whatever an earlier
44
+ // handle hosted, so a stale Buy button can't stay on screen.
45
+ self.container.clear()
31
46
  return
32
47
  }
33
48
  // Pin the SwiftUI content to the container's width so long ToS text wraps
34
49
  // instead of laying out at its intrinsic (single-line) width and spilling
35
- // past the right edge. `.fixedSize(vertical:)` lets it grow downward.
50
+ // past the right edge. `.fixedSize(vertical:)` lets it take its ideal
51
+ // height, which is measured and reported to JS so React Native can size
52
+ // the view (ToS text below the Buy button makes it taller than 56pt, and
53
+ // can appear or disappear after mount).
36
54
  self.container.host(AnyView(
37
55
  entry.button
38
56
  .frame(maxWidth: .infinity, alignment: .leading)
39
57
  .fixedSize(horizontal: false, vertical: true)
58
+ .background(GeometryReader { proxy in
59
+ // SwiftUI's first pass can run before the hosting view has a width,
60
+ // where wrapped text measures as one word per line (~1000pt tall).
61
+ // Report nothing for that pass; the real width follows immediately.
62
+ Color.clear.preference(
63
+ key: CheckoutContentHeightKey.self,
64
+ value: proxy.size.width > 0 ? proxy.size.height : 0
65
+ )
66
+ })
67
+ .onPreferenceChange(CheckoutContentHeightKey.self) { [weak self] height in
68
+ self?.reportContentHeight(height)
69
+ }
40
70
  ))
41
71
  }
42
72
  }
73
+
74
+ // Main thread only: called from SwiftUI preference changes and Nitro prop setters.
75
+ private func reportContentHeight(_ height: CGFloat) {
76
+ guard height > 0 else { return }
77
+ measuredHeight = height
78
+ flushContentHeight()
79
+ }
80
+
81
+ private func flushContentHeight() {
82
+ guard measuredHeight > 0, abs(measuredHeight - lastReportedHeight) > 0.5, let onContentHeightChange else { return }
83
+ lastReportedHeight = measuredHeight
84
+ onContentHeightChange(Double(measuredHeight))
85
+ }
86
+ }
87
+
88
+ /// Carries the hosted content's laid-out height out of SwiftUI.
89
+ private struct CheckoutContentHeightKey: PreferenceKey {
90
+ static let defaultValue: CGFloat = 0
91
+ static func reduce(value: inout CGFloat, nextValue: () -> CGFloat) {
92
+ value = max(value, nextValue())
93
+ }
43
94
  }
44
95
 
45
96
  /// Container that hosts the SwiftUI button and (re)attaches the hosting
@@ -47,6 +98,18 @@ final class HybridOnramperCheckoutButton: HybridOnramperCheckoutButtonSpec {
47
98
  final class CheckoutContainerView: UIView {
48
99
  private var hostingController: UIHostingController<AnyView>?
49
100
 
101
+ override init(frame: CGRect) {
102
+ super.init(frame: frame)
103
+ // Backstop: never draw over neighbouring React Native views, even for the
104
+ // frame between a content-size change and JS applying the new height.
105
+ clipsToBounds = true
106
+ }
107
+
108
+ required init?(coder: NSCoder) {
109
+ super.init(coder: coder)
110
+ clipsToBounds = true
111
+ }
112
+
50
113
  @MainActor
51
114
  func host(_ rootView: AnyView) {
52
115
  // Re-hosting (e.g. when `intentHandle` changes) must tear the previous
@@ -55,6 +118,11 @@ final class CheckoutContainerView: UIView {
55
118
  detachHosted()
56
119
  let controller = UIHostingController(rootView: rootView)
57
120
  controller.view.backgroundColor = .clear
121
+ // Inside a ScrollView near the notch/home indicator the hosting controller
122
+ // would otherwise inject safe-area padding that shifts the content as it scrolls.
123
+ if #available(iOS 16.4, *) {
124
+ controller.safeAreaRegions = []
125
+ }
58
126
  controller.view.translatesAutoresizingMaskIntoConstraints = false
59
127
  hostingController = controller
60
128
  addSubview(controller.view)
@@ -67,6 +135,12 @@ final class CheckoutContainerView: UIView {
67
135
  attachChildIfPossible()
68
136
  }
69
137
 
138
+ /// Removes the hosted content, leaving the view empty.
139
+ @MainActor
140
+ func clear() {
141
+ detachHosted()
142
+ }
143
+
70
144
  override func didMoveToWindow() {
71
145
  super.didMoveToWindow()
72
146
  attachChildIfPossible()
@@ -9,51 +9,75 @@ 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 var client: OnramperClient?
13
- private var stateObservation: AnyCancellable?
14
- private var transactionIdObservation: AnyCancellable?
15
- private var eventTask: Task<Void, Never>?
16
-
17
- private var onState: ((String) -> Void)?
18
- private var onEvent: ((String) -> Void)?
19
- private var onTransactionId: ((String?) -> Void)?
20
- // Nitro wraps a callback's return value in a Promise; our callback already
21
- // returns Promise<NitroSessionCredentials>, hence the doubly-nested Promise.
22
- private var sessionHandler: (() -> Promise<Promise<NitroSessionCredentials>>)?
23
-
24
- // Handle of the most recent prepared intent owned by *this* client, used to
25
- // enforce single-flight per client without touching other clients' intents.
26
- private var lastPreparedHandle: String?
12
+ var sdkVersion: String { OnramperSDK.OnramperClient.sdkVersion }
13
+
14
+ private let logRelay = SDKLogRelay()
15
+ @MainActor private var client: OnramperClient?
16
+ @MainActor private var stateObservation: AnyCancellable?
17
+ @MainActor private var transactionIdObservation: AnyCancellable?
18
+ @MainActor private var eventTask: Task<Void, Never>?
19
+
20
+ private let lifetime = PreparedIntentLifetime()
21
+ private let callbackLock = NSLock()
22
+ private struct Callbacks {
23
+ var onState: ((String) -> Void)?
24
+ var onEvent: ((String) -> Void)?
25
+ var onTransactionId: ((String?) -> Void)?
26
+ // Nitro wraps the async JS callback's own returned Promise.
27
+ var sessionHandler: (() -> Promise<Promise<NitroSessionCredentials>>)?
28
+ }
29
+ private var callbacks = Callbacks()
30
+
31
+ private func withCallbacks<Value>(_ body: (inout Callbacks) -> Value) -> Value {
32
+ callbackLock.lock()
33
+ defer { callbackLock.unlock() }
34
+ return body(&callbacks)
35
+ }
27
36
 
28
37
  // MARK: - Listener registration (synchronous)
29
38
 
39
+ func setLogListener(onLog: @escaping (String) -> Void) {
40
+ logRelay.setListener(onLog)
41
+ }
42
+
30
43
  func setStateListener(onState: @escaping (String) -> Void) {
31
- self.onState = onState
44
+ withCallbacks { $0.onState = onState }
32
45
  }
33
46
 
34
47
  func setEventListener(onEvent: @escaping (String) -> Void) {
35
- self.onEvent = onEvent
48
+ withCallbacks { $0.onEvent = onEvent }
36
49
  }
37
50
 
38
51
  func setTransactionIdListener(onTransactionId: @escaping (String?) -> Void) {
39
- self.onTransactionId = onTransactionId
52
+ withCallbacks { $0.onTransactionId = onTransactionId }
40
53
  }
41
54
 
42
55
  func setSessionExpirationHandler(handler: @escaping () -> Promise<Promise<NitroSessionCredentials>>) {
43
- self.sessionHandler = handler
56
+ withCallbacks { $0.sessionHandler = handler }
57
+ }
58
+
59
+ /// Also used by reset/signOut: invalidate synchronously before scheduling
60
+ /// asynchronous SDK work, so old results cannot become usable buttons.
61
+ private func beginPreparation() throws -> PreparedIntentLifetime.Ticket {
62
+ do { return try lifetime.begin() } catch { throw bridgeSDKError(error) }
44
63
  }
45
64
 
46
65
  // MARK: - Lifecycle
47
66
 
48
67
  func configure(config: OnramperNitroConfig) throws -> Promise<Void> {
49
68
  return Promise.async { [weak self] in
50
- guard let self else { return }
51
- try await self.performConfigure(config)
69
+ do {
70
+ guard let self else { throw BridgeLifecycleError.clientDestroyed }
71
+ try await self.performConfigure(config)
72
+ } catch {
73
+ throw bridgeSDKError(error)
74
+ }
52
75
  }
53
76
  }
54
77
 
55
78
  @MainActor
56
79
  private func performConfigure(_ config: OnramperNitroConfig) async throws {
80
+ try lifetime.checkActive()
57
81
  // Idempotent: a second configure() with the SDK already built is a no-op.
58
82
  if client != nil { return }
59
83
 
@@ -75,24 +99,34 @@ final class HybridOnramperNitro: HybridOnramperNitroSpec {
75
99
  }
76
100
  }()
77
101
 
102
+ let relay = logRelay
78
103
  let swiftConfig = OnramperConfiguration(
79
104
  apiKey: config.apiKey,
80
105
  clientId: config.clientId,
81
106
  environment: environment,
82
107
  theme: theme,
83
108
  logLevel: logLevel,
109
+ logHandler: { record in
110
+ OnramperLog.systemHandler(record)
111
+ relay.emit(record)
112
+ },
113
+ isEventLoggingEnabled: config.isEventLoggingEnabled,
114
+ isEventConsoleLoggingEnabled: config.isEventConsoleLoggingEnabled,
84
115
  sessionExpirationHandler: { [weak self] in
85
- guard let self, let handler = self.sessionHandler else {
116
+ guard let self, let handler = self.withCallbacks({ $0.sessionHandler }) else {
86
117
  throw OnramperError.userTokenRefreshFailed("no session expiration handler registered")
87
118
  }
88
119
  // Await twice: outer Promise = the callback invocation, inner Promise =
89
120
  // the JS async function's own returned Promise. Then map to the SDK type.
121
+ try self.lifetime.checkActive()
90
122
  let creds = try await handler().await().await()
123
+ try self.lifetime.checkActive()
91
124
  return SessionCredentials(sessionId: creds.sessionId, sessionToken: creds.sessionToken)
92
125
  }
93
126
  )
94
127
 
95
128
  let newClient = OnramperClient(configuration: swiftConfig)
129
+ try lifetime.checkActive()
96
130
  client = newClient
97
131
  attachObservers(to: newClient)
98
132
  }
@@ -100,10 +134,12 @@ final class HybridOnramperNitro: HybridOnramperNitroSpec {
100
134
  @MainActor
101
135
  private func attachObservers(to client: OnramperClient) {
102
136
  stateObservation = client.$state.sink { [weak self] state in
103
- self?.onState?(jsonString(state.toJSDict()))
137
+ guard let self, (try? self.lifetime.checkActive()) != nil else { return }
138
+ self.withCallbacks { $0.onState }?(jsonString(state.toJSDict()))
104
139
  }
105
140
  transactionIdObservation = client.$currentTransactionId.sink { [weak self] transactionId in
106
- self?.onTransactionId?(transactionId)
141
+ guard let self, (try? self.lifetime.checkActive()) != nil else { return }
142
+ self.withCallbacks { $0.onTransactionId }?(transactionId)
107
143
  }
108
144
  // `events` is a computed AsyncStream that captures a single continuation, so
109
145
  // access it exactly once here. CheckoutEvent is Sendable → safe to iterate
@@ -111,38 +147,59 @@ final class HybridOnramperNitro: HybridOnramperNitroSpec {
111
147
  let stream = client.events
112
148
  eventTask = Task { [weak self] in
113
149
  for await event in stream {
114
- self?.onEvent?(jsonString(event.toJSDict()))
150
+ guard let self, !Task.isCancelled, (try? self.lifetime.checkActive()) != nil else { break }
151
+ self.withCallbacks { $0.onEvent }?(jsonString(event.toJSDict()))
115
152
  }
116
153
  }
117
154
  }
118
155
 
119
156
  @MainActor
120
157
  private func requireClient() throws -> OnramperClient {
158
+ try lifetime.checkActive()
121
159
  guard let client else { throw OnramperError.notInitialized }
122
160
  return client
123
161
  }
124
162
 
125
163
  func initialize(sessionId: String, sessionToken: String) throws -> Promise<Void> {
126
164
  return Promise.async { [weak self] in
127
- guard let self else { return }
128
- let client = try await self.requireClient()
129
- try await client.initialize(sessionId: sessionId, sessionToken: sessionToken)
165
+ do {
166
+ guard let self else { throw BridgeLifecycleError.clientDestroyed }
167
+ let client = try await self.requireClient()
168
+ try await client.initialize(sessionId: sessionId, sessionToken: sessionToken)
169
+ try self.lifetime.checkActive()
170
+ } catch {
171
+ throw bridgeSDKError(error)
172
+ }
130
173
  }
131
174
  }
132
175
 
133
176
  func reset() throws -> Promise<Void> {
177
+ _ = try beginPreparation()
134
178
  return Promise.async { [weak self] in
135
- guard let self else { return }
136
- let client = try await self.requireClient()
137
- await client.reset()
179
+ do {
180
+ guard let self else { throw BridgeLifecycleError.clientDestroyed }
181
+ let client = try await self.requireClient()
182
+ await PreparedIntentRegistry.shared.dropInvalidated(for: self.lifetime)
183
+ await client.reset()
184
+ try self.lifetime.checkActive()
185
+ } catch {
186
+ throw bridgeSDKError(error)
187
+ }
138
188
  }
139
189
  }
140
190
 
141
191
  func signOut() throws -> Promise<Void> {
192
+ _ = try beginPreparation()
142
193
  return Promise.async { [weak self] in
143
- guard let self else { return }
144
- let client = try await self.requireClient()
145
- await client.signOut()
194
+ do {
195
+ guard let self else { throw BridgeLifecycleError.clientDestroyed }
196
+ let client = try await self.requireClient()
197
+ await PreparedIntentRegistry.shared.dropInvalidated(for: self.lifetime)
198
+ await client.signOut()
199
+ try self.lifetime.checkActive()
200
+ } catch {
201
+ throw bridgeSDKError(error)
202
+ }
146
203
  }
147
204
  }
148
205
 
@@ -153,13 +210,19 @@ final class HybridOnramperNitro: HybridOnramperNitroSpec {
153
210
  prefillJson: String,
154
211
  styleJson: String
155
212
  ) throws -> Promise<PreparedIntentResult> {
213
+ let ticket = try beginPreparation()
156
214
  return Promise.async { [weak self] in
157
- guard let self else { throw OnramperError.notInitialized }
158
- return try await self.performGetCheckoutRequirements(
159
- requestJson: requestJson,
160
- prefillJson: prefillJson,
161
- styleJson: styleJson
162
- )
215
+ do {
216
+ guard let self else { throw BridgeLifecycleError.clientDestroyed }
217
+ return try await self.performGetCheckoutRequirements(
218
+ requestJson: requestJson,
219
+ prefillJson: prefillJson,
220
+ styleJson: styleJson,
221
+ ticket: ticket
222
+ )
223
+ } catch {
224
+ throw bridgeSDKError(error)
225
+ }
163
226
  }
164
227
  }
165
228
 
@@ -167,37 +230,35 @@ final class HybridOnramperNitro: HybridOnramperNitroSpec {
167
230
  private func performGetCheckoutRequirements(
168
231
  requestJson: String,
169
232
  prefillJson: String,
170
- styleJson: String
233
+ styleJson: String,
234
+ ticket: PreparedIntentLifetime.Ticket
171
235
  ) async throws -> PreparedIntentResult {
172
- let client = try requireClient()
173
- let request = try decodeCheckoutRequest(requestJson)
174
- // Best-effort: an unusable prefill payload decodes to nil and the checkout
175
- // proceeds without it. Never logged — the values are user PII, and the SDK
176
- // makes the same guarantee at every log level.
177
- let prefill = decodeUserPrefill(prefillJson)
178
- let style = decodeCheckoutButtonStyle(styleJson)
179
-
180
- // Single-flight, scoped to this client: a new prepared intent supersedes
181
- // this instance's prior unconsumed one, but leaves other clients' intents
182
- // in the shared registry untouched.
183
- if let prev = lastPreparedHandle {
184
- await PreparedIntentRegistry.shared.drop(prev)
236
+ let prepared = try await PreparedIntentRegistry.shared.prepare(ticket: ticket) { [self] in
237
+ try ticket.check()
238
+ let client = try requireClient()
239
+ let request = try decodeCheckoutRequest(requestJson)
240
+ // Best-effort; prefill values are never logged.
241
+ let prefill = decodeUserPrefill(prefillJson)
242
+ let style = decodeCheckoutButtonStyle(styleJson)
243
+ let result = try await client.getCheckoutRequirements(request, prefill: prefill, buttonStyle: style)
244
+ let entry = PreparedIntentRegistry.PreparedIntent(button: AnyView(result.button), createdAt: Date())
245
+ let quoteDict = (codableToJSValue(result.quote) as? [String: Any]) ?? [:]
246
+ return (entry, jsonString(quoteDict))
185
247
  }
186
-
187
- let result = try await client.getCheckoutRequirements(request, prefill: prefill, buttonStyle: style)
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))
248
+ try await PreparedIntentRegistry.shared.validateDelivery(of: prepared.handle, ticket: ticket)
249
+ return PreparedIntentResult(intentHandle: prepared.handle, quoteJson: prepared.value)
193
250
  }
194
251
 
195
252
  func cancelPreparedIntent(intentHandle: String) throws -> Promise<Void> {
196
- if intentHandle == lastPreparedHandle {
197
- lastPreparedHandle = nil
198
- }
199
- return Promise.async {
200
- await PreparedIntentRegistry.shared.drop(intentHandle)
253
+ return Promise.async { [weak self] in
254
+ do {
255
+ guard let self else { throw BridgeLifecycleError.clientDestroyed }
256
+ try self.lifetime.checkActive()
257
+ await PreparedIntentRegistry.shared.drop(intentHandle, owner: self.lifetime)
258
+ try self.lifetime.checkActive()
259
+ } catch {
260
+ throw bridgeSDKError(error)
261
+ }
201
262
  }
202
263
  }
203
264
 
@@ -206,17 +267,20 @@ final class HybridOnramperNitro: HybridOnramperNitroSpec {
206
267
  /// Releases the stored JS callbacks (Nitro holds strong refs to them) and the
207
268
  /// SDK client. Called from `OnramperClient.destroy()` in JS.
208
269
  func dispose() {
209
- stateObservation?.cancel()
210
- stateObservation = nil
211
- transactionIdObservation?.cancel()
212
- transactionIdObservation = nil
213
- eventTask?.cancel()
214
- eventTask = nil
215
- onState = nil
216
- onEvent = nil
217
- onTransactionId = nil
218
- sessionHandler = nil
219
- lastPreparedHandle = nil
220
- client = nil
270
+ guard lifetime.destroy() else { return }
271
+ logRelay.setListener(nil)
272
+ withCallbacks { $0 = Callbacks() }
273
+ // SDK ownership and Combine subscriptions belong to the main actor.
274
+ // Tickets are already invalid even if this cleanup has not run yet.
275
+ Task { @MainActor [self] in
276
+ stateObservation?.cancel()
277
+ stateObservation = nil
278
+ transactionIdObservation?.cancel()
279
+ transactionIdObservation = nil
280
+ eventTask?.cancel()
281
+ eventTask = nil
282
+ client = nil
283
+ await PreparedIntentRegistry.shared.dropInvalidated(for: lifetime)
284
+ }
221
285
  }
222
286
  }
@@ -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: `{ code, message, info? }`.
79
+ /// A JS-friendly error dict, including customer-facing copy and support code.
77
80
  func toJSDict() -> [String: Any] {
78
- var dict: [String: Any] = ["code": jsCode, "message": errorDescription ?? "Unknown error"]
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 consumed: Bool = false
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
- guard var entry = entries[handle], !entry.consumed else { return nil }
34
- entry.consumed = true
35
- entries[handle] = entry
36
- return entry
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
  }