@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.
- package/CHANGELOG.md +249 -0
- package/README.md +64 -4
- 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 +110 -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 +3562 -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 +110 -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 +110 -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 +3562 -2684
- package/ios/Frameworks/OnramperSDK.xcframework/ios-arm64_x86_64-simulator/dSYMs/OnramperSDK.framework.dSYM/Contents/Resources/Relocations/x86_64/OnramperSDK.yml +3525 -2651
- package/ios/HybridOnramperCheckoutButton.swift +77 -3
- package/ios/HybridOnramperNitro.swift +142 -78
- package/ios/OnramperSDKBridge.swift +19 -2
- package/ios/PreparedIntentRegistry.swift +124 -6
- package/ios/SDKLogRelay.swift +41 -0
- package/lib/commonjs/CheckoutButton.js +34 -0
- package/lib/commonjs/CheckoutButton.js.map +1 -0
- package/lib/commonjs/OnramperClient.js +81 -12
- 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/commonjs/generated/OnramperCheckoutButtonConfig.json +1 -0
- package/lib/module/CheckoutButton.js +30 -0
- package/lib/module/CheckoutButton.js.map +1 -0
- package/lib/module/OnramperClient.js +81 -12
- 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/module/generated/OnramperCheckoutButtonConfig.json +1 -0
- package/lib/typescript/commonjs/src/CheckoutButton.d.ts +20 -0
- package/lib/typescript/commonjs/src/CheckoutButton.d.ts.map +1 -0
- package/lib/typescript/commonjs/src/OnramperClient.d.ts +10 -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/OnramperCheckoutButton.nitro.d.ts +6 -0
- package/lib/typescript/commonjs/src/specs/OnramperCheckoutButton.nitro.d.ts.map +1 -1
- package/lib/typescript/commonjs/src/specs/OnramperNitro.nitro.d.ts +4 -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/CheckoutButton.d.ts +20 -0
- package/lib/typescript/module/src/CheckoutButton.d.ts.map +1 -0
- package/lib/typescript/module/src/OnramperClient.d.ts +10 -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/OnramperCheckoutButton.nitro.d.ts +6 -0
- package/lib/typescript/module/src/specs/OnramperCheckoutButton.nitro.d.ts.map +1 -1
- package/lib/typescript/module/src/specs/OnramperNitro.nitro.d.ts +4 -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 +11 -3
- package/nitrogen/generated/ios/OnramperReactNative-Swift-Cxx-Bridge.hpp +44 -7
- package/nitrogen/generated/ios/c++/HybridOnramperCheckoutButtonSpecSwift.hpp +9 -0
- package/nitrogen/generated/ios/c++/HybridOnramperNitroSpecSwift.hpp +11 -2
- package/nitrogen/generated/ios/c++/views/HybridOnramperCheckoutButtonComponent.mm +5 -0
- package/nitrogen/generated/ios/swift/Func_void_double.swift +46 -0
- package/nitrogen/generated/ios/swift/Func_void_std__string.swift +5 -5
- package/nitrogen/generated/ios/swift/HybridOnramperCheckoutButtonSpec.swift +1 -0
- package/nitrogen/generated/ios/swift/HybridOnramperCheckoutButtonSpec_cxx.swift +32 -0
- package/nitrogen/generated/ios/swift/HybridOnramperNitroSpec.swift +2 -1
- package/nitrogen/generated/ios/swift/HybridOnramperNitroSpec_cxx.swift +32 -11
- package/nitrogen/generated/ios/swift/OnramperNitroConfig.swift +16 -6
- package/nitrogen/generated/shared/c++/HybridOnramperCheckoutButtonSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridOnramperCheckoutButtonSpec.hpp +4 -0
- package/nitrogen/generated/shared/c++/HybridOnramperNitroSpec.cpp +2 -0
- package/nitrogen/generated/shared/c++/HybridOnramperNitroSpec.hpp +3 -2
- package/nitrogen/generated/shared/c++/OnramperNitroConfig.hpp +10 -2
- package/nitrogen/generated/shared/c++/views/HybridOnramperCheckoutButtonComponent.cpp +11 -0
- package/nitrogen/generated/shared/c++/views/HybridOnramperCheckoutButtonComponent.hpp +3 -2
- package/nitrogen/generated/shared/json/OnramperCheckoutButtonConfig.json +1 -0
- package/package.json +5 -2
- package/src/CheckoutButton.ts +28 -0
- package/src/OnramperClient.ts +82 -9
- package/src/errors.ts +29 -6
- package/src/events.ts +5 -0
- package/src/generated/OnramperCheckoutButtonConfig.json +1 -0
- package/src/index.ts +3 -0
- package/src/specs/OnramperCheckoutButton.nitro.ts +6 -0
- package/src/specs/OnramperNitro.nitro.ts +5 -0
- 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
|
-
|
|
30
|
-
|
|
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
|
|
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
|
-
|
|
13
|
-
|
|
14
|
-
private
|
|
15
|
-
private var
|
|
16
|
-
|
|
17
|
-
private var
|
|
18
|
-
private var
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
private
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
44
|
+
withCallbacks { $0.onState = onState }
|
|
32
45
|
}
|
|
33
46
|
|
|
34
47
|
func setEventListener(onEvent: @escaping (String) -> Void) {
|
|
35
|
-
|
|
48
|
+
withCallbacks { $0.onEvent = onEvent }
|
|
36
49
|
}
|
|
37
50
|
|
|
38
51
|
func setTransactionIdListener(onTransactionId: @escaping (String?) -> Void) {
|
|
39
|
-
|
|
52
|
+
withCallbacks { $0.onTransactionId = onTransactionId }
|
|
40
53
|
}
|
|
41
54
|
|
|
42
55
|
func setSessionExpirationHandler(handler: @escaping () -> Promise<Promise<NitroSessionCredentials>>) {
|
|
43
|
-
|
|
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
|
-
|
|
51
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
144
|
-
|
|
145
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
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
|
-
|
|
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
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
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
|
}
|