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
@@ -6,13 +6,26 @@
|
||||
"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": {
|
||||
"acme-client": "^5.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"jsdom": "^30.0.1",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"express": "^5.2.1",
|
||||
"react": "^19.2.8",
|
||||
"sonner": "^2.0.8"
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,8 @@
|
||||
"network:outbound",
|
||||
"notify:send",
|
||||
"network:serve",
|
||||
"ui:surface"
|
||||
"ui:surface",
|
||||
"users:impersonate"
|
||||
],
|
||||
"optionalDependencies": {
|
||||
"workspaces": "^1.0.0",
|
||||
@@ -185,7 +186,8 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"uses": ["terminal.sidePanel", "terminal.toolbar"]
|
||||
},
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
|
||||
+24
-2
@@ -6,10 +6,32 @@
|
||||
"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": {
|
||||
"@anthropic-ai/sdk": "^0.120.0",
|
||||
"lucide-react": "^1.33.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@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",
|
||||
"react": "^19.2.8",
|
||||
"sonner": "^2.0.8",
|
||||
"ssh2": "^1.17.0"
|
||||
}
|
||||
}
|
||||
@@ -113,7 +113,7 @@ describe("createAiGate", () => {
|
||||
|
||||
it("passes an opted-in user through with their access attached", async () => {
|
||||
const next = vi.fn();
|
||||
const req: { aiAccess?: unknown } = {};
|
||||
const req: Parameters<ReturnType<typeof createAiGate>>[0] = {};
|
||||
await createAiGate(
|
||||
settings({ globallyEnabled: true }, { enabled: true }),
|
||||
() => "user-1",
|
||||
|
||||
@@ -6,14 +6,29 @@
|
||||
"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": {
|
||||
"lucide-react": "^1.33.0",
|
||||
"nodemailer": "^10.0.10"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*",
|
||||
"@types/nodemailer": "^8.0.2"
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@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",
|
||||
"react": "^19.2.8",
|
||||
"sonner": "^2.0.8"
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,8 @@
|
||||
"network:outbound",
|
||||
"network:serve",
|
||||
"settings:read-core",
|
||||
"ui:surface"
|
||||
"ui:surface",
|
||||
"users:impersonate"
|
||||
],
|
||||
"optionalDependencies": {
|
||||
"fleets": "^1.0.0",
|
||||
@@ -119,7 +120,8 @@
|
||||
],
|
||||
"http": {
|
||||
"legacyPaths": ["/automations/webhook"]
|
||||
}
|
||||
},
|
||||
"uses": ["alerts.openChannels"]
|
||||
},
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"],
|
||||
|
||||
@@ -6,10 +6,29 @@
|
||||
"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": {
|
||||
"lucide-react": "^1.33.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@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",
|
||||
"react": "^19.2.8",
|
||||
"sonner": "^2.0.8",
|
||||
"ssh2": "^1.17.0"
|
||||
}
|
||||
}
|
||||
@@ -61,7 +61,7 @@ describe("automations activate", () => {
|
||||
capabilities: manifest.capabilities.filter((c) => c !== "notify:send"),
|
||||
});
|
||||
await expect(
|
||||
withoutNotify.ctx.notify.send([1], { title: "t", body: "b" }),
|
||||
withoutNotify.ctx.notify.send({ title: "t", body: "b" } as never),
|
||||
).rejects.toBeInstanceOf(PluginCapabilityError);
|
||||
});
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ describe("onMetrics", () => {
|
||||
});
|
||||
|
||||
expect(run).toHaveBeenCalledTimes(1);
|
||||
expect(run.mock.calls[0][0]).toMatchObject({
|
||||
expect((run.mock.calls[0] as unknown[])[0]).toMatchObject({
|
||||
triggerType: "metric_threshold",
|
||||
triggerHostId: 7,
|
||||
});
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
"ssh:connect",
|
||||
"network:serve",
|
||||
"ui:surface",
|
||||
"events:core"
|
||||
"events:core",
|
||||
"users:impersonate"
|
||||
],
|
||||
"provides": [
|
||||
{
|
||||
@@ -93,7 +94,8 @@
|
||||
"viewMode": "detail",
|
||||
"containerLayout": "table"
|
||||
}
|
||||
}
|
||||
},
|
||||
"uses": ["homepage.widgets", "terminal.resolveTheme"]
|
||||
},
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
|
||||
@@ -6,10 +6,43 @@
|
||||
"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-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/react": "^16.3.3",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"jsdom": "^30.0.1",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"express": "^5.2.1",
|
||||
"react": "^19.2.8",
|
||||
"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,3 +1,5 @@
|
||||
// The terminal surfaces need xterm's own stylesheet.
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import type { ComponentType } from "react";
|
||||
import { Box } from "lucide-react";
|
||||
import type {
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"nav": {
|
||||
"files": "Files",
|
||||
"sftp": "SFTP"
|
||||
"sftp": "SFTP",
|
||||
"openFileManager": "Open File Manager"
|
||||
},
|
||||
"permissions": {
|
||||
"use": {
|
||||
@@ -520,6 +521,9 @@
|
||||
"transferError": "Transfer failed",
|
||||
"transferFailedRetryHint": "Partial data was kept on the destination. Retry will resume when the connection is back.",
|
||||
"transferPartialHint": "Could not transfer: {{paths}}",
|
||||
"transferSuccess": "Transfer complete"
|
||||
"transferSuccess": "Transfer complete",
|
||||
"metricsHopSourceRead": "Source → server: {{throughput}}",
|
||||
"metricsHopDestSftpWrite": "Server → dest (SFTP): {{throughput}}",
|
||||
"metricsHopDestLocalWrite": "Server → dest (local): {{throughput}}"
|
||||
}
|
||||
}
|
||||
@@ -23,7 +23,8 @@
|
||||
"network:serve",
|
||||
"ui:surface",
|
||||
"events:core",
|
||||
"kv:own"
|
||||
"kv:own",
|
||||
"users:impersonate"
|
||||
],
|
||||
"provides": [
|
||||
{
|
||||
@@ -108,7 +109,19 @@
|
||||
"key": "enableFileManager",
|
||||
"labelKey": "hostCapability.label",
|
||||
"editorTab": "plugins"
|
||||
}
|
||||
},
|
||||
"uiPresets": {
|
||||
"simple": {
|
||||
"viewMode": "grid"
|
||||
},
|
||||
"balanced": {
|
||||
"viewMode": "grid"
|
||||
},
|
||||
"advanced": {
|
||||
"viewMode": "list"
|
||||
}
|
||||
},
|
||||
"uses": ["homepage.widgets", "host-metrics.disk", "terminal.open"]
|
||||
},
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
|
||||
@@ -6,10 +6,56 @@
|
||||
"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": {
|
||||
"@codemirror/autocomplete": "^6.20.3",
|
||||
"@codemirror/commands": "^6.11.1",
|
||||
"@codemirror/search": "^6.7.2",
|
||||
"@codemirror/theme-one-dark": "^6.1.3",
|
||||
"@codemirror/view": "^6.43.12",
|
||||
"@monaco-editor/react": "^4.7.0",
|
||||
"@tanstack/react-virtual": "^3.14.13",
|
||||
"@uiw/codemirror-extensions-langs": "^4.25.11",
|
||||
"@uiw/react-codemirror": "^4.25.11",
|
||||
"busboy": "^1.6.0",
|
||||
"jszip": "^3.10.2",
|
||||
"lucide-react": "^1.33.0",
|
||||
"motion": "^13.1.1",
|
||||
"pdfjs-dist": "5.4.296",
|
||||
"react-h5-audio-player": "^3.10.2",
|
||||
"react-icons": "^5.7.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"react-pdf": "^10.5.0",
|
||||
"react-photo-view": "^1.2.7",
|
||||
"react-syntax-highlighter": "^16.1.1",
|
||||
"remark-gfm": "^4.0.1"
|
||||
},
|
||||
"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",
|
||||
"axios": "^1.19.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"express": "^5.2.1",
|
||||
"i18next": "^26.4.2",
|
||||
"react": "^19.2.8",
|
||||
"react-dom": "^19.2.8",
|
||||
"react-i18next": "^17.0.14",
|
||||
"sonner": "^2.0.8",
|
||||
"ssh2": "^1.17.0"
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,7 @@ import {
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { ConnectionLogPanel } from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
usePluginUiPreferences,
|
||||
invokeAction,
|
||||
useConnectionRetry,
|
||||
logActivity,
|
||||
@@ -177,25 +178,32 @@ function FileManagerContent({
|
||||
const [isReconnecting, setIsReconnecting] = useState<boolean>(false);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [lastRefreshTime, setLastRefreshTime] = useState<number>(0);
|
||||
const [viewMode, setViewMode] = useState<"grid" | "list">(() => {
|
||||
const saved = localStorage.getItem("fileManagerViewMode");
|
||||
return saved === "grid" || saved === "list" ? saved : "grid";
|
||||
});
|
||||
// An interface preset sets this too, so it is a plugin UI preference.
|
||||
const { values: fmPrefs, set: setFmPref } = usePluginUiPreferences<{
|
||||
viewMode: "grid" | "list";
|
||||
}>();
|
||||
const viewMode: "grid" | "list" =
|
||||
fmPrefs.viewMode === "list" ? "list" : "grid";
|
||||
const setViewMode = useCallback(
|
||||
(next: "grid" | "list") => setFmPref("viewMode", next),
|
||||
[setFmPref],
|
||||
);
|
||||
useEffect(() => {
|
||||
// Before 2.9 the view mode lived in this browser's localStorage.
|
||||
try {
|
||||
const legacy = localStorage.getItem("fileManagerViewMode");
|
||||
if (legacy === null) return;
|
||||
localStorage.removeItem("fileManagerViewMode");
|
||||
if (legacy === "grid" || legacy === "list") setFmPref("viewMode", legacy);
|
||||
} catch {
|
||||
// Storage can be unavailable.
|
||||
}
|
||||
}, []);
|
||||
const [density, setDensity] = useState<"comfortable" | "compact">(() =>
|
||||
localStorage.getItem("fileManagerDensity") === "compact"
|
||||
? "compact"
|
||||
: "comfortable",
|
||||
);
|
||||
// Picking an interface preset seeds this key from another part of the app.
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
const saved = localStorage.getItem("fileManagerViewMode");
|
||||
if (saved === "grid" || saved === "list") setViewMode(saved);
|
||||
};
|
||||
window.addEventListener("fileManagerViewModeChanged", handler);
|
||||
return () =>
|
||||
window.removeEventListener("fileManagerViewModeChanged", handler);
|
||||
}, []);
|
||||
const [sortBy, setSortBy] = useState<"name" | "modified" | "size">(() => {
|
||||
const saved = localStorage.getItem("fileManagerSortBy");
|
||||
return saved === "name" || saved === "modified" || saved === "size"
|
||||
@@ -3328,10 +3336,6 @@ function FileManagerContent({
|
||||
}
|
||||
}, [currentHost?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("fileManagerViewMode", viewMode);
|
||||
}, [viewMode]);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem("fileManagerDensity", density);
|
||||
}, [density]);
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Kbd, KbdKey, KbdSeparator } from "@termix/plugin-sdk/ui";
|
||||
import type { LocalFileEntry } from "@termix/plugin-sdk/ui";
|
||||
import type { LocalFileEntry } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
const VIEWPORT_PADDING = 16;
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { cn } from "@termix/plugin-sdk/ui";
|
||||
import { Button, Input } from "@termix/plugin-sdk/ui";
|
||||
import type { LocalFileEntry } from "@termix/plugin-sdk/ui";
|
||||
import type { LocalFileEntry } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
createLocalFile,
|
||||
createLocalFolder,
|
||||
|
||||
@@ -9,10 +9,11 @@
|
||||
// configured Remote Sync server) and the main process resolves the rest, so a
|
||||
// compromised renderer cannot turn the transfer bridge into an HTTP client.
|
||||
|
||||
import type {
|
||||
LocalTransferOrigin,
|
||||
LocalTransferProgress,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import {
|
||||
getLocalAuthToken,
|
||||
type LocalTransferOrigin,
|
||||
type LocalTransferProgress,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { getSessionOrigin } from "./client";
|
||||
import { getDeviceId } from "@termix/plugin-sdk/ui";
|
||||
|
||||
@@ -67,11 +68,7 @@ function transferOriginFor(sessionId: string): LocalTransferOrigin {
|
||||
*/
|
||||
function localAuthTokenFor(origin: LocalTransferOrigin): string | undefined {
|
||||
if (origin !== "local") return undefined;
|
||||
try {
|
||||
return localStorage.getItem("jwt") || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
return getLocalAuthToken() ?? undefined;
|
||||
}
|
||||
|
||||
export function createLocalTransferId(prefix: string): string {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { createElement } from "react";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { FileItem } from "../host-types";
|
||||
import type { LocalWalkResult } from "@termix/plugin-sdk/ui";
|
||||
import type { LocalWalkResult } from "@termix/plugin-sdk/frontend";
|
||||
import { useSettings } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
cancelLocalTransfer,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fileManagerHostSetting } from "./host-settings";
|
||||
import type { ComponentType } from "react";
|
||||
import { FolderSearch, ArrowLeftRight } from "lucide-react";
|
||||
import { FolderOpen, FolderSearch, ArrowLeftRight } from "lucide-react";
|
||||
import {
|
||||
invokeAction,
|
||||
type PluginHostRecord,
|
||||
@@ -70,8 +70,14 @@ function openEditorAction(
|
||||
app.tabs.openTab(host, "files", { data: { initialFilePath: filePath } });
|
||||
}
|
||||
|
||||
/** Where this plugin serves the streaming routes the desktop app calls. */
|
||||
const TRANSFER_API_PATH = "/plugin-api/file-manager";
|
||||
|
||||
export function activate(app: TermixApp): void {
|
||||
setFileManagerApp(app);
|
||||
if (typeof window !== "undefined") {
|
||||
void window.electronAPI?.localTransfer?.setApiPath?.(TRANSFER_API_PATH);
|
||||
}
|
||||
app.onDispose(() => setFileManagerApp(null));
|
||||
app.registerTab("files", FilesTab as unknown as ComponentType<TabProps>, {
|
||||
icon: FolderSearch,
|
||||
@@ -135,8 +141,21 @@ export function activate(app: TermixApp): void {
|
||||
const openHost = ((host: PluginHostRecord | null, path?: string) =>
|
||||
openHostAction(app, host, path)) as never;
|
||||
app.registerAction("files.openHost", openHost);
|
||||
// The shell's "open in file manager" tab button asks for this.
|
||||
app.registerAction("host.openFiles", openHost);
|
||||
// "Open File Manager" in the tab bar's menu, for a terminal tab. The
|
||||
// terminal answers with its working directory and opens the files tab.
|
||||
app.registerAction("file-manager.openFromTab", ((handle: unknown) =>
|
||||
(
|
||||
handle as { openFileManager?: () => void } | null
|
||||
)?.openFileManager?.()) as never);
|
||||
app.registerSlotContribution("tab.menu", {
|
||||
actionId: "file-manager.openFromTab",
|
||||
titleKey: "nav.openFileManager",
|
||||
icon: FolderOpen,
|
||||
kind: "button",
|
||||
when: (context) =>
|
||||
typeof (context.handle as { openFileManager?: unknown } | null)
|
||||
?.openFileManager === "function",
|
||||
});
|
||||
app.registerAction("files.openEditor", ((
|
||||
host: PluginHostRecord | null,
|
||||
filePath: string,
|
||||
|
||||
@@ -8,7 +8,7 @@ import type {
|
||||
LocalFsResult,
|
||||
LocalTrashResult,
|
||||
LocalWalkResult,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { isElectron } from "@termix/plugin-sdk/ui";
|
||||
|
||||
export function isLocalFileBrowserAvailable(): boolean {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Pure helpers shared by the dual-pane (local <-> remote) file manager code.
|
||||
// Kept free of React/DOM so they can be unit tested directly.
|
||||
|
||||
import type { LocalFileEntry } from "@termix/plugin-sdk/ui";
|
||||
import type { LocalFileEntry } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
/** Custom MIME type carried by drags that originate in the local pane. */
|
||||
export const LOCAL_FILES_DRAG_MIME = "application/x-termix-local-files";
|
||||
|
||||
@@ -12,7 +12,9 @@ function setup(remote: object, owned = true) {
|
||||
(req: Request, res: Response) => Promise<unknown>
|
||||
>();
|
||||
registerFileDownloadRoutes(
|
||||
{ post: (path, handler) => routes.set(path, handler) } as Express,
|
||||
{
|
||||
post: (path, handler) => routes.set(path, handler),
|
||||
} as unknown as Express,
|
||||
{
|
||||
ctx: {
|
||||
currentActor: () => "user",
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
LOCAL_FILES_DRAG_MIME,
|
||||
REMOTE_FILES_DRAG_MIME,
|
||||
} from "../../src/frontend/local-transfer-utils";
|
||||
import type { LocalFileEntry } from "@termix/plugin-sdk/ui";
|
||||
import type { LocalFileEntry } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
// jsdom has no layout, so render every row instead of a virtual window.
|
||||
vi.mock("@tanstack/react-virtual", () => ({
|
||||
|
||||
@@ -57,12 +57,15 @@ describe(`${manifest.id} activate`, () => {
|
||||
expect(rendered.registered.hostEditorSections()).toEqual([]);
|
||||
});
|
||||
|
||||
it("registers its open actions, including the shell's host.openFiles", async () => {
|
||||
it("registers its open actions and the tab menu entry", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
expect(rendered.registered.actions().sort()).toEqual([
|
||||
"file-manager.openFromTab",
|
||||
"files.openEditor",
|
||||
"files.openHost",
|
||||
"host.openFiles",
|
||||
]);
|
||||
expect(rendered.registered.slot("tab.menu")).toEqual([
|
||||
"file-manager.openFromTab",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
serializeLocalFilesDragPayload,
|
||||
sortLocalEntries,
|
||||
} from "../../src/frontend/local-transfer-utils";
|
||||
import type { LocalFileEntry } from "@termix/plugin-sdk/ui";
|
||||
import type { LocalFileEntry } from "@termix/plugin-sdk/frontend";
|
||||
|
||||
describe("drag payloads", () => {
|
||||
it("round-trips local file payloads", () => {
|
||||
|
||||
@@ -70,6 +70,7 @@
|
||||
"descriptionKey": "permissions.execute.description",
|
||||
"defaultRoles": ["user"]
|
||||
}
|
||||
]
|
||||
],
|
||||
"uses": ["snippets.extractInputs"]
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,31 @@
|
||||
"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": {
|
||||
"clsx": "^2.1.1",
|
||||
"jszip": "^3.10.2",
|
||||
"lucide-react": "^1.33.0",
|
||||
"tailwind-merge": "^3.5.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"@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",
|
||||
"sonner": "^2.0.8",
|
||||
"ssh2": "^1.17.0"
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ afterEach(() => {
|
||||
});
|
||||
|
||||
function fakeHosts(hosts: PluginHostSummary[]): PluginHosts {
|
||||
return {
|
||||
return <PluginHosts>(<unknown>{
|
||||
list: async () => hosts,
|
||||
get: async (hostId) => hosts.find((h) => h.id === hostId) ?? null,
|
||||
checkAccess: async (hostId) => {
|
||||
@@ -37,7 +37,7 @@ function fakeHosts(hosts: PluginHostSummary[]): PluginHosts {
|
||||
share: async (hostId) => ({ hostId, shared: true }),
|
||||
listUsers: async () => [],
|
||||
listRoles: async () => [],
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
async function setup(hosts: PluginHostSummary[]) {
|
||||
|
||||
@@ -19,15 +19,14 @@ describe("fleets.access", () => {
|
||||
const service = server.mock.services.get("fleets.access") as FleetsService;
|
||||
|
||||
await expect(
|
||||
server.mock.ctx.asUser("user-1", () => service.create({ name: "prod" })),
|
||||
server.mock.actAs("user-1", () => service.create({ name: "prod" })),
|
||||
).rejects.toThrow("fleets.manage");
|
||||
});
|
||||
|
||||
it("adds only hosts the caller can see", async () => {
|
||||
server = await startServer();
|
||||
const service = server.mock.services.get("fleets.access") as FleetsService;
|
||||
const run = <T>(fn: () => Promise<T>) =>
|
||||
server!.mock.ctx.asUser("user-1", fn);
|
||||
const run = <T>(fn: () => Promise<T>) => server!.mock.actAs("user-1", fn);
|
||||
|
||||
const fleet = await run(() => service.create({ name: "prod" }));
|
||||
await run(() => service.addMember(fleet.id, 10));
|
||||
|
||||
@@ -6,10 +6,27 @@
|
||||
"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": {
|
||||
"lucide-react": "^1.33.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@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",
|
||||
"react": "^19.2.8"
|
||||
}
|
||||
}
|
||||
@@ -333,7 +333,9 @@ export function HomepageCanvas({
|
||||
scheduleSave(next);
|
||||
return next;
|
||||
});
|
||||
} catch {}
|
||||
} catch {
|
||||
// A bad layout write is dropped; the canvas keeps what it shows.
|
||||
}
|
||||
},
|
||||
[scheduleSave],
|
||||
);
|
||||
@@ -376,7 +378,9 @@ export function HomepageCanvas({
|
||||
scheduleSave(next);
|
||||
return next;
|
||||
});
|
||||
} catch {}
|
||||
} catch {
|
||||
// A bad layout write is dropped; the canvas keeps what it shows.
|
||||
}
|
||||
},
|
||||
[scheduleSave],
|
||||
);
|
||||
@@ -403,7 +407,9 @@ export function HomepageCanvas({
|
||||
setWidgets((prev) =>
|
||||
prev.map((w) => (w.id === id ? { ...w, title, config } : w)),
|
||||
);
|
||||
} catch {}
|
||||
} catch {
|
||||
// A bad layout write is dropped; the canvas keeps what it shows.
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -93,10 +93,6 @@ export function activate(app: TermixApp): void {
|
||||
component: HomepagePreviewCard,
|
||||
});
|
||||
|
||||
app.declareActionSlot({
|
||||
id: "dashboard.secondaryView",
|
||||
accepts: ["component"],
|
||||
});
|
||||
app.registerSlotContribution("dashboard.secondaryView", {
|
||||
actionId: "homepage",
|
||||
titleKey: "nav.homepage",
|
||||
|
||||
@@ -4,7 +4,7 @@ import {
|
||||
useHostActions,
|
||||
useHostStatus,
|
||||
useHosts,
|
||||
type PluginHostRecord,
|
||||
useTabs,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
|
||||
import { registerWidget } from "./WidgetRegistry";
|
||||
@@ -18,14 +18,6 @@ import { GRID_SIZE } from "../types.js";
|
||||
import { quickConnectTargets } from "../quick-connect-targets.js";
|
||||
import { WidgetTitle } from "@termix/plugin-sdk/ui";
|
||||
|
||||
function openTab(host: PluginHostRecord, type: QuickConnectType) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("termix:open-tab", {
|
||||
detail: { hostId: String(host.id), type },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function StatusDot({ hostId }: { hostId: number }) {
|
||||
const status = useHostStatus(hostId);
|
||||
const cls =
|
||||
@@ -43,6 +35,7 @@ function QuickConnectWidget({
|
||||
}: WidgetComponentProps<QuickConnectConfig>) {
|
||||
const { t } = useTranslation();
|
||||
const { hosts: allHosts, loaded } = useHosts();
|
||||
const tabs = useTabs();
|
||||
const targets = quickConnectTargets(useHostActions());
|
||||
|
||||
const hosts =
|
||||
@@ -104,7 +97,7 @@ function QuickConnectWidget({
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openTab(host, target.type);
|
||||
tabs.connectHost(host, target.type);
|
||||
}}
|
||||
className="p-1.5 text-muted-foreground hover:text-accent-brand hover:bg-accent-brand/10 transition-colors border border-transparent hover:border-accent-brand/20"
|
||||
>
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
"db:own",
|
||||
"network:serve",
|
||||
"ui:surface",
|
||||
"events:core"
|
||||
"events:core",
|
||||
"users:impersonate"
|
||||
],
|
||||
"provides": [
|
||||
{
|
||||
@@ -153,7 +154,12 @@
|
||||
"advanced": {
|
||||
"columns": 4
|
||||
}
|
||||
}
|
||||
},
|
||||
"uses": [
|
||||
"homepage.hostMetrics",
|
||||
"homepage.widgets",
|
||||
"terminal.toolbarStatus"
|
||||
]
|
||||
},
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
|
||||
@@ -6,10 +6,30 @@
|
||||
"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": {
|
||||
"lucide-react": "^1.33.0"
|
||||
},
|
||||
"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",
|
||||
"@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",
|
||||
"react": "^19.2.8",
|
||||
"sonner": "^2.0.8",
|
||||
"ssh2": "^1.17.0"
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,6 @@ type PluginSettingsBag = Record<string, Record<string, unknown>>;
|
||||
*/
|
||||
export function HostStatsTab({
|
||||
form,
|
||||
setField,
|
||||
updateForm,
|
||||
host,
|
||||
}: HostEditorSectionProps) {
|
||||
|
||||
@@ -27,7 +27,7 @@ function RowBar({ label, value }: { label: string; value: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** CPU, RAM and disk bars in a dashboard host row ("dashboard.hostMetrics"). */
|
||||
/** CPU, RAM and disk bars in a dashboard host row ("dashboard.hostRow"). */
|
||||
export function createDashboardHostMetrics(store: MetricsSummaryStore) {
|
||||
return function DashboardHostMetrics(props: Record<string, unknown>) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -13,13 +13,6 @@ import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import { type MetricsHistoryRow, useHostMetricsApi } from "../host-metrics-api";
|
||||
import { CardTimeTabs, type HistoryTab } from "./CardTimeTabs";
|
||||
|
||||
function computeChartData(rows: MetricsHistoryRow[]) {
|
||||
return {
|
||||
data: rows.map((r) => r.cpu_percent),
|
||||
timestamps: rows.map((r) => r.ts),
|
||||
};
|
||||
}
|
||||
|
||||
export function CpuCard({
|
||||
metrics,
|
||||
history,
|
||||
|
||||
@@ -111,7 +111,7 @@ export async function activate(app: TermixApp): Promise<void> {
|
||||
});
|
||||
|
||||
// The usage bars in the dashboard's host status card.
|
||||
app.registerSlotContribution("dashboard.hostMetrics", {
|
||||
app.registerSlotContribution("dashboard.hostRow", {
|
||||
actionId: "host-metrics.dashboardHost",
|
||||
titleKey: "nav.hostMetrics",
|
||||
kind: "component",
|
||||
|
||||
@@ -32,8 +32,10 @@ describe("deriveEnabledWidgets", () => {
|
||||
|
||||
it("output is always a subset of the 10 known WidgetTypes", () => {
|
||||
const slots = METRIC_CARD_IDS.map((id) => ({ id })).concat([
|
||||
{ id: "user_manager" } as { id: (typeof METRIC_CARD_IDS)[number] },
|
||||
{ id: "bogus" } as { id: (typeof METRIC_CARD_IDS)[number] },
|
||||
{ id: "user_manager" } as unknown as {
|
||||
id: (typeof METRIC_CARD_IDS)[number];
|
||||
},
|
||||
{ id: "bogus" } as unknown as { id: (typeof METRIC_CARD_IDS)[number] },
|
||||
]);
|
||||
const out = deriveEnabledWidgets(slots);
|
||||
expect(out).toEqual(METRIC_CARD_IDS);
|
||||
|
||||
@@ -40,7 +40,7 @@ describe(`${manifest.id} activate`, () => {
|
||||
|
||||
it("fills core's metric slots and offers disk usage to other plugins", async () => {
|
||||
rendered = await renderWithApp(plugin, { manifest, locales });
|
||||
expect(rendered.registered.slot("dashboard.hostMetrics")).toEqual([
|
||||
expect(rendered.registered.slot("dashboard.hostRow")).toEqual([
|
||||
"host-metrics.dashboardHost",
|
||||
]);
|
||||
expect(rendered.registered.slot("homepage.hostMetrics")).toEqual([
|
||||
|
||||
@@ -6,14 +6,29 @@
|
||||
"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": {
|
||||
"ldapjs": "^3.0.7"
|
||||
"ldapjs": "^3.0.7",
|
||||
"lucide-react": "^1.33.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*",
|
||||
"@types/ldapjs": "^3.0.6"
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@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",
|
||||
"react": "^19.2.8",
|
||||
"sonner": "^2.0.8"
|
||||
}
|
||||
}
|
||||
@@ -6,14 +6,27 @@
|
||||
"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": {
|
||||
"cytoscape": "^3.34.3",
|
||||
"lucide-react": "^1.33.0",
|
||||
"react-cytoscapejs": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"@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",
|
||||
"react": "^19.2.8"
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,8 @@ import {
|
||||
useTranslation,
|
||||
usePluginApi,
|
||||
useHosts,
|
||||
useTabs,
|
||||
getHostStatusColorScheme,
|
||||
useHostActions,
|
||||
type PluginHostRecord,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
@@ -101,24 +103,6 @@ interface NetworkGraphCardProps {
|
||||
|
||||
type NetworkElement = NetworkTopologyNode | NetworkTopologyEdge;
|
||||
|
||||
type StatusColorScheme = "accent" | "status";
|
||||
|
||||
/** Reads the sidebar preferences cache directly, same as core's status dots do. */
|
||||
function readStatusColorScheme(): StatusColorScheme {
|
||||
try {
|
||||
const raw = localStorage.getItem("hostSidebarPreferences");
|
||||
if (!raw) return "accent";
|
||||
const parsed = JSON.parse(raw) as {
|
||||
display?: { statusColorScheme?: string };
|
||||
};
|
||||
return parsed?.display?.statusColorScheme === "status"
|
||||
? "status"
|
||||
: "accent";
|
||||
} catch {
|
||||
return "accent";
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCssVar(varName: string, fallback: string): string {
|
||||
const raw = getComputedStyle(document.documentElement)
|
||||
.getPropertyValue(varName)
|
||||
@@ -151,7 +135,7 @@ function buildNodeSvg(
|
||||
): string {
|
||||
const isOnline = status === "online";
|
||||
const isOffline = status === "offline";
|
||||
const useRealColors = readStatusColorScheme() === "status";
|
||||
const useRealColors = getHostStatusColorScheme() === "status";
|
||||
let statusColor: string;
|
||||
if (isOnline) {
|
||||
statusColor = useRealColors
|
||||
@@ -583,10 +567,10 @@ export function NetworkGraphCard({
|
||||
|
||||
const hideMenu = () => setContextMenu((p) => ({ ...p, visible: false }));
|
||||
|
||||
const shellTabs = useTabs();
|
||||
const fireOpen = (hostId: string, type?: string) => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent("termix:open-tab", { detail: { hostId, type } }),
|
||||
);
|
||||
const host = liveHosts.find((candidate) => String(candidate.id) === hostId);
|
||||
if (host) shellTabs.connectHost(host, type);
|
||||
};
|
||||
|
||||
const handleContextAction = (action: string) => {
|
||||
|
||||
@@ -38,7 +38,7 @@ describe("network-topology activate", () => {
|
||||
const service = server.mock.services.get("network-topology.graph") as {
|
||||
get: () => Promise<unknown>;
|
||||
};
|
||||
const result = await server.mock.ctx.asUser("user-1", () => service.get());
|
||||
const result = await server.mock.actAs("user-1", () => service.get());
|
||||
expect(result).toEqual({ nodes: [{ data: { id: "1" } }], edges: [] });
|
||||
});
|
||||
|
||||
@@ -47,7 +47,7 @@ describe("network-topology activate", () => {
|
||||
const service = server.mock.services.get("network-topology.graph") as {
|
||||
get: () => Promise<unknown>;
|
||||
};
|
||||
const result = await server.mock.ctx.asUser("user-1", () => service.get());
|
||||
const result = await server.mock.actAs("user-1", () => service.get());
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
|
||||
@@ -52,7 +52,8 @@
|
||||
"to": "/callback"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"uses": ["terminal.overlay"]
|
||||
},
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
|
||||
@@ -6,14 +6,30 @@
|
||||
"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": {
|
||||
"js-yaml": "^5.2.3"
|
||||
"js-yaml": "^5.2.3",
|
||||
"lucide-react": "^1.33.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*",
|
||||
"@types/js-yaml": "^4.0.9"
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@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",
|
||||
"axios": "^1.19.0",
|
||||
"drizzle-orm": "^0.45.2",
|
||||
"express": "^5.2.1",
|
||||
"react": "^19.2.8",
|
||||
"sonner": "^2.0.8"
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,8 @@
|
||||
"proxmoxLastSyncSummary": "{{created}} created, {{updated}} updated, {{markedMissing}} missing, {{skipped}} skipped",
|
||||
"proxmoxLastSyncStatus": {
|
||||
"error": "Failed",
|
||||
"pending": "Pending"
|
||||
"pending": "Pending",
|
||||
"success": "Success"
|
||||
},
|
||||
"proxmoxDiscoverAction": "Discover & import Proxmox guests",
|
||||
"proxmoxImportTitle": "Import from Proxmox",
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
"ssh:connect",
|
||||
"db:own",
|
||||
"network:serve",
|
||||
"ui:surface"
|
||||
"ui:surface",
|
||||
"users:impersonate"
|
||||
],
|
||||
"contributes": {
|
||||
"tabs": [
|
||||
|
||||
@@ -6,10 +6,27 @@
|
||||
"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": {
|
||||
"lucide-react": "^1.33.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"@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",
|
||||
"react": "^19.2.8",
|
||||
"sonner": "^2.0.8",
|
||||
"ssh2": "^1.17.0"
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "@termix/plugin-sdk/frontend";
|
||||
import {
|
||||
notifyHostsChanged,
|
||||
useTranslation,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { Server, RefreshCw, CheckSquare, Square, Download } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Button } from "@termix/plugin-sdk/ui";
|
||||
@@ -201,7 +204,7 @@ export function ProxmoxDiscoverDialog({
|
||||
if (toImport.length) {
|
||||
const updated = await listHosts();
|
||||
onHostsChanged(updated);
|
||||
window.dispatchEvent(new CustomEvent("termix:hosts-changed"));
|
||||
notifyHostsChanged();
|
||||
}
|
||||
|
||||
const msg = [
|
||||
|
||||
@@ -1,12 +1,4 @@
|
||||
const SECRET_BACKED_AUTH_TYPES = new Set(["password", "key"]);
|
||||
const SECRETLESS_AUTH_TYPES = new Set([
|
||||
"none",
|
||||
"agent",
|
||||
"opkssh",
|
||||
"stepca",
|
||||
"tailscale",
|
||||
"vault",
|
||||
]);
|
||||
|
||||
export type ProxmoxImportAuth = {
|
||||
authType: string;
|
||||
@@ -18,8 +10,13 @@ export function resolveProxmoxImportAuth(
|
||||
defaultAuthType: string | undefined,
|
||||
credentialId: number | null | undefined,
|
||||
): ProxmoxImportAuth {
|
||||
// An explicit secretless auth choice (none/opkssh/tailscale/vault) wins.
|
||||
if (defaultAuthType && SECRETLESS_AUTH_TYPES.has(defaultAuthType)) {
|
||||
// Any auth type that needs no stored secret (none, agent, or one a plugin
|
||||
// adds) wins, the same rule the backend copy applies.
|
||||
if (
|
||||
defaultAuthType &&
|
||||
defaultAuthType !== "credential" &&
|
||||
!SECRET_BACKED_AUTH_TYPES.has(defaultAuthType)
|
||||
) {
|
||||
return { authType: defaultAuthType };
|
||||
}
|
||||
|
||||
@@ -35,14 +32,5 @@ export function resolveProxmoxImportAuth(
|
||||
};
|
||||
}
|
||||
|
||||
// Explicit non secret-backed special type without a credential.
|
||||
if (
|
||||
defaultAuthType &&
|
||||
defaultAuthType !== "credential" &&
|
||||
!SECRET_BACKED_AUTH_TYPES.has(defaultAuthType)
|
||||
) {
|
||||
return { authType: defaultAuthType };
|
||||
}
|
||||
|
||||
return { authType: "none" };
|
||||
}
|
||||
@@ -86,7 +86,7 @@ describe("ProxmoxPollingManager", () => {
|
||||
};
|
||||
const manager = new ProxmoxPollingManager<TestHost>({
|
||||
fetchHostById,
|
||||
withSshConnection,
|
||||
withSshConnection: withSshConnection as never,
|
||||
historyRepository,
|
||||
});
|
||||
return { manager, fetchHostById, withSshConnection, historyRepository };
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"name": "Termix"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": "https://github.com/Termix-SSH/Plugin-RemoteDesktop",
|
||||
"repository": "https://github.com/Termix-SSH/Termix",
|
||||
"category": "Terminal",
|
||||
"icon": "Monitor",
|
||||
"engine": {
|
||||
@@ -21,7 +21,8 @@
|
||||
"ssh:connect",
|
||||
"network:serve",
|
||||
"desktop:window",
|
||||
"ui:surface"
|
||||
"ui:surface",
|
||||
"users:impersonate"
|
||||
],
|
||||
"optionalDependencies": {
|
||||
"session-recording": "^1.0.0",
|
||||
@@ -442,7 +443,8 @@
|
||||
"labelKey": "settings.host.guacamoleConfig.label",
|
||||
"default": {},
|
||||
"hidden": true,
|
||||
"shareRead": "edit"
|
||||
"shareRead": "edit",
|
||||
"secretKeys": ["gateway-password"]
|
||||
},
|
||||
{
|
||||
"key": "enableToolbar",
|
||||
@@ -454,7 +456,8 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"uses": ["session.remoteDisplay", "wakeOnLan.macAddress"]
|
||||
},
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
|
||||
@@ -6,10 +6,35 @@
|
||||
"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": {
|
||||
"guacamole-common-js": "^1.5.0",
|
||||
"guacamole-lite": "^1.2.0",
|
||||
"lucide-react": "^1.33.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"jsdom": "^30.0.1",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"express": "^5.2.1",
|
||||
"react": "^19.2.8",
|
||||
"react-i18next": "^17.0.14",
|
||||
"sonner": "^2.0.8",
|
||||
"ssh2": "^1.17.0"
|
||||
},
|
||||
"termix": {
|
||||
"patches": [
|
||||
"guacamole-common-js-flush"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,396 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
// Run by `termix-plugin build` with TERMIX_PATCH_ROOT set to the plugin, so
|
||||
// the package resolves the way the plugin's bundle will resolve it.
|
||||
function packageDir(name) {
|
||||
const root = process.env.TERMIX_PATCH_ROOT || process.cwd();
|
||||
// Walked by hand: a package whose exports map hides package.json cannot be
|
||||
// found with require.resolve.
|
||||
for (let dir = path.resolve(root); ; dir = path.dirname(dir)) {
|
||||
const candidate = path.join(dir, "node_modules", name);
|
||||
if (fs.existsSync(path.join(candidate, "package.json"))) return candidate;
|
||||
if (path.dirname(dir) === dir) return path.join(root, "node_modules", name);
|
||||
}
|
||||
}
|
||||
|
||||
const guacdClientPath = path.join(
|
||||
packageDir("guacamole-lite"),
|
||||
"lib",
|
||||
"GuacdClient.js",
|
||||
);
|
||||
const cryptPath = path.join(packageDir("guacamole-lite"), "lib", "Crypt.js");
|
||||
const clientConnectionPath = path.join(
|
||||
packageDir("guacamole-lite"),
|
||||
"lib",
|
||||
"ClientConnection.js",
|
||||
);
|
||||
const serverPath = path.join(packageDir("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",
|
||||
);
|
||||
@@ -636,7 +636,8 @@ export function registerRoutes(router: Router, deps: RouteDeps): void {
|
||||
return res.status(400).json({ error: "Missing host" });
|
||||
}
|
||||
try {
|
||||
const result = await ctx.desktop.launchNativeRdp({
|
||||
const result = await ctx.desktop.launchExternalClient({
|
||||
protocol: "rdp",
|
||||
host: body.host,
|
||||
port: Number(body.port) || DEFAULT_PORT.rdp,
|
||||
username: typeof body.username === "string" ? body.username : undefined,
|
||||
|
||||
@@ -117,17 +117,8 @@ export class GuacamoleTokenService {
|
||||
}
|
||||
}
|
||||
|
||||
const jwtSecret = process.env.JWT_SECRET;
|
||||
if (jwtSecret) {
|
||||
return crypto
|
||||
.createHash("sha256")
|
||||
.update(jwtSecret + "_guacamole")
|
||||
.digest();
|
||||
}
|
||||
|
||||
this.log.warn("No persistent encryption key found, generating random key", {
|
||||
operation: "guac_key_generation",
|
||||
});
|
||||
// A token lives for seconds between issue and connect, so a fresh key
|
||||
// per boot is enough and nothing is derived from core's own secrets.
|
||||
return crypto.randomBytes(KEY_LENGTH);
|
||||
}
|
||||
|
||||
|
||||
@@ -15,13 +15,13 @@ import { buildGuacamoleWebSocketBaseUrl } from "./guacamole-websocket-url.ts";
|
||||
import {
|
||||
buildOriginWsUrl,
|
||||
getBasePath,
|
||||
guacStateToStage,
|
||||
isElectron,
|
||||
resolveConnectionOrigin,
|
||||
type ConnectionOrigin,
|
||||
type ConnectionStage,
|
||||
} from "@termix/plugin-sdk/ui";
|
||||
import { errorMessage } from "./host-remote";
|
||||
import { guacStateToStage } from "./guacamole-state.ts";
|
||||
import { isPasteShortcut, pasteTextToRemote } from "./guacamole-clipboard.ts";
|
||||
import { getGuacamoleDisplaySize } from "./guacamole-display-size.ts";
|
||||
import { bindPointerInput } from "./guacamole-pointer.ts";
|
||||
@@ -88,8 +88,6 @@ interface GuacamoleDisplayProps {
|
||||
onZoomChange?: (zoom: number) => void;
|
||||
}
|
||||
|
||||
const isDev = import.meta.env.DEV;
|
||||
|
||||
export const GuacamoleDisplay = forwardRef<
|
||||
GuacamoleDisplayHandle,
|
||||
GuacamoleDisplayProps
|
||||
|
||||
@@ -0,0 +1,225 @@
|
||||
declare module "guacamole-common-js" {
|
||||
namespace Guacamole {
|
||||
class Client {
|
||||
constructor(tunnel: Tunnel);
|
||||
connect(data?: string): void;
|
||||
disconnect(): void;
|
||||
getDisplay(): Display;
|
||||
sendKeyEvent(pressed: number, keysym: number): void;
|
||||
sendMouseState(state: Mouse.State): void;
|
||||
sendSize(width: number, height: number): void;
|
||||
setClipboard(stream: OutputStream, mimetype: string): void;
|
||||
createClipboardStream(mimetype: string): OutputStream;
|
||||
onstatechange: ((state: number) => void) | null;
|
||||
onerror: ((error: Status) => void) | null;
|
||||
onsync: ((timestamp: number) => void) | null;
|
||||
onclipboard: ((stream: InputStream, mimetype: string) => void) | null;
|
||||
onaudio: ((stream: InputStream, mimetype: string) => void) | null;
|
||||
onfile:
|
||||
| ((stream: InputStream, mimetype: string, filename: string) => void)
|
||||
| null;
|
||||
onfilesystem: ((filesystem: Object, name: string) => void) | null;
|
||||
}
|
||||
|
||||
// Mirrors Guacamole.Object: a named collection of streams. Within this
|
||||
// namespace `Object` refers to this class, not the global one.
|
||||
class Object {
|
||||
static readonly ROOT_STREAM: string;
|
||||
static readonly STREAM_INDEX_MIMETYPE: string;
|
||||
readonly index: number;
|
||||
requestInputStream(
|
||||
name: string,
|
||||
bodyCallback?: (stream: InputStream, mimetype: string) => void,
|
||||
): void;
|
||||
createOutputStream(mimetype: string, name: string): OutputStream;
|
||||
onbody: ((stream: InputStream, mimetype: string) => void) | null;
|
||||
onundefine: (() => void) | null;
|
||||
}
|
||||
|
||||
class AudioPlayer {
|
||||
static getInstance(
|
||||
stream: InputStream,
|
||||
mimetype: string,
|
||||
): AudioPlayer | null;
|
||||
sync(): void;
|
||||
}
|
||||
|
||||
class Display {
|
||||
getElement(): HTMLElement;
|
||||
getWidth(): number;
|
||||
getHeight(): number;
|
||||
scale(scale: number): void;
|
||||
onresize: (() => void) | null;
|
||||
}
|
||||
|
||||
class Tunnel {
|
||||
onerror: ((status: Status) => void) | null;
|
||||
onstatechange: ((state: number) => void) | null;
|
||||
}
|
||||
|
||||
class WebSocketTunnel extends Tunnel {
|
||||
constructor(url: string);
|
||||
}
|
||||
|
||||
class SessionRecording {
|
||||
constructor(source: Blob | Tunnel, refreshInterval?: number);
|
||||
onload: (() => void) | null;
|
||||
onerror: ((message: string) => void) | null;
|
||||
onprogress: ((duration: number, parsedSize: number) => void) | null;
|
||||
onplay: (() => void) | null;
|
||||
onpause: (() => void) | null;
|
||||
onseek:
|
||||
((position: number, current: number, total: number) => void) | null;
|
||||
getDisplay(): Display;
|
||||
getPosition(): number;
|
||||
getDuration(): number;
|
||||
isPlaying(): boolean;
|
||||
play(): void;
|
||||
pause(): void;
|
||||
seek(position: number, callback?: () => void): void;
|
||||
abort(): void;
|
||||
}
|
||||
|
||||
class Mouse {
|
||||
constructor(element: HTMLElement);
|
||||
onmousedown: ((state: Mouse.State) => void) | null;
|
||||
onmouseup: ((state: Mouse.State) => void) | null;
|
||||
onmousemove: ((state: Mouse.State) => void) | null;
|
||||
onmouseout: ((state: Mouse.State) => void) | null;
|
||||
}
|
||||
|
||||
namespace Mouse {
|
||||
class State {
|
||||
constructor(
|
||||
x: number,
|
||||
y: number,
|
||||
left?: boolean,
|
||||
middle?: boolean,
|
||||
right?: boolean,
|
||||
up?: boolean,
|
||||
down?: boolean,
|
||||
);
|
||||
constructor(state: {
|
||||
x: number;
|
||||
y: number;
|
||||
left?: boolean;
|
||||
middle?: boolean;
|
||||
right?: boolean;
|
||||
up?: boolean;
|
||||
down?: boolean;
|
||||
});
|
||||
x: number;
|
||||
y: number;
|
||||
left: boolean;
|
||||
middle: boolean;
|
||||
right: boolean;
|
||||
up: boolean;
|
||||
down: boolean;
|
||||
}
|
||||
|
||||
interface MouseEvent {
|
||||
state: Mouse.State;
|
||||
preventDefault(): void;
|
||||
stopPropagation(): void;
|
||||
}
|
||||
|
||||
class Touchpad {
|
||||
constructor(element: HTMLElement);
|
||||
currentState: Mouse.State;
|
||||
clickTimingThreshold: number;
|
||||
clickMoveThreshold: number;
|
||||
scrollThreshold: number;
|
||||
onEach(
|
||||
types: string[],
|
||||
listener: (event: Mouse.MouseEvent) => void,
|
||||
): void;
|
||||
on(type: string, listener: (event: Mouse.MouseEvent) => void): void;
|
||||
}
|
||||
|
||||
class Touchscreen {
|
||||
constructor(element: HTMLElement);
|
||||
currentState: Mouse.State;
|
||||
clickTimingThreshold: number;
|
||||
clickMoveThreshold: number;
|
||||
scrollThreshold: number;
|
||||
longPressThreshold: number;
|
||||
onEach(
|
||||
types: string[],
|
||||
listener: (event: Mouse.MouseEvent) => void,
|
||||
): void;
|
||||
on(type: string, listener: (event: Mouse.MouseEvent) => void): void;
|
||||
}
|
||||
}
|
||||
|
||||
class Keyboard {
|
||||
constructor(element: Document | HTMLElement);
|
||||
reset(): void;
|
||||
onkeydown: ((keysym: number) => void) | null;
|
||||
onkeyup: ((keysym: number) => void) | null;
|
||||
}
|
||||
|
||||
class Status {
|
||||
code: number;
|
||||
message: string;
|
||||
isError(): boolean;
|
||||
static readonly Code: {
|
||||
SUCCESS: number;
|
||||
UNSUPPORTED: number;
|
||||
SERVER_ERROR: number;
|
||||
SERVER_BUSY: number;
|
||||
UPSTREAM_TIMEOUT: number;
|
||||
UPSTREAM_ERROR: number;
|
||||
RESOURCE_NOT_FOUND: number;
|
||||
RESOURCE_CONFLICT: number;
|
||||
RESOURCE_CLOSED: number;
|
||||
CLIENT_BAD_REQUEST: number;
|
||||
CLIENT_UNAUTHORIZED: number;
|
||||
CLIENT_FORBIDDEN: number;
|
||||
CLIENT_TIMEOUT: number;
|
||||
};
|
||||
}
|
||||
|
||||
class InputStream {
|
||||
onblob: ((data: string) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
sendAck(message: string, code: number): void;
|
||||
}
|
||||
|
||||
class OutputStream {
|
||||
sendBlob(data: string): void;
|
||||
sendEnd(): void;
|
||||
}
|
||||
|
||||
class StringReader {
|
||||
constructor(stream: InputStream);
|
||||
ontext: ((text: string) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
}
|
||||
|
||||
class StringWriter {
|
||||
constructor(stream: OutputStream);
|
||||
sendText(text: string): void;
|
||||
sendEnd(): void;
|
||||
}
|
||||
|
||||
class BlobReader {
|
||||
constructor(stream: InputStream, mimetype: string);
|
||||
getBlob(): Blob;
|
||||
getLength(): number;
|
||||
onprogress: ((length: number) => void) | null;
|
||||
onend: (() => void) | null;
|
||||
}
|
||||
|
||||
class BlobWriter {
|
||||
constructor(stream: OutputStream);
|
||||
sendBlob(blob: Blob): void;
|
||||
sendEnd(): void;
|
||||
onack: ((status: Status) => void) | null;
|
||||
onerror: ((blob: Blob, offset: number, error: Status) => void) | null;
|
||||
onprogress: ((blob: Blob, offset: number) => void) | null;
|
||||
oncomplete: ((blob: Blob) => void) | null;
|
||||
}
|
||||
}
|
||||
|
||||
export default Guacamole;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import type { ConnectionStage } from "@termix/plugin-sdk/ui";
|
||||
|
||||
// Guacamole client states, per guacamole-common-js Guacamole.Client#STATE_*.
|
||||
const GUAC_STATE_IDLE = 0;
|
||||
const GUAC_STATE_CONNECTING = 1;
|
||||
const GUAC_STATE_WAITING = 2;
|
||||
const GUAC_STATE_CONNECTED = 3;
|
||||
const GUAC_STATE_DISCONNECTING = 4;
|
||||
const GUAC_STATE_DISCONNECTED = 5;
|
||||
|
||||
export function guacStateToStage(state: number): ConnectionStage {
|
||||
switch (state) {
|
||||
case GUAC_STATE_CONNECTING:
|
||||
return "guac_connecting";
|
||||
case GUAC_STATE_WAITING:
|
||||
return "guac_handshake";
|
||||
case GUAC_STATE_CONNECTED:
|
||||
return "guac_ready";
|
||||
case GUAC_STATE_DISCONNECTING:
|
||||
case GUAC_STATE_DISCONNECTED:
|
||||
return "guac_disconnected";
|
||||
case GUAC_STATE_IDLE:
|
||||
default:
|
||||
return "guac_connecting";
|
||||
}
|
||||
}
|
||||
@@ -43,7 +43,7 @@ describe("createMacosVncCompatibilityProxy", () => {
|
||||
socket.write(Buffer.from([5, 30, 33]));
|
||||
socket.write(Buffer.from([36, 2, 35]));
|
||||
socket.once("data", (selection) => {
|
||||
selectedSecurityType = selection[0];
|
||||
selectedSecurityType = Number(selection[0]);
|
||||
socket.write("desktop-data");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,8 +17,8 @@ import { GuacamoleTokenService } from "../../src/backend/token-service.js";
|
||||
|
||||
const manifest = manifestJson as unknown as PluginManifest;
|
||||
|
||||
// The token key comes from JWT_SECRET; without one each service picks its own.
|
||||
process.env.JWT_SECRET = "remote-desktop-test-secret";
|
||||
// Without a fixed key each token service picks its own.
|
||||
process.env.GUACAMOLE_ENCRYPTION_KEY = "ab".repeat(32);
|
||||
|
||||
function target(
|
||||
overrides: Partial<PluginProtocolTarget["auth"]> = {},
|
||||
|
||||
@@ -10,7 +10,7 @@ import {
|
||||
isServerOwnedSetting,
|
||||
} from "../../src/backend/token-service.js";
|
||||
|
||||
process.env.JWT_SECRET = "remote-desktop-test-secret";
|
||||
process.env.GUACAMOLE_ENCRYPTION_KEY = "ab".repeat(32);
|
||||
|
||||
function decode(token: string) {
|
||||
return JSON.parse(Buffer.from(token, "base64").toString("utf8")) as {
|
||||
|
||||
@@ -59,8 +59,10 @@ describe("GuacamoleTokenService", () => {
|
||||
const termixMeta = {
|
||||
termixConnectId: "connect-1",
|
||||
hostId: 7,
|
||||
hostName: "windows",
|
||||
ownerUserId: "user-1",
|
||||
protocol: "rdp" as const,
|
||||
tabInstanceId: null,
|
||||
};
|
||||
const token = tokenService.createRdpToken(
|
||||
"windows.example.test",
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
"network:outbound",
|
||||
"network:serve",
|
||||
"auth:provide",
|
||||
"ui:surface"
|
||||
"ui:surface",
|
||||
"users:impersonate"
|
||||
],
|
||||
"contributes": {
|
||||
"auth": {
|
||||
|
||||
@@ -6,10 +6,28 @@
|
||||
"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": {
|
||||
"lucide-react": "^1.33.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@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",
|
||||
"react": "^19.2.8",
|
||||
"sonner": "^2.0.8"
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,8 @@
|
||||
"titleKey": "nav.serial",
|
||||
"icon": "Usb"
|
||||
}
|
||||
]
|
||||
],
|
||||
"uses": ["terminal.resolveTheme"]
|
||||
},
|
||||
"desktop": "local"
|
||||
}
|
||||
@@ -6,13 +6,39 @@
|
||||
"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-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"lucide-react": "^1.33.0",
|
||||
"react-xtermjs": "^1.0.10",
|
||||
"serialport": "^13.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@types/node": "^26.2.0",
|
||||
"@types/react": "^19.2.18",
|
||||
"@types/react-dom": "^19.2.4",
|
||||
"jsdom": "^30.0.1",
|
||||
"vitest": "^4.1.11"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"react": "^19.2.8",
|
||||
"ws": "^8.21.3"
|
||||
},
|
||||
"termix": {
|
||||
"patches": [
|
||||
"xterm-android-ime"
|
||||
]
|
||||
},
|
||||
"overrides": {
|
||||
"react-xtermjs": {
|
||||
"@xterm/xterm": "$@xterm/xterm"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
// The terminal surfaces need xterm's own stylesheet.
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import type { Ref } from "react";
|
||||
import { Usb } from "lucide-react";
|
||||
import type {
|
||||
|
||||
@@ -20,7 +20,8 @@
|
||||
"files:own",
|
||||
"network:serve",
|
||||
"ui:surface",
|
||||
"events:core"
|
||||
"events:core",
|
||||
"db:core-refs"
|
||||
],
|
||||
"provides": [
|
||||
{
|
||||
@@ -71,7 +72,8 @@
|
||||
"key": "enableSessionRecording",
|
||||
"labelKey": "hosts.enableSessionRecording",
|
||||
"editorTab": "general"
|
||||
}
|
||||
},
|
||||
"uses": ["terminal.toolbarStatus"]
|
||||
},
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
|
||||
@@ -6,10 +6,34 @@
|
||||
"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-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"guacamole-common-js": "^1.5.0",
|
||||
"lucide-react": "^1.33.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"@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",
|
||||
"react": "^19.2.8"
|
||||
},
|
||||
"termix": {
|
||||
"patches": [
|
||||
"xterm-android-ime",
|
||||
"guacamole-common-js-flush"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,11 @@ async function setup() {
|
||||
.run("user-1", "alice");
|
||||
db.sqlite.prepare("INSERT INTO ssh_data (id) VALUES (?)").run(7);
|
||||
const table = await db.database.define(sessionRecordings);
|
||||
return createSessionRecordingRepository(db.database, table, fakeHosts);
|
||||
return createSessionRecordingRepository(
|
||||
db.database,
|
||||
table,
|
||||
fakeHosts as never,
|
||||
);
|
||||
}
|
||||
|
||||
describe("session recording repository", () => {
|
||||
|
||||
@@ -59,7 +59,11 @@ async function setup(): Promise<{
|
||||
trackSession: () => () => {},
|
||||
recordActivity: async () => {},
|
||||
};
|
||||
const repo = createSessionRecordingRepository(db.database, table, hosts);
|
||||
const repo = createSessionRecordingRepository(
|
||||
db.database,
|
||||
table,
|
||||
hosts as never,
|
||||
);
|
||||
|
||||
const mock = createMockCtx({
|
||||
pluginId: manifest.id,
|
||||
|
||||
@@ -168,5 +168,11 @@
|
||||
"custom": "Custom"
|
||||
}
|
||||
}
|
||||
},
|
||||
"connections": {
|
||||
"sectionSharedWithMe": "Shared with me",
|
||||
"sharedBy": "Shared by {{username}}",
|
||||
"join": "Join",
|
||||
"sharedSessionLabel": "{{hostName}} (shared)"
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,14 @@
|
||||
"termix": ">=2.9.0",
|
||||
"api": "1"
|
||||
},
|
||||
"capabilities": ["hosts:read", "db:own", "network:serve", "ui:surface"],
|
||||
"capabilities": [
|
||||
"hosts:read",
|
||||
"db:own",
|
||||
"network:serve",
|
||||
"ui:surface",
|
||||
"users:impersonate",
|
||||
"db:core-refs"
|
||||
],
|
||||
"dependencies": {
|
||||
"ssh-terminal": "^1.0.0"
|
||||
},
|
||||
@@ -96,7 +103,8 @@
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"uses": ["session.remoteDisplay.token", "terminal.open"]
|
||||
},
|
||||
"locales": "locales",
|
||||
"platforms": ["linux", "win32", "darwin"]
|
||||
|
||||
@@ -6,16 +6,43 @@
|
||||
"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-fit": "^0.11.0",
|
||||
"@xterm/xterm": "^6.0.0",
|
||||
"lucide-react": "^1.33.0",
|
||||
"react-xtermjs": "^1.0.10",
|
||||
"redis": "^6.2.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@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",
|
||||
"react": "^19.2.8",
|
||||
"sonner": "^2.0.8",
|
||||
"ws": "^8.21.3"
|
||||
},
|
||||
"termix": {
|
||||
"patches": [
|
||||
"xterm-android-ime"
|
||||
]
|
||||
},
|
||||
"overrides": {
|
||||
"react-xtermjs": {
|
||||
"@xterm/xterm": "$@xterm/xterm"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,7 +38,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";
|
||||
|
||||
function toRef(share: ShareRecord): SharedSessionRef {
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { SquareTerminal } from "lucide-react";
|
||||
import {
|
||||
invokeAction,
|
||||
useHosts,
|
||||
usePermission,
|
||||
useTranslation,
|
||||
type PluginHostRecord,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { Badge, Button } from "@termix/plugin-sdk/ui";
|
||||
import { getSharedWithMe } from "./api";
|
||||
|
||||
const POLL_MS = 15_000;
|
||||
|
||||
interface SharedSession {
|
||||
sessionId: string;
|
||||
hostId: number;
|
||||
hostName: string;
|
||||
isConnected: boolean;
|
||||
sharedByUsername: string | null;
|
||||
permissionLevel: string | null;
|
||||
shareId: string | null;
|
||||
}
|
||||
|
||||
function isSharedSession(value: unknown): value is SharedSession {
|
||||
const row = value as Partial<SharedSession> | null;
|
||||
return (
|
||||
!!row &&
|
||||
typeof row.sessionId === "string" &&
|
||||
typeof row.hostName === "string"
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The active connections list's "Shared with me" section: live sessions other
|
||||
* users shared with this one, each joinable in a terminal tab.
|
||||
*/
|
||||
export function SharedWithMeSection({
|
||||
search = "",
|
||||
openTabData = [],
|
||||
}: {
|
||||
search?: string;
|
||||
openTabData?: Array<Record<string, unknown>>;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { hosts } = useHosts();
|
||||
const allowed = usePermission("use");
|
||||
const [sessions, setSessions] = useState<SharedSession[]>([]);
|
||||
|
||||
const load = useCallback(() => {
|
||||
if (!allowed) return;
|
||||
getSharedWithMe()
|
||||
.then((rows) => setSessions(rows.filter(isSharedSession)))
|
||||
.catch(() => setSessions([]));
|
||||
}, [allowed]);
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
const timer = window.setInterval(load, POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [load]);
|
||||
|
||||
const joined = new Set(
|
||||
openTabData
|
||||
.map((data) => data.joinSharedSessionId)
|
||||
.filter((id): id is string => typeof id === "string"),
|
||||
);
|
||||
const q = search.trim().toLowerCase();
|
||||
const visible = sessions.filter(
|
||||
(session) =>
|
||||
!joined.has(session.sessionId) &&
|
||||
(!q ||
|
||||
session.hostName.toLowerCase().includes(q) ||
|
||||
(session.sharedByUsername ?? "").toLowerCase().includes(q)),
|
||||
);
|
||||
if (!allowed || visible.length === 0) return null;
|
||||
|
||||
const join = (session: SharedSession) => {
|
||||
if (!session.shareId) return;
|
||||
const host: PluginHostRecord = hosts.find(
|
||||
(candidate) => String(candidate.id) === String(session.hostId),
|
||||
) ?? {
|
||||
id: String(session.hostId),
|
||||
name: session.hostName,
|
||||
ip: "",
|
||||
port: 0,
|
||||
username: "",
|
||||
};
|
||||
void invokeAction("terminal.open", host, {
|
||||
joinSharedSessionId: session.sessionId,
|
||||
joinShareId: session.shareId,
|
||||
label: t("connections.sharedSessionLabel", {
|
||||
hostName: session.hostName,
|
||||
}),
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex flex-col">
|
||||
<div className="flex items-center justify-between px-3 py-1.5">
|
||||
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/50">
|
||||
{t("connections.sectionSharedWithMe")}
|
||||
</span>
|
||||
<span className="rounded bg-muted/60 px-1.5 py-0.5 text-[10px] font-semibold text-muted-foreground/40">
|
||||
{visible.length}
|
||||
</span>
|
||||
</div>
|
||||
{visible.map((session) => {
|
||||
const readWrite = session.permissionLevel === "read-write";
|
||||
return (
|
||||
<div
|
||||
key={session.sessionId}
|
||||
className="group flex items-center gap-2.5 border-b border-border/40 px-3 py-2.5 last:border-b-0"
|
||||
>
|
||||
<div className="flex size-7 shrink-0 items-center justify-center rounded bg-muted/60 text-muted-foreground">
|
||||
<SquareTerminal className="size-3.5" />
|
||||
</div>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<div className="flex min-w-0 items-center gap-1.5">
|
||||
<span
|
||||
className={`size-1.5 shrink-0 rounded-full ${
|
||||
session.isConnected
|
||||
? "bg-green-500"
|
||||
: "bg-muted-foreground/30"
|
||||
}`}
|
||||
/>
|
||||
<span className="flex-1 truncate text-xs font-semibold text-foreground">
|
||||
{session.hostName}
|
||||
</span>
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={`h-4 shrink-0 border-border/60 px-1 py-0 font-mono text-[9px] ${
|
||||
readWrite ? "text-accent-brand" : "text-muted-foreground/60"
|
||||
}`}
|
||||
>
|
||||
{readWrite
|
||||
? t("sessionSharing.permissionLevel.readWrite")
|
||||
: t("sessionSharing.permissionLevel.readOnly")}
|
||||
</Badge>
|
||||
</div>
|
||||
<span className="truncate pl-3 text-[10px] text-muted-foreground/60">
|
||||
{t("connections.sharedBy", {
|
||||
username: session.sharedByUsername ?? "?",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 shrink-0 px-2 text-[10px] opacity-0 transition-opacity group-hover:opacity-100"
|
||||
onClick={() => join(session)}
|
||||
>
|
||||
{t("connections.join")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
// The terminal surfaces need xterm's own stylesheet.
|
||||
import "@xterm/xterm/css/xterm.css";
|
||||
import { useEffect, type ComponentType } from "react";
|
||||
import { Presentation, Share2 } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
@@ -9,7 +11,8 @@ import {
|
||||
type TabProps,
|
||||
type TermixApp,
|
||||
} from "@termix/plugin-sdk/frontend";
|
||||
import { bindApi, getSharedWithMe, listCollabRooms } from "./api";
|
||||
import { bindApi, listCollabRooms } from "./api";
|
||||
import { SharedWithMeSection } from "./SharedWithMeSection";
|
||||
import CollabGuestView from "./CollabGuestView";
|
||||
import { CollabPanel } from "./CollabPanel";
|
||||
import { CollabRoomTab } from "./CollabRoomTab";
|
||||
@@ -24,7 +27,6 @@ import { REMOTE_DISPLAY_SLOT } from "./shared";
|
||||
|
||||
const COLLAB = "collab";
|
||||
const SHARE_ACTION = "session-sharing.share";
|
||||
const SHARED_WITH_ME_ACTION = "sessions.sharedWithMe";
|
||||
const SEEN_ROOMS_KEY = "termix:collab-rooms-seen";
|
||||
const INVITE_POLL_MS = 60_000;
|
||||
|
||||
@@ -237,9 +239,12 @@ export function activate(app: TermixApp): void {
|
||||
>,
|
||||
});
|
||||
|
||||
// Core's active connections list asks for sessions shared with the user.
|
||||
app.registerAction(SHARED_WITH_ME_ACTION, () => getSharedWithMe(), {
|
||||
permission: "use",
|
||||
// The "Shared with me" section of the active connections list.
|
||||
app.registerSlotContribution("connections.sections", {
|
||||
actionId: "session-sharing.sharedWithMe",
|
||||
titleKey: "connections.sectionSharedWithMe",
|
||||
kind: "component",
|
||||
component: SharedWithMeSection as ComponentType<Record<string, unknown>>,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ export async function startServer(
|
||||
live,
|
||||
sharing: mock.services.get("sessions.sharing") as SessionSharingV1,
|
||||
guests: mock.ctx.registry.consume<SessionGuestsV1>(
|
||||
"sessions.sharing.guests",
|
||||
"session-sharing.guests",
|
||||
)!,
|
||||
async request(method, path, { user = "alice", body: given } = {}) {
|
||||
const body = method === "GET" ? undefined : given;
|
||||
|
||||
@@ -416,7 +416,9 @@ describe("room guest links", () => {
|
||||
roomGuestToken: "guess",
|
||||
clientIp: "9.9.9.9",
|
||||
});
|
||||
if (!result.ok && result.reason === "Too many requests") refused = true;
|
||||
if (result.ok === false && result.reason === "Too many requests") {
|
||||
refused = true;
|
||||
}
|
||||
}
|
||||
expect(refused).toBe(true);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
getSharedWithMe: vi.fn(async () => [] as unknown[]),
|
||||
}));
|
||||
const sdk = vi.hoisted(() => ({
|
||||
invokeAction: vi.fn(async () => undefined),
|
||||
allowed: true,
|
||||
}));
|
||||
|
||||
vi.mock("../../src/frontend/api", () => api);
|
||||
vi.mock("@termix/plugin-sdk/frontend", async (importActual) => ({
|
||||
...(await importActual<typeof import("@termix/plugin-sdk/frontend")>()),
|
||||
invokeAction: sdk.invokeAction,
|
||||
usePermission: () => sdk.allowed,
|
||||
useHosts: () => ({ hosts: [], loaded: true }),
|
||||
useTranslation: () => ({
|
||||
t: (key: string, options?: Record<string, unknown>) =>
|
||||
options ? `${key}:${JSON.stringify(options)}` : key,
|
||||
language: "en",
|
||||
}),
|
||||
}));
|
||||
|
||||
import { SharedWithMeSection } from "../../src/frontend/SharedWithMeSection";
|
||||
|
||||
function shared(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
sessionId: "sess-1",
|
||||
hostId: 5,
|
||||
hostName: "prod-db",
|
||||
isConnected: true,
|
||||
sharedByUsername: "alice",
|
||||
permissionLevel: "read-only",
|
||||
shareId: "share-1",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
api.getSharedWithMe.mockReset();
|
||||
sdk.invokeAction.mockClear();
|
||||
sdk.allowed = true;
|
||||
});
|
||||
|
||||
afterEach(() => cleanup());
|
||||
|
||||
describe("shared with me section", () => {
|
||||
it("lists sessions shared with the user", async () => {
|
||||
api.getSharedWithMe.mockResolvedValue([shared()]);
|
||||
render(<SharedWithMeSection />);
|
||||
|
||||
expect(await screen.findByText("prod-db")).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText('connections.sharedBy:{"username":"alice"}'),
|
||||
).toBeTruthy();
|
||||
expect(
|
||||
screen.getByText("sessionSharing.permissionLevel.readOnly"),
|
||||
).toBeTruthy();
|
||||
});
|
||||
|
||||
it("hides a session the user already joined in a tab", async () => {
|
||||
api.getSharedWithMe.mockResolvedValue([shared()]);
|
||||
render(
|
||||
<SharedWithMeSection openTabData={[{ joinSharedSessionId: "sess-1" }]} />,
|
||||
);
|
||||
await vi.waitFor(() => expect(api.getSharedWithMe).toHaveBeenCalled());
|
||||
expect(screen.queryByText("prod-db")).toBeNull();
|
||||
});
|
||||
|
||||
it("filters by the connections search", async () => {
|
||||
api.getSharedWithMe.mockResolvedValue([
|
||||
shared(),
|
||||
shared({ sessionId: "sess-2", hostName: "web", sharedByUsername: "bob" }),
|
||||
]);
|
||||
render(<SharedWithMeSection search="bob" />);
|
||||
expect(await screen.findByText("web")).toBeTruthy();
|
||||
expect(screen.queryByText("prod-db")).toBeNull();
|
||||
});
|
||||
|
||||
it("joins through the terminal's open action", async () => {
|
||||
api.getSharedWithMe.mockResolvedValue([shared()]);
|
||||
render(<SharedWithMeSection />);
|
||||
fireEvent.click(await screen.findByText("connections.join"));
|
||||
|
||||
expect(sdk.invokeAction).toHaveBeenCalledWith(
|
||||
"terminal.open",
|
||||
expect.objectContaining({ id: "5", name: "prod-db" }),
|
||||
expect.objectContaining({
|
||||
joinSharedSessionId: "sess-1",
|
||||
joinShareId: "share-1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders nothing for a user without the use permission", async () => {
|
||||
sdk.allowed = false;
|
||||
api.getSharedWithMe.mockResolvedValue([shared()]);
|
||||
const { container } = render(<SharedWithMeSection />);
|
||||
expect(container.textContent).toBe("");
|
||||
expect(api.getSharedWithMe).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -69,7 +69,9 @@ describe("session-sharing activate", () => {
|
||||
expect(rendered.registered.slot("shell.overlay")).toHaveLength(2);
|
||||
expect(rendered.registered.actions().sort()).toEqual([
|
||||
"session-sharing.share",
|
||||
"sessions.sharedWithMe",
|
||||
]);
|
||||
expect(rendered.registered.slot("connections.sections")).toEqual([
|
||||
"session-sharing.sharedWithMe",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -167,20 +169,6 @@ describe("session-sharing activate", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("answers sessions shared with the user through the api", async () => {
|
||||
const shared = [{ sessionId: "s", isOwnSession: false }];
|
||||
rendered = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
locales,
|
||||
api: stubApi({
|
||||
get: vi.fn(async () => ({ data: shared })) as never,
|
||||
}),
|
||||
});
|
||||
await expect(invokeAction("sessions.sharedWithMe")).resolves.toEqual(
|
||||
shared,
|
||||
);
|
||||
});
|
||||
|
||||
it("removes everything it registered on deactivate", async () => {
|
||||
const app = await renderWithApp(plugin, {
|
||||
manifest,
|
||||
|
||||
@@ -21,7 +21,8 @@
|
||||
"credentials:use",
|
||||
"hosts:write",
|
||||
"ui:surface",
|
||||
"events:core"
|
||||
"events:core",
|
||||
"db:core-refs"
|
||||
],
|
||||
"provides": [
|
||||
{
|
||||
@@ -140,6 +141,11 @@
|
||||
],
|
||||
"http": {
|
||||
"adminImpersonation": true
|
||||
}
|
||||
},
|
||||
"uses": [
|
||||
"host-metrics.toolbar",
|
||||
"terminal.sendToActive",
|
||||
"terminal.sendToSession"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,29 @@
|
||||
"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": {
|
||||
"lucide-react": "^1.33.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@termix/plugin-sdk": "*"
|
||||
"@termix/plugin-sdk": "^1.0.0",
|
||||
"@testing-library/dom": "^10.4.2",
|
||||
"@testing-library/react": "^16.3.3",
|
||||
"@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",
|
||||
"react": "^19.2.8",
|
||||
"sonner": "^2.0.8",
|
||||
"ssh2": "^1.17.0"
|
||||
}
|
||||
}
|
||||
@@ -45,12 +45,12 @@ describe("snippets activate", () => {
|
||||
const service = server.mock.services.get("snippets.access") as {
|
||||
list: () => Promise<unknown>;
|
||||
};
|
||||
const listed = await server.mock.ctx.asUser("user-1", () => service.list());
|
||||
const listed = await server.mock.actAs("user-1", () => service.list());
|
||||
expect(listed).toEqual([
|
||||
expect.objectContaining({ name: "Deploy", content: "echo deploy" }),
|
||||
]);
|
||||
|
||||
const otherUsersList = await server.mock.ctx.asUser("user-2", () =>
|
||||
const otherUsersList = await server.mock.actAs("user-2", () =>
|
||||
service.list(),
|
||||
);
|
||||
expect(otherUsersList).toEqual([]);
|
||||
@@ -64,7 +64,7 @@ describe("snippets activate", () => {
|
||||
remove: (id: number) => Promise<unknown>;
|
||||
};
|
||||
const asUser = <T>(fn: () => Promise<T>) =>
|
||||
server!.mock.ctx.asUser("user-1", fn);
|
||||
server!.mock.actAs("user-1", fn);
|
||||
|
||||
await expect(
|
||||
asUser(() => service.create({ name: "x", content: "ls" })),
|
||||
|
||||
@@ -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([]);
|
||||
|
||||
Loaded 100 of 310 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user