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());
|
SshService.SshSession sshSession = sessions.get(webSocketSession.getId());
|
||||||
if (sshSession != null && sshSession.isConnected()) {
|
if (sshSession != null && sshSession.isConnected()) {
|
||||||
lastActivity.put(webSocketSession.getId(), System.currentTimeMillis());
|
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().write(message.asBytes());
|
||||||
sshSession.getInputStream().flush();
|
sshSession.getInputStream().flush();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -89,6 +89,19 @@ public class SshService {
|
|||||||
return inputStream;
|
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() {
|
public void disconnect() {
|
||||||
if (channel != null && channel.isConnected()) {
|
if (channel != null && channel.isConnected()) {
|
||||||
channel.disconnect();
|
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.junit.jupiter.api.Assertions.assertTrue;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
import static org.mockito.ArgumentMatchers.anyLong;
|
import static org.mockito.ArgumentMatchers.anyLong;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
import static org.mockito.ArgumentMatchers.eq;
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
import static org.mockito.Mockito.mock;
|
import static org.mockito.Mockito.mock;
|
||||||
import static org.mockito.Mockito.doThrow;
|
import static org.mockito.Mockito.doThrow;
|
||||||
@@ -86,8 +87,8 @@ class SftpControllerTest {
|
|||||||
|
|
||||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||||
assertEquals("Transferred", response.getBody().get("message"));
|
assertEquals("Transferred", response.getBody().get("message"));
|
||||||
verify(sftpService).rename(session, "/src/file.txt", "/dst/file.txt");
|
verify(sftpService).transferRemote(eq(session), eq("/src/file.txt"), eq(session), eq("/dst/file.txt"), any(SftpService.TransferProgressListener.class));
|
||||||
verify(sftpService, never()).transferRemote(any(), any(), any(), any());
|
verify(sftpService, never()).rename(any(), any(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@@ -109,7 +110,7 @@ class SftpControllerTest {
|
|||||||
|
|
||||||
assertEquals(HttpStatus.OK, response.getStatusCode());
|
assertEquals(HttpStatus.OK, response.getStatusCode());
|
||||||
assertEquals("Transferred", response.getBody().get("message"));
|
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());
|
verify(sftpService, never()).rename(any(), any(), any());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,7 +119,7 @@ class SftpControllerTest {
|
|||||||
when(connectionService.getConnectionForSsh(anyLong(), eq(1L))).thenReturn(new Connection());
|
when(connectionService.getConnectionForSsh(anyLong(), eq(1L))).thenReturn(new Connection());
|
||||||
SftpService.SftpSession session = connectedSession(true);
|
SftpService.SftpSession session = connectedSession(true);
|
||||||
when(sftpService.connect(any(Connection.class), any(), any(), any())).thenReturn(session);
|
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(
|
ResponseEntity<Map<String, String>> response = sftpController.transferRemote(
|
||||||
3L,
|
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">
|
<script setup lang="ts">
|
||||||
import { nextTick, ref, onMounted, onUnmounted, watch } from 'vue'
|
import { nextTick, ref, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { Terminal } from 'xterm'
|
import { Terminal } from 'xterm'
|
||||||
import { AttachAddon } from '@xterm/addon-attach'
|
|
||||||
import { FitAddon } from '@xterm/addon-fit'
|
import { FitAddon } from '@xterm/addon-fit'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import 'xterm/css/xterm.css'
|
import 'xterm/css/xterm.css'
|
||||||
|
|
||||||
|
const CONTROL_PREFIX = '__SSHMANAGER__:'
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
connectionId: number
|
connectionId: number
|
||||||
active?: boolean
|
active?: boolean
|
||||||
@@ -19,6 +20,11 @@ let term: Terminal | null = null
|
|||||||
let fitAddon: FitAddon | null = null
|
let fitAddon: FitAddon | null = null
|
||||||
let ws: WebSocket | null = null
|
let ws: WebSocket | null = null
|
||||||
let resizeObserver: ResizeObserver | 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() {
|
function fitTerminal() {
|
||||||
fitAddon?.fit()
|
fitAddon?.fit()
|
||||||
@@ -51,6 +57,18 @@ function cleanup() {
|
|||||||
resizeObserver.unobserve(containerRef.value)
|
resizeObserver.unobserve(containerRef.value)
|
||||||
}
|
}
|
||||||
resizeObserver = null
|
resizeObserver = null
|
||||||
|
|
||||||
|
if (onDataDisposable) {
|
||||||
|
onDataDisposable.dispose()
|
||||||
|
onDataDisposable = null
|
||||||
|
}
|
||||||
|
if (onResizeDisposable) {
|
||||||
|
onResizeDisposable.dispose()
|
||||||
|
onResizeDisposable = null
|
||||||
|
}
|
||||||
|
clearTimeout(resizeDebounceTimer)
|
||||||
|
resizeDebounceTimer = 0
|
||||||
|
|
||||||
if (ws) {
|
if (ws) {
|
||||||
ws.close()
|
ws.close()
|
||||||
ws = null
|
ws = null
|
||||||
@@ -62,6 +80,40 @@ function cleanup() {
|
|||||||
fitAddon = 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(() => {
|
onMounted(() => {
|
||||||
const authStore = useAuthStore()
|
const authStore = useAuthStore()
|
||||||
if (!authStore.token) {
|
if (!authStore.token) {
|
||||||
@@ -90,6 +142,10 @@ onMounted(() => {
|
|||||||
term.open(containerRef.value)
|
term.open(containerRef.value)
|
||||||
fitTerminal()
|
fitTerminal()
|
||||||
|
|
||||||
|
onResizeDisposable = term.onResize(({ cols, rows }) => {
|
||||||
|
scheduleResize(cols, rows)
|
||||||
|
})
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ws = new WebSocket(getWsUrl())
|
ws = new WebSocket(getWsUrl())
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -100,9 +156,17 @@ onMounted(() => {
|
|||||||
|
|
||||||
ws.onopen = () => {
|
ws.onopen = () => {
|
||||||
status.value = 'connected'
|
status.value = 'connected'
|
||||||
const attachAddon = new AttachAddon(ws!)
|
|
||||||
term!.loadAddon(attachAddon)
|
|
||||||
refreshTerminalLayout()
|
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 = () => {
|
ws.onerror = () => {
|
||||||
@@ -120,6 +184,9 @@ onMounted(() => {
|
|||||||
|
|
||||||
resizeObserver = new ResizeObserver(() => {
|
resizeObserver = new ResizeObserver(() => {
|
||||||
fitTerminal()
|
fitTerminal()
|
||||||
|
if (term) {
|
||||||
|
scheduleResize(term.cols, term.rows)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
resizeObserver.observe(containerRef.value)
|
resizeObserver.observe(containerRef.value)
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user