mirror of
https://github.com/LorenEteval/Furious.git
synced 2026-10-05 13:28:03 +03:00
Unify application logging
Signed-off-by: Loren Eteval <loren.eteval@proton.me>
This commit is contained in:
@@ -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():
|
||||
|
||||
@@ -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(),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
Vendored
+32
-51
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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_')
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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 = ''
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
@@ -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',
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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():
|
||||
|
||||
Reference in New Issue
Block a user