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

+5 -23
View File
@@ -1,3 +1,7 @@
/**
* Where a connection is. Core's SSH pipeline uses the named stages; a plugin
* may log its own (such as "guac_ready") and they pass through unchanged.
*/
export type ConnectionStage =
| "dns"
| "tcp"
@@ -9,29 +13,7 @@ export type ConnectionStage =
| "proxy"
| "jump"
| "validation"
| "docker_connecting"
| "docker_auth"
| "docker_session"
| "docker_ready"
| "stats_connecting"
| "stats_totp"
| "stats_polling"
| "stats_heartbeat"
| "tunnel_connecting"
| "tunnel_source"
| "tunnel_endpoint"
| "tunnel_forwarding"
| "tunnel_retry"
| "tunnel_connected"
| "sftp_connecting"
| "sftp_auth"
| "sftp_connected"
| "guac_token"
| "guac_guacd"
| "guac_connecting"
| "guac_handshake"
| "guac_ready"
| "guac_disconnected";
| (string & {});
export type LogEntry = {
id: string;
@@ -85,14 +85,6 @@ function sanitizeStringArray(input: unknown): string[] {
return input.filter((v): v is string => typeof v === "string");
}
function sanitizeEnumArray<T extends string>(
input: unknown,
allowed: readonly T[],
): T[] {
if (!Array.isArray(input)) return [];
return input.filter((v): v is T => allowed.includes(v as T));
}
export function sanitizeCredentialSidebarPreferences(
input: unknown,
): CredentialSidebarPreferences {
+1 -383
View File
@@ -1,383 +1 @@
interface ServerConfig {
serverUrl?: string;
allowInvalidCertificate?: boolean;
[key: string]: unknown;
}
interface ConnectionTestResult {
success: boolean;
error?: string;
[key: string]: unknown;
}
interface DialogOptions {
title?: string;
defaultPath?: string;
buttonLabel?: string;
filters?: Array<{ name: string; extensions: string[] }>;
properties?: string[];
[key: string]: unknown;
}
interface DialogResult {
canceled: boolean;
filePath?: string;
filePaths?: string[];
[key: string]: unknown;
}
export interface LocalDirectoryResult {
success: boolean;
path: string;
parent?: string;
entries: LocalFileEntry[];
error?: string;
}
export interface LocalCollectedFile {
path: string;
name: string;
relativePath: string;
size: number;
created?: string;
modified: string;
}
export type LocalPathMutationResult =
| ({ success: true } & Partial<LocalFileEntry>)
| { success: false; error?: string };
export interface ElectronAPI {
getAppVersion: () => Promise<string>;
setZoomFactor?: (factor: number) => void;
getPlatform: () => Promise<string>;
getSetting?: (key: string) => Promise<string | null | undefined>;
setSetting?: (key: string, value: string) => Promise<void>;
getServerConfig: () => Promise<ServerConfig>;
saveServerConfig: (config: ServerConfig) => Promise<{ success: boolean }>;
testServerConnection: (
serverUrl: string,
allowInvalidCertificate?: boolean,
) => Promise<ConnectionTestResult>;
getC2STunnelConfig: () => Promise<unknown[]>;
saveC2STunnelConfig: (
config: unknown[],
) => Promise<{ success: boolean; error?: string }>;
checkLocalPortAvailable: (
host: string,
port: number,
) => Promise<{ available: boolean; error?: string }>;
getC2STunnelPresetDefaultName: () => Promise<string>;
startC2STunnel: (
tunnel: unknown,
index: number,
authToken?: string,
) => Promise<{
success: boolean;
tunnelName?: string;
error?: string;
code?: string;
}>;
testC2STunnel: (
tunnel: unknown,
index: number,
authToken?: string,
) => Promise<{
success: boolean;
message?: string;
error?: string;
code?: string;
}>;
stopC2STunnel: (
tunnelName: string,
) => Promise<{ success: boolean; error?: string }>;
getC2STunnelStatuses: () => Promise<Record<string, unknown>>;
onC2STunnelStatuses?: (
callback: (statuses: Record<string, unknown>) => void,
) => () => void;
startC2SAutoStartTunnels: () => Promise<{
success: boolean;
started: number;
errors: string[];
}>;
onCloseActiveTab?: (callback: () => void) => () => void;
clearSessionCookies: () => Promise<void>;
getSessionCookie: (
name: string,
targetUrl?: string,
) => Promise<string | null>;
waitForSessionCookie: (
name: string,
targetUrl?: string,
previousValue?: string | null,
timeoutMs?: number,
) => Promise<{ success: boolean; value?: string; error?: string }>;
showSaveDialog: (options: DialogOptions) => Promise<DialogResult>;
showOpenDialog: (options: DialogOptions) => Promise<DialogResult>;
getLocalHomeDirectory?: () => Promise<string>;
listLocalDirectory?: (path?: string) => Promise<LocalDirectoryResult>;
statLocalPaths?: (
paths: string[],
) => Promise<
Array<
| (LocalFileEntry & { success: true })
| { success: false; path: string; error?: string }
>
>;
collectLocalFiles?: (paths: string[]) => Promise<{
success: boolean;
files: LocalCollectedFile[];
truncated?: boolean;
error?: string;
}>;
createLocalFolder?: (
parentPath: string,
folderName: string,
) => Promise<LocalPathMutationResult>;
renameLocalPath?: (
path: string,
newName: string,
) => Promise<LocalPathMutationResult>;
trashLocalPath?: (path: string) => Promise<LocalPathMutationResult>;
chmodLocalPath?: (
path: string,
permissions: string,
) => Promise<LocalPathMutationResult>;
openExternalEditor: (fileData: {
fileName: string;
content: string;
encoding?: "utf8" | "base64";
editorPath?: string | null;
}) => Promise<{
success: boolean;
editId?: string;
path?: string;
error?: string;
}>;
closeExternalEditor: (editId: string) => Promise<{
success: boolean;
error?: string;
}>;
onExternalEditorSaved?: (
callback: (payload: {
editId: string;
content: string;
encoding: "utf8";
path: string;
}) => void,
) => () => void;
onUpdateAvailable: (callback: () => void) => void;
onUpdateDownloaded: (callback: () => void) => void;
removeAllListeners: (channel: string) => void;
isElectron: boolean;
isDev: boolean;
invoke: (channel: string, ...args: unknown[]) => Promise<unknown>;
createTempFile: (fileData: {
fileName: string;
content: string;
encoding?: "base64" | "utf8";
}) => Promise<{
success: boolean;
tempId?: string;
path?: string;
error?: string;
}>;
createTempFolder: (folderData: {
folderName: string;
files: Array<{
relativePath: string;
content: string;
encoding?: "base64" | "utf8";
}>;
}) => Promise<{
success: boolean;
tempId?: string;
path?: string;
error?: string;
}>;
startDragToDesktop: (dragData: {
tempId: string;
fileName: string;
}) => Promise<{
success: boolean;
error?: string;
}>;
cleanupTempFile: (tempId: string) => Promise<{
success: boolean;
error?: string;
}>;
startLocalTerminal(dimensions: {
cols: number;
rows: number;
shell?: "default" | "wsl";
}): Promise<{ sessionId: string; shell: string }>;
readyLocalTerminal(sessionId: string): Promise<boolean>;
writeLocalTerminal(sessionId: string, data: string): Promise<boolean>;
resizeLocalTerminal(
sessionId: string,
cols: number,
rows: number,
): Promise<boolean>;
closeLocalTerminal(sessionId: string): Promise<boolean>;
onLocalTerminalData(
sessionId: string,
callback: (data: string) => void,
): () => void;
onLocalTerminalExit(
sessionId: string,
callback: (exitCode: number) => void,
): () => void;
/** Local disk browsing for the dual-pane file manager (desktop only). */
localFs?: {
home: () => Promise<LocalFsResult<LocalFsHomeInfo>>;
list: (dirPath: string) => Promise<LocalFsResult<LocalDirectoryListing>>;
mkdir: (
parentPath: string,
name: string,
) => Promise<LocalFsResult<{ path: string }>>;
createFile: (
parentPath: string,
name: string,
) => Promise<LocalFsResult<{ path: string }>>;
rename: (
oldPath: string,
newName: string,
) => Promise<LocalFsResult<{ path: string }>>;
trash: (paths: string[]) => Promise<LocalFsResult<LocalTrashResult>>;
ensureDir: (
dirPath: string,
rootPath?: string,
) => Promise<LocalFsResult<{ path: string }>>;
exists: (paths: string[]) => Promise<LocalFsResult<{ existing: string[] }>>;
walk: (paths: string[]) => Promise<LocalFsResult<LocalWalkResult>>;
reveal: (targetPath: string) => Promise<LocalFsResult<unknown>>;
open: (targetPath: string) => Promise<LocalFsResult<unknown>>;
};
/** Streamed local<->remote transfers driven by the main process. */
localTransfer?: {
upload: (
options: LocalUploadRequest,
) => Promise<LocalFsResult<{ bytes: number }>>;
download: (
options: LocalDownloadRequest,
) => Promise<LocalFsResult<{ path: string }>>;
cancel: (
transferId: string,
) => Promise<LocalFsResult<{ cancelled: boolean }>>;
onProgress: (
callback: (payload: LocalTransferProgress) => void,
) => () => void;
};
}
export type LocalFsResult<T> =
({ success: true } & T) | { success: false; error: string; code?: string };
export interface LocalFsHomeInfo {
home: string;
separator: string;
platform: string;
}
export interface LocalFileEntry {
name: string;
path: string;
type: "file" | "directory" | "link" | "other";
created?: string;
modified?: string;
permissions?: string;
owner?: string;
group?: string;
size: number;
modifiedTimestamp?: number;
linkTarget?: string;
hidden: boolean;
}
export interface LocalDirectoryListing {
path: string;
parent: string | null;
entries: LocalFileEntry[];
}
export interface LocalTrashResult {
trashed: number;
failed: Array<{ path: string; error: string }>;
}
export interface LocalWalkFile {
localPath: string;
/** Path relative to the drop root; "/"-separated and includes the root's own name. */
relativePath: string;
size: number;
}
export interface LocalWalkResult {
files: LocalWalkFile[];
emptyDirs: string[];
totalBytes: number;
}
/**
* Which Termix backend a transfer talks to. The main process resolves the
* actual URL and credentials for the origin; the renderer never supplies them.
*/
export type LocalTransferOrigin = "local" | "remote";
export interface LocalUploadRequest {
transferId: string;
origin: LocalTransferOrigin;
fields: Record<string, string>;
localPath: string;
fileName: string;
deviceId?: string;
/** The renderer's token for the embedded backend (origin "local"); sent as a Bearer header. */
authToken?: string;
}
export interface LocalDownloadRequest {
transferId: string;
origin: LocalTransferOrigin;
body: Record<string, unknown>;
destPath: string;
/** Selected download folder; destPath must resolve strictly inside it (code EINVAL otherwise). */
rootPath: string;
expectedSize?: number;
/** Replace an existing file at destPath; otherwise the transfer is refused with code EEXIST. */
overwrite?: boolean;
deviceId?: string;
/** The renderer's token for the embedded backend (origin "local"); sent as a Bearer header. */
authToken?: string;
}
export interface LocalTransferProgress {
transferId: string;
transferred: number;
total?: number;
}
declare global {
interface Window {
electronAPI: ElectronAPI;
IS_ELECTRON: boolean;
electronClipboard?: {
writeText(text: string): Promise<boolean>;
readText(): Promise<string>;
};
}
}
export type * from "@termix/plugin-sdk/desktop";
-225
View File
@@ -1,225 +0,0 @@
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;
}
-84
View File
@@ -32,48 +32,6 @@ export interface HostProtocolAuthInput {
fields?: Record<string, string | null>;
}
export type WebEndpointAccess = "direct" | "tunnel";
export type WebEndpointRender = "external" | "embedded";
/** One web UI a host serves, declared in the host's settings. */
export interface WebEndpoint {
/**
* Stable identifier. Must NOT be derived from the port: it keys both the
* tunnel name and the tab identity, so editing a port has to leave a live
* tunnel findable under the same name.
*/
id: string;
label: string;
scheme: "http" | "https";
port: number;
/** Defaults to "/". Normalized at the storage boundary, never here. */
path?: string;
access: WebEndpointAccess;
render: WebEndpointRender;
/**
* Direct endpoints only. Allows an invalid TLS certificate for this
* endpoint's exact origin. A no-op for tunnel access, whose host component
* is loopback and therefore already exempt.
*/
ignoreCert?: boolean;
/**
* Tunnel endpoints only. Where the backend binds the forward, exactly as
* the server tunnels feature exposes it. Defaults to 127.0.0.1, reachable
* only from the machine running the backend. A web deployment runs the
* backend on a server, so reaching the forward from a browser needs an
* address that machine answers on -- which also exposes the target's web UI
* to anyone who can reach the port, with no login in front of it.
*/
bindHost?: string;
/**
* Tunnel endpoints only. Which port the forward listens on, as the server
* tunnels feature's Source Port does. Left unset the kernel picks a free
* one, which is fine when backend and browser share a machine -- but a
* container can only publish ports it knows in advance.
*/
localPort?: number;
}
export interface JumpHost {
hostId: number;
}
@@ -306,29 +264,6 @@ export interface CredentialBackend {
updatedAt: string;
}
// ============================================================================
// TUNNEL TYPES
// ============================================================================
export type TunnelScope = "s2s" | "c2s";
export type TunnelMode = "local" | "remote" | "dynamic";
// ============================================================================
// FILE MANAGER TYPES
// ============================================================================
export interface Tab {
id: string | number;
title: string;
fileName: string;
content: string;
isSSH?: boolean;
sshSessionId?: string;
filePath?: string;
loading?: boolean;
dirty?: boolean;
}
// ============================================================================
// TERMINAL CONFIGURATION TYPES
// ============================================================================
@@ -368,7 +303,6 @@ export interface TerminalRefHandle {
subscribeOutput?: (listener: (data: string) => void) => () => void;
notifyResize?: () => void;
refresh?: () => void;
openFileManager?: () => void;
}
// ============================================================================
@@ -427,21 +361,3 @@ export interface CacheEntry<T = unknown> {
timestamp: number;
expiresAt: number;
}
// ============================================================================
// DATABASE EXPORT/IMPORT TYPES
// ============================================================================
export interface ExportSummary {
sshHostsImported: number;
sshCredentialsImported: number;
pluginItemsImported: number;
credentialUsageImported: number;
settingsImported: number;
skippedItems: number;
errors: string[];
}
// ============================================================================
// DOCKER TYPES
// ============================================================================
+21 -45
View File
@@ -17,7 +17,7 @@
*/
/** 2: the docker and host metrics areas moved to their plugins. */
export const UI_PREFERENCES_VERSION = 2;
export const UI_PREFERENCES_VERSION = 3;
/** Bump when onboarding gains steps existing users should be shown again. */
export const UI_ONBOARDING_VERSION = 2;
@@ -30,16 +30,12 @@ export type UiAreaKey =
| "credentialList"
| "rail"
| "dashboard"
| "terminal"
| "fileManager"
| "hostEditor";
export type UiDensity = "comfortable" | "compact";
export type UiTrayTrigger = "always" | "hover" | "click" | "actionsOnly";
export type UiRowActions = "essential" | "full";
export type UiEmptyStateVerbosity = "minimal" | "guided";
export type UiToolbarDensity = "icon" | "labeled" | "expanded";
export type UiFileViewMode = "grid" | "list";
export type UiHostEditorMode = "simple" | "full";
export interface UiChromePreferences {
@@ -75,15 +71,6 @@ export interface UiDashboardPreferences {
enabledCards: string[];
}
export interface UiTerminalPreferences {
toolbarDensity: UiToolbarDensity;
}
export interface UiFileManagerPreferences {
viewMode: UiFileViewMode;
showHiddenFiles: boolean;
}
export interface UiHostEditorPreferences {
mode: UiHostEditorMode;
}
@@ -94,8 +81,6 @@ export interface UiAreaPreferences {
credentialList: UiCredentialListPreferences;
rail: UiRailPreferences;
dashboard: UiDashboardPreferences;
terminal: UiTerminalPreferences;
fileManager: UiFileManagerPreferences;
hostEditor: UiHostEditorPreferences;
}
@@ -149,10 +134,8 @@ const BALANCED_DASHBOARD_CARDS = [
"host_status",
"recent_activity",
];
// Advanced adds service links but leaves network_graph and homepage_preview
// off: both are wide, and enabling them by default pushes the dashboard past
// the edge of the screen. They stay available in the Add card tray.
const ADVANCED_DASHBOARD_CARDS = [...BALANCED_DASHBOARD_CARDS, "service_links"];
// Only core's own cards: a plugin's cards are added from the Add card tray.
const ADVANCED_DASHBOARD_CARDS = [...BALANCED_DASHBOARD_CARDS];
export const PRESETS: Record<Exclude<UiPreset, "custom">, UiAreaPreferences> = {
simple: {
@@ -181,8 +164,6 @@ export const PRESETS: Record<Exclude<UiPreset, "custom">, UiAreaPreferences> = {
"host_status",
],
},
terminal: { toolbarDensity: "icon" },
fileManager: { viewMode: "grid", showHiddenFiles: false },
hostEditor: { mode: "simple" },
},
balanced: {
@@ -202,9 +183,6 @@ export const PRESETS: Record<Exclude<UiPreset, "custom">, UiAreaPreferences> = {
credentialList: { density: "comfortable", showTags: true },
rail: { hiddenTabs: [] },
dashboard: { enabledCards: BALANCED_DASHBOARD_CARDS },
terminal: { toolbarDensity: "labeled" },
// FileManager.tsx has always defaulted to grid when nothing is stored.
fileManager: { viewMode: "grid", showHiddenFiles: false },
// 3 is defaultLayoutFromWidgets's own default, i.e. today's behavior.
hostEditor: { mode: "full" },
},
@@ -225,9 +203,6 @@ export const PRESETS: Record<Exclude<UiPreset, "custom">, UiAreaPreferences> = {
credentialList: { density: "compact", showTags: true },
rail: { hiddenTabs: [] },
dashboard: { enabledCards: ADVANCED_DASHBOARD_CARDS },
terminal: { toolbarDensity: "expanded" },
// List packs more files and metadata per screen than the grid.
fileManager: { viewMode: "list", showHiddenFiles: true },
hostEditor: { mode: "full" },
},
};
@@ -273,16 +248,6 @@ const AREA_SPECS: {
dashboard: {
enabledCards: { kind: "stringArray" },
},
terminal: {
toolbarDensity: {
kind: "enum",
values: ["icon", "labeled", "expanded"],
},
},
fileManager: {
viewMode: { kind: "enum", values: ["grid", "list"] },
showHiddenFiles: { kind: "bool" },
},
hostEditor: {
mode: { kind: "enum", values: ["simple", "full"] },
},
@@ -349,11 +314,21 @@ function sanitizePluginArea(input: unknown): Record<string, unknown> {
return out;
}
/** Version 1 stored the docker and host metrics areas under core names. */
const LEGACY_PLUGIN_AREAS: Record<string, UiPluginAreaKey> = {
docker: "plugin:docker",
hostMetrics: "plugin:host-metrics",
};
/**
* Areas that moved into a plugin: before `version` they were stored under
* core's name. Version 1 had docker and host metrics, version 2 the terminal
* and the file manager.
*/
const MOVED_PLUGIN_AREAS: Array<{
before: number;
legacy: string;
area: UiPluginAreaKey;
}> = [
{ before: 2, legacy: "docker", area: "plugin:docker" }, // plugin-id-ok: 2.8 key
{ before: 2, legacy: "hostMetrics", area: "plugin:host-metrics" },
{ before: 3, legacy: "terminal", area: "plugin:ssh-terminal" },
{ before: 3, legacy: "fileManager", area: "plugin:file-manager" },
];
export function sanitizeUiOverrides(
input: unknown,
@@ -362,9 +337,10 @@ export function sanitizeUiOverrides(
const out: Record<string, Record<string, unknown>> = {};
if (!input || typeof input !== "object") return out as UiOverrides;
if (version < 2) {
const moves = MOVED_PLUGIN_AREAS.filter((move) => version < move.before);
if (moves.length > 0) {
const upgraded = { ...(input as Record<string, unknown>) };
for (const [legacy, area] of Object.entries(LEGACY_PLUGIN_AREAS)) {
for (const { legacy, area } of moves) {
if (legacy in upgraded) {
upgraded[area] = upgraded[legacy];
delete upgraded[legacy];
+1 -22
View File
@@ -166,12 +166,6 @@ export type Tab = {
host?: Host;
openedAt: number;
restoredSessionId?: string | null;
/** Set when this tab joins someone else's live shared session instead of connecting/attaching its own. */
joinSharedSessionId?: string | null;
joinShareId?: string | null;
initialFilePath?: string;
/** Directory to open a Files tab into, distinct from initialFilePath (a specific file to open in an editor window). */
initialPath?: string;
/** Payload owned by the tab's plugin, e.g. which fleet or endpoint it shows. */
data?: Record<string, unknown>;
/** Present only on a split-screen container tab. Pane tab ids reference live child tabs. */
@@ -189,7 +183,6 @@ export type Tab = {
notifyResize?: () => void;
refresh?: () => void;
getApplicationCursorKeysMode?: () => boolean;
openFileManager?: () => void;
focus?: () => void;
} | null>;
};
@@ -281,9 +274,9 @@ export type WorkspaceTabSnapshot = {
hostNameSnapshot?: string | null;
label: string;
customLabel?: string;
/** Read from payloads saved before tabs carried `data`. */
initialFilePath?: string;
initialPath?: string;
/** Read from payloads saved before tabs carried `data`. */
fleetId?: number;
/** The tab's plugin payload. */
data?: Record<string, unknown>;
@@ -323,17 +316,3 @@ export type WorkspacePayload = {
right: WorkspaceDockState;
};
};
const FOLDER_ICONS = [
"folder",
"server",
"cloud",
"database",
"box",
"network",
"copy",
"settings",
"cpu",
"globe",
] as const;
type FolderIconId = (typeof FOLDER_ICONS)[number];