From c2ca77f09f8fb6166a32050984d942029086b0d1 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 10 Nov 2026 17:16:01 -0800 Subject: [PATCH 1/5] refactor(expo): extract shared FAPI request helpers and app identifier lookup --- packages/expo/src/hooks/useHostedAuth.ts | 26 +-------- packages/expo/src/utils/appIdentifier.ts | 25 +++++++++ packages/expo/src/utils/fapiRequest.ts | 71 ++++++++++++++++++++++++ packages/expo/src/utils/hostedAuth.ts | 67 +++------------------- 4 files changed, 104 insertions(+), 85 deletions(-) create mode 100644 packages/expo/src/utils/appIdentifier.ts create mode 100644 packages/expo/src/utils/fapiRequest.ts diff --git a/packages/expo/src/hooks/useHostedAuth.ts b/packages/expo/src/hooks/useHostedAuth.ts index ab7019b2678..2e07d8d29f6 100644 --- a/packages/expo/src/hooks/useHostedAuth.ts +++ b/packages/expo/src/hooks/useHostedAuth.ts @@ -5,10 +5,10 @@ import type * as WebBrowser from 'expo-web-browser'; import { Platform } from 'react-native'; import { getClerkInstance } from '../provider/singleton'; +import { getExpoAppIdentifier } from '../utils/appIdentifier'; import { errorThrower } from '../utils/errors'; import type { HostedAuthClerkInstance, HostedAuthMode } from '../utils/hostedAuth'; import { applyHostedAuthClientJSON, createHostedAuth, redeemHostedAuth } from '../utils/hostedAuth'; -import { isExpoGo } from '../utils/runtime'; export type { HostedAuthMode }; @@ -237,30 +237,6 @@ function warnAndroidDefaultRedirectOnce(): void { } } -function getExpoAppIdentifier(): string | undefined { - try { - // expo-constants is already an optional @clerk/expo peer and is present in - // standard Expo projects. Keep the fallback below for Expo Go and bare apps. - // eslint-disable-next-line @typescript-eslint/no-require-imports - const constantsModule = require('expo-constants') as { - default?: { - expoConfig?: { - ios?: { bundleIdentifier?: string }; - android?: { package?: string }; - }; - }; - }; - if (isExpoGo()) { - return undefined; - } - - const expoConfig = constantsModule.default?.expoConfig; - return Platform.OS === 'ios' ? expoConfig?.ios?.bundleIdentifier : expoConfig?.android?.package; - } catch { - return undefined; - } -} - function getHostedAuthClerk(): HostedAuthClerkInstance { const hostedAuthClerk = getClerkInstance() as Partial | undefined; if (typeof hostedAuthClerk?.getFapiClient !== 'function') { diff --git a/packages/expo/src/utils/appIdentifier.ts b/packages/expo/src/utils/appIdentifier.ts new file mode 100644 index 00000000000..a7800ca1ce6 --- /dev/null +++ b/packages/expo/src/utils/appIdentifier.ts @@ -0,0 +1,25 @@ +import { Platform } from 'react-native'; + +import { isExpoGo } from './runtime'; + +export function getExpoAppIdentifier(): string | undefined { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const constantsModule = require('expo-constants') as { + default?: { + expoConfig?: { + ios?: { bundleIdentifier?: string }; + android?: { package?: string }; + }; + }; + }; + if (isExpoGo()) { + return undefined; + } + + const expoConfig = constantsModule.default?.expoConfig; + return Platform.OS === 'ios' ? expoConfig?.ios?.bundleIdentifier : expoConfig?.android?.package; + } catch { + return undefined; + } +} diff --git a/packages/expo/src/utils/fapiRequest.ts b/packages/expo/src/utils/fapiRequest.ts new file mode 100644 index 00000000000..e54b5faf6a5 --- /dev/null +++ b/packages/expo/src/utils/fapiRequest.ts @@ -0,0 +1,71 @@ +import { ClerkAPIResponseError } from '@clerk/shared/error'; +import type { ClerkAPIErrorJSON } from '@clerk/shared/types'; + +export type FapiPayload = { + response?: R; + errors?: ClerkAPIErrorJSON[]; +}; + +export type FapiResponse = { + ok: boolean; + status: number; + statusText: string; + headers?: Headers; + payload: FapiPayload | null; +}; + +export async function requestWithSignedOutRetry( + request: () => Promise>, +): Promise> { + const response = await request(); + if ( + !response.ok && + response.status === 401 && + getFapiErrors(response.payload).some(error => error.code === 'signed_out') + ) { + return request(); + } + + return response; +} + +export function getResponseJSON( + payload: FapiPayload | null, + object: O, +): Extract | null { + const response = payload?.response; + return !!response && typeof response === 'object' && isResponseObject(response, object) ? response : null; +} + +function isResponseObject( + response: R, + object: O, +): response is Extract { + return response.object === object; +} + +export function toClerkAPIResponseError( + response: FapiResponse<{ object: string }>, + fallbackMessage: string, +): ClerkAPIResponseError { + const errors = getFapiErrors(response.payload); + return new ClerkAPIResponseError(errors[0]?.long_message || response.statusText || fallbackMessage, { + data: errors, + status: response.status, + retryAfter: getRetryAfter(response.headers), + }); +} + +function getFapiErrors(payload: FapiPayload<{ object: string }> | null): ClerkAPIErrorJSON[] { + return payload?.errors ?? []; +} + +function getRetryAfter(headers: Headers | undefined): number | undefined { + const retryAfter = headers?.get('retry-after'); + if (!retryAfter) { + return undefined; + } + + const retryAfterSeconds = parseInt(retryAfter, 10); + return Number.isNaN(retryAfterSeconds) ? undefined : retryAfterSeconds; +} diff --git a/packages/expo/src/utils/hostedAuth.ts b/packages/expo/src/utils/hostedAuth.ts index d1c9d3bb436..d7d0d56f012 100644 --- a/packages/expo/src/utils/hostedAuth.ts +++ b/packages/expo/src/utils/hostedAuth.ts @@ -1,7 +1,8 @@ -import { ClerkAPIResponseError } from '@clerk/shared/error'; -import type { ClerkAPIErrorJSON, ClientJSON, ClientResource } from '@clerk/shared/types'; +import type { ClientJSON, ClientResource } from '@clerk/shared/types'; import { errorThrower } from './errors'; +import type { FapiResponse } from './fapiRequest'; +import { getResponseJSON, requestWithSignedOutRetry, toClerkAPIResponseError } from './fapiRequest'; /** * Controls which Account Portal auth screen opens for hosted auth. @@ -25,18 +26,7 @@ type HostedAuthJSON = { url: string; }; -type HostedAuthPayload = { - response?: HostedAuthJSON | ClientJSON; - errors?: ClerkAPIErrorJSON[]; -}; - -type HostedAuthResponse = { - ok: boolean; - status: number; - statusText: string; - headers?: Headers; - payload: HostedAuthPayload | null; -}; +type HostedAuthResponse = FapiResponse; type FapiClient = { request: (requestInit: { @@ -62,18 +52,9 @@ export async function createHostedAuth( body: params, }); - let response = await request(); - if ( - !response.ok && - response.status === 401 && - getHostedAuthErrors(response.payload).some(error => error.code === 'signed_out') - ) { - // The Expo response hook has already persisted the replacement device token. - response = await request(); - } - + const response = await requestWithSignedOutRetry(request); if (!response.ok) { - throw buildHostedAuthAPIResponseError(response); + throw toClerkAPIResponseError(response, 'Hosted auth request failed.'); } const hostedAuthJSON = getResponseJSON(response.payload, 'hosted_auth'); @@ -110,13 +91,6 @@ export async function redeemHostedAuth( return clientJSON; } -function getResponseJSON(payload: HostedAuthResponse['payload'], object: 'hosted_auth'): HostedAuthJSON | null; -function getResponseJSON(payload: HostedAuthResponse['payload'], object: 'client'): ClientJSON | null; -function getResponseJSON(payload: HostedAuthResponse['payload'], object: string): HostedAuthJSON | ClientJSON | null { - const response = payload?.response; - return !!response && typeof response === 'object' && response.object === object ? response : null; -} - export function applyHostedAuthClientJSON(client: ClientResource, clientJSON: ClientJSON): ClientResource { // Hosted auth gets the same /client payload as Client.reload(), but its verifier-bound // exchange uses a request body. Apply it to the existing ClerkJS client instance here @@ -140,32 +114,5 @@ async function throwHostedAuthAPIResponseError( await clerk.handleUnauthenticated?.(); } - throw buildHostedAuthAPIResponseError(response); -} - -function buildHostedAuthAPIResponseError(response: HostedAuthResponse) { - const errors = getHostedAuthErrors(response.payload); - return new ClerkAPIResponseError(errors[0]?.long_message || response.statusText || 'Hosted auth request failed.', { - data: errors, - status: response.status, - retryAfter: getRetryAfter(response.headers), - }); -} - -function getHostedAuthErrors(payload: HostedAuthResponse['payload']): ClerkAPIErrorJSON[] { - if (!payload || !('errors' in payload)) { - return []; - } - - return payload.errors ?? []; -} - -function getRetryAfter(headers: Headers | undefined): number | undefined { - const retryAfter = headers?.get('retry-after'); - if (!retryAfter) { - return undefined; - } - - const retryAfterSeconds = parseInt(retryAfter, 10); - return Number.isNaN(retryAfterSeconds) ? undefined : retryAfterSeconds; + throw toClerkAPIResponseError(response, 'Hosted auth request failed.'); } From 888651aebe1db07a466d046de88b5e7a7ae47a03 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 10 Nov 2026 17:22:02 -0800 Subject: [PATCH 2/5] feat(expo): add prepareSessionTransfer and the session transfer lease ledger --- .../native-to-web/__tests__/errors.test.ts | 40 ++ .../__tests__/sessionTransfer.test.ts | 356 ++++++++++++++++++ packages/expo/src/native-to-web/advanced.ts | 2 + packages/expo/src/native-to-web/errors.ts | 77 ++++ packages/expo/src/native-to-web/index.ts | 2 + .../expo/src/native-to-web/sessionTransfer.ts | 240 ++++++++++++ 6 files changed, 717 insertions(+) create mode 100644 packages/expo/src/native-to-web/__tests__/errors.test.ts create mode 100644 packages/expo/src/native-to-web/__tests__/sessionTransfer.test.ts create mode 100644 packages/expo/src/native-to-web/advanced.ts create mode 100644 packages/expo/src/native-to-web/errors.ts create mode 100644 packages/expo/src/native-to-web/index.ts create mode 100644 packages/expo/src/native-to-web/sessionTransfer.ts diff --git a/packages/expo/src/native-to-web/__tests__/errors.test.ts b/packages/expo/src/native-to-web/__tests__/errors.test.ts new file mode 100644 index 00000000000..b9638b6e7f2 --- /dev/null +++ b/packages/expo/src/native-to-web/__tests__/errors.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from 'vitest'; + +import { isSessionTransferError, sessionTransferError, toSessionTransferError } from '../errors'; + +describe('session transfer errors', () => { + test('builds an Error with a name and code', () => { + const error = sessionTransferError('expired', 'The sign-in link expired.'); + + expect(error).toBeInstanceOf(Error); + expect(error).toMatchObject({ + name: 'SessionTransferError', + code: 'expired', + message: 'The sign-in link expired.', + }); + expect(error).not.toHaveProperty('cause'); + }); + + test('keeps the cause when given one', () => { + const cause = new Error('underlying'); + + expect(sessionTransferError('network_error', 'Offline.', cause).cause).toBe(cause); + }); + + test.each([ + ['a session transfer error', sessionTransferError('signed_out', 'Signed out.'), true], + ['an error with a code but another name', Object.assign(new Error('x'), { code: 'signed_out' }), false], + ['a plain object', { name: 'SessionTransferError', code: 'signed_out' }, false], + ['null', null, false], + ])('recognizes %s: %s', (_, value, expected) => { + expect(isSessionTransferError(value)).toBe(expected); + }); + + test('wraps an unknown failure as unexpected_response', () => { + const cause = new Error('boom'); + const known = sessionTransferError('load_failed', 'Load failed.'); + + expect(toSessionTransferError(known)).toBe(known); + expect(toSessionTransferError(cause)).toMatchObject({ code: 'unexpected_response', cause }); + }); +}); diff --git a/packages/expo/src/native-to-web/__tests__/sessionTransfer.test.ts b/packages/expo/src/native-to-web/__tests__/sessionTransfer.test.ts new file mode 100644 index 00000000000..b2096f21c3d --- /dev/null +++ b/packages/expo/src/native-to-web/__tests__/sessionTransfer.test.ts @@ -0,0 +1,356 @@ +import { isClerkAPIResponseError } from '@clerk/shared/error'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import type { SessionTransferError } from '../errors'; +import { isSessionTransferError } from '../errors'; +import type * as SessionTransferModule from '../sessionTransfer'; + +const mocks = vi.hoisted(() => { + return { + platformOS: 'ios', + getClerkInstance: vi.fn(), + request: vi.fn(), + }; +}); + +vi.mock('react-native', () => { + return { + Platform: { + get OS() { + return mocks.platformOS; + }, + }, + }; +}); + +vi.mock('../../provider/singleton', () => { + return { + getClerkInstance: mocks.getClerkInstance, + }; +}); + +const DESTINATION = 'https://app.example.com/billing'; +const TRANSFER_URL = 'https://clerk.example.com/v1/session_transfers/redeem?token=one-time'; +const NOW = new Date('2026-11-10T12:00:00Z').getTime(); + +let sessionTransfer: typeof SessionTransferModule; + +function mintedResponse(url = TRANSFER_URL, expiresAt = NOW + 60_000) { + return { + ok: true, + status: 200, + statusText: 'OK', + payload: { response: { object: 'session_transfer', url, expires_at: expiresAt } }, + }; +} + +function errorResponse(status: number, code: string, longMessage: string, headers?: Headers) { + return { + ok: false, + status, + statusText: 'Error', + headers, + payload: { errors: [{ code, message: longMessage, long_message: longMessage, meta: {} }] }, + }; +} + +function deferred() { + let resolve: (value: T) => void = () => {}; + const promise = new Promise(r => { + resolve = r; + }); + return { promise, resolve }; +} + +async function rejectionOf(promise: Promise): Promise { + const error = await promise.then( + () => null, + (e: unknown) => e, + ); + if (!isSessionTransferError(error)) { + throw new Error('Expected a SessionTransferError rejection'); + } + return error; +} + +function webviewRequest(destination = DESTINATION) { + return sessionTransfer.resolveTransferRequest({ destination, presentation: 'webview' }); +} + +describe('sessionTransfer', () => { + beforeEach(async () => { + vi.resetModules(); + vi.useFakeTimers({ toFake: ['Date'] }); + vi.setSystemTime(NOW); + mocks.platformOS = 'ios'; + mocks.request.mockReset(); + mocks.getClerkInstance.mockReset(); + mocks.getClerkInstance.mockReturnValue({ + loaded: true, + session: { id: 'sess_1' }, + getFapiClient: () => ({ request: mocks.request }), + }); + sessionTransfer = await import('../sessionTransfer'); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + describe('prepareSessionTransfer', () => { + test('posts the destination and presentation for the active session', async () => { + mocks.request.mockResolvedValue(mintedResponse()); + + const transfer = await sessionTransfer.prepareSessionTransfer({ + destination: DESTINATION, + presentation: 'browser', + }); + + expect(mocks.request).toHaveBeenCalledWith({ + method: 'POST', + path: '/client/sessions/sess_1/session_transfers', + body: { redirectUrl: DESTINATION, presentationMode: 'browser' }, + }); + expect(transfer.url).toBe(TRANSFER_URL); + expect(transfer.expiresAt).toEqual(new Date(NOW + 60_000)); + }); + + test('keeps the url out of enumeration, serialization, and spread', async () => { + mocks.request.mockResolvedValue(mintedResponse()); + + const transfer = await sessionTransfer.prepareSessionTransfer({ + destination: DESTINATION, + presentation: 'webview', + }); + + expect(Object.keys(transfer)).toEqual(['expiresAt']); + expect(JSON.stringify(transfer)).not.toContain('one-time'); + expect({ ...transfer }).not.toHaveProperty('url'); + }); + + test('mints on every call', async () => { + mocks.request.mockResolvedValue(mintedResponse()); + + await sessionTransfer.prepareSessionTransfer({ destination: DESTINATION, presentation: 'webview' }); + await sessionTransfer.prepareSessionTransfer({ destination: DESTINATION, presentation: 'webview' }); + + expect(mocks.request).toHaveBeenCalledTimes(2); + }); + + test('retries once after a signed_out response', async () => { + mocks.request + .mockResolvedValueOnce(errorResponse(401, 'signed_out', 'You are signed out')) + .mockResolvedValueOnce(mintedResponse()); + + const transfer = await sessionTransfer.prepareSessionTransfer({ + destination: DESTINATION, + presentation: 'webview', + }); + + expect(mocks.request).toHaveBeenCalledTimes(2); + expect(transfer.url).toBe(TRANSFER_URL); + }); + + test('rejects with signed_out when the retry is also signed out', async () => { + mocks.request.mockResolvedValue(errorResponse(401, 'signed_out', 'You are signed out')); + + await expect( + sessionTransfer.prepareSessionTransfer({ destination: DESTINATION, presentation: 'webview' }), + ).rejects.toMatchObject({ name: 'SessionTransferError', code: 'signed_out', message: 'You are signed out' }); + expect(mocks.request).toHaveBeenCalledTimes(2); + }); + + test('maps a Clerk API refusal to its error code and keeps the API error as the cause', async () => { + mocks.request.mockResolvedValue( + errorResponse( + 403, + 'feature_not_enabled', + 'Session transfers are not enabled', + new Headers({ 'retry-after': '7' }), + ), + ); + + const error = await rejectionOf( + sessionTransfer.prepareSessionTransfer({ destination: DESTINATION, presentation: 'webview' }), + ); + + expect(error).toMatchObject({ + code: 'feature_not_enabled', + message: 'Session transfers are not enabled', + cause: { status: 403, retryAfter: 7 }, + }); + expect(isClerkAPIResponseError(error.cause)).toBe(true); + expect(mocks.request).toHaveBeenCalledTimes(1); + }); + + test('uses unexpected_response when a refusal has no error code', async () => { + mocks.request.mockResolvedValue({ ok: false, status: 502, statusText: 'Bad Gateway', payload: null }); + + await expect( + sessionTransfer.prepareSessionTransfer({ destination: DESTINATION, presentation: 'webview' }), + ).rejects.toMatchObject({ code: 'unexpected_response', message: 'Bad Gateway' }); + }); + + test('maps a thrown request to network_error', async () => { + const failure = new Error('Network request failed'); + mocks.request.mockRejectedValue(failure); + + await expect( + sessionTransfer.prepareSessionTransfer({ destination: DESTINATION, presentation: 'webview' }), + ).rejects.toMatchObject({ code: 'network_error', cause: failure }); + }); + + test.each([ + ['a different object', { response: { object: 'client', url: TRANSFER_URL, expires_at: NOW } }], + ['a missing url', { response: { object: 'session_transfer', expires_at: NOW } }], + ['a non-numeric expiry', { response: { object: 'session_transfer', url: TRANSFER_URL, expires_at: 'soon' } }], + ['no response', {}], + ])('rejects an envelope with %s as unexpected_response', async (_, payload) => { + mocks.request.mockResolvedValue({ ok: true, status: 200, statusText: 'OK', payload }); + + const error = await rejectionOf( + sessionTransfer.prepareSessionTransfer({ destination: DESTINATION, presentation: 'webview' }), + ); + + expect(error).toMatchObject({ code: 'unexpected_response' }); + expect(error.message).not.toContain('one-time'); + }); + + test.each([ + ['unsupported_platform', () => (mocks.platformOS = 'web'), DESTINATION], + ['invalid_destination', () => {}, '/billing'], + ['invalid_destination', () => {}, 'myapp://billing'], + [ + 'clerk_not_loaded', + () => mocks.getClerkInstance.mockReturnValue({ loaded: false, session: null, getFapiClient: vi.fn() }), + DESTINATION, + ], + [ + 'clerk_not_loaded', + () => + mocks.getClerkInstance.mockImplementation(() => { + throw new Error('Missing publishableKey'); + }), + DESTINATION, + ], + [ + 'signed_out', + () => mocks.getClerkInstance.mockReturnValue({ loaded: true, session: null, getFapiClient: vi.fn() }), + DESTINATION, + ], + ])('rejects with %s before any request', async (code, arrange, destination) => { + arrange(); + + await expect( + sessionTransfer.prepareSessionTransfer({ destination, presentation: 'webview' }), + ).rejects.toMatchObject({ + code, + }); + expect(mocks.request).not.toHaveBeenCalled(); + }); + }); + + describe('transfer ledger', () => { + test('keys a request by session, presentation, and destination', () => { + expect(webviewRequest().key).toBe(`sess_1\nwebview\n${DESTINATION}`); + }); + + test('hands an unclaimed lease to the next acquire for the same key', async () => { + const minted = deferred>(); + mocks.request.mockReturnValue(minted.promise); + + const first = sessionTransfer.acquireTransfer(webviewRequest()); + const second = sessionTransfer.acquireTransfer(webviewRequest()); + minted.resolve(mintedResponse()); + + expect(second).toBe(first); + await expect(second.transfer).resolves.toMatchObject({ url: TRANSFER_URL }); + expect(sessionTransfer.acquireTransfer(webviewRequest())).toBe(first); + expect(mocks.request).toHaveBeenCalledTimes(1); + }); + + test('mints separately for a different destination or presentation', () => { + mocks.request.mockResolvedValue(mintedResponse()); + + const lease = sessionTransfer.acquireTransfer(webviewRequest()); + + expect(sessionTransfer.acquireTransfer(webviewRequest('https://app.example.com/other'))).not.toBe(lease); + expect( + sessionTransfer.acquireTransfer( + sessionTransfer.resolveTransferRequest({ destination: DESTINATION, presentation: 'browser' }), + ), + ).not.toBe(lease); + expect(mocks.request).toHaveBeenCalledTimes(3); + }); + + test('mints again after the lease is claimed', async () => { + mocks.request.mockResolvedValue(mintedResponse()); + + const first = sessionTransfer.acquireTransfer(webviewRequest()); + await first.transfer; + sessionTransfer.claimTransfer(first); + sessionTransfer.claimTransfer(first); + const second = sessionTransfer.acquireTransfer(webviewRequest()); + + expect(second).not.toBe(first); + expect(mocks.request).toHaveBeenCalledTimes(2); + }); + + test('leaves a newer lease alone when an older one is claimed', () => { + mocks.request.mockResolvedValue(mintedResponse()); + + const first = sessionTransfer.acquireTransfer(webviewRequest()); + sessionTransfer.claimTransfer(first); + const second = sessionTransfer.acquireTransfer(webviewRequest()); + sessionTransfer.claimTransfer(first); + + expect(sessionTransfer.acquireTransfer(webviewRequest())).toBe(second); + }); + + test('evicts a minted lease with less than 15 seconds left', async () => { + mocks.request.mockResolvedValue(mintedResponse()); + + const first = sessionTransfer.acquireTransfer(webviewRequest()); + await first.transfer; + vi.setSystemTime(NOW + 45_001); + + expect(sessionTransfer.acquireTransfer(webviewRequest())).not.toBe(first); + expect(mocks.request).toHaveBeenCalledTimes(2); + }); + + test('keeps a minted lease with 15 seconds or more left', async () => { + mocks.request.mockResolvedValue(mintedResponse()); + + const first = sessionTransfer.acquireTransfer(webviewRequest()); + await first.transfer; + vi.setSystemTime(NOW + 45_000); + + expect(sessionTransfer.acquireTransfer(webviewRequest())).toBe(first); + }); + + test('evicts expiring leases for other keys on every acquire', async () => { + mocks.request.mockResolvedValue(mintedResponse()); + + const stale = sessionTransfer.acquireTransfer(webviewRequest()); + await stale.transfer; + vi.setSystemTime(NOW + 50_000); + sessionTransfer.acquireTransfer(webviewRequest('https://app.example.com/other')); + vi.setSystemTime(NOW); + + expect(sessionTransfer.acquireTransfer(webviewRequest())).not.toBe(stale); + }); + + test('deletes a lease whose mint failed', async () => { + mocks.request + .mockResolvedValueOnce(errorResponse(403, 'feature_not_enabled', 'Session transfers are not enabled')) + .mockResolvedValueOnce(mintedResponse()); + + const failed = sessionTransfer.acquireTransfer(webviewRequest()); + await expect(failed.transfer).rejects.toMatchObject({ code: 'feature_not_enabled' }); + const next = sessionTransfer.acquireTransfer(webviewRequest()); + + expect(next).not.toBe(failed); + await expect(next.transfer).resolves.toMatchObject({ url: TRANSFER_URL }); + }); + }); +}); diff --git a/packages/expo/src/native-to-web/advanced.ts b/packages/expo/src/native-to-web/advanced.ts new file mode 100644 index 00000000000..bbafe655eb5 --- /dev/null +++ b/packages/expo/src/native-to-web/advanced.ts @@ -0,0 +1,2 @@ +export { prepareSessionTransfer } from './sessionTransfer'; +export type { PrepareSessionTransferParams, SessionTransfer, SessionTransferPresentation } from './sessionTransfer'; diff --git a/packages/expo/src/native-to-web/errors.ts b/packages/expo/src/native-to-web/errors.ts new file mode 100644 index 00000000000..c48063f41fb --- /dev/null +++ b/packages/expo/src/native-to-web/errors.ts @@ -0,0 +1,77 @@ +/** + * Why a session transfer failed. + * + * The SDK raises `unsupported_platform`, `clerk_not_loaded`, `signed_out`, `invalid_destination`, + * `missing_dependency`, `browser_unavailable`, `load_failed`, `network_error`, and `unexpected_response`. + * When Clerk refuses to create the transfer, the code is the first Clerk API error code, + * such as `feature_not_enabled`. When the web page refuses to redeem it, the code is the + * `__clerk_error_code` the page redirected with, such as `expired` or `already_used`. + */ +export type SessionTransferErrorCode = + | 'unsupported_platform' + | 'clerk_not_loaded' + | 'signed_out' + | 'invalid_destination' + | 'missing_dependency' + | 'browser_unavailable' + | 'load_failed' + | 'network_error' + | 'unexpected_response' + | 'feature_not_enabled' + | 'resource_forbidden' + | 'invalid_action_for_session' + | 'user_locked' + | 'invalid_token' + | 'expired' + | 'already_used' + | 'source_session_invalid' + | 'impersonation_in_progress' + | 'other_user_signed_in' + | 'cross_site_request' + | (string & {}); + +/** + * The error every session transfer API rejects with. Clerk API refusals keep the original + * `ClerkAPIResponseError` on `cause`, so `status` and `retryAfter` stay available. + */ +export type SessionTransferError = Error & { + name: 'SessionTransferError'; + code: SessionTransferErrorCode; + cause?: unknown; +}; + +/** + * Returns `true` when `error` came from a session transfer API. + * + * @example + * ### Send a signed-out user to sign in + * ```ts + * try { + * await openExternalBrowser({ destination: 'https://app.example.com/settings' }); + * } catch (error) { + * if (isSessionTransferError(error) && error.code === 'signed_out') { + * router.replace('/sign-in'); + * } + * } + * ``` + */ +export function isSessionTransferError(error: unknown): error is SessionTransferError { + return ( + error instanceof Error && error.name === 'SessionTransferError' && 'code' in error && typeof error.code === 'string' + ); +} + +export function sessionTransferError( + code: SessionTransferErrorCode, + message: string, + cause?: unknown, +): SessionTransferError { + const error = Object.assign(new Error(message), { name: 'SessionTransferError' as const, code }); + return cause === undefined ? error : Object.assign(error, { cause }); +} + +export function toSessionTransferError(error: unknown): SessionTransferError { + return isSessionTransferError(error) + ? error + : sessionTransferError('unexpected_response', 'The session transfer failed unexpectedly.', error); +} diff --git a/packages/expo/src/native-to-web/index.ts b/packages/expo/src/native-to-web/index.ts new file mode 100644 index 00000000000..ad537349fc1 --- /dev/null +++ b/packages/expo/src/native-to-web/index.ts @@ -0,0 +1,2 @@ +export { isSessionTransferError } from './errors'; +export type { SessionTransferError, SessionTransferErrorCode } from './errors'; diff --git a/packages/expo/src/native-to-web/sessionTransfer.ts b/packages/expo/src/native-to-web/sessionTransfer.ts new file mode 100644 index 00000000000..dd3e65615f0 --- /dev/null +++ b/packages/expo/src/native-to-web/sessionTransfer.ts @@ -0,0 +1,240 @@ +import { Platform } from 'react-native'; + +import { getClerkInstance } from '../provider/singleton'; +import type { FapiResponse } from '../utils/fapiRequest'; +import { getResponseJSON, requestWithSignedOutRetry, toClerkAPIResponseError } from '../utils/fapiRequest'; +import { sessionTransferError } from './errors'; + +/** + * Where the session transfer URL will be opened. Use `webview` for a WebView your app renders, + * and `browser` for the system browser or an in-app browser tab. + */ +export type SessionTransferPresentation = 'webview' | 'browser'; + +/** + * Options for preparing a session transfer. + */ +export type PrepareSessionTransferParams = { + /** + * Absolute URL on one of your instance's domains. It must use HTTPS outside development instances. + */ + destination: string; + /** + * Where the URL will be opened. + */ + presentation: SessionTransferPresentation; +}; + +/** + * A one-time URL that signs the active user in on the web. + */ +export type SessionTransfer = { + /** + * Open it once, within 60 seconds, and never log or store it. It is not enumerable, so + * `console.log`, `JSON.stringify`, and object spread leave it out. + */ + readonly url: string; + /** + * When the URL stops working. + */ + readonly expiresAt: Date; +}; + +type SessionTransferJSON = { + object: 'session_transfer'; + url: unknown; + expires_at: unknown; +}; + +type SessionTransferFapiClient = { + request: (requestInit: { + method: 'POST'; + path: string; + body: { redirectUrl: string; presentationMode: SessionTransferPresentation }; + }) => Promise>; +}; + +type FapiCapableClerk = { + getFapiClient: () => SessionTransferFapiClient; +}; + +export type TransferRequest = { + readonly key: string; + readonly clerk: FapiCapableClerk; + readonly sessionId: string; + readonly destination: string; + readonly presentation: SessionTransferPresentation; +}; + +export type TransferLease = { + readonly key: string; + readonly transfer: Promise; +}; + +type LedgerEntry = { + readonly lease: TransferLease; + expiresAt: number | null; +}; + +const MIN_REMAINING_MS = 15_000; +const unclaimed = new Map(); + +/** + * Creates a one-time URL that signs the active session's user in on `destination`. Every call + * creates a new transfer. Rejects with a `SessionTransferError`. + * + * Use it only to present the URL yourself. `useAuthenticatedWebView` and + * `useNativeToWeb().openExternalBrowser` cover WebViews and the system browser. + * + * > [!WARNING] + * > Android browsers redeem the URL only when the app sets `android.intent.extra.REFERRER_NAME` + * > to `android-app://` on the intent that opens it. `expo-web-browser` Custom Tabs + * > cannot set it, so redemption fails there with `cross_site_request`. + * + * @param params - The destination and where the URL will be opened. + * @returns The URL and when it expires. + * + * @example + * ### Open the destination in an in-app browser on iOS + * ```ts + * import { prepareSessionTransfer } from '@clerk/expo/native-to-web/advanced'; + * import * as WebBrowser from 'expo-web-browser'; + * + * const transfer = await prepareSessionTransfer({ + * destination: 'https://app.example.com/reports', + * presentation: 'browser', + * }); + * await WebBrowser.openBrowserAsync(transfer.url); + * ``` + */ +export async function prepareSessionTransfer(params: PrepareSessionTransferParams): Promise { + return mintSessionTransfer(resolveTransferRequest(params)); +} + +export function resolveTransferRequest({ destination, presentation }: PrepareSessionTransferParams): TransferRequest { + if (Platform.OS === 'web') { + throw sessionTransferError('unsupported_platform', 'Session transfer is only available in native apps.'); + } + if (!isAbsoluteHttpUrl(destination)) { + throw sessionTransferError( + 'invalid_destination', + 'The session transfer destination must be an absolute http or https URL.', + ); + } + + const clerk = getLoadedClerk(); + if (!clerk) { + throw sessionTransferError('clerk_not_loaded', 'Clerk must finish loading before preparing a session transfer.'); + } + + const sessionId = clerk.session?.id; + if (!sessionId) { + throw sessionTransferError('signed_out', 'A session transfer requires an active session.'); + } + + return { key: `${sessionId}\n${presentation}\n${destination}`, clerk, sessionId, destination, presentation }; +} + +export function acquireTransfer(request: TransferRequest): TransferLease { + evictExpiring(Date.now()); + + const existing = unclaimed.get(request.key); + if (existing) { + return existing.lease; + } + + const transfer = mintSessionTransfer(request); + const entry: LedgerEntry = { lease: { key: request.key, transfer }, expiresAt: null }; + unclaimed.set(request.key, entry); + transfer.then( + minted => { + entry.expiresAt = minted.expiresAt.getTime(); + }, + () => { + if (unclaimed.get(request.key) === entry) { + unclaimed.delete(request.key); + } + }, + ); + + return entry.lease; +} + +export function claimTransfer(lease: TransferLease): void { + if (unclaimed.get(lease.key)?.lease === lease) { + unclaimed.delete(lease.key); + } +} + +function evictExpiring(now: number): void { + for (const [key, entry] of unclaimed) { + if (entry.expiresAt !== null && entry.expiresAt - now < MIN_REMAINING_MS) { + unclaimed.delete(key); + } + } +} + +async function mintSessionTransfer({ + clerk, + sessionId, + destination, + presentation, +}: TransferRequest): Promise { + let response: FapiResponse; + try { + response = await requestWithSignedOutRetry(() => + clerk.getFapiClient().request({ + method: 'POST', + path: `/client/sessions/${sessionId}/session_transfers`, + body: { redirectUrl: destination, presentationMode: presentation }, + }), + ); + } catch (cause) { + throw sessionTransferError('network_error', 'Clerk could not be reached to prepare the session transfer.', cause); + } + + if (!response.ok) { + const apiError = toClerkAPIResponseError(response, 'Clerk refused to prepare the session transfer.'); + throw sessionTransferError(apiError.errors[0]?.code ?? 'unexpected_response', apiError.message, apiError); + } + + return parseSessionTransfer(getResponseJSON(response.payload, 'session_transfer')); +} + +function parseSessionTransfer(json: SessionTransferJSON | null): SessionTransfer { + if ( + !json || + typeof json.url !== 'string' || + typeof json.expires_at !== 'number' || + !Number.isFinite(json.expires_at) + ) { + throw sessionTransferError('unexpected_response', 'Clerk returned an unexpected session transfer response.'); + } + + const transfer = { url: json.url, expiresAt: new Date(json.expires_at) }; + return Object.defineProperty(transfer, 'url', { enumerable: false, writable: false }); +} + +function getLoadedClerk() { + let clerk: ReturnType; + try { + clerk = getClerkInstance(); + } catch { + return null; + } + + return clerk?.loaded && canRequestFapi(clerk) ? clerk : null; +} + +function canRequestFapi(clerk: C): clerk is C & FapiCapableClerk { + return 'getFapiClient' in clerk && typeof clerk.getFapiClient === 'function'; +} + +function isAbsoluteHttpUrl(value: string): boolean { + try { + const { protocol } = new URL(value); + return protocol === 'https:' || protocol === 'http:'; + } catch { + return false; + } +} From b294121d86654c4c8da6ae2a9f8aba2fbd4daad8 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 10 Nov 2026 17:27:58 -0800 Subject: [PATCH 3/5] feat(expo): add useAuthenticatedWebView for signed-in WebViews --- packages/expo/package.json | 1 + .../useAuthenticatedWebView.test.tsx | 309 +++++++++++++++++ .../__tests__/webViewNavigation.test.ts | 83 +++++ .../__tests__/webViewProps.test-d.ts | 21 ++ packages/expo/src/native-to-web/index.ts | 6 + .../native-to-web/useAuthenticatedWebView.ts | 259 ++++++++++++++ .../src/native-to-web/webViewNavigation.ts | 58 ++++ packages/expo/tsconfig.typecheck.json | 7 + packages/expo/vitest.config.mts | 4 + pnpm-lock.yaml | 315 ++++++++++++++++-- 10 files changed, 1030 insertions(+), 33 deletions(-) create mode 100644 packages/expo/src/native-to-web/__tests__/useAuthenticatedWebView.test.tsx create mode 100644 packages/expo/src/native-to-web/__tests__/webViewNavigation.test.ts create mode 100644 packages/expo/src/native-to-web/__tests__/webViewProps.test-d.ts create mode 100644 packages/expo/src/native-to-web/useAuthenticatedWebView.ts create mode 100644 packages/expo/src/native-to-web/webViewNavigation.ts create mode 100644 packages/expo/tsconfig.typecheck.json diff --git a/packages/expo/package.json b/packages/expo/package.json index 8e26691c811..3f72c53da17 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -145,6 +145,7 @@ "expo-secure-store": "^15.0.8", "expo-web-browser": "^15.0.11", "react-native": "^0.86.0", + "react-native-webview": "^14.0.1", "tslib": "catalog:repo" }, "peerDependencies": { diff --git a/packages/expo/src/native-to-web/__tests__/useAuthenticatedWebView.test.tsx b/packages/expo/src/native-to-web/__tests__/useAuthenticatedWebView.test.tsx new file mode 100644 index 00000000000..cf7f5b3d300 --- /dev/null +++ b/packages/expo/src/native-to-web/__tests__/useAuthenticatedWebView.test.tsx @@ -0,0 +1,309 @@ +import { act, renderHook, waitFor } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { StrictMode } from 'react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +import { useAuthenticatedWebView } from '../useAuthenticatedWebView'; + +const mocks = vi.hoisted(() => { + return { + auth: { isLoaded: true, isSignedIn: true as boolean, sessionId: 'sess_0' as string | null }, + getClerkInstance: vi.fn(), + request: vi.fn(), + }; +}); + +vi.mock('@clerk/react', () => { + return { + useAuth: () => mocks.auth, + }; +}); + +vi.mock('react-native', () => { + return { + Platform: { OS: 'ios' }, + }; +}); + +vi.mock('../../provider/singleton', () => { + return { + getClerkInstance: mocks.getClerkInstance, + }; +}); + +const DESTINATION = 'https://app.example.com/billing'; +const TRANSFER_URL = 'https://clerk.example.com/v1/session_transfers/redeem?token=one-time'; + +type MintResponse = ReturnType; + +function mintedResponse(url = TRANSFER_URL) { + return { + ok: true, + status: 200, + statusText: 'OK', + payload: { response: { object: 'session_transfer', url, expires_at: Date.now() + 60_000 } }, + }; +} + +function refusal(status: number, code: string) { + return { + ok: false, + status, + statusText: 'Error', + payload: { errors: [{ code, message: code, long_message: `Refused with ${code}`, meta: {} }] }, + }; +} + +function deferredMint() { + let resolve: (response: MintResponse) => void = () => {}; + const response = new Promise(r => { + resolve = r; + }); + mocks.request.mockReturnValueOnce(response); + return { + deliver: async (url?: string) => { + resolve(mintedResponse(url)); + await response; + }, + }; +} + +function strictMode({ children }: { children: ReactNode }) { + return {children}; +} + +function renderWebView(destination = DESTINATION, wrapper?: (props: { children: ReactNode }) => ReactNode) { + return renderHook((props: { destination: string }) => useAuthenticatedWebView(props), { + initialProps: { destination }, + wrapper, + }); +} + +let sessionCount = 0; + +describe('useAuthenticatedWebView', () => { + beforeEach(() => { + sessionCount += 1; + const sessionId = `sess_${sessionCount}`; + mocks.auth = { isLoaded: true, isSignedIn: true, sessionId }; + mocks.request.mockReset(); + mocks.getClerkInstance.mockReset(); + mocks.getClerkInstance.mockImplementation(() => ({ + loaded: true, + session: mocks.auth.sessionId ? { id: mocks.auth.sessionId } : null, + getFapiClient: () => ({ request: mocks.request }), + })); + }); + + test('mints once under Strict Mode and hands the URL to the WebView', async () => { + mocks.request.mockResolvedValue(mintedResponse()); + + const { result } = renderWebView(DESTINATION, strictMode); + + expect(result.current).toMatchObject({ status: 'preparing', webViewProps: null, error: null }); + await waitFor(() => expect(result.current.webViewProps).not.toBeNull()); + expect(result.current.status).toBe('preparing'); + expect(result.current.webViewProps?.source).toEqual({ uri: TRANSFER_URL }); + expect(mocks.request).toHaveBeenCalledTimes(1); + expect(mocks.request).toHaveBeenCalledWith( + expect.objectContaining({ body: { redirectUrl: DESTINATION, presentationMode: 'webview' } }), + ); + }); + + test('reuses the pending mint when the screen remounts before it arrives', async () => { + const mint = deferredMint(); + + const first = renderWebView(); + first.unmount(); + const second = renderWebView(); + await act(() => mint.deliver()); + + expect(second.result.current.webViewProps?.source).toEqual({ uri: TRANSFER_URL }); + expect(mocks.request).toHaveBeenCalledTimes(1); + }); + + test('mints a new URL when the screen remounts after the WebView received one', async () => { + mocks.request + .mockResolvedValueOnce(mintedResponse()) + .mockResolvedValueOnce(mintedResponse('https://clerk.example.com/v1/session_transfers/redeem?token=second')); + + const first = renderWebView(); + await waitFor(() => expect(first.result.current.webViewProps).not.toBeNull()); + first.unmount(); + const second = renderWebView(); + + await waitFor(() => + expect(second.result.current.webViewProps?.source.uri).toBe( + 'https://clerk.example.com/v1/session_transfers/redeem?token=second', + ), + ); + expect(mocks.request).toHaveBeenCalledTimes(2); + }); + + test('reports ready once the WebView finishes loading past the sign-in page', async () => { + mocks.request.mockResolvedValue(mintedResponse()); + const { result } = renderWebView(); + await waitFor(() => expect(result.current.webViewProps).not.toBeNull()); + const props = result.current.webViewProps; + + act(() => props?.onLoadStart({ nativeEvent: { url: TRANSFER_URL } })); + act(() => props?.onLoadEnd({ nativeEvent: { url: TRANSFER_URL } })); + expect(result.current.status).toBe('preparing'); + + act(() => props?.onLoadEnd({ nativeEvent: { url: DESTINATION } })); + expect(result.current).toMatchObject({ status: 'ready', webViewProps: props, error: null }); + + act(() => props?.onLoadStart({ nativeEvent: { url: `${DESTINATION}?__clerk_error_code=expired` } })); + expect(result.current.status).toBe('ready'); + }); + + test('keeps the WebView props when redeeming fails', async () => { + mocks.request.mockResolvedValue(mintedResponse()); + const { result } = renderWebView(); + await waitFor(() => expect(result.current.webViewProps).not.toBeNull()); + const props = result.current.webViewProps; + + act(() => + props?.onLoadStart({ + nativeEvent: { url: `${DESTINATION}?__clerk_status=failed&__clerk_error_code=already_used` }, + }), + ); + + expect(result.current).toMatchObject({ + status: 'error', + webViewProps: props, + error: { name: 'SessionTransferError', code: 'already_used' }, + }); + expect(result.current.error?.message).not.toContain('one-time'); + }); + + test('reports load_failed when the sign-in page returns an HTTP error', async () => { + mocks.request.mockResolvedValue(mintedResponse()); + const { result } = renderWebView(); + await waitFor(() => expect(result.current.webViewProps).not.toBeNull()); + + act(() => result.current.webViewProps?.onHttpError({ nativeEvent: { url: TRANSFER_URL } })); + + expect(result.current).toMatchObject({ status: 'error', error: { code: 'load_failed' } }); + expect(result.current.webViewProps).not.toBeNull(); + }); + + test('reports load_failed when the WebView fails to load', async () => { + mocks.request.mockResolvedValue(mintedResponse()); + const { result } = renderWebView(); + await waitFor(() => expect(result.current.webViewProps).not.toBeNull()); + + act(() => result.current.webViewProps?.onLoadEnd({ nativeEvent: { url: TRANSFER_URL, code: -1009 } })); + + expect(result.current).toMatchObject({ status: 'error', error: { code: 'load_failed' } }); + }); + + test('retry mints a new URL after a failed mint', async () => { + mocks.request.mockResolvedValueOnce(refusal(403, 'feature_not_enabled')).mockResolvedValueOnce(mintedResponse()); + const { result } = renderWebView(); + + await waitFor(() => expect(result.current.status).toBe('error')); + expect(result.current).toMatchObject({ webViewProps: null, error: { code: 'feature_not_enabled' } }); + + act(() => result.current.retry()); + expect(result.current).toMatchObject({ status: 'preparing', webViewProps: null }); + + await waitFor(() => expect(result.current.webViewProps).not.toBeNull()); + expect(mocks.request).toHaveBeenCalledTimes(2); + }); + + test('retry after a redeem failure replaces the WebView props', async () => { + mocks.request + .mockResolvedValueOnce(mintedResponse()) + .mockResolvedValueOnce(mintedResponse('https://clerk.example.com/v1/session_transfers/redeem?token=second')); + const { result } = renderWebView(); + await waitFor(() => expect(result.current.webViewProps).not.toBeNull()); + act(() => + result.current.webViewProps?.onLoadStart({ nativeEvent: { url: `${DESTINATION}?__clerk_error_code=expired` } }), + ); + const failedProps = result.current.webViewProps; + + act(() => result.current.retry()); + + await waitFor(() => + expect(result.current.webViewProps?.source.uri).toBe( + 'https://clerk.example.com/v1/session_transfers/redeem?token=second', + ), + ); + expect(result.current.status).toBe('preparing'); + act(() => failedProps?.onLoadEnd({ nativeEvent: { url: DESTINATION } })); + expect(result.current.status).toBe('preparing'); + }); + + test('a destination change mints for the new destination and drops the old result', async () => { + const oldMint = deferredMint(); + mocks.request.mockResolvedValueOnce( + mintedResponse('https://clerk.example.com/v1/session_transfers/redeem?token=new'), + ); + const { result, rerender } = renderWebView(); + + rerender({ destination: 'https://app.example.com/settings' }); + expect(result.current).toMatchObject({ status: 'preparing', webViewProps: null }); + await waitFor(() => + expect(result.current.webViewProps?.source.uri).toBe( + 'https://clerk.example.com/v1/session_transfers/redeem?token=new', + ), + ); + await act(() => oldMint.deliver()); + + expect(result.current.webViewProps?.source.uri).toBe( + 'https://clerk.example.com/v1/session_transfers/redeem?token=new', + ); + expect(mocks.request).toHaveBeenLastCalledWith( + expect.objectContaining({ + body: { redirectUrl: 'https://app.example.com/settings', presentationMode: 'webview' }, + }), + ); + }); + + test('reports signed-out without minting when there is no session', () => { + mocks.auth = { isLoaded: true, isSignedIn: false, sessionId: null }; + + const { result } = renderWebView(); + + expect(result.current).toMatchObject({ status: 'signed-out', webViewProps: null, error: null }); + expect(mocks.request).not.toHaveBeenCalled(); + }); + + test('reports signed-out when the user signs out after the URL was prepared', async () => { + mocks.request.mockResolvedValue(mintedResponse()); + const { result, rerender } = renderWebView(); + await waitFor(() => expect(result.current.webViewProps).not.toBeNull()); + + mocks.auth = { isLoaded: true, isSignedIn: false, sessionId: null }; + rerender({ destination: DESTINATION }); + + expect(result.current).toMatchObject({ status: 'signed-out', webViewProps: null }); + }); + + test('reports signed-out when Clerk refuses the mint as signed out', async () => { + mocks.request.mockResolvedValue(refusal(401, 'signed_out')); + + const { result } = renderWebView(); + + await waitFor(() => expect(result.current.status).toBe('signed-out')); + expect(mocks.request).toHaveBeenCalledTimes(2); + }); + + test('waits without minting until Clerk loads', () => { + mocks.auth = { isLoaded: false, isSignedIn: false, sessionId: null }; + + const { result } = renderWebView(); + + expect(result.current).toMatchObject({ status: 'preparing', webViewProps: null }); + expect(mocks.request).not.toHaveBeenCalled(); + }); + + test('reports invalid_destination without minting', async () => { + const { result } = renderWebView('/billing'); + + await waitFor(() => expect(result.current.status).toBe('error')); + expect(result.current.error?.code).toBe('invalid_destination'); + expect(mocks.request).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/expo/src/native-to-web/__tests__/webViewNavigation.test.ts b/packages/expo/src/native-to-web/__tests__/webViewNavigation.test.ts new file mode 100644 index 00000000000..775c79c187e --- /dev/null +++ b/packages/expo/src/native-to-web/__tests__/webViewNavigation.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, test } from 'vitest'; + +import type { NavigationEvent, NavigationVerdict } from '../webViewNavigation'; +import { judgeNavigation } from '../webViewNavigation'; + +const FAPI_TRANSFER = 'https://clerk.example.com/v1/session_transfers/redeem?token=one-time'; +const PROXIED_TRANSFER = 'https://app.example.com/__clerk/v1/session_transfers/redeem?token=one-time'; + +const pending: NavigationVerdict = { kind: 'pending' }; +const ready: NavigationVerdict = { kind: 'ready' }; +const failed = (code: string): NavigationVerdict => ({ kind: 'failed', code }); + +const start = (url: string): NavigationEvent => ({ kind: 'load-start', url }); +const end = (url: string, loadFailed = false): NavigationEvent => ({ kind: 'load-end', url, failed: loadFailed }); +const httpError = (url: string): NavigationEvent => ({ kind: 'http-error', url }); + +describe('judgeNavigation', () => { + test.each<[string, string, NavigationEvent, NavigationVerdict]>([ + ['redeem page starts loading', FAPI_TRANSFER, start(FAPI_TRANSFER), pending], + ['redeem page finishes loading', FAPI_TRANSFER, end(FAPI_TRANSFER), pending], + [ + 'redeem form post finishes without the token', + FAPI_TRANSFER, + end('https://clerk.example.com/v1/session_transfers/redeem'), + pending, + ], + ['redeem page returns an HTTP error', FAPI_TRANSFER, httpError(FAPI_TRANSFER), failed('load_failed')], + ['redeem page fails to load', FAPI_TRANSFER, end(FAPI_TRANSFER, true), failed('load_failed')], + ['a load fails with no URL', FAPI_TRANSFER, end('', true), failed('load_failed')], + ['destination fails to load', FAPI_TRANSFER, end('https://app.example.com/billing', true), failed('load_failed')], + ['destination starts loading', FAPI_TRANSFER, start('https://app.example.com/billing'), pending], + ['destination finishes loading', FAPI_TRANSFER, end('https://app.example.com/billing'), ready], + ['destination returns an HTTP error', FAPI_TRANSFER, httpError('https://app.example.com/billing'), pending], + [ + 'redirect carries a failed status', + FAPI_TRANSFER, + start('https://app.example.com/billing?__clerk_status=failed&__clerk_error_code=expired'), + failed('expired'), + ], + [ + 'redirect carries a session_transfer_failed status', + FAPI_TRANSFER, + start('https://app.example.com/billing?__clerk_status=session_transfer_failed&__clerk_error_code=already_used'), + failed('already_used'), + ], + [ + 'redirect carries an error code without a status', + FAPI_TRANSFER, + start('https://app.example.com/?__clerk_error_code=invalid_token'), + failed('invalid_token'), + ], + [ + 'finished page still carries the error code', + FAPI_TRANSFER, + end('https://app.example.com/billing?__clerk_status=failed&__clerk_error_code=cross_site_request'), + failed('cross_site_request'), + ], + [ + 'error code arrives with an unrelated status', + FAPI_TRANSFER, + end('https://app.example.com/billing?__clerk_status=verified&__clerk_error_code=expired'), + ready, + ], + ['about:blank finishes loading', FAPI_TRANSFER, end('about:blank'), pending], + [ + 'same path on another host finishes loading', + FAPI_TRANSFER, + end('https://app.example.com/v1/session_transfers/redeem'), + ready, + ], + ['proxied redeem page finishes loading', PROXIED_TRANSFER, end(PROXIED_TRANSFER), pending], + [ + 'proxied redeem form post finishes', + PROXIED_TRANSFER, + end('https://app.example.com/__clerk/v1/session_transfers/redeem'), + pending, + ], + ['proxied redeem page returns an HTTP error', PROXIED_TRANSFER, httpError(PROXIED_TRANSFER), failed('load_failed')], + ['destination on the proxy host finishes loading', PROXIED_TRANSFER, end('https://app.example.com/billing'), ready], + ])('%s', (_, transferUrl, event, verdict) => { + expect(judgeNavigation(transferUrl, event)).toEqual(verdict); + }); +}); diff --git a/packages/expo/src/native-to-web/__tests__/webViewProps.test-d.ts b/packages/expo/src/native-to-web/__tests__/webViewProps.test-d.ts new file mode 100644 index 00000000000..fe8f9740899 --- /dev/null +++ b/packages/expo/src/native-to-web/__tests__/webViewProps.test-d.ts @@ -0,0 +1,21 @@ +import type { ComponentProps } from 'react'; +import type { WebView } from 'react-native-webview'; +import type { AndroidWebViewProps, IOSWebViewProps } from 'react-native-webview/lib/WebViewTypes'; +import { describe, expectTypeOf, test } from 'vitest'; + +import type { AuthenticatedWebViewProps } from '../useAuthenticatedWebView'; + +describe('AuthenticatedWebViewProps', () => { + test('spreads onto the react-native-webview WebView', () => { + expectTypeOf().toExtend>>(); + }); + + test('fits the iOS and Android WebView props', () => { + expectTypeOf().toExtend>(); + expectTypeOf().toExtend>(); + }); + + test('rejects props the WebView would not accept', () => { + expectTypeOf<{ source: { url: string } }>().not.toExtend>>(); + }); +}); diff --git a/packages/expo/src/native-to-web/index.ts b/packages/expo/src/native-to-web/index.ts index ad537349fc1..652e5c7557d 100644 --- a/packages/expo/src/native-to-web/index.ts +++ b/packages/expo/src/native-to-web/index.ts @@ -1,2 +1,8 @@ export { isSessionTransferError } from './errors'; export type { SessionTransferError, SessionTransferErrorCode } from './errors'; +export { useAuthenticatedWebView } from './useAuthenticatedWebView'; +export type { + AuthenticatedWebView, + AuthenticatedWebViewProps, + UseAuthenticatedWebViewParams, +} from './useAuthenticatedWebView'; diff --git a/packages/expo/src/native-to-web/useAuthenticatedWebView.ts b/packages/expo/src/native-to-web/useAuthenticatedWebView.ts new file mode 100644 index 00000000000..13d086bcb1e --- /dev/null +++ b/packages/expo/src/native-to-web/useAuthenticatedWebView.ts @@ -0,0 +1,259 @@ +import { useAuth } from '@clerk/react'; +import type { Dispatch } from 'react'; +import { useCallback, useEffect, useReducer, useRef, useState } from 'react'; + +import type { SessionTransferError } from './errors'; +import { sessionTransferError, toSessionTransferError } from './errors'; +import type { SessionTransfer, TransferLease } from './sessionTransfer'; +import { acquireTransfer, claimTransfer, resolveTransferRequest } from './sessionTransfer'; +import type { NavigationEvent, NavigationVerdict } from './webViewNavigation'; +import { judgeNavigation } from './webViewNavigation'; + +/** + * Options for `useAuthenticatedWebView`. + */ +export type UseAuthenticatedWebViewParams = { + /** + * Absolute URL on one of your instance's domains. It must use HTTPS outside development instances. + * Changing it prepares a new transfer. + */ + destination: string; +}; + +/** + * Props to spread onto `` from `react-native-webview`. They carry the one-time sign-in URL, + * so never log or store them. If you pass your own `onLoadStart`, `onLoadEnd`, or `onHttpError`, + * call the matching handler from these props first. + */ +export type AuthenticatedWebViewProps = { + source: { uri: string }; + onLoadStart: (event: { nativeEvent: { url: string } }) => void; + onLoadEnd: (event: { nativeEvent: { url: string; code?: number } }) => void; + onHttpError: (event: { nativeEvent: { url: string } }) => void; +}; + +/** + * The state of an authenticated WebView. Keep your loading overlay up while `status` is `preparing`. + */ +export type AuthenticatedWebView = + | { + /** + * The sign-in URL is being prepared, or the WebView is still on Clerk's sign-in page. + * `webViewProps` is `null` until the URL is ready. Render the WebView under your overlay once it is set. + */ + status: 'preparing'; + webViewProps: AuthenticatedWebViewProps | null; + error: null; + retry: () => void; + } + | { + /** + * The WebView has left Clerk's sign-in page and shows the destination, signed in. + */ + status: 'ready'; + webViewProps: AuthenticatedWebViewProps; + error: null; + retry: () => void; + } + | { + /** + * There is no active session in the app, or Clerk reported it signed out. + */ + status: 'signed-out'; + webViewProps: null; + error: null; + retry: () => void; + } + | { + /** + * Preparing, loading, or redeeming the URL failed. `webViewProps` stays set when the WebView + * already loaded the URL, so you can keep showing the page it landed on. `retry` prepares a new URL. + */ + status: 'error'; + webViewProps: AuthenticatedWebViewProps | null; + error: SessionTransferError; + retry: () => void; + }; + +type TransferState = + | { phase: 'redeeming'; requestId: string; webViewProps: AuthenticatedWebViewProps } + | { phase: 'ready'; requestId: string; webViewProps: AuthenticatedWebViewProps } + | { + phase: 'failed'; + requestId: string; + webViewProps: AuthenticatedWebViewProps | null; + error: SessionTransferError; + }; + +type TransferResult = + | { type: 'minted'; requestId: string; webViewProps: AuthenticatedWebViewProps } + | { type: 'mint-failed'; requestId: string; error: SessionTransferError }; + +type TransferAction = TransferResult | { type: 'navigated'; requestId: string; verdict: NavigationVerdict }; + +function transferReducer(state: TransferState | null, action: TransferAction): TransferState | null { + switch (action.type) { + case 'minted': + return { phase: 'redeeming', requestId: action.requestId, webViewProps: action.webViewProps }; + case 'mint-failed': + return { phase: 'failed', requestId: action.requestId, webViewProps: null, error: action.error }; + case 'navigated': + return state?.phase === 'redeeming' && state.requestId === action.requestId + ? applyVerdict(state, action.verdict) + : state; + } +} + +function applyVerdict( + state: Extract, + verdict: NavigationVerdict, +): TransferState { + switch (verdict.kind) { + case 'pending': + return state; + case 'ready': + return { ...state, phase: 'ready' }; + case 'failed': + return { + ...state, + phase: 'failed', + error: sessionTransferError( + verdict.code, + verdict.code === 'load_failed' + ? 'The sign-in page failed to load.' + : 'The web page could not sign the user in.', + ), + }; + } +} + +/** + * Prepares a one-time URL that signs the active user in on `destination`, and returns props for a + * `react-native-webview` `` that opens it. The URL is prepared when the hook mounts, so mount it + * on the screen that shows the WebView. Remounts and React Strict Mode reuse a URL the WebView has not received yet. + * + * @param params - The page to open. + * @returns The WebView state, its props, and a `retry` function. + * + * @example + * ### Show a signed-in web page under a loading overlay + * ```tsx + * import { useAuthenticatedWebView } from '@clerk/expo/native-to-web'; + * import { Redirect } from 'expo-router'; + * import { View } from 'react-native'; + * import { WebView } from 'react-native-webview'; + * + * export default function BillingScreen() { + * const web = useAuthenticatedWebView({ destination: 'https://app.example.com/billing' }); + * + * if (web.status === 'signed-out') return ; + * if (web.status === 'error' && !web.webViewProps) return ; + * + * return ( + * + * {web.webViewProps && } + * {web.status === 'preparing' && } + * + * ); + * } + * ``` + * + * @example + * ### Add your own load handler + * ```tsx + * const { webViewProps } = useAuthenticatedWebView({ destination }); + * + * return ( + * webViewProps && ( + * { + * webViewProps.onLoadEnd(event); + * trackPageLoad(event.nativeEvent.url); + * }} + * /> + * ) + * ); + * ``` + */ +export function useAuthenticatedWebView({ destination }: UseAuthenticatedWebViewParams): AuthenticatedWebView { + const { isLoaded, isSignedIn, sessionId } = useAuth(); + const [attempt, setAttempt] = useState(0); + const [state, dispatch] = useReducer(transferReducer, null); + const settledRequestId = useRef(null); + const requestId = isLoaded && isSignedIn && sessionId ? `${sessionId}\n${destination}\n${attempt}` : null; + + useEffect(() => { + if (!requestId || settledRequestId.current === requestId) { + return; + } + + let attached = true; + const settle = (result: TransferResult) => { + if (attached) { + settledRequestId.current = requestId; + dispatch(result); + } + }; + const fail = (error: unknown) => settle({ type: 'mint-failed', requestId, error: toSessionTransferError(error) }); + + let lease: TransferLease; + try { + lease = acquireTransfer(resolveTransferRequest({ destination, presentation: 'webview' })); + } catch (error) { + fail(error); + return; + } + + lease.transfer.then(transfer => { + if (attached) { + claimTransfer(lease); + settle({ type: 'minted', requestId, webViewProps: createWebViewProps(requestId, transfer, dispatch) }); + } + }, fail); + + return () => { + attached = false; + }; + }, [requestId, destination]); + + const retry = useCallback(() => setAttempt(n => n + 1), []); + + if (!isLoaded) { + return { status: 'preparing', webViewProps: null, error: null, retry }; + } + if (!isSignedIn) { + return { status: 'signed-out', webViewProps: null, error: null, retry }; + } + + const current = state?.requestId === requestId ? state : null; + switch (current?.phase) { + case undefined: + return { status: 'preparing', webViewProps: null, error: null, retry }; + case 'redeeming': + return { status: 'preparing', webViewProps: current.webViewProps, error: null, retry }; + case 'ready': + return { status: 'ready', webViewProps: current.webViewProps, error: null, retry }; + case 'failed': + return current.error.code === 'signed_out' + ? { status: 'signed-out', webViewProps: null, error: null, retry } + : { status: 'error', webViewProps: current.webViewProps, error: current.error, retry }; + } +} + +function createWebViewProps( + requestId: string, + transfer: SessionTransfer, + dispatch: Dispatch, +): AuthenticatedWebViewProps { + const judge = (event: NavigationEvent) => + dispatch({ type: 'navigated', requestId, verdict: judgeNavigation(transfer.url, event) }); + + return { + source: { uri: transfer.url }, + onLoadStart: ({ nativeEvent }) => judge({ kind: 'load-start', url: nativeEvent.url }), + onLoadEnd: ({ nativeEvent }) => + judge({ kind: 'load-end', url: nativeEvent.url, failed: typeof nativeEvent.code === 'number' }), + onHttpError: ({ nativeEvent }) => judge({ kind: 'http-error', url: nativeEvent.url }), + }; +} diff --git a/packages/expo/src/native-to-web/webViewNavigation.ts b/packages/expo/src/native-to-web/webViewNavigation.ts new file mode 100644 index 00000000000..e2c9f38250d --- /dev/null +++ b/packages/expo/src/native-to-web/webViewNavigation.ts @@ -0,0 +1,58 @@ +import type { SessionTransferErrorCode } from './errors'; + +export type NavigationEvent = + | { kind: 'load-start'; url: string } + | { kind: 'load-end'; url: string; failed: boolean } + | { kind: 'http-error'; url: string }; + +export type NavigationVerdict = + | { kind: 'pending' } + | { kind: 'ready' } + | { kind: 'failed'; code: SessionTransferErrorCode }; + +const PENDING: NavigationVerdict = { kind: 'pending' }; +const READY: NavigationVerdict = { kind: 'ready' }; +const LOAD_FAILED: NavigationVerdict = { kind: 'failed', code: 'load_failed' }; +const REDEEM_FAILURE_STATUSES = new Set(['failed', 'session_transfer_failed']); + +export function judgeNavigation(transferUrl: string, event: NavigationEvent): NavigationVerdict { + if (event.kind === 'load-end' && event.failed) { + return LOAD_FAILED; + } + + const url = parseHttpUrl(event.url); + if (!url) { + return PENDING; + } + + if (isRedeemPage(transferUrl, url)) { + return event.kind === 'http-error' ? LOAD_FAILED : PENDING; + } + + const redeemErrorCode = getRedeemErrorCode(url); + if (redeemErrorCode) { + return { kind: 'failed', code: redeemErrorCode }; + } + + return event.kind === 'load-end' ? READY : PENDING; +} + +function isRedeemPage(transferUrl: string, url: URL): boolean { + const redeemUrl = new URL(transferUrl); + return url.origin === redeemUrl.origin && url.pathname === redeemUrl.pathname; +} + +function getRedeemErrorCode(url: URL): string | null { + const code = url.searchParams.get('__clerk_error_code'); + const status = url.searchParams.get('__clerk_status'); + return code && (status === null || REDEEM_FAILURE_STATUSES.has(status)) ? code : null; +} + +function parseHttpUrl(value: string): URL | null { + try { + const url = new URL(value); + return url.protocol === 'https:' || url.protocol === 'http:' ? url : null; + } catch { + return null; + } +} diff --git a/packages/expo/tsconfig.typecheck.json b/packages/expo/tsconfig.typecheck.json new file mode 100644 index 00000000000..c8453e22b56 --- /dev/null +++ b/packages/expo/tsconfig.typecheck.json @@ -0,0 +1,7 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "incremental": false + }, + "include": ["src/**/*.test-d.ts"] +} diff --git a/packages/expo/vitest.config.mts b/packages/expo/vitest.config.mts index 20bc22a33db..04948e371c9 100644 --- a/packages/expo/vitest.config.mts +++ b/packages/expo/vitest.config.mts @@ -6,5 +6,9 @@ export default defineConfig({ environment: 'jsdom', includeSource: ['**/*.{js,ts,jsx,tsx}'], setupFiles: './vitest.setup.mts', + typecheck: { + enabled: true, + tsconfig: './tsconfig.typecheck.json', + }, }, }); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dc4fae29ab5..b129f16f215 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -797,7 +797,7 @@ importers: version: 1.0.0 expo: specifier: catalog:peer-expo - version: 54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10) + version: 54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10) react: specifier: 18.3.1 version: 18.3.1 @@ -828,31 +828,34 @@ importers: version: 1.0.2 expo-apple-authentication: specifier: ^8.0.8 - version: 8.0.8(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10)) + version: 8.0.8(cf5e99b460473b4cfad5bfd24c3e414a) expo-application: specifier: ^7.0.8 - version: 7.0.8(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10)) + version: 7.0.8(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10)) expo-auth-session: specifier: ^7.0.11 - version: 7.0.11(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2) + version: 7.0.11(07ddf7a47e1ef7a31fb43d5b73e68e70) expo-constants: specifier: ^18.0.13 - version: 18.0.13(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(supports-color@10.2.2) + version: 18.0.13(2a5bb27999b3fa4ca231c24495b87abe) expo-crypto: specifier: ^15.0.9 - version: 15.0.9(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10)) + version: 15.0.9(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10)) expo-local-authentication: specifier: ^17.0.8 - version: 17.0.8(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10)) + version: 17.0.8(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10)) expo-secure-store: specifier: ^15.0.8 - version: 15.0.8(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10)) + version: 15.0.8(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10)) expo-web-browser: specifier: ^15.0.11 - version: 15.0.11(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10)) + version: 15.0.11(cf5e99b460473b4cfad5bfd24c3e414a) react-native: specifier: ^0.86.0 version: 0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10) + react-native-webview: + specifier: ^14.0.1 + version: 14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1) tslib: specifier: catalog:repo version: 2.8.1 @@ -13051,6 +13054,12 @@ packages: peerDependencies: react-native: '*' + react-native-webview@14.0.1: + resolution: {integrity: sha512-lBUuvFk459Mo+5vUNqwzGvn+I8rDJE6zvOulG2hWWudeEOJfWTsQwOfuYnRu+27djUIXrESFfC+EQE3PGu+FWA==} + peerDependencies: + react: 18.3.1 + react-native: '*' + react-native@0.86.0: resolution: {integrity: sha512-17ALh/dd6AO4pgOVmOO5Axll5PbErEo3XFyLokyzW6usyi+OShIEPwUW26wLPlhVifgSOIfECCH0WN+0IqtJ1w==} engines: {node: ^20.19.4 || ^22.13.0 || ^24.3.0 || >= 25.0.0} @@ -17414,6 +17423,81 @@ snapshots: '@eslint/core': 0.15.2 levn: 0.4.1 + '@expo/cli@54.0.26(23a1516f07678ee4e0e007f935f07e03)': + dependencies: + '@0no-co/graphql.web': 1.2.0(graphql@16.14.1) + '@expo/code-signing-certificates': 0.0.6 + '@expo/config': 12.0.14(supports-color@10.2.2) + '@expo/config-plugins': 54.0.5(supports-color@10.2.2) + '@expo/devcert': 1.2.1(supports-color@10.2.2) + '@expo/env': 2.0.12(supports-color@10.2.2) + '@expo/image-utils': 0.8.14(supports-color@10.2.2)(typescript@5.9.3) + '@expo/json-file': 10.2.0 + '@expo/metro': 54.2.0(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10) + '@expo/metro-config': 54.0.17(bufferutil@4.1.0)(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10))(supports-color@10.2.2)(utf-8-validate@5.0.10) + '@expo/osascript': 2.7.0 + '@expo/package-manager': 1.13.0 + '@expo/plist': 0.4.9 + '@expo/prebuild-config': 54.0.9(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10))(supports-color@10.2.2)(typescript@5.9.3) + '@expo/schema-utils': 0.1.9 + '@expo/spawn-async': 1.8.0 + '@expo/ws-tunnel': 1.0.6 + '@expo/xcpretty': 4.3.2 + '@react-native/dev-middleware': 0.81.5(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10) + '@urql/core': 5.2.0(graphql@16.14.1) + '@urql/exchange-retry': 1.3.2(@urql/core@5.2.0(graphql@16.14.1)) + accepts: 1.3.8 + arg: 5.0.2 + better-opn: 3.0.2 + bplist-creator: 0.1.0 + bplist-parser: 0.3.2 + chalk: 4.1.2 + ci-info: 3.9.0 + compression: 1.8.1(supports-color@10.2.2) + connect: 3.7.0(supports-color@10.2.2) + debug: 4.4.3(supports-color@10.2.2) + env-editor: 0.4.2 + expo: 54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10) + expo-server: 1.0.7 + freeport-async: 2.0.0 + getenv: 2.0.0 + glob: 13.0.6 + lan-network: 0.2.1 + minimatch: 9.0.7 + node-forge: 1.4.0 + npm-package-arg: 11.0.3 + ora: 3.4.0 + picomatch: 4.0.7 + pretty-bytes: 5.6.0 + pretty-format: 29.7.0 + progress: 2.0.3 + prompts: 2.4.2 + qrcode-terminal: 0.11.0 + require-from-string: 2.0.2 + requireg: 0.2.2 + resolve: 1.22.11 + resolve-from: 5.0.0 + resolve.exports: 2.0.3 + semver: 7.7.4 + send: 0.19.2(supports-color@10.2.2) + slugify: 1.6.6 + source-map-support: 0.5.21 + stacktrace-parser: 0.1.11 + structured-headers: 0.4.1 + tar: 7.5.21 + terminal-link: 2.1.1 + undici: 6.22.0 + wrap-ansi: 7.0.0 + ws: 8.21.3(bufferutil@4.1.0)(utf-8-validate@5.0.10) + optionalDependencies: + react-native: 0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - bufferutil + - graphql + - supports-color + - typescript + - utf-8-validate + '@expo/cli@54.0.26(bufferutil@4.1.0)(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10))(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10)': dependencies: '@0no-co/graphql.web': 1.2.0(graphql@16.14.1) @@ -17688,6 +17772,36 @@ snapshots: '@babel/code-frame': 7.29.7 json5: 2.2.3 + '@expo/metro-config@54.0.17(bufferutil@4.1.0)(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10))(supports-color@10.2.2)(utf-8-validate@5.0.10)': + dependencies: + '@babel/code-frame': 7.29.7 + '@babel/core': 7.29.7(supports-color@10.2.2) + '@babel/generator': 7.29.8 + '@expo/config': 12.0.14(supports-color@10.2.2) + '@expo/env': 2.0.12(supports-color@10.2.2) + '@expo/json-file': 10.0.16 + '@expo/metro': 54.2.0(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10) + '@expo/spawn-async': 1.8.0 + browserslist: 4.28.8 + chalk: 4.1.2 + debug: 4.4.3(supports-color@10.2.2) + dotenv: 16.4.7 + dotenv-expand: 11.0.7 + getenv: 2.0.0 + glob: 13.0.6 + hermes-parser: 0.29.1 + jsc-safe-url: 0.2.4 + lightningcss: 1.33.0 + picomatch: 4.0.7 + postcss: 8.4.49 + resolve-from: 5.0.0 + optionalDependencies: + expo: 54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + '@expo/metro-config@54.0.17(bufferutil@4.1.0)(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10))(supports-color@10.2.2)(utf-8-validate@5.0.10)': dependencies: '@babel/code-frame': 7.29.7 @@ -17788,6 +17902,23 @@ snapshots: base64-js: 1.5.1 xmlbuilder: 15.1.1 + '@expo/prebuild-config@54.0.9(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10))(supports-color@10.2.2)(typescript@5.9.3)': + dependencies: + '@expo/config': 12.0.14(supports-color@10.2.2) + '@expo/config-plugins': 54.0.5(supports-color@10.2.2) + '@expo/config-types': 54.0.10 + '@expo/image-utils': 0.8.14(supports-color@10.2.2)(typescript@5.9.3) + '@expo/json-file': 10.2.0 + '@react-native/normalize-colors': 0.81.5 + debug: 4.4.3(supports-color@10.2.2) + expo: 54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10) + resolve-from: 5.0.0 + semver: 7.7.4 + xml2js: 0.6.0 + transitivePeerDependencies: + - supports-color + - typescript + '@expo/prebuild-config@54.0.9(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10))(supports-color@10.2.2)(typescript@5.9.3)': dependencies: '@expo/config': 12.0.14(supports-color@10.2.2) @@ -17864,6 +17995,12 @@ snapshots: react: 18.3.1 react-native: 0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10) + '@expo/vector-icons@15.0.3(expo-font@14.0.12(718debe5aff8ec74c2edde4bb439cb53))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)': + dependencies: + expo-font: 14.0.12(718debe5aff8ec74c2edde4bb439cb53) + react: 18.3.1 + react-native: 0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10) + '@expo/ws-tunnel@1.0.6': {} '@expo/xcpretty@4.3.2': @@ -18218,7 +18355,7 @@ snapshots: dependencies: '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 22.20.1 + '@types/node': 24.19.2 '@types/yargs': 15.0.20 chalk: 4.1.2 optional: true @@ -21431,7 +21568,7 @@ snapshots: '@types/ws@7.4.7': dependencies: - '@types/node': 22.20.1 + '@types/node': 12.20.55 '@types/ws@8.18.1': dependencies: @@ -22862,6 +22999,38 @@ snapshots: transitivePeerDependencies: - '@babel/core' + babel-preset-expo@54.0.12(@babel/core@7.29.7(supports-color@10.2.2))(@babel/runtime@7.29.2)(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10))(react-refresh@0.14.2)(supports-color@10.2.2): + dependencies: + '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) + '@babel/plugin-proposal-decorators': 7.28.0(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-proposal-export-default-from': 7.27.1(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-syntax-export-default-from': 7.27.1(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-class-static-block': 7.28.3(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-flow-strip-types': 7.27.1(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-modules-commonjs': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-object-rest-spread': 7.28.4(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-parameters': 7.27.7(@babel/core@7.29.7(supports-color@10.2.2)) + '@babel/plugin-transform-private-methods': 7.27.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-private-property-in-object': 7.27.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/plugin-transform-runtime': 7.28.5(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/preset-react': 7.28.5(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@babel/preset-typescript': 7.29.7(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + '@react-native/babel-preset': 0.81.5(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2) + babel-plugin-react-compiler: 1.0.0 + babel-plugin-react-native-web: 0.21.2 + babel-plugin-syntax-hermes-parser: 0.29.1 + babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.29.7(supports-color@10.2.2)) + debug: 4.4.3(supports-color@10.2.2) + react-refresh: 0.14.2 + resolve-from: 5.0.0 + optionalDependencies: + '@babel/runtime': 7.29.2 + expo: 54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - '@babel/core' + - supports-color + babel-preset-expo@54.0.12(@babel/core@7.29.7(supports-color@10.2.2))(@babel/runtime@7.29.2)(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10))(react-refresh@0.14.2)(supports-color@10.2.2): dependencies: '@babel/helper-module-imports': 7.29.7(supports-color@10.2.2) @@ -24949,14 +25118,14 @@ snapshots: expect-type@1.4.0: {} - expo-apple-authentication@8.0.8(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10)): + expo-apple-authentication@8.0.8(cf5e99b460473b4cfad5bfd24c3e414a): dependencies: - expo: 54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10) + expo: 54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10) react-native: 0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10) - expo-application@7.0.8(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10)): + expo-application@7.0.8(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10)): dependencies: - expo: 54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10) + expo: 54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10) expo-asset@12.0.13(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3): dependencies: @@ -24980,13 +25149,24 @@ snapshots: - supports-color - typescript - expo-auth-session@7.0.11(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2): + expo-asset@12.0.13(fb19d2153f54c7b7e4b37279ee6ffd45): dependencies: - expo-application: 7.0.8(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10)) - expo-constants: 18.0.13(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(supports-color@10.2.2) - expo-crypto: 15.0.9(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10)) - expo-linking: 8.0.12(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2) - expo-web-browser: 15.0.11(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10)) + '@expo/image-utils': 0.8.14(supports-color@10.2.2)(typescript@5.9.3) + expo: 54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10) + expo-constants: 18.0.13(2a5bb27999b3fa4ca231c24495b87abe) + react: 18.3.1 + react-native: 0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - supports-color + - typescript + + expo-auth-session@7.0.11(07ddf7a47e1ef7a31fb43d5b73e68e70): + dependencies: + expo-application: 7.0.8(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10)) + expo-constants: 18.0.13(2a5bb27999b3fa4ca231c24495b87abe) + expo-crypto: 15.0.9(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10)) + expo-linking: 8.0.12(07ddf7a47e1ef7a31fb43d5b73e68e70) + expo-web-browser: 15.0.11(cf5e99b460473b4cfad5bfd24c3e414a) invariant: 2.2.4 react: 18.3.1 react-native: 0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10) @@ -24994,6 +25174,15 @@ snapshots: - expo - supports-color + expo-constants@18.0.13(2a5bb27999b3fa4ca231c24495b87abe): + dependencies: + '@expo/config': 12.0.14(supports-color@10.2.2) + '@expo/env': 2.0.12(supports-color@10.2.2) + expo: 54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10) + react-native: 0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10) + transitivePeerDependencies: + - supports-color + expo-constants@18.0.13(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(supports-color@10.2.2): dependencies: '@expo/config': 12.0.14(supports-color@10.2.2) @@ -25012,10 +25201,15 @@ snapshots: transitivePeerDependencies: - supports-color - expo-crypto@15.0.9(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10)): + expo-crypto@15.0.9(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10)): dependencies: base64-js: 1.5.1 - expo: 54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10) + expo: 54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10) + + expo-file-system@19.0.23(cf5e99b460473b4cfad5bfd24c3e414a): + dependencies: + expo: 54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10) + react-native: 0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10) expo-file-system@19.0.23(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10)): dependencies: @@ -25027,6 +25221,13 @@ snapshots: expo: 54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@6.0.3)(utf-8-validate@5.0.10) react-native: 0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10) + expo-font@14.0.12(718debe5aff8ec74c2edde4bb439cb53): + dependencies: + expo: 54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10) + fontfaceobserver: 2.3.0 + react: 18.3.1 + react-native: 0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10) + expo-font@14.0.12(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1): dependencies: expo: 54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10) @@ -25041,6 +25242,11 @@ snapshots: react: 18.3.1 react-native: 0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10) + expo-keep-awake@15.0.8(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10))(react@18.3.1): + dependencies: + expo: 54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10) + react: 18.3.1 + expo-keep-awake@15.0.8(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10))(react@18.3.1): dependencies: expo: 54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10) @@ -25051,9 +25257,9 @@ snapshots: expo: 54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@6.0.3)(utf-8-validate@5.0.10) react: 18.3.1 - expo-linking@8.0.12(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2): + expo-linking@8.0.12(07ddf7a47e1ef7a31fb43d5b73e68e70): dependencies: - expo-constants: 18.0.13(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(supports-color@10.2.2) + expo-constants: 18.0.13(2a5bb27999b3fa4ca231c24495b87abe) invariant: 2.2.4 react: 18.3.1 react-native: 0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10) @@ -25061,9 +25267,9 @@ snapshots: - expo - supports-color - expo-local-authentication@17.0.8(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10)): + expo-local-authentication@17.0.8(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10)): dependencies: - expo: 54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10) + expo: 54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10) invariant: 2.2.4 expo-modules-autolinking@3.0.26: @@ -25080,17 +25286,53 @@ snapshots: react: 18.3.1 react-native: 0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10) - expo-secure-store@15.0.8(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10)): + expo-secure-store@15.0.8(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10)): dependencies: - expo: 54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10) + expo: 54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10) expo-server@1.0.7: {} - expo-web-browser@15.0.11(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10)): + expo-web-browser@15.0.11(cf5e99b460473b4cfad5bfd24c3e414a): dependencies: - expo: 54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10) + expo: 54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10) react-native: 0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10) + expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10): + dependencies: + '@babel/runtime': 7.29.2 + '@expo/cli': 54.0.26(23a1516f07678ee4e0e007f935f07e03) + '@expo/config': 12.0.14(supports-color@10.2.2) + '@expo/config-plugins': 54.0.5(supports-color@10.2.2) + '@expo/devtools': 0.1.8(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1) + '@expo/fingerprint': 0.15.5(supports-color@10.2.2) + '@expo/metro': 54.2.0(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10) + '@expo/metro-config': 54.0.17(bufferutil@4.1.0)(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10))(supports-color@10.2.2)(utf-8-validate@5.0.10) + '@expo/vector-icons': 15.0.3(expo-font@14.0.12(718debe5aff8ec74c2edde4bb439cb53))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1) + '@ungap/structured-clone': 1.3.0 + babel-preset-expo: 54.0.12(@babel/core@7.29.7(supports-color@10.2.2))(@babel/runtime@7.29.2)(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10))(react-refresh@0.14.2)(supports-color@10.2.2) + expo-asset: 12.0.13(fb19d2153f54c7b7e4b37279ee6ffd45) + expo-constants: 18.0.13(2a5bb27999b3fa4ca231c24495b87abe) + expo-file-system: 19.0.23(cf5e99b460473b4cfad5bfd24c3e414a) + expo-font: 14.0.12(718debe5aff8ec74c2edde4bb439cb53) + expo-keep-awake: 15.0.8(expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1))(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10))(react@18.3.1) + expo-modules-autolinking: 3.0.26 + expo-modules-core: 3.0.30(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1) + pretty-format: 29.7.0 + react: 18.3.1 + react-native: 0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10) + react-refresh: 0.14.2 + whatwg-url-without-unicode: 8.0.0-3 + optionalDependencies: + react-native-webview: 14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1) + transitivePeerDependencies: + - '@babel/core' + - bufferutil + - expo-router + - graphql + - supports-color + - typescript + - utf-8-validate + expo@54.0.36(@babel/core@7.29.7(supports-color@10.2.2))(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1)(supports-color@10.2.2)(typescript@5.9.3)(utf-8-validate@5.0.10): dependencies: '@babel/runtime': 7.29.2 @@ -26603,7 +26845,7 @@ snapshots: jest-worker@27.5.1: dependencies: - '@types/node': 22.20.1 + '@types/node': 24.19.2 merge-stream: 2.0.0 supports-color: 8.1.1 optional: true @@ -29652,6 +29894,13 @@ snapshots: dependencies: react-native: 0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10) + react-native-webview@14.0.1(react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10))(react@18.3.1): + dependencies: + escape-string-regexp: 4.0.0 + invariant: 2.2.4 + react: 18.3.1 + react-native: 0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10) + react-native@0.86.0(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(supports-color@10.2.2)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(supports-color@10.2.2)(utf-8-validate@5.0.10): dependencies: '@react-native/assets-registry': 0.86.0 @@ -30192,7 +30441,7 @@ snapshots: rpc-websockets@9.3.8: dependencies: - '@swc/helpers': 0.5.21 + '@swc/helpers': 0.5.23 '@types/uuid': 10.0.0 '@types/ws': 8.18.1 buffer: 6.0.3 From 6e8bb35bf297f9a1a7d556ced3be6262a2f5f787 Mon Sep 17 00:00:00 2001 From: wobsoriano Date: Tue, 10 Nov 2026 17:32:01 -0800 Subject: [PATCH 4/5] feat(expo): add useNativeToWeb to open a signed-in system browser --- packages/expo/package.json | 5 + .../expo/src/app-version/nativeAppIdentity.ts | 2 +- .../__tests__/launchBrowser.android.test.ts | 158 +++++++++++ .../__tests__/useNativeToWeb.test.ts | 247 ++++++++++++++++++ packages/expo/src/native-to-web/index.ts | 2 + .../native-to-web/launchBrowser.android.ts | 53 ++++ .../expo/src/native-to-web/launchBrowser.ts | 17 ++ .../expo/src/native-to-web/useNativeToWeb.ts | 93 +++++++ pnpm-lock.yaml | 12 + 9 files changed, 588 insertions(+), 1 deletion(-) create mode 100644 packages/expo/src/native-to-web/__tests__/launchBrowser.android.test.ts create mode 100644 packages/expo/src/native-to-web/__tests__/useNativeToWeb.test.ts create mode 100644 packages/expo/src/native-to-web/launchBrowser.android.ts create mode 100644 packages/expo/src/native-to-web/launchBrowser.ts create mode 100644 packages/expo/src/native-to-web/useNativeToWeb.ts diff --git a/packages/expo/package.json b/packages/expo/package.json index 3f72c53da17..fcc5b575df6 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -141,6 +141,7 @@ "expo-auth-session": "^7.0.11", "expo-constants": "^18.0.13", "expo-crypto": "^15.0.9", + "expo-intent-launcher": "^13.0.8", "expo-local-authentication": "^17.0.8", "expo-secure-store": "^15.0.8", "expo-web-browser": "^15.0.11", @@ -159,6 +160,7 @@ "expo-auth-session": ">=5", "expo-constants": ">=12", "expo-crypto": ">=12", + "expo-intent-launcher": ">=12", "expo-local-authentication": ">=13.5.0", "expo-secure-store": ">=12.4.0", "expo-web-browser": ">=12.5.0", @@ -194,6 +196,9 @@ "expo-crypto": { "optional": true }, + "expo-intent-launcher": { + "optional": true + }, "expo-local-authentication": { "optional": true }, diff --git a/packages/expo/src/app-version/nativeAppIdentity.ts b/packages/expo/src/app-version/nativeAppIdentity.ts index 578f3cd55c6..f9db0635dc8 100644 --- a/packages/expo/src/app-version/nativeAppIdentity.ts +++ b/packages/expo/src/app-version/nativeAppIdentity.ts @@ -53,7 +53,7 @@ export function isExpoApplicationInstalled(): boolean { return loadExpoApplication() !== null; } -function loadExpoApplication(): ExpoApplication | null { +export function loadExpoApplication(): ExpoApplication | null { try { // eslint-disable-next-line @typescript-eslint/no-require-imports return require('expo-application') as ExpoApplication; diff --git a/packages/expo/src/native-to-web/__tests__/launchBrowser.android.test.ts b/packages/expo/src/native-to-web/__tests__/launchBrowser.android.test.ts new file mode 100644 index 00000000000..5034af8d58b --- /dev/null +++ b/packages/expo/src/native-to-web/__tests__/launchBrowser.android.test.ts @@ -0,0 +1,158 @@ +import Module from 'node:module'; + +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { resolveBrowserLauncher } from '../launchBrowser.android'; + +const moduleWithLoad = Module as unknown as { + _load: (request: string, parent?: unknown, isMain?: boolean) => unknown; +}; +const originalModuleLoad = moduleWithLoad._load; + +const mocks = vi.hoisted(() => { + return { + appStateListeners: [] as Array<(state: string) => void>, + removeSubscription: vi.fn(), + startActivityAsync: vi.fn(), + applicationId: 'com.example.app' as string | null, + expoApplicationInstalled: true, + androidPackage: undefined as string | undefined, + }; +}); + +vi.mock('react-native', () => { + return { + Platform: { OS: 'android' }, + AppState: { + addEventListener: (_: string, listener: (state: string) => void) => { + mocks.appStateListeners.push(listener); + return { remove: mocks.removeSubscription }; + }, + }, + }; +}); + +const TRANSFER_URL = 'https://clerk.example.com/v1/session_transfers/redeem?token=one-time'; + +function pending() { + let resolve: (value: unknown) => void = () => {}; + let reject: (reason: unknown) => void = () => {}; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +function changeAppState(state: string) { + mocks.appStateListeners.forEach(listener => listener(state)); +} + +async function outcome(promise: Promise) { + const settled = await Promise.race([ + promise.then( + () => 'resolved', + (error: unknown) => error, + ), + new Promise(resolve => setTimeout(() => resolve('pending'), 0)), + ]); + return settled; +} + +describe('resolveBrowserLauncher on Android', () => { + beforeEach(() => { + mocks.appStateListeners = []; + mocks.removeSubscription.mockReset(); + mocks.startActivityAsync.mockReset(); + mocks.applicationId = 'com.example.app'; + mocks.expoApplicationInstalled = true; + mocks.androidPackage = undefined; + vi.spyOn(moduleWithLoad, '_load').mockImplementation((request, parent, isMain) => { + if (request === 'expo-intent-launcher') { + return { startActivityAsync: mocks.startActivityAsync }; + } + if (request === 'expo-application') { + if (!mocks.expoApplicationInstalled) { + throw new Error("Cannot find module 'expo-application'"); + } + return { applicationId: mocks.applicationId }; + } + if (request === 'expo-constants') { + return { + default: { executionEnvironment: 'standalone', expoConfig: { android: { package: mocks.androidPackage } } }, + }; + } + return originalModuleLoad.call(Module, request, parent, isMain); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('sends REFERRER_NAME with the application id and leaves the browser choice to Android', async () => { + mocks.startActivityAsync.mockResolvedValue({ resultCode: 0 }); + + await resolveBrowserLauncher()(TRANSFER_URL); + + expect(mocks.startActivityAsync).toHaveBeenCalledWith('android.intent.action.VIEW', { + data: TRANSFER_URL, + extra: { 'android.intent.extra.REFERRER_NAME': 'android-app://com.example.app' }, + }); + expect(mocks.startActivityAsync.mock.calls[0][1]).not.toHaveProperty('packageName'); + }); + + test('falls back to the package name from the app config without expo-application', async () => { + mocks.expoApplicationInstalled = false; + mocks.androidPackage = 'com.example.config'; + mocks.startActivityAsync.mockResolvedValue({ resultCode: 0 }); + + await resolveBrowserLauncher()(TRANSFER_URL); + + expect(mocks.startActivityAsync).toHaveBeenCalledWith( + 'android.intent.action.VIEW', + expect.objectContaining({ extra: { 'android.intent.extra.REFERRER_NAME': 'android-app://com.example.config' } }), + ); + }); + + test('throws missing_dependency when no package name is available', () => { + mocks.expoApplicationInstalled = false; + + expect(() => resolveBrowserLauncher()).toThrow( + expect.objectContaining({ code: 'missing_dependency', message: expect.stringContaining('expo-application') }), + ); + }); + + test('resolves when the launch settles', async () => { + mocks.startActivityAsync.mockResolvedValue({ resultCode: 0 }); + + await expect(outcome(resolveBrowserLauncher()(TRANSFER_URL))).resolves.toBe('resolved'); + expect(mocks.removeSubscription).toHaveBeenCalled(); + }); + + test('resolves when the app leaves the foreground before the launch settles', async () => { + const launch = pending(); + mocks.startActivityAsync.mockReturnValue(launch.promise); + const opening = resolveBrowserLauncher()(TRANSFER_URL); + + changeAppState('active'); + await expect(outcome(opening)).resolves.toBe('pending'); + changeAppState('background'); + + await expect(outcome(opening)).resolves.toBe('resolved'); + expect(mocks.removeSubscription).toHaveBeenCalled(); + launch.reject(new Error('activity finished')); + await expect(outcome(opening)).resolves.toBe('resolved'); + }); + + test('rejects with browser_unavailable when the launch fails first', async () => { + mocks.startActivityAsync.mockRejectedValue(new Error(`No Activity found to handle Intent { dat=${TRANSFER_URL} }`)); + + const result = await outcome(resolveBrowserLauncher()(TRANSFER_URL)); + + expect(result).toMatchObject({ name: 'SessionTransferError', code: 'browser_unavailable' }); + expect(result).not.toHaveProperty('cause'); + expect(String(result)).not.toContain('one-time'); + expect(mocks.removeSubscription).toHaveBeenCalled(); + }); +}); diff --git a/packages/expo/src/native-to-web/__tests__/useNativeToWeb.test.ts b/packages/expo/src/native-to-web/__tests__/useNativeToWeb.test.ts new file mode 100644 index 00000000000..59cd0e0e00c --- /dev/null +++ b/packages/expo/src/native-to-web/__tests__/useNativeToWeb.test.ts @@ -0,0 +1,247 @@ +import Module from 'node:module'; + +import { renderHook } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import { useNativeToWeb } from '../useNativeToWeb'; + +const moduleWithLoad = Module as unknown as { + _load: (request: string, parent?: unknown, isMain?: boolean) => unknown; +}; +const originalModuleLoad = moduleWithLoad._load; + +const mocks = vi.hoisted(() => { + return { + platformOS: 'ios', + launcher: 'linking' as 'linking' | 'android', + openURL: vi.fn(), + addEventListener: vi.fn(), + startActivityAsync: vi.fn(), + intentLauncherInstalled: true, + getClerkInstance: vi.fn(), + request: vi.fn(), + sessionId: 'sess_0' as string | null, + }; +}); + +vi.mock('react-native', () => { + return { + Platform: { + get OS() { + return mocks.platformOS; + }, + }, + Linking: { openURL: mocks.openURL }, + AppState: { addEventListener: mocks.addEventListener }, + }; +}); + +vi.mock('../launchBrowser', async () => { + const linking = await vi.importActual('../launchBrowser'); + const android = await vi.importActual('../launchBrowser.android'); + return { + resolveBrowserLauncher: () => + mocks.launcher === 'android' ? android.resolveBrowserLauncher() : linking.resolveBrowserLauncher(), + }; +}); + +vi.mock('../../provider/singleton', () => { + return { + getClerkInstance: mocks.getClerkInstance, + }; +}); + +const DESTINATION = 'https://app.example.com/settings'; +const TRANSFER_URL = 'https://clerk.example.com/v1/session_transfers/redeem?token=one-time'; + +function mintedResponse() { + return { + ok: true, + status: 200, + statusText: 'OK', + payload: { response: { object: 'session_transfer', url: TRANSFER_URL, expires_at: Date.now() + 60_000 } }, + }; +} + +function pending() { + let resolve: (value: T) => void = () => {}; + const promise = new Promise(r => { + resolve = r; + }); + return { promise, resolve }; +} + +let sessionCount = 0; + +describe('useNativeToWeb', () => { + beforeEach(() => { + sessionCount += 1; + mocks.sessionId = `sess_${sessionCount}`; + mocks.platformOS = 'ios'; + mocks.launcher = 'linking'; + mocks.intentLauncherInstalled = true; + mocks.openURL.mockReset().mockResolvedValue(undefined); + mocks.addEventListener.mockReset().mockReturnValue({ remove: vi.fn() }); + mocks.startActivityAsync.mockReset().mockResolvedValue({ resultCode: 0 }); + mocks.request.mockReset().mockResolvedValue(mintedResponse()); + mocks.getClerkInstance.mockReset().mockImplementation(() => ({ + loaded: true, + session: mocks.sessionId ? { id: mocks.sessionId } : null, + getFapiClient: () => ({ request: mocks.request }), + })); + vi.spyOn(moduleWithLoad, '_load').mockImplementation((request, parent, isMain) => { + if (request === 'expo-intent-launcher') { + if (!mocks.intentLauncherInstalled) { + throw new Error("Cannot find module 'expo-intent-launcher'"); + } + return { startActivityAsync: mocks.startActivityAsync }; + } + if (request === 'expo-application') { + return { applicationId: 'com.example.app' }; + } + return originalModuleLoad.call(Module, request, parent, isMain); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('returns the same openExternalBrowser on every render', () => { + const { result, rerender } = renderHook(() => useNativeToWeb()); + const first = result.current; + + rerender(); + + expect(result.current).toBe(first); + expect(result.current.openExternalBrowser).toBe(first.openExternalBrowser); + }); + + test('mints a browser transfer and opens it', async () => { + const { openExternalBrowser } = useNativeToWeb(); + + await openExternalBrowser({ destination: DESTINATION }); + + expect(mocks.request).toHaveBeenCalledWith( + expect.objectContaining({ body: { redirectUrl: DESTINATION, presentationMode: 'browser' } }), + ); + expect(mocks.openURL).toHaveBeenCalledWith(TRANSFER_URL); + }); + + test('shares one mint and one launch between overlapping calls', async () => { + const { openExternalBrowser } = useNativeToWeb(); + + const first = openExternalBrowser({ destination: DESTINATION }); + const second = openExternalBrowser({ destination: DESTINATION }); + await Promise.all([first, second]); + + expect(second).toBe(first); + expect(mocks.request).toHaveBeenCalledTimes(1); + expect(mocks.openURL).toHaveBeenCalledTimes(1); + }); + + test('joins the open in flight after its URL was claimed', async () => { + const opened = pending(); + mocks.openURL.mockReturnValue(opened.promise); + const { openExternalBrowser } = useNativeToWeb(); + + const first = openExternalBrowser({ destination: DESTINATION }); + await vi.waitFor(() => expect(mocks.openURL).toHaveBeenCalledTimes(1)); + const second = openExternalBrowser({ destination: DESTINATION }); + opened.resolve(); + await Promise.all([first, second]); + + expect(second).toBe(first); + expect(mocks.request).toHaveBeenCalledTimes(1); + expect(mocks.openURL).toHaveBeenCalledTimes(1); + }); + + test('mints again once the previous open finished', async () => { + const { openExternalBrowser } = useNativeToWeb(); + + await openExternalBrowser({ destination: DESTINATION }); + await openExternalBrowser({ destination: DESTINATION }); + + expect(mocks.request).toHaveBeenCalledTimes(2); + expect(mocks.openURL).toHaveBeenCalledTimes(2); + }); + + test('rejects with browser_unavailable without exposing the URL', async () => { + mocks.openURL.mockRejectedValue(new Error(`Unable to open URL: ${TRANSFER_URL}`)); + const { openExternalBrowser } = useNativeToWeb(); + + const error = await openExternalBrowser({ destination: DESTINATION }).then( + () => null, + (e: unknown) => e, + ); + + expect(error).toMatchObject({ name: 'SessionTransferError', code: 'browser_unavailable' }); + expect(error).not.toHaveProperty('cause'); + expect(JSON.stringify(error)).not.toContain('one-time'); + expect(String(error)).not.toContain('one-time'); + }); + + test('rejects with signed_out without minting', async () => { + mocks.sessionId = null; + const { openExternalBrowser } = useNativeToWeb(); + + await expect(openExternalBrowser({ destination: DESTINATION })).rejects.toMatchObject({ code: 'signed_out' }); + expect(mocks.request).not.toHaveBeenCalled(); + }); + + test('rejects with unsupported_platform on web', async () => { + mocks.platformOS = 'web'; + const { openExternalBrowser } = useNativeToWeb(); + + await expect(openExternalBrowser({ destination: DESTINATION })).rejects.toMatchObject({ + code: 'unsupported_platform', + }); + expect(mocks.request).not.toHaveBeenCalled(); + }); + + describe('on Android', () => { + beforeEach(() => { + mocks.platformOS = 'android'; + mocks.launcher = 'android'; + }); + + test('opens the transfer with a VIEW intent that names the app as referrer', async () => { + const { openExternalBrowser } = useNativeToWeb(); + + await openExternalBrowser({ destination: DESTINATION }); + + expect(mocks.startActivityAsync).toHaveBeenCalledTimes(1); + expect(mocks.startActivityAsync).toHaveBeenCalledWith('android.intent.action.VIEW', { + data: TRANSFER_URL, + extra: { 'android.intent.extra.REFERRER_NAME': 'android-app://com.example.app' }, + }); + expect(mocks.openURL).not.toHaveBeenCalled(); + }); + + test('fails with missing_dependency before minting when expo-intent-launcher is missing', async () => { + mocks.intentLauncherInstalled = false; + const { openExternalBrowser } = useNativeToWeb(); + + const error = await openExternalBrowser({ destination: DESTINATION }).then( + () => null, + (e: unknown) => e, + ); + + expect(error).toMatchObject({ code: 'missing_dependency' }); + expect(String(error)).toContain('npx expo install expo-intent-launcher'); + expect(mocks.request).not.toHaveBeenCalled(); + }); + + test('shares one mint and one intent between overlapping calls', async () => { + const { openExternalBrowser } = useNativeToWeb(); + + await Promise.all([ + openExternalBrowser({ destination: DESTINATION }), + openExternalBrowser({ destination: DESTINATION }), + ]); + + expect(mocks.request).toHaveBeenCalledTimes(1); + expect(mocks.startActivityAsync).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/packages/expo/src/native-to-web/index.ts b/packages/expo/src/native-to-web/index.ts index 652e5c7557d..9e3439377e5 100644 --- a/packages/expo/src/native-to-web/index.ts +++ b/packages/expo/src/native-to-web/index.ts @@ -6,3 +6,5 @@ export type { AuthenticatedWebViewProps, UseAuthenticatedWebViewParams, } from './useAuthenticatedWebView'; +export { useNativeToWeb } from './useNativeToWeb'; +export type { OpenExternalBrowserParams, UseNativeToWebReturn } from './useNativeToWeb'; diff --git a/packages/expo/src/native-to-web/launchBrowser.android.ts b/packages/expo/src/native-to-web/launchBrowser.android.ts new file mode 100644 index 00000000000..509fb2c76e6 --- /dev/null +++ b/packages/expo/src/native-to-web/launchBrowser.android.ts @@ -0,0 +1,53 @@ +import type * as IntentLauncher from 'expo-intent-launcher'; +import { AppState } from 'react-native'; + +import { loadExpoApplication } from '../app-version/nativeAppIdentity'; +import { getExpoAppIdentifier } from '../utils/appIdentifier'; +import { sessionTransferError } from './errors'; + +export function resolveBrowserLauncher(): (url: string) => Promise { + const { startActivityAsync } = loadIntentLauncher(); + const packageName = loadExpoApplication()?.applicationId ?? getExpoAppIdentifier(); + if (!packageName) { + throw sessionTransferError( + 'missing_dependency', + 'Opening a signed-in browser on Android needs the app package name. Install expo-application by running: npx expo install expo-application', + ); + } + + return url => + new Promise((resolve, reject) => { + const subscription = AppState.addEventListener('change', state => { + if (state !== 'active') { + subscription.remove(); + resolve(); + } + }); + + startActivityAsync('android.intent.action.VIEW', { + data: url, + extra: { 'android.intent.extra.REFERRER_NAME': `android-app://${packageName}` }, + }).then( + () => { + subscription.remove(); + resolve(); + }, + () => { + subscription.remove(); + reject(sessionTransferError('browser_unavailable', 'No browser could open the sign-in link.')); + }, + ); + }); +} + +function loadIntentLauncher(): typeof IntentLauncher { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + return require('expo-intent-launcher') as typeof IntentLauncher; + } catch { + throw sessionTransferError( + 'missing_dependency', + 'Opening a signed-in browser on Android needs expo-intent-launcher. Install it by running: npx expo install expo-intent-launcher', + ); + } +} diff --git a/packages/expo/src/native-to-web/launchBrowser.ts b/packages/expo/src/native-to-web/launchBrowser.ts new file mode 100644 index 00000000000..8361312c3b0 --- /dev/null +++ b/packages/expo/src/native-to-web/launchBrowser.ts @@ -0,0 +1,17 @@ +import { Linking, Platform } from 'react-native'; + +import { sessionTransferError } from './errors'; + +export function resolveBrowserLauncher(): (url: string) => Promise { + if (Platform.OS === 'web') { + throw sessionTransferError('unsupported_platform', 'Session transfer is only available in native apps.'); + } + + return url => + Linking.openURL(url).then( + () => undefined, + () => { + throw sessionTransferError('browser_unavailable', 'No browser could open the sign-in link.'); + }, + ); +} diff --git a/packages/expo/src/native-to-web/useNativeToWeb.ts b/packages/expo/src/native-to-web/useNativeToWeb.ts new file mode 100644 index 00000000000..1c7ed4a1ea2 --- /dev/null +++ b/packages/expo/src/native-to-web/useNativeToWeb.ts @@ -0,0 +1,93 @@ +import { toSessionTransferError } from './errors'; +import { resolveBrowserLauncher } from './launchBrowser'; +import { acquireTransfer, claimTransfer, resolveTransferRequest } from './sessionTransfer'; + +/** + * Options for `openExternalBrowser`. + */ +export type OpenExternalBrowserParams = { + /** + * Absolute URL on one of your instance's domains. It must use HTTPS outside development instances. + */ + destination: string; +}; + +/** + * The value `useNativeToWeb` returns. + */ +export type UseNativeToWebReturn = { + /** + * Opens `destination` in the system browser with the active user signed in. Resolves once the + * operating system hands the URL to the browser, and rejects with a `SessionTransferError`. + * Calls for the same destination that overlap share one URL and open one browser tab. + * + * On Android it needs `expo-intent-launcher`, and `expo-application` when the package name is not + * in your app config. Install them with `npx expo install expo-intent-launcher expo-application`. + */ + openExternalBrowser: (params: OpenExternalBrowserParams) => Promise; +}; + +const inFlight = new Map>(); + +function openExternalBrowser({ destination }: OpenExternalBrowserParams): Promise { + try { + const launch = resolveBrowserLauncher(); + const request = resolveTransferRequest({ destination, presentation: 'browser' }); + const existing = inFlight.get(request.key); + if (existing) { + return existing; + } + + const lease = acquireTransfer(request); + const opening = lease.transfer + .then(transfer => { + claimTransfer(lease); + return launch(transfer.url); + }) + .finally(() => { + if (inFlight.get(request.key) === opening) { + inFlight.delete(request.key); + } + }); + inFlight.set(request.key, opening); + return opening; + } catch (error) { + return Promise.reject(toSessionTransferError(error)); + } +} + +const nativeToWeb: UseNativeToWebReturn = { openExternalBrowser }; + +/** + * Returns `openExternalBrowser`, which opens a web page in the user's default browser with the + * active user signed in. + * + * @returns An object holding `openExternalBrowser`. The object and the function never change identity. + * + * @example + * ### Open the web app from a button + * ```tsx + * import { isSessionTransferError, useNativeToWeb } from '@clerk/expo/native-to-web'; + * + * function OpenOnWebButton() { + * const { openExternalBrowser } = useNativeToWeb(); + * + * const onPress = async () => { + * try { + * await openExternalBrowser({ destination: 'https://app.example.com/settings' }); + * } catch (error) { + * if (isSessionTransferError(error) && error.code === 'signed_out') { + * router.replace('/sign-in'); + * return; + * } + * Alert.alert('Could not open the web app'); + * } + * }; + * + * return