ДА ЕБАЛ Я ВСЕЙ ДУШОЙ ВАШ ЦИД
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user