authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-30 16:57:00+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-30 16:57:00+02:00
log6154b46cb86b038f1b54c32690a794ac639520ca
treef56c44e2ecb001825a352fe8ec87a626fc0568c5
parentabc409c2d4a072c2de46c9015f5cffff00dcc46b
signature Signed by PGP key B5690EEEBB952194

Check for mobile app updates on launch (#4958)

Co-authored-by: codex <codex@users.noreply.github.com>

4 files changed, 512 insertions(+), 91 deletions(-)

apps/mobile/src/features/home/HomeRouteScreen.tsx+6
......@@ -11,6 +11,7 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re
1111import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout";
1212import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail";
1313import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar";
14import { checkForAppUpdateOnLaunch } from "../updates/app-updates";
1415import { AndroidHomeFabLayout } from "./AndroidHomeFab";
1516import { HomeScreen } from "./HomeScreen";
1617import { HomeHeader } from "./HomeHeader";
......@@ -29,6 +30,11 @@ export function HomeRouteScreen() {
2930 const { savedConnectionsById } = useSavedRemoteConnections();
3031 const navigation = useNavigation();
3132 const [searchQuery, setSearchQuery] = useState("");
33
34 useEffect(() => {
35 void checkForAppUpdateOnLaunch();
36 }, []);
37
3238 const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } =
3339 useThreadListActions();
3440 const pendingTasks = usePendingNewTasks();
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+26-91
......@@ -9,19 +9,10 @@ import { SymbolView } from "../../components/AppSymbol";
99import * as Effect from "effect/Effect";
1010import { AsyncResult } from "effect/unstable/reactivity";
1111import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
12import {
13 ActivityIndicator,
14 Alert,
15 Linking,
16 Platform,
17 Pressable,
18 ScrollView,
19 View,
20} from "react-native";
12import { Alert, Linking, Platform, Pressable, ScrollView, View } from "react-native";
2113import { useSafeAreaInsets } from "react-native-safe-area-context";
2214
2315import {
24 type AtomCommandResult,
2516 isAtomCommandInterrupted,
2617 reportAtomCommandResult,
2718 settleAsyncResult,
......@@ -47,6 +38,11 @@ import { runtime } from "../../lib/runtime";
4738import { useThemeColor } from "../../lib/useThemeColor";
4839import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences";
4940import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled";
41import {
42 type AppUpdateCheckState,
43 registerHiddenUpdateTap,
44 runAppUpdateCheck,
45} from "../updates/app-updates";
5046import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry";
5147import { SettingsRow } from "./components/SettingsRow";
5248import { SettingsSection } from "./components/SettingsSection";
......@@ -578,12 +574,11 @@ function BetaSettingsSection() {
578574 );
579575}
580576
581type UpdateCheckState = "idle" | "checking" | "downloading" | "restarting" | "current";
582
583577function AppSettingsSection() {
584578 const icon = useThemeColor("--color-icon");
585 const [updateState, setUpdateState] = useState<UpdateCheckState>("idle");
579 const [updateState, setUpdateState] = useState<AppUpdateCheckState>("idle");
586580 const updateInFlight = useRef(false);
581 const hiddenUpdateTapCount = useRef(0);
587582
588583 const version = Constants.expoConfig?.version ?? "0.0.0";
589584 // Fall back to "production" to match resolveAppVariant in app.config.ts, so a
......@@ -591,22 +586,11 @@ function AppSettingsSection() {
591586 const variant = (Constants.expoConfig?.extra?.appVariant as string | undefined) ?? "production";
592587 const variantLabel = variant === "production" ? "" : capitalize(variant);
593588 const versionLabel = variantLabel ? `${version} · ${variantLabel}` : version;
594 // Which JS is actually running: the bundle shipped in the binary, or an OTA
595 // update downloaded on top of it. Surfacing this makes "am I even on the
596 // right build?" answerable at a glance.
597 const bundleLabel = Updates.isEnabled
598 ? Updates.isEmbeddedLaunch
599 ? "Embedded"
600 : Updates.updateId
601 ? `OTA ${Updates.updateId.slice(0, 7)}`
602 : null
603 : null;
604
605589 const busy =
606590 updateState === "checking" || updateState === "downloading" || updateState === "restarting";
607591
608592 // "Up to date" is a transient acknowledgement, not a state worth persisting —
609 // drop back to the bundle label so the row keeps answering "what am I running?".
593 // return the version row to its normal, deliberately quiet state.
610594 useEffect(() => {
611595 if (updateState !== "current") return;
612596 const timer = setTimeout(() => setUpdateState("idle"), 3000);
......@@ -619,12 +603,24 @@ function AppSettingsSection() {
619603 if (updateInFlight.current) return;
620604 updateInFlight.current = true;
621605 try {
622 await runUpdateCheck(setUpdateState);
606 await runAppUpdateCheck({
607 onFailure: (message) => Alert.alert("Update failed", message),
608 onStateChange: setUpdateState,
609 });
623610 } finally {
624611 updateInFlight.current = false;
625612 }
626613 }, []);
627614
615 const handleVersionPress = useCallback(() => {
616 if (!Updates.isEnabled || updateInFlight.current) return;
617 const tap = registerHiddenUpdateTap(hiddenUpdateTapCount.current);
618 hiddenUpdateTapCount.current = tap.nextCount;
619 if (tap.shouldCheck) {
620 void checkForUpdate();
621 }
622 }, [checkForUpdate]);
623
628624 const statusLabel =
629625 updateState === "checking"
630626 ? "Checking…"
......@@ -634,7 +630,7 @@ function AppSettingsSection() {
634630 ? "Restarting…"
635631 : updateState === "current"
636632 ? "Up to date"
637 : bundleLabel;
633 : null;
638634
639635 const versionRow = (
640636 <View className="flex-row items-center gap-4 p-4">
......@@ -652,21 +648,6 @@ function AppSettingsSection() {
652648 <Text className="text-xs text-foreground-muted/70">{statusLabel}</Text>
653649 ) : null}
654650 </View>
655 {Updates.isEnabled ? (
656 <View className="w-[22px] items-center">
657 {busy ? (
658 <ActivityIndicator color={icon} size="small" />
659 ) : (
660 <SymbolView
661 name="arrow.clockwise"
662 size={18}
663 tintColor={icon}
664 type="monochrome"
665 weight="semibold"
666 />
667 )}
668 </View>
669 ) : null}
670651 </View>
671652 );
672653
......@@ -676,10 +657,10 @@ function AppSettingsSection() {
676657 <SettingsRow icon="doc.text" label="Legal" fullScreenTarget="SettingsLegal" />
677658 {Updates.isEnabled ? (
678659 <Pressable
679 accessibilityLabel="Check for updates"
680 accessibilityRole="button"
660 accessibilityLabel={`Version ${versionLabel}`}
661 accessibilityRole="text"
681662 disabled={busy}
682 onPress={() => void checkForUpdate()}
663 onPress={handleVersionPress}
683664 >
684665 {versionRow}
685666 </Pressable>
......@@ -690,52 +671,6 @@ function AppSettingsSection() {
690671 );
691672}
692673
693async function runUpdateCheck(setUpdateState: (state: UpdateCheckState) => void): Promise<void> {
694 setUpdateState("checking");
695 const check = await settlePromise(() => Updates.checkForUpdateAsync());
696 if (check._tag === "Failure") {
697 reportUpdateFailure(check, "Could not check for updates.");
698 setUpdateState("idle");
699 return;
700 }
701 // A rollback directive (`eas update:rollback`) arrives as isAvailable: false
702 // with isRollBackToEmbedded: true — there is nothing newer to install, but the
703 // running OTA still has to be dropped for the embedded bundle.
704 if (!check.value.isAvailable && !check.value.isRollBackToEmbedded) {
705 setUpdateState("current");
706 return;
707 }
708
709 setUpdateState("downloading");
710 const fetched = await settlePromise(() => Updates.fetchUpdateAsync());
711 if (fetched._tag === "Failure") {
712 reportUpdateFailure(fetched, "Could not download the update.");
713 setUpdateState("idle");
714 return;
715 }
716 // isNew is always false for a rollback, so it can't be the sole gate here either.
717 if (!fetched.value.isNew && !fetched.value.isRollBackToEmbedded) {
718 setUpdateState("current");
719 return;
720 }
721
722 setUpdateState("restarting");
723 // reloadAsync never resolves on success — the JS context is torn down — so
724 // reaching the failure branch below is the only way this returns.
725 const reloaded = await settlePromise(() => Updates.reloadAsync());
726 if (reloaded._tag === "Failure") {
727 reportUpdateFailure(reloaded, "Downloaded, but could not restart the app.");
728 setUpdateState("idle");
729 }
730}
731
732function reportUpdateFailure(result: AtomCommandResult<unknown, unknown>, fallback: string): void {
733 reportAtomCommandResult(result, { label: "app update check" });
734 if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
735 const error = squashAtomCommandFailure(result);
736 Alert.alert("Update failed", error instanceof Error ? error.message : fallback);
737}
738
739674function capitalize(value: string): string {
740675 return value.length > 0 ? value.charAt(0).toUpperCase() + value.slice(1) : value;
741676}
apps/mobile/src/features/updates/app-updates.test.ts created+252
......@@ -0,0 +1,252 @@
1import { describe, expect, it, vi } from "vite-plus/test";
2
3import {
4 createAppUpdateLaunchCheck,
5 registerHiddenUpdateTap,
6 runAppUpdateCheck,
7 type AppUpdateCheckState,
8 type AppUpdateClient,
9} from "./app-updates";
10
11vi.mock("expo-updates", () => ({
12 isEnabled: true,
13 checkForUpdateAsync: vi.fn(),
14 fetchUpdateAsync: vi.fn(),
15 reloadAsync: vi.fn(),
16}));
17
18function makeUpdateClient(overrides: Partial<AppUpdateClient> = {}): AppUpdateClient {
19 return {
20 isEnabled: true,
21 checkForUpdateAsync: vi.fn(async () => ({
22 isAvailable: false,
23 isRollBackToEmbedded: false,
24 })),
25 fetchUpdateAsync: vi.fn(async () => ({
26 isNew: true,
27 isRollBackToEmbedded: false,
28 })),
29 reloadAsync: vi.fn(async () => {}),
30 ...overrides,
31 };
32}
33
34describe("runAppUpdateCheck", () => {
35 it("downloads and restarts when a new update is available", async () => {
36 const client = makeUpdateClient({
37 checkForUpdateAsync: vi.fn(async () => ({
38 isAvailable: true,
39 isRollBackToEmbedded: false,
40 })),
41 });
42 const states: AppUpdateCheckState[] = [];
43
44 await runAppUpdateCheck({ client, onStateChange: (state) => states.push(state) });
45
46 expect(client.checkForUpdateAsync).toHaveBeenCalledOnce();
47 expect(client.fetchUpdateAsync).toHaveBeenCalledOnce();
48 expect(client.reloadAsync).toHaveBeenCalledOnce();
49 expect(states).toEqual(["checking", "downloading", "restarting"]);
50 });
51
52 it("restarts into the embedded bundle for a rollback directive", async () => {
53 const client = makeUpdateClient({
54 checkForUpdateAsync: vi.fn(async () => ({
55 isAvailable: false,
56 isRollBackToEmbedded: true,
57 })),
58 fetchUpdateAsync: vi.fn(async () => ({
59 isNew: false,
60 isRollBackToEmbedded: true,
61 })),
62 });
63
64 await runAppUpdateCheck({ client });
65
66 expect(client.fetchUpdateAsync).toHaveBeenCalledOnce();
67 expect(client.reloadAsync).toHaveBeenCalledOnce();
68 });
69
70 it("stops quietly when the running bundle is current", async () => {
71 const client = makeUpdateClient();
72 const states: AppUpdateCheckState[] = [];
73
74 await runAppUpdateCheck({ client, onStateChange: (state) => states.push(state) });
75
76 expect(client.fetchUpdateAsync).not.toHaveBeenCalled();
77 expect(client.reloadAsync).not.toHaveBeenCalled();
78 expect(states).toEqual(["checking", "current"]);
79 });
80
81 it("reports manual failures without continuing the update", async () => {
82 const reportError = vi.spyOn(console, "error").mockImplementation(() => {});
83 const client = makeUpdateClient({
84 checkForUpdateAsync: vi.fn(async () => {
85 throw new Error("offline");
86 }),
87 });
88 const failures: string[] = [];
89 const states: AppUpdateCheckState[] = [];
90
91 await runAppUpdateCheck({
92 client,
93 onFailure: (message) => failures.push(message),
94 onStateChange: (state) => states.push(state),
95 });
96
97 expect(client.fetchUpdateAsync).not.toHaveBeenCalled();
98 expect(failures).toEqual(["offline"]);
99 expect(states).toEqual(["checking", "idle"]);
100 reportError.mockRestore();
101 });
102
103 it("coalesces overlapping launch and manual checks", async () => {
104 let resolveCheck!: (result: {
105 readonly isAvailable: boolean;
106 readonly isRollBackToEmbedded: boolean;
107 }) => void;
108 const checkResult = new Promise<{
109 readonly isAvailable: boolean;
110 readonly isRollBackToEmbedded: boolean;
111 }>((resolve) => {
112 resolveCheck = resolve;
113 });
114 const client = makeUpdateClient({
115 checkForUpdateAsync: vi.fn(() => checkResult),
116 });
117 const checkOnLaunch = createAppUpdateLaunchCheck(client);
118 const manualStates: AppUpdateCheckState[] = [];
119
120 const launchCheck = checkOnLaunch();
121 const manualCheck = runAppUpdateCheck({
122 client,
123 onStateChange: (state) => manualStates.push(state),
124 });
125
126 expect(client.checkForUpdateAsync).toHaveBeenCalledOnce();
127 expect(manualStates).toEqual(["checking"]);
128
129 resolveCheck({
130 isAvailable: false,
131 isRollBackToEmbedded: false,
132 });
133 await Promise.all([launchCheck, manualCheck]);
134
135 expect(manualStates).toEqual(["checking", "current"]);
136
137 await runAppUpdateCheck({ client });
138 expect(client.checkForUpdateAsync).toHaveBeenCalledTimes(2);
139 });
140
141 it("forwards failures to a manual check coalesced with the launch check", async () => {
142 const reportError = vi.spyOn(console, "error").mockImplementation(() => {});
143 let rejectCheck!: (error: Error) => void;
144 const checkResult = new Promise<{
145 readonly isAvailable: boolean;
146 readonly isRollBackToEmbedded: boolean;
147 }>((_resolve, reject) => {
148 rejectCheck = reject;
149 });
150 const client = makeUpdateClient({
151 checkForUpdateAsync: vi.fn(() => checkResult),
152 });
153 const checkOnLaunch = createAppUpdateLaunchCheck(client);
154 const failures: string[] = [];
155 const manualStates: AppUpdateCheckState[] = [];
156
157 const launchCheck = checkOnLaunch();
158 const manualCheck = runAppUpdateCheck({
159 client,
160 onFailure: (message) => failures.push(message),
161 onStateChange: (state) => manualStates.push(state),
162 });
163
164 rejectCheck(new Error("offline"));
165 await Promise.all([launchCheck, manualCheck]);
166
167 expect(client.checkForUpdateAsync).toHaveBeenCalledOnce();
168 expect(failures).toEqual(["offline"]);
169 expect(manualStates).toEqual(["checking", "idle"]);
170 reportError.mockRestore();
171 });
172
173 it("publishes the in-flight check before a state callback can re-enter", async () => {
174 let resolveCheck!: (result: {
175 readonly isAvailable: boolean;
176 readonly isRollBackToEmbedded: boolean;
177 }) => void;
178 const checkResult = new Promise<{
179 readonly isAvailable: boolean;
180 readonly isRollBackToEmbedded: boolean;
181 }>((resolve) => {
182 resolveCheck = resolve;
183 });
184 const client = makeUpdateClient({
185 checkForUpdateAsync: vi.fn(() => checkResult),
186 });
187 const reentrantStates: AppUpdateCheckState[] = [];
188 let reentrantCheck: Promise<void> | undefined;
189 let didReenter = false;
190
191 const initialCheck = runAppUpdateCheck({
192 client,
193 onStateChange: (state) => {
194 if (state !== "checking" || didReenter) return;
195 didReenter = true;
196 reentrantCheck = runAppUpdateCheck({
197 client,
198 onStateChange: (reentrantState) => reentrantStates.push(reentrantState),
199 });
200 },
201 });
202
203 expect(client.checkForUpdateAsync).toHaveBeenCalledOnce();
204 expect(reentrantCheck).toBeDefined();
205 expect(reentrantStates).toEqual(["checking"]);
206
207 resolveCheck({
208 isAvailable: false,
209 isRollBackToEmbedded: false,
210 });
211 await Promise.all([initialCheck, reentrantCheck]);
212
213 expect(client.checkForUpdateAsync).toHaveBeenCalledOnce();
214 expect(reentrantStates).toEqual(["checking", "current"]);
215 });
216});
217
218describe("createAppUpdateLaunchCheck", () => {
219 it("checks at most once for each JavaScript launch", async () => {
220 const client = makeUpdateClient();
221 const checkOnLaunch = createAppUpdateLaunchCheck(client);
222
223 const first = checkOnLaunch();
224 const second = checkOnLaunch();
225 await first;
226
227 expect(second).toBeUndefined();
228 expect(client.checkForUpdateAsync).toHaveBeenCalledOnce();
229 });
230
231 it("does nothing when Expo updates are disabled", () => {
232 const client = makeUpdateClient({ isEnabled: false });
233 const checkOnLaunch = createAppUpdateLaunchCheck(client);
234
235 expect(checkOnLaunch()).toBeUndefined();
236 expect(client.checkForUpdateAsync).not.toHaveBeenCalled();
237 });
238});
239
240describe("registerHiddenUpdateTap", () => {
241 it("unlocks the manual check on the fifth tap", () => {
242 let count = 0;
243
244 for (let tap = 1; tap <= 5; tap += 1) {
245 const result = registerHiddenUpdateTap(count);
246 expect(result.shouldCheck).toBe(tap === 5);
247 count = result.nextCount;
248 }
249
250 expect(count).toBe(0);
251 });
252});
apps/mobile/src/features/updates/app-updates.ts created+228
......@@ -0,0 +1,228 @@
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 = "idle" | "checking" | "downloading" | "restarting" | "current";
12
13export interface AppUpdateClient {
14 readonly isEnabled: boolean;
15 readonly checkForUpdateAsync: () => Promise<{
16 readonly isAvailable: boolean;
17 readonly isRollBackToEmbedded: boolean;
18 }>;
19 readonly fetchUpdateAsync: () => Promise<{
20 readonly isNew: boolean;
21 readonly isRollBackToEmbedded: boolean;
22 }>;
23 readonly reloadAsync: () => Promise<void>;
24}
25
26interface AppUpdateCheckOptions {
27 readonly client?: AppUpdateClient;
28 readonly onFailure?: (message: string) => void;
29 readonly onStateChange?: (state: AppUpdateCheckState) => void;
30}
31
32interface AppUpdateCheckProgress {
33 failure: string | undefined;
34 state: AppUpdateCheckState | undefined;
35}
36
37interface AppUpdateCheckInFlight {
38 readonly failureListeners: Set<NonNullable<AppUpdateCheckOptions["onFailure"]>>;
39 readonly progress: AppUpdateCheckProgress;
40 readonly promise: Promise<void>;
41 readonly stateListeners: Set<NonNullable<AppUpdateCheckOptions["onStateChange"]>>;
42}
43
44interface Deferred {
45 readonly promise: Promise<void>;
46 readonly reject: (cause: unknown) => void;
47 readonly resolve: () => void;
48}
49
50const HIDDEN_UPDATE_TAP_COUNT = 5;
51let appUpdateCheckInFlight: AppUpdateCheckInFlight | undefined;
52
53/**
54 * Keeps the manual update affordance discoverable only to someone deliberately
55 * tapping the version row five times.
56 */
57export function registerHiddenUpdateTap(count: number): {
58 readonly nextCount: number;
59 readonly shouldCheck: boolean;
60} {
61 const nextCount = count + 1;
62 if (nextCount >= HIDDEN_UPDATE_TAP_COUNT) {
63 return {
64 nextCount: 0,
65 shouldCheck: true,
66 };
67 }
68 return {
69 nextCount,
70 shouldCheck: false,
71 };
72}
73
74export async function runAppUpdateCheck(options: AppUpdateCheckOptions = {}): Promise<void> {
75 const client = options.client ?? Updates;
76 if (!client.isEnabled) return;
77
78 if (appUpdateCheckInFlight) {
79 await observeAppUpdateCheck(appUpdateCheckInFlight, options);
80 return;
81 }
82
83 const progress: AppUpdateCheckProgress = {
84 failure: undefined,
85 state: undefined,
86 };
87 const failureListeners = new Set<NonNullable<AppUpdateCheckOptions["onFailure"]>>();
88 const stateListeners = new Set<NonNullable<AppUpdateCheckOptions["onStateChange"]>>();
89 if (options.onFailure) failureListeners.add(options.onFailure);
90 if (options.onStateChange) stateListeners.add(options.onStateChange);
91
92 const deferred = createDeferred();
93 const inFlight: AppUpdateCheckInFlight = {
94 failureListeners,
95 progress,
96 promise: deferred.promise,
97 stateListeners,
98 };
99 // Publish the operation before any state listener can synchronously re-enter.
100 appUpdateCheckInFlight = inFlight;
101
102 const execution = performAppUpdateCheck(client, {
103 onFailure: (message) => {
104 progress.failure = message;
105 notifyListeners(failureListeners, message);
106 },
107 onStateChange: (state) => {
108 progress.state = state;
109 notifyListeners(stateListeners, state);
110 },
111 });
112 void execution.then(deferred.resolve, deferred.reject);
113
114 try {
115 await deferred.promise;
116 } finally {
117 if (appUpdateCheckInFlight === inFlight) {
118 appUpdateCheckInFlight = undefined;
119 }
120 }
121}
122
123function createDeferred(): Deferred {
124 let reject!: Deferred["reject"];
125 let resolve!: Deferred["resolve"];
126 const promise = new Promise<void>((resolvePromise, rejectPromise) => {
127 resolve = () => resolvePromise();
128 reject = rejectPromise;
129 });
130 return { promise, reject, resolve };
131}
132
133function notifyListeners<A>(listeners: ReadonlySet<(value: A) => void>, value: A): void {
134 // A listener can synchronously subscribe another caller. Snapshot so that
135 // caller receives only observeAppUpdateCheck's explicit current-value replay.
136 const snapshot = Array.from(listeners);
137 for (const listener of snapshot) listener(value);
138}
139
140async function observeAppUpdateCheck(
141 inFlight: AppUpdateCheckInFlight,
142 options: AppUpdateCheckOptions,
143): Promise<void> {
144 const onFailure = options.onFailure;
145 const onStateChange = options.onStateChange;
146
147 if (onFailure) {
148 inFlight.failureListeners.add(onFailure);
149 if (inFlight.progress.failure) onFailure(inFlight.progress.failure);
150 }
151 if (onStateChange) {
152 inFlight.stateListeners.add(onStateChange);
153 if (inFlight.progress.state) onStateChange(inFlight.progress.state);
154 }
155
156 try {
157 await inFlight.promise;
158 } finally {
159 if (onFailure) inFlight.failureListeners.delete(onFailure);
160 if (onStateChange) inFlight.stateListeners.delete(onStateChange);
161 }
162}
163
164async function performAppUpdateCheck(
165 client: AppUpdateClient,
166 options: AppUpdateCheckOptions,
167): Promise<void> {
168 const setState = options.onStateChange ?? (() => {});
169
170 setState("checking");
171 const check = await settlePromise(() => client.checkForUpdateAsync());
172 if (check._tag === "Failure") {
173 reportUpdateFailure(check, "Could not check for updates.", options.onFailure);
174 setState("idle");
175 return;
176 }
177 // A rollback directive (`eas update:rollback`) arrives as isAvailable: false
178 // with isRollBackToEmbedded: true. The running OTA still has to be dropped.
179 if (!check.value.isAvailable && !check.value.isRollBackToEmbedded) {
180 setState("current");
181 return;
182 }
183
184 setState("downloading");
185 const fetched = await settlePromise(() => client.fetchUpdateAsync());
186 if (fetched._tag === "Failure") {
187 reportUpdateFailure(fetched, "Could not download the update.", options.onFailure);
188 setState("idle");
189 return;
190 }
191 // isNew is always false for a rollback, so it cannot be the sole gate.
192 if (!fetched.value.isNew && !fetched.value.isRollBackToEmbedded) {
193 setState("current");
194 return;
195 }
196
197 setState("restarting");
198 const reloaded = await settlePromise(() => client.reloadAsync());
199 if (reloaded._tag === "Failure") {
200 reportUpdateFailure(reloaded, "Downloaded, but could not restart the app.", options.onFailure);
201 setState("idle");
202 }
203}
204
205function reportUpdateFailure(
206 result: AtomCommandResult<unknown, unknown>,
207 fallback: string,
208 onFailure: AppUpdateCheckOptions["onFailure"],
209): void {
210 reportAtomCommandResult(result, { label: "app update check" });
211 if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return;
212 const error = squashAtomCommandFailure(result);
213 onFailure?.(error instanceof Error ? error.message : fallback);
214}
215
216export function createAppUpdateLaunchCheck(
217 client: AppUpdateClient = Updates,
218): () => Promise<void> | undefined {
219 let started = false;
220
221 return () => {
222 if (started || !client.isEnabled) return undefined;
223 started = true;
224 return runAppUpdateCheck({ client });
225 };
226}
227
228export const checkForAppUpdateOnLaunch = createAppUpdateLaunchCheck();