feat: serial comm (#141)

This commit is contained in:
hstyi
2025-01-28 10:23:05 +08:00
committed by GitHub
parent f037cbfac0
commit 483582a8d1
28 changed files with 489 additions and 53 deletions

View File

@@ -241,3 +241,7 @@ https://github.com/mixpanel/mixpanel-java/blob/master/LICENSE
json-20231013 json-20231013
Public Domain. Public Domain.
https://github.com/stleary/JSON-java/blob/master/LICENSE https://github.com/stleary/JSON-java/blob/master/LICENSE
jSerialComm 2.11.0
Apache License 2.0
https://github.com/Fazecast/jSerialComm/blob/master/LICENSE-APACHE-2.0

View File

@@ -1,5 +1,6 @@
import org.gradle.internal.jvm.Jvm import org.gradle.internal.jvm.Jvm
import org.gradle.kotlin.dsl.support.uppercaseFirstChar import org.gradle.kotlin.dsl.support.uppercaseFirstChar
import org.gradle.nativeplatform.platform.internal.ArchitectureInternal
import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform
import org.jetbrains.kotlin.org.apache.commons.io.FileUtils import org.jetbrains.kotlin.org.apache.commons.io.FileUtils
import org.jetbrains.kotlin.org.apache.commons.lang3.StringUtils import org.jetbrains.kotlin.org.apache.commons.lang3.StringUtils
@@ -17,7 +18,7 @@ group = "app.termora"
version = "1.0.5" version = "1.0.5"
val os: OperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem() val os: OperatingSystem = DefaultNativePlatform.getCurrentOperatingSystem()
val arch: Architecture = DefaultNativePlatform.getCurrentArchitecture() val arch: ArchitectureInternal = DefaultNativePlatform.getCurrentArchitecture()
// macOS 签名信息 // macOS 签名信息
val macOSSignUsername = System.getenv("TERMORA_MAC_SIGN_USER_NAME") ?: StringUtils.EMPTY val macOSSignUsername = System.getenv("TERMORA_MAC_SIGN_USER_NAME") ?: StringUtils.EMPTY
@@ -104,6 +105,7 @@ dependencies {
implementation(libs.bip39) implementation(libs.bip39)
implementation(libs.colorpicker) implementation(libs.colorpicker)
implementation(libs.mixpanel) implementation(libs.mixpanel)
implementation(libs.jSerialComm)
} }
application { application {
@@ -148,6 +150,8 @@ tasks.register<Copy>("copy-dependencies") {
val jna = libs.jna.asProvider().get() val jna = libs.jna.asProvider().get()
val dylib = dir.get().dir("dylib").asFile val dylib = dir.get().dir("dylib").asFile
val pty4j = libs.pty4j.get() val pty4j = libs.pty4j.get()
val jSerialComm = libs.jSerialComm.get()
for (file in dir.get().asFile.listFiles() ?: emptyArray()) { for (file in dir.get().asFile.listFiles() ?: emptyArray()) {
if ("${jna.name}-${jna.version}" == file.nameWithoutExtension) { if ("${jna.name}-${jna.version}" == file.nameWithoutExtension) {
val targetDir = File(dylib, jna.name) val targetDir = File(dylib, jna.name)
@@ -172,6 +176,21 @@ tasks.register<Copy>("copy-dependencies") {
// @formatter:on // @formatter:on
// 删除所有二进制类库 // 删除所有二进制类库
exec { commandLine("zip", "-d", file.absolutePath, "resources/*") } exec { commandLine("zip", "-d", file.absolutePath, "resources/*") }
} else if ("${jSerialComm.name}-${jSerialComm.version}" == file.nameWithoutExtension) {
val archName = if (arch.isArm) "aarch64" else "x86_64"
val targetDir = FileUtils.getFile(dylib, jSerialComm.name, "OSX", archName)
FileUtils.forceMkdir(targetDir)
// @formatter:off
exec { commandLine("unzip", "-j" , "-o", file.absolutePath, "OSX/${archName}/*", "-d", targetDir.absolutePath) }
// @formatter:on
// 删除所有二进制类库
exec { commandLine("zip", "-d", file.absolutePath, "Android/*") }
exec { commandLine("zip", "-d", file.absolutePath, "FreeBSD/*") }
exec { commandLine("zip", "-d", file.absolutePath, "Linux/*") }
exec { commandLine("zip", "-d", file.absolutePath, "OpenBSD/*") }
exec { commandLine("zip", "-d", file.absolutePath, "OSX/*") }
exec { commandLine("zip", "-d", file.absolutePath, "Solaris/*") }
exec { commandLine("zip", "-d", file.absolutePath, "Windows/*") }
} }
} }

View File

@@ -41,6 +41,7 @@ rhino = "1.7.15"
delight-rhino-sandbox = "0.0.17" delight-rhino-sandbox = "0.0.17"
testcontainers = "1.20.4" testcontainers = "1.20.4"
mixpanel = "1.5.3" mixpanel = "1.5.3"
jSerialComm="2.11.0"
[libraries] [libraries]
kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-swing = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
@@ -97,6 +98,7 @@ rhino = { module = "org.mozilla:rhino", version.ref = "rhino" }
delight-rhino-sandbox = { module = "org.javadelight:delight-rhino-sandbox", version.ref = "delight-rhino-sandbox" } delight-rhino-sandbox = { module = "org.javadelight:delight-rhino-sandbox", version.ref = "delight-rhino-sandbox" }
colorpicker = { module = "org.drjekyll:colorpicker", version.ref = "colorpicker" } colorpicker = { module = "org.drjekyll:colorpicker", version.ref = "colorpicker" }
mixpanel = { module = "com.mixpanel:mixpanel-java", version.ref = "mixpanel" } mixpanel = { module = "com.mixpanel:mixpanel-java", version.ref = "mixpanel" }
jSerialComm = { module = "com.fazecast:jSerialComm", version.ref = "jSerialComm" }
[plugins] [plugins]
kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" }

View File

@@ -22,10 +22,6 @@ class ChannelShellPtyConnector(
output.flush() output.flush()
} }
override fun write(buffer: String) {
write(buffer.toByteArray(charset))
}
override fun resize(rows: Int, cols: Int) { override fun resize(rows: Int, cols: Int) {
channel.sendWindowChange(cols, rows) channel.sendWindowChange(cols, rows)
} }
@@ -38,4 +34,8 @@ class ChannelShellPtyConnector(
override fun close() { override fun close() {
channel.close(true) channel.close(true)
} }
override fun getCharset(): Charset {
return charset
}
} }

View File

@@ -37,6 +37,16 @@ class EditHostOptionsPane(private val host: Host) : HostOptionsPane() {
} }
jumpHostsOption.filter = { it.id != host.id } jumpHostsOption.filter = { it.id != host.id }
val serialComm = host.options.serialComm
if (serialComm.port.isNotBlank()) {
serialCommOption.serialPortComboBox.selectedItem = serialComm.port
}
serialCommOption.baudRateComboBox.selectedItem = serialComm.baudRate
serialCommOption.dataBitsComboBox.selectedItem = serialComm.dataBits
serialCommOption.parityComboBox.selectedItem = serialComm.parity
serialCommOption.stopBitsComboBox.selectedItem = serialComm.stopBits
serialCommOption.flowControlComboBox.selectedItem = serialComm.flowControl
} }
override fun getHost(): Host { override fun getHost(): Host {

View File

@@ -13,6 +13,7 @@ enum class Protocol {
Folder, Folder,
SSH, SSH,
Local, Local,
Serial
} }
@@ -39,6 +40,53 @@ data class Authentication(
} }
} }
enum class SerialCommParity {
None,
Even,
Odd,
Mark,
Space
}
enum class SerialCommFlowControl {
None,
RTS_CTS,
XON_XOFF,
}
@Serializable
data class SerialComm(
/**
* 串口
*/
val port: String = StringUtils.EMPTY,
/**
* 波特率
*/
val baudRate: Int = 9600,
/**
* 数据位5、6、7、8
*/
val dataBits: Int = 8,
/**
* 停止位: 1、1.5、2
*/
val stopBits: String = "1",
/**
* 校验位
*/
val parity: SerialCommParity = SerialCommParity.None,
/**
* 流控
*/
val flowControl: SerialCommFlowControl = SerialCommFlowControl.None,
)
@Serializable @Serializable
data class Options( data class Options(
@@ -61,7 +109,12 @@ data class Options(
/** /**
* SSH 心跳间隔 * SSH 心跳间隔
*/ */
val heartbeatInterval: Int = 30 val heartbeatInterval: Int = 30,
/**
* 串口配置
*/
val serialComm: SerialComm = SerialComm(),
) { ) {
companion object { companion object {
val Default = Options() val Default = Options()

View File

@@ -67,37 +67,53 @@ class HostDialog(owner: Window, host: Host? = null) : DialogWrapper(owner) {
private suspend fun testConnection(host: Host) { private suspend fun testConnection(host: Host) {
val owner = this val owner = this
if (host.protocol != Protocol.SSH) { if (host.protocol == Protocol.Local) {
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(owner, I18n.getString("termora.new-host.test-connection-successful")) OptionPane.showMessageDialog(owner, I18n.getString("termora.new-host.test-connection-successful"))
} }
return return
} }
try {
if (host.protocol == Protocol.SSH) {
testSSH(host)
} else if (host.protocol == Protocol.Serial) {
testSerial(host)
}
} catch (e: Exception) {
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(
owner, ExceptionUtils.getMessage(e),
messageType = JOptionPane.ERROR_MESSAGE
)
}
return
}
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(
owner,
I18n.getString("termora.new-host.test-connection-successful")
)
}
}
private fun testSSH(host: Host) {
var client: SshClient? = null var client: SshClient? = null
var session: ClientSession? = null var session: ClientSession? = null
try { try {
client = SshClients.openClient(host) client = SshClients.openClient(host)
client.userInteraction = TerminalUserInteraction(owner) client.userInteraction = TerminalUserInteraction(owner)
session = SshClients.openSession(host, client) session = SshClients.openSession(host, client)
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(
owner,
I18n.getString("termora.new-host.test-connection-successful")
)
}
} catch (e: Exception) {
withContext(Dispatchers.Swing) {
OptionPane.showMessageDialog(
owner, ExceptionUtils.getRootCauseMessage(e),
messageType = JOptionPane.ERROR_MESSAGE
)
}
} finally { } finally {
session?.close() session?.close()
client?.close() client?.close()
} }
}
private fun testSerial(host: Host) {
Serials.openPort(host).closePort()
} }
override fun doOKAction() { override fun doOKAction() {

View File

@@ -2,11 +2,17 @@ package app.termora
import app.termora.keymgr.KeyManager import app.termora.keymgr.KeyManager
import app.termora.keymgr.KeyManagerDialog import app.termora.keymgr.KeyManagerDialog
import com.fazecast.jSerialComm.SerialPort
import com.formdev.flatlaf.FlatClientProperties import com.formdev.flatlaf.FlatClientProperties
import com.formdev.flatlaf.extras.components.FlatComboBox import com.formdev.flatlaf.extras.components.FlatComboBox
import com.formdev.flatlaf.ui.FlatTextBorder import com.formdev.flatlaf.ui.FlatTextBorder
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.GlobalScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.swing.Swing
import kotlinx.coroutines.withContext
import org.apache.commons.lang3.StringUtils import org.apache.commons.lang3.StringUtils
import java.awt.* import java.awt.*
import java.awt.event.* import java.awt.event.*
@@ -22,6 +28,7 @@ open class HostOptionsPane : OptionsPane() {
protected val proxyOption = ProxyOption() protected val proxyOption = ProxyOption()
protected val terminalOption = TerminalOption() protected val terminalOption = TerminalOption()
protected val jumpHostsOption = JumpHostsOption() protected val jumpHostsOption = JumpHostsOption()
protected val serialCommOption = SerialCommOption()
protected val owner: Window get() = SwingUtilities.getWindowAncestor(this) protected val owner: Window get() = SwingUtilities.getWindowAncestor(this)
init { init {
@@ -30,6 +37,7 @@ open class HostOptionsPane : OptionsPane() {
addOption(tunnelingOption) addOption(tunnelingOption)
addOption(jumpHostsOption) addOption(jumpHostsOption)
addOption(terminalOption) addOption(terminalOption)
addOption(serialCommOption)
setContentBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8)) setContentBorder(BorderFactory.createEmptyBorder(6, 8, 6, 8))
} }
@@ -43,6 +51,7 @@ open class HostOptionsPane : OptionsPane() {
var authentication = Authentication.No var authentication = Authentication.No
var proxy = Proxy.No var proxy = Proxy.No
if (generalOption.authenticationTypeComboBox.selectedItem == AuthenticationType.Password) { if (generalOption.authenticationTypeComboBox.selectedItem == AuthenticationType.Password) {
authentication = authentication.copy( authentication = authentication.copy(
type = AuthenticationType.Password, type = AuthenticationType.Password,
@@ -66,12 +75,23 @@ open class HostOptionsPane : OptionsPane() {
) )
} }
val serialComm = SerialComm(
port = serialCommOption.serialPortComboBox.selectedItem?.toString() ?: StringUtils.EMPTY,
baudRate = serialCommOption.baudRateComboBox.selectedItem?.toString()?.toIntOrNull() ?: 9600,
dataBits = serialCommOption.dataBitsComboBox.selectedItem as Int? ?: 8,
stopBits = serialCommOption.stopBitsComboBox.selectedItem as String? ?: "1",
parity = serialCommOption.parityComboBox.selectedItem as SerialCommParity,
flowControl = serialCommOption.flowControlComboBox.selectedItem as SerialCommFlowControl
)
val options = Options.Default.copy( val options = Options.Default.copy(
encoding = terminalOption.charsetComboBox.selectedItem as String, encoding = terminalOption.charsetComboBox.selectedItem as String,
env = terminalOption.environmentTextArea.text, env = terminalOption.environmentTextArea.text,
startupCommand = terminalOption.startupCommandTextField.text, startupCommand = terminalOption.startupCommandTextField.text,
heartbeatInterval = (terminalOption.heartbeatIntervalTextField.value ?: 30) as Int, heartbeatInterval = (terminalOption.heartbeatIntervalTextField.value ?: 30) as Int,
jumpHosts = jumpHostsOption.jumpHosts.map { it.id } jumpHosts = jumpHostsOption.jumpHosts.map { it.id },
serialComm = serialComm
) )
return Host( return Host(
@@ -103,6 +123,12 @@ open class HostOptionsPane : OptionsPane() {
if (validateField(generalOption.usernameTextField)) { if (validateField(generalOption.usernameTextField)) {
return false return false
} }
} else if (host.protocol == Protocol.Serial) {
if (validateField(serialCommOption.serialPortComboBox)
|| validateField(serialCommOption.baudRateComboBox)
) {
return false
}
} }
if (host.authentication.type == AuthenticationType.Password) { if (host.authentication.type == AuthenticationType.Password) {
@@ -152,7 +178,8 @@ open class HostOptionsPane : OptionsPane() {
* 返回 true 表示有错误 * 返回 true 表示有错误
*/ */
private fun validateField(comboBox: JComboBox<*>): Boolean { private fun validateField(comboBox: JComboBox<*>): Boolean {
if (comboBox.isEnabled && comboBox.selectedItem == null) { val selectedItem = comboBox.selectedItem
if (comboBox.isEnabled && (selectedItem == null || (selectedItem is String && selectedItem.isBlank()))) {
selectOptionJComponent(comboBox) selectOptionJComponent(comboBox)
comboBox.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR) comboBox.putClientProperty(FlatClientProperties.OUTLINE, FlatClientProperties.OUTLINE_ERROR)
comboBox.requestFocusInWindow() comboBox.requestFocusInWindow()
@@ -259,6 +286,7 @@ open class HostOptionsPane : OptionsPane() {
protocolTypeComboBox.addItem(Protocol.SSH) protocolTypeComboBox.addItem(Protocol.SSH)
protocolTypeComboBox.addItem(Protocol.Local) protocolTypeComboBox.addItem(Protocol.Local)
protocolTypeComboBox.addItem(Protocol.Serial)
authenticationTypeComboBox.addItem(AuthenticationType.No) authenticationTypeComboBox.addItem(AuthenticationType.No)
authenticationTypeComboBox.addItem(AuthenticationType.Password) authenticationTypeComboBox.addItem(AuthenticationType.Password)
@@ -328,7 +356,9 @@ open class HostOptionsPane : OptionsPane() {
passwordTextField.isEnabled = true passwordTextField.isEnabled = true
chooseKeyBtn.isEnabled = true chooseKeyBtn.isEnabled = true
if (protocolTypeComboBox.selectedItem == Protocol.Local) { if (protocolTypeComboBox.selectedItem == Protocol.Local
|| protocolTypeComboBox.selectedItem == Protocol.Serial
) {
hostTextField.isEnabled = false hostTextField.isEnabled = false
portTextField.isEnabled = false portTextField.isEnabled = false
usernameTextField.isEnabled = false usernameTextField.isEnabled = false
@@ -901,6 +931,127 @@ open class HostOptionsPane : OptionsPane() {
} }
} }
protected inner class SerialCommOption : JPanel(BorderLayout()), Option {
val serialPortComboBox = OutlineComboBox<String>()
val baudRateComboBox = OutlineComboBox<Int>()
val dataBitsComboBox = OutlineComboBox<Int>()
val parityComboBox = OutlineComboBox<SerialCommParity>()
val stopBitsComboBox = OutlineComboBox<String>()
val flowControlComboBox = OutlineComboBox<SerialCommFlowControl>()
init {
initView()
initEvents()
}
private fun initView() {
serialPortComboBox.isEditable = true
baudRateComboBox.isEditable = true
baudRateComboBox.addItem(9600)
baudRateComboBox.addItem(19200)
baudRateComboBox.addItem(38400)
baudRateComboBox.addItem(57600)
baudRateComboBox.addItem(115200)
dataBitsComboBox.addItem(5)
dataBitsComboBox.addItem(6)
dataBitsComboBox.addItem(7)
dataBitsComboBox.addItem(8)
dataBitsComboBox.selectedItem = 8
parityComboBox.addItem(SerialCommParity.None)
parityComboBox.addItem(SerialCommParity.Even)
parityComboBox.addItem(SerialCommParity.Odd)
parityComboBox.addItem(SerialCommParity.Mark)
parityComboBox.addItem(SerialCommParity.Space)
stopBitsComboBox.addItem("1")
stopBitsComboBox.addItem("1.5")
stopBitsComboBox.addItem("2")
stopBitsComboBox.selectedItem = "1"
flowControlComboBox.addItem(SerialCommFlowControl.None)
flowControlComboBox.addItem(SerialCommFlowControl.RTS_CTS)
flowControlComboBox.addItem(SerialCommFlowControl.XON_XOFF)
flowControlComboBox.renderer = object : DefaultListCellRenderer() {
override fun getListCellRendererComponent(
list: JList<*>?,
value: Any?,
index: Int,
isSelected: Boolean,
cellHasFocus: Boolean
): Component {
val text = value?.toString() ?: StringUtils.EMPTY
return super.getListCellRendererComponent(
list,
text.replace('_', '/'),
index,
isSelected,
cellHasFocus
)
}
}
add(getCenterComponent(), BorderLayout.CENTER)
}
private fun initEvents() {
addComponentListener(object : ComponentAdapter() {
override fun componentShown(e: ComponentEvent) {
removeComponentListener(this)
@Suppress("OPT_IN_USAGE")
GlobalScope.launch(Dispatchers.IO) {
for (commPort in SerialPort.getCommPorts()) {
withContext(Dispatchers.Swing) {
serialPortComboBox.addItem(commPort.systemPortName)
}
}
}
}
})
}
override fun getIcon(isSelected: Boolean): Icon {
return Icons.plugin
}
override fun getTitle(): String {
return I18n.getString("termora.new-host.serial")
}
override fun getJComponent(): JComponent {
return this
}
private fun getCenterComponent(): JComponent {
val layout = FormLayout(
"left:pref, $formMargin, default:grow, $formMargin",
"pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref, $formMargin, pref"
)
var rows = 1
val step = 2
val panel = FormBuilder.create().layout(layout)
.add("${I18n.getString("termora.new-host.serial.port")}:").xy(1, rows)
.add(serialPortComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.serial.baud-rate")}:").xy(1, rows)
.add(baudRateComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.serial.data-bits")}:").xy(1, rows)
.add(dataBitsComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.serial.parity")}:").xy(1, rows)
.add(parityComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.serial.stop-bits")}:").xy(1, rows)
.add(stopBitsComboBox).xy(3, rows).apply { rows += step }
.add("${I18n.getString("termora.new-host.serial.flow-control")}:").xy(1, rows)
.add(flowControlComboBox).xy(3, rows).apply { rows += step }
.build()
return panel
}
}
protected inner class JumpHostsOption : JPanel(BorderLayout()), Option { protected inner class JumpHostsOption : JPanel(BorderLayout()), Option {
val jumpHosts = mutableListOf<Host>() val jumpHosts = mutableListOf<Host>()

View File

@@ -69,6 +69,8 @@ class HostTree : JTree(), Disposable {
icon = if (expanded) FlatTreeOpenIcon() else FlatTreeClosedIcon() icon = if (expanded) FlatTreeOpenIcon() else FlatTreeClosedIcon()
} else if (host.protocol == Protocol.SSH || host.protocol == Protocol.Local) { } else if (host.protocol == Protocol.SSH || host.protocol == Protocol.Local) {
icon = if (sel && this@HostTree.hasFocus()) Icons.terminal.dark else Icons.terminal icon = if (sel && this@HostTree.hasFocus()) Icons.terminal.dark else Icons.terminal
} else if (host.protocol == Protocol.Serial) {
icon = if (sel && this@HostTree.hasFocus()) Icons.plugin.dark else Icons.plugin
} }
return c return c
} }

View File

@@ -3,6 +3,7 @@ package app.termora
object Icons { object Icons {
val bulletList by lazy { DynamicIcon("icons/bulletList.svg", "icons/bulletList_dark.svg") } val bulletList by lazy { DynamicIcon("icons/bulletList.svg", "icons/bulletList_dark.svg") }
val up by lazy { DynamicIcon("icons/up.svg", "icons/up_dark.svg") } val up by lazy { DynamicIcon("icons/up.svg", "icons/up_dark.svg") }
val plugin by lazy { DynamicIcon("icons/plugin.svg", "icons/plugin_dark.svg") }
val moveUp by lazy { DynamicIcon("icons/moveUp.svg", "icons/moveUp_dark.svg") } val moveUp by lazy { DynamicIcon("icons/moveUp.svg", "icons/moveUp_dark.svg") }
val down by lazy { DynamicIcon("icons/down.svg", "icons/down_dark.svg") } val down by lazy { DynamicIcon("icons/down.svg", "icons/down_dark.svg") }
val moveDown by lazy { DynamicIcon("icons/moveDown.svg", "icons/moveDown_dark.svg") } val moveDown by lazy { DynamicIcon("icons/moveDown.svg", "icons/moveDown_dark.svg") }

View File

@@ -41,4 +41,9 @@ private fun setupNativeLibraries() {
if (pty4j.exists()) { if (pty4j.exists()) {
System.setProperty(PtyUtil.PREFERRED_NATIVE_FOLDER_KEY, pty4j.absolutePath) System.setProperty(PtyUtil.PREFERRED_NATIVE_FOLDER_KEY, pty4j.absolutePath)
} }
val jSerialComm = FileUtils.getFile(dylib, "jSerialComm")
if (jSerialComm.exists()) {
System.setProperty("jSerialComm.library.path", jSerialComm.absolutePath)
}
} }

View File

@@ -53,8 +53,12 @@ abstract class PtyHostTerminalTab(
coroutineScope.launch(Dispatchers.IO) { coroutineScope.launch(Dispatchers.IO) {
delay(250.milliseconds) delay(250.milliseconds)
withContext(Dispatchers.Swing) { withContext(Dispatchers.Swing) {
ptyConnector.write(host.options.startupCommand) val charset = ptyConnector.getCharset()
ptyConnector.write(terminal.getKeyEncoder().encode(TerminalKeyEvent(KeyEvent.VK_ENTER))) ptyConnector.write(host.options.startupCommand.toByteArray(charset))
ptyConnector.write(
terminal.getKeyEncoder().encode(TerminalKeyEvent(KeyEvent.VK_ENTER))
.toByteArray(charset)
)
} }
} }
} }

View File

@@ -0,0 +1,61 @@
package app.termora
import app.termora.terminal.PtyConnector
import com.fazecast.jSerialComm.SerialPort
import com.fazecast.jSerialComm.SerialPortDataListener
import com.fazecast.jSerialComm.SerialPortEvent
import java.nio.charset.Charset
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
class SerialPortPtyConnector(
private val serialPort: SerialPort,
private val charset: Charset = Charsets.UTF_8
) : PtyConnector, SerialPortDataListener {
private val queue = LinkedBlockingQueue<Char>()
init {
serialPort.addDataListener(this)
}
override fun read(buffer: CharArray): Int {
buffer[0] = queue.poll(1, TimeUnit.SECONDS) ?: return 0
return 1
}
override fun write(buffer: ByteArray, offset: Int, len: Int) {
serialPort.writeBytes(buffer, len, offset)
}
override fun resize(rows: Int, cols: Int) {
}
override fun waitFor(): Int {
return 0
}
override fun close() {
queue.clear()
serialPort.closePort()
}
override fun getListeningEvents(): Int {
return SerialPort.LISTENING_EVENT_DATA_RECEIVED
}
override fun serialEvent(event: SerialPortEvent) {
if (event.eventType == SerialPort.LISTENING_EVENT_DATA_RECEIVED) {
val data = event.receivedData
if (data.isEmpty()) return
for (c in String(data, charset).toCharArray()) {
queue.add(c)
}
}
}
override fun getCharset(): Charset {
return charset
}
}

View File

@@ -0,0 +1,20 @@
package app.termora
import app.termora.terminal.PtyConnector
import org.apache.commons.io.Charsets
import java.nio.charset.StandardCharsets
import javax.swing.Icon
class SerialTerminalTab(windowScope: WindowScope, host: Host) : PtyHostTerminalTab(windowScope, host) {
override suspend fun openPtyConnector(): PtyConnector {
val serialPort = Serials.openPort(host)
return SerialPortPtyConnector(
serialPort,
Charsets.toCharset(host.options.encoding, StandardCharsets.UTF_8)
)
}
override fun getIcon(): Icon {
return Icons.plugin
}
}

View File

@@ -0,0 +1,38 @@
package app.termora
import com.fazecast.jSerialComm.SerialPort
object Serials {
fun openPort(host: Host): SerialPort {
val serialComm = host.options.serialComm
val serialPort = SerialPort.getCommPort(serialComm.port)
serialPort.setBaudRate(serialComm.baudRate)
serialPort.setNumDataBits(serialComm.dataBits)
when (serialComm.parity) {
SerialCommParity.None -> serialPort.setParity(SerialPort.NO_PARITY)
SerialCommParity.Mark -> serialPort.setParity(SerialPort.MARK_PARITY)
SerialCommParity.Even -> serialPort.setParity(SerialPort.EVEN_PARITY)
SerialCommParity.Odd -> serialPort.setParity(SerialPort.ODD_PARITY)
SerialCommParity.Space -> serialPort.setParity(SerialPort.SPACE_PARITY)
}
when (serialComm.stopBits) {
"1" -> serialPort.setNumStopBits(SerialPort.ONE_STOP_BIT)
"1.5" -> serialPort.setNumStopBits(SerialPort.ONE_POINT_FIVE_STOP_BITS)
"2" -> serialPort.setNumStopBits(SerialPort.TWO_STOP_BITS)
}
when (serialComm.flowControl) {
SerialCommFlowControl.None -> serialPort.setFlowControl(SerialPort.FLOW_CONTROL_DISABLED)
SerialCommFlowControl.RTS_CTS -> serialPort.setFlowControl(SerialPort.FLOW_CONTROL_RTS_ENABLED or SerialPort.FLOW_CONTROL_CTS_ENABLED)
SerialCommFlowControl.XON_XOFF -> serialPort.setFlowControl(SerialPort.FLOW_CONTROL_XONXOFF_IN_ENABLED or SerialPort.FLOW_CONTROL_XONXOFF_OUT_ENABLED)
}
if (!serialPort.openPort()) {
throw IllegalStateException("Open serial port [${serialComm.port}] failed")
}
return serialPort
}
}

View File

@@ -1,9 +1,6 @@
package app.termora.actions package app.termora.actions
import app.termora.LocalTerminalTab import app.termora.*
import app.termora.OpenHostActionEvent
import app.termora.Protocol
import app.termora.SSHTerminalTab
class OpenHostAction : AnAction() { class OpenHostAction : AnAction() {
companion object { companion object {
@@ -18,9 +15,11 @@ class OpenHostAction : AnAction() {
val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return val terminalTabbedManager = evt.getData(DataProviders.TerminalTabbedManager) ?: return
val windowScope = evt.getData(DataProviders.WindowScope) ?: return val windowScope = evt.getData(DataProviders.WindowScope) ?: return
val tab = if (evt.host.protocol == Protocol.SSH) val tab = when (evt.host.protocol) {
SSHTerminalTab(windowScope, evt.host) Protocol.SSH -> SSHTerminalTab(windowScope, evt.host)
else LocalTerminalTab(windowScope, evt.host) Protocol.Serial -> SerialTerminalTab(windowScope, evt.host)
else -> LocalTerminalTab(windowScope, evt.host)
}
terminalTabbedManager.addTerminalTab(tab) terminalTabbedManager.addTerminalTab(tab)
tab.start() tab.start()

View File

@@ -485,9 +485,11 @@ class ControlSequenceIntroducerProcessor(terminal: Terminal, reader: TerminalRea
val m = args.first() val m = args.first()
if (m == '6') { if (m == '6') {
val position = terminal.getCursorModel().getPosition() val position = terminal.getCursorModel().getPosition()
ptyConnector.write("${ControlCharacters.ESC}[${position.y};${position.x}R") val bytes = "${ControlCharacters.ESC}[${position.y};${position.x}R".toByteArray(ptyConnector.getCharset())
ptyConnector.write(bytes)
} else if (m == '5') { } else if (m == '5') {
ptyConnector.write("${ControlCharacters.ESC}[0n") val bytes = "${ControlCharacters.ESC}[0n".toByteArray(ptyConnector.getCharset())
ptyConnector.write(bytes)
} }
} }

View File

@@ -1,6 +1,7 @@
package app.termora.terminal package app.termora.terminal
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.charset.Charset
interface PtyConnector { interface PtyConnector {
@@ -15,15 +16,18 @@ interface PtyConnector {
*/ */
fun write(buffer: ByteArray, offset: Int, len: Int) fun write(buffer: ByteArray, offset: Int, len: Int)
/**
* 写入数组。
*
* 如果要写入 String 字符串,请通过 [getCharset] 编码。
*/
fun write(buffer: ByteArray) { fun write(buffer: ByteArray) {
write(buffer, 0, buffer.size) write(buffer, 0, buffer.size)
} }
fun write(buffer: String) { /**
if (buffer.isEmpty()) return * 写入单个 Int
write(buffer.toByteArray()) */
}
fun write(buffer: Int) { fun write(buffer: Int) {
write(ByteBuffer.allocate(Integer.BYTES).putInt(buffer).flip().array()) write(ByteBuffer.allocate(Integer.BYTES).putInt(buffer).flip().array())
} }
@@ -43,4 +47,8 @@ interface PtyConnector {
*/ */
fun close() fun close()
/**
* 编码
*/
fun getCharset(): Charset = Charsets.UTF_8
} }

View File

@@ -1,5 +1,7 @@
package app.termora.terminal package app.termora.terminal
import java.nio.charset.Charset
open class PtyConnectorDelegate( open class PtyConnectorDelegate(
@Volatile var ptyConnector: PtyConnector? = null @Volatile var ptyConnector: PtyConnector? = null
) : PtyConnector { ) : PtyConnector {
@@ -26,5 +28,7 @@ open class PtyConnectorDelegate(
ptyConnector = null ptyConnector = null
} }
override fun getCharset(): Charset {
return ptyConnector?.getCharset() ?: super.getCharset()
}
} }

View File

@@ -20,9 +20,6 @@ class PtyProcessConnector(private val process: PtyProcess, private val charset:
output.flush() output.flush()
} }
override fun write(buffer: String) {
write(buffer.toByteArray(charset))
}
override fun resize(rows: Int, cols: Int) { override fun resize(rows: Int, cols: Int) {
process.winSize = WinSize(cols, rows) process.winSize = WinSize(cols, rows)
@@ -38,5 +35,7 @@ class PtyProcessConnector(private val process: PtyProcess, private val charset:
process.destroyForcibly() process.destroyForcibly()
} }
override fun getCharset(): Charset {
return charset
}
} }

View File

@@ -298,7 +298,7 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
// 输入法提交 // 输入法提交
if (committedCharacterCount > 0) { if (committedCharacterCount > 0) {
ptyConnector.write(sb.toString()) ptyConnector.write(sb.toString().toByteArray(ptyConnector.getCharset()))
} else { } else {
val breakIterator = BreakIterator.getCharacterInstance() val breakIterator = BreakIterator.getCharacterInstance()
val chars = mutableListOf<Char>() val chars = mutableListOf<Char>()
@@ -404,9 +404,15 @@ class TerminalPanel(val terminal: Terminal, private val ptyConnector: PtyConnect
} }
if (terminal.getTerminalModel().getData(DataKey.BracketedPasteMode, false)) { if (terminal.getTerminalModel().getData(DataKey.BracketedPasteMode, false)) {
ptyConnector.write("${ControlCharacters.ESC}[200~${content}${ControlCharacters.ESC}[201~") val bytes = ptyConnector.getCharset()
.encode("${ControlCharacters.ESC}[200~${content}${ControlCharacters.ESC}[201~")
.array()
ptyConnector.write(bytes)
} else { } else {
ptyConnector.write(content) val bytes = ptyConnector.getCharset()
.encode(content)
.array()
ptyConnector.write(bytes)
} }
terminal.getScrollingModel().scrollToRow( terminal.getScrollingModel().scrollToRow(

View File

@@ -24,7 +24,7 @@ class TerminalPanelKeyAdapter(
} }
terminal.getSelectionModel().clearSelection() terminal.getSelectionModel().clearSelection()
ptyConnector.write("${e.keyChar}") ptyConnector.write("${e.keyChar}".toByteArray(ptyConnector.getCharset()))
terminal.getScrollingModel().scrollTo(Int.MAX_VALUE) terminal.getScrollingModel().scrollTo(Int.MAX_VALUE)
} }
@@ -47,7 +47,7 @@ class TerminalPanelKeyAdapter(
val encode = terminal.getKeyEncoder().encode(AWTTerminalKeyEvent(e)) val encode = terminal.getKeyEncoder().encode(AWTTerminalKeyEvent(e))
if (encode.isNotEmpty()) { if (encode.isNotEmpty()) {
ptyConnector.write(encode) ptyConnector.write(encode.toByteArray(ptyConnector.getCharset()))
} }
// https://github.com/TermoraDev/termora/issues/52 // https://github.com/TermoraDev/termora/issues/52
@@ -64,7 +64,7 @@ class TerminalPanelKeyAdapter(
terminal.getSelectionModel().clearSelection() terminal.getSelectionModel().clearSelection()
// 如果不为空表示已经发送过了,所以这里为空的时候再发送 // 如果不为空表示已经发送过了,所以这里为空的时候再发送
if (encode.isEmpty()) { if (encode.isEmpty()) {
ptyConnector.write("${e.keyChar}") ptyConnector.write("${e.keyChar}".toByteArray(ptyConnector.getCharset()))
} }
terminal.getScrollingModel().scrollTo(Int.MAX_VALUE) terminal.getScrollingModel().scrollTo(Int.MAX_VALUE)
} }

View File

@@ -70,9 +70,9 @@ class TerminalPanelMouseTrackingAdapter(
val encode = terminal.getKeyEncoder() val encode = terminal.getKeyEncoder()
.encode(TerminalKeyEvent(if (e.wheelRotation < 0) KeyEvent.VK_UP else KeyEvent.VK_DOWN)) .encode(TerminalKeyEvent(if (e.wheelRotation < 0) KeyEvent.VK_UP else KeyEvent.VK_DOWN))
if (encode.isBlank()) return if (encode.isBlank()) return
val bytes = encode.toByteArray(ptyConnector.getCharset())
for (i in 0 until abs(unitsToScroll)) { for (i in 0 until abs(unitsToScroll)) {
ptyConnector.write(encode) ptyConnector.write(bytes)
} }
} }
} }

View File

@@ -145,6 +145,14 @@ termora.new-host.terminal.heartbeat-interval=Heartbeat Interval
termora.new-host.terminal.startup-commands=Startup Command termora.new-host.terminal.startup-commands=Startup Command
termora.new-host.terminal.env=Environment termora.new-host.terminal.env=Environment
termora.new-host.serial=Serial
termora.new-host.serial.port=Port
termora.new-host.serial.baud-rate=Baud rate
termora.new-host.serial.data-bits=Data bits
termora.new-host.serial.parity=Parity
termora.new-host.serial.stop-bits=Stop bits
termora.new-host.serial.flow-control=Flow control
termora.new-host.tunneling=Tunneling termora.new-host.tunneling=Tunneling
termora.new-host.tunneling.table.name=Name termora.new-host.tunneling.table.name=Name
termora.new-host.tunneling.table.type=Type termora.new-host.tunneling.table.type=Type

View File

@@ -132,6 +132,14 @@ termora.new-host.terminal.startup-commands=启动命令
termora.new-host.terminal.env=环境 termora.new-host.terminal.env=环境
termora.new-host.serial=串口
termora.new-host.serial.port=端口
termora.new-host.serial.baud-rate=波特率
termora.new-host.serial.data-bits=数据位
termora.new-host.serial.parity=校验位
termora.new-host.serial.stop-bits=停止位
termora.new-host.serial.flow-control=流控
termora.new-host.test-connection=测试连接 termora.new-host.test-connection=测试连接
termora.new-host.test-connection-successful=连接成功 termora.new-host.test-connection-successful=连接成功

View File

@@ -130,6 +130,14 @@ termora.new-host.terminal.startup-commands=啟動命令
termora.new-host.terminal.heartbeat-interval=心跳間隔 termora.new-host.terminal.heartbeat-interval=心跳間隔
termora.new-host.terminal.env=環境 termora.new-host.terminal.env=環境
termora.new-host.serial=串口
termora.new-host.serial.port=端口
termora.new-host.serial.baud-rate=波特率
termora.new-host.serial.data-bits=資料位
termora.new-host.serial.parity=校驗位
termora.new-host.serial.stop-bits=停止位
termora.new-host.serial.flow-control=流控
termora.new-host.test-connection=測試連接 termora.new-host.test-connection=測試連接
termora.new-host.test-connection-successful=連線成功 termora.new-host.test-connection-successful=連線成功

View File

@@ -0,0 +1,4 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11 4H7C5.34315 4 4 5.34315 4 7V9C4 10.6569 5.34315 12 7 12H11V11V10V6V5V4ZM12 5V4C12 3.44772 11.5523 3 11 3H7C5.13616 3 3.57006 4.27477 3.12602 6H1C0.447715 6 0 6.44772 0 7V9C0 9.55228 0.447715 10 1 10H3.12602C3.57006 11.7252 5.13616 13 7 13H11C11.5523 13 12 12.5523 12 12V11H15.5C15.7761 11 16 10.7761 16 10.5C16 10.2239 15.7761 10 15.5 10H12V6H15.5C15.7761 6 16 5.77614 16 5.5C16 5.22386 15.7761 5 15.5 5H12ZM3 7V9H1V7L3 7Z" fill="#6C707E"/>
</svg>

After

Width:  |  Height:  |  Size: 724 B

View File

@@ -0,0 +1,4 @@
<!-- Copyright 2000-2022 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. -->
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11 4H7C5.34315 4 4 5.34315 4 7V9C4 10.6569 5.34315 12 7 12H11V11V10V6V5V4ZM12 5V4C12 3.44772 11.5523 3 11 3H7C5.13616 3 3.57006 4.27477 3.12602 6H1C0.447715 6 0 6.44772 0 7V9C0 9.55228 0.447715 10 1 10H3.12602C3.57006 11.7252 5.13616 13 7 13H11C11.5523 13 12 12.5523 12 12V11H15.5C15.7761 11 16 10.7761 16 10.5C16 10.2239 15.7761 10 15.5 10H12V6H15.5C15.7761 6 16 5.77614 16 5.5C16 5.22386 15.7761 5 15.5 5H12ZM3 7V9H1V7L3 7Z" fill="#CED0D6"/>
</svg>

After

Width:  |  Height:  |  Size: 724 B