Skip to content
Source
Sign inSign up

app-updates.ts

509 lines16.8 KB

1import * as Updates from "expo-updates";
2// Metro namespace imports evaluate unrelated React Native getters, including unavailable native modules.
3import { AppState } from "react-native";
4​
5import {
6 type AtomCommandResult,
7 isAtomCommandInterrupted,
8 reportAtomCommandResult,
9 settlePromise,
10 squashAtomCommandFailure,
11} from "@t3tools/client-runtime/state/runtime";
12​
13export type AppUpdateCheckState =
14 | "idle"
15 | "checking"
16 | "downloading"
17 | "ready"
18 | "restarting"
19 | "current";
20​
21export interface AppUpdateClient {
22 readonly isEnabled: boolean;
23 readonly checkForUpdateAsync: () => Promise<{
24 readonly isAvailable: boolean;
25 readonly isRollBackToEmbedded: boolean;
26 }>;
27 readonly fetchUpdateAsync: () => Promise<{
28 readonly isNew: boolean;
29 readonly isRollBackToEmbedded: boolean;
30 }>;
31 readonly reloadAsync: () => Promise<void>;
33​
34export interface AppUpdateEnvironment {
35 readonly confirmInstallNow: () => Promise<boolean>;
36 readonly flushPendingWrites: () => Promise<void>;
37 /** Re-check background state after flushing; exclude app-initiated handoffs such as image pickers. */
38 readonly isSafeToRestartInBackground: () => Promise<boolean>;
39 /** includeCurrent covers module-load backgrounding; omit after failure to prevent tight same-session retries. */
40 readonly onNextBackground: (apply: () => void, includeCurrent: boolean) => void;
41 readonly onForegroundStay: (apply: () => void) => void;
43​
44export interface AppUpdateDeferral {
45 pendingInstall: boolean;
46 installInProgress: boolean;
48​
49export function createAppUpdateDeferral(): AppUpdateDeferral {
50 return { pendingInstall: false, installInProgress: false };
52​
53const appUpdateDeferral = createAppUpdateDeferral();
54​
55interface AppUpdateCheckOptions {
56 readonly applyMode?: "background" | "immediate";
57 readonly client?: AppUpdateClient;
58 readonly deferral?: AppUpdateDeferral;
59 readonly environment?: AppUpdateEnvironment;
60 readonly onFailure?: (message: string) => void;
61 readonly onStateChange?: (state: AppUpdateCheckState) => void;
63​
64interface AppUpdateCheckProgress {
65 failure: string | undefined;
66 state: AppUpdateCheckState | undefined;
68​
69interface AppUpdateCheckInFlight {
70 readonly failureListeners: Set<NonNullable<AppUpdateCheckOptions["onFailure"]>>;
71 readonly progress: AppUpdateCheckProgress;
72 readonly promise: Promise<void>;
73 readonly stateListeners: Set<NonNullable<AppUpdateCheckOptions["onStateChange"]>>;
75​
76interface Deferred {
77 readonly promise: Promise<void>;
78 readonly reject: (cause: unknown) => void;
79 readonly resolve: () => void;
81​
82const HIDDEN_UPDATE_TAP_COUNT = 5;
83const UPDATE_CHECK_UNAVAILABLE_ERROR_CODES = new Set([
84 "ERR_NOT_AVAILABLE_IN_DEV_CLIENT",
85 "ERR_UPDATES_DISABLED",
86]);
87let appUpdateCheckInFlight: AppUpdateCheckInFlight | undefined;
88​
89/** Expo's development launcher reports updates as enabled even though its OTA APIs reject. */
90export function isAppUpdateCheckAvailable(client: Pick<AppUpdateClient, "isEnabled"> = Updates) {
91 return client.isEnabled && !(typeof __DEV__ !== "undefined" && __DEV__);
93​
94export function registerHiddenUpdateTap(count: number): {
95 readonly nextCount: number;
96 readonly shouldCheck: boolean;
97} {
98 const nextCount = count + 1;
99 if (nextCount >= HIDDEN_UPDATE_TAP_COUNT) {
100 return {
101 nextCount: 0,
102 shouldCheck: true,
103 };
104 }
105 return {
106 nextCount,
107 shouldCheck: false,
108 };
110​
111export async function runAppUpdateCheck(options: AppUpdateCheckOptions = {}): Promise<void> {
112 const client = options.client ?? Updates;
113 if (!isAppUpdateCheckAvailable(client)) return;
114​
115 if (appUpdateCheckInFlight) {
116 await observeAppUpdateCheck(appUpdateCheckInFlight, options);
117 if (options.applyMode === "immediate") {
118 const deferral = options.deferral ?? appUpdateDeferral;
119 if (deferral.pendingInstall) {
120 const environment = options.environment ?? defaultAppUpdateEnvironment;
121 await installPendingAppUpdate(client, environment, deferral, options);
122 }
123 }
124 return;
125 }
126​
127 const progress: AppUpdateCheckProgress = {
128 failure: undefined,
129 state: undefined,
130 };
131 const failureListeners = new Set<NonNullable<AppUpdateCheckOptions["onFailure"]>>();
132 const stateListeners = new Set<NonNullable<AppUpdateCheckOptions["onStateChange"]>>();
133 if (options.onFailure) failureListeners.add(options.onFailure);
134 if (options.onStateChange) stateListeners.add(options.onStateChange);
135​
136 const deferred = createDeferred();
137 const inFlight: AppUpdateCheckInFlight = {
138 failureListeners,
139 progress,
140 promise: deferred.promise,
141 stateListeners,
142 };
143 // Publish the operation before any state listener can synchronously re-enter.
144 appUpdateCheckInFlight = inFlight;
145​
146 const execution = performAppUpdateCheck(client, {
147 applyMode: options.applyMode,
148 deferral: options.deferral,
149 environment: options.environment,
150 onFailure: (message) => {
151 progress.failure = message;
152 notifyListeners(failureListeners, message);
153 },
154 onStateChange: (state) => {
155 progress.state = state;
156 notifyListeners(stateListeners, state);
157 },
158 });
159 void execution.then(deferred.resolve, deferred.reject);
160​
161 try {
162 await deferred.promise;
163 } finally {
164 if (appUpdateCheckInFlight === inFlight) {
165 appUpdateCheckInFlight = undefined;
166 }
167 }
169​
170function createDeferred(): Deferred {
171 let reject!: Deferred["reject"];
172 let resolve!: Deferred["resolve"];
173 const promise = new Promise<void>((resolvePromise, rejectPromise) => {
174 resolve = () => resolvePromise();
175 reject = rejectPromise;
176 });
177 return { promise, reject, resolve };
179​
180function notifyListeners<A>(listeners: ReadonlySet<(value: A) => void>, value: A): void {
181 // Snapshot listeners so reentrant subscribers receive only observeAppUpdateCheck's explicit replay.
182 const snapshot = Array.from(listeners);
183 for (const listener of snapshot) listener(value);
185​
186async function observeAppUpdateCheck(
187 inFlight: AppUpdateCheckInFlight,
188 options: AppUpdateCheckOptions,
189): Promise<void> {
190 const onFailure = options.onFailure;
191 const onStateChange = options.onStateChange;
192​
193 if (onFailure) {
194 inFlight.failureListeners.add(onFailure);
195 if (inFlight.progress.failure) onFailure(inFlight.progress.failure);
196 }
197 if (onStateChange) {
198 inFlight.stateListeners.add(onStateChange);
199 if (inFlight.progress.state) onStateChange(inFlight.progress.state);
200 }
201​
202 try {
203 await inFlight.promise;
204 } finally {
205 if (onFailure) inFlight.failureListeners.delete(onFailure);
206 if (onStateChange) inFlight.stateListeners.delete(onStateChange);
207 }
209​
210async function performAppUpdateCheck(
211 client: AppUpdateClient,
212 options: AppUpdateCheckOptions,
213): Promise<void> {
214 const setState = options.onStateChange ?? (() => {});
215 const environment = options.environment ?? defaultAppUpdateEnvironment;
216 const deferral = options.deferral ?? appUpdateDeferral;
217​
218 if (options.applyMode === "immediate" && deferral.pendingInstall) {
219 await installPendingAppUpdate(client, environment, deferral, options);
220 return;
221 }
222​
223 setState("checking");
224 const check = await settlePromise(() => client.checkForUpdateAsync());
225 if (check._tag === "Failure") {
226 reportUpdateFailure(check, "Could not check for updates.", options.onFailure);
227 setState("idle");
228 return;
229 }
230 // Rollback directives report isAvailable: false and isRollBackToEmbedded: true; still drop the running OTA.
231 if (!check.value.isAvailable && !check.value.isRollBackToEmbedded) {
232 setState("current");
233 return;
234 }
235​
236 setState("downloading");
237 const fetched = await settlePromise(() => client.fetchUpdateAsync());
238 if (fetched._tag === "Failure") {
239 reportUpdateFailure(fetched, "Could not download the update.", options.onFailure);
240 setState("idle");
241 return;
242 }
243 // isNew is always false for a rollback, so it cannot be the sole gate.
244 if (!fetched.value.isNew && !fetched.value.isRollBackToEmbedded) {
245 setState("current");
246 return;
247 }
248​
249 if (options.applyMode === "immediate" || fetched.value.isRollBackToEmbedded) {
250 const outcome = await installAppUpdate(
251 client,
252 environment,
253 deferral,
254 options,
255 options.applyMode === "immediate",
256 );
257 if (outcome === "flush-failed") {
258 setState("ready");
259 armDeferredAppUpdateInstall(client, environment, deferral);
260 }
261 return;
262 }
263​
264 setState("ready");
265 armDeferredAppUpdateInstall(client, environment, deferral);
267​
268type AppUpdateInstallOutcome = "installed" | "flush-failed" | "restart-failed";
269​
270/** Flush persistence and wait for backgrounding before restarting mounted native surfaces. */
271async function installAppUpdate(
272 client: AppUpdateClient,
273 environment: AppUpdateEnvironment,
274 deferral: AppUpdateDeferral,
275 options: AppUpdateCheckOptions,
276 userRequested: boolean,
277): Promise<AppUpdateInstallOutcome> {
278 if (deferral.installInProgress) return "installed";
279 deferral.installInProgress = true;
280 const setState = options.onStateChange ?? (() => {});
281 setState("restarting");
282 const flushed = await settlePromise(() => environment.flushPendingWrites());
283 if (flushed._tag === "Failure") {
284 reportUpdateFailure(flushed, "Could not save pending state.", undefined);
285 if (!userRequested) {
286 deferral.installInProgress = false;
287 return "flush-failed";
288 }
289 }
290 const reloaded = await settlePromise(() => client.reloadAsync());
291 if (reloaded._tag === "Failure") {
292 reportUpdateFailure(reloaded, "Downloaded, but could not restart the app.", options.onFailure);
293 setState("idle");
294 deferral.installInProgress = false;
295 return "restart-failed";
296 }
297 return "installed";
299​
300async function installPendingAppUpdate(
301 client: AppUpdateClient,
302 environment: AppUpdateEnvironment,
303 deferral: AppUpdateDeferral,
304 options: AppUpdateCheckOptions,
305): Promise<void> {
306 const outcome = await installAppUpdate(client, environment, deferral, options, true);
307 if (outcome === "restart-failed") {
308 deferral.pendingInstall = false;
309 }
311​
312function armDeferredAppUpdateInstall(
313 client: AppUpdateClient,
314 environment: AppUpdateEnvironment,
315 deferral: AppUpdateDeferral,
316): void {
317 if (deferral.pendingInstall) return;
318 deferral.pendingInstall = true;
319 scheduleDeferredAppUpdateInstall(client, environment, deferral, true);
320 environment.onForegroundStay(() => {
321 void promptDeferredAppUpdateInstall(client, environment, deferral);
322 });
324​
325async function promptDeferredAppUpdateInstall(
326 client: AppUpdateClient,
327 environment: AppUpdateEnvironment,
328 deferral: AppUpdateDeferral,
329): Promise<void> {
330 if (!deferral.pendingInstall || deferral.installInProgress) return;
331 const installNow = await settlePromise(() => environment.confirmInstallNow());
332 if (installNow._tag !== "Success" || !installNow.value) return;
333 // Backgrounding may already have started deferred restart while the alert was open; ignore stale acceptance.
334 if (!deferral.pendingInstall || deferral.installInProgress) return;
335 await installPendingAppUpdate(client, environment, deferral, {});
337​
338function scheduleDeferredAppUpdateInstall(
339 client: AppUpdateClient,
340 environment: AppUpdateEnvironment,
341 deferral: AppUpdateDeferral,
342 includeCurrent: boolean,
343): void {
344 environment.onNextBackground(() => {
345 void applyDeferredAppUpdateInstall(client, environment, deferral);
346 }, includeCurrent);
348​
349async function applyDeferredAppUpdateInstall(
350 client: AppUpdateClient,
351 environment: AppUpdateEnvironment,
352 deferral: AppUpdateDeferral,
353): Promise<void> {
354 if (!deferral.pendingInstall || deferral.installInProgress) return;
355 deferral.installInProgress = true;
356 const flushed = await settlePromise(() => environment.flushPendingWrites());
357 const safe = await settlePromise(() => environment.isSafeToRestartInBackground());
358 if (flushed._tag === "Failure" || safe._tag !== "Success" || !safe.value) {
359 if (flushed._tag === "Failure") {
360 reportUpdateFailure(flushed, "Could not save pending state.", undefined);
361 }
362 deferral.installInProgress = false;
363 // Wait for a fresh background transition after failure to avoid same-session retry loops.
364 scheduleDeferredAppUpdateInstall(client, environment, deferral, false);
365 return;
366 }
367 const reloaded = await settlePromise(() => client.reloadAsync());
368 if (reloaded._tag === "Failure") {
369 reportUpdateFailure(reloaded, "Downloaded, but could not restart the app.", undefined);
370 deferral.installInProgress = false;
371 deferral.pendingInstall = false;
372 }
374​
375async function defaultConfirmInstallNow(): Promise<boolean> {
376 const Dialog = await import("../../components/ui/Dialog");
377 return new Promise<boolean>((resolve) => {
378 Dialog.alert(
379 "Update ready",
380 "A new version has been downloaded and installs automatically the next time you leave the app. Install it now instead?",
381 [
382 { onPress: () => resolve(false), style: "cancel", text: "Later" },
383 { onPress: () => resolve(true), text: "Install Now" },
384 ],
385 { cancelable: true, onDismiss: () => resolve(false) },
386 );
387 });
389​
390async function defaultFlushPendingWrites(): Promise<void> {
391 const results = await Promise.allSettled([
392 import("../../state/use-composer-drafts").then((drafts) => drafts.flushComposerDrafts()),
393 import("../../state/thread-outbox").then((outbox) => outbox.flushThreadOutbox()),
394 ]);
395 const failed = results.find(
396 (result): result is PromiseRejectedResult => result.status === "rejected",
397 );
398 if (failed) throw failed.reason;
400​
401async function defaultIsSafeToRestartInBackground(): Promise<boolean> {
402 const { isForegroundHandoffActive } = await import("../../lib/foreground-handoff");
403 if (isForegroundHandoffActive()) return false;
404 return AppState.currentState === "background";
406​
407function defaultOnNextBackground(apply: () => void, includeCurrent: boolean): void {
408 const subscription = AppState.addEventListener("change", (state) => {
409 if (state !== "background") return;
410 subscription.remove();
411 apply();
412 });
413 if (includeCurrent && AppState.currentState === "background") {
414 subscription.remove();
415 apply();
416 }
418​
419const DEFERRED_INSTALL_PROMPT_AFTER_MS = 30 * 60 * 1000;
420​
421/** iOS inactive blips, including the app switcher, leave this timer running. */
422function defaultOnForegroundStay(apply: () => void): void {
423 let timer: ReturnType<typeof setTimeout> | undefined;
424 const arm = () => {
425 timer ??= setTimeout(() => {
426 subscription.remove();
427 apply();
428 }, DEFERRED_INSTALL_PROMPT_AFTER_MS);
429 };
430 const disarm = () => {
431 if (timer === undefined) return;
432 clearTimeout(timer);
433 timer = undefined;
434 };
435 const subscription = AppState.addEventListener("change", (state) => {
436 if (state === "active") arm();
437 else if (state === "background") disarm();
438 });
439 if (AppState.currentState === "active") arm();
441​
442const defaultAppUpdateEnvironment: AppUpdateEnvironment = {
443 confirmInstallNow: defaultConfirmInstallNow,
444 flushPendingWrites: defaultFlushPendingWrites,
445 isSafeToRestartInBackground: defaultIsSafeToRestartInBackground,
446 onNextBackground: defaultOnNextBackground,
447 onForegroundStay: defaultOnForegroundStay,
448};
449​
450function reportUpdateFailure(
451 result: AtomCommandResult<unknown, unknown>,
452 fallback: string,
453 onFailure: AppUpdateCheckOptions["onFailure"],
454): void {
455 if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
456 const error = squashAtomCommandFailure(result);
457 if (isAppUpdateUnavailableError(error)) return;
458​
459 reportAtomCommandResult(result, { label: "app update check" });
460 onFailure?.(error instanceof Error ? error.message : fallback);
462​
463function isAppUpdateUnavailableError(error: unknown): boolean {
464 if (typeof error !== "object" || error === null || !("code" in error)) return false;
465 const code = error.code;
466 return typeof code === "string" && UPDATE_CHECK_UNAVAILABLE_ERROR_CODES.has(code);
468​
469const FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS = 15 * 60 * 1000;
470​
471function shouldRecheckAppUpdateOnForeground(
472 backgroundedAtMs: number | null,
473 activeAtMs: number,
474 pendingInstall: boolean,
475): boolean {
476 if (pendingInstall) return false;
477 return (
478 backgroundedAtMs !== null &&
479 activeAtMs - backgroundedAtMs >= FOREGROUND_APP_UPDATE_RECHECK_AFTER_MS
480 );
482​
483/**
484 * App-lifetime update checks: one at launch, then again when the app returns
485 * from a long stay in the background.
486 */
487export function installAppUpdateChecks(
488 client: AppUpdateClient = Updates,
489 deferral: AppUpdateDeferral = appUpdateDeferral,
490): () => void {
491 if (!isAppUpdateCheckAvailable(client)) return () => undefined;
492 void runAppUpdateCheck({ client, deferral });
493 let backgroundedAtMs: number | null = null;
494 const subscription = AppState.addEventListener("change", (state) => {
495 if (state === "background") {
496 backgroundedAtMs = Date.now();
497 return;
498 }
499 if (state !== "active") return;
500 const shouldCheck = shouldRecheckAppUpdateOnForeground(
501 backgroundedAtMs,
502 Date.now(),
503 deferral.pendingInstall,
504 );
505 backgroundedAtMs = null;
506 if (shouldCheck) void runAppUpdateCheck({ client, deferral });
507 });
508 return () => subscription.remove();