diff --git a/mobile/src/terminal/document/document-externals.ts b/mobile/src/terminal/document/document-externals.ts index 08a75eabac4..3eaa8505f4d 100644 --- a/mobile/src/terminal/document/document-externals.ts +++ b/mobile/src/terminal/document/document-externals.ts @@ -98,3 +98,18 @@ export declare function isClickMouseTrackingMode(mode: string): boolean /** `write-queue`: the trailing bytes a DECSET scan must carry into the next chunk. */ export declare function extractMouseModeScanTail(input: string): string + +/** `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 + +/** `terminal-init-and-write`: re-fits the row count to the current viewport. */ +export declare function adjustRowsForViewport(): void + +/** `smooth-scroll-and-cell-geometry`: clamps the pan offsets to the scaled surface. */ +export declare function clampPan(): void + +/** `smooth-scroll-and-cell-geometry`: writes the pan and scale onto the surface transform. */ +export declare function updateTransform(): void diff --git a/mobile/src/terminal/document/document-scope.ts b/mobile/src/terminal/document/document-scope.ts index 37902b123ec..52f13813f06 100644 --- a/mobile/src/terminal/document/document-scope.ts +++ b/mobile/src/terminal/document/document-scope.ts @@ -124,6 +124,8 @@ export type TerminalDocumentScope = { initialOscLinkRowOffset: number /** `runtime-state`: the escape byte every report is prefixed with. */ ESC: string + /** `terminal-init-and-write`: whether the terminal has ever reached ready. */ + everReady: boolean /** `runtime-state`: the C1 form of the control sequence introducer. */ C1_CSI: string /** `runtime-state`: the tail of the last chunk, in case a DECSET straddles two writes. */ @@ -210,6 +212,7 @@ export function createTerminalDocumentScope(): TerminalDocumentScope { initialOscLinks: [], initialOscLinkRowOffset: 0, ESC: String.fromCharCode(27), + everReady: false, C1_CSI: String.fromCharCode(155), mouseModeScanTail: '', trackedMouseTrackingMode: 'none', diff --git a/mobile/src/terminal/document/message-bridge.test.ts b/mobile/src/terminal/document/message-bridge.test.ts new file mode 100644 index 00000000000..32a092a44a9 --- /dev/null +++ b/mobile/src/terminal/document/message-bridge.test.ts @@ -0,0 +1,26 @@ +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { emitTerminalDocumentModule } from '../../../scripts/build-terminal-document-script.mjs' +import { TERMINAL_HTML_MESSAGE_BRIDGE } from '../terminal-webview-html/message-bridge-and-document-close' +import { compareTerminalDocumentScripts } from './terminal-document-equivalence.test-support' + +const modulePath = fileURLToPath(new URL('./message-bridge.ts', import.meta.url)) + +describe('the message-bridge module', () => { + it('emits the script the document carries, modulo the five normalisations', async () => { + const emitted = await emitTerminalDocumentModule(modulePath) + expect(compareTerminalDocumentScripts(TERMINAL_HTML_MESSAGE_BRIDGE, emitted, 'scope')).toEqual({ + equivalent: true, + normalisations: { + // Only the ready flag, read to decide whether a failed init is fatal. + qualifiedReferences: 1, + scopeFieldDeclarations: 0, + rebindings: 1, + bracedBodies: 0, + // The parse guard. The second catch names its error and reports it, so it keeps its binding. + unboundCatches: 1, + numberProperties: 0 + } + }) + }) +}) diff --git a/mobile/src/terminal/document/message-bridge.ts b/mobile/src/terminal/document/message-bridge.ts new file mode 100644 index 00000000000..df94439e32f --- /dev/null +++ b/mobile/src/terminal/document/message-bridge.ts @@ -0,0 +1,60 @@ +import { + adjustRowsForViewport, + applyFitScale, + clampPan, + handleMsg, + notify, + reportEngineError, + repositionOverlay, + updateTransform +} from './document-externals' +import { scope } from './document-scope' + +declare global { + interface Window { + Terminal?: unknown + } +} + +/** 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 { + msg = typeof e.data === 'string' ? JSON.parse(e.data) : e.data + } catch { + return + } + try { + handleMsg(msg) + } catch (ex) { + reportEngineError( + msg && msg.type === 'init' ? 'terminal init failed' : 'terminal message failed', + ex, + msg && msg.type === 'init' && !scope.everReady + ) + } +} + +window.addEventListener('message', handleIncomingMessage) + +document.addEventListener('message', handleIncomingMessage) + +window.addEventListener('resize', function () { + // Why: viewport changed (keyboard open/close, orientation, RN container + // size update). Re-fit so the scale matches the new vpWidth — without + // this, opening the keyboard leaves the terminal at the old scale even + // though there's now less vertical room and the fit ratio may differ. + applyFitScale('window-resize') + adjustRowsForViewport() + repositionOverlay() + clampPan() + updateTransform() +}) + +if (window.Terminal) { + notify({ type: 'web-ready' }) +} else { + reportEngineError('terminal engine missing', 'xterm failed to load', true) +} diff --git a/mobile/src/terminal/terminal-webview-html.ts b/mobile/src/terminal/terminal-webview-html.ts index 17fadd4d26c..711f4402004 100644 --- a/mobile/src/terminal/terminal-webview-html.ts +++ b/mobile/src/terminal/terminal-webview-html.ts @@ -11,7 +11,10 @@ import { TERMINAL_HTML_MOUSE_REPORT_AND_SCROLL_ROUTING } from './terminal-webvie import { TERMINAL_HTML_SMOOTH_SCROLL_AND_CELL_GEOMETRY } from './terminal-webview-html/smooth-scroll-and-cell-geometry' import { TERMINAL_HTML_SELECTION_OVERLAY } from './terminal-webview-html/selection-overlay' import { TERMINAL_HTML_SURFACE_TOUCH_GESTURES } from './terminal-webview-html/surface-touch-gestures' -import { TERMINAL_HTML_MESSAGE_BRIDGE_AND_DOCUMENT_CLOSE } from './terminal-webview-html/message-bridge-and-document-close' +import { + TERMINAL_HTML_DOCUMENT_CLOSE, + TERMINAL_HTML_MESSAGE_BRIDGE +} from './terminal-webview-html/message-bridge-and-document-close' export { MOBILE_TERMINAL_CARET_OPTIONS } from './terminal-webview-html/theme' @@ -32,7 +35,8 @@ export const XTERM_HTML = [ TERMINAL_HTML_SMOOTH_SCROLL_AND_CELL_GEOMETRY, TERMINAL_HTML_SELECTION_OVERLAY, TERMINAL_HTML_SURFACE_TOUCH_GESTURES, - TERMINAL_HTML_MESSAGE_BRIDGE_AND_DOCUMENT_CLOSE + TERMINAL_HTML_MESSAGE_BRIDGE, + TERMINAL_HTML_DOCUMENT_CLOSE ].join('') export const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML } diff --git a/mobile/src/terminal/terminal-webview-html/message-bridge-and-document-close.ts b/mobile/src/terminal/terminal-webview-html/message-bridge-and-document-close.ts index 49cd3b4d84f..321c693ee0c 100644 --- a/mobile/src/terminal/terminal-webview-html/message-bridge-and-document-close.ts +++ b/mobile/src/terminal/terminal-webview-html/message-bridge-and-document-close.ts @@ -1,4 +1,5 @@ -export const TERMINAL_HTML_MESSAGE_BRIDGE_AND_DOCUMENT_CLOSE = ` function handleIncomingMessage(e) { +// The script ends here and the document ends below: the boundary is where the IIFE closes. +export const TERMINAL_HTML_MESSAGE_BRIDGE = ` function handleIncomingMessage(e) { var msg; try { msg = typeof e.data === 'string' ? JSON.parse(e.data) : e.data; @@ -37,7 +38,9 @@ export const TERMINAL_HTML_MESSAGE_BRIDGE_AND_DOCUMENT_CLOSE = ` function handl } else { reportEngineError('terminal engine missing', 'xterm failed to load', true); } -})(); +` + +export const TERMINAL_HTML_DOCUMENT_CLOSE = `})(); `