diff --git a/.changeset/expo-native-app-minimum-versions.md b/.changeset/expo-native-app-minimum-versions.md new file mode 100644 index 00000000000..e4cf332e695 --- /dev/null +++ b/.changeset/expo-native-app-minimum-versions.md @@ -0,0 +1,9 @@ +--- +'@clerk/expo': minor +--- + +Support the minimum app versions set in the Clerk Dashboard. When your iOS or Android app's version is older than its minimum, Clerk rejects the app's requests until the user updates, and `` shows an update screen over your app with a link to the store and, when your instance has a support email, a way to contact support. + +To show your own screen instead, set `updateRequiredViewEnabled={false}` on `` and read the new `useAppVersionSupport()` hook, which returns `isSupported`, `currentVersion`, `minimumVersion`, and `updateUrl`. + +Install `expo-application` (`npx expo install expo-application`), which Clerk uses to read your app's bundle ID or package name and its version. Without it, `useAppVersionSupport()` always reports the app as supported, Clerk doesn't block requests made from JavaScript, and `` shows no update screen. If you use `@clerk/expo-native-components`, the native iOS and Android SDKs still identify the app themselves, so their requests are blocked and they show their own update screen. Apps in Expo Go and web apps are never blocked. diff --git a/.changeset/expo-native-components-update-screen.md b/.changeset/expo-native-components-update-screen.md new file mode 100644 index 00000000000..e146e5da82d --- /dev/null +++ b/.changeset/expo-native-components-update-screen.md @@ -0,0 +1,5 @@ +--- +'@clerk/expo-native-components': patch +--- + +Let `@clerk/expo` turn off the native iOS and Android SDKs' own update-required screens. `` turns them off when it shows its own update screen, or when you set `updateRequiredViewEnabled={false}`, so an app whose version is older than its minimum sees one screen. Older versions of `@clerk/expo`, and apps without `expo-application`, which `@clerk/expo` needs to identify the app, keep the native screen. diff --git a/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt index 27055ffdaf4..4e19c06daaf 100644 --- a/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt +++ b/packages/expo-native-components/android/src/main/java/expo/modules/clerk/ClerkExpoModule.kt @@ -53,6 +53,7 @@ class ClerkExpoModule : Module() { emit = { sendEvent(NATIVE_CLIENT_INVALIDATED_EVENT, emptyMap()) } ) private var configuredProxyUrl: String? = null + @Volatile private var updateRequiredViewEnabled = true private data class AuthFlowStateSnapshot( val isLoaded: Boolean, @@ -80,6 +81,10 @@ class ClerkExpoModule : Module() { configureNative(pubKey, seedDeviceToken, proxyUrl, promise) } + AsyncFunction("setUpdateRequiredViewEnabled") { enabled: Boolean -> + updateRequiredViewEnabled = enabled + } + AsyncFunction("getDeviceToken") { promise: Promise -> getDeviceToken(promise) } @@ -110,8 +115,8 @@ class ClerkExpoModule : Module() { } // JS owns client state. The native foreground refresh races SSO completion and mints duplicate clients (#9217). - return ClerkConfigurationOptions(proxyUrl = proxyUrl) - .withForegroundRefreshDisabled() + val options = ClerkConfigurationOptions(proxyUrl = proxyUrl).withForegroundRefreshDisabled() + return (if (updateRequiredViewEnabled) options else options.withUpdateRequiredViewDisabled()) .withCustomHeaders(customHeaders) } diff --git a/packages/expo-native-components/ios/ClerkExpo.podspec b/packages/expo-native-components/ios/ClerkExpo.podspec index d50580c6a71..3483125b2e8 100644 --- a/packages/expo-native-components/ios/ClerkExpo.podspec +++ b/packages/expo-native-components/ios/ClerkExpo.podspec @@ -18,7 +18,7 @@ else end clerk_ios_repo = 'https://github.com/clerk/clerk-ios.git' -clerk_ios_version = '1.6.2' +clerk_ios_revision = '1e6edcbc483a9ae02513eb5f28310b4db3e6e42b' Pod::Spec.new do |s| s.name = 'ClerkExpo' @@ -44,7 +44,7 @@ Pod::Spec.new do |s| spm_dependency( s, url: clerk_ios_repo, - requirement: { :kind => 'exactVersion', :version => clerk_ios_version }, + requirement: { :kind => 'revision', :revision => clerk_ios_revision }, products: ['ClerkKit', 'ClerkKitUI'] ) else diff --git a/packages/expo-native-components/ios/ClerkExpoModule.swift b/packages/expo-native-components/ios/ClerkExpoModule.swift index 55d54335473..1a7edaef530 100644 --- a/packages/expo-native-components/ios/ClerkExpoModule.swift +++ b/packages/expo-native-components/ios/ClerkExpoModule.swift @@ -52,6 +52,13 @@ public class ClerkExpoModule: Module { } } + AsyncFunction("setUpdateRequiredViewEnabled") { (enabled: Bool, promise: Promise) in + Task { @MainActor in + ClerkNativeBridge.setUpdateRequiredViewEnabled(enabled) + promise.resolve() + } + } + AsyncFunction("getDeviceToken") { (promise: Promise) in Task { @MainActor in promise.resolve(ClerkNativeBridge.shared.getDeviceToken()) diff --git a/packages/expo-native-components/ios/ClerkNativeBridge.swift b/packages/expo-native-components/ios/ClerkNativeBridge.swift index 5616f82ae37..226f3f78fd9 100644 --- a/packages/expo-native-components/ios/ClerkNativeBridge.swift +++ b/packages/expo-native-components/ios/ClerkNativeBridge.swift @@ -420,6 +420,7 @@ final class ClerkNativeBridge { private static var clerkConfigured = false private static var configuredPublishableKey: String? private static var configuredProxyUrl: String? + private static var updateRequiredViewEnabled = true /// Parsed light and dark themes from Info.plist "ClerkTheme" dictionary. var lightTheme: ClerkTheme? @@ -672,9 +673,19 @@ final class ClerkNativeBridge { private static func makeClerkOptions(proxyUrl: String?) -> Clerk.Options { let middleware = Clerk.Options.MiddlewareConfig(request: [ClerkExpoHeaderMiddleware()]) guard let service = keychainService else { - return .init(proxyUrl: proxyUrl, middleware: middleware) + return .init(proxyUrl: proxyUrl, middleware: middleware, updateRequiredViewEnabled: updateRequiredViewEnabled) } - return .init(keychainConfig: .init(service: service), proxyUrl: proxyUrl, middleware: middleware) + return .init( + keychainConfig: .init(service: service), + proxyUrl: proxyUrl, + middleware: middleware, + updateRequiredViewEnabled: updateRequiredViewEnabled + ) + } + + @MainActor + static func setUpdateRequiredViewEnabled(_ enabled: Bool) { + updateRequiredViewEnabled = enabled } @MainActor diff --git a/packages/expo/package.json b/packages/expo/package.json index 82c2a358bba..06f7cfe6b3c 100644 --- a/packages/expo/package.json +++ b/packages/expo/package.json @@ -139,6 +139,7 @@ "@types/base-64": "^1.0.2", "esbuild": "^0.28.1", "expo-apple-authentication": "^8.0.8", + "expo-application": "^7.0.8", "expo-auth-session": "^7.0.11", "expo-constants": "^18.0.13", "expo-crypto": "^15.0.9", @@ -154,6 +155,7 @@ "@clerk/expo-passkeys": ">=0.0.6", "expo": "catalog:peer-expo", "expo-apple-authentication": ">=7.0.0", + "expo-application": ">=5.0.0", "expo-auth-session": ">=5", "expo-constants": ">=12", "expo-crypto": ">=12", @@ -180,6 +182,9 @@ "expo-apple-authentication": { "optional": true }, + "expo-application": { + "optional": true + }, "expo-auth-session": { "optional": true }, diff --git a/packages/expo/src/app-version/__tests__/appVersionRequestHooks.test.ts b/packages/expo/src/app-version/__tests__/appVersionRequestHooks.test.ts new file mode 100644 index 00000000000..2a341b72eb3 --- /dev/null +++ b/packages/expo/src/app-version/__tests__/appVersionRequestHooks.test.ts @@ -0,0 +1,282 @@ +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const appState = vi.hoisted(() => { + const listeners = new Set<(state: string) => void>(); + return { + listeners, + addEventListener: (_type: string, listener: (state: string) => void) => { + listeners.add(listener); + return { remove: () => listeners.delete(listener) }; + }, + emit: (state: string) => listeners.forEach(listener => listener(state)), + }; +}); + +vi.mock('react-native', () => ({ AppState: { addEventListener: appState.addEventListener } })); + +vi.mock('../nativeAppIdentity', () => ({ + getNativeAppIdentity: () => ({ + platform: 'ios', + appIdentifier: 'com.example.app', + version: '1.0', + displayVersion: '1.0', + }), + getNativeAppPlatform: () => 'ios', + isExpoApplicationInstalled: () => true, + setNativeAppIdentityHeaders: (headers: Headers) => headers.set('x-bundle-id', 'com.example.app'), +})); + +const BLOCKING_ENVIRONMENT = { + native_app_settings: { + minimum_supported_version: { + ios: [{ bundle_id: 'com.example.app', minimum_version: '2.0', update_url: 'https://apps.apple.com/app/id1' }], + android: [], + }, + }, +}; + +const response = ({ + status = 200, + headers = {}, + payload = null, +}: { + status?: number; + headers?: Record; + payload?: unknown; +}) => ({ ok: status >= 200 && status < 300, status, headers: new Headers(headers), payload }); + +const unsupportedAppVersion = response({ + status: 403, + payload: { errors: [{ code: 'unsupported_app_version', meta: { minimum_version: '2.0' } }] }, +}); + +const loadModules = async () => { + const hooks = await import('../appVersionRequestHooks'); + const store = await import('../appVersionSupport'); + return { createAppVersionRequestHooks: hooks.createAppVersionRequestHooks, status: store.getAppVersionSupportStatus }; +}; + +describe('createAppVersionRequestHooks', () => { + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + appState.listeners.clear(); + }); + + test('identifies the app on every request', async () => { + const { createAppVersionRequestHooks } = await loadModules(); + const requestInit = { path: '/client', headers: new Headers() }; + + createAppVersionRequestHooks().beforeRequest(requestInit); + + expect(requestInit.headers.get('x-bundle-id')).toBe('com.example.app'); + }); + + test('blocks the app on an unsupported app version response', async () => { + const { createAppVersionRequestHooks, status } = await loadModules(); + const hooks = createAppVersionRequestHooks(); + const requestInit = { path: '/client', headers: new Headers() }; + hooks.beforeRequest(requestInit); + + hooks.afterResponse(requestInit, unsupportedAppVersion); + + expect(status()).toMatchObject({ isSupported: false, minimumVersion: '2.0' }); + }); + + test('leaves the app supported on another 403', async () => { + const { createAppVersionRequestHooks, status } = await loadModules(); + const hooks = createAppVersionRequestHooks(); + const requestInit = { path: '/client', headers: new Headers() }; + hooks.beforeRequest(requestInit); + + hooks.afterResponse(requestInit, response({ status: 403, payload: { errors: [{ code: 'user_banned' }] } })); + + expect(status().isSupported).toBe(true); + }); + + test('the supported header lifts a block', async () => { + const { createAppVersionRequestHooks, status } = await loadModules(); + const hooks = createAppVersionRequestHooks(); + const rejectedRequest = { path: '/client', headers: new Headers() }; + hooks.beforeRequest(rejectedRequest); + hooks.afterResponse(rejectedRequest, unsupportedAppVersion); + + const laterRequest = { path: '/client', headers: new Headers() }; + hooks.beforeRequest(laterRequest); + hooks.afterResponse(laterRequest, response({ headers: { 'X-Clerk-App-Version-Status': 'supported' } })); + + expect(status().isSupported).toBe(true); + }); + + test('a rejection that arrives after a newer request was marked supported is ignored', async () => { + const { createAppVersionRequestHooks, status } = await loadModules(); + const hooks = createAppVersionRequestHooks(); + const olderRequest = { path: '/client', headers: new Headers() }; + const newerRequest = { path: '/client', headers: new Headers() }; + hooks.beforeRequest(olderRequest); + hooks.beforeRequest(newerRequest); + + hooks.afterResponse(newerRequest, response({ headers: { 'X-Clerk-App-Version-Status': 'supported' } })); + hooks.afterResponse(olderRequest, unsupportedAppVersion); + + expect(status().isSupported).toBe(true); + }); + + test('a loaded environment blocks an outdated app', async () => { + const { createAppVersionRequestHooks, status } = await loadModules(); + const hooks = createAppVersionRequestHooks(); + const requestInit = { path: '/environment', headers: new Headers() }; + hooks.beforeRequest(requestInit); + + hooks.afterResponse(requestInit, response({ payload: BLOCKING_ENVIRONMENT })); + + expect(status()).toMatchObject({ isSupported: false, minimumVersion: '2.0' }); + }); + + test('an environment that loads after a rejection keeps the block', async () => { + const { createAppVersionRequestHooks, status } = await loadModules(); + const hooks = createAppVersionRequestHooks(); + const environmentRequest = { path: '/environment', headers: new Headers() }; + const clientRequest = { path: '/client', headers: new Headers() }; + hooks.beforeRequest(clientRequest); + hooks.afterResponse(clientRequest, unsupportedAppVersion); + + hooks.beforeRequest(environmentRequest); + hooks.afterResponse(environmentRequest, response({ payload: { native_app_settings: {} } })); + + expect(status().isSupported).toBe(false); + }); + + test('ignores responses to a Clerk instance that was replaced', async () => { + const { createAppVersionRequestHooks, status } = await loadModules(); + const staleHooks = createAppVersionRequestHooks(); + const staleRequest = { path: '/client', headers: new Headers() }; + staleHooks.beforeRequest(staleRequest); + createAppVersionRequestHooks(); + + staleHooks.afterResponse(staleRequest, unsupportedAppVersion); + + expect(status().isSupported).toBe(true); + }); + + const block = (hooks: ReturnType>['createAppVersionRequestHooks']>) => { + const rejectedRequest = { path: '/client', headers: new Headers() }; + hooks.beforeRequest(rejectedRequest); + hooks.afterResponse(rejectedRequest, unsupportedAppVersion); + }; + + // Lets a check that already resolved finish, as the app does between two returns to the foreground. + const settle = () => new Promise(resolve => setTimeout(resolve, 0)); + + const foregroundAt = (now: number) => { + vi.spyOn(Date, 'now').mockReturnValue(now); + appState.emit('background'); + appState.emit('active'); + }; + + test('reloads instead of checking when the app returns to the foreground while unsupported', async () => { + const { createAppVersionRequestHooks } = await loadModules(); + vi.spyOn(Date, 'now').mockReturnValue(0); + const hooks = createAppVersionRequestHooks(); + const reload = vi.fn().mockResolvedValue(undefined); + const check = vi.fn().mockResolvedValue(undefined); + hooks.checkOnForeground({ reload, check }); + block(hooks); + + foregroundAt(1_000); + foregroundAt(2_000); + + expect(reload).toHaveBeenCalledTimes(2); + expect(check).not.toHaveBeenCalled(); + }); + + test('checks the app version on foreground at most every 30 seconds while supported', async () => { + const { createAppVersionRequestHooks } = await loadModules(); + vi.spyOn(Date, 'now').mockReturnValue(0); + const hooks = createAppVersionRequestHooks(); + const reload = vi.fn().mockResolvedValue(undefined); + const check = vi.fn().mockResolvedValue(undefined); + hooks.checkOnForeground({ reload, check }); + + foregroundAt(10_000); + expect(check).not.toHaveBeenCalled(); + + foregroundAt(30_000); + foregroundAt(45_000); + expect(check).toHaveBeenCalledTimes(1); + await settle(); + + foregroundAt(60_000); + expect(check).toHaveBeenCalledTimes(2); + expect(reload).not.toHaveBeenCalled(); + }); + + test('skips the foreground check while the previous one is still pending', async () => { + const { createAppVersionRequestHooks } = await loadModules(); + vi.spyOn(Date, 'now').mockReturnValue(0); + const hooks = createAppVersionRequestHooks(); + let finishCheck = () => {}; + const check = vi.fn( + () => + new Promise(resolve => { + finishCheck = resolve; + }), + ); + hooks.checkOnForeground({ reload: vi.fn().mockResolvedValue(undefined), check }); + + foregroundAt(30_000); + foregroundAt(70_000); + expect(check).toHaveBeenCalledTimes(1); + + finishCheck(); + await settle(); + foregroundAt(110_000); + expect(check).toHaveBeenCalledTimes(2); + }); + + test('a foreground check that Clerk rejects blocks the app', async () => { + const { createAppVersionRequestHooks, status } = await loadModules(); + vi.spyOn(Date, 'now').mockReturnValue(0); + const hooks = createAppVersionRequestHooks(); + const check = vi.fn(() => { + const checkRequest = { path: '/', headers: new Headers() }; + hooks.beforeRequest(checkRequest); + hooks.afterResponse(checkRequest, unsupportedAppVersion); + return Promise.resolve(); + }); + hooks.checkOnForeground({ reload: vi.fn().mockResolvedValue(undefined), check }); + + foregroundAt(30_000); + await vi.waitFor(() => expect(status().isSupported).toBe(false)); + }); + + test('a foreground check that fails leaves the app supported', async () => { + const { createAppVersionRequestHooks, status } = await loadModules(); + vi.spyOn(Date, 'now').mockReturnValue(0); + const hooks = createAppVersionRequestHooks(); + const check = vi.fn().mockRejectedValue(new Error('Network request failed')); + hooks.checkOnForeground({ reload: vi.fn().mockResolvedValue(undefined), check }); + + foregroundAt(30_000); + await Promise.resolve(); + + expect(check).toHaveBeenCalledTimes(1); + expect(status().isSupported).toBe(true); + }); + + test('stops checking for a Clerk instance that was replaced', async () => { + const { createAppVersionRequestHooks } = await loadModules(); + vi.spyOn(Date, 'now').mockReturnValue(0); + const staleReload = vi.fn().mockResolvedValue(undefined); + const staleCheck = vi.fn().mockResolvedValue(undefined); + createAppVersionRequestHooks().checkOnForeground({ reload: staleReload, check: staleCheck }); + const hooks = createAppVersionRequestHooks(); + block(hooks); + + foregroundAt(1_000); + foregroundAt(60_000); + + expect(staleReload).not.toHaveBeenCalled(); + expect(staleCheck).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/expo/src/app-version/__tests__/appVersionSupport.test.ts b/packages/expo/src/app-version/__tests__/appVersionSupport.test.ts new file mode 100644 index 00000000000..f6399c86459 --- /dev/null +++ b/packages/expo/src/app-version/__tests__/appVersionSupport.test.ts @@ -0,0 +1,352 @@ +import { act, render } from '@testing-library/react'; +import { createElement, useRef } from 'react'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +import type { NativeAppIdentity } from '../nativeAppIdentity'; + +const mocks = vi.hoisted(() => ({ + identity: null as NativeAppIdentity | null, + expoApplicationInstalled: true, +})); + +vi.mock('../nativeAppIdentity', () => ({ + getNativeAppIdentity: () => mocks.identity, + getNativeAppPlatform: () => 'ios', + isExpoApplicationInstalled: () => mocks.expoApplicationInstalled, +})); + +const PLAY_URL = 'https://play.google.com/store/apps/details?id=com.example.app'; +const APP_STORE_URL = 'https://apps.apple.com/app/id123'; + +const environment = ({ + ios = [] as unknown[], + android = [] as unknown[], +}: { + ios?: unknown[]; + android?: unknown[]; +} = {}) => ({ native_app_settings: { minimum_supported_version: { ios, android } } }); + +const iosPolicy = (minimumVersion: unknown, updateUrl: unknown = APP_STORE_URL) => ({ + bundle_id: 'com.example.app', + minimum_version: minimumVersion, + update_url: updateUrl, +}); + +const loadStore = async () => import('../appVersionSupport'); + +describe('appVersionSupport', () => { + beforeEach(() => { + vi.resetModules(); + mocks.identity = { platform: 'ios', appIdentifier: 'com.example.app', version: '1.9.9', displayVersion: '1.9.9' }; + mocks.expoApplicationInstalled = true; + }); + + afterEach(() => { + vi.unstubAllGlobals(); + vi.restoreAllMocks(); + }); + + const fetchEnvironment = (store: Awaited>, json: unknown) => { + store.applyFetchedEnvironment(json, store.nextAppVersionRequest()); + }; + + test('reports a supported app before anything is known', async () => { + const store = await loadStore(); + + expect(store.getAppVersionSupportStatus()).toEqual({ + isSupported: true, + currentVersion: '1.9.9', + minimumVersion: null, + updateUrl: null, + }); + }); + + test('blocks an iOS app older than its minimum', async () => { + const store = await loadStore(); + + fetchEnvironment(store, environment({ ios: [iosPolicy('2.0')] })); + + expect(store.getAppVersionSupportStatus()).toEqual({ + isSupported: false, + currentVersion: '1.9.9', + minimumVersion: '2.0', + updateUrl: APP_STORE_URL, + }); + }); + + test.each([ + ['at its minimum', '1.10.0'], + ['at its minimum written with fewer components', '1.10'], + ['above its minimum', '1.9.9'], + ])('lets an iOS app %s through', async (_label, minimumVersion) => { + mocks.identity = { platform: 'ios', appIdentifier: 'com.example.app', version: '1.10.0', displayVersion: '1.10.0' }; + const store = await loadStore(); + + fetchEnvironment(store, environment({ ios: [iosPolicy(minimumVersion)] })); + + expect(store.getAppVersionSupportStatus().isSupported).toBe(true); + }); + + test('compares Android version codes', async () => { + mocks.identity = { platform: 'android', appIdentifier: 'com.example.app', version: '41', displayVersion: '41' }; + const store = await loadStore(); + + fetchEnvironment( + store, + environment({ android: [{ package_name: 'com.example.app', minimum_version: '42', update_url: PLAY_URL }] }), + ); + + expect(store.getAppVersionSupportStatus()).toEqual({ + isSupported: false, + currentVersion: '41', + minimumVersion: '42', + updateUrl: PLAY_URL, + }); + }); + + test.each([['4.2.1.0'], ['abc'], [''], [42]])( + 'lets the app through when the minimum %j is unreadable', + async minimum => { + const store = await loadStore(); + + fetchEnvironment(store, environment({ ios: [iosPolicy(minimum)] })); + + expect(store.getAppVersionSupportStatus().isSupported).toBe(true); + }, + ); + + test('ignores minimums for other apps and malformed settings', async () => { + const store = await loadStore(); + + fetchEnvironment( + store, + environment({ ios: ['not a policy', { bundle_id: 'com.example.other', minimum_version: '9.0' }] }), + ); + expect(store.getAppVersionSupportStatus().isSupported).toBe(true); + + fetchEnvironment(store, { native_app_settings: 'not settings' }); + expect(store.getAppVersionSupportStatus().isSupported).toBe(true); + }); + + test.each([['market://details?id=com.example.app'], ['javascript:alert(1)'], ['not a url']])( + 'drops the update URL %s', + async updateUrl => { + const store = await loadStore(); + + fetchEnvironment(store, environment({ ios: [iosPolicy('2.0', updateUrl)] })); + + expect(store.getAppVersionSupportStatus().updateUrl).toBeNull(); + }, + ); + + test('a rejection blocks the app until a later response marks it supported', async () => { + const store = await loadStore(); + store.applyUnsupportedAppVersion( + { minimum_version: '2.0', update_url: APP_STORE_URL }, + store.nextAppVersionRequest(), + ); + expect(store.getAppVersionSupportStatus()).toEqual({ + isSupported: false, + currentVersion: '1.9.9', + minimumVersion: '2.0', + updateUrl: APP_STORE_URL, + }); + + store.applySupportedAppVersion(store.nextAppVersionRequest()); + expect(store.getAppVersionSupportStatus().isSupported).toBe(true); + }); + + test('a rejection uses the latest environment for what its meta leaves out', async () => { + const store = await loadStore(); + const newerAppStoreUrl = 'https://apps.apple.com/app/id456'; + fetchEnvironment(store, environment({ ios: [iosPolicy('2.0')] })); + store.applyUnsupportedAppVersion({}, store.nextAppVersionRequest()); + + fetchEnvironment(store, environment({ ios: [iosPolicy('3.0', newerAppStoreUrl)] })); + + expect(store.getAppVersionSupportStatus()).toEqual({ + isSupported: false, + currentVersion: '1.9.9', + minimumVersion: '3.0', + updateUrl: newerAppStoreUrl, + }); + }); + + test('a rejection falls back to the environment for what its meta leaves out', async () => { + const store = await loadStore(); + fetchEnvironment(store, environment({ ios: [iosPolicy('2.0')] })); + + store.applyUnsupportedAppVersion({ update_url: '' }, store.nextAppVersionRequest()); + + expect(store.getAppVersionSupportStatus()).toEqual({ + isSupported: false, + currentVersion: '1.9.9', + minimumVersion: '2.0', + updateUrl: APP_STORE_URL, + }); + }); + + test('an environment does not lift a rejection', async () => { + const store = await loadStore(); + store.applyUnsupportedAppVersion({ minimum_version: '2.0' }, store.nextAppVersionRequest()); + + fetchEnvironment(store, environment()); + + expect(store.getAppVersionSupportStatus()).toMatchObject({ isSupported: false, minimumVersion: '2.0' }); + }); + + test('an environment does not block an app a response marked supported', async () => { + const store = await loadStore(); + store.applySupportedAppVersion(store.nextAppVersionRequest()); + + fetchEnvironment(store, environment({ ios: [iosPolicy('2.0')] })); + + expect(store.getAppVersionSupportStatus()).toEqual({ + isSupported: true, + currentVersion: '1.9.9', + minimumVersion: null, + updateUrl: null, + }); + }); + + test('a supported answer shows the minimum a later environment sets', async () => { + mocks.identity = { platform: 'ios', appIdentifier: 'com.example.app', version: '2.1', displayVersion: '2.1' }; + const store = await loadStore(); + store.applySupportedAppVersion(store.nextAppVersionRequest()); + + fetchEnvironment(store, environment({ ios: [iosPolicy('2.0')] })); + + expect(store.getAppVersionSupportStatus()).toMatchObject({ isSupported: true, minimumVersion: '2.0' }); + }); + + test('a supported answer keeps the minimum and store page the environment set', async () => { + mocks.identity = { platform: 'ios', appIdentifier: 'com.example.app', version: '2.1', displayVersion: '2.1' }; + const store = await loadStore(); + fetchEnvironment(store, environment({ ios: [iosPolicy('2.0')] })); + + store.applySupportedAppVersion(store.nextAppVersionRequest()); + + expect(store.getAppVersionSupportStatus()).toEqual({ + isSupported: true, + currentVersion: '2.1', + minimumVersion: '2.0', + updateUrl: APP_STORE_URL, + }); + }); + + test('a supported answer drops a minimum the server no longer enforces', async () => { + const store = await loadStore(); + fetchEnvironment(store, environment({ ios: [iosPolicy('2.0')] })); + + store.applySupportedAppVersion(store.nextAppVersionRequest()); + + expect(store.getAppVersionSupportStatus()).toEqual({ + isSupported: true, + currentVersion: '1.9.9', + minimumVersion: null, + updateUrl: null, + }); + }); + + test('a late rejection does not block again after a newer supported answer', async () => { + const store = await loadStore(); + const olderRequest = store.nextAppVersionRequest(); + store.applySupportedAppVersion(store.nextAppVersionRequest()); + + store.applyUnsupportedAppVersion({ minimum_version: '2.0' }, olderRequest); + + expect(store.getAppVersionSupportStatus().isSupported).toBe(true); + }); + + test('a late supported answer does not lift a newer block', async () => { + const store = await loadStore(); + const olderRequest = store.nextAppVersionRequest(); + store.applyUnsupportedAppVersion({ minimum_version: '2.0' }, store.nextAppVersionRequest()); + + store.applySupportedAppVersion(olderRequest); + + expect(store.getAppVersionSupportStatus().isSupported).toBe(false); + }); + + test('ignores answers for a Clerk instance that was replaced', async () => { + const store = await loadStore(); + const staleRequest = store.nextAppVersionRequest(); + store.resetAppVersionSupport(); + + store.applyUnsupportedAppVersion({ minimum_version: '2.0' }, staleRequest); + + expect(store.getAppVersionSupportStatus().isSupported).toBe(true); + }); + + test('a reset during render updates subscribers after the render, not during it', async () => { + const store = await loadStore(); + const { useAppVersionSupport } = await import('../../hooks/useAppVersionSupport'); + const consoleError = vi.spyOn(console, 'error').mockImplementation(() => {}); + const seen: (string | null)[] = []; + const Subscriber = () => { + seen.push(useAppVersionSupport().minimumVersion); + return null; + }; + const Provider = ({ instance }: { instance: number }) => { + const resetFor = useRef(0); + if (resetFor.current !== instance) { + resetFor.current = instance; + store.resetAppVersionSupport(); + } + return createElement(Subscriber); + }; + const view = render(createElement(Provider, { instance: 0 })); + act(() => fetchEnvironment(store, environment({ ios: [iosPolicy('1.0')] }))); + + await act(async () => { + view.rerender(createElement(Provider, { instance: 1 })); + await Promise.resolve(); + }); + + expect(consoleError.mock.calls.some(([message]) => String(message).includes('Cannot update a component'))).toBe( + false, + ); + expect(store.getAppVersionSupportStatus().minimumVersion).toBeNull(); + expect(seen.at(-1)).toBeNull(); + }); + + test('notifies subscribers only when the status changes', async () => { + const store = await loadStore(); + const listener = vi.fn(); + store.subscribeToAppVersionSupport(listener); + + store.applyUnsupportedAppVersion({ minimum_version: '2.0' }, store.nextAppVersionRequest()); + store.applyUnsupportedAppVersion({ minimum_version: '2.0' }, store.nextAppVersionRequest()); + + expect(listener).toHaveBeenCalledTimes(1); + }); + + describe('without expo-application', () => { + beforeEach(() => { + mocks.identity = null; + mocks.expoApplicationInstalled = false; + vi.stubGlobal('__DEV__', true); + }); + + test('warns once in development when the instance sets minimums', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const store = await loadStore(); + + fetchEnvironment(store, environment({ ios: [iosPolicy('2.0')] })); + fetchEnvironment(store, environment({ ios: [iosPolicy('2.0')] })); + + expect(warn).toHaveBeenCalledTimes(1); + expect(warn.mock.calls[0][0]).toContain('npx expo install expo-application'); + expect(store.getAppVersionSupportStatus().isSupported).toBe(true); + }); + + test('stays quiet when the instance sets no minimums', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const store = await loadStore(); + + fetchEnvironment(store, environment()); + + expect(warn).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/packages/expo/src/app-version/__tests__/nativeAppIdentity.test.ts b/packages/expo/src/app-version/__tests__/nativeAppIdentity.test.ts new file mode 100644 index 00000000000..041259d90d1 --- /dev/null +++ b/packages/expo/src/app-version/__tests__/nativeAppIdentity.test.ts @@ -0,0 +1,116 @@ +import Module from 'node:module'; + +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; + +const moduleWithLoad = Module as unknown as { + _load: (request: string, parent?: unknown, isMain?: boolean) => unknown; +}; +const originalModuleLoad = moduleWithLoad._load; + +const mocks = vi.hoisted(() => ({ + platformOS: 'ios' as string, + application: null as Record | null, + executionEnvironment: 'standalone' as string, +})); + +vi.mock('react-native', () => ({ + AppState: { currentState: 'active', addEventListener: vi.fn() }, + Platform: { + get OS() { + return mocks.platformOS; + }, + }, +})); + +const loadIdentity = async () => import('../nativeAppIdentity'); + +describe('nativeAppIdentity', () => { + beforeEach(() => { + vi.resetModules(); + mocks.platformOS = 'ios'; + mocks.executionEnvironment = 'standalone'; + mocks.application = { + applicationId: 'com.example.app', + nativeApplicationVersion: '4.8.4', + nativeBuildVersion: '41', + }; + vi.spyOn(moduleWithLoad, '_load').mockImplementation((request, parent, isMain) => { + if (request === 'expo-application') { + if (!mocks.application) { + throw new Error("Cannot find module 'expo-application'"); + } + return mocks.application; + } + if (request === 'expo-constants') { + return { default: { executionEnvironment: mocks.executionEnvironment, appOwnership: null } }; + } + return originalModuleLoad.call(Module, request, parent, isMain); + }); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('identifies an iOS app by its bundle ID and version', async () => { + const { getNativeAppIdentity } = await loadIdentity(); + + expect(getNativeAppIdentity()).toEqual({ + platform: 'ios', + appIdentifier: 'com.example.app', + version: '4.8.4', + displayVersion: '4.8.4', + }); + }); + + test('identifies an Android app by its package name and version code', async () => { + mocks.platformOS = 'android'; + const { getNativeAppIdentity } = await loadIdentity(); + + expect(getNativeAppIdentity()).toEqual({ + platform: 'android', + appIdentifier: 'com.example.app', + version: '41', + displayVersion: '4.8.4', + }); + }); + + test('sends the identity headers for iOS', async () => { + const { setNativeAppIdentityHeaders } = await loadIdentity(); + const headers = new Headers(); + + setNativeAppIdentityHeaders(headers); + + expect(headers.get('x-bundle-id')).toBe('com.example.app'); + expect(headers.get('x-package-name')).toBeNull(); + expect(headers.get('x-app-version')).toBe('4.8.4'); + }); + + test('sends the identity headers for Android', async () => { + mocks.platformOS = 'android'; + const { setNativeAppIdentityHeaders } = await loadIdentity(); + const headers = new Headers(); + + setNativeAppIdentityHeaders(headers); + + expect(headers.get('x-package-name')).toBe('com.example.app'); + expect(headers.get('x-bundle-id')).toBeNull(); + expect(headers.get('x-app-version')).toBe('41'); + }); + + test.each([ + ['in Expo Go', () => (mocks.executionEnvironment = 'storeClient')], + ['on the web', () => (mocks.platformOS = 'web')], + ['without expo-application', () => (mocks.application = null)], + ['without an app version', () => (mocks.application = { ...mocks.application, nativeApplicationVersion: null })], + ])('sends no identity %s', async (_label, arrange) => { + arrange(); + const { getNativeAppIdentity, setNativeAppIdentityHeaders } = await loadIdentity(); + const headers = new Headers(); + + setNativeAppIdentityHeaders(headers); + + expect(getNativeAppIdentity()).toBeNull(); + expect([...headers.keys()]).toEqual([]); + }); +}); diff --git a/packages/expo/src/app-version/appVersionRequestHooks.ts b/packages/expo/src/app-version/appVersionRequestHooks.ts new file mode 100644 index 00000000000..b559cd2de57 --- /dev/null +++ b/packages/expo/src/app-version/appVersionRequestHooks.ts @@ -0,0 +1,103 @@ +import { AppState, type NativeEventSubscription } from 'react-native'; + +import { + applyFetchedEnvironment, + applySupportedAppVersion, + applyUnsupportedAppVersion, + getAppVersionSupportStatus, + isRecord, + nextAppVersionRequest, + resetAppVersionSupport, +} from './appVersionSupport'; +import { setNativeAppIdentityHeaders } from './nativeAppIdentity'; + +const UNSUPPORTED_APP_VERSION_CODE = 'unsupported_app_version'; +const APP_VERSION_STATUS_HEADER = 'x-clerk-app-version-status'; +const ENVIRONMENT_PATH = '/environment'; +const FOREGROUND_CHECK_INTERVAL_MS = 30_000; + +type AppVersionRequest = { + path?: string; + headers?: HeadersInit; +}; + +type AppVersionResponse = { + ok: boolean; + status: number; + headers: Headers; + payload: unknown; +}; + +type AppVersionRequestHooks = { + checkOnForeground: (requests: { reload: () => Promise; check: () => Promise }) => void; + beforeRequest: (requestInit: AppVersionRequest) => void; + afterResponse: (requestInit: AppVersionRequest, response: AppVersionResponse) => void; +}; + +let foregroundSubscription: NativeEventSubscription | null = null; + +export function createAppVersionRequestHooks(): AppVersionRequestHooks { + resetAppVersionSupport(); + foregroundSubscription?.remove(); + foregroundSubscription = null; + const requests = new WeakMap(); + + return { + // The native SDKs refresh on every foreground, but Expo turns that off, so a supported app asks again here. + // While it's unsupported, it reloads instead, so a block that lifts also finishes a load the block failed. + checkOnForeground({ reload, check }) { + let lastCheck = Date.now(); + let checking = false; + foregroundSubscription = AppState.addEventListener('change', state => { + if (state !== 'active') { + return; + } + if (!getAppVersionSupportStatus().isSupported) { + void reload().catch(() => {}); + return; + } + if (checking || Date.now() - lastCheck < FOREGROUND_CHECK_INTERVAL_MS) { + return; + } + lastCheck = Date.now(); + checking = true; + void check() + .catch(() => {}) + .finally(() => { + checking = false; + }); + }); + }, + beforeRequest(requestInit: AppVersionRequest) { + requests.set(requestInit, nextAppVersionRequest()); + setNativeAppIdentityHeaders(requestInit.headers as Headers); + }, + afterResponse(requestInit: AppVersionRequest, response: AppVersionResponse) { + const request = requests.get(requestInit); + if (request === undefined) { + return; + } + const unsupportedAppVersionError = + response.status === 403 ? findUnsupportedAppVersionError(response.payload) : null; + if (unsupportedAppVersionError) { + applyUnsupportedAppVersion(unsupportedAppVersionError.meta, request); + return; + } + if (response.headers.get(APP_VERSION_STATUS_HEADER)?.toLowerCase() === 'supported') { + applySupportedAppVersion(request); + } + if (response.ok && requestInit.path === ENVIRONMENT_PATH) { + applyFetchedEnvironment(environmentJSON(response.payload), request); + } + }, + }; +} + +function findUnsupportedAppVersionError(payload: unknown): { meta?: unknown } | null { + const errors = isRecord(payload) && Array.isArray(payload.errors) ? payload.errors : []; + return errors.find(error => isRecord(error) && error.code === UNSUPPORTED_APP_VERSION_CODE) ?? null; +} + +function environmentJSON(payload: unknown): unknown { + return isRecord(payload) && isRecord(payload.response) ? payload.response : payload; +} diff --git a/packages/expo/src/app-version/appVersionSupport.ts b/packages/expo/src/app-version/appVersionSupport.ts new file mode 100644 index 00000000000..5c61a2b3cfb --- /dev/null +++ b/packages/expo/src/app-version/appVersionSupport.ts @@ -0,0 +1,279 @@ +import type { NativeAppIdentity, NativeAppPlatform } from './nativeAppIdentity'; +import { getNativeAppIdentity, getNativeAppPlatform, isExpoApplicationInstalled } from './nativeAppIdentity'; + +/** + * Whether this build of your app meets the minimum version set for it in the Clerk Dashboard. + */ +export type AppVersionSupportStatus = { + /** + * Whether this app version is supported. When it is `false`, Clerk rejects the app's requests until the user updates. + */ + isSupported: boolean; + /** + * This build's version: its `CFBundleShortVersionString` on iOS and its `versionCode` on Android. `null` when it can't be read, such as in Expo Go or without `expo-application`. + */ + currentVersion: string | null; + /** + * The oldest supported version, when a minimum is set for this app. + */ + minimumVersion: string | null; + /** + * The store page to send users to so they can update. + */ + updateUrl: string | null; +}; + +export type UpdateRequiredDisplay = { + applicationName: string | null; + logoImageUrl: string | null; + supportEmail: string | null; +}; + +const EMPTY_DISPLAY: UpdateRequiredDisplay = { applicationName: null, logoImageUrl: null, supportEmail: null }; + +type MinimumVersionJSON = { + bundle_id?: unknown; + package_name?: unknown; + minimum_version?: unknown; + update_url?: unknown; +}; + +const IOS_VERSION = /^\d+(\.\d+){0,2}$/; +const ANDROID_VERSION_CODE = /^[1-9]\d*$/; + +const listeners = new Set<() => void>(); +let environmentStatus: AppVersionSupportStatus | null = null; +let serverAnswer: { isSupported: true } | { isSupported: false; meta: Record } | null = null; +let requestSequence = 0; +let lastResetRequest = 0; +let lastAnsweredRequest = 0; +let status = supportedStatus(); +let display = EMPTY_DISPLAY; +let missingExpoApplicationWarned = false; + +export function subscribeToAppVersionSupport(listener: () => void): () => void { + listeners.add(listener); + return () => listeners.delete(listener); +} + +export function getAppVersionSupportStatus(): AppVersionSupportStatus { + return status; +} + +export function getUpdateRequiredDisplay(): UpdateRequiredDisplay { + return display; +} + +export function nextAppVersionRequest(): number { + requestSequence += 1; + return requestSequence; +} + +export function resetAppVersionSupport(): void { + lastResetRequest = requestSequence; + lastAnsweredRequest = requestSequence; + environmentStatus = null; + serverAnswer = null; + display = EMPTY_DISPLAY; + publish({ displayChanged: true, notifyLater: true }); +} + +export function applyFetchedEnvironment(environment: unknown, request: number): void { + if (request <= lastResetRequest) { + return; + } + environmentStatus = resolveEnvironmentStatus(environment); + const nextDisplay = resolveDisplay(environment); + const displayChanged = !sameDisplay(nextDisplay, display); + if (displayChanged) { + display = nextDisplay; + } + publish({ displayChanged }); +} + +export function applyUnsupportedAppVersion(meta: unknown, request: number): void { + if (!acceptAnswer(request)) { + return; + } + serverAnswer = { isSupported: false, meta: isRecord(meta) ? meta : {} }; + publish(); +} + +export function applySupportedAppVersion(request: number): void { + if (!acceptAnswer(request)) { + return; + } + serverAnswer = { isSupported: true }; + publish(); +} + +function acceptAnswer(request: number): boolean { + if (request <= lastAnsweredRequest) { + return false; + } + lastAnsweredRequest = request; + return true; +} + +function resolveEnvironmentStatus(environment: unknown): AppVersionSupportStatus { + const identity = getNativeAppIdentity(); + const minimums = minimumVersionsForPlatform(environment, identity?.platform ?? getNativeAppPlatform()); + if (!identity) { + warnIfExpoApplicationIsMissing(minimums.length > 0); + return supportedStatus(); + } + const policy = minimums.find(entry => appIdentifier(entry, identity.platform) === identity.appIdentifier); + if (!policy) { + return supportedStatus(); + } + const minimumVersion = stringOrNull(policy.minimum_version); + const meetsMinimum = minimumVersion ? isAtLeast(identity, minimumVersion) : null; + return { + isSupported: meetsMinimum !== false, + currentVersion: identity.version, + minimumVersion: meetsMinimum === null ? null : minimumVersion, + updateUrl: httpUrlOrNull(policy.update_url), + }; +} + +function minimumVersionsForPlatform(environment: unknown, platform: NativeAppPlatform | null): MinimumVersionJSON[] { + if (!platform || !isRecord(environment)) { + return []; + } + const settings = environment.native_app_settings; + const minimums = isRecord(settings) ? settings.minimum_supported_version : undefined; + const entries = isRecord(minimums) ? minimums[platform] : undefined; + return Array.isArray(entries) ? entries.filter(isRecord) : []; +} + +function appIdentifier(entry: MinimumVersionJSON, platform: NativeAppPlatform): string | null { + return stringOrNull(platform === 'ios' ? entry.bundle_id : entry.package_name); +} + +function isAtLeast(identity: NativeAppIdentity, minimumVersion: string): boolean | null { + const current = parseVersion(identity.platform, identity.version); + const required = parseVersion(identity.platform, minimumVersion); + if (!current || !required) { + return null; + } + for (let index = 0; index < Math.max(current.length, required.length); index++) { + const difference = (current[index] ?? 0) - (required[index] ?? 0); + if (difference !== 0) { + return difference > 0; + } + } + return true; +} + +function parseVersion(platform: NativeAppPlatform, version: string): number[] | null { + const pattern = platform === 'ios' ? IOS_VERSION : ANDROID_VERSION_CODE; + if (!pattern.test(version)) { + return null; + } + const components = version.split('.').map(Number); + return components.every(Number.isSafeInteger) ? components : null; +} + +function httpUrlOrNull(value: unknown): string | null { + const url = stringOrNull(value); + if (!url) { + return null; + } + try { + const parsed = new URL(url); + return (parsed.protocol === 'https:' || parsed.protocol === 'http:') && parsed.hostname ? url : null; + } catch { + return null; + } +} + +function warnIfExpoApplicationIsMissing(appHasMinimums: boolean): void { + if (!__DEV__ || missingExpoApplicationWarned || !appHasMinimums || isExpoApplicationInstalled()) { + return; + } + missingExpoApplicationWarned = true; + console.warn( + 'Clerk: This instance sets minimum app versions, but Clerk cannot read your app version without expo-application. Until you install it, useAppVersionSupport() always reports the app as supported, requests from JavaScript are not blocked, and shows no update screen. Clerk native components still block outdated versions and show their own screen.\nInstall it with: npx expo install expo-application', + ); +} + +function supportedStatus(): AppVersionSupportStatus { + return { + isSupported: true, + currentVersion: getNativeAppIdentity()?.version ?? null, + minimumVersion: null, + updateUrl: null, + }; +} + +function rejectionStatus(meta: Record): AppVersionSupportStatus { + return { + isSupported: false, + currentVersion: stringOrNull(meta.current_version) ?? getNativeAppIdentity()?.version ?? null, + minimumVersion: stringOrNull(meta.minimum_version) ?? environmentStatus?.minimumVersion ?? null, + updateUrl: httpUrlOrNull(meta.update_url) ?? environmentStatus?.updateUrl ?? null, + }; +} + +function publish({ + displayChanged = false, + notifyLater = false, +}: { displayChanged?: boolean; notifyLater?: boolean } = {}): void { + const next = !serverAnswer + ? (environmentStatus ?? supportedStatus()) + : !serverAnswer.isSupported + ? rejectionStatus(serverAnswer.meta) + : environmentStatus?.isSupported + ? environmentStatus + : supportedStatus(); + const statusChanged = !sameStatus(next, status); + if (statusChanged) { + status = next; + } + if (statusChanged || displayChanged) { + if (notifyLater) { + queueMicrotask(notifyListeners); + } else { + notifyListeners(); + } + } +} + +function notifyListeners(): void { + listeners.forEach(listener => listener()); +} + +function resolveDisplay(environment: unknown): UpdateRequiredDisplay { + const displayConfig = isRecord(environment) ? environment.display_config : undefined; + if (!isRecord(displayConfig)) { + return display; + } + return { + applicationName: stringOrNull(displayConfig.application_name), + logoImageUrl: httpUrlOrNull(displayConfig.logo_image_url), + supportEmail: stringOrNull(displayConfig.support_email), + }; +} + +function sameDisplay(a: UpdateRequiredDisplay, b: UpdateRequiredDisplay): boolean { + return ( + a.applicationName === b.applicationName && a.logoImageUrl === b.logoImageUrl && a.supportEmail === b.supportEmail + ); +} + +function sameStatus(a: AppVersionSupportStatus, b: AppVersionSupportStatus): boolean { + return ( + a.isSupported === b.isSupported && + a.currentVersion === b.currentVersion && + a.minimumVersion === b.minimumVersion && + a.updateUrl === b.updateUrl + ); +} + +function stringOrNull(value: unknown): string | null { + return typeof value === 'string' && value.length > 0 ? value : null; +} + +export function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} diff --git a/packages/expo/src/app-version/nativeAppIdentity.ts b/packages/expo/src/app-version/nativeAppIdentity.ts new file mode 100644 index 00000000000..578f3cd55c6 --- /dev/null +++ b/packages/expo/src/app-version/nativeAppIdentity.ts @@ -0,0 +1,72 @@ +import { Platform } from 'react-native'; + +import { isExpoGo } from '../utils/runtime'; + +export type NativeAppPlatform = 'ios' | 'android'; + +export type NativeAppIdentity = { + platform: NativeAppPlatform; + appIdentifier: string; + version: string; + displayVersion: string; +}; + +type ExpoApplication = { + applicationId: string | null; + applicationName: string | null; + nativeApplicationVersion: string | null; + nativeBuildVersion: string | null; +}; + +let cachedIdentity: NativeAppIdentity | null | undefined; + +export function getNativeAppIdentity(): NativeAppIdentity | null { + if (cachedIdentity === undefined) { + cachedIdentity = readNativeAppIdentity(); + } + return cachedIdentity; +} + +export function getNativeAppPlatform(): NativeAppPlatform | null { + return Platform.OS === 'ios' || Platform.OS === 'android' ? Platform.OS : null; +} + +function readNativeAppIdentity(): NativeAppIdentity | null { + const platform = getNativeAppPlatform(); + if (!platform || isExpoGo()) { + return null; + } + const application = loadExpoApplication(); + const appIdentifier = application?.applicationId; + const version = platform === 'ios' ? application?.nativeApplicationVersion : application?.nativeBuildVersion; + if (!appIdentifier || !version) { + return null; + } + return { platform, appIdentifier, version, displayVersion: application?.nativeApplicationVersion || version }; +} + +export function getNativeAppName(): string | null { + return loadExpoApplication()?.applicationName || null; +} + +export function isExpoApplicationInstalled(): boolean { + return loadExpoApplication() !== null; +} + +function loadExpoApplication(): ExpoApplication | null { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + return require('expo-application') as ExpoApplication; + } catch { + return null; + } +} + +export function setNativeAppIdentityHeaders(headers: Headers): void { + const identity = getNativeAppIdentity(); + if (!identity) { + return; + } + headers.set(identity.platform === 'ios' ? 'x-bundle-id' : 'x-package-name', identity.appIdentifier); + headers.set('x-app-version', identity.version); +} diff --git a/packages/expo/src/hooks/__tests__/useAppVersionSupport.test.ts b/packages/expo/src/hooks/__tests__/useAppVersionSupport.test.ts new file mode 100644 index 00000000000..c696bf95b85 --- /dev/null +++ b/packages/expo/src/hooks/__tests__/useAppVersionSupport.test.ts @@ -0,0 +1,32 @@ +import { act, renderHook } from '@testing-library/react'; +import { describe, expect, test, vi } from 'vitest'; + +vi.mock('../../app-version/nativeAppIdentity', () => ({ + getNativeAppIdentity: () => ({ + platform: 'ios', + appIdentifier: 'com.example.app', + version: '1.0', + displayVersion: '1.0', + }), + getNativeAppPlatform: () => 'ios', + isExpoApplicationInstalled: () => true, +})); + +describe('useAppVersionSupport', () => { + test('re-renders when the app becomes unsupported and supported again', async () => { + const store = await import('../../app-version/appVersionSupport'); + const { useAppVersionSupport } = await import('../useAppVersionSupport'); + const { result } = renderHook(() => useAppVersionSupport()); + expect(result.current.isSupported).toBe(true); + + act(() => { + store.applyUnsupportedAppVersion({ minimum_version: '2.0' }, store.nextAppVersionRequest()); + }); + expect(result.current).toMatchObject({ isSupported: false, currentVersion: '1.0', minimumVersion: '2.0' }); + + act(() => { + store.applySupportedAppVersion(store.nextAppVersionRequest()); + }); + expect(result.current.isSupported).toBe(true); + }); +}); diff --git a/packages/expo/src/hooks/index.ts b/packages/expo/src/hooks/index.ts index 92644a4eee3..d6e5cd7dbb3 100644 --- a/packages/expo/src/hooks/index.ts +++ b/packages/expo/src/hooks/index.ts @@ -17,3 +17,4 @@ export { export * from './useSSO'; export * from './useOAuth'; export * from './useAuth'; +export * from './useAppVersionSupport'; diff --git a/packages/expo/src/hooks/useAppVersionSupport.ts b/packages/expo/src/hooks/useAppVersionSupport.ts new file mode 100644 index 00000000000..6e9f16d136e --- /dev/null +++ b/packages/expo/src/hooks/useAppVersionSupport.ts @@ -0,0 +1,25 @@ +import { useSyncExternalStore } from 'react'; + +import type { AppVersionSupportStatus } from '../app-version/appVersionSupport'; +import { getAppVersionSupportStatus, subscribeToAppVersionSupport } from '../app-version/appVersionSupport'; + +export type { AppVersionSupportStatus } from '../app-version/appVersionSupport'; + +/** + * Returns whether this build of your app meets the minimum version set for it in the Clerk Dashboard, and updates when that changes. + * + * When the version is unsupported, Clerk rejects the app's requests until the user updates. Read `updateUrl` to send them to the store. + * + * Clerk reads the app's version with `expo-application`. Without it, or in Expo Go, the app is always reported as supported. + * + * @example + * ```tsx + * const { isSupported, updateUrl } = useAppVersionSupport(); + * if (!isSupported) { + * return ; + * } + * ``` + */ +export function useAppVersionSupport(): AppVersionSupportStatus { + return useSyncExternalStore(subscribeToAppVersionSupport, getAppVersionSupportStatus, getAppVersionSupportStatus); +} diff --git a/packages/expo/src/hooks/useHostedAuth.ts b/packages/expo/src/hooks/useHostedAuth.ts index 632173cff42..ab7019b2678 100644 --- a/packages/expo/src/hooks/useHostedAuth.ts +++ b/packages/expo/src/hooks/useHostedAuth.ts @@ -8,6 +8,7 @@ import { getClerkInstance } from '../provider/singleton'; 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 }; @@ -243,20 +244,17 @@ function getExpoAppIdentifier(): string | undefined { // eslint-disable-next-line @typescript-eslint/no-require-imports const constantsModule = require('expo-constants') as { default?: { - appOwnership?: string | null; - executionEnvironment?: string; expoConfig?: { ios?: { bundleIdentifier?: string }; android?: { package?: string }; }; }; }; - const constants = constantsModule.default; - if (constants?.executionEnvironment === 'storeClient' || constants?.appOwnership === 'expo') { + if (isExpoGo()) { return undefined; } - const expoConfig = constants?.expoConfig; + const expoConfig = constantsModule.default?.expoConfig; return Platform.OS === 'ios' ? expoConfig?.ios?.bundleIdentifier : expoConfig?.android?.package; } catch { return undefined; diff --git a/packages/expo/src/provider/ClerkProvider.tsx b/packages/expo/src/provider/ClerkProvider.tsx index b4020defd97..94555b30445 100644 --- a/packages/expo/src/provider/ClerkProvider.tsx +++ b/packages/expo/src/provider/ClerkProvider.tsx @@ -4,7 +4,9 @@ import type { ClerkProviderProps as ReactClerkProviderProps } from '@clerk/react import { InternalClerkProvider as ClerkReactProvider, type Ui } from '@clerk/react/internal'; import { useEffect, useMemo, useRef } from 'react'; +import { getNativeAppIdentity } from '../app-version/nativeAppIdentity'; import type { TokenCache } from '../cache/types'; +import { UpdateRequiredModal } from '../update-required/UpdateRequiredModal'; import { ClerkExpoModule } from '../utils/native-module'; import { isNative, isWeb } from '../utils/runtime'; import { maybeCompleteAuthSession } from './maybeCompleteAuthSession'; @@ -50,6 +52,12 @@ export type ClerkProviderProps = Omit(props: ClerkProviderProps(props: ClerkProviderProps (nativeModule ? createNativeClientSync(nativeModule, pk, nativeProxyUrl, () => tokenCacheRef.current) : null), + () => + nativeModule + ? createNativeClientSync( + nativeModule, + pk, + nativeProxyUrl, + () => tokenCacheRef.current, + () => nativeUpdateRequiredViewEnabledRef.current, + ) + : null, [nativeModule, pk, nativeProxyUrl], ); @@ -127,6 +147,7 @@ export function ClerkProvider(props: ClerkProviderProps {children} + {isNative() && updateRequiredViewEnabled && } ); } diff --git a/packages/expo/src/provider/__tests__/ClerkProvider.recovery.test.tsx b/packages/expo/src/provider/__tests__/ClerkProvider.recovery.test.tsx new file mode 100644 index 00000000000..e5ce8084b2f --- /dev/null +++ b/packages/expo/src/provider/__tests__/ClerkProvider.recovery.test.tsx @@ -0,0 +1,122 @@ +import { useAuth } from '@clerk/react'; +import { createClerkEventBus } from '@clerk/shared/clerkEventBus'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { useState } from 'react'; +import { describe, expect, test, vi } from 'vitest'; + +import { + applySupportedAppVersion, + applyUnsupportedAppVersion, + nextAppVersionRequest, +} from '../../app-version/appVersionSupport'; +import { ClerkProvider } from '../ClerkProvider'; + +const { failingClerk } = vi.hoisted(() => ({ failingClerk: { current: null as unknown } })); + +vi.mock('../../polyfills', () => ({})); + +vi.mock('expo-secure-store', () => ({ + AFTER_FIRST_UNLOCK: 0, + deleteItemAsync: vi.fn(), + getItemAsync: vi.fn(), + setItemAsync: vi.fn(), +})); + +vi.mock('react-native', () => ({ + NativeModules: {}, + Platform: { OS: 'ios', constants: { reactNativeVersion: { major: 0, minor: 81, patch: 0 } } }, + AppState: { addEventListener: () => ({ remove: () => {} }) }, +})); + +vi.mock('../../utils/runtime', () => ({ + isExpoGo: () => false, + isNative: () => true, + isWeb: () => false, +})); + +vi.mock('../../utils/native-module', () => ({ ClerkExpoModule: null })); + +vi.mock('../../update-required/UpdateRequiredModal', () => ({ UpdateRequiredModal: () => null })); + +vi.mock('../../app-version/nativeAppIdentity', () => ({ + getNativeAppIdentity: () => ({ + platform: 'ios', + appIdentifier: 'com.example.app', + version: '1.0', + displayVersion: '1.0', + }), + getNativeAppPlatform: () => 'ios', + isExpoApplicationInstalled: () => true, +})); + +vi.mock('../singleton', () => ({ getClerkInstance: () => failingClerk.current })); + +function createFailingClerk() { + const eventBus = createClerkEventBus(); + let status = 'loading'; + eventBus.prioritizedOn('status', next => (status = next)); + const resources = { client: { signedInSessions: [] }, session: null, user: null, organization: null }; + return { + publishableKey: 'pk_test_Y2xlcmsuZXhhbXBsZS5jb20k', + get loaded() { + return status === 'ready' || status === 'degraded'; + }, + get status() { + return status; + }, + load: vi.fn(() => { + eventBus.emit('status', 'error'); + return Promise.reject(new Error('Unsupported app version')); + }), + on: eventBus.on, + off: eventBus.off, + addListener: (listener: (resources: unknown) => void) => { + listener(resources); + return () => {}; + }, + finishLoad: () => eventBus.emit('status', 'ready'), + __internal_lastEmittedResources: resources, + ...resources, + }; +} + +function Counter() { + const [count, setCount] = useState(0); + const { isLoaded } = useAuth(); + return ( + + ); +} + +describe('ClerkProvider after Clerk fails to load while the app version is unsupported', () => { + test('finishes loading without remounting the app', async () => { + const clerk = createFailingClerk(); + failingClerk.current = clerk; + act(() => applyUnsupportedAppVersion({ minimum_version: '2.0' }, nextAppVersionRequest())); + + render( + + + , + ); + // The failed load settles asynchronously. + await act(() => Promise.resolve()); + fireEvent.click(screen.getByRole('button')); + expect(screen.getByRole('button').textContent).toBe('1 not loaded'); + + act(() => applySupportedAppVersion(nextAppVersionRequest())); + // React replays its queued calls on a microtask after the finished load. + await act(() => { + clerk.finishLoad(); + return Promise.resolve(); + }); + + expect(screen.getByRole('button').textContent).toBe('1 loaded'); + expect(clerk.load).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/expo/src/provider/__tests__/nativeClientSync.test.ts b/packages/expo/src/provider/__tests__/nativeClientSync.test.ts index 7f178173d36..dd9e75a200f 100644 --- a/packages/expo/src/provider/__tests__/nativeClientSync.test.ts +++ b/packages/expo/src/provider/__tests__/nativeClientSync.test.ts @@ -88,6 +88,55 @@ describe('createNativeClientSync', () => { expect(native.configureNative).toHaveBeenCalledWith('pk_test_123', 'seed-token', 'https://example.com/__clerk'); }); + test('tells native whether to show its update screen before configuring it', async () => { + const createNativeClientSync = await loadEngine(); + const native = { ...nativeModule(), setUpdateRequiredViewEnabled: vi.fn().mockResolvedValue(undefined) }; + const cache = userTokenCache(vi.fn().mockResolvedValue(null)); + + await createNativeClientSync( + native, + 'pk_test_123', + null, + () => cache, + () => false, + ).tokenCache.getToken(CLERK_CLIENT_JWT_KEY); + + expect(native.setUpdateRequiredViewEnabled).toHaveBeenCalledWith(false); + expect(vi.mocked(native.setUpdateRequiredViewEnabled).mock.invocationCallOrder[0]).toBeLessThan( + vi.mocked(native.configureNative).mock.invocationCallOrder[0], + ); + }); + + test('leaves the native update screen on unless told otherwise', async () => { + const createNativeClientSync = await loadEngine(); + const native = { ...nativeModule(), setUpdateRequiredViewEnabled: vi.fn().mockResolvedValue(undefined) }; + const cache = userTokenCache(vi.fn().mockResolvedValue(null)); + + await createNativeClientSync(native, 'pk_test_123', null, () => cache).tokenCache.getToken(CLERK_CLIENT_JWT_KEY); + + expect(native.setUpdateRequiredViewEnabled).toHaveBeenCalledWith(true); + }); + + test('still configures native when setting its update screen fails', async () => { + const createNativeClientSync = await loadEngine(); + vi.spyOn(console, 'warn').mockImplementation(() => {}); + const native = { + ...nativeModule(), + setUpdateRequiredViewEnabled: vi.fn().mockRejectedValue(new Error('not available')), + }; + const cache = userTokenCache(vi.fn().mockResolvedValue(null)); + + await createNativeClientSync( + native, + 'pk_test_123', + null, + () => cache, + () => false, + ).tokenCache.getToken(CLERK_CLIENT_JWT_KEY); + + expect(native.configureNative).toHaveBeenCalledWith('pk_test_123', null, null); + }); + test('configures native again when the proxy URL changes', async () => { const createNativeClientSync = await loadEngine(); const native = nativeModule(); diff --git a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx index 18b923bdfc6..192dc882ece 100644 --- a/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx +++ b/packages/expo/src/provider/__tests__/nativeClientSyncBehavior/nativeClientSync.behavior.test.tsx @@ -11,6 +11,18 @@ vi.mock('react-native', () => ({ Platform: { OS: 'ios', constants: { reactNativeVersion: { major: 0, minor: 81, patch: 0 } } }, })); +vi.mock('../../../app-version/appVersionRequestHooks', () => ({ + createAppVersionRequestHooks: () => ({ + checkOnForeground: () => {}, + beforeRequest: () => {}, + afterResponse: () => {}, + }), +})); + +vi.mock('../../../update-required/UpdateRequiredModal', () => ({ + UpdateRequiredModal: () => null, +})); + vi.mock('expo-secure-store', () => ({ AFTER_FIRST_UNLOCK: 0, deleteItemAsync: vi.fn(), @@ -19,6 +31,7 @@ vi.mock('expo-secure-store', () => ({ })); vi.mock('../../../utils/runtime', () => ({ + isExpoGo: () => false, isNative: () => true, isWeb: () => false, })); diff --git a/packages/expo/src/provider/nativeClientSync.ts b/packages/expo/src/provider/nativeClientSync.ts index ca58041900d..4621de55ca7 100644 --- a/packages/expo/src/provider/nativeClientSync.ts +++ b/packages/expo/src/provider/nativeClientSync.ts @@ -135,12 +135,16 @@ function configureOnce( publishableKey: string, proxyUrl: string | null, tokenCache: TokenCache, + updateRequiredViewEnabled: boolean, ) { if (configuration?.publishableKey !== publishableKey || configuration.proxyUrl !== proxyUrl) { let configured = false; let timedOut = false; const configuring = (async () => { const seed = await readSeedDeviceToken(tokenCache); + await native + .setUpdateRequiredViewEnabled?.(updateRequiredViewEnabled) + .catch(error => warn('Failed to set whether the native SDK shows its update screen.', error)); await native .configureNative(publishableKey, seed, proxyUrl) .catch(() => native.configureNative(publishableKey, seed, proxyUrl)); @@ -182,9 +186,10 @@ export function createNativeClientSync( publishableKey: string, proxyUrl: string | null, getUserTokenCache: () => TokenCache | undefined, + getNativeUpdateRequiredViewEnabled: () => boolean = () => true, ) { const fallback = () => getUserTokenCache() ?? MemoryTokenCache; - const ready = () => configureOnce(native, publishableKey, proxyUrl, fallback()); + const ready = () => configureOnce(native, publishableKey, proxyUrl, fallback(), getNativeUpdateRequiredViewEnabled()); let mirroredToken: string | null = null; let lastFingerprint: string | undefined; let refreshFailure: { error: unknown } | null = null; diff --git a/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts b/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts index c5458adeb8d..18886229d6c 100644 --- a/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts +++ b/packages/expo/src/provider/singleton/__tests__/createClerkInstance.test.ts @@ -39,6 +39,17 @@ vi.mock('react-native', () => { }; }); +vi.mock('../../../app-version/appVersionRequestHooks', () => ({ + createAppVersionRequestHooks: () => ({ + checkOnForeground: () => {}, + beforeRequest: ({ headers }: { headers: Headers }) => { + headers.set('x-bundle-id', 'com.example.app'); + headers.set('x-app-version', '4.8.4'); + }, + afterResponse: () => {}, + }), +})); + class MockClerk { public publishableKey: string; public proxyUrl: unknown; @@ -386,6 +397,20 @@ describe('createClerkInstance', () => { await expect(tokenCache.getToken(CLERK_CLIENT_JWT_KEY)).resolves.toBe('fresh-token'); }); + test('identifies the app without changing the API version', async () => { + const { beforeRequest, requestInit } = await setUpBeforeRequest({ + getToken: vi.fn().mockResolvedValue(null), + saveToken: vi.fn(), + }); + requestInit.url.searchParams.set('__clerk_api_version', '2026-08-20'); + + await beforeRequest(requestInit); + + expect(requestInit.url.searchParams.getAll('__clerk_api_version')).toEqual(['2026-08-20']); + expect(requestInit.headers.get('x-bundle-id')).toBe('com.example.app'); + expect(requestInit.headers.get('x-app-version')).toBe('4.8.4'); + }); + test('waits until the app is active and retries when the token cache read fails in the background', async () => { setAppState('background'); const getToken = vi diff --git a/packages/expo/src/provider/singleton/createClerkInstance.ts b/packages/expo/src/provider/singleton/createClerkInstance.ts index c0e523cb0be..36c72dfd148 100644 --- a/packages/expo/src/provider/singleton/createClerkInstance.ts +++ b/packages/expo/src/provider/singleton/createClerkInstance.ts @@ -10,6 +10,7 @@ import type { import { Platform } from 'react-native'; import packageJson from '../../../package.json'; +import { createAppVersionRequestHooks } from '../../app-version/appVersionRequestHooks'; import { ClientResourceCache, DUMMY_CLERK_CLIENT_RESOURCE, @@ -140,10 +141,18 @@ export function createClerkInstance(ClerkClass: typeof Clerk) { : tokenCache.saveToken(CLERK_CLIENT_JWT_KEY, token); }; const requestTokens = new WeakMap(); + const appVersionRequestHooks = isNative() ? createAppVersionRequestHooks() : null; __internal_clerkOptions = { publishableKey, proxyUrl, domain }; const clerk = new ClerkClass(publishableKey, { proxyUrl, domain }) as unknown as BrowserClerk; __internal_clerk = clerk; + appVersionRequestHooks?.checkOnForeground({ + reload: () => clerk.__internal_reloadInitialResources(), + // `GET /v1/` only passes Clerk's app version check, and never reads or creates a client. + check: () => + // @ts-expect-error - This is an internal API + clerk.getFapiClient().request({ method: 'GET', path: '/' }, { allowEmptyBody: true, fetchMaxTries: 1 }), + }); if (Platform.OS === 'ios' || Platform.OS === 'android') { // @ts-expect-error - This is an internal API @@ -314,6 +323,7 @@ export function createClerkInstance(ClerkClass: typeof Clerk) { if (isNative()) { (requestInit.headers as Headers).set('x-mobile', '1'); (requestInit.headers as Headers).set('x-expo-sdk-version', packageJson.version); + appVersionRequestHooks?.beforeRequest(requestInit); } }); @@ -325,6 +335,8 @@ export function createClerkInstance(ClerkClass: typeof Clerk) { await saveClientToken(authHeader, requestTokens.get(requestInit) ?? null); } + appVersionRequestHooks?.afterResponse(requestInit, response); + if (__DEV__ && !nativeApiErrorShown && response.payload?.errors?.[0]?.code === 'native_api_disabled') { console.error( 'Clerk: The Native API is disabled for this instance.\nGo to Clerk Dashboard > Configure > Native applications to enable it.\nOr, navigate here: https://dashboard.clerk.com/~/native-applications', diff --git a/packages/expo/src/specs/NativeClerkModule.android.ts b/packages/expo/src/specs/NativeClerkModule.android.ts index 2447e7caf0d..931e6fb03c7 100644 --- a/packages/expo/src/specs/NativeClerkModule.android.ts +++ b/packages/expo/src/specs/NativeClerkModule.android.ts @@ -1,8 +1,12 @@ import { requireOptionalNativeModule } from 'expo'; -import type { NativeAuthFlowModule, NativeClientSyncModule } from './NativeClerkModule.types'; +import type { + NativeAuthFlowModule, + NativeClientSyncModule, + NativeUpdateRequiredViewModule, +} from './NativeClerkModule.types'; -interface Spec extends NativeAuthFlowModule, Partial { +interface Spec extends NativeAuthFlowModule, Partial, Partial { // Exposed by Expo Modules EventEmitter for the internal `clerkNativeClientInvalidated` event. // This is not part of the public @clerk/expo API. addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; diff --git a/packages/expo/src/specs/NativeClerkModule.ts b/packages/expo/src/specs/NativeClerkModule.ts index b99f38e0460..6d71d7f317a 100644 --- a/packages/expo/src/specs/NativeClerkModule.ts +++ b/packages/expo/src/specs/NativeClerkModule.ts @@ -1,8 +1,13 @@ import { requireOptionalNativeModule } from 'expo'; -import type { NativeAuthFlowModule, NativeClientSyncModule } from './NativeClerkModule.types'; +import type { + NativeAuthFlowModule, + NativeClientSyncModule, + NativeUpdateRequiredViewModule, +} from './NativeClerkModule.types'; -export interface Spec extends NativeAuthFlowModule, Partial { +export interface Spec + extends NativeAuthFlowModule, Partial, Partial { // Exposed by Expo Modules EventEmitter for the internal `clerkNativeClientInvalidated` event. // This is not part of the public @clerk/expo API. addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; diff --git a/packages/expo/src/specs/NativeClerkModule.types.ts b/packages/expo/src/specs/NativeClerkModule.types.ts index 9165f335198..8504ad003d4 100644 --- a/packages/expo/src/specs/NativeClerkModule.types.ts +++ b/packages/expo/src/specs/NativeClerkModule.types.ts @@ -7,6 +7,10 @@ export type NativeAuthFlowModule = { getAuthFlowState(): Promise; }; +export type NativeUpdateRequiredViewModule = { + setUpdateRequiredViewEnabled(enabled: boolean): Promise; +}; + /** * Native storage owns the device token. JS reads it before each FAPI request, writes rotated tokens * back with compare-and-set, and refetches its own client on `clerkNativeClientInvalidated`. diff --git a/packages/expo/src/update-required/UpdateRequiredModal.tsx b/packages/expo/src/update-required/UpdateRequiredModal.tsx new file mode 100644 index 00000000000..66e0a9b38fe --- /dev/null +++ b/packages/expo/src/update-required/UpdateRequiredModal.tsx @@ -0,0 +1,18 @@ +import { Modal } from 'react-native'; + +import { useAppVersionSupport } from '../hooks/useAppVersionSupport'; +import { UpdateRequiredView } from './UpdateRequiredView'; + +export function UpdateRequiredModal(): JSX.Element { + const { isSupported } = useAppVersionSupport(); + return ( + {}} + > + + + ); +} diff --git a/packages/expo/src/update-required/UpdateRequiredView.tsx b/packages/expo/src/update-required/UpdateRequiredView.tsx new file mode 100644 index 00000000000..bd08cf028ad --- /dev/null +++ b/packages/expo/src/update-required/UpdateRequiredView.tsx @@ -0,0 +1,299 @@ +import { useSyncExternalStore } from 'react'; +import { Image, Linking, Platform, Pressable, ScrollView, StyleSheet, Text, useColorScheme, View } from 'react-native'; + +import type { AppVersionSupportStatus, UpdateRequiredDisplay } from '../app-version/appVersionSupport'; +import { getUpdateRequiredDisplay, subscribeToAppVersionSupport } from '../app-version/appVersionSupport'; +import { getNativeAppIdentity, getNativeAppName } from '../app-version/nativeAppIdentity'; +import { useAppVersionSupport } from '../hooks/useAppVersionSupport'; +import { type UpdateRequiredColors, updateRequiredColors } from './colors'; + +const GOOGLE_PLAY_HOST = 'play.google.com'; +const MAX_ACTIONS_WIDTH = 320; + +export function UpdateRequiredView(): JSX.Element { + const status = useAppVersionSupport(); + const display = useSyncExternalStore( + subscribeToAppVersionSupport, + getUpdateRequiredDisplay, + getUpdateRequiredDisplay, + ); + const colors = updateRequiredColors(useColorScheme()); + + return ( + + ); +} + +type UpdateRequiredContentProps = { + status: AppVersionSupportStatus; + display: UpdateRequiredDisplay; + appName: string; + displayVersion: string | null; + colors: UpdateRequiredColors; +}; + +export function UpdateRequiredContent({ + status, + display, + appName, + displayVersion, + colors, +}: UpdateRequiredContentProps): JSX.Element { + const updateUrl = status.updateUrl; + const supportEmail = display.supportEmail; + + return ( + + + {display.logoImageUrl && ( + + )} + + + Update required + + + {`This version of ${appName} is no longer supported. Please update to continue.`} + + + + + + + {updateUrl && ( + void Linking.openURL(updateUrl)} + style={({ pressed }) => [ + styles.primaryButton, + { backgroundColor: colors.primary, opacity: pressed ? 0.8 : 1 }, + ]} + > + + {storeButtonText(updateUrl)} + + + )} + {supportEmail && ( + void Linking.openURL(`mailto:${supportEmail}`)} + style={({ pressed }) => [styles.helpButton, { opacity: pressed ? 0.6 : 1 }]} + > + + {"Can't update? "} + {getHelpText()} + + + )} + + + ); +} + +function VersionBox({ + status, + displayVersion, + colors, +}: { + status: AppVersionSupportStatus; + displayVersion: string | null; + colors: UpdateRequiredColors; +}): JSX.Element | null { + const boxStyle = [styles.versionBox, { backgroundColor: colors.versionBoxBackground, borderColor: colors.border }]; + + if (Platform.OS === 'android') { + if (!displayVersion) { + return null; + } + return ( + + + + ); + } + + if (!status.minimumVersion || !status.currentVersion) { + return null; + } + return ( + + + + + + ); +} + +function VersionColumn({ + label, + version, + colors, +}: { + label: string; + version: string; + colors: UpdateRequiredColors; +}): JSX.Element { + return ( + + {label} + {version} + + ); +} + +export function storeButtonText(updateUrl: string): string { + if (Platform.OS === 'ios') { + return 'Go to App Store'; + } + try { + return new URL(updateUrl).hostname.toLowerCase() === GOOGLE_PLAY_HOST ? 'Go to Google Play' : 'Update app'; + } catch { + return 'Update app'; + } +} + +function getHelpText(): string { + return Platform.OS === 'ios' ? 'Get Help' : 'Get help'; +} + +const isIOS = Platform.OS === 'ios'; + +const styles = StyleSheet.create({ + screen: { + flex: 1, + }, + scroll: { + flex: 1, + }, + content: { + alignItems: 'center', + gap: 24, + paddingHorizontal: 24, + paddingTop: isIOS ? 120 : 64, + paddingBottom: 24, + }, + logo: { + width: 44, + height: 44, + }, + header: { + alignItems: 'center', + gap: 8, + width: '100%', + }, + title: { + fontSize: 22, + lineHeight: 28, + fontWeight: isIOS ? '600' : '500', + textAlign: 'center', + }, + subtitle: { + fontSize: isIOS ? 15 : 16, + lineHeight: isIOS ? 20 : 24, + letterSpacing: isIOS ? 0 : 0.15, + textAlign: 'center', + }, + versionBox: { + flexDirection: 'row', + alignItems: 'stretch', + width: '100%', + padding: 16, + borderRadius: 12, + borderWidth: StyleSheet.hairlineWidth * 2, + }, + versionColumn: { + flex: 1, + alignItems: 'center', + gap: 2, + }, + versionDivider: { + width: 1, + }, + versionLabel: { + fontSize: 14, + lineHeight: 20, + fontWeight: '500', + letterSpacing: isIOS ? 0 : 0.1, + textAlign: 'center', + }, + versionValue: { + fontSize: 16, + lineHeight: 24, + fontWeight: '600', + letterSpacing: isIOS ? 0 : 0.15, + textAlign: 'center', + }, + actions: { + alignSelf: 'center', + width: '100%', + maxWidth: MAX_ACTIONS_WIDTH + 48, + paddingHorizontal: 24, + paddingTop: 16, + paddingBottom: isIOS ? 48 : 40, + gap: 12, + }, + primaryButton: { + height: 48, + borderRadius: 8, + alignItems: 'center', + justifyContent: 'center', + paddingHorizontal: 14, + }, + primaryButtonText: { + fontSize: 16, + lineHeight: 24, + fontWeight: '500', + letterSpacing: isIOS ? 0 : 0.15, + }, + helpButton: { + height: 32, + alignItems: 'center', + justifyContent: 'center', + }, + helpText: { + fontSize: 14, + lineHeight: 20, + fontWeight: '500', + }, + helpLink: { + fontWeight: '600', + }, +}); diff --git a/packages/expo/src/update-required/__tests__/UpdateRequiredView.test.tsx b/packages/expo/src/update-required/__tests__/UpdateRequiredView.test.tsx new file mode 100644 index 00000000000..5b0fd696309 --- /dev/null +++ b/packages/expo/src/update-required/__tests__/UpdateRequiredView.test.tsx @@ -0,0 +1,155 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import type { ReactNode } from 'react'; +import { beforeEach, describe, expect, test, vi } from 'vitest'; + +const mocks = vi.hoisted(() => ({ + platformOS: 'ios' as string, + openURL: vi.fn(), + identity: null as null | { platform: string; appIdentifier: string; version: string; displayVersion: string }, +})); + +vi.mock('react-native', async () => { + const React = await import('react'); + type Props = { children?: ReactNode; onPress?: () => void; accessibilityRole?: string; source?: { uri: string } }; + const element = + (tag: string) => + ({ children, onPress, accessibilityRole, source }: Props) => + React.createElement(tag, { onClick: onPress, role: accessibilityRole, src: source?.uri }, children); + return { + View: element('div'), + ScrollView: element('div'), + Text: element('span'), + Pressable: element('button'), + Image: element('img'), + Modal: ({ visible, children }: { visible: boolean; children?: ReactNode }) => + visible ? React.createElement('div', { 'data-testid': 'modal' }, children) : null, + StyleSheet: { create: (styles: T) => styles, hairlineWidth: 0.5 }, + Linking: { openURL: mocks.openURL }, + useColorScheme: () => 'light', + Platform: { + get OS() { + return mocks.platformOS; + }, + }, + }; +}); + +vi.mock('../../app-version/nativeAppIdentity', () => ({ + getNativeAppIdentity: () => mocks.identity, + getNativeAppPlatform: () => mocks.platformOS, + getNativeAppName: () => 'Acme Native', + isExpoApplicationInstalled: () => true, +})); + +const APP_STORE_URL = 'https://apps.apple.com/app/id123'; +const PLAY_URL = 'https://play.google.com/store/apps/details?id=com.example.app'; + +const loadModules = async () => { + const store = await import('../../app-version/appVersionSupport'); + const { UpdateRequiredView } = await import('../UpdateRequiredView'); + const { UpdateRequiredModal } = await import('../UpdateRequiredModal'); + return { store, UpdateRequiredView, UpdateRequiredModal }; +}; + +const environment = ({ updateUrl, supportEmail }: { updateUrl: string; supportEmail?: string }) => ({ + display_config: { + application_name: 'Acme', + logo_image_url: 'https://img.example.com/logo.png', + support_email: supportEmail, + }, + native_app_settings: { + minimum_supported_version: { + ios: [{ bundle_id: 'com.example.app', minimum_version: '2.0', update_url: updateUrl }], + android: [{ package_name: 'com.example.app', minimum_version: '42', update_url: updateUrl }], + }, + }, +}); + +describe('UpdateRequiredView', () => { + beforeEach(() => { + vi.resetModules(); + mocks.openURL.mockReset(); + mocks.platformOS = 'ios'; + mocks.identity = { platform: 'ios', appIdentifier: 'com.example.app', version: '1.9.9', displayVersion: '1.9.9' }; + }); + + const block = ( + store: Awaited>['store'], + options: { updateUrl: string; supportEmail?: string }, + ) => { + act(() => { + store.applyFetchedEnvironment(environment(options), store.nextAppVersionRequest()); + }); + }; + + test('tells an iOS user to update and compares their version with the minimum', async () => { + const { store, UpdateRequiredView } = await loadModules(); + render(); + block(store, { updateUrl: APP_STORE_URL, supportEmail: 'support@example.com' }); + + expect(screen.getByRole('header').textContent).toBe('Update required'); + expect(screen.getByText('This version of Acme is no longer supported. Please update to continue.')).toBeDefined(); + expect(screen.getByText('Your version')).toBeDefined(); + expect(screen.getByText('1.9.9')).toBeDefined(); + expect(screen.getByText('2.0+')).toBeDefined(); + expect(document.querySelector('img')?.getAttribute('src')).toBe('https://img.example.com/logo.png'); + }); + + test('opens the store and the support email', async () => { + const { store, UpdateRequiredView } = await loadModules(); + render(); + block(store, { updateUrl: APP_STORE_URL, supportEmail: 'support@example.com' }); + + fireEvent.click(screen.getByText('Go to App Store')); + fireEvent.click(screen.getByText('Get Help')); + + expect(mocks.openURL).toHaveBeenNthCalledWith(1, APP_STORE_URL); + expect(mocks.openURL).toHaveBeenNthCalledWith(2, 'mailto:support@example.com'); + }); + + test('leaves out the help link without a support email', async () => { + const { store, UpdateRequiredView } = await loadModules(); + render(); + block(store, { updateUrl: APP_STORE_URL }); + + expect(screen.queryByText('Get Help')).toBeNull(); + }); + + test('shows an Android user only their version name', async () => { + mocks.platformOS = 'android'; + mocks.identity = { platform: 'android', appIdentifier: 'com.example.app', version: '41', displayVersion: '4.8.4' }; + const { store, UpdateRequiredView } = await loadModules(); + render(); + block(store, { updateUrl: PLAY_URL, supportEmail: 'support@example.com' }); + + expect(screen.getByText('4.8.4')).toBeDefined(); + expect(screen.queryByText('Required')).toBeNull(); + expect(screen.queryByText('41')).toBeNull(); + expect(screen.getByText('Go to Google Play')).toBeDefined(); + expect(screen.getByText('Get help')).toBeDefined(); + }); + + test('names the Android button after a store other than Google Play', async () => { + mocks.platformOS = 'android'; + mocks.identity = { platform: 'android', appIdentifier: 'com.example.app', version: '41', displayVersion: '4.8.4' }; + const { store, UpdateRequiredView } = await loadModules(); + render(); + block(store, { updateUrl: 'https://galaxystore.samsung.com/detail/com.example.app' }); + + expect(screen.getByText('Update app')).toBeDefined(); + }); + + test('the modal covers the app only while it is unsupported', async () => { + const { store, UpdateRequiredModal } = await loadModules(); + render(); + expect(screen.queryByTestId('modal')).toBeNull(); + + block(store, { updateUrl: APP_STORE_URL }); + expect(screen.getByTestId('modal')).toBeDefined(); + + act(() => { + store.applySupportedAppVersion(store.nextAppVersionRequest()); + }); + expect(screen.queryByTestId('modal')).toBeNull(); + }); +}); diff --git a/packages/expo/src/update-required/colors.ts b/packages/expo/src/update-required/colors.ts new file mode 100644 index 00000000000..cb0c2c13d38 --- /dev/null +++ b/packages/expo/src/update-required/colors.ts @@ -0,0 +1,35 @@ +import type { ColorSchemeName } from 'react-native'; + +export type UpdateRequiredColors = { + background: string; + foreground: string; + mutedForeground: string; + primary: string; + primaryForeground: string; + border: string; + versionBoxBackground: string; +}; + +const LIGHT: UpdateRequiredColors = { + background: '#FFFFFF', + foreground: '#212126', + mutedForeground: '#747686', + primary: '#2F3037', + primaryForeground: '#FFFFFF', + border: 'rgba(0, 0, 0, 0.06)', + versionBoxBackground: 'rgba(0, 0, 0, 0.02)', +}; + +const DARK: UpdateRequiredColors = { + background: '#131316', + foreground: '#FFFFFF', + mutedForeground: '#B7B8C2', + primary: '#FAFAFB', + primaryForeground: '#000000', + border: 'rgba(255, 255, 255, 0.08)', + versionBoxBackground: 'rgba(255, 255, 255, 0.03)', +}; + +export function updateRequiredColors(colorScheme: ColorSchemeName): UpdateRequiredColors { + return colorScheme === 'dark' ? DARK : LIGHT; +} diff --git a/packages/expo/src/utils/native-module.ts b/packages/expo/src/utils/native-module.ts index dd0d06524da..70199b41f0d 100644 --- a/packages/expo/src/utils/native-module.ts +++ b/packages/expo/src/utils/native-module.ts @@ -1,14 +1,19 @@ import { Platform } from 'react-native'; import NativeClerkModule from '../specs/NativeClerkModule'; -import type { NativeAuthFlowModule, NativeClientSyncModule } from '../specs/NativeClerkModule.types'; +import type { + NativeAuthFlowModule, + NativeClientSyncModule, + NativeUpdateRequiredViewModule, +} from '../specs/NativeClerkModule.types'; export const isNativeSupported = Platform.OS === 'ios' || Platform.OS === 'android'; export type ClerkExpoNativeModule = { addListener?(eventName: string, listener?: (...args: unknown[]) => void): { remove: () => void }; } & NativeClientSyncModule & - Partial; + Partial & + Partial; function isClerkExpoModule(module: unknown): module is ClerkExpoNativeModule { if (!module || typeof module !== 'object') { diff --git a/packages/expo/src/utils/runtime.ts b/packages/expo/src/utils/runtime.ts index 2b81407bddf..39fb03431de 100644 --- a/packages/expo/src/utils/runtime.ts +++ b/packages/expo/src/utils/runtime.ts @@ -26,6 +26,21 @@ export function waitUntilAppIsActive(): Promise { }); } +export function isExpoGo(): boolean { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const constants = (require('expo-constants') as { default?: ExpoGoConstants }).default; + return constants?.executionEnvironment === 'storeClient' || constants?.appOwnership === 'expo'; + } catch { + return false; + } +} + +type ExpoGoConstants = { + appOwnership?: string | null; + executionEnvironment?: string; +}; + export function isHermes() { //@ts-expect-error HermesInternal is added only added by the Hermes JS Engine return !!global.HermesInternal; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0725a9079d6..2eea32a1522 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -689,6 +689,9 @@ importers: expo-apple-authentication: specifier: ^8.0.8 version: 8.0.8(expo@54.0.36(@babel/core@7.29.7)(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7)(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10))(react-native@0.86.0(@babel/core@7.29.7)(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(utf-8-validate@5.0.10)) + expo-application: + specifier: ^7.0.8 + version: 7.0.8(expo@54.0.36(@babel/core@7.29.7)(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7)(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(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)(bufferutil@4.1.0)(graphql@16.14.1)(react-native@0.86.0(@babel/core@7.29.7)(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)(typescript@5.9.3)(utf-8-validate@5.0.10))(react-native@0.86.0(@babel/core@7.29.7)(@react-native-community/cli@12.3.7(bufferutil@4.1.0)(utf-8-validate@5.0.10))(@types/react@18.3.28)(bufferutil@4.1.0)(react@18.3.1)(utf-8-validate@5.0.10))(react@18.3.1)