mirror of
https://github.com/gravitational/teleport.git
synced 2026-09-19 10:37:19 +08:00
Add the new session recording tty player (#58585)
* Add the new session recording tty player * Remove unnecessary casting * Clean up the aspect fit addon * Code review * Store cols/rows in object instead * Use the correct platform, get font from theme * Use Logger instead * Prettier * Undo changes to BotInstanceDetails
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Teleport
|
||||
* Copyright (C) 2025 Gravitational, Inc.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { ITerminalAddon, Terminal } from '@xterm/xterm';
|
||||
import type { IRenderDimensions } from '@xterm/xterm/src/browser/renderer/shared/Types';
|
||||
|
||||
import type { TerminalSize } from 'teleport/SessionRecordings/view/player/tty/types';
|
||||
|
||||
/**
|
||||
* AspectFitAddon is a xterm.js addon that resizes the terminal to fit within its parent element
|
||||
* while maintaining the specified aspect ratio defined by cols and rows.
|
||||
* It uses the same approach as xterm's fit addon (accessing the _renderService and its dimensions).
|
||||
* It applies CSS transforms to scale and center the terminal within its parent element.
|
||||
*/
|
||||
export class AspectFitAddon implements ITerminalAddon {
|
||||
private terminal: Terminal | undefined;
|
||||
|
||||
public activate(terminal: Terminal): void {
|
||||
this.terminal = terminal;
|
||||
}
|
||||
|
||||
public dispose(): void {}
|
||||
|
||||
public fitWithAspectRatio({ cols, rows }: TerminalSize): void {
|
||||
if (!this.terminal?.element?.parentElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
// accessing the internals of xterm, this is how the fit addon does it
|
||||
const core = (this.terminal as any)._core;
|
||||
const dims: IRenderDimensions = core._renderService.dimensions;
|
||||
|
||||
if (dims.css.cell.width === 0 || dims.css.cell.height === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parentElementStyle = window.getComputedStyle(
|
||||
this.terminal.element.parentElement
|
||||
);
|
||||
const parentElementHeight = parseInt(
|
||||
parentElementStyle.getPropertyValue('height')
|
||||
);
|
||||
const parentElementWidth = Math.max(
|
||||
0,
|
||||
parseInt(parentElementStyle.getPropertyValue('width'))
|
||||
);
|
||||
|
||||
const availableHeight = parentElementHeight;
|
||||
const availableWidth = parentElementWidth;
|
||||
|
||||
if (this.terminal.rows !== rows || this.terminal.cols !== cols) {
|
||||
core._renderService.clear();
|
||||
this.terminal.resize(cols, rows);
|
||||
}
|
||||
|
||||
const requiredWidth = cols * dims.css.cell.width;
|
||||
const requiredHeight = rows * dims.css.cell.height;
|
||||
|
||||
const scaleX = availableWidth / requiredWidth;
|
||||
const scaleY = availableHeight / requiredHeight;
|
||||
const scale = Math.min(scaleX, scaleY);
|
||||
|
||||
const scaledWidth = requiredWidth * scale;
|
||||
const scaledHeight = requiredHeight * scale;
|
||||
|
||||
const horizontalOffset = (availableWidth - scaledWidth) / 2;
|
||||
const verticalOffset = (availableHeight - scaledHeight) / 2;
|
||||
|
||||
const terminalElement = this.terminal.element;
|
||||
|
||||
terminalElement.style.width = `${requiredWidth}px`;
|
||||
terminalElement.style.height = `${requiredHeight}px`;
|
||||
terminalElement.style.position = 'absolute';
|
||||
terminalElement.style.left = '0';
|
||||
terminalElement.style.top = '0';
|
||||
terminalElement.style.transform = `translate(${horizontalOffset}px, ${verticalOffset}px) scale(${scale})`;
|
||||
terminalElement.style.transformOrigin = 'top left';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,198 @@
|
||||
/**
|
||||
* Teleport
|
||||
* Copyright (C) 2025 Gravitational, Inc.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import { CanvasAddon } from '@xterm/addon-canvas';
|
||||
import { ImageAddon } from '@xterm/addon-image';
|
||||
import { WebLinksAddon } from '@xterm/addon-web-links';
|
||||
import { WebglAddon } from '@xterm/addon-webgl';
|
||||
import { ITerminalAddon, Terminal } from '@xterm/xterm';
|
||||
import type { DefaultTheme } from 'styled-components';
|
||||
|
||||
import { Logger } from 'design/logger';
|
||||
import { getPlatform, Platform } from 'design/platform';
|
||||
|
||||
import { Player } from '../Player';
|
||||
import { AspectFitAddon } from './AspectFitRatio';
|
||||
import { EventType, type TerminalSize, type TtyEvent } from './types';
|
||||
|
||||
/**
|
||||
* TtyPlayer is a player that connects a stream of TtyEvents to an xterm.js terminal.
|
||||
*
|
||||
* It handles rendering the terminal, applying events, and managing terminal addons.
|
||||
*
|
||||
* It supports resizing, clearing the terminal, and focusing the terminal on play/seek.
|
||||
* It also handles terminal themes and font settings.
|
||||
*/
|
||||
export class TtyPlayer extends Player<TtyEvent> {
|
||||
private addons: ITerminalAddon[] = [];
|
||||
private aspectFitAddon = new AspectFitAddon();
|
||||
private terminal: Terminal | null = null;
|
||||
private playing = false;
|
||||
private logger = new Logger('TtyPlayer');
|
||||
|
||||
constructor(
|
||||
private theme: DefaultTheme,
|
||||
private size: TerminalSize
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
override init(element: HTMLElement) {
|
||||
this.terminal = new Terminal({
|
||||
fontSize: getPlatform() === Platform.macOS ? 12 : 14,
|
||||
fontFamily: this.theme.fonts.mono,
|
||||
cols: this.size.cols,
|
||||
rows: this.size.rows,
|
||||
theme: this.theme.colors.terminal,
|
||||
});
|
||||
|
||||
const linksAddon = new WebLinksAddon();
|
||||
const imageAddon = new ImageAddon();
|
||||
|
||||
this.addons.push(this.aspectFitAddon, linksAddon, imageAddon);
|
||||
|
||||
this.aspectFitAddon.activate(this.terminal);
|
||||
|
||||
for (const addon of this.addons) {
|
||||
this.terminal.loadAddon(addon);
|
||||
}
|
||||
|
||||
const createCanvasAddon = () => {
|
||||
const canvasAddon = new CanvasAddon();
|
||||
|
||||
this.addons.push(canvasAddon);
|
||||
this.terminal.loadAddon(canvasAddon);
|
||||
};
|
||||
|
||||
try {
|
||||
const webglAddon = new WebglAddon();
|
||||
|
||||
webglAddon.onContextLoss(() => {
|
||||
createCanvasAddon();
|
||||
});
|
||||
|
||||
this.terminal.loadAddon(webglAddon);
|
||||
this.addons.push(webglAddon);
|
||||
} catch {
|
||||
createCanvasAddon();
|
||||
}
|
||||
|
||||
this.terminal.open(element);
|
||||
|
||||
this.aspectFitAddon.fitWithAspectRatio(this.size);
|
||||
}
|
||||
|
||||
override applyEvent(event: TtyEvent) {
|
||||
if (!this.terminal) {
|
||||
throw new Error('Terminal is not initialized');
|
||||
}
|
||||
|
||||
switch (event.type) {
|
||||
case EventType.Resize:
|
||||
this.size = event.terminalSize;
|
||||
|
||||
this.aspectFitAddon.fitWithAspectRatio(this.size);
|
||||
|
||||
break;
|
||||
|
||||
case EventType.SessionPrint:
|
||||
this.terminal.write(event.data);
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
override clear() {
|
||||
if (!this.terminal) {
|
||||
throw new Error('Terminal is not initialized');
|
||||
}
|
||||
|
||||
this.terminal.reset();
|
||||
this.fit();
|
||||
}
|
||||
|
||||
fit() {
|
||||
this.aspectFitAddon.fitWithAspectRatio(this.size);
|
||||
|
||||
if (this.playing) {
|
||||
this.terminal?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
override handleEvent(event: TtyEvent) {
|
||||
if (!this.terminal) {
|
||||
throw new Error('Terminal is not initialized');
|
||||
}
|
||||
|
||||
if (event.type === EventType.Screen) {
|
||||
this.size.cols = event.screen.cols;
|
||||
this.size.rows = event.screen.rows;
|
||||
|
||||
this.clear();
|
||||
|
||||
this.terminal.write(event.screen.data);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
override destroy() {
|
||||
for (const addon of this.addons) {
|
||||
try {
|
||||
addon.dispose();
|
||||
} catch {
|
||||
this.logger.warn('Failed to dispose terminal addon', addon);
|
||||
}
|
||||
}
|
||||
|
||||
this.addons = [];
|
||||
|
||||
if (this.terminal) {
|
||||
this.terminal.dispose();
|
||||
this.terminal = null;
|
||||
}
|
||||
}
|
||||
|
||||
onPlay() {
|
||||
if (!this.terminal) {
|
||||
throw new Error('Terminal is not initialized');
|
||||
}
|
||||
|
||||
this.terminal.focus();
|
||||
|
||||
this.playing = true;
|
||||
}
|
||||
|
||||
onSeek() {
|
||||
if (!this.terminal) {
|
||||
throw new Error('Terminal is not initialized');
|
||||
}
|
||||
|
||||
this.terminal.focus();
|
||||
}
|
||||
|
||||
onPause() {
|
||||
this.playing = false;
|
||||
}
|
||||
|
||||
onStop() {
|
||||
this.playing = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Teleport
|
||||
* Copyright (C) 2025 Gravitational, Inc.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import {
|
||||
EventType,
|
||||
type SerializedTerminal,
|
||||
type TerminalSize,
|
||||
type TtyEvent,
|
||||
} from './types';
|
||||
|
||||
const responseHeaderSize = 17;
|
||||
|
||||
export function decodeTtyEvent(buffer: ArrayBuffer): TtyEvent {
|
||||
if (buffer.byteLength < responseHeaderSize) {
|
||||
throw new Error('Event too short');
|
||||
}
|
||||
|
||||
const view = new DataView(buffer);
|
||||
const eventType = view.getUint8(0);
|
||||
const timestamp = Number(view.getBigInt64(1));
|
||||
const dataLength = view.getUint32(9);
|
||||
|
||||
if (buffer.byteLength < responseHeaderSize + dataLength) {
|
||||
throw new Error('Incomplete event data');
|
||||
}
|
||||
|
||||
const data = new Uint8Array(buffer, responseHeaderSize, dataLength);
|
||||
|
||||
const requestId = view.getUint32(13);
|
||||
|
||||
switch (eventType) {
|
||||
case EventType.Resize:
|
||||
return {
|
||||
requestId,
|
||||
terminalSize: decodeTerminalSize(data),
|
||||
timestamp,
|
||||
type: eventType,
|
||||
};
|
||||
|
||||
case EventType.Screen:
|
||||
return {
|
||||
requestId,
|
||||
screen: decodeSerializedTerminal(data),
|
||||
timestamp,
|
||||
type: eventType,
|
||||
};
|
||||
|
||||
case EventType.SessionEnd:
|
||||
return { requestId, timestamp, type: eventType };
|
||||
|
||||
case EventType.SessionPrint:
|
||||
return {
|
||||
data,
|
||||
requestId,
|
||||
timestamp,
|
||||
type: eventType,
|
||||
};
|
||||
|
||||
case EventType.SessionStart:
|
||||
return {
|
||||
requestId,
|
||||
terminalSize: decodeTerminalSize(data),
|
||||
timestamp,
|
||||
type: eventType,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function decodeSerializedTerminal(data: Uint8Array): SerializedTerminal {
|
||||
if (data.length < responseHeaderSize) {
|
||||
throw new Error('Serialized terminal data too short');
|
||||
}
|
||||
|
||||
const view = new DataView(
|
||||
data.buffer,
|
||||
data.byteOffset + 1,
|
||||
data.byteLength - 1
|
||||
);
|
||||
|
||||
const cols = view.getUint32(0, false);
|
||||
const rows = view.getUint32(4, false);
|
||||
const cursorX = view.getUint32(8, false);
|
||||
const cursorY = view.getUint32(12, false);
|
||||
const dataLength = view.getUint32(16, false);
|
||||
|
||||
const totalLength = responseHeaderSize + dataLength;
|
||||
|
||||
if (data.length < totalLength) {
|
||||
throw new Error('Incomplete serialized terminal data');
|
||||
}
|
||||
|
||||
return {
|
||||
cols,
|
||||
cursorX,
|
||||
cursorY,
|
||||
data: data.subarray(responseHeaderSize, totalLength),
|
||||
rows,
|
||||
};
|
||||
}
|
||||
|
||||
function decodeTerminalSize(data: Uint8Array): TerminalSize {
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
const size = decoder.decode(data);
|
||||
const [cols, rows] = size.split(':').map(Number);
|
||||
|
||||
if (isNaN(cols) || isNaN(rows)) {
|
||||
throw new Error('Invalid terminal size format');
|
||||
}
|
||||
|
||||
return { cols, rows };
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Teleport
|
||||
* Copyright (C) 2025 Gravitational, Inc.
|
||||
*
|
||||
* This program is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU Affero General Public License as published by
|
||||
* the Free Software Foundation, either version 3 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* This program is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU Affero General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
import type { BaseEvent } from '../../stream/types';
|
||||
|
||||
export enum EventType {
|
||||
SessionStart = 4,
|
||||
SessionPrint = 5,
|
||||
SessionEnd = 6,
|
||||
Resize = 7,
|
||||
Screen = 8,
|
||||
}
|
||||
|
||||
export interface ResizeEvent extends BaseEvent<EventType.Resize> {
|
||||
terminalSize: TerminalSize;
|
||||
}
|
||||
|
||||
export interface ScreenEvent extends BaseEvent<EventType.Screen> {
|
||||
screen: SerializedTerminal;
|
||||
}
|
||||
|
||||
export interface SerializedTerminal {
|
||||
cols: number;
|
||||
cursorX: number;
|
||||
cursorY: number;
|
||||
data: Uint8Array;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export interface SessionEndEvent extends BaseEvent<EventType.SessionEnd> {}
|
||||
|
||||
export interface SessionPrintEvent extends BaseEvent<EventType.SessionPrint> {
|
||||
data: Uint8Array;
|
||||
}
|
||||
|
||||
export interface SessionStartEvent extends BaseEvent<EventType.SessionStart> {
|
||||
terminalSize: TerminalSize;
|
||||
}
|
||||
|
||||
export interface TerminalSize {
|
||||
cols: number;
|
||||
rows: number;
|
||||
}
|
||||
|
||||
export type TtyEvent =
|
||||
| ResizeEvent
|
||||
| ScreenEvent
|
||||
| SessionEndEvent
|
||||
| SessionPrintEvent
|
||||
| SessionStartEvent;
|
||||
Reference in New Issue
Block a user