diff --git a/mobile/src/terminal/document/document-externals.ts b/mobile/src/terminal/document/document-externals.ts index 58e97a4b61b..737927ea726 100644 --- a/mobile/src/terminal/document/document-externals.ts +++ b/mobile/src/terminal/document/document-externals.ts @@ -9,9 +9,6 @@ * This file only shrinks. When the last group moves it is deleted. */ -/** `runtime-state-and-text-scaling`: posts one message to the native host. */ -export declare function notify(message: Record): void - /** `surface-touch-gestures`: binds touch, wheel and tap handlers to a surface element. */ export declare function attachSurfaceEventHandlers(target: HTMLElement): void @@ -32,9 +29,3 @@ export declare function applyXtermSelection(): void /** `selection-overlay`: moves the handles and the menu pill to the current selection. */ export declare function repositionOverlay(): void - -/** `host-message-router`: routes one decoded host message. */ -export declare function handleMsg(msg: unknown): void - -/** `terminal-init-and-write`: reports an engine failure to the host. */ -export declare function reportEngineError(summary: string, cause: unknown, fatal: unknown): void diff --git a/mobile/src/terminal/document/document-scope.ts b/mobile/src/terminal/document/document-scope.ts index f546defe3d7..7e44a5c41b2 100644 --- a/mobile/src/terminal/document/document-scope.ts +++ b/mobile/src/terminal/document/document-scope.ts @@ -30,7 +30,7 @@ export type TerminalOscLinkService = { getLinkData?: (id: number) => { uri?: str /** The xterm internals the OSC 8 lookup walks. */ export type TerminalDocumentCore = { - _renderService?: { dimensions?: { css: { cell: { height?: number; width?: number } } } } + _renderService?: { dimensions?: { css: { cell: { height: number; width: number } } } } _oscLinkService?: TerminalOscLinkService _inputHandler?: { _oscLinkService?: TerminalOscLinkService } } @@ -62,6 +62,7 @@ export type TerminalDocumentLine = { /** One side of xterm's buffer, as the document reads it. */ export type TerminalDocumentBuffer = { + readonly length: number readonly viewportY: number readonly baseY: number readonly cursorY: number @@ -89,6 +90,9 @@ export type TerminalDocumentTerminal = { write: (data: string, callback?: () => void) => void open: (element: HTMLElement) => void scrollToLine: (line: number) => void + clear: () => void + reset: () => void + selectAll: () => void readonly unicode: { activeVersion: string } attachCustomKeyEventHandler: (handler: () => boolean) => void onData: (listener: (data: string) => void) => TerminalDocumentDisposable @@ -170,7 +174,7 @@ export type TerminalDocumentScope = { /** `text-scaling`: the largest text-scale preset. */ MAX_TEXT_SCALE: number /** `viewport-transform`: host message ids already handled, to drop repeats. */ - handledMessageIds: string[] + handledMessageIds: number[] /** `runtime-state`: the text scale the user picked, as a preset index. */ currentTextScale: number /** `runtime-state`: the font stack xterm renders with. */ diff --git a/mobile/src/terminal/document/host-message-router.test.ts b/mobile/src/terminal/document/host-message-router.test.ts new file mode 100644 index 00000000000..3916290526d --- /dev/null +++ b/mobile/src/terminal/document/host-message-router.test.ts @@ -0,0 +1,30 @@ +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { emitTerminalDocumentModule } from '../../../scripts/build-terminal-document-script.mjs' +import { TERMINAL_HTML_HOST_MESSAGE_ROUTER } from '../terminal-webview-html/host-message-router' +import { compareTerminalDocumentScripts } from './terminal-document-equivalence.test-support' + +/** The slice opens with the already-extracted reflow group. */ +const modulePaths = ['./reflow.ts', './host-notify.ts', './host-message-router.ts'].map( + (relative) => fileURLToPath(new URL(relative, import.meta.url)) +) + +describe('the host-message-router slice', () => { + it('emits the script the document carries, modulo the six normalisations', async () => { + const emitted = (await Promise.all(modulePaths.map(emitTerminalDocumentModule))).join('\n') + expect( + compareTerminalDocumentScripts(TERMINAL_HTML_HOST_MESSAGE_ROUTER, emitted, 'scope') + ).toEqual({ + equivalent: true, + normalisations: { + qualifiedReferences: 48, + scopeFieldDeclarations: 0, + rebindings: 20, + bracedBodies: 12, + unboundCatches: 2, + numberProperties: 0, + shorthandProperties: 0 + } + }) + }) +}) diff --git a/mobile/src/terminal/document/host-message-router.ts b/mobile/src/terminal/document/host-message-router.ts new file mode 100644 index 00000000000..f9caa4bae8e --- /dev/null +++ b/mobile/src/terminal/document/host-message-router.ts @@ -0,0 +1,195 @@ +import { scope } from './document-scope' +import { applyFitScale } from './fit-scale' +import { notify } from './host-notify' +import { emitKeyboardAvoidanceMetrics } from './keyboard-avoidance-metrics' +import { emitModesIfChanged } from './mode-mirroring' +import { reflow } from './reflow' +import { resumeTerminalDataReplyAuthority } from './query-reply' +import { cancelSelect, repositionOverlay } from './document-externals' +import { resetEvictionCounter } from './selection-state-and-eviction' +import { applyTerminalTheme } from './terminal-theme' +import { init, resize, write } from './terminal-init' +import { applyTextScale } from './text-scaling' +import { flog } from './viewport-transform' +import { resetWriteQueue } from './write-queue' + +/** One message from the host. Every field is optional because the router reads them by type. */ +export type TerminalHostMessage = { + id?: number + type?: string + cols?: number + rows?: number + initialData?: unknown + terminalTheme?: Parameters[0] + fontScale?: number + preserveScroll?: boolean + oscLinks?: unknown + data?: string + containerHeight?: number +} + +export function measureFitDimensions(containerHeightPx: unknown, retriesLeft?: number) { + if (typeof retriesLeft !== 'number') { + retriesLeft = 30 + } + // Why: init and measure are posted back-to-back from React, but + // init has an async rAF chain. A measure that runs synchronously + // after init can find term null, disposed, lacking element, or + // with cells size 0. Retry the whole gate for ~500ms. + const notReady = !scope.term || !scope.term.element + let cellWidth = 0 + let cellHeight = 0 + if (!notReady) { + // oxlint-disable-next-line typescript/no-non-null-assertion -- SAFETY: notReady is exactly the absence check above. + const core = scope.term!._core + if (core && core._renderService && core._renderService.dimensions) { + cellWidth = core._renderService.dimensions.css.cell.width + cellHeight = core._renderService.dimensions.css.cell.height + } + } + if (notReady || cellWidth <= 0 || cellHeight <= 0) { + if (retriesLeft > 0) { + requestAnimationFrame(function () { + measureFitDimensions(containerHeightPx, retriesLeft - 1) + }) + return + } + flog('measure-fail', { + notReady: notReady, + cellWidth: cellWidth, + cellHeight: cellHeight, + retriesLeft: retriesLeft + }) + notify({ type: 'measure-result', cols: null, rows: null }) + return + } + const vpWidth = window.innerWidth + // Why: prefer the container height passed from React Native over + // window.innerHeight. The RN layout system knows the exact pixel + // height of the terminal frame after the accessory/input bars are + // subtracted, whereas innerHeight can overstate the visible area + // due to layout timing or safe-area insets. + const vpHeight = + typeof containerHeightPx === 'number' && containerHeightPx > 0 + ? containerHeightPx + : window.innerHeight + const cols = Math.floor(vpWidth / cellWidth) + if (cols < scope.MIN_FIT_COLS) { + flog('measure-skip-small-width', { + vpWidth: vpWidth, + cellWidth: cellWidth, + cols: cols + }) + notify({ type: 'measure-result', cols: null, rows: null }) + return + } + // Why: the rows we report become the PTY's actual row count after the + // server fits to viewport, and xterm renders exactly that many lines + // anchored top-left of the WebView. Subtracting rows here would leave + // dead xterm-background space at the bottom of the container and make + // the last PTY rows visually appear above an "invisible line." Any + // safety margin between the prompt and the accessory bar must come + // from RN layout (terminalFrame's flex bounds), not from undersizing + // the PTY. + const rows = Math.max(8, Math.floor(vpHeight / cellHeight)) + notify({ type: 'measure-result', cols: cols, rows: rows }) +} + +export function handleMsg(msg: TerminalHostMessage) { + if (typeof msg.id === 'number') { + // oxlint-disable-next-line unicorn/prefer-includes -- the document's text is pinned token for token; rewriting this changes the native program + if (scope.handledMessageIds.indexOf(msg.id) !== -1) { + return + } + scope.handledMessageIds.push(msg.id) + if (scope.handledMessageIds.length > 256) { + scope.handledMessageIds.shift() + } + } + 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 === + // currentTextScale) so the post-pinch state isn't reset; only apply changes. + if ( + typeof msg.fontScale === 'number' && + msg.fontScale > 0 && + msg.fontScale !== scope.currentTextScale + ) { + scope.userScale = 1 + scope.panX = 0 + scope.panY = 0 + applyTextScale(msg.fontScale) + } + } else if (msg.type === 'resize') { + resize(msg.cols!, msg.rows!) + } else if (msg.type === 'reflow') { + reflow(msg.cols!, msg.rows!) + } else if (msg.type === 'write') { + write(msg.data!) + } else if (msg.type === 'clear') { + scope.terminalGeneration++ + resetWriteQueue() + resumeTerminalDataReplyAuthority() // Why: clear drops the replay boundary. + scope.statusDotPendingSelector = false + scope.afterDrainCallbacks = [] + scope.writesDraining = false + scope.mouseModeScanTail = '' + scope.trackedMouseTrackingMode = 'none' + scope.sgrMouseMode = false + scope.sgrMousePixelsMode = false + scope.initialOscLinks = [] + scope.initialOscLinkRowOffset = 0 + scope.initialOscLinkEvictionReady = false + if (scope.term) { + scope.term.clear() + scope.term.reset() + } + emitModesIfChanged() + emitKeyboardAvoidanceMetrics() + resetEvictionCounter() + if (scope.selMode === 'select') { + notify({ type: 'selection-evicted' }) + cancelSelect() + } + } else if (msg.type === 'measure') { + measureFitDimensions(msg.containerHeight) + } else if (msg.type === 'reset-zoom') { + applyFitScale('reset-zoom-msg') + } else if (msg.type === 'set-theme') { + applyTerminalTheme(msg.terminalTheme) + } else if (msg.type === 'cancel-select') { + if (scope.selMode === 'select') { + cancelSelect() + } + } else if (msg.type === 'do-select-all') { + if (scope.term) { + try { + scope.term.selectAll() + const b = scope.term.buffer.active + if (scope.selMode !== 'select') { + scope.selMode = 'select' + // oxlint-disable-next-line typescript/no-non-null-assertion -- SAFETY: the document shell ships the overlay element. + scope.selectionOverlay!.classList.add('active') + notify({ type: 'set-select-mode', enabled: true }) + } + scope.sel = { + anchor: { col: 0, row: 0 }, + focus: { col: scope.term.cols - 1, row: b.length - 1 }, + activeHandle: null + } + repositionOverlay() + } catch {} + } + } +} diff --git a/mobile/src/terminal/document/host-notify.ts b/mobile/src/terminal/document/host-notify.ts new file mode 100644 index 00000000000..310a80e2b39 --- /dev/null +++ b/mobile/src/terminal/document/host-notify.ts @@ -0,0 +1,86 @@ +import { scope } from './document-scope' + +/** + * The postMessage bridge to the host, and the engine error reporting that rides on it. + * + * They are one module because the document declares them together, ahead of the message router + * that both serve. + */ + +declare global { + interface Window { + __engineErrors: string[] + } +} + +export function notify(msg: Record) { + if (window.ReactNativeWebView) { + window.ReactNativeWebView.postMessage(JSON.stringify(msg)) + } +} + +/** What a thrown value can be here: an Error-shaped object, a string, or nothing. */ +export type TerminalEngineError = string | null | undefined | { message?: unknown } + +export function engineErrorText(err: TerminalEngineError) { + if (!err) { + return '' + } + if (typeof err === 'string') { + return err + } + if (err && typeof err.message === 'string') { + return err.message + } + try { + return String(err) + } catch { + return '' + } +} + +export function chromeVersionText() { + const match = String(navigator.userAgent || '').match(/(?:Chrome|Chromium)\/([0-9.]+)/) + return match ? 'Chrome ' + match[1] : 'Chrome version unknown' +} + +let nonFatalErrorNotifies = 0 + +export function reportEngineError(context: string, err: TerminalEngineError, fatal?: unknown) { + const isFatal = fatal === undefined ? !scope.everReady : !!fatal + if (!isFatal) { + // Why: a constructed-but-degraded engine can throw per frame; cap + // non-fatal notifies so RN isn't flooded. Fatal reports always emit. + nonFatalErrorNotifies++ + if (nonFatalErrorNotifies > 5) { + return + } + } + const parts = [context] + const errText = engineErrorText(err) + if (errText) { + parts.push(errText) + } + if (window.__engineErrors && window.__engineErrors.length) { + parts.push('captured: ' + window.__engineErrors.join(' | ')) + } + parts.push(chromeVersionText()) + notify({ + type: 'error', + fatal: isFatal, + message: parts.join(' - ') + }) +} + +window.onerror = function ( + msg: string | (Event & { message?: unknown }), + source, + line, + column, + err?: TerminalEngineError +) { + if (window.__engineErrors.length < 20) { + window.__engineErrors.push(String(msg)) + } + reportEngineError('terminal runtime error', err || msg) +} diff --git a/mobile/src/terminal/document/keyboard-avoidance-metrics.ts b/mobile/src/terminal/document/keyboard-avoidance-metrics.ts index 6e28992fcca..93cb9866d59 100644 --- a/mobile/src/terminal/document/keyboard-avoidance-metrics.ts +++ b/mobile/src/terminal/document/keyboard-avoidance-metrics.ts @@ -1,4 +1,4 @@ -import { notify } from './document-externals' +import { notify } from './host-notify' import { scope, type TerminalDocumentCell, type TerminalDocumentLine } from './document-scope' export function lineHasVisibleContent( diff --git a/mobile/src/terminal/document/message-bridge.ts b/mobile/src/terminal/document/message-bridge.ts index 34997a0bc62..7f64fe0fb76 100644 --- a/mobile/src/terminal/document/message-bridge.ts +++ b/mobile/src/terminal/document/message-bridge.ts @@ -1,5 +1,7 @@ import { adjustRowsForViewport, applyFitScale, clampPan } from './fit-scale' -import { handleMsg, notify, reportEngineError, repositionOverlay } from './document-externals' +import { repositionOverlay } from './document-externals' +import { handleMsg, type TerminalHostMessage } from './host-message-router' +import { notify, reportEngineError, type TerminalEngineError } from './host-notify' import { updateTransform } from './viewport-transform' import { scope } from './document-scope' @@ -9,9 +11,6 @@ declare global { } } -/** The decoded host message; only its type is read here, by the error reporter. */ -type TerminalHostMessage = { type?: unknown } | undefined - export function handleIncomingMessage(e: Event & { data?: TerminalHostMessage | string }) { let msg: TerminalHostMessage try { @@ -20,11 +19,12 @@ export function handleIncomingMessage(e: Event & { data?: TerminalHostMessage | return } try { - handleMsg(msg) + handleMsg(msg!) } catch (ex) { reportEngineError( msg && msg.type === 'init' ? 'terminal init failed' : 'terminal message failed', - ex, + // oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: a catch binding is `unknown`; the reporter reads only `message` and falls back to String(). + ex as TerminalEngineError, msg && msg.type === 'init' && !scope.everReady ) } diff --git a/mobile/src/terminal/document/mode-mirroring.ts b/mobile/src/terminal/document/mode-mirroring.ts index a9f6f740808..c5471bb8db4 100644 --- a/mobile/src/terminal/document/mode-mirroring.ts +++ b/mobile/src/terminal/document/mode-mirroring.ts @@ -1,4 +1,4 @@ -import { notify } from './document-externals' +import { notify } from './host-notify' import { getMouseTrackingMode } from './mouse-input-encoding' import { scope } from './document-scope' diff --git a/mobile/src/terminal/document/mouse-click-drag.ts b/mobile/src/terminal/document/mouse-click-drag.ts index 6b0a3d5016c..f9a4e3c80d7 100644 --- a/mobile/src/terminal/document/mouse-click-drag.ts +++ b/mobile/src/terminal/document/mouse-click-drag.ts @@ -2,10 +2,10 @@ import { applyXtermSelection, cancelSelect, handleDragMove, - notify, repositionOverlay, stopEdgeScroll } from './document-externals' +import { notify } from './host-notify' import { getMouseTrackingMode, isSafeSgrMouseCoordinate } from './mouse-input-encoding' import { viewportToCell } from './viewport-cell' import { scope } from './document-scope' diff --git a/mobile/src/terminal/document/mouse-input-encoding.ts b/mobile/src/terminal/document/mouse-input-encoding.ts index 9c314c6eaf5..1ea8360cca3 100644 --- a/mobile/src/terminal/document/mouse-input-encoding.ts +++ b/mobile/src/terminal/document/mouse-input-encoding.ts @@ -1,4 +1,4 @@ -import { notify } from './document-externals' +import { notify } from './host-notify' import { scope } from './document-scope' import { viewportToMouseReportCell } from './mouse-report-cell' diff --git a/mobile/src/terminal/document/query-reply.ts b/mobile/src/terminal/document/query-reply.ts index 824604430ca..70509735b2c 100644 --- a/mobile/src/terminal/document/query-reply.ts +++ b/mobile/src/terminal/document/query-reply.ts @@ -1,5 +1,5 @@ import { enqueueWriteBoundary } from './write-queue' -import { notify } from './document-externals' +import { notify } from './host-notify' import { scope, type TerminalDocumentDisposable } from './document-scope' /** diff --git a/mobile/src/terminal/document/selection-state-and-eviction.ts b/mobile/src/terminal/document/selection-state-and-eviction.ts index 426a2c9ef6b..d2ac2f63580 100644 --- a/mobile/src/terminal/document/selection-state-and-eviction.ts +++ b/mobile/src/terminal/document/selection-state-and-eviction.ts @@ -1,4 +1,5 @@ -import { cancelSelect, notify, repositionOverlay } from './document-externals' +import { cancelSelect, repositionOverlay } from './document-externals' +import { notify } from './host-notify' import { scope } from './document-scope' // ============================================================ diff --git a/mobile/src/terminal/document/surface-tap.ts b/mobile/src/terminal/document/surface-tap.ts index efb7bbaa9b3..475e3992648 100644 --- a/mobile/src/terminal/document/surface-tap.ts +++ b/mobile/src/terminal/document/surface-tap.ts @@ -1,4 +1,4 @@ -import { notify } from './document-externals' +import { notify } from './host-notify' import { buildMouseClickInput, getMouseTrackingMode, diff --git a/mobile/src/terminal/document/tap-dispatch.ts b/mobile/src/terminal/document/tap-dispatch.ts index f1aec1efe1d..3c8d1e5819d 100644 --- a/mobile/src/terminal/document/tap-dispatch.ts +++ b/mobile/src/terminal/document/tap-dispatch.ts @@ -1,10 +1,5 @@ -import { - cancelSelect, - enterSelect, - handleDragMove, - notify, - stopEdgeScroll -} from './document-externals' +import { cancelSelect, enterSelect, handleDragMove, stopEdgeScroll } from './document-externals' +import { notify } from './host-notify' import { viewportToCell } from './viewport-cell' import { scope } from './document-scope' import { notifyTerminalSurfaceTap } from './surface-tap' diff --git a/mobile/src/terminal/document/terminal-init.ts b/mobile/src/terminal/document/terminal-init.ts index 8afc1227c25..c744918bcdf 100644 --- a/mobile/src/terminal/document/terminal-init.ts +++ b/mobile/src/terminal/document/terminal-init.ts @@ -5,7 +5,7 @@ import { terminalCursorStyle, terminalShowCursorImmediately } from './document-constants' -import { notify } from './document-externals' +import { notify } from './host-notify' import { fontPxForScale } from './text-scaling' import { scope, diff --git a/mobile/src/terminal/document/terminal-theme.ts b/mobile/src/terminal/document/terminal-theme.ts index 7f7f2666690..cd4eb5743a1 100644 --- a/mobile/src/terminal/document/terminal-theme.ts +++ b/mobile/src/terminal/document/terminal-theme.ts @@ -8,10 +8,10 @@ export type TerminalDocumentRgb = { r: number; g: number; b: number } export type TerminalDocumentRgba = TerminalDocumentRgb & { a: number } /** The theme payload the host publishes; an older host omits the contrast floor. */ -export type TerminalDocumentThemeMessage = { - theme?: Record - minimumContrastRatio?: number -} | null +export type TerminalDocumentThemeMessage = + | { theme?: Record; minimumContrastRatio?: number } + | null + | undefined const DARK_BG_MIN_CONTRAST = 3 const LIGHT_BG_MIN_CONTRAST = 4.5