mirror of
https://github.com/Termix-SSH/Termix.git
synced 2026-10-30 07:05:24 +00:00
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>
This commit is contained in:
577 files changed
+76479
-101831
No files matched your search
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* 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;
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user