From b4250fff2c7110c4e8898fafaed204d6ca84d48c Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Fri, 26 Jun 2026 12:32:43 +0900 Subject: [PATCH 1/7] [webview_flutter_tizen] Implement clearLocalStorage and onHttpError Add Tizen native implementations for two previously unimplemented APIs: - clearLocalStorage: clears web local storage via ewk_context_web_storage_delete_all. - onHttpError: reports HTTP error status codes (>= 400) to the navigation delegate via the policy,response,decide callback. Bump version to 0.10.1. Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/webview_flutter/CHANGELOG.md | 5 ++++ .../webview_flutter/example/lib/main.dart | 10 +++---- .../lib/src/tizen_webview.dart | 4 +++ .../lib/src/tizen_webview_controller.dart | 27 ++++++++++------- packages/webview_flutter/pubspec.yaml | 2 +- packages/webview_flutter/tizen/src/webview.cc | 30 +++++++++++++++++++ packages/webview_flutter/tizen/src/webview.h | 1 + 7 files changed, 62 insertions(+), 17 deletions(-) diff --git a/packages/webview_flutter/CHANGELOG.md b/packages/webview_flutter/CHANGELOG.md index aa5a97dde..98fddfed2 100644 --- a/packages/webview_flutter/CHANGELOG.md +++ b/packages/webview_flutter/CHANGELOG.md @@ -1,3 +1,8 @@ +## 0.10.1 + +* Implement `clearLocalStorage`. +* Implement `onHttpError` for the navigation delegate. + ## 0.10.0 * Update minimum supported SDK version to Flutter 3.32/Dart 3.8. diff --git a/packages/webview_flutter/example/lib/main.dart b/packages/webview_flutter/example/lib/main.dart index 36e248fb6..7899aa526 100644 --- a/packages/webview_flutter/example/lib/main.dart +++ b/packages/webview_flutter/example/lib/main.dart @@ -190,10 +190,9 @@ Page resource error: debugPrint('allowing navigation to ${request.url}'); return NavigationDecision.navigate; }, - // Note: onHttpError is not implemented by TizenWebview. - // onHttpError: (HttpResponseError error) { - // debugPrint('Error occurred on page: ${error.response?.statusCode}'); - // }, + onHttpError: (HttpResponseError error) { + debugPrint('Error occurred on page: ${error.response?.statusCode}'); + }, onUrlChange: (UrlChange change) { debugPrint('url change to ${change.url}'); }, @@ -491,8 +490,7 @@ class SampleMenu extends StatelessWidget { Future _onClearCache(BuildContext context) async { await webViewController.clearCache(); - // This is unimplemented in webview_flutter_tizen. - // await webViewController.clearLocalStorage(); + await webViewController.clearLocalStorage(); if (context.mounted) { ScaffoldMessenger.of( context, diff --git a/packages/webview_flutter/lib/src/tizen_webview.dart b/packages/webview_flutter/lib/src/tizen_webview.dart index 236dd4821..ca3a0758b 100644 --- a/packages/webview_flutter/lib/src/tizen_webview.dart +++ b/packages/webview_flutter/lib/src/tizen_webview.dart @@ -151,6 +151,10 @@ class TizenWebView { /// Clears all caches used by the [WebView]. Future clearCache() => _invokeChannelMethod('clearCache'); + /// Clears the local storage used by the [WebView]. + Future clearLocalStorage() => + _invokeChannelMethod('clearLocalStorage'); + /// Sets the JavaScript execution mode to be used by the webview. Future setJavaScriptMode(int javaScriptMode) => _invokeChannelMethod('javaScriptMode', javaScriptMode); diff --git a/packages/webview_flutter/lib/src/tizen_webview_controller.dart b/packages/webview_flutter/lib/src/tizen_webview_controller.dart index b364fa7a1..b6909e1bc 100644 --- a/packages/webview_flutter/lib/src/tizen_webview_controller.dart +++ b/packages/webview_flutter/lib/src/tizen_webview_controller.dart @@ -218,12 +218,7 @@ class TizenWebViewController extends PlatformWebViewController { Future clearCache() => _webview.clearCache(); @override - Future clearLocalStorage() { - throw UnimplementedError( - 'This version of `TizenWebViewController` currently has no ' - 'implementation.', - ); - } + Future clearLocalStorage() => _webview.clearLocalStorage(); @override Future setPlatformNavigationDelegate( @@ -477,6 +472,7 @@ class TizenNavigationDelegate extends PlatformNavigationDelegate { WebResourceErrorCallback? _onWebResourceError; NavigationRequestCallback? _onNavigationRequest; UrlChangeCallback? _onUrlChange; + HttpResponseErrorCallback? _onHttpError; /// Called when [TizenView] is created. void createNavigationDelegateChannel(int viewId) { @@ -525,6 +521,20 @@ class TizenNavigationDelegate extends PlatformNavigationDelegate { _onUrlChange!(UrlChange(url: arguments['url']! as String)); } return null; + case 'onHttpError': + if (_onHttpError != null) { + final Uri uri = Uri.parse(arguments['url']! as String); + _onHttpError!( + HttpResponseError( + request: WebResourceRequest(uri: uri), + response: WebResourceResponse( + uri: uri, + statusCode: arguments['statusCode']! as int, + ), + ), + ); + } + return null; } throw MissingPluginException( @@ -580,10 +590,7 @@ class TizenNavigationDelegate extends PlatformNavigationDelegate { @override Future setOnHttpError(HttpResponseErrorCallback onHttpError) async { - throw UnimplementedError( - 'This version of `TizenNavigationDelegate` currently has no ' - 'implementation for `setOnHttpError`', - ); + _onHttpError = onHttpError; } @override diff --git a/packages/webview_flutter/pubspec.yaml b/packages/webview_flutter/pubspec.yaml index fc461b335..d619ea013 100644 --- a/packages/webview_flutter/pubspec.yaml +++ b/packages/webview_flutter/pubspec.yaml @@ -2,7 +2,7 @@ name: webview_flutter_tizen description: Tizen implementation of the webview_flutter plugin. homepage: https://github.com/flutter-tizen/plugins repository: https://github.com/flutter-tizen/plugins/tree/master/packages/webview_flutter -version: 0.10.0 +version: 0.10.1 environment: sdk: ^3.8.0 diff --git a/packages/webview_flutter/tizen/src/webview.cc b/packages/webview_flutter/tizen/src/webview.cc index 9bc0e73ad..f51a95e1d 100644 --- a/packages/webview_flutter/tizen/src/webview.cc +++ b/packages/webview_flutter/tizen/src/webview.cc @@ -429,6 +429,8 @@ bool WebView::InitWebView() { &WebView::OnConsoleMessage, this); evas_object_smart_callback_add(webview_instance_, "policy,navigation,decide", &WebView::OnNavigationPolicy, this); + evas_object_smart_callback_add(webview_instance_, "policy,response,decide", + &WebView::OnResponsePolicy, this); evas_object_smart_callback_add(webview_instance_, "url,changed", &WebView::OnUrlChange, this); @@ -580,6 +582,10 @@ void WebView::HandleWebViewMethodCall(const FlMethodCall& method_call, Ewk_Context* context = ewk_view_context_get(webview_instance_); ewk_context_resource_cache_clear(context); result->Success(); + } else if (method_name == "clearLocalStorage") { + Ewk_Context* context = ewk_view_context_get(webview_instance_); + ewk_context_web_storage_delete_all(context); + result->Success(); } else if (method_name == "getTitle") { result->Success(flutter::EncodableValue( std::string(ewk_view_title_get(webview_instance_)))); @@ -871,6 +877,30 @@ void WebView::OnNavigationPolicy(void* data, Evas_Object* obj, std::move(result)); } +void WebView::OnResponsePolicy(void* data, Evas_Object* obj, + void* event_info) { + WebView* webview = static_cast(data); + Ewk_Policy_Decision* policy_decision = + static_cast(event_info); + int status_code = + ewk_policy_decision_response_status_code_get(policy_decision); + const char* url = ewk_policy_decision_url_get(policy_decision); + ewk_policy_decision_use(policy_decision); + + // HTTP error status codes (4xx, 5xx) are reported to the navigation delegate. + if (!webview->has_navigation_delegate_ || status_code < 400) { + return; + } + flutter::EncodableMap args = { + {flutter::EncodableValue("url"), + flutter::EncodableValue(url ? url : "")}, + {flutter::EncodableValue("statusCode"), + flutter::EncodableValue(status_code)}, + }; + webview->navigation_delegate_channel_->InvokeMethod( + "onHttpError", std::make_unique(args)); +} + void WebView::OnUrlChange(void* data, Evas_Object* obj, void* event_info) { WebView* webview = static_cast(data); std::string url = std::string(ewk_view_url_get(webview->webview_instance_)); diff --git a/packages/webview_flutter/tizen/src/webview.h b/packages/webview_flutter/tizen/src/webview.h index 688f97ddc..ac47a9bd6 100644 --- a/packages/webview_flutter/tizen/src/webview.h +++ b/packages/webview_flutter/tizen/src/webview.h @@ -82,6 +82,7 @@ class WebView : public PlatformView { static void OnConsoleMessage(void* data, Evas_Object* obj, void* event_info); static void OnNavigationPolicy(void* data, Evas_Object* obj, void* event_info); + static void OnResponsePolicy(void* data, Evas_Object* obj, void* event_info); static void OnUrlChange(void* data, Evas_Object* obj, void* event_info); static void OnEvaluateJavaScript(Evas_Object* obj, const char* result_value, void* user_data); From 4d83119081a2a06912b797d18bf0131446e83099 Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Fri, 26 Jun 2026 12:32:49 +0900 Subject: [PATCH 2/7] [webview_flutter_tizen] Add integration tests based on upstream v4.13.1 Port the remaining runnable upstream test cases from webview_flutter v4.13.1: - NavigationDelegate > onHttpError - NavigationDelegate > onHttpError is not called when no HTTP error is received - clearLocalStorage These pass thanks to the new clearLocalStorage and onHttpError implementations. The other upstream test cases remain omitted because they cannot run on Tizen: window.open/new-window behavior, HTTP basic auth, and media playback policy are not supported by the engine. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../webview_flutter_test.dart | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) diff --git a/packages/webview_flutter/example/integration_test/webview_flutter_test.dart b/packages/webview_flutter/example/integration_test/webview_flutter_test.dart index d861f9994..cd28a8410 100644 --- a/packages/webview_flutter/example/integration_test/webview_flutter_test.dart +++ b/packages/webview_flutter/example/integration_test/webview_flutter_test.dart @@ -443,6 +443,68 @@ Future main() async { expect(currentUrl, isNot(contains('youtube.com'))); }); + testWidgets('onHttpError', (WidgetTester tester) async { + final Completer errorCompleter = + Completer(); + + final WebViewController controller = WebViewController(); + unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); + unawaited( + controller.setNavigationDelegate( + NavigationDelegate( + onHttpError: (HttpResponseError error) { + errorCompleter.complete(error); + }, + ), + ), + ); + + unawaited(controller.loadRequest(Uri.parse('$prefixUrl/favicon.ico'))); + + await tester.pumpWidget(WebViewWidget(controller: controller)); + + final HttpResponseError error = await errorCompleter.future; + + expect(error, isNotNull); + expect(error.response?.statusCode, 404); + }); + + testWidgets('onHttpError is not called when no HTTP error is received', ( + WidgetTester tester, + ) async { + const String testPage = ''' + + + + + + '''; + + final Completer errorCompleter = + Completer(); + final Completer pageFinishCompleter = Completer(); + + final WebViewController controller = WebViewController(); + unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); + unawaited( + controller.setNavigationDelegate( + NavigationDelegate( + onPageFinished: (_) => pageFinishCompleter.complete(), + onHttpError: (HttpResponseError error) { + errorCompleter.complete(error); + }, + ), + ), + ); + + unawaited(controller.loadHtmlString(testPage)); + + await tester.pumpWidget(WebViewWidget(controller: controller)); + + expect(errorCompleter.future, doesNotComplete); + await pageFinishCompleter.future; + }); + testWidgets('supports asynchronous decisions', (WidgetTester tester) async { Completer pageLoaded = Completer(); @@ -541,6 +603,41 @@ Future main() async { await expectLater(urlChangeCompleter.future, completion(secondaryUrl)); }); }); + + testWidgets('clearLocalStorage', (WidgetTester tester) async { + Completer pageLoadCompleter = Completer(); + + final WebViewController controller = WebViewController(); + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate(onPageFinished: (_) => pageLoadCompleter.complete()), + ); + await controller.loadRequest(Uri.parse(primaryUrl)); + + await tester.pumpWidget(WebViewWidget(controller: controller)); + + await pageLoadCompleter.future; + pageLoadCompleter = Completer(); + + await controller.runJavaScript('localStorage.setItem("myCat", "Tom");'); + final String myCatItem = + await controller.runJavaScriptReturningResult( + 'localStorage.getItem("myCat");', + ) + as String; + expect(myCatItem, 'Tom'); + + await controller.clearLocalStorage(); + + // Reload page to have changes take effect. + await controller.reload(); + await pageLoadCompleter.future; + + final Object nullItem = await controller.runJavaScriptReturningResult( + 'localStorage.getItem("myCat");', + ); + expect(nullItem, ''); + }); } class ResizableWebView extends StatefulWidget { From 30731b7d51ef1838f54d2a2ffcd1f222bd74b05e Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Fri, 26 Jun 2026 14:09:54 +0900 Subject: [PATCH 3/7] [webview_flutter_tizen] Align integration test async style with upstream The test file wrapped most controller setup calls (setJavaScriptMode, setNavigationDelegate, loadRequest, etc.) in unawaited(), a leftover from an older upstream version. Match the upstream v4.13.1 style by awaiting those calls instead, keeping unawaited() only where upstream does (the request server loop and the two onHttpError tests). No behavior change; the full suite still passes on the device. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../webview_flutter_test.dart | 206 ++++++++---------- 1 file changed, 85 insertions(+), 121 deletions(-) diff --git a/packages/webview_flutter/example/integration_test/webview_flutter_test.dart b/packages/webview_flutter/example/integration_test/webview_flutter_test.dart index cd28a8410..7f40a6a1a 100644 --- a/packages/webview_flutter/example/integration_test/webview_flutter_test.dart +++ b/packages/webview_flutter/example/integration_test/webview_flutter_test.dart @@ -44,12 +44,10 @@ Future main() async { final Completer pageFinished = Completer(); final WebViewController controller = WebViewController(); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate(onPageFinished: (_) => pageFinished.complete()), - ), + await controller.setNavigationDelegate( + NavigationDelegate(onPageFinished: (_) => pageFinished.complete()), ); - unawaited(controller.loadRequest(Uri.parse(primaryUrl))); + await controller.loadRequest(Uri.parse(primaryUrl)); await tester.pumpWidget(WebViewWidget(controller: controller)); @@ -63,13 +61,11 @@ Future main() async { final Completer pageFinished = Completer(); final WebViewController controller = WebViewController(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate(onPageFinished: (_) => pageFinished.complete()), - ), + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate(onPageFinished: (_) => pageFinished.complete()), ); - unawaited(controller.loadRequest(Uri.parse(primaryUrl))); + await controller.loadRequest(Uri.parse(primaryUrl)); await tester.pumpWidget(WebViewWidget(controller: controller)); @@ -89,16 +85,14 @@ Future main() async { final StreamController pageLoads = StreamController(); final WebViewController controller = WebViewController(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate(onPageFinished: (String url) => pageLoads.add(url)), - ), + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate(onPageFinished: (String url) => pageLoads.add(url)), ); await tester.pumpWidget(WebViewWidget(controller: controller)); - unawaited(controller.loadRequest(Uri.parse(headersUrl), headers: headers)); + await controller.loadRequest(Uri.parse(headersUrl), headers: headers); await pageLoads.stream.firstWhere((String url) => url == headersUrl); @@ -113,11 +107,9 @@ Future main() async { testWidgets('JavascriptChannel', (WidgetTester tester) async { final Completer pageFinished = Completer(); final WebViewController controller = WebViewController(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate(onPageFinished: (_) => pageFinished.complete()), - ), + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate(onPageFinished: (_) => pageFinished.complete()), ); final Completer channelCompleter = Completer(); @@ -178,14 +170,12 @@ Future main() async { final Completer pageFinished = Completer(); final WebViewController controller = WebViewController(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate(onPageFinished: (_) => pageFinished.complete()), - ), + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate(onPageFinished: (_) => pageFinished.complete()), ); - unawaited(controller.setUserAgent('Custom_User_Agent1')); - unawaited(controller.loadRequest(Uri.parse('about:blank'))); + await controller.setUserAgent('Custom_User_Agent1'); + await controller.loadRequest(Uri.parse('about:blank')); await tester.pumpWidget(WebViewWidget(controller: controller)); @@ -210,16 +200,12 @@ Future main() async { final Completer pageLoaded = Completer(); final WebViewController controller = WebViewController(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate(onPageFinished: (_) => pageLoaded.complete()), - ), + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate(onPageFinished: (_) => pageLoaded.complete()), ); - unawaited( - controller.loadRequest( - Uri.parse('data:text/html;charset=utf-8;base64,$getTitleTestBase64'), - ), + await controller.loadRequest( + Uri.parse('data:text/html;charset=utf-8;base64,$getTitleTestBase64'), ); await tester.pumpWidget(WebViewWidget(controller: controller)); @@ -265,18 +251,12 @@ Future main() async { final Completer pageLoaded = Completer(); final WebViewController controller = WebViewController(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate(onPageFinished: (_) => pageLoaded.complete()), - ), + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate(onPageFinished: (_) => pageLoaded.complete()), ); - unawaited( - controller.loadRequest( - Uri.parse( - 'data:text/html;charset=utf-8;base64,$scrollTestPageBase64', - ), - ), + await controller.loadRequest( + Uri.parse('data:text/html;charset=utf-8;base64,$scrollTestPageBase64'), ); await tester.pumpWidget(WebViewWidget(controller: controller)); @@ -319,23 +299,21 @@ Future main() async { Completer pageLoaded = Completer(); final WebViewController controller = WebViewController(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate( - onPageFinished: (_) => pageLoaded.complete(), - onNavigationRequest: (NavigationRequest navigationRequest) { - return (navigationRequest.url.contains('youtube.com')) - ? NavigationDecision.prevent - : NavigationDecision.navigate; - }, - ), + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate( + onPageFinished: (_) => pageLoaded.complete(), + onNavigationRequest: (NavigationRequest navigationRequest) { + return (navigationRequest.url.contains('youtube.com')) + ? NavigationDecision.prevent + : NavigationDecision.navigate; + }, ), ); await tester.pumpWidget(WebViewWidget(controller: controller)); - unawaited(controller.loadRequest(Uri.parse(blankPageEncoded))); + await controller.loadRequest(Uri.parse(blankPageEncoded)); await pageLoaded.future; // Wait for initial page load. @@ -352,19 +330,15 @@ Future main() async { Completer(); final WebViewController controller = WebViewController(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate( - onWebResourceError: (WebResourceError error) { - errorCompleter.complete(error); - }, - ), + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate( + onWebResourceError: (WebResourceError error) { + errorCompleter.complete(error); + }, ), ); - unawaited( - controller.loadRequest(Uri.parse('https://www.notawebsite..com')), - ); + await controller.loadRequest(Uri.parse('https://www.notawebsite..com')); await tester.pumpWidget(WebViewWidget(controller: controller)); @@ -380,21 +354,17 @@ Future main() async { final Completer pageFinishCompleter = Completer(); final WebViewController controller = WebViewController(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate( - onPageFinished: (_) => pageFinishCompleter.complete(), - onWebResourceError: (WebResourceError error) { - errorCompleter.complete(error); - }, - ), + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate( + onPageFinished: (_) => pageFinishCompleter.complete(), + onWebResourceError: (WebResourceError error) { + errorCompleter.complete(error); + }, ), ); - unawaited( - controller.loadRequest( - Uri.parse('data:text/html;charset=utf-8;base64,PCFET0NUWVBFIGh0bWw+'), - ), + await controller.loadRequest( + Uri.parse('data:text/html;charset=utf-8;base64,PCFET0NUWVBFIGh0bWw+'), ); await tester.pumpWidget(WebViewWidget(controller: controller)); @@ -407,23 +377,21 @@ Future main() async { Completer pageLoaded = Completer(); final WebViewController controller = WebViewController(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate( - onPageFinished: (_) => pageLoaded.complete(), - onNavigationRequest: (NavigationRequest navigationRequest) { - return (navigationRequest.url.contains('youtube.com')) - ? NavigationDecision.prevent - : NavigationDecision.navigate; - }, - ), + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate( + onPageFinished: (_) => pageLoaded.complete(), + onNavigationRequest: (NavigationRequest navigationRequest) { + return (navigationRequest.url.contains('youtube.com')) + ? NavigationDecision.prevent + : NavigationDecision.navigate; + }, ), ); await tester.pumpWidget(WebViewWidget(controller: controller)); - unawaited(controller.loadRequest(Uri.parse(blankPageEncoded))); + await controller.loadRequest(Uri.parse(blankPageEncoded)); await pageLoaded.future; // Wait for initial page load. @@ -509,26 +477,24 @@ Future main() async { Completer pageLoaded = Completer(); final WebViewController controller = WebViewController(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate( - onPageFinished: (_) => pageLoaded.complete(), - onNavigationRequest: (NavigationRequest navigationRequest) async { - NavigationDecision decision = NavigationDecision.prevent; - decision = await Future.delayed( - const Duration(milliseconds: 10), - () => NavigationDecision.navigate, - ); - return decision; - }, - ), + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate( + onPageFinished: (_) => pageLoaded.complete(), + onNavigationRequest: (NavigationRequest navigationRequest) async { + NavigationDecision decision = NavigationDecision.prevent; + decision = await Future.delayed( + const Duration(milliseconds: 10), + () => NavigationDecision.navigate, + ); + return decision; + }, ), ); await tester.pumpWidget(WebViewWidget(controller: controller)); - unawaited(controller.loadRequest(Uri.parse(blankPageEncoded))); + await controller.loadRequest(Uri.parse(blankPageEncoded)); await pageLoaded.future; // Wait for initial page load. @@ -545,13 +511,11 @@ Future main() async { final WebViewController controller = WebViewController(); final Completer urlChangeCompleter = Completer(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited( - controller.setNavigationDelegate( - NavigationDelegate(onPageFinished: (_) => pageLoaded.complete()), - ), + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate( + NavigationDelegate(onPageFinished: (_) => pageLoaded.complete()), ); - unawaited(controller.loadRequest(Uri.parse(blankPageEncoded))); + await controller.loadRequest(Uri.parse(blankPageEncoded)); await tester.pumpWidget(WebViewWidget(controller: controller)); @@ -579,9 +543,9 @@ Future main() async { ); final WebViewController controller = WebViewController(); - unawaited(controller.setJavaScriptMode(JavaScriptMode.unrestricted)); - unawaited(controller.setNavigationDelegate(navigationDelegate)); - unawaited(controller.loadRequest(Uri.parse(primaryUrl))); + await controller.setJavaScriptMode(JavaScriptMode.unrestricted); + await controller.setNavigationDelegate(navigationDelegate); + await controller.loadRequest(Uri.parse(primaryUrl)); await tester.pumpWidget(WebViewWidget(controller: controller)); From f82aa12b4b48dd555795012f8ca5a3f1d6e6557f Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Mon, 29 Jun 2026 11:20:19 +0900 Subject: [PATCH 4/7] [webview_flutter_tizen] Stabilize scroll position integration test getScrollPosition() settles asynchronously after scrollTo/scrollBy, so reading it once right after the call was flaky (more so on software-GL rendering such as emulators). Poll the scroll position until it reaches the expected value, with a timeout, so the test waits for the value to settle instead of failing on a transient stale read. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../webview_flutter_test.dart | 20 +++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/packages/webview_flutter/example/integration_test/webview_flutter_test.dart b/packages/webview_flutter/example/integration_test/webview_flutter_test.dart index 7f40a6a1a..1068d5898 100644 --- a/packages/webview_flutter/example/integration_test/webview_flutter_test.dart +++ b/packages/webview_flutter/example/integration_test/webview_flutter_test.dart @@ -276,14 +276,30 @@ Future main() async { expect(scrollPos.dx, isNot(X_SCROLL)); expect(scrollPos.dy, isNot(Y_SCROLL)); + // The scroll position settles asynchronously, so poll until it reaches + // the expected value (with a timeout) instead of reading it once. This + // keeps the test stable on slower software-GL rendering (e.g. emulators). + Future pollScrollPosition(int expectedX, int expectedY) async { + Offset pos = await controller.getScrollPosition(); + for ( + int i = 0; + i < 20 && (pos.dx != expectedX || pos.dy != expectedY); + i++ + ) { + await Future.delayed(const Duration(milliseconds: 100)); + pos = await controller.getScrollPosition(); + } + return pos; + } + await controller.scrollTo(X_SCROLL, Y_SCROLL); - scrollPos = await controller.getScrollPosition(); + scrollPos = await pollScrollPosition(X_SCROLL, Y_SCROLL); expect(scrollPos.dx, X_SCROLL); expect(scrollPos.dy, Y_SCROLL); // Check scrollBy() (on top of scrollTo()) await controller.scrollBy(X_SCROLL, Y_SCROLL); - scrollPos = await controller.getScrollPosition(); + scrollPos = await pollScrollPosition(X_SCROLL * 2, Y_SCROLL * 2); expect(scrollPos.dx, X_SCROLL * 2); expect(scrollPos.dy, Y_SCROLL * 2); }); From 35c7403f28c2ea5997cd2665b7c59cd0995f74e2 Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Thu, 9 Jul 2026 19:49:21 +0900 Subject: [PATCH 5/7] [webview_flutter_tizen] Fix disposal races and TV emulator teardown crash Rework WebView::Dispose() to tear resources down safely: - Detach all engine callbacks (including the missing "policy,response,decide") and defer evas_object_del() until the embedder's UnregisterTexture completion callback, so the engine-owned TBM surfaces are not freed while a raster-thread frame is still reading them (flutter-tizen/embedder#182). - Add is_alive_/is_disposing_ guards so async callbacks arriving after disposal no longer touch the destroyed WebView. - On the Tizen 10.0 TV emulator (TV_PROFILE + x86_64), hide the stopped view instead of deleting it to avoid a SIGSEGV in chromium-efl's ~SelectionControllerEfl(); revert once the engine fix ships. Verified on the TV 10.0 emulator: example integration tests previously crashed on WebView disposal and now pass 19/19. --- packages/webview_flutter/CHANGELOG.md | 2 + packages/webview_flutter/README.md | 2 +- packages/webview_flutter/tizen/src/webview.cc | 165 +++++++++++++++--- packages/webview_flutter/tizen/src/webview.h | 8 + 4 files changed, 152 insertions(+), 25 deletions(-) diff --git a/packages/webview_flutter/CHANGELOG.md b/packages/webview_flutter/CHANGELOG.md index 98fddfed2..13febe4ce 100644 --- a/packages/webview_flutter/CHANGELOG.md +++ b/packages/webview_flutter/CHANGELOG.md @@ -2,6 +2,8 @@ * Implement `clearLocalStorage`. * Implement `onHttpError` for the navigation delegate. +* Fix races and use-after-frees on WebView disposal, and avoid a web engine + teardown crash on the Tizen 10.0 TV emulator. ## 0.10.0 diff --git a/packages/webview_flutter/README.md b/packages/webview_flutter/README.md index 3dd1b9a1d..6dc6ef0dc 100644 --- a/packages/webview_flutter/README.md +++ b/packages/webview_flutter/README.md @@ -23,7 +23,7 @@ This package is not an _endorsed_ implementation of `webview_flutter`. Therefore ```yaml dependencies: webview_flutter: ^4.13.1 - webview_flutter_tizen: ^0.10.0 + webview_flutter_tizen: ^0.10.1 ``` ## Example diff --git a/packages/webview_flutter/tizen/src/webview.cc b/packages/webview_flutter/tizen/src/webview.cc index f51a95e1d..d1be2db7f 100644 --- a/packages/webview_flutter/tizen/src/webview.cc +++ b/packages/webview_flutter/tizen/src/webview.cc @@ -4,6 +4,7 @@ #include "webview.h" +#include #include #include #include @@ -44,9 +45,19 @@ std::string ConvertLogLevelToString(Ewk_Console_Message_Level level) { class NavigationRequestResult : public FlMethodResult { public: - NavigationRequestResult(WebView* webview) : webview_(webview) {} + // |alive| is the WebView's is_alive_ flag. Dart resolves the + // "navigationRequest" method call asynchronously (it round-trips through + // the Dart navigation delegate), so this result's completion can run well + // after the WebView that created it has been disposed; |webview_| would + // then be a dangling pointer. Checking |alive| before dereferencing it + // avoids a use-after-free in that case. + NavigationRequestResult(WebView* webview, std::shared_ptr alive) + : webview_(webview), alive_(std::move(alive)) {} void SuccessInternal(const flutter::EncodableValue* should_load) override { + if (!*alive_) { + return; + } if (std::holds_alternative(*should_load)) { if (std::get(*should_load)) { webview_->Resume(); @@ -60,16 +71,23 @@ class NavigationRequestResult : public FlMethodResult { const std::string& error_message, const flutter::EncodableValue* error_details) override { LOG_ERROR("The request unexpectedly completed with an error."); + if (!*alive_) { + return; + } webview_->Stop(); } void NotImplementedInternal() override { LOG_ERROR("The target method was unexpectedly unimplemented."); + if (!*alive_) { + return; + } webview_->Stop(); } private: WebView* webview_; + std::shared_ptr alive_; }; template @@ -173,33 +191,111 @@ void WebView::Dispose() { if (disposed_) { return; } + disposed_ = true; - texture_registrar_->UnregisterTexture(GetTextureId(), nullptr); - - if (webview_instance_) { - evas_object_smart_callback_del(webview_instance_, - "offscreen,frame,rendered", + // A Dart "navigationRequest" reply can still arrive after Dispose() has + // run. The reply handler checks this flag and returns early, instead of + // using a WebView that no longer exists. + *is_alive_ = false; + + Evas_Object* instance = webview_instance_; + webview_instance_ = nullptr; + + if (instance) { + // Detach every callback registered in InitWebView() and + // RegisterJavaScriptChannelName(). The engine instance lives until the + // deferred evas_object_del() below, while this WebView is destroyed + // right after Dispose(). Without detaching, the engine could invoke + // these callbacks on the already-destroyed WebView during that window. + evas_object_smart_callback_del(instance, "offscreen,frame,rendered", &WebView::OnFrameRendered); - evas_object_smart_callback_del(webview_instance_, "load,started", + evas_object_smart_callback_del(instance, "load,started", &WebView::OnLoadStarted); - evas_object_smart_callback_del(webview_instance_, "load,finished", + evas_object_smart_callback_del(instance, "load,finished", &WebView::OnLoadFinished); - evas_object_smart_callback_del(webview_instance_, "load,progress", + evas_object_smart_callback_del(instance, "load,progress", &WebView::OnProgress); - evas_object_smart_callback_del(webview_instance_, "load,error", + evas_object_smart_callback_del(instance, "load,error", &WebView::OnLoadError); - evas_object_smart_callback_del(webview_instance_, "console,message", + evas_object_smart_callback_del(instance, "console,message", &WebView::OnConsoleMessage); - evas_object_smart_callback_del(webview_instance_, - "policy,navigation,decide", + evas_object_smart_callback_del(instance, "policy,navigation,decide", &WebView::OnNavigationPolicy); - evas_object_smart_callback_del(webview_instance_, "url,changed", + evas_object_smart_callback_del(instance, "policy,response,decide", + &WebView::OnResponsePolicy); + evas_object_smart_callback_del(instance, "url,changed", &WebView::OnUrlChange); - evas_object_del(webview_instance_); + EwkInternalApiBinding::GetInstance().view.OnJavaScriptAlert( + instance, nullptr, nullptr); + EwkInternalApiBinding::GetInstance().view.OnJavaScriptConfirm( + instance, nullptr, nullptr); + EwkInternalApiBinding::GetInstance().view.OnJavaScriptPrompt( + instance, nullptr, nullptr); + evas_object_data_del(instance, kEwkInstance); + + // Cancel any in-flight load and pause the page so it stops running while + // the deferred teardown below is pending. + ewk_view_stop(instance); + ewk_view_suspend(instance); } + // Stop handing out engine-owned TBM surfaces to the raster thread, and + // detach the buffer pool so its GPU surface descriptors outlive this + // object for any raster-thread frame still in flight. + std::unique_ptr pool; + { + std::lock_guard lock(mutex_); + is_disposing_ = true; + working_surface_ = nullptr; + candidate_surface_ = nullptr; + rendered_surface_ = nullptr; + pool = std::move(tbm_pool_); + } + + // The TBM surfaces backing the texture are owned by the web engine and are + // freed when the engine view is deleted. Deleting the view while an + // in-flight raster-thread frame is still reading one of those surfaces is a + // use-after-free (the emulator's SW rendering path reads the buffer on the + // CPU), crashing with SIGSEGV during EWebView teardown. The embedder tears + // the external texture down on the render thread only after any in-flight + // frame callback has completed and then invokes this completion callback, + // so evas_object_del() (and the release of the descriptor-owning buffer + // pool) is deferred until then. The callback fires on the render thread; + // evas_object_del() must run on the main thread, so hop back via + // ecore_main_loop_thread_safe_call_async(). + struct TeardownContext { + Evas_Object* instance; + std::unique_ptr pool; + }; + auto* context = new TeardownContext{instance, std::move(pool)}; + texture_registrar_->UnregisterTexture(GetTextureId(), [context]() { + ecore_main_loop_thread_safe_call_async( + [](void* data) { + auto* context = static_cast(data); + if (context->instance) { +#if defined(TV_PROFILE) && (defined(__x86_64__) || defined(__i386__)) + // On the Tizen 10.0 TV emulator image (the only TV + x86_64 + // target), deleting the ewk view crashes with SIGSEGV inside + // chromium-efl's ~SelectionControllerEfl(): after unsubscribing + // VCONFKEY_LANGSET it calls HideHandleAndContextMenu() -> + // CancelContextMenu(), which dereferences the WebContents that is + // already being destructed. That is engine code this plugin + // cannot fix, so hide the (already stopped and suspended) view + // and intentionally leak it instead of crashing. All other + // targets (arm/arm64 devices, the 32-bit x86 emulator, and the + // common-profile x86_64 emulator, whose engines are unaffected) + // delete normally. + evas_object_hide(context->instance); +#else + evas_object_del(context->instance); +#endif + } + delete context; + }, + context); + }); + // ewk_shutdown(); - disposed_ = true; } void WebView::Offset(double left, double top) { @@ -331,9 +427,17 @@ bool WebView::SendKey(const char* key, const char* string, const char* compose, return true; } -void WebView::Resume() { ewk_view_resume(webview_instance_); } +void WebView::Resume() { + if (webview_instance_) { + ewk_view_resume(webview_instance_); + } +} -void WebView::Stop() { ewk_view_stop(webview_instance_); } +void WebView::Stop() { + if (webview_instance_) { + ewk_view_stop(webview_instance_); + } +} void WebView::SetDirection(int direction) { // TODO: Implement if necessary. @@ -454,6 +558,12 @@ void WebView::HandleWebViewMethodCall(const FlMethodCall& method_call, const std::string& method_name = method_call.method_name(); const flutter::EncodableValue* arguments = method_call.arguments(); + if (disposed_) { + result->Error("Invalid operation", + "The webview instance has been disposed."); + return; + } + if (method_name == "setEnginePolicy") { const auto* engine_policy = std::get_if(arguments); if (engine_policy) { @@ -751,6 +861,9 @@ void WebView::HandleCookieMethodCall(const FlMethodCall& method_call, FlutterDesktopGpuSurfaceDescriptor* WebView::ObtainGpuSurface(size_t width, size_t height) { std::lock_guard lock(mutex_); + if (is_disposing_ || !tbm_pool_) { + return nullptr; + } if (!candidate_surface_) { if (rendered_surface_) { return rendered_surface_->GpuSurface(); @@ -770,6 +883,9 @@ void WebView::OnFrameRendered(void* data, Evas_Object* obj, void* event_info) { WebView* webview = static_cast(data); std::lock_guard lock(webview->mutex_); + if (webview->is_disposing_ || !webview->tbm_pool_) { + return; + } if (!webview->working_surface_) { webview->working_surface_ = webview->tbm_pool_->GetAvailableBuffer(); webview->working_surface_->UseExternalBuffer(); @@ -871,14 +987,14 @@ void WebView::OnNavigationPolicy(void* data, Evas_Object* obj, {flutter::EncodableValue("isForMainFrame"), flutter::EncodableValue(true)}, }; - auto result = std::make_unique(webview); + auto result = + std::make_unique(webview, webview->is_alive_); webview->navigation_delegate_channel_->InvokeMethod( "navigationRequest", std::make_unique(args), std::move(result)); } -void WebView::OnResponsePolicy(void* data, Evas_Object* obj, - void* event_info) { +void WebView::OnResponsePolicy(void* data, Evas_Object* obj, void* event_info) { WebView* webview = static_cast(data); Ewk_Policy_Decision* policy_decision = static_cast(event_info); @@ -892,8 +1008,7 @@ void WebView::OnResponsePolicy(void* data, Evas_Object* obj, return; } flutter::EncodableMap args = { - {flutter::EncodableValue("url"), - flutter::EncodableValue(url ? url : "")}, + {flutter::EncodableValue("url"), flutter::EncodableValue(url ? url : "")}, {flutter::EncodableValue("statusCode"), flutter::EncodableValue(status_code)}, }; @@ -926,7 +1041,9 @@ void WebView::OnJavaScriptMessage(Evas_Object* obj, if (obj) { WebView* webview = static_cast(evas_object_data_get(obj, kEwkInstance)); - if (webview->webview_channel_) { + // The data key is removed in Dispose(), so a message arriving during the + // deferred teardown yields nullptr here rather than a dangling pointer. + if (webview && webview->webview_channel_) { std::string channel_name(message.name); std::string message_body(static_cast(message.body)); diff --git a/packages/webview_flutter/tizen/src/webview.h b/packages/webview_flutter/tizen/src/webview.h index ac47a9bd6..e0999f3ee 100644 --- a/packages/webview_flutter/tizen/src/webview.h +++ b/packages/webview_flutter/tizen/src/webview.h @@ -118,6 +118,14 @@ class WebView : public PlatformView { std::mutex mutex_; std::unique_ptr tbm_pool_; bool disposed_ = false; + // Set under mutex_ at the start of Dispose(). The raster thread checks it + // in ObtainGpuSurface() and stops being handed engine-owned TBM surfaces + // that are about to be freed by the deferred evas_object_del(). + bool is_disposing_ = false; + // Set to false at the start of Dispose(). A pending "navigationRequest" + // reply from Dart (resolved asynchronously) captures a copy and checks it + // before dereferencing this WebView, avoiding a use-after-free. + std::shared_ptr is_alive_ = std::make_shared(true); Ewk_Mouse_Button_Type mouse_button_type_ = (Ewk_Mouse_Button_Type)0; bool scrollbar_enabled_ = true; }; From bd27a24560081d49e8b418a6ef818bab120a8ac7 Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Mon, 27 Jul 2026 13:40:24 +0900 Subject: [PATCH 6/7] [webview_flutter_tizen] Fix WebView disposal races, including a buffer-pool UAF - Fix a use-after-free in BufferPool: the engine's release_callback for an in-flight frame can fire on the raster thread after the owning BufferUnit has already been destroyed on the platform thread, dereferencing freed memory. Track live BufferUnits in a mutex-guarded registry and have the callback check it before touching the buffer. tbm_pool_ is now a shared_ptr so its lifetime extends through the deferred teardown below. - Replace ecore_main_loop_thread_safe_call_async() with GLib, following the Ecore removal in #1033 / #1045 / #1046. Must be g_timeout_add_full() at G_PRIORITY_HIGH, not g_idle_add(): an idle source runs too late and lets the delete race the raster thread. - Narrow the TV_PROFILE compile-time macro to a runtime getenv("ELM_PROFILE") check for the same evas_object_hide()-instead-of-del() workaround. Still needed: even with the buffer-pool fix above, evas_object_del() can intermittently crash the raster thread on the Tizen 10.0 TV emulator, and this replaces the compile-time check the review flagged. - Trim the disposal comments down to the constraints; the ordering rationale moves to the PR description. Verified via flutter-tizen drive: - TV 10.0 x86_64 emulator: 8/8 consecutive runs green (0 crashes). - Real TV device (armv7l): one full clean run (19/19); further repeats hit app-launch failures unrelated to this change. I will create a new issue for this situation. --- packages/webview_flutter/CHANGELOG.md | 6 +- .../webview_flutter/tizen/src/buffer_pool.cc | 27 +++++- packages/webview_flutter/tizen/src/webview.cc | 90 ++++++++----------- packages/webview_flutter/tizen/src/webview.h | 12 ++- 4 files changed, 69 insertions(+), 66 deletions(-) diff --git a/packages/webview_flutter/CHANGELOG.md b/packages/webview_flutter/CHANGELOG.md index 13febe4ce..aa832b8cd 100644 --- a/packages/webview_flutter/CHANGELOG.md +++ b/packages/webview_flutter/CHANGELOG.md @@ -2,8 +2,10 @@ * Implement `clearLocalStorage`. * Implement `onHttpError` for the navigation delegate. -* Fix races and use-after-frees on WebView disposal, and avoid a web engine - teardown crash on the Tizen 10.0 TV emulator. +* Fix races and use-after-frees on WebView disposal, including a buffer-pool + use-after-free on the raster thread. +* Replace the Ecore main loop API with GLib. +* Narrow the TV emulator teardown workaround to a runtime profile check. ## 0.10.0 diff --git a/packages/webview_flutter/tizen/src/buffer_pool.cc b/packages/webview_flutter/tizen/src/buffer_pool.cc index ed8fde021..28a2dd82f 100644 --- a/packages/webview_flutter/tizen/src/buffer_pool.cc +++ b/packages/webview_flutter/tizen/src/buffer_pool.cc @@ -4,11 +4,31 @@ #include "buffer_pool.h" +#include +#include + #include "log.h" -BufferUnit::BufferUnit(int32_t width, int32_t height) { Reset(width, height); } +namespace { +// Tracks live BufferUnits so the engine's release_callback (below) can detect +// one that was already destroyed instead of dereferencing freed memory. +std::set active_buffers; +std::mutex active_buffers_mutex; +} // namespace + +BufferUnit::BufferUnit(int32_t width, int32_t height) { + { + std::lock_guard lock(active_buffers_mutex); + active_buffers.insert(this); + } + Reset(width, height); +} BufferUnit::~BufferUnit() { + { + std::lock_guard lock(active_buffers_mutex); + active_buffers.erase(this); + } if (tbm_surface_ && !use_external_buffer_) { tbm_surface_destroy(tbm_surface_); tbm_surface_ = nullptr; @@ -76,7 +96,10 @@ void BufferUnit::Reset(int32_t width, int32_t height) { gpu_surface_->handle = tbm_surface_; gpu_surface_->release_callback = [](void* release_context) { BufferUnit* buffer = reinterpret_cast(release_context); - buffer->UnmarkInUse(); + std::lock_guard lock(active_buffers_mutex); + if (active_buffers.find(buffer) != active_buffers.end()) { + buffer->UnmarkInUse(); + } }; gpu_surface_->release_context = this; } diff --git a/packages/webview_flutter/tizen/src/webview.cc b/packages/webview_flutter/tizen/src/webview.cc index d1be2db7f..cc589ca9a 100644 --- a/packages/webview_flutter/tizen/src/webview.cc +++ b/packages/webview_flutter/tizen/src/webview.cc @@ -4,13 +4,16 @@ #include "webview.h" -#include #include #include #include #include +#include #include +#include +#include + #include "buffer_pool.h" #include "log.h" #include "webview_factory.h" @@ -45,12 +48,8 @@ std::string ConvertLogLevelToString(Ewk_Console_Message_Level level) { class NavigationRequestResult : public FlMethodResult { public: - // |alive| is the WebView's is_alive_ flag. Dart resolves the - // "navigationRequest" method call asynchronously (it round-trips through - // the Dart navigation delegate), so this result's completion can run well - // after the WebView that created it has been disposed; |webview_| would - // then be a dangling pointer. Checking |alive| before dereferencing it - // avoids a use-after-free in that case. + // Dart resolves "navigationRequest" asynchronously, so completion can run + // after |webview| is destroyed; |alive| gates every dereference. NavigationRequestResult(WebView* webview, std::shared_ptr alive) : webview_(webview), alive_(std::move(alive)) {} @@ -121,7 +120,7 @@ WebView::WebView(flutter::PluginRegistrar* registrar, int view_id, return; } - tbm_pool_ = std::make_unique(width, height); + tbm_pool_ = std::make_shared(width, height); texture_variant_ = std::make_unique(flutter::GpuSurfaceTexture( @@ -192,21 +191,14 @@ void WebView::Dispose() { return; } disposed_ = true; - - // A Dart "navigationRequest" reply can still arrive after Dispose() has - // run. The reply handler checks this flag and returns early, instead of - // using a WebView that no longer exists. *is_alive_ = false; Evas_Object* instance = webview_instance_; webview_instance_ = nullptr; if (instance) { - // Detach every callback registered in InitWebView() and - // RegisterJavaScriptChannelName(). The engine instance lives until the - // deferred evas_object_del() below, while this WebView is destroyed - // right after Dispose(). Without detaching, the engine could invoke - // these callbacks on the already-destroyed WebView during that window. + // The engine instance outlives this WebView until the deferred + // evas_object_del() below, so every callback must be detached here. evas_object_smart_callback_del(instance, "offscreen,frame,rendered", &WebView::OnFrameRendered); evas_object_smart_callback_del(instance, "load,started", @@ -233,16 +225,12 @@ void WebView::Dispose() { instance, nullptr, nullptr); evas_object_data_del(instance, kEwkInstance); - // Cancel any in-flight load and pause the page so it stops running while - // the deferred teardown below is pending. + // Stop the page so it cannot run while the deferred teardown is pending. ewk_view_stop(instance); ewk_view_suspend(instance); } - // Stop handing out engine-owned TBM surfaces to the raster thread, and - // detach the buffer pool so its GPU surface descriptors outlive this - // object for any raster-thread frame still in flight. - std::unique_ptr pool; + std::shared_ptr pool; { std::lock_guard lock(mutex_); is_disposing_ = true; @@ -252,47 +240,36 @@ void WebView::Dispose() { pool = std::move(tbm_pool_); } - // The TBM surfaces backing the texture are owned by the web engine and are - // freed when the engine view is deleted. Deleting the view while an - // in-flight raster-thread frame is still reading one of those surfaces is a - // use-after-free (the emulator's SW rendering path reads the buffer on the - // CPU), crashing with SIGSEGV during EWebView teardown. The embedder tears - // the external texture down on the render thread only after any in-flight - // frame callback has completed and then invokes this completion callback, - // so evas_object_del() (and the release of the descriptor-owning buffer - // pool) is deferred until then. The callback fires on the render thread; - // evas_object_del() must run on the main thread, so hop back via - // ecore_main_loop_thread_safe_call_async(). + // evas_object_del() frees TBM surfaces the raster thread may still be + // reading, so defer it until the embedder confirms the texture is torn + // down. That confirmation fires on the render thread, hence the hop below. struct TeardownContext { Evas_Object* instance; - std::unique_ptr pool; + std::shared_ptr pool; }; auto* context = new TeardownContext{instance, std::move(pool)}; texture_registrar_->UnregisterTexture(GetTextureId(), [context]() { - ecore_main_loop_thread_safe_call_async( - [](void* data) { + // Must stay a high-priority timeout: g_idle_add() runs too late and the + // delete then races the raster thread on the TV emulator. + g_timeout_add_full( + G_PRIORITY_HIGH, 0, + [](gpointer data) -> gboolean { auto* context = static_cast(data); if (context->instance) { -#if defined(TV_PROFILE) && (defined(__x86_64__) || defined(__i386__)) - // On the Tizen 10.0 TV emulator image (the only TV + x86_64 - // target), deleting the ewk view crashes with SIGSEGV inside - // chromium-efl's ~SelectionControllerEfl(): after unsubscribing - // VCONFKEY_LANGSET it calls HideHandleAndContextMenu() -> - // CancelContextMenu(), which dereferences the WebContents that is - // already being destructed. That is engine code this plugin - // cannot fix, so hide the (already stopped and suspended) view - // and intentionally leak it instead of crashing. All other - // targets (arm/arm64 devices, the 32-bit x86 emulator, and the - // common-profile x86_64 emulator, whose engines are unaffected) - // delete normally. - evas_object_hide(context->instance); -#else - evas_object_del(context->instance); -#endif + const char* profile = getenv("ELM_PROFILE"); + if (profile && strcmp(profile, "tv") == 0) { + // TODO: evas_object_del() still crashes the raster thread + // intermittently on the Tizen 10.0 TV emulator, so leak the view + // there instead until the engine is fixed. + evas_object_hide(context->instance); + } else { + evas_object_del(context->instance); + } } - delete context; + return G_SOURCE_REMOVE; }, - context); + context, + [](gpointer data) { delete static_cast(data); }); }); // ewk_shutdown(); @@ -468,6 +445,9 @@ bool WebView::InitWebView() { // temporarily comment out ewk_init() and ewk_shutdown(). It can be reverted // depending on updates to chromium-efl. // ewk_init(); + + // Not freed on disposal: ecore_evas_free() would eglTerminate() the EGL + // display shared with the Flutter renderer and kill the process. Ecore_Evas* evas = ecore_evas_new("wayland_egl", 0, 0, 1, 1, 0); webview_instance_ = ewk_view_add(ecore_evas_get(evas)); diff --git a/packages/webview_flutter/tizen/src/webview.h b/packages/webview_flutter/tizen/src/webview.h index e0999f3ee..33ee206a0 100644 --- a/packages/webview_flutter/tizen/src/webview.h +++ b/packages/webview_flutter/tizen/src/webview.h @@ -116,15 +116,13 @@ class WebView : public PlatformView { std::unique_ptr navigation_delegate_channel_; std::unique_ptr texture_variant_; std::mutex mutex_; - std::unique_ptr tbm_pool_; + std::shared_ptr tbm_pool_; bool disposed_ = false; - // Set under mutex_ at the start of Dispose(). The raster thread checks it - // in ObtainGpuSurface() and stops being handed engine-owned TBM surfaces - // that are about to be freed by the deferred evas_object_del(). + // Guarded by mutex_. Keeps the raster thread from being handed TBM surfaces + // that the deferred evas_object_del() is about to free. bool is_disposing_ = false; - // Set to false at the start of Dispose(). A pending "navigationRequest" - // reply from Dart (resolved asynchronously) captures a copy and checks it - // before dereferencing this WebView, avoiding a use-after-free. + // Copied into pending async Dart replies so they can detect a WebView that + // was destroyed before the reply arrived. std::shared_ptr is_alive_ = std::make_shared(true); Ewk_Mouse_Button_Type mouse_button_type_ = (Ewk_Mouse_Button_Type)0; bool scrollbar_enabled_ = true; From 552c399e91c8b47f2d27fad001c4f3aa47d6c41d Mon Sep 17 00:00:00 2001 From: Seungsoo Lee Date: Thu, 6 Aug 2026 20:14:00 +0900 Subject: [PATCH 7/7] [webview_flutter_tizen] Document that multiple WebViews are unsupported Using more than one WebView at the same time hits internal native resource lifetime and disposal issues; a single WebView works as expected. --- packages/webview_flutter/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/webview_flutter/README.md b/packages/webview_flutter/README.md index 6dc6ef0dc..bec15f044 100644 --- a/packages/webview_flutter/README.md +++ b/packages/webview_flutter/README.md @@ -96,3 +96,5 @@ await controller.setVerticalScrollBarEnabled(false); // This will show both vertical and horizontal scrollbars await controller.setHorizontalScrollBarEnabled(true); ``` + +- Using more than one `WebView` at the same time is not supported due to internal implementation issues around native resource lifetime and disposal. Using a single `WebView` at a time works as expected.