feat(ssh): add SSH terminal functionality (JSch)

- SshTerminalManager: SSH connection lifecycle, shell I/O, ANSI-aware line parsing
- SshTerminalScreen: Compose UI with terminal output, input bar, connect dialog
- Added JSch 0.1.55 dependency to gradle (libs.versions.toml + build.gradle.kts)
This commit is contained in:
茂之钳
2026-05-23 07:19:05 +00:00
parent 8cdf48ab95
commit a72b01e142
4 changed files with 542 additions and 0 deletions
+1
View File
@@ -215,6 +215,7 @@ dependencies {
// Unicast DNS-SD (Wide-Area Bonjour) for tailnet discovery domains.
implementation(libs.dnsjava)
implementation(libs.jsch)
testImplementation(libs.junit)
testImplementation(libs.kotlinx.coroutines.test)
@@ -0,0 +1,163 @@
package ai.openclaw.app.ui.tergent
import com.jcraft.jsch.ChannelShell
import com.jcraft.jsch.JSch
import com.jcraft.jsch.Session
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withContext
import java.io.InputStream
import java.io.OutputStream
import java.util.concurrent.atomic.AtomicBoolean
data class SshConfig(
val host: String = "",
val port: Int = 22,
val username: String = "",
val password: String = "",
val displayName: String = ""
)
data class SshConnectionState(
val connected: Boolean = false,
val host: String = "",
val displayName: String = "",
val lines: List<String> = emptyList(),
val error: String? = null
)
class SshTerminalManager {
private val jsch = JSch()
private var session: Session? = null
private var channel: ChannelShell? = null
private var inputStream: InputStream? = null
private var outputStream: OutputStream? = null
private val _connectionState = MutableStateFlow(SshConnectionState())
val connectionState: StateFlow<SshConnectionState> = _connectionState.asStateFlow()
private val running = AtomicBoolean(false)
private var lineBuffer = mutableListOf<String>()
private var currentLine = StringBuilder()
suspend fun connect(config: SshConfig): Boolean = withContext(Dispatchers.IO) {
try {
_connectionState.value = _connectionState.value.copy(error = null)
session = jsch.getSession(config.username, config.host, config.port).apply {
setPassword(config.password)
setConfig("StrictHostKeyChecking", "no")
connect(10000) // 10s timeout
}
channel = session?.openChannel("shell") as? ChannelShell
channel?.setPtyType("xterm-256color")
channel?.setPtySize(120, 40, 0, 0)
inputStream = channel?.inputStream
outputStream = channel?.outputStream
channel?.connect(5000)
lineBuffer.clear()
currentLine.clear()
running.set(true)
_connectionState.value = SshConnectionState(
connected = true,
host = config.host,
displayName = config.displayName.ifEmpty { config.host }
)
// Start reading output
withContext(Dispatchers.IO) {
readOutput()
}
true
} catch (e: Exception) {
_connectionState.value = SshConnectionState(
connected = false,
error = "连接失败: ${e.message ?: "未知错误"}"
)
disconnect()
false
}
}
private fun readOutput() {
val buffer = ByteArray(4096)
while (running.get()) {
try {
val len = inputStream?.read(buffer) ?: break
if (len < 0) break
val text = String(buffer, 0, len)
// Parse ANSI and add lines
for (ch in text) {
when (ch) {
'\n' -> {
if (currentLine.isNotEmpty()) {
lineBuffer.add(currentLine.toString())
currentLine.clear()
}
}
'\r' -> {
// carriage return: some terminals use \r\n
// if next char is \n it will be handled above
}
else -> currentLine.append(ch)
}
}
// Keep line buffer manageable
if (lineBuffer.size > 2000) {
lineBuffer = lineBuffer.takeLast(1500).toMutableList()
}
_connectionState.value = _connectionState.value.copy(
lines = if (currentLine.isNotEmpty())
lineBuffer + currentLine.toString()
else
lineBuffer.toList()
)
} catch (e: Exception) {
if (running.get()) {
_connectionState.value = _connectionState.value.copy(
error = "读取错误: ${e.message}"
)
}
break
}
}
}
suspend fun sendCommand(command: String): Boolean = withContext(Dispatchers.IO) {
try {
outputStream?.write((command + "\n").toByteArray())
outputStream?.flush()
true
} catch (e: Exception) {
_connectionState.value = _connectionState.value.copy(
error = "发送失败: ${e.message}"
)
false
}
}
fun disconnect() {
running.set(false)
try { channel?.disconnect() } catch (_: Exception) {}
try { session?.disconnect() } catch (_: Exception) {}
channel = null
session = null
inputStream = null
outputStream = null
_connectionState.value = SshConnectionState()
}
fun isConnected(): Boolean = _connectionState.value.connected
}
@@ -0,0 +1,376 @@
package ai.openclaw.app.ui.tergent
import androidx.compose.foundation.background
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.PowerSettingsNew
import androidx.compose.material3.*
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import kotlinx.coroutines.launch
// Tergent colors inline for now
private val bg = Color(0xFF111827)
private val surface = Color(0xFF1F2937)
private val border = Color(0xFF374151)
private val textPrimary = Color(0xFFF9FAFB)
private val textSecondary = Color(0xFF9CA3AF)
private val textTertiary = Color(0xFF6B7280)
private val primary = Color(0xFF5B8CFF)
private val success = Color(0xFF34D399)
private val danger = Color(0xFFF87171)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun SshConnectDialog(
onDismiss: () -> Unit,
onConnect: (SshConfig) -> Unit,
savedConfigs: List<SshConfig> = emptyList()
) {
var host by remember { mutableStateOf("") }
var port by remember { mutableStateOf("22") }
var username by remember { mutableStateOf("") }
var password by remember { mutableStateOf("") }
var displayName by remember { mutableStateOf("") }
AlertDialog(
onDismissRequest = onDismiss,
containerColor = surface,
titleContentColor = textPrimary,
textContentColor = textSecondary,
shape = RoundedCornerShape(16.dp),
title = {
Text("SSH 连接", color = textPrimary, style = MaterialTheme.typography.titleMedium)
},
text = {
Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
OutlinedTextField(
value = host,
onValueChange = { host = it },
label = { Text("主机地址") },
placeholder = { Text("192.168.1.100", color = textTertiary) },
singleLine = true,
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = primary,
unfocusedBorderColor = border,
focusedTextColor = textPrimary,
unfocusedTextColor = textPrimary,
cursorColor = primary,
focusedLabelColor = primary,
unfocusedLabelColor = textTertiary
)
)
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
OutlinedTextField(
value = port,
onValueChange = { port = it.filter { c -> c.isDigit() }.take(5) },
label = { Text("端口") },
placeholder = { Text("22", color = textTertiary) },
singleLine = true,
modifier = Modifier.width(100.dp),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = primary,
unfocusedBorderColor = border,
focusedTextColor = textPrimary,
unfocusedTextColor = textPrimary,
cursorColor = primary,
focusedLabelColor = primary,
unfocusedLabelColor = textTertiary
)
)
Spacer(Modifier.width(8.dp))
OutlinedTextField(
value = username,
onValueChange = { username = it },
label = { Text("用户名") },
placeholder = { Text("root", color = textTertiary) },
singleLine = true,
modifier = Modifier.weight(1f),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = primary,
unfocusedBorderColor = border,
focusedTextColor = textPrimary,
unfocusedTextColor = textPrimary,
cursorColor = primary,
focusedLabelColor = primary,
unfocusedLabelColor = textTertiary
)
)
}
OutlinedTextField(
value = password,
onValueChange = { password = it },
label = { Text("密码") },
placeholder = { Text("••••••••", color = textTertiary) },
singleLine = true,
visualTransformation = androidx.compose.ui.text.input.PasswordVisualTransformation(),
colors = OutlinedTextFieldDefaults.colors(
focusedBorderColor = primary,
unfocusedBorderColor = border,
focusedTextColor = textPrimary,
unfocusedTextColor = textPrimary,
cursorColor = primary,
focusedLabelColor = primary,
unfocusedLabelColor = textTertiary
)
)
}
},
confirmButton = {
Button(
onClick = {
onConnect(SshConfig(
host = host.trim(),
port = port.toIntOrNull() ?: 22,
username = username.trim(),
password = password,
displayName = displayName.trim().ifEmpty { host.trim() }
))
},
enabled = host.isNotBlank() && username.isNotBlank() && password.isNotBlank(),
colors = ButtonDefaults.buttonColors(containerColor = primary)
) {
Text("连接", color = Color.White)
}
},
dismissButton = {
TextButton(onClick = onDismiss) {
Text("取消", color = textSecondary)
}
}
)
}
@Composable
fun SshTerminalScreen(
manager: SshTerminalManager,
onDisconnect: () -> Unit,
onShowConnectDialog: () -> Unit,
modifier: Modifier = Modifier
) {
val state by manager.connectionState.collectAsState()
var input by remember { mutableStateOf(TextFieldValue("")) }
val coroutineScope = rememberCoroutineScope()
val listState = rememberLazyListState()
// Auto-scroll to bottom
LaunchedEffect(state.lines.size) {
if (state.lines.isNotEmpty()) {
listState.animateScrollToItem(state.lines.size - 1)
}
}
Box(
modifier = modifier
.fillMaxSize()
.background(bg)
) {
Column(modifier = Modifier.fillMaxSize()) {
// Terminal header
Surface(
modifier = Modifier.fillMaxWidth(),
color = surface,
shadowElevation = 0.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(
imageVector = if (state.connected) Icons.Default.CheckCircle else Icons.Default.Close,
contentDescription = null,
tint = if (state.connected) success else danger,
modifier = Modifier.size(16.dp)
)
Spacer(Modifier.width(6.dp))
Text(
text = if (state.connected) state.displayName else "SSH 终端",
color = textPrimary,
fontSize = 14.sp
)
}
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
if (state.connected) {
IconButton(
onClick = { coroutineScope.launch { manager.sendCommand("exit") }; onDisconnect() },
modifier = Modifier.size(32.dp)
) {
Icon(
Icons.Default.PowerSettingsNew,
contentDescription = "断开",
tint = danger,
modifier = Modifier.size(18.dp)
)
}
} else {
IconButton(
onClick = onShowConnectDialog,
modifier = Modifier.size(28.dp)
) {
Text("+", color = primary, fontSize = 18.sp)
}
}
}
}
}
// Error message
if (state.error != null) {
Surface(
modifier = Modifier
.fillMaxWidth()
.padding(8.dp),
color = Color(0xFF3B1A1A),
shape = RoundedCornerShape(8.dp)
) {
Text(
text = state.error ?: "",
color = danger,
fontSize = 12.sp,
modifier = Modifier.padding(12.dp)
)
}
}
// Terminal output area
if (state.connected && state.lines.isNotEmpty()) {
LazyColumn(
modifier = Modifier
.weight(1f)
.fillMaxWidth()
.padding(horizontal = 8.dp),
state = listState,
verticalArrangement = Arrangement.spacedBy(0.dp)
) {
items(state.lines) { line ->
Text(
text = line,
color = textPrimary,
fontSize = 11.sp,
fontFamily = FontFamily.Monospace,
modifier = Modifier
.fillMaxWidth()
.horizontalScroll(rememberScrollState())
.padding(vertical = 1.dp)
)
}
}
} else if (!state.connected) {
// Empty placeholder
Box(
modifier = Modifier.weight(1f).fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(
text = "terminal@tergent:~$",
color = textTertiary,
fontFamily = FontFamily.Monospace,
fontSize = 16.sp
)
Spacer(Modifier.height(8.dp))
Text(
text = "点击 + 开始 SSH 连接",
color = textTertiary,
fontSize = 13.sp
)
}
}
} else {
// Connected but no output yet
Box(
modifier = Modifier.weight(1f).fillMaxWidth(),
contentAlignment = Alignment.Center
) {
Text(
text = "等待输出...",
color = textTertiary,
fontSize = 12.sp
)
}
}
// Bottom input bar
if (state.connected) {
Surface(
modifier = Modifier.fillMaxWidth(),
color = Color(0xFF0D1117),
shadowElevation = 0.dp
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 6.dp),
verticalAlignment = Alignment.CenterVertically
) {
Text(
text = "$ ",
color = success,
fontFamily = FontFamily.Monospace,
fontSize = 13.sp
)
Box(modifier = Modifier.weight(1f)) {
BasicTextField(
value = input,
onValueChange = { input = it },
textStyle = TextStyle(
color = textPrimary,
fontSize = 13.sp,
fontFamily = FontFamily.Monospace
),
cursorBrush = SolidColor(primary),
modifier = Modifier.fillMaxWidth(),
decorationBox = { innerTextField ->
if (input.text.isEmpty()) {
Text(
text = "输入命令...",
color = textTertiary,
fontSize = 13.sp,
fontFamily = FontFamily.Monospace
)
}
innerTextField()
}
)
}
IconButton(
onClick = {
if (input.text.isNotBlank()) {
coroutineScope.launch {
manager.sendCommand(input.text)
input = TextFieldValue("")
}
}
},
modifier = Modifier.size(36.dp)
) {
Text("", color = primary, fontSize = 16.sp)
}
}
}
}
}
}
}
+2
View File
@@ -25,6 +25,7 @@ okhttp = "5.3.2"
play-services-code-scanner = "16.1.0"
robolectric = "4.16.1"
serialization-json = "1.11.0"
jsch = "0.1.55"
[libraries]
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
@@ -64,6 +65,7 @@ material = { module = "com.google.android.material:material", version.ref = "mat
mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
play-services-code-scanner = { module = "com.google.android.gms:play-services-code-scanner", version.ref = "play-services-code-scanner" }
jsch = { module = "com.jcraft:jsch", version.ref = "jsch" }
robolectric = { module = "org.robolectric:robolectric", version.ref = "robolectric" }
[plugins]