diff --git a/packages/teleport/package.json b/packages/teleport/package.json index c1c931f35..144eaf819 100644 --- a/packages/teleport/package.json +++ b/packages/teleport/package.json @@ -24,7 +24,8 @@ }, "devDependencies": { "@gravitational/build": "^1.0.0", - "@types/wicg-native-file-system": "^2020.6.0", - "jest-canvas-mock": "^2.3.1" + "@types/wicg-file-system-access": "^2020.9.5", + "jest-canvas-mock": "^2.3.1", + "ts-loader": "^9.3.1" } } diff --git a/packages/teleport/src/DesktopSession/DesktopSession.story.test.tsx b/packages/teleport/src/DesktopSession/DesktopSession.story.test.tsx index 65da83b24..d23a5dcc3 100644 --- a/packages/teleport/src/DesktopSession/DesktopSession.story.test.tsx +++ b/packages/teleport/src/DesktopSession/DesktopSession.story.test.tsx @@ -10,6 +10,7 @@ import { ClipboardError, UnintendedDisconnect, WebAuthnPrompt, + DismissibleError, } from './DesktopSession.story'; test('connected settings false', () => { @@ -28,26 +29,31 @@ test('disconnected', () => { }); test('fetch error', () => { - const { container } = render(); - expect(container).toMatchSnapshot(); + const { getByTestId } = render(); + expect(getByTestId('Modal')).toMatchSnapshot(); }); test('connection error', () => { - const { container } = render(); - expect(container).toMatchSnapshot(); + const { getByTestId } = render(); + expect(getByTestId('Modal')).toMatchSnapshot(); }); test('clipboard error', () => { - const { container } = render(); - expect(container).toMatchSnapshot(); + const { getByTestId } = render(); + expect(getByTestId('Modal')).toMatchSnapshot(); }); test('unintended disconnect', () => { - const { container } = render(); - expect(container).toMatchSnapshot(); + const { getByTestId } = render(); + expect(getByTestId('Modal')).toMatchSnapshot(); +}); + +test('dismissible error', () => { + const { getByTestId } = render(); + expect(getByTestId('Modal')).toMatchSnapshot(); }); test('webauthn prompt', () => { - const { container } = render(); - expect(container).toMatchSnapshot(); + const { getByTestId } = render(); + expect(getByTestId('Modal')).toMatchSnapshot(); }); diff --git a/packages/teleport/src/DesktopSession/DesktopSession.story.tsx b/packages/teleport/src/DesktopSession/DesktopSession.story.tsx index 2dbd461f6..4d1939d72 100644 --- a/packages/teleport/src/DesktopSession/DesktopSession.story.tsx +++ b/packages/teleport/src/DesktopSession/DesktopSession.story.tsx @@ -54,9 +54,12 @@ const props: State = { disconnected: false, setDisconnected: () => {}, setClipboardState: () => {}, - canShareDirectory: true, - isSharingDirectory: false, - setIsSharingDirectory: () => {}, + directorySharingState: { + canShare: true, + isSharing: false, + browserError: false, + }, + setDirectorySharingState: () => {}, onPngFrame: () => {}, onTdpError: () => {}, onKeyDown: () => {}, @@ -68,6 +71,7 @@ const props: State = { onContextMenu: () => false, onMouseEnter: () => {}, onClipboardData: () => {}, + setTdpConnection: () => {}, windowOnFocus: () => {}, webauthn: { errorText: '', @@ -75,6 +79,7 @@ const props: State = { authenticate: () => {}, setState: () => {}, }, + isUsingChrome: true, }; export const Processing = () => ( @@ -137,7 +142,11 @@ export const ConnectedSettingsTrue = () => { permission: { state: 'granted' }, errorText: '', }} - isSharingDirectory={true} + directorySharingState={{ + canShare: true, + isSharing: true, + browserError: false, + }} onPngFrame={(ctx: CanvasRenderingContext2D) => { fillGray(ctx.canvas); }} @@ -193,6 +202,16 @@ export const ClipboardError = () => ( /> ); +export const DismissibleError = () => ( + +); + export const UnintendedDisconnect = () => ( { + // The following state-setting calls will + // cause the useEffect below to calculate the + // errorDialog state. - if (alertText) { + setTdpConnection(prevState => { + if (prevState.status === '') { + // If prevState.status was a non-fatal error, + // we assume that the TDP connection remains open. + return { status: 'success' }; + } + return prevState; + }); + + setDirectorySharingState(prevState => ({ + ...prevState, + browserError: false, + })); + }; + + const computeErrorDialog = () => { + const clipboardError = clipboardState.enabled && clipboardState.errorText; + + // Websocket is closed but we haven't + // closed it on purpose or registered a fatal tdp error. + const unknownConnectionError = + wsConnection === 'closed' && + !disconnected && + (tdpConnection.status === 'success' || tdpConnection.status === ''); + + let errorText = ''; + if (fetchAttempt.status === 'failed') { + errorText = fetchAttempt.statusText || 'fetch attempt failed'; + } else if (tdpConnection.status === 'failed') { + errorText = tdpConnection.statusText || 'tdp connection failed'; + } else if (tdpConnection.status === '') { + errorText = tdpConnection.statusText || 'encountered a non-fatal error'; + } else if (clipboardError) { + errorText = clipboardState.errorText || 'clipboard sharing failed'; + } else if (unknownConnectionError) { + errorText = 'Session disconnected for an unknown reason.'; + } else if (directorySharingState.browserError) { + errorText = + 'Your user role supports directory sharing over desktop access, \ + however this feature is only available by default on some Chromium \ + based browsers like Google Chrome or Microsoft Edge. Brave users can \ + use the feature by navigating to brave://flags/#file-system-access-api \ + and selecting "Enable". Please switch to a supported browser.'; + } + const open = errorText !== ''; + const fatal = !( + tdpConnection.status === '' || directorySharingState.browserError + ); + + return { open, text: errorText, fatal }; + }; + + const errorDialog = computeErrorDialog(); + + if (errorDialog.open) { return ( - + ({ width: '484px' })} + onClose={onDialogClose} + open={errorDialog.open} + > + + {errorDialog.fatal && Fatal Error} + {!errorDialog.fatal && ( + Unsupported Action + )} + + + {errorDialog.fatal && ( + <> + {errorDialog.text}} /> + Refresh the page to try again. + + )} + + {!errorDialog.fatal && ( + + )} + + + {!errorDialog.fatal && ( + + Dismiss + + )} + {errorDialog.fatal && ( + { + window.location.reload(); + }} + > + Refresh + + )} + + ); } @@ -108,9 +200,8 @@ function Session(props: PropsWithChildren) { hostname, clipboardState, setClipboardState, - canShareDirectory, - isSharingDirectory, - setIsSharingDirectory, + directorySharingState, + setDirectorySharingState, onPngFrame, onClipboardData, onTdpError, @@ -137,22 +228,35 @@ function Session(props: PropsWithChildren) { const showCanvas = fetchAttempt.status === 'success' && - tdpConnection.status === 'success' && + (tdpConnection.status === 'success' || tdpConnection.status === '') && wsConnection === 'open' && !disconnected && clipboardSuccess; const onShareDirectory = () => { - window - .showDirectoryPicker() - .then(sharedDirHandle => { - setIsSharingDirectory(true); - tdpClient.sharedDirectory = sharedDirHandle; - tdpClient.sendSharedDirectoryAnnounce(); - }) - .catch(() => { - setIsSharingDirectory(false); - }); + try { + window + .showDirectoryPicker() + .then(sharedDirHandle => { + setDirectorySharingState(prevState => ({ + ...prevState, + isSharing: true, + })); + tdpClient.addSharedDirectory(sharedDirHandle); + tdpClient.sendSharedDirectoryAnnounce(); + }) + .catch(() => { + setDirectorySharingState(prevState => ({ + ...prevState, + isSharing: false, + })); + }); + } catch (e) { + setDirectorySharingState(prevState => ({ + ...prevState, + browserError: true, + })); + } }; return ( @@ -164,13 +268,16 @@ function Session(props: PropsWithChildren) { ...prevState, enabled: false, })); - setIsSharingDirectory(false); + setDirectorySharingState(prevState => ({ + ...prevState, + isSharing: false, + })); tdpClient.nuke(); }} userHost={`${username}@${hostname}`} clipboardSharingEnabled={clipboardSharingActive} - canShareDirectory={canShareDirectory} - isSharingDirectory={isSharingDirectory} + canShareDirectory={directorySharingState.canShare} + isSharingDirectory={directorySharingState.isSharing} onShareDirectory={onShareDirectory} /> @@ -220,8 +327,3 @@ function Session(props: PropsWithChildren) { ); } - -const DesktopSessionAlert = styled(Alert)` - align-self: center; - min-width: 450px; -`; diff --git a/packages/teleport/src/DesktopSession/__snapshots__/DesktopSession.story.test.tsx.snap b/packages/teleport/src/DesktopSession/__snapshots__/DesktopSession.story.test.tsx.snap index c6191c76c..b3da4a124 100644 --- a/packages/teleport/src/DesktopSession/__snapshots__/DesktopSession.story.test.tsx.snap +++ b/packages/teleport/src/DesktopSession/__snapshots__/DesktopSession.story.test.tsx.snap @@ -1,194 +1,197 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`clipboard error 1`] = ` -.c6 { - display: inline-block; - transition: color 0.3s; - padding-right: 16px; +.c7 { + display: flex; + align-items: center; + justify-content: center; + border-radius: 2px; + box-sizing: border-box; + box-shadow: 0 1px 4px rgba(0,0,0,0.24); + margin: 0 0 24px 0; + min-height: 40px; + padding: 8px 16px; + overflow: auto; + word-break: break-word; + line-height: 1.5; + background: #f50057; color: #FFFFFF; } -.c10 { - display: inline-block; - transition: color 0.3s; +.c7 a { color: #FFFFFF; } .c9 { + line-height: 1.5; + margin: 0; + display: inline-flex; + justify-content: center; align-items: center; + box-sizing: border-box; border: none; + border-radius: 4px; cursor: pointer; - display: flex; + font-family: inherit; + font-weight: 600; outline: none; - border-radius: 50%; - overflow: visible; - justify-content: center; + position: relative; text-align: center; - flex: 0 0 auto; - background: transparent; - color: inherit; + text-decoration: none; + text-transform: uppercase; transition: all 0.3s; -webkit-font-smoothing: antialiased; + background: #222C59; + color: rgba(255,255,255,0.87); + min-height: 40px; font-size: 12px; - height: 24px; - width: 24px; - margin-left: 24px; - color: rgba(255,255,255,0.56); + padding: 0px 40px; + width: 30%; } -.c9 .c5 { - color: inherit; +.c9:active { + opacity: 0.56; } -.c9:disabled { - color: rgba(255,255,255,0.3); +.c9:hover, +.c9:focus { + background: #2C3A73; } .c9:disabled { + background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.3); } -.c9:hover, -.c9:focus { - background: rgba(255,255,255,0.1); -} - -.c2 { +.c5 { overflow: hidden; text-overflow: ellipsis; + font-weight: 300; + font-size: 22px; + line-height: 32px; + text-transform: uppercase; margin: 0px; - padding-left: 16px; - padding-right: 16px; + color: rgba(255,255,255,0.87); +} + +.c1 { + z-index: -1; + position: fixed; + right: 0; + bottom: 0; + top: 0; + left: 0; + background-color: rgba(0,0,0,0.5); + opacity: 1; + touch-action: none; } .c0 { - box-sizing: border-box; - display: flex; - flex-direction: column; + position: fixed; + z-index: 1200; + right: 0; + bottom: 0; + top: 0; + left: 0; } -.c1 { - box-sizing: border-box; - height: 40px; - background-color: #000; - flex: 0 0 auto; +.c2 { + height: 100%; + outline: none; + color: black; display: flex; align-items: center; - flex-direction: row; + justify-content: center; + opacity: 1; + will-change: opacity; + transition: opacity 225ms cubic-bezier(0.4,0,0.2,1) 0ms; } .c3 { - box-sizing: border-box; - padding-left: 16px; - padding-right: 16px; + padding: 32px; + padding-top: 24px; + background: #1C254D; + color: rgba(255,255,255,0.87); + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0,0,0,0.24); display: flex; + flex-direction: column; + position: relative; + overflow-y: auto; + max-height: calc(100% - 96px); + width: 484px; } .c4 { box-sizing: border-box; + margin-bottom: 16px; + min-height: 32px; display: flex; align-items: center; } -.c8 { - font-weight: 600; - font-size: 18px; - align-self: 'center'; -} - -.c7 { - font-weight: 600; - font-size: 22px; - align-self: 'center'; -} - -.c11 { - display: flex; - align-items: center; - justify-content: center; - border-radius: 2px; +.c6 { box-sizing: border-box; - box-shadow: 0 1px 4px rgba(0,0,0,0.24); - margin: 0 0 24px 0; - min-height: 40px; - padding: 8px 16px; - overflow: auto; - word-break: break-word; - line-height: 1.5; - margin-left: 72px; - margin-right: 72px; - margin-top: 8px; - margin-bottom: 8px; - background: #f50057; - color: #FFFFFF; - align-self: center; - min-width: 450px; + margin-bottom: 32px; + flex: 1; + display: flex; + flex-direction: column; } -.c11 a { - color: #FFFFFF; +.c8 { + box-sizing: border-box; } -
+
`; @@ -514,194 +517,197 @@ exports[`connected settings true 1`] = ` `; exports[`connection error 1`] = ` -.c6 { - display: inline-block; - transition: color 0.3s; - padding-right: 16px; +.c7 { + display: flex; + align-items: center; + justify-content: center; + border-radius: 2px; + box-sizing: border-box; + box-shadow: 0 1px 4px rgba(0,0,0,0.24); + margin: 0 0 24px 0; + min-height: 40px; + padding: 8px 16px; + overflow: auto; + word-break: break-word; + line-height: 1.5; + background: #f50057; color: #FFFFFF; } -.c10 { - display: inline-block; - transition: color 0.3s; +.c7 a { color: #FFFFFF; } .c9 { + line-height: 1.5; + margin: 0; + display: inline-flex; + justify-content: center; align-items: center; + box-sizing: border-box; border: none; + border-radius: 4px; cursor: pointer; - display: flex; + font-family: inherit; + font-weight: 600; outline: none; - border-radius: 50%; - overflow: visible; - justify-content: center; + position: relative; text-align: center; - flex: 0 0 auto; - background: transparent; - color: inherit; + text-decoration: none; + text-transform: uppercase; transition: all 0.3s; -webkit-font-smoothing: antialiased; + background: #222C59; + color: rgba(255,255,255,0.87); + min-height: 40px; font-size: 12px; - height: 24px; - width: 24px; - margin-left: 24px; - color: rgba(255,255,255,0.56); + padding: 0px 40px; + width: 30%; } -.c9 .c5 { - color: inherit; +.c9:active { + opacity: 0.56; } -.c9:disabled { - color: rgba(255,255,255,0.3); +.c9:hover, +.c9:focus { + background: #2C3A73; } .c9:disabled { + background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.3); } -.c9:hover, -.c9:focus { - background: rgba(255,255,255,0.1); -} - -.c2 { +.c5 { overflow: hidden; text-overflow: ellipsis; + font-weight: 300; + font-size: 22px; + line-height: 32px; + text-transform: uppercase; margin: 0px; - padding-left: 16px; - padding-right: 16px; + color: rgba(255,255,255,0.87); +} + +.c1 { + z-index: -1; + position: fixed; + right: 0; + bottom: 0; + top: 0; + left: 0; + background-color: rgba(0,0,0,0.5); + opacity: 1; + touch-action: none; } .c0 { - box-sizing: border-box; - display: flex; - flex-direction: column; + position: fixed; + z-index: 1200; + right: 0; + bottom: 0; + top: 0; + left: 0; } -.c1 { - box-sizing: border-box; - height: 40px; - background-color: #000; - flex: 0 0 auto; +.c2 { + height: 100%; + outline: none; + color: black; display: flex; align-items: center; - flex-direction: row; + justify-content: center; + opacity: 1; + will-change: opacity; + transition: opacity 225ms cubic-bezier(0.4,0,0.2,1) 0ms; } .c3 { - box-sizing: border-box; - padding-left: 16px; - padding-right: 16px; + padding: 32px; + padding-top: 24px; + background: #1C254D; + color: rgba(255,255,255,0.87); + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0,0,0,0.24); display: flex; + flex-direction: column; + position: relative; + overflow-y: auto; + max-height: calc(100% - 96px); + width: 484px; } .c4 { box-sizing: border-box; + margin-bottom: 16px; + min-height: 32px; display: flex; align-items: center; } -.c8 { - font-weight: 600; - font-size: 18px; - align-self: 'center'; +.c6 { + box-sizing: border-box; + margin-bottom: 32px; + flex: 1; + display: flex; + flex-direction: column; } -.c7 { - font-weight: 600; - font-size: 22px; - align-self: 'center'; +.c8 { + box-sizing: border-box; } -.c11 { - display: flex; - align-items: center; - justify-content: center; - border-radius: 2px; - box-sizing: border-box; - box-shadow: 0 1px 4px rgba(0,0,0,0.24); - margin: 0 0 24px 0; - min-height: 40px; - padding: 8px 16px; - overflow: auto; - word-break: break-word; - line-height: 1.5; - margin-left: 72px; - margin-right: 72px; - margin-top: 8px; - margin-bottom: 8px; - background: #f50057; - color: #FFFFFF; - align-self: center; - min-width: 450px; -} - -.c11 a { - color: #FFFFFF; -} - -
+
`; @@ -887,302 +893,401 @@ exports[`disconnected 1`] = `
`; -exports[`fetch error 1`] = ` -.c6 { - display: inline-block; - transition: color 0.3s; - padding-right: 16px; +exports[`dismissible error 1`] = ` +.c7 { + display: flex; + align-items: center; + justify-content: center; + border-radius: 2px; + box-sizing: border-box; + box-shadow: 0 1px 4px rgba(0,0,0,0.24); + margin: 0 0 24px 0; + min-height: 40px; + padding: 8px 16px; + overflow: auto; + word-break: break-word; + line-height: 1.5; + margin-top: 8px; + margin-bottom: 8px; + background: #ff9100; color: #FFFFFF; } -.c10 { - display: inline-block; - transition: color 0.3s; +.c7 a { color: #FFFFFF; } .c9 { + line-height: 1.5; + margin: 0; + display: inline-flex; + justify-content: center; align-items: center; + box-sizing: border-box; border: none; + border-radius: 4px; cursor: pointer; - display: flex; + font-family: inherit; + font-weight: 600; outline: none; - border-radius: 50%; - overflow: visible; - justify-content: center; + position: relative; text-align: center; - flex: 0 0 auto; - background: transparent; - color: inherit; + text-decoration: none; + text-transform: uppercase; transition: all 0.3s; -webkit-font-smoothing: antialiased; + background: #222C59; + color: rgba(255,255,255,0.87); + min-height: 40px; font-size: 12px; - height: 24px; - width: 24px; - margin-left: 24px; - color: rgba(255,255,255,0.56); + padding: 0px 40px; + width: 30%; } -.c9 .c5 { - color: inherit; +.c9:active { + opacity: 0.56; } -.c9:disabled { - color: rgba(255,255,255,0.3); +.c9:hover, +.c9:focus { + background: #2C3A73; } .c9:disabled { + background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.3); } -.c9:hover, -.c9:focus { - background: rgba(255,255,255,0.1); -} - -.c2 { +.c5 { overflow: hidden; text-overflow: ellipsis; + font-weight: 300; + font-size: 22px; + line-height: 32px; + text-transform: uppercase; margin: 0px; - padding-left: 16px; - padding-right: 16px; + color: rgba(255,255,255,0.87); +} + +.c1 { + z-index: -1; + position: fixed; + right: 0; + bottom: 0; + top: 0; + left: 0; + background-color: rgba(0,0,0,0.5); + opacity: 1; + touch-action: none; } .c0 { - box-sizing: border-box; - display: flex; - flex-direction: column; + position: fixed; + z-index: 1200; + right: 0; + bottom: 0; + top: 0; + left: 0; } -.c1 { - box-sizing: border-box; - height: 40px; - background-color: #000; - flex: 0 0 auto; +.c2 { + height: 100%; + outline: none; + color: black; display: flex; align-items: center; - flex-direction: row; + justify-content: center; + opacity: 1; + will-change: opacity; + transition: opacity 225ms cubic-bezier(0.4,0,0.2,1) 0ms; } .c3 { - box-sizing: border-box; - padding-left: 16px; - padding-right: 16px; + padding: 32px; + padding-top: 24px; + background: #1C254D; + color: rgba(255,255,255,0.87); + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0,0,0,0.24); display: flex; + flex-direction: column; + position: relative; + overflow-y: auto; + max-height: calc(100% - 96px); + width: 484px; } .c4 { box-sizing: border-box; + margin-bottom: 16px; + min-height: 32px; display: flex; align-items: center; } -.c8 { - font-weight: 600; - font-size: 18px; - align-self: 'center'; -} - -.c7 { - font-weight: 600; - font-size: 22px; - align-self: 'center'; -} - -.c11 { - display: flex; - align-items: center; - justify-content: center; - border-radius: 2px; +.c6 { box-sizing: border-box; - box-shadow: 0 1px 4px rgba(0,0,0,0.24); - margin: 0 0 24px 0; - min-height: 40px; - padding: 8px 16px; - overflow: auto; - word-break: break-word; - line-height: 1.5; - margin-left: 72px; - margin-right: 72px; - margin-top: 8px; - margin-bottom: 8px; - background: #f50057; - color: #FFFFFF; - align-self: center; - min-width: 450px; + margin-bottom: 32px; + flex: 1; + display: flex; + flex-direction: column; } -.c11 a { - color: #FFFFFF; +.c8 { + box-sizing: border-box; } -
+
`; -exports[`unintended disconnect 1`] = ` -.c6 { - display: inline-block; - transition: color 0.3s; - padding-right: 16px; +exports[`fetch error 1`] = ` +.c7 { + display: flex; + align-items: center; + justify-content: center; + border-radius: 2px; + box-sizing: border-box; + box-shadow: 0 1px 4px rgba(0,0,0,0.24); + margin: 0 0 24px 0; + min-height: 40px; + padding: 8px 16px; + overflow: auto; + word-break: break-word; + line-height: 1.5; + background: #f50057; color: #FFFFFF; } -.c10 { - display: inline-block; - transition: color 0.3s; +.c7 a { color: #FFFFFF; } .c9 { + line-height: 1.5; + margin: 0; + display: inline-flex; + justify-content: center; align-items: center; + box-sizing: border-box; border: none; + border-radius: 4px; cursor: pointer; - display: flex; + font-family: inherit; + font-weight: 600; outline: none; - border-radius: 50%; - overflow: visible; - justify-content: center; + position: relative; text-align: center; - flex: 0 0 auto; - background: transparent; - color: inherit; + text-decoration: none; + text-transform: uppercase; transition: all 0.3s; -webkit-font-smoothing: antialiased; + background: #222C59; + color: rgba(255,255,255,0.87); + min-height: 40px; font-size: 12px; - height: 24px; - width: 24px; - margin-left: 24px; - color: rgba(255,255,255,0.56); + padding: 0px 40px; + width: 30%; } -.c9 .c5 { - color: inherit; +.c9:active { + opacity: 0.56; } -.c9:disabled { - color: rgba(255,255,255,0.3); +.c9:hover, +.c9:focus { + background: #2C3A73; } .c9:disabled { + background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.3); } -.c9:hover, -.c9:focus { - background: rgba(255,255,255,0.1); -} - -.c2 { +.c5 { overflow: hidden; text-overflow: ellipsis; + font-weight: 300; + font-size: 22px; + line-height: 32px; + text-transform: uppercase; margin: 0px; - padding-left: 16px; - padding-right: 16px; + color: rgba(255,255,255,0.87); +} + +.c1 { + z-index: -1; + position: fixed; + right: 0; + bottom: 0; + top: 0; + left: 0; + background-color: rgba(0,0,0,0.5); + opacity: 1; + touch-action: none; } .c0 { - box-sizing: border-box; - display: flex; - flex-direction: column; + position: fixed; + z-index: 1200; + right: 0; + bottom: 0; + top: 0; + left: 0; } -.c1 { - box-sizing: border-box; - height: 40px; - background-color: #000; - flex: 0 0 auto; +.c2 { + height: 100%; + outline: none; + color: black; display: flex; align-items: center; - flex-direction: row; + justify-content: center; + opacity: 1; + will-change: opacity; + transition: opacity 225ms cubic-bezier(0.4,0,0.2,1) 0ms; } .c3 { - box-sizing: border-box; - padding-left: 16px; - padding-right: 16px; + padding: 32px; + padding-top: 24px; + background: #1C254D; + color: rgba(255,255,255,0.87); + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0,0,0,0.24); display: flex; + flex-direction: column; + position: relative; + overflow-y: auto; + max-height: calc(100% - 96px); + width: 484px; } .c4 { box-sizing: border-box; + margin-bottom: 16px; + min-height: 32px; display: flex; align-items: center; } -.c8 { - font-weight: 600; - font-size: 18px; - align-self: 'center'; +.c6 { + box-sizing: border-box; + margin-bottom: 32px; + flex: 1; + display: flex; + flex-direction: column; } -.c7 { - font-weight: 600; - font-size: 22px; - align-self: 'center'; +.c8 { + box-sizing: border-box; } -.c11 { +
+ +`; + +exports[`unintended disconnect 1`] = ` +.c7 { display: flex; align-items: center; justify-content: center; @@ -1195,249 +1300,418 @@ exports[`unintended disconnect 1`] = ` overflow: auto; word-break: break-word; line-height: 1.5; - margin-left: 72px; - margin-right: 72px; - margin-top: 8px; - margin-bottom: 8px; background: #f50057; color: #FFFFFF; - align-self: center; - min-width: 450px; } -.c11 a { +.c7 a { color: #FFFFFF; } -
+.c9 { + line-height: 1.5; + margin: 0; + display: inline-flex; + justify-content: center; + align-items: center; + box-sizing: border-box; + border: none; + border-radius: 4px; + cursor: pointer; + font-family: inherit; + font-weight: 600; + outline: none; + position: relative; + text-align: center; + text-decoration: none; + text-transform: uppercase; + transition: all 0.3s; + -webkit-font-smoothing: antialiased; + background: #222C59; + color: rgba(255,255,255,0.87); + min-height: 40px; + font-size: 12px; + padding: 0px 40px; + width: 30%; +} + +.c9:active { + opacity: 0.56; +} + +.c9:hover, +.c9:focus { + background: #2C3A73; +} + +.c9:disabled { + background: rgba(255,255,255,0.12); + color: rgba(255,255,255,0.3); +} + +.c5 { + overflow: hidden; + text-overflow: ellipsis; + font-weight: 300; + font-size: 22px; + line-height: 32px; + text-transform: uppercase; + margin: 0px; + color: rgba(255,255,255,0.87); +} + +.c1 { + z-index: -1; + position: fixed; + right: 0; + bottom: 0; + top: 0; + left: 0; + background-color: rgba(0,0,0,0.5); + opacity: 1; + touch-action: none; +} + +.c0 { + position: fixed; + z-index: 1200; + right: 0; + bottom: 0; + top: 0; + left: 0; +} + +.c2 { + height: 100%; + outline: none; + color: black; + display: flex; + align-items: center; + justify-content: center; + opacity: 1; + will-change: opacity; + transition: opacity 225ms cubic-bezier(0.4,0,0.2,1) 0ms; +} + +.c3 { + padding: 32px; + padding-top: 24px; + background: #1C254D; + color: rgba(255,255,255,0.87); + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0,0,0,0.24); + display: flex; + flex-direction: column; + position: relative; + overflow-y: auto; + max-height: calc(100% - 96px); + width: 484px; +} + +.c4 { + box-sizing: border-box; + margin-bottom: 16px; + min-height: 32px; + display: flex; + align-items: center; +} + +.c6 { + box-sizing: border-box; + margin-bottom: 32px; + flex: 1; + display: flex; + flex-direction: column; +} + +.c8 { + box-sizing: border-box; +} + +
`; exports[`webauthn prompt 1`] = ` -.c11 { +.c9 { + line-height: 1.5; + margin: 0; + display: inline-flex; + justify-content: center; + align-items: center; box-sizing: border-box; - margin: 72px; + border: none; + border-radius: 4px; + cursor: pointer; + font-family: inherit; + font-weight: 600; + outline: none; + position: relative; text-align: center; + text-decoration: none; + text-transform: uppercase; + transition: all 0.3s; + -webkit-font-smoothing: antialiased; + background: #512FC9; + color: rgba(255,255,255,0.87); + min-height: 32px; + font-size: 12px; + padding: 0px 24px; + margin-right: 16px; + width: 130px; } -.c6 { - display: inline-block; - transition: color 0.3s; - padding-right: 16px; - color: #FFFFFF; +.c9:active { + opacity: 0.56; } -.c10 { - display: inline-block; - transition: color 0.3s; - color: #FFFFFF; +.c9:hover, +.c9:focus { + background: #651FFF; } -.c9 { +.c9:active { + background: #354AA4; +} + +.c9:disabled { + background: rgba(255,255,255,0.12); + color: rgba(255,255,255,0.3); +} + +.c10 { + line-height: 1.5; + margin: 0; + display: inline-flex; + justify-content: center; align-items: center; + box-sizing: border-box; border: none; + border-radius: 4px; cursor: pointer; - display: flex; + font-family: inherit; + font-weight: 600; outline: none; - border-radius: 50%; - overflow: visible; - justify-content: center; + position: relative; text-align: center; - flex: 0 0 auto; - background: transparent; - color: inherit; + text-decoration: none; + text-transform: uppercase; transition: all 0.3s; -webkit-font-smoothing: antialiased; + background: #222C59; + color: rgba(255,255,255,0.87); + min-height: 32px; font-size: 12px; - height: 24px; - width: 24px; - margin-left: 24px; - color: rgba(255,255,255,0.56); + padding: 0px 24px; } -.c9 .c5 { - color: inherit; +.c10:active { + opacity: 0.56; } -.c9:disabled { - color: rgba(255,255,255,0.3); +.c10:hover, +.c10:focus { + background: #2C3A73; } -.c9:disabled { +.c10:disabled { + background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.3); } -.c9:hover, -.c9:focus { - background: rgba(255,255,255,0.1); +.c5 { + overflow: hidden; + text-overflow: ellipsis; + font-weight: 300; + font-size: 22px; + line-height: 32px; + text-transform: uppercase; + margin: 0px; + color: rgba(255,255,255,0.87); + text-align: center; } -.c2 { +.c7 { overflow: hidden; text-overflow: ellipsis; margin: 0px; - padding-left: 16px; - padding-right: 16px; + text-align: center; +} + +.c1 { + z-index: -1; + position: fixed; + right: 0; + bottom: 0; + top: 0; + left: 0; + background-color: rgba(0,0,0,0.5); + opacity: 1; + touch-action: none; } .c0 { - box-sizing: border-box; - display: flex; - flex-direction: column; + position: fixed; + z-index: 1200; + right: 0; + bottom: 0; + top: 0; + left: 0; } -.c1 { - box-sizing: border-box; - height: 40px; - background-color: #000; - flex: 0 0 auto; +.c2 { + height: 100%; + outline: none; + color: black; display: flex; align-items: center; - flex-direction: row; + justify-content: center; + opacity: 1; + will-change: opacity; + transition: opacity 225ms cubic-bezier(0.4,0,0.2,1) 0ms; } .c3 { - box-sizing: border-box; - padding-left: 16px; - padding-right: 16px; + padding: 32px; + padding-top: 24px; + background: #1C254D; + color: rgba(255,255,255,0.87); + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0,0,0,0.24); display: flex; + flex-direction: column; + position: relative; + overflow-y: auto; + max-height: calc(100% - 96px); + width: 400px; } .c4 { box-sizing: border-box; + margin-bottom: 16px; + min-height: 32px; display: flex; align-items: center; } -.c8 { - font-weight: 600; - font-size: 18px; - align-self: 'center'; +.c6 { + box-sizing: border-box; + margin-bottom: 40px; + flex: 1; + display: flex; + flex-direction: column; } -.c7 { - font-weight: 600; - font-size: 22px; - align-self: 'center'; +.c8 { + box-sizing: border-box; + text-align: center; } -
+
`; diff --git a/packages/teleport/src/DesktopSession/useDesktopSession.tsx b/packages/teleport/src/DesktopSession/useDesktopSession.tsx index 399ba2ffb..99585ea5d 100644 --- a/packages/teleport/src/DesktopSession/useDesktopSession.tsx +++ b/packages/teleport/src/DesktopSession/useDesktopSession.tsx @@ -28,10 +28,10 @@ export default function useDesktopSession() { const { attempt: fetchAttempt, run } = useAttempt('processing'); // tdpConnection tracks the state of the tdpClient's TDP connection - // tdpConnection.status === // - 'processing' at first // - 'success' once the first TdpClientEvent.IMAGE_FRAGMENT is seen - // - 'failed' if a TdpClientEvent.TDP_ERROR is encountered + // - 'failed' if a fatal error is encountered + // - '' if a non-fatal error is encountered const { attempt: tdpConnection, setAttempt: setTdpConnection } = useAttempt('processing'); @@ -43,8 +43,11 @@ export default function useDesktopSession() { // disconnected tracks whether the user intentionally disconnected the client const [disconnected, setDisconnected] = useState(false); - const [canShareDirectory, setCanShareDirectory] = useState(false); - const [isSharingDirectory, setIsSharingDirectory] = useState(false); + const [directorySharingState, setDirectorySharingState] = useState({ + canShare: false, + isSharing: false, + browserError: false, + }); const { username, desktopName, clusterId } = useParams(); @@ -124,7 +127,10 @@ export default function useDesktopSession() { .then(desktop => setHostname(desktop.name)), userService.fetchUserContext().then(user => { setHasClipboardSharingEnabled(user.acl.clipboardSharingEnabled); - setCanShareDirectory(user.acl.directorySharingEnabled); + setDirectorySharingState(prevState => ({ + ...prevState, + canShare: user.acl.directorySharingEnabled, + })); }), ]) ); @@ -137,7 +143,7 @@ export default function useDesktopSession() { setTdpConnection, setWsConnection, setClipboardState, - setIsSharingDirectory, + setDirectorySharingState, enableClipboardSharing: clipboardState.enabled && clipboardState.permission.state === 'granted' && @@ -151,15 +157,16 @@ export default function useDesktopSession() { username, clipboardState, setClipboardState, - canShareDirectory, - isSharingDirectory, - setIsSharingDirectory, + directorySharingState, + setDirectorySharingState, + isUsingChrome, fetchAttempt, tdpConnection, wsConnection, disconnected, setDisconnected, webauthn, + setTdpConnection, ...clientCanvasProps, }; } diff --git a/packages/teleport/src/DesktopSession/useTdpClientCanvas.tsx b/packages/teleport/src/DesktopSession/useTdpClientCanvas.tsx index 975110a9e..8065c4bc7 100644 --- a/packages/teleport/src/DesktopSession/useTdpClientCanvas.tsx +++ b/packages/teleport/src/DesktopSession/useTdpClientCanvas.tsx @@ -31,7 +31,7 @@ export default function useTdpClientCanvas(props: Props) { setTdpConnection, setWsConnection, setClipboardState, - setIsSharingDirectory, + setDirectorySharingState, enableClipboardSharing, } = props; const [tdpClient, setTdpClient] = useState(null); @@ -77,14 +77,21 @@ export default function useTdpClientCanvas(props: Props) { } }; - // Default TdpClientEvent.TDP_ERROR handler - const onTdpError = (err: Error) => { - setIsSharingDirectory(false); + // Default TdpClientEvent.TDP_ERROR and TdpClientEvent.CLIENT_ERROR handler + const onTdpError = (error: { err: Error; isFatal: boolean }) => { + const { err, isFatal } = error; + setDirectorySharingState(prevState => ({ + ...prevState, + isSharing: false, + })); setClipboardState(prevState => ({ ...prevState, enabled: false, })); - setTdpConnection({ status: 'failed', statusText: err.message }); + setTdpConnection({ + status: isFatal ? 'failed' : '', + statusText: err.message, + }); }; const onWsClose = () => { @@ -215,6 +222,12 @@ type Props = { errorText: string; }> >; - setIsSharingDirectory: Dispatch>; + setDirectorySharingState: Dispatch< + SetStateAction<{ + canShare: boolean; + isSharing: boolean; + browserError: boolean; + }> + >; enableClipboardSharing: boolean; }; diff --git a/packages/teleport/src/Player/DesktopPlayer.tsx b/packages/teleport/src/Player/DesktopPlayer.tsx index 34c8a8a5c..cc39320d2 100644 --- a/packages/teleport/src/Player/DesktopPlayer.tsx +++ b/packages/teleport/src/Player/DesktopPlayer.tsx @@ -171,7 +171,8 @@ const useDesktopPlayer = ({ }); }; - const tdpCliOnTdpError = (err: Error) => { + const tdpCliOnTdpError = (error: { err: Error; isFatal: boolean }) => { + const { err } = error; setAttempt({ status: 'failed', statusText: err.message, diff --git a/packages/teleport/src/components/TdpClientCanvas/TdpClientCanvas.tsx b/packages/teleport/src/components/TdpClientCanvas/TdpClientCanvas.tsx index 1dd579649..75baa74c7 100644 --- a/packages/teleport/src/components/TdpClientCanvas/TdpClientCanvas.tsx +++ b/packages/teleport/src/components/TdpClientCanvas/TdpClientCanvas.tsx @@ -128,9 +128,11 @@ export default function TdpClientCanvas(props: Props) { useEffect(() => { if (tdpCli && tdpCliOnTdpError) { tdpCli.on(TdpClientEvent.TDP_ERROR, tdpCliOnTdpError); + tdpCli.on(TdpClientEvent.CLIENT_ERROR, tdpCliOnTdpError); return () => { tdpCli.removeListener(TdpClientEvent.TDP_ERROR, tdpCliOnTdpError); + tdpCli.removeListener(TdpClientEvent.CLIENT_ERROR, tdpCliOnTdpError); }; } }, [tdpCli, tdpCliOnTdpError]); @@ -291,7 +293,7 @@ export type Props = { pngFrame: PngFrame ) => void; tdpCliOnClipboardData?: (clipboardData: ClipboardData) => void; - tdpCliOnTdpError?: (err: Error) => void; + tdpCliOnTdpError?: (error: { err: Error; isFatal: boolean }) => void; tdpCliOnWsClose?: () => void; tdpCliOnWsOpen?: () => void; tdpCliOnClientScreenSpec?: ( diff --git a/packages/teleport/src/lib/tdp/client.ts b/packages/teleport/src/lib/tdp/client.ts index 077ee6234..bdad9ee56 100644 --- a/packages/teleport/src/lib/tdp/client.ts +++ b/packages/teleport/src/lib/tdp/client.ts @@ -23,14 +23,31 @@ import Codec, { ClientScreenSpec, PngFrame, ClipboardData, + FileType, SharedDirectoryErrCode, + SharedDirectoryInfoResponse, + SharedDirectoryListResponse, + SharedDirectoryMoveResponse, + SharedDirectoryReadResponse, + SharedDirectoryWriteResponse, + SharedDirectoryCreateResponse, + SharedDirectoryDeleteResponse, + FileSystemObject, } from './codec'; +import { + PathDoesNotExistError, + SharedDirectoryManager, + FileOrDirInfo, +} from './sharedDirectoryManager'; export enum TdpClientEvent { TDP_CLIENT_SCREEN_SPEC = 'tdp client screen spec', TDP_PNG_FRAME = 'tdp png frame', TDP_CLIPBOARD_DATA = 'tdp clipboard data', + // TDP_ERROR corresponds with https://github.com/gravitational/teleport/blob/86e824fc7879538e4de400eb1518e4f88930c109/rfd/0037-desktop-access-protocol.md?plain=1#L200-L206 TDP_ERROR = 'tdp error', + // CLIENT_ERROR represents an error event in the client that isn't a TDP_ERROR + CLIENT_ERROR = 'client error', WS_OPEN = 'ws open', WS_CLOSE = 'ws close', } @@ -43,7 +60,7 @@ export default class Client extends EventEmitterWebAuthnSender { protected codec: Codec; protected socket: WebSocket | undefined; private socketAddr: string; - sharedDirectory: FileSystemDirectoryHandle | undefined; + private sdManager: SharedDirectoryManager; private logger = Logger.create('TDPClient'); @@ -51,6 +68,7 @@ export default class Client extends EventEmitterWebAuthnSender { super(); this.socketAddr = socketAddr; this.codec = new Codec(); + this.sdManager = new SharedDirectoryManager(); } // Connect to the websocket and register websocket event handlers. @@ -63,8 +81,8 @@ export default class Client extends EventEmitterWebAuthnSender { this.emit(TdpClientEvent.WS_OPEN); }; - this.socket.onmessage = (ev: MessageEvent) => { - this.processMessage(ev.data as ArrayBuffer); + this.socket.onmessage = async (ev: MessageEvent) => { + await this.processMessage(ev.data as ArrayBuffer); }; // The socket 'error' event will only ever be emitted by the socket @@ -84,7 +102,9 @@ export default class Client extends EventEmitterWebAuthnSender { }; } - processMessage(buffer: ArrayBuffer) { + // processMessage should be await-ed when called, + // so that its internal await-or-not logic is obeyed. + async processMessage(buffer: ArrayBuffer): Promise { try { const messageType = this.codec.decodeMessageType(buffer); switch (messageType) { @@ -104,7 +124,10 @@ export default class Client extends EventEmitterWebAuthnSender { this.handleClipboardData(buffer); break; case MessageType.ERROR: - this.handleError(new Error(this.codec.decodeErrorMessage(buffer))); + this.handleError( + new Error(this.codec.decodeErrorMessage(buffer)), + TdpClientEvent.TDP_ERROR + ); break; case MessageType.MFA_JSON: this.handleMfaChallenge(buffer); @@ -115,11 +138,34 @@ export default class Client extends EventEmitterWebAuthnSender { case MessageType.SHARED_DIRECTORY_INFO_REQUEST: this.handleSharedDirectoryInfoRequest(buffer); break; + case MessageType.SHARED_DIRECTORY_CREATE_REQUEST: + // A typical sequence is that we receive a SharedDirectoryCreateRequest + // immediately followed by a SharedDirectoryWriteRequest. It's important + // that we await here so that this client doesn't field the SharedDirectoryWriteRequest + // until the create has successfully completed, or else we might get an error + // trying to write to a file that hasn't been created yet. + await this.handleSharedDirectoryCreateRequest(buffer); + break; + case MessageType.SHARED_DIRECTORY_DELETE_REQUEST: + this.handleSharedDirectoryDeleteRequest(buffer); + break; + case MessageType.SHARED_DIRECTORY_READ_REQUEST: + this.handleSharedDirectoryReadRequest(buffer); + break; + case MessageType.SHARED_DIRECTORY_WRITE_REQUEST: + this.handleSharedDirectoryWriteRequest(buffer); + break; + case MessageType.SHARED_DIRECTORY_MOVE_REQUEST: + this.handleSharedDirectoryMoveRequest(buffer); + break; + case MessageType.SHARED_DIRECTORY_LIST_REQUEST: + this.handleSharedDirectoryListRequest(buffer); + break; default: this.logger.warn(`received unsupported message type ${messageType}`); } } catch (err) { - this.handleError(err); + this.handleError(err, TdpClientEvent.CLIENT_ERROR); } } @@ -162,9 +208,6 @@ export default class Client extends EventEmitterWebAuthnSender { ); } - // TODO(isaiah): neither of the TdpClientEvent.TDP_ERROR are accurate, they should - // instead be associated with a new event TdpClientEvent.CLIENT_ERROR. - // https://github.com/gravitational/webapps/issues/615 handleMfaChallenge(buffer: ArrayBuffer) { try { const mfaJson = this.codec.decodeMfaJson(buffer); @@ -178,11 +221,12 @@ export default class Client extends EventEmitterWebAuthnSender { however the U2F API for hardware keys is not supported for desktop sessions. \ Please notify your system administrator to update cluster settings \ to use WebAuthn as the second factor protocol.' - ) + ), + TdpClientEvent.CLIENT_ERROR ); } } catch (err) { - this.handleError(err); + this.handleError(err, TdpClientEvent.CLIENT_ERROR); } } @@ -192,7 +236,8 @@ export default class Client extends EventEmitterWebAuthnSender { } this.handleError( - new Error(`Encountered shared directory error: ${errCode}`) + new Error(`Encountered shared directory error: ${errCode}`), + TdpClientEvent.CLIENT_ERROR ); return false; } @@ -203,28 +248,187 @@ export default class Client extends EventEmitterWebAuthnSender { if (!this.wasSuccessful(ack.errCode)) { return; } - - this.logger.info('Started sharing directory: ' + this.sharedDirectory.name); + try { + this.logger.info( + 'Started sharing directory: ' + this.sdManager.getName() + ); + } catch (e) { + this.handleError(e, TdpClientEvent.CLIENT_ERROR); + } } - handleSharedDirectoryInfoRequest(buffer: ArrayBuffer) { + async handleSharedDirectoryInfoRequest(buffer: ArrayBuffer) { const req = this.codec.decodeSharedDirectoryInfoRequest(buffer); - // TODO(isaiah): remove debug once message is handled. - this.logger.debug( - 'Received SharedDirectoryInfoRequest: ' + JSON.stringify(req) + const path = req.path; + try { + const info = await this.sdManager.getInfo(path); + this.sendSharedDirectoryInfoResponse({ + completionId: req.completionId, + errCode: SharedDirectoryErrCode.Nil, + fso: this.toFso(info), + }); + } catch (e) { + if (e.constructor === PathDoesNotExistError) { + this.sendSharedDirectoryInfoResponse({ + completionId: req.completionId, + errCode: SharedDirectoryErrCode.DoesNotExist, + fso: { + lastModified: BigInt(0), + fileType: FileType.File, + size: BigInt(0), + path: path, + }, + }); + } else { + this.handleError(e, TdpClientEvent.CLIENT_ERROR); + } + } + } + + async handleSharedDirectoryCreateRequest(buffer: ArrayBuffer) { + const req = this.codec.decodeSharedDirectoryCreateRequest(buffer); + + try { + await this.sdManager.create(req.path, req.fileType); + const info = await this.sdManager.getInfo(req.path); + this.sendSharedDirectoryCreateResponse({ + completionId: req.completionId, + errCode: SharedDirectoryErrCode.Nil, + fso: this.toFso(info), + }); + } catch (e) { + this.sendSharedDirectoryCreateResponse({ + completionId: req.completionId, + errCode: SharedDirectoryErrCode.Failed, + fso: { + lastModified: BigInt(0), + fileType: FileType.File, + size: BigInt(0), + path: req.path, + }, + }); + this.handleError(e, TdpClientEvent.CLIENT_ERROR, false); + } + } + + async handleSharedDirectoryDeleteRequest(buffer: ArrayBuffer) { + const req = this.codec.decodeSharedDirectoryDeleteRequest(buffer); + + try { + await this.sdManager.delete(req.path); + this.sendSharedDirectoryDeleteResponse({ + completionId: req.completionId, + errCode: SharedDirectoryErrCode.Nil, + }); + } catch (e) { + this.sendSharedDirectoryDeleteResponse({ + completionId: req.completionId, + errCode: SharedDirectoryErrCode.Failed, + }); + this.handleError(e, TdpClientEvent.CLIENT_ERROR, false); + } + } + + async handleSharedDirectoryReadRequest(buffer: ArrayBuffer) { + const req = this.codec.decodeSharedDirectoryReadRequest(buffer); + try { + const readData = await this.sdManager.readFile( + req.path, + req.offset, + req.length + ); + this.sendSharedDirectoryReadResponse({ + completionId: req.completionId, + errCode: SharedDirectoryErrCode.Nil, + readDataLength: readData.length, + readData, + }); + } catch (e) { + this.handleError(e, TdpClientEvent.CLIENT_ERROR); + } + } + + async handleSharedDirectoryWriteRequest(buffer: ArrayBuffer) { + const req = this.codec.decodeSharedDirectoryWriteRequest(buffer); + try { + const bytesWritten = await this.sdManager.writeFile( + req.path, + req.offset, + req.writeData + ); + + this.sendSharedDirectoryWriteResponse({ + completionId: req.completionId, + errCode: SharedDirectoryErrCode.Nil, + bytesWritten, + }); + } catch (e) { + this.handleError(e, TdpClientEvent.CLIENT_ERROR); + } + } + + handleSharedDirectoryMoveRequest(buffer: ArrayBuffer) { + const req = this.codec.decodeSharedDirectoryMoveRequest(buffer); + // Always send back Failed for now, see https://github.com/gravitational/webapps/issues/1064 + this.sendSharedDirectoryMoveResponse({ + completionId: req.completionId, + errCode: SharedDirectoryErrCode.Failed, + }); + this.handleError( + new Error( + 'Moving files and directories within a shared \ + directory is not supported.' + ), + TdpClientEvent.CLIENT_ERROR, + false ); - // TODO(isaiah): here's where we'll respond with SharedDirectoryInfoResponse + } + + async handleSharedDirectoryListRequest(buffer: ArrayBuffer) { + try { + const req = this.codec.decodeSharedDirectoryListRequest(buffer); + const path = req.path; + + const infoList: FileOrDirInfo[] = await this.sdManager.listContents(path); + const fsoList: FileSystemObject[] = infoList.map(info => + this.toFso(info) + ); + + this.sendSharedDirectoryListResponse({ + completionId: req.completionId, + errCode: SharedDirectoryErrCode.Nil, + fsoList, + }); + } catch (e) { + this.handleError(e, TdpClientEvent.CLIENT_ERROR); + } + } + + private toFso(info: FileOrDirInfo): FileSystemObject { + return { + lastModified: BigInt(info.lastModified), + fileType: info.kind === 'file' ? FileType.File : FileType.Directory, + size: BigInt(info.size), + path: info.path, + }; } protected send( data: string | ArrayBufferLike | Blob | ArrayBufferView ): void { if (this.socket && this.socket.readyState === 1) { - this.socket.send(data); + try { + this.socket.send(data); + } catch (e) { + this.handleError(e, TdpClientEvent.CLIENT_ERROR); + } return; } - this.handleError(new Error('websocket unavailable')); + this.handleError( + new Error('websocket unavailable'), + TdpClientEvent.CLIENT_ERROR + ); } sendUsername(username: string) { @@ -261,42 +465,73 @@ export default class Client extends EventEmitterWebAuthnSender { this.send(msg); } - private sharedDirectoryReady() { - if (!this.sharedDirectory) { - this.handleError( - new Error( - 'attempted to use a shared directory before one was initialized' - ) + addSharedDirectory(sharedDirectory: FileSystemDirectoryHandle) { + try { + this.sdManager.add(sharedDirectory); + } catch (err) { + this.handleError(err, TdpClientEvent.CLIENT_ERROR); + } + } + + sendSharedDirectoryAnnounce() { + let name: string; + try { + name = this.sdManager.getName(); + this.send( + this.codec.encodeSharedDirectoryAnnounce({ + completionId: 0, // This is always the first request. + // Hardcode directoryId for now since we only support sharing 1 directory. + // We're using 2 because the smartcard device is hardcoded to 1 in the backend. + directoryId: 2, + name, + }) ); - return false; + } catch (e) { + this.handleError(e, TdpClientEvent.CLIENT_ERROR); } + } - return true; + sendSharedDirectoryInfoResponse(res: SharedDirectoryInfoResponse) { + this.send(this.codec.encodeSharedDirectoryInfoResponse(res)); } - sendSharedDirectoryAnnounce() { - if (!this.sharedDirectoryReady()) return; - this.socket.send( - this.codec.encodeSharedDirectoryAnnounce({ - completionId: 0, // This is always the first request. - // Hardcode directoryId for now since we only support sharing 1 directory. - // We're using 2 because the smartcard device is hardcoded to 1 in the backend. - directoryId: 2, - name: this.sharedDirectory.name, - }) - ); + sendSharedDirectoryListResponse(res: SharedDirectoryListResponse) { + this.send(this.codec.encodeSharedDirectoryListResponse(res)); + } + + sendSharedDirectoryMoveResponse(res: SharedDirectoryMoveResponse) { + this.send(this.codec.encodeSharedDirectoryMoveResponse(res)); + } + + sendSharedDirectoryReadResponse(response: SharedDirectoryReadResponse) { + this.send(this.codec.encodeSharedDirectoryReadResponse(response)); + } + + sendSharedDirectoryWriteResponse(response: SharedDirectoryWriteResponse) { + this.send(this.codec.encodeSharedDirectoryWriteResponse(response)); + } + + sendSharedDirectoryCreateResponse(response: SharedDirectoryCreateResponse) { + this.send(this.codec.encodeSharedDirectoryCreateResponse(response)); + } + + sendSharedDirectoryDeleteResponse(response: SharedDirectoryDeleteResponse) { + this.send(this.codec.encodeSharedDirectoryDeleteResponse(response)); } resize(spec: ClientScreenSpec) { this.send(this.codec.encodeClientScreenSpec(spec)); } - // Emits an TdpClientEvent.ERROR event. Sets this.errored to true to alert the socket.onclose handler that - // it needn't emit a generic unknown error event. - private handleError(err: Error) { + // Emits an errType event, closing the socket if the error was fatal. + private handleError( + err: Error, + errType: TdpClientEvent.TDP_ERROR | TdpClientEvent.CLIENT_ERROR, + isFatal = true + ) { this.logger.error(err); - this.emit(TdpClientEvent.TDP_ERROR, err); - this.socket?.close(); + this.emit(errType, { err, isFatal }); + if (isFatal) this.socket?.close(); } // Ensures full cleanup of this object. diff --git a/packages/teleport/src/lib/tdp/codec.ts b/packages/teleport/src/lib/tdp/codec.ts index ccc1e848d..971073a8e 100644 --- a/packages/teleport/src/lib/tdp/codec.ts +++ b/packages/teleport/src/lib/tdp/codec.ts @@ -40,6 +40,19 @@ export enum MessageType { SHARED_DIRECTORY_ANNOUNCE = 11, SHARED_DIRECTORY_ACKNOWLEDGE = 12, SHARED_DIRECTORY_INFO_REQUEST = 13, + SHARED_DIRECTORY_INFO_RESPONSE = 14, + SHARED_DIRECTORY_CREATE_REQUEST = 15, + SHARED_DIRECTORY_CREATE_RESPONSE = 16, + SHARED_DIRECTORY_DELETE_REQUEST = 17, + SHARED_DIRECTORY_DELETE_RESPONSE = 18, + SHARED_DIRECTORY_READ_REQUEST = 19, + SHARED_DIRECTORY_READ_RESPONSE = 20, + SHARED_DIRECTORY_WRITE_REQUEST = 21, + SHARED_DIRECTORY_WRITE_RESPONSE = 22, + SHARED_DIRECTORY_MOVE_REQUEST = 23, + SHARED_DIRECTORY_MOVE_RESPONSE = 24, + SHARED_DIRECTORY_LIST_REQUEST = 25, + SHARED_DIRECTORY_LIST_RESPONSE = 26, __LAST, // utility value } @@ -95,7 +108,7 @@ export type SharedDirectoryAnnounce = { name: string; }; -// | message type (12) | errCode error | directory_id uint32 | +// | message type (12) | err_code error | directory_id uint32 | export type SharedDirectoryAcknowledge = { errCode: SharedDirectoryErrCode; directoryId: number; @@ -108,6 +121,114 @@ export type SharedDirectoryInfoRequest = { path: string; }; +// | message type (14) | completion_id uint32 | err_code uint32 | file_system_object fso | +export type SharedDirectoryInfoResponse = { + completionId: number; + errCode: SharedDirectoryErrCode; + fso: FileSystemObject; +}; + +// | message type (15) | completion_id uint32 | directory_id uint32 | file_type uint32 | path_length uint32 | path []byte | +export type SharedDirectoryCreateRequest = { + completionId: number; + directoryId: number; + fileType: FileType; + path: string; +}; + +// | message type (16) | completion_id uint32 | err_code uint32 | file_system_object fso | +export type SharedDirectoryCreateResponse = { + completionId: number; + errCode: SharedDirectoryErrCode; + fso: FileSystemObject; +}; + +// | message type (17) | completion_id uint32 | directory_id uint32 | path_length uint32 | path []byte | +export type SharedDirectoryDeleteRequest = { + completionId: number; + directoryId: number; + path: string; +}; + +// | message type (18) | completion_id uint32 | err_code uint32 | +export type SharedDirectoryDeleteResponse = { + completionId: number; + errCode: SharedDirectoryErrCode; +}; + +// | message type (19) | completion_id uint32 | directory_id uint32 | path_length uint32 | path []byte | offset uint64 | length uint32 | +export type SharedDirectoryReadRequest = { + completionId: number; + directoryId: number; + path: string; + pathLength: number; + offset: bigint; + length: number; +}; + +// | message type (20) | completion_id uint32 | err_code uint32 | read_data_length uint32 | read_data []byte | +export type SharedDirectoryReadResponse = { + completionId: number; + errCode: SharedDirectoryErrCode; + readDataLength: number; + readData: Uint8Array; +}; + +// | message type (21) | completion_id uint32 | directory_id uint32 | path_length uint32 | path []byte | offset uint64 | write_data_length uint32 | write_data []byte | +export type SharedDirectoryWriteRequest = { + completionId: number; + directoryId: number; + pathLength: number; + path: string; + offset: bigint; + writeData: Uint8Array; +}; + +// | message type (22) | completion_id uint32 | err_code uint32 | bytes_written uint32 | +export type SharedDirectoryWriteResponse = { + completionId: number; + errCode: number; + bytesWritten: number; +}; + +// | message type (23) | completion_id uint32 | directory_id uint32 | original_path_length uint32 | original_path []byte | new_path_length uint32 | new_path []byte | +export type SharedDirectoryMoveRequest = { + completionId: number; + directoryId: number; + originalPathLength: number; + originalPath: string; + newPathLength: number; + newPath: string; +}; + +// | message type (24) | completion_id uint32 | err_code uint32 | +export type SharedDirectoryMoveResponse = { + completionId: number; + errCode: SharedDirectoryErrCode; +}; + +// | message type (25) | completion_id uint32 | directory_id uint32 | path_length uint32 | path []byte | +export type SharedDirectoryListRequest = { + completionId: number; + directoryId: number; + path: string; +}; + +// | message type (26) | completion_id uint32 | err_code uint32 | fso_list_length uint32 | fso_list fso[] | +export type SharedDirectoryListResponse = { + completionId: number; + errCode: SharedDirectoryErrCode; + fsoList: FileSystemObject[]; +}; + +// | last_modified uint64 | size uint64 | file_type uint32 | path_length uint32 | path byte[] | +export type FileSystemObject = { + lastModified: bigint; + size: bigint; + fileType: FileType; + path: string; +}; + export enum SharedDirectoryErrCode { // nil (no error, operation succeeded) Nil = 0, @@ -119,6 +240,11 @@ export enum SharedDirectoryErrCode { AlreadyExists = 3, } +export enum FileType { + File = 0, + Directory = 1, +} + function toSharedDirectoryErrCode(errCode: number): SharedDirectoryErrCode { if (!(errCode in SharedDirectoryErrCode)) { throw new Error(`attempted to convert invalid error code ${errCode}`); @@ -457,6 +583,185 @@ export default class Codec { return buffer; } + // | message type (14) | completion_id uint32 | err_code uint32 | file_system_object fso | + encodeSharedDirectoryInfoResponse(res: SharedDirectoryInfoResponse): Message { + const bufLenSansFso = byteLength + 2 * uint32Length; + const bufferSansFso = new ArrayBuffer(bufLenSansFso); + const view = new DataView(bufferSansFso); + let offset = 0; + + view.setUint8(offset++, MessageType.SHARED_DIRECTORY_INFO_RESPONSE); + view.setUint32(offset, res.completionId); + offset += uint32Length; + view.setUint32(offset, res.errCode); + offset += uint32Length; + + const fsoBuffer = this.encodeFileSystemObject(res.fso); + + // https://gist.github.com/72lions/4528834?permalink_comment_id=2395442#gistcomment-2395442 + return new Uint8Array([ + ...new Uint8Array(bufferSansFso), + ...new Uint8Array(fsoBuffer), + ]).buffer; + } + + // | message type (16) | completion_id uint32 | err_code uint32 | file_system_object fso | + encodeSharedDirectoryCreateResponse( + res: SharedDirectoryCreateResponse + ): Message { + const bufLenSansFso = byteLength + 2 * uint32Length; + const bufferSansFso = new ArrayBuffer(bufLenSansFso); + const view = new DataView(bufferSansFso); + let offset = 0; + + view.setUint8(offset, MessageType.SHARED_DIRECTORY_CREATE_RESPONSE); + offset += byteLength; + view.setUint32(offset, res.completionId); + offset += uint32Length; + view.setUint32(offset, res.errCode); + offset += uint32Length; + + const fsoBuffer = this.encodeFileSystemObject(res.fso); + + // https://gist.github.com/72lions/4528834?permalink_comment_id=2395442#gistcomment-2395442 + return new Uint8Array([ + ...new Uint8Array(bufferSansFso), + ...new Uint8Array(fsoBuffer), + ]).buffer; + } + + // | message type (18) | completion_id uint32 | err_code uint32 | + encodeSharedDirectoryDeleteResponse( + res: SharedDirectoryDeleteResponse + ): Message { + const bufLen = byteLength + 2 * uint32Length; + const buffer = new ArrayBuffer(bufLen); + const view = new DataView(buffer); + let offset = 0; + + view.setUint8(offset, MessageType.SHARED_DIRECTORY_DELETE_RESPONSE); + offset += byteLength; + view.setUint32(offset, res.completionId); + offset += uint32Length; + view.setUint32(offset, res.errCode); + offset += uint32Length; + + return buffer; + } + + // | message type (20) | completion_id uint32 | err_code uint32 | read_data_length uint32 | read_data []byte | + encodeSharedDirectoryReadResponse(res: SharedDirectoryReadResponse): Message { + const bufLen = + byteLength + 3 * uint32Length + byteLength * res.readDataLength; + const buffer = new ArrayBuffer(bufLen); + const view = new DataView(buffer); + let offset = 0; + + view.setUint8(offset, MessageType.SHARED_DIRECTORY_READ_RESPONSE); + offset += byteLength; + view.setUint32(offset, res.completionId); + offset += uint32Length; + view.setUint32(offset, res.errCode); + offset += uint32Length; + view.setUint32(offset, res.readDataLength); + offset += uint32Length; + res.readData.forEach(byte => { + view.setUint8(offset++, byte); + }); + + return buffer; + } + + // | message type (22) | completion_id uint32 | err_code uint32 | bytes_written uint32 | + encodeSharedDirectoryWriteResponse( + res: SharedDirectoryWriteResponse + ): Message { + const bufLen = byteLength + 3 * uint32Length; + const buffer = new ArrayBuffer(bufLen); + const view = new DataView(buffer); + let offset = 0; + + view.setUint8(offset, MessageType.SHARED_DIRECTORY_WRITE_RESPONSE); + offset += byteLength; + view.setUint32(offset, res.completionId); + offset += uint32Length; + view.setUint32(offset, res.errCode); + offset += uint32Length; + view.setUint32(offset, res.bytesWritten); + offset += uint32Length; + + return buffer; + } + + // | message type (24) | completion_id uint32 | err_code uint32 | + encodeSharedDirectoryMoveResponse(res: SharedDirectoryMoveResponse): Message { + const bufLen = byteLength + 2 * uint32Length; + const buffer = new ArrayBuffer(bufLen); + const view = new DataView(buffer); + let offset = 0; + + view.setUint8(offset, MessageType.SHARED_DIRECTORY_MOVE_RESPONSE); + offset += byteLength; + view.setUint32(offset, res.completionId); + offset += uint32Length; + view.setUint32(offset, res.errCode); + offset += uint32Length; + + return buffer; + } + + // | message type (26) | completion_id uint32 | err_code uint32 | fso_list_length uint32 | fso_list fso[] | + encodeSharedDirectoryListResponse(res: SharedDirectoryListResponse): Message { + const bufLenSansFsoList = byteLength + 3 * uint32Length; + const bufferSansFsoList = new ArrayBuffer(bufLenSansFsoList); + const view = new DataView(bufferSansFsoList); + let offset = 0; + + view.setUint8(offset++, MessageType.SHARED_DIRECTORY_LIST_RESPONSE); + view.setUint32(offset, res.completionId); + offset += uint32Length; + view.setUint32(offset, res.errCode); + offset += uint32Length; + view.setUint32(offset, res.fsoList.length); + offset += uint32Length; + + let withFsoList = new Uint8Array(bufferSansFsoList); + res.fsoList.forEach(fso => { + const fsoBuffer = this.encodeFileSystemObject(fso); + + // https://gist.github.com/72lions/4528834?permalink_comment_id=2395442#gistcomment-2395442 + withFsoList = new Uint8Array([ + ...withFsoList, + ...new Uint8Array(fsoBuffer), + ]); + }); + + return withFsoList.buffer; + } + + // | last_modified uint64 | size uint64 | file_type uint32 | path_length uint32 | path byte[] | + encodeFileSystemObject(fso: FileSystemObject): Message { + const dataUtf8array = this.encoder.encode(fso.path); + + const bufLen = 2 * uint64Length + 2 * uint32Length + dataUtf8array.length; + const buffer = new ArrayBuffer(bufLen); + const view = new DataView(buffer); + let offset = 0; + view.setBigUint64(offset, fso.lastModified); + offset += uint64Length; + view.setBigUint64(offset, fso.size); + offset += uint64Length; + view.setUint32(offset, fso.fileType); + offset += uint32Length; + view.setUint32(offset, dataUtf8array.length); + offset += uint32Length; + dataUtf8array.forEach(byte => { + view.setUint8(offset++, byte); + }); + + return buffer; + } + // decodeClipboardData decodes clipboard data decodeClipboardData(buffer: ArrayBuffer): ClipboardData { return { @@ -484,7 +789,7 @@ export default class Codec { // decodeMfaChallenge decodes a raw tdp MFA challenge message and returns it as a string (of a json). // | message type (10) | mfa_type byte | message_length uint32 | json []byte decodeMfaJson(buffer: ArrayBuffer): MfaJson { - let dv = new DataView(buffer); + const dv = new DataView(buffer); let offset = 0; offset += byteLength; // eat message type const mfaType = String.fromCharCode(dv.getUint8(offset)); @@ -533,7 +838,7 @@ export default class Codec { return pngFrame; } - // | message type (12) | errCode error | directory_id uint32 | + // | message type (12) | err_code error | directory_id uint32 | decodeSharedDirectoryAcknowledge( buffer: ArrayBuffer ): SharedDirectoryAcknowledge { @@ -541,7 +846,7 @@ export default class Codec { let offset = 0; offset += byteLength; // eat message type const errCode = toSharedDirectoryErrCode(dv.getUint32(offset)); - offset += uint32Length; // eat errCode + offset += uint32Length; // eat err_code const directoryId = dv.getUint32(5); return { @@ -571,6 +876,155 @@ export default class Codec { }; } + // | message type (15) | completion_id uint32 | directory_id uint32 | file_type uint32 | path_length uint32 | path []byte | + decodeSharedDirectoryCreateRequest( + buffer: ArrayBuffer + ): SharedDirectoryCreateRequest { + const dv = new DataView(buffer); + let offset = 0; + offset += byteLength; // eat message type + const completionId = dv.getUint32(offset); + offset += uint32Length; // eat completion_id + const directoryId = dv.getUint32(offset); + offset += uint32Length; // eat directory_id + const fileType = dv.getUint32(offset); + offset += uint32Length; // eat directory_id + offset += uint32Length; // eat path_length + const path = this.decoder.decode(new Uint8Array(buffer.slice(offset))); + + return { + completionId, + directoryId, + fileType, + path, + }; + } + + // | message type (17) | completion_id uint32 | directory_id uint32 | path_length uint32 | path []byte | + decodeSharedDirectoryDeleteRequest( + buffer: ArrayBuffer + ): SharedDirectoryDeleteRequest { + const dv = new DataView(buffer); + let offset = 0; + offset += byteLength; // eat message type + const completionId = dv.getUint32(offset); + offset += uint32Length; // eat completion_id + const directoryId = dv.getUint32(offset); + offset += uint32Length; // eat directory_id + offset += uint32Length; // eat path_length + const path = this.decoder.decode(new Uint8Array(buffer.slice(offset))); + + return { + completionId, + directoryId, + path, + }; + } + + // | message type (19) | completion_id uint32 | directory_id uint32 | path_length uint32 | path []byte | offset uint64 | length uint32 | + decodeSharedDirectoryReadRequest( + buffer: ArrayBuffer + ): SharedDirectoryReadRequest { + const dv = new DataView(buffer); + let bufOffset = 0; + bufOffset += byteLength; // eat message type + const completionId = dv.getUint32(bufOffset); + bufOffset += uint32Length; // eat completion_id + const directoryId = dv.getUint32(bufOffset); + bufOffset += uint32Length; // eat directory_id + const pathLength = dv.getUint32(bufOffset); + bufOffset += uint32Length; // eat path_length + const path = this.decoder.decode( + new Uint8Array(buffer.slice(bufOffset, bufOffset + pathLength)) + ); + bufOffset += pathLength; // eat path + const offset = dv.getBigUint64(bufOffset); + bufOffset += uint64Length; // eat offset + const length = dv.getUint32(bufOffset); + + return { + completionId, + directoryId, + pathLength, + path, + offset, + length, + }; + } + + // | message type (21) | completion_id uint32 | directory_id uint32 | path_length uint32 | path []byte | offset uint64 | write_data_length uint32 | write_data []byte | + decodeSharedDirectoryWriteRequest( + buffer: ArrayBuffer + ): SharedDirectoryWriteRequest { + const dv = new DataView(buffer); + let bufOffset = byteLength; // eat message type + const completionId = dv.getUint32(bufOffset); + bufOffset += uint32Length; // eat completion_id + const directoryId = dv.getUint32(bufOffset); + bufOffset += uint32Length; // eat directory_id + const offset = dv.getBigUint64(bufOffset); + bufOffset += uint64Length; // eat offset + const pathLength = dv.getUint32(bufOffset); + bufOffset += uint32Length; // eat path_length + const path = this.decoder.decode( + new Uint8Array(buffer.slice(bufOffset, bufOffset + pathLength)) + ); + bufOffset += pathLength; // eat path + const writeDataLength = dv.getUint32(bufOffset); + bufOffset += uint32Length; // eat write_data_length + const writeData = new Uint8Array( + buffer.slice(bufOffset, bufOffset + writeDataLength) + ); + + return { + completionId, + directoryId, + pathLength, + path, + offset, + writeData, + }; + } + + // | message type (23) | completion_id uint32 | directory_id uint32 | original_path_length uint32 | original_path []byte | new_path_length uint32 | new_path []byte | + decodeSharedDirectoryMoveRequest( + buffer: ArrayBuffer + ): SharedDirectoryMoveRequest { + const dv = new DataView(buffer); + let bufOffset = byteLength; // eat message type + const completionId = dv.getUint32(bufOffset); + bufOffset += uint32Length; // eat completion_id + const directoryId = dv.getUint32(bufOffset); + bufOffset += uint32Length; // eat directory_id + const originalPathLength = dv.getUint32(bufOffset); + bufOffset += uint32Length; // eat original_path_length + const originalPath = this.decoder.decode( + new Uint8Array(buffer.slice(bufOffset, bufOffset + originalPathLength)) + ); + bufOffset += originalPathLength; // eat original_path + const newPathLength = dv.getUint32(bufOffset); + bufOffset += uint32Length; // eat new_path_length + const newPath = this.decoder.decode( + new Uint8Array(buffer.slice(bufOffset, bufOffset + newPathLength)) + ); + + return { + completionId, + directoryId, + originalPathLength, + originalPath, + newPathLength, + newPath, + }; + } + + // | message type (25) | completion_id uint32 | directory_id uint32 | path_length uint32 | path []byte | + decodeSharedDirectoryListRequest( + buffer: ArrayBuffer + ): SharedDirectoryListRequest { + return this.decodeSharedDirectoryInfoRequest(buffer); + } + // asBase64Url creates a data:image uri from the png data part of a PNG_FRAME tdp message. private asBase64Url(buffer: ArrayBuffer, offset: number): string { return `data:image/png;base64,${arrayBufferToBase64(buffer.slice(offset))}`; @@ -579,3 +1033,4 @@ export default class Codec { const byteLength = 1; const uint32Length = 4; +const uint64Length = uint32Length * 2; diff --git a/packages/teleport/src/lib/tdp/playerClient.ts b/packages/teleport/src/lib/tdp/playerClient.ts index 9180723a6..9d1be6294 100644 --- a/packages/teleport/src/lib/tdp/playerClient.ts +++ b/packages/teleport/src/lib/tdp/playerClient.ts @@ -49,7 +49,7 @@ export class PlayerClient extends Client { } // Overrides Client implementation. - processMessage(buffer: ArrayBuffer) { + async processMessage(buffer: ArrayBuffer): Promise { const json = JSON.parse(this.textDecoder.decode(buffer)); if (json.message === 'end') { @@ -59,7 +59,7 @@ export class PlayerClient extends Client { } else { const ms = json.ms; this.emit(PlayerClientEvent.UPDATE_CURRENT_TIME, ms); - super.processMessage(base64ToArrayBuffer(json.message)); + await super.processMessage(base64ToArrayBuffer(json.message)); } } diff --git a/packages/teleport/src/lib/tdp/sharedDirectoryManager.ts b/packages/teleport/src/lib/tdp/sharedDirectoryManager.ts new file mode 100644 index 000000000..60b90e8a0 --- /dev/null +++ b/packages/teleport/src/lib/tdp/sharedDirectoryManager.ts @@ -0,0 +1,264 @@ +// Copyright 2022 Gravitational, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { FileType } from './codec'; + +// SharedDirectoryManager manages a FileSystemDirectoryHandle for use +// by the TDP client. Most of its methods can potentially throw errors +// and so should be wrapped in try/catch blocks. +export class SharedDirectoryManager { + private dir: FileSystemDirectoryHandle | undefined; + + /** + * @throws Will throw an error if a directory is already being shared. + */ + add(sharedDirectory: FileSystemDirectoryHandle) { + if (this.dir) { + throw new Error( + 'SharedDirectoryManager currently only supports sharing a single directory' + ); + } + this.dir = sharedDirectory; + } + + /** + * @throws Will throw an error if a directory has not already been initialized via add(). + */ + getName(): string { + this.checkReady(); + return this.dir.name; + } + + /** + * Gets the information for the file or directory at path where path is the relative path from the root directory. + * @throws Will throw an error if a directory has not already been initialized via add(). + * @throws {PathDoesNotExistError} if the pathstr isn't a valid path in the shared directory + */ + async getInfo(path: string): Promise { + this.checkReady(); + + const fileOrDir = await this.walkPath(path); + + if (fileOrDir.kind === 'directory') { + // Magic numbers are the values for directories where the true + // value is unavailable, according to the TDP spec. + return { size: 4096, lastModified: 0, kind: fileOrDir.kind, path }; + } + + let file = await fileOrDir.getFile(); + return { + size: file.size, + lastModified: file.lastModified, + kind: fileOrDir.kind, + path, + }; + } + + /** + * Gets the FileOrDirInfo for all the children of the directory at path. + * @throws Will throw an error if a directory has not already been initialized via add(). + * @throws {PathDoesNotExistError} if the pathstr isn't a valid path in the shared directory + */ + async listContents(path: string): Promise { + this.checkReady(); + + // Get the directory whose contents we want to list. + const dir = await this.walkPath(path); + if (dir.kind !== 'directory') { + throw new Error('cannot list the contents of a file'); + } + + let infos: FileOrDirInfo[] = []; + for await (const entry of dir.values()) { + // Create the full relative path to the entry + let entryPath = path; + if (entryPath !== '') { + entryPath = [entryPath, entry.name].join('/'); + } else { + entryPath = entry.name; + } + infos.push(await this.getInfo(entryPath)); + } + + return infos; + } + + /** + * Reads length bytes starting at offset from a file at path. + * @throws Will throw an error if a directory has not already been initialized via add(). + * @throws {PathDoesNotExistError} if the pathstr isn't a valid path in the shared directory + */ + async readFile( + path: string, + offset: bigint, + length: number + ): Promise { + this.checkReady(); + + const fileHandle = await this.walkPath(path); + if (fileHandle.kind !== 'file') { + throw new Error('cannot read the bytes of a directory'); + } + + const file = await fileHandle.getFile(); + + return new Uint8Array( + await file.slice(Number(offset), Number(offset) + length).arrayBuffer() + ); + } + + /** + * Writes the bytes in writeData to the file at path starting at offset. + * @throws Will throw an error if a directory has not already been initialized via add(). + * @throws {PathDoesNotExistError} if the pathstr isn't a valid path in the shared directory + */ + async writeFile( + path: string, + offset: bigint, + writeData: Uint8Array + ): Promise { + this.checkReady(); + + const fileHandle = await this.walkPath(path); + if (fileHandle.kind !== 'file') { + throw new Error('cannot read the bytes of a directory'); + } + + const file = await fileHandle.createWritable(); + if (offset > 0) { + file.seek(Number(offset)); + } + file.write(writeData); + file.close(); // Needed to actually write data to disk. + + return writeData.length; + } + + /** + * Creates a new file or directory (determined by fileType) at path. + * If the path already exists for the given fileType, this operation is effectively ignored. + * @throws {DomException} If the path already exists but not for the given fileType. + * @throws Anything potentially thrown by getFileHandle/getDirectoryHandle. + * @throws {PathDoesNotExistError} if the path isn't a valid path to a directory. + */ + async create(path: string, fileType: FileType): Promise { + let splitPath = path.split('/'); + const fileOrDirName = splitPath.pop(); + const dirPath = splitPath.join('/'); + + const dirHandle = await this.walkPath(dirPath); + if (dirHandle.kind !== 'directory') { + throw new PathDoesNotExistError( + 'destination was a file, not a directory' + ); + } + + if (fileType === FileType.File) { + await dirHandle.getFileHandle(fileOrDirName, { create: true }); + } else { + await dirHandle.getDirectoryHandle(fileOrDirName, { create: true }); + } + } + + /** + * Deletes a file or directory at path. + * If the path doesn't exist, this operation is effectively ignored. + * @throws Anything potentially thrown by getFileHandle/getDirectoryHandle. + * @throws {PathDoesNotExistError} if the path isn't a valid path to a directory. + */ + async delete(path: string): Promise { + let splitPath = path.split('/'); + const fileOrDirName = splitPath.pop(); + const dirPath = splitPath.join('/'); + + const dirHandle = await this.walkPath(dirPath); + if (dirHandle.kind !== 'directory') { + throw new PathDoesNotExistError( + 'destination was a file, not a directory' + ); + } + + await dirHandle.removeEntry(fileOrDirName, { recursive: true }); + } + + /** + * walkPath walks a pathstr (assumed to be in the qualified Unix format specified + * in the TDP spec), returning the FileSystemDirectoryHandle | FileSystemFileHandle + * it finds at its end. + * @throws {PathDoesNotExistError} if the pathstr isn't a valid path in the shared directory + */ + private async walkPath( + pathstr: string + ): Promise { + if (pathstr === '') { + return this.dir; + } + + let path = pathstr.split('/'); + + let walkIt = async ( + dir: FileSystemDirectoryHandle, + path: string[] + ): Promise => { + // Pop the next path element off the stack + let nextPathElem = path.shift(); + + // Iterate through the items in the directory + for await (const entry of dir.values()) { + // If we find the entry we're looking for + if (entry.name === nextPathElem) { + if (path.length === 0) { + // We're at the end of the path, so this + // is the end element we've been walking towards. + return entry; + } else if (entry.kind === 'directory') { + // We're not at the end of the path and + // have encountered a directory, recurse + // further. + return walkIt(entry, path); + } else { + break; + } + } + } + + throw new PathDoesNotExistError('path does not exist'); + }; + + return walkIt(this.dir, path); + } + + /** + * @throws Will throw an error if a directory has not already been initialized via add(). + */ + private checkReady() { + if (!this.dir) { + throw new Error( + 'attempted to use a shared directory before one was initialized' + ); + } + } +} + +export class PathDoesNotExistError extends Error { + constructor(message: string) { + super(message); + } +} + +export type FileOrDirInfo = { + size: number; // bytes + lastModified: number; // ms since unix epoch + kind: 'file' | 'directory'; + path: string; +}; diff --git a/tsconfig.json b/tsconfig.json index 648f92e8e..6203030d0 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -31,6 +31,9 @@ "skipLibCheck": true, // TODO: https://github.com/gravitational/webapps/issues/957 "strictNullChecks": false, + "types": [ + "@types/wicg-file-system-access" + ], }, "exclude": [ "node_modules", diff --git a/yarn.lock b/yarn.lock index 4e573343f..9304f55d3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3070,10 +3070,10 @@ tapable "^2.2.0" webpack "^5" -"@types/wicg-native-file-system@^2020.6.0": - version "2020.6.0" - resolved "https://registry.yarnpkg.com/@types/wicg-native-file-system/-/wicg-native-file-system-2020.6.0.tgz#63cbb7bac47bdb9eae4b0d66e63134b33e47e05d" - integrity sha512-M7n6jvHfUzUXDtf6UGpL6rVIddV7UzEYrvwZPORApeHvDGQnZJ79fXorLlDj8xJKyUemnEBohRd8yx09k9NBUw== +"@types/wicg-file-system-access@^2020.9.5": + version "2020.9.5" + resolved "https://registry.yarnpkg.com/@types/wicg-file-system-access/-/wicg-file-system-access-2020.9.5.tgz#4a0c8f3d1ed101525f329e86c978f7735404474f" + integrity sha512-UYK244awtmcUYQfs7FR8710MJcefL2WvkyHMjA8yJzxd1mo0Gfn88sRZ1Bls7hiUhA2w7ne1gpJ9T5g3G0wOyA== "@types/ws@^8.5.1": version "8.5.3" @@ -4394,7 +4394,7 @@ braces@^2.3.1, braces@^2.3.2: split-string "^3.0.2" to-regex "^3.0.1" -braces@^3.0.1, braces@~3.0.2: +braces@^3.0.1, braces@^3.0.2, braces@~3.0.2: version "3.0.2" resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== @@ -6269,7 +6269,7 @@ enhanced-resolve@^4.5.0: memory-fs "^0.5.0" tapable "^1.0.0" -enhanced-resolve@^5.10.0: +enhanced-resolve@^5.0.0, enhanced-resolve@^5.10.0: version "5.10.0" resolved "https://registry.yarnpkg.com/enhanced-resolve/-/enhanced-resolve-5.10.0.tgz#0dc579c3bb2a1032e357ac45b8f3a6f3ad4fb1e6" integrity sha512-T0yTFjdpldGY8PmuXXR0PyQ1ufZpEGiHVrp7zHKB7jdR4qlmZHhONVM5AQOAWXuF/w3dnHbEQVrNptJgt7F+cQ== @@ -10027,6 +10027,14 @@ micromatch@^3.1.10, micromatch@^3.1.4: snapdragon "^0.8.1" to-regex "^3.0.2" +micromatch@^4.0.0: + version "4.0.5" + resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.5.tgz#bc8999a7cbbf77cdc89f132f6e467051b49090c6" + integrity sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA== + dependencies: + braces "^3.0.2" + picomatch "^2.3.1" + micromatch@^4.0.2, micromatch@^4.0.4: version "4.0.4" resolved "https://registry.yarnpkg.com/micromatch/-/micromatch-4.0.4.tgz#896d519dfe9db25fce94ceb7a500919bf881ebf9" @@ -11084,6 +11092,11 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3, picomatch@^2.3.0: resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.0.tgz#f1f061de8f6a4bf022892e2d128234fb98302972" integrity sha512-lY1Q/PiJGC2zOv/z391WOTD+Z02bCgsFfvxoXXf6h7kv9o+WmsmzYqrAwY63sNgOxE4xEdq0WyUnXfKeBrSvYw== +picomatch@^2.3.1: + version "2.3.1" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== + pify@^2.0.0: version "2.3.0" resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" @@ -13686,6 +13699,16 @@ ts-essentials@^2.0.3: resolved "https://registry.yarnpkg.com/ts-essentials/-/ts-essentials-2.0.12.tgz#c9303f3d74f75fa7528c3d49b80e089ab09d8745" integrity sha512-3IVX4nI6B5cc31/GFFE+i8ey/N2eA0CZDbo6n0yrz0zDX8ZJ8djmU1p+XRz7G3is0F3bB3pu2pAroFdAWQKU3w== +ts-loader@^9.3.1: + version "9.3.1" + resolved "https://registry.yarnpkg.com/ts-loader/-/ts-loader-9.3.1.tgz#fe25cca56e3e71c1087fe48dc67f4df8c59b22d4" + integrity sha512-OkyShkcZTsTwyS3Kt7a4rsT/t2qvEVQuKCTg4LJmpj9fhFR7ukGdZwV6Qq3tRUkqcXtfGpPR7+hFKHCG/0d3Lw== + dependencies: + chalk "^4.1.0" + enhanced-resolve "^5.0.0" + micromatch "^4.0.0" + semver "^7.3.4" + ts-pnp@^1.1.6: version "1.2.0" resolved "https://registry.yarnpkg.com/ts-pnp/-/ts-pnp-1.2.0.tgz#a500ad084b0798f1c3071af391e65912c86bca92"