feat: support clone session

This commit is contained in:
hstyi
2025-07-05 11:57:06 +08:00
committed by hstyi
parent d54671757e
commit a32838dad6
19 changed files with 691 additions and 190 deletions

View File

@@ -9,10 +9,8 @@ import app.termora.findeverywhere.BasicFilterFindEverywhereProvider
import app.termora.findeverywhere.FindEverywhereProvider import app.termora.findeverywhere.FindEverywhereProvider
import app.termora.findeverywhere.FindEverywhereProviderExtension import app.termora.findeverywhere.FindEverywhereProviderExtension
import app.termora.findeverywhere.FindEverywhereResult import app.termora.findeverywhere.FindEverywhereResult
import app.termora.plugin.ExtensionManager
import app.termora.plugin.internal.extension.DynamicExtensionHandler 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 app.termora.terminal.DataKey
import com.formdev.flatlaf.FlatLaf import com.formdev.flatlaf.FlatLaf
import com.formdev.flatlaf.extras.components.FlatPopupMenu 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.MouseAdapter
import java.awt.event.MouseEvent import java.awt.event.MouseEvent
import java.beans.PropertyChangeListener import java.beans.PropertyChangeListener
import java.util.*
import javax.swing.* import javax.swing.*
import javax.swing.JTabbedPane.SCROLL_TAB_LAYOUT import javax.swing.JTabbedPane.SCROLL_TAB_LAYOUT
import kotlin.math.min import kotlin.math.min
@@ -211,6 +208,15 @@ class TerminalTabbed(
private fun showContextMenu(tabIndex: Int, e: MouseEvent) { private fun showContextMenu(tabIndex: Int, e: MouseEvent) {
val c = tabbedPane.getComponentAt(tabIndex) as JComponent val c = tabbedPane.getComponentAt(tabIndex) as JComponent
val tab = tabs[tabIndex] val tab = tabs[tabIndex]
val extensions = ExtensionManager.getInstance().getExtensions(TerminalTabbedContextMenuExtension::class.java)
val menuItems = mutableListOf<JMenuItem>()
for (extension in extensions) {
try {
menuItems.add(extension.createJMenuItem(windowScope, tab))
} catch (_: UnsupportedOperationException) {
continue
}
}
val popupMenu = FlatPopupMenu() 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 -> clone.addActionListener { evt ->
if (tab is HostTerminalTab) { if (tab is HostTerminalTab) {
actionManager actionManager
@@ -284,14 +290,10 @@ class TerminalTabbed(
} }
}) })
if (tab is HostTerminalTab) { if (menuItems.isNotEmpty()) {
val openHostAction = actionManager.getAction(OpenHostAction.OPEN_HOST) popupMenu.addSeparator()
if (openHostAction != null) { for (item in menuItems) {
if (tab.host.protocol == SSHProtocolProvider.PROTOCOL || tab.host.protocol == SFTPPtyProtocolProvider.PROTOCOL) { popupMenu.add(item)
popupMenu.addSeparator()
val sftpCommand = popupMenu.add(I18n.getString("termora.tabbed.contextmenu.sftp-command"))
sftpCommand.addActionListener { openSFTPPtyTab(tab, openHostAction, it) }
}
} }
} }
@@ -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 右键 * 对着 ToolBar 右键
*/ */

View File

@@ -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
}

View File

@@ -2,6 +2,7 @@ package app.termora.keymgr
import app.termora.* import app.termora.*
import app.termora.keyboardinteractive.TerminalUserInteraction import app.termora.keyboardinteractive.TerminalUserInteraction
import app.termora.plugin.internal.ssh.SshClients
import app.termora.terminal.ControlCharacters import app.termora.terminal.ControlCharacters
import app.termora.terminal.DataKey import app.termora.terminal.DataKey
import app.termora.terminal.PtyConnectorDelegate import app.termora.terminal.PtyConnectorDelegate

View File

@@ -4,6 +4,7 @@ import app.termora.*
import app.termora.database.DatabaseManager import app.termora.database.DatabaseManager
import app.termora.keymgr.KeyManager import app.termora.keymgr.KeyManager
import app.termora.keymgr.OhKeyPairKeyPairProvider import app.termora.keymgr.OhKeyPairKeyPairProvider
import app.termora.plugin.internal.ssh.SshClients
import app.termora.terminal.* import app.termora.terminal.*
import com.formdev.flatlaf.util.SystemInfo import com.formdev.flatlaf.util.SystemInfo
import org.apache.commons.io.Charsets import org.apache.commons.io.Charsets

View File

@@ -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()
}
}

View File

@@ -1,5 +1,6 @@
package app.termora.plugin.internal.ssh package app.termora.plugin.internal.ssh
import app.termora.TerminalTabbedContextMenuExtension
import app.termora.plugin.Extension import app.termora.plugin.Extension
import app.termora.plugin.InternalPlugin import app.termora.plugin.InternalPlugin
import app.termora.protocol.ProtocolHostPanelExtension import app.termora.protocol.ProtocolHostPanelExtension
@@ -9,6 +10,8 @@ internal class SSHInternalPlugin : InternalPlugin() {
init { init {
support.addExtension(ProtocolProviderExtension::class.java) { SSHProtocolProviderExtension.instance } support.addExtension(ProtocolProviderExtension::class.java) { SSHProtocolProviderExtension.instance }
support.addExtension(ProtocolHostPanelExtension::class.java) { SSHProtocolHostPanelExtension.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 { override fun getName(): String {

View File

@@ -10,41 +10,36 @@ import app.termora.keymap.KeymapManager
import app.termora.terminal.ControlCharacters import app.termora.terminal.ControlCharacters
import app.termora.terminal.DataKey import app.termora.terminal.DataKey
import app.termora.terminal.PtyConnector import app.termora.terminal.PtyConnector
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.*
import kotlinx.coroutines.launch
import kotlinx.coroutines.swing.Swing import kotlinx.coroutines.swing.Swing
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.withContext
import org.apache.commons.io.Charsets import org.apache.commons.io.Charsets
import org.apache.commons.lang3.StringUtils
import org.apache.sshd.client.SshClient import org.apache.sshd.client.SshClient
import org.apache.sshd.client.channel.ChannelShell import org.apache.sshd.client.channel.ChannelShell
import org.apache.sshd.client.session.ClientSession import org.apache.sshd.client.session.ClientSession
import org.apache.sshd.common.SshConstants import org.apache.sshd.common.future.CloseFuture
import org.apache.sshd.common.channel.Channel import org.apache.sshd.common.future.SshFutureListener
import org.apache.sshd.common.channel.ChannelListener
import org.apache.sshd.common.session.Session
import org.apache.sshd.common.session.SessionListener
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
import java.nio.charset.StandardCharsets import java.nio.charset.StandardCharsets
import javax.swing.Icon import javax.swing.Icon
import javax.swing.JComponent import javax.swing.JComponent
import javax.swing.SwingUtilities 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 { companion object {
val SSHSession = DataKey(ClientSession::class) val SSHSession = DataKey(ClientSession::class)
internal val MySshHandler = DataKey(SshHandler::class)
private val log = LoggerFactory.getLogger(SSHTerminalTab::class.java) private val log = LoggerFactory.getLogger(SSHTerminalTab::class.java)
} }
private val mutex = Mutex() private val mutex = Mutex()
private val tab = this private val owner get() = SwingUtilities.getWindowAncestor(terminalPanel)
private val tab get() = this
private var sshClient: SshClient? = null
private var sshSession: ClientSession? = null
private var sshChannelShell: ChannelShell? = null
init { init {
terminalPanel.dropFiles = false terminalPanel.dropFiles = false
@@ -55,12 +50,10 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
return terminalPanel return terminalPanel
} }
override fun canReconnect(): Boolean { override fun canReconnect(): Boolean {
return !mutex.isLocked return mutex.isLocked.not()
} }
override suspend fun openPtyConnector(): PtyConnector { override suspend fun openPtyConnector(): PtyConnector {
if (mutex.tryLock()) { if (mutex.tryLock()) {
try { try {
@@ -82,74 +75,32 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
// hide cursor // hide cursor
terminalModel.setData(DataKey.Companion.ShowCursor, false) terminalModel.setData(DataKey.Companion.ShowCursor, false)
// print // print
terminal.write("SSH client is opening...\r\n") terminal.write("Connecting to remote server ")
} }
val owner = SwingUtilities.getWindowAncestor(terminalPanel) val loading = coroutineScope.launch(Dispatchers.Swing) {
val client = SshClients.openClient(host, owner).also { sshClient = it } var c = 0
val sessionListener = MySessionListener() while (isActive) {
val channelListener = MyChannelListener() if (++c > 6) c = 1
terminal.write("${ControlCharacters.ESC}[1;32m")
withContext(Dispatchers.Swing) { terminal.write("SSH client opened successfully.\r\n\r\n") } terminal.write(".".repeat(c))
terminal.write(" ".repeat(6 - c))
client.addSessionListener(sessionListener) terminal.write("${ControlCharacters.ESC}[0m")
client.addChannelListener(channelListener) delay(350.milliseconds)
terminal.write("${ControlCharacters.BS}".repeat(6))
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 channel: ChannelShell
openTunnelings(session, host) try {
val client = openClient()
val session = openSession(client)
channel = openChannel(session)
// 打开隧道
openTunnelings(session, host)
} finally {
loading.cancel()
}
// 隐藏提示 // 隐藏提示
withContext(Dispatchers.Swing) { 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<CloseFuture> {
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") @Suppress("UNCHECKED_CAST")
override fun <T : Any> getData(dataKey: DataKey<T>): T? { override fun <T : Any> getData(dataKey: DataKey<T>): T? {
if (dataKey == SSHSession) { if (dataKey == SSHSession) {
return sshSession as T? return handler.session as T?
}
if (dataKey == MySshHandler) {
return handler as T?
} }
return super.getData(dataKey) return super.getData(dataKey)
} }
@@ -206,16 +215,7 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
if (mutex.tryLock()) { if (mutex.tryLock()) {
try { try {
super.stop() super.stop()
handler.close()
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
} finally { } finally {
mutex.unlock() mutex.unlock()
} }
@@ -231,36 +231,4 @@ class SSHTerminalTab(windowScope: WindowScope, host: Host) :
terminalPanel.storeVisualWindows(host.id) 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") }
}
}
} }

View File

@@ -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))
}
}

View File

@@ -1,5 +1,6 @@
package app.termora package app.termora.plugin.internal.ssh
import app.termora.*
import app.termora.keyboardinteractive.TerminalUserInteraction import app.termora.keyboardinteractive.TerminalUserInteraction
import app.termora.keymgr.OhKeyPairKeyPairProvider import app.termora.keymgr.OhKeyPairKeyPairProvider
import app.termora.terminal.TerminalSize 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.ClientSessionImpl
import org.apache.sshd.client.session.SessionFactory import org.apache.sshd.client.session.SessionFactory
import org.apache.sshd.common.AttributeRepository import org.apache.sshd.common.AttributeRepository
import org.apache.sshd.common.SshConstants
import org.apache.sshd.common.SshException import org.apache.sshd.common.SshException
import org.apache.sshd.common.channel.ChannelFactory import org.apache.sshd.common.channel.ChannelFactory
import org.apache.sshd.common.channel.PtyChannelConfiguration 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.HttpClientConnector
import org.eclipse.jgit.internal.transport.sshd.proxy.Socks5ClientConnector import org.eclipse.jgit.internal.transport.sshd.proxy.Socks5ClientConnector
import org.eclipse.jgit.transport.CredentialsProvider 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.IdentityPasswordProvider
import org.eclipse.jgit.transport.sshd.agent.ConnectorFactory import org.eclipse.jgit.transport.sshd.agent.ConnectorFactory
import org.slf4j.LoggerFactory import org.slf4j.LoggerFactory
@@ -89,7 +89,7 @@ object SshClients {
val HOST_KEY = AttributeRepository.AttributeKey<Host>() val HOST_KEY = AttributeRepository.AttributeKey<Host>()
private val timeout = Duration.ofSeconds(30) 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) } private val log by lazy { LoggerFactory.getLogger(SshClients::class.java) }
/** /**
@@ -166,7 +166,7 @@ object SshClients {
} }
val jumpHosts = mutableListOf<Host>() val jumpHosts = mutableListOf<Host>()
val hosts = HostManager.getInstance().hosts().associateBy { it.id } val hosts = HostManager.Companion.getInstance().hosts().associateBy { it.id }
for (jumpHostId in h.options.jumpHosts) { for (jumpHostId in h.options.jumpHosts) {
val e = hosts[jumpHostId] val e = hosts[jumpHostId]
if (e == null) { if (e == null) {
@@ -235,16 +235,16 @@ object SshClients {
if (SystemInfo.isMacOS) { if (SystemInfo.isMacOS) {
val file = FileUtils.getFile(Application.getBaseDataDir(), "config", "ssh-agent.sock") val file = FileUtils.getFile(Application.getBaseDataDir(), "config", "ssh-agent.sock")
if (file.exists()) { 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()) if (host.authentication.password.isNotBlank())
entry.setProperty(IDENTITY_AGENT, host.authentication.password) entry.setProperty(SshConstants.IDENTITY_AGENT, host.authentication.password)
else if (SystemInfo.isWindows) else if (SystemInfo.isWindows)
entry.setProperty(IDENTITY_AGENT, PageantConnector.DESCRIPTOR.identityAgent) entry.setProperty(SshConstants.IDENTITY_AGENT, PageantConnector.DESCRIPTOR.identityAgent)
else 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") throw SshException("Authentication failed")
} }
} catch (e: Exception) { } 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 owner = client.properties["owner"] as Window? ?: throw e
val askUserInfo = ask(host, entry, owner) ?: throw e val askUserInfo = ask(host, entry, owner) ?: throw e
if (askUserInfo.authentication.type == AuthenticationType.No) throw e if (askUserInfo.authentication.type == AuthenticationType.No) throw e
@@ -383,7 +383,7 @@ object SshClients {
val channelFactories = mutableListOf<ChannelFactory>() val channelFactories = mutableListOf<ChannelFactory>()
channelFactories.addAll(ClientBuilder.DEFAULT_CHANNEL_FACTORIES) channelFactories.addAll(ClientBuilder.DEFAULT_CHANNEL_FACTORIES)
channelFactories.add(X11ChannelFactory.INSTANCE) channelFactories.add(X11ChannelFactory.Companion.INSTANCE)
builder.channelFactories(channelFactories) builder.channelFactories(channelFactories)
val sshClient = builder.build() as JGitSshClient val sshClient = builder.build() as JGitSshClient
@@ -725,5 +725,4 @@ object SshClients {
} }
} }

View File

@@ -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
}
}
}

View File

@@ -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<ClientSession, MyClientSession>()
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<String?, *>?
): 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<ClientSession.ClientSessionEvent?>?,
timeout: Duration?
): Set<ClientSession.ClientSessionEvent?>? {
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 <T : Any?> resolveAttribute(key: AttributeRepository.AttributeKey<T?>?): 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 <T : Any?> computeAttributeIfAbsent(
key: AttributeRepository.AttributeKey<T?>?,
resolver: Function<in AttributeRepository.AttributeKey<T>, 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<String?>? {
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<String?>?) {
delegate.setCipherFactoriesNames(names)
}
override fun getCompressionFactoriesNameList(): String? {
return delegate.getCompressionFactoriesNameList()
}
override fun getCompressionFactoriesNames(): List<String?>? {
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<String?>?) {
delegate.setCompressionFactoriesNames(names)
}
override fun getMacFactoriesNameList(): String? {
return delegate.getMacFactoriesNameList()
}
override fun getMacFactoriesNames(): List<String?>? {
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<String?>?) {
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<String?>?) {
delegate.setSignatureFactoriesNames(names)
}
override fun getSignatureFactoriesNameList(): String? {
return delegate.getSignatureFactoriesNameList()
}
override fun getSignatureFactoriesNames(): List<String?>? {
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<String?>?) {
delegate.setUserAuthFactoriesNames(names)
}
override fun setUserAuthFactoriesNames(vararg names: String?) {
delegate.setUserAuthFactoriesNames(*names)
}
override fun getUserAuthFactoriesNameList(): String? {
return delegate.getUserAuthFactoriesNameList()
}
override fun getUserAuthFactoriesNames(): List<String?>? {
return delegate.getUserAuthFactoriesNames()
}
override fun setUserAuthFactoriesNameList(names: String?) {
delegate.setUserAuthFactoriesNameList(names)
}
override fun startLocalPortForwarding(
localPort: Int,
remote: SshdSocketAddress?
): SshdSocketAddress? {
return delegate.startLocalPortForwarding(localPort, remote)
}
}
}

View File

@@ -3,8 +3,8 @@ package app.termora.terminal.panel.vw
import app.termora.Disposer import app.termora.Disposer
import app.termora.I18n import app.termora.I18n
import app.termora.Icons import app.termora.Icons
import app.termora.SshClients
import app.termora.plugin.internal.ssh.SSHTerminalTab import app.termora.plugin.internal.ssh.SSHTerminalTab
import app.termora.plugin.internal.ssh.SshClients
import com.formdev.flatlaf.extras.FlatSVGIcon import com.formdev.flatlaf.extras.FlatSVGIcon
import com.jgoodies.forms.builder.FormBuilder import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout import com.jgoodies.forms.layout.FormLayout
@@ -28,7 +28,7 @@ import javax.xml.parsers.DocumentBuilderFactory
import javax.xml.xpath.XPathFactory import javax.xml.xpath.XPathFactory
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
class NvidiaSMIVisualWindow(tab: SSHTerminalTab, visualWindowManager: VisualWindowManager) : internal class NvidiaSMIVisualWindow(tab: SSHTerminalTab, visualWindowManager: VisualWindowManager) :
SSHVisualWindow(tab, "NVIDIA-SMI", visualWindowManager) { SSHVisualWindow(tab, "NVIDIA-SMI", visualWindowManager) {
companion object { companion object {

View File

@@ -1,7 +1,11 @@
package app.termora.terminal.panel.vw 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.SSHTerminalTab
import app.termora.plugin.internal.ssh.SshClients
import com.jgoodies.forms.builder.FormBuilder import com.jgoodies.forms.builder.FormBuilder
import com.jgoodies.forms.layout.FormLayout import com.jgoodies.forms.layout.FormLayout
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
@@ -16,7 +20,7 @@ import javax.swing.table.DefaultTableCellRenderer
import javax.swing.table.DefaultTableModel import javax.swing.table.DefaultTableModel
class SystemInformationVisualWindow(tab: SSHTerminalTab, visualWindowManager: VisualWindowManager) : internal class SystemInformationVisualWindow(tab: SSHTerminalTab, visualWindowManager: VisualWindowManager) :
SSHVisualWindow(tab, "SystemInformation", visualWindowManager) { SSHVisualWindow(tab, "SystemInformation", visualWindowManager) {
companion object { companion object {

View File

@@ -38,7 +38,7 @@ import kotlin.reflect.cast
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
class TransferVisualWindow(tab: SSHTerminalTab, visualWindowManager: VisualWindowManager) : internal class TransferVisualWindow(tab: SSHTerminalTab, visualWindowManager: VisualWindowManager) :
SSHVisualWindow(tab, "Transfer", visualWindowManager) { SSHVisualWindow(tab, "Transfer", visualWindowManager) {
companion object { companion object {

View File

@@ -1,6 +1,6 @@
package app.termora.transfer.internal.sftp 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.PathHandler
import app.termora.protocol.PathHandlerRequest import app.termora.protocol.PathHandlerRequest
import app.termora.protocol.TransferProtocolProvider import app.termora.protocol.TransferProtocolProvider

View File

@@ -258,6 +258,7 @@ termora.tabbed.contextmenu.rename=Rename
termora.tabbed.contextmenu.sftp-command=SFTP Command 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.sftp-not-install=SFTP programme not found, please install and try again
termora.tabbed.contextmenu.clone=Clone 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.open-in-new-window=Open in New Window
termora.tabbed.contextmenu.close=Close termora.tabbed.contextmenu.close=Close
termora.tabbed.contextmenu.close-other-tabs=Close Other Tabs termora.tabbed.contextmenu.close-other-tabs=Close Other Tabs

View File

@@ -253,6 +253,7 @@ termora.tabbed.contextmenu.rename=重命名
termora.tabbed.contextmenu.sftp-command=SFTP 终端 termora.tabbed.contextmenu.sftp-command=SFTP 终端
termora.tabbed.contextmenu.sftp-not-install=没有找到 SFTP 程序,请安装后重试 termora.tabbed.contextmenu.sftp-not-install=没有找到 SFTP 程序,请安装后重试
termora.tabbed.contextmenu.clone=克隆 termora.tabbed.contextmenu.clone=克隆
termora.tabbed.contextmenu.clone-session=克隆会话
termora.tabbed.contextmenu.open-in-new-window=在新窗口打开 termora.tabbed.contextmenu.open-in-new-window=在新窗口打开
termora.tabbed.contextmenu.close=关闭 termora.tabbed.contextmenu.close=关闭
termora.tabbed.contextmenu.close-other-tabs=关闭其他标签页 termora.tabbed.contextmenu.close-other-tabs=关闭其他标签页

View File

@@ -248,6 +248,7 @@ termora.tabbed.contextmenu.rename=重新命名
termora.tabbed.contextmenu.sftp-command=SFTP 終端 termora.tabbed.contextmenu.sftp-command=SFTP 終端
termora.tabbed.contextmenu.sftp-not-install=沒有找到 SFTP 程序,請安裝後重試 termora.tabbed.contextmenu.sftp-not-install=沒有找到 SFTP 程序,請安裝後重試
termora.tabbed.contextmenu.clone=克隆 termora.tabbed.contextmenu.clone=克隆
termora.tabbed.contextmenu.clone-session=克隆會話
termora.tabbed.contextmenu.open-in-new-window=在新視窗打開 termora.tabbed.contextmenu.open-in-new-window=在新視窗打開
termora.tabbed.contextmenu.close=關閉 termora.tabbed.contextmenu.close=關閉
termora.tabbed.contextmenu.close-other-tabs=關閉其他標籤頁 termora.tabbed.contextmenu.close-other-tabs=關閉其他標籤頁

View File

@@ -1,5 +1,6 @@
package app.termora package app.termora
import app.termora.plugin.internal.ssh.SshClients
import org.apache.sshd.client.session.ClientSession import org.apache.sshd.client.session.ClientSession
import org.testcontainers.containers.GenericContainer import org.testcontainers.containers.GenericContainer
import kotlin.test.AfterTest import kotlin.test.AfterTest