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