@onramper/onramper-react-native 1.2.2 → 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.
Files changed (96) hide show
  1. package/CHANGELOG.md +214 -0
  2. package/README.md +46 -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 +107 -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 +3560 -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 +107 -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 +107 -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 +3560 -2684
  15. package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64_x86_64-simulator/dSYMs/OnramperSDK.framework.dSYM/Contents/Resources/Relocations/x86_64/OnramperSDK.yml +3523 -2651
  16. package/ios/HybridOnramperCheckoutButton.swift +77 -3
  17. package/ios/HybridOnramperNitro.swift +140 -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 +69 -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 +69 -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 +5 -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 +3 -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 +5 -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 +3 -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 +6 -0
  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 +1 -0
  77. package/nitrogen/generated/ios/swift/HybridOnramperNitroSpec_cxx.swift +27 -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 +1 -0
  82. package/nitrogen/generated/shared/c++/HybridOnramperNitroSpec.hpp +1 -0
  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 +69 -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 +3 -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,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 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
+ 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
- self.onState = onState
42
+ withCallbacks { $0.onState = onState }
32
43
  }
33
44
 
34
45
  func setEventListener(onEvent: @escaping (String) -> Void) {
35
- self.onEvent = onEvent
46
+ withCallbacks { $0.onEvent = onEvent }
36
47
  }
37
48
 
38
49
  func setTransactionIdListener(onTransactionId: @escaping (String?) -> Void) {
39
- self.onTransactionId = onTransactionId
50
+ withCallbacks { $0.onTransactionId = onTransactionId }
40
51
  }
41
52
 
42
53
  func setSessionExpirationHandler(handler: @escaping () -> Promise<Promise<NitroSessionCredentials>>) {
43
- self.sessionHandler = handler
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
- guard let self else { return }
51
- try await self.performConfigure(config)
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?.onState?(jsonString(state.toJSDict()))
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?.onTransactionId?(transactionId)
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?.onEvent?(jsonString(event.toJSDict()))
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
- guard let self else { return }
128
- let client = try await self.requireClient()
129
- try await client.initialize(sessionId: sessionId, sessionToken: sessionToken)
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
- guard let self else { return }
136
- let client = try await self.requireClient()
137
- await client.reset()
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
- guard let self else { return }
144
- let client = try await self.requireClient()
145
- await client.signOut()
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
- guard let self else { throw OnramperError.notInitialized }
158
- return try await self.performGetCheckoutRequirements(
159
- requestJson: requestJson,
160
- prefillJson: prefillJson,
161
- styleJson: styleJson
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 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)
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
- 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))
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
- if intentHandle == lastPreparedHandle {
197
- lastPreparedHandle = nil
198
- }
199
- return Promise.async {
200
- await PreparedIntentRegistry.shared.drop(intentHandle)
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
- 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
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: `{ 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
  }