// Full-screen overlay shown while an EAS Update downloads and applies. // // It exists because of what expo-updates does by DEFAULT, which is nothing // visible. `fallbackToCacheTimeout: 0` means the app launches from the cached // bundle immediately, fetches any new update in the background, and applies it // on the NEXT cold start — so a tester relaunches once, sees the old build, // relaunches again, and sees the new one. Every app here reacts to the pending // update by reloading straight away instead; this component is what turns the // resulting pause into a visible, explained wait rather than the app appearing // to hang. // // # Why this is in the shared package // // It was written in vault, ported by hand into wheatley and glados, and then // FIXED in the ports while vault kept the bug (see the colour note below). // Three copies, one of which was wrong, of a component whose entire job is to // render correctly in the worst conditions the app ever has. Sharing it is how // the next fix reaches all three. // // # Every colour is a literal, and that is the fix // // This renders while the app is NOT ready — during a cold start whose update // is still downloading, and during the reload that applies it. NativeWind's // `dark:` variant and theme tokens like `text-foreground` only resolve once the // provider tree has mounted and run its effects, which by definition has not // happened yet. Asking the theme produced light-mode ink on a dark ground: an // invisible label on what the user reads as a black screen. Observed for real // on Android in both wheatley (2026-08-23) and vault (2026-08-29). // // The values ARE the palettes' own — this matches the theme, it just does not // ask it. An overlay whose whole job is to render before the app is ready // cannot depend on app state to be legible. // // reanimated rather than RN's `Animated`, matching the animation convention in // every consumer. import * as React from "react"; import { StyleSheet, Text, View, useColorScheme } from "react-native"; import Animated, { Easing, useAnimatedStyle, useSharedValue, withRepeat, withTiming, } from "react-native-reanimated"; import { EntropyMark } from "./entropy-mark"; export interface UpdateOverlayProps { /** "downloading" shows a progress-aware label; "restarting" is the moment * between a finished download and the reload call that applies it. */ phase: "downloading" | "restarting"; /** 0-1, and only meaningful while downloading. Undefined when the update * server sent no Content-Length for expo-updates to compute it from, which * is why the percentage renders conditionally rather than defaulting to 0 — * a "0%" that never moves reads as a stall. */ progress?: number; /** * Glyph shown inside the ring. Defaults to the Entropy mark. * * A slot rather than a name or an icon set, because the consumers genuinely * differ: vault shows its own product mark, wheatley and glados show the * shared one. Whatever is passed must not derive its colour from the theme, * for the reason in the header — pass an explicit ink. */ mark?: React.ReactNode; /** * Force a colour scheme instead of following the system. * * Dark-only apps pass `"dark"`: without it the overlay would follow a light * system setting and render a white flash before a dark app. */ scheme?: "light" | "dark"; /** * Ground colour override. * * Defaults to the palette's own background. Worth setting when the native * splash and window background use a specific value — matching it makes the * overlay read as a continuation of the launch rather than a second screen. */ background?: string; /** Label under the ring while downloading. */ downloadingLabel?: string; /** Label under the ring while restarting. */ restartingLabel?: string; } /** How long one full turn takes. Slow enough to read as deliberate, fast * enough to look like work rather than a frozen frame. */ const SPIN_MS = 1100; const PALETTE = { dark: { ground: "#000000", ink: "#EBEFEB", muted: "#A3A3A3", track: "#262626", arc: "#FFFFFF", }, light: { ground: "#FFFFFF", ink: "#0A0A0A", muted: "#737373", track: "#E5E5E5", arc: "#000000", }, } as const; export function UpdateOverlay({ phase, progress, mark, scheme, background, downloadingLabel = "Installing update…", restartingLabel = "Restarting…", }: UpdateOverlayProps) { // RN's own hook, not NativeWind's: this has to work before the NativeWind // provider has done anything, which is the entire premise of the component. const system = useColorScheme(); const isDark = (scheme ?? system) !== "light"; const c = isDark ? PALETTE.dark : PALETTE.light; const rotation = useSharedValue(0); // Started on mount rather than gated on a `visible` prop: the caller renders // this conditionally, so mounting IS becoming visible, and a prop would be a // second source of truth for the same fact. React.useEffect(() => { rotation.value = withRepeat( withTiming(360, { duration: SPIN_MS, easing: Easing.linear }), -1, false ); }, [rotation]); const ringStyle = useAnimatedStyle(() => ({ transform: [{ rotate: `${rotation.value}deg` }], })); return ( {mark ?? } {phase === "downloading" ? downloadingLabel : restartingLabel} {phase === "downloading" && typeof progress === "number" ? ( {Math.round(progress * 100)}% ) : null} ); } // StyleSheet rather than className throughout, deliberately: NativeWind classes // resolve through the same provider this component cannot wait for. Plain RN // styles are the only thing guaranteed to apply here. The ring also needs // concrete numbers that agree with each other (a 72px box, a 36px radius, one // 3px border), and its transform comes from an animated style anyway. const styles = StyleSheet.create({ ground: { alignItems: "center", justifyContent: "center", // Both, not either: zIndex orders within the JS view hierarchy and // elevation is what Android's native compositor honours. A high zIndex // alone can still be drawn under an elevated sibling. zIndex: 10000, elevation: 10000, }, markWell: { width: 72, height: 72, alignItems: "center", justifyContent: "center", }, ring: { position: "absolute", width: 72, height: 72, borderRadius: 36, borderWidth: 3, }, // No font family: the overlay can render before the app's fonts have loaded, // and naming one that is not there yet gives a blank label rather than a // system-font fallback. label: { marginTop: 20, fontSize: 16, fontWeight: "500" }, progress: { marginTop: 4, fontSize: 12 }, });