expo-release 2.0.0 → 2.2.1
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 +269 -11
- package/action.yml +11 -0
- package/dist/args.d.ts +49 -9
- package/dist/cli.js +178 -171
- package/dist/index.js +132 -134
- 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 +45 -8
- package/src/args.ts +129 -250
- package/src/cli.ts +78 -41
- 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
package/src/cli.ts
CHANGED
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
|
|
3
|
+
import { type CommandContext, defineCli, defineCommand } from 'hanzio/cli'
|
|
3
4
|
import {
|
|
4
|
-
type
|
|
5
|
-
USAGE,
|
|
5
|
+
type CommandName,
|
|
6
6
|
needsDecide,
|
|
7
|
-
|
|
8
|
-
|
|
7
|
+
type ReleaseFlags,
|
|
8
|
+
type ReleaseOptions,
|
|
9
|
+
releaseFlags,
|
|
10
|
+
shouldIgnoreExpoImpact,
|
|
11
|
+
toReleaseOptions
|
|
9
12
|
} from './args'
|
|
10
13
|
import {
|
|
11
14
|
type DecisionInput,
|
|
12
|
-
type ReleaseDecision,
|
|
13
15
|
decideRelease,
|
|
16
|
+
type ReleaseDecision,
|
|
14
17
|
writeDecideGitHubOutput
|
|
15
18
|
} from './decide'
|
|
16
19
|
import {
|
|
@@ -49,49 +52,83 @@ function toOperationInput(
|
|
|
49
52
|
preUpdate: options.preUpdate,
|
|
50
53
|
postUpdate: options.postUpdate,
|
|
51
54
|
updateExtraArgs: options.updateExtraArgs,
|
|
55
|
+
exportSourceMaps: options.exportSourceMaps,
|
|
52
56
|
dryRun: options.dryRun
|
|
53
57
|
}
|
|
54
58
|
}
|
|
55
59
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
}
|
|
60
|
+
type Context = CommandContext<ReleaseFlags>
|
|
61
|
+
|
|
62
|
+
async function decide(options: ReleaseOptions, ctx: Context) {
|
|
63
|
+
const decision = await decideRelease(toDecisionInput(options))
|
|
64
|
+
await writeDecideGitHubOutput(decision)
|
|
65
|
+
ctx.stdout.write(`${JSON.stringify(decision)}\n`)
|
|
66
|
+
return decision
|
|
67
|
+
}
|
|
62
68
|
|
|
63
|
-
|
|
69
|
+
async function operate(
|
|
70
|
+
options: ReleaseOptions,
|
|
71
|
+
decision: ReleaseDecision | null,
|
|
72
|
+
ctx: Context
|
|
73
|
+
) {
|
|
74
|
+
const result = await operateRelease(toOperationInput(options, decision))
|
|
75
|
+
await writeOperateGitHubOutput(result)
|
|
76
|
+
ctx.stdout.write(`${result}\n`)
|
|
77
|
+
}
|
|
64
78
|
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
await
|
|
83
|
-
|
|
79
|
+
const COMMANDS: CommandName[] = ['release', 'decide', 'operate']
|
|
80
|
+
|
|
81
|
+
const cli = defineCli({
|
|
82
|
+
name: 'expo-release',
|
|
83
|
+
description:
|
|
84
|
+
'Fingerprint-driven Expo EAS release: OTA update or native store build',
|
|
85
|
+
// All three commands take the same flags, before or after the command name.
|
|
86
|
+
globals: releaseFlags,
|
|
87
|
+
context: (flags) => flags,
|
|
88
|
+
commands: {
|
|
89
|
+
release: defineCommand({
|
|
90
|
+
description: 'Run decide, then operate on its decision',
|
|
91
|
+
run: async (_input, ctx: Context) => {
|
|
92
|
+
const options = toReleaseOptions('release', ctx)
|
|
93
|
+
const decision = needsDecide(options.operation)
|
|
94
|
+
? await decide(options, ctx)
|
|
95
|
+
: null
|
|
96
|
+
await operate(options, decision, ctx)
|
|
97
|
+
}
|
|
98
|
+
}),
|
|
99
|
+
decide: defineCommand({
|
|
100
|
+
description:
|
|
101
|
+
'Print whether this change needs no deploy, an OTA update or a native build',
|
|
102
|
+
run: async (_input, ctx: Context) => {
|
|
103
|
+
await decide(toReleaseOptions('decide', ctx), ctx)
|
|
84
104
|
}
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
throw new Error(`Unsupported command: ${_exhaustive}`)
|
|
93
|
-
}
|
|
105
|
+
}),
|
|
106
|
+
operate: defineCommand({
|
|
107
|
+
description: 'Run --operation for --deploy-type',
|
|
108
|
+
run: async (_input, ctx: Context) => {
|
|
109
|
+
await operate(toReleaseOptions('operate', ctx), null, ctx)
|
|
110
|
+
}
|
|
111
|
+
})
|
|
94
112
|
}
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
// GitHub Actions passes an unset input as an empty string; treat it as unset.
|
|
116
|
+
const env = Object.fromEntries(
|
|
117
|
+
Object.entries(process.env).flatMap(([name, value]) =>
|
|
118
|
+
value?.trim() ? [[name, value.trim()]] : []
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
let argv = process.argv.slice(2)
|
|
123
|
+
if (!argv.some((arg) => COMMANDS.includes(arg as CommandName))) {
|
|
124
|
+
// The composite action names the command in EXPO_RELEASE_COMMAND and passes no argv.
|
|
125
|
+
argv = env.EXPO_RELEASE_COMMAND
|
|
126
|
+
? [env.EXPO_RELEASE_COMMAND, ...argv]
|
|
127
|
+
: argv.length === 0
|
|
128
|
+
? ['--help']
|
|
129
|
+
: argv
|
|
95
130
|
}
|
|
96
131
|
|
|
97
|
-
await
|
|
132
|
+
const code = await cli.run(argv, { env })
|
|
133
|
+
// Bad arguments exit 1, as before hanzio/cli, which uses 2.
|
|
134
|
+
process.exitCode = code === 2 ? 1 : code
|
package/src/operate.ts
CHANGED
|
@@ -7,6 +7,11 @@ import {
|
|
|
7
7
|
generateFingerprint,
|
|
8
8
|
runInherited
|
|
9
9
|
} from './run'
|
|
10
|
+
import {
|
|
11
|
+
assertSourceMaps,
|
|
12
|
+
mergeUpdateArgs,
|
|
13
|
+
sourceMapExportCommand
|
|
14
|
+
} from './source-maps'
|
|
10
15
|
|
|
11
16
|
export type { Operation }
|
|
12
17
|
|
|
@@ -25,8 +30,10 @@ export type OperationInput = {
|
|
|
25
30
|
preUpdate?: string
|
|
26
31
|
postUpdate?: string
|
|
27
32
|
updateExtraArgs?: string
|
|
33
|
+
exportSourceMaps?: boolean
|
|
28
34
|
dryRun?: boolean
|
|
29
35
|
readPackageJson?: () => Promise<unknown>
|
|
36
|
+
hasSourceMaps?: () => Promise<boolean>
|
|
30
37
|
}
|
|
31
38
|
|
|
32
39
|
export type OperationResult = 'skipped' | 'ota' | 'native-build' | 'recovery'
|
|
@@ -107,6 +114,12 @@ async function publishUpdate(
|
|
|
107
114
|
commandRunner: CommandRunner
|
|
108
115
|
) {
|
|
109
116
|
await requireEnvironmentIfNeeded(input)
|
|
117
|
+
if (input.exportSourceMaps) {
|
|
118
|
+
await commandRunner(sourceMapExportCommand(input.platform))
|
|
119
|
+
if (input.dryRun !== true) {
|
|
120
|
+
await assertSourceMaps(input.hasSourceMaps)
|
|
121
|
+
}
|
|
122
|
+
}
|
|
110
123
|
await runHook(input.preUpdate, commandRunner)
|
|
111
124
|
const command = easCommand(
|
|
112
125
|
'update',
|
|
@@ -121,7 +134,12 @@ async function publishUpdate(
|
|
|
121
134
|
if (input.environment) {
|
|
122
135
|
command.push('--environment', input.environment)
|
|
123
136
|
}
|
|
124
|
-
command.push(
|
|
137
|
+
command.push(
|
|
138
|
+
...mergeUpdateArgs(
|
|
139
|
+
extraUpdateArgs(input.updateExtraArgs),
|
|
140
|
+
input.exportSourceMaps === true
|
|
141
|
+
)
|
|
142
|
+
)
|
|
125
143
|
await commandRunner(command)
|
|
126
144
|
await runHook(input.postUpdate, commandRunner)
|
|
127
145
|
}
|
package/src/run.ts
CHANGED
|
@@ -16,18 +16,47 @@ export async function runCaptured(command: string[]): Promise<string> {
|
|
|
16
16
|
child.exited
|
|
17
17
|
])
|
|
18
18
|
if (exitCode !== 0) {
|
|
19
|
-
throw new Error(
|
|
20
|
-
`Command failed (${command.join(' ')}):\n${stderr || stdout}`
|
|
21
|
-
)
|
|
19
|
+
throw new Error(describeFailure(command, exitCode, { stderr, stdout }))
|
|
22
20
|
}
|
|
23
21
|
return stdout
|
|
24
22
|
}
|
|
25
23
|
|
|
24
|
+
const OUTPUT_TAIL_LINES = 100
|
|
25
|
+
|
|
26
|
+
function tail(output: string): string {
|
|
27
|
+
const lines = output.trimEnd().split('\n')
|
|
28
|
+
if (lines.length <= OUTPUT_TAIL_LINES) return lines.join('\n')
|
|
29
|
+
const omitted = lines.length - OUTPUT_TAIL_LINES
|
|
30
|
+
return [
|
|
31
|
+
`... (${omitted} earlier lines omitted)`,
|
|
32
|
+
...lines.slice(-OUTPUT_TAIL_LINES)
|
|
33
|
+
].join('\n')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Both streams are kept: EAS can print only an upgrade notice on stderr while
|
|
37
|
+
// the real error lands on stdout (or the reverse). The command line holds no
|
|
38
|
+
// secrets; tokens reach EAS through the environment, which is never printed.
|
|
39
|
+
export function describeFailure(
|
|
40
|
+
command: string[],
|
|
41
|
+
exitCode: number,
|
|
42
|
+
output: { stderr: string; stdout: string }
|
|
43
|
+
): string {
|
|
44
|
+
const sections = [
|
|
45
|
+
`Command failed with exit code ${exitCode}: ${quoteCommand(command)}`
|
|
46
|
+
]
|
|
47
|
+
if (output.stderr.trim()) sections.push(`stderr:\n${tail(output.stderr)}`)
|
|
48
|
+
if (output.stdout.trim()) sections.push(`stdout:\n${tail(output.stdout)}`)
|
|
49
|
+
if (sections.length === 1) sections.push('(no output)')
|
|
50
|
+
return sections.join('\n')
|
|
51
|
+
}
|
|
52
|
+
|
|
26
53
|
export async function runInherited(command: string[]): Promise<string> {
|
|
27
54
|
const child = Bun.spawn(command, { stdout: 'inherit', stderr: 'inherit' })
|
|
28
55
|
const exitCode = await child.exited
|
|
29
56
|
if (exitCode !== 0) {
|
|
30
|
-
throw new Error(
|
|
57
|
+
throw new Error(
|
|
58
|
+
`Command failed with exit code ${exitCode}: ${quoteCommand(command)} (output above)`
|
|
59
|
+
)
|
|
31
60
|
}
|
|
32
61
|
return ''
|
|
33
62
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { Component, type ErrorInfo, type ReactNode } from 'react'
|
|
2
|
+
|
|
3
|
+
export interface ErrorBoundaryFallbackProps {
|
|
4
|
+
error: Error
|
|
5
|
+
/** Clears the error and renders the children again. */
|
|
6
|
+
reset: () => void
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface ErrorBoundaryProps {
|
|
10
|
+
children?: ReactNode
|
|
11
|
+
fallback: (props: ErrorBoundaryFallbackProps) => ReactNode
|
|
12
|
+
/**
|
|
13
|
+
* Report the error here. Render errors never reach global handlers or
|
|
14
|
+
* error-tracking autocapture, so capture them explicitly.
|
|
15
|
+
*/
|
|
16
|
+
onError?: (error: Error, info: ErrorInfo) => void
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
interface ErrorBoundaryState {
|
|
20
|
+
error: Error | null
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const toError = (value: unknown) =>
|
|
24
|
+
value instanceof Error ? value : new Error(String(value))
|
|
25
|
+
|
|
26
|
+
/** Error boundary without UI: `fallback` renders the error screen. */
|
|
27
|
+
export class ErrorBoundary extends Component<
|
|
28
|
+
ErrorBoundaryProps,
|
|
29
|
+
ErrorBoundaryState
|
|
30
|
+
> {
|
|
31
|
+
override state: ErrorBoundaryState = { error: null }
|
|
32
|
+
|
|
33
|
+
static getDerivedStateFromError(error: unknown): ErrorBoundaryState {
|
|
34
|
+
return { error: toError(error) }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
override componentDidCatch(error: unknown, info: ErrorInfo) {
|
|
38
|
+
this.props.onError?.(toError(error), info)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
reset = () => {
|
|
42
|
+
this.setState({ error: null })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
override render() {
|
|
46
|
+
const { error } = this.state
|
|
47
|
+
return error
|
|
48
|
+
? this.props.fallback({ error, reset: this.reset })
|
|
49
|
+
: this.props.children
|
|
50
|
+
}
|
|
51
|
+
}
|
|
@@ -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
|
+
}
|