mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 08:03:14 +00:00
fix(mobile): recover terminal state after iOS resume
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<ConnectionState, string> = {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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))
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<ViewStyle>
|
||||
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<TerminalWebViewHandle, Props>(function TerminalWebView(
|
||||
{
|
||||
@@ -61,6 +43,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
const isWebReadyRef = useRef(false)
|
||||
const pendingMessages = useMemo(() => createTerminalWebViewPendingMessages(), [])
|
||||
const messageIdRef = useRef(0)
|
||||
const pendingPingIdRef = useRef<number | null>(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<TerminalWebViewHandle, Props>(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<TerminalWebViewHandle, Props>(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<string, unknown>
|
||||
@@ -108,11 +117,13 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(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<TerminalWebViewHandle, Props>(function
|
||||
}
|
||||
},
|
||||
[
|
||||
flushPendingMessages,
|
||||
clearEngineError,
|
||||
clearWebReadyWatchdog,
|
||||
confirmWebReady,
|
||||
reportEngineError,
|
||||
onWebReady,
|
||||
onSelectionMode,
|
||||
onSelectionCopy,
|
||||
onSelectionEvicted,
|
||||
@@ -229,6 +237,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(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<TerminalWebViewHandle, Props>(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<TerminalWebViewHandle, Props>(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<TerminalWebViewHandle, Props>(function
|
||||
})
|
||||
}
|
||||
}),
|
||||
[postMessage, sendToWebView, terminalTheme, textScale]
|
||||
[armWebReadyWatchdog, postMessage, sendToWebView, terminalTheme, textScale]
|
||||
)
|
||||
|
||||
return (
|
||||
@@ -389,9 +419,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
|
||||
onRenderProcessGone={(event) =>
|
||||
reportNativeEngineError('Terminal WebView render process ended', event)
|
||||
}
|
||||
onContentProcessDidTerminate={(event) =>
|
||||
reportNativeEngineError('Terminal WebView content process ended', event)
|
||||
}
|
||||
onContentProcessDidTerminate={handleContentProcessDidTerminate}
|
||||
/>
|
||||
{engineError ? (
|
||||
<TerminalWebViewEngineErrorOverlay message={engineError} onReload={handleReload} />
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<RpcClient | null>
|
||||
deviceTokenRef: RefObject<string | null>
|
||||
initializedHandlesRef: RefObject<Set<string>>
|
||||
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<ReturnType<typeof setTimeout> | 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 () => {
|
||||
|
||||
@@ -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<ViewStyle>
|
||||
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,
|
||||
|
||||
@@ -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<string, unknown>, 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<string, unknown> = {}
|
||||
) {
|
||||
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<Record<string, unknown>> {
|
||||
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<TerminalWebViewHandle>()
|
||||
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<TerminalWebViewHandle>()
|
||||
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<TerminalWebViewHandle>()
|
||||
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'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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<typeof vi.fn>
|
||||
dispose: ReturnType<typeof vi.fn>
|
||||
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 })")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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 ===
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
`
|
||||
@@ -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();
|
||||
});
|
||||
`
|
||||
Reference in New Issue
Block a user