From 1e2fc42b356dc79b5e89e68e6d535c43e377ef28 Mon Sep 17 00:00:00 2001 From: klockky Date: Tue, 14 Jul 2026 23:05:15 +0300 Subject: [PATCH] =?UTF-8?q?feat(debug):=20=D0=B4=D0=B8=D0=BF=D0=BB=D0=B8?= =?UTF-8?q?=D0=BD=D0=BA=20komet.pw/export-logs=20=D0=B8=20zip-=D1=8D=D0=BA?= =?UTF-8?q?=D1=81=D0=BF=D0=BE=D1=80=D1=82=20=D0=BB=D0=BE=D0=B3=D0=BE=D0=B2?= =?UTF-8?q?=20=D0=B7=D0=B0=2024=20=D1=87=D0=B0=D1=81=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- android/app/src/main/AndroidManifest.xml | 9 ++ lib/core/links/deep_link_service.dart | 48 ++++++- lib/core/utils/debug_session_log.dart | 129 +++++++++++------- lib/frontend/debug/log_export.dart | 48 +++++++ lib/frontend/debug/quick_actions_section.dart | 2 +- .../screens/profile/debug_menu_screen.dart | 44 +----- pubspec.lock | 2 +- pubspec.yaml | 1 + 8 files changed, 188 insertions(+), 95 deletions(-) create mode 100644 lib/frontend/debug/log_export.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 6c621f0..654dcc7 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -64,6 +64,15 @@ + + + + + + + + + diff --git a/lib/core/links/deep_link_service.dart b/lib/core/links/deep_link_service.dart index 133aecc..7f4956f 100644 --- a/lib/core/links/deep_link_service.dart +++ b/lib/core/links/deep_link_service.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:app_links/app_links.dart'; import '../../backend/api.dart'; +import '../../frontend/debug/log_export.dart'; import '../../frontend/widgets/max_link_handler.dart'; import '../../main.dart'; import 'desktop_url_scheme.dart'; @@ -16,6 +17,8 @@ class DeepLinkService { StreamSubscription? _sub; StreamSubscription? _stateSub; String? _pending; + bool _pendingLogExport = false; + Timer? _logExportRetry; bool _ready = false; bool _started = false; @@ -42,6 +45,11 @@ class DeepLinkService { } void _onUri(Uri uri) { + if (_isLogExportLink(uri)) { + _pendingLogExport = true; + _flushPending(); + return; + } final url = _normalize(uri); if (url == null) return; _pending = url; @@ -49,16 +57,46 @@ class DeepLinkService { } void _flushPending() { - final pending = _pending; - if (pending == null || !_ready) return; - if (api.state != SessionState.online) return; final context = KometApp.navigatorKey.currentContext; - if (context == null) return; + if (_pendingLogExport) { + if (context == null) { + _logExportRetry ??= Timer(const Duration(milliseconds: 300), () { + _logExportRetry = null; + _flushPending(); + }); + } else { + _pendingLogExport = false; + exportDebugLog(context); + } + } + + if (!_ready || context == null) return; + final pending = _pending; + if (pending == null || api.state != SessionState.online) return; _pending = null; tryHandleMaxLink(context, pending); } + bool _isLogExportLink(Uri uri) { + final scheme = uri.scheme.toLowerCase(); + final host = uri.host.toLowerCase(); + final segments = [ + if (scheme == 'komet' && host.isNotEmpty) host, + ...uri.pathSegments, + ].where((s) => s.isNotEmpty).toList(); + + if (scheme == 'komet') { + return segments.length == 1 && segments.first == 'export-logs'; + } + if (scheme == 'https' || scheme == 'http') { + return (host == 'komet.pw' || host == 'www.komet.pw') && + segments.length == 1 && + segments.first == 'export-logs'; + } + return false; + } + String? _normalize(Uri uri) { final scheme = uri.scheme.toLowerCase(); @@ -82,6 +120,8 @@ class DeepLinkService { } void dispose() { + _logExportRetry?.cancel(); + _logExportRetry = null; _sub?.cancel(); _sub = null; _stateSub?.cancel(); diff --git a/lib/core/utils/debug_session_log.dart b/lib/core/utils/debug_session_log.dart index 02cb02f..75eda80 100644 --- a/lib/core/utils/debug_session_log.dart +++ b/lib/core/utils/debug_session_log.dart @@ -5,8 +5,16 @@ import 'dart:io'; import 'package:path_provider/path_provider.dart'; import '../protocol/opcode_map.dart'; +import 'format.dart'; import 'log_redact.dart'; +class DebugExportFile { + final String name; + final String content; + + DebugExportFile(this.name, this.content); +} + class _LogEntry { final int opcode; final int seq; @@ -99,7 +107,8 @@ class DebugSessionLog { DebugSessionLog._(); static final DebugSessionLog instance = DebugSessionLog._(); - static const int _maxSessions = 3; + static const Duration _retention = Duration(hours: 24); + static const int _maxStoredSessions = 30; static const int _maxEntriesPerSession = 2000; static const int _maxLogLinesPerSession = 5000; static const Duration _flushDebounce = Duration(seconds: 3); @@ -225,9 +234,17 @@ class DebugSessionLog { Future _rotate() async { final files = await _sessionFiles(); - const keep = _maxSessions - 1; - if (files.length <= keep) return; - for (final file in files.take(files.length - keep)) { + final cutoff = DateTime.now().subtract(_retention).millisecondsSinceEpoch; + final stale = []; + final fresh = []; + for (final file in files) { + (_startMillis(file) < cutoff ? stale : fresh).add(file); + } + const keep = _maxStoredSessions - 1; + if (fresh.length > keep) { + stale.addAll(fresh.take(fresh.length - keep)); + } + for (final file in stale) { try { await file.delete(); } catch (_) {} @@ -255,7 +272,8 @@ class DebugSessionLog { return int.tryParse(digits) ?? 0; } - Future buildExport({String? endpoint}) async { + Future?> buildExportFiles({String? endpoint}) async { + final cutoff = DateTime.now().subtract(_retention); final sessions = <_SessionData>[]; final dir = _dir; if (dir != null) { @@ -263,7 +281,10 @@ class DebugSessionLog { if (_currentFile != null && file.path == _currentFile!.path) continue; try { final decoded = jsonDecode(await file.readAsString()); - if (decoded is Map) sessions.add(_SessionData.fromJson(decoded)); + if (decoded is Map) { + final session = _SessionData.fromJson(decoded); + if (!session.startedAt.isBefore(cutoff)) sessions.add(session); + } } catch (_) {} } } @@ -277,56 +298,66 @@ class DebugSessionLog { ), ); sessions.sort((a, b) => a.startedAt.compareTo(b.startedAt)); - final lastN = sessions.length > _maxSessions - ? sessions.sublist(sessions.length - _maxSessions) - : sessions; - final totalEntries = lastN.fold(0, (sum, s) => sum + s.entries.length); - final totalLogs = lastN.fold(0, (sum, s) => sum + s.logLines.length); + final totalEntries = sessions.fold( + 0, + (sum, s) => sum + s.entries.length, + ); + final totalLogs = sessions.fold( + 0, + (sum, s) => sum + s.logLines.length, + ); if (totalEntries == 0 && totalLogs == 0) return null; + final info = StringBuffer(); + info.writeln('Komet — отладочный лог'); + if (endpoint != null) info.writeln('Сервер: $endpoint'); + info.writeln('Экспортирован: ${DateTime.now().toIso8601String()}'); + info.writeln('Период: последние ${_retention.inHours} часа'); + info.writeln('Заходов в приложение: ${sessions.length}'); + info.writeln('Всего запросов: $totalEntries'); + info.writeln('Всего строк лога: $totalLogs'); + info.writeln('Скрыто: токен полностью, номер кроме первых 3 символов'); + + final files = [DebugExportFile('info.txt', '$info')]; + for (var s = 0; s < sessions.length; s++) { + final session = sessions[s]; + final name = + 'session_${(s + 1).toString().padLeft(2, '0')}_' + '${formatFileStamp(session.startedAt)}.txt'; + files.add(DebugExportFile(name, _buildSessionText(s + 1, session))); + } + return files; + } + + String _buildSessionText(int index, _SessionData session) { final buffer = StringBuffer(); - buffer.writeln('Komet — отладочный лог'); - if (endpoint != null) buffer.writeln('Сервер: $endpoint'); - buffer.writeln('Экспортирован: ${DateTime.now().toIso8601String()}'); - buffer.writeln('Заходов в приложение: ${lastN.length}'); - buffer.writeln('Всего запросов: $totalEntries'); - buffer.writeln('Всего строк лога: $totalLogs'); - buffer.writeln('Скрыто: токен полностью, номер кроме первых 3 символов'); + buffer.writeln('=================================================='); + buffer.writeln('ЗАХОД #$index — ${session.startedAt.toIso8601String()}'); + buffer.writeln( + 'запросов: ${session.entries.length}' + '${session.truncated ? ' (обрезано до $_maxEntriesPerSession)' : ''}' + ' · строк лога: ${session.logLines.length}' + '${session.logsTruncated ? ' (обрезано до $_maxLogLinesPerSession)' : ''}', + ); + buffer.writeln('=================================================='); buffer.writeln(); - for (var s = 0; s < lastN.length; s++) { - final session = lastN[s]; - buffer.writeln('=================================================='); - buffer.writeln( - 'ЗАХОД #${s + 1} — ${session.startedAt.toIso8601String()}', - ); - buffer.writeln( - 'запросов: ${session.entries.length}' - '${session.truncated ? ' (обрезано до $_maxEntriesPerSession)' : ''}' - ' · строк лога: ${session.logLines.length}' - '${session.logsTruncated ? ' (обрезано до $_maxLogLinesPerSession)' : ''}', - ); - buffer.writeln('=================================================='); - buffer.writeln(); - - buffer.writeln('----- ЛОГИ ПРИЛОЖЕНИЯ -----'); - if (session.logLines.isEmpty) { - buffer.writeln('(пусто)'); - } else { - for (final line in session.logLines) { - buffer.writeln(line); - } + buffer.writeln('----- ЛОГИ ПРИЛОЖЕНИЯ -----'); + if (session.logLines.isEmpty) { + buffer.writeln('(пусто)'); + } else { + for (final line in session.logLines) { + buffer.writeln(line); } - buffer.writeln(); + } + 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]); - } + buffer.writeln('----- ЗАПРОСЫ -----'); + if (session.entries.isEmpty) { + 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/frontend/debug/log_export.dart b/lib/frontend/debug/log_export.dart new file mode 100644 index 0000000..4b6d1b2 --- /dev/null +++ b/lib/frontend/debug/log_export.dart @@ -0,0 +1,48 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:archive/archive.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/widgets.dart'; + +import '../../core/transport/traffic_monitor.dart'; +import '../../core/utils/debug_session_log.dart'; +import '../../core/utils/format.dart'; +import '../widgets/custom_notification.dart'; + +Future exportDebugLog(BuildContext context) async { + final exportFiles = await DebugSessionLog.instance.buildExportFiles( + endpoint: TrafficMonitor.instance.activeEndpoint, + ); + if (exportFiles == null) { + if (context.mounted) showCustomNotification(context, 'Лог пуст'); + return; + } + final archive = Archive(); + for (final file in exportFiles) { + final data = utf8.encode(file.content); + archive.addFile(ArchiveFile(file.name, data.length, data)); + } + final bytes = ZipEncoder().encodeBytes(archive); + final fileName = 'komet_debug_${formatFileStamp(DateTime.now())}.zip'; + final isMobile = Platform.isAndroid || Platform.isIOS; + try { + final path = await FilePicker.platform.saveFile( + dialogTitle: 'Сохранить отладочный лог', + fileName: fileName, + type: FileType.any, + bytes: isMobile ? bytes : null, + ); + if (path == null) return; + if (!isMobile) { + await File(path).writeAsBytes(bytes); + } + if (context.mounted) { + showCustomNotification(context, 'Лог сохранён: $path'); + } + } catch (e) { + if (context.mounted) { + showCustomNotification(context, 'Не удалось сохранить лог: $e'); + } + } +} diff --git a/lib/frontend/debug/quick_actions_section.dart b/lib/frontend/debug/quick_actions_section.dart index 0df3867..f5c3ae2 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 захода в приложение', + 'Zip-архив: логи и запросы за последние 24 часа, каждый заход отдельным файлом', 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 81fa819..4d47bd3 100644 --- a/lib/frontend/screens/profile/debug_menu_screen.dart +++ b/lib/frontend/screens/profile/debug_menu_screen.dart @@ -1,8 +1,3 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:material_symbols_icons/symbols.dart'; import '../../../backend/modules/chats.dart'; @@ -10,8 +5,6 @@ import '../../../core/calls/call_controller.dart'; import '../../../core/config/app_media_cache.dart'; import '../../../core/protocol/opcode_map.dart'; import '../../../core/protocol/packet.dart'; -import '../../../core/transport/traffic_monitor.dart'; -import '../../../core/utils/debug_session_log.dart'; import '../../../core/utils/format.dart'; import '../../../core/utils/logger.dart'; import '../../../core/utils/media_cache.dart'; @@ -20,6 +13,7 @@ import '../../debug/cache_section.dart'; import '../../debug/feature_toggles_section.dart'; import '../../debug/header_section.dart'; import '../../debug/id_search_section.dart'; +import '../../debug/log_export.dart'; import '../../debug/network_section.dart'; import '../../debug/previews_section.dart'; import '../../debug/quick_actions_section.dart'; @@ -68,38 +62,6 @@ class _DebugMenuScreenState extends State { if (mounted) setState(() => _cacheSize = size); } - Future _exportDebugLog() async { - final content = await DebugSessionLog.instance.buildExport( - endpoint: TrafficMonitor.instance.activeEndpoint, - ); - if (content == null) { - if (mounted) showCustomNotification(context, 'Лог пуст'); - return; - } - final bytes = Uint8List.fromList(utf8.encode(content)); - final fileName = 'komet_debug_${formatFileStamp(DateTime.now())}.txt'; - final isMobile = Platform.isAndroid || Platform.isIOS; - try { - final path = await FilePicker.platform.saveFile( - dialogTitle: 'Сохранить отладочный лог', - fileName: fileName, - type: FileType.any, - bytes: isMobile ? bytes : null, - ); - if (path == null) return; - if (!isMobile) { - await File(path).writeAsBytes(bytes); - } - if (mounted) { - showCustomNotification(context, 'Лог сохранён: $path'); - } - } catch (e) { - if (mounted) { - showCustomNotification(context, 'Не удалось сохранить лог: $e'); - } - } - } - Future _clearCache() async { if (_clearingCache) return; setState(() => _clearingCache = true); @@ -255,7 +217,9 @@ class _DebugMenuScreenState extends State { ), ), SliverToBoxAdapter( - child: DebugQuickActionsSection(onExportLog: _exportDebugLog), + child: DebugQuickActionsSection( + onExportLog: () => exportDebugLog(context), + ), ), SliverToBoxAdapter(child: DebugNetworkSection(appState: appState)), const SliverToBoxAdapter(child: DebugFeatureTogglesSection()), diff --git a/pubspec.lock b/pubspec.lock index 57a1c9b..66872a1 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -50,7 +50,7 @@ packages: source: hosted version: "1.0.4" archive: - dependency: transitive + dependency: "direct main" description: name: archive sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff diff --git a/pubspec.yaml b/pubspec.yaml index 5e59c9f..f4cc93e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -49,6 +49,7 @@ dependencies: flutter_timezone: ^5.0.1 timezone: ^0.11.0 file_picker: ^8.0.0 + archive: ^4.0.9 geolocator: ^13.0.0 photo_manager: ^3.0.0 image: ^4.3.0