diff --git a/apps/mobile/package.json b/apps/mobile/package.json index f0a4dc2a905..fc730c9f46d 100644 --- a/apps/mobile/package.json +++ b/apps/mobile/package.json @@ -94,7 +94,7 @@ "react-native-image-viewing": "^0.2.2", "react-native-keyboard-controller": "1.21.6", "react-native-nitro-markdown": "^0.5.0", - "react-native-nitro-modules": "^0.35.4", + "react-native-nitro-modules": "0.35.9", "react-native-reanimated": "4.3.1", "react-native-safe-area-context": "~5.7.0", "react-native-screens": "4.25.2", diff --git a/apps/mobile/src/app/settings/waitlist.tsx b/apps/mobile/src/app/settings/waitlist.tsx index 3fc2fad2695..5379f8ce863 100644 --- a/apps/mobile/src/app/settings/waitlist.tsx +++ b/apps/mobile/src/app/settings/waitlist.tsx @@ -6,6 +6,7 @@ import { ScrollView } from "react-native"; import { CloudWaitlistEnrollment } from "../../features/cloud/CloudWaitlistEnrollment"; import { useClerkSettingsSheetDetent } from "../../features/cloud/ClerkSettingsSheetDetent"; import { hasCloudPublicConfig } from "../../features/cloud/publicConfig"; +import { useNativeClerkAuthModal } from "../../features/cloud/useNativeClerkAuthModal"; export default function SettingsWaitlistRouteScreen() { return hasCloudPublicConfig() ? ( @@ -18,6 +19,7 @@ export default function SettingsWaitlistRouteScreen() { function ConfiguredSettingsWaitlistRouteScreen() { const { isLoaded, isSignedIn } = useAuth({ treatPendingAsSignedOut: false }); const { expand } = useClerkSettingsSheetDetent(); + const { isAvailable: isNativeAuthAvailable, presentAuth } = useNativeClerkAuthModal(); const router = useRouter(); useFocusEffect( @@ -45,6 +47,13 @@ function ConfiguredSettingsWaitlistRouteScreen() { > { + if (isNativeAuthAvailable) { + void presentAuth().catch(() => { + expand(); + router.push("/settings/auth"); + }); + return; + } expand(); router.push("/settings/auth"); }} diff --git a/apps/mobile/src/features/cloud/useNativeClerkAuthModal.ts b/apps/mobile/src/features/cloud/useNativeClerkAuthModal.ts new file mode 100644 index 00000000000..7c29ae05448 --- /dev/null +++ b/apps/mobile/src/features/cloud/useNativeClerkAuthModal.ts @@ -0,0 +1,150 @@ +import { getClerkInstance } from "@clerk/expo"; +import { tokenCache } from "@clerk/expo/token-cache"; +import * as Data from "effect/Data"; +import { useCallback, useRef } from "react"; +import type { TurboModule } from "react-native"; +import { TurboModuleRegistry } from "react-native"; + +const CLERK_CLIENT_JWT_KEY = "__clerk_client_jwt"; + +interface NativeClerkModule extends TurboModule { + readonly getClientToken?: () => Promise; + readonly presentAuth?: (options: { + readonly dismissable: boolean; + readonly mode: "signInOrUp"; + }) => Promise; +} + +interface NativeAuthResult { + readonly cancelled?: boolean; + readonly session?: { + readonly id?: string; + }; + readonly sessionId?: string; +} + +interface ClerkWithNativeSync { + readonly __internal_reloadInitialResources?: () => Promise; + readonly setActive?: (params: { readonly session: string }) => Promise; +} + +interface TokenCacheWithClear { + readonly deleteToken?: (key: string) => Promise; + readonly saveToken?: (key: string, token: string) => Promise; +} + +const NativeClerk = TurboModuleRegistry.get("ClerkExpo"); + +class NativeClerkAuthError extends Data.TaggedError("NativeClerkAuthError")<{ + readonly message: string; + readonly cause?: unknown; +}> {} + +async function syncNativeSession(sessionId: string): Promise { + const getClientToken = NativeClerk?.getClientToken; + let nativeClientToken: string | null = null; + if (getClientToken) { + try { + nativeClientToken = await getClientToken(); + } catch (cause) { + throw new NativeClerkAuthError({ + message: "Could not read native Clerk client token.", + cause, + }); + } + } + const cache = tokenCache as TokenCacheWithClear | null | undefined; + if (nativeClientToken) { + const saveToken = cache?.saveToken; + if (saveToken) { + try { + await saveToken(CLERK_CLIENT_JWT_KEY, nativeClientToken); + } catch (cause) { + throw new NativeClerkAuthError({ + message: "Could not save native Clerk client token.", + cause, + }); + } + } + } else if (cache?.deleteToken) { + try { + await cache.deleteToken(CLERK_CLIENT_JWT_KEY); + } catch (cause) { + throw new NativeClerkAuthError({ + message: "Could not clear native Clerk client token.", + cause, + }); + } + } + + const clerk = getClerkInstance(); + const clerkWithNativeSync = clerk as ClerkWithNativeSync; + const reloadInitialResources = clerkWithNativeSync.__internal_reloadInitialResources; + if (reloadInitialResources) { + try { + await reloadInitialResources(); + } catch (cause) { + throw new NativeClerkAuthError({ + message: "Could not reload Clerk resources after native auth.", + cause, + }); + } + } + const setActive = clerkWithNativeSync.setActive; + if (setActive) { + try { + await setActive({ session: sessionId }); + } catch (cause) { + throw new NativeClerkAuthError({ + message: "Could not activate native Clerk session.", + cause, + }); + } + } +} + +export function useNativeClerkAuthModal() { + const presentingRef = useRef(false); + + const presentAuth = useCallback(async (): Promise => { + if (presentingRef.current || !NativeClerk?.presentAuth) { + return; + } + + presentingRef.current = true; + const presentNativeAuth = NativeClerk.presentAuth; + try { + // Clerk's iOS AuthView is not inline. It presents this same native modal + // internally; call the presenter directly so Expo Router does not render + // an empty formSheet behind it. + let result: NativeAuthResult | null; + try { + result = await presentNativeAuth({ + dismissable: true, + mode: "signInOrUp", + }); + } catch (cause) { + throw new NativeClerkAuthError({ + message: "Native Clerk auth presentation failed.", + cause, + }); + } + const sessionId = result?.sessionId ?? result?.session?.id ?? null; + if (sessionId && !result?.cancelled) { + await syncNativeSession(sessionId); + } + } catch (error) { + if (__DEV__) { + console.error("[useNativeClerkAuthModal] presentAuth failed:", error); + } + throw error; + } finally { + presentingRef.current = false; + } + }, []); + + return { + isAvailable: !!NativeClerk?.presentAuth, + presentAuth, + }; +} diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 2f9d3ab7eb2..c8ff979b3a7 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -1428,6 +1428,11 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsService.layerTest(), T assert.strictEqual(initialCodex?.installed, false); assert.deepStrictEqual(spawnedCommands, [firstMissing]); + // The rebuilt instance may re-probe synchronously during the + // settings update. Advance the TestClock first so `checkedAt` + // can safely act as the fresh-probe marker this assertion uses. + yield* TestClock.adjust("1 second"); + // Drive a settings change. The Hydration layer's // `SettingsWatcherLive` consumes this via `streamChanges`, // calls `reconcile`, which rebuilds the codex instance (the diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1e817cabe0d..a794788d7b6 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -342,7 +342,7 @@ importers: specifier: ^0.5.0 version: 0.5.8(react-native-nitro-modules@0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native-svg@15.15.4(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3))(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-nitro-modules: - specifier: ^0.35.4 + specifier: 0.35.9 version: 0.35.9(patch_hash=825622aae63a8fb5b904f3c77908a0e216261d727ea171709f2c0b6088422675)(react-native@0.85.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.3(@babel/core@7.29.7))(@types/react@19.2.16)(bufferutil@4.1.0)(react@19.2.3)(utf-8-validate@6.0.6))(react@19.2.3) react-native-reanimated: specifier: 4.3.1