expo-release 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,19 @@
1
+ export {
2
+ ErrorBoundary,
3
+ type ErrorBoundaryFallbackProps,
4
+ type ErrorBoundaryProps
5
+ } from './error-boundary'
6
+ export {
7
+ checkForOTAUpdate,
8
+ type ReloadApp,
9
+ type UseOTAUpdatesOptions,
10
+ useOTAUpdates
11
+ } from './ota'
12
+ export {
13
+ createPushNotifications,
14
+ type KeyValueStorage,
15
+ type NotificationListeners,
16
+ type PushNotifications,
17
+ type PushNotificationsOptions,
18
+ usePushNotifications
19
+ } from './push'
@@ -0,0 +1,54 @@
1
+ import * as Updates from 'expo-updates'
2
+ import { useEffect, useRef } from 'react'
3
+
4
+ declare const __DEV__: boolean | undefined
5
+
6
+ /** Restarts the app on the downloaded update. */
7
+ export type ReloadApp = () => Promise<void>
8
+
9
+ /**
10
+ * Checks for an EAS update and downloads it. Calls `onUpdateReady` once a new
11
+ * update is on disk; show a "Restart" prompt there and call `reload` to apply.
12
+ * Returns whether a new update was downloaded.
13
+ */
14
+ export async function checkForOTAUpdate(
15
+ onUpdateReady: (reload: ReloadApp) => void
16
+ ): Promise<boolean> {
17
+ const update = await Updates.checkForUpdateAsync()
18
+ if (!update.isAvailable) return false
19
+ const result = await Updates.fetchUpdateAsync()
20
+ if (!result.isNew) return false
21
+ onUpdateReady(() => Updates.reloadAsync())
22
+ return true
23
+ }
24
+
25
+ export interface UseOTAUpdatesOptions {
26
+ /** Defaults to off in development and wherever expo-updates is disabled (Expo Go). */
27
+ enabled?: boolean
28
+ /** A failed check is retried on the next launch; defaults to ignoring it. */
29
+ onError?: (error: unknown) => void
30
+ }
31
+
32
+ /**
33
+ * Checks for an update once per mount. The callbacks are read through refs, so
34
+ * an unstable toast function never re-runs the check or stacks prompts.
35
+ */
36
+ export function useOTAUpdates(
37
+ onUpdateReady: (reload: ReloadApp) => void,
38
+ options: UseOTAUpdatesOptions = {}
39
+ ): void {
40
+ const {
41
+ enabled = Updates.isEnabled && !(typeof __DEV__ !== 'undefined' && __DEV__)
42
+ } = options
43
+ const callbacks = useRef({ onUpdateReady, onError: options.onError })
44
+ callbacks.current = { onUpdateReady, onError: options.onError }
45
+ const checked = useRef(false)
46
+
47
+ useEffect(() => {
48
+ if (!enabled || checked.current) return
49
+ checked.current = true
50
+ checkForOTAUpdate((reload) =>
51
+ callbacks.current.onUpdateReady(reload)
52
+ ).catch((error: unknown) => callbacks.current.onError?.(error))
53
+ }, [enabled])
54
+ }
@@ -0,0 +1,243 @@
1
+ import * as Device from 'expo-device'
2
+ import * as Notifications from 'expo-notifications'
3
+ import { useCallback, useEffect, useState } from 'react'
4
+
5
+ /** Async key-value store. AsyncStorage and expo-secure-store adapters fit. */
6
+ export interface KeyValueStorage {
7
+ getItem(key: string): Promise<string | null>
8
+ setItem(key: string, value: string): Promise<void>
9
+ }
10
+
11
+ export interface PushNotificationsOptions {
12
+ /** Remembers that the one-time prompt ran. */
13
+ storage: KeyValueStorage
14
+ /** Saves this device's Expo push token on your backend; `null` removes it. */
15
+ registerDevice: (token: string | null) => Promise<void>
16
+ /**
17
+ * Explains push before the OS dialog. Resolve `true` to continue. Without
18
+ * it, `prompt()` goes straight to the OS dialog.
19
+ */
20
+ confirm?: () => Promise<boolean>
21
+ /** Push is off: the app runs on a simulator or the user denied permission. */
22
+ onUnavailable?: (reason: 'not-a-device' | 'denied') => void
23
+ onError?: (message: string, error: unknown) => void
24
+ /** Defaults to what expo-notifications reads from the app config. */
25
+ projectId?: string
26
+ /** Android channel `default`, needed for heads-up notifications. */
27
+ androidChannel?: Notifications.NotificationChannelInput
28
+ /** How notifications show while the app is in the foreground. */
29
+ foregroundBehavior?: Notifications.NotificationBehavior
30
+ storageKey?: string
31
+ }
32
+
33
+ export interface NotificationListeners {
34
+ onReceived?: (notification: Notifications.Notification) => void
35
+ /** Also called with the response that launched the app, if any. */
36
+ onResponse?: (response: Notifications.NotificationResponse) => void
37
+ }
38
+
39
+ export type PushNotifications = ReturnType<typeof createPushNotifications>
40
+
41
+ /**
42
+ * expo-notifications behind one object: permission flow, Expo push token and
43
+ * device registration. Create it once at module level.
44
+ */
45
+ export function createPushNotifications(options: PushNotificationsOptions) {
46
+ const {
47
+ storage,
48
+ registerDevice,
49
+ confirm,
50
+ onUnavailable,
51
+ onError = (message, error) => console.error(message, error),
52
+ projectId,
53
+ storageKey = '@notifications_permission_asked'
54
+ } = options
55
+
56
+ Notifications.setNotificationHandler({
57
+ handleNotification: async () =>
58
+ options.foregroundBehavior ?? {
59
+ shouldShowBanner: true,
60
+ shouldShowList: true,
61
+ shouldPlaySound: true,
62
+ shouldSetBadge: false
63
+ }
64
+ })
65
+ // Channels exist only on Android; elsewhere this resolves to null.
66
+ Notifications.setNotificationChannelAsync(
67
+ 'default',
68
+ options.androidChannel ?? {
69
+ name: 'Default',
70
+ importance: Notifications.AndroidImportance.HIGH,
71
+ vibrationPattern: [0, 250, 250, 250]
72
+ }
73
+ ).catch((error: unknown) =>
74
+ onError('Failed to create the notification channel', error)
75
+ )
76
+
77
+ let pendingPrompt: Promise<boolean> | null = null
78
+
79
+ const getPermissionStatus = () => Notifications.getPermissionsAsync()
80
+
81
+ async function hasPrompted() {
82
+ try {
83
+ return (await storage.getItem(storageKey)) === 'true'
84
+ } catch {
85
+ return false
86
+ }
87
+ }
88
+
89
+ /** Shows the OS dialog when it still can. Resolves whether push is allowed. */
90
+ async function requestPermissions() {
91
+ if (!Device.isDevice) {
92
+ onUnavailable?.('not-a-device')
93
+ return false
94
+ }
95
+ try {
96
+ const { granted } = await Notifications.requestPermissionsAsync()
97
+ if (!granted) onUnavailable?.('denied')
98
+ return granted
99
+ } catch (error) {
100
+ onError('Failed to request notification permissions', error)
101
+ return false
102
+ }
103
+ }
104
+
105
+ async function getPushToken() {
106
+ if (!Device.isDevice) return null
107
+ try {
108
+ const token = await Notifications.getExpoPushTokenAsync(
109
+ projectId ? { projectId } : undefined
110
+ )
111
+ return token.data
112
+ } catch (error) {
113
+ onError('Failed to get the Expo push token', error)
114
+ return null
115
+ }
116
+ }
117
+
118
+ /** Registers the current token. Resolves whether one was registered. */
119
+ async function syncToken() {
120
+ if (!(await getPermissionStatus()).granted) return false
121
+ const token = await getPushToken()
122
+ if (!token) return false
123
+ try {
124
+ await registerDevice(token)
125
+ return true
126
+ } catch (error) {
127
+ onError('Failed to register the push token', error)
128
+ return false
129
+ }
130
+ }
131
+
132
+ async function runPrompt() {
133
+ if (await hasPrompted()) return false
134
+ // Mark first so a crash or a second caller never shows the dialog twice.
135
+ try {
136
+ await storage.setItem(storageKey, 'true')
137
+ } catch (error) {
138
+ onError('Failed to remember the notification prompt', error)
139
+ }
140
+ if (confirm && !(await confirm())) return false
141
+ if (!(await requestPermissions())) return false
142
+ await syncToken()
143
+ return true
144
+ }
145
+
146
+ return {
147
+ getPermissionStatus,
148
+ hasPrompted,
149
+ requestPermissions,
150
+ getPushToken,
151
+ syncToken,
152
+
153
+ /**
154
+ * Asks for push once per install (after sign-in, say), then registers the
155
+ * token. Never throws; concurrent calls share one dialog.
156
+ */
157
+ prompt(): Promise<boolean> {
158
+ pendingPrompt ??= runPrompt()
159
+ .catch((error: unknown) => {
160
+ onError('Failed to prompt for notifications', error)
161
+ return false
162
+ })
163
+ .finally(() => {
164
+ pendingPrompt = null
165
+ })
166
+ return pendingPrompt
167
+ },
168
+
169
+ /** Turns push on from settings. Resolves whether it is on. */
170
+ async enable() {
171
+ if (!(await requestPermissions())) return false
172
+ await syncToken()
173
+ return true
174
+ },
175
+
176
+ /** Removes this device's token from your backend. */
177
+ disable() {
178
+ return registerDevice(null)
179
+ },
180
+
181
+ /** Subscribes to notifications. Returns the unsubscribe function. */
182
+ listen({ onReceived, onResponse }: NotificationListeners) {
183
+ const subscriptions = [
184
+ onReceived && Notifications.addNotificationReceivedListener(onReceived),
185
+ onResponse &&
186
+ Notifications.addNotificationResponseReceivedListener(onResponse)
187
+ ]
188
+ const launchResponse = onResponse
189
+ ? Notifications.getLastNotificationResponse()
190
+ : null
191
+ if (launchResponse) onResponse?.(launchResponse)
192
+ return () => {
193
+ for (const subscription of subscriptions) subscription?.remove()
194
+ }
195
+ }
196
+ }
197
+ }
198
+
199
+ /**
200
+ * Push state for a settings screen. `enabled` is the user's saved preference;
201
+ * while it is on, the token is re-registered on mount.
202
+ */
203
+ export function usePushNotifications(
204
+ push: PushNotifications,
205
+ { enabled = false }: { enabled?: boolean } = {}
206
+ ) {
207
+ const [permission, setPermission] =
208
+ useState<Notifications.NotificationPermissionsStatus | null>(null)
209
+ const [isLoading, setIsLoading] = useState(false)
210
+
211
+ const refresh = useCallback(async () => {
212
+ setPermission(await push.getPermissionStatus())
213
+ }, [push])
214
+
215
+ useEffect(() => {
216
+ void refresh()
217
+ if (enabled) void push.syncToken()
218
+ }, [push, enabled, refresh])
219
+
220
+ const withLoading = useCallback(
221
+ <T>(action: () => Promise<T>) =>
222
+ async () => {
223
+ setIsLoading(true)
224
+ try {
225
+ return await action()
226
+ } finally {
227
+ await refresh()
228
+ setIsLoading(false)
229
+ }
230
+ },
231
+ [refresh]
232
+ )
233
+
234
+ return {
235
+ permission,
236
+ isEnabled: enabled && permission?.granted === true,
237
+ isLoading,
238
+ prompt: withLoading(push.prompt),
239
+ enable: withLoading(push.enable),
240
+ disable: withLoading(push.disable),
241
+ refresh
242
+ }
243
+ }
@@ -0,0 +1,61 @@
1
+ import { exists } from 'node:fs/promises'
2
+ import type { ReleasePlatform } from './args'
3
+
4
+ export const SOURCE_MAP_OUTPUT_DIR = 'dist'
5
+
6
+ export function expoCommand(...args: string[]): string[] {
7
+ const bin = process.env.EXPO_BIN?.trim()
8
+ if (bin && bin.length > 0) return [bin, ...args]
9
+ return ['bunx', 'expo', ...args]
10
+ }
11
+
12
+ export function sourceMapExportCommand(platform: ReleasePlatform): string[] {
13
+ return expoCommand(
14
+ 'export',
15
+ '--platform',
16
+ platform,
17
+ '--output-dir',
18
+ SOURCE_MAP_OUTPUT_DIR,
19
+ '--source-maps',
20
+ 'external'
21
+ )
22
+ }
23
+
24
+ export function sourceMapUpdateArgs(): string[] {
25
+ return ['--input-dir', SOURCE_MAP_OUTPUT_DIR, '--skip-bundler']
26
+ }
27
+
28
+ export function mergeUpdateArgs(
29
+ extra: string[] | undefined,
30
+ exportSourceMaps: boolean
31
+ ): string[] {
32
+ const args = extra ? [...extra] : []
33
+ if (!exportSourceMaps) return args
34
+ if (!args.includes('--input-dir')) {
35
+ args.push('--input-dir', SOURCE_MAP_OUTPUT_DIR)
36
+ }
37
+ if (!args.includes('--skip-bundler')) {
38
+ args.push('--skip-bundler')
39
+ }
40
+ return args
41
+ }
42
+
43
+ export async function defaultHasSourceMaps(
44
+ directory = SOURCE_MAP_OUTPUT_DIR
45
+ ): Promise<boolean> {
46
+ if (!(await exists(directory))) return false
47
+
48
+ for await (const _ of new Bun.Glob('**/*.map').scan(directory)) {
49
+ return true
50
+ }
51
+ return false
52
+ }
53
+
54
+ export async function assertSourceMaps(
55
+ hasSourceMaps: () => Promise<boolean> = defaultHasSourceMaps
56
+ ) {
57
+ if (await hasSourceMaps()) return
58
+ throw new Error(
59
+ 'Expo export did not produce Hermes source maps in dist; refusing to publish an OTA update.'
60
+ )
61
+ }