From 7018b7feaf3db8b13cfb910ce2368a300d82a392 Mon Sep 17 00:00:00 2001 From: Spoki4 Date: Fri, 25 Sep 2026 15:24:43 +0500 Subject: [PATCH 01/17] feat(windows): add DevTools, accelerator key and download settings WebView2 enables DevTools, browser accelerator keys and downloads by default, and WindowsWebViewController had no way to turn any of them off: F12 always opened DevTools and a page could always save files to the user's Downloads folder. Add three controller methods, each backed by a WebView2 setting: - setInspectable(bool): ICoreWebView2Settings::AreDevToolsEnabled; named after WebKitWebViewController.setInspectable. - setBrowserAcceleratorKeysEnabled(bool): ICoreWebView2Settings3::AreBrowserAcceleratorKeysEnabled. - setDownloadsEnabled(bool): cancels DownloadStarting before a download operation exists, so nothing is written and no download event is sent. Defaults are unchanged. The requested values are kept by the controller and re-applied when a failed initialization is retried, because the retried WebView2 starts from the WebView2 defaults again. --- .../lib/src/windows_webview_api.g.dart | 63 +++++++++ .../lib/src/windows_webview_controller.dart | 86 ++++++++++++ .../lib/src/windows_webview_native.dart | 27 ++++ .../pigeons/windows_webview_api.dart | 6 + .../test/webview_windows_test.dart | 110 +++++++++++++++ .../generated/windows_webview_api.g.cpp | 128 ++++++++++++++++++ .../windows/generated/windows_webview_api.g.h | 6 + .../windows/src/plugin/windows_host_api.cc | 41 ++++++ .../windows/src/plugin/windows_host_api.h | 6 + .../windows/src/webview/webview.cc | 27 ++++ .../windows/src/webview/webview.h | 5 + .../windows/src/webview/webview_bridge.cc | 12 ++ .../windows/src/webview/webview_bridge.h | 3 + 13 files changed, 520 insertions(+) diff --git a/webview_all_windows/lib/src/windows_webview_api.g.dart b/webview_all_windows/lib/src/windows_webview_api.g.dart index fb74fec..471d8a3 100644 --- a/webview_all_windows/lib/src/windows_webview_api.g.dart +++ b/webview_all_windows/lib/src/windows_webview_api.g.dart @@ -1396,6 +1396,69 @@ class WindowsWebViewHostApi { ); } + Future setDevToolsEnabled(int textureId, bool enabled) async { + final pigeonVar_channelName = + 'com.abandoft.pigeon.webview_all_windows.WindowsWebViewHostApi.setDevToolsEnabled$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [textureId, enabled], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ); + } + + Future setBrowserAcceleratorKeysEnabled( + int textureId, + bool enabled, + ) async { + final pigeonVar_channelName = + 'com.abandoft.pigeon.webview_all_windows.WindowsWebViewHostApi.setBrowserAcceleratorKeysEnabled$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [textureId, enabled], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ); + } + + Future setDownloadsEnabled(int textureId, bool enabled) async { + final pigeonVar_channelName = + 'com.abandoft.pigeon.webview_all_windows.WindowsWebViewHostApi.setDownloadsEnabled$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send( + [textureId, enabled], + ); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ); + } + Future setZoomFactor(int textureId, double zoomFactor) async { final pigeonVar_channelName = 'com.abandoft.pigeon.webview_all_windows.WindowsWebViewHostApi.setZoomFactor$pigeonVar_messageChannelSuffix'; diff --git a/webview_all_windows/lib/src/windows_webview_controller.dart b/webview_all_windows/lib/src/windows_webview_controller.dart index a2824c8..14d9480 100644 --- a/webview_all_windows/lib/src/windows_webview_controller.dart +++ b/webview_all_windows/lib/src/windows_webview_controller.dart @@ -168,6 +168,17 @@ class WindowsWebViewController extends PlatformWebViewController { String? _pageStartedUrl; String? _title; String? _userAgent; + late final _WebView2Setting _devToolsEnabled = _WebView2Setting( + (bool enabled) => _webviewController.setDevToolsEnabled(enabled), + ); + late final _WebView2Setting _browserAcceleratorKeysEnabled = + _WebView2Setting( + (bool enabled) => + _webviewController.setBrowserAcceleratorKeysEnabled(enabled), + ); + late final _WebView2Setting _downloadsEnabled = _WebView2Setting( + (bool enabled) => _webviewController.setDownloadsEnabled(enabled), + ); bool _canGoBack = false; bool _canGoForward = false; bool _verticalScrollBarEnabled = true; @@ -313,6 +324,16 @@ class WindowsWebViewController extends PlatformWebViewController { }, ); _throwIfDisposed(); + // A retried initialization creates a new WebView2, which starts from the + // WebView2 defaults; settings requested earlier must not be lost. + for (final _WebView2Setting setting in <_WebView2Setting>[ + _devToolsEnabled, + _browserAcceleratorKeysEnabled, + _downloadsEnabled, + ]) { + await setting.applyToNewWebView(); + _throwIfDisposed(); + } _subscriptions.addAll(>[ _webviewController.url.listen((String url) { @@ -1338,6 +1359,40 @@ ${params.functionBody} await _webviewController.setZoomControlEnabled(enabled); } + /// Sets whether the WebView2 DevTools can be opened. + /// + /// When disabled, neither F12 / Ctrl+Shift+I nor [openDevTools] opens + /// them. Mirrors `WebKitWebViewController.setInspectable`. WebView2 enables + /// DevTools by default. + Future setInspectable(bool inspectable) async { + _devToolsEnabled.request(inspectable); + await _ensureInitialized(); + await _devToolsEnabled.apply(); + } + + /// Sets whether browser-specific accelerator keys are enabled. + /// + /// When disabled, WebView2 stops acting on browser-specific keys such as + /// F5 (reload), Ctrl+P (print), Ctrl+F (find) and Ctrl+Shift+I (DevTools). + /// Enabled by default. Requires `ICoreWebView2Settings3`; on a WebView2 + /// Runtime without it this throws a [PlatformException]. + Future setBrowserAcceleratorKeysEnabled(bool enabled) async { + _browserAcceleratorKeysEnabled.request(enabled); + await _ensureInitialized(); + await _browserAcceleratorKeysEnabled.apply(); + } + + /// Sets whether the page may start downloads. + /// + /// When disabled, every download is cancelled as it starts: nothing is + /// written to disk and the WebView2 download UI is never shown. Enabled by + /// default. + Future setDownloadsEnabled(bool enabled) async { + _downloadsEnabled.request(enabled); + await _ensureInitialized(); + await _downloadsEnabled.apply(); + } + @override Future setBackgroundColor(Color color) async { await _ensureInitialized(); @@ -2193,3 +2248,34 @@ class _WindowsWebViewPermissionRequest } } } + +/// A WebView2 setting owned by [WindowsWebViewController]. +/// +/// Remembers the requested value so it survives a retried initialization, +/// and remembers what the current native WebView already has so the value is +/// sent once, whichever of the setter and the initialization gets there first. +final class _WebView2Setting { + _WebView2Setting(this._push); + + final Future Function(T value) _push; + T? _requested; + T? _applied; + + void request(T value) => _requested = value; + + /// Sends the requested value unless the native WebView already has it. + Future apply() async { + final T? value = _requested; + if (value == null || value == _applied) { + return; + } + await _push(value); + _applied = value; + } + + /// A new native WebView starts from the WebView2 defaults. + Future applyToNewWebView() { + _applied = null; + return apply(); + } +} diff --git a/webview_all_windows/lib/src/windows_webview_native.dart b/webview_all_windows/lib/src/windows_webview_native.dart index 02e46f8..3766a2e 100644 --- a/webview_all_windows/lib/src/windows_webview_native.dart +++ b/webview_all_windows/lib/src/windows_webview_native.dart @@ -1029,6 +1029,33 @@ class WebviewController extends ValueNotifier { return _hostApi.setZoomControlEnabled(_textureId, enabled); } + /// Sets whether the DevTools can be opened (F12, Ctrl+Shift+I, [openDevTools]). + Future setDevToolsEnabled(bool enabled) async { + if (_isDisposed) { + return; + } + assert(value.isInitialized); + return _hostApi.setDevToolsEnabled(_textureId, enabled); + } + + /// Sets whether browser-specific accelerator keys (F5, Ctrl+P, Ctrl+F, ...) are enabled. + Future setBrowserAcceleratorKeysEnabled(bool enabled) async { + if (_isDisposed) { + return; + } + assert(value.isInitialized); + return _hostApi.setBrowserAcceleratorKeysEnabled(_textureId, enabled); + } + + /// Sets whether downloads are allowed; when disabled they are cancelled before anything is written to disk. + Future setDownloadsEnabled(bool enabled) async { + if (_isDisposed) { + return; + } + assert(value.isInitialized); + return _hostApi.setDownloadsEnabled(_textureId, enabled); + } + /// Sets the zoom factor. Future setZoomFactor(double zoomFactor) async { if (_isDisposed) { diff --git a/webview_all_windows/pigeons/windows_webview_api.dart b/webview_all_windows/pigeons/windows_webview_api.dart index be993c8..4b77cf7 100644 --- a/webview_all_windows/pigeons/windows_webview_api.dart +++ b/webview_all_windows/pigeons/windows_webview_api.dart @@ -206,6 +206,12 @@ abstract class WindowsWebViewHostApi { void setZoomControlEnabled(int textureId, bool enabled); + void setDevToolsEnabled(int textureId, bool enabled); + + void setBrowserAcceleratorKeysEnabled(int textureId, bool enabled); + + void setDownloadsEnabled(int textureId, bool enabled); + void setZoomFactor(int textureId, double zoomFactor); void setPopupWindowPolicy(int textureId, int policy); diff --git a/webview_all_windows/test/webview_windows_test.dart b/webview_all_windows/test/webview_windows_test.dart index e66c914..17f4b87 100644 --- a/webview_all_windows/test/webview_windows_test.dart +++ b/webview_all_windows/test/webview_windows_test.dart @@ -1315,6 +1315,85 @@ void main() { expect(zoomControlEnabledValues, [false, true]); }); + test( + 'sets DevTools, accelerator keys and downloads through WebView2', + () async { + final devToolsValues = []; + final acceleratorKeysValues = []; + final downloadsValues = []; + _mockWindowsWebViewCreation( + onSetDevToolsEnabled: devToolsValues.add, + onSetBrowserAcceleratorKeysEnabled: acceleratorKeysValues.add, + onSetDownloadsEnabled: downloadsValues.add, + ); + + final controller = WindowsWebViewController( + const PlatformWebViewControllerCreationParams(), + ); + + await controller.setInspectable(false); + await controller.setBrowserAcceleratorKeysEnabled(false); + await controller.setDownloadsEnabled(false); + await controller.setDownloadsEnabled(true); + + expect(devToolsValues, [false]); + expect(acceleratorKeysValues, [false]); + expect(downloadsValues, [false, true]); + }, + ); + + testWidgets( + 'WebView2 settings are re-applied after a retried initialization', + (WidgetTester tester) async { + final devToolsValues = []; + final acceleratorKeysValues = []; + final downloadsValues = []; + _mockWindowsWebViewCreation( + creationFailureCount: 1, + onSetDevToolsEnabled: devToolsValues.add, + onSetBrowserAcceleratorKeysEnabled: acceleratorKeysValues.add, + onSetDownloadsEnabled: downloadsValues.add, + ); + final WindowsWebViewController controller = WindowsWebViewController( + const PlatformWebViewControllerCreationParams(), + ); + + // The first environment fails, so the settings cannot reach WebView2 yet; + // they must still hold once the widget's refresh creates a new one. + await tester.runAsync(() async { + for (final Future Function() apply in Function()>[ + () => controller.setInspectable(false), + () => controller.setBrowserAcceleratorKeysEnabled(false), + () => controller.setDownloadsEnabled(false), + ]) { + await expectLater(apply(), throwsA(isA())); + } + }); + expect(devToolsValues, isEmpty); + + final WindowsWebViewWidget platformWidget = WindowsWebViewWidget( + PlatformWebViewWidgetCreationParams(controller: controller), + ); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: 800, + height: 600, + child: Builder(builder: platformWidget.build), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Refresh')); + await tester.pumpAndSettle(); + + expect(devToolsValues, [false]); + expect(acceleratorKeysValues, [false]); + expect(downloadsValues, [false]); + }, + ); + test( 'dispatches Windows permission requests and returns decisions', () async { @@ -1908,6 +1987,9 @@ void _mockWindowsWebViewCreation({ String? clearAllWebsiteDataFailureCode, void Function(bool enabled)? onSetJavaScriptEnabled, void Function(bool enabled)? onSetZoomControlEnabled, + void Function(bool enabled)? onSetDevToolsEnabled, + void Function(bool enabled)? onSetBrowserAcceleratorKeysEnabled, + void Function(bool enabled)? onSetDownloadsEnabled, void Function(bool enabled)? onSetNavigationRequestCallbacksEnabled, void Function(WindowsSizeData size)? onSetSize, int setSizeFailureCount = 0, @@ -2088,6 +2170,28 @@ void _mockWindowsWebViewCreation({ onSetZoomControlEnabled?.call(args[1]! as bool); return _encodePigeonSuccess(); }); + messenger.setMockMessageHandler(_hostApiChannel('setDevToolsEnabled'), ( + ByteData? message, + ) async { + final args = _decodePigeonArgs(message); + onSetDevToolsEnabled?.call(args[1]! as bool); + return _encodePigeonSuccess(); + }); + messenger.setMockMessageHandler( + _hostApiChannel('setBrowserAcceleratorKeysEnabled'), + (ByteData? message) async { + final args = _decodePigeonArgs(message); + onSetBrowserAcceleratorKeysEnabled?.call(args[1]! as bool); + return _encodePigeonSuccess(); + }, + ); + messenger.setMockMessageHandler(_hostApiChannel('setDownloadsEnabled'), ( + ByteData? message, + ) async { + final args = _decodePigeonArgs(message); + onSetDownloadsEnabled?.call(args[1]! as bool); + return _encodePigeonSuccess(); + }); messenger.setMockMessageHandler( _hostApiChannel('setJavaScriptDialogCallbacksEnabled'), (ByteData? message) async { @@ -2194,6 +2298,12 @@ void _clearWindowsWebViewCreationMock() { _hostApiChannel('setZoomControlEnabled'), null, ); + messenger.setMockMessageHandler(_hostApiChannel('setDevToolsEnabled'), null); + messenger.setMockMessageHandler( + _hostApiChannel('setBrowserAcceleratorKeysEnabled'), + null, + ); + messenger.setMockMessageHandler(_hostApiChannel('setDownloadsEnabled'), null); messenger.setMockMessageHandler( _hostApiChannel('setJavaScriptDialogCallbacksEnabled'), null, diff --git a/webview_all_windows/windows/generated/windows_webview_api.g.cpp b/webview_all_windows/windows/generated/windows_webview_api.g.cpp index ef882be..506d3a5 100644 --- a/webview_all_windows/windows/generated/windows_webview_api.g.cpp +++ b/webview_all_windows/windows/generated/windows_webview_api.g.cpp @@ -2742,6 +2742,134 @@ void WindowsWebViewHostApi::SetUp(::flutter::BinaryMessenger *binary_messenger, channel.SetMessageHandler(nullptr); } } + { + BasicMessageChannel<> channel(binary_messenger, + "com.abandoft.pigeon.webview_all_windows." + "WindowsWebViewHostApi.setDevToolsEnabled" + + prepended_suffix, + &GetCodec()); + if (api != nullptr) { + channel.SetMessageHandler( + [api](const EncodableValue &message, + const ::flutter::MessageReply &reply) { + try { + const auto &args = std::get(message); + const auto &encodable_texture_id_arg = args.at(0); + if (encodable_texture_id_arg.IsNull()) { + reply(WrapError("texture_id_arg unexpectedly null.")); + return; + } + const int64_t texture_id_arg = + encodable_texture_id_arg.LongValue(); + const auto &encodable_enabled_arg = args.at(1); + if (encodable_enabled_arg.IsNull()) { + reply(WrapError("enabled_arg unexpectedly null.")); + return; + } + const auto &enabled_arg = std::get(encodable_enabled_arg); + std::optional output = + api->SetDevToolsEnabled(texture_id_arg, enabled_arg); + if (output.has_value()) { + reply(WrapError(output.value())); + return; + } + EncodableList wrapped; + wrapped.push_back(EncodableValue()); + reply(EncodableValue(std::move(wrapped))); + } catch (const std::exception &exception) { + reply(WrapError(exception.what())); + } + }); + } else { + channel.SetMessageHandler(nullptr); + } + } + { + BasicMessageChannel<> channel( + binary_messenger, + "com.abandoft.pigeon.webview_all_windows.WindowsWebViewHostApi." + "setBrowserAcceleratorKeysEnabled" + + prepended_suffix, + &GetCodec()); + if (api != nullptr) { + channel.SetMessageHandler( + [api](const EncodableValue &message, + const ::flutter::MessageReply &reply) { + try { + const auto &args = std::get(message); + const auto &encodable_texture_id_arg = args.at(0); + if (encodable_texture_id_arg.IsNull()) { + reply(WrapError("texture_id_arg unexpectedly null.")); + return; + } + const int64_t texture_id_arg = + encodable_texture_id_arg.LongValue(); + const auto &encodable_enabled_arg = args.at(1); + if (encodable_enabled_arg.IsNull()) { + reply(WrapError("enabled_arg unexpectedly null.")); + return; + } + const auto &enabled_arg = std::get(encodable_enabled_arg); + std::optional output = + api->SetBrowserAcceleratorKeysEnabled(texture_id_arg, + enabled_arg); + if (output.has_value()) { + reply(WrapError(output.value())); + return; + } + EncodableList wrapped; + wrapped.push_back(EncodableValue()); + reply(EncodableValue(std::move(wrapped))); + } catch (const std::exception &exception) { + reply(WrapError(exception.what())); + } + }); + } else { + channel.SetMessageHandler(nullptr); + } + } + { + BasicMessageChannel<> channel(binary_messenger, + "com.abandoft.pigeon.webview_all_windows." + "WindowsWebViewHostApi.setDownloadsEnabled" + + prepended_suffix, + &GetCodec()); + if (api != nullptr) { + channel.SetMessageHandler( + [api](const EncodableValue &message, + const ::flutter::MessageReply &reply) { + try { + const auto &args = std::get(message); + const auto &encodable_texture_id_arg = args.at(0); + if (encodable_texture_id_arg.IsNull()) { + reply(WrapError("texture_id_arg unexpectedly null.")); + return; + } + const int64_t texture_id_arg = + encodable_texture_id_arg.LongValue(); + const auto &encodable_enabled_arg = args.at(1); + if (encodable_enabled_arg.IsNull()) { + reply(WrapError("enabled_arg unexpectedly null.")); + return; + } + const auto &enabled_arg = std::get(encodable_enabled_arg); + std::optional output = + api->SetDownloadsEnabled(texture_id_arg, enabled_arg); + if (output.has_value()) { + reply(WrapError(output.value())); + return; + } + EncodableList wrapped; + wrapped.push_back(EncodableValue()); + reply(EncodableValue(std::move(wrapped))); + } catch (const std::exception &exception) { + reply(WrapError(exception.what())); + } + }); + } else { + channel.SetMessageHandler(nullptr); + } + } { BasicMessageChannel<> channel(binary_messenger, "com.abandoft.pigeon.webview_all_windows." diff --git a/webview_all_windows/windows/generated/windows_webview_api.g.h b/webview_all_windows/windows/generated/windows_webview_api.g.h index af1a6f3..d1eb4fb 100644 --- a/webview_all_windows/windows/generated/windows_webview_api.g.h +++ b/webview_all_windows/windows/generated/windows_webview_api.g.h @@ -524,6 +524,12 @@ class WindowsWebViewHostApi { int64_t color) = 0; virtual std::optional SetZoomControlEnabled(int64_t texture_id, bool enabled) = 0; + virtual std::optional SetDevToolsEnabled(int64_t texture_id, + bool enabled) = 0; + virtual std::optional + SetBrowserAcceleratorKeysEnabled(int64_t texture_id, bool enabled) = 0; + virtual std::optional SetDownloadsEnabled(int64_t texture_id, + bool enabled) = 0; virtual std::optional SetZoomFactor(int64_t texture_id, double zoom_factor) = 0; virtual std::optional SetPopupWindowPolicy(int64_t texture_id, diff --git a/webview_all_windows/windows/src/plugin/windows_host_api.cc b/webview_all_windows/windows/src/plugin/windows_host_api.cc index c0305b0..9bbb609 100644 --- a/webview_all_windows/windows/src/plugin/windows_host_api.cc +++ b/webview_all_windows/windows/src/plugin/windows_host_api.cc @@ -916,6 +916,47 @@ WindowsHostApi::SetZoomControlEnabled(int64_t texture_id, bool enabled) { return std::nullopt; } +std::optional +WindowsHostApi::SetDevToolsEnabled(int64_t texture_id, bool enabled) { + auto bridge = FindBridge(texture_id); + if (!bridge) { + return InvalidIdError(); + } + if (!bridge->SetDevToolsEnabled(enabled)) { + return webview_all_windows::FlutterError( + kErrorNotSupported, "Setting the DevTools mode failed."); + } + return std::nullopt; +} + +std::optional +WindowsHostApi::SetBrowserAcceleratorKeysEnabled(int64_t texture_id, + bool enabled) { + auto bridge = FindBridge(texture_id); + if (!bridge) { + return InvalidIdError(); + } + if (!bridge->SetBrowserAcceleratorKeysEnabled(enabled)) { + return webview_all_windows::FlutterError( + kErrorNotSupported, + "Setting the browser accelerator keys mode failed."); + } + return std::nullopt; +} + +std::optional +WindowsHostApi::SetDownloadsEnabled(int64_t texture_id, bool enabled) { + auto bridge = FindBridge(texture_id); + if (!bridge) { + return InvalidIdError(); + } + if (!bridge->SetDownloadsEnabled(enabled)) { + return webview_all_windows::FlutterError( + kErrorNotSupported, "Setting the downloads mode failed."); + } + return std::nullopt; +} + std::optional WindowsHostApi::SetZoomFactor(int64_t texture_id, double zoom_factor) { auto bridge = FindBridge(texture_id); diff --git a/webview_all_windows/windows/src/plugin/windows_host_api.h b/webview_all_windows/windows/src/plugin/windows_host_api.h index 4ed2725..4f3716e 100644 --- a/webview_all_windows/windows/src/plugin/windows_host_api.h +++ b/webview_all_windows/windows/src/plugin/windows_host_api.h @@ -159,6 +159,12 @@ class WindowsHostApi : public flutter::Plugin, public WindowsWebViewHostApi { int64_t color) override; std::optional SetZoomControlEnabled(int64_t texture_id, bool enabled) override; + std::optional SetDevToolsEnabled(int64_t texture_id, + bool enabled) override; + std::optional + SetBrowserAcceleratorKeysEnabled(int64_t texture_id, bool enabled) override; + std::optional SetDownloadsEnabled(int64_t texture_id, + bool enabled) override; std::optional SetZoomFactor(int64_t texture_id, double zoom_factor) override; std::optional SetPopupWindowPolicy(int64_t texture_id, diff --git a/webview_all_windows/windows/src/webview/webview.cc b/webview_all_windows/windows/src/webview/webview.cc index 9809965..2524c89 100644 --- a/webview_all_windows/windows/src/webview/webview.cc +++ b/webview_all_windows/windows/src/webview/webview.cc @@ -231,6 +231,7 @@ Webview::Webview( if (SUCCEEDED(webview_->get_Settings(settings.put()))) { settings_ = settings; settings2_ = settings.try_query(); + settings3_ = settings.try_query(); if (settings2_) { wil::unique_cotaskmem_string default_user_agent; if (SUCCEEDED(settings2_->get_UserAgent(&default_user_agent)) && @@ -905,6 +906,12 @@ void Webview::RegisterEventHandlers() { Callback( [this](ICoreWebView2 *sender, ICoreWebView2DownloadStartingEventArgs *args) -> HRESULT { + if (!downloads_enabled_) { + // Cancelled before the operation is created, so nothing is + // written to disk and no download event reaches Dart. + args->put_Cancel(TRUE); + return S_OK; + } args->put_Handled(TRUE); wil::com_ptr download; @@ -1418,6 +1425,26 @@ bool Webview::SetZoomControlEnabled(bool enabled) { return false; } +bool Webview::SetDevToolsEnabled(bool enabled) { + if (settings_) { + return settings_->put_AreDevToolsEnabled(enabled ? TRUE : FALSE) == S_OK; + } + return false; +} + +bool Webview::SetBrowserAcceleratorKeysEnabled(bool enabled) { + if (settings3_) { + return settings3_->put_AreBrowserAcceleratorKeysEnabled( + enabled ? TRUE : FALSE) == S_OK; + } + return false; +} + +bool Webview::SetDownloadsEnabled(bool enabled) { + downloads_enabled_ = enabled; + return true; +} + bool Webview::SetBackgroundColor(int32_t color) { if (!IsValid()) { return false; diff --git a/webview_all_windows/windows/src/webview/webview.h b/webview_all_windows/windows/src/webview/webview.h index 559e9b4..aee90f2 100644 --- a/webview_all_windows/windows/src/webview/webview.h +++ b/webview_all_windows/windows/src/webview/webview.h @@ -265,6 +265,9 @@ class Webview { std::optional GetUserAgent(); bool SetJavaScriptEnabled(bool enabled); bool SetZoomControlEnabled(bool enabled); + bool SetDevToolsEnabled(bool enabled); + bool SetBrowserAcceleratorKeysEnabled(bool enabled); + bool SetDownloadsEnabled(bool enabled); bool OpenDevTools(); bool SetBackgroundColor(int32_t color); bool SetZoomFactor(double factor); @@ -366,6 +369,7 @@ class Webview { devtools_protocol_event_receiver_; wil::com_ptr settings_; wil::com_ptr settings2_; + wil::com_ptr settings3_; std::string default_user_agent_; POINT last_cursor_pos_ = {0, 0}; VirtualKeyState virtual_keys_; @@ -375,6 +379,7 @@ class Webview { bool java_script_confirm_dialog_enabled_ = false; bool java_script_prompt_dialog_enabled_ = false; bool navigation_request_callbacks_enabled_ = false; + bool downloads_enabled_ = true; uint64_t latest_navigation_request_id_ = 0; size_t bypass_next_navigation_count_ = 0; std::unordered_multiset approved_navigation_urls_; diff --git a/webview_all_windows/windows/src/webview/webview_bridge.cc b/webview_all_windows/windows/src/webview/webview_bridge.cc index ba629ae..0489137 100644 --- a/webview_all_windows/windows/src/webview/webview_bridge.cc +++ b/webview_all_windows/windows/src/webview/webview_bridge.cc @@ -735,6 +735,18 @@ bool WebviewBridge::SetZoomControlEnabled(bool enabled) { return webview_->SetZoomControlEnabled(enabled); } +bool WebviewBridge::SetDevToolsEnabled(bool enabled) { + return webview_->SetDevToolsEnabled(enabled); +} + +bool WebviewBridge::SetBrowserAcceleratorKeysEnabled(bool enabled) { + return webview_->SetBrowserAcceleratorKeysEnabled(enabled); +} + +bool WebviewBridge::SetDownloadsEnabled(bool enabled) { + return webview_->SetDownloadsEnabled(enabled); +} + bool WebviewBridge::SetBackgroundColor(int64_t color) { return webview_->SetBackgroundColor(static_cast(color)); } diff --git a/webview_all_windows/windows/src/webview/webview_bridge.h b/webview_all_windows/windows/src/webview/webview_bridge.h index e4f6bac..1a085bd 100644 --- a/webview_all_windows/windows/src/webview/webview_bridge.h +++ b/webview_all_windows/windows/src/webview/webview_bridge.h @@ -71,6 +71,9 @@ class WebviewBridge { std::optional GetUserAgent(); bool SetJavaScriptEnabled(bool enabled); bool SetZoomControlEnabled(bool enabled); + bool SetDevToolsEnabled(bool enabled); + bool SetBrowserAcceleratorKeysEnabled(bool enabled); + bool SetDownloadsEnabled(bool enabled); bool SetBackgroundColor(int64_t color); bool SetZoomFactor(double zoom_factor); bool OpenDevTools(); From ba7093ad841effe00aa392c8a53b84125b92176a Mon Sep 17 00:00:00 2001 From: Spoki4 Date: Fri, 25 Sep 2026 15:24:43 +0500 Subject: [PATCH 02/17] test(windows): cover refused downloads with a real WebView2 Start one download with downloads allowed as a control, then the same download with downloads disabled, and check the user's Downloads folder. Without the control, a page that never started a download would pass too. --- .../webview_flutter_test.dart | 90 +++++++++++++++++++ 1 file changed, 90 insertions(+) diff --git a/examples/platform/integration_test/webview_flutter_test.dart b/examples/platform/integration_test/webview_flutter_test.dart index e49b80b..016b83c 100644 --- a/examples/platform/integration_test/webview_flutter_test.dart +++ b/examples/platform/integration_test/webview_flutter_test.dart @@ -228,6 +228,89 @@ return { ); }); + testWidgets('Windows controller can refuse downloads', ( + WidgetTester tester, + ) async { + if (!Platform.isWindows) { + return; + } + + // WebView2 saves to the user's Downloads folder by default. The allowed + // download is the control: without it, a page that never starts a + // download at all would pass the refused case too. + final Directory downloads = Directory( + '${Platform.environment['USERPROFILE']}\\Downloads', + ); + final String stamp = DateTime.now().microsecondsSinceEpoch.toString(); + final File refused = File( + '${downloads.path}\\webview_all_refused_$stamp.txt', + ); + final File allowed = File( + '${downloads.path}\\webview_all_allowed_$stamp.txt', + ); + addTearDown(() async { + for (final File file in [refused, allowed]) { + if (file.existsSync()) { + file.deleteSync(); + } + } + }); + + Future download({ + required bool enabled, + required String fileName, + }) async { + final Completer pageFinished = Completer(); + final WebViewController controller = WebViewController(); + final WindowsWebViewController windowsController = + controller.platform as WindowsWebViewController; + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await windowsController.setDownloadsEnabled(enabled); + await controller.setNavigationDelegate( + NavigationDelegate( + onPageFinished: (_) { + if (!pageFinished.isCompleted) { + pageFinished.complete(); + } + }, + ), + ); + await controller.loadHtmlString(''' + +Windows download test +Windows download test +'''); + await tester.pumpWidget(WebViewWidget(controller: controller)); + await pageFinished.future.timeout(const Duration(seconds: 15)); + await controller.runJavaScript(''' +const link = document.createElement('a'); +link.href = URL.createObjectURL(new Blob(['webview_all'], {type: 'text/plain'})); +link.download = '$fileName'; +document.body.appendChild(link); +link.click(); +'''); + } + + await download(enabled: true, fileName: allowed.uri.pathSegments.last); + await _waitFor(allowed.existsSync, const Duration(seconds: 15)); + expect( + allowed.existsSync(), + isTrue, + reason: 'The control download never reached ${downloads.path}.', + ); + + await download(enabled: false, fileName: refused.uri.pathSegments.last); + // Longer than the allowed download took: absence is the assertion. + await Future.delayed(const Duration(seconds: 3)); + expect( + refused.existsSync(), + isFalse, + reason: 'A download started while downloads were disabled.', + ); + + await tester.pumpWidget(const SizedBox.shrink()); + }); + testWidgets('Windows controller releases its renderer process', ( WidgetTester tester, ) async { @@ -2012,3 +2095,10 @@ Future _waitForJavaScriptPredicate( } throw TestFailure('JavaScript result did not satisfy: $expression'); } + +Future _waitFor(bool Function() condition, Duration timeout) async { + final DateTime deadline = DateTime.now().add(timeout); + while (!condition() && DateTime.now().isBefore(deadline)) { + await Future.delayed(const Duration(milliseconds: 100)); + } +} From 71a0d425931ee86fa1c5a4b533dfd4927f027167 Mon Sep 17 00:00:00 2001 From: Spoki4 Date: Fri, 25 Sep 2026 15:24:43 +0500 Subject: [PATCH 03/17] docs(windows): document DevTools, accelerator key and download settings --- docs/src/content/docs/guides/debugging.md | 7 +++++++ docs/src/content/docs/platforms/windows.md | 3 +++ docs/src/content/docs/zh/platforms/windows.md | 3 +++ 3 files changed, 13 insertions(+) diff --git a/docs/src/content/docs/guides/debugging.md b/docs/src/content/docs/guides/debugging.md index 8ba39d3..2edbfbd 100644 --- a/docs/src/content/docs/guides/debugging.md +++ b/docs/src/content/docs/guides/debugging.md @@ -35,6 +35,13 @@ final windows = controller.platform as WindowsWebViewController; await windows.openDevTools(); ``` +WebView2 allows DevTools by default. Block them, including F12 and +Ctrl+Shift+I, per controller: + +```dart +await windows.setInspectable(false); +``` + Check runtime availability: ```dart diff --git a/docs/src/content/docs/platforms/windows.md b/docs/src/content/docs/platforms/windows.md index 4d9dad3..3c9ba5b 100644 --- a/docs/src/content/docs/platforms/windows.md +++ b/docs/src/content/docs/platforms/windows.md @@ -119,6 +119,9 @@ final widget = WebViewWidget.fromPlatformCreationParams( | `setPopupWindowPolicy(policy)` | Changes popup handling after creation. | | `setZoomFactor(double zoomFactor)` | Sets WebView2 zoom factor. | | `setCacheDisabled(bool disabled)` | Toggles cache bypass behavior. | +| `setInspectable(bool inspectable)` | Allows or blocks DevTools (F12, Ctrl+Shift+I, `openDevTools()`). Allowed by default. | +| `setBrowserAcceleratorKeysEnabled(bool enabled)` | Enables or disables browser accelerator keys such as F5, Ctrl+P, and Ctrl+F. Enabled by default. | +| `setDownloadsEnabled(bool enabled)` | Allows downloads or cancels them as they start, before anything is written to disk. Allowed by default. | | `dispose()` | Permanently releases this controller and its WebView2 resources. | Common APIs implemented on Windows include request loading with method, headers, and body; JavaScript execution; JavaScript channels; console messages; JavaScript dialogs; permission requests; HTTP errors; HTTP auth; SSL auth; scroll position; scrollbars; background color; user agent override; and overscroll styling. diff --git a/docs/src/content/docs/zh/platforms/windows.md b/docs/src/content/docs/zh/platforms/windows.md index 5d12512..bd6843e 100644 --- a/docs/src/content/docs/zh/platforms/windows.md +++ b/docs/src/content/docs/zh/platforms/windows.md @@ -82,6 +82,9 @@ final params = const WindowsWebViewControllerCreationParams( | `setPopupWindowPolicy` | 运行时修改 popup 策略。 | | `setZoomFactor` | 设置 WebView2 缩放因子。 | | `setCacheDisabled` | 控制请求是否绕过 cache。 | +| `setInspectable` | 允许或禁止 DevTools(F12、Ctrl+Shift+I、`openDevTools`)。默认允许。 | +| `setBrowserAcceleratorKeysEnabled` | 启用或禁用浏览器快捷键,例如 F5、Ctrl+P 和 Ctrl+F。默认启用。 | +| `setDownloadsEnabled` | 允许下载,或在下载开始时取消,不会写入磁盘。默认允许。 | | `dispose` | 永久释放此 controller 及其 WebView2 资源。 | `onNavigationRequest` 会覆盖 controller 加载以及页面内容触发的 WebView2 主 frame 导航,包括 redirect 和 `sameWindow` popup。controller 请求在原生分发前完成判断,因此会保留自定义 method、headers 和 body;页面导航通过取消后等待异步 Dart 决策、放行后重放来实现,策略性取消不会触发 `onWebResourceError`。 From 36868d0b3ac37c231187f529e061f490279c8d58 Mon Sep 17 00:00:00 2001 From: moluopro Date: Fri, 25 Sep 2026 21:49:59 +0800 Subject: [PATCH 04/17] fix(windows): propagate native setting failures and download cancellation --- .../lib/src/windows_webview_types.dart | 1 + .../windows/src/plugin/windows_host_api.cc | 35 ++++++++------- .../windows/src/webview/webview.cc | 43 +++++++++++++------ .../windows/src/webview/webview.h | 10 +++-- .../windows/src/webview/webview_bridge.cc | 6 +-- .../windows/src/webview/webview_bridge.h | 6 +-- 6 files changed, 62 insertions(+), 39 deletions(-) diff --git a/webview_all_windows/lib/src/windows_webview_types.dart b/webview_all_windows/lib/src/windows_webview_types.dart index 50c8097..74bc3a6 100644 --- a/webview_all_windows/lib/src/windows_webview_types.dart +++ b/webview_all_windows/lib/src/windows_webview_types.dart @@ -10,6 +10,7 @@ enum WebviewDownloadEventKind { downloadStarted, downloadCompleted, downloadProgress, + downloadCancelled, } /// Pointer Event kind diff --git a/webview_all_windows/windows/src/plugin/windows_host_api.cc b/webview_all_windows/windows/src/plugin/windows_host_api.cc index 9bbb609..fbc6ac6 100644 --- a/webview_all_windows/windows/src/plugin/windows_host_api.cc +++ b/webview_all_windows/windows/src/plugin/windows_host_api.cc @@ -43,6 +43,21 @@ std::string FormatHresult(HRESULT result) { return value.str(); } +std::optional SettingResult(const char *setting, HRESULT result) { + if (SUCCEEDED(result)) { + return std::nullopt; + } + return FlutterError( + result == E_NOINTERFACE ? kErrorNotSupported : kErrorMethodFailed, + std::string("Applying ") + setting + " failed (HRESULT: " + + FormatHresult(result) + ").", + flutter::EncodableValue(flutter::EncodableMap{ + {flutter::EncodableValue("setting"), flutter::EncodableValue(setting)}, + {flutter::EncodableValue("hresult"), + flutter::EncodableValue(FormatHresult(result))}, + })); +} + FlutterError CreateInitializationError( const std::string &code, const std::string &stage, const std::string &message, HRESULT result, @@ -922,11 +937,7 @@ WindowsHostApi::SetDevToolsEnabled(int64_t texture_id, bool enabled) { if (!bridge) { return InvalidIdError(); } - if (!bridge->SetDevToolsEnabled(enabled)) { - return webview_all_windows::FlutterError( - kErrorNotSupported, "Setting the DevTools mode failed."); - } - return std::nullopt; + return SettingResult("devToolsEnabled", bridge->SetDevToolsEnabled(enabled)); } std::optional @@ -936,12 +947,8 @@ WindowsHostApi::SetBrowserAcceleratorKeysEnabled(int64_t texture_id, if (!bridge) { return InvalidIdError(); } - if (!bridge->SetBrowserAcceleratorKeysEnabled(enabled)) { - return webview_all_windows::FlutterError( - kErrorNotSupported, - "Setting the browser accelerator keys mode failed."); - } - return std::nullopt; + return SettingResult("browserAcceleratorKeysEnabled", + bridge->SetBrowserAcceleratorKeysEnabled(enabled)); } std::optional @@ -950,11 +957,7 @@ WindowsHostApi::SetDownloadsEnabled(int64_t texture_id, bool enabled) { if (!bridge) { return InvalidIdError(); } - if (!bridge->SetDownloadsEnabled(enabled)) { - return webview_all_windows::FlutterError( - kErrorNotSupported, "Setting the downloads mode failed."); - } - return std::nullopt; + return SettingResult("downloadsEnabled", bridge->SetDownloadsEnabled(enabled)); } std::optional diff --git a/webview_all_windows/windows/src/webview/webview.cc b/webview_all_windows/windows/src/webview/webview.cc index 2524c89..2be8645 100644 --- a/webview_all_windows/windows/src/webview/webview.cc +++ b/webview_all_windows/windows/src/webview/webview.cc @@ -902,15 +902,28 @@ void Webview::RegisterEventHandlers() { auto webview24 = webview_.try_query(); if (webview24) { - webview24->add_DownloadStarting( + download_handler_result_ = webview24->add_DownloadStarting( Callback( [this](ICoreWebView2 *sender, ICoreWebView2DownloadStartingEventArgs *args) -> HRESULT { if (!downloads_enabled_) { - // Cancelled before the operation is created, so nothing is - // written to disk and no download event reaches Dart. - args->put_Cancel(TRUE); - return S_OK; + const HRESULT result = args->put_Cancel(TRUE); + if (FAILED(result)) { + util::LogWarning("Cancelling a WebView2 download failed."); + } else if (download_event_callback_) { + wil::com_ptr download; + wil::unique_cotaskmem_string uri; + wil::unique_cotaskmem_string path; + if (SUCCEEDED(args->get_DownloadOperation(download.put())) && + download && SUCCEEDED(download->get_Uri(&uri)) && + SUCCEEDED(args->get_ResultFilePath(&path))) { + download_event_callback_( + {WebviewDownloadEventKind::DownloadCancelled, + util::Utf8FromUtf16(uri.get()), + util::Utf8FromUtf16(path.get()), 0, 0}); + } + } + return result; } args->put_Handled(TRUE); @@ -1425,24 +1438,28 @@ bool Webview::SetZoomControlEnabled(bool enabled) { return false; } -bool Webview::SetDevToolsEnabled(bool enabled) { +HRESULT Webview::SetDevToolsEnabled(bool enabled) { if (settings_) { - return settings_->put_AreDevToolsEnabled(enabled ? TRUE : FALSE) == S_OK; + return settings_->put_AreDevToolsEnabled(enabled ? TRUE : FALSE); } - return false; + return E_NOINTERFACE; } -bool Webview::SetBrowserAcceleratorKeysEnabled(bool enabled) { +HRESULT Webview::SetBrowserAcceleratorKeysEnabled(bool enabled) { if (settings3_) { return settings3_->put_AreBrowserAcceleratorKeysEnabled( - enabled ? TRUE : FALSE) == S_OK; + enabled ? TRUE : FALSE); } - return false; + // Older runtimes always allow browser accelerators. + return enabled ? S_OK : E_NOINTERFACE; } -bool Webview::SetDownloadsEnabled(bool enabled) { +HRESULT Webview::SetDownloadsEnabled(bool enabled) { + if (!enabled && FAILED(download_handler_result_)) { + return download_handler_result_; + } downloads_enabled_ = enabled; - return true; + return S_OK; } bool Webview::SetBackgroundColor(int32_t color) { diff --git a/webview_all_windows/windows/src/webview/webview.h b/webview_all_windows/windows/src/webview/webview.h index aee90f2..8a1b71a 100644 --- a/webview_all_windows/windows/src/webview/webview.h +++ b/webview_all_windows/windows/src/webview/webview.h @@ -26,7 +26,8 @@ enum class WebviewPointerEventKind { Activate, Down, Enter, Leave, Up, Update }; enum class WebviewDownloadEventKind { DownloadStarted, DownloadCompleted, - DownloadProgress + DownloadProgress, + DownloadCancelled }; enum class WebviewPermissionKind { @@ -265,9 +266,9 @@ class Webview { std::optional GetUserAgent(); bool SetJavaScriptEnabled(bool enabled); bool SetZoomControlEnabled(bool enabled); - bool SetDevToolsEnabled(bool enabled); - bool SetBrowserAcceleratorKeysEnabled(bool enabled); - bool SetDownloadsEnabled(bool enabled); + HRESULT SetDevToolsEnabled(bool enabled); + HRESULT SetBrowserAcceleratorKeysEnabled(bool enabled); + HRESULT SetDownloadsEnabled(bool enabled); bool OpenDevTools(); bool SetBackgroundColor(int32_t color); bool SetZoomFactor(double factor); @@ -380,6 +381,7 @@ class Webview { bool java_script_prompt_dialog_enabled_ = false; bool navigation_request_callbacks_enabled_ = false; bool downloads_enabled_ = true; + HRESULT download_handler_result_ = E_NOINTERFACE; uint64_t latest_navigation_request_id_ = 0; size_t bypass_next_navigation_count_ = 0; std::unordered_multiset approved_navigation_urls_; diff --git a/webview_all_windows/windows/src/webview/webview_bridge.cc b/webview_all_windows/windows/src/webview/webview_bridge.cc index 0489137..296e998 100644 --- a/webview_all_windows/windows/src/webview/webview_bridge.cc +++ b/webview_all_windows/windows/src/webview/webview_bridge.cc @@ -735,15 +735,15 @@ bool WebviewBridge::SetZoomControlEnabled(bool enabled) { return webview_->SetZoomControlEnabled(enabled); } -bool WebviewBridge::SetDevToolsEnabled(bool enabled) { +HRESULT WebviewBridge::SetDevToolsEnabled(bool enabled) { return webview_->SetDevToolsEnabled(enabled); } -bool WebviewBridge::SetBrowserAcceleratorKeysEnabled(bool enabled) { +HRESULT WebviewBridge::SetBrowserAcceleratorKeysEnabled(bool enabled) { return webview_->SetBrowserAcceleratorKeysEnabled(enabled); } -bool WebviewBridge::SetDownloadsEnabled(bool enabled) { +HRESULT WebviewBridge::SetDownloadsEnabled(bool enabled) { return webview_->SetDownloadsEnabled(enabled); } diff --git a/webview_all_windows/windows/src/webview/webview_bridge.h b/webview_all_windows/windows/src/webview/webview_bridge.h index 1a085bd..bcb4745 100644 --- a/webview_all_windows/windows/src/webview/webview_bridge.h +++ b/webview_all_windows/windows/src/webview/webview_bridge.h @@ -71,9 +71,9 @@ class WebviewBridge { std::optional GetUserAgent(); bool SetJavaScriptEnabled(bool enabled); bool SetZoomControlEnabled(bool enabled); - bool SetDevToolsEnabled(bool enabled); - bool SetBrowserAcceleratorKeysEnabled(bool enabled); - bool SetDownloadsEnabled(bool enabled); + HRESULT SetDevToolsEnabled(bool enabled); + HRESULT SetBrowserAcceleratorKeysEnabled(bool enabled); + HRESULT SetDownloadsEnabled(bool enabled); bool SetBackgroundColor(int64_t color); bool SetZoomFactor(double zoom_factor); bool OpenDevTools(); From 76751d76ff4e3170363f48921b9cb8ceb573ffdb Mon Sep 17 00:00:00 2001 From: moluopro Date: Fri, 25 Sep 2026 21:49:59 +0800 Subject: [PATCH 05/17] fix(windows): make controller settings recoverable and DevTools opt-in --- .../lib/src/windows_webview_controller.dart | 176 ++++++--- .../lib/src/windows_webview_native.dart | 5 +- .../test/webview_windows_test.dart | 361 +++++++++++++++++- 3 files changed, 466 insertions(+), 76 deletions(-) diff --git a/webview_all_windows/lib/src/windows_webview_controller.dart b/webview_all_windows/lib/src/windows_webview_controller.dart index 14d9480..4dd6b53 100644 --- a/webview_all_windows/lib/src/windows_webview_controller.dart +++ b/webview_all_windows/lib/src/windows_webview_controller.dart @@ -61,16 +61,39 @@ class WindowsWebViewControllerCreationParams /// Creates a new [WindowsWebViewControllerCreationParams]. const WindowsWebViewControllerCreationParams({ this.popupWindowPolicy = WindowsPopupWindowPolicy.sameWindow, + this.devToolsEnabled = false, + this.browserAcceleratorKeysEnabled, + this.downloadsEnabled, }); /// Creates a [WindowsWebViewControllerCreationParams] from generic params. const WindowsWebViewControllerCreationParams.fromPlatformWebViewControllerCreationParams( PlatformWebViewControllerCreationParams params, { this.popupWindowPolicy = WindowsPopupWindowPolicy.sameWindow, + this.devToolsEnabled = false, + this.browserAcceleratorKeysEnabled, + this.downloadsEnabled, }); /// How popup windows should be handled. final WindowsPopupWindowPolicy popupWindowPolicy; + + /// Whether users can open DevTools through menus and keyboard shortcuts. + /// + /// Disabled by default in all build modes. Set to true to enable user access. + /// Does not prevent the app from explicitly calling + /// [WindowsWebViewController.openDevTools]. + final bool devToolsEnabled; + + /// Whether browser shortcuts such as reload, print and find are enabled. + /// + /// Null preserves the WebView2 default (enabled). Editing shortcuts remain + /// available. Disabling requires ICoreWebView2Settings3. + final bool? browserAcceleratorKeysEnabled; + + /// Whether this WebView can start downloads. Null preserves the default + /// (enabled). Disabling downloads requires ICoreWebView2_4. + final bool? downloadsEnabled; } /// Windows-specific creation parameters for [WindowsWebViewWidget]. @@ -168,17 +191,29 @@ class WindowsWebViewController extends PlatformWebViewController { String? _pageStartedUrl; String? _title; String? _userAgent; - late final _WebView2Setting _devToolsEnabled = _WebView2Setting( - (bool enabled) => _webviewController.setDevToolsEnabled(enabled), - ); + late final _WebView2Setting _devToolsEnabled = _WebView2Setting(( + bool enabled, + ) { + _throwIfDisposed(); + return _webviewController.setDevToolsEnabled(enabled); + }, _windowsParams.devToolsEnabled); late final _WebView2Setting _browserAcceleratorKeysEnabled = - _WebView2Setting( - (bool enabled) => - _webviewController.setBrowserAcceleratorKeysEnabled(enabled), - ); - late final _WebView2Setting _downloadsEnabled = _WebView2Setting( - (bool enabled) => _webviewController.setDownloadsEnabled(enabled), - ); + _WebView2Setting((bool enabled) { + _throwIfDisposed(); + return _webviewController.setBrowserAcceleratorKeysEnabled(enabled); + }, _windowsParams.browserAcceleratorKeysEnabled); + late final _WebView2Setting _downloadsEnabled = _WebView2Setting(( + bool enabled, + ) { + _throwIfDisposed(); + return _webviewController.setDownloadsEnabled(enabled); + }, _windowsParams.downloadsEnabled); + + Iterable<_WebView2Setting> get _settings => <_WebView2Setting>[ + _devToolsEnabled, + _browserAcceleratorKeysEnabled, + _downloadsEnabled, + ]; bool _canGoBack = false; bool _canGoForward = false; bool _verticalScrollBarEnabled = true; @@ -324,17 +359,6 @@ class WindowsWebViewController extends PlatformWebViewController { }, ); _throwIfDisposed(); - // A retried initialization creates a new WebView2, which starts from the - // WebView2 defaults; settings requested earlier must not be lost. - for (final _WebView2Setting setting in <_WebView2Setting>[ - _devToolsEnabled, - _browserAcceleratorKeysEnabled, - _downloadsEnabled, - ]) { - await setting.applyToNewWebView(); - _throwIfDisposed(); - } - _subscriptions.addAll(>[ _webviewController.url.listen((String url) { weakThis.target?._handleUrlChanged(url); @@ -372,15 +396,31 @@ class WindowsWebViewController extends PlatformWebViewController { _throwIfDisposed(); await _initializationFuture!; _throwIfDisposed(); + await _applySettings(); } - Future _retryInitialization() { - if (_isDisposed) { - return Future.error(_disposedStateError()); + Future _applySettings() async { + for (final _WebView2Setting setting in _settings) { + _throwIfDisposed(); + await setting.apply(); + } + _throwIfDisposed(); + } + + Future _retryInitialization() async { + _throwIfDisposed(); + final Future previous = _initializationFuture!; + try { + await previous; + } catch (_) { + _throwIfDisposed(); + if (identical(_initializationFuture, previous)) { + _setInitializationFuture(_prepareRetry()); + } + await _initializationFuture!; } - final Future initializationFuture = _prepareRetry(); - _setInitializationFuture(initializationFuture); - return initializationFuture; + // A setting failure leaves the native view and its subscriptions intact. + await _applySettings(); } void _setInitializationFuture(Future future) { @@ -453,6 +493,10 @@ class WindowsWebViewController extends PlatformWebViewController { // Continue with cleanup. } + await Future.wait( + _settings.map((_WebView2Setting setting) => setting.settled), + ); + Object? cleanupError; StackTrace? cleanupStackTrace; final List> subscriptions = @@ -1359,38 +1403,40 @@ ${params.functionBody} await _webviewController.setZoomControlEnabled(enabled); } - /// Sets whether the WebView2 DevTools can be opened. + /// Sets whether users can open DevTools through menus and keyboard shortcuts. /// - /// When disabled, neither F12 / Ctrl+Shift+I nor [openDevTools] opens - /// them. Mirrors `WebKitWebViewController.setInspectable`. WebView2 enables - /// DevTools by default. - Future setInspectable(bool inspectable) async { - _devToolsEnabled.request(inspectable); + /// Disabled by default. Does not close existing DevTools or prevent the app + /// from calling [openDevTools]. Changes apply on the next top-level navigation. + Future setDevToolsEnabled(bool enabled) async { + _throwIfDisposed(); + _devToolsEnabled.request(enabled); await _ensureInitialized(); - await _devToolsEnabled.apply(); } /// Sets whether browser-specific accelerator keys are enabled. /// /// When disabled, WebView2 stops acting on browser-specific keys such as /// F5 (reload), Ctrl+P (print), Ctrl+F (find) and Ctrl+Shift+I (DevTools). - /// Enabled by default. Requires `ICoreWebView2Settings3`; on a WebView2 + /// Enabled by default. Disabling requires `ICoreWebView2Settings3`; on a /// Runtime without it this throws a [PlatformException]. + /// Changes apply on the next top-level navigation. Text-editing shortcuts + /// remain enabled; disabled browser shortcuts are not forwarded to Flutter. Future setBrowserAcceleratorKeysEnabled(bool enabled) async { + _throwIfDisposed(); _browserAcceleratorKeysEnabled.request(enabled); await _ensureInitialized(); - await _browserAcceleratorKeysEnabled.apply(); } /// Sets whether the page may start downloads. /// - /// When disabled, every download is cancelled as it starts: nothing is - /// written to disk and the WebView2 download UI is never shown. Enabled by - /// default. + /// When disabled, new downloads are cancelled without saving the downloaded + /// file or showing the download UI. Existing downloads are not cancelled. + /// Enabled by default. Disabling requires `ICoreWebView2_4`; an unavailable + /// download handler causes a [PlatformException], not a silent success. Future setDownloadsEnabled(bool enabled) async { + _throwIfDisposed(); _downloadsEnabled.request(enabled); await _ensureInitialized(); - await _downloadsEnabled.apply(); } @override @@ -2251,31 +2297,47 @@ class _WindowsWebViewPermissionRequest /// A WebView2 setting owned by [WindowsWebViewController]. /// -/// Remembers the requested value so it survives a retried initialization, -/// and remembers what the current native WebView already has so the value is -/// sent once, whichever of the setter and the initialization gets there first. +/// Retains requested values across initialization failures. Native creation and +/// settings application have separate futures so a failed setting can be +/// corrected without leaving the controller's initialization future failed. final class _WebView2Setting { - _WebView2Setting(this._push); + _WebView2Setting(this._push, this._requested); final Future Function(T value) _push; T? _requested; T? _applied; + Future? _tail; + + Future get settled async { + await _tail; + } void request(T value) => _requested = value; /// Sends the requested value unless the native WebView already has it. - Future apply() async { - final T? value = _requested; - if (value == null || value == _applied) { - return; - } - await _push(value); - _applied = value; - } - - /// A new native WebView starts from the WebView2 defaults. - Future applyToNewWebView() { - _applied = null; - return apply(); + Future apply() { + if (_tail == null && _requested == _applied) { + return Future.value(); + } + final Future? previous = _tail; + final Future result = () async { + await previous; + while (_requested != null && _requested != _applied) { + final T value = _requested!; + await _push(value); + _applied = value; + } + }(); + // Report failures to this caller without poisoning subsequent requests. + final Future tail = result.catchError((Object _) {}); + _tail = tail; + unawaited( + tail.then((_) { + if (identical(_tail, tail)) { + _tail = null; + } + }), + ); + return result; } } diff --git a/webview_all_windows/lib/src/windows_webview_native.dart b/webview_all_windows/lib/src/windows_webview_native.dart index 3766a2e..2cec76b 100644 --- a/webview_all_windows/lib/src/windows_webview_native.dart +++ b/webview_all_windows/lib/src/windows_webview_native.dart @@ -1029,7 +1029,8 @@ class WebviewController extends ValueNotifier { return _hostApi.setZoomControlEnabled(_textureId, enabled); } - /// Sets whether the DevTools can be opened (F12, Ctrl+Shift+I, [openDevTools]). + /// Sets whether users can open DevTools through menus and keyboard shortcuts. + /// Does not prevent explicit calls to [openDevTools]. Future setDevToolsEnabled(bool enabled) async { if (_isDisposed) { return; @@ -1047,7 +1048,7 @@ class WebviewController extends ValueNotifier { return _hostApi.setBrowserAcceleratorKeysEnabled(_textureId, enabled); } - /// Sets whether downloads are allowed; when disabled they are cancelled before anything is written to disk. + /// Sets whether new downloads may save files. Existing downloads are unchanged. Future setDownloadsEnabled(bool enabled) async { if (_isDisposed) { return; diff --git a/webview_all_windows/test/webview_windows_test.dart b/webview_all_windows/test/webview_windows_test.dart index 17f4b87..5279773 100644 --- a/webview_all_windows/test/webview_windows_test.dart +++ b/webview_all_windows/test/webview_windows_test.dart @@ -1331,12 +1331,14 @@ void main() { const PlatformWebViewControllerCreationParams(), ); - await controller.setInspectable(false); + await controller.setDevToolsEnabled(false); + await controller.setDevToolsEnabled(true); + await controller.setDevToolsEnabled(false); await controller.setBrowserAcceleratorKeysEnabled(false); await controller.setDownloadsEnabled(false); await controller.setDownloadsEnabled(true); - expect(devToolsValues, [false]); + expect(devToolsValues, [false, true, false]); expect(acceleratorKeysValues, [false]); expect(downloadsValues, [false, true]); }, @@ -1360,15 +1362,13 @@ void main() { // The first environment fails, so the settings cannot reach WebView2 yet; // they must still hold once the widget's refresh creates a new one. - await tester.runAsync(() async { - for (final Future Function() apply in Function()>[ - () => controller.setInspectable(false), - () => controller.setBrowserAcceleratorKeysEnabled(false), - () => controller.setDownloadsEnabled(false), - ]) { - await expectLater(apply(), throwsA(isA())); - } - }); + for (final Future Function() apply in Function()>[ + () => controller.setDevToolsEnabled(false), + () => controller.setBrowserAcceleratorKeysEnabled(false), + () => controller.setDownloadsEnabled(false), + ]) { + await expectLater(apply(), throwsA(isA())); + } expect(devToolsValues, isEmpty); final WindowsWebViewWidget platformWidget = WindowsWebViewWidget( @@ -1391,6 +1391,305 @@ void main() { expect(devToolsValues, [false]); expect(acceleratorKeysValues, [false]); expect(downloadsValues, [false]); + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pump(); + var disposed = false; + // EventChannel teardown also schedules work outside the fake test clock. + final disposal = controller.dispose().then((_) { + disposed = true; + }); + for (var i = 0; !disposed && i < 10; i++) { + await tester.pump(); + await tester.runAsync(() => Future.delayed(Duration.zero)); + } + expect(disposed, isTrue); + await disposal; + }, + ); + + testWidgets( + 'refresh recovers a setting failure without replacing native subscriptions', + (WidgetTester tester) async { + var creations = 0; + _mockWindowsWebViewCreation( + beforeCreateWebView: () async { + creations++; + }, + onSetDownloadsEnabled: (bool enabled) { + if (!enabled) throw PlatformException(code: 'not_supported'); + }, + ); + final controller = WindowsWebViewController( + const WindowsWebViewControllerCreationParams(downloadsEnabled: false), + ); + final platformWidget = WindowsWebViewWidget( + PlatformWebViewWidgetCreationParams(controller: controller), + ); + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: SizedBox( + width: 800, + height: 600, + child: Builder(builder: platformWidget.build), + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('Refresh'), findsOneWidget); + expect(find.text('Install Webview2'), findsNothing); + await controller.setDownloadsEnabled(true); + await tester.tap(find.text('Refresh')); + await tester.pumpAndSettle(); + expect(find.text('Refresh'), findsNothing); + expect(find.byType(native_webview.Webview), findsOneWidget); + expect(creations, 1); + await tester.pumpWidget(const SizedBox.shrink()); + var disposed = false; + final disposal = controller.dispose().then((_) { + disposed = true; + }); + for (var i = 0; !disposed && i < 10; i++) { + await tester.pump(); + await tester.runAsync(() => Future.delayed(Duration.zero)); + } + expect(disposed, isTrue); + await disposal; + }, + ); + + for (final entry in { + 'generic': const PlatformWebViewControllerCreationParams(), + 'Windows': const WindowsWebViewControllerCreationParams(), + 'converted': + const WindowsWebViewControllerCreationParams.fromPlatformWebViewControllerCreationParams( + PlatformWebViewControllerCreationParams(), + ), + }.entries) { + test( + '${entry.key} params disable DevTools before loading without changing other defaults', + () async { + final events = []; + _mockWindowsWebViewCreation( + onSetDevToolsEnabled: (bool value) => events.add('devTools:$value'), + onSetBrowserAcceleratorKeysEnabled: (bool value) => + events.add('keys:$value'), + onSetDownloadsEnabled: (bool value) => events.add('downloads:$value'), + onLoadRequest: (_) => events.add('load'), + ); + final controller = WindowsWebViewController(entry.value); + addTearDown(controller.dispose); + final request = LoadRequestParams( + uri: Uri.parse('https://example.test'), + ); + await controller.loadRequest(request); + await controller.loadRequest(request); + expect(events, ['devTools:false', 'load', 'load']); + }, + ); + } + + for (final entry in { + 'Windows': const WindowsWebViewControllerCreationParams( + devToolsEnabled: true, + ), + 'converted': + const WindowsWebViewControllerCreationParams.fromPlatformWebViewControllerCreationParams( + PlatformWebViewControllerCreationParams(), + devToolsEnabled: true, + ), + }.entries) { + test( + '${entry.key} params explicitly enable DevTools before loading', + () async { + final events = []; + _mockWindowsWebViewCreation( + onSetDevToolsEnabled: (bool value) => events.add('devTools:$value'), + onLoadRequest: (_) => events.add('load'), + ); + final controller = WindowsWebViewController(entry.value); + addTearDown(controller.dispose); + await controller.loadRequest( + LoadRequestParams(uri: Uri.parse('https://example.test')), + ); + expect(events, ['devTools:true', 'load']); + }, + ); + } + + test('opens DevTools explicitly without enabling user shortcuts', () async { + final events = []; + _mockWindowsWebViewCreation( + onSetDevToolsEnabled: (bool value) => events.add('devTools:$value'), + onOpenDevTools: () => events.add('open'), + ); + final controller = WindowsWebViewController( + const WindowsWebViewControllerCreationParams(), + ); + addTearDown(controller.dispose); + await controller.openDevTools(); + expect(events, ['devTools:false', 'open']); + }); + + test('applies creation settings before the first navigation', () async { + final events = []; + _mockWindowsWebViewCreation( + onSetDevToolsEnabled: (bool value) => events.add('devTools:$value'), + onSetBrowserAcceleratorKeysEnabled: (bool value) => + events.add('keys:$value'), + onSetDownloadsEnabled: (bool value) => events.add('downloads:$value'), + onLoadRequest: (_) => events.add('load'), + ); + final controller = WindowsWebViewController( + const WindowsWebViewControllerCreationParams.fromPlatformWebViewControllerCreationParams( + PlatformWebViewControllerCreationParams(), + devToolsEnabled: false, + browserAcceleratorKeysEnabled: false, + downloadsEnabled: false, + ), + ); + addTearDown(controller.dispose); + await controller.loadRequest( + LoadRequestParams(uri: Uri.parse('https://example.test')), + ); + expect(events, [ + 'devTools:false', + 'keys:false', + 'downloads:false', + 'load', + ]); + }); + + test('serializes changes while a native setting is in flight', () async { + final values = []; + final started = Completer(); + final release = Completer(); + _mockWindowsWebViewCreation( + onSetDownloadsEnabled: (bool value) async { + values.add(value); + if (value) { + started.complete(); + await release.future; + } + }, + ); + final controller = WindowsWebViewController( + const WindowsWebViewControllerCreationParams(), + ); + addTearDown(controller.dispose); + await controller.setDownloadsEnabled(false); + final enabling = controller.setDownloadsEnabled(true); + await started.future; + final disabling = controller.setDownloadsEnabled(false); + release.complete(); + await Future.wait(>[enabling, disabling]); + expect(values, [false, true, false]); + }); + + test( + 'failed settings block navigation but can be corrected without recreating the WebView', + () async { + var creations = 0; + var loads = 0; + _mockWindowsWebViewCreation( + beforeCreateWebView: () async { + creations++; + }, + onSetDownloadsEnabled: (bool enabled) { + if (!enabled) { + throw PlatformException( + code: 'not_supported', + details: {'hresult': '0x80004002'}, + ); + } + }, + onLoadRequest: (_) { + loads++; + }, + ); + final controller = WindowsWebViewController( + const WindowsWebViewControllerCreationParams(downloadsEnabled: false), + ); + addTearDown(controller.dispose); + final request = LoadRequestParams(uri: Uri.parse('https://example.test')); + await expectLater( + controller.loadRequest(request), + throwsA( + isA() + .having((error) => error.code, 'code', 'not_supported') + .having((error) => error.details, 'details', { + 'hresult': '0x80004002', + }), + ), + ); + expect(loads, 0); + await controller.setDownloadsEnabled(true); + await controller.loadRequest(request); + expect(loads, 1); + expect(creations, 1); + }, + ); + + test('a transient setting failure can be retried', () async { + var attempts = 0; + _mockWindowsWebViewCreation( + onSetDevToolsEnabled: (_) { + if (attempts++ == 0) { + throw PlatformException(code: 'method_failed'); + } + }, + ); + final controller = WindowsWebViewController( + const WindowsWebViewControllerCreationParams(), + ); + addTearDown(controller.dispose); + await expectLater( + controller.setDevToolsEnabled(false), + throwsA( + isA().having( + (error) => error.code, + 'code', + 'method_failed', + ), + ), + ); + await controller.setDevToolsEnabled(false); + expect(attempts, 2); + }); + + test( + 'disposing drains in-flight settings and rejects further changes', + () async { + final started = Completer(); + final release = Completer(); + var disposed = false; + _mockWindowsWebViewCreation( + onSetDownloadsEnabled: (_) async { + started.complete(); + await release.future; + }, + onDisposeWebView: () { + disposed = true; + }, + ); + final controller = WindowsWebViewController( + const WindowsWebViewControllerCreationParams(), + ); + final setting = controller.setDownloadsEnabled(false); + final failure = expectLater(setting, throwsStateError); + await started.future; + final disposal = controller.dispose(); + expect(disposed, isFalse); + release.complete(); + await failure; + await disposal; + expect(disposed, isTrue); + await expectLater(controller.setDownloadsEnabled(true), throwsStateError); + await expectLater(controller.setDevToolsEnabled(true), throwsStateError); + await expectLater( + controller.setBrowserAcceleratorKeysEnabled(true), + throwsStateError, + ); }, ); @@ -1987,9 +2286,10 @@ void _mockWindowsWebViewCreation({ String? clearAllWebsiteDataFailureCode, void Function(bool enabled)? onSetJavaScriptEnabled, void Function(bool enabled)? onSetZoomControlEnabled, - void Function(bool enabled)? onSetDevToolsEnabled, - void Function(bool enabled)? onSetBrowserAcceleratorKeysEnabled, - void Function(bool enabled)? onSetDownloadsEnabled, + FutureOr Function(bool enabled)? onSetDevToolsEnabled, + void Function()? onOpenDevTools, + FutureOr Function(bool enabled)? onSetBrowserAcceleratorKeysEnabled, + FutureOr Function(bool enabled)? onSetDownloadsEnabled, void Function(bool enabled)? onSetNavigationRequestCallbacksEnabled, void Function(WindowsSizeData size)? onSetSize, int setSizeFailureCount = 0, @@ -2170,18 +2470,32 @@ void _mockWindowsWebViewCreation({ onSetZoomControlEnabled?.call(args[1]! as bool); return _encodePigeonSuccess(); }); + messenger.setMockMessageHandler(_hostApiChannel('openDevTools'), ( + ByteData? message, + ) async { + onOpenDevTools?.call(); + return _encodePigeonSuccess(); + }); messenger.setMockMessageHandler(_hostApiChannel('setDevToolsEnabled'), ( ByteData? message, ) async { final args = _decodePigeonArgs(message); - onSetDevToolsEnabled?.call(args[1]! as bool); + try { + await onSetDevToolsEnabled?.call(args[1]! as bool); + } on PlatformException catch (error) { + return _encodePigeonError(error); + } return _encodePigeonSuccess(); }); messenger.setMockMessageHandler( _hostApiChannel('setBrowserAcceleratorKeysEnabled'), (ByteData? message) async { final args = _decodePigeonArgs(message); - onSetBrowserAcceleratorKeysEnabled?.call(args[1]! as bool); + try { + await onSetBrowserAcceleratorKeysEnabled?.call(args[1]! as bool); + } on PlatformException catch (error) { + return _encodePigeonError(error); + } return _encodePigeonSuccess(); }, ); @@ -2189,7 +2503,11 @@ void _mockWindowsWebViewCreation({ ByteData? message, ) async { final args = _decodePigeonArgs(message); - onSetDownloadsEnabled?.call(args[1]! as bool); + try { + await onSetDownloadsEnabled?.call(args[1]! as bool); + } on PlatformException catch (error) { + return _encodePigeonError(error); + } return _encodePigeonSuccess(); }); messenger.setMockMessageHandler( @@ -2299,6 +2617,7 @@ void _clearWindowsWebViewCreationMock() { null, ); messenger.setMockMessageHandler(_hostApiChannel('setDevToolsEnabled'), null); + messenger.setMockMessageHandler(_hostApiChannel('openDevTools'), null); messenger.setMockMessageHandler( _hostApiChannel('setBrowserAcceleratorKeysEnabled'), null, @@ -2326,6 +2645,14 @@ ByteData? _encodePigeonSuccess() { ]); } +ByteData? _encodePigeonError(PlatformException error) { + return WindowsWebViewHostApi.pigeonChannelCodec.encodeMessage([ + error.code, + error.message, + error.details, + ]); +} + List _decodePigeonArgs(ByteData? message) { return WindowsWebViewHostApi.pigeonChannelCodec.decodeMessage(message!) as List; From be066b695020ce5f799bf81b5f97214a4271a739 Mon Sep 17 00:00:00 2001 From: moluopro Date: Fri, 25 Sep 2026 21:50:00 +0800 Subject: [PATCH 06/17] test(windows): verify download policies with native lifecycle events --- .../webview_flutter_test.dart | 172 +++++++++++------- 1 file changed, 106 insertions(+), 66 deletions(-) diff --git a/examples/platform/integration_test/webview_flutter_test.dart b/examples/platform/integration_test/webview_flutter_test.dart index 016b83c..5d9c275 100644 --- a/examples/platform/integration_test/webview_flutter_test.dart +++ b/examples/platform/integration_test/webview_flutter_test.dart @@ -20,6 +20,10 @@ import 'package:webview_all_android/webview_all_android.dart'; import 'package:webview_all_linux/webview_all_linux.dart'; import 'package:webview_all_wkwebview/webview_all_wkwebview.dart'; import 'package:webview_all_windows/webview_all_windows.dart'; +import 'package:webview_all_windows/src/windows_webview_native.dart' + as native_windows; +import 'package:webview_all_windows/src/windows_webview_types.dart' + as windows_types; Future main() async { IntegrationTestWidgetsFlutterBinding.ensureInitialized(); @@ -38,6 +42,13 @@ Future main() async { request.response.writeln('${request.headers}'); } else if (request.uri.path == '/favicon.ico') { request.response.statusCode = HttpStatus.notFound; + } else if (request.uri.path.startsWith('/webview_all_download_')) { + request.response.headers.contentType = ContentType.text; + request.response.headers.set( + 'Content-Disposition', + 'attachment; filename="${request.uri.pathSegments.last}"', + ); + request.response.write('webview_all'); } else if (request.uri.path == '/http-basic-authentication') { final List? authHeader = request.headers[HttpHeaders.authorizationHeader]; @@ -235,80 +246,116 @@ return { return; } - // WebView2 saves to the user's Downloads folder by default. The allowed - // download is the control: without it, a page that never starts a - // download at all would pass the refused case too. - final Directory downloads = Directory( - '${Platform.environment['USERPROFILE']}\\Downloads', - ); final String stamp = DateTime.now().microsecondsSinceEpoch.toString(); - final File refused = File( - '${downloads.path}\\webview_all_refused_$stamp.txt', - ); - final File allowed = File( - '${downloads.path}\\webview_all_allowed_$stamp.txt', + final files = {}; + final controllers = List.generate( + 2, + (_) => WebViewController.fromPlatformCreationParams( + const WindowsWebViewControllerCreationParams( + devToolsEnabled: false, + browserAcceleratorKeysEnabled: false, + ), + ), ); + final windows = controllers + .map((controller) => controller.platform as WindowsWebViewController) + .toList(); + final subscriptions = + >[]; addTearDown(() async { - for (final File file in [refused, allowed]) { + await tester.pumpWidget(const SizedBox.shrink()); + for (final controller in windows) { + await controller.dispose(); + } + for (final subscription in subscriptions) { + await subscription.cancel(); + } + for (final String path in files) { + final file = File(path); if (file.existsSync()) { file.deleteSync(); } } }); + for (final controller in controllers) { + await controller.currentUrl(); + } + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: controllers + .map( + (controller) => + Expanded(child: WebViewWidget(controller: controller)), + ) + .toList(), + ), + ), + ); + await tester.pumpAndSettle(); + final native = tester + .widgetList(find.byType(native_windows.Webview)) + .map((widget) => widget.controller) + .toList(); + expect(native, hasLength(2)); + for (final controller in native) { + subscriptions.add( + controller.onDownloadEvent.listen((event) { + if (event.resultFilePath.isNotEmpty && + File(event.resultFilePath).uri.pathSegments.last.startsWith( + 'webview_all_download_$stamp', + )) { + files.add(event.resultFilePath); + } + }), + ); + } + Future download({ + required int index, required bool enabled, - required String fileName, + required String suffix, }) async { - final Completer pageFinished = Completer(); - final WebViewController controller = WebViewController(); - final WindowsWebViewController windowsController = - controller.platform as WindowsWebViewController; - await controller.setJavaScriptMode(JavaScriptMode.unrestricted); - await windowsController.setDownloadsEnabled(enabled); - await controller.setNavigationDelegate( - NavigationDelegate( - onPageFinished: (_) { - if (!pageFinished.isCompleted) { - pageFinished.complete(); - } - }, - ), + final name = 'webview_all_download_${stamp}_$suffix.txt'; + final url = '$prefixUrl/$name'; + final result = native[index].onDownloadEvent + .firstWhere( + (event) => + event.url == url && + (event.kind == + windows_types + .WebviewDownloadEventKind + .downloadCompleted || + event.kind == + windows_types + .WebviewDownloadEventKind + .downloadCancelled), + ) + .timeout(const Duration(seconds: 20)); + await controllers[index].loadRequest(Uri.parse(url)); + final event = await result; + expect( + event.kind, + enabled + ? windows_types.WebviewDownloadEventKind.downloadCompleted + : windows_types.WebviewDownloadEventKind.downloadCancelled, ); - await controller.loadHtmlString(''' - -Windows download test -Windows download test -'''); - await tester.pumpWidget(WebViewWidget(controller: controller)); - await pageFinished.future.timeout(const Duration(seconds: 15)); - await controller.runJavaScript(''' -const link = document.createElement('a'); -link.href = URL.createObjectURL(new Blob(['webview_all'], {type: 'text/plain'})); -link.download = '$fileName'; -document.body.appendChild(link); -link.click(); -'''); + final file = File(event.resultFilePath); + expect(file.uri.pathSegments.last, name); + expect(file.existsSync(), enabled); + if (enabled) { + expect(file.readAsStringSync(), 'webview_all'); + } } - await download(enabled: true, fileName: allowed.uri.pathSegments.last); - await _waitFor(allowed.existsSync, const Duration(seconds: 15)); - expect( - allowed.existsSync(), - isTrue, - reason: 'The control download never reached ${downloads.path}.', - ); - - await download(enabled: false, fileName: refused.uri.pathSegments.last); - // Longer than the allowed download took: absence is the assertion. - await Future.delayed(const Duration(seconds: 3)); - expect( - refused.existsSync(), - isFalse, - reason: 'A download started while downloads were disabled.', - ); - - await tester.pumpWidget(const SizedBox.shrink()); + await download(index: 0, enabled: true, suffix: 'default'); + await windows[0].setDownloadsEnabled(false); + await download(index: 0, enabled: false, suffix: 'disabled'); + await download(index: 1, enabled: true, suffix: 'independent'); + await windows[0].setDownloadsEnabled(true); + await download(index: 0, enabled: true, suffix: 'enabled'); }); testWidgets('Windows controller releases its renderer process', ( @@ -2095,10 +2142,3 @@ Future _waitForJavaScriptPredicate( } throw TestFailure('JavaScript result did not satisfy: $expression'); } - -Future _waitFor(bool Function() condition, Duration timeout) async { - final DateTime deadline = DateTime.now().add(timeout); - while (!condition() && DateTime.now().isBefore(deadline)) { - await Future.delayed(const Duration(milliseconds: 100)); - } -} From 931da8728bf4d003c57ab03219f9c2c69d626038 Mon Sep 17 00:00:00 2001 From: moluopro Date: Fri, 25 Sep 2026 21:50:00 +0800 Subject: [PATCH 07/17] feat(linux): enforce per-WebView native download policies --- webview_all_linux/linux/CMakeLists.txt | 1 + .../plugin/webview_all_linux_plugin_private.h | 3 +++ .../linux/src/webview/download_policy.cc | 23 ++++++++++++++++++ .../linux/src/webview/download_policy.h | 24 +++++++++++++++++++ .../linux/src/webview/linux_webview.cc | 4 ++++ .../src/webview/webview_method_handler.cc | 18 ++++++++++++++ 6 files changed, 73 insertions(+) create mode 100644 webview_all_linux/linux/src/webview/download_policy.cc create mode 100644 webview_all_linux/linux/src/webview/download_policy.h diff --git a/webview_all_linux/linux/CMakeLists.txt b/webview_all_linux/linux/CMakeLists.txt index 9d6a5bd..46af8f4 100644 --- a/webview_all_linux/linux/CMakeLists.txt +++ b/webview_all_linux/linux/CMakeLists.txt @@ -15,6 +15,7 @@ add_library(${PLUGIN_NAME} SHARED "src/plugin/webview_all_linux_plugin.cc" "src/plugin/root_channel.cc" "src/webview/linux_webview.cc" + "src/webview/download_policy.cc" "src/webview/webview_javascript.cc" "src/webview/webview_method_handler.cc" ) diff --git a/webview_all_linux/linux/src/plugin/webview_all_linux_plugin_private.h b/webview_all_linux/linux/src/plugin/webview_all_linux_plugin_private.h index 8371259..edfa2fc 100644 --- a/webview_all_linux/linux/src/plugin/webview_all_linux_plugin_private.h +++ b/webview_all_linux/linux/src/plugin/webview_all_linux_plugin_private.h @@ -9,6 +9,8 @@ constexpr const gchar *kLinuxWebViewInstanceKey = "webview_all_linux_instance"; +class DownloadPolicy; + struct _WebviewAllLinuxPlugin { GObject parent_instance; @@ -51,6 +53,7 @@ typedef struct { gboolean vertical_scrollbar_enabled; gboolean horizontal_scrollbar_enabled; gboolean zoom_enabled; + DownloadPolicy *download_policy; gint media_playback_requires_user_gesture; const gchar *over_scroll_behavior; gint frame_x; diff --git a/webview_all_linux/linux/src/webview/download_policy.cc b/webview_all_linux/linux/src/webview/download_policy.cc new file mode 100644 index 0000000..73fb9fe --- /dev/null +++ b/webview_all_linux/linux/src/webview/download_policy.cc @@ -0,0 +1,23 @@ +#include "webview/download_policy.h" + +DownloadPolicy::DownloadPolicy(WebKitWebView *web_view) : web_view_(web_view) { + signal_id_ = + g_signal_connect(webkit_web_view_get_context(web_view_), + "download-started", G_CALLBACK(OnDownloadStarted), this); +} + +DownloadPolicy::~DownloadPolicy() { + g_signal_handler_disconnect(webkit_web_view_get_context(web_view_), + signal_id_); +} + +void DownloadPolicy::OnDownloadStarted(WebKitWebContext *context, + WebKitDownload *download, + gpointer user_data) { + auto *policy = static_cast(user_data); + // A shared context must not let one view cancel another view's downloads. + if (!policy->enabled_ && + webkit_download_get_web_view(download) == policy->web_view_) { + webkit_download_cancel(download); + } +} diff --git a/webview_all_linux/linux/src/webview/download_policy.h b/webview_all_linux/linux/src/webview/download_policy.h new file mode 100644 index 0000000..1b8c374 --- /dev/null +++ b/webview_all_linux/linux/src/webview/download_policy.h @@ -0,0 +1,24 @@ +#ifndef WEBVIEW_ALL_LINUX_DOWNLOAD_POLICY_H_ +#define WEBVIEW_ALL_LINUX_DOWNLOAD_POLICY_H_ + +#include + +// Owned by a single WebView and destroyed before that WebView is released. +class DownloadPolicy { +public: + explicit DownloadPolicy(WebKitWebView *web_view); + ~DownloadPolicy(); + DownloadPolicy(const DownloadPolicy &) = delete; + DownloadPolicy &operator=(const DownloadPolicy &) = delete; + + void SetEnabled(bool enabled) { enabled_ = enabled; } + +private: + static void OnDownloadStarted(WebKitWebContext *context, + WebKitDownload *download, gpointer user_data); + WebKitWebView *web_view_; + gulong signal_id_; + bool enabled_ = true; +}; + +#endif // WEBVIEW_ALL_LINUX_DOWNLOAD_POLICY_H_ diff --git a/webview_all_linux/linux/src/webview/linux_webview.cc b/webview_all_linux/linux/src/webview/linux_webview.cc index e50c313..5c134eb 100644 --- a/webview_all_linux/linux/src/webview/linux_webview.cc +++ b/webview_all_linux/linux/src/webview/linux_webview.cc @@ -1,5 +1,6 @@ #include "common/method_channel_utils.h" #include "plugin/webview_all_linux_plugin_private.h" +#include "webview/download_policy.h" #include "webview/webview_internal.h" #include @@ -726,6 +727,8 @@ void destroy_linux_webview(gpointer data) { } if (webview->web_view != nullptr) { + delete webview->download_policy; + webview->download_policy = nullptr; webview->visible = FALSE; release_linux_webview_focus(webview); if (webview->plugin != nullptr && !webview->plugin->disposing) { @@ -804,6 +807,7 @@ LinuxWebView *create_linux_webview(WebviewAllLinuxPlugin *self) { webview->vertical_scrollbar_enabled = TRUE; webview->horizontal_scrollbar_enabled = TRUE; webview->zoom_enabled = TRUE; + webview->download_policy = new DownloadPolicy(webview->web_view); webview->media_playback_requires_user_gesture = -1; webview->frame_sequence = 0; webview->over_scroll_behavior = ""; diff --git a/webview_all_linux/linux/src/webview/webview_method_handler.cc b/webview_all_linux/linux/src/webview/webview_method_handler.cc index 85cca48..f6fe4a1 100644 --- a/webview_all_linux/linux/src/webview/webview_method_handler.cc +++ b/webview_all_linux/linux/src/webview/webview_method_handler.cc @@ -1,4 +1,5 @@ #include "common/method_channel_utils.h" +#include "webview/download_policy.h" #include "webview/webview_internal.h" #include @@ -135,6 +136,11 @@ void apply_webkit_settings(LinuxWebView *webview, FlValue *args) { WebKitSettings *settings = webkit_web_view_get_settings(webview->web_view); FlValue *value = nullptr; + value = map_lookup(args, "downloadsEnabled"); + if (value != nullptr && fl_value_get_type(value) == FL_VALUE_TYPE_BOOL) { + webview->download_policy->SetEnabled(fl_value_get_bool(value)); + } + value = map_lookup(args, "developerExtrasEnabled"); if (value != nullptr && fl_value_get_type(value) == FL_VALUE_TYPE_BOOL) { webkit_settings_set_enable_developer_extras(settings, @@ -768,6 +774,18 @@ void instance_method_call_cb(FlMethodChannel *channel, return; } + if (strcmp(method, "setDownloadsEnabled") == 0) { + FlValue *enabled = map_lookup(args, "enabled"); + if (enabled == nullptr || fl_value_get_type(enabled) != FL_VALUE_TYPE_BOOL) { + respond(method_call, error_response("invalid_arguments", + "enabled must be a boolean")); + return; + } + webview->download_policy->SetEnabled(fl_value_get_bool(enabled)); + respond(method_call, success_response()); + return; + } + if (strcmp(method, "openDevTools") == 0) { WebKitSettings *settings = webkit_web_view_get_settings(webview->web_view); webkit_settings_set_enable_developer_extras(settings, TRUE); From 4f8a48026bed6e5f64f0502844a65809d381576f Mon Sep 17 00:00:00 2001 From: moluopro Date: Fri, 25 Sep 2026 21:50:08 +0800 Subject: [PATCH 08/17] feat(linux): expose creation-time and runtime download controls --- .../lib/src/linux_webview_controller.dart | 12 +++++++ .../src/linux_webview_creation_params.dart | 6 ++++ .../test/linux_webview_platform_test.dart | 31 +++++++++++++++++++ 3 files changed, 49 insertions(+) diff --git a/webview_all_linux/lib/src/linux_webview_controller.dart b/webview_all_linux/lib/src/linux_webview_controller.dart index ea388d0..463a94a 100644 --- a/webview_all_linux/lib/src/linux_webview_controller.dart +++ b/webview_all_linux/lib/src/linux_webview_controller.dart @@ -151,6 +151,8 @@ class LinuxWebViewController extends PlatformWebViewController { final Map settings = { if (_linuxParams.developerExtrasEnabled case final bool value) 'developerExtrasEnabled': value, + if (_linuxParams.downloadsEnabled case final bool value) + 'downloadsEnabled': value, if (_linuxParams.javascriptCanOpenWindowsAutomatically case final bool value) 'javascriptCanOpenWindowsAutomatically': value, @@ -670,6 +672,16 @@ class LinuxWebViewController extends PlatformWebViewController { /// Opens the WebKitGTK web inspector for this WebView. Future openDevTools() => _invoke('openDevTools'); + /// Sets whether this WebView can start downloads. Enabled by default. + /// + /// When disabled, new downloads are cancelled before saving a file. Downloads + /// already in progress are not cancelled. Other WebViews are not affected. + Future setDownloadsEnabled(bool enabled) { + return _invoke('setDownloadsEnabled', { + 'enabled': enabled, + }); + } + /// Sets whether JavaScript may open windows automatically. Future setJavaScriptCanOpenWindowsAutomatically(bool enabled) { return _invoke( diff --git a/webview_all_linux/lib/src/linux_webview_creation_params.dart b/webview_all_linux/lib/src/linux_webview_creation_params.dart index 668e3c5..0895199 100644 --- a/webview_all_linux/lib/src/linux_webview_creation_params.dart +++ b/webview_all_linux/lib/src/linux_webview_creation_params.dart @@ -6,6 +6,7 @@ class LinuxWebViewControllerCreationParams extends PlatformWebViewControllerCreationParams { const LinuxWebViewControllerCreationParams({ this.developerExtrasEnabled, + this.downloadsEnabled, this.javascriptCanOpenWindowsAutomatically, this.mediaPlaybackRequiresUserGesture, this.mediaPlaybackAllowsInline, @@ -22,6 +23,7 @@ class LinuxWebViewControllerCreationParams const LinuxWebViewControllerCreationParams.fromPlatformWebViewControllerCreationParams( PlatformWebViewControllerCreationParams params, { this.developerExtrasEnabled, + this.downloadsEnabled, this.javascriptCanOpenWindowsAutomatically, this.mediaPlaybackRequiresUserGesture, this.mediaPlaybackAllowsInline, @@ -38,6 +40,10 @@ class LinuxWebViewControllerCreationParams /// Whether WebKitGTK developer extras are enabled for this WebView. final bool? developerExtrasEnabled; + /// Whether this WebView can start downloads. Null preserves the WebKitGTK + /// default (enabled). Disabled downloads are cancelled before saving a file. + final bool? downloadsEnabled; + /// Whether JavaScript is allowed to open windows automatically. final bool? javascriptCanOpenWindowsAutomatically; diff --git a/webview_all_linux/test/linux_webview_platform_test.dart b/webview_all_linux/test/linux_webview_platform_test.dart index a90bff1..81562bb 100644 --- a/webview_all_linux/test/linux_webview_platform_test.dart +++ b/webview_all_linux/test/linux_webview_platform_test.dart @@ -861,6 +861,7 @@ void main() { final LinuxWebViewController controller = LinuxWebViewController( const LinuxWebViewControllerCreationParams( developerExtrasEnabled: true, + downloadsEnabled: false, javascriptCanOpenWindowsAutomatically: true, mediaPlaybackRequiresUserGesture: false, mediaPlaybackAllowsInline: true, @@ -880,6 +881,7 @@ void main() { expect(calls.first.method, 'applySettings'); expect(calls.first.arguments, { 'developerExtrasEnabled': true, + 'downloadsEnabled': false, 'javascriptCanOpenWindowsAutomatically': true, 'mediaPlaybackRequiresUserGesture': false, 'mediaPlaybackAllowsInline': true, @@ -946,6 +948,35 @@ void main() { expect(calls[12].arguments, {'zoomFactor': 1.25}); }); + test( + 'leaves downloads enabled by default and forwards runtime changes', + () async { + final calls = []; + _mockLinuxWebViewCreation(onInstanceCall: calls.add); + final controller = LinuxWebViewController( + const LinuxWebViewControllerCreationParams.fromPlatformWebViewControllerCreationParams( + PlatformWebViewControllerCreationParams(), + ), + ); + addTearDown(controller.dispose); + await controller.setDownloadsEnabled(false); + await controller.setDownloadsEnabled(true); + expect(calls.map((call) => call.method), [ + 'setDownloadsEnabled', + 'setDownloadsEnabled', + ]); + expect(calls.map((call) => call.arguments), [ + {'enabled': false}, + {'enabled': true}, + ]); + await controller.dispose(); + await expectLater( + controller.setDownloadsEnabled(false), + throwsStateError, + ); + }, + ); + test('loads requests with method headers and body', () async { final List calls = []; _mockLinuxWebViewCreation(onInstanceCall: calls.add); From f1b25108ca8ac8f0591676f5171bf79696b56947 Mon Sep 17 00:00:00 2001 From: moluopro Date: Fri, 25 Sep 2026 21:50:08 +0800 Subject: [PATCH 09/17] test(linux): run native download policy regressions in CI --- .github/workflows/ci.yml | 6 + webview_all_linux/linux/test/CMakeLists.txt | 20 ++ .../linux/test/download_policy_test.cc | 176 ++++++++++++++++++ 3 files changed, 202 insertions(+) create mode 100644 webview_all_linux/linux/test/CMakeLists.txt create mode 100644 webview_all_linux/linux/test/download_policy_test.cc diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b017785..9c637e8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -269,6 +269,12 @@ jobs: channel: stable cache: true + - name: Test native download policies + run: | + cmake -S webview_all_linux/linux/test -B build/linux-native-tests + cmake --build build/linux-native-tests + xvfb-run -a ctest --test-dir build/linux-native-tests --output-on-failure + - name: Build Linux example working-directory: examples/platform run: | diff --git a/webview_all_linux/linux/test/CMakeLists.txt b/webview_all_linux/linux/test/CMakeLists.txt new file mode 100644 index 0000000..0f9c53f --- /dev/null +++ b/webview_all_linux/linux/test/CMakeLists.txt @@ -0,0 +1,20 @@ +cmake_minimum_required(VERSION 3.10) +project(webview_all_linux_native_tests LANGUAGES CXX) +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) + +find_package(PkgConfig REQUIRED) +pkg_check_modules(WEBKIT REQUIRED IMPORTED_TARGET webkit2gtk-4.1) + +add_executable(download_policy_test + download_policy_test.cc + ../src/webview/download_policy.cc +) +target_include_directories(download_policy_test PRIVATE ../src) +target_link_libraries(download_policy_test PRIVATE PkgConfig::WEBKIT) +target_compile_options(download_policy_test PRIVATE -Wall -Wextra -Werror + -Wno-unused-parameter) + +enable_testing() +add_test(NAME download_policy COMMAND download_policy_test) +set_tests_properties(download_policy PROPERTIES TIMEOUT 60) diff --git a/webview_all_linux/linux/test/download_policy_test.cc b/webview_all_linux/linux/test/download_policy_test.cc new file mode 100644 index 0000000..69fe320 --- /dev/null +++ b/webview_all_linux/linux/test/download_policy_test.cc @@ -0,0 +1,176 @@ +#include "webview/download_policy.h" + +#include +#include + +#include +#include + +namespace { + +struct DownloadResult { + GMainLoop *loop; + std::string destination; + bool destination_requested = false; + bool cancelled = false; + bool finished = false; + bool timed_out = false; +}; + +class Fixture { +public: + Fixture() { + GError *error = nullptr; + directory_ = g_dir_make_tmp("webview-all-download-test-XXXXXX", &error); + g_assert_no_error(error); + server_ = soup_server_new(nullptr, nullptr); + soup_server_add_handler( + server_, nullptr, + [](SoupServer *, SoupServerMessage *message, const char *, GHashTable *, + gpointer) { + soup_server_message_set_status(message, SOUP_STATUS_OK, nullptr); + soup_message_headers_append( + soup_server_message_get_response_headers(message), + "Content-Disposition", "attachment; filename=test.txt"); + soup_server_message_set_response( + message, "text/plain", SOUP_MEMORY_STATIC, "download test", 13); + }, + nullptr, nullptr); + soup_server_listen_local(server_, 0, SOUP_SERVER_LISTEN_IPV4_ONLY, &error); + g_assert_no_error(error); + GSList *uris = soup_server_get_uris(server_); + gchar *uri = g_uri_to_string(static_cast(uris->data)); + url_ = uri; + g_free(uri); + g_slist_free_full(uris, reinterpret_cast(g_uri_unref)); + context_ = webkit_web_context_new_ephemeral(); + first_ = WEBKIT_WEB_VIEW(webkit_web_view_new_with_context(context_)); + second_ = WEBKIT_WEB_VIEW(webkit_web_view_new_with_context(context_)); + g_object_ref_sink(first_); + g_object_ref_sink(second_); + first_policy_ = std::make_unique(first_); + second_policy_ = std::make_unique(second_); + } + + ~Fixture() { + first_policy_.reset(); + second_policy_.reset(); + gtk_widget_destroy(GTK_WIDGET(first_)); + gtk_widget_destroy(GTK_WIDGET(second_)); + g_object_unref(first_); + g_object_unref(second_); + g_object_unref(context_); + soup_server_disconnect(server_); + g_object_unref(server_); + g_assert_cmpint(g_rmdir(directory_), ==, 0); + g_free(directory_); + } + + void Check(WebKitWebView *view, bool allowed) { + const std::string path = std::string(directory_) + "/download.txt"; + gchar *destination = g_filename_to_uri(path.c_str(), nullptr, nullptr); + DownloadResult result{g_main_loop_new(nullptr, FALSE), destination}; + g_free(destination); + WebKitDownload *download = + view != nullptr + ? webkit_web_view_download_uri(view, url_.c_str()) + : webkit_web_context_download_uri(context_, url_.c_str()); + g_signal_connect(download, "decide-destination", + G_CALLBACK(+[](WebKitDownload *download, const gchar *, + gpointer data) -> gboolean { + auto *result = static_cast(data); + result->destination_requested = true; + webkit_download_set_destination( + download, result->destination.c_str()); + return TRUE; + }), + &result); + g_signal_connect( + download, "failed", + G_CALLBACK(+[](WebKitDownload *, GError *error, gpointer data) { + auto *result = static_cast(data); + g_assert_error(error, WEBKIT_DOWNLOAD_ERROR, + WEBKIT_DOWNLOAD_ERROR_CANCELLED_BY_USER); + result->cancelled = true; + }), + &result); + g_signal_connect(download, "finished", + G_CALLBACK(+[](WebKitDownload *, gpointer data) { + auto *result = static_cast(data); + result->finished = true; + g_main_loop_quit(result->loop); + }), + &result); + const guint timeout = g_timeout_add_seconds( + 10, + +[](gpointer data) -> gboolean { + auto *result = static_cast(data); + result->timed_out = true; + g_main_loop_quit(result->loop); + return G_SOURCE_REMOVE; + }, + &result); + g_main_loop_run(result.loop); + g_assert_false(result.timed_out); + g_source_remove(timeout); + g_assert_true(result.finished); + g_assert_cmpint(result.cancelled, ==, !allowed); + g_assert_cmpint(result.destination_requested, ==, allowed); + g_assert_cmpint(g_file_test(path.c_str(), G_FILE_TEST_EXISTS), ==, allowed); + if (allowed) { + gchar *contents = nullptr; + gsize length = 0; + g_assert_true( + g_file_get_contents(path.c_str(), &contents, &length, nullptr)); + g_assert_cmpstr(contents, ==, "download test"); + g_free(contents); + g_assert_cmpint(g_remove(path.c_str()), ==, 0); + } + g_object_unref(download); + g_main_loop_unref(result.loop); + } + + WebKitWebView *first_; + WebKitWebView *second_; + std::unique_ptr first_policy_; + std::unique_ptr second_policy_; + +private: + WebKitWebContext *context_; + SoupServer *server_; + gchar *directory_; + std::string url_; +}; + +void TestScopeAndRuntimeChanges() { + Fixture fixture; + fixture.Check(fixture.first_, true); + fixture.first_policy_->SetEnabled(false); + fixture.Check(fixture.first_, false); + fixture.Check(fixture.second_, true); + fixture.first_policy_->SetEnabled(true); + fixture.Check(fixture.first_, true); + fixture.first_policy_->SetEnabled(false); + fixture.Check(fixture.first_, false); +} + +void TestCleanupAndContextDownloads() { + Fixture fixture; + fixture.first_policy_->SetEnabled(false); + fixture.second_policy_->SetEnabled(false); + fixture.Check(nullptr, true); + fixture.first_policy_.reset(); + fixture.Check(fixture.first_, true); + fixture.Check(fixture.second_, false); +} + +} // namespace + +int main(int argc, char **argv) { + gtk_test_init(&argc, &argv, nullptr); + g_test_add_func("/downloads/scope-and-runtime-changes", + TestScopeAndRuntimeChanges); + g_test_add_func("/downloads/cleanup-and-context-downloads", + TestCleanupAndContextDownloads); + return g_test_run(); +} From b9490ce7e4ccf06d39b2ceabf429b6788185ba1a Mon Sep 17 00:00:00 2001 From: moluopro Date: Fri, 25 Sep 2026 21:50:08 +0800 Subject: [PATCH 10/17] fix(web): preserve custom sandbox restrictions when disabling scripts --- .../lib/src/web_webview_controller.dart | 12 +++++- .../test/web_webview_controller_test.dart | 43 +++++++++++++++---- 2 files changed, 44 insertions(+), 11 deletions(-) diff --git a/webview_all_web/lib/src/web_webview_controller.dart b/webview_all_web/lib/src/web_webview_controller.dart index 483446a..f8f062e 100644 --- a/webview_all_web/lib/src/web_webview_controller.dart +++ b/webview_all_web/lib/src/web_webview_controller.dart @@ -136,7 +136,8 @@ class WebWebViewControllerCreationParams /// Value for the iframe `allow` attribute. final String? iFrameAllow; - /// Value for the iframe `sandbox` attribute while JavaScript is unrestricted. + /// Permissions for the iframe `sandbox` attribute. Disabling JavaScript + /// removes `allow-scripts` without adding permissions to an explicit sandbox. final String? iFrameSandbox; /// Value for the iframe `referrerpolicy` attribute. @@ -865,7 +866,14 @@ class WebWebViewController extends PlatformWebViewController { if (_javaScriptMode == JavaScriptMode.disabled) { _webWebViewParams.iFrame.setAttribute( 'sandbox', - _javaScriptDisabledSandbox, + _customSandbox == null + ? _javaScriptDisabledSandbox + : _customSandbox! + .split(RegExp(r'[\t\n\f\r ]+')) + .where( + (String token) => token.toLowerCase() != 'allow-scripts', + ) + .join(' '), ); } else { _setIFrameAttribute(_webWebViewParams.iFrame, 'sandbox', _customSandbox); diff --git a/webview_all_web/test/web_webview_controller_test.dart b/webview_all_web/test/web_webview_controller_test.dart index f687cb8..9ce0ee3 100644 --- a/webview_all_web/test/web_webview_controller_test.dart +++ b/webview_all_web/test/web_webview_controller_test.dart @@ -1299,18 +1299,11 @@ void main() { await controller.setJavaScriptMode(JavaScriptMode.disabled); - expect(params.iFrame.getAttribute('sandbox'), isNotNull); - expect( - params.iFrame.getAttribute('sandbox'), - isNot(contains('allow-scripts')), - ); + expect(params.iFrame.getAttribute('sandbox'), 'allow-forms'); await controller.setIFrameSandbox('allow-popups'); - expect( - params.iFrame.getAttribute('sandbox'), - isNot(contains('allow-scripts')), - ); + expect(params.iFrame.getAttribute('sandbox'), 'allow-popups'); await controller.setJavaScriptMode(JavaScriptMode.unrestricted); @@ -1322,6 +1315,38 @@ void main() { }, ); + test( + 'keeps explicit sandbox permissions when scripts are disabled', + () async { + final params = WebWebViewControllerCreationParams(iFrameSandbox: ''); + final controller = WebWebViewController(params); + await controller.setJavaScriptMode(JavaScriptMode.disabled); + expect(params.iFrame.getAttribute('sandbox'), ''); + + await controller.setIFrameSandbox( + 'ALLOW-SCRIPTS\tallow-downloads\nallow-forms', + ); + expect( + params.iFrame.getAttribute('sandbox'), + 'allow-downloads allow-forms', + ); + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + expect( + params.iFrame.getAttribute('sandbox'), + 'ALLOW-SCRIPTS\tallow-downloads\nallow-forms', + ); + + await controller.setJavaScriptMode(JavaScriptMode.disabled); + await controller.setIFrameSandbox(null); + expect( + params.iFrame.getAttribute('sandbox'), + isNot(contains('allow-scripts')), + ); + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + expect(params.iFrame.hasAttribute('sandbox'), isFalse); + }, + ); + test('sets and removes custom iframe attributes at runtime', () async { final params = WebWebViewControllerCreationParams(); final controller = WebWebViewController(params); From dead5de2c9ff50f494c5174e4e553f6e412c5d64 Mon Sep 17 00:00:00 2001 From: moluopro Date: Fri, 25 Sep 2026 21:50:08 +0800 Subject: [PATCH 11/17] docs(windows): explain opt-in DevTools and WebView control semantics --- docs/src/content/docs/guides/debugging.md | 16 ++++++++++--- docs/src/content/docs/platforms/windows.md | 24 +++++++++++++++++-- docs/src/content/docs/zh/guides/debugging.md | 14 +++++++++++ docs/src/content/docs/zh/platforms/windows.md | 22 ++++++++++++++--- 4 files changed, 68 insertions(+), 8 deletions(-) diff --git a/docs/src/content/docs/guides/debugging.md b/docs/src/content/docs/guides/debugging.md index 2edbfbd..abd1f35 100644 --- a/docs/src/content/docs/guides/debugging.md +++ b/docs/src/content/docs/guides/debugging.md @@ -35,13 +35,23 @@ final windows = controller.platform as WindowsWebViewController; await windows.openDevTools(); ``` -WebView2 allows DevTools by default. Block them, including F12 and -Ctrl+Shift+I, per controller: +The plugin disables user access to DevTools by default in all build modes. +Enable the menu and keyboard entry points (including F12 and Ctrl+Shift+I) +per controller when needed: ```dart -await windows.setInspectable(false); +await windows.setDevToolsEnabled(true); ``` +Apply this setting before loading content; runtime changes take effect on the +next top-level navigation. It does not close existing DevTools or prevent the +app from calling `openDevTools()` explicitly. + +To allow user access only in debug builds, pass `devToolsEnabled: kDebugMode` +to `WindowsWebViewControllerCreationParams`. Import `kDebugMode` from +`package:flutter/foundation.dart`. Browser shortcuts must also be enabled for +the DevTools shortcuts to work. + Check runtime availability: ```dart diff --git a/docs/src/content/docs/platforms/windows.md b/docs/src/content/docs/platforms/windows.md index 3c9ba5b..14c5690 100644 --- a/docs/src/content/docs/platforms/windows.md +++ b/docs/src/content/docs/platforms/windows.md @@ -82,11 +82,26 @@ renderer if hardware device creation fails. ```dart final params = const WindowsWebViewControllerCreationParams( popupWindowPolicy: WindowsPopupWindowPolicy.sameWindow, + devToolsEnabled: false, + browserAcceleratorKeysEnabled: false, + downloadsEnabled: false, ); final controller = WebViewController.fromPlatformCreationParams(params); ``` +DevTools user access is disabled by default in all build modes. Set +`devToolsEnabled: true` to enable it, or use `kDebugMode` from +`package:flutter/foundation.dart` to enable it only in debug builds. +Browser shortcuts and downloads keep their native defaults (enabled) when +omitted. The DevTools default and explicit settings are applied before the first navigation and retained +when initialization is retried. A failed setting is reported to the caller; +navigation remains blocked until the setting is successfully applied or changed. +Disabling browser shortcuts requires `ICoreWebView2Settings3`; disabling downloads +requires a successfully registered `ICoreWebView2_4` download handler. Unsupported +restrictions report `not_supported`; native failures report `method_failed` with +the setting name and HRESULT. + `WindowsPopupWindowPolicy`: | Value | Behavior | @@ -119,13 +134,18 @@ final widget = WebViewWidget.fromPlatformCreationParams( | `setPopupWindowPolicy(policy)` | Changes popup handling after creation. | | `setZoomFactor(double zoomFactor)` | Sets WebView2 zoom factor. | | `setCacheDisabled(bool disabled)` | Toggles cache bypass behavior. | -| `setInspectable(bool inspectable)` | Allows or blocks DevTools (F12, Ctrl+Shift+I, `openDevTools()`). Allowed by default. | +| `setDevToolsEnabled(bool enabled)` | Controls the menu and keyboard shortcuts for opening DevTools. Disabled by default; does not block `openDevTools()`. | | `setBrowserAcceleratorKeysEnabled(bool enabled)` | Enables or disables browser accelerator keys such as F5, Ctrl+P, and Ctrl+F. Enabled by default. | -| `setDownloadsEnabled(bool enabled)` | Allows downloads or cancels them as they start, before anything is written to disk. Allowed by default. | +| `setDownloadsEnabled(bool enabled)` | Allows new downloads or cancels them without saving the downloaded file. Enabled by default; ongoing downloads are unchanged. | | `dispose()` | Permanently releases this controller and its WebView2 resources. | Common APIs implemented on Windows include request loading with method, headers, and body; JavaScript execution; JavaScript channels; console messages; JavaScript dialogs; permission requests; HTTP errors; HTTP auth; SSL auth; scroll position; scrollbars; background color; user agent override; and overscroll styling. +DevTools and browser-shortcut changes take effect on the next top-level +navigation. Editing shortcuts such as copy and paste remain available; disabling +browser shortcuts does not forward them to Flutter. These switches control +WebView behavior and do not constitute a sandbox for untrusted content. + `onNavigationRequest` covers controller loads and WebView2 main-frame navigations initiated by page content, including redirects and popups opened with `sameWindow`. Controller loads are approved before native dispatch so custom methods, headers, and bodies are preserved. Page-initiated navigations wait for the asynchronous Dart policy through cancel-and-replay, and the intentional cancellation is suppressed from `onWebResourceError`. Local files and Flutter assets use private randomized HTTPS hosts per controller. Paths are canonicalized, asset traversal and symlink escapes are rejected, cross-origin access is denied, and mappings are cleared before unrelated remote or inline navigation. diff --git a/docs/src/content/docs/zh/guides/debugging.md b/docs/src/content/docs/zh/guides/debugging.md index 24ce9af..f40b56b 100644 --- a/docs/src/content/docs/zh/guides/debugging.md +++ b/docs/src/content/docs/zh/guides/debugging.md @@ -31,6 +31,20 @@ final version = await WindowsWebViewController.getWebViewVersion(); `getWebViewVersion()` 可用于启动时确认 WebView2 Runtime 是否存在。 +插件在所有构建模式下默认关闭 DevTools 的用户入口,需要时可按 controller +开启菜单和快捷键入口(包括 F12 和 Ctrl+Shift+I): + +```dart +await windows.setDevToolsEnabled(true); +``` + +建议在加载内容前设置;运行时修改会在下次顶层导航时生效。该开关不会关闭已经 +打开的 DevTools,也不影响应用主动调用 `openDevTools()`。 + +如需仅在调试构建中开放用户入口,可在 `WindowsWebViewControllerCreationParams` +中设置 `devToolsEnabled: kDebugMode`,并从 `package:flutter/foundation.dart` +导入 `kDebugMode`。使用 DevTools 快捷键还需要保持浏览器快捷键启用。 + ## Linux ```dart diff --git a/docs/src/content/docs/zh/platforms/windows.md b/docs/src/content/docs/zh/platforms/windows.md index bd6843e..65256b4 100644 --- a/docs/src/content/docs/zh/platforms/windows.md +++ b/docs/src/content/docs/zh/platforms/windows.md @@ -58,14 +58,26 @@ controller 初始化失败时,组件中心会显示错误和恢复操作: 帧捕获及其回调统一使用该 UI 线程队列,Direct3D 硬件设备创建失败时会回退到 WARP 软件渲染器。 -## Popup 策略 +## 创建参数 ```dart final params = const WindowsWebViewControllerCreationParams( popupWindowPolicy: WindowsPopupWindowPolicy.sameWindow, + devToolsEnabled: false, + browserAcceleratorKeysEnabled: false, + downloadsEnabled: false, ); ``` +DevTools 用户入口在所有构建模式下默认关闭,可设置 `devToolsEnabled: true` +开启;如需仅在调试构建中开启,可使用 `package:flutter/foundation.dart` 的 +`kDebugMode`。浏览器快捷键和下载省略配置时保持原生默认值(启用)。 +DevTools 默认配置及显式配置均会在首次导航前应用,初始化 +重试时也会保留。设置失败会返回错误,后续导航需等待该配置成功应用或被修改。 +禁用浏览器快捷键需要 `ICoreWebView2Settings3`,禁用下载需要成功注册 +`ICoreWebView2_4` 下载监听。不支持的限制返回 `not_supported`;原生调用失败 +返回 `method_failed`,错误详情包含设置名称和 HRESULT。 + | 值 | 行为 | | --- | --- | | `allow` | 允许 popup 新窗口。 | @@ -82,11 +94,15 @@ final params = const WindowsWebViewControllerCreationParams( | `setPopupWindowPolicy` | 运行时修改 popup 策略。 | | `setZoomFactor` | 设置 WebView2 缩放因子。 | | `setCacheDisabled` | 控制请求是否绕过 cache。 | -| `setInspectable` | 允许或禁止 DevTools(F12、Ctrl+Shift+I、`openDevTools`)。默认允许。 | +| `setDevToolsEnabled` | 控制通过菜单和快捷键打开 DevTools。默认关闭,不影响 `openDevTools()`。 | | `setBrowserAcceleratorKeysEnabled` | 启用或禁用浏览器快捷键,例如 F5、Ctrl+P 和 Ctrl+F。默认启用。 | -| `setDownloadsEnabled` | 允许下载,或在下载开始时取消,不会写入磁盘。默认允许。 | +| `setDownloadsEnabled` | 允许新下载,或在开始时取消并阻止保存下载文件。默认允许,不影响已开始的下载。 | | `dispose` | 永久释放此 controller 及其 WebView2 资源。 | +开发者工具和浏览器快捷键设置会在下次顶层导航时生效。复制、粘贴等编辑快捷键 +不受影响;禁用浏览器快捷键也不会自动将按键交给 Flutter。这些开关用于控制 +WebView 行为,不是隔离不可信内容的完整安全沙箱。 + `onNavigationRequest` 会覆盖 controller 加载以及页面内容触发的 WebView2 主 frame 导航,包括 redirect 和 `sameWindow` popup。controller 请求在原生分发前完成判断,因此会保留自定义 method、headers 和 body;页面导航通过取消后等待异步 Dart 决策、放行后重放来实现,策略性取消不会触发 `onWebResourceError`。 本地文件和 Flutter asset 会使用每个 controller 独立的随机 HTTPS host。路径会先规范化,asset 路径穿越和逃出 bundle 的符号链接会被拒绝,跨 origin 访问默认禁止,切换到无关的远程或 inline 内容前会清理映射。 From cdb1b2d316117687a8b0bf3f53e5fc3838c76f9c Mon Sep 17 00:00:00 2001 From: moluopro Date: Fri, 25 Sep 2026 21:50:20 +0800 Subject: [PATCH 12/17] docs(linux): document per-WebView download controls --- docs/src/content/docs/platforms/linux.md | 7 +++++++ docs/src/content/docs/zh/platforms/linux.md | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/docs/src/content/docs/platforms/linux.md b/docs/src/content/docs/platforms/linux.md index e6dcc94..e465ef9 100644 --- a/docs/src/content/docs/platforms/linux.md +++ b/docs/src/content/docs/platforms/linux.md @@ -29,6 +29,7 @@ runner realizes `FlView`; applications do not need to modify their runner. ```dart final params = const LinuxWebViewControllerCreationParams( developerExtrasEnabled: true, + downloadsEnabled: false, javascriptCanOpenWindowsAutomatically: true, mediaPlaybackRequiresUserGesture: false, mediaPlaybackAllowsInline: true, @@ -45,12 +46,18 @@ final params = const LinuxWebViewControllerCreationParams( Each field is nullable. `null` leaves the WebKitGTK default unchanged. +Downloads are allowed by default. Set `downloadsEnabled: false` at creation, or +await `setDownloadsEnabled(false)` before loading content, to cancel new +downloads before a file is saved. Ongoing downloads and other WebViews are not +affected. Allowed downloads use WebKitGTK's default download destination. + ## Controller API | API | Purpose | | --- | --- | | `setDeveloperExtrasEnabled(bool enabled)` | Enables WebKitGTK developer extras. | | `openDevTools()` | Opens the Web Inspector. | +| `setDownloadsEnabled(bool enabled)` | Allows or cancels new downloads for this WebView. Enabled by default. | | `setJavaScriptCanOpenWindowsAutomatically(bool enabled)` | Controls JavaScript popups. | | `setMediaPlaybackRequiresUserGesture(bool require)` | Controls media autoplay policy. | | `setMediaPlaybackAllowsInline(bool allow)` | Controls inline media playback. | diff --git a/docs/src/content/docs/zh/platforms/linux.md b/docs/src/content/docs/zh/platforms/linux.md index e009992..46a4298 100644 --- a/docs/src/content/docs/zh/platforms/linux.md +++ b/docs/src/content/docs/zh/platforms/linux.md @@ -24,6 +24,7 @@ Linux WebView 是 native GTK widget。插件会在标准 Flutter runner realize ```dart final params = const LinuxWebViewControllerCreationParams( developerExtrasEnabled: true, + downloadsEnabled: false, mediaPlaybackRequiresUserGesture: false, pageCacheEnabled: true, allowFileAccessFromFileUrls: false, @@ -34,12 +35,17 @@ final params = const LinuxWebViewControllerCreationParams( 所有字段都可为 `null`,表示保留 WebKitGTK 默认值。 +下载默认允许。可在创建时设置 `downloadsEnabled: false`,或在加载内容前等待 +`setDownloadsEnabled(false)` 完成,取消新下载并阻止保存文件。已开始的下载和 +其他 WebView 不受影响。允许下载时沿用 WebKitGTK 的默认保存位置。 + ## 主要 API | API | 作用 | | --- | --- | | `setDeveloperExtrasEnabled` | 启用开发者功能。 | | `openDevTools` | 打开 Web Inspector。 | +| `setDownloadsEnabled` | 控制此 WebView 是否允许新下载,默认允许。 | | `setJavaScriptCanOpenWindowsAutomatically` | 控制 JS popup。 | | `setMediaPlaybackRequiresUserGesture` | 控制媒体自动播放。 | | `setMediaPlaybackAllowsInline` | 控制内联媒体播放。 | From c299197229ee60bb5c3200e7ddfd835117c7ee8f Mon Sep 17 00:00:00 2001 From: moluopro Date: Fri, 25 Sep 2026 21:50:20 +0800 Subject: [PATCH 13/17] docs(web): clarify sandbox behavior when JavaScript is disabled --- docs/src/content/docs/platforms/web.md | 7 ++++++- docs/src/content/docs/zh/platforms/web.md | 4 ++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/docs/src/content/docs/platforms/web.md b/docs/src/content/docs/platforms/web.md index 40d7dda..a2af50d 100644 --- a/docs/src/content/docs/platforms/web.md +++ b/docs/src/content/docs/platforms/web.md @@ -35,7 +35,7 @@ final controller = WebViewController.fromPlatformCreationParams(params); | Param | Meaning | | --- | --- | | `iFrameAllow` | Sets iframe `allow`. | -| `iFrameSandbox` | Sets iframe `sandbox` when JavaScript is unrestricted. | +| `iFrameSandbox` | Sets iframe sandbox permissions. Disabling JavaScript removes `allow-scripts` without adding permissions. | | `iFrameReferrerPolicy` | Sets iframe `referrerpolicy`. | | `iFrameAttributes` | Additional iframe attributes. `null` removes an attribute. | | `httpRequestFactory` | Test hook for fetch-backed loads. | @@ -44,6 +44,11 @@ Attribute names are validated before they reach the DOM. Empty or malformed names are rejected, and `id`, `src`, and `srcdoc` are reserved because the controller owns iframe identity and document loading. +An explicit sandbox is preserved when JavaScript is disabled, including an empty +sandbox (all restrictions). Only `allow-scripts` is removed; switching back to +unrestricted mode restores the configured value. Sandbox changes apply to the +next document load, not to a document already loaded in the iframe. + ## Controller API | API | Purpose | diff --git a/docs/src/content/docs/zh/platforms/web.md b/docs/src/content/docs/zh/platforms/web.md index ee6be9d..0428059 100644 --- a/docs/src/content/docs/zh/platforms/web.md +++ b/docs/src/content/docs/zh/platforms/web.md @@ -29,6 +29,10 @@ final params = WebWebViewControllerCreationParams( iframe 属性名会在写入 DOM 前校验。空名称、非法名称以及由控制器管理的 `id`、`src`、`srcdoc` 都会被拒绝,避免破坏实例标识和加载状态。 +关闭 JavaScript 时会保留自定义 sandbox 的其他限制,仅移除 `allow-scripts`; +空字符串仍表示启用全部 sandbox 限制。恢复 JavaScript 后会还原用户配置。 +sandbox 的修改在下次文档加载时生效,不会改变已经加载的文档。 + ## 主要 API | API | 作用 | From c5b3f915eb0395b0ed6e94b853fbdc8885e95b25 Mon Sep 17 00:00:00 2001 From: moluopro Date: Fri, 25 Sep 2026 21:50:20 +0800 Subject: [PATCH 14/17] docs: list new Windows and Linux platform controls --- docs/src/content/docs/reference/platform-specific-api.md | 4 ++-- docs/src/content/docs/zh/reference/platform-specific-api.md | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/src/content/docs/reference/platform-specific-api.md b/docs/src/content/docs/reference/platform-specific-api.md index 3a2fd1e..e7a888c 100644 --- a/docs/src/content/docs/reference/platform-specific-api.md +++ b/docs/src/content/docs/reference/platform-specific-api.md @@ -35,7 +35,7 @@ Package: `webview_all_windows`. Main types: `WindowsWebViewController`, `WindowsWebViewWidget`, `WindowsNavigationDelegate`, `WindowsWebViewCookieManager`, `WindowsWebViewDataManager`, `WindowsWebViewCookie`, `WindowsPlatformSslAuthError`, `WindowsWebResourceRequest`, `WindowsWebResourceResponse`, `WindowsWebResourceError`. -Important APIs: idempotent `ensureEnvironment`, strict `initializeEnvironment`, `getWebViewVersion`, `openDevTools`, `suspend`, `resume`, `setPopupWindowPolicy`, `setZoomFactor`, `setCacheDisabled`, Windows-specific deterministic `dispose`, full cookie set/query/delete. `WindowsWebViewDataManagerCreationParams` carries the same environment options into controller-independent data cleanup. Removing the widget does not dispose a reusable controller; call `dispose` only when its owner will never use it again. +Important APIs: idempotent `ensureEnvironment`, strict `initializeEnvironment`, `getWebViewVersion`, `openDevTools`, `setDevToolsEnabled`, `setBrowserAcceleratorKeysEnabled`, `setDownloadsEnabled`, `suspend`, `resume`, `setPopupWindowPolicy`, `setZoomFactor`, `setCacheDisabled`, Windows-specific deterministic `dispose`, full cookie set/query/delete. `WindowsWebViewDataManagerCreationParams` carries the same environment options into controller-independent data cleanup. Removing the widget does not dispose a reusable controller; call `dispose` only when its owner will never use it again. ## Linux @@ -43,7 +43,7 @@ Package: `webview_all_linux`. Main types: `LinuxWebViewController`, `LinuxWebViewWidget`, `LinuxNavigationDelegate`, `LinuxWebViewCookieManager`, `LinuxWebViewDataManager`, `LinuxWebResourceRequest`, `LinuxWebResourceResponse`, `LinuxWebResourceError`, `LinuxPlatformWebViewPermissionRequest`, `LinuxPlatformSslAuthError`. -Important APIs: WebKitGTK developer extras, Inspector, JS popup, media settings, page cache, file URL access, font size, zoom factor, and the pre-existing Linux-specific `dispose()` for optional early release. Normal cleanup is automatic through its finalizer; no common controller lifecycle API is added. +Important APIs: WebKitGTK developer extras, Inspector, `setDownloadsEnabled`, JS popup, media settings, page cache, file URL access, font size, zoom factor, and the pre-existing Linux-specific `dispose()` for optional early release. Normal cleanup is automatic through its finalizer; no common controller lifecycle API is added. ## OHOS diff --git a/docs/src/content/docs/zh/reference/platform-specific-api.md b/docs/src/content/docs/zh/reference/platform-specific-api.md index c69df10..7155d17 100644 --- a/docs/src/content/docs/zh/reference/platform-specific-api.md +++ b/docs/src/content/docs/zh/reference/platform-specific-api.md @@ -35,7 +35,7 @@ if (controller.platform is WindowsWebViewController) { 主要类型:`WindowsWebViewController`、`WindowsWebViewWidget`、`WindowsNavigationDelegate`、`WindowsWebViewCookieManager`、`WindowsWebViewDataManager`、`WindowsWebViewCookie`、`WindowsPlatformSslAuthError`、`WindowsWebResourceRequest`、`WindowsWebResourceResponse`、`WindowsWebResourceError`。 -重要 API:幂等的 `ensureEnvironment`、严格的 `initializeEnvironment`、`getWebViewVersion`、`openDevTools`、`suspend`、`resume`、`setPopupWindowPolicy`、`setZoomFactor`、`setCacheDisabled`、Windows 专属的确定性 `dispose`、完整 cookie 设置/查询/删除。`WindowsWebViewDataManagerCreationParams` 可把同一套环境参数传给无需 controller 的数据清理。移除组件不会销毁仍可复用的 controller;仅在其所有者确认不再使用时调用 `dispose`。 +重要 API:幂等的 `ensureEnvironment`、严格的 `initializeEnvironment`、`getWebViewVersion`、`openDevTools`、`setDevToolsEnabled`、`setBrowserAcceleratorKeysEnabled`、`setDownloadsEnabled`、`suspend`、`resume`、`setPopupWindowPolicy`、`setZoomFactor`、`setCacheDisabled`、Windows 专属的确定性 `dispose`、完整 cookie 设置/查询/删除。`WindowsWebViewDataManagerCreationParams` 可把同一套环境参数传给无需 controller 的数据清理。移除组件不会销毁仍可复用的 controller;仅在其所有者确认不再使用时调用 `dispose`。 ## Linux @@ -43,7 +43,7 @@ if (controller.platform is WindowsWebViewController) { 主要类型:`LinuxWebViewController`、`LinuxWebViewWidget`、`LinuxNavigationDelegate`、`LinuxWebViewCookieManager`、`LinuxWebViewDataManager`、`LinuxWebResourceRequest`、`LinuxWebResourceResponse`、`LinuxWebResourceError`、`LinuxPlatformWebViewPermissionRequest`、`LinuxPlatformSslAuthError`。 -重要 API:WebKitGTK developer extras、Inspector、JS popup、media settings、page cache、file URL access、font size、zoom factor,以及可选提前释放资源的既有 Linux 专属 `dispose()`。正常生命周期由 finalizer 自动清理,没有给公共 controller 增加生命周期 API。 +重要 API:WebKitGTK developer extras、Inspector、`setDownloadsEnabled`、JS popup、media settings、page cache、file URL access、font size、zoom factor,以及可选提前释放资源的既有 Linux 专属 `dispose()`。正常生命周期由 finalizer 自动清理,没有给公共 controller 增加生命周期 API。 ## OHOS From 8decf82389ec3599f594903296929285e63f68f8 Mon Sep 17 00:00:00 2001 From: moluopro Date: Fri, 25 Sep 2026 21:50:20 +0800 Subject: [PATCH 15/17] docs: record version 1.4.2 changes across package changelogs --- webview_all/CHANGELOG-ZH.md | 7 +++++++ webview_all/CHANGELOG.md | 7 +++++++ webview_all_android/CHANGELOG-ZH.md | 7 +++++++ webview_all_android/CHANGELOG.md | 7 +++++++ webview_all_linux/CHANGELOG-ZH.md | 7 +++++++ webview_all_linux/CHANGELOG.md | 7 +++++++ webview_all_ohos/CHANGELOG-ZH.md | 7 +++++++ webview_all_ohos/CHANGELOG.md | 7 +++++++ webview_all_web/CHANGELOG-ZH.md | 7 +++++++ webview_all_web/CHANGELOG.md | 7 +++++++ webview_all_windows/CHANGELOG-ZH.md | 7 +++++++ webview_all_windows/CHANGELOG.md | 7 +++++++ webview_all_wkwebview/CHANGELOG-ZH.md | 7 +++++++ webview_all_wkwebview/CHANGELOG.md | 7 +++++++ webview_platform_interface/CHANGELOG-ZH.md | 7 +++++++ webview_platform_interface/CHANGELOG.md | 7 +++++++ 16 files changed, 112 insertions(+) diff --git a/webview_all/CHANGELOG-ZH.md b/webview_all/CHANGELOG-ZH.md index 12a3f60..f361e91 100644 --- a/webview_all/CHANGELOG-ZH.md +++ b/webview_all/CHANGELOG-ZH.md @@ -1,3 +1,10 @@ +## 1.4.2 + +* Windows 新增开发者工具、浏览器快捷键和下载开关,支持创建时配置及运行时调整。 +* Windows 默认关闭开发者工具的菜单和快捷键入口,可按需开启。 +* Linux 新增下载开关,可按 WebView 单独控制是否允许下载。 +* 修复 Web 关闭 JavaScript 时覆盖自定义 iframe sandbox 限制的问题。 + ## 1.4.1 * 修复 Windows 应用退出时的崩溃。 diff --git a/webview_all/CHANGELOG.md b/webview_all/CHANGELOG.md index dbead51..cfe6648 100644 --- a/webview_all/CHANGELOG.md +++ b/webview_all/CHANGELOG.md @@ -1,3 +1,10 @@ +## 1.4.2 + +* Add Windows controls for DevTools, browser shortcuts, and downloads, configurable at creation and at runtime. +* Disable Windows DevTools menu and keyboard entry points by default; enable them explicitly when needed. +* Add per-WebView download controls on Linux. +* Preserve custom iframe sandbox restrictions when disabling JavaScript on Web. + ## 1.4.1 * Fix a crash when exiting Windows applications. diff --git a/webview_all_android/CHANGELOG-ZH.md b/webview_all_android/CHANGELOG-ZH.md index 12a3f60..f361e91 100644 --- a/webview_all_android/CHANGELOG-ZH.md +++ b/webview_all_android/CHANGELOG-ZH.md @@ -1,3 +1,10 @@ +## 1.4.2 + +* Windows 新增开发者工具、浏览器快捷键和下载开关,支持创建时配置及运行时调整。 +* Windows 默认关闭开发者工具的菜单和快捷键入口,可按需开启。 +* Linux 新增下载开关,可按 WebView 单独控制是否允许下载。 +* 修复 Web 关闭 JavaScript 时覆盖自定义 iframe sandbox 限制的问题。 + ## 1.4.1 * 修复 Windows 应用退出时的崩溃。 diff --git a/webview_all_android/CHANGELOG.md b/webview_all_android/CHANGELOG.md index dbead51..cfe6648 100644 --- a/webview_all_android/CHANGELOG.md +++ b/webview_all_android/CHANGELOG.md @@ -1,3 +1,10 @@ +## 1.4.2 + +* Add Windows controls for DevTools, browser shortcuts, and downloads, configurable at creation and at runtime. +* Disable Windows DevTools menu and keyboard entry points by default; enable them explicitly when needed. +* Add per-WebView download controls on Linux. +* Preserve custom iframe sandbox restrictions when disabling JavaScript on Web. + ## 1.4.1 * Fix a crash when exiting Windows applications. diff --git a/webview_all_linux/CHANGELOG-ZH.md b/webview_all_linux/CHANGELOG-ZH.md index 12a3f60..f361e91 100644 --- a/webview_all_linux/CHANGELOG-ZH.md +++ b/webview_all_linux/CHANGELOG-ZH.md @@ -1,3 +1,10 @@ +## 1.4.2 + +* Windows 新增开发者工具、浏览器快捷键和下载开关,支持创建时配置及运行时调整。 +* Windows 默认关闭开发者工具的菜单和快捷键入口,可按需开启。 +* Linux 新增下载开关,可按 WebView 单独控制是否允许下载。 +* 修复 Web 关闭 JavaScript 时覆盖自定义 iframe sandbox 限制的问题。 + ## 1.4.1 * 修复 Windows 应用退出时的崩溃。 diff --git a/webview_all_linux/CHANGELOG.md b/webview_all_linux/CHANGELOG.md index dbead51..cfe6648 100644 --- a/webview_all_linux/CHANGELOG.md +++ b/webview_all_linux/CHANGELOG.md @@ -1,3 +1,10 @@ +## 1.4.2 + +* Add Windows controls for DevTools, browser shortcuts, and downloads, configurable at creation and at runtime. +* Disable Windows DevTools menu and keyboard entry points by default; enable them explicitly when needed. +* Add per-WebView download controls on Linux. +* Preserve custom iframe sandbox restrictions when disabling JavaScript on Web. + ## 1.4.1 * Fix a crash when exiting Windows applications. diff --git a/webview_all_ohos/CHANGELOG-ZH.md b/webview_all_ohos/CHANGELOG-ZH.md index 12a3f60..f361e91 100644 --- a/webview_all_ohos/CHANGELOG-ZH.md +++ b/webview_all_ohos/CHANGELOG-ZH.md @@ -1,3 +1,10 @@ +## 1.4.2 + +* Windows 新增开发者工具、浏览器快捷键和下载开关,支持创建时配置及运行时调整。 +* Windows 默认关闭开发者工具的菜单和快捷键入口,可按需开启。 +* Linux 新增下载开关,可按 WebView 单独控制是否允许下载。 +* 修复 Web 关闭 JavaScript 时覆盖自定义 iframe sandbox 限制的问题。 + ## 1.4.1 * 修复 Windows 应用退出时的崩溃。 diff --git a/webview_all_ohos/CHANGELOG.md b/webview_all_ohos/CHANGELOG.md index dbead51..cfe6648 100644 --- a/webview_all_ohos/CHANGELOG.md +++ b/webview_all_ohos/CHANGELOG.md @@ -1,3 +1,10 @@ +## 1.4.2 + +* Add Windows controls for DevTools, browser shortcuts, and downloads, configurable at creation and at runtime. +* Disable Windows DevTools menu and keyboard entry points by default; enable them explicitly when needed. +* Add per-WebView download controls on Linux. +* Preserve custom iframe sandbox restrictions when disabling JavaScript on Web. + ## 1.4.1 * Fix a crash when exiting Windows applications. diff --git a/webview_all_web/CHANGELOG-ZH.md b/webview_all_web/CHANGELOG-ZH.md index 12a3f60..f361e91 100644 --- a/webview_all_web/CHANGELOG-ZH.md +++ b/webview_all_web/CHANGELOG-ZH.md @@ -1,3 +1,10 @@ +## 1.4.2 + +* Windows 新增开发者工具、浏览器快捷键和下载开关,支持创建时配置及运行时调整。 +* Windows 默认关闭开发者工具的菜单和快捷键入口,可按需开启。 +* Linux 新增下载开关,可按 WebView 单独控制是否允许下载。 +* 修复 Web 关闭 JavaScript 时覆盖自定义 iframe sandbox 限制的问题。 + ## 1.4.1 * 修复 Windows 应用退出时的崩溃。 diff --git a/webview_all_web/CHANGELOG.md b/webview_all_web/CHANGELOG.md index dbead51..cfe6648 100644 --- a/webview_all_web/CHANGELOG.md +++ b/webview_all_web/CHANGELOG.md @@ -1,3 +1,10 @@ +## 1.4.2 + +* Add Windows controls for DevTools, browser shortcuts, and downloads, configurable at creation and at runtime. +* Disable Windows DevTools menu and keyboard entry points by default; enable them explicitly when needed. +* Add per-WebView download controls on Linux. +* Preserve custom iframe sandbox restrictions when disabling JavaScript on Web. + ## 1.4.1 * Fix a crash when exiting Windows applications. diff --git a/webview_all_windows/CHANGELOG-ZH.md b/webview_all_windows/CHANGELOG-ZH.md index 12a3f60..f361e91 100644 --- a/webview_all_windows/CHANGELOG-ZH.md +++ b/webview_all_windows/CHANGELOG-ZH.md @@ -1,3 +1,10 @@ +## 1.4.2 + +* Windows 新增开发者工具、浏览器快捷键和下载开关,支持创建时配置及运行时调整。 +* Windows 默认关闭开发者工具的菜单和快捷键入口,可按需开启。 +* Linux 新增下载开关,可按 WebView 单独控制是否允许下载。 +* 修复 Web 关闭 JavaScript 时覆盖自定义 iframe sandbox 限制的问题。 + ## 1.4.1 * 修复 Windows 应用退出时的崩溃。 diff --git a/webview_all_windows/CHANGELOG.md b/webview_all_windows/CHANGELOG.md index dbead51..cfe6648 100644 --- a/webview_all_windows/CHANGELOG.md +++ b/webview_all_windows/CHANGELOG.md @@ -1,3 +1,10 @@ +## 1.4.2 + +* Add Windows controls for DevTools, browser shortcuts, and downloads, configurable at creation and at runtime. +* Disable Windows DevTools menu and keyboard entry points by default; enable them explicitly when needed. +* Add per-WebView download controls on Linux. +* Preserve custom iframe sandbox restrictions when disabling JavaScript on Web. + ## 1.4.1 * Fix a crash when exiting Windows applications. diff --git a/webview_all_wkwebview/CHANGELOG-ZH.md b/webview_all_wkwebview/CHANGELOG-ZH.md index 12a3f60..f361e91 100644 --- a/webview_all_wkwebview/CHANGELOG-ZH.md +++ b/webview_all_wkwebview/CHANGELOG-ZH.md @@ -1,3 +1,10 @@ +## 1.4.2 + +* Windows 新增开发者工具、浏览器快捷键和下载开关,支持创建时配置及运行时调整。 +* Windows 默认关闭开发者工具的菜单和快捷键入口,可按需开启。 +* Linux 新增下载开关,可按 WebView 单独控制是否允许下载。 +* 修复 Web 关闭 JavaScript 时覆盖自定义 iframe sandbox 限制的问题。 + ## 1.4.1 * 修复 Windows 应用退出时的崩溃。 diff --git a/webview_all_wkwebview/CHANGELOG.md b/webview_all_wkwebview/CHANGELOG.md index dbead51..cfe6648 100644 --- a/webview_all_wkwebview/CHANGELOG.md +++ b/webview_all_wkwebview/CHANGELOG.md @@ -1,3 +1,10 @@ +## 1.4.2 + +* Add Windows controls for DevTools, browser shortcuts, and downloads, configurable at creation and at runtime. +* Disable Windows DevTools menu and keyboard entry points by default; enable them explicitly when needed. +* Add per-WebView download controls on Linux. +* Preserve custom iframe sandbox restrictions when disabling JavaScript on Web. + ## 1.4.1 * Fix a crash when exiting Windows applications. diff --git a/webview_platform_interface/CHANGELOG-ZH.md b/webview_platform_interface/CHANGELOG-ZH.md index 12a3f60..f361e91 100644 --- a/webview_platform_interface/CHANGELOG-ZH.md +++ b/webview_platform_interface/CHANGELOG-ZH.md @@ -1,3 +1,10 @@ +## 1.4.2 + +* Windows 新增开发者工具、浏览器快捷键和下载开关,支持创建时配置及运行时调整。 +* Windows 默认关闭开发者工具的菜单和快捷键入口,可按需开启。 +* Linux 新增下载开关,可按 WebView 单独控制是否允许下载。 +* 修复 Web 关闭 JavaScript 时覆盖自定义 iframe sandbox 限制的问题。 + ## 1.4.1 * 修复 Windows 应用退出时的崩溃。 diff --git a/webview_platform_interface/CHANGELOG.md b/webview_platform_interface/CHANGELOG.md index dbead51..cfe6648 100644 --- a/webview_platform_interface/CHANGELOG.md +++ b/webview_platform_interface/CHANGELOG.md @@ -1,3 +1,10 @@ +## 1.4.2 + +* Add Windows controls for DevTools, browser shortcuts, and downloads, configurable at creation and at runtime. +* Disable Windows DevTools menu and keyboard entry points by default; enable them explicitly when needed. +* Add per-WebView download controls on Linux. +* Preserve custom iframe sandbox restrictions when disabling JavaScript on Web. + ## 1.4.1 * Fix a crash when exiting Windows applications. From e1ca53fa72864d938dc2835ca6bcb3f3e403f773 Mon Sep 17 00:00:00 2001 From: moluopro Date: Fri, 25 Sep 2026 22:06:10 +0800 Subject: [PATCH 16/17] test(linux): isolate download tests from desktop accessibility services --- webview_all_linux/linux/test/CMakeLists.txt | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/webview_all_linux/linux/test/CMakeLists.txt b/webview_all_linux/linux/test/CMakeLists.txt index 0f9c53f..003c985 100644 --- a/webview_all_linux/linux/test/CMakeLists.txt +++ b/webview_all_linux/linux/test/CMakeLists.txt @@ -17,4 +17,8 @@ target_compile_options(download_policy_test PRIVATE -Wall -Wextra -Werror enable_testing() add_test(NAME download_policy COMMAND download_policy_test) -set_tests_properties(download_policy PROPERTIES TIMEOUT 60) +# Download policy tests do not use the desktop accessibility service. +set_tests_properties(download_policy PROPERTIES + TIMEOUT 60 + ENVIRONMENT "NO_AT_BRIDGE=1" +) From c8b6c07e15f512bafcaf64319522e0fd42160666 Mon Sep 17 00:00:00 2001 From: moluopro Date: Fri, 25 Sep 2026 22:06:10 +0800 Subject: [PATCH 17/17] test(windows): dispose controllers before clearing channel mocks --- .../test/webview_windows_test.dart | 248 ++++++++++-------- 1 file changed, 140 insertions(+), 108 deletions(-) diff --git a/webview_all_windows/test/webview_windows_test.dart b/webview_all_windows/test/webview_windows_test.dart index 5279773..9a1030f 100644 --- a/webview_all_windows/test/webview_windows_test.dart +++ b/webview_all_windows/test/webview_windows_test.dart @@ -23,8 +23,12 @@ void main() { _mockWindowsWebViewCreation(); }); - tearDown(() { - _clearWindowsWebViewCreationMock(); + tearDown(() async { + try { + await _disposeTestControllers(); + } finally { + _clearWindowsWebViewCreationMock(); + } }); test('registerWith sets the Windows WebView platform implementation', () { @@ -42,13 +46,11 @@ void main() { test('creates Windows platform implementation objects', () { final platform = WindowsWebViewPlatform(); - - expect( - platform.createPlatformWebViewController( - const PlatformWebViewControllerCreationParams(), - ), - isA(), + final controller = platform.createPlatformWebViewController( + const PlatformWebViewControllerCreationParams(), ); + expect(controller, isA()); + _controllerDisposals.add(controller.dispose); expect( platform.createPlatformNavigationDelegate( const PlatformNavigationDelegateCreationParams(), @@ -58,7 +60,7 @@ void main() { expect( platform.createPlatformWebViewWidget( PlatformWebViewWidgetCreationParams( - controller: WindowsWebViewController( + controller: _createWindowsController( const PlatformWebViewControllerCreationParams(), ), ), @@ -198,7 +200,7 @@ void main() { return 'null'; }, ); - final WindowsWebViewController controller = WindowsWebViewController( + final WindowsWebViewController controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); @@ -245,7 +247,7 @@ void main() { }, onRemoveScript: removedScripts.add, ); - final WindowsWebViewController controller = WindowsWebViewController( + final WindowsWebViewController controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); @@ -277,7 +279,7 @@ void main() { }, ); final native_webview.WebviewController controller = - native_webview.WebviewController(); + _createNativeController(); await controller.initialize(); await controller.dispose(); @@ -293,7 +295,7 @@ void main() { disposeCallCount++; }, ); - final WindowsWebViewController controller = WindowsWebViewController( + final WindowsWebViewController controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); @@ -314,7 +316,7 @@ void main() { disposeCallCount++; }, ); - final WindowsWebViewController controller = WindowsWebViewController( + final WindowsWebViewController controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); @@ -328,7 +330,7 @@ void main() { test('native controller unregisters callbacks and closes streams', () async { final native_webview.WebviewController controller = - native_webview.WebviewController(); + _createNativeController(); final List> streamDoneFutures = >[ controller.url.drain(), controller.loadingState.drain(), @@ -360,7 +362,7 @@ void main() { test('native controller can retry after an initialization failure', () async { _mockWindowsWebViewCreation(creationFailureCount: 1); final native_webview.WebviewController controller = - native_webview.WebviewController(); + _createNativeController(); await expectLater( controller.initialize(), @@ -376,13 +378,13 @@ void main() { await controller.dispose(); }); - testWidgets('native surface attachment follows the widget lifecycle', ( + _widgetTest('native surface attachment follows the widget lifecycle', ( WidgetTester tester, ) async { final List attachmentStates = []; _mockWindowsWebViewCreation(onSetSurfaceAttached: attachmentStates.add); final native_webview.WebviewController controller = - native_webview.WebviewController(); + _createNativeController(); await controller.initialize(); await tester.pumpWidget( @@ -404,13 +406,13 @@ void main() { expect(attachmentStates, [true, false]); }); - testWidgets('native surface detaches when an ancestor stops painting', ( + _widgetTest('native surface detaches when an ancestor stops painting', ( WidgetTester tester, ) async { final List attachmentStates = []; _mockWindowsWebViewCreation(onSetSurfaceAttached: attachmentStates.add); final native_webview.WebviewController controller = - native_webview.WebviewController(); + _createNativeController(); await controller.initialize(); double opacity = 1; @@ -443,13 +445,13 @@ void main() { expect(attachmentStates, [true, false, true]); }); - testWidgets('native surface follows the application lifecycle', ( + _widgetTest('native surface follows the application lifecycle', ( WidgetTester tester, ) async { final List attachmentStates = []; _mockWindowsWebViewCreation(onSetSurfaceAttached: attachmentStates.add); final native_webview.WebviewController controller = - native_webview.WebviewController(); + _createNativeController(); await controller.initialize(); addTearDown(() { tester.binding.handleAppLifecycleStateChanged(AppLifecycleState.resumed); @@ -478,13 +480,13 @@ void main() { expect(attachmentStates, [true, false, true]); }); - testWidgets('native surface updates an explicit scale factor', ( + _widgetTest('native surface updates an explicit scale factor', ( WidgetTester tester, ) async { final List sizes = []; _mockWindowsWebViewCreation(onSetSize: sizes.add); final native_webview.WebviewController controller = - native_webview.WebviewController(); + _createNativeController(); await controller.initialize(); Widget buildWebView(double scaleFactor) { @@ -511,16 +513,15 @@ void main() { await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(); - await tester.runAsync(controller.dispose); }); - testWidgets('native surface follows device-pixel-ratio changes', ( + _widgetTest('native surface follows device-pixel-ratio changes', ( WidgetTester tester, ) async { final List sizes = []; _mockWindowsWebViewCreation(onSetSize: sizes.add); final native_webview.WebviewController controller = - native_webview.WebviewController(); + _createNativeController(); await controller.initialize(); tester.view.devicePixelRatio = 1; addTearDown(tester.view.resetDevicePixelRatio); @@ -548,10 +549,9 @@ void main() { await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(); - await tester.runAsync(controller.dispose); }); - testWidgets('native rendering errors are latched and can be retried', ( + _widgetTest('native rendering errors are latched and can be retried', ( WidgetTester tester, ) async { var sizeCalls = 0; @@ -560,7 +560,7 @@ void main() { onSetSize: (WindowsSizeData size) => sizeCalls += 1, ); final native_webview.WebviewController controller = - native_webview.WebviewController(); + _createNativeController(); await controller.initialize(); await tester.pumpWidget( @@ -595,10 +595,9 @@ void main() { await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(); - await tester.runAsync(controller.dispose); }); - testWidgets('initialization error offers install and refresh actions', ( + _widgetTest('initialization error offers install and refresh actions', ( WidgetTester tester, ) async { var openDownloadPageCount = 0; @@ -609,7 +608,7 @@ void main() { openDownloadPageCount += 1; }, ); - final WindowsWebViewController controller = WindowsWebViewController( + final WindowsWebViewController controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); final WindowsWebViewWidget platformWidget = WindowsWebViewWidget( @@ -643,7 +642,7 @@ void main() { expect(find.byType(Texture), findsOneWidget); }); - testWidgets( + _widgetTest( 'rendering error offers refresh without a runtime install action', (WidgetTester tester) async { _mockWindowsWebViewCreation( @@ -656,7 +655,7 @@ void main() { 'webView2RuntimeVersion': '1.0.0.0', }, ); - final WindowsWebViewController controller = WindowsWebViewController( + final WindowsWebViewController controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); final WindowsWebViewWidget platformWidget = WindowsWebViewWidget( @@ -766,7 +765,7 @@ void main() { }, ); - final controller = WindowsWebViewController( + final controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); final alertRequests = []; @@ -845,7 +844,7 @@ void main() { test( 'dispatches HTTP auth requests through the navigation delegate', () async { - final controller = WindowsWebViewController( + final controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); final delegate = WindowsNavigationDelegate( @@ -881,7 +880,7 @@ void main() { ); test('cancels HTTP auth requests without a handler', () async { - final controller = WindowsWebViewController( + final controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); await controller.currentUrl(); @@ -898,7 +897,7 @@ void main() { }); test('dispatches SSL auth errors through the navigation delegate', () async { - final controller = WindowsWebViewController( + final controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); final delegate = WindowsNavigationDelegate( @@ -934,7 +933,7 @@ void main() { }); test('cancels SSL auth errors without a handler', () async { - final controller = WindowsWebViewController( + final controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); await controller.currentUrl(); @@ -954,7 +953,7 @@ void main() { final loadRequests = []; _mockWindowsWebViewCreation(onLoadRequest: loadRequests.add); - final controller = WindowsWebViewController( + final controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); final body = Uint8List.fromList([1, 2, 3]); @@ -997,7 +996,7 @@ void main() { final File file = File('${tempDir.path}/index.html') ..writeAsStringSync(''); - final controller = WindowsWebViewController( + final controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); @@ -1022,7 +1021,7 @@ void main() { final loadRequests = []; _mockWindowsWebViewCreation(onLoadRequest: loadRequests.add); - final controller = WindowsWebViewController( + final controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); @@ -1039,7 +1038,7 @@ void main() { }); test('dispatches HTTP response errors from Windows events', () async { - final controller = WindowsWebViewController( + final controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); final delegate = WindowsNavigationDelegate( @@ -1087,7 +1086,7 @@ void main() { }); test('dispatches web resource errors from Windows events', () async { - final controller = WindowsWebViewController( + final controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); final delegate = WindowsNavigationDelegate( @@ -1132,7 +1131,7 @@ void main() { }, ); - final controller = WindowsWebViewController( + final controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); final List consoleMessages = @@ -1205,7 +1204,7 @@ void main() { }, ); - final controller = WindowsWebViewController( + final controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); @@ -1244,7 +1243,7 @@ void main() { onSetJavaScriptEnabled: javaScriptEnabledValues.add, ); - final controller = WindowsWebViewController( + final controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); @@ -1263,7 +1262,7 @@ void main() { onGetUserAgent: () => 'DefaultWebView2/1.0', ); - final controller = WindowsWebViewController( + final controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); @@ -1289,7 +1288,7 @@ void main() { }, ); - final controller = WindowsWebViewController( + final controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); @@ -1305,7 +1304,7 @@ void main() { onSetZoomControlEnabled: zoomControlEnabledValues.add, ); - final controller = WindowsWebViewController( + final controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); @@ -1327,7 +1326,7 @@ void main() { onSetDownloadsEnabled: downloadsValues.add, ); - final controller = WindowsWebViewController( + final controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); @@ -1344,7 +1343,7 @@ void main() { }, ); - testWidgets( + _widgetTest( 'WebView2 settings are re-applied after a retried initialization', (WidgetTester tester) async { final devToolsValues = []; @@ -1356,7 +1355,7 @@ void main() { onSetBrowserAcceleratorKeysEnabled: acceleratorKeysValues.add, onSetDownloadsEnabled: downloadsValues.add, ); - final WindowsWebViewController controller = WindowsWebViewController( + final WindowsWebViewController controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); @@ -1391,23 +1390,10 @@ void main() { expect(devToolsValues, [false]); expect(acceleratorKeysValues, [false]); expect(downloadsValues, [false]); - await tester.pumpWidget(const SizedBox.shrink()); - await tester.pump(); - var disposed = false; - // EventChannel teardown also schedules work outside the fake test clock. - final disposal = controller.dispose().then((_) { - disposed = true; - }); - for (var i = 0; !disposed && i < 10; i++) { - await tester.pump(); - await tester.runAsync(() => Future.delayed(Duration.zero)); - } - expect(disposed, isTrue); - await disposal; }, ); - testWidgets( + _widgetTest( 'refresh recovers a setting failure without replacing native subscriptions', (WidgetTester tester) async { var creations = 0; @@ -1419,7 +1405,7 @@ void main() { if (!enabled) throw PlatformException(code: 'not_supported'); }, ); - final controller = WindowsWebViewController( + final controller = _createWindowsController( const WindowsWebViewControllerCreationParams(downloadsEnabled: false), ); final platformWidget = WindowsWebViewWidget( @@ -1444,17 +1430,6 @@ void main() { expect(find.text('Refresh'), findsNothing); expect(find.byType(native_webview.Webview), findsOneWidget); expect(creations, 1); - await tester.pumpWidget(const SizedBox.shrink()); - var disposed = false; - final disposal = controller.dispose().then((_) { - disposed = true; - }); - for (var i = 0; !disposed && i < 10; i++) { - await tester.pump(); - await tester.runAsync(() => Future.delayed(Duration.zero)); - } - expect(disposed, isTrue); - await disposal; }, ); @@ -1477,8 +1452,7 @@ void main() { onSetDownloadsEnabled: (bool value) => events.add('downloads:$value'), onLoadRequest: (_) => events.add('load'), ); - final controller = WindowsWebViewController(entry.value); - addTearDown(controller.dispose); + final controller = _createWindowsController(entry.value); final request = LoadRequestParams( uri: Uri.parse('https://example.test'), ); @@ -1507,8 +1481,7 @@ void main() { onSetDevToolsEnabled: (bool value) => events.add('devTools:$value'), onLoadRequest: (_) => events.add('load'), ); - final controller = WindowsWebViewController(entry.value); - addTearDown(controller.dispose); + final controller = _createWindowsController(entry.value); await controller.loadRequest( LoadRequestParams(uri: Uri.parse('https://example.test')), ); @@ -1523,10 +1496,9 @@ void main() { onSetDevToolsEnabled: (bool value) => events.add('devTools:$value'), onOpenDevTools: () => events.add('open'), ); - final controller = WindowsWebViewController( + final controller = _createWindowsController( const WindowsWebViewControllerCreationParams(), ); - addTearDown(controller.dispose); await controller.openDevTools(); expect(events, ['devTools:false', 'open']); }); @@ -1540,7 +1512,7 @@ void main() { onSetDownloadsEnabled: (bool value) => events.add('downloads:$value'), onLoadRequest: (_) => events.add('load'), ); - final controller = WindowsWebViewController( + final controller = _createWindowsController( const WindowsWebViewControllerCreationParams.fromPlatformWebViewControllerCreationParams( PlatformWebViewControllerCreationParams(), devToolsEnabled: false, @@ -1548,7 +1520,6 @@ void main() { downloadsEnabled: false, ), ); - addTearDown(controller.dispose); await controller.loadRequest( LoadRequestParams(uri: Uri.parse('https://example.test')), ); @@ -1573,10 +1544,9 @@ void main() { } }, ); - final controller = WindowsWebViewController( + final controller = _createWindowsController( const WindowsWebViewControllerCreationParams(), ); - addTearDown(controller.dispose); await controller.setDownloadsEnabled(false); final enabling = controller.setDownloadsEnabled(true); await started.future; @@ -1607,10 +1577,9 @@ void main() { loads++; }, ); - final controller = WindowsWebViewController( + final controller = _createWindowsController( const WindowsWebViewControllerCreationParams(downloadsEnabled: false), ); - addTearDown(controller.dispose); final request = LoadRequestParams(uri: Uri.parse('https://example.test')); await expectLater( controller.loadRequest(request), @@ -1639,10 +1608,9 @@ void main() { } }, ); - final controller = WindowsWebViewController( + final controller = _createWindowsController( const WindowsWebViewControllerCreationParams(), ); - addTearDown(controller.dispose); await expectLater( controller.setDevToolsEnabled(false), throwsA( @@ -1672,7 +1640,7 @@ void main() { disposed = true; }, ); - final controller = WindowsWebViewController( + final controller = _createWindowsController( const WindowsWebViewControllerCreationParams(), ); final setting = controller.setDownloadsEnabled(false); @@ -1696,7 +1664,7 @@ void main() { test( 'dispatches Windows permission requests and returns decisions', () async { - final controller = WindowsWebViewController( + final controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); final requests = []; @@ -1742,7 +1710,7 @@ void main() { test( 'uses the default Windows decision for unsupported permissions', () async { - final controller = WindowsWebViewController( + final controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); var callbackCalled = false; @@ -1775,10 +1743,10 @@ void main() { }, ); - testWidgets('uses safe defaults when application decisions do not complete', ( + _widgetTest('uses safe defaults when application decisions do not complete', ( WidgetTester tester, ) async { - final controller = WindowsWebViewController( + final controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); final delegate = WindowsNavigationDelegate( @@ -1854,7 +1822,7 @@ void main() { }); test('uses safe defaults when decision callbacks throw', () async { - final controller = WindowsWebViewController( + final controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); final delegate = WindowsNavigationDelegate( @@ -1928,7 +1896,7 @@ void main() { test( 'contains callback failures and preserves completed decisions', () async { - final controller = WindowsWebViewController( + final controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); final delegate = WindowsNavigationDelegate( @@ -2037,7 +2005,7 @@ void main() { }, ); - final controller = WindowsWebViewController( + final controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); @@ -2065,7 +2033,7 @@ void main() { _mockWindowsWebViewCreation( onSetNavigationRequestCallbacksEnabled: callbackStates.add, ); - final controller = WindowsWebViewController( + final controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); final delegate = WindowsNavigationDelegate( @@ -2131,7 +2099,7 @@ void main() { debugPrint = previousDebugPrint; }); - final controller = WindowsWebViewController( + final controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); final delegate = WindowsNavigationDelegate( @@ -2176,7 +2144,7 @@ void main() { return 'null'; }, ); - final controller = WindowsWebViewController( + final controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); const String channelName = 'channel-name"\\line'; @@ -2203,7 +2171,7 @@ void main() { test('escapes base URLs inserted into HTML', () async { final contents = []; _mockWindowsWebViewCreation(onLoadStringContent: contents.add); - final controller = WindowsWebViewController( + final controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); @@ -2234,7 +2202,7 @@ void main() { addTearDown(() => tempDir.deleteSync(recursive: true)); final File file = File('${tempDir.path}/index.html') ..writeAsStringSync(''); - final controller = WindowsWebViewController( + final controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); @@ -2248,7 +2216,7 @@ void main() { }); test('rejects relative file paths and traversing asset keys', () async { - final controller = WindowsWebViewController( + final controller = _createWindowsController( const PlatformWebViewControllerCreationParams(), ); @@ -2263,6 +2231,70 @@ void main() { }); } +final _controllerDisposals = Function()>[]; + +void _widgetTest( + String description, + Future Function(WidgetTester tester) callback, +) { + testWidgets(description, (WidgetTester tester) async { + try { + await callback(tester); + } finally { + await tester.pumpWidget(const SizedBox.shrink()); + await tester.pumpAndSettle(); + // Cancellation crosses the fake clock and the real EventChannel queue. + // Keep both moving until cleanup completes, before removing the mocks. + var completed = false; + Object? cleanupError; + StackTrace? cleanupStack; + final cleanup = _disposeTestControllers().then( + (_) => completed = true, + onError: (Object error, StackTrace stack) { + cleanupError = error; + cleanupStack = stack; + completed = true; + return true; + }, + ); + final elapsed = Stopwatch()..start(); + while (!completed && elapsed.elapsed < const Duration(seconds: 5)) { + await tester.pump(); + await tester.runAsync( + () => Future.delayed(const Duration(milliseconds: 1)), + ); + } + expect(completed, isTrue, reason: 'Controller cleanup did not complete.'); + await cleanup; + if (cleanupError != null) { + Error.throwWithStackTrace(cleanupError!, cleanupStack!); + } + } + }); +} + +Future _disposeTestControllers() async { + final disposals = _controllerDisposals.reversed.toList(); + _controllerDisposals.clear(); + await Future.wait( + disposals.map((dispose) => Future.sync(dispose)), + ); +} + +WindowsWebViewController _createWindowsController( + PlatformWebViewControllerCreationParams params, +) { + final controller = WindowsWebViewController(params); + _controllerDisposals.add(controller.dispose); + return controller; +} + +native_webview.WebviewController _createNativeController() { + final controller = native_webview.WebviewController(); + _controllerDisposals.add(controller.dispose); + return controller; +} + void _mockWindowsWebViewCreation({ int creationFailureCount = 0, String creationFailureCode = 'environment_creation_failed',