diff --git a/packages/reflex-base/news/+download-blob-url.bugfix.md b/packages/reflex-base/news/+download-blob-url.bugfix.md new file mode 100644 index 00000000000..9ce9c56dad6 --- /dev/null +++ b/packages/reflex-base/news/+download-blob-url.bugfix.md @@ -0,0 +1 @@ +`rx.download` with `data` saves the file on iOS (Safari, other iOS browsers and installed web apps) instead of only showing it. diff --git a/packages/reflex-frontend/src/utils/state.js b/packages/reflex-frontend/src/utils/state.js index d1d5acf5a28..0542133e803 100644 --- a/packages/reflex-frontend/src/utils/state.js +++ b/packages/reflex-frontend/src/utils/state.js @@ -694,6 +694,28 @@ export const applyEvent = async (event, socket, navigate, params) => { ); } a.download = event.payload.filename; + // iOS WebKit ignores the download attribute of data: urls and only shows + // the file, so save inline data through a blob: url instead. The data is + // decoded here because a connect-src policy can block fetching a data: url. + if (a.href.startsWith("data:")) { + const [url] = a.href.split("#", 1); + const comma = url.indexOf(","); + const meta = url.slice(5, comma); + const type = meta.replace(/;\s*base64\s*$/i, ""); + // a.href is serialized ascii, so each percent-decoded char is one byte. + let raw = url + .slice(comma + 1) + .replace(/%([0-9a-f]{2})/gi, (_, hex) => + String.fromCharCode(parseInt(hex, 16)), + ); + if (type !== meta) raw = atob(raw); + const bytes = new Uint8Array(raw.length); + for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i); + a.href = URL.createObjectURL(new Blob([bytes], { type })); + // Release the blob url after one minute. + setTimeout(() => URL.revokeObjectURL(a.href), 60000); + } + document.body.appendChild(a); a.click(); a.remove(); return; diff --git a/tests/integration/tests_playwright/test_download.py b/tests/integration/tests_playwright/test_download.py new file mode 100644 index 00000000000..ed319fc821e --- /dev/null +++ b/tests/integration/tests_playwright/test_download.py @@ -0,0 +1,141 @@ +"""Integration test for ``rx.download`` with inline data. + +iOS WebKit ignores the ``download`` attribute of ``data:`` urls and only shows +the file, so data has to be saved through a ``blob:`` url. The page runs under a +``connect-src`` policy that does not allow ``data:``, so the frontend has to +decode the data itself rather than fetch it. +""" + +from collections.abc import Generator +from pathlib import Path + +import pytest +from playwright.sync_api import Page, expect + +from reflex.testing import AppHarness + + +def DownloadApp(): + """App downloading inline data from the frontend and from a backend event.""" + import reflex as rx + + class State(rx.State): + text: str = "var;text#%41 รค" + base64_url: str = "data:text/plain;base64,aGk%3D#part" + text_url: str = "data:text/plain,a%20b%FF#part" + + @rx.event + def download_bytes(self): + return rx.download(data=b"backend,bytes\n", filename="backend.csv") + + @rx.page("/") + def index(): + return rx.box( + rx.input( + value=State.router.session.client_token, + read_only=True, + id="token", + ), + rx.button( + "text", + on_click=rx.download(data="frontend;text#%41", filename="frontend.txt"), + id="download-text", + ), + rx.button( + "var", + on_click=rx.download(data=State.text, filename="var.txt"), + id="download-var", + ), + rx.button( + "base64 url", + on_click=rx.download(data=State.base64_url, filename="base64.txt"), + id="download-base64-url", + ), + rx.button( + "text url", + on_click=rx.download(data=State.text_url, filename="text.txt"), + id="download-text-url", + ), + rx.button("bytes", on_click=State.download_bytes, id="download-bytes"), + ) + + app = rx.App( # noqa: F841 + head_components=[ + rx.el.meta( + http_equiv="Content-Security-Policy", + content="connect-src 'self' http: ws:", + ) + ] + ) + + +@pytest.fixture(scope="module") +def download_app(tmp_path_factory) -> Generator[AppHarness, None, None]: + """Start DownloadApp via AppHarness. + + Args: + tmp_path_factory: pytest fixture for creating temporary directories. + + Yields: + Running AppHarness instance. + """ + with AppHarness.create( + root=tmp_path_factory.mktemp("download_app"), + app_source=DownloadApp, + ) as harness: + assert harness.app_instance is not None, "app is not running" + yield harness + + +@pytest.mark.parametrize( + ("button_id", "filename", "content"), + [ + ("download-text", "frontend.txt", b"frontend;text#%41"), + ("download-var", "var.txt", '"var;text#%41 \u00e4"'.encode()), + ("download-base64-url", "base64.txt", b"hi"), + ("download-text-url", "text.txt", b"a b\xff"), + ("download-bytes", "backend.csv", b"backend,bytes\n"), + ], +) +def test_download_data_through_blob_url( + download_app: AppHarness, + page: Page, + button_id: str, + filename: str, + content: bytes, +): + """Inline data downloads from a blob: url with its file name and content. + + The page runs under a ``connect-src`` policy that blocks fetching ``data:`` + urls. + + Args: + download_app: AppHarness running the test app. + page: Playwright page. + button_id: The id of the button that starts the download. + filename: The expected file name. + content: The expected file content. + """ + assert download_app.frontend_url is not None + + # Record the blob urls the page creates, to check the download is one of them. + page.add_init_script(""" + const createObjectURL = URL.createObjectURL; + window.blobUrls = []; + URL.createObjectURL = (obj) => { + const url = createObjectURL(obj); + window.blobUrls.push(url); + return url; + }; + """) + page.goto(download_app.frontend_url) + expect(page.locator("#token")).not_to_have_value("") + page.evaluate("window.blobUrls = []") + + with page.expect_download() as download_info: + page.click(f"#{button_id}") + download = download_info.value + + assert page.evaluate("window.blobUrls") == [download.url] + assert download.suggested_filename == filename + assert Path(download.path()).read_bytes() == content