@clerk/expo-native-components 0.0.0-stage → 0.0.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/LICENSE +21 -0
- package/README.md +106 -2
- package/android/build.gradle +152 -0
- package/android/src/main/AndroidManifest.xml +4 -0
- package/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt +241 -0
- package/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt +148 -0
- package/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt +1077 -0
- package/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt +156 -0
- package/android/src/main/java/expo/modules/clerk/ClerkUserProfileCustomPageState.kt +120 -0
- package/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt +315 -0
- package/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt +326 -0
- package/android/src/test/java/expo/modules/clerk/ClerkUserProfileCustomPageStateTest.kt +163 -0
- package/app.plugin.d.ts +5 -0
- package/app.plugin.js +333 -0
- package/dist/AuthView.d.ts +35 -0
- package/dist/AuthView.d.ts.map +1 -0
- package/dist/AuthView.js +79 -0
- package/dist/AuthView.js.map +1 -0
- package/dist/AuthView.types.d.ts +62 -0
- package/dist/AuthView.types.d.ts.map +1 -0
- package/dist/AuthView.types.js +0 -0
- package/dist/EmbeddedNavigation.types.d.ts +24 -0
- package/dist/EmbeddedNavigation.types.d.ts.map +1 -0
- package/dist/EmbeddedNavigation.types.js +0 -0
- package/dist/UserButton.d.ts +38 -0
- package/dist/UserButton.d.ts.map +1 -0
- package/dist/UserButton.js +68 -0
- package/dist/UserButton.js.map +1 -0
- package/dist/UserProfileCustomPages.d.ts +81 -0
- package/dist/UserProfileCustomPages.d.ts.map +1 -0
- package/dist/UserProfileCustomPages.js +140 -0
- package/dist/UserProfileCustomPages.js.map +1 -0
- package/dist/UserProfileView.d.ts +76 -0
- package/dist/UserProfileView.d.ts.map +1 -0
- package/dist/UserProfileView.js +101 -0
- package/dist/UserProfileView.js.map +1 -0
- package/dist/_virtual/_rolldown/runtime.js +29 -0
- package/dist/index.d.ts +41 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +37 -0
- package/dist/specs/NativeClerkAuthView.android.d.ts +15 -0
- package/dist/specs/NativeClerkAuthView.android.d.ts.map +1 -0
- package/dist/specs/NativeClerkAuthView.android.js +8 -0
- package/dist/specs/NativeClerkAuthView.android.js.map +1 -0
- package/dist/specs/NativeClerkAuthView.d.ts +15 -0
- package/dist/specs/NativeClerkAuthView.d.ts.map +1 -0
- package/dist/specs/NativeClerkAuthView.js +9 -0
- package/dist/specs/NativeClerkAuthView.js.map +1 -0
- package/dist/specs/NativeClerkModule.d.ts +4 -0
- package/dist/specs/NativeClerkModule.d.ts.map +1 -0
- package/dist/specs/NativeClerkModule.js +8 -0
- package/dist/specs/NativeClerkModule.js.map +1 -0
- package/dist/specs/NativeClerkModule.types.d.ts +11 -0
- package/dist/specs/NativeClerkModule.types.d.ts.map +1 -0
- package/dist/specs/NativeClerkModule.types.js +0 -0
- package/dist/specs/NativeClerkModule.web.d.ts +4 -0
- package/dist/specs/NativeClerkModule.web.d.ts.map +1 -0
- package/dist/specs/NativeClerkModule.web.js +7 -0
- package/dist/specs/NativeClerkModule.web.js.map +1 -0
- package/dist/specs/NativeClerkUserButtonView.android.d.ts +6 -0
- package/dist/specs/NativeClerkUserButtonView.android.d.ts.map +1 -0
- package/dist/specs/NativeClerkUserButtonView.android.js +8 -0
- package/dist/specs/NativeClerkUserButtonView.android.js.map +1 -0
- package/dist/specs/NativeClerkUserButtonView.d.ts +6 -0
- package/dist/specs/NativeClerkUserButtonView.d.ts.map +1 -0
- package/dist/specs/NativeClerkUserButtonView.js +9 -0
- package/dist/specs/NativeClerkUserButtonView.js.map +1 -0
- package/dist/specs/NativeClerkUserProfileView.android.d.ts +13 -0
- package/dist/specs/NativeClerkUserProfileView.android.d.ts.map +1 -0
- package/dist/specs/NativeClerkUserProfileView.android.js +8 -0
- package/dist/specs/NativeClerkUserProfileView.android.js.map +1 -0
- package/dist/specs/NativeClerkUserProfileView.d.ts +13 -0
- package/dist/specs/NativeClerkUserProfileView.d.ts.map +1 -0
- package/dist/specs/NativeClerkUserProfileView.js +9 -0
- package/dist/specs/NativeClerkUserProfileView.js.map +1 -0
- package/dist/useAuthViewState.d.ts +12 -0
- package/dist/useAuthViewState.d.ts.map +1 -0
- package/dist/useAuthViewState.js +76 -0
- package/dist/useAuthViewState.js.map +1 -0
- package/dist/utils/native-module.d.ts +3 -0
- package/dist/utils/native-module.d.ts.map +1 -0
- package/dist/utils/native-module.js +14 -0
- package/dist/utils/native-module.js.map +1 -0
- package/expo-module.config.json +15 -0
- package/ios/ClerkAppDelegateSubscriber.swift +16 -0
- package/ios/ClerkAuthNativeView.swift +157 -0
- package/ios/ClerkExpo.podspec +67 -0
- package/ios/ClerkExpoModule.swift +315 -0
- package/ios/ClerkNativeBridge.swift +1575 -0
- package/ios/ClerkNativeViewHost.swift +204 -0
- package/ios/ClerkUserButtonNativeView.swift +34 -0
- package/ios/ClerkUserProfileNativeView.swift +92 -0
- package/ios/Tests/ClerkAuthNativeViewPaperTests.swift +30 -0
- package/ios/Tests/ClerkNativeBridgeTests.swift +185 -0
- package/ios/Tests/ClerkNativeViewHostTests.swift +66 -0
- package/ios/Tests/ClerkUserProfileCustomPageStateTests.swift +311 -0
- package/package.json +79 -3
- package/react-native.config.js +8 -0
- package/src/specs/NativeClerkAuthView.android.ts +15 -0
- package/src/specs/NativeClerkAuthView.ts +19 -0
- package/src/specs/NativeClerkModule.ts +6 -0
- package/src/specs/NativeClerkModule.types.ts +9 -0
- package/src/specs/NativeClerkModule.web.ts +3 -0
- package/src/specs/NativeClerkUserButtonView.android.ts +9 -0
- package/src/specs/NativeClerkUserButtonView.ts +11 -0
- package/src/specs/NativeClerkUserProfileView.android.ts +13 -0
- package/src/specs/NativeClerkUserProfileView.ts +17 -0
- package/src/specs/__tests__/native-view.web.test.ts +43 -0
|
@@ -0,0 +1,1575 @@
|
|
|
1
|
+
// ClerkNativeBridge - Provides Clerk SDK operations and SwiftUI view controllers to ClerkExpo.
|
|
2
|
+
|
|
3
|
+
import UIKit
|
|
4
|
+
import SwiftUI
|
|
5
|
+
import Observation
|
|
6
|
+
@_spi(FrameworkIntegration) import ClerkKit
|
|
7
|
+
@_spi(FrameworkIntegration) import ClerkKitUI
|
|
8
|
+
|
|
9
|
+
/// Events emitted by the native view wrappers to their React Native host views.
|
|
10
|
+
public enum ClerkNativeViewEvent: String {
|
|
11
|
+
/// Emitted by the Expo host view when app-owned dismissible content leaves the window.
|
|
12
|
+
case dismissed
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
extension Notification.Name {
|
|
16
|
+
static let clerkNativeSDKDidConfigure = Notification.Name("com.clerk.expo.native-sdk.did-configure")
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
@Observable
|
|
20
|
+
final class ClerkInlineAuthLogoState {
|
|
21
|
+
struct Content {
|
|
22
|
+
let view: UIView
|
|
23
|
+
let size: CGSize
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
private(set) var content: Content?
|
|
27
|
+
|
|
28
|
+
func setView(_ view: UIView) {
|
|
29
|
+
content = Content(view: view, size: view.bounds.size)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
func updateSize(for view: UIView) {
|
|
33
|
+
guard content?.view === view else { return }
|
|
34
|
+
let currentSize = view.bounds.size
|
|
35
|
+
guard content?.size != currentSize else { return }
|
|
36
|
+
content = Content(view: view, size: currentSize)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func removeView(_ view: UIView) {
|
|
40
|
+
guard content?.view === view else { return }
|
|
41
|
+
content = nil
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
@MainActor
|
|
46
|
+
@Observable
|
|
47
|
+
final class ClerkUserProfileCustomPageState {
|
|
48
|
+
typealias InactiveResetAction = @MainActor () -> Void
|
|
49
|
+
typealias PostInactiveReset = (@escaping InactiveResetAction) -> Void
|
|
50
|
+
|
|
51
|
+
private struct PagePresentation {
|
|
52
|
+
let path: String
|
|
53
|
+
let navigationDepth: Int?
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
private(set) var views: [UIView] = []
|
|
57
|
+
@ObservationIgnored private var navigateBackAction: (() -> Void)?
|
|
58
|
+
@ObservationIgnored private var popToRootAction: (() -> Void)?
|
|
59
|
+
@ObservationIgnored private var pushAction: ((String) -> Void)?
|
|
60
|
+
@ObservationIgnored private var pageEventHandler: ((String, String) -> Void)?
|
|
61
|
+
@ObservationIgnored private var pagePresentation: PagePresentation?
|
|
62
|
+
@ObservationIgnored private var retainedNavigationPath = NavigationPath()
|
|
63
|
+
@ObservationIgnored private var retainedCustomPagePathsByDepth: [Int: String] = [:]
|
|
64
|
+
@ObservationIgnored private var retainedNavigatorPaths: [String] = []
|
|
65
|
+
@ObservationIgnored private var navigatorResetGeneration = 0
|
|
66
|
+
@ObservationIgnored private var hasObservedUserID = false
|
|
67
|
+
@ObservationIgnored private var observedUserID: String?
|
|
68
|
+
private let postInactiveReset: PostInactiveReset
|
|
69
|
+
|
|
70
|
+
init(
|
|
71
|
+
postInactiveReset: @escaping PostInactiveReset = { action in
|
|
72
|
+
Task { @MainActor in
|
|
73
|
+
await Task.yield()
|
|
74
|
+
action()
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
) {
|
|
78
|
+
self.postInactiveReset = postInactiveReset
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
func insertView(_ view: UIView, at index: Int) {
|
|
82
|
+
view.removeFromSuperview()
|
|
83
|
+
views.insert(view, at: min(max(index, 0), views.count))
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
func removeView(_ view: UIView) {
|
|
87
|
+
guard let index = views.firstIndex(where: { $0 === view }) else { return }
|
|
88
|
+
view.removeFromSuperview()
|
|
89
|
+
views.remove(at: index)
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
func configureNavigation(
|
|
93
|
+
_ navigator: UserProfileNavigator<String>,
|
|
94
|
+
navigateBack: @escaping () -> Void
|
|
95
|
+
) {
|
|
96
|
+
configureNavigation(
|
|
97
|
+
navigateBack: navigateBack,
|
|
98
|
+
popToRoot: navigator.popToRoot,
|
|
99
|
+
push: navigator.push
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
func configureNavigation(_ navigationPath: Binding<NavigationPath>) {
|
|
104
|
+
configureNavigation(
|
|
105
|
+
navigateBack: {
|
|
106
|
+
guard !navigationPath.wrappedValue.isEmpty else { return }
|
|
107
|
+
navigationPath.wrappedValue.removeLast()
|
|
108
|
+
},
|
|
109
|
+
popToRoot: {
|
|
110
|
+
navigationPath.wrappedValue = NavigationPath()
|
|
111
|
+
},
|
|
112
|
+
push: {
|
|
113
|
+
navigationPath.wrappedValue.append($0)
|
|
114
|
+
}
|
|
115
|
+
)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
func setPageEventHandler(_ handler: @escaping (String, String) -> Void) {
|
|
119
|
+
pageEventHandler = handler
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
func pageDidPresent(path: String, navigationDepth: Int? = nil) {
|
|
123
|
+
if navigationDepth == nil {
|
|
124
|
+
cancelPendingNavigatorReset()
|
|
125
|
+
retainNavigatorPath(path)
|
|
126
|
+
}
|
|
127
|
+
pagePresentation = PagePresentation(path: path, navigationDepth: navigationDepth)
|
|
128
|
+
if let navigationDepth {
|
|
129
|
+
retainedCustomPagePathsByDepth[navigationDepth] = path
|
|
130
|
+
}
|
|
131
|
+
pageEventHandler?("presented", path)
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
func pageDidDismiss(path: String) {
|
|
135
|
+
guard pagePresentation?.path == path else { return }
|
|
136
|
+
let usesNavigator = pagePresentation?.navigationDepth == nil
|
|
137
|
+
if usesNavigator, retainedNavigatorPaths.last == path {
|
|
138
|
+
retainedNavigatorPaths.removeLast()
|
|
139
|
+
scheduleNavigatorResetIfInactive()
|
|
140
|
+
}
|
|
141
|
+
dismissPage(path)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
func navigationDepthDidChange(_ navigationDepth: Int) {
|
|
145
|
+
let removedPaths = retainedCustomPagePathsByDepth
|
|
146
|
+
.filter { $0.key > navigationDepth }
|
|
147
|
+
.sorted { $0.key > $1.key }
|
|
148
|
+
.map(\.value)
|
|
149
|
+
let remainingPathsByDepth = retainedCustomPagePathsByDepth.filter {
|
|
150
|
+
$0.key <= navigationDepth
|
|
151
|
+
}
|
|
152
|
+
let remainingPaths = Set(remainingPathsByDepth.values)
|
|
153
|
+
retainedCustomPagePathsByDepth = remainingPathsByDepth
|
|
154
|
+
|
|
155
|
+
var dismissedPaths = Set<String>()
|
|
156
|
+
for path in removedPaths where !remainingPaths.contains(path) && dismissedPaths.insert(path).inserted {
|
|
157
|
+
dismissPage(path)
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if let pagePresentation,
|
|
161
|
+
let presentedDepth = pagePresentation.navigationDepth,
|
|
162
|
+
navigationDepth < presentedDepth
|
|
163
|
+
{
|
|
164
|
+
self.pagePresentation = nil
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/// Expo can rebuild its hosting controller when a tab detaches. The live path starts
|
|
169
|
+
/// empty in each new controller so ClerkKitUI captures the correct zero-depth baseline,
|
|
170
|
+
/// while this retained snapshot is restored after that first appearance.
|
|
171
|
+
func navigationPathForRestoration() -> NavigationPath {
|
|
172
|
+
retainedNavigationPath
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
func navigationPathDidChange(_ navigationPath: NavigationPath) {
|
|
176
|
+
retainedNavigationPath = navigationPath
|
|
177
|
+
navigationDepthDidChange(navigationPath.count)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
func userDidChange(to userID: String?) {
|
|
181
|
+
guard hasObservedUserID else {
|
|
182
|
+
observedUserID = userID
|
|
183
|
+
hasObservedUserID = true
|
|
184
|
+
return
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
guard observedUserID != userID else { return }
|
|
188
|
+
observedUserID = userID
|
|
189
|
+
invalidateNavigation()
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
func reconcileCustomPagePaths(_ validPaths: Set<String>) {
|
|
193
|
+
var retainedPaths = Set(retainedCustomPagePathsByDepth.values)
|
|
194
|
+
retainedPaths.formUnion(retainedNavigatorPaths)
|
|
195
|
+
if let presentedPath = pagePresentation?.path {
|
|
196
|
+
retainedPaths.insert(presentedPath)
|
|
197
|
+
}
|
|
198
|
+
guard !retainedPaths.isSubset(of: validPaths) else { return }
|
|
199
|
+
|
|
200
|
+
invalidateNavigation()
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
func navigate(action: String, routeKey: String?) {
|
|
204
|
+
switch action {
|
|
205
|
+
case "back":
|
|
206
|
+
navigateBackAction?()
|
|
207
|
+
case "popToRoot":
|
|
208
|
+
popToRootAction?()
|
|
209
|
+
case "push":
|
|
210
|
+
if let routeKey {
|
|
211
|
+
guard !retainedCustomPagePathsByDepth.values.contains(routeKey),
|
|
212
|
+
!retainedNavigatorPaths.contains(routeKey)
|
|
213
|
+
else { return }
|
|
214
|
+
if let pagePresentation, pagePresentation.navigationDepth == nil {
|
|
215
|
+
retainNavigatorPath(routeKey)
|
|
216
|
+
}
|
|
217
|
+
pushAction?(routeKey)
|
|
218
|
+
}
|
|
219
|
+
default:
|
|
220
|
+
break
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
private func configureNavigation(
|
|
225
|
+
navigateBack: @escaping () -> Void,
|
|
226
|
+
popToRoot: @escaping () -> Void,
|
|
227
|
+
push: @escaping (String) -> Void
|
|
228
|
+
) {
|
|
229
|
+
navigateBackAction = navigateBack
|
|
230
|
+
popToRootAction = popToRoot
|
|
231
|
+
pushAction = push
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
private func invalidateNavigation() {
|
|
235
|
+
var dismissedPaths = retainedCustomPagePathsByDepth
|
|
236
|
+
.sorted { $0.key > $1.key }
|
|
237
|
+
.map(\.value)
|
|
238
|
+
dismissedPaths.append(contentsOf: retainedNavigatorPaths.reversed())
|
|
239
|
+
if let presentedPath = pagePresentation?.path,
|
|
240
|
+
!dismissedPaths.contains(presentedPath)
|
|
241
|
+
{
|
|
242
|
+
dismissedPaths.insert(presentedPath, at: 0)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
retainedNavigationPath = NavigationPath()
|
|
246
|
+
retainedCustomPagePathsByDepth.removeAll()
|
|
247
|
+
retainedNavigatorPaths.removeAll()
|
|
248
|
+
cancelPendingNavigatorReset()
|
|
249
|
+
popToRootAction?()
|
|
250
|
+
var uniqueDismissedPaths: [String] = []
|
|
251
|
+
for path in dismissedPaths where !uniqueDismissedPaths.contains(path) {
|
|
252
|
+
uniqueDismissedPaths.append(path)
|
|
253
|
+
}
|
|
254
|
+
for path in uniqueDismissedPaths {
|
|
255
|
+
dismissPage(path)
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
private func retainNavigatorPath(_ path: String) {
|
|
260
|
+
guard let retainedIndex = retainedNavigatorPaths.lastIndex(of: path) else {
|
|
261
|
+
retainedNavigatorPaths.append(path)
|
|
262
|
+
return
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
let removedPaths = Array(retainedNavigatorPaths.suffix(from: retainedIndex + 1).reversed())
|
|
266
|
+
retainedNavigatorPaths.removeSubrange((retainedIndex + 1)..<retainedNavigatorPaths.endIndex)
|
|
267
|
+
for removedPath in removedPaths {
|
|
268
|
+
dismissPage(removedPath)
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
private func scheduleNavigatorResetIfInactive() {
|
|
273
|
+
navigatorResetGeneration += 1
|
|
274
|
+
let generation = navigatorResetGeneration
|
|
275
|
+
postInactiveReset { [weak self] in
|
|
276
|
+
guard let self, generation == navigatorResetGeneration else { return }
|
|
277
|
+
resetInactiveNavigatorPaths()
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
private func cancelPendingNavigatorReset() {
|
|
282
|
+
navigatorResetGeneration += 1
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
private func resetInactiveNavigatorPaths() {
|
|
286
|
+
guard pagePresentation == nil else { return }
|
|
287
|
+
|
|
288
|
+
let dismissedPaths = retainedNavigatorPaths.reversed()
|
|
289
|
+
retainedNavigatorPaths.removeAll()
|
|
290
|
+
navigateBackAction = nil
|
|
291
|
+
popToRootAction = nil
|
|
292
|
+
pushAction = nil
|
|
293
|
+
for path in dismissedPaths {
|
|
294
|
+
dismissPage(path)
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
private func dismissPage(_ path: String) {
|
|
299
|
+
if pagePresentation?.path == path {
|
|
300
|
+
pagePresentation = nil
|
|
301
|
+
}
|
|
302
|
+
pageEventHandler?("dismissed", path)
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
struct ClerkUserProfileCustomRowConfig: Decodable {
|
|
307
|
+
struct Placement: Decodable {
|
|
308
|
+
let type: String
|
|
309
|
+
let section: String?
|
|
310
|
+
let row: String?
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
let path: String
|
|
314
|
+
let label: String
|
|
315
|
+
let icon: String
|
|
316
|
+
let placement: Placement
|
|
317
|
+
let showAsRow: Bool?
|
|
318
|
+
|
|
319
|
+
var shouldShowAsRow: Bool {
|
|
320
|
+
showAsRow ?? true
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
var nativeRow: UserProfileCustomRow<String> {
|
|
324
|
+
UserProfileCustomRow(
|
|
325
|
+
route: path,
|
|
326
|
+
title: label,
|
|
327
|
+
icon: .system(name: systemIconName),
|
|
328
|
+
placement: nativePlacement
|
|
329
|
+
)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
private var systemIconName: String {
|
|
333
|
+
switch icon {
|
|
334
|
+
case "user": "person"
|
|
335
|
+
case "profile": "person.crop.circle"
|
|
336
|
+
case "security": "shield"
|
|
337
|
+
case "billing": "creditcard"
|
|
338
|
+
case "key": "key"
|
|
339
|
+
case "lock": "lock"
|
|
340
|
+
case "email": "envelope"
|
|
341
|
+
case "phone": "phone"
|
|
342
|
+
case "add": "plus"
|
|
343
|
+
case "switch": "arrow.left.arrow.right"
|
|
344
|
+
case "users": "person.2"
|
|
345
|
+
case "warning": "exclamationmark.triangle"
|
|
346
|
+
case "info": "info.circle"
|
|
347
|
+
case "globe": "globe"
|
|
348
|
+
case "folder": "folder"
|
|
349
|
+
case "book": "book"
|
|
350
|
+
default: "gearshape"
|
|
351
|
+
}
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
private var nativePlacement: UserProfileCustomRowPlacement {
|
|
355
|
+
switch placement.type {
|
|
356
|
+
case "sectionStart": .sectionStart(nativeSection)
|
|
357
|
+
case "before": .before(nativeAnchorRow)
|
|
358
|
+
case "after": .after(nativeAnchorRow)
|
|
359
|
+
default: .sectionEnd(nativeSection)
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
private var nativeSection: UserProfileSection {
|
|
364
|
+
placement.section == "account" ? .account : .profile
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
private var nativeAnchorRow: UserProfileRow {
|
|
368
|
+
switch placement.row {
|
|
369
|
+
case "security": .security
|
|
370
|
+
case "switchAccount": .switchAccount
|
|
371
|
+
case "addAccount": .addAccount
|
|
372
|
+
case "signOut": .signOut
|
|
373
|
+
default: .manageAccount
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
func decodeUserProfileCustomPages(_ json: String) -> [ClerkUserProfileCustomRowConfig] {
|
|
379
|
+
guard let data = json.data(using: .utf8),
|
|
380
|
+
let rows = try? JSONDecoder().decode([ClerkUserProfileCustomRowConfig].self, from: data)
|
|
381
|
+
else {
|
|
382
|
+
return []
|
|
383
|
+
}
|
|
384
|
+
return rows
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
func parseUserProfileCustomPages(_ json: String, pageCount: Int) -> [ClerkUserProfileCustomRowConfig] {
|
|
388
|
+
Array(decodeUserProfileCustomPages(json).prefix(pageCount))
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
func userProfileCustomPageLabel(
|
|
392
|
+
for path: String,
|
|
393
|
+
rows: [ClerkUserProfileCustomRowConfig]
|
|
394
|
+
) -> String {
|
|
395
|
+
rows.first(where: { $0.path == path })?.label ?? ""
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
private let clerkNativeClientEventQueue = DispatchQueue(label: "com.clerk.expo.native-client-events")
|
|
399
|
+
private var clerkNativeAuthFlowChangedEmitter: (([String: Any]?) -> Void)?
|
|
400
|
+
private var clerkNativeClientChangedEmitter: (([String: Any]?) -> Void)?
|
|
401
|
+
|
|
402
|
+
struct ClerkNativeErrorDescriptor {
|
|
403
|
+
let code: String
|
|
404
|
+
let message: String
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
private struct ClerkExpoBiometricCredentialError: LocalizedError {
|
|
408
|
+
let code: String
|
|
409
|
+
let message: String
|
|
410
|
+
|
|
411
|
+
var errorDescription: String? {
|
|
412
|
+
message
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
private struct ClerkExpoHeaderMiddleware: ClerkRequestMiddleware {
|
|
417
|
+
private static var hostSdkVersion: String? {
|
|
418
|
+
Bundle.main.object(forInfoDictionaryKey: "ClerkExpoVersion") as? String
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
func prepare(_ request: inout URLRequest) async throws {
|
|
422
|
+
request.addValue("expo", forHTTPHeaderField: "x-clerk-host-sdk")
|
|
423
|
+
if let hostSdkVersion = Self.hostSdkVersion, !hostSdkVersion.isEmpty {
|
|
424
|
+
request.addValue(hostSdkVersion, forHTTPHeaderField: "x-clerk-host-sdk-version")
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// MARK: - Native Bridge Implementation
|
|
430
|
+
|
|
431
|
+
final class ClerkNativeBridge {
|
|
432
|
+
static let shared = ClerkNativeBridge()
|
|
433
|
+
|
|
434
|
+
private static let clerkLoadMaxAttempts = 30
|
|
435
|
+
private static let clerkLoadIntervalNs: UInt64 = 100_000_000
|
|
436
|
+
private static var clerkConfigured = false
|
|
437
|
+
private static var configuredPublishableKey: String?
|
|
438
|
+
|
|
439
|
+
/// Parsed light and dark themes from Info.plist "ClerkTheme" dictionary.
|
|
440
|
+
var lightTheme: ClerkTheme?
|
|
441
|
+
var darkTheme: ClerkTheme?
|
|
442
|
+
|
|
443
|
+
private var clientObservationGeneration = 0
|
|
444
|
+
private var lastObservedClientState: ClientStateSnapshot?
|
|
445
|
+
private var authFlowObservationGeneration = 0
|
|
446
|
+
private var lastObservedAuthFlowState: AuthFlowStateSnapshot?
|
|
447
|
+
private var configurationDepth = 0
|
|
448
|
+
private var jsOriginatedClientSyncDepth = 0
|
|
449
|
+
private var pendingURL: URL?
|
|
450
|
+
private var shouldFlushPendingURL = false
|
|
451
|
+
|
|
452
|
+
private init() {}
|
|
453
|
+
|
|
454
|
+
private struct ClientStateSnapshot: Equatable {
|
|
455
|
+
let client: Client?
|
|
456
|
+
let deviceToken: String?
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
private struct AuthFlowStateSnapshot: Equatable {
|
|
460
|
+
let isLoaded: Bool
|
|
461
|
+
let isAuthFlowComplete: Bool
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
private struct ClientStateChanges {
|
|
465
|
+
let client: Bool
|
|
466
|
+
let deviceToken: Bool
|
|
467
|
+
|
|
468
|
+
static let all = ClientStateChanges(client: true, deviceToken: true)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/// Resolves the keychain service name, checking ClerkKeychainService in Info.plist first
|
|
472
|
+
/// (for extension apps sharing a keychain group), then falling back to the bundle identifier.
|
|
473
|
+
private static var keychainService: String? {
|
|
474
|
+
if let custom = Bundle.main.object(forInfoDictionaryKey: "ClerkKeychainService") as? String, !custom.isEmpty {
|
|
475
|
+
return custom
|
|
476
|
+
}
|
|
477
|
+
return Bundle.main.bundleIdentifier
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
@MainActor
|
|
481
|
+
func configure(publishableKey: String, bearerToken: String? = nil) async throws {
|
|
482
|
+
configurationDepth += 1
|
|
483
|
+
defer {
|
|
484
|
+
lastObservedClientState = Self.clerkConfigured ? Self.clientStateSnapshot() : nil
|
|
485
|
+
let authFlowState = Self.authFlowStateSnapshot()
|
|
486
|
+
lastObservedAuthFlowState = authFlowState
|
|
487
|
+
configurationDepth = max(0, configurationDepth - 1)
|
|
488
|
+
Self.emitAuthFlowChanged(Self.authFlowStatePayload(authFlowState))
|
|
489
|
+
|
|
490
|
+
// Overlapping calls can finish out of order, so replay once the last one settles and any
|
|
491
|
+
// of them succeeded. A batch where every call threw keeps the URL for the next attempt.
|
|
492
|
+
if configurationDepth == 0, shouldFlushPendingURL {
|
|
493
|
+
shouldFlushPendingURL = false
|
|
494
|
+
flushPendingURL()
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
loadThemes()
|
|
499
|
+
|
|
500
|
+
if Self.shouldReconfigure(for: publishableKey) {
|
|
501
|
+
try await Clerk.reconfigure(publishableKey: publishableKey, options: Self.makeClerkOptions())
|
|
502
|
+
Self.clerkConfigured = true
|
|
503
|
+
Self.configuredPublishableKey = publishableKey
|
|
504
|
+
startClientObserver(reset: true)
|
|
505
|
+
startAuthFlowObserver(reset: true)
|
|
506
|
+
|
|
507
|
+
let shouldWaitForClient = try await Self.syncTokenState(bearerToken: bearerToken)
|
|
508
|
+
await Self.waitForLoadedClientIfNeeded(shouldWaitForClient)
|
|
509
|
+
Self.postConfiguredNotification()
|
|
510
|
+
shouldFlushPendingURL = true
|
|
511
|
+
return
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if Self.clerkConfigured {
|
|
515
|
+
startClientObserver()
|
|
516
|
+
startAuthFlowObserver()
|
|
517
|
+
let didUpdateDeviceToken = try await Self.syncTokenState(bearerToken: bearerToken)
|
|
518
|
+
if didUpdateDeviceToken {
|
|
519
|
+
await Self.waitForLoadedClient()
|
|
520
|
+
} else if let token = bearerToken?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty {
|
|
521
|
+
// A remounted JS runtime can have the same token while native client
|
|
522
|
+
// state is stale, so preserve one refresh in that case.
|
|
523
|
+
_ = try await Clerk.shared.refreshClient()
|
|
524
|
+
await Self.waitForLoadedClient()
|
|
525
|
+
}
|
|
526
|
+
shouldFlushPendingURL = true
|
|
527
|
+
return
|
|
528
|
+
}
|
|
529
|
+
|
|
530
|
+
Self.clerkConfigured = true
|
|
531
|
+
Self.configuredPublishableKey = publishableKey
|
|
532
|
+
Clerk.configure(publishableKey: publishableKey, options: Self.makeClerkOptions())
|
|
533
|
+
startClientObserver()
|
|
534
|
+
startAuthFlowObserver()
|
|
535
|
+
|
|
536
|
+
let shouldWaitForClient = try await Self.syncTokenState(bearerToken: bearerToken)
|
|
537
|
+
await Self.waitForLoadedClientIfNeeded(shouldWaitForClient)
|
|
538
|
+
Self.postConfiguredNotification()
|
|
539
|
+
shouldFlushPendingURL = true
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
@MainActor
|
|
543
|
+
private func flushPendingURL() {
|
|
544
|
+
guard let url = pendingURL else { return }
|
|
545
|
+
pendingURL = nil
|
|
546
|
+
handle(url: url)
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/// `AuthView` only reaches `Clerk.handle(_:)` from `.onOpenURL`, which never fires for a UIKit-hosted controller.
|
|
550
|
+
@MainActor
|
|
551
|
+
func handle(url: URL) {
|
|
552
|
+
// A cold launch delivers the callback before, or partway through, JS calling `configure`.
|
|
553
|
+
guard Self.clerkConfigured, configurationDepth == 0 else {
|
|
554
|
+
pendingURL = url
|
|
555
|
+
return
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
Task { @MainActor in
|
|
559
|
+
do {
|
|
560
|
+
try await Clerk.shared.handle(url)
|
|
561
|
+
} catch {
|
|
562
|
+
NSLog("[Clerk] Failed to handle callback URL: \(error.localizedDescription)")
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
@MainActor
|
|
568
|
+
private func startClientObserver(reset: Bool = false) {
|
|
569
|
+
guard reset || clientObservationGeneration == 0 else {
|
|
570
|
+
return
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
clientObservationGeneration += 1
|
|
574
|
+
let generation = clientObservationGeneration
|
|
575
|
+
lastObservedClientState = Self.clientStateSnapshot()
|
|
576
|
+
observeClient(generation: generation)
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
@MainActor
|
|
580
|
+
private func observeClient(generation: Int) {
|
|
581
|
+
withObservationTracking {
|
|
582
|
+
_ = Self.clientStateSnapshot()
|
|
583
|
+
} onChange: { [weak self] in
|
|
584
|
+
Task { @MainActor [weak self] in
|
|
585
|
+
await Task.yield()
|
|
586
|
+
|
|
587
|
+
guard let self, generation == self.clientObservationGeneration else { return }
|
|
588
|
+
|
|
589
|
+
let newClientState = Self.clientStateSnapshot()
|
|
590
|
+
if let previousClientState = self.lastObservedClientState, newClientState != previousClientState {
|
|
591
|
+
self.lastObservedClientState = newClientState
|
|
592
|
+
if self.configurationDepth == 0, self.jsOriginatedClientSyncDepth == 0 {
|
|
593
|
+
let payload = Self.clientChangedPayload(
|
|
594
|
+
changes: .init(
|
|
595
|
+
client: newClientState.client != previousClientState.client,
|
|
596
|
+
deviceToken: newClientState.deviceToken != previousClientState.deviceToken
|
|
597
|
+
)
|
|
598
|
+
)
|
|
599
|
+
Self.emitClientChanged(payload)
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
self.observeClient(generation: generation)
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
@MainActor
|
|
609
|
+
private func startAuthFlowObserver(reset: Bool = false) {
|
|
610
|
+
guard reset || authFlowObservationGeneration == 0 else {
|
|
611
|
+
return
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
authFlowObservationGeneration += 1
|
|
615
|
+
let generation = authFlowObservationGeneration
|
|
616
|
+
lastObservedAuthFlowState = Self.authFlowStateSnapshot()
|
|
617
|
+
observeAuthFlow(generation: generation)
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
@MainActor
|
|
621
|
+
private func observeAuthFlow(generation: Int) {
|
|
622
|
+
withObservationTracking {
|
|
623
|
+
_ = Self.authFlowStateSnapshot()
|
|
624
|
+
} onChange: { [weak self] in
|
|
625
|
+
Task { @MainActor [weak self] in
|
|
626
|
+
await Task.yield()
|
|
627
|
+
|
|
628
|
+
guard let self, generation == self.authFlowObservationGeneration else { return }
|
|
629
|
+
|
|
630
|
+
let newState = Self.authFlowStateSnapshot()
|
|
631
|
+
if let previousState = self.lastObservedAuthFlowState, newState != previousState {
|
|
632
|
+
self.lastObservedAuthFlowState = newState
|
|
633
|
+
if self.configurationDepth == 0 {
|
|
634
|
+
Self.emitAuthFlowChanged(Self.authFlowStatePayload(newState))
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
self.observeAuthFlow(generation: generation)
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
@MainActor
|
|
644
|
+
private static func authFlowStateSnapshot() -> AuthFlowStateSnapshot {
|
|
645
|
+
guard clerkConfigured else {
|
|
646
|
+
return AuthFlowStateSnapshot(isLoaded: false, isAuthFlowComplete: false)
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
return AuthFlowStateSnapshot(
|
|
650
|
+
isLoaded: Clerk.shared.isLoaded,
|
|
651
|
+
isAuthFlowComplete: Clerk.shared.isAuthFlowComplete
|
|
652
|
+
)
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
private static func authFlowStatePayload(_ state: AuthFlowStateSnapshot) -> [String: Any] {
|
|
656
|
+
[
|
|
657
|
+
"isLoaded": state.isLoaded,
|
|
658
|
+
"isAuthFlowComplete": state.isAuthFlowComplete,
|
|
659
|
+
]
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
@MainActor
|
|
663
|
+
private static func clientStateSnapshot() -> ClientStateSnapshot {
|
|
664
|
+
let client = Clerk.shared.client
|
|
665
|
+
|
|
666
|
+
return ClientStateSnapshot(
|
|
667
|
+
client: client,
|
|
668
|
+
deviceToken: Clerk.shared.deviceToken
|
|
669
|
+
)
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
@MainActor
|
|
673
|
+
private static func clientChangedPayload(sourceId: String? = nil, changes: ClientStateChanges = .all) -> [String: Any] {
|
|
674
|
+
var payload: [String: Any] = [:]
|
|
675
|
+
payload["changed"] = [
|
|
676
|
+
"client": changes.client,
|
|
677
|
+
"deviceToken": changes.deviceToken,
|
|
678
|
+
]
|
|
679
|
+
payload["deviceToken"] = Clerk.shared.deviceToken ?? NSNull()
|
|
680
|
+
if let sourceId, !sourceId.isEmpty {
|
|
681
|
+
payload["sourceId"] = sourceId
|
|
682
|
+
}
|
|
683
|
+
|
|
684
|
+
return payload
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
@MainActor
|
|
688
|
+
private static func syncTokenState(bearerToken: String?) async throws -> Bool {
|
|
689
|
+
await waitForLoadedClient()
|
|
690
|
+
|
|
691
|
+
guard let token = bearerToken?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty
|
|
692
|
+
else {
|
|
693
|
+
return false
|
|
694
|
+
}
|
|
695
|
+
guard Clerk.shared.deviceToken != token || Clerk.shared.client == nil else {
|
|
696
|
+
return false
|
|
697
|
+
}
|
|
698
|
+
_ = try await Clerk.shared.updateDeviceToken(token)
|
|
699
|
+
return true
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
private static func shouldReconfigure(for publishableKey: String) -> Bool {
|
|
703
|
+
guard clerkConfigured, let configuredPublishableKey else { return false }
|
|
704
|
+
return configuredPublishableKey != publishableKey
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
private static func makeClerkOptions() -> Clerk.Options {
|
|
708
|
+
let middleware = Clerk.Options.MiddlewareConfig(request: [ClerkExpoHeaderMiddleware()])
|
|
709
|
+
guard let service = keychainService else {
|
|
710
|
+
return .init(middleware: middleware)
|
|
711
|
+
}
|
|
712
|
+
return .init(keychainConfig: .init(service: service), middleware: middleware)
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
@MainActor
|
|
716
|
+
private static func waitForLoadedClient() async {
|
|
717
|
+
// Wait for Clerk to finish loading client state from cached data + API refresh.
|
|
718
|
+
// The bridge sync contract is device-token based, not session based.
|
|
719
|
+
for _ in 0..<clerkLoadMaxAttempts {
|
|
720
|
+
if Clerk.shared.isLoaded {
|
|
721
|
+
return
|
|
722
|
+
}
|
|
723
|
+
try? await Task.sleep(nanoseconds: clerkLoadIntervalNs)
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
@MainActor
|
|
728
|
+
private static func waitForLoadedClientIfNeeded(_ shouldWait: Bool) async {
|
|
729
|
+
guard shouldWait else { return }
|
|
730
|
+
await waitForLoadedClient()
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
@MainActor
|
|
734
|
+
func getClientToken() async -> String? {
|
|
735
|
+
guard Self.clerkConfigured else { return nil }
|
|
736
|
+
return Clerk.shared.deviceToken
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
@MainActor
|
|
740
|
+
func getAuthFlowState() -> [String: Any] {
|
|
741
|
+
Self.authFlowStatePayload(Self.authFlowStateSnapshot())
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// MARK: - Biometric credentials
|
|
745
|
+
|
|
746
|
+
@MainActor
|
|
747
|
+
func getBiometricCredentialAvailability(id: String?, identifierHint: String?) async throws -> [String: Any] {
|
|
748
|
+
guard Self.clerkConfigured else {
|
|
749
|
+
return [
|
|
750
|
+
"isAvailable": false,
|
|
751
|
+
"unavailableReason": "environment_unavailable",
|
|
752
|
+
]
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
let availability = try await Clerk.shared.biometricCredentials.availability(
|
|
756
|
+
id: id,
|
|
757
|
+
identifierHint: identifierHint
|
|
758
|
+
)
|
|
759
|
+
|
|
760
|
+
return [
|
|
761
|
+
"isAvailable": availability.isAvailable,
|
|
762
|
+
"unavailableReason": availability.unavailableReason
|
|
763
|
+
.map(Self.biometricCredentialUnavailableReason) ?? NSNull(),
|
|
764
|
+
]
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
@MainActor
|
|
768
|
+
func listBiometricCredentials() async throws -> [[String: Any]] {
|
|
769
|
+
try Self.requireBiometricCredentialEnvironment()
|
|
770
|
+
let biometricCredentials = try await Clerk.shared.biometricCredentials.list()
|
|
771
|
+
return biometricCredentials.map(Self.biometricCredentialPayload)
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
@MainActor
|
|
775
|
+
func enrollBiometricCredential(
|
|
776
|
+
deviceName: String?,
|
|
777
|
+
identifierHint: String?,
|
|
778
|
+
reason: String?,
|
|
779
|
+
policy: String
|
|
780
|
+
) async throws -> [String: Any] {
|
|
781
|
+
try Self.requireBiometricCredentialEnvironment()
|
|
782
|
+
|
|
783
|
+
guard let biometricCredentialPolicy = BiometricCredentialPolicy(rawValue: policy) else {
|
|
784
|
+
throw ClerkExpoBiometricCredentialError(
|
|
785
|
+
code: "invalid_trusted_device_policy",
|
|
786
|
+
message: "Invalid biometric-credential policy: \(policy)."
|
|
787
|
+
)
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
let biometricCredential = try await Clerk.shared.biometricCredentials.enroll(
|
|
791
|
+
name: deviceName,
|
|
792
|
+
identifierHint: identifierHint,
|
|
793
|
+
reason: reason,
|
|
794
|
+
policy: biometricCredentialPolicy
|
|
795
|
+
)
|
|
796
|
+
return Self.biometricCredentialPayload(biometricCredential)
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
@MainActor
|
|
800
|
+
func revokeBiometricCredential(id: String) async throws -> [String: Any] {
|
|
801
|
+
try Self.requireBiometricCredentialEnvironment()
|
|
802
|
+
let biometricCredential = try await Clerk.shared.biometricCredentials.revoke(id: id)
|
|
803
|
+
return Self.biometricCredentialPayload(biometricCredential)
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
@MainActor
|
|
807
|
+
func signInWithBiometrics(
|
|
808
|
+
id: String?,
|
|
809
|
+
identifierHint: String?,
|
|
810
|
+
reason: String?
|
|
811
|
+
) async throws -> [String: Any] {
|
|
812
|
+
try Self.requireBiometricCredentialEnvironment()
|
|
813
|
+
let signIn = try await Clerk.shared.auth.signInWithBiometrics(
|
|
814
|
+
id: id,
|
|
815
|
+
identifierHint: identifierHint,
|
|
816
|
+
reason: reason
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
return [
|
|
820
|
+
"id": signIn.id,
|
|
821
|
+
"status": signIn.status.rawValue,
|
|
822
|
+
"createdSessionId": Self.bridgeValue(signIn.createdSessionId),
|
|
823
|
+
]
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
@MainActor
|
|
827
|
+
private static func requireBiometricCredentialEnvironment() throws {
|
|
828
|
+
guard clerkConfigured else {
|
|
829
|
+
throw ClerkExpoBiometricCredentialError(
|
|
830
|
+
code: "environment_unavailable",
|
|
831
|
+
message: "Biometric credential operations are unavailable until Clerk finishes configuring."
|
|
832
|
+
)
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
@MainActor
|
|
837
|
+
func reverifyWithBiometrics(sessionId: String, level: String, reason: String?) async throws -> [String: Any] {
|
|
838
|
+
try Self.requireBiometricCredentialEnvironment()
|
|
839
|
+
let requestedLevel = try Self.biometricReverificationLevel(level)
|
|
840
|
+
guard let session = Clerk.shared.client?.sessions.first(where: { $0.id == sessionId }) else {
|
|
841
|
+
throw ClerkExpoBiometricCredentialError(
|
|
842
|
+
code: "biometric_reverification_session_unavailable",
|
|
843
|
+
message: "The session to reverify is unavailable in the native Clerk client."
|
|
844
|
+
)
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
let started = try await session.startVerification(level: requestedLevel)
|
|
848
|
+
let verification = try await Self.verifyBiometricReverification(started) { level in
|
|
849
|
+
try await session.verifyWithBiometrics(reason: reason, level: level)
|
|
850
|
+
}
|
|
851
|
+
return Self.biometricReverificationPayload(verification, sessionId: sessionId)
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
@MainActor
|
|
855
|
+
static func verifyBiometricReverification(
|
|
856
|
+
_ started: SessionVerification,
|
|
857
|
+
verify: (Session.BiometricVerificationLevel) async throws -> SessionVerification
|
|
858
|
+
) async throws -> SessionVerification {
|
|
859
|
+
switch started.status {
|
|
860
|
+
case .needsFirstFactor:
|
|
861
|
+
let verification = try await verify(.firstFactor)
|
|
862
|
+
if verification.status == .needsSecondFactor {
|
|
863
|
+
try Task.checkCancellation()
|
|
864
|
+
return try await verify(.secondFactor)
|
|
865
|
+
}
|
|
866
|
+
return verification
|
|
867
|
+
case .needsSecondFactor:
|
|
868
|
+
return try await verify(.secondFactor)
|
|
869
|
+
case .complete:
|
|
870
|
+
return started
|
|
871
|
+
case .unknown:
|
|
872
|
+
throw ClerkExpoBiometricCredentialError(
|
|
873
|
+
code: "E_BIOMETRIC_REVERIFICATION_FAILED",
|
|
874
|
+
message: "The server returned an unsupported reverification status."
|
|
875
|
+
)
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
static func biometricReverificationLevel(_ level: String) throws -> SessionVerification.Level {
|
|
880
|
+
switch level {
|
|
881
|
+
case "first_factor": .firstFactor
|
|
882
|
+
case "second_factor": .secondFactor
|
|
883
|
+
case "multi_factor": .multiFactor
|
|
884
|
+
default:
|
|
885
|
+
throw ClerkExpoBiometricCredentialError(
|
|
886
|
+
code: "invalid_reverification_level",
|
|
887
|
+
message: "Biometric reverification level must be first_factor, second_factor, or multi_factor."
|
|
888
|
+
)
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
static func biometricReverificationPayload(_ verification: SessionVerification, sessionId: String) -> [String: Any] {
|
|
893
|
+
[
|
|
894
|
+
"id": bridgeValue(verification.id),
|
|
895
|
+
"status": verification.status.rawValue,
|
|
896
|
+
"level": verification.level.rawValue,
|
|
897
|
+
"sessionId": verification.session?.id ?? sessionId,
|
|
898
|
+
]
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
private static func biometricCredentialPayload(_ biometricCredential: BiometricCredential) -> [String: Any] {
|
|
902
|
+
[
|
|
903
|
+
"id": biometricCredential.id,
|
|
904
|
+
"object": biometricCredential.object,
|
|
905
|
+
"platform": biometricCredential.platform.rawValue,
|
|
906
|
+
"appIdentifier": biometricCredential.appIdentifier,
|
|
907
|
+
"name": bridgeValue(biometricCredential.name),
|
|
908
|
+
"algorithm": biometricCredential.algorithm.rawValue,
|
|
909
|
+
"status": biometricCredential.status.rawValue,
|
|
910
|
+
"createdAt": millisecondsSince1970(biometricCredential.createdAt),
|
|
911
|
+
"updatedAt": millisecondsSince1970(biometricCredential.updatedAt),
|
|
912
|
+
"lastUsedAt": optionalMillisecondsSince1970(biometricCredential.lastUsedAt),
|
|
913
|
+
"revokedAt": optionalMillisecondsSince1970(biometricCredential.revokedAt),
|
|
914
|
+
]
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
private static func biometricCredentialUnavailableReason(
|
|
918
|
+
_ reason: BiometricCredentialAvailability.UnavailableReason
|
|
919
|
+
) -> String {
|
|
920
|
+
snakeCase(reason.rawValue)
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
static func biometricCredentialErrorDescriptor(
|
|
924
|
+
_ error: Error,
|
|
925
|
+
fallbackCode: String
|
|
926
|
+
) -> ClerkNativeErrorDescriptor {
|
|
927
|
+
if let error = error as? ClerkExpoBiometricCredentialError {
|
|
928
|
+
return ClerkNativeErrorDescriptor(code: error.code, message: error.localizedDescription)
|
|
929
|
+
}
|
|
930
|
+
|
|
931
|
+
if let error = error as? ClerkAPIError {
|
|
932
|
+
return ClerkNativeErrorDescriptor(code: error.code, message: error.localizedDescription)
|
|
933
|
+
}
|
|
934
|
+
|
|
935
|
+
if let error = error as? BiometricCredentialError {
|
|
936
|
+
return ClerkNativeErrorDescriptor(code: error.rawValue, message: error.localizedDescription)
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
if let error = error as? BiometricCredentialKeyManagerError {
|
|
940
|
+
return ClerkNativeErrorDescriptor(
|
|
941
|
+
code: biometricCredentialKeyManagerErrorCode(error),
|
|
942
|
+
message: error.localizedDescription
|
|
943
|
+
)
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
return ClerkNativeErrorDescriptor(code: fallbackCode, message: error.localizedDescription)
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
private static func biometricCredentialKeyManagerErrorCode(
|
|
950
|
+
_ error: BiometricCredentialKeyManagerError
|
|
951
|
+
) -> String {
|
|
952
|
+
switch error {
|
|
953
|
+
case .unsupportedPlatform:
|
|
954
|
+
"unsupported_platform"
|
|
955
|
+
case .biometricAuthenticationUnavailable:
|
|
956
|
+
"biometric_authentication_unavailable"
|
|
957
|
+
case .biometricAuthenticationCanceled:
|
|
958
|
+
"biometric_authentication_canceled"
|
|
959
|
+
case .biometricAuthenticationFailed:
|
|
960
|
+
"biometric_authentication_failed"
|
|
961
|
+
case .keyGenerationFailed:
|
|
962
|
+
"key_generation_failed"
|
|
963
|
+
case .keyNotFound:
|
|
964
|
+
"key_not_found"
|
|
965
|
+
case .invalidPublicKey:
|
|
966
|
+
"invalid_public_key"
|
|
967
|
+
case .publicKeyExportFailed:
|
|
968
|
+
"public_key_export_failed"
|
|
969
|
+
case .unsupportedAlgorithm:
|
|
970
|
+
"unsupported_algorithm"
|
|
971
|
+
case .signingFailed:
|
|
972
|
+
"signing_failed"
|
|
973
|
+
case .deletionFailed:
|
|
974
|
+
"key_deletion_failed"
|
|
975
|
+
@unknown default:
|
|
976
|
+
"trusted_device_key_manager_error"
|
|
977
|
+
}
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
private static func snakeCase(_ value: String) -> String {
|
|
981
|
+
value
|
|
982
|
+
.replacingOccurrences(
|
|
983
|
+
of: "([A-Z]+)([A-Z][a-z])",
|
|
984
|
+
with: "$1_$2",
|
|
985
|
+
options: .regularExpression
|
|
986
|
+
)
|
|
987
|
+
.replacingOccurrences(
|
|
988
|
+
of: "([a-z0-9])([A-Z])",
|
|
989
|
+
with: "$1_$2",
|
|
990
|
+
options: .regularExpression
|
|
991
|
+
)
|
|
992
|
+
.lowercased()
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
private static func millisecondsSince1970(_ date: Date) -> Double {
|
|
996
|
+
date.timeIntervalSince1970 * 1_000
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
private static func optionalMillisecondsSince1970(_ date: Date?) -> Any {
|
|
1000
|
+
guard let date else { return NSNull() }
|
|
1001
|
+
return millisecondsSince1970(date)
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
private static func bridgeValue<Value>(_ value: Value?) -> Any {
|
|
1005
|
+
guard let value else { return NSNull() }
|
|
1006
|
+
return value
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
// MARK: - Inline View Creation
|
|
1010
|
+
|
|
1011
|
+
@MainActor
|
|
1012
|
+
func makeAuthViewController(
|
|
1013
|
+
mode: String,
|
|
1014
|
+
dismissible: Bool,
|
|
1015
|
+
logoState: ClerkInlineAuthLogoState,
|
|
1016
|
+
logoMaxHeight: CGFloat?,
|
|
1017
|
+
hostBackAction: (() -> Void)? = nil,
|
|
1018
|
+
onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void
|
|
1019
|
+
) -> UIViewController? {
|
|
1020
|
+
guard Self.clerkConfigured else { return nil }
|
|
1021
|
+
|
|
1022
|
+
let onDismiss: (() -> Void)? = dismissible ? { onEvent(.dismissed, [:]) } : nil
|
|
1023
|
+
|
|
1024
|
+
return makeHostingController(
|
|
1025
|
+
rootView: ClerkInlineAuthWrapperView(
|
|
1026
|
+
mode: Self.authMode(from: mode),
|
|
1027
|
+
dismissible: dismissible,
|
|
1028
|
+
hostBackAction: hostBackAction.map(ClerkHostBackAction.init),
|
|
1029
|
+
hostDismissAction: onDismiss.map(ClerkHostDismissAction.init),
|
|
1030
|
+
lightTheme: lightTheme,
|
|
1031
|
+
darkTheme: darkTheme,
|
|
1032
|
+
logoState: logoState,
|
|
1033
|
+
logoMaxHeight: logoMaxHeight,
|
|
1034
|
+
onAuthComplete: { onEvent(.dismissed, [:]) }
|
|
1035
|
+
),
|
|
1036
|
+
onDismiss: onDismiss
|
|
1037
|
+
)
|
|
1038
|
+
}
|
|
1039
|
+
|
|
1040
|
+
@MainActor
|
|
1041
|
+
func makeUserProfileViewController(
|
|
1042
|
+
dismissible: Bool,
|
|
1043
|
+
customRows: [ClerkUserProfileCustomRowConfig],
|
|
1044
|
+
customPageState: ClerkUserProfileCustomPageState,
|
|
1045
|
+
hostBackAction: (() -> Void)? = nil,
|
|
1046
|
+
onEvent: @escaping (ClerkNativeViewEvent, [String: Any]) -> Void
|
|
1047
|
+
) -> UIViewController? {
|
|
1048
|
+
guard Self.clerkConfigured else { return nil }
|
|
1049
|
+
|
|
1050
|
+
return makeHostingController(
|
|
1051
|
+
rootView: ClerkInlineProfileWrapperView(
|
|
1052
|
+
dismissible: dismissible,
|
|
1053
|
+
hostBackAction: hostBackAction.map(ClerkHostBackAction.init),
|
|
1054
|
+
lightTheme: lightTheme,
|
|
1055
|
+
darkTheme: darkTheme,
|
|
1056
|
+
customRows: customRows,
|
|
1057
|
+
customPageState: customPageState
|
|
1058
|
+
)
|
|
1059
|
+
.environment(Clerk.shared),
|
|
1060
|
+
onDismiss: dismissible ? { onEvent(.dismissed, [:]) } : nil
|
|
1061
|
+
)
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
@MainActor
|
|
1065
|
+
func makeUserButtonViewController(
|
|
1066
|
+
customRows: [ClerkUserProfileCustomRowConfig],
|
|
1067
|
+
customPageState: ClerkUserProfileCustomPageState
|
|
1068
|
+
) -> UIViewController? {
|
|
1069
|
+
guard Self.clerkConfigured else { return nil }
|
|
1070
|
+
|
|
1071
|
+
return makeHostingController(
|
|
1072
|
+
rootView: ClerkInlineUserButtonWrapperView(
|
|
1073
|
+
lightTheme: lightTheme,
|
|
1074
|
+
darkTheme: darkTheme,
|
|
1075
|
+
customRows: customRows,
|
|
1076
|
+
customPageState: customPageState
|
|
1077
|
+
)
|
|
1078
|
+
.environment(Clerk.shared)
|
|
1079
|
+
)
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
@MainActor
|
|
1083
|
+
func syncClientStateFromJs(
|
|
1084
|
+
deviceToken: String?,
|
|
1085
|
+
sourceId: String?,
|
|
1086
|
+
didChangeClient: Bool,
|
|
1087
|
+
didChangeDeviceToken: Bool
|
|
1088
|
+
) async throws {
|
|
1089
|
+
guard Self.clerkConfigured else { return }
|
|
1090
|
+
|
|
1091
|
+
let previousClientState = Self.clientStateSnapshot()
|
|
1092
|
+
var completedSuccessfully = false
|
|
1093
|
+
jsOriginatedClientSyncDepth += 1
|
|
1094
|
+
defer {
|
|
1095
|
+
let finalClientState = Self.clientStateSnapshot()
|
|
1096
|
+
lastObservedClientState = finalClientState
|
|
1097
|
+
jsOriginatedClientSyncDepth = max(0, jsOriginatedClientSyncDepth - 1)
|
|
1098
|
+
|
|
1099
|
+
if !completedSuccessfully, finalClientState != previousClientState {
|
|
1100
|
+
Self.emitClientChanged(
|
|
1101
|
+
Self.clientChangedPayload(
|
|
1102
|
+
changes: .init(
|
|
1103
|
+
client: finalClientState.client != previousClientState.client,
|
|
1104
|
+
deviceToken: finalClientState.deviceToken != previousClientState.deviceToken
|
|
1105
|
+
)
|
|
1106
|
+
)
|
|
1107
|
+
)
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
var refreshedClientWhileUpdatingToken = false
|
|
1112
|
+
|
|
1113
|
+
if didChangeDeviceToken,
|
|
1114
|
+
let token = deviceToken?.trimmingCharacters(in: .whitespacesAndNewlines), !token.isEmpty
|
|
1115
|
+
{
|
|
1116
|
+
if Clerk.shared.deviceToken != token {
|
|
1117
|
+
_ = try await Clerk.shared.updateDeviceToken(token)
|
|
1118
|
+
await Self.waitForLoadedClient()
|
|
1119
|
+
refreshedClientWhileUpdatingToken = true
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
if !refreshedClientWhileUpdatingToken, didChangeClient || didChangeDeviceToken {
|
|
1124
|
+
_ = try await Clerk.shared.refreshClient()
|
|
1125
|
+
await Self.waitForLoadedClient()
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
let newClientState = Self.clientStateSnapshot()
|
|
1129
|
+
lastObservedClientState = newClientState
|
|
1130
|
+
Self.emitClientChanged(
|
|
1131
|
+
Self.clientChangedPayload(
|
|
1132
|
+
sourceId: sourceId,
|
|
1133
|
+
changes: .init(
|
|
1134
|
+
client: newClientState.client != previousClientState.client,
|
|
1135
|
+
deviceToken: newClientState.deviceToken != previousClientState.deviceToken
|
|
1136
|
+
)
|
|
1137
|
+
)
|
|
1138
|
+
)
|
|
1139
|
+
completedSuccessfully = true
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
private static func postConfiguredNotification() {
|
|
1143
|
+
NotificationCenter.default.post(name: .clerkNativeSDKDidConfigure, object: nil)
|
|
1144
|
+
}
|
|
1145
|
+
|
|
1146
|
+
static func setClientChangedEmitter(_ emitter: (([String: Any]?) -> Void)?) {
|
|
1147
|
+
clerkNativeClientEventQueue.sync {
|
|
1148
|
+
clerkNativeClientChangedEmitter = emitter
|
|
1149
|
+
}
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
static func setAuthFlowChangedEmitter(_ emitter: (([String: Any]?) -> Void)?) {
|
|
1153
|
+
clerkNativeClientEventQueue.sync {
|
|
1154
|
+
clerkNativeAuthFlowChangedEmitter = emitter
|
|
1155
|
+
}
|
|
1156
|
+
}
|
|
1157
|
+
|
|
1158
|
+
static func emitAuthFlowChanged(_ body: [String: Any]? = nil) {
|
|
1159
|
+
let emitter = clerkNativeClientEventQueue.sync {
|
|
1160
|
+
clerkNativeAuthFlowChangedEmitter
|
|
1161
|
+
}
|
|
1162
|
+
emitter?(body)
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
/// Requests that ClerkProvider reload the JS client from native client state.
|
|
1166
|
+
static func emitClientChanged(_ body: [String: Any]? = nil) {
|
|
1167
|
+
let emitter = clerkNativeClientEventQueue.sync {
|
|
1168
|
+
clerkNativeClientChangedEmitter
|
|
1169
|
+
}
|
|
1170
|
+
emitter?(body)
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
private static func authMode(from mode: String) -> AuthView.Mode {
|
|
1174
|
+
switch mode {
|
|
1175
|
+
case "signIn":
|
|
1176
|
+
.signIn
|
|
1177
|
+
case "signUp":
|
|
1178
|
+
.signUp
|
|
1179
|
+
default:
|
|
1180
|
+
.signInOrUp
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
// MARK: - Theme Parsing
|
|
1185
|
+
|
|
1186
|
+
/// Reads the "ClerkTheme" dictionary from Info.plist and builds light / dark themes.
|
|
1187
|
+
@MainActor func loadThemes() {
|
|
1188
|
+
guard let themeDictionary = Bundle.main.object(forInfoDictionaryKey: "ClerkTheme") as? [String: Any] else {
|
|
1189
|
+
return
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// Build light theme from top-level "colors" and "design"
|
|
1193
|
+
let lightColors = (themeDictionary["colors"] as? [String: String]).flatMap { parseColors(from: $0) }
|
|
1194
|
+
let design = (themeDictionary["design"] as? [String: Any]).flatMap { parseDesign(from: $0) }
|
|
1195
|
+
let fonts = (themeDictionary["design"] as? [String: Any]).flatMap { parseFonts(from: $0) }
|
|
1196
|
+
|
|
1197
|
+
if lightColors != nil || design != nil || fonts != nil {
|
|
1198
|
+
lightTheme = ClerkTheme(colors: lightColors ?? .default, fonts: fonts ?? .default, design: design ?? .default)
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
// Build dark theme from "darkColors" (inherits same design/fonts)
|
|
1202
|
+
if let darkColorsDict = themeDictionary["darkColors"] as? [String: String] {
|
|
1203
|
+
let darkColors = parseColors(from: darkColorsDict)
|
|
1204
|
+
if darkColors != nil || design != nil || fonts != nil {
|
|
1205
|
+
darkTheme = ClerkTheme(colors: darkColors ?? .default, fonts: fonts ?? .default, design: design ?? .default)
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
private func parseColors(from dict: [String: String]) -> ClerkTheme.Colors? {
|
|
1211
|
+
let hasAny = dict.values.contains { colorFromHex($0) != nil }
|
|
1212
|
+
guard hasAny else { return nil }
|
|
1213
|
+
|
|
1214
|
+
return ClerkTheme.Colors(
|
|
1215
|
+
primary: dict["primary"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultPrimaryColor,
|
|
1216
|
+
background: dict["background"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultBackgroundColor,
|
|
1217
|
+
input: dict["input"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultInputColor,
|
|
1218
|
+
danger: dict["danger"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultDangerColor,
|
|
1219
|
+
success: dict["success"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultSuccessColor,
|
|
1220
|
+
warning: dict["warning"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultWarningColor,
|
|
1221
|
+
foreground: dict["foreground"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultForegroundColor,
|
|
1222
|
+
mutedForeground: dict["mutedForeground"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultMutedForegroundColor,
|
|
1223
|
+
primaryForeground: dict["primaryForeground"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultPrimaryForegroundColor,
|
|
1224
|
+
inputForeground: dict["inputForeground"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultInputForegroundColor,
|
|
1225
|
+
neutral: dict["neutral"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultNeutralColor,
|
|
1226
|
+
ring: dict["ring"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultRingColor,
|
|
1227
|
+
muted: dict["muted"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultMutedColor,
|
|
1228
|
+
secondaryButtonBackground: dict["secondaryButtonBackground"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultSecondaryButtonBackgroundColor,
|
|
1229
|
+
secondaryButtonForeground: dict["secondaryButtonForeground"].flatMap { colorFromHex($0) },
|
|
1230
|
+
shadow: dict["shadow"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultShadowColor,
|
|
1231
|
+
border: dict["border"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultBorderColor
|
|
1232
|
+
)
|
|
1233
|
+
}
|
|
1234
|
+
|
|
1235
|
+
private func colorFromHex(_ hex: String) -> Color? {
|
|
1236
|
+
var cleaned = hex.trimmingCharacters(in: .whitespacesAndNewlines)
|
|
1237
|
+
if cleaned.hasPrefix("#") { cleaned.removeFirst() }
|
|
1238
|
+
|
|
1239
|
+
var rgb: UInt64 = 0
|
|
1240
|
+
guard Scanner(string: cleaned).scanHexInt64(&rgb) else { return nil }
|
|
1241
|
+
|
|
1242
|
+
switch cleaned.count {
|
|
1243
|
+
case 6:
|
|
1244
|
+
return Color(
|
|
1245
|
+
red: Double((rgb >> 16) & 0xFF) / 255.0,
|
|
1246
|
+
green: Double((rgb >> 8) & 0xFF) / 255.0,
|
|
1247
|
+
blue: Double(rgb & 0xFF) / 255.0
|
|
1248
|
+
)
|
|
1249
|
+
case 8:
|
|
1250
|
+
return Color(
|
|
1251
|
+
red: Double((rgb >> 24) & 0xFF) / 255.0,
|
|
1252
|
+
green: Double((rgb >> 16) & 0xFF) / 255.0,
|
|
1253
|
+
blue: Double((rgb >> 8) & 0xFF) / 255.0,
|
|
1254
|
+
opacity: Double(rgb & 0xFF) / 255.0
|
|
1255
|
+
)
|
|
1256
|
+
default:
|
|
1257
|
+
return nil
|
|
1258
|
+
}
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
private func parseFonts(from dict: [String: Any]) -> ClerkTheme.Fonts? {
|
|
1262
|
+
guard let fontFamily = dict["fontFamily"] as? String, !fontFamily.isEmpty else { return nil }
|
|
1263
|
+
return ClerkTheme.Fonts(fontFamily: fontFamily)
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
private func parseDesign(from dict: [String: Any]) -> ClerkTheme.Design? {
|
|
1267
|
+
guard let radius = dict["borderRadius"] as? Double else { return nil }
|
|
1268
|
+
return ClerkTheme.Design(borderRadius: CGFloat(radius))
|
|
1269
|
+
}
|
|
1270
|
+
|
|
1271
|
+
private func makeHostingController<Content: View>(
|
|
1272
|
+
rootView: Content,
|
|
1273
|
+
onDismiss: (() -> Void)? = nil
|
|
1274
|
+
) -> UIViewController {
|
|
1275
|
+
let hostingController = ClerkNativeHostingController(rootView: rootView, onDismiss: onDismiss)
|
|
1276
|
+
hostingController.view.backgroundColor = .clear
|
|
1277
|
+
return hostingController
|
|
1278
|
+
}
|
|
1279
|
+
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
// MARK: - Inline User Button Wrapper (for embedded rendering)
|
|
1283
|
+
|
|
1284
|
+
struct ClerkInlineUserButtonWrapperView: View {
|
|
1285
|
+
@Environment(Clerk.self) private var clerk
|
|
1286
|
+
@Environment(\.colorScheme) private var colorScheme
|
|
1287
|
+
|
|
1288
|
+
let lightTheme: ClerkTheme?
|
|
1289
|
+
let darkTheme: ClerkTheme?
|
|
1290
|
+
let customRows: [ClerkUserProfileCustomRowConfig]
|
|
1291
|
+
let customPageState: ClerkUserProfileCustomPageState
|
|
1292
|
+
|
|
1293
|
+
private var userID: String? {
|
|
1294
|
+
clerk.user?.id
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
var body: some View {
|
|
1298
|
+
let view = UserButton()
|
|
1299
|
+
.userProfileRows(customRows.filter(\.shouldShowAsRow).map(\.nativeRow))
|
|
1300
|
+
.userProfileDestination { routeKey in
|
|
1301
|
+
ClerkReactUserProfileCustomPage(
|
|
1302
|
+
path: routeKey,
|
|
1303
|
+
rows: customRows,
|
|
1304
|
+
state: customPageState
|
|
1305
|
+
)
|
|
1306
|
+
}
|
|
1307
|
+
let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme
|
|
1308
|
+
let themedView = Group {
|
|
1309
|
+
if let theme {
|
|
1310
|
+
view.environment(\.clerkTheme, theme)
|
|
1311
|
+
} else {
|
|
1312
|
+
view
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
themedView
|
|
1316
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center)
|
|
1317
|
+
.onAppear {
|
|
1318
|
+
customPageState.userDidChange(to: userID)
|
|
1319
|
+
}
|
|
1320
|
+
.onChange(of: userID) { _, newUserID in
|
|
1321
|
+
customPageState.userDidChange(to: newUserID)
|
|
1322
|
+
}
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// MARK: - Inline Auth View Wrapper (for embedded rendering)
|
|
1327
|
+
|
|
1328
|
+
struct ClerkInlineAuthWrapperView: View {
|
|
1329
|
+
let mode: AuthView.Mode
|
|
1330
|
+
let dismissible: Bool
|
|
1331
|
+
let hostBackAction: ClerkHostBackAction?
|
|
1332
|
+
let hostDismissAction: ClerkHostDismissAction?
|
|
1333
|
+
let lightTheme: ClerkTheme?
|
|
1334
|
+
let darkTheme: ClerkTheme?
|
|
1335
|
+
let logoState: ClerkInlineAuthLogoState
|
|
1336
|
+
let logoMaxHeight: CGFloat?
|
|
1337
|
+
let onAuthComplete: @MainActor () -> Void
|
|
1338
|
+
|
|
1339
|
+
@Environment(\.colorScheme) private var colorScheme
|
|
1340
|
+
|
|
1341
|
+
@ViewBuilder private var themedAuthView: some View {
|
|
1342
|
+
let view = AuthView(
|
|
1343
|
+
mode: mode,
|
|
1344
|
+
isDismissible: dismissible,
|
|
1345
|
+
onAuthComplete: onAuthComplete
|
|
1346
|
+
)
|
|
1347
|
+
.environment(Clerk.shared)
|
|
1348
|
+
.environment(\.clerkHostBackAction, hostBackAction)
|
|
1349
|
+
.environment(\.clerkHostDismissAction, hostDismissAction)
|
|
1350
|
+
let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme
|
|
1351
|
+
let themedView = Group {
|
|
1352
|
+
if let theme {
|
|
1353
|
+
view.environment(\.clerkTheme, theme)
|
|
1354
|
+
} else {
|
|
1355
|
+
view
|
|
1356
|
+
}
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1359
|
+
if let logo = logoState.content {
|
|
1360
|
+
themedView.clerkAppIconView {
|
|
1361
|
+
ClerkReactLogoView(view: logo.view)
|
|
1362
|
+
.frame(width: logo.size.width, height: logo.size.height)
|
|
1363
|
+
}
|
|
1364
|
+
} else if let logoMaxHeight {
|
|
1365
|
+
themedView.clerkAppIcon(maxHeight: logoMaxHeight)
|
|
1366
|
+
} else {
|
|
1367
|
+
themedView
|
|
1368
|
+
}
|
|
1369
|
+
}
|
|
1370
|
+
|
|
1371
|
+
var body: some View {
|
|
1372
|
+
themedAuthView
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
private struct ClerkReactLogoView: UIViewRepresentable {
|
|
1377
|
+
let view: UIView
|
|
1378
|
+
|
|
1379
|
+
func makeUIView(context: Context) -> ClerkReactContentContainerView {
|
|
1380
|
+
return ClerkReactContentContainerView(contentView: view)
|
|
1381
|
+
}
|
|
1382
|
+
|
|
1383
|
+
func updateUIView(_ uiView: ClerkReactContentContainerView, context: Context) {
|
|
1384
|
+
uiView.setContentView(view)
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
private final class ClerkReactContentContainerView: UIView {
|
|
1389
|
+
private var contentView: UIView?
|
|
1390
|
+
|
|
1391
|
+
init(contentView: UIView) {
|
|
1392
|
+
super.init(frame: .zero)
|
|
1393
|
+
setContentView(contentView)
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1396
|
+
required init?(coder: NSCoder) {
|
|
1397
|
+
return nil
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
func setContentView(_ view: UIView) {
|
|
1401
|
+
guard contentView !== view else { return }
|
|
1402
|
+
contentView?.removeFromSuperview()
|
|
1403
|
+
view.removeFromSuperview()
|
|
1404
|
+
contentView = view
|
|
1405
|
+
addSubview(view)
|
|
1406
|
+
setNeedsLayout()
|
|
1407
|
+
}
|
|
1408
|
+
|
|
1409
|
+
override func layoutSubviews() {
|
|
1410
|
+
super.layoutSubviews()
|
|
1411
|
+
contentView?.frame = bounds
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
private final class ClerkNativeHostingController<Content: View>: UIHostingController<Content> {
|
|
1416
|
+
private let onDismiss: (() -> Void)?
|
|
1417
|
+
private var didSendDismiss = false
|
|
1418
|
+
|
|
1419
|
+
init(rootView: Content, onDismiss: (() -> Void)? = nil) {
|
|
1420
|
+
self.onDismiss = onDismiss
|
|
1421
|
+
super.init(rootView: rootView)
|
|
1422
|
+
}
|
|
1423
|
+
|
|
1424
|
+
@MainActor @preconcurrency required dynamic init?(coder aDecoder: NSCoder) {
|
|
1425
|
+
fatalError("init(coder:) has not been implemented")
|
|
1426
|
+
}
|
|
1427
|
+
|
|
1428
|
+
override func dismiss(animated flag: Bool, completion: (() -> Void)? = nil) {
|
|
1429
|
+
sendDismissIfNeeded()
|
|
1430
|
+
super.dismiss(animated: flag, completion: completion)
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
private func sendDismissIfNeeded() {
|
|
1434
|
+
guard !didSendDismiss else { return }
|
|
1435
|
+
didSendDismiss = true
|
|
1436
|
+
onDismiss?()
|
|
1437
|
+
}
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
// MARK: - Inline Profile View Wrapper (for embedded rendering)
|
|
1441
|
+
|
|
1442
|
+
struct ClerkInlineProfileWrapperView: View {
|
|
1443
|
+
@Environment(Clerk.self) private var clerk
|
|
1444
|
+
@Environment(\.colorScheme) private var colorScheme
|
|
1445
|
+
|
|
1446
|
+
let dismissible: Bool
|
|
1447
|
+
let hostBackAction: ClerkHostBackAction?
|
|
1448
|
+
let lightTheme: ClerkTheme?
|
|
1449
|
+
let darkTheme: ClerkTheme?
|
|
1450
|
+
let customRows: [ClerkUserProfileCustomRowConfig]
|
|
1451
|
+
let customPageState: ClerkUserProfileCustomPageState
|
|
1452
|
+
|
|
1453
|
+
@State private var navigationPath = NavigationPath()
|
|
1454
|
+
@State private var didRestoreNavigation = false
|
|
1455
|
+
|
|
1456
|
+
private var userID: String? {
|
|
1457
|
+
clerk.user?.id
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
var body: some View {
|
|
1461
|
+
let view = NavigationStack(path: $navigationPath) {
|
|
1462
|
+
UserProfileView(
|
|
1463
|
+
isDismissible: dismissible,
|
|
1464
|
+
navigationPath: $navigationPath
|
|
1465
|
+
)
|
|
1466
|
+
.userProfileRows(customRows.filter(\.shouldShowAsRow).map(\.nativeRow))
|
|
1467
|
+
.navigationDestination(for: String.self) { routeKey in
|
|
1468
|
+
ClerkReactEmbeddedUserProfileCustomPage(
|
|
1469
|
+
path: routeKey,
|
|
1470
|
+
rows: customRows,
|
|
1471
|
+
state: customPageState,
|
|
1472
|
+
navigationPath: $navigationPath
|
|
1473
|
+
)
|
|
1474
|
+
}
|
|
1475
|
+
}
|
|
1476
|
+
.environment(\.clerkHostBackAction, hostBackAction)
|
|
1477
|
+
let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme
|
|
1478
|
+
let themedView = Group {
|
|
1479
|
+
if let theme {
|
|
1480
|
+
view.environment(\.clerkTheme, theme)
|
|
1481
|
+
} else {
|
|
1482
|
+
view
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
themedView
|
|
1486
|
+
.onAppear {
|
|
1487
|
+
customPageState.configureNavigation($navigationPath)
|
|
1488
|
+
customPageState.userDidChange(to: userID)
|
|
1489
|
+
}
|
|
1490
|
+
.onChange(of: navigationPath.count) { _, _ in
|
|
1491
|
+
customPageState.navigationPathDidChange(navigationPath)
|
|
1492
|
+
}
|
|
1493
|
+
.onChange(of: userID) { _, newUserID in
|
|
1494
|
+
customPageState.userDidChange(to: newUserID)
|
|
1495
|
+
}
|
|
1496
|
+
.task(restoreNavigationIfNeeded)
|
|
1497
|
+
}
|
|
1498
|
+
|
|
1499
|
+
@MainActor
|
|
1500
|
+
private func restoreNavigationIfNeeded() async {
|
|
1501
|
+
guard !didRestoreNavigation else { return }
|
|
1502
|
+
await Task.yield()
|
|
1503
|
+
guard !Task.isCancelled else { return }
|
|
1504
|
+
navigationPath = customPageState.navigationPathForRestoration()
|
|
1505
|
+
didRestoreNavigation = true
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
private struct ClerkReactUserProfileCustomPage: View {
|
|
1510
|
+
@Environment(UserProfileNavigator<String>.self) private var navigator
|
|
1511
|
+
@Environment(\.dismiss) private var dismiss
|
|
1512
|
+
|
|
1513
|
+
let path: String
|
|
1514
|
+
let rows: [ClerkUserProfileCustomRowConfig]
|
|
1515
|
+
let state: ClerkUserProfileCustomPageState
|
|
1516
|
+
|
|
1517
|
+
var body: some View {
|
|
1518
|
+
ClerkReactUserProfileCustomPageContent(path: path, rows: rows, state: state)
|
|
1519
|
+
.onAppear {
|
|
1520
|
+
state.configureNavigation(navigator) {
|
|
1521
|
+
dismiss()
|
|
1522
|
+
}
|
|
1523
|
+
state.pageDidPresent(path: path)
|
|
1524
|
+
}
|
|
1525
|
+
.onDisappear {
|
|
1526
|
+
state.pageDidDismiss(path: path)
|
|
1527
|
+
}
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
private struct ClerkReactEmbeddedUserProfileCustomPage: View {
|
|
1532
|
+
let path: String
|
|
1533
|
+
let rows: [ClerkUserProfileCustomRowConfig]
|
|
1534
|
+
let state: ClerkUserProfileCustomPageState
|
|
1535
|
+
@Binding var navigationPath: NavigationPath
|
|
1536
|
+
|
|
1537
|
+
var body: some View {
|
|
1538
|
+
ClerkReactUserProfileCustomPageContent(path: path, rows: rows, state: state)
|
|
1539
|
+
.onAppear {
|
|
1540
|
+
state.configureNavigation($navigationPath)
|
|
1541
|
+
state.pageDidPresent(path: path, navigationDepth: navigationPath.count)
|
|
1542
|
+
}
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
private struct ClerkReactUserProfileCustomPageContent: View {
|
|
1547
|
+
let path: String
|
|
1548
|
+
let rows: [ClerkUserProfileCustomRowConfig]
|
|
1549
|
+
let state: ClerkUserProfileCustomPageState
|
|
1550
|
+
|
|
1551
|
+
var body: some View {
|
|
1552
|
+
Group {
|
|
1553
|
+
if let index = rows.firstIndex(where: { $0.path == path }),
|
|
1554
|
+
state.views.indices.contains(index)
|
|
1555
|
+
{
|
|
1556
|
+
ClerkReactCustomPageView(view: state.views[index])
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
1560
|
+
.navigationTitle(userProfileCustomPageLabel(for: path, rows: rows))
|
|
1561
|
+
.navigationBarTitleDisplayMode(.inline)
|
|
1562
|
+
}
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
private struct ClerkReactCustomPageView: UIViewRepresentable {
|
|
1566
|
+
let view: UIView
|
|
1567
|
+
|
|
1568
|
+
func makeUIView(context: Context) -> ClerkReactContentContainerView {
|
|
1569
|
+
ClerkReactContentContainerView(contentView: view)
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
func updateUIView(_ uiView: ClerkReactContentContainerView, context: Context) {
|
|
1573
|
+
uiView.setContentView(view)
|
|
1574
|
+
}
|
|
1575
|
+
}
|