fix: 终端 PTY 尺寸同步

前端在 xterm fit/resize 后通过 WebSocket 发送 resize 控制消息,后端收到后调用 ChannelShell.setPtySize 触发远端重绘,修复 less/vim/top 等全屏程序只显示部分区域的问题。

同时补齐控制消息解析与 PTY resize 的单测,并修正失配的 SftpControllerTest 断言。
This commit is contained in:
liumangmang
2026-03-24 12:03:03 +08:00
parent aced2871b2
commit acac45b692
8 changed files with 317 additions and 44 deletions

View File

@@ -0,0 +1,53 @@
package com.sshmanager.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.Optional;
public class TerminalControlMessage {
public static final String CONTROL_PREFIX = "__SSHMANAGER__:";
private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
private String type;
private Integer cols;
private Integer rows;
public String getType() {
return type;
}
public void setType(String type) {
this.type = type;
}
public Integer getCols() {
return cols;
}
public void setCols(Integer cols) {
this.cols = cols;
}
public Integer getRows() {
return rows;
}
public void setRows(Integer rows) {
this.rows = rows;
}
public static Optional<TerminalControlMessage> parse(String payload) {
if (payload == null || !payload.startsWith(CONTROL_PREFIX)) {
return Optional.empty();
}
String json = payload.substring(CONTROL_PREFIX.length());
try {
return Optional.of(OBJECT_MAPPER.readValue(json, TerminalControlMessage.class));
} catch (Exception e) {
return Optional.empty();
}
}
}

View File

@@ -100,15 +100,26 @@ public class TerminalWebSocketHandler extends TextWebSocketHandler {
}
}
@Override
protected void handleTextMessage(WebSocketSession webSocketSession, TextMessage message) throws Exception {
SshService.SshSession sshSession = sessions.get(webSocketSession.getId());
if (sshSession != null && sshSession.isConnected()) {
lastActivity.put(webSocketSession.getId(), System.currentTimeMillis());
sshSession.getInputStream().write(message.asBytes());
sshSession.getInputStream().flush();
}
}
@Override
protected void handleTextMessage(WebSocketSession webSocketSession, TextMessage message) throws Exception {
SshService.SshSession sshSession = sessions.get(webSocketSession.getId());
if (sshSession != null && sshSession.isConnected()) {
lastActivity.put(webSocketSession.getId(), System.currentTimeMillis());
String payload = message.getPayload();
TerminalControlMessage.parse(payload).ifPresent(ctrl -> {
if ("resize".equals(ctrl.getType()) && ctrl.getCols() != null && ctrl.getRows() != null) {
sshSession.resize(ctrl.getCols(), ctrl.getRows());
}
});
if (payload != null && payload.startsWith(TerminalControlMessage.CONTROL_PREFIX)) {
return;
}
sshSession.getInputStream().write(message.asBytes());
sshSession.getInputStream().flush();
}
}
@Override
public void afterConnectionClosed(WebSocketSession webSocketSession, CloseStatus status) throws Exception {

View File

@@ -85,14 +85,27 @@ public class SshService {
return outputStream;
}
public OutputStream getInputStream() {
return inputStream;
}
public OutputStream getInputStream() {
return inputStream;
}
public void resize(int cols, int rows) {
if (cols <= 0 || rows <= 0) {
return;
}
if (channel == null) {
return;
}
try {
channel.setPtySize(cols, rows, 0, 0);
} catch (Exception ignored) {
}
}
public void disconnect() {
if (channel != null && channel.isConnected()) {
channel.disconnect();
}
public void disconnect() {
if (channel != null && channel.isConnected()) {
channel.disconnect();
}
if (session != null && session.isConnected()) {
session.disconnect();
}

View File

@@ -24,6 +24,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyLong;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.doThrow;
@@ -86,8 +87,8 @@ class SftpControllerTest {
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals("Transferred", response.getBody().get("message"));
verify(sftpService).rename(session, "/src/file.txt", "/dst/file.txt");
verify(sftpService, never()).transferRemote(any(), any(), any(), any());
verify(sftpService).transferRemote(eq(session), eq("/src/file.txt"), eq(session), eq("/dst/file.txt"), any(SftpService.TransferProgressListener.class));
verify(sftpService, never()).rename(any(), any(), any());
}
@Test
@@ -109,7 +110,7 @@ class SftpControllerTest {
assertEquals(HttpStatus.OK, response.getStatusCode());
assertEquals("Transferred", response.getBody().get("message"));
verify(sftpService).transferRemote(sourceSession, "/src/file.txt", targetSession, "/dst/file.txt");
verify(sftpService).transferRemote(eq(sourceSession), eq("/src/file.txt"), eq(targetSession), eq("/dst/file.txt"), any(SftpService.TransferProgressListener.class));
verify(sftpService, never()).rename(any(), any(), any());
}
@@ -118,7 +119,7 @@ class SftpControllerTest {
when(connectionService.getConnectionForSsh(anyLong(), eq(1L))).thenReturn(new Connection());
SftpService.SftpSession session = connectedSession(true);
when(sftpService.connect(any(Connection.class), any(), any(), any())).thenReturn(session);
doThrow(new RuntimeException("boom")).when(sftpService).rename(any(), any(), any());
doThrow(new RuntimeException("boom")).when(sftpService).transferRemote(any(), anyString(), any(), anyString(), any(SftpService.TransferProgressListener.class));
ResponseEntity<Map<String, String>> response = sftpController.transferRemote(
3L,

View File

@@ -0,0 +1,38 @@
package com.sshmanager.controller;
import org.junit.jupiter.api.Test;
import java.util.Optional;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertTrue;
class TerminalControlMessageTest {
@Test
void parseReturnsResizeMessageWhenPayloadHasControlPrefix() {
String payload = TerminalControlMessage.CONTROL_PREFIX
+ "{\"type\":\"resize\",\"cols\":120,\"rows\":40}";
Optional<TerminalControlMessage> parsed = TerminalControlMessage.parse(payload);
assertTrue(parsed.isPresent());
assertEquals("resize", parsed.get().getType());
assertEquals(120, parsed.get().getCols());
assertEquals(40, parsed.get().getRows());
}
@Test
void parseReturnsEmptyForNonControlPayload() {
Optional<TerminalControlMessage> parsed = TerminalControlMessage.parse("ls -la\n");
assertFalse(parsed.isPresent());
}
@Test
void parseReturnsEmptyForInvalidJson() {
String payload = TerminalControlMessage.CONTROL_PREFIX + "not-json";
Optional<TerminalControlMessage> parsed = TerminalControlMessage.parse(payload);
assertFalse(parsed.isPresent());
}
}

View File

@@ -0,0 +1,63 @@
package com.sshmanager.controller;
import com.sshmanager.repository.ConnectionRepository;
import com.sshmanager.repository.UserRepository;
import com.sshmanager.service.ConnectionService;
import com.sshmanager.service.SshService;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import java.lang.reflect.Field;
import java.util.Map;
import java.util.concurrent.ExecutorService;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class TerminalWebSocketHandlerTest {
@Test
void handleTextMessageAppliesResizeControlMessageWithoutForwardingToShell() throws Exception {
ConnectionRepository connectionRepository = mock(ConnectionRepository.class);
UserRepository userRepository = mock(UserRepository.class);
ConnectionService connectionService = mock(ConnectionService.class);
SshService sshService = mock(SshService.class);
ExecutorService executor = mock(ExecutorService.class);
TerminalWebSocketHandler handler = new TerminalWebSocketHandler(
connectionRepository,
userRepository,
connectionService,
sshService,
executor
);
WebSocketSession ws = mock(WebSocketSession.class);
when(ws.getId()).thenReturn("s1");
SshService.SshSession sshSession = mock(SshService.SshSession.class);
when(sshSession.isConnected()).thenReturn(true);
Map<String, SshService.SshSession> sessions = sessionsMap(handler);
sessions.put("s1", sshSession);
String payload = TerminalControlMessage.CONTROL_PREFIX
+ "{\"type\":\"resize\",\"cols\":120,\"rows\":40}";
handler.handleTextMessage(ws, new TextMessage(payload));
verify(sshSession).resize(120, 40);
verify(sshSession, never()).getInputStream();
}
@SuppressWarnings("unchecked")
private Map<String, SshService.SshSession> sessionsMap(TerminalWebSocketHandler handler) throws Exception {
Field f = TerminalWebSocketHandler.class.getDeclaredField("sessions");
f.setAccessible(true);
return (Map<String, SshService.SshSession>) f.get(handler);
}
}

View File

@@ -0,0 +1,27 @@
package com.sshmanager.service;
import com.jcraft.jsch.ChannelShell;
import com.jcraft.jsch.Session;
import org.junit.jupiter.api.Test;
import java.io.InputStream;
import java.io.OutputStream;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
class SshSessionResizeTest {
@Test
void resizeSetsPtySizeOnChannel() {
Session session = mock(Session.class);
ChannelShell channel = mock(ChannelShell.class);
InputStream out = mock(InputStream.class);
OutputStream in = mock(OutputStream.class);
SshService.SshSession sshSession = new SshService.SshSession(session, channel, out, in);
sshSession.resize(120, 40);
verify(channel).setPtySize(120, 40, 0, 0);
}
}

View File

@@ -1,11 +1,12 @@
<script setup lang="ts">
import { nextTick, ref, onMounted, onUnmounted, watch } from 'vue'
import { Terminal } from 'xterm'
import { AttachAddon } from '@xterm/addon-attach'
import { FitAddon } from '@xterm/addon-fit'
import { useAuthStore } from '../stores/auth'
import 'xterm/css/xterm.css'
const CONTROL_PREFIX = '__SSHMANAGER__:'
const props = defineProps<{
connectionId: number
active?: boolean
@@ -15,10 +16,15 @@ const containerRef = ref<HTMLElement | null>(null)
const status = ref<'connecting' | 'connected' | 'error'>('connecting')
const errorMessage = ref('')
let term: Terminal | null = null
let fitAddon: FitAddon | null = null
let term: Terminal | null = null
let fitAddon: FitAddon | null = null
let ws: WebSocket | null = null
let resizeObserver: ResizeObserver | null = null
let onDataDisposable: { dispose: () => void } | null = null
let onResizeDisposable: { dispose: () => void } | null = null
let resizeDebounceTimer = 0
let lastSentCols = 0
let lastSentRows = 0
function fitTerminal() {
fitAddon?.fit()
@@ -46,21 +52,67 @@ function getWsUrl(): string {
return `${protocol}//${host}/ws/terminal?connectionId=${props.connectionId}&token=${encodeURIComponent(token)}`
}
function cleanup() {
if (resizeObserver && containerRef.value) {
resizeObserver.unobserve(containerRef.value)
}
resizeObserver = null
if (ws) {
ws.close()
ws = null
}
if (term) {
term.dispose()
term = null
}
fitAddon = null
}
function cleanup() {
if (resizeObserver && containerRef.value) {
resizeObserver.unobserve(containerRef.value)
}
resizeObserver = null
if (onDataDisposable) {
onDataDisposable.dispose()
onDataDisposable = null
}
if (onResizeDisposable) {
onResizeDisposable.dispose()
onResizeDisposable = null
}
clearTimeout(resizeDebounceTimer)
resizeDebounceTimer = 0
if (ws) {
ws.close()
ws = null
}
if (term) {
term.dispose()
term = null
}
fitAddon = null
}
function sendResize(cols: number, rows: number) {
if (!ws || ws.readyState !== WebSocket.OPEN) return
if (!cols || !rows) return
if (cols === lastSentCols && rows === lastSentRows) return
lastSentCols = cols
lastSentRows = rows
ws.send(CONTROL_PREFIX + JSON.stringify({ type: 'resize', cols, rows }))
}
function scheduleResize(cols: number, rows: number) {
clearTimeout(resizeDebounceTimer)
resizeDebounceTimer = window.setTimeout(() => {
sendResize(cols, rows)
}, 80)
}
async function handleWsMessage(data: unknown) {
if (!term) return
if (typeof data === 'string') {
term.write(data)
return
}
if (data instanceof ArrayBuffer) {
const text = new TextDecoder('utf-8').decode(new Uint8Array(data))
term.write(text)
return
}
if (data instanceof Blob) {
const text = await data.text()
term.write(text)
}
}
onMounted(() => {
const authStore = useAuthStore()
@@ -72,7 +124,7 @@ onMounted(() => {
if (!containerRef.value) return
term = new Terminal({
term = new Terminal({
cursorBlink: true,
theme: {
background: '#0f172a',
@@ -85,10 +137,14 @@ onMounted(() => {
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
})
fitAddon = new FitAddon()
term.loadAddon(fitAddon)
fitAddon = new FitAddon()
term.loadAddon(fitAddon)
term.open(containerRef.value)
fitTerminal()
onResizeDisposable = term.onResize(({ cols, rows }) => {
scheduleResize(cols, rows)
})
try {
ws = new WebSocket(getWsUrl())
@@ -98,11 +154,19 @@ onMounted(() => {
return
}
ws.onopen = () => {
ws.onopen = () => {
status.value = 'connected'
const attachAddon = new AttachAddon(ws!)
term!.loadAddon(attachAddon)
refreshTerminalLayout()
scheduleResize(term!.cols, term!.rows)
}
onDataDisposable = term.onData((data) => {
if (!ws || ws.readyState !== WebSocket.OPEN) return
ws.send(data)
})
ws.onmessage = (ev) => {
void handleWsMessage(ev.data)
}
ws.onerror = () => {
@@ -120,6 +184,9 @@ onMounted(() => {
resizeObserver = new ResizeObserver(() => {
fitTerminal()
if (term) {
scheduleResize(term.cols, term.rows)
}
})
resizeObserver.observe(containerRef.value)