diff --git a/docs/architecture.md b/docs/architecture.md index c049690..6fd7d7a 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -204,7 +204,10 @@ left waiting for a `controllerchange` that is never coming. returns a `WritableStream` backed by the MessagePort sink (one `PULL` credit per `WRITE`, so the service worker's 4-chunk queue is the backpressure boundary). Aborting the writable errors the response so the browser shows a -failed download instead of a truncated file. +failed download instead of a truncated file. The iframe is only removed on +failure, or when the next download starts: iOS Safari finalises a download +when its initiating frame goes away, even while accepted bytes are still +being flushed to disk. ### `export` (browser only, covered by e2e) diff --git a/src/lib/download/README.md b/src/lib/download/README.md index 3d60869..6d65302 100644 --- a/src/lib/download/README.md +++ b/src/lib/download/README.md @@ -25,6 +25,12 @@ activation from the click is still live. The worker cannot see a navigation that never reached it, so the page waits for its `downloadStarted` message before handing over the `MessagePort` and returning the writable. +The iframe outlives the writable. The worker's `PULL`s only mean the +browser's network layer has read the bytes, not that they are on disk, and +iOS Safari finalises a download the moment its initiating frame is removed, +saving only what had been flushed. So `cleanup()` is for failures; after +success the iframe stays until the next `startDownload` replaces it. + ## Backpressure and errors The sink posts one `WRITE` per chunk and waits for the worker's `PULL` before diff --git a/src/lib/download/index.ts b/src/lib/download/index.ts index 6eaaf8e..edec206 100644 --- a/src/lib/download/index.ts +++ b/src/lib/download/index.ts @@ -24,10 +24,15 @@ export interface DownloadOptions { export interface Download { writable: WritableStream; - /** Removes the iframe that started the download. */ + /** Removes the iframe that started the download. Call it only after a + * failure: iOS Safari finalises a download when its initiating frame goes + * away, even while bytes it has already accepted are still being flushed + * to disk, so after success the iframe stays until the next download. */ cleanup(): void; } +let activeIframe: HTMLIFrameElement | undefined; + export function registerDownloadWorker(): void { if (!("serviceWorker" in navigator)) return; // `updateViaCache: 'none'` bypasses the browser's 24h HTTP-cache rule for sw.js. @@ -143,11 +148,16 @@ async function prepareSwDownload( // Navigate first and synchronously, before any await: Safari only starts a // download while the click's user activation is live. The service worker // holds the request open until the stream below reaches it. + activeIframe?.remove(); const iframe = document.createElement("iframe"); iframe.hidden = true; iframe.src = url; document.body.appendChild(iframe); - const cleanup = () => iframe.remove(); + activeIframe = iframe; + const cleanup = () => { + iframe.remove(); + if (activeIframe === iframe) activeIframe = undefined; + }; // Nothing is generated until the request is known to have arrived. if (!(await waitForDownloadStart(url, DOWNLOAD_START_TIMEOUT_MS))) { diff --git a/src/lib/export/README.md b/src/lib/export/README.md index 3ff5836..552894a 100644 --- a/src/lib/export/README.md +++ b/src/lib/export/README.md @@ -51,5 +51,7 @@ last band is rendered rather than as the last byte is written. Aborting `signal` stops the mosaic and aborts the download, so the browser reports a failed download instead of leaving a truncated PNG. Any other -failure does the same. The renderer is destroyed and the download's iframe -removed in `finally`, whatever happened. +failure does the same, and removes the download's iframe. After success the +iframe is left in place: iOS Safari finalises a download when its initiating +frame is removed, even while accepted bytes are still being flushed to disk. +The renderer is destroyed in `finally`, whatever happened. diff --git a/src/lib/export/index.ts b/src/lib/export/index.ts index e59da4f..b5c8e6f 100644 --- a/src/lib/export/index.ts +++ b/src/lib/export/index.ts @@ -125,10 +125,10 @@ export async function exportMap(opts: ExportOptions): Promise { // The response only errors if the worker is told; a failure before or // outside `pipeTo` would otherwise leave a truncated file on disk. await writable.abort(err).catch(() => {}); + cleanup(); throw err; } finally { renderer?.destroy(); - cleanup(); } return { bbox: viewportBbox(viewport), zoom: viewport.zoom };