diff --git a/mobile/app/h/[hostId]/session/[worktreeId].tsx b/mobile/app/h/[hostId]/session/[worktreeId].tsx index b881ddebabb..18a393cae28 100644 --- a/mobile/app/h/[hostId]/session/[worktreeId].tsx +++ b/mobile/app/h/[hostId]/session/[worktreeId].tsx @@ -93,7 +93,7 @@ import type { TerminalKeyboardAvoidanceMetrics, TerminalModes, TerminalWebViewHandle -} from '../../../../src/terminal/TerminalWebView' +} from '../../../../src/terminal/terminal-webview-contract' import { isTerminalOscLinkRanges } from '../../../../src/terminal/terminal-osc-link-ranges' import { useTerminalViewportRefit } from '../../../../src/terminal/terminal-viewport-refit' import { @@ -2530,8 +2530,12 @@ export default function SessionScreen() { if (!shouldRecover) { return } + for (const terminalRef of terminalRefs.current.values()) { + terminalRef.prepareForForegroundRecovery() + } // Why: iOS can resume a live WKWebView with a blank xterm backing store - // without firing web-ready/reconnect; replay scrollback to repaint it. + // without firing web-ready/reconnect; invalidate the native readiness + // latch before replay so init waits for the document's pong. recoverActiveTerminalAfterForeground({ activeHandleRef, terminalRefs, @@ -2559,6 +2563,7 @@ export default function SessionScreen() { clientRef, deviceTokenRef, initializedHandlesRef, + connState, tabStripVisible: terminals.length > 1, textScale: terminalTextScale, terminalFrameWidth, diff --git a/mobile/app/h/[hostId]/session/mobile-session-route-types.ts b/mobile/app/h/[hostId]/session/mobile-session-route-types.ts index 5f0f800d9c2..4bc6215f4a2 100644 --- a/mobile/app/h/[hostId]/session/mobile-session-route-types.ts +++ b/mobile/app/h/[hostId]/session/mobile-session-route-types.ts @@ -1,5 +1,5 @@ import type { MobileBrowserTab } from '../../../../src/browser/MobileBrowserPane' -import type { MobileTerminalTheme } from '../../../../src/terminal/TerminalWebView' +import type { MobileTerminalTheme } from '../../../../src/terminal/terminal-webview-contract' import type { MobileDiffLine } from '../../../../src/session/mobile-diff-lines' import type { MobileHighlightedDiffLine, diff --git a/mobile/src/session/TerminalPaneView.tsx b/mobile/src/session/TerminalPaneView.tsx index da34ed870c1..beb8118dabf 100644 --- a/mobile/src/session/TerminalPaneView.tsx +++ b/mobile/src/session/TerminalPaneView.tsx @@ -1,12 +1,12 @@ import { useCallback } from 'react' import { StyleSheet, View } from 'react-native' -import { - TerminalWebView, - type MobileTerminalTheme, - type TerminalKeyboardAvoidanceMetrics, - type TerminalModes, - type TerminalWebViewHandle -} from '../terminal/TerminalWebView' +import { TerminalWebView } from '../terminal/TerminalWebView' +import type { + MobileTerminalTheme, + TerminalKeyboardAvoidanceMetrics, + TerminalModes, + TerminalWebViewHandle +} from '../terminal/terminal-webview-contract' type TerminalPaneViewProps = { handle: string diff --git a/mobile/src/session/mobile-session-route-helpers.ts b/mobile/src/session/mobile-session-route-helpers.ts index e5a8d4329b7..18434e21f5a 100644 --- a/mobile/src/session/mobile-session-route-helpers.ts +++ b/mobile/src/session/mobile-session-route-helpers.ts @@ -1,4 +1,4 @@ -import type { TerminalModes } from '../terminal/TerminalWebView' +import type { TerminalModes } from '../terminal/terminal-webview-contract' import type { ConnectionState } from '../transport/types' export const MOBILE_SESSION_STATUS_LABELS: Record = { diff --git a/mobile/src/session/mobile-terminal-records.test.ts b/mobile/src/session/mobile-terminal-records.test.ts index 4ebf7476682..0ebd2c55631 100644 --- a/mobile/src/session/mobile-terminal-records.test.ts +++ b/mobile/src/session/mobile-terminal-records.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest' import { getTerminalRecordsFromSessionTabs, mergeTerminalListWithKnownRecords, + mergeTerminalRecordsByCurrentOrder, mobileSessionTabsEqual, type MobileTerminalSessionTab, type TerminalRecord @@ -24,6 +25,17 @@ const darkTheme = { } describe('mobile terminal records', () => { + it('keeps the known theme when a session-tab snapshot omits it', () => { + const known: TerminalRecord[] = [ + { handle: 'pty-1', title: 'Old title', terminalTheme: darkTheme, isActive: false } + ] + const snapshot: TerminalRecord[] = [{ handle: 'pty-1', title: 'Current title', isActive: true }] + + expect(mergeTerminalRecordsByCurrentOrder(snapshot, known)).toEqual([ + { handle: 'pty-1', title: 'Current title', terminalTheme: darkTheme, isActive: true } + ]) + }) + it('keeps session-tab terminal themes when terminal.list omits them', () => { const terminalList: TerminalRecord[] = [ { handle: 'pty-1', title: 'Terminal', isActive: true }, diff --git a/mobile/src/session/mobile-terminal-records.ts b/mobile/src/session/mobile-terminal-records.ts index 0f97af2529e..3348e307898 100644 --- a/mobile/src/session/mobile-terminal-records.ts +++ b/mobile/src/session/mobile-terminal-records.ts @@ -1,4 +1,4 @@ -import type { MobileTerminalTheme } from '../terminal/TerminalWebView' +import type { MobileTerminalTheme } from '../terminal/terminal-webview-contract' import type { AgentStatusEntry } from '../../../src/shared/agent-status-types' export type TerminalRecord = { @@ -126,7 +126,18 @@ export function mergeTerminalRecordsByCurrentOrder( const terminalTabsByHandle = new Map(terminalTabs.map((tab) => [tab.handle, tab])) const currentHandles = new Set(currentTerminals.map((terminal) => terminal.handle)) return [ - ...currentTerminals.map((terminal) => terminalTabsByHandle.get(terminal.handle) ?? terminal), + ...currentTerminals.map((terminal) => { + const snapshotTerminal = terminalTabsByHandle.get(terminal.handle) + if (!snapshotTerminal) { + return terminal + } + // Why: lightweight session snapshots can omit the theme; keep the last + // known value while still accepting current title/activity fields. + return { + ...snapshotTerminal, + terminalTheme: snapshotTerminal.terminalTheme ?? terminal.terminalTheme + } + }), ...terminalTabs.filter((terminal) => !currentHandles.has(terminal.handle)) ] } diff --git a/mobile/src/session/use-mobile-terminal-paste.ts b/mobile/src/session/use-mobile-terminal-paste.ts index e3148be67f0..57ea720c4c8 100644 --- a/mobile/src/session/use-mobile-terminal-paste.ts +++ b/mobile/src/session/use-mobile-terminal-paste.ts @@ -2,7 +2,7 @@ import { useCallback, type RefObject } from 'react' import * as Clipboard from 'expo-clipboard' import { File as FsFile, Paths } from 'expo-file-system' import { ImageManipulator, SaveFormat } from 'expo-image-manipulator' -import type { TerminalModes } from '../terminal/TerminalWebView' +import type { TerminalModes } from '../terminal/terminal-webview-contract' import type { RpcClient } from '../transport/rpc-client' import type { ConnectionState } from '../transport/types' import { diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx index 1798fedd1d8..4b84e44081a 100644 --- a/mobile/src/terminal/TerminalWebView.tsx +++ b/mobile/src/terminal/TerminalWebView.tsx @@ -1,13 +1,9 @@ import { useRef, useCallback, forwardRef, useImperativeHandle, useEffect, useMemo } from 'react' -import { View, type StyleProp, type ViewStyle } from 'react-native' +import { Platform, View } from 'react-native' import { WebView } from 'react-native-webview' import type { WebViewMessageEvent } from 'react-native-webview' import type { TerminalOscLinkRange } from './terminal-osc-link-ranges' -import type { - MobileTerminalTheme, - TerminalSelectionEvents, - TerminalWebViewHandle -} from './terminal-webview-contract' +import type { TerminalWebViewHandle, TerminalWebViewProps } from './terminal-webview-contract' import { TerminalWebViewEngineErrorOverlay, useTerminalWebViewEngineErrorState @@ -18,23 +14,9 @@ import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html' import type { TerminalWebViewCommand } from './terminal-webview-messages' import { createTerminalWebViewPendingMessages } from './terminal-webview-pending-messages' -export type { - MobileTerminalTheme, - TerminalKeyboardAvoidanceMetrics, - TerminalModes, - TerminalSelectionEvents, - TerminalWebViewHandle -} from './terminal-webview-contract' +type Props = TerminalWebViewProps -type Props = { - style?: StyleProp - terminalTheme?: MobileTerminalTheme - // Why: baseline zoom multiplier ("text size") applied on top of the fit-to-width - // scale; raw xterm fontSize can't drive apparent size because the fit cancels it. - textScale?: number - onWebReady?: () => void - onEngineError?: (message: string) => void -} & TerminalSelectionEvents +export type { TerminalWebViewHandle } from './terminal-webview-contract' export const TerminalWebView = forwardRef(function TerminalWebView( { @@ -61,6 +43,7 @@ export const TerminalWebView = forwardRef(function const isWebReadyRef = useRef(false) const pendingMessages = useMemo(() => createTerminalWebViewPendingMessages(), []) const messageIdRef = useRef(0) + const pendingPingIdRef = useRef(null) const terminalThemeKey = useMemo(() => JSON.stringify(terminalTheme ?? null), [terminalTheme]) const measureResolveRef = useRef< ((result: { cols: number; rows: number } | null) => void) | null @@ -80,7 +63,9 @@ export const TerminalWebView = forwardRef(function const sendToWebView = useCallback((msg: TerminalWebViewCommand) => { messageIdRef.current += 1 - webViewRef.current?.postMessage(JSON.stringify({ ...msg, id: messageIdRef.current })) + const id = messageIdRef.current + webViewRef.current?.postMessage(JSON.stringify({ ...msg, id })) + return id }, []) const flushPendingMessages = useCallback(() => { @@ -98,6 +83,30 @@ export const TerminalWebView = forwardRef(function [pendingMessages, sendToWebView] ) + const confirmWebReady = useCallback( + (notifyParent: boolean) => { + pendingPingIdRef.current = null + isWebReadyRef.current = true + clearWebReadyWatchdog() + clearEngineError() + if (notifyParent) { + onWebReady?.() + } + // Why: reload clears queued commands, so readiness must always restore the + // native-selected theme even when its value did not change in React. + sendToWebView({ type: 'set-theme', terminalTheme }) + flushPendingMessages() + }, + [ + clearEngineError, + clearWebReadyWatchdog, + flushPendingMessages, + onWebReady, + sendToWebView, + terminalTheme + ] + ) + const handleMessage = useCallback( (event: WebViewMessageEvent) => { let msg: Record @@ -108,11 +117,13 @@ export const TerminalWebView = forwardRef(function } if (msg.type === 'web-ready') { - isWebReadyRef.current = true - clearWebReadyWatchdog() - clearEngineError() - onWebReady?.() - flushPendingMessages() + confirmWebReady(true) + } else if ( + msg.type === 'pong' && + typeof msg.pingId === 'number' && + msg.pingId === pendingPingIdRef.current + ) { + confirmWebReady(false) } else if (msg.type === 'ready') { // Why: the WebView's init() rAF chain has run — term is open, // renderService is populated, first paint has happened. Resolve @@ -208,11 +219,8 @@ export const TerminalWebView = forwardRef(function } }, [ - flushPendingMessages, - clearEngineError, - clearWebReadyWatchdog, + confirmWebReady, reportEngineError, - onWebReady, onSelectionMode, onSelectionCopy, onSelectionEvicted, @@ -229,6 +237,7 @@ export const TerminalWebView = forwardRef(function const handleLoadStart = useCallback(() => { isWebReadyRef.current = false + pendingPingIdRef.current = null armWebReadyWatchdog() // Why: messages queued for a previous WebView generation are stale after a reload; // dropping them avoids replaying terminal chunks before the next init snapshot. @@ -240,6 +249,17 @@ export const TerminalWebView = forwardRef(function webViewRef.current?.reload() }, [clearEngineError]) + const handleContentProcessDidTerminate = useCallback(() => { + // Why: WKWebView content-process loss is recoverable; stale commands belong + // to the dead document and the replacement must prove readiness before replay. + isWebReadyRef.current = false + pendingPingIdRef.current = null + pendingMessages.clear() + clearEngineError() + armWebReadyWatchdog() + webViewRef.current?.reload() + }, [armWebReadyWatchdog, clearEngineError, pendingMessages]) + useEffect(() => { postMessage({ type: 'set-theme', terminalTheme }) }, [postMessage, terminalThemeKey, terminalTheme]) @@ -253,6 +273,16 @@ export const TerminalWebView = forwardRef(function useImperativeHandle( ref, () => ({ + prepareForForegroundRecovery() { + if (Platform.OS !== 'ios') { + return + } + // Why: direct ping is the only command allowed through while readiness is + // invalid; init/write commands queue until this exact document answers. + isWebReadyRef.current = false + armWebReadyWatchdog() + pendingPingIdRef.current = sendToWebView({ type: 'ping' }) + }, write(data: string) { postMessage({ type: 'write', data }) }, @@ -363,7 +393,7 @@ export const TerminalWebView = forwardRef(function }) } }), - [postMessage, sendToWebView, terminalTheme, textScale] + [armWebReadyWatchdog, postMessage, sendToWebView, terminalTheme, textScale] ) return ( @@ -389,9 +419,7 @@ export const TerminalWebView = forwardRef(function onRenderProcessGone={(event) => reportNativeEngineError('Terminal WebView render process ended', event) } - onContentProcessDidTerminate={(event) => - reportNativeEngineError('Terminal WebView content process ended', event) - } + onContentProcessDidTerminate={handleContentProcessDidTerminate} /> {engineError ? ( diff --git a/mobile/src/terminal/terminal-foreground-recovery.test.ts b/mobile/src/terminal/terminal-foreground-recovery.test.ts index 4eda53a9c22..6aeceaaa14a 100644 --- a/mobile/src/terminal/terminal-foreground-recovery.test.ts +++ b/mobile/src/terminal/terminal-foreground-recovery.test.ts @@ -127,5 +127,11 @@ describe('terminal foreground recovery', () => { expect(foregroundPredicate).toContain('Platform.OS') expect(sessionSource).toContain('recoverActiveTerminalAfterForeground({') expect(sessionSource).toContain("AppState.addEventListener('change'") + const readinessInvalidation = sessionSource.indexOf( + 'terminalRef.prepareForForegroundRecovery()' + ) + const replay = sessionSource.indexOf('recoverActiveTerminalAfterForeground({') + expect(readinessInvalidation).toBeGreaterThanOrEqual(0) + expect(replay).toBeGreaterThan(readinessInvalidation) }) }) diff --git a/mobile/src/terminal/terminal-viewport-refit.test.ts b/mobile/src/terminal/terminal-viewport-refit.test.ts index 4cd3794ad0f..b658dad437a 100644 --- a/mobile/src/terminal/terminal-viewport-refit.test.ts +++ b/mobile/src/terminal/terminal-viewport-refit.test.ts @@ -52,6 +52,36 @@ describe('terminal viewport refit', () => { expect(sessionSource).toContain('useTerminalViewportRefit({') expect(sessionSource).toContain('tabStripVisible: terminals.length > 1') expect(sessionSource).toContain('textScale: terminalTextScale') + expect(sessionSource).toContain('connState,') + }) + + it('forces a refit on iOS foreground and connection recovery', () => { + const foregroundEffect = hookSource.slice( + hookSource.indexOf("if (Platform.OS !== 'ios')"), + hookSource.indexOf('const previousConnStateRef') + ) + const reconnectEffect = hookSource.slice( + hookSource.indexOf('const previousConnStateRef'), + hookSource.indexOf('disposedRef.current = false') + ) + + expect(foregroundEffect).toContain("AppState.addEventListener('change'") + expect(foregroundEffect).toContain('viewportMeasuredRef.current = false') + expect(foregroundEffect).toContain('scheduleForcedViewportRefit()') + expect(reconnectEffect).toContain("connState !== 'connected'") + expect(reconnectEffect).toContain('viewportMeasuredRef.current = false') + expect(reconnectEffect).toContain('scheduleForcedViewportRefit()') + }) + + it('bypasses the equal-dimensions guard for resume and reconnect refits', () => { + expect(hookSource).toContain('const forceRefit = forceNextRefitRef.current') + expect(hookSource).toContain( + 'if (!forceRefit && prev && prev.cols === dims.cols && prev.rows === dims.rows)' + ) + const forceRead = hookSource.indexOf('const forceRefit = forceNextRefitRef.current') + const updateViewport = hookSource.indexOf("sendRequest('terminal.updateViewport'") + expect(forceRead).toBeGreaterThanOrEqual(0) + expect(updateViewport).toBeGreaterThan(forceRead) }) it('prefers the in-place updateViewport RPC over resubscribe', () => { diff --git a/mobile/src/terminal/terminal-viewport-refit.ts b/mobile/src/terminal/terminal-viewport-refit.ts index b8a823be8c7..44c6a9b7e03 100644 --- a/mobile/src/terminal/terminal-viewport-refit.ts +++ b/mobile/src/terminal/terminal-viewport-refit.ts @@ -1,7 +1,9 @@ import { useCallback, useEffect, useRef, type RefObject } from 'react' -import { useWindowDimensions } from 'react-native' +import { AppState, Platform, useWindowDimensions, type AppStateStatus } from 'react-native' import type { RpcClient } from '../transport/rpc-client' +import type { ConnectionState } from '../transport/types' import type { TerminalWebViewHandle } from './TerminalWebView' +import { shouldRecoverTerminalOnAppStateChange } from './terminal-foreground-recovery' import { isTerminalUpdateViewportApplied, isTerminalUpdateViewportUpdated, @@ -19,6 +21,7 @@ type TerminalViewportRefitOptions = { clientRef: RefObject deviceTokenRef: RefObject initializedHandlesRef: RefObject> + connState: ConnectionState tabStripVisible: boolean // Why: terminal text size (font scale) — changing it changes the cell size, so // the PTY must be re-fitted to a new column count and reflowed. @@ -49,6 +52,7 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions): clientRef, deviceTokenRef, initializedHandlesRef, + connState, tabStripVisible, textScale, terminalFrameWidth, @@ -58,6 +62,7 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions): const refitTimerRef = useRef | null>(null) const refitRunSeqRef = useRef(0) + const forceNextRefitRef = useRef(false) const disposedRef = useRef(false) const scheduleViewportRefit = useCallback(() => { if (refitTimerRef.current) { @@ -93,8 +98,10 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions): if (!dims) { return } + const forceRefit = forceNextRefitRef.current + forceNextRefitRef.current = false const prev = viewportRef.current - if (prev && prev.cols === dims.cols && prev.rows === dims.rows) { + if (!forceRefit && prev && prev.cols === dims.cols && prev.rows === dims.rows) { return } viewportRef.current = dims @@ -152,6 +159,10 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions): unsubscribeTerminal, subscribeToTerminal ]) + const scheduleForcedViewportRefit = useCallback(() => { + forceNextRefitRef.current = true + scheduleViewportRefit() + }, [scheduleViewportRefit]) // Why: the tab strip is hidden when only one terminal exists and shown // once a second is created. Crossing the 1↔2 boundary changes the @@ -215,6 +226,42 @@ export function useTerminalViewportRefit(options: TerminalViewportRefitOptions): scheduleViewportRefit() }, [terminalFrameWidth, viewportMeasuredRef, scheduleViewportRefit]) + useEffect(() => { + if (Platform.OS !== 'ios') { + return + } + let previousAppState: AppStateStatus | null = AppState.currentState + const sub = AppState.addEventListener('change', (nextAppState: AppStateStatus) => { + const shouldRefit = shouldRecoverTerminalOnAppStateChange( + previousAppState, + nextAppState, + Platform.OS + ) + previousAppState = nextAppState + if (!shouldRefit) { + return + } + // Why: the cached grid can match while the host PTY changed in background; + // reasserting equal dimensions is the convergence signal after iOS resume. + viewportMeasuredRef.current = false + scheduleForcedViewportRefit() + }) + return () => sub.remove() + }, [viewportMeasuredRef, scheduleForcedViewportRefit]) + + const previousConnStateRef = useRef(connState) + useEffect(() => { + const previous = previousConnStateRef.current + previousConnStateRef.current = connState + if (previous === 'connected' || connState !== 'connected') { + return + } + // Why: reconnect can restore a PTY whose host-side size changed while the + // socket was down, so equal cached dimensions still need reassertion. + viewportMeasuredRef.current = false + scheduleForcedViewportRefit() + }, [connState, viewportMeasuredRef, scheduleForcedViewportRefit]) + useEffect(() => { disposedRef.current = false return () => { diff --git a/mobile/src/terminal/terminal-webview-contract.ts b/mobile/src/terminal/terminal-webview-contract.ts index aff86cecad2..808dece2648 100644 --- a/mobile/src/terminal/terminal-webview-contract.ts +++ b/mobile/src/terminal/terminal-webview-contract.ts @@ -1,4 +1,5 @@ import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types' +import type { StyleProp, ViewStyle } from 'react-native' import type { TerminalOscLinkRange } from './terminal-osc-link-ranges' type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any' @@ -37,7 +38,20 @@ export type TerminalSelectionEvents = { onTextScaleChange?: (scale: number) => void } +export type TerminalWebViewProps = { + style?: StyleProp + terminalTheme?: MobileTerminalTheme + // Why: baseline zoom multiplier applied on top of fit-to-width scale; raw + // xterm fontSize alone cannot drive apparent size because fitting cancels it. + textScale?: number + onWebReady?: () => void + onEngineError?: (message: string) => void +} & TerminalSelectionEvents + export type TerminalWebViewHandle = { + // Why: iOS can preserve the native view while discarding its JS/backing-store + // state; foreground recovery must wait for the document to answer before replay. + prepareForForegroundRecovery: () => void write: (data: string) => void init: ( cols: number, diff --git a/mobile/src/terminal/terminal-webview-engine-error.test.ts b/mobile/src/terminal/terminal-webview-engine-error.test.ts index 82267967b7c..4e29c287321 100644 --- a/mobile/src/terminal/terminal-webview-engine-error.test.ts +++ b/mobile/src/terminal/terminal-webview-engine-error.test.ts @@ -1,10 +1,18 @@ -import { createElement } from 'react' +import { createElement, createRef } from 'react' +import { Platform } from 'react-native' import { act, create, type ReactTestRenderer } from 'react-test-renderer' import { afterEach, describe, expect, it, vi } from 'vitest' import { TerminalWebView } from './TerminalWebView' +import type { TerminalWebViewHandle } from './terminal-webview-contract' + +const nativeWebViewMethods = vi.hoisted(() => ({ + postMessage: vi.fn<(message: string) => void>(), + reload: vi.fn<() => void>() +})) vi.mock('react-native', () => ({ AppState: { currentState: 'active' }, + Platform: { OS: 'ios' }, Pressable: 'Pressable', StyleSheet: { absoluteFillObject: { @@ -20,10 +28,14 @@ vi.mock('react-native', () => ({ View: 'View' })) -vi.mock('react-native-webview', () => ({ - WebView: 'WebView', - default: 'WebView' -})) +vi.mock('react-native-webview', async () => { + const React = await import('react') + const WebView = React.forwardRef((props: Record, ref) => { + React.useImperativeHandle(ref, () => nativeWebViewMethods) + return React.createElement('WebView', props) + }) + return { WebView, default: WebView } +}) vi.mock('lucide-react-native', () => ({ RefreshCw: 'RefreshCw' @@ -45,12 +57,15 @@ function suppressReactTestRendererDeprecationWarning(): () => void { // unmount so the timer can't survive into later tests and fire in teardown. let activeRenderer: ReactTestRenderer | null = null -function createTerminalWebViewRenderer(onEngineError = vi.fn()) { +function createTerminalWebViewRenderer( + onEngineError = vi.fn(), + props: Record = {} +) { let renderer: ReactTestRenderer | null = null const restoreConsoleError = suppressReactTestRendererDeprecationWarning() try { act(() => { - renderer = create(createElement(TerminalWebView, { onEngineError })) + renderer = create(createElement(TerminalWebView, { onEngineError, ...props })) }) } finally { restoreConsoleError() @@ -76,6 +91,10 @@ function renderedText(renderer: ReactTestRenderer): string { .join(' ') } +function postedCommands(): Array> { + return nativeWebViewMethods.postMessage.mock.calls.map(([message]) => JSON.parse(message)) +} + describe('TerminalWebView engine errors', () => { afterEach(() => { if (activeRenderer) { @@ -84,6 +103,7 @@ describe('TerminalWebView engine errors', () => { }) activeRenderer = null } + vi.clearAllMocks() vi.restoreAllMocks() }) @@ -175,4 +195,78 @@ describe('TerminalWebView engine errors', () => { vi.useRealTimers() } }) + + it('queues iOS foreground traffic until the current document answers its ping', () => { + const terminalRef = createRef() + const onWebReady = vi.fn() + const terminalTheme = { + mode: 'dark', + theme: { background: '#111111', foreground: '#eeeeee' } + } + const { renderer } = createTerminalWebViewRenderer(vi.fn(), { + ref: terminalRef, + onWebReady, + terminalTheme + }) + postWebViewMessage(renderer, { type: 'web-ready' }) + nativeWebViewMethods.postMessage.mockClear() + + act(() => { + terminalRef.current?.prepareForForegroundRecovery() + terminalRef.current?.write('queued output') + }) + + const ping = postedCommands()[0] + expect(postedCommands().map((command) => command.type)).toEqual(['ping']) + postWebViewMessage(renderer, { type: 'pong', pingId: Number(ping?.id) + 1 }) + expect(postedCommands().map((command) => command.type)).toEqual(['ping']) + + postWebViewMessage(renderer, { type: 'pong', pingId: ping?.id }) + expect(postedCommands().map((command) => command.type)).toEqual(['ping', 'set-theme', 'write']) + expect(onWebReady).toHaveBeenCalledTimes(1) + }) + + it('keeps Android foreground traffic on the existing ready document', () => { + const terminalRef = createRef() + const { renderer } = createTerminalWebViewRenderer(vi.fn(), { ref: terminalRef }) + postWebViewMessage(renderer, { type: 'web-ready' }) + nativeWebViewMethods.postMessage.mockClear() + const mutablePlatform = Platform as { OS: string } + mutablePlatform.OS = 'android' + try { + act(() => { + terminalRef.current?.prepareForForegroundRecovery() + terminalRef.current?.write('android output') + }) + expect(postedCommands().map((command) => command.type)).toEqual(['write']) + } finally { + mutablePlatform.OS = 'ios' + } + }) + + it('reloads a terminated iOS content process and restores theme on readiness', () => { + const terminalRef = createRef() + const { onEngineError, renderer } = createTerminalWebViewRenderer(vi.fn(), { + ref: terminalRef, + terminalTheme: { + mode: 'light', + theme: { background: '#ffffff', foreground: '#111111' } + } + }) + postWebViewMessage(renderer, { type: 'web-ready' }) + nativeWebViewMethods.postMessage.mockClear() + const webView = renderer.root.findByType('WebView') + + act(() => { + webView.props.onContentProcessDidTerminate({ nativeEvent: {} }) + terminalRef.current?.write('after termination') + }) + + expect(nativeWebViewMethods.reload).toHaveBeenCalledTimes(1) + expect(nativeWebViewMethods.postMessage).not.toHaveBeenCalled() + expect(onEngineError).not.toHaveBeenCalled() + + postWebViewMessage(renderer, { type: 'web-ready' }) + expect(postedCommands().map((command) => command.type)).toEqual(['set-theme', 'write']) + }) }) diff --git a/mobile/src/terminal/terminal-webview-engine.test.ts b/mobile/src/terminal/terminal-webview-engine.test.ts index 36c8c039493..503a25a1961 100644 --- a/mobile/src/terminal/terminal-webview-engine.test.ts +++ b/mobile/src/terminal/terminal-webview-engine.test.ts @@ -1,15 +1,95 @@ import { readFileSync } from 'node:fs' import { Script } from 'node:vm' import { parse } from 'acorn' -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated' import { XTERM_HTML } from './terminal-webview-html' +import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected' const terminalHtmlSource = readFileSync( new URL('./terminal-webview-html.ts', import.meta.url), 'utf8' ) +function createWebglRecoveryHarness(failSecondAttach = false) { + const variablesStart = terminalHtmlSource.indexOf(' var webglAddon = null;') + const variablesEnd = terminalHtmlSource.indexOf( + '\n', + terminalHtmlSource.indexOf(' var webglRecoveryTimer = null;') + ) + expect(variablesStart).toBeGreaterThanOrEqual(0) + expect(variablesEnd).toBeGreaterThan(variablesStart) + + const timers: Array<() => void> = [] + const addons: Array<{ + clearTextureAtlas: ReturnType + dispose: ReturnType + fireContextLoss: () => void + }> = [] + const term = { + rows: 24, + refresh: vi.fn(), + loadAddon: vi.fn(() => { + if (failSecondAttach && addons.length === 2) { + throw new Error('retry unavailable') + } + }) + } + function WebglAddon() { + let contextLoss = () => {} + const addon = { + clearTextureAtlas: vi.fn(), + dispose: vi.fn(), + fireContextLoss: () => contextLoss() + } + addons.push(addon) + return Object.assign(addon, { + onContextLoss: (listener: () => void) => { + contextLoss = listener + } + }) + } + let visibilityChange = () => {} + const document = { + addEventListener: vi.fn((eventName: string, listener: () => void) => { + if (eventName === 'visibilitychange') { + visibilityChange = listener + } + }), + visibilityState: 'hidden' + } + const applyTerminalTheme = vi.fn() + const flog = vi.fn() + const terminalThemeInput = { mode: 'dark' } + const context = { + applyTerminalTheme, + clearTimeout: vi.fn(), + document, + flog, + setTimeout: (callback: () => void) => { + timers.push(callback) + return timers.length + }, + term, + terminalGeneration: 1, + terminalThemeInput, + window: { WebglAddon: { WebglAddon } } + } + new Script(`${terminalHtmlSource.slice(variablesStart, variablesEnd)} +${TERMINAL_WEBGL_RECOVERY_JS} +attachWebglAddon(true);`).runInNewContext(context) + return { + addons, + applyTerminalTheme, + document, + fireVisibilityChange: () => visibilityChange(), + flog, + term, + terminalThemeInput, + timers + } +} + describe('terminal WebView bundled engine', () => { it('keeps the assembled terminal HTML free of external engine URLs', () => { expect(XTERM_HTML).not.toMatch(/\bhttps?:\/\//) @@ -106,4 +186,57 @@ describe('terminal WebView bundled engine', () => { expect(capSites.length).toBe(2) expect(terminalHtmlSource).toContain('nonFatalErrorNotifies > 5') }) + + it('recreates WebGL once after context loss, then stays on the DOM renderer', () => { + const { addons, flog, term, timers } = createWebglRecoveryHarness() + + expect(addons).toHaveLength(1) + addons[0]?.fireContextLoss() + expect(flog).toHaveBeenCalledWith( + 'webgl-context-loss', + expect.objectContaining({ retry: true }) + ) + expect(addons[0]?.dispose).toHaveBeenCalledTimes(1) + expect(term.refresh).toHaveBeenCalledTimes(1) + expect(timers).toHaveLength(1) + + timers.shift()?.() + expect(addons).toHaveLength(2) + expect(addons[1]?.clearTextureAtlas).toHaveBeenCalledTimes(1) + expect(term.refresh).toHaveBeenCalledTimes(2) + addons[1]?.fireContextLoss() + expect(addons[1]?.dispose).toHaveBeenCalledTimes(1) + expect(term.refresh).toHaveBeenCalledTimes(3) + expect(timers).toHaveLength(0) + }) + + it('falls back to a refreshed DOM renderer when the delayed WebGL retry fails', () => { + const { addons, term, timers } = createWebglRecoveryHarness(true) + + addons[0]?.fireContextLoss() + timers.shift()?.() + + expect(addons).toHaveLength(2) + expect(addons[1]?.dispose).toHaveBeenCalledTimes(1) + expect(term.refresh).toHaveBeenCalledTimes(2) + }) + + it('reapplies theme, clears the active atlas, and refreshes when visible', () => { + const harness = createWebglRecoveryHarness() + + harness.fireVisibilityChange() + expect(harness.applyTerminalTheme).not.toHaveBeenCalled() + + harness.document.visibilityState = 'visible' + harness.fireVisibilityChange() + + expect(harness.applyTerminalTheme).toHaveBeenCalledWith(harness.terminalThemeInput) + expect(harness.addons[0]?.clearTextureAtlas).toHaveBeenCalledTimes(1) + expect(harness.term.refresh).toHaveBeenCalledTimes(1) + }) + + it('answers native readiness probes from the live document', () => { + expect(terminalHtmlSource).toContain("if (msg.type === 'ping')") + expect(terminalHtmlSource).toContain("notify({ type: 'pong', pingId: msg.id })") + }) }) diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index 63e4d2b9ed7..fcd5ea9cafa 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -7,7 +7,9 @@ import { TERMINAL_PATH_TAP_JS } from './terminal-path-tap-injected' import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated' import { TERMINAL_REFLOW_JS } from './terminal-webview-reflow-injected' import { TERMINAL_TAP_DISPATCH_JS } from './terminal-webview-tap-dispatch-injected' +import { TERMINAL_WEBVIEW_THEME_JS } from './terminal-webview-theme-injected' import { URL_TAP_WEBVIEW_JS } from './terminal-webview-url-tap' +import { TERMINAL_WEBGL_RECOVERY_JS } from './terminal-webview-webgl-recovery-injected' const DEFAULT_TERMINAL_THEME: RuntimeMobileTerminalTheme['theme'] = { background: colors.terminalBg, @@ -292,7 +294,10 @@ window.onerror = function(msg) { var initRows = 24; var terminalGeneration = 0; var defaultTheme = ${JSON.stringify(DEFAULT_TERMINAL_THEME)}; + var terminalThemeInput = null; var terminalTheme = defaultTheme; + var webglAddon = null; + var webglRecoveryTimer = null; var activeAltScreenSnapshot = false; var trackedMouseTrackingMode = 'none'; var sgrMouseMode = false; @@ -380,27 +385,7 @@ window.onerror = function(msg) { }, 550); } - function normalizeTerminalTheme(input) { - var source = input && typeof input === 'object' && input.theme && typeof input.theme === 'object' - ? input.theme - : null; - if (!source) return defaultTheme; - var next = {}; - var keys = Object.keys(defaultTheme); - for (var i = 0; i < keys.length; i++) { - var key = keys[i]; - if (typeof source[key] === 'string') next[key] = source[key]; - } - return Object.assign({}, defaultTheme, next); - } - - function applyTerminalTheme(input) { - terminalTheme = normalizeTerminalTheme(input); - var background = terminalTheme.background || '${colors.terminalBg}'; - document.documentElement.style.background = background; - document.body.style.background = background; - if (term) term.options.theme = terminalTheme; - } +${TERMINAL_WEBVIEW_THEME_JS} function getCellHeight() { if (!term || !term._core) return 15; @@ -679,6 +664,8 @@ window.onerror = function(msg) { pumpWrites(terminalGeneration); } +${TERMINAL_WEBGL_RECOVERY_JS} + function init(cols, rows, initialData, nextTheme, nextFontScale, preserveScroll, nextOscLinks) { if (typeof nextFontScale === 'number' && nextFontScale > 0) currentTextScale = nextFontScale; // Why: a width-reflow re-stream rewraps the same content at new cols. @@ -688,6 +675,8 @@ window.onerror = function(msg) { var scrollAnchorRows = prevB ? Math.max(0, (prevB.baseY || 0) - (prevB.viewportY || 0)) : -1; terminalGeneration++; var gen = terminalGeneration; + cancelWebglContextRecovery(); + webglAddon = null; ready = false; resetWriteQueue(); statusDotPendingSelector = false; @@ -750,9 +739,7 @@ window.onerror = function(msg) { allowProposedApi: true }); term.open(surface); - if (window.WebglAddon && window.WebglAddon.WebglAddon) { - try { var webglAddon = new window.WebglAddon.WebglAddon(); term.loadAddon(webglAddon); if (webglAddon.onContextLoss) webglAddon.onContextLoss(function() { try { webglAddon && webglAddon.dispose && webglAddon.dispose(); } catch (e) {} }); } catch (e) {} - } + attachWebglAddon(true); if (window.Unicode11Addon && window.Unicode11Addon.Unicode11Addon) try { term.loadAddon(new window.Unicode11Addon.Unicode11Addon()); term.unicode.activeVersion = '11'; } catch (e) {} if (typeof replayData === 'string' && replayData.length > 0) { enqueueWrite(replayData); @@ -936,7 +923,9 @@ window.onerror = function(msg) { handledMessageIds.push(msg.id); if (handledMessageIds.length > 256) handledMessageIds.shift(); } - if (msg.type === 'init') { + if (msg.type === 'ping') { + notify({ type: 'pong', pingId: msg.id }); + } else if (msg.type === 'init') { init(msg.cols, msg.rows, msg.initialData, msg.terminalTheme, msg.fontScale, msg.preserveScroll, msg.oscLinks); } else if (msg.type === 'set-font-scale') { // Why: ignore RN echoing back the value a pinch just set (msg.fontScale === diff --git a/mobile/src/terminal/terminal-webview-messages.ts b/mobile/src/terminal/terminal-webview-messages.ts index f38c5a1dfbb..c031363268c 100644 --- a/mobile/src/terminal/terminal-webview-messages.ts +++ b/mobile/src/terminal/terminal-webview-messages.ts @@ -2,6 +2,7 @@ import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-typ import type { TerminalOscLinkRange } from './terminal-osc-link-ranges' export type TerminalWebViewCommand = + | { type: 'ping'; id?: number } | { type: 'write'; id?: number; data: string } | { type: 'init' diff --git a/mobile/src/terminal/terminal-webview-text-zoom.test.ts b/mobile/src/terminal/terminal-webview-text-zoom.test.ts index aeb7785751c..09aeb82d3f7 100644 --- a/mobile/src/terminal/terminal-webview-text-zoom.test.ts +++ b/mobile/src/terminal/terminal-webview-text-zoom.test.ts @@ -10,6 +10,10 @@ const terminalHtmlSource = readFileSync( new URL('./terminal-webview-html.ts', import.meta.url), 'utf8' ) +const terminalWebglRecoverySource = readFileSync( + new URL('./terminal-webview-webgl-recovery-injected.ts', import.meta.url), + 'utf8' +) function extractStatusDotNormalizer() { const declarationStart = terminalHtmlSource.indexOf(' var CLAUDE_STATUS_DOT =') @@ -148,12 +152,12 @@ describe('TerminalWebView text zoom', () => { it('uses the bundled WebGL-capable xterm stack and platform-safe font fallbacks', () => { expect(terminalHtmlSource).not.toContain('cdn.jsdelivr.net') - expect(terminalHtmlSource).toContain('window.WebglAddon.WebglAddon') + expect(terminalWebglRecoverySource).toContain('window.WebglAddon.WebglAddon') expect(terminalHtmlSource).toContain('function isIOSWebView()') expect(terminalHtmlSource).toContain('fontFamily: terminalFontFamily') expect(terminalHtmlSource).toContain("fontWeight: '300'") expect(terminalHtmlSource).toContain("fontWeightBold: '500'") - expect(terminalHtmlSource).toContain('new window.WebglAddon.WebglAddon()') + expect(terminalWebglRecoverySource).toContain('new window.WebglAddon.WebglAddon()') }) const IOS_IPHONE_NAVIGATOR = { diff --git a/mobile/src/terminal/terminal-webview-theme-injected.ts b/mobile/src/terminal/terminal-webview-theme-injected.ts new file mode 100644 index 00000000000..1f798fe337e --- /dev/null +++ b/mobile/src/terminal/terminal-webview-theme-injected.ts @@ -0,0 +1,27 @@ +import { colors } from '../theme/mobile-theme' + +// Theme normalization and page-surface painting injected into the WebView IIFE. +export const TERMINAL_WEBVIEW_THEME_JS = ` + function normalizeTerminalTheme(input) { + var source = input && typeof input === 'object' && input.theme && typeof input.theme === 'object' + ? input.theme + : null; + if (!source) return defaultTheme; + var next = {}; + var keys = Object.keys(defaultTheme); + for (var i = 0; i < keys.length; i++) { + var key = keys[i]; + if (typeof source[key] === 'string') next[key] = source[key]; + } + return Object.assign({}, defaultTheme, next); + } + + function applyTerminalTheme(input) { + terminalThemeInput = input; + terminalTheme = normalizeTerminalTheme(input); + var background = terminalTheme.background || '${colors.terminalBg}'; + document.documentElement.style.background = background; + document.body.style.background = background; + if (term) term.options.theme = terminalTheme; + } +` diff --git a/mobile/src/terminal/terminal-webview-webgl-recovery-injected.ts b/mobile/src/terminal/terminal-webview-webgl-recovery-injected.ts new file mode 100644 index 00000000000..8e4d27c2348 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-webgl-recovery-injected.ts @@ -0,0 +1,62 @@ +// WebGL loss and visibility recovery injected into the terminal WebView IIFE. +// It closes over term, terminalGeneration, theme state, and xterm's addon global. +export const TERMINAL_WEBGL_RECOVERY_JS = ` + function refreshTerminalSurface() { + if (!term) return; + try { term.refresh(0, Math.max(0, term.rows - 1)); } catch (e) {} + } + + function cancelWebglContextRecovery() { + if (!webglRecoveryTimer) return; + clearTimeout(webglRecoveryTimer); + webglRecoveryTimer = null; + } + + function attachWebglAddon(allowRecovery) { + if (!term || !window.WebglAddon || !window.WebglAddon.WebglAddon) return false; + var addon = null; + try { + addon = new window.WebglAddon.WebglAddon(); + webglAddon = addon; + if (addon.onContextLoss) addon.onContextLoss(function() { + if (webglAddon !== addon) return; + flog('webgl-context-loss', { retry: allowRecovery }); + webglAddon = null; + try { addon.dispose(); } catch (e) {} + refreshTerminalSurface(); + if (!allowRecovery) return; + // Why: one delayed retry handles transient iOS context loss without + // entering a GPU crash loop; a second loss stays on the DOM renderer. + cancelWebglContextRecovery(); + var recoveryTerm = term; + var recoveryGeneration = terminalGeneration; + webglRecoveryTimer = setTimeout(function() { + webglRecoveryTimer = null; + if (term !== recoveryTerm || terminalGeneration !== recoveryGeneration) return; + attachWebglAddon(false); + }, 100); + }); + term.loadAddon(addon); + if (!allowRecovery) { + try { if (addon.clearTextureAtlas) addon.clearTextureAtlas(); } catch (e) {} + refreshTerminalSurface(); + } + return true; + } catch (e) { + flog('webgl-attach-failed', { retry: !allowRecovery, message: String(e) }); + if (webglAddon === addon) webglAddon = null; + try { if (addon) addon.dispose(); } catch (disposeError) {} + refreshTerminalSurface(); + return false; + } + } + + document.addEventListener('visibilitychange', function() { + if (document.visibilityState !== 'visible') return; + // Why: iOS may restore the xterm model while discarding GPU pixels/theme + // paint state, so visibility must rebuild the atlas and repaint every row. + applyTerminalTheme(terminalThemeInput); + try { if (webglAddon && webglAddon.clearTextureAtlas) webglAddon.clearTextureAtlas(); } catch (e) {} + refreshTerminalSurface(); + }); +`