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.
- package/README.md +266 -10
- package/action.yml +5 -0
- package/dist/args.d.ts +2 -1
- package/dist/cli.js +82 -6
- package/dist/index.js +81 -7
- package/dist/operate.d.ts +2 -0
- package/dist/run.d.ts +4 -0
- package/dist/runtime/error-boundary.d.ts +27 -0
- package/dist/runtime/index.d.ts +3 -0
- package/dist/runtime/index.js +214 -0
- package/dist/runtime/ota.d.ts +19 -0
- package/dist/runtime/push.d.ts +70 -0
- package/dist/source-maps.d.ts +8 -0
- package/package.json +41 -8
- package/src/args.ts +11 -1
- package/src/cli.ts +8 -1
- package/src/operate.ts +19 -1
- package/src/run.ts +33 -4
- package/src/runtime/error-boundary.ts +51 -0
- package/src/runtime/index.ts +19 -0
- package/src/runtime/ota.ts +54 -0
- package/src/runtime/push.ts +243 -0
- package/src/source-maps.ts +61 -0
|
@@ -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
|
+
}
|