diff --git a/modules/ensemble/lib/widget/webview/web/webviewstate.dart b/modules/ensemble/lib/widget/webview/web/webviewstate.dart index 73a8685ba..eb4dc6154 100644 --- a/modules/ensemble/lib/widget/webview/web/webviewstate.dart +++ b/modules/ensemble/lib/widget/webview/web/webviewstate.dart @@ -30,6 +30,18 @@ class WebViewState extends EWidgetState { // widget.controller.webViewController = ControllerImpl(_iframeElement); } + @override + void didUpdateWidget(covariant EnsembleWebView oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.controller.url != widget.controller.url || + oldWidget.controller.html != widget.controller.html || + oldWidget.controller.htmlBaseUrl != widget.controller.htmlBaseUrl || + oldWidget.controller.injectedJavaScript != + widget.controller.injectedJavaScript) { + _loadIframeContent(); + } + } + @override void dispose() { _cleanupIFrame(); @@ -45,10 +57,9 @@ class WebViewState extends EWidgetState { HtmlElementView buildIFrameWidget() { _iframeElement.style.width = '100%'; _iframeElement.style.height = '100%'; - - _iframeElement.src = widget.controller.url ?? ''; _iframeElement.style.border = 'none'; + _loadIframeContent(); _setupJavaScriptChannels(); // ignore: undefined_prefixed_name @@ -63,6 +74,22 @@ class WebViewState extends EWidgetState { ); } + void _loadIframeContent() { + final html = widget.controller.html; + if (html != null && html.isNotEmpty) { + _iframeElement.removeAttribute('src'); + _iframeElement.srcdoc = buildIframeHtmlDocument( + html: html, + htmlBaseUrl: widget.controller.htmlBaseUrl, + injectedJavaScript: widget.controller.injectedJavaScript, + ); + return; + } + + _iframeElement.removeAttribute('srcdoc'); + _iframeElement.src = widget.controller.url ?? ''; + } + void _setupJavaScriptChannels() { window.addEventListener('message', (event) { if (event is MessageEvent) { @@ -72,7 +99,13 @@ class WebViewState extends EWidgetState { } void _handleWebMessage(MessageEvent event) { - if (event.origin != Uri.parse(widget.controller.url ?? '').origin) { + final expectedOrigin = resolvedWebViewOrigin( + url: widget.controller.url, + htmlBaseUrl: widget.controller.htmlBaseUrl, + ); + if (expectedOrigin != null && + expectedOrigin.isNotEmpty && + event.origin != expectedOrigin) { return; } @@ -106,10 +139,14 @@ class WebViewState extends EWidgetState { } } + bool get _hasContent => + (widget.controller.url != null && widget.controller.url!.isNotEmpty) || + (widget.controller.html != null && widget.controller.html!.isNotEmpty); + @override Widget buildWidget(BuildContext context) { // WebView's height will be the same as the HTML height - if (widget.controller.url == null) { + if (!_hasContent) { return const Text('Loading...'); } return SizedBox(height: widget.controller.height, child: htmlView!); diff --git a/modules/ensemble/lib/widget/webview/webview.dart b/modules/ensemble/lib/widget/webview/webview.dart index 410824afc..874784964 100644 --- a/modules/ensemble/lib/widget/webview/webview.dart +++ b/modules/ensemble/lib/widget/webview/webview.dart @@ -219,6 +219,48 @@ Map parseYamlMap(value) { return result; } +/// Default base URL for inline HTML WebViews when [htmlBaseUrl] is not set. +const String kWebViewHtmlDefaultBaseUrl = 'https://ensemble.local/'; + +/// Resolve the expected postMessage origin for a WebView URL or inline HTML base. +@visibleForTesting +String? resolvedWebViewOrigin({String? url, String? htmlBaseUrl}) { + if (url != null && url.isNotEmpty) { + return Uri.tryParse(url)?.origin; + } + final base = htmlBaseUrl?.trim(); + return Uri.tryParse( + (base == null || base.isEmpty) ? kWebViewHtmlDefaultBaseUrl : base, + )?.origin; +} + +/// Build a full HTML document for iframe `srcdoc` rendering on web. +@visibleForTesting +String buildIframeHtmlDocument({ + required String html, + String? htmlBaseUrl, + String? injectedJavaScript, +}) { + final base = htmlBaseUrl?.trim(); + final resolvedBase = (base == null || base.isEmpty) + ? kWebViewHtmlDefaultBaseUrl + : base; + final buffer = StringBuffer() + ..write('') + ..write(html); + final script = injectedJavaScript?.trim(); + if (script != null && script.isNotEmpty) { + buffer.write(''); + } + buffer.write(''); + return buffer.toString(); +} + +String _escapeHtmlAttribute(String value) => + value.replaceAll('&', '&').replaceAll('"', '"'); + enum HeaderMatchType { CONTAINS, EXACT, REGEX } class HeaderOverrideRule { final String urlPattern; diff --git a/modules/ensemble/test/webview_html_content_test.dart b/modules/ensemble/test/webview_html_content_test.dart new file mode 100644 index 000000000..32e373cfb --- /dev/null +++ b/modules/ensemble/test/webview_html_content_test.dart @@ -0,0 +1,54 @@ +import 'package:ensemble/widget/webview/webview.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('inline WebView HTML helpers', () { + test('resolvedWebViewOrigin prefers url over html base', () { + expect( + resolvedWebViewOrigin( + url: 'https://example.com/page', + htmlBaseUrl: 'https://ignored.test/', + ), + 'https://example.com', + ); + }); + + test('resolvedWebViewOrigin falls back to html base url', () { + expect( + resolvedWebViewOrigin(htmlBaseUrl: 'https://cdn.example/app/'), + 'https://cdn.example', + ); + }); + + test('resolvedWebViewOrigin uses default base for inline html', () { + expect( + resolvedWebViewOrigin(), + Uri.parse(kWebViewHtmlDefaultBaseUrl).origin, + ); + }); + + test('buildIframeHtmlDocument wraps html with base and injected script', () { + final document = buildIframeHtmlDocument( + html: '

Hello

', + htmlBaseUrl: 'https://app.example/', + injectedJavaScript: 'console.log("ready");', + ); + + expect(document, contains('')); + expect(document, contains('

Hello

')); + expect(document, contains('')); + }); + + test('buildIframeHtmlDocument escapes base url attribute characters', () { + final document = buildIframeHtmlDocument( + html: '

Hi

', + htmlBaseUrl: 'https://app.example/path?a=1&b=2"', + ); + + expect( + document, + contains(''), + ); + }); + }); +}