authorgravatar for julius0216@outlook.comJulius Marminge <julius0216@outlook.com> 2026-07-28 00:55:00+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-07-28 00:55:00+02:00
loga148e08197fc38b24e59c10c7cd5ba06dd182dab
tree206ab3d0d1920d884fd30b50cc7b7f633d145c11
parent831eb66fee91e397f5f8d6ae179408291e8f28b0
signature Signed by PGP key B5690EEEBB952194

Add OTA update checks to mobile settings (#4686)


1 files changed, 141 insertions(+), 18 deletions(-)

apps/mobile/src/features/settings/SettingsRouteScreen.tsx+141-18
...@@ -8,11 +8,20 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";...@@ -8,11 +8,20 @@ import { NativeStackScreenOptions } from "../../native/StackHeader";
8import { SymbolView } from "../../components/AppSymbol";8import { SymbolView } from "../../components/AppSymbol";
9import * as Effect from "effect/Effect";9import * as Effect from "effect/Effect";
10import { AsyncResult } from "effect/unstable/reactivity";10import { AsyncResult } from "effect/unstable/reactivity";
11import { useCallback, useEffect, useMemo, useState, useSyncExternalStore } from "react";11import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
12import { Alert, Linking, Platform, ScrollView, View } from "react-native";12import {
13 ActivityIndicator,
14 Alert,
15 Linking,
16 Platform,
17 Pressable,
18 ScrollView,
19 View,
20} from "react-native";
13import { useSafeAreaInsets } from "react-native-safe-area-context";21import { useSafeAreaInsets } from "react-native-safe-area-context";
1422
15import {23import {
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}
572581
582type UpdateCheckState = "idle" | "checking" | "downloading" | "restarting" | "current";
583
573function AppSettingsSection() {584function 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);
575588
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 a590 // 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 : null603 : null
591 : null;604 : null;
592605
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 <SymbolView679 <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}
616693
694async 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
733function 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
617function capitalize(value: string): string {740function 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}