| 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,6 +11,7 @@ import { useSavedRemoteConnections } from "../../state/use-remote-environment-re |
| 11 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; | 11 | import { useAdaptiveWorkspaceLayout } from "../layout/AdaptiveWorkspaceLayout"; |
| 12 | import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail"; | 12 | import { WorkspaceEmptyDetail } from "../layout/WorkspaceEmptyDetail"; |
| 13 | import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; | 13 | import { WorkspaceSidebarToolbar } from "../layout/workspace-sidebar-toolbar"; |
| 14 | import { checkForAppUpdateOnLaunch } from "../updates/app-updates"; | ||
| 14 | import { AndroidHomeFabLayout } from "./AndroidHomeFab"; | 15 | import { AndroidHomeFabLayout } from "./AndroidHomeFab"; |
| 15 | import { HomeScreen } from "./HomeScreen"; | 16 | import { HomeScreen } from "./HomeScreen"; |
| 16 | import { HomeHeader } from "./HomeHeader"; | 17 | import { HomeHeader } from "./HomeHeader"; |
| ... | @@ -29,6 +30,11 @@ export function HomeRouteScreen() { | ... | @@ -29,6 +30,11 @@ export function HomeRouteScreen() { |
| 29 | const { savedConnectionsById } = useSavedRemoteConnections(); | 30 | const { savedConnectionsById } = useSavedRemoteConnections(); |
| 30 | const navigation = useNavigation(); | 31 | const navigation = useNavigation(); |
| 31 | const [searchQuery, setSearchQuery] = useState(""); | 32 | const [searchQuery, setSearchQuery] = useState(""); |
| 33 | |||
| 34 | useEffect(() => { | ||
| 35 | void checkForAppUpdateOnLaunch(); | ||
| 36 | }, []); | ||
| 37 | |||
| 32 | const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } = | 38 | const { archiveThread, confirmDeleteThread, settleThread, unsettleThread } = |
| 33 | useThreadListActions(); | 39 | useThreadListActions(); |
| 34 | const pendingTasks = usePendingNewTasks(); | 40 | const pendingTasks = usePendingNewTasks(); |
apps/mobile/src/features/settings/SettingsRouteScreen.tsx+26-91| ... | @@ -9,19 +9,10 @@ import { SymbolView } from "../../components/AppSymbol"; | ... | @@ -9,19 +9,10 @@ import { SymbolView } from "../../components/AppSymbol"; |
| 9 | import * as Effect from "effect/Effect"; | 9 | import * as Effect from "effect/Effect"; |
| 10 | import { AsyncResult } from "effect/unstable/reactivity"; | 10 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 11 | import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; | 11 | import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; |
| 12 | import { | 12 | import { Alert, Linking, Platform, Pressable, ScrollView, View } from "react-native"; |
| 13 | ActivityIndicator, | ||
| 14 | Alert, | ||
| 15 | Linking, | ||
| 16 | Platform, | ||
| 17 | Pressable, | ||
| 18 | ScrollView, | ||
| 19 | View, | ||
| 20 | } from "react-native"; | ||
| 21 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 13 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 22 | 14 | ||
| 23 | import { | 15 | import { |
| 24 | type AtomCommandResult, | ||
| 25 | isAtomCommandInterrupted, | 16 | isAtomCommandInterrupted, |
| 26 | reportAtomCommandResult, | 17 | reportAtomCommandResult, |
| 27 | settleAsyncResult, | 18 | settleAsyncResult, |
| ... | @@ -47,6 +38,11 @@ import { runtime } from "../../lib/runtime"; | ... | @@ -47,6 +38,11 @@ import { runtime } from "../../lib/runtime"; |
| 47 | import { useThemeColor } from "../../lib/useThemeColor"; | 38 | import { useThemeColor } from "../../lib/useThemeColor"; |
| 48 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; | 39 | import { mobilePreferencesAtom, updateMobilePreferencesAtom } from "../../state/preferences"; |
| 49 | import { useThreadListV2Enabled } from "../threads/use-thread-list-v2-enabled"; | 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 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; | 46 | import { useSavedRemoteConnections } from "../../state/use-remote-environment-registry"; |
| 51 | import { SettingsRow } from "./components/SettingsRow"; | 47 | import { SettingsRow } from "./components/SettingsRow"; |
| 52 | import { SettingsSection } from "./components/SettingsSection"; | 48 | import { SettingsSection } from "./components/SettingsSection"; |
| ... | @@ -578,12 +574,11 @@ function BetaSettingsSection() { | ... | @@ -578,12 +574,11 @@ function BetaSettingsSection() { |
| 578 | ); | 574 | ); |
| 579 | } | 575 | } |
| 580 | 576 | ||
| 581 | type UpdateCheckState = "idle" | "checking" | "downloading" | "restarting" | "current"; | ||
| 582 | |||
| 583 | function AppSettingsSection() { | 577 | function AppSettingsSection() { |
| 584 | const icon = useThemeColor("--color-icon"); | 578 | const icon = useThemeColor("--color-icon"); |
| 585 | const [updateState, setUpdateState] = useState<UpdateCheckState>("idle"); | 579 | const [updateState, setUpdateState] = useState<AppUpdateCheckState>("idle"); |
| 586 | const updateInFlight = useRef(false); | 580 | const updateInFlight = useRef(false); |
| 581 | const hiddenUpdateTapCount = useRef(0); | ||
| 587 | 582 | ||
| 588 | const version = Constants.expoConfig?.version ?? "0.0.0"; | 583 | const version = Constants.expoConfig?.version ?? "0.0.0"; |
| 589 | // Fall back to "production" to match resolveAppVariant in app.config.ts, so a | 584 | // Fall back to "production" to match resolveAppVariant in app.config.ts, so a |
| ... | @@ -591,22 +586,11 @@ function AppSettingsSection() { | ... | @@ -591,22 +586,11 @@ function AppSettingsSection() { |
| 591 | const variant = (Constants.expoConfig?.extra?.appVariant as string | undefined) ?? "production"; | 586 | const variant = (Constants.expoConfig?.extra?.appVariant as string | undefined) ?? "production"; |
| 592 | const variantLabel = variant === "production" ? "" : capitalize(variant); | 587 | const variantLabel = variant === "production" ? "" : capitalize(variant); |
| 593 | const versionLabel = variantLabel ? `${version} · ${variantLabel}` : version; | 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 | const busy = | 589 | const busy = |
| 606 | updateState === "checking" || updateState === "downloading" || updateState === "restarting"; | 590 | updateState === "checking" || updateState === "downloading" || updateState === "restarting"; |
| 607 | 591 | ||
| 608 | // "Up to date" is a transient acknowledgement, not a state worth persisting — | 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 | useEffect(() => { | 594 | useEffect(() => { |
| 611 | if (updateState !== "current") return; | 595 | if (updateState !== "current") return; |
| 612 | const timer = setTimeout(() => setUpdateState("idle"), 3000); | 596 | const timer = setTimeout(() => setUpdateState("idle"), 3000); |
| ... | @@ -619,12 +603,24 @@ function AppSettingsSection() { | ... | @@ -619,12 +603,24 @@ function AppSettingsSection() { |
| 619 | if (updateInFlight.current) return; | 603 | if (updateInFlight.current) return; |
| 620 | updateInFlight.current = true; | 604 | updateInFlight.current = true; |
| 621 | try { | 605 | try { |
| 622 | await runUpdateCheck(setUpdateState); | 606 | await runAppUpdateCheck({ |
| 607 | onFailure: (message) => Alert.alert("Update failed", message), | ||
| 608 | onStateChange: setUpdateState, | ||
| 609 | }); | ||
| 623 | } finally { | 610 | } finally { |
| 624 | updateInFlight.current = false; | 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 | const statusLabel = | 624 | const statusLabel = |
| 629 | updateState === "checking" | 625 | updateState === "checking" |
| 630 | ? "Checking…" | 626 | ? "Checking…" |
| ... | @@ -634,7 +630,7 @@ function AppSettingsSection() { | ... | @@ -634,7 +630,7 @@ function AppSettingsSection() { |
| 634 | ? "Restarting…" | 630 | ? "Restarting…" |
| 635 | : updateState === "current" | 631 | : updateState === "current" |
| 636 | ? "Up to date" | 632 | ? "Up to date" |
| 637 | : bundleLabel; | 633 | : null; |
| 638 | 634 | ||
| 639 | const versionRow = ( | 635 | const versionRow = ( |
| 640 | <View className="flex-row items-center gap-4 p-4"> | 636 | <View className="flex-row items-center gap-4 p-4"> |
| ... | @@ -652,21 +648,6 @@ function AppSettingsSection() { | ... | @@ -652,21 +648,6 @@ function AppSettingsSection() { |
| 652 | <Text className="text-xs text-foreground-muted/70">{statusLabel}</Text> | 648 | <Text className="text-xs text-foreground-muted/70">{statusLabel}</Text> |
| 653 | ) : null} | 649 | ) : null} |
| 654 | </View> | 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 | </View> | 651 | </View> |
| 671 | ); | 652 | ); |
| 672 | 653 | ||
| ... | @@ -676,10 +657,10 @@ function AppSettingsSection() { | ... | @@ -676,10 +657,10 @@ function AppSettingsSection() { |
| 676 | <SettingsRow icon="doc.text" label="Legal" fullScreenTarget="SettingsLegal" /> | 657 | <SettingsRow icon="doc.text" label="Legal" fullScreenTarget="SettingsLegal" /> |
| 677 | {Updates.isEnabled ? ( | 658 | {Updates.isEnabled ? ( |
| 678 | <Pressable | 659 | <Pressable |
| 679 | accessibilityLabel="Check for updates" | 660 | accessibilityLabel={`Version ${versionLabel}`} |
| 680 | accessibilityRole="button" | 661 | accessibilityRole="text" |
| 681 | disabled={busy} | 662 | disabled={busy} |
| 682 | onPress={() => void checkForUpdate()} | 663 | onPress={handleVersionPress} |
| 683 | > | 664 | > |
| 684 | {versionRow} | 665 | {versionRow} |
| 685 | </Pressable> | 666 | </Pressable> |
| ... | @@ -690,52 +671,6 @@ function AppSettingsSection() { | ... | @@ -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 | function capitalize(value: string): string { | 674 | function capitalize(value: string): string { |
| 740 | return value.length > 0 ? value.charAt(0).toUpperCase() + value.slice(1) : value; | 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(); | ||