mirror of
https://github.com/stablyai/orca.git
synced 2026-11-03 10:37:35 +00:00
The OTA download screen now shows a progress bar over bytes received, labelled "Downloading the latest app" with a "from <host> · X.X of Y.Y MB" caption, instead of a spinner with raw file and byte counts. Related offline and failure copy says "the latest app" instead of "the workspace". The bar barely moved before because React never committed progress during the download. SHA-256 of each asset ran in JS (3.8 s of a 5.7 s download), and React Native's scheduler runs queued native callbacks ahead of normal-priority renders, forcing a render only after 5 s. Assets are now hashed with expo-crypto's native digest (~5 ms total), so progress renders every 100–250 ms at 1 MB/s. Verified on an Android emulator and the iOS simulator. The bar glides between bursts of progress (moving average of burst gaps, clamped 200–2000 ms) and is ShellWaitingFrame's indicator, so the label keeps the same position as on "Opening workspace". On an uncapped local link, decryption still keeps the JS thread busy and the bar can jump; that is left for a follow-up.
124 lines
3.7 KiB
TypeScript
124 lines
3.7 KiB
TypeScript
import { useEffect, useRef, useState, type ReactNode } from 'react'
|
|
import { ActivityIndicator, Animated, Easing, StyleSheet, Text, View } from 'react-native'
|
|
import { colors, spacing, typography } from '../theme/mobile-theme'
|
|
|
|
/** What the shell calls the wait from the bytes being on disk to the page having a frame. */
|
|
export const SHELL_OPENING_LABEL = 'Opening workspace'
|
|
|
|
/**
|
|
* Eased in, so the cover holds near full opacity through the handover: the page reports its own
|
|
* renderer's paint, and the compositor needs a frame or two more to put that on the app's surface.
|
|
* A linear fade left two frames of bare surface between the two on an emulator.
|
|
*/
|
|
export const SHELL_PAGE_COVER_FADE_MS = 220
|
|
|
|
/**
|
|
* The shell's neutral frame: an indicator, a spinner unless the caller has a better one, and what it
|
|
* is waiting on. The indicator slot and the out-of-flow caption keep the label at one height across
|
|
* every waiting screen, whatever each one shows.
|
|
*/
|
|
export function ShellWaitingFrame({
|
|
label,
|
|
indicator,
|
|
caption
|
|
}: {
|
|
label: string
|
|
indicator?: ReactNode
|
|
caption?: string
|
|
}) {
|
|
return (
|
|
<>
|
|
<View style={styles.indicatorSlot}>
|
|
{indicator ?? <ActivityIndicator color={colors.textSecondary} accessibilityLabel={label} />}
|
|
</View>
|
|
<View style={styles.labelRow}>
|
|
<Text style={styles.label}>{label}</Text>
|
|
{caption !== undefined && <Text style={styles.caption}>{caption}</Text>}
|
|
</View>
|
|
</>
|
|
)
|
|
}
|
|
|
|
/**
|
|
* That same frame, held over a mounted view until the page reports one of its own: a WebView that
|
|
* has not painted draws nothing, so uncovering at `ready` shows the surface behind it and nothing
|
|
* else for the whole of the page's boot. Never interactive even while opaque, so a report that
|
|
* never arrives strands a spinner over a usable page rather than a dead one.
|
|
*/
|
|
export function ShellPageCover({ label, visible }: { label: string; visible: boolean }) {
|
|
const opacity = useRef(new Animated.Value(1)).current
|
|
const [mounted, setMounted] = useState(visible)
|
|
|
|
useEffect(() => {
|
|
if (visible) {
|
|
opacity.setValue(1)
|
|
setMounted(true)
|
|
return
|
|
}
|
|
const fade = Animated.timing(opacity, {
|
|
toValue: 0,
|
|
duration: SHELL_PAGE_COVER_FADE_MS,
|
|
easing: Easing.in(Easing.quad),
|
|
useNativeDriver: true
|
|
})
|
|
// Unmounted on the callback rather than on a timer, so a fade the platform cut short does not
|
|
// leave an opaque cover behind; one that never calls back leaves a transparent inert one.
|
|
fade.start(({ finished }) => {
|
|
if (finished) {
|
|
setMounted(false)
|
|
}
|
|
})
|
|
return () => {
|
|
fade.stop()
|
|
}
|
|
}, [opacity, visible])
|
|
|
|
if (!mounted) {
|
|
return null
|
|
}
|
|
return (
|
|
<Animated.View
|
|
style={[styles.cover, { opacity }]}
|
|
testID="mobile-web-shell-cover"
|
|
pointerEvents="none"
|
|
>
|
|
<ShellWaitingFrame label={label} />
|
|
</Animated.View>
|
|
)
|
|
}
|
|
|
|
const styles = StyleSheet.create({
|
|
cover: {
|
|
...StyleSheet.absoluteFillObject,
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
backgroundColor: colors.bgBase,
|
|
paddingHorizontal: spacing.lg
|
|
},
|
|
indicatorSlot: {
|
|
height: 20,
|
|
justifyContent: 'center'
|
|
},
|
|
labelRow: {
|
|
alignSelf: 'stretch'
|
|
},
|
|
label: {
|
|
fontSize: typography.bodySize,
|
|
color: colors.textSecondary,
|
|
marginTop: spacing.md,
|
|
textAlign: 'center'
|
|
},
|
|
// Out of flow, so a frame with a caption centres at the same height as one without.
|
|
caption: {
|
|
position: 'absolute',
|
|
top: '100%',
|
|
left: 0,
|
|
right: 0,
|
|
marginTop: spacing.sm,
|
|
textAlign: 'center',
|
|
fontSize: typography.metaSize,
|
|
color: colors.textMuted,
|
|
fontVariant: ['tabular-nums']
|
|
}
|
|
})
|