@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.
Files changed (108) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +106 -2
  3. package/android/build.gradle +152 -0
  4. package/android/src/main/AndroidManifest.xml +4 -0
  5. package/android/src/main/java/expo/modules/clerk/ClerkAuthViewModule.kt +241 -0
  6. package/android/src/main/java/expo/modules/clerk/ClerkComposeNativeViewHost.kt +148 -0
  7. package/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt +1077 -0
  8. package/android/src/main/java/expo/modules/clerk/ClerkUserButtonViewModule.kt +156 -0
  9. package/android/src/main/java/expo/modules/clerk/ClerkUserProfileCustomPageState.kt +120 -0
  10. package/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewModule.kt +315 -0
  11. package/android/src/test/java/expo/modules/clerk/BiometricCredentialBridgeTest.kt +326 -0
  12. package/android/src/test/java/expo/modules/clerk/ClerkUserProfileCustomPageStateTest.kt +163 -0
  13. package/app.plugin.d.ts +5 -0
  14. package/app.plugin.js +333 -0
  15. package/dist/AuthView.d.ts +35 -0
  16. package/dist/AuthView.d.ts.map +1 -0
  17. package/dist/AuthView.js +79 -0
  18. package/dist/AuthView.js.map +1 -0
  19. package/dist/AuthView.types.d.ts +62 -0
  20. package/dist/AuthView.types.d.ts.map +1 -0
  21. package/dist/AuthView.types.js +0 -0
  22. package/dist/EmbeddedNavigation.types.d.ts +24 -0
  23. package/dist/EmbeddedNavigation.types.d.ts.map +1 -0
  24. package/dist/EmbeddedNavigation.types.js +0 -0
  25. package/dist/UserButton.d.ts +38 -0
  26. package/dist/UserButton.d.ts.map +1 -0
  27. package/dist/UserButton.js +68 -0
  28. package/dist/UserButton.js.map +1 -0
  29. package/dist/UserProfileCustomPages.d.ts +81 -0
  30. package/dist/UserProfileCustomPages.d.ts.map +1 -0
  31. package/dist/UserProfileCustomPages.js +140 -0
  32. package/dist/UserProfileCustomPages.js.map +1 -0
  33. package/dist/UserProfileView.d.ts +76 -0
  34. package/dist/UserProfileView.d.ts.map +1 -0
  35. package/dist/UserProfileView.js +101 -0
  36. package/dist/UserProfileView.js.map +1 -0
  37. package/dist/_virtual/_rolldown/runtime.js +29 -0
  38. package/dist/index.d.ts +41 -0
  39. package/dist/index.d.ts.map +1 -0
  40. package/dist/index.js +37 -0
  41. package/dist/specs/NativeClerkAuthView.android.d.ts +15 -0
  42. package/dist/specs/NativeClerkAuthView.android.d.ts.map +1 -0
  43. package/dist/specs/NativeClerkAuthView.android.js +8 -0
  44. package/dist/specs/NativeClerkAuthView.android.js.map +1 -0
  45. package/dist/specs/NativeClerkAuthView.d.ts +15 -0
  46. package/dist/specs/NativeClerkAuthView.d.ts.map +1 -0
  47. package/dist/specs/NativeClerkAuthView.js +9 -0
  48. package/dist/specs/NativeClerkAuthView.js.map +1 -0
  49. package/dist/specs/NativeClerkModule.d.ts +4 -0
  50. package/dist/specs/NativeClerkModule.d.ts.map +1 -0
  51. package/dist/specs/NativeClerkModule.js +8 -0
  52. package/dist/specs/NativeClerkModule.js.map +1 -0
  53. package/dist/specs/NativeClerkModule.types.d.ts +11 -0
  54. package/dist/specs/NativeClerkModule.types.d.ts.map +1 -0
  55. package/dist/specs/NativeClerkModule.types.js +0 -0
  56. package/dist/specs/NativeClerkModule.web.d.ts +4 -0
  57. package/dist/specs/NativeClerkModule.web.d.ts.map +1 -0
  58. package/dist/specs/NativeClerkModule.web.js +7 -0
  59. package/dist/specs/NativeClerkModule.web.js.map +1 -0
  60. package/dist/specs/NativeClerkUserButtonView.android.d.ts +6 -0
  61. package/dist/specs/NativeClerkUserButtonView.android.d.ts.map +1 -0
  62. package/dist/specs/NativeClerkUserButtonView.android.js +8 -0
  63. package/dist/specs/NativeClerkUserButtonView.android.js.map +1 -0
  64. package/dist/specs/NativeClerkUserButtonView.d.ts +6 -0
  65. package/dist/specs/NativeClerkUserButtonView.d.ts.map +1 -0
  66. package/dist/specs/NativeClerkUserButtonView.js +9 -0
  67. package/dist/specs/NativeClerkUserButtonView.js.map +1 -0
  68. package/dist/specs/NativeClerkUserProfileView.android.d.ts +13 -0
  69. package/dist/specs/NativeClerkUserProfileView.android.d.ts.map +1 -0
  70. package/dist/specs/NativeClerkUserProfileView.android.js +8 -0
  71. package/dist/specs/NativeClerkUserProfileView.android.js.map +1 -0
  72. package/dist/specs/NativeClerkUserProfileView.d.ts +13 -0
  73. package/dist/specs/NativeClerkUserProfileView.d.ts.map +1 -0
  74. package/dist/specs/NativeClerkUserProfileView.js +9 -0
  75. package/dist/specs/NativeClerkUserProfileView.js.map +1 -0
  76. package/dist/useAuthViewState.d.ts +12 -0
  77. package/dist/useAuthViewState.d.ts.map +1 -0
  78. package/dist/useAuthViewState.js +76 -0
  79. package/dist/useAuthViewState.js.map +1 -0
  80. package/dist/utils/native-module.d.ts +3 -0
  81. package/dist/utils/native-module.d.ts.map +1 -0
  82. package/dist/utils/native-module.js +14 -0
  83. package/dist/utils/native-module.js.map +1 -0
  84. package/expo-module.config.json +15 -0
  85. package/ios/ClerkAppDelegateSubscriber.swift +16 -0
  86. package/ios/ClerkAuthNativeView.swift +157 -0
  87. package/ios/ClerkExpo.podspec +67 -0
  88. package/ios/ClerkExpoModule.swift +315 -0
  89. package/ios/ClerkNativeBridge.swift +1575 -0
  90. package/ios/ClerkNativeViewHost.swift +204 -0
  91. package/ios/ClerkUserButtonNativeView.swift +34 -0
  92. package/ios/ClerkUserProfileNativeView.swift +92 -0
  93. package/ios/Tests/ClerkAuthNativeViewPaperTests.swift +30 -0
  94. package/ios/Tests/ClerkNativeBridgeTests.swift +185 -0
  95. package/ios/Tests/ClerkNativeViewHostTests.swift +66 -0
  96. package/ios/Tests/ClerkUserProfileCustomPageStateTests.swift +311 -0
  97. package/package.json +79 -3
  98. package/react-native.config.js +8 -0
  99. package/src/specs/NativeClerkAuthView.android.ts +15 -0
  100. package/src/specs/NativeClerkAuthView.ts +19 -0
  101. package/src/specs/NativeClerkModule.ts +6 -0
  102. package/src/specs/NativeClerkModule.types.ts +9 -0
  103. package/src/specs/NativeClerkModule.web.ts +3 -0
  104. package/src/specs/NativeClerkUserButtonView.android.ts +9 -0
  105. package/src/specs/NativeClerkUserButtonView.ts +11 -0
  106. package/src/specs/NativeClerkUserProfileView.android.ts +13 -0
  107. package/src/specs/NativeClerkUserProfileView.ts +17 -0
  108. 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
+ }