| ... | @@ -8,11 +8,20 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; | ... | @@ -8,11 +8,20 @@ import { NativeStackScreenOptions } from "../../native/StackHeader"; |
| 8 | import { SymbolView } from "../../components/AppSymbol"; | 8 | 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, useState, useSyncExternalStore } from "react"; | 11 | import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react"; |
| 12 | import { Alert, Linking, Platform, ScrollView, View } from "react-native"; | 12 | import { |
| | 13 | ActivityIndicator, |
| | 14 | Alert, |
| | 15 | Linking, |
| | 16 | Platform, |
| | 17 | Pressable, |
| | 18 | ScrollView, |
| | 19 | View, |
| | 20 | } from "react-native"; |
| 13 | import { useSafeAreaInsets } from "react-native-safe-area-context"; | 21 | import { useSafeAreaInsets } from "react-native-safe-area-context"; |
| 14 | | 22 | |
| 15 | import { | 23 | import { |
| | 24 | type AtomCommandResult, |
| 16 | isAtomCommandInterrupted, | 25 | isAtomCommandInterrupted, |
| 17 | reportAtomCommandResult, | 26 | reportAtomCommandResult, |
| 18 | settleAsyncResult, | 27 | settleAsyncResult, |
| ... | @@ -570,8 +579,12 @@ function BetaSettingsSection() { | ... | @@ -570,8 +579,12 @@ function BetaSettingsSection() { |
| 570 | ); | 579 | ); |
| 571 | } | 580 | } |
| 572 | | 581 | |
| | 582 | type UpdateCheckState = "idle" | "checking" | "downloading" | "restarting" | "current"; |
| | 583 | |
| 573 | function AppSettingsSection() { | 584 | function AppSettingsSection() { |
| 574 | const icon = useThemeColor("--color-icon"); | 585 | const icon = useThemeColor("--color-icon"); |
| | 586 | const [updateState, setUpdateState] = useState<UpdateCheckState>("idle"); |
| | 587 | const updateInFlight = useRef(false); |
| 575 | | 588 | |
| 576 | const version = Constants.expoConfig?.version ?? "0.0.0"; | 589 | const version = Constants.expoConfig?.version ?? "0.0.0"; |
| 577 | // Fall back to "production" to match resolveAppVariant in app.config.ts, so a | 590 | // Fall back to "production" to match resolveAppVariant in app.config.ts, so a |
| ... | @@ -590,30 +603,140 @@ function AppSettingsSection() { | ... | @@ -590,30 +603,140 @@ function AppSettingsSection() { |
| 590 | : null | 603 | : null |
| 591 | : null; | 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 | return ( | 674 | return ( |
| 594 | <SettingsSection title="App"> | 675 | <SettingsSection title="App"> |
| 595 | <SettingsRow icon="internaldrive" label="Client Storage" target="SettingsClientStorage" /> | 676 | <SettingsRow icon="internaldrive" label="Client Storage" target="SettingsClientStorage" /> |
| 596 | <SettingsRow icon="doc.text" label="Legal" fullScreenTarget="SettingsLegal" /> | 677 | <SettingsRow icon="doc.text" label="Legal" fullScreenTarget="SettingsLegal" /> |
| 597 | <View className="flex-row items-center gap-4 p-4"> | 678 | {Updates.isEnabled ? ( |
| 598 | <SymbolView | 679 | <Pressable |
| 599 | name="info.circle" | 680 | accessibilityLabel="Check for updates" |
| 600 | size={22} | 681 | accessibilityRole="button" |
| 601 | tintColor={icon} | 682 | disabled={busy} |
| 602 | type="monochrome" | 683 | onPress={() => void checkForUpdate()} |
| 603 | weight="regular" | 684 | > |
| 604 | /> | 685 | {versionRow} |
| 605 | <Text className="flex-1 text-lg text-foreground">Version</Text> | 686 | </Pressable> |
| 606 | <View className="items-end"> | 687 | ) : ( |
| 607 | <Text className="text-lg text-foreground-muted">{versionLabel}</Text> | 688 | versionRow |
| 608 | {bundleLabel ? ( | 689 | )} |
| 609 | <Text className="text-xs text-foreground-muted/70">{bundleLabel}</Text> | | |
| 610 | ) : null} | | |
| 611 | </View> | | |
| 612 | </View> | | |
| 613 | </SettingsSection> | 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 | function capitalize(value: string): string { | 740 | function capitalize(value: string): string { |
| 618 | return value.length > 0 ? value.charAt(0).toUpperCase() + value.slice(1) : value; | 741 | return value.length > 0 ? value.charAt(0).toUpperCase() + value.slice(1) : value; |
| 619 | } | 742 | } |