diff --git a/web/packages/shared/components/DesktopSession/ActionMenu.tsx b/web/packages/shared/components/DesktopSession/ActionMenu.tsx index 9e8e1a7ae469d..0f4596095f0d8 100644 --- a/web/packages/shared/components/DesktopSession/ActionMenu.tsx +++ b/web/packages/shared/components/DesktopSession/ActionMenu.tsx @@ -16,7 +16,6 @@ * along with this program. If not, see . */ -import { Flex } from 'design'; import * as Icons from 'design/Icon'; import { MenuIcon, MenuItem, MenuItemIcon } from 'shared/components/MenuAction'; @@ -25,32 +24,30 @@ export default function ActionMenu(props: Props) { props; return ( - - - {showShareDirectory && ( - - - Share Directory - - )} - - - Send Ctrl+Alt+Del + + {showShareDirectory && ( + + + Share Directory - - - Disconnect - - - + )} + + + Send Ctrl+Alt+Del + + + + Disconnect + + ); } diff --git a/web/packages/shared/components/DesktopSession/DesktopSession.story.tsx b/web/packages/shared/components/DesktopSession/DesktopSession.story.tsx index 356ea21a2dd12..df8088ebf9944 100644 --- a/web/packages/shared/components/DesktopSession/DesktopSession.story.tsx +++ b/web/packages/shared/components/DesktopSession/DesktopSession.story.tsx @@ -71,6 +71,7 @@ const props: DesktopSessionProps = { client: fakeClient(), username: 'user', desktop: 'windows-11', + browserSupportsSharing: true, hasAnotherSession: () => Promise.resolve(false), }; diff --git a/web/packages/shared/components/DesktopSession/DesktopSession.test.tsx b/web/packages/shared/components/DesktopSession/DesktopSession.test.tsx index cf079f8c4fbd3..44ade6977cb7b 100644 --- a/web/packages/shared/components/DesktopSession/DesktopSession.test.tsx +++ b/web/packages/shared/components/DesktopSession/DesktopSession.test.tsx @@ -19,11 +19,11 @@ import { EventEmitter } from 'events'; import { screen } from '@testing-library/react'; +import { act } from 'react'; import { render } from 'design/utils/testing'; import { makeSuccessAttempt } from 'shared/hooks/useAsync'; -import { BrowserFileSystem, TdpClient } from 'shared/libs/tdp'; -import { wait } from 'shared/utils/wait'; +import { BrowserFileSystem, MessageType, TdpClient } from 'shared/libs/tdp'; import { DesktopSession } from './DesktopSession'; @@ -36,6 +36,19 @@ import { TdpTransport } from 'shared/libs/tdp/client'; // Disable WASM in tests. jest.mock('shared/libs/ironrdp/pkg/ironrdp'); +// Matches codec.decodePngFrame. +function encodePngFrame(): ArrayBuffer { + const buffer = new ArrayBuffer(21); + const view = new DataView(buffer); + view.setUint8(0, MessageType.PNG_FRAME); + view.setUint32(1, 0); + view.setUint32(5, 0); + view.setUint32(9, 0); + view.setUint32(13, 0); + view.setUint32(17, 0); + return buffer; +} + const hasNoOtherSession = jest.fn().mockResolvedValue(false); const aclAttempt = makeSuccessAttempt({ clipboardSharingEnabled: true, @@ -46,9 +59,9 @@ const getMockTransport = () => { return { emitTransportError: () => emitter.emit('error', new Error('Could not send bytes')), + emitPngFrameMessage: () => emitter.emit('message', encodePngFrame()), getTransport: async (abortSignal: AbortSignal): Promise => { - abortSignal.onabort = async () => { - await wait(50); + abortSignal.onabort = () => { emitter.emit('complete'); }; return { @@ -70,6 +83,21 @@ const getMockTransport = () => { }; }; +let originalQuery: typeof navigator.permissions.query; + +beforeEach(() => { + originalQuery = navigator.permissions.query; + + navigator.permissions.query = jest.fn().mockResolvedValue({ + state: 'granted', + onchange: null, + }); +}); + +afterEach(() => { + navigator.permissions.query = originalQuery; +}); + test('reconnect button reinitializes the connection', async () => { const transport = getMockTransport(); const tpdClient = new TdpClient( @@ -85,6 +113,7 @@ test('reconnect button reinitializes the connection', async () => { desktop="win-lab" aclAttempt={aclAttempt} hasAnotherSession={hasNoOtherSession} + browserSupportsSharing /> ); @@ -114,3 +143,40 @@ test('reconnect button reinitializes the connection', async () => { // Called 2 times: the first one during reconnecting, the second one after unmounting. expect(tpdClient.shutdown).toHaveBeenCalledTimes(2); }); + +test('ensure sharing remains enabled if the initial desktop connection attempt fails', async () => { + const transport = getMockTransport(); + const tpdClient = new TdpClient( + transport.getTransport, + new BrowserFileSystem() + ); + render( + + ); + + // The session is initializing. + expect(await screen.findByTestId('indicator')).toBeInTheDocument(); + + // An error occurred, the connection has been closed. + transport.emitTransportError(); + + expect( + await screen.findByText('The desktop session is offline.') + ).toBeInTheDocument(); + const reconnect = await screen.findByRole('button', { name: 'Reconnect' }); + + await userEvent.click(reconnect); + // This time the connection succeeded. + await act(() => transport.emitPngFrameMessage()); + + expect(await screen.findByTitle('More actions')).toBeVisible(); + await userEvent.click(screen.getByTitle('More actions')); + expect(await screen.findByText('Share Directory')).toBeVisible(); +}); diff --git a/web/packages/shared/components/DesktopSession/DesktopSession.tsx b/web/packages/shared/components/DesktopSession/DesktopSession.tsx index 0481cb7cc1797..6eaddd101da12 100644 --- a/web/packages/shared/components/DesktopSession/DesktopSession.tsx +++ b/web/packages/shared/components/DesktopSession/DesktopSession.tsx @@ -53,8 +53,6 @@ import { KeyboardHandler } from './KeyboardHandler'; import TopBar from './TopBar'; import useDesktopSession, { clipboardSharingMessage, - defaultClipboardSharingState, - defaultDirectorySharingState, directorySharingPossible, isSharingClipboard, isSharingDirectory, @@ -70,6 +68,8 @@ export interface DesktopSessionProps { clipboardSharingEnabled: boolean; directorySharingEnabled: boolean; }>; + /** Determines if the browser client support directory and clipboard sharing. */ + browserSupportsSharing: boolean; /** * Injects a custom component that overrides other connection states. * Useful for per-session MFA, which differs between Web UI and Connect. @@ -92,19 +92,19 @@ export function DesktopSession({ hasAnotherSession, customConnectionState, keyboardLayout = 0, + browserSupportsSharing, }: DesktopSessionProps) { const { directorySharingState, - setDirectorySharingState, onClipboardData, sendLocalClipboardToRemote, clipboardSharingState, - setClipboardSharingState, + clearSharing, onShareDirectory, alerts, onRemoveAlert, addAlert, - } = useDesktopSession(client, aclAttempt); + } = useDesktopSession(client, aclAttempt, browserSupportsSharing); const [tdpConnectionStatus, setTdpConnectionStatus] = useState({ status: '' }); @@ -145,8 +145,7 @@ export function DesktopSession({ const handleFatalError = useCallback( (error: Error) => { - setDirectorySharingState(defaultDirectorySharingState); - setClipboardSharingState(defaultClipboardSharingState); + clearSharing(); setTdpConnectionStatus({ status: 'disconnected', fromTdpError: error instanceof TdpError, @@ -154,7 +153,7 @@ export function DesktopSession({ }); initialTdpConnectionSucceeded.current = false; }, - [setClipboardSharingState, setDirectorySharingState] + [clearSharing] ); useListener(client.onError, handleFatalError); @@ -362,14 +361,7 @@ export function DesktopSession({ { - setClipboardSharingState(prevState => ({ - ...prevState, - isSharing: false, - })); - setDirectorySharingState(prevState => ({ - ...prevState, - isSharing: false, - })); + clearSharing(); client.shutdown(); }} userHost={`${username} on ${desktop}`} diff --git a/web/packages/shared/components/DesktopSession/TopBar.tsx b/web/packages/shared/components/DesktopSession/TopBar.tsx index b40d841f8091a..4ad1a5718fd31 100644 --- a/web/packages/shared/components/DesktopSession/TopBar.tsx +++ b/web/packages/shared/components/DesktopSession/TopBar.tsx @@ -54,35 +54,28 @@ export default function TopBar(props: Props) { - - {userHost} - + {userHost} {isConnected && ( - - - {latency && } - - - - - - - - + + {latency && } + + + + + + + + }>, + browserSupportsSharing: boolean ) { const encoder = useRef(new TextEncoder()); const latestClipboardDigest = useRef(''); - const [directorySharingState, setDirectorySharingState] = - useState(defaultDirectorySharingState); - - const [clipboardSharingState, setClipboardSharingState] = - useState(defaultClipboardSharingState); + const [directorySharingState, setDirectorySharingState] = useState<{ + directorySelected: boolean; + }>({ directorySelected: false }); + + const [clipboardSharingState, setClipboardSharingState] = useState<{ + readState?: PermissionState; + writeState?: PermissionState; + }>({}); + + const clipboardSharing: ClipboardSharingState = { + ...clipboardSharingState, + browserSupported: browserSupportsSharing, + allowedByAcl: + aclAttempt.status === 'success' && + aclAttempt.data.clipboardSharingEnabled, + }; + const directorySharing: DirectorySharingState = { + ...directorySharingState, + browserSupported: browserSupportsSharing, + allowedByAcl: + aclAttempt.status === 'success' && + aclAttempt.data.directorySharingEnabled, + }; useEffect(() => { const clearReadListenerPromise = initClipboardPermissionTracking( @@ -68,21 +87,6 @@ export default function useDesktopSession( }; }, []); - //TODO(gzdunek): This is workaround for synchronizing *sharingState with aclAttempt. - //Refactor clipboard and directory sharing so that we won't need allowedByAcl fields in state. - useEffect(() => { - if (aclAttempt.status === 'success') { - setClipboardSharingState(prevState => ({ - ...prevState, - allowedByAcl: aclAttempt.data.clipboardSharingEnabled, - })); - setDirectorySharingState(prevState => ({ - ...prevState, - allowedByAcl: aclAttempt.data.directorySharingEnabled, - })); - } - }, [aclAttempt]); - const [alerts, setAlerts] = useState([]); const onRemoveAlert = (id: string) => { setAlerts(prevState => prevState.filter(alert => alert.id !== id)); @@ -95,7 +99,7 @@ export default function useDesktopSession( }, []); async function sendLocalClipboardToRemote() { - if (!(await sysClipboardGuard(clipboardSharingState, 'read'))) { + if (!(await sysClipboardGuard(clipboardSharing, 'read'))) { return; } const text = await navigator.clipboard.readText(); @@ -111,7 +115,7 @@ export default function useDesktopSession( async function onClipboardData(clipboardData: ClipboardData) { if ( clipboardData.data && - (await sysClipboardGuard(clipboardSharingState, 'write')) + (await sysClipboardGuard(clipboardSharing, 'write')) ) { await navigator.clipboard.writeText(clipboardData.data); latestClipboardDigest.current = await sha256Digest( @@ -124,15 +128,13 @@ export default function useDesktopSession( const onShareDirectory = async () => { try { await tdpClient.shareDirectory(); - setDirectorySharingState(prevState => ({ - ...prevState, + setDirectorySharingState({ directorySelected: true, - })); + }); } catch (e) { - setDirectorySharingState(prevState => ({ - ...prevState, + setDirectorySharingState({ directorySelected: false, - })); + }); addAlert({ severity: 'warn', content: { @@ -143,11 +145,17 @@ export default function useDesktopSession( } }; + /** Clears sharing state. */ + const clearSharing = useCallback(() => { + setDirectorySharingState({ + directorySelected: false, + }); + }, []); + return { - clipboardSharingState, - setClipboardSharingState, - directorySharingState, - setDirectorySharingState, + clipboardSharingState: clipboardSharing, + directorySharingState: directorySharing, + clearSharing, onShareDirectory, alerts, onRemoveAlert, @@ -311,15 +319,6 @@ export function isSharingDirectory( ); } -export const defaultDirectorySharingState: DirectorySharingState = { - browserSupported: navigator.userAgent.includes('Chrome'), - directorySelected: false, -}; - -export const defaultClipboardSharingState: ClipboardSharingState = { - browserSupported: navigator.userAgent.includes('Chrome'), -}; - /** * To be called before any system clipboard read/write operation. */ diff --git a/web/packages/teleport/src/DesktopSession/DesktopSession.tsx b/web/packages/teleport/src/DesktopSession/DesktopSession.tsx index e24008757e084..e497ac444ab4b 100644 --- a/web/packages/teleport/src/DesktopSession/DesktopSession.tsx +++ b/web/packages/teleport/src/DesktopSession/DesktopSession.tsx @@ -131,6 +131,7 @@ export function DesktopSession() { } }} aclAttempt={aclAttempt} + browserSupportsSharing={navigator.userAgent.includes('Chrome')} hasAnotherSession={hasAnotherSession} keyboardLayout={preferences.keyboardLayout} /> diff --git a/web/packages/teleterm/src/ui/DocumentDesktopSession/DocumentDesktopSession.tsx b/web/packages/teleterm/src/ui/DocumentDesktopSession/DocumentDesktopSession.tsx index 9cd24ee1e92f0..67225c8829752 100644 --- a/web/packages/teleterm/src/ui/DocumentDesktopSession/DocumentDesktopSession.tsx +++ b/web/packages/teleterm/src/ui/DocumentDesktopSession/DocumentDesktopSession.tsx @@ -130,6 +130,7 @@ export function DocumentDesktopSession(props: { client={client} username={login} aclAttempt={acl} + browserSupportsSharing /> ); }