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.
This commit is contained in:
Michael Vandesompel
2026-10-14 14:15:11 +02:00
committed by paseo-bot[bot]
parent fe422acbf4
commit 8f85e85f35
4 changed files with 378 additions and 19 deletions
+15 -5
View File
@@ -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,
],
);
}
@@ -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,
@@ -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<typeof createTokenRequest>;
reader?: ReturnType<typeof createFileReader>;
saver?: ReturnType<typeof createFileSaver>;
}
async function startDownload(input: StartInput): Promise<Download | undefined> {
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([]);
});
});
+105 -14
View File
@@ -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<void>;
updateProgress: (id: string, progress: DownloadProgress) => void;
@@ -66,7 +82,10 @@ export const useDownloadStore = create<DownloadState>()((set, get) => ({
fileName,
path,
daemonProfile,
activeConnectionId,
requestFileDownloadToken,
readFile,
saveFile = saveDownloadedBytes,
}) => {
const id = generateDownloadId();
const download: Download = {
@@ -89,12 +108,42 @@ export const useDownloadStore = create<DownloadState>()((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<DownloadState>()((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<string | null> {
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<void> {
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") {