@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.
- package/android/src/main/java/expo/modules/clerk/ClerkAuthActivity.kt +1 -1
- package/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt +1 -1
- package/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt +92 -0
- package/app.plugin.js +119 -0
- package/dist/errorThrower.d.ts +1 -1
- package/dist/hooks/index.d.ts +1 -1
- package/dist/hooks/index.d.ts.map +1 -1
- package/dist/hooks/index.js +0 -2
- package/dist/hooks/index.js.map +1 -1
- package/dist/provider/ClerkProvider.js +1 -1
- package/dist/utils/errors.d.ts +1 -1
- package/ios/ClerkViewFactory.swift +157 -10
- package/package.json +6 -6
- package/ios/templates/ClerkViewFactory.swift +0 -548
|
@@ -275,7 +275,7 @@ class ClerkAuthActivity : ComponentActivity() {
|
|
|
275
275
|
// Client is ready, show AuthView
|
|
276
276
|
AuthView(
|
|
277
277
|
modifier = Modifier.fillMaxSize(),
|
|
278
|
-
clerkTheme =
|
|
278
|
+
clerkTheme = Clerk.customTheme
|
|
279
279
|
)
|
|
280
280
|
}
|
|
281
281
|
else -> {
|
|
@@ -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 };
|
package/dist/errorThrower.d.ts
CHANGED
package/dist/hooks/index.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { useClerk, useEmailLink, useOrganization, useOrganizationList, useSession, useSessionList, useSignIn, useSignUp, useWaitlist, useUser, useReverification, useAPIKeys,
|
|
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,
|
|
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"}
|
package/dist/hooks/index.js
CHANGED
|
@@ -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,
|
package/dist/hooks/index.js.map
CHANGED
|
@@ -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
|
|
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
|
|
48
|
+
version: "3.2.0"
|
|
49
49
|
};
|
|
50
50
|
function NativeSessionSync({
|
|
51
51
|
publishableKey,
|
package/dist/utils/errors.d.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
1
|
import type { DomainOrProxyUrl } from '@clerk/shared/types';
|
|
2
|
-
export declare const errorThrower: import("@clerk/shared/index-
|
|
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
|
|
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
|
|
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.
|
|
108
|
-
"@clerk/react": "6.
|
|
109
|
-
"@clerk/shared": "4.
|
|
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.
|
|
123
|
+
"@clerk/expo-passkeys": "1.0.14"
|
|
124
124
|
},
|
|
125
125
|
"peerDependencies": {
|
|
126
|
-
"@clerk/expo-passkeys": "
|
|
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
|
-
|