Unify application logging

Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
Loren Eteval
2026-08-12 12:20:50 +08:00
parent 84b33ce6ca
commit d7f9d49089
15 changed files with 643 additions and 200 deletions
+14 -6
View File
@@ -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():
+9 -39
View File
@@ -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(),
),
)
+2 -2
View File
@@ -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)
+1 -2
View File
@@ -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)
+32 -51
View File
@@ -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"
}
}
+6 -9
View File
@@ -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_')
+3 -2
View File
@@ -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',
+56
View File
@@ -0,0 +1,56 @@
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
#
# 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 <https://www.gnu.org/licenses/>.
"""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 = ''
+3
View File
@@ -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',
+303
View File
@@ -0,0 +1,303 @@
# Copyright (C) 2024–present Loren Eteval & contributors <loren.eteval@proton.me>
#
# 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 <https://www.gnu.org/licenses/>.
"""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)
+18
View File
@@ -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',
]
+7 -1
View File
@@ -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)
+3 -39
View File
@@ -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,
+171 -15
View File
@@ -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()
+15 -34
View File
@@ -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():