Skip to content
Open
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/reflex-base/news/+download-blob-url.bugfix.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 22 additions & 0 deletions packages/reflex-base/src/reflex_base/.templates/web/utils/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,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 comma = a.href.indexOf(",");
const meta = a.href.slice(5, comma);
const type = meta.replace(/;\s*base64\s*$/i, "");
const payload = a.href.slice(comma + 1);
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
// a.href is serialized ascii, so each decoded char is one byte.
const raw =
type !== meta
? atob(payload)
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
Resolved
: payload.replace(/%([0-9a-f]{2})/gi, (_, hex) =>
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
String.fromCharCode(parseInt(hex, 16)),
);
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;
Expand Down
116 changes: 116 additions & 0 deletions tests/integration/tests_playwright/test_download.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
"""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 ä"

@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("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-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

page.goto(download_app.frontend_url)
expect(page.locator("#token")).not_to_have_value("")

with page.expect_download() as download_info:
page.click(f"#{button_id}")
download = download_info.value

assert download.url.startswith("blob:")
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Resolved
assert download.suggested_filename == filename
assert Path(download.path()).read_bytes() == content
Loading