diff --git a/.changeset/expo-native-component-theming.md b/.changeset/expo-native-component-theming.md new file mode 100644 index 00000000000..e2b4bc311b1 --- /dev/null +++ b/.changeset/expo-native-component-theming.md @@ -0,0 +1,24 @@ +--- +'@clerk/expo': minor +--- + +Add native component theming via the Expo config plugin. You can now customize the appearance of Clerk's native components (``, ``, ``) on iOS and Android by passing a `theme` prop to the plugin pointing at a JSON file: + +```json +{ + "expo": { + "plugins": [ + ["@clerk/expo", { "theme": "./clerk-theme.json" }] + ] + } +} +``` + +The JSON theme supports: + +- `colors` — 15 semantic color tokens (`primary`, `background`, `input`, `danger`, `success`, `warning`, `foreground`, `mutedForeground`, `primaryForeground`, `inputForeground`, `neutral`, `border`, `ring`, `muted`, `shadow`) as 6- or 8-digit hex strings. +- `darkColors` — same shape as `colors`; applied automatically when the system is in dark mode. +- `design.borderRadius` — number, applied to both platforms. +- `design.fontFamily` — string, **iOS only**. + +Theme JSON is validated at prebuild. On iOS the theme is embedded into `Info.plist`; on Android the JSON is copied into `android/app/src/main/assets/clerk_theme.json`. The plugin does not modify your app's `userInterfaceStyle` setting — control light/dark mode via `"userInterfaceStyle"` in `app.json`. diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthActivity.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthActivity.kt index acd934830de..1c8049adba6 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthActivity.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthActivity.kt @@ -275,7 +275,7 @@ class ClerkAuthActivity : ComponentActivity() { // Client is ready, show AuthView AuthView( modifier = Modifier.fillMaxSize(), - clerkTheme = null // Use default theme, or pass custom + clerkTheme = Clerk.customTheme ) } else -> { diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt index 60280542e27..a479e205085 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt @@ -105,7 +105,7 @@ class ClerkAuthNativeView(context: Context) : FrameLayout(context) { ) { AuthView( modifier = Modifier.fillMaxSize(), - clerkTheme = null + clerkTheme = Clerk.customTheme ) } } diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt index e4d15f6a963..ff2dee642a9 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt @@ -4,8 +4,13 @@ import android.app.Activity import android.content.Context import android.content.Intent import android.util.Log +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp import com.clerk.api.Clerk import com.clerk.api.network.serialization.ClerkResult +import com.clerk.api.ui.ClerkColors +import com.clerk.api.ui.ClerkDesign +import com.clerk.api.ui.ClerkTheme import com.facebook.react.bridge.ActivityEventListener import com.facebook.react.bridge.Promise import com.facebook.react.bridge.ReactApplicationContext @@ -18,6 +23,7 @@ import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.withTimeout +import org.json.JSONObject private const val TAG = "ClerkExpoModule" @@ -79,6 +85,13 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : } Clerk.initialize(reactApplicationContext, pubKey) + // Theme loading is centralized here. ClerkViewFactory.configure() + // and ClerkUserProfileActivity.onCreate() only call Clerk.initialize() + // when Clerk is not yet initialized, so by the time they run + // ClerkExpoModule has already set the custom theme. + // Must be set AFTER Clerk.initialize() because initialize() + // resets customTheme to its `theme` parameter (default null). + loadThemeFromAssets() // Wait for initialization to complete with timeout try { @@ -367,4 +380,83 @@ class ClerkExpoModule(reactContext: ReactApplicationContext) : promise.resolve(result) } + + // MARK: - Theme Loading + + private fun loadThemeFromAssets() { + try { + val jsonString = reactApplicationContext.assets + .open("clerk_theme.json") + .bufferedReader() + .use { it.readText() } + val json = JSONObject(jsonString) + Clerk.customTheme = parseClerkTheme(json) + } catch (e: java.io.FileNotFoundException) { + // No theme file provided — use defaults + } catch (e: Exception) { + debugLog(TAG, "Failed to load clerk_theme.json: ${e.message}") + } + } + + private fun parseClerkTheme(json: JSONObject): ClerkTheme { + val colors = json.optJSONObject("colors")?.let { parseColors(it) } + val darkColors = json.optJSONObject("darkColors")?.let { parseColors(it) } + val design = json.optJSONObject("design")?.let { parseDesign(it) } + return ClerkTheme( + colors = colors, + darkColors = darkColors, + design = design + ) + } + + private fun parseColors(json: JSONObject): ClerkColors { + return ClerkColors( + primary = json.optStringColor("primary"), + background = json.optStringColor("background"), + input = json.optStringColor("input"), + danger = json.optStringColor("danger"), + success = json.optStringColor("success"), + warning = json.optStringColor("warning"), + foreground = json.optStringColor("foreground"), + mutedForeground = json.optStringColor("mutedForeground"), + primaryForeground = json.optStringColor("primaryForeground"), + inputForeground = json.optStringColor("inputForeground"), + neutral = json.optStringColor("neutral"), + border = json.optStringColor("border"), + ring = json.optStringColor("ring"), + muted = json.optStringColor("muted"), + shadow = json.optStringColor("shadow") + ) + } + + private fun parseDesign(json: JSONObject): ClerkDesign { + return if (json.has("borderRadius")) { + ClerkDesign(borderRadius = json.getDouble("borderRadius").toFloat().dp) + } else { + ClerkDesign() + } + } + + private fun parseHexColor(hex: String): Color? { + val cleaned = hex.removePrefix("#") + return try { + when (cleaned.length) { + 6 -> Color(android.graphics.Color.parseColor("#FF$cleaned")) + // Theme JSON uses RRGGBBAA; Android parseColor expects AARRGGBB + 8 -> { + val rrggbb = cleaned.substring(0, 6) + val aa = cleaned.substring(6, 8) + Color(android.graphics.Color.parseColor("#$aa$rrggbb")) + } + else -> null + } + } catch (e: Exception) { + null + } + } + + private fun JSONObject.optStringColor(key: String): Color? { + val value = optString(key, null) ?: return null + return parseHexColor(value) + } } diff --git a/packages/expo/app.plugin.js b/packages/expo/app.plugin.js index 758e80b5692..c19c5d57a7b 100644 --- a/packages/expo/app.plugin.js +++ b/packages/expo/app.plugin.js @@ -585,6 +585,123 @@ const withClerkAppleSignIn = config => { }); }; +/** + * Apply a custom theme to Clerk native components (iOS + Android). + * + * Accepts a `theme` prop pointing to a JSON file with optional keys: + * - colors: { primary, background, input, danger, success, warning, + * foreground, mutedForeground, primaryForeground, inputForeground, + * neutral, border, ring, muted, shadow } (hex color strings) + * - darkColors: same keys as colors (for dark mode) + * - design: { fontFamily: string, borderRadius: number } + * + * iOS: Embeds the parsed JSON into Info.plist under key "ClerkTheme". + * Android: Copies the JSON file to android/app/src/main/assets/clerk_theme.json. + */ +const VALID_COLOR_KEYS = [ + 'primary', + 'background', + 'input', + 'danger', + 'success', + 'warning', + 'foreground', + 'mutedForeground', + 'primaryForeground', + 'inputForeground', + 'neutral', + 'border', + 'ring', + 'muted', + 'shadow', +]; + +const HEX_COLOR_REGEX = /^#([0-9A-Fa-f]{6}|[0-9A-Fa-f]{8})$/; + +function isPlainObject(value) { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function validateThemeJson(theme) { + if (!isPlainObject(theme)) { + throw new Error('Clerk theme: theme JSON must be a plain object'); + } + + const validateColors = (colors, label) => { + if (!isPlainObject(colors)) { + throw new Error(`Clerk theme: ${label} must be an object`); + } + for (const [key, value] of Object.entries(colors)) { + if (!VALID_COLOR_KEYS.includes(key)) { + console.warn(`⚠️ Clerk theme: unknown color key "${key}" in ${label}, ignoring`); + continue; + } + if (typeof value !== 'string' || !HEX_COLOR_REGEX.test(value)) { + throw new Error(`Clerk theme: invalid hex color for ${label}.${key}: "${value}"`); + } + } + }; + + if (theme.colors != null) validateColors(theme.colors, 'colors'); + if (theme.darkColors != null) validateColors(theme.darkColors, 'darkColors'); + + if (theme.design != null) { + if (!isPlainObject(theme.design)) { + throw new Error(`Clerk theme: design must be an object`); + } + if (theme.design.fontFamily != null && typeof theme.design.fontFamily !== 'string') { + throw new Error(`Clerk theme: design.fontFamily must be a string`); + } + if (theme.design.borderRadius != null && typeof theme.design.borderRadius !== 'number') { + throw new Error(`Clerk theme: design.borderRadius must be a number`); + } + } +} + +const withClerkTheme = (config, props = {}) => { + const { theme } = props; + if (!theme) return config; + + // Resolve the theme file path relative to the project root + const themePath = path.resolve(theme); + if (!fs.existsSync(themePath)) { + console.warn(`⚠️ Clerk theme file not found: ${themePath}, skipping theme`); + return config; + } + + let themeJson; + try { + themeJson = JSON.parse(fs.readFileSync(themePath, 'utf8')); + validateThemeJson(themeJson); + } catch (e) { + throw new Error(`Clerk theme: failed to parse ${themePath}: ${e.message}`); + } + + // iOS: Embed theme in Info.plist under "ClerkTheme" + config = withInfoPlist(config, modConfig => { + modConfig.modResults.ClerkTheme = themeJson; + console.log('✅ Embedded Clerk theme in Info.plist'); + return modConfig; + }); + + // Android: Copy theme JSON to assets + config = withDangerousMod(config, [ + 'android', + async config => { + const assetsDir = path.join(config.modRequest.platformProjectRoot, 'app', 'src', 'main', 'assets'); + if (!fs.existsSync(assetsDir)) { + fs.mkdirSync(assetsDir, { recursive: true }); + } + const destPath = path.join(assetsDir, 'clerk_theme.json'); + fs.writeFileSync(destPath, JSON.stringify(themeJson, null, 2) + '\n'); + console.log('✅ Copied Clerk theme to Android assets'); + return config; + }, + ]); + + return config; +}; + const withClerkExpo = (config, props = {}) => { const { appleSignIn = true } = props; config = withClerkIOS(config); @@ -594,7 +711,9 @@ const withClerkExpo = (config, props = {}) => { config = withClerkGoogleSignIn(config); config = withClerkAndroid(config); config = withClerkKeychainService(config, props); + config = withClerkTheme(config, props); return config; }; module.exports = withClerkExpo; +module.exports._testing = { validateThemeJson, isPlainObject, VALID_COLOR_KEYS, HEX_COLOR_REGEX }; diff --git a/packages/expo/ios/ClerkViewFactory.swift b/packages/expo/ios/ClerkViewFactory.swift index 38b64c29edb..d30bb5f4ea7 100644 --- a/packages/expo/ios/ClerkViewFactory.swift +++ b/packages/expo/ios/ClerkViewFactory.swift @@ -18,6 +18,10 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { private static let clerkLoadIntervalNs: UInt64 = 100_000_000 private static var clerkConfigured = false + /// Parsed light and dark themes from Info.plist "ClerkTheme" dictionary. + var lightTheme: ClerkTheme? + var darkTheme: ClerkTheme? + private enum KeychainKey { static let jsClientJWT = "__clerk_client_jwt" static let nativeDeviceToken = "clerkDeviceToken" @@ -42,7 +46,8 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { } // Register this factory with the ClerkExpo module - public static func register() { + @MainActor public static func register() { + shared.loadThemes() clerkViewFactory = shared } @@ -152,6 +157,8 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { let wrapper = ClerkAuthWrapperViewController( mode: Self.authMode(from: mode), dismissable: dismissable, + lightTheme: lightTheme, + darkTheme: darkTheme, completion: completion ) return wrapper @@ -163,6 +170,8 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { ) -> UIViewController? { let wrapper = ClerkProfileWrapperViewController( dismissable: dismissable, + lightTheme: lightTheme, + darkTheme: darkTheme, completion: completion ) return wrapper @@ -179,6 +188,8 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { rootView: ClerkInlineAuthWrapperView( mode: Self.authMode(from: mode), dismissable: dismissable, + lightTheme: lightTheme, + darkTheme: darkTheme, onEvent: onEvent ) ) @@ -191,6 +202,8 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { makeHostingController( rootView: ClerkInlineProfileWrapperView( dismissable: dismissable, + lightTheme: lightTheme, + darkTheme: darkTheme, onEvent: onEvent ) ) @@ -226,6 +239,91 @@ public final class ClerkViewFactory: ClerkViewFactoryProtocol { } } + // MARK: - Theme Parsing + + /// Reads the "ClerkTheme" dictionary from Info.plist and builds light / dark themes. + @MainActor func loadThemes() { + guard let themeDictionary = Bundle.main.object(forInfoDictionaryKey: "ClerkTheme") as? [String: Any] else { + return + } + + // Build light theme from top-level "colors" and "design" + let lightColors = (themeDictionary["colors"] as? [String: String]).flatMap { parseColors(from: $0) } + let design = (themeDictionary["design"] as? [String: Any]).flatMap { parseDesign(from: $0) } + let fonts = (themeDictionary["design"] as? [String: Any]).flatMap { parseFonts(from: $0) } + + if lightColors != nil || design != nil || fonts != nil { + lightTheme = ClerkTheme(colors: lightColors ?? .default, fonts: fonts ?? .default, design: design ?? .default) + } + + // Build dark theme from "darkColors" (inherits same design/fonts) + if let darkColorsDict = themeDictionary["darkColors"] as? [String: String] { + let darkColors = parseColors(from: darkColorsDict) + if darkColors != nil || design != nil || fonts != nil { + darkTheme = ClerkTheme(colors: darkColors ?? .default, fonts: fonts ?? .default, design: design ?? .default) + } + } + } + + private func parseColors(from dict: [String: String]) -> ClerkTheme.Colors? { + let hasAny = dict.values.contains { colorFromHex($0) != nil } + guard hasAny else { return nil } + + return ClerkTheme.Colors( + primary: dict["primary"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultPrimaryColor, + background: dict["background"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultBackgroundColor, + input: dict["input"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultInputColor, + danger: dict["danger"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultDangerColor, + success: dict["success"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultSuccessColor, + warning: dict["warning"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultWarningColor, + foreground: dict["foreground"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultForegroundColor, + mutedForeground: dict["mutedForeground"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultMutedForegroundColor, + primaryForeground: dict["primaryForeground"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultPrimaryForegroundColor, + inputForeground: dict["inputForeground"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultInputForegroundColor, + neutral: dict["neutral"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultNeutralColor, + ring: dict["ring"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultRingColor, + muted: dict["muted"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultMutedColor, + shadow: dict["shadow"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultShadowColor, + border: dict["border"].flatMap { colorFromHex($0) } ?? ClerkTheme.Colors.defaultBorderColor + ) + } + + private func colorFromHex(_ hex: String) -> Color? { + var cleaned = hex.trimmingCharacters(in: .whitespacesAndNewlines) + if cleaned.hasPrefix("#") { cleaned.removeFirst() } + + var rgb: UInt64 = 0 + guard Scanner(string: cleaned).scanHexInt64(&rgb) else { return nil } + + switch cleaned.count { + case 6: + return Color( + red: Double((rgb >> 16) & 0xFF) / 255.0, + green: Double((rgb >> 8) & 0xFF) / 255.0, + blue: Double(rgb & 0xFF) / 255.0 + ) + case 8: + return Color( + red: Double((rgb >> 24) & 0xFF) / 255.0, + green: Double((rgb >> 16) & 0xFF) / 255.0, + blue: Double((rgb >> 8) & 0xFF) / 255.0, + opacity: Double(rgb & 0xFF) / 255.0 + ) + default: + return nil + } + } + + private func parseFonts(from dict: [String: Any]) -> ClerkTheme.Fonts? { + guard let fontFamily = dict["fontFamily"] as? String, !fontFamily.isEmpty else { return nil } + return ClerkTheme.Fonts(fontFamily: fontFamily) + } + + private func parseDesign(from dict: [String: Any]) -> ClerkTheme.Design? { + guard let radius = dict["borderRadius"] as? Double else { return nil } + return ClerkTheme.Design(borderRadius: CGFloat(radius)) + } + private func makeHostingController(rootView: Content) -> UIViewController { let hostingController = UIHostingController(rootView: rootView) hostingController.view.backgroundColor = .clear @@ -329,9 +427,9 @@ class ClerkAuthWrapperViewController: UIHostingController private var authEventTask: Task? private var completionCalled = false - init(mode: AuthView.Mode, dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) { + init(mode: AuthView.Mode, dismissable: Bool, lightTheme: ClerkTheme?, darkTheme: ClerkTheme?, completion: @escaping (Result<[String: Any], Error>) -> Void) { self.completion = completion - let view = ClerkAuthWrapperView(mode: mode, dismissable: dismissable) + let view = ClerkAuthWrapperView(mode: mode, dismissable: dismissable, lightTheme: lightTheme, darkTheme: darkTheme) super.init(rootView: view) self.modalPresentationStyle = .fullScreen subscribeToAuthEvents() @@ -393,10 +491,20 @@ class ClerkAuthWrapperViewController: UIHostingController struct ClerkAuthWrapperView: View { let mode: AuthView.Mode let dismissable: Bool + let lightTheme: ClerkTheme? + let darkTheme: ClerkTheme? + + @Environment(\.colorScheme) private var colorScheme var body: some View { - AuthView(mode: mode, isDismissable: dismissable) + let view = AuthView(mode: mode, isDismissable: dismissable) .environment(Clerk.shared) + let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme + if let theme { + view.environment(\.clerkTheme, theme) + } else { + view + } } } @@ -407,9 +515,9 @@ class ClerkProfileWrapperViewController: UIHostingController? private var completionCalled = false - init(dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) { + init(dismissable: Bool, lightTheme: ClerkTheme?, darkTheme: ClerkTheme?, completion: @escaping (Result<[String: Any], Error>) -> Void) { self.completion = completion - let view = ClerkProfileWrapperView(dismissable: dismissable) + let view = ClerkProfileWrapperView(dismissable: dismissable, lightTheme: lightTheme, darkTheme: darkTheme) super.init(rootView: view) self.modalPresentationStyle = .fullScreen subscribeToAuthEvents() @@ -454,10 +562,20 @@ class ClerkProfileWrapperViewController: UIHostingController Void // Track initial session to detect new sign-ins (same approach as Android) @State private var initialSessionId: String? = Clerk.shared.session?.id @State private var eventSent = false + @Environment(\.colorScheme) private var colorScheme + private func sendAuthCompleted(sessionId: String, type: String) { guard !eventSent, sessionId != initialSessionId else { return } eventSent = true onEvent(type, ["sessionId": sessionId, "type": type == "signUpCompleted" ? "signUp" : "signIn"]) } - var body: some View { - AuthView(mode: mode, isDismissable: dismissable) + private var themedAuthView: some View { + let view = AuthView(mode: mode, isDismissable: dismissable) .environment(Clerk.shared) + let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme + return Group { + if let theme { + view.environment(\.clerkTheme, theme) + } else { + view + } + } + } + + var body: some View { + themedAuthView // Primary detection: observe Clerk.shared.session directly (matches Android's sessionFlow approach). // This is more reliable than auth.events which may not emit for inline AuthView sign-ins. .onChange(of: Clerk.shared.session?.id) { _, newSessionId in @@ -512,11 +646,24 @@ struct ClerkInlineAuthWrapperView: View { struct ClerkInlineProfileWrapperView: View { let dismissable: Bool + let lightTheme: ClerkTheme? + let darkTheme: ClerkTheme? let onEvent: (String, [String: Any]) -> Void + @Environment(\.colorScheme) private var colorScheme + var body: some View { - UserProfileView(isDismissable: dismissable) + let view = UserProfileView(isDismissable: dismissable) .environment(Clerk.shared) + let theme = colorScheme == .dark ? (darkTheme ?? lightTheme) : lightTheme + let themedView = Group { + if let theme { + view.environment(\.clerkTheme, theme) + } else { + view + } + } + themedView .task { for await event in Clerk.shared.auth.events { switch event { diff --git a/packages/expo/ios/templates/ClerkViewFactory.swift b/packages/expo/ios/templates/ClerkViewFactory.swift deleted file mode 100644 index d9048643f9a..00000000000 --- a/packages/expo/ios/templates/ClerkViewFactory.swift +++ /dev/null @@ -1,548 +0,0 @@ -// ClerkViewFactory - Provides Clerk view controllers to the ClerkExpo module -// This file is injected into the app target by the config plugin. -// It uses `import ClerkKit` (SPM) which is only accessible from the app target. - -import UIKit -import SwiftUI -import Security -import ClerkKit -import ClerkKitUI -import ClerkExpo // Import the pod to access ClerkViewFactoryProtocol - -// MARK: - View Factory Implementation - -public class ClerkViewFactory: ClerkViewFactoryProtocol { - public static let shared = ClerkViewFactory() - - private static let clerkLoadMaxAttempts = 30 - private static let clerkLoadIntervalNs: UInt64 = 100_000_000 - private static var clerkConfigured = false - - /// Resolves the keychain service name, checking ClerkKeychainService in Info.plist first - /// (for extension apps sharing a keychain group), then falling back to the bundle identifier. - private static var keychainService: String? { - if let custom = Bundle.main.object(forInfoDictionaryKey: "ClerkKeychainService") as? String, !custom.isEmpty { - return custom - } - return Bundle.main.bundleIdentifier - } - - private init() {} - - // Register this factory with the ClerkExpo module - public static func register() { - clerkViewFactory = shared - } - - @MainActor - public func configure(publishableKey: String, bearerToken: String? = nil) async throws { - // Sync JS SDK's client token to native keychain so both SDKs share the same client. - // This handles the case where the user signed in via JS SDK but the native SDK - // has no device token (e.g., after app reinstall or first launch). - if let token = bearerToken, !token.isEmpty { - let existingToken = Self.readNativeDeviceToken() - Self.writeNativeDeviceToken(token) - - // If the device token changed (or didn't exist), clear stale cached client/environment. - // A previous launch may have cached an anonymous client (no device token), and the - // SDK would send both the new device token AND the stale client ID in API requests, - // causing a 400 error. Clearing the cache forces a fresh client fetch using only - // the device token. - if existingToken != token { - Self.clearCachedClerkData() - } - } else { - Self.syncJSTokenToNativeKeychainIfNeeded() - } - - // If already configured with a new bearer token, refresh the client - // to pick up the session associated with the device token we just wrote. - // Clerk.configure() is a no-op on subsequent calls, so we use refreshClient(). - if Self.clerkConfigured, let token = bearerToken, !token.isEmpty { - _ = try? await Clerk.shared.refreshClient() - return - } - - Self.clerkConfigured = true - if let service = Self.keychainService { - Clerk.configure( - publishableKey: publishableKey, - options: .init(keychainConfig: .init(service: service)) - ) - } else { - Clerk.configure(publishableKey: publishableKey) - } - - // Wait for Clerk to finish loading (cached data + API refresh). - // The static configure() fires off async refreshes; poll until loaded. - for _ in 0.. String? { - guard let service = keychainService, !service.isEmpty else { return nil } - - var result: CFTypeRef? - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: "clerkDeviceToken", - kSecReturnData as String: true, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, - let data = result as? Data else { return nil } - return String(data: data, encoding: .utf8) - } - - /// Clears stale cached client and environment data from keychain. - /// This prevents the native SDK from loading a stale anonymous client - /// during initialization, which would conflict with a newly-synced device token. - private static func clearCachedClerkData() { - guard let service = keychainService, !service.isEmpty else { return } - - for key in ["cachedClient", "cachedEnvironment"] { - let query: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: key, - ] - SecItemDelete(query as CFDictionary) - } - } - - /// Writes the provided bearer token as the native SDK's device token. - /// If the native SDK already has a device token, it is updated with the new value. - private static func writeNativeDeviceToken(_ token: String) { - guard let service = keychainService, !service.isEmpty else { return } - - let nativeTokenKey = "clerkDeviceToken" - guard let tokenData = token.data(using: .utf8) else { return } - - // Check if native SDK already has a device token - let checkQuery: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: nativeTokenKey, - kSecReturnData as String: false, - kSecMatchLimit as String: kSecMatchLimitOne, - ] - - if SecItemCopyMatching(checkQuery as CFDictionary, nil) == errSecSuccess { - // Update the existing token - let updateQuery: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: nativeTokenKey, - ] - let updateAttributes: [String: Any] = [ - kSecValueData as String: tokenData, - ] - SecItemUpdate(updateQuery as CFDictionary, updateAttributes as CFDictionary) - } else { - // Write a new token - let writeQuery: [String: Any] = [ - kSecClass as String: kSecClassGenericPassword, - kSecAttrService as String: service, - kSecAttrAccount as String: nativeTokenKey, - kSecValueData as String: tokenData, - kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly, - ] - SecItemAdd(writeQuery as CFDictionary, nil) - } - } - - public func getClientToken() -> String? { - Self.readNativeDeviceToken() - } - - public func createAuthViewController( - mode: String, - dismissable: Bool, - completion: @escaping (Result<[String: Any], Error>) -> Void - ) -> UIViewController? { - let authMode: AuthView.Mode - switch mode { - case "signIn": - authMode = .signIn - case "signUp": - authMode = .signUp - default: - authMode = .signInOrUp - } - - let wrapper = ClerkAuthWrapperViewController( - mode: authMode, - dismissable: dismissable, - completion: completion - ) - return wrapper - } - - public func createUserProfileViewController( - dismissable: Bool, - completion: @escaping (Result<[String: Any], Error>) -> Void - ) -> UIViewController? { - let wrapper = ClerkProfileWrapperViewController( - dismissable: dismissable, - completion: completion - ) - return wrapper - } - - // MARK: - Inline View Creation - - public func createAuthView( - mode: String, - dismissable: Bool, - onEvent: @escaping (String, [String: Any]) -> Void - ) -> UIViewController? { - let authMode: AuthView.Mode - switch mode { - case "signIn": - authMode = .signIn - case "signUp": - authMode = .signUp - default: - authMode = .signInOrUp - } - - let hostingController = UIHostingController( - rootView: ClerkInlineAuthWrapperView( - mode: authMode, - dismissable: dismissable, - onEvent: onEvent - ) - ) - hostingController.view.backgroundColor = .clear - return hostingController - } - - public func createUserProfileView( - dismissable: Bool, - onEvent: @escaping (String, [String: Any]) -> Void - ) -> UIViewController? { - let hostingController = UIHostingController( - rootView: ClerkInlineProfileWrapperView( - dismissable: dismissable, - onEvent: onEvent - ) - ) - hostingController.view.backgroundColor = .clear - return hostingController - } - - @MainActor - public func getSession() async -> [String: Any]? { - guard Self.clerkConfigured, let session = Clerk.shared.session else { - return nil - } - - var result: [String: Any] = [ - "sessionId": session.id, - "status": String(describing: session.status) - ] - - // Include user details if available - let user = session.user ?? Clerk.shared.user - - if let user = user { - var userDict: [String: Any] = [ - "id": user.id, - "imageUrl": user.imageUrl - ] - if let firstName = user.firstName { - userDict["firstName"] = firstName - } - if let lastName = user.lastName { - userDict["lastName"] = lastName - } - if let primaryEmail = user.emailAddresses.first(where: { $0.id == user.primaryEmailAddressId }) { - userDict["primaryEmailAddress"] = primaryEmail.emailAddress - } else if let firstEmail = user.emailAddresses.first { - userDict["primaryEmailAddress"] = firstEmail.emailAddress - } - result["user"] = userDict - } - - return result - } - - @MainActor - public func signOut() async throws { - if Self.clerkConfigured { - defer { Clerk.clearAllKeychainItems() } - if let sessionId = Clerk.shared.session?.id { - try await Clerk.shared.auth.signOut(sessionId: sessionId) - } - } - Self.clerkConfigured = false - } -} - -// MARK: - Auth View Controller Wrapper - -class ClerkAuthWrapperViewController: UIHostingController { - private let completion: (Result<[String: Any], Error>) -> Void - private var authEventTask: Task? - private var completionCalled = false - - init(mode: AuthView.Mode, dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) { - self.completion = completion - let view = ClerkAuthWrapperView(mode: mode, dismissable: dismissable) - super.init(rootView: view) - self.modalPresentationStyle = .fullScreen - subscribeToAuthEvents() - } - - @MainActor required dynamic init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - authEventTask?.cancel() - } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - if isBeingDismissed { - completeOnce(.failure(NSError(domain: "ClerkAuth", code: 3, userInfo: [NSLocalizedDescriptionKey: "Auth modal was dismissed"]))) - } - } - - private func completeOnce(_ result: Result<[String: Any], Error>) { - guard !completionCalled else { return } - completionCalled = true - completion(result) - } - - private func subscribeToAuthEvents() { - authEventTask = Task { @MainActor [weak self] in - for await event in Clerk.shared.auth.events { - guard let self = self, !self.completionCalled else { return } - switch event { - case .signInCompleted(let signIn): - if let sessionId = signIn.createdSessionId { - self.completeOnce(.success(["sessionId": sessionId, "type": "signIn"])) - self.dismiss(animated: true) - } else { - self.completeOnce(.failure(NSError(domain: "ClerkAuth", code: 1, userInfo: [NSLocalizedDescriptionKey: "Sign-in completed but no session ID was created"]))) - self.dismiss(animated: true) - } - case .signUpCompleted(let signUp): - if let sessionId = signUp.createdSessionId { - self.completeOnce(.success(["sessionId": sessionId, "type": "signUp"])) - self.dismiss(animated: true) - } else { - self.completeOnce(.failure(NSError(domain: "ClerkAuth", code: 1, userInfo: [NSLocalizedDescriptionKey: "Sign-up completed but no session ID was created"]))) - self.dismiss(animated: true) - } - default: - break - } - } - // Stream ended without an auth completion event - guard let self = self else { return } - self.completeOnce(.failure(NSError(domain: "ClerkAuth", code: 2, userInfo: [NSLocalizedDescriptionKey: "Auth event stream ended unexpectedly"]))) - } - } -} - -struct ClerkAuthWrapperView: View { - let mode: AuthView.Mode - let dismissable: Bool - - var body: some View { - AuthView(mode: mode, isDismissable: dismissable) - .environment(Clerk.shared) - } -} - -// MARK: - Profile View Controller Wrapper - -class ClerkProfileWrapperViewController: UIHostingController { - private let completion: (Result<[String: Any], Error>) -> Void - private var authEventTask: Task? - private var completionCalled = false - - init(dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) { - self.completion = completion - let view = ClerkProfileWrapperView(dismissable: dismissable) - super.init(rootView: view) - self.modalPresentationStyle = .fullScreen - subscribeToAuthEvents() - } - - @MainActor required dynamic init?(coder aDecoder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - deinit { - authEventTask?.cancel() - } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - if isBeingDismissed { - completeOnce(.failure(NSError(domain: "ClerkProfile", code: 3, userInfo: [NSLocalizedDescriptionKey: "Profile modal was dismissed"]))) - } - } - - private func completeOnce(_ result: Result<[String: Any], Error>) { - guard !completionCalled else { return } - completionCalled = true - completion(result) - } - - private func subscribeToAuthEvents() { - authEventTask = Task { @MainActor [weak self] in - for await event in Clerk.shared.auth.events { - guard let self = self, !self.completionCalled else { return } - switch event { - case .signedOut(let session): - self.completeOnce(.success(["sessionId": session.id])) - self.dismiss(animated: true) - default: - break - } - } - // Stream ended without a sign-out event - guard let self = self else { return } - self.completeOnce(.failure(NSError(domain: "ClerkProfile", code: 2, userInfo: [NSLocalizedDescriptionKey: "Profile event stream ended unexpectedly"]))) - } - } -} - -struct ClerkProfileWrapperView: View { - let dismissable: Bool - - var body: some View { - UserProfileView(isDismissable: dismissable) - .environment(Clerk.shared) - } -} - -// MARK: - Inline Auth View Wrapper (for embedded rendering) - -struct ClerkInlineAuthWrapperView: View { - let mode: AuthView.Mode - let dismissable: Bool - let onEvent: (String, [String: Any]) -> Void - - // Track initial session to detect new sign-ins (same approach as Android) - @State private var initialSessionId: String? = Clerk.shared.session?.id - @State private var eventSent = false - - private func sendAuthCompleted(sessionId: String, type: String) { - guard !eventSent, sessionId != initialSessionId else { return } - eventSent = true - onEvent(type, ["sessionId": sessionId, "type": type == "signUpCompleted" ? "signUp" : "signIn"]) - } - - var body: some View { - AuthView(mode: mode, isDismissable: dismissable) - .environment(Clerk.shared) - // Primary detection: observe Clerk.shared.session directly (matches Android's sessionFlow approach). - // This is more reliable than auth.events which may not emit for inline AuthView sign-ins. - .onChange(of: Clerk.shared.session?.id) { _, newSessionId in - guard let sessionId = newSessionId else { return } - sendAuthCompleted(sessionId: sessionId, type: "signInCompleted") - } - // Fallback: also listen to auth.events for signUp events and edge cases - .task { - for await event in Clerk.shared.auth.events { - guard !eventSent else { continue } - switch event { - case .signInCompleted(let signIn): - let sessionId = signIn.createdSessionId ?? Clerk.shared.session?.id - if let sessionId { sendAuthCompleted(sessionId: sessionId, type: "signInCompleted") } - case .signUpCompleted(let signUp): - let sessionId = signUp.createdSessionId ?? Clerk.shared.session?.id - if let sessionId { sendAuthCompleted(sessionId: sessionId, type: "signUpCompleted") } - case .sessionChanged(_, let newSession): - if let sessionId = newSession?.id { sendAuthCompleted(sessionId: sessionId, type: "signInCompleted") } - default: - break - } - } - } - } -} - -// MARK: - Inline Profile View Wrapper (for embedded rendering) - -struct ClerkInlineProfileWrapperView: View { - let dismissable: Bool - let onEvent: (String, [String: Any]) -> Void - - var body: some View { - UserProfileView(isDismissable: dismissable) - .environment(Clerk.shared) - .task { - for await event in Clerk.shared.auth.events { - switch event { - case .signedOut(let session): - onEvent("signedOut", ["sessionId": session.id]) - default: - break - } - } - } - } -} - diff --git a/packages/expo/src/__tests__/appPlugin.theme.test.js b/packages/expo/src/__tests__/appPlugin.theme.test.js new file mode 100644 index 00000000000..b9a880863ca --- /dev/null +++ b/packages/expo/src/__tests__/appPlugin.theme.test.js @@ -0,0 +1,108 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +// eslint-disable-next-line @typescript-eslint/no-require-imports -- CJS plugin, no ESM export +const { validateThemeJson } = require('../../app.plugin.js')._testing; + +describe('validateThemeJson', () => { + beforeEach(() => { + vi.restoreAllMocks(); + }); + + test('accepts a valid full theme', () => { + expect(() => + validateThemeJson({ + colors: { primary: '#6C47FF', background: '#FFFFFF' }, + darkColors: { primary: '#8B6FFF' }, + design: { borderRadius: 12, fontFamily: 'Inter' }, + }), + ).not.toThrow(); + }); + + test('accepts an empty theme (no keys)', () => { + expect(() => validateThemeJson({})).not.toThrow(); + }); + + test('throws when theme is null', () => { + expect(() => validateThemeJson(null)).toThrow('theme JSON must be a plain object'); + }); + + test('throws when theme is a string', () => { + expect(() => validateThemeJson('hello')).toThrow('theme JSON must be a plain object'); + }); + + test('throws when theme is an array', () => { + expect(() => validateThemeJson([])).toThrow('theme JSON must be a plain object'); + }); + + test('accepts theme with only design', () => { + expect(() => validateThemeJson({ design: { borderRadius: 8 } })).not.toThrow(); + }); + + // --- colors / darkColors shape validation --- + + test('throws when colors is a string', () => { + expect(() => validateThemeJson({ colors: 'red' })).toThrow('colors must be an object'); + }); + + test('throws when colors is an array', () => { + expect(() => validateThemeJson({ colors: ['#FF0000'] })).toThrow('colors must be an object'); + }); + + test('accepts colors: null (treated as absent)', () => { + expect(() => validateThemeJson({ colors: null })).not.toThrow(); + }); + + test('throws when darkColors is a number', () => { + expect(() => validateThemeJson({ darkColors: 42 })).toThrow('darkColors must be an object'); + }); + + // --- design shape validation --- + + test('throws when design is a string', () => { + expect(() => validateThemeJson({ design: 'round' })).toThrow('design must be an object'); + }); + + test('throws when design is an array', () => { + expect(() => validateThemeJson({ design: [12] })).toThrow('design must be an object'); + }); + + test('accepts design: null (treated as absent)', () => { + expect(() => validateThemeJson({ design: null })).not.toThrow(); + }); + + // --- hex color validation --- + + test('throws for invalid hex color (no hash)', () => { + expect(() => validateThemeJson({ colors: { primary: 'FF0000' } })).toThrow('invalid hex color'); + }); + + test('throws for 3-digit hex color', () => { + expect(() => validateThemeJson({ colors: { primary: '#FFF' } })).toThrow('invalid hex color'); + }); + + test('accepts 6-digit hex', () => { + expect(() => validateThemeJson({ colors: { primary: '#FF00AA' } })).not.toThrow(); + }); + + test('accepts 8-digit hex (with alpha)', () => { + expect(() => validateThemeJson({ colors: { shadow: '#00000080' } })).not.toThrow(); + }); + + // --- unknown keys --- + + test('warns on unknown color keys but does not throw', () => { + const spy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + expect(() => validateThemeJson({ colors: { customColor: '#FF0000' } })).not.toThrow(); + expect(spy).toHaveBeenCalledWith(expect.stringContaining('unknown color key "customColor"')); + }); + + // --- design field types --- + + test('throws when fontFamily is a number', () => { + expect(() => validateThemeJson({ design: { fontFamily: 42 } })).toThrow('design.fontFamily must be a string'); + }); + + test('throws when borderRadius is a string', () => { + expect(() => validateThemeJson({ design: { borderRadius: '12' } })).toThrow('design.borderRadius must be a number'); + }); +});