From a32838dad6e5b577ce4dccc754a8a0b82a6267bb Mon Sep 17 00:00:00 2001 From: hstyi Date: Sat, 5 Jul 2025 11:57:06 +0800 Subject: [PATCH] feat: support clone session --- src/main/kotlin/app/termora/TerminalTabbed.kt | 58 +-- .../TerminalTabbedContextMenuExtension.kt | 12 + .../app/termora/keymgr/SSHCopyIdDialog.kt | 1 + .../internal/sftppty/SFTPPtyTerminalTab.kt | 1 + ...ssionTerminalTabbedContextMenuExtension.kt | 44 ++ .../plugin/internal/ssh/SSHInternalPlugin.kt | 3 + .../plugin/internal/ssh/SSHTerminalTab.kt | 222 +++++----- ...mmandTerminalTabbedContextMenuExtension.kt | 72 ++++ .../{ => plugin/internal/ssh}/SshClients.kt | 27 +- .../termora/plugin/internal/ssh/SshHandler.kt | 27 ++ .../plugin/internal/ssh/SshSessionPool.kt | 394 ++++++++++++++++++ .../panel/vw/NvidiaSMIVisualWindow.kt | 4 +- .../panel/vw/SystemInformationVisualWindow.kt | 8 +- .../terminal/panel/vw/TransferVisualWindow.kt | 2 +- .../sftp/SFTPTransferProtocolProvider.kt | 2 +- src/main/resources/i18n/messages.properties | 1 + .../resources/i18n/messages_zh_CN.properties | 1 + .../resources/i18n/messages_zh_TW.properties | 1 + src/test/kotlin/app/termora/SSHDTest.kt | 1 + 19 files changed, 691 insertions(+), 190 deletions(-) create mode 100644 src/main/kotlin/app/termora/TerminalTabbedContextMenuExtension.kt create mode 100644 src/main/kotlin/app/termora/plugin/internal/ssh/CloneSessionTerminalTabbedContextMenuExtension.kt create mode 100644 src/main/kotlin/app/termora/plugin/internal/ssh/SftpCommandTerminalTabbedContextMenuExtension.kt rename src/main/kotlin/app/termora/{ => plugin/internal/ssh}/SshClients.kt (96%) create mode 100644 src/main/kotlin/app/termora/plugin/internal/ssh/SshHandler.kt create mode 100644 src/main/kotlin/app/termora/plugin/internal/ssh/SshSessionPool.kt diff --git a/src/main/kotlin/app/termora/TerminalTabbed.kt b/src/main/kotlin/app/termora/TerminalTabbed.kt index a9925b3..1f80de7 100644 --- a/src/main/kotlin/app/termora/TerminalTabbed.kt +++ b/src/main/kotlin/app/termora/TerminalTabbed.kt @@ -9,10 +9,8 @@ import app.termora.findeverywhere.BasicFilterFindEverywhereProvider import app.termora.findeverywhere.FindEverywhereProvider import app.termora.findeverywhere.FindEverywhereProviderExtension import app.termora.findeverywhere.FindEverywhereResult +import app.termora.plugin.ExtensionManager import app.termora.plugin.internal.extension.DynamicExtensionHandler -import app.termora.plugin.internal.sftppty.SFTPPtyProtocolProvider -import app.termora.plugin.internal.sftppty.SFTPPtyTerminalTab -import app.termora.plugin.internal.ssh.SSHProtocolProvider import app.termora.terminal.DataKey import com.formdev.flatlaf.FlatLaf import com.formdev.flatlaf.extras.components.FlatPopupMenu @@ -24,7 +22,6 @@ import java.awt.event.ActionEvent import java.awt.event.MouseAdapter import java.awt.event.MouseEvent import java.beans.PropertyChangeListener -import java.util.* import javax.swing.* import javax.swing.JTabbedPane.SCROLL_TAB_LAYOUT import kotlin.math.min @@ -211,6 +208,15 @@ class TerminalTabbed( private fun showContextMenu(tabIndex: Int, e: MouseEvent) { val c = tabbedPane.getComponentAt(tabIndex) as JComponent val tab = tabs[tabIndex] + val extensions = ExtensionManager.getInstance().getExtensions(TerminalTabbedContextMenuExtension::class.java) + val menuItems = mutableListOf() + for (extension in extensions) { + try { + menuItems.add(extension.createJMenuItem(windowScope, tab)) + } catch (_: UnsupportedOperationException) { + continue + } + } val popupMenu = FlatPopupMenu() @@ -232,7 +238,7 @@ class TerminalTabbed( } // 克隆 - val clone = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.clone")) + val clone = popupMenu.add(I18n.getString("termora.copy")) clone.addActionListener { evt -> if (tab is HostTerminalTab) { actionManager @@ -284,14 +290,10 @@ class TerminalTabbed( } }) - if (tab is HostTerminalTab) { - val openHostAction = actionManager.getAction(OpenHostAction.OPEN_HOST) - if (openHostAction != null) { - if (tab.host.protocol == SSHProtocolProvider.PROTOCOL || tab.host.protocol == SFTPPtyProtocolProvider.PROTOCOL) { - popupMenu.addSeparator() - val sftpCommand = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.sftp-command")) - sftpCommand.addActionListener { openSFTPPtyTab(tab, openHostAction, it) } - } + if (menuItems.isNotEmpty()) { + popupMenu.addSeparator() + for (item in menuItems) { + popupMenu.add(item) } } @@ -383,36 +385,6 @@ class TerminalTabbed( } - private fun openSFTPPtyTab(tab: HostTerminalTab, openHostAction: Action, evt: EventObject) { - if (!SFTPPtyTerminalTab.canSupports) { - OptionPane.showMessageDialog( - SwingUtilities.getWindowAncestor(this), - I18n.getString("termora.tabbed.contextmenu.sftp-not-install"), - messageType = JOptionPane.ERROR_MESSAGE - ) - return - } - - var host = tab.host - - if (host.protocol == SSHProtocolProvider.PROTOCOL) { - val envs = tab.host.options.envs().toMutableMap() - val currentDir = tab.getData(DataProviders.Terminal)?.getTerminalModel() - ?.getData(DataKey.CurrentDir, StringUtils.EMPTY) ?: StringUtils.EMPTY - - if (currentDir.isNotBlank()) { - envs["CurrentDir"] = currentDir - } - - host = host.copy( - protocol = SFTPPtyProtocolProvider.PROTOCOL, - options = host.options.copy(env = envs.toPropertiesString()) - ) - } - - openHostAction.actionPerformed(OpenHostActionEvent(this, host, evt)) - } - /** * 对着 ToolBar 右键 */ diff --git a/src/main/kotlin/app/termora/TerminalTabbedContextMenuExtension.kt b/src/main/kotlin/app/termora/TerminalTabbedContextMenuExtension.kt new file mode 100644 index 0000000..2999aa6 --- /dev/null +++ b/src/main/kotlin/app/termora/TerminalTabbedContextMenuExtension.kt @@ -0,0 +1,12 @@ +package app.termora + +import app.termora.plugin.Extension +import javax.swing.JMenuItem + +interface TerminalTabbedContextMenuExtension : Extension { + + /** + * 抛出 [UnsupportedOperationException] 表示不支持 + */ + fun createJMenuItem(windowScope: WindowScope, tab: TerminalTab): JMenuItem +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/keymgr/SSHCopyIdDialog.kt b/src/main/kotlin/app/termora/keymgr/SSHCopyIdDialog.kt index ff36c18..9d64320 100644 --- a/src/main/kotlin/app/termora/keymgr/SSHCopyIdDialog.kt +++ b/src/main/kotlin/app/termora/keymgr/SSHCopyIdDialog.kt @@ -2,6 +2,7 @@ package app.termora.keymgr import app.termora.* import app.termora.keyboardinteractive.TerminalUserInteraction +import app.termora.plugin.internal.ssh.SshClients import app.termora.terminal.ControlCharacters import app.termora.terminal.DataKey import app.termora.terminal.PtyConnectorDelegate diff --git a/src/main/kotlin/app/termora/plugin/internal/sftppty/SFTPPtyTerminalTab.kt b/src/main/kotlin/app/termora/plugin/internal/sftppty/SFTPPtyTerminalTab.kt index 2320edf..fa6c164 100644 --- a/src/main/kotlin/app/termora/plugin/internal/sftppty/SFTPPtyTerminalTab.kt +++ b/src/main/kotlin/app/termora/plugin/internal/sftppty/SFTPPtyTerminalTab.kt @@ -4,6 +4,7 @@ import app.termora.* import app.termora.database.DatabaseManager import app.termora.keymgr.KeyManager import app.termora.keymgr.OhKeyPairKeyPairProvider +import app.termora.plugin.internal.ssh.SshClients import app.termora.terminal.* import com.formdev.flatlaf.util.SystemInfo import org.apache.commons.io.Charsets diff --git a/src/main/kotlin/app/termora/plugin/internal/ssh/CloneSessionTerminalTabbedContextMenuExtension.kt b/src/main/kotlin/app/termora/plugin/internal/ssh/CloneSessionTerminalTabbedContextMenuExtension.kt new file mode 100644 index 0000000..ed0fc08 --- /dev/null +++ b/src/main/kotlin/app/termora/plugin/internal/ssh/CloneSessionTerminalTabbedContextMenuExtension.kt @@ -0,0 +1,44 @@ +package app.termora.plugin.internal.ssh + +import app.termora.I18n +import app.termora.TerminalTab +import app.termora.TerminalTabbedContextMenuExtension +import app.termora.WindowScope +import app.termora.actions.AnAction +import app.termora.actions.AnActionEvent +import app.termora.actions.DataProviders +import javax.swing.JMenuItem + +class CloneSessionTerminalTabbedContextMenuExtension private constructor() : TerminalTabbedContextMenuExtension { + companion object { + val instance = CloneSessionTerminalTabbedContextMenuExtension() + } + + override fun createJMenuItem( + windowScope: WindowScope, + tab: TerminalTab + ): JMenuItem { + if (tab is SSHTerminalTab) { + if (tab.host.protocol == SSHProtocolProvider.PROTOCOL) { + val cloneSession = JMenuItem(I18n.getString("termora.tabbed.contextmenu.clone-session")) + val c = tab.getData(SSHTerminalTab.MySshHandler) + cloneSession.isEnabled = c?.channel?.isOpen == true + if (c != null) { + cloneSession.addActionListener(object : AnAction() { + override fun actionPerformed(evt: AnActionEvent) { + val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return + val handler = c.copy(channel = null) + val newTab = SSHTerminalTab(windowScope, tab.host, handler) + terminalTabbedManager.addTerminalTab(newTab) + newTab.start() + } + }) + } + return cloneSession + } + } + throw UnsupportedOperationException() + } + + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/plugin/internal/ssh/SSHInternalPlugin.kt b/src/main/kotlin/app/termora/plugin/internal/ssh/SSHInternalPlugin.kt index 3ea8305..b646384 100644 --- a/src/main/kotlin/app/termora/plugin/internal/ssh/SSHInternalPlugin.kt +++ b/src/main/kotlin/app/termora/plugin/internal/ssh/SSHInternalPlugin.kt @@ -1,5 +1,6 @@ package app.termora.plugin.internal.ssh +import app.termora.TerminalTabbedContextMenuExtension import app.termora.plugin.Extension import app.termora.plugin.InternalPlugin import app.termora.protocol.ProtocolHostPanelExtension @@ -9,6 +10,8 @@ internal class SSHInternalPlugin : InternalPlugin() { init { support.addExtension(ProtocolProviderExtension::class.java) { SSHProtocolProviderExtension.instance } support.addExtension(ProtocolHostPanelExtension::class.java) { SSHProtocolHostPanelExtension.instance } + support.addExtension(TerminalTabbedContextMenuExtension::class.java) { SftpCommandTerminalTabbedContextMenuExtension.instance } + support.addExtension(TerminalTabbedContextMenuExtension::class.java) { CloneSessionTerminalTabbedContextMenuExtension.instance } } override fun getName(): String { diff --git a/src/main/kotlin/app/termora/plugin/internal/ssh/SSHTerminalTab.kt b/src/main/kotlin/app/termora/plugin/internal/ssh/SSHTerminalTab.kt index bcc1583..6690c24 100644 --- a/src/main/kotlin/app/termora/plugin/internal/ssh/SSHTerminalTab.kt +++ b/src/main/kotlin/app/termora/plugin/internal/ssh/SSHTerminalTab.kt @@ -10,41 +10,36 @@ import app.termora.keymap.KeymapManager import app.termora.terminal.ControlCharacters import app.termora.terminal.DataKey import app.termora.terminal.PtyConnector -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch +import kotlinx.coroutines.* import kotlinx.coroutines.swing.Swing import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.withContext import org.apache.commons.io.Charsets -import org.apache.commons.lang3.StringUtils import org.apache.sshd.client.SshClient import org.apache.sshd.client.channel.ChannelShell import org.apache.sshd.client.session.ClientSession -import org.apache.sshd.common.SshConstants -import org.apache.sshd.common.channel.Channel -import org.apache.sshd.common.channel.ChannelListener -import org.apache.sshd.common.session.Session -import org.apache.sshd.common.session.SessionListener +import org.apache.sshd.common.future.CloseFuture +import org.apache.sshd.common.future.SshFutureListener import org.slf4j.LoggerFactory import java.nio.charset.StandardCharsets import javax.swing.Icon import javax.swing.JComponent import javax.swing.SwingUtilities +import kotlin.time.Duration.Companion.milliseconds + +class SSHTerminalTab( + windowScope: WindowScope, host: Host, + private val handler: SshHandler = SshHandler() +) : PtyHostTerminalTab(windowScope, host) { -class SSHTerminalTab(windowScope: WindowScope, host: Host) : - PtyHostTerminalTab(windowScope, host) { companion object { val SSHSession = DataKey(ClientSession::class) - + internal val MySshHandler = DataKey(SshHandler::class) private val log = LoggerFactory.getLogger(SSHTerminalTab::class.java) } private val mutex = Mutex() - private val tab = this - - private var sshClient: SshClient? = null - private var sshSession: ClientSession? = null - private var sshChannelShell: ChannelShell? = null + private val owner get() = SwingUtilities.getWindowAncestor(terminalPanel) + private val tab get() = this init { terminalPanel.dropFiles = false @@ -55,12 +50,10 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) : return terminalPanel } - override fun canReconnect(): Boolean { - return !mutex.isLocked + return mutex.isLocked.not() } - override suspend fun openPtyConnector(): PtyConnector { if (mutex.tryLock()) { try { @@ -82,74 +75,32 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) : // hide cursor terminalModel.setData(DataKey.Companion.ShowCursor, false) // print - terminal.write("SSH client is opening...\r\n") + terminal.write("Connecting to remote server ") } - val owner = SwingUtilities.getWindowAncestor(terminalPanel) - val client = SshClients.openClient(host, owner).also { sshClient = it } - val sessionListener = MySessionListener() - val channelListener = MyChannelListener() - - withContext(Dispatchers.Swing) { terminal.write("SSH client opened successfully.\r\n\r\n") } - - client.addSessionListener(sessionListener) - client.addChannelListener(channelListener) - - val (session, channel) = try { - val session = SshClients.openSession(host, client).also { sshSession = it } - val channel = SshClients.openShell( - host, - terminalPanel.winSize(), - session - ).also { sshChannelShell = it } - Pair(session, channel) - } finally { - client.removeSessionListener(sessionListener) - client.removeChannelListener(channelListener) - } - - // newline - withContext(Dispatchers.Swing) { - terminal.write("\r\n") - } - - - channel.addChannelListener(object : ChannelListener { - private val reconnectShortcut - get() = KeymapManager.Companion.getInstance().getActiveKeymap() - .getShortcut(TabReconnectAction.Companion.RECONNECT_TAB).firstOrNull() - - override fun channelClosed(channel: Channel, reason: Throwable?) { - coroutineScope.launch(Dispatchers.Swing) { - terminal.write("\r\n\r\n${ControlCharacters.Companion.ESC}[31m") - terminal.write(I18n.getString("termora.terminal.channel-disconnected")) - if (reconnectShortcut is KeyShortcut) { - terminal.write( - I18n.getString( - "termora.terminal.channel-reconnect", - reconnectShortcut.toString() - ) - ) - } - terminal.write("\r\n") - terminal.write("${ControlCharacters.Companion.ESC}[0m") - terminalModel.setData(DataKey.Companion.ShowCursor, false) - if (DatabaseManager.getInstance().terminal.autoCloseTabWhenDisconnected) { - terminalTabbedManager?.let { manager -> - SwingUtilities.invokeLater { - manager.closeTerminalTab(tab, true) - } - } - } - - // stop - stop() - } + val loading = coroutineScope.launch(Dispatchers.Swing) { + var c = 0 + while (isActive) { + if (++c > 6) c = 1 + terminal.write("${ControlCharacters.ESC}[1;32m") + terminal.write(".".repeat(c)) + terminal.write(" ".repeat(6 - c)) + terminal.write("${ControlCharacters.ESC}[0m") + delay(350.milliseconds) + terminal.write("${ControlCharacters.BS}".repeat(6)) } - }) + } - // 打开隧道 - openTunnelings(session, host) + val channel: ChannelShell + try { + val client = openClient() + val session = openSession(client) + channel = openChannel(session) + // 打开隧道 + openTunnelings(session, host) + } finally { + loading.cancel() + } // 隐藏提示 withContext(Dispatchers.Swing) { @@ -194,10 +145,68 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) : } } + private fun openClient(): SshClient { + val client = handler.client + if (client != null) return client + return SshClients.openClient(host, owner).also { handler.client = it } + } + + private fun openSession(client: SshClient): ClientSession { + val session = handler.session + if (session != null) return SshSessionPool.register(session, client) + return SshClients.openSession(host, client).also { handler.session = SshSessionPool.register(it, client) } + } + + private fun openChannel(session: ClientSession): ChannelShell { + val channel = SshClients.openShell(host, terminalPanel.winSize(), session) + handler.channel = channel + + channel.addCloseFutureListener(object : SshFutureListener { + private val reconnectShortcut + get() = KeymapManager.Companion.getInstance().getActiveKeymap() + .getShortcut(TabReconnectAction.Companion.RECONNECT_TAB).firstOrNull() + private val autoCloseTabWhenDisconnected get() = DatabaseManager.getInstance().terminal.autoCloseTabWhenDisconnected + + override fun operationComplete(future: CloseFuture) { + coroutineScope.launch(Dispatchers.Swing) { + terminal.write("\r\n\r\n${ControlCharacters.Companion.ESC}[31m") + terminal.write(I18n.getString("termora.terminal.channel-disconnected")) + if (reconnectShortcut is KeyShortcut) { + terminal.write( + I18n.getString( + "termora.terminal.channel-reconnect", + reconnectShortcut.toString() + ) + ) + } + terminal.write("\r\n") + terminal.write("${ControlCharacters.Companion.ESC}[0m") + terminalModel.setData(DataKey.Companion.ShowCursor, false) + + if (autoCloseTabWhenDisconnected) { + terminalTabbedManager?.let { manager -> + SwingUtilities.invokeLater { + manager.closeTerminalTab(tab, true) + } + } + } + + // stop + stop() + } + } + }) + + return channel + } + @Suppress("UNCHECKED_CAST") override fun getData(dataKey: DataKey): T? { if (dataKey == SSHSession) { - return sshSession as T? + return handler.session as T? + } + if (dataKey == MySshHandler) { + return handler as T? } return super.getData(dataKey) } @@ -206,16 +215,7 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) : if (mutex.tryLock()) { try { super.stop() - - sshChannelShell?.close(true) - sshSession?.disableSessionHeartbeat() - sshSession?.disconnect(SshConstants.SSH2_DISCONNECT_BY_APPLICATION, StringUtils.EMPTY) - sshSession?.close(true) - sshClient?.close(true) - - sshChannelShell = null - sshSession = null - sshClient = null + handler.close() } finally { mutex.unlock() } @@ -231,36 +231,4 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) : terminalPanel.storeVisualWindows(host.id) } - private inner class MySessionListener : SessionListener, Disposable { - override fun sessionEvent(session: Session, event: SessionListener.Event) { - coroutineScope.launch { - when (event) { - SessionListener.Event.KeyEstablished -> terminal.write("Session Key exchange successful.\r\n") - SessionListener.Event.Authenticated -> terminal.write("Session authentication successful.\r\n\r\n") - SessionListener.Event.KexCompleted -> terminal.write("Session KEX negotiation successful.\r\n") - } - } - } - - override fun sessionEstablished(session: Session) { - coroutineScope.launch { terminal.write("Session established.\r\n") } - } - - override fun sessionCreated(session: Session?) { - coroutineScope.launch { terminal.write("Session created.\r\n") } - } - - - } - - private inner class MyChannelListener : ChannelListener, Disposable { - override fun channelOpenSuccess(channel: Channel) { - coroutineScope.launch { terminal.write("Channel shell opened successfully.\r\n") } - } - - override fun channelInitialized(channel: Channel) { - coroutineScope.launch { terminal.write("Channel shell initialization successful.\r\n") } - } - - } } \ No newline at end of file diff --git a/src/main/kotlin/app/termora/plugin/internal/ssh/SftpCommandTerminalTabbedContextMenuExtension.kt b/src/main/kotlin/app/termora/plugin/internal/ssh/SftpCommandTerminalTabbedContextMenuExtension.kt new file mode 100644 index 0000000..511b460 --- /dev/null +++ b/src/main/kotlin/app/termora/plugin/internal/ssh/SftpCommandTerminalTabbedContextMenuExtension.kt @@ -0,0 +1,72 @@ +package app.termora.plugin.internal.ssh + +import app.termora.* +import app.termora.actions.* +import app.termora.plugin.internal.sftppty.SFTPPtyProtocolProvider +import app.termora.plugin.internal.sftppty.SFTPPtyTerminalTab +import app.termora.terminal.DataKey +import org.apache.commons.lang3.StringUtils +import java.util.* +import javax.swing.Action +import javax.swing.JMenuItem +import javax.swing.JOptionPane + +class SftpCommandTerminalTabbedContextMenuExtension private constructor() : TerminalTabbedContextMenuExtension { + companion object { + val instance = SftpCommandTerminalTabbedContextMenuExtension() + } + + private val actionManager = ActionManager.getInstance() + + override fun createJMenuItem( + windowScope: WindowScope, + tab: TerminalTab + ): JMenuItem { + if (tab is HostTerminalTab) { + val openHostAction = actionManager.getAction(OpenHostAction.OPEN_HOST) + if (openHostAction != null) { + if (tab.host.protocol == SSHProtocolProvider.PROTOCOL || tab.host.protocol == SFTPPtyProtocolProvider.PROTOCOL) { + val sftpCommand = JMenuItem(I18n.getString("termora.tabbed.contextmenu.sftp-command")) + sftpCommand.addActionListener(object : AnAction() { + override fun actionPerformed(evt: AnActionEvent) { + openSFTPPtyTab(tab, openHostAction, evt) + } + }) + return sftpCommand + } + } + } + throw UnsupportedOperationException() + } + + private fun openSFTPPtyTab(tab: HostTerminalTab, openHostAction: Action, evt: EventObject) { + if (SFTPPtyTerminalTab.canSupports.not()) { + OptionPane.showMessageDialog( + tab.windowScope.window, + I18n.getString("termora.tabbed.contextmenu.sftp-not-install"), + messageType = JOptionPane.ERROR_MESSAGE + ) + return + } + + var host = tab.host + + if (host.protocol == SSHProtocolProvider.PROTOCOL) { + val envs = tab.host.options.envs().toMutableMap() + val currentDir = tab.getData(DataProviders.Terminal)?.getTerminalModel() + ?.getData(DataKey.CurrentDir, StringUtils.EMPTY) ?: StringUtils.EMPTY + + if (currentDir.isNotBlank()) { + envs["CurrentDir"] = currentDir + } + + host = host.copy( + protocol = SFTPPtyProtocolProvider.PROTOCOL, + options = host.options.copy(env = envs.toPropertiesString()) + ) + } + + openHostAction.actionPerformed(OpenHostActionEvent(evt.source, host, evt)) + } + +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/SshClients.kt b/src/main/kotlin/app/termora/plugin/internal/ssh/SshClients.kt similarity index 96% rename from src/main/kotlin/app/termora/SshClients.kt rename to src/main/kotlin/app/termora/plugin/internal/ssh/SshClients.kt index ffb5630..fe5090f 100644 --- a/src/main/kotlin/app/termora/SshClients.kt +++ b/src/main/kotlin/app/termora/plugin/internal/ssh/SshClients.kt @@ -1,5 +1,6 @@ -package app.termora +package app.termora.plugin.internal.ssh +import app.termora.* import app.termora.keyboardinteractive.TerminalUserInteraction import app.termora.keymgr.OhKeyPairKeyPairProvider import app.termora.terminal.TerminalSize @@ -29,7 +30,6 @@ import org.apache.sshd.client.session.ClientSession import org.apache.sshd.client.session.ClientSessionImpl import org.apache.sshd.client.session.SessionFactory import org.apache.sshd.common.AttributeRepository -import org.apache.sshd.common.SshConstants import org.apache.sshd.common.SshException import org.apache.sshd.common.channel.ChannelFactory import org.apache.sshd.common.channel.PtyChannelConfiguration @@ -63,7 +63,7 @@ import org.eclipse.jgit.internal.transport.sshd.proxy.AbstractClientProxyConnect import org.eclipse.jgit.internal.transport.sshd.proxy.HttpClientConnector import org.eclipse.jgit.internal.transport.sshd.proxy.Socks5ClientConnector import org.eclipse.jgit.transport.CredentialsProvider -import org.eclipse.jgit.transport.SshConstants.IDENTITY_AGENT +import org.eclipse.jgit.transport.SshConstants import org.eclipse.jgit.transport.sshd.IdentityPasswordProvider import org.eclipse.jgit.transport.sshd.agent.ConnectorFactory import org.slf4j.LoggerFactory @@ -89,7 +89,7 @@ object SshClients { val HOST_KEY = AttributeRepository.AttributeKey() private val timeout = Duration.ofSeconds(30) - private val hostManager get() = HostManager.getInstance() + private val hostManager get() = HostManager.Companion.getInstance() private val log by lazy { LoggerFactory.getLogger(SshClients::class.java) } /** @@ -166,7 +166,7 @@ object SshClients { } val jumpHosts = mutableListOf() - val hosts = HostManager.getInstance().hosts().associateBy { it.id } + val hosts = HostManager.Companion.getInstance().hosts().associateBy { it.id } for (jumpHostId in h.options.jumpHosts) { val e = hosts[jumpHostId] if (e == null) { @@ -235,16 +235,16 @@ object SshClients { if (SystemInfo.isMacOS) { val file = FileUtils.getFile(Application.getBaseDataDir(), "config", "ssh-agent.sock") if (file.exists()) { - entry.setProperty(IDENTITY_AGENT, file.absolutePath) + entry.setProperty(SshConstants.IDENTITY_AGENT, file.absolutePath) } } - if (entry.getProperty(IDENTITY_AGENT).isNullOrBlank()) { + if (entry.getProperty(SshConstants.IDENTITY_AGENT).isNullOrBlank()) { if (host.authentication.password.isNotBlank()) - entry.setProperty(IDENTITY_AGENT, host.authentication.password) + entry.setProperty(SshConstants.IDENTITY_AGENT, host.authentication.password) else if (SystemInfo.isWindows) - entry.setProperty(IDENTITY_AGENT, PageantConnector.DESCRIPTOR.identityAgent) + entry.setProperty(SshConstants.IDENTITY_AGENT, PageantConnector.DESCRIPTOR.identityAgent) else - entry.setProperty(IDENTITY_AGENT, UnixDomainSocketConnector.DESCRIPTOR.identityAgent) + entry.setProperty(SshConstants.IDENTITY_AGENT, UnixDomainSocketConnector.DESCRIPTOR.identityAgent) } } @@ -272,7 +272,7 @@ object SshClients { throw SshException("Authentication failed") } } catch (e: Exception) { - if (e !is SshException || e.disconnectCode != SshConstants.SSH2_DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE) throw e + if (e !is SshException || e.disconnectCode != org.apache.sshd.common.SshConstants.SSH2_DISCONNECT_NO_MORE_AUTH_METHODS_AVAILABLE) throw e val owner = client.properties["owner"] as Window? ?: throw e val askUserInfo = ask(host, entry, owner) ?: throw e if (askUserInfo.authentication.type == AuthenticationType.No) throw e @@ -383,7 +383,7 @@ object SshClients { val channelFactories = mutableListOf() channelFactories.addAll(ClientBuilder.DEFAULT_CHANNEL_FACTORIES) - channelFactories.add(X11ChannelFactory.INSTANCE) + channelFactories.add(X11ChannelFactory.Companion.INSTANCE) builder.channelFactories(channelFactories) val sshClient = builder.build() as JGitSshClient @@ -725,5 +725,4 @@ object SshClients { } -} - +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/plugin/internal/ssh/SshHandler.kt b/src/main/kotlin/app/termora/plugin/internal/ssh/SshHandler.kt new file mode 100644 index 0000000..48b3b65 --- /dev/null +++ b/src/main/kotlin/app/termora/plugin/internal/ssh/SshHandler.kt @@ -0,0 +1,27 @@ +package app.termora.plugin.internal.ssh + +import org.apache.sshd.client.SshClient +import org.apache.sshd.client.session.ClientSession +import org.apache.sshd.common.channel.Channel + +data class SshHandler( + var client: SshClient? = null, + var session: ClientSession? = null, + var channel: Channel? = null +) : AutoCloseable { + override fun close() { + + channel?.close(true)?.await() + session?.close(true)?.await() + + channel = null + session = null + + + // client 由 SshSessionPool 负责关闭 + if (client?.isClosing == true || client?.isClosed == true) { + client = null + } + + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/plugin/internal/ssh/SshSessionPool.kt b/src/main/kotlin/app/termora/plugin/internal/ssh/SshSessionPool.kt new file mode 100644 index 0000000..cfa96b6 --- /dev/null +++ b/src/main/kotlin/app/termora/plugin/internal/ssh/SshSessionPool.kt @@ -0,0 +1,394 @@ +package app.termora.plugin.internal.ssh + +import org.apache.sshd.client.SshClient +import org.apache.sshd.client.channel.ChannelExec +import org.apache.sshd.client.channel.ChannelShell +import org.apache.sshd.client.session.ClientSession +import org.apache.sshd.client.session.forward.DynamicPortForwardingTracker +import org.apache.sshd.client.session.forward.ExplicitPortForwardingTracker +import org.apache.sshd.common.AttributeRepository +import org.apache.sshd.common.channel.Channel +import org.apache.sshd.common.channel.PtyChannelConfigurationHolder +import org.apache.sshd.common.channel.throttle.ChannelStreamWriter +import org.apache.sshd.common.channel.throttle.ChannelStreamWriterResolver +import org.apache.sshd.common.future.CloseFuture +import org.apache.sshd.common.future.DefaultCloseFuture +import org.apache.sshd.common.io.IoWriteFuture +import org.apache.sshd.common.session.SessionHeartbeatController +import org.apache.sshd.common.util.buffer.Buffer +import org.apache.sshd.common.util.net.SshdSocketAddress +import java.io.OutputStream +import java.net.SocketAddress +import java.nio.charset.Charset +import java.time.Duration +import java.util.* +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import java.util.function.Function + +internal object SshSessionPool { + private val map = WeakHashMap() + + fun register(session: ClientSession, client: SshClient): ClientSession { + if (session is MyClientSession) { + session.refCount.incrementAndGet() + return session + } + + synchronized(session) { + val delegate = map[session] ?: MyClientSession(client, session) + map[session] = delegate + delegate.refCount.incrementAndGet() + return delegate + } + } + + + private class MyClientSession( + private val client: SshClient, + private val delegate: ClientSession + ) : ClientSession by delegate { + val refCount = AtomicInteger(0) + + override fun createShellChannel(): ChannelShell? { + return delegate.createShellChannel() + } + + override fun createExecChannel(command: String?): ChannelExec? { + return delegate.createExecChannel(command) + } + + override fun createExecChannel( + command: String?, + ptyConfig: PtyChannelConfigurationHolder?, + env: Map? + ): ChannelExec? { + return delegate.createExecChannel(command, ptyConfig, env) + } + + override fun executeRemoteCommand(command: String?): String? { + return delegate.executeRemoteCommand(command) + } + + override fun executeRemoteCommand( + command: String?, + stderr: OutputStream?, + charset: Charset? + ): String? { + return delegate.executeRemoteCommand(command, stderr, charset) + } + + override fun executeRemoteCommand( + command: String?, + stdout: OutputStream?, + stderr: OutputStream?, + charset: Charset? + ) { + delegate.executeRemoteCommand(command, stdout, stderr, charset) + } + + override fun createLocalPortForwardingTracker( + localPort: Int, + remote: SshdSocketAddress? + ): ExplicitPortForwardingTracker? { + return delegate.createLocalPortForwardingTracker(localPort, remote) + } + + override fun createLocalPortForwardingTracker( + local: SshdSocketAddress?, + remote: SshdSocketAddress? + ): ExplicitPortForwardingTracker? { + return delegate.createLocalPortForwardingTracker(local, remote) + } + + override fun createRemotePortForwardingTracker( + remote: SshdSocketAddress?, + local: SshdSocketAddress? + ): ExplicitPortForwardingTracker? { + return delegate.createRemotePortForwardingTracker(remote, local) + } + + override fun createDynamicPortForwardingTracker(local: SshdSocketAddress?): DynamicPortForwardingTracker? { + return delegate.createDynamicPortForwardingTracker(local) + } + + override fun waitFor( + mask: Collection?, + timeout: Duration? + ): Set? { + return delegate.waitFor(mask, timeout) + } + + override fun createBuffer(cmd: Byte): Buffer? { + return delegate.createBuffer(cmd) + } + + override fun writePacket( + buffer: Buffer?, + timeout: Duration? + ): IoWriteFuture? { + return delegate.writePacket(buffer, timeout) + } + + override fun writePacket( + buffer: Buffer?, + maxWaitMillis: Long + ): IoWriteFuture? { + return delegate.writePacket(buffer, maxWaitMillis) + } + + override fun request( + request: String?, + buffer: Buffer?, + timeout: Long, + unit: TimeUnit? + ): Buffer? { + return delegate.request(request, buffer, timeout, unit) + } + + override fun request( + request: String?, + buffer: Buffer?, + timeout: Duration? + ): Buffer? { + return delegate.request(request, buffer, timeout) + } + + override fun getLocalAddress(): SocketAddress? { + return delegate.getLocalAddress() + } + + override fun getRemoteAddress(): SocketAddress? { + return delegate.getRemoteAddress() + } + + override fun resolveAttribute(key: AttributeRepository.AttributeKey?): T? { + return delegate.resolveAttribute(key) + } + + override fun getSessionHeartbeatType(): SessionHeartbeatController.HeartbeatType? { + return delegate.getSessionHeartbeatType() + } + + override fun getSessionHeartbeatInterval(): Duration? { + return delegate.getSessionHeartbeatInterval() + } + + override fun disableSessionHeartbeat() { + delegate.disableSessionHeartbeat() + } + + override fun setSessionHeartbeat( + type: SessionHeartbeatController.HeartbeatType?, + unit: TimeUnit?, + count: Long + ) { + delegate.setSessionHeartbeat(type, unit, count) + } + + override fun setSessionHeartbeat( + type: SessionHeartbeatController.HeartbeatType?, + interval: Duration? + ) { + delegate.setSessionHeartbeat(type, interval) + } + + override fun isEmpty(): Boolean { + return delegate.isEmpty() + } + + override fun getLongProperty(name: String?, def: Long): Long { + return delegate.getLongProperty(name, def) + } + + override fun getLong(name: String?): Long? { + return delegate.getLong(name) + } + + override fun getIntProperty(name: String?, def: Int): Int { + return delegate.getIntProperty(name, def) + } + + override fun getInteger(name: String?): Int? { + return delegate.getInteger(name) + } + + override fun getBooleanProperty(name: String?, def: Boolean): Boolean { + return delegate.getBooleanProperty(name, def) + } + + override fun getBoolean(name: String?): Boolean? { + return delegate.getBoolean(name) + } + + override fun getStringProperty(name: String?, def: String?): String? { + return delegate.getStringProperty(name, def) + } + + override fun getString(name: String?): String? { + return delegate.getString(name) + } + + override fun getObject(name: String?): Any? { + return delegate.getObject(name) + } + + override fun getCharset( + name: String?, + defaultValue: Charset? + ): Charset? { + return delegate.getCharset(name, defaultValue) + } + + override fun computeAttributeIfAbsent( + key: AttributeRepository.AttributeKey?, + resolver: Function, out T?>? + ): T? { + return delegate.computeAttributeIfAbsent(key, resolver) + } + + override fun close() { + close(true)?.await() + } + + override fun close(immediately: Boolean): CloseFuture? { + synchronized(delegate) { + if (refCount.decrementAndGet() < 1) { + delegate.close(immediately).await() + return client.close(immediately) + } + } + return DefaultCloseFuture(this, this).apply { setClosed() } + } + + override fun isOpen(): Boolean { + return delegate.isOpen() + } + + override fun getCipherFactoriesNameList(): String? { + return delegate.getCipherFactoriesNameList() + } + + override fun getCipherFactoriesNames(): List? { + return delegate.getCipherFactoriesNames() + } + + override fun setCipherFactoriesNameList(names: String?) { + delegate.setCipherFactoriesNameList(names) + } + + override fun setCipherFactoriesNames(vararg names: String?) { + delegate.setCipherFactoriesNames(*names) + } + + override fun setCipherFactoriesNames(names: Collection?) { + delegate.setCipherFactoriesNames(names) + } + + override fun getCompressionFactoriesNameList(): String? { + return delegate.getCompressionFactoriesNameList() + } + + override fun getCompressionFactoriesNames(): List? { + return delegate.getCompressionFactoriesNames() + } + + override fun setCompressionFactoriesNameList(names: String?) { + delegate.setCompressionFactoriesNameList(names) + } + + override fun setCompressionFactoriesNames(vararg names: String?) { + delegate.setCompressionFactoriesNames(*names) + } + + override fun setCompressionFactoriesNames(names: Collection?) { + delegate.setCompressionFactoriesNames(names) + } + + override fun getMacFactoriesNameList(): String? { + return delegate.getMacFactoriesNameList() + } + + override fun getMacFactoriesNames(): List? { + return delegate.getMacFactoriesNames() + } + + override fun setMacFactoriesNameList(names: String?) { + delegate.setMacFactoriesNameList(names) + } + + override fun setMacFactoriesNames(vararg names: String?) { + delegate.setMacFactoriesNames(*names) + } + + override fun setMacFactoriesNames(names: Collection?) { + delegate.setMacFactoriesNames(names) + } + + override fun setSignatureFactoriesNameList(names: String?) { + delegate.setSignatureFactoriesNameList(names) + } + + override fun setSignatureFactoriesNames(vararg names: String?) { + delegate.setSignatureFactoriesNames(*names) + } + + override fun setSignatureFactoriesNames(names: Collection?) { + delegate.setSignatureFactoriesNames(names) + } + + override fun getSignatureFactoriesNameList(): String? { + return delegate.getSignatureFactoriesNameList() + } + + override fun getSignatureFactoriesNames(): List? { + return delegate.getSignatureFactoriesNames() + } + + override fun resolveChannelStreamWriterResolver(): ChannelStreamWriterResolver? { + return delegate.resolveChannelStreamWriterResolver() + } + + override fun resolveChannelStreamWriter( + channel: Channel?, + cmd: Byte + ): ChannelStreamWriter? { + return delegate.resolveChannelStreamWriter(channel, cmd) + } + + override fun isLocalPortForwardingStartedForPort(port: Int): Boolean { + return delegate.isLocalPortForwardingStartedForPort(port) + } + + override fun isRemotePortForwardingStartedForPort(port: Int): Boolean { + return delegate.isRemotePortForwardingStartedForPort(port) + } + + override fun setUserAuthFactoriesNames(names: Collection?) { + delegate.setUserAuthFactoriesNames(names) + } + + override fun setUserAuthFactoriesNames(vararg names: String?) { + delegate.setUserAuthFactoriesNames(*names) + } + + override fun getUserAuthFactoriesNameList(): String? { + return delegate.getUserAuthFactoriesNameList() + } + + override fun getUserAuthFactoriesNames(): List? { + return delegate.getUserAuthFactoriesNames() + } + + override fun setUserAuthFactoriesNameList(names: String?) { + delegate.setUserAuthFactoriesNameList(names) + } + + override fun startLocalPortForwarding( + localPort: Int, + remote: SshdSocketAddress? + ): SshdSocketAddress? { + return delegate.startLocalPortForwarding(localPort, remote) + } + } +} \ No newline at end of file diff --git a/src/main/kotlin/app/termora/terminal/panel/vw/NvidiaSMIVisualWindow.kt b/src/main/kotlin/app/termora/terminal/panel/vw/NvidiaSMIVisualWindow.kt index d64a50a..1303a3c 100644 --- a/src/main/kotlin/app/termora/terminal/panel/vw/NvidiaSMIVisualWindow.kt +++ b/src/main/kotlin/app/termora/terminal/panel/vw/NvidiaSMIVisualWindow.kt @@ -3,8 +3,8 @@ package app.termora.terminal.panel.vw import app.termora.Disposer import app.termora.I18n import app.termora.Icons -import app.termora.SshClients import app.termora.plugin.internal.ssh.SSHTerminalTab +import app.termora.plugin.internal.ssh.SshClients import com.formdev.flatlaf.extras.FlatSVGIcon import com.jgoodies.forms.builder.FormBuilder import com.jgoodies.forms.layout.FormLayout @@ -28,7 +28,7 @@ import javax.xml.parsers.DocumentBuilderFactory import javax.xml.xpath.XPathFactory import kotlin.time.Duration.Companion.milliseconds -class NvidiaSMIVisualWindow(tab: SSHTerminalTab, visualWindowManager: VisualWindowManager) : +internal class NvidiaSMIVisualWindow(tab: SSHTerminalTab, visualWindowManager: VisualWindowManager) : SSHVisualWindow(tab, "NVIDIA-SMI", visualWindowManager) { companion object { diff --git a/src/main/kotlin/app/termora/terminal/panel/vw/SystemInformationVisualWindow.kt b/src/main/kotlin/app/termora/terminal/panel/vw/SystemInformationVisualWindow.kt index cdfdebe..abe6dd0 100644 --- a/src/main/kotlin/app/termora/terminal/panel/vw/SystemInformationVisualWindow.kt +++ b/src/main/kotlin/app/termora/terminal/panel/vw/SystemInformationVisualWindow.kt @@ -1,7 +1,11 @@ package app.termora.terminal.panel.vw -import app.termora.* +import app.termora.Disposer +import app.termora.DynamicColor +import app.termora.I18n +import app.termora.formatBytes import app.termora.plugin.internal.ssh.SSHTerminalTab +import app.termora.plugin.internal.ssh.SshClients import com.jgoodies.forms.builder.FormBuilder import com.jgoodies.forms.layout.FormLayout import kotlinx.coroutines.Dispatchers @@ -16,7 +20,7 @@ import javax.swing.table.DefaultTableCellRenderer import javax.swing.table.DefaultTableModel -class SystemInformationVisualWindow(tab: SSHTerminalTab, visualWindowManager: VisualWindowManager) : +internal class SystemInformationVisualWindow(tab: SSHTerminalTab, visualWindowManager: VisualWindowManager) : SSHVisualWindow(tab, "SystemInformation", visualWindowManager) { companion object { diff --git a/src/main/kotlin/app/termora/terminal/panel/vw/TransferVisualWindow.kt b/src/main/kotlin/app/termora/terminal/panel/vw/TransferVisualWindow.kt index 11ad4cc..0698333 100644 --- a/src/main/kotlin/app/termora/terminal/panel/vw/TransferVisualWindow.kt +++ b/src/main/kotlin/app/termora/terminal/panel/vw/TransferVisualWindow.kt @@ -38,7 +38,7 @@ import kotlin.reflect.cast import kotlin.time.Duration.Companion.milliseconds -class TransferVisualWindow(tab: SSHTerminalTab, visualWindowManager: VisualWindowManager) : +internal class TransferVisualWindow(tab: SSHTerminalTab, visualWindowManager: VisualWindowManager) : SSHVisualWindow(tab, "Transfer", visualWindowManager) { companion object { diff --git a/src/main/kotlin/app/termora/transfer/internal/sftp/SFTPTransferProtocolProvider.kt b/src/main/kotlin/app/termora/transfer/internal/sftp/SFTPTransferProtocolProvider.kt index aebbdc6..70bf82c 100644 --- a/src/main/kotlin/app/termora/transfer/internal/sftp/SFTPTransferProtocolProvider.kt +++ b/src/main/kotlin/app/termora/transfer/internal/sftp/SFTPTransferProtocolProvider.kt @@ -1,6 +1,6 @@ package app.termora.transfer.internal.sftp -import app.termora.SshClients +import app.termora.plugin.internal.ssh.SshClients import app.termora.protocol.PathHandler import app.termora.protocol.PathHandlerRequest import app.termora.protocol.TransferProtocolProvider diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index 02b6f44..96ca7f0 100644 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -258,6 +258,7 @@ termora.tabbed.contextmenu.rename=Rename termora.tabbed.contextmenu.sftp-command=SFTP Command termora.tabbed.contextmenu.sftp-not-install=SFTP programme not found, please install and try again termora.tabbed.contextmenu.clone=Clone +termora.tabbed.contextmenu.clone-session=Clone Session termora.tabbed.contextmenu.open-in-new-window=Open in New Window termora.tabbed.contextmenu.close=Close termora.tabbed.contextmenu.close-other-tabs=Close Other Tabs diff --git a/src/main/resources/i18n/messages_zh_CN.properties b/src/main/resources/i18n/messages_zh_CN.properties index d1f7cda..b550358 100644 --- a/src/main/resources/i18n/messages_zh_CN.properties +++ b/src/main/resources/i18n/messages_zh_CN.properties @@ -253,6 +253,7 @@ termora.tabbed.contextmenu.rename=重命名 termora.tabbed.contextmenu.sftp-command=SFTP 终端 termora.tabbed.contextmenu.sftp-not-install=没有找到 SFTP 程序,请安装后重试 termora.tabbed.contextmenu.clone=克隆 +termora.tabbed.contextmenu.clone-session=克隆会话 termora.tabbed.contextmenu.open-in-new-window=在新窗口打开 termora.tabbed.contextmenu.close=关闭 termora.tabbed.contextmenu.close-other-tabs=关闭其他标签页 diff --git a/src/main/resources/i18n/messages_zh_TW.properties b/src/main/resources/i18n/messages_zh_TW.properties index 716e930..7bf13a2 100644 --- a/src/main/resources/i18n/messages_zh_TW.properties +++ b/src/main/resources/i18n/messages_zh_TW.properties @@ -248,6 +248,7 @@ termora.tabbed.contextmenu.rename=重新命名 termora.tabbed.contextmenu.sftp-command=SFTP 終端 termora.tabbed.contextmenu.sftp-not-install=沒有找到 SFTP 程序,請安裝後重試 termora.tabbed.contextmenu.clone=克隆 +termora.tabbed.contextmenu.clone-session=克隆會話 termora.tabbed.contextmenu.open-in-new-window=在新視窗打開 termora.tabbed.contextmenu.close=關閉 termora.tabbed.contextmenu.close-other-tabs=關閉其他標籤頁 diff --git a/src/test/kotlin/app/termora/SSHDTest.kt b/src/test/kotlin/app/termora/SSHDTest.kt index 27b44bf..3bbdc43 100644 --- a/src/test/kotlin/app/termora/SSHDTest.kt +++ b/src/test/kotlin/app/termora/SSHDTest.kt @@ -1,5 +1,6 @@ package app.termora +import app.termora.plugin.internal.ssh.SshClients import org.apache.sshd.client.session.ClientSession import org.testcontainers.containers.GenericContainer import kotlin.test.AfterTest