From 8f85e85f356eff9497797f7e6f79aa8a064c030d Mon Sep 17 00:00:00 2001 From: Michael Vandesompel Date: Thu, 8 Oct 2026 21:48:07 +0200 Subject: [PATCH] Download files over the session socket when no direct connection is active The download store only knew the HTTP token route and picked the first directTcp connection in the host profile, whatever connection the host runtime was using. A relay-only host has none, so every download ended in "Download host is unavailable." (#543). SSH, socket and pipe hosts failed the same way, and a stale directTcp entry won over a working relay (#3753). Choose the transport from the active connection: directTcp keeps the HTTP token download unchanged; anything else reads the file over the session socket with the binary frames previews already use, then hands the bytes to the browser's download manager on web and Electron or writes them to the cache directory and offers the share sheet on native. Files over 64 MiB are refused before any byte moves, using the existing fileTooLarge string, and a daemon response without content fails instead of saving an empty file. --- packages/app/src/hooks/use-file-download.ts | 20 +- .../src/hooks/use-file-explorer-actions.ts | 16 ++ .../app/src/stores/download-store.test.ts | 242 ++++++++++++++++++ packages/app/src/stores/download-store.ts | 119 ++++++++- 4 files changed, 378 insertions(+), 19 deletions(-) create mode 100644 packages/app/src/stores/download-store.test.ts 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") {