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
@@ -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);
|
||||
}
|
||||
@@ -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)}`);
|
||||
@@ -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(
|
||||
|
||||
@@ -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({});
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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"'),
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
@@ -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",
|
||||
);
|
||||
@@ -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",
|
||||
);
|
||||
@@ -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}`);
|
||||
}
|
||||
@@ -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).`);
|
||||
Reference in new issue
Block a user