Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 4 additions & 3 deletions synodic_client/application/bootstrap.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,12 @@
from synodic_client.protocol import extract_uri_from_args
from synodic_client.updater import initialize_velopack

# Parse --dev flag early so logging uses the right filename.
# Parse flags early so logging uses the right filename and level.
_dev_mode = '--dev' in sys.argv[1:]
_debug = '--debug' in sys.argv[1:]
set_dev_mode(_dev_mode)

configure_logging()
configure_logging(debug=_debug)
initialize_velopack()

if not _dev_mode:
Expand All @@ -35,4 +36,4 @@
# Heavy imports happen here — PySide6, porringer, etc.
from synodic_client.application.qt import application

application(uri=extract_uri_from_args(), dev_mode=_dev_mode)
application(uri=extract_uri_from_args(), dev_mode=_dev_mode, debug=_debug)
14 changes: 10 additions & 4 deletions synodic_client/application/qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@
from synodic_client.application.uri import parse_uri
from synodic_client.client import Client
from synodic_client.config import set_dev_mode
from synodic_client.logging import configure_logging
from synodic_client.logging import configure_logging, set_debug_level
from synodic_client.protocol import extract_uri_from_args
from synodic_client.resolution import (
ResolvedConfig,
Expand Down Expand Up @@ -104,7 +104,7 @@ def eventFilter(self, obj: QObject, event: QEvent) -> bool: # noqa: N802
):
geo = obj.geometry()
stack = ''.join(traceback.format_stack(limit=12))
self._diag_logger.warning(
self._diag_logger.debug(
'[DIAG] Top-level window %s: class=%s title=%r geo=(%d,%d %dx%d) visible=%s\n%s',
event.type().name,
type(obj).__qualname__,
Expand Down Expand Up @@ -149,7 +149,7 @@ def _init_app() -> QApplication:
return app


def application(*, uri: str | None = None, dev_mode: bool = False) -> None:
def application(*, uri: str | None = None, dev_mode: bool = False, debug: bool = False) -> None:
"""Application entry point.

Args:
Expand All @@ -159,13 +159,14 @@ def application(*, uri: str | None = None, dev_mode: bool = False) -> None:
log files, or single-instance locks with the user-installed
application. Velopack initialisation and protocol
registration are skipped.
debug: When ``True``, enable DEBUG-level file logging.
"""
# Activate dev-mode namespacing before anything reads config paths.
set_dev_mode(dev_mode)

# Configure logging before Velopack so install/uninstall hooks and
# first-run diagnostics are captured in the log file.
configure_logging()
configure_logging(debug=debug)
logger = logging.getLogger('synodic_client')
_install_exception_hook(logger)

Expand All @@ -180,6 +181,11 @@ def application(*, uri: str | None = None, dev_mode: bool = False) -> None:

client, porringer, config = _init_services(logger)

# Honour the persisted debug_logging preference unless the --debug
# flag already activated it.
if not debug and config.debug_logging:
set_debug_level(enabled=True)

app = _init_app()

loop = qasync.QEventLoop(app)
Expand Down
5 changes: 2 additions & 3 deletions synodic_client/application/screen/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,8 +199,7 @@ def refresh(self) -> None:
"""Schedule an asynchronous rebuild of the tool list."""
if self._refresh_in_progress:
return
caller = ''.join(traceback.format_stack(limit=4))
logger.info('[DIAG] ToolsView.refresh() called, parent_visible=%s\n%s', self.isVisible(), caller)
logger.debug('ToolsView.refresh() called (visible=%s)', self.isVisible())
asyncio.create_task(self._async_refresh())

async def _async_refresh(self) -> None:
Expand Down Expand Up @@ -1112,7 +1111,7 @@ def showEvent(self, event: QShowEvent) -> None: # noqa: N802
"""[DIAG] Log every show event with a stack trace."""
geo = self.geometry()
stack = ''.join(traceback.format_stack(limit=10))
logger.warning(
logger.debug(
'[DIAG] MainWindow.showEvent: geo=(%d,%d %dx%d) visible=%s\n%s',
geo.x(),
geo.y(),
Expand Down
32 changes: 30 additions & 2 deletions synodic_client/application/screen/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@
from synodic_client.application.screen import _format_relative_time
from synodic_client.application.screen.card import CardFrame
from synodic_client.application.theme import SETTINGS_WINDOW_MIN_SIZE, UPDATE_STATUS_CHECKING_STYLE
from synodic_client.logging import log_path
from synodic_client.logging import log_path, set_debug_level
from synodic_client.resolution import ResolvedConfig, update_user_config
from synodic_client.schema import GITHUB_REPO_URL
from synodic_client.startup import is_startup_registered, register_startup, remove_startup
Expand All @@ -54,11 +54,14 @@ class SettingsWindow(QMainWindow):
check_updates_requested = Signal()
"""Emitted when the user clicks the *Check for Updates* button."""

restart_requested = Signal()
"""Emitted when the user clicks the *Restart & Update* button."""

def showEvent(self, event: QShowEvent) -> None: # noqa: N802
"""[DIAG] Log every show event with a stack trace."""
geo = self.geometry()
stack = ''.join(traceback.format_stack(limit=10))
logger.warning(
logger.debug(
'[DIAG] SettingsWindow.showEvent: geo=(%d,%d %dx%d) visible=%s\n%s',
geo.x(),
geo.y(),
Expand Down Expand Up @@ -199,6 +202,12 @@ def _add_update_controls(self, content: QVBoxLayout) -> None:
self._update_status_label = QLabel('')
self._update_status_label.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
row.addWidget(self._update_status_label)

self._restart_btn = QPushButton('Restart \u0026 Update')
self._restart_btn.clicked.connect(self.restart_requested.emit)
self._restart_btn.hide()
row.addWidget(self._restart_btn)

row.addStretch()
content.addLayout(row)

Expand All @@ -218,6 +227,12 @@ def _build_startup_section(self) -> CardFrame:
def _build_advanced_section(self) -> CardFrame:
"""Construct the *Advanced* settings card."""
card = CardFrame('Advanced')

self._debug_logging_check = QCheckBox('Debug logging')
self._debug_logging_check.setToolTip('Write DEBUG-level messages to the log file')
self._debug_logging_check.toggled.connect(self._on_debug_logging_changed)
card.content_layout.addWidget(self._debug_logging_check)

row = QHBoxLayout()
open_log_btn = QPushButton('Open Log\u2026')
open_log_btn.clicked.connect(self._open_log)
Expand Down Expand Up @@ -254,6 +269,9 @@ def sync_from_config(self) -> None:
self._auto_apply_check.setChecked(config.auto_apply)
self._auto_start_check.setChecked(is_startup_registered())

# Debug logging
self._debug_logging_check.setChecked(config.debug_logging)

# Last client update timestamp
if config.last_client_update:
relative = _format_relative_time(config.last_client_update)
Expand All @@ -275,13 +293,18 @@ def set_update_status(self, text: str, style: str = '') -> None:
def set_checking(self) -> None:
"""Enter the *checking* state — disable button and show status."""
self._check_updates_btn.setEnabled(False)
self._restart_btn.hide()
self._update_status_label.setText('Checking\u2026')
self._update_status_label.setStyleSheet(UPDATE_STATUS_CHECKING_STYLE)

def reset_check_updates_button(self) -> None:
"""Re-enable the *Check for Updates* button after a check completes."""
self._check_updates_btn.setEnabled(True)

def show_restart_button(self) -> None:
"""Show the *Restart & Update* button."""
self._restart_btn.show()

def show(self) -> None:
"""Sync controls from config, then show the window."""
self.sync_from_config()
Expand Down Expand Up @@ -313,6 +336,7 @@ def _block_signals(self) -> Iterator[None]:
self._detect_updates_check,
self._auto_apply_check,
self._auto_start_check,
self._debug_logging_check,
self._check_updates_btn,
)
for w in widgets:
Expand Down Expand Up @@ -362,6 +386,10 @@ def _on_auto_start_changed(self, checked: bool) -> None:
remove_startup()
self.settings_changed.emit(self._config)

def _on_debug_logging_changed(self, checked: bool) -> None:
set_debug_level(enabled=checked)
self._persist(debug_logging=checked)

@staticmethod
def _open_log() -> None:
"""Open the log file in the system's default editor."""
Expand Down
13 changes: 8 additions & 5 deletions synodic_client/application/screen/tray.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from PySide6.QtGui import QAction
from PySide6.QtWidgets import (
QApplication,
QMainWindow,
QMenu,
QSystemTrayIcon,
)
Expand Down Expand Up @@ -130,13 +131,15 @@ def _show_settings(self) -> None:
"""Show the settings window."""
self._settings_window.show()

def _is_user_active(self) -> bool:
"""Return ``True`` when the user has a visible window.
@staticmethod
def _is_user_active() -> bool:
"""Return ``True`` when the user has a visible application window.

Used by the update controllers to defer automatic updates
while the user is actively interacting with the application.
Checks all top-level ``QMainWindow`` instances (main window,
settings, install previews) so that auto-apply is deferred
whenever *any* window is open.
"""
return self._window.isVisible() or self._settings_window.isVisible()
return any(w.isVisible() for w in QApplication.topLevelWidgets() if isinstance(w, QMainWindow))

def _on_settings_changed(self, config: ResolvedConfig) -> None:
"""React to a change made in the settings window."""
Expand Down
14 changes: 7 additions & 7 deletions synodic_client/application/update_controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ class UpdateController:
Optional pre-resolved configuration. ``None`` resolves from disk.
is_user_active:
Predicate returning ``True`` when the user has a visible window.
Automatic checks and auto-apply are deferred while active.
Auto-apply is deferred while active; checks still run normally.
"""

def __init__(
Expand Down Expand Up @@ -99,9 +99,10 @@ def __init__(

# Wire settings check-updates button
self._settings_window.check_updates_requested.connect(self._on_manual_check)
self._settings_window.restart_requested.connect(self._apply_update)

def set_user_active_predicate(self, predicate: Callable[[], bool]) -> None:
"""Set the predicate used to defer automatic checks when the user is active.
"""Set the predicate used to defer auto-apply when the user is active.

Args:
predicate: Returns ``True`` when the user has a visible window.
Expand Down Expand Up @@ -200,12 +201,10 @@ def _on_manual_check(self) -> None:
def _on_auto_check(self) -> None:
"""Handle automatic (periodic) check — silent.

Skipped when the user has a visible window to avoid disruptive
downloads and auto-apply restarts. The next timer tick retries.
The check always runs so the settings window can show the
latest status and the *last updated* timestamp stays current.
Auto-apply is gated separately by :meth:`_can_auto_apply`.
"""
if self._is_user_active():
logger.debug('Automatic update check deferred — user is active')
return
self._do_check(silent=True)

def _do_check(self, *, silent: bool) -> None:
Expand Down Expand Up @@ -324,6 +323,7 @@ def _on_download_finished(self, success: bool, version: str) -> None:
f'v{version} ready',
UPDATE_STATUS_UP_TO_DATE_STYLE,
)
self._settings_window.show_restart_button()

def _on_download_error(self, error: str) -> None:
"""Handle download error — show error banner."""
Expand Down
6 changes: 5 additions & 1 deletion synodic_client/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,12 @@ def main(
bool,
typer.Option('--dev', help='Run in dev mode with isolated config, logs, and instance lock.'),
] = False,
debug: Annotated[
bool,
typer.Option('--debug', help='Enable DEBUG-level file logging for this session.'),
] = False,
) -> None:
"""Launch the Synodic Client GUI application."""
from synodic_client.application.qt import application

application(uri=uri, dev_mode=dev)
application(uri=uri, dev_mode=dev, debug=debug)
35 changes: 33 additions & 2 deletions synodic_client/logging.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
"""Centralised logging configuration for the Synodic Client.

Provides a rotating file handler with eager flushing.
Provides a rotating file handler with eager flushing and runtime
log-level switching via :func:`set_debug_level`.
"""

import logging
Expand All @@ -17,6 +18,8 @@
_BACKUP_COUNT = 3
_FORMAT = '%(asctime)s [%(levelname)s] %(name)s: %(message)s'

_debug_active: bool = False


def log_path() -> Path:
"""Return the path to the application log file.
Expand All @@ -43,13 +46,18 @@ def emit(self, record: logging.LogRecord) -> None:
self.flush()


def configure_logging() -> None:
def configure_logging(*, debug: bool = False) -> None:
"""Set up application-wide logging.

Attaches a :class:`EagerRotatingFileHandler` to the ``synodic_client``
and ``porringer`` loggers and configures :func:`logging.basicConfig`
for ``INFO`` level output on *stderr*.

Args:
debug: When ``True``, set the file handler and app logger to
``DEBUG`` level immediately. Equivalent to calling
:func:`set_debug_level` after configuration.

Safe to call more than once — subsequent calls are no-ops.
"""
app_logger = logging.getLogger('synodic_client')
Expand Down Expand Up @@ -80,3 +88,26 @@ def configure_logging() -> None:
porringer_logger.setLevel(logging.DEBUG)
else:
porringer_logger.setLevel(logging.INFO)

if debug:
set_debug_level(enabled=True)


def set_debug_level(*, enabled: bool) -> None:
"""Switch the app logger and file handler between DEBUG and INFO at runtime.

Safe to call at any time. Has no effect if logging has not been
configured yet.

Args:
enabled: ``True`` for DEBUG, ``False`` for INFO.
"""
global _debug_active # noqa: PLW0603
_debug_active = enabled
level = logging.DEBUG if enabled else logging.INFO

app_logger = logging.getLogger('synodic_client')
app_logger.setLevel(level)
for h in app_logger.handlers:
if isinstance(h, EagerRotatingFileHandler):
h.setLevel(level)
2 changes: 2 additions & 0 deletions synodic_client/resolution.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,7 @@ def _resolve_from_user(user: UserConfig) -> ResolvedConfig:

auto_apply = user.auto_apply if user.auto_apply is not None else True
auto_start = user.auto_start if user.auto_start is not None else True
debug_logging = user.debug_logging if user.debug_logging is not None else False

return ResolvedConfig(
update_source=user.update_source,
Expand All @@ -125,6 +126,7 @@ def _resolve_from_user(user: UserConfig) -> ResolvedConfig:
prerelease_packages=user.prerelease_packages,
auto_apply=auto_apply,
auto_start=auto_start,
debug_logging=debug_logging,
last_client_update=user.last_client_update,
last_tool_updates=user.last_tool_updates,
)
Expand Down
5 changes: 5 additions & 0 deletions synodic_client/schema.py
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,10 @@ class UserConfig(BaseModel):
# auto-startup.
auto_start: bool | None = None

# Enable verbose DEBUG-level logging to the log file.
# None resolves to False (INFO level).
debug_logging: bool | None = None

# ISO 8601 timestamp of the last successful client self-update.
# None means no update has been recorded.
last_client_update: str | None = None
Expand Down Expand Up @@ -231,5 +235,6 @@ class ResolvedConfig:
prerelease_packages: dict[str, list[str]] | None
auto_apply: bool
auto_start: bool
debug_logging: bool
last_client_update: str | None
last_tool_updates: dict[str, str] | None
Loading