Build ET UI: host settings panel, protocol picker, and session starters

Add ET configuration section in HostDetailsAdvancedSections (etPort,
etTerminalPath). Add ET option to ProtocolSelectDialog. Wire ET session
creation in createTerminalSessionStarters with proxy and multi-jump
validation. Add ET badges and labels in Terminal, TerminalToolbar,
TerminalConnectionDialog, and TerminalLayerSupport. Propagate ET
settings through GroupDetailsPanel, GroupSshSettingsSection, and
VaultView.
This commit is contained in:
lateautumn233
2026-06-02 23:21:23 +08:00
parent d45dea4bff
commit 7927da2085
14 changed files with 316 additions and 13 deletions

View File

@@ -103,6 +103,7 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
!!c.proxyProfileId || !!c.proxyConfig || !!c.hostChain || !!c.startupCommand || c.legacyAlgorithms !== undefined || c.skipEcdsaHostKey !== undefined || c.algorithms !== undefined || c.backspaceBehavior !== undefined ||
(c.environmentVariables && c.environmentVariables.length > 0) ||
c.moshEnabled !== undefined || !!c.moshServerPath ||
c.etEnabled !== undefined || c.etPort !== undefined ||
(c.identityFilePaths && c.identityFilePaths.length > 0);
const hasTelnetFields = (c: Partial<GroupConfig>) =>
c.telnetPort !== undefined || !!c.telnetUsername || !!c.telnetPassword || c.telnetEnabled === true;
@@ -171,6 +172,8 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
delete next.protocol;
delete next.moshEnabled;
delete next.moshServerPath;
delete next.etEnabled;
delete next.etPort;
return next;
});
};
@@ -391,6 +394,8 @@ const GroupDetailsPanel: React.FC<GroupDetailsPanelProps> = ({
...(form.environmentVariables !== undefined && { environmentVariables: form.environmentVariables }),
...(form.moshEnabled !== undefined && { moshEnabled: form.moshEnabled }),
...(form.moshServerPath !== undefined && { moshServerPath: form.moshServerPath }),
...(form.etEnabled !== undefined && { etEnabled: form.etEnabled }),
...(form.etPort !== undefined && { etPort: form.etPort }),
}),
// Only include Telnet fields if Telnet section is enabled
...(telnetEnabled && {

View File

@@ -523,6 +523,25 @@ export const GroupSshSettingsSection: React.FC<GroupSshSettingsSectionProps> = (
/>
)}
{/* EternalTerminal */}
<ToggleRow
label="EternalTerminal"
enabled={!!form.etEnabled}
onToggle={() => update("etEnabled", !form.etEnabled)}
/>
{form.etEnabled && (
<Input
type="number"
placeholder={t("hostDetails.et.port") || "ET server port (2022)"}
value={form.etPort ?? ""}
onChange={(e) => {
const v = e.target.value.trim();
update("etPort", v === "" ? undefined : Number(v));
}}
className="h-10"
/>
)}
{/* Backspace behavior — terminal input mapping, lives at the
bottom of the SSH section so it doesn't get visually
grouped with the algorithm controls above. */}

View File

@@ -175,6 +175,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
setForm(prev => ({
...prev,
moshEnabled: true,
etEnabled: false,
deviceType: prev.deviceType === 'network' ? undefined : prev.deviceType,
x11Forwarding: undefined,
}));
@@ -185,6 +186,46 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
/>
</HostDetailsSection>
<HostDetailsSection
icon={<Wifi size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.et")}
>
<ToggleRow
label="EternalTerminal"
enabled={!!form.etEnabled}
onToggle={() => {
const enabling = !form.etEnabled;
if (enabling) {
setForm(prev => ({
...prev,
etEnabled: true,
moshEnabled: false,
deviceType: prev.deviceType === 'network' ? undefined : prev.deviceType,
x11Forwarding: undefined,
}));
} else {
update("etEnabled", false);
}
}}
/>
{form.etEnabled && (
<>
<HostDetailsSettingRow label={t("hostDetails.et.port")} hint={t("hostDetails.et.port.desc")}>
<Input
type="number"
className="w-28"
placeholder="2022"
value={form.etPort ?? ""}
onChange={(e) => {
const v = e.target.value.trim();
update("etPort", v === "" ? undefined : Number(v));
}}
/>
</HostDetailsSettingRow>
</>
)}
</HostDetailsSection>
{/* Agent Forwarding */}
<HostDetailsSection
icon={<Forward size={14} className="text-muted-foreground" />}
@@ -212,7 +253,7 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
</HostDetailsSection>
{/* X11 Forwarding */}
{(!form.protocol || form.protocol === "ssh") && !form.moshEnabled && (
{(!form.protocol || form.protocol === "ssh") && !form.moshEnabled && !form.etEnabled && (
<HostDetailsSection
icon={<TerminalSquare size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.x11Forwarding")}
@@ -226,8 +267,8 @@ export const HostDetailsAdvancedSections: React.FC<HostDetailsAdvancedSectionsPr
</HostDetailsSection>
)}
{/* Network Device Mode — only for SSH hosts without Mosh (serial already uses raw mode) */}
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && (
{/* Network Device Mode — only for SSH hosts without Mosh / ET (serial already uses raw mode) */}
{(!form.protocol || form.protocol === 'ssh') && !form.moshEnabled && !form.etEnabled && (
<HostDetailsSection
icon={<Router size={14} className="text-muted-foreground" />}
title={t("hostDetails.section.deviceType")}

View File

@@ -443,7 +443,7 @@ const HostDetailsPanel: React.FC<HostDetailsPanelProps> = ({
cleaned.fontSize = initialData?.fontSize;
}
if ((cleaned.protocol && cleaned.protocol !== "ssh") || cleaned.moshEnabled) {
if ((cleaned.protocol && cleaned.protocol !== "ssh") || cleaned.moshEnabled || cleaned.etEnabled) {
delete cleaned.x11Forwarding;
}
onSave(cleaned);

View File

@@ -63,6 +63,18 @@ const ProtocolSelectDialog: React.FC<ProtocolSelectDialogProps> = ({
});
}
// EternalTerminal (if enabled)
if (host.etEnabled || host.protocols?.some(p => p.protocol === 'et' && p.enabled)) {
options.push({
protocol: 'et',
port: host.port || 22,
label: 'EternalTerminal',
icon: <Wifi size={18} />,
description: `et ${host.hostname}`,
enabled: true,
});
}
// Telnet (if enabled)
if (host.telnetEnabled || host.protocol === 'telnet' || host.protocols?.some(p => p.protocol === 'telnet' && p.enabled)) {
const telnetConfig = host.protocols?.find(p => p.protocol === 'telnet');

View File

@@ -425,6 +425,10 @@ const TerminalComponent: React.FC<TerminalProps> = ({
starters.startMosh(term);
return;
}
if (host.etEnabled) {
starters.startEt(term);
return;
}
starters.startSSH(term);
},
setStatus: (next) => setStatus(next),
@@ -605,7 +609,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
const isSerial = host.protocol === 'serial' || host.id?.startsWith('serial-');
const isTelnet = host.protocol === 'telnet';
const isMosh = host.protocol === 'mosh' || host.moshEnabled;
const isSSH = !isLocal && !isSerial && !isTelnet && !isMosh;
const isEt = host.protocol === 'et' || host.etEnabled;
const isSSH = !isLocal && !isSerial && !isTelnet && !isMosh && !isEt;
if (isSSH) {
setSessionEncoding(id, terminalEncodingRef.current);
return;
@@ -893,6 +898,8 @@ const TerminalComponent: React.FC<TerminalProps> = ({
sessionStarters.startTelnet(term);
} else if (host.moshEnabled) {
sessionStarters.startMosh(term);
} else if (host.etEnabled) {
sessionStarters.startEt(term);
} else {
sessionStarters.startSSH(term);
}

View File

@@ -170,6 +170,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
protocol: session.protocol ?? host.protocol,
port: session.port ?? host.port,
moshEnabled: session.moshEnabled ?? host.moshEnabled,
etEnabled: session.etEnabled ?? host.etEnabled,
}
: {
// Quick Connect / temporary session — build minimal host from session data
@@ -518,11 +519,13 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
const protocol = session.protocol ?? existingHost.protocol;
const port = session.port ?? existingHost.port;
const moshEnabled = session.moshEnabled ?? existingHost.moshEnabled;
const etEnabled = session.etEnabled ?? existingHost.etEnabled;
if (
protocol === existingHost.protocol &&
port === existingHost.port &&
moshEnabled === existingHost.moshEnabled
&& etEnabled === existingHost.etEnabled
) {
map.set(session.id, existingHost);
} else {
@@ -531,6 +534,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
protocol,
port,
moshEnabled,
etEnabled,
});
}
} else {
@@ -555,6 +559,7 @@ const TerminalLayerInner: React.FC<TerminalLayerProps> = ({
tags: [],
protocol: fallbackProtocol,
moshEnabled: session.moshEnabled,
etEnabled: session.etEnabled,
charset: session.charset,
localShell: session.localShell,
localShellArgs: session.localShellArgs,

View File

@@ -430,9 +430,10 @@ const VaultViewInner: React.FC<VaultViewProps> = ({
if (protocolSelectHost) {
const hostWithProtocol: Host = {
...protocolSelectHost,
protocol: protocol === "mosh" ? "ssh" : protocol,
protocol: (protocol === "mosh" || protocol === "et") ? "ssh" : protocol,
port,
moshEnabled: protocol === "mosh",
etEnabled: protocol === "et",
};
onConnect(hostWithProtocol);
setProtocolSelectHost(null);

View File

@@ -50,6 +50,10 @@ const getProtocolInfo = (host: Host): { i18nKey: string; showPort: boolean; port
if (host.moshEnabled) {
return { i18nKey: 'terminal.connection.protocol.mosh', showPort: true, port: host.port || 22 };
}
// ET likewise uses protocol: "ssh" with etEnabled: true
if (host.etEnabled) {
return { i18nKey: 'terminal.connection.protocol.et', showPort: true, port: host.port || 22 };
}
const protocol = host.protocol || 'ssh';
switch (protocol) {
case 'local':

View File

@@ -61,13 +61,14 @@ export const TerminalToolbar: React.FC<TerminalToolbarProps> = ({
const isLocalTerminal = host?.protocol === 'local' || host?.id?.startsWith('local-');
const isSerialTerminal = host?.protocol === 'serial' || host?.id?.startsWith('serial-');
const isMoshSession = host?.protocol === 'mosh' || host?.moshEnabled;
// Local PTY inherits the OS locale and mosh always uses its own UTF-8
// framing, so the quick-switch menu only makes sense for sessions whose
const isEtSession = host?.protocol === 'et' || host?.etEnabled;
// Local PTY inherits the OS locale and mosh/ET always use their own framing,
// so the quick-switch menu only makes sense for sessions whose
// backend decoder we actually control (SSH, telnet, serial). Hostname
// isn't part of the gate — telnet/SSH targets pointed at localhost
// (test daemons, forwarded endpoints) still have a real backend
// decoder we can drive.
const encodingSwitchSupported = !isLocalTerminal && !isMoshSession;
const encodingSwitchSupported = !isLocalTerminal && !isMoshSession && !isEtSession;
const hidesSftp = isLocalTerminal || isSerialTerminal;
const menuItemClass = "w-full flex items-center gap-2 px-2 py-1.5 text-xs rounded-sm hover:bg-secondary transition-colors";

View File

@@ -732,6 +732,206 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
};
const startEt = async (term: XTerm) => {
if (!ctx.terminalBackend.etAvailable()) {
ctx.setError("EternalTerminal bridge unavailable. Please run the desktop build.");
writeTerminalLine(ctx, term, "\r\n[EternalTerminal bridge unavailable. Please run the desktop build.]");
ctx.updateStatus("disconnected");
return;
}
try {
const stopEt = (message: string) => {
ctx.setError(message);
writeTerminalLine(ctx, term, `\r\n[${message}]`);
ctx.updateStatus("disconnected");
};
if (ctx.host.proxyProfileId && !ctx.host.proxyConfig) {
stopEt(`Saved proxy for host "${ctx.host.label || ctx.host.hostname}" is missing. Open host settings and select a valid proxy.`);
return;
}
if (ctx.host.proxyConfig?.host && ctx.host.proxyConfig?.port) {
stopEt(tr(
"terminal.et.proxyUnsupported",
"EternalTerminal does not currently support Netcatty proxy settings. Use SSH or remove the proxy for this host.",
));
return;
}
if (ctx.resolvedChainHosts.length > 1) {
stopEt(tr(
"terminal.et.multiJumpUnsupported",
"EternalTerminal currently supports at most one jump host in Netcatty.",
));
return;
}
const pendingAuth = ctx.pendingAuthRef.current;
const resolvedAuth = resolveHostAuth({
host: ctx.host,
keys: ctx.keys,
identities: ctx.identities,
override: pendingAuth
? {
authMethod: pendingAuth.authMethod,
username: pendingAuth.username,
password: pendingAuth.password,
keyId: pendingAuth.keyId,
passphrase: pendingAuth.passphrase,
}
: null,
});
const effectivePassword = sanitizeCredentialValue(resolvedAuth.password);
const effectivePassphrase = sanitizeCredentialValue(resolvedAuth.passphrase);
const authMethod = resolvedAuth.authMethod;
const key = authMethod === "password" ? undefined : resolvedAuth.key;
const hasEncryptedPrimaryPassword = isEncryptedCredentialPlaceholder(resolvedAuth.password);
const hasEncryptedPrimaryKey = isEncryptedCredentialPlaceholder(resolvedAuth.key?.privateKey);
const allowsLocalIdentityFallback = !resolvedAuth.keyId;
const etReferenceKeyPath = key?.source === 'reference' ? key.filePath : undefined;
const etIdentityFilePaths = authMethod === "password"
? undefined
: etReferenceKeyPath
? [etReferenceKeyPath]
: allowsLocalIdentityFallback
? ctx.host.identityFilePaths
: undefined;
const hasKeyMaterial = (!!sanitizeCredentialValue(key?.privateKey) || !!etIdentityFilePaths?.length) && authMethod !== "password";
const hasPassword = !!effectivePassword;
const needsCredentialReentry =
(authMethod === "password" && hasEncryptedPrimaryPassword && !hasPassword) ||
(authMethod !== "password" && hasEncryptedPrimaryKey && !hasKeyMaterial && !hasPassword);
if (needsCredentialReentry) {
ctx.setError(null);
ctx.setNeedsAuth(true);
ctx.setAuthRetryMessage(
tr(
"terminal.auth.credentialsUnavailable",
"Saved credentials cannot be decrypted on this device. Please re-enter and save them again.",
),
);
ctx.setAuthPassword("");
ctx.setStatus("connecting");
return;
}
const jumpHostsWithUnavailableCredentials: string[] = [];
const unsupportedJumpProxies: string[] = [];
const jumpHosts = ctx.resolvedChainHosts.map<NetcattyJumpHost>((jumpHost) => {
const jumpAuth = resolveHostAuth({
host: jumpHost,
keys: ctx.keys,
identities: ctx.identities,
});
const jumpKey = jumpAuth.key;
const rawJumpPassword = jumpAuth.password;
const rawJumpPrivateKey = jumpKey?.privateKey;
const rawJumpPassphrase = jumpAuth.passphrase || jumpKey?.passphrase;
const jumpPassword = sanitizeCredentialValue(rawJumpPassword);
const jumpPrivateKey = sanitizeCredentialValue(rawJumpPrivateKey);
const jumpPassphrase = sanitizeCredentialValue(rawJumpPassphrase);
if (jumpHost.proxyConfig?.host && jumpHost.proxyConfig?.port) {
unsupportedJumpProxies.push(jumpHost.label || jumpHost.hostname);
}
const hasEncryptedJumpCredential =
isEncryptedCredentialPlaceholder(rawJumpPassword) ||
isEncryptedCredentialPlaceholder(rawJumpPrivateKey) ||
isEncryptedCredentialPlaceholder(rawJumpPassphrase);
if (hasEncryptedJumpCredential && !jumpPassword && !jumpPrivateKey && !jumpPassphrase) {
jumpHostsWithUnavailableCredentials.push(jumpHost.label || jumpHost.hostname);
}
return {
hostname: jumpHost.hostname,
port: jumpHost.port || 22,
username: jumpAuth.username || "root",
password: jumpPassword,
privateKey: jumpKey?.source === 'reference' ? undefined : jumpPrivateKey,
certificate: jumpKey?.certificate,
passphrase: jumpPassphrase,
keyId: jumpAuth.keyId,
keySource: jumpKey?.source,
label: jumpHost.label,
identityFilePaths: jumpHost.identityFilePaths,
};
});
if (unsupportedJumpProxies.length > 0) {
stopEt(tr(
"terminal.et.proxyUnsupported",
"EternalTerminal does not currently support Netcatty proxy settings. Use SSH or remove the proxy for this host.",
));
return;
}
if (jumpHostsWithUnavailableCredentials.length > 0) {
const jumpList = jumpHostsWithUnavailableCredentials.slice(0, 2).join(", ");
const suffix = jumpHostsWithUnavailableCredentials.length > 2
? ` +${jumpHostsWithUnavailableCredentials.length - 2}`
: "";
const base = tr(
"terminal.auth.jumpCredentialsUnavailable",
"A jump host has saved credentials that cannot be decrypted on this device. Open host settings and re-enter them.",
);
stopEt(`${base} (${jumpList}${suffix})`);
return;
}
const etEnv = buildTermEnv(ctx.host, ctx.terminalSettings);
const id = await ctx.terminalBackend.startEtSession({
sessionId: ctx.sessionId,
hostname: ctx.host.hostname,
username: resolvedAuth.username || "root",
password: effectivePassword,
privateKey: key?.source === 'reference' ? undefined : sanitizeCredentialValue(key?.privateKey),
certificate: key?.certificate,
keyId: key?.id,
passphrase: key
? (effectivePassphrase || sanitizeCredentialValue(key.passphrase))
: undefined,
authMethod,
identityFilePaths: etIdentityFilePaths,
port: ctx.host.port || 22,
etPort: ctx.host.etPort,
legacyAlgorithms: ctx.host.legacyAlgorithms,
jumpHosts: jumpHosts.length > 0 ? jumpHosts : undefined,
agentForwarding: ctx.host.agentForwarding,
cols: term.cols,
rows: term.rows,
charset: ctx.host.charset,
env: etEnv,
sessionLog: ctx.sessionLog?.enabled ? ctx.sessionLog : undefined,
});
attachSessionToTerminal(ctx, term, id, {
onExitMessage: (evt) =>
`\r\n[EternalTerminal session closed${evt?.exitCode !== undefined ? ` (code ${evt.exitCode})` : ""}]`,
});
scheduleStartupCommand(ctx, term, id);
// ET sessions are full remote shells, so run OS detection like SSH for
// server stats / distro icons.
{
const connectionToken = registerConnectionToken(id);
setTimeout(() => {
if (!isConnectionTokenCurrent(id, connectionToken)) return;
void runDistroDetection(ctx, id, connectionToken);
}, 600);
}
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
ctx.setError(message);
writeTerminalLine(ctx, term, `\r\n[Failed to start EternalTerminal: ${message}]`);
ctx.updateStatus("disconnected");
}
};
const startLocal = async (term: XTerm) => {
if (!ctx.terminalBackend.localAvailable()) {
ctx.setError("Local shell bridge unavailable. Please run the desktop build.");
@@ -868,5 +1068,5 @@ export const createTerminalSessionStarters = (ctx: TerminalSessionStartersContex
}
};
return { startSSH, startTelnet, startMosh, startLocal, startSerial };
return { startSSH, startTelnet, startMosh, startEt, startLocal, startSerial };
};

View File

@@ -8,6 +8,7 @@ export type TerminalBackendApi = {
backendAvailable: () => boolean;
telnetAvailable: () => boolean;
moshAvailable: () => boolean;
etAvailable: () => boolean;
localAvailable: () => boolean;
serialAvailable: () => boolean;
execAvailable: () => boolean;
@@ -18,6 +19,9 @@ export type TerminalBackendApi = {
startMoshSession: (
options: Parameters<NonNullable<NetcattyBridge["startMoshSession"]>>[0],
) => Promise<string>;
startEtSession: (
options: Parameters<NonNullable<NetcattyBridge["startEtSession"]>>[0],
) => Promise<string>;
startLocalSession: (
options: Parameters<NonNullable<NetcattyBridge["startLocalSession"]>>[0],
) => Promise<string>;

View File

@@ -254,6 +254,10 @@ export function useTerminalEffects(ctx: TerminalEffectsContext) {
setStatus("connecting");
setProgressLogs(["Initializing Mosh connection..."]);
await sessionStarters.startMosh(term);
} else if (host.etEnabled) {
setStatus("connecting");
setProgressLogs(["Initializing EternalTerminal connection..."]);
await sessionStarters.startEt(term);
} else {
const resolvedAuth = resolveHostAuth({ host, keys, identities });
const hasPassword = !!resolvedAuth.password;

View File

@@ -218,9 +218,9 @@ export const buildAITerminalSessionInfo = (
username: host?.username || session?.username,
protocol,
shellType: session?.shellType && session.shellType !== 'unknown' ? session.shellType : undefined,
// Suppress deviceType for Mosh sessions — Mosh requires a shell-backed PTY
// and cannot connect to vendor CLIs, so network device mode doesn't apply.
deviceType: (session?.moshEnabled || host?.moshEnabled) ? undefined : host?.deviceType,
// Suppress deviceType for Mosh / ET sessions — both require a shell-backed
// PTY and cannot connect to vendor CLIs, so network device mode doesn't apply.
deviceType: (session?.moshEnabled || host?.moshEnabled || session?.etEnabled || host?.etEnabled) ? undefined : host?.deviceType,
connected: session?.status === 'connected',
};
};