diff --git a/docs/pages/desktop-access/reference/sessions.mdx b/docs/pages/desktop-access/reference/sessions.mdx index c73af58ae628a..7e4903e2287d5 100644 --- a/docs/pages/desktop-access/reference/sessions.mdx +++ b/docs/pages/desktop-access/reference/sessions.mdx @@ -58,19 +58,15 @@ for all possible settings in the `record_session` section. ## Playback -Recorded sessions can be viewed in the *Session Recordings* page under the +Recorded sessions can be viewed in the *Session Recordings* page under the *Activity* section in the *Management* area. Desktop recordings show a desktop icon in the first column to distinguish them from SSH recordings. ![Desktop Session Recording](../../../img/desktop-access/session-recording@2x.png) -Click the play button to open the player in a new tab. The desktop session -player supports toggling between play and pause, but does not support seeking to -a specific point in the stream, rewinding, or restarting playback when the end -of the stream is reached. To replay a session, refresh the page. - -To export desktop session recordings to video for playback outside of Teleport, -use the `tsh recordings export` command: +Click the play button to open the player in a new tab. To export desktop session +recordings to video for playback outside of Teleport, use the +`tsh recordings export` command: ```code $ tsh recordings export @@ -88,7 +84,7 @@ Be aware, desktop session recordings save PNGs of changing sections of the screen, which means they take up significantly more disk space than SSH or Kubernetes session recordings. When using async recording modes, ensure that the host running Teleport's Desktop Service has sufficient disk space to store -recordings that are in progress. +recordings that are in progress. As a point of reference, when a full 1080p screen is redrawn (for example when opening a new full-sized application window), you can expect about 250kb to be diff --git a/lib/srv/desktop/rdp/rdpclient/client.go b/lib/srv/desktop/rdp/rdpclient/client.go index b269f0e82af2f..240c61710a1a0 100644 --- a/lib/srv/desktop/rdp/rdpclient/client.go +++ b/lib/srv/desktop/rdp/rdpclient/client.go @@ -200,7 +200,7 @@ func (c *Client) Run(ctx context.Context) error { case err := <-rustRDPReturnCh: // Ensure the startInputStreaming goroutine returns. close(stopCh) - return err + return trace.Wrap(err) case err := <-inputStreamingReturnCh: // Ensure the startRustRDP goroutine returns. stopErr := c.stopRustRDP() @@ -340,7 +340,7 @@ func (c *Client) startInputStreaming(stopCh chan struct{}) error { msg, err := c.cfg.Conn.ReadMessage() if utils.IsOKNetworkError(err) { - return err + return nil } else if tdp.IsNonFatalErr(err) { c.cfg.Conn.SendNotification(err.Error(), tdp.SeverityWarning) continue diff --git a/lib/web/desktop/playback.go b/lib/web/desktop/playback.go index eb73b9e77cd2e..38fcb8c38b0b9 100644 --- a/lib/web/desktop/playback.go +++ b/lib/web/desktop/playback.go @@ -22,6 +22,7 @@ import ( "context" "encoding/json" "fmt" + "time" "github.com/gorilla/websocket" "github.com/sirupsen/logrus" @@ -47,6 +48,9 @@ const ( // actionSpeed sets the playback speed actionSpeed = playbackAction("speed") + + // actionSeek moves to a different position in the recording + actionSeek = playbackAction("seek") ) // actionMessage is a message passed from the playback client @@ -55,6 +59,7 @@ const ( type actionMessage struct { Action playbackAction `json:"action"` PlaybackSpeed float64 `json:"speed,omitempty"` + Pos int64 `json:"pos"` } // ReceivePlaybackActions handles logic for receiving playbackAction messages @@ -90,6 +95,8 @@ func ReceivePlaybackActions( action.PlaybackSpeed = max(action.PlaybackSpeed, minPlaybackSpeed) action.PlaybackSpeed = min(action.PlaybackSpeed, maxPlaybackSpeed) player.SetSpeed(action.PlaybackSpeed) + case actionSeek: + player.SetPos(time.Duration(action.Pos) * time.Millisecond) default: log.Warnf("invalid desktop playback action: %v", action.Action) return diff --git a/web/packages/teleport/src/DesktopSession/useTdpClientCanvas.tsx b/web/packages/teleport/src/DesktopSession/useTdpClientCanvas.tsx index 32d6fa8b2b22b..0d0bef034f817 100644 --- a/web/packages/teleport/src/DesktopSession/useTdpClientCanvas.tsx +++ b/web/packages/teleport/src/DesktopSession/useTdpClientCanvas.tsx @@ -156,7 +156,7 @@ export default function useTdpClientCanvas(props: Props) { setClipboardSharingEnabled(false); setTdpConnection({ status: 'failed', - statusText: error.message, + statusText: error.message || error.toString(), }); }; diff --git a/web/packages/teleport/src/Player/DesktopPlayer.tsx b/web/packages/teleport/src/Player/DesktopPlayer.tsx index 6208850ddc465..4bafeb7b984a0 100644 --- a/web/packages/teleport/src/Player/DesktopPlayer.tsx +++ b/web/packages/teleport/src/Player/DesktopPlayer.tsx @@ -1,4 +1,3 @@ -/* eslint-disable no-console */ /** * Teleport * Copyright (C) 2023 Gravitational, Inc. @@ -17,21 +16,33 @@ * along with this program. If not, see . */ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import styled from 'styled-components'; import { Indicator, Box, Alert } from 'design'; -import useAttempt from 'shared/hooks/useAttemptNext'; import cfg from 'teleport/config'; -import { PlayerClient, PlayerClientEvent, TdpClient } from 'teleport/lib/tdp'; +import { StatusEnum } from 'teleport/lib/player'; +import { PlayerClient, TdpClient } from 'teleport/lib/tdp'; import { getAccessToken, getHostName } from 'teleport/services/api'; import TdpClientCanvas from 'teleport/components/TdpClientCanvas'; -import { ProgressBarDesktop } from './ProgressBar'; +import { formatDisplayTime } from 'teleport/lib/player'; + +import ProgressBar from './ProgressBar'; import type { PngFrame, ClientScreenSpec } from 'teleport/lib/tdp/codec'; import type { BitmapFrame } from 'teleport/lib/tdp/client'; +const reload = () => window.location.reload(); +const handleContextMenu = () => true; + +// overflow: 'hidden' is needed to prevent the canvas from outgrowing the container due to some weird css flex idiosyncracy. +// See https://gaurav5430.medium.com/css-flex-positioning-gotchas-child-expands-to-more-than-the-width-allowed-by-the-parent-799c37428dd6. +const canvasStyle = { + alignSelf: 'center', + overflow: 'hidden', +}; + export const DesktopPlayer = ({ sid, clusterId, @@ -43,32 +54,35 @@ export const DesktopPlayer = ({ }) => { const { playerClient, + playerStatus, + statusText, + time, + clientOnPngFrame, clientOnBitmapFrame, clientOnClientScreenSpec, clientOnWsClose, clientOnTdpError, - attempt, } = useDesktopPlayer({ sid, clusterId, }); - const displayCanvas = attempt.status === 'success' || attempt.status === ''; - const displayProgressBar = attempt.status !== 'processing'; + const isError = playerStatus === StatusEnum.ERROR; + const isLoading = playerStatus === StatusEnum.LOADING; + const isPlaying = playerStatus === StatusEnum.PLAYING; + const isComplete = isError || playerStatus === StatusEnum.COMPLETE; + + const t = playerStatus === StatusEnum.COMPLETE ? durationMs : time; return ( - {attempt.status === 'processing' && ( + {isError && } + {isLoading && ( )} - - {attempt.status === 'failed' && ( - - )} - true} - // overflow: 'hidden' is needed to prevent the canvas from outgrowing the container due to some weird css flex idiosyncracy. - // See https://gaurav5430.medium.com/css-flex-positioning-gotchas-child-expands-to-more-than-the-width-allowed-by-the-parent-799c37428dd6. - style={{ - alignSelf: 'center', - overflow: 'hidden', - display: displayCanvas ? 'flex' : 'none', - }} + canvasOnContextMenu={handleContextMenu} + style={canvasStyle} /> - playerClient.suspendTimeUpdates()} + move={pos => { + playerClient.seekTo(pos); + playerClient.resumeTimeUpdates(); + }} + onPlaySpeedChange={s => playerClient.setPlaySpeed(s)} + toggle={() => playerClient.togglePlayPause()} /> ); }; -const useDesktopPlayer = ({ - sid, - clusterId, -}: { - sid: string; - clusterId: string; -}) => { - const [playerClient, setPlayerClient] = useState(null); - // attempt.status === '' means the playback ended gracefully - const { attempt, setAttempt } = useAttempt('processing'); - - useEffect(() => { - setPlayerClient( - new PlayerClient( - cfg.api.desktopPlaybackWsAddr - .replace(':fqdn', getHostName()) - .replace(':clusterId', clusterId) - .replace(':sid', sid) - .replace(':token', getAccessToken()) - ) - ); - }, [clusterId, sid]); - - const clientOnPngFrame = ( - ctx: CanvasRenderingContext2D, - pngFrame: PngFrame - ) => { - ctx.drawImage(pngFrame.data, pngFrame.left, pngFrame.top); - }; - - const clientOnBitmapFrame = ( - ctx: CanvasRenderingContext2D, - bmpFrame: BitmapFrame - ) => { - ctx.putImageData(bmpFrame.image_data, bmpFrame.left, bmpFrame.top); - }; - - const clientOnClientScreenSpec = ( - cli: TdpClient, - canvas: HTMLCanvasElement, - spec: ClientScreenSpec - ) => { - const { width, height } = spec; - - const styledPlayer = canvas.parentElement; - const progressBar = styledPlayer.children.namedItem('progressBarDesktop'); - - const fullWidth = styledPlayer.clientWidth; - const fullHeight = styledPlayer.clientHeight - progressBar.clientHeight; - const originalAspectRatio = width / height; - const currentAspectRatio = fullWidth / fullHeight; - - if (originalAspectRatio > currentAspectRatio) { - // Use the full width of the screen and scale the height. - canvas.style.height = `${(fullWidth * height) / width}px`; - console.debug( - `set canvas.style.height to ${(fullWidth * height) / width}px` - ); - } else if (originalAspectRatio < currentAspectRatio) { - // Use the full height of the screen and scale the width. - canvas.style.width = `${(fullHeight * width) / height}px`; - console.debug( - `set canvas.style.width to ${(fullHeight * width) / height}px` - ); - } +const clientOnPngFrame = ( + ctx: CanvasRenderingContext2D, + pngFrame: PngFrame +) => { + ctx.drawImage(pngFrame.data, pngFrame.left, pngFrame.top); +}; - canvas.width = width; - canvas.height = height; - console.debug(`set canvas.width x canvas.height to ${width} x ${height}`); +const clientOnBitmapFrame = ( + ctx: CanvasRenderingContext2D, + bmpFrame: BitmapFrame +) => { + ctx.putImageData(bmpFrame.image_data, bmpFrame.left, bmpFrame.top); +}; - setAttempt({ status: 'success' }); - }; +const clientOnClientScreenSpec = ( + cli: TdpClient, + canvas: HTMLCanvasElement, + spec: ClientScreenSpec +) => { + const { width, height } = spec; + + const styledPlayer = canvas.parentElement; + const progressBar = styledPlayer.children.namedItem('progressBarDesktop'); + + const fullWidth = styledPlayer.clientWidth; + const fullHeight = styledPlayer.clientHeight - progressBar.clientHeight; + const originalAspectRatio = width / height; + const currentAspectRatio = fullWidth / fullHeight; + + if (originalAspectRatio > currentAspectRatio) { + // Use the full width of the screen and scale the height. + canvas.style.height = `${(fullWidth * height) / width}px`; + } else if (originalAspectRatio < currentAspectRatio) { + // Use the full height of the screen and scale the width. + canvas.style.width = `${(fullHeight * width) / height}px`; + } + + canvas.width = width; + canvas.height = height; +}; - useEffect(() => { +const useDesktopPlayer = ({ clusterId, sid }) => { + const [time, setTime] = React.useState(0); + const [playerStatus, setPlayerStatus] = React.useState(StatusEnum.LOADING); + const [statusText, setStatusText] = React.useState(''); + + const playerClient = React.useMemo(() => { + const url = cfg.api.desktopPlaybackWsAddr + .replace(':fqdn', getHostName()) + .replace(':clusterId', clusterId) + .replace(':sid', sid) + .replace(':token', getAccessToken()); + return new PlayerClient({ url, setTime, setPlayerStatus, setStatusText }); + }, [clusterId, sid, setTime, setPlayerStatus]); + + const clientOnWsClose = React.useCallback(() => { if (playerClient) { - playerClient.addListener(PlayerClientEvent.SESSION_END, () => { - setAttempt({ status: '' }); - }); - - playerClient.addListener( - PlayerClientEvent.PLAYBACK_ERROR, - (err: Error) => { - setAttempt({ - status: 'failed', - statusText: `There was an error while playing this session: ${err.message}`, - }); - } - ); - - return () => { - playerClient.shutdown(); - }; + playerClient.cancelTimeUpdate(); } - }, [playerClient, setAttempt]); - - // If the websocket closed for some reason other than the session playback ending, - // as signaled by the server (which sets prevAttempt.status = '' in - // the PlayerClientEvent.SESSION_END event handler), or a TDP message from the server - // signalling an error, assume some sort of network or playback error and alert the user. - const clientOnWsClose = () => { - setAttempt(prevAttempt => { - if (prevAttempt.status !== '' && prevAttempt.status !== 'failed') { - return { - status: 'failed', - statusText: 'connection to the server failed for an unknown reason', - }; - } - return prevAttempt; - }); - }; + }, [playerClient]); + + const clientOnTdpError = React.useCallback( + (error: Error) => { + setPlayerStatus(StatusEnum.ERROR); + setStatusText(error.message || error.toString()); + }, + [setPlayerStatus, setStatusText] + ); - const clientOnTdpError = (error: Error) => { - setAttempt({ - status: 'failed', - statusText: error.message, - }); - }; + React.useEffect(() => { + return playerClient.shutdown; + }, [playerClient]); return { + time, playerClient, + playerStatus, + statusText, + clientOnPngFrame, clientOnBitmapFrame, clientOnClientScreenSpec, clientOnWsClose, clientOnTdpError, - attempt, }; }; diff --git a/web/packages/teleport/src/Player/ProgressBar/ProgressBar.tsx b/web/packages/teleport/src/Player/ProgressBar/ProgressBar.tsx index 7bc0755ed371f..b943cc3eb20f2 100644 --- a/web/packages/teleport/src/Player/ProgressBar/ProgressBar.tsx +++ b/web/packages/teleport/src/Player/ProgressBar/ProgressBar.tsx @@ -25,7 +25,11 @@ import Slider from './Slider'; export default function ProgressBar(props: ProgressBarProps) { const Icon = props.isPlaying ? Icons.CirclePause : Icons.CirclePlay; return ( - + @@ -183,7 +187,11 @@ const StyledProgessBar = styled.div` } .grv-slider .handle { - background-color: ${props => props.theme.colors.text.main}; + background-color: ${props => + props.disabled + ? props.theme.colors.text.disabled + : props.theme.colors.success.main}; + border-radius: 200px; box-shadow: 0 0 4px rgba(0, 0, 0, 0.12), 0 4px 4px rgba(0, 0, 0, 0.24); width: 16px; @@ -193,11 +201,17 @@ const StyledProgessBar = styled.div` } .grv-slider .bar-0 { - background-color: ${props => props.theme.colors.success.main}; + background-color: ${props => + props.disabled + ? props.theme.colors.text.disabled + : props.theme.colors.success.main}; box-shadow: none; } .grv-slider .bar-1 { - background-color: ${props => props.theme.colors.spotBackground[2]}; + background-color: ${props => + props.disabled + ? props.theme.colors.text.disabled + : props.theme.colors.spotBackground[2]}; } `; diff --git a/web/packages/teleport/src/Player/ProgressBar/ProgressBarDesktop.tsx b/web/packages/teleport/src/Player/ProgressBar/ProgressBarDesktop.tsx deleted file mode 100644 index 4073380452b9f..0000000000000 --- a/web/packages/teleport/src/Player/ProgressBar/ProgressBarDesktop.tsx +++ /dev/null @@ -1,168 +0,0 @@ -/** - * Teleport - * Copyright (C) 2023 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 . - */ - -import React, { useState, useEffect, useRef } from 'react'; - -import { throttle } from 'shared/utils/highbar'; -import { dateToUtc } from 'shared/services/loc'; -import { format } from 'date-fns'; - -import { PlayerClient, PlayerClientEvent } from 'teleport/lib/tdp'; - -import ProgressBar from './ProgressBar'; - -export const ProgressBarDesktop = (props: { - playerClient: PlayerClient; - durationMs: number; - style?: React.CSSProperties; - id?: string; -}) => { - const { playerClient, durationMs } = props; - const intervalRef = useRef(); - let playSpeed = 1.0; - - const toHuman = (currentMs: number) => { - return format(dateToUtc(new Date(currentMs)), 'mm:ss'); - }; - - const [state, setState] = useState({ - max: durationMs, - min: 0, - current: 0, // the recording always starts at 0 ms - time: toHuman(0), - isPlaying: true, // determines whether play or pause symbol is shown - }); - - // updateCurrentTime is a helper function to update the state variable. - // It should be used within a setState, like - // setState(prevState => { - // return updateCurrentTime(prevState, newTime) - // }) - const updateCurrentTime = ( - prevState: typeof state, - currentTimeMs: number - ) => { - return { - ...prevState, - current: currentTimeMs, - time: toHuman(currentTimeMs), - }; - }; - - useEffect(() => { - if (playerClient) { - // Starts the smoothing interval, which smooths out the progress of the progress bar. - // This ensures the bar continues to progress even during playbacks where there are long - // intervals between TDP events sent to us by the server. The interval should be active - // whenever the playback is in "play" mode. - const smoothOutProgress = (speed: number) => { - const smoothingInterval = 25; - - intervalRef.current = window.setInterval(() => { - setState(prevState => { - const nextTimeMs = prevState.current + smoothingInterval * speed; - if (nextTimeMs <= durationMs) { - return updateCurrentTime(prevState, nextTimeMs); - } else { - stopProgress(); - return updateCurrentTime(prevState, durationMs); - } - }); - }, smoothingInterval); - }; - - // The player always starts in play mode, so call this initially. - smoothOutProgress(playSpeed); - - // Clears the smoothing interval and cancels any throttled updates, - // should be called when the playback is paused or ended. - const stopProgress = () => { - throttledUpdateCurrentTime.cancel(); - window.clearInterval(intervalRef.current); - }; - - const throttledUpdateCurrentTime = throttle( - currentTimeMs => { - setState(prevState => { - return updateCurrentTime(prevState, currentTimeMs); - }); - }, - // Magic number to throttle progress bar updates caused by TDP events - // so that the playback is smoother. - 50 - ); - - // Listens for UPDATE_CURRENT_TIME events which coincide with - // TDP events sent to the playerClient by the server. - playerClient.addListener( - PlayerClientEvent.UPDATE_CURRENT_TIME, - currentTimeMs => throttledUpdateCurrentTime(currentTimeMs) - ); - - playerClient.addListener(PlayerClientEvent.TOGGLE_PLAY_PAUSE, () => { - // setState({...state, isPlaying: !state.isPlaying}) doesn't work because - // the listener is added when state == initialState, and that initialState - // value is effectively hardcoded into its logic. - setState(prevState => { - if (prevState.isPlaying) { - // pause - stopProgress(); - } else { - // play - smoothOutProgress(playSpeed); - } - return { ...prevState, isPlaying: !prevState.isPlaying }; - }); - }); - - playerClient.addListener( - PlayerClientEvent.PLAY_SPEED, - (speed: number) => { - playSpeed = speed; - - setState(prevState => { - if (prevState.isPlaying) { - stopProgress(); - smoothOutProgress(playSpeed); - } - return { ...prevState, isPlaying: prevState.isPlaying }; - }); - } - ); - - return () => { - playerClient.shutdown(); - stopProgress(); - }; - } - }, [playerClient]); - - return ( - playerClient.togglePlayPause()} - onPlaySpeedChange={(newSpeed: number) => - playerClient.setPlaySpeed(newSpeed) - } - move={() => {}} - style={props.style} - id={props.id} - onRestart={() => window.location.reload()} - /> - ); -}; diff --git a/web/packages/teleport/src/Player/ProgressBar/index.tsx b/web/packages/teleport/src/Player/ProgressBar/index.tsx index 9d892ade1a00f..88e5de79b2c09 100644 --- a/web/packages/teleport/src/Player/ProgressBar/index.tsx +++ b/web/packages/teleport/src/Player/ProgressBar/index.tsx @@ -17,7 +17,5 @@ */ import ProgressBar from './ProgressBar'; -import { ProgressBarDesktop } from './ProgressBarDesktop'; export default ProgressBar; -export { ProgressBarDesktop }; diff --git a/web/packages/teleport/src/Player/SshPlayer.tsx b/web/packages/teleport/src/Player/SshPlayer.tsx index 479f9237812ed..49dd5976ecc6f 100644 --- a/web/packages/teleport/src/Player/SshPlayer.tsx +++ b/web/packages/teleport/src/Player/SshPlayer.tsx @@ -22,10 +22,8 @@ import { Indicator, Flex, Box } from 'design'; import { Danger } from 'design/Alert'; import cfg from 'teleport/config'; -import TtyPlayer, { - StatusEnum, - StatusEnum as TtyStatusEnum, -} from 'teleport/lib/term/ttyPlayer'; +import TtyPlayer from 'teleport/lib/term/ttyPlayer'; +import { formatDisplayTime, StatusEnum } from 'teleport/lib/player'; import { getAccessToken, getHostName } from 'teleport/services/api'; import ProgressBar from './ProgressBar'; @@ -36,9 +34,10 @@ export default function Player({ sid, clusterId, durationMs }) { clusterId, sid ); - const isError = playerStatus === TtyStatusEnum.ERROR; - const isLoading = playerStatus === TtyStatusEnum.LOADING; - const isPlaying = playerStatus === TtyStatusEnum.PLAYING; + const isError = playerStatus === StatusEnum.ERROR; + const isLoading = playerStatus === StatusEnum.LOADING; + const isPlaying = playerStatus === StatusEnum.PLAYING; + const isComplete = isError || playerStatus === StatusEnum.COMPLETE; if (isError) { return ( @@ -65,14 +64,11 @@ export default function Player({ sid, clusterId, durationMs }) { min={0} max={durationMs} current={time} - disabled={ - playerStatus === TtyStatusEnum.ERROR || - playerStatus === TtyStatusEnum.COMPLETE - } + disabled={isComplete} isPlaying={isPlaying} time={formatDisplayTime(time)} - onRestart={window.location.reload} - onStartMove={tty.suspendTimeUpdates} + onRestart={() => window.location.reload()} + onStartMove={() => tty.suspendTimeUpdates()} move={pos => { tty.move(pos); tty.resumeTimeUpdates(); @@ -125,19 +121,3 @@ function useStreamingSshPlayer(clusterId: string, sid: string) { return { tty, playerStatus, statusText, time }; } - -function formatDisplayTime(ms: number) { - if (ms <= 0) { - return '00:00'; - } - - const totalSec = Math.floor(ms / 1000); - const totalDays = (totalSec % 31536000) % 86400; - const h = Math.floor(totalDays / 3600); - const m = Math.floor((totalDays % 3600) / 60); - const s = (totalDays % 3600) % 60; - - return `${h > 0 ? h + ':' : ''}${m.toString().padStart(2, '0')}:${s - .toString() - .padStart(2, '0')}`; -} diff --git a/web/packages/teleport/src/components/TdpClientCanvas/TdpClientCanvas.tsx b/web/packages/teleport/src/components/TdpClientCanvas/TdpClientCanvas.tsx index 92ebabd83ecd4..b452dfb404161 100644 --- a/web/packages/teleport/src/components/TdpClientCanvas/TdpClientCanvas.tsx +++ b/web/packages/teleport/src/components/TdpClientCanvas/TdpClientCanvas.tsx @@ -16,20 +16,21 @@ * along with this program. If not, see . */ -import React, { useEffect, useRef } from 'react'; +import React, { memo, useEffect, useRef } from 'react'; import { TdpClientEvent } from 'teleport/lib/tdp'; import { BitmapFrame } from 'teleport/lib/tdp/client'; +import { TdpClient } from 'teleport/lib/tdp'; + import type { CSSProperties } from 'react'; import type { PngFrame, ClientScreenSpec, ClipboardData, } from 'teleport/lib/tdp/codec'; -import type { TdpClient } from 'teleport/lib/tdp'; -export default function TdpClientCanvas(props: Props) { +function TdpClientCanvas(props: Props) { const { client, clientShouldConnect = false, @@ -51,7 +52,6 @@ export default function TdpClientCanvas(props: Props) { canvasOnContextMenu, style, } = props; - const canvasRef = useRef(null); if (canvasRef.current) { @@ -226,8 +226,9 @@ export default function TdpClientCanvas(props: Props) { } return () => { - if (canvasOnMouseMove) + if (canvasOnMouseMove) { canvas.removeEventListener('mousemove', _onmousemove); + } }; }, [client, canvasOnMouseMove]); @@ -303,7 +304,22 @@ export default function TdpClientCanvas(props: Props) { }; }, [client, canvasOnKeyUp]); - // Call init after all listeners have been registered + useEffect(() => { + if (client) { + const canvas = canvasRef.current; + const _clearCanvas = () => { + const ctx = canvas.getContext('2d'); + ctx.clearRect(0, 0, canvas.width, canvas.height); + }; + client.on(TdpClientEvent.RESET, _clearCanvas); + + return () => { + client.removeListener(TdpClientEvent.RESET, _clearCanvas); + }; + } + }, [client]); + + // Call connect after all listeners have been registered useEffect(() => { if (client && clientShouldConnect) { client.connect(clientScreenSpecToRequest); @@ -355,3 +371,5 @@ export type Props = { canvasOnContextMenu?: () => boolean; style?: CSSProperties; }; + +export default memo(TdpClientCanvas); diff --git a/web/packages/teleport/src/lib/player/index.ts b/web/packages/teleport/src/lib/player/index.ts new file mode 100644 index 0000000000000..89593ca76b683 --- /dev/null +++ b/web/packages/teleport/src/lib/player/index.ts @@ -0,0 +1,41 @@ +/** + * Teleport + * Copyright (C) 2023 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 . + */ + +export enum StatusEnum { + LOADING = 'LOADING', + PLAYING = 'PLAYING', + PAUSED = 'PAUSED', + COMPLETE = 'COMPLETE', + ERROR = 'ERROR', +} + +export function formatDisplayTime(ms: number) { + if (ms <= 0) { + return '00:00'; + } + + const totalSec = Math.floor(ms / 1000); + const totalDays = (totalSec % 31536000) % 86400; + const h = Math.floor(totalDays / 3600); + const m = Math.floor((totalDays % 3600) / 60); + const s = (totalDays % 3600) % 60; + + return `${h > 0 ? h + ':' : ''}${m.toString().padStart(2, '0')}:${s + .toString() + .padStart(2, '0')}`; +} diff --git a/web/packages/teleport/src/lib/tdp/client.ts b/web/packages/teleport/src/lib/tdp/client.ts index ea2990c96612c..66d78d0fd1d6d 100644 --- a/web/packages/teleport/src/lib/tdp/client.ts +++ b/web/packages/teleport/src/lib/tdp/client.ts @@ -71,6 +71,7 @@ export enum TdpClientEvent { CLIENT_WARNING = 'client warning', WS_OPEN = 'ws open', WS_CLOSE = 'ws close', + RESET = 'reset', } export enum LogType { @@ -327,13 +328,6 @@ export default class Client extends EventEmitterWebAuthnSender { } handleRDPFastPathPDU(buffer: ArrayBuffer) { - if (!this.fastPathProcessor) { - this.handleError( - new Error("fastPathProcessor isn't initialized yet"), - TdpClientEvent.CLIENT_ERROR - ); - } - let rdpFastPathPDU = this.codec.decodeRDPFastPathPDU(buffer); // This should never happen but let's catch it with an error in case it does. @@ -343,16 +337,20 @@ export default class Client extends EventEmitterWebAuthnSender { TdpClientEvent.CLIENT_ERROR ); - this.fastPathProcessor.process( - rdpFastPathPDU, - this, - (bmpFrame: BitmapFrame) => { - this.emit(TdpClientEvent.TDP_BMP_FRAME, bmpFrame); - }, - (responseFrame: ArrayBuffer) => { - this.sendRDPResponsePDU(responseFrame); - } - ); + try { + this.fastPathProcessor.process( + rdpFastPathPDU, + this, + (bmpFrame: BitmapFrame) => { + this.emit(TdpClientEvent.TDP_BMP_FRAME, bmpFrame); + }, + (responseFrame: ArrayBuffer) => { + this.sendRDPResponsePDU(responseFrame); + } + ); + } catch (e) { + this.handleError(e, TdpClientEvent.CLIENT_ERROR); + } } handleMfaChallenge(buffer: ArrayBuffer) { diff --git a/web/packages/teleport/src/lib/tdp/index.ts b/web/packages/teleport/src/lib/tdp/index.ts index 135f77b21038f..2d1f6ef0f85b6 100644 --- a/web/packages/teleport/src/lib/tdp/index.ts +++ b/web/packages/teleport/src/lib/tdp/index.ts @@ -17,14 +17,7 @@ */ import TdpClient, { TdpClientEvent } from './client'; -import { PlayerClient, PlayerClientEvent } from './playerClient'; +import { PlayerClient } from './playerClient'; import { ButtonState, ScrollAxis } from './codec'; -export { - TdpClient, - TdpClientEvent, - PlayerClient, - PlayerClientEvent, - ButtonState, - ScrollAxis, -}; +export { TdpClient, TdpClientEvent, PlayerClient, ButtonState, ScrollAxis }; diff --git a/web/packages/teleport/src/lib/tdp/playerClient.ts b/web/packages/teleport/src/lib/tdp/playerClient.ts index e35770581f7e5..bbba36bc9297d 100644 --- a/web/packages/teleport/src/lib/tdp/playerClient.ts +++ b/web/packages/teleport/src/lib/tdp/playerClient.ts @@ -16,41 +16,102 @@ * along with this program. If not, see . */ +import { throttle } from 'shared/utils/highbar'; import { base64ToArrayBuffer } from 'shared/utils/base64'; +import { StatusEnum } from 'teleport/lib/player'; + import Client, { TdpClientEvent } from './client'; +import { ClientScreenSpec } from './codec'; -enum Action { - TOGGLE_PLAY_PAUSE = 'play/pause', - PLAY_SPEED = 'speed', - // TODO: MOVE = 'move' -} +// we update the time every time we receive data, or +// at this interval (which ensures that the progress +// bar updates even when we aren't receiving data) +const PROGRESS_UPDATE_INTERVAL_MS = 50; -export enum PlayerClientEvent { +enum Action { TOGGLE_PLAY_PAUSE = 'play/pause', PLAY_SPEED = 'speed', - UPDATE_CURRENT_TIME = 'time', - SESSION_END = 'end', - PLAYBACK_ERROR = 'playback error', + SEEK = 'seek', } export class PlayerClient extends Client { - textDecoder = new TextDecoder(); + private textDecoder = new TextDecoder(); + private setPlayerStatus: React.Dispatch>; + private setStatusText: React.Dispatch>; + private _setTime: React.Dispatch>; + private setTime: React.Dispatch>; + + private speed = 1.0; + private paused = false; + private lastPlayedTimestamp = 0; + private sendTimeUpdates = true; + private lastUpdate = 0; + private timeout = null; + + constructor({ url, setTime, setPlayerStatus, setStatusText }) { + super(url); + this.setPlayerStatus = setPlayerStatus; + this.setStatusText = setStatusText; + this._setTime = setTime; + this.setTime = throttle(t => { + // time updates are suspended when a user is dragging the slider to + // a new position (it's very disruptive if we're updating the slider + // position every few milliseconds while the user is trying to + // reposition it) + if (this.sendTimeUpdates) { + this._setTime(t); + } + }, PROGRESS_UPDATE_INTERVAL_MS); + } + + // Override so we can set player status. + async connect(spec?: ClientScreenSpec) { + await super.connect(spec); + this.setPlayerStatus(StatusEnum.PLAYING); + } + + scheduleNextUpdate(current: number) { + this.timeout = setTimeout(() => { + const delta = Date.now() - this.lastUpdate; + const next = current + delta * this.speed; + + this.setTime(next); + this.lastUpdate = Date.now(); + + this.scheduleNextUpdate(next); + }, PROGRESS_UPDATE_INTERVAL_MS); + } + + cancelTimeUpdate() { + if (this.timeout != null) { + clearTimeout(this.timeout); + this.timeout = null; + } + } - constructor(socketAddr: string) { - super(socketAddr); + suspendTimeUpdates() { + this.sendTimeUpdates = false; + } + + resumeTimeUpdates() { + this.sendTimeUpdates = true; } // togglePlayPause toggles the playback system between "playing" and "paused" states. togglePlayPause() { + this.paused = !this.paused; this.send(JSON.stringify({ action: Action.TOGGLE_PLAY_PAUSE })); - this.emit(PlayerClientEvent.TOGGLE_PLAY_PAUSE); + if (this.paused) { + this.cancelTimeUpdate(); + } + this.setPlayerStatus(this.paused ? StatusEnum.PAUSED : StatusEnum.PLAYING); } // setPlaySpeed sets the playback speed of the recording. setPlaySpeed(speed: number) { + this.speed = speed; this.send(JSON.stringify({ action: Action.PLAY_SPEED, speed })); - this.emit(PlayerClientEvent.PLAY_SPEED, speed); } // Overrides Client implementation. @@ -58,16 +119,41 @@ export class PlayerClient extends Client { const json = JSON.parse(this.textDecoder.decode(buffer)); if (json.message === 'end') { - this.emit(PlayerClientEvent.SESSION_END); + this.setPlayerStatus(StatusEnum.COMPLETE); } else if (json.message === 'error') { - this.emit(PlayerClientEvent.PLAYBACK_ERROR, new Error(json.errorText)); + this.setPlayerStatus(StatusEnum.ERROR); + this.setStatusText(json.errorText); } else { - const ms = json.ms; - this.emit(PlayerClientEvent.UPDATE_CURRENT_TIME, ms); + this.cancelTimeUpdate(); + this.lastPlayedTimestamp = json.ms; + this.lastUpdate = Date.now(); + this.setTime(json.ms); + + // schedule the next time update (in case this + // part of the recording is dead time) + if (!this.paused) { + this.scheduleNextUpdate(json.ms); + } + await super.processMessage(base64ToArrayBuffer(json.message)); } } + seekTo(pos: number) { + this.cancelTimeUpdate(); + + this.send(JSON.stringify({ action: Action.SEEK, pos })); + + if (pos < this.lastPlayedTimestamp) { + // TODO: clear canvas + } else if (this.paused) { + // if we're paused, we want the scrubber to "stick" at the new + // time until we press play (rather than waiting for us to click + // play and start receiving new data) + this._setTime(pos); + } + } + // Overrides Client implementation. handleClientScreenSpec(buffer: ArrayBuffer) { this.emit( diff --git a/web/packages/teleport/src/lib/term/ttyPlayer.js b/web/packages/teleport/src/lib/term/ttyPlayer.js index 3b33b33c65d31..d7f04846824c6 100644 --- a/web/packages/teleport/src/lib/term/ttyPlayer.js +++ b/web/packages/teleport/src/lib/term/ttyPlayer.js @@ -19,19 +19,13 @@ import { throttle } from 'shared/utils/highbar'; import Logger from 'shared/libs/logger'; +import { StatusEnum } from 'teleport/lib/player'; + import Tty from './tty'; import { TermEvent, WebsocketCloseCode } from './enums'; const logger = Logger.create('TtyPlayer'); -export const StatusEnum = { - PLAYING: 'PLAYING', - ERROR: 'ERROR', - PAUSED: 'PAUSED', - LOADING: 'LOADING', - COMPLETE: 'COMPLETE', -}; - const messageTypePty = 1; const messageTypeError = 2; const messageTypePlayPause = 3; @@ -155,7 +149,6 @@ export default class TtyPlayer extends Tty { // schedule the next time update (in case this // part of the recording is dead time) - // TODO(zmb3): implement this for desktops too if (!this._paused) { this.scheduleNextUpdate(delay); } diff --git a/web/packages/teleport/src/lib/term/ttyPlayer.test.js b/web/packages/teleport/src/lib/term/ttyPlayer.test.js index 569d3f3e3b0ae..0b5b3edf4d18a 100644 --- a/web/packages/teleport/src/lib/term/ttyPlayer.test.js +++ b/web/packages/teleport/src/lib/term/ttyPlayer.test.js @@ -21,8 +21,9 @@ import '@gravitational/shared/libs/polyfillFinally'; import WS from 'jest-websocket-mock'; import { TermEvent } from 'teleport/lib/term/enums'; +import { StatusEnum } from 'teleport/lib/player'; -import TtyPlayer, { StatusEnum } from './ttyPlayer'; +import TtyPlayer from './ttyPlayer'; describe('lib/ttyPlayer', () => { let server;