diff --git a/packages/app/src/hooks/use-file-download.ts b/packages/app/src/hooks/use-file-download.ts index 91a88170b8..683b7a2381 100644 --- a/packages/app/src/hooks/use-file-download.ts +++ b/packages/app/src/hooks/use-file-download.ts @@ -1,5 +1,5 @@ import { useCallback, useMemo } from "react"; -import { useHosts } from "@/runtime/host-runtime"; +import { getHostRuntimeStore, useHosts } from "@/runtime/host-runtime"; import { useDownloadStore } from "@/stores/download-store"; import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions"; @@ -12,8 +12,9 @@ interface UseFileDownloadParams { /** * Returns a stable callback that downloads a single workspace file by its * workspace-relative path. Shared by the file explorer tree and the git diff - * pane so both surfaces download through the same host token + download-store - * pipeline instead of duplicating the plumbing. + * pane so both surfaces download through the same download-store pipeline instead + * of duplicating the plumbing: the HTTP token download over a direct TCP connection, + * the session socket over anything else. */ export function useFileDownload({ serverId, @@ -30,7 +31,7 @@ export function useFileDownload({ () => workspaceId?.trim() || normalizedWorkspaceRoot, [normalizedWorkspaceRoot, workspaceId], ); - const { requestFileDownloadToken } = useFileExplorerActions({ + const { requestFileDownloadToken, requestFileBytes } = useFileExplorerActions({ serverId, workspaceId, workspaceRoot: normalizedWorkspaceRoot, @@ -48,9 +49,18 @@ export function useFileDownload({ fileName, path, daemonProfile, + activeConnectionId: getHostRuntimeStore().getSnapshot(serverId)?.activeConnectionId ?? null, requestFileDownloadToken: (targetPath) => requestFileDownloadToken(targetPath), + readFile: requestFileBytes, }); }, - [daemonProfile, requestFileDownloadToken, serverId, startDownload, workspaceScopeId], + [ + daemonProfile, + requestFileBytes, + requestFileDownloadToken, + serverId, + startDownload, + workspaceScopeId, + ], ); } diff --git a/packages/app/src/hooks/use-file-explorer-actions.ts b/packages/app/src/hooks/use-file-explorer-actions.ts index 53714e37b3..42b22964fe 100644 --- a/packages/app/src/hooks/use-file-explorer-actions.ts +++ b/packages/app/src/hooks/use-file-explorer-actions.ts @@ -253,6 +253,21 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor [client, normalizedWorkspaceRoot, t], ); + const requestFileBytes = useCallback( + async (path: string) => { + if (!normalizedWorkspaceRoot) { + throw new Error(t("workspace.fileExplorer.states.unavailable")); + } + if (!client) { + throw new Error(t("workspace.terminal.hostDisconnected")); + } + // No request timeout: a large file over a slow relay legitimately takes longer than the + // 60 s default, and a dead connection still rejects on disconnect. + return client.readFile(normalizedWorkspaceRoot, path, undefined, undefined, 0); + }, + [client, normalizedWorkspaceRoot, t], + ); + const createEntry = useCallback( async (input: { parentPath: string; name: string; kind: "file" | "directory" }) => { if (!client || !normalizedWorkspaceRoot) { @@ -342,6 +357,7 @@ export function useFileExplorerActions(params: { serverId: string } & FileExplor requestDirectoryListing, requestFilePreview, requestFileDownloadToken, + requestFileBytes, createEntry, renameEntry, duplicateEntry, diff --git a/packages/app/src/stores/download-store.test.ts b/packages/app/src/stores/download-store.test.ts new file mode 100644 index 0000000000..75ffb1dcf0 --- /dev/null +++ b/packages/app/src/stores/download-store.test.ts @@ -0,0 +1,242 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import { defaultHostAppearance } from "@/hosts/appearance"; +import { i18n } from "@/i18n/i18next"; +import { + MAX_SESSION_DOWNLOAD_BYTES, + useDownloadStore, + type Download, + type DownloadedBytes, +} from "@/stores/download-store"; +import type { + DirectTcpHostConnection, + HostProfile, + RelayHostConnection, +} from "@/types/host-connection"; + +const FILE_BYTES = new TextEncoder().encode("relay download payload"); +const FILE_PATH = "reports/données été.bin"; +const FILE_NAME = "données été.bin"; +const MIME_TYPE = "application/octet-stream"; + +const relayConnection: RelayHostConnection = { + id: "relay:relay.paseo.sh:443", + type: "relay", + relayEndpoint: "relay.paseo.sh:443", + daemonPublicKeyB64: "cHVibGljLWtleQ==", +}; + +// The #3753 shape: the relay hostname stored as a directTcp entry that nothing can reach. +const staleTcpConnection: DirectTcpHostConnection = { + id: "direct:relay.paseo.sh:443", + type: "directTcp", + endpoint: "relay.paseo.sh:443", +}; + +const localTcpConnection: DirectTcpHostConnection = { + id: "direct:localhost:6767", + type: "directTcp", + endpoint: "localhost:6767", +}; + +function profileWith(connections: HostProfile["connections"]): HostProfile { + return { + serverId: "srv_remote", + label: "Remote", + appearance: defaultHostAppearance(), + lifecycle: {}, + connections, + preferredConnectionId: null, + createdAt: "2026-01-01T00:00:00.000Z", + updatedAt: "2026-01-01T00:00:00.000Z", + }; +} + +function createTokenRequest(size: number | null = FILE_BYTES.byteLength) { + const requested: string[] = []; + return { + requested, + request: async (path: string) => { + requested.push(path); + return { token: "tok", fileName: FILE_NAME, mimeType: MIME_TYPE, size, error: null }; + }, + }; +} + +function createFileReader(read: () => Promise<{ bytes: Uint8Array; size: number }>) { + const requested: string[] = []; + return { + requested, + read: async (path: string) => { + requested.push(path); + return read(); + }, + }; +} + +function createFileSaver() { + const savedFiles: DownloadedBytes[] = []; + return { + savedFiles, + save: async (file: DownloadedBytes) => { + savedFiles.push(file); + return null; + }, + }; +} + +interface StartInput { + profile: HostProfile; + activeConnectionId: string | null; + token?: ReturnType; + reader?: ReturnType; + saver?: ReturnType; +} + +async function startDownload(input: StartInput): Promise { + const token = input.token ?? createTokenRequest(); + const reader = + input.reader ?? + createFileReader(async () => ({ bytes: FILE_BYTES, size: FILE_BYTES.byteLength })); + const saver = input.saver ?? createFileSaver(); + await useDownloadStore.getState().startDownload({ + serverId: "srv_remote", + scopeId: "workspace-1", + fileName: FILE_NAME, + path: FILE_PATH, + daemonProfile: input.profile, + activeConnectionId: input.activeConnectionId, + requestFileDownloadToken: token.request, + readFile: reader.read, + saveFile: saver.save, + }); + const { activeDownloadId, downloads } = useDownloadStore.getState(); + return activeDownloadId ? downloads.get(activeDownloadId) : undefined; +} + +describe("download store transport selection", () => { + beforeEach(() => { + useDownloadStore.setState({ downloads: new Map(), activeDownloadId: null }); + }); + + it("downloads over the session when the active connection is the relay", async () => { + const token = createTokenRequest(); + const reader = createFileReader(async () => ({ + bytes: FILE_BYTES, + size: FILE_BYTES.byteLength, + })); + const saver = createFileSaver(); + + const download = await startDownload({ + profile: profileWith([relayConnection]), + activeConnectionId: relayConnection.id, + token, + reader, + saver, + }); + + expect(download).toMatchObject({ fileName: FILE_NAME, status: "complete" }); + expect(token.requested).toEqual([FILE_PATH]); + expect(reader.requested).toEqual([FILE_PATH]); + expect(saver.savedFiles).toEqual([ + { bytes: FILE_BYTES, fileName: FILE_NAME, mimeType: MIME_TYPE }, + ]); + }); + + it("ignores a directTcp entry that is not the active connection", async () => { + const reader = createFileReader(async () => ({ + bytes: FILE_BYTES, + size: FILE_BYTES.byteLength, + })); + const saver = createFileSaver(); + + const download = await startDownload({ + profile: profileWith([staleTcpConnection, relayConnection]), + activeConnectionId: relayConnection.id, + reader, + saver, + }); + + expect(download).toMatchObject({ status: "complete" }); + expect(reader.requested).toEqual([FILE_PATH]); + expect(saver.savedFiles).toHaveLength(1); + }); + + it("keeps the HTTP token download when the active connection is directTcp", async () => { + const reader = createFileReader(async () => ({ + bytes: FILE_BYTES, + size: FILE_BYTES.byteLength, + })); + const saver = createFileSaver(); + + const download = await startDownload({ + profile: profileWith([localTcpConnection, relayConnection]), + activeConnectionId: localTcpConnection.id, + reader, + saver, + }); + + expect(download).toMatchObject({ status: "complete" }); + expect(reader.requested).toEqual([]); + expect(saver.savedFiles).toEqual([]); + }); + + it("fails the download and saves nothing when the session read fails", async () => { + const reader = createFileReader(async () => { + throw new Error("Connection lost"); + }); + const saver = createFileSaver(); + + const download = await startDownload({ + profile: profileWith([relayConnection]), + activeConnectionId: relayConnection.id, + reader, + saver, + }); + + expect(download).toMatchObject({ status: "error", message: "Connection lost" }); + expect(saver.savedFiles).toEqual([]); + }); + + it("refuses a file over the session size limit before reading it", async () => { + const token = createTokenRequest(MAX_SESSION_DOWNLOAD_BYTES + 1); + const reader = createFileReader(async () => ({ + bytes: FILE_BYTES, + size: FILE_BYTES.byteLength, + })); + const saver = createFileSaver(); + + const download = await startDownload({ + profile: profileWith([relayConnection]), + activeConnectionId: relayConnection.id, + token, + reader, + saver, + }); + + expect(download).toMatchObject({ + status: "error", + message: i18n.t("composer.errors.fileTooLarge", { fileName: FILE_NAME, size: "64 MB" }), + }); + expect(download?.message).toContain("64 MB"); + expect(reader.requested).toEqual([]); + expect(saver.savedFiles).toEqual([]); + }); + + it("fails instead of saving an empty file when the daemon returns no content", async () => { + const reader = createFileReader(async () => ({ bytes: new Uint8Array(), size: 6 })); + const saver = createFileSaver(); + + const download = await startDownload({ + profile: profileWith([relayConnection]), + activeConnectionId: relayConnection.id, + reader, + saver, + }); + + expect(download).toMatchObject({ + status: "error", + message: "File transfer incomplete: expected 6 bytes, received 0.", + }); + expect(saver.savedFiles).toEqual([]); + }); +}); diff --git a/packages/app/src/stores/download-store.ts b/packages/app/src/stores/download-store.ts index 6d13b3ed3c..3b9935a86e 100644 --- a/packages/app/src/stores/download-store.ts +++ b/packages/app/src/stores/download-store.ts @@ -16,6 +16,16 @@ interface DownloadProgress { eta: number; } +// The session path holds the file in memory two to three times over (client chunks, the +// concatenated result, then the Blob or native write). Lift once the client streams chunks to disk. +export const MAX_SESSION_DOWNLOAD_BYTES = 64 * 1024 * 1024; + +export interface DownloadedBytes { + bytes: Uint8Array; + fileName: string; + mimeType: string | null; +} + export interface Download { id: string; serverId: string; @@ -37,12 +47,18 @@ interface DownloadState { fileName: string; path: string; daemonProfile: HostProfile | undefined; + /** Connection the host runtime is using right now; `null` while disconnected. */ + activeConnectionId: string | null; requestFileDownloadToken: (path: string) => Promise<{ token: string | null; fileName: string | null; mimeType: string | null; + size: number | null; error: string | null; }>; + /** Reads the whole file over the session socket; used when no direct HTTP endpoint is active. */ + readFile: (path: string) => Promise<{ bytes: Uint8Array; size: number }>; + saveFile?: typeof saveDownloadedBytes; }) => Promise; updateProgress: (id: string, progress: DownloadProgress) => void; @@ -66,7 +82,10 @@ export const useDownloadStore = create()((set, get) => ({ fileName, path, daemonProfile, + activeConnectionId, requestFileDownloadToken, + readFile, + saveFile = saveDownloadedBytes, }) => { const id = generateDownloadId(); const download: Download = { @@ -89,12 +108,42 @@ export const useDownloadStore = create()((set, get) => ({ throw new Error(tokenResponse.error ?? i18n.t("downloads.requestTokenFailed")); } - const downloadTarget = resolveDaemonDownloadTarget(daemonProfile); + const resolvedFileName = tokenResponse.fileName ?? fileName; + const downloadTarget = resolveDaemonDownloadTarget(daemonProfile, activeConnectionId); + if (!downloadTarget) { + // Relay, SSH, socket and pipe hosts have no HTTP endpoint: read the file over the + // session socket that is already connected (#543). + if ( + typeof tokenResponse.size === "number" && + tokenResponse.size > MAX_SESSION_DOWNLOAD_BYTES + ) { + throw new Error( + i18n.t("composer.errors.fileTooLarge", { fileName: resolvedFileName, size: "64 MB" }), + ); + } + const { bytes, size } = await readFile(path); + // A daemon without a binary channel answers `encoding: "none"` for a binary file: no + // content, real size. Never save that as a complete download. + if (bytes.byteLength !== size) { + throw new Error( + `File transfer incomplete: expected ${size} bytes, received ${bytes.byteLength}.`, + ); + } + const savedUri = await saveFile({ + bytes, + fileName: resolvedFileName, + mimeType: tokenResponse.mimeType, + }); + get().completeDownload(id); + if (savedUri !== null) { + await shareSavedFile(savedUri, tokenResponse.mimeType, resolvedFileName); + } + return; + } if (!downloadTarget.baseUrl) { throw new Error(i18n.t("downloads.hostUnavailable")); } - const resolvedFileName = tokenResponse.fileName ?? fileName; const downloadUrl = buildDownloadUrl( downloadTarget.baseUrl, tokenResponse.token, @@ -146,14 +195,7 @@ export const useDownloadStore = create()((set, get) => ({ get().completeDownload(id); - if (await Sharing.isAvailableAsync()) { - await Sharing.shareAsync(result.uri, { - mimeType: tokenResponse.mimeType ?? undefined, - dialogTitle: resolvedFileName - ? i18n.t("downloads.shareFileNamed", { fileName: resolvedFileName }) - : i18n.t("downloads.shareFile"), - }); - } + await shareSavedFile(result.uri, tokenResponse.mimeType, resolvedFileName); } catch (error) { const message = error instanceof Error ? error.message : i18n.t("downloads.failed"); if (isWeb) { @@ -244,10 +286,18 @@ interface DownloadTarget { authCredentials: { username: string; password: string } | null; } -function resolveDaemonDownloadTarget(daemon?: HostProfile): DownloadTarget { - const connection = daemon?.connections.find((conn) => conn.type === "directTcp") ?? null; - if (!connection) { - return { baseUrl: null, authHeader: null, authCredentials: null }; +/** + * The HTTP download route is only reachable over a direct TCP connection. Returns `null` when + * the connection in use is anything else (relay, SSH, socket, pipe, or none yet), so the caller + * reads the file over the session socket instead. + */ +function resolveDaemonDownloadTarget( + daemon: HostProfile | undefined, + activeConnectionId: string | null, +): DownloadTarget | null { + const connection = daemon?.connections.find((conn) => conn.id === activeConnectionId); + if (connection?.type !== "directTcp") { + return null; } let parsed: URL; @@ -299,6 +349,47 @@ function buildDownloadUrl( return url.toString(); } +/** + * Saves bytes read over the session. Web and Electron hand them to the browser's download + * manager and return `null`; native writes the file and returns its URI for the share sheet. + */ +async function saveDownloadedBytes({ + bytes, + fileName, + mimeType, +}: DownloadedBytes): Promise { + if (isWeb) { + const buffer = new ArrayBuffer(bytes.byteLength); + new Uint8Array(buffer).set(bytes); + const url = URL.createObjectURL( + new Blob([buffer], { type: mimeType ?? "application/octet-stream" }), + ); + triggerBrowserDownload(url, fileName); + // The browser may still be reading the blob while its save prompt is open. + setTimeout(() => URL.revokeObjectURL(url), 60_000); + return null; + } + // Resolve and write in one tick so two downloads of the same name cannot pick the same path. + const targetFile = resolveDownloadTargetFile(fileName); + targetFile.write(bytes); + return targetFile.uri; +} + +async function shareSavedFile( + uri: string, + mimeType: string | null, + fileName: string, +): Promise { + if (await Sharing.isAvailableAsync()) { + await Sharing.shareAsync(uri, { + mimeType: mimeType ?? undefined, + dialogTitle: fileName + ? i18n.t("downloads.shareFileNamed", { fileName }) + : i18n.t("downloads.shareFile"), + }); + } +} + function triggerBrowserDownload(url: string, fileName: string) { if (typeof document === "undefined") { if (typeof window !== "undefined") {