ДА ЕБАЛ Я ВСЕЙ ДУШОЙ ВАШ ЦИД

This commit is contained in:
klockky
2026-08-14 20:33:54 +03:00
parent b57be4c661
commit 198f137826
6 changed files with 358 additions and 86 deletions
+50 -4
View File
@@ -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<String> 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<Map<String, dynamic>?> 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 = <String, String>{};
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;
}
}
+11 -3
View File
@@ -20,8 +20,9 @@ bool hasMiniAppOption(Set<String>? 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<WebAppLaunch> 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<WebAppLaunch> fetchSferum() async {
+51
View File
@@ -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<SessionState>? _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<void> _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();
@@ -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<DigitalIdScreen>
rethrow;
}
}
final cards = await digitalIdModule.getCardsList(passStatus: 'active');
List<DigitalIdAcmsCard> 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<DigitalIdScreen>
);
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) {
@@ -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<void> resetDigitalIdWebData() async {
@@ -21,22 +26,53 @@ Future<void> 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() ?? '';
@@ -26,6 +26,7 @@ class WebAppScreen extends StatefulWidget {
onLoadStart;
final Future<WebAppLaunch> Function(String url)? onExternalCallback;
final bool closeAfterExternalCallback;
final bool preferSystemUserAgent;
final Future<NavigationActionPolicy?> 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<WebAppScreen> {
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);