feat: harden plugin api for separate repos, signed updates and generic external login

This commit is contained in:
LukeGus committed 2026-09-29 18:11:59 -05:00
1 parent fb9c168b6b
commit 80944301dd
310 files changed
+6577 -4927

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
/**
* Applies every workspace plugin's dependency patches after npm install, so
* the Vite dev server serves the same patched libraries a build bundles.
*/
const fs = require("node:fs");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const root = path.resolve(__dirname, "..");
const cli = path.join(root, "packages", "plugin-sdk", "cli", "index.mjs");
const plugins = path.join(root, "plugins");
for (const id of fs.readdirSync(plugins)) {
const dir = path.join(plugins, id);
const pkgPath = path.join(dir, "package.json");
if (!fs.existsSync(pkgPath)) continue;
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
if (!pkg.termix?.patches?.length && !fs.existsSync(path.join(dir, "patches")))
continue;
const result = spawnSync(process.execPath, [cli, "patch"], {
cwd: dir,
stdio: "inherit",
});
if (result.status !== 0) process.exit(result.status ?? 1);
}
+140
View File
@@ -0,0 +1,140 @@
/**
* Builds the two pieces of the plugin SDK that come from core:
*
* dist/host/ui.js @termix/plugin-sdk/ui, the shell's UI kit
* dist/host/testing-host.js what renderWithApp() renders plugins into
*
* Inside Termix the page's import map points @termix/plugin-sdk/ui at the
* shell's own copy, so ui.js is never loaded there. It exists so a plugin in
* its own repo can typecheck and run its tests without a Termix checkout.
*
* One esbuild run with code splitting, so both entries share one copy of the
* registries: a test host with its own registries would never see what the
* plugin's components registered.
*/
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import { spawnSync } from "node:child_process";
import * as esbuild from "esbuild";
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const outDir = path.join(root, "packages", "plugin-sdk", "dist", "host");
// Shared with the plugin, so never a second copy. Same list the page's import
// map provides, plus the test library the host renders with.
const EXTERNALS = [
"react",
"react-dom",
"react/jsx-runtime",
"react-dom/client",
"i18next",
"react-i18next",
"sonner",
"@termix/plugin-sdk/frontend",
"@testing-library/react",
];
fs.rmSync(outDir, { recursive: true, force: true });
await esbuild.build({
entryPoints: {
ui: path.join(root, "src/ui/plugin-host/sdk-ui.ts"),
"testing-host": path.join(root, "src/ui/plugin-host/testing-host.tsx"),
},
outdir: outDir,
bundle: true,
splitting: true,
format: "esm",
platform: "browser",
target: "es2022",
jsx: "automatic",
logLevel: "warning",
external: EXTERNALS,
alias: {
"@/types": path.join(root, "src/types"),
"@": path.join(root, "src/ui"),
},
loader: {
".css": "empty",
".svg": "dataurl",
".png": "dataurl",
".woff": "empty",
".woff2": "empty",
".ttf": "empty",
},
// import.meta.glob is Vite's; outside Termix the host ships English only,
// so the translated locale files it would list are simply none.
banner: { js: "const __termixNoGlob = () => ({});" },
define: {
"import.meta.glob": "__termixNoGlob",
"import.meta.env.DEV": "false",
"import.meta.env.PROD": "true",
"import.meta.env.MODE": '"production"',
"import.meta.env.BASE_URL": '"/"',
"import.meta.env":
'{"DEV":false,"PROD":true,"MODE":"production","SSR":false,"BASE_URL":"/"}',
},
});
// Types for @termix/plugin-sdk/ui: the declarations tsc emits for sdk-ui.ts
// and everything it reaches, with core's "@/" paths made relative.
const typesDir = path.join(outDir, "types");
const tsc = spawnSync(
process.execPath,
[
path.join(root, "node_modules", "typescript", "bin", "tsc"),
"-p",
path.join(root, "tsconfig.sdk-ui.json"),
"--outDir",
typesDir,
],
{ cwd: root, stdio: "inherit" },
);
if (tsc.status !== 0) process.exit(tsc.status ?? 1);
// tsc follows @termix/plugin-sdk/frontend into the SDK's source; plugins get
// those types from the package itself.
fs.rmSync(path.join(typesDir, "packages"), { recursive: true, force: true });
const srcTypes = path.join(typesDir, "src");
function rewrite(dir) {
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const file = path.join(dir, entry.name);
if (entry.isDirectory()) {
rewrite(file);
continue;
}
if (!entry.name.endsWith(".d.ts")) continue;
const text = fs
.readFileSync(file, "utf8")
.replace(
/(["'])@\/(types(?=[/"'])|)([^"']*)\1/g,
(_match, quote, types, rest) => {
const target = types
? path.join(srcTypes, "types", rest.replace(/^\//, ""))
: path.join(srcTypes, "ui", rest);
let relative = path
.relative(path.dirname(file), target)
.replaceAll("\\", "/")
.replace(/\.tsx?$/, "");
if (!relative.startsWith(".")) relative = `./${relative}`;
return `${quote}${relative}${quote}`;
},
);
fs.writeFileSync(file, text);
}
}
rewrite(srcTypes);
fs.writeFileSync(
path.join(outDir, "ui.d.ts"),
'export * from "./types/src/ui/plugin-host/sdk-ui.js";\n',
);
// The design tokens termix-plugin build compiles a plugin's classes against.
fs.copyFileSync(
path.join(root, "src/ui/theme.css"),
path.join(outDir, "theme.css"),
);
console.log(`Built the SDK host modules into ${path.relative(root, outDir)}`);
+44 -10
View File
@@ -10,7 +10,13 @@
* through the registries instead. Regex literals are read too, so
* /^\/plugin-api\/docker/ counts the same as "/plugin-api/docker".
*
* Tests and locales are exempt, and so is src/backend/upgrade/: the one-time
* Ids come from every plugin manifest and from docker/bundled-plugins.json, so
* the check still means something once plugins live in their own repos. An
* action, slot or extension point id a plugin owns ("terminal.open") counts
* too, whatever its prefix. electron/ and vite.config.ts are read as well.
*
* src/backend/tests, src/ui/tests and src/ui/locales are exempt, and so is
* src/backend/upgrade/: the one-time
* 2.8 to 2.9 data moves have to name the plugin each piece of data moves to,
* the same way LEGACY_TABLE_OWNERS in the SDK names the plugin that adopts
* each legacy table.
@@ -23,7 +29,7 @@
const fs = require("node:fs");
const path = require("node:path");
const EXEMPT_DIRS = new Set(["tests", "locales"]);
const { owners } = require("./check-plugin-uses.cjs");
const SSH_TERMS = new Set(["totp"]);
function paths(root) {
@@ -33,12 +39,20 @@ function paths(root) {
src,
ui: path.join(src, "ui"),
plugins: path.join(root, "plugins"),
exempt: [path.join(src, "backend", "upgrade")],
bundled: path.join(root, "docker", "bundled-plugins.json"),
extra: [path.join(root, "electron"), path.join(root, "vite.config.ts")],
exempt: [
path.join(src, "backend", "upgrade"),
path.join(src, "backend", "tests"),
path.join(src, "ui", "tests"),
path.join(src, "ui", "locales"),
],
sshConnect: path.join(src, "backend", "hosts", "connect"),
};
}
function manifests(dir) {
if (!fs.existsSync(dir)) return [];
return fs
.readdirSync(dir, { withFileTypes: true })
.filter((entry) => entry.isDirectory())
@@ -47,9 +61,15 @@ function manifests(dir) {
.map((file) => JSON.parse(fs.readFileSync(file, "utf8")));
}
function names(pluginsDir) {
function names(pluginsDir, bundledFile) {
const ids = new Set();
const views = new Set();
if (bundledFile && fs.existsSync(bundledFile)) {
for (const entry of JSON.parse(fs.readFileSync(bundledFile, "utf8"))
.plugins ?? []) {
if (typeof entry?.id === "string") ids.add(entry.id);
}
}
for (const manifest of manifests(pluginsDir)) {
ids.add(manifest.id);
const contributes = manifest.contributes ?? {};
@@ -65,11 +85,15 @@ function names(pluginsDir) {
}
function walk(dir, exempt, out) {
if (exempt.includes(dir)) return out;
if (exempt.includes(dir) || !fs.existsSync(dir)) return out;
if (fs.statSync(dir).isFile()) {
out.push(dir);
return out;
}
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const full = path.join(dir, entry.name);
if (entry.isDirectory()) {
if (EXEMPT_DIRS.has(entry.name)) continue;
if (entry.name === "node_modules") continue;
walk(full, exempt, out);
} else if (/\.(tsx?|mjs|cjs|jsx?)$/.test(entry.name)) {
out.push(full);
@@ -107,16 +131,25 @@ function regexLiterals(source) {
}
function scan(root = path.resolve(__dirname, "..")) {
const { src, ui, plugins, exempt, sshConnect } = paths(root);
const { ids, views } = names(plugins);
const { src, ui, plugins, bundled, extra, exempt, sshConnect } = paths(root);
const { ids, views } = names(plugins, bundled);
const owned = fs.existsSync(plugins) ? owners(plugins) : new Map();
const found = {};
const add = (file, what) => {
const key = path.relative(root, file).replaceAll("\\", "/");
(found[key] ??= new Set()).add(what);
};
for (const file of walk(src, exempt, [])) {
const source = fs.readFileSync(file, "utf8");
const files = walk(src, exempt, []);
for (const target of extra) walk(target, exempt, files);
for (const file of files) {
// A line marked "plugin-id-ok" names a plugin on purpose, such as a key
// an older release stored; the marker has to say why.
const source = fs
.readFileSync(file, "utf8")
.split("\n")
.filter((line) => !/plugin-id-ok: \S/.test(line))
.join("\n");
const inShell = file.startsWith(ui + path.sep);
const inConnect = file.startsWith(sshConnect + path.sep);
for (const match of source.matchAll(
@@ -141,6 +174,7 @@ function scan(root = path.resolve(__dirname, "..")) {
}
const prefix = /^([a-z][a-z0-9-]*)\.[a-zA-Z]/.exec(text);
if (prefix && ids.has(prefix[1])) add(file, text);
if (owned.has(text)) add(file, `${text} (owned by ${owned.get(text)})`);
}
for (const pattern of regexLiterals(source)) {
for (const route of pattern.matchAll(
+31
View File
@@ -81,3 +81,34 @@ describe("scan", () => {
});
});
});
describe("scan beyond src with plugins elsewhere", () => {
it("knows the bundled ids even when plugins/ is empty", () => {
const dir = fixture({
"docker/bundled-plugins.json": JSON.stringify({
plugins: [{ id: "tunnels", source: "workspace" }],
}),
"src/ui/x.ts": 'const route = "/plugin-ws/tunnels/c2s";\n',
});
fs.rmSync(path.join(dir, "plugins"), { recursive: true, force: true });
expect(scan(dir)["src/ui/x.ts"]).toContain("/plugin-ws/tunnels");
});
it("reads electron and catches an action a plugin owns", () => {
const dir = fixture({
"plugins/docker/src/frontend/index.tsx":
'app.registerAction("terminal.open", fn);\n',
"electron/main.cjs": 'invoke("terminal.open");\n',
});
expect(scan(dir)["electron/main.cjs"]).toEqual([
"terminal.open (owned by docker)",
]);
});
it("skips a line marked plugin-id-ok with a reason", () => {
const dir = fixture({
"src/types/a.ts": 'const legacy = "docker"; // plugin-id-ok: 2.8 key\n',
});
expect(scan(dir)).toEqual({});
});
});
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env node
/**
* A plugin that calls another plugin's action, fills its slot or extends its
* extension point has to say so in manifest.json contributes.uses. Nothing
* else records that coupling: the ids are plain strings, so without it a
* plugin moved to its own repo silently loses a feature when the other one is
* renamed, and nobody can tell what depends on what.
*
* Owners are read from the code: a plugin owns the actions it registers, the
* slots it renders (ActionSlot/ComponentSlot/useSlotContributions/
* declareActionSlot) and the extension points it reads (useExtensions). An id
* nobody owns is core's and needs no declaration.
*
* --list prints what each plugin uses from which owner.
*/
const fs = require("node:fs");
const path = require("node:path");
const ROOT = path.resolve(__dirname, "..");
const PLUGINS = path.join(ROOT, "plugins");
// Each pattern captures a string literal or a constant's name; constants are
// resolved against the plugin's own `const NAME = "id"` declarations.
const ID = String.raw`(?:"([^"]+)"|([A-Z][A-Z0-9_]*))`;
const OWNS = [
new RegExp(String.raw`registerAction\(\s*${ID}`, "g"),
new RegExp(String.raw`declareActionSlot\(\s*\{\s*id:\s*${ID}`, "g"),
new RegExp(
String.raw`<(?:ActionSlot|ComponentSlot)[^>]*?slotId=\{?${ID}`,
"g",
),
new RegExp(String.raw`useSlotContributions\(\s*${ID}`, "g"),
new RegExp(String.raw`useExtensions(?:<[^>]*>)?\(\s*${ID}`, "g"),
new RegExp(String.raw`getExtension\(\s*${ID}`, "g"),
];
const USES = [
new RegExp(String.raw`invokeAction\(\s*${ID}`, "g"),
new RegExp(String.raw`registerSlotContribution\(\s*${ID}`, "g"),
new RegExp(String.raw`registerExtension\(\s*${ID}`, "g"),
new RegExp(
String.raw`<(?:ActionSlot|ComponentSlot)[^>]*?slotId=\{?${ID}`,
"g",
),
new RegExp(String.raw`useSlotContributions\(\s*${ID}`, "g"),
];
function sourceOf(dir) {
let text = "";
const walk = (current) => {
if (!fs.existsSync(current)) return;
for (const entry of fs.readdirSync(current, { withFileTypes: true })) {
const file = path.join(current, entry.name);
if (entry.isDirectory()) walk(file);
else if (/\.(ts|tsx)$/.test(entry.name)) {
text += fs.readFileSync(file, "utf8");
}
}
};
walk(path.join(dir, "src"));
return text;
}
function constants(text) {
const map = new Map();
for (const match of text.matchAll(
/const ([A-Z][A-Z0-9_]*)\s*=\s*"([^"]+)"/g,
)) {
map.set(match[1], match[2]);
}
return map;
}
function matches(patterns, text) {
const names = constants(text);
const found = new Set();
for (const pattern of patterns) {
for (const match of text.matchAll(pattern)) {
const id = match[1] ?? names.get(match[2]);
if (id) found.add(id);
}
}
return found;
}
function collect(pluginsDir = PLUGINS) {
const plugins = fs
.readdirSync(pluginsDir, { withFileTypes: true })
.filter((entry) =>
fs.existsSync(path.join(pluginsDir, entry.name, "manifest.json")),
)
.map((entry) => {
const dir = path.join(pluginsDir, entry.name);
const manifest = JSON.parse(
fs.readFileSync(path.join(dir, "manifest.json"), "utf8"),
);
const source = sourceOf(dir);
return {
id: manifest.id,
declared: new Set(manifest.contributes?.uses ?? []),
owns: matches(OWNS, source),
uses: matches(USES, source),
};
});
const owner = new Map();
for (const plugin of plugins) {
for (const id of plugin.owns) if (!owner.has(id)) owner.set(id, plugin.id);
}
const problems = [];
const usage = {};
for (const plugin of plugins) {
for (const id of plugin.uses) {
const by = owner.get(id);
if (!by || by === plugin.id || plugin.owns.has(id)) continue;
(usage[plugin.id] ??= []).push(`${id} (${by})`);
if (!plugin.declared.has(id)) {
problems.push(
`${plugin.id} uses "${id}" from the ${by} plugin; add it to contributes.uses in plugins/${plugin.id}/manifest.json`,
);
}
}
for (const id of plugin.declared) {
if (!plugin.uses.has(id)) {
problems.push(
`${plugin.id} declares "${id}" in contributes.uses but never uses it`,
);
}
}
}
return { problems, usage, owner };
}
function main() {
const { problems, usage } = collect();
if (process.argv.includes("--list")) {
console.log(JSON.stringify(usage, null, 2));
return;
}
if (problems.length === 0) return;
for (const problem of problems) console.error(problem);
process.exit(1);
}
if (require.main === module) main();
/** Every action, slot and extension point id a plugin owns, by owner. */
function owners(pluginsDir = PLUGINS) {
return collect(pluginsDir).owner;
}
module.exports = { collect, owners };
+70
View File
@@ -0,0 +1,70 @@
import { afterEach, describe, expect, it } from "vitest";
import { createRequire } from "node:module";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
const require = createRequire(import.meta.url);
const { collect } = require("./check-plugin-uses.cjs") as {
collect: (dir: string) => { problems: string[]; owner: Map<string, string> };
};
let root: string | null = null;
afterEach(() => {
if (root) fs.rmSync(root, { recursive: true, force: true });
root = null;
});
function plugins(
specs: Record<string, { source: string; uses?: string[] }>,
): string {
root = fs.mkdtempSync(path.join(os.tmpdir(), "plugin-uses-"));
for (const [id, spec] of Object.entries(specs)) {
const dir = path.join(root, id);
fs.mkdirSync(path.join(dir, "src"), { recursive: true });
fs.writeFileSync(
path.join(dir, "manifest.json"),
JSON.stringify({ id, contributes: spec.uses ? { uses: spec.uses } : {} }),
);
fs.writeFileSync(path.join(dir, "src", "index.tsx"), spec.source);
}
return root;
}
describe("check-plugin-uses", () => {
it("finds owners through string literals and constants", () => {
const dir = plugins({
terminal: {
source:
'const SLOT = "terminal.toolbar";\napp.declareActionSlot({ id: SLOT });\napp.registerAction("terminal.open", f);\n',
},
});
const { owner } = collect(dir);
expect(owner.get("terminal.open")).toBe("terminal");
expect(owner.get("terminal.toolbar")).toBe("terminal");
});
it("requires another plugin's id in contributes.uses", () => {
const dir = plugins({
terminal: { source: 'app.registerAction("terminal.open", f);\n' },
files: { source: 'invokeAction("terminal.open", host);\n' },
});
expect(collect(dir).problems).toEqual([
expect.stringContaining('files uses "terminal.open" from the terminal'),
]);
});
it("passes a declared use and flags a declared one that is unused", () => {
const dir = plugins({
terminal: { source: 'app.registerAction("terminal.open", f);\n' },
files: {
source: 'invokeAction("terminal.open", host);\n',
uses: ["terminal.open", "terminal.gone"],
},
});
expect(collect(dir).problems).toEqual([
expect.stringContaining('declares "terminal.gone"'),
]);
});
});
+78 -16
View File
@@ -106,16 +106,6 @@ const ALLOWED = [
"Label",
"LineChart",
"LineChartSeries",
"LocalCollectedFile",
"LocalDirectoryListing",
"LocalFileEntry",
"LocalFsHomeInfo",
"LocalFsResult",
"LocalTransferOrigin",
"LocalTransferProgress",
"LocalTrashResult",
"LocalWalkFile",
"LocalWalkResult",
"LogContext",
"LogEntry",
"MFAPromptMode",
@@ -178,9 +168,7 @@ const ALLOWED = [
"VersionInfo",
"WebSocketConnectionTarget",
"WidgetTitle",
"badgeVariants",
"buildOriginWsUrl",
"buttonVariants",
"cn",
"copyToClipboard",
"createFrontendLogger",
@@ -195,12 +183,9 @@ const ALLOWED = [
"getUptime",
"getVersionInfo",
"globalShortcutHandler",
"guacStateToStage",
"guacStateToStatus",
"hydrateLocalSharedHostAuth",
"isElectron",
"isMacPlatform",
"isQuickConnectHost",
"isTabJumpHotkey",
"linkedServerUrl",
"markAdaptiveResourceUsed",
@@ -243,6 +228,53 @@ function exportedNames(file = SDK_UI, tsconfig = TSCONFIG) {
.sort();
}
const SNAPSHOT = path.join(ROOT, "packages", "plugin-sdk", "ui.api.txt");
/**
* One line per export with its type as the compiler prints it. Committed as
* packages/plugin-sdk/ui.api.txt, so a changed prop or signature shows up as
* a diff instead of reaching plugins unnoticed.
*/
function exportSignatures(file = SDK_UI, tsconfig = TSCONFIG) {
const ts = require("typescript");
const parsed = ts.getParsedCommandLineOfConfigFile(
tsconfig,
{},
{ ...ts.sys, onUnRecoverableConfigFileDiagnostic: () => {} },
);
const program = ts.createProgram([file], {
...(parsed?.options ?? {}),
noEmit: true,
});
const checker = program.getTypeChecker();
const source = program.getSourceFile(file);
const symbol = source && checker.getSymbolAtLocation(source);
const flags =
ts.TypeFormatFlags.NoTruncation |
ts.TypeFormatFlags.WriteArrowStyleSignature;
return checker
.getExportsOfModule(symbol)
.map((entry) => {
const target =
entry.flags & ts.SymbolFlags.Alias
? checker.getAliasedSymbol(entry)
: entry;
const isValue = !!(target.flags & ts.SymbolFlags.Value);
const declaration = target.declarations?.[0] ?? source;
const type = isValue
? checker.getTypeOfSymbolAtLocation(target, declaration)
: checker.getDeclaredTypeOfSymbol(target);
const text = checker
.typeToString(type, undefined, flags)
.replace(/import\("[^"]*"\)\./g, "")
.replace(/\s+/g, " ");
return `${entry.getName()} (${isValue ? "value" : "type"}): ${text}`;
})
.sort()
.join("\n")
.concat("\n");
}
/** What the module adds to, and drops from, the allowlist. */
function compare(names, allowed = ALLOWED) {
const allowedSet = new Set(allowed);
@@ -254,6 +286,36 @@ function compare(names, allowed = ALLOWED) {
}
function main() {
const fs = require("node:fs");
if (/^export (type )?\*/m.test(fs.readFileSync(SDK_UI, "utf8"))) {
console.error(
"sdk-ui.ts uses export *; name each export so nothing becomes public API by accident.",
);
process.exit(1);
}
if (process.argv.includes("--update-snapshot")) {
fs.writeFileSync(SNAPSHOT, exportSignatures());
console.log(`Wrote ${path.relative(ROOT, SNAPSHOT)}`);
return;
}
const saved = fs.existsSync(SNAPSHOT)
? fs.readFileSync(SNAPSHOT, "utf8")
: "";
const current = exportSignatures();
if (current !== saved) {
const before = new Set(saved.split("\n"));
const after = new Set(current.split("\n"));
console.error(
"@termix/plugin-sdk/ui changed shape. If that is deliberate, run: node scripts/check-sdk-ui-exports.cjs --update-snapshot",
);
for (const line of saved.split("\n")) {
if (line && !after.has(line)) console.error(` - ${line.slice(0, 200)}`);
}
for (const line of current.split("\n")) {
if (line && !before.has(line)) console.error(` + ${line.slice(0, 200)}`);
}
process.exitCode = 1;
}
const names = exportedNames();
if (process.argv.includes("--list")) {
console.log(names.join("\n"));
@@ -278,4 +340,4 @@ function main() {
if (require.main === module) main();
module.exports = { ALLOWED, compare, exportedNames };
module.exports = { ALLOWED, compare, exportedNames, exportSignatures };
+40
View File
@@ -0,0 +1,40 @@
/**
* Installs the native dependencies bundled plugins declare. esbuild leaves a
* nativeDependencies package out of the plugin bundle, so the server's own
* node_modules has to have it. Run in the production image after npm ci.
*/
const fs = require("node:fs");
const path = require("node:path");
const { spawnSync } = require("node:child_process");
const root = path.resolve(__dirname, "..");
const plugins = path.resolve(root, process.argv[2] ?? "plugins");
const wanted = new Map();
for (const id of fs.existsSync(plugins) ? fs.readdirSync(plugins) : []) {
const manifestPath = path.join(plugins, id, "manifest.json");
const pkgPath = path.join(plugins, id, "package.json");
if (!fs.existsSync(manifestPath) || !fs.existsSync(pkgPath)) continue;
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
for (const name of manifest.nativeDependencies ?? []) {
const range = pkg.dependencies?.[name];
if (!range) {
console.error(`${id}: native dependency ${name} has no version`);
process.exit(1);
}
wanted.set(name, range);
}
}
if (wanted.size === 0) process.exit(0);
const specs = [...wanted].map(([name, range]) => `${name}@${range}`);
console.log(`Installing native plugin dependencies: ${specs.join(" ")}`);
const result = spawnSync(
"npm",
["install", "--no-save", "--omit=dev", "--no-audit", "--no-fund", ...specs],
{ cwd: root, stdio: "inherit", shell: process.platform === "win32" },
);
process.exit(result.status ?? 1);
-68
View File
@@ -1,68 +0,0 @@
const fs = require("fs");
const path = require("path");
const packageRoot = path.join(
__dirname,
"..",
"node_modules",
"guacamole-common-js",
);
const bundlePaths = [
path.join(packageRoot, "dist", "esm", "guacamole-common.js"),
path.join(packageRoot, "dist", "cjs", "guacamole-common.js"),
];
const oldFlushBlock =
" if (window.requestAnimationFrame && document.hasFocus())\n" +
" asyncFlush();\n" +
" else\n" +
" syncFlush();";
const newFlushBlock =
" // Electron can throttle or skip requestAnimationFrame() for inactive\n" +
" // windows/tabs even while guacd is still sending display frames. Flush\n" +
" // synchronously so Guacamole connections do not stall while waiting for\n" +
" // a frame callback that may never run.\n" +
" syncFlush();";
let patched = false;
let foundBundle = false;
for (const bundlePath of bundlePaths) {
if (!fs.existsSync(bundlePath)) {
console.log(
`[patch-guacamole-common-js] ${bundlePath} not found, skipping`,
);
continue;
}
foundBundle = true;
let content = fs.readFileSync(bundlePath, "utf8");
if (content.includes(newFlushBlock)) continue;
if (!content.includes(oldFlushBlock)) {
console.log(
`[patch-guacamole-common-js] Flush target not found in ${bundlePath}, skipping`,
);
continue;
}
content = content.replace(oldFlushBlock, newFlushBlock);
fs.writeFileSync(bundlePath, content);
patched = true;
}
if (!foundBundle) {
console.log("[patch-guacamole-common-js] File not found, skipping");
process.exit(0);
}
if (!patched) {
console.log("[patch-guacamole-common-js] Already patched");
process.exit(0);
}
console.log(
"[patch-guacamole-common-js] Patched display flush to avoid Electron requestAnimationFrame stalls",
);
-403
View File
@@ -1,403 +0,0 @@
const fs = require("fs");
const path = require("path");
const guacdClientPath = path.join(
__dirname,
"..",
"node_modules",
"guacamole-lite",
"lib",
"GuacdClient.js",
);
const cryptPath = path.join(
__dirname,
"..",
"node_modules",
"guacamole-lite",
"lib",
"Crypt.js",
);
const clientConnectionPath = path.join(
__dirname,
"..",
"node_modules",
"guacamole-lite",
"lib",
"ClientConnection.js",
);
const serverPath = path.join(
__dirname,
"..",
"node_modules",
"guacamole-lite",
"lib",
"Server.js",
);
if (
!fs.existsSync(guacdClientPath) ||
!fs.existsSync(cryptPath) ||
!fs.existsSync(clientConnectionPath) ||
!fs.existsSync(serverPath)
) {
console.log("[patch-guacamole-lite] File not found, skipping");
process.exit(0);
}
// Every patch below is required for correctness: protocol negotiation, the
// guacd 1.6.0 name handshake, dynamic argument answering, UTF-8 tokens and
// read-only joins. If an upstream release moves an anchor string, silently
// skipping would ship a Termix that looks fine and then drops VNC/RDP sessions
// at runtime, so a missing anchor has to stop the install instead.
function missingAnchor(patch) {
console.error(
`[patch-guacamole-lite] ${patch} anchor not found in guacamole-lite. ` +
"The upstream file has changed and this patch no longer applies — " +
"update scripts/patch-guacamole-lite.cjs to match the new source.",
);
process.exit(1);
}
let guacdClientContent = fs.readFileSync(guacdClientPath, "utf8");
let cryptContent = fs.readFileSync(cryptPath, "utf8");
let clientConnectionContent = fs.readFileSync(clientConnectionPath, "utf8");
let serverContent = fs.readFileSync(serverPath, "utf8");
// Patch 1: protocol version negotiation.
// guacamole-lite originally only accepted 1.0.0/1.1.0. Support the protocol
// versions Termix can handle, and conservatively answer future 1.x versions as
// VERSION_1_5_0 so guacd still sees support for `require`/`name` without us
// claiming support for unknown instructions.
const oldVersionBlock =
" if (version === '1_0_0' || version === '1_1_0') {\n" +
" protocolVersion = version;\n" +
" } else {\n" +
" protocolVersion = '1_1_0';\n" +
" }";
const oldPatchedVersionBlock =
" if (version === '1_0_0' || version === '1_1_0' || version === '1_3_0' || version === '1_5_0') {\n" +
" protocolVersion = version;\n" +
" } else {\n" +
" protocolVersion = '1_1_0';\n" +
" }";
const newVersionBlock =
" if (version === '1_0_0' || version === '1_1_0' || version === '1_3_0' || version === '1_5_0') {\n" +
" protocolVersion = version;\n" +
" } else if (/^1_\\d+_0$/.test(version)) {\n" +
" protocolVersion = '1_5_0';\n" +
" } else {\n" +
" protocolVersion = '1_1_0';\n" +
" }";
// Patch 2: timezone instruction must be sent for all protocols >= 1.1.0, not just 1.1.0
const oldTimezone = "if (protocolVersion === '1_1_0') {";
const newTimezone = "if (protocolVersion !== '1_0_0') {";
// Patch 3: send the `name` handshake instruction for all protocol versions >= 1.1.0.
// The Guacamole protocol added `name` in 1.3.0, but guacd 1.6.0 began requiring it
// during the VNC handshake even when negotiating VERSION_1_1_0, causing connections to
// silently drop right after "User joined". Sending it for all non-1.0.0 sessions is
// harmless (guacd ignores unknown handshake instructions for older versions). See
// Termix-SSH/Support#567 and #734.
const oldConnect =
" this.sendInstruction(['connect'].concat(connectArgs));";
const oldNameConnect =
" if (protocolVersion === '1_3_0' || protocolVersion === '1_5_0') {\n" +
" this.sendInstruction(['name', this.connectionSettings.name || 'guacamole-lite']);\n" +
" }\n" +
"\n" +
" this.sendInstruction(['connect'].concat(connectArgs));";
const newConnect =
" if (protocolVersion !== '1_0_0') {\n" +
" this.sendInstruction(['name', this.connectionSettings.name || 'guacamole-lite']);\n" +
" }\n" +
"\n" +
" this.sendInstruction(['connect'].concat(connectArgs));";
// Patch 4: answer guacd's dynamic argument requests locally.
// macOS Screen Sharing can request VNC username/password through the
// post-handshake `required`/`require` flow. guacamole-lite forwards those
// instructions to the browser, but Termix already keeps the credentials in the
// server-side token and the browser does not provide an onrequired handler.
const oldSendBuffer =
" this.lastActivity = Date.now();\n" + " this.sendBuffer = '';";
const newSendBuffer =
" this.lastActivity = Date.now();\n" +
" this.sendBuffer = '';\n" +
" this.nextArgumentStreamIndex = 0;";
const oldSendInstructionBlock =
" sendInstruction(instruction) {\n" +
" // convert every element in the instruction array to a string. convert null to an empty string\n" +
" instruction = instruction.map((element) => {\n" +
" if (element === null || element === undefined) {\n" +
" return '';\n" +
" }\n" +
" return String(element);\n" +
" });\n" +
"\n" +
" const instructionString = GuacamoleParser.toInstruction(instruction);\n" +
" this.send(instructionString);\n" +
" }\n";
const newSendInstructionBlock =
oldSendInstructionBlock +
"\n" +
" sendArgumentValue(name, value) {\n" +
" const stream = this.nextArgumentStreamIndex++;\n" +
" this.sendInstruction(['argv', stream, 'text/plain', name]);\n" +
" this.sendInstruction(['blob', stream, Buffer.from(String(value ?? ''), 'utf8').toString('base64')]);\n" +
" this.sendInstruction(['end', stream]);\n" +
" }\n" +
"\n" +
" sendRequiredArguments(params) {\n" +
" params.forEach((name) => {\n" +
" this.sendArgumentValue(name, this.connectionSettings[name]);\n" +
" });\n" +
" }\n";
const oldReadyHandler =
' // Handle "ready" instruction\n' +
" if (opcode === 'ready') {";
const newReadyHandler =
" // Handle dynamic argument requests from guacd\n" +
" if (opcode === 'required' || opcode === 'require') {\n" +
" this.sendRequiredArguments(params);\n" +
" return;\n" +
" }\n" +
"\n" +
oldReadyHandler;
let patched = false;
if (!guacdClientContent.includes("} else if (/^1_\\d+_0$/.test(version)) {")) {
if (guacdClientContent.includes(oldPatchedVersionBlock)) {
guacdClientContent = guacdClientContent.replace(
oldPatchedVersionBlock,
newVersionBlock,
);
} else if (guacdClientContent.includes(oldVersionBlock)) {
guacdClientContent = guacdClientContent.replace(
oldVersionBlock,
newVersionBlock,
);
} else {
missingAnchor("Version check");
}
patched = true;
}
if (!guacdClientContent.includes(newTimezone)) {
if (!guacdClientContent.includes(oldTimezone)) {
missingAnchor("Timezone");
}
guacdClientContent = guacdClientContent.replace(oldTimezone, newTimezone);
patched = true;
}
if (!guacdClientContent.includes(newConnect)) {
if (guacdClientContent.includes(oldNameConnect)) {
guacdClientContent = guacdClientContent.replace(oldNameConnect, newConnect);
} else if (guacdClientContent.includes(oldConnect)) {
guacdClientContent = guacdClientContent.replace(oldConnect, newConnect);
} else {
missingAnchor("Connect");
}
patched = true;
}
if (!guacdClientContent.includes("this.nextArgumentStreamIndex = 0;")) {
if (!guacdClientContent.includes(oldSendBuffer)) {
missingAnchor("Argument stream index");
}
guacdClientContent = guacdClientContent.replace(oldSendBuffer, newSendBuffer);
patched = true;
}
if (!guacdClientContent.includes("sendRequiredArguments(params) {")) {
if (!guacdClientContent.includes(oldSendInstructionBlock)) {
missingAnchor("Required argument helper");
}
guacdClientContent = guacdClientContent.replace(
oldSendInstructionBlock,
newSendInstructionBlock,
);
patched = true;
}
if (
!guacdClientContent.includes("opcode === 'required' || opcode === 'require'")
) {
if (!guacdClientContent.includes(oldReadyHandler)) {
missingAnchor("Required opcode");
}
guacdClientContent = guacdClientContent.replace(
oldReadyHandler,
newReadyHandler,
);
patched = true;
}
// Patch 5: guacamole-lite decrypts token JSON through ASCII/binary strings,
// which corrupts IV/ciphertext bytes and non-ASCII connection settings such as
// RDP/VNC passwords with umlauts. Keep the encrypted fields as Buffers and
// decode the plaintext JSON as UTF-8.
const oldDecryptBlock =
" let encoded = JSON.parse(this.constructor.base64decode(encodedString));\n" +
"\n" +
" encoded.iv = this.constructor.base64decode(encoded.iv);\n" +
" encoded.value = this.constructor.base64decode(encoded.value, 'binary');\n" +
"\n" +
" const decipher = Crypto.createDecipheriv(this.cypher, this.key, encoded.iv);\n" +
"\n" +
" let decrypted = decipher.update(encoded.value, 'binary', 'ascii');\n" +
" decrypted += decipher.final('ascii');";
const oldPartiallyPatchedDecryptBlock =
" let encoded = JSON.parse(this.constructor.base64decode(encodedString));\n" +
"\n" +
" encoded.iv = this.constructor.base64decode(encoded.iv);\n" +
" encoded.value = this.constructor.base64decode(encoded.value, 'binary');\n" +
"\n" +
" const decipher = Crypto.createDecipheriv(this.cypher, this.key, encoded.iv);\n" +
"\n" +
" let decrypted = decipher.update(encoded.value, 'binary', 'utf8');\n" +
" decrypted += decipher.final('utf8');";
const newDecryptBlock =
" const encoded = JSON.parse(Buffer.from(encodedString, 'base64').toString('utf8'));\n" +
"\n" +
" const iv = Buffer.from(encoded.iv, 'base64');\n" +
" const value = Buffer.from(encoded.value, 'base64');\n" +
"\n" +
" const decipher = Crypto.createDecipheriv(this.cypher, this.key, iv);\n" +
"\n" +
" let decrypted = decipher.update(value, undefined, 'utf8');\n" +
" decrypted += decipher.final('utf8');";
if (!cryptContent.includes(newDecryptBlock)) {
if (cryptContent.includes(oldDecryptBlock)) {
cryptContent = cryptContent.replace(oldDecryptBlock, newDecryptBlock);
} else if (cryptContent.includes(oldPartiallyPatchedDecryptBlock)) {
cryptContent = cryptContent.replace(
oldPartiallyPatchedDecryptBlock,
newDecryptBlock,
);
} else {
missingAnchor("UTF-8 token decrypt");
}
patched = true;
}
// Patch 7: drop client-to-guacd input instructions from read-only session-share
// joins. guacd has no native read-only enforcement in the versions this project
// targets, so Termix must gate here. Denylist (not allowlist) on purpose: an
// unrecognized opcode is far more likely to be protocol plumbing (sync, blob,
// clipboard streams) than a new input vector, so failing open is the safer
// default for a client we already control.
const oldSendMessageToGuacd =
" sendMessageToGuacd(message) {\n" +
" this.lastActivity = Date.now();\n" +
" this.logger.log(LOGLEVEL.DEBUG, '[ >>> # ] Received from WS: ```' + message + '```');\n" +
"\n" +
" if (this.guacdClient) {\n" +
" this.guacdClient.send(message, true);\n" +
" }\n" +
" }";
const newSendMessageToGuacd =
" sendMessageToGuacd(message) {\n" +
" this.lastActivity = Date.now();\n" +
" this.logger.log(LOGLEVEL.DEBUG, '[ >>> # ] Received from WS: ```' + message + '```');\n" +
"\n" +
" if (this.isReadOnlyJoin() && this.isInputInstruction(message)) {\n" +
" return;\n" +
" }\n" +
"\n" +
" if (this.guacdClient) {\n" +
" this.guacdClient.send(message, true);\n" +
" }\n" +
" }\n" +
"\n" +
" isReadOnlyJoin() {\n" +
" const connection = this.connectionSettings && this.connectionSettings.connection;\n" +
" return !!(connection && connection.join && connection.readOnly === true);\n" +
" }\n" +
"\n" +
" // Termix-only read-only gate, not part of the vendored library: extracts just\n" +
" // the leading opcode from a raw '<len>.<opcode>,...;' instruction without the\n" +
" // overhead of a full stateful parse.\n" +
" isInputInstruction(message) {\n" +
" const dot = message.indexOf('.');\n" +
" if (dot === -1) return false;\n" +
" const len = parseInt(message.substring(0, dot), 10);\n" +
" if (isNaN(len)) return false;\n" +
" const opcode = message.substring(dot + 1, dot + 1 + len);\n" +
" return ['mouse', 'key', 'touch', 'size'].includes(opcode);\n" +
" }";
if (!clientConnectionContent.includes("isReadOnlyJoin()")) {
if (!clientConnectionContent.includes(oldSendMessageToGuacd)) {
missingAnchor("sendMessageToGuacd");
}
clientConnectionContent = clientConnectionContent.replace(
oldSendMessageToGuacd,
newSendMessageToGuacd,
);
patched = true;
}
// Patch 8: mergeConnectionOptions only preserves `join` across the settings
// merge, dropping Termix's `readOnly` flag before sendMessageToGuacd can see it.
const oldPreserveJoin =
" // For join connections, preserve the join property\n" +
" if (this.connectionSettings.connection.join) {\n" +
" compiledSettings.join = this.connectionSettings.connection.join;\n" +
" }";
const newPreserveJoin =
" // For join connections, preserve the join property\n" +
" if (this.connectionSettings.connection.join) {\n" +
" compiledSettings.join = this.connectionSettings.connection.join;\n" +
" compiledSettings.readOnly = this.connectionSettings.connection.readOnly === true;\n" +
" }";
if (!clientConnectionContent.includes("compiledSettings.readOnly")) {
if (!clientConnectionContent.includes(oldPreserveJoin)) {
missingAnchor("join-preserve");
}
clientConnectionContent = clientConnectionContent.replace(
oldPreserveJoin,
newPreserveJoin,
);
patched = true;
}
// Patch 9: ClientConnection closes malformed-token WebSockets in its
// constructor, but Server.newConnection still called connect() afterwards.
// That dereferenced the absent connection settings and turned one bad token
// into an unhandled rejection that could terminate the backend process.
const oldConnectionSetup =
" newConnection.on('ready', async (clientConnection) => {";
const newConnectionSetup =
" if (!newConnection.connectionSettings || !newConnection.connectionSettings.connection) {\n" +
" return;\n" +
" }\n" +
"\n" +
oldConnectionSetup;
if (!serverContent.includes("!newConnection.connectionSettings.connection")) {
if (!serverContent.includes(oldConnectionSetup)) {
missingAnchor("invalid-token connection guard");
}
serverContent = serverContent.replace(oldConnectionSetup, newConnectionSetup);
patched = true;
}
if (!patched) {
console.log("[patch-guacamole-lite] Already patched");
process.exit(0);
}
fs.writeFileSync(guacdClientPath, guacdClientContent);
fs.writeFileSync(cryptPath, cryptContent);
fs.writeFileSync(clientConnectionPath, clientConnectionContent);
fs.writeFileSync(serverPath, serverContent);
console.log(
"[patch-guacamole-lite] Patched protocol negotiation, name handshake, required arguments, UTF-8 token decrypt, read-only joins, and malformed-token handling",
);
-124
View File
@@ -1,124 +0,0 @@
const fs = require("node:fs");
const path = require("node:path");
const xtermDir = path.join(
__dirname,
"..",
"node_modules",
"@xterm",
"xterm",
"lib",
);
// Backport the textarea-shrink fix from gmuxapp/xterm.js@6a011cf while
// xtermjs/xterm.js#3600 remains unresolved upstream. Android IMEs can restart
// composition on the previous word and replace it with a shorter value (for
// example, Vietnamese "Hoar" -> "Hỏa"). xterm 6.0 otherwise emits nothing.
//
// Also fixes _handleAnyTextareaChanges, which iOS Safari/WKWebView drives
// for ordinary typing (it reports keyCode 229 for all software-keyboard
// input, not just IME composition). That handler diffs the textarea value
// via `newValue.replace(oldValue, "")`, a literal substring removal. When
// keystrokes arrive faster than the function's setTimeout(0) callback runs,
// several overlapping callbacks each capture a stale oldValue, so the
// literal-substring search fails to match and the diff silently comes back
// empty - characters are dropped instead of sent. Swap in the same
// common-prefix diff used for composition-end above so a stale oldValue
// still yields the correct delta.
const patches = [
{
file: "xterm.mjs",
replacements: [
[
[
'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""',
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""',
],
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._pendingTextareaValue=null,this._dataAlreadySent=""',
],
[
'this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent=""',
'this._compositionPosition.start=this._textarea.value.length,this._preCompositionValue=this._textarea.value,this._compositionView.textContent=""',
],
[
"let e={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0",
"let e={start:this._compositionPosition.start,end:this._compositionPosition.end};const s=this._preCompositionValue;this._isSendingComposition=!0",
],
[
"e.start+=this._dataAlreadySent.length,this._isComposing?i=this._textarea.value.substring(e.start,this._compositionPosition.start):i=this._textarea.value.substring(e.start),i.length>0&&",
"e.start+=this._dataAlreadySent.length;if(this._isComposing)i=this._textarea.value.substring(e.start,this._compositionPosition.start);else{const t=this._textarea.value;if(t.length<s.length){let e=0;const r=Math.min(t.length,s.length);for(;e<r&&t.charCodeAt(e)===s.charCodeAt(e);)e++;i=b.DEL.repeat(s.length-e)+t.substring(e)}else i=t.substring(e.start)}i.length>0&&",
],
[
[
'_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,i=e.replace(t,"");this._dataAlreadySent=i,e.length>t.length?this._coreService.triggerDataEvent(i,!0):e.length<t.length?this._coreService.triggerDataEvent(`${b.DEL}`,!0):e.length===t.length&&e!==t&&this._coreService.triggerDataEvent(e,!0)}},0)}',
"_handleAnyTextareaChanges(){let t=this._textarea.value;setTimeout(()=>{if(!this._isComposing){let e=this._textarea.value,r=0;const n=Math.min(e.length,t.length);for(;r<n&&e.charCodeAt(r)===t.charCodeAt(r);)r++;let i=e.length<t.length?b.DEL.repeat(t.length-r)+e.substring(r):e.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}},0)}",
],
"_handleAnyTextareaChanges(){if(this._pendingTextareaValue!==null)return;this._pendingTextareaValue=this._textarea.value,setTimeout(()=>{const t=this._pendingTextareaValue;this._pendingTextareaValue=null;if(!this._isComposing){let e=this._textarea.value,r=0;const n=Math.min(e.length,t.length);for(;r<n&&e.charCodeAt(r)===t.charCodeAt(r);)r++;let i=e.length<t.length?b.DEL.repeat(t.length-r)+e.substring(r):e.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}},0)}",
],
],
},
{
file: "xterm.js",
replacements: [
[
[
'this._compositionPosition={start:0,end:0},this._dataAlreadySent=""',
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._dataAlreadySent=""',
],
'this._compositionPosition={start:0,end:0},this._preCompositionValue="",this._pendingTextareaValue=null,this._dataAlreadySent=""',
],
[
'this._compositionPosition.start=this._textarea.value.length,this._compositionView.textContent=""',
'this._compositionPosition.start=this._textarea.value.length,this._preCompositionValue=this._textarea.value,this._compositionView.textContent=""',
],
[
"const e={start:this._compositionPosition.start,end:this._compositionPosition.end};this._isSendingComposition=!0",
"const e={start:this._compositionPosition.start,end:this._compositionPosition.end},i=this._preCompositionValue;this._isSendingComposition=!0",
],
[
"e.start+=this._dataAlreadySent.length,t=this._isComposing?this._textarea.value.substring(e.start,this._compositionPosition.start):this._textarea.value.substring(e.start),t.length>0&&",
"e.start+=this._dataAlreadySent.length;this._isComposing?t=this._textarea.value.substring(e.start,this._compositionPosition.start):(()=>{const s=this._textarea.value;if(s.length<i.length){let e=0;const r=Math.min(s.length,i.length);for(;e<r&&s.charCodeAt(e)===i.charCodeAt(e);)e++;t=a.C0.DEL.repeat(i.length-e)+s.substring(e)}else t=s.substring(e.start)})(),t.length>0&&",
],
[
[
'_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value,i=t.replace(e,"");this._dataAlreadySent=i,t.length>e.length?this._coreService.triggerDataEvent(i,!0):t.length<e.length?this._coreService.triggerDataEvent(`${a.C0.DEL}`,!0):t.length===e.length&&t!==e&&this._coreService.triggerDataEvent(t,!0)}}),0)}',
"_handleAnyTextareaChanges(){const e=this._textarea.value;setTimeout((()=>{if(!this._isComposing){const t=this._textarea.value;let r=0;const n=Math.min(t.length,e.length);for(;r<n&&t.charCodeAt(r)===e.charCodeAt(r);)r++;const i=t.length<e.length?a.C0.DEL.repeat(e.length-r)+t.substring(r):t.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}}),0)}",
],
"_handleAnyTextareaChanges(){if(this._pendingTextareaValue!==null)return;this._pendingTextareaValue=this._textarea.value,setTimeout((()=>{const e=this._pendingTextareaValue;this._pendingTextareaValue=null;if(!this._isComposing){const t=this._textarea.value;let r=0;const n=Math.min(t.length,e.length);for(;r<n&&t.charCodeAt(r)===e.charCodeAt(r);)r++;const i=t.length<e.length?a.C0.DEL.repeat(e.length-r)+t.substring(r):t.substring(r);this._dataAlreadySent=i,i.length>0&&this._coreService.triggerDataEvent(i,!0)}}),0)}",
],
],
},
];
for (const { file, replacements } of patches) {
const filePath = path.join(xtermDir, file);
if (!fs.existsSync(filePath)) {
throw new Error(`[patch-xterm-android-ime] Missing ${filePath}`);
}
let source = fs.readFileSync(filePath, "utf8");
let changed = false;
for (const [original, patched] of replacements) {
if (source.includes(patched)) {
continue;
}
const originals = Array.isArray(original) ? original : [original];
const matched = originals.find((candidate) => source.includes(candidate));
if (!matched) {
throw new Error(
`[patch-xterm-android-ime] Expected source not found in ${file}`,
);
}
source = source.replace(matched, patched);
changed = true;
}
if (!changed) {
console.log(`[patch-xterm-android-ime] ${file} already patched`);
continue;
}
fs.writeFileSync(filePath, source);
console.log(`[patch-xterm-android-ime] Patched ${file}`);
}
+34 -6
View File
@@ -23,6 +23,24 @@ const pluginIds = fs
.map((entry) => entry.name);
const failed = [];
const skipped = [];
let ran = 0;
// A vitest fork that dies mid-run (seen on Windows under memory pressure)
// fails the suite without any test failing. That is retried once; a real
// test failure is not.
const WORKER_CRASH = /Worker exited unexpectedly|Worker forks emitted error/;
function runSuite(pluginDir) {
const result = spawnSync(process.execPath, [cli, "test"], {
cwd: pluginDir,
encoding: "utf8",
maxBuffer: 64 * 1024 * 1024,
});
process.stdout.write(result.stdout ?? "");
process.stderr.write(result.stderr ?? "");
return result;
}
for (const id of pluginIds) {
const pluginDir = path.join(source, id);
@@ -40,18 +58,28 @@ for (const id of pluginIds) {
)
);
});
if (!hasTests) continue;
if (!hasTests) {
skipped.push(id);
continue;
}
const result = spawnSync(process.execPath, [cli, "test"], {
cwd: pluginDir,
stdio: "inherit",
});
ran++;
let result = runSuite(pluginDir);
const output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
if (result.status !== 0 && WORKER_CRASH.test(output)) {
console.warn(`\n${id}: a test worker crashed, running the suite again`);
result = runSuite(pluginDir);
}
if (result.status !== 0) failed.push(id);
}
if (skipped.length > 0) {
console.warn(`\nNo tests found for: ${skipped.join(", ")}`);
}
if (failed.length > 0) {
console.error(`\nPlugin tests failed: ${failed.join(", ")}`);
process.exit(1);
}
console.log(`\nPlugin tests passed for ${pluginIds.length} plugin(s).`);
console.log(`\nPlugin tests passed for ${ran} plugin(s).`);