надеюсь будет работать сукi
This commit is contained in:
+162
-70
@@ -70,10 +70,16 @@ class Api {
|
||||
StreamSubscription<SocketState>? _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<void> 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<void> _resetStuckConnect(int gen) async {
|
||||
if (gen != _connectGen) return;
|
||||
_connectGen++;
|
||||
_cancelConnectWatchdog();
|
||||
_cleanup();
|
||||
try {
|
||||
await _connection.disconnect();
|
||||
} catch (_) {}
|
||||
_setSessionState(SessionState.disconnected);
|
||||
if (_autoReconnect) _scheduleReconnect();
|
||||
}
|
||||
|
||||
Future<void> _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<void> 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<void> _forceReconnect() async {
|
||||
_connectGen++;
|
||||
_cleanup();
|
||||
await _connection.disconnect();
|
||||
_reconnectAttempts = 0;
|
||||
@@ -472,6 +563,7 @@ class Api {
|
||||
}
|
||||
|
||||
void _cleanup() {
|
||||
_cancelConnectWatchdog();
|
||||
_pingTimer?.cancel();
|
||||
_dataSubscription?.cancel();
|
||||
_socketStateSubscription?.cancel();
|
||||
|
||||
@@ -269,16 +269,15 @@ class AccountModule {
|
||||
final dataMap = data.cast<dynamic, dynamic>();
|
||||
|
||||
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);
|
||||
|
||||
@@ -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'];
|
||||
|
||||
@@ -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<String?> _readSecureSafe(String key) async {
|
||||
try {
|
||||
return await TokenStorage.readSecure(key).timeout(_secureReadTimeout);
|
||||
} catch (e) {
|
||||
logger.w('ProxyConfig: чтение secure "$key" не удалось/зависло: $e');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static Future<void> _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<ProxySettings> 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)],
|
||||
|
||||
@@ -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<Uint8List>? _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;
|
||||
}
|
||||
|
||||
@@ -57,11 +57,15 @@ class _SessionData {
|
||||
final DateTime startedAt;
|
||||
final List<_LogEntry> entries;
|
||||
final bool truncated;
|
||||
final List<String> 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 = <String>[];
|
||||
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<String> _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<int>(0, (sum, s) => sum + s.entries.length);
|
||||
if (totalEntries == 0) return null;
|
||||
final totalLogs = lastN.fold<int>(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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -49,7 +49,7 @@ class DebugQuickActionsSection extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'Все запросы за последние 3 захода в приложение',
|
||||
'Все логи и запросы за последние 3 захода в приложение',
|
||||
style: TextStyle(
|
||||
color: cs.onSurfaceVariant,
|
||||
fontSize: 13,
|
||||
|
||||
@@ -73,7 +73,7 @@ class _DebugMenuScreenState extends State<DebugMenuScreen> {
|
||||
endpoint: TrafficMonitor.instance.activeEndpoint,
|
||||
);
|
||||
if (content == null) {
|
||||
if (mounted) showCustomNotification(context, 'Нет запросов для лога');
|
||||
if (mounted) showCustomNotification(context, 'Лог пуст');
|
||||
return;
|
||||
}
|
||||
final bytes = Uint8List.fromList(utf8.encode(content));
|
||||
|
||||
@@ -105,8 +105,47 @@ Future<Locale> _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,
|
||||
|
||||
Reference in New Issue
Block a user