@marktiderman/genesis-native 0.2.0 → 0.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.
Files changed (98) hide show
  1. package/README.md +470 -10
  2. package/dist/block-Onqx7NiK.d.ts +223 -0
  3. package/dist/blocks-C2RDpvaN.d.ts +1595 -0
  4. package/dist/chunk-65P7RU4V.js +49 -0
  5. package/dist/chunk-65P7RU4V.js.map +1 -0
  6. package/dist/chunk-6BT4PTGH.js +37 -0
  7. package/dist/chunk-6BT4PTGH.js.map +1 -0
  8. package/dist/chunk-75YP72G6.js +374 -0
  9. package/dist/chunk-75YP72G6.js.map +1 -0
  10. package/dist/chunk-NGYNYJPL.js +90 -0
  11. package/dist/chunk-NGYNYJPL.js.map +1 -0
  12. package/dist/chunk-ONAXQHNG.js +23 -0
  13. package/dist/chunk-ONAXQHNG.js.map +1 -0
  14. package/dist/chunk-VKTIHNAV.js +95 -0
  15. package/dist/chunk-VKTIHNAV.js.map +1 -0
  16. package/dist/{chunk-MMTPWUWI.js → chunk-VTJDIKFC.js} +9 -6
  17. package/dist/chunk-VTJDIKFC.js.map +1 -0
  18. package/dist/chunk-XE7LC7K6.js +29 -0
  19. package/dist/chunk-XE7LC7K6.js.map +1 -0
  20. package/dist/dev-warnings.d.ts +34 -0
  21. package/dist/dev-warnings.js +15 -0
  22. package/dist/dev-warnings.js.map +1 -0
  23. package/dist/devtools.d.ts +144 -0
  24. package/dist/devtools.js +390 -0
  25. package/dist/devtools.js.map +1 -0
  26. package/dist/index.d.ts +12 -368
  27. package/dist/index.js +88 -191
  28. package/dist/index.js.map +1 -1
  29. package/dist/observability.d.ts +170 -0
  30. package/dist/observability.js +123 -0
  31. package/dist/observability.js.map +1 -0
  32. package/dist/persist.d.ts +1 -1
  33. package/dist/persist.js +1 -1
  34. package/dist/push-payload.d.ts +43 -0
  35. package/dist/push-payload.js +3 -0
  36. package/dist/push-payload.js.map +1 -0
  37. package/dist/push.d.ts +187 -0
  38. package/dist/push.js +1069 -0
  39. package/dist/push.js.map +1 -0
  40. package/dist/{query-DhH3lLFa.d.ts → query-DUmVAfsF.d.ts} +11 -6
  41. package/dist/rich-push-plugin.d.ts +74 -0
  42. package/dist/rich-push-plugin.js +209 -0
  43. package/dist/rich-push-plugin.js.map +1 -0
  44. package/dist/updates.d.ts +8 -1
  45. package/dist/updates.js +44 -69
  46. package/dist/updates.js.map +1 -1
  47. package/ios/notification-service/NotificationService.swift +309 -0
  48. package/package.json +238 -5
  49. package/src/app.tsx +491 -0
  50. package/src/auth.tsx +176 -0
  51. package/src/blocks.tsx +144 -0
  52. package/src/boot.tsx +188 -0
  53. package/src/dev-warnings.ts +43 -0
  54. package/src/devtools/block.tsx +488 -0
  55. package/src/devtools/gestures.ts +107 -0
  56. package/src/devtools/index.ts +22 -0
  57. package/src/devtools/info.ts +45 -0
  58. package/src/devtools/slots.ts +21 -0
  59. package/src/errors.tsx +195 -0
  60. package/src/held-link.ts +90 -0
  61. package/src/hydration-gate.ts +88 -0
  62. package/src/index.ts +83 -0
  63. package/src/links.ts +140 -0
  64. package/src/nativewind.d.ts +41 -0
  65. package/src/observability/block.tsx +300 -0
  66. package/src/observability/events.ts +89 -0
  67. package/src/observability/index.ts +44 -0
  68. package/src/observability/scrub.ts +166 -0
  69. package/src/observability/sentry.ts +222 -0
  70. package/src/observability/sink.ts +147 -0
  71. package/src/persist.ts +83 -0
  72. package/src/plugin/rich-push.ts +312 -0
  73. package/src/push/backend.ts +137 -0
  74. package/src/push/block.tsx +604 -0
  75. package/src/push/index.ts +105 -0
  76. package/src/push/modules.ts +123 -0
  77. package/src/push/payload.ts +61 -0
  78. package/src/push/permission.ts +191 -0
  79. package/src/push/preferences.ts +136 -0
  80. package/src/push/quiet-hours.ts +71 -0
  81. package/src/push/registration.ts +225 -0
  82. package/src/push/routing.ts +57 -0
  83. package/src/push/screens.tsx +46 -0
  84. package/src/push/settings.tsx +378 -0
  85. package/src/query.ts +100 -0
  86. package/src/updates/block.tsx +442 -0
  87. package/src/updates/channels.ts +131 -0
  88. package/src/updates/checker.ts +190 -0
  89. package/src/updates/index.ts +87 -0
  90. package/src/updates/modules.ts +64 -0
  91. package/src/updates/policy.ts +314 -0
  92. package/src/updates/screens.tsx +159 -0
  93. package/src/updates/signature.ts +80 -0
  94. package/src/updates/store.ts +33 -0
  95. package/src/updates/version.ts +39 -0
  96. package/src/updates/whats-new.ts +122 -0
  97. package/dist/block-Dh5ny06W.d.ts +0 -535
  98. package/dist/chunk-MMTPWUWI.js.map +0 -1
@@ -0,0 +1,442 @@
1
+ /**
2
+ * `createUpdates(options)`: the updates block. Pass the result to
3
+ * `<GenesisNativeApp updates={updates}>` (removing that line turns the block
4
+ * off), or render `<UpdatesRoot block={updates}>` yourself.
5
+ *
6
+ * UpdatesRoot
7
+ * ├ forceUpgrade (blocking: below the policy's minimum version)
8
+ * ├ maintenance (blocking: the policy's maintenance switch, fetched live)
9
+ * └ your screens
10
+ * ├ updateBanner (non-blocking: an update is downloaded; reload on tap)
11
+ * └ whatsNew (release notes, once per version)
12
+ *
13
+ * Every renderer is a slot, a primitive (`ForceUpgradeScreen`, …) and a hook
14
+ * (`useForceUpgrade`, …).
15
+ *
16
+ * The block is also a `defineBlock` declaration (`id: "app-updates"`), so
17
+ * `<GenesisNativeApp blocks={[updates]}>` runs it too. It contributes a
18
+ * Settings row with the version signature (section `about`; see
19
+ * `useBlockSettings`) and names the release-policy migration it reads.
20
+ */
21
+ import { createContext, useContext, useEffect, useMemo, useState, useSyncExternalStore, type ComponentType, type ReactNode } from "react";
22
+ import { View } from "react-native";
23
+ import { renderSlot, type SlotPropsFor, type Slots } from "@marktiderman/genesis-core/slots";
24
+ import { defineBlock } from "@marktiderman/genesis-features/blocks";
25
+ import type { NativeBlock } from "../blocks";
26
+ import { createUpdateChecker, createReloadGuard, type ReloadGuard, type UpdateChecker, type UpdateCheckState } from "./checker";
27
+ import { createChannelSwitcher, type ChannelSwitcher } from "./channels";
28
+ import { delay } from "./store";
29
+ import {
30
+ WEEK_MS,
31
+ createPolicyStore,
32
+ validTimeout,
33
+ evaluatePolicy,
34
+ type PolicyState,
35
+ type PolicyStore,
36
+ type ReleaseNote,
37
+ type UpdatePolicySource,
38
+ type UpdateVerdict,
39
+ } from "./policy";
40
+ import { versionSignature, type VersionSignature } from "./signature";
41
+ import { createWhatsNewStore, type WhatsNewState, type WhatsNewStore } from "./whats-new";
42
+ import {
43
+ ForceUpgradeScreen,
44
+ MaintenanceScreen,
45
+ UpdateBanner,
46
+ WhatsNewSheet,
47
+ type ForceUpgradeProps,
48
+ type MaintenanceProps,
49
+ type UpdateBannerProps,
50
+ type WhatsNewProps,
51
+ } from "./screens";
52
+ import { toError, type ApplicationLike, type UpdatesAppStateLike, type UpdatesModuleLike, type UpdatesOnError, type UpdatesStorageLike } from "./modules";
53
+
54
+ // A type, not an interface, so it fits the slots contract's Record constraint.
55
+ export type UpdatesSlots = {
56
+ /** Blocking. Default `ForceUpgradeScreen`. */
57
+ forceUpgrade: ForceUpgradeProps;
58
+ /** Blocking. Default `MaintenanceScreen`. */
59
+ maintenance: MaintenanceProps;
60
+ /** Over your screens while an update is downloaded. Default `UpdateBanner`. */
61
+ updateBanner: UpdateBannerProps;
62
+ /** Once per version. Default `WhatsNewSheet`. */
63
+ whatsNew: WhatsNewProps;
64
+ };
65
+
66
+ export interface UpdatesOptions {
67
+ /** `Platform.OS`. Force upgrade applies to `ios` and `android` only. */
68
+ platform: string;
69
+ /** `expo-application`: the installed version. Without it, nothing is ever forced and notes never show. */
70
+ application?: ApplicationLike;
71
+ /** `expo-updates`. Without it (or with updates off), no banner and no channel switching. */
72
+ updates?: UpdatesModuleLike;
73
+ /** Min versions, maintenance and notes. Without it, only `notes` and the banner. */
74
+ policy?: UpdatePolicySource;
75
+ /** AsyncStorage: the policy cache, the version whose notes were seen, the picked channel. */
76
+ storage?: UpdatesStorageLike;
77
+ /** `AppState`: ask again when the app comes to the front. */
78
+ appState?: UpdatesAppStateLike;
79
+ /** `Linking.openURL`, for the store link. */
80
+ openURL?: (url: string) => unknown;
81
+ /** Extra say over reloading (return false to hold it off); `useBlockReload` covers forms. */
82
+ canReload?: () => boolean;
83
+ /** Release notes written in the app, merged with the policy's. */
84
+ notes?: readonly ReleaseNote[];
85
+ /** `false` turns what's new off. */
86
+ whatsNew?: boolean;
87
+ /** Channel switching, for development and preview builds only: `{ enabled: __DEV__ || isPreview }`. */
88
+ channels?: { enabled: boolean };
89
+ /** The longest the first policy load holds the splash. Default 3000 ms. */
90
+ policyTimeoutMs?: number;
91
+ /** How old a cached policy may be and still force an upgrade. Default 7 days. */
92
+ cacheMaxAgeMs?: number;
93
+ /** Ask for the policy again on coming to the front at most this often. Default 5 minutes. */
94
+ policyRefreshMs?: number;
95
+ /** Check for an update on coming to the front at most this often. Default 15 minutes. */
96
+ checkIntervalMs?: number;
97
+ /** `reloadAsync`'s reload screen (background color, spinner). */
98
+ reloadScreenOptions?: object;
99
+ /**
100
+ * The Settings row with the version signature the block contributes
101
+ * (`useBlockSettings`): its title and section, or `false` for none.
102
+ * Default `{ title: "Version", section: "about" }`.
103
+ */
104
+ versionRow?: false | { title?: string; section?: string };
105
+ /** Storage key prefix. Default `"genesis:updates"`. */
106
+ storageKeyPrefix?: string;
107
+ /** Every caught error, for reporting. None of them blocks anyone. */
108
+ onError?: UpdatesOnError;
109
+ now?: () => number;
110
+ }
111
+
112
+ /** The FeatureId of the updates block. */
113
+ export const UPDATES_BLOCK_ID = "app-updates";
114
+
115
+ export interface UpdatesBlock extends NativeBlock<typeof UPDATES_BLOCK_ID> {
116
+ readonly options: UpdatesOptions;
117
+ readonly policy: PolicyStore;
118
+ readonly checker: UpdateChecker;
119
+ readonly guard: ReloadGuard;
120
+ readonly whatsNew: WhatsNewStore;
121
+ /** Channel switching: `switchTo` throws unless `channels.enabled` was passed; `reset` always works. */
122
+ readonly channels: ChannelSwitcher;
123
+ /** The signature from the modules, without the picked channel (see `useVersionSignature`). */
124
+ readonly signature: VersionSignature;
125
+ /** Loads the policy and the seen version behind the splash; never fails, done within `policyTimeoutMs`. */
126
+ readonly bootStep: { id: string; run: () => Promise<void>; timeoutMs: number };
127
+ /** `[bootStep]`, for a shell that runs blocks. */
128
+ readonly bootSteps: readonly { id: string; run: () => Promise<void>; timeoutMs: number }[];
129
+ /** Start following the app's state; returns the stop. `UpdatesRoot` calls it. */
130
+ start(): () => void;
131
+ /** The block's root, for `GenesisNativeApp`. */
132
+ readonly Root: ComponentType<Omit<UpdatesRootProps, "block">>;
133
+ }
134
+
135
+ /** Build the block once, at module scope, next to your Supabase client. */
136
+ export function createUpdates(options: UpdatesOptions): UpdatesBlock {
137
+ const {
138
+ platform,
139
+ application,
140
+ updates,
141
+ policy: source,
142
+ storage,
143
+ appState,
144
+ canReload,
145
+ channels,
146
+ policyRefreshMs = 5 * 60_000,
147
+ checkIntervalMs = 15 * 60_000,
148
+ reloadScreenOptions,
149
+ storageKeyPrefix = "genesis:updates",
150
+ onError,
151
+ now = Date.now,
152
+ } = options;
153
+ const policyTimeoutMs = validTimeout(options.policyTimeoutMs, 3000);
154
+ const guard = createReloadGuard(canReload);
155
+ const policy = createPolicyStore({
156
+ source: source ?? { load: async () => null },
157
+ storage: source ? storage : undefined,
158
+ cacheKey: `${storageKeyPrefix}:policy`,
159
+ timeoutMs: policyTimeoutMs,
160
+ now,
161
+ onError,
162
+ });
163
+ const checker = createUpdateChecker({ updates, guard, reloadScreenOptions, now, onError });
164
+ const whatsNew = createWhatsNewStore({
165
+ storage: options.whatsNew === false ? undefined : storage,
166
+ version: options.whatsNew === false ? null : (application?.nativeApplicationVersion ?? null),
167
+ storageKey: `${storageKeyPrefix}:seen-version`,
168
+ onError,
169
+ });
170
+ const switcher = createChannelSwitcher({
171
+ updates,
172
+ enabled: channels?.enabled === true,
173
+ guard,
174
+ storage,
175
+ storageKey: `${storageKeyPrefix}:channel`,
176
+ reloadScreenOptions,
177
+ onError,
178
+ });
179
+ const signature = versionSignature({ application, updates, platform });
180
+
181
+ const allNotes = (): ReleaseNote[] => [...(options.notes ?? []), ...(policy.getState().policy?.notes ?? [])];
182
+ const syncNotes = () => {
183
+ const state = policy.getState();
184
+ // Final once the policy has answered from the network (or there is none): no more notes can arrive.
185
+ whatsNew.update(allNotes(), !source || state.origin === "network");
186
+ };
187
+ policy.subscribe(syncNotes);
188
+
189
+ let loaded: Promise<void> | null = null;
190
+ // Bounded and never rejects, so the boot step can't time out or fail the boot (a stalled storage read included).
191
+ const load = () => {
192
+ loaded ??= Promise.race([Promise.all([policy.load(), whatsNew.load()]), delay(policyTimeoutMs)])
193
+ .then(syncNotes)
194
+ .catch((error) => {
195
+ try {
196
+ onError?.(toError(error), "storage");
197
+ } catch {
198
+ // ignore a failing reporter
199
+ }
200
+ });
201
+ return loaded;
202
+ };
203
+
204
+ const bootStep = { id: "genesis:updates", run: load, timeoutMs: policyTimeoutMs + 5000 };
205
+ const { versionRow } = options;
206
+ const declaration = defineBlock<typeof UPDATES_BLOCK_ID, ComponentType<never>>({
207
+ id: UPDATES_BLOCK_ID,
208
+ platforms: ["native"],
209
+ contributes: {
210
+ settings:
211
+ versionRow === false
212
+ ? []
213
+ : [
214
+ {
215
+ id: "app-version",
216
+ section: versionRow?.section ?? "about",
217
+ title: versionRow?.title ?? "Version",
218
+ testID: "genesis-app-version",
219
+ // Bound to this block, so it needs no provider above the Settings screen.
220
+ useSubtitle: () => useVersionSignatureOf(block).short,
221
+ },
222
+ ],
223
+ migrations: [
224
+ {
225
+ package: "@marktiderman/genesis-supabase",
226
+ file: "migrations/0002_genesis_app_release_policy.sql",
227
+ test: "migrations/tests/0002_genesis_app_release_policy.test.sql",
228
+ },
229
+ ],
230
+ privacy: { notes: ["Reads the installed version and update ids on the device to decide on a force upgrade; sends nothing about the person."] },
231
+ },
232
+ });
233
+
234
+ const block: UpdatesBlock = {
235
+ ...declaration,
236
+ options,
237
+ policy,
238
+ checker,
239
+ guard,
240
+ whatsNew,
241
+ channels: switcher,
242
+ signature,
243
+ bootStep,
244
+ bootSteps: [bootStep],
245
+ start() {
246
+ void load();
247
+ void checker.check();
248
+ const subscription = appState?.addEventListener("change", (state) => {
249
+ if (state !== "active") return;
250
+ void policy.refreshIfDue(policyRefreshMs);
251
+ void checker.checkIfDue(checkIntervalMs);
252
+ });
253
+ return () => subscription?.remove();
254
+ },
255
+ Root: function UpdatesBlockRoot(props) {
256
+ return <UpdatesRoot block={block} {...props} />;
257
+ },
258
+ };
259
+ return block;
260
+ }
261
+
262
+ const UpdatesContext = createContext<UpdatesBlock | null>(null);
263
+
264
+ /** Provides the block to the hooks, without rendering any of its parts (rung 4). */
265
+ export function UpdatesProvider({ block, children }: { block: UpdatesBlock; children?: ReactNode }) {
266
+ return <UpdatesContext.Provider value={block}>{children}</UpdatesContext.Provider>;
267
+ }
268
+
269
+ /** The block from `UpdatesProvider` / `UpdatesRoot` / `GenesisNativeApp`. Throws outside one. */
270
+ export function useUpdates(): UpdatesBlock {
271
+ const block = useContext(UpdatesContext);
272
+ if (!block) throw new Error("useUpdates: no updates block. Pass `updates={createUpdates(…)}` to GenesisNativeApp, or wrap in <UpdatesProvider>.");
273
+ return block;
274
+ }
275
+
276
+ /**
277
+ * Hold reloads off while `active` (a form with unsaved changes):
278
+ * `useBlockReload(form.formState.isDirty)`. Does nothing without an updates
279
+ * block, so a screen can call it whether or not the block is on.
280
+ */
281
+ export function useBlockReload(active: boolean) {
282
+ const block = useContext(UpdatesContext);
283
+ useEffect(() => (active && block ? block.guard.block() : undefined), [active, block]);
284
+ }
285
+
286
+ export function useUpdatePolicy(): PolicyState {
287
+ const { policy } = useUpdates();
288
+ return useSyncExternalStore(policy.subscribe, policy.getState, policy.getState);
289
+ }
290
+
291
+ /** What the policy says now: `ok`, `forceUpgrade` or `maintenance`. */
292
+ export function useUpdateVerdict(): UpdateVerdict {
293
+ const block = useUpdates();
294
+ const state = useUpdatePolicy();
295
+ const { platform, application, cacheMaxAgeMs = WEEK_MS, now = Date.now } = block.options;
296
+ return useMemo(
297
+ () => evaluatePolicy(state, { platform, installedVersion: application?.nativeApplicationVersion ?? null, now: now(), cacheMaxAgeMs }),
298
+ [state, platform, application, cacheMaxAgeMs, now],
299
+ );
300
+ }
301
+
302
+ /** The force-upgrade screen's props, or `null` when this version is supported. */
303
+ export function useForceUpgrade(): ForceUpgradeProps | null {
304
+ const block = useUpdates();
305
+ const verdict = useUpdateVerdict();
306
+ const { refreshing } = useUpdatePolicy();
307
+ if (verdict.kind !== "forceUpgrade") return null;
308
+ const { storeUrl } = verdict;
309
+ return {
310
+ minimumVersion: verdict.minimumVersion,
311
+ installedVersion: verdict.installedVersion,
312
+ storeUrl,
313
+ checking: refreshing,
314
+ retry: () => void block.policy.refresh(),
315
+ openStore: () => {
316
+ if (!block.options.openURL) return;
317
+ Promise.resolve()
318
+ .then(() => block.options.openURL?.(storeUrl))
319
+ .catch((error) => block.options.onError?.(toError(error), "policy"));
320
+ },
321
+ };
322
+ }
323
+
324
+ /** The maintenance screen's props, or `null` when maintenance is off (or not known live). */
325
+ export function useMaintenance(): MaintenanceProps | null {
326
+ const block = useUpdates();
327
+ const verdict = useUpdateVerdict();
328
+ const { refreshing } = useUpdatePolicy();
329
+ if (verdict.kind !== "maintenance") return null;
330
+ return { message: verdict.message, retrying: refreshing, retry: () => void block.policy.refresh() };
331
+ }
332
+
333
+ export function useUpdateCheck(): UpdateCheckState {
334
+ const { checker } = useUpdates();
335
+ return useSyncExternalStore(checker.subscribe, checker.getState, checker.getState);
336
+ }
337
+
338
+ /** True when a reload would be allowed now. */
339
+ export function useCanReload(): boolean {
340
+ const { guard } = useUpdates();
341
+ return useSyncExternalStore(guard.subscribe, guard.canReload, guard.canReload);
342
+ }
343
+
344
+ /** The banner's props, or `null` when there is nothing to show. */
345
+ export function useUpdateBanner(): UpdateBannerProps | null {
346
+ const { checker } = useUpdates();
347
+ const state = useUpdateCheck();
348
+ const canReload = useCanReload();
349
+ if ((state.status !== "ready" && state.status !== "reloading") || state.dismissed) return null;
350
+ return {
351
+ canReload,
352
+ blocked: state.blocked,
353
+ reloading: state.status === "reloading",
354
+ reload: () => void checker.reload(),
355
+ dismiss: () => checker.dismiss(),
356
+ };
357
+ }
358
+
359
+ /** The notes to show now, or `null`. */
360
+ export function useWhatsNew(): WhatsNewProps | null {
361
+ const block = useUpdates();
362
+ const state: WhatsNewState = useSyncExternalStore(block.whatsNew.subscribe, block.whatsNew.getState, block.whatsNew.getState);
363
+ if (state.status !== "showing") return null;
364
+ return { notes: state.notes, version: block.options.application?.nativeApplicationVersion ?? null, dismiss: () => block.whatsNew.dismiss() };
365
+ }
366
+
367
+ /** The version signature, with the channel picked on this device once it has been read. */
368
+ export function useVersionSignature(): VersionSignature {
369
+ return useVersionSignatureOf(useUpdates());
370
+ }
371
+
372
+ function useVersionSignatureOf(block: UpdatesBlock): VersionSignature {
373
+ const [requested, setRequested] = useState<string | null>(null);
374
+ useEffect(() => {
375
+ let alive = true;
376
+ void block.channels.requested().then((value) => alive && setRequested(value));
377
+ return () => {
378
+ alive = false;
379
+ };
380
+ }, [block]);
381
+ return useMemo(() => {
382
+ if (!requested) return block.signature;
383
+ const { application, updates, platform } = block.options;
384
+ return versionSignature({ application, updates, platform, requestedChannel: requested });
385
+ }, [block, requested]);
386
+ }
387
+
388
+ export interface UpdatesRootProps {
389
+ block: UpdatesBlock;
390
+ slots?: Slots<UpdatesSlots>;
391
+ slotProps?: SlotPropsFor<UpdatesSlots>;
392
+ children?: ReactNode;
393
+ }
394
+
395
+ /** The block's parts around your screens. */
396
+ export function UpdatesRoot({ block, slots, slotProps, children }: UpdatesRootProps) {
397
+ useEffect(() => block.start(), [block]);
398
+ return (
399
+ <UpdatesProvider block={block}>
400
+ <UpdatesParts slots={slots} slotProps={slotProps}>
401
+ {children}
402
+ </UpdatesParts>
403
+ </UpdatesProvider>
404
+ );
405
+ }
406
+
407
+ const FILL = { position: "absolute", top: 0, right: 0, bottom: 0, left: 0 } as const;
408
+
409
+ function UpdatesParts({ slots, slotProps, children }: Omit<UpdatesRootProps, "block">) {
410
+ const forceUpgrade = useForceUpgrade();
411
+ const maintenance = useMaintenance();
412
+ const banner = useUpdateBanner();
413
+ const whatsNew = useWhatsNew();
414
+ // An ejected blocking slot (`null`) falls through to the screens: a block that is turned off never blocks.
415
+ const blocking =
416
+ forceUpgrade && slots?.forceUpgrade !== null
417
+ ? renderSlot(ForceUpgradeScreen, slots?.forceUpgrade, forceUpgrade, slotProps?.forceUpgrade)
418
+ : maintenance && slots?.maintenance !== null
419
+ ? renderSlot(MaintenanceScreen, slots?.maintenance, maintenance, slotProps?.maintenance)
420
+ : null;
421
+ // The screens stay mounted under a blocking screen, so a block that starts and ends
422
+ // mid-session (maintenance on, then off) keeps a half-filled form and the navigation state.
423
+ return (
424
+ <View style={{ flex: 1 }}>
425
+ <View
426
+ style={{ flex: 1 }}
427
+ pointerEvents={blocking ? "none" : "auto"}
428
+ accessibilityElementsHidden={Boolean(blocking)}
429
+ importantForAccessibility={blocking ? "no-hide-descendants" : "auto"}
430
+ >
431
+ {children}
432
+ </View>
433
+ {!blocking && banner ? renderSlot(UpdateBanner, slots?.updateBanner, banner, slotProps?.updateBanner) : null}
434
+ {!blocking && whatsNew ? renderSlot(WhatsNewSheet, slots?.whatsNew, whatsNew, slotProps?.whatsNew) : null}
435
+ {blocking ? (
436
+ <View style={FILL} testID="genesis-updates-blocking">
437
+ {blocking}
438
+ </View>
439
+ ) : null}
440
+ </View>
441
+ );
442
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Switching a test build to another update channel (EAS "channel surfing"):
3
+ * override the `expo-channel-name` header, check, fetch, reload. The
4
+ * override persists on the device until it is reset or the app is removed.
5
+ *
6
+ * Off unless you turn it on, and turn it on only in development and preview
7
+ * builds (`enabled: __DEV__ || isPreviewBuild`): a store build that can be
8
+ * pointed at any channel can be pointed at an unreviewed one. Going back
9
+ * (`reset`) always works, for everyone, because it only removes the
10
+ * override.
11
+ *
12
+ * Switching reloads, so it respects the reload guard: with unsaved changes
13
+ * it sets the channel and says `blocked`; the channel applies on the next
14
+ * launch.
15
+ */
16
+ import { toError, type UpdatesModuleLike, type UpdatesOnError, type UpdatesStorageLike } from "./modules";
17
+ import type { ReloadGuard } from "./checker";
18
+
19
+ export type ChannelSwitchResult =
20
+ /** The reload was sent; JS may stop at any moment. */
21
+ | { kind: "reloading" }
22
+ /** The channel is set, but something is mid-edit: it applies on the next launch. */
23
+ | { kind: "blocked" }
24
+ /** expo-updates is off or too old here (development, Expo Go). Nothing changed. */
25
+ | { kind: "unavailable"; reason: string }
26
+ /** The channel is set but checking, fetching or reloading failed. */
27
+ | { kind: "error"; error: Error };
28
+
29
+ export interface ChannelSwitcher {
30
+ /** True when this build may switch channels (`channels.enabled`). */
31
+ readonly enabled: boolean;
32
+ /** Switch to `channel` and reload. Throws when switching is off. */
33
+ switchTo(channel: string): Promise<ChannelSwitchResult>;
34
+ /** Back to the build's own channel. Always allowed. */
35
+ reset(): Promise<ChannelSwitchResult>;
36
+ /** The channel picked on this device, or `null` when it follows the build. */
37
+ requested(): Promise<string | null>;
38
+ }
39
+
40
+ export interface ChannelSwitcherOptions {
41
+ updates?: UpdatesModuleLike;
42
+ /** Must be explicitly true (development and preview builds only). */
43
+ enabled: boolean;
44
+ guard: ReloadGuard;
45
+ /** Remembers the picked channel, for the version signature. */
46
+ storage?: UpdatesStorageLike;
47
+ /** Default `"genesis:updates:channel"`. */
48
+ storageKey?: string;
49
+ reloadScreenOptions?: object;
50
+ onError?: UpdatesOnError;
51
+ }
52
+
53
+ const CHANNEL = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,99}$/;
54
+
55
+ export function createChannelSwitcher(options: ChannelSwitcherOptions): ChannelSwitcher {
56
+ const { updates, enabled, guard, storage, storageKey = "genesis:updates:channel", reloadScreenOptions, onError } = options;
57
+ const report = (error: Error) => {
58
+ try {
59
+ onError?.(error, "channel");
60
+ } catch {
61
+ // ignore a failing reporter
62
+ }
63
+ };
64
+ const unavailable = (): ChannelSwitchResult | null => {
65
+ if (!updates?.isEnabled) return { kind: "unavailable", reason: "Updates are off in this build (development or Expo Go)." };
66
+ if (typeof updates.setUpdateRequestHeadersOverride !== "function") {
67
+ return { kind: "unavailable", reason: "This expo-updates can't override the channel." };
68
+ }
69
+ return null;
70
+ };
71
+ const save = async (channel: string | null) => {
72
+ if (!storage) return;
73
+ try {
74
+ if (channel === null) await (storage.removeItem ? storage.removeItem(storageKey) : storage.setItem(storageKey, ""));
75
+ else await storage.setItem(storageKey, channel);
76
+ } catch (error) {
77
+ report(toError(error));
78
+ }
79
+ };
80
+ const apply = async (): Promise<ChannelSwitchResult> => {
81
+ const u = updates as UpdatesModuleLike;
82
+ try {
83
+ const check = await u.checkForUpdateAsync();
84
+ if (check.isAvailable || check.isRollBackToEmbedded) await u.fetchUpdateAsync();
85
+ if (!guard.canReload()) return { kind: "blocked" };
86
+ await u.reloadAsync(reloadScreenOptions ? { reloadScreenOptions } : undefined);
87
+ return { kind: "reloading" };
88
+ } catch (raw) {
89
+ const error = toError(raw);
90
+ report(error);
91
+ return { kind: "error", error };
92
+ }
93
+ };
94
+
95
+ return {
96
+ enabled,
97
+ async switchTo(channel) {
98
+ if (enabled !== true) {
99
+ throw new Error("Channel switching is off. Pass `channels: { enabled: true }` only in development and preview builds.");
100
+ }
101
+ const name = channel.trim();
102
+ if (!CHANNEL.test(name)) throw new Error(`Not a channel name: "${channel}"`);
103
+ const off = unavailable();
104
+ if (off) return off;
105
+ updates!.setUpdateRequestHeadersOverride!({ "expo-channel-name": name });
106
+ await save(name === updates!.channel ? null : name);
107
+ return apply();
108
+ },
109
+ async reset() {
110
+ const off = unavailable();
111
+ if (off) {
112
+ await save(null);
113
+ return off;
114
+ }
115
+ // Clearing the override is the part that must happen; a failed check or reload is reported, not a reason to stay.
116
+ updates!.setUpdateRequestHeadersOverride!(null);
117
+ await save(null);
118
+ return apply();
119
+ },
120
+ async requested() {
121
+ if (!storage) return null;
122
+ try {
123
+ const value = await storage.getItem(storageKey);
124
+ return value ? value : null;
125
+ } catch (error) {
126
+ report(toError(error));
127
+ return null;
128
+ }
129
+ },
130
+ };
131
+ }