mirror of
https://github.com/getpaseo/paseo.git
synced 2026-10-26 03:03:04 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a41e126f3 | ||
|
|
f508410d85 | ||
|
|
f83701e891 | ||
|
|
9a5d60c083 | ||
|
|
4e77bc0a2c |
@@ -2,10 +2,10 @@ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { test } from "../support/fixtures";
|
||||
import { openFileExplorer } from "../support/helpers/file-explorer";
|
||||
import { downloadFileFromExplorer, openFileExplorer } from "../support/helpers/file-explorer";
|
||||
import { openChangesPanel } from "../support/helpers/workspace-tabs";
|
||||
import { gotoWorkspace } from "../support/helpers/launcher";
|
||||
import { daemonWsRoutePattern } from "../support/helpers/daemon-port";
|
||||
import { daemonWsRoutePattern, getE2EDaemonPort } from "../support/helpers/daemon-port";
|
||||
import { seedWorkspace, type SeededWorkspace } from "../support/helpers/seed-client";
|
||||
|
||||
let workspace: SeededWorkspace;
|
||||
@@ -444,3 +444,25 @@ test("hides unsupported file operations and revert actions", async ({ page }) =>
|
||||
await expect(page.getByTestId("diff-file-0-duplicate")).toHaveCount(0);
|
||||
await expect(page.getByTestId("diff-file-0-revert")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("downloads a file over direct TCP with its original filename and bytes", async ({
|
||||
page,
|
||||
}, testInfo) => {
|
||||
const fileName = "rapport données.bin";
|
||||
const source = Buffer.from([0, 1, 2, 127, 128, 255, 10]);
|
||||
await writeFile(path.join(workspace.workspaceDirectory, fileName), source);
|
||||
await gotoWorkspace(page, workspace.workspaceId);
|
||||
await openFileExplorer(page);
|
||||
|
||||
const downloaded = await downloadFileFromExplorer(page, fileName);
|
||||
const url = new URL(downloaded.url());
|
||||
expect(url.protocol).toBe("http:");
|
||||
expect(url.port).toBe(getE2EDaemonPort());
|
||||
expect(url.pathname).toBe("/api/files/download");
|
||||
expect(url.searchParams.get("token")).toBeTruthy();
|
||||
expect(downloaded.suggestedFilename()).toBe(fileName);
|
||||
const savedFile = testInfo.outputPath(fileName);
|
||||
await downloaded.saveAs(savedFile);
|
||||
expect(await downloaded.failure()).toBeNull();
|
||||
expect(await readFile(savedFile)).toEqual(source);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { expect, type Page } from "@playwright/test";
|
||||
import { expect, type Download, type Page } from "@playwright/test";
|
||||
import { openFilesPanel } from "./workspace-tabs";
|
||||
|
||||
function fileExplorerTree(page: Page) {
|
||||
@@ -39,3 +39,10 @@ export async function expectFileTabOpen(page: Page, filePath: string): Promise<v
|
||||
timeout: 30_000,
|
||||
});
|
||||
}
|
||||
|
||||
export async function downloadFileFromExplorer(page: Page, fileName: string): Promise<Download> {
|
||||
await fileExplorerEntry(page, fileName).click({ button: "right" });
|
||||
const downloaded = page.waitForEvent("download");
|
||||
await page.getByRole("menuitem", { name: "Download", exact: true }).click();
|
||||
return downloaded;
|
||||
}
|
||||
|
||||
@@ -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, maxBytes: number) => {
|
||||
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, maxBytes, 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,265 @@
|
||||
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("bounds the session read even when the token reports a smaller file", async () => {
|
||||
const readRequests: Array<{ path: string; maxBytes: number }> = [];
|
||||
const saver = createFileSaver();
|
||||
await useDownloadStore.getState().startDownload({
|
||||
serverId: "srv_remote",
|
||||
scopeId: "workspace-1",
|
||||
fileName: FILE_NAME,
|
||||
path: FILE_PATH,
|
||||
daemonProfile: profileWith([relayConnection]),
|
||||
activeConnectionId: relayConnection.id,
|
||||
requestFileDownloadToken: createTokenRequest(1).request,
|
||||
readFile: async (path: string, maxBytes: number) => {
|
||||
readRequests.push({ path, maxBytes });
|
||||
return { bytes: FILE_BYTES, size: FILE_BYTES.byteLength };
|
||||
},
|
||||
saveFile: saver.save,
|
||||
});
|
||||
expect(readRequests).toEqual([{ path: FILE_PATH, maxBytes: MAX_SESSION_DOWNLOAD_BYTES }]);
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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, maxBytes: number) => 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, MAX_SESSION_DOWNLOAD_BYTES);
|
||||
// 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") {
|
||||
|
||||
@@ -2801,6 +2801,102 @@ test("readFile drops an old daemon's over-budget binary chunks and reports the r
|
||||
await expect(responsePromise).rejects.toThrow("File is too large to display");
|
||||
});
|
||||
|
||||
async function connectFileClient(clientId: string) {
|
||||
const mock = createMockTransport();
|
||||
const client = new DaemonClient({
|
||||
url: "ws://test",
|
||||
clientId,
|
||||
logger: createMockLogger(),
|
||||
reconnect: { enabled: false },
|
||||
transportFactory: () => mock.transport,
|
||||
});
|
||||
clients.push(client);
|
||||
const connectPromise = client.connect();
|
||||
mock.triggerOpen();
|
||||
await connectPromise;
|
||||
return { mock, client };
|
||||
}
|
||||
|
||||
function sendBinaryFile(
|
||||
mock: ReturnType<typeof createMockTransport>,
|
||||
requestId: string,
|
||||
announcedSize: number,
|
||||
chunks: string[],
|
||||
) {
|
||||
mock.triggerMessage(
|
||||
encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileBegin,
|
||||
requestId,
|
||||
metadata: {
|
||||
mime: "application/octet-stream",
|
||||
size: announcedSize,
|
||||
encoding: "binary",
|
||||
modifiedAt: "2026-05-02T00:00:00.000Z",
|
||||
},
|
||||
}),
|
||||
);
|
||||
for (const chunk of chunks) {
|
||||
mock.triggerMessage(
|
||||
encodeFileTransferFrame({
|
||||
opcode: FileTransferOpcode.FileChunk,
|
||||
requestId,
|
||||
payload: new TextEncoder().encode(chunk),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
test("readFile with timeout 0 outlives the default request timeout", async () => {
|
||||
useHeartbeatClock();
|
||||
const { mock, client } = await connectFileClient("clsk_file_no_timeout");
|
||||
|
||||
const responsePromise = client.readFile("/tmp/project", "slow.bin", "req-slow", undefined, 0);
|
||||
await vi.advanceTimersByTimeAsync(60_001);
|
||||
|
||||
sendBinaryFile(mock, "req-slow", 5, ["hello"]);
|
||||
mock.triggerMessage(
|
||||
encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-slow" }),
|
||||
);
|
||||
|
||||
const result = await responsePromise;
|
||||
expect(result.size).toBe(5);
|
||||
expect(new TextDecoder().decode(result.bytes)).toBe("hello");
|
||||
});
|
||||
|
||||
test("readFile rejects a binary transfer that ends before the announced size", async () => {
|
||||
const { mock, client } = await connectFileClient("clsk_file_short");
|
||||
|
||||
const responsePromise = client.readFile("/tmp/project", "short.bin", "req-short");
|
||||
sendBinaryFile(mock, "req-short", 10, ["hello"]);
|
||||
mock.triggerMessage(
|
||||
encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-short" }),
|
||||
);
|
||||
|
||||
await expect(responsePromise).rejects.toThrow(
|
||||
"File transfer incomplete: expected 10 bytes, received 5.",
|
||||
);
|
||||
});
|
||||
|
||||
test("readFile rejects a binary transfer that exceeds the announced size", async () => {
|
||||
const { mock, client } = await connectFileClient("clsk_file_long");
|
||||
|
||||
const responsePromise = client.readFile("/tmp/project", "long.bin", "req-long");
|
||||
const settled = responsePromise.then(
|
||||
() => null,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
sendBinaryFile(mock, "req-long", 4, ["hello!"]);
|
||||
|
||||
expect(() =>
|
||||
mock.triggerMessage(
|
||||
encodeFileTransferFrame({ opcode: FileTransferOpcode.FileEnd, requestId: "req-long" }),
|
||||
),
|
||||
).not.toThrow();
|
||||
expect(await settled).toMatchObject({
|
||||
message: "File transfer incomplete: expected 4 bytes, received 6.",
|
||||
});
|
||||
});
|
||||
|
||||
test("uploadFile sends metadata request and file bytes as binary chunks", async () => {
|
||||
const logger = createMockLogger();
|
||||
const mock = createMockTransport();
|
||||
|
||||
@@ -4741,6 +4741,7 @@ export class DaemonClient {
|
||||
requestId?: string,
|
||||
acceptBinary = false,
|
||||
maxBytes?: number,
|
||||
timeout?: number,
|
||||
): Promise<FileExplorerPayload> {
|
||||
return this.sendCorrelatedSessionRequest({
|
||||
requestId,
|
||||
@@ -4753,6 +4754,7 @@ export class DaemonClient {
|
||||
...(maxBytes ? { maxBytes } : {}),
|
||||
},
|
||||
responseType: "file_explorer_response",
|
||||
timeout,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4771,11 +4773,17 @@ export class DaemonClient {
|
||||
return payload.directory;
|
||||
}
|
||||
|
||||
/**
|
||||
* `timeout` bounds the whole transfer, not each frame. `0` disables the timer (as
|
||||
* `workspace.create` does) for files that legitimately take longer than the default over a
|
||||
* slow link; a dead connection still rejects on disconnect.
|
||||
*/
|
||||
async readFile(
|
||||
cwd: string,
|
||||
path: string,
|
||||
requestId?: string,
|
||||
maxBytes?: number,
|
||||
timeout?: number,
|
||||
): Promise<FileReadResult> {
|
||||
const resolvedRequestId = this.createRequestId(requestId);
|
||||
this.pendingBinaryFileReads.set(resolvedRequestId, { cwd, path, maxBytes });
|
||||
@@ -4787,6 +4795,7 @@ export class DaemonClient {
|
||||
resolvedRequestId,
|
||||
true,
|
||||
maxBytes,
|
||||
timeout,
|
||||
);
|
||||
if (payload.error) {
|
||||
throw new Error(payload.error);
|
||||
@@ -6523,6 +6532,18 @@ export class DaemonClient {
|
||||
return;
|
||||
}
|
||||
|
||||
const received = transfer.chunks.reduce((total, chunk) => total + chunk.byteLength, 0);
|
||||
if (received !== transfer.size) {
|
||||
this.activeBinaryFileTransfers.delete(frame.requestId);
|
||||
this.rejectWaitersForRequestId(
|
||||
frame.requestId,
|
||||
new Error(
|
||||
`File transfer incomplete: expected ${transfer.size} bytes, received ${received}.`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const bytes = concatByteChunks(transfer.chunks, transfer.size);
|
||||
this.activeBinaryFileTransfers.delete(frame.requestId);
|
||||
this.completedBinaryFileReads.set(frame.requestId, {
|
||||
|
||||
@@ -6,9 +6,14 @@ import net from "node:net";
|
||||
import path from "node:path";
|
||||
import { spawn, type ChildProcess } from "node:child_process";
|
||||
import { Buffer } from "node:buffer";
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
|
||||
import { generateLocalPairingOffer } from "../pairing-offer.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { FILE_EXPLORER_STREAM_CHUNK_BYTES } from "../file-explorer/service.js";
|
||||
import { createClientChannel, type Transport } from "@getpaseo/relay/e2ee";
|
||||
import {
|
||||
deriveSharedKey,
|
||||
@@ -73,6 +78,10 @@ function decodeOfferFromFragmentUrl(url: string): {
|
||||
return { serverId: offer.serverId, daemonPublicKeyB64: offer.daemonPublicKeyB64 };
|
||||
}
|
||||
|
||||
function sha256(bytes: Uint8Array): string {
|
||||
return createHash("sha256").update(bytes).digest("hex");
|
||||
}
|
||||
|
||||
function encodeCiphertext(ciphertext: ArrayBuffer): string {
|
||||
return Buffer.from(new Uint8Array(ciphertext)).toString("base64");
|
||||
}
|
||||
@@ -783,4 +792,67 @@ async function waitForCapturedLog(
|
||||
await stopRelay();
|
||||
}
|
||||
}, 90000);
|
||||
|
||||
test("reads a workspace file over the E2EE relay session byte-for-byte", async () => {
|
||||
process.env.PASEO_PRIMARY_LAN_IP = "192.168.1.12";
|
||||
|
||||
const { logger, lines } = createCapturingLogger();
|
||||
let daemon: Awaited<ReturnType<typeof createTestPaseoDaemon>> | null = null;
|
||||
let workspaceDir: string | null = null;
|
||||
const fileName = "rapport données.bin";
|
||||
const source = randomBytes(4 * FILE_EXPLORER_STREAM_CHUNK_BYTES + 123);
|
||||
let client: DaemonClient | null = null;
|
||||
|
||||
try {
|
||||
await startRelay({ useLocalRelay: true });
|
||||
daemon = await createTestPaseoDaemon({
|
||||
listen: "127.0.0.1",
|
||||
logger,
|
||||
relayEnabled: true,
|
||||
relayEndpoint: `127.0.0.1:${relayPort}`,
|
||||
});
|
||||
workspaceDir = await mkdtemp(path.join(os.tmpdir(), "paseo-relay-download-"));
|
||||
await writeFile(path.join(workspaceDir, fileName), source);
|
||||
const offerUrl = await getPairingOfferUrl({
|
||||
paseoHome: daemon.paseoHome,
|
||||
relayEnabled: daemon.config.relayEnabled,
|
||||
relayEndpoint: daemon.config.relayEndpoint,
|
||||
relayPublicEndpoint: daemon.config.relayPublicEndpoint,
|
||||
appBaseUrl: daemon.config.appBaseUrl,
|
||||
});
|
||||
const { serverId, daemonPublicKeyB64 } = decodeOfferFromFragmentUrl(offerUrl);
|
||||
|
||||
client = new DaemonClient({
|
||||
url: buildRelayWebSocketUrl({
|
||||
endpoint: `127.0.0.1:${relayPort}`,
|
||||
useTls: false,
|
||||
serverId,
|
||||
role: "client",
|
||||
}),
|
||||
clientType: "cli",
|
||||
connectTimeoutMs: 30_000,
|
||||
e2ee: { enabled: true, daemonPublicKeyB64 },
|
||||
reconnect: { enabled: false },
|
||||
});
|
||||
await client.connect();
|
||||
|
||||
const result = await client.readFile(workspaceDir, fileName, undefined, undefined, 0);
|
||||
|
||||
expect(result.size).toBe(source.byteLength);
|
||||
expect(result.bytes.byteLength).toBe(source.byteLength);
|
||||
expect(sha256(result.bytes)).toBe(sha256(source));
|
||||
} catch (err) {
|
||||
const tail = lines.slice(-50).join("");
|
||||
// eslint-disable-next-line no-console
|
||||
console.error("daemon logs (tail):\n", tail);
|
||||
throw err;
|
||||
} finally {
|
||||
await client?.close().catch(() => undefined);
|
||||
await daemon?.close();
|
||||
await stopRelay();
|
||||
if (workspaceDir) {
|
||||
await rm(workspaceDir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
}, 90000);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user