fix(mobile): ship bounded terminal contrast caches

This commit is contained in:
m4air
2026-09-19 17:54:01 -07:00
committed by Neil
parent d93d117452
commit dac9dd1aeb
15 changed files with 1255 additions and 23 deletions
+3
View File
@@ -93,6 +93,9 @@ jobs:
- name: Install dependencies
run: pnpm install --frozen-lockfile
- name: Verify mobile xterm patch matches the pinned upstream build
run: node ../config/scripts/regenerate-xterm-patches-mobile.mjs --check
- name: Typecheck
run: pnpm typecheck
+3 -1
View File
@@ -461,7 +461,9 @@ jobs:
- name: Verify xterm patches match the pinned upstream build
env:
WORK_DIR: ${{ runner.temp }}/xterm-patch-build
run: node config/scripts/regenerate-xterm-patches.mjs --check --work-dir="$WORK_DIR"
run: |
node config/scripts/regenerate-xterm-patches.mjs --check --work-dir="$WORK_DIR"
node config/scripts/regenerate-xterm-patches-mobile.mjs --check --work-dir="$WORK_DIR"
shell_contracts:
name: shell contracts
@@ -181,6 +181,9 @@ describe('per-job path classification', () => {
expectClassification(['config/patches/@xterm__xterm@6.1.0-beta.287.patch'], {
xterm_patch_sync: true
})
expectClassification(['config/scripts/regenerate-xterm-patches-mobile.mjs'], {
xterm_patch_sync: true
})
})
it('runs native package jobs only for the platform that ships the changed native', () => {
@@ -0,0 +1,102 @@
#!/usr/bin/env node
import { mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { pathToFileURL } from 'node:url'
import { regenerateXtermPatches } from './regenerate-xterm-patches.mjs'
import { splitPatchEntries } from './xterm-patch-text.mjs'
const REPO_ROOT = path.resolve(import.meta.dirname, '..', '..')
const CONTRAST_SOURCE = 'src/browser/ColorContrastCache.ts'
export function mobileXtermPatchProfile(manifest, mobilePackage, desktopSource) {
const coreEntries = manifest.packages.filter((entry) => entry.name === '@xterm/xterm')
if (coreEntries.length !== 1) {
throw new Error('Expected exactly one pinned @xterm/xterm package in the upstream manifest')
}
const core = coreEntries[0]
if (mobilePackage.dependencies['@xterm/xterm'] !== core.version) {
throw new Error(`Mobile @xterm/xterm must pin the upstream manifest version ${core.version}`)
}
const entries = splitPatchEntries(desktopSource).filter((entry) => entry.path === CONTRAST_SOURCE)
if (entries.length !== 1) {
throw new Error(`Expected exactly one ${CONTRAST_SOURCE} source stanza`)
}
const filename = `@xterm__xterm@${core.version}`
return {
source: entries[0].text,
manifest: {
...manifest,
packages: [
{
...core,
sourcePatch: `mobile/patches/xterm-src/${filename}.src.patch`,
patch: `mobile/patches/${filename}.patch`
}
]
}
}
}
export function regenerateMobileXtermPatch({
mode,
repoRoot = REPO_ROOT,
workDir = path.join(tmpdir(), 'orca-mobile-xterm-patch-build')
}) {
const manifest = JSON.parse(
readFileSync(path.join(repoRoot, 'config', 'patches', 'xterm-upstream.json'), 'utf8')
)
const core = manifest.packages.find((entry) => entry.name === '@xterm/xterm')
if (!core) {
throw new Error('Missing pinned @xterm/xterm package in the upstream manifest')
}
const profile = mobileXtermPatchProfile(
manifest,
JSON.parse(readFileSync(path.join(repoRoot, 'mobile', 'package.json'), 'utf8')),
readFileSync(path.join(repoRoot, core.sourcePatch), 'utf8')
)
const sourcePath = path.join(repoRoot, profile.manifest.packages[0].sourcePatch)
if (mode === 'write') {
mkdirSync(path.dirname(sourcePath), { recursive: true })
writeFileSync(sourcePath, profile.source)
} else if (readFileSync(sourcePath, 'utf8') !== profile.source) {
throw new Error(
'Mobile contrast source drifted; run regenerate-xterm-patches-mobile.mjs --write'
)
}
regenerateXtermPatches({
mode,
repoRoot,
workDir,
manifest: profile.manifest,
lockfileRelativePath: path.join('mobile', 'pnpm-lock.yaml')
})
}
function main(argv) {
const known = (value) => ['--write', '--check'].includes(value) || value.startsWith('--work-dir=')
if (argv.some((value) => !known(value))) {
throw new Error(
'Usage: regenerate-xterm-patches-mobile.mjs [--check | --write] [--work-dir=<path>]'
)
}
if (argv.includes('--write') && argv.includes('--check')) {
throw new Error('Pass either --write or --check, not both')
}
const workDir = argv.find((value) => value.startsWith('--work-dir='))
regenerateMobileXtermPatch({
mode: argv.includes('--write') ? 'write' : 'check',
...(workDir ? { workDir: path.resolve(workDir.slice('--work-dir='.length)) } : {})
})
}
const invokedPath = process.argv[1] ? pathToFileURL(realpathSync(process.argv[1])).href : null
if (invokedPath === import.meta.url) {
try {
main(process.argv.slice(2))
} catch (error) {
console.error(`\n${error.message}\n`)
process.exit(1)
}
}
@@ -0,0 +1,115 @@
import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { mobileXtermPatchProfile } from './regenerate-xterm-patches-mobile.mjs'
import {
patchHash,
readLockfilePatchHash,
readLockfileResolutionHashes,
regenerateXtermPatches
} from './regenerate-xterm-patches.mjs'
import { selectPatchEntries, sourceHunks, splitPatchEntries } from './xterm-patch-text.mjs'
const ROOT = path.resolve(import.meta.dirname, '..', '..')
const temporaryDirectories = []
const readProject = (file) => readFile(path.join(ROOT, file), 'utf8')
const manifest = JSON.parse(await readProject('config/patches/xterm-upstream.json'))
const mobilePackage = JSON.parse(await readProject('mobile/package.json'))
const desktopCore = manifest.packages.find((entry) => entry.name === '@xterm/xterm')
const desktopSource = await readProject(desktopCore.sourcePatch)
const profile = mobileXtermPatchProfile(manifest, mobilePackage, desktopSource)
afterEach(async () => {
await Promise.all(
temporaryDirectories.splice(0).map((dir) => rm(dir, { recursive: true, force: true }))
)
})
describe('mobile xterm patch generation', () => {
it('reuses the pinned core build while selecting only the contrast source', () => {
expect(profile.manifest.upstream).toEqual(manifest.upstream)
expect(profile.manifest.toolchain).toEqual(manifest.toolchain)
expect(profile.manifest.packages).toHaveLength(1)
expect(profile.manifest.packages[0]).toMatchObject({
name: '@xterm/xterm',
version: desktopCore.version,
build: desktopCore.build,
generatedPaths: desktopCore.generatedPaths
})
expect(splitPatchEntries(profile.source).map((entry) => entry.path)).toEqual([
'src/browser/ColorContrastCache.ts'
])
expect(profile.source).toBe(
selectPatchEntries(desktopSource, (file) => file.endsWith('/ColorContrastCache.ts'))
)
expect(profile.manifest.packages[0].sourcePatch).toMatch(/^mobile\/patches\/xterm-src\//)
expect(profile.manifest.packages[0].patch).toMatch(/^mobile\/patches\//)
expect(desktopCore.sourcePatch).toMatch(/^config\/patches\//)
})
it('refuses a divergent or unpinned mobile version before building', () => {
for (const version of ['6.1.0-beta.302', `^${desktopCore.version}`, undefined]) {
const changed = {
...mobilePackage,
dependencies: { ...mobilePackage.dependencies, '@xterm/xterm': version }
}
expect(() => mobileXtermPatchProfile(manifest, changed, desktopSource)).toThrow('must pin')
}
})
it('refuses missing or duplicate core entries and contrast stanzas', () => {
for (const packages of [[], [desktopCore, desktopCore]]) {
expect(() =>
mobileXtermPatchProfile({ ...manifest, packages }, mobilePackage, desktopSource)
).toThrow('exactly one pinned')
}
for (const source of ['', profile.source + profile.source]) {
expect(() => mobileXtermPatchProfile(manifest, mobilePackage, source)).toThrow(
'exactly one src/browser/ColorContrastCache.ts'
)
}
})
it('uses the selected manifest and lockfile without requiring desktop files', async () => {
const repoRoot = await mkdtemp(path.join(tmpdir(), 'orca-mobile-xterm-profile-'))
temporaryDirectories.push(repoRoot)
await mkdir(path.join(repoRoot, 'mobile'))
const lockfile = 'lockfileVersion: 9.0\n'
await writeFile(path.join(repoRoot, 'mobile', 'pnpm-lock.yaml'), lockfile)
regenerateXtermPatches({
mode: 'check',
repoRoot,
workDir: path.join(repoRoot, 'scratch'),
manifest: { ...profile.manifest, packages: [] },
lockfileRelativePath: path.join('mobile', 'pnpm-lock.yaml')
})
expect(await readFile(path.join(repoRoot, 'mobile', 'pnpm-lock.yaml'), 'utf8')).toBe(lockfile)
})
it('ships only the contrast source and all four rebuilt bundle/map files', async () => {
const core = profile.manifest.packages[0]
const source = await readProject(core.sourcePatch)
const patch = await readProject(core.patch)
expect(source).toBe(profile.source)
expect(sourceHunks(patch)).toBe(profile.source)
expect(
splitPatchEntries(patch)
.map((entry) => entry.path)
.sort()
).toEqual([
'lib/xterm.js',
'lib/xterm.js.map',
'lib/xterm.mjs',
'lib/xterm.mjs.map',
'src/browser/ColorContrastCache.ts'
])
expect(patch).not.toContain('xterm-composition-transaction-accepted')
const lockfile = await readProject('mobile/pnpm-lock.yaml')
const key = `${core.name}@${core.version}`
const hash = patchHash(patch)
expect(readLockfilePatchHash(lockfile, key)).toBe(hash)
expect(readLockfileResolutionHashes(lockfile, key).length).toBeGreaterThan(0)
expect(new Set(readLockfileResolutionHashes(lockfile, key))).toEqual(new Set([hash]))
})
})
+3 -2
View File
@@ -439,14 +439,15 @@ export function regenerateXtermPatches({
mode,
repoRoot = DEFAULT_REPO_ROOT,
workDir = path.join(tmpdir(), 'orca-xterm-patch-build'),
manifest = JSON.parse(readFileSync(path.join(repoRoot, MANIFEST_RELATIVE_PATH), 'utf8')),
lockfileRelativePath = 'pnpm-lock.yaml',
log = console.info
} = {}) {
const manifest = JSON.parse(readFileSync(path.join(repoRoot, MANIFEST_RELATIVE_PATH), 'utf8'))
assertBuildStepsAllowed(manifest)
assertSourcemapPolicy(manifest)
mkdirSync(workDir, { recursive: true })
const lockfilePath = path.join(repoRoot, 'pnpm-lock.yaml')
const lockfilePath = path.join(repoRoot, lockfileRelativePath)
let lockfile = readFileSync(lockfilePath, 'utf8')
let lockfileChanged = false
@@ -17,6 +17,7 @@ terminal output, and transport behavior do not change.
```sh
ORCA_BACKGROUND_LAUNCH=1 node docs/audits/terminal-contrast-cache-retention/reproduce.mjs
ORCA_BACKGROUND_LAUNCH=1 node docs/audits/terminal-contrast-cache-retention/reproduce-dom.mjs
ORCA_BACKGROUND_LAUNCH=1 ORCA_AUDIT_MOBILE=1 node docs/audits/terminal-contrast-cache-retention/reproduce-dom.mjs
```
Both scripts exercise the installed CJS and ESM bundles in headless Chromium.
@@ -49,6 +50,12 @@ produce the same computed CSS color and rendered row HTML as before.
All 12 DOM cases pass across the baseline and both fixed module formats; samples
reach the exact 4,096-entry cap. Every page and browser is closed after the run.
The mobile mode loads the actual generated WebView engine after mobile's
postinstall. `ORCA_AUDIT_MOBILE_BASELINE` accepts a pre-fix generated engine to
include before cases. Its eight recorded dark/light and normal/dim cases also
preserve the corrected CSS colors and row HTML after eviction and recalculation.
The fixed engine stays within 4,096 entries; the baseline exceeds 10,000.
## Validation and limits
- 122 tests pass across cache, regeneration, contrast, appearance, IME, and
@@ -59,8 +66,13 @@ reach the exact 4,096-entry cap. Every page and browser is closed after the run.
- Full desktop typecheck, formatting/lint, and changed-code quality pass.
- Captured browser: Chromium 147.0.7727.15 on macOS. Heap measurements include
GC/allocator variation and other terminal state.
- This is a desktop renderer fix. Mobile resolves its own unpatched xterm package
when generating its WebView engine; that separate bundle is not fixed here.
- Mobile's separate package receives a contrast-only source patch, rebuilt
CJS/ESM bundles and source maps. The existing pinned generator verifies both
variants, and mobile's postinstall rebuilds its gitignored WebView engine.
Desktop IME and SortedList patches are not copied into mobile.
- The mobile follow-up passes 90 generator/cache/CI-contract tests and 23 mobile
engine/theme tests, mobile typecheck, frozen install, and regeneration checks.
These counts overlap with the earlier desktop pass.
- `v1.4.198` shipped the same xterm version and automatic 3/4.5 contrast settings.
Its appearance path also skipped unchanged theme/ratio assignments, so ordinary
reapplication did not periodically clear the cache. User contrast overrides were
@@ -2,19 +2,26 @@ import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { pathToFileURL } from 'node:url'
import { chromium } from 'playwright'
if (process.env.ORCA_BACKGROUND_LAUNCH !== '1') {
throw new Error('Run with ORCA_BACKGROUND_LAUNCH=1')
}
const mobile = process.env.ORCA_AUDIT_MOBILE === '1'
const installed = resolve('node_modules/@xterm/xterm/lib/xterm.js')
const bundles = [
['after', installed],
['after', installed.replace(/\.js$/, '.mjs')]
]
if (process.env.ORCA_AUDIT_XTERM_BASELINE) {
bundles.unshift(['before', resolve(process.env.ORCA_AUDIT_XTERM_BASELINE)])
const bundles = mobile
? [['after', resolve('mobile/src/terminal/terminal-webview-engine.generated.ts')]]
: [
['after', installed],
['after', installed.replace(/\.js$/, '.mjs')]
]
const baseline = mobile
? process.env.ORCA_AUDIT_MOBILE_BASELINE
: process.env.ORCA_AUDIT_XTERM_BASELINE
if (baseline) {
bundles.unshift(['before', resolve(baseline)])
}
const browser = await chromium.launch({
executablePath: process.env.ORCA_AUDIT_CHROMIUM,
@@ -27,7 +34,9 @@ let pagesOpened = 0
let pagesClosed = 0
try {
for (const [phase, bundle] of bundles) {
const source = await readFile(bundle, 'utf8')
const source = mobile
? (await import(pathToFileURL(bundle).href)).XTERM_ENGINE_JS
: await readFile(bundle, 'utf8')
const sha256 = createHash('sha256').update(source).digest('hex')
for (const theme of ['dark', 'light']) {
for (const mode of ['normal', 'dim']) {
@@ -43,7 +52,7 @@ try {
},
`data:text/javascript;base64,${Buffer.from(source).toString('base64')}`
)
: page.addScriptTag({ path: bundle }))
: page.addScriptTag({ content: source }))
const initial = await page.evaluate(
async ({ theme, mode }) => {
const terminal = new Terminal({
@@ -181,7 +190,7 @@ try {
}
results.push({
phase,
format: bundle.endsWith('.mjs') ? 'esm' : 'cjs',
format: mobile ? 'mobile-webview' : bundle.endsWith('.mjs') ? 'esm' : 'cjs',
theme,
mode,
sha256,
@@ -212,6 +221,7 @@ console.log(
browser: browserVersion,
headless: true,
renderer: 'DOM',
mobile,
pagesOpened,
pagesClosed,
browserClosed: !browser.isConnected(),
@@ -0,0 +1,708 @@
{
"node": "v26.6.0",
"browser": "147.0.7727.15",
"headless": true,
"renderer": "DOM",
"mobile": true,
"pagesOpened": 8,
"pagesClosed": 8,
"browserClosed": true,
"results": [
{
"phase": "before",
"format": "mobile-webview",
"theme": "dark",
"mode": "normal",
"sha256": "96cb91487352f919c4c374da16305f9074c4a866bf5ad90fbd087a3cc0e83077",
"initial": {
"minimumContrastRatio": 3,
"rawColor": "rgb(5, 50, 25)",
"color": "rgb(74, 107, 88)",
"opacity": "1",
"html": "<span style=\"color:#4a6b58;\">M</span><span> </span>",
"text": "M",
"contrast": 2,
"dimContrast": 0,
"probeCached": true
},
"samples": [
{
"updates": 0,
"contrast": 2,
"dimContrast": 0,
"probeCached": true
},
{
"updates": 1000,
"contrast": 1002,
"dimContrast": 0,
"probeCached": true
},
{
"updates": 4094,
"contrast": 4096,
"dimContrast": 0,
"probeCached": true
},
{
"updates": 4095,
"contrast": 4097,
"dimContrast": 0,
"probeCached": true
},
{
"updates": 4096,
"contrast": 4098,
"dimContrast": 0,
"probeCached": true
},
{
"updates": 5000,
"contrast": 5002,
"dimContrast": 0,
"probeCached": true
},
{
"updates": 10000,
"contrast": 10002,
"dimContrast": 0,
"probeCached": true
}
],
"checks": {
"revisited": {
"color": "rgb(74, 107, 88)",
"opacity": "1",
"html": "<span style=\"color:#4a6b58;\">M</span><span> </span>",
"text": "M",
"contrast": 10002,
"dimContrast": 0,
"probeCached": true
},
"cleared": {
"contrast": 0,
"dimContrast": 0,
"probeCached": false
},
"recomputed": {
"color": "rgb(74, 107, 88)",
"opacity": "1",
"html": "<span style=\"color:#4a6b58;\">M</span><span> </span>",
"text": "M",
"contrast": 2,
"dimContrast": 0,
"probeCached": true
}
}
},
{
"phase": "before",
"format": "mobile-webview",
"theme": "dark",
"mode": "dim",
"sha256": "96cb91487352f919c4c374da16305f9074c4a866bf5ad90fbd087a3cc0e83077",
"initial": {
"minimumContrastRatio": 3,
"rawColor": "rgb(5, 50, 25)",
"color": "rgb(30, 71, 48)",
"opacity": "1",
"html": "<span style=\"color:#1e4730;\" class=\"xterm-dim\">M</span><span> </span>",
"text": "M",
"contrast": 1,
"dimContrast": 1,
"probeCached": true
},
"samples": [
{
"updates": 0,
"contrast": 1,
"dimContrast": 1,
"probeCached": true
},
{
"updates": 1000,
"contrast": 1,
"dimContrast": 1001,
"probeCached": true
},
{
"updates": 4094,
"contrast": 1,
"dimContrast": 4095,
"probeCached": true
},
{
"updates": 4095,
"contrast": 1,
"dimContrast": 4096,
"probeCached": true
},
{
"updates": 4096,
"contrast": 1,
"dimContrast": 4097,
"probeCached": true
},
{
"updates": 5000,
"contrast": 1,
"dimContrast": 5001,
"probeCached": true
},
{
"updates": 10000,
"contrast": 1,
"dimContrast": 10001,
"probeCached": true
}
],
"checks": {
"revisited": {
"color": "rgb(30, 71, 48)",
"opacity": "1",
"html": "<span style=\"color:#1e4730;\" class=\"xterm-dim\">M</span><span> </span>",
"text": "M",
"contrast": 1,
"dimContrast": 10001,
"probeCached": true
},
"cleared": {
"contrast": 0,
"dimContrast": 0,
"probeCached": false
},
"recomputed": {
"color": "rgb(30, 71, 48)",
"opacity": "1",
"html": "<span style=\"color:#1e4730;\" class=\"xterm-dim\">M</span><span> </span>",
"text": "M",
"contrast": 1,
"dimContrast": 1,
"probeCached": true
}
}
},
{
"phase": "before",
"format": "mobile-webview",
"theme": "light",
"mode": "normal",
"sha256": "96cb91487352f919c4c374da16305f9074c4a866bf5ad90fbd087a3cc0e83077",
"initial": {
"minimumContrastRatio": 4.5,
"rawColor": "rgb(245, 250, 240)",
"color": "rgb(116, 117, 113)",
"opacity": "1",
"html": "<span style=\"color:#747571;\">M</span><span> </span>",
"text": "M",
"contrast": 2,
"dimContrast": 0,
"probeCached": true
},
"samples": [
{
"updates": 0,
"contrast": 2,
"dimContrast": 0,
"probeCached": true
},
{
"updates": 1000,
"contrast": 1002,
"dimContrast": 0,
"probeCached": true
},
{
"updates": 4094,
"contrast": 4096,
"dimContrast": 0,
"probeCached": true
},
{
"updates": 4095,
"contrast": 4097,
"dimContrast": 0,
"probeCached": true
},
{
"updates": 4096,
"contrast": 4098,
"dimContrast": 0,
"probeCached": true
},
{
"updates": 5000,
"contrast": 5002,
"dimContrast": 0,
"probeCached": true
},
{
"updates": 10000,
"contrast": 10002,
"dimContrast": 0,
"probeCached": true
}
],
"checks": {
"revisited": {
"color": "rgb(116, 117, 113)",
"opacity": "1",
"html": "<span style=\"color:#747571;\">M</span><span> </span>",
"text": "M",
"contrast": 10002,
"dimContrast": 0,
"probeCached": true
},
"cleared": {
"contrast": 0,
"dimContrast": 0,
"probeCached": false
},
"recomputed": {
"color": "rgb(116, 117, 113)",
"opacity": "1",
"html": "<span style=\"color:#747571;\">M</span><span> </span>",
"text": "M",
"contrast": 2,
"dimContrast": 0,
"probeCached": true
}
}
},
{
"phase": "before",
"format": "mobile-webview",
"theme": "light",
"mode": "dim",
"sha256": "96cb91487352f919c4c374da16305f9074c4a866bf5ad90fbd087a3cc0e83077",
"initial": {
"minimumContrastRatio": 4.5,
"rawColor": "rgb(245, 250, 240)",
"color": "rgb(160, 162, 156)",
"opacity": "1",
"html": "<span style=\"color:#a0a29c;\" class=\"xterm-dim\">M</span><span> </span>",
"text": "M",
"contrast": 1,
"dimContrast": 1,
"probeCached": true
},
"samples": [
{
"updates": 0,
"contrast": 1,
"dimContrast": 1,
"probeCached": true
},
{
"updates": 1000,
"contrast": 1,
"dimContrast": 1001,
"probeCached": true
},
{
"updates": 4094,
"contrast": 1,
"dimContrast": 4095,
"probeCached": true
},
{
"updates": 4095,
"contrast": 1,
"dimContrast": 4096,
"probeCached": true
},
{
"updates": 4096,
"contrast": 1,
"dimContrast": 4097,
"probeCached": true
},
{
"updates": 5000,
"contrast": 1,
"dimContrast": 5001,
"probeCached": true
},
{
"updates": 10000,
"contrast": 1,
"dimContrast": 10001,
"probeCached": true
}
],
"checks": {
"revisited": {
"color": "rgb(160, 162, 156)",
"opacity": "1",
"html": "<span style=\"color:#a0a29c;\" class=\"xterm-dim\">M</span><span> </span>",
"text": "M",
"contrast": 1,
"dimContrast": 10001,
"probeCached": true
},
"cleared": {
"contrast": 0,
"dimContrast": 0,
"probeCached": false
},
"recomputed": {
"color": "rgb(160, 162, 156)",
"opacity": "1",
"html": "<span style=\"color:#a0a29c;\" class=\"xterm-dim\">M</span><span> </span>",
"text": "M",
"contrast": 1,
"dimContrast": 1,
"probeCached": true
}
}
},
{
"phase": "after",
"format": "mobile-webview",
"theme": "dark",
"mode": "normal",
"sha256": "0da8b0e571e1325f5af62e990318a3dd31c444dab6a260563952498ce1fc9932",
"initial": {
"minimumContrastRatio": 3,
"rawColor": "rgb(5, 50, 25)",
"color": "rgb(74, 107, 88)",
"opacity": "1",
"html": "<span style=\"color:#4a6b58;\">M</span><span> </span>",
"text": "M",
"contrast": 2,
"dimContrast": 0,
"probeCached": true
},
"samples": [
{
"updates": 0,
"contrast": 2,
"dimContrast": 0,
"probeCached": true
},
{
"updates": 1000,
"contrast": 1002,
"dimContrast": 0,
"probeCached": true
},
{
"updates": 4094,
"contrast": 4096,
"dimContrast": 0,
"probeCached": true
},
{
"updates": 4095,
"contrast": 2,
"dimContrast": 0,
"probeCached": false
},
{
"updates": 4096,
"contrast": 3,
"dimContrast": 0,
"probeCached": false
},
{
"updates": 5000,
"contrast": 907,
"dimContrast": 0,
"probeCached": false
},
{
"updates": 10000,
"contrast": 1812,
"dimContrast": 0,
"probeCached": false
}
],
"checks": {
"revisited": {
"color": "rgb(74, 107, 88)",
"opacity": "1",
"html": "<span style=\"color:#4a6b58;\">M</span><span> </span>",
"text": "M",
"contrast": 1813,
"dimContrast": 0,
"probeCached": true
},
"cleared": {
"contrast": 0,
"dimContrast": 0,
"probeCached": false
},
"recomputed": {
"color": "rgb(74, 107, 88)",
"opacity": "1",
"html": "<span style=\"color:#4a6b58;\">M</span><span> </span>",
"text": "M",
"contrast": 2,
"dimContrast": 0,
"probeCached": true
}
}
},
{
"phase": "after",
"format": "mobile-webview",
"theme": "dark",
"mode": "dim",
"sha256": "0da8b0e571e1325f5af62e990318a3dd31c444dab6a260563952498ce1fc9932",
"initial": {
"minimumContrastRatio": 3,
"rawColor": "rgb(5, 50, 25)",
"color": "rgb(30, 71, 48)",
"opacity": "1",
"html": "<span style=\"color:#1e4730;\" class=\"xterm-dim\">M</span><span> </span>",
"text": "M",
"contrast": 1,
"dimContrast": 1,
"probeCached": true
},
"samples": [
{
"updates": 0,
"contrast": 1,
"dimContrast": 1,
"probeCached": true
},
{
"updates": 1000,
"contrast": 1,
"dimContrast": 1001,
"probeCached": true
},
{
"updates": 4094,
"contrast": 1,
"dimContrast": 4095,
"probeCached": true
},
{
"updates": 4095,
"contrast": 1,
"dimContrast": 4096,
"probeCached": true
},
{
"updates": 4096,
"contrast": 1,
"dimContrast": 1,
"probeCached": false
},
{
"updates": 5000,
"contrast": 1,
"dimContrast": 905,
"probeCached": false
},
{
"updates": 10000,
"contrast": 1,
"dimContrast": 1809,
"probeCached": false
}
],
"checks": {
"revisited": {
"color": "rgb(30, 71, 48)",
"opacity": "1",
"html": "<span style=\"color:#1e4730;\" class=\"xterm-dim\">M</span><span> </span>",
"text": "M",
"contrast": 1,
"dimContrast": 1810,
"probeCached": true
},
"cleared": {
"contrast": 0,
"dimContrast": 0,
"probeCached": false
},
"recomputed": {
"color": "rgb(30, 71, 48)",
"opacity": "1",
"html": "<span style=\"color:#1e4730;\" class=\"xterm-dim\">M</span><span> </span>",
"text": "M",
"contrast": 1,
"dimContrast": 1,
"probeCached": true
}
}
},
{
"phase": "after",
"format": "mobile-webview",
"theme": "light",
"mode": "normal",
"sha256": "0da8b0e571e1325f5af62e990318a3dd31c444dab6a260563952498ce1fc9932",
"initial": {
"minimumContrastRatio": 4.5,
"rawColor": "rgb(245, 250, 240)",
"color": "rgb(116, 117, 113)",
"opacity": "1",
"html": "<span style=\"color:#747571;\">M</span><span> </span>",
"text": "M",
"contrast": 2,
"dimContrast": 0,
"probeCached": true
},
"samples": [
{
"updates": 0,
"contrast": 2,
"dimContrast": 0,
"probeCached": true
},
{
"updates": 1000,
"contrast": 1002,
"dimContrast": 0,
"probeCached": true
},
{
"updates": 4094,
"contrast": 4096,
"dimContrast": 0,
"probeCached": true
},
{
"updates": 4095,
"contrast": 2,
"dimContrast": 0,
"probeCached": false
},
{
"updates": 4096,
"contrast": 3,
"dimContrast": 0,
"probeCached": false
},
{
"updates": 5000,
"contrast": 907,
"dimContrast": 0,
"probeCached": false
},
{
"updates": 10000,
"contrast": 1812,
"dimContrast": 0,
"probeCached": false
}
],
"checks": {
"revisited": {
"color": "rgb(116, 117, 113)",
"opacity": "1",
"html": "<span style=\"color:#747571;\">M</span><span> </span>",
"text": "M",
"contrast": 1813,
"dimContrast": 0,
"probeCached": true
},
"cleared": {
"contrast": 0,
"dimContrast": 0,
"probeCached": false
},
"recomputed": {
"color": "rgb(116, 117, 113)",
"opacity": "1",
"html": "<span style=\"color:#747571;\">M</span><span> </span>",
"text": "M",
"contrast": 2,
"dimContrast": 0,
"probeCached": true
}
}
},
{
"phase": "after",
"format": "mobile-webview",
"theme": "light",
"mode": "dim",
"sha256": "0da8b0e571e1325f5af62e990318a3dd31c444dab6a260563952498ce1fc9932",
"initial": {
"minimumContrastRatio": 4.5,
"rawColor": "rgb(245, 250, 240)",
"color": "rgb(160, 162, 156)",
"opacity": "1",
"html": "<span style=\"color:#a0a29c;\" class=\"xterm-dim\">M</span><span> </span>",
"text": "M",
"contrast": 1,
"dimContrast": 1,
"probeCached": true
},
"samples": [
{
"updates": 0,
"contrast": 1,
"dimContrast": 1,
"probeCached": true
},
{
"updates": 1000,
"contrast": 1,
"dimContrast": 1001,
"probeCached": true
},
{
"updates": 4094,
"contrast": 1,
"dimContrast": 4095,
"probeCached": true
},
{
"updates": 4095,
"contrast": 1,
"dimContrast": 4096,
"probeCached": true
},
{
"updates": 4096,
"contrast": 1,
"dimContrast": 1,
"probeCached": false
},
{
"updates": 5000,
"contrast": 1,
"dimContrast": 905,
"probeCached": false
},
{
"updates": 10000,
"contrast": 1,
"dimContrast": 1809,
"probeCached": false
}
],
"checks": {
"revisited": {
"color": "rgb(160, 162, 156)",
"opacity": "1",
"html": "<span style=\"color:#a0a29c;\" class=\"xterm-dim\">M</span><span> </span>",
"text": "M",
"contrast": 1,
"dimContrast": 1810,
"probeCached": true
},
"cleared": {
"contrast": 0,
"dimContrast": 0,
"probeCached": false
},
"recomputed": {
"color": "rgb(160, 162, 156)",
"opacity": "1",
"html": "<span style=\"color:#a0a29c;\" class=\"xterm-dim\">M</span><span> </span>",
"text": "M",
"contrast": 1,
"dimContrast": 1,
"probeCached": true
}
}
}
]
}
+26 -4
View File
@@ -2,13 +2,14 @@
## Scope
Orca ships `@xterm/xterm` with four source changes it needs and upstream has
Orca ships `@xterm/xterm` with source changes it needs and upstream has
not taken: the IME composition hooks, the `xterm-composition-*` custom events
they raise, the `ICompositionHelper` surface those hooks widen, and a `SortedList`
fix. pnpm applies them through `config/patches/@xterm__xterm@<version>.patch`.
fix, plus a bound on contrast-cache entries. pnpm applies them through
`config/patches/@xterm__xterm@<version>.patch`.
That patch touches eight files. Four are hand-authored source
(`src/browser/CoreBrowserTerminal.ts`, `src/browser/Types.ts`,
That patch touches nine files. Five are hand-authored source
(`src/browser/ColorContrastCache.ts`, `src/browser/CoreBrowserTerminal.ts`, `src/browser/Types.ts`,
`src/browser/input/CompositionHelper.ts`, `src/common/SortedList.ts`) and four
are the build output those sources produce (`lib/xterm.js`, `lib/xterm.mjs`,
and both sourcemaps). The bundle half is 7.3 MB of minified code. It is
@@ -122,6 +123,27 @@ Run the checkout outside this repository. A build tree underneath it makes
`tsgo` walk up into Orca's own `node_modules` and fail with `TS2300: Duplicate
identifier`, which is a symptom of where the tree sits and not of the patch.
## Mobile Contrast Cache Patch
Mobile installs xterm in a separate pnpm project. Its patch includes only
`ColorContrastCache.ts`; the desktop IME and `SortedList` changes are excluded.
`regenerate-xterm-patches-mobile.mjs` derives that source stanza and reuses the
desktop manifest's version, upstream commit, toolchain, and build steps. It fails
if mobile pins a different version or the contrast stanza is missing or duplicated.
After editing the desktop contrast source and regenerating the desktop patch:
```sh
node config/scripts/regenerate-xterm-patches-mobile.mjs --write
pnpm exec pnpm --dir mobile install
node config/scripts/regenerate-xterm-patches-mobile.mjs --check
```
Commit the derived mobile source patch, full patch, and mobile lockfile together.
Mobile's postinstall rebuilds its gitignored terminal engine from that patched
package. Do not copy desktop's full patch or hand-edit the generated engine.
The existing xterm patch-sync CI job verifies both variants.
## How the Commit Is Known
Upstream `bin/publish.js` sets `packageJson.commit` before `npm publish`, so
File diff suppressed because one or more lines are too long
@@ -0,0 +1,47 @@
diff --git a/src/browser/ColorContrastCache.ts b/src/browser/ColorContrastCache.ts
index fdcd9d133199a6cd6ba9bea9606a02c03ad03b3d..0558a8873f680e8fb2a27cc833c49c5ba658e1dd 100644
--- a/src/browser/ColorContrastCache.ts
+++ b/src/browser/ColorContrastCache.ts
@@ -7,11 +7,17 @@ import { IColorContrastCache } from './Types';
import { IColor } from '../common/Types';
import { TwoKeyMap } from '../common/MultiKeyMap';
+const CONTRAST_CACHE_MAX_ENTRIES = 4096;
+
export class ColorContrastCache implements IColorContrastCache {
private _color: TwoKeyMap</* bg */number, /* fg */number, IColor | null> = new TwoKeyMap();
private _css: TwoKeyMap</* bg */number, /* fg */number, string | null> = new TwoKeyMap();
+ private _entryCount = 0;
public setCss(bg: number, fg: number, value: string | null): void {
+ if (this._css.get(bg, fg) === undefined) {
+ this._admitNewEntry();
+ }
this._css.set(bg, fg, value);
}
@@ -20,6 +26,9 @@ export class ColorContrastCache implements IColorContrastCache {
}
public setColor(bg: number, fg: number, value: IColor | null): void {
+ if (this._color.get(bg, fg) === undefined) {
+ this._admitNewEntry();
+ }
this._color.set(bg, fg, value);
}
@@ -30,5 +39,14 @@ export class ColorContrastCache implements IColorContrastCache {
public clear(): void {
this._color.clear();
this._css.clear();
+ this._entryCount = 0;
+ }
+
+ private _admitNewEntry(): void {
+ // Color pairs outlive atlas pages, including cached misses and DOM-rendered colors.
+ if (this._entryCount >= CONTRAST_CACHE_MAX_ENTRIES) {
+ this.clear();
+ }
+ this._entryCount++;
}
}
+6 -5
View File
@@ -9,6 +9,7 @@ overrides:
query-string: 9.5.1
patchedDependencies:
'@xterm/xterm@6.1.0-beta.303': 3a6c0d12de82b2cb60b3bb93dd7f03cf25672986bce77aa3aefac986e4135163
expo-notifications@55.0.27:
hash: ce20843a3daad4185d7e8571788fa323ba4d11984936188790858650a61749c0
path: patches/expo-notifications@55.0.27.patch
@@ -34,13 +35,13 @@ importers:
version: 2.2.0(react-native@0.83.10(patch_hash=44876634a8efbb0f2c3f66cd4332be170ec821d1cbfc0264ac80680983e8513d)(@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.8))
'@xterm/addon-unicode11':
specifier: 0.10.0-beta.300
version: 0.10.0-beta.300(@xterm/xterm@6.1.0-beta.303)
version: 0.10.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=3a6c0d12de82b2cb60b3bb93dd7f03cf25672986bce77aa3aefac986e4135163))
'@xterm/addon-webgl':
specifier: 0.20.0-beta.299
version: 0.20.0-beta.299(@xterm/xterm@6.1.0-beta.303)
version: 0.20.0-beta.299(@xterm/xterm@6.1.0-beta.303(patch_hash=3a6c0d12de82b2cb60b3bb93dd7f03cf25672986bce77aa3aefac986e4135163))
'@xterm/xterm':
specifier: 6.1.0-beta.303
version: 6.1.0-beta.303
version: 6.1.0-beta.303(patch_hash=3a6c0d12de82b2cb60b3bb93dd7f03cf25672986bce77aa3aefac986e4135163)
buffer:
specifier: ^6.0.3
version: 6.0.3
@@ -10516,11 +10517,11 @@ snapshots:
'@xmldom/xmldom@0.9.12': {}
'@xterm/addon-unicode11@0.10.0-beta.300(@xterm/xterm@6.1.0-beta.303)':
'@xterm/addon-unicode11@0.10.0-beta.300(@xterm/xterm@6.1.0-beta.303(patch_hash=3a6c0d12de82b2cb60b3bb93dd7f03cf25672986bce77aa3aefac986e4135163))':
dependencies:
'@xterm/xterm': 6.1.0-beta.303
'@xterm/addon-webgl@0.20.0-beta.299(@xterm/xterm@6.1.0-beta.303)':
'@xterm/addon-webgl@0.20.0-beta.299(@xterm/xterm@6.1.0-beta.303(patch_hash=3a6c0d12de82b2cb60b3bb93dd7f03cf25672986bce77aa3aefac986e4135163))':
dependencies:
'@xterm/xterm': 6.1.0-beta.303
+1
View File
@@ -8,6 +8,7 @@ overrides:
query-string: 9.5.1
patchedDependencies:
'@xterm/xterm@6.1.0-beta.303': patches/@xterm__xterm@6.1.0-beta.303.patch
expo-notifications@55.0.27: patches/expo-notifications@55.0.27.patch
react-native-webview@13.16.2: patches/react-native-webview@13.16.2.patch
react-native@0.83.10: patches/react-native@0.83.10.patch
@@ -0,0 +1,108 @@
// @vitest-environment happy-dom
import { Script } from 'node:vm'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { XTERM_ENGINE_JS } from './terminal-webview-engine.generated'
type CachedColor = { css: string; rgba: number }
type ContrastCache = {
setColor(bg: number, fg: number, value: CachedColor | null): void
getColor(bg: number, fg: number): CachedColor | null | undefined
setCss(bg: number, fg: number, value: string | null): void
getCss(bg: number, fg: number): string | null | undefined
_color: { _data: Record<number, Record<number, CachedColor | null>> }
_css: { _data: Record<number, Record<number, string | null>> }
}
type BundledTerminal = {
open(element: HTMLElement): void
dispose(): void
options: { theme: { background: string } }
_core: {
_themeService: { colors: { contrastCache: ContrastCache; halfContrastCache: ContrastCache } }
}
}
const entries = (cache: ContrastCache): number =>
[cache._color, cache._css].reduce(
(total, map) =>
total + Object.values(map._data).reduce((count, row) => count + Object.keys(row).length, 0),
0
)
describe('the mobile bundled xterm contrast caches', () => {
let terminal: BundledTerminal
let cache: ContrastCache
let dimCache: ContrastCache
beforeEach(() => {
vi.stubGlobal('OffscreenCanvas', undefined)
// Happy DOM has no font rasterizer; cache behavior uses the real bundled implementation.
// oxlint-disable-next-line typescript/consistent-type-assertions -- SAFETY: xterm's DOM font probe only reads font and measureText from this canvas context.
vi.spyOn(HTMLCanvasElement.prototype, 'getContext').mockReturnValue({
font: '',
measureText: () => ({ width: 8 })
} as CanvasRenderingContext2D)
const Terminal: new (options: { minimumContrastRatio: number }) => BundledTerminal = new Script(
`${XTERM_ENGINE_JS}\nwindow.Terminal`
).runInThisContext()
terminal = new Terminal({ minimumContrastRatio: 3 })
document.body.innerHTML = '<div id="terminal"></div>'
terminal.open(document.getElementById('terminal')!)
cache = terminal._core._themeService.colors.contrastCache
dimCache = terminal._core._themeService.colors.halfContrastCache
})
afterEach(() => {
terminal?.dispose()
vi.restoreAllMocks()
vi.unstubAllGlobals()
document.body.innerHTML = ''
})
it('counts cached nulls and leaves replacement writes within capacity', () => {
for (let index = 0; index < 4096; index++) {
cache.setColor(0, index, null)
}
const corrected = { css: '#ffffff', rgba: 0xffffffff }
for (let index = 0; index < 10000; index++) {
cache.setColor(0, 4095, corrected)
}
expect(cache.getColor(0, 0)).toBeNull()
expect(cache.getColor(0, 4095)).toBe(corrected)
expect(entries(cache)).toBe(4096)
cache.setColor(0, 4096, null)
expect(cache.getColor(0, 0)).toBeUndefined()
expect(entries(cache)).toBe(1)
})
it('bounds mixed color/CSS entries in both normal and dim caches', () => {
for (let index = 0; index < 100000; index++) {
cache.setColor(index, 0, null)
cache.setCss(index, 1, '#ffffff')
dimCache.setColor(index, 0, null)
dimCache.setCss(index, 1, '#eeeeee')
if (index % 2048 === 2047) {
expect(entries(cache)).toBeLessThanOrEqual(4096)
expect(entries(dimCache)).toBeLessThanOrEqual(4096)
}
}
expect(cache.getCss(99999, 1)).toBe('#ffffff')
expect(dimCache.getCss(99999, 1)).toBe('#eeeeee')
expect(entries(cache) + entries(dimCache)).toBeLessThanOrEqual(8192)
})
it('resets both capacities when the actual theme service clears them', () => {
for (let index = 0; index < 4096; index++) {
cache.setColor(index, 0, null)
dimCache.setCss(index, 0, null)
}
terminal.options.theme = { background: '#123456' }
expect(entries(cache)).toBe(0)
expect(entries(dimCache)).toBe(0)
for (let index = 0; index < 4096; index++) {
cache.setColor(index, 0, null)
dimCache.setCss(index, 0, null)
}
expect(cache.getColor(0, 0)).toBeNull()
expect(dimCache.getCss(0, 0)).toBeNull()
})
})