import 'dart:collection'; import 'package:flutter/material.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/webapp.dart'; import '../../../core/storage/spoofing_service.dart'; import '../../../core/utils/link_opener.dart'; import '../../../main.dart' show api; import '../../widgets/confirm_dialog.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/error_view.dart'; import '../../widgets/small_spinner.dart'; import '../../widgets/webview_permission_prompt.dart'; import 'web_app_bridge.dart'; import 'web_app_security_policy.dart'; class WebAppScreen extends StatefulWidget { final String title; final Future Function() loader; final String entryPoint; final bool privateChannel; final WebAppMobileIdVerifier? mobileIdVerifier; final List? extraUserScripts; final void Function(InAppWebViewController controller)? onWebViewCreated; final void Function( InAppWebViewController controller, ConsoleMessage consoleMessage, )? onConsoleMessage; final void Function(InAppWebViewController controller, WebUri? url)? onLoadStart; final Future Function(String url)? onExternalCallback; final bool closeAfterExternalCallback; final bool preferSystemUserAgent; final List allowedOrigins; final Future Function( InAppWebViewController controller, NavigationAction navigationAction, String? currentUrl, )? shouldOverrideUrlLoading; const WebAppScreen({ super.key, required this.title, required this.loader, this.entryPoint = WebAppEntryPoint.webApp, this.privateChannel = false, this.mobileIdVerifier, this.extraUserScripts, this.onWebViewCreated, this.onConsoleMessage, this.onLoadStart, this.onExternalCallback, this.closeAfterExternalCallback = false, this.preferSystemUserAgent = false, this.allowedOrigins = const [], this.shouldOverrideUrlLoading, }); @override State createState() => _WebAppScreenState(); } class _WebAppScreenState extends State { InAppWebViewController? _controller; WebAppBridge? _bridge; WebAppLaunch? _launch; WebAppSecurityPolicy? _securityPolicy; Uri? _currentUrl; String? _loadError; String _userAgent = ''; double _progress = 0; Size _viewport = Size.zero; @override void initState() { super.initState(); _load(); } @override void dispose() { _bridge?.dispose(); super.dispose(); } Future _load() async { setState(() { _loadError = null; _launch = null; _securityPolicy = null; _currentUrl = null; _bridge?.dispose(); _bridge = null; }); try { // Тот же UA, что уходит в sessionInit (из handshake-устройства ядра), // чтобы веб-аппы видели нативный клиент; фолбэк — браузерный UA спуфа. // Для веб-аппов с внешней авторизацией (Госуслуги/ЕСИА) клиентский UA // ядра отбраковывается антифродом — там нужен UA настоящего WebView. _userAgent = ''; if (widget.preferSystemUserAgent) { try { _userAgent = await InAppWebViewController.getDefaultUserAgent(); } catch (_) {} } if (_userAgent.isEmpty) { _userAgent = api.session?.userAgent() ?? await SpoofingService.getWebViewUserAgent() ?? ''; } final launch = await widget.loader(); final policy = WebAppSecurityPolicy.fromLaunchUrl( launch.url, additionalOrigins: widget.allowedOrigins, ); if (!mounted) return; setState(() { _launch = launch; _securityPolicy = policy; _currentUrl = Uri.parse(launch.url); _bridge = _createBridge(launch.botId); }); } catch (e) { if (!mounted) return; setState(() => _loadError = e.toString()); } } WebAppBridge _createBridge(int botId) => WebAppBridge( botId: botId, entryPoint: widget.entryPoint, privateChannel: widget.privateChannel, mobileIdVerifier: widget.mobileIdVerifier, contextResolver: () => mounted ? context : null, viewportResolver: () => _viewport, onClose: _closeFromWebApp, ); void _closeFromWebApp() { if (!mounted) return; Navigator.of(context).maybePop(); } Future _handleBack() async { final bridge = _bridge; if (bridge != null && bridge.handlesBackButton) { bridge.notifyBackPressed(); return false; } final controller = _controller; if (controller != null && await controller.canGoBack()) { await controller.goBack(); return false; } if (bridge != null && bridge.needsCloseConfirmation && mounted) { return showConfirmDialog( context, title: widget.title, message: 'Закрыть мини-приложение?', confirmLabel: 'Закрыть', ); } return true; } Future _handleNavigation( InAppWebViewController controller, NavigationAction action, ) async { final uri = action.request.url; final callback = widget.onExternalCallback; if (callback != null && uri != null && WebAppSecurityPolicy.originOf(uri) != null && uri.queryParameters['externalCallback'] == '1') { try { final launch = await callback(uri.toString()); if (!mounted) return NavigationActionPolicy.CANCEL; if (widget.closeAfterExternalCallback) { Navigator.of(context).pop(launch); return NavigationActionPolicy.CANCEL; } setState(() { _launch = launch; _securityPolicy = WebAppSecurityPolicy.fromLaunchUrl( launch.url, additionalOrigins: widget.allowedOrigins, ); _currentUrl = Uri.parse(launch.url); _loadError = null; }); await controller.loadUrl( urlRequest: URLRequest(url: WebUri(launch.url)), ); } catch (e) { if (mounted) setState(() => _loadError = e.toString()); } return NavigationActionPolicy.CANCEL; } final handler = widget.shouldOverrideUrlLoading; if (handler != null) { final decision = await handler(controller, action, _launch?.url); if (decision == NavigationActionPolicy.CANCEL) return decision; } if (uri != null && leavesWebView(uri.scheme)) { if (mounted) await openExternalUrl(context, uri.toString()); return NavigationActionPolicy.CANCEL; } final policy = _securityPolicy; if (uri != null && policy != null && !policy.allowsNavigation(uri)) { if (mounted) await openExternalUrl(context, uri.toString()); return NavigationActionPolicy.CANCEL; } return NavigationActionPolicy.ALLOW; } @override Widget build(BuildContext context) { final cs = Theme.of(context).colorScheme; return PopScope( canPop: false, onPopInvokedWithResult: (didPop, result) async { if (didPop) return; final navigator = Navigator.of(context); if (await _handleBack()) navigator.pop(); }, child: Scaffold( backgroundColor: cs.surface, floatingActionButtonLocation: FloatingActionButtonLocation.startFloat, floatingActionButton: const ConnectionSpinner(), appBar: AppBar( backgroundColor: cs.surface, surfaceTintColor: Colors.transparent, title: Text(widget.title), leading: IconButton( icon: const Icon(Symbols.close), onPressed: () => Navigator.of(context).maybePop(), ), actions: [ IconButton( icon: const Icon(Symbols.refresh), onPressed: _launch == null ? null : () => _controller?.reload(), ), ], bottom: _progress > 0 && _progress < 1 ? PreferredSize( preferredSize: const Size.fromHeight(2), child: LinearProgressIndicator( value: _progress, minHeight: 2, backgroundColor: Colors.transparent, ), ) : null, ), body: _buildBody(cs), ), ); } Widget _buildBody(ColorScheme cs) { if (_loadError != null) { return ErrorView(message: _loadError!, onRetry: _load); } final launch = _launch; final bridge = _bridge; if (launch == null || bridge == null || _securityPolicy == null) { return const Center(child: SmallSpinner(size: 36)); } return LayoutBuilder( builder: (context, constraints) { _viewport = Size(constraints.maxWidth, constraints.maxHeight); return Listener( onPointerDown: (_) => bridge.registerGesture(), child: _buildWebView(launch, bridge), ); }, ); } Widget _buildWebView(WebAppLaunch launch, WebAppBridge bridge) { return InAppWebView( initialUrlRequest: URLRequest(url: WebUri(launch.url)), initialUserScripts: UnmodifiableListView([ bridge.userScript, ...?widget.extraUserScripts, ]), initialSettings: InAppWebViewSettings( javaScriptEnabled: true, domStorageEnabled: true, thirdPartyCookiesEnabled: false, supportZoom: false, transparentBackground: true, mediaPlaybackRequiresUserGesture: true, allowsInlineMediaPlayback: true, sharedCookiesEnabled: false, incognito: true, allowsBackForwardNavigationGestures: true, useHybridComposition: true, supportMultipleWindows: true, allowFileAccess: false, useShouldOverrideUrlLoading: true, userAgent: _userAgent, ), onWebViewCreated: (controller) { _controller = controller; bridge.attach(controller); widget.onWebViewCreated?.call(controller); }, onPermissionRequest: (controller, request) => askWebViewPermission( context, request, policy: _securityPolicy!, currentUrl: _currentUrl, ), onCreateWindow: (controller, action) async { final url = action.request.url?.toString(); if (url != null && url.isNotEmpty && mounted) { await openExternalUrl(context, url); } return false; }, onConsoleMessage: widget.onConsoleMessage, onLoadStart: (controller, url) { _currentUrl = url == null ? null : Uri.parse(url.toString()); widget.onLoadStart?.call(controller, url); }, shouldOverrideUrlLoading: _handleNavigation, onProgressChanged: (controller, progress) { if (!mounted) return; setState(() => _progress = progress / 100); }, onReceivedError: (controller, request, error) { if (!mounted) return; if (request.isForMainFrame ?? false) { setState(() => _loadError = error.description); } }, ); } }