| author | |
| committer | |
| log | 6154b46cb86b038f1b54c32690a794ac639520ca |
| tree | f56c44e2ecb001825a352fe8ec87a626fc0568c5 |
| parent | abc409c2d4a072c2de46c9015f5cffff00dcc46b |
| signature | Signed by PGP key B5690EEEBB952194 |
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 |
| 11 | 11 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; |
| 12 | 12 | import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail"; |
| 13 | 13 | import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; |
| 14 | import { checkForAppUpdateOnLaunch } from "../updates/app-updates"; | |
| 14 | 15 | import { AndroidHomeFabLayout } from "./AndroidHomeFab"; |
| 15 | 16 | import { HomeScreen } from "./HomeScreen"; |
| 16 | 17 | import { HomeHeader } from "./HomeHeader"; |
| ... | ... | @@ -29,6 +30,11 @@ export function HomeRouteScreen() { |
| 29 | 30 | const { savedConnectionsById } = useSavedRemoteConnections(); |
| 30 | 31 | const navigation = useNavigation(); |
| 31 | 32 | const [searchQuery, setSearchQuery] = useState(""); |
| 33 | ||
| 34 | useEffect(() => { | |
| 35 | void checkForAppUpdateOnLaunch(); | |
| 36 | }, []); | |
| 37 | ||
| 32 | 38 | const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } = |
| 33 | 39 | useThreadListActions(); |
| 34 | 40 | const pendingTasks = usePendingNewTasks(); |
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+26-91| ... | ... | @@ -9,19 +9,10 @@ import { SymbolView } from "../../components/AppSymbol"; |
| 9 | 9 | import * as Effect from "effect/Effect"; |
| 10 | 10 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 11 | 11 | import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; |
| 12 | import { | |
| 13 | ActivityIndicator, | |
| 14 | Alert, | |
| 15 | Linking, | |
| 16 | Platform, | |
| 17 | Pressable, | |
| 18 | ScrollView, | |
| 19 | View, | |
| 20 | } from "react-native"; | |
| 12 | import { Alert, Linking, Platform, Pressable, ScrollView, View } from "react-native"; | |
| 21 | 13 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 22 | 14 | |
| 23 | 15 | import { |
| 24 | type AtomCommandResult, | |
| 25 | 16 | isAtomCommandInterrupted, |
| 26 | 17 | reportAtomCommandResult, |
| 27 | 18 | settleAsyncResult, |
| ... | ... | @@ -47,6 +38,11 @@ import { runtime } from "../../lib/runtime"; |
| 47 | 38 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 48 | 39 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; |
| 49 | 40 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; |
| 41 | import { | |
| 42 | type AppUpdateCheckState, | |
| 43 | registerHiddenUpdateTap, | |
| 44 | runAppUpdateCheck, | |
| 45 | } from "../updates/app-updates"; | |
| 50 | 46 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 51 | 47 | import { SettingsRow } from "./components/SettingsRow"; |
| 52 | 48 | import { SettingsSection } from "./components/SettingsSection"; |
| ... | ... | @@ -578,12 +574,11 @@ function BetaSettingsSection() { |
| 578 | 574 | ); |
| 579 | 575 | } |
| 580 | 576 | |
| 581 | type UpdateCheckState = "idle" | "checking" | "downloading" | "restarting" | "current"; | |
| 582 | ||
| 583 | 577 | function AppSettingsSection() { |
| 584 | 578 | const icon = useThemeColor("--color-icon"); |
| 585 | const [updateState, setUpdateState] = useState<UpdateCheckState>("idle"); | |
| 579 | const [updateState, setUpdateState] = useState<AppUpdateCheckState>("idle"); | |
| 586 | 580 | const updateInFlight = useRef(false); |
| 581 | const hiddenUpdateTapCount = useRef(0); | |
| 587 | 582 | |
| 588 | 583 | const version = Constants.expoConfig?.version ?? "0.0.0"; |
| 589 | 584 | // Fall back to "production" to match resolveAppVariant in app.config.ts, so a |
| ... | ... | @@ -591,22 +586,11 @@ function AppSettingsSection() { |
| 591 | 586 | const variant = (Constants.expoConfig?.extra?.appVariant as string | undefined) ?? "production"; |
| 592 | 587 | const variantLabel = variant === "production" ? "" : capitalize(variant); |
| 593 | 588 | 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 | ||
| 605 | 589 | const busy = |
| 606 | 590 | updateState === "checking" || updateState === "downloading" || updateState === "restarting"; |
| 607 | 591 | |
| 608 | 592 | // "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. | |
| 610 | 594 | useEffect(() => { |
| 611 | 595 | if (updateState !== "current") return; |
| 612 | 596 | const timer = setTimeout(() => setUpdateState("idle"), 3000); |
| ... | ... | @@ -619,12 +603,24 @@ function AppSettingsSection() { |
| 619 | 603 | if (updateInFlight.current) return; |
| 620 | 604 | updateInFlight.current = true; |
| 621 | 605 | try { |
| 622 | await runUpdateCheck(setUpdateState); | |
| 606 | await runAppUpdateCheck({ | |
| 607 | onFailure: (message) => Alert.alert("Update failed", message), | |
| 608 | onStateChange: setUpdateState, | |
| 609 | }); | |
| 623 | 610 | } finally { |
| 624 | 611 | updateInFlight.current = false; |
| 625 | 612 | } |
| 626 | 613 | }, []); |
| 627 | 614 | |
| 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 | ||
| 628 | 624 | const statusLabel = |
| 629 | 625 | updateState === "checking" |
| 630 | 626 | ? "Checking…" |
| ... | ... | @@ -634,7 +630,7 @@ function AppSettingsSection() { |
| 634 | 630 | ? "Restarting…" |
| 635 | 631 | : updateState === "current" |
| 636 | 632 | ? "Up to date" |
| 637 | : bundleLabel; | |
| 633 | : null; | |
| 638 | 634 | |
| 639 | 635 | const versionRow = ( |
| 640 | 636 | <View className="flex-row items-center gap-4 p-4"> |
| ... | ... | @@ -652,21 +648,6 @@ function AppSettingsSection() { |
| 652 | 648 | <Text className="text-xs text-foreground-muted/70">{statusLabel}</Text> |
| 653 | 649 | ) : null} |
| 654 | 650 | </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} | |
| 670 | 651 | </View> |
| 671 | 652 | ); |
| 672 | 653 | |
| ... | ... | @@ -676,10 +657,10 @@ function AppSettingsSection() { |
| 676 | 657 | <SettingsRow icon="doc.text" label="Legal" fullScreenTarget="SettingsLegal" /> |
| 677 | 658 | {Updates.isEnabled ? ( |
| 678 | 659 | <Pressable |
| 679 | accessibilityLabel="Check for updates" | |
| 680 | accessibilityRole="button" | |
| 660 | accessibilityLabel={`Version ${versionLabel}`} | |
| 661 | accessibilityRole="text" | |
| 681 | 662 | disabled={busy} |
| 682 | onPress={() => void checkForUpdate()} | |
| 663 | onPress={handleVersionPress} | |
| 683 | 664 | > |
| 684 | 665 | {versionRow} |
| 685 | 666 | </Pressable> |
| ... | ... | @@ -690,52 +671,6 @@ function AppSettingsSection() { |
| 690 | 671 | ); |
| 691 | 672 | } |
| 692 | 673 | |
| 693 | async 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 | ||
| 732 | function 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 | ||
| 739 | 674 | function capitalize(value: string): string { |
| 740 | 675 | return value.length > 0 ? value.charAt(0).toUpperCase() + value.slice(1) : value; |
| 741 | 676 | } |
apps/mobile/src/features/updates/app-updates.test.ts created+252| ... | ... | @@ -0,0 +1,252 @@ |
| 1 | import { describe, expect, it, vi } from "vite-plus/test"; | |
| 2 | ||
| 3 | import { | |
| 4 | createAppUpdateLaunchCheck, | |
| 5 | registerHiddenUpdateTap, | |
| 6 | runAppUpdateCheck, | |
| 7 | type AppUpdateCheckState, | |
| 8 | type AppUpdateClient, | |
| 9 | } from "./app-updates"; | |
| 10 | ||
| 11 | vi.mock("expo-updates", () => ({ | |
| 12 | isEnabled: true, | |
| 13 | checkForUpdateAsync: vi.fn(), | |
| 14 | fetchUpdateAsync: vi.fn(), | |
| 15 | reloadAsync: vi.fn(), | |
| 16 | })); | |
| 17 | ||
| 18 | function 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 | ||
| 34 | describe("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 | ||
| 218 | describe("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 | ||
| 240 | describe("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 @@ |
| 1 | import * as Updates from "expo-updates"; | |
| 2 | ||
| 3 | import { | |
| 4 | type AtomCommandResult, | |
| 5 | isAtomCommandInterrupted, | |
| 6 | reportAtomCommandResult, | |
| 7 | settlePromise, | |
| 8 | squashAtomCommandFailure, | |
| 9 | } from "@t3tools/client-runtime/state/runtime"; | |
| 10 | ||
| 11 | export type AppUpdateCheckState = "idle" | "checking" | "downloading" | "restarting" | "current"; | |
| 12 | ||
| 13 | export 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 | ||
| 26 | interface AppUpdateCheckOptions { | |
| 27 | readonly client?: AppUpdateClient; | |
| 28 | readonly onFailure?: (message: string) => void; | |
| 29 | readonly onStateChange?: (state: AppUpdateCheckState) => void; | |
| 30 | } | |
| 31 | ||
| 32 | interface AppUpdateCheckProgress { | |
| 33 | failure: string | undefined; | |
| 34 | state: AppUpdateCheckState | undefined; | |
| 35 | } | |
| 36 | ||
| 37 | interface 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 | ||
| 44 | interface Deferred { | |
| 45 | readonly promise: Promise<void>; | |
| 46 | readonly reject: (cause: unknown) => void; | |
| 47 | readonly resolve: () => void; | |
| 48 | } | |
| 49 | ||
| 50 | const HIDDEN_UPDATE_TAP_COUNT = 5; | |
| 51 | let appUpdateCheckInFlight: AppUpdateCheckInFlight | undefined; | |
| 52 | ||
| 53 | /** | |
| 54 | * Keeps the manual update affordance discoverable only to someone deliberately | |
| 55 | * tapping the version row five times. | |
| 56 | */ | |
| 57 | export 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 | ||
| 74 | export 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 | ||
| 123 | function 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 | ||
| 133 | function 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 | ||
| 140 | async 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 | ||
| 164 | async 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 | ||
| 205 | function 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 | ||
| 216 | export 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 | ||
| 228 | export const checkForAppUpdateOnLaunch = createAppUpdateLaunchCheck(); |