1import * as Updates from "expo-updates";
2
3import {
4 type AtomCommandResult,
5 isAtomCommandInterrupted,
6 reportAtomCommandResult,
7 settlePromise,
8 squashAtomCommandFailure,
9} from "@t3tools/client-runtime/state/runtime";
10
11export type AppUpdateCheckState =
12 | "idle"
13 | "checking"
14 | "downloading"
15 | "ready"
16 | "restarting"
17 | "current";
18
19export interface AppUpdateClient {
20 readonly isEnabled: boolean;
21 readonly checkForUpdateAsync: () => Promise<{
22 readonly isAvailable: boolean;
23 readonly isRollBackToEmbedded: boolean;
24 }>;
25 readonly fetchUpdateAsync: () => Promise<{
26 readonly isNew: boolean;
27 readonly isRollBackToEmbedded: boolean;
28 }>;
29 readonly reloadAsync: () => Promise<void>;
30}
31
32/**
33 * The pieces of the app the update flow has to coordinate with before it may
34 * tear down the JavaScript runtime. Injectable so the flow stays unit-testable.
35 */
36export interface AppUpdateEnvironment {
37 /** Asks the user to install the waiting update now; `false` keeps it deferred. */
38 readonly confirmInstallNow: () => Promise<boolean>;
39 /**
40 * Lands persisted state (drafts, outbox) before the restart. Rejects when a
41 * write failed, so a silent restart can hold off instead of dropping the
42 * unsaved in-memory state.
43 */
44 readonly flushPendingWrites: () => Promise<void>;
45 /**
46 * Whether a deferred restart may fire right now: the app must still be
47 * backgrounded (flush latency or an iOS suspend can push the continuation
48 * into the next foreground session) and not merely paused behind an
49 * app-initiated handoff like the Android image picker.
50 */
51 readonly isSafeToRestartInBackground: () => Promise<boolean>;
52 /**
53 * Runs `apply` the next time the app enters the background. With
54 * `includeCurrent`, an app that is already backgrounded fires immediately
55 * (so a backgrounding that raced module load is not missed); without it,
56 * only a future transition fires, so an attempt that already failed in the
57 * current background session cannot retry in a tight loop.
58 */
59 readonly onNextBackground: (apply: () => void, includeCurrent: boolean) => void;
60 /**
61 * Runs `apply` once the app has stayed foregrounded for the whole prompt
62 * window — the signal that a deferred install has had no backgrounding to
63 * ride on.
64 */
65 readonly onForegroundStay: (apply: () => void) => void;
66}
67
68/** Tracks a downloaded update waiting for a safe moment to install. */
69export interface AppUpdateDeferral {
70 pendingInstall: boolean;
71 /**
72 * Claimed by whichever restart sequence (deferred backgrounding, foreground
73 * prompt, manual install) starts first, so racing paths cannot tear down
74 * the runtime twice.
75 */
76 installInProgress: boolean;
77}
78
79export function createAppUpdateDeferral(): AppUpdateDeferral {
80 return { pendingInstall: false, installInProgress: false };
81}
82
83const appUpdateDeferral = createAppUpdateDeferral();
84
85interface AppUpdateCheckOptions {
86 /**
87 * "background" (default) installs silently at the next backgrounding,
88 * asking only if the app then stays foregrounded so long that the install
89 * never gets its chance. "immediate" restarts as soon as the download
90 * lands — reserved for flows where the user explicitly requested the update.
91 */
92 readonly applyMode?: "background" | "immediate";
93 readonly client?: AppUpdateClient;
94 readonly deferral?: AppUpdateDeferral;
95 readonly environment?: AppUpdateEnvironment;
96 readonly onFailure?: (message: string) => void;
97 readonly onStateChange?: (state: AppUpdateCheckState) => void;
98}
99
100interface AppUpdateCheckProgress {
101 failure: string | undefined;
102 state: AppUpdateCheckState | undefined;
103}
104
105interface AppUpdateCheckInFlight {
106 readonly failureListeners: Set<NonNullable<AppUpdateCheckOptions["onFailure"]>>;
107 readonly progress: AppUpdateCheckProgress;
108 readonly promise: Promise<void>;
109 readonly stateListeners: Set<NonNullable<AppUpdateCheckOptions["onStateChange"]>>;
110}
111
112interface Deferred {
113 readonly promise: Promise<void>;
114 readonly reject: (cause: unknown) => void;
115 readonly resolve: () => void;
116}
117
118const HIDDEN_UPDATE_TAP_COUNT = 5;
119const UPDATE_CHECK_UNAVAILABLE_ERROR_CODES = new Set([
120 "ERR_NOT_AVAILABLE_IN_DEV_CLIENT",
121 "ERR_UPDATES_DISABLED",
122]);
123let appUpdateCheckInFlight: AppUpdateCheckInFlight | undefined;
124
125/** Expo's development launcher reports updates as enabled even though its OTA APIs reject. */
126export function isAppUpdateCheckAvailable(client: Pick<AppUpdateClient, "isEnabled"> = Updates) {
127 return client.isEnabled && !(typeof __DEV__ !== "undefined" && __DEV__);
128}
129
130/**
131 * Keeps the manual update affordance discoverable only to someone deliberately
132 * tapping the version row five times.
133 */
134export function registerHiddenUpdateTap(count: number): {
135 readonly nextCount: number;
136 readonly shouldCheck: boolean;
137} {
138 const nextCount = count + 1;
139 if (nextCount >= HIDDEN_UPDATE_TAP_COUNT) {
140 return {
141 nextCount: 0,
142 shouldCheck: true,
143 };
144 }
145 return {
146 nextCount,
147 shouldCheck: false,
148 };
149}
150
151export async function runAppUpdateCheck(options: AppUpdateCheckOptions = {}): Promise<void> {
152 const client = options.client ?? Updates;
153 if (!isAppUpdateCheckAvailable(client)) return;
154
155 if (appUpdateCheckInFlight) {
156 await observeAppUpdateCheck(appUpdateCheckInFlight, options);
157 // A background-mode check in flight may have deferred the download this
158 // caller explicitly asked to install; honor the explicit request now.
159 if (options.applyMode === "immediate") {
160 const deferral = options.deferral ?? appUpdateDeferral;
161 if (deferral.pendingInstall) {
162 const environment = options.environment ?? defaultAppUpdateEnvironment;
163 await installPendingAppUpdate(client, environment, deferral, options);
164 }
165 }
166 return;
167 }
168
169 const progress: AppUpdateCheckProgress = {
170 failure: undefined,
171 state: undefined,
172 };
173 const failureListeners = new Set<NonNullable<AppUpdateCheckOptions["onFailure"]>>();
174 const stateListeners = new Set<NonNullable<AppUpdateCheckOptions["onStateChange"]>>();
175 if (options.onFailure) failureListeners.add(options.onFailure);
176 if (options.onStateChange) stateListeners.add(options.onStateChange);
177
178 const deferred = createDeferred();
179 const inFlight: AppUpdateCheckInFlight = {
180 failureListeners,
181 progress,
182 promise: deferred.promise,
183 stateListeners,
184 };
185 // Publish the operation before any state listener can synchronously re-enter.
186 appUpdateCheckInFlight = inFlight;
187
188 const execution = performAppUpdateCheck(client, {
189 applyMode: options.applyMode,
190 deferral: options.deferral,
191 environment: options.environment,
192 onFailure: (message) => {
193 progress.failure = message;
194 notifyListeners(failureListeners, message);
195 },
196 onStateChange: (state) => {
197 progress.state = state;
198 notifyListeners(stateListeners, state);
199 },
200 });
201 void execution.then(deferred.resolve, deferred.reject);
202
203 try {
204 await deferred.promise;
205 } finally {
206 if (appUpdateCheckInFlight === inFlight) {
207 appUpdateCheckInFlight = undefined;
208 }
209 }
210}
211
212function createDeferred(): Deferred {
213 let reject!: Deferred["reject"];
214 let resolve!: Deferred["resolve"];
215 const promise = new Promise<void>((resolvePromise, rejectPromise) => {
216 resolve = () => resolvePromise();
217 reject = rejectPromise;
218 });
219 return { promise, reject, resolve };
220}
221
222function notifyListeners<A>(listeners: ReadonlySet<(value: A) => void>, value: A): void {
223 // A listener can synchronously subscribe another caller. Snapshot so that
224 // caller receives only observeAppUpdateCheck's explicit current-value replay.
225 const snapshot = Array.from(listeners);
226 for (const listener of snapshot) listener(value);
227}
228
229async function observeAppUpdateCheck(
230 inFlight: AppUpdateCheckInFlight,
231 options: AppUpdateCheckOptions,
232): Promise<void> {
233 const onFailure = options.onFailure;
234 const onStateChange = options.onStateChange;
235
236 if (onFailure) {
237 inFlight.failureListeners.add(onFailure);
238 if (inFlight.progress.failure) onFailure(inFlight.progress.failure);
239 }
240 if (onStateChange) {
241 inFlight.stateListeners.add(onStateChange);
242 if (inFlight.progress.state) onStateChange(inFlight.progress.state);
243 }
244
245 try {
246 await inFlight.promise;
247 } finally {
248 if (onFailure) inFlight.failureListeners.delete(onFailure);
249 if (onStateChange) inFlight.stateListeners.delete(onStateChange);
250 }
251}
252
253async function performAppUpdateCheck(
254 client: AppUpdateClient,
255 options: AppUpdateCheckOptions,
256): Promise<void> {
257 const setState = options.onStateChange ?? (() => {});
258 const environment = options.environment ?? defaultAppUpdateEnvironment;
259 const deferral = options.deferral ?? appUpdateDeferral;
260
261 // The user explicitly asked to install and a previous check has already
262 // downloaded the update; restart into it without another network round trip.
263 if (options.applyMode === "immediate" && deferral.pendingInstall) {
264 await installPendingAppUpdate(client, environment, deferral, options);
265 return;
266 }
267
268 setState("checking");
269 const check = await settlePromise(() => client.checkForUpdateAsync());
270 if (check._tag === "Failure") {
271 reportUpdateFailure(check, "Could not check for updates.", options.onFailure);
272 setState("idle");
273 return;
274 }
275 // A rollback directive (`eas update:rollback`) arrives as isAvailable: false
276 // with isRollBackToEmbedded: true. The running OTA still has to be dropped.
277 if (!check.value.isAvailable && !check.value.isRollBackToEmbedded) {
278 setState("current");
279 return;
280 }
281
282 setState("downloading");
283 const fetched = await settlePromise(() => client.fetchUpdateAsync());
284 if (fetched._tag === "Failure") {
285 reportUpdateFailure(fetched, "Could not download the update.", options.onFailure);
286 setState("idle");
287 return;
288 }
289 // isNew is always false for a rollback, so it cannot be the sole gate.
290 if (!fetched.value.isNew && !fetched.value.isRollBackToEmbedded) {
291 setState("current");
292 return;
293 }
294
295 // A rollback directive exists to pull a broken bundle; never hold it
296 // behind a prompt or a deferred install.
297 if (options.applyMode === "immediate" || fetched.value.isRollBackToEmbedded) {
298 const outcome = await installAppUpdate(
299 client,
300 environment,
301 deferral,
302 options,
303 options.applyMode === "immediate",
304 );
305 if (outcome === "flush-failed") {
306 // Only reachable for an automatic rollback: keep the state-bearing
307 // runtime alive and retry like a deferred install. The fetched rollback
308 // still applies at the next cold start regardless.
309 setState("ready");
310 armDeferredAppUpdateInstall(client, environment, deferral);
311 }
312 return;
313 }
314
315 setState("ready");
316 armDeferredAppUpdateInstall(client, environment, deferral);
317}
318
319type AppUpdateInstallOutcome = "installed" | "flush-failed" | "restart-failed";
320
321/**
322 * Restarting mid-session while native surfaces are mounted is the crashiest
323 * moment expo-updates has, so the restart flushes persistence first and, by
324 * default, waits for a backgrounding — where nothing is rendering and the
325 * teardown is invisible. Only a restart the user explicitly asked for may
326 * proceed over a failed flush; an automatic one aborts with "flush-failed"
327 * so unsaved state is never silently discarded.
328 */
329async function installAppUpdate(
330 client: AppUpdateClient,
331 environment: AppUpdateEnvironment,
332 deferral: AppUpdateDeferral,
333 options: AppUpdateCheckOptions,
334 userRequested: boolean,
335): Promise<AppUpdateInstallOutcome> {
336 // A concurrent install sequence already owns the restart.
337 if (deferral.installInProgress) return "installed";
338 deferral.installInProgress = true;
339 const setState = options.onStateChange ?? (() => {});
340 setState("restarting");
341 const flushed = await settlePromise(() => environment.flushPendingWrites());
342 if (flushed._tag === "Failure") {
343 reportUpdateFailure(flushed, "Could not save pending state.", undefined);
344 if (!userRequested) {
345 deferral.installInProgress = false;
346 return "flush-failed";
347 }
348 }
349 const reloaded = await settlePromise(() => client.reloadAsync());
350 if (reloaded._tag === "Failure") {
351 reportUpdateFailure(reloaded, "Downloaded, but could not restart the app.", options.onFailure);
352 setState("idle");
353 deferral.installInProgress = false;
354 return "restart-failed";
355 }
356 return "installed";
357}
358
359/** Restarts into an already-downloaded update at the user's request. */
360async function installPendingAppUpdate(
361 client: AppUpdateClient,
362 environment: AppUpdateEnvironment,
363 deferral: AppUpdateDeferral,
364 options: AppUpdateCheckOptions,
365): Promise<void> {
366 const outcome = await installAppUpdate(client, environment, deferral, options, true);
367 if (outcome === "restart-failed") {
368 // Let later checks re-arm the install; the downloaded update still
369 // applies at the next cold start regardless.
370 deferral.pendingInstall = false;
371 }
372}
373
374function armDeferredAppUpdateInstall(
375 client: AppUpdateClient,
376 environment: AppUpdateEnvironment,
377 deferral: AppUpdateDeferral,
378): void {
379 if (deferral.pendingInstall) return;
380 deferral.pendingInstall = true;
381 scheduleDeferredAppUpdateInstall(client, environment, deferral, true);
382 environment.onForegroundStay(() => {
383 void promptDeferredAppUpdateInstall(client, environment, deferral);
384 });
385}
386
387/**
388 * A deferred install normally rides the next backgrounding, but a session that
389 * never leaves the foreground would sit on the download forever. Only then is
390 * the user asked, and declining simply leaves the background install armed.
391 */
392async function promptDeferredAppUpdateInstall(
393 client: AppUpdateClient,
394 environment: AppUpdateEnvironment,
395 deferral: AppUpdateDeferral,
396): Promise<void> {
397 if (!deferral.pendingInstall || deferral.installInProgress) return;
398 const installNow = await settlePromise(() => environment.confirmInstallNow());
399 if (installNow._tag !== "Success" || !installNow.value) return;
400 // A backgrounding while the alert was up may have started the deferred
401 // restart already; the stale accept must not start a second one.
402 if (!deferral.pendingInstall || deferral.installInProgress) return;
403 await installPendingAppUpdate(client, environment, deferral, {});
404}
405
406function scheduleDeferredAppUpdateInstall(
407 client: AppUpdateClient,
408 environment: AppUpdateEnvironment,
409 deferral: AppUpdateDeferral,
410 includeCurrent: boolean,
411): void {
412 environment.onNextBackground(() => {
413 void applyDeferredAppUpdateInstall(client, environment, deferral);
414 }, includeCurrent);
415}
416
417async function applyDeferredAppUpdateInstall(
418 client: AppUpdateClient,
419 environment: AppUpdateEnvironment,
420 deferral: AppUpdateDeferral,
421): Promise<void> {
422 if (!deferral.pendingInstall || deferral.installInProgress) return;
423 deferral.installInProgress = true;
424 const flushed = await settlePromise(() => environment.flushPendingWrites());
425 const safe = await settlePromise(() => environment.isSafeToRestartInBackground());
426 if (flushed._tag === "Failure" || safe._tag !== "Success" || !safe.value) {
427 if (flushed._tag === "Failure") {
428 // Nothing is lost yet: keep the state-bearing runtime alive and retry
429 // the flush at the next backgrounding instead of restarting over it.
430 reportUpdateFailure(flushed, "Could not save pending state.", undefined);
431 }
432 deferral.installInProgress = false;
433 // This attempt already ran in the current background session; retrying
434 // before a fresh transition would just loop over the same failure.
435 scheduleDeferredAppUpdateInstall(client, environment, deferral, false);
436 return;
437 }
438 const reloaded = await settlePromise(() => client.reloadAsync());
439 if (reloaded._tag === "Failure") {
440 reportUpdateFailure(reloaded, "Downloaded, but could not restart the app.", undefined);
441 deferral.installInProgress = false;
442 // Let later checks re-arm the install; the downloaded update still
443 // applies at the next cold start regardless.
444 deferral.pendingInstall = false;
445 }
446}
447
448async function defaultConfirmInstallNow(): Promise<boolean> {
449 const { Alert } = await import("react-native");
450 return new Promise<boolean>((resolve) => {
451 Alert.alert(
452 "Update ready",
453 "A new version has been downloaded and installs automatically the next time you leave the app. Install it now instead?",
454 [
455 { onPress: () => resolve(false), style: "cancel", text: "Later" },
456 { onPress: () => resolve(true), text: "Install Now" },
457 ],
458 { cancelable: true, onDismiss: () => resolve(false) },
459 );
460 });
461}
462
463async function defaultFlushPendingWrites(): Promise<void> {
464 // Attempt every flush before surfacing the first failure, so one broken
465 // store cannot keep the others from landing.
466 const results = await Promise.allSettled([
467 import("../../state/use-composer-drafts").then((drafts) => drafts.flushComposerDrafts()),
468 import("../../state/thread-outbox").then((outbox) => outbox.flushThreadOutbox()),
469 ]);
470 const failed = results.find(
471 (result): result is PromiseRejectedResult => result.status === "rejected",
472 );
473 if (failed) throw failed.reason;
474}
475
476async function defaultIsSafeToRestartInBackground(): Promise<boolean> {
477 const { isForegroundHandoffActive } = await import("../../lib/foreground-handoff");
478 if (isForegroundHandoffActive()) return false;
479 const { AppState } = await import("react-native");
480 return AppState.currentState === "background";
481}
482
483function defaultOnNextBackground(apply: () => void, includeCurrent: boolean): void {
484 void import("react-native").then(({ AppState }) => {
485 const subscription = AppState.addEventListener("change", (state) => {
486 if (state !== "background") return;
487 subscription.remove();
488 apply();
489 });
490 // The app may already have backgrounded while this module was loading;
491 // the listener alone would then wait a whole extra foreground cycle.
492 if (includeCurrent && AppState.currentState === "background") {
493 subscription.remove();
494 apply();
495 }
496 });
497}
498
499/**
500 * How long the app may stay foregrounded with a downloaded update before the
501 * install prompt appears. Long enough that most sessions background naturally
502 * and install silently instead.
503 */
504export const DEFERRED_INSTALL_PROMPT_AFTER_MS = 30 * 60 * 1000;
505
506/**
507 * The window resets on every backgrounding because that is exactly when the
508 * deferred install gets its chance. iOS "inactive" blips (app switcher, a
509 * pulled-down notification shade) leave the timer running.
510 */
511function defaultOnForegroundStay(apply: () => void): void {
512 void import("react-native").then(({ AppState }) => {
513 let timer: ReturnType<typeof setTimeout> | undefined;
514 const arm = () => {
515 timer ??= setTimeout(() => {
516 subscription.remove();
517 apply();
518 }, DEFERRED_INSTALL_PROMPT_AFTER_MS);
519 };
520 const disarm = () => {
521 if (timer === undefined) return;
522 clearTimeout(timer);
523 timer = undefined;
524 };
525 const subscription = AppState.addEventListener("change", (state) => {
526 if (state === "active") arm();
527 else if (state === "background") disarm();
528 });
529 if (AppState.currentState === "active") arm();
530 });
531}
532
533const defaultAppUpdateEnvironment: AppUpdateEnvironment = {
534 confirmInstallNow: defaultConfirmInstallNow,
535 flushPendingWrites: defaultFlushPendingWrites,
536 isSafeToRestartInBackground: defaultIsSafeToRestartInBackground,
537 onNextBackground: defaultOnNextBackground,
538 onForegroundStay: defaultOnForegroundStay,
539};
540
541function reportUpdateFailure(
542 result: AtomCommandResult<unknown, unknown>,
543 fallback: string,
544 onFailure: AppUpdateCheckOptions["onFailure"],
545): void {
546 if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
547 const error = squashAtomCommandFailure(result);
548 if (isAppUpdateUnavailableError(error)) return;
549
550 reportAtomCommandResult(result, { label: "app update check" });
551 onFailure?.(error instanceof Error ? error.message : fallback);
552}
553
554function isAppUpdateUnavailableError(error: unknown): boolean {
555 if (typeof error !== "object" || error === null || !("code" in error)) return false;
556 const code = error.code;
557 return typeof code === "string" && UPDATE_CHECK_UNAVAILABLE_ERROR_CODES.has(code);
558}
559
560export function createAppUpdateLaunchCheck(
561 client: AppUpdateClient = Updates,
562): () => Promise<void> | undefined {
563 let started = false;
564
565 return () => {
566 if (started || !isAppUpdateCheckAvailable(client)) return undefined;
567 started = true;
568 return runAppUpdateCheck({ client });
569 };
570}
571
572export const checkForAppUpdateOnLaunch = createAppUpdateLaunchCheck();
573
574/**
575 * The app can stay resident for days, so a launch-only check misses updates
576 * published while it was in memory. Anything shorter reads as noise: brief
577 * app switches should not trigger network checks or an install prompt.
578 */
579export const FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS = 15 * 60 * 1000;
580
581export function shouldRecheckAppUpdateOnForeground(
582 backgroundedAtMs: number | null,
583 activeAtMs: number,
584 pendingInstall: boolean,
585): boolean {
586 if (pendingInstall) return false;
587 return (
588 backgroundedAtMs !== null &&
589 activeAtMs - backgroundedAtMs >= FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS
590 );
591}
592
593export function createAppUpdateForegroundRecheck(
594 client: AppUpdateClient = Updates,
595 deferral: AppUpdateDeferral = appUpdateDeferral,
596): () => void {
597 let started = false;
598
599 return () => {
600 if (started || !isAppUpdateCheckAvailable(client)) return;
601 started = true;
602 void import("react-native").then(({ AppState }) => {
603 let backgroundedAtMs: number | null = null;
604 AppState.addEventListener("change", (state) => {
605 if (state === "background") {
606 backgroundedAtMs = Date.now();
607 return;
608 }
609 if (state !== "active") return;
610 const shouldCheck = shouldRecheckAppUpdateOnForeground(
611 backgroundedAtMs,
612 Date.now(),
613 deferral.pendingInstall,
614 );
615 backgroundedAtMs = null;
616 if (shouldCheck) void runAppUpdateCheck({ client, deferral });
617 });
618 });
619 };
620}
621
622export const startAppUpdateForegroundRecheck = createAppUpdateForegroundRecheck();