conduit/lib/shared/widgets/web_content_embed.dart
cogwheel 332f0c75bb
Some checks failed
L10n / l10n (push) Has been cancelled
feat: unify app surfaces with native grouped design (#628)
Squash-merge the reviewed native UI, sidebar, onboarding, direct connection, Hermes, workspace, theming, haptics, and consistency updates.
2026-08-15 18:37:46 +05:30

1202 lines
34 KiB
Dart

import 'dart:async';
import 'dart:collection';
import 'dart:convert';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.dart';
import 'package:conduit/l10n/app_localizations.dart';
import '../theme/theme_extensions.dart';
import '../utils/external_link_launcher.dart';
import 'webview_content_height.dart';
const _embedDefaultHeight = 360.0;
const _embedFallbackHeight = 160.0;
const _embedMinHeight = 220.0;
const _embedMaxHeight = 900.0;
class WebContentEmbed extends StatefulWidget {
const WebContentEmbed({
super.key,
required this.source,
this.argsText = '',
this.deferUntilExpanded = true,
this.initiallyExpanded = false,
this.showChrome = true,
this.fillAvailableHeight = false,
this.previewTitle,
this.previewDescription,
@visibleForTesting this.debugTreatAsSupported,
@visibleForTesting this.debugSeedControllerForTesting = false,
@visibleForTesting this.debugOnControllerReset,
});
final String source;
final String argsText;
final bool deferUntilExpanded;
final bool initiallyExpanded;
final bool showChrome;
final bool fillAvailableHeight;
final String? previewTitle;
final String? previewDescription;
@visibleForTesting
final bool? debugTreatAsSupported;
@visibleForTesting
final bool debugSeedControllerForTesting;
@visibleForTesting
final VoidCallback? debugOnControllerReset;
@visibleForTesting
static String debugWrapHtmlDocument(
String source, {
String argsText = '',
bool fillAvailableHeight = false,
}) {
return _WebContentEmbedState._wrapHtmlDocument(
source,
argsText: argsText,
fillAvailableHeight: fillAvailableHeight,
);
}
@visibleForTesting
static String debugWrapRemoteDocument(
String source, {
bool fillAvailableHeight = false,
}) => _WebContentEmbedState._wrapRemoteDocument(
Uri.parse(source),
fillAvailableHeight: fillAvailableHeight,
);
@visibleForTesting
static String debugFrameBootstrapScript(String argsText) =>
_WebContentEmbedState._frameBootstrapScript(argsText);
@visibleForTesting
static String debugAllFrameBootstrapScript() =>
_WebContentEmbedState._allFrameBootstrapScript;
@visibleForTesting
static String debugInlineArgumentsScript(String argsText) =>
_WebContentEmbedState._inlineArgumentsScript(argsText);
@visibleForTesting
static Future<bool> debugOpenExternalLink(
String rawUrl, {
required Future<bool> Function(String url) launcher,
}) => _WebContentEmbedState._openAllowedExternalLink(
rawUrl,
launcher: launcher,
);
@visibleForTesting
static bool debugShouldOpenNavigationExternally({
required String targetUrl,
String? currentUrl,
required bool userActivated,
}) => _WebContentEmbedState._shouldOpenNavigationExternally(
targetUrl: targetUrl,
currentUrl: currentUrl,
userActivated: userActivated,
);
@visibleForTesting
static bool debugShouldAllowAutomaticNavigation(String targetUrl) =>
_WebContentEmbedState._shouldAllowAutomaticNavigation(targetUrl);
@visibleForTesting
static bool debugShouldHandleCreateWindow({
required bool requestIsCurrent,
required String? targetUrl,
}) => _WebContentEmbedState._shouldHandleCreateWindow(
requestIsCurrent: requestIsCurrent,
targetUrl: targetUrl,
);
@visibleForTesting
static bool debugShouldResolveMissingPopupUrl({
required bool requestIsCurrent,
required String? targetUrl,
}) => _WebContentEmbedState._shouldResolveMissingPopupUrl(
requestIsCurrent: requestIsCurrent,
targetUrl: targetUrl,
);
@visibleForTesting
static bool debugHasUserActivationEvidence({
required bool? hasGesture,
required bool linkActivated,
}) => _WebContentEmbedState._hasUserActivationEvidence(
hasGesture: hasGesture,
linkActivated: linkActivated,
);
@visibleForTesting
static bool debugShouldAllowInlineFragmentNavigation(String targetUrl) =>
_WebContentEmbedState._shouldAllowInlineFragmentNavigation(targetUrl);
@visibleForTesting
static bool debugShouldSurfaceLoadFailure({
required bool isForMainFrame,
required Iterable<String> remoteEmbedUrls,
required String requestUrl,
}) => _WebContentEmbedState._shouldSurfaceLoadFailure(
isForMainFrame: isForMainFrame,
remoteEmbedUrls: remoteEmbedUrls,
requestUrl: requestUrl,
);
@override
State<WebContentEmbed> createState() => _WebContentEmbedState();
}
class _WebContentEmbedState extends State<WebContentEmbed> {
final Set<Factory<OneSequenceGestureRecognizer>> _gestureRecognizers =
<Factory<OneSequenceGestureRecognizer>>{
Factory<OneSequenceGestureRecognizer>(() => EagerGestureRecognizer()),
};
InAppWebViewController? _controller;
final Set<HeadlessInAppWebView> _popupWebViews = {};
final Set<String> _remoteFrameDocumentUrls = {};
double _height = _embedDefaultHeight;
bool _isLoading = true;
bool _loadScheduled = false;
bool _retryLoadScheduled = false;
String? _loadError;
int _loadRequestId = 0;
late bool _isExpanded;
bool _debugHasSeededController = false;
bool _shouldRenderWebView = false;
bool get _isRunningInTestEnvironment {
return WidgetsBinding.instance.runtimeType.toString().contains(
'TestWidgetsFlutterBinding',
);
}
bool get _isSupported {
if (widget.debugTreatAsSupported != null) {
return widget.debugTreatAsSupported!;
}
if (kIsWeb) {
return false;
}
if (_isRunningInTestEnvironment) {
return false;
}
return switch (defaultTargetPlatform) {
TargetPlatform.android ||
TargetPlatform.iOS ||
TargetPlatform.macOS => true,
_ => false,
};
}
bool get _isRemoteUrl {
final raw = widget.source.trim();
return raw.startsWith('http://') ||
raw.startsWith('https://') ||
raw.startsWith('//');
}
Uri? get _resolvedRemoteUri {
if (!_isRemoteUrl) {
return null;
}
return Uri.tryParse(
widget.source.startsWith('//') ? 'https:${widget.source}' : widget.source,
);
}
bool get _hasController =>
_controller != null || _debugHasSeededController || _shouldRenderWebView;
String get _unsupportedMessage {
if (_isRunningInTestEnvironment) {
return 'Embedded content preview is unavailable in widget tests.';
}
return 'Embedded content is available on supported mobile and macOS builds.';
}
@override
void initState() {
super.initState();
_resetRemoteFrameDocumentUrls();
_isExpanded = widget.initiallyExpanded || !widget.deferUntilExpanded;
if (widget.debugSeedControllerForTesting) {
_debugHasSeededController = true;
_isLoading = false;
}
}
@override
void didUpdateWidget(covariant WebContentEmbed oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.source != widget.source ||
oldWidget.argsText != widget.argsText ||
oldWidget.deferUntilExpanded != widget.deferUntilExpanded ||
oldWidget.initiallyExpanded != widget.initiallyExpanded ||
oldWidget.fillAvailableHeight != widget.fillAvailableHeight) {
_loadScheduled = false;
_retryLoadScheduled = false;
_isExpanded = widget.initiallyExpanded || !widget.deferUntilExpanded;
_resetControllerState(isLoading: _isExpanded);
if (_isExpanded) {
unawaited(_initializeController(reuseCurrentRequestId: true));
}
}
}
void _resetControllerState({required bool isLoading}) {
_disposePopupWebViews();
_resetRemoteFrameDocumentUrls();
final hadController = _hasController;
if (mounted) {
setState(() {
_loadRequestId += 1;
_controller = null;
_debugHasSeededController = false;
_shouldRenderWebView = false;
_height = _embedDefaultHeight;
_isLoading = isLoading;
_loadError = null;
});
} else {
_loadRequestId += 1;
_controller = null;
_debugHasSeededController = false;
_shouldRenderWebView = false;
_height = _embedDefaultHeight;
_isLoading = isLoading;
_loadError = null;
}
if (hadController) {
widget.debugOnControllerReset?.call();
}
}
void _resetRemoteFrameDocumentUrls() {
_remoteFrameDocumentUrls.clear();
final remoteUri = _resolvedRemoteUri;
if (remoteUri != null) {
_remoteFrameDocumentUrls.add(remoteUri.toString());
}
}
void _disposePopupWebViews() {
final popupWebViews = _popupWebViews.toList(growable: false);
_popupWebViews.clear();
for (final popupWebView in popupWebViews) {
unawaited(popupWebView.dispose());
}
}
@override
void dispose() {
_disposePopupWebViews();
super.dispose();
}
void _scheduleControllerInitialization(BuildContext context) {
if (!_isExpanded ||
_loadScheduled ||
_shouldRenderWebView ||
!_isSupported) {
return;
}
if (Scrollable.recommendDeferredLoadingForContext(context)) {
if (_retryLoadScheduled) {
return;
}
_retryLoadScheduled = true;
Future<void>.delayed(const Duration(milliseconds: 250), () {
if (!mounted) {
return;
}
_retryLoadScheduled = false;
if (!_hasController && !_loadScheduled) {
setState(() {});
}
});
return;
}
_retryLoadScheduled = false;
_loadScheduled = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) {
return;
}
unawaited(_initializeController());
});
}
Future<void> _initializeController({
bool reuseCurrentRequestId = false,
}) async {
if (!_isSupported || !_isExpanded) {
_loadScheduled = false;
return;
}
if (_isRemoteUrl && _resolvedRemoteUri == null) {
_loadScheduled = false;
if (!mounted) {
return;
}
setState(() {
_isLoading = false;
_loadError = 'Unable to load embedded content.';
});
return;
}
try {
if (!reuseCurrentRequestId) {
_loadRequestId += 1;
}
setState(() {
_controller = null;
_debugHasSeededController = false;
_shouldRenderWebView = true;
_height = _embedDefaultHeight;
_isLoading = true;
_loadError = null;
});
} finally {
_loadScheduled = false;
}
}
Future<void> _handleWebViewCreated(
InAppWebViewController controller,
int requestId,
) async {
if (requestId != _loadRequestId) {
return;
}
if (mounted) {
setState(() {
_controller = controller;
});
} else {
_controller = controller;
}
try {
final remoteUri = _resolvedRemoteUri;
if (_isRemoteUrl && remoteUri == null) {
throw StateError('Invalid embed URL');
}
final document = remoteUri != null
? _wrapRemoteDocument(
remoteUri,
fillAvailableHeight: widget.fillAvailableHeight,
)
: _wrapHtmlDocument(
widget.source,
argsText: widget.argsText,
fillAvailableHeight: widget.fillAvailableHeight,
);
final baseUrl = WebUri('https://embed.conduit.local/');
await controller.loadData(
data: document,
baseUrl: baseUrl,
historyUrl: baseUrl,
);
} catch (_) {
if (!mounted || requestId != _loadRequestId) {
return;
}
setState(() {
_controller = null;
_shouldRenderWebView = false;
_isLoading = false;
_loadError = 'Unable to load embedded content.';
});
}
}
Future<NavigationActionPolicy> _handleNavigationAction(
InAppWebViewController controller,
NavigationAction navigationAction,
int requestId,
) async {
if (requestId != _loadRequestId) {
return NavigationActionPolicy.CANCEL;
}
final targetUrl = navigationAction.request.url?.toString();
if (targetUrl == null || targetUrl.isEmpty) {
return NavigationActionPolicy.CANCEL;
}
final userActivated = _isUserActivatedNavigation(navigationAction);
if (!userActivated) {
if (!_shouldAllowAutomaticNavigation(targetUrl)) {
return NavigationActionPolicy.CANCEL;
}
_rememberRemoteFrameDocumentUrl(navigationAction, targetUrl);
return NavigationActionPolicy.ALLOW;
}
if (_shouldAllowInlineFragmentNavigation(targetUrl)) {
return NavigationActionPolicy.ALLOW;
}
if (parseAllowedExternalLink(targetUrl) == null) {
return NavigationActionPolicy.CANCEL;
}
String? currentUrl;
try {
currentUrl = (await controller.getUrl())?.toString();
} catch (_) {}
if (!mounted || requestId != _loadRequestId) {
return NavigationActionPolicy.CANCEL;
}
if (!_shouldOpenNavigationExternally(
targetUrl: targetUrl,
currentUrl: currentUrl,
userActivated: userActivated,
)) {
_rememberRemoteFrameDocumentUrl(navigationAction, targetUrl);
return NavigationActionPolicy.ALLOW;
}
await _openAllowedExternalLink(targetUrl);
return NavigationActionPolicy.CANCEL;
}
void _rememberRemoteFrameDocumentUrl(
NavigationAction navigationAction,
String targetUrl,
) {
if (_isRemoteUrl && !navigationAction.isForMainFrame) {
_remoteFrameDocumentUrls.add(targetUrl);
}
}
Future<bool> _handleCreateWindow(
CreateWindowAction createWindowAction,
int requestId,
) async {
if (!mounted) {
return false;
}
final targetUrl = createWindowAction.request.url?.toString();
if (_shouldHandleCreateWindow(
requestIsCurrent: requestId == _loadRequestId,
targetUrl: targetUrl,
)) {
await _openAllowedExternalLink(targetUrl!);
return false;
}
// javaScriptCanOpenWindowsAutomatically is false, so the platform has
// already approved this popup. Do not re-gate on Android's unreliable
// hasGesture metadata when recovering its omitted URL.
if (!_shouldResolveMissingPopupUrl(
requestIsCurrent: requestId == _loadRequestId,
targetUrl: targetUrl,
)) {
return false;
}
// Android's WebChromeClient omits request.url for JavaScript window.open
// calls. Attach a non-scriptable headless WebView to the pending window so
// its first URL can be recovered, cancelled, and validated before launch.
late final HeadlessInAppWebView popupWebView;
var resolved = false;
Future<void> disposePopup() async {
if (_popupWebViews.remove(popupWebView)) {
await popupWebView.dispose();
}
}
Future<void> resolvePopupUrl(WebUri? url) async {
final rawUrl = url?.toString();
if (resolved || rawUrl == null || rawUrl.isEmpty) {
return;
}
final uri = Uri.tryParse(rawUrl);
if (uri?.scheme.toLowerCase() == 'about') {
return;
}
resolved = true;
try {
if (mounted && requestId == _loadRequestId) {
await _openAllowedExternalLink(rawUrl);
}
} finally {
await disposePopup();
}
}
popupWebView = HeadlessInAppWebView(
windowId: createWindowAction.windowId,
initialSettings: InAppWebViewSettings(
javaScriptEnabled: false,
allowContentAccess: false,
allowFileAccess: false,
allowFileAccessFromFileURLs: false,
allowUniversalAccessFromFileURLs: false,
supportMultipleWindows: false,
useShouldOverrideUrlLoading: true,
),
shouldOverrideUrlLoading: (controller, navigationAction) async {
unawaited(resolvePopupUrl(navigationAction.request.url));
return NavigationActionPolicy.CANCEL;
},
);
_popupWebViews.add(popupWebView);
try {
await popupWebView.run();
} catch (_) {
await disposePopup();
return false;
}
Future<void>.delayed(const Duration(seconds: 5), disposePopup);
return true;
}
void _surfaceLoadError(int requestId, String description) {
if (!mounted || requestId != _loadRequestId) {
return;
}
setState(() {
_controller = null;
_shouldRenderWebView = false;
_isLoading = false;
_loadError = description;
});
}
void _scheduleHeightUpdates(
InAppWebViewController controller,
int requestId,
) {
_updateHeight(controller, requestId);
for (final delay in <int>[60, 250, 600]) {
Future<void>.delayed(Duration(milliseconds: delay), () {
_updateHeight(controller, requestId);
});
}
Future<void>.delayed(const Duration(milliseconds: 900), () {
if (!mounted || requestId != _loadRequestId || !_isLoading) {
return;
}
setState(() {
_isLoading = false;
});
});
}
Future<void> _updateHeight(
InAppWebViewController controller,
int requestId,
) async {
try {
final measuredHeight = await measureWebViewContentHeight(controller);
if (!mounted ||
requestId != _loadRequestId ||
measuredHeight == null ||
measuredHeight <= 0) {
return;
}
final clampedHeight = widget.fillAvailableHeight
? _height
: measuredHeight.clamp(_embedMinHeight, _embedMaxHeight).toDouble();
setState(() {
_height = clampedHeight;
_isLoading = false;
});
} catch (_) {}
}
@override
Widget build(BuildContext context) {
final theme = context.conduitTheme;
if (!_isSupported) {
return _EmbedFallbackCard(
source: widget.source,
message: _unsupportedMessage,
);
}
if (_loadError != null) {
return _EmbedFallbackCard(source: widget.source, message: _loadError!);
}
if (!_isExpanded) {
return _EmbedDeferredCard(
title: widget.previewTitle ?? 'Embedded Preview',
description:
widget.previewDescription ??
(_isRemoteUrl
? (widget.source.startsWith('//')
? 'https:${widget.source}'
: widget.source)
: 'Load the embedded preview when you need it.'),
onOpen: () {
if (!mounted) {
return;
}
setState(() {
_isExpanded = true;
});
},
);
}
if (!_shouldRenderWebView) {
_scheduleControllerInitialization(context);
if (!widget.showChrome) {
return const Center(child: CircularProgressIndicator());
}
return const _EmbedLoadingCard();
}
final requestId = _loadRequestId;
final webView = Stack(
children: [
Positioned.fill(
child: InAppWebView(
key: ValueKey<int>(requestId),
gestureRecognizers: _gestureRecognizers,
initialUserScripts: UnmodifiableListView([
UserScript(
// This script reaches remote frames too, so it must not contain
// tool arguments. Inline srcdoc content receives its private
// argument bootstrap from _injectSandboxBootstrap instead.
source: _allFrameBootstrapScript,
injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
forMainFrameOnly: false,
allowedOriginRules: const {'*'},
),
]),
initialSettings: InAppWebViewSettings(
javaScriptEnabled: true,
// Keep script-created popups disabled. onCreateWindow therefore
// represents a platform-approved user popup request even when
// Android omits its gesture/navigation metadata.
javaScriptCanOpenWindowsAutomatically: false,
supportMultipleWindows: true,
transparentBackground: true,
useShouldOverrideUrlLoading: true,
),
onWebViewCreated: (controller) {
unawaited(_handleWebViewCreated(controller, requestId));
},
onLoadStop: (controller, _) async {
if (requestId != _loadRequestId) {
return;
}
_scheduleHeightUpdates(controller, requestId);
},
onReceivedError: (controller, request, error) {
if (!_shouldSurfaceLoadFailure(
isForMainFrame: request.isForMainFrame ?? false,
remoteEmbedUrls: _remoteFrameDocumentUrls,
requestUrl: request.url.toString(),
)) {
return;
}
_surfaceLoadError(requestId, error.description);
},
onReceivedHttpError: (controller, request, errorResponse) {
if (!_shouldSurfaceLoadFailure(
isForMainFrame: request.isForMainFrame ?? false,
remoteEmbedUrls: _remoteFrameDocumentUrls,
requestUrl: request.url.toString(),
)) {
return;
}
final statusCode = errorResponse.statusCode;
_surfaceLoadError(
requestId,
statusCode == null
? 'Unable to load embedded content.'
: 'Unable to load embedded content (HTTP $statusCode).',
);
},
shouldOverrideUrlLoading: (controller, navigationAction) =>
_handleNavigationAction(
controller,
navigationAction,
requestId,
),
onCreateWindow: (controller, createWindowAction) =>
_handleCreateWindow(createWindowAction, requestId),
),
),
if (_isLoading)
const Positioned.fill(
child: ColoredBox(
color: Colors.transparent,
child: Center(child: CircularProgressIndicator()),
),
),
],
);
final sizedWebView = widget.fillAvailableHeight
? LayoutBuilder(
builder: (context, constraints) {
if (constraints.hasBoundedHeight) {
return SizedBox.expand(child: webView);
}
return SizedBox(height: _height, child: webView);
},
)
: SizedBox(height: _height, child: webView);
if (!widget.showChrome) {
return sizedWebView;
}
return DecoratedBox(
decoration: BoxDecoration(
color: theme.cardBackground,
border: Border.all(color: theme.cardBorder),
borderRadius: BorderRadius.circular(AppBorderRadius.md),
boxShadow: theme.cardShadows,
),
child: ClipRRect(
borderRadius: BorderRadius.circular(AppBorderRadius.md),
child: sizedWebView,
),
);
}
static String _wrapHtmlDocument(
String source, {
String argsText = '',
bool fillAvailableHeight = false,
}) {
final sandboxedSource = _injectSandboxBootstrap(source, argsText: argsText);
final encodedSource = _escapeHtmlAttribute(sandboxedSource);
return _wrapSandboxedFrameDocument(
sourceAttribute: 'srcdoc="$encodedSource"',
fillAvailableHeight: fillAvailableHeight,
);
}
static String _wrapRemoteDocument(
Uri source, {
bool fillAvailableHeight = false,
}) {
final encodedSource = _escapeHtmlAttribute(source.toString());
return _wrapSandboxedFrameDocument(
sourceAttribute: 'src="$encodedSource"',
fillAvailableHeight: fillAvailableHeight,
);
}
static String _wrapSandboxedFrameDocument({
required String sourceAttribute,
required bool fillAvailableHeight,
}) {
return '''
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<style>
html, body {
margin: 0;
padding: 0;
background: transparent;
width: 100%;
}
#embed-frame {
display: block;
width: 100%;
height: ${fillAvailableHeight ? '100vh' : '${_embedDefaultHeight}px'};
min-height: ${_embedMinHeight}px;
border: 0;
background: transparent;
}
</style>
<script>
(() => {
const minHeight = $_embedMinHeight;
const maxHeight = $_embedMaxHeight;
const fillAvailableHeight = $fillAvailableHeight;
window.addEventListener('message', (event) => {
const data = event.data || {};
const frame = document.getElementById('embed-frame');
if (!frame || event.source !== frame.contentWindow) return;
if (data.type !== 'conduit-embed-height') return;
const height = Number(data.height);
if (!Number.isFinite(height) || height <= 0) return;
if (fillAvailableHeight) return;
const clamped = Math.min(Math.max(height, minHeight), maxHeight);
frame.style.height = `\${clamped}px`;
});
})();
</script>
</head>
<body>
<iframe
id="embed-frame"
sandbox="allow-scripts allow-forms allow-popups"
referrerpolicy="no-referrer"
$sourceAttribute
></iframe>
</body>
</html>
''';
}
static String _injectSandboxBootstrap(
String source, {
required String argsText,
}) {
final argumentsScript = _inlineArgumentsScript(argsText);
if (argumentsScript.isEmpty) {
return source;
}
final bootstrap =
'''
<script>
$argumentsScript
</script>
''';
final headMatch = RegExp(
r'<head\b[^>]*>',
caseSensitive: false,
).firstMatch(source);
if (headMatch != null) {
return source.replaceRange(headMatch.end, headMatch.end, bootstrap);
}
final htmlMatch = RegExp(
r'<html\b[^>]*>',
caseSensitive: false,
).firstMatch(source);
if (htmlMatch != null) {
return source.replaceRange(htmlMatch.end, htmlMatch.end, bootstrap);
}
return '$bootstrap$source';
}
static String _frameBootstrapScript(String argsText) {
return '''
${_inlineArgumentsScript(argsText)}
$_allFrameBootstrapScript
''';
}
static String _inlineArgumentsScript(String argsText) {
return argsText.trim().isEmpty
? ''
: 'window.args = ${_jsonForInlineScript(argsText)};';
}
static const String _allFrameBootstrapScript = '''
(() => {
const reportHeight = () => {
const body = document.body;
const html = document.documentElement;
const height = Math.ceil(Math.max(
body?.scrollHeight || 0,
body?.offsetHeight || 0,
html?.clientHeight || 0,
html?.scrollHeight || 0,
html?.offsetHeight || 0
));
parent.postMessage({ type: 'conduit-embed-height', height }, '*');
};
window.addEventListener('load', reportHeight);
if (typeof ResizeObserver !== 'undefined') {
const observer = new ResizeObserver(reportHeight);
const observeDocument = () => {
if (document.documentElement) observer.observe(document.documentElement);
if (document.body) observer.observe(document.body);
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', observeDocument, { once: true });
} else {
observeDocument();
}
}
setTimeout(reportHeight, 0);
setTimeout(reportHeight, 250);
setTimeout(reportHeight, 1000);
})();
''';
static String _jsonForInlineScript(String value) {
return jsonEncode(value)
.replaceAll('&', r'\u0026')
.replaceAll('<', r'\u003C')
.replaceAll('>', r'\u003E');
}
static String _escapeHtmlAttribute(String value) {
return value
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
static bool _isUserActivatedNavigation(NavigationAction action) =>
_hasUserActivationEvidence(
hasGesture: action.hasGesture,
linkActivated: action.navigationType == NavigationType.LINK_ACTIVATED,
);
static bool _hasUserActivationEvidence({
required bool? hasGesture,
required bool linkActivated,
}) {
// Android reports hasGesture, so an explicit false must stay authoritative
// for scripted anchor clicks. iOS reports null and exposes only the
// navigation type, which remains the fallback for genuine link taps there.
return hasGesture == true || (hasGesture == null && linkActivated);
}
static bool _shouldAllowAutomaticNavigation(String targetUrl) {
final uri = Uri.tryParse(targetUrl.trim());
if (uri == null) {
return false;
}
final scheme = uri.scheme.toLowerCase();
return scheme == 'http' ||
scheme == 'https' ||
(scheme == 'about' &&
const {'blank', 'srcdoc'}.contains(uri.path.toLowerCase()));
}
static bool _shouldAllowInlineFragmentNavigation(String targetUrl) {
final uri = Uri.tryParse(targetUrl.trim());
return uri != null &&
uri.hasFragment &&
uri.scheme.toLowerCase() == 'about' &&
const {'blank', 'srcdoc'}.contains(uri.path.toLowerCase());
}
static bool _shouldSurfaceLoadFailure({
required bool isForMainFrame,
required Iterable<String> remoteEmbedUrls,
required String requestUrl,
}) {
if (isForMainFrame) {
return true;
}
final normalizedRequest = _normalizedDocumentUrl(requestUrl);
if (normalizedRequest == null) {
return false;
}
return remoteEmbedUrls.any(
(url) => _normalizedDocumentUrl(url) == normalizedRequest,
);
}
static String? _normalizedDocumentUrl(String rawUrl) {
final uri = Uri.tryParse(rawUrl.trim());
if (uri == null || !uri.hasScheme || uri.host.isEmpty) {
return null;
}
final scheme = uri.scheme.toLowerCase();
if (scheme != 'http' && scheme != 'https') {
return null;
}
final path = uri.path.isEmpty ? '/' : uri.path;
return '$scheme|${uri.userInfo}|${uri.host.toLowerCase()}|${uri.port}|'
'$path|${uri.query}';
}
static bool _shouldHandleCreateWindow({
required bool requestIsCurrent,
required String? targetUrl,
}) =>
requestIsCurrent &&
targetUrl != null &&
targetUrl.isNotEmpty &&
parseAllowedExternalLink(targetUrl) != null;
static bool _shouldResolveMissingPopupUrl({
required bool requestIsCurrent,
required String? targetUrl,
}) => requestIsCurrent && (targetUrl == null || targetUrl.isEmpty);
static bool _shouldOpenNavigationExternally({
required String targetUrl,
required String? currentUrl,
required bool userActivated,
}) {
if (!userActivated || parseAllowedExternalLink(targetUrl) == null) {
return false;
}
final target = Uri.tryParse(targetUrl);
final current = currentUrl == null ? null : Uri.tryParse(currentUrl);
if (target == null || current == null) {
return true;
}
final targetWithoutFragment = target.replace(fragment: '');
final currentWithoutFragment = current.replace(fragment: '');
return targetWithoutFragment != currentWithoutFragment;
}
static Future<bool> _openAllowedExternalLink(
String rawUrl, {
Future<bool> Function(String url)? launcher,
}) async {
final uri = parseAllowedExternalLink(rawUrl);
if (uri == null) {
return false;
}
final normalizedUrl = uri.toString();
if (launcher != null) {
return launcher(normalizedUrl);
}
return launchExternalLink(normalizedUrl, scope: 'embeds/navigation');
}
}
class _EmbedLoadingCard extends StatelessWidget {
const _EmbedLoadingCard();
@override
Widget build(BuildContext context) {
final theme = context.conduitTheme;
return DecoratedBox(
decoration: BoxDecoration(
color: theme.cardBackground,
border: Border.all(color: theme.cardBorder),
borderRadius: BorderRadius.circular(AppBorderRadius.md),
),
child: const SizedBox(
height: _embedFallbackHeight,
child: Center(child: CircularProgressIndicator()),
),
);
}
}
class _EmbedDeferredCard extends StatelessWidget {
const _EmbedDeferredCard({
required this.title,
required this.description,
required this.onOpen,
});
final String title;
final String description;
final VoidCallback onOpen;
@override
Widget build(BuildContext context) {
final theme = context.conduitTheme;
final l10n = AppLocalizations.of(context);
return DecoratedBox(
decoration: BoxDecoration(
color: theme.cardBackground,
border: Border.all(color: theme.cardBorder),
borderRadius: BorderRadius.circular(AppBorderRadius.md),
),
child: Padding(
padding: const EdgeInsets.all(Spacing.sm),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: AppTypography.bodyMediumStyle.copyWith(
color: theme.textPrimary,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: Spacing.xs),
Text(
description,
style: AppTypography.bodySmallStyle.copyWith(
color: theme.textSecondary,
),
),
const SizedBox(height: Spacing.sm),
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: onOpen,
child: Text(l10n?.openPreview ?? 'Open preview'),
),
),
],
),
),
);
}
}
class _EmbedFallbackCard extends StatelessWidget {
const _EmbedFallbackCard({required this.source, required this.message});
final String source;
final String message;
bool get _isRemoteUrl =>
source.startsWith('http://') ||
source.startsWith('https://') ||
source.startsWith('//');
@override
Widget build(BuildContext context) {
final theme = context.conduitTheme;
return DecoratedBox(
decoration: BoxDecoration(
color: theme.cardBackground,
border: Border.all(color: theme.cardBorder),
borderRadius: BorderRadius.circular(AppBorderRadius.md),
),
child: Padding(
padding: const EdgeInsets.all(Spacing.sm),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
message,
style: AppTypography.bodySmallStyle.copyWith(
color: theme.textSecondary,
),
),
if (_isRemoteUrl) ...[
const SizedBox(height: Spacing.xs),
SelectableText(
source.startsWith('//') ? 'https:$source' : source,
style: AppTypography.codeStyle.copyWith(color: theme.codeText),
),
],
],
),
),
);
}
}