Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 41 additions & 4 deletions modules/ensemble/lib/widget/webview/web/webviewstate.dart
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,18 @@ class WebViewState extends EWidgetState<EnsembleWebView> {
// 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();
Expand All @@ -45,10 +57,9 @@ class WebViewState extends EWidgetState<EnsembleWebView> {
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
Expand All @@ -63,6 +74,22 @@ class WebViewState extends EWidgetState<EnsembleWebView> {
);
}

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) {
Expand All @@ -72,7 +99,13 @@ class WebViewState extends EWidgetState<EnsembleWebView> {
}

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

Expand Down Expand Up @@ -106,10 +139,14 @@ class WebViewState extends EWidgetState<EnsembleWebView> {
}
}

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!);
Expand Down
42 changes: 42 additions & 0 deletions modules/ensemble/lib/widget/webview/webview.dart
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,48 @@ Map<String, String> 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('<!DOCTYPE html><html><head><base href="')
..write(_escapeHtmlAttribute(resolvedBase))
..write('"></head><body>')
..write(html);
final script = injectedJavaScript?.trim();
if (script != null && script.isNotEmpty) {
buffer.write('<script>$script</script>');
}
buffer.write('</body></html>');
return buffer.toString();
}

String _escapeHtmlAttribute(String value) =>
value.replaceAll('&', '&amp;').replaceAll('"', '&quot;');

enum HeaderMatchType { CONTAINS, EXACT, REGEX }
class HeaderOverrideRule {
final String urlPattern;
Expand Down
54 changes: 54 additions & 0 deletions modules/ensemble/test/webview_html_content_test.dart
Original file line number Diff line number Diff line change
@@ -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: '<p>Hello</p>',
htmlBaseUrl: 'https://app.example/',
injectedJavaScript: 'console.log("ready");',
);

expect(document, contains('<base href="https://app.example/">'));
expect(document, contains('<p>Hello</p>'));
expect(document, contains('<script>console.log("ready");</script>'));
});

test('buildIframeHtmlDocument escapes base url attribute characters', () {
final document = buildIframeHtmlDocument(
html: '<p>Hi</p>',
htmlBaseUrl: 'https://app.example/path?a=1&b=2"',
);

expect(
document,
contains('<base href="https://app.example/path?a=1&amp;b=2&quot;">'),
);
});
});
}
Loading