Merge commit 'e4feda6965' into mobile-rearch

This commit is contained in:
Jinwoo-H
2026-09-03 03:30:40 -04:00
17 changed files with 328 additions and 191 deletions
@@ -210,9 +210,10 @@ rather than reused.
Repeated WebView process loss or a health timeout can promote the compatible
verified previous generation. The recovery UI exposes:
- **Retry** — request the current package from the authenticated Desktop.
- **Use previous** — promote this host's verified prior generation.
- **Clear cache** — remove this host's package cache and require a verified
- **Retry** — request the current package from the authenticated Desktop. This
is the only primary button; the rest render as demoted text links.
- **Use last version** — promote this host's verified prior generation.
- **Reset** — remove this host's package cache and require a verified
redownload.
- **Switch hosts** — leave the affected host without changing another host's
cache or credentials.
@@ -302,11 +303,11 @@ result persists after reconnect, restart, or switching hosts.
| ------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | -------------------------------------------------------------------------- |
| No paired Desktop | Confirm Desktop is running and pairing completed | Scan a new QR code; never copy the encoded credential into a ticket |
| Desktop unreachable | Check LAN/Tailscale state, firewall port 6768, sleep state, and Connection Log | Reconnect, wake Desktop, or switch hosts |
| “Connect once to cache” | No compatible verified generation exists for that host | Connect to the paired Desktop and choose **Retry** |
| “Connect to … to finish setting up.” | No compatible verified generation exists for that host | Connect to the paired Desktop and choose **Retry** |
| Refresh warning while UI still works | Healthy cached generation is active but the new package failed | Keep using the cache; correct Desktop delivery, then **Retry** |
| Interface fails after one Desktop update | Regression follows the Desktop package | Stop that Desktop rollout and use the rollback runbook |
| Repeated WebView termination | Health/crash-loop recovery should select a compatible previous generation | Choose **Use previous** if offered; otherwise switch hosts |
| Corrupt or unreadable host cache | Package fails native verification or open | Choose **Clear cache**, then redownload from the authenticated Desktop |
| Repeated WebView termination | Health/crash-loop recovery should select a compatible previous generation | Choose **Use last version** if offered; otherwise switch hosts |
| Corrupt or unreadable host cache | Package fails native verification or open | Choose **Reset**, then redownload from the authenticated Desktop |
| Incompatible bridge | Desktop package does not support the installed shell | Restore a compatible Desktop package or install the required store release |
| Pairing, origin, bridge, picker, audio, notification, or recovery failure | Native-owned boundary is affected | Halt the native rollout and ship a corrected store build |
@@ -34,7 +34,7 @@ boundaries used by this runbook.
| ------------------------------------------------------------------------- | ----------------------------- | --------------------------------------------------------------------------- |
| Workspace UI regression follows one Desktop version | Desktop web package | Stop that Desktop rollout and restore known-good package content |
| New package times out or repeatedly terminates its WebView process | Native verified generation | Let automatic recovery promote the previous verified generation |
| One host reports corrupt or unreadable cached assets | Host-scoped native cache | Use **Clear cache** and redownload from an authenticated paired Desktop |
| One host reports corrupt or unreadable cached assets | Host-scoped native cache | Use **Reset** and redownload from an authenticated paired Desktop |
| Package requires an unsupported bridge version | Desktop/native compatibility | Restore a package compatible with the installed shell; do not force-open it |
| Pairing, encrypted connectivity, asset origin, cache, or bridge is broken | Native shell | Halt the store rollout and prepare a corrected native release |
| Notification, deep-link, permission, audio, picker, or recovery UI fails | Native shell | Halt the store rollout and prepare a corrected native release |
@@ -81,9 +81,9 @@ restarting must not make an affected Desktop safe.
manifest and assets and the page reaches the health boundary.
- A health timeout or third WebView process loss inside the crash window
attempts to promote the compatible verified previous generation.
- **Use previous** promotes the verified previous generation and removes the
failed generation from the rollback position.
- **Clear cache** closes the current package session, removes only the selected
- **Use last version** promotes the verified previous generation and removes
the failed generation from the rollback position.
- **Reset** closes the current package session, removes only the selected
paired host's cache, and requires a verified redownload.
- An implicit cold open may replace an invalid active generation with a
compatible verified previous generation. An explicit build open fails
@@ -134,17 +134,23 @@ or native recovery UI.
Use the least destructive control that addresses the observed failure:
The recovery UI promotes **Retry** as the single primary button and demotes the
remaining controls to text links, so support must name them by these labels:
- **Retry** asks the authenticated Desktop for its current package again. Use it
after connectivity or Desktop package delivery is corrected.
- **Use previous** switches to the verified prior generation for the selected
host. Use it for a newly activated functional regression or repeated process
failure when the previous action is available.
- **Clear cache** removes the selected host's verified generations and forces a
- **Use last version** switches to the verified prior generation for the
selected host. Use it for a newly activated functional regression or repeated
process failure when the previous action is available.
- **Reset** removes the selected host's verified generations and forces a
redownload. Use it for host-scoped corruption or when support explicitly
needs to eliminate cached-package state.
- **Switch hosts** leaves the affected paired Desktop without changing another
host's cache or credentials.
The shell shows a plain-language notice plus an `Error: <code>` support line;
that code is the same stable failure code recorded in diagnostics.
Cache clearing is not a Desktop rollback. If the Desktop still serves the bad
package, a cleared client downloads the same bad package again.
@@ -173,8 +179,8 @@ rather than a routine rollback.
Before production cutover, record iOS and Android evidence for:
1. Readiness timeout and three-process-loss automatic recovery.
2. Manual **Use previous** recovery.
3. **Clear cache** followed by an authenticated verified redownload.
2. Manual **Use last version** recovery.
3. **Reset** followed by an authenticated verified redownload.
4. Corrupt active generation with compatible previous-generation fallback.
5. Incompatible bridge, disconnected Desktop, pairing removal, and WebView
process loss.
+4 -4
View File
@@ -124,7 +124,7 @@ export default function HybridScreen() {
if (sessionId && view) {
void view.activateSessionView(sessionId).catch(() => {
if (activeSessionIdRef.current === sessionId) {
showWarning('Hosted session could not be restored.')
showWarning('Couldn’t reopen Orca. Try again.', 'session_reopen_failed')
}
})
}
@@ -320,7 +320,7 @@ export default function HybridScreen() {
routeHandoff: nativeRouteHandoffRef.current,
setHostedViewActive,
navigateToTerminalSettings: () => router.push('/terminal-settings'),
onNavigationFailure: () => showWarning('Terminal settings could not be opened.')
onNavigationFailure: () => showWarning('Couldn’t open Terminal settings.')
})
}
}
@@ -377,14 +377,14 @@ export default function HybridScreen() {
onUsePrevious={recoverPrevious}
onClearCache={clearCache}
onRecoveryFailure={() =>
showWarning('The workspace interface recovery action could not be completed.')
showWarning('That didn’t work. Try again.', 'recovery_action_failed')
}
onBridgeMessage={(message) => void handleBridgeMessage(message)}
onPageLoaded={() => {
hardwareBackHandoff.resetPage()
void postInit()
}}
onNavigationBlocked={() => showWarning('Navigation outside Orca was blocked.')}
onNavigationBlocked={() => showWarning('That link can’t be opened here.')}
onProcessTerminated={(sessionId) => {
hardwareBackHandoff.resetPage()
healthDeadlineRef.current.clear()
@@ -17,6 +17,7 @@ import {
mobileWebShellShowsNativeChrome
} from './mobile-web-shell-presentation-state'
import type { MobileWebPackageDownloadProgress } from './mobile-web-package-downloader'
import { mobileWebShellHostName, type MobileWebShellNotice } from './mobile-web-shell-notice'
type MobileWebHybridShellPresentationProps = {
viewRef: RefObject<MobileWebShellViewRef | null>
@@ -25,7 +26,7 @@ type MobileWebHybridShellPresentationProps = {
viewEpoch: number
packageLoading: boolean
packageProgress: MobileWebPackageDownloadProgress | undefined
packageWarning: string | undefined
packageWarning: MobileWebShellNotice | undefined
hostedViewActive: boolean
onBack: () => void
onShowHosts: () => void
@@ -66,6 +67,14 @@ export function MobileWebHybridShellPresentation({
packageLoading
})
const showNativeChrome = mobileWebShellShowsNativeChrome(presentationState)
// The notice already reads as a full sentence, so it replaces the generic status line.
const statusLine =
packageWarning?.message ??
(presentationState === 'package-loading'
? selectedHost
? `Connecting to ${mobileWebShellHostName(selectedHost.name)}…`
: 'Getting things ready…'
: `Couldn’t connect to ${mobileWebShellHostName(selectedHost?.name)}.`)
return (
<View style={[styles.container, { paddingTop: insets.top }]}>
@@ -82,9 +91,8 @@ export function MobileWebHybridShellPresentation({
</Pressable>
<View style={styles.headerCopy}>
<Text numberOfLines={1} style={styles.heading}>
{selectedHost?.name ?? 'Hybrid workspace UI'}
{selectedHost?.name ?? 'Orca'}
</Text>
<Text style={styles.headerMeta}>Verified desktop-served interface</Text>
</View>
{selectedHost ? (
<Pressable
@@ -105,19 +113,23 @@ export function MobileWebHybridShellPresentation({
<MobileWebPackageProgress progress={packageProgress} />
) : null}
{packageWarning ? (
<>
<Text accessibilityRole="alert" style={styles.warning}>
{packageWarning}
<View style={styles.noticeBanner}>
<Text accessibilityRole="alert" style={styles.noticeBannerText}>
{packageWarning.message}
</Text>
{packageWarning.code ? (
<Text style={styles.noticeCode}>Error: {packageWarning.code}</Text>
) : null}
<MobileWebRecoveryActions
canUsePrevious
align="start"
onClearCache={onClearCache}
onFailure={onRecoveryFailure}
onRetry={onRetryRecovery}
onShowHosts={onShowHosts}
onUsePrevious={onUsePrevious}
/>
</>
</View>
) : null}
<MobileWebShellView
key={`${session.sessionId}:${viewEpoch}`}
@@ -141,28 +153,29 @@ export function MobileWebHybridShellPresentation({
) : (
<MonitorSmartphone size={26} color={colors.textMuted} />
)}
<Text accessibilityLiveRegion="polite" style={styles.loadingTitle}>
{presentationState === 'package-loading'
? 'Preparing verified interface…'
: 'Workspace interface unavailable'}
</Text>
{packageLoading && packageProgress ? (
<MobileWebPackageProgress progress={packageProgress} />
) : (
<Text
accessibilityLiveRegion="polite"
accessibilityRole={packageWarning ? 'alert' : undefined}
style={styles.loadingTitle}
>
{statusLine}
</Text>
)}
{packageWarning?.code ? (
<Text style={styles.noticeCode}>Error: {packageWarning.code}</Text>
) : null}
{packageWarning ? (
<>
<Text accessibilityRole="alert" style={styles.loadingBody}>
{packageWarning}
</Text>
<MobileWebRecoveryActions
canUsePrevious={false}
onClearCache={onClearCache}
onFailure={onRecoveryFailure}
onRetry={onRetryRecovery}
onShowHosts={onShowHosts}
onUsePrevious={onUsePrevious}
/>
</>
<MobileWebRecoveryActions
canUsePrevious={false}
onClearCache={onClearCache}
onFailure={onRecoveryFailure}
onRetry={onRetryRecovery}
onShowHosts={onShowHosts}
onUsePrevious={onUsePrevious}
/>
) : null}
</View>
)}
@@ -13,14 +13,13 @@ export function MobileWebPackageProgress({
: 0
const label =
progress.phase === 'downloading'
? `Downloading workspace interface… ${percent}%`
: progress.phase === 'verifying'
? 'Verifying workspace interface…'
: 'Starting workspace interface…'
? `Downloading… ${formatBytes(progress.completedBytes)} of ${formatBytes(progress.totalBytes)}`
: 'Almost ready…'
return (
<View
style={styles.packageProgress}
testID="mobile-web-package-progress"
accessibilityRole="progressbar"
accessibilityLabel={label}
accessibilityValue={{ min: 0, max: 100, now: percent }}
@@ -31,9 +30,6 @@ export function MobileWebPackageProgress({
<View style={styles.packageProgressTrack}>
<View style={[styles.packageProgressFill, { width: `${percent}%` }]} />
</View>
<Text style={styles.packageProgressBytes}>
{formatBytes(progress.completedBytes)} of {formatBytes(progress.totalBytes)}
</Text>
</View>
)
}
@@ -7,6 +7,7 @@ type RecoveryAction = 'retry' | 'previous' | 'clear' | 'hosts'
type MobileWebRecoveryActionsProps = {
canUsePrevious: boolean
align?: 'center' | 'start'
onRetry: () => void | Promise<void>
onUsePrevious: () => void | Promise<void>
onClearCache: () => void | Promise<void>
@@ -16,6 +17,7 @@ type MobileWebRecoveryActionsProps = {
export function MobileWebRecoveryActions({
canUsePrevious,
align = 'center',
onRetry,
onUsePrevious,
onClearCache,
@@ -48,37 +50,80 @@ export function MobileWebRecoveryActions({
}
}
const actions = [
{ id: 'retry' as const, label: 'Retry', operation: onRetry },
// Retry is the only action most people need; the rest stay reachable as demoted links.
const secondaryActions = [
...(canUsePrevious
? [{ id: 'previous' as const, label: 'Use previous', operation: onUsePrevious }]
? [
{
id: 'previous' as const,
label: 'Use last version',
testID: 'mobile-web-recovery-previous',
operation: onUsePrevious
}
]
: []),
{ id: 'clear' as const, label: 'Clear cache', operation: onClearCache },
{ id: 'hosts' as const, label: 'Switch hosts', operation: onShowHosts }
{
id: 'clear' as const,
label: 'Reset',
testID: 'mobile-web-recovery-reset',
operation: onClearCache
},
{
id: 'hosts' as const,
label: 'Switch hosts',
testID: 'mobile-web-recovery-hosts',
operation: onShowHosts
}
]
const busy = Boolean(busyAction)
return (
<View accessibilityRole="toolbar" style={styles.recoveryActions}>
{actions.map((action) => (
<Pressable
key={action.id}
accessibilityLabel={`${action.label} workspace interface`}
accessibilityRole="button"
accessibilityState={{ disabled: Boolean(busyAction) }}
disabled={Boolean(busyAction)}
style={({ pressed }) => [
styles.recoveryButton,
pressed && styles.recoveryButtonPressed,
busyAction && styles.recoveryButtonDisabled
]}
onPress={() => void run(action.id, action.operation)}
>
{busyAction === action.id && showBusy ? (
<ActivityIndicator color={colors.textSecondary} size="small" />
) : null}
<Text style={styles.recoveryButtonText}>{action.label}</Text>
</Pressable>
))}
<View
accessibilityRole="toolbar"
style={[styles.recoveryActions, align === 'start' && styles.recoveryActionsStart]}
>
<Pressable
accessibilityLabel="Retry"
accessibilityRole="button"
accessibilityState={{ disabled: busy }}
disabled={busy}
testID="mobile-web-recovery-retry"
style={({ pressed }) => [
styles.recoveryPrimaryButton,
pressed && styles.recoveryPressed,
busy && styles.recoveryDisabled
]}
onPress={() => void run('retry', onRetry)}
>
{busyAction === 'retry' && showBusy ? (
<ActivityIndicator color={colors.bgBase} size="small" />
) : null}
<Text style={styles.recoveryPrimaryButtonText}>Retry</Text>
</Pressable>
<View style={[styles.recoveryLinkRow, align === 'start' && styles.recoveryActionsStart]}>
{secondaryActions.map((action) => (
<Pressable
key={action.id}
accessibilityLabel={action.label}
accessibilityRole="button"
accessibilityState={{ disabled: busy }}
disabled={busy}
hitSlop={8}
testID={action.testID}
style={({ pressed }) => [
styles.recoveryLink,
pressed && styles.recoveryPressed,
busy && styles.recoveryDisabled
]}
onPress={() => void run(action.id, action.operation)}
>
{busyAction === action.id && showBusy ? (
<ActivityIndicator color={colors.accentBlue} size="small" />
) : null}
<Text style={styles.recoveryLinkText}>{action.label}</Text>
</Pressable>
))}
</View>
</View>
)
}
+43 -34
View File
@@ -15,7 +15,6 @@ export const hybridShellStyles = StyleSheet.create({
headerButton: { width: 38, height: 38, alignItems: 'center', justifyContent: 'center' },
headerCopy: { flex: 1, minWidth: 0 },
heading: { color: colors.textPrimary, fontSize: 16, fontWeight: '600' },
headerMeta: { color: colors.textMuted, fontSize: 11, marginTop: 1 },
hostsButton: { paddingHorizontal: spacing.md, paddingVertical: spacing.sm },
hostsButtonText: {
color: colors.textSecondary,
@@ -24,53 +23,64 @@ export const hybridShellStyles = StyleSheet.create({
},
webContainer: { flex: 1, minHeight: 0 },
webView: { flex: 1, backgroundColor: colors.bgBase },
warning: {
color: colors.textSecondary,
backgroundColor: colors.bgRaised,
fontSize: typography.metaSize,
lineHeight: 17,
paddingHorizontal: spacing.md,
paddingVertical: spacing.sm
noticeBanner: {
gap: spacing.sm,
backgroundColor: colors.bgPanel,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: colors.borderSubtle,
paddingHorizontal: spacing.lg,
paddingVertical: spacing.md
},
recoveryActions: {
flexDirection: 'row',
flexWrap: 'wrap',
gap: spacing.xs,
backgroundColor: colors.bgRaised,
paddingHorizontal: spacing.md,
paddingBottom: spacing.sm
},
recoveryButton: {
minHeight: 32,
noticeBannerText: { color: colors.textPrimary, fontSize: 13, lineHeight: 18 },
noticeCode: { color: colors.textMuted, fontSize: 11 },
recoveryActions: { alignItems: 'center', gap: spacing.md },
recoveryActionsStart: { alignItems: 'flex-start' },
recoveryPrimaryButton: {
minHeight: 36,
flexDirection: 'row',
alignItems: 'center',
gap: spacing.xs,
justifyContent: 'center',
gap: spacing.sm,
borderRadius: radii.button,
backgroundColor: colors.bgPanel,
paddingHorizontal: spacing.sm
backgroundColor: colors.textPrimary,
paddingHorizontal: spacing.xl,
paddingVertical: spacing.sm
},
recoveryButtonPressed: { backgroundColor: colors.bgBase },
recoveryButtonDisabled: { opacity: 0.6 },
recoveryButtonText: {
color: colors.textPrimary,
recoveryPrimaryButtonText: {
color: colors.bgBase,
fontSize: typography.bodySize,
fontWeight: '600'
},
recoveryLinkRow: {
flexDirection: 'row',
flexWrap: 'wrap',
alignItems: 'center',
justifyContent: 'center',
columnGap: spacing.lg,
rowGap: spacing.sm
},
recoveryLink: { flexDirection: 'row', alignItems: 'center', gap: spacing.xs },
recoveryLinkText: {
color: colors.accentBlue,
fontSize: typography.metaSize,
fontWeight: '600'
},
recoveryPressed: { opacity: 0.7 },
recoveryDisabled: { opacity: 0.5 },
loadingState: {
flex: 1,
alignItems: 'center',
justifyContent: 'center',
gap: spacing.sm,
gap: spacing.md,
paddingHorizontal: spacing.xl
},
loadingTitle: { color: colors.textPrimary, fontSize: typography.bodySize, fontWeight: '600' },
loadingBody: {
color: colors.textSecondary,
fontSize: typography.metaSize,
lineHeight: 18,
loadingTitle: {
color: colors.textPrimary,
fontSize: typography.bodySize,
fontWeight: '600',
textAlign: 'center'
},
packageProgress: { width: '100%', maxWidth: 320, gap: spacing.xs },
packageProgress: { width: '100%', maxWidth: 320, gap: spacing.sm },
packageProgressLabel: {
color: colors.textSecondary,
fontSize: typography.metaSize,
@@ -86,6 +96,5 @@ export const hybridShellStyles = StyleSheet.create({
height: '100%',
borderRadius: radii.button,
backgroundColor: colors.textSecondary
},
packageProgressBytes: { color: colors.textMuted, fontSize: 11, textAlign: 'center' }
}
})
@@ -1,13 +1,23 @@
import { mobileWebShellHostName, type MobileWebShellNotice } from './mobile-web-shell-notice'
export function mobileWebPackageRefreshWarning(
failureCode: string,
hasHealthyInterface: boolean
): string {
hasHealthyInterface: boolean,
hostName?: string
): MobileWebShellNotice {
const host = mobileWebShellHostName(hostName)
if (failureCode === 'incompatible_bridge') {
return hasHealthyInterface
? 'Using the last healthy interface because the refreshed interface is not compatible with this Orca Mobile version.'
: 'This desktop’s workspace interface is not compatible with this Orca Mobile version.'
return {
message: hasHealthyInterface
? `Update Orca Mobile to get the latest from ${host}.`
: `Update Orca Mobile to open ${host}.`,
code: failureCode
}
}
return {
message: hasHealthyInterface
? `Couldn’t update from ${host}. Showing the last version that worked.`
: `Couldn’t load ${host}.`,
code: failureCode
}
return hasHealthyInterface
? 'Using the last healthy interface because the desktop package could not be refreshed.'
: 'The desktop did not provide a valid workspace interface.'
}
@@ -1,5 +1,6 @@
import type { MobileWebShellSession } from '@orca/expo-mobile-web-shell'
import type { MobileWebPackageDownloadProgress } from './mobile-web-package-downloader'
import type { MobileWebShellNotice } from './mobile-web-shell-notice'
export type MobileWebPackageSession = {
session: MobileWebShellSession | null
@@ -7,12 +8,12 @@ export type MobileWebPackageSession = {
viewEpoch: number
packageLoading: boolean
packageProgress: MobileWebPackageDownloadProgress | undefined
packageWarning: string | undefined
packageWarning: MobileWebShellNotice | undefined
markHealthy: (sessionId: string) => Promise<void>
handleHealthTimeout: (sessionId: string) => Promise<void>
handleProcessTerminated: (sessionId: string) => Promise<void>
retryPackage: () => void
recoverPrevious: () => Promise<void>
clearCache: () => Promise<void>
showWarning: (warning: string) => void
showWarning: (message: string, code?: string) => void
}
@@ -18,7 +18,9 @@ describe('mobile hybrid rollback runbook', () => {
})
it('documents every native recovery action by its product label', () => {
for (const label of ['Retry', 'Use previous', 'Clear cache', 'Switch hosts']) {
expect(recoveryActions).toContain('accessibilityLabel="Retry"')
expect(runbook).toContain('**Retry**')
for (const label of ['Use last version', 'Reset', 'Switch hosts']) {
expect(recoveryActions).toContain(`label: '${label}'`)
expect(runbook).toContain(`**${label}**`)
}
@@ -9,6 +9,10 @@ const recoveryActionsSource = readFileSync(
new URL('./MobileWebRecoveryActions.tsx', import.meta.url),
'utf8'
)
const progressSource = readFileSync(
new URL('./MobileWebPackageProgress.tsx', import.meta.url),
'utf8'
)
describe('mobile web shell accessibility', () => {
it('keeps shell navigation controls named and exposed as buttons', () => {
@@ -20,24 +24,48 @@ describe('mobile web shell accessibility', () => {
)
})
it('announces package loading and warnings', () => {
expect(shellSource).toMatch(
/accessibilityLiveRegion="polite"[\s\S]*?Preparing verified interface…/
)
expect(shellSource.match(/accessibilityRole="alert"/g)).toHaveLength(2)
it('announces status and warnings', () => {
expect(shellSource).toMatch(/accessibilityLiveRegion="polite"[\s\S]*?\{statusLine\}/)
expect(shellSource).toContain("accessibilityRole={packageWarning ? 'alert' : undefined}")
expect(shellSource).toContain('accessibilityRole="alert"')
expect(progressSource).toContain('accessibilityRole="progressbar"')
expect(progressSource).toContain('accessibilityLiveRegion="polite"')
})
it('names recovery controls', () => {
it('names recovery controls and anchors them on stable test ids', () => {
expect(recoveryActionsSource).toContain('accessibilityRole="toolbar"')
expect(recoveryActionsSource).toContain(
'accessibilityLabel={`${action.label} workspace interface`}'
)
expect(recoveryActionsSource).toContain("label: 'Retry'")
expect(recoveryActionsSource).toContain("label: 'Use previous'")
expect(recoveryActionsSource).toContain("label: 'Clear cache'")
expect(recoveryActionsSource).toContain("label: 'Switch hosts'")
expect(recoveryActionsSource).toContain(
'accessibilityState={{ disabled: Boolean(busyAction) }}'
)
expect(recoveryActionsSource).toContain('accessibilityLabel={action.label}')
expect(recoveryActionsSource).toContain('accessibilityState={{ disabled: busy }}')
for (const [testID, label] of [
['mobile-web-recovery-retry', 'Retry'],
['mobile-web-recovery-previous', 'Use last version'],
['mobile-web-recovery-reset', 'Reset'],
['mobile-web-recovery-hosts', 'Switch hosts']
]) {
expect(recoveryActionsSource).toContain(testID)
expect(recoveryActionsSource).toContain(label)
}
})
it('keeps implementation vocabulary out of the shell copy', () => {
for (const source of [shellSource, recoveryActionsSource, progressSource]) {
for (const banned of [
'verified',
'cache',
'hosted session',
'workspace interface',
'workspace UI',
'desktop-served'
]) {
expect(renderedCopy(source).toLowerCase()).not.toContain(banned.toLowerCase())
}
}
})
})
/** Quoted literals a user can read, excluding prop names and identifiers. */
function renderedCopy(source: string): string {
return [...source.matchAll(/'([^'\n]*)'/g), ...source.matchAll(/`([^`\n]*)`/g)]
.map((match) => match[1])
.join('\n')
}
@@ -0,0 +1,12 @@
// A shell notice is what the user reads; `code` is the stable support identifier shown
// underneath it, so product copy never has to carry an error token.
export type MobileWebShellNotice = {
message: string
code?: string
}
const DEFAULT_HOST_NAME = 'your computer'
export function mobileWebShellHostName(name: string | undefined): string {
return name?.trim() || DEFAULT_HOST_NAME
}
@@ -32,7 +32,7 @@ export function useMobileWebNavigationIntentHandoff(options: {
postMessage: (message: MobileWebBridgeShellMessage) => Promise<void>
rememberRoute: (route: MobileWebResumeRoute) => void
onNavigationResolved?: (intent: MobileWebNavigationIntent, route: MobileWebResumeRoute) => void
showWarning: (warning: string) => void
showWarning: (message: string, code?: string) => void
}): void {
const [intent, setIntent] = useState<MobileWebNavigationIntent | null>(null)
const activeIntent =
@@ -107,7 +107,8 @@ export function useMobileWebNavigationIntentHandoff(options: {
if (!cancelled && MOBILE_WEB_NAVIGATION_INTENTS.consume(activeIntent.sequence)) {
setIntent(null)
options.showWarning(
`${navigationIntentFailureSubject(activeIntent.source)} could not be verified (${mobileWebBridgeErrorCode(error)}).`
`${navigationIntentFailureSubject(activeIntent.source)} couldn’t be opened.`,
mobileWebBridgeErrorCode(error)
)
}
}
@@ -4,6 +4,7 @@ import type { HostProfile } from '../transport/types'
import { mobileWebDiagnosticsStore } from './mobile-web-diagnostics-store'
import { removeMobileWebHostCache } from './mobile-web-native-stager'
import type { MobileWebProcessFailureTracker } from './mobile-web-process-failure-tracker'
import { mobileWebShellHostName, type MobileWebShellNotice } from './mobile-web-shell-notice'
type PackageRecoveryState = {
host: HostProfile | undefined
@@ -17,7 +18,7 @@ type PackageRecoveryState = {
setSessionHostId: Dispatch<SetStateAction<string | undefined>>
setViewEpoch: Dispatch<SetStateAction<number>>
setPackageLoading: Dispatch<SetStateAction<boolean>>
setPackageWarning: Dispatch<SetStateAction<string | undefined>>
setPackageWarning: Dispatch<SetStateAction<MobileWebShellNotice | undefined>>
setRefreshEpoch: Dispatch<SetStateAction<number>>
}
@@ -46,7 +47,7 @@ export function useMobileWebPackageRecovery({
setRefreshEpoch
}: PackageRecoveryState): MobileWebPackageRecoveryActions {
const recoverSession = useCallback(
async (sessionId: string, warning: string, failureCode: string) => {
async (sessionId: string, warning: MobileWebShellNotice, failureCode: string) => {
const current = ownedSessionRef.current
const hostEpoch = hostEpochRef.current
const hostId = activeHostIdRef.current
@@ -76,9 +77,10 @@ export function useMobileWebPackageRecovery({
ownedSessionRef.current?.sessionId === sessionId
) {
setViewEpoch((value) => value + 1)
setPackageWarning(
'The workspace view restarted; no previous healthy interface is available.'
)
setPackageWarning({
message: 'Orca restarted. There’s no earlier version to go back to.',
code: 'no_previous_version'
})
mobileWebDiagnosticsStore.restarted(hostId, current.buildId)
}
}
@@ -123,7 +125,10 @@ export function useMobileWebPackageRecovery({
current?.sessionId === sessionId &&
current.buildId === owned.buildId
) {
setPackageWarning('The workspace interface is running but could not be marked healthy.')
setPackageWarning({
message: 'Orca started, but couldn’t finish its checks.',
code: 'health_mark_failed'
})
const hostId = activeHostIdRef.current
if (hostId) {
mobileWebDiagnosticsStore.warning(hostId, 'health_mark_failed')
@@ -138,7 +143,7 @@ export function useMobileWebPackageRecovery({
async (sessionId: string) => {
await recoverSession(
sessionId,
'The refreshed interface did not become healthy; the previous verified version was restored.',
{ message: 'The update didn’t start correctly, so the last version that worked is back.' },
'health_timeout'
)
},
@@ -153,7 +158,7 @@ export function useMobileWebPackageRecovery({
}
if (!processFailuresRef.current.record(current.buildId)) {
setViewEpoch((value) => value + 1)
setPackageWarning('The workspace view stopped and was restarted.')
setPackageWarning({ message: 'Orca stopped unexpectedly and restarted.' })
const hostId = activeHostIdRef.current
if (hostId) {
mobileWebDiagnosticsStore.restarted(hostId, current.buildId)
@@ -162,7 +167,7 @@ export function useMobileWebPackageRecovery({
}
await recoverSession(
sessionId,
'The workspace view stopped repeatedly; the previous verified version was restored.',
{ message: 'Orca kept stopping, so the last version that worked is back.' },
'webview_crash_loop'
)
},
@@ -188,12 +193,15 @@ export function useMobileWebPackageRecovery({
const recoverPrevious = useCallback(async () => {
const current = ownedSessionRef.current
if (!current) {
setPackageWarning('No previous verified workspace interface is available.')
setPackageWarning({
message: 'There’s no earlier version to go back to.',
code: 'no_previous_version'
})
return
}
await recoverSession(
current.sessionId,
'The previous verified workspace interface was restored.',
{ message: 'Went back to the last version that worked.' },
'manual_recovery'
)
}, [ownedSessionRef, recoverSession, setPackageWarning])
@@ -222,7 +230,10 @@ export function useMobileWebPackageRecovery({
} catch {
if (hostEpochRef.current === hostEpoch) {
setPackageLoading(false)
setPackageWarning('The workspace interface cache could not be cleared.')
setPackageWarning({
message: `Couldn’t reset ${mobileWebShellHostName(host.name)}. Try again.`,
code: 'reset_failed'
})
}
return
}
@@ -12,6 +12,7 @@ import {
import { mobileWebDiagnosticsStore } from './mobile-web-diagnostics-store'
import type { MobileWebCachedBuildProbe } from './mobile-web-cached-build-probe'
import { mobileWebPackageRefreshWarning } from './mobile-web-package-refresh-warning'
import type { MobileWebShellNotice } from './mobile-web-shell-notice'
import type { MobileWebPackageCapability } from './use-mobile-web-package-capability'
type PublishSession = (
@@ -35,7 +36,7 @@ export function useMobileWebPackageRefresh(args: {
publishSession: PublishSession
refreshEpoch: number
setPackageLoading: (loading: boolean) => void
setPackageWarning: (warning: string | undefined) => void
setPackageWarning: (warning: MobileWebShellNotice | undefined) => void
setPackageProgress: (progress: MobileWebPackageDownloadProgress | undefined) => void
}) {
const {
@@ -102,10 +103,7 @@ export function useMobileWebPackageRefresh(args: {
}
if (rejectedBuildIdsRef.current.has(downloaded.commit.buildId)) {
mobileWebDiagnosticsStore.warning(host.id, 'rejected_build')
finishPackage(
false,
'Using the previous verified interface until the desktop build changes.'
)
finishPackage(false, { message: 'Showing the last version that worked.' })
return
}
const activationStartedAt = Date.now()
@@ -127,7 +125,7 @@ export function useMobileWebPackageRefresh(args: {
console.warn('[mobile-web] package refresh failed', { code: failureCode })
finishPackage(
false,
mobileWebPackageRefreshWarning(failureCode, Boolean(ownedSessionRef.current))
mobileWebPackageRefreshWarning(failureCode, Boolean(ownedSessionRef.current), host.name)
)
}
}
@@ -148,7 +146,7 @@ export function useMobileWebPackageRefresh(args: {
finishPackage(true)
}
function finishPackage(success: boolean, warning?: string): void {
function finishPackage(success: boolean, warning?: MobileWebShellNotice): void {
setPackageLoading(false)
setPackageProgress(undefined)
if (success || warning) {
@@ -224,9 +224,10 @@ describe('useMobileWebPackageSession', () => {
expect(packageSession?.session).toBeNull()
expect(packageSession?.packageLoading).toBe(false)
expect(packageSession?.packageWarning).toBe(
'Update Orca on this desktop to use its workspace interface.'
)
expect(packageSession?.packageWarning).toEqual({
message: 'Update Orca on Desktop to continue.',
code: 'host_update_required'
})
expect(native.closeSession).toHaveBeenCalledWith(SESSION_A.sessionId)
expect(downloadPackage).not.toHaveBeenCalled()
expect(sendRequest).toHaveBeenCalledTimes(1)
@@ -242,9 +243,10 @@ describe('useMobileWebPackageSession', () => {
await mount('connected')
expect(packageSession?.session).toBeNull()
expect(packageSession?.packageWarning).toBe(
'Update Orca on this desktop to use its workspace interface.'
)
expect(packageSession?.packageWarning).toEqual({
message: 'Update Orca on Desktop to continue.',
code: 'host_update_required'
})
expect(native.openSession).not.toHaveBeenCalled()
expect(downloadPackage).not.toHaveBeenCalled()
})
@@ -468,14 +470,8 @@ describe('useMobileWebPackageSession', () => {
})
it.each([
[
'incompatible_bridge',
'Using the last healthy interface because the refreshed interface is not compatible with this Orca Mobile version.'
],
[
'test_failure',
'Using the last healthy interface because the desktop package could not be refreshed.'
]
['incompatible_bridge', 'Update Orca Mobile to get the latest from Desktop.'],
['test_failure', 'Couldn’t update from Desktop. Showing the last version that worked.']
])('retains a cached session after %s package refresh failure', async (code, warning) => {
native.openSession.mockResolvedValue(SESSION_A)
downloadFailure.code = code
@@ -484,15 +480,12 @@ describe('useMobileWebPackageSession', () => {
await mount('connected')
expect(packageSession?.session).toEqual(SESSION_A)
expect(packageSession?.packageWarning).toBe(warning)
expect(packageSession?.packageWarning).toEqual({ message: warning, code })
})
it.each([
[
'incompatible_bridge',
'This desktop’s workspace interface is not compatible with this Orca Mobile version.'
],
['test_failure', 'The desktop did not provide a valid workspace interface.']
['incompatible_bridge', 'Update Orca Mobile to open Desktop.'],
['test_failure', 'Couldn’t load Desktop.']
])('reports %s package refresh failure without a cached session', async (code, warning) => {
native.openSession.mockRejectedValue(new Error('cache unavailable'))
downloadFailure.code = code
@@ -502,7 +495,7 @@ describe('useMobileWebPackageSession', () => {
expect(packageSession?.session).toBeNull()
expect(packageSession?.packageLoading).toBe(false)
expect(packageSession?.packageWarning).toBe(warning)
expect(packageSession?.packageWarning).toEqual({ message: warning, code })
})
it('remounts after isolated process loss and rolls back a crash loop', async () => {
@@ -515,7 +508,9 @@ describe('useMobileWebPackageSession', () => {
await packageSession?.markHealthy(SESSION_B.sessionId)
})
expect(packageSession?.viewEpoch).toBe(1)
expect(packageSession?.packageWarning).toBe('The workspace view stopped and was restarted.')
expect(packageSession?.packageWarning).toEqual({
message: 'Orca stopped unexpectedly and restarted.'
})
expect(native.recoverSession).not.toHaveBeenCalled()
await act(async () => {
@@ -544,9 +539,9 @@ describe('useMobileWebPackageSession', () => {
expect(native.recoverSession).toHaveBeenCalledWith(SESSION_B.sessionId)
expect(packageSession?.session).toEqual(SESSION_A)
expect(packageSession?.packageWarning).toBe(
'The previous verified workspace interface was restored.'
)
expect(packageSession?.packageWarning).toEqual({
message: 'Went back to the last version that worked.'
})
})
it('clears only the selected host cache and downloads it again', async () => {
@@ -14,10 +14,9 @@ import { useMobileWebPackageCapability } from './use-mobile-web-package-capabili
import { useMobileWebPackageRecovery } from './use-mobile-web-package-recovery'
import { useMobileWebPackageRefresh } from './use-mobile-web-package-refresh'
import type { MobileWebPackageSession } from './mobile-web-package-session-state'
import { mobileWebShellHostName, type MobileWebShellNotice } from './mobile-web-shell-notice'
export type { MobileWebPackageSession } from './mobile-web-package-session-state'
const MOBILE_WEB_PACKAGE_UPDATE_REQUIRED_WARNING =
'Update Orca on this desktop to use its workspace interface.'
export function useMobileWebPackageSession({
client,
host,
@@ -42,7 +41,7 @@ export function useMobileWebPackageSession({
const [viewEpoch, setViewEpoch] = useState(0)
const [packageLoading, setPackageLoading] = useState(false)
const [packageProgress, setPackageProgress] = useState<MobileWebPackageDownloadProgress>()
const [packageWarning, setPackageWarning] = useState<string>()
const [packageWarning, setPackageWarning] = useState<MobileWebShellNotice>()
const [refreshEpoch, setRefreshEpoch] = useState(0)
const packageCapability = useMobileWebPackageCapability({
client,
@@ -58,7 +57,10 @@ export function useMobileWebPackageSession({
const effectivePackageWarning =
packageWarning ??
(packageCapability.status === 'update-required'
? MOBILE_WEB_PACKAGE_UPDATE_REQUIRED_WARNING
? {
message: `Update Orca on ${mobileWebShellHostName(host?.name)} to continue.`,
code: 'host_update_required'
}
: undefined)
const publishSession = useCallback(
@@ -152,7 +154,9 @@ export function useMobileWebPackageSession({
setPackageLoading(false)
setPackageWarning(
(current) =>
current ?? 'Connect to this desktop once to cache its verified workspace UI.'
current ?? {
message: `Connect to ${mobileWebShellHostName(host.name)} to finish setting up.`
}
)
}
}
@@ -200,6 +204,11 @@ export function useMobileWebPackageSession({
setPackageProgress
})
const showWarning = useCallback(
(message: string, code?: string) => setPackageWarning({ message, code }),
[]
)
const {
markHealthy,
handleHealthTimeout,
@@ -236,6 +245,6 @@ export function useMobileWebPackageSession({
retryPackage,
recoverPrevious,
clearCache,
showWarning: setPackageWarning
showWarning
}
}