mirror of
https://github.com/stablyai/orca.git
synced 2026-10-31 00:57:45 +00:00
feat(mobile): show the OTA download as a progress bar that renders while downloading (#28056)
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.
This commit is contained in:
@@ -92,7 +92,7 @@ describe('the hybrid shell screen', () => {
|
||||
reason: 'document-load-failed',
|
||||
retriedOnce: true
|
||||
})
|
||||
expect(textOf(tree)).toContain('The downloaded workspace could not be opened.')
|
||||
expect(textOf(tree)).toContain('The downloaded app could not be opened.')
|
||||
const retry = tree.root.findAll((node) => node.props.testID === 'mobile-web-shell-retry')
|
||||
expect(retry).toHaveLength(1)
|
||||
await act(async () => {
|
||||
@@ -123,20 +123,38 @@ describe('the hybrid shell screen', () => {
|
||||
|
||||
it('names what is missing when the host is unreachable and nothing is cached', async () => {
|
||||
expect(textOf(await renderScreen({ kind: 'offline' }))).toContain(
|
||||
'Connect to this host to download the workspace'
|
||||
'Connect to this host to download the latest app'
|
||||
)
|
||||
})
|
||||
|
||||
it('counts assets and bytes while downloading', async () => {
|
||||
it('shows the download as a progress bar with megabytes from the paired host', async () => {
|
||||
const tree = await renderScreen({
|
||||
kind: 'fetching',
|
||||
completedAssets: 2,
|
||||
totalAssets: 4,
|
||||
receivedBytes: 2048,
|
||||
totalBytes: 4096
|
||||
completedAssets: 0,
|
||||
totalAssets: 128,
|
||||
receivedBytes: 2_020_592,
|
||||
totalBytes: 8_082_368
|
||||
})
|
||||
expect(textOf(tree)).toContain('2/4 files')
|
||||
expect(textOf(tree)).toContain('2048/4096 bytes')
|
||||
expect(textOf(tree)).toContain('Downloading the latest app')
|
||||
expect(textOf(tree)).toContain('from Host One · 2.0 of 8.1 MB')
|
||||
const bar = tree.root.findAll((node) => node.props.testID === 'mobile-web-shell-progress-bar')
|
||||
expect(bar).toHaveLength(1)
|
||||
expect(bar[0].props.accessibilityRole).toBe('progressbar')
|
||||
expect(bar[0].props.accessibilityValue).toEqual({ min: 0, max: 100, now: 25 })
|
||||
expect(byName(tree, 'ActivityIndicator')).toEqual([])
|
||||
})
|
||||
|
||||
it('reads an empty download as no progress rather than dividing by zero', async () => {
|
||||
const tree = await renderScreen({
|
||||
kind: 'fetching',
|
||||
completedAssets: 0,
|
||||
totalAssets: 0,
|
||||
receivedBytes: 0,
|
||||
totalBytes: 0
|
||||
})
|
||||
const bar = tree.root.findAll((node) => node.props.testID === 'mobile-web-shell-progress-bar')
|
||||
expect(bar[0].props.accessibilityValue).toEqual({ min: 0, max: 100, now: 0 })
|
||||
expect(textOf(tree)).toContain('0.0 of 0.0 MB')
|
||||
})
|
||||
|
||||
it('hands the shell view the generation path and the session id', async () => {
|
||||
@@ -360,7 +378,7 @@ describe('the hybrid shell screen', () => {
|
||||
textOf(
|
||||
await renderScreen({ kind: 'failed', reason: 'document-load-failed', retriedOnce: true })
|
||||
)
|
||||
).toContain('The downloaded workspace could not be opened.')
|
||||
).toContain('The downloaded app could not be opened.')
|
||||
})
|
||||
|
||||
it('reports a URL nothing on this phone could open, which is the dead tap that survives', async () => {
|
||||
@@ -584,7 +602,7 @@ describe('a refused update is said beside the page, not in front of it', () => {
|
||||
const tree = await renderScreen(readyState('session-one'))
|
||||
expect(byName(tree, 'ShellViewProbe')).toHaveLength(1)
|
||||
const text = textOf(tree)
|
||||
expect(text).toContain("Couldn't update the workspace from this host")
|
||||
expect(text).toContain("Couldn't download the latest app from this host")
|
||||
expect(text).toContain('Showing the last version that worked')
|
||||
expect(text).not.toContain('Try again')
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { ActivityIndicator, Linking, Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { Linking, Pressable, StyleSheet, Text, View } from 'react-native'
|
||||
import { useNavigation, useRouter } from 'expo-router'
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context'
|
||||
import {
|
||||
@@ -36,6 +36,7 @@ import { isHostRouteOf } from './page-route-policy'
|
||||
import { useResponsiveLayout } from '../layout/responsive-layout'
|
||||
import { storageReadParamsSchema, type BridgeNativeVerb } from './bridge/bridge-native-verbs'
|
||||
import { SHELL_OPENING_LABEL, ShellPageCover, ShellWaitingFrame } from './ShellWaitingFrame'
|
||||
import { ShellDownloadProgress } from './ShellDownloadProgress'
|
||||
import { pageSafeAreaInsets, usePublishedSafeAreaInsets } from './page-safe-area-insets'
|
||||
|
||||
function failureMessage(reason: MobileWebShellFailureCause): string {
|
||||
@@ -43,14 +44,14 @@ function failureMessage(reason: MobileWebShellFailureCause): string {
|
||||
case 'isolation-unavailable':
|
||||
return "This device's WebView is too old to open the workspace safely."
|
||||
case 'download-failed':
|
||||
return 'The workspace could not be downloaded from this host.'
|
||||
return 'The latest app could not be downloaded from this host.'
|
||||
case 'status-unreadable':
|
||||
return "Could not read this host's status. Go back and reopen it."
|
||||
case 'render-process-gone':
|
||||
return 'The workspace stopped responding.'
|
||||
case 'generation-unreadable':
|
||||
case 'document-load-failed':
|
||||
return 'The downloaded workspace could not be opened.'
|
||||
return 'The downloaded app could not be opened.'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +60,7 @@ function failureMessage(reason: MobileWebShellFailureCause): string {
|
||||
function updateNoticeMessage(notice: MobileWebShellUpdateNotice): string {
|
||||
switch (notice) {
|
||||
case 'update-failed':
|
||||
return "Couldn't update the workspace from this host. Showing the last version that worked."
|
||||
return "Couldn't download the latest app from this host. Showing the last version that worked."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,18 +76,6 @@ function Waiting({ label }: { label: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function Fetching({ state }: { state: Extract<MobileWebShellSessionState, { kind: 'fetching' }> }) {
|
||||
return (
|
||||
<Centered>
|
||||
<ActivityIndicator color={colors.textSecondary} accessibilityLabel="Downloading workspace" />
|
||||
<Text style={styles.waitingLabel}>Downloading workspace</Text>
|
||||
<Text style={styles.progress} testID="mobile-web-shell-progress">
|
||||
{`${state.completedAssets}/${state.totalAssets} files · ${state.receivedBytes}/${state.totalBytes} bytes`}
|
||||
</Text>
|
||||
</Centered>
|
||||
)
|
||||
}
|
||||
|
||||
function Failed({
|
||||
state,
|
||||
onRetry
|
||||
@@ -366,13 +355,17 @@ export function MobileWebShellScreen({
|
||||
return (
|
||||
<Centered>
|
||||
<Text style={styles.waitingLabel} testID="mobile-web-shell-offline">
|
||||
Connect to this host to download the workspace
|
||||
Connect to this host to download the latest app
|
||||
</Text>
|
||||
</Centered>
|
||||
)
|
||||
}
|
||||
if (state.kind === 'fetching') {
|
||||
return <Fetching state={state} />
|
||||
return (
|
||||
<Centered>
|
||||
<ShellDownloadProgress state={state} host={snapshot?.host ?? null} />
|
||||
</Centered>
|
||||
)
|
||||
}
|
||||
if (state.kind !== 'ready') {
|
||||
return <Waiting label={state.kind === 'activating' ? SHELL_OPENING_LABEL : 'Checking host'} />
|
||||
@@ -461,11 +454,6 @@ const styles = StyleSheet.create({
|
||||
marginTop: spacing.md,
|
||||
textAlign: 'center'
|
||||
},
|
||||
progress: {
|
||||
fontSize: typography.metaSize,
|
||||
color: colors.textMuted,
|
||||
marginTop: spacing.sm
|
||||
},
|
||||
failedMessage: {
|
||||
fontSize: typography.bodySize,
|
||||
color: colors.textPrimary,
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Animated, StyleSheet, View } from 'react-native'
|
||||
import { colors } from '../theme/mobile-theme'
|
||||
import { useHostDisplay, type HostDisplaySource } from '../transport/use-host-display'
|
||||
import type { MobileWebShellSessionState } from './mobile-web-shell-session-contract'
|
||||
import { ShellWaitingFrame } from './ShellWaitingFrame'
|
||||
import { useEasedDownloadProgress } from './use-eased-download-progress'
|
||||
|
||||
const DOWNLOADING_LABEL = 'Downloading the latest app'
|
||||
|
||||
function megabytes(bytes: number): string {
|
||||
return (bytes / 1_000_000).toFixed(1)
|
||||
}
|
||||
|
||||
/** The page bundle coming down from the host, as a bar over the bytes received. */
|
||||
export function ShellDownloadProgress({
|
||||
state,
|
||||
host
|
||||
}: {
|
||||
state: Extract<MobileWebShellSessionState, { kind: 'fetching' }>
|
||||
/** Null until the stored profile is read, which leaves the caption without a name. */
|
||||
host: HostDisplaySource | null
|
||||
}) {
|
||||
const fraction = state.totalBytes > 0 ? Math.min(1, state.receivedBytes / state.totalBytes) : 0
|
||||
const shown = useEasedDownloadProgress(fraction)
|
||||
const hostTitle = useHostDisplay(host).title
|
||||
const source = host === null ? '' : `from ${hostTitle} · `
|
||||
return (
|
||||
<ShellWaitingFrame
|
||||
label={DOWNLOADING_LABEL}
|
||||
indicator={
|
||||
<View
|
||||
style={styles.track}
|
||||
accessibilityRole="progressbar"
|
||||
accessibilityLabel={DOWNLOADING_LABEL}
|
||||
accessibilityValue={{ min: 0, max: 100, now: Math.round(fraction * 100) }}
|
||||
testID="mobile-web-shell-progress-bar"
|
||||
>
|
||||
<Animated.View style={[styles.fill, { transform: [{ scaleX: shown }] }]} />
|
||||
</View>
|
||||
}
|
||||
caption={`${source}${megabytes(state.receivedBytes)} of ${megabytes(state.totalBytes)} MB`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
track: {
|
||||
width: 180,
|
||||
height: 4,
|
||||
borderRadius: 2,
|
||||
overflow: 'hidden',
|
||||
backgroundColor: colors.bgRaised
|
||||
},
|
||||
fill: {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
transformOrigin: 'left',
|
||||
backgroundColor: colors.textSecondary
|
||||
}
|
||||
})
|
||||
@@ -26,7 +26,8 @@ vi.mock('react-native', () => ({
|
||||
create: (styles: unknown) => styles,
|
||||
absoluteFillObject: { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }
|
||||
},
|
||||
Text: 'Text'
|
||||
Text: 'Text',
|
||||
View: 'View'
|
||||
}))
|
||||
|
||||
const { ShellPageCover } = await import('./ShellWaitingFrame')
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { ActivityIndicator, Animated, Easing, StyleSheet, Text } from 'react-native'
|
||||
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. */
|
||||
@@ -12,12 +12,29 @@ export const SHELL_OPENING_LABEL = 'Opening workspace'
|
||||
*/
|
||||
export const SHELL_PAGE_COVER_FADE_MS = 220
|
||||
|
||||
/** The shell's neutral frame: one spinner and what it is waiting on. */
|
||||
export function ShellWaitingFrame({ label }: { label: string }) {
|
||||
/**
|
||||
* 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 (
|
||||
<>
|
||||
<ActivityIndicator color={colors.textSecondary} accessibilityLabel={label} />
|
||||
<Text style={styles.label}>{label}</Text>
|
||||
<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>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -78,10 +95,29 @@ const styles = StyleSheet.create({
|
||||
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']
|
||||
}
|
||||
})
|
||||
|
||||
@@ -261,7 +261,7 @@ describe('the screen the catch-all paints for each shell state', () => {
|
||||
it('says the host is unreachable rather than that the screen is unavailable', async () => {
|
||||
const tree = await renderRoute({ kind: 'offline' })
|
||||
const text = textOf(tree)
|
||||
expect(text).toContain('Connect to this host to download the workspace')
|
||||
expect(text).toContain('Connect to this host to download the latest app')
|
||||
expect(text).not.toContain('This workspace screen is not available on this host.')
|
||||
})
|
||||
|
||||
|
||||
@@ -8,8 +8,12 @@ import { SCREEN_SNAPSHOT, type ScreenDependencies } from './mobile-web-shell-scr
|
||||
export function screenModuleMocks(dependencies: ScreenDependencies) {
|
||||
return {
|
||||
'react-native': () => ({
|
||||
AccessibilityInfo: {
|
||||
isReduceMotionEnabled: () => Promise.resolve(false),
|
||||
addEventListener: () => ({ remove: () => {} })
|
||||
},
|
||||
ActivityIndicator: 'ActivityIndicator',
|
||||
Easing: { in: (fn: unknown) => fn, quad: 'quad' },
|
||||
Easing: { in: (fn: unknown) => fn, quad: 'quad', linear: 'linear' },
|
||||
// Enough of it for the cover to mount, fade and unmount. What the fade looks like is not this
|
||||
// test's business; that the cover is up until the page paints is, and that is the `visible` prop.
|
||||
Animated: {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
type Glide = { toValue: number; duration: number }
|
||||
|
||||
const motion: { reduced: boolean; glides: Glide[] } = vi.hoisted(() => ({
|
||||
reduced: false,
|
||||
glides: []
|
||||
}))
|
||||
|
||||
vi.mock('react-native', () => ({
|
||||
AccessibilityInfo: {
|
||||
isReduceMotionEnabled: () => Promise.resolve(motion.reduced),
|
||||
addEventListener: () => ({ remove: () => {} })
|
||||
},
|
||||
Easing: { linear: 'linear' },
|
||||
Animated: {
|
||||
Value: class {},
|
||||
timing: (_value: unknown, { toValue, duration }: Glide) => ({
|
||||
start: () => {
|
||||
motion.glides.push({ toValue, duration })
|
||||
}
|
||||
})
|
||||
}
|
||||
}))
|
||||
|
||||
import { createElement } from 'react'
|
||||
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
|
||||
import { useEasedDownloadProgress } from './use-eased-download-progress'
|
||||
|
||||
function Probe({ fraction }: { fraction: number }) {
|
||||
useEasedDownloadProgress(fraction)
|
||||
return null
|
||||
}
|
||||
|
||||
let tree: ReactTestRenderer | null = null
|
||||
|
||||
async function render(fraction: number): Promise<void> {
|
||||
await act(async () => {
|
||||
if (tree === null) {
|
||||
tree = create(createElement(Probe, { fraction }))
|
||||
} else {
|
||||
tree.update(createElement(Probe, { fraction }))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
async function renderAt(ms: number, fraction: number): Promise<void> {
|
||||
vi.setSystemTime(ms)
|
||||
await render(fraction)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers({ toFake: ['Date'] })
|
||||
vi.setSystemTime(0)
|
||||
motion.reduced = false
|
||||
motion.glides = []
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
act(() => tree?.unmount())
|
||||
tree = null
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
describe('useEasedDownloadProgress', () => {
|
||||
it('glides to each update over the averaged gap, the first measured from the mount', async () => {
|
||||
await render(0)
|
||||
await renderAt(800, 0.25)
|
||||
await renderAt(1200, 0.5)
|
||||
expect(motion.glides).toEqual([
|
||||
{ toValue: 0.25, duration: 800 },
|
||||
{ toValue: 0.5, duration: 600 }
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps the glide for a burst of replies instead of letting their tiny gaps shorten it', async () => {
|
||||
const burst = async (startMs: number, fractions: number[]): Promise<void> => {
|
||||
for (const [index, fraction] of fractions.entries()) {
|
||||
await renderAt(startMs + index * 5, fraction)
|
||||
}
|
||||
}
|
||||
await render(0)
|
||||
await burst(1600, [0.1, 0.15, 0.2, 0.25])
|
||||
await burst(3200, [0.35, 0.4, 0.45, 0.5])
|
||||
expect(motion.glides).toEqual(
|
||||
[0.1, 0.15, 0.2, 0.25, 0.35, 0.4, 0.45, 0.5].map((toValue) => ({ toValue, duration: 1600 }))
|
||||
)
|
||||
})
|
||||
|
||||
it('clamps the glide between 200 ms and 2 s', async () => {
|
||||
await render(0)
|
||||
await renderAt(50, 0.1)
|
||||
await renderAt(5050, 0.2)
|
||||
expect(motion.glides).toEqual([
|
||||
{ toValue: 0.1, duration: 200 },
|
||||
{ toValue: 0.2, duration: 2000 }
|
||||
])
|
||||
})
|
||||
|
||||
it('does not count the reduce-motion setting arriving after mount as an update', async () => {
|
||||
// The setting resolves inside this render's act, re-running the effect with the same fraction.
|
||||
await render(0.1)
|
||||
await renderAt(1000, 0.2)
|
||||
expect(motion.glides).toEqual([{ toValue: 0.2, duration: 1000 }])
|
||||
})
|
||||
|
||||
it('jumps straight to each update when reduce motion is on', async () => {
|
||||
motion.reduced = true
|
||||
await render(0)
|
||||
await renderAt(500, 0.4)
|
||||
expect(motion.glides).toEqual([{ toValue: 0.4, duration: 0 }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,48 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { Animated, Easing } from 'react-native'
|
||||
import { useReducedMotionEnabled } from '../onboarding/use-reduced-motion'
|
||||
|
||||
/** Each window reply of a concurrent batch is its own update, landing a few ms after the first. */
|
||||
const BURST_WINDOW_MS = 200
|
||||
const MIN_GLIDE_MS = 200
|
||||
const MAX_GLIDE_MS = 2000
|
||||
|
||||
/**
|
||||
* The received fraction as the bar shows it: each update glides from what is on screen to the new
|
||||
* value over the average gap between bursts of updates, so the fill keeps moving until the next
|
||||
* burst lands instead of jumping and waiting. Mount is the download's start.
|
||||
*/
|
||||
export function useEasedDownloadProgress(fraction: number): Animated.Value {
|
||||
const shown = useRef(new Animated.Value(fraction)).current
|
||||
const [mountedAt] = useState(Date.now)
|
||||
const burstAt = useRef(mountedAt)
|
||||
const averageGapMs = useRef<number | null>(null)
|
||||
const target = useRef(fraction)
|
||||
const reduceMotion = useReducedMotionEnabled()
|
||||
|
||||
useEffect(() => {
|
||||
if (fraction === target.current) {
|
||||
return
|
||||
}
|
||||
const now = Date.now()
|
||||
const sinceBurst = now - burstAt.current
|
||||
// A reply inside the current burst says nothing about when the next burst comes.
|
||||
if (averageGapMs.current === null || sinceBurst >= BURST_WINDOW_MS) {
|
||||
averageGapMs.current =
|
||||
averageGapMs.current === null ? sinceBurst : (averageGapMs.current + sinceBurst) / 2
|
||||
burstAt.current = now
|
||||
}
|
||||
target.current = fraction
|
||||
Animated.timing(shown, {
|
||||
toValue: fraction,
|
||||
duration:
|
||||
reduceMotion === true
|
||||
? 0
|
||||
: Math.min(MAX_GLIDE_MS, Math.max(MIN_GLIDE_MS, averageGapMs.current)),
|
||||
easing: Easing.linear,
|
||||
useNativeDriver: true
|
||||
}).start()
|
||||
}, [fraction, reduceMotion, shown])
|
||||
|
||||
return shown
|
||||
}
|
||||
@@ -35,8 +35,8 @@ describe('nativeMountingSubstitutes', () => {
|
||||
|
||||
it('throws on a member nobody substituted rather than recording an undefined native API', () => {
|
||||
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: the read itself is the assertion; the proxy has no declared shape.
|
||||
expect(() => (substitute('expo-crypto') as { digest?: unknown }).digest).toThrow(
|
||||
'Unsubstituted native member: expo-crypto.digest'
|
||||
expect(() => (substitute('expo-crypto') as { randomUUID?: unknown }).randomUUID).toThrow(
|
||||
'Unsubstituted native member: expo-crypto.randomUUID'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -24,8 +24,9 @@ import { reactNativeScreenMembers, screenNativeSubstitutes } from './screen-nati
|
||||
* React additionally has to be the one instance the test renderer drives, which is also why
|
||||
* `react/jsx-runtime` is the real module: the automatic runtime a screen compiles to must build
|
||||
* elements for that instance. `@noble/hashes` is the same pure-JS digest the product would run on a
|
||||
* device, and `expo-crypto` is routed through the Web Crypto the recording scheduler already pins,
|
||||
* which is both deterministic and what the library itself does off-device.
|
||||
* device, and also stands in for `expo-crypto`'s native SHA-256. `expo-crypto`'s random bytes are
|
||||
* routed through the Web Crypto the recording scheduler already pins, which is both deterministic
|
||||
* and what the library itself does off-device.
|
||||
*
|
||||
* Every substitute keeps one of the two trap shapes in `native-module-traps.ts`, which is where the
|
||||
* rule about unlisted members and `__esModule` lives. The view packages a mounted screen needs are
|
||||
@@ -77,7 +78,10 @@ export function nativeMountingSubstitutes(): Map<string, unknown> {
|
||||
'expo-crypto',
|
||||
partialNativeModule('expo-crypto', {
|
||||
getRandomBytes: (length: number) =>
|
||||
globalThis.crypto.getRandomValues(new Uint8Array(length))
|
||||
globalThis.crypto.getRandomValues(new Uint8Array(length)),
|
||||
// Hashed before it resolves, as the native module's synchronous digest is.
|
||||
CryptoDigestAlgorithm: { SHA256: 'SHA-256' },
|
||||
digest: async (_algorithm: string, data: Uint8Array) => sha256(data).buffer
|
||||
})
|
||||
],
|
||||
// The RN polyfill mobile bundles is this same pure implementation of the same encoding.
|
||||
|
||||
@@ -7,6 +7,15 @@ import { readMobileWebBundleErrorCode } from './mobile-web-bundle-operations'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import type { RpcResponse } from './types'
|
||||
|
||||
// Hashed before it resolves, as the native module's synchronous digest is.
|
||||
vi.mock('expo-crypto', async () => {
|
||||
const { sha256: hash } = await import('@noble/hashes/sha256')
|
||||
return {
|
||||
CryptoDigestAlgorithm: { SHA256: 'SHA-256' },
|
||||
digest: async (_algorithm: string, data: Uint8Array) => hash(data).buffer
|
||||
}
|
||||
})
|
||||
|
||||
const CHUNK_BYTES = 4
|
||||
|
||||
function toHex(bytes: Uint8Array): string {
|
||||
|
||||
@@ -7,6 +7,15 @@ import { MobileWebBundleFetchError } from './mobile-web-bundle-fetch-refusal'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import type { RpcResponse } from './types'
|
||||
|
||||
// Hashed before it resolves, as the native module's synchronous digest is.
|
||||
vi.mock('expo-crypto', async () => {
|
||||
const { sha256: hash } = await import('@noble/hashes/sha256')
|
||||
return {
|
||||
CryptoDigestAlgorithm: { SHA256: 'SHA-256' },
|
||||
digest: async (_algorithm: string, data: Uint8Array) => hash(data).buffer
|
||||
}
|
||||
})
|
||||
|
||||
/** The grids this fake host names on its manifest reply. The phone pages whatever grid the host
|
||||
* names, so a few KiB exercises the same shapes as the real 384 KiB range and 48 KiB chunk; the
|
||||
* desktop suite pins that the real host names `MOBILE_WEB_BUNDLE_RANGE_BYTES`. */
|
||||
|
||||
@@ -7,6 +7,15 @@ import { readMobileWebBundleErrorCode } from './mobile-web-bundle-operations'
|
||||
import type { RpcClient } from './rpc-client'
|
||||
import type { RpcResponse } from './types'
|
||||
|
||||
// Hashed before it resolves, as the native module's synchronous digest is.
|
||||
vi.mock('expo-crypto', async () => {
|
||||
const { sha256: hash } = await import('@noble/hashes/sha256')
|
||||
return {
|
||||
CryptoDigestAlgorithm: { SHA256: 'SHA-256' },
|
||||
digest: async (_algorithm: string, data: Uint8Array) => hash(data).buffer
|
||||
}
|
||||
})
|
||||
|
||||
function toHex(bytes: Uint8Array): string {
|
||||
return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { sha256 } from '@noble/hashes/sha256'
|
||||
import { CryptoDigestAlgorithm, digest } from 'expo-crypto'
|
||||
import { mobileWebBundleManifestRead } from './mobile-web-bundle-operations'
|
||||
import type {
|
||||
MobileWebBundleAssetRead,
|
||||
@@ -36,7 +36,7 @@ export type MobileWebBundleFetchResult = {
|
||||
|
||||
type AssetReassembly = {
|
||||
readonly entry: MobileWebBundleAssetRead
|
||||
readonly whole: Uint8Array
|
||||
readonly whole: Uint8Array<ArrayBuffer>
|
||||
outstandingWindows: number
|
||||
}
|
||||
|
||||
@@ -86,7 +86,12 @@ export async function fetchMobileWebBundle(args: {
|
||||
asset.whole.set(bytes, offset)
|
||||
asset.outstandingWindows -= 1
|
||||
if (asset.outstandingWindows === 0) {
|
||||
assets.set(asset.entry.path, verifyReassembledAsset(asset))
|
||||
const verified = await verifyReassembledAsset(asset)
|
||||
// A sibling failed while this hashed; the fetch already rejected with that failure.
|
||||
if (stopped.signal.aborted) {
|
||||
return
|
||||
}
|
||||
assets.set(asset.entry.path, verified)
|
||||
}
|
||||
// Per window, not per asset: the largest asset goes first, so asset completions bunch at the end.
|
||||
receivedBytes += bytes.byteLength
|
||||
@@ -191,12 +196,13 @@ function assertWindowFillsItsSlot(
|
||||
}
|
||||
|
||||
/** Every slot was accepted exactly once, so only the hash is left to say the bytes are right. */
|
||||
function verifyReassembledAsset(asset: AssetReassembly): Uint8Array {
|
||||
const digest = toHex(sha256(asset.whole))
|
||||
if (digest !== asset.entry.sha256) {
|
||||
async function verifyReassembledAsset(asset: AssetReassembly): Promise<Uint8Array> {
|
||||
// Native: hashing in JS held the JS thread for seconds, and React's progress renders waited on it.
|
||||
const hashed = toHex(new Uint8Array(await digest(CryptoDigestAlgorithm.SHA256, asset.whole)))
|
||||
if (hashed !== asset.entry.sha256) {
|
||||
throw new MobileWebBundleFetchError(
|
||||
'asset-checksum-mismatch',
|
||||
`bundle asset ${asset.entry.path} hashed ${digest}, not ${asset.entry.sha256}`
|
||||
`bundle asset ${asset.entry.path} hashed ${hashed}, not ${asset.entry.sha256}`
|
||||
)
|
||||
}
|
||||
return asset.whole
|
||||
|
||||
Reference in New Issue
Block a user