feat: harden plugin api for separate repos, signed updates and generic external login

This commit is contained in:
LukeGus committed 2026-09-29 18:11:59 -05:00
1 parent fb9c168b6b
commit 80944301dd
310 files changed
+6577 -4927

No files matched your search

+6 -2
View File
@@ -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}}"
}
}
+15 -2
View File
@@ -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"]
+48 -2
View File
@@ -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,
+22 -3
View File
@@ -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", () => {