expo-release 2.3.1 → 2.4.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 +117 -124
- package/action.yml +63 -12
- package/dist/args.d.ts +17 -15
- package/dist/cli.js +865 -143
- package/dist/decide.d.ts +26 -0
- package/dist/doctor.d.ts +30 -0
- package/dist/eas-json.d.ts +25 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +409 -122
- package/dist/operate.d.ts +8 -1
- package/dist/run.d.ts +42 -2
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.js +74 -14
- package/dist/runtime/ota.d.ts +23 -3
- package/dist/runtime/push.d.ts +15 -3
- package/dist/source-maps.d.ts +1 -0
- package/package.json +7 -3
- package/src/args.ts +35 -20
- package/src/cli.ts +233 -36
- package/src/decide.ts +236 -22
- package/src/doctor.ts +296 -0
- package/src/eas-json.ts +128 -0
- package/src/index.ts +2 -0
- package/src/operate.ts +97 -53
- package/src/run.ts +259 -37
- package/src/runtime/index.ts +2 -0
- package/src/runtime/ota.ts +92 -15
- package/src/runtime/push.ts +46 -13
- package/src/runtime/react-native.d.ts +10 -0
- package/src/source-maps.ts +4 -1
package/src/run.ts
CHANGED
|
@@ -1,22 +1,166 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { dirname, join } from 'node:path'
|
|
3
|
+
|
|
1
4
|
export type CommandRunner = (command: string[]) => Promise<string>
|
|
2
5
|
|
|
6
|
+
/**
|
|
7
|
+
* The app's own eas-cli (node_modules/.bin, searched up to the repository
|
|
8
|
+
* root) wins over PATH: its lockfile pins the version. `EAS_BIN` wins over both.
|
|
9
|
+
*/
|
|
3
10
|
export function easCommand(...args: string[]): string[] {
|
|
4
11
|
const bin = process.env.EAS_BIN?.trim()
|
|
5
|
-
return [bin && bin.length > 0 ? bin : 'eas', ...args]
|
|
12
|
+
return [bin && bin.length > 0 ? bin : (localBin('eas') ?? 'eas'), ...args]
|
|
6
13
|
}
|
|
7
14
|
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
15
|
+
const localBins = new Map<string, string | null>()
|
|
16
|
+
|
|
17
|
+
/** `node_modules/.bin/<name>` from the working directory up to the repository root. */
|
|
18
|
+
export function localBin(name: string): string | null {
|
|
19
|
+
const cwd = process.cwd()
|
|
20
|
+
const cacheKey = `${cwd}\0${name}`
|
|
21
|
+
const cached = localBins.get(cacheKey)
|
|
22
|
+
if (cached !== undefined) return cached
|
|
23
|
+
let bin: string | null = null
|
|
24
|
+
for (let dir = cwd; ; dir = dirname(dir)) {
|
|
25
|
+
const candidate = join(dir, 'node_modules', '.bin', name)
|
|
26
|
+
if (existsSync(candidate)) {
|
|
27
|
+
bin = candidate
|
|
28
|
+
break
|
|
29
|
+
}
|
|
30
|
+
if (existsSync(join(dir, '.git')) || dirname(dir) === dir) break
|
|
31
|
+
}
|
|
32
|
+
localBins.set(cacheKey, bin)
|
|
33
|
+
return bin
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** A failed command. `tail` holds its last non-empty output lines. */
|
|
37
|
+
export class CommandError extends Error {
|
|
38
|
+
constructor(
|
|
39
|
+
message: string,
|
|
40
|
+
readonly tail: string[]
|
|
41
|
+
) {
|
|
42
|
+
super(message)
|
|
43
|
+
this.name = 'CommandError'
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const ERROR_TAIL_LINES = 20
|
|
48
|
+
|
|
49
|
+
function lastLines(output: string, count = ERROR_TAIL_LINES): string[] {
|
|
50
|
+
return output
|
|
51
|
+
.split('\n')
|
|
52
|
+
.map((line) => line.trimEnd())
|
|
53
|
+
.filter((line) => line.trim().length > 0)
|
|
54
|
+
.slice(-count)
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// After the command exits, a background process it started can keep the
|
|
58
|
+
// pipes open; stop reading after this long instead of waiting for it.
|
|
59
|
+
const DRAIN_AFTER_EXIT_MS = 1000
|
|
60
|
+
|
|
61
|
+
function collect(
|
|
62
|
+
stream: ReadableStream<Uint8Array>,
|
|
63
|
+
echo: NodeJS.WriteStream | null,
|
|
64
|
+
sink: string[]
|
|
65
|
+
) {
|
|
66
|
+
const reader = stream.getReader()
|
|
67
|
+
const decoder = new TextDecoder()
|
|
68
|
+
let text = ''
|
|
69
|
+
const done = (async () => {
|
|
70
|
+
try {
|
|
71
|
+
for (;;) {
|
|
72
|
+
const { value, done } = await reader.read()
|
|
73
|
+
if (done) break
|
|
74
|
+
echo?.write(value)
|
|
75
|
+
const part = decoder.decode(value, { stream: true })
|
|
76
|
+
text += part
|
|
77
|
+
sink.push(part)
|
|
78
|
+
}
|
|
79
|
+
} catch {}
|
|
80
|
+
const rest = decoder.decode()
|
|
81
|
+
sink.push(rest)
|
|
82
|
+
text += rest
|
|
83
|
+
})()
|
|
84
|
+
return {
|
|
85
|
+
done,
|
|
86
|
+
stop: () => reader.cancel().catch(() => undefined),
|
|
87
|
+
text: () => text
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
async function spawn(
|
|
92
|
+
command: string[],
|
|
93
|
+
echoStdout: boolean,
|
|
94
|
+
echoStderr = true
|
|
95
|
+
) {
|
|
96
|
+
const child = Bun.spawn(command, { stdout: 'pipe', stderr: 'pipe' })
|
|
97
|
+
// Both streams in arrival order, for the failure tail.
|
|
98
|
+
const combined: string[] = []
|
|
99
|
+
const out = collect(
|
|
100
|
+
child.stdout,
|
|
101
|
+
echoStdout ? process.stdout : null,
|
|
102
|
+
combined
|
|
103
|
+
)
|
|
104
|
+
// stderr reaches the log: EAS prints its warnings and progress there.
|
|
105
|
+
const err = collect(
|
|
106
|
+
child.stderr,
|
|
107
|
+
echoStderr ? process.stderr : null,
|
|
108
|
+
combined
|
|
109
|
+
)
|
|
110
|
+
const exitCode = await child.exited
|
|
111
|
+
const drained = Promise.all([out.done, err.done])
|
|
112
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
113
|
+
const timedOut = await Promise.race([
|
|
114
|
+
drained.then(() => false),
|
|
115
|
+
new Promise<boolean>((resolve) => {
|
|
116
|
+
timer = setTimeout(() => resolve(true), DRAIN_AFTER_EXIT_MS)
|
|
117
|
+
})
|
|
17
118
|
])
|
|
119
|
+
clearTimeout(timer)
|
|
120
|
+
if (timedOut) {
|
|
121
|
+
await Promise.all([out.stop(), err.stop()])
|
|
122
|
+
await drained
|
|
123
|
+
}
|
|
124
|
+
return {
|
|
125
|
+
stdout: out.text(),
|
|
126
|
+
stderr: err.text(),
|
|
127
|
+
exitCode,
|
|
128
|
+
tail: lastLines(combined.join(''))
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Runs a command for its stdout. stderr streams to the log. */
|
|
133
|
+
export async function runCaptured(command: string[]): Promise<string> {
|
|
134
|
+
const { stdout, stderr, exitCode, tail } = await spawn(command, false)
|
|
18
135
|
if (exitCode !== 0) {
|
|
19
|
-
throw new
|
|
136
|
+
throw new CommandError(
|
|
137
|
+
describeFailure(command, exitCode, { stderr, stdout }),
|
|
138
|
+
tail
|
|
139
|
+
)
|
|
140
|
+
}
|
|
141
|
+
return stdout
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Like runCaptured, but prints nothing: for checks that quote the error. */
|
|
145
|
+
export async function runSilent(command: string[]): Promise<string> {
|
|
146
|
+
const { stdout, stderr, exitCode, tail } = await spawn(command, false, false)
|
|
147
|
+
if (exitCode !== 0) {
|
|
148
|
+
throw new CommandError(
|
|
149
|
+
describeFailure(command, exitCode, { stderr, stdout }),
|
|
150
|
+
tail
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
return stdout
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Runs a command with its output in the log. Resolves its stdout. */
|
|
157
|
+
export async function runInherited(command: string[]): Promise<string> {
|
|
158
|
+
const { stdout, exitCode, tail } = await spawn(command, true)
|
|
159
|
+
if (exitCode !== 0) {
|
|
160
|
+
throw new CommandError(
|
|
161
|
+
`Command failed with exit code ${exitCode}: ${quoteCommand(command)} (output above)`,
|
|
162
|
+
tail
|
|
163
|
+
)
|
|
20
164
|
}
|
|
21
165
|
return stdout
|
|
22
166
|
}
|
|
@@ -50,23 +194,37 @@ export function describeFailure(
|
|
|
50
194
|
return sections.join('\n')
|
|
51
195
|
}
|
|
52
196
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
197
|
+
/**
|
|
198
|
+
* Parses the last JSON document in `output`. `preamble` is whatever came
|
|
199
|
+
* before it: eas-cli prints warnings to stdout before it switches to JSON-only
|
|
200
|
+
* output, for example when `npx expo config` failed and it fell back.
|
|
201
|
+
*/
|
|
202
|
+
export function parseJsonDocument(output: string): {
|
|
203
|
+
value: unknown
|
|
204
|
+
preamble: string
|
|
205
|
+
} {
|
|
206
|
+
// A document starts at the beginning of the output or of a line; pretty
|
|
207
|
+
// printed JSON indents everything after its first line.
|
|
208
|
+
const starts = [0]
|
|
209
|
+
for (const match of output.matchAll(/\n(?=[[{])/g)) {
|
|
210
|
+
starts.push(match.index + 1)
|
|
60
211
|
}
|
|
61
|
-
|
|
212
|
+
for (const start of starts) {
|
|
213
|
+
try {
|
|
214
|
+
return {
|
|
215
|
+
value: JSON.parse(output.slice(start)) as unknown,
|
|
216
|
+
preamble: output.slice(0, start).trim()
|
|
217
|
+
}
|
|
218
|
+
} catch {}
|
|
219
|
+
}
|
|
220
|
+
throw new Error(`Expected JSON output, received:\n${tail(output)}`)
|
|
62
221
|
}
|
|
63
222
|
|
|
223
|
+
/** Parses JSON output, logging any text printed before it. */
|
|
64
224
|
export function parseJson(output: string): unknown {
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
throw new Error(`Expected JSON output, received:\n${output}`)
|
|
69
|
-
}
|
|
225
|
+
const { value, preamble } = parseJsonDocument(output)
|
|
226
|
+
if (preamble) process.stderr.write(`${preamble}\n`)
|
|
227
|
+
return value
|
|
70
228
|
}
|
|
71
229
|
|
|
72
230
|
export function getRecord(
|
|
@@ -80,7 +238,7 @@ export function getRecord(
|
|
|
80
238
|
}
|
|
81
239
|
|
|
82
240
|
export function parseFingerprintHash(output: string): string {
|
|
83
|
-
const record = getRecord(
|
|
241
|
+
const record = getRecord(parseJsonDocument(output).value, 'EAS fingerprint')
|
|
84
242
|
if (typeof record.hash !== 'string' || record.hash.length === 0) {
|
|
85
243
|
throw new Error('EAS fingerprint output did not contain a full hash')
|
|
86
244
|
}
|
|
@@ -99,21 +257,85 @@ export function dryRunCommand(command: string[]): void {
|
|
|
99
257
|
process.stderr.write(`[dry-run] ${quoteCommand(command)}\n`)
|
|
100
258
|
}
|
|
101
259
|
|
|
260
|
+
export type Stage =
|
|
261
|
+
| 'doctor'
|
|
262
|
+
| 'diff'
|
|
263
|
+
| 'fingerprint'
|
|
264
|
+
| 'build list'
|
|
265
|
+
| 'errored build'
|
|
266
|
+
| 'fingerprint diff'
|
|
267
|
+
| 'native build'
|
|
268
|
+
| 'ota export'
|
|
269
|
+
| 'ota update'
|
|
270
|
+
| 'pre-update'
|
|
271
|
+
| 'post-update'
|
|
272
|
+
| 'recovery'
|
|
273
|
+
|
|
274
|
+
/** A failed release step: what ran, and the last lines it printed. */
|
|
275
|
+
export class StageError extends Error {
|
|
276
|
+
constructor(
|
|
277
|
+
readonly stage: Stage,
|
|
278
|
+
message: string,
|
|
279
|
+
readonly tail: string[] = []
|
|
280
|
+
) {
|
|
281
|
+
super(message)
|
|
282
|
+
this.name = 'StageError'
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** Runs one release step, logs how long it took, and names it on failure. */
|
|
287
|
+
export async function stage<T>(name: Stage, run: () => Promise<T>): Promise<T> {
|
|
288
|
+
const started = performance.now()
|
|
289
|
+
try {
|
|
290
|
+
return await run()
|
|
291
|
+
} catch (error) {
|
|
292
|
+
if (error instanceof StageError) throw error
|
|
293
|
+
throw new StageError(
|
|
294
|
+
name,
|
|
295
|
+
error instanceof Error ? error.message : String(error),
|
|
296
|
+
error instanceof CommandError ? error.tail : []
|
|
297
|
+
)
|
|
298
|
+
} finally {
|
|
299
|
+
const seconds = ((performance.now() - started) / 1000).toFixed(1)
|
|
300
|
+
process.stderr.write(`[expo-release] ${name}: ${seconds}s\n`)
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* The fingerprint fails when eas-cli printed anything before its JSON: that
|
|
306
|
+
* means the app config did not load and eas-cli fell back to its bundled
|
|
307
|
+
* `@expo/config`, whose fingerprint may not be the one EAS builds with.
|
|
308
|
+
* `explainConfig` runs `expo config` to surface the real error.
|
|
309
|
+
*/
|
|
102
310
|
export async function generateFingerprint(
|
|
103
311
|
input: { platform: string; profile: string },
|
|
104
|
-
commandRunner: CommandRunner
|
|
312
|
+
commandRunner: CommandRunner,
|
|
313
|
+
explainConfig?: () => Promise<string>
|
|
105
314
|
): Promise<string> {
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
'--non-interactive'
|
|
116
|
-
)
|
|
315
|
+
const output = await commandRunner(
|
|
316
|
+
easCommand(
|
|
317
|
+
'fingerprint:generate',
|
|
318
|
+
'--platform',
|
|
319
|
+
input.platform,
|
|
320
|
+
'--build-profile',
|
|
321
|
+
input.profile,
|
|
322
|
+
'--json',
|
|
323
|
+
'--non-interactive'
|
|
117
324
|
)
|
|
118
325
|
)
|
|
326
|
+
const { preamble } = parseJsonDocument(output)
|
|
327
|
+
if (preamble) {
|
|
328
|
+
const reason = explainConfig ? await explainConfig() : ''
|
|
329
|
+
throw new CommandError(
|
|
330
|
+
[
|
|
331
|
+
'eas fingerprint:generate could not load the app config and fell back to a default one, so its fingerprint may not match what EAS builds.',
|
|
332
|
+
`eas-cli printed:\n${preamble}`,
|
|
333
|
+
reason && `expo config said:\n${reason}`
|
|
334
|
+
]
|
|
335
|
+
.filter(Boolean)
|
|
336
|
+
.join('\n'),
|
|
337
|
+
lastLines(reason || preamble)
|
|
338
|
+
)
|
|
339
|
+
}
|
|
340
|
+
return parseFingerprintHash(output)
|
|
119
341
|
}
|
package/src/runtime/index.ts
CHANGED
package/src/runtime/ota.ts
CHANGED
|
@@ -1,11 +1,44 @@
|
|
|
1
1
|
import * as Updates from 'expo-updates'
|
|
2
2
|
import { useEffect, useRef } from 'react'
|
|
3
|
+
import { AppState } from 'react-native'
|
|
3
4
|
|
|
4
5
|
declare const __DEV__: boolean | undefined
|
|
5
6
|
|
|
6
7
|
/** Restarts the app on the downloaded update. */
|
|
7
8
|
export type ReloadApp = () => Promise<void>
|
|
8
9
|
|
|
10
|
+
/** Which release is running, for crash reports and a version screen. */
|
|
11
|
+
export interface ReleaseInfo {
|
|
12
|
+
/** The running EAS update; null for the build's embedded bundle and in Expo Go. */
|
|
13
|
+
updateId: string | null
|
|
14
|
+
runtimeVersion: string | null
|
|
15
|
+
channel: string | null
|
|
16
|
+
/** When the running update (or the embedded bundle) was published. */
|
|
17
|
+
createdAt: Date | null
|
|
18
|
+
isEmbeddedLaunch: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** The running release, read from expo-updates. Safe in Expo Go and development. */
|
|
22
|
+
export function getReleaseInfo(): ReleaseInfo {
|
|
23
|
+
return {
|
|
24
|
+
updateId: Updates.updateId ?? null,
|
|
25
|
+
runtimeVersion: Updates.runtimeVersion ?? null,
|
|
26
|
+
channel: Updates.channel ?? null,
|
|
27
|
+
createdAt: Updates.createdAt ?? null,
|
|
28
|
+
isEmbeddedLaunch: Updates.isEmbeddedLaunch === true
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Downloads an available update; resolves its id (or '') when it is new. */
|
|
33
|
+
async function downloadUpdate(): Promise<string | null> {
|
|
34
|
+
const update = await Updates.checkForUpdateAsync()
|
|
35
|
+
if (!update.isAvailable) return null
|
|
36
|
+
const result = await Updates.fetchUpdateAsync()
|
|
37
|
+
if (!result.isNew) return null
|
|
38
|
+
const id = (result.manifest as { id?: unknown } | undefined)?.id
|
|
39
|
+
return typeof id === 'string' ? id : ''
|
|
40
|
+
}
|
|
41
|
+
|
|
9
42
|
/**
|
|
10
43
|
* Checks for an EAS update and downloads it. Calls `onUpdateReady` once a new
|
|
11
44
|
* update is on disk; show a "Restart" prompt there and call `reload` to apply.
|
|
@@ -14,10 +47,7 @@ export type ReloadApp = () => Promise<void>
|
|
|
14
47
|
export async function checkForOTAUpdate(
|
|
15
48
|
onUpdateReady: (reload: ReloadApp) => void
|
|
16
49
|
): Promise<boolean> {
|
|
17
|
-
|
|
18
|
-
if (!update.isAvailable) return false
|
|
19
|
-
const result = await Updates.fetchUpdateAsync()
|
|
20
|
-
if (!result.isNew) return false
|
|
50
|
+
if ((await downloadUpdate()) === null) return false
|
|
21
51
|
onUpdateReady(() => Updates.reloadAsync())
|
|
22
52
|
return true
|
|
23
53
|
}
|
|
@@ -25,30 +55,77 @@ export async function checkForOTAUpdate(
|
|
|
25
55
|
export interface UseOTAUpdatesOptions {
|
|
26
56
|
/** Defaults to off in development and wherever expo-updates is disabled (Expo Go). */
|
|
27
57
|
enabled?: boolean
|
|
28
|
-
/**
|
|
58
|
+
/**
|
|
59
|
+
* Check again when the app returns to the foreground, at most once per this
|
|
60
|
+
* many minutes. Off by default: an app kept open for days then gets a fix
|
|
61
|
+
* only after the OS kills it.
|
|
62
|
+
*/
|
|
63
|
+
recheckOnForeground?: number
|
|
64
|
+
/** A failed check is retried on the next launch or foreground. Defaults to console.warn. */
|
|
29
65
|
onError?: (error: unknown) => void
|
|
30
66
|
}
|
|
31
67
|
|
|
32
68
|
/**
|
|
33
|
-
* Checks for an update once per mount
|
|
34
|
-
*
|
|
69
|
+
* Checks for an update once per mount, and on foreground with
|
|
70
|
+
* `recheckOnForeground`. `onUpdateReady` runs once per downloaded update. The
|
|
71
|
+
* callbacks are read through refs, so an unstable toast function never re-runs
|
|
72
|
+
* the check or stacks prompts.
|
|
35
73
|
*/
|
|
36
74
|
export function useOTAUpdates(
|
|
37
75
|
onUpdateReady: (reload: ReloadApp) => void,
|
|
38
76
|
options: UseOTAUpdatesOptions = {}
|
|
39
77
|
): void {
|
|
40
78
|
const {
|
|
41
|
-
enabled = Updates.isEnabled && !(typeof __DEV__ !== 'undefined' && __DEV__)
|
|
79
|
+
enabled = Updates.isEnabled && !(typeof __DEV__ !== 'undefined' && __DEV__),
|
|
80
|
+
recheckOnForeground
|
|
42
81
|
} = options
|
|
43
82
|
const callbacks = useRef({ onUpdateReady, onError: options.onError })
|
|
44
83
|
callbacks.current = { onUpdateReady, onError: options.onError }
|
|
45
|
-
const
|
|
84
|
+
const state = useRef({
|
|
85
|
+
checked: false,
|
|
86
|
+
running: false,
|
|
87
|
+
lastCheck: 0,
|
|
88
|
+
readyId: null as string | null
|
|
89
|
+
})
|
|
46
90
|
|
|
47
91
|
useEffect(() => {
|
|
48
|
-
if (!enabled
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
92
|
+
if (!enabled) return
|
|
93
|
+
const check = () => {
|
|
94
|
+
const current = state.current
|
|
95
|
+
if (current.running) return
|
|
96
|
+
current.running = true
|
|
97
|
+
current.lastCheck = Date.now()
|
|
98
|
+
downloadUpdate()
|
|
99
|
+
.then((id) => {
|
|
100
|
+
// '' (no manifest id) always counts as a new update.
|
|
101
|
+
if (id === null || (id !== '' && id === current.readyId)) return
|
|
102
|
+
current.readyId = id
|
|
103
|
+
callbacks.current.onUpdateReady(() => Updates.reloadAsync())
|
|
104
|
+
})
|
|
105
|
+
.catch((error: unknown) =>
|
|
106
|
+
(
|
|
107
|
+
callbacks.current.onError ??
|
|
108
|
+
((e: unknown) => console.warn('OTA update check failed', e))
|
|
109
|
+
)(error)
|
|
110
|
+
)
|
|
111
|
+
.finally(() => {
|
|
112
|
+
current.running = false
|
|
113
|
+
})
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (!state.current.checked) {
|
|
117
|
+
state.current.checked = true
|
|
118
|
+
check()
|
|
119
|
+
}
|
|
120
|
+
if (recheckOnForeground === undefined) return
|
|
121
|
+
const subscription = AppState.addEventListener('change', (next) => {
|
|
122
|
+
if (
|
|
123
|
+
next === 'active' &&
|
|
124
|
+
Date.now() - state.current.lastCheck >= recheckOnForeground * 60_000
|
|
125
|
+
) {
|
|
126
|
+
check()
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
return () => subscription.remove()
|
|
130
|
+
}, [enabled, recheckOnForeground])
|
|
54
131
|
}
|
package/src/runtime/push.ts
CHANGED
|
@@ -23,7 +23,10 @@ export interface PushNotificationsOptions {
|
|
|
23
23
|
onError?: (message: string, error: unknown) => void
|
|
24
24
|
/** Defaults to what expo-notifications reads from the app config. */
|
|
25
25
|
projectId?: string
|
|
26
|
-
/**
|
|
26
|
+
/**
|
|
27
|
+
* Android channel `default`, needed for heads-up notifications. Call
|
|
28
|
+
* `setAndroidChannel` again when its texts depend on the locale.
|
|
29
|
+
*/
|
|
27
30
|
androidChannel?: Notifications.NotificationChannelInput
|
|
28
31
|
/** How notifications show while the app is in the foreground. */
|
|
29
32
|
foregroundBehavior?: Notifications.NotificationBehavior
|
|
@@ -34,7 +37,10 @@ export interface PushNotificationsOptions {
|
|
|
34
37
|
|
|
35
38
|
export interface NotificationListeners {
|
|
36
39
|
onReceived?: (notification: Notifications.Notification) => void
|
|
37
|
-
/**
|
|
40
|
+
/**
|
|
41
|
+
* Also called with the response that launched the app, if any. Each
|
|
42
|
+
* response is delivered once per app start, however often `listen` runs.
|
|
43
|
+
*/
|
|
38
44
|
onResponse?: (response: Notifications.NotificationResponse) => void
|
|
39
45
|
}
|
|
40
46
|
|
|
@@ -66,18 +72,29 @@ export function createPushNotifications(options: PushNotificationsOptions) {
|
|
|
66
72
|
}
|
|
67
73
|
})
|
|
68
74
|
// Channels exist only on Android; elsewhere this resolves to null.
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
options.androidChannel ?? {
|
|
75
|
+
function setAndroidChannel(
|
|
76
|
+
channel: Notifications.NotificationChannelInput = {
|
|
72
77
|
name: 'Default',
|
|
73
78
|
importance: Notifications.AndroidImportance.HIGH,
|
|
74
79
|
vibrationPattern: [0, 250, 250, 250]
|
|
75
80
|
}
|
|
76
|
-
)
|
|
77
|
-
|
|
78
|
-
|
|
81
|
+
) {
|
|
82
|
+
return Notifications.setNotificationChannelAsync('default', channel)
|
|
83
|
+
.then(() => undefined)
|
|
84
|
+
.catch((error: unknown) =>
|
|
85
|
+
onError('Failed to create the notification channel', error)
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
void setAndroidChannel(options.androidChannel)
|
|
79
89
|
|
|
80
90
|
let pendingPrompt: Promise<boolean> | null = null
|
|
91
|
+
// Responses already handed to onResponse. Every getLastNotificationResponse
|
|
92
|
+
// call returns the last one again, so a replay is skipped once delivered;
|
|
93
|
+
// live taps always go through. A repeating notification keeps its
|
|
94
|
+
// identifier, so the date is part of the key.
|
|
95
|
+
const deliveredResponses = new Set<string>()
|
|
96
|
+
const responseKey = (response: Notifications.NotificationResponse) =>
|
|
97
|
+
`${response.notification?.request?.identifier}:${response.notification?.date}:${response.actionIdentifier}`
|
|
81
98
|
|
|
82
99
|
const getPermissionStatus = () => Notifications.getPermissionsAsync()
|
|
83
100
|
|
|
@@ -181,6 +198,8 @@ export function createPushNotifications(options: PushNotificationsOptions) {
|
|
|
181
198
|
requestPermissions,
|
|
182
199
|
getPushToken,
|
|
183
200
|
syncToken,
|
|
201
|
+
/** Recreates the Android `default` channel, for example after a locale change. */
|
|
202
|
+
setAndroidChannel,
|
|
184
203
|
|
|
185
204
|
/**
|
|
186
205
|
* Asks for push once per install (after sign-in, say), then registers the
|
|
@@ -205,7 +224,11 @@ export function createPushNotifications(options: PushNotificationsOptions) {
|
|
|
205
224
|
return true
|
|
206
225
|
},
|
|
207
226
|
|
|
208
|
-
/**
|
|
227
|
+
/**
|
|
228
|
+
* Removes this device's token from your backend, and stops `listen` from
|
|
229
|
+
* re-registering it. Rejects when `registerDevice(null)` does, so a
|
|
230
|
+
* settings screen can show the failure; catch it on sign-out.
|
|
231
|
+
*/
|
|
209
232
|
async disable() {
|
|
210
233
|
await registerDevice(null)
|
|
211
234
|
// KeyValueStorage has no removeItem; an empty token means none was sent.
|
|
@@ -222,16 +245,26 @@ export function createPushNotifications(options: PushNotificationsOptions) {
|
|
|
222
245
|
onError('Failed to refresh the push token', error)
|
|
223
246
|
)
|
|
224
247
|
void refresh()
|
|
248
|
+
const deliver =
|
|
249
|
+
onResponse &&
|
|
250
|
+
((response: Notifications.NotificationResponse) => {
|
|
251
|
+
deliveredResponses.add(responseKey(response))
|
|
252
|
+
onResponse(response)
|
|
253
|
+
})
|
|
225
254
|
const subscriptions = [
|
|
226
255
|
Notifications.addPushTokenListener(refresh),
|
|
227
256
|
onReceived && Notifications.addNotificationReceivedListener(onReceived),
|
|
228
|
-
|
|
229
|
-
Notifications.addNotificationResponseReceivedListener(
|
|
257
|
+
deliver &&
|
|
258
|
+
Notifications.addNotificationResponseReceivedListener(deliver)
|
|
230
259
|
]
|
|
231
|
-
const launchResponse =
|
|
260
|
+
const launchResponse = deliver
|
|
232
261
|
? Notifications.getLastNotificationResponse()
|
|
233
262
|
: null
|
|
234
|
-
if (
|
|
263
|
+
if (
|
|
264
|
+
launchResponse &&
|
|
265
|
+
!deliveredResponses.has(responseKey(launchResponse))
|
|
266
|
+
)
|
|
267
|
+
deliver?.(launchResponse)
|
|
235
268
|
return () => {
|
|
236
269
|
for (const subscription of subscriptions) subscription?.remove()
|
|
237
270
|
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// The one react-native API the runtime uses. react-native is an optional peer
|
|
2
|
+
// that every Expo app has; this keeps it out of the devDependencies.
|
|
3
|
+
declare module 'react-native' {
|
|
4
|
+
export const AppState: {
|
|
5
|
+
addEventListener(
|
|
6
|
+
type: 'change',
|
|
7
|
+
listener: (state: 'active' | 'background' | 'inactive' | string) => void
|
|
8
|
+
): { remove(): void }
|
|
9
|
+
}
|
|
10
|
+
}
|
package/src/source-maps.ts
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { exists } from 'node:fs/promises'
|
|
2
2
|
import type { ReleasePlatform } from './args'
|
|
3
|
+
import { localBin } from './run'
|
|
3
4
|
|
|
4
5
|
export const SOURCE_MAP_OUTPUT_DIR = 'dist'
|
|
5
6
|
|
|
7
|
+
/** `EXPO_BIN`, else the app's own expo CLI, else `bunx expo`. */
|
|
6
8
|
export function expoCommand(...args: string[]): string[] {
|
|
7
9
|
const bin = process.env.EXPO_BIN?.trim()
|
|
8
10
|
if (bin && bin.length > 0) return [bin, ...args]
|
|
9
|
-
|
|
11
|
+
const local = localBin('expo')
|
|
12
|
+
return local ? [local, ...args] : ['bunx', 'expo', ...args]
|
|
10
13
|
}
|
|
11
14
|
|
|
12
15
|
export function sourceMapExportCommand(platform: ReleasePlatform): string[] {
|