diff --git a/.changeset/expo-native-to-web.md b/.changeset/expo-native-to-web.md index caa545dd62a..0e89fd3f3e4 100644 --- a/.changeset/expo-native-to-web.md +++ b/.changeset/expo-native-to-web.md @@ -2,9 +2,10 @@ '@clerk/expo': minor --- -Add `@clerk/expo/native-to-web` to open your web app with the native app's user already signed in, in a WebView. +Add `@clerk/expo/native-to-web` to open your web app with the native app's user already signed in, in a WebView or in the system browser. - `useAuthenticatedWebView({ destination })` prepares a one-time sign-in link and returns `webViewProps` to spread onto a `react-native-webview` ``, plus a `status` of `preparing`, `ready`, `signed-out`, or `error`. Keep your loading overlay up while `status` is `preparing`, so users never see Clerk's sign-in page between your app and the destination. A remount or React Strict Mode reuses a link the WebView has not received yet. +- `useNativeToWeb().openExternalBrowser({ destination })` opens the destination in the default browser. On Android it needs `expo-intent-launcher`, so install it with `npx expo install expo-intent-launcher expo-application`. - `prepareSessionTransfer({ destination, presentation })` from `@clerk/expo/native-to-web/advanced` returns the link for apps that present it themselves. Failures reject or report a `SessionTransferError`, which you can detect with `isSessionTransferError()` and branch on through its `code`, such as `signed_out`, `feature_not_enabled`, or `expired`. Session transfers must be enabled for your instance, and `destination` must be on one of your instance's domains. diff --git a/packages/expo/package.json b/packages/expo/package.json index b0538be7100..5fb7924a90a 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -150,6 +150,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", @@ -168,6 +169,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", @@ -203,6 +205,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/errors.ts b/packages/expo/src/native-to-web/errors.ts index 8f7164dc72c..c48063f41fb 100644 --- a/packages/expo/src/native-to-web/errors.ts +++ b/packages/expo/src/native-to-web/errors.ts @@ -2,7 +2,7 @@ * Why a session transfer failed. * * The SDK raises `unsupported_platform`, `clerk_not_loaded`, `signed_out`, `invalid_destination`, - * `load_failed`, `network_error`, and `unexpected_response`. + * `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`. @@ -12,6 +12,8 @@ export type SessionTransferErrorCode = | 'clerk_not_loaded' | 'signed_out' | 'invalid_destination' + | 'missing_dependency' + | 'browser_unavailable' | 'load_failed' | 'network_error' | 'unexpected_response' @@ -45,7 +47,7 @@ export type SessionTransferError = Error & { * ### Send a signed-out user to sign in * ```ts * try { - * await prepareSessionTransfer({ destination: 'https://app.example.com/settings', presentation: 'webview' }); + * await openExternalBrowser({ destination: 'https://app.example.com/settings' }); * } catch (error) { * if (isSessionTransferError(error) && error.code === 'signed_out') { * router.replace('/sign-in'); 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/sessionTransfer.ts b/packages/expo/src/native-to-web/sessionTransfer.ts index 828f4991347..fe9b4231fa1 100644 --- a/packages/expo/src/native-to-web/sessionTransfer.ts +++ b/packages/expo/src/native-to-web/sessionTransfer.ts @@ -85,7 +85,8 @@ 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` covers WebViews. + * 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` 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..f297a091a08 --- /dev/null +++ b/packages/expo/src/native-to-web/useNativeToWeb.ts @@ -0,0 +1,89 @@ +import { toSessionTransferError } from './errors'; +import { resolveBrowserLauncher } from './launchBrowser'; +import { mintSessionTransfer, 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 opening = mintSessionTransfer(request) + .then(transfer => 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