diff --git a/mobile/scripts/build-terminal-webview-engine.mjs b/mobile/scripts/build-terminal-webview-engine.mjs index 9df4bee1a47..ef6bf0dc1b7 100644 --- a/mobile/scripts/build-terminal-webview-engine.mjs +++ b/mobile/scripts/build-terminal-webview-engine.mjs @@ -85,6 +85,10 @@ async function main() { readFile(require.resolve('@xterm/xterm/css/xterm.css'), 'utf8'), ...packages.map(readPackageVersion) ]) + // Why: the no-external-URL regression gate bans http(s):// anywhere in the + // terminal document. These xmlns URIs live inside data: URLs (never fetched); + // percent-encoding the scheme colon satisfies the gate and URI-decodes back + // before the SVG is parsed. const engineCss = rawEngineCss .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/http:\/\/www\.w3\.org\/2000\/svg/g, 'http%3A//www.w3.org/2000/svg') diff --git a/mobile/src/terminal/TerminalWebView.tsx b/mobile/src/terminal/TerminalWebView.tsx index 132e9a29b5d..1798fedd1d8 100644 --- a/mobile/src/terminal/TerminalWebView.tsx +++ b/mobile/src/terminal/TerminalWebView.tsx @@ -2,78 +2,29 @@ import { useRef, useCallback, forwardRef, useImperativeHandle, useEffect, useMem import { View, type StyleProp, type ViewStyle } from 'react-native' import { WebView } from 'react-native-webview' import type { WebViewMessageEvent } from 'react-native-webview' -import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types' import type { TerminalOscLinkRange } from './terminal-osc-link-ranges' +import type { + MobileTerminalTheme, + TerminalSelectionEvents, + TerminalWebViewHandle +} from './terminal-webview-contract' import { TerminalWebViewEngineErrorOverlay, useTerminalWebViewEngineErrorState } from './terminal-webview-engine-error-state' import { TERMINAL_WEBVIEW_FRAME_STYLES } from './terminal-webview-frame-styles' +import { useTerminalWebReadyWatchdog } from './terminal-webview-ready-watchdog' import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html' import type { TerminalWebViewCommand } from './terminal-webview-messages' import { createTerminalWebViewPendingMessages } from './terminal-webview-pending-messages' -type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any' - -export type TerminalModes = { - bracketedPasteMode: boolean - altScreen: boolean - mouseTrackingMode: TerminalMouseTrackingMode - sgrMouseMode: boolean - sgrMousePixelsMode: boolean -} - -export type TerminalKeyboardAvoidanceMetrics = { - cursorY: number - rows: number - altScreen: boolean -} - -export type MobileTerminalTheme = RuntimeMobileTerminalTheme - -export type TerminalSelectionEvents = { - onSelectionMode?: (active: boolean) => void - onSelectionCopy?: (text: string) => void - onSelectionEvicted?: () => void - onModesChanged?: (modes: TerminalModes) => void - onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void - onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void - onTerminalInput?: (bytes: string) => void - onTerminalTap?: () => void - // Tap landed on a detected file path; RN resolves + opens it. - onFileTap?: (pathText: string, line: number | null, column: number | null) => void - // WebView-detected URL tap; RN chooses the mobile routing destination. - onOpenUrl?: (url: string) => void - // Why: pinch-to-zoom in the terminal snaps to a text-size preset and reports it - // here so the app persists it and keeps Settings + other panes in sync. - onTextScaleChange?: (scale: number) => void -} - -export type TerminalWebViewHandle = { - write: (data: string) => void - init: ( - cols: number, - rows: number, - initialData?: string, - preserveScroll?: boolean, - oscLinks?: TerminalOscLinkRange[] - ) => void - resize: (cols: number, rows: number) => void - // Why: reflow the local xterm buffer (scrollback included) to a new width - // after a server-side PTY reflow, so older wrapped lines rewrap to match the - // latest output. No-op on the alternate screen. - reflow: (cols: number, rows: number) => void - clear: () => void - measureFitDimensions: (containerHeight?: number) => Promise<{ cols: number; rows: number } | null> - resetZoom: () => void - cancelSelect: () => void - doSelectAll: () => void - // Why: lets callers await the WebView-side `init` rAF chain (term.open - // → renderService population → first paint) so a follow-up measure - // doesn't race ahead and find term=null or cellWidth=0. Resolves on - // the next 'ready' notify after the most recent init. - awaitReady: () => Promise -} +export type { + MobileTerminalTheme, + TerminalKeyboardAvoidanceMetrics, + TerminalModes, + TerminalSelectionEvents, + TerminalWebViewHandle +} from './terminal-webview-contract' type Props = { style?: StyleProp @@ -122,6 +73,10 @@ export const TerminalWebView = forwardRef(function const readyResolveRef = useRef<(() => void) | null>(null) const { clearEngineError, engineError, reportEngineError, reportNativeEngineError } = useTerminalWebViewEngineErrorState(onEngineError) + const { armWebReadyWatchdog, clearWebReadyWatchdog } = useTerminalWebReadyWatchdog( + isWebReadyRef, + reportEngineError + ) const sendToWebView = useCallback((msg: TerminalWebViewCommand) => { messageIdRef.current += 1 @@ -154,6 +109,7 @@ export const TerminalWebView = forwardRef(function if (msg.type === 'web-ready') { isWebReadyRef.current = true + clearWebReadyWatchdog() clearEngineError() onWebReady?.() flushPendingMessages() @@ -254,6 +210,7 @@ export const TerminalWebView = forwardRef(function [ flushPendingMessages, clearEngineError, + clearWebReadyWatchdog, reportEngineError, onWebReady, onSelectionMode, @@ -272,10 +229,11 @@ export const TerminalWebView = forwardRef(function const handleLoadStart = useCallback(() => { isWebReadyRef.current = false + 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. pendingMessages.clear() - }, [pendingMessages]) + }, [armWebReadyWatchdog, pendingMessages]) const handleReload = useCallback(() => { clearEngineError() diff --git a/mobile/src/terminal/terminal-webview-contract.ts b/mobile/src/terminal/terminal-webview-contract.ts new file mode 100644 index 00000000000..aff86cecad2 --- /dev/null +++ b/mobile/src/terminal/terminal-webview-contract.ts @@ -0,0 +1,64 @@ +import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types' +import type { TerminalOscLinkRange } from './terminal-osc-link-ranges' + +type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any' + +export type TerminalModes = { + bracketedPasteMode: boolean + altScreen: boolean + mouseTrackingMode: TerminalMouseTrackingMode + sgrMouseMode: boolean + sgrMousePixelsMode: boolean +} + +export type TerminalKeyboardAvoidanceMetrics = { + cursorY: number + rows: number + altScreen: boolean +} + +export type MobileTerminalTheme = RuntimeMobileTerminalTheme + +export type TerminalSelectionEvents = { + onSelectionMode?: (active: boolean) => void + onSelectionCopy?: (text: string) => void + onSelectionEvicted?: () => void + onModesChanged?: (modes: TerminalModes) => void + onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void + onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void + onTerminalInput?: (bytes: string) => void + onTerminalTap?: () => void + // Tap landed on a detected file path; RN resolves + opens it. + onFileTap?: (pathText: string, line: number | null, column: number | null) => void + // WebView-detected URL tap; RN chooses the mobile routing destination. + onOpenUrl?: (url: string) => void + // Why: pinch-to-zoom in the terminal snaps to a text-size preset and reports it + // here so the app persists it and keeps Settings + other panes in sync. + onTextScaleChange?: (scale: number) => void +} + +export type TerminalWebViewHandle = { + write: (data: string) => void + init: ( + cols: number, + rows: number, + initialData?: string, + preserveScroll?: boolean, + oscLinks?: TerminalOscLinkRange[] + ) => void + resize: (cols: number, rows: number) => void + // Why: reflow the local xterm buffer (scrollback included) to a new width + // after a server-side PTY reflow, so older wrapped lines rewrap to match the + // latest output. No-op on the alternate screen. + reflow: (cols: number, rows: number) => void + clear: () => void + measureFitDimensions: (containerHeight?: number) => Promise<{ cols: number; rows: number } | null> + resetZoom: () => void + cancelSelect: () => void + doSelectAll: () => void + // Why: lets callers await the WebView-side `init` rAF chain (term.open + // → renderService population → first paint) so a follow-up measure + // doesn't race ahead and find term=null or cellWidth=0. Resolves on + // the next 'ready' notify after the most recent init. + awaitReady: () => Promise +} diff --git a/mobile/src/terminal/terminal-webview-engine-error-state.tsx b/mobile/src/terminal/terminal-webview-engine-error-state.tsx index dd3d11e4e35..0ba3e79db8d 100644 --- a/mobile/src/terminal/terminal-webview-engine-error-state.tsx +++ b/mobile/src/terminal/terminal-webview-engine-error-state.tsx @@ -29,7 +29,10 @@ export function useTerminalWebViewEngineErrorState(onEngineError?: (message: str // eslint-disable-next-line no-console console.warn('[terminal-webview] engine error', message) if (fatal) { - setEngineError(message) + // Why: the first fatal report is the root cause; later cascades (e.g. the + // web-ready watchdog firing after a process-crash report) must not + // overwrite its more specific diagnostics. clearEngineError resets. + setEngineError((previous) => previous ?? message) } }, [onEngineError] diff --git a/mobile/src/terminal/terminal-webview-engine-error.test.ts b/mobile/src/terminal/terminal-webview-engine-error.test.ts index 9fc5d23c561..e2560ecdae8 100644 --- a/mobile/src/terminal/terminal-webview-engine-error.test.ts +++ b/mobile/src/terminal/terminal-webview-engine-error.test.ts @@ -4,6 +4,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { TerminalWebView } from './TerminalWebView' vi.mock('react-native', () => ({ + AppState: { currentState: 'active' }, Pressable: 'Pressable', StyleSheet: { absoluteFillObject: { @@ -104,4 +105,63 @@ describe('TerminalWebView engine errors', () => { expect(onEngineError).toHaveBeenCalledWith('terminal message failed - malformed chunk') expect(renderedText(renderer)).not.toContain('Terminal failed to load') }) + + it('keeps the first fatal diagnostics when later fatal reports cascade', () => { + vi.spyOn(console, 'warn').mockImplementation(() => {}) + const { renderer } = createTerminalWebViewRenderer() + + postWebViewMessage(renderer, { + fatal: true, + message: 'terminal engine missing - root cause', + type: 'error' + }) + postWebViewMessage(renderer, { + fatal: true, + message: 'Terminal did not initialize - watchdog cascade', + type: 'error' + }) + + expect(renderedText(renderer)).toContain('terminal engine missing - root cause') + expect(renderedText(renderer)).not.toContain('watchdog cascade') + }) + + // Why: if the document dies before the glue can post anything (or the RN + // bridge never comes up), no message and no native error handler fires — + // the watchdog must convert that silence into the visible fatal overlay. + it('paints the fatal overlay when web-ready never arrives', () => { + vi.useFakeTimers() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const { onEngineError, renderer } = createTerminalWebViewRenderer() + + act(() => { + vi.advanceTimersByTime(15000) + }) + + expect(onEngineError).toHaveBeenCalledWith( + 'Terminal did not initialize - no ready signal from the terminal view' + ) + expect(renderedText(renderer)).toContain('Terminal failed to load') + } finally { + vi.useRealTimers() + } + }) + + it('does not fire the watchdog once web-ready has arrived', () => { + vi.useFakeTimers() + vi.spyOn(console, 'warn').mockImplementation(() => {}) + try { + const { onEngineError, renderer } = createTerminalWebViewRenderer() + + postWebViewMessage(renderer, { type: 'web-ready' }) + act(() => { + vi.advanceTimersByTime(60000) + }) + + expect(onEngineError).not.toHaveBeenCalled() + expect(renderedText(renderer)).not.toContain('Terminal failed to load') + } finally { + vi.useRealTimers() + } + }) }) diff --git a/mobile/src/terminal/terminal-webview-ready-watchdog.ts b/mobile/src/terminal/terminal-webview-ready-watchdog.ts new file mode 100644 index 00000000000..f111679ba2c --- /dev/null +++ b/mobile/src/terminal/terminal-webview-ready-watchdog.ts @@ -0,0 +1,48 @@ +import { useCallback, useEffect, useRef, type RefObject } from 'react' +import { AppState } from 'react-native' + +// Why: if the document dies before the glue can post anything (or the RN +// message bridge never comes up), no webview error and no native handler +// fires — without a native watchdog that failure is a silent blank pane. +const WEB_READY_WATCHDOG_MS = 15000 + +export function useTerminalWebReadyWatchdog( + isWebReadyRef: RefObject, + reportEngineError: (message: string, fatal: boolean) => void +) { + const watchdogRef = useRef | null>(null) + + const clearWebReadyWatchdog = useCallback(() => { + if (watchdogRef.current) { + clearTimeout(watchdogRef.current) + watchdogRef.current = null + } + }, []) + + const armWebReadyWatchdog = useCallback(() => { + clearWebReadyWatchdog() + const fire = () => { + watchdogRef.current = null + if (isWebReadyRef.current) { + return + } + if (AppState.currentState !== 'active') { + // Why: backgrounded WebViews legitimately stall; only judge foreground loads. + watchdogRef.current = setTimeout(fire, WEB_READY_WATCHDOG_MS) + return + } + reportEngineError( + 'Terminal did not initialize - no ready signal from the terminal view', + true + ) + } + watchdogRef.current = setTimeout(fire, WEB_READY_WATCHDOG_MS) + }, [clearWebReadyWatchdog, isWebReadyRef, reportEngineError]) + + useEffect(() => { + armWebReadyWatchdog() + return clearWebReadyWatchdog + }, [armWebReadyWatchdog, clearWebReadyWatchdog]) + + return { armWebReadyWatchdog, clearWebReadyWatchdog } +}