mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-29 14:45: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
@@ -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", () => {
|
||||
|
||||
Reference in new issue
Block a user