Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
5 changes: 5 additions & 0 deletions dio/README-ZH.md
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,11 @@ response = await dio.download(
);
```

在 Web 平台上,第二个参数会被当作浏览器下载的建议文件名,而不是本地文件系统路径。
浏览器会决定实际保存位置;下载内容会先完整载入内存,并且仍受 CORS 限制。
`FileAccessMode.append` 不支持,`deleteOnError` 没有可删除的本地文件,自定义
`lengthHeader` 也不会用于 Web 下载进度总量。

### 以流的方式接收响应数据

```dart
Expand Down
7 changes: 7 additions & 0 deletions dio/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions dio/lib/src/dio.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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].
///
Expand Down
21 changes: 12 additions & 9 deletions melos.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion plugins/web_adapter/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
26 changes: 26 additions & 0 deletions plugins/web_adapter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,3 +47,29 @@ 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.

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
browser response progress event.
79 changes: 76 additions & 3 deletions plugins/web_adapter/lib/src/dio_impl.dart
Original file line number Diff line number Diff line change
@@ -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);
Expand All @@ -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<String> Function(Headers)) {
throw ArgumentError.value(
savePath.runtimeType,
'savePath',
'The type must be `String` or `FutureOr<String> Function(Headers)`.',
);
}

options ??= Options(method: 'GET');
// Do not modify previous options.
options = options.copyWith(responseType: ResponseType.bytes);

final response = await request<List<int>>(
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<String> _resolveFilename(dynamic savePath, Response response) async {
if (savePath is FutureOr<String> Function(Headers)) {
response.headers
..add('redirects', response.redirects.length.toString())
..add('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;
Comment thread
AlexV525 marked this conversation as resolved.
}
84 changes: 84 additions & 0 deletions plugins/web_adapter/lib/src/download_trigger.dart

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like the file needs more code comments.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updated.

  • Added comments around the Blob/object URL/anchor click implementation.
  • Expanded dio_web_adapter README to clarify that the browser schedules a download from a blob: URL, and that the returned Response only means Dio fetched the bytes and dispatched the download click.
  • Documented browser-controlled limitations: actual file write, filename handling, user prompts, page security policy, CORS, HTTP protocol handling through XHR, and required browser APIs.

Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
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,
}) {
// 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 = <JSUint8Array>[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);
// 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);
}
}

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);
}
Loading