diff --git a/docs/reference/mobile-hybrid-webview-architecture.md b/docs/reference/mobile-hybrid-webview-architecture.md index 1c2731d61a7..ce853837833 100644 --- a/docs/reference/mobile-hybrid-webview-architecture.md +++ b/docs/reference/mobile-hybrid-webview-architecture.md @@ -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 | diff --git a/docs/reference/mobile-hybrid-webview-rollback.md b/docs/reference/mobile-hybrid-webview-rollback.md index b542abc73db..dcb2a099699 100644 --- a/docs/reference/mobile-hybrid-webview-rollback.md +++ b/docs/reference/mobile-hybrid-webview-rollback.md @@ -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: ` 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. diff --git a/mobile/app/hybrid.tsx b/mobile/app/hybrid.tsx index 3e0a348cf66..37f7fc8989d 100644 --- a/mobile/app/hybrid.tsx +++ b/mobile/app/hybrid.tsx @@ -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() diff --git a/mobile/src/mobile-web/MobileWebHybridShellPresentation.tsx b/mobile/src/mobile-web/MobileWebHybridShellPresentation.tsx index 183ad149ad5..d9c1daca23e 100644 --- a/mobile/src/mobile-web/MobileWebHybridShellPresentation.tsx +++ b/mobile/src/mobile-web/MobileWebHybridShellPresentation.tsx @@ -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 @@ -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 ( @@ -82,9 +91,8 @@ export function MobileWebHybridShellPresentation({ - {selectedHost?.name ?? 'Hybrid workspace UI'} + {selectedHost?.name ?? 'Orca'} - Verified desktop-served interface {selectedHost ? ( ) : null} {packageWarning ? ( - <> - - {packageWarning} + + + {packageWarning.message} + {packageWarning.code ? ( + Error: {packageWarning.code} + ) : null} - + ) : null} )} - - {presentationState === 'package-loading' - ? 'Preparing verified interface…' - : 'Workspace interface unavailable'} - {packageLoading && packageProgress ? ( + ) : ( + + {statusLine} + + )} + {packageWarning?.code ? ( + Error: {packageWarning.code} ) : null} {packageWarning ? ( - <> - - {packageWarning} - - - + ) : null} )} diff --git a/mobile/src/mobile-web/MobileWebPackageProgress.tsx b/mobile/src/mobile-web/MobileWebPackageProgress.tsx index c8d14814f70..6c79271eca0 100644 --- a/mobile/src/mobile-web/MobileWebPackageProgress.tsx +++ b/mobile/src/mobile-web/MobileWebPackageProgress.tsx @@ -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 ( - - {formatBytes(progress.completedBytes)} of {formatBytes(progress.totalBytes)} - ) } diff --git a/mobile/src/mobile-web/MobileWebRecoveryActions.tsx b/mobile/src/mobile-web/MobileWebRecoveryActions.tsx index c0ad728a169..311c6ffb004 100644 --- a/mobile/src/mobile-web/MobileWebRecoveryActions.tsx +++ b/mobile/src/mobile-web/MobileWebRecoveryActions.tsx @@ -7,6 +7,7 @@ type RecoveryAction = 'retry' | 'previous' | 'clear' | 'hosts' type MobileWebRecoveryActionsProps = { canUsePrevious: boolean + align?: 'center' | 'start' onRetry: () => void | Promise onUsePrevious: () => void | Promise onClearCache: () => void | Promise @@ -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 ( - - {actions.map((action) => ( - [ - styles.recoveryButton, - pressed && styles.recoveryButtonPressed, - busyAction && styles.recoveryButtonDisabled - ]} - onPress={() => void run(action.id, action.operation)} - > - {busyAction === action.id && showBusy ? ( - - ) : null} - {action.label} - - ))} + + [ + styles.recoveryPrimaryButton, + pressed && styles.recoveryPressed, + busy && styles.recoveryDisabled + ]} + onPress={() => void run('retry', onRetry)} + > + {busyAction === 'retry' && showBusy ? ( + + ) : null} + Retry + + + {secondaryActions.map((action) => ( + [ + styles.recoveryLink, + pressed && styles.recoveryPressed, + busy && styles.recoveryDisabled + ]} + onPress={() => void run(action.id, action.operation)} + > + {busyAction === action.id && showBusy ? ( + + ) : null} + {action.label} + + ))} + ) } diff --git a/mobile/src/mobile-web/hybrid-shell-styles.ts b/mobile/src/mobile-web/hybrid-shell-styles.ts index 3549434d269..ea939eafaab 100644 --- a/mobile/src/mobile-web/hybrid-shell-styles.ts +++ b/mobile/src/mobile-web/hybrid-shell-styles.ts @@ -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' } + } }) diff --git a/mobile/src/mobile-web/mobile-web-package-refresh-warning.ts b/mobile/src/mobile-web/mobile-web-package-refresh-warning.ts index 80fcdd9930b..6736df435e7 100644 --- a/mobile/src/mobile-web/mobile-web-package-refresh-warning.ts +++ b/mobile/src/mobile-web/mobile-web-package-refresh-warning.ts @@ -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.' } diff --git a/mobile/src/mobile-web/mobile-web-package-session-state.ts b/mobile/src/mobile-web/mobile-web-package-session-state.ts index 24fcf2a9f64..08a86a12ea6 100644 --- a/mobile/src/mobile-web/mobile-web-package-session-state.ts +++ b/mobile/src/mobile-web/mobile-web-package-session-state.ts @@ -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 handleHealthTimeout: (sessionId: string) => Promise handleProcessTerminated: (sessionId: string) => Promise retryPackage: () => void recoverPrevious: () => Promise clearCache: () => Promise - showWarning: (warning: string) => void + showWarning: (message: string, code?: string) => void } diff --git a/mobile/src/mobile-web/mobile-web-rollback-runbook.test.ts b/mobile/src/mobile-web/mobile-web-rollback-runbook.test.ts index 713766b07b2..f042b25784e 100644 --- a/mobile/src/mobile-web/mobile-web-rollback-runbook.test.ts +++ b/mobile/src/mobile-web/mobile-web-rollback-runbook.test.ts @@ -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}**`) } diff --git a/mobile/src/mobile-web/mobile-web-shell-accessibility-source.test.ts b/mobile/src/mobile-web/mobile-web-shell-accessibility-source.test.ts index ceb61705ee6..d7806d752e9 100644 --- a/mobile/src/mobile-web/mobile-web-shell-accessibility-source.test.ts +++ b/mobile/src/mobile-web/mobile-web-shell-accessibility-source.test.ts @@ -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') +} diff --git a/mobile/src/mobile-web/mobile-web-shell-notice.ts b/mobile/src/mobile-web/mobile-web-shell-notice.ts new file mode 100644 index 00000000000..b6fa36f68b5 --- /dev/null +++ b/mobile/src/mobile-web/mobile-web-shell-notice.ts @@ -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 +} diff --git a/mobile/src/mobile-web/use-mobile-web-navigation-intent-handoff.ts b/mobile/src/mobile-web/use-mobile-web-navigation-intent-handoff.ts index 0d0e08d336a..54286e343a6 100644 --- a/mobile/src/mobile-web/use-mobile-web-navigation-intent-handoff.ts +++ b/mobile/src/mobile-web/use-mobile-web-navigation-intent-handoff.ts @@ -32,7 +32,7 @@ export function useMobileWebNavigationIntentHandoff(options: { postMessage: (message: MobileWebBridgeShellMessage) => Promise 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(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) ) } } diff --git a/mobile/src/mobile-web/use-mobile-web-package-recovery.ts b/mobile/src/mobile-web/use-mobile-web-package-recovery.ts index 47bb35d337f..a29f3c64a08 100644 --- a/mobile/src/mobile-web/use-mobile-web-package-recovery.ts +++ b/mobile/src/mobile-web/use-mobile-web-package-recovery.ts @@ -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> setViewEpoch: Dispatch> setPackageLoading: Dispatch> - setPackageWarning: Dispatch> + setPackageWarning: Dispatch> setRefreshEpoch: Dispatch> } @@ -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 } diff --git a/mobile/src/mobile-web/use-mobile-web-package-refresh.ts b/mobile/src/mobile-web/use-mobile-web-package-refresh.ts index 500db6f64ce..9589ac09507 100644 --- a/mobile/src/mobile-web/use-mobile-web-package-refresh.ts +++ b/mobile/src/mobile-web/use-mobile-web-package-refresh.ts @@ -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) { diff --git a/mobile/src/mobile-web/use-mobile-web-package-session.test.ts b/mobile/src/mobile-web/use-mobile-web-package-session.test.ts index 2d168bffca3..e0113e05613 100644 --- a/mobile/src/mobile-web/use-mobile-web-package-session.test.ts +++ b/mobile/src/mobile-web/use-mobile-web-package-session.test.ts @@ -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 () => { diff --git a/mobile/src/mobile-web/use-mobile-web-package-session.ts b/mobile/src/mobile-web/use-mobile-web-package-session.ts index f13c9392505..a1c531d759d 100644 --- a/mobile/src/mobile-web/use-mobile-web-package-session.ts +++ b/mobile/src/mobile-web/use-mobile-web-package-session.ts @@ -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() - const [packageWarning, setPackageWarning] = useState() + const [packageWarning, setPackageWarning] = useState() 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 } }