fix(digital-id): завершать ЕСИА callback через EXTERNAL_CALLBACK

This commit is contained in:
klockky
2026-07-24 14:38:19 +03:00
parent 164ab98fb7
commit f7265b4fe8
5 changed files with 141 additions and 23 deletions
+55
View File
@@ -19,6 +19,41 @@ class WebAppLaunch {
const WebAppLaunch({required this.url});
}
class ExternalCallbackResult {
final int botId;
final String? startParam;
const ExternalCallbackResult({required this.botId, this.startParam});
static ExternalCallbackResult? fromPayload(dynamic payload) {
final data = _findResponseMap(payload);
if (data == null) return null;
final botId = _asInt(data['botId'] ?? data['bot_id']);
if (botId == null) return null;
final startParam = data['startParam'] ?? data['start_param'];
return ExternalCallbackResult(
botId: botId,
startParam: startParam?.toString(),
);
}
static Map? _findResponseMap(dynamic value) {
if (value is! Map) return null;
if (value.containsKey('botId') || value.containsKey('bot_id')) return value;
for (final key in const ['data', 'result', 'response']) {
final nested = _findResponseMap(value[key]);
if (nested != null) return nested;
}
return null;
}
static int? _asInt(dynamic value) {
if (value is int) return value;
if (value is num) return value.toInt();
return int.tryParse(value?.toString() ?? '');
}
}
class WebAppModule {
final Api _api;
@@ -68,6 +103,26 @@ class WebAppModule {
return fetchLaunch(botId);
}
Future<WebAppLaunch> handleExternalCallback(String url) async {
final uri = Uri.tryParse(url);
if (uri == null || uri.queryParameters['externalCallback'] != '1') {
throw const WebAppUnavailable('Некорректный callback Цифрового ID');
}
if (_api.state != SessionState.online) {
throw const WebAppUnavailable('Нет соединения с сервером');
}
final packet = await _api.sendRequest(Opcode.externalCallback, {
'url': url,
});
final result = ExternalCallbackResult.fromPayload(packet.payload);
if (result == null) {
throw const WebAppUnavailable(
'Сервер не вернул данные для завершения Цифрового ID',
);
}
return fetchLaunch(result.botId, startParam: result.startParam);
}
Future<int?> _resolveEntryApp(String key) async {
final accountId = await TokenStorage.getActiveAccountId();
if (accountId == null) return null;
@@ -7,7 +7,7 @@ import '../../../backend/modules/digital_id.dart';
import '../../../backend/modules/webapp.dart';
import '../../../core/utils/webview_support.dart';
import '../../../l10n/app_localizations.dart';
import '../../../main.dart' show digitalIdModule;
import '../../../main.dart' show digitalIdModule, webAppModule;
import '../../../models/digital_id.dart';
import '../../widgets/connection_status.dart';
import '../../widgets/custom_notification.dart';
@@ -123,10 +123,14 @@ class _DigitalIdScreenState extends State<DigitalIdScreen> {
builder: (context) => WebAppScreen(
title: AppLocalizations.of(context)!.digitalIdGosuslugiTitle,
loader: () async => WebAppLaunch(url: link.url),
onExternalCallback: webAppModule.handleExternalCallback,
closeAfterExternalCallback: true,
),
),
);
if (!mounted) return;
await digitalIdModule.loadDocuments(createIfMissing: true);
if (!mounted) return;
await _load();
} on DigitalIdException catch (e) {
if (mounted) showCustomNotification(context, e.message);
@@ -186,6 +186,7 @@ class DigitalIdWebScreen extends StatelessWidget {
},
);
},
onExternalCallback: webAppModule.handleExternalCallback,
onConsoleMessage: kDebugMode
? (controller, consoleMessage) {
debugPrint('[KOMET-DID] ${consoleMessage.message}');
@@ -199,7 +200,7 @@ class DigitalIdWebScreen extends StatelessWidget {
);
}
: null,
shouldOverrideUrlLoading: (controller, action, currentUrl) async {
shouldOverrideUrlLoading: (_, action, _) async {
final uri = action.request.url;
final url = uri?.toString() ?? '';
final scheme = uri?.scheme ?? '';
@@ -208,19 +209,7 @@ class DigitalIdWebScreen extends StatelessWidget {
'[KOMET-DID] nav: ${url.length > 140 ? url.substring(0, 140) : url}',
);
}
final isCallback =
url.contains('?externalCallback=') ||
url.contains('&externalCallback=');
if (isCallback || (scheme != 'http' && scheme != 'https')) {
final launchUrl = currentUrl ?? 'https://digital-id.max.ru';
final hashIdx = launchUrl.indexOf('#');
final base = hashIdx >= 0
? launchUrl.substring(0, hashIdx)
: launchUrl;
final frag = hashIdx >= 0 ? launchUrl.substring(hashIdx) : '';
final query = uri?.query ?? '';
final target = query.isEmpty ? launchUrl : '$base?$query$frag';
controller.loadUrl(urlRequest: URLRequest(url: WebUri(target)));
if (scheme != 'http' && scheme != 'https') {
return NavigationActionPolicy.CANCEL;
}
return NavigationActionPolicy.ALLOW;
@@ -24,6 +24,8 @@ class WebAppScreen extends StatefulWidget {
onConsoleMessage;
final void Function(InAppWebViewController controller, WebUri? url)?
onLoadStart;
final Future<WebAppLaunch> Function(String url)? onExternalCallback;
final bool closeAfterExternalCallback;
final Future<NavigationActionPolicy?> Function(
InAppWebViewController controller,
NavigationAction navigationAction,
@@ -39,6 +41,8 @@ class WebAppScreen extends StatefulWidget {
this.onWebViewCreated,
this.onConsoleMessage,
this.onLoadStart,
this.onExternalCallback,
this.closeAfterExternalCallback = false,
this.shouldOverrideUrlLoading,
});
@@ -67,7 +71,8 @@ class _WebAppScreenState extends State<WebAppScreen> {
try {
// Тот же UA, что уходит в sessionInit (из handshake-устройства ядра),
// чтобы веб-аппы видели нативный клиент; фолбэк — браузерный UA спуфа.
_userAgent = api.session?.userAgent() ??
_userAgent =
api.session?.userAgent() ??
await SpoofingService.getWebViewUserAgent() ??
'';
final launch = await widget.loader();
@@ -88,6 +93,37 @@ class _WebAppScreenState extends State<WebAppScreen> {
return true;
}
Future<NavigationActionPolicy?> _handleNavigation(
InAppWebViewController controller,
NavigationAction action,
) async {
final uri = action.request.url;
final callback = widget.onExternalCallback;
if (callback != 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;
_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) return NavigationActionPolicy.ALLOW;
return handler(controller, action, _launch?.url);
}
@override
Widget build(BuildContext context) {
final cs = Theme.of(context).colorScheme;
@@ -153,7 +189,9 @@ class _WebAppScreenState extends State<WebAppScreen> {
transparentBackground: true,
mediaPlaybackRequiresUserGesture: false,
useHybridComposition: true,
useShouldOverrideUrlLoading: widget.shouldOverrideUrlLoading != null,
useShouldOverrideUrlLoading:
widget.shouldOverrideUrlLoading != null ||
widget.onExternalCallback != null,
userAgent: _userAgent,
),
onWebViewCreated: (controller) {
@@ -164,13 +202,11 @@ class _WebAppScreenState extends State<WebAppScreen> {
askWebViewPermission(context, request),
onConsoleMessage: widget.onConsoleMessage,
onLoadStart: widget.onLoadStart,
shouldOverrideUrlLoading: widget.shouldOverrideUrlLoading == null
shouldOverrideUrlLoading:
widget.shouldOverrideUrlLoading == null &&
widget.onExternalCallback == null
? null
: (controller, action) => widget.shouldOverrideUrlLoading!(
controller,
action,
launch.url,
),
: _handleNavigation,
onProgressChanged: (controller, progress) {
if (!mounted) return;
setState(() => _progress = progress / 100);