import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'dart:typed_data'; /// Small, structured breadcrumbs. Never accepts raw URLs, HTML or exceptions. class DiagnosticLog { DiagnosticLog({DateTime Function()? clock}) : clock = clock ?? DateTime.now; static final instance = DiagnosticLog(); static const maxBytes = 256 * 1024; static const _keys = { 'school', 'phase', 'page', 'count', 'term', 'type', 'status', 'code', 'version', 'result', }; final DateTime Function() clock; final _entries = >[]; File? _file; Future _writes = Future.value(); Timer? _timer; Map _safe(Map fields) => { for (final e in fields.entries.take(6)) if (_keys.contains(e.key) && (e.value is bool || e.value is int || e.value is String && RegExp(r'^[A-Za-z0-9_.-]{1,40}$').hasMatch(e.value))) e.key as String: e.value as Object, }; void _trim() { final now = clock().toUtc(); _entries.removeWhere((e) { final at = DateTime.tryParse(e['at'] as String? ?? ''); return at == null || at.isAfter(now) || now.difference(at) > const Duration(minutes: 10); }); if (_entries.length > 500) _entries.removeRange(0, _entries.length - 500); } void add(String event, {Map fields = const {}}) { if (!RegExp(r'^[a-z_]{1,64}$').hasMatch(event)) return; _trim(); final safe = _safe(fields); if (_entries.isNotEmpty) { final last = _entries.last; final at = DateTime.tryParse(last['at'] as String? ?? ''); final previous = Map.from(last) ..remove('at') ..remove('event'); if (last['event'] == event && jsonEncode(previous) == jsonEncode(safe) && at != null && clock().toUtc().difference(at) < const Duration(seconds: 3)) { return; } } _entries.add({ 'at': clock().toUtc().toIso8601String(), 'event': event, ...safe, }); _trim(); unawaited(flush()); } void navigation(String school, String phase, String url) { final uri = Uri.tryParse(url); final page = uri == null ? 'unknown' : uri.host == 'idas.uestc.edu.cn' ? 'identity' : uri.host == 'yjsjy.uestc.edu.cn' ? uri.path.contains('xskbcx') ? 'timetable' : uri.path.contains('kskcxx') ? 'exams' : 'graduate_portal' : uri.host == 'eams.uestc.edu.cn' ? uri.path.contains('stdExamTable') ? 'exams' : uri.path.contains('courseTable') ? 'timetable' : 'undergraduate_portal' : 'external'; add('navigation_$phase', fields: {'school': school, 'page': page}); } Future attach(Directory directory) async { final file = File('${directory.path}/diagnostics-recent.json'); try { if (await file.exists() && await file.length() <= maxBytes) { final saved = jsonDecode(await file.readAsString()); if (saved is List) { final restored = >[]; for (final row in saved.take(500)) { if (row is Map && row['at'] is String && row['event'] is String && RegExp(r'^[a-z_]{1,64}$').hasMatch(row['event'])) { restored.add({ 'at': row['at'], 'event': row['event'], ..._safe(row), }); } } _entries.insertAll(0, restored); } } } catch (_) { /* Diagnostics must never block startup or feedback. */ } _file = file; _timer?.cancel(); _timer = Timer.periodic( const Duration(minutes: 1), (_) => unawaited(flush()), ); await flush(); } Future flush() { _trim(); final file = _file; if (file == null) return Future.value(); final text = jsonEncode(_entries); _writes = _writes.then((_) async { try { await file.writeAsString(text, flush: true); } catch (_) {} }); return _writes; } Future export() async { _trim(); await flush(); final header = 'TPSchedule diagnostic log\nWindow: last 10 minutes (UTC timestamps)\n' 'Up to 500 recent events; no passwords, cookies, HTML or feedback text.\n'; return Uint8List.fromList( utf8.encode('$header${_entries.map(jsonEncode).join('\n')}\n'), ); } void dispose() => _timer?.cancel(); }