diff --git a/web/packages/shared/components/DesktopSession/DesktopSession.story.tsx b/web/packages/shared/components/DesktopSession/DesktopSession.story.tsx index 6ea5c4fd50dca..53b29c246554f 100644 --- a/web/packages/shared/components/DesktopSession/DesktopSession.story.tsx +++ b/web/packages/shared/components/DesktopSession/DesktopSession.story.tsx @@ -26,6 +26,7 @@ import { } from 'shared/hooks/useAsync'; import { BitmapFrame, + BrowserFileSystem, ClientScreenSpec, TdpClient, TdpClientEvent, @@ -54,7 +55,7 @@ const meta: Meta = { export default meta; const fakeClient = () => { - const client = new TdpClient(() => null); + const client = new TdpClient(() => null, new BrowserFileSystem()); // Don't try to connect to a websocket. client.connect = async spec => { emitFrame(client, spec); diff --git a/web/packages/shared/components/DesktopSession/DesktopSession.test.tsx b/web/packages/shared/components/DesktopSession/DesktopSession.test.tsx index f59a0aa425f36..cf079f8c4fbd3 100644 --- a/web/packages/shared/components/DesktopSession/DesktopSession.test.tsx +++ b/web/packages/shared/components/DesktopSession/DesktopSession.test.tsx @@ -22,7 +22,7 @@ import { screen } from '@testing-library/react'; import { render } from 'design/utils/testing'; import { makeSuccessAttempt } from 'shared/hooks/useAsync'; -import { TdpClient } from 'shared/libs/tdp'; +import { BrowserFileSystem, TdpClient } from 'shared/libs/tdp'; import { wait } from 'shared/utils/wait'; import { DesktopSession } from './DesktopSession'; @@ -72,7 +72,10 @@ const getMockTransport = () => { test('reconnect button reinitializes the connection', async () => { const transport = getMockTransport(); - const tpdClient = new TdpClient(transport.getTransport); + const tpdClient = new TdpClient( + transport.getTransport, + new BrowserFileSystem() + ); jest.spyOn(tpdClient, 'connect'); jest.spyOn(tpdClient, 'shutdown'); const { unmount } = render( diff --git a/web/packages/shared/components/DesktopSession/Withholder.test.ts b/web/packages/shared/components/DesktopSession/Withholder.test.ts index bc597e81b51c0..43ee1267cf4f3 100644 --- a/web/packages/shared/components/DesktopSession/Withholder.test.ts +++ b/web/packages/shared/components/DesktopSession/Withholder.test.ts @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import { ButtonState, TdpClient } from 'shared/libs/tdp'; +import { BrowserFileSystem, ButtonState, TdpClient } from 'shared/libs/tdp'; import { Withholder } from './Withholder'; @@ -53,7 +53,7 @@ describe('withholder', () => { const params = { e: { key: 'Enter' } as KeyboardEvent as KeyboardEvent, state: ButtonState.DOWN, - cli: new TdpClient(() => null), + cli: new TdpClient(() => null, new BrowserFileSystem()), }; withholder.handleKeyboardEvent(params, mockHandleKeyboardEvent); expect(mockHandleKeyboardEvent).toHaveBeenCalledWith(params); @@ -63,19 +63,19 @@ describe('withholder', () => { const metaDown = { e: { key: 'Meta' } as KeyboardEvent, state: ButtonState.DOWN, - cli: new TdpClient(() => null), + cli: new TdpClient(() => null, new BrowserFileSystem()), }; const metaUp = { e: { key: 'Meta' } as KeyboardEvent, state: ButtonState.UP, - cli: new TdpClient(() => null), + cli: new TdpClient(() => null, new BrowserFileSystem()), }; const enterDown = { e: { key: 'Enter' } as KeyboardEvent as KeyboardEvent, state: ButtonState.DOWN, - cli: new TdpClient(() => null), + cli: new TdpClient(() => null, new BrowserFileSystem()), }; withholder.handleKeyboardEvent(metaDown, mockHandleKeyboardEvent); @@ -95,12 +95,12 @@ describe('withholder', () => { const metaParams = { e: { key: 'Meta' } as KeyboardEvent, state: ButtonState.UP, - cli: new TdpClient(() => null), + cli: new TdpClient(() => null, new BrowserFileSystem()), }; const altParams = { e: { key: 'Alt' } as KeyboardEvent, state: ButtonState.UP, - cli: new TdpClient(() => null), + cli: new TdpClient(() => null, new BrowserFileSystem()), }; withholder.handleKeyboardEvent(metaParams, mockHandleKeyboardEvent); @@ -119,7 +119,7 @@ describe('withholder', () => { const metaParams = { e: { key: 'Meta' } as KeyboardEvent, state: ButtonState.UP, - cli: new TdpClient(() => null), + cli: new TdpClient(() => null, new BrowserFileSystem()), }; withholder.handleKeyboardEvent(metaParams, mockHandleKeyboardEvent); expect((withholder as any).withheldKeys).toHaveLength(1); diff --git a/web/packages/shared/components/DesktopSession/useDesktopSession.tsx b/web/packages/shared/components/DesktopSession/useDesktopSession.tsx index 15ea9e1db1bf5..e68041a114a2e 100644 --- a/web/packages/shared/components/DesktopSession/useDesktopSession.tsx +++ b/web/packages/shared/components/DesktopSession/useDesktopSession.tsx @@ -121,32 +121,13 @@ export default function useDesktopSession( } } - const onShareDirectory = () => { + const onShareDirectory = async () => { try { - window - .showDirectoryPicker() - .then(sharedDirHandle => { - // Permissions granted and/or directory selected - setDirectorySharingState(prevState => ({ - ...prevState, - directorySelected: true, - })); - tdpClient.addSharedDirectory(sharedDirHandle); - tdpClient.sendSharedDirectoryAnnounce(); - }) - .catch(e => { - setDirectorySharingState(prevState => ({ - ...prevState, - directorySelected: false, - })); - addAlert({ - severity: 'warn', - content: { - title: 'Failed to open the directory picker', - description: e.message, - }, - }); - }); + await tdpClient.shareDirectory(); + setDirectorySharingState(prevState => ({ + ...prevState, + directorySelected: true, + })); } catch (e) { setDirectorySharingState(prevState => ({ ...prevState, @@ -154,19 +135,9 @@ export default function useDesktopSession( })); addAlert({ severity: 'warn', - // This is a gross error message, but should be infrequent enough that its worth just telling - // the user the likely problem, while also displaying the error message just in case that's not it. - // In a perfect world, we could check for which error message this is and display - // context appropriate directions. content: { - title: 'Encountered an error while attempting to share a directory', - description: - e.message + - '. \n\nYour 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". If you\'re not already, please switch to a supported browser.', + title: 'Could not share a directory', + description: e.message, }, }); } diff --git a/web/packages/shared/libs/tdp/client.ts b/web/packages/shared/libs/tdp/client.ts index cb961087bb7a8..f942ef1bd93c3 100644 --- a/web/packages/shared/libs/tdp/client.ts +++ b/web/packages/shared/libs/tdp/client.ts @@ -51,9 +51,9 @@ import Codec, { } from './codec'; import { PathDoesNotExistError, - SharedDirectoryManager, + SharedDirectoryAccess, type FileOrDirInfo, -} from './sharedDirectoryManager'; +} from './sharedDirectoryAccess'; export enum TdpClientEvent { TDP_CLIENT_SCREEN_SPEC = 'tdp client screen spec', @@ -110,18 +110,17 @@ export class TdpClient extends EventEmitter { protected codec: Codec; protected transport: TdpTransport | undefined; private transportAbortController: AbortController | undefined; - private sdManager: SharedDirectoryManager; private fastPathProcessor: FastPathProcessor | undefined; private wasmReady: Promise | undefined; private logger = Logger.create('TDPClient'); constructor( - private getTransport: (signal: AbortSignal) => Promise + private getTransport: (signal: AbortSignal) => Promise, + private sharedDirectoryAccess: SharedDirectoryAccess ) { super(); this.codec = new Codec(); - this.sdManager = new SharedDirectoryManager(); } /** @@ -479,20 +478,22 @@ export class TdpClient extends EventEmitter { // Since this is not a fatal error, we emit a warning but otherwise // keep the sesion alive. this.handleWarning( - `Failed to share directory '${this.sdManager.getName()}', drive redirection may be disabled on the RDP server.`, + `Failed to share directory '${this.sharedDirectoryAccess.getDirectoryName()}', drive redirection may be disabled on the RDP server.`, TdpClientEvent.TDP_WARNING ); return; } - this.logger.info('Started sharing directory: ' + this.sdManager.getName()); + this.logger.info( + `Started sharing directory: ${this.sharedDirectoryAccess.getDirectoryName()}` + ); } async handleSharedDirectoryInfoRequest(buffer: ArrayBuffer) { const req = this.codec.decodeSharedDirectoryInfoRequest(buffer); const path = req.path; try { - const info = await this.sdManager.getInfo(path); + const info = await this.sharedDirectoryAccess.stat(path); this.sendSharedDirectoryInfoResponse({ completionId: req.completionId, errCode: SharedDirectoryErrCode.Nil, @@ -521,8 +522,8 @@ export class TdpClient extends EventEmitter { const req = this.codec.decodeSharedDirectoryCreateRequest(buffer); try { - await this.sdManager.create(req.path, req.fileType); - const info = await this.sdManager.getInfo(req.path); + await this.sharedDirectoryAccess.create(req.path, req.fileType); + const info = await this.sharedDirectoryAccess.stat(req.path); this.sendSharedDirectoryCreateResponse({ completionId: req.completionId, errCode: SharedDirectoryErrCode.Nil, @@ -548,7 +549,7 @@ export class TdpClient extends EventEmitter { const req = this.codec.decodeSharedDirectoryDeleteRequest(buffer); try { - await this.sdManager.delete(req.path); + await this.sharedDirectoryAccess.delete(req.path); this.sendSharedDirectoryDeleteResponse({ completionId: req.completionId, errCode: SharedDirectoryErrCode.Nil, @@ -564,7 +565,7 @@ export class TdpClient extends EventEmitter { async handleSharedDirectoryReadRequest(buffer: ArrayBuffer) { const req = this.codec.decodeSharedDirectoryReadRequest(buffer); - const readData = await this.sdManager.readFile( + const readData = await this.sharedDirectoryAccess.read( req.path, req.offset, req.length @@ -579,7 +580,7 @@ export class TdpClient extends EventEmitter { async handleSharedDirectoryWriteRequest(buffer: ArrayBuffer) { const req = this.codec.decodeSharedDirectoryWriteRequest(buffer); - const bytesWritten = await this.sdManager.writeFile( + const bytesWritten = await this.sharedDirectoryAccess.write( req.path, req.offset, req.writeData @@ -610,7 +611,8 @@ export class TdpClient extends EventEmitter { const req = this.codec.decodeSharedDirectoryListRequest(buffer); const path = req.path; - const infoList: FileOrDirInfo[] = await this.sdManager.listContents(path); + const infoList: FileOrDirInfo[] = + await this.sharedDirectoryAccess.readDir(path); const fsoList: FileSystemObject[] = infoList.map(info => this.toFso(info)); this.sendSharedDirectoryListResponse({ @@ -622,7 +624,7 @@ export class TdpClient extends EventEmitter { async handleSharedDirectoryTruncateRequest(buffer: ArrayBuffer) { const req = this.codec.decodeSharedDirectoryTruncateRequest(buffer); - await this.sdManager.truncateFile(req.path, req.endOfFile); + await this.sharedDirectoryAccess.truncate(req.path, req.endOfFile); this.sendSharedDirectoryTruncateResponse({ completionId: req.completionId, errCode: SharedDirectoryErrCode.Nil, @@ -706,12 +708,13 @@ export class TdpClient extends EventEmitter { this.send(msg); } - addSharedDirectory(sharedDirectory: FileSystemDirectoryHandle) { - this.sdManager.add(sharedDirectory); + async shareDirectory() { + await this.sharedDirectoryAccess.selectDirectory(); + this.sendSharedDirectoryAnnounce(); } sendSharedDirectoryAnnounce() { - const name = this.sdManager.getName(); + const name = this.sharedDirectoryAccess.getDirectoryName(); this.send( this.codec.encodeSharedDirectoryAnnounce({ discard: 0, // This is always the first request. diff --git a/web/packages/shared/libs/tdp/index.ts b/web/packages/shared/libs/tdp/index.ts index 602656d77b041..dc59db2619bf7 100644 --- a/web/packages/shared/libs/tdp/index.ts +++ b/web/packages/shared/libs/tdp/index.ts @@ -23,3 +23,4 @@ export { type BitmapFrame, } from './client'; export * from './codec'; +export * from './sharedDirectoryAccess'; diff --git a/web/packages/shared/libs/tdp/sharedDirectoryManager.ts b/web/packages/shared/libs/tdp/sharedDirectoryAccess.ts similarity index 77% rename from web/packages/shared/libs/tdp/sharedDirectoryManager.ts rename to web/packages/shared/libs/tdp/sharedDirectoryAccess.ts index 53f700974424e..dc9168c5f4902 100644 --- a/web/packages/shared/libs/tdp/sharedDirectoryManager.ts +++ b/web/packages/shared/libs/tdp/sharedDirectoryAccess.ts @@ -18,16 +18,54 @@ 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 { +export interface SharedDirectoryAccess { + /** Prompts the user to select a directory to share. */ + selectDirectory(): Promise; + /** Returns the name of the currently shared directory. */ + getDirectoryName(): string; + /** Retrieves metadata about a file or directory at the given path. */ + stat(path: string): Promise; + /** Lists files and directories within the given directory path. */ + readDir(path: string): Promise; + /** Reads a slice of a file. */ + read(path: string, offset: bigint, length: number): Promise; + /** Writes data to a file at a given offset. */ + write(path: string, offset: bigint, data: Uint8Array): Promise; + /** Truncates a file to the specified size. */ + truncate(path: string, size: number): Promise; + /** Creates a new file or directory at the given path. */ + create(path: string, fileType: FileType): Promise; + /** Deletes a file or directory at the given path. */ + delete(path: string): Promise; +} + +/** + * Enables directory sharing using FileSystem API. + * Most of the methods can potentially throw errors and so should be wrapped in try/catch blocks. + */ +export class BrowserFileSystem implements SharedDirectoryAccess { private dir: FileSystemDirectoryHandle | undefined; /** + * Opens a directory. * @throws Will throw an error if a directory is already being shared. */ - add(sharedDirectory: FileSystemDirectoryHandle) { + async selectDirectory() { + if (typeof window.showDirectoryPicker !== 'function') { + // This is a gross error message, but should be infrequent enough that its worth just telling + // the user the likely problem, while also displaying the error message just in case that's not it. + // In a perfect world, we could check for which error message this is and display + // context appropriate directions. + throw new Error( + '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". If you\'re not already, please switch to a supported browser.' + ); + } + + const sharedDirectory = await window.showDirectoryPicker(); if (this.dir) { throw new Error( 'SharedDirectoryManager currently only supports sharing a single directory' @@ -37,19 +75,19 @@ export class SharedDirectoryManager { } /** - * @throws Will throw an error if a directory has not already been initialized via add(). + * @throws Will throw an error if a directory has not already been initialized. */ - getName(): string { + getDirectoryName(): 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 Will throw an error if a directory has not already been initialized. * @throws {PathDoesNotExistError} if the pathstr isn't a valid path in the shared directory */ - async getInfo(path: string): Promise { + async stat(path: string): Promise { this.checkReady(); const fileOrDir = await this.walkPath(path); @@ -89,10 +127,10 @@ export class SharedDirectoryManager { /** * 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 Will throw an error if a directory has not already been initialized. * @throws {PathDoesNotExistError} if the pathstr isn't a valid path in the shared directory */ - async listContents(path: string): Promise { + async readDir(path: string): Promise { this.checkReady(); // Get the directory whose contents we want to list. @@ -110,7 +148,7 @@ export class SharedDirectoryManager { } else { entryPath = entry.name; } - infos.push(await this.getInfo(entryPath)); + infos.push(await this.stat(entryPath)); } return infos; @@ -118,10 +156,10 @@ export class SharedDirectoryManager { /** * 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 Will throw an error if a directory has not already been initialized. * @throws {PathDoesNotExistError} if the pathstr isn't a valid path in the shared directory */ - async readFile( + async read( path: string, offset: bigint, length: number @@ -136,14 +174,10 @@ export class SharedDirectoryManager { /** * 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 Will throw an error if a directory has not already been initialized. * @throws {PathDoesNotExistError} if the pathstr isn't a valid path in the shared directory */ - async writeFile( - path: string, - offset: bigint, - data: Uint8Array - ): Promise { + async write(path: string, offset: bigint, data: Uint8Array): Promise { this.checkReady(); const fileHandle = await this.getFileHandle(path); @@ -156,10 +190,10 @@ export class SharedDirectoryManager { /** * Truncates the file at path to size bytes. - * @throws Will throw an error if a directory has not already been initialized via add(). + * @throws Will throw an error if a directory has not already been initialized. * @throws {PathDoesNotExistError} if the pathstr isn't a valid path in the shared directory */ - async truncateFile(path: string, size: number): Promise { + async truncate(path: string, size: number): Promise { this.checkReady(); const fileHandle = await this.getFileHandle(path); const file = await fileHandle.createWritable({ keepExistingData: true }); @@ -276,7 +310,7 @@ export class SharedDirectoryManager { } /** - * @throws Will throw an error if a directory has not already been initialized via add(). + * @throws Will throw an error if a directory has not already been initialized. */ private checkReady() { if (!this.dir) { diff --git a/web/packages/teleport/src/DesktopSession/DesktopSession.tsx b/web/packages/teleport/src/DesktopSession/DesktopSession.tsx index 4d74ea6dbdb79..7bcaeda1a8700 100644 --- a/web/packages/teleport/src/DesktopSession/DesktopSession.tsx +++ b/web/packages/teleport/src/DesktopSession/DesktopSession.tsx @@ -24,7 +24,7 @@ import { DesktopSession as SharedDesktopSession, } from 'shared/components/DesktopSession'; import { useAsync } from 'shared/hooks/useAsync'; -import { TdpClient } from 'shared/libs/tdp'; +import { BrowserFileSystem, TdpClient } from 'shared/libs/tdp'; import { useTeleport } from 'teleport'; import AuthnDialog from 'teleport/components/AuthnDialog'; @@ -44,17 +44,19 @@ export function DesktopSession() { const [client] = useState( () => //TODO(gzdunek): It doesn't really matter here, but make TdpClient reactive to addr change. - new TdpClient(abortSignal => - adaptWebSocketToTdpTransport( - new AuthenticatedWebSocket( - cfg.api.desktopWsAddr - .replace(':fqdn', getHostName()) - .replace(':clusterId', clusterId) - .replace(':desktopName', desktopName) - .replace(':username', username) + new TdpClient( + abortSignal => + adaptWebSocketToTdpTransport( + new AuthenticatedWebSocket( + cfg.api.desktopWsAddr + .replace(':fqdn', getHostName()) + .replace(':clusterId', clusterId) + .replace(':desktopName', desktopName) + .replace(':username', username) + ), + abortSignal ), - abortSignal - ) + new BrowserFileSystem() ) ); const mfa = useMfaEmitter(client, undefined, { diff --git a/web/packages/teleport/src/lib/tdp/playerClient.ts b/web/packages/teleport/src/lib/tdp/playerClient.ts index 5633368da7f85..7e7f2055a6b3e 100644 --- a/web/packages/teleport/src/lib/tdp/playerClient.ts +++ b/web/packages/teleport/src/lib/tdp/playerClient.ts @@ -16,7 +16,7 @@ * along with this program. If not, see . */ -import { TdpClient, TdpClientEvent } from 'shared/libs/tdp'; +import { BrowserFileSystem, TdpClient, TdpClientEvent } from 'shared/libs/tdp'; import { base64ToArrayBuffer } from 'shared/utils/base64'; import { throttle } from 'shared/utils/highbar'; @@ -54,8 +54,10 @@ export class PlayerClient extends TdpClient { private timeout = null; constructor({ url, setTime, setPlayerStatus, setStatusText }) { - super(signal => - adaptWebSocketToTdpTransport(new AuthenticatedWebSocket(url), signal) + super( + signal => + adaptWebSocketToTdpTransport(new AuthenticatedWebSocket(url), signal), + new BrowserFileSystem() ); this.setPlayerStatus = setPlayerStatus; this.setStatusText = setStatusText;