diff --git a/.changeset/fix-native-bridge-quality.md b/.changeset/fix-native-bridge-quality.md new file mode 100644 index 00000000000..9a44d458408 --- /dev/null +++ b/.changeset/fix-native-bridge-quality.md @@ -0,0 +1,7 @@ +--- +'@clerk/expo': minor +--- + +- Add native React Native components (AuthView, UserButton, UserProfileView) with `useUserProfileModal()` hook +- Add native Google Sign-In via Credential Manager (Android) and ASAuthorization (iOS) +- Update to Core-3 Signal APIs diff --git a/.gitignore b/.gitignore index dd7130c8543..8ae9cbf415e 100644 --- a/.gitignore +++ b/.gitignore @@ -91,6 +91,10 @@ sessions.pem # pkglab / Verdaccio local registry .verdaccio +# yalc +.yalc/ +yalc.lock + # Release preflight .release-artifacts/ 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..a57de111813 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.6" + clerkAndroidUiVersion = "1.0.9" + 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) @@ -41,18 +54,32 @@ android { kotlinOptions { jvmTarget = "17" + // clerk-android transitive deps (e.g. telemetry-api) still ship Kotlin 2.3.x metadata + freeCompilerArgs += ['-Xskip-metadata-version-check'] + } + + 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 +88,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..acd934830de --- /dev/null +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthActivity.kt @@ -0,0 +1,306 @@ +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 const val CLIENT_SYNC_MAX_ATTEMPTS = 30 + private const val CLIENT_SYNC_INTERVAL_MS = 100L + private const val POLL_INTERVAL_MS = 500L + + 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 - hasInitialSession: ${initialSession != null}, 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 < CLIENT_SYNC_MAX_ATTEMPTS) { + val client = Clerk.client + if (client != null) { + debugLog(TAG, "Client is ready") + isClientReady = true + break + } + delay(CLIENT_SYNC_INTERVAL_MS) + 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(POLL_INTERVAL_MS) + val client = Clerk.client + val signUp = client?.signUp + + if (signUp != null && signUp.id != lastSignUpId) { + lastSignUpId = signUp.id + debugLog(TAG, "New signUp detected, 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..60280542e27 --- /dev/null +++ b/packages/expo/android/src/main/java/expo/modules/clerk/ClerkAuthExpoView.kt @@ -0,0 +1,155 @@ +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" + +private fun debugLog(tag: String, message: String) { + if (BuildConfig.DEBUG) { + Log.d(tag, message) + } +} + +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() { + debugLog(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) { + debugLog(TAG, "Auth completed - session present: true") + 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..f08753c21fe --- /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 - hasSession: ${session != null}, hasUser: ${user != null}") + + 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 - hasSession: ${session != null}, hasUser: ${user != null}") + + 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..db96f1a9097 --- /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 - hasSession: ${Clerk.session != null}, hasUser: ${Clerk.user != null}") + + // 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 - hasSession: ${session != null}, hasUser: ${user != null}") + } + + // 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.js b/packages/expo/app.plugin.js index 65835131de7..f8dca293ce2 100644 --- a/packages/expo/app.plugin.js +++ b/packages/expo/app.plugin.js @@ -1 +1,624 @@ -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, + withEntitlementsPlist, +} = 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: 'exactVersion', + version: 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; + + // --- META-INF exclusion --- + if (!buildGradle.includes('META-INF/versions/9/OSGI-INF/MANIFEST.MF')) { + // 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); + } 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); + } + } + console.log('✅ Clerk Android packaging exclusions added'); + } + + // --- Kotlin metadata version check skip --- + if (!buildGradle.includes('-Xskip-metadata-version-check')) { + const kotlinOptionsMatch = buildGradle.match(/kotlinOptions\s*\{/); + if (kotlinOptionsMatch) { + buildGradle = buildGradle.replace( + /kotlinOptions\s*\{/, + `kotlinOptions {\n // Clerk: allow reading metadata from newer Kotlin versions\n freeCompilerArgs += ['-Xskip-metadata-version-check']`, + ); + } else { + const androidMatch = buildGradle.match(/android\s*\{/); + if (androidMatch) { + buildGradle = buildGradle.replace( + /android\s*\{/, + `android {\n kotlinOptions {\n // Clerk: allow reading metadata from newer Kotlin versions\n freeCompilerArgs += ['-Xskip-metadata-version-check']\n }`, + ); + } + } + console.log('✅ Clerk Android Kotlin metadata version check skip added'); + } + + modConfig.modResults.contents = buildGradle; + 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). + */ +/** + * Write ClerkKeychainService to Info.plist when keychainService is provided. + * This allows extension apps (watch, widget, app clip) to share the same + * keychain entry as the main app by using a custom service identifier. + */ +const withClerkKeychainService = (config, { keychainService } = {}) => { + if (!keychainService) { + return config; + } + + return withInfoPlist(config, modConfig => { + modConfig.modResults.ClerkKeychainService = keychainService; + console.log(`✅ Set ClerkKeychainService in Info.plist: ${keychainService}`); + return modConfig; + }); +}; + +/** + * Add Sign in with Apple entitlement to the iOS app. + * Required for the native Apple Sign In flow via ASAuthorizationController. + */ +const withClerkAppleSignIn = config => { + return withEntitlementsPlist(config, modConfig => { + if (!modConfig.modResults['com.apple.developer.applesignin']) { + modConfig.modResults['com.apple.developer.applesignin'] = ['Default']; + console.log('✅ Added Sign in with Apple entitlement'); + } + return modConfig; + }); +}; + +const withClerkExpo = (config, props = {}) => { + config = withClerkIOS(config); + config = withClerkAppleSignIn(config); + config = withClerkGoogleSignIn(config); + config = withClerkAndroid(config); + config = withClerkKeychainService(config, props); + 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..fbd91f9a91c --- /dev/null +++ b/packages/expo/ios/ClerkExpo.podspec @@ -0,0 +1,46 @@ +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'] + # NOTE: Must match CLERK_MIN_IOS_VERSION in app.plugin.js + 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..eabfb44d685 --- /dev/null +++ b/packages/expo/ios/ClerkExpoModule.swift @@ -0,0 +1,395 @@ +// 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) { + // Use a custom keychain service if configured in Info.plist (for extension apps + // sharing a keychain group). Falls back to the main bundle identifier. + let keychainService: String = { + if let custom = Bundle.main.object(forInfoDictionaryKey: "ClerkKeychainService") as? String, !custom.isEmpty { + return custom + } + return Bundle.main.bundleIdentifier ?? "" + }() + + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: keychainService, + 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..d4a80aa6bc6 --- /dev/null +++ b/packages/expo/ios/ClerkViewFactory.swift @@ -0,0 +1,461 @@ +// ClerkViewFactory - Provides Clerk view controllers to the ClerkExpo module +// This file is injected into the app target by the config plugin. +// It uses `import ClerkKit` (SPM) which is only accessible from the app target. + +import UIKit +import SwiftUI +import Security +import ClerkKit +import ClerkKitUI +import ClerkExpo // Import the pod to access ClerkViewFactoryProtocol + +// MARK: - View Factory Implementation + +public class ClerkViewFactory: ClerkViewFactoryProtocol { + public static let shared = ClerkViewFactory() + + private static let clerkLoadMaxAttempts = 30 + private static let clerkLoadIntervalNs: UInt64 = 100_000_000 + + private init() {} + + /// Resolves the keychain service name, checking ClerkKeychainService in Info.plist first + /// (for extension apps sharing a keychain group), then falling back to the bundle identifier. + private static var keychainService: String? { + if let custom = Bundle.main.object(forInfoDictionaryKey: "ClerkKeychainService") as? String, !custom.isEmpty { + return custom + } + return Bundle.main.bundleIdentifier + } + + // 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..) -> 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(.success(["cancelled": true])) + } + } + + private func completeOnce(_ result: Result<[String: Any], Error>) { + guard !completionCalled else { return } + completionCalled = true + completion(result) + } + + private var initialSessionId: String? = Clerk.shared.session?.id + + 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): + let sessionId = signIn.createdSessionId ?? Clerk.shared.session?.id + if let sessionId, sessionId != self.initialSessionId { + self.completeOnce(.success(["sessionId": sessionId, "type": "signIn"])) + self.dismiss(animated: true) + } + case .signUpCompleted(let signUp): + let sessionId = signUp.createdSessionId ?? Clerk.shared.session?.id + if let sessionId, sessionId != self.initialSessionId { + self.completeOnce(.success(["sessionId": sessionId, "type": "signUp"])) + self.dismiss(animated: true) + } + case .sessionChanged(_, let newSession): + if let sessionId = newSession?.id, sessionId != self.initialSessionId { + self.completeOnce(.success(["sessionId": sessionId, "type": "signIn"])) + self.dismiss(animated: true) + } + default: + break + } + } + } + } +} + +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(.success(["dismissed": true])) + } + } + + 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 + } + } + } + } +} + +struct ClerkProfileWrapperView: View { + let dismissable: Bool + + var body: some View { + UserProfileView(isDismissable: dismissable) + .environment(Clerk.shared) + } +} + +// MARK: - Inline Auth View Wrapper (for embedded rendering) + +struct ClerkInlineAuthWrapperView: View { + let mode: AuthView.Mode + let dismissable: Bool + let onEvent: (String, [String: Any]) -> Void + + // Track initial session to detect new sign-ins (same approach as Android) + @State private var initialSessionId: String? = Clerk.shared.session?.id + @State private var eventSent = false + + private func sendAuthCompleted(sessionId: String, type: String) { + guard !eventSent, sessionId != initialSessionId else { return } + eventSent = true + onEvent(type, ["sessionId": sessionId, "type": type == "signUpCompleted" ? "signUp" : "signIn"]) + } + + var body: some View { + AuthView(mode: mode, isDismissable: dismissable) + .environment(Clerk.shared) + .task { + for await event in Clerk.shared.auth.events { + guard !eventSent else { continue } + switch event { + case .signInCompleted(let signIn): + // Use createdSessionId if available, fall back to current session + let sessionId = signIn.createdSessionId ?? Clerk.shared.session?.id + if let sessionId { sendAuthCompleted(sessionId: sessionId, type: "signInCompleted") } + case .signUpCompleted(let signUp): + let sessionId = signUp.createdSessionId ?? Clerk.shared.session?.id + if let sessionId { sendAuthCompleted(sessionId: sessionId, type: "signUpCompleted") } + case .sessionChanged(_, let newSession): + // Catches auth completion even when signIn/signUp events lack a sessionId + if let sessionId = newSession?.id { sendAuthCompleted(sessionId: sessionId, type: "signInCompleted") } + default: + break + } + } + } + } +} + +// MARK: - Inline Profile View Wrapper (for embedded rendering) + +struct ClerkInlineProfileWrapperView: View { + let dismissable: Bool + let onEvent: (String, [String: Any]) -> Void + + var body: some View { + UserProfileView(isDismissable: dismissable) + .environment(Clerk.shared) + .task { + for await event in Clerk.shared.auth.events { + switch event { + case .signedOut(let session): + onEvent("signedOut", ["sessionId": session.id]) + default: + break + } + } + } + } +} + diff --git a/packages/expo/ios/templates/ClerkViewFactory.swift b/packages/expo/ios/templates/ClerkViewFactory.swift new file mode 100644 index 00000000000..0a96fcdf86c --- /dev/null +++ b/packages/expo/ios/templates/ClerkViewFactory.swift @@ -0,0 +1,442 @@ +// ClerkViewFactory - Provides Clerk view controllers to the ClerkExpo module +// This file is injected into the app target by the config plugin. +// It uses `import ClerkKit` (SPM) which is only accessible from the app target. + +import UIKit +import SwiftUI +import Security +import ClerkKit +import ClerkKitUI +import ClerkExpo // Import the pod to access ClerkViewFactoryProtocol + +// MARK: - View Factory Implementation + +public class ClerkViewFactory: ClerkViewFactoryProtocol { + public static let shared = ClerkViewFactory() + + private static let clerkLoadMaxAttempts = 30 + private static let clerkLoadIntervalNs: UInt64 = 100_000_000 + + private 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..) -> 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 af6223f9149..25a4a6f52ff 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -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", @@ -163,5 +177,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/constants.ts b/packages/expo/src/constants.ts new file mode 100644 index 00000000000..9b36b334fbb --- /dev/null +++ b/packages/expo/src/constants.ts @@ -0,0 +1,3 @@ +// Token cache key used by the Clerk JS SDK for storing the client JWT. +// This must be consistent across all usages (ClerkProvider, native views, etc.) +export const CLERK_CLIENT_JWT_KEY = '__clerk_client_jwt'; diff --git a/packages/expo/src/google-one-tap/ClerkGoogleOneTapSignIn.ts b/packages/expo/src/google-one-tap/ClerkGoogleOneTapSignIn.ts index 5dc89a69982..9e562bc4f79 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,23 +10,14 @@ 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; - -function getNativeModule(): ClerkGoogleSignInNativeModule { - if (!_nativeModule) { - _nativeModule = requireNativeModule('ClerkGoogleSignIn'); +function getNativeModule(): NonNullable { + if (!NativeClerkGoogleSignIn) { + throw new Error( + 'ClerkGoogleSignIn native module is not available. ' + + 'Ensure the @clerk/expo plugin is added to your app.json and you have run a development build.', + ); } - return _nativeModule; + return NativeClerkGoogleSignIn; } /** @@ -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..1b9f82ad707 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,6 @@ export { export * from './useSSO'; export * from './useOAuth'; export * from './useAuth'; +export * from './useNativeSession'; +export * from './useNativeAuthEvents'; +export * from './useUserProfileModal'; 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/hooks/useUserProfileModal.ts b/packages/expo/src/hooks/useUserProfileModal.ts new file mode 100644 index 00000000000..b403d7ec04d --- /dev/null +++ b/packages/expo/src/hooks/useUserProfileModal.ts @@ -0,0 +1,124 @@ +import { useClerk } from '@clerk/react'; +import { useCallback, useRef } 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'; + +// Raw result from the native module (may vary by platform) +type NativeSessionResult = { + sessionId?: string; + session?: { id: string }; +}; + +// Safely get the native module +let ClerkExpo: typeof NativeClerkModule | null = null; +if (isNativeSupported) { + try { + ClerkExpo = NativeClerkModule; + } catch { + ClerkExpo = null; + } +} + +export interface UseUserProfileModalReturn { + /** + * Present the native user profile modal. + * + * The returned promise resolves when the modal is dismissed. + * If the user signed out from within the profile modal, + * the JS SDK session is automatically cleared. + */ + presentUserProfile: () => Promise; + + /** + * Whether the native module supports presenting the profile modal. + */ + isAvailable: boolean; +} + +/** + * Imperative hook for presenting the native user profile modal. + * + * Call `presentUserProfile()` from a button's `onPress` to show the native + * profile management screen (SwiftUI on iOS, Jetpack Compose on Android). + * The promise resolves when the modal is dismissed. + * + * Sign-out is detected automatically — if the user signs out from within + * the profile modal, the JS SDK session is cleared so `useAuth()` updates + * reactively. + * + * @example + * ```tsx + * import { useUserProfileModal } from '@clerk/expo'; + * + * function MyScreen() { + * const { presentUserProfile } = useUserProfileModal(); + * + * return ( + * + * Manage Profile + * + * ); + * } + * ``` + */ +export function useUserProfileModal(): UseUserProfileModalReturn { + const clerk = useClerk(); + const presentingRef = useRef(false); + + const presentUserProfile = useCallback(async () => { + if (presentingRef.current) { + return; + } + + if (!isNativeSupported || !ClerkExpo?.presentUserProfile) { + return; + } + + presentingRef.current = true; + 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 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 { + // Best effort + } + } + } + } catch (error) { + // Dismissal resolves successfully with { dismissed: true }, so reaching + // here means a real native error (E_NOT_INITIALIZED, E_CREATE_FAILED, E_NO_ROOT_VC). + if (__DEV__) { + console.error('[useUserProfileModal] presentUserProfile failed:', error); + } + } finally { + presentingRef.current = false; + } + }, [clerk]); + + return { + presentUserProfile, + isAvailable: isNativeSupported && !!ClerkExpo?.presentUserProfile, + }; +} diff --git a/packages/expo/src/native/AuthView.tsx b/packages/expo/src/native/AuthView.tsx new file mode 100644 index 00000000000..9aa01ce884d --- /dev/null +++ b/packages/expo/src/native/AuthView.tsx @@ -0,0 +1,164 @@ +import { ClerkRuntimeError } from '@clerk/shared/error'; +import { useCallback, useRef } from 'react'; +import { Platform, Text, View } from 'react-native'; + +import { CLERK_CLIENT_JWT_KEY } from '../constants'; +import { getClerkInstance } from '../provider/singleton'; +import NativeClerkAuthView from '../specs/NativeClerkAuthView'; +import NativeClerkModule from '../specs/NativeClerkModule'; +import { tokenCache } from '../token-cache'; +import type { AuthViewProps } from './AuthView.types'; + +// 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 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 (__DEV__) { + console.log( + '[syncNativeSession] getClientToken:', + nativeClientToken ? `${nativeClientToken.slice(0, 20)}...` : 'null', + ); + } + if (nativeClientToken) { + await tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, nativeClientToken); + } + } + + const clerkInstance = getClerkInstance(); + if (!clerkInstance) { + throw new ClerkRuntimeError( + 'Clerk instance is not available. Ensure is mounted before using .', + { code: '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') { + if (__DEV__) { + console.log('[syncNativeSession] reloading initial resources...'); + } + await (clerkRecord.__internal_reloadInitialResources as () => Promise)(); + if (__DEV__) { + console.log('[syncNativeSession] reload complete'); + } + } + + if (typeof clerkInstance.setActive === 'function') { + if (__DEV__) { + console.log('[syncNativeSession] calling setActive with session:', sessionId); + } + await clerkInstance.setActive({ session: sessionId }); + if (__DEV__) { + console.log('[syncNativeSession] setActive complete'); + } + } +} + +/** + * A pre-built native authentication component that handles sign-in and sign-up flows. + * + * `AuthView` renders inline within your React Native view hierarchy, powered by: + * - **iOS**: clerk-ios (SwiftUI) - https://github.com/clerk/clerk-ios + * - **Android**: clerk-android (Jetpack Compose) - https://github.com/clerk/clerk-android + * + * After authentication completes, the session is automatically synced with the JS SDK. + * Use `useAuth()`, `useUser()`, or `useSession()` in a `useEffect` to react to state changes. + * + * @example + * ```tsx + * import { AuthView } from '@clerk/expo/native'; + * import { useAuth } from '@clerk/expo'; + * + * export default function SignInScreen() { + * const { isSignedIn } = useAuth(); + * + * useEffect(() => { + * if (isSignedIn) router.replace('/home'); + * }, [isSignedIn]); + * + * return ; + * } + * ``` + * + * @see {@link https://clerk.com/docs/components/authentication/sign-in} Clerk Sign-In Documentation + */ +export function AuthView({ mode = 'signInOrUp', isDismissable = false }: AuthViewProps) { + const authCompletedRef = useRef(false); + + const syncSession = useCallback(async (sessionId: string) => { + if (authCompletedRef.current) { + return; + } + + if (__DEV__) { + console.log('[AuthView] syncSession called with sessionId:', sessionId); + } + + try { + await syncNativeSession(sessionId); + authCompletedRef.current = true; + if (__DEV__) { + console.log('[AuthView] syncSession succeeded'); + } + } catch (err) { + if (__DEV__) { + console.error('[AuthView] Failed to sync session:', err); + } + } + }, []); + + const handleAuthEvent = useCallback( + async (event: { nativeEvent: { type: string; data: string } }) => { + const { type, data: rawData } = event.nativeEvent; + if (__DEV__) { + console.log('[AuthView] onAuthEvent:', type, rawData); + } + const data: Record = typeof rawData === 'string' ? JSON.parse(rawData) : rawData; + + if (type === 'signInCompleted' || type === 'signUpCompleted') { + const sessionId = data?.sessionId; + if (sessionId) { + await syncSession(sessionId); + } else if (__DEV__) { + console.warn('[AuthView] Auth event received but no sessionId in data:', data); + } + } + }, + [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 ( + + ); +} diff --git a/packages/expo/src/native/AuthView.types.ts b/packages/expo/src/native/AuthView.types.ts new file mode 100644 index 00000000000..2f316488827 --- /dev/null +++ b/packages/expo/src/native/AuthView.types.ts @@ -0,0 +1,40 @@ +/** + * 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 renders a native authentication UI inline (fills parent container). + * Use `useAuth()`, `useUser()`, or `useSession()` in a `useEffect` to react + * to authentication state changes. + */ +export interface AuthViewProps { + /** + * 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. + * + * When `false`, the user must complete authentication to close the view. + * Use this for flows where authentication is required to proceed. + * + * @default false + */ + isDismissable?: boolean; +} diff --git a/packages/expo/src/native/InlineAuthView.tsx b/packages/expo/src/native/InlineAuthView.tsx new file mode 100644 index 00000000000..ad9472751ba --- /dev/null +++ b/packages/expo/src/native/InlineAuthView.tsx @@ -0,0 +1,164 @@ +import { ClerkRuntimeError } from '@clerk/shared/error'; +import { useCallback, useRef } from 'react'; +import { Platform, StyleSheet, Text, View } from 'react-native'; + +import { CLERK_CLIENT_JWT_KEY } from '../constants'; +import { getClerkInstance } from '../provider/singleton'; +import NativeClerkAuthView from '../specs/NativeClerkAuthView'; +import NativeClerkModule from '../specs/NativeClerkModule'; +import { tokenCache } from '../token-cache'; +import type { AuthViewMode } from './AuthView.types'; + +// Check if native module is supported on this platform +const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android'; + +// 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 false + */ + isDismissable?: boolean; +} + +/** + * An inline native authentication component that renders in-place. + * + * `InlineAuthView` renders directly within your React Native view hierarchy, + * allowing you to embed the native authentication UI anywhere in your layout. + * + * After authentication completes, the session is automatically synced with the JS SDK. + * Use `useAuth()`, `useUser()`, or `useSession()` in a `useEffect` to react to state changes. + * + * @example + * ```tsx + * import { InlineAuthView } from '@clerk/expo/native'; + * import { useAuth } from '@clerk/expo'; + * + * export default function SignInScreen() { + * const { isSignedIn } = useAuth(); + * + * useEffect(() => { + * if (isSignedIn) router.replace('/home'); + * }, [isSignedIn]); + * + * return ( + * + * Welcome + * + * + * ); + * } + * ``` + */ +export function InlineAuthView({ mode = 'signInOrUp', isDismissable = false }: InlineAuthViewProps) { + const authCompletedRef = useRef(false); + + const syncSession = useCallback(async (sessionId: string) => { + if (authCompletedRef.current) { + return; + } + + try { + if (ClerkExpoModule?.getClientToken) { + const nativeClientToken = await ClerkExpoModule.getClientToken(); + if (nativeClientToken) { + await tokenCache?.saveToken(CLERK_CLIENT_JWT_KEY, nativeClientToken); + } + } + + const clerkInstance = getClerkInstance(); + if (!clerkInstance) { + throw new ClerkRuntimeError( + 'Clerk instance is not available. Ensure is mounted before using .', + { code: 'clerk_instance_not_available' }, + ); + } + + 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 }); + } + + authCompletedRef.current = true; + } catch (err) { + if (__DEV__) { + console.error('[InlineAuthView] Failed to sync session:', err); + } + } + }, []); + + const handleAuthEvent = useCallback( + async (event: { nativeEvent: { type: string; data: string } }) => { + const { type, data: rawData } = event.nativeEvent; + if (__DEV__) { + console.log('[InlineAuthView] onAuthEvent:', type, rawData); + } + const data: Record = typeof rawData === 'string' ? JSON.parse(rawData) : rawData; + + if (type === 'signInCompleted' || type === 'signUpCompleted') { + const sessionId = data?.sessionId; + if (sessionId) { + await syncSession(sessionId); + } else if (__DEV__) { + console.warn('[InlineAuthView] Auth event received but no sessionId in data:', data); + } + } + }, + [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, + }, + fallback: { + justifyContent: 'center', + alignItems: 'center', + }, + 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..cdb12bc1812 --- /dev/null +++ b/packages/expo/src/native/InlineUserProfileView.tsx @@ -0,0 +1,118 @@ +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 false + */ + isDismissable?: boolean; + + /** + * Style applied to the container view. + */ + style?: StyleProp; +} + +/** + * An inline native user profile component that renders in-place. + * + * `InlineUserProfileView` renders directly within your React Native view hierarchy. + * + * Sign-out is detected automatically and synced with the JS SDK. Use `useAuth()` in a + * `useEffect` to react to sign-out. + * + * @example + * ```tsx + * import { InlineUserProfileView } from '@clerk/expo/native'; + * import { useAuth } from '@clerk/expo'; + * + * export default function ProfileScreen() { + * const { isSignedIn } = useAuth(); + * + * useEffect(() => { + * if (!isSignedIn) router.replace('/sign-in'); + * }, [isSignedIn]); + * + * return ; + * } + * ``` + */ +export function InlineUserProfileView({ isDismissable = false, 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); + } + } + } + }, + [clerk], + ); + + 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..2597bb5e072 --- /dev/null +++ b/packages/expo/src/native/UserButton.tsx @@ -0,0 +1,249 @@ +import { useClerk, useUser } from '@clerk/react'; +import { useEffect, useRef, useState } from 'react'; +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. + */ +// eslint-disable-next-line @typescript-eslint/no-empty-object-type +export interface UserButtonProps {} + +/** + * 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 native profile management modal. + * + * Sign-out is detected automatically and synced with the JS SDK, causing `useAuth()` to + * update reactively. Use `useAuth()` in a `useEffect` to react to sign-out. + * + * @example Basic usage in a header + * ```tsx + * import { UserButton } from '@clerk/expo/native'; + * + * export default function Header() { + * return ( + * + * My App + * + * + * ); + * } + * ``` + * + * @example Reacting to sign-out + * ```tsx + * import { UserButton } from '@clerk/expo/native'; + * import { useAuth } from '@clerk/expo'; + * + * export default function Header() { + * const { isSignedIn } = useAuth(); + * + * useEffect(() => { + * if (!isSignedIn) router.replace('/sign-in'); + * }, [isSignedIn]); + * + * return ; + * } + * ``` + * + * @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(_props: UserButtonProps) { + const [nativeUser, setNativeUser] = useState(null); + const presentingRef = useRef(false); + 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); + } + }; + + void 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 () => { + if (presentingRef.current) { + return; + } + + if (!isNativeSupported || !ClerkExpo?.presentUserProfile) { + return; + } + + presentingRef.current = true; + 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 + } + } + } + } + } + } catch (error) { + // Dismissal resolves successfully with { dismissed: true }, so reaching + // here means a real native error (E_NOT_INITIALIZED, E_CREATE_FAILED, E_NO_ROOT_VC). + if (__DEV__) { + console.error('[UserButton] presentUserProfile failed:', error); + } + } finally { + presentingRef.current = false; + } + }; + + // 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 ( + void handlePress()} + style={styles.button} + > + {user?.imageUrl ? ( + + ) : ( + + {getInitials()} + + )} + + ); +} + +const styles = StyleSheet.create({ + button: { + width: '100%', + height: '100%', + overflow: 'hidden', + }, + avatar: { + flex: 1, + backgroundColor: '#6366f1', + justifyContent: 'center', + alignItems: 'center', + }, + avatarImage: { + width: '100%', + height: '100%', + }, + 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..0215bd4a3af --- /dev/null +++ b/packages/expo/src/native/UserProfileView.tsx @@ -0,0 +1,132 @@ +import { useClerk } from '@clerk/react'; +import { useCallback, 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 { + /** + * Whether the inline profile view shows a dismiss button. + * + * This controls the native view's built-in dismiss button — it does not + * present a modal. To present a native modal, use the `useUserProfileModal()` hook. + * + * @default false + */ + isDismissable?: boolean; + + /** + * Style applied to the container view. + */ + style?: StyleProp; +} + +/** + * A pre-built native component for managing the user's profile and account settings. + * + * `UserProfileView` renders inline within your React Native view hierarchy, powered by: + * - **iOS**: clerk-ios (SwiftUI) - https://github.com/clerk/clerk-ios + * - **Android**: clerk-android (Jetpack Compose) - https://github.com/clerk/clerk-android + * + * To present the profile as a native modal, use the `useUserProfileModal()` hook instead. + * + * Sign-out is detected automatically and synced with the JS SDK. Use `useAuth()` in a + * `useEffect` to react to sign-out. + * + * @example + * ```tsx + * import { UserProfileView } from '@clerk/expo/native'; + * import { useAuth } from '@clerk/expo'; + * + * export default function ProfileScreen() { + * const { isSignedIn } = useAuth(); + * + * useEffect(() => { + * if (!isSignedIn) router.replace('/sign-in'); + * }, [isSignedIn]); + * + * return ; + * } + * ``` + * + * @see {@link https://clerk.com/docs/components/user/user-profile} Clerk UserProfile Documentation + */ +export function UserProfileView({ isDismissable = false, style }: UserProfileViewProps) { + 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); + } + } + } + }, + [clerk], + ); + + 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..8ccd60b6f2c --- /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), renders inline + * - {@link UserProfileView} - User profile and account management, renders inline + * - {@link UserButton} - Avatar button that opens native profile modal + * + * @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..0cddd5ff281 100644 --- a/packages/expo/src/plugin/withClerkExpo.ts +++ b/packages/expo/src/plugin/withClerkExpo.ts @@ -1,12 +1,45 @@ -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; + + // --- META-INF exclusion --- + if (!buildGradle.includes('META-INF/versions/9/OSGI-INF/MANIFEST.MF')) { + // 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); + } 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 +75,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..68a4763bc10 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 WebBrowser from 'expo-web-browser'; +import { useEffect, useRef } from 'react'; +import { Platform } from 'react-native'; import type { TokenCache } from '../cache/types'; +import { CLERK_CLIENT_JWT_KEY } from '../constants'; +import { useNativeAuthEvents } from '../hooks/useNativeAuthEvents'; +import NativeClerkModule from '../specs/NativeClerkModule'; +import { tokenCache as defaultTokenCache } from '../token-cache'; import { isNative, isWeb } from '../utils/runtime'; import { getClerkInstance } from './singleton'; import type { BuildClerkOptions } from './singleton/types'; @@ -59,9 +64,208 @@ export function ClerkProvider(props: ClerkProviderProps(null); + const initStartedRef = useRef(false); + const sessionSyncedRef = useRef(false); + // Reset refs when publishable key changes (hot-swap support) + useEffect(() => { + pendingNativeSessionRef.current = null; + initStartedRef.current = false; + sessionSyncedRef.current = false; + }, [pk]); + + // 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 defaultTokenCache?.getToken(CLERK_CLIENT_JWT_KEY)) ?? null; + } catch { + // Token cache 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); + } + } + }; + void 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); + } + }; + + void syncNativeAuthToJs(); + }, [nativeAuthState, clerkInstance]); + if (isWeb()) { // This is needed in order for useOAuth to work correctly on web. - WebBrowser.maybeCompleteAuthSession(); + // Must stay synchronous during render to catch the redirect URL before children mount. + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const WebBrowser = require('expo-web-browser'); + WebBrowser.maybeCompleteAuthSession(); + } catch { + // expo-web-browser not installed — SSO/OAuth on web won't work + } } return ( @@ -72,21 +276,13 @@ export function ClerkProvider(props: ClerkProviderProps {children} diff --git a/packages/expo/src/provider/singleton/createClerkInstance.ts b/packages/expo/src/provider/singleton/createClerkInstance.ts index 993f3bd2edc..7fc8e6228a6 100644 --- a/packages/expo/src/provider/singleton/createClerkInstance.ts +++ b/packages/expo/src/provider/singleton/createClerkInstance.ts @@ -18,6 +18,7 @@ import { SessionJWTCache, } from '../../cache'; import { MemoryTokenCache } from '../../cache/MemoryTokenCache'; +import { CLERK_CLIENT_JWT_KEY } from '../../constants'; import { errorThrower } from '../../errorThrower'; import { isNative } from '../../utils'; import type { BuildClerkOptions } from './types'; @@ -36,8 +37,6 @@ type FapiResponse = Response & { payload: { errors?: Array<{ code: string }> } | null; }; -const KEY = '__clerk_client_jwt'; - let __internal_clerk: HeadlessBrowserClerk | BrowserClerk | undefined; export function createClerkInstance(ClerkClass: typeof Clerk) { @@ -57,7 +56,7 @@ export function createClerkInstance(ClerkClass: typeof Clerk) { if (!__internal_clerk || hasKeyChanged) { if (hasKeyChanged) { - tokenCache.clearToken?.(KEY); + tokenCache.clearToken?.(CLERK_CLIENT_JWT_KEY); } const getToken = tokenCache.getToken; @@ -103,10 +102,9 @@ export function createClerkInstance(ClerkClass: typeof Clerk) { return Promise.resolve(true); }; - if (createResourceCache) { - const isClerkNetworkError = (err: unknown): boolean => - isClerkRuntimeError(err) && err.code === 'network_error'; + const isClerkNetworkError = (err: unknown): boolean => isClerkRuntimeError(err) && err.code === 'network_error'; + if (createResourceCache) { const retryInitilizeResourcesFromFAPI = async () => { try { await __internal_clerk?.__internal_reloadInitialResources(); @@ -125,12 +123,9 @@ export function createClerkInstance(ClerkClass: typeof Clerk) { ClientResourceCache.init({ publishableKey, storage: createResourceCache }); SessionJWTCache.init({ publishableKey, storage: createResourceCache }); - // At this point __internal_clerk is guaranteed to be defined (just created above) - - const clerk = __internal_clerk; - clerk.addListener(({ client }) => { + __internal_clerk.addListener(({ client }) => { // @ts-expect-error - This is an internal API - const environment = clerk?.__internal_environment as EnvironmentResource; + const environment = __internal_clerk?.__internal_environment as EnvironmentResource; if (environment) { void EnvironmentResourceCache.save(environment.__internal_toSnapshot()); } @@ -149,7 +144,7 @@ export function createClerkInstance(ClerkClass: typeof Clerk) { } }); - clerk.__internal_getCachedResources = async (): Promise<{ + __internal_clerk.__internal_getCachedResources = async (): Promise<{ client: ClientJSONSnapshot | null; environment: EnvironmentJSONSnapshot | null; }> => { @@ -173,7 +168,7 @@ export function createClerkInstance(ClerkClass: typeof Clerk) { // Instructs the backend to parse the api token from the Authorization header. requestInit.url?.searchParams.append('_is_native', '1'); - const jwt = await getToken(KEY); + const jwt = await getToken(CLERK_CLIENT_JWT_KEY); (requestInit.headers as Headers).set('authorization', jwt || ''); // Instructs the backend that the request is from a mobile device. @@ -189,7 +184,7 @@ export function createClerkInstance(ClerkClass: typeof Clerk) { __internal_clerk.__internal_onAfterResponse(async (_: FapiRequestInit, response: FapiResponse) => { const authHeader = response.headers.get('authorization'); if (authHeader) { - await saveToken(KEY, authHeader); + await saveToken(CLERK_CLIENT_JWT_KEY, authHeader); } if (!nativeApiErrorShown && response.payload?.errors?.[0]?.code === 'native_api_disabled') { @@ -200,6 +195,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/specs/NativeClerkAuthView.ts b/packages/expo/src/specs/NativeClerkAuthView.ts new file mode 100644 index 00000000000..e4cffd1497d --- /dev/null +++ b/packages/expo/src/specs/NativeClerkAuthView.ts @@ -0,0 +1,16 @@ +/* eslint-disable import/namespace, import/default, import/no-named-as-default, import/no-named-as-default-member, simple-import-sort/imports */ +// These deep imports from react-native internals are required by codegen. +import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'; +import type { HostComponent, ViewProps } from 'react-native'; +import type { BubblingEventHandler } from 'react-native/Libraries/Types/CodegenTypes'; +/* eslint-enable import/namespace, import/default, import/no-named-as-default, import/no-named-as-default-member, simple-import-sort/imports */ + +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..7ee7e3cc00b --- /dev/null +++ b/packages/expo/src/specs/NativeClerkGoogleSignIn.ts @@ -0,0 +1,13 @@ +import type { TurboModule } from 'react-native'; +import { TurboModuleRegistry } from 'react-native'; +import type { UnsafeObject } from 'react-native/Libraries/Types/CodegenTypesNamespace'; + +export interface Spec extends TurboModule { + configure(params: UnsafeObject): void; + signIn(params: UnsafeObject | null): Promise; + createAccount(params: UnsafeObject | null): Promise; + presentExplicitSignIn(params: UnsafeObject | null): Promise; + signOut(): Promise; +} + +export default TurboModuleRegistry.get('ClerkGoogleSignIn'); diff --git a/packages/expo/src/specs/NativeClerkModule.ts b/packages/expo/src/specs/NativeClerkModule.ts new file mode 100644 index 00000000000..1c38d2c1f92 --- /dev/null +++ b/packages/expo/src/specs/NativeClerkModule.ts @@ -0,0 +1,14 @@ +import type { TurboModule } from 'react-native'; +import { TurboModuleRegistry } from 'react-native'; +import type { UnsafeObject } from 'react-native/Libraries/Types/CodegenTypesNamespace'; + +export interface Spec extends TurboModule { + configure(publishableKey: string, bearerToken: string | null): Promise; + presentAuth(options: UnsafeObject): Promise; + presentUserProfile(options: UnsafeObject): Promise; + getSession(): Promise; + getClientToken(): Promise; + signOut(): Promise; +} + +export default TurboModuleRegistry.get('ClerkExpo'); diff --git a/packages/expo/src/specs/NativeClerkUserProfileView.ts b/packages/expo/src/specs/NativeClerkUserProfileView.ts new file mode 100644 index 00000000000..a6096769738 --- /dev/null +++ b/packages/expo/src/specs/NativeClerkUserProfileView.ts @@ -0,0 +1,15 @@ +/* eslint-disable import/namespace, import/default, import/no-named-as-default, import/no-named-as-default-member, simple-import-sort/imports */ +// These deep imports from react-native internals are required by codegen. +import codegenNativeComponent from 'react-native/Libraries/Utilities/codegenNativeComponent'; +import type { HostComponent, ViewProps } from 'react-native'; +import type { BubblingEventHandler } from 'react-native/Libraries/Types/CodegenTypes'; +/* eslint-enable import/namespace, import/default, import/no-named-as-default, import/no-named-as-default-member, simple-import-sort/imports */ + +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 b1bb8774b05..d79594cac57 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,11 +279,79 @@ export class IsomorphicClerk implements IsomorphicLoadedClerk { this.#eventBus.emit(clerkEvents.Status, 'loading'); this.#eventBus.prioritizedOn(clerkEvents.Status, status => (this.#status = status)); - if (this.#publishableKey) { + // TODO: Please check into refactoring the type logic here, the experimental type interface is using type Autocomplete = U | (T & Record); + // so we are casting options.experimental.runtimeEnvironment to avoid changing Autocomplete to use an intersection (&) instead of a union (|), as this could cause problems in other parts of the codebase if not also refactored. + + if ( + this.#publishableKey && + (this.options.experimental as { runtimeEnvironment?: string } | undefined)?.runtimeEnvironment === 'headless' && + this.options.Clerk + ) { + void this.loadHeadlessClerk(); + } else if (this.#publishableKey) { void this.getEntryChunks(); } } + /** + * 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(); + } + } + get sdkMetadata() { return this.clerkjs?.sdkMetadata || this.options.sdkMetadata || undefined; } diff --git a/packages/shared/src/types/clerk.ts b/packages/shared/src/types/clerk.ts index a3e3659130c..670a1a21ba0 100644 --- a/packages/shared/src/types/clerk.ts +++ b/packages/shared/src/types/clerk.ts @@ -1254,6 +1254,11 @@ export type ClerkOptions = ClerkOptionsNavigation & */ rethrowOfflineNetworkErrors: boolean; commerce: boolean; + /** + * When set to `'headless'`, Clerk will skip script/chunk loading and initialize + * directly with the provided Clerk instance. Used by React Native / Expo. + */ + runtimeEnvironment: 'headless'; }, Record >; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 046eff3c79c..0a5b24875d4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -602,6 +602,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)) @@ -617,9 +620,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)) @@ -2273,102 +2273,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'} @@ -2381,6 +2483,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'} @@ -2393,6 +2501,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'} @@ -2405,24 +2519,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'} @@ -7799,6 +7937,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'} @@ -16520,81 +16663,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 @@ -23650,6 +23862,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