* feat: add an auto or fixed columns picker to card grids

* feat: add safe mode that only runs layout and theme plugins

* docs: add simple layout to plugin table

* fix: ask for local network access in the macOS app

* feat: allow plugin registries on listed private hosts (#1705)

* chore: add private registry hosts to the changelog

* feat: use the proxy for plugin registry downloads

* fix: allow changing connection origin on shared hosts

* chore: update translations and plugin pins

* chore: lint, format, and bump version to 26.10.5

---------

Co-authored-by: Lukas <80832688+0skater0@users.noreply.github.com>
This commit is contained in:
Luke GustafsonandLukas authored and GitHub committed 2026-10-22 13:30:48 -05:00
1 parent 7b7b17e31f
commit 96b8520fb4
96 files changed
+1885 -474

No files matched your search

+26 -1
View File
@@ -1,5 +1,8 @@
import { describe, expect, it } from "vitest";
import { startablePluginIds } from "../../plugins/boot-state.js";
import {
missingShippedPluginIds,
startablePluginIds,
} from "../../plugins/boot-state.js";
describe("startablePluginIds", () => {
it("starts enabled plugins and retries ones that failed or were blocked", () => {
@@ -13,3 +16,25 @@ describe("startablePluginIds", () => {
expect([...ids].sort()).toEqual(["a", "b", "c"]);
});
});
describe("missingShippedPluginIds", () => {
const records = [
{ id: "gone", tier: "bundled", state: "enabled" },
{ id: "here", tier: "bundled", state: "enabled" },
{ id: "off", tier: "bundled", state: "disabled" },
{ id: "failed", tier: "bundled", state: "failed" },
{ id: "community", tier: "community", state: "enabled" },
{ id: "removed", tier: "bundled", state: "enabled" },
];
it("lists shipped plugins that are on but not in the image", () => {
expect(
missingShippedPluginIds(records, new Set(["here"]), new Set(["removed"])),
).toEqual(["failed", "gone"]);
});
it("finds nothing when every plugin is present", () => {
const present = new Set(records.map((record) => record.id));
expect(missingShippedPluginIds(records, present, new Set())).toEqual([]);
});
});
+20
View File
@@ -621,6 +621,26 @@ describe("dependencies", () => {
});
});
describe("safe mode", () => {
afterEach(() => {
delete process.env.TERMIX_SAFE_MODE;
});
it("refuses to start a plugin that is not a layout or theme, but still stops one", async () => {
createFixturePlugin({ id: "base", root: bundled });
const loader = await boot();
await manage.setPluginState("base", false);
process.env.TERMIX_SAFE_MODE = "1";
await expect(manage.setPluginState("base", true)).rejects.toMatchObject({
status: 409,
code: "SAFE_MODE",
});
expect(loader.get("base")?.state).toBe("stopped");
expect(db.rows.get("base")?.state).toBe("disabled");
});
});
describe("reloading a bundled plugin for the dev runner", () => {
it("loads the rebuilt copy and restarts what depends on it", async () => {
createFixturePlugin({ id: "base", root: bundled });
@@ -8,10 +8,23 @@ import * as tar from "tar";
const fetchMock = vi.hoisted(() => vi.fn());
const testKey = vi.hoisted(() => ({ value: null as null | object }));
vi.mock("../../utils/safe-outbound-fetch.js", () => ({
vi.mock("../../utils/safe-outbound-fetch.js", async (importOriginal) => ({
...(await importOriginal<
typeof import("../../utils/safe-outbound-fetch.js")
>()),
safeOutboundFetch: fetchMock,
}));
const proxyMock = vi.hoisted(() => ({
on: false,
fetch: vi.fn(),
}));
vi.mock("../../utils/proxy-agent.js", () => ({
getProxyAgent: () => (proxyMock.on ? {} : undefined),
fetchWithProxy: proxyMock.fetch,
}));
vi.mock("../../utils/app-version.js", () => ({
getLocalVersion: () => "26.10.3",
}));
@@ -43,9 +56,11 @@ vi.mock("../../plugins/trust.js", async (importOriginal) => {
import {
communitySource,
downloadRelease,
fetchIndexAt,
fetchRegistryIndex,
fetchRegistryStats,
findRelease,
getRegistryPrivateHosts,
latestOnChannel,
getStatsUrl,
canRunRelease,
@@ -409,6 +424,91 @@ describe("fetchRegistryIndex", () => {
});
});
describe("registry fetches with a proxy", () => {
afterEach(() => {
proxyMock.on = false;
proxyMock.fetch.mockReset();
});
it("goes through the proxy when one is configured", async () => {
proxyMock.on = true;
proxyMock.fetch.mockResolvedValueOnce(
response(JSON.stringify({ plugins: [] })),
);
await fetchIndexAt("https://registry.example/index.json");
expect(proxyMock.fetch).toHaveBeenCalledTimes(1);
expect(fetchMock).not.toHaveBeenCalled();
});
it("still refuses a private address typed in as a literal", async () => {
proxyMock.on = true;
await expect(fetchIndexAt("https://127.0.0.1/index.json")).rejects.toThrow(
"Private destinations are not allowed",
);
expect(proxyMock.fetch).not.toHaveBeenCalled();
});
});
describe("private registry hosts", () => {
afterEach(() => {
delete process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS;
});
it("reads a comma separated list of host names", () => {
process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS =
" GitLab.lan ,, 192.168.1.5 ";
expect(getRegistryPrivateHosts()).toEqual(["gitlab.lan", "192.168.1.5"]);
delete process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS;
expect(getRegistryPrivateHosts()).toEqual([]);
});
it("brings each entry into the form the fetch compares against", () => {
process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS =
"GitLab.LAN,[FD00::0001],fd00::2,bücher.lan";
expect(getRegistryPrivateHosts()).toEqual([
"gitlab.lan",
"fd00::1",
"fd00::2",
"xn--bcher-kva.lan",
]);
});
it("drops entries with more than a host", () => {
process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS =
"gitlab.lan:8443,https://git.lan,[fd00::1]:80,nas.lan";
expect(getRegistryPrivateHosts()).toEqual(["nas.lan"]);
});
it("allows the listed hosts on every hop", async () => {
process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS = "gitlab.lan";
fetchMock
.mockResolvedValueOnce(
response("", 302, { location: "https://gitlab.lan/raw/index.json" }),
)
.mockResolvedValueOnce(response(JSON.stringify({ plugins: [] })));
await fetchIndexAt("https://gitlab.lan/index.json");
expect(fetchMock.mock.calls.map((call) => call[2])).toEqual([
["gitlab.lan"],
["gitlab.lan"],
]);
});
it("still blocks a redirect to a private host that is not listed", async () => {
process.env.TERMIX_PLUGIN_REGISTRY_PRIVATE_HOSTS = "gitlab.lan";
const actual = await vi.importActual<
typeof import("../../utils/safe-outbound-fetch.js")
>("../../utils/safe-outbound-fetch.js");
fetchMock
.mockResolvedValueOnce(
response("", 302, { location: "https://127.0.0.1/index.json" }),
)
.mockImplementationOnce(actual.safeOutboundFetch);
await expect(fetchIndexAt("https://gitlab.lan/index.json")).rejects.toThrow(
"Private destinations are not allowed",
);
});
});
/** A real .tmxplug holding only a manifest, enough for the checks. */
function pack(manifest: Record<string, unknown>): Buffer {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "termix-pack-"));
@@ -0,0 +1,62 @@
import { afterEach, describe, expect, it } from "vitest";
import {
mayRunInSafeMode,
safeModeOn,
safeModePluginIds,
} from "../../plugins/safe-mode.js";
import type { PluginManifest } from "../../plugins/manifest.js";
function plugin(id: string, manifest: Partial<PluginManifest> = {}) {
return { id, manifest: { id, ...manifest } as PluginManifest };
}
const plugins = [
plugin("layout", {
contributes: { layouts: [{ id: "classic" }] },
dependencies: { helper: "^1.0.0" },
} as Partial<PluginManifest>),
plugin("themes", {
contributes: { themes: [{ id: "dark" }] },
} as Partial<PluginManifest>),
plugin("helper"),
plugin("docker"),
];
afterEach(() => {
delete process.env.TERMIX_SAFE_MODE;
});
describe("safeModeOn", () => {
it("reads 1 and true, anything else is off", () => {
expect(safeModeOn()).toBe(false);
process.env.TERMIX_SAFE_MODE = "1";
expect(safeModeOn()).toBe(true);
process.env.TERMIX_SAFE_MODE = " TRUE ";
expect(safeModeOn()).toBe(true);
process.env.TERMIX_SAFE_MODE = "0";
expect(safeModeOn()).toBe(false);
});
});
describe("safeModePluginIds", () => {
it("keeps layout and theme plugins and what they depend on", () => {
expect([...safeModePluginIds(plugins)].sort()).toEqual([
"helper",
"layout",
"themes",
]);
});
});
describe("mayRunInSafeMode", () => {
it("lets every plugin run when safe mode is off", () => {
expect(mayRunInSafeMode("docker", plugins)).toBe(true);
});
it("only lets look plugins run when safe mode is on", () => {
process.env.TERMIX_SAFE_MODE = "1";
expect(mayRunInSafeMode("docker", plugins)).toBe(false);
expect(mayRunInSafeMode("layout", plugins)).toBe(true);
expect(mayRunInSafeMode("helper", plugins)).toBe(true);
});
});