Files
Qlyra/lib/frontend/screens/webapp/web_app_screen.dart
T
sevenhill 50816588e4
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
Update Qlyra application
2026-08-30 22:53:15 +03:00

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