feat(patch): implement isolation for xterm WebGL texture atlas in NyaTerm
- Added a script to disable texture-atlas sharing in @xterm/addon-webgl for NyaTerm, ensuring each terminal instance maintains its own TextureAtlas. - Updated the patching logic to verify the expected version and handle multiple target files for both CJS and ESM bundles. - Enhanced error handling and logging for better observability during the patching process.
This commit is contained in:
1 parent
1412f314ab
commit
0c47adca87
1 file changed
+61
-73
@@ -1,14 +1,33 @@
|
||||
#!/usr/bin/env node
|
||||
/* global process, console */
|
||||
/**
|
||||
* Disable @xterm/addon-webgl cross-terminal texture-atlas sharing for NyaTerm.
|
||||
*
|
||||
* NyaTerm uses multiple live xterm WebGL instances in split panes. When xterm
|
||||
* reuses one TextureAtlas for terminals with the same render configuration,
|
||||
* clearing or rebuilding the atlas for one terminal can corrupt glyph rendering
|
||||
* in another terminal.
|
||||
*
|
||||
* Fix: remove the "reuse a matching atlas" loop from the published CJS and ESM
|
||||
* bundles, so each terminal creates and owns its own TextureAtlas.
|
||||
*
|
||||
* This script is intended for:
|
||||
* @xterm/addon-webgl@0.20.0-beta.287
|
||||
*
|
||||
* It is idempotent and fails fast when the installed package or minified bundle
|
||||
* no longer matches the expected version, so dependency upgrades cannot silently
|
||||
* reintroduce the issue.
|
||||
*/
|
||||
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
const PACKAGE_NAME = "@xterm/addon-webgl";
|
||||
const EXPECTED_VERSIONS = new Set([
|
||||
"0.20.0-beta.287",
|
||||
]);
|
||||
const EXPECTED_VERSION = "0.20.0-beta.287";
|
||||
const MARKER = "/*nyaterm:webgl-atlas-isolation*/";
|
||||
|
||||
const PATCH_MARKER = "/* nyaterm:xterm-webgl-clear-model */";
|
||||
const PACKAGE_DIR = path.resolve(
|
||||
process.cwd(),
|
||||
"node_modules",
|
||||
@@ -17,39 +36,24 @@ const PACKAGE_DIR = path.resolve(
|
||||
);
|
||||
|
||||
const PACKAGE_JSON = path.join(PACKAGE_DIR, "package.json");
|
||||
const TARGET_FILES = [
|
||||
path.join(PACKAGE_DIR, "lib", "addon-webgl.js"),
|
||||
path.join(PACKAGE_DIR, "lib", "addon-webgl.mjs"),
|
||||
|
||||
const TARGETS = [
|
||||
{
|
||||
file: path.join(PACKAGE_DIR, "lib", "addon-webgl.mjs"),
|
||||
// @xterm/addon-webgl@0.20.0-beta.287 ESM bundle
|
||||
loop:
|
||||
"for(let c=0;c<e0.length;c++){let u=e0[c];if(Ee(u.config,h))return u.ownedBy.push(i),u.atlas}",
|
||||
},
|
||||
{
|
||||
file: path.join(PACKAGE_DIR, "lib", "addon-webgl.js"),
|
||||
// @xterm/addon-webgl@0.20.0-beta.287 CJS bundle
|
||||
loop:
|
||||
"for(let e=0;e<a.length;e++){const i=a[e];if((0,r.configEquals)(i.config,c))return i.ownedBy.push(t),i.atlas}",
|
||||
},
|
||||
];
|
||||
|
||||
/*
|
||||
* @xterm/addon-webgl@0.20.0-beta.287:
|
||||
*
|
||||
* TextureAtlas.clearTexture() currently ends with:
|
||||
*
|
||||
* this._cacheMap.clear(),
|
||||
* this._cacheMapCombined.clear(),
|
||||
* this._didWarmUp = false;
|
||||
*
|
||||
* The shared atlas is cleared, but sibling renderers are not told to rebuild
|
||||
* their render model. Inject `_requestClearModel = true` before the method exits.
|
||||
*
|
||||
* This is equivalent to xterm.js PR #6018.
|
||||
*/
|
||||
const SEARCH =
|
||||
"this._cacheMap.clear(),this._cacheMapCombined.clear(),this._didWarmUp=!1}}_createNewPage(){";
|
||||
|
||||
const REPLACEMENT =
|
||||
"this._cacheMap.clear(),this._cacheMapCombined.clear(),this._didWarmUp=!1," +
|
||||
`${PATCH_MARKER}this._requestClearModel=!0}}_createNewPage(){`;
|
||||
|
||||
function fail(message) {
|
||||
console.error(`[patch-xterm-webgl-clear-model] ERROR: ${message}`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
|
||||
function readJson(filename) {
|
||||
return JSON.parse(fs.readFileSync(filename, "utf8"));
|
||||
throw new Error(`[patch-xterm-webgl-atlas] ${message}`);
|
||||
}
|
||||
|
||||
function writeFileAtomically(filename, content) {
|
||||
@@ -60,96 +64,80 @@ function writeFileAtomically(filename, content) {
|
||||
|
||||
if (!fs.existsSync(PACKAGE_JSON)) {
|
||||
fail(`${PACKAGE_NAME} is not installed: ${PACKAGE_JSON}`);
|
||||
return;
|
||||
}
|
||||
|
||||
let packageJson;
|
||||
try {
|
||||
packageJson = readJson(PACKAGE_JSON);
|
||||
packageJson = JSON.parse(fs.readFileSync(PACKAGE_JSON, "utf8"));
|
||||
} catch (error) {
|
||||
fail(
|
||||
`cannot read ${PACKAGE_JSON}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!EXPECTED_VERSIONS.has(packageJson.version)) {
|
||||
if (packageJson.version !== EXPECTED_VERSION) {
|
||||
fail(
|
||||
`unsupported ${PACKAGE_NAME} version ${JSON.stringify(packageJson.version)}; ` +
|
||||
`expected one of: ${[...EXPECTED_VERSIONS].join(", ")}. ` +
|
||||
"Review whether the upstream fix is already included before updating this script.",
|
||||
`expected ${EXPECTED_VERSION}. Review and refresh this patch before upgrading.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
let patched = 0;
|
||||
let alreadyPatched = 0;
|
||||
let already = 0;
|
||||
|
||||
for (const filename of TARGET_FILES) {
|
||||
const relative = path.relative(process.cwd(), filename);
|
||||
for (const { file, loop } of TARGETS) {
|
||||
const relative = path.relative(process.cwd(), file);
|
||||
|
||||
if (!fs.existsSync(filename)) {
|
||||
fail(`missing runtime bundle: ${relative}`);
|
||||
continue;
|
||||
if (!fs.existsSync(file)) {
|
||||
fail(`runtime bundle not found: ${relative}`);
|
||||
}
|
||||
|
||||
let source;
|
||||
try {
|
||||
source = fs.readFileSync(filename, "utf8");
|
||||
source = fs.readFileSync(file, "utf8");
|
||||
} catch (error) {
|
||||
fail(
|
||||
`cannot read ${relative}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (source.includes(MARKER)) {
|
||||
already += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (source.includes(PATCH_MARKER)) {
|
||||
alreadyPatched += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
const occurrences = source.split(SEARCH).length - 1;
|
||||
const occurrences = source.split(loop).length - 1;
|
||||
if (occurrences !== 1) {
|
||||
fail(
|
||||
`expected exactly one patch target in ${relative}, found ${occurrences}. ` +
|
||||
"The addon bundle may have changed.",
|
||||
`expected exactly one atlas-sharing loop in ${relative}, found ${occurrences}. ` +
|
||||
"The minified addon bundle may have changed.",
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextSource = source.replace(SEARCH, REPLACEMENT);
|
||||
const patchedSource = source.replace(loop, MARKER);
|
||||
|
||||
if (!nextSource.includes(PATCH_MARKER)) {
|
||||
if (!patchedSource.includes(MARKER)) {
|
||||
fail(`patch verification failed for ${relative}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
writeFileAtomically(filename, nextSource);
|
||||
writeFileAtomically(file, patchedSource);
|
||||
} catch (error) {
|
||||
fail(
|
||||
`cannot write ${relative}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
patched += 1;
|
||||
}
|
||||
|
||||
if (process.exitCode) {
|
||||
console.error(
|
||||
`[patch-xterm-webgl-clear-model] failed: patched=${patched}, ` +
|
||||
`already=${alreadyPatched}, total=${TARGET_FILES.length}`,
|
||||
);
|
||||
} else {
|
||||
console.log(
|
||||
`[patch-xterm-webgl-clear-model] success: patched=${patched}, ` +
|
||||
`already=${alreadyPatched}, total=${TARGET_FILES.length}`,
|
||||
);
|
||||
}
|
||||
console.log(
|
||||
`[patch-xterm-webgl-atlas] atlas isolation: ` +
|
||||
`patched=${patched} already=${already} total=${TARGETS.length}`,
|
||||
);
|
||||
Reference in new issue
Block a user