diff --git a/mobile/src/terminal/document/document-externals.ts b/mobile/src/terminal/document/document-externals.ts index 34811d1ecd2..797a119c187 100644 --- a/mobile/src/terminal/document/document-externals.ts +++ b/mobile/src/terminal/document/document-externals.ts @@ -32,3 +32,9 @@ export declare function applyFitScale(reason: string): void /** `smooth-scroll-and-cell-geometry`: shows or hides the scroll indicator. */ export declare function updateScrollIndicator(visible: boolean): void + +/** `runtime-state-and-text-scaling`: one diagnostic line, forwarded to the host. */ +export declare function flog(name: string, detail: Record): void + +/** `terminal-fit-scale`: applies a theme the host sent to the live terminal. */ +export declare function applyTerminalTheme(input: unknown): void diff --git a/mobile/src/terminal/document/document-scope.ts b/mobile/src/terminal/document/document-scope.ts index 0325dab3136..1116205fecb 100644 --- a/mobile/src/terminal/document/document-scope.ts +++ b/mobile/src/terminal/document/document-scope.ts @@ -53,6 +53,8 @@ export type TerminalDocumentTerminal = { readonly rows: number readonly buffer: { readonly active: TerminalDocumentBuffer } resize: (cols: number, rows: number) => void + refresh: (start: number, end: number) => void + loadAddon: (addon: TerminalDocumentWebglAddon) => void scrollToBottom: () => void scrollLines: (amount: number) => void } @@ -69,11 +71,24 @@ export type TerminalDocumentScope = { termObserverDisposables: TerminalDocumentDisposable[] /** `terminal-init-and-write`: the row count the last init or reflow settled on. */ initRows: number + /** `webgl-recovery`: the loaded WebGL addon, or null on the DOM renderer. */ + webglAddon: TerminalDocumentWebglAddon | null + /** `webgl-recovery`: the pending single retry after a context loss. */ + webglRecoveryTimer: ReturnType | null + /** `runtime-state-and-text-scaling`: the theme the host last sent, replayed on visibility. */ + terminalThemeInput: unknown } /** An xterm listener handle, as the document disposes of one. */ export type TerminalDocumentDisposable = { dispose?: () => void } +/** xterm's WebGL addon, as the document loads, repaints and disposes of it. */ +export type TerminalDocumentWebglAddon = { + onContextLoss?: (listener: () => void) => void + clearTextureAtlas?: () => void + dispose: () => void +} + /** * The initial values, which are the ones the document's own declarations carried. * @@ -87,7 +102,10 @@ export function createTerminalDocumentScope(): TerminalDocumentScope { panY: 0, terminalGeneration: 0, termObserverDisposables: [], - initRows: 24 + initRows: 24, + webglAddon: null, + webglRecoveryTimer: null, + terminalThemeInput: null } } diff --git a/mobile/src/terminal/document/webgl-recovery.test.ts b/mobile/src/terminal/document/webgl-recovery.test.ts new file mode 100644 index 00000000000..b2c1e41d060 --- /dev/null +++ b/mobile/src/terminal/document/webgl-recovery.test.ts @@ -0,0 +1,24 @@ +import { fileURLToPath } from 'node:url' +import { describe, expect, it } from 'vitest' +import { emitTerminalDocumentModule } from '../../../scripts/build-terminal-document-script.mjs' +import { TERMINAL_WEBGL_RECOVERY_JS } from '../terminal-webview-webgl-recovery-injected' +import { compareTerminalDocumentScripts } from './terminal-document-equivalence.test-support' + +const modulePath = fileURLToPath(new URL('./webgl-recovery.ts', import.meta.url)) + +describe('the WebGL recovery module', () => { + it('emits the script the document carries, modulo the four normalisations', async () => { + const emitted = await emitTerminalDocumentModule(modulePath) + expect(compareTerminalDocumentScripts(TERMINAL_WEBGL_RECOVERY_JS, emitted, 'scope')).toEqual({ + equivalent: true, + normalisations: { + qualifiedReferences: 23, + scopeFieldDeclarations: 0, + rebindings: 3, + bracedBodies: 12, + // Five of the six catch clauses; the attach failure reads its error and keeps its binding. + unboundCatches: 5 + } + }) + }) +}) diff --git a/mobile/src/terminal/document/webgl-recovery.ts b/mobile/src/terminal/document/webgl-recovery.ts new file mode 100644 index 00000000000..0c9258c366b --- /dev/null +++ b/mobile/src/terminal/document/webgl-recovery.ts @@ -0,0 +1,105 @@ +import { applyTerminalTheme, flog } from './document-externals' +import { scope, type TerminalDocumentWebglAddon } from './document-scope' + +/** xterm's WebGL addon constructor, as the engine bundle puts it on `window`. */ +type WebglAddonGlobal = { WebglAddon?: new () => TerminalDocumentWebglAddon } + +declare global { + interface Window { + WebglAddon?: WebglAddonGlobal + } +} + +export function refreshTerminalSurface() { + if (!scope.term) { + return + } + try { + scope.term.refresh(0, Math.max(0, scope.term.rows - 1)) + } catch {} +} + +export function cancelWebglContextRecovery() { + if (!scope.webglRecoveryTimer) { + return + } + clearTimeout(scope.webglRecoveryTimer) + scope.webglRecoveryTimer = null +} + +export function attachWebglAddon(allowRecovery: boolean) { + if (!scope.term || !window.WebglAddon || !window.WebglAddon.WebglAddon) { + return false + } + let addon: TerminalDocumentWebglAddon | null = null + try { + addon = new window.WebglAddon.WebglAddon() + scope.webglAddon = addon + if (addon.onContextLoss) { + addon.onContextLoss(function () { + if (scope.webglAddon !== addon) { + return + } + flog('webgl-context-loss', { retry: allowRecovery }) + scope.webglAddon = null + try { + // oxlint-disable-next-line typescript/no-non-null-assertion -- SAFETY: this listener is registered on the addon, so it cannot run before the assignment above. + addon!.dispose() + } catch {} + 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() + const recoveryTerm = scope.term + const recoveryGeneration = scope.terminalGeneration + scope.webglRecoveryTimer = setTimeout(function () { + scope.webglRecoveryTimer = null + if (scope.term !== recoveryTerm || scope.terminalGeneration !== recoveryGeneration) { + return + } + attachWebglAddon(false) + }, 100) + }) + } + scope.term.loadAddon(addon) + if (!allowRecovery) { + try { + if (addon.clearTextureAtlas) { + addon.clearTextureAtlas() + } + } catch {} + refreshTerminalSurface() + } + return true + } catch (e) { + flog('webgl-attach-failed', { retry: !allowRecovery, message: String(e) }) + if (scope.webglAddon === addon) { + scope.webglAddon = null + } + try { + if (addon) { + addon.dispose() + } + } catch {} + 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(scope.terminalThemeInput) + try { + if (scope.webglAddon && scope.webglAddon.clearTextureAtlas) { + scope.webglAddon.clearTextureAtlas() + } + } catch {} + refreshTerminalSurface() +})