| ... | ... | @@ -8,11 +8,20 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 8 | 8 | import { SymbolView } from "../../components/AppSymbol"; |
| 9 | 9 | import * as Effect from "effect/Effect"; |
| 10 | 10 | import { AsyncResult } from "effect/unstable/reactivity"; |
| 11 | | import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react"; |
| 12 | | import { Alert, Linking, Platform, ScrollView, View } from "react-native"; |
| 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"; |
| 13 | 21 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 14 | 22 | |
| 15 | 23 | import { |
| 24 | type AtomCommandResult, |
| 16 | 25 | isAtomCommandInterrupted, |
| 17 | 26 | reportAtomCommandResult, |
| 18 | 27 | settleAsyncResult, |
| ... | ... | @@ -570,8 +579,12 @@ function BetaSettingsSection() { |
| 570 | 579 | ); |
| 571 | 580 | } |
| 572 | 581 | |
| 582 | type UpdateCheckState = "idle" | "checking" | "downloading" | "restarting" | "current"; |
| 583 | |
| 573 | 584 | function AppSettingsSection() { |
| 574 | 585 | const icon = useThemeColor("--color-icon"); |
| 586 | const [updateState, setUpdateState] = useState<UpdateCheckState>("idle"); |
| 587 | const updateInFlight = useRef(false); |
| 575 | 588 | |
| 576 | 589 | const version = Constants.expoConfig?.version ?? "0.0.0"; |
| 577 | 590 | // Fall back to "production" to match resolveAppVariant in app.config.ts, so a |
| ... | ... | @@ -590,30 +603,140 @@ function AppSettingsSection() { |
| 590 | 603 | : null |
| 591 | 604 | : null; |
| 592 | 605 | |
| 606 | const busy = |
| 607 | updateState === "checking" || updateState === "downloading" || updateState === "restarting"; |
| 608 | |
| 609 | // "Up to date" is a transient acknowledgement, not a state worth persisting — |
| 610 | // drop back to the bundle label so the row keeps answering "what am I running?". |
| 611 | useEffect(() => { |
| 612 | if (updateState !== "current") return; |
| 613 | const timer = setTimeout(() => setUpdateState("idle"), 3000); |
| 614 | return () => clearTimeout(timer); |
| 615 | }, [updateState]); |
| 616 | |
| 617 | const checkForUpdate = useCallback(async () => { |
| 618 | // `disabled={busy}` only takes effect on the next render, so two taps in the |
| 619 | // same frame would both get through. The ref closes that window. |
| 620 | if (updateInFlight.current) return; |
| 621 | updateInFlight.current = true; |
| 622 | try { |
| 623 | await runUpdateCheck(setUpdateState); |
| 624 | } finally { |
| 625 | updateInFlight.current = false; |
| 626 | } |
| 627 | }, []); |
| 628 | |
| 629 | const statusLabel = |
| 630 | updateState === "checking" |
| 631 | ? "Checking…" |
| 632 | : updateState === "downloading" |
| 633 | ? "Downloading…" |
| 634 | : updateState === "restarting" |
| 635 | ? "Restarting…" |
| 636 | : updateState === "current" |
| 637 | ? "Up to date" |
| 638 | : bundleLabel; |
| 639 | |
| 640 | const versionRow = ( |
| 641 | <View className="flex-row items-center gap-4 p-4"> |
| 642 | <SymbolView |
| 643 | name="info.circle" |
| 644 | size={22} |
| 645 | tintColor={icon} |
| 646 | type="monochrome" |
| 647 | weight="regular" |
| 648 | /> |
| 649 | <Text className="flex-1 text-lg text-foreground">Version</Text> |
| 650 | <View className="items-end"> |
| 651 | <Text className="text-lg text-foreground-muted">{versionLabel}</Text> |
| 652 | {statusLabel ? ( |
| 653 | <Text className="text-xs text-foreground-muted/70">{statusLabel}</Text> |
| 654 | ) : null} |
| 655 | </View> |
| 656 | {Updates.isEnabled ? ( |
| 657 | <View className="w-[22px] items-center"> |
| 658 | {busy ? ( |
| 659 | <ActivityIndicator color={icon} size="small" /> |
| 660 | ) : ( |
| 661 | <SymbolView |
| 662 | name="arrow.clockwise" |
| 663 | size={18} |
| 664 | tintColor={icon} |
| 665 | type="monochrome" |
| 666 | weight="semibold" |
| 667 | /> |
| 668 | )} |
| 669 | </View> |
| 670 | ) : null} |
| 671 | </View> |
| 672 | ); |
| 673 | |
| 593 | 674 | return ( |
| 594 | 675 | <SettingsSection title="App"> |
| 595 | 676 | <SettingsRow icon="internaldrive" label="Client Storage" target="SettingsClientStorage" /> |
| 596 | 677 | <SettingsRow icon="doc.text" label="Legal" fullScreenTarget="SettingsLegal" /> |
| 597 | | <View className="flex-row items-center gap-4 p-4"> |
| 598 | | <SymbolView |
| 599 | | name="info.circle" |
| 600 | | size={22} |
| 601 | | tintColor={icon} |
| 602 | | type="monochrome" |
| 603 | | weight="regular" |
| 604 | | /> |
| 605 | | <Text className="flex-1 text-lg text-foreground">Version</Text> |
| 606 | | <View className="items-end"> |
| 607 | | <Text className="text-lg text-foreground-muted">{versionLabel}</Text> |
| 608 | | {bundleLabel ? ( |
| 609 | | <Text className="text-xs text-foreground-muted/70">{bundleLabel}</Text> |
| 610 | | ) : null} |
| 611 | | </View> |
| 612 | | </View> |
| 678 | {Updates.isEnabled ? ( |
| 679 | <Pressable |
| 680 | accessibilityLabel="Check for updates" |
| 681 | accessibilityRole="button" |
| 682 | disabled={busy} |
| 683 | onPress={() => void checkForUpdate()} |
| 684 | > |
| 685 | {versionRow} |
| 686 | </Pressable> |
| 687 | ) : ( |
| 688 | versionRow |
| 689 | )} |
| 613 | 690 | </SettingsSection> |
| 614 | 691 | ); |
| 615 | 692 | } |
| 616 | 693 | |
| 694 | async function runUpdateCheck(setUpdateState: (state: UpdateCheckState) => void): Promise<void> { |
| 695 | setUpdateState("checking"); |
| 696 | const check = await settlePromise(() => Updates.checkForUpdateAsync()); |
| 697 | if (check._tag === "Failure") { |
| 698 | reportUpdateFailure(check, "Could not check for updates."); |
| 699 | setUpdateState("idle"); |
| 700 | return; |
| 701 | } |
| 702 | // A rollback directive (`eas update:rollback`) arrives as isAvailable: false |
| 703 | // with isRollBackToEmbedded: true — there is nothing newer to install, but the |
| 704 | // running OTA still has to be dropped for the embedded bundle. |
| 705 | if (!check.value.isAvailable && !check.value.isRollBackToEmbedded) { |
| 706 | setUpdateState("current"); |
| 707 | return; |
| 708 | } |
| 709 | |
| 710 | setUpdateState("downloading"); |
| 711 | const fetched = await settlePromise(() => Updates.fetchUpdateAsync()); |
| 712 | if (fetched._tag === "Failure") { |
| 713 | reportUpdateFailure(fetched, "Could not download the update."); |
| 714 | setUpdateState("idle"); |
| 715 | return; |
| 716 | } |
| 717 | // isNew is always false for a rollback, so it can't be the sole gate here either. |
| 718 | if (!fetched.value.isNew && !fetched.value.isRollBackToEmbedded) { |
| 719 | setUpdateState("current"); |
| 720 | return; |
| 721 | } |
| 722 | |
| 723 | setUpdateState("restarting"); |
| 724 | // reloadAsync never resolves on success — the JS context is torn down — so |
| 725 | // reaching the failure branch below is the only way this returns. |
| 726 | const reloaded = await settlePromise(() => Updates.reloadAsync()); |
| 727 | if (reloaded._tag === "Failure") { |
| 728 | reportUpdateFailure(reloaded, "Downloaded, but could not restart the app."); |
| 729 | setUpdateState("idle"); |
| 730 | } |
| 731 | } |
| 732 | |
| 733 | function reportUpdateFailure(result: AtomCommandResult<unknown, unknown>, fallback: string): void { |
| 734 | reportAtomCommandResult(result, { label: "app update check" }); |
| 735 | if (result._tag !== "Failure" || isAtomCommandInterrupted(result)) return; |
| 736 | const error = squashAtomCommandFailure(result); |
| 737 | Alert.alert("Update failed", error instanceof Error ? error.message : fallback); |
| 738 | } |
| 739 | |
| 617 | 740 | function capitalize(value: string): string { |
| 618 | 741 | return value.length > 0 ? value.charAt(0).toUpperCase() + value.slice(1) : value; |
| 619 | 742 | } |