mirror of
https://github.com/cogwheel0/conduit.git
synced 2026-08-26 19:11:45 +00:00
Some checks failed
L10n / l10n (push) Has been cancelled
Squash-merge the reviewed native UI, sidebar, onboarding, direct connection, Hermes, workspace, theming, haptics, and consistency updates.
1202 lines
34 KiB
Dart
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('&', '&')
|
|
.replaceAll('<', '<')
|
|
.replaceAll('>', '>')
|
|
.replaceAll('"', '"')
|
|
.replaceAll("'", ''');
|
|
}
|
|
|
|
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),
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|