From 198f13782697e4c9d429bdfd72783e45fd5ae3cd Mon Sep 17 00:00:00 2001 From: klockky Date: Fri, 14 Aug 2026 20:33:54 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=90=20=D0=95=D0=91=D0=90=D0=9B=20?= =?UTF-8?q?=D0=AF=20=D0=92=D0=A1=D0=95=D0=99=20=D0=94=D0=A3=D0=A8=D0=9E?= =?UTF-8?q?=D0=99=20=D0=92=D0=90=D0=A8=20=D0=A6=D0=98=D0=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/modules/digital_id.dart | 54 +++- lib/backend/modules/webapp.dart | 14 +- lib/core/links/deep_link_service.dart | 51 ++++ .../screens/digital_id/digital_id_screen.dart | 34 +-- .../digital_id/digital_id_web_screen.dart | 271 ++++++++++++++---- .../screens/webapp/web_app_screen.dart | 20 +- 6 files changed, 358 insertions(+), 86 deletions(-) diff --git a/lib/backend/modules/digital_id.dart b/lib/backend/modules/digital_id.dart index 7285ac1..283f490 100644 --- a/lib/backend/modules/digital_id.dart +++ b/lib/backend/modules/digital_id.dart @@ -1,20 +1,25 @@ import 'dart:convert'; import 'dart:io'; import 'dart:math'; +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart' show kDebugMode; import 'package:device_info_plus/device_info_plus.dart'; import '../../core/storage/app_database.dart'; import '../../core/storage/spoofing_service.dart'; import '../../core/storage/token_storage.dart'; +import '../../core/utils/logger.dart'; import '../../models/digital_id.dart'; import 'webapp.dart'; class DigitalIdException implements Exception { final String code; final String message; + final int? statusCode; - const DigitalIdException(this.code, this.message); + const DigitalIdException(this.code, this.message, {this.statusCode}); bool get isUnauthorized => code == 'UNAUTHORIZED'; bool get isNoGosuslugiLink => code == 'NO_GOSUSLUGI_LINK'; @@ -61,10 +66,21 @@ class DigitalIdModule { ).firstMatch(fragment); final raw = match?.group(1); if (raw == null || raw.isEmpty) return null; - return Uri.decodeComponent(raw); + // Фрагмент несёт WebAppData полностью percent-encoded (hash%3D…%26…); + // сервер ждёт канонический initDataRaw (hash=…&…) — как шлёт web-страница + // после decodeURIComponent. Декодируем один раз (фолбэк — сырое значение). + try { + return Uri.decodeComponent(raw); + } catch (_) { + return raw; + } } Future deviceId() async { + // Тот же device_id, что уходит в handshake (опкод 6) — иначе сервер вернёт + // device_mismatch. Сессия обычно уже поднята к моменту открытия Цифрового ID. + final session = _webApp.sessionDeviceId; + if (session != null && session.isNotEmpty) return session; if (_deviceId != null) return _deviceId!; final accountId = await TokenStorage.getActiveAccountId(); if (accountId != null) { @@ -144,6 +160,9 @@ class DigitalIdModule { } final response = await request.close(); final text = await response.transform(utf8.decoder).join(); + if (kDebugMode) { + logger.i('[DID-native] $method $path -> ${response.statusCode}'); + } if (response.statusCode == 401 && retry) { await _ensureWebAppData(forceRefresh: true); @@ -170,7 +189,7 @@ class DigitalIdModule { } } catch (_) {} if (statusCode == 401) code = 'UNAUTHORIZED'; - return DigitalIdException(code, message); + return DigitalIdException(code, message, statusCode: statusCode); } Map _unwrapData(dynamic decoded) { @@ -246,7 +265,7 @@ class DigitalIdModule { ); return _unwrapData(decoded)['shadow_mode'] == true; } on DigitalIdException catch (e) { - if (e.code == 'HTTP_404') return false; + if (e.statusCode == 404) return false; rethrow; } } @@ -377,7 +396,34 @@ class DigitalIdModule { return null; } + Future?> fetchMobileIdVerification(String url) async { + final uri = Uri.tryParse(url); + if (uri == null || uri.scheme != 'https') return null; + try { + final request = await _http.getUrl(uri); + request.followRedirects = true; + final response = await request.close(); + final builder = BytesBuilder(copy: false); + await for (final chunk in response) { + builder.add(chunk); + } + final headers = {}; + response.headers.forEach((name, values) { + headers[name] = values.join(','); + }); + return { + 'statusCode': response.statusCode, + 'headers': headers, + 'data': base64Encode(builder.takeBytes()), + }; + } catch (_) { + return null; + } + } + void reset() { _webAppData = null; + _deviceId = null; + _realUserAgent = null; } } diff --git a/lib/backend/modules/webapp.dart b/lib/backend/modules/webapp.dart index 143a304..c66bfcd 100644 --- a/lib/backend/modules/webapp.dart +++ b/lib/backend/modules/webapp.dart @@ -20,8 +20,9 @@ bool hasMiniAppOption(Set? options) => class WebAppLaunch { final String url; + final String? queryId; - const WebAppLaunch({required this.url}); + const WebAppLaunch({required this.url, this.queryId}); } class ExternalCallbackResult { @@ -64,6 +65,10 @@ class WebAppModule { WebAppModule(this._api); + /// device_id сессии (опкод 6 sessionInit, с учётом спуфинга) — тот же id, + /// к которому сервер привязывает Цифровой ID. + String? get sessionDeviceId => _api.deviceId; + Future fetchLaunch( int botId, { String? startParam, @@ -72,9 +77,11 @@ class WebAppModule { if (_api.state != SessionState.online) { throw const WebAppUnavailable('Нет соединения с сервером'); } + final normalizedStartParam = + (startParam != null && startParam.trim().isNotEmpty) ? startParam : null; final packet = await _api.sendRequest(Opcode.webAppInitData, { 'botId': botId, - 'startParam': ?startParam, + 'startParam': ?normalizedStartParam, 'chatId': ?chatId, }); if (!packet.isOk) { @@ -85,7 +92,8 @@ class WebAppModule { if (url == null || url.isEmpty) { throw const WebAppUnavailable('Сервер не вернул адрес приложения'); } - return WebAppLaunch(url: url); + final queryId = (data is Map) ? data['query_id']?.toString() : null; + return WebAppLaunch(url: url, queryId: queryId); } Future fetchSferum() async { diff --git a/lib/core/links/deep_link_service.dart b/lib/core/links/deep_link_service.dart index 9a533a6..745bced 100644 --- a/lib/core/links/deep_link_service.dart +++ b/lib/core/links/deep_link_service.dart @@ -1,10 +1,14 @@ import 'dart:async'; import 'package:app_links/app_links.dart'; +import 'package:flutter/widgets.dart'; import '../../backend/api.dart'; import '../../frontend/debug/log_export.dart'; +import '../../frontend/screens/digital_id/digital_id_web_screen.dart'; +import '../../frontend/widgets/custom_notification.dart'; import '../../frontend/widgets/max_link_handler.dart'; +import '../../frontend/widgets/swipe_route.dart'; import '../../main.dart'; import 'desktop_url_scheme.dart'; import 'max_link.dart'; @@ -19,6 +23,9 @@ class DeepLinkService { StreamSubscription? _stateSub; String? _pending; bool _pendingLogExport = false; + String? _pendingExternalCallback; + String? _lastExternalCallback; + Timer? _externalCallbackRetry; Timer? _logExportRetry; bool _ready = false; bool _started = false; @@ -51,6 +58,14 @@ class DeepLinkService { _flushPending(); return; } + if (_isExternalCallback(uri)) { + final callbackUrl = uri.toString(); + if (callbackUrl == _lastExternalCallback) return; + _lastExternalCallback = callbackUrl; + _pendingExternalCallback = callbackUrl; + _flushPending(); + return; + } final url = _normalize(uri); if (url == null) return; _pending = url; @@ -72,6 +87,19 @@ class DeepLinkService { } } + if (_pendingExternalCallback != null) { + if (context == null || api.state != SessionState.online) { + _externalCallbackRetry ??= Timer(const Duration(milliseconds: 300), () { + _externalCallbackRetry = null; + _flushPending(); + }); + } else { + final url = _pendingExternalCallback!; + _pendingExternalCallback = null; + _handleExternalCallback(context, url); + } + } + if (!_ready || context == null) return; final pending = _pending; if (pending == null) return; @@ -81,6 +109,29 @@ class DeepLinkService { tryHandleMaxLink(context, pending); } + bool _isExternalCallback(Uri uri) { + final scheme = uri.scheme.toLowerCase(); + if (scheme != 'https' && scheme != 'http' && scheme != 'max') return false; + final host = uri.host.toLowerCase(); + if (host != 'max.ru' && host != 'www.max.ru') return false; + return uri.queryParameters['externalCallback'] == '1'; + } + + Future _handleExternalCallback(BuildContext context, String url) async { + try { + final launch = await webAppModule.handleExternalCallback(url); + if (!context.mounted) return; + await pushSwipeable( + context, + (_) => DigitalIdWebScreen(initialLaunch: launch), + ); + } catch (e) { + if (context.mounted) { + showCustomNotification(context, 'Не удалось завершить Цифровой ID: $e'); + } + } + } + bool _isLogExportLink(Uri uri) { final scheme = uri.scheme.toLowerCase(); final host = uri.host.toLowerCase(); diff --git a/lib/frontend/screens/digital_id/digital_id_screen.dart b/lib/frontend/screens/digital_id/digital_id_screen.dart index fdcfbc3..e8fdb80 100644 --- a/lib/frontend/screens/digital_id/digital_id_screen.dart +++ b/lib/frontend/screens/digital_id/digital_id_screen.dart @@ -2,19 +2,18 @@ import 'package:flutter/material.dart'; import 'package:m3e_collection/m3e_collection.dart' show ExpressiveRefreshIndicator; import 'package:material_symbols_icons/symbols.dart'; +import 'package:url_launcher/url_launcher.dart'; 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, webAppModule; +import '../../../main.dart' show digitalIdModule; import '../../../models/digital_id.dart'; import '../../widgets/connection_status.dart'; import '../../widgets/reload_on_reconnect.dart'; import '../../widgets/custom_notification.dart'; import '../../widgets/error_view.dart'; import '../../widgets/small_spinner.dart'; -import '../webapp/web_app_screen.dart'; String _documentLabel(AppLocalizations l10n, String type) { return switch (type) { @@ -79,7 +78,12 @@ class _DigitalIdScreenState extends State rethrow; } } - final cards = await digitalIdModule.getCardsList(passStatus: 'active'); + List cards; + try { + cards = await digitalIdModule.getCardsList(passStatus: 'active'); + } catch (_) { + cards = const []; + } if (!mounted) return; setState(() { _biometry = biometry; @@ -122,21 +126,13 @@ class _DigitalIdScreenState extends State ); return; } - await Navigator.push( - context, - MaterialPageRoute( - 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(); + // Госуслуги (ЕСИА) открываем во ВНЕШНЕМ браузере — их антифрод режет + // встроенный webview. Возврат придёт диплинком max.ru?externalCallback=1 + // (deep_link_service -> опкод 105), после чего экран перезагрузится. + final uri = Uri.tryParse(link.url); + if (uri != null) { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } } on DigitalIdException catch (e) { if (mounted) showCustomNotification(context, e.message); } catch (e) { diff --git a/lib/frontend/screens/digital_id/digital_id_web_screen.dart b/lib/frontend/screens/digital_id/digital_id_web_screen.dart index 0bc22fe..7d87c96 100644 --- a/lib/frontend/screens/digital_id/digital_id_web_screen.dart +++ b/lib/frontend/screens/digital_id/digital_id_web_screen.dart @@ -1,8 +1,13 @@ +import 'dart:convert'; + import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter/material.dart'; import 'package:flutter_inappwebview/flutter_inappwebview.dart'; +import 'package:url_launcher/url_launcher.dart'; -import '../../../main.dart' show webAppModule, digitalIdModule; +import '../../../backend/modules/webapp.dart' show WebAppLaunch; +import '../../../core/utils/logger.dart'; +import '../../../main.dart' show api, webAppModule, digitalIdModule; import '../webapp/web_app_screen.dart'; Future resetDigitalIdWebData() async { @@ -21,22 +26,53 @@ Future resetDigitalIdSession() async { const String _kBridge = r''' (function(){ + if (!/(^|\.)max\.ru$/i.test(location.hostname)) { return; } var sawOpenLink = false; var DBG = !!window.__KOMET_DID_DEBUG; - function log(m){ if (!DBG) return; try { console.log('[BRIDGE] ' + m); } catch(e){} } + function log(m){ try { console.log('[BRIDGE] ' + m); } catch(e){} } + try { log('UA ' + navigator.userAgent); } catch(e){} + var lastTouch = 0; + var lastExternalOpen = 0; + try { + ['touchstart','pointerdown','mousedown','click'].forEach(function(ev){ + document.addEventListener(ev, function(){ lastTouch = Date.now(); }, true); + }); + } catch(e){} if (DBG) { try { var origFetch = window.fetch; window.fetch = function(){ - var u; - try { u = (typeof arguments[0] === 'string') ? arguments[0] : (arguments[0] && arguments[0].url); } catch(e){} - var watched = ('' + u).indexOf('ext-api') >= 0; + var u, method = 'GET'; + try { + if (typeof arguments[0] === 'string') { u = arguments[0]; } + else if (arguments[0]) { u = arguments[0].url; method = arguments[0].method || method; } + if (arguments[1] && arguments[1].method) method = arguments[1].method; + } catch(e){} + var watched = ('' + u).indexOf('ext-api') >= 0 || ('' + u).indexOf('digital-id') >= 0 || ('' + u).indexOf('oneme.ru') >= 0; + var path = ('' + u).split('?')[0]; + if (watched) { + var body = ''; + try { + if (arguments[1] && typeof arguments[1].body === 'string') body = arguments[1].body.slice(0, 300); + else if (arguments[0] && arguments[0]._bodyInit && typeof arguments[0]._bodyInit === 'string') body = arguments[0]._bodyInit.slice(0, 300); + } catch(e){} + var auth = ''; + try { + var h = (arguments[1] && arguments[1].headers) || (arguments[0] && arguments[0].headers); + if (h) { + if (typeof h.get === 'function') auth = h.get('Authorization') || h.get('authorization') || ''; + else if (typeof h.forEach === 'function') { h.forEach(function(v,k){ if (('' + k).toLowerCase() === 'authorization') auth = v; }); } + else auth = h['Authorization'] || h['authorization'] || ''; + } + } catch(e){} + log('FETCH> ' + method + ' ' + path + (auth ? ' AUTH=[' + ('' + auth).slice(0, 70) + ']' : ' AUTH=none') + (body ? ' body=' + body : '')); + } return origFetch.apply(this, arguments).then(function(r){ if (watched) { - try { r.clone().text().then(function(t){ log('FETCH ' + r.status + ' ' + u + ' :: ' + t.slice(0, 200)); }); } catch(e){} + try { r.clone().text().then(function(t){ log('FETCH< ' + r.status + ' ' + path + ' :: ' + t.slice(0, 500)); }); } catch(e){} } return r; - }).catch(function(err){ if (watched) log('FETCH ERR ' + u + ' ' + err); throw err; }); + }).catch(function(err){ if (watched) log('FETCH ERR ' + path + ' ' + err); throw err; }); }; } catch(e){} } @@ -55,7 +91,13 @@ const String _kBridge = r''' } try { var uid = userId(); - if (localStorage.getItem('komet_did_owner') !== uid) { + var prev = localStorage.getItem('komet_did_owner'); + log('owner check uid=' + uid + ' prev=' + prev); + // Wipe ТОЛЬКО при реальной смене аккаунта (реальный id -> другой реальный id). + // Никогда не чистим, когда id не удалось определить ('anon') или его не было — + // иначе флапающий userId() стирает device-binding на каждом перезапуске (петля). + if (uid !== 'anon' && prev && prev !== 'anon' && prev !== uid) { + log('owner switch ' + prev + ' -> ' + uid + ' :: wiping did storage'); try { localStorage.clear(); } catch(e){} try { sessionStorage.clear(); } catch(e){} try { @@ -65,13 +107,34 @@ const String _kBridge = r''' }); } } catch(e){} - localStorage.setItem('komet_did_owner', uid); } + if (uid !== 'anon') { localStorage.setItem('komet_did_owner', uid); } } catch(e){} - function reply(type, data){ - setTimeout(function(){ - try { window.WebApp.receiveEvent(type, data); } catch(e){} - }, 0); + var pending = []; + function chan(priv){ + var pub = (window.WebApp && typeof window.WebApp.sendEvent === 'function') ? window.WebApp : null; + var prv = (window.PrivateWebApp && typeof window.PrivateWebApp.sendEvent === 'function') ? window.PrivateWebApp : null; + if (priv) return prv; + return pub || prv; + } + function flush(){ + if (!pending.length) return; + var keep = []; + for (var i = 0; i < pending.length; i++) { + var it = pending[i]; + var t = chan(it[2]); + if (!t) { keep.push(it); continue; } + try { t.sendEvent(it[0], it[1]); } catch(e){ log('sendEvent err ' + e); keep.push(it); } + } + pending = keep; + } + setInterval(flush, 50); + function reply(type, data, priv){ + var payload; + try { payload = JSON.stringify(data == null ? {} : data); } catch(e){ payload = '{}'; } + log('reply ' + (priv ? '[priv] ' : '') + type + ' ' + payload); + pending.push([type, payload, !!priv]); + setTimeout(flush, 0); } function bioToken(){ try { @@ -88,88 +151,160 @@ const String _kBridge = r''' function tokenSaved(){ try { return !!localStorage.getItem('komet_did_bio_token'); } catch(e){ return false; } } - function handle(type, dataStr){ + function didDeviceId(){ + // Реальный device_id (как MAX: ANDROID_ID/persisted). Приоритет — прокинутый + // из Dart стабильный per-install id (переопределяемый под реальный id из MAX); + // фолбэк — стабильный 16-hex в localStorage (формат ANDROID_ID). + try { if (window.__KOMET_DID_DEVICE_ID) return '' + window.__KOMET_DID_DEVICE_ID; } catch(e){} + try { + var k = 'komet_did_device_id'; + var v = localStorage.getItem(k); + if (!v) { + v = ''; + for (var i = 0; i < 16; i++) v += Math.floor(Math.random() * 16).toString(16); + localStorage.setItem(k, v); + } + return v; + } catch(e){ return 'komet-device'; } + } + var NO_REPLY = { + WebAppReady: 1, WebAppSetupBackButton: 1, WebAppBackButtonPressed: 1, + WebAppUrlInterceptor: 1, WebAppSetupClosingBehavior: 1, WebAppStat: 1, + WebAppHapticFeedbackImpact: 1, WebAppHapticFeedbackNotification: 1, + WebAppHapticFeedbackSelectionChange: 1 + }; + function handle(type, dataStr, priv){ var data = {}; try { data = JSON.parse(dataStr || '{}'); } catch(e){} - log('recv ' + type + ' ' + dataStr); + log('recv ' + (priv ? '[priv] ' : '') + type + ' ' + dataStr); var requestId = data.requestId; switch (type) { case 'WebAppBiometryGetInfo': reply(type, { requestId: requestId, available: true, - access_requested: tokenSaved(), accessRequested: tokenSaved(), - access_granted: tokenSaved(), accessGranted: tokenSaved(), - token_saved: tokenSaved(), tokenSaved: tokenSaved(), - device_id: 'komet-device', deviceId: 'komet-device', - type: 'face', biometricType: 'face' + accessRequested: tokenSaved(), accessGranted: tokenSaved(), + access_requested: tokenSaved(), access_granted: tokenSaved(), + tokenSaved: tokenSaved(), token_saved: tokenSaved(), + deviceId: didDeviceId(), device_id: didDeviceId(), + type: ['face'] }); return; case 'WebAppBiometryRequestAccess': - reply(type, { requestId: requestId, granted: true, access_granted: true, accessGranted: true, status: 'granted' }); + reply(type, { requestId: requestId, granted: true, access_granted: true, accessGranted: true, status: 'authorized' }); return; case 'WebAppBiometryAuthenticate': - reply(type, { requestId: requestId, token: bioToken(), success: true, status: 'authenticated' }); + case 'WebAppBiometryRequestAuth': + reply(type, { requestId: requestId, token: bioToken(), success: true, status: 'authorized' }); return; case 'WebAppBiometryUpdateToken': case 'WebAppBiometryUpdateBiometricToken': reply(type, { requestId: requestId, success: true, status: 'updated' }); return; + case 'WebAppGetLaunchContext': + reply(type, { requestId: requestId, entryPoint: 'default' }); + return; + case 'WebAppGetViewportSize': + reply(type, { requestId: requestId, height: window.innerHeight, width: window.innerWidth, isStateStable: true }); + return; + case 'WebAppSetupScreenCaptureBehavior': + reply(type, { requestId: requestId, isScreenCaptureEnabled: !!data.isScreenCaptureEnabled }); + return; + case 'WebAppRequestPhone': + reply(type, { requestId: requestId, error: { code: 'client.request_phone.user_refused_provide_phone_number' } }); + return; + case 'WebAppVerifyMobileId': + (function(){ + try { + window.flutter_inappwebview.callHandler('verifyMobileId', data.url).then(function(res){ + if (res && typeof res.statusCode !== 'undefined') { + reply(type, { requestId: requestId, statusCode: res.statusCode, headers: res.headers || {}, data: res.data || '' }, true); + } else { + reply(type, { requestId: requestId, error: { code: 'client.verify_mobile_id.request_failed' } }, true); + } + }).catch(function(e){ + reply(type, { requestId: requestId, error: { code: 'client.verify_mobile_id.request_failed' } }, true); + }); + } catch(e){ + reply(type, { requestId: requestId, error: { code: 'client.verify_mobile_id.json_decode_error' } }, true); + } + })(); + return; case 'WebAppOpenLink': sawOpenLink = true; - if (data && data.url) { - setTimeout(function(){ - try { window.location.assign(data.url); } catch(e){} - }, 0); + var now = Date.now(); + if (data && data.url && (now - lastTouch < 8000) && (now - lastExternalOpen > 15000)) { + lastExternalOpen = now; + try { window.flutter_inappwebview.callHandler('openExternal', data.url); } catch(e){} + } else { + log('OpenLink dropped (gesture ' + (now - lastTouch) + 'ms, sinceOpen ' + (now - lastExternalOpen) + 'ms)'); } return; case 'WebAppClose': - if (!sawOpenLink) { - try { window.flutter_inappwebview.callHandler('closeWebApp'); } catch(e){} - } return; default: if (type.indexOf('SecureStorage') >= 0 || type.indexOf('DeviceStorage') >= 0) { + var backend = (type.indexOf('SecureStorage') >= 0) ? 's' : 'd'; + var pfx = 'komet_did_' + backend + '_'; var key = data.key; if (/Set|Save|Put/i.test(type)) { - try { localStorage.setItem(ssKey(key), JSON.stringify(data.value !== undefined ? data.value : null)); } catch(e){} - reply(type, { requestId: requestId, success: true }); - } else if (/Remove|Delete|Clear/i.test(type)) { - try { localStorage.removeItem(ssKey(key)); } catch(e){} - reply(type, { requestId: requestId, success: true }); + try { localStorage.setItem(pfx + key, JSON.stringify(data.value !== undefined ? data.value : null)); } catch(e){} + reply(type, { requestId: requestId, status: 'saved' }); + } else if (/Clear/i.test(type)) { + try { + var rm = []; + for (var i = 0; i < localStorage.length; i++) { var k = localStorage.key(i); if (k && k.indexOf(pfx) === 0) rm.push(k); } + for (var j = 0; j < rm.length; j++) localStorage.removeItem(rm[j]); + } catch(e){} + reply(type, { requestId: requestId, status: 'cleared' }); + } else if (/Remove|Delete/i.test(type)) { + try { localStorage.removeItem(pfx + key); } catch(e){} + reply(type, { requestId: requestId, status: 'removed' }); } else { var val = null; try { - var raw = localStorage.getItem(ssKey(key)); + var raw = localStorage.getItem(pfx + key); val = (raw == null) ? null : JSON.parse(raw); } catch(e){} - reply(type, { requestId: requestId, value: val, data: val }); + reply(type, { requestId: requestId, key: key, value: val }); } return; } - if (requestId != null) reply(type, { requestId: requestId }); + if (NO_REPLY[type]) return; + reply(type, { requestId: requestId }); } } try { window.WebViewHandler = { - postEvent: function(type, dataStr){ - try { handle(type, dataStr); } catch(e){} - } + postEvent: function(type, dataStr){ try { handle(type, dataStr, false); } catch(e){} }, + resolveShare: function(){} }; + window.PrivateWebViewHandler = { + postEvent: function(type, dataStr){ try { handle(type, dataStr, true); } catch(e){} }, + resolveShare: function(){} + }; + if (!window.AndroidPerf) window.AndroidPerf = { trackFcp: function(){} }; } catch(e){} })(); '''; class DigitalIdWebScreen extends StatelessWidget { - const DigitalIdWebScreen({super.key}); + final WebAppLaunch? initialLaunch; + + const DigitalIdWebScreen({super.key, this.initialLaunch}); @override Widget build(BuildContext context) { + // Тот же device_id, что уходит в handshake (опкод 6, sessionInit), + // с учётом спуфинга — сервер привязывает Цифровой ID к device'у сессии. + final deviceId = api.deviceId ?? ''; return WebAppScreen( title: 'Цифровой ID', - loader: () => webAppModule.fetchDigitalId(), + preferSystemUserAgent: true, + loader: () async => initialLaunch ?? await webAppModule.fetchDigitalId(), extraUserScripts: [ UserScript( - source: 'window.__KOMET_DID_DEBUG=$kDebugMode;', + source: + 'window.__KOMET_DID_DEBUG=$kDebugMode; window.__KOMET_DID_DEVICE_ID=${jsonEncode(deviceId)};', injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START, ), UserScript( @@ -185,21 +320,45 @@ class DigitalIdWebScreen extends StatelessWidget { return null; }, ); + controller.addJavaScriptHandler( + handlerName: 'openExternal', + callback: (args) async { + final url = args.isNotEmpty ? args.first?.toString() : null; + final uri = (url != null && url.isNotEmpty) ? Uri.tryParse(url) : null; + if (uri != null) { + try { + await launchUrl(uri, mode: LaunchMode.externalApplication); + } catch (_) {} + } + return null; + }, + ); + controller.addJavaScriptHandler( + handlerName: 'verifyMobileId', + callback: (args) async { + final url = args.isNotEmpty ? args.first?.toString() : null; + if (url == null || url.isEmpty) return null; + return digitalIdModule.fetchMobileIdVerification(url); + }, + ); }, onExternalCallback: webAppModule.handleExternalCallback, - onConsoleMessage: kDebugMode - ? (controller, consoleMessage) { - debugPrint('[KOMET-DID] ${consoleMessage.message}'); - } - : null, - onLoadStart: kDebugMode - ? (controller, url) { - final u = url?.toString() ?? ''; - debugPrint( - '[KOMET-DID] loadStart: ${u.length > 160 ? u.substring(0, 160) : u}', - ); - } - : null, + onConsoleMessage: (controller, consoleMessage) { + final msg = '[DID] ${consoleMessage.message}'; + final lvl = consoleMessage.messageLevel.toString().toUpperCase(); + if (lvl.contains('ERROR')) { + logger.e(msg); + } else if (lvl.contains('WARNING')) { + logger.w(msg); + } else { + logger.i(msg); + } + }, + onLoadStart: (controller, url) { + if (url != null) { + logger.i('[DID] loadStart: ${url.scheme}://${url.host}${url.path}'); + } + }, shouldOverrideUrlLoading: (_, action, _) async { final uri = action.request.url; final url = uri?.toString() ?? ''; diff --git a/lib/frontend/screens/webapp/web_app_screen.dart b/lib/frontend/screens/webapp/web_app_screen.dart index fd2bf29..3d9821a 100644 --- a/lib/frontend/screens/webapp/web_app_screen.dart +++ b/lib/frontend/screens/webapp/web_app_screen.dart @@ -26,6 +26,7 @@ class WebAppScreen extends StatefulWidget { onLoadStart; final Future Function(String url)? onExternalCallback; final bool closeAfterExternalCallback; + final bool preferSystemUserAgent; final Future Function( InAppWebViewController controller, NavigationAction navigationAction, @@ -43,6 +44,7 @@ class WebAppScreen extends StatefulWidget { this.onLoadStart, this.onExternalCallback, this.closeAfterExternalCallback = false, + this.preferSystemUserAgent = false, this.shouldOverrideUrlLoading, }); @@ -71,10 +73,20 @@ class _WebAppScreenState extends State { try { // Тот же UA, что уходит в sessionInit (из handshake-устройства ядра), // чтобы веб-аппы видели нативный клиент; фолбэк — браузерный UA спуфа. - _userAgent = - api.session?.userAgent() ?? - await SpoofingService.getWebViewUserAgent() ?? - ''; + // Для веб-аппов с внешней авторизацией (Госуслуги/ЕСИА) клиентский 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(); if (!mounted) return; setState(() => _launch = launch);