feat: support login scripts

This commit is contained in:
hstyi
2025-07-01 10:08:01 +08:00
committed by hstyi
parent 21229e352f
commit 472bf6e81f
8 changed files with 332 additions and 10 deletions

View File

@@ -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<String> = mutableListOf(),
/**
* 登录脚本
*/
val loginScripts: List<LoginScript> = emptyList(),
/**
* 编码
*/

View File

@@ -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<LoginScript> {
if (loginScripts.isEmpty()) return emptyList()
if (len < 1) return emptyList()
val scripts = mutableListOf<LoginScript>()
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)
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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=串口

View File

@@ -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=端口