fix(mobile): render offline Nerd Font symbols on first terminal display (#31446)

Fixes #17168

Supersedes community PR #17220. Original contributor credit is preserved below.

Co-authored-by: Arthur Chang <arthurc0102@gmail.com>
This commit is contained in:
Neil
2026-10-21 01:17:08 -07:00
committed by GitHub
co-authored by Arthur Chang
parent efcf8f0888
commit 0cba9f2e08
26 changed files with 687 additions and 39 deletions
@@ -327,7 +327,7 @@ export function mobileWebAppBuildOptions(routes) {
// Images are emitted as same-origin assets, not data: URLs, so their content-hashed names keep
// the buildId reproducible and the bytes out of every chunk that imports one. The policy now
// admits data: for images, but that is for a preview the page composes at runtime, not for a
// bundled asset. A font would fail the build here rather than silently ship under font-src 'none'.
// bundled asset. The terminal font also uses its own hashed asset to avoid base64 overhead.
loader: {
...ROUTE_SOURCE_LOADERS,
'.png': 'file',
@@ -335,7 +335,8 @@ export function mobileWebAppBuildOptions(routes) {
'.jpeg': 'file',
'.gif': 'file',
'.webp': 'file',
'.svg': 'file'
'.svg': 'file',
'.woff2': 'file'
},
assetNames: '[hash]',
// Absolute, because the document is served at every route depth and a path relative to the
@@ -30,6 +30,7 @@ import {
MOBILE_WEB_APP_SOURCE_DIRS,
assertAssetCeilingFitsShell,
mobileWebAppBundleMaxAssets,
mobileWebAppBundleBudgetBytes,
mobileWebAppBundleMaxChunks,
readMobileWebBundleMaxAssets,
verifyMobileWebAppBundle
@@ -380,6 +381,41 @@ describeBundling('the app bundle', () => {
})
describe('the Phase C budget', () => {
it('reserves the font allowance only for the exact licensed symbol asset', async () => {
const font = hashedAsset(
await readFile(join(projectDir, 'mobile/assets/fonts/SymbolsNerdFontMono-Regular.woff2')),
'woff2'
)
const manifest = { totalBytes: font.byteLength + 123, assets: [font] }
expect(mobileWebAppBundleBudgetBytes(manifest)).toBe(123)
expect(
mobileWebAppBundleBudgetBytes({
...manifest,
totalBytes: font.byteLength + MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES + 1
})
).toBeGreaterThan(MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES)
expect(mobileWebAppBundleBudgetBytes({ totalBytes: 123, assets: [] })).toBe(123)
for (const change of [
{ path: 'assets/another.woff2' },
{ sha256: 'a'.repeat(64) },
{ contentType: 'text/javascript' },
{ byteLength: font.byteLength + 1 }
]) {
expect(() =>
mobileWebAppBundleBudgetBytes({ ...manifest, assets: [{ ...font, ...change }] })
).toThrow(/approved hash, path, MIME type and bounded byte length/)
}
expect(() => mobileWebAppBundleBudgetBytes({ ...manifest, assets: [font, font] })).toThrow(
/only one licensed terminal font/
)
expect(
mobileWebAppBundleBudgetBytes({
totalBytes: font.byteLength,
assets: [{ ...font, path: 'assets/code.js', contentType: 'text/javascript' }]
})
).toBe(font.byteLength)
})
it('sits below the contract per-asset ceiling, so growth trips a build not a phone', () => {
expect(MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES).toBeLessThan(MOBILE_WEB_BUNDLE_MAX_ASSET_BYTES)
})
@@ -389,7 +425,9 @@ describe('the Phase C budget', () => {
async () => {
const { manifest, chunkCount, entryStaticBytes, imageCount, routeKeys } =
await readWrittenBundle()
expect(manifest.totalBytes).toBeLessThanOrEqual(MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES)
expect(mobileWebAppBundleBudgetBytes(manifest)).toBeLessThanOrEqual(
MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES
)
expect(manifest.assets.length).toBeLessThanOrEqual(
mobileWebAppBundleMaxAssets(routeKeys.length, imageCount)
)
@@ -1,6 +1,7 @@
import { createServer } from 'node:http'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { extname, join } from 'node:path'
import { contentTypeForExtension } from './mobile-web-bundle-manifest.mjs'
import { fileURLToPath } from 'node:url'
export const projectDir = fileURLToPath(new URL('../..', import.meta.url))
@@ -568,7 +569,7 @@ export async function createBundleServer({
(real) => {
const bytes = transformChunk ? transformChunk(path, real) : real
const headers = {
'content-type': file.endsWith('.js') ? 'text/javascript' : 'text/html'
'content-type': contentTypeForExtension(extname(file).slice(1))
}
// The document carries the shell's real policy, so a directive the page violates fails
// here rather than on a phone. Assets carry none, exactly as the native handler does.
@@ -328,9 +328,10 @@ describe('the shell policy this page is tested under', () => {
expect(cspHeader).not.toContain("script-src 'self' 'unsafe-inline'")
})
it('admits data: and https: for images and for nothing else', () => {
it('admits embedded fonts and limits external sources to images', () => {
expect(cspHeader.split('; ').filter((entry) => entry.includes('data:'))).toEqual([
"img-src 'self' data: blob: https:"
"img-src 'self' data: blob: https:",
"font-src 'self' data:"
])
expect(cspHeader.split('; ').filter((entry) => entry.includes('https:'))).toEqual([
"img-src 'self' data: blob: https:"
@@ -0,0 +1,110 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
import {
openProbeTerminal,
startTerminalRenderFixture
} from './mobile-web-app-terminal-render-fixture.mjs'
const describeRender = mobileWebAppDependenciesPresent() ? describe : describe.skip
let fixture = null
beforeAll(async () => {
if (mobileWebAppDependenciesPresent()) {
fixture = await startTerminalRenderFixture()
}
}, 600_000)
afterAll(async () => {
await fixture?.close()
})
const openTerminal = (options) => fixture.openTerminal(options)
describeRender('terminal font startup and late painting', () => {
it('opens on a stalled font and replaces cached fallback glyphs when it finally arrives', async () => {
let releaseFont = () => {}
const fontGate = new Promise((resolve) => {
releaseFont = resolve
})
const { page } = await openTerminal({
beforeNavigate: async (page) => {
await page.route('**/*.woff2', async (route) => {
await fontGate
await route.continue()
})
}
})
try {
expect(
await page.evaluate(() => document.fonts.check('13px "Orca Nerd Font Symbols"', '\ue0b0'))
).toBe(false)
await openProbeTerminal(page)
await page.evaluate(() =>
globalThis.__orcaTerminalProbe.write('\x1b[?25l\x1b[2J\x1b[H\uf07b')
)
const screen = page.locator('.xterm-screen')
expect(await screen.locator('canvas').count()).toBeGreaterThan(0)
await page.evaluate(
() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
)
const box = await screen.boundingBox()
expect(box).toBeTruthy()
const clip = { x: box.x, y: box.y, width: 20, height: 20 }
// Playwright screenshots wait for fonts, which would hide the stalled-font frame.
const cdp = await page.context().newCDPSession(page)
const capture = async () => {
const { data } = await cdp.send('Page.captureScreenshot', {
format: 'png',
clip: { ...clip, scale: 1 }
})
return Buffer.from(data, 'base64')
}
const before = await capture()
releaseFont()
await page.waitForFunction(
() => document.fonts.check('13px "Orca Nerd Font Symbols"', '\ue0b0'),
undefined,
{ timeout: 30_000, polling: 50 }
)
await page.evaluate(
() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(resolve)))
)
const after = await capture()
expect(after.equals(before), 'the folder glyph must repaint after the font arrives').toBe(
false
)
expect(await page.evaluate(() => globalThis.__orcaTerminalEngineErrors)).toEqual([])
} finally {
releaseFont()
await page.close()
}
}, 300_000)
it('still opens under an older shell that refuses fonts', async () => {
const { page } = await openTerminal({
beforeNavigate: async (page) => {
await page.route('**/*', async (route) => {
const response = await route.fetch()
const headers = response.headers()
if (headers['content-security-policy']) {
headers['content-security-policy'] = headers['content-security-policy'].replace(
/font-src[^;]+/,
"font-src 'none'"
)
}
await route.fulfill({ response, headers })
})
}
})
try {
await openProbeTerminal(page)
expect(await page.locator('.xterm').count()).toBe(1)
expect(
await page.evaluate(() => document.fonts.check('13px "Orca Nerd Font Symbols"', '\ue0b0'))
).toBe(false)
expect(await page.evaluate(() => globalThis.__orcaTerminalEngineErrors)).toEqual([])
} finally {
await page.close()
}
}, 300_000)
})
@@ -90,8 +90,26 @@ describeRender(
}, 300_000)
it('opens xterm under the shipped policy and paints a dense stream into its buffer', async () => {
const { errors, page } = await openTerminal()
const fonts = []
const { errors, page } = await openTerminal({
beforeNavigate: async (page) => {
page.on('response', (response) => {
if (response.url().endsWith('.woff2')) {
fonts.push(response)
}
})
}
})
await openProbeTerminal(page)
expect(fonts).toHaveLength(1)
expect(fonts[0].status()).toBe(200)
expect(fonts[0].headers()['content-type']).toBe('font/woff2')
expect(new URL(fonts[0].url()).pathname).toBe(
'/assets/8efa6ba89f0a1f3eefde028f36aa64a13e36282e15ea0ca6929c664501037467.woff2'
)
expect(
await page.evaluate(() => document.fonts.check('13px "Orca Nerd Font Symbols"', '\ue0b0'))
).toBe(true)
const applied = await page.evaluate((data) => {
globalThis.__orcaTerminalProbe.write(data)
return data.length
@@ -17,13 +17,13 @@ const CONTENT_TYPE_BY_EXTENSION = {
js: 'text/javascript; charset=utf-8',
png: 'image/png',
// The app bundle emits images as same-origin assets rather than data: URLs, so each one is
// content-hashed and served from here. Fonts are absent by design: the policy sets
// font-src 'none'.
// content-hashed and served from here, including the terminal's licensed symbol font.
jpg: 'image/jpeg',
jpeg: 'image/jpeg',
gif: 'image/gif',
webp: 'image/webp',
svg: 'image/svg+xml'
svg: 'image/svg+xml',
woff2: 'font/woff2'
}
/**
@@ -44,6 +44,36 @@ export function mobileWebAppBundleMaxAssets(routeCount, imageCount) {
*/
export const MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES = 9 * 1024 * 1024
// Only this licensed desktop-identical asset has its own allowance; code retains the 9 MiB fence.
export const MOBILE_WEB_APP_TERMINAL_FONT_MAX_BYTES = 1200 * 1024
const TERMINAL_FONT_SHA256 = '8efa6ba89f0a1f3eefde028f36aa64a13e36282e15ea0ca6929c664501037467'
const TERMINAL_FONT_BYTES = 1_177_916
export function mobileWebAppBundleBudgetBytes(manifest) {
const fonts = manifest.assets.filter(
(asset) => asset.path.endsWith('.woff2') || asset.contentType.startsWith('font/')
)
if (fonts.length > 1) {
fail('only one licensed terminal font may use the independent font allowance')
}
const font = fonts[0]
if (!font) {
return manifest.totalBytes
}
if (
font.path !== `assets/${TERMINAL_FONT_SHA256}.woff2` ||
font.sha256 !== TERMINAL_FONT_SHA256 ||
font.contentType !== 'font/woff2' ||
font.byteLength !== TERMINAL_FONT_BYTES ||
font.byteLength > MOBILE_WEB_APP_TERMINAL_FONT_MAX_BYTES
) {
fail(
'terminal font allowance requires the approved hash, path, MIME type and bounded byte length'
)
}
return manifest.totalBytes - font.byteLength
}
/**
* Scripts emitted at every prefix of the sorted route key list, and the route each prefix added.
*
@@ -222,9 +252,10 @@ export async function verifyMobileWebAppBundle({ bundleDir = defaultBundleDir }
const manifest = assertMobileWebBundleBuilt(bundleDir)
if (manifest.totalBytes > MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES) {
const budgetBytes = mobileWebAppBundleBudgetBytes(manifest)
if (budgetBytes > MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES) {
fail(
`bundle is ${String(manifest.totalBytes)} bytes, over the Phase C budget of ` +
`bundle excluding the approved font is ${String(budgetBytes)} bytes, over the Phase C budget of ` +
`${String(MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES)}`
)
}
@@ -275,7 +306,8 @@ if (isDirectInvocation(import.meta.url, process.argv[1])) {
const manifest = await verifyMobileWebAppBundle()
console.log(
`[verify-mobile-web-app-bundle] OK — ${String(manifest.assets.length)} asset(s), ` +
`${String(manifest.totalBytes)}/${String(MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES)} bytes, ` +
`${String(manifest.totalBytes)} total bytes, ` +
`${String(mobileWebAppBundleBudgetBytes(manifest))}/${String(MOBILE_WEB_APP_BUNDLE_MAX_TOTAL_BYTES)} bytes excluding the approved font, ` +
`reproducible buildId ${manifest.buildId}`
)
} catch (error) {
@@ -0,0 +1,94 @@
Symbols Nerd Font Mono
Copyright (c) 2014, Ryan L McIntyre (https://ryanlmcintyre.com).
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
http://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -37,7 +37,8 @@ internal val MOBILE_WEB_SHELL_CSP = listOf(
// `http:` stays out: it reaches no image TLS cannot serve, and a cleartext image is readable and
// replaceable in flight by anything on the path.
"img-src 'self' data: blob: https:",
"font-src 'none'",
// Terminal fonts use bundled same-origin assets or embedded data URLs.
"font-src 'self' data:",
// The origin is one read-only directory behind the manifest map, so 'self' reaches nothing the
// page cannot already read, and the bootstrap page reads ./manifest.json through it. This is the
// fence for fetch and XMLHttpRequest; the document-start script covers only the two things the
@@ -39,10 +39,9 @@ class MobileWebShellCspTest {
)
assertTrue(directives.contains("script-src 'self'"))
assertFalse(MOBILE_WEB_SHELL_CSP.contains("unsafe-eval"))
// Narrowed rather than absent: `data:` is a fetch source for images and for nothing else, so a
// directive that grew one would fail here instead of passing a blanket absence check.
// Only images and bundled fonts can use embedded data URLs.
assertEquals(
listOf("img-src 'self' data: blob: https:"),
listOf("img-src 'self' data: blob: https:", "font-src 'self' data:"),
directives.filter { it.contains("data:") }
)
assertEquals(
@@ -34,7 +34,8 @@ enum MobileWebShellCsp {
// `http:` stays out: it reaches no image TLS cannot serve, and a cleartext image is readable
// and replaceable in flight by anything on the path.
"img-src 'self' data: blob: https:",
"font-src 'none'",
// Terminal fonts use bundled same-origin assets or embedded data URLs.
"font-src 'self' data:",
// The origin is one read-only directory behind the manifest map, so 'self' reaches nothing the
// page cannot already read, and the bootstrap page reads ./manifest.json through it. This is
// the fence for fetch and XMLHttpRequest; the document-start script covers only the two things
@@ -222,9 +222,8 @@ import Foundation
// arrive as a fetched same-origin script, which is the directive that matters.
precondition(directives.filter { $0.contains("unsafe-inline") } == ["style-src 'self' 'unsafe-inline'"])
precondition(!header.contains("unsafe-eval"))
// Narrowed rather than absent: `data:` is a fetch source for images and for nothing else, so a
// directive that grew one would fail here instead of passing a blanket absence check.
precondition(directives.filter { $0.contains("data:") } == ["img-src 'self' data: blob: https:"])
// Only images and bundled fonts can use embedded data URLs.
precondition(directives.filter { $0.contains("data:") } == ["img-src 'self' data: blob: https:", "font-src 'self' data:"])
precondition(directives.filter { $0.contains("blob:") } == ["img-src 'self' data: blob: https:", "media-src blob:"])
// Same shape for `https:`: images and nothing else. `http:` is not a substring of `https:`, so
// this still refuses a cleartext source anywhere in the header.
@@ -2,6 +2,7 @@ import { readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { createRequire } from 'node:module'
import * as esbuild from 'esbuild'
import { terminalNerdFontCss } from '../src/terminal/terminal-nerd-font-css.mjs'
const require = createRequire(import.meta.url)
const scriptDir = import.meta.dirname
@@ -96,9 +97,10 @@ async function buildEngineJs() {
}
async function main() {
const [engineJs, rawEngineCss, ...versions] = await Promise.all([
const [engineJs, rawEngineCss, nerdFont, ...versions] = await Promise.all([
buildEngineJs(),
readFile(require.resolve('@xterm/xterm/css/xterm.css'), 'utf8'),
readFile(path.join(mobileRoot, 'assets', 'fonts', 'SymbolsNerdFontMono-Regular.woff2')),
...packages.map(readPackageVersion)
])
// Why: the no-external-URL regression gate bans http(s):// anywhere in the
@@ -109,6 +111,8 @@ async function main() {
.replace(/\/\*[\s\S]*?\*\//g, '')
.replace(/http:\/\/www\.w3\.org\/2000\/svg/g, 'http%3A//www.w3.org/2000/svg')
const nerdFontCss = terminalNerdFontCss(`data:font/woff2;base64,${nerdFont.toString('base64')}`)
const header = [
'// Generated by scripts/build-terminal-webview-engine.mjs.',
`// Packages: ${versions.join(', ')}.`,
@@ -128,6 +132,7 @@ async function main() {
[
...header,
`export const XTERM_ENGINE_CSS = ${JSON.stringify(htmlText(engineCss, 'style'))}`,
`export const TERMINAL_NERD_FONT_CSS = ${JSON.stringify(htmlText(nerdFontCss, 'style'))}`,
''
].join('\n')
)
@@ -2,7 +2,7 @@ import { createTerminalDocumentScope } from './document-scope'
import { cancelDocumentFrames } from './document-frame-registry'
import { startFitScale, stopFitScale } from './fit-scale'
import { handleMsg } from './host-message-router'
import { startHostNotify, stopHostNotify } from './host-notify'
import { reportEngineError, startHostNotify, stopHostNotify } from './host-notify'
import { startMessageBridge, stopMessageBridge } from './message-bridge'
import { stopNormalBufferSmoothScroll } from './normal-buffer-smooth-scroll'
import { startSelectionMenuButtons } from './selection-menu-buttons'
@@ -67,13 +67,23 @@ export function startTerminalDocument(scope: TerminalDocumentScope) {
startTapDispatch(scope)
startSelectionMenuButtons(scope)
startSurfaceTouchGestures(scope)
startMessageBridge(scope)
startMessageBridge(scope)?.catch(failDeferredStartup.bind(null, scope))
} catch (error) {
stopTerminalDocument(scope)
throw error
}
}
function failDeferredStartup(scope: TerminalDocumentScope, error: unknown) {
stopTerminalDocument(scope)
reportEngineError(
scope,
'terminal startup failed',
error instanceof Error ? error.message : String(error),
true
)
}
/** The undo, in reverse, with the frames the document is still owed taken back last (ruling 21). */
export function stopTerminalDocument(scope: TerminalDocumentScope) {
stopMessageBridge(scope)
@@ -181,6 +181,8 @@ export type TerminalDocumentState = {
uninstallErrorReporter: (() => void) | null
/** `message-bridge`: undoes the host transport's install, or null before one. */
uninstallHostTransport: (() => void) | null
/** `message-bridge`: cancels the bounded font wait and late repaint after disposal. */
cancelFontReadiness: (() => void) | null
/** `fit-scale`: takes the viewport refit's listener off again, or null before one. */
removeViewportRefit: (() => void) | null
/** `tap-dispatch`: takes its four document listeners off again, or null before them. */
@@ -317,6 +319,7 @@ function createTerminalDocumentState(): TerminalDocumentState {
nonFatalErrorNotifies: 0,
uninstallErrorReporter: null,
uninstallHostTransport: null,
cancelFontReadiness: null,
removeViewportRefit: null,
removeTapDispatch: null,
removeWebglRecovery: null,
@@ -0,0 +1,198 @@
// @vitest-environment happy-dom
import { afterEach, describe, expect, it, vi } from 'vitest'
import { terminalDocumentDouble } from './document-terminal-double.test-support'
import { createTerminalDocumentScope } from './document-scope'
import { startMessageBridge, stopMessageBridge } from './message-bridge'
import { startTerminalDocument } from './create-terminal-document'
import { TERMINAL_DOCUMENT_MARKUP } from '../terminal-webview-html/document-markup'
const originalFonts = Object.getOwnPropertyDescriptor(document, 'fonts')
afterEach(() => {
vi.useRealTimers()
if (originalFonts) {
Object.defineProperty(document, 'fonts', originalFonts)
} else {
Reflect.deleteProperty(document, 'fonts')
}
})
function pendingFonts() {
let finish = () => {}
let fail = () => {}
const loaded = new Promise<FontFace[]>((resolve, reject) => {
finish = () => resolve([])
fail = () => reject(new Error('font unavailable'))
})
Object.defineProperty(document, 'fonts', {
configurable: true,
value: { check: () => false, load: () => loaded }
})
const postToHost = vi.fn()
const scope = createTerminalDocumentScope({
postToHost,
hasEngine: () => true,
start: () => ({ shown: false, textScale: 1 }),
installHostTransport: () => () => {}
})
return { fail, finish, loaded, postToHost, scope }
}
describe('the terminal waits for its bundled glyphs', () => {
it('starts once by the deadline if font loading never settles', async () => {
vi.useFakeTimers()
const fonts = pendingFonts()
const ready = startMessageBridge(fonts.scope)
await vi.advanceTimersByTimeAsync(1999)
expect(fonts.postToHost).not.toHaveBeenCalled()
await vi.advanceTimersByTimeAsync(1)
await ready
expect(fonts.postToHost).toHaveBeenCalledExactlyOnceWith({ type: 'web-ready', cellBox: null })
expect(vi.getTimerCount()).toBe(0)
stopMessageBridge(fonts.scope)
})
it('clears the fallback atlas when a timed-out font arrives without repeating readiness', async () => {
vi.useFakeTimers()
const fonts = pendingFonts()
const terminal = terminalDocumentDouble().terminal
const refresh = vi.spyOn(terminal, 'refresh')
const clearTextureAtlas = vi.fn()
fonts.scope.term = terminal
fonts.scope.webglAddon = { dispose() {}, clearTextureAtlas }
startMessageBridge(fonts.scope)
await vi.advanceTimersByTimeAsync(2000)
expect(fonts.postToHost).toHaveBeenCalledOnce()
fonts.finish()
await fonts.loaded
expect(clearTextureAtlas).toHaveBeenCalledOnce()
expect(refresh).toHaveBeenCalledExactlyOnceWith(0, 23)
expect(fonts.postToHost).toHaveBeenCalledOnce()
expect(fonts.scope.cancelFontReadiness).toBeNull()
stopMessageBridge(fonts.scope)
})
it('cancels the deadline and ignores late font completion after disposal', async () => {
vi.useFakeTimers()
const fonts = pendingFonts()
const terminal = terminalDocumentDouble().terminal
const refresh = vi.spyOn(terminal, 'refresh')
const clearTextureAtlas = vi.fn()
fonts.scope.term = terminal
fonts.scope.webglAddon = { dispose() {}, clearTextureAtlas }
const ready = startMessageBridge(fonts.scope)
stopMessageBridge(fonts.scope)
await ready
expect(vi.getTimerCount()).toBe(0)
fonts.finish()
await fonts.loaded
await vi.advanceTimersByTimeAsync(2000)
expect(fonts.postToHost).not.toHaveBeenCalled()
expect(clearTextureAtlas).not.toHaveBeenCalled()
expect(refresh).not.toHaveBeenCalled()
})
it('also ignores font completion after a timed-out document is disposed', async () => {
vi.useFakeTimers()
const fonts = pendingFonts()
const clearTextureAtlas = vi.fn()
fonts.scope.webglAddon = { dispose() {}, clearTextureAtlas }
startMessageBridge(fonts.scope)
await vi.advanceTimersByTimeAsync(2000)
stopMessageBridge(fonts.scope)
fonts.finish()
await fonts.loaded
expect(clearTextureAtlas).not.toHaveBeenCalled()
expect(fonts.postToHost).toHaveBeenCalledOnce()
})
it('takes back its deadline after rejection and ignores a later deadline', async () => {
vi.useFakeTimers()
const fonts = pendingFonts()
const ready = startMessageBridge(fonts.scope)
fonts.fail()
await ready
expect(vi.getTimerCount()).toBe(0)
await vi.advanceTimersByTimeAsync(2000)
expect(fonts.postToHost).toHaveBeenCalledExactlyOnceWith({ type: 'web-ready', cellBox: null })
stopMessageBridge(fonts.scope)
})
it('unwinds failed startup reached by the font deadline', async () => {
vi.useFakeTimers()
const fonts = pendingFonts()
document.body.innerHTML = TERMINAL_DOCUMENT_MARKUP
fonts.scope.postToHost = (message) => {
if (message.type === 'web-ready') {
throw new Error('timed-out readiness transport failed')
}
fonts.postToHost(message)
}
const removeTransport = vi.fn()
fonts.scope.installHostTransport = () => removeTransport
startTerminalDocument(fonts.scope)
await vi.advanceTimersByTimeAsync(2000)
expect(removeTransport).toHaveBeenCalledOnce()
expect(fonts.scope.framesStopped).toBe(true)
expect(vi.getTimerCount()).toBe(0)
expect(fonts.postToHost).toHaveBeenCalledWith({
type: 'error',
fatal: true,
message: expect.stringContaining('timed-out readiness transport failed')
})
fonts.finish()
await fonts.loaded
expect(fonts.postToHost).toHaveBeenCalledOnce()
})
it('withholds readiness until the font can paint the first terminal snapshot', async () => {
const fonts = pendingFonts()
startMessageBridge(fonts.scope)
expect(fonts.postToHost).not.toHaveBeenCalled()
fonts.finish()
await fonts.loaded
expect(fonts.postToHost).toHaveBeenCalledWith({ type: 'web-ready', cellBox: null })
stopMessageBridge(fonts.scope)
})
it('still starts the terminal if font decoding fails', async () => {
const fonts = pendingFonts()
startMessageBridge(fonts.scope)
fonts.fail()
await fonts.loaded.catch(() => {})
expect(fonts.postToHost).toHaveBeenCalledWith({ type: 'web-ready', cellBox: null })
stopMessageBridge(fonts.scope)
})
it('unwinds the document and reports an error if deferred startup fails', async () => {
const fonts = pendingFonts()
document.body.innerHTML = TERMINAL_DOCUMENT_MARKUP
fonts.scope.postToHost = (message) => {
if (message.type === 'web-ready') {
throw new Error('readiness transport failed')
}
fonts.postToHost(message)
}
const removeTransport = vi.fn()
fonts.scope.installHostTransport = () => removeTransport
startTerminalDocument(fonts.scope)
fonts.finish()
await fonts.loaded
await Promise.resolve()
expect(removeTransport).toHaveBeenCalledOnce()
expect(fonts.scope.framesStopped).toBe(true)
expect(fonts.postToHost).toHaveBeenCalledWith({
type: 'error',
fatal: true,
message: expect.stringContaining('readiness transport failed')
})
})
it('does not revive a document stopped during the font load', async () => {
const fonts = pendingFonts()
startMessageBridge(fonts.scope)
stopMessageBridge(fonts.scope)
fonts.finish()
await fonts.loaded
expect(fonts.postToHost).not.toHaveBeenCalled()
})
})
+87 -9
View File
@@ -2,6 +2,7 @@ import { handleMsg, type TerminalHostMessage } from './host-message-router'
import { notify, reportEngineError, type TerminalEngineError } from './host-notify'
import { laidOutCellBox } from './laid-out-cell-box'
import { prepareTerminal } from './terminal-init'
import { refreshTerminalSurface } from './webgl-recovery'
import type { TerminalDocumentScope } from './document-scope'
import type { TerminalDocumentHostFrame } from './document-host-seams'
@@ -46,20 +47,97 @@ export function startMessageBridge(scope: TerminalDocumentScope) {
scope.uninstallHostTransport = scope.installHostTransport((frame) =>
handleIncomingMessage(scope, frame)
)
if (scope.hasEngine()) {
// Why: ready carries the cell box xterm itself laid out, so the host sizes the first subscribe.
if (scope.start().shown) {
prepareTerminal(scope)
const reportReady = () => {
if (!scope.uninstallHostTransport) {
return
}
if (scope.hasEngine()) {
// Why: ready carries the cell box xterm itself laid out, so the host sizes the first subscribe.
if (scope.start().shown) {
prepareTerminal(scope)
}
scope.reportedCellBox = laidOutCellBox(scope)
notify(scope, { type: 'web-ready', cellBox: scope.reportedCellBox?.cellBox ?? null })
} else {
reportEngineError(scope, 'terminal engine missing', 'xterm failed to load', true)
}
// Why: the first init's report is checked against the box the terminal built before ready laid out.
scope.reportedCellBox = laidOutCellBox(scope)
notify(scope, { type: 'web-ready', cellBox: scope.reportedCellBox?.cellBox ?? null })
} else {
reportEngineError(scope, 'terminal engine missing', 'xterm failed to load', true)
}
// The WebGL atlas retains missing glyphs if it paints before the bundled font finishes loading.
if (document.fonts && !document.fonts.check('13px "Orca Nerd Font Symbols"', '\ue0b0')) {
return waitForTerminalFont(scope, reportReady)
}
reportReady()
}
const FONT_READY_TIMEOUT_MS = 2000
function waitForTerminalFont(scope: TerminalDocumentScope, reportReady: () => void) {
return new Promise<void>((resolve, reject) => {
let canceled = false
let ready = false
const finish = () => {
clearTimeout(timer)
if (canceled || ready) {
return
}
ready = true
try {
reportReady()
resolve()
} catch (error) {
reject(error)
}
}
const timer = setTimeout(finish, FONT_READY_TIMEOUT_MS)
const completion: TerminalFontCompletion = { loaded: null, failed: null }
scope.cancelFontReadiness = () => {
canceled = true
clearTimeout(timer)
completion.loaded = null
completion.failed = null
resolve()
}
completion.loaded = () => {
if (canceled) {
return
}
scope.cancelFontReadiness = null
if (ready) {
// A late font must evict fallback glyphs already cached after the startup deadline.
try {
scope.webglAddon?.clearTextureAtlas?.()
} catch {}
refreshTerminalSurface(scope)
} else {
finish()
}
}
completion.failed = () => {
if (!canceled) {
scope.cancelFontReadiness = null
finish()
}
}
observeFontCompletion(
document.fonts.load('13px "Orca Nerd Font Symbols"', '\ue0b0'),
completion
)
})
}
type TerminalFontCompletion = { loaded: (() => void) | null; failed: (() => void) | null }
// Detach document callbacks on disposal even if the browser's font promise stays pending.
function observeFontCompletion(loaded: Promise<FontFace[]>, completion: TerminalFontCompletion) {
loaded.then(
() => completion.loaded?.(),
() => completion.failed?.()
)
}
export function stopMessageBridge(scope: TerminalDocumentScope) {
scope.cancelFontReadiness?.()
scope.cancelFontReadiness = null
if (scope.uninstallHostTransport) {
scope.uninstallHostTransport()
scope.uninstallHostTransport = null
+1 -1
View File
@@ -50,7 +50,7 @@ export function isIOSWebView() {
// Why: iOS WebKit does not reliably resolve "SF Mono" by CSS family name and can
// fall to a non-monospace face; lead with the ui-monospace generic to avoid that.
const TERMINAL_FONT_FALLBACKS =
'"Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", "Symbols Nerd Font Mono", monospace'
'"Menlo", "Monaco", "Cascadia Mono", "Consolas", "DejaVu Sans Mono", "Liberation Mono", "Orca Nerd Font Symbols", "Symbols Nerd Font Mono", monospace'
// Why: change the real font size, then resize the grid to fit the viewport at
// the new cell metrics so the text shows at its true size immediately. RN's
+4
View File
@@ -0,0 +1,4 @@
declare module '*.woff2' {
const url: string
export default url
}
@@ -0,0 +1,13 @@
/** @param {string} url */
export function terminalNerdFontCss(url) {
return [
'@font-face{',
"font-family:'Orca Nerd Font Symbols';",
`src:url('${url}') format('woff2');`,
'font-weight:400;',
'font-style:normal;',
'font-display:swap;',
'unicode-range:U+E000-F8FF,U+F0000-FFFFD,U+100000-10FFFD;',
'}'
].join('')
}
@@ -6,6 +6,7 @@ import { terminalDocumentDouble } from './document/document-terminal-double.test
import type { TerminalDocumentTerminal } from './document/document-terminal-shape'
import type { TerminalWebViewCommand } from './terminal-webview-messages'
import { TERMINAL_DOCUMENT_MARKUP } from './terminal-webview-html'
import terminalNerdFontUrl from '../../assets/fonts/SymbolsNerdFontMono-Regular.woff2'
/**
* The page's mount, as a unit.
@@ -91,6 +92,15 @@ afterEach(() => {
})
describe('a stopped document takes its engines with it', () => {
it('loads the page symbol font from its bundled asset before starting the document', () => {
const terminal = mountTerminalWebDocument(plantHost(), () => {})
const style = document.getElementById('orca-terminal-document-style')?.textContent ?? ''
expect(style).toContain(`src:url('${terminalNerdFontUrl}') format('woff2')`)
expect(style).not.toContain('data:font/')
expect(style).toContain('unicode-range:U+E000-F8FF,U+F0000-FFFFD,U+100000-10FFFD')
terminal.dispose()
})
/**
* `stopTerminalInit`'s set (ruling 22's disposal): a swap that never committed leaves the
* committed terminal with nothing pointing at it, so `scope.term` alone is not every engine the
@@ -6,6 +6,8 @@ import type { TerminalDocumentStart, TerminalViewportChange } from './document/d
import { TERMINAL_DOCUMENT_ELEMENT_STYLE, TERMINAL_DOCUMENT_MARKUP } from './terminal-webview-html'
import { scopeStyleToHost } from '../style-scoping/document-style-scoping'
import { XTERM_ENGINE_CSS } from './terminal-webview-engine-css.generated'
import terminalNerdFontUrl from '../../assets/fonts/SymbolsNerdFontMono-Regular.woff2'
import { terminalNerdFontCss } from './terminal-nerd-font-css.mjs'
import { createTerminalDocument } from './document/create-terminal-document'
import type { TerminalWebViewCommand } from './terminal-webview-messages'
@@ -51,7 +53,7 @@ const HOST_CLASS = 'orca-terminal-document-host'
* behind entirely and every remaining selector is prefixed with the host's class, so nothing here
* can match an element the terminal does not own. That is also what makes leaving the sheet in
* the head after unmount the right trade: it matches nothing once the host has dropped the class,
* the next mount wants it back, and re-parsing 11 KiB per mount is all removing it would buy.
* the next mount wants it back, and re-parsing the sheet per mount is all removing it would buy.
*/
function ensureDocumentStyle() {
if (document.getElementById(STYLE_ELEMENT_ID)) {
@@ -62,7 +64,7 @@ function ensureDocumentStyle() {
const prefix = `.${HOST_CLASS}`
const engine = scopeStyleToHost(XTERM_ENGINE_CSS, prefix)
const elements = scopeStyleToHost(TERMINAL_DOCUMENT_ELEMENT_STYLE, prefix)
style.textContent = `${engine}\n${elements}\n${hostFrameStyle(prefix)}`
style.textContent = `${terminalNerdFontCss(terminalNerdFontUrl)}\n${engine}\n${elements}\n${hostFrameStyle(prefix)}`
document.head.appendChild(style)
}
@@ -1,8 +1,10 @@
// @vitest-environment happy-dom
import { readFileSync } from 'node:fs'
import path from 'node:path'
import { Script } from 'node:vm'
import { parse } from 'acorn'
import { describe, expect, it, vi } from 'vitest'
import { XTERM_ENGINE_CSS } from './terminal-webview-engine-css.generated'
import { TERMINAL_NERD_FONT_CSS, XTERM_ENGINE_CSS } from './terminal-webview-engine-css.generated'
import { XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
import { createTerminalDocumentScope } from './document/document-scope'
import { attachWebglAddon, startWebglRecovery } from './document/webgl-recovery'
@@ -88,6 +90,33 @@ describe('terminal WebView bundled engine', () => {
expect(XTERM_HTML).not.toContain('rel="stylesheet" href=')
})
it('ships the licensed desktop symbol font offline without covering normal text', () => {
const encoded = TERMINAL_NERD_FONT_CSS.match(/base64,([^']+)'/)?.[1]
expect(encoded).toBeTruthy()
const bundled = readFileSync(
path.resolve(import.meta.dirname, '../../assets/fonts/SymbolsNerdFontMono-Regular.woff2')
)
expect(Buffer.from(encoded ?? '', 'base64').equals(bundled)).toBe(true)
expect(
bundled.equals(
readFileSync(
path.resolve(
import.meta.dirname,
'../../../src/renderer/src/assets/fonts/SymbolsNerdFontMono-Regular.woff2'
)
)
)
).toBe(true)
expect(TERMINAL_NERD_FONT_CSS).toContain(
'unicode-range:U+E000-F8FF,U+F0000-FFFFD,U+100000-10FFFD'
)
expect(XTERM_HTML).toContain(`<style>${TERMINAL_NERD_FONT_CSS}</style>`)
expect(XTERM_HTML).toContain(
'"Liberation Mono", "Orca Nerd Font Symbols", "Symbols Nerd Font Mono"'
)
expect(TERMINAL_NERD_FONT_CSS).not.toMatch(/<\/style/i)
})
it('parses the bundled engine at the Chrome 74 syntax floor', () => {
expect(() => parse(XTERM_ENGINE_JS, { ecmaVersion: 2019 })).not.toThrow()
})
@@ -1,6 +1,6 @@
import { TERMINAL_DOCUMENT_MARKUP } from './document-markup'
import { TERMINAL_DOCUMENT_STYLE } from './document-style'
import { XTERM_ENGINE_CSS } from '../terminal-webview-engine-css.generated'
import { TERMINAL_NERD_FONT_CSS, XTERM_ENGINE_CSS } from '../terminal-webview-engine-css.generated'
import { XTERM_ENGINE_JS } from '../terminal-webview-engine.generated'
export const TERMINAL_HTML_DOCUMENT_SHELL = `<!DOCTYPE html>
@@ -17,6 +17,7 @@ window.onerror = function(msg) {
};
</script>
<style>${XTERM_ENGINE_CSS}</style>
<style>${TERMINAL_NERD_FONT_CSS}</style>
<style>
${TERMINAL_DOCUMENT_STYLE}
</style>