Files
Termix/src/backend/plugins/ctx-desktop-local.ts
T
a89ccc6b07 v26.10.3 (#1692)
* feat: shell controller, TabStage, layout and theme plugins with repair view

* feat: move the classic layout into its own bundled plugin

* feat: move color themes into a bundled themes plugin

* fix: plugin tests and type checks work with a linked sdk

* fix: home tab, layout switch and missing layout fallbacks for looks

* chore: add classic layout and themes to the readme

* chore: drop example plugin links from the plugin guide

* feat: plugin filters by type, paged browse, hide plugins needing newer termix

* feat: move desktop, status, import, audit and branding features into plugins

* feat: host lock api so a plugin can load hosts from a config file (#1581)

* chore: add hosts config to the plugin table

* feat: login policy hook for plugins and optional https redirect (#1584)

* chore: add login security to the plugin table

* feat: api key socket tickets so a master can open remote sessions (#1654)

* feat: sync moves to a plugin, desktop gets multiple accounts (#1580, #1654)

* chore: add sync to the plugin table

* feat: move the sync screens and wizard out of core into the plugin (#1580)

* fix: add a neutral linked desktop hook for plugins (#1580)

* fix: show the linked account's name and admin flag in the app (#1580)

* feat: ssh transport providers, core ssh relay and plugin folders api

* feat: support FIDO2 security keys through ssh agent

* feat: add companion panels beside host tabs

* feat: mTLS client certificates for desktop server links

* feat: native web view for plugins in the desktop app

* fix: detect the desktop link window by Electron's default user agent

Fixes #1675

* fix: changelog for desktop link fix

* fix: keep text primary keys in the plugin data export

* fix: ship sharp binaries for Windows arm64 builds

* feat: client cert on the sync link types (sdk 1.0.18)

* chore: update translations

* feat: themed title bar for the desktop app (#1690)

* chore: changelog for the themed title bar

* fix: draw the title bar in the sidebar color

* feat: per host layout for the terminal panel bar

* chore: sdk 1.0.19 for the panel bar layout

* chore: bundle all 43 plugins for 26.10.3

* chore: update translations

---------

Co-authored-by: SirHephaistos <60616553+SirHephaistos@users.noreply.github.com>
Co-authored-by: Giovanny Aranda <garanda21@gmail.com>
2026-10-20 16:03:29 -05:00

261 lines
8.9 KiB
TypeScript

/**
* ctx.desktop.local: the user's own machine, behind desktop:local.
*
* Only works on the desktop app's embedded backend. On a server every call
* throws, and available() is false. Path rules live in desktop-local/local-fs.
*/
import type {
PluginDesktopLocal,
PluginLocalPty,
} from "@termix-ssh/plugin-sdk/backend";
import type { PluginManifest } from "@termix-ssh/plugin-sdk/manifest";
import { assertCapability } from "./permissions.js";
import type { DisposableBag } from "./disposables.js";
import * as localFs from "./desktop-local/local-fs.js";
import { remoteJson, requestHeaders } from "../sync/client/http.js";
import { spawnLocalPty } from "./desktop-local/local-pty.js";
type AuditFn = (
action: string,
details: string,
outcome: { success: boolean; errorMessage?: string },
) => Promise<void>;
interface Deps {
manifest: PluginManifest;
bag: DisposableBag;
audit: AuditFn;
}
function isEmbedded(): boolean {
return process.env.ELECTRON_EMBEDDED === "true";
}
export function createPluginDesktopLocal(deps: Deps): PluginDesktopLocal {
const { manifest, bag, audit } = deps;
const audited = new Set<string>();
const guard = async (action: string, details: string, every = false) => {
await assertCapability(
manifest.id,
"desktop:local",
manifest.capabilities,
action,
);
if (!isEmbedded()) {
throw new Error(
`Plugin ${manifest.id} used the local machine outside the desktop app`,
);
}
if (every || !audited.has(action)) {
audited.add(action);
await audit(action, details, { success: true });
}
};
const mainRequest = async (channel: string, path: string) => {
const { requestFromElectronMain } =
await import("../utils/electron-ipc-bridge.js");
return requestFromElectronMain<{ success: boolean; error?: string }>(
channel,
{ path },
);
};
return {
available: () =>
isEmbedded() && manifest.capabilities.includes("desktop:local"),
pluginFetch: async (origin, subPath, init) => {
await guard("desktop_local_fetch", "called its own routes");
if (
typeof subPath !== "string" ||
!subPath.startsWith("/") ||
subPath.startsWith("//") ||
subPath.includes("\\") ||
/(^|\/)\.\.?(\/|\?|$)/.test(subPath)
) {
throw new Error("Invalid plugin route path");
}
const route = `/plugin-api/${manifest.id}${subPath}`;
const headers: Record<string, string> = { ...init?.headers };
for (const name of Object.keys(headers)) {
if (/^(authorization|cookie|host)$/i.test(name)) delete headers[name];
}
if (origin === "remote") {
const { getLink } = await import("../sync/client/link-store.js");
const link = await getLink();
if (!link) throw new Error("This device is not linked to a server");
const { remoteStreamFetch } = await import("../sync/client/http.js");
return (await remoteStreamFetch(link, route, {
method: init?.method,
headers,
body: init?.body,
signal: init?.signal,
})) as never;
}
if (origin !== "local") throw new Error("Unknown origin");
const { getActor } = await import("./actor.js");
const actor = getActor();
if (!actor) throw new Error("No acting user");
const { AuthManager } = await import("../utils/auth-manager.js");
const session = await AuthManager.getInstance().issuePluginSession(
actor,
{
pluginId: manifest.id,
purpose: "desktop local transfer",
ttlSeconds: 900,
},
);
const port = Number(process.env.EMBEDDED_BACKEND_PORT) || 30001;
const { fetch: undiciFetch } = await import("undici");
return (await undiciFetch(`http://127.0.0.1:${port}${route}`, {
method: init?.method ?? "GET",
headers: {
...headers,
"X-Electron-App": "true",
Authorization: `Bearer ${session.token}`,
},
body: init?.body as never,
duplex: "half",
signal: init?.signal,
} as never)) as never;
},
info: async () => {
await guard("desktop_local_info", "read local machine info");
const home = await localFs.homeInfo();
const { getLink } = await import("../sync/client/link-store.js");
const link = await getLink();
return {
...home,
userDataDir: process.env.ELECTRON_USER_DATA ?? "",
backendPort: Number(process.env.EMBEDDED_BACKEND_PORT) || 30001,
link: link
? {
serverUrl: link.serverUrl,
token: link.sessionToken,
headers: requestHeaders(link),
allowInvalidCertificate: !!link.allowInvalidCertificate,
}
: null,
};
},
linkJson: async (pathname) => {
await guard("desktop_local_link", "called the linked server");
const { getLink } = await import("../sync/client/link-store.js");
const link = await getLink();
if (!link)
throw new Error("This device is not linked to a Termix server");
if (!pathname.startsWith("/")) throw new Error("Invalid path");
return remoteJson(
{
serverUrl: link.serverUrl,
sessionToken: link.sessionToken,
customHeaders: link.customHeaders,
basicAuth: link.basicAuth,
allowInvalidCertificate: link.allowInvalidCertificate,
clientCert: link.clientCert,
},
pathname,
);
},
fs: {
list: async (dirPath) => {
await guard("desktop_local_fs", "used the local file system");
return localFs.listDirectory(dirPath);
},
mkdir: async (parentPath, name) => {
await guard("desktop_local_fs", "used the local file system");
return localFs.makeDirectory(parentPath, name);
},
createFile: async (parentPath, name) => {
await guard("desktop_local_fs", "used the local file system");
return localFs.createEmptyFile(parentPath, name);
},
rename: async (path, newName) => {
await guard("desktop_local_fs", "used the local file system");
return localFs.renameEntry(path, newName);
},
ensureDir: async (dirPath, rootPath) => {
await guard("desktop_local_fs", "used the local file system");
return localFs.ensureDirectory(dirPath, rootPath);
},
exists: async (paths) => {
await guard("desktop_local_fs", "used the local file system");
return localFs.existingPaths(paths);
},
walk: async (rootPaths) => {
await guard("desktop_local_fs", "used the local file system");
if (!Array.isArray(rootPaths) || rootPaths.length === 0) {
throw new Error("No local paths provided");
}
return localFs.walkPaths(rootPaths);
},
trash: async (paths) => {
await guard(
"desktop_local_trash",
`moved ${Array.isArray(paths) ? paths.length : 0} local items to the trash`,
true,
);
if (!Array.isArray(paths) || paths.length === 0) {
throw new Error("No local paths provided");
}
const failed: Array<{ path: string; error: string }> = [];
for (const candidate of paths) {
const target = localFs.normalizeLocalPath(candidate);
try {
const result = await mainRequest("shell-trash-item", target);
if (!result?.success) throw new Error(result?.error ?? "Failed");
} catch (error) {
failed.push({
path: target,
error: error instanceof Error ? error.message : String(error),
});
}
}
return { trashed: paths.length - failed.length, failed };
},
reveal: async (path) => {
await guard("desktop_local_fs", "used the local file system");
await mainRequest("shell-show-item", localFs.normalizeLocalPath(path));
},
open: async (path) => {
await guard("desktop_local_open", "opened a local path", true);
const result = await mainRequest(
"shell-open-path",
localFs.normalizeLocalPath(path),
);
if (!result?.success) throw new Error(result?.error ?? "Failed");
},
openRead: async (path) => {
await guard("desktop_local_fs", "used the local file system");
return localFs.openRead(path);
},
openDownload: async (options) => {
await guard("desktop_local_fs", "used the local file system");
return localFs.openDownload(options);
},
},
pty: {
spawn: async (options) => {
await guard("desktop_local_pty", "started a local shell", true);
const child: PluginLocalPty = spawnLocalPty(options);
// A shell never outlives the plugin that started it.
bag.add(() => {
try {
child.kill();
} catch {
// already gone
}
});
return child;
},
},
};
}