diff --git a/CHANGELOG.md b/CHANGELOG.md index 4094fed..f111c0f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.11.8] - 2026-08-23 + +### Added + +- `?dl=1` on the file byte path switches `Content-Disposition` from `inline` + to `attachment`, so WebViews that implement a DownloadListener (the Android + app routes these into the system DownloadManager) download instead of + navigating to the file. + ## [0.11.7] - 2026-08-23 ### Added diff --git a/docs/REMOTE-CLIENTS.md b/docs/REMOTE-CLIENTS.md index 78aa358..ec16c18 100644 --- a/docs/REMOTE-CLIENTS.md +++ b/docs/REMOTE-CLIENTS.md @@ -331,6 +331,7 @@ GET {hub}/api/instances/{id}/fs/list?sessionId=…&path= one directory l GET {hub}/api/instances/{id}/fs/file?sessionId=…&path= file bytes &offset=…&length=… byte window → 206 + Content-Range &line=…&limit=… text window (defaults 1 / 200, cap 5000) + &dl=1 Content-Disposition: attachment (download, not inline) ``` - `path` resolves against the session's root cwd — the directory the session diff --git a/package.json b/package.json index e15958f..b7d0a36 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "zcode-acp-server", - "version": "0.11.7", + "version": "0.11.8", "description": "Agent Client Protocol (ACP) server bridging headless ZCode to editors like Zed and JetBrains.", "type": "module", "license": "Apache-2.0", diff --git a/src/remote/file-endpoint.ts b/src/remote/file-endpoint.ts index 97e545b..da99ef7 100644 --- a/src/remote/file-endpoint.ts +++ b/src/remote/file-endpoint.ts @@ -94,12 +94,15 @@ function mimeFor(file: string): string { /** * Content-Disposition for the byte path. The URL's basename is "file" (the * real name lives in a query param), so without this header browsers save - * direct-link downloads nameless. `inline` keeps previewable types rendering - * in-browser; the filename* form preserves non-ASCII names (RFC 6266/5987). + * direct-link downloads nameless. Default `inline` keeps previewable types + * rendering in-browser; `?dl=1` switches to `attachment` so WebViews (whose + * host implements a DownloadListener) download instead of navigating. The + * filename* form preserves non-ASCII names (RFC 6266/5987). */ -function contentDisposition(name: string): string { +function contentDisposition(name: string, attachment: boolean): string { const ascii = name.replace(/["\\]/g, "_").replace(/[^\x20-\x7e]/g, "_"); - return `inline; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`; + const type = attachment ? "attachment" : "inline"; + return `${type}; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(name)}`; } function sendText(res: ServerResponse, code: number, message: string): void { @@ -348,7 +351,10 @@ async function handleFile( // Same headers the GET would send, no body. const headers: Record = { "Content-Type": mime, - "Content-Disposition": contentDisposition(path.basename(file)), + "Content-Disposition": contentDisposition( + path.basename(file), + url.searchParams.get("dl") === "1", + ), }; if (hasByte) { headers["Content-Range"] = `bytes ${start}-${end}/${s.size}`; @@ -370,7 +376,10 @@ async function handleFile( req.on("close", () => stream.destroy()); const headers: Record = { "Content-Type": mime, - "Content-Disposition": contentDisposition(path.basename(file)), + "Content-Disposition": contentDisposition( + path.basename(file), + url.searchParams.get("dl") === "1", + ), }; if (hasByte) { headers["Content-Range"] = `bytes ${start}-${end}/${s.size}`; diff --git a/tests/remote-file-endpoint.test.ts b/tests/remote-file-endpoint.test.ts index f2b137f..df3cc2a 100644 --- a/tests/remote-file-endpoint.test.ts +++ b/tests/remote-file-endpoint.test.ts @@ -176,6 +176,12 @@ describe("session files over the hub proxy", () => { // Line windows are viewer-internal partial views — no download name. const win = await fsFetch(base, "/file?sessionId=s-fs&path=README.md&line=1&limit=1"); expect(win.headers.get("content-disposition")).toBeNull(); + + // ?dl=1 forces attachment so WebViews download instead of navigating. + const dl = await fsFetch(base, "/file?sessionId=s-fs&path=README.md&dl=1"); + expect(dl.headers.get("content-disposition")).toBe( + `attachment; filename="README.md"; filename*=UTF-8''README.md`, + ); }); it("serves text line windows with X-Zcode-First-Line", async () => {