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:
Ryan Clark
2025-09-09 08:41:03 +00:00
committed by GitHub
parent 4e25a14055
commit aef38286c6
4 changed files with 484 additions and 0 deletions
@@ -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;