mirror of
https://github.com/stablyai/orca.git
synced 2026-09-21 16:02:20 +00:00
* fix(mobile): bundle terminal engine and show load errors instead of a blank pane The mobile terminal WebView loaded xterm.js from cdn.jsdelivr.net at runtime; old WebViews (< Chrome 85) fail to parse the modern bundle and blocked-CDN networks fail to fetch it, and the resulting error was silently dropped, leaving the pane permanently blank (#7030). Bundle the engine into the app via exact-pinned npm deps + a postinstall esbuild step (chrome74 target, guarded WeakRef/structuredClone/ replaceChildren shims) emitting a gitignored generated module, inline it into the terminal document, and surface fatal engine failures as a visible overlay with diagnostics and a Reload wired into the existing resubscribe path. Non-fatal errors log without covering a live terminal. Co-authored-by: Orca <help@stably.ai> * fix(mobile): add a native watchdog so a dead terminal document can't stay silently blank CodeRabbit round: if the webview document dies before the glue can post anything (or the RN message bridge never comes up), no error message and no native handler fires. Arm a 15s foreground-gated watchdog per document generation that paints the fatal overlay when web-ready never arrives; first fatal diagnostics win over later cascades. Extract the watchdog and the public contract types to keep TerminalWebView under the line cap, and document the SVG xmlns percent-encoding transform. Co-authored-by: Orca <help@stably.ai> * test(mobile): unmount TerminalWebView renderers so watchdog timers can't leak across tests Co-authored-by: Orca <help@stably.ai> --------- Co-authored-by: Orca <help@stably.ai>
109 lines
4.4 KiB
JavaScript
109 lines
4.4 KiB
JavaScript
import { readFile, writeFile } from 'node:fs/promises'
|
|
import path from 'node:path'
|
|
import { createRequire } from 'node:module'
|
|
import * as esbuild from 'esbuild'
|
|
|
|
const require = createRequire(import.meta.url)
|
|
const scriptDir = import.meta.dirname
|
|
const mobileRoot = path.resolve(scriptDir, '..')
|
|
const outputPath = path.join(mobileRoot, 'src', 'terminal', 'terminal-webview-engine.generated.ts')
|
|
const target = 'chrome74'
|
|
|
|
const packages = ['@xterm/xterm', '@xterm/addon-unicode11', '@xterm/addon-webgl']
|
|
|
|
async function readPackageVersion(packageName) {
|
|
// Why: a package.json module specifier must use '/' — path.join emits '\' on
|
|
// Windows, yielding an unresolvable bare specifier that fails postinstall there.
|
|
const packageJsonPath = require.resolve(`${packageName}/package.json`)
|
|
const packageJson = JSON.parse(await readFile(packageJsonPath, 'utf8'))
|
|
return `${packageName}@${packageJson.version}`
|
|
}
|
|
|
|
function htmlText(value, closingTag) {
|
|
return value.replace(new RegExp(`</${closingTag}`, 'gi'), `<\\/${closingTag}`)
|
|
}
|
|
|
|
async function buildEngineJs() {
|
|
const result = await esbuild.build({
|
|
stdin: {
|
|
contents: `
|
|
import { Terminal } from '@xterm/xterm'
|
|
import { Unicode11Addon } from '@xterm/addon-unicode11'
|
|
import { WebglAddon } from '@xterm/addon-webgl'
|
|
|
|
// Why: xterm reaches for these runtime APIs on the terminal-bringup path,
|
|
// and esbuild lowers syntax but not runtime APIs. Guarded shims let the
|
|
// chrome74-syntax bundle actually run on old WebViews (the #7030 goal)
|
|
// instead of throwing at construction and only surfacing the error overlay.
|
|
|
|
// WeakRef (Chrome 84+): lazily constructed in window-tracking paths.
|
|
// Strong retention is fine for a single-document terminal WebView.
|
|
if (typeof window.WeakRef === 'undefined') {
|
|
window.WeakRef = function WeakRefShim(target) { this.__target = target }
|
|
window.WeakRef.prototype.deref = function () { return this.__target }
|
|
}
|
|
|
|
// structuredClone (Chrome 98+): xterm clones its plain-data DEC-mode default
|
|
// objects at Terminal construction; a JSON round-trip clones those correctly
|
|
// (any undefined-valued keys drop, but every reader treats absent == undefined).
|
|
if (typeof window.structuredClone === 'undefined') {
|
|
window.structuredClone = function (value) { return JSON.parse(JSON.stringify(value)) }
|
|
}
|
|
|
|
// Element.prototype.replaceChildren (Chrome 86+): used on the row/selection
|
|
// render path; polyfill via remove-all + append (Chrome 54+, under the floor).
|
|
if (typeof Element !== 'undefined' && !Element.prototype.replaceChildren) {
|
|
Element.prototype.replaceChildren = function () {
|
|
while (this.firstChild) this.removeChild(this.firstChild)
|
|
this.append.apply(this, arguments)
|
|
}
|
|
}
|
|
|
|
window.Terminal = Terminal
|
|
window.Unicode11Addon = { Unicode11Addon }
|
|
window.WebglAddon = { WebglAddon }
|
|
`,
|
|
resolveDir: mobileRoot,
|
|
sourcefile: 'terminal-webview-engine-entry.js'
|
|
},
|
|
bundle: true,
|
|
format: 'iife',
|
|
minify: true,
|
|
platform: 'browser',
|
|
target,
|
|
legalComments: 'none',
|
|
write: false,
|
|
logLevel: 'silent'
|
|
})
|
|
|
|
return result.outputFiles[0].text
|
|
}
|
|
|
|
async function main() {
|
|
const [engineJs, rawEngineCss, ...versions] = await Promise.all([
|
|
buildEngineJs(),
|
|
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')
|
|
|
|
const source = [
|
|
'// Generated by scripts/build-terminal-webview-engine.mjs.',
|
|
`// Packages: ${versions.join(', ')}.`,
|
|
`// Target: ${target}. Do not edit by hand; regenerate via pnpm postinstall.`,
|
|
`export const XTERM_ENGINE_JS = ${JSON.stringify(htmlText(engineJs, 'script'))}`,
|
|
`export const XTERM_ENGINE_CSS = ${JSON.stringify(htmlText(engineCss, 'style'))}`,
|
|
''
|
|
].join('\n')
|
|
|
|
await writeFile(outputPath, source)
|
|
}
|
|
|
|
await main()
|