diff --git a/.changeset/fix-native-bridge-quality.md b/.changeset/fix-native-bridge-quality.md new file mode 100644 index 00000000000..6d65646ce5f --- /dev/null +++ b/.changeset/fix-native-bridge-quality.md @@ -0,0 +1,8 @@ +--- +'@clerk/expo': minor +'@clerk/react': patch +--- + +Add native AuthView and UserProfileView components for iOS (SwiftUI) and Android (Jetpack Compose) +Update @clerk/expo and @clerk/react to Core-3 Signal APIs +Integrate native Google Sign-In and Apple Sign-In via Credential Manager and ASAuthorization diff --git a/packages/expo/NATIVE_IOS_SETUP.md b/packages/expo/NATIVE_IOS_SETUP.md new file mode 100644 index 00000000000..2fb3cb9b249 --- /dev/null +++ b/packages/expo/NATIVE_IOS_SETUP.md @@ -0,0 +1,279 @@ +# Native iOS Setup for @clerk/clerk-expo + +This guide explains how to use Clerk's native iOS components in your Expo or React Native application. + +## Overview + +`@clerk/clerk-expo` supports two implementations: + +1. **Native-First (Recommended)**: Uses Clerk's native iOS Swift UI components for the best user experience +2. **React Native**: Cross-platform React Native components that work everywhere + +## Feature Comparison + +| Feature | Native iOS (Swift UI) | React Native | +| -------------------- | ------------------------------------ | ------------------------------- | +| **UI/UX** | Native iOS design, follows Apple HIG | Cross-platform design | +| **Performance** | Native Swift performance | JavaScript bridge overhead | +| **Bundle Size** | Smaller JS bundle | Larger JS bundle | +| **Customization** | Limited to Clerk iOS theming | Full React Native customization | +| **Platform Support** | iOS only | iOS, Android, Web | +| **Build Method** | Requires native build (EAS/Xcode) | Works with Expo Go | +| **Face ID/Touch ID** | Native biometric integration | Via expo-local-authentication | +| **Passkeys** | Native passkey support | Limited support | +| **OAuth** | Native SFAuthenticationSession | WebBrowser-based | + +--- + +## Setup Instructions + +### For Expo Users (Recommended) + +#### Prerequisites + +- Expo SDK 50 or later +- EAS Build account (native builds required) +- iOS deployment target 15.1+ + +#### 1. Install the Package + +```bash +npx expo install @clerk/clerk-expo +``` + +#### 2. Add the Expo Config Plugin + +In your `app.json` or `app.config.js`: + +```json +{ + "expo": { + "plugins": [["@clerk/clerk-expo/app.plugin"]] + } +} +``` + +#### 3. Configure Your App + +```tsx +// app/_layout.tsx +import { ClerkProvider } from '@clerk/clerk-expo'; + +export default function RootLayout() { + return ( + + {/* Your app content */} + + ); +} +``` + +#### 4. Use Native Components + +```tsx +// app/(auth)/sign-in.tsx +import { SignIn } from '@clerk/clerk-expo/native'; +import { useRouter } from 'expo-router'; + +export default function SignInScreen() { + const router = useRouter(); + + return ( + router.replace('/(home)')} + onError={error => console.error('Sign in error:', error)} + /> + ); +} +``` + +#### 5. Build with EAS + +The native iOS components require a native build: + +```bash +# Development build +eas build --profile development --platform ios + +# Install on simulator +eas build:run --profile development --platform ios + +# Production build +eas build --profile production --platform ios +``` + +**Important**: Native iOS components **will not work** with Expo Go. You must create a development build. + +--- + +### For React Native CLI Users + +If you're using React Native without Expo, you'll need to manually add the clerk-ios Swift package. + +#### Prerequisites + +- React Native 0.70 or later +- CocoaPods +- Xcode 14+ +- iOS deployment target 15.1+ + +#### 1. Install the Package + +```bash +npm install @clerk/clerk-expo +# or +yarn add @clerk/clerk-expo +``` + +#### 2. Install iOS Dependencies + +```bash +cd ios && pod install && cd .. +``` + +#### 3. Add clerk-ios Swift Package in Xcode + +1. Open your `.xcworkspace` file in Xcode +2. Select your project in the Project Navigator +3. Select your app target +4. Go to the "Package Dependencies" tab +5. Click the "+" button +6. Enter the repository URL: `https://github.com/clerk/clerk-ios.git` +7. Select "Up to Next Major Version" with minimum version `0.68.1` +8. Ensure the "Clerk" product is selected for your target +9. Click "Add Package" + +#### 4. Verify Installation + +Build your project to ensure the Swift package is properly linked: + +```bash +npx react-native run-ios +``` + +--- + +## Using React Native Components Instead + +If you want to use the cross-platform React Native components (works with Expo Go), import from the main package: + +```tsx +import { SignIn } from '@clerk/clerk-expo'; +// NOT from '@clerk/clerk-expo/native' +``` + +### When to Use React Native Components + +- Testing in Expo Go +- Need Android support +- Want full UI customization +- Don't need native iOS features (Face ID, Passkeys) + +### When to Use Native iOS Components + +- Building a production iOS app +- Want the best iOS user experience +- Need native biometric authentication +- Want smaller JavaScript bundle size +- Need passkey support + +--- + +## API Reference + +### Native SignIn Component + +```tsx +import { SignIn } from '@clerk/clerk-expo/native'; + + void} + onError={(error) => void} +/> +``` + +**Props:** + +- `mode`: Authentication mode (default: `"signInOrUp"`) +- `isDismissable`: Whether the view can be dismissed (default: `true`) +- `onSuccess`: Callback when authentication succeeds +- `onError`: Callback when authentication fails + +--- + +## Troubleshooting + +### "Module 'Clerk' not found" + +The clerk-ios Swift package isn't installed. Follow the manual setup steps above. + +### "Expo Go doesn't show native components" + +Native components require a development build. Run `eas build --profile development --platform ios`. + +### Plugin doesn't add Swift package + +The config plugin only runs during `expo prebuild` or `eas build`. If you're using a bare workflow, you'll need to add the package manually in Xcode. + +### Build fails with Swift errors + +Ensure your iOS deployment target is at least 15.1 in your `Podfile`: + +```ruby +platform :ios, '15.1' +``` + +--- + +## Migration Guide + +### From React Native Components to Native + +1. Change your imports: + +```tsx +// Before +import { SignIn } from '@clerk/clerk-expo'; + +// After +import { SignIn } from '@clerk/clerk-expo/native'; +``` + +2. Create a development build (can't use Expo Go) +3. Test on a physical device or simulator + +### From Native to React Native + +1. Change your imports back: + +```tsx +// Before +import { SignIn } from '@clerk/clerk-expo/native'; + +// After +import { SignIn } from '@clerk/clerk-expo'; +``` + +2. Can now use Expo Go for testing + +--- + +## Additional Resources + +- [Clerk iOS SDK Documentation](https://github.com/clerk/clerk-ios) +- [Expo Config Plugins](https://docs.expo.dev/config-plugins/introduction/) +- [EAS Build Documentation](https://docs.expo.dev/build/introduction/) +- [Clerk Dashboard](https://dashboard.clerk.com/) + +--- + +## Support + +For issues related to: + +- Native iOS components: [clerk-ios repository](https://github.com/clerk/clerk-ios/issues) +- Expo integration: [clerk-javascript repository](https://github.com/clerk/javascript/issues) +- General Clerk questions: [Clerk Discord](https://clerk.com/discord) diff --git a/packages/expo/android/build.gradle b/packages/expo/android/build.gradle index ee1fab8fa00..edb1f5e2387 100644 --- a/packages/expo/android/build.gradle +++ b/packages/expo/android/build.gradle @@ -1,5 +1,13 @@ -apply plugin: 'com.android.library' -apply plugin: 'kotlin-android' +plugins { + id 'com.android.library' + id 'org.jetbrains.kotlin.android' + id 'org.jetbrains.kotlin.plugin.compose' version '2.1.20' +} + +// Required for React Native codegen to generate Fabric component descriptors +if (project.hasProperty("newArchEnabled") && project.newArchEnabled == "true") { + apply plugin: "com.facebook.react" +} group = 'com.clerk.expo' version = '1.0.0' @@ -10,6 +18,11 @@ ext { credentialsVersion = "1.3.0" googleIdVersion = "1.1.1" kotlinxCoroutinesVersion = "1.7.3" + clerkAndroidApiVersion = "1.0.1" + clerkAndroidUiVersion = "1.0.1" + composeVersion = "1.7.0" + activityComposeVersion = "1.9.0" + lifecycleVersion = "2.8.0" } def safeExtGet(prop, fallback) { @@ -17,7 +30,7 @@ def safeExtGet(prop, fallback) { } android { - namespace "expo.modules.clerk.googlesignin" + namespace "expo.modules.clerk" compileSdk safeExtGet("compileSdkVersion", 36) @@ -43,16 +56,28 @@ android { jvmTarget = "17" } + buildFeatures { + compose = true + } + + packaging { + resources { + excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF'] + } + } + sourceSets { main { - java.srcDirs = ['src/main/java'] + java.srcDirs = ['src/main/java', "${project.buildDir}/generated/source/codegen/java"] } } } +// Note: kotlin-stdlib exclusions are handled in the clerk-android-ui dependency declaration + dependencies { - // Expo modules core - implementation project(':expo-modules-core') + // React Native + implementation 'com.facebook.react:react-native:+' // Credential Manager for Google Sign-In with nonce support implementation "androidx.credentials:credentials:$credentialsVersion" @@ -61,4 +86,22 @@ dependencies { // Coroutines for async operations implementation "org.jetbrains.kotlinx:kotlinx-coroutines-android:$kotlinxCoroutinesVersion" + + // Clerk Android SDK with prebuilt UI + // Exclude kotlin-stdlib to prevent 2.3.0 from polluting the project + // Exclude okhttp to prevent version conflict with React Native's okhttp + implementation("com.clerk:clerk-android-ui:$clerkAndroidUiVersion") { + exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib' + exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-jdk7' + exclude group: 'org.jetbrains.kotlin', module: 'kotlin-stdlib-jdk8' + exclude group: 'com.squareup.okhttp3', module: 'okhttp' + exclude group: 'com.squareup.okhttp3', module: 'okhttp-urlconnection' + } + + // Jetpack Compose for wrapping Clerk views + implementation "androidx.compose.ui:ui:$composeVersion" + implementation "androidx.compose.material3:material3:1.3.0" + implementation "androidx.activity:activity-compose:$activityComposeVersion" + implementation "androidx.lifecycle:lifecycle-runtime-compose:$lifecycleVersion" + implementation "androidx.lifecycle:lifecycle-viewmodel-compose:$lifecycleVersion" } diff --git a/packages/expo/android/src/main/AndroidManifest.xml b/packages/expo/android/src/main/AndroidManifest.xml index a2f47b6057d..4683222f409 100644 --- a/packages/expo/android/src/main/AndroidManifest.xml +++ b/packages/expo/android/src/main/AndroidManifest.xml @@ -1,2 +1,17 @@ + + + + + + + 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 new file mode 100644 index 00000000000..7ef0bee81a0 --- /dev/null +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthActivity.kt @@ -0,0 +1,303 @@ +package expo.modules.clerk + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.compose.BackHandler +import androidx.activity.compose.setContent +import java.util.concurrent.atomic.AtomicBoolean +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.size +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.clerk.api.Clerk +import com.clerk.api.signin.SignIn +import com.clerk.api.signin.prepareSecondFactor +import com.clerk.api.signup.SignUp +import com.clerk.api.signup.prepareVerification +import com.clerk.api.network.serialization.onSuccess +import com.clerk.api.network.serialization.onFailure +import com.clerk.api.network.serialization.errorMessage +import com.clerk.ui.auth.AuthView +import kotlinx.coroutines.delay + +/** + * Activity that hosts Clerk's AuthView Compose component. + * + * This activity is launched from ClerkExpoModule to present a full-screen + * authentication modal (sign-in, sign-up, or combined flow). + * + * Intent extras: + * - "mode": String - "signIn", "signUp", or "signInOrUp" (default) + * - "dismissable": Boolean - whether back press dismisses (default: true) + * + * Result: + * - RESULT_OK: Auth completed successfully (session is available via Clerk.session) + * - RESULT_CANCELED: User dismissed the modal + */ +class ClerkAuthActivity : ComponentActivity() { + + companion object { + private const val TAG = "ClerkAuthActivity" + + private fun debugLog(tag: String, message: String) { + if (BuildConfig.DEBUG) { + Log.d(tag, message) + } + } + } + + private val authCompleteGuard = AtomicBoolean(false) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val mode = intent.getStringExtra(ClerkExpoModule.EXTRA_MODE) ?: "signInOrUp" + val dismissable = intent.getBooleanExtra(ClerkExpoModule.EXTRA_DISMISSABLE, true) + + // Track if we had a session when we started (to detect new sign-in) + val initialSession = Clerk.session + debugLog(TAG, "onCreate - initialSession: ${initialSession?.id}, mode: $mode") + + setContent { + // Observe initialization state + val isInitialized by Clerk.isInitialized.collectAsStateWithLifecycle() + + // Observe both session and user state for completion + val session by Clerk.sessionFlow.collectAsStateWithLifecycle() + val user by Clerk.userFlow.collectAsStateWithLifecycle() + + // Track if the client has been synced (environment is ready) + // We need to wait for the client to sync before showing AuthView + var isClientReady by remember { mutableStateOf(false) } + + // Track when auth is complete to hide AuthView before finishing + // This prevents the "NavDisplay backstack cannot be empty" crash + var isAuthComplete by remember { mutableStateOf(false) } + + // Wait for SDK to be fully initialized AND client to sync + // The client sync happens after isInitialized becomes true + LaunchedEffect(isInitialized) { + if (isInitialized) { + // Give the client a moment to sync after initialization + // The SDK needs time to fetch the environment configuration + var attempts = 0 + while (attempts < 30) { // Wait up to 3 seconds + val client = Clerk.client + if (client != null) { + debugLog(TAG, "Client is ready: ${client.id}") + isClientReady = true + break + } + delay(100) + attempts++ + } + if (!isClientReady) { + Log.w(TAG, "Client did not become ready after 3 seconds, showing AuthView anyway") + isClientReady = true + } + } + } + + // Track last signUp ID to detect when a new signUp is created + var lastSignUpId by remember { mutableStateOf(null) } + // Track if we've already triggered prepareVerification for this signUp + var preparedSignUpId by remember { mutableStateOf(null) } + + // Track if we've already triggered prepareSecondFactor for this signIn + var preparedSecondFactorSignInId by remember { mutableStateOf(null) } + + // Monitor signUp state changes and manually trigger prepareVerification + LaunchedEffect(isClientReady) { + if (isClientReady) { + while (true) { + delay(500) // Check every 500ms + val client = Clerk.client + val signUp = client?.signUp + + if (signUp != null && signUp.id != lastSignUpId) { + lastSignUpId = signUp.id + debugLog(TAG, "New signUp detected: ${signUp.id}, status: ${signUp.status}") + } + + // Manually trigger prepareVerification if needed + // This is a workaround for clerk-android-ui not calling prepareVerification + if (signUp != null && + signUp.id != preparedSignUpId && + signUp.emailAddress != null && + signUp.status == SignUp.Status.MISSING_REQUIREMENTS) { + + val emailVerification = signUp.verifications?.get("email_address") + // Only prepare if email is unverified + if (emailVerification?.status?.name == "UNVERIFIED") { + preparedSignUpId = signUp.id + + try { + val result = signUp.prepareVerification( + SignUp.PrepareVerificationParams.Strategy.EmailCode() + ) + result + .onSuccess { + debugLog(TAG, "prepareVerification succeeded") + } + .onFailure { error -> + Log.e(TAG, "prepareVerification failed: ${error.errorMessage}") + } + } catch (e: Exception) { + Log.e(TAG, "prepareVerification exception: ${e.message}") + } + } + } + + // Manually trigger prepareSecondFactor for MFA if needed + // This is a workaround for clerk-android-ui not calling prepareSecondFactor + val signIn = client?.signIn + if (signIn != null && + signIn.id != preparedSecondFactorSignInId && + signIn.status == SignIn.Status.NEEDS_SECOND_FACTOR) { + + preparedSecondFactorSignInId = signIn.id + + try { + val result = signIn.prepareSecondFactor() + result + .onSuccess { updatedSignIn -> + debugLog(TAG, "prepareSecondFactor succeeded, status: ${updatedSignIn.status}") + } + .onFailure { error -> + Log.e(TAG, "prepareSecondFactor failed: ${error.errorMessage}") + // Reset so we can retry + preparedSecondFactorSignInId = null + } + } catch (e: Exception) { + Log.e(TAG, "prepareSecondFactor exception: ${e.message}") + // Reset so we can retry + preparedSecondFactorSignInId = null + } + } + + // Check if auth completed - finish activity immediately + val currentSession = Clerk.session + if (currentSession != null && authCompleteGuard.compareAndSet(false, true)) { + isAuthComplete = true + + val resultIntent = Intent().apply { + putExtra("sessionId", currentSession.id) + putExtra("userId", currentSession.user?.id ?: Clerk.user?.id) + } + setResult(Activity.RESULT_OK, resultIntent) + finish() + break + } + } + } + } + + // Backup: Also listen for session via Flow (in case polling misses it) + LaunchedEffect(session) { + if (session != null && initialSession == null && authCompleteGuard.compareAndSet(false, true)) { + // Mark auth as complete FIRST to hide AuthView + // This prevents the "NavDisplay backstack cannot be empty" crash + isAuthComplete = true + + // Small delay to let the UI update before finishing + delay(100) + + // Auth completed - return session info + val resultIntent = Intent().apply { + putExtra("sessionId", session?.id) + putExtra("userId", session?.user?.id ?: user?.id) + } + setResult(Activity.RESULT_OK, resultIntent) + finish() + } + } + + // Handle back press + if (dismissable) { + BackHandler { + setResult(Activity.RESULT_CANCELED) + finish() + } + } else { + // Block back press when not dismissable + BackHandler { /* Do nothing */ } + } + + // Render Clerk's AuthView in a Material3 surface + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + when { + isAuthComplete -> { + // Auth completed - show success indicator while finishing + // This prevents AuthView from crashing with empty navigation backstack + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + CircularProgressIndicator( + modifier = Modifier.size(48.dp) + ) + Text( + text = "Signed in!", + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + isClientReady -> { + // Client is ready, show AuthView + AuthView( + modifier = Modifier.fillMaxSize(), + clerkTheme = null // Use default theme, or pass custom + ) + } + else -> { + // Show loading while waiting for client to sync + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(16.dp) + ) { + CircularProgressIndicator( + modifier = Modifier.size(48.dp) + ) + Text( + text = "Loading...", + style = MaterialTheme.typography.bodyMedium + ) + } + } + } + } + } + } + } + } +} 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 new file mode 100644 index 00000000000..712b0ec62f3 --- /dev/null +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt @@ -0,0 +1,149 @@ +package expo.modules.clerk + +import android.content.Context +import android.content.ContextWrapper +import android.util.Log +import android.widget.FrameLayout +import androidx.activity.ComponentActivity +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Recomposer +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.AndroidUiDispatcher +import androidx.compose.ui.platform.ComposeView +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.lifecycle.setViewTreeViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.savedstate.compose.LocalSavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import com.clerk.api.Clerk +import com.clerk.ui.auth.AuthView +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReactContext +import com.facebook.react.uimanager.events.RCTEventEmitter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +private const val TAG = "ClerkAuthExpoView" + +class ClerkAuthNativeView(context: Context) : FrameLayout(context) { + var mode: String = "signInOrUp" + var isDismissable: Boolean = true + + private val activity: ComponentActivity? = findActivity(context) + + private var recomposer: Recomposer? = null + private var recomposerJob: kotlinx.coroutines.Job? = null + + private val composeView = ComposeView(context).also { view -> + activity?.let { act -> + view.setViewTreeLifecycleOwner(act) + view.setViewTreeViewModelStoreOwner(act) + view.setViewTreeSavedStateRegistryOwner(act) + + // Create an explicit Recomposer to bypass windowRecomposer resolution. + // In Compose 1.7+, windowRecomposer looks at rootView which may not have + // lifecycle owners in React Native Fabric's detached view trees. + val recomposerContext = AndroidUiDispatcher.Main + val newRecomposer = Recomposer(recomposerContext) + recomposer = newRecomposer + view.setParentCompositionContext(newRecomposer) + val scope = CoroutineScope(recomposerContext + kotlinx.coroutines.SupervisorJob()) + recomposerJob = scope.coroutineContext[kotlinx.coroutines.Job] + scope.launch { + newRecomposer.runRecomposeAndApplyChanges() + } + } + addView(view, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + } + + override fun onDetachedFromWindow() { + recomposer?.cancel() + recomposerJob?.cancel() + super.onDetachedFromWindow() + } + + // Track the initial session to detect new sign-ins + private var initialSessionId: String? = Clerk.session?.id + + fun setupView() { + Log.d(TAG, "setupView - mode: $mode, isDismissable: $isDismissable, activity: $activity") + + composeView.setContent { + val session by Clerk.sessionFlow.collectAsStateWithLifecycle() + + // Detect auth completion: session appeared when there wasn't one + LaunchedEffect(session) { + val currentSession = session + if (currentSession != null && initialSessionId == null) { + Log.d(TAG, "Auth completed - session: ${currentSession.id}") + sendEvent("signInCompleted", mapOf( + "sessionId" to currentSession.id, + "type" to "signIn" + )) + } + } + + // Provide the Activity as ViewModelStoreOwner so Clerk's viewModel() calls work + val content = @androidx.compose.runtime.Composable { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + AuthView( + modifier = Modifier.fillMaxSize(), + clerkTheme = null + ) + } + } + } + + if (activity != null) { + CompositionLocalProvider( + LocalViewModelStoreOwner provides activity, + LocalLifecycleOwner provides activity, + LocalSavedStateRegistryOwner provides activity, + ) { + content() + } + } else { + Log.e(TAG, "No ComponentActivity found!") + content() + } + } + } + + private fun sendEvent(type: String, data: Map) { + val reactContext = context as? ReactContext ?: return + val eventData = Arguments.createMap().apply { + putString("type", type) + // Serialize data as JSON string for codegen event + val jsonString = try { + org.json.JSONObject(data).toString() + } catch (e: Exception) { + "{}" + } + putString("data", jsonString) + } + reactContext.getJSModule(RCTEventEmitter::class.java) + .receiveEvent(id, "onAuthEvent", eventData) + } + + companion object { + fun findActivity(context: Context): ComponentActivity? { + var ctx: Context? = context + while (ctx != null) { + if (ctx is ComponentActivity) return ctx + ctx = (ctx as? ContextWrapper)?.baseContext + } + return null + } + } +} diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewManager.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewManager.kt new file mode 100644 index 00000000000..9ff989d9ea8 --- /dev/null +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthViewManager.kt @@ -0,0 +1,38 @@ +package expo.modules.clerk + +import com.facebook.react.common.MapBuilder +import com.facebook.react.uimanager.SimpleViewManager +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.annotations.ReactProp +import com.facebook.react.viewmanagers.ClerkAuthViewManagerInterface + +class ClerkAuthViewManager : SimpleViewManager(), + ClerkAuthViewManagerInterface { + + override fun getName(): String = "ClerkAuthView" + + override fun createViewInstance(reactContext: ThemedReactContext): ClerkAuthNativeView { + return ClerkAuthNativeView(reactContext) + } + + @ReactProp(name = "mode") + override fun setMode(view: ClerkAuthNativeView, mode: String?) { + view.mode = mode ?: "signInOrUp" + view.setupView() + } + + @ReactProp(name = "isDismissable") + override fun setIsDismissable(view: ClerkAuthNativeView, isDismissable: Boolean) { + view.isDismissable = isDismissable + view.setupView() + } + + override fun getExportedCustomBubblingEventTypeConstants(): MutableMap? { + return MapBuilder.builder() + .put("onAuthEvent", MapBuilder.of( + "phasedRegistrationNames", + MapBuilder.of("bubbled", "onAuthEvent") + )) + .build() as MutableMap + } +} 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 new file mode 100644 index 00000000000..23cf1cd24a3 --- /dev/null +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt @@ -0,0 +1,340 @@ +package expo.modules.clerk + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.util.Log +import com.clerk.api.Clerk +import com.facebook.react.bridge.ActivityEventListener +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactMethod +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.WritableNativeMap +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout + +private const val TAG = "ClerkExpoModule" + +private fun debugLog(tag: String, message: String) { + if (BuildConfig.DEBUG) { + Log.d(tag, message) + } +} + +class ClerkExpoModule(reactContext: ReactApplicationContext) : + NativeClerkModuleSpec(reactContext), + ActivityEventListener { + + companion object { + const val CLERK_AUTH_REQUEST_CODE = 9001 + const val CLERK_PROFILE_REQUEST_CODE = 9002 + + // Intent extras + const val EXTRA_DISMISSABLE = "dismissable" + const val EXTRA_PUBLISHABLE_KEY = "publishableKey" + const val EXTRA_MODE = "mode" + + // Result extras + const val RESULT_SESSION_ID = "sessionId" + const val RESULT_CANCELLED = "cancelled" + + // Pending promises for activity results + private var pendingAuthPromise: Promise? = null + private var pendingProfilePromise: Promise? = null + + // Store publishable key for passing to activities + private var publishableKey: String? = null + } + + private val coroutineScope = CoroutineScope(Dispatchers.Main) + + init { + reactContext.addActivityEventListener(this) + } + + override fun getName(): String = "ClerkExpo" + + // MARK: - configure + + @ReactMethod + override fun configure(pubKey: String, bearerToken: String?, promise: Promise) { + coroutineScope.launch { + try { + publishableKey = pubKey + + // If the JS SDK has a bearer token, write it to the native SDK's + // SharedPreferences so both SDKs share the same Clerk API client. + if (!bearerToken.isNullOrEmpty()) { + reactApplicationContext.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE) + .edit() + .putString("DEVICE_TOKEN", bearerToken) + .apply() + debugLog(TAG, "configure - wrote JS bearer token to native SharedPreferences") + } + + Clerk.initialize(reactApplicationContext, pubKey) + + // Wait for initialization to complete with timeout + try { + withTimeout(10_000L) { + Clerk.isInitialized.first { it } + } + } catch (e: TimeoutCancellationException) { + val initError = Clerk.initializationError.value + val message = if (initError != null) { + "Clerk initialization timed out: ${initError.message}" + } else { + "Clerk initialization timed out after 10 seconds" + } + promise.reject("E_TIMEOUT", message) + return@launch + } + + // Check for initialization errors + val error = Clerk.initializationError.value + if (error != null) { + promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${error.message}") + } else { + promise.resolve(null) + } + } catch (e: Exception) { + promise.reject("E_INIT_FAILED", "Failed to initialize Clerk SDK: ${e.message}", e) + } + } + } + + // MARK: - presentAuth + + @ReactMethod + override fun presentAuth(options: ReadableMap, promise: Promise) { + val activity = getCurrentActivity() ?: run { + promise.reject("E_ACTIVITY_UNAVAILABLE", "No activity available to present Clerk UI.") + return + } + + if (!Clerk.isInitialized.value) { + promise.reject("E_NOT_INITIALIZED", "Clerk SDK is not initialized. Call configure() first.") + return + } + + // Check if user is already signed in + if (Clerk.session != null) { + promise.reject("already_signed_in", "User is already signed in") + return + } + + pendingAuthPromise?.reject("E_SUPERSEDED", "Auth presentation was superseded") + pendingAuthPromise = promise + + val mode = if (options.hasKey("mode")) options.getString("mode") ?: "signInOrUp" else "signInOrUp" + val dismissable = if (options.hasKey("dismissable")) options.getBoolean("dismissable") else true + + val intent = Intent(activity, ClerkAuthActivity::class.java).apply { + putExtra(EXTRA_MODE, mode) + putExtra(EXTRA_DISMISSABLE, dismissable) + } + + activity.startActivityForResult(intent, CLERK_AUTH_REQUEST_CODE) + } + + // MARK: - presentUserProfile + + @ReactMethod + override fun presentUserProfile(options: ReadableMap, promise: Promise) { + val activity = getCurrentActivity() ?: run { + promise.reject("E_ACTIVITY_UNAVAILABLE", "No activity available to present Clerk UI.") + return + } + + if (!Clerk.isInitialized.value) { + promise.reject("E_NOT_INITIALIZED", "Clerk SDK is not initialized. Call configure() first.") + return + } + + pendingProfilePromise?.reject("E_SUPERSEDED", "Profile presentation was superseded") + pendingProfilePromise = promise + + val dismissable = if (options.hasKey("dismissable")) options.getBoolean("dismissable") else true + + val intent = Intent(activity, ClerkUserProfileActivity::class.java).apply { + putExtra(EXTRA_DISMISSABLE, dismissable) + putExtra(EXTRA_PUBLISHABLE_KEY, publishableKey) + } + + activity.startActivityForResult(intent, CLERK_PROFILE_REQUEST_CODE) + } + + // MARK: - getSession + + @ReactMethod + override fun getSession(promise: Promise) { + if (!Clerk.isInitialized.value) { + promise.reject("E_NOT_INITIALIZED", "Clerk SDK is not initialized. Call configure() first.") + return + } + + val session = Clerk.session + val user = Clerk.user + + debugLog(TAG, "getSession - session: ${session?.id}, user: ${user?.id}") + + val result = WritableNativeMap() + + session?.let { + val sessionMap = WritableNativeMap() + sessionMap.putString("id", it.id) + sessionMap.putString("status", it.status.name) + sessionMap.putString("userId", it.user?.id) + result.putMap("session", sessionMap) + } + + user?.let { + val primaryEmail = it.emailAddresses?.find { e -> e.id == it.primaryEmailAddressId } + val primaryPhone = it.phoneNumbers.find { p -> p.id == it.primaryPhoneNumberId } + + val userMap = WritableNativeMap() + userMap.putString("id", it.id) + userMap.putString("firstName", it.firstName) + userMap.putString("lastName", it.lastName) + userMap.putString("imageUrl", it.imageUrl) + userMap.putString("primaryEmailAddress", primaryEmail?.emailAddress) + userMap.putString("primaryPhoneNumber", primaryPhone?.phoneNumber) + result.putMap("user", userMap) + } + + promise.resolve(result) + } + + // MARK: - getClientToken + + @ReactMethod + override fun getClientToken(promise: Promise) { + try { + val prefs = reactApplicationContext.getSharedPreferences("clerk_preferences", Context.MODE_PRIVATE) + val deviceToken = prefs.getString("DEVICE_TOKEN", null) + debugLog(TAG, "getClientToken - deviceToken: ${if (deviceToken != null) "found" else "null"}") + promise.resolve(deviceToken) + } catch (e: Exception) { + debugLog(TAG, "getClientToken failed: ${e.message}") + promise.resolve(null) + } + } + + // MARK: - signOut + + @ReactMethod + override fun signOut(promise: Promise) { + if (!Clerk.isInitialized.value) { + promise.reject("E_NOT_INITIALIZED", "Clerk SDK is not initialized. Call configure() first.") + return + } + + coroutineScope.launch { + try { + Clerk.auth.signOut() + promise.resolve(null) + } catch (e: Exception) { + promise.reject("E_SIGN_OUT_FAILED", e.message ?: "Sign out failed", e) + } + } + } + + // MARK: - Activity Result Handling + + override fun onActivityResult(activity: Activity, requestCode: Int, resultCode: Int, data: Intent?) { + when (requestCode) { + CLERK_AUTH_REQUEST_CODE -> handleAuthResult(resultCode, data) + CLERK_PROFILE_REQUEST_CODE -> handleProfileResult(resultCode, data) + } + } + + override fun onNewIntent(intent: Intent) { + // Not used + } + + private fun handleAuthResult(resultCode: Int, data: Intent?) { + debugLog(TAG, "handleAuthResult - resultCode: $resultCode") + + val promise = pendingAuthPromise ?: return + pendingAuthPromise = null + + if (resultCode == Activity.RESULT_OK) { + val session = Clerk.session + val user = Clerk.user + + debugLog(TAG, "handleAuthResult - session: ${session?.id}, user: ${user?.id}") + + val result = WritableNativeMap() + + // Top-level sessionId for JS SDK compatibility (matches iOS response format) + result.putString("sessionId", session?.id) + + session?.let { + val sessionMap = WritableNativeMap() + sessionMap.putString("id", it.id) + sessionMap.putString("status", it.status.name) + sessionMap.putString("userId", it.user?.id) + result.putMap("session", sessionMap) + } + + user?.let { + val primaryEmail = it.emailAddresses?.find { e -> e.id == it.primaryEmailAddressId } + + val userMap = WritableNativeMap() + userMap.putString("id", it.id) + userMap.putString("firstName", it.firstName) + userMap.putString("lastName", it.lastName) + userMap.putString("imageUrl", it.imageUrl) + userMap.putString("primaryEmailAddress", primaryEmail?.emailAddress) + result.putMap("user", userMap) + } + + promise.resolve(result) + } else { + debugLog(TAG, "handleAuthResult - user cancelled") + val result = WritableNativeMap() + result.putBoolean("cancelled", true) + promise.resolve(result) + } + } + + private fun handleProfileResult(resultCode: Int, data: Intent?) { + val promise = pendingProfilePromise ?: return + pendingProfilePromise = null + + // Profile always returns current session state + val session = Clerk.session + val user = Clerk.user + + val result = WritableNativeMap() + + session?.let { + val sessionMap = WritableNativeMap() + sessionMap.putString("id", it.id) + sessionMap.putString("status", it.status.name) + sessionMap.putString("userId", it.user?.id) + result.putMap("session", sessionMap) + } + + user?.let { + val primaryEmail = it.emailAddresses?.find { e -> e.id == it.primaryEmailAddressId } + + val userMap = WritableNativeMap() + userMap.putString("id", it.id) + userMap.putString("firstName", it.firstName) + userMap.putString("lastName", it.lastName) + userMap.putString("imageUrl", it.imageUrl) + userMap.putString("primaryEmailAddress", primaryEmail?.emailAddress) + result.putMap("user", userMap) + } + + result.putBoolean("dismissed", resultCode == Activity.RESULT_CANCELED) + + promise.resolve(result) + } +} diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkPackage.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkPackage.kt new file mode 100644 index 00000000000..9a97309ac5e --- /dev/null +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkPackage.kt @@ -0,0 +1,43 @@ +package expo.modules.clerk + +import com.facebook.react.TurboReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.module.model.ReactModuleInfo +import com.facebook.react.module.model.ReactModuleInfoProvider +import com.facebook.react.uimanager.ViewManager + +class ClerkPackage : TurboReactPackage() { + + override fun getModule(name: String, reactContext: ReactApplicationContext): NativeModule? { + return when (name) { + NativeClerkModuleSpec.NAME -> ClerkExpoModule(reactContext) + NativeClerkGoogleSignInSpec.NAME -> expo.modules.clerk.googlesignin.ClerkGoogleSignInModule(reactContext) + else -> null + } + } + + override fun getReactModuleInfoProvider(): ReactModuleInfoProvider { + return ReactModuleInfoProvider { + mapOf( + NativeClerkModuleSpec.NAME to ReactModuleInfo( + NativeClerkModuleSpec.NAME, + ClerkExpoModule::class.java.name, + false, false, true, false, true + ), + NativeClerkGoogleSignInSpec.NAME to ReactModuleInfo( + NativeClerkGoogleSignInSpec.NAME, + expo.modules.clerk.googlesignin.ClerkGoogleSignInModule::class.java.name, + false, false, true, false, true + ), + ) + } + } + + override fun createViewManagers(reactContext: ReactApplicationContext): List> { + return listOf( + ClerkAuthViewManager(), + ClerkUserProfileViewManager(), + ) + } +} diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileActivity.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileActivity.kt new file mode 100644 index 00000000000..b95f4fbe7fa --- /dev/null +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileActivity.kt @@ -0,0 +1,119 @@ +package expo.modules.clerk + +import android.app.Activity +import android.content.Intent +import android.os.Bundle +import android.util.Log +import androidx.activity.ComponentActivity +import androidx.activity.OnBackPressedCallback +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.clerk.api.Clerk +import com.clerk.ui.userprofile.UserProfileView + +/** + * Activity that hosts the Clerk UserProfileView composable. + * Presents the native user profile UI and returns the result when dismissed. + */ +class ClerkUserProfileActivity : ComponentActivity() { + + companion object { + private const val TAG = "ClerkUserProfileActivity" + + private fun debugLog(tag: String, message: String) { + if (BuildConfig.DEBUG) { + Log.d(tag, message) + } + } + } + + private var dismissed = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + enableEdgeToEdge() + + val dismissable = intent.getBooleanExtra(ClerkExpoModule.EXTRA_DISMISSABLE, true) + val publishableKey = intent.getStringExtra(ClerkExpoModule.EXTRA_PUBLISHABLE_KEY) + + debugLog(TAG, "onCreate - isInitialized: ${Clerk.isInitialized.value}") + debugLog(TAG, "onCreate - session: ${Clerk.session?.id}, user: ${Clerk.user?.id}") + + // Initialize Clerk if not already initialized + if (publishableKey != null && !Clerk.isInitialized.value) { + debugLog(TAG, "Initializing Clerk...") + Clerk.initialize(applicationContext, publishableKey) + } + + setContent { + // Observe user state changes + val user by Clerk.userFlow.collectAsStateWithLifecycle() + val session by Clerk.sessionFlow.collectAsStateWithLifecycle() + + // Track if we had a session when the profile opened (to detect sign-out) + var hadSession by remember { mutableStateOf(Clerk.session != null) } + + // Log when user/session state changes + LaunchedEffect(user, session) { + debugLog(TAG, "State changed - session: ${session?.id}, user: ${user?.id}") + } + + // Detect sign-out: if we had a session and now it's null, user signed out + LaunchedEffect(session) { + if (hadSession && session == null) { + debugLog(TAG, "Sign-out detected - session became null, dismissing activity") + finishWithSuccess() + } + // Update hadSession if we get a session (handles edge cases) + if (session != null) { + hadSession = true + } + } + + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + UserProfileView( + clerkTheme = Clerk.customTheme, + onDismiss = { + finishWithSuccess() + } + ) + } + } + } + + // Handle back press via onBackPressedDispatcher (replaces deprecated onBackPressed) + onBackPressedDispatcher.addCallback(this, object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + if (dismissable) { + finishWithSuccess() + } + // Otherwise ignore back press + } + }) + } + + private fun finishWithSuccess() { + if (dismissed) return + dismissed = true + + val result = Intent() + result.putExtra(ClerkExpoModule.RESULT_SESSION_ID, Clerk.session?.id) + result.putExtra(ClerkExpoModule.RESULT_CANCELLED, false) + setResult(Activity.RESULT_OK, result) + finish() + } +} diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileExpoView.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileExpoView.kt new file mode 100644 index 00000000000..dd770bee4f5 --- /dev/null +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileExpoView.kt @@ -0,0 +1,132 @@ +package expo.modules.clerk + +import android.content.Context +import android.util.Log +import android.widget.FrameLayout +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Recomposer +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.AndroidUiDispatcher +import androidx.compose.ui.platform.ComposeView +import androidx.lifecycle.compose.LocalLifecycleOwner +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.setViewTreeLifecycleOwner +import androidx.lifecycle.setViewTreeViewModelStoreOwner +import androidx.lifecycle.viewmodel.compose.LocalViewModelStoreOwner +import androidx.savedstate.compose.LocalSavedStateRegistryOwner +import androidx.savedstate.setViewTreeSavedStateRegistryOwner +import com.clerk.api.Clerk +import com.clerk.ui.userprofile.UserProfileView +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReactContext +import com.facebook.react.uimanager.events.RCTEventEmitter +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +private const val TAG = "ClerkUserProfileExpoView" + +class ClerkUserProfileNativeView(context: Context) : FrameLayout(context) { + var isDismissable: Boolean = true + + private val activity = ClerkAuthNativeView.findActivity(context) + + private var recomposer: Recomposer? = null + private var recomposerJob: kotlinx.coroutines.Job? = null + + private val composeView = ComposeView(context).also { view -> + activity?.let { act -> + view.setViewTreeLifecycleOwner(act) + view.setViewTreeViewModelStoreOwner(act) + view.setViewTreeSavedStateRegistryOwner(act) + + val recomposerContext = AndroidUiDispatcher.Main + val newRecomposer = Recomposer(recomposerContext) + recomposer = newRecomposer + view.setParentCompositionContext(newRecomposer) + val scope = CoroutineScope(recomposerContext + kotlinx.coroutines.SupervisorJob()) + recomposerJob = scope.coroutineContext[kotlinx.coroutines.Job] + scope.launch { + newRecomposer.runRecomposeAndApplyChanges() + } + } + addView(view, LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)) + } + + override fun onDetachedFromWindow() { + recomposer?.cancel() + recomposerJob?.cancel() + super.onDetachedFromWindow() + } + + fun setupView() { + Log.d(TAG, "setupView - isDismissable: $isDismissable") + + composeView.setContent { + val session by Clerk.sessionFlow.collectAsStateWithLifecycle() + + var hadSession by remember { mutableStateOf(Clerk.session != null) } + + LaunchedEffect(session) { + if (hadSession && session == null) { + Log.d(TAG, "Sign-out detected") + sendEvent("signedOut", emptyMap()) + } + if (session != null) { + hadSession = true + } + } + + val content = @androidx.compose.runtime.Composable { + MaterialTheme { + Surface( + modifier = Modifier.fillMaxSize(), + color = MaterialTheme.colorScheme.background + ) { + UserProfileView( + clerkTheme = Clerk.customTheme, + onDismiss = { + Log.d(TAG, "Profile dismissed") + sendEvent("dismissed", emptyMap()) + } + ) + } + } + } + + if (activity != null) { + CompositionLocalProvider( + LocalViewModelStoreOwner provides activity, + LocalLifecycleOwner provides activity, + LocalSavedStateRegistryOwner provides activity, + ) { + content() + } + } else { + content() + } + } + } + + private fun sendEvent(type: String, data: Map) { + val reactContext = context as? ReactContext ?: return + val eventData = Arguments.createMap().apply { + putString("type", type) + val jsonString = try { + org.json.JSONObject(data).toString() + } catch (e: Exception) { + "{}" + } + putString("data", jsonString) + } + reactContext.getJSModule(RCTEventEmitter::class.java) + .receiveEvent(id, "onProfileEvent", eventData) + } +} diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewManager.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewManager.kt new file mode 100644 index 00000000000..bc5a338271e --- /dev/null +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkUserProfileViewManager.kt @@ -0,0 +1,32 @@ +package expo.modules.clerk + +import com.facebook.react.common.MapBuilder +import com.facebook.react.uimanager.SimpleViewManager +import com.facebook.react.uimanager.ThemedReactContext +import com.facebook.react.uimanager.annotations.ReactProp +import com.facebook.react.viewmanagers.ClerkUserProfileViewManagerInterface + +class ClerkUserProfileViewManager : SimpleViewManager(), + ClerkUserProfileViewManagerInterface { + + override fun getName(): String = "ClerkUserProfileView" + + override fun createViewInstance(reactContext: ThemedReactContext): ClerkUserProfileNativeView { + return ClerkUserProfileNativeView(reactContext) + } + + @ReactProp(name = "isDismissable") + override fun setIsDismissable(view: ClerkUserProfileNativeView, isDismissable: Boolean) { + view.isDismissable = isDismissable + view.setupView() + } + + override fun getExportedCustomBubblingEventTypeConstants(): MutableMap? { + return MapBuilder.builder() + .put("onProfileEvent", MapBuilder.of( + "phasedRegistrationNames", + MapBuilder.of("bubbled", "onProfileEvent") + )) + .build() as MutableMap + } +} diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactory.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactory.kt new file mode 100644 index 00000000000..e77ad21ddf0 --- /dev/null +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactory.kt @@ -0,0 +1,102 @@ +package expo.modules.clerk + +import android.content.Context +import android.content.Intent +import com.clerk.api.Clerk +import com.clerk.api.network.serialization.ClerkResult +import kotlinx.coroutines.flow.first + +/** + * Implementation of ClerkViewFactoryInterface. + * Provides Clerk SDK operations and creates intents for auth/profile activities. + */ +class ClerkViewFactory : ClerkViewFactoryInterface { + + // Store the publishable key for later use + private var storedPublishableKey: String? = null + private var storedContext: Context? = null + + override suspend fun configure(context: Context, publishableKey: String) { + println("[ClerkViewFactory] Configuring Clerk with publishable key: ${publishableKey.take(20)}...") + + // Store for later use + storedPublishableKey = publishableKey + storedContext = context.applicationContext + + // Initialize Clerk if not already initialized + if (!Clerk.isInitialized.value) { + Clerk.initialize(context.applicationContext, publishableKey) + + // Wait for initialization to complete + Clerk.isInitialized.first { it } + println("[ClerkViewFactory] Clerk initialized successfully") + } else { + println("[ClerkViewFactory] Clerk already initialized") + } + } + + override fun createAuthIntent(context: Context, mode: String, dismissable: Boolean): Intent { + return Intent(context, ClerkAuthActivity::class.java).apply { + putExtra(ClerkExpoModule.EXTRA_MODE, mode) + putExtra(ClerkExpoModule.EXTRA_DISMISSABLE, dismissable) + storedPublishableKey?.let { putExtra(ClerkExpoModule.EXTRA_PUBLISHABLE_KEY, it) } + } + } + + override fun createUserProfileIntent(context: Context, dismissable: Boolean): Intent { + return Intent(context, ClerkUserProfileActivity::class.java).apply { + putExtra(ClerkExpoModule.EXTRA_DISMISSABLE, dismissable) + storedPublishableKey?.let { putExtra(ClerkExpoModule.EXTRA_PUBLISHABLE_KEY, it) } + } + } + + override suspend fun getSession(): Map? { + val session = Clerk.session ?: return null + val user = Clerk.user ?: return null + + return mapOf( + "sessionId" to session.id, + "userId" to user.id, + "user" to mapOf( + "id" to user.id, + "firstName" to user.firstName, + "lastName" to user.lastName, + "fullName" to "${user.firstName ?: ""} ${user.lastName ?: ""}".trim().ifEmpty { null }, + "username" to user.username, + "imageUrl" to user.imageUrl, + "primaryEmailAddress" to user.primaryEmailAddress?.emailAddress, + "primaryPhoneNumber" to user.primaryPhoneNumber?.phoneNumber, + "createdAt" to user.createdAt, + "updatedAt" to user.updatedAt, + ) + ) + } + + override suspend fun signOut() { + val result = Clerk.auth.signOut() + when (result) { + is ClerkResult.Success -> { + println("[ClerkViewFactory] Sign out successful") + } + is ClerkResult.Failure -> { + println("[ClerkViewFactory] Sign out failed: ${result.error}") + throw Exception("Sign out failed: ${result.error}") + } + } + } + + override fun isInitialized(): Boolean { + return Clerk.isInitialized.value + } + + companion object { + /** + * Initialize the ClerkViewFactory and register it globally. + * Call this from your Application.onCreate() or MainActivity.onCreate() + */ + fun initialize() { + ClerkViewFactoryRegistry.factory = ClerkViewFactory() + println("[ClerkViewFactory] Factory registered") + } + } +} diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactoryInterface.kt b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactoryInterface.kt new file mode 100644 index 00000000000..7b82bd1ec20 --- /dev/null +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkViewFactoryInterface.kt @@ -0,0 +1,52 @@ +package expo.modules.clerk + +import android.content.Context +import android.content.Intent + +/** + * Interface for providing Clerk views and SDK operations. + * This mirrors the iOS ClerkViewFactoryProtocol pattern. + */ +interface ClerkViewFactoryInterface { + /** + * Configure the Clerk SDK with the publishable key. + */ + suspend fun configure(context: Context, publishableKey: String) + + /** + * Create an Intent to launch the authentication activity. + * @param mode The auth mode: "signIn", "signUp", or "signInOrUp" + * @param dismissable Whether the user can dismiss the modal + */ + fun createAuthIntent(context: Context, mode: String, dismissable: Boolean): Intent + + /** + * Create an Intent to launch the user profile activity. + * @param dismissable Whether the user can dismiss the modal + */ + fun createUserProfileIntent(context: Context, dismissable: Boolean): Intent + + /** + * Get the current session data as a Map for JS. + * Returns null if no session is active. + */ + suspend fun getSession(): Map? + + /** + * Sign out the current user. + */ + suspend fun signOut() + + /** + * Check if the SDK is initialized. + */ + fun isInitialized(): Boolean +} + +/** + * Global registry for the Clerk view factory. + * Set by the app target at startup (similar to iOS pattern). + */ +object ClerkViewFactoryRegistry { + var factory: ClerkViewFactoryInterface? = null +} diff --git a/packages/expo/android/src/main/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModule.kt b/packages/expo/android/src/main/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModule.kt index 3234fea2214..54183ce5552 100644 --- a/packages/expo/android/src/main/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModule.kt +++ b/packages/expo/android/src/main/java/expo/modules/clerk/googlesignin/ClerkGoogleSignInModule.kt @@ -9,225 +9,209 @@ import androidx.credentials.GetCredentialResponse import androidx.credentials.exceptions.GetCredentialCancellationException import androidx.credentials.exceptions.GetCredentialException import androidx.credentials.exceptions.NoCredentialException +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactMethod +import expo.modules.clerk.NativeClerkGoogleSignInSpec +import com.facebook.react.bridge.ReadableMap +import com.facebook.react.bridge.WritableNativeMap import com.google.android.libraries.identity.googleid.GetGoogleIdOption import com.google.android.libraries.identity.googleid.GetSignInWithGoogleOption import com.google.android.libraries.identity.googleid.GoogleIdTokenCredential import com.google.android.libraries.identity.googleid.GoogleIdTokenParsingException -import expo.modules.kotlin.Promise -import expo.modules.kotlin.exception.CodedException -import expo.modules.kotlin.modules.Module -import expo.modules.kotlin.modules.ModuleDefinition -import expo.modules.kotlin.records.Field -import expo.modules.kotlin.records.Record import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -// Configuration parameters -class ConfigureParams : Record { - @Field - val webClientId: String = "" +class ClerkGoogleSignInModule(reactContext: ReactApplicationContext) : + NativeClerkGoogleSignInSpec(reactContext) { - @Field - val hostedDomain: String? = null - - @Field - val autoSelectEnabled: Boolean? = null -} - -// Sign-in parameters -class SignInParams : Record { - @Field - val nonce: String? = null - - @Field - val filterByAuthorizedAccounts: Boolean? = null -} - -// Create account parameters -class CreateAccountParams : Record { - @Field - val nonce: String? = null -} - -// Explicit sign-in parameters -class ExplicitSignInParams : Record { - @Field - val nonce: String? = null -} - -// Custom exceptions -class GoogleSignInCancelledException : CodedException("SIGN_IN_CANCELLED", "User cancelled the sign-in flow", null) -class GoogleSignInNoCredentialException : CodedException("NO_SAVED_CREDENTIAL_FOUND", "No saved credential found", null) -class GoogleSignInException(message: String) : CodedException("GOOGLE_SIGN_IN_ERROR", message, null) -class GoogleSignInNotConfiguredException : CodedException("NOT_CONFIGURED", "Google Sign-In is not configured. Call configure() first.", null) -class GoogleSignInActivityUnavailableException : CodedException("E_ACTIVITY_UNAVAILABLE", "Activity is not available", null) - -class ClerkGoogleSignInModule : Module() { private var webClientId: String? = null private var hostedDomain: String? = null private var autoSelectEnabled: Boolean = false private val mainScope = CoroutineScope(Dispatchers.Main) - private val context: Context - get() = requireNotNull(appContext.reactContext) - private val credentialManager: CredentialManager - get() = CredentialManager.create(context) + get() = CredentialManager.create(reactApplicationContext) - override fun definition() = ModuleDefinition { - Name("ClerkGoogleSignIn") + override fun getName(): String = "ClerkGoogleSignIn" - // Configure the module - Function("configure") { params: ConfigureParams -> - webClientId = params.webClientId - hostedDomain = params.hostedDomain - autoSelectEnabled = params.autoSelectEnabled ?: false - } + // MARK: - configure - // Sign in - attempts automatic sign-in with saved credentials - AsyncFunction("signIn") { params: SignInParams?, promise: Promise -> - val clientId = webClientId ?: run { - promise.reject(GoogleSignInNotConfiguredException()) - return@AsyncFunction - } + @ReactMethod + override fun configure(params: ReadableMap) { + webClientId = if (params.hasKey("webClientId")) params.getString("webClientId") else null + hostedDomain = if (params.hasKey("hostedDomain")) params.getString("hostedDomain") else null + autoSelectEnabled = if (params.hasKey("autoSelectEnabled")) params.getBoolean("autoSelectEnabled") else false + } - val activity = appContext.currentActivity ?: run { - promise.reject(GoogleSignInActivityUnavailableException()) - return@AsyncFunction - } + // MARK: - signIn - mainScope.launch { - try { - val googleIdOption = GetGoogleIdOption.Builder() - .setFilterByAuthorizedAccounts(params?.filterByAuthorizedAccounts ?: true) - .setServerClientId(clientId) - .setAutoSelectEnabled(autoSelectEnabled) - .apply { - params?.nonce?.let { setNonce(it) } - } - .build() - - val request = GetCredentialRequest.Builder() - .addCredentialOption(googleIdOption) - .build() - - val result = credentialManager.getCredential( - request = request, - context = activity - ) - - handleSignInResult(result, promise) - } catch (e: GetCredentialCancellationException) { - promise.reject(GoogleSignInCancelledException()) - } catch (e: NoCredentialException) { - promise.reject(GoogleSignInNoCredentialException()) - } catch (e: GetCredentialException) { - promise.reject(GoogleSignInException(e.message ?: "Unknown error")) - } catch (e: Exception) { - promise.reject(GoogleSignInException(e.message ?: "Unknown error")) + @ReactMethod + override fun signIn(params: ReadableMap?, promise: Promise) { + val clientId = webClientId ?: run { + promise.reject("NOT_CONFIGURED", "Google Sign-In is not configured. Call configure() first.") + return + } + + val activity = getCurrentActivity() ?: run { + promise.reject("E_ACTIVITY_UNAVAILABLE", "Activity is not available") + return + } + + mainScope.launch { + try { + val filterByAuthorized = params?.let { + if (it.hasKey("filterByAuthorizedAccounts")) it.getBoolean("filterByAuthorizedAccounts") else true + } ?: true + val nonce = params?.let { + if (it.hasKey("nonce")) it.getString("nonce") else null } + + val googleIdOption = GetGoogleIdOption.Builder() + .setFilterByAuthorizedAccounts(filterByAuthorized) + .setServerClientId(clientId) + .setAutoSelectEnabled(autoSelectEnabled) + .apply { + nonce?.let { setNonce(it) } + } + .build() + + val request = GetCredentialRequest.Builder() + .addCredentialOption(googleIdOption) + .build() + + val result = credentialManager.getCredential( + request = request, + context = activity + ) + + handleSignInResult(result, promise) + } catch (e: GetCredentialCancellationException) { + promise.reject("SIGN_IN_CANCELLED", "User cancelled the sign-in flow", e) + } catch (e: NoCredentialException) { + promise.reject("NO_SAVED_CREDENTIAL_FOUND", "No saved credential found", e) + } catch (e: GetCredentialException) { + promise.reject("GOOGLE_SIGN_IN_ERROR", e.message ?: "Unknown error", e) + } catch (e: Exception) { + promise.reject("GOOGLE_SIGN_IN_ERROR", e.message ?: "Unknown error", e) } } + } - // Create account - shows account creation UI - AsyncFunction("createAccount") { params: CreateAccountParams?, promise: Promise -> - val clientId = webClientId ?: run { - promise.reject(GoogleSignInNotConfiguredException()) - return@AsyncFunction - } + // MARK: - createAccount - val activity = appContext.currentActivity ?: run { - promise.reject(GoogleSignInActivityUnavailableException()) - return@AsyncFunction - } + @ReactMethod + override fun createAccount(params: ReadableMap?, promise: Promise) { + val clientId = webClientId ?: run { + promise.reject("NOT_CONFIGURED", "Google Sign-In is not configured. Call configure() first.") + return + } - mainScope.launch { - try { - val googleIdOption = GetGoogleIdOption.Builder() - .setFilterByAuthorizedAccounts(false) // Show all accounts for creation - .setServerClientId(clientId) - .apply { - params?.nonce?.let { setNonce(it) } - } - .build() - - val request = GetCredentialRequest.Builder() - .addCredentialOption(googleIdOption) - .build() - - val result = credentialManager.getCredential( - request = request, - context = activity - ) - - handleSignInResult(result, promise) - } catch (e: GetCredentialCancellationException) { - promise.reject(GoogleSignInCancelledException()) - } catch (e: NoCredentialException) { - promise.reject(GoogleSignInNoCredentialException()) - } catch (e: GetCredentialException) { - promise.reject(GoogleSignInException(e.message ?: "Unknown error")) - } catch (e: Exception) { - promise.reject(GoogleSignInException(e.message ?: "Unknown error")) + val activity = getCurrentActivity() ?: run { + promise.reject("E_ACTIVITY_UNAVAILABLE", "Activity is not available") + return + } + + mainScope.launch { + try { + val nonce = params?.let { + if (it.hasKey("nonce")) it.getString("nonce") else null } + + val googleIdOption = GetGoogleIdOption.Builder() + .setFilterByAuthorizedAccounts(false) // Show all accounts for creation + .setServerClientId(clientId) + .apply { + nonce?.let { setNonce(it) } + } + .build() + + val request = GetCredentialRequest.Builder() + .addCredentialOption(googleIdOption) + .build() + + val result = credentialManager.getCredential( + request = request, + context = activity + ) + + handleSignInResult(result, promise) + } catch (e: GetCredentialCancellationException) { + promise.reject("SIGN_IN_CANCELLED", "User cancelled the sign-in flow", e) + } catch (e: NoCredentialException) { + promise.reject("NO_SAVED_CREDENTIAL_FOUND", "No saved credential found", e) + } catch (e: GetCredentialException) { + promise.reject("GOOGLE_SIGN_IN_ERROR", e.message ?: "Unknown error", e) + } catch (e: Exception) { + promise.reject("GOOGLE_SIGN_IN_ERROR", e.message ?: "Unknown error", e) } } + } - // Explicit sign-in - uses Sign In With Google button flow - AsyncFunction("presentExplicitSignIn") { params: ExplicitSignInParams?, promise: Promise -> - val clientId = webClientId ?: run { - promise.reject(GoogleSignInNotConfiguredException()) - return@AsyncFunction - } + // MARK: - presentExplicitSignIn - val activity = appContext.currentActivity ?: run { - promise.reject(GoogleSignInActivityUnavailableException()) - return@AsyncFunction - } + @ReactMethod + override fun presentExplicitSignIn(params: ReadableMap?, promise: Promise) { + val clientId = webClientId ?: run { + promise.reject("NOT_CONFIGURED", "Google Sign-In is not configured. Call configure() first.") + return + } - mainScope.launch { - try { - val signInWithGoogleOption = GetSignInWithGoogleOption.Builder(clientId) - .apply { - params?.nonce?.let { setNonce(it) } - hostedDomain?.let { setHostedDomainFilter(it) } - } - .build() - - val request = GetCredentialRequest.Builder() - .addCredentialOption(signInWithGoogleOption) - .build() - - val result = credentialManager.getCredential( - request = request, - context = activity - ) - - handleSignInResult(result, promise) - } catch (e: GetCredentialCancellationException) { - promise.reject(GoogleSignInCancelledException()) - } catch (e: GetCredentialException) { - promise.reject(GoogleSignInException(e.message ?: "Unknown error")) - } catch (e: Exception) { - promise.reject(GoogleSignInException(e.message ?: "Unknown error")) + val activity = getCurrentActivity() ?: run { + promise.reject("E_ACTIVITY_UNAVAILABLE", "Activity is not available") + return + } + + mainScope.launch { + try { + val nonce = params?.let { + if (it.hasKey("nonce")) it.getString("nonce") else null } + + val signInWithGoogleOption = GetSignInWithGoogleOption.Builder(clientId) + .apply { + nonce?.let { setNonce(it) } + hostedDomain?.let { setHostedDomainFilter(it) } + } + .build() + + val request = GetCredentialRequest.Builder() + .addCredentialOption(signInWithGoogleOption) + .build() + + val result = credentialManager.getCredential( + request = request, + context = activity + ) + + handleSignInResult(result, promise) + } catch (e: GetCredentialCancellationException) { + promise.reject("SIGN_IN_CANCELLED", "User cancelled the sign-in flow", e) + } catch (e: GetCredentialException) { + promise.reject("GOOGLE_SIGN_IN_ERROR", e.message ?: "Unknown error", e) + } catch (e: Exception) { + promise.reject("GOOGLE_SIGN_IN_ERROR", e.message ?: "Unknown error", e) } } + } - // Sign out - clears credential state - AsyncFunction("signOut") { promise: Promise -> - mainScope.launch { - try { - credentialManager.clearCredentialState(ClearCredentialStateRequest()) - promise.resolve(null) - } catch (e: Exception) { - promise.reject(GoogleSignInException(e.message ?: "Failed to sign out")) - } + // MARK: - signOut + + @ReactMethod + override fun signOut(promise: Promise) { + mainScope.launch { + try { + credentialManager.clearCredentialState(ClearCredentialStateRequest()) + promise.resolve(null) + } catch (e: Exception) { + promise.reject("GOOGLE_SIGN_IN_ERROR", e.message ?: "Failed to sign out", e) } } } + // MARK: - Helpers + private fun handleSignInResult(result: GetCredentialResponse, promise: Promise) { when (val credential = result.credential) { is CustomCredential -> { @@ -235,29 +219,35 @@ class ClerkGoogleSignInModule : Module() { try { val googleIdTokenCredential = GoogleIdTokenCredential.createFrom(credential.data) - promise.resolve(mapOf( - "type" to "success", - "data" to mapOf( - "idToken" to googleIdTokenCredential.idToken, - "user" to mapOf( - "id" to googleIdTokenCredential.id, - "email" to googleIdTokenCredential.id, - "name" to googleIdTokenCredential.displayName, - "givenName" to googleIdTokenCredential.givenName, - "familyName" to googleIdTokenCredential.familyName, - "photo" to googleIdTokenCredential.profilePictureUri?.toString() - ) - ) - )) + val userMap = WritableNativeMap().apply { + putString("id", googleIdTokenCredential.id) + putString("email", googleIdTokenCredential.id) + putString("name", googleIdTokenCredential.displayName) + putString("givenName", googleIdTokenCredential.givenName) + putString("familyName", googleIdTokenCredential.familyName) + putString("photo", googleIdTokenCredential.profilePictureUri?.toString()) + } + + val dataMap = WritableNativeMap().apply { + putString("idToken", googleIdTokenCredential.idToken) + putMap("user", userMap) + } + + val responseMap = WritableNativeMap().apply { + putString("type", "success") + putMap("data", dataMap) + } + + promise.resolve(responseMap) } catch (e: GoogleIdTokenParsingException) { - promise.reject(GoogleSignInException("Failed to parse Google ID token: ${e.message}")) + promise.reject("GOOGLE_SIGN_IN_ERROR", "Failed to parse Google ID token: ${e.message}", e) } } else { - promise.reject(GoogleSignInException("Unexpected credential type: ${credential.type}")) + promise.reject("GOOGLE_SIGN_IN_ERROR", "Unexpected credential type: ${credential.type}") } } else -> { - promise.reject(GoogleSignInException("Unexpected credential type")) + promise.reject("GOOGLE_SIGN_IN_ERROR", "Unexpected credential type") } } } diff --git a/packages/expo/app.plugin.d.ts b/packages/expo/app.plugin.d.ts new file mode 100644 index 00000000000..82abd6c984b --- /dev/null +++ b/packages/expo/app.plugin.d.ts @@ -0,0 +1,6 @@ +export = withClerkExpo; +/** + * Combined Clerk Expo plugin + */ +declare function withClerkExpo(config: any): any; +//# sourceMappingURL=app.plugin.d.ts.map diff --git a/packages/expo/app.plugin.d.ts.map b/packages/expo/app.plugin.d.ts.map new file mode 100644 index 00000000000..99752a11459 --- /dev/null +++ b/packages/expo/app.plugin.d.ts.map @@ -0,0 +1 @@ +{"version":3,"file":"app.plugin.d.ts","sourceRoot":"","sources":["app.plugin.js"],"names":[],"mappings":";AA0gBA;;GAEG;AACH,iDAKC"} \ No newline at end of file diff --git a/packages/expo/app.plugin.js b/packages/expo/app.plugin.js index 65835131de7..acb6d5c06b9 100644 --- a/packages/expo/app.plugin.js +++ b/packages/expo/app.plugin.js @@ -1 +1,563 @@ -module.exports = require('./dist/plugin/withClerkExpo'); +/** + * Expo config plugin for @clerk/clerk-expo + * Automatically configures iOS and Android to work with Clerk native components + * + * When this plugin is used: + * 1. iOS is configured with Swift Package Manager dependency for clerk-ios + * 2. Android is configured with packaging exclusions for dependencies + * + * Native modules are registered via react-native.config.js and standard + * React Native autolinking (RCTViewManager / ReactPackage). + */ +const { withXcodeProject, withDangerousMod, withInfoPlist, withAppBuildGradle } = require('@expo/config-plugins'); +const path = require('path'); +const fs = require('fs'); + +const CLERK_IOS_REPO = 'https://github.com/clerk/clerk-ios.git'; +const CLERK_IOS_VERSION = '1.0.0'; + +const CLERK_MIN_IOS_VERSION = '17.0'; + +const withClerkIOS = config => { + console.log('✅ Clerk iOS plugin loaded'); + + // IMPORTANT: Set iOS deployment target in Podfile.properties.json BEFORE pod install + // This ensures ClerkExpo pod gets installed (it requires iOS 17.0) + config = withDangerousMod(config, [ + 'ios', + async config => { + const podfilePropertiesPath = path.join(config.modRequest.platformProjectRoot, 'Podfile.properties.json'); + + let properties = {}; + if (fs.existsSync(podfilePropertiesPath)) { + try { + properties = JSON.parse(fs.readFileSync(podfilePropertiesPath, 'utf8')); + } catch { + // If file exists but is invalid JSON, start fresh + } + } + + // Set the iOS deployment target + if ( + !properties['ios.deploymentTarget'] || + parseFloat(properties['ios.deploymentTarget']) < parseFloat(CLERK_MIN_IOS_VERSION) + ) { + properties['ios.deploymentTarget'] = CLERK_MIN_IOS_VERSION; + fs.writeFileSync(podfilePropertiesPath, JSON.stringify(properties, null, 2) + '\n'); + console.log(`✅ Set ios.deploymentTarget to ${CLERK_MIN_IOS_VERSION} in Podfile.properties.json`); + } + + return config; + }, + ]); + + // First update the iOS deployment target to 17.0 (required by Clerk iOS SDK) + config = withXcodeProject(config, config => { + const xcodeProject = config.modResults; + + try { + // Update deployment target in all build configurations + const buildConfigs = xcodeProject.hash.project.objects.XCBuildConfiguration || {}; + + for (const [uuid, buildConfig] of Object.entries(buildConfigs)) { + if (buildConfig && buildConfig.buildSettings) { + const currentTarget = buildConfig.buildSettings.IPHONEOS_DEPLOYMENT_TARGET; + if (currentTarget && parseFloat(currentTarget) < parseFloat(CLERK_MIN_IOS_VERSION)) { + buildConfig.buildSettings.IPHONEOS_DEPLOYMENT_TARGET = CLERK_MIN_IOS_VERSION; + } + } + } + + console.log(`✅ Updated iOS deployment target to ${CLERK_MIN_IOS_VERSION}`); + } catch (error) { + console.error('❌ Error updating deployment target:', error.message); + } + + return config; + }); + + // Then add the Swift Package dependency + config = withXcodeProject(config, config => { + const xcodeProject = config.modResults; + + try { + // Get the main app target + const targets = xcodeProject.getFirstTarget(); + if (!targets) { + console.warn('⚠️ Could not find main target in Xcode project'); + return config; + } + + const targetUuid = targets.uuid; + const targetName = targets.name; + + // Add Swift Package reference to the project + const packageUuid = xcodeProject.generateUuid(); + const packageName = 'clerk-ios'; + + // Add package reference to XCRemoteSwiftPackageReference section + if (!xcodeProject.hash.project.objects.XCRemoteSwiftPackageReference) { + xcodeProject.hash.project.objects.XCRemoteSwiftPackageReference = {}; + } + + xcodeProject.hash.project.objects.XCRemoteSwiftPackageReference[packageUuid] = { + isa: 'XCRemoteSwiftPackageReference', + repositoryURL: CLERK_IOS_REPO, + requirement: { + kind: 'upToNextMajorVersion', + minimumVersion: CLERK_IOS_VERSION, + }, + }; + + // Add package product dependencies (ClerkKit + ClerkKitUI) + const productUuidKit = xcodeProject.generateUuid(); + const productUuidKitUI = xcodeProject.generateUuid(); + if (!xcodeProject.hash.project.objects.XCSwiftPackageProductDependency) { + xcodeProject.hash.project.objects.XCSwiftPackageProductDependency = {}; + } + + xcodeProject.hash.project.objects.XCSwiftPackageProductDependency[productUuidKit] = { + isa: 'XCSwiftPackageProductDependency', + package: packageUuid, + productName: 'ClerkKit', + }; + + xcodeProject.hash.project.objects.XCSwiftPackageProductDependency[productUuidKitUI] = { + isa: 'XCSwiftPackageProductDependency', + package: packageUuid, + productName: 'ClerkKitUI', + }; + + // Add package to project's package references + const projectSection = xcodeProject.hash.project.objects.PBXProject; + const projectUuid = Object.keys(projectSection)[0]; + const project = projectSection[projectUuid]; + + if (!project.packageReferences) { + project.packageReferences = []; + } + + // Check if package is already added + const alreadyAdded = project.packageReferences.some(ref => { + const refObj = xcodeProject.hash.project.objects.XCRemoteSwiftPackageReference[ref.value]; + return refObj && refObj.repositoryURL === CLERK_IOS_REPO; + }); + + if (!alreadyAdded) { + project.packageReferences.push({ + value: packageUuid, + comment: packageName, + }); + } + + // Add package products to main app target + const nativeTarget = xcodeProject.hash.project.objects.PBXNativeTarget[targetUuid]; + if (!nativeTarget.packageProductDependencies) { + nativeTarget.packageProductDependencies = []; + } + + const kitAlreadyAdded = nativeTarget.packageProductDependencies.some(dep => dep.value === productUuidKit); + if (!kitAlreadyAdded) { + nativeTarget.packageProductDependencies.push({ + value: productUuidKit, + comment: 'ClerkKit', + }); + } + + const kitUIAlreadyAdded = nativeTarget.packageProductDependencies.some(dep => dep.value === productUuidKitUI); + if (!kitUIAlreadyAdded) { + nativeTarget.packageProductDependencies.push({ + value: productUuidKitUI, + comment: 'ClerkKitUI', + }); + } + + // Also add packages to ClerkExpo pod target if it exists + const allTargets = xcodeProject.hash.project.objects.PBXNativeTarget; + for (const [uuid, target] of Object.entries(allTargets)) { + if (target && target.name === 'ClerkExpo') { + if (!target.packageProductDependencies) { + target.packageProductDependencies = []; + } + + const podKitAdded = target.packageProductDependencies.some(dep => dep.value === productUuidKit); + if (!podKitAdded) { + target.packageProductDependencies.push({ + value: productUuidKit, + comment: 'ClerkKit', + }); + } + + const podKitUIAdded = target.packageProductDependencies.some(dep => dep.value === productUuidKitUI); + if (!podKitUIAdded) { + target.packageProductDependencies.push({ + value: productUuidKitUI, + comment: 'ClerkKitUI', + }); + } + + console.log(`✅ Added ClerkKit and ClerkKitUI packages to ClerkExpo pod target`); + } + } + + console.log(`✅ Added clerk-ios Swift package dependency (${CLERK_IOS_VERSION})`); + } catch (error) { + console.error('❌ Error adding clerk-ios package:', error.message); + } + + return config; + }); + + // Inject ClerkViewFactory.register() call into AppDelegate.swift + config = withDangerousMod(config, [ + 'ios', + async config => { + const platformProjectRoot = config.modRequest.platformProjectRoot; + const projectName = config.modRequest.projectName; + const appDelegatePath = path.join(platformProjectRoot, projectName, 'AppDelegate.swift'); + + if (fs.existsSync(appDelegatePath)) { + let contents = fs.readFileSync(appDelegatePath, 'utf8'); + + // Check if already added + if (!contents.includes('ClerkViewFactory.register()')) { + // Find the didFinishLaunchingWithOptions method and add the registration call + // Look for the return statement in didFinishLaunching + const pattern = /(func application\s*\([^)]*didFinishLaunchingWithOptions[^)]*\)[^{]*\{)/; + const match = contents.match(pattern); + + if (match) { + // Insert after the opening brace of didFinishLaunching + const insertPoint = match.index + match[0].length; + const registrationCode = '\n // Register Clerk native views\n ClerkViewFactory.register()\n'; + contents = contents.slice(0, insertPoint) + registrationCode + contents.slice(insertPoint); + fs.writeFileSync(appDelegatePath, contents); + console.log('✅ Added ClerkViewFactory.register() to AppDelegate.swift'); + } else { + console.warn('⚠️ Could not find didFinishLaunchingWithOptions in AppDelegate.swift'); + } + } + } + + return config; + }, + ]); + + // Then inject ClerkViewFactory.swift into the app target + // This is required because the file uses `import ClerkKit` which is only available + // via SPM in the app target (CocoaPods targets can't see SPM packages) + config = withXcodeProject(config, config => { + try { + const platformProjectRoot = config.modRequest.platformProjectRoot; + const projectName = config.modRequest.projectName; + const iosProjectPath = path.join(platformProjectRoot, projectName); + + // Find the ClerkViewFactory.swift source file + // Check multiple possible locations in order of preference + let sourceFile; + const possiblePaths = [ + // Standard node_modules (npm, yarn) + path.join(config.modRequest.projectRoot, 'node_modules', '@clerk', 'expo', 'ios', 'ClerkViewFactory.swift'), + // pnpm hoisted node_modules + path.join( + config.modRequest.projectRoot, + '..', + 'node_modules', + '@clerk', + 'expo', + 'ios', + 'ClerkViewFactory.swift', + ), + // Monorepo workspace (pnpm workspace) + path.join( + config.modRequest.projectRoot, + '..', + 'javascript', + 'packages', + 'expo', + 'ios', + 'ClerkViewFactory.swift', + ), + // Alternative monorepo structure + path.join(config.modRequest.projectRoot, '..', 'packages', 'expo', 'ios', 'ClerkViewFactory.swift'), + ]; + + for (const possiblePath of possiblePaths) { + if (fs.existsSync(possiblePath)) { + sourceFile = possiblePath; + break; + } + } + + if (sourceFile && fs.existsSync(sourceFile)) { + // ALWAYS copy the file to ensure we have the latest version + const targetFile = path.join(iosProjectPath, 'ClerkViewFactory.swift'); + fs.copyFileSync(sourceFile, targetFile); + console.log('✅ Copied ClerkViewFactory.swift to app target'); + + // Add the file to the Xcode project manually + const xcodeProject = config.modResults; + const relativePath = `${projectName}/ClerkViewFactory.swift`; + const fileName = 'ClerkViewFactory.swift'; + + try { + // Get the main target + const target = xcodeProject.getFirstTarget(); + if (!target || !target.uuid) { + console.warn('⚠️ Could not find target UUID, file copied but not added to project'); + return config; + } + + const targetUuid = target.uuid; + + // Check if file is already in the Xcode project references + const fileReferences = xcodeProject.hash.project.objects.PBXFileReference || {}; + const alreadyExists = Object.values(fileReferences).some(ref => ref && ref.path === fileName); + + if (alreadyExists) { + // File is already in project, but we still copied the latest version + console.log('✅ ClerkViewFactory.swift updated in app target'); + return config; + } + + // 1. Create PBXFileReference + const fileRefUuid = xcodeProject.generateUuid(); + if (!xcodeProject.hash.project.objects.PBXFileReference) { + xcodeProject.hash.project.objects.PBXFileReference = {}; + } + + xcodeProject.hash.project.objects.PBXFileReference[fileRefUuid] = { + isa: 'PBXFileReference', + lastKnownFileType: 'sourcecode.swift', + name: fileName, + path: relativePath, // Use full relative path (projectName/ClerkViewFactory.swift) + sourceTree: '""', + }; + + // 2. Create PBXBuildFile + const buildFileUuid = xcodeProject.generateUuid(); + if (!xcodeProject.hash.project.objects.PBXBuildFile) { + xcodeProject.hash.project.objects.PBXBuildFile = {}; + } + + xcodeProject.hash.project.objects.PBXBuildFile[buildFileUuid] = { + isa: 'PBXBuildFile', + fileRef: fileRefUuid, + fileRef_comment: fileName, + }; + + // 3. Add to PBXSourcesBuildPhase + const buildPhases = xcodeProject.hash.project.objects.PBXSourcesBuildPhase || {}; + let sourcesPhaseUuid = null; + + // Find the sources build phase for the main target + const nativeTarget = xcodeProject.hash.project.objects.PBXNativeTarget[targetUuid]; + if (nativeTarget && nativeTarget.buildPhases) { + for (const phase of nativeTarget.buildPhases) { + if (buildPhases[phase.value] && buildPhases[phase.value].isa === 'PBXSourcesBuildPhase') { + sourcesPhaseUuid = phase.value; + break; + } + } + } + + if (sourcesPhaseUuid && buildPhases[sourcesPhaseUuid]) { + if (!buildPhases[sourcesPhaseUuid].files) { + buildPhases[sourcesPhaseUuid].files = []; + } + + buildPhases[sourcesPhaseUuid].files.push({ + value: buildFileUuid, + comment: fileName, + }); + } else { + console.warn('⚠️ Could not find PBXSourcesBuildPhase for target'); + } + + // 4. Add to PBXGroup (main group for the project) + const groups = xcodeProject.hash.project.objects.PBXGroup || {}; + let mainGroupUuid = null; + + // Find the group with the same name as the project + for (const [uuid, group] of Object.entries(groups)) { + if (group && group.name === projectName) { + mainGroupUuid = uuid; + break; + } + } + + if (mainGroupUuid && groups[mainGroupUuid]) { + if (!groups[mainGroupUuid].children) { + groups[mainGroupUuid].children = []; + } + + // Add file reference to the group + groups[mainGroupUuid].children.push({ + value: fileRefUuid, + comment: fileName, + }); + } else { + console.warn('⚠️ Could not find main PBXGroup for project'); + } + + console.log('✅ Added ClerkViewFactory.swift to Xcode project'); + } catch (addError) { + console.error('❌ Error adding file to Xcode project:', addError.message); + console.error(addError.stack); + } + } else { + console.warn('⚠️ ClerkViewFactory.swift not found, skipping injection'); + } + } catch (error) { + console.error('❌ Error injecting ClerkViewFactory.swift:', error.message); + } + + return config; + }); + + // Inject SPM package resolution into Podfile post_install hook + // This runs synchronously during pod install, ensuring packages are resolved before prebuild completes + config = withDangerousMod(config, [ + 'ios', + async config => { + const platformProjectRoot = config.modRequest.platformProjectRoot; + const projectName = config.modRequest.projectName; + const podfilePath = path.join(platformProjectRoot, 'Podfile'); + + if (fs.existsSync(podfilePath)) { + let podfileContents = fs.readFileSync(podfilePath, 'utf8'); + + // Check if we've already added our resolution code + if (!podfileContents.includes('# Clerk: Resolve SPM packages')) { + // Code to inject into existing post_install block + // Note: We run this AFTER react_native_post_install to ensure the workspace is fully written + const spmResolutionCode = ` + # Clerk: Resolve SPM packages synchronously during pod install + # This ensures packages are downloaded before the user opens Xcode + # We wait until the end of post_install to ensure workspace is fully written + at_exit do + workspace_path = File.join(__dir__, '${projectName}.xcworkspace') + if File.exist?(workspace_path) + puts "" + puts "📦 [Clerk] Resolving Swift Package dependencies..." + puts " This may take a minute on first run..." + # Use backticks to capture output and check exit status + output = \`xcodebuild -resolvePackageDependencies -workspace "#{workspace_path}" -scheme "${projectName}" 2>&1\` + if $?.success? + puts "✅ [Clerk] Swift Package dependencies resolved successfully" + else + puts "⚠️ [Clerk] SPM resolution output:" + puts output.lines.last(10).join + end + puts "" + end + end +`; + + // Insert our code at the beginning of the existing post_install block + if (podfileContents.includes('post_install do |installer|')) { + podfileContents = podfileContents.replace( + /post_install do \|installer\|/, + `post_install do |installer|${spmResolutionCode}`, + ); + fs.writeFileSync(podfilePath, podfileContents); + console.log('✅ Added SPM resolution to Podfile post_install hook'); + } + } + } + + return config; + }, + ]); + + return config; +}; + +/** + * Add packaging exclusions to Android app build.gradle to resolve + * duplicate META-INF file conflicts from clerk-android dependencies. + */ +const withClerkAndroid = config => { + console.log('✅ Clerk Android plugin loaded'); + + return withAppBuildGradle(config, modConfig => { + let buildGradle = modConfig.modResults.contents; + + // Check if exclusion already exists + if (buildGradle.includes('META-INF/versions/9/OSGI-INF/MANIFEST.MF')) { + console.log('✅ Clerk Android packaging exclusions already configured'); + return modConfig; + } + + // Find the existing packagingOptions block and add resources.excludes + const packagingOptionsMatch = buildGradle.match(/packagingOptions\s*\{/); + if (packagingOptionsMatch) { + // Add resources block inside packagingOptions + const resourcesExclude = `packagingOptions { + // Clerk Android SDK: exclude duplicate META-INF files + resources { + excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF'] + }`; + + buildGradle = buildGradle.replace(/packagingOptions\s*\{/, resourcesExclude); + modConfig.modResults.contents = buildGradle; + console.log('✅ Clerk Android packaging exclusions added'); + } else { + console.warn('⚠️ Could not find packagingOptions block in build.gradle'); + } + + return modConfig; + }); +}; + +/** + * Add Google Sign-In URL scheme to Info.plist (from main branch) + */ +const withClerkGoogleSignIn = config => { + const iosUrlScheme = + process.env.EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME || + (config.extra && config.extra.EXPO_PUBLIC_CLERK_GOOGLE_IOS_URL_SCHEME); + + if (!iosUrlScheme) { + return config; + } + + return withInfoPlist(config, modConfig => { + if (!Array.isArray(modConfig.modResults.CFBundleURLTypes)) { + modConfig.modResults.CFBundleURLTypes = []; + } + + const schemeExists = modConfig.modResults.CFBundleURLTypes.some(urlType => + urlType.CFBundleURLSchemes?.includes(iosUrlScheme), + ); + + if (!schemeExists) { + modConfig.modResults.CFBundleURLTypes.push({ + CFBundleURLSchemes: [iosUrlScheme], + }); + console.log(`✅ Added Google Sign-In URL scheme: ${iosUrlScheme}`); + } + + return modConfig; + }); +}; + +/** + * Combined Clerk Expo plugin + * + * When this plugin is configured in app.json/app.config.js: + * 1. iOS gets Swift Package Manager dependency for clerk-ios SDK + * 2. Android gets packaging exclusions for dependency conflicts + * 3. Google Sign-In URL scheme is configured (if env var is set) + * + * Native modules are registered via react-native.config.js and standard + * React Native autolinking (RCTViewManager / ReactPackage). + */ +const withClerkExpo = config => { + config = withClerkIOS(config); + config = withClerkGoogleSignIn(config); + config = withClerkAndroid(config); + return config; +}; + +module.exports = withClerkExpo; diff --git a/packages/expo/expo-module.config.json b/packages/expo/expo-module.config.json index e59f14eef13..876f466b1ad 100644 --- a/packages/expo/expo-module.config.json +++ b/packages/expo/expo-module.config.json @@ -1,9 +1,3 @@ { - "platforms": ["android", "ios"], - "android": { - "modules": ["expo.modules.clerk.googlesignin.ClerkGoogleSignInModule"] - }, - "ios": { - "modules": ["ClerkGoogleSignInModule"] - } + "platforms": ["apple"] } diff --git a/packages/expo/ios/ClerkAuthViewManager.m b/packages/expo/ios/ClerkAuthViewManager.m new file mode 100644 index 00000000000..c5a25dd8a9b --- /dev/null +++ b/packages/expo/ios/ClerkAuthViewManager.m @@ -0,0 +1,9 @@ +#import + +@interface RCT_EXTERN_MODULE(ClerkAuthViewManager, RCTViewManager) + +RCT_EXPORT_VIEW_PROPERTY(mode, NSString) +RCT_EXPORT_VIEW_PROPERTY(isDismissable, NSNumber) +RCT_EXPORT_VIEW_PROPERTY(onAuthEvent, RCTBubblingEventBlock) + +@end diff --git a/packages/expo/ios/ClerkAuthViewManager.swift b/packages/expo/ios/ClerkAuthViewManager.swift new file mode 100644 index 00000000000..0ab9629edba --- /dev/null +++ b/packages/expo/ios/ClerkAuthViewManager.swift @@ -0,0 +1,13 @@ +import React + +@objc(ClerkAuthViewManager) +class ClerkAuthViewManager: RCTViewManager { + + override static func requiresMainQueueSetup() -> Bool { + return true + } + + override func view() -> UIView! { + return ClerkAuthNativeView() + } +} diff --git a/packages/expo/ios/ClerkExpo.podspec b/packages/expo/ios/ClerkExpo.podspec new file mode 100644 index 00000000000..f5b31fb617c --- /dev/null +++ b/packages/expo/ios/ClerkExpo.podspec @@ -0,0 +1,45 @@ +require 'json' + +# Find package.json by following symlinks if necessary +package_json_path = File.join(__dir__, '..', 'package.json') +package_json_path = File.join(File.readlink(__dir__), '..', 'package.json') if File.symlink?(__dir__) + +# Fallback to hardcoded values if package.json is not found +if File.exist?(package_json_path) + package = JSON.parse(File.read(package_json_path)) +else + package = { + 'version' => '0.0.0-FALLBACK', + 'description' => 'Clerk React Native/Expo library', + 'license' => 'MIT', + 'author' => 'Clerk', + 'homepage' => 'https://clerk.com/' + } +end + +Pod::Spec.new do |s| + s.name = 'ClerkExpo' + s.version = package['version'] + s.summary = package['description'] + s.license = package['license'] + s.author = package['author'] + s.homepage = package['homepage'] + s.platforms = { :ios => '17.0' } # Clerk iOS SDK requires iOS 17 + s.swift_version = '5.10' + s.source = { git: 'https://github.com/clerk/javascript' } + s.static_framework = true + + s.pod_target_xcconfig = { + 'DEFINES_MODULE' => 'YES', + 'SWIFT_COMPILATION_MODE' => 'wholemodule' + } + + # Only include the module files in the pod (both Swift and ObjC bridges). + # ClerkViewFactory.swift (with views) is injected into the app target by the config plugin + # because it uses `import ClerkKit` which is only available via SPM in the app target. + s.source_files = "ClerkExpoModule.swift", "ClerkExpoModule.m", + "ClerkAuthViewManager.swift", "ClerkAuthViewManager.m", + "ClerkUserProfileViewManager.swift", "ClerkUserProfileViewManager.m" + + install_modules_dependencies(s) +end diff --git a/packages/expo/ios/ClerkExpoModule.m b/packages/expo/ios/ClerkExpoModule.m new file mode 100644 index 00000000000..febfe003c61 --- /dev/null +++ b/packages/expo/ios/ClerkExpoModule.m @@ -0,0 +1,28 @@ +#import +#import + +@interface RCT_EXTERN_MODULE(ClerkExpo, RCTEventEmitter) + +RCT_EXTERN_METHOD(configure:(NSString *)publishableKey + bearerToken:(NSString *)bearerToken + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(presentAuth:(NSDictionary *)options + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(presentUserProfile:(NSDictionary *)options + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(getSession:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(getClientToken:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(signOut:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) + +@end diff --git a/packages/expo/ios/ClerkExpoModule.swift b/packages/expo/ios/ClerkExpoModule.swift new file mode 100644 index 00000000000..33b8b91e2b8 --- /dev/null +++ b/packages/expo/ios/ClerkExpoModule.swift @@ -0,0 +1,386 @@ +// ClerkExpoModule - Native module for Clerk integration +// This module provides the configure function and view presentation methods. +// Views are presented as modal view controllers (not embedded views) +// because the Clerk SDK (SPM) isn't accessible from CocoaPods. + +import UIKit +import React + +// Global registry for the Clerk view factory (set by app target at startup) +public var clerkViewFactory: ClerkViewFactoryProtocol? + +// Protocol that the app target implements to provide Clerk views +public protocol ClerkViewFactoryProtocol { + // Modal presentation (existing) + func createAuthViewController(mode: String, dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) -> UIViewController? + func createUserProfileViewController(dismissable: Bool, completion: @escaping (Result<[String: Any], Error>) -> Void) -> UIViewController? + + // Inline rendering — returns UIViewController to preserve SwiftUI lifecycle + func createAuthView(mode: String, dismissable: Bool, onEvent: @escaping (String, [String: Any]) -> Void) -> UIViewController? + func createUserProfileView(dismissable: Bool, onEvent: @escaping (String, [String: Any]) -> Void) -> UIViewController? + + // SDK operations + func configure(publishableKey: String, bearerToken: String?) async throws + func getSession() async -> [String: Any]? + func signOut() async throws +} + +// MARK: - Module + +@objc(ClerkExpo) +class ClerkExpoModule: RCTEventEmitter { + + private static var _hasListeners = false + + override init() { + super.init() + } + + @objc override static func requiresMainQueueSetup() -> Bool { + return false + } + + override func supportedEvents() -> [String]! { + return ["onAuthStateChange"] + } + + override func startObserving() { + ClerkExpoModule._hasListeners = true + } + + override func stopObserving() { + ClerkExpoModule._hasListeners = false + } + + /// Returns the topmost presented view controller, avoiding deprecated `keyWindow`. + private static func topViewController() -> UIViewController? { + guard let scene = UIApplication.shared.connectedScenes + .compactMap({ $0 as? UIWindowScene }) + .first(where: { $0.activationState == .foregroundActive }), + let rootVC = scene.windows.first(where: { $0.isKeyWindow })?.rootViewController + else { return nil } + + var top = rootVC + while let presented = top.presentedViewController { + top = presented + } + return top + } + + // MARK: - configure + + @objc func configure(_ publishableKey: String, + bearerToken: String?, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock) { + guard let factory = clerkViewFactory else { + reject("E_NOT_INITIALIZED", "Clerk not initialized. Make sure ClerkViewFactory is registered.", nil) + return + } + + Task { + do { + try await factory.configure(publishableKey: publishableKey, bearerToken: bearerToken) + resolve(nil) + } catch { + reject("E_CONFIGURE_FAILED", error.localizedDescription, error) + } + } + } + + // MARK: - presentAuth + + @objc func presentAuth(_ options: NSDictionary, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock) { + guard let factory = clerkViewFactory else { + reject("E_NOT_INITIALIZED", "Clerk not initialized", nil) + return + } + + let mode = options["mode"] as? String ?? "signInOrUp" + let dismissable = options["dismissable"] as? Bool ?? true + + DispatchQueue.main.async { + guard let vc = factory.createAuthViewController(mode: mode, dismissable: dismissable, completion: { result in + switch result { + case .success(let data): + resolve(data) + case .failure(let error): + reject("E_AUTH_FAILED", error.localizedDescription, error) + } + }) else { + reject("E_CREATE_FAILED", "Could not create auth view controller", nil) + return + } + + if let rootVC = Self.topViewController() { + rootVC.present(vc, animated: true) + } else { + reject("E_NO_ROOT_VC", "No root view controller available to present auth", nil) + } + } + } + + // MARK: - presentUserProfile + + @objc func presentUserProfile(_ options: NSDictionary, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock) { + guard let factory = clerkViewFactory else { + reject("E_NOT_INITIALIZED", "Clerk not initialized", nil) + return + } + + let dismissable = options["dismissable"] as? Bool ?? true + + DispatchQueue.main.async { + guard let vc = factory.createUserProfileViewController(dismissable: dismissable, completion: { result in + switch result { + case .success(let data): + resolve(data) + case .failure(let error): + reject("E_PROFILE_FAILED", error.localizedDescription, error) + } + }) else { + reject("E_CREATE_FAILED", "Could not create profile view controller", nil) + return + } + + if let rootVC = Self.topViewController() { + rootVC.present(vc, animated: true) + } else { + reject("E_NO_ROOT_VC", "No root view controller available to present profile", nil) + } + } + } + + // MARK: - getSession + + @objc func getSession(_ resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock) { + guard let factory = clerkViewFactory else { + resolve(nil) + return + } + + Task { + let session = await factory.getSession() + resolve(session) + } + } + + // MARK: - getClientToken + + @objc func getClientToken(_ resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock) { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: Bundle.main.bundleIdentifier ?? "", + kSecAttrAccount as String: "clerkDeviceToken", + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne + ] + + var result: AnyObject? + let status = SecItemCopyMatching(query as CFDictionary, &result) + + if status == errSecSuccess, let data = result as? Data { + resolve(String(data: data, encoding: .utf8)) + } else { + resolve(nil) + } + } + + // MARK: - signOut + + @objc func signOut(_ resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock) { + guard let factory = clerkViewFactory else { + reject("E_NOT_INITIALIZED", "Clerk not initialized", nil) + return + } + + Task { + do { + try await factory.signOut() + resolve(nil) + } catch { + reject("E_SIGN_OUT_FAILED", error.localizedDescription, error) + } + } + } +} + +// MARK: - Inline View: ClerkAuthNativeView + +public class ClerkAuthNativeView: UIView { + private var hostingController: UIViewController? + private var currentMode: String = "signInOrUp" + private var currentDismissable: Bool = true + private var hasInitialized: Bool = false + + @objc var onAuthEvent: RCTBubblingEventBlock? + + @objc var mode: NSString? { + didSet { + currentMode = (mode as String?) ?? "signInOrUp" + if hasInitialized { updateView() } + } + } + + @objc var isDismissable: NSNumber? { + didSet { + currentDismissable = isDismissable?.boolValue ?? true + if hasInitialized { updateView() } + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func didMoveToWindow() { + super.didMoveToWindow() + if window != nil && !hasInitialized { + hasInitialized = true + updateView() + } + } + + private func updateView() { + // Remove old hosting controller + hostingController?.view.removeFromSuperview() + hostingController?.removeFromParent() + hostingController = nil + + guard let factory = clerkViewFactory else { return } + + guard let returnedController = factory.createAuthView( + mode: currentMode, + dismissable: currentDismissable, + onEvent: { [weak self] eventName, data in + // Convert data dict to JSON string for codegen event + let jsonData = (try? JSONSerialization.data(withJSONObject: data)) ?? Data() + let jsonString = String(data: jsonData, encoding: .utf8) ?? "{}" + self?.onAuthEvent?(["type": eventName, "data": jsonString]) + } + ) else { return } + + // Attach the returned UIHostingController as a child to preserve SwiftUI lifecycle + if let parentVC = findViewController() { + parentVC.addChild(returnedController) + returnedController.view.frame = bounds + returnedController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + addSubview(returnedController.view) + returnedController.didMove(toParent: parentVC) + hostingController = returnedController + } else { + returnedController.view.frame = bounds + returnedController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + addSubview(returnedController.view) + hostingController = returnedController + } + } + + private func findViewController() -> UIViewController? { + var responder: UIResponder? = self + while let nextResponder = responder?.next { + if let vc = nextResponder as? UIViewController { + return vc + } + responder = nextResponder + } + return nil + } + + override public func layoutSubviews() { + super.layoutSubviews() + hostingController?.view.frame = bounds + } +} + +// MARK: - Inline View: ClerkUserProfileNativeView + +public class ClerkUserProfileNativeView: UIView { + private var hostingController: UIViewController? + private var currentDismissable: Bool = true + private var hasInitialized: Bool = false + + @objc var onProfileEvent: RCTBubblingEventBlock? + + @objc var isDismissable: NSNumber? { + didSet { + currentDismissable = isDismissable?.boolValue ?? true + if hasInitialized { updateView() } + } + } + + override init(frame: CGRect) { + super.init(frame: frame) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override public func didMoveToWindow() { + super.didMoveToWindow() + if window != nil && !hasInitialized { + hasInitialized = true + updateView() + } + } + + private func updateView() { + // Remove old hosting controller + hostingController?.view.removeFromSuperview() + hostingController?.removeFromParent() + hostingController = nil + + guard let factory = clerkViewFactory else { return } + + guard let returnedController = factory.createUserProfileView( + dismissable: currentDismissable, + onEvent: { [weak self] eventName, data in + let jsonData = (try? JSONSerialization.data(withJSONObject: data)) ?? Data() + let jsonString = String(data: jsonData, encoding: .utf8) ?? "{}" + self?.onProfileEvent?(["type": eventName, "data": jsonString]) + } + ) else { return } + + if let parentVC = findViewController() { + parentVC.addChild(returnedController) + returnedController.view.frame = bounds + returnedController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + addSubview(returnedController.view) + returnedController.didMove(toParent: parentVC) + hostingController = returnedController + } else { + returnedController.view.frame = bounds + returnedController.view.autoresizingMask = [.flexibleWidth, .flexibleHeight] + addSubview(returnedController.view) + hostingController = returnedController + } + } + + private func findViewController() -> UIViewController? { + var responder: UIResponder? = self + while let nextResponder = responder?.next { + if let vc = nextResponder as? UIViewController { + return vc + } + responder = nextResponder + } + return nil + } + + override public func layoutSubviews() { + super.layoutSubviews() + hostingController?.view.frame = bounds + } +} diff --git a/packages/expo/ios/ClerkGoogleSignIn.podspec b/packages/expo/ios/ClerkGoogleSignIn.podspec index be0f3551b2b..e356ea70c8c 100644 --- a/packages/expo/ios/ClerkGoogleSignIn.podspec +++ b/packages/expo/ios/ClerkGoogleSignIn.podspec @@ -15,8 +15,10 @@ Pod::Spec.new do |s| s.source = { :git => 'https://github.com/clerk/javascript.git' } s.static_framework = true - s.dependency 'ExpoModulesCore' s.dependency 'GoogleSignIn', '~> 9.0' - s.source_files = '*.swift' + # Only include the Google Sign-In module files + s.source_files = 'ClerkGoogleSignInModule.swift', 'ClerkGoogleSignInModule.m' + + install_modules_dependencies(s) end diff --git a/packages/expo/ios/ClerkGoogleSignInModule.m b/packages/expo/ios/ClerkGoogleSignInModule.m new file mode 100644 index 00000000000..5848d4a17b7 --- /dev/null +++ b/packages/expo/ios/ClerkGoogleSignInModule.m @@ -0,0 +1,22 @@ +#import + +@interface RCT_EXTERN_MODULE(ClerkGoogleSignIn, NSObject) + +RCT_EXTERN_METHOD(configure:(NSDictionary *)params) + +RCT_EXTERN_METHOD(signIn:(NSDictionary *)params + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(createAccount:(NSDictionary *)params + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(presentExplicitSignIn:(NSDictionary *)params + resolve:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(signOut:(RCTPromiseResolveBlock)resolve + reject:(RCTPromiseRejectBlock)reject) + +@end diff --git a/packages/expo/ios/ClerkGoogleSignInModule.swift b/packages/expo/ios/ClerkGoogleSignInModule.swift index c06f85b8031..ea29ad2ae79 100644 --- a/packages/expo/ios/ClerkGoogleSignInModule.swift +++ b/packages/expo/ios/ClerkGoogleSignInModule.swift @@ -1,229 +1,192 @@ -import ExpoModulesCore +import React import GoogleSignIn -public class ClerkGoogleSignInModule: Module { - private var clientId: String? - private var hostedDomain: String? - - public func definition() -> ModuleDefinition { - Name("ClerkGoogleSignIn") - - // Configure the module - Function("configure") { (params: ConfigureParams) in - self.clientId = params.iosClientId ?? params.webClientId - self.hostedDomain = params.hostedDomain - - // Set the configuration globally - // clientID: iOS client ID for OAuth flow - // serverClientID: Web client ID for token audience (what Clerk backend verifies) - if let clientId = self.clientId { - let config = GIDConfiguration( - clientID: clientId, - serverClientID: params.webClientId - ) - GIDSignIn.sharedInstance.configuration = config - } - } - - // Sign in - attempts sign-in with hint if available - AsyncFunction("signIn") { (params: SignInParams?, promise: Promise) in - guard self.clientId != nil else { - promise.reject(NotConfiguredException()) - return - } - - DispatchQueue.main.async { - guard let presentingVC = self.getPresentingViewController() else { - promise.reject(GoogleSignInException(message: "No presenting view controller available")) - return - } - - // Build sign-in hint if filtering by authorized accounts - let hint: String? = params?.filterByAuthorizedAccounts == true - ? GIDSignIn.sharedInstance.currentUser?.profile?.email - : nil - - GIDSignIn.sharedInstance.signIn( - withPresenting: presentingVC, - hint: hint, - additionalScopes: nil, - nonce: params?.nonce - ) { result, error in - self.handleSignInResult(result: result, error: error, promise: promise) - } - } - } - - // Create account - shows account creation UI (same as sign in on iOS) - AsyncFunction("createAccount") { (params: CreateAccountParams?, promise: Promise) in - guard self.clientId != nil else { - promise.reject(NotConfiguredException()) - return - } - - DispatchQueue.main.async { - guard let presentingVC = self.getPresentingViewController() else { - promise.reject(GoogleSignInException(message: "No presenting view controller available")) - return - } - - GIDSignIn.sharedInstance.signIn( - withPresenting: presentingVC, - hint: nil, - additionalScopes: nil, - nonce: params?.nonce - ) { result, error in - self.handleSignInResult(result: result, error: error, promise: promise) - } - } - } - - // Explicit sign-in - uses standard Google Sign-In flow - AsyncFunction("presentExplicitSignIn") { (params: ExplicitSignInParams?, promise: Promise) in - guard self.clientId != nil else { - promise.reject(NotConfiguredException()) - return - } - - DispatchQueue.main.async { - guard let presentingVC = self.getPresentingViewController() else { - promise.reject(GoogleSignInException(message: "No presenting view controller available")) - return - } - - GIDSignIn.sharedInstance.signIn( - withPresenting: presentingVC, - hint: nil, - additionalScopes: nil, - nonce: params?.nonce - ) { result, error in - self.handleSignInResult(result: result, error: error, promise: promise) - } - } - } - - // Sign out - clears credential state - AsyncFunction("signOut") { (promise: Promise) in - GIDSignIn.sharedInstance.signOut() - promise.resolve(nil) - } - } +@objc(ClerkGoogleSignIn) +class ClerkGoogleSignInModule: NSObject, RCTBridgeModule { - private func getPresentingViewController() -> UIViewController? { - guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let window = scene.windows.first, - let rootVC = window.rootViewController else { - return nil - } - - var topVC = rootVC - while let presentedVC = topVC.presentedViewController { - topVC = presentedVC - } - return topVC - } + static func moduleName() -> String! { + return "ClerkGoogleSignIn" + } + + @objc static func requiresMainQueueSetup() -> Bool { + return false + } + + private var clientId: String? + private var hostedDomain: String? + + // MARK: - configure + + @objc func configure(_ params: NSDictionary) { + let webClientId = params["webClientId"] as? String ?? "" + let iosClientId = params["iosClientId"] as? String + self.clientId = iosClientId ?? webClientId + self.hostedDomain = params["hostedDomain"] as? String - private func handleSignInResult(result: GIDSignInResult?, error: Error?, promise: Promise) { - if let error = error { - let nsError = error as NSError - - // Check for user cancellation - if nsError.domain == kGIDSignInErrorDomain && nsError.code == GIDSignInError.canceled.rawValue { - promise.reject(SignInCancelledException()) - return - } - - promise.reject(GoogleSignInException(message: error.localizedDescription)) - return - } - - guard let result = result, - let idToken = result.user.idToken?.tokenString else { - promise.reject(GoogleSignInException(message: "No ID token received")) - return - } - - let user = result.user - let profile = user.profile - - let response: [String: Any] = [ - "type": "success", - "data": [ - "idToken": idToken, - "user": [ - "id": user.userID ?? "", - "email": profile?.email ?? "", - "name": profile?.name ?? "", - "givenName": profile?.givenName ?? "", - "familyName": profile?.familyName ?? "", - "photo": profile?.imageURL(withDimension: 200)?.absoluteString ?? NSNull() - ] as [String: Any] - ] as [String: Any] - ] - - promise.resolve(response) + if let clientId = self.clientId { + let config = GIDConfiguration( + clientID: clientId, + serverClientID: webClientId + ) + GIDSignIn.sharedInstance.configuration = config } -} + } -// MARK: - Records + // MARK: - signIn -struct ConfigureParams: Record { - @Field - var webClientId: String = "" + @objc func signIn(_ params: NSDictionary?, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock) { + guard self.clientId != nil else { + reject("NOT_CONFIGURED", "Google Sign-In is not configured. Call configure() first.", nil) + return + } - @Field - var iosClientId: String? + DispatchQueue.main.async { + guard let presentingVC = self.getPresentingViewController() else { + reject("GOOGLE_SIGN_IN_ERROR", "No presenting view controller available", nil) + return + } + + let filterByAuthorized = params?["filterByAuthorizedAccounts"] as? Bool ?? false + let hint: String? = filterByAuthorized + ? GIDSignIn.sharedInstance.currentUser?.profile?.email + : nil + let nonce = params?["nonce"] as? String + + GIDSignIn.sharedInstance.signIn( + withPresenting: presentingVC, + hint: hint, + additionalScopes: nil, + nonce: nonce + ) { result, error in + self.handleSignInResult(result: result, error: error, resolve: resolve, reject: reject) + } + } + } - @Field - var hostedDomain: String? + // MARK: - createAccount - @Field - var autoSelectEnabled: Bool? -} + @objc func createAccount(_ params: NSDictionary?, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock) { + guard self.clientId != nil else { + reject("NOT_CONFIGURED", "Google Sign-In is not configured. Call configure() first.", nil) + return + } -struct SignInParams: Record { - @Field - var nonce: String? + DispatchQueue.main.async { + guard let presentingVC = self.getPresentingViewController() else { + reject("GOOGLE_SIGN_IN_ERROR", "No presenting view controller available", nil) + return + } + + let nonce = params?["nonce"] as? String + + GIDSignIn.sharedInstance.signIn( + withPresenting: presentingVC, + hint: nil, + additionalScopes: nil, + nonce: nonce + ) { result, error in + self.handleSignInResult(result: result, error: error, resolve: resolve, reject: reject) + } + } + } - @Field - var filterByAuthorizedAccounts: Bool? -} + // MARK: - presentExplicitSignIn -struct CreateAccountParams: Record { - @Field - var nonce: String? -} + @objc func presentExplicitSignIn(_ params: NSDictionary?, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock) { + guard self.clientId != nil else { + reject("NOT_CONFIGURED", "Google Sign-In is not configured. Call configure() first.", nil) + return + } -struct ExplicitSignInParams: Record { - @Field - var nonce: String? -} + DispatchQueue.main.async { + guard let presentingVC = self.getPresentingViewController() else { + reject("GOOGLE_SIGN_IN_ERROR", "No presenting view controller available", nil) + return + } + + let nonce = params?["nonce"] as? String + + GIDSignIn.sharedInstance.signIn( + withPresenting: presentingVC, + hint: nil, + additionalScopes: nil, + nonce: nonce + ) { result, error in + self.handleSignInResult(result: result, error: error, resolve: resolve, reject: reject) + } + } + } -// MARK: - Exceptions + // MARK: - signOut -class SignInCancelledException: Exception { - override var code: String { "SIGN_IN_CANCELLED" } - override var reason: String { "User cancelled the sign-in flow" } -} + @objc func signOut(_ resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock) { + GIDSignIn.sharedInstance.signOut() + resolve(nil) + } -class NoSavedCredentialException: Exception { - override var code: String { "NO_SAVED_CREDENTIAL_FOUND" } - override var reason: String { "No saved credential found" } -} + // MARK: - Helpers -class NotConfiguredException: Exception { - override var code: String { "NOT_CONFIGURED" } - override var reason: String { "Google Sign-In is not configured. Call configure() first." } -} + private func getPresentingViewController() -> UIViewController? { + guard let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let window = scene.windows.first, + let rootVC = window.rootViewController else { + return nil + } -class GoogleSignInException: Exception { - private let errorMessage: String + var topVC = rootVC + while let presentedVC = topVC.presentedViewController { + topVC = presentedVC + } + return topVC + } + + private func handleSignInResult(result: GIDSignInResult?, error: Error?, + resolve: @escaping RCTPromiseResolveBlock, + reject: @escaping RCTPromiseRejectBlock) { + if let error = error { + let nsError = error as NSError + + // Check for user cancellation + if nsError.domain == kGIDSignInErrorDomain && nsError.code == GIDSignInError.canceled.rawValue { + reject("SIGN_IN_CANCELLED", "User cancelled the sign-in flow", error) + return + } + + reject("GOOGLE_SIGN_IN_ERROR", error.localizedDescription, error) + return + } - init(message: String) { - self.errorMessage = message - super.init() + guard let result = result, + let idToken = result.user.idToken?.tokenString else { + reject("GOOGLE_SIGN_IN_ERROR", "No ID token received", nil) + return } - override var code: String { "GOOGLE_SIGN_IN_ERROR" } - override var reason: String { errorMessage } + let user = result.user + let profile = user.profile + + let response: [String: Any] = [ + "type": "success", + "data": [ + "idToken": idToken, + "user": [ + "id": user.userID ?? "", + "email": profile?.email ?? "", + "name": profile?.name ?? "", + "givenName": profile?.givenName ?? "", + "familyName": profile?.familyName ?? "", + "photo": profile?.imageURL(withDimension: 200)?.absoluteString ?? NSNull() + ] as [String: Any] + ] as [String: Any] + ] + + resolve(response) + } } diff --git a/packages/expo/ios/ClerkUserProfileViewManager.m b/packages/expo/ios/ClerkUserProfileViewManager.m new file mode 100644 index 00000000000..35eaf720ed9 --- /dev/null +++ b/packages/expo/ios/ClerkUserProfileViewManager.m @@ -0,0 +1,8 @@ +#import + +@interface RCT_EXTERN_MODULE(ClerkUserProfileViewManager, RCTViewManager) + +RCT_EXPORT_VIEW_PROPERTY(isDismissable, NSNumber) +RCT_EXPORT_VIEW_PROPERTY(onProfileEvent, RCTBubblingEventBlock) + +@end diff --git a/packages/expo/ios/ClerkUserProfileViewManager.swift b/packages/expo/ios/ClerkUserProfileViewManager.swift new file mode 100644 index 00000000000..b8e9c269f6a --- /dev/null +++ b/packages/expo/ios/ClerkUserProfileViewManager.swift @@ -0,0 +1,13 @@ +import React + +@objc(ClerkUserProfileViewManager) +class ClerkUserProfileViewManager: RCTViewManager { + + override static func requiresMainQueueSetup() -> Bool { + return true + } + + override func view() -> UIView! { + return ClerkUserProfileNativeView() + } +} diff --git a/packages/expo/ios/ClerkViewFactory.swift b/packages/expo/ios/ClerkViewFactory.swift new file mode 100644 index 00000000000..ccd09e6b1d9 --- /dev/null +++ b/packages/expo/ios/ClerkViewFactory.swift @@ -0,0 +1,439 @@ +// 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 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 { + Self.writeNativeDeviceTokenIfNeeded(token) + } else { + Self.syncJSTokenToNativeKeychainIfNeeded() + } + + 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..<30 { // Wait up to 3 seconds + if Clerk.shared.isLoaded && Clerk.shared.session != nil { + return + } + try? await Task.sleep(nanoseconds: 100_000_000) // 100ms + } + } + + /// Copies the JS SDK's client JWT from expo-secure-store to the native SDK's + /// keychain entry, but only if the native SDK doesn't already have a device token. + /// Both expo-secure-store and the native Clerk SDK use the iOS Keychain with the + /// bundle identifier as the service name, making cross-SDK token sharing possible. + private static func syncJSTokenToNativeKeychainIfNeeded() { + guard let service = Bundle.main.bundleIdentifier, !service.isEmpty else { return } + + let jsTokenKey = "__clerk_client_jwt" + let nativeTokenKey = "clerkDeviceToken" + + // Check if native SDK already has a device token — don't overwrite + 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 { + return // Native token exists, don't overwrite + } + + // Read JS SDK's client JWT from keychain (stored by expo-secure-store) + var result: CFTypeRef? + let readQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: jsTokenKey, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + guard SecItemCopyMatching(readQuery as CFDictionary, &result) == errSecSuccess, + let data = result as? Data, + let jsToken = String(data: data, encoding: .utf8), + !jsToken.isEmpty else { + return // No JS token available + } + + // Write JS token as native device token + guard let tokenData = jsToken.data(using: .utf8) else { return } + 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) + } + + /// Writes the provided bearer token as the native SDK's device token, + /// but only if the native SDK doesn't already have one. + private static func writeNativeDeviceTokenIfNeeded(_ token: String) { + guard let service = Bundle.main.bundleIdentifier, !service.isEmpty else { return } + + let nativeTokenKey = "clerkDeviceToken" + + // Check if native SDK already has a device token — don't overwrite + 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 { + return + } + + // Write the provided token as native device token + guard let tokenData = token.data(using: .utf8) else { return } + 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 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 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 { + guard let sessionId = Clerk.shared.session?.id else { return } + try await Clerk.shared.auth.signOut(sessionId: sessionId) + } +} + +// 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 + + var body: some View { + AuthView(mode: mode, isDismissable: dismissable) + .environment(Clerk.shared) + .task { + for await event in Clerk.shared.auth.events { + switch event { + case .signInCompleted(let signIn): + if let sessionId = signIn.createdSessionId { + onEvent("signInCompleted", ["sessionId": sessionId, "type": "signIn"]) + } + case .signUpCompleted(let signUp): + if let sessionId = signUp.createdSessionId { + onEvent("signUpCompleted", ["sessionId": sessionId, "type": "signUp"]) + } + 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/ios/templates/ClerkViewFactory.swift b/packages/expo/ios/templates/ClerkViewFactory.swift new file mode 100644 index 00000000000..ccd09e6b1d9 --- /dev/null +++ b/packages/expo/ios/templates/ClerkViewFactory.swift @@ -0,0 +1,439 @@ +// 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 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 { + Self.writeNativeDeviceTokenIfNeeded(token) + } else { + Self.syncJSTokenToNativeKeychainIfNeeded() + } + + 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..<30 { // Wait up to 3 seconds + if Clerk.shared.isLoaded && Clerk.shared.session != nil { + return + } + try? await Task.sleep(nanoseconds: 100_000_000) // 100ms + } + } + + /// Copies the JS SDK's client JWT from expo-secure-store to the native SDK's + /// keychain entry, but only if the native SDK doesn't already have a device token. + /// Both expo-secure-store and the native Clerk SDK use the iOS Keychain with the + /// bundle identifier as the service name, making cross-SDK token sharing possible. + private static func syncJSTokenToNativeKeychainIfNeeded() { + guard let service = Bundle.main.bundleIdentifier, !service.isEmpty else { return } + + let jsTokenKey = "__clerk_client_jwt" + let nativeTokenKey = "clerkDeviceToken" + + // Check if native SDK already has a device token — don't overwrite + 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 { + return // Native token exists, don't overwrite + } + + // Read JS SDK's client JWT from keychain (stored by expo-secure-store) + var result: CFTypeRef? + let readQuery: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: jsTokenKey, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + guard SecItemCopyMatching(readQuery as CFDictionary, &result) == errSecSuccess, + let data = result as? Data, + let jsToken = String(data: data, encoding: .utf8), + !jsToken.isEmpty else { + return // No JS token available + } + + // Write JS token as native device token + guard let tokenData = jsToken.data(using: .utf8) else { return } + 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) + } + + /// Writes the provided bearer token as the native SDK's device token, + /// but only if the native SDK doesn't already have one. + private static func writeNativeDeviceTokenIfNeeded(_ token: String) { + guard let service = Bundle.main.bundleIdentifier, !service.isEmpty else { return } + + let nativeTokenKey = "clerkDeviceToken" + + // Check if native SDK already has a device token — don't overwrite + 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 { + return + } + + // Write the provided token as native device token + guard let tokenData = token.data(using: .utf8) else { return } + 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 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 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 { + guard let sessionId = Clerk.shared.session?.id else { return } + try await Clerk.shared.auth.signOut(sessionId: sessionId) + } +} + +// 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 + + var body: some View { + AuthView(mode: mode, isDismissable: dismissable) + .environment(Clerk.shared) + .task { + for await event in Clerk.shared.auth.events { + switch event { + case .signInCompleted(let signIn): + if let sessionId = signIn.createdSessionId { + onEvent("signInCompleted", ["sessionId": sessionId, "type": "signIn"]) + } + case .signUpCompleted(let signUp): + if let sessionId = signUp.createdSessionId { + onEvent("signUpCompleted", ["sessionId": sessionId, "type": "signUp"]) + } + 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/native/package.json b/packages/expo/native/package.json new file mode 100644 index 00000000000..6ae24b71af4 --- /dev/null +++ b/packages/expo/native/package.json @@ -0,0 +1,4 @@ +{ + "main": "../dist/native/index.js", + "types": "../dist/native/index.d.ts" +} diff --git a/packages/expo/package.json b/packages/expo/package.json index 72ec50aa25e..c7be4789c10 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -1,6 +1,6 @@ { "name": "@clerk/expo", - "version": "2.19.10", + "version": "2.19.24", "description": "Clerk React Native/Expo library", "keywords": [ "react", @@ -28,6 +28,11 @@ "types": "./dist/index.d.ts", "default": "./dist/index.js" }, + "./app.plugin.js": "./app.plugin.js", + "./native": { + "types": "./dist/native/index.d.ts", + "default": "./dist/native/index.js" + }, "./web": { "types": "./dist/web/index.d.ts", "default": "./dist/web/index.js" @@ -69,7 +74,7 @@ "default": "./dist/legacy.js" }, "./types": "./dist/types/index.d.ts", - "./app.plugin.js": "./app.plugin.js" + "./package.json": "./package.json" }, "main": "./dist/index.js", "source": "./src/index.ts", @@ -78,10 +83,20 @@ "dist", "android", "ios", + "native", + "web", + "local-credentials", + "passkeys", + "secure-store", + "resource-cache", + "token-cache", "google", "apple", + "src/specs", "expo-module.config.json", - "app.plugin.js" + "react-native.config.js", + "app.plugin.js", + "app.plugin.d.ts" ], "scripts": { "build": "tsup", @@ -107,12 +122,12 @@ "@clerk/expo-passkeys": "workspace:*", "@expo/config-plugins": "^54.0.4", "@types/base-64": "^1.0.2", + "esbuild": "^0.19.0", "expo-apple-authentication": "^7.2.4", "expo-auth-session": "^5.4.0", "expo-constants": "^18.0.0", "expo-crypto": "^15.0.7", "expo-local-authentication": "^13.8.0", - "expo-modules-core": "^3.0.0", "expo-secure-store": "^12.8.1", "expo-web-browser": "^12.8.2", "react-native": "^0.81.4" @@ -125,7 +140,6 @@ "expo-constants": ">=12", "expo-crypto": ">=12", "expo-local-authentication": ">=13.5.0", - "expo-modules-core": ">=3.0.0", "expo-secure-store": ">=12.4.0", "expo-web-browser": ">=12.5.0", "react": "^18.0.0 || ^19.0.0", @@ -157,5 +171,13 @@ }, "publishConfig": { "access": "public" + }, + "codegenConfig": { + "name": "ClerkExpoSpec", + "type": "all", + "jsSrcsDir": "src/specs", + "android": { + "javaPackageName": "expo.modules.clerk" + } } } diff --git a/packages/expo/react-native.config.js b/packages/expo/react-native.config.js new file mode 100644 index 00000000000..84cec6c149d --- /dev/null +++ b/packages/expo/react-native.config.js @@ -0,0 +1,11 @@ +module.exports = { + dependency: { + platforms: { + ios: {}, + android: { + packageImportPath: 'import expo.modules.clerk.ClerkPackage;', + packageInstance: 'new ClerkPackage()', + }, + }, + }, +}; diff --git a/packages/expo/src/components/controlComponents.tsx b/packages/expo/src/components/controlComponents.tsx index 5ef4f45e015..79f3d97177f 100644 --- a/packages/expo/src/components/controlComponents.tsx +++ b/packages/expo/src/components/controlComponents.tsx @@ -1 +1,22 @@ -export { ClerkLoaded, ClerkLoading, Show } from '@clerk/react'; +// Re-export control components from @clerk/react +// These provide conditional rendering based on auth state +export { ClerkLoaded, ClerkLoading, RedirectToTasks, Show } from '@clerk/react'; + +import { Show } from '@clerk/react'; +import type { PropsWithChildren, ReactNode } from 'react'; + +/** + * Render children only when the user is signed in. + * A convenience wrapper around ``. + */ +export function SignedIn({ children }: PropsWithChildren): ReactNode { + return {children}; +} + +/** + * Render children only when the user is signed out. + * A convenience wrapper around ``. + */ +export function SignedOut({ children }: PropsWithChildren): ReactNode { + return {children}; +} diff --git a/packages/expo/src/google-one-tap/ClerkGoogleOneTapSignIn.ts b/packages/expo/src/google-one-tap/ClerkGoogleOneTapSignIn.ts index 5dc89a69982..14fc502e949 100644 --- a/packages/expo/src/google-one-tap/ClerkGoogleOneTapSignIn.ts +++ b/packages/expo/src/google-one-tap/ClerkGoogleOneTapSignIn.ts @@ -1,5 +1,4 @@ -import { requireNativeModule } from 'expo-modules-core'; - +import NativeClerkGoogleSignIn from '../specs/NativeClerkGoogleSignIn'; import type { CancelledResponse, ConfigureParams, @@ -11,21 +10,12 @@ import type { SignInParams, } from './types'; -// Type for the native module methods -interface ClerkGoogleSignInNativeModule { - configure(params: ConfigureParams): void; - signIn(params: SignInParams): Promise; - createAccount(params: CreateAccountParams): Promise; - presentExplicitSignIn(params: ExplicitSignInParams): Promise; - signOut(): Promise; -} - // Lazy-load the native module to avoid crashes when not available -let _nativeModule: ClerkGoogleSignInNativeModule | null = null; +let _nativeModule: typeof NativeClerkGoogleSignIn | null = null; -function getNativeModule(): ClerkGoogleSignInNativeModule { +function getNativeModule(): typeof NativeClerkGoogleSignIn { if (!_nativeModule) { - _nativeModule = requireNativeModule('ClerkGoogleSignIn'); + _nativeModule = NativeClerkGoogleSignIn; } return _nativeModule; } @@ -84,7 +74,7 @@ export const ClerkGoogleOneTapSignIn = { * @param params.autoSelectEnabled - Auto-select for single credential (default: false) */ configure(params: ConfigureParams): void { - getNativeModule().configure(params); + getNativeModule().configure(params as any); }, /** @@ -101,7 +91,7 @@ export const ClerkGoogleOneTapSignIn = { */ async signIn(params?: SignInParams): Promise { try { - return await getNativeModule().signIn(params ?? {}); + return (await getNativeModule().signIn((params as any) ?? null)) as unknown as OneTapResponse; } catch (error) { if (isErrorWithCode(error)) { if (error.code === 'SIGN_IN_CANCELLED') { @@ -128,7 +118,7 @@ export const ClerkGoogleOneTapSignIn = { */ async createAccount(params?: CreateAccountParams): Promise { try { - return await getNativeModule().createAccount(params ?? {}); + return (await getNativeModule().createAccount((params as any) ?? null)) as unknown as OneTapResponse; } catch (error) { if (isErrorWithCode(error)) { if (error.code === 'SIGN_IN_CANCELLED') { @@ -155,7 +145,7 @@ export const ClerkGoogleOneTapSignIn = { */ async presentExplicitSignIn(params?: ExplicitSignInParams): Promise { try { - return await getNativeModule().presentExplicitSignIn(params ?? {}); + return (await getNativeModule().presentExplicitSignIn((params as any) ?? null)) as unknown as OneTapResponse; } catch (error) { if (isErrorWithCode(error)) { if (error.code === 'SIGN_IN_CANCELLED') { diff --git a/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts b/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts index 08e2e2a92b8..c297713a801 100644 --- a/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts +++ b/packages/expo/src/hooks/__tests__/useSignInWithGoogle.test.ts @@ -46,10 +46,26 @@ vi.mock('react-native', () => { }; }); -vi.mock('expo-modules-core', () => { +vi.mock('../../specs/NativeClerkModule', () => { return { - EventEmitter: vi.fn(), - requireNativeModule: vi.fn(), + default: { + configure: vi.fn(), + getSession: vi.fn(), + getClientToken: vi.fn(), + signOut: vi.fn(), + }, + }; +}); + +vi.mock('../../specs/NativeClerkGoogleSignIn', () => { + return { + default: { + configure: vi.fn(), + signIn: vi.fn(), + createAccount: vi.fn(), + presentExplicitSignIn: vi.fn(), + signOut: vi.fn(), + }, }; }); diff --git a/packages/expo/src/hooks/index.ts b/packages/expo/src/hooks/index.ts index 0b64ce0c5be..8f9e13694ea 100644 --- a/packages/expo/src/hooks/index.ts +++ b/packages/expo/src/hooks/index.ts @@ -1,3 +1,4 @@ +// Re-export hooks that don't need type overrides export { useClerk, useEmailLink, @@ -15,3 +16,5 @@ export { export * from './useSSO'; export * from './useOAuth'; export * from './useAuth'; +export * from './useNativeSession'; +export * from './useNativeAuthEvents'; diff --git a/packages/expo/src/hooks/useNativeAuthEvents.ts b/packages/expo/src/hooks/useNativeAuthEvents.ts new file mode 100644 index 00000000000..8ef9feeb83f --- /dev/null +++ b/packages/expo/src/hooks/useNativeAuthEvents.ts @@ -0,0 +1,107 @@ +import { useEffect, useState } from 'react'; +import { NativeEventEmitter, Platform } from 'react-native'; + +import NativeClerkModule from '../specs/NativeClerkModule'; + +// Check if native module is supported on this platform +const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android'; + +// Get the native module for event listening +let ClerkExpo: typeof NativeClerkModule | null = null; + +if (isNativeSupported) { + try { + ClerkExpo = NativeClerkModule; + } catch { + // Native module not available - plugin not configured + ClerkExpo = null; + } +} + +/** + * Auth state change event from native SDK + */ +export interface NativeAuthStateEvent { + type: 'signedIn' | 'signedOut'; + sessionId: string | null; +} + +export interface UseNativeAuthEventsReturn { + /** + * The latest auth state event from the native SDK. + * Will be null until an event is received. + */ + nativeAuthState: NativeAuthStateEvent | null; + + /** + * Whether native event listening is supported (plugin installed) + */ + isSupported: boolean; +} + +/** + * Hook to listen for auth state change events from the native Clerk SDK. + * + * This provides reactive updates when the user signs in or out via native UI. + * Events are emitted by the native module when: + * - User completes sign-in (signInCompleted event from clerk-ios/clerk-android) + * - User completes sign-up (signUpCompleted event from clerk-ios/clerk-android) + * - User signs out (signedOut event from clerk-ios/clerk-android) + * + * @example + * ```tsx + * import { useNativeAuthEvents } from '@clerk/expo'; + * + * function MyComponent() { + * const { nativeAuthState, isSupported } = useNativeAuthEvents(); + * + * useEffect(() => { + * if (nativeAuthState?.type === 'signedIn') { + * console.log('User signed in via native UI'); + * } else if (nativeAuthState?.type === 'signedOut') { + * console.log('User signed out via native UI'); + * } + * }, [nativeAuthState]); + * + * return ; + * } + * ``` + */ +export function useNativeAuthEvents(): UseNativeAuthEventsReturn { + const [nativeAuthState, setNativeAuthState] = useState(null); + + useEffect(() => { + console.log(`[useNativeAuthEvents] INIT: isNativeSupported=${isNativeSupported}, ClerkExpo=${!!ClerkExpo}`); + + if (!isNativeSupported || !ClerkExpo) { + console.log(`[useNativeAuthEvents] SKIP: Native not supported or ClerkExpo not available`); + return; + } + + let subscription: { remove: () => void } | null = null; + + try { + console.log(`[useNativeAuthEvents] SETUP: Creating NativeEventEmitter for ClerkExpo`); + const eventEmitter = new NativeEventEmitter(ClerkExpo as any); + + console.log(`[useNativeAuthEvents] LISTEN: Adding listener for 'onAuthStateChange' events`); + subscription = eventEmitter.addListener('onAuthStateChange', (event: NativeAuthStateEvent) => { + console.log('[useNativeAuthEvents] EVENT_RECEIVED:', JSON.stringify(event)); + setNativeAuthState(event); + }); + console.log(`[useNativeAuthEvents] LISTEN: Listener added successfully`); + } catch (error) { + console.log('[useNativeAuthEvents] ERROR: Could not set up event listener:', error); + } + + return () => { + console.log(`[useNativeAuthEvents] CLEANUP: Removing event listener`); + subscription?.remove(); + }; + }, []); + + return { + nativeAuthState, + isSupported: isNativeSupported && !!ClerkExpo, + }; +} diff --git a/packages/expo/src/hooks/useNativeSession.ts b/packages/expo/src/hooks/useNativeSession.ts new file mode 100644 index 00000000000..103037b6f1f --- /dev/null +++ b/packages/expo/src/hooks/useNativeSession.ts @@ -0,0 +1,141 @@ +import { useCallback, useEffect, useState } from 'react'; +import { Platform } from 'react-native'; + +import NativeClerkModule from '../specs/NativeClerkModule'; + +// Check if native module is supported on this platform +const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android'; + +// Native session data structure (normalized) +interface NativeSessionData { + sessionId?: string; + user?: { + id: string; + firstName?: string; + lastName?: string; + imageUrl?: string; + primaryEmailAddress?: string; + }; +} + +// Raw result from the native module (may vary by platform) +interface NativeSessionRawResult { + sessionId?: string; + session?: { id: string }; + user?: NativeSessionData['user']; +} + +// Safely get the native module +let ClerkExpo: typeof NativeClerkModule | null = null; + +if (isNativeSupported) { + try { + ClerkExpo = NativeClerkModule; + } catch { + // Native module not available - this is expected when expo plugin is not installed + } +} + +export interface UseNativeSessionReturn { + /** + * Whether the native module is available (expo plugin installed) + */ + isAvailable: boolean; + + /** + * Whether the native session check is still loading + */ + isLoading: boolean; + + /** + * Whether there is an active native session + */ + isSignedIn: boolean; + + /** + * The native session ID, if available + */ + sessionId: string | null; + + /** + * The native user data, if available + */ + user: NativeSessionData['user'] | null; + + /** + * Refresh the native session state + */ + refresh: () => Promise; +} + +/** + * Hook to access native SDK session state. + * + * This hook is only useful when the @clerk/expo native plugin is installed. + * Without the plugin, `isAvailable` will be false and session will always be null. + * + * @example + * ```tsx + * import { useNativeSession } from '@clerk/expo'; + * + * function MyComponent() { + * const { isAvailable, isLoading, isSignedIn, user } = useNativeSession(); + * + * if (!isAvailable) { + * // Native plugin not installed, use regular useAuth() instead + * return ; + * } + * + * if (isLoading) { + * return ; + * } + * + * if (isSignedIn) { + * return Welcome {user?.firstName}!; + * } + * + * return ; + * } + * ``` + */ +export function useNativeSession(): UseNativeSessionReturn { + const [isLoading, setIsLoading] = useState(isNativeSupported && !!ClerkExpo); + const [sessionId, setSessionId] = useState(null); + const [user, setUser] = useState(null); + + const refresh = useCallback(async () => { + if (!isNativeSupported || !ClerkExpo?.getSession) { + setIsLoading(false); + return; + } + + try { + setIsLoading(true); + const result = (await ClerkExpo.getSession()) as NativeSessionRawResult | null; + // Normalize: iOS returns { sessionId }, Android returns { session: { id } } + const id = result?.sessionId ?? result?.session?.id ?? null; + setSessionId(id); + setUser(result?.user ?? null); + } catch (error) { + console.log('[useNativeSession] Error fetching native session:', error); + setSessionId(null); + setUser(null); + } finally { + setIsLoading(false); + } + }, []); + + // Check native session on mount + useEffect(() => { + refresh(); + }, [refresh]); + + return { + isAvailable: isNativeSupported && !!ClerkExpo, + isLoading, + isSignedIn: !!sessionId, + sessionId, + user, + refresh, + }; +} diff --git a/packages/expo/src/native/AuthView.tsx b/packages/expo/src/native/AuthView.tsx new file mode 100644 index 00000000000..23cdfd9af73 --- /dev/null +++ b/packages/expo/src/native/AuthView.tsx @@ -0,0 +1,370 @@ +import { useAuth } from '@clerk/react'; +import * as SecureStore from 'expo-secure-store'; +import { useCallback, useEffect, useRef } from 'react'; +import { Platform, StyleSheet, Text, View } from 'react-native'; + +import { getClerkInstance } from '../provider/singleton'; +import NativeClerkAuthView from '../specs/NativeClerkAuthView'; +import NativeClerkModule from '../specs/NativeClerkModule'; +import type { AuthViewProps } from './AuthView.types'; + +// Token cache key used by the Clerk JS SDK (must match createClerkInstance.ts) +const CLERK_CLIENT_JWT_KEY = '__clerk_client_jwt'; + +// Check if native module is supported on this platform +const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android'; + +// Safely get the native module +let ClerkExpo: typeof NativeClerkModule | null = null; +if (isNativeSupported) { + try { + ClerkExpo = NativeClerkModule; + } catch { + ClerkExpo = null; + } +} + +async function syncNativeSession(sessionId: string): Promise { + // Copy the native client's bearer token to the JS SDK's token cache + if (ClerkExpo?.getClientToken) { + const nativeClientToken = await ClerkExpo.getClientToken(); + if (nativeClientToken) { + await SecureStore.setItemAsync(CLERK_CLIENT_JWT_KEY, nativeClientToken, { + keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK, + }); + } + } + + const clerkInstance = getClerkInstance(); + if (!clerkInstance) { + throw new Error('[AuthView] Clerk instance not available'); + } + + // Reload resources using the native client's token + const clerkRecord = clerkInstance as unknown as Record; + if (typeof clerkRecord.__internal_reloadInitialResources === 'function') { + await (clerkRecord.__internal_reloadInitialResources as () => Promise)(); + } + + if (typeof clerkInstance.setActive === 'function') { + await clerkInstance.setActive({ session: sessionId }); + } +} + +/** + * Check if an error indicates the user is already signed in. + * Prefers structured error code, falls back to message matching. + */ +function isAlreadySignedInError(error: Error & { code?: string }): boolean { + if (error.code === 'already_signed_in') { + return true; + } + return /already signed in/i.test(error.message ?? ''); +} + +/** + * A pre-built native authentication component that handles sign-in and sign-up flows. + * + * `AuthView` presents a comprehensive, native UI for authentication powered by: + * - **iOS**: clerk-ios (SwiftUI) - https://github.com/clerk/clerk-ios + * - **Android**: clerk-android (Jetpack Compose) - https://github.com/clerk/clerk-android + * + * @example Modal presentation (default) + * ```tsx + * import { AuthView } from '@clerk/expo/native'; + * + * export default function SignInScreen() { + * return ( + * router.replace('/home')} + * onError={(error) => console.error(error)} + * /> + * ); + * } + * ``` + * + * @example Inline presentation + * ```tsx + * router.replace('/home')} + * /> + * ``` + * + * @see {@link https://clerk.com/docs/components/authentication/sign-in} Clerk Sign-In Documentation + */ +export function AuthView({ + presentation = 'modal', + mode = 'signInOrUp', + isDismissable = true, + onSuccess, + onError, + onDismiss, + style, +}: AuthViewProps) { + if (presentation === 'inline') { + return ( + + ); + } + + return ( + + ); +} + +// MARK: - Modal Presentation + +function ModalPresentation({ + mode, + isDismissable, + onSuccess, + onError, + onDismiss, +}: Pick) { + const { isSignedIn } = useAuth(); + const authCompletedRef = useRef(false); + const initialSignedInRef = useRef(isSignedIn); + const hasStartedRef = useRef(false); + + const onSuccessRef = useRef(onSuccess); + onSuccessRef.current = onSuccess; + const onErrorRef = useRef(onError); + onErrorRef.current = onError; + const onDismissRef = useRef(onDismiss); + onDismissRef.current = onDismiss; + + useEffect(() => { + if (!isNativeSupported || !ClerkExpo?.presentAuth) { + return; + } + + if (authCompletedRef.current) { + return; + } + + if (hasStartedRef.current) { + return; + } + + if (initialSignedInRef.current && isSignedIn) { + authCompletedRef.current = true; + onSuccessRef.current?.(); + return; + } + + if (isSignedIn && !initialSignedInRef.current) { + return; + } + + hasStartedRef.current = true; + + const presentModal = async () => { + if (ClerkExpo?.getSession) { + try { + const nativeSession = (await ClerkExpo.getSession()) as { sessionId?: string } | null; + const sessionId = nativeSession?.sessionId; + if (sessionId) { + if (isSignedIn) { + // JS SDK agrees we're signed in — sync native session and complete + authCompletedRef.current = true; + await syncNativeSession(sessionId); + onSuccessRef.current?.(); + return; + } + // JS SDK is signed out but native has a stale session — clear it + try { + await ClerkExpo.signOut?.(); + } catch { + // Best effort + } + } + } catch { + // Failed to check native session, continue to present modal + } + } + + try { + const result = (await ClerkExpo.presentAuth({ + mode: mode ?? 'signInOrUp', + dismissable: isDismissable ?? true, + })) as { sessionId?: string }; + + if (result.sessionId) { + try { + await syncNativeSession(result.sessionId); + authCompletedRef.current = true; + onSuccessRef.current?.(); + } catch (syncError) { + console.error('[AuthView] Failed to sync session:', syncError); + onErrorRef.current?.(syncError as Error); + } + return; + } + + // Modal was dismissed without completing auth (resolved with no sessionId) + hasStartedRef.current = false; + onDismissRef.current?.(); + } catch (err) { + const error = err as Error & { code?: string }; + + if (isAlreadySignedInError(error)) { + authCompletedRef.current = true; + + if (ClerkExpo?.getSession) { + try { + const nativeSession = (await ClerkExpo.getSession()) as { sessionId?: string } | null; + if (nativeSession?.sessionId) { + await syncNativeSession(nativeSession.sessionId); + onSuccessRef.current?.(); + return; + } + } catch (syncErr) { + console.error('[AuthView] Failed to sync native session:', syncErr); + } + } + } + + // Modal was dismissed (native promise rejected) — reset so remounting works + hasStartedRef.current = false; + onDismissRef.current?.(); + onErrorRef.current?.(error); + } + }; + + presentModal(); + }, [mode, isDismissable, isSignedIn]); + + if (!isNativeSupported || !ClerkExpo) { + return ( + + + {!isNativeSupported + ? 'Native AuthView is only available on iOS and Android' + : 'Native AuthView requires the @clerk/expo plugin. Add "@clerk/expo" to your app.json plugins array.'} + + + ); + } + + return null; +} + +// MARK: - Inline Presentation + +function InlinePresentation({ + mode, + isDismissable, + onSuccess, + onError, + style, +}: Pick) { + const authCompletedRef = useRef(false); + + const onSuccessRef = useRef(onSuccess); + onSuccessRef.current = onSuccess; + const onErrorRef = useRef(onError); + onErrorRef.current = onError; + + const syncSession = useCallback(async (sessionId: string) => { + if (authCompletedRef.current) { + return; + } + + try { + await syncNativeSession(sessionId); + authCompletedRef.current = true; + onSuccessRef.current?.(); + } catch (err) { + console.error('[AuthView] Failed to sync session:', err); + onErrorRef.current?.(err as Error); + } + }, []); + + const handleAuthEvent = useCallback( + async (event: { nativeEvent: { type: string; data: string } }) => { + const { type, data: rawData } = event.nativeEvent; + const data: Record = typeof rawData === 'string' ? JSON.parse(rawData) : rawData; + + if (type === 'signInCompleted' || type === 'signUpCompleted') { + const sessionId = data?.sessionId; + if (sessionId) { + await syncSession(sessionId); + } + } + }, + [syncSession], + ); + + // Fallback: poll native session to detect auth completion + useEffect(() => { + if (!ClerkExpo?.getSession) { + return; + } + + const interval = setInterval(async () => { + if (authCompletedRef.current) { + clearInterval(interval); + return; + } + + try { + const session = (await ClerkExpo.getSession()) as { sessionId?: string } | null; + if (session?.sessionId) { + clearInterval(interval); + await syncSession(session.sessionId); + } + } catch { + // ignore polling errors + } + }, 1500); + + return () => clearInterval(interval); + }, [syncSession]); + + if (!isNativeSupported || !NativeClerkAuthView) { + return ( + + + {!isNativeSupported + ? 'Native AuthView is only available on iOS and Android' + : 'Native AuthView requires the @clerk/expo plugin. Add "@clerk/expo" to your app.json plugins array.'} + + + ); + } + + return ( + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + text: { + fontSize: 16, + color: '#666', + }, +}); diff --git a/packages/expo/src/native/AuthView.types.ts b/packages/expo/src/native/AuthView.types.ts new file mode 100644 index 00000000000..b6677fc29d3 --- /dev/null +++ b/packages/expo/src/native/AuthView.types.ts @@ -0,0 +1,84 @@ +import type { StyleProp, ViewStyle } from 'react-native'; + +/** + * Authentication mode that determines which flows are available to the user. + * + * - `'signInOrUp'` - Allows users to choose between signing in or creating a new account (default) + * - `'signIn'` - Restricts to sign-in flows only + * - `'signUp'` - Restricts to sign-up flows only + */ +export type AuthViewMode = 'signIn' | 'signUp' | 'signInOrUp'; + +/** + * Props for the AuthView component. + * + * AuthView presents a comprehensive native authentication UI that handles + * sign-in and sign-up flows with support for multiple authentication methods. + */ +export interface AuthViewProps { + /** + * How the auth view is presented. + * + * - `'modal'` - Presents a full-screen native modal (default) + * - `'inline'` - Renders directly within the React Native view hierarchy + * + * @default 'modal' + */ + presentation?: 'modal' | 'inline'; + + /** + * Authentication mode that determines which flows are available. + * + * - `'signInOrUp'` - Users can choose between signing in or creating an account + * - `'signIn'` - Only sign-in flows are available + * - `'signUp'` - Only sign-up flows are available + * + * @default 'signInOrUp' + */ + mode?: AuthViewMode; + + /** + * Whether the authentication view can be dismissed by the user. + * + * When `true`, a dismiss button appears in the navigation bar and the modal + * can be dismissed by swiping down or tapping outside (on iOS). + * + * When `false`, the user must complete authentication to close the view. + * Use this for flows where authentication is required to proceed. + * + * @default true + */ + isDismissable?: boolean; + + /** + * Callback fired when authentication completes successfully. + * + * This is called after: + * 1. The user successfully signs in or signs up + * 2. The native session is synced with the JavaScript SDK + * + * After this callback, all `@clerk/expo` hooks (`useUser()`, `useAuth()`, + * `useOrganization()`, etc.) will reflect the authenticated state. + */ + onSuccess?: () => void; + + /** + * Callback fired when an error occurs during authentication. + * + * @param error - The error that occurred + */ + onError?: (error: Error) => void; + + /** + * Callback fired when the modal is dismissed without completing authentication. + * + * Only applies to `presentation="modal"`. Use this to update your UI state + * (e.g., navigate back or show a landing screen). + */ + onDismiss?: () => void; + + /** + * Style applied to the container view (inline mode only). + */ + style?: StyleProp; +} diff --git a/packages/expo/src/native/InlineAuthView.tsx b/packages/expo/src/native/InlineAuthView.tsx new file mode 100644 index 00000000000..ac8a53de206 --- /dev/null +++ b/packages/expo/src/native/InlineAuthView.tsx @@ -0,0 +1,215 @@ +import * as SecureStore from 'expo-secure-store'; +import { useCallback, useEffect, useRef } from 'react'; +import { Platform, type StyleProp, StyleSheet, Text, View, type ViewStyle } from 'react-native'; + +import { getClerkInstance } from '../provider/singleton'; +import NativeClerkAuthView from '../specs/NativeClerkAuthView'; +import NativeClerkModule from '../specs/NativeClerkModule'; +import type { AuthViewMode } from './AuthView.types'; + +// Check if native module is supported on this platform +const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android'; + +// Token cache key used by the Clerk JS SDK (must match createClerkInstance.ts) +const CLERK_CLIENT_JWT_KEY = '__clerk_client_jwt'; + +// Safely get the native module +let ClerkExpoModule: typeof NativeClerkModule | null = null; +if (isNativeSupported) { + try { + ClerkExpoModule = NativeClerkModule; + } catch { + ClerkExpoModule = null; + } +} + +export interface InlineAuthViewProps { + /** + * Authentication mode that determines which flows are available. + * @default 'signInOrUp' + */ + mode?: AuthViewMode; + + /** + * Whether the authentication view can be dismissed by the user. + * @default true + */ + isDismissable?: boolean; + + /** + * Callback fired when authentication completes successfully. + * After this callback, all `@clerk/expo` hooks will reflect the authenticated state. + */ + onSuccess?: () => void; + + /** + * Callback fired when an error occurs during authentication. + */ + onError?: (error: Error) => void; + + /** + * Style applied to the container view. + */ + style?: StyleProp; +} + +/** + * An inline native authentication component that renders in-place (not as a modal). + * + * Unlike `AuthView` which presents a full-screen modal, `InlineAuthView` renders + * directly within your React Native view hierarchy, allowing you to embed the + * native authentication UI anywhere in your layout. + * + * @example + * ```tsx + * import { InlineAuthView } from '@clerk/expo/native'; + * + * export default function SignInScreen() { + * return ( + * + * Welcome + * router.replace('/home')} + * /> + * + * ); + * } + * ``` + */ +export function InlineAuthView({ + mode = 'signInOrUp', + isDismissable = true, + onSuccess, + onError: _onError, + style, +}: InlineAuthViewProps) { + const authCompletedRef = useRef(false); + + // Use stable refs for callbacks + const onSuccessRef = useRef(onSuccess); + onSuccessRef.current = onSuccess; + const onErrorRef = useRef(_onError); + onErrorRef.current = _onError; + + const syncSession = useCallback(async (sessionId: string) => { + if (authCompletedRef.current) { + return; + } + + try { + // The native SDK (clerk-ios/clerk-android) and JS SDK (clerk-js) use separate + // Clerk API clients. The native session won't appear in the JS client's sessions. + // To fix this, we copy the native client's bearer token to the JS SDK's token cache + // so both SDKs use the same Clerk API client. + if (ClerkExpoModule?.getClientToken) { + const nativeClientToken = await ClerkExpoModule.getClientToken(); + if (nativeClientToken) { + await SecureStore.setItemAsync(CLERK_CLIENT_JWT_KEY, nativeClientToken, { + keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK, + }); + } + } + + // Get the raw Clerk instance (not IsomorphicClerk from useClerk()) + // because __internal_reloadInitialResources is stripped from IsomorphicClerk + const clerkInstance = getClerkInstance(); + if (!clerkInstance) { + throw new Error('[InlineAuthView] Clerk instance not available'); + } + + const clerkRecord = clerkInstance as unknown as Record; + + // Reload the client from the API - now using the native client's token, + // so the JS SDK will see the same sessions as the native SDK + if (typeof clerkRecord.__internal_reloadInitialResources === 'function') { + await (clerkRecord.__internal_reloadInitialResources as () => Promise)(); + } + + if (typeof clerkInstance.setActive === 'function') { + await clerkInstance.setActive({ session: sessionId }); + } + + // Mark complete only after successful sync to allow retries on transient failures + authCompletedRef.current = true; + onSuccessRef.current?.(); + } catch (err) { + console.error('[InlineAuthView] Failed to sync session:', err); + onErrorRef.current?.(err as Error); + } + }, []); + + // Handle native events from the view bridge + const handleAuthEvent = useCallback( + async (event: { nativeEvent: { type: string; data: string } }) => { + const { type, data: rawData } = event.nativeEvent; + const data: Record = typeof rawData === 'string' ? JSON.parse(rawData) : rawData; + + if (type === 'signInCompleted' || type === 'signUpCompleted') { + const sessionId = data?.sessionId; + if (sessionId) { + await syncSession(sessionId); + } + } + }, + [syncSession], + ); + + // Fallback: poll native session to detect auth completion + // This handles cases where the native event bridge doesn't fire + useEffect(() => { + if (!ClerkExpoModule?.getSession) { + return; + } + + const interval = setInterval(async () => { + if (authCompletedRef.current) { + clearInterval(interval); + return; + } + + try { + const session = (await ClerkExpoModule.getSession()) as { sessionId?: string } | null; + if (session?.sessionId) { + clearInterval(interval); + await syncSession(session.sessionId); + } + } catch { + // ignore polling errors + } + }, 1500); + + return () => clearInterval(interval); + }, [syncSession]); + + if (!isNativeSupported || !NativeClerkAuthView) { + return ( + + + {!isNativeSupported + ? 'Native InlineAuthView is only available on iOS and Android' + : 'Native InlineAuthView requires the @clerk/expo plugin. Add "@clerk/expo" to your app.json plugins array.'} + + + ); + } + + return ( + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + text: { + fontSize: 16, + color: '#666', + }, +}); diff --git a/packages/expo/src/native/InlineUserProfileView.tsx b/packages/expo/src/native/InlineUserProfileView.tsx new file mode 100644 index 00000000000..fa44c2d1062 --- /dev/null +++ b/packages/expo/src/native/InlineUserProfileView.tsx @@ -0,0 +1,134 @@ +import { useClerk } from '@clerk/react'; +import { useCallback, useRef } from 'react'; +import { Platform, type StyleProp, StyleSheet, Text, View, type ViewStyle } from 'react-native'; + +import NativeClerkModule from '../specs/NativeClerkModule'; +import NativeClerkUserProfileView from '../specs/NativeClerkUserProfileView'; + +// Check if native module is supported on this platform +const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android'; + +// Safely get the native module +let ClerkExpo: typeof NativeClerkModule | null = null; +if (isNativeSupported) { + try { + ClerkExpo = NativeClerkModule; + } catch { + ClerkExpo = null; + } +} + +export interface InlineUserProfileViewProps { + /** + * Whether the profile view can be dismissed by the user. + * @default true + */ + isDismissable?: boolean; + + /** + * Callback fired when the user signs out from the profile view. + * After this callback, `useAuth()` will return `isSignedIn: false`. + */ + onSignOut?: () => void; + + /** + * Callback fired when the user dismisses the profile view. + */ + onDismiss?: () => void; + + /** + * Style applied to the container view. + */ + style?: StyleProp; +} + +/** + * An inline native user profile component that renders in-place (not as a modal). + * + * Unlike `UserProfileView` which presents a full-screen modal, `InlineUserProfileView` + * renders directly within your React Native view hierarchy. + * + * @example + * ```tsx + * import { InlineUserProfileView } from '@clerk/expo/native'; + * + * export default function ProfileScreen() { + * return ( + * router.replace('/sign-in')} + * /> + * ); + * } + * ``` + */ +export function InlineUserProfileView({ + isDismissable = true, + onSignOut, + onDismiss, + style, +}: InlineUserProfileViewProps) { + const clerk = useClerk(); + const signOutTriggered = useRef(false); + + const handleProfileEvent = useCallback( + async (event: { nativeEvent: { type: string; data: string } }) => { + const { type } = event.nativeEvent; + + if (type === 'signedOut' && !signOutTriggered.current) { + signOutTriggered.current = true; + + // Clear native session + try { + await ClerkExpo?.signOut(); + } catch { + // May already be signed out + } + + // Sign out from JS SDK + if (clerk?.signOut) { + try { + await clerk.signOut(); + } catch (err) { + console.warn('[InlineUserProfileView] JS SDK sign out error:', err); + } + } + + onSignOut?.(); + } else if (type === 'dismissed') { + onDismiss?.(); + } + }, + [clerk, onSignOut, onDismiss], + ); + + if (!isNativeSupported || !NativeClerkUserProfileView) { + return ( + + + {!isNativeSupported + ? 'Native InlineUserProfileView is only available on iOS and Android' + : 'Native InlineUserProfileView requires the @clerk/expo plugin. Add "@clerk/expo" to your app.json plugins array.'} + + + ); + } + + return ( + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + text: { + fontSize: 16, + color: '#666', + }, +}); diff --git a/packages/expo/src/native/README.md b/packages/expo/src/native/README.md new file mode 100644 index 00000000000..f602a73993e --- /dev/null +++ b/packages/expo/src/native/README.md @@ -0,0 +1,246 @@ +# Clerk Native iOS Components + +This package provides **complete 1:1 access to all 107 SwiftUI components** from the official [clerk-ios SDK](https://github.com/clerk/clerk-ios) through 3 high-level components. + +## Architecture + +The clerk-ios SDK is architected with 3 public-facing views that internally compose 104+ sub-components: + +### 1. AuthView (SignIn Component) + +**Wraps 35+ internal authentication screens including:** + +- Sign-in flows (email, phone, username, OAuth providers) +- Sign-up flows with verification +- Multi-factor authentication (SMS, TOTP, backup codes) +- Password reset and account recovery +- Passkey authentication +- Alternative authentication methods +- Forgot password flows +- Get help screens + +**Internal Components (automatically included):** + +- `AuthStartView` +- `SignInFactorOneView` +- `SignInFactorOnePasswordView` +- `SignInFactorOnePasskeyView` +- `SignInFactorCodeView` +- `SignInFactorTwoView` +- `SignInFactorTwoBackupCodeView` +- `SignInFactorAlternativeMethodsView` +- `SignInForgotPasswordView` +- `SignInSetNewPasswordView` +- `SignInGetHelpView` +- `SignUpCodeView` +- `SignUpCollectFieldView` +- `SignUpCompleteProfileView` +- Plus 20+ common UI components + +### 2. UserButton + +**Wraps 4+ internal components including:** + +- User avatar display +- User profile popover +- Account switcher (multi-session support) +- Quick sign-out + +**Internal Components (automatically included):** + +- `UserButtonPopover` +- `UserButtonAccountSwitcher` +- `UserPreviewView` +- `UserProfileRowView` + +### 3. UserProfileView + +**Wraps 65+ internal profile management screens including:** + +- Profile information display and editing +- Email address management (add, verify, remove, set primary) +- Phone number management (add, verify, remove, set primary) +- Password management and updates +- MFA settings (SMS, TOTP authenticator apps, backup codes) +- Passkey management (add, rename, remove) +- Connected OAuth accounts management +- Active device sessions management +- Account switching (multi-session mode) +- Delete account +- Sign out + +**Internal Components (automatically included):** + +- `UserProfileDetailView` +- `UserProfileUpdateProfileView` +- `UserProfileSecurityView` +- `UserProfileAddEmailView` +- `UserProfileEmailRow` +- `UserProfileAddPhoneView` +- `UserProfilePhoneRow` +- `UserProfilePasswordSection` +- `UserProfileChangePasswordView` +- `UserProfileMfaSection` +- `UserProfileMfaRow` +- `UserProfileMfaAddSmsView` +- `UserProfileMfaAddTotpView` +- `UserProfileAddMfaView` +- `BackupCodesView` +- `UserProfilePasskeySection` +- `UserProfilePasskeyRow` +- `UserProfilePasskeyRenameView` +- `UserProfileExternalAccountRow` +- `UserProfileAddConnectedAccountView` +- `UserProfileDevicesSection` +- `UserProfileDeviceRow` +- `UserProfileButtonRow` +- `UserProfileDeleteAccountSection` +- `UserProfileDeleteAccountConfirmationView` +- `UserProfileSectionHeader` +- `UserProfileVerifyView` +- Plus 40+ common UI components + +### Common UI Components (19+ files) + +All 3 public components share these internal building blocks: + +- `ClerkTextField` +- `ClerkPhoneNumberField` +- `OTPField` +- `AsyncButton` +- `SocialButton` +- `SocialButtonLayout` +- `ErrorView` +- `ErrorText` +- `HeaderView` +- `DismissButton` +- `AppLogoView` +- `Badge` +- `ClerkFocusedBorder` +- `IdentityPreviewView` +- `OverlayProgressView` +- `SecuredByClerkView` +- `SpinnerView` +- `TextDivider` +- `WrappingHStack` + +### Theme System (10+ files) + +- `ClerkTheme` +- `ClerkColors` +- `ClerkFonts` +- `ClerkDesign` +- `ClerkThemes` +- `PrimaryButtonStyle` +- `SecondaryButtonStyle` +- `NegativeButtonStyle` +- `PressedBackgroundButtonStyle` +- `ClerkButtonConfig` + +## What This Means + +When you import and use these 3 components, you get **full access to ALL 107 files** and every single screen, flow, and feature from clerk-ios: + +```typescript +import { AuthView, UserButton, UserProfileView } from '@clerk/expo/native' + +// This ONE component gives you access to: +// - 15+ sign-in screens +// - 10+ sign-up screens +// - 10+ MFA screens +// - 5+ password reset screens +// - 50+ internal UI components + + +// This ONE component gives you access to: +// - User avatar +// - Profile popover +// - Account switcher +// - 4+ internal components + + +// This ONE component gives you access to: +// - 25+ profile management screens +// - 15+ security settings screens +// - 10+ MFA configuration screens +// - 10+ device management screens +// - 40+ internal UI components + +``` + +## Complete Feature List + +Every single feature from clerk-ios is now available in React Native: + +### Authentication Features + +✅ Email + Password sign-in +✅ Phone number sign-in with SMS OTP +✅ Username sign-in +✅ Email sign-up with verification +✅ Phone sign-up with SMS verification +✅ OAuth providers (Google, Apple, GitHub, etc.) +✅ Passkey authentication (WebAuthn) +✅ Multi-factor authentication (MFA) +✅ SMS-based 2FA +✅ TOTP authenticator apps (Google Authenticator, Authy, etc.) +✅ Backup codes +✅ Password reset flows +✅ Forgot password +✅ Account recovery +✅ Alternative authentication methods + +### Profile Management Features + +✅ View and edit profile information +✅ Update name, username +✅ Manage profile image +✅ Add/remove email addresses +✅ Verify email addresses +✅ Set primary email +✅ Add/remove phone numbers +✅ Verify phone numbers +✅ Set primary phone +✅ Change password +✅ Password strength validation +✅ Enable/disable MFA +✅ Configure SMS 2FA +✅ Configure TOTP 2FA +✅ Generate backup codes +✅ View/download backup codes +✅ Add passkeys +✅ Rename passkeys +✅ Remove passkeys +✅ Connect OAuth accounts +✅ Disconnect OAuth accounts +✅ View active sessions +✅ View devices +✅ Revoke device sessions +✅ Sign out from specific devices +✅ Multi-session support +✅ Account switching +✅ Add accounts +✅ Delete account + +### UI/UX Features + +✅ Clerk's official design system +✅ Light/dark theme support +✅ Customizable themes +✅ Responsive layouts +✅ Native iOS look and feel +✅ Smooth animations +✅ Loading states +✅ Error handling +✅ Form validation +✅ Accessibility support + +## Total Component Count + +- **3 Public Components** (exported from this package) +- **104 Internal Components** (automatically included) +- **107 Total Components** from clerk-ios + +## Usage Examples + +See the `/examples` directory for comprehensive usage examples of all features. diff --git a/packages/expo/src/native/UserButton.tsx b/packages/expo/src/native/UserButton.tsx new file mode 100644 index 00000000000..0e7e2a432c6 --- /dev/null +++ b/packages/expo/src/native/UserButton.tsx @@ -0,0 +1,295 @@ +import { useClerk, useUser } from '@clerk/react'; +import { useEffect, useState } from 'react'; +import type { StyleProp, ViewStyle } from 'react-native'; +import { Image, Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; + +import NativeClerkModule from '../specs/NativeClerkModule'; + +// Check if native module is supported on this platform +const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android'; + +// Raw result from native module (may vary by platform) +interface NativeSessionResult { + sessionId?: string; + session?: { id: string }; + user?: { id: string; firstName?: string; lastName?: string; imageUrl?: string; primaryEmailAddress?: string }; +} + +// Safely get the native module +let ClerkExpo: typeof NativeClerkModule | null = null; +if (isNativeSupported) { + try { + ClerkExpo = NativeClerkModule; + } catch { + ClerkExpo = null; + } +} + +interface NativeUser { + id: string; + firstName?: string; + lastName?: string; + imageUrl?: string; + primaryEmailAddress?: string; +} + +/** + * Props for the UserButton component. + */ +export interface UserButtonProps { + /** + * Custom style for the button container. + */ + style?: StyleProp; + + /** + * Callback fired when the user button is pressed. + * + * This is called immediately when the button is tapped, before the + * profile modal is presented. Use this for analytics or custom behavior. + */ + onPress?: () => void; + + /** + * Callback fired when the user signs out from the profile modal. + * + * This is called after: + * 1. The native session is cleared + * 2. The JS SDK session is cleared + * + * After this callback, `useAuth()` will return `isSignedIn: false`. + */ + onSignOut?: () => void; +} + +/** + * A pre-built native button component that displays the user's avatar and opens their profile. + * + * `UserButton` renders a circular button showing the user's profile image (or initials if + * no image is available). When tapped, it presents the {@link UserProfileView} modal for + * account management. + * + * This component is powered by: + * - **iOS**: clerk-ios (SwiftUI) - https://github.com/clerk/clerk-ios + * - **Android**: clerk-android (Jetpack Compose) - https://github.com/clerk/clerk-android + * + * ## Features + * + * - **Profile Image**: Displays the user's profile photo from their Clerk account + * - **Initials Fallback**: Shows user's initials when no profile image is set + * - **Profile Modal**: Opens {@link UserProfileView} with full account management + * - **Sign Out Handling**: Properly syncs sign-out between native and JS SDKs + * + * ## Avatar Display + * + * The button displays the user's avatar in this order of preference: + * 1. User's profile image from Clerk (if available) + * 2. First letter of first name + first letter of last name + * 3. "U" as a fallback + * + * ## Styling + * + * The button is 36x36 pixels by default with circular border radius. + * You can customize the size using the `style` prop: + * + * ```tsx + * + * ``` + * + * @example Basic usage in a header + * ```tsx + * import { UserButton } from '@clerk/expo/native'; + * + * export default function Header() { + * return ( + * + * My App + * + * + * ); + * } + * ``` + * + * @example With sign-out handling + * ```tsx + * router.replace('/sign-in')} + * style={{ width: 40, height: 40 }} + * /> + * ``` + * + * @example With press tracking + * ```tsx + * analytics.track('profile_opened')} + * onSignOut={() => { + * analytics.track('signed_out'); + * router.replace('/sign-in'); + * }} + * /> + * ``` + * + * @see {@link UserProfileView} The profile view that opens when tapped + * @see {@link https://clerk.com/docs/components/user/user-button} Clerk UserButton Documentation + */ +export function UserButton({ onPress, onSignOut, style }: UserButtonProps) { + const [nativeUser, setNativeUser] = useState(null); + const clerk = useClerk(); + // Use the reactive user hook from clerk-react to observe sign-out state changes + const { user: clerkUser } = useUser(); + + // Fetch native user data on mount and when clerk user changes + useEffect(() => { + const fetchUser = async () => { + if (!isNativeSupported || !ClerkExpo?.getSession) { + return; + } + + try { + const result = (await ClerkExpo.getSession()) as NativeSessionResult | null; + const hasSession = !!(result?.sessionId || result?.session?.id); + if (hasSession && result?.user) { + setNativeUser(result.user); + } else { + // Clear local state if no native session + setNativeUser(null); + } + } catch (err) { + console.error('[UserButton] Error fetching user:', err); + } + }; + + fetchUser(); + }, [clerkUser?.id]); // Re-fetch when clerk user changes (including sign-out) + + // Derive the user to display - prefer native data, fall back to clerk-react data + const user: NativeUser | null = + nativeUser ?? + (clerkUser + ? { + id: clerkUser.id, + firstName: clerkUser.firstName ?? undefined, + lastName: clerkUser.lastName ?? undefined, + imageUrl: clerkUser.imageUrl ?? undefined, + primaryEmailAddress: clerkUser.primaryEmailAddress?.emailAddress, + } + : null); + + const handlePress = async () => { + onPress?.(); + + if (!isNativeSupported || !ClerkExpo?.presentUserProfile) { + return; + } + + try { + await ClerkExpo.presentUserProfile({ + dismissable: true, + }); + + // Check if native session still exists after modal closes + // If session is null, user signed out from the native UI + const sessionCheck = (await ClerkExpo.getSession?.()) as NativeSessionResult | null; + const hasNativeSession = !!(sessionCheck?.sessionId || sessionCheck?.session?.id); + + if (!hasNativeSession) { + // Clear local state immediately for instant UI feedback + setNativeUser(null); + + // Clear native session explicitly (may already be cleared, but ensure it) + try { + await ClerkExpo.signOut?.(); + } catch { + // May already be signed out + } + + // Sign out from JS SDK to update isSignedIn state + if (clerk?.signOut) { + try { + await clerk.signOut(); + } catch { + // Even if signOut throws, try to force reload to clear stale state + const clerkRecord = clerk as unknown as Record; + if (typeof clerkRecord.__internal_reloadInitialResources === 'function') { + try { + await (clerkRecord.__internal_reloadInitialResources as () => Promise)(); + } catch { + // Best effort + } + } + } + } + + onSignOut?.(); + } + } catch { + // Modal was dismissed by the user — not an error + } + }; + + // Get initials from user name + const getInitials = () => { + if (user?.firstName) { + const first = user.firstName.charAt(0).toUpperCase(); + const last = user.lastName?.charAt(0).toUpperCase() || ''; + return first + last; + } + return 'U'; + }; + + // Show fallback when native modules aren't available + if (!isNativeSupported || !ClerkExpo) { + return ( + + ? + + ); + } + + return ( + + {user?.imageUrl ? ( + + ) : ( + + {getInitials()} + + )} + + ); +} + +const styles = StyleSheet.create({ + button: { + width: 36, + height: 36, + borderRadius: 18, + overflow: 'hidden', + }, + avatar: { + flex: 1, + backgroundColor: '#6366f1', + justifyContent: 'center', + alignItems: 'center', + }, + avatarImage: { + width: '100%', + height: '100%', + borderRadius: 18, + }, + avatarText: { + color: 'white', + fontSize: 14, + fontWeight: '600', + }, + text: { + fontSize: 14, + color: '#666', + }, +}); diff --git a/packages/expo/src/native/UserProfileView.tsx b/packages/expo/src/native/UserProfileView.tsx new file mode 100644 index 00000000000..4306dbf7514 --- /dev/null +++ b/packages/expo/src/native/UserProfileView.tsx @@ -0,0 +1,289 @@ +import { useAuth, useClerk } from '@clerk/react'; +import { useCallback, useEffect, useRef } from 'react'; +import type { StyleProp, ViewStyle } from 'react-native'; +import { Platform, StyleSheet, Text, View } from 'react-native'; + +import NativeClerkModule from '../specs/NativeClerkModule'; +import NativeClerkUserProfileView from '../specs/NativeClerkUserProfileView'; + +// Check if native module is supported on this platform +const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android'; + +// Safely get the native module +let ClerkExpo: typeof NativeClerkModule | null = null; +if (isNativeSupported) { + try { + ClerkExpo = NativeClerkModule; + } catch { + ClerkExpo = null; + } +} + +/** + * Props for the UserProfileView component. + */ +export interface UserProfileViewProps { + /** + * How the profile view is presented. + * + * - `'modal'` - Presents a full-screen native modal (default) + * - `'inline'` - Renders directly within the React Native view hierarchy + * + * @default 'modal' + */ + presentation?: 'modal' | 'inline'; + + /** + * Whether the profile view can be dismissed by the user. + * + * When `true`, a dismiss button appears in the navigation bar and the modal + * can be dismissed by swiping down or tapping outside (on iOS). + * + * When `false`, the user must use the sign-out action to close the view. + * + * @default true + */ + isDismissable?: boolean; + + /** + * Callback fired when the user signs out from the profile view. + * + * This is called after: + * 1. The native session is cleared + * 2. The JS SDK session is cleared + * + * After this callback, `useAuth()` will return `isSignedIn: false`. + */ + onSignOut?: () => void; + + /** + * Callback fired when the user dismisses the profile view (inline mode only). + */ + onDismiss?: () => void; + + /** + * Style applied to the container view. + */ + style?: StyleProp; +} + +/** + * A pre-built native component for managing the user's profile and account settings. + * + * `UserProfileView` presents a comprehensive, native UI for account management powered by: + * - **iOS**: clerk-ios (SwiftUI) - https://github.com/clerk/clerk-ios + * - **Android**: clerk-android (Jetpack Compose) - https://github.com/clerk/clerk-android + * + * @example Modal presentation (default) + * ```tsx + * import { UserProfileView } from '@clerk/expo/native'; + * + * export default function ProfileScreen() { + * return ( + * router.replace('/sign-in')} + * /> + * ); + * } + * ``` + * + * @example Inline presentation + * ```tsx + * router.replace('/sign-in')} + * /> + * ``` + * + * @see {@link https://clerk.com/docs/components/user/user-profile} Clerk UserProfile Documentation + */ +export function UserProfileView({ + presentation = 'modal', + isDismissable = true, + onSignOut, + onDismiss, + style, + ...props +}: UserProfileViewProps) { + if (presentation === 'inline') { + return ( + + ); + } + + return ( + + ); +} + +// MARK: - Modal Presentation + +function ModalPresentation({ + isDismissable, + onSignOut, + onDismiss, + style, + ...props +}: Omit) { + const clerk = useClerk(); + const { isSignedIn } = useAuth(); + const signOutTriggered = useRef(false); + + const onSignOutRef = useRef(onSignOut); + onSignOutRef.current = onSignOut; + const onDismissRef = useRef(onDismiss); + onDismissRef.current = onDismiss; + const clerkRef = useRef(clerk); + clerkRef.current = clerk; + const isSignedInRef = useRef(isSignedIn); + isSignedInRef.current = isSignedIn; + + useEffect(() => { + signOutTriggered.current = false; + }, []); + + useEffect(() => { + if (!isNativeSupported || !ClerkExpo?.presentUserProfile) { + return; + } + + const presentModal = async () => { + try { + await ClerkExpo.presentUserProfile({ + dismissable: isDismissable ?? true, + }); + + const sessionCheck = (await ClerkExpo.getSession?.()) as { session?: { id: string } } | null; + const hasNativeSession = !!sessionCheck?.session; + + if (!hasNativeSession && !signOutTriggered.current) { + signOutTriggered.current = true; + + try { + await ClerkExpo.signOut?.(); + } catch { + // May already be signed out + } + + const currentClerk = clerkRef.current; + if (currentClerk?.signOut) { + try { + await currentClerk.signOut(); + } catch (signOutErr) { + console.warn('[UserProfileView] JS SDK sign out error:', signOutErr); + } + } + + onSignOutRef.current?.(); + } + } catch { + // Modal was dismissed by the user + onDismissRef.current?.(); + } + }; + + void presentModal(); + }, [isDismissable]); + + if (!isNativeSupported || !ClerkExpo) { + return ( + + + {!isNativeSupported + ? 'Native UserProfileView is only available on iOS and Android' + : 'Native UserProfileView requires the @clerk/expo plugin. Add "@clerk/expo" to your app.json plugins array.'} + + + ); + } + + return null; +} + +// MARK: - Inline Presentation + +function InlinePresentation({ + isDismissable, + onSignOut, + onDismiss, + style, +}: Pick) { + const clerk = useClerk(); + const signOutTriggered = useRef(false); + + const handleProfileEvent = useCallback( + async (event: { nativeEvent: { type: string; data: string } }) => { + const { type } = event.nativeEvent; + + if (type === 'signedOut' && !signOutTriggered.current) { + signOutTriggered.current = true; + + try { + await ClerkExpo?.signOut(); + } catch { + // May already be signed out + } + + if (clerk?.signOut) { + try { + await clerk.signOut(); + } catch (err) { + console.warn('[UserProfileView] JS SDK sign out error:', err); + } + } + + onSignOut?.(); + } else if (type === 'dismissed') { + onDismiss?.(); + } + }, + [clerk, onSignOut, onDismiss], + ); + + if (!isNativeSupported || !NativeClerkUserProfileView) { + return ( + + + {!isNativeSupported + ? 'Native UserProfileView is only available on iOS and Android' + : 'Native UserProfileView requires the @clerk/expo plugin. Add "@clerk/expo" to your app.json plugins array.'} + + + ); + } + + return ( + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + text: { + fontSize: 16, + color: '#666', + }, +}); diff --git a/packages/expo/src/native/index.ts b/packages/expo/src/native/index.ts new file mode 100644 index 00000000000..73aadb8b186 --- /dev/null +++ b/packages/expo/src/native/index.ts @@ -0,0 +1,36 @@ +/** + * Native UI components for Clerk authentication in Expo apps. + * + * These components provide pre-built, native authentication experiences powered by: + * - **iOS**: clerk-ios (SwiftUI) - https://github.com/clerk/clerk-ios + * - **Android**: clerk-android (Jetpack Compose) - https://github.com/clerk/clerk-android + * + * ## Installation + * + * Native components require the `@clerk/expo` plugin to be configured in your `app.json`: + * + * ```json + * { + * "expo": { + * "plugins": ["@clerk/expo"] + * } + * } + * ``` + * + * Then run `npx expo prebuild` to generate native code. + * + * ## Components + * + * - {@link AuthView} - Authentication flow (sign-in/sign-up), supports `presentation="modal"` (default) or `presentation="inline"` + * - {@link UserProfileView} - User profile and account management, supports `presentation="modal"` (default) or `presentation="inline"` + * - {@link UserButton} - Avatar button that opens profile + * + * @module @clerk/expo/native + */ + +export { AuthView } from './AuthView'; +export type { AuthViewProps, AuthViewMode } from './AuthView.types'; +export { UserButton } from './UserButton'; +export type { UserButtonProps } from './UserButton'; +export { UserProfileView } from './UserProfileView'; +export type { UserProfileViewProps } from './UserProfileView'; diff --git a/packages/expo/src/plugin/withClerkExpo.ts b/packages/expo/src/plugin/withClerkExpo.ts index d342ef370b4..7e538ff6ce2 100644 --- a/packages/expo/src/plugin/withClerkExpo.ts +++ b/packages/expo/src/plugin/withClerkExpo.ts @@ -1,12 +1,48 @@ -import { type ConfigPlugin, createRunOncePlugin, withInfoPlist } from '@expo/config-plugins'; +import { type ConfigPlugin, createRunOncePlugin, withAppBuildGradle, withInfoPlist } from '@expo/config-plugins'; import pkg from '../../package.json'; /** - * Expo config plugin for @clerk/expo. - * - * This plugin configures the iOS URL scheme required for Google Sign-In. - * The native Android module is automatically linked via expo-module.config.json. + * Add packaging exclusions to Android app build.gradle to resolve + * duplicate META-INF file conflicts from clerk-android dependencies. + */ +const withClerkAndroidPackaging: ConfigPlugin = config => { + return withAppBuildGradle(config, modConfig => { + let buildGradle = modConfig.modResults.contents; + + // Check if exclusion already exists + if (buildGradle.includes('META-INF/versions/9/OSGI-INF/MANIFEST.MF')) { + return modConfig; + } + + // AGP 8+ uses `packaging` DSL, older versions use `packagingOptions` + const packagingMatch = buildGradle.match(/packaging\s*\{/) || buildGradle.match(/packagingOptions\s*\{/); + if (packagingMatch) { + const blockName = packagingMatch[0].trim().replace(/\s*\{$/, ''); + const resourcesExclude = `${blockName} { + // Clerk Android SDK: exclude duplicate META-INF files + resources { + excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF'] + }`; + + buildGradle = buildGradle.replace(new RegExp(`${blockName}\\s*\\{`), resourcesExclude); + modConfig.modResults.contents = buildGradle; + } else { + // No packaging block found; append one at the end of the android block + const androidBlockEnd = buildGradle.lastIndexOf('}'); + if (androidBlockEnd !== -1) { + const packagingBlock = `\n packaging {\n resources {\n excludes += ['META-INF/versions/9/OSGI-INF/MANIFEST.MF']\n }\n }\n`; + buildGradle = buildGradle.slice(0, androidBlockEnd) + packagingBlock + buildGradle.slice(androidBlockEnd); + modConfig.modResults.contents = buildGradle; + } + } + + return modConfig; + }); +}; + +/** + * Configures iOS URL scheme for Google Sign-In. */ const withClerkGoogleSignIn: ConfigPlugin = config => { // Get the iOS URL scheme from environment or config.extra @@ -42,4 +78,20 @@ const withClerkGoogleSignIn: ConfigPlugin = config => { }); }; -export default createRunOncePlugin(withClerkGoogleSignIn, pkg.name, pkg.version); +/** + * Combined plugin that applies all Clerk configurations. + * + * When this plugin is used, it: + * 1. Configures iOS URL scheme for Google Sign-In (if env var is set) + * 2. Adds Android packaging exclusions to resolve dependency conflicts + * + * Native modules are registered via react-native.config.js and standard + * React Native autolinking (RCTViewManager / ReactPackage). + */ +const withClerkExpo: ConfigPlugin = config => { + config = withClerkGoogleSignIn(config); + config = withClerkAndroidPackaging(config); + return config; +}; + +export default createRunOncePlugin(withClerkExpo, pkg.name, pkg.version); diff --git a/packages/expo/src/provider/ClerkProvider.tsx b/packages/expo/src/provider/ClerkProvider.tsx index d76e3fa541c..97b575c0b53 100644 --- a/packages/expo/src/provider/ClerkProvider.tsx +++ b/packages/expo/src/provider/ClerkProvider.tsx @@ -2,9 +2,14 @@ import '../polyfills'; import type { ClerkProviderProps as ReactClerkProviderProps } from '@clerk/react'; import { InternalClerkProvider as ClerkReactProvider, type Ui } from '@clerk/react/internal'; +import * as SecureStore from 'expo-secure-store'; import * as WebBrowser from 'expo-web-browser'; +import { useEffect, useRef } from 'react'; +import { Platform } from 'react-native'; import type { TokenCache } from '../cache/types'; +import { useNativeAuthEvents } from '../hooks/useNativeAuthEvents'; +import NativeClerkModule from '../specs/NativeClerkModule'; import { isNative, isWeb } from '../utils/runtime'; import { getClerkInstance } from './singleton'; import type { BuildClerkOptions } from './singleton/types'; @@ -59,6 +64,203 @@ export function ClerkProvider(props: ClerkProviderProps(null); + const initStartedRef = useRef(false); + const sessionSyncedRef = useRef(false); + const prevPkRef = useRef(pk); + + // Reset refs when publishable key changes (hot-swap support) + if (prevPkRef.current !== pk) { + prevPkRef.current = pk; + pendingNativeSessionRef.current = null; + initStartedRef.current = false; + sessionSyncedRef.current = false; + } + + // Get the Clerk instance for syncing + const clerkInstance = isNative() + ? getClerkInstance({ + publishableKey: pk, + tokenCache, + __experimental_passkeys, + __experimental_resourceCache, + }) + : null; + + // Track whether the component is still mounted + const isMountedRef = useRef(true); + + // Configure native Clerk SDK and set up session sync callback + useEffect(() => { + isMountedRef.current = true; + + if ((Platform.OS === 'ios' || Platform.OS === 'android') && pk && !initStartedRef.current) { + initStartedRef.current = true; + + const configureNativeClerk = async () => { + try { + const ClerkExpo = NativeClerkModule; + + if (ClerkExpo?.configure) { + // Read the JS SDK's client JWT to sync with the native SDK + let bearerToken: string | null = null; + try { + bearerToken = await SecureStore.getItemAsync('__clerk_client_jwt', { + keychainAccessible: SecureStore.AFTER_FIRST_UNLOCK, + }); + } catch { + // SecureStore may not be available + } + await ClerkExpo.configure(pk, bearerToken); + + if (!isMountedRef.current) { + return; + } + + // Poll for native session (matching iOS's 3-second max wait) + const MAX_WAIT_MS = 3000; + const POLL_INTERVAL_MS = 100; + let sessionId: string | null = null; + + for (let elapsed = 0; elapsed < MAX_WAIT_MS; elapsed += POLL_INTERVAL_MS) { + if (!isMountedRef.current) { + return; + } + if (ClerkExpo?.getSession) { + const nativeSession = (await ClerkExpo.getSession()) as { + sessionId?: string; + session?: { id: string }; + } | null; + // Normalize: iOS returns { sessionId }, Android returns { session: { id } } + sessionId = nativeSession?.sessionId ?? nativeSession?.session?.id ?? null; + if (sessionId) { + break; + } + } + await new Promise(resolve => setTimeout(resolve, POLL_INTERVAL_MS)); + } + + if (!isMountedRef.current) { + return; + } + + if (sessionId && clerkInstance) { + pendingNativeSessionRef.current = sessionId; + + // Wait for clerk to be loaded before syncing + const clerkAny = clerkInstance as any; + + const waitForLoad = (): Promise => { + return new Promise(resolve => { + if (clerkAny.loaded) { + resolve(); + } else if (typeof clerkAny.addOnLoaded === 'function') { + clerkAny.addOnLoaded(() => resolve()); + } else { + if (__DEV__) { + console.warn('[ClerkProvider] Clerk instance has no loaded property or addOnLoaded method'); + } + resolve(); + } + }); + }; + + await waitForLoad(); + + if (!isMountedRef.current) { + return; + } + + if (!sessionSyncedRef.current && typeof clerkInstance.setActive === 'function') { + sessionSyncedRef.current = true; + const pendingSession = pendingNativeSessionRef.current; + + // If the native session is not in the client's sessions list, + // reload the client from the API so setActive can find it. + const sessionInClient = clerkInstance.client?.sessions?.some( + (s: { id: string }) => s.id === pendingSession, + ); + if (!sessionInClient && typeof clerkAny.__internal_reloadInitialResources === 'function') { + await clerkAny.__internal_reloadInitialResources(); + } + + try { + await clerkInstance.setActive({ session: pendingSession }); + } catch (err) { + console.error(`[ClerkProvider] Failed to sync native session:`, err); + } + } + } + } + } catch (error) { + const isNativeModuleNotFound = + error instanceof Error && + (error.message.includes('Cannot find native module') || + error.message.includes("TurboModuleRegistry.getEnforcing(...): 'ClerkExpo'")); + if (isNativeModuleNotFound) { + if (__DEV__) { + console.debug( + `[ClerkProvider] Native Clerk module not available. ` + + `To enable native features, add "@clerk/expo" to your app.json plugins array.`, + ); + } + } else { + console.error(`[ClerkProvider] Failed to configure Clerk ${Platform.OS}:`, error); + } + } + }; + configureNativeClerk(); + } + + return () => { + isMountedRef.current = false; + }; + }, [pk, clerkInstance]); + + // Listen for native auth state changes and sync to JS SDK + const { nativeAuthState } = useNativeAuthEvents(); + + useEffect(() => { + if (!nativeAuthState || !clerkInstance) { + return; + } + + const syncNativeAuthToJs = async () => { + try { + if (nativeAuthState.type === 'signedIn' && nativeAuthState.sessionId && clerkInstance.setActive) { + // Ensure the session exists in the client before calling setActive + const sessionInClient = clerkInstance.client?.sessions?.some( + (s: { id: string }) => s.id === nativeAuthState.sessionId, + ); + if (!sessionInClient) { + const clerkAny = clerkInstance as any; + if (typeof clerkAny.__internal_reloadInitialResources === 'function') { + await clerkAny.__internal_reloadInitialResources(); + } + if (!isMountedRef.current) { + return; + } + } + + if (!isMountedRef.current) { + return; + } + await clerkInstance.setActive({ session: nativeAuthState.sessionId }); + } else if (nativeAuthState.type === 'signedOut' && clerkInstance.signOut) { + if (!isMountedRef.current) { + return; + } + await clerkInstance.signOut(); + } + } catch (error) { + console.error(`[ClerkProvider] Failed to sync native auth state:`, error); + } + }; + + syncNativeAuthToJs(); + }, [nativeAuthState, clerkInstance]); + if (isWeb()) { // This is needed in order for useOAuth to work correctly on web. WebBrowser.maybeCompleteAuthSession(); @@ -72,16 +274,7 @@ export function ClerkProvider(props: ClerkProviderProps + isClerkRuntimeError(err) && err.code === 'network_error'; + const retryInitilizeResourcesFromFAPI = async () => { - const isClerkNetworkError = (err: unknown) => isClerkRuntimeError(err) && err.code === 'network_error'; try { await __internal_clerk?.__internal_reloadInitialResources(); - } catch (err) { + } catch (err: unknown) { // Retry after 3 seconds if the error is a network error or a 5xx error if (isClerkNetworkError(err) || !is4xxError(err)) { // Retry after 2 seconds if the error is a network error @@ -123,9 +125,12 @@ export function createClerkInstance(ClerkClass: typeof Clerk) { ClientResourceCache.init({ publishableKey, storage: createResourceCache }); SessionJWTCache.init({ publishableKey, storage: createResourceCache }); - __internal_clerk.addListener(({ client }) => { + // At this point __internal_clerk is guaranteed to be defined (just created above) + + const clerk = __internal_clerk; + clerk.addListener(({ client }) => { // @ts-expect-error - This is an internal API - const environment = __internal_clerk?.__internal_environment as EnvironmentResource; + const environment = clerk?.__internal_environment as EnvironmentResource; if (environment) { void EnvironmentResourceCache.save(environment.__internal_toSnapshot()); } @@ -144,7 +149,7 @@ export function createClerkInstance(ClerkClass: typeof Clerk) { } }); - __internal_clerk.__internal_getCachedResources = async (): Promise<{ + clerk.__internal_getCachedResources = async (): Promise<{ client: ClientJSONSnapshot | null; environment: EnvironmentJSONSnapshot | null; }> => { @@ -195,6 +200,7 @@ export function createClerkInstance(ClerkClass: typeof Clerk) { } }); } + // At this point __internal_clerk is guaranteed to be defined return __internal_clerk; }; } diff --git a/packages/expo/src/provider/singleton/singleton.web.ts b/packages/expo/src/provider/singleton/singleton.web.ts index 3a307f6350b..e931cabe7bb 100644 --- a/packages/expo/src/provider/singleton/singleton.web.ts +++ b/packages/expo/src/provider/singleton/singleton.web.ts @@ -2,6 +2,13 @@ import type { BrowserClerk, HeadlessBrowserClerk } from '@clerk/react'; import type { BuildClerkOptions } from './types'; +// Augment the global Window type to include Clerk +declare global { + interface Window { + Clerk?: HeadlessBrowserClerk | BrowserClerk; + } +} + /** * Access the existing Clerk instance from `window.Clerk` on the web. * Unlike the native implementation, this does not create a new instance—it only returns the existing one set by ClerkProvider. diff --git a/packages/expo/src/specs/NativeClerkAuthView.ts b/packages/expo/src/specs/NativeClerkAuthView.ts new file mode 100644 index 00000000000..666b2d7c31e --- /dev/null +++ b/packages/expo/src/specs/NativeClerkAuthView.ts @@ -0,0 +1,15 @@ +// eslint-disable-next-line simple-import-sort/imports, import/namespace, import/default, import/no-named-as-default, import/no-named-as-default-member +import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'; +import type { HostComponent, ViewProps } from 'react-native'; +// eslint-disable-next-line import/namespace +import type { BubblingEventHandler } from 'react-native/Libraries/Types/CodegenTypes'; + +type AuthEvent = Readonly<{ type: string; data: string }>; + +interface NativeProps extends ViewProps { + mode?: string; + isDismissable?: boolean; + onAuthEvent?: BubblingEventHandler; +} + +export default codegenNativeComponent('ClerkAuthView') as HostComponent; diff --git a/packages/expo/src/specs/NativeClerkGoogleSignIn.ts b/packages/expo/src/specs/NativeClerkGoogleSignIn.ts new file mode 100644 index 00000000000..44206cdd304 --- /dev/null +++ b/packages/expo/src/specs/NativeClerkGoogleSignIn.ts @@ -0,0 +1,12 @@ +import type { TurboModule } from 'react-native'; +import { TurboModuleRegistry } from 'react-native'; + +export interface Spec extends TurboModule { + configure(params: object): void; + signIn(params: object | null): Promise; + createAccount(params: object | null): Promise; + presentExplicitSignIn(params: object | null): Promise; + signOut(): Promise; +} + +export default TurboModuleRegistry.getEnforcing('ClerkGoogleSignIn'); diff --git a/packages/expo/src/specs/NativeClerkModule.ts b/packages/expo/src/specs/NativeClerkModule.ts new file mode 100644 index 00000000000..e3289c2c638 --- /dev/null +++ b/packages/expo/src/specs/NativeClerkModule.ts @@ -0,0 +1,13 @@ +import type { TurboModule } from 'react-native'; +import { TurboModuleRegistry } from 'react-native'; + +export interface Spec extends TurboModule { + configure(publishableKey: string, bearerToken: string | null): Promise; + presentAuth(options: object): Promise; + presentUserProfile(options: object): Promise; + getSession(): Promise; + getClientToken(): Promise; + signOut(): Promise; +} + +export default TurboModuleRegistry.getEnforcing('ClerkExpo'); diff --git a/packages/expo/src/specs/NativeClerkUserProfileView.ts b/packages/expo/src/specs/NativeClerkUserProfileView.ts new file mode 100644 index 00000000000..0526927613c --- /dev/null +++ b/packages/expo/src/specs/NativeClerkUserProfileView.ts @@ -0,0 +1,14 @@ +// eslint-disable-next-line simple-import-sort/imports, import/namespace, import/default, import/no-named-as-default, import/no-named-as-default-member +import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'; +import type { HostComponent, ViewProps } from 'react-native'; +// eslint-disable-next-line import/namespace +import type { BubblingEventHandler } from 'react-native/Libraries/Types/CodegenTypes'; + +type ProfileEvent = Readonly<{ type: string; data: string }>; + +interface NativeProps extends ViewProps { + isDismissable?: boolean; + onProfileEvent?: BubblingEventHandler; +} + +export default codegenNativeComponent('ClerkUserProfileView') as HostComponent; diff --git a/packages/expo/tsconfig.declarations.json b/packages/expo/tsconfig.declarations.json index 30037049bb1..ac04a85ce27 100644 --- a/packages/expo/tsconfig.declarations.json +++ b/packages/expo/tsconfig.declarations.json @@ -11,5 +11,6 @@ "sourceMap": false, "declarationDir": "./dist" }, - "exclude": ["**/__tests__/**/*"] + "include": ["src"], + "exclude": ["**/__tests__/**/*", "app.plugin.js"] } diff --git a/packages/expo/tsconfig.json b/packages/expo/tsconfig.json index 193a7812407..46556b4b9f3 100644 --- a/packages/expo/tsconfig.json +++ b/packages/expo/tsconfig.json @@ -23,5 +23,5 @@ "incremental": true, "moduleSuffixes": [".web", ".ios", ".android", ".native", ""] }, - "include": ["src"] + "include": ["src", "app.plugin.js"] } diff --git a/packages/react/src/isomorphicClerk.ts b/packages/react/src/isomorphicClerk.ts index 3227b5b1b9d..ac92628b2e7 100644 --- a/packages/react/src/isomorphicClerk.ts +++ b/packages/react/src/isomorphicClerk.ts @@ -178,13 +178,15 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { #publishableKey: string; #eventBus = createClerkEventBus(); #stateProxy: StateProxy; + #initialized = false; get publishableKey(): string { return this.#publishableKey; } get loaded(): boolean { - return this.clerkjs?.loaded || false; + // Consider loaded if either clerk is loaded OR we've initialized headlessly + return this.clerkjs?.loaded || this.#initialized; } get status(): ClerkStatus { @@ -277,8 +279,71 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { this.#eventBus.emit(clerkEvents.Status, 'loading'); this.#eventBus.prioritizedOn(clerkEvents.Status, status => (this.#status = status)); - if (this.#publishableKey) { + // Only load entry chunks in standard browser environments (not React Native/headless) + if (this.#publishableKey && this.options.standardBrowser !== false) { void this.getEntryChunks(); + } else if (this.#publishableKey && this.options.Clerk) { + // For React Native/headless: initialize with the provided Clerk instance + void this.loadHeadlessClerk(); + } + } + + /** + * Initialize Clerk for headless/React Native environments where a Clerk instance is provided directly. + */ + private loadHeadlessClerk(): void { + const clerk = isConstructor(this.options.Clerk) + ? new this.options.Clerk(this.#publishableKey, { proxyUrl: this.proxyUrl, domain: this.domain }) + : this.options.Clerk; + + if (!clerk) { + this.#eventBus.emit(clerkEvents.Status, 'error'); + return; + } + + // Helper to finish initialization - marks as ready and triggers re-renders + const finishInit = () => { + this.#initialized = true; + this.clerkjs = clerk; + this.premountMethodCalls.forEach(cb => cb()); + this.premountAddListenerCalls.forEach((listenerExtras, listener) => { + const unsubscribe = clerk.addListener(listener, listenerExtras.options); + listenerExtras.handlers.nativeUnsubscribe = unsubscribe; + }); + + // Emit current state to all listeners so React context gets updated with actual values + // Use null instead of undefined for missing values to signal "loaded but empty" + const currentState = { + client: clerk.client ?? null, + session: clerk.session ?? null, + user: clerk.user ?? null, + organization: clerk.organization ?? null, + }; + if (currentState.client) { + this.premountAddListenerCalls.forEach((_, listener) => { + listener(currentState as Resources); + }); + } + + // Emit status through eventBus + this.#eventBus.emit(clerkEvents.Status, 'ready'); + this.emitLoaded(); + }; + + // Try to load, but finish initialization regardless + if (!clerk.loaded) { + clerk + .load(this.options) + .then(() => finishInit()) + .catch(err => { + if (__DEV__) { + console.error('Clerk: Failed to load:', err); + } + this.#eventBus.emit(clerkEvents.Status, 'error'); + this.emitLoaded(); + }); + } else { + finishInit(); } } diff --git a/packages/upgrade/src/versions/core-3/changes/expo-native-components.md b/packages/upgrade/src/versions/core-3/changes/expo-native-components.md new file mode 100644 index 00000000000..e7123ad111e --- /dev/null +++ b/packages/upgrade/src/versions/core-3/changes/expo-native-components.md @@ -0,0 +1,32 @@ +--- +title: 'Native UI components available via `@clerk/expo/native`' +packages: ['expo'] +matcher: '@clerk/expo' +category: 'feature' +--- + +New native UI components are available for Clerk authentication in Expo apps. These components provide pre-built, native authentication experiences powered by [clerk-ios](https://github.com/clerk/clerk-ios) and [clerk-android](https://github.com/clerk/clerk-android). + +Import from the new `/native` entry point: + +```tsx +import { AuthView, UserButton, UserProfileView } from '@clerk/expo/native'; +``` + +**Available components:** + +- `AuthView` - Authentication flow (sign-in/sign-up) +- `UserButton` - Avatar button that opens profile +- `UserProfileView` - User profile and account management + +**Requirements:** + +1. Add the plugin to your `app.json`: + ```json + { + "expo": { + "plugins": ["@clerk/expo"] + } + } + ``` +2. Run `npx expo prebuild` to generate native code diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5ebfd5c54ac..d2838e7008c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -605,6 +605,9 @@ importers: '@types/base-64': specifier: ^1.0.2 version: 1.0.2 + esbuild: + specifier: ^0.19.0 + version: 0.19.12 expo-apple-authentication: specifier: ^7.2.4 version: 7.2.4(expo@54.0.23(@babel/core@7.28.5)(@modelcontextprotocol/sdk@1.25.2(@cfworker/json-schema@4.1.1)(hono@4.11.7)(zod@3.25.76))(bufferutil@4.0.9)(graphql@16.12.0)(react-native@0.81.5(@babel/core@7.28.5)(@react-native-community/cli@12.3.7(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@18.3.26)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(utf-8-validate@5.0.10))(react-native@0.81.5(@babel/core@7.28.5)(@react-native-community/cli@12.3.7(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@18.3.26)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10)) @@ -620,9 +623,6 @@ importers: expo-local-authentication: specifier: ^13.8.0 version: 13.8.0(expo@54.0.23(@babel/core@7.28.5)(@modelcontextprotocol/sdk@1.25.2(@cfworker/json-schema@4.1.1)(hono@4.11.7)(zod@3.25.76))(bufferutil@4.0.9)(graphql@16.12.0)(react-native@0.81.5(@babel/core@7.28.5)(@react-native-community/cli@12.3.7(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@18.3.26)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(utf-8-validate@5.0.10)) - expo-modules-core: - specifier: ^3.0.0 - version: 3.0.25(react-native@0.81.5(@babel/core@7.28.5)(@react-native-community/cli@12.3.7(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@18.3.26)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1) expo-secure-store: specifier: ^12.8.1 version: 12.8.1(expo@54.0.23(@babel/core@7.28.5)(@modelcontextprotocol/sdk@1.25.2(@cfworker/json-schema@4.1.1)(hono@4.11.7)(zod@3.25.76))(bufferutil@4.0.9)(graphql@16.12.0)(react-native@0.81.5(@babel/core@7.28.5)(@react-native-community/cli@12.3.7(bufferutil@4.0.9)(utf-8-validate@5.0.10))(@types/react@18.3.26)(bufferutil@4.0.9)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(utf-8-validate@5.0.10)) @@ -2279,102 +2279,204 @@ packages: resolution: {integrity: sha512-YAdE/IJSpwbOTiaURNCKECdAwqrJuFiZhylmesBcIRawtYKnBR2wxPhoIewMg+Yu+QuYvHfJNReWpoxGBKOChA==} engines: {node: '>=18'} + '@esbuild/aix-ppc64@0.19.12': + resolution: {integrity: sha512-bmoCYyWdEL3wDQIVbcyzRyeKLgk2WtWLTWz1ZIAZF/EGbNOwSA6ew3PftJ1PqMiOOGu0OyFMzG53L0zqIpPeNA==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + '@esbuild/aix-ppc64@0.25.12': resolution: {integrity: sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==} engines: {node: '>=18'} cpu: [ppc64] os: [aix] + '@esbuild/android-arm64@0.19.12': + resolution: {integrity: sha512-P0UVNGIienjZv3f5zq0DP3Nt2IE/3plFzuaS96vihvD0Hd6H/q4WXUGpCxD/E8YrSXfNyRPbpTq+T8ZQioSuPA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + '@esbuild/android-arm64@0.25.12': resolution: {integrity: sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==} engines: {node: '>=18'} cpu: [arm64] os: [android] + '@esbuild/android-arm@0.19.12': + resolution: {integrity: sha512-qg/Lj1mu3CdQlDEEiWrlC4eaPZ1KztwGJ9B6J+/6G+/4ewxJg7gqj8eVYWvao1bXrqGiW2rsBZFSX3q2lcW05w==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + '@esbuild/android-arm@0.25.12': resolution: {integrity: sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==} engines: {node: '>=18'} cpu: [arm] os: [android] + '@esbuild/android-x64@0.19.12': + resolution: {integrity: sha512-3k7ZoUW6Q6YqhdhIaq/WZ7HwBpnFBlW905Fa4s4qWJyiNOgT1dOqDiVAQFwBH7gBRZr17gLrlFCRzF6jFh7Kew==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + '@esbuild/android-x64@0.25.12': resolution: {integrity: sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==} engines: {node: '>=18'} cpu: [x64] os: [android] + '@esbuild/darwin-arm64@0.19.12': + resolution: {integrity: sha512-B6IeSgZgtEzGC42jsI+YYu9Z3HKRxp8ZT3cqhvliEHovq8HSX2YX8lNocDn79gCKJXOSaEot9MVYky7AKjCs8g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + '@esbuild/darwin-arm64@0.25.12': resolution: {integrity: sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==} engines: {node: '>=18'} cpu: [arm64] os: [darwin] + '@esbuild/darwin-x64@0.19.12': + resolution: {integrity: sha512-hKoVkKzFiToTgn+41qGhsUJXFlIjxI/jSYeZf3ugemDYZldIXIxhvwN6erJGlX4t5h417iFuheZ7l+YVn05N3A==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + '@esbuild/darwin-x64@0.25.12': resolution: {integrity: sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==} engines: {node: '>=18'} cpu: [x64] os: [darwin] + '@esbuild/freebsd-arm64@0.19.12': + resolution: {integrity: sha512-4aRvFIXmwAcDBw9AueDQ2YnGmz5L6obe5kmPT8Vd+/+x/JMVKCgdcRwH6APrbpNXsPz+K653Qg8HB/oXvXVukA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + '@esbuild/freebsd-arm64@0.25.12': resolution: {integrity: sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] + '@esbuild/freebsd-x64@0.19.12': + resolution: {integrity: sha512-EYoXZ4d8xtBoVN7CEwWY2IN4ho76xjYXqSXMNccFSx2lgqOG/1TBPW0yPx1bJZk94qu3tX0fycJeeQsKovA8gg==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + '@esbuild/freebsd-x64@0.25.12': resolution: {integrity: sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==} engines: {node: '>=18'} cpu: [x64] os: [freebsd] + '@esbuild/linux-arm64@0.19.12': + resolution: {integrity: sha512-EoTjyYyLuVPfdPLsGVVVC8a0p1BFFvtpQDB/YLEhaXyf/5bczaGeN15QkR+O4S5LeJ92Tqotve7i1jn35qwvdA==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + '@esbuild/linux-arm64@0.25.12': resolution: {integrity: sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==} engines: {node: '>=18'} cpu: [arm64] os: [linux] + '@esbuild/linux-arm@0.19.12': + resolution: {integrity: sha512-J5jPms//KhSNv+LO1S1TX1UWp1ucM6N6XuL6ITdKWElCu8wXP72l9MM0zDTzzeikVyqFE6U8YAV9/tFyj0ti+w==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + '@esbuild/linux-arm@0.25.12': resolution: {integrity: sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==} engines: {node: '>=18'} cpu: [arm] os: [linux] + '@esbuild/linux-ia32@0.19.12': + resolution: {integrity: sha512-Thsa42rrP1+UIGaWz47uydHSBOgTUnwBwNq59khgIwktK6x60Hivfbux9iNR0eHCHzOLjLMLfUMLCypBkZXMHA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + '@esbuild/linux-ia32@0.25.12': resolution: {integrity: sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==} engines: {node: '>=18'} cpu: [ia32] os: [linux] + '@esbuild/linux-loong64@0.19.12': + resolution: {integrity: sha512-LiXdXA0s3IqRRjm6rV6XaWATScKAXjI4R4LoDlvO7+yQqFdlr1Bax62sRwkVvRIrwXxvtYEHHI4dm50jAXkuAA==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + '@esbuild/linux-loong64@0.25.12': resolution: {integrity: sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==} engines: {node: '>=18'} cpu: [loong64] os: [linux] + '@esbuild/linux-mips64el@0.19.12': + resolution: {integrity: sha512-fEnAuj5VGTanfJ07ff0gOA6IPsvrVHLVb6Lyd1g2/ed67oU1eFzL0r9WL7ZzscD+/N6i3dWumGE1Un4f7Amf+w==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + '@esbuild/linux-mips64el@0.25.12': resolution: {integrity: sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==} engines: {node: '>=18'} cpu: [mips64el] os: [linux] + '@esbuild/linux-ppc64@0.19.12': + resolution: {integrity: sha512-nYJA2/QPimDQOh1rKWedNOe3Gfc8PabU7HT3iXWtNUbRzXS9+vgB0Fjaqr//XNbd82mCxHzik2qotuI89cfixg==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + '@esbuild/linux-ppc64@0.25.12': resolution: {integrity: sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==} engines: {node: '>=18'} cpu: [ppc64] os: [linux] + '@esbuild/linux-riscv64@0.19.12': + resolution: {integrity: sha512-2MueBrlPQCw5dVJJpQdUYgeqIzDQgw3QtiAHUC4RBz9FXPrskyyU3VI1hw7C0BSKB9OduwSJ79FTCqtGMWqJHg==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + '@esbuild/linux-riscv64@0.25.12': resolution: {integrity: sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==} engines: {node: '>=18'} cpu: [riscv64] os: [linux] + '@esbuild/linux-s390x@0.19.12': + resolution: {integrity: sha512-+Pil1Nv3Umes4m3AZKqA2anfhJiVmNCYkPchwFJNEJN5QxmTs1uzyy4TvmDrCRNT2ApwSari7ZIgrPeUx4UZDg==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + '@esbuild/linux-s390x@0.25.12': resolution: {integrity: sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==} engines: {node: '>=18'} cpu: [s390x] os: [linux] + '@esbuild/linux-x64@0.19.12': + resolution: {integrity: sha512-B71g1QpxfwBvNrfyJdVDexenDIt1CiDN1TIXLbhOw0KhJzE78KIFGX6OJ9MrtC0oOqMWf+0xop4qEU8JrJTwCg==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + '@esbuild/linux-x64@0.25.12': resolution: {integrity: sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==} engines: {node: '>=18'} @@ -2387,6 +2489,12 @@ packages: cpu: [arm64] os: [netbsd] + '@esbuild/netbsd-x64@0.19.12': + resolution: {integrity: sha512-3ltjQ7n1owJgFbuC61Oj++XhtzmymoCihNFgT84UAmJnxJfm4sYCiSLTXZtE00VWYpPMYc+ZQmB6xbSdVh0JWA==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + '@esbuild/netbsd-x64@0.25.12': resolution: {integrity: sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==} engines: {node: '>=18'} @@ -2399,6 +2507,12 @@ packages: cpu: [arm64] os: [openbsd] + '@esbuild/openbsd-x64@0.19.12': + resolution: {integrity: sha512-RbrfTB9SWsr0kWmb9srfF+L933uMDdu9BIzdA7os2t0TXhCRjrQyCeOt6wVxr79CKD4c+p+YhCj31HBkYcXebw==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + '@esbuild/openbsd-x64@0.25.12': resolution: {integrity: sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==} engines: {node: '>=18'} @@ -2411,24 +2525,48 @@ packages: cpu: [arm64] os: [openharmony] + '@esbuild/sunos-x64@0.19.12': + resolution: {integrity: sha512-HKjJwRrW8uWtCQnQOz9qcU3mUZhTUQvi56Q8DPTLLB+DawoiQdjsYq+j+D3s9I8VFtDr+F9CjgXKKC4ss89IeA==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + '@esbuild/sunos-x64@0.25.12': resolution: {integrity: sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==} engines: {node: '>=18'} cpu: [x64] os: [sunos] + '@esbuild/win32-arm64@0.19.12': + resolution: {integrity: sha512-URgtR1dJnmGvX864pn1B2YUYNzjmXkuJOIqG2HdU62MVS4EHpU2946OZoTMnRUHklGtJdJZ33QfzdjGACXhn1A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + '@esbuild/win32-arm64@0.25.12': resolution: {integrity: sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==} engines: {node: '>=18'} cpu: [arm64] os: [win32] + '@esbuild/win32-ia32@0.19.12': + resolution: {integrity: sha512-+ZOE6pUkMOJfmxmBZElNOx72NKpIa/HFOMGzu8fqzQJ5kgf6aTGrcJaFsNiVMH4JKpMipyK+7k0n2UXN7a8YKQ==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + '@esbuild/win32-ia32@0.25.12': resolution: {integrity: sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==} engines: {node: '>=18'} cpu: [ia32] os: [win32] + '@esbuild/win32-x64@0.19.12': + resolution: {integrity: sha512-T1QyPSDCyMXaO3pzBkF96E8xMkiRYbUEZADd29SyPGabqxMViNoii+NcK7eWJAEoU6RZyEm5lVSIjTmcdoB9HA==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + '@esbuild/win32-x64@0.25.12': resolution: {integrity: sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==} engines: {node: '>=18'} @@ -2475,7 +2613,7 @@ packages: '@expo/bunyan@4.0.1': resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==} - engines: {node: '>=0.10.0'} + engines: {'0': node >=0.10.0} '@expo/cli@0.22.26': resolution: {integrity: sha512-I689wc8Fn/AX7aUGiwrh3HnssiORMJtR2fpksX+JIe8Cj/EDleblYMSwRPd0025wrwOV9UN1KM/RuEt/QjCS3Q==} @@ -7910,6 +8048,11 @@ packages: resolution: {integrity: sha512-lNjylaAsJMprYg28zjUyBivP3y0ms9b7RJZ5tdhDUFLa3sCbqZw4wDnbFUSmnyZYWhCYDPxxp7KkXM2TXGw3PQ==} engines: {node: '>=v14.0.0', npm: '>=7.0.0'} + esbuild@0.19.12: + resolution: {integrity: sha512-aARqgq8roFBj054KvQr5f1sFu0D65G+miZRCuJyJ0G13Zwx7vRar5Zhn2tkQNzIXcBrNVsv/8stehpj+GAjgbg==} + engines: {node: '>=12'} + hasBin: true + esbuild@0.25.12: resolution: {integrity: sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==} engines: {node: '>=18'} @@ -16773,81 +16916,150 @@ snapshots: esquery: 1.6.0 jsdoc-type-pratt-parser: 4.1.0 + '@esbuild/aix-ppc64@0.19.12': + optional: true + '@esbuild/aix-ppc64@0.25.12': optional: true + '@esbuild/android-arm64@0.19.12': + optional: true + '@esbuild/android-arm64@0.25.12': optional: true + '@esbuild/android-arm@0.19.12': + optional: true + '@esbuild/android-arm@0.25.12': optional: true + '@esbuild/android-x64@0.19.12': + optional: true + '@esbuild/android-x64@0.25.12': optional: true + '@esbuild/darwin-arm64@0.19.12': + optional: true + '@esbuild/darwin-arm64@0.25.12': optional: true + '@esbuild/darwin-x64@0.19.12': + optional: true + '@esbuild/darwin-x64@0.25.12': optional: true + '@esbuild/freebsd-arm64@0.19.12': + optional: true + '@esbuild/freebsd-arm64@0.25.12': optional: true + '@esbuild/freebsd-x64@0.19.12': + optional: true + '@esbuild/freebsd-x64@0.25.12': optional: true + '@esbuild/linux-arm64@0.19.12': + optional: true + '@esbuild/linux-arm64@0.25.12': optional: true + '@esbuild/linux-arm@0.19.12': + optional: true + '@esbuild/linux-arm@0.25.12': optional: true + '@esbuild/linux-ia32@0.19.12': + optional: true + '@esbuild/linux-ia32@0.25.12': optional: true + '@esbuild/linux-loong64@0.19.12': + optional: true + '@esbuild/linux-loong64@0.25.12': optional: true + '@esbuild/linux-mips64el@0.19.12': + optional: true + '@esbuild/linux-mips64el@0.25.12': optional: true + '@esbuild/linux-ppc64@0.19.12': + optional: true + '@esbuild/linux-ppc64@0.25.12': optional: true + '@esbuild/linux-riscv64@0.19.12': + optional: true + '@esbuild/linux-riscv64@0.25.12': optional: true + '@esbuild/linux-s390x@0.19.12': + optional: true + '@esbuild/linux-s390x@0.25.12': optional: true + '@esbuild/linux-x64@0.19.12': + optional: true + '@esbuild/linux-x64@0.25.12': optional: true '@esbuild/netbsd-arm64@0.25.12': optional: true + '@esbuild/netbsd-x64@0.19.12': + optional: true + '@esbuild/netbsd-x64@0.25.12': optional: true '@esbuild/openbsd-arm64@0.25.12': optional: true + '@esbuild/openbsd-x64@0.19.12': + optional: true + '@esbuild/openbsd-x64@0.25.12': optional: true '@esbuild/openharmony-arm64@0.25.12': optional: true + '@esbuild/sunos-x64@0.19.12': + optional: true + '@esbuild/sunos-x64@0.25.12': optional: true + '@esbuild/win32-arm64@0.19.12': + optional: true + '@esbuild/win32-arm64@0.25.12': optional: true + '@esbuild/win32-ia32@0.19.12': + optional: true + '@esbuild/win32-ia32@0.25.12': optional: true + '@esbuild/win32-x64@0.19.12': + optional: true + '@esbuild/win32-x64@0.25.12': optional: true @@ -24074,6 +24286,32 @@ snapshots: esbuild-plugin-file-path-extensions@2.1.4: {} + esbuild@0.19.12: + optionalDependencies: + '@esbuild/aix-ppc64': 0.19.12 + '@esbuild/android-arm': 0.19.12 + '@esbuild/android-arm64': 0.19.12 + '@esbuild/android-x64': 0.19.12 + '@esbuild/darwin-arm64': 0.19.12 + '@esbuild/darwin-x64': 0.19.12 + '@esbuild/freebsd-arm64': 0.19.12 + '@esbuild/freebsd-x64': 0.19.12 + '@esbuild/linux-arm': 0.19.12 + '@esbuild/linux-arm64': 0.19.12 + '@esbuild/linux-ia32': 0.19.12 + '@esbuild/linux-loong64': 0.19.12 + '@esbuild/linux-mips64el': 0.19.12 + '@esbuild/linux-ppc64': 0.19.12 + '@esbuild/linux-riscv64': 0.19.12 + '@esbuild/linux-s390x': 0.19.12 + '@esbuild/linux-x64': 0.19.12 + '@esbuild/netbsd-x64': 0.19.12 + '@esbuild/openbsd-x64': 0.19.12 + '@esbuild/sunos-x64': 0.19.12 + '@esbuild/win32-arm64': 0.19.12 + '@esbuild/win32-ia32': 0.19.12 + '@esbuild/win32-x64': 0.19.12 + esbuild@0.25.12: optionalDependencies: '@esbuild/aix-ppc64': 0.25.12