fix: 终端 PTY 尺寸同步
前端在 xterm fit/resize 后通过 WebSocket 发送 resize 控制消息,后端收到后调用 ChannelShell.setPtySize 触发远端重绘,修复 less/vim/top 等全屏程序只显示部分区域的问题。 同时补齐控制消息解析与 PTY resize 的单测,并修正失配的 SftpControllerTest 断言。
This commit is contained in:
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -105,6 +105,17 @@ public class TerminalWebSocketHandler extends TextWebSocketHandler {
|
||||
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();
|
||||
}
|
||||
|
||||
@@ -89,6 +89,19 @@ public class SshService {
|
||||
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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -19,6 +20,11 @@ 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()
|
||||
@@ -51,6 +57,18 @@ function cleanup() {
|
||||
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
|
||||
@@ -62,6 +80,40 @@ function cleanup() {
|
||||
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()
|
||||
if (!authStore.token) {
|
||||
@@ -90,6 +142,10 @@ onMounted(() => {
|
||||
term.open(containerRef.value)
|
||||
fitTerminal()
|
||||
|
||||
onResizeDisposable = term.onResize(({ cols, rows }) => {
|
||||
scheduleResize(cols, rows)
|
||||
})
|
||||
|
||||
try {
|
||||
ws = new WebSocket(getWsUrl())
|
||||
} catch (e) {
|
||||
@@ -100,9 +156,17 @@ onMounted(() => {
|
||||
|
||||
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)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user