@clerk/expo 3.2.0-snapshot.v20260410221756 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -275,7 +275,7 @@ class ClerkAuthActivity : ComponentActivity() {
275
275
  // Client is ready, show AuthView
276
276
  AuthView(
277
277
  modifier = Modifier.fillMaxSize(),
278
- clerkTheme = null // Use default theme, or pass custom
278
+ clerkTheme = Clerk.customTheme
279
279
  )
280
280
  }
281
281
  else -> {
@@ -126,7 +126,7 @@ class ClerkAuthNativeView(context: Context) : FrameLayout(context) {
126
126
  ) {
127
127
  AuthView(
128
128
  modifier = Modifier.fillMaxSize(),
129
- clerkTheme = null
129
+ clerkTheme = Clerk.customTheme
130
130
  )
131
131
  }
132
132
  }
@@ -4,8 +4,13 @@ import android.app.Activity
4
4
  import android.content.Context
5
5
  import android.content.Intent
6
6
  import android.util.Log
7
+ import androidx.compose.ui.graphics.Color
8
+ import androidx.compose.ui.unit.dp
7
9
  import com.clerk.api.Clerk
8
10
  import com.clerk.api.network.serialization.ClerkResult
11
+ import com.clerk.api.ui.ClerkColors
12
+ import com.clerk.api.ui.ClerkDesign
13
+ import com.clerk.api.ui.ClerkTheme
9
14
  import com.facebook.react.bridge.ActivityEventListener
10
15
  import com.facebook.react.bridge.Promise
11
16
  import com.facebook.react.bridge.ReactApplicationContext
@@ -18,6 +23,7 @@ import kotlinx.coroutines.TimeoutCancellationException
18
23
  import kotlinx.coroutines.flow.first
19
24
  import kotlinx.coroutines.launch
20
25
  import kotlinx.coroutines.withTimeout
26
+ import org.json.JSONObject
21
27
 
22
28
  private const val TAG = "ClerkExpoModule"
23
29
 
@@ -79,6 +85,13 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
79
85
  }
80
86
 
81
87
  Clerk.initialize(reactApplicationContext, pubKey)
88
+ // Theme loading is centralized here. ClerkViewFactory.configure()
89
+ // and ClerkUserProfileActivity.onCreate() only call Clerk.initialize()
90
+ // when Clerk is not yet initialized, so by the time they run
91
+ // ClerkExpoModule has already set the custom theme.
92
+ // Must be set AFTER Clerk.initialize() because initialize()
93
+ // resets customTheme to its `theme` parameter (default null).
94
+ loadThemeFromAssets()
82
95
 
83
96
  // Wait for initialization to complete with timeout
84
97
  try {
@@ -371,4 +384,83 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) :
371
384
 
372
385
  promise.resolve(result)
373
386
  }
387
+
388
+ // MARK: - Theme Loading
389
+
390
+ private fun loadThemeFromAssets() {
391
+ try {
392
+ val jsonString = reactApplicationContext.assets
393
+ .open("clerk_theme.json")
394
+ .bufferedReader()
395
+ .use { it.readText() }
396
+ val json = JSONObject(jsonString)
397
+ Clerk.customTheme = parseClerkTheme(json)
398
+ } catch (e: java.io.FileNotFoundException) {
399
+ // No theme file provided — use defaults
400
+ } catch (e: Exception) {
401
+ debugLog(TAG, "Failed to load clerk_theme.json: ${e.message}")
402
+ }
403
+ }
404
+
405
+ private fun parseClerkTheme(json: JSONObject): ClerkTheme {
406
+ val colors = json.optJSONObject("colors")?.let { parseColors(it) }
407
+ val darkColors = json.optJSONObject("darkColors")?.let { parseColors(it) }
408
+ val design = json.optJSONObject("design")?.let { parseDesign(it) }
409
+ return ClerkTheme(
410
+ colors = colors,
411
+ darkColors = darkColors,
412
+ design = design
413
+ )
414
+ }
415
+
416
+ private fun parseColors(json: JSONObject): ClerkColors {
417
+ return ClerkColors(
418
+ primary = json.optStringColor("primary"),
419
+ background = json.optStringColor("background"),
420
+ input = json.optStringColor("input"),
421
+ danger = json.optStringColor("danger"),
422
+ success = json.optStringColor("success"),
423
+ warning = json.optStringColor("warning"),
424
+ foreground = json.optStringColor("foreground"),
425
+ mutedForeground = json.optStringColor("mutedForeground"),
426
+ primaryForeground = json.optStringColor("primaryForeground"),
427
+ inputForeground = json.optStringColor("inputForeground"),
428
+ neutral = json.optStringColor("neutral"),
429
+ border = json.optStringColor("border"),
430
+ ring = json.optStringColor("ring"),
431
+ muted = json.optStringColor("muted"),
432
+ shadow = json.optStringColor("shadow")
433
+ )
434
+ }
435
+
436
+ private fun parseDesign(json: JSONObject): ClerkDesign {
437
+ return if (json.has("borderRadius")) {
438
+ ClerkDesign(borderRadius = json.getDouble("borderRadius").toFloat().dp)
439
+ } else {
440
+ ClerkDesign()
441
+ }
442
+ }
443
+
444
+ private fun parseHexColor(hex: String): Color? {
445
+ val cleaned = hex.removePrefix("#")
446
+ return try {
447
+ when (cleaned.length) {
448
+ 6 -> Color(android.graphics.Color.parseColor("#FF$cleaned"))
449
+ // Theme JSON uses RRGGBBAA; Android parseColor expects AARRGGBB
450
+ 8 -> {
451
+ val rrggbb = cleaned.substring(0, 6)
452
+ val aa = cleaned.substring(6, 8)
453
+ Color(android.graphics.Color.parseColor("#$aa$rrggbb"))
454
+ }
455
+ else -> null
456
+ }
457
+ } catch (e: Exception) {
458
+ null
459
+ }
460
+ }
461
+
462
+ private fun JSONObject.optStringColor(key: String): Color? {
463
+ val value = optString(key, null) ?: return null
464
+ return parseHexColor(value)
465
+ }
374
466
  }
package/app.plugin.js CHANGED
@@ -585,6 +585,123 @@ const withClerkAppleSignIn = config => {
585
585
  });
586
586
  };
587
587
 
588
+ /**
589
+ * Apply a custom theme to Clerk native components (iOS + Android).
590
+ *
591
+ * Accepts a `theme` prop pointing to a JSON file with optional keys:
592
+ * - colors: { primary, background, input, danger, success, warning,
593
+ * foreground, mutedForeground, primaryForeground, inputForeground,
594
+ * neutral, border, ring, muted, shadow } (hex color strings)
595
+ * - darkColors: same keys as colors (for dark mode)
596
+ * - design: { fontFamily: string, borderRadius: number }
597
+ *
598
+ * iOS: Embeds the parsed JSON into Info.plist under key "ClerkTheme".
599
+ * Android: Copies the JSON file to android/app/src/main/assets/clerk_theme.json.
600
+ */
601
+ const VALID_COLOR_KEYS = [
602
+ 'primary',
603
+ 'background',
604
+ 'input',
605
+ 'danger',
606
+ 'success',
607
+ 'warning',
608
+ 'foreground',
609
+ 'mutedForeground',
610
+ 'primaryForeground',
611
+ 'inputForeground',
612
+ 'neutral',
613
+ 'border',
614
+ 'ring',
615
+ 'muted',
616
+ 'shadow',
617
+ ];
618
+
619
+ const HEX_COLOR_REGEX = /^#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/;
620
+
621
+ function isPlainObject(value) {
622
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
623
+ }
624
+
625
+ function validateThemeJson(theme) {
626
+ if (!isPlainObject(theme)) {
627
+ throw new Error('Clerk theme: theme JSON must be a plain object');
628
+ }
629
+
630
+ const validateColors = (colors, label) => {
631
+ if (!isPlainObject(colors)) {
632
+ throw new Error(`Clerk theme: ${label} must be an object`);
633
+ }
634
+ for (const [key, value] of Object.entries(colors)) {
635
+ if (!VALID_COLOR_KEYS.includes(key)) {
636
+ console.warn(`⚠️ Clerk theme: unknown color key "${key}" in ${label}, ignoring`);
637
+ continue;
638
+ }
639
+ if (typeof value !== 'string' || !HEX_COLOR_REGEX.test(value)) {
640
+ throw new Error(`Clerk theme: invalid hex color for ${label}.${key}: "${value}"`);
641
+ }
642
+ }
643
+ };
644
+
645
+ if (theme.colors != null) validateColors(theme.colors, 'colors');
646
+ if (theme.darkColors != null) validateColors(theme.darkColors, 'darkColors');
647
+
648
+ if (theme.design != null) {
649
+ if (!isPlainObject(theme.design)) {
650
+ throw new Error(`Clerk theme: design must be an object`);
651
+ }
652
+ if (theme.design.fontFamily != null && typeof theme.design.fontFamily !== 'string') {
653
+ throw new Error(`Clerk theme: design.fontFamily must be a string`);
654
+ }
655
+ if (theme.design.borderRadius != null && typeof theme.design.borderRadius !== 'number') {
656
+ throw new Error(`Clerk theme: design.borderRadius must be a number`);
657
+ }
658
+ }
659
+ }
660
+
661
+ const withClerkTheme = (config, props = {}) => {
662
+ const { theme } = props;
663
+ if (!theme) return config;
664
+
665
+ // Resolve the theme file path relative to the project root
666
+ const themePath = path.resolve(theme);
667
+ if (!fs.existsSync(themePath)) {
668
+ console.warn(`⚠️ Clerk theme file not found: ${themePath}, skipping theme`);
669
+ return config;
670
+ }
671
+
672
+ let themeJson;
673
+ try {
674
+ themeJson = JSON.parse(fs.readFileSync(themePath, 'utf8'));
675
+ validateThemeJson(themeJson);
676
+ } catch (e) {
677
+ throw new Error(`Clerk theme: failed to parse ${themePath}: ${e.message}`);
678
+ }
679
+
680
+ // iOS: Embed theme in Info.plist under "ClerkTheme"
681
+ config = withInfoPlist(config, modConfig => {
682
+ modConfig.modResults.ClerkTheme = themeJson;
683
+ console.log('✅ Embedded Clerk theme in Info.plist');
684
+ return modConfig;
685
+ });
686
+
687
+ // Android: Copy theme JSON to assets
688
+ config = withDangerousMod(config, [
689
+ 'android',
690
+ async config => {
691
+ const assetsDir = path.join(config.modRequest.platformProjectRoot, 'app', 'src', 'main', 'assets');
692
+ if (!fs.existsSync(assetsDir)) {
693
+ fs.mkdirSync(assetsDir, { recursive: true });
694
+ }
695
+ const destPath = path.join(assetsDir, 'clerk_theme.json');
696
+ fs.writeFileSync(destPath, JSON.stringify(themeJson, null, 2) + '\n');
697
+ console.log('✅ Copied Clerk theme to Android assets');
698
+ return config;
699
+ },
700
+ ]);
701
+
702
+ return config;
703
+ };
704
+
588
705
  const withClerkExpo = (config, props = {}) => {
589
706
  const { appleSignIn = true } = props;
590
707
  config = withClerkIOS(config);
@@ -594,7 +711,9 @@ const withClerkExpo = (config, props = {}) => {
594
711
  config = withClerkGoogleSignIn(config);
595
712
  config = withClerkAndroid(config);
596
713
  config = withClerkKeychainService(config, props);
714
+ config = withClerkTheme(config, props);
597
715
  return config;
598
716
  };
599
717
 
600
718
  module.exports = withClerkExpo;
719
+ module.exports._testing = { validateThemeJson, isPlainObject, VALID_COLOR_KEYS, HEX_COLOR_REGEX };
@@ -1,3 +1,3 @@
1
- declare const errorThrower: import("@clerk/shared/index-ZRXKVsvy").rr;
1
+ declare const errorThrower: import("@clerk/shared/index-gwPUTb24").rr;
2
2
  export { errorThrower };
3
3
  //# sourceMappingURL=errorThrower.d.ts.map
@@ -1,4 +1,4 @@
1
- export { useClerk, useEmailLink, useOrganization, useOrganizationList, useSession, useSessionList, useSignIn, useSignUp, useWaitlist, useUser, useReverification, useAPIKeys, useOAuthConsent, } from '@clerk/react';
1
+ export { useClerk, useEmailLink, useOrganization, useOrganizationList, useSession, useSessionList, useSignIn, useSignUp, useWaitlist, useUser, useReverification, useAPIKeys, } from '@clerk/react';
2
2
  export * from './useSSO';
3
3
  export * from './useOAuth';
4
4
  export * from './useAuth';
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/hooks/index.ts"],"names":[],"mappings":"AACA,OAAO,EACL,QAAQ,EACR,YAAY,EACZ,eAAe,EACf,mBAAmB,EACnB,UAAU,EACV,cAAc,EACd,SAAS,EACT,SAAS,EACT,WAAW,EACX,OAAO,EACP,iBAAiB,EACjB,UAAU,EACV,eAAe,GAChB,MAAM,cAAc,CAAC;AAEtB,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,WAAW,CAAC;AAC1B,cAAc,oBAAoB,CAAC;AACnC,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/hooks/index.ts"],"names":[],"mappings":"AACA,OAAO,EACL,QAAQ,EACR,YAAY,EACZ,eAAe,EACf,mBAAmB,EACnB,UAAU,EACV,cAAc,EACd,SAAS,EACT,SAAS,EACT,WAAW,EACX,OAAO,EACP,iBAAiB,EACjB,UAAU,GACX,MAAM,cAAc,CAAC;AAEtB,cAAc,UAAU,CAAC;AACzB,cAAc,YAAY,CAAC;AAC3B,cAAc,WAAW,CAAC;AAC1B,cAAc,oBAAoB,CAAC;AACnC,cAAc,uBAAuB,CAAC;AACtC,cAAc,uBAAuB,CAAC"}
@@ -22,7 +22,6 @@ __export(hooks_exports, {
22
22
  useAPIKeys: () => import_react.useAPIKeys,
23
23
  useClerk: () => import_react.useClerk,
24
24
  useEmailLink: () => import_react.useEmailLink,
25
- useOAuthConsent: () => import_react.useOAuthConsent,
26
25
  useOrganization: () => import_react.useOrganization,
27
26
  useOrganizationList: () => import_react.useOrganizationList,
28
27
  useReverification: () => import_react.useReverification,
@@ -46,7 +45,6 @@ __reExport(hooks_exports, require("./useUserProfileModal"), module.exports);
46
45
  useAPIKeys,
47
46
  useClerk,
48
47
  useEmailLink,
49
- useOAuthConsent,
50
48
  useOrganization,
51
49
  useOrganizationList,
52
50
  useReverification,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/hooks/index.ts"],"sourcesContent":["// Re-export hooks that don't need type overrides\nexport {\n useClerk,\n useEmailLink,\n useOrganization,\n useOrganizationList,\n useSession,\n useSessionList,\n useSignIn,\n useSignUp,\n useWaitlist,\n useUser,\n useReverification,\n useAPIKeys,\n useOAuthConsent,\n} from '@clerk/react';\n\nexport * from './useSSO';\nexport * from './useOAuth';\nexport * from './useAuth';\nexport * from './useNativeSession';\nexport * from './useNativeAuthEvents';\nexport * from './useUserProfileModal';\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AACA,mBAcO;AAEP,0BAAc,qBAjBd;AAkBA,0BAAc,uBAlBd;AAmBA,0BAAc,sBAnBd;AAoBA,0BAAc,+BApBd;AAqBA,0BAAc,kCArBd;AAsBA,0BAAc,kCAtBd;","names":[]}
1
+ {"version":3,"sources":["../../src/hooks/index.ts"],"sourcesContent":["// Re-export hooks that don't need type overrides\nexport {\n useClerk,\n useEmailLink,\n useOrganization,\n useOrganizationList,\n useSession,\n useSessionList,\n useSignIn,\n useSignUp,\n useWaitlist,\n useUser,\n useReverification,\n useAPIKeys,\n} from '@clerk/react';\n\nexport * from './useSSO';\nexport * from './useOAuth';\nexport * from './useAuth';\nexport * from './useNativeSession';\nexport * from './useNativeAuthEvents';\nexport * from './useUserProfileModal';\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AAAA;AACA,mBAaO;AAEP,0BAAc,qBAhBd;AAiBA,0BAAc,uBAjBd;AAkBA,0BAAc,sBAlBd;AAmBA,0BAAc,+BAnBd;AAoBA,0BAAc,kCApBd;AAqBA,0BAAc,kCArBd;","names":[]}
@@ -45,7 +45,7 @@ var import_runtime = require("../utils/runtime");
45
45
  var import_singleton = require("./singleton");
46
46
  const SDK_METADATA = {
47
47
  name: "@clerk/expo",
48
- version: "3.2.0-snapshot.v20260410221756"
48
+ version: "3.2.0"
49
49
  };
50
50
  function NativeSessionSync({
51
51
  publishableKey,
@@ -1,4 +1,4 @@
1
1
  import type { DomainOrProxyUrl } from '@clerk/shared/types';
2
- export declare const errorThrower: import("@clerk/shared/index-ZRXKVsvy").rr;
2
+ export declare const errorThrower: import("@clerk/shared/index-gwPUTb24").rr;
3
3
  export declare const assertValidProxyUrl: (proxyUrl: DomainOrProxyUrl["proxyUrl"]) => void;
4
4
  //# sourceMappingURL=errors.d.ts.map
@@ -18,6 +18,10 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol {
18
18
  private static let clerkLoadIntervalNs: UInt64 = 100_000_000
19
19
  private static var clerkConfigured = false
20
20
 
21
+ /// Parsed light and dark themes from Info.plist "ClerkTheme" dictionary.
22
+ var lightTheme: ClerkTheme?
23
+ var darkTheme: ClerkTheme?
24
+
21
25
  private enum KeychainKey {
22
26
  static let jsClientJWT = "__clerk_client_jwt"
23
27
  static let nativeDeviceToken = "clerkDeviceToken"
@@ -42,7 +46,8 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol {
42
46
  }
43
47
 
44
48
  // Register this factory with the ClerkExpo module
45
- public static func register() {
49
+ @MainActor public static func register() {
50
+ shared.loadThemes()
46
51
  clerkViewFactory = shared
47
52
  }
48
53
 
@@ -152,6 +157,8 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol {
152
157
  let wrapper = ClerkAuthWrapperViewController(
153
158
  mode: Self.authMode(from: mode),
154
159
  dismissable: dismissable,
160
+ lightTheme: lightTheme,
161
+ darkTheme: darkTheme,
155
162
  completion: completion
156
163
  )
157
164
  return wrapper
@@ -163,6 +170,8 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol {
163
170
  ) -> UIViewController? {
164
171
  let wrapper = ClerkProfileWrapperViewController(
165
172
  dismissable: dismissable,
173
+ lightTheme: lightTheme,
174
+ darkTheme: darkTheme,
166
175
  completion: completion
167
176
  )
168
177
  return wrapper
@@ -179,6 +188,8 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol {
179
188
  rootView: ClerkInlineAuthWrapperView(
180
189
  mode: Self.authMode(from: mode),
181
190
  dismissable: dismissable,
191
+ lightTheme: lightTheme,
192
+ darkTheme: darkTheme,
182
193
  onEvent: onEvent
183
194
  )
184
195
  )
@@ -191,6 +202,8 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol {
191
202
  makeHostingController(
192
203
  rootView: ClerkInlineProfileWrapperView(
193
204
  dismissable: dismissable,
205
+ lightTheme: lightTheme,
206
+ darkTheme: darkTheme,
194
207
  onEvent: onEvent
195
208
  )
196
209
  )
@@ -226,6 +239,91 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol {
226
239
  }
227
240
  }
228
241
 
242
+ // MARK: - Theme Parsing
243
+
244
+ /// Reads the "ClerkTheme" dictionary from Info.plist and builds light / dark themes.
245
+ @MainActor func loadThemes() {
246
+ guard let themeDictionary = Bundle.main.object(forInfoDictionaryKey: "ClerkTheme") as? [String: Any] else {
247
+ return
248
+ }
249
+
250
+ // Build light theme from top-level "colors" and "design"
251
+ let lightColors = (themeDictionary["colors"] as? [String: String]).flatMap { parseColors(from: $0) }
252
+ let design = (themeDictionary["design"] as? [String: Any]).flatMap { parseDesign(from: $0) }
253
+ let fonts = (themeDictionary["design"] as? [String: Any]).flatMap { parseFonts(from: $0) }
254
+
255
+ if lightColors != nil || design != nil || fonts != nil {
256
+ lightTheme = ClerkTheme(colors: lightColors ?? .default, fonts: fonts ?? .default, design: design ?? .default)
257
+ }
258
+
259
+ // Build dark theme from "darkColors" (inherits same design/fonts)
260
+ if let darkColorsDict = themeDictionary["darkColors"] as? [String: String] {
261
+ let darkColors = parseColors(from: darkColorsDict)
262
+ if darkColors != nil || design != nil || fonts != nil {
263
+ darkTheme = ClerkTheme(colors: darkColors ?? .default, fonts: fonts ?? .default, design: design ?? .default)
264
+ }
265
+ }
266
+ }
267
+
268
+ private func parseColors(from dict: [String: String]) -> ClerkTheme.Colors? {
269
+ let hasAny = dict.values.contains { colorFromHex($0) != nil }
270
+ guard hasAny else { return nil }
271
+
272
+ return ClerkTheme.Colors(
273
+ primary: dict["primary"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultPrimaryColor,
274
+ background: dict["background"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultBackgroundColor,
275
+ input: dict["input"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultInputColor,
276
+ danger: dict["danger"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultDangerColor,
277
+ success: dict["success"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultSuccessColor,
278
+ warning: dict["warning"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultWarningColor,
279
+ foreground: dict["foreground"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultForegroundColor,
280
+ mutedForeground: dict["mutedForeground"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultMutedForegroundColor,
281
+ primaryForeground: dict["primaryForeground"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultPrimaryForegroundColor,
282
+ inputForeground: dict["inputForeground"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultInputForegroundColor,
283
+ neutral: dict["neutral"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultNeutralColor,
284
+ ring: dict["ring"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultRingColor,
285
+ muted: dict["muted"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultMutedColor,
286
+ shadow: dict["shadow"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultShadowColor,
287
+ border: dict["border"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultBorderColor
288
+ )
289
+ }
290
+
291
+ private func colorFromHex(_ hex: String) -> Color? {
292
+ var cleaned = hex.trimmingCharacters(in: .whitespacesAndNewlines)
293
+ if cleaned.hasPrefix("#") { cleaned.removeFirst() }
294
+
295
+ var rgb: UInt64 = 0
296
+ guard Scanner(string: cleaned).scanHexInt64(&rgb) else { return nil }
297
+
298
+ switch cleaned.count {
299
+ case 6:
300
+ return Color(
301
+ red: Double((rgb >> 16) & 0xFF) / 255.0,
302
+ green: Double((rgb >> 8) & 0xFF) / 255.0,
303
+ blue: Double(rgb & 0xFF) / 255.0
304
+ )
305
+ case 8:
306
+ return Color(
307
+ red: Double((rgb >> 24) & 0xFF) / 255.0,
308
+ green: Double((rgb >> 16) & 0xFF) / 255.0,
309
+ blue: Double((rgb >> 8) & 0xFF) / 255.0,
310
+ opacity: Double(rgb & 0xFF) / 255.0
311
+ )
312
+ default:
313
+ return nil
314
+ }
315
+ }
316
+
317
+ private func parseFonts(from dict: [String: Any]) -> ClerkTheme.Fonts? {
318
+ guard let fontFamily = dict["fontFamily"] as? String, !fontFamily.isEmpty else { return nil }
319
+ return ClerkTheme.Fonts(fontFamily: fontFamily)
320
+ }
321
+
322
+ private func parseDesign(from dict: [String: Any]) -> ClerkTheme.Design? {
323
+ guard let radius = dict["borderRadius"] as? Double else { return nil }
324
+ return ClerkTheme.Design(borderRadius: CGFloat(radius))
325
+ }
326
+
229
327
  private func makeHostingController<Content: View>(rootView: Content) -> UIViewController {
230
328
  let hostingController = UIHostingController(rootView: rootView)
231
329
  hostingController.view.backgroundColor = .clear
@@ -329,9 +427,9 @@ class ClerkAuthWrapperViewController: UIHostingController<ClerkAuthWrapperView>
329
427
  private var authEventTask: Task<Void, Never>?
330
428
  private var completionCalled = false
331
429
 
332
- init(mode: AuthView.Mode, dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) {
430
+ init(mode: AuthView.Mode, dismissable: Bool, lightTheme: ClerkTheme?, darkTheme: ClerkTheme?, completion: @escaping (Result<[String: Any], Error>) -> Void) {
333
431
  self.completion = completion
334
- let view = ClerkAuthWrapperView(mode: mode, dismissable: dismissable)
432
+ let view = ClerkAuthWrapperView(mode: mode, dismissable: dismissable, lightTheme: lightTheme, darkTheme: darkTheme)
335
433
  super.init(rootView: view)
336
434
  self.modalPresentationStyle = .fullScreen
337
435
  subscribeToAuthEvents()
@@ -398,10 +496,20 @@ class ClerkAuthWrapperViewController: UIHostingController<ClerkAuthWrapperView>
398
496
  struct ClerkAuthWrapperView: View {
399
497
  let mode: AuthView.Mode
400
498
  let dismissable: Bool
499
+ let lightTheme: ClerkTheme?
500
+ let darkTheme: ClerkTheme?
501
+
502
+ @Environment(\.colorScheme) private var colorScheme
401
503
 
402
504
  var body: some View {
403
- AuthView(mode: mode, isDismissable: dismissable)
505
+ let view = AuthView(mode: mode, isDismissable: dismissable)
404
506
  .environment(Clerk.shared)
507
+ let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme
508
+ if let theme {
509
+ view.environment(\.clerkTheme, theme)
510
+ } else {
511
+ view
512
+ }
405
513
  }
406
514
  }
407
515
 
@@ -412,9 +520,9 @@ class ClerkProfileWrapperViewController: UIHostingController<ClerkProfileWrapper
412
520
  private var authEventTask: Task<Void, Never>?
413
521
  private var completionCalled = false
414
522
 
415
- init(dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) {
523
+ init(dismissable: Bool, lightTheme: ClerkTheme?, darkTheme: ClerkTheme?, completion: @escaping (Result<[String: Any], Error>) -> Void) {
416
524
  self.completion = completion
417
- let view = ClerkProfileWrapperView(dismissable: dismissable)
525
+ let view = ClerkProfileWrapperView(dismissable: dismissable, lightTheme: lightTheme, darkTheme: darkTheme)
418
526
  super.init(rootView: view)
419
527
  self.modalPresentationStyle = .fullScreen
420
528
  subscribeToAuthEvents()
@@ -459,10 +567,20 @@ class ClerkProfileWrapperViewController: UIHostingController<ClerkProfileWrapper
459
567
 
460
568
  struct ClerkProfileWrapperView: View {
461
569
  let dismissable: Bool
570
+ let lightTheme: ClerkTheme?
571
+ let darkTheme: ClerkTheme?
572
+
573
+ @Environment(\.colorScheme) private var colorScheme
462
574
 
463
575
  var body: some View {
464
- UserProfileView(isDismissable: dismissable)
576
+ let view = UserProfileView(isDismissable: dismissable)
465
577
  .environment(Clerk.shared)
578
+ let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme
579
+ if let theme {
580
+ view.environment(\.clerkTheme, theme)
581
+ } else {
582
+ view
583
+ }
466
584
  }
467
585
  }
468
586
 
@@ -471,21 +589,37 @@ struct ClerkProfileWrapperView: View {
471
589
  struct ClerkInlineAuthWrapperView: View {
472
590
  let mode: AuthView.Mode
473
591
  let dismissable: Bool
592
+ let lightTheme: ClerkTheme?
593
+ let darkTheme: ClerkTheme?
474
594
  let onEvent: (String, [String: Any]) -> Void
475
595
 
476
596
  // Track initial session to detect new sign-ins (same approach as Android)
477
597
  @State private var initialSessionId: String? = Clerk.shared.session?.id
478
598
  @State private var eventSent = false
479
599
 
600
+ @Environment(\.colorScheme) private var colorScheme
601
+
480
602
  private func sendAuthCompleted(sessionId: String, type: String) {
481
603
  guard !eventSent, sessionId != initialSessionId else { return }
482
604
  eventSent = true
483
605
  onEvent(type, ["sessionId": sessionId, "type": type == "signUpCompleted" ? "signUp" : "signIn"])
484
606
  }
485
607
 
486
- var body: some View {
487
- AuthView(mode: mode, isDismissable: dismissable)
608
+ private var themedAuthView: some View {
609
+ let view = AuthView(mode: mode, isDismissable: dismissable)
488
610
  .environment(Clerk.shared)
611
+ let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme
612
+ return Group {
613
+ if let theme {
614
+ view.environment(\.clerkTheme, theme)
615
+ } else {
616
+ view
617
+ }
618
+ }
619
+ }
620
+
621
+ var body: some View {
622
+ themedAuthView
489
623
  // Primary detection: observe Clerk.shared.session directly (matches Android's sessionFlow approach).
490
624
  // This is more reliable than auth.events which may not emit for inline AuthView sign-ins.
491
625
  .onChange(of: Clerk.shared.session?.id) { _, newSessionId in
@@ -517,11 +651,24 @@ struct ClerkInlineAuthWrapperView: View {
517
651
 
518
652
  struct ClerkInlineProfileWrapperView: View {
519
653
  let dismissable: Bool
654
+ let lightTheme: ClerkTheme?
655
+ let darkTheme: ClerkTheme?
520
656
  let onEvent: (String, [String: Any]) -> Void
521
657
 
658
+ @Environment(\.colorScheme) private var colorScheme
659
+
522
660
  var body: some View {
523
- UserProfileView(isDismissable: dismissable)
661
+ let view = UserProfileView(isDismissable: dismissable)
524
662
  .environment(Clerk.shared)
663
+ let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme
664
+ let themedView = Group {
665
+ if let theme {
666
+ view.environment(\.clerkTheme, theme)
667
+ } else {
668
+ view
669
+ }
670
+ }
671
+ themedView
525
672
  .task {
526
673
  for await event in Clerk.shared.auth.events {
527
674
  switch event {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clerk/expo",
3
- "version": "3.2.0-snapshot.v20260410221756",
3
+ "version": "3.2.0",
4
4
  "description": "Clerk React Native/Expo library",
5
5
  "keywords": [
6
6
  "react",
@@ -104,9 +104,9 @@
104
104
  "base-64": "^1.0.0",
105
105
  "react-native-url-polyfill": "2.0.0",
106
106
  "tslib": "2.8.1",
107
- "@clerk/clerk-js": "6.7.0-snapshot.v20260410221756",
108
- "@clerk/react": "6.3.0-snapshot.v20260410221756",
109
- "@clerk/shared": "4.7.0-snapshot.v20260410221756"
107
+ "@clerk/clerk-js": "^6.7.3",
108
+ "@clerk/react": "^6.4.2",
109
+ "@clerk/shared": "^4.8.2"
110
110
  },
111
111
  "devDependencies": {
112
112
  "@expo/config-plugins": "^54.0.4",
@@ -120,10 +120,10 @@
120
120
  "expo-secure-store": "^12.8.1",
121
121
  "expo-web-browser": "^12.8.2",
122
122
  "react-native": "^0.81.4",
123
- "@clerk/expo-passkeys": "1.0.11-snapshot.v20260410221756"
123
+ "@clerk/expo-passkeys": "1.0.14"
124
124
  },
125
125
  "peerDependencies": {
126
- "@clerk/expo-passkeys": "1.0.11-snapshot.v20260410221756",
126
+ "@clerk/expo-passkeys": ">=0.0.6",
127
127
  "expo": ">=53 <56",
128
128
  "expo-apple-authentication": ">=7.0.0",
129
129
  "expo-auth-session": ">=5",
@@ -1,548 +0,0 @@
1
- // ClerkViewFactory - Provides Clerk view controllers to the ClerkExpo module
2
- // This file is injected into the app target by the config plugin.
3
- // It uses `import ClerkKit` (SPM) which is only accessible from the app target.
4
-
5
- import UIKit
6
- import SwiftUI
7
- import Security
8
- import ClerkKit
9
- import ClerkKitUI
10
- import ClerkExpo // Import the pod to access ClerkViewFactoryProtocol
11
-
12
- // MARK: - View Factory Implementation
13
-
14
- public class ClerkViewFactory: ClerkViewFactoryProtocol {
15
- public static let shared = ClerkViewFactory()
16
-
17
- private static let clerkLoadMaxAttempts = 30
18
- private static let clerkLoadIntervalNs: UInt64 = 100_000_000
19
- private static var clerkConfigured = false
20
-
21
- /// Resolves the keychain service name, checking ClerkKeychainService in Info.plist first
22
- /// (for extension apps sharing a keychain group), then falling back to the bundle identifier.
23
- private static var keychainService: String? {
24
- if let custom = Bundle.main.object(forInfoDictionaryKey: "ClerkKeychainService") as? String, !custom.isEmpty {
25
- return custom
26
- }
27
- return Bundle.main.bundleIdentifier
28
- }
29
-
30
- private init() {}
31
-
32
- // Register this factory with the ClerkExpo module
33
- public static func register() {
34
- clerkViewFactory = shared
35
- }
36
-
37
- @MainActor
38
- public func configure(publishableKey: String, bearerToken: String? = nil) async throws {
39
- // Sync JS SDK's client token to native keychain so both SDKs share the same client.
40
- // This handles the case where the user signed in via JS SDK but the native SDK
41
- // has no device token (e.g., after app reinstall or first launch).
42
- if let token = bearerToken, !token.isEmpty {
43
- let existingToken = Self.readNativeDeviceToken()
44
- Self.writeNativeDeviceToken(token)
45
-
46
- // If the device token changed (or didn't exist), clear stale cached client/environment.
47
- // A previous launch may have cached an anonymous client (no device token), and the
48
- // SDK would send both the new device token AND the stale client ID in API requests,
49
- // causing a 400 error. Clearing the cache forces a fresh client fetch using only
50
- // the device token.
51
- if existingToken != token {
52
- Self.clearCachedClerkData()
53
- }
54
- } else {
55
- Self.syncJSTokenToNativeKeychainIfNeeded()
56
- }
57
-
58
- // If already configured with a new bearer token, refresh the client
59
- // to pick up the session associated with the device token we just wrote.
60
- // Clerk.configure() is a no-op on subsequent calls, so we use refreshClient().
61
- if Self.clerkConfigured, let token = bearerToken, !token.isEmpty {
62
- _ = try? await Clerk.shared.refreshClient()
63
- return
64
- }
65
-
66
- Self.clerkConfigured = true
67
- if let service = Self.keychainService {
68
- Clerk.configure(
69
- publishableKey: publishableKey,
70
- options: .init(keychainConfig: .init(service: service))
71
- )
72
- } else {
73
- Clerk.configure(publishableKey: publishableKey)
74
- }
75
-
76
- // Wait for Clerk to finish loading (cached data + API refresh).
77
- // The static configure() fires off async refreshes; poll until loaded.
78
- for _ in 0..<Self.clerkLoadMaxAttempts {
79
- if Clerk.shared.isLoaded && Clerk.shared.session != nil {
80
- return
81
- }
82
- try? await Task.sleep(nanoseconds: Self.clerkLoadIntervalNs)
83
- }
84
- }
85
-
86
- /// Copies the JS SDK's client JWT from expo-secure-store to the native SDK's
87
- /// keychain entry, but only if the native SDK doesn't already have a device token.
88
- /// Both expo-secure-store and the native Clerk SDK use the iOS Keychain with the
89
- /// bundle identifier as the service name, making cross-SDK token sharing possible.
90
- private static func syncJSTokenToNativeKeychainIfNeeded() {
91
- guard let service = keychainService, !service.isEmpty else { return }
92
-
93
- let jsTokenKey = "__clerk_client_jwt"
94
- let nativeTokenKey = "clerkDeviceToken"
95
-
96
- // Check if native SDK already has a device token — don't overwrite
97
- let checkQuery: [String: Any] = [
98
- kSecClass as String: kSecClassGenericPassword,
99
- kSecAttrService as String: service,
100
- kSecAttrAccount as String: nativeTokenKey,
101
- kSecReturnData as String: false,
102
- kSecMatchLimit as String: kSecMatchLimitOne,
103
- ]
104
- if SecItemCopyMatching(checkQuery as CFDictionary, nil) == errSecSuccess {
105
- return // Native token exists, don't overwrite
106
- }
107
-
108
- // Read JS SDK's client JWT from keychain (stored by expo-secure-store)
109
- var result: CFTypeRef?
110
- let readQuery: [String: Any] = [
111
- kSecClass as String: kSecClassGenericPassword,
112
- kSecAttrService as String: service,
113
- kSecAttrAccount as String: jsTokenKey,
114
- kSecReturnData as String: true,
115
- kSecMatchLimit as String: kSecMatchLimitOne,
116
- ]
117
- guard SecItemCopyMatching(readQuery as CFDictionary, &result) == errSecSuccess,
118
- let data = result as? Data,
119
- let jsToken = String(data: data, encoding: .utf8),
120
- !jsToken.isEmpty else {
121
- return // No JS token available
122
- }
123
-
124
- // Write JS token as native device token
125
- guard let tokenData = jsToken.data(using: .utf8) else { return }
126
- let writeQuery: [String: Any] = [
127
- kSecClass as String: kSecClassGenericPassword,
128
- kSecAttrService as String: service,
129
- kSecAttrAccount as String: nativeTokenKey,
130
- kSecValueData as String: tokenData,
131
- kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
132
- ]
133
- SecItemAdd(writeQuery as CFDictionary, nil)
134
- }
135
-
136
- /// Reads the native device token from keychain, if present.
137
- private static func readNativeDeviceToken() -> String? {
138
- guard let service = keychainService, !service.isEmpty else { return nil }
139
-
140
- var result: CFTypeRef?
141
- let query: [String: Any] = [
142
- kSecClass as String: kSecClassGenericPassword,
143
- kSecAttrService as String: service,
144
- kSecAttrAccount as String: "clerkDeviceToken",
145
- kSecReturnData as String: true,
146
- kSecMatchLimit as String: kSecMatchLimitOne,
147
- ]
148
- guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess,
149
- let data = result as? Data else { return nil }
150
- return String(data: data, encoding: .utf8)
151
- }
152
-
153
- /// Clears stale cached client and environment data from keychain.
154
- /// This prevents the native SDK from loading a stale anonymous client
155
- /// during initialization, which would conflict with a newly-synced device token.
156
- private static func clearCachedClerkData() {
157
- guard let service = keychainService, !service.isEmpty else { return }
158
-
159
- for key in ["cachedClient", "cachedEnvironment"] {
160
- let query: [String: Any] = [
161
- kSecClass as String: kSecClassGenericPassword,
162
- kSecAttrService as String: service,
163
- kSecAttrAccount as String: key,
164
- ]
165
- SecItemDelete(query as CFDictionary)
166
- }
167
- }
168
-
169
- /// Writes the provided bearer token as the native SDK's device token.
170
- /// If the native SDK already has a device token, it is updated with the new value.
171
- private static func writeNativeDeviceToken(_ token: String) {
172
- guard let service = keychainService, !service.isEmpty else { return }
173
-
174
- let nativeTokenKey = "clerkDeviceToken"
175
- guard let tokenData = token.data(using: .utf8) else { return }
176
-
177
- // Check if native SDK already has a device token
178
- let checkQuery: [String: Any] = [
179
- kSecClass as String: kSecClassGenericPassword,
180
- kSecAttrService as String: service,
181
- kSecAttrAccount as String: nativeTokenKey,
182
- kSecReturnData as String: false,
183
- kSecMatchLimit as String: kSecMatchLimitOne,
184
- ]
185
-
186
- if SecItemCopyMatching(checkQuery as CFDictionary, nil) == errSecSuccess {
187
- // Update the existing token
188
- let updateQuery: [String: Any] = [
189
- kSecClass as String: kSecClassGenericPassword,
190
- kSecAttrService as String: service,
191
- kSecAttrAccount as String: nativeTokenKey,
192
- ]
193
- let updateAttributes: [String: Any] = [
194
- kSecValueData as String: tokenData,
195
- ]
196
- SecItemUpdate(updateQuery as CFDictionary, updateAttributes as CFDictionary)
197
- } else {
198
- // Write a new token
199
- let writeQuery: [String: Any] = [
200
- kSecClass as String: kSecClassGenericPassword,
201
- kSecAttrService as String: service,
202
- kSecAttrAccount as String: nativeTokenKey,
203
- kSecValueData as String: tokenData,
204
- kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
205
- ]
206
- SecItemAdd(writeQuery as CFDictionary, nil)
207
- }
208
- }
209
-
210
- public func getClientToken() -> String? {
211
- Self.readNativeDeviceToken()
212
- }
213
-
214
- public func createAuthViewController(
215
- mode: String,
216
- dismissable: Bool,
217
- completion: @escaping (Result<[String: Any], Error>) -> Void
218
- ) -> UIViewController? {
219
- let authMode: AuthView.Mode
220
- switch mode {
221
- case "signIn":
222
- authMode = .signIn
223
- case "signUp":
224
- authMode = .signUp
225
- default:
226
- authMode = .signInOrUp
227
- }
228
-
229
- let wrapper = ClerkAuthWrapperViewController(
230
- mode: authMode,
231
- dismissable: dismissable,
232
- completion: completion
233
- )
234
- return wrapper
235
- }
236
-
237
- public func createUserProfileViewController(
238
- dismissable: Bool,
239
- completion: @escaping (Result<[String: Any], Error>) -> Void
240
- ) -> UIViewController? {
241
- let wrapper = ClerkProfileWrapperViewController(
242
- dismissable: dismissable,
243
- completion: completion
244
- )
245
- return wrapper
246
- }
247
-
248
- // MARK: - Inline View Creation
249
-
250
- public func createAuthView(
251
- mode: String,
252
- dismissable: Bool,
253
- onEvent: @escaping (String, [String: Any]) -> Void
254
- ) -> UIViewController? {
255
- let authMode: AuthView.Mode
256
- switch mode {
257
- case "signIn":
258
- authMode = .signIn
259
- case "signUp":
260
- authMode = .signUp
261
- default:
262
- authMode = .signInOrUp
263
- }
264
-
265
- let hostingController = UIHostingController(
266
- rootView: ClerkInlineAuthWrapperView(
267
- mode: authMode,
268
- dismissable: dismissable,
269
- onEvent: onEvent
270
- )
271
- )
272
- hostingController.view.backgroundColor = .clear
273
- return hostingController
274
- }
275
-
276
- public func createUserProfileView(
277
- dismissable: Bool,
278
- onEvent: @escaping (String, [String: Any]) -> Void
279
- ) -> UIViewController? {
280
- let hostingController = UIHostingController(
281
- rootView: ClerkInlineProfileWrapperView(
282
- dismissable: dismissable,
283
- onEvent: onEvent
284
- )
285
- )
286
- hostingController.view.backgroundColor = .clear
287
- return hostingController
288
- }
289
-
290
- @MainActor
291
- public func getSession() async -> [String: Any]? {
292
- guard Self.clerkConfigured, let session = Clerk.shared.session else {
293
- return nil
294
- }
295
-
296
- var result: [String: Any] = [
297
- "sessionId": session.id,
298
- "status": String(describing: session.status)
299
- ]
300
-
301
- // Include user details if available
302
- let user = session.user ?? Clerk.shared.user
303
-
304
- if let user = user {
305
- var userDict: [String: Any] = [
306
- "id": user.id,
307
- "imageUrl": user.imageUrl
308
- ]
309
- if let firstName = user.firstName {
310
- userDict["firstName"] = firstName
311
- }
312
- if let lastName = user.lastName {
313
- userDict["lastName"] = lastName
314
- }
315
- if let primaryEmail = user.emailAddresses.first(where: { $0.id == user.primaryEmailAddressId }) {
316
- userDict["primaryEmailAddress"] = primaryEmail.emailAddress
317
- } else if let firstEmail = user.emailAddresses.first {
318
- userDict["primaryEmailAddress"] = firstEmail.emailAddress
319
- }
320
- result["user"] = userDict
321
- }
322
-
323
- return result
324
- }
325
-
326
- @MainActor
327
- public func signOut() async throws {
328
- if Self.clerkConfigured {
329
- defer { Clerk.clearAllKeychainItems() }
330
- if let sessionId = Clerk.shared.session?.id {
331
- try await Clerk.shared.auth.signOut(sessionId: sessionId)
332
- }
333
- }
334
- Self.clerkConfigured = false
335
- }
336
- }
337
-
338
- // MARK: - Auth View Controller Wrapper
339
-
340
- class ClerkAuthWrapperViewController: UIHostingController<ClerkAuthWrapperView> {
341
- private let completion: (Result<[String: Any], Error>) -> Void
342
- private var authEventTask: Task<Void, Never>?
343
- private var completionCalled = false
344
-
345
- init(mode: AuthView.Mode, dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) {
346
- self.completion = completion
347
- let view = ClerkAuthWrapperView(mode: mode, dismissable: dismissable)
348
- super.init(rootView: view)
349
- self.modalPresentationStyle = .fullScreen
350
- subscribeToAuthEvents()
351
- }
352
-
353
- @MainActor required dynamic init?(coder aDecoder: NSCoder) {
354
- fatalError("init(coder:) has not been implemented")
355
- }
356
-
357
- deinit {
358
- authEventTask?.cancel()
359
- }
360
-
361
- override func viewDidDisappear(_ animated: Bool) {
362
- super.viewDidDisappear(animated)
363
- if isBeingDismissed {
364
- completeOnce(.failure(NSError(domain: "ClerkAuth", code: 3, userInfo: [NSLocalizedDescriptionKey: "Auth modal was dismissed"])))
365
- }
366
- }
367
-
368
- private func completeOnce(_ result: Result<[String: Any], Error>) {
369
- guard !completionCalled else { return }
370
- completionCalled = true
371
- completion(result)
372
- }
373
-
374
- private func subscribeToAuthEvents() {
375
- authEventTask = Task { @MainActor [weak self] in
376
- for await event in Clerk.shared.auth.events {
377
- guard let self = self, !self.completionCalled else { return }
378
- switch event {
379
- case .signInCompleted(let signIn):
380
- if let sessionId = signIn.createdSessionId {
381
- self.completeOnce(.success(["sessionId": sessionId, "type": "signIn"]))
382
- self.dismiss(animated: true)
383
- } else {
384
- self.completeOnce(.failure(NSError(domain: "ClerkAuth", code: 1, userInfo: [NSLocalizedDescriptionKey: "Sign-in completed but no session ID was created"])))
385
- self.dismiss(animated: true)
386
- }
387
- case .signUpCompleted(let signUp):
388
- if let sessionId = signUp.createdSessionId {
389
- self.completeOnce(.success(["sessionId": sessionId, "type": "signUp"]))
390
- self.dismiss(animated: true)
391
- } else {
392
- self.completeOnce(.failure(NSError(domain: "ClerkAuth", code: 1, userInfo: [NSLocalizedDescriptionKey: "Sign-up completed but no session ID was created"])))
393
- self.dismiss(animated: true)
394
- }
395
- default:
396
- break
397
- }
398
- }
399
- // Stream ended without an auth completion event
400
- guard let self = self else { return }
401
- self.completeOnce(.failure(NSError(domain: "ClerkAuth", code: 2, userInfo: [NSLocalizedDescriptionKey: "Auth event stream ended unexpectedly"])))
402
- }
403
- }
404
- }
405
-
406
- struct ClerkAuthWrapperView: View {
407
- let mode: AuthView.Mode
408
- let dismissable: Bool
409
-
410
- var body: some View {
411
- AuthView(mode: mode, isDismissable: dismissable)
412
- .environment(Clerk.shared)
413
- }
414
- }
415
-
416
- // MARK: - Profile View Controller Wrapper
417
-
418
- class ClerkProfileWrapperViewController: UIHostingController<ClerkProfileWrapperView> {
419
- private let completion: (Result<[String: Any], Error>) -> Void
420
- private var authEventTask: Task<Void, Never>?
421
- private var completionCalled = false
422
-
423
- init(dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) {
424
- self.completion = completion
425
- let view = ClerkProfileWrapperView(dismissable: dismissable)
426
- super.init(rootView: view)
427
- self.modalPresentationStyle = .fullScreen
428
- subscribeToAuthEvents()
429
- }
430
-
431
- @MainActor required dynamic init?(coder aDecoder: NSCoder) {
432
- fatalError("init(coder:) has not been implemented")
433
- }
434
-
435
- deinit {
436
- authEventTask?.cancel()
437
- }
438
-
439
- override func viewDidDisappear(_ animated: Bool) {
440
- super.viewDidDisappear(animated)
441
- if isBeingDismissed {
442
- completeOnce(.failure(NSError(domain: "ClerkProfile", code: 3, userInfo: [NSLocalizedDescriptionKey: "Profile modal was dismissed"])))
443
- }
444
- }
445
-
446
- private func completeOnce(_ result: Result<[String: Any], Error>) {
447
- guard !completionCalled else { return }
448
- completionCalled = true
449
- completion(result)
450
- }
451
-
452
- private func subscribeToAuthEvents() {
453
- authEventTask = Task { @MainActor [weak self] in
454
- for await event in Clerk.shared.auth.events {
455
- guard let self = self, !self.completionCalled else { return }
456
- switch event {
457
- case .signedOut(let session):
458
- self.completeOnce(.success(["sessionId": session.id]))
459
- self.dismiss(animated: true)
460
- default:
461
- break
462
- }
463
- }
464
- // Stream ended without a sign-out event
465
- guard let self = self else { return }
466
- self.completeOnce(.failure(NSError(domain: "ClerkProfile", code: 2, userInfo: [NSLocalizedDescriptionKey: "Profile event stream ended unexpectedly"])))
467
- }
468
- }
469
- }
470
-
471
- struct ClerkProfileWrapperView: View {
472
- let dismissable: Bool
473
-
474
- var body: some View {
475
- UserProfileView(isDismissable: dismissable)
476
- .environment(Clerk.shared)
477
- }
478
- }
479
-
480
- // MARK: - Inline Auth View Wrapper (for embedded rendering)
481
-
482
- struct ClerkInlineAuthWrapperView: View {
483
- let mode: AuthView.Mode
484
- let dismissable: Bool
485
- let onEvent: (String, [String: Any]) -> Void
486
-
487
- // Track initial session to detect new sign-ins (same approach as Android)
488
- @State private var initialSessionId: String? = Clerk.shared.session?.id
489
- @State private var eventSent = false
490
-
491
- private func sendAuthCompleted(sessionId: String, type: String) {
492
- guard !eventSent, sessionId != initialSessionId else { return }
493
- eventSent = true
494
- onEvent(type, ["sessionId": sessionId, "type": type == "signUpCompleted" ? "signUp" : "signIn"])
495
- }
496
-
497
- var body: some View {
498
- AuthView(mode: mode, isDismissable: dismissable)
499
- .environment(Clerk.shared)
500
- // Primary detection: observe Clerk.shared.session directly (matches Android's sessionFlow approach).
501
- // This is more reliable than auth.events which may not emit for inline AuthView sign-ins.
502
- .onChange(of: Clerk.shared.session?.id) { _, newSessionId in
503
- guard let sessionId = newSessionId else { return }
504
- sendAuthCompleted(sessionId: sessionId, type: "signInCompleted")
505
- }
506
- // Fallback: also listen to auth.events for signUp events and edge cases
507
- .task {
508
- for await event in Clerk.shared.auth.events {
509
- guard !eventSent else { continue }
510
- switch event {
511
- case .signInCompleted(let signIn):
512
- let sessionId = signIn.createdSessionId ?? Clerk.shared.session?.id
513
- if let sessionId { sendAuthCompleted(sessionId: sessionId, type: "signInCompleted") }
514
- case .signUpCompleted(let signUp):
515
- let sessionId = signUp.createdSessionId ?? Clerk.shared.session?.id
516
- if let sessionId { sendAuthCompleted(sessionId: sessionId, type: "signUpCompleted") }
517
- case .sessionChanged(_, let newSession):
518
- if let sessionId = newSession?.id { sendAuthCompleted(sessionId: sessionId, type: "signInCompleted") }
519
- default:
520
- break
521
- }
522
- }
523
- }
524
- }
525
- }
526
-
527
- // MARK: - Inline Profile View Wrapper (for embedded rendering)
528
-
529
- struct ClerkInlineProfileWrapperView: View {
530
- let dismissable: Bool
531
- let onEvent: (String, [String: Any]) -> Void
532
-
533
- var body: some View {
534
- UserProfileView(isDismissable: dismissable)
535
- .environment(Clerk.shared)
536
- .task {
537
- for await event in Clerk.shared.auth.events {
538
- switch event {
539
- case .signedOut(let session):
540
- onEvent("signedOut", ["sessionId": session.id])
541
- default:
542
- break
543
- }
544
- }
545
- }
546
- }
547
- }
548
-