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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user