From 825800f327d2fa5d139e01d1253bb797d6ae98aa Mon Sep 17 00:00:00 2001 From: Loren Eteval Date: Sun, 9 Aug 2026 21:42:57 +0800 Subject: [PATCH] Unify application logging Signed-off-by: Loren Eteval --- Furious/Actions/Connection.py | 20 +- Furious/Application/DesktopApplication.py | 48 +--- Furious/Core/CoreProcessWorker.py | 4 +- Furious/Core/Tun2socks.py | 3 +- Furious/Externals/GenTranslation.py | 83 +++--- Furious/Frozenlib/Globals.py | 15 +- Furious/Frozenlib/__init__.py | 5 +- Furious/Models/Logging.py | 56 ++++ Furious/Models/__init__.py | 3 + Furious/Service/LogManager.py | 303 ++++++++++++++++++++++ Furious/Service/__init__.py | 18 ++ Furious/Utility/AppMainProcess.py | 8 +- Furious/Widget/ServerTableView.py | 42 +-- Furious/Window/LogWindow.py | 186 +++++++++++-- Furious/Window/MainWindow.py | 49 ++-- 15 files changed, 643 insertions(+), 200 deletions(-) create mode 100644 Furious/Models/Logging.py create mode 100644 Furious/Service/LogManager.py diff --git a/Furious/Actions/Connection.py b/Furious/Actions/Connection.py index 5249e09..3db008b 100644 --- a/Furious/Actions/Connection.py +++ b/Furious/Actions/Connection.py @@ -26,7 +26,12 @@ from Furious.Repository import * from Furious.Plugins import getPluginRegistry from Furious.Qt import * from Furious.Qt import gettext as _ -from Furious.Service import ConnectionManager, UpdateManager +from Furious.Service import ( + TUN2SOCKS_LOG_CATEGORY, + ConnectionManager, + UpdateManager, + coreLogCallback, +) from Furious.Widget.ConnectionProgressWidget import ConnectionProgressWidget from PySide6 import QtCore @@ -259,16 +264,19 @@ class ConnectAction(AppQAction): self.doConnecting() - # Clear previous log - AppLoggerWindow.Core().clear() - AppLoggerWindow.TUN_().clear() + logManager = AppLogManager() + # Retain application diagnostics while starting a fresh runtime log. + logManager.clear(runtimeOnly=True) success = self.coreManager.start( config, routing=AppSettings.get('Routing'), exitCallback=self.coreExitCallback, - msgCallbackCore=lambda line: AppLoggerWindow.Core().appendLine(line), - msgCallbackTUN_=lambda line: AppLoggerWindow.TUN_().appendLine(line), + msgCallbackCore=coreLogCallback(logManager), + msgCallbackTUN_=logManager.callback( + TUN2SOCKS_LOG_CATEGORY, + source='Tun2socks', + ), ) if self.actionQueue.empty(): diff --git a/Furious/Application/DesktopApplication.py b/Furious/Application/DesktopApplication.py index 88908eb..96cfab7 100644 --- a/Furious/Application/DesktopApplication.py +++ b/Furious/Application/DesktopApplication.py @@ -29,6 +29,7 @@ from Furious.Qt import AppStyleSheet from Furious.Qt.TextEditorTheme import configureEditorLogMetadata from Furious.Qt import gettext as _ from Furious.Repository import * +from Furious.Service import ApplicationLogHandler, LogManager from Furious.Application.TrayIcon import * from Furious.Window.LogWindow import * from Furious.Window.MainWindow import * @@ -49,10 +50,6 @@ import darkdetect logger = logging.getLogger(__name__) -registerAppSettings('LogViewerWidgetPointSizeSelf') -registerAppSettings('LogViewerWidgetPointSizeCore') -registerAppSettings('LogViewerWidgetPointSizeTun_') - class SystemTrayUnavailable(Exception): """Represent system tray unavailable.""" @@ -60,21 +57,6 @@ class SystemTrayUnavailable(Exception): pass -class AppLogHandler(logging.Handler): - """Represent app log handler.""" - - def __init__(self, emitCallback): - """Initialize the AppLogHandler.""" - super().__init__() - - self.emitCallback = emitCallback - - def emit(self, record): - """Emit the current app log handler event or message.""" - if callable(self.emitCallback): - self.emitCallback(self.format(record)) - - class ApplicationExitHelper(QApplication): """Represent application exit helper.""" @@ -198,10 +180,9 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): self.mainWindow = None self.systemTray = None - # Logging window - self.logViewerWindowSelf = None - self.logViewerWindowCore = None - self.logViewerWindowTun_ = None + # Unified logging service and presentation + self.logManager = None + self.logWindow = None # Protected storage access self._userActivatedItemIndex = None @@ -248,29 +229,18 @@ class DesktopApplication(ApplicationRunner, SingletonApplication): def configureLogging(self): """Configure logging.""" - self.logViewerWindowSelf = LogWindow( - tabTitle=_('Furious Log'), + self.logManager = LogManager(parent=self) + + self.logWindow = LogWindow( + manager=self.logManager, fontFamily=self.customFontName, - pointSizeSettingsName='LogViewerWidgetPointSizeSelf', - ) - self.logViewerWindowCore = LogWindow( - tabTitle=_('Core Log'), - fontFamily=self.customFontName, - pointSizeSettingsName='LogViewerWidgetPointSizeCore', - ) - self.logViewerWindowTun_ = LogWindow( - tabTitle=_('Tun2socks Log'), - fontFamily=self.customFontName, - pointSizeSettingsName='LogViewerWidgetPointSizeTun_', ) logging.basicConfig( format='[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s', level=logging.INFO, handlers=( - AppLogHandler( - functools.partial(self.logViewerWindowSelf.appendLine), - ), + ApplicationLogHandler(self.logManager), logging.StreamHandler(), ), ) diff --git a/Furious/Core/CoreProcessWorker.py b/Furious/Core/CoreProcessWorker.py index f74b4a4..f78fe7d 100644 --- a/Furious/Core/CoreProcessWorker.py +++ b/Furious/Core/CoreProcessWorker.py @@ -300,8 +300,8 @@ class CoreProcessWorker(CoreProcessMonitor, ABC): def __init__(self, **kwargs): """Initialize the CoreProcessWorker.""" msgCallback = kwargs.pop('msgCallback', None) - # Optimizer is AppLoggerWindow.Core window - backgroundOptimizer = kwargs.pop('backgroundOptimizer', AppLoggerWindow.Core) + # Drain output more frequently while the unified log window is visible. + backgroundOptimizer = kwargs.pop('backgroundOptimizer', AppLogWindow) super().__init__(**kwargs) diff --git a/Furious/Core/Tun2socks.py b/Furious/Core/Tun2socks.py index 43f69e9..249749e 100644 --- a/Furious/Core/Tun2socks.py +++ b/Furious/Core/Tun2socks.py @@ -59,9 +59,8 @@ class Tun2socks(CoreProcessWorker): SystemShuttingDown = 0x40010004 def __init__(self, **kwargs): - # Optimizer is AppLoggerWindow.TUN_ window """Initialize the Tun2socks.""" - backgroundOptimizer = kwargs.pop('backgroundOptimizer', AppLoggerWindow.TUN_) + backgroundOptimizer = kwargs.pop('backgroundOptimizer', AppLogWindow) super().__init__(**kwargs, backgroundOptimizer=backgroundOptimizer) diff --git a/Furious/Externals/GenTranslation.py b/Furious/Externals/GenTranslation.py index 9fe43fd..b8a7e73 100644 --- a/Furious/Externals/GenTranslation.py +++ b/Furious/Externals/GenTranslation.py @@ -826,33 +826,6 @@ TRANSLATION = { "ZH": "日志", "isReviewed": "True" }, - "Show Furious Log...": { - "source": [ - "Furious.Widget.ServerTableView", - "Furious.Window.MainWindow" - ], - "RU": "Показать журнал Furious...", - "ZH": "显示Furious日志...", - "isReviewed": "True" - }, - "Show Core Log...": { - "source": [ - "Furious.Widget.ServerTableView", - "Furious.Window.MainWindow" - ], - "RU": "Показать журнал ядра...", - "ZH": "显示内核日志...", - "isReviewed": "True" - }, - "Show Tun2socks Log...": { - "source": [ - "Furious.Widget.ServerTableView", - "Furious.Window.MainWindow" - ], - "RU": "Показать журнал Tun2socks...", - "ZH": "显示Tun2socks日志...", - "isReviewed": "True" - }, "Update Subscription (Use Current Proxy)": { "source": [ "Furious.Window.MainWindow" @@ -941,30 +914,6 @@ TRANSLATION = { "ZH": "已启动", "isReviewed": "True" }, - "Furious Log": { - "source": [ - "Furious.Application.DesktopApplication" - ], - "RU": "Лог Furious", - "ZH": "Furious日志", - "isReviewed": "True" - }, - "Core Log": { - "source": [ - "Furious.Application.DesktopApplication" - ], - "RU": "Лог ядра", - "ZH": "内核日志", - "isReviewed": "True" - }, - "Tun2socks Log": { - "source": [ - "Furious.Application.DesktopApplication" - ], - "RU": "Лог Tun2socks", - "ZH": "Tun2socks日志", - "isReviewed": "True" - }, "Connect": { "source": [ "Furious.Actions.Connection" @@ -2301,5 +2250,37 @@ TRANSLATION = { "RU": "Расширения", "ZH": "扩展功能", "isReviewed": "True" + }, + "Show Logs...": { + "source": [ + "Furious.Window.MainWindow" + ], + "RU": "Показать журналы...", + "ZH": "显示日志...", + "isReviewed": "True" + }, + "Log Type": { + "source": [ + "Furious.Window.LogWindow" + ], + "RU": "Тип журнала", + "ZH": "日志类型", + "isReviewed": "True" + }, + "Application": { + "source": [ + "Furious.Window.LogWindow" + ], + "RU": "Приложение", + "ZH": "应用程序", + "isReviewed": "True" + }, + "All Logs": { + "source": [ + "Furious.Window.LogWindow" + ], + "RU": "Все журналы", + "ZH": "全部日志", + "isReviewed": "True" } } diff --git a/Furious/Frozenlib/Globals.py b/Furious/Frozenlib/Globals.py index 137ef31..a5e6563 100644 --- a/Furious/Frozenlib/Globals.py +++ b/Furious/Frozenlib/Globals.py @@ -26,8 +26,9 @@ import functools __all__ = [ 'APP', 'AppFontName', + 'AppLogManager', + 'AppLogWindow', 'AppThreadPool', - 'AppLoggerWindow', ] APP = functools.partial(QApplication.instance) @@ -40,16 +41,12 @@ def getAppAttributes(name: str): ( AppFontName, + AppLogManager, + AppLogWindow, AppThreadPool, ) = ( functools.partial(getAppAttributes, 'customFontName'), + functools.partial(getAppAttributes, 'logManager'), + functools.partial(getAppAttributes, 'logWindow'), functools.partial(getAppAttributes, 'threadPool'), ) - - -class AppLoggerWindow: - """Present the app logger window.""" - - Self = functools.partial(getAppAttributes, 'logViewerWindowSelf') - Core = functools.partial(getAppAttributes, 'logViewerWindowCore') - TUN_ = functools.partial(getAppAttributes, 'logViewerWindowTun_') diff --git a/Furious/Frozenlib/__init__.py b/Furious/Frozenlib/__init__.py index 0087721..144303e 100644 --- a/Furious/Frozenlib/__init__.py +++ b/Furious/Frozenlib/__init__.py @@ -67,7 +67,7 @@ from .Constants import ( XRAY_ASSET_PATH_GEOSITE, ) from .Enum import AppBuiltinCommand, AppBuiltinProxyMode, AppBuiltinRouting -from .Globals import APP, AppFontName, AppLoggerWindow, AppThreadPool +from .Globals import APP, AppFontName, AppLogManager, AppLogWindow, AppThreadPool from .Mixins import Mixins from .PySide6Legacy import PySide6Legacy from .StartupOnBoot import StartupOnBoot @@ -111,7 +111,8 @@ __all__ = [ 'AppBuiltinProxyMode', 'AppBuiltinRouting', 'AppFontName', - 'AppLoggerWindow', + 'AppLogManager', + 'AppLogWindow', 'AppResources', 'AppSettings', 'AppThreadPool', diff --git a/Furious/Models/Logging.py b/Furious/Models/Logging.py new file mode 100644 index 0000000..77099e0 --- /dev/null +++ b/Furious/Models/Logging.py @@ -0,0 +1,56 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Define UI-independent records used by the unified logging service.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime + +__all__ = ['LogCategory', 'LogEntry'] + + +@dataclass(frozen=True) +class LogCategory: + """Describe one independently filterable source of log entries.""" + + id: str + displayName: str + translatable: bool = False + runtime: bool = False + + def __post_init__(self): + """Validate the stable identifier and user-facing label.""" + if not isinstance(self.id, str) or not self.id.strip(): + raise ValueError('log category ID cannot be empty') + + if not isinstance(self.displayName, str) or not self.displayName.strip(): + raise ValueError('log category display name cannot be empty') + + +@dataclass(frozen=True) +class LogEntry: + """Capture one message together with its classification metadata.""" + + message: str + timestamp: datetime + categoryId: str + categoryLabel: str + categoryTranslatable: bool = False + source: str = '' + severity: str = '' diff --git a/Furious/Models/__init__.py b/Furious/Models/__init__.py index 30a734e..701a718 100644 --- a/Furious/Models/__init__.py +++ b/Furious/Models/__init__.py @@ -26,6 +26,7 @@ from .Encoding import ( PyBase64Encoder, UJSONEncoder, ) +from .Logging import LogCategory, LogEntry from .Profile import ( ProfileMetadata, ServerProfile, @@ -38,6 +39,8 @@ __all__ = [ 'Base64Encoder', 'ConfigFactory', 'JSONEncoder', + 'LogCategory', + 'LogEntry', 'ProfileMetadata', 'PyBase64Encoder', 'Protocol', diff --git a/Furious/Service/LogManager.py b/Furious/Service/LogManager.py new file mode 100644 index 0000000..c23e72e --- /dev/null +++ b/Furious/Service/LogManager.py @@ -0,0 +1,303 @@ +# Copyright (C) 2024–present Loren Eteval & contributors +# +# This file is part of Furious. +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +"""Collect, classify, and publish logs independently from their presentation.""" + +from __future__ import annotations + +from Furious.Models.Logging import LogCategory, LogEntry + +from PySide6 import QtCore + +from datetime import datetime +from typing import Optional + +import logging +import threading + +__all__ = [ + 'ALL_LOGS_FILTER', + 'APPLICATION_LOG_CATEGORY', + 'CORE_LOG_CATEGORY', + 'TUN2SOCKS_LOG_CATEGORY', + 'ApplicationLogHandler', + 'LogManager', + 'coreLogCallback', + 'formatLogEntry', +] + +ALL_LOGS_FILTER = 'all' +APPLICATION_LOG_CATEGORY = 'application' +CORE_LOG_CATEGORY = 'core' +TUN2SOCKS_LOG_CATEGORY = 'component.tun2socks' + + +def formatLogEntry(entry: LogEntry) -> str: + """Return the producer-formatted text stored by one structured entry.""" + return entry.message + + +class LogManager(QtCore.QObject): + """Own the application-wide categorized log stream.""" + + categoryRegistered = QtCore.Signal(object) + entryAdded = QtCore.Signal(object) + entriesCleared = QtCore.Signal(object) + + def __init__(self, parent=None): + """Initialize the category registry and thread-safe entry collection.""" + super().__init__(parent) + + self._lock = threading.RLock() + self._categories: dict[str, LogCategory] = {} + self._entries: list[LogEntry] = [] + + self.registerCategory( + LogCategory( + APPLICATION_LOG_CATEGORY, + 'Application', + translatable=True, + ) + ) + self.registerCategory( + LogCategory( + CORE_LOG_CATEGORY, + 'Core', + translatable=True, + runtime=True, + ) + ) + self.registerCategory( + LogCategory( + TUN2SOCKS_LOG_CATEGORY, + 'Tun2socks', + runtime=True, + ) + ) + + def registerCategory(self, category: LogCategory) -> LogCategory: + """Register a filterable category and publish it exactly once.""" + if not isinstance(category, LogCategory): + raise TypeError('category must be a LogCategory') + + with self._lock: + existing = self._categories.get(category.id) + + if existing is not None: + if existing != category: + raise ValueError( + f'log category {category.id!r} is already registered ' + f'with different metadata' + ) + + return existing + + self._categories[category.id] = category + + self.categoryRegistered.emit(category) + + return category + + def registerComponent( + self, + identifier: str, + displayName: str, + *, + runtime: bool = True, + translatable: bool = False, + ) -> LogCategory: + """Register a component using its stable identifier and display label.""" + return self.registerCategory( + LogCategory( + identifier, + displayName, + translatable=translatable, + runtime=runtime, + ) + ) + + def category(self, categoryId: str) -> Optional[LogCategory]: + """Return a registered category by stable identifier.""" + with self._lock: + return self._categories.get(categoryId) + + def categories(self) -> tuple[LogCategory, ...]: + """Return registered categories in registration order.""" + with self._lock: + return tuple(self._categories.values()) + + def append( + self, + message, + categoryId: str = APPLICATION_LOG_CATEGORY, + *, + timestamp: Optional[datetime] = None, + source: str = '', + severity: str = '', + ) -> LogEntry: + """Append a structured entry and notify interested presenters.""" + with self._lock: + category = self._categories.get(categoryId) + + if category is None: + raise KeyError(f'unknown log category {categoryId!r}') + + if timestamp is None: + timestamp = datetime.now() + elif not isinstance(timestamp, datetime): + raise TypeError('log timestamp must be a datetime') + + entry = LogEntry( + message=str(message).rstrip('\r\n'), + timestamp=timestamp, + categoryId=category.id, + categoryLabel=category.displayName, + categoryTranslatable=category.translatable, + source=str(source) if source else '', + severity=str(severity) if severity else '', + ) + self._entries.append(entry) + + self.entryAdded.emit(entry) + + return entry + + def callback( + self, + categoryId: str, + *, + source: str = '', + severity: str = '', + ): + """Return a safe line callback for a process or another log producer.""" + + def appendLine(line): + """Append one externally produced line without affecting its producer.""" + try: + self.append( + line, + categoryId, + source=source, + severity=severity, + ) + except Exception: + # Any non-exit exceptions + + pass + + return appendLine + + def entries(self, categoryId: Optional[str] = None) -> tuple[LogEntry, ...]: + """Return an immutable snapshot, optionally filtered by category.""" + with self._lock: + if categoryId in (None, ALL_LOGS_FILTER): + return tuple(self._entries) + + return tuple( + entry for entry in self._entries if entry.categoryId == categoryId + ) + + def clear( + self, + categoryId: Optional[str] = None, + *, + runtimeOnly: bool = False, + ): + """Clear all, one category, or every transient runtime category.""" + if categoryId is not None and runtimeOnly: + raise ValueError('categoryId and runtimeOnly cannot be combined') + + with self._lock: + if runtimeOnly: + clearedCategoryIds = { + category.id + for category in self._categories.values() + if category.runtime + } + elif categoryId is None or categoryId == ALL_LOGS_FILTER: + clearedCategoryIds = None + else: + if categoryId not in self._categories: + raise KeyError(f'unknown log category {categoryId!r}') + + clearedCategoryIds = {categoryId} + + if clearedCategoryIds is None: + changed = bool(self._entries) + self._entries.clear() + else: + oldLength = len(self._entries) + self._entries = [ + entry + for entry in self._entries + if entry.categoryId not in clearedCategoryIds + ] + changed = len(self._entries) != oldLength + + if changed: + self.entriesCleared.emit( + None if clearedCategoryIds is None else frozenset(clearedCategoryIds) + ) + + def plainText( + self, + categoryId: Optional[str] = None, + ) -> str: + """Return a plain-text snapshot for exporting or crash diagnostics.""" + return '\n'.join(formatLogEntry(entry) for entry in self.entries(categoryId)) + + +class ApplicationLogHandler(logging.Handler): + """Convert standard-library log records into structured application entries.""" + + def __init__(self, manager: LogManager): + """Bind the handler to the application-wide log manager.""" + super().__init__() + + if not isinstance(manager, LogManager): + raise TypeError('manager must be a LogManager') + + self.manager = manager + + def emit(self, record: logging.LogRecord): + """Publish one logging record without coupling it to a widget.""" + try: + categoryId = getattr( + record, + 'furiousCategory', + APPLICATION_LOG_CATEGORY, + ) + + if self.manager.category(categoryId) is None: + categoryId = APPLICATION_LOG_CATEGORY + + self.manager.append( + self.format(record), + categoryId, + timestamp=datetime.fromtimestamp(record.created), + source=getattr(record, 'furiousSource', record.name), + severity=record.levelname, + ) + except Exception: + # Any non-exit exceptions + + self.handleError(record) + + +def coreLogCallback(manager: LogManager): + """Create a callback for output from the currently selected proxy core.""" + return manager.callback(CORE_LOG_CATEGORY) diff --git a/Furious/Service/__init__.py b/Furious/Service/__init__.py index c5737ef..d1ad5d6 100644 --- a/Furious/Service/__init__.py +++ b/Furious/Service/__init__.py @@ -22,6 +22,16 @@ from __future__ import annotations from .ConnectionManager import ConnectionManager from .ConnectivityManager import ConnectivityManager from .DnsResolver import DnsResolver +from .LogManager import ( + ALL_LOGS_FILTER, + APPLICATION_LOG_CATEGORY, + CORE_LOG_CATEGORY, + TUN2SOCKS_LOG_CATEGORY, + ApplicationLogHandler, + LogManager, + coreLogCallback, + formatLogEntry, +) from .SubscriptionImporter import ( SubscriptionImportResult, SubscriptionImportService, @@ -38,6 +48,13 @@ __all__ = [ 'ConnectionManager', 'ConnectivityManager', 'DnsResolver', + 'ALL_LOGS_FILTER', + 'APPLICATION_LOG_CATEGORY', + 'CORE_LOG_CATEGORY', + 'TUN2SOCKS_LOG_CATEGORY', + 'ApplicationLogHandler', + 'LogManager', + 'coreLogCallback', 'SubscriptionImportResult', 'SubscriptionImportService', 'SubscriptionSource', @@ -45,4 +62,5 @@ __all__ = [ 'UpdateManager', 'formatTrafficSpeed', 'formatTrafficUsage', + 'formatLogEntry', ] diff --git a/Furious/Utility/AppMainProcess.py b/Furious/Utility/AppMainProcess.py index 5632717..88f262f 100644 --- a/Furious/Utility/AppMainProcess.py +++ b/Furious/Utility/AppMainProcess.py @@ -99,7 +99,13 @@ class AppMainProcess(ProcessContext.Process): if APP() is None: crashLog = f'{stackLog}' else: - crashLog = f'{AppLoggerWindow.Self().plainText()}\n{stackLog}' + logManager = AppLogManager() + + crashLog = ( + f'{logManager.plainText()}\n{stackLog}' + if logManager is not None + else f'{stackLog}' + ) with open(CRASH_LOG_DIR / self.logFileName, 'w', encoding='utf-8') as file: file.write(crashLog) diff --git a/Furious/Widget/ServerTableView.py b/Furious/Widget/ServerTableView.py index 3108c49..4f0c703 100644 --- a/Furious/Widget/ServerTableView.py +++ b/Furious/Widget/ServerTableView.py @@ -35,6 +35,7 @@ from Furious.Service import ( ConnectionManager, SubscriptionImportService, SubscriptionSource, + coreLogCallback, ) from Furious.Widget.WaitingSpinner import * @@ -510,19 +511,10 @@ class TestDownloadSpeedWorker(WebGETManager): finally: self.must() - @staticmethod - def coreMsgCallback(line): - """Handle the core msg callback.""" - try: - AppLoggerWindow.Core().appendLine(line) - except Exception: - # Any non-exit exceptions - - pass - def _startKernel(self, config) -> bool: """Prepare and start a download test through its runtime factory.""" configcopy = getPluginRegistry().prepareDownloadTest(config, self.port) + if configcopy is None: self.factory.metadata.speed = 'Invalid' self.sync() @@ -536,7 +528,7 @@ class TestDownloadSpeedWorker(WebGETManager): configcopy, AppBuiltinRouting.Global.value, self.coreExitCallback, - msgCallbackCore=self.coreMsgCallback, + msgCallbackCore=coreLogCallback(AppLogManager()), deepcopy=False, proxyModeOnly=True, log=False, @@ -1462,34 +1454,6 @@ class ServerTableView( _('Advanced...'), menu=AppQMenu( self.customizeJSONConfigActionRef, - AppQSeperator(), - AppQAction( - _('Show Furious Log...'), - callback=lambda: AppLoggerWindow.Self().showMaximized(), - shortcut=QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier - | QtCore.Qt.KeyboardModifier.ShiftModifier, - QtCore.Qt.Key.Key_F, - ), - ), - AppQAction( - _('Show Core Log...'), - callback=lambda: AppLoggerWindow.Core().showMaximized(), - shortcut=QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier - | QtCore.Qt.KeyboardModifier.ShiftModifier, - QtCore.Qt.Key.Key_C, - ), - ), - AppQAction( - _('Show Tun2socks Log...'), - callback=lambda: AppLoggerWindow.TUN_().showMaximized(), - shortcut=QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier - | QtCore.Qt.KeyboardModifier.ShiftModifier, - QtCore.Qt.Key.Key_T, - ), - ), ), useActionGroup=False, checkable=False, diff --git a/Furious/Window/LogWindow.py b/Furious/Window/LogWindow.py index 4c608d4..8ca0de3 100644 --- a/Furious/Window/LogWindow.py +++ b/Furious/Window/LogWindow.py @@ -19,14 +19,48 @@ from __future__ import annotations +from Furious.Frozenlib import AppSettings, registerAppSettings from Furious.Qt import * from Furious.Qt import gettext as _ +from Furious.Service.LogManager import ( + ALL_LOGS_FILTER, + APPLICATION_LOG_CATEGORY, + CORE_LOG_CATEGORY, + LogManager, + formatLogEntry, +) from PySide6 import QtCore from PySide6.QtWidgets import * __all__ = ['LogWindow'] +registerAppSettings('LogViewerWidgetPointSize') +registerAppSettings('LogViewerSelectedCategory', default=ALL_LOGS_FILTER) + +_LEGACY_POINT_SIZE_SETTINGS = ( + 'LogViewerWidgetPointSizeSelf', + 'LogViewerWidgetPointSizeCore', + 'LogViewerWidgetPointSizeTun_', +) + + +def _migratePointSizeSettings(): + """Preserve one legacy size and remove the obsolete per-source settings.""" + settings = QtCore.QSettings() + + if settings.value('LogViewerWidgetPointSize') is None: + for name in _LEGACY_POINT_SIZE_SETTINGS: + value = settings.value(name) + + if value is not None: + settings.setValue('LogViewerWidgetPointSize', value) + + break + + for name in _LEGACY_POINT_SIZE_SETTINGS: + settings.remove(name) + class MBoxSaveError(AppQMessageBox): """Represent m box save error.""" @@ -77,28 +111,52 @@ def saveAsFile(content: str): class LogWindow(AppQMainWindow): - """Present the log viewer window.""" + """Present and filter the unified application log stream.""" def __init__(self, *args, **kwargs): """Initialize the log window.""" - tabTitle = kwargs.pop('tabTitle', '') - fontFamily = kwargs.pop('fontFamily', '') - pointSizeSettingsName = kwargs.pop('pointSizeSettingsName', '') + manager, fontFamily = ( + kwargs.pop('manager', None), + kwargs.pop('fontFamily', ''), + ) super().__init__(*args, **kwargs) + if not isinstance(manager, LogManager): + raise TypeError('manager must be a LogManager') + + self.setFocusPolicy(QtCore.Qt.FocusPolicy.StrongFocus) + + _migratePointSizeSettings() + + self.manager = manager + self._preferredFilter = str(AppSettings.get('LogViewerSelectedCategory')) + self.setWindowTitle(_('Log Viewer')) + self.filterLabel = AppQLabel(_('Log Type')) + self.filterComboBox = QComboBox() + self.filterComboBox.setMinimumWidth(180) + self.textBrowser = DraculaTextBrowser( fontFamily=fontFamily, - pointSizeSettingsName=pointSizeSettingsName, + pointSizeSettingsName='LogViewerWidgetPointSize', ) self.textBrowser.setLineWrapMode(DraculaTextBrowser.LineWrapMode.NoWrap) - self.tabWidget = AppQTabWidget() - self.tabWidget.addTab(self.textBrowser, tabTitle) + filterLayout = QHBoxLayout() + filterLayout.addWidget(self.filterLabel) + filterLayout.addWidget(self.filterComboBox) + filterLayout.addStretch() - self.setCentralWidget(self.tabWidget) + centralLayout = QVBoxLayout() + centralLayout.addLayout(filterLayout) + centralLayout.addWidget(self.textBrowser) + + centralWidget = QWidget() + centralWidget.setLayout(centralLayout) + + self.setCentralWidget(centralWidget) self._fileMenu = AppQMenu( AppQAction( @@ -162,14 +220,112 @@ class LogWindow(AppQMainWindow): self.menuBar().addMenu(self._editMenu) self.menuBar().addMenu(self._viewMenu) + self._populateFilters(self._preferredFilter) + self._refreshEntries() + + self.filterComboBox.currentIndexChanged.connect(self._filterChanged) + self.manager.categoryRegistered.connect(self._categoryRegistered) + self.manager.entryAdded.connect(self._entryAdded) + self.manager.entriesCleared.connect(self._entriesCleared) + + def _categoryText(self, category) -> str: + """Return a category's translated or literal display label.""" + if category.id == APPLICATION_LOG_CATEGORY: + return _('Application') + + return ( + _(category.displayName) if category.translatable else category.displayName + ) + + def _populateFilters(self, selectedCategoryId: str): + """Rebuild filter choices from the registered category collection.""" + self.filterComboBox.blockSignals(True) + + try: + self.filterComboBox.clear() + self.filterComboBox.addItem(_('All Logs'), ALL_LOGS_FILTER) + + for category in self.manager.categories(): + self.filterComboBox.addItem( + self._categoryText(category), + category.id, + ) + + index = self.filterComboBox.findData(selectedCategoryId) + + self.filterComboBox.setCurrentIndex(max(index, 0)) + finally: + self.filterComboBox.blockSignals(False) + + def _refreshEntries(self): + """Render the current immutable filtered-entry snapshot.""" + selectedCategoryId = self.filterComboBox.currentData() + + content = '\n'.join( + formatLogEntry(entry) for entry in self.manager.entries(selectedCategoryId) + ) + self.textBrowser.setPlainText(content) + + scrollbar = self.textBrowser.verticalScrollBar() + scrollbar.setValue(scrollbar.maximum()) + + @QtCore.Slot(int) + def _filterChanged(self, _index: int): + """Persist and apply the category selected by the user.""" + categoryId = self.filterComboBox.currentData() + + if not isinstance(categoryId, str): + categoryId = ALL_LOGS_FILTER + + self._preferredFilter = categoryId + + AppSettings.set('LogViewerSelectedCategory', categoryId) + + self._refreshEntries() + + @QtCore.Slot(object) + def _categoryRegistered(self, category): + """Add a newly registered component without rebuilding the window.""" + if self.filterComboBox.findData(category.id) == -1: + self.filterComboBox.addItem(self._categoryText(category), category.id) + + if category.id == self._preferredFilter: + self.filterComboBox.setCurrentIndex( + self.filterComboBox.findData(category.id) + ) + + @QtCore.Slot(object) + def _entryAdded(self, entry): + """Append an entry when it matches the active filter.""" + categoryId = self.filterComboBox.currentData() + + if categoryId in (ALL_LOGS_FILTER, entry.categoryId): + self.textBrowser.appendLine(formatLogEntry(entry)) + + @QtCore.Slot(object) + def _entriesCleared(self, _categoryIds): + """Refresh the presentation after the underlying collection changes.""" + self._refreshEntries() + + def showEvent(self, event): + """Focus the window itself instead of an untouched child control.""" + super().showEvent(event) + + self.setFocus(QtCore.Qt.FocusReason.OtherFocusReason) + def plainText(self) -> str: - """Return the plain text value used by the log viewer window.""" + """Return the plain text currently shown by the selected filter.""" return self.textBrowser.toPlainText() - def appendLine(self, line: str): - """Append line.""" - self.textBrowser.appendLine(line) - def clear(self): - """Remove all data from the log viewer window.""" - self.textBrowser.clear() + """Remove all entries through the unified logging service.""" + self.manager.clear() + + def retranslate(self): + """Refresh translated window and filter labels.""" + super().retranslate() + + selectedCategoryId = self.filterComboBox.currentData() + + self._populateFilters(selectedCategoryId) + self._refreshEntries() diff --git a/Furious/Window/MainWindow.py b/Furious/Window/MainWindow.py index 85c5aef..73f3ddc 100644 --- a/Furious/Window/MainWindow.py +++ b/Furious/Window/MainWindow.py @@ -407,6 +407,8 @@ class MainWindow(AppQMainWindow): """Initialize the main window.""" super().__init__(*args, **kwargs) + self.setFocusPolicy(QtCore.Qt.FocusPolicy.StrongFocus) + if SystemRuntime.isAdmin(): self.setWindowTitle(f'{_(APPLICATION_NAME)} ({_(ADMINISTRATOR_NAME)})') else: @@ -442,36 +444,6 @@ class MainWindow(AppQMainWindow): self.mainTab = AppQTabWidget() self.mainTab.addTab(self.userServersQTableWidget, _('Server')) - logActions = [ - AppQAction( - _('Show Furious Log...'), - callback=lambda: AppLoggerWindow.Self().showMaximized(), - shortcut=QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier - | QtCore.Qt.KeyboardModifier.ShiftModifier, - QtCore.Qt.Key.Key_F, - ), - ), - AppQAction( - _('Show Core Log...'), - callback=lambda: AppLoggerWindow.Core().showMaximized(), - shortcut=QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier - | QtCore.Qt.KeyboardModifier.ShiftModifier, - QtCore.Qt.Key.Key_C, - ), - ), - AppQAction( - _('Show Tun2socks Log...'), - callback=lambda: AppLoggerWindow.TUN_().showMaximized(), - shortcut=QtCore.QKeyCombination( - QtCore.Qt.KeyboardModifier.ControlModifier - | QtCore.Qt.KeyboardModifier.ShiftModifier, - QtCore.Qt.Key.Key_T, - ), - ), - ] - serverActions = [] for descriptor in pluginRegistry.protocolDescriptors(): @@ -692,10 +664,8 @@ class MainWindow(AppQMainWindow): AppQAction( _('Log'), icon=bootstrapIcon('pin-angle.svg'), - menu=AppQMenu(*logActions), - useSetMenu=False, - useActionGroup=False, checkable=False, + callback=lambda: AppLogWindow().showMaximized(), ), AppQSeperator(), AppQAction( @@ -750,7 +720,12 @@ class MainWindow(AppQMainWindow): # Menu actions logMenu = { 'name': 'Log', - 'actions': [*logActions], + 'actions': [ + AppQAction( + _('Show Logs...'), + callback=lambda: AppLogWindow().showMaximized(), + ) + ], } serverMenu = { @@ -1049,6 +1024,12 @@ class MainWindow(AppQMainWindow): f'{size.toTuple()}' ) + def showEvent(self, event): + """Focus the window itself instead of an untouched child control.""" + super().showEvent(event) + + self.setFocus(QtCore.Qt.FocusReason.OtherFocusReason) + def retranslate(self): """Refresh translated text for the app main window.""" if SystemRuntime.isAdmin():