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

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:convert';
import 'dart:io'; import 'dart:io';
import 'dart:math'; import 'dart:math';
import 'dart:typed_data';
import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:device_info_plus/device_info_plus.dart'; import 'package:device_info_plus/device_info_plus.dart';
import '../../core/storage/app_database.dart'; import '../../core/storage/app_database.dart';
import '../../core/storage/spoofing_service.dart'; import '../../core/storage/spoofing_service.dart';
import '../../core/storage/token_storage.dart'; import '../../core/storage/token_storage.dart';
import '../../core/utils/logger.dart';
import '../../models/digital_id.dart'; import '../../models/digital_id.dart';
import 'webapp.dart'; import 'webapp.dart';
class DigitalIdException implements Exception { class DigitalIdException implements Exception {
final String code; final String code;
final String message; 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 isUnauthorized => code == 'UNAUTHORIZED';
bool get isNoGosuslugiLink => code == 'NO_GOSUSLUGI_LINK'; bool get isNoGosuslugiLink => code == 'NO_GOSUSLUGI_LINK';
@@ -61,10 +66,21 @@ class DigitalIdModule {
).firstMatch(fragment); ).firstMatch(fragment);
final raw = match?.group(1); final raw = match?.group(1);
if (raw == null || raw.isEmpty) return null; 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 { 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!; if (_deviceId != null) return _deviceId!;
final accountId = await TokenStorage.getActiveAccountId(); final accountId = await TokenStorage.getActiveAccountId();
if (accountId != null) { if (accountId != null) {
@@ -144,6 +160,9 @@ class DigitalIdModule {
} }
final response = await request.close(); final response = await request.close();
final text = await response.transform(utf8.decoder).join(); final text = await response.transform(utf8.decoder).join();
if (kDebugMode) {
logger.i('[DID-native] $method $path -> ${response.statusCode}');
}
if (response.statusCode == 401 && retry) { if (response.statusCode == 401 && retry) {
await _ensureWebAppData(forceRefresh: true); await _ensureWebAppData(forceRefresh: true);
@@ -170,7 +189,7 @@ class DigitalIdModule {
} }
} catch (_) {} } catch (_) {}
if (statusCode == 401) code = 'UNAUTHORIZED'; if (statusCode == 401) code = 'UNAUTHORIZED';
return DigitalIdException(code, message); return DigitalIdException(code, message, statusCode: statusCode);
} }
Map _unwrapData(dynamic decoded) { Map _unwrapData(dynamic decoded) {
@@ -246,7 +265,7 @@ class DigitalIdModule {
); );
return _unwrapData(decoded)['shadow_mode'] == true; return _unwrapData(decoded)['shadow_mode'] == true;
} on DigitalIdException catch (e) { } on DigitalIdException catch (e) {
if (e.code == 'HTTP_404') return false; if (e.statusCode == 404) return false;
rethrow; rethrow;
} }
} }
@@ -377,7 +396,34 @@ class DigitalIdModule {
return null; 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() { void reset() {
_webAppData = null; _webAppData = null;
_deviceId = null;
_realUserAgent = null;
} }
} }
+11 -3
View File
@@ -20,8 +20,9 @@ bool hasMiniAppOption(Set<String>? options) =>
class WebAppLaunch { class WebAppLaunch {
final String url; final String url;
final String? queryId;
const WebAppLaunch({required this.url}); const WebAppLaunch({required this.url, this.queryId});
} }
class ExternalCallbackResult { class ExternalCallbackResult {
@@ -64,6 +65,10 @@ class WebAppModule {
WebAppModule(this._api); WebAppModule(this._api);
/// device_id сессии (опкод 6 sessionInit, с учётом спуфинга) — тот же id,
/// к которому сервер привязывает Цифровой ID.
String? get sessionDeviceId => _api.deviceId;
Future<WebAppLaunch> fetchLaunch( Future<WebAppLaunch> fetchLaunch(
int botId, { int botId, {
String? startParam, String? startParam,
@@ -72,9 +77,11 @@ class WebAppModule {
if (_api.state != SessionState.online) { if (_api.state != SessionState.online) {
throw const WebAppUnavailable('Нет соединения с сервером'); throw const WebAppUnavailable('Нет соединения с сервером');
} }
final normalizedStartParam =
(startParam != null && startParam.trim().isNotEmpty) ? startParam : null;
final packet = await _api.sendRequest(Opcode.webAppInitData, { final packet = await _api.sendRequest(Opcode.webAppInitData, {
'botId': botId, 'botId': botId,
'startParam': ?startParam, 'startParam': ?normalizedStartParam,
'chatId': ?chatId, 'chatId': ?chatId,
}); });
if (!packet.isOk) { if (!packet.isOk) {
@@ -85,7 +92,8 @@ class WebAppModule {
if (url == null || url.isEmpty) { if (url == null || url.isEmpty) {
throw const WebAppUnavailable('Сервер не вернул адрес приложения'); 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 { Future<WebAppLaunch> fetchSferum() async {
+51
View File
@@ -1,10 +1,14 @@
import 'dart:async'; import 'dart:async';
import 'package:app_links/app_links.dart'; import 'package:app_links/app_links.dart';
import 'package:flutter/widgets.dart';
import '../../backend/api.dart'; import '../../backend/api.dart';
import '../../frontend/debug/log_export.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/max_link_handler.dart';
import '../../frontend/widgets/swipe_route.dart';
import '../../main.dart'; import '../../main.dart';
import 'desktop_url_scheme.dart'; import 'desktop_url_scheme.dart';
import 'max_link.dart'; import 'max_link.dart';
@@ -19,6 +23,9 @@ class DeepLinkService {
StreamSubscription<SessionState>? _stateSub; StreamSubscription<SessionState>? _stateSub;
String? _pending; String? _pending;
bool _pendingLogExport = false; bool _pendingLogExport = false;
String? _pendingExternalCallback;
String? _lastExternalCallback;
Timer? _externalCallbackRetry;
Timer? _logExportRetry; Timer? _logExportRetry;
bool _ready = false; bool _ready = false;
bool _started = false; bool _started = false;
@@ -51,6 +58,14 @@ class DeepLinkService {
_flushPending(); _flushPending();
return; return;
} }
if (_isExternalCallback(uri)) {
final callbackUrl = uri.toString();
if (callbackUrl == _lastExternalCallback) return;
_lastExternalCallback = callbackUrl;
_pendingExternalCallback = callbackUrl;
_flushPending();
return;
}
final url = _normalize(uri); final url = _normalize(uri);
if (url == null) return; if (url == null) return;
_pending = url; _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; if (!_ready || context == null) return;
final pending = _pending; final pending = _pending;
if (pending == null) return; if (pending == null) return;
@@ -81,6 +109,29 @@ class DeepLinkService {
tryHandleMaxLink(context, pending); 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) { bool _isLogExportLink(Uri uri) {
final scheme = uri.scheme.toLowerCase(); final scheme = uri.scheme.toLowerCase();
final host = uri.host.toLowerCase(); final host = uri.host.toLowerCase();
@@ -2,19 +2,18 @@ import 'package:flutter/material.dart';
import 'package:m3e_collection/m3e_collection.dart' import 'package:m3e_collection/m3e_collection.dart'
show ExpressiveRefreshIndicator; show ExpressiveRefreshIndicator;
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../../backend/modules/digital_id.dart'; import '../../../backend/modules/digital_id.dart';
import '../../../backend/modules/webapp.dart';
import '../../../core/utils/webview_support.dart'; import '../../../core/utils/webview_support.dart';
import '../../../l10n/app_localizations.dart'; import '../../../l10n/app_localizations.dart';
import '../../../main.dart' show digitalIdModule, webAppModule; import '../../../main.dart' show digitalIdModule;
import '../../../models/digital_id.dart'; import '../../../models/digital_id.dart';
import '../../widgets/connection_status.dart'; import '../../widgets/connection_status.dart';
import '../../widgets/reload_on_reconnect.dart'; import '../../widgets/reload_on_reconnect.dart';
import '../../widgets/custom_notification.dart'; import '../../widgets/custom_notification.dart';
import '../../widgets/error_view.dart'; import '../../widgets/error_view.dart';
import '../../widgets/small_spinner.dart'; import '../../widgets/small_spinner.dart';
import '../webapp/web_app_screen.dart';
String _documentLabel(AppLocalizations l10n, String type) { String _documentLabel(AppLocalizations l10n, String type) {
return switch (type) { return switch (type) {
@@ -79,7 +78,12 @@ class _DigitalIdScreenState extends State<DigitalIdScreen>
rethrow; rethrow;
} }
} }
final cards = await digitalIdModule.getCardsList(passStatus: 'active'); List<DigitalIdAcmsCard> cards;
try {
cards = await digitalIdModule.getCardsList(passStatus: 'active');
} catch (_) {
cards = const [];
}
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
_biometry = biometry; _biometry = biometry;
@@ -122,21 +126,13 @@ class _DigitalIdScreenState extends State<DigitalIdScreen>
); );
return; return;
} }
await Navigator.push( // Госуслуги (ЕСИА) открываем во ВНЕШНЕМ браузере — их антифрод режет
context, // встроенный webview. Возврат придёт диплинком max.ru?externalCallback=1
MaterialPageRoute( // (deep_link_service -> опкод 105), после чего экран перезагрузится.
builder: (context) => WebAppScreen( final uri = Uri.tryParse(link.url);
title: AppLocalizations.of(context)!.digitalIdGosuslugiTitle, if (uri != null) {
loader: () async => WebAppLaunch(url: link.url), await launchUrl(uri, mode: LaunchMode.externalApplication);
onExternalCallback: webAppModule.handleExternalCallback, }
closeAfterExternalCallback: true,
),
),
);
if (!mounted) return;
await digitalIdModule.loadDocuments(createIfMissing: true);
if (!mounted) return;
await _load();
} on DigitalIdException catch (e) { } on DigitalIdException catch (e) {
if (mounted) showCustomNotification(context, e.message); if (mounted) showCustomNotification(context, e.message);
} catch (e) { } catch (e) {
@@ -1,8 +1,13 @@
import 'dart:convert';
import 'package:flutter/foundation.dart' show kDebugMode; import 'package:flutter/foundation.dart' show kDebugMode;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_inappwebview/flutter_inappwebview.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'; import '../webapp/web_app_screen.dart';
Future<void> resetDigitalIdWebData() async { Future<void> resetDigitalIdWebData() async {
@@ -21,22 +26,53 @@ Future<void> resetDigitalIdSession() async {
const String _kBridge = r''' const String _kBridge = r'''
(function(){ (function(){
if (!/(^|\.)max\.ru$/i.test(location.hostname)) { return; }
var sawOpenLink = false; var sawOpenLink = false;
var DBG = !!window.__KOMET_DID_DEBUG; 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) { if (DBG) {
try { try {
var origFetch = window.fetch; var origFetch = window.fetch;
window.fetch = function(){ window.fetch = function(){
var u; var u, method = 'GET';
try { u = (typeof arguments[0] === 'string') ? arguments[0] : (arguments[0] && arguments[0].url); } catch(e){} try {
var watched = ('' + u).indexOf('ext-api') >= 0; 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){ return origFetch.apply(this, arguments).then(function(r){
if (watched) { 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; 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){} } catch(e){}
} }
@@ -55,7 +91,13 @@ const String _kBridge = r'''
} }
try { try {
var uid = userId(); 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 { localStorage.clear(); } catch(e){}
try { sessionStorage.clear(); } catch(e){} try { sessionStorage.clear(); } catch(e){}
try { try {
@@ -65,13 +107,34 @@ const String _kBridge = r'''
}); });
} }
} catch(e){} } catch(e){}
localStorage.setItem('komet_did_owner', uid);
} }
if (uid !== 'anon') { localStorage.setItem('komet_did_owner', uid); }
} catch(e){} } catch(e){}
function reply(type, data){ var pending = [];
setTimeout(function(){ function chan(priv){
try { window.WebApp.receiveEvent(type, data); } catch(e){} var pub = (window.WebApp && typeof window.WebApp.sendEvent === 'function') ? window.WebApp : null;
}, 0); 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(){ function bioToken(){
try { try {
@@ -88,88 +151,160 @@ const String _kBridge = r'''
function tokenSaved(){ function tokenSaved(){
try { return !!localStorage.getItem('komet_did_bio_token'); } catch(e){ return false; } 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 = {}; var data = {};
try { data = JSON.parse(dataStr || '{}'); } catch(e){} try { data = JSON.parse(dataStr || '{}'); } catch(e){}
log('recv ' + type + ' ' + dataStr); log('recv ' + (priv ? '[priv] ' : '') + type + ' ' + dataStr);
var requestId = data.requestId; var requestId = data.requestId;
switch (type) { switch (type) {
case 'WebAppBiometryGetInfo': case 'WebAppBiometryGetInfo':
reply(type, { reply(type, {
requestId: requestId, available: true, requestId: requestId, available: true,
access_requested: tokenSaved(), accessRequested: tokenSaved(), accessRequested: tokenSaved(), accessGranted: tokenSaved(),
access_granted: tokenSaved(), accessGranted: tokenSaved(), access_requested: tokenSaved(), access_granted: tokenSaved(),
token_saved: tokenSaved(), tokenSaved: tokenSaved(), tokenSaved: tokenSaved(), token_saved: tokenSaved(),
device_id: 'komet-device', deviceId: 'komet-device', deviceId: didDeviceId(), device_id: didDeviceId(),
type: 'face', biometricType: 'face' type: ['face']
}); });
return; return;
case 'WebAppBiometryRequestAccess': 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; return;
case 'WebAppBiometryAuthenticate': 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; return;
case 'WebAppBiometryUpdateToken': case 'WebAppBiometryUpdateToken':
case 'WebAppBiometryUpdateBiometricToken': case 'WebAppBiometryUpdateBiometricToken':
reply(type, { requestId: requestId, success: true, status: 'updated' }); reply(type, { requestId: requestId, success: true, status: 'updated' });
return; 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': case 'WebAppOpenLink':
sawOpenLink = true; sawOpenLink = true;
if (data && data.url) { var now = Date.now();
setTimeout(function(){ if (data && data.url && (now - lastTouch < 8000) && (now - lastExternalOpen > 15000)) {
try { window.location.assign(data.url); } catch(e){} lastExternalOpen = now;
}, 0); try { window.flutter_inappwebview.callHandler('openExternal', data.url); } catch(e){}
} else {
log('OpenLink dropped (gesture ' + (now - lastTouch) + 'ms, sinceOpen ' + (now - lastExternalOpen) + 'ms)');
} }
return; return;
case 'WebAppClose': case 'WebAppClose':
if (!sawOpenLink) {
try { window.flutter_inappwebview.callHandler('closeWebApp'); } catch(e){}
}
return; return;
default: default:
if (type.indexOf('SecureStorage') >= 0 || type.indexOf('DeviceStorage') >= 0) { 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; var key = data.key;
if (/Set|Save|Put/i.test(type)) { if (/Set|Save|Put/i.test(type)) {
try { localStorage.setItem(ssKey(key), JSON.stringify(data.value !== undefined ? data.value : null)); } catch(e){} try { localStorage.setItem(pfx + key, JSON.stringify(data.value !== undefined ? data.value : null)); } catch(e){}
reply(type, { requestId: requestId, success: true }); reply(type, { requestId: requestId, status: 'saved' });
} else if (/Remove|Delete|Clear/i.test(type)) { } else if (/Clear/i.test(type)) {
try { localStorage.removeItem(ssKey(key)); } catch(e){} try {
reply(type, { requestId: requestId, success: true }); 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 { } else {
var val = null; var val = null;
try { try {
var raw = localStorage.getItem(ssKey(key)); var raw = localStorage.getItem(pfx + key);
val = (raw == null) ? null : JSON.parse(raw); val = (raw == null) ? null : JSON.parse(raw);
} catch(e){} } catch(e){}
reply(type, { requestId: requestId, value: val, data: val }); reply(type, { requestId: requestId, key: key, value: val });
} }
return; return;
} }
if (requestId != null) reply(type, { requestId: requestId }); if (NO_REPLY[type]) return;
reply(type, { requestId: requestId });
} }
} }
try { try {
window.WebViewHandler = { window.WebViewHandler = {
postEvent: function(type, dataStr){ postEvent: function(type, dataStr){ try { handle(type, dataStr, false); } catch(e){} },
try { handle(type, dataStr); } 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){} } catch(e){}
})(); })();
'''; ''';
class DigitalIdWebScreen extends StatelessWidget { class DigitalIdWebScreen extends StatelessWidget {
const DigitalIdWebScreen({super.key}); final WebAppLaunch? initialLaunch;
const DigitalIdWebScreen({super.key, this.initialLaunch});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Тот же device_id, что уходит в handshake (опкод 6, sessionInit),
// с учётом спуфинга — сервер привязывает Цифровой ID к device'у сессии.
final deviceId = api.deviceId ?? '';
return WebAppScreen( return WebAppScreen(
title: 'Цифровой ID', title: 'Цифровой ID',
loader: () => webAppModule.fetchDigitalId(), preferSystemUserAgent: true,
loader: () async => initialLaunch ?? await webAppModule.fetchDigitalId(),
extraUserScripts: [ extraUserScripts: [
UserScript( 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, injectionTime: UserScriptInjectionTime.AT_DOCUMENT_START,
), ),
UserScript( UserScript(
@@ -185,21 +320,45 @@ class DigitalIdWebScreen extends StatelessWidget {
return null; 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, onExternalCallback: webAppModule.handleExternalCallback,
onConsoleMessage: kDebugMode onConsoleMessage: (controller, consoleMessage) {
? (controller, consoleMessage) { final msg = '[DID] ${consoleMessage.message}';
debugPrint('[KOMET-DID] ${consoleMessage.message}'); final lvl = consoleMessage.messageLevel.toString().toUpperCase();
} if (lvl.contains('ERROR')) {
: null, logger.e(msg);
onLoadStart: kDebugMode } else if (lvl.contains('WARNING')) {
? (controller, url) { logger.w(msg);
final u = url?.toString() ?? ''; } else {
debugPrint( logger.i(msg);
'[KOMET-DID] loadStart: ${u.length > 160 ? u.substring(0, 160) : u}', }
); },
} onLoadStart: (controller, url) {
: null, if (url != null) {
logger.i('[DID] loadStart: ${url.scheme}://${url.host}${url.path}');
}
},
shouldOverrideUrlLoading: (_, action, _) async { shouldOverrideUrlLoading: (_, action, _) async {
final uri = action.request.url; final uri = action.request.url;
final url = uri?.toString() ?? ''; final url = uri?.toString() ?? '';
@@ -26,6 +26,7 @@ class WebAppScreen extends StatefulWidget {
onLoadStart; onLoadStart;
final Future<WebAppLaunch> Function(String url)? onExternalCallback; final Future<WebAppLaunch> Function(String url)? onExternalCallback;
final bool closeAfterExternalCallback; final bool closeAfterExternalCallback;
final bool preferSystemUserAgent;
final Future<NavigationActionPolicy?> Function( final Future<NavigationActionPolicy?> Function(
InAppWebViewController controller, InAppWebViewController controller,
NavigationAction navigationAction, NavigationAction navigationAction,
@@ -43,6 +44,7 @@ class WebAppScreen extends StatefulWidget {
this.onLoadStart, this.onLoadStart,
this.onExternalCallback, this.onExternalCallback,
this.closeAfterExternalCallback = false, this.closeAfterExternalCallback = false,
this.preferSystemUserAgent = false,
this.shouldOverrideUrlLoading, this.shouldOverrideUrlLoading,
}); });
@@ -71,10 +73,20 @@ class _WebAppScreenState extends State<WebAppScreen> {
try { try {
// Тот же UA, что уходит в sessionInit (из handshake-устройства ядра), // Тот же UA, что уходит в sessionInit (из handshake-устройства ядра),
// чтобы веб-аппы видели нативный клиент; фолбэк — браузерный UA спуфа. // чтобы веб-аппы видели нативный клиент; фолбэк — браузерный UA спуфа.
_userAgent = // Для веб-аппов с внешней авторизацией (Госуслуги/ЕСИА) клиентский UA
api.session?.userAgent() ?? // ядра отбраковывается антифродом — там нужен UA настоящего WebView.
await SpoofingService.getWebViewUserAgent() ?? _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 launch = await widget.loader();
if (!mounted) return; if (!mounted) return;
setState(() => _launch = launch); setState(() => _launch = launch);