Skip to content
This repository has been archived by the owner on Feb 8, 2024. It is now read-only.

Commit

Permalink
v9 backports (#652)
Browse files Browse the repository at this point in the history
* Fix clipboard sync (#628)

* Maintain aspect ratio on Desktop Playback (#635)

* only synchronize clipboards if data was or is going to be sent (#640)

* desktop playback error handling (#638)

* smooth out progress bar (#648)
  • Loading branch information
Isaiah Becker-Mayer authored Mar 7, 2022
1 parent 7df0fcc commit 02e7791
Show file tree
Hide file tree
Showing 10 changed files with 321 additions and 129 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ const props: State = {
onContextMenu: () => false,
onMouseEnter: () => {},
onClipboardData: () => {},
windowOnFocus: () => {},
webauthn: {
errorText: '',
requested: false,
Expand Down
2 changes: 2 additions & 0 deletions packages/teleport/src/DesktopSession/DesktopSession.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ function Session(props: PropsWithChildren<State>) {
onMouseWheelScroll,
onContextMenu,
onMouseEnter,
windowOnFocus,
} = props;

const clipboardSharingActive =
Expand Down Expand Up @@ -193,6 +194,7 @@ function Session(props: PropsWithChildren<State>) {
onMouseWheelScroll={onMouseWheelScroll}
onContextMenu={onContextMenu}
onMouseEnter={onMouseEnter}
windowOnFocus={windowOnFocus}
/>
</Flex>
);
Expand Down
46 changes: 15 additions & 31 deletions packages/teleport/src/DesktopSession/useTdpClientCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,6 @@ export default function useTdpClientCanvas(props: Props) {
} = props;
const [tdpClient, setTdpClient] = useState<TdpClient | null>(null);
const initialTdpConnectionSucceeded = useRef(false);
const latestClipboardData = useRef<string>(null);

useEffect(() => {
const { width, height } = getDisplaySize();
Expand Down Expand Up @@ -70,17 +69,8 @@ export default function useTdpClientCanvas(props: Props) {

// Default TdpClientEvent.TDP_CLIPBOARD_DATA handler.
const onClipboardData = (clipboardData: ClipboardData) => {
if (
enableClipboardSharing &&
document.hasFocus() &&
clipboardData.data !== latestClipboardData.current
) {
navigator.clipboard.writeText(clipboardData.data).then(() => {
// Set latestClipboardText.current to whatever we got, so that
// next time onMouseEnter fires we don't try to send it back to
// the remote machine.
latestClipboardData.current = clipboardData.data;
});
if (enableClipboardSharing && document.hasFocus() && clipboardData.data) {
navigator.clipboard.writeText(clipboardData.data);
}
};

Expand Down Expand Up @@ -150,26 +140,12 @@ export default function useTdpClientCanvas(props: Props) {

const sendLocalClipboardToRemote = (cli: TdpClient) => {
// We must check that the DOM is focused or navigator.clipboard.readText throws an error.
// We check that initialTdpConnectionSucceeded so that we don't mistakenly send clipboard data
// to a backend that isn't ready for it yet, which would fail silently.
if (
enableClipboardSharing &&
document.hasFocus() &&
initialTdpConnectionSucceeded.current
) {
if (enableClipboardSharing && document.hasFocus()) {
navigator.clipboard.readText().then(text => {
if (text != latestClipboardData.current) {
// Wrap in try catch so that lastCopiedClipboardText is only
// updated if sendClipboardData succeeds.
// eslint-disable-next-line no-useless-catch
try {
cli.sendClipboardData({
data: text,
});
latestClipboardData.current = text;
} catch (e) {
throw e;
}
if (text) {
cli.sendClipboardData({
data: text,
});
}
});
}
Expand All @@ -182,6 +158,13 @@ export default function useTdpClientCanvas(props: Props) {
sendLocalClipboardToRemote(cli);
};

// onMouseEnter does not fire in certain situations, so ensure we cover all of our bases by adding a window level
// onfocus handler. See https://github.com/gravitational/webapps/issues/626 for further details.
const windowOnFocus = (cli: TdpClient, e: FocusEvent) => {
e.preventDefault();
sendLocalClipboardToRemote(cli);
};

return {
tdpClient,
onPngFrame,
Expand All @@ -197,6 +180,7 @@ export default function useTdpClientCanvas(props: Props) {
onMouseWheelScroll,
onContextMenu,
onMouseEnter,
windowOnFocus,
};
}

Expand Down
226 changes: 136 additions & 90 deletions packages/teleport/src/Player/DesktopPlayer.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import React, { useState, useEffect } from 'react';
import React, { useEffect, useState } from 'react';

import styled from 'styled-components';
import { throttle } from 'lodash';
import { dateToUtc } from 'shared/services/loc';
import { format } from 'date-fns';

import { Indicator, Box, Alert } from 'design';

import useAttempt from 'shared/hooks/useAttemptNext';

import cfg from 'teleport/config';
import { PlayerClient, PlayerClientEvent } from 'teleport/lib/tdp';
import { PngFrame, ClientScreenSpec } from 'teleport/lib/tdp/codec';
import { getAccessToken, getHostName } from 'teleport/services/api';
import TdpClientCanvas from 'teleport/components/TdpClientCanvas';

import ProgressBar from './ProgressBar';
import { ProgressBarDesktop } from './ProgressBar';

export const DesktopPlayer = ({
sid,
Expand All @@ -22,113 +23,82 @@ export const DesktopPlayer = ({
clusterId: string;
durationMs: number;
}) => {
const { playerClient, tdpCliOnPngFrame, tdpCliOnClientScreenSpec } =
useDesktopPlayer({
sid,
clusterId,
});
const {
playerClient,
tdpCliOnPngFrame,
tdpCliOnClientScreenSpec,
tdpCliOnWsClose,
tdpCliOnTdpError,
attempt,
} = useDesktopPlayer({
sid,
clusterId,
});

const displayCanvas = attempt.status === 'success' || attempt.status === '';
const displayProgressBar = attempt.status !== 'processing';

return (
<StyledPlayer>
{attempt.status === 'processing' && (
<Box textAlign="center" m={10}>
<Indicator />
</Box>
)}

{attempt.status === 'failed' && (
<DesktopPlayerAlert my={4} children={attempt.statusText} />
)}

<TdpClientCanvas
tdpCli={playerClient}
tdpCliOnPngFrame={tdpCliOnPngFrame}
tdpCliOnClientScreenSpec={tdpCliOnClientScreenSpec}
tdpCliOnWsClose={tdpCliOnWsClose}
tdpCliOnTdpError={tdpCliOnTdpError}
onContextMenu={() => 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={{ display: 'flex', flexGrow: 1, overflow: 'hidden' }}
style={{
alignSelf: 'center',
overflow: 'hidden',
display: displayCanvas ? 'flex' : 'none',
}}
/>
<ProgressBarDesktop
playerClient={playerClient}
durationMs={durationMs}
style={{
display: displayProgressBar ? 'flex' : 'none',
}}
id="progressBarDesktop"
/>
<ProgressBarDesktop playerClient={playerClient} durationMs={durationMs} />
</StyledPlayer>
);
};

export const ProgressBarDesktop = (props: {
playerClient: PlayerClient;
durationMs: number;
}) => {
const { playerClient, durationMs } = props;

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
});

useEffect(() => {
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 => {
return { ...prevState, isPlaying: !prevState.isPlaying };
});
});

const throttledUpdateCurrentTime = throttle(
currentTimeMs => {
setState(prevState => {
return {
...prevState,
current: currentTimeMs,
time: toHuman(currentTimeMs),
};
});
},
// Magic number to throttle progress bar updates so that the playback is smoother.
50
);

playerClient.addListener(
PlayerClientEvent.UPDATE_CURRENT_TIME,
currentTimeMs => throttledUpdateCurrentTime(currentTimeMs)
);

playerClient.addListener(PlayerClientEvent.SESSION_END, () => {
throttledUpdateCurrentTime.cancel();
// TODO(isaiah): Make this smoother
// https://github.com/gravitational/webapps/issues/579
setState(prevState => {
return { ...prevState, current: durationMs };
});
});

return () => {
throttledUpdateCurrentTime.cancel();
playerClient.nuke();
};
}, [playerClient]);

return (
<ProgressBar
{...state}
toggle={() => playerClient.togglePlayPause()}
move={() => {}}
/>
);
};

const useDesktopPlayer = ({
sid,
clusterId,
}: {
sid: string;
clusterId: string;
}) => {
const playerClient = new PlayerClient(
cfg.api.desktopPlaybackWsAddr
.replace(':fqdn', getHostName())
.replace(':clusterId', clusterId)
.replace(':sid', sid)
.replace(':token', getAccessToken())
);
const [playerClient, setPlayerClient] = useState<PlayerClient | null>(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 tdpCliOnPngFrame = (
ctx: CanvasRenderingContext2D,
Expand All @@ -141,20 +111,96 @@ const useDesktopPlayer = ({
canvas: HTMLCanvasElement,
spec: ClientScreenSpec
) => {
const styledPlayer = canvas.parentElement;
const progressBar = styledPlayer.children.namedItem('progressBarDesktop');

const fullWidth = styledPlayer.clientWidth;
const fullHeight = styledPlayer.clientHeight - progressBar.clientHeight;
const originalAspectRatio = spec.width / spec.height;
const currentAspectRatio = fullWidth / fullHeight;

if (originalAspectRatio > currentAspectRatio) {
// Use the full width of the screen and scale the height.
canvas.style.height = `${(fullWidth * spec.height) / spec.width}px`;
} else if (originalAspectRatio < currentAspectRatio) {
// Use the full height of the screen and scale the width.
canvas.style.width = `${(fullHeight * spec.width) / spec.height}px`;
}

canvas.width = spec.width;
canvas.height = spec.height;

setAttempt({ status: 'success' });
};

useEffect(() => {
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.nuke();
};
}
}, [playerClient]);

// 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 tdpCliOnWsClose = () => {
setAttempt(prevAttempt => {
if (prevAttempt.status !== '' && prevAttempt.status !== 'failed') {
return {
status: 'failed',
statusText: 'connection to the server failed for an unknown reason',
};
}
return prevAttempt;
});
};

const tdpCliOnTdpError = (err: Error) => {
setAttempt({
status: 'failed',
statusText: err.message,
});
};

return {
playerClient,
tdpCliOnPngFrame,
tdpCliOnClientScreenSpec,
tdpCliOnWsClose,
tdpCliOnTdpError,
attempt,
};
};

const StyledPlayer = styled.div`
display: flex;
flex-direction: column;
justify-content: center;
width: 100%;
height: 100%;
`;

const DesktopPlayerAlert = styled(Alert)`
align-self: center;
min-width: 450px;
// Overrides StyledPlayer container's justify-content
// https://stackoverflow.com/a/34063808/6277051
margin-bottom: auto;
`;
Loading

0 comments on commit 02e7791

Please sign in to comment.