Build Android (FCM) / build-android-fcm (push) Canceled after 0s
Build Android / build-android (push) Canceled after 0s
Build iOS / build-ios (push) Canceled after 0s
Build Linux / build-linux (push) Canceled after 0s
Build macOS / build-macos (push) Canceled after 0s
Build Windows / build-windows (push) Canceled after 0s
Release (main) / android (oneme) (push) Canceled after 0s
Release (main) / android (qlyra) (push) Canceled after 0s
Release (main) / windows (push) Canceled after 0s
Release (main) / linux (push) Canceled after 0s
Release (main) / macos (push) Canceled after 0s
Release (main) / ios (push) Canceled after 0s
Release (main) / release (push) Canceled after 0s
348 lines
11 KiB
Dart
348 lines
11 KiB
Dart
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<WebAppLaunch> Function() loader;
|
|
final String entryPoint;
|
|
final bool privateChannel;
|
|
final WebAppMobileIdVerifier? mobileIdVerifier;
|
|
final List<UserScript>? 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<WebAppLaunch> Function(String url)? onExternalCallback;
|
|
final bool closeAfterExternalCallback;
|
|
final bool preferSystemUserAgent;
|
|
final List<String> allowedOrigins;
|
|
final Future<NavigationActionPolicy?> 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<WebAppScreen> createState() => _WebAppScreenState();
|
|
}
|
|
|
|
class _WebAppScreenState extends State<WebAppScreen> {
|
|
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<void> _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<bool> _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<NavigationActionPolicy?> _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<UserScript>([
|
|
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);
|
|
}
|
|
},
|
|
);
|
|
}
|
|
}
|