expo-release 2.2.0 → 2.3.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 +25 -5
- package/action.yml +6 -0
- package/dist/args.d.ts +48 -9
- package/dist/cli.js +101 -170
- package/dist/index.js +54 -130
- package/dist/runtime/index.d.ts +1 -1
- package/dist/runtime/index.js +65 -20
- package/dist/runtime/push.d.ts +19 -7
- package/package.json +5 -1
- package/src/args.ts +128 -259
- package/src/cli.ts +77 -47
- package/src/runtime/index.ts +1 -0
- package/src/runtime/push.ts +98 -21
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 {
|
|
@@ -54,51 +57,78 @@ function toOperationInput(
|
|
|
54
57
|
}
|
|
55
58
|
}
|
|
56
59
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
const
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
) {
|
|
66
|
-
process.stdout.write(`${USAGE}\n`)
|
|
67
|
-
return
|
|
68
|
-
}
|
|
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
|
+
}
|
|
69
68
|
|
|
70
|
-
|
|
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
|
+
}
|
|
71
78
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
await
|
|
90
|
-
|
|
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)
|
|
91
104
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
throw new Error(`Unsupported command: ${_exhaustive}`)
|
|
100
|
-
}
|
|
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
|
+
})
|
|
101
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
|
|
102
130
|
}
|
|
103
131
|
|
|
104
|
-
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/runtime/index.ts
CHANGED
package/src/runtime/push.ts
CHANGED
|
@@ -9,7 +9,7 @@ export interface KeyValueStorage {
|
|
|
9
9
|
}
|
|
10
10
|
|
|
11
11
|
export interface PushNotificationsOptions {
|
|
12
|
-
/** Remembers that the one-time prompt ran. */
|
|
12
|
+
/** Remembers that the one-time prompt ran and the last token sent. */
|
|
13
13
|
storage: KeyValueStorage
|
|
14
14
|
/** Saves this device's Expo push token on your backend; `null` removes it. */
|
|
15
15
|
registerDevice: (token: string | null) => Promise<void>
|
|
@@ -28,6 +28,8 @@ export interface PushNotificationsOptions {
|
|
|
28
28
|
/** How notifications show while the app is in the foreground. */
|
|
29
29
|
foregroundBehavior?: Notifications.NotificationBehavior
|
|
30
30
|
storageKey?: string
|
|
31
|
+
/** Holds the last token passed to `registerDevice`. */
|
|
32
|
+
tokenStorageKey?: string
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
export interface NotificationListeners {
|
|
@@ -50,7 +52,8 @@ export function createPushNotifications(options: PushNotificationsOptions) {
|
|
|
50
52
|
onUnavailable,
|
|
51
53
|
onError = (message, error) => console.error(message, error),
|
|
52
54
|
projectId,
|
|
53
|
-
storageKey = '@notifications_permission_asked'
|
|
55
|
+
storageKey = '@notifications_permission_asked',
|
|
56
|
+
tokenStorageKey = '@notifications_push_token'
|
|
54
57
|
} = options
|
|
55
58
|
|
|
56
59
|
Notifications.setNotificationHandler({
|
|
@@ -78,14 +81,26 @@ export function createPushNotifications(options: PushNotificationsOptions) {
|
|
|
78
81
|
|
|
79
82
|
const getPermissionStatus = () => Notifications.getPermissionsAsync()
|
|
80
83
|
|
|
81
|
-
async function
|
|
84
|
+
async function read(key: string) {
|
|
82
85
|
try {
|
|
83
|
-
return
|
|
86
|
+
return await storage.getItem(key)
|
|
84
87
|
} catch {
|
|
85
|
-
return
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function rememberToken(token: string) {
|
|
93
|
+
try {
|
|
94
|
+
await storage.setItem(tokenStorageKey, token)
|
|
95
|
+
} catch (error) {
|
|
96
|
+
onError('Failed to remember the push token', error)
|
|
86
97
|
}
|
|
87
98
|
}
|
|
88
99
|
|
|
100
|
+
async function hasPrompted() {
|
|
101
|
+
return (await read(storageKey)) === 'true'
|
|
102
|
+
}
|
|
103
|
+
|
|
89
104
|
/** Shows the OS dialog when it still can. Resolves whether push is allowed. */
|
|
90
105
|
async function requestPermissions() {
|
|
91
106
|
if (!Device.isDevice) {
|
|
@@ -102,11 +117,13 @@ export function createPushNotifications(options: PushNotificationsOptions) {
|
|
|
102
117
|
}
|
|
103
118
|
}
|
|
104
119
|
|
|
105
|
-
async function getPushToken() {
|
|
120
|
+
async function getPushToken(devicePushToken?: Notifications.DevicePushToken) {
|
|
106
121
|
if (!Device.isDevice) return null
|
|
107
122
|
try {
|
|
108
123
|
const token = await Notifications.getExpoPushTokenAsync(
|
|
109
|
-
projectId
|
|
124
|
+
projectId || devicePushToken
|
|
125
|
+
? { projectId, devicePushToken }
|
|
126
|
+
: undefined
|
|
110
127
|
)
|
|
111
128
|
return token.data
|
|
112
129
|
} catch (error) {
|
|
@@ -115,18 +132,33 @@ export function createPushNotifications(options: PushNotificationsOptions) {
|
|
|
115
132
|
}
|
|
116
133
|
}
|
|
117
134
|
|
|
118
|
-
|
|
119
|
-
async function syncToken() {
|
|
120
|
-
if (!(await getPermissionStatus()).granted) return false
|
|
121
|
-
const token = await getPushToken()
|
|
122
|
-
if (!token) return false
|
|
135
|
+
async function sendToken(token: string) {
|
|
123
136
|
try {
|
|
124
137
|
await registerDevice(token)
|
|
125
|
-
return true
|
|
126
138
|
} catch (error) {
|
|
127
139
|
onError('Failed to register the push token', error)
|
|
128
140
|
return false
|
|
129
141
|
}
|
|
142
|
+
await rememberToken(token)
|
|
143
|
+
return true
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Registers the current token. Resolves whether one was registered. */
|
|
147
|
+
async function syncToken() {
|
|
148
|
+
if (!(await getPermissionStatus()).granted) return false
|
|
149
|
+
const token = await getPushToken()
|
|
150
|
+
return token ? sendToken(token) : false
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Re-registers a token that changed since it was last sent. Runs only while
|
|
155
|
+
* push is on for this device: a token was sent and `disable()` has not run.
|
|
156
|
+
*/
|
|
157
|
+
async function refreshToken(devicePushToken?: Notifications.DevicePushToken) {
|
|
158
|
+
const sent = await read(tokenStorageKey)
|
|
159
|
+
if (!sent || !(await getPermissionStatus()).granted) return
|
|
160
|
+
const token = await getPushToken(devicePushToken)
|
|
161
|
+
if (token && token !== sent) await sendToken(token)
|
|
130
162
|
}
|
|
131
163
|
|
|
132
164
|
async function runPrompt() {
|
|
@@ -174,13 +206,24 @@ export function createPushNotifications(options: PushNotificationsOptions) {
|
|
|
174
206
|
},
|
|
175
207
|
|
|
176
208
|
/** Removes this device's token from your backend. */
|
|
177
|
-
disable() {
|
|
178
|
-
|
|
209
|
+
async disable() {
|
|
210
|
+
await registerDevice(null)
|
|
211
|
+
// KeyValueStorage has no removeItem; an empty token means none was sent.
|
|
212
|
+
await rememberToken('')
|
|
179
213
|
},
|
|
180
214
|
|
|
181
|
-
/**
|
|
182
|
-
|
|
215
|
+
/**
|
|
216
|
+
* Subscribes to notifications and keeps the registered token current: on
|
|
217
|
+
* start and whenever Expo rotates it. Returns the unsubscribe function.
|
|
218
|
+
*/
|
|
219
|
+
listen({ onReceived, onResponse }: NotificationListeners = {}) {
|
|
220
|
+
const refresh = (devicePushToken?: Notifications.DevicePushToken) =>
|
|
221
|
+
refreshToken(devicePushToken).catch((error: unknown) =>
|
|
222
|
+
onError('Failed to refresh the push token', error)
|
|
223
|
+
)
|
|
224
|
+
void refresh()
|
|
183
225
|
const subscriptions = [
|
|
226
|
+
Notifications.addPushTokenListener(refresh),
|
|
184
227
|
onReceived && Notifications.addNotificationReceivedListener(onReceived),
|
|
185
228
|
onResponse &&
|
|
186
229
|
Notifications.addNotificationResponseReceivedListener(onResponse)
|
|
@@ -196,27 +239,55 @@ export function createPushNotifications(options: PushNotificationsOptions) {
|
|
|
196
239
|
}
|
|
197
240
|
}
|
|
198
241
|
|
|
242
|
+
export interface UsePushNotificationsOptions {
|
|
243
|
+
/** The user's saved preference, for example from your API. */
|
|
244
|
+
enabled?: boolean
|
|
245
|
+
/**
|
|
246
|
+
* Saves the preference. `enable()` and `disable()` call it first and show
|
|
247
|
+
* the new value at once; when it rejects, they roll back and rethrow.
|
|
248
|
+
*/
|
|
249
|
+
persist?: (enabled: boolean) => Promise<unknown>
|
|
250
|
+
}
|
|
251
|
+
|
|
199
252
|
/**
|
|
200
253
|
* Push state for a settings screen. `enabled` is the user's saved preference;
|
|
201
254
|
* while it is on, the token is re-registered on mount.
|
|
202
255
|
*/
|
|
203
256
|
export function usePushNotifications(
|
|
204
257
|
push: PushNotifications,
|
|
205
|
-
{ enabled = false }:
|
|
258
|
+
{ enabled = false, persist }: UsePushNotificationsOptions = {}
|
|
206
259
|
) {
|
|
207
260
|
const [permission, setPermission] =
|
|
208
261
|
useState<Notifications.NotificationPermissionsStatus | null>(null)
|
|
209
262
|
const [isLoading, setIsLoading] = useState(false)
|
|
263
|
+
// Set by enable/disable until `enabled` catches up with the saved value.
|
|
264
|
+
const [optimistic, setOptimistic] = useState<boolean | null>(null)
|
|
265
|
+
const preference = optimistic ?? enabled
|
|
210
266
|
|
|
211
267
|
const refresh = useCallback(async () => {
|
|
212
268
|
setPermission(await push.getPermissionStatus())
|
|
213
269
|
}, [push])
|
|
214
270
|
|
|
215
271
|
useEffect(() => {
|
|
272
|
+
setOptimistic(null)
|
|
216
273
|
void refresh()
|
|
217
274
|
if (enabled) void push.syncToken()
|
|
218
275
|
}, [push, enabled, refresh])
|
|
219
276
|
|
|
277
|
+
const save = useCallback(
|
|
278
|
+
async (next: boolean) => {
|
|
279
|
+
if (!persist) return
|
|
280
|
+
setOptimistic(next)
|
|
281
|
+
try {
|
|
282
|
+
await persist(next)
|
|
283
|
+
} catch (error) {
|
|
284
|
+
setOptimistic(null)
|
|
285
|
+
throw error
|
|
286
|
+
}
|
|
287
|
+
},
|
|
288
|
+
[persist]
|
|
289
|
+
)
|
|
290
|
+
|
|
220
291
|
const withLoading = useCallback(
|
|
221
292
|
<T>(action: () => Promise<T>) =>
|
|
222
293
|
async () => {
|
|
@@ -233,11 +304,17 @@ export function usePushNotifications(
|
|
|
233
304
|
|
|
234
305
|
return {
|
|
235
306
|
permission,
|
|
236
|
-
isEnabled:
|
|
307
|
+
isEnabled: preference && permission?.granted === true,
|
|
237
308
|
isLoading,
|
|
238
309
|
prompt: withLoading(push.prompt),
|
|
239
|
-
enable: withLoading(
|
|
240
|
-
|
|
310
|
+
enable: withLoading(async () => {
|
|
311
|
+
await save(true)
|
|
312
|
+
return push.enable()
|
|
313
|
+
}),
|
|
314
|
+
disable: withLoading(async () => {
|
|
315
|
+
await save(false)
|
|
316
|
+
await push.disable()
|
|
317
|
+
}),
|
|
241
318
|
refresh
|
|
242
319
|
}
|
|
243
320
|
}
|