mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-28 06:05:24 +00:00
feat: harden plugin api for separate repos, signed updates and generic external login
This commit is contained in:
1 parent
fb9c168b6b
commit
80944301dd
310 files changed
+6577
-4927
No files matched your search
@@ -1,5 +1,7 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import * as esbuild from "esbuild";
|
||||
import { BACKEND_EXTERNALS, FRONTEND_EXTERNALS } from "../lib/externals.mjs";
|
||||
import { staticUrlImports } from "../lib/static-url-imports.mjs";
|
||||
@@ -48,11 +50,110 @@ export function externalRequireInterop(externals) {
|
||||
};
|
||||
}
|
||||
|
||||
const SDK_PATCHES = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"patches",
|
||||
);
|
||||
|
||||
/**
|
||||
* Patches a dependency before it is bundled, so the fix ships inside the
|
||||
* plugin rather than depending on whoever ran npm install.
|
||||
*
|
||||
* package.json "termix": { "patches": ["xterm-android-ime"] } names patches
|
||||
* the SDK ships for libraries several plugins share. Any .cjs file in the
|
||||
* plugin's own patches/ folder runs too. Every patch must be safe to run
|
||||
* twice.
|
||||
*/
|
||||
export function applyPatches(cwd, pluginId) {
|
||||
const pkgPath = path.join(cwd, "package.json");
|
||||
const pkg = fs.existsSync(pkgPath)
|
||||
? JSON.parse(fs.readFileSync(pkgPath, "utf8"))
|
||||
: {};
|
||||
const scripts = [];
|
||||
for (const name of pkg.termix?.patches ?? []) {
|
||||
const file = path.join(SDK_PATCHES, `${name}.cjs`);
|
||||
if (!/^[a-z0-9-]+$/.test(name) || !fs.existsSync(file)) {
|
||||
throw new Error(`${pluginId}: the SDK has no patch named "${name}"`);
|
||||
}
|
||||
scripts.push(file);
|
||||
}
|
||||
const own = path.join(cwd, "patches");
|
||||
if (fs.existsSync(own)) {
|
||||
for (const file of fs.readdirSync(own).sort()) {
|
||||
if (file.endsWith(".cjs")) scripts.push(path.join(own, file));
|
||||
}
|
||||
}
|
||||
for (const file of scripts) {
|
||||
const result = spawnSync(process.execPath, [file], {
|
||||
cwd,
|
||||
env: { ...process.env, TERMIX_PATCH_ROOT: cwd },
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
throw new Error(`${pluginId}: patch ${path.basename(file)} failed`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const THEME_CSS = path.join(
|
||||
path.dirname(fileURLToPath(import.meta.url)),
|
||||
"..",
|
||||
"..",
|
||||
"dist",
|
||||
"host",
|
||||
"theme.css",
|
||||
);
|
||||
|
||||
/**
|
||||
* Compiles the Tailwind classes the plugin's frontend uses into
|
||||
* dist/frontend.css, against the theme core ships in the SDK. Core only
|
||||
* compiles its own classes, so a class no core file happens to use would
|
||||
* otherwise have no CSS at all. Appended to whatever CSS the bundle already
|
||||
* produced (a library's stylesheet the plugin imports).
|
||||
*/
|
||||
export async function buildTailwind(cwd, outDir) {
|
||||
const { compile, optimize } = await import("@tailwindcss/node");
|
||||
const { Scanner } = await import("@tailwindcss/oxide");
|
||||
if (!fs.existsSync(THEME_CSS)) {
|
||||
throw new Error("The SDK theme is missing. Run: npm run build:sdk");
|
||||
}
|
||||
const input = [
|
||||
'@import "tailwindcss/theme.css" theme(reference);',
|
||||
`@import ${JSON.stringify(THEME_CSS.replaceAll("\\", "/"))};`,
|
||||
'@import "tailwindcss/utilities.css" layer(utilities);',
|
||||
].join("\n");
|
||||
const compiler = await compile(input, {
|
||||
base: path.dirname(THEME_CSS),
|
||||
onDependency: () => {},
|
||||
});
|
||||
const scanner = new Scanner({
|
||||
sources: [
|
||||
{
|
||||
base: path.join(cwd, "src", "frontend"),
|
||||
pattern: "**/*",
|
||||
negated: false,
|
||||
},
|
||||
],
|
||||
});
|
||||
// Flattened and minified the way core's own CSS is, so it does not rely
|
||||
// on native CSS nesting.
|
||||
const css = optimize(compiler.build(scanner.scan()), {
|
||||
minify: true,
|
||||
}).code.trim();
|
||||
if (!css) return;
|
||||
const target = path.join(outDir, "frontend.css");
|
||||
const existing = fs.existsSync(target) ? fs.readFileSync(target, "utf8") : "";
|
||||
fs.writeFileSync(target, `${existing}${existing ? "\n" : ""}${css}\n`);
|
||||
}
|
||||
|
||||
export async function build({ cwd }) {
|
||||
const manifest = readManifest(cwd);
|
||||
const pluginId = manifest.id ?? path.basename(cwd);
|
||||
const outDir = path.join(cwd, "dist");
|
||||
|
||||
applyPatches(cwd, pluginId);
|
||||
|
||||
fs.rmSync(outDir, { recursive: true, force: true });
|
||||
fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
@@ -123,6 +224,8 @@ export async function build({ cwd }) {
|
||||
});
|
||||
}
|
||||
|
||||
if (frontendEntry) await buildTailwind(cwd, outDir);
|
||||
|
||||
copyDir(path.join(cwd, "locales"), path.join(outDir, "locales"));
|
||||
copyDir(path.join(cwd, "migrations"), path.join(outDir, "migrations"));
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { readManifest } from "../lib/plugin-dir.mjs";
|
||||
import { LEGACY_TABLE_OWNERS as LEGACY_TABLES } from "../../dist/db.js";
|
||||
import { findUnownedTableWrites } from "../../dist/ddl.js";
|
||||
import { collectOwnedIndexes, findUnownedTableWrites } from "../../dist/ddl.js";
|
||||
|
||||
/**
|
||||
* The manifest rules live in src/manifest.ts, which the server uses too, so
|
||||
@@ -39,6 +39,7 @@ export async function validate({ cwd }) {
|
||||
|
||||
problems.push(...validateMigrations(cwd, raw.id ?? path.basename(cwd)));
|
||||
problems.push(...validateNativeDependencies(cwd, raw));
|
||||
problems.push(...validatePackage(cwd, raw));
|
||||
|
||||
if (problems.length > 0) {
|
||||
for (const problem of problems) console.error(` ${problem}`);
|
||||
@@ -48,6 +49,36 @@ export async function validate({ cwd }) {
|
||||
console.log(`ok ${raw.id ?? path.basename(cwd)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* package.json has to agree with the manifest: the same version, and a real
|
||||
* SDK range rather than "*", so a plugin built against a newer SDK says so.
|
||||
*/
|
||||
function validatePackage(cwd, raw) {
|
||||
const problems = [];
|
||||
const pkgPath = path.join(cwd, "package.json");
|
||||
if (!fs.existsSync(pkgPath)) return problems;
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
||||
|
||||
if (pkg.version && raw.version && pkg.version !== raw.version) {
|
||||
problems.push(
|
||||
`package.json version ${pkg.version} does not match manifest version ${raw.version}`,
|
||||
);
|
||||
}
|
||||
|
||||
const sdkRange =
|
||||
pkg.peerDependencies?.["@termix/plugin-sdk"] ??
|
||||
pkg.dependencies?.["@termix/plugin-sdk"] ??
|
||||
pkg.devDependencies?.["@termix/plugin-sdk"];
|
||||
if (!sdkRange) {
|
||||
problems.push("package.json does not depend on @termix/plugin-sdk");
|
||||
} else if (sdkRange === "*" || sdkRange === "latest") {
|
||||
problems.push(
|
||||
`@termix/plugin-sdk is "${sdkRange}"; pin a range such as "^1.0.0"`,
|
||||
);
|
||||
}
|
||||
return problems;
|
||||
}
|
||||
|
||||
/**
|
||||
* A native dependency has to actually be a real dependency of the plugin, or
|
||||
* the build's external declaration points at nothing node_modules can
|
||||
@@ -106,16 +137,25 @@ function validateMigrations(cwd, pluginId) {
|
||||
.filter((file) => file.endsWith(".sql"))
|
||||
.sort();
|
||||
byDialect.set(dialect, files);
|
||||
const sqls = files.map((file) =>
|
||||
fs.readFileSync(path.join(dir, file), "utf8"),
|
||||
);
|
||||
const indexes = collectOwnedIndexes(pluginId, sqls, legacy);
|
||||
|
||||
for (const file of files) {
|
||||
for (const [index, file] of files.entries()) {
|
||||
if (!/^\d{4}_[a-z0-9_]+\.sql$/.test(file)) {
|
||||
problems.push(
|
||||
`migrations/${dialect}/${file} must be named NNNN_name.sql, lower snake_case`,
|
||||
);
|
||||
}
|
||||
|
||||
const sql = fs.readFileSync(path.join(dir, file), "utf8");
|
||||
for (const problem of findUnownedTableWrites(pluginId, sql, legacy)) {
|
||||
const sql = sqls[index];
|
||||
for (const problem of findUnownedTableWrites(
|
||||
pluginId,
|
||||
sql,
|
||||
legacy,
|
||||
indexes,
|
||||
)) {
|
||||
problems.push(`migrations/${dialect}/${file} ${problem}`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,8 @@
|
||||
*/
|
||||
|
||||
import process from "node:process";
|
||||
import { build } from "./commands/build.mjs";
|
||||
import { applyPatches, build } from "./commands/build.mjs";
|
||||
import { readManifest } from "./lib/plugin-dir.mjs";
|
||||
import { validate } from "./commands/validate.mjs";
|
||||
import { test } from "./commands/test.mjs";
|
||||
import { pack } from "./commands/pack.mjs";
|
||||
@@ -15,6 +16,7 @@ import { migrations } from "./commands/migrations.mjs";
|
||||
|
||||
const COMMANDS = {
|
||||
build,
|
||||
patch: async ({ cwd }) => applyPatches(cwd, readManifest(cwd).id),
|
||||
validate,
|
||||
test,
|
||||
pack,
|
||||
@@ -32,6 +34,7 @@ if (!command || command === "--help" || command === "-h") {
|
||||
"Usage: termix-plugin <command>",
|
||||
"",
|
||||
" build Bundle the plugin into dist/",
|
||||
" patch Apply the plugin's dependency patches (build does this too)",
|
||||
" validate Check manifest.json and the files it names",
|
||||
" test Run the plugin's vitest suite",
|
||||
" pack Write <id>-<version>.tmxplug of the built plugin [--out dir]",
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
* a second ssh2 would mean a second set of native bindings. A plugin may
|
||||
* depend on anything outside these lists, and that does get bundled.
|
||||
*
|
||||
* A plugin lists the ones it uses as peerDependencies. Only add a package here
|
||||
* when core itself ships it; a library one plugin needs belongs in that
|
||||
* plugin's own dependencies, so it can update without a core release.
|
||||
*
|
||||
* Node builtins are external automatically through platform: "node".
|
||||
*/
|
||||
|
||||
@@ -18,9 +22,6 @@ export const BACKEND_EXTERNALS = [
|
||||
"multer",
|
||||
"cookie-parser",
|
||||
"axios",
|
||||
"jszip",
|
||||
"guacamole-lite",
|
||||
"@anthropic-ai/sdk",
|
||||
"drizzle-orm",
|
||||
"drizzle-orm/*",
|
||||
// Native image processing, loaded on first use by the terminal's image upload.
|
||||
|
||||
@@ -0,0 +1,76 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// Run by `termix-plugin build` with TERMIX_PATCH_ROOT set to the plugin, so
|
||||
// the package resolves the way the plugin's bundle will resolve it.
|
||||
function packageDir(name) {
|
||||
const root = process.env.TERMIX_PATCH_ROOT || process.cwd();
|
||||
// Walked by hand: a package whose exports map hides package.json cannot be
|
||||
// found with require.resolve.
|
||||
for (let dir = path.resolve(root); ; dir = path.dirname(dir)) {
|
||||
const candidate = path.join(dir, "node_modules", name);
|
||||
if (fs.existsSync(path.join(candidate, "package.json"))) return candidate;
|
||||
if (path.dirname(dir) === dir) return path.join(root, "node_modules", name);
|
||||
}
|
||||
}
|
||||
|
||||
const packageRoot = path.join(packageDir("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",
|
||||
);
|
||||
@@ -0,0 +1,130 @@
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
|
||||
// Run by `termix-plugin build` with TERMIX_PATCH_ROOT set to the plugin, so
|
||||
// the package resolves the way the plugin's bundle will resolve it.
|
||||
function packageDir(name) {
|
||||
const root = process.env.TERMIX_PATCH_ROOT || process.cwd();
|
||||
// Walked by hand: a package whose exports map hides package.json cannot be
|
||||
// found with require.resolve.
|
||||
for (let dir = path.resolve(root); ; dir = path.dirname(dir)) {
|
||||
const candidate = path.join(dir, "node_modules", name);
|
||||
if (fs.existsSync(path.join(candidate, "package.json"))) return candidate;
|
||||
if (path.dirname(dir) === dir) return path.join(root, "node_modules", name);
|
||||
}
|
||||
}
|
||||
|
||||
const xtermDir = path.join(packageDir("@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}`);
|
||||
}
|
||||
Reference in new issue
Block a user