Fix blank mobile terminal on Android devices with outdated WebViews or blocked CDN (#7186)

* 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>
This commit is contained in:
Brennan Benson
2026-07-03 15:22:00 -07:00
committed by GitHub
co-authored by Orca
parent f3e103ebe7
commit f4790e9fac
15 changed files with 1137 additions and 161 deletions
+1
View File
@@ -1,4 +1,5 @@
node_modules/
src/terminal/terminal-webview-engine.generated.ts
.expo/
dist/
/android/
+1
View File
@@ -1,6 +1,7 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"extends": ["../.oxlintrc.json"],
"ignorePatterns": ["src/terminal/terminal-webview-engine.generated.ts"],
"rules": {
"react-hooks/exhaustive-deps": "off",
"react/no-unescaped-entities": "off",
+6
View File
@@ -7,6 +7,7 @@
"start": "node scripts/start-expo.mjs",
"android": "expo run:android",
"ios": "expo run:ios",
"postinstall": "node scripts/build-terminal-webview-engine.mjs",
"test": "vitest run",
"lint": "oxlint",
"format": "oxfmt --write .",
@@ -18,6 +19,9 @@
"dependencies": {
"@orca/expo-two-way-audio": "file:./packages/expo-two-way-audio",
"@react-native-async-storage/async-storage": "^2.2.0",
"@xterm/addon-unicode11": "0.10.0-beta.285",
"@xterm/addon-webgl": "0.20.0-beta.284",
"@xterm/xterm": "6.1.0-beta.285",
"buffer": "^6.0.3",
"expo": "^55.0.23",
"expo-build-properties": "^55.0.13",
@@ -62,6 +66,8 @@
"@types/react-native": "^0.73.0",
"@types/react-test-renderer": "19.1.0",
"@types/ws": "^8.18.1",
"acorn": "8.15.0",
"esbuild": "0.25.4",
"expo-module-scripts": "^55.0.2",
"oxfmt": "^0.52.0",
"oxlint": "^1.71.0",
+317 -47
View File
@@ -14,6 +14,15 @@ importers:
'@react-native-async-storage/async-storage':
specifier: ^2.2.0
version: 2.2.0(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))
'@xterm/addon-unicode11':
specifier: 0.10.0-beta.285
version: 0.10.0-beta.285(@xterm/xterm@6.1.0-beta.285)
'@xterm/addon-webgl':
specifier: 0.20.0-beta.284
version: 0.20.0-beta.284(@xterm/xterm@6.1.0-beta.285)
'@xterm/xterm':
specifier: 6.1.0-beta.285
version: 6.1.0-beta.285
buffer:
specifier: ^6.0.3
version: 6.0.3
@@ -141,9 +150,15 @@ importers:
'@types/ws':
specifier: ^8.18.1
version: 8.18.1
acorn:
specifier: 8.15.0
version: 8.15.0
esbuild:
specifier: 0.25.4
version: 0.25.4
expo-module-scripts:
specifier: ^55.0.2
version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6)
version: 55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6)
oxfmt:
specifier: ^0.52.0
version: 0.52.0
@@ -161,10 +176,10 @@ importers:
version: 5.9.3
vite:
specifier: ^8.0.16
version: 8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
version: 8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
vitest:
specifier: ^4.1.9
version: 4.1.9(@types/node@25.9.3)(jsdom@20.0.3)(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))
version: 4.1.9(@types/node@25.9.3)(jsdom@20.0.3)(vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))
packages:
@@ -1157,126 +1172,252 @@ packages:
'@emnapi/wasi-threads@1.2.2':
resolution: {integrity: sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==}
'@esbuild/aix-ppc64@0.25.4':
resolution: {integrity: sha512-1VCICWypeQKhVbE9oW/sJaAmjLxhVqacdkvPLEjwlttjfwENRSClS8EjBz0KzRyFSCPDIkuXW34Je/vk7zdB7Q==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/aix-ppc64@0.28.1':
resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [aix]
'@esbuild/android-arm64@0.25.4':
resolution: {integrity: sha512-bBy69pgfhMGtCnwpC/x5QhfxAz/cBgQ9enbtwjf6V9lnPI/hMyT9iWpR1arm0l3kttTr4L0KSLpKmLp/ilKS9A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm64@0.28.1':
resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==}
engines: {node: '>=18'}
cpu: [arm64]
os: [android]
'@esbuild/android-arm@0.25.4':
resolution: {integrity: sha512-QNdQEps7DfFwE3hXiU4BZeOV68HHzYwGd0Nthhd3uCkkEKK7/R6MTgM0P7H7FAs5pU/DIWsviMmEGxEoxIZ+ZQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-arm@0.28.1':
resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [android]
'@esbuild/android-x64@0.25.4':
resolution: {integrity: sha512-TVhdVtQIFuVpIIR282btcGC2oGQoSfZfmBdTip2anCaVYcqWlZXGcdcKIUklfX2wj0JklNYgz39OBqh2cqXvcQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/android-x64@0.28.1':
resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==}
engines: {node: '>=18'}
cpu: [x64]
os: [android]
'@esbuild/darwin-arm64@0.25.4':
resolution: {integrity: sha512-Y1giCfM4nlHDWEfSckMzeWNdQS31BQGs9/rouw6Ub91tkK79aIMTH3q9xHvzH8d0wDru5Ci0kWB8b3up/nl16g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-arm64@0.28.1':
resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [darwin]
'@esbuild/darwin-x64@0.25.4':
resolution: {integrity: sha512-CJsry8ZGM5VFVeyUYB3cdKpd/H69PYez4eJh1W/t38vzutdjEjtP7hB6eLKBoOdxcAlCtEYHzQ/PJ/oU9I4u0A==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/darwin-x64@0.28.1':
resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [darwin]
'@esbuild/freebsd-arm64@0.25.4':
resolution: {integrity: sha512-yYq+39NlTRzU2XmoPW4l5Ifpl9fqSk0nAJYM/V/WUGPEFfek1epLHJIkTQM6bBs1swApjO5nWgvr843g6TjxuQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-arm64@0.28.1':
resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [freebsd]
'@esbuild/freebsd-x64@0.25.4':
resolution: {integrity: sha512-0FgvOJ6UUMflsHSPLzdfDnnBBVoCDtBTVyn/MrWloUNvq/5SFmh13l3dvgRPkDihRxb77Y17MbqbCAa2strMQQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/freebsd-x64@0.28.1':
resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [freebsd]
'@esbuild/linux-arm64@0.25.4':
resolution: {integrity: sha512-+89UsQTfXdmjIvZS6nUnOOLoXnkUTB9hR5QAeLrQdzOSWZvNSAXAtcRDHWtqAUtAmv7ZM1WPOOeSxDzzzMogiQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm64@0.28.1':
resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==}
engines: {node: '>=18'}
cpu: [arm64]
os: [linux]
'@esbuild/linux-arm@0.25.4':
resolution: {integrity: sha512-kro4c0P85GMfFYqW4TWOpvmF8rFShbWGnrLqlzp4X1TNWjRY3JMYUfDCtOxPKOIY8B0WC8HN51hGP4I4hz4AaQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-arm@0.28.1':
resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==}
engines: {node: '>=18'}
cpu: [arm]
os: [linux]
'@esbuild/linux-ia32@0.25.4':
resolution: {integrity: sha512-yTEjoapy8UP3rv8dB0ip3AfMpRbyhSN3+hY8mo/i4QXFeDxmiYbEKp3ZRjBKcOP862Ua4b1PDfwlvbuwY7hIGQ==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-ia32@0.28.1':
resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==}
engines: {node: '>=18'}
cpu: [ia32]
os: [linux]
'@esbuild/linux-loong64@0.25.4':
resolution: {integrity: sha512-NeqqYkrcGzFwi6CGRGNMOjWGGSYOpqwCjS9fvaUlX5s3zwOtn1qwg1s2iE2svBe4Q/YOG1q6875lcAoQK/F4VA==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-loong64@0.28.1':
resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==}
engines: {node: '>=18'}
cpu: [loong64]
os: [linux]
'@esbuild/linux-mips64el@0.25.4':
resolution: {integrity: sha512-IcvTlF9dtLrfL/M8WgNI/qJYBENP3ekgsHbYUIzEzq5XJzzVEV/fXY9WFPfEEXmu3ck2qJP8LG/p3Q8f7Zc2Xg==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-mips64el@0.28.1':
resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==}
engines: {node: '>=18'}
cpu: [mips64el]
os: [linux]
'@esbuild/linux-ppc64@0.25.4':
resolution: {integrity: sha512-HOy0aLTJTVtoTeGZh4HSXaO6M95qu4k5lJcH4gxv56iaycfz1S8GO/5Jh6X4Y1YiI0h7cRyLi+HixMR+88swag==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-ppc64@0.28.1':
resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==}
engines: {node: '>=18'}
cpu: [ppc64]
os: [linux]
'@esbuild/linux-riscv64@0.25.4':
resolution: {integrity: sha512-i8JUDAufpz9jOzo4yIShCTcXzS07vEgWzyX3NH2G7LEFVgrLEhjwL3ajFE4fZI3I4ZgiM7JH3GQ7ReObROvSUA==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-riscv64@0.28.1':
resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==}
engines: {node: '>=18'}
cpu: [riscv64]
os: [linux]
'@esbuild/linux-s390x@0.25.4':
resolution: {integrity: sha512-jFnu+6UbLlzIjPQpWCNh5QtrcNfMLjgIavnwPQAfoGx4q17ocOU9MsQ2QVvFxwQoWpZT8DvTLooTvmOQXkO51g==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-s390x@0.28.1':
resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==}
engines: {node: '>=18'}
cpu: [s390x]
os: [linux]
'@esbuild/linux-x64@0.25.4':
resolution: {integrity: sha512-6e0cvXwzOnVWJHq+mskP8DNSrKBr1bULBvnFLpc1KY+d+irZSgZ02TGse5FsafKS5jg2e4pbvK6TPXaF/A6+CA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/linux-x64@0.28.1':
resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==}
engines: {node: '>=18'}
cpu: [x64]
os: [linux]
'@esbuild/netbsd-arm64@0.25.4':
resolution: {integrity: sha512-vUnkBYxZW4hL/ie91hSqaSNjulOnYXE1VSLusnvHg2u3jewJBz3YzB9+oCw8DABeVqZGg94t9tyZFoHma8gWZQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-arm64@0.28.1':
resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==}
engines: {node: '>=18'}
cpu: [arm64]
os: [netbsd]
'@esbuild/netbsd-x64@0.25.4':
resolution: {integrity: sha512-XAg8pIQn5CzhOB8odIcAm42QsOfa98SBeKUdo4xa8OvX8LbMZqEtgeWE9P/Wxt7MlG2QqvjGths+nq48TrUiKw==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/netbsd-x64@0.28.1':
resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==}
engines: {node: '>=18'}
cpu: [x64]
os: [netbsd]
'@esbuild/openbsd-arm64@0.25.4':
resolution: {integrity: sha512-Ct2WcFEANlFDtp1nVAXSNBPDxyU+j7+tId//iHXU2f/lN5AmO4zLyhDcpR5Cz1r08mVxzt3Jpyt4PmXQ1O6+7A==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-arm64@0.28.1':
resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==}
engines: {node: '>=18'}
cpu: [arm64]
os: [openbsd]
'@esbuild/openbsd-x64@0.25.4':
resolution: {integrity: sha512-xAGGhyOQ9Otm1Xu8NT1ifGLnA6M3sJxZ6ixylb+vIUVzvvd6GOALpwQrYrtlPouMqd/vSbgehz6HaVk4+7Afhw==}
engines: {node: '>=18'}
cpu: [x64]
os: [openbsd]
'@esbuild/openbsd-x64@0.28.1':
resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==}
engines: {node: '>=18'}
@@ -1289,24 +1430,48 @@ packages:
cpu: [arm64]
os: [openharmony]
'@esbuild/sunos-x64@0.25.4':
resolution: {integrity: sha512-Mw+tzy4pp6wZEK0+Lwr76pWLjrtjmJyUB23tHKqEDP74R3q95luY/bXqXZeYl4NYlvwOqoRKlInQialgCKy67Q==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/sunos-x64@0.28.1':
resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [sunos]
'@esbuild/win32-arm64@0.25.4':
resolution: {integrity: sha512-AVUP428VQTSddguz9dO9ngb+E5aScyg7nOeJDrF1HPYu555gmza3bDGMPhmVXL8svDSoqPCsCPjb265yG/kLKQ==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-arm64@0.28.1':
resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==}
engines: {node: '>=18'}
cpu: [arm64]
os: [win32]
'@esbuild/win32-ia32@0.25.4':
resolution: {integrity: sha512-i1sW+1i+oWvQzSgfRcxxG2k4I9n3O9NRqy8U+uugaT2Dy7kLO9Y7wI72haOahxceMX8hZAzgGou1FhndRldxRg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-ia32@0.28.1':
resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==}
engines: {node: '>=18'}
cpu: [ia32]
os: [win32]
'@esbuild/win32-x64@0.25.4':
resolution: {integrity: sha512-nOT2vZNw6hJ+z43oP1SPea/G/6AbN6X+bGNhNuq8NtRHy4wsMhw765IKLNmnjek7GvjWBYQ8Q5VBoYTFg9y1UQ==}
engines: {node: '>=18'}
cpu: [x64]
os: [win32]
'@esbuild/win32-x64@0.28.1':
resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==}
engines: {node: '>=18'}
@@ -1730,56 +1895,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-arm64-musl@0.52.0':
resolution: {integrity: sha512-wZg6bLjDvh2KibyI3QFUYo8GTXneIFsd0JvehtvJiUmQ8WRPERgxd/VM4ctWb86U5FT1FkqgS8/wZKVB+AZScg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-ppc64-gnu@0.52.0':
resolution: {integrity: sha512-IngE8uxhNvxcMrLjZNDo9xNLY7rEK33AKnaMd2B46he1e/mz2CfcW6If/U1wUjdRZddm1QzQaciqZkuMkdh1FA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-gnu@0.52.0':
resolution: {integrity: sha512-H3+DdFMv/efN3Efmhsv18jDrpiWWqKG7wsfAlQBqAt6z/E2Bx+TwEj2Nowe51CPOWB8/mFBC2dAMSgVFLvvowA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-riscv64-musl@0.52.0':
resolution: {integrity: sha512-zji+1kb7lJKohSDjzC1IsS+K/cKRs1hdVf0ZH0VbdbiakmtLvN9twBoXo/k8VdjFax7kfo+DyPxS7vv52br1aw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxfmt/binding-linux-s390x-gnu@0.52.0':
resolution: {integrity: sha512-hcLBYedpCy7ToUvvBidWk7+11Yhg1oAZ4+6hKPic/mQI6NaqXJSXMps5nFlwUuX2ewhtLZZDPg63TI042qGKBg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-gnu@0.52.0':
resolution: {integrity: sha512-IDO2loXK2OtTOhSPchU9MW25mWL2QCDGdJbjN8MXKZVS80qXe5gMTwQWu/gMJ3juoBHbkuUZNB2N1LHzNT7DoA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxfmt/binding-linux-x64-musl@0.52.0':
resolution: {integrity: sha512-mAV2Hjn0SatJ+KoAzKUC3eJhdJ8wv+3m1KyuS0dTsbF0c5weq+QrCt/DRZZM+uj/XiKzCDEUKYsBF30e2qkcyw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxfmt/binding-openharmony-arm64@0.52.0':
resolution: {integrity: sha512-vd4npaUIwChxp7XzkqmepBWTT9YMcSe/NBApVGPC30/lLyOVaV3dvma1SKo03t8O73BPRAG7EyJzGlN5cJM5hQ==}
@@ -1852,56 +2009,48 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-arm64-musl@1.71.0':
resolution: {integrity: sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-ppc64-gnu@1.71.0':
resolution: {integrity: sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-gnu@1.71.0':
resolution: {integrity: sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-riscv64-musl@1.71.0':
resolution: {integrity: sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [riscv64]
os: [linux]
libc: [musl]
'@oxlint/binding-linux-s390x-gnu@1.71.0':
resolution: {integrity: sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-gnu@1.71.0':
resolution: {integrity: sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@oxlint/binding-linux-x64-musl@1.71.0':
resolution: {integrity: sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@oxlint/binding-openharmony-arm64@1.71.0':
resolution: {integrity: sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==}
@@ -2363,42 +2512,36 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-arm64-musl@1.1.3':
resolution: {integrity: sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [arm64]
os: [linux]
libc: [musl]
'@rolldown/binding-linux-ppc64-gnu@1.1.3':
resolution: {integrity: sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [ppc64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-s390x-gnu@1.1.3':
resolution: {integrity: sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [s390x]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-gnu@1.1.3':
resolution: {integrity: sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [glibc]
'@rolldown/binding-linux-x64-musl@1.1.3':
resolution: {integrity: sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==}
engines: {node: ^20.19.0 || >=22.12.0}
cpu: [x64]
os: [linux]
libc: [musl]
'@rolldown/binding-openharmony-arm64@1.1.3':
resolution: {integrity: sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==}
@@ -2653,6 +2796,19 @@ packages:
resolution: {integrity: sha512-A9gOqLdi6cV4ibazAjcQufGj0B1y/vDqYrcuP6d/6x8P27gRS8643Dj9o1dEKtB6O7fwxb2FgBmJS2mX7gpvdw==}
engines: {node: '>=14.6'}
'@xterm/addon-unicode11@0.10.0-beta.285':
resolution: {integrity: sha512-rfijFu7UcYpaFx5wzxvTpQbIyyq/amf2PuS9pktywcFQr4ITxRgid5EVzKLRG1vchkApNcQplWeYxGEtjiw0Cw==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.285
'@xterm/addon-webgl@0.20.0-beta.284':
resolution: {integrity: sha512-tzkUiEfdpHCY8mXCbuIaP9V67QDfBJvDr9jdxs5jjxNCIQvw+NCoKD97y5sUrQhrIlr7xrDGniPgPYThQ/1FWg==}
peerDependencies:
'@xterm/xterm': ^6.1.0-beta.285
'@xterm/xterm@6.1.0-beta.285':
resolution: {integrity: sha512-S3K58tepMkbpWRBzOGKd0In6AVvt9QPAnNs8DJ8rPUPODYtsCYWAtINHKYtC2OpXcE5EBKM35dl+Dgv03OoE/w==}
abab@2.0.6:
resolution: {integrity: sha512-j2afSsaIENvHZN2B8GOpF566vZ5WVk5opAiMTvWgaQT8DkbOqsTfvNAvHoRGU2zzP8cPoqys+xHTRDWW8L+/BA==}
deprecated: Use your platform's native atob() and btoa() methods instead
@@ -2681,8 +2837,8 @@ packages:
resolution: {integrity: sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==}
engines: {node: '>=0.4.0'}
acorn@8.16.0:
resolution: {integrity: sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==}
acorn@8.15.0:
resolution: {integrity: sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==}
engines: {node: '>=0.4.0'}
hasBin: true
@@ -3378,6 +3534,11 @@ packages:
resolution: {integrity: sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==}
engines: {node: '>= 0.4'}
esbuild@0.25.4:
resolution: {integrity: sha512-8pgjLUcUjcgDg+2Q4NYXnPbo/vncAY4UmyaCm0jZevERqCHZIaWwdJHkf8XQtu4AxSKCdvrUbT0XUr1IdZzI8Q==}
engines: {node: '>=18'}
hasBin: true
esbuild@0.28.1:
resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==}
engines: {node: '>=18'}
@@ -4660,28 +4821,24 @@ packages:
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [glibc]
lightningcss-linux-arm64-musl@1.32.0:
resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==}
engines: {node: '>= 12.0.0'}
cpu: [arm64]
os: [linux]
libc: [musl]
lightningcss-linux-x64-gnu@1.32.0:
resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [glibc]
lightningcss-linux-x64-musl@1.32.0:
resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==}
engines: {node: '>= 12.0.0'}
cpu: [x64]
os: [linux]
libc: [musl]
lightningcss-win32-arm64-msvc@1.32.0:
resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==}
@@ -7675,81 +7832,156 @@ snapshots:
tslib: 2.8.1
optional: true
'@esbuild/aix-ppc64@0.25.4':
optional: true
'@esbuild/aix-ppc64@0.28.1':
optional: true
'@esbuild/android-arm64@0.25.4':
optional: true
'@esbuild/android-arm64@0.28.1':
optional: true
'@esbuild/android-arm@0.25.4':
optional: true
'@esbuild/android-arm@0.28.1':
optional: true
'@esbuild/android-x64@0.25.4':
optional: true
'@esbuild/android-x64@0.28.1':
optional: true
'@esbuild/darwin-arm64@0.25.4':
optional: true
'@esbuild/darwin-arm64@0.28.1':
optional: true
'@esbuild/darwin-x64@0.25.4':
optional: true
'@esbuild/darwin-x64@0.28.1':
optional: true
'@esbuild/freebsd-arm64@0.25.4':
optional: true
'@esbuild/freebsd-arm64@0.28.1':
optional: true
'@esbuild/freebsd-x64@0.25.4':
optional: true
'@esbuild/freebsd-x64@0.28.1':
optional: true
'@esbuild/linux-arm64@0.25.4':
optional: true
'@esbuild/linux-arm64@0.28.1':
optional: true
'@esbuild/linux-arm@0.25.4':
optional: true
'@esbuild/linux-arm@0.28.1':
optional: true
'@esbuild/linux-ia32@0.25.4':
optional: true
'@esbuild/linux-ia32@0.28.1':
optional: true
'@esbuild/linux-loong64@0.25.4':
optional: true
'@esbuild/linux-loong64@0.28.1':
optional: true
'@esbuild/linux-mips64el@0.25.4':
optional: true
'@esbuild/linux-mips64el@0.28.1':
optional: true
'@esbuild/linux-ppc64@0.25.4':
optional: true
'@esbuild/linux-ppc64@0.28.1':
optional: true
'@esbuild/linux-riscv64@0.25.4':
optional: true
'@esbuild/linux-riscv64@0.28.1':
optional: true
'@esbuild/linux-s390x@0.25.4':
optional: true
'@esbuild/linux-s390x@0.28.1':
optional: true
'@esbuild/linux-x64@0.25.4':
optional: true
'@esbuild/linux-x64@0.28.1':
optional: true
'@esbuild/netbsd-arm64@0.25.4':
optional: true
'@esbuild/netbsd-arm64@0.28.1':
optional: true
'@esbuild/netbsd-x64@0.25.4':
optional: true
'@esbuild/netbsd-x64@0.28.1':
optional: true
'@esbuild/openbsd-arm64@0.25.4':
optional: true
'@esbuild/openbsd-arm64@0.28.1':
optional: true
'@esbuild/openbsd-x64@0.25.4':
optional: true
'@esbuild/openbsd-x64@0.28.1':
optional: true
'@esbuild/openharmony-arm64@0.28.1':
optional: true
'@esbuild/sunos-x64@0.25.4':
optional: true
'@esbuild/sunos-x64@0.28.1':
optional: true
'@esbuild/win32-arm64@0.25.4':
optional: true
'@esbuild/win32-arm64@0.28.1':
optional: true
'@esbuild/win32-ia32@0.25.4':
optional: true
'@esbuild/win32-ia32@0.28.1':
optional: true
'@esbuild/win32-x64@0.25.4':
optional: true
'@esbuild/win32-x64@0.28.1':
optional: true
@@ -9327,13 +9559,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.0
'@vitest/mocker@4.1.9(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))':
'@vitest/mocker@4.1.9(vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))':
dependencies:
'@vitest/spy': 4.1.9
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
vite: 8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
vite: 8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
'@vitest/pretty-format@4.1.9':
dependencies:
@@ -9363,6 +9595,16 @@ snapshots:
'@xmldom/xmldom@0.9.10': {}
'@xterm/addon-unicode11@0.10.0-beta.285(@xterm/xterm@6.1.0-beta.285)':
dependencies:
'@xterm/xterm': 6.1.0-beta.285
'@xterm/addon-webgl@0.20.0-beta.284(@xterm/xterm@6.1.0-beta.285)':
dependencies:
'@xterm/xterm': 6.1.0-beta.285
'@xterm/xterm@6.1.0-beta.285': {}
abab@2.0.6: {}
abort-controller@3.0.0:
@@ -9381,7 +9623,7 @@ snapshots:
acorn-globals@7.0.1:
dependencies:
acorn: 8.16.0
acorn: 8.15.0
acorn-walk: 8.3.5
acorn-jsx@5.3.2(acorn@8.17.0):
@@ -9390,9 +9632,9 @@ snapshots:
acorn-walk@8.3.5:
dependencies:
acorn: 8.16.0
acorn: 8.15.0
acorn@8.16.0: {}
acorn@8.15.0: {}
acorn@8.17.0: {}
@@ -10235,6 +10477,34 @@ snapshots:
is-date-object: 1.1.0
is-symbol: 1.1.1
esbuild@0.25.4:
optionalDependencies:
'@esbuild/aix-ppc64': 0.25.4
'@esbuild/android-arm': 0.25.4
'@esbuild/android-arm64': 0.25.4
'@esbuild/android-x64': 0.25.4
'@esbuild/darwin-arm64': 0.25.4
'@esbuild/darwin-x64': 0.25.4
'@esbuild/freebsd-arm64': 0.25.4
'@esbuild/freebsd-x64': 0.25.4
'@esbuild/linux-arm': 0.25.4
'@esbuild/linux-arm64': 0.25.4
'@esbuild/linux-ia32': 0.25.4
'@esbuild/linux-loong64': 0.25.4
'@esbuild/linux-mips64el': 0.25.4
'@esbuild/linux-ppc64': 0.25.4
'@esbuild/linux-riscv64': 0.25.4
'@esbuild/linux-s390x': 0.25.4
'@esbuild/linux-x64': 0.25.4
'@esbuild/netbsd-arm64': 0.25.4
'@esbuild/netbsd-x64': 0.25.4
'@esbuild/openbsd-arm64': 0.25.4
'@esbuild/openbsd-x64': 0.25.4
'@esbuild/sunos-x64': 0.25.4
'@esbuild/win32-arm64': 0.25.4
'@esbuild/win32-ia32': 0.25.4
'@esbuild/win32-x64': 0.25.4
esbuild@0.28.1:
optionalDependencies:
'@esbuild/aix-ppc64': 0.28.1
@@ -10688,7 +10958,7 @@ snapshots:
expo: 55.0.23(@babel/core@7.29.7)(@expo/dom-webview@55.0.5)(@expo/metro-runtime@55.0.10)(expo-router@55.0.14)(react-dom@19.2.6(react@19.2.6))(react-native-webview@13.16.1(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native-worklets@0.8.3(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6))(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
expo-json-utils: 55.0.2
expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6):
expo-module-scripts@55.0.2(@babel/core@7.29.7)(@babel/runtime@7.29.2)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(eslint@9.39.4)(expo@55.0.23)(jest@29.7.0(@types/node@25.9.3))(prettier@2.8.8)(react-native@0.83.9(@babel/core@7.29.7)(@react-native/metro-config@0.85.2(@babel/core@7.29.7))(@types/react@19.2.14)(react@19.2.6))(react-refresh@0.14.2)(react-test-renderer@19.2.6(react@19.2.6))(react@19.2.6):
dependencies:
'@babel/cli': 7.28.6(@babel/core@7.29.7)
'@babel/plugin-transform-export-namespace-from': 7.27.1(@babel/core@7.29.7)
@@ -10708,7 +10978,7 @@ snapshots:
jest-snapshot-prettier: prettier@2.8.8
jest-watch-typeahead: 2.2.1(jest@29.7.0(@types/node@25.9.3))
resolve-workspace-root: 2.0.1
ts-jest: 29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(jest@29.7.0(@types/node@25.9.3))(typescript@5.9.3)
ts-jest: 29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@25.9.3))(typescript@5.9.3)
typescript: 5.9.3
transitivePeerDependencies:
- '@babel/core'
@@ -11832,7 +12102,7 @@ snapshots:
jsdom@20.0.3:
dependencies:
abab: 2.0.6
acorn: 8.16.0
acorn: 8.15.0
acorn-globals: 7.0.1
cssom: 0.5.0
cssstyle: 2.3.0
@@ -13463,7 +13733,7 @@ snapshots:
terser@5.46.2:
dependencies:
'@jridgewell/source-map': 0.3.11
acorn: 8.16.0
acorn: 8.15.0
commander: 2.20.3
source-map-support: 0.5.21
@@ -13527,7 +13797,7 @@ snapshots:
picomatch: 4.0.4
typescript: 5.9.3
ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.28.1)(jest@29.7.0(@types/node@25.9.3))(typescript@5.9.3):
ts-jest@29.0.5(@babel/core@7.29.7)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.29.7))(esbuild@0.25.4)(jest@29.7.0(@types/node@25.9.3))(typescript@5.9.3):
dependencies:
bs-logger: 0.2.6
fast-json-stable-stringify: 2.1.0
@@ -13543,7 +13813,7 @@ snapshots:
'@babel/core': 7.29.7
'@jest/types': 29.6.3
babel-jest: 29.7.0(@babel/core@7.29.7)
esbuild: 0.28.1
esbuild: 0.25.4
tsconfig-paths@3.15.0:
dependencies:
@@ -13700,7 +13970,7 @@ snapshots:
- '@types/react'
- '@types/react-dom'
vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0):
vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0):
dependencies:
lightningcss: 1.32.0
picomatch: 4.0.4
@@ -13709,16 +13979,16 @@ snapshots:
tinyglobby: 0.2.17
optionalDependencies:
'@types/node': 25.9.3
esbuild: 0.28.1
esbuild: 0.25.4
fsevents: 2.3.3
terser: 5.48.0
tsx: 4.22.4
yaml: 2.9.0
vitest@4.1.9(@types/node@25.9.3)(jsdom@20.0.3)(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)):
vitest@4.1.9(@types/node@25.9.3)(jsdom@20.0.3)(vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)):
dependencies:
'@vitest/expect': 4.1.9
'@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))
'@vitest/mocker': 4.1.9(vite@8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0))
'@vitest/pretty-format': 4.1.9
'@vitest/runner': 4.1.9
'@vitest/snapshot': 4.1.9
@@ -13735,7 +14005,7 @@ snapshots:
tinyexec: 1.1.2
tinyglobby: 0.2.17
tinyrainbow: 3.1.0
vite: 8.1.0(@types/node@25.9.3)(esbuild@0.28.1)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
vite: 8.1.0(@types/node@25.9.3)(esbuild@0.25.4)(terser@5.48.0)(tsx@4.22.4)(yaml@2.9.0)
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 25.9.3
@@ -0,0 +1,108 @@
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()
+74 -96
View File
@@ -1,76 +1,30 @@
import { useRef, useCallback, forwardRef, useImperativeHandle, useEffect, useMemo } from 'react'
import { StyleSheet, type StyleProp, type ViewStyle } from 'react-native'
import { View, type StyleProp, type ViewStyle } from 'react-native'
import { WebView } from 'react-native-webview'
import type { WebViewMessageEvent } from 'react-native-webview'
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
import { colors } from '../theme/mobile-theme'
import type { TerminalOscLinkRange } from './terminal-osc-link-ranges'
import { XTERM_HTML } from './terminal-webview-html'
import type {
MobileTerminalTheme,
TerminalSelectionEvents,
TerminalWebViewHandle
} from './terminal-webview-contract'
import {
TerminalWebViewEngineErrorOverlay,
useTerminalWebViewEngineErrorState
} from './terminal-webview-engine-error-state'
import { TERMINAL_WEBVIEW_FRAME_STYLES } from './terminal-webview-frame-styles'
import { useTerminalWebReadyWatchdog } from './terminal-webview-ready-watchdog'
import { XTERM_WEBVIEW_SOURCE } from './terminal-webview-html'
import type { TerminalWebViewCommand } from './terminal-webview-messages'
import { createTerminalWebViewPendingMessages } from './terminal-webview-pending-messages'
type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any'
type TerminalOscLinks = TerminalOscLinkRange[]
export type TerminalModes = {
bracketedPasteMode: boolean
altScreen: boolean
mouseTrackingMode: TerminalMouseTrackingMode
sgrMouseMode: boolean
sgrMousePixelsMode: boolean
}
export type TerminalKeyboardAvoidanceMetrics = {
cursorY: number
rows: number
altScreen: boolean
}
export type MobileTerminalTheme = RuntimeMobileTerminalTheme
export type TerminalSelectionEvents = {
onSelectionMode?: (active: boolean) => void
onSelectionCopy?: (text: string) => void
onSelectionEvicted?: () => void
onModesChanged?: (modes: TerminalModes) => void
onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void
onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
onTerminalInput?: (bytes: string) => void
onTerminalTap?: () => void
// Tap landed on a detected file path; RN resolves + opens it.
onFileTap?: (pathText: string, line: number | null, column: number | null) => void
// WebView-detected URL tap; RN chooses the mobile routing destination.
onOpenUrl?: (url: string) => void
// Why: pinch-to-zoom in the terminal snaps to a text-size preset and reports it
// here so the app persists it and keeps Settings + other panes in sync.
onTextScaleChange?: (scale: number) => void
}
export type TerminalWebViewHandle = {
write: (data: string) => void
init: (
cols: number,
rows: number,
initialData?: string,
preserveScroll?: boolean,
oscLinks?: TerminalOscLinks
) => void
resize: (cols: number, rows: number) => void
// Why: reflow the local xterm buffer (scrollback included) to a new width
// after a server-side PTY reflow, so older wrapped lines rewrap to match the
// latest output. No-op on the alternate screen.
reflow: (cols: number, rows: number) => void
clear: () => void
measureFitDimensions: (containerHeight?: number) => Promise<{ cols: number; rows: number } | null>
resetZoom: () => void
cancelSelect: () => void
doSelectAll: () => void
// Why: lets callers await the WebView-side `init` rAF chain (term.open
// → renderService population → first paint) so a follow-up measure
// doesn't race ahead and find term=null or cellWidth=0. Resolves on
// the next 'ready' notify after the most recent init.
awaitReady: () => Promise<void>
}
export type {
MobileTerminalTheme,
TerminalKeyboardAvoidanceMetrics,
TerminalModes,
TerminalSelectionEvents,
TerminalWebViewHandle
} from './terminal-webview-contract'
type Props = {
style?: StyleProp<ViewStyle>
@@ -79,18 +33,16 @@ type Props = {
// scale; raw xterm fontSize can't drive apparent size because the fit cancels it.
textScale?: number
onWebReady?: () => void
onEngineError?: (message: string) => void
} & TerminalSelectionEvents
// Why: WebView treats source identity as page identity on some platforms; keep
// parent/session re-renders from reloading xterm and forcing fresh snapshots.
const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML }
export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function TerminalWebView(
{
style,
terminalTheme,
textScale = 1,
onWebReady,
onEngineError,
onSelectionMode,
onSelectionCopy,
onSelectionEvicted,
@@ -119,6 +71,12 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
// race ahead of term.open() / renderService population.
const readyPromiseRef = useRef<Promise<void> | null>(null)
const readyResolveRef = useRef<(() => void) | null>(null)
const { clearEngineError, engineError, reportEngineError, reportNativeEngineError } =
useTerminalWebViewEngineErrorState(onEngineError)
const { armWebReadyWatchdog, clearWebReadyWatchdog } = useTerminalWebReadyWatchdog(
isWebReadyRef,
reportEngineError
)
const sendToWebView = useCallback((msg: TerminalWebViewCommand) => {
messageIdRef.current += 1
@@ -151,6 +109,8 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
if (msg.type === 'web-ready') {
isWebReadyRef.current = true
clearWebReadyWatchdog()
clearEngineError()
onWebReady?.()
flushPendingMessages()
} else if (msg.type === 'ready') {
@@ -175,6 +135,9 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
const tag = typeof msg.tag === 'string' ? msg.tag : '[fit]'
// eslint-disable-next-line no-console
console.log(tag, msg.payload)
} else if (msg.type === 'error') {
const message = typeof msg.message === 'string' ? msg.message : 'Unknown terminal error'
reportEngineError(message, msg.fatal !== false)
} else if (msg.type === 'set-select-mode') {
onSelectionMode?.(!!msg.enabled)
} else if (msg.type === 'selection') {
@@ -246,6 +209,9 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
},
[
flushPendingMessages,
clearEngineError,
clearWebReadyWatchdog,
reportEngineError,
onWebReady,
onSelectionMode,
onSelectionCopy,
@@ -263,10 +229,16 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
const handleLoadStart = useCallback(() => {
isWebReadyRef.current = false
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.
pendingMessages.clear()
}, [pendingMessages])
}, [armWebReadyWatchdog, pendingMessages])
const handleReload = useCallback(() => {
clearEngineError()
webViewRef.current?.reload()
}, [clearEngineError])
useEffect(() => {
postMessage({ type: 'set-theme', terminalTheme })
@@ -289,7 +261,7 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
rows: number,
initialData?: string,
preserveScroll?: boolean,
oscLinks?: TerminalOscLinks
oscLinks?: TerminalOscLinkRange[]
) {
// Why: arm a fresh ready promise BEFORE posting init. The WebView
// resolves it via the 'ready' notify at the end of its rAF chain.
@@ -395,29 +367,35 @@ export const TerminalWebView = forwardRef<TerminalWebViewHandle, Props>(function
)
return (
<WebView
ref={webViewRef}
source={XTERM_WEBVIEW_SOURCE}
style={[styles.webview, style]}
originWhitelist={['*']}
javaScriptEnabled
scrollEnabled={false}
// Why: Android parent gesture containers can intercept vertical drags
// before the injected xterm scroll router sees them.
nestedScrollEnabled
scalesPageToFit={false}
// Why: Android WebView defaults textZoom to the system font scale, inflating
// xterm's DOM glyphs past its canvas-measured cell grid (#4579). iOS ignores it.
textZoom={100}
onLoadStart={handleLoadStart}
onMessage={handleMessage}
/>
<View style={[TERMINAL_WEBVIEW_FRAME_STYLES.container, style]}>
<WebView
ref={webViewRef}
source={XTERM_WEBVIEW_SOURCE}
style={TERMINAL_WEBVIEW_FRAME_STYLES.webview}
originWhitelist={['*']}
javaScriptEnabled
scrollEnabled={false}
// Why: Android parent gesture containers can intercept vertical drags
// before the injected xterm scroll router sees them.
nestedScrollEnabled
scalesPageToFit={false}
// Why: Android WebView defaults textZoom to the system font scale, inflating
// xterm's DOM glyphs past its canvas-measured cell grid (#4579). iOS ignores it.
textZoom={100}
onLoadStart={handleLoadStart}
onMessage={handleMessage}
onError={(event) => reportNativeEngineError('Terminal WebView load failed', event)}
onHttpError={(event) => reportNativeEngineError('Terminal WebView HTTP error', event)}
onRenderProcessGone={(event) =>
reportNativeEngineError('Terminal WebView render process ended', event)
}
onContentProcessDidTerminate={(event) =>
reportNativeEngineError('Terminal WebView content process ended', event)
}
/>
{engineError ? (
<TerminalWebViewEngineErrorOverlay message={engineError} onReload={handleReload} />
) : null}
</View>
)
})
const styles = StyleSheet.create({
webview: {
flex: 1,
backgroundColor: colors.terminalBg
}
})
@@ -0,0 +1,64 @@
import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-types'
import type { TerminalOscLinkRange } from './terminal-osc-link-ranges'
type TerminalMouseTrackingMode = 'none' | 'x10' | 'vt200' | 'drag' | 'any'
export type TerminalModes = {
bracketedPasteMode: boolean
altScreen: boolean
mouseTrackingMode: TerminalMouseTrackingMode
sgrMouseMode: boolean
sgrMousePixelsMode: boolean
}
export type TerminalKeyboardAvoidanceMetrics = {
cursorY: number
rows: number
altScreen: boolean
}
export type MobileTerminalTheme = RuntimeMobileTerminalTheme
export type TerminalSelectionEvents = {
onSelectionMode?: (active: boolean) => void
onSelectionCopy?: (text: string) => void
onSelectionEvicted?: () => void
onModesChanged?: (modes: TerminalModes) => void
onKeyboardAvoidanceMetrics?: (metrics: TerminalKeyboardAvoidanceMetrics) => void
onHaptic?: (kind: 'selection' | 'success' | 'error' | 'edge-bump') => void
onTerminalInput?: (bytes: string) => void
onTerminalTap?: () => void
// Tap landed on a detected file path; RN resolves + opens it.
onFileTap?: (pathText: string, line: number | null, column: number | null) => void
// WebView-detected URL tap; RN chooses the mobile routing destination.
onOpenUrl?: (url: string) => void
// Why: pinch-to-zoom in the terminal snaps to a text-size preset and reports it
// here so the app persists it and keeps Settings + other panes in sync.
onTextScaleChange?: (scale: number) => void
}
export type TerminalWebViewHandle = {
write: (data: string) => void
init: (
cols: number,
rows: number,
initialData?: string,
preserveScroll?: boolean,
oscLinks?: TerminalOscLinkRange[]
) => void
resize: (cols: number, rows: number) => void
// Why: reflow the local xterm buffer (scrollback included) to a new width
// after a server-side PTY reflow, so older wrapped lines rewrap to match the
// latest output. No-op on the alternate screen.
reflow: (cols: number, rows: number) => void
clear: () => void
measureFitDimensions: (containerHeight?: number) => Promise<{ cols: number; rows: number } | null>
resetZoom: () => void
cancelSelect: () => void
doSelectAll: () => void
// Why: lets callers await the WebView-side `init` rAF chain (term.open
// → renderService population → first paint) so a follow-up measure
// doesn't race ahead and find term=null or cellWidth=0. Resolves on
// the next 'ready' notify after the most recent init.
awaitReady: () => Promise<void>
}
@@ -0,0 +1,130 @@
import { useCallback, useState } from 'react'
import { RefreshCw } from 'lucide-react-native'
import { Pressable, StyleSheet, Text, View } from 'react-native'
import { colors } from '../theme/mobile-theme'
export type NativeWebViewEngineEvent = {
readonly nativeEvent?: object
}
type TerminalWebViewEngineErrorOverlayProps = {
readonly message: string
readonly onReload: () => void
}
type NativeWebViewEngineFields = {
readonly description?: unknown
readonly code?: unknown
readonly statusCode?: unknown
readonly domain?: unknown
readonly didCrash?: unknown
}
export function useTerminalWebViewEngineErrorState(onEngineError?: (message: string) => void) {
const [engineError, setEngineError] = useState<string | null>(null)
const clearEngineError = useCallback(() => setEngineError(null), [])
const reportEngineError = useCallback(
(message: string, fatal: boolean) => {
onEngineError?.(message)
// eslint-disable-next-line no-console
console.warn('[terminal-webview] engine error', message)
if (fatal) {
// Why: the first fatal report is the root cause; later cascades (e.g. the
// web-ready watchdog firing after a process-crash report) must not
// overwrite its more specific diagnostics. clearEngineError resets.
setEngineError((previous) => previous ?? message)
}
},
[onEngineError]
)
const reportNativeEngineError = useCallback(
(context: string, event?: NativeWebViewEngineEvent) => {
reportEngineError(describeNativeWebViewEngineError(context, event), true)
},
[reportEngineError]
)
return { clearEngineError, engineError, reportEngineError, reportNativeEngineError }
}
export function describeNativeWebViewEngineError(
context: string,
event?: NativeWebViewEngineEvent
): string {
const native = event?.nativeEvent as NativeWebViewEngineFields | undefined
const parts = [context]
const description = native?.description
const statusCode = native?.statusCode
const code = native?.code
const domain = native?.domain
if (typeof description === 'string') {
parts.push(description)
}
if (typeof statusCode === 'number') {
parts.push(`status ${statusCode}`)
}
if (typeof code === 'number') {
parts.push(`code ${code}`)
}
if (typeof domain === 'string') {
parts.push(domain)
}
if (native?.didCrash === true) {
parts.push('renderer crashed')
}
return parts.join(' - ')
}
export function TerminalWebViewEngineErrorOverlay({
message,
onReload
}: TerminalWebViewEngineErrorOverlayProps) {
return (
<View style={styles.errorOverlay}>
<Text style={styles.errorTitle}>Terminal failed to load</Text>
<Text style={styles.errorDetail} numberOfLines={4}>
{message}
</Text>
<Pressable accessibilityRole="button" style={styles.reloadButton} onPress={onReload}>
<RefreshCw size={16} color={colors.terminalBg} />
<Text style={styles.reloadButtonText}>Reload</Text>
</Pressable>
</View>
)
}
const styles = StyleSheet.create({
errorOverlay: {
...StyleSheet.absoluteFillObject,
alignItems: 'center',
justifyContent: 'center',
gap: 12,
padding: 24,
backgroundColor: colors.terminalBg
},
errorTitle: {
color: colors.textPrimary,
fontSize: 16,
fontWeight: '700',
textAlign: 'center'
},
errorDetail: {
color: colors.textSecondary,
fontSize: 13,
lineHeight: 18,
textAlign: 'center'
},
reloadButton: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
minHeight: 36,
paddingHorizontal: 14,
borderRadius: 6,
backgroundColor: colors.surfaceBright
},
reloadButtonText: {
color: colors.terminalBg,
fontSize: 14,
fontWeight: '700'
}
})
@@ -0,0 +1,178 @@
import { createElement } from 'react'
import { act, create, type ReactTestRenderer } from 'react-test-renderer'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { TerminalWebView } from './TerminalWebView'
vi.mock('react-native', () => ({
AppState: { currentState: 'active' },
Pressable: 'Pressable',
StyleSheet: {
absoluteFillObject: {
bottom: 0,
left: 0,
position: 'absolute',
right: 0,
top: 0
},
create: (styles: unknown) => styles
},
Text: 'Text',
View: 'View'
}))
vi.mock('react-native-webview', () => ({
WebView: 'WebView',
default: 'WebView'
}))
vi.mock('lucide-react-native', () => ({
RefreshCw: 'RefreshCw'
}))
function suppressReactTestRendererDeprecationWarning(): () => void {
const originalConsoleError = console.error
const consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const firstArg = args[0]
if (typeof firstArg === 'string' && firstArg.includes('react-test-renderer is deprecated')) {
return
}
originalConsoleError(...args)
})
return () => consoleErrorSpy.mockRestore()
}
// Why: mounting TerminalWebView arms the web-ready watchdog; tests must
// unmount so the timer can't survive into later tests and fire in teardown.
let activeRenderer: ReactTestRenderer | null = null
function createTerminalWebViewRenderer(onEngineError = vi.fn()) {
let renderer: ReactTestRenderer | null = null
const restoreConsoleError = suppressReactTestRendererDeprecationWarning()
try {
act(() => {
renderer = create(createElement(TerminalWebView, { onEngineError }))
})
} finally {
restoreConsoleError()
}
if (!renderer) {
throw new Error('TerminalWebView did not render')
}
activeRenderer = renderer
return { onEngineError, renderer }
}
function postWebViewMessage(renderer: ReactTestRenderer, payload: Record<string, unknown>) {
const webView = renderer.root.findByType('WebView')
act(() => {
webView.props.onMessage({ nativeEvent: { data: JSON.stringify(payload) } })
})
}
function renderedText(renderer: ReactTestRenderer): string {
return renderer.root
.findAllByType('Text')
.flatMap((node) => node.props.children)
.join(' ')
}
describe('TerminalWebView engine errors', () => {
afterEach(() => {
if (activeRenderer) {
act(() => {
activeRenderer?.unmount()
})
activeRenderer = null
}
vi.restoreAllMocks()
})
it('renders the reload overlay for fatal engine errors from the WebView', () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
const { onEngineError, renderer } = createTerminalWebViewRenderer()
postWebViewMessage(renderer, {
fatal: true,
message: 'terminal engine missing - SyntaxError - Chrome 74',
type: 'error'
})
expect(onEngineError).toHaveBeenCalledWith('terminal engine missing - SyntaxError - Chrome 74')
expect(renderedText(renderer)).toContain('Terminal failed to load')
expect(renderedText(renderer)).toContain('terminal engine missing - SyntaxError - Chrome 74')
expect(renderedText(renderer)).toContain('Reload')
})
it('reports non-fatal engine errors without covering a live terminal', () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
const { onEngineError, renderer } = createTerminalWebViewRenderer()
postWebViewMessage(renderer, {
fatal: false,
message: 'terminal message failed - malformed chunk',
type: 'error'
})
expect(onEngineError).toHaveBeenCalledWith('terminal message failed - malformed chunk')
expect(renderedText(renderer)).not.toContain('Terminal failed to load')
})
it('keeps the first fatal diagnostics when later fatal reports cascade', () => {
vi.spyOn(console, 'warn').mockImplementation(() => {})
const { renderer } = createTerminalWebViewRenderer()
postWebViewMessage(renderer, {
fatal: true,
message: 'terminal engine missing - root cause',
type: 'error'
})
postWebViewMessage(renderer, {
fatal: true,
message: 'Terminal did not initialize - watchdog cascade',
type: 'error'
})
expect(renderedText(renderer)).toContain('terminal engine missing - root cause')
expect(renderedText(renderer)).not.toContain('watchdog cascade')
})
// Why: if the document dies before the glue can post anything (or the RN
// bridge never comes up), no message and no native error handler fires —
// the watchdog must convert that silence into the visible fatal overlay.
it('paints the fatal overlay when web-ready never arrives', () => {
vi.useFakeTimers()
vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const { onEngineError, renderer } = createTerminalWebViewRenderer()
act(() => {
vi.advanceTimersByTime(15000)
})
expect(onEngineError).toHaveBeenCalledWith(
'Terminal did not initialize - no ready signal from the terminal view'
)
expect(renderedText(renderer)).toContain('Terminal failed to load')
} finally {
vi.useRealTimers()
}
})
it('does not fire the watchdog once web-ready has arrived', () => {
vi.useFakeTimers()
vi.spyOn(console, 'warn').mockImplementation(() => {})
try {
const { onEngineError, renderer } = createTerminalWebViewRenderer()
postWebViewMessage(renderer, { type: 'web-ready' })
act(() => {
vi.advanceTimersByTime(60000)
})
expect(onEngineError).not.toHaveBeenCalled()
expect(renderedText(renderer)).not.toContain('Terminal failed to load')
} finally {
vi.useRealTimers()
}
})
})
@@ -0,0 +1,109 @@
import { readFileSync } from 'node:fs'
import { Script } from 'node:vm'
import { parse } from 'acorn'
import { describe, expect, it } from 'vitest'
import { XTERM_ENGINE_CSS, XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
import { XTERM_HTML } from './terminal-webview-html'
const terminalHtmlSource = readFileSync(
new URL('./terminal-webview-html.ts', import.meta.url),
'utf8'
)
describe('terminal WebView bundled engine', () => {
it('keeps the assembled terminal HTML free of external engine URLs', () => {
expect(XTERM_HTML).not.toMatch(/\bhttps?:\/\//)
expect(XTERM_HTML).not.toContain('cdn.jsdelivr.net')
expect(XTERM_HTML).not.toContain('<script src=')
expect(XTERM_HTML).not.toContain('rel="stylesheet" href=')
})
it('parses the bundled engine at the Chrome 74 syntax floor', () => {
expect(() => parse(XTERM_ENGINE_JS, { ecmaVersion: 2019 })).not.toThrow()
})
// Why: the context deliberately omits WeakRef (Chrome 84+) / structuredClone
// (Chrome 98+) and supplies an Element without replaceChildren (Chrome 86+) —
// the engine must evaluate on older WebViews via its own guarded runtime shims,
// which are the linchpin of the old-WebView support (esbuild lowers syntax only).
it('exposes the xterm globals and installs the old-WebView runtime shims', () => {
const window: Record<string, unknown> = {}
class ElementStub {}
const context = {
window,
self: window,
document: {},
Element: ElementStub,
navigator: {
platform: 'Linux armv8l',
userAgent: 'Mozilla/5.0 Chrome/74.0.3729.157'
},
console,
setTimeout,
clearTimeout,
queueMicrotask,
URL
}
new Script(XTERM_ENGINE_JS).runInNewContext(context)
expect(window).toMatchObject({
Terminal: expect.any(Function),
Unicode11Addon: { Unicode11Addon: expect.any(Function) },
WebglAddon: { WebglAddon: expect.any(Function) }
})
const weakRef = window.WeakRef as (new (target: unknown) => { deref(): unknown }) | undefined
expect(typeof weakRef).toBe('function')
const token = {}
expect(new weakRef!(token).deref()).toBe(token)
expect(typeof window.structuredClone).toBe('function')
expect(typeof (ElementStub.prototype as { replaceChildren?: unknown }).replaceChildren).toBe(
'function'
)
})
it('keeps the bundled engine from breaking out of its inline script/style tags', () => {
// Why: the engine JS/CSS are inlined into <script>/<style> blocks. </script
// and </style are neutralized at build time; the tokenizer-escape openers that
// could swallow the rest of the document must also be absent from the bundle.
expect(XTERM_ENGINE_JS).not.toMatch(/<\/script/i)
expect(XTERM_ENGINE_JS).not.toMatch(/<script/i)
expect(XTERM_ENGINE_JS).not.toContain('<!--')
expect(XTERM_ENGINE_CSS).not.toMatch(/<\/style/i)
})
it('reports WebView message handler failures instead of swallowing them', () => {
const start = terminalHtmlSource.indexOf('function handleIncomingMessage')
const end = terminalHtmlSource.indexOf("window.addEventListener('resize'", start)
expect(start).toBeGreaterThanOrEqual(0)
expect(end).toBeGreaterThan(start)
const handlerSource = terminalHtmlSource.slice(start, end)
expect(handlerSource).toContain('reportEngineError(')
expect(handlerSource).toContain("'terminal init failed'")
expect(handlerSource).toContain("'terminal message failed'")
expect(handlerSource).not.toContain('catch(ex) {}')
})
it('classifies runtime errors by a document-scoped ever-ready latch', () => {
// Why: init() flips `ready` false on every re-init (live width reflow keeps the
// old surface visible meanwhile), so the fatal default and the init-catch must
// key off `everReady` — otherwise a transient reflow error blanks a live
// terminal behind the fatal overlay. The latch stays set for the document.
expect(terminalHtmlSource).toContain('var everReady = false;')
expect(terminalHtmlSource).toContain('everReady = true;')
expect(terminalHtmlSource).toContain('fatal === undefined ? !everReady : !!fatal')
expect(terminalHtmlSource).toContain("msg.type === 'init' && !everReady")
expect(terminalHtmlSource).not.toMatch(/fatal === undefined \? !ready\b/)
})
it('bounds error capture and non-fatal reporting on a degraded engine', () => {
// Why: a constructed-but-broken engine can throw per render frame; both
// onerror capture sites must cap the buffer and non-fatal notifies must
// stop flooding RN while fatal reports always emit.
const capSites = terminalHtmlSource.match(/__engineErrors\.length < 20/g) ?? []
expect(capSites.length).toBe(2)
expect(terminalHtmlSource).toContain('nonFatalErrorNotifies > 5')
})
})
@@ -0,0 +1,13 @@
import { StyleSheet } from 'react-native'
import { colors } from '../theme/mobile-theme'
export const TERMINAL_WEBVIEW_FRAME_STYLES = StyleSheet.create({
container: {
flex: 1,
backgroundColor: colors.terminalBg
},
webview: {
flex: 1,
backgroundColor: colors.terminalBg
}
})
+81 -12
View File
@@ -4,6 +4,7 @@ import type { RuntimeMobileTerminalTheme } from '../../../src/shared/runtime-typ
import { colors } from '../theme/mobile-theme'
import { TERMINAL_TEXT_SCALES } from '../storage/preferences'
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 { URL_TAP_WEBVIEW_JS } from './terminal-webview-url-tap'
@@ -47,7 +48,15 @@ export const XTERM_HTML = `<!DOCTYPE html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.1.0-beta.285/css/xterm.min.css">
<script>
window.__engineErrors = [];
window.onerror = function(msg) {
// Why: a degraded engine can throw per frame; cap so the capture buffer
// and downstream reporting stay bounded for the document's lifetime.
if (window.__engineErrors.length < 20) window.__engineErrors.push(String(msg));
};
</script>
<style>${XTERM_ENGINE_CSS}</style>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
@@ -192,7 +201,7 @@ export const XTERM_HTML = `<!DOCTYPE html>
<button id="sel-menu-all">Select All</button>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@6.1.0-beta.285/lib/xterm.min.js"></script><script src="https://cdn.jsdelivr.net/npm/@xterm/addon-unicode11@0.10.0-beta.285/lib/addon-unicode11.js"></script><script src="https://cdn.jsdelivr.net/npm/@xterm/addon-webgl@0.20.0-beta.284/lib/addon-webgl.js"></script>
<script>${XTERM_ENGINE_JS}</script>
<script>
(function() {
var surface = document.getElementById('terminal-surface');
@@ -214,6 +223,10 @@ export const XTERM_HTML = `<!DOCTYPE html>
var afterDrainCallbacks = [];
var termObserverDisposables = [];
var ready = false;
// Why: init() flips ready false on every re-init (live width reflow included)
// while the old surface stays visible; a document-scoped latch drives the
// fatal/non-fatal decision so a transient reflow cannot blank a live terminal.
var everReady = false;
var currentScale = 1;
// Why: userScale is transient pinch zoom (CSS) for smooth feedback DURING a
// gesture only; it resets to 1 on release. The persistent "text size" is the
@@ -745,6 +758,7 @@ export const XTERM_HTML = `<!DOCTYPE html>
requestAnimationFrame(function() {
if (gen !== terminalGeneration) return;
ready = true;
everReady = true;
afterWritesDrained(function() {
if (gen !== terminalGeneration) return;
if (nextSurface && oldSurface) {
@@ -804,6 +818,47 @@ export const XTERM_HTML = `<!DOCTYPE html>
}
}
function engineErrorText(err) {
if (!err) return '';
if (typeof err === 'string') return err;
if (err && typeof err.message === 'string') return err.message;
try { return String(err); } catch (e) { return ''; }
}
function chromeVersionText() {
var match = String(navigator.userAgent || '').match(/(?:Chrome|Chromium)\\/([0-9.]+)/);
return match ? 'Chrome ' + match[1] : 'Chrome version unknown';
}
var nonFatalErrorNotifies = 0;
function reportEngineError(context, err, fatal) {
var isFatal = fatal === undefined ? !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;
}
var parts = [context];
var 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, source, line, column, err) {
if (window.__engineErrors.length < 20) window.__engineErrors.push(String(msg));
reportEngineError('terminal runtime error', err || msg);
};
function measureFitDimensions(containerHeightPx, retriesLeft) {
if (typeof retriesLeft !== 'number') retriesLeft = 30;
// Why: init and measure are posted back-to-back from React, but
@@ -1801,17 +1856,27 @@ export const XTERM_HTML = `<!DOCTYPE html>
attachSurfaceEventHandlers(surface);
window.addEventListener('message', function(e) {
function handleIncomingMessage(e) {
var msg;
try {
handleMsg(typeof e.data === 'string' ? JSON.parse(e.data) : e.data);
} catch(ex) {}
});
msg = typeof e.data === 'string' ? JSON.parse(e.data) : e.data;
} catch (ex) {
return;
}
try {
handleMsg(msg);
} catch(ex) {
reportEngineError(
msg && msg.type === 'init' ? 'terminal init failed' : 'terminal message failed',
ex,
msg && msg.type === 'init' && !everReady
);
}
}
document.addEventListener('message', function(e) {
try {
handleMsg(typeof e.data === 'string' ? JSON.parse(e.data) : e.data);
} catch(ex) {}
});
window.addEventListener('message', handleIncomingMessage);
document.addEventListener('message', handleIncomingMessage);
window.addEventListener('resize', function() {
// Why: viewport changed (keyboard open/close, orientation, RN container
@@ -1828,9 +1893,13 @@ export const XTERM_HTML = `<!DOCTYPE html>
if (window.Terminal) {
notify({ type: 'web-ready' });
} else {
notify({ type: 'error', message: 'xterm failed to load' });
reportEngineError('terminal engine missing', 'xterm failed to load', true);
}
})();
</script>
</body>
</html>`
// Why: WebView treats source identity as page identity on some platforms; keep
// parent/session re-renders from reloading xterm and forcing fresh snapshots.
export const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML }
@@ -0,0 +1,48 @@
import { useCallback, useEffect, useRef, type RefObject } from 'react'
import { AppState } from 'react-native'
// Why: if the document dies before the glue can post anything (or the RN
// message bridge never comes up), no webview error and no native handler
// fires — without a native watchdog that failure is a silent blank pane.
const WEB_READY_WATCHDOG_MS = 15000
export function useTerminalWebReadyWatchdog(
isWebReadyRef: RefObject<boolean>,
reportEngineError: (message: string, fatal: boolean) => void
) {
const watchdogRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const clearWebReadyWatchdog = useCallback(() => {
if (watchdogRef.current) {
clearTimeout(watchdogRef.current)
watchdogRef.current = null
}
}, [])
const armWebReadyWatchdog = useCallback(() => {
clearWebReadyWatchdog()
const fire = () => {
watchdogRef.current = null
if (isWebReadyRef.current) {
return
}
if (AppState.currentState !== 'active') {
// Why: backgrounded WebViews legitimately stall; only judge foreground loads.
watchdogRef.current = setTimeout(fire, WEB_READY_WATCHDOG_MS)
return
}
reportEngineError(
'Terminal did not initialize - no ready signal from the terminal view',
true
)
}
watchdogRef.current = setTimeout(fire, WEB_READY_WATCHDOG_MS)
}, [clearWebReadyWatchdog, isWebReadyRef, reportEngineError])
useEffect(() => {
armWebReadyWatchdog()
return clearWebReadyWatchdog
}, [armWebReadyWatchdog, clearWebReadyWatchdog])
return { armWebReadyWatchdog, clearWebReadyWatchdog }
}
@@ -14,7 +14,7 @@ function iifeSource(): string {
function bodyMarkup(): string {
const start = XTERM_HTML.indexOf('<body>') + '<body>'.length
const end = XTERM_HTML.indexOf('<script src=')
const end = XTERM_HTML.indexOf('<script>', start)
return XTERM_HTML.slice(start, end)
}
@@ -48,7 +48,7 @@ describe('TerminalWebView text zoom', () => {
const end = terminalWebViewSource.indexOf('/>', start)
expect(end).toBeGreaterThan(start)
const webViewProps = terminalWebViewSource.slice(start, end)
expect(terminalWebViewSource).toContain('const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML }')
expect(terminalHtmlSource).toContain('export const XTERM_WEBVIEW_SOURCE = { html: XTERM_HTML }')
expect(webViewProps).toContain('source={XTERM_WEBVIEW_SOURCE}')
expect(webViewProps).not.toContain('source={{ html: XTERM_HTML }}')
})
@@ -113,8 +113,8 @@ describe('TerminalWebView text zoom', () => {
})
it('loads Unicode 11 before replaying mobile terminal bytes', () => {
expect(terminalHtmlSource).toContain('@xterm/xterm@6.1.0-beta.285')
expect(terminalHtmlSource).toContain('@xterm/addon-unicode11@0.10.0-beta.285')
expect(terminalHtmlSource).toContain('XTERM_ENGINE_JS')
expect(terminalHtmlSource).toContain('window.Unicode11Addon.Unicode11Addon')
const open = terminalHtmlSource.indexOf('term.open(surface)')
const unicode = terminalHtmlSource.indexOf("term.unicode.activeVersion = '11'")
const replay = terminalHtmlSource.indexOf('enqueueWrite(replayData)')
@@ -123,8 +123,9 @@ describe('TerminalWebView text zoom', () => {
expect(replay).toBeGreaterThan(unicode)
})
it('uses the newer WebGL-capable xterm stack and desktop font fallbacks', () => {
expect(terminalHtmlSource).toContain('@xterm/addon-webgl@0.20.0-beta.284')
it('uses the bundled WebGL-capable xterm stack and desktop font fallbacks', () => {
expect(terminalHtmlSource).not.toContain('cdn.jsdelivr.net')
expect(terminalHtmlSource).toContain('window.WebglAddon.WebglAddon')
expect(terminalHtmlSource).toContain('"SF Mono", "Menlo", "Monaco", "Cascadia Mono"')
expect(terminalHtmlSource).toContain("fontWeight: '300'")
expect(terminalHtmlSource).toContain("fontWeightBold: '500'")