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

+33 -1
View File
@@ -513,7 +513,39 @@
"useSSHTitleDesc": "Update the tab title from the shell's window title instead of the host name",
"useSSHTitleLabel": "Use SSH Window Title",
"useUserDefaults": "Use user defaults",
"useUserTerminalDefaultsDesc": "Keep this host in step with the terminal defaults in your profile."
"useUserTerminalDefaultsDesc": "Keep this host in step with the terminal defaults in your profile.",
"enableTunnel": "Enable Tunnel",
"enableFileManager": "Enable File Manager",
"enableDocker": "Enable Docker",
"startupSnippet": "Startup Snippet",
"quickActions": "Quick Actions",
"proxmoxLastSyncStatus": {
"success": "Success"
},
"proxmoxResultSkippedNoIp": "{{count}} skipped (no IP found)",
"proxmoxStatsUrlCopied": "Proxmox Stats URL copied to clipboard",
"rdpUrlCopied": "RDP URL copied",
"vncUrlCopied": "VNC URL copied",
"filterAuthStepca": "Step CA",
"guac": {
"noCredential": "No credential (direct credentials below)",
"authTypeDirect": "Direct",
"authTypeCredential": "Credential",
"authTypeNone": "None",
"recordingPath": "Recording Path",
"recordingName": "Recording Name",
"saveHostFirst": "Save the host first.",
"sharingOptionsAfterSave": "Sharing options are available after the host has been saved.",
"permissionLevel": "Permission Level",
"typeHeader": "Type",
"targetHeader": "Target",
"permissionHeader": "Permission",
"cancelBtn": "Cancel",
"savingBtn": "Saving...",
"updateHostBtn": "Update Host",
"addHostBtn": "Add Host"
},
"filterAuthVault": "Vault"
},
"nav": {
"history": "History",
+13 -1
View File
@@ -797,7 +797,19 @@
}
}
}
]
],
"uiPresets": {
"simple": {
"toolbarDensity": "icon"
},
"balanced": {
"toolbarDensity": "labeled"
},
"advanced": {
"toolbarDensity": "expanded"
}
},
"uses": ["files.openEditor", "files.openHost", "snippets.startupCommand"]
},
"locales": "locales",
"platforms": ["linux", "win32", "darwin"]
+44 -2
View File
@@ -6,10 +6,52 @@
"scripts": {
"build": "termix-plugin build",
"test": "termix-plugin test",
"typecheck": "tsc -b ../../tsconfig.plugins.backend.json ../../tsconfig.plugins.frontend.json",
"typecheck": "tsc -p .",
"validate": "termix-plugin validate"
},
"dependencies": {
"@xterm/addon-clipboard": "^0.2.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/addon-search": "^0.16.0",
"@xterm/addon-unicode11": "^0.9.0",
"@xterm/addon-web-links": "^0.12.0",
"@xterm/xterm": "^6.0.0",
"lucide-react": "^1.33.0",
"react-xtermjs": "^1.0.10"
},
"devDependencies": {
"@termix/plugin-sdk": "*"
"@termix/plugin-sdk": "^1.0.0",
"@testing-library/dom": "^10.4.2",
"@testing-library/jest-dom": "^7.0.1",
"@testing-library/react": "^16.3.3",
"@testing-library/user-event": "^14.6.7",
"@types/node": "^26.2.0",
"@types/react": "^19.2.18",
"@types/react-dom": "^19.2.4",
"better-sqlite3": "^13.0.3",
"jsdom": "^30.0.1",
"vitest": "^4.1.11"
},
"peerDependencies": {
"@termix/plugin-sdk": "^1.0.0",
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"multer": "^2.3.0",
"react": "^19.2.8",
"react-dom": "^19.2.8",
"sharp": "^0.35.4",
"sonner": "^2.0.8",
"ssh2": "^1.17.0",
"ws": "^8.21.3"
},
"termix": {
"patches": [
"xterm-android-ime"
]
},
"overrides": {
"react-xtermjs": {
"@xterm/xterm": "$@xterm/xterm"
}
}
}
+1 -1
View File
@@ -116,7 +116,7 @@ export interface SessionGuestsV1 {
recordJoin: (shareId: string) => Promise<void>;
}
export const SESSION_GUESTS_KEY = "sessions.sharing.guests";
export const SESSION_GUESTS_KEY = "session-sharing.guests";
export interface RecordingSink {
/** Appends one batch; the first batch starts with the asciicast header. */
@@ -1,3 +1,5 @@
// The terminal surfaces need xterm's own stylesheet.
import "@xterm/xterm/css/xterm.css";
import { lazy, Suspense, type ComponentType } from "react";
import {
Braces,
@@ -0,0 +1,30 @@
import { useSyncExternalStore } from "react";
/**
* The terminal theme the host editor is previewing, so an open terminal for
* that host can show it before the host is saved. Null when nothing is being
* previewed.
*/
let preview: string | null = null;
const listeners = new Set<() => void>();
export function setThemePreview(theme: string | null): void {
if (theme === preview) return;
preview = theme;
for (const listener of listeners) listener();
}
function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
}
export function useThemePreview(): string | null {
return useSyncExternalStore(
subscribe,
() => preview,
() => preview,
);
}
@@ -1,4 +1,5 @@
import { useMemo, useState } from "react";
import { useMemo, useState, useEffect } from "react";
import { setThemePreview } from "../look/theme-preview";
import { Info, Palette, X, Zap } from "lucide-react";
import { toast } from "sonner";
import {
@@ -13,7 +14,6 @@ import {
TooltipContent,
TooltipProvider,
TooltipTrigger,
useTabsSafe,
} from "@termix/plugin-sdk/ui";
import {
useSettings,
@@ -69,7 +69,8 @@ export function HostTerminalSettings({
const { t } = useTranslation();
const client = useTerminalClientSettings();
const userSettings = useSettings("user");
const { setPreviewTerminalTheme } = useTabsSafe();
// The preview ends when the editor section goes away.
useEffect(() => () => setThemePreview(null), []);
const stored = ((editorForm?.pluginSettings as HostPluginSettings)?.[
PLUGIN_ID
@@ -222,7 +223,7 @@ export function HostTerminalSettings({
onChange={(e) => {
const newTheme = e.target.value;
setField("theme", newTheme);
setPreviewTerminalTheme(newTheme);
setThemePreview(newTheme);
if (newTheme === "custom" && !form.customThemeColors) {
setField("customThemeColors", {
...TERMINAL_THEMES.termixDark.colors,
@@ -119,6 +119,7 @@ import {
isElectron,
} from "@termix/plugin-sdk/ui";
import {
notifyHostsChanged,
useTranslation,
invokeAction,
usePluginApi,
@@ -1558,9 +1559,7 @@ const TerminalInner = forwardRef<TerminalHandle, SSHTerminalProps>(
onClick: () => {
void enableHostAutoTmux(api, hostConfig.id as number)
.then(() => {
window.dispatchEvent(
new CustomEvent("termix:hosts-changed"),
);
notifyHostsChanged();
toast.success(
t("terminal.autoTmuxEnabled", { host: hostLabel }),
);
@@ -1,7 +1,8 @@
import { lazy, Suspense } from "react";
import { useThemePreview } from "../look/theme-preview";
import { TerminalSquare } from "lucide-react";
import type { TerminalHandle, TerminalHostConfig } from "./Terminal";
import { useIsMobile, useTabsSafe } from "@termix/plugin-sdk/ui";
import { useIsMobile } from "@termix/plugin-sdk/ui";
import {
useTranslation,
invokeAction,
@@ -63,7 +64,7 @@ export function TerminalTabContent({
const tab = tabRecord as unknown as TerminalTabRecord;
const host = hostRecord as unknown as Host | undefined;
const { t } = useTranslation();
const { previewTerminalTheme } = useTabsSafe();
const previewTerminalTheme = useThemePreview();
const isMobile = useIsMobile();
const useSshTitle = readHostTerminalSettings(
hostTerminalValues(host),
@@ -48,6 +48,7 @@ import {
useIsMobile,
} from "@termix/plugin-sdk/ui";
import {
usePluginUiPreferences,
useHostActions,
useTabs,
useTranslation,
@@ -57,8 +58,6 @@ import {
} from "@termix/plugin-sdk/frontend";
type SelectedToolbarDensity = ToolbarDensity;
const DENSITY_STORAGE_KEY = "termix-terminal-toolbar-density";
/** Plugins contribute toolbar buttons here. Declared by the ssh-terminal plugin. */
const TOOLBAR_SLOT_ID = TERMINAL_TOOLBAR_SLOT;
@@ -69,16 +68,12 @@ const DENSITY_OPTIONS: { value: ToolbarDensity }[] = [
];
const DENSITIES = DENSITY_OPTIONS.map((option) => option.value);
function readStoredDensity(): ToolbarDensity {
if (typeof window === "undefined") return "labeled";
try {
const stored = window.localStorage.getItem(DENSITY_STORAGE_KEY);
return DENSITIES.includes(stored as ToolbarDensity)
? (stored as ToolbarDensity)
: "labeled";
} catch {
return "labeled";
}
const LEGACY_DENSITY_KEY = "termix-terminal-toolbar-density";
function asDensity(value: unknown): ToolbarDensity {
return DENSITIES.includes(value as ToolbarDensity)
? (value as ToolbarDensity)
: "labeled";
}
const CONTROL =
@@ -151,8 +146,28 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
const settings = useMemo(() => readToolbarSettings(host), [host]);
const { anchor } = settings;
const rememberDensity = settings.density === null;
// The remembered density is this plugin's UI preference, so an interface
// preset sets it too.
const { values: uiPrefs, set: setUiPref } = usePluginUiPreferences<{
toolbarDensity: ToolbarDensity;
}>();
const preferredDensity = asDensity(uiPrefs.toolbarDensity);
useEffect(() => {
// Before 2.9 the density lived in this browser's localStorage.
try {
const legacy = window.localStorage.getItem(LEGACY_DENSITY_KEY);
if (legacy === null) return;
window.localStorage.removeItem(LEGACY_DENSITY_KEY);
if (DENSITIES.includes(legacy as ToolbarDensity)) {
setUiPref("toolbarDensity", legacy as ToolbarDensity);
}
} catch {
// Storage can be unavailable in hardened browser contexts.
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const [density, setDensity] = useState<SelectedToolbarDensity>(
() => settings.density ?? readStoredDensity(),
() => settings.density ?? preferredDensity,
);
const [responsiveDensity, setResponsiveDensity] =
useState<SelectedToolbarDensity>(() => density);
@@ -217,26 +232,12 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
setPosition(next);
}, [anchor]);
// A preset (or another tab) changing the saved density applies here too.
useEffect(() => {
if (!rememberDensity) return;
if (desktopViewportReady !== true || isMobile !== false) return;
try {
window.localStorage.setItem(DENSITY_STORAGE_KEY, density);
} catch {
// Storage can be unavailable in hardened browser contexts.
}
}, [density, desktopViewportReady, isMobile, rememberDensity]);
// Picking an interface preset seeds this key, so pick the new value up
// without needing the tab to remount.
useEffect(() => {
if (!rememberDensity) return;
if (desktopViewportReady !== true || isMobile !== false) return;
const handler = () => setDensity(readStoredDensity());
window.addEventListener("terminalToolbarDensityChanged", handler);
return () =>
window.removeEventListener("terminalToolbarDensityChanged", handler);
}, [desktopViewportReady, isMobile, rememberDensity]);
setDensity(preferredDensity);
}, [preferredDensity, desktopViewportReady, isMobile, rememberDensity]);
useEffect(() => {
if (desktopViewportReady !== true || isMobile !== false) return;
const measurement = measurementRef.current;
@@ -375,6 +376,7 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
const setAndStoreDensity = (next: ToolbarDensity) => {
setDensity(next);
if (rememberDensity) setUiPref("toolbarDensity", next);
};
const reportImageResult = (
action: () => void | Promise<void>,
@@ -692,7 +694,10 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
}}
>
{collapsed ? (
<div className="terminal-toolbar-collapsed-shell flex rounded-sm border border-border bg-background/90 p-0.5 shadow-lg backdrop-blur-sm">
<div
data-toolbar-collapsed
className="relative flex rounded-sm border border-border bg-background/90 p-0.5 shadow-lg backdrop-blur-sm"
>
{expandButton}
{grabButton()}
</div>
@@ -48,9 +48,7 @@ describe("ssh-terminal activate", () => {
const history = server.mock.services.get(
"terminal.history",
) as TerminalHistoryV1;
const listed = await server.mock.ctx.asUser("user-1", () =>
history.list(1, 10),
);
const listed = await server.mock.actAs("user-1", () => history.list(1, 10));
expect(listed.map((entry) => entry.command)).toEqual(["df -h"]);
});
@@ -59,7 +57,7 @@ describe("ssh-terminal activate", () => {
const live = server.mock.services.get(
"sessions.live#ssh",
) as LiveSessionsV1;
const other = await server.mock.ctx.asUser("user-2", async () =>
const other = await server.mock.actAs("user-2", async () =>
live.listForUser("user-1"),
);
expect(other).toEqual([]);
@@ -264,7 +264,7 @@ describe("storeImageViaSftp", () => {
stream.destroyed = true;
};
streams.push(stream);
stream.end = (data: Buffer) => {
(stream as { end: unknown }).end = (data: Buffer) => {
if (behavior.stallWrite) return;
queueMicrotask(() => {
if (behavior.writeError) {
@@ -45,7 +45,7 @@ describe("MemoryAgent", () => {
expect((parsed as ParsedKey).verify(data, signature)).toBe(true);
client.destroy();
stream.destroy();
(stream as unknown as { destroy: () => void }).destroy();
});
});
@@ -98,7 +98,7 @@ describe("MemoryAgent unsupported extension framing", () => {
expect(replies[1][0]).toBe(12); // The following identities request succeeds.
expect(replies[1].readUInt32BE(1)).toBe(1);
} finally {
stream.destroy();
(stream as unknown as { destroy: () => void }).destroy();
}
},
);
@@ -23,7 +23,6 @@ vi.mock(
vi.mock("@termix/plugin-sdk/ui", () => ({
useIsMobile: () => false,
useTabsSafe: () => ({ previewTerminalTheme: null }),
}));
import { TerminalTabContent } from "../../../src/frontend/terminal/TerminalTabContent";
@@ -56,8 +56,22 @@ vi.mock("@termix/plugin-sdk/frontend", async (importOriginal) => ({
slotId === "terminal.toolbar" ? slots.toolbar : [],
useHostActions: () => slots.hostActions,
useTabs: () => slots.tabs,
usePluginUiPreferences: () => ({
values: uiPrefs.values,
set: uiPrefs.set,
}),
}));
const uiPrefs = vi.hoisted(() => {
const values: Record<string, unknown> = {};
return {
values,
set: vi.fn((key: string, value: unknown) => {
values[key] = value;
}),
};
});
vi.mock("@termix/plugin-sdk/ui", async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
useIsMobile: () => mobileApi.isMobile,
@@ -111,6 +125,8 @@ function renderToolbar(
// jsdom lacks the pointer APIs Radix Select needs to open its popup.
beforeEach(() => {
for (const key of Object.keys(uiPrefs.values)) delete uiPrefs.values[key];
uiPrefs.set.mockClear();
Element.prototype.hasPointerCapture ??= () => false;
Element.prototype.setPointerCapture ??= () => {};
Element.prototype.releasePointerCapture ??= () => {};
@@ -406,9 +422,7 @@ describe("TerminalToolbar Phase 1", () => {
screen.getByRole("option", { name: "Expanded with metrics" }),
);
await waitFor(() =>
expect(localStorage.getItem("termix-terminal-toolbar-density")).toBe(
"expanded",
),
expect(uiPrefs.set).toHaveBeenCalledWith("toolbarDensity", "expanded"),
);
});
@@ -561,7 +575,7 @@ describe("TerminalToolbar Phase 1", () => {
});
expect(collapsedMover).toBeVisible();
expect(
container.querySelector(".terminal-toolbar-collapsed-shell"),
container.querySelector("[data-toolbar-collapsed]"),
).toBeInTheDocument();
collapsedMover.focus();
expect(toolbar).toHaveClass("focus-within:opacity-100");
@@ -1018,12 +1032,21 @@ describe("TerminalToolbar Phase 1", () => {
});
it("uses the host display mode without overwriting the saved one", () => {
localStorage.setItem("termix-terminal-toolbar-density", "labeled");
uiPrefs.values.toolbarDensity = "labeled";
uiPrefs.set.mockClear();
renderToolbar({ host: withSettings({ terminalToolbarDisplay: "icon" }) });
expect(screen.queryByText("Upload image")).toBeNull();
expect(localStorage.getItem("termix-terminal-toolbar-density")).toBe(
"labeled",
);
expect(uiPrefs.set).not.toHaveBeenCalled();
});
it("moves a density saved in this browser into the plugin preferences", () => {
localStorage.setItem("termix-terminal-toolbar-density", "icon");
uiPrefs.set.mockClear();
renderToolbar();
expect(uiPrefs.set).toHaveBeenCalledWith("toolbarDensity", "icon");
expect(
localStorage.getItem("termix-terminal-toolbar-density"),
).toBeNull();
});
it("stays fully visible when fading is off", () => {