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
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ quote-style = "single"
skip_empty = true

[tool.pyrefly]
search_path = ["synodic_client/..."]
replace-imports-with-any = ["velopack", "winreg"]

[tool.pdm.version]
source = "scm"
Expand Down
58 changes: 7 additions & 51 deletions synodic_client/application/qt.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,9 @@
import ctypes
import logging
import signal
import subprocess
import sys
import types
from collections.abc import Callable
from urllib.parse import parse_qs, urlparse

from porringer.api import API
from porringer.schema import LocalConfiguration
Expand All @@ -19,6 +17,7 @@
from synodic_client.application.screen.install import InstallPreviewWindow
from synodic_client.application.screen.screen import Screen
from synodic_client.application.screen.tray import TrayScreen
from synodic_client.application.uri import parse_uri
from synodic_client.client import Client
from synodic_client.config import GlobalConfiguration, set_dev_mode
from synodic_client.logging import configure_logging
Expand All @@ -27,27 +26,6 @@
from synodic_client.updater import initialize_velopack


def parse_uri(uri: str) -> dict[str, str | list[str]]:
"""Parse a ``synodic://`` URI into its components.

Example:
``synodic://install?manifest=https://example.com/foo.toml``
returns ``{'action': 'install', 'manifest': ['https://example.com/foo.toml']}``.

Args:
uri: A ``synodic://`` URI string.

Returns:
A dict with ``'action'`` (the host/path) and any query parameters.
"""
parsed = urlparse(uri)
result: dict[str, str | list[str]] = {
'action': parsed.netloc or parsed.path.strip('/'),
}
result.update(parse_qs(parsed.query))
return result


def _init_services(logger: logging.Logger) -> tuple[Client, API, GlobalConfiguration]:
"""Create and configure core services.

Expand Down Expand Up @@ -85,28 +63,6 @@ def _process_uri(uri: str, handler: Callable[[str], None]) -> None:
handler(manifests[0])


def _suppress_subprocess_consoles() -> None:
"""Monkey-patch ``subprocess.Popen`` to hide console windows on Windows.

When the application is built as a windowed executable (``console=False``
in PyInstaller), every ``subprocess.Popen`` call that launches a console
program (pip, pipx, uv, winget, etc.) would briefly flash a visible
console window. This patch adds ``CREATE_NO_WINDOW`` to *creationflags*
for all calls that don't already set it, suppressing those flashes.
"""
if sys.platform != 'win32':
return

_original_init = subprocess.Popen.__init__

def _patched_init(self: subprocess.Popen, *args: object, **kwargs: object) -> None: # type: ignore[override]
if 'creationflags' not in kwargs:
kwargs['creationflags'] = subprocess.CREATE_NO_WINDOW
_original_init(self, *args, **kwargs) # type: ignore[arg-type]

subprocess.Popen.__init__ = _patched_init # type: ignore[assignment]


def _install_exception_hook(logger: logging.Logger) -> None:
"""Redirect unhandled exceptions to the log file.

Expand All @@ -131,7 +87,9 @@ def _init_app() -> QApplication:
# Set the App User Model ID so Windows uses our icon on the taskbar
# instead of the generic python.exe icon.
if sys.platform == 'win32':
ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID('synodic.client') # type: ignore[union-attr]
windll = getattr(ctypes, 'windll', None)
if windll is not None:
windll.shell32.SetCurrentProcessExplicitAppUserModelID('synodic.client')

app = QApplication([])
app.setQuitOnLastWindowClosed(False)
Expand Down Expand Up @@ -164,12 +122,10 @@ def application(*, uri: str | None = None, dev_mode: bool = False) -> None:
# Activate dev-mode namespacing before anything reads config paths.
set_dev_mode(dev_mode)

# Suppress console window flashes from subprocess calls (e.g. porringer
# running pip, pipx, uv) before any subprocesses are spawned. Skipped
# in dev mode because the source-run process already has a console.
if not dev_mode:
_suppress_subprocess_consoles()
# Initialize Velopack early, before any UI
# Initialize Velopack early, before any UI.
# Console window suppression for subprocesses is handled by the
# PyInstaller runtime hook (rthook_no_console.py).
initialize_velopack()
register_protocol(sys.executable)

Expand Down
15 changes: 7 additions & 8 deletions synodic_client/application/screen/install.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@
SetupResults,
SubActionProgress,
)
from PySide6.QtCore import QObject, Qt, QTimer, Signal
from PySide6.QtCore import Qt, QThread, QTimer, Signal
from PySide6.QtGui import QFont, QKeySequence, QShortcut
from PySide6.QtWidgets import (
QApplication,
Expand Down Expand Up @@ -69,7 +69,6 @@
MUTED_STYLE,
NO_MARGINS,
)
from synodic_client.application.threading import ThreadRunner

logger = logging.getLogger(__name__)

Expand All @@ -83,7 +82,7 @@ def format_cli_command(action: SetupAction) -> str:
return action.description


class InstallWorker(QObject):
class InstallWorker(QThread):
"""Background worker that executes setup actions via porringer.

Uses the ``execute_stream`` async generator to consume progress events
Expand Down Expand Up @@ -275,7 +274,7 @@ def __init__(self, porringer: API, parent: QWidget | None = None, *, show_close:
self._preview: SetupResults | None = None
self._manifest_path: Path | None = None
self._project_directory: Path | None = None
self._runner: ThreadRunner | None = None
self._runner: QThread | None = None
self._cancellation_token: CancellationToken | None = None
self._completed_count = 0
self._action_statuses: list[str] = []
Expand Down Expand Up @@ -586,7 +585,7 @@ def _on_install(self) -> None:
worker.finished.connect(self._on_install_finished)
worker.error.connect(self._on_install_error)

self._runner = ThreadRunner(worker)
self._runner = worker
self._runner.start()

def _on_action_started(self, action: SetupAction) -> None:
Expand Down Expand Up @@ -686,7 +685,7 @@ def __init__(self, porringer: API, manifest_url: str, parent: QWidget | None = N
self._porringer = porringer
self._manifest_url = manifest_url
self._temp_dir_path: str | None = None
self._runner: ThreadRunner | None = None
self._runner: QThread | None = None

# Default project directory to the current working directory
self._project_directory: Path = Path.cwd()
Expand Down Expand Up @@ -788,7 +787,7 @@ def start(self) -> None:
preview_worker.finished.connect(self._preview_widget.on_preview_finished)
preview_worker.error.connect(self._preview_widget.on_preview_error)

self._runner = ThreadRunner(preview_worker)
self._runner = preview_worker
self._runner.start()

# --- Preview callback (intercepts to capture temp dir) ---
Expand All @@ -804,7 +803,7 @@ def _on_preview_ready(self, preview: SetupResults, manifest_path: str, temp_dir_
self._preview_widget.on_preview_ready(preview, manifest_path, temp_dir_path)


class PreviewWorker(QObject):
class PreviewWorker(QThread):
"""Background worker that downloads a manifest and performs a dry-run.

Combines two stages into a single background pipeline:
Expand Down
10 changes: 5 additions & 5 deletions synodic_client/application/screen/screen.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

from porringer.api import API
from porringer.schema import PluginInfo, PluginKind
from PySide6.QtCore import Qt, Signal
from PySide6.QtCore import Qt, QThread, Signal
from PySide6.QtGui import QStandardItem
from PySide6.QtWidgets import (
QComboBox,
Expand Down Expand Up @@ -44,7 +44,6 @@
PLUGIN_TOGGLE_STYLE,
PLUGIN_UPDATE_STYLE,
)
from synodic_client.application.threading import ThreadRunner
from synodic_client.config import GlobalConfiguration, save_config

if TYPE_CHECKING:
Expand Down Expand Up @@ -490,7 +489,7 @@ def __init__(self, porringer: API, parent: QWidget | None = None) -> None:
"""
super().__init__(parent)
self._porringer = porringer
self._runner: ThreadRunner | None = None
self._runner: QThread | None = None
self._init_ui()

def _init_ui(self) -> None:
Expand Down Expand Up @@ -548,7 +547,8 @@ def refresh(self) -> None:

if not exists:
# Grey out entries whose directory no longer exists on disk
item = self._combo.model().item(idx) # type: ignore[union-attr]
model = self._combo.model()
item = model.item(idx) if hasattr(model, 'item') else None
if isinstance(item, QStandardItem):
item.setForeground(self.palette().placeholderText())
item.setToolTip(f'{tooltip} \u2014 directory not found' if tooltip else 'Directory not found')
Expand Down Expand Up @@ -645,7 +645,7 @@ def _load_preview(self) -> None:
preview_worker.finished.connect(self._preview.on_preview_finished)
preview_worker.error.connect(self._on_preview_error)

self._runner = ThreadRunner(preview_worker)
self._runner = preview_worker
self._runner.start()

def _on_preview_error(self, message: str) -> None:
Expand Down
21 changes: 10 additions & 11 deletions synodic_client/application/screen/tray.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

from porringer.api import API
from porringer.schema import SetupParameters, SyncStrategy
from PySide6.QtCore import QObject, QTimer, Signal
from PySide6.QtCore import QThread, QTimer, Signal
from PySide6.QtGui import QAction
from PySide6.QtWidgets import (
QApplication,
Expand All @@ -27,7 +27,6 @@
from synodic_client.application.icon import app_icon
from synodic_client.application.screen.screen import MainWindow
from synodic_client.application.theme import UPDATE_SOURCE_DIALOG_MIN_WIDTH
from synodic_client.application.threading import ThreadRunner
from synodic_client.client import Client
from synodic_client.config import GlobalConfiguration
from synodic_client.logging import open_log
Expand All @@ -37,7 +36,7 @@
logger = logging.getLogger(__name__)


class UpdateCheckWorker(QObject):
class UpdateCheckWorker(QThread):
"""Worker for checking updates in a background thread."""

finished = Signal(object) # UpdateInfo
Expand All @@ -58,7 +57,7 @@ def run(self) -> None:
self.error.emit(str(e))


class UpdateDownloadWorker(QObject):
class UpdateDownloadWorker(QThread):
"""Worker for downloading updates in a background thread."""

finished = Signal(bool) # success status
Expand All @@ -84,7 +83,7 @@ def progress_callback(percentage: int) -> None:
self.error.emit(str(e))


class ToolUpdateWorker(QObject):
class ToolUpdateWorker(QThread):
"""Worker for re-syncing manifest-declared tools in a background thread."""

finished = Signal(int) # number of manifests processed
Expand Down Expand Up @@ -209,8 +208,8 @@ def __init__(
self._client = client
self._window = window
self._config = config
self._runner: ThreadRunner | None = None
self._tool_runner: ThreadRunner | None = None
self._runner: QThread | None = None
self._tool_runner: QThread | None = None
self._progress_dialog: QProgressDialog | None = None
self._pending_update_info: UpdateInfo | None = None
self._download_cancelled = False
Expand Down Expand Up @@ -429,7 +428,7 @@ def _do_check_updates(self, *, silent: bool) -> None:
worker.finished.connect(lambda result: self._on_update_check_finished(result, silent=silent))
worker.error.connect(lambda error: self._on_update_check_error(error, silent=silent))

self._runner = ThreadRunner(worker)
self._runner = worker
self._runner.start()

def _on_update_check_finished(self, result: UpdateInfo | None, *, silent: bool = False) -> None:
Expand Down Expand Up @@ -509,7 +508,7 @@ def _on_tool_update(self) -> None:
worker.finished.connect(self._on_tool_update_finished)
worker.error.connect(self._on_tool_update_error)

self._tool_runner = ThreadRunner(worker)
self._tool_runner = worker
self._tool_runner.start()

def _on_single_plugin_update(self, plugin_name: str) -> None:
Expand All @@ -525,7 +524,7 @@ def _on_single_plugin_update(self, plugin_name: str) -> None:
worker.finished.connect(self._on_tool_update_finished)
worker.error.connect(self._on_tool_update_error)

self._tool_runner = ThreadRunner(worker)
self._tool_runner = worker
self._tool_runner.start()

def _on_tool_update_finished(self, count: int) -> None:
Expand Down Expand Up @@ -570,7 +569,7 @@ def _start_download(self) -> None:
worker.progress.connect(self._on_download_progress)
worker.error.connect(self._on_download_error)

self._runner = ThreadRunner(worker)
self._runner = worker
self._runner.start()

def _on_download_cancelled(self) -> None:
Expand Down
Loading