diff --git a/CHANGELOG.md b/CHANGELOG.md index fab9bd51..8d56e98b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,22 +1,20 @@ # Changelog - All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## Unreleased - ### Added - - Basic region based routing rules - Russian region +- Logs flow control ### Changed - - Theme preferences +- Logs page ### Fixed - - Localization mistakes in Russian from [solokot](https://github.com/solokot) - Localization mistakes in Russian from [Elshad Guseynov](https://github.com/lifeindarkside) +- Logs filtering diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/LogHandler.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/LogHandler.kt index d1ae2e46..3fc91860 100644 --- a/android/app/src/main/kotlin/com/hiddify/hiddify/LogHandler.kt +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/LogHandler.kt @@ -1,5 +1,6 @@ package com.hiddify.hiddify +import android.util.Log import io.flutter.embedding.engine.plugins.FlutterPlugin import io.flutter.plugin.common.EventChannel @@ -18,13 +19,15 @@ class LogHandler : FlutterPlugin { logsChannel.setStreamHandler(object : EventChannel.StreamHandler { override fun onListen(arguments: Any?, events: EventChannel.EventSink?) { - MainActivity.instance.serviceLogs.observeForever { - if (it == null) return@observeForever - events?.success(it) + val activity = MainActivity.instance + events?.success(activity.logList) + activity.logCallback = { + events?.success(activity.logList) } } override fun onCancel(arguments: Any?) { + MainActivity.instance.logCallback = null } }) } diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/MainActivity.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/MainActivity.kt index f2253b36..f605144c 100644 --- a/android/app/src/main/kotlin/com/hiddify/hiddify/MainActivity.kt +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/MainActivity.kt @@ -39,7 +39,6 @@ class MainActivity : FlutterFragmentActivity(), ServiceConnection.Callback { var logCallback: ((Boolean) -> Unit)? = null val serviceStatus = MutableLiveData(Status.Stopped) val serviceAlerts = MutableLiveData(null) - val serviceLogs = MutableLiveData(null) override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) @@ -102,37 +101,18 @@ class MainActivity : FlutterFragmentActivity(), ServiceConnection.Callback { serviceAlerts.postValue(ServiceEvent(Status.Stopped, type, message)) } - private var paused = false - override fun onPause() { - super.onPause() - - paused = true - } - - override fun onResume() { - super.onResume() - - paused = false - logCallback?.invoke(true) - } - override fun onServiceWriteLog(message: String?) { - if (paused) { - if (logList.size > 300) { - logList.removeFirst() - } + if (logList.size > 300) { + logList.removeFirst() } logList.addLast(message) - if (!paused) { - logCallback?.invoke(false) - serviceLogs.postValue(message) - } + logCallback?.invoke(false) } override fun onServiceResetLogs(messages: MutableList) { logList.clear() logList.addAll(messages) - if (!paused) logCallback?.invoke(true) + logCallback?.invoke(true) } override fun onDestroy() { diff --git a/android/app/src/main/kotlin/com/hiddify/hiddify/MethodHandler.kt b/android/app/src/main/kotlin/com/hiddify/hiddify/MethodHandler.kt index 944c52f7..36e01b2a 100644 --- a/android/app/src/main/kotlin/com/hiddify/hiddify/MethodHandler.kt +++ b/android/app/src/main/kotlin/com/hiddify/hiddify/MethodHandler.kt @@ -29,6 +29,7 @@ class MethodHandler(private val scope: CoroutineScope) : FlutterPlugin, Restart("restart"), SelectOutbound("select_outbound"), UrlTest("url_test"), + ClearLogs("clear_logs"), } } @@ -63,38 +64,44 @@ class MethodHandler(private val scope: CoroutineScope) : FlutterPlugin, } Trigger.ChangeConfigOptions.method -> { - result.runCatching { - val args = call.arguments as String - Settings.configOptions = args - success(true) + scope.launch { + result.runCatching { + val args = call.arguments as String + Settings.configOptions = args + success(true) + } } } Trigger.Start.method -> { - result.runCatching { - val args = call.arguments as Map<*, *> - Settings.activeConfigPath = args["path"] as String? ?: "" - val mainActivity = MainActivity.instance - val started = mainActivity.serviceStatus.value == Status.Started - if (started) { - Log.w(TAG, "service is already running") - return success(true) + scope.launch { + result.runCatching { + val args = call.arguments as Map<*, *> + Settings.activeConfigPath = args["path"] as String? ?: "" + val mainActivity = MainActivity.instance + val started = mainActivity.serviceStatus.value == Status.Started + if (started) { + Log.w(TAG, "service is already running") + return@launch success(true) + } + mainActivity.startService() + success(true) } - mainActivity.startService() - success(true) } } Trigger.Stop.method -> { - result.runCatching { - val mainActivity = MainActivity.instance - val started = mainActivity.serviceStatus.value == Status.Started - if (!started) { - Log.w(TAG, "service is not running") - return success(true) + scope.launch { + result.runCatching { + val mainActivity = MainActivity.instance + val started = mainActivity.serviceStatus.value == Status.Started + if (!started) { + Log.w(TAG, "service is not running") + return@launch success(true) + } + BoxService.stop() + success(true) } - BoxService.stop() - success(true) } } @@ -151,6 +158,15 @@ class MethodHandler(private val scope: CoroutineScope) : FlutterPlugin, } } + Trigger.ClearLogs.method -> { + scope.launch { + result.runCatching { + MainActivity.instance.onServiceResetLogs(mutableListOf()) + success(true) + } + } + } + else -> result.notImplemented() } } diff --git a/assets/translations/strings_en.i18n.json b/assets/translations/strings_en.i18n.json index 186966de..8a5e9056 100644 --- a/assets/translations/strings_en.i18n.json +++ b/assets/translations/strings_en.i18n.json @@ -106,11 +106,13 @@ }, "logs": { "pageTitle": "Logs", - "clearLogsButtonText": "Clear Logs", "filterHint": "Filter", "allLevelsFilter": "All", "shareCoreLogs": "Share Core Logs", - "shareAppLogs": "Share App logs" + "shareAppLogs": "Share App logs", + "pauseTooltip": "Pause", + "resumeTooltip": "Resume", + "clearTooltip": "Clear" }, "settings": { "pageTitle": "Settings", diff --git a/assets/translations/strings_fa.i18n.json b/assets/translations/strings_fa.i18n.json index 520be7ad..cebc0b0a 100644 --- a/assets/translations/strings_fa.i18n.json +++ b/assets/translations/strings_fa.i18n.json @@ -106,11 +106,13 @@ }, "logs": { "pageTitle": "لاگ‌ها", - "clearLogsButtonText": "پاک‌سازی", "filterHint": "فیلتر", "allLevelsFilter": "همه", "shareCoreLogs": "اشتراک‌گذاری لاگ هسته", - "shareAppLogs": "اشتراک‌گذاری لاگ برنامه" + "shareAppLogs": "اشتراک‌گذاری لاگ برنامه", + "pauseTooltip": "مکث", + "resumeTooltip": "از سرگیری", + "clearTooltip": "پاک‌سازی" }, "settings": { "pageTitle": "تنظیمات", diff --git a/assets/translations/strings_ru.i18n.json b/assets/translations/strings_ru.i18n.json index 7bcda9c2..12259231 100644 --- a/assets/translations/strings_ru.i18n.json +++ b/assets/translations/strings_ru.i18n.json @@ -106,11 +106,13 @@ }, "logs": { "pageTitle": "Журналы", - "clearLogsButtonText": "Очистить журналы", "filterHint": "Фильтр", "allLevelsFilter": "Все", "shareCoreLogs": "Поделиться журналами ядра", - "shareAppLogs": "Поделиться журналами приложения" + "shareAppLogs": "Поделиться журналами приложения", + "pauseTooltip": "Пауза", + "resumeTooltip": "Резюме", + "clearTooltip": "Прозрачный" }, "settings": { "pageTitle": "Настройки", diff --git a/assets/translations/strings_zh.i18n.json b/assets/translations/strings_zh.i18n.json index 168f9da1..a1ec9bd4 100644 --- a/assets/translations/strings_zh.i18n.json +++ b/assets/translations/strings_zh.i18n.json @@ -106,11 +106,13 @@ }, "logs": { "pageTitle": "日志", - "clearLogsButtonText": "清除日志", "filterHint": "筛选", "allLevelsFilter": "全部", "shareCoreLogs": "分享核心日志", - "shareAppLogs": "分享日志" + "shareAppLogs": "分享日志", + "pauseTooltip": "暂停", + "resumeTooltip": "恢复", + "clearTooltip": "清除" }, "settings": { "pageTitle": "设置", diff --git a/lib/data/repository/core_facade_impl.dart b/lib/data/repository/core_facade_impl.dart index b344809f..fa89af87 100644 --- a/lib/data/repository/core_facade_impl.dart +++ b/lib/data/repository/core_facade_impl.dart @@ -179,10 +179,21 @@ class CoreFacadeImpl with ExceptionHandler, InfraLogger implements CoreFacade { } @override - Stream> watchLogs() { - return singbox - .watchLogs(filesEditor.coreLogsPath) - .handleExceptions(CoreServiceFailure.unexpected); + Stream>> watchLogs() { + return singbox.watchLogs(filesEditor.coreLogsPath).handleExceptions( + (error, stackTrace) { + loggy.warning("error watching logs", error, stackTrace); + return CoreServiceFailure.unexpected(error, stackTrace); + }, + ); + } + + @override + TaskEither clearLogs() { + return exceptionHandler( + () => singbox.clearLogs().mapLeft(CoreServiceFailure.other).run(), + CoreServiceFailure.unexpected, + ); } @override diff --git a/lib/domain/singbox/box_log.dart b/lib/domain/singbox/box_log.dart new file mode 100644 index 00000000..84ee25c1 --- /dev/null +++ b/lib/domain/singbox/box_log.dart @@ -0,0 +1,62 @@ +import 'package:dartx/dartx.dart'; +import 'package:flutter/material.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:tint/tint.dart'; + +part 'box_log.freezed.dart'; + +enum LogLevel { + trace, + debug, + info, + warn, + error, + fatal, + panic; + + static List get choices => values.takeFirst(4); + + Color? get color => switch (this) { + trace => Colors.lightBlueAccent, + debug => Colors.grey, + info => Colors.lightGreen, + warn => Colors.orange, + error => Colors.redAccent, + fatal => Colors.red, + panic => Colors.red, + }; +} + +@freezed +class BoxLog with _$BoxLog { + const factory BoxLog({ + LogLevel? level, + DateTime? time, + required String message, + }) = _BoxLog; + + factory BoxLog.parse(String log) { + log = log.strip(); + DateTime? time; + if (log.length > 25) { + time = DateTime.tryParse(log.substring(6, 25)); + } + if (time != null) { + log = log.substring(26); + } + final level = LogLevel.values.firstOrNullWhere( + (e) { + if (log.startsWith(e.name.toUpperCase())) { + log = log.removePrefix(e.name.toUpperCase()); + return true; + } + return false; + }, + ); + return BoxLog( + level: level, + time: time, + message: log.trim(), + ); + } +} diff --git a/lib/domain/singbox/config_options.dart b/lib/domain/singbox/config_options.dart index bfae62f4..4206a840 100644 --- a/lib/domain/singbox/config_options.dart +++ b/lib/domain/singbox/config_options.dart @@ -2,6 +2,7 @@ import 'dart:convert'; import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:hiddify/core/prefs/prefs.dart'; +import 'package:hiddify/domain/singbox/box_log.dart'; import 'package:hiddify/domain/singbox/rules.dart'; import 'package:hiddify/utils/platform_utils.dart'; @@ -53,13 +54,6 @@ class ConfigOptions with _$ConfigOptions { _$ConfigOptionsFromJson(json); } -enum LogLevel { - warn, - info, - debug, - trace, -} - @JsonEnum(valueField: 'key') enum IPv6Mode { disable("ipv4_only"), diff --git a/lib/domain/singbox/singbox.dart b/lib/domain/singbox/singbox.dart index 39bb5774..ea665c8f 100644 --- a/lib/domain/singbox/singbox.dart +++ b/lib/domain/singbox/singbox.dart @@ -1,3 +1,4 @@ +export 'box_log.dart'; export 'config_options.dart'; export 'core_status.dart'; export 'outbounds.dart'; diff --git a/lib/domain/singbox/singbox_facade.dart b/lib/domain/singbox/singbox_facade.dart index bcd40f80..6a8d9541 100644 --- a/lib/domain/singbox/singbox_facade.dart +++ b/lib/domain/singbox/singbox_facade.dart @@ -37,5 +37,7 @@ abstract interface class SingboxFacade { Stream> watchCoreStatus(); - Stream> watchLogs(); + Stream>> watchLogs(); + + TaskEither clearLogs(); } diff --git a/lib/features/common/common_controllers.dart b/lib/features/common/common_controllers.dart index 8cfbb413..db6e7e2c 100644 --- a/lib/features/common/common_controllers.dart +++ b/lib/features/common/common_controllers.dart @@ -2,7 +2,6 @@ import 'package:hiddify/core/prefs/general_prefs.dart'; import 'package:hiddify/features/common/app_update_notifier.dart'; import 'package:hiddify/features/common/connectivity/connectivity_controller.dart'; import 'package:hiddify/features/common/window/window_controller.dart'; -import 'package:hiddify/features/logs/notifier/notifier.dart'; import 'package:hiddify/features/profiles/notifier/notifier.dart'; import 'package:hiddify/features/system_tray/controller/system_tray_controller.dart'; import 'package:hiddify/services/service_providers.dart'; @@ -24,10 +23,6 @@ void commonControllers(CommonControllersRef ref) { }, fireImmediately: true, ); - ref.listen( - logsNotifierProvider, - (previous, next) {}, - ); ref.listen( connectivityControllerProvider, (previous, next) {}, diff --git a/lib/features/logs/notifier/logs_notifier.dart b/lib/features/logs/notifier/logs_notifier.dart index 6c7cfd43..b089840d 100644 --- a/lib/features/logs/notifier/logs_notifier.dart +++ b/lib/features/logs/notifier/logs_notifier.dart @@ -1,66 +1,133 @@ import 'dart:async'; -import 'package:dartx/dartx.dart'; import 'package:hiddify/data/data_providers.dart'; -import 'package:hiddify/domain/clash/clash.dart'; +import 'package:hiddify/domain/singbox/singbox.dart'; import 'package:hiddify/features/logs/notifier/logs_state.dart'; +import 'package:hiddify/utils/riverpod_utils.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:rxdart/rxdart.dart'; part 'logs_notifier.g.dart'; -// TODO: rewrite -@Riverpod(keepAlive: true) +@riverpod class LogsNotifier extends _$LogsNotifier with AppLogger { - static const maxLength = 1000; - @override - Stream build() { - state = const AsyncData(LogsState()); - return ref.read(coreFacadeProvider).watchLogs().asyncMap( - (event) async { - _logs = [ - event.getOrElse((l) => throw l), - ..._logs.takeFirst(maxLength - 1), - ]; - return switch (state) { - // ignore: unused_result - AsyncData(:final value) => value.copyWith(logs: await _computeLogs()), - _ => LogsState(logs: await _computeLogs()), - }; + LogsState build() { + ref.disposeDelay(const Duration(seconds: 20)); + state = const LogsState(); + ref.onDispose( + () { + loggy.debug("disposing"); + _listener?.cancel(); + _listener = null; }, ); + ref.onCancel( + () { + if (_listener?.isPaused != true) { + loggy.debug("pausing"); + _listener?.pause(); + } + }, + ); + ref.onResume( + () { + if (!state.paused && (_listener?.isPaused ?? false)) { + loggy.debug("resuming"); + _listener?.resume(); + } + }, + ); + + _addListeners(); + return const LogsState(); } - var _logs = []; + StreamSubscription? _listener; + + Future _addListeners() async { + loggy.debug("adding listeners"); + await _listener?.cancel(); + _listener = ref + .read(coreFacadeProvider) + .watchLogs() + .throttle( + (_) => Stream.value(_listener?.isPaused ?? false), + leading: false, + trailing: true, + ) + .throttleTime( + const Duration(milliseconds: 250), + leading: false, + trailing: true, + ) + .asyncMap( + (event) async { + await event.fold( + (f) { + _logs = []; + state = state.copyWith(logs: AsyncError(f, StackTrace.current)); + }, + (a) async { + _logs = a.reversed; + state = state.copyWith(logs: AsyncData(await _computeLogs())); + }, + ); + }, + ).listen((event) {}); + } + + Iterable _logs = []; final _debouncer = CallbackDebouncer(const Duration(milliseconds: 200)); LogLevel? _levelFilter; String _filter = ""; - Future> _computeLogs() async { - if (_levelFilter == null && _filter.isEmpty) return _logs; - return _logs.where((e) { - return _filter.isEmpty || e.contains(_filter); + Future> _computeLogs() async { + final logs = _logs.map(BoxLog.parse); + if (_levelFilter == null && _filter.isEmpty) return logs.toList(); + return logs.where((e) { + return (_filter.isEmpty || e.message.contains(_filter)) && + (_levelFilter == null || + e.level == null || + e.level!.index >= _levelFilter!.index); }).toList(); } - void clear() { - if (state case AsyncData(:final value)) { - state = AsyncData(value.copyWith(logs: [])).copyWithPrevious(state); - } + void pause() { + loggy.debug("pausing"); + _listener?.pause(); + state = state.copyWith(paused: true); + } + + void resume() { + loggy.debug("resuming"); + _listener?.resume(); + state = state.copyWith(paused: false); + } + + Future clear() async { + loggy.debug("clearing"); + await ref.read(coreFacadeProvider).clearLogs().match( + (l) { + loggy.warning("error clearing logs", l); + }, + (_) { + _logs = []; + state = state.copyWith(logs: const AsyncData([])); + }, + ).run(); } void filterMessage(String? filter) { _filter = filter ?? ''; _debouncer( () async { - if (state case AsyncData(:final value)) { - state = AsyncData( - value.copyWith( - filter: _filter, - logs: await _computeLogs(), - ), - ).copyWithPrevious(state); + if (state.logs case AsyncData()) { + state = state.copyWith( + filter: _filter, + logs: AsyncData(await _computeLogs()), + ); } }, ); @@ -68,13 +135,11 @@ class LogsNotifier extends _$LogsNotifier with AppLogger { Future filterLevel(LogLevel? level) async { _levelFilter = level; - if (state case AsyncData(:final value)) { - state = AsyncData( - value.copyWith( - levelFilter: _levelFilter, - logs: await _computeLogs(), - ), - ).copyWithPrevious(state); + if (state.logs case AsyncData()) { + state = state.copyWith( + levelFilter: _levelFilter, + logs: AsyncData(await _computeLogs()), + ); } } } diff --git a/lib/features/logs/notifier/logs_state.dart b/lib/features/logs/notifier/logs_state.dart index ba34a075..4318870a 100644 --- a/lib/features/logs/notifier/logs_state.dart +++ b/lib/features/logs/notifier/logs_state.dart @@ -1,5 +1,6 @@ import 'package:freezed_annotation/freezed_annotation.dart'; -import 'package:hiddify/domain/clash/clash.dart'; +import 'package:hiddify/domain/singbox/singbox.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; part 'logs_state.freezed.dart'; @@ -8,7 +9,8 @@ class LogsState with _$LogsState { const LogsState._(); const factory LogsState({ - @Default([]) List logs, + @Default(AsyncLoading()) AsyncValue> logs, + @Default(false) bool paused, @Default("") String filter, LogLevel? levelFilter, }) = _LogsState; diff --git a/lib/features/logs/view/logs_page.dart b/lib/features/logs/view/logs_page.dart index af2e7954..7c5d093c 100644 --- a/lib/features/logs/view/logs_page.dart +++ b/lib/features/logs/view/logs_page.dart @@ -1,31 +1,31 @@ -import 'package:dartx/dartx.dart'; + import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:fpdart/fpdart.dart'; import 'package:gap/gap.dart'; import 'package:hiddify/core/core_providers.dart'; import 'package:hiddify/core/prefs/prefs.dart'; -import 'package:hiddify/domain/clash/clash.dart'; import 'package:hiddify/domain/failures.dart'; -import 'package:hiddify/features/common/common.dart'; +import 'package:hiddify/domain/singbox/singbox.dart'; import 'package:hiddify/features/logs/notifier/notifier.dart'; import 'package:hiddify/services/service_providers.dart'; import 'package:hiddify/utils/utils.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:tint/tint.dart'; - class LogsPage extends HookConsumerWidget with PresLogger { const LogsPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { final t = ref.watch(translationsProvider); - final asyncState = ref.watch(logsNotifierProvider); + final state = ref.watch(logsNotifierProvider); final notifier = ref.watch(logsNotifierProvider.notifier); final debug = ref.watch(debugModeNotifierProvider); final filesEditor = ref.watch(filesEditorServiceProvider); + final filterController = useTextEditingController(text: state.filter); + final List popupButtons = debug || PlatformUtils.isDesktop ? [ PopupMenuItem( @@ -49,115 +49,146 @@ class LogsPage extends HookConsumerWidget with PresLogger { ] : []; - switch (asyncState) { - case AsyncData(value: final state): - return Scaffold( - appBar: AppBar( - // TODO: fix height - toolbarHeight: 90, - title: Text(t.logs.pageTitle), - actions: [ - if (popupButtons.isNotEmpty) - PopupMenuButton( - itemBuilder: (context) { - return popupButtons; - }, - ), - ], - bottom: PreferredSize( - preferredSize: const Size.fromHeight(36), - child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Row( - children: [ - Flexible( - child: TextFormField( - onChanged: notifier.filterMessage, - decoration: InputDecoration( - isDense: true, - hintText: t.logs.filterHint, - ), - ), + return Scaffold( + appBar: AppBar( + // TODO: fix height + toolbarHeight: 90, + title: Text(t.logs.pageTitle), + actions: [ + if (state.paused) + IconButton( + onPressed: notifier.resume, + icon: const Icon(Icons.play_arrow), + tooltip: t.logs.resumeTooltip, + ) + else + IconButton( + onPressed: notifier.pause, + icon: const Icon(Icons.pause), + tooltip: t.logs.pauseTooltip, + ), + IconButton( + onPressed: notifier.clear, + icon: const Icon(Icons.clear_all), + tooltip: t.logs.clearTooltip, + ), + if (popupButtons.isNotEmpty) + PopupMenuButton( + itemBuilder: (context) { + return popupButtons; + }, + ), + ], + bottom: PreferredSize( + preferredSize: const Size.fromHeight(36), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Row( + children: [ + Flexible( + child: TextFormField( + controller: filterController, + onChanged: notifier.filterMessage, + decoration: InputDecoration( + isDense: true, + hintText: t.logs.filterHint, ), - const Gap(16), - DropdownButton>( - value: optionOf(state.levelFilter), - onChanged: (v) { - if (v == null) return; - notifier.filterLevel(v.toNullable()); - }, - padding: const EdgeInsets.symmetric(horizontal: 8), - borderRadius: BorderRadius.circular(4), - items: [ - DropdownMenuItem( - value: none(), - child: Text(t.logs.allLevelsFilter), - ), - ...LogLevel.values.takeFirst(3).map( - (e) => DropdownMenuItem( - value: some(e), - child: Text(e.name), - ), - ), - ], + ), + ), + const Gap(16), + DropdownButton>( + value: optionOf(state.levelFilter), + onChanged: (v) { + if (v == null) return; + notifier.filterLevel(v.toNullable()); + }, + padding: const EdgeInsets.symmetric(horizontal: 8), + borderRadius: BorderRadius.circular(4), + items: [ + DropdownMenuItem( + value: none(), + child: Text(t.logs.allLevelsFilter), + ), + ...LogLevel.choices.map( + (e) => DropdownMenuItem( + value: some(e), + child: Text(e.name), + ), ), ], ), - ), + ], ), ), - body: ListView.builder( - itemCount: state.logs.length, - reverse: true, - itemBuilder: (context, index) { - final log = state.logs[index]; - return Column( - mainAxisSize: MainAxisSize.min, - children: [ - ListTile( - dense: true, - subtitle: Text(log.strip()), - ), - if (index != 0) - const Divider( - indent: 16, - endIndent: 16, - height: 4, + ), + ), + body: switch (state.logs) { + AsyncData(value: final logs) => SelectionArea( + child: ListView.builder( + itemCount: logs.length, + reverse: true, + itemBuilder: (context, index) { + final log = logs[index]; + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (log.level != null) + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + log.level!.name.toUpperCase(), + style: Theme.of(context) + .textTheme + .labelMedium + ?.copyWith(color: log.level!.color), + ), + if (log.time != null) + Text( + log.time!.toString(), + style: + Theme.of(context).textTheme.labelSmall, + ), + ], + ), + Text( + log.message, + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), ), - ], - ); - }, + if (index != 0) + const Divider( + indent: 16, + endIndent: 16, + height: 4, + ), + ], + ); + }, + ), ), - ); - - case AsyncError(:final error): - return Scaffold( - body: CustomScrollView( + AsyncError(:final error) => CustomScrollView( slivers: [ - NestedTabAppBar( - title: Text(t.logs.pageTitle), - ), SliverErrorBodyPlaceholder(t.presentShortError(error)), ], ), - ); - - case AsyncLoading(): - return Scaffold( - body: CustomScrollView( + _ => const CustomScrollView( slivers: [ - NestedTabAppBar( - title: Text(t.logs.pageTitle), - ), - const SliverLoadingBodyPlaceholder(), + SliverLoadingBodyPlaceholder(), ], ), - ); - - // TODO: remove - default: - return const Scaffold(); - } + }, + ); } } diff --git a/lib/features/settings/view/config_options_page.dart b/lib/features/settings/view/config_options_page.dart index a677fd7a..87e804f5 100644 --- a/lib/features/settings/view/config_options_page.dart +++ b/lib/features/settings/view/config_options_page.dart @@ -52,13 +52,13 @@ class ConfigOptionsPage extends HookConsumerWidget { ), ListTile( title: Text(t.settings.config.logLevel), - subtitle: Text(options.logLevel.name), + subtitle: Text(options.logLevel.name.toUpperCase()), onTap: () async { final logLevel = await SettingsPickerDialog( title: t.settings.config.logLevel, selected: options.logLevel, - options: LogLevel.values, - getTitle: (e) => e.name, + options: LogLevel.choices, + getTitle: (e) => e.name.toUpperCase(), resetValue: _default.logLevel, ).show(context); if (logLevel == null) return; diff --git a/lib/services/singbox/ffi_singbox_service.dart b/lib/services/singbox/ffi_singbox_service.dart index 47dc4e65..8b9b3aff 100644 --- a/lib/services/singbox/ffi_singbox_service.dart +++ b/lib/services/singbox/ffi_singbox_service.dart @@ -16,6 +16,7 @@ import 'package:hiddify/utils/utils.dart'; import 'package:loggy/loggy.dart'; import 'package:path/path.dart' as p; import 'package:rxdart/rxdart.dart'; +import 'package:watcher/watcher.dart'; final _logger = Loggy('FFISingboxService'); @@ -301,33 +302,47 @@ class FFISingboxService ); } + final _logBuffer = []; + int _logFilePosition = 0; + @override - Stream watchLogs(String path) { - var linesRead = 0; - return Stream.periodic( - const Duration(seconds: 1), - ).asyncMap((_) async { - final result = await _readLogs(path, linesRead); - linesRead = result.$2; - return result.$1; - }).transform( - StreamTransformer.fromHandlers( - handleData: (data, sink) { - for (final item in data) { - sink.add(item); - } - }, - ), - ); + Stream> watchLogs(String path) async* { + yield await _readLogFile(File(path)); + yield* Watcher(path, pollingDelay: const Duration(seconds: 1)) + .events + .asyncMap((event) async { + if (event.type == ChangeType.MODIFY) { + await _readLogFile(File(path)); + } + return _logBuffer; + }); } - Future<(List, int)> _readLogs(String path, int from) async { - return CombineWorker().execute( + @override + TaskEither clearLogs() { + return TaskEither( () async { - final lines = await File(path).readAsLines(); - final to = lines.length; - return (lines.sublist(from), to); + _logBuffer.clear(); + return right(unit); }, ); } + + Future> _readLogFile(File file) async { + if (_logFilePosition == 0 && file.lengthSync() == 0) return []; + final content = + await file.openRead(_logFilePosition).transform(utf8.decoder).join(); + _logFilePosition = file.lengthSync(); + final lines = const LineSplitter().convert(content); + if (lines.length > 300) { + lines.removeRange(0, lines.length - 300); + } + for (final line in lines) { + _logBuffer.add(line); + if (_logBuffer.length > 300) { + _logBuffer.removeAt(0); + } + } + return _logBuffer; + } } diff --git a/lib/services/singbox/mobile_singbox_service.dart b/lib/services/singbox/mobile_singbox_service.dart index 83d1e1cc..aaf2b399 100644 --- a/lib/services/singbox/mobile_singbox_service.dart +++ b/lib/services/singbox/mobile_singbox_service.dart @@ -163,11 +163,18 @@ class MobileSingboxService } @override - Stream watchLogs(String path) { - return _logsChannel.receiveBroadcastStream().map( - (event) { - // loggy.debug("received log: $event"); - return event as String; + Stream> watchLogs(String path) async* { + yield* _logsChannel + .receiveBroadcastStream() + .map((event) => (event as List).map((e) => e as String).toList()); + } + + @override + TaskEither clearLogs() { + return TaskEither( + () async { + await _methodChannel.invokeMethod("clear_logs"); + return right(unit); }, ); } diff --git a/lib/services/singbox/singbox_service.dart b/lib/services/singbox/singbox_service.dart index 5f1c31f4..eb181695 100644 --- a/lib/services/singbox/singbox_service.dart +++ b/lib/services/singbox/singbox_service.dart @@ -48,5 +48,7 @@ abstract interface class SingboxService { Stream watchStats(); - Stream watchLogs(String path); + Stream> watchLogs(String path); + + TaskEither clearLogs(); } diff --git a/libcore b/libcore index 0480a5d3..7b367fe7 160000 --- a/libcore +++ b/libcore @@ -1 +1 @@ -Subproject commit 0480a5d3ec0571b7a0662625679a277219c51d0f +Subproject commit 7b367fe70c9ecbf0dda2b73289905565e2451745 diff --git a/pubspec.lock b/pubspec.lock index 609db473..68535a90 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -1555,7 +1555,7 @@ packages: source: hosted version: "11.10.0" watcher: - dependency: transitive + dependency: "direct main" description: name: watcher sha256: "3d2ad6751b3c16cf07c7fca317a1413b3f26530319181b37e3b9039b84fc01d8" diff --git a/pubspec.yaml b/pubspec.yaml index e460c9b9..f2ef1f46 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -10,8 +10,6 @@ dependencies: flutter: sdk: flutter cupertino_icons: ^1.0.6 - - # internationalization flutter_localizations: sdk: flutter intl: ^0.18.1 @@ -19,28 +17,18 @@ dependencies: slang_flutter: ^3.24.0 timeago: ^3.5.0 flutter_localized_locales: ^2.0.5 - - # data & serialization fpdart: ^1.1.0 freezed_annotation: ^2.4.1 json_annotation: ^4.8.1 - - # state management hooks_riverpod: ^2.4.3 flutter_hooks: ^0.20.3 riverpod_annotation: ^2.2.0 rxdart: ^0.27.7 - - # persistence drift: ^2.12.1 sqlite3_flutter_libs: ^0.5.16 shared_preferences: ^2.2.2 - - # networking dio: ^5.3.3 web_socket_channel: ^2.4.0 - - # native ffi: ^2.1.0 path_provider: ^2.1.1 flutter_local_notifications: ^15.1.1 @@ -54,13 +42,9 @@ dependencies: url_launcher: ^6.1.14 vclibs: ^0.1.0 launch_at_startup: ^0.2.2 - - # analytics sentry_flutter: ^7.10.1 sentry_dart_plugin: ^1.6.2 sentry_dio: ^7.10.1 - - # utils combine: ^0.5.6 path: ^1.8.3 loggy: ^2.0.3 @@ -73,8 +57,7 @@ dependencies: accessibility_tools: ^1.0.0 neat_periodic_task: ^2.0.1 retry: ^3.1.2 - - # widgets + watcher: ^1.1.0 go_router: ^11.1.4 flex_color_scheme: ^7.3.1 flutter_animate: ^4.2.0+1