diff --git a/src/main/kotlin/app/termora/Host.kt b/src/main/kotlin/app/termora/Host.kt index 4f26354..e2eea4e 100644 --- a/src/main/kotlin/app/termora/Host.kt +++ b/src/main/kotlin/app/termora/Host.kt @@ -88,7 +88,27 @@ data class SerialComm( ) @Serializable -data class HostTag(val text: String) +data class LoginScript( + /** + * 等待字符串 + */ + val expect: String, + + /** + * 等待之后发送 + */ + val send: String, + + /** + * [expect] 是否是正则 + */ + val regex: Boolean = false, + + /** + * [expect] 是否大小写匹配,如果为 true 表示不忽略大小写,也就是:'A != a';如果为 false 那么 'A == a' + */ + val matchCase: Boolean = false, +) @Serializable @@ -97,6 +117,10 @@ data class Options( * 跳板机 */ val jumpHosts: List = mutableListOf(), + /** + * 登录脚本 + */ + val loginScripts: List = emptyList(), /** * 编码 */ diff --git a/src/main/kotlin/app/termora/PtyHostTerminalTab.kt b/src/main/kotlin/app/termora/PtyHostTerminalTab.kt index 585bafe..e3b8812 100644 --- a/src/main/kotlin/app/termora/PtyHostTerminalTab.kt +++ b/src/main/kotlin/app/termora/PtyHostTerminalTab.kt @@ -37,7 +37,7 @@ abstract class PtyHostTerminalTab( } // 开启 PTY - val ptyConnector = openPtyConnector() + val ptyConnector = loginScriptsPtyConnector(host, openPtyConnector()) ptyConnectorDelegate.ptyConnector = ptyConnector // 开启 reader @@ -81,6 +81,73 @@ abstract class PtyHostTerminalTab( } } + /** + * 登录脚本 + */ + open fun loginScriptsPtyConnector(host: Host, ptyConnector: PtyConnector): PtyConnector { + val loginScripts = host.options.loginScripts.toMutableList() + if (loginScripts.isEmpty()) { + return ptyConnector + } + + return object : PtyConnectorDelegate(ptyConnector) { + override fun read(buffer: CharArray): Int { + val len = super.read(buffer) + + // 获取一个匹配的登录脚本 + val scripts = runCatching { popLoginScript(buffer, len) }.getOrNull() ?: return len + if (scripts.isEmpty()) return len + + for (script in scripts) { + // send + write(script.send.toByteArray(getCharset())) + + // send \r or \n + val enter = terminal.getKeyEncoder().encode(TerminalKeyEvent(KeyEvent.VK_ENTER)) + .toByteArray(getCharset()) + write(enter) + } + + + return len + } + + private fun popLoginScript(buffer: CharArray, len: Int): List { + if (loginScripts.isEmpty()) return emptyList() + if (len < 1) return emptyList() + + val scripts = mutableListOf() + val text = String(buffer, 0, len) + val iterator = loginScripts.iterator() + while (iterator.hasNext()) { + val script = iterator.next() + if (script.expect.isEmpty()) { + scripts.add(script) + iterator.remove() + continue + } else if (script.regex) { + val regex = if (script.matchCase) script.expect.toRegex() + else script.expect.toRegex(RegexOption.IGNORE_CASE) + if (regex.matches(text)) { + scripts.add(script) + iterator.remove() + continue + } + } else { + if (text.contains(script.expect, script.matchCase.not())) { + scripts.add(script) + iterator.remove() + continue + } + } + break + } + + return scripts + } + } + } + open fun sendStartupCommand(ptyConnector: PtyConnector, bytes: ByteArray) { ptyConnector.write(bytes) } diff --git a/src/main/kotlin/app/termora/highlight/NewKeywordHighlightDialog.kt b/src/main/kotlin/app/termora/highlight/NewKeywordHighlightDialog.kt index fe1b3c0..ab50955 100644 --- a/src/main/kotlin/app/termora/highlight/NewKeywordHighlightDialog.kt +++ b/src/main/kotlin/app/termora/highlight/NewKeywordHighlightDialog.kt @@ -45,8 +45,8 @@ class NewKeywordHighlightDialog( Color(colorPalette.getColor(TerminalColor.Basic.BACKGROUND)), I18n.getString("termora.highlight.background-color") ) - val matchCaseBtn = JToggleButton(Icons.matchCase) - val regexBtn = JToggleButton(Icons.regex) + val matchCaseBtn = JToggleButton(Icons.matchCase).apply { toolTipText = I18n.getString("termora.match-case") } + val regexBtn = JToggleButton(Icons.regex).apply { toolTipText = I18n.getString("termora.regex") } private val textColorRevert = JButton(Icons.revert) 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 197c19e..2320edf 100644 --- a/src/main/kotlin/app/termora/plugin/internal/sftppty/SFTPPtyTerminalTab.kt +++ b/src/main/kotlin/app/termora/plugin/internal/sftppty/SFTPPtyTerminalTab.kt @@ -188,6 +188,11 @@ class SFTPPtyTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminal // Nothing } + override fun loginScriptsPtyConnector(host: Host, ptyConnector: PtyConnector): PtyConnector { + // Nothing + return ptyConnector + } + private inner class PasswordReporterDataListener(private val host: Host) : DataListener { override fun onChanged(key: DataKey<*>, data: Any) { if (key == VisualTerminal.Companion.Written && data is String) { diff --git a/src/main/kotlin/app/termora/plugin/internal/ssh/SSHHostOptionsPane.kt b/src/main/kotlin/app/termora/plugin/internal/ssh/SSHHostOptionsPane.kt index 8386965..5deaf8c 100644 --- a/src/main/kotlin/app/termora/plugin/internal/ssh/SSHHostOptionsPane.kt +++ b/src/main/kotlin/app/termora/plugin/internal/ssh/SSHHostOptionsPane.kt @@ -9,6 +9,9 @@ import app.termora.tree.HostTreeNode import app.termora.tree.NewHostTreeDialog import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.extras.components.FlatComboBox +import com.formdev.flatlaf.extras.components.FlatTabbedPane +import com.formdev.flatlaf.extras.components.FlatTable +import com.formdev.flatlaf.extras.components.FlatToolBar import com.formdev.flatlaf.ui.FlatTextBorder import com.formdev.flatlaf.util.SystemInfo import com.jgoodies.forms.builder.FormBuilder @@ -23,6 +26,7 @@ import java.nio.charset.Charset import javax.swing.* import javax.swing.table.DefaultTableCellRenderer import javax.swing.table.DefaultTableModel +import kotlin.math.max @Suppress("CascadeIf") open class SSHHostOptionsPane : OptionsPane() { @@ -95,6 +99,7 @@ open class SSHHostOptionsPane : OptionsPane() { sftpDefaultDirectory = sftpOption.defaultDirectoryField.text, enableX11Forwarding = tunnelingOption.x11ForwardingCheckBox.isSelected, x11Forwarding = tunnelingOption.x11ServerTextField.text, + loginScripts = terminalOption.loginScripts, ) return Host( @@ -138,6 +143,7 @@ open class SSHHostOptionsPane : OptionsPane() { terminalOption.environmentTextArea.text = host.options.env terminalOption.startupCommandTextField.text = host.options.startupCommand terminalOption.heartbeatIntervalTextField.value = host.options.heartbeatInterval + terminalOption.loginScripts.addAll(host.options.loginScripts) tunnelingOption.tunnelings.addAll(host.tunnelings) tunnelingOption.x11ForwardingCheckBox.isSelected = host.options.enableX11Forwarding @@ -367,11 +373,11 @@ open class SSHHostOptionsPane : OptionsPane() { private fun chooseKeyPair() { val dialog = KeyManagerDialog( - SwingUtilities.getWindowAncestor(this), + owner, selectMode = true, ) dialog.pack() - dialog.setLocationRelativeTo(null) + dialog.setLocationRelativeTo(owner) dialog.isVisible = true val selectedItem = publicKeyComboBox.selectedItem @@ -486,15 +492,61 @@ open class SSHHostOptionsPane : OptionsPane() { val startupCommandTextField = OutlineTextField() val heartbeatIntervalTextField = IntSpinner(30, minimum = 3, maximum = Int.MAX_VALUE) val environmentTextArea = FixedLengthTextArea(2048) + val loginScripts = mutableListOf() + private val addBtn = JButton(I18n.getString("termora.new-host.tunneling.add")) + private val editBtn = JButton(I18n.getString("termora.new-host.tunneling.edit")) + private val deleteBtn = JButton(I18n.getString("termora.new-host.tunneling.delete")) + private val table = FlatTable() + private val model = object : DefaultTableModel() { + override fun getRowCount(): Int { + return loginScripts.size + } + + override fun isCellEditable(row: Int, column: Int): Boolean { + return false + } + + fun addRow(loginScript: LoginScript) { + val rowCount = super.getRowCount() + loginScripts.add(loginScript) + super.fireTableRowsInserted(rowCount, rowCount + 1) + } + + override fun getValueAt(row: Int, column: Int): Any { + val loginScript = loginScripts[row] + return when (column) { + 0 -> loginScript.expect + 1 -> loginScript.send + else -> super.getValueAt(row, column) + } + } + } + private val tabbed = FlatTabbedPane() + init { initView() initEvents() } private fun initView() { - add(getCenterComponent(), BorderLayout.CENTER) + addBtn.isFocusable = false + editBtn.isFocusable = false + deleteBtn.isFocusable = false + + deleteBtn.isEnabled = false + editBtn.isEnabled = false + + tabbed.styleMap = mapOf( + "focusColor" to DynamicColor("TabbedPane.background"), + "hoverColor" to DynamicColor("TabbedPane.background"), + ) + tabbed.tabHeight = UIManager.getInt("TabbedPane.tabHeight") - 4 + putClientProperty("ContentPanelBorder", BorderFactory.createEmptyBorder()) + tabbed.addTab(I18n.getString("termora.new-host.general"), getCenterComponent()) + tabbed.addTab(I18n.getString("termora.new-host.terminal.login-scripts"), getLoginScriptsComponent()) + add(tabbed, BorderLayout.CENTER) environmentTextArea.setFocusTraversalKeys( @@ -521,7 +573,39 @@ open class SSHHostOptionsPane : OptionsPane() { } private fun initEvents() { + addBtn.addActionListener(object : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + val dialog = LoginScriptDialog(owner) + dialog.isVisible = true + model.addRow(dialog.loginScript ?: return) + } + }) + editBtn.addActionListener(object : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + val dialog = LoginScriptDialog(owner, loginScripts[table.selectedRow]) + dialog.isVisible = true + loginScripts[table.selectedRow] = dialog.loginScript ?: return + model.fireTableRowsUpdated(table.selectedRow, table.selectedRow) + } + }) + + deleteBtn.addActionListener(object : AbstractAction() { + override fun actionPerformed(e: ActionEvent) { + val rows = table.selectedRows + if (rows.isEmpty()) return + rows.sortDescending() + for (row in rows) { + loginScripts.removeAt(row) + model.fireTableRowsDeleted(row, row) + } + } + }) + + table.selectionModel.addListSelectionListener { + deleteBtn.isEnabled = table.selectedRowCount > 0 + editBtn.isEnabled = deleteBtn.isEnabled + } } @@ -546,6 +630,7 @@ open class SSHHostOptionsPane : OptionsPane() { var rows = 1 val step = 2 val panel = FormBuilder.create().layout(layout) + .border(BorderFactory.createEmptyBorder(6, 8, 6, 8)) .add("${I18n.getString("termora.new-host.terminal.encoding")}:").xy(1, rows) .add(charsetComboBox).xy(3, rows).apply { rows += step } .add("${I18n.getString("termora.new-host.terminal.heartbeat-interval")}:").xy(1, rows) @@ -560,6 +645,124 @@ open class SSHHostOptionsPane : OptionsPane() { return panel } + + private fun getLoginScriptsComponent(): JComponent { + val panel = JPanel(BorderLayout()) + val scrollPane = JScrollPane(table) + + model.addColumn(I18n.getString("termora.new-host.terminal.expect")) + model.addColumn(I18n.getString("termora.new-host.terminal.send")) + + table.putClientProperty( + FlatClientProperties.STYLE, mapOf( + "showHorizontalLines" to true, + "showVerticalLines" to true, + ) + ) + table.model = model + table.autoResizeMode = JTable.AUTO_RESIZE_SUBSEQUENT_COLUMNS + table.setDefaultRenderer( + Any::class.java, + DefaultTableCellRenderer().apply { horizontalAlignment = SwingConstants.CENTER }) + table.fillsViewportHeight = true + scrollPane.border = BorderFactory.createCompoundBorder( + BorderFactory.createEmptyBorder(4, 0, 4, 0), + BorderFactory.createMatteBorder(1, 1, 1, 1, DynamicColor.Companion.BorderColor) + ) + table.border = BorderFactory.createEmptyBorder() + + + val box = Box.createHorizontalBox() + box.add(addBtn) + box.add(Box.createHorizontalStrut(4)) + box.add(editBtn) + box.add(Box.createHorizontalStrut(4)) + box.add(deleteBtn) + + panel.add(scrollPane, BorderLayout.CENTER) + panel.add(box, BorderLayout.SOUTH) + panel.border = BorderFactory.createEmptyBorder(6, 8, 6, 8) + + return panel + } + + private inner class LoginScriptDialog( + owner: Window, + var loginScript: LoginScript? = null + ) : DialogWrapper(owner) { + private val formMargin = "4dlu" + private val expectTextField = OutlineTextField() + private val sendTextField = OutlineTextField() + private val regexToggleBtn = JToggleButton(Icons.regex) + .apply { toolTipText = I18n.getString("termora.regex") } + private val matchCaseToggleBtn = JToggleButton(Icons.matchCase) + .apply { toolTipText = I18n.getString("termora.match-case") } + + init { + isModal = true + title = I18n.getString("termora.new-host.terminal.login-scripts") + controlsVisible = false + + init() + pack() + size = Dimension(max(UIManager.getInt("Dialog.width") - 300, 250), preferredSize.height) + setLocationRelativeTo(owner) + + val toolbar = FlatToolBar().apply { isFloatable = false } + toolbar.add(regexToggleBtn) + toolbar.add(matchCaseToggleBtn) + expectTextField.trailingComponent = toolbar + expectTextField.placeholderText = I18n.getString("termora.optional") + + val script = loginScript + if (script != null) { + expectTextField.text = script.expect + sendTextField.text = script.send + matchCaseToggleBtn.isSelected = script.matchCase + regexToggleBtn.isSelected = script.regex + } + } + + override fun doOKAction() { + if (sendTextField.text.isBlank()) { + sendTextField.outline = "error" + sendTextField.requestFocusInWindow() + return + } + + loginScript = LoginScript( + expect = expectTextField.text, + send = sendTextField.text, + matchCase = matchCaseToggleBtn.isSelected, + regex = regexToggleBtn.isSelected, + ) + + super.doOKAction() + } + + override fun doCancelAction() { + loginScript = null + super.doCancelAction() + } + + override fun createCenterPanel(): JComponent { + val layout = FormLayout( + "left:pref, $formMargin, default:grow", + "pref, $formMargin, pref" + ) + + var rows = 1 + val step = 2 + return FormBuilder.create().layout(layout).padding("0dlu, $formMargin, $formMargin, $formMargin") + .add("${I18n.getString("termora.new-host.terminal.expect")}:").xy(1, rows) + .add(expectTextField).xy(3, rows).apply { rows += step } + .add("${I18n.getString("termora.new-host.terminal.send")}:").xy(1, rows) + .add(sendTextField).xy(3, rows).apply { rows += step } + .build() + } + + + } } protected inner class SFTPOption : JPanel(BorderLayout()), Option { @@ -720,7 +923,7 @@ open class SSHHostOptionsPane : OptionsPane() { addBtn.addActionListener(object : AbstractAction() { override fun actionPerformed(e: ActionEvent?) { - val dialog = PortForwardingDialog(SwingUtilities.getWindowAncestor(this@SSHHostOptionsPane)) + val dialog = PortForwardingDialog(owner) dialog.isVisible = true val tunneling = dialog.tunneling ?: return model.addRow(tunneling) @@ -735,7 +938,7 @@ open class SSHHostOptionsPane : OptionsPane() { return } val dialog = PortForwardingDialog( - SwingUtilities.getWindowAncestor(this@SSHHostOptionsPane), + owner, tunnelings[row] ) dialog.isVisible = true @@ -835,7 +1038,7 @@ open class SSHHostOptionsPane : OptionsPane() { init() pack() size = Dimension(UIManager.getInt("Dialog.width") - 300, size.height) - setLocationRelativeTo(null) + setLocationRelativeTo(owner) } diff --git a/src/main/resources/i18n/messages.properties b/src/main/resources/i18n/messages.properties index 29c1161..b050484 100644 --- a/src/main/resources/i18n/messages.properties +++ b/src/main/resources/i18n/messages.properties @@ -14,6 +14,9 @@ termora.file=File termora.explorer=Explorer termora.quit-confirm=Quit {0}? +termora.regex=Regex +termora.match-case=Match Case +termora.optional=Optional # update termora.update.title=New version @@ -192,6 +195,9 @@ termora.new-host.terminal.encoding=Encoding termora.new-host.terminal.heartbeat-interval=Heartbeat Interval termora.new-host.terminal.startup-commands=Startup Command termora.new-host.terminal.env=Environment +termora.new-host.terminal.login-scripts=Login Scripts +termora.new-host.terminal.expect=Expect +termora.new-host.terminal.send=Send termora.new-host.serial=Serial termora.new-host.serial.port=Port diff --git a/src/main/resources/i18n/messages_zh_CN.properties b/src/main/resources/i18n/messages_zh_CN.properties index 8dce696..f315c2f 100644 --- a/src/main/resources/i18n/messages_zh_CN.properties +++ b/src/main/resources/i18n/messages_zh_CN.properties @@ -13,6 +13,12 @@ termora.file=文件 termora.explorer=文件管理器 termora.quit-confirm=你要退出 {0} 吗? + +termora.regex=正则表达式 +termora.match-case=匹配大小写 +termora.optional=可选的 + + # update termora.update.title=新版本 termora.update.update=更新 @@ -180,6 +186,10 @@ termora.new-host.terminal.encoding=编码 termora.new-host.terminal.heartbeat-interval=心跳间隔 termora.new-host.terminal.startup-commands=启动命令 termora.new-host.terminal.env=环境 +termora.new-host.terminal.login-scripts=登录脚本 +termora.new-host.terminal.expect=预期 +termora.new-host.terminal.send=发送 + termora.new-host.serial=串口 diff --git a/src/main/resources/i18n/messages_zh_TW.properties b/src/main/resources/i18n/messages_zh_TW.properties index d4db0d2..e04a5d3 100644 --- a/src/main/resources/i18n/messages_zh_TW.properties +++ b/src/main/resources/i18n/messages_zh_TW.properties @@ -12,6 +12,10 @@ termora.file=文件 termora.explorer=檔案管理器 termora.quit-confirm=你要退出 {0} 嗎? +termora.regex=正規表示式 +termora.match-case=匹配大小寫 +termora.optional=可選的 + # update termora.update.title=新版本 termora.update.update=更新 @@ -181,6 +185,9 @@ termora.new-host.terminal.encoding=編碼 termora.new-host.terminal.startup-commands=啟動命令 termora.new-host.terminal.heartbeat-interval=心跳間隔 termora.new-host.terminal.env=環境 +termora.new-host.terminal.login-scripts=登入腳本 +termora.new-host.terminal.expect=預期 +termora.new-host.terminal.send=發送 termora.new-host.serial=串口 termora.new-host.serial.port=端口