From 815e41648ab605185c0d0746bd5e7acafb0a2e01 Mon Sep 17 00:00:00 2001 From: Jganenokk Date: Sun, 5 Jul 2026 23:43:15 +0700 Subject: [PATCH] =?UTF-8?q?=D0=BD=D0=B0=D0=B4=D0=B5=D1=8E=D1=81=D1=8C=20?= =?UTF-8?q?=D0=B1=D1=83=D0=B4=D0=B5=D1=82=20=D1=80=D0=B0=D0=B1=D0=BE=D1=82?= =?UTF-8?q?=D0=B0=D1=82=D1=8C=20=D1=81=D1=83=D0=BAi?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/backend/api.dart | 232 ++++++++++++------ lib/backend/modules/account.dart | 13 +- .../modules/account/account_models.dart | 72 +++++- lib/core/config/proxy_config.dart | 45 +++- lib/core/transport/connection.dart | 48 +++- lib/core/utils/debug_session_log.dart | 70 +++++- lib/core/utils/logger.dart | 13 +- lib/frontend/debug/quick_actions_section.dart | 2 +- .../screens/profile/debug_menu_screen.dart | 2 +- lib/main.dart | 39 +++ 10 files changed, 419 insertions(+), 117 deletions(-) diff --git a/lib/backend/api.dart b/lib/backend/api.dart index 8852943..0be7e66 100644 --- a/lib/backend/api.dart +++ b/lib/backend/api.dart @@ -70,10 +70,16 @@ class Api { StreamSubscription? _socketStateSubscription; Timer? _pingTimer; Timer? _reconnectTimer; + Timer? _connectWatchdog; + int _connectGen = 0; int _reconnectAttempts = 0; bool _autoReconnect = false; int _sessionEpoch = 0; + static const Duration _connectWatchdogTimeout = Duration(seconds: 75); + static const Duration _shouldArmTimeout = Duration(seconds: 5); + static const Duration _endpointTimeout = Duration(seconds: 5); + int get sessionEpoch => _sessionEpoch; /// Залипает на время сессии: VPN-путь не сработал — идём мимо туннеля. @@ -83,87 +89,168 @@ class Api { /// Подключается к серверу, шлёт хэндшейк, запускает пинг. Future connect() async { - if (_sessionState != SessionState.disconnected) return; - // Ставим автоматический реконнект и статус подключения - _autoReconnect = true; - _setSessionState(SessionState.connecting); - - _dataSubscription = _connection.dataStream.listen(_onDataReceived); - _socketStateSubscription = _connection.stateStream.listen((socketState) { - if (socketState == SocketState.disconnected && - _sessionState != SessionState.disconnected) { - _onDisconnected(); - } - }); - - final bypassArmed = await VpnBypassService.instance.shouldArm(); - if (!bypassArmed) _bypassActive = false; - final useBypass = _bypassActive && bypassArmed; - // Попытку через VPN ограничиваем по времени, чтобы быстро понять, - // что туннель не пропускает, и переключиться на обход. - final attemptTimeout = bypassArmed && !useBypass - ? const Duration(seconds: 8) - : null; - - try { - final endpoint = await ServerConfig.loadEndpoint(); - await _connection.connect( - endpoint.host, - endpoint.port, - bypassVpn: useBypass, - timeout: attemptTimeout, - ); - } catch (e) { - await _handleConnectFailure( - e, - phase: 'Не удалось подключиться', - bypassArmed: bypassArmed, - useBypass: useBypass, - bypassWhy: 'подключение не удалось', - ); + if (_sessionState != SessionState.disconnected) { + logger.i('connect пропущен: состояние ${_sessionState.name}'); return; } - - _setSessionState(SessionState.connected); - _reconnectAttempts = 0; + _autoReconnect = true; + final gen = ++_connectGen; + _setSessionState(SessionState.connecting); + logger.i('connect: старт (поколение $gen)'); + _armConnectWatchdog(gen); try { - final response = await sendHandshake(); - if (response.isOk) { - _callsSeed = response.payload['callsSeed'] as int?; - _registrationCountries = _parseRegistrationCountries(response.payload); - _sessionState = SessionState.online; - _sessionEpoch++; - _startPinging(); - logger.i('Сессия онлайн, хэндшейк ок'); - if (_onReconnectCallback != null) { - try { - await _onReconnectCallback!(); - } catch (e) { - logger.w('Авто-логин при хэндшейке не удался: $e'); - } + _dataSubscription = _connection.dataStream.listen(_onDataReceived); + _socketStateSubscription = _connection.stateStream.listen((socketState) { + if (socketState == SocketState.disconnected && + _sessionState != SessionState.disconnected) { + _onDisconnected(); } - if (_sessionState == SessionState.online) { - _stateController.add(SessionState.online); - _handshakeSuccessController.add( - response.payload['device_name'] as String? ?? 'Unknown', + }); + + bool bypassArmed; + try { + bypassArmed = await VpnBypassService.instance.shouldArm().timeout( + _shouldArmTimeout, + ); + } catch (e) { + logger.w('connect: shouldArm завис/упал ($e) — без обхода VPN'); + bypassArmed = false; + } + if (gen != _connectGen) return; + + if (!bypassArmed) _bypassActive = false; + final useBypass = _bypassActive && bypassArmed; + final attemptTimeout = bypassArmed && !useBypass + ? const Duration(seconds: 8) + : null; + + ({String host, int port}) endpoint; + try { + endpoint = await ServerConfig.loadEndpoint().timeout(_endpointTimeout); + } catch (e) { + logger.w('connect: loadEndpoint завис/упал ($e) — дефолтный endpoint'); + endpoint = ( + host: ServerConfig.defaultHost, + port: ServerConfig.defaultPort, + ); + } + if (gen != _connectGen) return; + + logger.i( + 'connect: endpoint ${endpoint.host}:${endpoint.port}, bypass=$useBypass', + ); + try { + await _connection.connect( + endpoint.host, + endpoint.port, + bypassVpn: useBypass, + timeout: attemptTimeout, + ); + } catch (e) { + if (gen != _connectGen) return; + await _handleConnectFailure( + e, + phase: 'Не удалось подключиться', + bypassArmed: bypassArmed, + useBypass: useBypass, + bypassWhy: 'подключение не удалось', + ); + return; + } + if (gen != _connectGen) return; + + _setSessionState(SessionState.connected); + _reconnectAttempts = 0; + + try { + logger.i('connect: сокет готов, отправляю хэндшейк'); + final response = await sendHandshake(); + if (gen != _connectGen) return; + if (response.isOk) { + _callsSeed = response.payload['callsSeed'] as int?; + _registrationCountries = _parseRegistrationCountries( + response.payload, + ); + _sessionState = SessionState.online; + _sessionEpoch++; + _cancelConnectWatchdog(); + _startPinging(); + logger.i('Сессия онлайн, хэндшейк ок'); + if (_onReconnectCallback != null) { + try { + await _onReconnectCallback!(); + } catch (e) { + logger.w('Авто-логин при хэндшейке не удался: $e'); + } + } + if (_sessionState == SessionState.online) { + _stateController.add(SessionState.online); + _handshakeSuccessController.add( + response.payload['device_name'] as String? ?? 'Unknown', + ); + } + } else { + logger.e('Хэндшейк отклонён: ${response.payload}'); + await _handleConnectFailure( + StateError('хэндшейк отклонён сервером'), + phase: 'Хэндшейк отклонён', + bypassArmed: bypassArmed, + useBypass: useBypass, + bypassWhy: 'хэндшейк отклонён', + disconnectSocket: true, ); } - } else { - logger.e('Хэндшейк отклонён: ${response.payload}'); + } catch (e) { + if (gen != _connectGen) return; + await _handleConnectFailure( + e, + phase: 'Ошибка хэндшейка', + bypassArmed: bypassArmed, + useBypass: useBypass, + bypassWhy: 'хэндшейк не прошёл', + disconnectSocket: true, + ); } - } catch (e) { - await _handleConnectFailure( - e, - phase: 'Ошибка хэндшейка', - bypassArmed: bypassArmed, - useBypass: useBypass, - bypassWhy: 'хэндшейк не прошёл', - disconnectSocket: true, - ); + } catch (e, st) { + logger.e('connect: непредвиденная ошибка: $e\n$st'); + if (gen == _connectGen) await _resetStuckConnect(gen); } } + void _armConnectWatchdog(int gen) { + _connectWatchdog?.cancel(); + _connectWatchdog = Timer(_connectWatchdogTimeout, () { + if (gen != _connectGen) return; + if (_sessionState == SessionState.online || + _sessionState == SessionState.disconnected) { + return; + } + logger.e( + 'connect: watchdog ${_connectWatchdogTimeout.inSeconds}с — застряли в ' + '${_sessionState.name}, принудительный сброс', + ); + unawaited(_resetStuckConnect(gen)); + }); + } + + void _cancelConnectWatchdog() { + _connectWatchdog?.cancel(); + _connectWatchdog = null; + } + + Future _resetStuckConnect(int gen) async { + if (gen != _connectGen) return; + _connectGen++; + _cancelConnectWatchdog(); + _cleanup(); + try { + await _connection.disconnect(); + } catch (_) {} + _setSessionState(SessionState.disconnected); + if (_autoReconnect) _scheduleReconnect(); + } + Future _handleConnectFailure( Object error, { required String phase, @@ -173,6 +260,7 @@ class Api { bool disconnectSocket = false, }) async { logger.e('$phase: $error'); + _cancelConnectWatchdog(); if (_sessionState != SessionState.disconnected) { _cleanup(); if (disconnectSocket) await _connection.disconnect(); @@ -193,6 +281,7 @@ class Api { Future disconnect() async { _autoReconnect = false; _bypassActive = false; + _connectGen++; _reconnectTimer?.cancel(); _cleanup(); await _connection.disconnect(); @@ -441,6 +530,7 @@ class Api { } void _onDisconnected() { + _connectGen++; _cleanup(); _setSessionState(SessionState.disconnected); if (_autoReconnect) _scheduleReconnect(); @@ -463,6 +553,7 @@ class Api { } Future _forceReconnect() async { + _connectGen++; _cleanup(); await _connection.disconnect(); _reconnectAttempts = 0; @@ -472,6 +563,7 @@ class Api { } void _cleanup() { + _cancelConnectWatchdog(); _pingTimer?.cancel(); _dataSubscription?.cancel(); _socketStateSubscription?.cancel(); diff --git a/lib/backend/modules/account.dart b/lib/backend/modules/account.dart index be7507a..5112b4e 100644 --- a/lib/backend/modules/account.dart +++ b/lib/backend/modules/account.dart @@ -269,16 +269,15 @@ class AccountModule { final dataMap = data.cast(); if (resolvedAccountId == null) { - final profileMap = dataMap['profile']; - if (profileMap is Map) { - final contact = profileMap['contact']; - if (contact is Map) { - resolvedAccountId = contact['id'] as int?; - } - } + resolvedAccountId = extractAccountId(dataMap); if (resolvedAccountId == null) { + logger.e( + 'login: accountId не найден в ответе; ' + '${describeResponseShape(dataMap)}', + ); throw Exception('login: не удалось определить accountId из ответа'); } + logger.i('login: accountId=$resolvedAccountId определён из ответа'); await TokenStorage.saveToken(authToken, resolvedAccountId); await TokenStorage.setActiveAccount(resolvedAccountId); await SpoofingService.commitPendingSpoof(resolvedAccountId); diff --git a/lib/backend/modules/account/account_models.dart b/lib/backend/modules/account/account_models.dart index 989a4f2..844e6b8 100644 --- a/lib/backend/modules/account/account_models.dart +++ b/lib/backend/modules/account/account_models.dart @@ -2,6 +2,70 @@ import 'dart:convert'; import '../../../core/storage/app_database.dart'; +int? _coerceAccountId(dynamic value) { + if (value is int) return value; + if (value is double) return value.toInt(); + if (value is String) return int.tryParse(value.trim()); + return null; +} + +int? extractAccountId(dynamic response) { + if (response is! Map) return null; + + final profile = response['profile']; + if (profile is Map) { + final contact = profile['contact']; + if (contact is Map) { + final cid = + _coerceAccountId(contact['id']) ?? + _coerceAccountId(contact['contactId']) ?? + _coerceAccountId(contact['accountId']); + if (cid != null) return cid; + } + final pid = + _coerceAccountId(profile['id']) ?? + _coerceAccountId(profile['accountId']); + if (pid != null) return pid; + } + + final contact = response['contact']; + if (contact is Map) { + final cid = + _coerceAccountId(contact['id']) ?? + _coerceAccountId(contact['contactId']); + if (cid != null) return cid; + } + + final account = response['account']; + if (account is Map) { + final aid = + _coerceAccountId(account['id']) ?? + _coerceAccountId(account['accountId']); + if (aid != null) return aid; + } + + return _coerceAccountId(response['accountId']) ?? + _coerceAccountId(response['account_id']); +} + +String describeResponseShape(dynamic response) { + if (response is! Map) return 'не-Map (${response.runtimeType})'; + final sb = StringBuffer('keys=${response.keys.toList()}'); + final profile = response['profile']; + if (profile is Map) { + sb.write(' profile.keys=${profile.keys.toList()}'); + final contact = profile['contact']; + if (contact is Map) { + sb.write(' contact.keys=${contact.keys.toList()}'); + } else { + sb.write(' profile.contact=${contact.runtimeType}'); + } + } else { + sb.write(' profile=${profile.runtimeType}'); + } + return sb.toString(); +} + class PrivacyConfig { final String searchByPhone; final String incomingCall; @@ -295,13 +359,7 @@ class VerifyCodeResult { String? get challengeHint => passwordChallenge?['hint'] as String?; - int? get accountId { - final profileData = payload['profile']; - if (profileData is! Map) return null; - final contact = profileData['contact']; - if (contact is! Map) return null; - return contact['id'] as int?; - } + int? get accountId => extractAccountId(payload); String? _nestedToken(String key) { final attrs = payload['tokenAttrs']; diff --git a/lib/core/config/proxy_config.dart b/lib/core/config/proxy_config.dart index ba5cc40..55d4c70 100644 --- a/lib/core/config/proxy_config.dart +++ b/lib/core/config/proxy_config.dart @@ -1,6 +1,9 @@ +import 'dart:async'; + import 'package:shared_preferences/shared_preferences.dart'; import '../storage/token_storage.dart'; +import '../utils/logger.dart'; enum ProxyType { none, socks5, httpConnect } @@ -35,26 +38,52 @@ abstract class ProxyConfig { static const String _prefUsername = 'proxy_username'; static const String _prefPassword = 'proxy_password'; + static const Duration _secureReadTimeout = Duration(seconds: 5); + + static Future _readSecureSafe(String key) async { + try { + return await TokenStorage.readSecure(key).timeout(_secureReadTimeout); + } catch (e) { + logger.w('ProxyConfig: чтение secure "$key" не удалось/зависло: $e'); + return null; + } + } + + static Future _migrateLegacySecure(String key, String value) async { + try { + await TokenStorage.writeSecure(key, value).timeout(_secureReadTimeout); + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(key); + } catch (e) { + logger.w('ProxyConfig: миграция legacy "$key" не удалась: $e'); + } + } + static Future load() async { final prefs = await SharedPreferences.getInstance(); final typeIndex = prefs.getInt(_prefType) ?? 0; final host = prefs.getString(_prefHost) ?? ''; final port = prefs.getInt(_prefPort) ?? 1080; - var username = await TokenStorage.readSecure(_prefUsername); - var password = await TokenStorage.readSecure(_prefPassword); + if (ProxyType.values[typeIndex.clamp(0, ProxyType.values.length - 1)] == + ProxyType.none || + host.isEmpty) { + return ProxySettings( + type: ProxyType.values[typeIndex.clamp(0, ProxyType.values.length - 1)], + host: host, + port: port, + ); + } + var username = await _readSecureSafe(_prefUsername); + var password = await _readSecureSafe(_prefPassword); final legacyUsername = prefs.getString(_prefUsername); final legacyPassword = prefs.getString(_prefPassword); if (username == null && legacyUsername != null) { username = legacyUsername; - await TokenStorage.writeSecure(_prefUsername, legacyUsername); + unawaited(_migrateLegacySecure(_prefUsername, legacyUsername)); } if (password == null && legacyPassword != null) { password = legacyPassword; - await TokenStorage.writeSecure(_prefPassword, legacyPassword); - } - if (legacyUsername != null || legacyPassword != null) { - await prefs.remove(_prefUsername); - await prefs.remove(_prefPassword); + unawaited(_migrateLegacySecure(_prefPassword, legacyPassword)); } return ProxySettings( type: ProxyType.values[typeIndex.clamp(0, ProxyType.values.length - 1)], diff --git a/lib/core/transport/connection.dart b/lib/core/transport/connection.dart index ac7ee8e..dff5aed 100644 --- a/lib/core/transport/connection.dart +++ b/lib/core/transport/connection.dart @@ -15,6 +15,8 @@ enum SocketState { disconnected, connecting, connected } /// Отдаёт сырые байты через [dataStream], сборкой пакетов занимается [PacketReceiver]. class Connection { static const Duration _defaultConnectTimeout = Duration(seconds: 15); + static const Duration _proxyLoadTimeout = Duration(seconds: 8); + static const Duration _vpnCallTimeout = Duration(seconds: 5); SecureSocket? _socket; StreamSubscription? _subscription; @@ -40,20 +42,41 @@ class Connection { bool bypassVpn = false, Duration? timeout, }) async { - if (_state != SocketState.disconnected) return; + if (_state != SocketState.disconnected) { + logger.w('Connection.connect пропущен: state=$_state (уже $_state)'); + return; + } _setState(SocketState.connecting); try { - final proxySettings = await ProxyConfig.load(); - - // Решение «обходить VPN или нет» принимает вызывающий (Api): - // первая попытка идёт через VPN, при её провале — мимо туннеля. - if (bypassVpn) { - await VpnBypassService.instance.bind(); - } else { - await VpnBypassService.instance.restoreDefault(); + logger.i('Connection: загрузка прокси-конфига'); + ProxySettings proxySettings; + try { + proxySettings = await ProxyConfig.load().timeout(_proxyLoadTimeout); + } catch (e) { + logger.w('Connection: ProxyConfig.load завис/упал ($e) — без прокси'); + proxySettings = const ProxySettings(); } + logger.i( + 'Connection: VPN ${bypassVpn ? 'bind (обход)' : 'restoreDefault'}', + ); + try { + if (bypassVpn) { + await VpnBypassService.instance.bind().timeout(_vpnCallTimeout); + } else { + await VpnBypassService.instance + .restoreDefault() + .timeout(_vpnCallTimeout); + } + } catch (e) { + logger.w('Connection: VPN-вызов завис/упал ($e) — продолжаю'); + } + + logger.i( + 'Connection: открываю сокет $host:$port ' + '(прокси: ${proxySettings.isEnabled ? proxySettings.type.name : 'нет'})', + ); final socket = await _openSecureSocket( host, port, @@ -109,7 +132,9 @@ class Connection { socket = await connector.connect(host, port).timeout(connectTimeout); logger.i('Подключено через прокси ${proxySettings.type.name}'); } else { + logger.i('Connection: TCP connect $host:$port (лимит ${connectTimeout.inSeconds}с)'); socket = await Socket.connect(host, port, timeout: connectTimeout); + logger.i('Connection: TCP установлен, начинаю TLS'); } final allowInsecure = await TlsConfig.isInsecureAllowed(); if (allowInsecure) { @@ -121,8 +146,11 @@ class Connection { ? SecureSocket.secure(socket, host: host, onBadCertificate: (_) => true) : SecureSocket.secure(socket, host: host); try { - return await secured.timeout(connectTimeout); + final result = await secured.timeout(connectTimeout); + logger.i('Connection: TLS-handshake завершён'); + return result; } on TimeoutException { + logger.w('Connection: TLS-handshake таймаут ${connectTimeout.inSeconds}с'); socket.destroy(); rethrow; } diff --git a/lib/core/utils/debug_session_log.dart b/lib/core/utils/debug_session_log.dart index 11bf6f8..02cb02f 100644 --- a/lib/core/utils/debug_session_log.dart +++ b/lib/core/utils/debug_session_log.dart @@ -57,11 +57,15 @@ class _SessionData { final DateTime startedAt; final List<_LogEntry> entries; final bool truncated; + final List logLines; + final bool logsTruncated; _SessionData({ required this.startedAt, required this.entries, this.truncated = false, + this.logLines = const [], + this.logsTruncated = false, }); static _SessionData fromJson(Map data) { @@ -72,37 +76,43 @@ class _SessionData { if (e is Map) entries.add(_LogEntry.fromJson(e)); } } + final rawLogs = data['logs']; + final logLines = []; + if (rawLogs is List) { + for (final l in rawLogs) { + logLines.add(l.toString()); + } + } return _SessionData( startedAt: DateTime.tryParse(data['startedAt']?.toString() ?? '') ?? DateTime.fromMillisecondsSinceEpoch(0), entries: entries, truncated: data['truncated'] == true, + logLines: logLines, + logsTruncated: data['logsTruncated'] == true, ); } } -/// Персистентный лог запросов для кнопки «Отладочный лог». -/// -/// Хранит ВСЕ запросы (с payload) за последние [_maxSessions] заходов в -/// приложение: каждый запуск — отдельная сессия-файл, старые ротируются. -/// Пинги идут мимо [Api.sendRequest], поэтому в лог не попадают. -/// -/// Payload сохраняется уже отредактированным — токен скрыт целиком, от номера -/// остаются первые 3 символа, остальное видно. Поэтому секреты не лежат на диске. class DebugSessionLog { DebugSessionLog._(); static final DebugSessionLog instance = DebugSessionLog._(); static const int _maxSessions = 3; static const int _maxEntriesPerSession = 2000; + static const int _maxLogLinesPerSession = 5000; static const Duration _flushDebounce = Duration(seconds: 3); + static final RegExp _ansiEscape = RegExp(r'\x1B\[[0-9;]*m'); + Directory? _dir; File? _currentFile; DateTime? _currentStart; final List<_LogEntry> _entries = []; + final List _logLines = []; bool _truncated = false; + bool _logsTruncated = false; bool _initialized = false; bool _dirty = false; Timer? _flushTimer; @@ -120,12 +130,23 @@ class DebugSessionLog { _currentFile = File( '${dir.path}/session_${_currentStart!.millisecondsSinceEpoch}.json', ); + if (_dirty) _scheduleFlush(); } catch (_) { _dir = null; _currentFile = null; } } + void recordLogLine(String line) { + final clean = line.replaceAll(_ansiEscape, ''); + _logLines.add(clean); + if (_logLines.length > _maxLogLinesPerSession) { + _logLines.removeRange(0, _logLines.length - _maxLogLinesPerSession); + _logsTruncated = true; + } + _scheduleFlush(); + } + void recordRequest(int opcode, int seq, dynamic payload) { _entries.add( _LogEntry( @@ -189,6 +210,8 @@ class DebugSessionLog { 'startedAt': _currentStart?.toIso8601String(), 'truncated': _truncated, 'entries': _entries.map((e) => e.toJson()).toList(), + 'logsTruncated': _logsTruncated, + 'logs': List.of(_logLines), }); await file.writeAsString(data); } catch (_) {} @@ -249,6 +272,8 @@ class DebugSessionLog { startedAt: _currentStart ?? DateTime.now(), entries: List.of(_entries), truncated: _truncated, + logLines: List.of(_logLines), + logsTruncated: _logsTruncated, ), ); sessions.sort((a, b) => a.startedAt.compareTo(b.startedAt)); @@ -256,7 +281,8 @@ class DebugSessionLog { ? sessions.sublist(sessions.length - _maxSessions) : sessions; final totalEntries = lastN.fold(0, (sum, s) => sum + s.entries.length); - if (totalEntries == 0) return null; + final totalLogs = lastN.fold(0, (sum, s) => sum + s.logLines.length); + if (totalEntries == 0 && totalLogs == 0) return null; final buffer = StringBuffer(); buffer.writeln('Komet — отладочный лог'); @@ -264,6 +290,7 @@ class DebugSessionLog { buffer.writeln('Экспортирован: ${DateTime.now().toIso8601String()}'); buffer.writeln('Заходов в приложение: ${lastN.length}'); buffer.writeln('Всего запросов: $totalEntries'); + buffer.writeln('Всего строк лога: $totalLogs'); buffer.writeln('Скрыто: токен полностью, номер кроме первых 3 символов'); buffer.writeln(); @@ -275,12 +302,31 @@ class DebugSessionLog { ); buffer.writeln( 'запросов: ${session.entries.length}' - '${session.truncated ? ' (обрезано до $_maxEntriesPerSession)' : ''}', + '${session.truncated ? ' (обрезано до $_maxEntriesPerSession)' : ''}' + ' · строк лога: ${session.logLines.length}' + '${session.logsTruncated ? ' (обрезано до $_maxLogLinesPerSession)' : ''}', ); buffer.writeln('=================================================='); buffer.writeln(); - for (var i = 0; i < session.entries.length; i++) { - _writeEntry(buffer, i + 1, session.entries[i]); + + buffer.writeln('----- ЛОГИ ПРИЛОЖЕНИЯ -----'); + if (session.logLines.isEmpty) { + buffer.writeln('(пусто)'); + } else { + for (final line in session.logLines) { + buffer.writeln(line); + } + } + buffer.writeln(); + + buffer.writeln('----- ЗАПРОСЫ -----'); + if (session.entries.isEmpty) { + buffer.writeln('(пусто)'); + buffer.writeln(); + } else { + for (var i = 0; i < session.entries.length; i++) { + _writeEntry(buffer, i + 1, session.entries[i]); + } } } return buffer.toString(); diff --git a/lib/core/utils/logger.dart b/lib/core/utils/logger.dart index 1460e91..66de238 100644 --- a/lib/core/utils/logger.dart +++ b/lib/core/utils/logger.dart @@ -3,6 +3,8 @@ import 'dart:convert'; import 'package:flutter/foundation.dart'; import 'package:logger/logger.dart'; +import 'debug_session_log.dart'; + Level _minimumLogLevel() { const raw = String.fromEnvironment('KOMET_LOG_LEVEL', defaultValue: ''); switch (raw.toLowerCase()) { @@ -41,9 +43,18 @@ final logger = Logger( filter: _logFilter(), level: _minimumLogLevel(), printer: KometLogPrinter(), - output: ConsoleOutput(), + output: MultiOutput([ConsoleOutput(), DebugSessionLogOutput()]), ); +class DebugSessionLogOutput extends LogOutput { + @override + void output(OutputEvent event) { + for (final line in event.lines) { + DebugSessionLog.instance.recordLogLine(line); + } + } +} + int _importanceSortKey(Level level) { final v = level.value; if (v >= 5999) { diff --git a/lib/frontend/debug/quick_actions_section.dart b/lib/frontend/debug/quick_actions_section.dart index 5758d1a..0df3867 100644 --- a/lib/frontend/debug/quick_actions_section.dart +++ b/lib/frontend/debug/quick_actions_section.dart @@ -49,7 +49,7 @@ class DebugQuickActionsSection extends StatelessWidget { ), const SizedBox(height: 2), Text( - 'Все запросы за последние 3 захода в приложение', + 'Все логи и запросы за последние 3 захода в приложение', style: TextStyle( color: cs.onSurfaceVariant, fontSize: 13, diff --git a/lib/frontend/screens/profile/debug_menu_screen.dart b/lib/frontend/screens/profile/debug_menu_screen.dart index d48cd57..81fa819 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -73,7 +73,7 @@ class _DebugMenuScreenState extends State { endpoint: TrafficMonitor.instance.activeEndpoint, ); if (content == null) { - if (mounted) showCustomNotification(context, 'Нет запросов для лога'); + if (mounted) showCustomNotification(context, 'Лог пуст'); return; } final bytes = Uint8List.fromList(utf8.encode(content)); diff --git a/lib/main.dart b/lib/main.dart index dc82e4b..1dfb8e1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -105,8 +105,47 @@ Future _loadInitialLocale() async { return const Locale('ru'); } +void _installLogCapture() { + final previousDebugPrint = debugPrint; + debugPrint = (String? message, {int? wrapWidth}) { + if (message != null) { + final t = DateTime.now(); + final stamp = + '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}:${t.second.toString().padLeft(2, '0')}.${t.millisecond.toString().padLeft(3, '0')}'; + DebugSessionLog.instance.recordLogLine(' |$stamp P $message'); + } + previousDebugPrint(message, wrapWidth: wrapWidth); + }; + + final previousFlutterOnError = FlutterError.onError; + FlutterError.onError = (FlutterErrorDetails details) { + DebugSessionLog.instance.recordLogLine( + ' | FlutterError: ${details.exceptionAsString()}', + ); + if (details.stack != null) { + DebugSessionLog.instance.recordLogLine(details.stack.toString()); + } + if (previousFlutterOnError != null) { + previousFlutterOnError(details); + } else { + FlutterError.presentError(details); + } + }; + + final previousPlatformOnError = ui.PlatformDispatcher.instance.onError; + ui.PlatformDispatcher.instance.onError = (Object error, StackTrace stack) { + DebugSessionLog.instance.recordLogLine(' | Uncaught: $error'); + DebugSessionLog.instance.recordLogLine(stack.toString()); + if (previousPlatformOnError != null) { + return previousPlatformOnError(error, stack); + } + return false; + }; +} + void main() async { WidgetsFlutterBinding.ensureInitialized(); + _installLogCapture(); VideoPlayerMediaKit.ensureInitialized( windows: true, linux: true,