From 9fed315da8c159ba4fe797a7184b736e0c8113fc Mon Sep 17 00:00:00 2001 From: Chien Date: Mon, 1 Jun 2026 12:08:57 +0800 Subject: [PATCH 1/4] Support web downloads in browser adapter --- dio/README-ZH.md | 5 + dio/README.md | 7 + dio/lib/src/dio.dart | 10 + plugins/web_adapter/CHANGELOG.md | 3 +- plugins/web_adapter/README.md | 16 + plugins/web_adapter/lib/src/dio_impl.dart | 79 ++++- .../web_adapter/lib/src/download_trigger.dart | 78 +++++ plugins/web_adapter/test/download_test.dart | 305 ++++++++++++++++++ 8 files changed, 499 insertions(+), 4 deletions(-) create mode 100644 plugins/web_adapter/lib/src/download_trigger.dart create mode 100644 plugins/web_adapter/test/download_test.dart diff --git a/dio/README-ZH.md b/dio/README-ZH.md index 9cda516ec..28a92e7d8 100644 --- a/dio/README-ZH.md +++ b/dio/README-ZH.md @@ -161,6 +161,11 @@ response = await dio.download( ); ``` +在 Web 平台上,第二个参数会被当作浏览器下载的建议文件名,而不是本地文件系统路径。 +浏览器会决定实际保存位置;下载内容会先完整载入内存,并且仍受 CORS 限制。 +`FileAccessMode.append` 不支持,`deleteOnError` 没有可删除的本地文件,自定义 +`lengthHeader` 也不会用于 Web 下载进度总量。 + ### 以流的方式接收响应数据 ```dart diff --git a/dio/README.md b/dio/README.md index a98575eaf..f561b60df 100644 --- a/dio/README.md +++ b/dio/README.md @@ -145,6 +145,13 @@ response = await dio.download( ); ``` +On Web, the second argument is used as the browser's suggested filename instead +of a local filesystem path. The browser chooses the actual saved location, the +response is loaded into memory before the download is triggered, and CORS still +applies. `FileAccessMode.append` is not supported, `deleteOnError` has no local +file to delete, and custom `lengthHeader` values are not used for Web progress +totals. + ### Get response stream ```dart diff --git a/dio/lib/src/dio.dart b/dio/lib/src/dio.dart index 9d374c55b..8528148fa 100644 --- a/dio/lib/src/dio.dart +++ b/dio/lib/src/dio.dart @@ -203,6 +203,16 @@ abstract class Dio { /// ); /// ``` /// + /// On Web, browsers do not allow writing to arbitrary local file paths. + /// In that environment, [savePath] is used as the suggested filename for the + /// browser download. The browser decides the actual saved location, and the + /// returned [Response] only means the response was fetched and the download + /// was triggered. Web downloads load the whole response into memory before + /// triggering the browser download, remain subject to CORS, do not support + /// [FileAccessMode.append], and ignore [deleteOnError] because there is no + /// local file managed by Dio. The [lengthHeader] override is also not used + /// on Web; progress totals come from the browser response progress event. + /// /// [onReceiveProgress] is the callback to listen downloading progress. /// Please refer to [ProgressCallback]. /// diff --git a/plugins/web_adapter/CHANGELOG.md b/plugins/web_adapter/CHANGELOG.md index 87661cec5..12b71e63c 100644 --- a/plugins/web_adapter/CHANGELOG.md +++ b/plugins/web_adapter/CHANGELOG.md @@ -2,7 +2,8 @@ ## Unreleased -*None.* +- Support `Dio.download` on Web by fetching bytes and triggering a browser + download with a Blob URL. ## 2.1.2 diff --git a/plugins/web_adapter/README.md b/plugins/web_adapter/README.md index fcf52a716..b5193a73d 100644 --- a/plugins/web_adapter/README.md +++ b/plugins/web_adapter/README.md @@ -47,3 +47,19 @@ void main() async { print(response); } ``` + +## Downloading files + +`Dio.download` is supported on Web by fetching the response bytes and triggering +a browser download with a Blob URL. The `savePath` argument is treated as the +suggested filename, not as a local filesystem path. The browser decides the +actual save location. + +Web downloads have platform limitations: + +- The request is still subject to CORS because it is fetched through Dio. +- The whole response is loaded into memory before the browser download starts. +- `FileAccessMode.append` is not supported. +- `deleteOnError` has no local file to delete on Web. +- Custom `lengthHeader` values are not used; progress totals come from the + browser response progress event. diff --git a/plugins/web_adapter/lib/src/dio_impl.dart b/plugins/web_adapter/lib/src/dio_impl.dart index c9a210086..00ac32848 100644 --- a/plugins/web_adapter/lib/src/dio_impl.dart +++ b/plugins/web_adapter/lib/src/dio_impl.dart @@ -1,6 +1,10 @@ +import 'dart:async'; +import 'dart:typed_data'; + import 'package:dio/dio.dart'; import 'adapter_impl.dart'; +import 'download_trigger.dart' as download_trigger; /// Create the [Dio] instance for Web platforms. Dio createDio([BaseOptions? options]) => DioForBrowser(options); @@ -26,9 +30,78 @@ class DioForBrowser with DioMixin implements Dio { String lengthHeader = Headers.contentLengthHeader, Object? data, Options? options, - }) { - throw UnsupportedError( - 'The download method is not available in the Web environment.', + }) async { + if (fileAccessMode == FileAccessMode.append) { + throw UnsupportedError( + 'The append mode is not available when downloading files on Web.', + ); + } + if (savePath is! String && + savePath is! FutureOr Function(Headers)) { + throw ArgumentError.value( + savePath.runtimeType, + 'savePath', + 'The type must be `String` or `FutureOr Function(Headers)`.', + ); + } + + options ??= Options(method: 'GET'); + // Do not modify previous options. + options = options.copyWith(responseType: ResponseType.bytes); + + final response = await request>( + urlPath, + data: data, + options: options, + queryParameters: queryParameters, + cancelToken: cancelToken, + onReceiveProgress: onReceiveProgress, ); + final filename = await _resolveFilename(savePath, response); + final cancelError = cancelToken?.cancelError; + if (cancelError != null) { + throw cancelError; + } + final responseBytes = response.data; + final bytes = responseBytes == null + ? Uint8List(0) + : responseBytes is Uint8List + ? responseBytes + : Uint8List.fromList(responseBytes); + try { + download_trigger.triggerBrowserDownload( + bytes: bytes, + filename: filename, + contentType: response.headers.value(Headers.contentTypeHeader), + ); + } catch (e, s) { + if (e is DioException) { + rethrow; + } + throw DioException( + requestOptions: response.requestOptions, + response: response, + error: e, + stackTrace: s, + ); + } + return response; + } +} + +Future _resolveFilename(dynamic savePath, Response response) async { + if (savePath is FutureOr Function(Headers)) { + response.headers + ..set('redirects', response.redirects.length.toString()) + ..set('uri', response.realUri.toString()); + return _suggestedFilenameFromPath(await savePath(response.headers)); } + return _suggestedFilenameFromPath(savePath as String); +} + +String _suggestedFilenameFromPath(String path) { + final normalized = path.replaceAll('\\', '/'); + final slash = normalized.lastIndexOf('/'); + final filename = slash == -1 ? normalized : normalized.substring(slash + 1); + return filename.isEmpty ? 'download' : filename; } diff --git a/plugins/web_adapter/lib/src/download_trigger.dart b/plugins/web_adapter/lib/src/download_trigger.dart new file mode 100644 index 000000000..38a87ede7 --- /dev/null +++ b/plugins/web_adapter/lib/src/download_trigger.dart @@ -0,0 +1,78 @@ +import 'dart:js_interop'; +import 'dart:typed_data'; + +import 'package:meta/meta.dart'; +import 'package:web/web.dart' as web; + +typedef TriggerBrowserDownload = void Function({ + required Uint8List bytes, + required String filename, + String? contentType, +}); + +typedef CreateObjectUrl = String Function(JSObject object); + +typedef RevokeObjectUrl = void Function(String objectUrl); + +typedef CreateDownloadAnchor = web.HTMLAnchorElement Function( + String href, + String filename, +); + +typedef ClickDownloadAnchor = void Function(web.HTMLAnchorElement anchor); + +TriggerBrowserDownload triggerBrowserDownload = _triggerBrowserDownload; + +@visibleForTesting +CreateObjectUrl createObjectUrl = _createObjectUrl; + +@visibleForTesting +RevokeObjectUrl revokeObjectUrl = _revokeObjectUrl; + +@visibleForTesting +CreateDownloadAnchor createDownloadAnchor = _createDownloadAnchor; + +@visibleForTesting +ClickDownloadAnchor clickDownloadAnchor = (anchor) => anchor.click(); + +@visibleForTesting +void resetBrowserDownloadHooks() { + triggerBrowserDownload = _triggerBrowserDownload; + createObjectUrl = _createObjectUrl; + revokeObjectUrl = _revokeObjectUrl; + createDownloadAnchor = _createDownloadAnchor; + clickDownloadAnchor = (anchor) => anchor.click(); +} + +void _triggerBrowserDownload({ + required Uint8List bytes, + required String filename, + String? contentType, +}) { + final blobParts = [bytes.toJS].toJS; + final blob = contentType == null + ? web.Blob(blobParts) + : web.Blob(blobParts, web.BlobPropertyBag(type: contentType)); + final objectUrl = createObjectUrl(blob); + web.HTMLAnchorElement? anchor; + try { + anchor = createDownloadAnchor(objectUrl, filename); + web.document.body?.appendChild(anchor); + clickDownloadAnchor(anchor); + } finally { + anchor?.remove(); + revokeObjectUrl(objectUrl); + } +} + +web.HTMLAnchorElement _createDownloadAnchor(String href, String filename) { + return web.HTMLAnchorElement() + ..href = href + ..download = filename; +} + +String _createObjectUrl(JSObject object) => web.URL.createObjectURL(object); + +void _revokeObjectUrl(String objectUrl) { + web.URL.revokeObjectURL(objectUrl); +} diff --git a/plugins/web_adapter/test/download_test.dart b/plugins/web_adapter/test/download_test.dart new file mode 100644 index 000000000..3cadbe2ad --- /dev/null +++ b/plugins/web_adapter/test/download_test.dart @@ -0,0 +1,305 @@ +@TestOn('browser') +import 'dart:async'; +import 'dart:typed_data'; + +import 'package:dio/browser.dart'; +import 'package:dio/dio.dart'; +import 'package:dio_web_adapter/src/download_trigger.dart' as download_trigger; +import 'package:test/test.dart'; +import 'package:web/web.dart' as web; + +void main() { + group('DioForBrowser.download', () { + late DioForBrowser dio; + late _TestAdapter adapter; + late List<_Download> downloads; + + setUp(() { + adapter = _TestAdapter(); + dio = DioForBrowser(BaseOptions(baseUrl: 'https://example.com')) + ..httpClientAdapter = adapter; + downloads = []; + download_trigger.triggerBrowserDownload = ({ + required Uint8List bytes, + required String filename, + String? contentType, + }) { + downloads.add( + _Download( + bytes: bytes, + filename: filename, + contentType: contentType, + ), + ); + }; + }); + + tearDown(download_trigger.resetBrowserDownloadHooks); + + test('downloads bytes using savePath as suggested filename', () async { + adapter.body = Uint8List.fromList([1, 2, 3]); + adapter.headers = { + Headers.contentLengthHeader: ['3'], + Headers.contentTypeHeader: ['application/octet-stream'], + }; + + final options = Options(responseType: ResponseType.plain); + var progressEventCount = 0; + int? received; + int? total; + + final response = await dio.download( + '/bytes/3', + 'nested/file.bin', + options: options, + onReceiveProgress: (count, expectedTotal) { + progressEventCount++; + received = count; + total = expectedTotal; + }, + ); + + expect(response.statusCode, 200); + expect(response.data, [1, 2, 3]); + expect(options.responseType, ResponseType.plain); + expect(adapter.requests.single.responseType, ResponseType.bytes); + expect(progressEventCount, greaterThanOrEqualTo(1)); + expect(received, 3); + expect(total, 3); + expect(downloads, hasLength(1)); + expect(downloads.single.filename, 'file.bin'); + expect(downloads.single.bytes, [1, 2, 3]); + expect(downloads.single.contentType, 'application/octet-stream'); + }); + + test('keeps query and fragment characters in suggested filenames', + () async { + await dio.download('/bytes/1', 'report#1?.csv'); + + expect(downloads.single.filename, 'report#1?.csv'); + }); + + test('resolves savePath callback after response headers are available', + () async { + adapter.body = Uint8List.fromList([4, 5]); + + await dio.download( + '/payload', + (Headers headers) { + expect(headers.value('redirects'), '0'); + expect(headers.value('uri'), 'https://example.com/payload'); + return 'from-headers.txt'; + }, + ); + + expect(downloads.single.filename, 'from-headers.txt'); + expect(downloads.single.bytes, [4, 5]); + }); + + test('rejects unsupported savePath types', () async { + await expectLater( + dio.download('/bytes/1', Object()), + throwsA(isA()), + ); + expect(adapter.requests, isEmpty); + expect(downloads, isEmpty); + }); + + test('rejects append mode', () async { + await expectLater( + dio.download( + '/bytes/1', + 'file.bin', + fileAccessMode: FileAccessMode.append, + ), + throwsA(isA()), + ); + expect(adapter.requests, isEmpty); + expect(downloads, isEmpty); + }); + + test('does not trigger browser download for bad responses', () async { + adapter.statusCode = 500; + adapter.body = Uint8List.fromList([1]); + + await expectLater( + dio.download('/status/500', 'error.bin'), + throwsA( + isA().having( + (e) => e.type, + 'type', + DioExceptionType.badResponse, + ), + ), + ); + expect(downloads, isEmpty); + }); + + test('does not trigger browser download when cancelled before request', + () async { + final cancelToken = CancelToken()..cancel('cancelled'); + + await expectLater( + dio.download('/bytes/1', 'cancelled.bin', cancelToken: cancelToken), + throwsA( + isA().having( + (e) => e.type, + 'type', + DioExceptionType.cancel, + ), + ), + ); + expect(adapter.requests, isEmpty); + expect(downloads, isEmpty); + }); + + test('does not trigger browser download when cancelled after response', + () async { + final cancelToken = CancelToken(); + final completer = Completer(); + adapter.body = Uint8List.fromList([1]); + + final download = dio.download( + '/bytes/1', + (_) => completer.future, + cancelToken: cancelToken, + ); + + await Future.delayed(Duration.zero); + cancelToken.cancel('cancelled'); + completer.complete('cancelled.bin'); + + await expectLater( + download, + throwsA( + isA().having( + (e) => e.type, + 'type', + DioExceptionType.cancel, + ), + ), + ); + expect(downloads, isEmpty); + }); + + test('wraps browser download trigger failures in a DioException', () async { + download_trigger.triggerBrowserDownload = ({ + required Uint8List bytes, + required String filename, + String? contentType, + }) { + throw StateError('trigger'); + }; + + await expectLater( + dio.download('/bytes/1', 'file.bin'), + throwsA( + isA() + .having((e) => e.type, 'type', DioExceptionType.unknown) + .having((e) => e.error, 'error', isA()), + ), + ); + }); + }); + + group('triggerBrowserDownload', () { + tearDown(download_trigger.resetBrowserDownloadHooks); + + test('creates, clicks, removes, and revokes an object URL', () { + final revokedUrls = []; + web.HTMLAnchorElement? clickedAnchor; + + download_trigger.createObjectUrl = (_) => 'blob:dio-test'; + download_trigger.revokeObjectUrl = revokedUrls.add; + download_trigger.clickDownloadAnchor = (anchor) { + clickedAnchor = anchor; + expect(web.document.body!.contains(anchor), isTrue); + }; + + download_trigger.triggerBrowserDownload( + bytes: Uint8List.fromList([1, 2, 3]), + filename: 'file.bin', + contentType: 'application/octet-stream', + ); + + expect(clickedAnchor, isNotNull); + expect(clickedAnchor!.href, 'blob:dio-test'); + expect(clickedAnchor!.download, 'file.bin'); + expect(web.document.body!.contains(clickedAnchor), isFalse); + expect(revokedUrls, ['blob:dio-test']); + }); + + test('revokes the object URL when clicking throws', () { + final revokedUrls = []; + + download_trigger.createObjectUrl = (_) => 'blob:dio-test'; + download_trigger.revokeObjectUrl = revokedUrls.add; + download_trigger.clickDownloadAnchor = (_) => throw StateError('click'); + + expect( + () => download_trigger.triggerBrowserDownload( + bytes: Uint8List.fromList([1]), + filename: 'file.bin', + ), + throwsStateError, + ); + + expect(revokedUrls, ['blob:dio-test']); + }); + + test('revokes the object URL when creating the anchor throws', () { + final revokedUrls = []; + + download_trigger.createObjectUrl = (_) => 'blob:dio-test'; + download_trigger.revokeObjectUrl = revokedUrls.add; + download_trigger.createDownloadAnchor = + (_, __) => throw StateError('anchor'); + + expect( + () => download_trigger.triggerBrowserDownload( + bytes: Uint8List.fromList([1]), + filename: 'file.bin', + ), + throwsStateError, + ); + + expect(revokedUrls, ['blob:dio-test']); + }); + }); +} + +class _TestAdapter implements HttpClientAdapter { + int statusCode = 200; + Uint8List body = Uint8List(0); + Map> headers = const {}; + final requests = []; + + @override + Future fetch( + RequestOptions options, + Stream? requestStream, + Future? cancelFuture, + ) async { + requests.add(options); + return ResponseBody.fromBytes( + body, + statusCode, + headers: headers, + ); + } + + @override + void close({bool force = false}) {} +} + +class _Download { + const _Download({ + required this.bytes, + required this.filename, + required this.contentType, + }); + + final Uint8List bytes; + final String filename; + final String? contentType; +} From 085d1ab0063a9dd519fd043aa81ff1ff284a99f1 Mon Sep 17 00:00:00 2001 From: Chien Date: Mon, 1 Jun 2026 12:08:57 +0800 Subject: [PATCH 2/4] Address web download review feedback --- plugins/web_adapter/README.md | 10 ++++++++++ plugins/web_adapter/lib/src/download_trigger.dart | 6 ++++++ 2 files changed, 16 insertions(+) diff --git a/plugins/web_adapter/README.md b/plugins/web_adapter/README.md index b5193a73d..5810106de 100644 --- a/plugins/web_adapter/README.md +++ b/plugins/web_adapter/README.md @@ -55,10 +55,20 @@ a browser download with a Blob URL. The `savePath` argument is treated as the suggested filename, not as a local filesystem path. The browser decides the actual save location. +The browser schedules the download from a `blob:` URL created in the current +page. A returned `Response` means Dio fetched the response and dispatched the +browser download click; it does not guarantee that the browser wrote the file to +disk, kept the suggested filename, or skipped a user prompt. Those decisions are +controlled by the browser, user settings, and page security policies. + Web downloads have platform limitations: - The request is still subject to CORS because it is fetched through Dio. +- The network request is handled by the browser through XHR, so HTTP protocol + details such as HTTP/1.1, HTTP/2, or HTTP/3 are browser-controlled. - The whole response is loaded into memory before the browser download starts. +- The download trigger relies on standard browser support for `Blob`, + `URL.createObjectURL`, and `HTMLAnchorElement.download`. - `FileAccessMode.append` is not supported. - `deleteOnError` has no local file to delete on Web. - Custom `lengthHeader` values are not used; progress totals come from the diff --git a/plugins/web_adapter/lib/src/download_trigger.dart b/plugins/web_adapter/lib/src/download_trigger.dart index 38a87ede7..0709f55c5 100644 --- a/plugins/web_adapter/lib/src/download_trigger.dart +++ b/plugins/web_adapter/lib/src/download_trigger.dart @@ -49,6 +49,8 @@ void _triggerBrowserDownload({ required String filename, String? contentType, }) { + // Use JS typed arrays and package:web APIs instead of dart:html so this path + // remains compatible with Dart's WebAssembly compilation target. final blobParts = [bytes.toJS].toJS; final blob = contentType == null ? web.Blob(blobParts) @@ -57,9 +59,13 @@ void _triggerBrowserDownload({ web.HTMLAnchorElement? anchor; try { anchor = createDownloadAnchor(objectUrl, filename); + // Keep the anchor connected while clicking; some browsers are stricter + // about dispatching downloads from detached elements. web.document.body?.appendChild(anchor); clickDownloadAnchor(anchor); } finally { + // Once the click is dispatched, cleanup should happen even if browser + // policies later block, rename, or prompt for the actual download. anchor?.remove(); revokeObjectUrl(objectUrl); } From d78d5c309df99c0db4cd7a7a6d36e9b2d8c61bb3 Mon Sep 17 00:00:00 2001 From: Chien Date: Wed, 10 Jun 2026 09:11:14 +0800 Subject: [PATCH 3/4] Use Headers.add for redirects/uri extras in web download Aligns with DioForNative.download and avoids clobbering existing response headers with the same names. Co-Authored-By: Claude Fable 5 --- plugins/web_adapter/lib/src/dio_impl.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/web_adapter/lib/src/dio_impl.dart b/plugins/web_adapter/lib/src/dio_impl.dart index 00ac32848..e16275a6b 100644 --- a/plugins/web_adapter/lib/src/dio_impl.dart +++ b/plugins/web_adapter/lib/src/dio_impl.dart @@ -92,8 +92,8 @@ class DioForBrowser with DioMixin implements Dio { Future _resolveFilename(dynamic savePath, Response response) async { if (savePath is FutureOr Function(Headers)) { response.headers - ..set('redirects', response.redirects.length.toString()) - ..set('uri', response.realUri.toString()); + ..add('redirects', response.redirects.length.toString()) + ..add('uri', response.realUri.toString()); return _suggestedFilenameFromPath(await savePath(response.headers)); } return _suggestedFilenameFromPath(savePath as String); From 0c608def5d391cb49166cef820baeb2b7a99d75c Mon Sep 17 00:00:00 2001 From: Alex Li Date: Fri, 12 Jun 2026 01:46:44 +0800 Subject: [PATCH 4/4] test: defer package-scoped web coverage selection Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- melos.yaml | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/melos.yaml b/melos.yaml index f6275b1ca..27eb560f3 100644 --- a/melos.yaml +++ b/melos.yaml @@ -91,18 +91,21 @@ scripts: test:web:single: name: Dart Web tests in a browser exec: | - if [ "$TARGET_DART_SDK" = "min" ]; then - dart test --platform ${TEST_PLATFORM} --preset=${TEST_PRESET:-default},${TARGET_DART_SDK:-stable} --chain-stack-traces - else - if [ "${MELOS_PACKAGE_NAME:-}" = "dio_web_adapter" ] && [ "${TEST_PLATFORM}" = "chrome" ]; then - dart test --platform ${TEST_PLATFORM} --coverage=coverage/${TEST_PLATFORM} --preset=${TEST_PRESET:-default},${TARGET_DART_SDK:-stable} --chain-stack-traces + sh -c ' + if [ "\$TARGET_DART_SDK" = "min" ]; then + exec dart test --platform "\$TEST_PLATFORM" --preset="\${TEST_PRESET:-default},\${TARGET_DART_SDK:-stable}" --chain-stack-traces + fi + + if [ "\$MELOS_PACKAGE_NAME" = "dio_web_adapter" ] && [ "\$TEST_PLATFORM" = "chrome" ]; then + dart test --platform "\$TEST_PLATFORM" --coverage="coverage/\$TEST_PLATFORM" --preset="\${TEST_PRESET:-default},\${TARGET_DART_SDK:-stable}" --chain-stack-traces else - dart test --platform ${TEST_PLATFORM} --preset=${TEST_PRESET:-default},${TARGET_DART_SDK:-stable} --chain-stack-traces + dart test --platform "\$TEST_PLATFORM" --preset="\${TEST_PRESET:-default},\${TARGET_DART_SDK:-stable}" --chain-stack-traces fi - if [ "$WITH_WASM" = "true" ]; then - dart test --platform ${TEST_PLATFORM} --preset=${TEST_PRESET:-default},${TARGET_DART_SDK:-stable} --chain-stack-traces --compiler=dart2wasm + + if [ "\$WITH_WASM" = "true" ]; then + dart test --platform "\$TEST_PLATFORM" --preset="\${TEST_PRESET:-default},\${TARGET_DART_SDK:-stable}" --chain-stack-traces --compiler=dart2wasm fi - fi + ' packageFilters: flutter: false dirExists: test